第3章 垃圾收集器与内存分配策略
3.2 对象已死
Java世界中的所有对象实例,垃圾收集器进行回收前就是确定对象哪些是活着的,哪些已经死去。
3.2.1 引用计数算法
常见的回答是:给对象中添加一个引用计数器,有地方引用,计数器+1,引用失效,计数器-1,任何时刻计数器为零的对象就是不可能再被使用了。
引用计数算法(Reference Counting)占了一些额外内存空间来计数,但原理简单,判定效率高,大多数下也是一个好的算法。
Java领域主流虚拟机都没用引用计数算法,原因为看似简单,很多例外情况要考虑,要配合大量额外处理才能保证正确地的工作,例如很难解决对象之间相互循环引用的问题。
引用计数算法陷阱
package a.b.c;
/**
*
*/
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 testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.out.println("---------进行GC!---------");
//假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
证明虚拟机没有因为两个对象互相引用就放弃回收它们。
所以Java虚拟机不是通过引用计数算法判断对象是否存活的。
3.2.2 可达性分析算法
Java通过可达性分析(Reachability Analysis)算法来判定对象是否存活。
基本思路:通过一些列"GC Roots"的根对象作为起始节点集,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链(Reference Chain)",如果某对象到GC Roots间没有任何引用链相连,说明这个对象不可达,证明此对象不可能再被使用。
Java技术体系里,固定可作为GC Roots的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 方法区中,类静态属性引用的对象。
- 方法区中,常量引用的对象,字符串常量池(String Table)里的引用。
- 本地方法栈中JNI(Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,常驻的异常对象NullPointException,OutOfMemoryError等,系统类加载器。
- 被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调、本地代码缓存等。
3.2.3 再谈引用
Java引用的传统定义:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称该reference数据是代表某块内存,某个对象的引用。
更广义的讲,Java对引用的概念进行了扩充,将引用分为**强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)**4种,引用强度依次减弱。
- 强引用是最传统的引用,指在程序代码中普遍存在的引用赋值,类似"Object obj = new Object()",这种引用关系,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用用来描述一些还有用,但非必须得对象,只被软引用关联着的对象在系统发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收,如果这次回收还没有足够内存,会抛出内存溢出异常,JDK1.2之后提供SoftReference类来实现软引用。
- 弱引用也是用来描述非必须对象,强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止,当垃圾收集器开始工作,无论当前内存是否足够,都会回收弱引用关联的对象,提供WeakReference类来实现。
- 虚引用也称为幽灵引用或幻影引用,最弱的引用关系,一个对象是否有虚引用存在不会对其生成时间构成影响,也无法通过虚引用获取对象实例,唯一目的是为了能在这个对象被收集器回收时收到一个系统通知,PhantomReference类实现。
3.2.4 生存还是死亡?
可达性分析算法判定为不可达对象也不似非死不可,这时候处于缓刑,宣告对象死亡要经历两次标记过程:对象可达性分析后没有与GC Roots相连接的引用链,第一次标记,对象没有覆盖finalize()方法或此方法已经被虚拟机调用过,虚拟机将这两种情况视为"没有必要执行"。
对象被判定为执行finalize()方法,会被放到F-Queue队列中,由虚拟机自动建立,低调度优先级的Finalizer线程去执行它们的finalize()方法。
最后一次逃脱机会是在F-Queue队列的对象被收集器第二次小规模标记,若对象重新与引用链连接上,第二次标记会溢出即将回收的集合,若没有逃脱,那么就要回收了。
一次对象的自我拯救演示
package a.b.c;
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes,i am still alive 0.0");
}
@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 InterruptedException {
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("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("no,i am dead *.*");
}
}
}
两段完全一样的代码执行结果一次逃脱成功,一次失败,因为finalize()方法只会被系统自动调用一次,第二次不执行了,第二段代码自救失败了。
finalize()方法不推荐使用,把它忘了吧。
3.2.5 回收方法区
方法区垃圾收集性价比低,主要回收:废弃的常量和不再使用的类型,如没有任何一个字符串对象引用常量池中的"java"常量,虚拟机中其他地方也没引用,则它会被系统清理出常量池。
不是使用的类,判定起来比较苛刻:
- 该类以及子类所有实例已经被回收。
- 加载该类的类加载器已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
也仅仅是被允许,提供了一系列参数控制类的卸载,自己查吧。
大量使用反射,动态代理,CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景,通常都需要具备Java虚拟机类型卸载的能力,保证不会对方法区造成过大的内存压力。
3.3 垃圾回收算法
理论细节参考《垃圾回收算法手册》2~4章内容。
垃圾回收算法可划分为:**引用计数式垃圾收集(Reference Counting GC)和追踪式垃圾收集(Tracing GC)**两大类。
主流Java虚拟机都采用的是追踪式垃圾收集。
3.3.1 分代收集理论
当前商业虚拟机的垃圾收集器,大多数遵循"分代收集"(Generational Collection)的理论进行设计。是程序的经验法则,建立在两个分代假说之上:
1.弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕死。
2.强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难消亡。
这两个假说奠定了垃圾收集器的一致设计原则:
收集器将Java堆划分出不同的区域,将回收对象依据年龄(熬过垃圾收集的次数)分配到不同的区域之中存储。大多数朝生夕死的对象放在一起,每次回收只关注少量存活的对象,而不去标记大量要被回收的对象,以低频率来回收这个区域,同时兼顾了垃圾收集的时间开销和内存空间的有效利用。
Java堆划分区域后,每次只回收某一个或某部分区域,也就有了**“Minor GC,新生代垃圾回收”,“Major GC,老年代垃圾回收”,“Full GC”**这些回收类型的划分,也就有了针对不同区域与存储对象存亡特征相匹配的垃圾收集算法,“标记-清除算法”,“标记-整理算法”,“标记清除算法”。Minor:/ˈmaɪnər/未成年,少数的,次要的,Major:/ˈmeɪdʒər/主要的,大的。
一般至少会把堆分为"新生代(Young Generation)"和**老年代(Old Generation)**两个区域。
新生代中,每次垃圾收集都发现大批对象死去,每次回收后存活的少量对象都会逐步晋升到老年代中存放。分代收集并非简单划分内存区域那么简单,至少有1条,对象不是孤立的,对象之间会存在跨代引用。
假设进行新生代区域的收集(Minor GC),新生代对象可能被老年代所引用,为找出新生代中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也一样。遍历老年代所有对象可行,但为内存回收带来很大的性能负担,为解决这个问题,添加第三条经验法则:
3.跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
隐含推论:存在互相引用关系的两个对象,应该倾向于同时生存或者同时消亡的。
如某新生代对象引用老年代对象,老年代对象难以消亡,新生代对象得以存活,进而年龄增长后晋升到老年代中,这时跨代引用也随之消除了。
根据第3假说,不应该为少量跨代引用去扫描整个老年代,也不必浪费空间记录每个对象是否存在跨代引用,只需再新生代上建立全局的数据结构(记忆集,Remembered Set),此结构把老年代划分若干小块,标识老年代哪块内存存在跨代引用,发生MinorGC时,跨代引用小块的对象才会被加入到GC Roots进行扫描。改变引用关系维护记录数据的正确性会增加一点开销,但是比起扫描整个老年代来说还是划算的。
收集分类
- 部分收集(Partial GC):指目标不是完整收集整个Java堆,而是收集部分,又分为:
- 新生代收集(Minor GC/Young GC):只收集新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。目前只有CMS会单独收集老年代的行为,另外注意,Major GC说法有混淆,根据上下文看到底是指老年代收集还是整堆收集。
- 混合收集(Mixed GC):
- 整堆收集(Full GC):收集整个Java堆的方法区的垃圾收集。
3.3.2 标记-清除算法
最基础的垃圾收集算法,**标记-清除(Mark-Sweep)**算法。
算法分为两阶段:
- 标记阶段:标记所有需要回收的对象。
- 清除阶段:标记完成后回收掉所有被标记的对象。
缺点有两个:
- 执行效率不稳定,堆中包含大量对象,大部分需要被回收,要进行大量标记和清除动作,两个动作的执行效率随对象数量增长而降低。
- 内存碎片化问题,清除后产生大量不连续的内存碎片,碎片太多当程序需要分配大对象时无法找到连续的内存而不得不提前触发一次垃圾收集动作。
3.3.3 标记-复制算法
简称为复制算法,解决了大量可回收对象执行效率低的问题。
最早提出的了"半区复制(Semispace Copying)",将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,一块用完,将存活的对象复制到另外一块上面,把已使用过的内存空间一次清理掉。
优点:不用考虑内存碎片,分配内存时移动堆顶的指针,按顺序分配即可实现简单,运行高效。
缺点:可用内存缩小为原来的一半,空间浪费太多。
现在的Java虚拟机优化了这种收集算法。
新生代中的对象98%熬不过第一轮收集,因此不需要1:1的比例来划分新生代的内存空间。
更优化的半区复制分代策略,称为"Appel式回收"。
HotSpot虚拟机的Serial、ParNew等新生代收集器采用这种策略。
新生代内存区域,每次使用Eden+其中1个Survivor,共计90%内存区域。
- Eden,80%
- Survivor0,10%
- Survivor1,10%
老年代内存区域
若上面Minor GC垃圾回收后,复制到Survivor中的对象超过10%,触发了分配担保(Handle Promotion),将这些对象直接进入到老年代,这是安全的。
3.3.4 标记-整理算法
复制算法中,对象存活率高的极端情况,复制操作多,效率低,而且不想浪费50%内存就要进行分配担保,用来应对100%对象存活的极端情况,所有老年代一般不能直接选用这种算法。
根据老年代对象活的久的特点,提出了"标记-整理算法(Mark-Compact)".
标记过程:还是标记-清除算法。
整理过程:将所有存活的对象向内存空间一端移动,然后直接清理边界以外的内存。
移动存活的对象是极重的负担, 操作过程要暂停用户应用程序才能进行。
Stop The World,全局停止,世界停止,这个系统开销也了不得。
如果不考虑移动和整理的话,空间碎片化问题就要用"分区空闲分配链表"来解决,增加额外负担,影响程序的吞吐量。
基于以上了两点,是否移动对象都有弊端,移动则内存回收复杂,不移动则内存分配复杂。
有一种折中的方法,虚拟机平时多数时间采用标记-清除算法,暂时容忍碎片,知道内存碎片大到影响对象分配时,再采用标记整理算法收集一次,以获得规整的内存空间,基于标记-清除算法CMS收集器面临的空间碎片过多时就采用这种办法。
3.4 HotSpot的算法细节实现
较枯燥,可先看垃圾收集器。
3.5 经典垃圾收集器
如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。
规范没有对垃圾回收器的实现做规定。
各款经典收集器之间的关系图:
展示了7种作用于不同分代的收集器,两个收集器之间存在连线,就说明它们可以搭配使用。
收集器所处的区域标识它们属于新生代收集器或是老年代收集器(Tenured,终身的,长期保有的)。
明确一个观点:各个收集器的比较不是挑选最好的收集器出来,没出现最好的收集器,更不存在万能收集器,只是选择最合适的收集器。
新生代垃圾回收器:Serial,ParNew,ParallelScavenge
老年代垃圾回收器:CMS,Serial Old(MSC),Parallel Old
3.5.1 Serial收集器
Serial/ˈsɪəriəl/连续的,排成顺序的。
最基础,历史最悠久,JDK1.3.1之前是新生代收集器的唯一选择。
单线程工作的收集器。
不仅仅使用一个处理器或一条收集线程去完成垃圾收集工作,进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
会"Stop The World",在用户不可知的情况下,把正常工作的线程全部停掉,这无法接受。
下图Serial/Serial Old收集器的运行过程。
给用户带来恶劣体验,早起设计者们完全理解,但也很委屈,你妈妈给你打扫房间的时候,肯定会让你老老实实的在椅子上或者房间外待着,如果她一边打扫,一边扔纸屑,这房间还能打扫完?,这也合情合理。
看似老而无用,实则依然是HotSpot运行在客户端模式下默认的新生代收集器。
优点:简单而高效,内存受限的情况下,内存额外消耗最小,对于单核或处理器核心少的环境来说,没有线程交互的开销,尤其微服务应用中,分配内存小,收集几十到一两百兆新生代,垃圾回收停顿时间可以控制在十几、几十毫秒,最多100毫秒以内,只要不频繁发生,这些停顿时间可以接受。
缺点:单线程,暂停世界导致全部工作线程频繁停顿,不适合在大内存虚拟机中使用。
3.5.2 ParNew收集器
ParNew是Serial收集器的多线程并行版本。
除了使用多条线程进行垃圾收集之外,其余包括Serial可用的所有控制参数
-XX:SurvivorRatio,ratio(比例),SurvivorRatio 指的是幸存区比例。在垃圾回收机制中,通常会有新生代和老年代等不同的内存区域划分,而新生代中又有 Eden 区和两个 Survivor 区。SurvivorRatio 这个参数用于设置 Eden 区和 Survivor 区的大小比例关系。例如,如果设置为 8,则表示 Eden 区和 Survivor 区的大小比例为 8:1:1。
-XX:PretenureSizeThreshold,中文意思是 “晋升到老年代的大小阈值”。在 Java 虚拟机中,这个参数用于设置对象从新生代晋升到老年代之前的大小阈值。如果对象的大小超过这个阈值,就会直接晋升到老年代,而不是在新生代中经历多次垃圾回收。这个参数可以帮助优化垃圾回收的性能,根据不同的应用场景进行调整。例如,对于创建大型对象较多的应用,可以适当调大这个阈值,以减少新生代的垃圾回收次数。一般来说,大对象是32K,也有1M,或2M。
-XX:HandlerPromotionFailure,垃圾回收(GC)过程中的对象晋升失败相关,原因有老年代空间不足,老年代内存碎片放不下晋升的对象,大对象直接分配给老年代导致老年代空间不足。老年代还未充分整理,新生代对象就晋升为老年代,导致空间不足,JVM设置的老年代太小导致无法满足晋升的需求。
收集算法,暂停世界,对象分配规则等与Serial收集器完全一致,共用很多代码。
ParNew收集器工作过程:
是JDK7之前遗留系统中首选的新生代收集器。
有一个重要原因:除Serial收集器外,只有它能与CMS收集器配合工作。
ParNew是激活CMS后的默认新生代收集器,也只能相互搭配使用,不能和其他收集器配合了。
- 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条线程在协同工作,通常默认此时用户线程是出于等待状态。
- 并发(Concurrent):并发描述的垃圾收集器线程和用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。
3.5.3 Parallel Scavenge(清理)收集器
也是新生代收集器,基于标记复制算法,能够并行收集的多线程收集器。
特点与其他不同,CMS等关注点是缩短用户线程的停顿时间。
而它则是达到一个可控制的吞吐量(Throughput)。
$ 吞吐量=\frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间} $
如果虚拟机完成某个任务,总共耗费100分钟,垃圾收集花费1分钟,吞吐量就是99%。
Parallel Scavenge收集器提供两个参数用于精确控制吞吐量:
最大垃圾收集停顿时间:-XX:MaxGCPauseMillis:大于0的毫秒数,越小,频率越高
直接设置吞吐量大小:-XX:GCTimeRatio:0<整数<100,默认99,则允许1%,19则是5%。
也经常被称为"吞吐量优先收集器"
自适应调节策略(GC Ergonomics)也是Parallel Scavenge收集器区别于ParNew收集器的重要特征,把内存管理的调优任务交给虚拟机完成,把内存数据设置好-Xmx。
3.5.4 Serial Old收集器
是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记复制算法。
JDK5之前与Parallel Scavenge收集器搭配使用。
作为CMS收集器发生失败时的后备预案。
3.5.5 Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,标记-整理算法。
3.5.6 CMS收集器
CMS(Concurrent Mark Sweep)是以获取最短回收停顿时间为目标的收集器。
B/S架构更关注服务的响应速度,希望系统停顿时间更短,CMS更符合要求。
基于标记-清除算法实现的:
- 初始标记(CMS initial mark),标记GC Roots关联到的对象,速度很快,会Stop The World。
- 并发标记(CMS concurrent mark),通过GC Roots的直接关联对象遍历整个对象图的过程,可与用户线程并发。
- 重新标记(CMS remark),修正并发标记期间用户继续运行导致的标记产生变动的对象,会STW
- 并发清除(CMS concurrent sweep),并发删除掉已经判断死亡的对象,可与用户线程并发。
优点:并发收集、低停顿。
缺点:1.对处理器资源敏感,核心4个以上,并发回收线程不超过25%,2.无法处理浮动垃圾(新产生的垃圾),3.空间碎片多,空间不够分配对象会Full GC。
3.5.7 Garbage First收集器
G1,全功能垃圾回收器,服务端默认回收器。
开创Region布局,遵循分代理论,但是堆内存划分为大小相等的独立区域(Region),每个区域可以根据需要扮演新生代Eden,Survivor,老年代。
Region中超过容量(1-32MB,为2的幂,通常2MB)的一半判定为大对象,存放在多个Humongous Region块中,把它看成老年代。
6-8GB内存以上,G1表现更好,否则CMS。
3.6 低延迟垃圾收集器
垃圾收集器三项最重要指标:内存占用(Footprint)、吞吐量(Throughtput)、延迟(Latency)。
不可能三角,最多占两个。延迟是最重要的指标。
3.6.1 Shenandoah收集器(谢南多厄)
同G1相似,基于Region堆,放大对象Humongous Region,优先处理回收价值最大的Region。
不同点:支持并发整理算法,回收阶段以多线程并行,却不能与用户线程并发。不分代。
工作过程分为9个阶段:
等待。。。
3.6.2 ZGC收集器
JDK11加入,低延迟垃圾收集器。垃圾收集停顿时间限制在10毫秒。
也采用Region堆内存布局:
待定。。。