除了 C,C++等还在维护指针以及应用程序管理内存以外,Java 或者 Dotnet 或者现在任何其他的某种语言,都不约而同的采用了垃圾回收的自动内存管理方式,这给我们带来的方便的同时,也带来的一大堆的问题。失去的对内存使用的控制权,不再害怕内存泄露,等等这些原因,导致了对象这个东西被滥用,以致一个小小的功能,动辄就需要数百M的内存,另一方面,虚拟机则疲于进行垃圾回收,迫使程序的性能直线下降。

 

控制内存的使用,包括两个方面的内容,第一,控制内存的使用量,尽量节约内存。第二,控制垃圾回收,尽量少的进行垃圾回收以及让垃圾回收能够尽量快的完成。这里罗列一些曾经碰到过的问题以及解决方法。

 

== 一些基本概念 ==

控制内存最基本的一点,是要搞清楚我们所使用的对象等等,到底需要多少内存?这个有人做过测量,请参见 http://andyao.javaeye.com/blog/146124 这个是英文的,当然 google 一下也有很多翻译的版本。大致归纳一下该文章得到事实,就是每个 Java 对象至少需要 8 字节空间,此外任何内存块都是以 8 字节为对齐单位的。


另外,在后面的说明中,我们大多使用到 { int id, string name } 这样一个结构来举例。

 

== List ==

向列表中追加元素是如此的方便,以至于我们往往忘记了它对内存需求的那一面,特别在泛型被广泛的使用以后。在 Java 语言中,没有值类型这种概念,泛型出来以后,为了能够在原生类型上方便的使用泛型,编译器自动的为我们处理了对于原生类型的转换封装,以至于我们以为自己不过是在维护一个可增长的数组。


然而事实很残酷,Java中我们最常用的ArrayList实现,内部其实是一个 Object[],也就是说,如果在一个 ArrayList<Integer> 中保存 100万 个值的话,将至少需要 20 M空间,其中 16M 用来保存这 100W 个对象,4M 用来在 object[] 中保存对这 100W 对象的引用。 而同样的 int[] 数组,只需要 4M 空间。


ArrayList<Integer> 除了更多内存的需求之外,还存在一个额外的问题,那就是垃圾收集时要处理这 100W 个对象,检查他们是否需要收集。

实际上对于原生类型,使用简单的数组,加上一个 size 标志,需要扩展时通过 Arrays.copyOf 方法可以很容易的扩展大小,而且,也有很多开源的原生类型 List 实现可使用。

 

== 列存储 ==

原生类型我们可以用数组来存储,但是对于复杂的类型呢?比如包括 { int ID, string Name } 在这样一个结构。问题依然是,如果用 ArrayList<T> 来存储 100W 个这种对象,同样会对垃圾收集阶段造成压力。解决方案来源于 Basic 等一些没有对象类型的语言,他们通常使用两个数组来存储这种结构,{ int[] ID, string[] Name },显然,现在虚拟机只需要管理两个对象即可了。同样,我们可以用三个,四个数组来表示更多字段的结构。

 

但是这样存储使用起来方便吗?当然,既然我们使用的是一个面向对象的语言,那么将这样一个结构封装起来:

 

class T {

   int[] ID;

   string[] name;


   public int getId();

   public string getName();


   public int size();

   public boolean moveToFirst();

   public boolean moveToNext();

}

通过提供 moveToXXXX 这种方法,提供游标式的访问,使用起来和普通对象并没有太大分别。


更进一步,通过实现 Iteretor Iterable 等接口,提供枚举服务,使用 for each 循环来遍历集合内容,则和使用普通对象没有任何区别了。

 

== 瞬态对象 ==

前面提到的遍历,同样存在一个问题,根据接口要求,每个 Next 方法 需要返回一个对象,这样完全遍历下来,同样需要创建大量的对象,但一般情况下,这些对象在遍历完成后就已经不再是使用了。因此,有必要对这种情况进行处理,以防止大量的临时对象导致频繁的垃圾收集以及对垃圾收集带来的负面影响。


