09-GC_Algorithm

1. 标记阶段

  • Heap里存放着几乎所有的Obj实例,在GC之前,首先需要区分出内存中哪些是存活Obj,哪些是已经死亡Obj。只有被标记为己经死亡的Obj,GCtor才会GC,释放掉其所占用的内存空间,这个过程称为垃圾标记阶段
  • 那么在JVM中究竟是如何标记一个死亡Obj呢?简单来说,当一个Obj已经不再被任何的存活Obj继续引用时,就可以宣判为已经死亡
  • 判断Obj存活一般有两种方式
    • 引用计数算法
    • 可达性分析算法

1. 引用计数算法

  • 引用计数算法(Reference Counting):对每个Obj保存一个整型的引用计数器属性。用于记录Obj被引用的情况
    • 任何一个Obj引用了A,则A的引用计数器+1;当引用失效时,引用计数器-1。只要ObjA的引用计数器的值为0,即表示ObjA为垃圾,可进行回收
  • 优点:实现简单,垃圾Obj便于辨识;判定效率高,回收没有延迟性
  • 缺点:
    • 需要单独的字段存储计数器,增加了存储空间的开销
    • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
    • 无法处理循环引用的情况。这是一条致命缺陷,GCtor中没有使用这类算法

Python如何解决循环引用?

  • 手动解除:在合适的时机,解除引用关系
  • 使用弱引用WeakRef,WeakRef是Python提供的标准库,旨在解决循环引用

1. 循环引用

当p的指针断开,内部的引用形成一个循环,造成内存泄漏

image-20200712102205795

2. 循环引用GC

/*
 * -XX:+PrintGCDetails
 * 证明:Hotspot使用的不是引用计数算法
 */
public class RefCountGC {

    // 这个成员属性唯一的作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024]; // 5MB

    Object reference = null;

    public static void main(String[] args) throws InterruptedException {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();

        obj1.reference = obj2;
        obj2.reference = obj1;

        obj1 = null;
        obj2 = null;
        // 显式的执行垃圾回收行为
        // 发生GC,obj1和obj2能否被回收?
        System.gc();

        Thread.sleep(1_000_000);
    }

}















 
 





 





[GC (System.gc()) [PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K), 0.0061980 secs] [Times: user=0.00 sys=0.00, real=0.36 secs]
[Full GC (System.gc()) [PSYoungGen: 808K->0K(76288K)] [ParOldGen: 8K->672K(175104K)] 816K->672K(251392K), [Metaspace: 3479K->3479K(1056768K)], 0.0045983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 PSYoungGen      total 76288K, used 655K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
  eden space 65536K, 1% used [0x000000076b500000,0x000000076b5a3ee8,0x000000076f500000)
  from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
  to   space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
 ParOldGen       total 175104K, used 672K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
  object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1ea8070,0x00000006cc900000)
 Metaspace       used 3486K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 385K, capacity 388K, committed 512K, reserved 1048576K
// 进行了GC,将新生代中的两个Obj进行回收
PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K)

2. 可达性分析算法

  • 可达性分析算法(Reachability Analysis)、根搜索算法、追踪性垃圾收集
  • 不仅同样具备实现简单和执行高效等特点,更重要的是可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏发生
  • 可达性分析是Java、C#的选择。这种类型的GC通常也叫作追踪性垃圾收集(Tracing Garbage Collection)

1. 思路

所谓"GC Roots”根集合就是一组必须活跃的引用

  • 以根Obj集合(GC_Roots)为起始点,按照从上至下的方式搜索被根Obj集合所连接的目标Obj是否可达
  • 使用可达性分析算法后,存活Obj都被GC_Roots直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
  • 如果目标Obj没有任何引用链相连,则是不可达的,就意味着该Obj己经死亡,可以标记为垃圾Obj
image-20200712104149246

2. GC Roots哪些?

除了Heap外的一些结构,eg:虚拟机栈、本地方法栈、方法区、字符串常量池等对Heap进行引用的,都可以作为GC Roots进行可达性分析

  1. JVM_Stack中引用的Obj
    • eg:各个线程调用的方法中使用到的参数、局部变量等
  2. 本地方法栈内JNI(native方法)引用的Obj
  3. 方法区中类静态属性引用的Obj
    • eg:Java类的引用类型静态变量
  4. 方法区中常量引用的Obj
    • eg:字符串常量池(String Table)里的引用
  5. 所有被同步锁synchronized持有的Obj
  6. JVM内部的引用
    • 基本数据类型对应的ClassObj,一些常驻的异常Obj(eg:NullPointerException、OutOfMemoryError),系统类加载器
  7. 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
  8. 除了这些固定的GC Roots集合以外,根据所选用的GCtor以及当前回收的内存区域不同,还可以有其他Obj“临时性”地加入,共同构成完整GC Roots集合
    • eg:分代收集、局部回收(Partial GC)
    • 如果只针对Heap中的某一块区域进行GC(eg:典型的只针对新生代),必须考虑到内存区域是JVM自己的实现细节,更不是孤立封闭的,这个区域的Obj完全有可能被其他区域的Obj所引用,这时候就需要一并将关联的区域Obj也加入GCRoots集合中去考虑,才能保证可达性分析的准确性
