一.简介

最近又复习下jvm相关内容,理解下思想,吸收下前辈经验,本文jdk 1.7/1.8

二.引用计数法与可达性分析

垃圾回收,便是将已经分配出去的的,但却不再使用的内存回收回来,以便能够再次分配。在Java虚拟机的语境下,垃圾指的是死亡对象所占据的堆空间。这里便涉及了一个关键问题:如何辨别一个对象死亡。

2.1 引用计数法

给个对象添加引用计数器,每当有一个地方引用它,计数器值就加1;当引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不再被使用。

引用计数法有个重大的漏洞,无法处理循环引用的对象。

public class ReferenceCountingGC {

public Object instance = null;
private static final int _1MB = 1024*1024;
//占内存
private byte[] bigSize = new byte[2 *_1MB];

public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();

objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

System.gc();

}

public static void main(String[] args) {
testGC();
}
}

0.767: [GC (System.gc()) [PSYoungGen: 7470K->544K(38400K)] 7470K->552K(125952K), 0.0555924 secs] [Times: user=0.00 sys=0.00, real=0.06 secs]
0.823: [Full GC (System.gc()) [PSYoungGen: 544K->0K(38400K)] [ParOldGen: 8K->450K(87552K)] 552K->450K(125952K), [Metaspace: 3303K->3303K(1056768K)], 0.0058261 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 38400K, used 333K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
eden space 33280K, 1% used [0x0000000795580000,0x00000007955d34a8,0x0000000797600000)
from space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
to space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
ParOldGen total 87552K, used 450K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
object space 87552K, 0% used [0x0000000740000000,0x0000000740070920,0x0000000745580000)
Metaspace used 3310K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 365K, capacity 388K, committed 512K, reserved 1048576K

2.2 可达性分析

主流商用程序语言,主流实现中都是通过可达性分析,来判断对象是否存活的。这个算法实质在将一系列GC Roots作为初始的存活对象集合(live set),然后从该合集出发,搜索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程称之为标记(mark)。最终,未被探索到对象便是死亡,是可以回收。

GC Roots:暂时理解为由堆外指向堆内的引用,一般而言,GC Roots包括(但不限于)如下几种:


  • Java方法栈帧桢中的局部变量;
  • 以加载类的静态变量;
  • JNI handles(即一般说的Native方法);
  • 已启动且未停止的Java线程;

可达性分析可以解决引用计数不能解决的循环引用问题。举例来说,即便对象 a 和 b 相互引用,只要从 GC Roots 出发无法到达 a 或者 b,那么可达性分析便不会将它们加入存活对象合集之中。

问题:

比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。

误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。

漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。

三.stop-the-world以及安全点

传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是stop-the-world请求,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。

Java虚拟机中stop-the-world是通过安全点(safepoint)机制来实现。当Java虚拟机收到stop-the-world请求,它便会等待所有的线程都在到达安全点,才允许请求stop-the-world 的线程进行独占的工作。

当然,安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。

四.垃圾收集算法

当标记完所有存活对象时,我们便可以进行死亡对象的回收工作了,主流的基础回收方式可分三种。

4.1 清除

即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中,当需要新建对象时,内存管理模块便会从该空闲列表种寻找空闲内存,并划分给新建得对象。

有两个缺点:


  • 造成内存碎片,由于Java虚拟机得堆种对象必须是连续分布得,因此可能出现总空闲内存足够,但是无法分配得极端情况。
  • 分配效率太低,如果一块连续得内存空间那么我们可以通过指针加法来分配。而对于空闲列表,Java虚拟机则需要逐个访问列表中得项,来查找能够放入新建对象得空闲内存。

4.2 压缩

把存活得对象聚集到内存区域得起始位置,从而留下一段连续得内存空间。这种做法能够解决内存碎片问题,但是压缩性能开销太大。

4.3 复制

把内存区域分为二等分,分别用两个指针from和to来维护,并且只是用from指针指向内存区域来分配。当发生垃圾回收时,便把存货得对象复制到to指针指向得内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。

4.4 小结

Java虚拟机中垃圾回收器采用可达性分析来探索所有存活对象,它从一系列GC Roots出发,边标记边探索所有被引用的对象。

为了防止在标记过程中堆栈的状态发生改变,Java虚拟机采取安全点机制来实现Stop-the-world操作,暂停其他非垃圾回收线程。

回收死亡对象的内存共有三种方式:会造成内存碎片的清除,性能开销大的压缩,以及堆使用的效率低下的复制。

五.堆划分

Java虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区。

默认情况,Java虚拟机采取的是一种动态分配策略(对应Java虚拟机参数-XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survior区的比例。

当然,你也可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。

通常来说,当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。

创建对象时,需要在堆上申请指定大小的内存,如果同时有大量线程申请内存的话,可以通过锁机制或者指针碰撞的方式确保不会申请到同一块内存,在JVM运行中,内存分配是一个极其频繁的动作,这种方式势必会降低性能。

TLAB全称ThreadLocalAllocBuffer,是线程的一块私有内存,如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用,这个申请动作还是需要原子操作的。

TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销。

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。

当 Eden 区的空间耗尽了怎么办?这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。

前面提到,新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。

当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。

六.卡表

HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关

参考:

《深入拆解Java虚拟机》

《深入理解Java虚拟机》