JVM性能优化实践-读书笔记-第7章
[
]
7. 垃圾收集高级话题
7.1 权衡与可插拔的收集器
冷知识—-Java语言和虚拟机规范都没有说明如何实现垃圾收集。 有一些牛逼的Java实现甚至没有垃圾收集器(ex. Lego Mindstorm)。
在Sun/Oracle的环境中,垃圾收集被视为一个可插拔的子系统,所以一个Java程序可以通过配置不同的垃圾收集器来运行。
没有一种垃圾收集器可以对所有问题进行优化。
选择垃圾收集器时需要考虑的问题:
- 暂停时间—-STW的持续时间
- 吞吐率—-垃圾收集时间/程序运行时间
- 暂停频率—-STW的频率
- 回收效率—-一次可以收集多久
- 暂停的一致性—-STW时间是否大致相同。
我们总在关注暂停时间,然而:
对于许多工作负载来说,暂停时间并不是一个有效或有用的性能特性。
整个来讲,选择垃圾收集器主要关注:CPU效率和吞吐量
7.2 并发垃圾收集理论
问题:通用的垃圾收集器没有办法提高暂停时间的确定性
解决: 使用并发(至少部分并发,或大部分并发)的垃圾收集器
这种安排的次要缺点是本身的计算被延迟了,而主要的缺点是垃圾收集器穿插而带来的不确定性。 —-Edsger Dijkstra
7.2.1 JVM安全点
JVM即不是一个完全抢占式的多线环境,也不是一个纯粹的合作式环境
- 操作系统可以在任何时间抢占线程,如在线程用尽时间片或是进入
wait时。 - 但JVM需要协调操作,来确定什么时候线程可以被抢占。
为了达到以上两点,JVM引入了安全点的机制。
安全点(SafePoint) JVM中应用程序线程特殊执行点,在这个执行点上:
- 线程内部数据结构处于已知的结构良好的状态。
- 线程此时可以被挂起,让JVM执行协调操作。
“在STW经典示例和线程同步中已经看到了安全点作用。”
垃圾收集在运行时要求有一个稳定的对象图,因此所有的应用程序线程都必须暂停。因此要求所有的线程都有相应的安全点,同时JVM对安全点的方式有以下原则:
- 不能强制线程进入安全点—-只能等
- 可以阻止线程离开安全点—-进来了就没跑
姜太公钓鱼,愿者上钩呗 :)
当需要安全点(也就是要STW时),JVM解释器的实现要有在屏障(Barrier)处释放(yeild)的代码。到达安全点的机制为:
- JVM设置一个全局的安全点时间标志—-都得停
- 单个应用程序查看当前标签是否已经设置—-看看你是不是想停?
- 如果设置了就暂停并等待再次唤起—-好,停就停吧。
所以一设置了一后,所有的线程都得停,而且停的快的要等停的慢的,而且这个等待的时间并不统计在暂停时间里。
等待所有线程暂停,比较像使用锁存器,比
CountDownLatch
JIT编译时安全点的插入/决定安全点的方式:
- 在退出方法时
- 在循一次分支向后跳转(循环,if/else的尾部?)
线程在以下情况下自动处于安全点:
- 阻塞在一个管程(
monitor)上 - 在执行JNI代码
JNI是Java Native Interface的缩写,通过使用 Java本地接口书写程序,可以确保代码在不同的平台上方便移植。 [1] 从Java1.1开始,JNI标准成为java平台的一部分,它允许Java代码和其他语言写的代码进行交互。JNI一开始是为了本地已编译语言,尤其是C和C++而设计的,但是它并不妨碍你使用其他编程语言,只要调用约定受支持就可以了。使用java与本地已编译的代码交互,通常会丧失平台可移植性。但是,有些情况下这样做是可以接受的,甚至是必须的。例如,使用一些旧的库,与硬件、操作系统进行交互,或者为了提高程序的性能。JNI标准至少要保证本地代码能工作在任何Java 虚拟机环境。
注意,以下情况,线程未必进入安全点
- 在执行字节码的过程中(解释模式)
- 已经被操作系统中断。
7.2.2 三色标记算法
算法说明
1. 将根结点标记为灰色
2. 所有其他结点都标记为白色
3. 随机移到一个灰色结点
4. 如果该结点有任何标记的白色子结点,则将子结点标记为灰色,然后将该结点标记为黑色
5. 重复到没有灰色结点
6. 黑色点证明是可达的
7. 白色结点是要收集的。
书上个有个图,如下:

