洋蔥

贪婪,找不到比这更好的词了,是件好事。

本文对JVM中的字节码指令做了详细的介绍,包括加载与存储指令、算数指令、对象创建与访问指令、方法调用与返回指令、操作数栈管理指令、控制转义指令等。

阅读全文 »

[TOC]

13. 垃圾回收器

13.1. GC 分类与性能指标

13.1.1. 垃圾回收器概述

垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的 JVM 来实现。

由于 JDK 的版本处于高速迭代过程中,因此 Java 发展至今已经衍生了众多的 GC 版本。

从不同角度分析垃圾收集器,可以将 GC 分为不同的类型。

13.1.2. 垃圾收集器分类

线程数分类

分为串行垃圾回收器并行垃圾回收器

image-20210512144253383

串行回收指的是在同一时间段内只允许有一个 CPU 用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。

  • 在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的 Client 模式下的 JVM 中
  • 在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器。

和串行回收相反,并行收集可以运用多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制。

按照工作模式分类

分为并发式垃圾回收器独占式垃圾回收器

  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

image-20200713083443486

碎片处理方式分类

分为压缩式垃圾回收器非压缩式垃圾回收器

  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
  • 非压缩式的垃圾回收器不进行这步操作。

工作的内存区间分类

可分为年轻代垃圾回收器老年代垃圾回收器

13.1.3. 评估 GC 的性能指标

  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java 堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。

吞吐量、暂停时间、内存占用 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。

这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。

简单来说,主要抓住两点:吞吐量、暂停时间

吞吐量

吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)。比如:虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的

吞吐量优先,意味着在单位时间内,STW 的时间最短:0.2 + 0.2 = 0.4

image-20200713084726176

暂停时间

“暂停时间”是指一个时间段内应用程序线程暂停,让 GC 线程执行的状态。

例如,GC 期间 100 毫秒的暂停时间意味着在这 100 毫秒期间内没有应用程序线程是活动的。

暂停时间优先,意味着尽可能让单次 STW 的时间最短:0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5

image-20200713085306400

吞吐量 vs 暂停时间

高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。

低暂停时间(低延迟)较好因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的 200 毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序

不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。

  • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。
  • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。

在设计(或使用)GC 算法时,我们必须确定我们的目标:一个 GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。

现在标准:在最大吞吐量优先的情况下,降低停顿时间

13.2. 不同的垃圾回收器概述

垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。这当然也是面试的热点。

13.2.1. 垃圾回收器发展史

有了虚拟机,就一定需要收集垃圾的机制,这就是 Garbage Collection,对应的产品我们称为 Garbage Collector。

  • 1999 年随 JDK1.3.1 一起来的是串行方式的 serialGc,它是第一款 GC。ParNew 垃圾收集器是 Serial 收集器的多线程版本
  • 2002 年 2 月 26 日,Parallel GC 和 Concurrent Mark Sweep GC 跟随 JDK1.4.2 一起发布·
  • Parallel GC 在 JDK6 之后成为 HotSpot 默认 GC。
  • 2012 年,在 JDK1.7u4 版本中,G1 可用。
  • 2017 年,JDK9 中 G1 变成默认的垃圾收集器,以替代 CMS。
  • 2018 年 3 月,JDK10 中 G1 垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  • 2018 年 9 月,JDK11 发布。引入 Epsilon 垃圾回收器,又被称为 “No-Op(无操作)“ 回收器。同时,引入 ZGC:可伸缩的低延迟垃圾回收器(Experimental)
  • 2019 年 3 月,JDK12 发布。增强 G1,自动返回未用堆内存给操作系统。同时,引入 Shenandoah GC:低停顿时间的 GC(Experimental)。·
  • 2019 年 9 月,JDK13 发布。增强 ZGC,自动返回未用堆内存给操作系统。
  • 2020 年 3 月,JDK14 发布。删除 CMS 垃圾回收器。扩展 ZGC 在 macos 和 Windows 上的应用

13.2.2. 7 种经典的垃圾收集器

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel old
  • 并发回收器:CMS、G1

image-20200713093551365

官方手册:https://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

image-20210512145950897

13.2.3. 7 款经典收集器与垃圾分代之间的关系

image-20200713093757644

  • 新生代收集器:Serial、ParNew、Parallel Scavenge;

  • 老年代收集器:Serial Old、Parallel Old、CMS;

  • 整堆收集器:G1;

13.2.4. 垃圾收集器的组合关系

image-20200713094745366

  1. 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  2. 其中 Serial Old 作为 CMS 出现”Concurrent Mode Failure“失败的后备预案。
  3. (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial+CMS、ParNew+Serial Old 这两个组合声明为废弃(JEP173),并在 JDK9 中完全取消了这些组合的支持(JEP214),即:移除。
  4. (绿色虚线)JDK14 中:弃用 Parallel Scavenge 和 Serialold GC 组合(JEP366)
  5. (绿色虚框)JDK14 中:删除 CMS 垃圾回收器(JEP363)

13.2.5. 不同的垃圾收集器概述

为什么要有很多收集器,一个不够吗?因为 Java 的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器

13.2.6. 如何查看默认垃圾收集器

-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)

使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID

1
2
3
4
5
6
7
8
9
10
C:\Users\Administrator>java -XX:+PrintCommandLineFlags --version
-XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=10 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=803098688 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=12849579008 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedOops -XX:+UseG1GC -XX:-UseLargePagesIndividualAllocation
java version "21.0.5" 2024-10-15 LTS
Java(TM) SE Runtime Environment (build 21.0.5+9-LTS-239)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.5+9-LTS-239, mixed mode, sharing)
C:\Users\Administrator>jps
15908 mars-eureka-server-1.0.0-deluxe-springboot.jar
16804 Jps
C:\Users\Administrator>jinfo -flag UseG1GC 15908
-XX:+UseG1GC

13.3. Serial 回收器:串行回收

Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3 之前回收新生代唯一的选择。

Serial 收集器作为 HotSpot 中 client 模式下的默认新生代垃圾收集器。

Serial 收集器采用复制算法、串行回收和”stop-the-World”机制的方式执行内存回收。

除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和”Stop the World”机制,只不过内存回收算法使用的是标记-压缩算法。

  • Serial old 是运行在 Client 模式下默认的老年代的垃圾回收器
  • Serial 0ld 在 Server 模式下主要有两个用途:① 与新生代的 Parallel scavenge 配合使用 ② 作为老年代 CMS 收集器的后备垃圾收集方案

image-20200713100703799

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)

优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在 Client 模式下的虚拟机是个不错的选择。

在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。

在 HotSpot 虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用 Serial GC,且老年代用 Serial Old GC

总结

这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核 cpu 才可以用。现在都不是单核的了。

对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在 Java web 应用程序中是不会采用串行垃圾收集器的。

13.4. ParNew 回收器:并行回收

如果说 Serial GC 是年轻代中的单线程垃圾收集器,那么 ParNew 收集器则是 Serial 收集器的多线程版本。Par 是 Parallel 的缩写,New:只能处理的是新生代

ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 收集器在年轻代中同样也是采用复制算法、”Stop-the-World”机制

ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。

image-20200713102030127

  • 对于新生代,回收次数频繁,使用并行方式高效。
  • 对于老年代,回收次数少,使用串行方式节省资源。(CPU 并行需要切换线程,串行可以省去切换线程的资源)

由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 serial 收集器更高效?

  • ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
  • 但是在单个 CPU 的环境下,ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。

因为除 Serial 外,目前只有 ParNew GC 能与 CMS 收集器配合工作

在程序中,开发人员可以通过选项”-XX:+UseParNewGC“手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。

-XX:ParallelGCThreads限制线程数量,默认开启和 CPU 数据相同的线程数。

13.5. Parallel 回收器:吞吐量优先

HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和”Stop the World”机制

那么 Parallel 收集器的出现是否多此一举?

  • 和 ParNew 收集器不同,ParallelScavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
  • 自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。

高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序

Parallel 收集器在 JDK1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。

Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和”Stop-the-World”机制

image-20200713110359441

在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。在 Java8 中,默认是此垃圾收集器。

参数配置

  • -XX:+UseParallelGC 手动指定年轻代使用 Parallel 并行收集器执行内存回收任务。

  • -XX:+UseParallelOldGC 手动指定老年代都是使用并行回收收集器。

    • 分别适用于新生代和老年代。默认 jdk8 是开启的。
    • 上面两个参数,默认开启一个,另一个也会被开启。(互相激活)
  • -XX:ParallelGCThreads 设置年轻代并行收集器的线程数。一般地,最好与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能。

    $$ ParallelGCThreads = \begin{cases} CPU_Count & \text (CPU_Count <= 8) \ 3 + (5 * CPU_Count / 8) & \text (CPU_Count > 8) \end{cases} $$

  • -XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即 STw 的时间)。单位是毫秒。

    • 为了尽可能地把停顿时间控制在 MaxGCPauseMills 以内,收集器在工作时会调整 Java 堆大小或者其他一些参数。
    • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合 Parallel,进行控制。
    • 该参数使用需谨慎
  • -XX:GCTimeRatio 垃圾收集时间占总时间的比例(=1/(N+1))。用于衡量吞吐量的大小。

    • 取值范围(0, 100)。默认值 99,也就是垃圾回收时间不超过 1%。
    • 与前一个-XX:MaxGCPauseMillis 参数有一定矛盾性。暂停时间越长,Radio 参数就容易超过设定的比例。
  • -XX:+UseAdaptivesizePolicy 设置 Parallel Scavenge 收集器具有自适应调节策略

    • 在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
    • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。

13.6. CMS 回收器:低延迟(已被移出)

在 JDK1.5 时期,Hotspot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作

CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

  • 目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

CMS 的垃圾收集算法采用标记-清除算法,并且也会”Stop-the-World”

不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

在 G1 出现之前,CMS 使用还是非常广泛的。一直到今天,仍然有很多系统使用 CMS GC。

image-20200713205154007

CMS 整个过程比之前的收集器要复杂,整个过程分为 4 个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GCRoots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快
  • 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

尽管 CMS 收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS 收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

image-20200713212230352

有人会觉得既然 Mark Sweep 会造成内存碎片,那么为什么不把算法换成 Mark Compact?

答案其实很简单,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact 更适合“Stop the World” 这种场景下使用

13.6.1. CMS 的优点

  • 并发收集
  • 低延迟

13.6.2. CMS 的弊端

  • 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 FullGC。
  • CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • CMS 收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure“失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS 将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行 GC 时释放这些之前未被回收的内存空间。

13.6.3. 设置的参数

  • -XX:+UseConcMarkSweepGC 手动指定使用 CMS 收集器执行内存回收任务。

    开启该参数后会自动将-xx:+UseParNewGC打开。即:ParNew(Young 区用)+CMS(Old 区用)+ Serial Old 的组合。

  • -XX:CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。

    • JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68%时,会执行一次 CMS 回收。JDK6 及以上版本默认值为 92%
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低 CMS 的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低 Ful1Gc 的执行次数。
  • -XX:+UseCMSCompactAtFullCollection 用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

  • -XX:CMSFullGCsBeforeCompaction 设置在执行多少次 Full GC 后对内存空间进行压缩整理。

  • -XX:ParallelcMSThreads 设置 CMS 的线程数量。

    • CMS 默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads 是年轻代并行收集器的线程数。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

小结

HotSpot 有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 Gc 有什么不同呢?

请记住以下口令:

  • 如果你想要最小化地使用内存和并行开销,请选 Serial GC;
  • 如果你想要最大化应用程序的吞吐量,请选 Parallel GC;
  • 如果你想要最小化 GC 的中断或停顿时间,请选 CMS GC。

13.6.4. JDK 后续版本中 CMS 的变化

JDK9 新特性:CMS 被标记为 Deprecate 了(JEP291)

  • 如果对 JDK9 及以上版本的 HotSpot 虚拟机使用参数-XX: +UseConcMarkSweepGC来开启 CMS 收集器的话,用户会收到一个警告信息,提示 CMS 未来将会被废弃。

JDK14 新特性:删除 CMS 垃圾回收器(JEP363)

  • 移除了 CMS 垃圾收集器,如果在 JDK14 中使用 -XX:+UseConcMarkSweepGC的话,JVM 不会报错,只是给出一个 warning 信息,但是不会 exit。JVM 会自动回退以默认 GC 方式启动 JVM

