JAVA 垃圾回收读书笔记

对象已死

  在JAVA代码运行中,会不停的创建对象,因为内存空间不是无限的,Java虚拟机必须不停的回收无用的数据空间。那么虚拟机是怎么判断对象空间是需要被回收的呢,也就是怎么样的数据算是垃圾数据呢?

引用计数法

  引用计数法是指给没一个对象中添加一个引用计数器,每当一个地方引用了该对象,就让该对象的引用数+1,当一对象的引用值变为0的时候,就表示该对象已经无法访问。垃圾回收器就可以回收这个数据空间。这种算法简单粗暴,有些语言会使用该算法来判断对象是否已经死亡。

  但是该算法有一个比较大的问题。当两个对象互相引用对方,且没有其他引用时,则此时这两个对象引用计数都是1,垃圾回收器将永远无法回收他们。如下方代码所示:

 1 public class ReferenceCounter {
 2 
 3     private ReferenceCounter reference;
 4 
 5     public ReferenceCounter getReference() {
 6         return reference;
 7     }
 8 
 9     public void setReference(ReferenceCounter reference) {
10         this.reference = reference;
11     }
12 
13     public static void main(String[] args) {
14         ReferenceCounter referenceCounterA = new ReferenceCounter();
15 
16         ReferenceCounter referenceCounterB = new ReferenceCounter();
17         referenceCounterA.reference = referenceCounterB;
18         referenceCounterB.reference = referenceCounterA;
19 
20         referenceCounterA = null;
21         referenceCounterB = null;
22     }
23 }
View Code

  因为引用计数法无法处理对象之间相互引用的问题,所以JAVA虚拟机没有使用该算法来校验对象是否可以回收。

可达性分析算法

  可达性分析算法是大部分JAVA虚拟机所采用的主流算法。该算法有一个根节点称之为GCRoots,通过一系列的根节点引用,如果通过GCRoots能够访问到的数据全部是有效数据,不可回收。反之,则为垃圾数据,等待垃圾回收器的回收。如下图所示:

  

  object1、object2、object3三个对象通过GCRoots可以访问到,因此这三个对象是不可回收的。object4,object5,object6通过GCRoots是不能访问到的,因为这几个对象是可以回收的。那么,GCRoots又是什么呢?在JAVA语言里,可以作为GCRoot的对象有这几种

  • 虚拟机栈(栈帧中的本地变量表)中应用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(NATIVE方法)引用的对象

  当一个对象对于GCRoots不可达时,并发直接被垃圾回收。会先执行该对象的fnalize()方法,此时该对象有一次自救机会,将自己关联到GCRoot上。如果对象在finalize()方法中将自己关联到GCRoots上,该对象将不会被垃圾回收器回收。但是虚拟机并不保finalize()执行完毕之后才进行垃圾回收,因此finalize()方法并不能一定自救成功。并且如果一个对象被自救过一次之后,仍旧脱离GCRoot,第二次将不再执行finalize()方法。finalize()方法运行代价高昂,不稳定性高,只是JAVA诞生之初为了让C/C++程序员接受而做出的一种妥协,有些说法说finalize()可以用来关闭外部资源,但是try{}finally{}可以执行得更好,JAVA程序员完全可以无视finalize()的用法。

方法区回收

  方法区的数据会被回收吗?(方法区即HotSpot中的永久代,JDK8已经移除,用元空间替代)

  这是一道常见的老面试题,先说这道题的答案,方法区的数据是会被回收的。方法区回收主要分为两部分:无用常量和无用的类。

  回收无用常量:假如我们在代码中生成一个常量"123456",该字符串会被储存在常量池中。当该常量已经没有被任何引用所持有,也就是代码已经无法通过引用获取到该常量的时候,这个常量就是可以被回收的。

  回收无用的类 : 回收一个无用的类相比起来就比较复杂,需要保证以下几点

  • 该类的所有实例都已经被回收
  • 加载该类的classLoad已经被回收
  • 该类对应的java.lang.Class没有被其他任何地方引用,无法在任何地方通过反射获取到该类

  满足上面三个条件的java类可以被回收,但不一定会被回收,需要通过-XnoClass参数进行控制,还可以通过-verbose:class 和 -XX:+TraceClassLoading、-XX:+TraceClassUnLoding查看类加载和卸载信息。在大量使用反射、动态代理、CGLib等byteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,保证永久代不会溢出。

 

垃圾回收算法

标记-清除算法

   标记清除算法是最基础的算法,算法分为标记-清除两个阶段。第一阶段将所有需要回收的对象进行标记,第二阶段将所有被标记的数据进行清除。该算法有两个不足:一个是效率问题,标记和清除两个阶段的效率都不高。二是空间问题,该算法在清除之后会有大量的不连续空间。大量不连续空间可能会导致JVM在分配大对象的时候,没有足够的空间。

