《深入理解Java虚拟机》的阅读笔记——第三章 垃圾收集器与内存分配策略。
参考了JavaGuide网站的相关内容:https://javaguide.cn/
Q:哪些内存需要回收?什么时候回收?如何回收?
缺点:很难解决对象之间相互循环引用的问题。
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虚拟机没有采用这种方法。
通过一系列的称为GC Roots
的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。
当一个对象到GC Roots
没有任何引用链相连(从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
在Java语言中,可作为GC Roots
的对象包括下面几种:
在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。
类比于整理桌子的话,强引用是宿舍桌子上的电脑,不得不使用它;软引用是switch,可以放着,但是桌子空间不够的时候收起来也行;软引用是吃完了的薯片袋子,垃圾收集的时候就被处理掉;虚引用是墙上照片里的物品,照片如何并不会对清理桌子的行为产生影响。
在程序代码之中普遍存在的,类似Object obj = new Object()
这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。【是程序正常执行的生活必需品,即使内存溢出也不会回收】
有用但不是必须的对象,在程序将要发生内存溢出异常之前,将把这些对象列进回收范围进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
非必需对象,只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于**“缓刑”**阶段。
要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots
相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
overwrite finalize()
方法,或者finalize()
方法已经被虚拟机调用过finalize()
方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue
中的对象进行第二次小规模的标记。
F-Queue
的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer
线程去执行它。在下面的代码中,任何一个对象的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 :(
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
现在的商业虚拟机都采用这种算法来回收新生代。
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空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
复制-收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
根据对象存活周期的不同将内存划分为几块。
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
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上。
所谓的大对象是指,需要大量连续内存空间的Java对象,比如很长的字符串以及数组。
比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免。
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。
部分收集 (Partial GC):
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。(只有G1收集器可以做到)
整堆收集 (Full GC):收集整个 Java 堆和方法区。
以Serial GC为例:
System.gc()
也默认触发full GC。如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
并不存在放之四海皆准、任何场景下都适用的完美收集器。需要根据场景合适选择。
最基本、发展历史最悠久的收集器。
一个单线程的收集器,在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。新生代采用标记-复制算法,老年代采用标记-整理算法。
缺点:Stop The World 会带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短。
优点:它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
它依然是虚拟机运行在Client模式下的默认新生代收集器。
ParNew收集器其实就是Serial收集器的多线程版本。
ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
● 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
● 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
新生代采用标记-复制算法,老年代采用标记-整理算法。
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。
CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。
吞吐量 = 运行用户代码的时间 C P U 总消耗时间的比值 吞吐量=\frac{运行用户代码的时间}{CPU 总消耗时间的比值} 吞吐量=CPU总消耗时间的比值运行用户代码的时间
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
整个垃圾回收过程分为4个步骤:
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
主要优点:并发收集、低停顿。
缺点:
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”
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。
回收方法包括:标记-清除法,标记-复制法,标记-整理法。标记-复制法通常用于较少对象存活的新生代,标记-整理法通常用于老年代。不同垃圾收集器的回收方法有所区别。