一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第14天,点击查看活动详情
本系列专栏:JVM专栏
前言
上一篇文章我们说了垃圾回收机制中如何认定一个对象是垃圾,以及如何回收这些垃圾所占用空间的3种算法,那本篇文章就来说一下现代JVM的垃圾回收机制是如何取长补短,做出一个高效的垃圾回收策略。
正文
二八定律
在JVM系列的第1篇文章我们说过代码执行的频率几乎符合二八定律,即20%的代码频繁运行(热点代码),占据了80%的运行时间,而大多数代码运行次数很少,所以JVM采用混合的编译器来综合解释执行和即时编译的优缺点来达到最佳性能效率。
而对于堆中的对象生命周期也是的,即少数对象的存活时间久,而项目中大多数的对象一般在执行后就没用了,这个也是符合二八定律的,所以JVM利用这点也对垃圾回收机制做出了改进。
简单来说就是将堆空间划分为俩代,分别叫做新生代和老年代,新生代用来存放新建的对象,当对象存活的时间够长,则将其放入老年代。
对于老年代,我们预期是大部分的垃圾已经在新生代中被回收了,而老年代中的对象大概率会继续存活,当真正触发老年代中对象回收时,则说明老年代区域不足或者其他原因,需要做一次全堆扫描,耗时就会很久。
我们先来看一下针对新生代的Minor GC。
JVM的堆划分
前面提到堆会被分为新生代和老年代,而新生代又被分为Eden区和2个大小相同的Survivor区,其中2个Survivor分别用2个指针from和to来管理,而这个管理机制和上一篇文章中说的标记-复制发是一样的,即to指针指向的Survivor区始终是空,如下图所示:
其实会发现这里使用的就是标记-复制算法,当一个新对象被new时,就把它放入到Eden区中,当Eden区空间快耗尽时,就会触发Minor GC,这时Eden区中的大部分对象根据二八定律都会被标记为垃圾,这时把存活的对象和from中的对象都复制到to区中,这时to区中就是这一次Minor GC存活下来的对象,然后from和to指针再交换,from指针就是存活下来的所有新生代对象,而to指向的区域还是空的。
当这样几轮之后,from区中的对象有的是刚被从Eden区复制进来的,有的已经在Survivor区中被复制很多次了,当一个对象复制次数超过15次,则把这个对象晋升为老年代,复制到老年代区中。
这里之所以使用标记-复制算法,是因为理想状态下,每次Eden区中的对象都会死很多,存活的很少,所以复制不会太消耗性能。
TLAB
上面情况我们说当new一个对象时,会在堆的Eden区中申请一块内存,但是在多线程的情况下会有问题,因为堆内存是线程共享的,所以可能出现多个线程同时去申请同一块内存来存放new的对象,假如每次都在new对象要存放时进行加锁,则比较麻烦,JVM中使用TLAB来解决这个问题。
TLAB全称为Thread Local Allocation Buffer,即线程本地申请的缓冲区,也就是给每个线程都预留一块堆中内存,当不同线程进行new对象申请空间时,就在它自己申请的空间中存放即可,当然要注意线程申请空间这个操作是线程安全的。
卡表
回想一下我们标记一个对象为垃圾时用的是可达性算法,是从GC Roots开始标记能够到达的对象,由于对象存储在堆空间中,所以这个GC Roots我们当是说一般从堆外指向堆中,比如方法栈帧中的局部变量区,而且Minor GC有一个好处就是它是针对新生代的,不会去扫描整个堆内存。
但是却有个问题,就是可能出现老年代中的对象引用了新生代中的对象,虽然这种情况极少,因为一般情况下这种引用的对象都是一起晋升到老年代或者一起死亡,假如出现存在一个对象在新生代中,它却被老年代中的一个对象引用所指向,而Minor GC又不遍历老年代中的堆,来查找老年代堆中哪些对象有指向新生代的对象,JVM一般解决这个问题的方案叫做卡表。
卡表(Card Table)将整个堆(也可以只是老年代堆)划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位,而这个标识位代表对应的卡是否可能存在指向新生代对象的引用,如果存在,则标记为脏卡。
在进行Minor GC时,就没有必要去扫描整个老年代,而是在卡表中寻找脏卡,并且将脏卡中的对象加入到Minor GC的GC Roots里,当完成所有脏卡的扫描,JVM会将脏卡的标识清零。
这个思维就和现在的群体核酸一样,N个人一起做核酸,当这N个人组成的卡是阳性,则再去遍历。
还有一点要注意,这里为什么要设计卡表,前面说的可能不准,是为了防止错回收,即新生代中的某个对象不是垃圾,Minor GC却因为可达性算法到不了认定它是垃圾,所以可能会导致程序崩溃。
既然知道了原理,那这个卡中的标志位什么时候去设置呢 我们想一下,这里可能出错的地方是堆内一个对象引用,指向堆中另一个对象,而这种模型在Java语言就是引用类型实例变量的写操作,所以JVM要截获每个引用类型实例变量的写操作,找到该对象对应的卡,标记为脏卡。
其他垃圾回收器概述
其实理解了现代的JVM是分区来进行垃圾回收的,而且不同堆区有不同的算法很重要,其中新生代的垃圾回收叫做Minor GC,而老年代的垃圾回收叫做Full GC,我们来看看一些常用的垃圾回收器。
针对新生代的垃圾回收器有3个,都是使用标记-复制算法:
-
Serial:是一个单线程版本;
-
Parallel New:可以看成是Serial的多线程版本;
-
Parallel Scavenge:和上面一个类似,但是更加注重吞吐率。
针对老年代的垃圾回收器也有3个:
-
Serial Old:采用标记-压缩算法,单线程版本;
-
Parallel Old:采用标记-压缩算法,多线程版本。
-
CMS:采用标记-清除算法,并且是并发的,但是Java 9中被废弃,由于G1的出现。
G1:是一个横跨新生代和老年代的垃圾回收器,实际上,它打乱了前面所说的堆结构,直接将堆分成极其多区域,每个区域都可以充当Eden区、Survivor区或者老年代中的一个,采用标记-压缩算法。
总结
其实JVM就是根据对象生命周期的长短来把堆分为了不同区域,不同区域采用了不同的垃圾回收器,但是垃圾回收算法都是上一篇文章里说的,其中新生代主要使用标记-复制算法,而老年代主要使用标记-压缩算法。