写点什么

【新】虚拟机深层系列「GC 本质底层机制」SafePoint 的深入分析和底层原理探究指南

发布于: 2021 年 10 月 20 日
【新】虚拟机深层系列「GC本质底层机制」SafePoint的深入分析和底层原理探究指南

SafePoint 前提介绍


在高度优化的现代 JVM 里,Safepoint 有几种不同的用法。GC safepoint 是最常见、大家听说得最多的,但还有 deoptimization safepoint 也很重要。


在 HotSpot VM 里,这两种 Safepoint 目前实现在一起,但其实概念上它们俩没有直接联系,需要的数据不一样。


无论是哪种 SafePoint,最简洁的定义是“A point in program where the state of execution is known by the VM”。这里“state of execution”特意说得模糊,是因为不同种类的 safepoint 需要的数据不一样。

GC safepoint

GC Safepoint 需要知道在那个程序位置上,调用栈、寄存器等一些重要的数据区域里什么地方包含了 GC 管理的指针; 如果要触发一次 GC,那么 JVM 里的所有 Java 线程都必须到达 GC safepoint。

Deoptimization safepoint

Deoptimization safepoint 需要知道在那个程序位置上,原本抽象概念上的 JVM 的执行状态(所有局部变量、临时变量、锁,等等)到底分配到了什么地方,是在栈帧的具体某个操作数栈 slot,还是在某个寄存器里。


如果要执行一次 deoptimization,那么需要执行 deoptimization 的线程要在到达 deoptimization safepoint 之后才可以开始 deoptimize。


不同 JVM 实现会选用不同的位置放置 safepoint。

HotSpotVM 的 SafePoint

解释器里每条字节码的边界都可以是一个 safepoint,因为 HotSpot 的解释器总是能很容易的找出完整的“state of execution”。


JIT 编译的世界里,HotSpot 会在所有方法的临返回之前,以及所有非 counted loop 的循环的回跳之前放置 safepoint,(counted loop 则没有放置 safepoint)。


HotSpot 的 JIT 编译器不但会生成机器码,还会额外在每个 safepoint 生成一些“调试符号信息”,以便 VM 能找到所需的“state of execution”。

SafePoint 的存储信息

为 GC SafePoint 生成的符号信息是 OopMap,指出栈上和寄存器里哪里有 GC 管理的指针;


为 deoptimization SafePoint 生成的符号信息是 debugInfo,指出如果要把当前栈帧从 compiled frame 转换为 interpreted frame 的话,要从哪里把相应的局部变量、临时变量、锁等信息找出来。

选择在 SafePoint 的位置地点

  • 挂在 safepoint 的调试符号信息要占用空间,如果允许每条机器码都可以是 safepoint 的话,需要存储的数据量会很大(当然这有办法解决,例如用 delta 存储和用压缩)

  • safepoint 会影响优化,特别是 deoptimization safepoint,会迫使 JVM 保留一些只有解释器可能需要的、JIT 编译器认定无用的变量的值。本来 JIT 编译器可能可以发现某些值不需要而消除它们对应的运算,如果在 safepoint 需要这些值的话那就只好保留了。这才是更重要的地方,所以要尽量少放置 safepoint。


像 HotSpotVM 这样,在 Safepoint 会生成(polling 代码)主动请求询问 JVM 是否要进入 safepoint,polling 也有开销所以要尽量减少。

Native 代码的特殊性

当某个线程在执行 native 函数的时候。此时该线程在执行 JVM 管理之外的代码,不能对 JVM 的执行状态做任何修改,因而 JVM 要进入 safepoint 不需要关心它。


所以也可以把正在执行 native 函数的线程看作“已经进入了 safepoint”,或者把这种情况叫做“在 safe-region 里”。


JVM 外部要对 JVM 执行状态做修改必须要通过 JNI。所有能修改 JVM 执行状态的 JNI 函数在入口处都有 safepoint 检查,一旦 JVM 已经发出通知说此时应该已经到达 safepoint 就会在这些检查的地方停下来把控制权交给 JVM。