13.7. G1 回收器:区域化分代式

既然我们已经有了前面几个强大的 GC,为什么还要发布 Garbage First(G1)?

原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序正常进行,而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。G1(Garbage-First)垃圾回收器是在 Java7 update4 之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

官方给 G1 设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

为什么名字叫 Garbage First(G1)呢?

因为 G1 是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region 来表示 Eden、幸存者 0 区,幸存者 1 区,老年代等。

G1 GC 有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region

由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给 G1 一个名字:垃圾优先(Garbage First)。

G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐量的性能特征。

在 JDK1.7 版本正式启用,移除了 Experimenta1 的标识,是JDK9 以后的默认垃圾回收器,取代了 CMS 回收器以及 Parallel+Parallel Old 组合。被 Oracle 官方称为“全功能的垃圾收集器”。

与此同时,CMS 已经在 JDK9 中被标记为废弃(deprecated)。

在 jdk8 中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。

13.7.1. G1 回收器的特点(优势)

与其他 GC 收集器相比,G1 使用了全新的分区算法,其特点如下所示:

并行与并发

  • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。此时用户线程 STW
  • 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

分代收集

  • 从分代上看,G1 依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。但从堆的结构上看,它不要求整个 Eden 区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  • 堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代
  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

image-20200713215105293

image-20200713215133839

空间整合->垃圾回收算法

  • CMS:“标记-清除”算法、内存碎片、若干次 Gc 后进行一次碎片整理
  • G1 将内存划分为一个个的 region。内存的回收是以 region 作为基本单位的。Region 之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。尤其是当 Java 堆非常大的时候,G1 的优势更加明显。

可预测的停顿时间模型(即:软实时 soft real-time)

这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

  • 由于分区的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。

13.7.2. G1 垃圾收集器的缺点

相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。

从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势。平衡点在 6-8GB 之间。

13.7.3. G1 回收器的参数设置

  • -XX:+UseG1GC:手动指定使用 G1 垃圾收集器执行内存回收任务
  • -XX:G1HeapRegionSize 设置每个 Region 的大小。值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区域。默认是堆内存的 1/2000。
  • -XX:MaxGCPauseMillis 设置期望达到的最大 GC 停顿时间指标(JVM 会尽力实现,但不保证达到)。默认值是 200ms(人的平均反应速度)
  • -XX:+ParallelGCThread 设置 STW 工作线程数的值。最多设置为 8(上面说过 Parallel 回收器的线程计算公式,当 CPU_Count > 8 时,ParallelGCThreads 也会大于 8)
  • -XX:ConcGCThreads 设置并发标记的线程数。将 n 设置为并行垃圾回收线程数(ParallelGCThreads)的 1/4 左右。
  • -XX:InitiatingHeapOccupancyPercent 设置触发并发 GC 周期的 Java 堆占用率阈值。超过此值,就触发 GC。默认值是 45。

13.7.4. G1 收集器的常见操作步骤

G1 的设计原则就是简化 JVM 性能调优,开发人员只需要简单的三步即可完成调优:

  • 第一步:开启 G1 垃圾收集器
  • 第二步:设置堆的最大内存
  • 第三步:设置最大的停顿时间

G1 中提供了三种垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。

13.7.5. G1 收集器的适用场景

面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

最主要的应用是需要低 GC 延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约 6GB 或更大时,可预测的暂停时间可以低于 0.5 秒;(G1 通过每次只清理一部分而不是全部的 Region 的增量式清理来保证每次 GC 停顿时间不会过长)。

用来替换掉 JDK1.5 中的 CMS 收集器;在下面的情况时,使用 G1 可能比 CMS 好:

  • 超过 50%的 Java 堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC 停顿时间过长(长于 0.5 至 1 秒)

HotSpot 垃圾收集器里,除了 G1 以外,其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,即当 JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

13.7.6. 分区 Region:化整为零

使用 G1 收集器时,它将整个 Java 堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32MB 之间,且为 2 的 N 次幂,即 1MB,2MB,4MB,8MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的 Region 大小相同,且在 JVM 生命周期内不会被改变。

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。通过 Region 的动态分配方式实现逻辑上的连续。

image-20200713223244886

一个 region 有可能属于 Eden,Survivor 或者 Old/Tenured 内存区域。但是一个 region 只可能属于一个角色。图中的 E 表示该 region 属于 Eden 内存区域,S 表示属于 survivor 内存区域,O 表示属于 Old 内存区域。图中空白的表示未使用的内存空间。

G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。主要用于存储大对象,如果超过 1.5 个 region,就放到 H。

设置 H 的原因:对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。G1 的大多数行为都把 H 区作为老年代的一部分来看待。

每个 Region 都是通过指针碰撞来分配空间

image-20200713223509993

13.7.7. G1 垃圾回收器的回收过程

G1GC 的垃圾回收过程主要包括如下三个环节:

  • 年轻代 GC(Young GC)

  • 老年代并发标记过程(Concurrent Marking)

  • 混合回收(Mixed GC)

    (如果需要,单线程、独占式、高强度的 Full GC 还是继续存在的。它针对 GC 的评估失败提供了一种失败保护机制,即强力回收。)

image-20200713224113996

顺时针,Young gc -> Young gc + Concurrent mark->Mixed GC 顺序,进行垃圾回收。

应用程序分配内存,当年轻代的 Eden 区用尽时开始年轻代回收过程;G1 的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC 暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到 Survivor 区间或者老年区间,也有可能是两个区间都会涉及

当堆内存使用达到一定值(默认 45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC 从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的 G1 回收器和其他 GC 不同,G1 的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的 Region 就可以了。同时,这个老年代 Region 是和年轻代一起被回收的。

举个例子:一个 Web 服务器,Java 进程最大堆内存为 4G,每分钟响应 1500 个请求,每 45 秒钟会新分配大约 2G 的内存。G1 会每 45 秒钟进行一次年轻代回收,每 31 个小时整个堆的使用率会达到 45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

13.7.8. Remembered Set:记忆集

  • 一个对象被不同区域引用的问题

  • 一个 Region 不可能是孤立的,一个 Region 中的对象可能被其他任意 Region 中对象引用,判断对象存活时,是否需要扫描整个 Java 堆才能保证准确?

  • 在其他的分代收集器,也存在这样的问题(而 G1 更突出)回收新生代也不得不同时扫描老年代?

  • 这样的话会降低 MinorGC 的效率;

解决方法:

无论 G1 还是其他分代收集器,JVM 都是使用 Remembered Set 来避免全局扫描:

每个 Region 都有一个对应的 Remembered Set;

每次 Reference 类型数据写操作时,都会产生一个 Write Barrier 暂时中断操作;

然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象);

如果不同,通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 Remembered Set 中;

当进行垃圾收集时,在 GC 根节点的枚举范围加入 Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

image-20200713224716715

13.7.9. G1 回收过程一:年轻代 GC

JVM 启动时,G1 先准备好 Eden 区,程序在运行过程中不断创建对象到 Eden 区,当 Eden 空间耗尽时,G1 会启动一次年轻代垃圾回收过程。

年轻代垃圾回收只会回收 Eden 区和 Survivor 区。

首先 G1 停止应用程序的执行(Stop-The-World),G1 创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代 Eden 区和 Survivor 区所有的内存分段。

image-20200713225100632

然后开始如下回收过程:

  1. 第一阶段,扫描根。根是指 static 变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。
  2. 第二阶段,更新 RSet。处理 dirty card queue(见备注)中的 card,更新 RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用
  3. 第三阶段,处理 RSet。识别被老年代对象指向的 Eden 中的对象,这些被指向的 Eden 中的对象被认为是存活的对象。
  4. 第四阶段,复制对象。此阶段,对象树被遍历,Eden 区内存段中存活的对象会被复制到 Survivor 区中空的内存分段,Survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加 1,达到阀值会被会被复制到 Old 区中空的内存分段。如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到老年代空间。
  5. 第五阶段,处理引用。处理 Soft,Weak,Phantom,Final,JNI Weak 等引用。最终 Eden 空间的数据为空,GC 停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

13.7.10. G1 回收过程二:并发标记过程

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是 STW 的,并且会触发一次年轻代 GC。
  2. 根区域扫描(Root Region Scanning):G1 GC 扫描 Survivor 区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在 YoungGC 之前完成。
  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 YoungGC 中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是 STW 的。G1 中采用了比 CMS 更快的初始快照算法:snapshot-at-the-beginning(SATB)。
  5. 独占清理(cleanup,STW):计算各个区域的存活对象和 GC 回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是 STW 的。这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。

13.7.11. G1 回收过程三:混合回收

当越来越多的对象晋升到老年代 o1d region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个 Young Region,还会回收一部分的 Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些 Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是 Mixed GC 并不是 Full GC。

image-20200713225810871

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分 8 次(可以通过-XX:G1MixedGCCountTarget设置)被回收

混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden 区内存分段,Survivor 区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分 8 次回收,G1 会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为 65%,意思是垃圾占内存分段比例要达到 65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行 8 次。有一个阈值-XX:G1HeapWastePercent,默认值为 10%,意思是允许整个堆内存中有 10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于 10%,则不再进行混合回收。因为 GC 会花费很多的时间但是回收到的内存却很少。

13.7.12. G1 回收可选的过程四:Full GC

G1 的初衷就是要避免 Full GC 的出现。但是如果上述方式不能正常工作,G1 会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免 Full GC 的发生,一旦发生需要进行调整。什么时候会发生 Full GC 呢?比如堆内存太小,当 G1 在复制存活对象的时候没有空的内存分段可用,则会回退到 Full GC,这种情况可以通过增大内存解决。

导致 G1 Full GC 的原因可能有两个:

  • Evacuation 的时候没有足够的 to-space 来存放晋升的对象;
  • 并发处理过程完成之前空间耗尽。

13.7.13. 补充

从 Oracle 官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到 G1 只是回一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器(即 ZGC)中。另外,还考虑到 G1 不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

13.7.14. G1 回收器优化建议

年轻代大小

  • 避免使用-Xmn-XX:NewRatio等相关选项显式设置年轻代大小
  • 固定年轻代的大小会覆盖暂停时间目标

暂停时间目标不要太过严苛

  • G1 GC 的吞吐量目标是 90%的应用程序时间和 10%的垃圾回收时间
  • 评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

13.8. 垃圾回收器总结

13.8.1. 7 种经典垃圾回收器总结

截止 JDK1.8,一共有 7 款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 作用于新生代 复制算法 响应速度优先 适用于单 CPU 环境下的 client 模式
ParNew 并行运行 作用于新生代 复制算法 响应速度优先 多 CPU 环境 Server 模式下与 CMS 配合使用
Parallel 并行运行 作用于新生代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
Serial Old 串行运行 作用于老年代 标记-压缩算法 响应速度优先 适用于单 CPU 环境下的 Client 模式
Parallel Old 并行运行 作用于老年代 标记-压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
CMS 并发运行 作用于老年代 标记-清除算法 响应速度优先 适用于互联网或 B/S 业务
G1 并发、并行运行 作用于新生代、老年代 标记-压缩算法、复制算法 响应速度优先 面向服务端应用

GC 发展阶段:Serial => Parallel(并行)=> CMS(并发)=> G1 => ZGC

13.8.2. 垃圾回收器组合

不同厂商、不同版本的虚拟机实现差距比较大。HotSpot 虚拟机在 JDK7/8 后所有收集器及组合如下图

image-20200714080151020

  1. 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure"失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial + CMS、ParNew + Serial old 这两个组合声明为 Deprecated(JEP 173),并在 JDK 9 中

完全取消了这些组合的支持(JEP214),即:移除。

  1. (绿色虚线)JDK 14 中:弃用 ParallelScavenge 和 SeriaOold GC 组合(JEP 366)

  2. (绿色虚框)JDK 14 中:删除 CMS 垃圾回收器(JEP 363)

13.8.3. 怎么选择垃圾回收器

Java 垃圾收集器的配置对于 JVM 优化来说是一个很重要的选择,选择合适的垃圾收集器可以让 JVM 的性能有一个很大的提升。

