当前位置:   article > 正文

JVM之OopMap,安全点,安全区

oopmap


一、什么是OopMap

由于目前几乎所有虚拟机都是用可达性分析算法来判定对象是否存活,即通过选定固定的gc roots作为起始节点,像剥洋葱一样往下溜达,只要存在任意节点从gc roots到该节点不可达,那表示这个对象不被任何对象所引用,这个对象最终就要被当做垃圾回收掉。


问题来了,如何找到这些gc roots呢?
从源代码上看,对象引用不是在类中,就是在方法中,如此,通过扫描所有的对象就可以获取到这些gc roots。但是目前随便一个Java应用相当庞大(低情商叫臃肿),内存中的类,对象,常量数不胜数,每次gc都去扫描一遍,这个性能损耗是不可能接受的,而这只是其一。其二为了保证内存的一致性,获取这些gc roots过程中,必须暂停用户线程。用户线程在这个阶段内不能工作,所以能做的就是尽量要缩短用户线程的停顿时间,也就是要尽快完成gc roots的扫描。惯用套路,既然后期处理遍历耗时,那就前期维护一套数据结构呗,所谓的空间换时间。而OopMap就是这套数据结构,通过OopMap提前记录类、方法的引用信息,查找gc roots时,直接通过OopMap去获取,而不必扫描整个对象。

  1. 首先对于一个类在加载进内存的时候,空间是“确定的”,即结构是确定的,比如定义了哪些变量,哪些引用,而且一定是连续内存,所以对象中的引用是可以通过地址偏移量计算得到的,所以把这个偏移量放在OopMap中,需要的时候OopMap去找就可以了
  2. 一个线程在运行过程中,有自己的栈空间,每一个方法都是一个栈帧,即时编译过程中会在特定位置记录下栈中和寄存器里哪些位置是引用。