复制算法

  复制算法是将内存空间分为大小相同的两块,每次只使用其中一块,当垃圾回收的时候将不需要回收的数据复制到另外一块内存中,清理剩余的内存。因为年轻代的数据大部分都是朝生夕死,所以该算法在很多商用虚拟机的年轻带上使用。常见的内存方式就是将年轻带分为Eden区和两个Survivor区,每次年轻带使用Eden区和一个Survivor区。当进行垃圾回收的时候,将Eden区和Survivor区中存活的对象复制到另外一个Survivor区中(如果Survivor区中的内存不足,通过分配担保原则,将存在对象放入年老代)。HotSpot中的Eden和Survivor的内存空间比例为8:1:1,可以通过-SurvivorRatio进行调整,假设将这个数据改为4,则Eden和Survivor区域的大小就分为4:1:1。

标记-整理算法

  标记-整理算法感觉更像是对标记-清楚算法的一种改良。标记整理算法在对内存进行标记完成后,将所有存活的对象往内存的一个地方进行移动,然后删除边界外的所有内存。因为年老代没有可以分配担保的内存,因此没办法使用复制算法。

 

分代收集算法

  分代收集算法就没啥好说的了,就是对年轻代和年老代使用不同的收集算法进行收集。

HotSpot的算法实现

枚举根节点

  在现在的应用中,内存越来越大,分析对象是否可以回收时,如果使用上面讲过的可达性分析算法来计算,必然会花费很多时间。而且为了保证JVM在计算时候,内存中的对象引用不是在一直变化中,必须暂停所有工作线程。这是STOP THE WORLD的一个重要原因,为了快速分析哪里内存可以回收,哪里内存不可以回收,HotSpot引入了一个OoPMap来记录。在类加载过程完成的时候,HotSpot就将对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。然后当GC发生时候,就不需要扫描整个栈,而只需要扫描OopMap就可以获取到想要的信息,完成可达性分析。

安全点

  在OopMap的协助下,HotSpot可以快速且准确的完成GCRoot枚举。但是如果为每一条指令都生成OopMap,那么将需要大量的额外空间,这个GC的空间成本将会非常高。其实,HotSpot只有在特定的位置上记录这些信息,这些位置称为安全点,也就是并非在所有地方都能停顿下来进行GC,必须停留在安全点才能进行GC,一般选择在执行时间长的地方生成安全点,如以下:

  • 循环的末尾 
  • 方法临返回前 / 调用方法的call指令后 
  • 可能抛异常的位置

对于安全点的另外一个考虑,是让GC时候,让所有的线程都跑到一个安全的地方再停下来。有两种方案:抢断式中断和主动式中断。

抢断式中断:不需要其他线程配合,当GC发生的时候,先暂停所有的线程,如果线程不在安全点上,则让线程跑到下一个安全点。(几乎没有虚拟机采用这种方式)

主动式中断:GC发生的时候,设置一个标识符,所有的线程跑到安全点的时候就去轮询这个标识符,发现GC标识符为真的时候,就将自己挂起。轮询标识符的位置和安全点的位置和创建对象需要分配内存的地方。

安全区域

  在使用SafePoint时候,可以基本实现GC的安全执行。但还有一种情况,当线程没有分配CPU时间的时候,例如线程处于Sleep或者Block状态的时候。JVM将这种一段时间内关系不会发生变化的区域称为安全区。当线程执行到安全区,先将自己标识进入Safe Region,表示已经进入了安全区域。当这段时间里发生了GC,就不用管标识自己为Safe Region状态的线程。如果线程要离开安全区域的时候,要先检查系统是否完成了根节点枚举,或者完成了整个GC。

垃圾收集器

  如上图所示,年轻代采用的垃圾收集器为Serial、ParNew、Parallel Scavenge。年老代的垃圾收集器为CMS、Serial Old、Parallel Old。G1收集器可以对年轻代和年老代共同收集。图中的连线表示可以搭配使用,例如可以年老代如果选用CMS,则年轻代可以使用serial和ParNew来进行垃圾收集。

新生代收集器

  Serial垃圾收集器:serial垃圾收集器是历史最古老的垃圾收集器,而且从名字就能知道它是通过单线程进行垃圾收集。Stop The World这个词在当时就从这里产生。虽然这个收集器很老了,但是因为它的简单高效,并且客户端的内存一般不会太大,所以在客户端还是默认的垃圾收集器。Serial收集器在新生代使用复制算法。

  ParNew收集器:ParNew跟Serial相比,唯一的区别就是将Serial的单线程变更为多线程,ParNew和Serial之间也公用很多代码。但是它确是JDK8之前新生代首选收集器,因为只有ParNew和Serial能和CMS收集器配合。在单CPU的环境中,效率不如Serial,即便双核也不能保证一定比Serilal强,但是随着核数逐渐增多,ParNew多线程的优势才逐渐体现。

  Parallel Scavenge收集器:Parallel Scavenge所用算法跟ParNew差不多,但是Parallel Scavenge所要达到的目标是实现可控制的吞吐量,吞吐量=(运行代码时间)/(运行代码时间+垃圾回收时间)。停顿时间短的虚拟机适合用于用户交互,吞吐量大的收集器适合用于服务器计算。

