深入理解JVM:垃圾收集器与内存分配策略

来自Wikioe
跳到导航 跳到搜索


概述

垃圾收集(Garbage Collection,GC),并非Java语言的伴生产物。


垃圾收集需要完成的三件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

 

对象已死?

引用计数算法(Reference Counting)

引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

  • 优点:原理简单,判定效率高;
  • 缺点:不能解决对象之间相互循环引用的问题;
    /**
    * testGC()方法执行后,objA和objB会不会被GC呢?
    * @author zzm
    */
    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;
          
          // 假设在这行发生GC,objA和objB是否能被回收?
          System.gc();
       }
    }
    

可达性分析算法(Reachability Analysis)

  • 当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。

可达性分析算法:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

利用可达性分析算法判定对象是否可回收.jpg


Java中可作为“GC Roots”的对象:【全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中】

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象
    譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象
    譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象
    譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  • Java虚拟机内部的引用;【?】
    如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。【???】
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。【?】

再谈引用(强、软、弱、虚)

判定对象是否存活都和“引用”离不开关系。JDK 1.2版之后,Java将引用分为强、软、弱、虚四种:

  1. 强引用(Strongly Reference):是指在程序代码之中普遍存在的引用赋值,即类似“Objectobj = new Object()”这种引用关系。
    只要强引用关系还存在,就永远不会回收掉被引用的对象。
  2. 软引用(Soft Reference):JDK 1.2版之后提供了“SoftReference”类来实现软引用。
    在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  3. 弱引用(Weak Reference):在JDK 1.2版之后提供了“WeakReference”类来实现弱引用。
    被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
  4. 虚引用(Phantom Reference):也称为“幽灵引用”或者“幻影引用”,在JDK 1.2版之后提供了“PhantomReference”类来实现虚引用。
    虚引用的对于对象的生存时间没有影响,也无法通过虚引用来取得一个对象实例。(唯一作用是在对象被收集器回收时收到一个系统通知)

生存还是死亡?(两次标记)

真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 标记:对象在进行可达性分析后发现没有与GC Roots相连接的引用链,将会被第一次标记;
  2. 筛选(对标记的对象):筛选的条件是此对象是否有必要执行“finalize()”方法;
    1. “没有必要执行”:【对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过】直接回收;
    2. “有必要执行”:将该对象放如“F-Queue”队列中,稍后由“Finalizer”(虚拟机自动建立的低优先级)线程去调用对象的“finalize()”方法;
      并不承诺一定会等待对象的“finalize()”方法运行结束(防止等待造成回收系统崩溃);
  3. 标记(对F-Queue中的对象):被标记的对象会被回收;


  • finalize()方法中:如果对象重新与引用链上的对象建立了关联【如把自己(this关键字)赋值给某个类变量或者对象的成员变量】,那么在第二次标记时它将被移出“即将回收”的集合;
  • finalize()方法的不确定性大:不确定是否一定被调用,不确定何时被调用;所以应尽量避免使用;


一次对象自我拯救的演示:

/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
* @author zzm
*/
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("no, i am dead :(");
		}
		
		// 下面这段代码与上面的完全相同,但是这次自救却失败了
		// (finalize已被执行过,筛选后直接被回收)
		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 method executed!
