JVM学习(2)垃圾回收

参考书《深入理解Java虚拟机》

1. 简介

  在上篇博客(JVM学习(1) Java内存区域与内存溢出异常)中介绍过,由于程序计数器、虚拟机栈、本地方法栈都是线程私有的区域,生命周期和线程保持一致,所以当方法结束或者线程结束的时候,内存自然也就跟着回收了,所以也就不存在内存回收的问题。

  但是Java堆和方法区这两个区域,是所有线程共享的部分,每一个线程会创建多少个对象,需要多少内存都是无法确定的,所以这部分内存也就是垃圾回收器所关注的地方。

  垃圾回收器所关注的本质其实就是:内存的回收与分配。


2. 什么是“垃圾”

  垃圾回收器要回收的垃圾,那么就需要先弄明白什么是垃圾。垃圾指的是“不可能再被任何途径使用的对象”。

2.1 引用计数法

  判断对象是否是垃圾,最简单的方法就是引用计数法,基本思路:在对象中添加一个引用计数器,没当有一个地方引用它的时候,计数器值就加1;当引用失效的时候,计数器值就减1。那么当一个对象的引用计数器为0的时候,也就说明这个对象是不可能再被任何途径使用的对象,也就成为了“垃圾”。

  这种方法原理简单,判断效率也很高,但是也存在一个巨大的缺陷:循环引用问题。代码示例如下:

public class TestMain {

    private TestMain newRef = null;

    public static void main(String[] args) {
        TestMain a = new TestMain();
        TestMain b = new TestMain();
        a.newRef = b;
        b.newRef = a;

        a = null;
        b = null;
    }
}

  我们能够发现,实际上运行完了之后这两个对象是不可能再被任何途径访问到的,也就变成了所谓的垃圾,但是他们却因为互相引用着,导致引用计数器不为0,也就因此垃圾回收器会认为他们是存活的,而无法回收。

  以上代码在Java上是会出现回收的,因为主流的JVM都没有选择使用引用计数法。


2.2 可达性分析算法

  Java采用的就是可达性分析算法来判定对象是否存活的,基本思路:通过一系列的GC Roots 的根对象作为起始点集合,通过引用关系向下搜索,搜索的路径就成为“引用链”,如果一个对象到GC Roots集合间没有任何引用链存在,也就是说无法从任何一个GC Root走到该对象,那么这个对象就是不可能再被使用的。

  固定可作为GC Root的对象包括以下几种:

   1) 虚拟机栈中(栈帧的本地变量表)引用的对象。比如各个线程被调用的方法中的参数、局部变量等

   2) 方法区中的类静态引用的对象。比如说类的静态变量

   3) 方法区中常量引用的对象。比如说字符串常量池里面的那些引用

   4) 在JNI(Native方法)引用的对象

   5) JVM内部的引用。比如基本数据类型对应的Class对象,系统类加载器,常驻的异常对象(NPE、OOM)等

   6) 所有同步锁持有的对象(Synchronized关键字持有的对象)

  除了这些固定可以作为的GC Root的对象以外,还可以有其他对象临时性加入,比如分代收集和局部收集时,会存在这种情况在Java堆中的某些对象可能被堆中其他区域的对象所引用,那么就必须将这个关联区域的对象也临时加入到GC Root集合中,才能保证回收的正确性(比如新生代对象被老年代对象所引用,那么我们新生代回收的时候就必须将老年代的这个对象临时加入到GC Root集合中)


2.3 什么是引用

  在判断对象是否存活的时候,反复提到的就是一个词:引用。传统的定义:如果reference类型的数据中存储的数值是另一块内存的地址,那么这个reference数据就是某个对象的引用。

  但是JDK1.2 之后,Java对于这个概念进行了扩充,将引用分为四大类:

    1) 强引用,也就是传统意义上的引用(如 Object obj = new Object() 。只要这个对象存在着强引用,那么在任何情况都不会被垃圾回收器回收。

   2) 软引用,一些还有用,但是非必须的对象。存在这种情况:如果内存足够的时候,我们希望能够保留这些对象,只有在内存空间不足的情况下,才回收这些对象。软引用就是在将OOM之前,会吧这些对象列入回收返回,如果回收后还是内存不足,才会爆OOM错误。可以通过SoftReference来实现。