JRockit 选择放置 safepoint 的地方在方法的入口以及循环末尾回跳之前,跟 HotSpot 略为不同。


UseCountedLoopSafepoints:
复制代码


可以避免 GC 发生时,线程因长时间运行 counted loop,进入不到 safepoint,而引起 GC 的 STW 时间过长。


JVM 参数-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1


在控制台输出以下信息:


vmop [threads: total initially_running wait_to_block]  [time: spin block sync cleanup vmop] page_trap_count  370337.312: GenCollectForAllocation     [  1070     2       3  ]   [ 8830   0 8831   1  24  ] 
复制代码


YGC 所花费的时间非常短,主要时间花费在所有线程达到安全点并暂停。

JVM 参数配置如下:

-server -Xms8192M -Xmx8192M -Xmn1500M -Xss256k -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:-UseBiasedLocking -XX:MonitorBound=16384 -XX:+UseSpinning -XX:PreBlockSpin=1 -XX:+UseParNewGC -XX:ParallelGCThreads=8 -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=55 -XX:CMSMaxAbortablePrecleanTime=5 -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0 -XX:+CMSClassUnloadingEnabled -XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+DisableExplicitGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/xmail/jvm_heap.dump -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1  
复制代码


最有可能导致问题的是代码里有 Java 代码


for (int i = 0; i < ...; i++) { } 或者类似的循环代码。
复制代码


这种循环称为“counted loop”,就是有明确的循环计数器变量,而且该变量有明确的起始值、终止值、步进长度的循环。


它有可能被优化为循环末尾没有 safepoint,于是如果这个循环的循环次数很多、循环体里又不调用别的方法或者是调用了方法但被内联进来了,就有可能会导致进入 safepoint 非常耗时。


可惜的是现在没什么特别方便的办法直接指出是什么地方有这种循环。有的话,一种解决办法是把单层循环拆成等价的双重嵌套循环,这样其中一层循环末尾的 safepoint 就可能会留下来,减少进入 safepoint 的等待时间。

如何判断内联方法

从代码角度如何判断方法被内联进来了,主要是方法被 final 修饰。 final 是可以帮助 JIT 编译器做出内联的判断,但不是必要条件。


  • -XX:+PrintCompilation -XX:+PrintInlining 来看内联状况

  • -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 ”输出的结果“[time: spin block sync cleanup vmop] ”中 spin 是指什么呢?

  • PrintSafepointStatics:打印出来的 spin 值指的是 SafepointSynchronize 在同步每个线程时做的自旋。


thread locking / biased locking 的 spin 完全没关系,自然设置那些参数也不会影响 safepoint 的自旋(UseSpinning 之类控制的是 thread locking 的自旋)。

SafePoint 存在的目的?

为什么把这些位置设置为 jvm 的安全点呢,主要目的就是避免程序长时间无法进入 safepoint,比如 JVM 在做 GC 之前要等所有的应用线程进入到安全点后 VM 线程才能分派 GC 任务 ,如果有线程一直没有进入到安全点,就会导致 GC 时 JVM 停顿时间延长。


比如,写了一个超大的循环导致线程一直没有进入到安全点,GC 前停顿了 8 秒。


产生的日志信息基本上 STW 的原因都是 RevokeBias 或者 BulkRevokeBias。这个是撤销偏向锁操作,虽然每次暂停的时间很短,但是特别频繁出现也会很耗时。

GC 如何找到不可用的对象

编写代码的时候是可以知道对象不可用的,但对于程序来说,需要一定的方式来知晓,可用方法比如:编译分析,引用计数,和对象是否可达。

可达性分析

因而可达性分析,只需要找到直接可达的引用,直接可达的引用就是根引用,根引用的集合就是根的集合


  1. 一个对象只要能够通过 mutator 触达,那么它就是“活”着的。

  2. 如果 Mutator 栈的一个槽位包含了对象的引用,那么对象就是直接可触达。

  3. 从直接可达对象可触达的对象必定也是可达的,