image-20200712104622677
  • 由于Root采用栈方式存放变量和指针,所以如果一个指针,保存了Heap里面的Obj,而自己又不在Heap里面,那它就是一个Root
  • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证
  • 这也是导致GC时必须“Stop The World”的重要原因。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的

2. 清除阶段

当成功区分出内存中存活Obj、死亡Obj后,GCtor执行垃圾回收,释放掉死亡Obj内存空间

JVM中比较常见的三种GC算法:

  1. 标记-清除算法(Mark-Sweep)
  2. 复制算法(Copying)
  3. 标记-压缩算法(Mark-Compact)

1. Mark-Sweep

标记-清除算法(Mark-Sweep):是一种非常基础、常见的GC算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言

Heap中的有效内存空间(available memory)被耗尽时,就会STW,然后进行两项工作

  1. 标记:GCtor从Root开始遍历,标记所有被引用的Obj。一般是在Obj的Header中记录为可达Obj
  2. 清除:GCtor对Heap从头到尾进行线性的遍历,回收不可达Obj
image-20200712150935078

1. 清除?

清除并不是真的置空,而是把清除Obj地址保存在空闲地址列表里。下次有新Obj加载时,判断垃圾的位置空间如果够,覆盖原有的地址

  1. 如果内存规整
    • 采用指针碰撞的方式进行内存分配
  2. 如果内存不规整
    • JVM需要维护一个列表,空闲列表分配

2. 缺点

  • 效率不算高
  • 在进行GC时,STW,用户体验较差
  • 产生内存碎片,需要维护一个空闲列表

2. Copying

  • 背景
    • 解决《标记-清除算法》在GC效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中
  • 核心思想
    • 将活着的内存空间分为两块。GC时,将正在使用的内存中的存活Obj复制到未被使用的内存块中,之后清除正在使用的内存块中的所有Obj,交换两个内存的角色,完成GC
image-20200712151916991

把可达Obj,直接复制到另外一个区域中。复制完成后,A区就没有用了,里面的Obj可以直接清除掉,其实新生代就用Copying

image-20200712152029615
  • 优点
    • 没有标记、清除过程,实现简单,运行高效
    • 不会出现内存碎片
  • 缺点
    • 需要两倍的内存空间
    • 对于G1分拆成为大量Region的GCtor,复制而不是移动,意味着GC需要维护Region之间Obj引用关系,内存占用、时间开销也不小

  • 如果系统中的垃圾Obj很多,Copying需要复制的存活Obj数量非常低(老年代大量的Obj存活,那么复制很多,效率很低)
  • 在新生代,对常规应用的GC,一次通常可以回收70% - 99%的内存空间。回收性价比很高。所以现在的商业JVM都用Copying回收新生代
image-20200712152847218

3. Mark-Compact

标记-压缩(整理)算法

  • Copying的高效性是建立在存活Obj少、垃圾Obj多的前提下。适合新生代GC。老年代,大部分都是存活Obj。使用Copying,存活Obj较多,复制成本很高
  • 1970年前后,G.L.Steele、C.J.Chene、D.s.Wise等研究者发布标记-压缩算法
  1. 第一阶段:和标记清除算法一样,从根节点开始标记所有被引用Obj
  2. 第二阶段:将所有的存活Obj压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间
image-20200712153236508

标清、标整本质差异

  • 标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动是一项优缺点并存的风险决策
  • 标记的存活Obj将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,给新Obj分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销

  • 优点
    • 消除了Mark-Sweep中,内存区域分散的缺点
    • 消除了Copying中,内存减半的高额代价
  • 缺点
    • 效率低于Copying
    • 移动Obj的同时,如果Obj被其他Obj引用,则还需要调整引用的地址
    • 移动过程中,需要STW

4. 小结

  • 效率上来说,Copying是当之无愧的老大,但是却浪费了太多内存
  • 而为了尽量兼顾上面提到的三个指标,Mark-Compact相对来说更平滑一些,但是效率上不尽如人意,它比Copy多了一个标记的阶段,比Mark-Sweep多了一个整理内存的阶段
Mark-SweepMark-CompactCopying
速率中等最慢最快
空间开销少(堆积碎片)少(不堆积碎片)活Obj的2倍空间(不堆积碎片)
移动Obj

3. 分代收集算法