yes, i am still alive :)
no, i am dead :(

回收方法区

方法区的垃圾收集主要回收两部分内容:

  1. 废弃常量:没有被对象或对象属性引用的常量(与回收Java堆中的对象类似);
  2. 不再使用的类:需要同时满足下面三个条件:
    1. 该类所有的实例都已经被回收;
      也就是Java堆中不存在该类及其任何派生子类的实例。
    2. 加载该类的类加载器已经被回收;
      (这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。)
    3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
    • Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。


关于是否要对类型进行回收,HotSpot虚拟机提供了“-Xnoclassgc”参数进行控制,还可以使用“-verbose:class”以及“-XX:+TraceClass-Loading”、“-XX:+TraceClassUnLoading”查看类加载和卸载信息,其中“-verbose:class”和“-XX:+TraceClassLoading”可以在Product版的虚拟机中使用,“-XX:+TraceClassUnLoading”参数需要FastDebug版的虚拟机支持。
  • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

【???】

垃圾收集算法

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


“半区复制”(Semispace Copying):算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

“标记-清除”算法示意图.jpg


问题:

  1. 执行效率不稳定:随对象数量增长而降低;
  2. 内存空间碎片化:标记、清除之后会产生大量不连续的内存碎片,导致分配较大对象时无法找到足够的连续内存,而不得不提前触发另一次垃圾收集动作。

标记-复制算法(Semispace Copying)


算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,另一块用于。

标记-复制算法示意图.jpg


问题:

  1. 可用内存缩小为了原来的一半;

“Appel式回收”

Appel式回收:

  1. 把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
  2. 发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。


  • (“Appel式回收”:现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代);
    如:HotSpot虚拟机的Serial、ParNew等新生代收集器。
  • HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1
  • 当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(老年代)进行“分配担保”(Handle Promotion);

标记-整理算法(Mark-Compact)


算法:

  1. 标记:过程仍然与“标记-清除”算法一样,
  2. 整理:将所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-复制算法示意图.jpg


问题:

  1. 移动操作必须全程暂停用户应用程序才能进行(“Stop The World”);

分代收集理论(Generational Collection)


  • (当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计);
  • (除了分代,已有分区收集的理论:即将内存划分为多个区,分别进行内存管理);


分代收集理论建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的;
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡;
  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

由此引出了垃圾收集器的一致的设计原则:“收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储”。


对不同的区域“”

  1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集;
  2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
    • 目前只有“CMS”收集器会有单独收集老年代的行为。
    • 另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
  3. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
    • 目前只有“G1”收集器会有这种行为。
  4. 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。“Full GC”:;


  • 通常能单独发生收集行为的只是新生代,实际上除了CMS收集器,其他都不存在只针对老年代的收集


关于“跨代引用”:

   依据这条假说(跨代引用假说),我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。
   
   此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

即,新生代维护了“记忆集”(Remembered Set),来标识老年代中存在跨代引用的内存块,用于Minor GC时被加入扫描整理范围。

HotSpot的算法细节实现

根节点枚举


   现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发(具体见3.4.6节),但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上(“Stop The World”),不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。
   这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。

目前主流Java虚拟机使用的都是“准确式GC”,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。

  • “枚举根节点”:(在“查找GC Root链”之前)找出内存中所有的引用;
  • “准确式GC”:对“准确式内存管理”(虚拟机明确知道内存中的某个位置数据的具体类型)进行垃圾回收;
  • 在HotSpot中,使用一组称为“OopMap”的数据结构来达到这个目的;

即,“HotSpot使用在 OopMap 来快速准确地完成GC Roots枚举

安全点(Safepoint)


用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达“安全点”后才能够暂停。

安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

如何产生安全点

只有具有“指令功能复用”的指令才能产生安全点

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如“方法调用”、“循环跳转”、“异常跳转”等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

如何运行到安全点

如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:

  1. 抢先式中断(Preemptive Suspension):不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
    (现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件)
  2. 主动式中断(Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最

近的安全点上主动中断挂起。

  1. 轮询标志的地方包括:安全点,及“所有创建对象和其他需要在Java堆上分配内存”的地方(为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象)。

安全区域(Safe Region)


  • 安全点用于线程执行过程中的GC时刻;
  • 安全区域:指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。
    用于线程不执行(没有分配CPU时间,如:Sleep、Blocked状态)的时候,此时线程无法响应 JVM 的中断请求

记忆集(Remembered Set)与卡表


记忆集(Remembered Set):垃圾收集器在新生代中建立了名为“记忆集”(Remembered Set)的数据结构,用以避免(存在跨代引用时)把整个老年代加进GC Roots扫描范围。

  • 所有涉及部分区域收集(Partial GC)行为的垃圾收集器(典型的如G1、ZGC和Shenandoah收集器),都会面临“跨代引用”问题;
  • 新生代、老年代之间的跨代引用:老年代中对象或对象属性,引用了新生代对象;

记忆集的实现:卡表(Card Table)

记忆集其实是一种“抽象”的数据结构,通常需要根据不同的记录精度,有不同的数据结构实现。

  • 记录精度:
    “字长精度”:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
    “对象精度”:每个记录精确到一个对象,该对象里有字段含有跨代指针。
    卡精度”:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。


卡表(Card Table)就是记忆集的一种具体实现(“卡精度”),它定义了记忆集的记录精度、与堆内存的映射关系等。

  • (卡表与记忆集的关系,可以类比于:HashMap与Map)
  • 卡表最简单的形式可以只是一个字节数组(HotSpot的形式)

HotSpot中的卡表

HotSpot默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;
   字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。
   
   一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。
   
   即,如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。
卡表与卡页对应示意图.jpg


卡表的跨代扫描:(一个卡页的内存中通常包含不止一个对象)

  1. 只要卡页内有对象的字段存在着跨代指针,则将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0;
  2. 垃圾收集时,根据卡表的元素标识,将对应卡页内存卡加入GC Roots中一并扫描;

写屏障(如何维护卡表状态?)


在HotSpot虚拟机里是通过“写屏障”(Write Barrier)技术维护卡表状态的。

  • (先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。)
   写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内:
      在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),
      在赋值后的则叫作写后屏障(Post-Write Barrier)。
      
   HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。

写后屏障更新卡表(简化逻辑):

void oop_field_store(oop* field, oop new_value) {
   // 引用字段赋值操作
   *field = new_value;
   
   // 写后屏障,在这里完成卡表状态更新
   post_write_barrier(field, new_value);
}


“伪共享”(False Sharing)【???】

经典垃圾收集器

HotSpot虚拟机的垃圾收集器:

HotSpot虚拟机的垃圾收集器.jpg

垃圾收集器的上下文语境中的并行与并发:

  • 并行(Parallel):描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
  • 并发(Concurrent):描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

Serial 收集器


Serial及Serial Old收集器运行示意图.jpg
  • 单线程收集器;
  • “Stop The World”:必须暂停其他所有工作线程;
  • HotSpot虚拟机运行在Client模式下的默认新生代收集器;

ParNew 收集器


ParNew及Serial Old收集器运行示意图.jpg

(Serial收集器的多线程并行版本)

  • 多线程收集器;
  • 只有“ParNew”(多线程)或者“Serial”(单线程)收集器,可以与CMS配合使用;
    ParNew收集器是激活CMS后(使用“-XX:+UseConcMarkSweepGC”选项)的默认新生代收集器,也可以使用“-XX:+/-UseParNewGC”选项来强制指定或者禁用它。
  • JDK 9之前,“ParNew + CMS”是官方推荐的服务端模式下的收集器解决方案;(JDK 9之后是“G1”)
  • 默认开启的收集线程数与处理器核心数量相同,可以使用“-XX:ParallelGCThreads”参数来限制垃圾收集的线程数;

Parallel Scavenge 收集器


Parallel Scavenge及Parallel Old收集器运行示意图.jpg

(“吞吐量优先收集器”)
Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput):