然而,本拐要吐槽!!!!伪代码不香么
其实就是类似于这个样子:
root.color = 'grey'
for node in get_all_nodes(root)
node.color = 'white'
end
do
node = random_get_node(root,'grey')
if node == null
break
end
for child in node.children
if child.color == 'white'
child.color = 'grey'
end
end
node.color = 'black'
while(true)
do
node = random_get_node(root,'white')
if node == null
break
end
gabage(node)
while(true)
说明:
- 基于对象引用/活跃对象图的算法
- 使用原始快照(
Snapshot at the begining,SATB)技术,在收集开始时如果可达,就认为是活的。 - 由此引出问题: 如果在标记期间对象状态被应用进程
Mutator改变,会有不一致的情况。
- Collector – 收集线程
- Mutator – 应用线程
标记失效情况:

- a已经被标记为黑色,然后
Mutator刷新,a有对白色对象C的引用。 - 如果B此时被删除,那么此时C是要的,产生冲突。
- 简单讲,就是计划把C删了,结果A又要用C!!
拐言:如果看不懂这个图,可以把B划掉再食用
处理标记失效的方式
- 方式:将a改为灰色,这样会重新处理a。
这种方式用了写屏障来进行更新。同时在更新的过程中保持了三色不变性。
写屏障在书中没有解释,应该理解为是在对象更新会
反向更新对象活跃图
三色不变性(
tri-color invariant—在并发标记期间,任何黑色结点都不能持有对白色结点的引用。
- 方式:维护一个队列,保存所有会破坏三色不变性的修改,在主阶段结束时运行一个修正的流程。
也用了写屏障?
7.3 CMS
CMS是为老年代设计的一个低延迟垃圾处理器,通常与ParNew(用于Young GC,并行处理器)配对使用。
CMS想在标记阶段多做一些工作,以减少暂停时间。
CMS的工程流程:
-
初始标记(inital mark)—-STW
- 为垃圾收集提供一个稳定点的起点集合,这些起点被称为内部指针,用于三色标记时的根结点
- 并发标记(concurrent mark)
- 并发处理每一个垃圾收集池
- 并发预清理(concurrent preclean)
- 重新标记(remark)—STW
- 用卡表进行修正更改
- 并发清除(concurrent sweep)
- 并发重置(concurrent reset)
用两个短的STW代替一次长的STW
CMS的效果:
- 应用程序不会暂停很久
- 一次Full GC需要更多时间
- 运行垃圾收集时,吞吐量会降低
- 垃圾收集会用更多信息记录对象信息
- 垃圾收集整体看需要更多CPU时间
- 不会压缩堆,老年代内存碎片会越来越多
7.3.1 CMS的工作原理
- CMS大多数与应用程序并发运行
- 默认使用一半的线程来执行垃圾收集的并发阶段,另一半给应用线程
- 不可避免会涉分配新对象。
如果Eden在CMS运行期间被填满:
- 应用暂停,并运行一次Young GC
-
Young GC会比并行收集时间长,因为只有一半线程可用(可一半还在运行CMS)
- Young GC结束时,会把少量对象晋升到Tenured区域,同时CMS结束时,也会释放部分Tenured。
并发模式失败(CMF,Concurrent Mode Failure)
在分配率过高时,Young GC会导致过早晋升。称为并发失败。 此时只能使用STW的ParallelOld,导致FullGC
如果Tenured内存没有足够连续空间来复制对象,导致Young GC的对象晋升失败,也会引发FullGC
避免方式
- CMS在Tenured被填满时启动一次收集,通常为75%,可以参数控制。
- CMS使用一个内存块来管理可用内存,在并发清除阶段,会被
Sweeper进程合并,以提供更大的空闲空间块。- Sweeper和Mutator是并发运行的,因此在清除过程中会锁住空闲列表。
7.3.2 用于CMS的基本JVM标志
开启CMS的方式:-XX:+UseConcMarkSweepGC
会同时激活 ParNew
7.4 G1
版本要求
- 建议在Java 8u40 以后使用
特性
- 调优比CMS容易
- 不容易受到过早晋升的影响
- 行为在大堆上能够更好的扩展(尤其是STW)
- 能够消除(或者能极大减少回退到)完全STW的收集。
7.4.1 G1 的堆布局和区域
- G1堆基于区域(
region)的概念,其默认大小为1Mb - 区域支持非连续的分代
相当于把不同内存区域标记成tenured,eden,以及survior,在清理时可以直接转换标记????
- region大小为1/2/4/8/16/32/64Mb中的某个值,可以参数化设置
-
期望堆中的区块数量在2048到4095之间
Number of Region = heap size / region size
堆的表示,这个图还是有必要的。

7.4.2 G1的算法设计
G1有如下特点:
- 使用了一个并发标记阶段;
- 是一款疏散收集器;
- 提供了“统计型压缩”(
statistical compaction)
TLAB分配,Suvivior收集及Tenured晋升,与之前无异。
占用空间超过区域一半的对象被认为是巨型的(humongous),会被直接分配在特殊的巨型区域中,该区域是连续的空闲区域,可以立即成为Tenured的一部分,而非Eden
仍然有Eden,Survivor,Tured的概念,但不是连续的。
对于从老年代指向新生代的引用,使用记忆集来处理。
记忆集,Remembered Set,通常称为RSet,是每个区域都有的条目,用来指向对外部区域的引用。

浮动垃圾(floating gabage)
浮动垃圾:有些对象本来应该是死的,但是在当前收集集合的死亡对象中仍然保持着对这些对象的引用。
Rset和卡表都可以处理浮动垃圾。
拐言,有一种标记处理方式,是记录对象的引用次数,在清理时会将引用次数为0的对象清理掉,这种标记方式不无道理,但是如果有两个对象互相引用的话,而外部又没有调用,那么这两个对象都不会被清理,由此产生浮动垃圾。
算是理解浮动垃圾的一种方式。
7.4.3 G1的各阶段
- 初始标记(initial mark)—-STW
- 并发根扫描(concurrent root scan)
- 扫描初始标记的Survivor区块以寻找指向Tenured的引用
- 需要在下一次Young GC前完成
- 同时执行引用处理,并实现与TLAB相关的清理工作。
- 并发标记(concurrent mark)
- 重新标记(remark)—-STW
- 清理(cleanup)—-STW
- 处理记帐信息,清洗RSet
- 记账信息会识别标记空闲区域
7.4.4 用于G1的JVM标志
开启
Java 8 及早期版本
-XX:UseG1GC
垃圾收集中应该暂停的最大时间
-XX:MaxGCPauseMillis=200
默认区域大小
-XX:G1HeapRegionSize=(n)
n 为 2 的幂
7.5 Shenandoah
由RedHat发布
目标: 减少大堆的的暂停时间
收集阶段
- 初始标记(initial mark)—-STW
- 并发标记(concurrent marking)
- 最终标记(final marking)—-STW
- 并发压缩(cocurrent compaction)
关键特性:使用了Brooks 指针
每个oop用一个额外的内存字来指示对象在垃圾收集的上一个阶段是否被重新安置(relocate)
- brooks指针使用cas来维护
- 如果没有调整,brooks指向当前对象
- 如果调整对象区域,会指向新的位置
brooks指针的初始状态:

这里Brooks Pointer存的为当前的地址ac49
在并发标记阶段会跟踪整个堆,如果一个对象引用包含了转发指针的oop 则更新。
brook指针的转发:

- oop从ac49的位置移到了0112 (图的上侧)
- 在并发标记阶段,更新引用(图的下侧)
在最终阶段会STW,然后重新扫描扫描根集,复制并更新根集指向疏散后的内
7.5.1 并发压缩
- 将对象复制到某个STAB
- 使用CAS操作更新Brooks指针
- 如果成功,以后用Brooks访问
- 如果失败,撤消操作。
拐言,相当于在mutator运行时去复制对象,然后试着去更新brooks,如果更新成功,表明对象移动(清理)成功,如果失败,那就下一波再搞了。
7.5.2 获取Shenandoah
- 需要源码编译
激活开关
-XX:+UseShenandoahGC
特性

7.6 C4(Azul Zing)
略 :)
7.7 J9
略 :)
7.8 遗留的HotSpot收集器
略 :)
关于老拐瘦
中年争取不油不丧积极向上的码农一名
咖啡,摄影,骑行,音乐
样样通,样样松
喜欢可以关注一下公众号 IT老拐瘦

目前个人博客长驻: yfge.github.io