怎么选择垃圾收集器?

  1. 优先调整堆的大小让 JVM 自适应完成。

  2. 如果内存小于 100M,使用串行收集器

  3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器

  4. 如果是多 CPU、需要高吞吐量、允许停顿时间超过 1 秒,选择并行或者 JVM 自己选择

  5. 如果是多 CPU、追求低停顿时间,需快速响应(比如延迟不能超过 1 秒,如互联网应用),使用并发收集器

    官方推荐 G1,性能高。现在互联网的项目,基本都是使用 G1

最后需要明确一个观点:

  1. 没有最好的收集器,更没有万能的收集
  2. 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器

面试

对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。 这里较通用、基础性的部分如下:

  • 垃圾收集的算法有哪些?如何判断一个对象是否可以回收?

  • 垃圾收集器工作的基本流程。

另外,大家需要多关注垃圾回收器这一章的各种常用的参数,主要包括 内存相关、垃圾回收器相关 两类参数。

13.9. GC 日志分析

通过阅读 Gc 日志,我们可以了解 Java 虚拟机内存分配与回收策略。 内存分配与垃圾回收的参数列表

  • -XX:+PrintGC 输出 GC 日志。类似:-verbose:gc
  • -XX:+PrintGCDetails 输出 GC 的详细日志
  • -XX:+PrintGCTimestamps 输出 GC 的时间戳(以基准时间的形式)
  • -XX:+PrintGCDatestamps 输出 GcC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

打开 GC 日志

1
-verbose:gc

这个只会显示总的 GC 堆的变化,如下:

1
2
3
[GC (Allocation Failure) 80832K->19298K(227840K),0.0084018 secs]
[GC (Metadata GC Threshold) 109499K->21465K(228352K),0.0184066 secs]
[Full GC (Metadata GC Threshold) 21465K->16716K(201728K),0.0619261 secs]

参数解析

1
2
3
4
5
GC、Full GC:GC的类型,GC只在新生代上进行,Full GC包括永生代,新生代,老年代。
Allocation Failure:GC发生的原因。
80832K->19298K:堆在GC前的大小和GC后的大小。
228840k:现在的堆大小。
0.0084018 secs:GC持续的时间。

打开 GC 日志

1
-verbose:gc -XX:+PrintGCDetails

输入信息如下

1
2
3
4
5
[GC (Allocation Failure) [PSYoungGen:70640K->10116K(141312K)] 80541K->20017K(227328K),0.0172573 secs] [Times:user=0.03 sys=0.00,real=0.02 secs]
[GC (Metadata GC Threshold) [PSYoungGen:98859K->8154K(142336K)] 108760K->21261K(228352K),0.0151573 secs] [Times:user=0.00 sys=0.01,real=0.02 secs]
[Full GC (Metadata GC Threshold)[PSYoungGen:8154K->0K(142336K)]
[ParOldGen:13107K->16809K(62464K)] 21261K->16809K(204800K),[Metaspace:20599K->20599K(1067008K)],0.0639732 secs]
[Times:user=0.14 sys=0.00,real=0.06 secs]

参数解析

1
2
3
4
5
6
7
GC,Full FC:同样是GC的类型
Allocation Failure:GC原因
PSYoungGen:使用了Parallel Scavenge并行垃圾收集器的新生代GC前后大小的变化
ParOldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小的变化
Metaspace: 元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
xxx secs:指GC花费的时间
Times:user:指的是垃圾收集器花费的所有CPU时间,sys:花费在等待系统调用或系统事件的时间,real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间。

打开 GC 日志

1
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimestamps -XX:+PrintGCDatestamps

输入信息如下

1
2
3
4
5
2019-09-24T22:15:24.518+0800: 3.287: [GC (Allocation Failure) [PSYoungGen:136162K->5113K(136192K)] 141425K->17632K(222208K),0.0248249 secs] [Times:user=0.05 sys=0.00,real=0.03 secs]

2019-09-24T22:15:25.559+0800: 4.329: [GC (Metadata GC Threshold) [PSYoungGen:97578K->10068K(274944K)] 110096K->22658K(360960K),0.0094071 secs] [Times: user=0.00 sys=0.00,real=0.01 secs]

2019-09-24T22:15:25.569+0800: 4.338: [Full GC (Metadata GC Threshold) [PSYoungGen:10068K->0K(274944K)][ParoldGen:12590K->13564K(56320K)] 22658K->13564K(331264K),[Metaspace:20590K->20590K(1067008K)],0.0494875 secs] [Times: user=0.17 sys=0.02,real=0.05 secs]

说明:带上了日期和实践

如果想把 GC 日志存到文件的话,是下面的参数:

1
-Xloggc:/path/to/gc.log

日志补充说明

  • [GC“和”[Full GC“说明了这次垃圾收集的停顿类型,如果有”Full”则说明 GC 发生了”Stop The World”

  • 使用 Serial 收集器在新生代的名字是 Default New Generation,因此显示的是”[DefNew

  • 使用 ParNew 收集器在新生代的名字会变成”[ParNew“,意思是”Parallel New Generation”

  • 使用 Parallel scavenge 收集器在新生代的名字是”[PSYoungGen

  • 老年代的收集和新生代道理一样,名字也是收集器决定的

  • 使用 G1 收集器的话,会显示为”garbage-first heap”

  • Allocation Failure

    表明本次引起 GC 的原因是因为在年轻代中没有足够的空间能够存储新的数据了。

  • [PSYoungGen:5986K->696K(8704K) ] 5986K->704K(9216K)

    中括号内:GC 回收前年轻代大小,回收后大小,(年轻代总大小)

    括号外:GC 回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)

  • user 代表用户态回收耗时,sys 内核态回收耗时,rea 实际耗时。由于多核的原因,时间总和可能会超过 real 时间

1
2
3
4
5
6
7
8
9
10
11
Heap(堆)
PSYoungGen(Parallel Scavenge收集器新生代)total 9216K,used 6234K [0x00000000ff600000,0x0000000100000000,0x0000000100000000)
eden space(堆中的Eden区默认占比是8)8192K,768 used [0x00000000ff600000,0x00000000ffc16b08,0x00000000ffe00000)
from space(堆中的Survivor,这里是From Survivor区默认占比是1)1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space(堆中的Survivor,这里是to Survivor区默认占比是1,需要先了解一下堆的分配策略)1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)

ParOldGen(老年代总大小和使用大小)total 10240K, used 7001K [0x00000000fec00000,0x00000000ff600000,0x00000000ff600000)
object space(显示个使用百分比)10240K,688 used [0x00000000fec00000,0x00000000ff2d6630,0x00000000ff600000)

PSPermGen(永久代总大小和使用大小)total 21504K, used 4949K [0x00000000f9a00000,0x00000000faf00000,0x00000000fec00000)
object space(显示个使用百分比,自己能算出来)21504K, 238 used [0x00000000f9a00000,0x00000000f9ed55e0,0x00000000faf00000)

Minor GC 日志

image-20200714082555688

Full GC 日志

image-20210512194815354

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* -Xms20m -Xmx20m -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -Xlog:gc*:garbage-collection.log
* 高版本jdk提示 -XX:+PrintGCDetails 已弃用,使用 -Xlog:gc* 代替
*/
public class Test {
private static final int _1MB = 1024 * 1024;

public static void main(String[] args) {
testAllocation();
}

public static void testAllocation() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB];
}
}

JDK7 GC图示

image-20200714083332238

image-20200714083526790

可以用一些工具去分析这些 GC 日志

常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat 等

13.X. 垃圾回收器的新发展

GC 仍然处于飞速发展之中,目前的默认选项G1 GC 在不断的进行改进,很多我们原来认为的缺点,例如串行的 Fu11GC、Card Table 扫描的低效等,都已经被大幅改进,例如,JDK10 以后,Fu11GC 已经是并行运行,在很多场景下,其表现还略优于 ParallelGC 的并行 Ful1GC 实现。

即使是 Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是 GC 相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在 Serverless 等新的应用场景下,Serial GC 找到了新的舞台

比较不幸的是 CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在 JDK9 中已经被标记为废弃,并在 JDK14 版本中移除

13.X.1. JDK11 新特性

Epsilon:A No-Op GarbageCollector(Epsilon 垃圾回收器,”No-Op(无操作)”回收器)http://openidk.iava.net/jeps/318

ZGC:A Scalable Low-Latency Garbage Collector(Experimental)(ZGC:可伸缩的低延迟垃圾回收器,处于实验性阶段)http://openidk.iava.net/jeps/333

image-20210512195426194

现在 G1 回收器已成为默认回收器好几年了。

我们还看到了引入了两个新的收集器:ZGC(JDK11 出现)和 Shenandoah(Open JDK12)。主打特点:低停顿时间

image-20210512195528695

13.X.2. Open JDK12 的 Shenandoash GC

Open JDK12 的 Shenandoash GC:低停顿时间的 GC(实验性)

Shenandoah,无疑是众多 GC 中最孤独的一个。是第一款不由 oracle 公司团队领导开发的 Hotspot 垃圾收集器。不可避免的受到官方的排挤。比如号称 OpenJDK 和 OracleJDK 没有区别的 Oracle 公司仍拒绝在 OracleJDK12 中支持 Shenandoah。

Shenandoah 垃圾回收器最初由 RedHat 进行的一项垃圾收集器研究项目 Pauseless GC 的实现,旨在针对 JVM 上的内存回收实现低停顿的需求.。在 2014 年贡献给 OpenJDK。

Red Hat 研发 Shenandoah 团队对外宣称,Shenandoah 垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为 200MB 还是 200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。

image-20200714090608807

这是 RedHat 在 2016 年发表的论文数据,测试内容是使用 Es 对 200GB 的维基百科数据进行索引。从结果看:

  • 停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。
  • 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。

总结

  • Shenandoah GC 的弱项:高运行负担下的吞吐量下降。
  • Shenandoah GC 的强项:低延迟时间。
  • Shenandoah GC 的工作过程大致分为九个阶段,这里就不再赘述。在之前 Java12 新特性视频里有过介绍。

【Java12 新特性地址】

http://www.atguigu.com/download_detail.shtml?v=222

https://www.bilibili.com/video/BV1jJ411M7kQ?from=search&seid=12339069673726242866

13.X.3. 令人震惊、革命性的 ZGC

官方地址:https://docs.oracle.com/en/java/javase/12/gctuning/

image-20210512200236647

ZGC 与 Shenandoah 目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟。

《深入理解 Java 虚拟机》一书中这样定义 ZGC:ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC 的工作过程可以分为 4 个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射 等。

ZGC 几乎在所有地方并发执行的,除了初始标记的是 STW 的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

测试数据:

image-20200714091201073

image-20200714091401511

在 ZGC 的强项停顿时间测试上,它毫不留情的将 Parallel、G1 拉开了两个数量级的差距。无论平均停顿、95%停顿、99%停顿、99.9%停顿,还是最大停顿时间,ZGC 都能毫不费劲控制在 10 毫秒以内。

虽然 ZGC 还在试验状态,没有完成所有特性,但此时性能已经相当亮眼,用“令人震惊、革命性”来形容,不为过。 未来将在服务端、大内存、低延迟应用的首选垃圾收集器。

image-20200714093243028

JEP 364:ZGC 应用在 macos 上

JEP 365:ZGC 应用在 Windows 上

JDK14 之前,ZGC 仅 Linux 才支持。

尽管许多使用 zGc 的用户都使用类 Linux 的环境,但在 Windows 和 macos 上,人们也需要 ZGC 进行开发部署和测试。许多桌面应用也可以从 ZGC 中受益。因此,ZGC 特性被移植到了 Windows 和 macos 上。

现在 mac 或 Windows 上也能使用 zGC 了,示例如下:

1
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

13.X.4. 其他垃圾回收器:AliGC

AliGC 是阿里巴巴 JVM 团队基于 G1 算法,面向大堆(LargeHeap)应用场景。指定场景下的对比:

image-20200714093604012

当然,其它厂商也提供了各种别具一格的 GC 实现,例如比较有名的低延迟 GC:Zing,有兴趣可以参考提供的链接 https://www.infoq.com/articles/azul_gc_in_detail

[TOC]

12. 垃圾回收相关概念

12.1. System.gc()的理解

在默认情况下,通过 system.gc()或者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

然而 System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)

JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用 System.gc()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SystemGCTest {
public static void main(String[] args) {
new SystemGCTest();
System.gc();// 提醒JVM的垃圾回收器执行gc,但是不确定是否马上执行gc
// 与Runtime.getRuntime().gc();的作用一样

System.runFinalization();//强制执行使用引用的对象的finalize()方法
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("SystemGCTest 重写了finalize()");
}
}

12.2. 内存溢出与内存泄露

内存溢出(OOM)

内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。

由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 ooM 的情况。

大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。

javadoc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存

首先说没有空闲内存的情况:说明 Java 虚拟机的堆内存不够。原因有二:

  1. Java 虚拟机的堆内存设置不够。

    比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数-Xms-Xmx来调整。

  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

    对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space“。

    随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 ooM 有所改观,出现 OOM,异常信息则变成了:“java.lang.OutofMemoryError:Metaspace“。直接内存不足,也会导致 OOM。

这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

  • 例如:在引用机制分析中,涉及到 JVM 会去尝试回收软引用指向的对象等。
  • java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。

当然,也不是在任何情况下垃圾收集器都会被触发的

  • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。

内存泄漏(Memory Leak)

也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏

但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 00M,也可以叫做宽泛意义上的“内存泄漏”

尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。

注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

image-20200712195158470

举例

  1. 单例模式

    单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

  2. 一些提供 close 的资源未关闭导致内存泄漏

    数据库连接(dataSourse.getConnection() ),网络连接(socket)和 io 连接必须手动 close,否则是不能被回收的。

12.3. Stop The World

Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行
  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。

STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。

哪怕是 G1 也不能完全避免 Stop-the-World 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

开发中不要用 System.gc() 会导致 Stop-the-World 的发生。

12.4. 垃圾回收的并行与并发

并发(Concurrent)

在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。

并发不是真正意义上的“同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

image-20200712202522051

并行(Parallel)

当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,我们称之为并行(Parallel)。

其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。

适合科学计算,后台处理等弱交互场景

image-20200712202822129

并发 vs 并行

  • 并发,指的是多个事情,在同一时间段内同时发生了。

  • 并行,指的是多个事情,在同一时间点上同时发生了。

  • 并发的多个任务之间是互相抢占资源的。

  • 并行的多个任务之间是不互相抢占资源的。

  • 只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。

  • 否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收的并发与并行

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

并行(Parallel)

指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如 ParNew、Parallel Scavenge、Parallel Old;

串行(Serial)

相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动 JM 垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

image-20210512112822896

并发(Concurrent)

指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上;如:CMS、G1

image-20200712203815517

12.5. 安全点与安全区域

安全点

程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为“安全点(Safepoint)”。

Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等

如何在 GC 发生时,检查所有线程都跑到最近的安全点停顿下来呢?

抢先式中断:(目前没有虚拟机采用了)

  • 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。\

主动式中断

设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)

安全区域(Safe Resion)

Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 Gc 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。

实际执行时:

  1. 当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Relgion,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程
  2. 当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开 Safe Region 的信号为止;

12.6. 再谈引用

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?

在 JDK1.2 版之后,Java 对引用的概念进行了扩充,将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这 4 种引用强度依次逐渐减弱

除强引用外,其他 3 种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这 3 种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

.image-20200712205813321

Reference 子类中只有终结器引用是包内可见的,其他 3 种引用类型均为 public,可以在应用程序中直接使用

  • 强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存流出异常。
  • 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知用来跟踪对象回收

强引用(Strong Reference)——不回收

在 Java 程序中,最常见的引用类型是强引用(普通系统 99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型

当在 Java 语言中使用 new 操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。

强引用的对象是可触及的(对象可能的三种状态之一),垃圾收集器就永远不会回收掉被引用的对象。

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。

相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成 Java 内存泄漏的主要原因之一

强引用例子

1
StringBuffer str = new StringBuffer("hello mogublog");

局部变量 str 指向 StringBuffer 实例所在堆空间,通过 str 可以操作该实例,那么 str 就是 StringBuffer 实例的强引用

对应内存结构

image-20200712211501377

此时,如果再运行一个赋值语句

1
StringBuffer str1 = str;

对应的内存结构

image-20200712211732976

本例中的两个引用,都是强引用,强引用具备以下特点:

  • 强引用可以直接访问目标对象。
  • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向对象。
  • 强引用可能导致内存泄漏。

软引用(Soft Reference)——内存不足即回收

软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)。

类似弱引用,只不过 Java 虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。

在 JDK1.2 版之后提供了 java.lang.ref.SoftReference 类来实现软引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Test {
public static void main(String[] args) {
// SoftReference<Object> sf = new SoftReference<>(new Object());
// 上面一行代码,等价于如下三行代码
Object obj = new Object(); // 声明强引用
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; // 强引用去除

System.out.println(sf.get()); // 从软引用中获得对象

System.gc();
System.out.println("After GC:");
System.out.println(sf.get()); // 由于堆空间内存足够,所以不会回收软引用的可达对象

try {
// 让系统认为内存资源紧张、不够
byte[] by = new byte[1024 * 1024 * 7]; // -Xms9m -Xmx9m -XX:+PrintGCDetails,新生代为3m
} catch (Throwable e) {
e.printStackTrace();
} finally {
// 再次从软引用中获取数据
System.out.println(sf.get()); // 在报OOM之前,垃圾回收器会回收软引用的可达对象
System.out.println("*******************");
}
}
}

弱引用(Weak Reference)——发现即回收

弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统 GC 时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间

弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。

软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

在 JDK1.2 版之后提供了 WeakReference 类来实现弱引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test {
public static void main(String[] args) {
// WeakReference<Object> sf = new WeakReference<>(new Object());
// 上面一行代码,等价于如下三行代码
Object obj = new Object(); // 声明强引用
WeakReference<Object> sf = new WeakReference<>(obj);
obj = null; // 强引用去除

System.out.println(sf.get()); // 从弱引用中获得对象

System.gc();
System.out.println("After GC:");
System.out.println(sf.get());
}
}

弱引用对象与软引用对象的最大不同就在于,当 GC 在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC 总是进行回收。弱引用对象更容易、更快被 GC 回收

面试题:你开发中使用过 WeakHashMap 吗?

WeakHashMap 用来存储图片信息,可以在内存不足的时候,及时回收,避免了 OOM

虚引用(Phantom Reference)——对象回收跟踪

也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。

一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。

它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的 get()方法取得对象时,总是 null

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。

在 JDK1.2 版之后提供了 PhantomReference 类来实现虚引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class PhantomReferenceTest {
public static ReferenceQueue<Object> phantomQueue = null; // 引用队列

public static class CheckRefQueue extends Thread {
@Override
public void run() {
while (true) {
if (phantomQueue != null) {
PhantomReference<Object> objWeak = null;
try {
objWeak = (PhantomReference<Object>) phantomQueue.remove();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (objWeak != null) {
System.out.println("追踪垃圾回收过程:Object实例被GC了");
}
}
}
}
}

public static void main(String[] args) {
Thread t = new CheckRefQueue();
t.setDaemon(true); // 默认非守护线程
t.start();

Object obj = new Object();
phantomQueue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, phantomQueue); // 构造 Object 的虚引用,并指定了引用队列
obj = null; // 强引用去除

try {
System.out.println(phantomRef.get()); // 不可获取虚引用中的对象
Thread.sleep(1000);
System.gc(); // 若obj对象被回收,就会将此虚引用存放到引用队列中
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}

12.7. 终结器引用(已被JDK移除)

它用于实现对象的 finalize() 方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。

在 GC 时,终结器引用入队。由 Finalizer 线程通过终结器引用找到被引用对象调用它的 finalize()方法,第二次 GC 时才回收被引用的对象

[toc]

11. 垃圾回收概述及算法

11.1. 垃圾回收概述

11.1.1. 什么是垃圾?

image-20200712085456113

垃圾收集,不是 Java 语言的伴生产物。早在 1960 年,第一门开始使用内存动态分配和垃圾收集技术的 Lisp 语言诞生。

关于垃圾收集有三个经典问题:

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

垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。

大厂面试题

蚂蚁金服

  • 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下 cms 和 G1?

  • JVM GC 算法有哪些,目前的 JDK 版本采用什么回收算法?

  • G1 回收器讲下回收过程 GC 是什么?为什么要有 GC?

  • GC 的两种判定方法?CMS 收集器与 G1 收集器的特点

百度

  • 说一下 GC 算法,分代回收说下

  • 垃圾收集策略和算法

天猫

  • JVM GC 原理,JVM 怎么回收内存

  • CMS 特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?

滴滴

  • Java 的垃圾回收器都有哪些,说下 g1 的应用场景,平时你是如何搭配使用垃圾回收器的

京东

  • 你知道哪几种垃圾收集器,各自的优缺点,重点讲下 cms 和 G1,

  • 包括原理,流程,优缺点。垃圾回收算法的实现原理

阿里

  • 讲一讲垃圾回收算法。

  • 什么情况下触发垃圾回收?

  • 如何选择合适的垃圾收集算法?

  • JVM 有哪三种垃圾回收器?

字节跳动

  • 常见的垃圾回收器算法有哪些,各有什么优劣?
  • System.gc()和 Runtime.gc()会做什么事情?
  • Java GC 机制?GC Roots 有哪些?
  • Java 对象的回收方式,回收算法。
  • CMS 和 G1 了解么,CMS 解决什么问题,说一下回收的过程。
  • CMS 回收停顿了几次,为什么要停顿两次?

什么是垃圾?

An object is considered garbage when it can no longer be reached from any pointer in the running program

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。

磁盘碎片整理的日子

机械硬盘需要进行磁盘整理,同时还有坏道

image-20200712090848669

11.1.2. 为什么需要 GC

想要学习 GC,首先需要理解为什么需要 GC?

对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM 将整理出的内存分配给新的对象

随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序的正常进行。而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。

11.1.3. 早期垃圾回收

在早期的 C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用 new 关键字进行内存申请,并使用 delete 关键字进行内存释放。比如以下代码:

1
2
3
4
MibBridge *pBridge= new cmBaseGroupBridge();
//如果注册失败,使用Delete释放该对象所占内存区域
if (pBridge->Register(kDestroy) != NO ERROR)
delete pBridge;

这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃

在有了垃圾回收机制后,上述代码极有可能变成这样

1
2
MibBridge *pBridge = new cmBaseGroupBridge();
pBridge->Register(kDestroy);

现在,除了 Java 以外,C#、Python、Ruby 等语言都使用了自动垃圾回收的思想,也是未来发展趋势,可以说这种自动化的内存分配和来及回收方式已经成为了线代开发语言必备的标准。

11.1.4. Java 垃圾回收机制

自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险

  • 没有垃圾回收器,java 也会和 cpp 一样,各种悬垂指针,野指针,泄露问题让你头疼不已。

自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

oracle 官网关于垃圾回收的介绍 https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html

担忧

对于 Java 开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力

此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 outofMemoryError 时,快速地根据错误异常日志定位问题和解决问题。

当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节

GC 主要关注的区域

GC 主要关注于 方法区 和堆中的垃圾收集

image-20200712092427246

垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。其中,Java 堆是垃圾收集器的工作重点

从次数上讲:

  • 频繁收集 Young 区
  • 较少收集 Old 区
  • 基本不收集 Perm 区(元空间)

11.2. 垃圾回收相关算法

对象存活判断

在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段

那么在 JVM 中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。

判断对象存活一般有两种方式:引用计数算法可达性分析算法

11.2.1. 标记阶段:引用计数算法

方式一:引用计数算法

引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。

优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点:

  • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
  • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。

循环引用

当 p 的指针断开的时候,内部的引用形成一个循环,这就是循环引用

image-20200712102205795

举例

测试 Java 中是否采用的是引用计数算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RefCountGC {
// 这个成员属性的唯一作用就是占用一点内存
private byte[] bigSize = new byte[5*1024*1024];
// 引用
Object reference = null;

public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();

obj1.reference = obj2;
obj2.reference = obj1;

obj1 = null;
obj2 = null;
// 显示的执行垃圾收集行为
// 这里发生GC,obj1和obj2是否被回收?
System.gc();
}
}
// 运行结果
PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K)

上述进行了 GC 收集的行为,所以可以证明 JVM 中采用的不是引用计数器的算法