Parallel Scavenge吞吐量优先.jpg


参数:

  1. “-XX:MaxGCPauseMillis”:最大垃圾收集停顿时间(大于0的毫秒数);
    垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的;
  2. “-XX:GCTimeRatio”:吞吐量(大于0小于100的整数,默认为 99);
    如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19));
    默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。;
  3. -XX:+UseAdaptiveSizePolicy”:GC自适应的调节策略(开关);
    这个参数被激活之后,就不需要指定“新生代的大小(-Xmn)”、“Eden与Survivor区的比例(-XX:SurvivorRatio)”、“晋升老年代对象大小(-XX:PretenureSizeThreshold)”等细节参数;
    需要设置“MaxGCPauseMillis”或“GCTimeRatio”;


  • 二者关系:“MaxGCPauseMillis 占总时间的比值 = 1 / ( 1 + GCTimeRatio )”:
    “MaxGCPauseMillis 占总时间的比值”,即“GCTime / 总时间”(也即“1 - 吞吐量”);
    由上可知(总时间 = GCTime + UserTime):“GCTime / (GCTime + UserTime) = 1 / ( 1 + GCTimeRatio )”
    转化可得:“GCTimeRatio = UserTime / GCTime”,即【“GCTimeRatio” 是“用户代码时间”与“GC时间”的比值

