二 、JVM GC回收机制和分代回收策略

273 阅读15分钟

概述

JAVA 开发者比C++开发者幸福的地方就是 我们不必手动去申请和释放内存,JVM会帮我们自动释放。

但是JAVA开发者的代价是,一旦这种自动回收机制出现问题,我们必须去深入理解JVM的GC回收原理,甚至对这种回收过程进行必要的监控和调节(JVM调优)。

前文回顾

JVM中有3个区域随线程而生而灭:

  • 用于记录当前线程运行代码位置的程序计数器
  • 记录native方法调用结构的 本地方法栈
  • 记录java方法调用过程的 虚拟机栈 (包含局部变量表,操作数栈,动态连接和返回地址)

这几个地方不会出现内存溢出的问题,所以,这里不涉及到GC回收机制。

堆和方法区 只有在程序运行时才需要多少内存来存放对象,因为:

  • 一个方法的多个分支需要的内存可能不同
  • 一个接口的多个实现所需的内存也可能不同

这部分内存的分配和回收是动态的,所以必须需要用GC来智能化管理。

正文

垃圾的概念

JVM中不会再使用到的对象,被称为 “垃圾”。判定垃圾的方式是,可达性分析的算法。

可达性分析是 将堆中的所有对象看作一张图,这张图中存在GCROOT节点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,通过判断对象的引用链来判断对象是否可以被回收。

image-20230903155043090.png

GC ROOT的概念

  1. JAVA虚拟机栈(局部变量表)中所引用的对象

    也可以理解为,正在运行时的方法中所创建的局部变量(注意:方法区中存放的常量并不能作为GCROOT,它不会阻止GC回收)

  2. 方法区中静态引用所指向的对象

    即 静态变量

  3. 仍处于存活状态下的线程对象

  4. Native方法中jni所引用的对象

GC触发的时机

  1. 在堆内存分配时,如果发现剩余空间不足,无法分配足够的内存空间时
  2. 开发者手动执行System.gc()

代码实验室

使用java命令执行 class文件时,可以指定jvm的参数:

  • -Xms 指定JVM运行时的内存大小,默认值为物理内存的1/64

    举例:如果我给他分配200000M超出了最大物理内存,则会报错 Initial heap size set to a larger value than the maximum heap size

    PS E:\FlutterPros\javaTest\testJava001\src> java -Xms200000M App
    Error occurred during initialization of VM
    Initial heap size set to a larger value than the maximum heap size
    

验证 方法执行过程中局部变量作为GCROOT

public class App {
​
    private int _10M = 10 * 1024 * 1024;
    private byte[] memory = new byte[8 * _10M]; // 每一次new 一个App对象会占用80M的堆空间
​
    public static void main(String[] args) throws Exception {
        System.out.println("when start:");
        printMemory();
        method();
​
        System.gc();
​
        System.out.println(" second time GC completed!");
        printMemory();
​
    }
​
    public static void method() {
        App g = new App();
        System.gc();
        System.out.println("first Time GC completed!");
        printMemory();
    }
​
    private static void printMemory() {
        System.out.println("free " + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "M,");
        System.out.println("total " + Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M,");
    }
}
​

案例说明:

  • 每次new一个App对象,会在堆中花费80M的空间。
  • 在 method正在执行时,如果调用GC,此时 new出来的对象g 隶属 正在运行的方法中的局部变量,他属于GC root
  • 所以,哪怕我们手动执行了System.gc(); ,被占用的内存,也不会释放
  • 而当method方法执行完毕之后,再调用gc(), 这块内存就会被释放

我们使用JVM最大分配内存200M作为案例:

javac App.java
java App -Xms200M

运行结果为:

when start:
free 198M,
total 200M,
first Time GC completed!
free 117M,
total 200M,
 second time GC completed!
free 198M,
total 200M,

这里存在1M到2M的误差,是因为程序运行时有我们不可见的中间变量,不影响我们对于GC机制的判断。

验证静态变量作为GCROOT

public class App {
​
    private static int _10M = 10 * 1024 * 1024;
    private byte[] memory; 
​
    private static App staticVariable;
​
    public App(int size) {
        memory = new byte[size];
    }
​
    public static void main(String[] args) throws Exception {
        System.out.println("when start:");
        printMemory();
​
        App g = new App(4 * _10M);
        g.staticVariable = new App(8*_10M);
        
        g = null;
        System.gc();
        System.out.println(" second time GC completed!");
        printMemory();
​
    }
​
    private static void printMemory() {
        System.out.println("free " + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "M,");
        System.out.println("total " + Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M,");
    }
}
​

案例说明:

  • App 在创建的时候需要指定它的memory属性所占用的内存
  • App还有一个私有静态变量 staticVariable ,类型同样为 App
  • 程序开始时,打印了一次空闲内存和总内存
  • 创建了一个App对象 40M,并且 指定它内部的静态 变量为80M,所以这个对象总共占用了120M
  • g=null释放了 app对象之后,进行了一次gc

程序执行的结果为:

when start:
free 198M,
total 200M,
 second time GC completed!
free 117M,
total 200M,

可以算出,静态变量所占用的80M并没有被回收。

验证活跃线程作为GC ROOT

public class App {
​
    private static int _10M = 10 * 1024 * 1024;
    private byte[] memory = new byte[8 * _10M];
​
    public static void main(String[] args) throws Exception {
        System.out.println("when start:");
        printMemory();
        AsyncTask at = new AsyncTask(new App());
        Thread thread = new Thread(at);
        thread.start();
​
        System.gc();
        System.out.println(" main execute completed!");
        printMemory();
​
        thread.join();
        at = null;
        System.gc();
        System.out.println(" thread execute completed!");
        printMemory();
    }
​
    private static class AsyncTask implements Runnable {
​
        private App gcRootThread;
​
        public AsyncTask(App gcRootThread) {
            this.gcRootThread = gcRootThread;
        }
​
        @Override
        public void run() {
            try {
                Thread.sleep(500);
            } catch (Exception e) {
​
            }
        }
​
    }
​
    private static void printMemory() {
        System.out.println("free " + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "M,");
        System.out.println("total " + Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M,");
    }
}
​

案例说明:

  • 程序开始时创建了一个子线程对象,它与 main线程同时执行
  • 由于子线程会耗时,所以在第一次GC之后打印内存时,由于子线程持有了一个App对象(占内存80M),这部分内存并不会被回收
  • 而后,使用了thread.join(); 使得 子线程的任务结束后再执行后续代码,
  • 此时,调用gc,并且打印内存,发现 80M内存被回收了

运行结果:

when start:
free 198M,
total 200M,
 main execute completed!
free 117M,
total 200M,
 thread execute completed!
free 198M,
total 200M,
PS E:\Flutter

测试成员变量能否作为GCROOT

public class App {

    private static int _10M = 10 * 1024 * 1024;
    private byte[] memory;
    private App classVarialbe;

    public App(int size) {
        memory = new byte[size];
    }

    public static void main(String[] args) throws Exception {
        System.out.println("when start:");
        printMemory();

        App app = new App(4 * _10M);
        app.classVarialbe = new App(8 * _10M);

        app = null;

        System.gc();

        System.out.println(" gc execute completed!");
        printMemory();
    }

    private static void printMemory() {
        System.out.println("free " + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "M,");
        System.out.println("total " + Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M,");
    }
}

案例说明:

  • 与静态变量类比,将静态去掉,现在只是一个普通的成员变量
  • 预测运行结果为,gc之后,app对象所占用的40+80=120M内存将全部被释放

运行结果:

when start:
free 198M,
total 200M,
 gc execute completed!
free 198M,
total 200M,

可得出结论,普通成员变量并不能作为GCROOT,当一个类被置为null之后,其内部的非静态成员变量,将不会继留存。

垃圾回收算法

标记清除

  1. mark阶段

    从GcRoot集合为开头,往下分别遍历,将所有可以被GCroot直接或者间接引用到的对象都视作 存活对象,其他的一律认为是垃圾对象。

  2. Swep阶段

    将所有垃圾对象清除

image-20230903175335920.png

优点:

  • 实现简单,不需要将对象移动

缺点:

  • 会引起STW(中断其他进程的执行),引起卡顿
  • 可能产生内存碎片
  • 回收频率较高

复制清除

将现有内存分为两块A,B,每次只使用一块,假如当前使用的是A, 步骤如下:

  1. 将A中的存活对象都标记出来
  2. 将A中的存活对象都移动到B
  3. 将A中对象全部清除

优点:

  • 实现简单,运行高效,只需要按次序分配内存即可,不需要考虑内存碎片的问题
  • 将可用内存直接降低为原来的一半
  • 对象存活率高时频繁复制,CPU压力较大

标记压缩

从GcRoot根节点开始对所有可达对象进行一次标记,之后,将所有的可达对象都移动到内存的一端,然后清理掉另一端的所有对象。

3步走:

  1. mark阶段

    从GcRoot根节点开始对所有可达对象进行一次标记

