垃圾收集器与内存分配策略(深入理解Java虚拟机笔记(二))

参考资料:《深入理解Java虚拟机:JVM高级特性与最佳实践》 周志明 著

垃圾收集器(GC)概述

垃圾收集器(Garbage Collection,GC)需要完成三件事:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

GC管理的内存区域

GC主要管理Java堆和方法区。

Java内存运行时区域中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈、出栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑回收问题,因为方法结束或线程结束时,内存自然就跟着回收了。

而Java堆和方法区不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC所关注的是这部分内存。

引用计数算法(判断对象是否存活)

给对象中添加一个引用计数器,每当有一个地方引用它(指该对象)时,计数器值就+1;当引用失效时,计数器值就-1;任何时刻计数器都为0的对象就是不可能再被使用的。

缺点:很难解决对象之间的相互循环引用的问题。

Java语言使用根搜索算法判断对象是否存活。

根搜索算法(主流程序语言使用该算法来判断对象是否存活)

基本思路:通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

可作为GC Roots的对象包括4种:

  1. 虚拟机栈(栈帧中的变量表)中的引用的对象;
  2. 方法区中的类静态属性引用的对象;
  3. 方法区中的常量引用的对象;
  4. 本地方法栈中Native方法的引用的对象。

如图,对象object 5、object 6、object 7虽然有关联,但它们与GC Roots是不可达的,所以它们将会被判定为可回收的对象。

再谈引用(由强到弱,4种)

  • 强引用:类似“Object obj = new Object()”这类的引用。只要存在,GC永远不会回收。
  • 软引用:描述一些还有用,但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中并进行第二次回收;若回收后还是没有足够的内存,才会抛出OOM异常。
  • 弱引用:描述非必需对象,强度比软引用更弱,其对象只能生存到下一次垃圾收集之前。GC工作时直接回收。
  • 虚引用:有无不会影响对象的生存时间,无法通过虚引用来取得对象实例。唯一目的是在该对象被回收时收到一个系统通知。

生存还是死亡

在根搜索算法中不可达的对象,此时暂时处于“缓刑”阶段,对象要真正死亡,至少要经历两次标记过程:

如果对象在进行根搜索算法后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选(筛选条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”),如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为“F-Queue”的队列之中,并在稍后,由一条由JVM自动建立的、低优先级的Finalizer线程去执行。

注意    这里的“执行”是指JVM会触发这个方法,但并不承诺等待它运行结束。其原因为:如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那它就真的离死不远了。

下面例子可以看到一个对象的finalize()被执行,但是它仍然存活。

/**
 * 此代码演示了两点:
 * 1、对象可以在被GC时自我拯救;
 * 2、这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次。
 * @author Administrator
 */
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 InterruptedException {
		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()方法都只会被系统自动调用一次。

这种方法只是不是C/C++中的析构函数,只是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协。其运行代价高昂,不确定性大,无法保证各个对象的调用顺序,应避免使用。

回收方法区

  • 在堆中,新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间;
  • 永久代(特指HotSpot虚拟机的永久代)的垃圾收集效率远低于此。

永久代主要回收:废弃常量和无用的类。

判定一个类时否是“无用的类”的条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方被反射访问该类的方法

虚拟机对满足上述3个条件的无用类可以回收,但非必然回收。是否回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class及-XX:+TraceClassLoading、-XX:+TraceClassLoadUnLoading查看类的加载和卸载信息。-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,但是-XX:+TraceClassLoading参数需要fastdebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。

垃圾收集算法

标记-清除算法(Mark-Sweep)

最基础的收集算法。算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

缺点:

  1. 效率问题:标记和清除过程的效率都不高;
  2. 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的执行过程如图:

复制算法(Copying)

目的:为了解决效率问题。

将可用内存按容量划分为大小相等的两块,每次只用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

缺点:内存缩小为原来的一半;在对象存活率较高时就要执行较多的复制操作,效率将会变低。

复制算法的执行过程如图:


现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存为整个新生代容量的90%,只要10%的内存是会被“浪费”的。当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。

内存的分配担保,如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。

标记-整理算法(Mark-Compact,用于老年代)

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

“标记-整理”算法的示意图如下:


分代收集算法(Generational Collection)

当代商业虚拟机的垃圾收集都采用“分代收集”算法。这种算法并没有新的思想,只是根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。这里讨论的是收集器基于Sun HotSpot虚拟机1.6版Update22,这个虚拟机包含的所有收集器如图所示(如果两个收集器之间存在连线,就说明它们可以搭配使用):


Serial收集器

最基本、历史最悠久的单线程收集器。在进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到它收集结束。依然是虚拟机运行在Client模式下的默认新生代收集器。

优点:简单高效(与其他收集器的单线程比)。


ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本。出来使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所以控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。


它是许多运行在Server模式下的虚拟机中首选的新生代收集器(除了Serial收集器外,目前只有它能跟CMS收集器配合工作)。

它作为老年代的收集器,却无法与新生代收集器Parallel Scavenge配合工作,所以使用CMS来收集老年代的时候,新生代只能选择ParNew或Serial收集器中的一个。ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:UseParNewGC选项来强制指定它。

ParNew默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

Parallel Scavenge收集器

Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

特点:关注点与其他收集器不同。CMS等的收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间;而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量。分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数及直接设置吞吐量大小的-XX:GCTimeRatio参数。由于与吞吐量关系密切,也称为”吞吐量优先“收集器。

Parallel Scavenge收集器还有一个开关参数-XX:+UseAdaptiveSizePolicy。当这个参数打开之后,就不需要手工指定新生代的大小(-Xmm)、Eden与Servivor区的比例(-XX:SurvivorRatio)、晋升老年对象年龄(-XX:PretenureSizeThreshold)等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大吞吐量,这种调节方式称为GC自适应的调整策略。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,是单线程收集器,使用”标记-整理“算法。主要被Client模式下的虚拟机使用。        在Server模式下,有两个用途:一是在JDK1.5及以前的版本中与Parallel Scavenge收集器搭配使用;二是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。


Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用”标记-整理“算法。

在注重吞吐量及CPU资源敏感的场合,可以优先考虑Parallel Scavenge+Parallel Old收集器。


CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于”标记-清除“算法实现。适用于互联网站或B/S系统的服务器上(重视服务响应速度,希望系统停顿时间最短,以给用户带来较好体验为目的)。

运作过程分为4个步骤:

  1. 初始标记(CMS initial mark):需要Stop The World,仅仅标记一下GC Roots能直接关联到的对象,速度很快;
  2. 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程;
  3. 重新标记(CMS remark):需要Stop The World,为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,该阶段停顿时间一般比出事标记阶段稍长,但远比并发标记的时间短。
  4. 并发清除(CMS concurrent sweep)

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起给工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。下图可清楚地看到CMS收集器地运作步骤中并发和需要停顿地时间。


优点:并发收集、低停顿,又称并发低停顿收集器。

缺点:

  • CMS收集器对CPU资源非常敏感。并发阶段因占用部分线程导致应用程序变慢,吞吐量降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集器线程最多占用不超过25%的CPU资源。
  • CMS收集器无法处理浮动垃圾。伴随并发清理阶段程序运行产生的新的垃圾,无法在本次收集中处理,只好留待下次GC时再次清理掉,这部分垃圾称为”浮动垃圾“。默认设置,CMS收集器在老年代使用了68%的空间后被激活。
  • 收集结束时产生大量空间碎片。

G1收集器

G1(Garbage First)收集器是当前收集器技术发展的最前沿成果。G1收集器可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收。这是由于它极力避免全区域的垃圾收集;之前的收集器进行收集的范围都是整个新生代或老年代,而G1将整个Java堆划分为多个大小固定的独立区域,并且根据这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的由来)。