Serial Old 收集器


Serial及Serial Old收集器运行示意图.jpg

(Serial收集器的老年代版本)

  • 单线程收集器;
  • “Stop The World”:必须暂停其他所有工作线程;
  • 运行在Client模式;
  • 用途:
    1. JDK 5以及之前的版本中与“Parallel Scavenge”收集器搭配使用;
    2. 作为“CMS”收集器发生失败时的后备预案,在并发收集发生“Concurrent Mode Failure”时使用;

Parallel Old 收集器


Parallel Scavenge及Parallel Old收集器运行示意图.jpg

(Parallel Scavenge收集器的老年代版本)

  • 多线程并发收集,基于标记-整理算法实现。
  • 在注重吞吐量、处理器资源较为稀缺的场合,都可以优先考虑“Parallel Scavenge + Parallel Old”收集器这个组合。

CMS收集器(Concurrent Mark Sweep)


Concurrent Mark Sweep收集器运行示意图.jpg

(“并发低停顿收集器”Concurrent Low Pause Collector)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

  • 通常用于“互联网网站或者基于浏览器的B/S系统的服务端上”,关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。
  • 基于“标记-清除”算法实现;


整个过程分为四个步骤,包括:

  1. 初始标记(CMS initial mark):【“Stop The World”】标记一下GCRoots能直接关联到的对象,速度很快;
  2. 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时较长但不需“Stop The World”,可以与垃圾收集线程一起并发运行;
  3. 重新标记(CMS remark):【“Stop The World”】修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录;
  4. 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象(不需移动对象,所以可与用户线程并发);