  2. compact 压缩阶段

    将剩余的存活对象都按顺序压缩到内存的一端

  3. Sweep阶段

    将内存另一端的空间清空

优点:

  • 避免了内存碎片的产生
  • 不需要像 复制清除那样将可用内存砍半

缺点:

  • 压缩动作实际上还是对 存活对象进行了移动,如果存活对象较多,还是容易降低效率

JVM分代回收策略

由于新生代老年代对象的生命周期特征不一致,将 内存区域分为了 新生代,老年代,

在 hotSpot中,除了这两个之外,还有永久代。

分代回收的中心思想就是,新创建的对象都放在新生代中,如果经过多次回收仍然存活,那么就转移到老年代中。

新生代

新生成的对象优先存放在新生代中,在GC过程中,存活率很低。通常,常规应用经过一次GC,新生代中垃圾回收效率一般在 70%-95%之间,回收效率很高。

新生代中通常采用的是 复制清除算法。

新生代又可以细分为3部分:

  1. Eden(伊甸园,代表新生)
  2. Survirvor0 (S0)
  3. Survivor1 (S1)

这3部分通常按照8:1:1的比例划分空间大小。

image-20230903180840017.png

策略

  • 绝大部分新创建的对象会存放在Eden区,当Eden区第一次满的时候,会进行垃圾回收,先将Eden区域的垃圾对象清除,并将存活对象复制到S0,此时S1是空的
  • 当Eden区域第二次满的时候,会将 Eden区域和S0区域中所有垃圾对象清除,并将存活对象复制到S1,此时S0为空
  • 下一次Eden再满的时候,再将Eden和S1中的垃圾对象清除,并且将所有的存活对象复制到S0

这样,存活对象便在 S0和S1之间复制来复制去,当复制次数达到15次之后,如果S0或者S1中还存在存活对象,那么就说明这些对象的生命周期较长,则将他们都转移到老年代。

老年代

一个对象如果在新生代多次回收动作中依然存活,那么将会复制它到老年代。

老年代中对象通常比新生代的大,且能存放更多对象。

如果对象较大(较大的字符串和较大的数组),且新生代中内存不足,也有可能直接分配老年代的空间。

JVM参数 -XX:PretenureSizeThrshould

可以使用这个参数,来控制直接升入老年代的对象大小的阈值。超过这个阈值的对象将会直接进入老年代。

策略

老年代中的对象生命周期较长,不需要过多的复制操作,通常采用标记压缩的算法。

但是,老年代中可能存在这么一个情况:多个老年代对象有时候会引用到新生代中的对象,此时,如果要执行新生代GC,就要查询整个老年代中新生代对象的引用情况,这显然是低效的,所以,老年代中维护了一个512B的 table,用于保存 老年代持有新生代对象的情况,每当新生代发生GC,只需要查询这个表即可。

GCLog分析

我使用的JDK版本是 11.0.1

为了让上层开发者更方便调试Java程序,JVM提供了GC日志:

  • 新生代GC,MinorGC
  • 老年代GC, MajorGC / FullGc

JAVA命令参数

image-20230903182754306.png

案例代码

public class App {
​
    private static final int _1MB = 1 * 1024* 1024;
​
    private static void testAllocation(){
        byte[] a1,a2,a3,a4;
        a1 = new byte[2 * _1MB];
        a2 = new byte[2 * _1MB];
        a3 = new byte[2 * _1MB];
        a4 = new byte[1 * _1MB];
        
    }
   
    public static void main(String[] args) throws Exception {
        testAllocation();
    }
​
  
}
​

以上代码将演示,JVM是如何对 a1,a2,a3,a4这4个对象分配堆内存的。java 命令如下:

java -Xms20M -Xmx20M -Xmn10M -Xlog:gc* -XX:SurvivorRatio=8 App

命令含义为:

  • JVM初始内存和扩容后最大内存都是20M
  • 新生代中内存为10M
  • 打印gc详细日志
  • 新生代中Eden权重为8(S0,S1默认都是1)

运行日志为:

[0.006s][info][gc,heap] Heap region size: 1M
[0.007s][info][gc     ] Using G1
[0.007s][info][gc,heap,coops] Heap address: 0x00000000fec00000, size: 20 MB, Compressed Oops mode: 32-bit
[0.080s][info][gc,start     ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[0.080s][info][gc,task      ] GC(0) Using 2 workers of 8 for evacuation
[0.082s][info][gc,phases    ] GC(0)   Pre Evacuate Collection Set: 0.0ms
[0.082s][info][gc,phases    ] GC(0)   Evacuate Collection Set: 1.3ms
[0.083s][info][gc,phases    ] GC(0)   Post Evacuate Collection Set: 0.1ms
[0.083s][info][gc,phases    ] GC(0)   Other: 1.0ms
[0.083s][info][gc,heap      ] GC(0) Eden regions: 2->0(9)
[0.083s][info][gc,heap      ] GC(0) Survivor regions: 0->1(2)
[0.083s][info][gc,heap      ] GC(0) Old regions: 0->0
[0.084s][info][gc,heap      ] GC(0) Humongous regions: 9->9
[0.084s][info][gc,metaspace ] GC(0) Metaspace: 3620K(4864K)->3620K(4864K) NonClass: 3305K(4352K)->3305K(4352K) Class: 314K(512K)->314K(512K)
[0.084s][info][gc           ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 10M->9M(20M) 4.054ms
[0.084s][info][gc,cpu       ] GC(0) User=0.00s Sys=0.00s Real=0.00s
[0.084s][info][gc           ] GC(1) Concurrent Cycle
[0.084s][info][gc,marking   ] GC(1) Concurrent Clear Claimed Marks
[0.085s][info][gc,marking   ] GC(1) Concurrent Clear Claimed Marks 0.148ms
[0.085s][info][gc,marking   ] GC(1) Concurrent Scan Root Regions
[0.085s][info][gc,marking   ] GC(1) Concurrent Scan Root Regions 0.531ms
[0.085s][info][gc,marking   ] GC(1) Concurrent Mark (0.085s)
[0.086s][info][gc,marking   ] GC(1) Concurrent Mark From Roots
[0.086s][info][gc,task      ] GC(1) Using 2 workers of 2 for marking
[0.086s][info][gc,marking   ] GC(1) Concurrent Mark From Roots 0.420ms
[0.086s][info][gc,marking   ] GC(1) Concurrent Preclean
[0.086s][info][gc,marking   ] GC(1) Concurrent Preclean 0.170ms
[0.086s][info][gc,marking   ] GC(1) Concurrent Mark (0.085s, 0.086s) 1.048ms
[0.087s][info][gc,start     ] GC(1) Pause Remark
[0.087s][info][gc,stringtable] GC(1) Cleaned string and symbol table, strings: 1868 processed, 0 removed, symbols: 16820 processed, 0 removed
[0.087s][info][gc            ] GC(1) Pause Remark 12M->12M(20M) 0.665ms
[0.087s][info][gc,cpu        ] GC(1) User=0.00s Sys=0.00s Real=0.00s
[0.088s][info][gc,marking    ] GC(1) Concurrent Rebuild Remembered Sets
[0.088s][info][gc,marking    ] GC(1) Concurrent Rebuild Remembered Sets 0.162ms
[0.088s][info][gc,start      ] GC(1) Pause Cleanup
[0.088s][info][gc            ] GC(1) Pause Cleanup 12M->12M(20M) 0.161ms
[0.088s][info][gc,cpu        ] GC(1) User=0.00s Sys=0.00s Real=0.00s
[0.088s][info][gc,marking    ] GC(1) Concurrent Cleanup for Next Mark
[0.089s][info][gc,marking    ] GC(1) Concurrent Cleanup for Next Mark 0.183ms
[0.089s][info][gc            ] GC(1) Concurrent Cycle 4.490ms
[0.089s][info][gc,heap,exit  ] Heap
[0.089s][info][gc,heap,exit  ]  garbage-first heap   total 20480K, used 12088K [0x00000000fec00000, 0x0000000100000000)[0.089s][info][gc,heap,exit  ]   region size 1024K, 2 young (2048K), 1 survivors (1024K)
[0.089s][info][gc,heap,exit  ]  Metaspace       used 3629K, capacity 4486K, committed 4864K, reserved 1056768K
[0.090s][info][gc,heap,exit  ]   class space    used 316K, capacity 386K, committed 512K, reserved 1048576K

这个还挺复杂的,先略过。与教程中的日志完全不同了,我无法判断新生代老年代的内存分配情况。# TODO

概谈引用

JVM中的引用不止强引用一种。

image-20230903184648046.png

除了强引用之外,还有软弱虚。虚引用一般不关注。

  • 当内存不足时,将软引用到的对象进行回收

  • 只要发生GC,遍历到了这个引用下的对象,则会将它回收

软引用隐藏问题

被软引用对象关联的对象会自动被GC回收。但是软引用本身也是一个对象,他们并不会自动被GC回收。

TODO