SoftReference<String> s = new SoftReference<>("hello");

   3) 弱引用,一些非必须对象。当垃圾回收器开始的时候,无论内存是否足够,都会直接回收掉。可以通过WeakReference类实现。

   4) 虚引用,无法通过虚引用来取得对象实例,同样这个对象是否存活也不受到虚引用的影响,唯一的作用就是,这个对象被回收的时候会收到一个系统通知。可以通过PhantomReference类实现。


2.4 finalize()方法(不推荐使用,try-fianlly可以实现得更好)

  真正判断一个对象死亡是至少需要经过两次标记:第一次就是对象再进行可达性分析算法的时候,与任何一个GC Root之间都不存在引用链,那么此时就会进行第一次标记;第二次就是对象是否覆写了finalize()方法,或者对象的finalize()是否已经被调用过。

  如果对象覆写了finalize()方法,而且该方法从未被JVM调用过,那么对象会被添加到F-Queue队列中,并且JVM会创建一条优先级低的线程去执行他们的finalize()方法。但是只是执行而已,并不保证能够执行完。这是为了防止finalize()方法造成整个系统等待,甚至崩溃。这里可以理解为:一个死缓的犯人,给你机会去改造(finalize()),但是为了防止你再次造成破坏,你只有在执行死刑前表现好(重新与引用链上任何一个对象建立关系),才能解除死刑,否则时间一到,你改造没完成也一样执行死刑。

  finalize()只会被JVM自动调用一次,也很好理解,只能接受一次改造,再改不好,就拉去回收了。

  示例代码:

public class TestMain {

    private static TestMain A = null;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize() Method");
        TestMain.A = this;
    }

    public static void main(String[] args) throws Exception {
        A = new TestMain();

        A = null;
        //进行垃圾回收
        System.gc();
        //执行finalize()方法的线程的优先级较低,需要等待,否则不一定执行完
        Thread.sleep(1000);
        if(A != null){
            System.out.println("I'm OK");
        }
        else {
            System.out.println("I'm Garbage");
        }
        
        //只会给一次机会,第二次调用就无效了
        A = null;
        System.gc();
        Thread.sleep(1000);
        if(A != null){
            System.out.println("I'm OK");
        }
        else {
            System.out.println("I'm Garbage");
        }
    }
}

  运行结果:
在这里插入图片描述


2.5 方法区回收

  对于方法区(元空间,永久代)的回收,性价比是比较低的,因为方法区回收的条件相对而言比较苛刻。因此,《Java虚拟机规范》并未要求虚拟机必须在方法区中实现垃圾回收。

  方法区垃圾回收主要是两部分内容:废弃的常量和不再使用的类型。

  废弃的常量,比如字符串“java”入池了,但是系统中又没有任何一个字符串对象的值是“java”,且不存在引用,那么这个字符串常量就会移出常量池。

  不再使用的类型,判定条件比较苛刻:

   1) 该类的所有实例已经被回收,即Java堆中不存在任何该类及其子类的实例对象

   2) 加载该类的类加载器已经被回收(通常情况下很难做到)

   3) 该类的java.lang.Class对象没有在任何地方被引用,即没有在任何地方通过反射得到该对象


3. 垃圾收集算法

  根据第二部分所说的两种判断对象是否存活的算法,垃圾收集算法可以划分为两类:引用计数式垃圾收集算法(Reference Counting GC),追踪式垃圾收集算法(Tracing GC)。也称为“直接垃圾收集算法”和“间接垃圾收集算法”。

   已经提到过,主流的Java虚拟机并不存在使用引用计数法来判断对象存活,因此,Java主流虚拟机使用的都是追踪式垃圾收集算法。


3.1 分代收集理论

  大部分主流的垃圾收集器,都遵循了“分代收集”这一理论模型,遵循一致的设计原则:收集器应该将Java堆划分为不同的区域,然后将收集对象根据其年龄(即对象经历过几次垃圾收集,经历过一次,年龄加1,这个年龄通常存储在对象头之中)分配到不同的区域之中存储。

  将Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,也因此才有了常说的“Minor GC”,“Major GC”, “Full GC”

部分收集(Partial GC):收集范围并非完整的Java堆

  ● 新生代收集(Minor GC / Young GC):收集范围是新生代
  ● 老年代收集(Major GC / Old GC):收集范围是老年代
  ● 混合收集(Mixed GC):收集范围整个新生代以及部分老年代,目前只有G1收集器存在这种行为。
  
