虚拟机研究系列-「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

参考文献

posted @ 2021-10-20 22:49  洛神灬殇  阅读(471)  评论(0编辑  收藏  举报