老年代收集器

  SerialOld垃圾收集器:Serial Old收集器是Serial的老年代版本,同样是单线程收集器,它采用的是标记整理算法。

  Parallel Old垃圾收集器:Parallel Old是Parallel Scavenge老年代版本,使用多线程的标记-整理算法。直到Parallel Old出现,Parallel Scavenge在有了合适的应用组合。在Parallel Old出现之前,Parallel Scavenge只能和SerialOld配合,整体性能被拖累,并不实用。

  CMS垃圾收集器:CMS收集器是以最短回收停顿为目标的收集器很多B/S系统的服务器上很注重响应速度,长时间的回收停顿是无法接受的,因此CMS垃圾收集器就非常符合他们的需求。CMS基于标记-清除算法,相比其他收集器稍微复杂。CMS回收过程分为4个步骤:

  • 初始标记(标记GCRoots能直接关联到的目标,STOP THE WORLD)
  • 并发标记(进行并发的GCRoot  Tracing操作,GCRoots Tracing就是可达性分析,可以跟工作线程一起运行)
  • 重新标记(修改因为并发标记时候,工作线程引起的标记变化,STOP THE WORLD
  • 并发清除(可以跟工作线程一起运行)

  因为CMS收集器能让工作线程和垃圾回收线程一起进行,所以程序响应时间受GC影响比较小。但是CMS也有三个明显缺点:1、CMS对CPU资源敏感,因为并发收集,占用了工作线程。2、CMS无法处理浮动垃圾,因为CMS在清除工作的时候跟工作线程一起并发,此时有可能产生新的垃圾。导致GC完成后,垃圾回收后,系统内存仍然不足,引起Full GC。3、CMS采用的"标记-清除"算法会导致内存不连续。

 

G1收集器(Garbage-First)

  G1垃圾收集器在JDK9中,被设置为默认垃圾收集器。G1垃圾收集器不再是以上面的分区方式来进行垃圾收集。而是在保留eden、Survivor、Tenured区的前提下,将内存划分为一个个更小Region区。G1跟踪每个区域内存的回收价值,在后台维护一个优先列表,每次根据垃圾回收允许的时间,选择回收价值最大的Region进行回收,这就是Garbage-First名字的由来。

  通过对每个小Region区进行垃圾回收,实现了化整为零的回收思路。但是如果某个Region里的对象被其他Region的对象所引用呢?其实这个问题再其他垃圾回收器中也有相同问题,但是因为G1的分区更多,所以这个问题在G1中会更加突出。为了不用在收集某个Region的时候,扫描整个堆引用,虚拟机都是采用Remembered Set来记录引用信息(年老代引用年轻带也是通过这个)。G1中每一个Region区都维护一个RemerberSet来记录,假设A区Region中的对象被B Region区对象所引用时候,就通过CardTable被相关引用信息记录在A区的Remerber Set中。

 

   Remerber Set是个hashMap格式,key指向被引用的区域,Value指向引用该对象对应的Card Table。而Card Table记录数据为0或者1,且对应某个区域的内存。因此,当GC发生时候,只需要对当前Region GC根节点枚举的范围中加入Remerber Set即可保证不对全堆扫描也不会有遗漏。

     G1收集器的运作大致可以划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

  这四个步骤跟CMS的回收大致相同,筛选回收的时候,G1对各个Region进行回收价值和回收成本进行排序,然后用户期望的GC停顿进行回收。但是SUN公司透露其实这个阶段也是可以并发的。

内存分配

   JVM的内存分配根据选择不同的垃圾回收器和不同的虚拟参数会略有不同,大致规则如下:

  

  

Tips

对象变老

  在Eden区正常发育的对象,没经历一次Minor GC,就给活下来的对象年龄+1,当年龄达到默认的15岁的时候,这些对象晋升到老年代。15岁是默认参数,可以通过+XX:MaxTenuringThreshold来进行设置。

动态年龄判定

  新对象除了常规等年龄大于年龄阈值晋升老年代,如果Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的直接进入老年代。比如Survior区中8岁的对象占了Survior一半空间以上,则8岁或者8岁以上的直接进入老年代。

空间分配担保

  在发生Minor GC之前,虚拟机先检查老年代最大的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保安全。否则,要看HandlePromotionFailure是否允许,如果不允许,则直接进行Full GC。设置HandlePromotionFailure为允许的情况下,则取之前每一次晋升到老年代大小的平均值作为参考,决定是否使用Full GC让老年代腾出更多空间。一般情况下,HandlePromotionFailure会打开,避免Full GC执行得太过频繁。

  

posted on 2018-11-22 18:04  阿姆斯特朗回旋炮  阅读(151)  评论(0编辑  收藏

导航