image-20200712103230349

小结

引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的 Python,它更是同时支持引用计数和垃圾收集机制。

具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。

Java 并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。

Python 如何解决循环引用?

  • 手动解除:很好理解,就是在合适的时机,解除引用关系。 使用弱引用 weakref,weakref 是 Python 提供的标准库,旨在解决循环引用。

11.2.2. 标记阶段:可达性分析算法

可达性分析算法(根搜索算法、追踪性垃圾收集)

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生

相较于引用计数算法,这里的可达性分析就是 Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)

所谓”GCRoots”根集合就是一组必须活跃的引用。

基本思路

  • 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

image-20210511195540451

在 Java 语言中,GC Roots 包括以下几类元素:

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

image-20200712104622677

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。比如:分代收集和局部回收(PartialGC)。

如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GCRoots 集合中去考虑,才能保证可达性分析的准确性。

小技巧:由于 Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个 Root。

注意

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。

这点也是导致 GC 进行时必须“stop The World”的一个重要原因。

  • 即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

11.2.3. 对象的 finalization 机制

Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize()方法。

finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

永远不要主动调用某个对象的 finalize()方法 I 应该交给垃圾回收机制调用。理由包括下面三点:

  • 在 finalize()时可能会导致对象复活。
  • finalize()方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize()方法将没有执行机会。
  • 一个糟糕的 finalize()会严重影响 Gc 的性能。

从功能上来说,finalize()方法与 C++中的析构函数比较相似,但是 Java 采用的是基于垃圾回收器的自动内存管理机制,所以 finalize()方法在本质上不同于 C++中的析构函数。

由于 finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态

生存还是死亡?

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize()中复活。
  • 不可触及的:对象的 finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次

以上 3 种状态中,是由于 inalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

具体过程

判定一个对象 objA 是否可回收,至少要经历两次标记过程:

  1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行 finalize()方法
  3. 如果对象 objA 没有重写 finalize()方法,或者 finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的。
  4. 如果对象 objA 重写了 finalize()方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize()方法执行。
  5. finalize()方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize 方法只会被调用一次。

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class CanReliveObj {
// 类变量,属于GC Roots的一部分
public static CanReliveObj canReliveObj;

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
canReliveObj = this;
}

public static void main(String[] args) throws InterruptedException {
canReliveObj = new CanReliveObj();
canReliveObj = null;
System.gc();
System.out.println("-----------------第一次gc操作------------");
// 因为Finalizer线程的优先级比较低,暂停2秒,以等待它
Thread.sleep(2000);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}

System.out.println("-----------------第二次gc操作------------");
canReliveObj = null;
System.gc();
// 下面代码和上面代码是一样的,但是 canReliveObj却自救失败了
Thread.sleep(2000);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}

}
}

运行结果

1
2
3
4
5
-----------------第一次gc操作------------
调用当前类重写的finalize()方法
obj is still alive
-----------------第二次gc操作------------
obj is dead

在第一次 GC 时,执行了 finalize 方法,但 finalize()方法只会被调用一次,所以第二次该对象被 GC 标记并清除了。

11.2.4. MAT 与 JProfiler 的 GC Roots 溯源

MAT 是什么?

MAT 是 Memory Analyzer 的简称,它是一款功能强大的 Java 堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。

MAT 是基于 Eclipse 开发的,是一款免费的性能分析工具。

大家可以在 http://www.eclipse.org/mat/ 下载并使用 MAT

获取 dump 文件

方式一:命令行使用 jmap

image-20210512105418987

方式二:使用 JVisualVM 导出

捕获的 heap dump 文件是一个临时文件,关闭 JVisualVM 后自动删除,若要保留,需要将其另存为文件。

可通过以下方法捕获 heap dump:

  • 在左侧“Application”(应用程序)子窗口中右击相应的应用程序,选择 Heap Dump(堆 Dump)。

  • 在 Monitor(监视)子标签页中点击 Heap Dump(堆 Dump)按钮。

本地应用程序的 Heap dumps 作为应用程序标签页的一个子标签页打开。同时,heap dump 在左侧的 Application(应用程序)栏中对应一个含有时间戳的节点。

右击这个节点选择 save as(另存为)即可将 heap dump 保存到本地。

方式三:使用 MAT 打开 Dump 文件

image-20200712112512720

JProfiler 的 GC Roots 溯源

我们在实际的开发中,一般不会查找全部的 GC Roots,可能只是查找某个对象的整个链路,或者称为 GC Roots 溯源,这个时候,我们就可以使用 JProfiler

image-20200712113256075

11.2.5. 清除阶段:标记-清除算法

当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

目前在 JVM 中比较常见的三种垃圾收集算法是标记一清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact)

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

标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在 1960 年提出并并应用于 Lisp 语言。

执行过程

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除

  • 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。

  • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收

image-20200712150935078

缺点

  • 标记清除算法的效率不算高
  • 在进行 GC 的时候,需要停止整个应用程序,用户体验较差
  • 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表

何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。

11.2.6. 清除阶段:复制算法

复制(Copying)算法

为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于 1963 年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。

核心思想

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收

image-20200712151916991

优点

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。
  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小

特别的

如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,或者说非常低才行

应用场景

在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

image-20200712152847218

11.2.7. 清除阶段:标记-压缩(整理)算法

标记-压缩(或标记-整理、Mark-Compact)算法

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。

1970 年前后,G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。

执行过程

  1. 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象

  2. 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。

  3. 之后,清理边界外所有的空间。

image-20200712153236508

标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法

二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

指针碰撞(Bump the Pointer)

如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump tHe Pointer)。

优点

  • 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

缺点

  • 从效率上来说,标记-整理算法要低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序。即:STW

11.2.8. 小结

Mark-Sweep Mark-Compact Copying
速率 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的 2 倍空间(不堆积碎片)
移动对象

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段

难道就没有一种最优算法吗?

回答:无,没有最好的算法,只有最合适的算法。

image-20210512104415281

11.2.9. 分代收集算法

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。

分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的 GC 都采用分代收集算法执行垃圾回收的。

在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代(Young Gen)

年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 hotspot 中的两个 survivor 的设计得到缓解。

老年代(Tenured Gen)

老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。

这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

  • Mark 阶段的开销与存活对象的数量成正比。
  • Sweep 阶段的开销与所管理区域的大小成正相关。
  • Compact 阶段的开销与存活对象的数据成正比。

以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的 Serial Old 回收器作为补偿措施:当内存回收不佳(碎片导致的 Concurrent Mode Failure 时),将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代

11.2.X. 增量收集算法、分区算法

增量收集算法

上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态。在 Stop the World 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。

基本思想

如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成

总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作

缺点

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

分区算法

一般来说,在相同条件下,堆空间越大,一次 Gc 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。

每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

image-20200712165318590

写到最后

注意,这些只是基本的算法思路,实际 GC 实现过程要复杂的多,目前还在发展中的前沿 GC 都是复合算法,并且并行和并发兼备。

[toc]

10. StringTable

10.1. String 的基本特性

  • String:字符串,使用一对””引起来表示
  • String 声明为 final 的,不可被继承
  • String 实现了 Serializable 接口:表示字符串是支持序列化的。
  • String 实现了 Comparable 接口:表示 string 可以比较大小
  • String 在 jdk8 及以前内部定义了 final char[] value 用于存储字符串数据。JDK9 时改为 byte[]

10.1.1. String 在 jdk9 中存储结构变更

官网地址:JEP 254: Compact Strings (java.net)

Motivation

The current implementation of the String class stores characters in a char array, using two bytes (sixteen bits) for each character. Data gathered from many different applications indicates that strings are a major component of heap usage and, moreover, that most String objects contain only Latin-1 characters. Such characters require only one byte of storage, hence half of the space in the internal char arrays of such String objects is going unused.

Description

We propose to change the internal representation of the String class from a UTF-16 char array to a byte array plus an encoding-flag field. The new String class will store characters encoded either as ISO-8859-1/Latin-1 (one byte per character), or as UTF-16 (two bytes per character), based upon the contents of the string. The encoding flag will indicate which encoding is used.

String-related classes such as AbstractStringBuilder, StringBuilder, and StringBuffer will be updated to use the same representation, as will the HotSpot VM’s intrinsic string operations.

This is purely an implementation change, with no changes to existing public interfaces. There are no plans to add any new public APIs or other interfaces.

The prototyping work done to date confirms the expected reduction in memory footprint, substantial reductions of GC activity, and minor performance regressions in some corner cases.

动机

目前 String 类的实现将字符存储在一个 char 数组中,每个字符使用两个字节(16 位)。从许多不同的应用中收集到的数据表明,字符串是堆使用的主要组成部分,此外,大多数字符串对象只包含 Latin-1 字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部字符数组中有一半的空间没有被使用。

说明

我们建议将 String 类的内部表示方法从 UTF-16 字符数组改为字节数组加编码标志域。新的 String 类将根据字符串的内容,以 ISO-8859-1/Latin-1(每个字符一个字节)或 UTF-16(每个字符两个字节)的方式存储字符编码。编码标志将表明使用的是哪种编码。


与字符串相关的类,如AbstractStringBuilder、StringBuilder 和 StringBuffer 将被更新以使用相同的表示方法,HotSpot VM 的内在字符串操作也是如此

这纯粹是一个实现上的变化,对现有的公共接口没有变化。目前没有计划增加任何新的公共 API 或其他接口。

迄今为止所做的原型设计工作证实了内存占用的预期减少,GC 活动的大幅减少,以及在某些角落情况下的轻微性能倒退。

结论:String 再也不用 char[] 来存储了,改成了 byte [] 加上编码标记,节约了一些空间

1
2
3
4
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
}

10.1.2. String 的基本特性

String:代表不可变的字符序列。简称:不可变性。

  • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value 进行赋值。
  • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
  • 当调用 string 的 replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。

通过字面量的方式(区别于 new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

字符串常量池是不会存储相同内容的字符串的

String 的 String Pool 是一个固定大小的 Hashtable,默认值大小长度是 1009。如果放进 String Pool 的 String 非常多,就会造成 Hash 冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern 时性能会大幅下降。

使用-XX:StringTablesize可设置 StringTable 的长度

  • 在 jdk6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTablesize 设置没有要求

  • 在 jdk7 中,StringTable 的长度默认值是 60013,StringTablesize 设置没有要求

  • 在 JDK8 中,设置 StringTable 长度的话,1009 是可以设置的最小值

10.2. String 的内存分配

在 Java 语言中有 8 种基本数据类型和一种比较特殊的类型 String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

常量池就类似一个 Java 系统级别提供的缓存。8 种基本数据类型的常量池都是系统协调的,String 类型的常量池比较特殊。它的主要使用方法有两种。

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。

  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern()方法。这个后面重点谈

Java 6 及以前,字符串常量池存放在永久代

Java 7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到 Java 堆内

  • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。

  • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在 Java 7 中使用String.intern()

Java8 元空间,字符串常量在堆

image-20200711093546398

image-20200711093558709

StringTable 为什么要调整?

官网地址:Java SE 7 Features and Enhancements (oracle.com)

Synopsis: In JDK 7, interned strings are no longer allocated in the permanent generation of the Java heap, but are instead allocated in the main part of the Java heap (known as the young and old generations), along with the other objects created by the application. This change will result in more data residing in the main Java heap, and less data in the permanent generation, and thus may require heap sizes to be adjusted. Most applications will see only relatively small differences in heap usage due to this change, but larger applications that load many classes or make heavy use of the String.intern() method will see more significant differences.

简介:在 JDK 7 中,内部字符串不再分配在 Java 堆的永久代中,而是分配在 Java 堆的主要部分(称为年轻代和老年代),与应用程序创建的其他对象一起。这种变化将导致更多的数据驻留在主 Java 堆中,而更少的数据在永久代中,因此可能需要调整堆的大小。大多数应用程序将看到由于这一变化而导致的堆使用的相对较小的差异,但加载许多类或大量使用 String.intern()方法的大型应用程序将看到更明显的差异

10.3. String 的基本操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void test1() {
System.out.print1n("1"); //2321
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10"); //2330
System.out.println("1"); //2321
System.out.println("2"); //2322
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.print1n("6");
System.out.print1n("7");
System.out.println("8");
System.out.println("9");
System.out.println("10");//2330
}

Java 语言规范里要求完全相同的字符串字面量,应该包含同样的 Unicode 字符序列(包含同一份码点序列的常量),并且必须是指向同一个 String 类实例。

1
2
3
4
5
6
7
8
9
10
11
12
class Memory {
public static void main(String[] args) {//line 1
int i= 1;//line 2
Object obj = new Object();//line 3
Memory mem = new Memory();//Line 4
mem.foo(obj);//Line 5
}//Line 9
private void foo(Object param) {//line 6
String str = param.toString();//line 7
System.out.println(str);
}//Line 8
}

image-20210511111607132

10.4. 字符串拼接操作

  • 常量与常量的拼接结果在常量池,原理是编译期优化
  • 常量池中不会存在相同内容的变量
  • 只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder
  • 如果拼接的结果调用 intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址

举例 1

1
2
3
4
5
6
7
8
9
public static void test1() {
// 都是常量,前端编译期会进行代码优化
// 通过idea直接看对应的反编译的class文件,会显示 String s1 = "abc"; 说明做了代码优化
String s1 = "a" + "b" + "c";
String s2 = "abc";

// true,有上述可知,s1和s2实际上指向字符串常量池中的同一个值
System.out.println(s1 == s2);
}

举例 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void test5() {
String s1 = "javaEE";
String s2 = "hadoop";

String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4); // true 编译期优化
System.out.println(s3 == s5); // false s1是变量,不能编译期优化
System.out.println(s3 == s6); // false s2是变量,不能编译期优化
System.out.println(s3 == s7); // false s1、s2都是变量
System.out.println(s5 == s6); // false s5、s6 不同的对象实例
System.out.println(s5 == s7); // false s5、s7 不同的对象实例
System.out.println(s6 == s7); // false s6、s7 不同的对象实例

String s8 = s6.intern();
System.out.println(s3 == s8); // true intern之后,s8和s3一样,指向字符串常量池中的"javaEEhadoop"
}