缺点:

  1. 对CPU资源敏感。
    • CMS默认启动的回收线程数是“(处理器核心数量+3)/4”:如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。
    • 为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种。【JDK 7开始,i-CMS模式已经被声明为“deprecated”】
  2. 由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
    • “浮动垃圾”:在CMS的并发标记和并发清理阶段,随着用户线程的继续运行而不断产生的垃圾对象,CMS只能留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
    • “并发失败”(Concurrent Mode Failure):CMS运行期间预留的内存无法满足新对象的分配需要,就会出现一次“并发失败”,这时候虚拟机将冻结用户线程的执行,临时启用“Serial Old”收集器来重新进行老年代的垃圾收集。
    • “-XX:CMSInitiatingOccu-pancyFraction”:用于设置CMS的触发百分比,即“当老年代使用了xx%的空间后就会被激活”(JDK 6时默认 92%);该值设置太低会导致老年区频繁回收频繁,设置太高可能导致“Concurrent Mode Failure”;
  3. 收集结束时会有大量空间碎片产生(CMS 基于“标记-清除”算法实现)。
    空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
    • “-XX:+UseCMSCompactAtFullCollection”:开关参数(默认开启,从JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。
    • “-XX:CMSFullGCsBeforeCompaction”:(从JDK 9开始废弃)用于要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

Garbage First 收集器


G1收集器运行示意图.jpg

Garbage First(简称G1)收集器,开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

  • 面向服务端应用的垃圾收集器;
  • JDK 9发布之日,G1宣告取代“Parallel Scavenge + Parallel Old”组合,成为服务端模式下的默认垃圾收集器;【CMS已被声明为不推荐使用(Deprecate)的收集器】

基于Region

   G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
   
   Region中还有一类特殊的“Humongous”区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数“-XX:G1HeapRegionSize”设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
   
   虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。
   
   G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
   
   更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数“-XX:MaxGCPauseMillis”指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。
   
   这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

G1收集器Region分区示意图.jpg

跨Region引用

每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

  • G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。

步骤

G1收集器的运作过程大致可划分为以下四个步骤:【???????】

  1. 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。【?】
  4. 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

低延迟垃圾收集器

3.6.1 Shenandoah收集器 3.6.2 ZGC收集器

选择合适的垃圾收集器

3.7.1 Epsilon收集器 3.7.2 收集器的权衡

虚拟机及垃圾收集器日志

  • 在JDK 9以前,HotSpot并没有提供统一的日志处理框架,虚拟机各个功能模块的日志开关分布在不同的参数上,日志级别、循环日志大小、输出格式、重定向等设置在不同功能上都要单独解决。

JDK 9,HotSpot所有功能的日志都收归到了“-Xlog”参数上,这个参数的能力也相应被极大拓展了:

-Xlog[:[selector][:[output][:[decorators][:output-options]]]]

其中,最关键的参数是选择器(Selector),它由标签(Tag)和日志级别(Level)共同组成。

  • 标签:可理解为虚拟机中某个功能模块的名字,用于设置日志框架输出的虚拟机功能;(垃圾收集器的标签名称为“gc”)
    全部支持的功能模块标签名如下所示:
    addageallocannotationaotargumentsattachbarrierbiasedlockingblocksbotbreakpointbytecodecensus
    
  • 日志级别:从低到高,共有“Trace”,“Debug”,“Info”,“Warning”,“Error”,“Off”六种级别;(默认级别为Info)
    HotSpot的日志规则与Log4j、SLF4j这类Java日志框架大体上是一致的。
  • 修饰器(Decorator):用于要求每行日志输出都附加上额外的内容;(默认值是 uptime、level、tags 三个)
    支持附加在日志行上的信息包括:
    • time:当前日期和时间。
    • uptime:虚拟机启动到现在经过的时间,以秒为单位。
    • timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。
    • uptimemillis:虚拟机启动到现在经过的毫秒数。
    • timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。
    • uptimenanos:虚拟机启动到现在经过的纳秒数。
    • pid:进程ID。
    • tid:线程ID。
    • level:日志级别。


垃圾收集器参数总结

垃圾收集相关的常用参数: 【改天自己列个表】

垃圾收集相关的常用参数1.jpg
垃圾收集相关的常用参数2.jpg

内存分配与回收策略

 

大对象优先在Eden分配

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

每次GC后,都会进行堆的调整

  1. 默认空闲堆内存小于40%时,JVM 会增大堆到“-Xmx”设置值;
  2. ..............大于70%..,......减小....“-Xms”......;
  • 将“-Xmx”与“-Xms”设置相同,可以避免每次GC后调整堆的大小

大对象直接进入老年代

HotSpot虚拟机提供了“-XX:PretenureSizeThreshold”参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

  • 大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

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

可以通过参数“-XX:MaxTenuringThreshold”设置对象晋升老年代的年龄阈值。

  • 新生代中,对象熬过一次“Minor GC”,年龄增加1;

动态对象年龄判定

HotSpot虚拟机并不是永远要求对象的年龄必须达到“-XX:MaxTenuringThreshold”才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代(比较的是占用的空间),无须等到“-XX:MaxTenuringThreshold”中要求的年龄。

空间分配担保

  • JDK 6之前:
    发生Minor GC之前,JVM 检查“老年代最大可用的连续空间是否大于新生代所有对象总空间”:
    1. 如果成立,那这一次Minor GC可以确保是安全的。(即,进行“Minor GC”)
    2. 如果不成立,则虚拟机会先查看“-XX:HandlePromotionFailure”参数设置【是否允许担保失败(Handle Promotion Failure)】:
      1. 如果允许,继续检查“老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小”:
        1. 如果大于,尽管有风险,将尝试进行一次“Minor GC”;
        2. 如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次“Full GC”。
      2. 如果不允许,进行“Full GC”;


  • JDK 6之后:(“-XX:HandlePromotionFailure”被废弃,不再有效)
    发生Minor GC之前,JVM 检查“老年代最大可用的连续空间是否大于新生代所有对象总空间”或“老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小”:
    1. 如果成立,那这一次Minor GC可以确保是安全的。(即,进行“Minor GC”)
    2. 如果不成立,那这时就要改为进行一次“Full GC”。