JVM学习(三):垃圾回收算法

局部性原理和分代回收思想

大学学习操作系统或者计算机组成原理的时候都提到一个重要概念,叫局部性原理。

局部性原理是指CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。

后来发现,这个原理说的存储器不只是高速缓存(Cache),访问内存(RAM)、磁盘(ROM)时都有局部性。其实在项目中使用redis、memcache这样的缓存也体现了局部性原理的重要性。

在Java虚拟机的堆空间中,活跃的对象往往是小部分,大多数对象创建若干次后就会被回收,这就让Java虚拟机引入了分代回收的算法。我不清楚Sun公司的研究人员是不是受到了局部性原理的启发,不过我确实是通过局部性原理理解了分代思想。

分代回收就是将堆空间分为新生代和老年代。新生代用来存储新创建的对象。当对象存活时间很长时将其移动到老年代。根据新生代和老年代的特性,Java虚拟机可以采用不同的回收算法。

新生代的对象大多数存活时间很短,因此可以采用耗时短的回收算法,新生代触发的GC一般叫做Minor GC。

老年代的对象往往可以长时间存活,因此老年代的垃圾回收频率不高,往往是堆空间用完的时候才会针对老年代进行垃圾回收。但老年代的回收一般是进行全堆扫描,找出能被回收的空间,这会耗费大量时间。现代的垃圾回收器会用各种手段避免进行全堆扫描。老年代的GC叫做 Full GC。

本篇我们重点关注的是Minor GC,也就是针对新生代的垃圾回收。

新生代的内存划分和回收机制

上一篇提到了复制算法,他的思想是把堆空间分为1:1两部分,并维持两个from和to指针。由于Java虚拟机中活跃的对象是小部分,因此实际上复制算法并不需要保持1:1的比例(Sun公司给出的理论上是98%的对象都是用几次就回收了的)。假定按照我们预测的,新生代的大部分对象会死亡,那么使用复制算法仅需要复制少量的数据,算法效果也会很好。因此新生代又被划分为 Eden区和两个大小相同的 Survivor区。

Survivor区的大小默认是自动调节的,也可以手动调节。需要注意的是,Survivor区分配的内存越多,堆空间的使用效率越低。

image.png

我们使用new指令时,new指令会在Eden区中划分出一块作为存储对象的内存。如果new新对象时Eden 区满了,这时候会触发一次Minor GC。Minor GC存活下来的对象会移动到Survivor区。Java虚拟机会记录Survivor区中的对象被复制了多少次。

跟上篇说到的复制算法一样,to指针指向一个空的Survivor区。进行Minor GC时,Eden区和from中的存活对象会被复制到to指向的区域中,然后交换from和to指针。这样当下次有Minor GC的时候保证to中的内容是空的。

什么时机晋升老年代

有两种情况会让新生代的对象晋升到老年代。第一种是对象复制次数达到设定值。如果一个对象复制次数为15(默认为15,可使用 -XX:+MaxTenuringThreshold修改),则这个对象会被晋升到老年代。第二种是Survivor区占用超过50%(可使用 -XX:TargetSurvivorRatio修改)时,虚拟机会将复制次数较高的对象复制到老年代。

Minor GC如何避免全堆扫描

我们希望Minor GC时只扫描新生代的GC Roots,然而老年代对象有可能引用了新生代对象。这种情况我们无法预期,所以Minor GC的时候必须要考虑老年代对新生代对象的引用,把老年代对新生代的引用加入GC Roots里面。你会发现,新生代的GC要扫描老年代,这岂不是跟全堆扫描一样了吗?

Hotspot虚拟机使用了卡表(Card Table)技术去解决这个问题。具体操作是Java虚拟机把整个堆分成一个个512字节的卡,并且维护一张表用来存储每张卡的标识位。这个标识位代表这张卡是否包含对新生代对象的引用。如果存在就认为这张卡是脏的。

这样在Minor GC的时候就可以不进行全堆扫描,而是从卡表中寻找脏卡,并将脏卡中的对象加入到GC Roots中。脏卡扫描结束后则清空标识位。

写屏障(write barrier)

在解释执行器中,Java虚拟机需要截获每个可能更新引用的操作,并把对应位置的标识位标记为脏。这样来保证每个可能指向新生代对象的卡都被标记到。
但是在即时编译器生成的机器码中,这块代码并不在Java虚拟机管理之下。因此这部分代码需要插入额外的逻辑,这就是所谓的写屏障。
写屏障不会判断是否指向了新生代对象,而是把这块区域认为已经指向了新生代。这样就简化了指令,变为一个简单的移位操作。写屏障虽然带来了性能上的开销,但是它加大了Minor GC的吞吐率。因此这些代价还是值得的。

虚共享(false sharing)

假设CPU缓存行大小为64字节,由于一个卡表项占1个字节,这代表一共有64张卡。HotSpot每个卡页为512字节,那么一个缓存行将对应64个卡页一共64*512=32KB。
如果不同线程对对象引用的更新操作,恰好位于同一个32KB区域内,这将导致同时更新卡表的同一个缓存行,从而造成缓存行的写回、无效化或者同步操作,间接影响程序性能。
Hotspot引入了新的参数-XX:+UseCondCardMark,来减少写卡表的操作。在执行写屏障之前,先简单的做一下判断。如果卡页已被标识过,则不再进行标识。伪代码如下:

if (CARD_TABLE [this address >> 9] != 0)
  CARD_TABLE [this address >> 9] = 0;

总结

总结一下,本篇从局部性原理出发引出了 Java 虚拟机分代回收的思想。分代回收是指,堆内存分为新生代和老年代,并采用不同的垃圾回收算法。

其中把新生代分为 Eden区和两个大小相同的 Survivor 区。新生代的GC 成为 Minor GC。Minor GC 时 Eden 区和 from 指向的 Survivor 区的存活对象会被存储到 to 指向的 Survivor 区。当 Survivor 区对象复制次数到一定值或者Survivor区空间使用超过一定值的时候,会把对象晋升到老年代。

针对Minor GC 可能出现的老年代对象包含新生代对象引用的问题,Hotspot虚拟机是用卡表技术来解决的。

参考文章

JVM之卡表(Card Table)
郑雨迪:深入拆解虚拟机
深入理解Java虚拟机:JVM高级特性与最佳实践

posted @ 2019-08-30 09:11  六层楼  阅读(400)  评论(0编辑  收藏  举报