没有一种算法可以完全替代其他算法,都有自己独特的优势和特点。分代收集算法应运而生

  • 分代收集算法:不同Obj生命周期是不一样的。因此,不同生命周期Obj可以采取不同的收集方式,以便提高回收效率。一般是把Heap分为新生代、老年代,根据各个年代的特点使用不同的GC算法,以提高GC效率
  • 程序运行中,产生大量的Obj,其中有些Obj是与业务信息相关(eg:Http请求中的SessionObj、线程、Socket连接,这类Obj跟业务直接挂钩),因此生命周期较长。但是还有一些Obj,是程序运行过程中生成的临时变量,这些Obj生命周期较短,甚至只用一次即可回收(eg:StringObj,由于其不变类的特性,系统会产生大量的这些Obj)

目前几乎所有的GC都采用分代收集(Generational Collectioning)算法,执行垃圾回收。HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代、老年代各自的特点

  • 年轻代(Young Gen)
    • 特点:区域相对较小,Obj生命周期短、存活率低,回收频繁
    • Copying的回收整理,速度是最快的。效率只和当前存活Obj大小有关,因此很适用于年轻代的回收。而Copying内存利用率不高的问题,通过Hotspot中的两个Survivor的设计得到缓解
  • 老年代(Tenured Gen)
    • 特点:区域较大,Obj生命周期长、存活率高,回收不频繁
    • 存在大量存活率高的Obj,Copying明显变得不合适。一般是由《标记-清除》《标记-清除、标记-整理》的混合实现
      • Mark阶段开销,与存活Obj的数量成正比
      • Sweep阶段开销,与所管理区域的大小成正相关
      • Compact阶段开销,与存活Obj的数量成正比

以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现,Obj的回收效率很高。而对于碎片问题,采用基于Mark-Compact的Serial_Old作为补偿措施,当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial_Old执行FullGC以达到对老年代内存的整理

4. 增量收集算法

  • 上述现有算法,在GC过程中,应用软件将处于STW状态。如果GC时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时GC算法的研究,直接导致了增量收集(Incremental Collecting)算法的诞生
  • 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让GC线程和应用程序线程交替执行。每次,GC线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到GC完成
  • 总的来说,增量收集算法的基础仍是传统的Mark-Sweep和Copying。增量收集算法通过对线程间冲突的妥善处理,允许GC线程以分阶段的方式完成标记、清理或复制工作
  • 缺点:这种方式,由于在GC过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得GC的总体成本上升,造成系统吞吐量下降

5. 分区算法

  • 相同条件下,Heap越大,一次GC时所需时间就越长,STW也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个Heap,从而减少STW
  • 分代算法:将按照Obj的生命周期长短划分成两个部分
  • 分区算法:将整个Heap划分成连续的不同Region,每个Region都独立使用,独立回收。好处是可以控制一次回收多少个小区间
image-20200712165318590

6. finalization机制

  • Java语言提供了Obj终止(finalization)机制,允许开发人员提供Obj被销毁之前的自定义处理逻辑
  • 当GCtor发现没有引用指向一个Obj。即:GC此Obj之前,总会先调用这个Obj的finalize()
  • finalize()方法允许在子类中被重写,用于在Obj被回收时进行资源释放。通常这个方法进行一些资源释放和清理的工作
    • eg:关闭文件、套接字、数据库连接等

永远不要主动调用某个Obj的finalize(),应该交给GC机制调用。理由包括下面三点:

  • finalize()可能会导致Obj复活
  • finalize()执行时间是没有保障的,完全由GC线程决定,极端情况下,若不发生GC,则finalize()将没有执行机会
    • 因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收
  • 一个糟糕的finalize()会严重影响GC性能

1. Obj三种状态

从所有的根节点都无法访问到某个Obj。一般来说,此Obj需要被回收。但事实上,也并非是“非死不可”的,它们暂时处于“缓刑”阶段。一个无法触及的Obj有可能在某一个条件下“复活”自己,JVM由于finalize()的存在,Obj一般处于三种可能的状态:

  • 可达状态:从根节点开始,可以到达这个Obj
  • 可恢复状态:Obj的所有引用都被释放,但可能在finalize()中复活
  • 不可达状态:Obj的finalize()被调用,并且没有复活。finalize()只会被调用一次

以上3种状态,只有在Obj不可触及时才可以被回收

image-20230725095421625

2. 具体过程

判定一个ObjA是否可回收,至少要经历两次标记过程:

  • 如果ObjA到GC Roots没有引用链,则进行第一次标记
  • 进行筛选,执行finalize()方法
    • 没有重写finalize(),或者finalize()已经被JVM调用过。ObjA被判定为不可触及的
    • 重写了finalize(),且还未执行过。那么ObjA会被插入到F-Queue队列,由一个JVM自动创建的、低优先级的Finalizer线程,执行finalize()
    • finalize()是Obj逃脱死亡的最后机会。稍后GC会对F-Queue队列中的Obj进行第二次标记,如果ObjA在finalize()中与引用链上的任何一个Obj建立了联系,那么在第二次标记时,ObjA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize不会被再次调用,Obj会直接变成不可触及的状态,也就是说,一个Obj的finalize只会被调用一次