一种通常采用的方法是采用一种称为“瞬态对象”的东东作为 Next 方法的返回值,瞬态对象是这么一种对象,它的内容将在以后被提供者进行修改,因此不可认为该对象的内容是稳定的,内容在什么时间内有效,由内容提供者提供协议说明。例如,这里 Next 方法声明“本方法返回的是一个瞬态对象,在下一次调用 Next 方法之后,该对象的内容将被改变。”


一般瞬态对象同时会提供 clone 方法,将瞬态对象复制为一个稳定的通常对象,消费者根据需要,在必要时通过调用 clone 方法来获得稳定的对象引用。

采用瞬态对象提供遍历服务的一般写法,以及消费者的策略如下:


class Iterator {
   private T obj = new T();
   ...
   public T next() {
      ...
      obj.setId(id);
      obj.setName(name);
      return obj;
   }
}

class Consume {
   T function () { // 查找 Id 最小的项
      T item;
      for (T t : x.getIterator() ) {
         if ( t.id < minId) {
            minId = t.id;
            item = t.clone();
         } 
      }
      return item;
   }
}


== 大小估算 ==

即使我们已经掌握了一些基本的使用数组来有效降低内存需求的方法,有时候一个不合适的初始大小或者增长策略,也可能带来不必要的内存需求。过小的初始大小或增长将很快导致数组耗尽并要求重新分配空间,这等于原来分配的空间被浪费并增加了一个需要垃圾处理的对象,同时需要一个将数据复制到新空间的拷贝操作。而过大的初始大小或增长,显然将导致所分配的空间中很多没有用到,白白浪费了。

 

很多时候,如果能够估算出最终大致需要多大空间,通过对所需空间进行简单的估算,就可以解决这样的问题。


例如,要将一个文件加载到内存之中,文件是一个简单的文本文件,每一行是一个整数。在读取之前,我们可以得到文件大小,在读取了10或者100或者1000行以后,我们可以得到已经读取了多少字节,根据这两个值的比例,大致即可估算出剩余的内容大致还有多少行,根据这个估算来分配最终的数组大小,那么在一直处理到文件结束,可能都不需要再进行空间重新分配,而且也不会留下太多的剩余。

 

== Map ==

一般情况下需要进行查找的时候,我们往往使用 Map 的具体实现 HashMap,这个东西同样是个内存杀手。简单的说,HashMap 的内部实现,是使用一个 Entry<k,v>[] 数组来保存所有的元素,因此对于添加到Map中的每一项,都需要创建一个对应的 Entry 对象,这个导致每个元素额外的 24 字节空间,以及更重要的是 100W 个对象对垃圾回收的压力。


在实际应用中,其实往往我们在 Map 中的 Key 本身就是元素的某个字段,例如要根据 name 来查找 id,那么name就是key。在这种情况下,一个简单的排序数组就可以取代 Map, 通过建立 T 的比较器,那么可以对 T[] 进行排序,以后查找时通过 Arrays.binarySearch 方法,同样可根据 name 检索到对应的对象,对分查找虽然比哈希查找慢一点,但测试表明,对于 100W 元素进行 100W 次查找,总时间还是可以维持在几十至几百秒这个级别,性能上完全是可以接受的。


通过 T[] 数组的方式避免了 HashMap 本身额外的 100W 个Entry对象,但是依然需要保存本身的 100W 个对象,因此必要时可能需要将 T[] 数组进一步转换为列存储方式,通过 {int[] id, string[] name} 这种方式来存储,将进一步提高内存的利用率。这样做将稍微复杂一点,必须在该类中实现排序算法,主要是排序过程中交换元素时,id, name 数组的对应项都需要被交换。不过排序算法本身在 Arrays 类中已经提供,只需要将代码拷贝过来稍作修改即可,相对节省 80% 的内存空间而言,很多时候这种复杂度是完全可以接受的,并且其可靠性也可以得到保证。

 

== byte[] ==

