《深入理解Java虚拟机》的阅读笔记——第三章 垃圾收集器与内存分配策略。
参考了JavaGuide网站的相关内容:https://javaguide.cn/
Q:哪些内存需要回收?什么时候回收?如何回收?
2 对象已死吗?
2.1 引用计数法
- 给对象中添加一个引用计数器
- 每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;
- 任何时刻计数器为0的对象就是不可能再被使用的
缺点:很难解决对象之间相互循环引用的问题。
public class ReferenceCountingGC {
public Object instance = null;
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
//假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}
如果使用的是引用计数法,objA和objB不会被回收。但实际上运行时,虚拟机并没有因为这两个对象相互引用就不回收它们,也侧面证明了java虚拟机没有采用这种方法。
2.2 可达性分析算法
通过一系列的称为GC Roots
的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。
当一个对象到GC Roots
没有任何引用链相连(从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
在Java语言中,可作为GC Roots
的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
2.3 引用
在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。
类比于整理桌子的话,强引用是宿舍桌子上的电脑,不得不使用它;软引用是switch,可以放着,但是桌子空间不够的时候收起来也行;软引用是吃完了的薯片袋子,垃圾收集的时候就被处理掉;虚引用是墙上照片里的物品,照片如何并不会对清理桌子的行为产生影响。
强引用
在程序代码之中普遍存在的,类似Object obj = new Object()
这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。【是程序正常执行的生活必需品,即使内存溢出也不会回收】
软引用
有用但不是必须的对象,在程序将要发生内存溢出异常之前,将把这些对象列进回收范围进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用
非必需对象,只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
2.4 生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于**“缓刑”**阶段。
要真正宣告一个对象死亡,至少要经历两次标记过程:
-
如果对象在进行可达性分析后发现没有与
GC Roots
相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。- 没有必要,直接死刑哩:当对象没有
overwrite finalize()
方法,或者finalize()
方法已经被虚拟机调用过
- 没有必要,直接死刑哩:当对象没有
-
finalize()
方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue
中的对象进行第二次小规模的标记。- 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做
F-Queue
的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer
线程去执行它。 - 虚拟机触发这个方法,但并不承诺会等待它运行结束。(避免发生死循环等情况,导致整个内存回收系统崩溃)
- 如果对象要在finalize()中成功拯救自己:只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合。
- 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做
在下面的代码中,任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
建议尽量避免使用
finalize()
,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。玛尼玛尼哄~忘掉它吧!
/**
* 此代码演示了两点:
* 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 mehtod executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停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();
//因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
finalize mehtod executed!
yes, i am still alive :)
no, i am dead :(
3 垃圾收集算法
3.1 标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:
- 效率问题,标记和清除两个过程的效率都不高。
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.2 复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
现在的商业虚拟机都采用这种算法来回收新生代。
IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照 1 : 1 1:1 1:1的比例来划分内存空间。
将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是 8 ∶ 1 8∶1 8∶1,也就是每次新生代中可用内存空间为整个新生代容量的 90 % ( 80 % + 10 % ) 90\% (80\%+10\%) 90%(80%+10%),只有10%的内存会被“浪费”。
当然,我们没有办法保证每次回收都只有不多于10%的对象存活,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
3.3 标记整理算法
复制-收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.4 分代收集算法
根据对象存活周期的不同将内存划分为几块。
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
- 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
- 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者**“标记—整理”**算法来进行回收。
6 内存分配与回收策略
6.1 对象优先在Eden分配
- 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
- 如果GC 期间虚拟机又发现 Eden上的对象无法存入 Survivor 空间,将通过 分配担保机制 把新生代的对象提前转移到老年代中去。
- 执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存。
public class GCTest {
public static void main(String[] args) {
byte[] allocation1, allocation2,allocation3,allocation4,allocation5;
allocation1 = new byte[32000*1024];
allocation2 = new byte[1000*1024];
allocation3 = new byte[1000*1024];
allocation4 = new byte[1000*1024];
allocation5 = new byte[1000*1024];
}
}
allocation1
,分配在Eden上:
allocation2
,Eden区没有足够空间进行分配,虚拟机发起一次Minor GC。allocation1
过大,无法存入 Survivor 空间,将通过 分配担保机制 将其提前转移到老年代中去,老年代上的空间足够存放 allocation1
,所以不会出现 Full GC。
allocation3
,allocation4
,allocation5
被分配在Eden上。
6.2 大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,比如很长的字符串以及数组。
比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免。
6.3 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。
- 虚拟机给每个对象定义了一个对象年龄(Age)计数器。
- 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。
- 对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度,就将会被晋升到老年代中。
6.4 总结★
GC两大分类
-
部分收集 (Partial GC):
-
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
-
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
-
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。(只有G1收集器可以做到)
-
-
整堆收集 (Full GC):收集整个 Java 堆和方法区。
各种GC的触发条件
以Serial GC为例:
- 当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
- 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC,收集整个GC堆(除了CMS收集器外,其他能收集老年代的GC都会同时收集整个GC堆)。
- 如果有Perm Gen的话,要在Perm Gen分配空间但已经没有足够空间时,也要触发一次full GC。
System.gc()
也默认触发full GC。
5 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
并不存在放之四海皆准、任何场景下都适用的完美收集器。需要根据场景合适选择。
5.1 Serial收集器
最基本、发展历史最悠久的收集器。
一个单线程的收集器,在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。新生代采用标记-复制算法,老年代采用标记-整理算法。
缺点:Stop The World 会带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短。
优点:它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
它依然是虚拟机运行在Client模式下的默认新生代收集器。
5.2 ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本。
ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
- CMS:并发的收集器,作为老年代的收集器使用。
- CMS无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。
● 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
● 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
5.3 Parallerl Scavenge收集器
新生代采用标记-复制算法,老年代采用标记-整理算法。
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。
CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。
吞吐量
=
运行用户代码的时间
C
P
U
总消耗时间的比值
吞吐量=\frac{运行用户代码的时间}{CPU 总消耗时间的比值}
吞吐量=CPU总消耗时间的比值运行用户代码的时间
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
5.4 Serial Old 收集器
Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
5.5 Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
5.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
整个垃圾回收过程分为4个步骤:
- 初始标记:暂停所有的其他线程,标记一下GC Roots能直接关联到的对象,速度很快;
- 并发标记:同时开启 GC 和用户线程,进行GC Roots Tracing,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记:暂停所有的其他线程,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,比初始标记时间稍长,比并发标记时间短。
- 并发清除:开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
主要优点:并发收集、低停顿。
缺点:
- CMS收集器对CPU资源非常敏感;
- 无法处理浮动垃圾;
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”
G1 收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于**“标记-复制”**算法实现的。
可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
总结
Q:哪些内存需要回收?什么时候回收?如何回收?
我的回答:
通过GC Roots可达性分析,执行finalize方法后未自救成功的对象需要被回收,(若无finalize方法或者执行过一次,直接死刑),GC Roots包括栈帧中的本地变量表、类中的静态属性、常量引用的对象、Native方法引用的对象。引用可分为强引用、软引用、弱引用、虚引用,强引用的对象不会被回收;软引用的对象在第一次GC后若仍空间不足,再次GC时会被回收;弱引用的对象在第一次GC时就将被回收;虚引用不影响垃圾回收,
什么时候回收?以Serial垃圾收集器为例,对象优先被分配在新生代Eden区(大对象则进入老年代),当Eden区没有空间时,检查老年代空间是否大于历次平均晋升大小,如果大于,进行Minor GC,否则进行Full GC。
回收方法包括:标记-清除法,标记-复制法,标记-整理法。标记-复制法通常用于较少对象存活的新生代,标记-整理法通常用于老年代。不同垃圾收集器的回收方法有所区别。
- Serial为单线程垃圾回收器,用于新生代,采用标记-复制法,Serial Old用于老年代,采用标记-整理法;
- ParNew收集器是Serial的多线程版本;
- Parallel Scavenge优先考虑吞吐量而不是最短等待时间,Parallel Old是其老年代版本。同样使用标记-复制法和标记-整理法。
- CMS使用初次标记、并发标记、重新标记、并发清除方法,以获取最短回收停顿时间为目标。
- G1收集器将整个GC堆划分为多个Region,保留了分代的概念。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。