public void test()
{
	Person person = new Person();
	person.setPhone(new Phone());
	Dog dog = new Dog();
	//...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

一个栈帧可以有多个OopMap,这里假设一个栈帧只有一个,且记录状态是在方法返回之前,如下:
图示参考来自:https://zhuanlan.zhihu.com/p/441867302

二、安全点(safe point)

虽然OopMap避免了大量扫描内存的消耗,但是内存中对象繁多,对象之间的引用关系也时刻在发生变化,如果每条指令都去记录OopMap,将会消耗大量内存和cpu资源,垃圾回收反而变成了系统的负担,为了解决这个问题,引入了安全点(safe point)的概念。即只在指令流的特定位置记录OopMap,垃圾回收行为发生后,线程如果没有到达安全点,将继续执行,直到到达最近的一个安全点才停下来,等待垃圾回收器完成gc roots的选取。

就像公交车一样,每个乘客到达的地点是不同的,但公交车不会为每一个人去停车,必须等到提前设定的站台才会停下,这个时候乘客才可以下车。

当线程到达安全点后,有两种方式中断线程:

  1. 抢占式中断,在发生垃圾回收时,系统将所有线程中断,如果发现有线程还没有到达安全点,则恢复该线程的执行,等待一会儿继续中断,直到到达安全点上,目前几乎没有虚拟机采用这种方案去停止用户线程
  2. 主动式中断,在特定位置设置一个中断标志位,所有线程在执行过程中不断去轮询这个标志位,如果发现该标志位被置位,就在距离自己最近的安全点主动挂起自己,等待垃圾回收器工作

轮询标志的地方和安全点是重合的,还需要加上所有创建对象和其他需要在对上分配空间的地方,这是为了检查是否要发生gc,避免没有足够的内存分配对象

适合插入安全点的地方:

  • 方法(栈帧)结束前,但并不意味着一个方法只能有一个安全点
  • 非计数循环末尾,避免循环体执行时间太长,导致长时间无法到达安全点
  • 每条Java编译后的字节码边界

根据以上的概括,我们能总结出一个点,安全点既不能太多,也不能太少,如果安全点过多,会对虚拟机资源产生更多的挤压,如果安全点太少,则会导致垃圾回收器等待时间过长,因此,需要在这两者之间取其平衡。

三、安全区(safe region)

安全区域可以理解是对安全点的存在问题的补充,上边说到线程会执行到附近的安全点停下来等待垃圾回收器介入处理,但如果线程没有执行呢,换句话就是说没有获得cpu执行权,比如某一个线程正在sleep或者等待磁盘输入,那么这个线程是不会走到安全点挂起自己的。
这个时候就要引入安全区概念了,顾名思义,安全区就是一段指令域,在这个域中的指令不会对当前内存中的引用造成修改,当线程进入该区域后,会主动将自己的状态标记为“进入安全区”,这个时候如果发生gc,垃圾回收器发现该线程处于安全区域内,认为该线程不会对内存安全造成影响,便会跳过该线程,不会等待该线程到达安全点。
而线程在到达安全区边界时,同样也会检查当前gc是否在工作,如果gc正在工作,这个时候线程便会主动停下来,等待gc动作完成后再继续执行。

四、卡表(card table)

为了解决在垃圾回收算法中,无论哪种算法,都需要先对对象进行标记,然后再进行回收操作。标记过程中,存在跨代引用问题,为了完整的标记对象引用链,将不得不对跨代内存中的对象进行遍历,尤其是老年代对象,对象存活率相对高,遍历的性价比极低。于是就引入了记忆集的概念,即将老年代内存划分为若干个小块,同时在新生代特定位置维护一块数据区域用来标记老年代中的哪个小块内存中存在跨代引用,当发生gc时,只需要检查这块数据区域中哪些老年代内存块中存在跨代引用,然后再对这一小块内存进行遍历。
这个过程相当于将老年代整个内存的搜索粒度降低了,从搜索整个老年代到“只搜索其中的某几小块”。
此外,老年代中内存小块具体设置多大,也需要综合考虑,如果设置太小,虽然粒度更低,精度更高,遍历单个块效率会提高,但分块会变多,同时也需要更多新生代空间去维护这个分块信息;如果划分的太大,则会降低老年代搜索效率。
以下是记忆集可供选择的记录精度:

  • 字长精度:每个记录精确到机器字长,64位机下就是8字节空间,该区域存在跨代指针
  • 对象精度:每个记录精确到一个对象,该区域存在跨代指针
  • 卡精度:每个记录精确到一块内存区域,该区域存在跨代指针

而card table可以理解为是Java虚拟机对记忆集的一种实现,hotspot用一个字节数组完成了卡表的记录,字节数组的每一个元素都标识老年代内存区域中的一段特定长度的区域,这个区域称为卡页。

CARD_TABLE[(this address >> 9)] = 0;
//根据以上的代码可以看出一个卡页的大小是512byte,即2^9 。
//将内存块地址右移9位,即除以512,得到的余数正好对应该内存区域对应的卡表标号
  • 1
  • 2
  • 3

在这里插入图片描述
只要卡页中存在跨引用对象,则将对应的卡表标识位置位为1,称之为变脏,发生gc时,只需要检查卡表变脏的元素,就能很快定位到哪一块内存中存在跨代指针。

五、写屏障(write barier)

通过记忆集减少了跨代指针扫描的问题,那卡表示如何维护的呢?答案就是写屏障(write barier),
首先卡表变脏肯定发生在引用赋值那一刻,写屏障可以理解成在指定的操作前后设置一组“保护措施”,可以理解成Spring框架中的AOP,在引用赋值时会产生一个环形通知,供程序执行额外的动作。在赋值前的部分叫做写前屏障,在赋值后的部分叫做写后屏障。除了G1收集器,其他的收集器都只用到了写后屏障。
应用写屏障后,虚拟机就会为所有的赋值操作生成相应的指令,这意味着所有的引用赋值都会增加更新卡表的操作,对产生一定的开销浪费,但是相比扫描整个老年代代价要小得多。

一个伪共享问题:
众所周知,cpu内部存在高速缓存,缓存中的存储单位是以缓存行为单位的,也就是说一个缓存行可以存储多个数据单位,而这些数据可能来自于不同线程,都知道不同线程的变量是相互隔离的,并不会互相影响。但是在并发场景下,缓存中的数据行尽管是来自不同线程的数据,但是由于存储单位是一个缓存行,此时线程不得不进行同步、或者通过cas算法以达到“有序执行”,从而降低了性能。本来数据实际上是相互独立、相互隔离的,但是在这里线程却认为访问的是共享空间,故名“伪共享”。

同样应用写屏障面临这个问题,如果卡表元素被缓存在一个缓存行中,多个线程对该卡表的更新操作就会出现“伪共享”,为了避免该问题,当内存对象引用发生变化需要更新卡表时,先判断一下该对象所对应的卡表是否已经变脏,如果没有变脏,再进行更新,避免多次重复标记导致的线程同步。
JDK7之后增加了新参数 -XX:+UseCondCardMark用来决定是否开启卡表更新的条件判断

总结

看过的东西总是容易忘记… ,就重新对垃圾回收算法其中的一些理解性概念进行了简单的记录和总结,同时也为后续的文章笔记做些一些铺垫,能力有限,如有问题,还望看官们指正~

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/261004
推荐阅读
相关标签
  

闽ICP备14008679号