概述
JAVA 开发者比C++开发者幸福的地方就是 我们不必手动去申请和释放内存,JVM会帮我们自动释放。
但是JAVA开发者的代价是,一旦这种自动回收机制出现问题,我们必须去深入理解JVM的GC回收原理,甚至对这种回收过程进行必要的监控和调节(JVM调优)。
前文回顾
JVM中有3个区域随线程而生而灭:
- 用于记录当前线程运行代码位置的程序计数器
- 记录native方法调用结构的 本地方法栈
- 记录java方法调用过程的 虚拟机栈 (包含局部变量表,操作数栈,动态连接和返回地址)
这几个地方不会出现内存溢出的问题,所以,这里不涉及到GC回收机制。
而堆和方法区 只有在程序运行时才需要多少内存来存放对象,因为:
- 一个方法的多个分支需要的内存可能不同
- 一个接口的多个实现所需的内存也可能不同
这部分内存的分配和回收是动态的,所以必须需要用GC来智能化管理。
正文
垃圾的概念
JVM中不会再使用到的对象,被称为 “垃圾”。判定垃圾的方式是,可达性分析的算法。
可达性分析是 将堆中的所有对象看作一张图,这张图中存在GCROOT节点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,通过判断对象的引用链来判断对象是否可以被回收。
GC ROOT的概念
-
JAVA虚拟机栈(局部变量表)中所引用的对象
也可以理解为,正在运行时的方法中所创建的局部变量(注意:方法区中存放的常量并不能作为GCROOT,它不会阻止GC回收)
-
方法区中静态引用所指向的对象
即 静态变量
-
仍处于存活状态下的线程对象
-
Native方法中jni所引用的对象
GC触发的时机
- 在堆内存分配时,如果发现剩余空间不足,无法分配足够的内存空间时
- 开发者手动执行
System.gc()时
代码实验室
使用java命令执行 class文件时,可以指定jvm的参数:
-
-Xms 指定JVM运行时的内存大小,默认值为物理内存的1/64
举例:如果我给他分配200000M超出了最大物理内存,则会报错
Initial heap size set to a larger value than the maximum heap sizePS 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之后,其内部的非静态成员变量,将不会继留存。
垃圾回收算法
标记清除
-
mark阶段
从GcRoot集合为开头,往下分别遍历,将所有可以被GCroot直接或者间接引用到的对象都视作 存活对象,其他的一律认为是垃圾对象。
-
Swep阶段
将所有垃圾对象清除
优点:
- 实现简单,不需要将对象移动
缺点:
- 会引起STW(中断其他进程的执行),引起卡顿
- 可能产生内存碎片
- 回收频率较高
复制清除
将现有内存分为两块A,B,每次只使用一块,假如当前使用的是A, 步骤如下:
- 将A中的存活对象都标记出来
- 将A中的存活对象都移动到B
- 将A中对象全部清除
优点:
- 实现简单,运行高效,只需要按次序分配内存即可,不需要考虑内存碎片的问题
- 将可用内存直接降低为原来的一半
- 对象存活率高时频繁复制,CPU压力较大
标记压缩
从GcRoot根节点开始对所有可达对象进行一次标记,之后,将所有的可达对象都移动到内存的一端,然后清理掉另一端的所有对象。
3步走:
-
mark阶段
从GcRoot根节点开始对所有可达对象进行一次标记
-
compact 压缩阶段
将剩余的存活对象都按顺序压缩到内存的一端
-
Sweep阶段
将内存另一端的空间清空
优点:
- 避免了内存碎片的产生
- 不需要像 复制清除那样将可用内存砍半
缺点:
- 压缩动作实际上还是对 存活对象进行了移动,如果存活对象较多,还是容易降低效率
JVM分代回收策略
由于新生代老年代对象的生命周期特征不一致,将 内存区域分为了 新生代,老年代,
在 hotSpot中,除了这两个之外,还有永久代。
分代回收的中心思想就是,新创建的对象都放在新生代中,如果经过多次回收仍然存活,那么就转移到老年代中。
新生代
新生成的对象优先存放在新生代中,在GC过程中,存活率很低。通常,常规应用经过一次GC,新生代中垃圾回收效率一般在 70%-95%之间,回收效率很高。
新生代中通常采用的是 复制清除算法。
新生代又可以细分为3部分:
- Eden(伊甸园,代表新生)
- Survirvor0 (S0)
- Survivor1 (S1)
这3部分通常按照8:1:1的比例划分空间大小。
策略
- 绝大部分新创建的对象会存放在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命令参数
案例代码
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中的引用不止强引用一种。
除了强引用之外,还有软弱虚。虚引用一般不关注。
-
软
当内存不足时,将软引用到的对象进行回收
-
弱
只要发生GC,遍历到了这个引用下的对象,则会将它回收
软引用隐藏问题
被软引用对象关联的对象会自动被GC回收。但是软引用本身也是一个对象,他们并不会自动被GC回收。