聊聊JVM(八)说说GC标记阶段的一些事

11 篇文章 8 订阅
10 篇文章 178 订阅

这篇说说GC标记阶段的一些事情,尝试把一些概念说清楚。本人不是研究JVM实现的,如果表述有问题请查看参考资料进一步学习,推荐高级语言虚拟机圈子 ,里面有很多好的文章值得一看。


GC最简单的理解就是先把live的对象标记出来,然后把没有标记到的对象清除掉。那么就有几个问题:

1. 什么是活的对象?

2. 如何标记

3. 如何清除


先简单说一下清除,清除的方法常见有三种:

1. 复制

    优点:直接按照顺序复制内容即可,只需要移动指针,老的区域可以直接全部被重用

    缺点:需要一块额外的区域专门用来做复制,空间利用率降低了

2. 清除Sweep,就是直接把要清除的内存区域标记为清除,这样新的对象可以在这些被逻辑清除的地方分配

   优点:不需要移动内存,直接标记即可

   缺点:造成大量内存碎片。给对象分配内存空间时,看的是是否有连续可用的内存空间,而不是全部的内存空间,造成内存空间总量很大,却不得不GC

3. 压缩Compact, 就是把要保留的对象移动到内存的一端,把剩下的另外一部分就标记为清除

   优点:每次压缩后剩下的可用空间都是连续的

   缺点:需要大量的内存复制操作


现在回到标记的话题,标记就是为了解决1,2这两个问题,确定活的对象,把他们全部标记出来。

JVM里面定义一个对象是否是活的采用的是GC root tracing(根搜索)方法,看的是reachability可达到性。所以标记的第一步就是得先获得GC roots。

GC roots是下面这些数据的集合:

  • JVM栈的栈帧中的局部变量表里的有效的局部变量(局部变量有作用域)所引用的对象
  • 方法区里面的类元素对象的static引用所引用的对象
  • 方法区里面的类元素对象的常量引用所引用的对象
  • 本地方法栈里的引用所引用的对象


其实很好理解GC roots,它们要么就是活着的线程产生的JVM栈的栈帧Frame中正在被直接使用的对象(可以通过有效的局部变量获得),要么就是JVM本身需要它们长期驻留的,比如方法区里面的类的static/final数据,它们有两个特点:

1. 语义上是活的,还有用的

2. 可以方便获取的


GC roots必须可以方便获取,这样GC的标记才能从这些可以快速获取的GC roots开始,对GC roots能到达的对象图进行标记。否则还要花很多代价才能找到标记的输入点。最初的虚拟机很多采用保守式GC,不记录这些信息,实现简单但是效率低。现在主流的虚拟机都采用准确式的GC,尽量早和方便地收集这些信息,加快整个标记的速度。


方法区的类对象的静态数据和final数据作为GC roots很好理解,它们要长期存在,并且在类加载的时候就可以确定内存位置

获取GC roots最主要的部分在解决如何快速找到JVM栈的栈帧的局部变量表中的局部变量所引用的对象


找出栈上的指针/引用 这篇文章很好地解释了这个问题。大致的思路是JVM采用了OopMap这个数据结构记录了GC roots,GC的标记开始的时候,直接从OopMap就可以获得GC roots。OopMap记录了特定时刻栈上(内存)和寄存器(CPU)的哪些位置是引用,通过这些引用就可以找到堆中的对象,这些对象就是GC roots. 而不需要一个一个的去判断某个内存位置的值是不是引用。


关于OopMap,可以考虑三个问题:

1. OopMap何时收集数据?

在线程运行到特定位置 safepoint时,OopMap会收集数据,也就是运行到了safepoint的时候,是一个相对稳定的状态的,可以来做snapshot。更多关于safepoint的信息看这篇:聊聊JVM(六)理解JVM的safepoint

每个被JIT编译过后的方法运行到了一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。这些特定的位置被称为safepoint, 主要在:
1、循环的末尾
2、方法临返回前 / 调用方法的call指令后
3、可能抛异常的位置
之所以要选择一些特定的位置来记录OopMap,是因为如果对每条指令(的位置)都记录OopMap的话,这些记录就会比较大,那么空间开销会显得不值得。选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的


2. OopMap如何采集数据?

在JIT编译模式下,Java代码被编译后就确定了OopMap如何采集,它会插入相应的机器码在safepoint的时候做这个工作。下面的代码来自内存篇:JVM内存回收理论与实现

 [Verified Entry Point]  
    0x026eb730: mov    %eax,-0x8000(%esp)  
    …………  
    ;; ImplicitNullCheckStub slow case  
    0x026eb7a9: call   0x026e83e0         ; OopMap{ebx=Oop [16]=Oop off=142}  
                                            ;*caload  
                                            ; - java.lang.String::hashCode@48 (line 1489)  
                                            ;   {runtime_call}  
      0x026eb7ae: push   $0x83c5c18         ;   {external_word}  
      0x026eb7b3: call   0x026eb7b8  
      0x026eb7b8: pusha    
      0x026eb7b9: call   0x0822bec0         ;   {runtime_call}  
      0x026eb7be: hlt      


