文章目录
- JVM内存区
- 对象是否需要回收?
- 1. 引用计数法
- 2. 可达性分析法(根搜索算法)
- Java的引用
- 对象何时被回收?
- 回收策略
- 回收策略1:引用计数算法
- 回收策略2:标记清除算法(Mark-Sweep)
- 回收策略3:标记整理算法(Mark-Mark-Compact)
- 回收策略4:复制算法(Copying)
- 回收策略5:分待回收机制
- 内存区分配
- 为何还要对新生代进行划分?
- 为啥Survivor区需要两个?
- 垃圾收集器
- 新生代:Serial
- 新生代:ParNew
- 新生代:Parallel Scavenge
- 老年代:Serial Old
- 老年代:Parallel Old
- CMS收集器
- 混合:G1
- 复习
文章源于学习若干个博客过程中整合,图源于图文并茂,万字详解,带你掌握 JVM 垃圾回收!
7种垃圾回收器特点,优劣及使用场景
GC garbage collection 垃圾回收
C/C++中,对象的申请和释放都需要程序员自己进行操作,然而程序员如果忘记对内存空间进行正确的释放,可能导致内存泄漏。
为避免此类情况发生,GC机制可以让程序员开发过程中更专注程序应用本身,而无需考虑内存泄露问题。
- GC自动检测对象是否超过作用域,自动回收内存
- 垃圾收集器自动管理
JVM内存区
JVM一共有五个区域: Method Area, VM stack, Native Mathod Stack, Heap, Program Counter Register
其中GC回收的区域为:
- 堆区
- 方法区
GC回收区域的共同点为:线程共享
对象是否需要回收?
对象无引用,或不可达
判断方法
1. 引用计数法
static class Test{
public Test instance;
}
public void run() {
Test t1 = new Test();
Test t2 = new Test();
t1.instance = t2;
t2.instance = t1;
// t1和t2相互引用
}
如果我们执行run()
方法后,虽然t1和t2不会再被访问,但由于t1, t2相互引用对方,引用计数器不为0,无法对他们进行回收。所以,市面上主流的Java虚拟机都没有使用这个算法,而是使用可达性分析法
2. 可达性分析法(根搜索算法)
通过 GC Roots根对象来作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程的就是一条引用链(Reference Chain)。不在这个链里面的对象,就认为是 可回收的
哪些对象可以作为GC Roots?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,参数,局部变量,临时变量
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈JNI引用对象
虚拟机栈引用的对象
当运行某个函数的时候,JVM就会为这个函数在栈区开辟内存,如果运行main函数,那么JVM为main函数的局部变量在栈区开辟内存
public class Test {
public static void main(String[] args) {
Test a = new Test();//a是栈中局部变量
a = null; //a为null的时候,他和原本的new Test()断开连接
//对象回收
}
}
方法区中类静态属性引用的对象
方法区本身用于存放类的信息:名称,父类,接口,变量等
public class Test {
public static Test s; //s为类静态属性引用的对象
public static void main(String[] args) {
Test a = new Test();
a.s = new Test();
a = null;
}
}
a = null 时,由于 a 原来指向的对象与 GC Root (变量 a) 断开了连接,所以 a 原来指向的对象会被回收。
然而s是类静态属性,且被赋值引用,被认为是GC Root,s依然 可达
方法区中常量引用的对象
public class Test {
public static final Test s = new Test();
public static void main(String[] args) {
Test a = new Test();
a = null;
}
同上,a对象被回收不会影响到常量s指向的对象
本地方法栈中 JNI(Java Native Interface引用) 的对象
- 一个 java 调用非 java 代码的接口
- 可能由 C 或 Python等其他语言实现的
- 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(Windows为dll, unix上为so文件)
区别?
Java方法 | 本地方法 |
---|---|
虚拟机会创建一个栈桢并压入 Java 栈 | 虚拟机会保持 Java 栈不变,不会在 Java 栈祯中压入新的祯,虚拟机只是简单地动态连接并直接调用指定的本地方法。 |
Java的引用
强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)
//强引用
String s = new String("hello");
System.gc();
System.out.println(s);
和前文一样,强引用存在,垃圾收集器永远不会回收被引用的对象,只有当引用被设为null的时候,对象才会被回收。如果赋值给static变量,对象很长一段时间都不会被回收
//软引用
String s = new String("hello");
//强引用添加到软引用
SoftReference<String> softReference = new SoftReference<>(s);
s=null;
//执行垃圾回收
System.gc();
//再次获取
if(softReference !=null ){
System.out.println(softReference.get());
}
GC过程中,如果内存充足,软引用对象不会被释放
//弱引用
WeakReference<String> weakReference = new WeakReference<>(new String("hello"));
//执行垃圾回收
System.out.println("执行垃圾回收之前");
System.out.println(weakReference.get());
System.gc();
System.out.println("执行垃圾回收之后:");
System.out.println(weakReference.get());
只要系统执行完垃圾回收,无论内存是否足够,弱引用变量指向的对象都会被回收。
ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
PhantomReference<String> phantomReference = new PhantomReference<>(new String("hello"), referenceQueue);
System.gc();
System.out.println(referenceQueue.remove().get());
无论内存是否足够,弱引用变量指向的对象都会被回收,和虚引用的区别在于,无法通过虚引用来取得一个对象实例,虚引用也不会对生成他的对象生存时间产生影响。
作用:对象被收集器回收的时候得到系统通知
对象何时被回收?
-
自动调用的情况:
- 程序创建新对象/基本类型数据
- 内存空间不足
-
手动调用的情况
System.gc()
回收策略
回收策略1:引用计数算法
-
发现垃圾时,立即回收。
-
最大限度减少程序暂停,因为发现后立即回收,减少了程序因内存爆满而被迫停止的现象
-
时间开销大,因为引用计数算法需要时刻监控引用计数器的变化。
-
无法回收循环引用的对象
回收策略2:标记清除算法(Mark-Sweep)
未引用对象并不会被立即回收,垃圾对象将一直累计到内存耗尽为止,当内存耗尽时,程序将会被挂起,垃圾回收开始执行
- 遍历所有对象,标记所有的可达对象
- 会遍历所有对象,清除没有标记的对象
优点:简单,快速
缺点:标记和清除过程效率不高 —— 之后会产生大量不连续的内存碎片,提高触发另一次垃圾收集动作的概率
回收策略3:标记整理算法(Mark-Mark-Compact)
-
标记过程仍然与“标记-清除”算法一样
-
第二阶段不对可回收对象进行清理,而让所有存活的对象都向一端移动
回收策略4:复制算法(Copying)
改进了标记-清除算法的效率
内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
优点
- 不用考虑内存碎片
- 简单高效
缺点
- 内存缩小一半
回收策略5:分待回收机制
于是我们结合前面的2,3,4 三种策略,得到了当前最流行的分带回收机制算法
- 新生代:复制算法
- 老年代:标记-清除算法、标记-整理算法
弱分代假说:绝大多数对象都是朝生夕死的。
强分代假说:熬过越多次的垃圾回收的对象,就越难消亡
绝大多数对象在前几次GC过程中回收,经过多次GC过程未被回收的对象很难消亡。
于是把对象分为 新生代, 老年代。然后分配到不同的区域后,执行不同的策略。比例一般为1:2
新生代 | 老年代 |
---|---|
大部分对象会被回收 | 难被回收 |
频繁使用可达性分析法 | 较少频率回收 |
存活对象被复制到幸存者区域后被释放 |
详细说明
新生代
- 所有new的对象先出现在新生代中
- 新生代内存满了触发一次GC事件(Minor garbage collection)
- 无法回收的对象移动到老年代
- Minor garbage collection为全局暂停事件,垃圾回收过程结束其他线程才可运行
老年代
- 老年代被占满时也会触发GC时间(Major garbage collection)
- 也需要全局暂停
- 涉及所有存活对象,更慢。受老年代垃圾回收器影响
除此之外,还有一个永久代(在非heap内存)
- 存储了描述应用程序类和方法的源数据
- JVM 在运行时基于应用程序所使用的类产生
- 类不在被其他类所需要,同时其他类需要更多的空间的时候才会被回收(此回收过程为Full garbage collection)
内存区分配
前面说过,新生代(Young generation)、老年代(Old generation)所占空间比例为 1 : 2,其中新生代还会被分为三个空间
- 1个伊甸园空间(Eden)
- 2个幸存者空间(Fron Survivor、To Survivor)
默认情况下,新生代空间的分配:Eden : Fron : To = 8 : 1 : 1
为何还要对新生代进行划分?
假设我们没有survivor
- Eden区每进行一次Minor GC,存活的对象就会被送到老年代。
- 老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)
- 老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多
如果不划分新生代区,有无其他方法避免上述情况?
方案 | 优点 | 缺点 |
---|---|---|
增加老年代空间 | 更多存活对象才能填满老年代。降低Full GC频率 | 随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长 |
减少老年代空间 | Full GC所需时间减少 | 老年代很快被存活对象填满,Full GC频率增加 |
上述两种解决方案都不能从根本上解决问题
Survivor:就是减少被送到老年代的对象,进而减少Full GC的发生。Survivor的预筛选保证:经历16次Minor GC还能存活的对象,才会被送到老年代。
为啥Survivor区需要两个?
若只有一个Survivor区,会出现以下情况
- Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区,此时Eden和Survivor各有一些存活对象
- Minor GC到底一定次数后,Survivor区会把部分数据存到Old区,eden也会把数据存放到Survivor区
- 但是Eden存到Survivor的数据不一定能刚好存入Survivor空缺部分的数据,导致内存碎片化
应该建立两块Survivor区
- 刚刚新建的对象在Eden中
- 经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;
- 等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制到survivor space S1
垃圾收集器
新生代收集器
- Serial
- ParNew
- parallel
老年代收集器 - Serial Old
- CMS
- Parallel Old
新生代和老年代收集器
G1
常用组合
新生代:Serial
- 单线程收集器
- 使用**“复制”**算法
- GC过程中,所有线程必须暂停
特点
- 简单,无上下文切换,效率高
- 用户体验较差,其不知情情况下停止所有线程
- 适用场景:Client 模式(桌面应用);单核服务器。
- 使用命令如下开启Serial作为新生代收集器
-XX:+UserSerialGC #选择Serial作为新生代垃圾收集器
新生代:ParNew
serial的一个多线程版本
多核机器上,其默认开启的收集线程数与cpu数量相等。可以通过如下命令进行修改
-XX:ParallelGCThreads #设置JVM垃圾收集的线程数
当用户线程都执行到安全点时,所有线程暂停执行,采用复制算法进行垃圾收集工作
特点
- 有效利用CPU
- 用户体验较差,其不知情情况下停止所有线程
- Server模式常用,因为CMS收集器只能和serial或者parNew联合使用。可以使用如下命令进行强制指定
-XX:UseParNewGC #新生代采用ParNew收集器
新生代:Parallel Scavenge
- 复制算法
- 对比parNew,PS收集器关注缩短GC停顿时间,从而达到可控的吞吐量
吞吐量 = 运行用户代码时间 运行用户代码时间 + 垃圾收集时间 吞吐量=\frac{运行用户代码时间}{运行用户代码时间+垃圾收集时间} 吞吐量=运行用户代码时间+垃圾收集时间运行用户代码时间
例如虚拟机一共运行了 100 分钟,其中垃圾收集花费了 1 分钟,那吞吐量就是 99%
垃圾收集器每 100 秒收集一次,每次停顿 10 秒,和垃圾收集器每 50 秒收集一次,每次停顿时间 7 秒,虽然后者每次停顿时间变短了,但是总体吞吐量变低了,CPU 总体利用率变低了。
特点
- 高吞吐量,高CPU利用率,可控
- 使用高吞吐量高CPU利用率场景,如高速运算,少量交互的情况
老年代:Serial Old
Serial Old是Serial收集器的老年代版本
标记-整理算法
适用场景
- Client模式;
- 单核服务器;
- 与Parallel Scavenge收集器搭配;
- 作为CMS收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用
老年代:Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法
一般搭配Parallel Scavenge 收集器
参数指定
-XX:+UserParallelOldGC
CMS收集器
获取最短回收停顿时间为目标的收集器。采用的算法是“标记-清除“
- 标记GC Roots 能够直接关联到达对象
- 并发标记,进行GC Roots Tracing 的过程
- 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
- 标记清除算法清除对象
特点
- 并发收集,低停顿
- CPU资源非常敏感,默认回收线程数(CPU数量+3)/4,CPU数量不足4个对用户影响较大
- 无法处理浮动垃圾
- 可能会出现“Concurrent Mode Failure”失败而导致一次FullGC的产生
- 此时用SerialOld来重新进行老年代GC
- CMS并发清理阶段用户线程还在运行,伴随程序运行自然还会有新的垃圾产生,所以只能等待下一次GC清楚,无法填满后GC
-XX:CMSInitiatingOccupancyFraction修改CMS触发的百分比
- 空间碎片
混合:G1
面向服务端应用的垃圾收集器,目前是JDK9的默认垃圾收集器
- 并行并发
- 分代收集。能够采用不同的方式去处理新创建的对象和已经存活了一段时间的对象
- 整体上来看基于“标记-整理”,部上看是基于复制算法,不产生空间碎片
- 可预测的停顿
Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但两者之间不是物理隔离的。他们都是一部分Region的集合
一个方块对应一个区域,他的身份不定,每种身份的数量也不一定
JVM启动设置每个区为2的次幂大小,最多为2048个区域( 2048 × 32 M = 64 G 2048\times 32M=64G 2048×32M=64G)假如设置 -Xmx8g -Xms8g,则每个区域大小为 8g/2048=4M。
作用
- 避免在整个Java堆全区域GC
- 跟踪各个区的价值大小(回收空间大小,所需时间)得到优先队列
- 每个区域都有Remembered Set 来实时记录 引用类型数据和其他区域数据的关系
- 初始阶段:标记处GC直接关联对象(需要停止线程,单线程执行)
- 并发阶段:GC roots开始对heap对象进行可达分析,和用户线程并行
- 最终标记:修正并行过程中变动的记录
- 筛选回收:对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划 —— 最少的时间回收垃圾最多的区域,这里需要停止用户线程
复习
- JVM的五大内存区是?
- 判定是否需要回收某个对象的方法有哪些?优缺点是什么?
- 为什么市面上多采用可达性分析方法
- 可达性分析方法如何判断GC Root
- 四种引用的区别是什么
- 对象什么时候会被回收?主动回收和被动回收的区别?
- 回收策略有哪些?
- 标记清除算法和标记整理算法的区别?
- 分带回收机制和Copying机制主要区别在哪?
- 这些回收策略的优缺点是什么?
- 新生代,老年代如何划分?
- 新生代如何变成老年代?
- Minor, Major, Full GC的区别
- 为何Survivor区设置为两个?少一个会如何?
- 为啥主流的回收策略是分带回收机制?
- 常见的垃圾收集器有哪些?一般他们如何搭配使用?