- 概述
- 对象已死
- 垃圾收集算法
- HotSpot的算法细节实现
- 经典垃圾收集器(JDK7 Update4之后、JDK11正式发布之前,HotSpot所包含的全部可用垃圾收集器)
- 低延迟垃圾收集器(还在实验中)
- 选择合适的垃圾收集器
- 实战:内存分配会回收策略
概述
垃圾收集(Garbage Collection,GC)历史远比Java久远
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,需要进行调节
Java堆和方法区这2个区域有很明显的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能直到程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器就是关注这部分内存的管理
对象已死
堆里面存放着Java各种各样的对象,在回收前,需要确定哪些对象还“活着”、“死去”(不可能再被任何途径使用的对象)
引用计数法(非主流)
在Java领域,主流的虚拟机都没有使用引用计数法。
定义:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就+1;当引用失效时,计数器值就-1;任何时刻计数器为零的对象是不可能再被使用的
单纯的引用计数很难解决对象之间相互循环引用的问题。必须要配合大量额外处理才能够保证工作
/**
* @Author jtao
* @Date 2021/1/19 23:47
* @Description
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
//这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
private byte[] bigSize = new byte[2 * _1MB];
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance=objB;
objB.instance=objA;
objA=null;
objB=null;
System.gc();
}
}
可达性分析算法(主流算法)
当前主流程序语言(Java、C#)的内存管理子系统都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活
算法思路
通过一系列成为"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连(即从GC Roots到这个对象不可达时),则证明此对象是不可嗯在被引用的
可作为GC Root的对象
- 在虚拟机栈(栈帧中的本地变量表)引用的对象,如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 方法区中类静态属性引用的对象,如Java类的引用类型静态变量
- 方法区中常量引用的对象,如字符串常量池(String Table)里的引用
- 本地方法栈中JNI(Native)引用的对象
- JVM内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointException、OutOfMemoryError)、系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
- 其他对象“临时性”地加入
再谈引用
无论何种算法,判定对象是否存活都和“引用”离不开关系
JDK1.2版本之前
- Java里面的引用是很传统的定义,如果reference类型的数据中存储的值代表的是另外一块内存的起始地址,就该称reference数据是代表某块内存、某个对象的引用
JDK1.2版本之后
Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phanton Reference)
强引用
最传统的“引用”定义,在代码之中普遍存在的引用赋值,类似"Object obj=new Object()"这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
软引用
一些还有用,但非必需的对象。只被软引用关联着的对象在系统将发生内存溢出前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
弱引用
描述非必需对象,比软引用更弱一些。所关联的对象只能生存到下一次垃圾收集发生为止,无论如何都会被回收掉若引用关联的对象
虚引用
也成为“幽灵引用”、“幻影引用”,最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
生存还是死亡?
即使在可达性分析算法中判定为不可达的对象,也不是facebook,该对象此时被标记一次,如果正真宣告一个对象死亡,至少要经历两次标记过程
对象死亡过程
- 第一次标记
对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么它将会被第一次标记 - 筛选
筛选的条件是此对象是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都是为“没有必要执行” - 有必要执行finalize()方法
如果有必要,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由JVM自动建立的、低调度优先级的Finalizer运行,但并不承诺一定会等待它允许结束 - 第二次小规模标记
收集器会对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己-只要重新与引用链上的任何一个对象建立关联即可(如把自己(this关键字)赋值给某个类变量或者对象的成员变量),那么在第二次标记时它将被移出“即将回收”的集合 - 第二次未被剔除回收集合
如果还未逃脱,则基本就被回收了
代码演示
/**
* @Author jtao
* @Date 2021/1/20 21:27
* @Description 此代码演示了两点:
* 1.对象可以再被GC时自我拯救
* 2.这种拯救的机会只有一次,因为对象的finalize()方法最多只会被系统自动调用一次
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("Yes,I am still alive !:)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("Oh,no! I am dead ! :(");
}
//下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
//因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("Oh,no! I am dead ! :(");
}
}
}
上图一样的代码第一次执行逃脱回收,第二次执行失败,原因如下:
任何一个对象的finalize()方法只会被系统自动调用一次,如果面临下一次回收,则finalize()方法不会被再次执行,因此第二次不会执行该方法,则不会赋值,则为null,故会被回收
避免使用finalize()方法(不推荐语法)
其方法允许代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已经被官方明确声明为不推荐的语法。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时
回收方法区
在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%~99%的内存空间,相比之下,方法区(元空间或者永久代)回收由于苛刻的判定条件,其区域垃圾收集的回收成果往往很低,性价比不高
方法区回收对象:废弃的常量、不再使用的类型
回收对象包括2部分:废弃的常量、不再使用的类型
废弃的常量
回收废弃常量与Java堆中的对象非常类似,判定没有对象引用常量池中该常量,且虚拟机也没有其他地方引用这个字面量
不再使用的类型
需要同时满足3个条件
- 该类所有的实例都已经被回收即Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,通常很难达成
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
只有满足了上述3个条件的无用类才会被允许回收,但并不是没有了引用就必然会被回收,由虚拟机参数进行控制
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要JVM具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
垃圾收集算法
垃圾收集算法的实现涉及大量的程序细节,且各个版本JVM操作内存的方法都有差异。
可分为2大类:“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,也被称作“直接垃圾收集”和“间接垃圾收集”
以下算法均属于追踪式垃圾收集范畴
分代收集理论
- 建立在2个分代假说之上:
1.弱分代假说:绝大多数对象都是朝生夕灭的
2.强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
收集器将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾收集过程的次数)分配到不同的区域之中存储。如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把他们集中放在一起,每次回收时只关注如何保留少量存活而不是区标记那些大量要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用
分代收集理论带来的回收类型和收集算法
- Java堆划分出不同的区域后,垃圾收集器才可以每次只回收其中一个或某些部分的区域,才有了Minor GC、Major GC、Full GC这样的回收类型的划分
- 针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法,发展出了标记-复制、标记-清除、标记-整理算法等针对性的垃圾收集算法
新生代和老年代
一般会至少把Java堆划分为新生代和老年代两个区域。
- 过程:
在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象将会逐步晋升到老年代中存放 - 问题:
分代收集并非只是简单划分一下内存区域那么简单,因为对象不是孤立的,对象之间会存在跨代引用 - 解决方案:
需要对分代收集理论添加第三条经验法则:
跨代引用假说:跨代引用相对于同代引用来说占极少数
依据该假说,不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在在哪些跨代引用,只需在新生代上建立一个全局的数据结构
标记-清除算法
最早出现的最基础的算法,分为“标记”和“清除”两个阶段
偷的图:https://www.processon.com/view/5bf7cd14e4b006dc83a193ae
- 过程:
首先标记出所有需要回收掉额对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来标记存活的对象,统一回收掉没有被标记的对象 - 缺点:
1.执行效率不稳定:标记和清除两个过程的执行效率都随对象数量增长而降低
2.内存空间的碎片化问题:会产生大量不连续的内存碎片,在需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-复制算法(大多新生代采用该方法)
偷的图:https://www.processon.com/view/5f356169e0b34d6a0eabb3d7
简称为复制算法。
- 过程:
将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是活的,这种算法将会产生大量的内存间复制的开销,但是对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象。 - 优点:
半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。实现简单,运行高效 - 缺点:
将可用内存缩小为原来一半,空间浪费大
JVM大多优先采用该算法去回收新生代,新生代种的对象有98%熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间
Appel式回收(HotSpot最初的分代布局方式)
appel式回收是一种更优化的半区复制分代策略,把新生代分为一块较大的Eden空间和两块较小的Survivor空间,大小比例为8:1:1
- 过程:
每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor种仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和其中一块Survivor。即每次新生代中可用内存空间为整个新生代容量的90%,只有一个Survivor空间10%的新生代是会被浪费的
内存的分配担保
当存活的对象大于10%时,Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多为老年代)进行分配担保,直接进入老年代
标记-整理算法(老年代)
老年代对象的存亡特征不适合标记-复制算法,因为其存活率较高,需要进行较多的复制操作,效率将会降低
- 过程:
标记-整理与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有的存活的对象都向内存空间一端移动,然后直接清理掉边界意外的内存
与标记-清除算法最本质差别
- 标记-清除:非移动式的回收算法
- 标记-整理:移动式的
移动存活对象的利弊(标记-整理:关注高吞吐量的Parallel Scavenge收集器)
老年代含有大量对象存活,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,这种对象移动操作必须全程暂停用户应用程序才能进行
不移动存活对象的利弊(标记-清除:关注延迟的CMS收集器)
如果不移动和整理存活对象的话,堆中的存活对象导致的空间碎片化问题只能依赖更为复杂的内存分配和内存访问来解决
权衡利弊
从垃圾收集的停顿时间看:不移动对象会更短,甚至可不需要停顿
从整个程序的吞吐量(赋值器与收集器的效率总和)来看:移动对象会更划算
不同垃圾收集器
- CMS收集器
关注延迟的CMS收集器:标记-清除 - Parallel Scavenge
关注高吞吐量的Parallel Scavenge收集器:标记-整理 - 二者都有的
CMS收集器:在内存空间的碎片化程度不够影响对象分配时采用标记-清除算法;在碎片化程度影响对象分配时再采用标记-整理算法收集一次
HotSpot的算法细节实现
JVM实现这些算法时,必须堆算法的执行效率有严格的考量,才能保证虚拟机高效运行
从可达性分析算法开展
根节点枚举
GC Root集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。
所有收集器再跟节点枚举这一步骤时都是必须暂停用户线程的
主流JVM使用的都是准确式垃圾收集。JVM通过使用一组称为OopMap的数据结构可以直接得到哪些地方存放对象引用的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定位置记录下栈里和寄存器里哪些位置时引用。这样收集器在扫描时就可以直接得知这些信息,不需要正真一个不漏地从方法区等GC Root开始查找
安全点
- 问题
在OopMap的协助下,HotSpot可以快速准确的完成GC Root枚举,但导致OopMap内容变化的指令非常多,如果每一条指令都声称对于的OopMap,那么需要大量的额外存储空间 - 解决办法
HotSpot不会为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停
保证所有线程都到达安全点
如何在垃圾收集发生时让所有线程(不包括JNI)都跑到最近的安全点,然后停顿下来。
- 有2种方案:
1.抢先式中断:
2.主动式中断
抢先式中断
已经很少有JVM采用该方法来暂停线程响应GC事件
不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,直到跑到安全点上再中断。
主动式中断
当垃圾收集需要中断线程的时候,不直接对线程进行操作,仅仅设置一个标志位,线程执行中会不断地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的
由于轮询操作在代码中会频繁出现,所以必须高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指定的程度
安全区域
- 问题
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但当程序“不执行”的时候,即没有分配处理器事件,典型的如用户线程处于Sleep状态或者Blocked状态,则无法响应虚拟机的中断请求,不能走到安全的地方去中断挂起自己。则必须引用安全区域(Safe Region)来解决 - 解决办法
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,这个区域中任意地方开始垃圾收集都是安全的。可把安全区域看作被扩展拉伸了的安全点
记忆集与卡表
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加入GC Roots扫描范围。所有涉及部分区域收集行为的垃圾收集器都有这样的问题。
记忆集
用于记录从非手机区域指向收集区域的指针集合的抽象数据结构
该记录全部含跨代引用对象的实现方案。
记忆集的精度
设计者在涉及记忆集的时候,可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本:
- 字长精度:每个记录精确到一个机器字长,该字包含跨代指针
- 对象精度:每个记录精确到一个对象,对象里面有字段含有跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
卡表
第三种“卡精度”所指的是一种称为“卡表”(Card Table)的方式去实现记忆集,是最常见的一种记忆集实现形式
卡表最简单的形式可以只是一个字节数组,HotSpot虚拟机也是这样做的
写屏障
- 问题
记忆集可以所借GC Roots扫描范围的问题,但带来了卡表元素如何维护问题(如它们何时变脏、被谁变脏) - 卡表如何变脏
有其他分代区域对象中引用了本区域对象时,其对应的卡表元素就应该变脏,时间点发生在引用类型字段赋值那一刻
卡表如何变脏
//TODO
卡表如何维护
//TODO
并发的可达性分析
当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能爆涨一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行
问题
标记时间:堆越大,存储的对象就越多,对象图结构就越复杂,要标记更多对象而产生的停顿时间就更长
标记阶段是所有追踪式垃圾手机算法的共同特征,如果能够消减标记部分停顿时间的话,那么收益也将会是系统性的
为什么需要在能保障一致性的快照上进行对象图的遍历
-
三色标记
把遍历对象图过程中遇到的对象,按照“是否访问过”分为3种颜色
1.白色:对象尚未被垃圾收集器访问过
2.灰色:对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过
3.黑色:对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过
-
并发后果
如果用户线程与收集器是并发工作,收集器在对象图上标记颜色,同时用户线程再修改引用关系——即修改对象图的结构,可能出现两种后果:
1.把原本消亡的对象错误标记为存活,可以容忍等待下次收集清理掉就OK
2.把原本存活的对象错误标记为已消亡,这是非常致命的后果,程序肯定会因此发生错误 -
产生“对象消失的条件”
1.赋值器插入了一条或多条从黑色对象到白色对象的新引用
2.赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
解决
-
只需要破坏上述2个条件
1.增量更新(Incremental Update):破坏第一个条件
2.原始快照(Snapshot At The Beginning,SATV):破坏第二个条件 -
增量更新(CMS)
当黑色对象插入新的指向白色对象的引用关系时,将新插入引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,再重新扫描一次 -
原始快照(G1、Shenandoah)
当灰色对象要要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次 -
总结
无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索
经典垃圾收集器(JDK7 Update4之后、JDK11正式发布之前,HotSpot所包含的全部可用垃圾收集器)
收集算法是内存回收的方法论,垃圾收集器是内存回收的实践者。《Java虚拟机规范》中对垃圾收集器应该如何实现并没有做出任何规定,因此不同的JVM包含的垃圾收集器会有很大差别
上图展示的七个作用域不同分代的收集器,如果两个收集器之间存在连线,就说明其可以搭配使用。
没有最好的收集器,更加不存在“万能”的收集器,所以我们选择的只是对具体应用最合适的收集器
Serial收集器
最基础、历史最悠久的收集器。
一个单线程工作的收集器,此处单线程指的是它在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束
由JVM再用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说是不能接受的
缩短标记时间
带给用户的体验很不好,从JDK1.3开始到最新的JDK13,HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一致持续着,用户线程的停顿时间在持续缩短,但是任然没有办法彻底消除,探索更优秀垃圾收集器的工作仍在继续
总结
Serial依然是HotSpot虚拟机运行在客户端(桌面级)下的默认新生代收集器,有着优于其他收集器的地方,就是简单高效,对于内存受限的环境,是有所收集器里面额外内存消耗最小的
ParNew收集器(Serial收集器的多线程并行版)
是Serial收集器的多线程并行版本,其他基本和Serial相同
ParNew在单核心处理器环境不必Serial好,甚至出现线程交互的开销,但多核心较好
是不少运行在服务端模式下的HotSpot虚拟机,因为其是除了Serial收集器之外,能与CMS收集器配合工作的
后续的G1收集器是一个面向全堆的收集器,自此以后PaeNew合并入CMS,成为其专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器
垃圾收集器上下文的并发与并行
并行
多条垃圾收集器线程之间的关系,同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态
并发
垃圾收集器线程与用户线程之间的关系,同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响
Parallel Scavenge 收集器(标记-复制)
新生代收集器,基于标记-复制算法实现的收集器,能够并行收集的多线程收集器,和ParNew类似
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”
专注点:吞吐量
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同:
CMS等收集器的关注点为尽可能地缩短垃圾收集时用户线程的停顿时间
Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。
吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值
低停顿时间
停顿时间越短越适合需要与用户交互或需要保证服务响应质量的程序
高吞吐量
高吞吐量则可以最高效率地利用处理器资源,尽快完成程序地运算任务,主要适合在后台运算而不需要太多交互的分析任务
如果对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务将给虚拟机去完成也是一个很不错的选择
Serial Old收集器(标记-整理)
是Serial收集器的老年代版本,是一个单线程收集器,使用标记-整理算法。主要提供给客户端模式下的HotSpot虚拟机使用。
Parallel Old收集器(标记-整理)
是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现
在注重高吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合
CMS收集器(目标为低响应时间,标记-清除)
CMS(Concurrent Mark Sweep)收集器是以获取最短回收停顿时间为目标的收集器,适合浏览器B/S架构的服务端上,CMS收集器非常符合这类应用的需求
运作过程
相对于前几种收集器来说要更复杂一些,整个过程分为四个步骤:
- 初始标记(CMS initial mark)
仅仅标记一下GC Roots能直接关联到的对象,速度很快,需要暂停用户线程 - 并发标记(CMS concurrent mark)
从GC Roots的直接关联对象开始遍历整个对象的过程,耗时较长,可与用户线程并发运行 - 重新标记(CMS remark)
修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分的标记记录,需暂停用户线程,停顿时间比初始标记阶段稍长,远小于并发标记阶段的时间 - 并发清除(CMS concurrent sweep)
清理删除掉标记阶段判断的已经死亡对象,由于不需要移动存活对象,所以这个阶段是可以与用户线程同时并发的
初始标记、重新标记任然需要暂停其他所有线程,但并发标记和清除耗时最长,所以总体上,CMS收集器的内存回收过程是与用户线程一起并发执行的
CMS是一款优秀的收集器:并发收集、低停顿,但也有缺点
CMS的3个缺点
- 1.CMS收集器对处理器资源非常敏感
面向并发设计的程序都被处理器资源比较敏感。在并发阶段,虽然不会导致用户线程停顿,但会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量 - 2.CMS收集器无法处理“浮动垃圾”,有可能出现失败进而导致另一次完全暂停其他用户程序的Full GC的产生
浮动垃圾指的是标记过程结束之后,CMS无法在档次收集中处理它们,只好留待下一次垃圾收集时再清理掉 - 3.基于标记-清除算法实现的收集器,在收集结束时会有大量空间碎片产生
Garbage First(G1) 收集器
G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于Region的内存布局形式
JDK 8 Update40的时候,G1提供并发的类卸载的支持,至此G1收集器被Oracle官方称为“全功能的垃圾收集器”
G1是一款主要面向服务端应用的垃圾收集器,自JDK9之后,称为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用
G1收集器的工作
G1虽然页式遵循分代收集理论设计的,但其跳出了这个樊笼,它可以面向堆内存任何部分来组成会收集(Collection Set),进行回收,衡量标准不再是它属于哪个分代,而是那块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的就对象都能够获取很好的收集效果
G1收集器模型
//TODO
G1收集器实现细节问题
//TODO
G1收集器的运作过程
-
初始标记
-
并发标记
-
最终标记
-
筛选回收
G1收集器除了并发标记外,其余阶段也是要完全暂停使用用户线程的,其并非追求低延迟,其设定目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”
低延迟垃圾收集器(还在实验中)
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”
一款优秀的收集器通常最多可以同时达成其中的两项
随着硬件的发展、性能的提升有助于内存和高吞吐,但对于延迟则不是,所以延迟会成为垃圾收集器最重视的指标
Shenandoah和ZGC几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段由短暂的停顿,且时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。目前人处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”
Shenandoah收集器
//TODO
ZGC收集器
//TODO
选择合适的垃圾收集器
探讨一下如何选择合适的垃圾收集器
Epsilon收集器
JDK11中Epsilon收集器是一款以不能够进行垃圾收集为“卖点”的垃圾收集器——“自动内存管理系统”
最近几个版本的JDK逐渐加入了提前编译、面向应用的类数据共享等支持。如果应用只要运行数分钟甚至树苗,只要JVM能正确分配内存,在推内存耗尽之前就退出,那显然运行负载小、没有任何回收行为的Epsilon便是很恰当的选择
收集器的权衡
收集器的选区取决于一下3个因素:
- 1.应用程序的主要关注点是什么
- 2.运行应用的基础设施如何
- 3.使用JDK的发行商是什么
应用关注点
- 数据分析、科学计算类:要尽快算出结果,则高吞吐量为关注点
- SLA应用:停顿时间直接影响服务质量,甚至导致事务超时,则低延迟为关注点
- 客户端及嵌入式应用:垃圾收集的内存占用是关注点
应用基础硬件
- 硬件规格及架构X86、SPARC还是ARM
- 处理器数量、分配内存的大小
- 操作系统:Linux、Solaris还是Windows等
JDK的版本
- JDK的发行商、版本号
- 对应了《Java虚拟机规范》的哪个版本
B/S架构收集器选择举例
延迟是这类应用的主要关注点
- 有充足的预算但没有太多调优经验:
带商业技术支持的专有硬件或者软件解决方案:VEga系统和Zing VM,C4收集器 - 没有预算,有软硬件知识:
ZGC - 运行在Windows操作系统下:
Shenandoah - 接手遗留系统,软硬件和JDK版本落后:
内存4~6GB:CMS
大内存:G1
虚拟机及垃圾收集器日志
阅读分析虚拟机和垃圾收集器的日志是处理Java虚拟机内存问题必备的基础技能。。。
垃圾收集器日志没有任何的“业界标准”可言,每个收集器的日志格式都可能不宜一样
JDK9以前,hotSpot没有提供统一的日志处理框架,虚拟机各个功能模块日志开关分布在不同的参数上。
JDK9以后,HotSpot所有功能的日志都收归到了“-Xlog”参数上
-Xlog[:[selector][:output][:[decorators][:output-options]]]
- 参数:
- 选择器(selector):由标签(tag)和日志级别(level)共同组成。
- 标签:可理解为虚拟机中某个功能模块的名字,告诉日志框架用户希望得到虚拟机哪些功能的日志输出。垃圾收集器的标签名称为“gc”,全部支持的功能模块标签名N多,不一一例举
- 日志级别从低到高,共有Trace、Debug、Info、Warning、Error、Off六种级别,默认为Info
- 日志规则与Log4J、SLF4j大体上一致
- 可用修饰器(Decorator)来要求每行日志输出都附加上额外的内容,日志行信息包括:
time:当前日期和时间
uptime:虚拟机启动到现在经过的时间,以s为单位
timemillis:当前时间的ms数
uptimemillis:虚拟机启动到现在经过的ms数
timenano:当前时间的ns数
uptimenanos:虚拟机启动到现在经过的ns数目
pid:进程IP
tid:线程ID
level:日志级别
tags:日志输出的标签集
如果不指定,默认值是uptime、level、tags
JDK9前后日志参数举例
- 查看GC基本信息
JDK9之前-XX:+PrintGC
JDK9之后-Xlog:gc
- 查看GC详细信息
JDK9之前-XX:+PrintGCDetails
JDK9之后-Xlog:gc* 用通配符*将GC标签下所有细分过程都打印出来,如果调整日志级别Debug会有更多信息
- 查看GC前后的堆、方法区可用容量变化
JDK9之前-XX:+PrintHeapAtGC
JDK9之后-Xlog:ge+heap=debug
- 查看GC过程中用户线程并发时间以及停顿的时间
JDK9之前-XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCApplicationStoppedTime
JDK9之后-Xlog:safepoint
- 查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息
JDK9之前-XX:+PrintAdaptiveSizePolicy
JDK9之后-Xlog:gc+ergo*=trace
- 查看熬过收集后剩余对象的年龄分布信息
JDK9之前-XX:+PrintTenuringDistribution
JDK9之后-Xlog:gc+age=trace
垃圾收集器参数总结
- 垃圾收集相关的常用参数
偷个网上的图
实战:内存分配会回收策略
Java技术体系的自动内存管理,最根本的目标是自动化地解决自动给对象分配内存以及自动回收分配给对象的内存
对象的内存分配从概念上将,应该都是在堆上分配(实际上也有可能经过即时编译后被查三位标量类型并间接地在栈上分配)。
在经典分代的设计下,新生代对象通常会分配在新生代中,少数情况下(如对象大小超过一定阈值)也可能会直接分配在老年代
下面例子JVM是HotSpot虚拟机,以客户端(UseSerialGC)运行,且未指定收集器组合
对象优先在Eden分配
对象在新生代Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
import org.junit.Test;
/**
* @Author jtao
* @Date 2021/1/22 14:32
* @Description
*/
public class 内存分配与回收 {
private static final int _1MB = 1024 * 1024;
/**
* @Author jtao
* @Date 2021/1/22 14:37
* @Description
* VM参数:-XX:+UseSerialGC -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
@Test
public void testAllocation() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB];//第一次出现Minor GC
}
}
上图代码限制了堆内存20M,新生代为10M,eden与一个survivor比例为8:1
图片所示新生代的eden:survivor=8192K:1024K。
在堆给allocation4对象分配内存时,因为总共8M,已经使用6M,不够4M分配内存。所以发生Minor GC,但存活的3个2M对象都无法转存到只有1M的Survivor空间,所以通过分配担保机制提前转移到老年代去。之后新生代的eden存储4M的allocation4,所以使用了51%,老年代存储其他3个共6M,所以显示如下结果
大对象直接进入老年代
大对象定义
需要大量连续内存空间的Java对象,例如很长的字符串,或者元素数量很庞大的数组,例如byte[]数组
大对象对于内存的坏处
大对象堆虚拟机内存分配来说并不友好,当遇到一个“朝生夕灭”、“短命大对象”就更不友好,编写代码时应尽量避免
因为大对象需要连续的内存空间,会提前触发垃圾收集。且复制时开销会比较大
HotSpot解决办法:-XX:PretenureSizeThreshold
提供了-XX:PretenureSizeThreshold参数
指定大于该设置值的对象直接在老年代分配,目的是为了避免在Eden区及两个Survivor区之间来回复制,产生大量的复制操作
/**
* @Author jtao
* @Date 2021/1/22 15:55
* @Description 大对象直接进入老年代
* VM参数:-XX:+UseSerialGC -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=1
*/
@Test
public void testPretenureSizeThreshold(){
byte[] allocation;
allocation=new byte[4* _1MB];
}
由上图可知老年代10M空间被使用了40%,即为被分配的对象
长期存活的对象将进入老年代
分代管理时,内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。
虚拟机给每个对象定义了一个对象年龄(Age)机器数,存储在对象头中。对象通常在Eden诞生,如果经过第一次Minor GC后仍然存活,且能被Survivor容纳,则被移动到Survivor中,Age+1,当Age达到一定程度(默认为15),就会被晋升到老年代中。
可通过-XX:MaxTenuringThreshold设置Age
动态对象年龄判定
为了更好地适应不同程序的内存状况,HotSpot可以在对象年龄没有达到默认次数就可以晋升老年代。
如果Survivor空间中相同年龄所有对象大小的综合大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
/**
* @Author jtao
* @Date 2021/1/22 20:16
* @Description 动态对象年龄判定
* VM参数:-XX:+UseSerialGC -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
@Test
public void testTenuringThreshold2() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[_1MB / 4];
allocation2 = new byte[_1MB / 4];//allocation1和allocation2大于survivor空间1M的一半
allocation3 = new byte[4 * _1MB];
allocation4 = new byte[4 * _1MB];
allocation4 = null;
allocation4 = new byte[4 * _1MB];
}
空间分配担保 :XX:HandlePromotionFailure JDK7以后失效了
- 步骤
1.在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,
2.如果成立,则该次Minor GC可以确保安全。
如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败。
3.如果允许,继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
4.如果大于,将尝试一次冒险的Minor GC,如果失败则需要重新发起一次Full GC,等待的停顿时间就非常长了
5.如果小于,或者以上参数均不成立则需要进行一次Full GC
失效
XX:HandlePromotionFailure JDK6 Update 24之后就已经失效
只要老年代的连续空间大于新生代对象总大小,或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC
Comments | 0 条评论