可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个oop的引用,这个OopMap和offset  142处的指令关联,从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令。换句话说执行到hlt指令时的OopMap状态是ebx和[rsp+16]的内存位置上有oop。

call 0x026e83e0这个指令就是调用一个方法,OopMap出现在这个位置,也就是call之后,我们说了方法的call之后是一个safepoint,用OopMap记录一下寄存器和JVM栈内存的哪些位置是引用。

更多OopMap的off是什么含义可以看这篇: 请教一下一个关于OopMap的问题  所谓的OopMap和某条指令关联,意思是执行这个指令时可以看到的OopMap的状态


3. 什么是一个良好实现的OopMap?

OopMap里的数据直接关系到GC roots,也就是会被标记为活的对象的数据,所以OopMap里的数据不能少,因为少了之后就会把活的对象判别为死了。OopMap已标记的数据也不能出错,比如这篇文章里面描述的JVM的一个BugJust fixed a 20-year-old bug… 这篇文章中描述了发现的一个Stale Oop的问题。他还描述了一下OopMap:

OopMap - An OOP map is a list of machine registers holding OOPs at a particular point in the code.  If a GC cycle needs to happen while the program is stopped at a Safepoint, GC will consult the OopMap to find all the pointers needing updating.


有了OopMap就可以快速获得GC roots,接着就可以开始标记了。标记的基本思路就是遍历一个有向图,节点是对象,边是引用。不同的垃圾收集器实现的标记算法也不一样,可以参考这篇[讨论] HotSpot VM Serial GC的一个问题, 里面讨论了Serial GC收集器如何标记的,它采用了Cheney算法的变种。


标记实际上是GC最重要的一个事情,因为只有标记成功了,就相当于确定了那些对象是活的,另外的对象就是死的。那么接下来的清除工作就可以不用stop the world,可以并发地去清除死的对象了。所以标记的时候大部分是需要stop the world的。


这篇里面说过聊聊JVM(四)深入理解Major GC, Full GC, CMS CMS在initial mark和 remark这两个阶段需要stop the world,Full GC的计数器被加了2次。

  • 可以理解initial mark是必须要stop the world的,目的是为了获得堆中数据某个阶段的snapshot时的GC roots,我们上面说了GC roots是可以快速获取的,所以initial mark阶段很短。
  • 接下来concurrent mark就是以initial mark获取的GC roots为起点,并发地去遍历一个有向图,这时候是不需要stop the world的,因为GC roots是确定的。
  • 接下来的remark又要stop the world,它是为了修复一下在之前没有stop the world的时候可能对GC roots和相关对象做的修改造成的影响,也很快。
  • 这样CMS的标记阶段就完成了。清除的时候就不需要stop the world了,可以并发去清理那些死的,根本不会被引用到的对象了。


再看看一下Card Table卡表对GC标记的影响,在这篇聊聊JVM(一)相对全面的GC总结 中说了一下Card Marking的大致概念,就是老年代的内存空间划分成了一个卡表,每一个卡表项大小默认是512Byte。如果一个卡表项中的老年代对象引用了新生代中的对象,那么这个卡表项就被标记成了脏 dirty,这样在新生代做GC的时候,不需要对整个老年代进行遍历,只需要对dirty card中的老年代对象进行扫描,这些对象对新生代GC来说,就相当于GC roots,它们可到达的新生代对象也是活的,也需要标记。


最后再来说一下把手动设置成Nul到底起不起作用的问题,看这篇的讨论 把引用设成null真的对GC有帮助吗? 

我们知道局部变量是有作用域的,这个作用域在编译的时候就确定了。在JIT在做优化的时候,都会做liveness analysis,如果一些局部变量的引用只是在前面使用了,后面没有使用,那么代码执行到后面的时候,其实前面的这些局部变量的引用的对象实际已经死了,那么这时候的OopMap是不需要再记录之前的局部变量的引用的。比如这个例子:

    class Test {  
        public int foo(int a) {  
            Object obj = new Object();  
            while(a > 0){  
                a --;  
            }  
            return a;  
        }  
      
        public static void main(String[] args) {  
            new Test().foo(10);  
        }  
    }  

obj这个引用在执行到while的时候实际上已经没用了,可以判定对象已死了,被JIT编译器优化之后的代码相当于下面的

public int foo(int a) {  
    new Object(); // obj变量直接就没了,反正后面也没用  
    while(a > 0){  
        a --;  
    }  
    return a;  
}

实例生成的汇编码也确实指明了while的时候的OopMap里面是有没有活的引用的

0x01b4b774: dec %edx ; <strong>OopMap{off=101}</strong>
;*goto
; - Test::foo@15 (line 9)
0x01b4b775: test %eax,0x180100 ;*goto
; - Test::foo@15 (line 9)
; {poll}
;; block B1 [8, 9]

所以在JIT模式下,编译器会计算局部变量的实际作用域,对这些局部变量做liveness analysis, 把这些无意义的set null语句给优化掉。


参考资料:

找出栈上的指针/引用

内存篇:JVM内存回收理论与实现

Just fixed a 20-year-old bug…

[讨论] HotSpot VM Serial GC的一个问题


  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值