美文网首页Java 杂谈
你必须了解的java内存管理机制(三)-垃圾标记

你必须了解的java内存管理机制(三)-垃圾标记

作者: Java程序员笔记 | 来源:发表于2019-08-13 21:35 被阅读1次

    1、怎么找到存活对象?

    通过上篇文章我们知道,JVM创建对象时会通过某种方式从内存中划分一块区域进行分配。那么当我们服务器源源不断的接收请求的时候,就会频繁的需要进行内存分配的操作,但是我们服务器的内存确是非常有限的呢!所以对不再使用的内存进行回收再利用就成了JVM肩负的重任了! 那么,摆在JVM面前的问题来了,怎么判断哪些内存不再使用了?怎么合理、高效的进行回收操作?既然要回收,那第一步就是要找到需要回收的对象!

    1.1、引用计数法

    实现思路:给对象添加一个引用计数器,每当有一个地方引用它,计数器加1。当引用失效,计数器值减1。任何时刻计数器值为0,则认为对象是不再被使用的。举个小栗子,我们有一个People的类,People类有id和bestFriend的属性。我们用People类来造两个小人:

    People p1 = new People();
    People p2 = new People();
    

    通过上篇文章的知识我们知道,当方法执行的时候,方法的局部变量表和堆的关系应该是如下图的(注意堆中对象头中红色括号内的数字,就是引用计数器,这里只是举栗,实际实现可能会有差异):

    造出来的p1和p2两个人,我想让他们互为最好的朋友,于是代码如下:

    People p1 = new People();
    People p2 = new People();
    p1.setBestFriend(p2);
    p2.setBestFriend(p1);
    

    对应的引用关系图应该如下(注意引用计数器值的变化):

    然后我们再做一些处理,去除变量和堆中对象的引用关系。

    People p1 = new People();
    People p2 = new People();
    p1.setBestFriend(p2);
    p2.setBestFriend(p1);
    p1 = null;
    p2 = null;
    
    

    这时候引用关系图就变成如下了,由于p1和p2对象还相互引用着,所以引用计数器的值还为1。

    优点:实现简单,效率高。
    缺点:很难解决对象之间的相互循环引用。且开销较大,频繁的引用变化会带来大量的额外运算。在谈实现思路的时候有这样一句话“任何时刻计数器值为0,则认为对象是不再被使用的”。但是通过上面的例子我们可以看到,虽然对象已经不再使用了,但计数器的值仍然是1,所以这两个对象不会被标记为垃圾。
    现状:主流的JVM都没有选用引用计数法来管理内存。

    1.2、可达性分析

    实现思路:通过GC Roots的对象作为起始点,从这些节点向下搜索,搜索走过的路径成为引用链,当一个对象到GC Root没有任何引用链相连时,则证明对象是不可用的。如下图,红色的几个对象由于没有跟GC Root没有任何引用链相连,所以会进行标记。

    优点:可以很好的解决对象相互循环引用的问题。
    缺点:实现比较复杂;需要分析大量数据,消耗大量时间;
    现状:主流的JVM(如HotSpot)都选用可达性分析来管理内存。

    2、标记死亡对象

    通过可达性分析可以对需要回收的对象进行标记,是否标记的对象一定会被回收呢?并不是呢!要真正宣告一个对象的死亡,至少要经历两次的标记过程!

    2.1、第一次标记

    在可达性分析后发现到GC Roots没有任何引用链相连时,被第一次标记。并且判断此对象是否必要执行finalize()方法!如果对象没有覆盖finalize()方法或者finalize()已经被JVM调用过,则这个对象就会认为是垃圾,可以回收。对于覆盖了finalize()方法,且finalize()方法没有被JVM调用过时,对象会被放入一个成为F-Queue的队列中,等待着被触发调用对象的finalize()方法。

    2.2、第二次标记

    执行完第一次的标记后,GC将对F-Queue队列中的对象进行第二次小规模标记。也就是执行对象的finalize()方法!如果对象在其finalize()方法中重新与引用链上任何一个对象建立关联,第二次标记时会将其移出"即将回收"的集合。如果对象没有,也可以认为对象已死,可以回收了。

    finalize()方法是被第一次标记对象的逃脱死亡的最后一次机会。在jvm中,一个对象的finalize()方法只会被系统调用一次,经过finalize()方法逃脱死亡的对象,第二次不会再调用。由于该方法是在对象进行回收的时候调用,所以可以在该方法中实现资源关闭的操作。但是,由于该方法执行的时间是不确定的,甚至,在java程序不正常退出的情况下该方法都不一定会执行!所以在正常情况下,尽量避免使用!如果需要"释放资源",可以定义显式的终止方法,并在"try-catch-finally"的finally{}块中保证及时调用,如File相关类的close()方法。下面我们看一个在finalize中逃脱死亡的栗子吧:

    public class GCDemo {
        public static GCDemo gcDemo = null;
        public static void main(String[] args) throws InterruptedException {
            gcDemo = new GCDemo();
            System.out.println("------------对象刚创建------------");
            if (gcDemo != null) {
                System.out.println("我还活得好好的!");
            } else {
                System.out.println("我死了!");
            }
            gcDemo = null;
            System.gc();
            System.out.println("------------对象第一次被回收后------------");
            Thread.sleep(500);
            // 由于finalize方法的调用时间不确定(F-Queue线程调用),所以休眠一会儿确保方法完成调用
            if (gcDemo != null) {
                System.out.println("我还活得好好的!");
            } else {
                System.out.println("我死了!");
            }
            gcDemo = null;
            System.gc();
            System.out.println("------------对象第二次被回收后------------");
            Thread.sleep(500);
            if (gcDemo != null) {
                System.out.println("我还活得好好的!");
            } else {
                System.out.println("我死了!");
            }
            // 后面无论多少次GC都不会再执行对象的finalize方法
        }
        @Override
            protected void finalize() throws Throwable {
            super.finalize();
            System.out.println("execute method finalize()");
            gcDemo = this;
        }
    }
    
    

    执行结果如下,具体就不多说啦,不明白的就自己动手去试试吧!

    3、枚举根节点

    通过上面可达性分析我们了解了有哪些GC Root,了解了通过这些GC Root去搜寻并标记对象是生存还是死亡的思路。但是具体的实现就是那张图显示的那么简单吗?当然不是,因为我们的堆是分代收集的,那GC Root连接的对象可能在新生代,也可能在老年代,新生代的对象可能会引用老年代的对象,老年代的对象也可能引用新生代。如果直接通过GC Root去搜寻,则每次都会遍历整个堆,那分代收集就没法实现了呢!并且,枚举整个根节点的时候是需要线程停顿的(保证一致性,不能出现正在枚举 GC Roots,而程序还在跑的情况,这会导致 GC Roots 不断变化,产生数据不一致导致统计不准确的情况),而枚举根节点又比较耗时,这在大并发高访问量情况下,分分钟就会导致系统瘫痪!啥意思呢,下面一张图感受一下:

    如果是进行根节点枚举,我们先要全栈扫描,找到变量表中存放为reference类型的变量,然后找到堆中对应的对象,最后遍历对象的数据(如属性等),找到对象数据中存放为指向其他reference的对象……这样的开销无疑是非常大的!

    为解决上述问题,HotSpot 采用了一种 “准确式GC” 的技术,该技术主要功能就是让虚拟机可以准确的知道内存中某个位置的数据类型是什么,比如某个内存位置到底是一个整型的变量,还是对某个对象的reference,这样在进行 GC Roots枚举时,只需要枚举reference类型的即可。那怎么让虚拟机准确的知道哪些位置存在的是reference类型数据呢?OopMap+RememberedSet!

    OopMap记录了栈上本地变量到堆上对象的引用关系,在GC发生时,线程会运行到最近的一个安全点停下来,然后更新自己的OopMap,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的OopMap,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。这样,OopMap就避免了全栈扫描,加快枚举根节点的速度。

    OopMap解决了枚举根节点耗时的问题,但是分代收集的问题依然存在!这时候就需要另一利器了- RememberedSet。对于位于不同年代对象之间的引用关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来,这就是RememberedSet!所以“新生代的 GC Roots ” + “ RememberedSet存储的内容”,才是新生代收集时真正的GC Roots(G1 收集器也使用了 RememberedSet 这种技术)。

    3.1、安全点

    HotSpot在OopMap的帮助下可以快速且准确的完成GC Roots枚举,但是在运行过程中,非常多的指令都会导致引用关系变化,如果为这些指令都生成对应的OopMap,需要的空间成本太高。所以只在特定的位置记录OopMap引用关系,这些位置称为安全点(Safepoint)。如何在GC发生时让所有线程(不包括JNI线程)运行到其所在最近的安全点上再停顿下来?这里有两种方案:

    1、抢先式中断:不需要线程的执行代码去主动配合,当发生GC时,先强制中断所有线程,然后如果发现某些线程未处于安全点,那么将其唤醒,直至其到达安全点再次将其中断。这样一直等待所有线程都在安全点后开始GC。

    2、主动式中断:不强制中断线程,只是简单地设置一个中断标记,各个线程在执行时主动轮询这个标记,一旦发现标记被改变(出现中断标记)时,就将自己中断挂起。目前所有商用虚拟机全部采用主动式中断。

    安全点既不能太少,以至于 GC 过程等待程序到达安全点的时间过长,也不能太多,以至于 GC 过程带来的成本过高。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点(在主动式中断中,轮询标志的地方和安全点是重合的,所以线程在遇到这些指令时都会去轮询中断标志!)。

    3.2、安全区域

    使用安全点似乎已经完美解决如何进入GC的问题了,但是GC发生的时候,某个线程正在睡觉(sleep),无法响应JVM的中断请求,这时候线程一旦醒来就会继续执行了,这会导致引用关系发生变化呢!所以需要安全区域的思路来解决这个问题。线程执行进入安全区域,首先标识自己已经进入安全区域。线程被唤醒离开安全区域时,其需要检查系统是否已经完成根节点枚举(或整个GC)。如果已经完成,就继续执行,否则必须等待,直到收到可以安全离开Safe Region的信号通知!

    正文到此结束

    相关文章

      网友评论

        本文标题:你必须了解的java内存管理机制(三)-垃圾标记

        本文链接:https://www.haomeiwen.com/subject/wtdijctx.html