整堆收集(Full GC):收集范围是整个堆和方法区

   分代收集理论模型映射到JVM中,设计者一般将Java堆至少分为2个部分:新生代、老年代。但是我们会发现,分代收集其实存在有一个很麻烦的地方,Minor GC只针对于新生代收集,Major GC值针对于老年代收集,那么跨代引用怎么办?

  对象是很灵活的一种东西,很可能就存在新生代中的对象被老年代所引用的情况,那么为了保证正确的找出新生代的存活对象,不得不在固定的GC Roots 以外,还需要额外遍历整个老年代的所有对象,这就会造成巨大的额外负担。

  当然,跨代引用这种情况属于少数情况。因此解决方法就是,在新生代上建立一个全局的数据结构——记忆集(Remembered Set)整个结构将整个老年代划分成若干个小块,标识出老年代的哪一块内存存在跨代引用,发生Minor GC的时候,只有处于这些被标识的小块内存中的对象才会被临时加入到GC Root集合中。

3.2 标记-清除算法(Mark-Sweep)

  和名字一样,整个算法分成了两个阶段:标记阶段和清除阶段。基本思路:首先标记处所有需要回收的对象,标记过程也就是垃圾判定过程;在标记完成之后,统一回收所有被标记的对象。(当然也可以反过来,标记存活对象,然后回收未被标记的)

  缺点:一是执行效率不稳定,标记和清除阶段的执行效率完全是和对象的数量挂钩的,对象越多,可能需要回收的也就越多,那么两个阶段的耗时也就越长。二是内存碎片问题,标记清除之后会产生大量的内存碎片,这会导致以后的程序运行时,如果需要分配较大的对象时无法得到足够的连续内存,这会提前触发新一次的垃圾回收操作。

  算法执行过程:
在这里插入图片描述

3.3 复制算法

  为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,复制算法将可用的内存按照容量划分为大小相等的两块,每次只使用其中一块,一块内存使用完了,就将还存活着的对象复制到另外一块上,然后清空原本的那块。这样针对于大量要回收的对象的情况,我们就只需要复制少量存活对象即可。但是缺点也在于让我们的可用内存缩小成了原来的一半,导致空间浪费较大。

  也正是因为这种情况,复制算法非常适合于对象朝生夕灭的新生代,现有的大部分JVM都是优先采用这种算法来回收新生代。

  为了解决空间浪费较大的问题,将新生划分成为三块:一块较大的Eden区,两个较小的大小相等的Survivor区。HotSpot默认的Eden和Survivor区比例为8 : 1 : 1。每次分配内存只会分配到Eden和一块Survivor区(这块Survivor区也称为:From Survivor)上面,然后垃圾收集的时候,将Eden和这块Survivor区上的存活对象复制到另外一块Survivor(To Survivor)。注意From Survivor和To Survivor是会不断交换的,空的那块就是To,也就是存放存活对象的那块。

  为了防止Survivor区域空间不足以存储存活对象的情况,还提供了一个兜底机制,将对象分配到老年代(大部分情况是老年代)。

算法示意图:

在这里插入图片描述


3.4 标记-整理算法(Mark-Compact)

   复制算法在对象存活率较高的情况下,就需要进行大量的复制操作,效率也会降低。更关键的是,还会存在空间浪费以及会存在一个兜底机制问题。老年代的对象存活率是很高的,那么老年代也就很难以采用复制算法实现。

  标记-整理算法正是针对于老年代对象存活率较高的问题而提出的,算法分为两个阶段:标记阶段和整理阶段。标记阶段和标记-清除算法的一样,但是后续不是清除阶段,而是整理阶段,将所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。

  算法示意图:

在这里插入图片描述
  对象移动了之后,我们还需要一项额外的操作,就是更新引用,这也是一块额外的操作。而且要注意,移动对象的时候,必须全程暂停用户应用程序,也就是常说的“Stop The World”。

  从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。因为不移动对象的话,那么面对内存碎片就只能依赖较复杂的内存分配器和内存访问器来解决(如分区空闲链表),但是使用空闲链表也就会导致用户程序访问内存这一最频繁的步骤上增加额外的负担,从而影响到吞吐量。

  HotSpot虚拟机中,关注吞吐量的Parallel Scavenge垃圾回收器采用的就是标记-整理算法,而关注延迟的CMS垃圾回收器则使用的是标记-清除算法。

  还有一种折中方法,也就是JVM大多数情况下使用的都是标记-清除算法,容忍内存碎片的存在,直到内存碎片影响到了对象分配的时候,再使用标记-整理算法收集一次,获得规整的内存空间。CMS当内存碎片过多的时候就采用了这种折中办法。



4. HotSpot实现细节