字符串是内存消耗的一个不可忽视的原因,大部分垃圾回收机制的语言中字符串都被设计为只读对象,这样做有很多好处,但同时带来的问题是对字符串操作将产生大量的临时对象。很多情况下我们会使用StringBuilder,可避免大多数的字符串拼接操作时带来的问题,但是仍然有些情况不可避免,例如从文件中读取字符串,拆分等处理。


其实很多这种情况下我们并没有必要一定要使用字符串,例如文件处理中对CSV文件的预处理,一些原始的CSV文件中,可能以逗号作为分隔符,也可能以制表符作为分隔符,还有用竖线作为分隔符的,等等,一些不规范的文件,可能还存在某些行的末尾省略掉了空值列,从而不同的行列数不同,为了后续处理的方便,往往要将这样的文件通过预处理转换为标准规范的格式,如统一分隔符为制表键,补齐缺少的列等。在这样一些情况下,byte[] 就完全可以提供很好的支持,由于 byte[] 的能够被重复使用,因此一个字节数组可以从头用到尾,无需为每一行创建一个临时字符串对象。

 

其实 StringBuilder 本身的工作原理也无非如此,理论上继承并扩展它既可以实现以上我们需要的功能。然而非常遗憾的是 StringBuilder 被标识为 fanal 无非通过继承扩展它的能力,其内部缓冲区 char[] 也没有办法获取,因此我们不得不复制 StringBuilder 的整个代码,在此基础上添加对内部缓冲区 char[] 的访问、内容直接IO操作等函数,就可以得到一个具备了 StringBuider 全部功能的对象了。这样做可以在代码简洁和内存消耗上取得一个完美的结合。

 

== ByteBuffer ==

除了列存储以外,使用 ByteBuffer 来存储大量的对象也是一种常见的方法。很奇怪ByteBuffer被放到了 nio 包中,以至于很少有人了解它。简单的说,ByteBuffer 提供了一系列对 byte[] 进行读写的方法,getLong, putLong, getFloat, putFloat 等等,通过这个东西,我们几乎可以完全回到C的那个年代,在 Java 之中实现类似 struct, union 这种结构。


结合前面提到的游标式访问,以及基于瞬态对象的遍历方法,将一个 ByteBuffer 包装为一个 List<T> 真是再简单不过了,当然这样由于每次 get set 操作都要实际将结果返回到 ByteBuffer 中,因此效率上可能会有那么一点降低。

 

class T {
   private ByteBuffer b;
   private int pos;

   public int getId() {
      return b.getInt(pos);
   }
   public void setId(int value) {
      b.putInt(pos, value);
   }

   public float getSomeOther() {
      return b.getFloat(pos + 4);
   }
   public void setSomeOther(float value) {
      b.setFloat(pos + 4, value);
   }
}

 

== 内存映射文件 ==

这个算是比较高级的话题了,如果要管理的数据总量太大,例如上千万甚至几十亿,完全加载到内存中几乎是不可能的,而对这些数据的访问,那么通过内存映射文件来管理这些数据将是非常简单和合算的,创建一个内存映射非常的简单,首先假设要管理的数据已经在磁盘的某个文件之中,那么通过 RandomAccessFile 打开这个文件,并调用 其 getChannel().map() 方法,即可得到一个内存映射文件,此后的使用,就和 ByteBuffer 没什么区别了。

 

比起自己写代码加载数据以及写回磁盘的方式而言,内存映射文件有很多好处,JRE的具体实现中,内存映射一般会映射到操作系统的内存映射API,因此操作系统将控制文件内容何时加载,加载那些内容等事项,操作系统会在需要时才文件内容加载到内存中,并在适当的时候写回磁盘,完全无需我们去考虑缓存等等问题。另外,内存映射文件的内存空间通常是可跨进程共享的,这在多个进程需要同时访问同一个大的数据文件时非常有用,首先共享内存使得同样的内容不会被多个进程多次加载,降低了内存使用率,另外,一个进程修改的内容能立即被其他进程读取到,进程间的数据始终是一致的。


当然内存映射也有一些限制,首先对于不大的数据量,它的性能反而不如IO流处理,其次,映射文件的大小不可改变。因此更适合一次性分配一个几百M至几G空间的场景。