Finalizer线程(JVM自动创建的、低优先级的线程)

image-20200712110411885

3. code

重写finalize(),然后在方法的内部,将其关联到GC Roots中

/**
 * 测试finalize(),即finalization机制
 */
public class CanReliveObj {
    public static CanReliveObj obj; // 类变量,属于GC_Root

    // finalize()只能被调用一次
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("调用当前类重写的finalize()");
        obj = this; // 当前待回收的Obj与引用链上的obj建立了联系
    }


    public static void main(String[] args) throws InterruptedException {
        obj = new CanReliveObj();
        // Obj第一次成功拯救自己
        obj = null;
        System.gc();
        System.out.println("第1次 gc");
        // Finalizer线程优先级很低,暂停2秒,等待它
        Thread.sleep(2000);
        if (obj == null) {
            System.out.println("obj is null");
        } else {
            System.out.println("obj is still alive");
        }
        System.out.println("第2次 gc");

        // 此次自救却失败了
        obj = null;
        System.gc();

        Thread.sleep(2000);
        if (obj == null) {
            System.out.println("obj is null");
        } else {
            System.out.println("obj is still alive");
        }
    }

}







 
 
 
 
 
 






























进行第一次清除时,会执行finalize(),Obj进行了一次自救。finalize()只会被调用一次,因此第二次该Obj将会被GC掉

1次 gc
调用当前类重写的`finalize()`方法
obj is still alive
第2次 gc
obj is dead

7. dump文件

1. Create

1. jmap

public class GCRootsTest {

    public static void main(String[] args) throws InterruptedException {
        List<Object> numList = new ArrayList<>();
        Date birth = new Date();

        for (int i = 0; i < 100; i++) {
            numList.add(String.valueOf(i));
            Thread.sleep(10);
        }

        System.out.println("数据添加完毕,请操作:");
        new Scanner(System.in).next();
        numList = null;
        birth = null;

        System.out.println("numList、birth已置空,请操作:");
        new Scanner(System.in).next();

        System.out.println("结束");
    }

}












 




 





java git:(master)# jps
3680 GCRootsTest
753 RemoteMavenServer36
3777 Jps
711
3679 Launcher
➜  java git:(master)# jmap -dump:format=b,live,file=test1.bin 3680
Dumping heap to /Users/list/mca_proj/tmp/target/classes/com/listao/jvm/chapter09/java/test1.bin ...
Heap dump file created

2. JVisualVM

  • 捕获的 heap_dump 文件是一个临时文件,关闭JVisualVM后自动删除,若要保留,需要将其另存为文件
  • 通过以下方法捕获 heap_dump:
    • 在左侧Application(应用程序)子窗口中,右击相应的应用程序,选择Heap_Dump(堆Dump)
    • Monitor(监视)子标签页中,点击Heap_Dump(堆Dump)按钮
  • 本地应用程序的Heap_dumps作为应用程序标签页的一个子标签页打开。同时,heap_dump在左侧的Application(应用程序)栏中对应一个含有时间戳的节点。右击这个节点选择save_as(另存为)即可将heap_dump保存到本地

2. MAT打开Dump

  • 官网下载open in new window
  • MAT(Memory Analyzer)是一款功能强大的Heap内存分析器。用于查找内存泄漏以及查看内存消耗情况。基于Eclipse开发的,是一款免费的性能分析工具

可以看到有哪些可以作为GC_Roots的Obj,能够看到有一些常用的Java类,然后Thread线程

image-20200712112512720

3. JProfiler

1. GC_Roots溯源

实际开发中,一般不会查找全部的GC_Roots,可能只查找某个Obj的整个链路,称为GC_Roots溯源。可以使用JProfiler

image-20200712113256075

2. OOM

  • 当程序出现OOM时,进行排查
  • -XX:+HeapDumpOnOutOfMemoryError将出错时的dump文件输出
/**
 * 内存溢出排查
 * -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    byte[] buffer = new byte[1 * 1024 * 1024]; // 1MB

    public static void main(String[] args) {
        ArrayList<HeapOOM> list = new ArrayList<>();
        int count = 0;

        try {
            while (true) {
                list.add(new HeapOOM());
                count++;
            }
        } catch (Throwable e) {
            System.out.println("count = " + count);
            e.printStackTrace();
        }
    }
}


 









 









打开dump文件,然后点击Biggest Objects就能够看到超大Obj

image-20200712150229048

然后通过线程,定位到哪里出现OOM

image-20200712150303710