4.1 根节点枚举

  在使用可达性分析算法进行对象存活判定的时候,我们需要遍历所有的GC Roots。虽然固定可作为GC Roots的节点主要在全局性的引用(常量,类的静态变量)、执行上下文(栈帧中的本地变量表)中,但是遍历这些也并非是一个简单的事情。

  而且,目前所有收集器在根节点枚举这一个步骤时都是必须暂停用户线程的(“Stop The World”)。这是为了防止对象引用关系的不断变化,如果不能满足的话,分析结构的准确性也就不能保证。即使是几乎不会存在停顿的CMS、G1、ZGC在这一阶段也需要停顿。

  事实上并不需要一个不漏的检查完所有执行上下文和全局引用位置,Hotspot使用了一组成为OopMap的数据结构来达到这个目的。在类加载完成的时候,HotSpot就会吧对象内什么位置是什么类型数据计算出来。这样收集器在扫描的时候就可以直接得知这些信息,并不需要一个不漏的从方法区等GC Roots 开始查找。

4.2 安全点与安全区域

  在OopMap的帮助下,HotSpot能够快速准确的完成根节点枚举,但是对象是一个很灵活的东西,引用关系是不断在变化的,那么引用关系的变化也就会导致OopMap的变化,如果为每一条指令都生成OopMap,那么也会造成需要大量的额外存储空间。

  因此整个用户程序只会在特定位置停下来开始垃圾收集,并非是在代码指令流的任意位置都可以停下来。这个位置就称为安全点。即用户程序必须执行到安全点后才能暂停。

  因此安全点的选定既不能太少,让垃圾回收器一直等待,也不能太过频繁以至于过分增大运行时的内存符合。安全点的选定基本上是以“是否具有让程序长时间执行的特征”为标准选定的。比如说具有方法调用,循环跳转,异常跳转等这些功能的“长时间执行”的指令才会产生安全点。

  剩下的问题就是:如何在垃圾收集的时候,让线程都跑到最近安全点,然后停顿下来。两种方法:
   1)抢先式中断。垃圾收集时,系统首先把全部线程都终端,如果某些线程终端位置不在安全点上,那么就让这条线程继续运行,过会再中断,直到跑到安全点上(几乎没有JVM这么使用这种方法)

   2) 主动式中断。当垃圾收集时要中断线程,不直接对线程操作,而是设置一个标志位,各个线程执行时会不停的主动轮询这个标志,一旦发现标志为真,那么就会自己在最近的安全点上主动中断挂起。轮询标志的位置和安全点位置是重合的,轮询操作仅仅是一条汇编指令。

  安全点的存在,似乎已经完美解决了如何停顿线程,但是实际情况下却存在着“不执行”的问题,比如说程序处于Sleep,没有获取到CPU,这时线程就无法响应JVM的中断请求。为了解决这种情况,就引入了安全区。

  所谓了安全区,就是拉伸了的安全点,指能够确保在某一段代码片段中,引用关系不会发生变化,那么在这块代码区域里面任意位置开始垃圾收集都是安全的。

  整体流程如下:当线程执行到安全区域的代码时,首先会表示自己已经进入了安全区,JVM如果在这段时间内发起垃圾收集,就不会去管这些已经表示自己进入安全区的线程。当线程要离开安全区域时,先要检查JVM是否已经完成根节点枚举或者其他需要暂停用户线程的阶段,如果完成了线程就能继续运行,否则一直等待,知道收到可以离开安全区的信号为止。


4.3 记忆集和卡表

  在3.1节分代收集理论出提到过,对象可能存在跨代引用的情况,因此垃圾收集器为了解决这一问题,在新生代使用了记忆集这一数据结构,来避免去扫描整个老年代。

  记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,最简单的实现方式就是一个对象数组,里面存的对象就是非收集区域中存在跨代引用的对象。

  但是这么小粒度会导致空间占用和维护成本都比较高,因此我们会选择更大的粒度来记录这些信息,以节省空间和维护成本。目前最常用的记忆集实现方式就是“卡表”,它的粒度不再是对象,而是一块内存区域,每条记录只精确到某一块内存区域,该区域中存在对象包含跨代引用。

  而卡表的最简单的实现就是一个字节数组,数组中的每一个元素对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为“卡页”,卡页大小一般是2的N次幂,比如HotSpot中选择的大小就是2的9次幂,即512字节。

  由于一个卡页中存在不止一个对象,但是只要这个卡页中存在有一个对象有跨代引用,那么就将对应卡表的数组元素标识为1(变脏)。垃圾收集的时候,只需要筛选出卡表中标识符为1的,就可以清楚知道哪些内存块包含跨代引用,然后加入到GC Roots集合一起扫描。