举例 3

1
2
3
4
5
6
7
8
9
10
11
12
public void test6(){
String s0 = "beijing";
String s1 = "bei";
String s2 = "jing";
String s3 = s1 + s2;
System.out.println(s0 == s3); // false s3指向对象实例,s0指向字符串常量池中的"beijing"
String s7 = "shanxi";
final String s4 = "shan";
final String s5 = "xi";
String s6 = s4 + s5;
System.out.println(s6 == s7); // true s4和s5是final修饰的,编译期就能确定s6的值了
}
  • 不使用 final 修饰,即为变量。如 s3 行的 s1 和 s2,会通过 new StringBuilder 进行拼接
  • 使用 final 修饰,即为常量。会在编译器进行代码优化。在实际开发中,能够使用 final 的,尽量使用

举例 4

1
2
3
4
5
6
7
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3==s4);
}

字节码

我们拿例 4 的字节码进行查看,可以发现s1 + s2实际上是 new 了一个 StringBuilder 对象,并使用了 append 方法将 s1 和 s2 添加进来,最后调用了 toString 方法赋给 s4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 0 ldc #2 <a>
2 astore_1
3 ldc #3 <b>
5 astore_2
6 ldc #4 <ab>
8 astore_3
9 new #5 <java/lang/StringBuilder>
12 dup
13 invokespecial #6 <java/lang/StringBuilder.<init>>
16 aload_1
17 invokevirtual #7 <java/lang/StringBuilder.append>
20 aload_2
21 invokevirtual #7 <java/lang/StringBuilder.append>
24 invokevirtual #8 <java/lang/StringBuilder.toString>
27 astore 4
29 getstatic #9 <java/lang/System.out>
32 aload_3
33 aload 4
35 if_acmpne 42 (+7)
38 iconst_1
39 goto 43 (+4)
42 iconst_0
43 invokevirtual #10 <java/io/PrintStream.println>
46 return

字符串拼接操作性能对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Test {
public static void main(String[] args) {
int times = 50000; // String
long start = System.currentTimeMillis();
testString(times);
long end = System.currentTimeMillis();
System.out.println("String: " + (end - start) + "ms"); // StringBuilder
start = System.currentTimeMillis();
testStringBuilder(times);
end = System.currentTimeMillis();
System.out.println("StringBuilder: " + (end - start) + "ms"); // StringBuffer
start = System.currentTimeMillis();
testStringBuffer(times);
end = System.currentTimeMillis();
System.out.println("StringBuffer: " + (end - start) + "ms");
}

public static void testString(int times) {
String str = "";
for (int i = 0; i < times; i++) {
str += "test";
}
}

public static void testStringBuilder(int times) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < times; i++) {
sb.append("test");
}
}

public static void testStringBuffer(int times) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < times; i++) {
sb.append("test");
}
}
} // 结果 String: 7963ms StringBuilder: 1ms StringBuffer: 4ms

本实验进行 5 万次循环,String 拼接方式的时间是 StringBuilder.append 方式的约 8000 倍,StringBuffer.append()方式的时间是 StringBuilder.append()方式的约 4 倍

可以看到,通过 StringBuilder 的 append 方式的速度,要比直接对 String 使用“+”拼接的方式快的不是一点半点

那么,在实际开发中,对于需要多次或大量拼接的操作,在不考虑线程安全问题时,我们就应该尽可能使用 StringBuilder 进行 append 操作

除此之外,还有那些操作能够帮助我们提高字符串方面的运行效率呢?

StringBuilder 空参构造器的初始化大小为 16。那么,如果提前知道需要拼接 String 的个数,就应该直接使用带参构造器指定 capacity,以减少扩容的次数(扩容的逻辑可以自行查看源代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Constructs a string builder with no characters in it and an * initial capacity of 16 characters.
*/
public StringBuilder() {
super(16);
}

/**
* Constructs a string builder with no characters in it and an * initial capacity specified by the {@code capacity} argument. * * @param
* capacity the initial capacity. * @throws NegativeArraySizeException if the {@code capacity} * argument is less than {@code 0}.
*/
public StringBuilder(int capacity) {
super(capacity);
}

10.5. intern()的使用

官方 API 文档中的解释

public String intern()

Returns a canonical representation for the string object.

A pool of strings, initially empty, is maintained privately by the class String.

When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.

It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.

All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java™ Language Specification.

  • Returns:

    a string that has the same contents as this string, but is guaranteed to be from a pool of unique strings.

当调用 intern 方法时,如果池子里已经包含了一个与这个 String 对象相等的字符串,正如 equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个 String 对象被添加到池中,并返回这个 String 对象的引用。

由此可见,对于任何两个字符串 s 和 t,当且仅当 s.equals(t)为真时,s.intern() == t.intern()为真。

所有字面字符串和以字符串为值的常量表达式都是 interned。

返回一个与此字符串内容相同的字符串,但保证是来自一个唯一的字符串池。


intern 是一个 native 方法,调用的是底层 C 的方法

1
public native String intern();

如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法,它会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

1
String myInfo = new string("I love atguigu").intern();

也就是说,如果在任意字符串上调用 String.intern 方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是 true

1
("a"+"b"+"c").intern() == "abc"

通俗点讲,Interned string 就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)

image-20210511145542579

10.5.1. intern 的使用:JDK6 vs JDK7/8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
/**
* ① String s = new String("1") * 创建了两个对象,堆空间中一个new对象,字符串常量池中一个字符串常量"1"(注意:此时字符串常量池中已有"1")
* ② s.intern() 由于字符串常量池中已存在"1" ,s 指向的是堆空间中的对象地址,s2 指向的是堆空间中常量池中"1"的地址,所以不相等
*/
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2); // jdk1.6 false jdk7/8 false
/**
* ① String s3 = new String("1") + new String("1") 等价于new String("11"),但是,常量池中并不生成字符串"11";
* ② s3.intern() 由于此时常量池中并无"11",所以把s3中记录的对象的地址存入常量池,所以s3 和 s4 指向的都是一个地址
*/
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4); // jdk1.6 false jdk7/8 true
}

image-20210511152240683

image-20200711145925091

总结 String 的 intern()的使用:

JDK1.6 中,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址

JDK1.7 起,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址

练习 1

image-20200711150859709

image-20200711151326909

练习 2

image-20200711151433277

10.5.2. intern 的效率测试:空间角度

我们通过测试一下,使用了 intern 和不使用的时候,其实相差还挺多的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class StringIntern2 {
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];

public static void main(String[] args) {
Integer[] data = new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
arr[i] = new String(String.valueOf(data[i % data.length]));
// arr[i] = new String(String.valueOf(data[i % data.length])).intern();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
try {
Thread.sleep(1000000);
} catch (Exception e) {
e.getStackTrace();
}
}
} // 运行结果不使用intern:7256ms, 使用intern:1395ms

结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用 intern()方法能够节省内存空间。

大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern()方法,就会很明显降低内存的大小。

10.6. StringTable 的垃圾回收

1
2
3
4
5
6
7
8
9
10
public class StringGCTest {
/**
* -Xms15m -Xmx15m -XX:+PrintGCDetails
*/
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
String.valueOf(i).intern();
}
}
}

运行结果

1
[GC (Allocation Failure) [PSYoungGen: 4096K->504K(4608K)] 4096K->1689K(15872K), 0.0581583 secs] [Times: user=0.00 sys=0.00, real=0.06 secs] [GC (Allocation Failure) [PSYoungGen: 4600K->504K(4608K)] 5785K->2310K(15872K), 0.0015621 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 4600K->504K(4608K)] 6406K->2350K(15872K), 0.0034849 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Heap PSYoungGen      total 4608K, used 1919K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)  eden space 4096K, 34% used [0x00000000ffb00000,0x00000000ffc61d30,0x00000000fff00000)  from space 512K, 98% used [0x00000000fff00000,0x00000000fff7e010,0x00000000fff80000)  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000) ParOldGen       total 11264K, used 1846K [0x00000000ff000000, 0x00000000ffb00000, 0x00000000ffb00000)  object space 11264K, 16% used [0x00000000ff000000,0x00000000ff1cd9b0,0x00000000ffb00000) Metaspace       used 3378K, capacity 4496K, committed 4864K, reserved 1056768K  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K

10.7. G1 中的 String 去重操作

官网地址:JEP 192: String Deduplication in G1 (java.net)

Motivation

Many large-scale Java applications are currently bottlenecked on memory. Measurements have shown that roughly 25% of the Java heap live data set in these types of applications is consumed by String objects. Further, roughly half of those String objects are duplicates, where duplicates means string1.equals(string2) is true. Having duplicate String objects on the heap is, essentially, just a waste of memory. This project will implement automatic and continuous String deduplication in the G1 garbage collector to avoid wasting memory and reduce the memory footprint.

目前,许多大规模的 Java 应用程序在内存上遇到了瓶颈。测量表明,在这些类型的应用程序中,大约 25%的 Java 堆实时数据集被String'对象所消耗。此外,这些 "String "对象中大约有一半是重复的,其中重复意味着 "string1.equals(string2) "是真的。在堆上有重复的String’对象,从本质上讲,只是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动和持续的`String’重复数据删除,以避免浪费内存,减少内存占用。


注意这里说的重复,指的是在堆中的数据,而不是常量池中的,因为常量池中的本身就不会重复

背景:对许多 Java 应用(有大的也有小的)做的测试得出以下结果:

  • 堆存活数据集合里面 string 对象占了 25%
  • 堆存活数据集合里面重复的 string 对象有 13.5%
  • string 对象的平均长度是 45

许多大规模的 Java 应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java 堆中存活的数据集合差不多 25%是 String 对象。更进一步,这里面差不多一半 string 对象是重复的,重复的意思是说: stringl.equals(string2)= true堆上存在重复的 String 对象必然是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动持续对重复的 string 对象进行去重,这样就能避免浪费内存。

实现

  1. 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的 String 对象
  2. 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的 string 对象。
  3. 使用一个 hashtable 来记录所有的被 String 对象使用的不重复的 char 数组。当去重的时候,会查这个 hashtable,来看堆上是否已经存在一个一模一样的 char 数组。
  4. 如果存在,String 对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
  5. 如果查找失败,char 数组会被插入到 hashtable,这样以后的时候就可以共享这个数组了。

命令行选项

