JVM基础(二) - 垃圾收集器与内存分配策略

本文主要内容出自周志明老师《深入理解Java虚拟机》一书,是笔者结合自己的理解,提取重点,重新组织排版,再补充了一些内容后,总结的读书笔记。

概述

在 JVM 运行时 5 大数据区中,程序计数器、虚拟机栈、本地方法栈 3 个区域都是线程私有的,随线程而生,随线程而灭。这几个区域的内存分配和回收都具备确定性,不需要过多考虑回收的问题,因为方法结束或线程结束时,内存自然就跟着回收了。

而Java堆和方法区的内存分配和回收都是动态的,因为只有在程序运行时才能知道会创建哪些对象,垃圾收集器关注的就是这部分内存。

对象的回收

垃圾收集器在对堆进行回收前,必须确定哪些对象“活着”,哪些已经“死去”,只有死去的对象才可以被回收。判断对象是否存活有两种方法:引用计数算法 和 可达性分析算法。

引用计数算法

基本思想是指向对象的强引用总和为0时,对象不会再被使用,具体是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器为0的对象就是不会再被使用的。

  • 实现简单,判定效率高,有不少应用案例。
  • 但主流的Java虚拟机并未采用该算法,因为它很难解决对象间相互循环引用的问题。

可达性分析算法

当一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到这个对象不可达)时,此对象将不再被使用。

  • 主流商用程序语言都是采用可达性分析算法。
GC Roots

GC Roots是一些由虚拟机自身保持存活的对象,这个对象从堆外可以访问读取。比如运行中的线程、当前处于调用栈中的对象、由system class loader加载的类等等。

以下一些方法可以使一个对象成为GC Root:

  1. System class:被Bootstrap或者system class loader加载的类,比如位于rt.jar里的所有类(如java.util.*);
  2. JNI local:native代码里的local变量,比如用户定义的JNI代码和JVM的内部代码;
  3. JNI global:native代码里的global变量,比如用户定义的JNI代码和JVM的内部代码;
  4. Thread block:当前活跃的线程block中引用的对象;
  5. Thread:已经启动并且没有stop的线程
  6. busy monitor:调用了wait()或者notify()或者被同步的对象,比如调用了synchronized(Object) 或使用了synchronized方法。静态方法指的是类,非静态方法指的是对象;
  7. java local:local变量,比如仍然存在于线程栈中的方法的入参和方法内创建的对象;
  8. native stack:native代码里的出入参数,比如file/net/IO方法以及反射的参数;
  9. finalizable:在一个队列里等待它的finalizer 运行的对象;
  10. unfinalized:一个有finalize方法的对象,还没有被finalize,同时也没有进入finalizer队列等待finalize;
  11. unreachable:被MAT标记为root,并且无法通过任何其他root到达的对象,这个root的作用是retain那些不这么做就无法包含在分析中的objects;
  12. java stack frame:一个持有本地变量的java栈帧。只有在dump被解析且在preferences里设置把栈帧当做对象对待时才会产生;
  13. unknown:未知root类型的对象。

对象“死亡”前的“缓刑”

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

虚拟机发现对象到 GC Roots 不可达后,会第一次标记对象,本次标记的依据是:此对象是否有必要执行 finalize() 方法。以下情况下,对象被视为“没有必要执行”:

  • 对象没有覆盖 finalize() 方法
  • finalize() 方法已经被虚拟机调用过

被判定为没有必要执行 finalize() 方法的对象,将被确定为“死亡”。而被判定为有必要执行 finalize() 方法的对象,将会进入 F-Queue 队列中,稍后会由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它的 finalize()。

这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。因为,如果一个对象在 finalize() 中执行缓慢,或发生死循环,将可能会导致 F-Queue 队列中其他对象永久处于等待,甚至整个内存回收系统崩溃。

思考一下,Java中还有哪个方法或某种情况下与finalize()的这个特性类似,即虚拟机并不承诺会等待其运行结束?

finalize() 方法是对象“逃脱”死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,本次标记的依据是:是否在 finalize() 方法中实施“自救”。“自救”的方法很简单——只要重新与引用链上的任何一个对象建立关联即可。

没有成功自救的对象,将被确定为“死亡”。而成功自救的对象,将被移出“即将回收”的集合。

需要注意的是,任何一个对象的 finalize() 方法都只会被系统自动调用一次

