首先,我们知道许多主流GC都是基于分代收集理论设计的,所以常把Java堆分为新生代和老年代(但这不是一个强制
规范,只是一种设计思想),本文也按照这种经典思想将Java堆分代。当然,你可能会说,都0202了,不应该直接
学习G1吗,私以为“循序而渐进”乃学习之正道,施主,急不得。
一、基础理论
根可达性分析
目前主流的GC在标记阶段采用的都是这种算法。
- 什么是引用计数法?它的劣势主要在哪?
- 引用计数法的做法是让对象额外记录一个计数器,当有一个变量或其他对象引用它时,这个计数器自增1,如果一个对象没有被引用(计数器的值为0),就会被认定为垃圾。如果两个对象相互引用(
对象内心OS:不要清理我们,我们深爱着对方),则它们不会被清理,但实际上对于线程而言,它们已经没用(秀恩爱死得快)了,因为线程不能通过引用访问到它们。
- 那什么是根可达性分析呢?
- 首先,得明确一下“根”的定义,由于标准定义过于冗长,难以记忆,这里简化一下,我们可以认为“根”是被JVM管理着的线程们(或者说是存活着的线程们)存储着的引用。
- 根可达性分析本质上就是DFS(Depth-First-Search,深度优先搜索),即从“根”(对象的引用)出发,
一根筋地往下蹿,把遍历到的对象都标记为可用的。
- 为什么要根可达性分析算法是主流?
- 最重要的原因就是根可达性算法不会让1中所述的循环引用(我好像在说废话,因为它们根不可达)存在(
其实这个算法是fff团设计的),即它不会因为自身算法的问题而导致未回收垃圾。
下面粗略地给出(怎么高兴怎么写)以上算法的实现:
// 遍历每个GCRoot的引用链
void isReachableFromRoots(GCRoots[] gcRoots) {
for (GCRoots gcRoot : gcRoots) {
// 遍历当前gcRoot的引用链
sign(gcRoot.getRefs());
}
}
// 标记(dfs)
void sign(Reference... refs) {
if (refs == null || refs.isEmpty()) {
return;
}
for (Reference ref : refs) {
// 标记为有用对象
ref.isReachable = true;
// 遍历当前对象的引用集
sign(ref.getRefs());
}
}
注:下文的一些图片也用黑色表示对象可达,白色表示不可达(但是作为一个资深垃圾收集者,你可能会说,咱玩的不都是三色标记吗,啊,被发现了,下面会谈到~)
二、算法的内容与实现
复制算法
复制算法(严格来说是改进版的复制算法)就是针对新生代的。
- 什么是复制算法?
- 现在我们有一块内存空间,线程们迫不及待地想要在上边放置自己的对象。
- 但是聪明的线程管理者——JVM,不允许线程任意妄为,JVM将这块内存划成两个部分(实际上通常是三个部分,这里只关注“复制”环节),我们暂且分别称它们为小a和小b吧。
- 活泼的线程们很快就把小a填满了,这时JVM(GC)拍马赶到,开始了自己的
擦屁股之旅:它把“垃圾”(根不可达的对象)清理了,然后把存活的对象挪到了小b。宝剑锋从磨砺出,梅花香自苦寒来,经过这一次历练,小b中的对象成长了(分代年龄+1)。 - 现在小a空空如也,小b放着一些还有用的对象,然后线程们又开始在小b上放置对象了,于是下一个轮回开始了……
- 普通的复制算法有什么优点?缺点呢?
- 从1中我们知道这种算法很简单,但你可能会说,这太浪费空间了啊!古人云:“鱼与熊掌不可兼得”,故普通的复制算法的优点就是实现简单、不产生内存碎片(不连续的内存空间),缺点就是内存利用率低。
- JVM中的复制算法是怎么样的呢?
- 前面提到,JVM通常把新生代划分为3个部分,它们分别是Eden区,From区和To区,其中Eden区是新对象优先被分配的地方(
故事的开始,对象们快乐地生活在伊甸园),From区和To区对应的就是1中举例用的小a和小b。 - 默认情况下,Eden:From:To=8:1:1,为什么这样分配呢?因为设计大佬们统计发现,98%的对象是会被回收的,也就是说,大部分对象其实在Eden区就被回收了,即能幸存下来溜到From区和to区的对象少之又少。
- 增加了一个Eden区(Appel式的复制算法,
别问,问就是用大佬名字命的名)提高了复制算法的空间利用率,使得这个算法既省事儿又有效,存活下来的少部分对象在From区和To区之间反复横跳,满足一定条件之后被送到老年代(动态年龄判定)。 - 关于动态年龄判定,这里不阐述,因为比较容易产生歧义,具体内容见下方源码及其注释。
// 动态年龄判定(计算晋升至老年代的阈值年龄)
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
// survivor_capacity是survivor空间(即上图的From区加上To区)的大小
// TargetSurvivorRatio默认值是50,即desired_survivor_size默认是survivor的一半
size_t desired_survivor_size = (size_t)((((double) survivor_capacity) * TargetSurvivorRatio) / 100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age]; // sizes数组存储了每个年龄段对象总大小
if (total > desired_survivor_size) break; // 当前总大小大于目标值则直接结束循环
age++;
}
// MaxTenuringThreshold是默认阈值,一般设为15
// result是动态判定之后的阈值,GC会把年龄大于等于result的对象送往老年代
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
// 之后的内容省略...
}
- 小结
- 复制算法常用于新生代,很贴合对象“朝生夕死”的特点。许多算法本身往往没有优劣之分,要结合应用场景选取合适的算法。
复制算法是针对新生代的,剩下的两个算法则在老年代中运用。
标记-清除算法
标记清除的英文是Mark-Sweep,没错,常用的并发GC——CMS中的MS就是标记-清除。接下来就基于CMS分析标记-清除算法。
- 什么是标记?如何实现?
- 标记的含义和实现见根可达性分析部分(CMS中的标记比较复杂,这篇文章不详细讨论)。咳咳,差点忘了,还得填坑:在之前的分析中我们简单地把对象分为可达(图中用黑色表示)和不可达(图中用白色表示),但实际上(并发)GC采用的是三色标记法。
- 什么是三色标记?
- 我们知道多线程可以提升效率,但由于涉及到线程的调度,我们就不能简单地只把对象分为可达和不可达,因为线程被挂起时很有可能并没有完成整个引用链的扫描。
- 于是引入了第三种颜色——“灰色”,用于标记可达但未遍历其引用集的的对象。再明确一下“黑色”和“白色”的定义——“黑色”用来标记可达且已经遍历其引用集的对象,“白色”用来标记不可达的对象。
- 什么是漏标?
- 三色标记解决了一部分问题,但由于GC线程和业务线程是并发执行的(妈妈一边打扫,你一边丢垃圾,
请问医院wifi速度快吗),难以避免产生新的引用关系,或是删除/改变旧的引用关系,即并发会导致漏标,于是由此又引出了新的算法(如Increment Update),本文不做详细阐述(作者不会)。下图(承接上图)简单描绘了漏标的情形:
- 优点?缺点?
- 标记-整理算法实际上包括标记、清除和整理,即标记-清除算法与之相比少了清除之后的整理。这样明显省事儿,但是代价是产生了内存碎片。同样地,CMS中的清除也是分阶段进行的。CMS的设计初衷就是要减短STW(Stop the world,
咋瓦鲁多)的时间,而标记-清除算法就很符合这样的理念,因为它够快,缺点在上文也提及了,就是会产生内存碎片
标记-整理算法
- 很明显,此算法的优点是不产生内存碎片,缺点是实现繁琐(涉及到地址的改变)。
- 说累了,懒癌晚期的作者想溜了。不过u1s1,搞懂了标记-清除,其实也就搞懂了标记-整理,最后就用一张图来结束吧(图中黑色表示被内存使用,白色表示空闲,即不存在内存碎片,可用空间是连续的)~
三、总结
算法 | 优点 | 缺点 |
---|---|---|
复制算法 | 实现简单、无内存碎片 | 内存利用率较低 |
标记-清除算法 | 速度快 | 有内存碎片 |
标记-整理算法 | 实现较复杂 | 无内存碎片 |
作者水平有限,文中存在的问题还望大家能够指出~