1
2
3
4
# 开启String去重,默认是不开启的,需要手动开启。
UseStringDeduplication(bool) # 打印详细的去重统计信息
PrintStringDeduplicationStatistics(bool) # 达到这个年龄的String对象被认为是去重的候选对象
StringpeDuplicationAgeThreshold(uintx)

[toc]

9. 执行引擎

9.1. 执行引擎概述

执行引擎属于 JVM 的下层,里面包括解释器、及时编译器、垃圾回收器

image-20200710080707873

执行引擎是 Java 虚拟机核心的组成部分之一。

“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式

JVM 的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。

image-20200710081118053

那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令.才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H3dqdi5T-1620741818957)(https://gitee.com/vectorx/ImageCloud/raw/master/img/20210511090655.png)]

9.1.1. 执行引擎的工作流程

  1. 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于 PC 寄存器。
  2. 每当执行完一项指令操作后,PC 寄存器就会更新下一条需要被执行的指令地址。
  3. 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。

image-20200710081627217

从外观上来看,所有的 Java 虚拟机的执行引擎输入,输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程。

9.2. Java 代码编译和执行过程

image-20200710082141643

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤

Java 代码编译是由 Java 源码编译器(前端编译器)来完成,流程图如下所示:

image-20200710082433146

Java 字节码的执行是由 JVM 执行引擎(后端编译器)来完成,流程图 如下所示

image-20200710083036258

9.2.1. 什么是解释器(Interpreter)?什么是 JIT 编译器?

解释器:当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

9.2.2. 为什么 Java 是半编译半解释型语言?

JDK1.0 时代,将 Java 语言定位为“解释执行”还是比较准确的。再后来,Java 也发展出可以直接生成本地代码的编译器。现在 JVM 在执行 Java 代码的时候,通常都会将解释执行与编译执行二者结合起来进行。

图示

image-20200710083656277

9.3. 机器码、指令、汇编语言

9.3.1. 机器码

各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。

机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。

用它编写的程序一经输入计算机,CPU 直接读取运行,因此和其他语言编的程序相比,执行速度最快。

机器指令与 CPU 紧密相关,所以不同种类的 CPU 所对应的机器指令也就不同。

9.3.2. 指令

由于机器码是有 0 和 1 组成的二进制序列,可读性实在太差,于是人们发明了指令。

指令就是把机器码中特定的 0 和 1 序列,简化成对应的指令(一般为英文简写,如 mov,inc 等),可读性稍好

由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如 mov),对应的机器码也可能不同。

9.3.3. 指令集

不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。 如常见的

  • x86 指令集,对应的是 x86 架构的平台
  • ARM 指令集,对应的是 ARM 架构的平台

9.3.4. 汇编语言

由于指令的可读性还是太差,于是人们又发明了汇编语言。

在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用 mark 地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。

由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。

9.3.5. 高级语言

为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言

当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。

image-20200710085323733

高级语言也不是直接翻译成机器指令,而是翻译成汇编语言码,如下面说的 C 和 C++

C、C++源程序执行过程

编译过程又可以分成两个阶段:编译和汇编。

编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码

汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。

image-20200710085553258

9.3.6. 字节码

字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码

字节码主要为了实现特定软件运行和软件环境、与硬件环境无关

字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。字节码典型的应用为:Java bytecode

image-20210511092336091

9.4. 解释器

JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

image-20200710090203674

为什么 Java 源文件不直接翻译成 JMV,而是翻译成字节码文件?可能是因为直接翻译的代价是比较大的

9.4.1. 解释器工作机制

解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

当一条字节码指令被解释执行完成后,接着再根据 PC 寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

9.4.2. 解释器分类

在 Java 的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
  • 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

在 HotSpot VM 中,解释器主要由 Interpreter 模块和 Code 模块构成。

  • Interpreter 模块:实现了解释器的核心功能
  • Code 模块:用于管理 HotSpot VM 在运行时生成的本地机器指令

9.4.3. 现状

由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如 Python、Perl、Ruby 等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些 C/C++程序员所调侃。

为了解决这个问题,JVM 平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

9.5. JIT 编译器

9.5.1. Java 代码的执行分类

  • 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行

  • 第二种是编译执行(直接编译成机器码,但是要知道不同机器上编译的机器码是不一样,而字节码是可以跨平台的)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行

HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

在今天,Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。

问题来了

有些开发人员会感觉到诧异,既然 HotSpot VM 中已经内置 JIT 编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如 JRockit VM 内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

首先明确: 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。

所以: 尽管 JRockit VM 中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当 Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。

同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。

9.5.2. HotSpot JVM 执行方式

当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。

案例来了

注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。

在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的 1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的 JVM 均是解释执行,还没有进行热点代码统计和 JIT 动态编译,导致机器启动之后,当前 1/2 发布成功的服务器马上全部宕机,此故障说明了 JIT 的存在。—阿里团队

image-20200710095417462

9.5.3. 概念解释

Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java 文件转变成.class 文件的过程;

也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程。

还可能是指使用静态提前编译器(AOT 编译器,Ahead of Time Compiler)直接把.java 文件编译成本地机器代码的过程。

  • 前端编译器:Sun 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)。

  • JIT 编译器:HotSpot VM 的 C1、C2 编译器。

  • AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。

9.5.4. 热点代码及探测技术

当然是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT 编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升 Java 程序的执行性能。

一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过 JIT 编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为OSR(On Stack Replacement)编译

一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT 编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能

目前 HotSpot VM 所采用的热点探测方式是基于计数器的热点探测

采用基于计数器的热点探测,HotSpot VM 将会为每一个方法都建立 2 个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)

  • 方法调用计数器用于统计方法的调用次数
  • 回边计数器则用于统计循环体执行的循环次数

方法调用计数器

这个计数器就用于统计方法被调用的次数,它的默认阀值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次。超过这个阈值,就会触发 JIT 编译。

这个阀值可以通过虚拟机参数 -XX:CompileThreshold来人为设定。

当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

image-20200710101829934

热点衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)

进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。

另外,可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒

回边计数器

它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。

image-20200710103103869

9.5.5. HotSpotVM 可以设置程序执行方法

缺省情况下 HotSpot VM 是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为 Java 虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

  • -Xint:完全采用解释器模式执行程序;
  • -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。

测试方式:循环调用某个方法100万次

-Xint:6520ms

-Xcomp:950ms

-Xmixed:936ms。比 -Xcomp 还快如何解释?

9.5.6. HotSpotVM 中 JIT 分类

JIT 的编译器还分为了两种,分别是 C1 和 C2,在 HotSpot VM 中内嵌有两个 JIT 编译器,分别为 Client Compiler 和 Server Compiler,但大多数情况下我们简称为 C1 编译器 和 C2 编译器。开发人员可以通过如下命令显式指定 Java 虚拟机在运行时到底使用哪一种即时编译器,如下所示:

  • -client:指定 Java 虚拟机运行在 Client 模式下,并使用 C1 编译器;C1 编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。
  • -server:指定 Java 虚拟机运行在 server 模式下,并使用 C2 编译器。C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。

分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发 C1 编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。

不过在 Java7 版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由 C1 编译器和 C2 编译器相互协作共同来执行编译任务。

C1 和 C2 编译器不同的优化策略

在不同的编译器上有不同的优化策略,C1 编译器上主要有方法内联、去虚拟化、冗余消除

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
  • 去虚拟化:对唯一的实现类进行内联
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉

C2 的优化主要是在全局层面,逃逸分析(前面讲过,并不成熟)是优化的基础。基于逃逸分析在 C2 上有如下几种优化:

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
  • 同步消除:清除同步操作,通常指 synchronized

总结

一般来讲,JIT 编译出来的机器码性能比解释器高。

C2 编译器启动时长比 C1 慢,系统稳定执行以后,C2 编译器执行速度远快于 C1 编译器。

写到最后 1

  • 自 JDK10 起,HotSpot 又加入了一个全新的及时编译器:Graal 编译器
  • 编译效果短短几年时间就追评了 C2 编译器,未来可期
  • 目前,带着实验状态标签,需要使用开关参数-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler去激活才能使用

写到最后 2:AOT 编译器

jdk9 引入了 AOT 编译器(静态提前编译器,Ahead of Time Compiler)

Java 9 引入了实验性 AOT 编译工具 jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。

所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。

最大的好处:Java 虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少 Java 应用给人带来“第一次运行慢” 的不良体验

缺点:

  • 破坏了 java “ 一次编译,到处运行”的理念,必须为每个不同的硬件,OS 编译对应的发行包
  • 降低了 Java 链接过程的动态性,加载的代码在编译器就必须全部已知。
  • 还需要继续优化中,最初只支持 Linux X64 java base

[toc]

8. 对象实例化及直接内存

8.1. 对象实例化

面试题

美团

对象在 JVM 中是怎么存储的?

对象头信息里面有哪些东西?

蚂蚁金服

Java 对象头有什么?

image-20200709095356247

8.1.1. 创建对象的方式

  • new:最常见的方式、Xxx 的静态方法,XxxBuilder/XxxFactory 的静态方法
  • Class 的 newInstance 方法:反射的方式,只能调用空参的构造器,权限必须是 public
  • Constructor 的 newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用 clone():不调用任何的构造器,要求当前的类需要实现 Cloneable 接口,实现 clone()
  • 使用序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库 Objenesis

8.1.2. 创建对象的步骤

前面所述是从字节码角度看待对象的创建过程,现在从执行步骤的角度来分析:

image-20210510220743192

1. 判断对象对应的类是否加载、链接、初始化

虚拟机遇到一条 new 指令,首先去检查这个指令的参数能否在 Metaspace 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化(即判断类元信息是否存在)。

如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为 key 进行查找对应的 .class 文件;

  • 如果没有找到文件,则抛出 ClassNotFoundException 异常
  • 如果找到,则进行类加载,并生成对应的 Class 对象

2. 为对象分配内存

首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小

如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。

  • 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是 Serial ,ParNew 这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带 Compact(整理)过程的收集器时,使用指针碰撞。

如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。

  • 已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。

选择哪种分配方式由 Java 堆是否规整所决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3. 处理并发问题

  • 采用 CAS 失败重试、区域加锁保证更新的原子性
  • 每个线程预先分配一块 TLAB:通过设置 -XX:+UseTLAB参数来设定

4. 初始化分配到的内存

所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用

5. 设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于 JVM 实现。

6. 执行 init 方法进行初始化

在 Java 程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

因此一般来说(由字节码中跟随 invokespecial 指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

给对象属性赋值的操作

  • 属性的默认初始化
  • 显式初始化
  • 代码块中初始化
  • 构造器中初始化

对象实例化的过程

  1. 加载类元信息
  2. 为对象分配内存
  3. 处理并发问题
  4. 属性的默认初始化(零值初始化)
  5. 设置对象头信息
  6. 属性的显示初始化:代码块中初始化、构造器中初始化

8.2. 对象内存布局

image-20200709151033237

8.2.1. 对象头(Header)

对象头包含了两部分,分别是运行时元数据(Mark Word)类型指针。如果是数组,还需要记录数组的长度

运行时元数据

  • 哈希值(HashCode)
  • GC 分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程 ID
  • 翩向时间戳

类型指针

指向类元数据 InstanceKlass,确定该对象所属的类型。

8.2.2. 实例数据(Instance Data)

它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)

  • 相同宽度的字段总是被分配在一起
  • 父类中定义的变量会出现在子类之前
  • 如果 CompactFields 参数为 true(默认为 true):子类的窄变量可能插入到父类变量的空隙

8.2.3. 对齐填充(Padding)

不是必须的,也没有特别的含义,仅仅起到占位符的作用

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Customer{
int id = 1001;
String name;
Account acct;

{
name = "匿名客户";
}

public Customer() {
acct = new Account();
}
}

public class CustomerTest{
public static void main(string[] args){
Customer cust=new Customer();
}
}

图示

image-20200709152801713

小结

image-20210510225407119

8.3. 对象的访问定位

image-20210510230045654

JVM 是如何通过栈帧中的对象引用访问到其内部的对象实例呢?

image-20200709164149920

8.3.1. 句柄访问

image-20210510230241991

reference 中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference 本身不需要被修改

8.3.2. 直接指针(HotSpot 采用)

image-20210510230337956

直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据

8.4. 直接内存(Direct Memory)