4.4 写屏障

  利用卡表来缩小了扫描GC Roots的范围,但是每引入一个数据结构都会多出一个维护的问题,如何维护卡表,何时变脏,谁来让卡表变脏。

  何时变脏其实很简单,当有其他分代的对象引用了本区域内对象的时候,就应该让卡表变脏,即变脏的时间点就应该是引用类型字段赋值的那一刻。

  那么剩下的问题就是,谁来让卡表变脏,如何在引用类型字段赋值的时候去更新卡表。在HotSpot中使用过写屏障(Write Barrier)技术来维护卡表状态的。所谓的写屏障技术,可以看做虚拟机层面对“引用类型字段赋值”这个动作的AOP切面(学过Spring的应该很容易理解AOP),在引用对象赋值的时候会产生一个环形通知,即在赋值的前后都在写屏障的覆盖范围内。在赋值前的部分称为写前屏障,赋值后的部分称为写后屏障。

  虽然我们利用写屏障多了一部分的额外开销,但是这个开销和Minor GC去扫描整个老年代来说还是要低很多的。除了写屏障的开销外,卡表在高并发场景下还面临着伪共享问题。

伪共享是指:现代CPU的缓存系统是以缓存行为单位进行存储的,那么当多个线程修改相互独立的变量的时候,如果这些变量恰好共享一个缓存行,就会彼此影响(写回,无效化,或者同步)而导致性能降低。

  为了避免伪共享问题,一个简单的解决方案就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为1(变脏)。JVM参数-XX:+UseCondCardMark 用来开启卡表更新的条件判断。


4.5 并发的可达性分析

  可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须停止用户线程。

  然后先要弄清楚怎么样在快照上去遍历才能保障一致性,首先引入三色标记来辅助,即按照“是否访问过”这个条件将节点标记成不同的三种颜色:

白色:对象尚未被垃圾收集器访问过。如果在遍历结束的时候,对象仍然是白色,那么就表示这个对象是不可达的,也就是垃圾。
  
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用也都被访问扫描过了,不会被再次访问。实际上黑色节点就表示是存活的对象。
  
灰色:自己本身已经被访问过,但是自己还存在至少有一个引用没有被访问扫描过。

  如果此时将用户线程暂停的话,那么收集器扫描起来将不会出现任何错误;但是如果用户线程和垃圾线程并发呢?那么就可能出现原本是垃圾对象的被标记成存活对象,其实这种情况还好,只不过是产生了浮动垃圾而已可以下次再进行收集,但是如果出现了将原本是存活对象被标记成为垃圾对象的时候,整个程序就会出现错误。
在这里插入图片描述

  如图所示的这种情况,用户线程并发修改了引用关系(图中虚线部分)删除了灰色指向白色的引用关系,添加黑色指向白色部分引用关系。那么就出现了问题,收集器线程已经扫描到了灰色节点,由于删除了引用,收集器线程不会扫描到最下方白色,但是用户线程有添加了黑色指向白色的引用,所以最下方白色节点应该是存活的,而扫描结果却是最下方白色对象是垃圾。这也就导致最危险的情况,删除了存活对象。

  经过上面的示例,我们也能够发现当且仅当两个条件满足的时候,才会出现对象消失的问题:
   (1)插入了一条或者多条从黑色对象到白色对象的新引用关系

   (2)删除了全部灰色对象到这个白色对象的引用关系

  因此,我们保证一致性只需要破坏其中一个条件即可。因此就有了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning)

  增量更新,破坏的是条件(1),即当用户线程建立黑色对象到白色对象的引用关系的时候,将这个新插入的引用记录下来,等并发扫描结束之后,再次将这些记录中建立引用关系的黑色对象作为根,再次扫描一遍。

  原始快照,破坏的是条件(2),当灰色对象要删除指向白色对象的引用关系的时候,将这个要删除的引用关系记录下来,等并发扫描结束之后,再次将这些记录中删除引用关系的灰色对象作为根,再次扫描一遍。即无论用户线程是否删除了引用关系,我们扫描的都是刚开始扫描的那一刻的对象图引用关系快照。

  两种方式都是垃圾收集器常用的方式,比如CMS使用的就是增量更新来完成并发标记阶段,G1和Shenandoah则是使用的原始快照。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值