降低Java垃圾回收开销的5条建议


  保持GC低开销的窍门有哪些?

 

  随着一再拖延而即将发布的Java9,G1(“GarbageFirst”)垃圾回收器将被成为HotSpot虚拟机默认的垃圾回收器。从serial垃圾回收器到CMS收集器,JVM见证了许多GC实现,而G1将成为其下一代垃圾回收器。

 

  随着垃圾收集器的发展,每一代GC与其上一代相比,都带来了巨大的进步和改善。parallelGC与serialGC相比,它让垃圾收集器以多线程的方式工作,充分利用了多核计算机的计算能力。CMS(“ConcurrentMark-Sweep”)收集器与parallelGC相比,它将回收过程分成了多个阶段,使得应用线程正在运行的时候,收集工作可以并发地完成,大大改善了频繁执行“stop-the-world”的情况。G1对于拥有大量堆内存的JVM表现出更好的性能,并且具有更好的可预测和统一的暂停过程。

 

  Tip#1:预测集合的容量

 

  所有标准的Java集合,包括定制和扩展的实现(比如Trove和Google的Guava),底层都使用了数组(原生数据类型或者基于对象的类型)。因为数组一旦被分配,其大小就不可变,因此添加元素到集合时,大多数情况下都会导致需要重新申请一个新的大容量数组替换老的数组(指集合底层实现使用的数组)。

 

  即使没有提供集合初始化的大小,大多数集合的实现都尽量优化重新分配数组的处理并且将其开销平摊到zui低。不过,在构造集合的时候就提供大小可以得到很好的效果。

 

  让我们将下面的代码作为一个简单的例子分析一下:

 

  publicstaticListreverse(List<?extendsT>list){

 

  Listresult=newArrayList();

 

  for(inti=list.size()-1;i>=0;i--){

 

  result.add(list.get(i));

 

  }

 

  returnresult;

 

  }

 

  Thismethodallocatesanewarray,thenfillsitupwithitemsfromanotherlist,onlyinreverseorder.这个方法分配了一个新的数组,然后用另一个list中元素对该数组进行填充,只是元素的数序发生了变化。

 

  这个处理方式可能会付出惨重的性能代价,其优化的点在添加元素到新的list中这行代码。随着每一次添加元素,list都需要确保其底层数组拥有足够的位置来容纳新的元素。如果有空闲的位置,那么只是简单地将新元素存储到下一个空闲的槽位。如果没有的话,将分配一个新的底层数组,拷贝旧的数组内容到新的数组中,然后添加新的元素。这将导致多次分配数组,那些剩余的旧数组会被GC所回收。

 

  我们可以通过在构造集合时让其底层的数组知道它将存储多少元素,从而避免这些多余的分配

 

  publicstaticListreverse(List<?extendsT>list){

 

  Listresult=newArrayList(list.size());

 

  for(inti=list.size()-1;i>=0;i--){

 

  result.add(list.get(i));

 

  }

 

  returnresult;

 

  }

 

  上面的代码通过ArrayList的构造器指定足够大的空间来存储list.size()个元素,在初始化时完成分配的执行,这意味着List在迭代的过程中无需再次分配内存。

 

  Guava的集合类则更进一步,允许初始化集合时明确指定期望元素的个数或者指定一个预测值。

 

  Listresult=Lists.newArrayListWithCapacity(list.size());

 

  Listresult=Lists.newArrayListWithExpectedSize(list.size());

 

  上面的代码中,前者用于我们已经准确地知道集合将要存储多少元素,而后者的分配方式考虑了错误预估的情况。

 

  Tip#2:直接处理数据流

 

  当处理数据流时,比如从一个文件读取数据或者从网络中下载数据,下面的代码是非常常见的:

 

  byte[]fileData=readFileToByteArray(newFile("myfile.txt"));

 

  所产生的字节数组可能被解析XML文档、JSON对象或者协议缓冲消息,以及一些常见的可选项。

 

  当处理大文件或者文件的大小无法预测时,上面的做法很是不明智的,因为当JVM无法分配一个缓冲区来处理真正文件时,就会导致OutOfMemeoryErrors。

 

  即使数据的大小是可管理的,当到垃圾回收时,使用上面的模式依然会造成巨大的开销,因为它在堆中分配了一块非常大的区域来存储文件数据。

 

  一种更加好的处理方式是使用合适的InputStream(比如在这个例子中使用FileInputStream)直接传递给解析器,不再一次性将整个文件读取到一个字节数组中。所有主流的开源库都提供相应的API来直接接受一个输入流进行处理,比如:

 

  FileInputStreamfis=newFileInputStream(fileName);

 

  MyProtoBufMessagemsg=MyProtoBufMessage.parseFrom(fis);

 

  Tip#3:使用不可变的对象

 

  不变性有太多的好处。甚至不用我赘述什么。然而,有一个优点会对垃圾回收产生影响,应该关注一下。

 

  一个不可变对象的属性在对象被创建后就不能被修改(在这里的例子使用的是引用数据类型的属性),比如:

 

  publicclassObjectPair{

 

  privatefinalObjectfirst;

 

  privatefinalObjectsecond;

 

  publicObjectPair(Objectfirst,Objectsecond){

 

  this.first=first;

 

  this.second=second;

 

  }

 

  publicObjectgetFirst(){

 

  returnfirst;

 

  }

 

  publicObjectgetSecond(){

 

  returnsecond;

 

  }

 

  }

 

  将上面的类实例化后会产生一个不可变对象—它的所有属性用final修饰,构造完成后就不能改变了。

 

  不可变性意味着所有被一个不可变容器所引用的对象,在容器构造完成前对象就已经被创建。就GC而言:这个容器年轻程度至少和其所持有的年轻的引用一样。这意味着当在年轻代执行垃圾回收的过程中,GC因为不可变对象处于老年代而跳过它们,直到确定这些不可变对象在老年代中不被任何对象所引用时,才完成对它们的回收。

 

  更少的扫描对象意味着对内存页更少的扫描,越少的扫描内存页就意味着更短的GC生命周期,也意味着更短的GC暂停和更好的总吞吐量。

 

  Tip#4:小心字符串拼接

 

  字符串可能是在所有基于JVM应用程序中常用的非原生数据结构。然而,由于其隐式地开销负担和简便的使用,非常容易成为占用大量内存的罪归祸首。

 

  这个问题很明显不在于字符串字面值,而是在运行时分配内存初始化产生的。让我们快速看一下动态构建字符串的例子:

 

  publicstaticStringtoString(T[]array){

 

  Stringresult="[";

 

  for(inti=0;i<array.length;i++){

 

  result+=(array[i]==array?"this":array[i]);

 

  if(i<array.length-1){

 

  result+=",";

 

  }

 

  }

 

  result+="]";

 

  returnresult;

 

  }

 

  这是个看似不错的方法,接收一个字符数组然后返回一个字符串。但是这对于对象内存分配却是灾难性的。

 

  很难看清这语法糖的背后,但是幕后的实际情况是这样的:

 

  publicstaticStringtoString(T[]array){

 

  Stringresult="[";

 

  for(inti=0;i<array.length;i++){

 

  StringBuildersb1=newStringBuilder(result);

 

  sb1.append(array[i]==array?"this":array[i]);

 

  result=sb1.toString();

 

  if(i<array.length-1){

 

  StringBuildersb2=newStringBuilder(result);

 

  sb2.append(",");

 

  result=sb2.toString();

 

  }

 

  }

 

  StringBuildersb3=newStringBuilder(result);

 

  sb3.append("]");

 

  result=sb3.toString();

 

  returnresult;

 

  }

 

  字符串是不可变的,这意味着每发生一次拼接时,它们本身不会被修改,而是依次分配新的字符串。此外,编译器使用了标准的StringBuilder类来执行这些拼接操作。这就会有问题了,因为每一次迭代,既隐式地分配了一个临时字符串,又隐式分配了一个临时的StringBuilder对象来帮助构建结果。

 

  更好的方式是避免上面的情况,使用StringBuilder和直接的追加,以取代本地拼接操作符(“+”)。下面是一个例子:

 

  publicstaticStringtoString(T[]array){

 

  StringBuildersb=newStringBuilder("[");

 

  for(inti=0;i<array.length;i++){

 

  sb.append(array[i]==array?"this":array[i]);

 

  if(i<array.length-1){

 

  sb.append(",");

 

  }

 

  }

 

  sb.append("]");

 

  returnsb.toString();

 

  }

 

  这里,我们只在方法开始的时候分配了仅有的一个StringBuilder。至此,所有的字符串和list中的元素都被追加到单独的一个StringBuilder中。使用toString()方法一次性将其转成成字符串返回。

 

  Tip#5:使用特定的原生类型的集合

 

  Java标准的集合库简单且支持泛型,允许在使用集合时对类型进行半静态地绑定。比如想要创建一个只存放字符串的Set或者存储Map<Pair,List>这样的map,这种处理方式是非常棒的。

 

  真正的问题源于当我们想要使用一个list存储int类型,或者一个map存储double类型作为value。因为泛型不支持原生数据类型,因此另外的一种选择是使用包装类型来进行替换,这里我们使用List。

 

  这种处理方式是非常浪费的,因为一个Integer是一个完全的对象,一个对象的头部占用12个字节以及其内部的所维护的int属性,每个Integer对象总共占用16个字节。这比起存储相同个数的int类型的list而言,其消耗的空间是它的四倍!比这个更加严重的问题在于,事实上因为Integer是真正的对象实例,因此它需要垃圾收集阶段被垃圾收集器所考虑是否要回收。

 

  为了处理这个问题,我们在Takipi中使用非常棒的Trove集合库。Trove摒弃了部分泛型的特定来支持特定的使用内存更快速的原生类型的集合。比如,我们使用非常消耗性能的Map<Integer,Double>,在Trove中有另一种特别的选择方案,其形式为TIntDoubleMap

 

  TIntDoubleMapmap=newTIntDoubleHashMap();

 

  map.put(5,7.0);

 

  map.put(-1,9.999);

 

  ...

 

  Trove的底层实现使用了原生类型的数组,所以当操作集合的时候不会发生元素的装箱(int->Integer)或者拆箱(Integer->int),没有存储对象,因为底层使用原生数据类型存储。

 

  最后

 

  随着垃圾收集器持续的改进,以及运行时的优化和JIT编译器也变得越来越智能。我们作为开发者将会发现越来越少地考虑如何编写GC友好的代码。然而,就目前阶段,不论G1如何改进,我们仍然有很多可以做的事来帮JVM提升性能。

 

  

 

上一篇:程序员你为什么迷茫?
下一篇:关于社招和Java程序员聊聊

开班信息