8.4.1. 直接内存概述

不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。

直接内存是在 Java 堆外的、直接向系统申请的内存区间

来源于 NIO,通过存在堆中的 DirectByteBuffer 操作 Native 内存。

通常,访问直接内存的速度会优于 Java 堆,即读写性能高

  • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
  • Java 的 NIO 库允许 Java 程序使用直接内存,用于数据缓冲区

8.4.2. 非直接缓存区

使用 IO 读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要两份内存存储重复数据,效率低。

image-20210510231408607

8.4.3. 直接缓存区

使用 NIO 时,操作系统划出的直接缓存区可以被 java 代码直接访问,只有一份。NIO 适合对大文件的读写操作。

image-20210510231456550

也可能导致 OutOfMemoryError 异常

1
2
3
4
5
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:693)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at com.atguigu.java.BufferTest2.main(BufferTest2.java:20)

由于直接内存在 Java 堆外,因此它的大小不会直接受限于-Xmx 指定的最大堆大小,但是系统内存是有限的,Java 堆和直接内存的总和依然受限于操作系统能给出的最大内存。

  • 分配回收成本较高
  • 不受 JVM 内存回收管理

直接内存大小可以通过MaxDirectMemorySize设置。如果不指定,默认与堆的最大值-Xmx 参数值一致

image-20200709230647277

[toc]

7. 方法区

image-20210510141044840

从线程共享与否的角度来看

image-20210510141131860

7.1. 栈、堆、方法区的交互关系

image-20200708094747667

7.2. 方法区的理解

官方文档:Chapter 2. The Structure of the Java Virtual Machine (oracle.com)

image-20210510195446194

7.2.1. 方法区在哪里?

《Java 虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于 HotSpotJVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。

所以,方法区看作是一块独立于 Java 堆的内存空间

image-20200708095853544

7.2.2. 方法区的基本理解

  • 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。
  • 方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace
    • 加载大量的第三方的 jar 包;Tomcat 部署的工程过多(30~50 个);大量动态的生成反射类
  • 关闭 JVM 就会释放这个区域的内存。

7.2.3. HotSpot 中方法区的演进

在 jdk7 及以前,习惯上把方法区,称为永久代。jdk8 开始,使用元空间取代了永久代。

image-20210510142516373

本质上,方法区和永久代并不等价。仅是对 hotspot 而言的。《Java 虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit / IBM J9 中不存在永久代的概念。

现在来看,当年使用永久代,不是好的 idea。导致 Java 程序更容易 OOM(超过-XX:MaxPermsize上限)

image-20210510142656677

而到了 JDK8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Metaspace)来代替

image-20200708103055914

元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

永久代、元空间二者并不只是名字变了,内部结构也调整了

根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OOM 异常

7.3. 设置方法区大小与 OOM

7.3.1. 设置方法区内存的大小

方法区的大小不必是固定的,JVM 可以根据应用的需要动态调整。

jdk7 及以前

  • 通过-XX:Permsize来设置永久代初始分配空间。默认值是 20.75M
  • 通过-XX:MaxPermsize来设定永久代最大可分配空间。32 位机器默认是 64M,64 位机器模式是 82M
  • 当 JVM 加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen space

image-20200708111756800

JDK8 以后

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize指定
  • 默认值依赖于平台。windows 下,-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1//即没有限制
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize:设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的-XX:MetaspaceSize值为 21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到 Full GC 多次调用。为了避免频繁地 GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

举例 1:《深入理解 Java 虚拟机》的例子

image-20210510143959924

举例 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* jdk8中:
* -XX:MetaspaceSize=10m-XX:MaxMetaspaceSize=10m
* jdk6中:
* -XX:PermSize=10m-XX:MaxPermSize=10m
*/
public class OOMTest extends ClassLoader{
public static void main(String[] args){
int j = 0;
try{
OOMTest test = new OOMTest();
for (int i=0;i<10000;i++){
//创建Classwriter对象,用于生成类的二进制字节码
ClassWriter classWriter = new ClassWriter(0);
//指明版本号,public,类名,包名,父类,接口
classWriter.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, nu1l, "java/lang/Object", null);
//返回byte[]
byte[] code = classWriter.toByteArray();
//类的加载
test.defineClass("Class" + i, code, 0, code.length); //CLass对象
j++;
}
} finally{
System.out.println(j);
}
}
}

7.3.2. 如何解决这些 OOM

  1. 要解决 OOM 异常或 heap space 的异常,一般的手段是首先通过内存映像分析工具(如 Eclipse Memory Analyzer)对 dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)

  2. 如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与 GCRoots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及 GCRoots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。

  3. 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

7.4. 方法区的内部结构

image-20200708161728320

7.4.1. 方法区(Method Area)存储什么?

《深入理解 Java 虚拟机》书中对方法区(Method Area)存储内容描述如下:

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

image-20200708161856504

7.4.2. 方法区的内部结构

类型信息

对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名=包名.类名)
  2. 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.object,都没有父类)
  3. 这个类型的修饰符(public,abstract,final 的某个子集)
  4. 这个类型直接接口的一个有序列表

域(Field)信息

JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient 的某个子集)

方法(Method)信息

JVM 必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  1. 方法名称
  2. 方法的返回类型(或 void)
  3. 方法参数的数量和类型(按顺序)
  4. 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
  5. 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
  6. 异常表(abstract 和 native 方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

non-final 的类变量

  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
  • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
1
2
3
4
5
6
7
8
9
10
11
12
13
public class MethodAreaTest {
public static void main(String[] args) {
Order order = new Order();
order.hello();
System.out.println(order.count);
}
}
class Order {
public static int count = 1;
public static void hello() {
System.out.println("hello!");
}
}

补充说明:全局常量(static final)

被声明为 final 的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。

7.4.3. 运行时常量池 VS 常量池

image-20200708171151384

  • 方法区,内部包含了运行时常量池
  • 字节码文件,内部包含了常量池
  • 要弄清楚方法区,需要理解清楚 ClassFile,因为加载类的信息都在方法区。
  • 要弄清楚方法区的运行时常量池,需要理解清楚 ClassFile 中的常量池。

官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html

image-20200708172357052

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用

为什么需要常量池?

一个 java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

比如:如下的代码:

1
2
3
4
5
public class SimpleClass {
public void sayHello() {
System.out.println("hello");
}
}

虽然只有 194 字节,但是里面却使用了 String、System、PrintStream 及 Object 等结构。这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。

image-20210510145947122

常量池中有什么?

击中常量池内存储的数据类型包括:

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

例如下面这段代码:

1
2
3
4
5
public class MethodAreaTest2 {
public static void main(String args[]) {
Object obj = new Object();
}
}

Object obj = new Object();将会被翻译成如下字节码:

1
2
3
0: new #2  // Class java/lang/Object
1: dup
2: invokespecial // Method java/lang/Object "<init>"() V

小结

常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

7.4.4. 运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  • 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  • JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
    • 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。String.intern()
  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OutOfMemoryError 异常。

7.5. 方法区使用举例

1
2
3
4
5
6
7
8
9
public class MethodAreaDemo {
public static void main(String args[]) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a+b);
}
}

image-20210510151436251

image-20210510151504259

image-20210510151520952

image-20210510151609566

image-20210510151648231

image-20210510151712355

image-20210510151753579

image-20210510151829404

image-20210510151918342

image-20210510151951327

image-20200708205708057

image-20210510152102989

image-20210510152138492

image-20210510195824437

image-20210510195911639

image-20210510152243933

7.6. 方法区的演进细节

  1. 首先明确:只有 Hotspot 才有永久代。BEA JRockit、IBMJ9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一
  2. Hotspot 中方法区的变化:
JDK1.6 及之前 有永久代(permanet),静态变量存储在永久代上
JDK1.7 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中
JDK1.8 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。

image-20200708211541300

image-20200708211609911

image-20200708211637952

7.6.1. 为什么永久代要被元空间替代?

官网地址:JEP 122: Remove the Permanent Generation (java.net)

image-20210510163843564

JRockit 是和 HotSpot 融合后的结果,因为 JRockit 没有永久代,所以他们不需要配置永久代

随着 Java8 的到来,HotSpot VM 中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

这项改动是很有必要的,原因有:

  • 为永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 oom。比如某个实际 Web 工 程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。

    1
    "Exception in thread 'dubbo client x.x connector' java.lang.OutOfMemoryError:PermGen space"

    而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。

  • 对永久代进行调优是很困难的。

有些人认为方法区(如 HotSpot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的 ZGC 收集器就不支持类卸载)。 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型

7.6.2. StringTable 为什么要调整位置?

jdk7 中将 StringTable (字符串常量池)放到了堆空间中。因为永久代的回收效率很低,在 full gc 的时候才会触发。而 full gc 是老年代的空间不足、永久代不足时才会触发。

这就导致 StringTable 回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

7.6.3. 静态变量存放在那里?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 结论:静态引用对应的"对象实体"始终都存在堆空间
* jdk7:
* -Xms200m -Xmx200m -XX:PermSize=300m -XX:MaxPermSize=300m -XX:+PrintGCDetails
* jdk8:
* -Xms200m -Xmx200m-XX:MetaspaceSize=300m -XX:MaxMetaspaceSize=300m -XX:+PrintGCDetails
*/
public class StaticFieldTest {
private static byte[] arr = new byte[1024 * 1024 * 100]; //100MB
public static void main(String[] args) {
System.out.println(StaticFieldTest.arr);

try {
Thread.sleep(1000000);
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* staticobj、instanceobj、Localobj 变量名 存放在哪里?
*/
public class StaticobjTest {
static class Test {
static ObjectHolder staticobj = new ObjectHolder();
ObjectHolder instanceobj = new ObjectHolder();

void foo() {
ObjectHolder localobj = new ObjectHolder();
System.out.println("done");
}
}

private static class ObjectHolder {
public static void main(String[] args) {
Test test = new StaticobjTest.Test();
test.foo();
}
}
}

使用 JHSDB 工具(jdk9以后)进行分析,这里细节略掉

image-20241015093940268

staticobj 随着 Test 的类型信息存放在方法区

instanceobj 随着 Test 的对象实例存放在 Java 堆

localobject 则是存放在 foo()方法栈帧的局部变量表中。

image-20200708215025527

测试发现:三个对象的数据在内存中的地址都落在 Eden 区范围内,所以结论:只要是对象实例必然会在 Java 堆中分配。

接着,找到了一个引用该 staticobj 对象的地方,是在一个 java.lang.Class 的实例里,并且给出了这个实例的地址,通过 Inspector 查看该对象实例,可以清楚看到这确实是一个 java.lang.Class 类型的对象实例,里面有一个名为 staticobj 的实例字段:

image-20200708215218078

从《Java 虚拟机规范》所定义的概念模型来看,所有 Class 相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java 虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。JDK7 及其以后版本的 HotSpot 虚拟机选择把静态变量与类型在 Java 语言一端的映射 class 对象存放在一起,存储于 Java 堆之中,从我们的实验中也明确验证了这一点

7.7. 方法区的垃圾回收

有些人认为方法区(如 Hotspot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 zGC 收集器就不支持类卸载)。

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

回收废弃常量与回收 Java 堆中的对象非常类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。

  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

总结

image-20200708220303243

常见面试题

百度

说一下 JVM 内存模型吧,有哪些区?分别干什么的?

蚂蚁金服

Java8 的内存分代改进 JVM 内存分哪几个区,每个区的作用是什么?

一面:JVM 内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个 survivor 区?

二面:Eden 和 survior 的比例分配

小米

jvm 内存分区,为什么要有新生代和老年代

字节跳动

二面:Java 的内存分区

二面:讲讲 vm 运行时数据库区 什么时候对象会进入老年代?

京东

JVM 的内存结构,Eden 和 Survivor 比例。

JVM 内存为什么要分成新生代,老年代,持久代。

新生代中为什么要分为 Eden 和 survivor。

天猫

一面:Jvm 内存模型以及分区,需要详细到每个区放什么。

一面:JVM 的内存模型,Java8 做了什么改

拼多多

JVM 内存分哪几个区,每个区的作用是什么?

美团

java 内存分配 jvm 的永久代中会发生垃圾回收吗?

一面:jvm 内存分区,为什么要有新生代和老年代?

0%