muator 线程分析
  • mutator 的上下文就包含了直接可达的数据,所以要获取对象根集合就是要找到 mutator 上下文中的对象引用,

  • mutator 的上下文指的就是它的栈、它的寄存器文件以及一些线程上特定的数据。

静态数据

全局数据本身也是直接可达的


可达性分析为了确保能正确的决定对象是否存活,GC 需要获取 mutator 上下文的(当前)一致性快照,然后枚举所有的根对象。


  • 一致性指的是:快照的抽取就像只在一个时间点发生,来避免丢失一些活着的对象。

如何获取 mutator 上下文的一致性快照

一种简单的方式就是在跟引用的过程中暂停所有的线程。当 mutator 暂停了它的执行时,只有将所有引用信息保存在其上下文中,才能枚举根的集合,这意味着,mutator 需要能够告诉 JVM 哪些栈的槽位有用,哪些寄存器持有引用。


如果 GC 能够准确的获取上述引用信息,它就称作精准根集合枚举。而无法获取就是不精准的。

如何获取精准的引用信息枚举

对于 java 来说,JIT 知晓所有的栈帧信息和寄存器的内容,当 JIT 编译一个方法时,对于每条指令,它都可以去保存根引用信息,保存意味着额外的存储空间,如果要存储所有的指令就显得花销太大,另外在真实的运行过程中也只有少数指令才会成为暂停点,因此 JIT 只需要保存这些指令点的信息就够了。而真正有机会成为暂停点的地方就称作 safe-points,即能够安全的枚举根集合的暂停点

如何保证 mutator 会在 safe-point 暂停

当 GC 想要触发一次回收时,它会设置一个标志,mutator 则周期性的去检查(poll)这个标志,如果检查到了,就会立马暂停,这里的检查点(poll points)也是安全点,由 JIT 负责把 poll points 放到合适的位置,那些地方适合设置检查 GC 事件的标记

polling point 插入的主要原则是:
  • polling point 应该足够多,防止 GC 等一个 mutator 的暂停太长,导致其他 mutator 都走在等 GC 释放空间,程序整个等待过长

  • polling point 不能太频繁导致运行时存储开销过大

  • polling 本身也是有开销的,不能过多

  • 权衡下来只在必须和必要的地方加

  • 分配地址的时候强制添加,因为分配空间很有肯能导致回收,所以这里是一个安全点

  • 长时间的执行一般意味着循环和方法调用,所以方法调用和循环返回最好加上


但是有时候并不是长时间的执行,而是长时间的空闲,比如 sleep、block,线程在执行其他的 native 函数,这些时候 JVM 无法掌控执行能力,也就无法响应 GC 事件。


SafePoint 无法解决 sleep/block 带来的问题,当这段时间内 JVM 要发起 GC 时,就不管没到安全点但是在安全区域的线程。在线程要离开安全区域时,要检查系统是否已经完成了 GC,故我们又定义了一个安全区域的概念.

SafeRegion 的简介

safe-region 是指代码快中没有用到会变异的部分,这样的代码块中,任何一个点都可以安全的枚举根。


  • 当进入到 safe-region 中时,mutator 会设置一个准备标记,在离开 safe-region 区域之前,会检查 GC 是否已经完成了回收,如果没有,那么就暂停执行,如果有,就可以直接离开 safe-region 区域,不需要暂停 mutator。

  • 关于 Java/JVM 的 safepoint / safe-region,代码的执行过程中,如果需要执行某些操作,比如 GC,deoptimize,等等,必须知道当前程序所有线程运行到的地方,是否能够恰好满足我执行对应操作,而不会对应用程序本身造成损害,能够正确执行的地方就是 safepoint/saferegion

参考文献

发布于: 2021 年 10 月 20 日阅读数: 29
用户头像

🏆 2021年InfoQ写作平台-签约作者 🏆 2020.03.25 加入

【个人简介】酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“ 【技术格言】任何足够先进的技术都与魔法无异 【技术范畴】Java领域、Spring生态、MySQL专项、APM专题及微服务/分布式体系等

评论

发布
暂无评论
【新】虚拟机深层系列「GC本质底层机制」SafePoint的深入分析和底层原理探究指南