一次对象自我拯救的演示
/**
 * 此代码演示了两点:
 * 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 method 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 method executed!
yes, i am still alive :)
no, i am dead :(

建议尽量避免使用 finalize() ,因为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所作出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以建议大家完全可以忘掉 Java 中有这个方法的存在。

Java的引用级别

在JDK 1.2之前,对象只有两种状态:被引用、未被引用。但为了描述这样一类对象:当内存足够时,则能保留在内存中;若内存在进行GC后还是非常紧张,则可以回收这些对象。缓存功能就很符合这样的应用场景。因此,在JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用有强到弱依次分为 强引用(Strong Reference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference) 4种。

  • 强引用:通过 new 关键字创建出来的对象引用都是强引用,只有去掉强引用,对象才会被回收。请记住,JVM宁可抛出OOM也不会去回收一个有强引用的对象
  • 软引用:只要有足够的内存,就一直保持对象,直到发现一次GC后内存仍然不够,系统会在将要发生OOM之前针对此类对象进行二次回收。如果此次回收还没有足够的内存,才会抛出OOM。一般可用来实现缓存,通过java.lang.ref.SoftReference类实现。
  • 弱引用:比Soft Ref更弱,被弱引用关联的对象只能生存到下一次GC发生之前。在GC执行时,无论当前内存是否足够,都会立刻回收只被弱引用关联的对象。通过java.lang.ref.WeakReference和java.util.WeakHashMap类实现。
  • 虚引用:也成为幽灵引用或幻影引用。虚引用完全不会影响对象的生存时间,你只能使用Phantom Ref本身,而无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被GC时收到一个系统通知。一般用于在进入finalize()方法后进行特殊的清理过程,通过 java.lang.ref.PhantomReference实现。

回收方法区

虚拟机规范中并没有强制要求方法区必须实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”往往较低。在堆中,尤其在新生代中,一次常规垃圾收集的回收率在70% ~ 95%,而方法区远低于此。

方法区的回收对象主要有两部分:废弃常量无用的类

废弃常量

不再被引用的常量,有可能被清理出常量池。

无用的类

判定条件比较苛刻,同时满足以下3个条件的类才能算是“无用的类”:

  • 该类所有实例都已被回收
  • 加载该类的 ClassLoader 已被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法

虚拟机可以对无用的类进行回收,注意仅仅是“可以”,而非必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制。

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

垃圾收集算法

(本章节各算法图片资料取自 图解JVM垃圾回收算法 ,只有该作者的复制算法图解考虑到了 内存分配策略 的影响,内存分配策略稍后讲解)

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

最基础的收集算法,分“标记”和“清除”两个阶段:首先标记出所有需回收的对象,之后统一回收。标记方法就是 对象“死亡”前的“缓刑” 一节中提到的 两次标记 过程。

两大不足
  • 标记和清除的效率都不高
  • 易产生大量内存碎片,当后续需要分配较大对象时,若无法找到足够的连续内存则不得不提前触发另一次GC

复制算法(Copying)

算法思想

将内存分为大小相等的两块,每次只使用一块,当这一块用完了,就将还存活的对象全部复制到另一块中,然后一次性清理之前那块。

  • 不用考虑内存碎片
  • 实现简单,运行高效
  • 但代价高昂,可用内存只有一半
现代商用虚拟机中的实现

现代商用虚拟机都采用复制算法回收新生代。但为了避免浪费 50% 之大的内存,做了优化,具体实现是:

将新生代的内存按照8:1:1(默认)的比例分为 EdenSurvivor (From)Survivor (To) 3 块,每次只使用 Eden 和其中一块 Survivor ,回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 中,最后清理掉 Eden 和之前的 Survivor。

  • 每次新生代的可用内存空间为 90% ,只有 10% 会被“浪费”
  • 虽然不会浪费 50% 这么多的内存,但需要加入“分配担保”机制(稍后解释)

为什么默认值是 “8:1:1”?

这个比例设计是基于这项研究数据:新生代中的对象 98% 是“朝生夕死”的。当然,98% 的对象可回收只是一般场景下的数据,我们没法保证每次回收都只有不多于 10% 的对象存活,一旦超出预期,Survivor 空间肯定不够用,此时就需要依赖其他内存(此处是老年代)进行 分配担保 ,让超出的对象直接进入老年代。“分配担保”也是内存分配策略之一,会在后面详细介绍。

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

商用领域的复制算法也有其缺陷:

  • 对象存活率较高时要进行较多的复制操作,效率低下
  • 为了不浪费 50% 那么多的内存, 不得不提供额外空间进行分配担保

年轻代对象存活率较低的特点,适合使用复制算法。但年老代的对象存活率一般都较高,所以不适合使用复制算法。年老代使用“标记 - 整理”算法,具体实现为:

标记阶段与“标记 - 清理”算法一样,采用 对象“死亡”前的“缓刑” 一节中提到的 两次标记 过程,但后续不是直接清理可回收对象,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

  • 并非新的思想
  • 不同代使用不同算法:年轻代使用“复制算法”,老年代使用“标记 - 清理”或“标记 - 整理”算法
  • 现代商用虚拟机普遍采用

垃圾收集器

虚拟机规范对垃圾收集器应该如何实现没有做任何规定,因此不同厂商、不同版本的虚拟机提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自身需求组合出各个年代所使用的收集器。笔者并不打算在本章节详细介绍每个收集器的实现细节,只带大家简单了解一下。

上图是 HotSpot 不同分代的收集器,两个收集器之间的连线代表它们可以搭配使用。

在开始之前,需要明确一个很重要的概念——GC停顿,即 GC 进行时必须停顿所有 Java 执行线程(Sun 称之为“Stop The World”)。可以说,垃圾收集器发展的驱动就是尽可能减少GC停顿对用户工作线程的影响。

无论是最早的“单线程”收集器——Serial 收集器,还是后来的各种多线程收集器,都无法完全避免 GC 停顿。这些多线程收集器大多也只是“并行”而非“并发”。即便是号称第一款真正意义上的并发收集器——CMS 收集器,也无法做到垃圾收集线程与用户线程完全并发,而只能大部分时间并发。当今最前沿的 G1 收集器(目标是未来替代CMS)也同样如此。

区分这里提到的“并行(Parallel)”与“并发(Concurrent)”

并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发:指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。

理解 GC 日志

每个收集器的日志格式都可以不一样,但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,如下一段GC日志:

33.15:  [GC  [DefNew:  3324K->152K(3712K),  0.0025925 secs]  3324K->152K(11904K),  0.0031680 secs]
  • 最前面的“33.125:”代表GC发生的时间,其含义是从Java虚拟机启动以来经过的秒数
  • “[GC”说明了这次GC的停顿类型,如果是“[Full GC”,说明这次GC是发生了Stop-The-World的
  • 接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里的区域名称与收集器相关
  • 后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”
  • 而在方括号之外的“3324K->152K(11904K)”表示”含义是“GC前该Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”
  • 再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times: user=0.01 sys=0.01, real=0.02 secs]”。

对象内存分配策略

1.对象优先在Eden分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区空间不够时,虚拟机将发起一次 Minor GC,然后再次尝试将对象分配在 Eden 区。

新生代GC(Minor GC)
  • 发生在新生代的 GC
  • 非常频繁
  • 回收速度较快
老年代GC(Major GC / Full GC)
  • 发生在老年代的 GC
  • 不频繁
  • 速度一般比 Minor GC 慢 10 倍以上

2.大对象直接进入老年代

  • 大对象:需要大量连续内存空间的对象
  • 虚拟机参数-XX:PretenureSizeThreshold:大于其值的对象直接进入老年代分配
  • 目的是避免在 Eden 和两个 Survivor 之间频繁复制(新生代采用复制算法收集内存)

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

  • 虚拟机为每个对象添加了一个年龄(Age)计数器
  • 对象在 Eden 出生并经过一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,就被移动到 Survivor 中,并且年龄设为 1
  • 对象在 Survivor 中每”熬过“一次 Minor GC ,年龄加 1
  • 达到一定年龄(默认是15岁)的对象,被晋升到老年代
  • 设置老年代年龄阈值:-XX:MaxTenuringThreshold

4.动态对象年龄判定

如果 Survivor 中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,那么年龄大于等于该年龄的对象可以直接进入老年代,无须等到 -XX:MaxTenuringThreshold 指定的阈值。

5.空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代的最大可用连续空间大小,若大于新生代对象总大小或历次晋升到老年代的平均大小,就进行 Minor GC ,否则进行 Full GC。

在 JDK 6 Update 24 之前,上述规则更加复杂一点:

先检查老年代最大可用连续空间是否大于新生代对象总大小,大于就进行 Minor GC ,否则检查 HandlePromotionFailure 的设置值是否允许担保失败。若允许,继续检查是否大于历次晋升到老年代对象的平均大小,若大于,将尝试进行一次 Minor GC,尽管这次 Minor GC是有风险的。若小于,或者 HandlePromotionFailure 设置不允许冒险,就改为进行一次 Full GC 。

JDK 6 Update 24 之后虽然仍保留了 HandlePromotionFailure ,但并不生效。

取平均值进行比较只是基于概率论的,极端情况下会出现 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下是不会失败的,这样做的目的是避免 Full GC 过于频繁。

猜你喜欢

转载自blog.csdn.net/lovelease/article/details/83278776
今日推荐