相比CMS收集器的2个改进:

  1. G1收集器基于”标记-整理“算法实现,即不会产生空间碎片;
  2. 可以非常精确地控制停顿,即能让使用者明确指定在一个长度为M毫秒的世界片段内,消耗在垃圾收集器上的时间不得超过N毫秒。

垃圾收集器参数总结

垃圾收集相关的常用参数(下表来源于https://blog.csdn.net/wsyw126/article/details/62334387

参  数描  述
UseSerialGC虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收
UseParNewGC打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收
UseConcMarkSweepGC打开此开关后,使用ParNew + CMS + Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用
UseParallelGC虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收
UseParallelOldGC打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收
SurvivorRatio新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden∶Survivor=8∶1
PretenureSizeThreshold直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
MaxTenuringThreshold晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就增加1,当超过这个参数值时就进入老年代
UseAdaptiveSizePolicy动态调整Java堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况
ParallelGCThreads设置并行GC时进行内存回收的线程数
GCTimeRatioGC时间占总时间的比率,默认值为99,即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效
MaxGCPauseMillis设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效
CMSInitiatingOccupancyFraction设置CMS收集器在老年代空间被使用多少后触发垃圾收集。默认值为68%,仅在使用CMS收集器时生效
UseCMSCompactAtFullCollection设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用CMS收集器时生效
CMSFullGCsBeforeCompaction设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用CMS收集器时生效

内存分配与回收策略

Java技术体系中自动内存管理最终自动化地解决了2个问题:给对象分配内存、回收分配给对象的内存。

对象的内存分配,主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况也可能会直接分配在老年代中。分配规则取决于当前使用的垃圾收集器组合,以及虚拟机中与内存相关的参数的设置。

下面是几条最普遍的内存分配规则,其验证代码在测试时使用Client模式虚拟机进行,即验证的是使用Serial/Serial Old收集器下的内存分配和回收策略。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

虚拟机提供了收集器日志参数-XX:+PrintGCDetails,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出时输出当前内存各区域的分配情况。

注意    Minor GC和Full GC有什么不一样吗?
  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所有Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

大对象直接进入老年代

所谓大对象,就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存拷贝。

注意    PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑ParaNew+CMS的收集器组合。

长期存活的对象将进入老年代

虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对象应当放在新生代,哪些对象应当放在老年代。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC时,虚拟机会检测之前晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败:如果允许,那只会进行Minor GC;如果不允许,则也要改为进行一次Full GC。

前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时(最极端就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来,在实际完成内存回收之前是无法明确知道的,所有只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(HandlePromotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值