2025年【Java书笔记】:《深入理解Java虚拟机:JVM高级特性与**实践(第3版)》第2部分-自动内存管理,第3部分-虚拟机执行子系统,第5部分-高效并发

【Java书笔记】:《深入理解Java虚拟机:JVM高级特性与**实践(第3版)》第2部分-自动内存管理,第3部分-虚拟机执行子系统,第5部分-高效并发作者 周志明 整理者 GitHub https github com starjuly Understandin 第 2 部分 自动内存管理 第 2 章 Java 内存区域与内存溢出异常 2 2 运行时数据区域 Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域 这些区域有各自的用途

大家好,我是讯享网,很高兴认识大家。

第2部分-自动内存管理

第2章 Java内存区域与内存溢出异常

在这里插入图片描述
讯享网

2.2 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机的进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如图2-1所示。
在这里插入图片描述

图2-1 Java虚拟机运行时数据区

2.2.1 程序计数器

  • 程序计数器(Program Counter Register)是一块较小的内存空间,他可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条指令中的指令。因此,为了线程切换后能恢复到正确的位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存。
  • 如果程序正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个没有规定任何OutOfMemoryError情况的区域。

2.2.2 Java虚拟机栈

  • Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个战争用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和returnAddress类型(指向了一条字节码指令的地址)
  • 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。
  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.2.3 本地方法栈

  • 本地方法栈与虚拟机栈所发挥的作用很相似,区别在于虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法服务。

2.2.4 Java堆

  • 几乎所有的对象实例以及数组都应当在堆上分配。
  • 垃圾收集器管理的主要区域。
  • 从分配内存的角度,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Theard Local Allocation Buffer, TLAB),以提升效率。
  • Java堆可以实现成固定大小的,也可以是可扩展的,主流的Java虚拟机都是可扩展的(通过参数最大值:-Xmx和最小值:-Xms设定),Java堆没有内存分配实例对象,并且无法扩展时,将会抛出OutOfMemoryError异常。

2.2.5 方法区

  • 和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、及时编译器编译后的代码缓存等数据。
    • 类信息:即 Class 类,如类名、访问修饰符、常量池、字段描述、方法描述等。
  • 关于“永久代”(Permanent Generation)这个概念:由于HotSpot虚拟机使用“永久代”来实现方法区,故许多Java程序员都习惯把方法区称呼为“永久代”,但这种设计更容易导致内存溢出问题。在JDK6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区,到了JDK7,已经把原本放在永久代的字符串常量池、静态变量等移除,而到了JDK8,终于完全废弃了永久代的概念,改用了与JPockit、J9一样在本地内存中实现的元空间中 。
  • 垃圾收集行为比较少,甚至可以不实现垃圾收集。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
  • 无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

2.2.6 运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,在类加载后存放到方法区。

2.2.7 直接内存

  • 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,但被频繁地使用,而且也可能导致OutOfMemoryError异常。
  • 在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
  • 一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。

2.3 HotSpot 虚拟机堆中的对象

这一小节将对 JVM 对 Java 堆中的对象的创建、布局和访问的全过程进行讲解。

2.3.1对象的创建

遇到一条New指令,虚拟机的步骤:

  1. 检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先把这个类加载进内存;
  2. 类加载检查通过后,虚拟机将为新对象分配内存,类加载完就可以确定存储这个对象所需的内存大小;
  3. 将分配到的内存空间初始化为零值;
  4. 设置对象头(Object Header)中的数据,包括这个对象是哪个类的实例、如何才能找到类的元数据、对象的哈希码(实际在调用Object::hashCode()方法才计算)、对象的GC分代年龄等;
  5. 此时从虚拟机的角度看,对象已经产生,但从 Java 程序的角度看,构造函数还没有执行。执行完初始化函数,一个真正的对象才算完全构造出来。

在第二步中,为对象分配内存,就是在内存划分一块确定大小的空闲内存,但存在两个问题:

  1. 如何划分空闲内存和已被使用的内存?
    • 假设Java堆中内存是绝对规整的,空闲内存和被使用内存被分到两边,中间放置指针作为分界点的指示器,那分配内存就是把指针向空闲内存一定一段,这种方式成为“指针碰撞(Bump The Pointer)”。
    • 但如果Java堆内存不是规整的,那就没有办法简单地进行指针碰撞了,虚拟机需要维护一个列表,记录哪些内存块可以使用,在分配内存的时候,找到一块足够打的内存划分给对象实例,并更新列表上的记录,这种方式被称为“空闲列表(Free List)”。
    • 事实上,这由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Seria、parNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存。
  2. 如何处理多线程下,内存分配问题?
    • 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
    • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Theard Local Allocation Buffer, TLAB),哪个线程要分配内存就先在本地线程分配缓冲中分配,只有缓冲使用完了,分配新的缓存区时需要同步锁定。
    • 通过-XX:+/-UseTLAB 参数设置是否使用TLAB。

2.3.2对象的内存布局

  • 对象头(Header):
    • 第一部分:存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。(官方称之为“Mark Word”)。
    • 第二部分:类型指针,即对象指向它的类型元数据的指针,虚拟机通过这个指针来判断这个对象是哪个类的实例,如果是数组对象,将会在对象头存储数组长度,以确定对象大小。
  • 实例数据(Instance Data):
    • 在程序代码里面所有定义的各种类型的字段内容都必须记录。
    • 这部分的存储顺序受到虚拟机分配策略参数(-XX:FieldsAllocationStyle 参数)和Java源码中定义顺序的影响。
    • HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers, OOPs),相同宽度的字段总是被分配到一起存放,满足这个条件的前提下,父类定义的变量会出现在子类之前。
  • 对齐填充(Padding):

2.3.3 对象的访问定位

Java程序通过栈上的reference数据来操作堆上具体对象,主流的访问方式主要有以下两种:

  • 使用句柄:Java堆可能会划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄包含了对象实例数据与类型数据各自Juin的地址信息。结构如图2-2所示。
  • 优势:在对象被移动(垃圾收集时移动对象是非常普遍的行为)时,只会改变句柄中的实例数据指针,而不需修改reference。

通过句柄访问对

  • 使用直接指针:Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身,就不需要多一次间接访问的开销。如图2-3所示。
  • 优势:速度快,节省一次指针定位的时间开销。

通过直接指针访问对

2.4 实战: OutOfMemoryError异常

2.4.1 Java堆溢出

  • Java堆内存的OutOfMemoryError异常是实际应用最常见的内存溢出情况,异常堆栈信息“java.lang.OutOfMemoryError”会进一步提示“Java heap space”。
  • 解决方法:
    • 首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。分析清楚是出现内存泄漏(Memory Leak)还是内存溢出(Memory overflow),即导致OOM的对象是否是必要的。
    • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们。根据泄漏对象的类型信息以及它到GC Roots 引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找到产生内存泄漏的代码具体位置。
    • 如果是内存溢出,检查Java虚拟机的堆参数(-Xmx与-Xms)设置,检查代码对象生命周期是否过长、存储结构设计不合理等情况,减少程序运行的内存消耗。

2.4.2 虚拟机栈和本地方法栈溢出

  • 线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机栈允许动态扩展,当扩展栈容量无法申请到足够的内存,将抛出OutOfMemoryError异常。

2.4.3方法区和运行时常量池溢出

  • 运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息是“PermGen space”。
  • 类加载过多,会导致方法区内存溢出,在实际的应用中,如Spring框架,会使用到CGLib这类字节码技术,会产生大量的动态类,容易导致内存溢出,需要注意垃圾回收;除此之外常见的还有大量JSP或动态JSP文件应用、基于OSGi的应用等。
  • 在JDK8后,永久代完全退出,元空间作为替代者登场。在默认设置下,动态创建新类型已经很难使虚拟机出现产生方法区内存溢出。

2.4.4本机直接内存溢出

  • --XX:MaxDirectMemorySize设置,如不指定,默认与Java堆最大值(-Xmx指定)一致。
  • 出现的明显特征,Heap Dump文件不会看见明显的异常情况,如发现内存溢出后产生的Dump文件很小,而程序又直接或间接使用了DirectMemory(典型的间接使用是NIO),那可以考虑重点检查直接内存的问题。

垃圾收集器与内存分配策略

3.1 概述

  • 了解垃圾收集和内存分配的原因:当需要排查各种内存写出、内存泄漏问题时,当垃圾收集器成为系统达到更高并发量的瓶颈时,我们就必须对这些"自动化"的技术实施必要的监控和调节。
  • 垃圾收集器关注Java堆和方法区的内存该如何管理。因为Java堆和方法区这两个区域有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

3.2 对象已死?

垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还"存活"着,哪些已经"死去"("死去"即不可能再被任何途径使用的对象)了。

3.2.1 引用计数算法

  • Java虚拟机并不是通过引用计数算法来判断对象是否存活的。
  • 举个简单的例子,请看代码清单3-1中的testGC方法:对象objA和objB都有字段instance,赋值令objA.instance = objB 及 objB.instance = objA,
    除此以外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是他们因为互相引用着对方,导致他们的引用计数都不为领,引用计数算法也就无法回收它们。
 / * testGC()方法执行后,ObjA和ObjB会不会被GC呢? */ public class ReferenceCountingGC { 
    public Object instance = null; private static final int _1MB = 1024 * 1024; / * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过 */ private byte[] bigSize = new byte[2 * _1MB]; public static void testGC() { 
    ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; // 假设在这行发生GC,objA和objB是否能被回收? System.gc(); } public static void main(String[] args) { 
    testGC(); } } 

讯享网

运行结果:

讯享网[GC (System.gc()) [PSYoungGen: 8034K->624K(76288K)] 8034K->632K(K), 0.0014423 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] [Full GC (System.gc()) [PSYoungGen: 624K->0K(76288K)] [ParOldGen: 8K->394K(K)] 632K->394K(K), [Metaspace: 3161K->3161K(K)], 0.0045513 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] Heap PSYoungGen total 76288K, used 1966K [0x000000076ab00000, 0x0000000, 0x00000007c0000000) eden space 65536K, 3% used [0x000000076ab00000,0x000000076aceb9e0,0x000000076eb00000) from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f) to space 10752K, 0% used [0x000000076f,0x000000076f,0x0000000) ParOldGen total K, used 394K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000) object space K, 0% used [0x00000006c0000000,0x00000006c00629d0,0x00000006cab00000) Metaspace used 3173K, capacity 4496K, committed 4864K, reserved K class space used 349K, capacity 388K, committed 512K, reserved K 

从运行结果可以清楚看到内存回收日志中包含"8034K->624K",意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的。

3.2.2 可达性分析算法

  • 可达性分析算法的基本思路就是通过一系列成为"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链"(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
  • 如图3-1所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots都是不可达的,因此它们将会被判定为可回收的对象。

可达性分析算

图3-1 利用可达性分析算法判定对象是否可回收

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量(String Table)里的引用。
  • 在本地方法栈JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointerException、OutOfMemoryException)等,还有系统类加载器。
  • 所有被同步锁(sysnchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入。

3.2.3 再谈引用

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

  • 强引用是最传统的"引用"的定义,是指在程序代码之中普遍存在的引用赋值,即类似于"Object obj = new Object()"这种引用关系。永远不会被垃圾收集器回收。
  • 软引用是用来描述一些还有用、但非必须的对象。
  • 弱引用也是用来描述那些非必须对象,但是他的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
  • 虚引用也称为"幽灵引用"或者"幻影引用",它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能再这个对象被收集器回收时收到一个系统通知。

3.2.4 生存还是死亡?

  • 在可达性算法中被判定为不可达对象后,至少要经历两次标记过程对象才会真正死亡:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。加入对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为"没有必要执行"。
  • 如果这个对象呗判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,如果对象在finalize()中重新与引用链上的一个对象建立关联即可避免被回收,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量。从代码清单3-2中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。
/ * 此代码演示了两点: * 1.对象可以在被GC时自我拯救。 * 2.这种自救的机会只有一次,因为一个 对象的finalize()方法最多只会被系统自动调用一次 */ public class FinalizeEscapeGC { 
    public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { 
    System.out.println("Yes, I am still alive!"); } @Override protected void finalize() throws Throwable { 
    super.finalize(); System.out.println("finalize method is exceted!"); // 重新建立关联,避免被回收 SAVE_HOOK = this; } public static void main(String[] args) throws InterruptedException { 
    SAVE_HOOK = new FinalizeEscapeGC(); // 对象第一次成功拯救自己 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { 
    SAVE_HOOK.isAlive(); } else { 
    System.out.println("No, I am dead :("); } // 下面这段代码和上面的完全相同,但是这次自我拯救失败了 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { 
    SAVE_HOOK.isAlive(); } else { 
    System.out.println("No, I am dead :("); } } } 

运行结果:

讯享网finalize method is exceted! Yes, I am still alive! No, I am dead :( 
  • 从运行结果可以看到finalize()方法被执行,并且只执行了一次,因此第二段代码自救行动失败
  • 官方不推荐使用finalize方法。finalize()能做的所有工作,使用try-finally或者其他方法都可以做得更好,更及时。

3.2.5 回收方法区

方法区的垃圾收集主要回收两个部分内容:废弃的常量和不再使用的类型。

  • 回收废弃常量:一个字符串"java"曾经进入常量池,但是当前系统有没有任何一个字符串对象的值是"java",换句话说,已经没有任何字符串对象引用常量池中的"java"常量,且虚拟机中也没有其他地方引用这个字面量。如果再这个时候发生内存回收就会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
  • 判断一个类型是否属于"不再被使用的类"需要满足下面三个条件:
    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
    • 加载该类的类加载器已经被回收。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.3 垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为"引用计数式垃圾收集"(Reference Counting GC)和"追踪式垃圾收集"(Tracing GC)两大类,这两类也常备称作"直接垃圾收集"和"间接垃圾收集"。本节所有算法均属于追踪式垃圾收集的范畴。

3.3.1 分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了"分代收集"(Generational Collection)的理论进行设计,它建立在两个分代假说之上:

  • 1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象越难以消亡。

    这两个分代假说共同奠定了多款常用的垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果剩下的都是难以消亡的对象,那就把它们集中放在一起,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾回收的时间开销和内存空间的有效利用。

    在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了"Minor GC" “Major GC” “Full GC"这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而展示出了"标记-复制算法” “标记-清除算法” "标记-整理算法"等针对性的垃圾收集算法。
  • 3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代来说仅占极少数。
    • 这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。例如,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增大之后晋升到老年代中,这时跨时代引用也随即被消除了。

注意

对于不同分代的名词定义:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Yong GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有GI收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.3.2 标记-清除算法

  • 标记-清除算法是最基础的垃圾收集算法,该算法分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
  • 该算法有两个缺点:
    • 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
    • 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后再程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集算法。

3.3.3 标记-复制算法

标记-复制算法常被称为复制算法,为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。该算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉。

3.3.4 标记-整理算法

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

标记-清理算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

3.4 HopSpot的算法细节实现

3.4.1 根节点枚举

  • 迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的"Stop The World"的困扰。
  • 由于目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。
    在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译(见第11章)过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。
    这样收集器在扫描时就可以直接得知这些信息了 ,并不需要真正一个不漏地从方法区等GC Roots开始查找。

3.4.2 安全点

  • HotSpot没有为每条指令都生成OopMap,因为将会需要大量的额外存储空间,只是在"特定的位置"记录了这些信息,这些位置被称为安全点(Safepoint)。
    有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,
    也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以"是否具有让程序长时间执行的特征"为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,"长时间执行"的最明显特征就是指令序列的复用,
    例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
  • 对于安全点,另一个需要考虑的问题是,如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来。这里有以下两种方案可供选择:
    • 抢先式中断(Preemptive Suspension):不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
      现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
    • 主动式中断(Voluntary Suspension):主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
      轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

3.4.3 安全区域

  • 安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序"不执行"的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,
    不能再走到安全的地方中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
  • 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸的安全点。
  • 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已经申明自己在安全区域内的线程了。当线程要离开安全区域时,
    它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当做没事发生过,继续执行;否则它就必须一直等待,知道收到可以离开安全区域的信号为止。

3.4.4 记忆集与卡表

  • 讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。
  • 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。 在垃圾收集场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。
    下面列举了一些可供选择的记录精度:
    • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含了跨代指针。
    • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
    • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
  • 其中,第三种"卡精度"所指的是用一种称为"卡表"(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。卡表是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
    关于卡表与记忆集的关系,可以按照Java语言中HashMap和Map的关系来类比理解。
  • 卡表最简单的形式可以只是一个字节数组,以下这行代码是HotSpot默认的卡表标记逻辑:
CARD_TABLE [this address >> 9] = 0; 
  • 字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作"卡页"(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,
    相当于用地址除以512)。
  • 一个卡页的内存中通常包含不止一个对象,只要卡页内存内有一个(或多个)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。
    在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

3.4.5 写屏障

  • 卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
  • 在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护表状态的(也就是变脏)。写屏障可以看作在虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时产生一个环形(Around)通知,供程序执行额外的动作,
    也就是说赋值的前后都写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值之后的则叫作写后屏障(Post-Write Barrier)。
    HotSpot虚拟机的许多收集器都有使用到写屏障,但直至G1收集器之前,其他收集器都只用到了写后屏障。下面这段代码清单 3-6 是一段更新卡表状态的简化逻辑。
讯享网void oop_field_store(oop* field, oop new_value) { // 引用字段赋值操作 *field = new_value; // 写后屏障,在这里完成卡表状态更新 post_write_barrier(field, new_value); } 
  • 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比是低得多的。
  • 除了写屏障的开销外,卡表在高并发场景下还面临着"伪共享"(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,
    如果这些变量恰好共享同一个缓存行,就会彼此影响(写回,无效化或者同步)而导致性能降低,这就是伪共享问题。
  • 为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变量,即将卡表更新的逻辑变为以下代码所示:
if (CARD_TABLE [this address >> 9] != 0) CARD_TABLE [this address >> 9] = 0; 

3.4.6 并发的可达性分析

  • 可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。
    在GC Roots往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系:堆越大,存储的对象越多,对象结构越复杂,要标记更多对象而产生的停顿时间要更长。
  • 想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
    为了能解释清楚这个问题,这里引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照"是否访问过"这个条件标记成以下三种颜色:
    • 白色:表示对象尚未被垃圾收集器访问过。虽然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在**系结束的阶段,仍然是白色的对象,即代表不可达。
    • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,
      如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
    • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
  • 关于可达性分析的扫描过程,可以看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作 ,那不会有任何问题。
    但如果用户线程与收集器是并发工作的呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样子可能出现两种后果。
    • 把原本消亡的喜爱那个错误标记为存活,可以容忍。
    • 把原本存活的对象错误标记为已消亡,后果很致命,程序肯定会因此发生错误。下面表3-1演示了这样的致命错误具体如何产生的。
      在这里插入图片描述

表3-1 并发出现“对象”消失问题的示意

  • 当且仅当以下两个条件同时满足时,会产生"对象消失"的问题,即原本应该是黑色的对象被误标为白色:
    • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
    • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
  • 破坏以上两个条件任意一个即可以解决并发扫描时的对象消失问题。 由此产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。
  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
    这可以理解为,黑色对象一旦新插入了指向白色对象的引用,它就变回灰色对象了。
  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
    这也可以理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
  • 以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

3.5 经典垃圾收集器

3.5.1 Serial收集器

  • Serial收集器是最基础、历史最悠久的收集器。该收集器是一个单线程工作的收集器,但它的"单线程"的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,
    更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。"Stop The World"这项工作是由虚拟机在后台自动发起和自动完成的,
    在用户不可知、不可控的情况下把用户的正常工作的线程全部都停掉,这对很多应用来说都是不可接受的。
  • 虽然有着以上的缺点,但它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),
    对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,
    专心做垃圾收集自然可以获得最高的单线程收集效率。

3.5.2 ParNew收集器

  • ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法Stop The World、
    对象分配规则、回收策略等都与Serial收集器完全一致。

注意

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

3.5.3 Parallel Scavenge收集器

  • Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。
  • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总耗时时间的比值,即:
    • 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
  • 如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,
    良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽可能完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

3.5.4 Serial Old收集器

  • Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也有可能有两种用途:
    一种是在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

3.5.5 Parallel Old收集器

  • Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。该收集器是JDK6时才开始提供的。

3.5.6CMS收集器

  • CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤,包括:
    • 1)初始标记(CMS initial mark)
    • 2)并发标记(CMS concurrent mark)
    • 3)重新标记(CMS remark)
    • 4)并发清除(CMSconcurrent sweep)
  • 其中初始标记、重新标记这两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,
    这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,
    这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
  • CMS收集器至少有以下三个明显的缺点:
    • 首先,CMS收集器对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算器的计算能力)而导致应用程序变慢,降低总吞吐量。
      CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,当处理器核心数量不足4个时,CMS对用户程序的影响就可能变得很大。如果应用程序本来的处理器负载就很高,
      还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓和这种情况,虚拟机提供了一种称为"增量式并发收集器"的CMS收集器变种,在并发标记、清理的时候让收集器线程、
      用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间。
    • 然后,由于CMS收集器无法处理"浮动垃圾"(Floating Grabage),有可能出现"Concurrent Mode Failure"失败进而导致另一次完全"Stop The World"的Full GC的产生。在CMS的并发标记和并发清理阶段,
      用户线程是还在继续运行的,程序在运行自然就还会伴随着有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为"浮动垃圾"。
    • 最后,CMS是一款基于"标记-清除"算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很**烦,往往会出现老年代还有很多剩余空间,
      但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

3.5.7Grabage First收集器

  • Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1是一款主要面向服务端应用的垃圾收集器。
  • 作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起"停顿时间模型"(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,
    消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。
  • 具体要实现这个目标,首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),
    再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,
    而是哪块内存中存放的垃圾数量最多,回收效益最大,这就是G1收集器的Mixed GC模式。
  • G1开创的机油Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,
    而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,
    这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
  • Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,
    取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分进行看待。
  • 虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,
    即每次收集到的内存空间都是Region大小的整倍数,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的"价值"大小,
    价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值最大的那些Region,这也就是"Garbage First"名字的由来。
    这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能的收集效率。
  • 如果不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤:
    • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改了TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。
      这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
    • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
      当对象图扫描完成以后,还要重新处理STAB记录下来的在并发时有引用变动的对象。
    • 最终标记(Final Marking):对用户线程做另一个短暂暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
    • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行统计数据,对各个Region的回收价值和成本进行排序,
      根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。
      这里的操作涉及存活的对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
  • 与CMS的"标记-清除"算法不同,G1从整体来看是基于"标记-整理"算法实现的收集器,但从局部(两个Region之间)上看是基于"标记-复制"算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,
    垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

3.6 低延迟垃圾收集器

  • 衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。
  • 在这三项指标中,延迟的重要性日益凸显,越备受关注。其原因是硬件规格提升,准确地说是内存的扩大,吞吐量会更高,但对延迟反而会带来负面的效果,这点也是很符合直观思维:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的对内容耗费更多时间。

3.6.1 Shenandoah收集器

  • Shenandoah收集器的工作过程大致可以划分为以下九个阶段。(在最新版本的Shenandoah2.0中,进一步强化了“部分收集”的特性,
    初始标记之前还有Initial Partial、Concurrent Partial和Final Partial阶段,他们可以不太严谨地理解为对应于以前分代收集中的Minor GC的工作):
    • 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只有与GC Roots的数量相关。
    • 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂度。
    • 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收截止最高的Region,将这些Region构成一组回收集(Collection Set)。
      最终标记阶段也会有一小段短暂的停顿。
    • 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
    • 并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前的HotSpot中其他收集器的核心差异。在这个阶段,
      Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做是相当简单的,
      但如果两者必须同时并发进行的话,就会变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,
      但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,
      Shenandoah将会通过读屏障和被称为“Brooks Points”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。
    • 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,
      这个操作称为引用更新。引用更新的初始化阶段实际上并未做出什么具体的处理,设立这个阶段只是为了建立一个线程集合点,
      确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
    • 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。
      并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
    • 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,
      停顿时间只与GC Roots的数量相关。
    • 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成了Immediate Garbage Regions了,
      最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

3.6.2 ZGC收集器

  • 首先从ZGC的内存布局说起。与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region具有动态性——动态创建和销毁,
    以及动态的区域容量大小。在x64硬件平台下,ZGC的Region可以具有大、中、小类容量:
    • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
    • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
    • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。
  • 对于并发整理算法的实现,ZGC收集器采用染色指针技术。染色指针是一种直接将少量额外的信息存储在指针上的技术。
  • 尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术将这剩下的46位指针宽度,
    将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。
  • 染色指针的三大优势:
    • 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中多有指向该Region的引用都被修正后才能清理。
    • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,
      显然就可以省去一些专门的记录操作。
    • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定向过程相关的数据,以便日后更进一步提高性能。
  • ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,譬如初始化GC Root直接关联对象的Mark Start,
    与之前的G1和Shenandoah的Initial Mark阶段并没有什么差异。
    • 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记的短暂停顿。不同的是,
      ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0,Marked 1标志位。
    • 并发预备重分配(Concurrent Prepare for Replace):这个阶段需要根据特定的查询条件统计得出本次收集过程中要清理哪些Region,将这些Region组成重分配集(Relocation Set)。
      ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定了里面存放对象会被重新复制到其他的Region中,里面的Region会被释放,
      而不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。此外,在JDK12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段完成的。
    • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存货对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),
      记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,
      这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接引用新对象,
      ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的Brooks转发指针,
      那就是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。还有另外一个直接的好处是由于染色指针的存在,
      一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发还得留着不能释放掉),
      哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
    • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点可从目标角度看是与Shenandoah并发引用更新阶段一样的,
      但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。
      重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射阶段要做的工作,
      合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。
      一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

3.7 选择合适的垃圾收集器

3.7.1 Epsilon收集器

  • Epsilon可以形容为"自动内存管理子系统"。一个垃圾收集器除了垃圾收集这个本职工作之外,它还需要负责堆的管理与布局、对象分配、与解释器的协作、
    与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能的垃圾收集器也必须实现的内容。
  • 近年来大型系统从传统单体应用向微服务化、无服务化方向发展的趋势已越发明显,Java在这方面比起Golang等后起之秀确实有一些先天不足,使用率正渐渐下降。
    为了应对新的技术潮流,最近几个版本的JDK逐渐加入了提前编译、面向应用的类数据共享等支持。Epsilon也是有着类似的目标,如果读者的应用只要运行数分钟甚至数秒,
    只要Java虚拟机能正确分配内存,在堆内存之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。

3.7.2 收集器的权衡

  • 如何选择一款适合自己应用的收集器?该答案主要受以下三个因素影响:
    • 应用程序的关注点是什么?如果是数学分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;如果是SLA应用,延迟是主要关注点;
      如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。
    • 运行以你应用的基础设施如何?譬如硬件规格,要设计的系统是x86-32/64、SPARC还是其他;处理器的数量多少,分配内存的大小;选择的操作系统等。
    • 使用JDK的发行商是什么?版本号是多少?该JDK对应了《Java虚拟机规范》的哪个版本?
  • 一般来说,收集器的选择就从以上几个点出发来考虑。举个例子,假设某个直接面向用户提供服务的B/S系统准备选择垃圾收集器,
    一般来说延迟时间是这类应用的主要关注点,那么:
    • 如果你有充足的预算但没有太多调优经验,那么一套带商业技术的专有硬件或者软件解决方案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,
      这样你就可以使用传说中的C4收集器了。
    • 如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时有特别注重延迟,那ZGC很值得尝试。
    • 如果你对还处于试验状态的收集器的稳定性有所顾虑,或者应用必须运行在Windows操作系统下,那ZGC就无缘了,试试Shenandoah吧。
    • 如果你接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,
      而对于更大的堆内存,可重点考擦以下G1.
  • 当然,以上都是仅从理论出发的分析,实战中切不可纸上谈兵,根据系统实施情况去测试才是选择收集器的最终依据。

虚拟机及垃圾收集器日志

  • 在JDK9后,HotSpot提供统一的日志处理框架,HotSpot所有功能的日志都放到了"-Xlog"的参数上,这个参数的能力也相应被极大扩展了。
讯享网-Xlog[:[selector][:[output][:[decorators][:output-options]]]] 
  • 命令中最关键的参数是选择器(Selector),它由标签(Tag)和日志级别(Level)共同组成。标签可理解为虚拟机中某个功能模块的名字,
    它告诉日志框架用户希望得到虚拟机哪些功能的日志输出。垃圾收集器的标签名称为"gc",由此可见,垃圾收集器日志只是HotSpot众多功能日志的其中一项。
  • 日志级别从低到高,共有Trace,Debug,Info,Warning,Error,Off六种级别,日志级别决定了输出信息的详细程度,默认级别为Info,
    HotSpot的日志规则与Log4j,SLF4j这类Java日志框架大体上是一致的。另外,还可以使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容,
    支持附加在日志上的信息包括:
    • time:当前日期和时间。
    • uptime:虚拟机启动到现在经过的时间,以秒为单位。
    • timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。
    • uptimemillis:虚拟机启动到现在经过的毫秒数。
    • timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。
    • uptimenanos:虚拟机启动到现在经过的纳秒数。
    • pid:进程ID。
    • tid:线程ID。
    • level:日志级别。
    • tags:日志输出的标签集。
  • 如果不指定,默认值是uptime、level、tags这三个。
  • 下面举几个例子,展示在JDK9统一日志框架前、后是如何获得垃圾收集器过程的相关信息,以下均已JDK9的G1收集器(JDK9下默认收集器就是G1,
    所以命令行中没有指定收集器)为例。
    • 1)查看GC基本信息,在JDK9之前使用-XX:+PrintGC,JDK9之后使用-Xlog:gc:
     java -Xlog:gc GCTest.java [0.014s][info][gc] Using G1 [0.602s][info][gc] GC(0) Pause Full (System.gc()) 14M->2M(20M) 8.272ms [0.609s][info][gc] GC(1) Pause Full (System.gc()) 2M->2M(14M) 7.350ms [0.619s][info][gc] GC(2) Pause Full (System.gc()) 2M->2M(10M) 9.763ms 
    • 2)查看GC详细信息,在JDK9之前使用-XX:+PrintGCDetails,在JDK9之后使用-X-log:gc*,用通配符*将GC标签下所有细分过程都打印出来,
      如果把日志级别调整到Debug或者Trace,还将获取更多信息:
    讯享网 java -XX:+PrintGCDetails GCTest.java [0.002s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead. [0.009s][info ][gc,heap] Heap region size: 1M [0.013s][info ][gc ] Using G1 [0.013s][info ][gc,heap,coops] Heap address: 0x0000000, size: 4096 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 [0.589s][info ][gc,task ] GC(0) Using 6 workers of 10 for full compaction [0.589s][info ][gc,start ] GC(0) Pause Full (System.gc()) [0.589s][info ][gc,phases,start] GC(0) Phase 1: Mark live objects [0.591s][info ][gc,stringtable ] GC(0) Cleaned string and symbol table, strings: 5459 processed, 12 removed, symbols: 48571 processed, 12 removed [0.591s][info ][gc,phases ] GC(0) Phase 1: Mark live objects 1.942ms [0.591s][info ][gc,phases,start] GC(0) Phase 2: Prepare for compaction [0.592s][info ][gc,phases ] GC(0) Phase 2: Prepare for compaction 0.610ms [0.592s][info ][gc,phases,start] GC(0) Phase 3: Adjust pointers [0.593s][info ][gc,phases ] GC(0) Phase 3: Adjust pointers 0.941ms [0.593s][info ][gc,phases,start] GC(0) Phase 4: Compact heap [0.594s][info ][gc,phases ] GC(0) Phase 4: Compact heap 1.076ms [0.596s][info ][gc,heap ] GC(0) Eden regions: 15->0(6) [0.596s][info ][gc,heap ] GC(0) Survivor regions: 0->0(0) [0.596s][info ][gc,heap ] GC(0) Old regions: 0->6 [0.596s][info ][gc,heap ] GC(0) Humongous regions: 0->0 [0.596s][info ][gc,metaspace ] GC(0) Metaspace: 14200K->14200K(K) [0.596s][info ][gc ] GC(0) Pause Full (System.gc()) 14M->2M(20M) 7.147ms [0.596s][info ][gc,cpu ] GC(0) User=0.02s Sys=0.01s Real=0.00s [0.596s][info ][gc,task ] GC(1) Using 2 workers of 10 for full compaction [0.596s][info ][gc,start ] GC(1) Pause Full (System.gc()) [0.596s][info ][gc,phases,start] GC(1) Phase 1: Mark live objects [0.599s][info ][gc,stringtable ] GC(1) Cleaned string and symbol table, strings: 5447 processed, 0 removed, symbols: 48559 processed, 0 removed [0.599s][info ][gc,phases ] GC(1) Phase 1: Mark live objects 2.560ms [0.599s][info ][gc,phases,start] GC(1) Phase 2: Prepare for compaction [0.599s][info ][gc,phases ] GC(1) Phase 2: Prepare for compaction 0.442ms [0.599s][info ][gc,phases,start] GC(1) Phase 3: Adjust pointers [0.601s][info ][gc,phases ] GC(1) Phase 3: Adjust pointers 1.238ms [0.601s][info ][gc,phases,start] GC(1) Phase 4: Compact heap [0.602s][info ][gc,phases ] GC(1) Phase 4: Compact heap 0.948ms [0.602s][info ][gc,heap ] GC(1) Eden regions: 0->0(3) [0.602s][info ][gc,heap ] GC(1) Survivor regions: 0->0(0) [0.602s][info ][gc,heap ] GC(1) Old regions: 6->3 [0.602s][info ][gc,heap ] GC(1) Humongous regions: 0->0 [0.602s][info ][gc,metaspace ] GC(1) Metaspace: 14200K->14200K(K) [0.602s][info ][gc ] GC(1) Pause Full (System.gc()) 2M->2M(10M) 6.079ms [0.603s][info ][gc,cpu ] GC(1) User=0.01s Sys=0.00s Real=0.01s [0.603s][info ][gc,task ] GC(2) Using 1 workers of 10 for full compaction [0.603s][info ][gc,start ] GC(2) Pause Full (System.gc()) [0.603s][info ][gc,phases,start] GC(2) Phase 1: Mark live objects [0.608s][info ][gc,stringtable ] GC(2) Cleaned string and symbol table, strings: 5447 processed, 0 removed, symbols: 48559 processed, 0 removed [0.608s][info ][gc,phases ] GC(2) Phase 1: Mark live objects 5.014ms [0.608s][info ][gc,phases,start] GC(2) Phase 2: Prepare for compaction [0.608s][info ][gc,phases ] GC(2) Phase 2: Prepare for compaction 0.754ms [0.608s][info ][gc,phases,start] GC(2) Phase 3: Adjust pointers [0.611s][info ][gc,phases ] GC(2) Phase 3: Adjust pointers 2.747ms [0.611s][info ][gc,phases,start] GC(2) Phase 4: Compact heap [0.613s][info ][gc,phases ] GC(2) Phase 4: Compact heap 2.177ms [0.617s][info ][gc,heap ] GC(2) Eden regions: 0->0(3) [0.617s][info ][gc,heap ] GC(2) Survivor regions: 0->0(0) [0.617s][info ][gc,heap ] GC(2) Old regions: 3->3 [0.617s][info ][gc,heap ] GC(2) Humongous regions: 0->0 [0.617s][info ][gc,metaspace ] GC(2) Metaspace: 14200K->14200K(K) [0.617s][info ][gc ] GC(2) Pause Full (System.gc()) 2M->2M(10M) 14.832ms [0.618s][info ][gc,cpu ] GC(2) User=0.03s Sys=0.00s Real=0.02s [0.619s][info ][gc,heap,exit ] Heap [0.619s][info ][gc,heap,exit ] garbage-first heap total 10240K, used 2282K [0x0000000, 0x0000000) [0.619s][info ][gc,heap,exit ] region size 1024K, 1 young (1024K), 0 survivors (0K) [0.619s][info ][gc,heap,exit ] Metaspace used 14206K, capacity 14530K, committed 14720K, reserved K [0.619s][info ][gc,heap,exit ] class space used 1528K, capacity 1655K, committed 1664K, reserved K 
    • 3)查看GC前后的堆、方法区可用容量变化,在JDK9之前使用-XX:+PrintHeapAtGC,JDK9之后使用-Xlog:gc+heap=debug:
    java -Xlog:gc+heap=debug GCTest.java [0.011s][info][gc,heap] Heap region size: 1M [0.011s][debug][gc,heap] Minimum heap  Initial heap  Maximum heap  [0.652s][debug][gc,heap] GC(0) Heap before GC invocations=0 (full 0): garbage-first heap total K, used 14336K [0x0000000, 0x0000000) [0.652s][debug][gc,heap] GC(0) region size 1024K, 15 young (15360K), 0 survivors (0K) [0.652s][debug][gc,heap] GC(0) Metaspace used 14186K, capacity 14498K, committed 14720K, reserved K [0.652s][debug][gc,heap] GC(0) class space used 1526K, capacity 1623K, committed 1664K, reserved K [0.657s][info ][gc,heap] GC(0) Eden regions: 15->0(6) [0.657s][info ][gc,heap] GC(0) Survivor regions: 0->0(0) [0.657s][info ][gc,heap] GC(0) Old regions: 0->6 [0.657s][info ][gc,heap] GC(0) Humongous regions: 0->0 [0.657s][debug][gc,heap] GC(0) Heap after GC invocations=1 (full 1): garbage-first heap total 20480K, used 2285K [0x0000000, 0x0000000) [0.657s][debug][gc,heap] GC(0) region size 1024K, 0 young (0K), 0 survivors (0K) [0.657s][debug][gc,heap] GC(0) Metaspace used 14186K, capacity 14498K, committed 14720K, reserved K [0.657s][debug][gc,heap] GC(0) class space used 1526K, capacity 1623K, committed 1664K, reserved K [0.657s][debug][gc,heap] GC(1) Heap before GC invocations=1 (full 1): garbage-first heap total 20480K, used 2285K [0x0000000, 0x0000000) [0.657s][debug][gc,heap] GC(1) region size 1024K, 0 young (0K), 0 survivors (0K) [0.657s][debug][gc,heap] GC(1) Metaspace used 14186K, capacity 14498K, committed 14720K, reserved K [0.657s][debug][gc,heap] GC(1) class space used 1526K, capacity 1623K, committed 1664K, reserved K [0.664s][info ][gc,heap] GC(1) Eden regions: 0->0(3) [0.664s][info ][gc,heap] GC(1) Survivor regions: 0->0(0) [0.664s][info ][gc,heap] GC(1) Old regions: 6->3 [0.664s][info ][gc,heap] GC(1) Humongous regions: 0->0 [0.664s][debug][gc,heap] GC(1) Heap after GC invocations=2 (full 2): garbage-first heap total 10240K, used 2285K [0x0000000, 0x0000000) [0.664s][debug][gc,heap] GC(1) region size 1024K, 0 young (0K), 0 survivors (0K) [0.664s][debug][gc,heap] GC(1) Metaspace used 14186K, capacity 14498K, committed 14720K, reserved K [0.664s][debug][gc,heap] GC(1) class space used 1526K, capacity 1623K, committed 1664K, reserved K [0.664s][debug][gc,heap] GC(2) Heap before GC invocations=2 (full 2): garbage-first heap total 10240K, used 2285K [0x0000000, 0x0000000) [0.664s][debug][gc,heap] GC(2) region size 1024K, 0 young (0K), 0 survivors (0K) [0.664s][debug][gc,heap] GC(2) Metaspace used 14186K, capacity 14498K, committed 14720K, reserved K [0.664s][debug][gc,heap] GC(2) class space used 1526K, capacity 1623K, committed 1664K, reserved K [0.674s][info ][gc,heap] GC(2) Eden regions: 0->0(3) [0.674s][info ][gc,heap] GC(2) Survivor regions: 0->0(0) [0.674s][info ][gc,heap] GC(2) Old regions: 3->3 [0.674s][info ][gc,heap] GC(2) Humongous regions: 0->0 [0.674s][debug][gc,heap] GC(2) Heap after GC invocations=3 (full 3): garbage-first heap total 10240K, used 2285K [0x0000000, 0x0000000) [0.674s][debug][gc,heap] GC(2) region size 1024K, 0 young (0K), 0 survivors (0K) [0.674s][debug][gc,heap] GC(2) Metaspace used 14186K, capacity 14498K, committed 14720K, reserved K [0.674s][debug][gc,heap] GC(2) class space used 1526K, capacity 1623K, committed 1664K, reserved K 
    • 4)查看GC过程中用户线程并发时间及停顿时间,在JDK9之前使用-XX:+Print-GCApplicationConcurrentTime以及-XX:+PrintGCApplicationStoppedTime,JDK9之后使用-Xlog:safepoint:
    讯享网java -Xlog:safepoint GCTest.java [0.131s][info][safepoint] Entering safepoint region: EnableBiasedLocking [0.131s][info][safepoint] Leaving safepoint region [0.131s][info][safepoint] Total time for which application threads were stopped: 0.0000875 seconds, Stopping threads took: 0.0000249 seconds [0.495s][info][safepoint] Application time: 0. seconds [0.495s][info][safepoint] Entering safepoint region: Deoptimize [0.495s][info][safepoint] Leaving safepoint region [0.495s][info][safepoint] Total time for which application threads were stopped: 0.0001419 seconds, Stopping threads took: 0.0000055 seconds [0.643s][info][safepoint] Application time: 0. seconds [0.643s][info][safepoint] Entering safepoint region: G1CollectFull [0.649s][info][safepoint] Leaving safepoint region [0.649s][info][safepoint] Total time for which application threads were stopped: 0.0057503 seconds, Stopping threads took: 0.0000048 seconds [0.649s][info][safepoint] Application time: 0.0000766 seconds [0.649s][info][safepoint] Entering safepoint region: G1CollectFull [0.656s][info][safepoint] Leaving safepoint region [0.656s][info][safepoint] Total time for which application threads were stopped: 0.0066743 seconds, Stopping threads took: 0.0000049 seconds [0.656s][info][safepoint] Application time: 0.0000392 seconds [0.656s][info][safepoint] Entering safepoint region: G1CollectFull [0.666s][info][safepoint] Leaving safepoint region [0.666s][info][safepoint] Total time for which application threads were stopped: 0.0097514 seconds, Stopping threads took: 0.0000049 seconds [0.667s][info][safepoint] Application time: 0.0012239 seconds [0.667s][info][safepoint] Entering safepoint region: Halt 
    • 5)查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息。
      在JDK9之前使用-XX:+PrintAdaptive-SizePolicy,JDK9之后使用-Xlog:gc+ergo*=trace:
    • 6)查看熬过收集后剩余的年龄分布信息,在JDK9前使用-XX:+PrintTenuringDistribution,JDK9之后使用-Xlog:gc+age=trace:

3.8 实战:内存分配与回收策略

  • 本节验证实际是使用Serial加Serial Old客户端默认收集器组合下的内存分配和回收策略。这种配置收集器组合也许是开发人员做研发时的默认组合
    (其实现在研发时也默认使用服务端虚拟机了),但在生产环境中一般不会这样用。

3.8.1 对象优先在Eden分配

  • 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
  • HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存区域分配情况。
    在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析,不过本节实验的日志并不多,直接阅读就能看得很清楚。
  • 在代码清单3-7的testAllocation()方法中,尝试分配三个2MB大小和一个4MB大小的对象,在运行时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了Java堆大小为20MB,
    不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1,
    从输出的结果也清晰地看到"eden space 8192K、from space 1024K、to space 1024K"的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。
  • 执行的testAllocation()中分配allocation4对象的语句时会发生一次Minor GC,这次回收的结果是新生代6651KB变为148KB,而总内存占用量几乎没有减少
    (因为allocation1、2、3三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。产生这次垃圾收集的原因是为allocation4分配内存时,发现Eden已被占用了6MB,
    剩余空间不足以分配allocation4所需的4MB内存,因此发生Minor GC。垃圾收集期间虚拟机又发现已有的三个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),
    所以只好是Eden占用4MB(被allocation4占用),Survivor空闲,老年代被占用6MB(被allocation1、2、3占用)。通过GC日志可以证实这一点。
  • 代码清单 3-7 对象优先在Eden分配
 private static final int _1MB = 1024 * 1024; / * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 */ public static void testAllocation() { byte[] allocation1, allocation2, allocation3, allocation4; allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; // 出现一次Minor GC allocation4 = new byte[4 * _1MB]; } 

在这里插入图片描述

3.8.2 大对象直接进入老年代

  • 大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,本节例子中的byte[]数组就是典型的大对象。
    大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群”朝生夕灭“的”短命大对象“,我们写程序的时候应该注意避免。
    在Java虚拟机中要避免大对象的原因是,在分配内存空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,
    大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,
    这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
  • 执行代码清单3-8中的testPretenureSizeThreshold()方法后,我们看到Eden空间几乎没有被使用,而老年代的10MB空间使用了40%,也就是4MB的allocation对象直接就分配在老年代中,
    这是因为-XX:PretenureSizeThreshold被设置为3MB(就是,这个参数不能与-Xmx之类的参数一样直接写3MB),因此超过3MB的对象都会直接在老年代进行分配。
  • 代码清单 3-8 大对象直接进入老年代
讯享网 private static final int _1MB = 1024 * 1024; / * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 * -XX:PretenureSizeThreshold= */ public static void testPretenureSizeThreshold() { byte[] allocation; // 直接分配在老年代中 allocation = new byte[4 * _1MB]; } 

在这里插入图片描述

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

  • HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。
    为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第2章)。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,
    该对象会被移动到Survivor空间中,年龄就增加一岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阀值,可以通过参数-XX:MaxTenuringThreshold设置。
  • 读者可以试试分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行代码清单3-9中的testTenuringThreshold()方法,此方法中allocation1对象需要256KB内存,
    Survivor空间可以容纳。当-XX:MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存在垃圾收集以后非常干净地变成0KB。而当-XX:MaxTenuringThreshold=15时,
    第二次GC发生后,allocation1对象则还留在新生代Survivor空间,这时候新生代仍然有404KB被占用。
  • 代码清单 3-9 长期存活的对象进入老年代
 private static final int _1MB = 1024 * 1024; / * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 * -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution */ public static void testTenuringThreshold() { byte[] allocation1, allocation2, allocation3; // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置 allocation1 = new byte[4 * _1MB]; allocation2 = new byte[4 * _1MB]; allocation3 = null; allocation3 = new byte[4 * _1MB]; } 

在这里插入图片描述

3.8.4 动态对象年龄判定

  • 为了更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的综合大于Survivor空间的一半,
    年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中的要求的年龄。
  • 执行代码清单3-10中的testTenuringThreshold2()方法,并将设置-XX:MaxTenuringThreshold=15,发现运行结果中Survivor占用仍然为0%,而老年代比预期增加了6%,
    也就是说allocation1、allocation2对象都直接进入老年代,并没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年龄的,满足同年对象达到Survivor空间一半的规则。
    我们只要注释掉其中一个对象的new操作,就会发现另外一个就不会晋升到老年代了。
  • 代码清单 3-7 动态对象年龄判定
讯享网 private static final int _1MB = 1024 * 1024; / * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 * -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution */ @SuppressWarnings("unused") public static void testTenuringThreshold2() { byte[] allocation1, allocation2, allocation3, allocation4; // allocation1+allocation2大于survivor空间一半 allocation1 = new byte[_1MB / 4]; allocation2 = new byte[_1MB / 4]; allocation3 = new byte[4 * _1MB]; allocation4 = new byte[4 * _1MB]; allocation4 = null; allocation4 = new byte[4 * _1MB]; } 

在这里插入图片描述

3.8.5 空间分配担保

  • 在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,
    则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
    如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许毛线,这是就要改为进行一次Full GC。
  • 解释一下”冒险“是冒了什么风险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在MinorGC后仍然存活的情况——
    最极端的情况就是内存回收后新生代所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象至今送入老年代,这与生活中贷款担保类似。老年代要进行这样的担保,
    前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升老年代对象容量的平均大小作为经验值,
    与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
  • 取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说加入某次Minor GC存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,
    那就只好老老实实地重新发起一次Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁。
    参见代码清单3-11,请读者现以JDK 6 Update 24之前的HotSpot运行测试代码。
 private static final int _1MB = 1024 * 1024; / * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 * -XX:HandlePromotionFailure */ @SuppressWarnings("unused") public static void testHandlePromotion() { byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7; // allocation1+allocation2大于survivor空间一半 allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; allocation1 = null; allocation4 = new byte[2 * _1MB]; allocation5 = new byte[2 * _1MB]; allocation6 = new byte[2 * _1MB]; allocation4 = null; allocation5 = null; allocation6 = null; allocation7 = new byte[2 * _1MB]; } 

在这里插入图片描述

本章小结

  • 本章介绍了垃圾收集的算法、若干款HotSpot虚拟机中提供的垃圾收集器的特点以及运作原理。通过代码示例验证了Java虚拟机中自动内存分配及回收的主要规则。
  • 垃圾收集器在许多场景中都是影响系统停顿时间和吞吐能力的重要因素之一,虚拟机之所以提供多种不同的收集器以及大量的调节参数,就是因为只有根据实际应用需求、
    实现方式选择最优的收集方式才能获取最好的性能。没有固定收集器、参数组合,没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。
    因此学习虚拟机内存知识,如果要到实战调优阶段,必须了解每个具体收集器的行为、优势劣势、调节参数。

第4章 虚拟机性能监控、故障处理工具

4.1 概述

  • 给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括但不限于异常堆栈、虚拟机运行日志、
    垃圾收集器日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hrof文件)等。恰当地使用虚拟机故障处理、分析的工具可以提升我们分析数据、
    定位并解决问题的效率。

4.2 基础故障处理工具

  • 在JDK的bin目录下有各种小工具,这些主要是用于监视虚拟机运行状态和进行故障处理的工具,根据软件可用性和授权不同,可以把它们划分为三类:
    • 商业授权工具:主要是JMC(Java Mission Control)及它要使用到的JFR(Java Flight Recorder)。
    • 正式支持工具:这一类是属于被长期支持的工具。
    • 实验性工具:这一类工具在它们使用说明中被声明为"没有技术支持,并且是实验性质的"产品,日后可能会转正,也可能会在某个JDK版本中无声无息地消失。
      但事实上它们通常都非常稳定而且功能强大,也能在处理应用程序性能问题、定位故障时发挥很大作用。

4.2.1 jps:虚拟机进程状况工具

  • jps:可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,
    Local Virtual Machine Identifier)。
  • jps命令格式
讯享网jps [ options ] [ hostid ] 
jps -l 1177 org.jetbrains.idea.maven.server.RemoteMavenServer36 1481 jdk.jcmd/sun.tools.jps.Jps 1147 
  • jps还可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,参数hostid为RMI注册表中注册的主机名。

4.2.2 jstat:虚拟机统计信息监视工具

  • jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、
    垃圾收集、即时编译等运行时数据,在没有GUI图形界面、只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的常用工具。
  • jstat命令格式为:
讯享网jstat [ option vmid [interval[s|ms] [count]] ] 
  • 对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与LVMID是一致的;如果是远程虚拟机进程,那VMID的格式应当是:
[protocol:][//]lvmid[@hostname[:port]/servername] 
  • 参数interval和count代表查询间隔和次数,如果省略这2个参数,说明只查询一次。假设需要每250毫秒查询一次进程2764垃圾收集情况,一共查询20次,
    那命令应当是:
讯享网jstat -gc 2764 250 20 
  • 选项option代表用户希望查询的虚拟机信息,主要分为三类:类加载、垃圾收集、运行期编译状况。
  • jstat执行样例
jstat -gcutil 7304 S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT 0.00 44.06 39.52 70.35 93.62 88.93 103 0.644 12 0.322 - - 0.966 
  • 查询结果表明:该进程的新生代Eden(E,表示Eden)使用了39.52%的空间,2个Survivor区(S0、S1,表示Survivor0、Survivor1)分别使用了0和44.06%的空间。
    老年代(O,表示 Old)使用了70.35%的空间。程序运行以来共发生Minor GC(YGC,表示Yong GC)16次,总耗时0.644秒;发生Full GC(FGC,表示Full GC)12次,
    总耗时(FGCT,表示Full GC Time)为0.322秒;所有GC总耗时(GCT,表示 GC Time)为0.966秒。

4.2.3 jinfo:Java配置信息工具

  • jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数。jinfo的-flag选项可以查看虚拟机启动时未被显式指定的参数的系统默认值。
    jinfo还可以使用-sysprops选项把虚拟机进程的System.getProperties()的内容打印出来。
  • jinfo命令格式:
讯享网jinfo [ option ] pid 
  • 执行样例:查询CMSInitiatingOccupancyFraction参数值
jinfo -flag CMSInitiatingOccupancyFraction 7304 -XX:CMSInitiatingOccupancyFraction=-1 

4.2.4 jmap:Java内存映像工具

  • jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)。jmap还可以查询finalize执行队列、Java堆和方法区的详细信息,
    如空间使用率、当前用的是哪种收集器等。
  • jmap命令格式:
讯享网jmap [ option ] vmid 
  • 执行样例:
jmap -dump:format=b,file=idea.bin 7304 [2d5h7m] ✹ ✭ Heap dump file created 

4.2.5 jhat:虚拟机堆转储快照分析工具

  • JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。jhat内置了一个微型的HTTP/Web服务器,
    生成的堆转储快照的分析结果后,可以在浏览器查看。
  • 使用jhat分析dump文件
讯享网jhat idea.bin Reading from idea.bin... Dump file created Mon Mar 23 23:39:03 CST 2020 Snapshot read, resolving... Resolving  objects... Chasing references, expect 563 dots...... Eliminating duplicate references....... Snapshot resolved. Started HTTP server on port 7000 Server is ready. 
  • 屏幕显示"Server is ready."的提示后,用户在浏览器中输出http://localhost:7000/可以看到分析结果。

4.2.6 jstack:Java堆栈跟踪工具

  • jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。
    线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、
    请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。线程出现停顿时通过jstack来查看各个线程的调用堆栈,
    就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源。
  • jstack命令格式:
jstack [ option ] vmid 
  • 代码清单4-4 使用jstack查看线程堆栈(部分结果)
讯享网jstack -l 6275 ⏎ 2020-03-26 22:43:04 Full thread dump OpenJDK 64-Bit Server VM (25.152-b39 mixed mode): "rebel-notifications-queue-1" #57 daemon prio=5 os_prio=31 tid=0x00007ff24a70a000 nid=0x959f waiting on condition [0x00000] java.lang.Thread.State: TIMED_WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000007a59095c0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078) at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:1093) at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:809) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) at java.lang.Thread.run(Thread.java:745) Locked ownable synchronizers: - None 
  • 从JDK5起,java.lang.Thread类新增了一个getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象。
    使用这个方法可以通过简单的几行代码完成jstack的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈,
    如代码清单4-5所示。
  • 代码清单4-5 查看线程状况的JSP页面
<%@ page import="java.util.Map"%>

<html>
<head>
    <title>服务器线程信息</title>
</head>
<body>
<pre>
    <%
        for (Map.Entry<Thread, StackTraceElement[]> stackTrace : Thread.getAllStackTraces().entrySet()) {
            Thread thread = (Thread) stackTrace.getKey();
            StackTraceElement[] stack = stackTrace.getValue();
            if (thread.equals(Thread.currentThread())) {
                continue;
            }
            out.print("\n线程:"  + thread.getName() + "\n");
            for (StackTraceElement element : stack) {
                out.print("\t" + element + "\n");
            }
        }
    %>
</pre>
</body>
</html>

4.3.1 JHSDB:基于服务性代理的调试工具

  • JHSDB是一款基于服务性代理实现的进程外调试工具。服务性代理是HotSpot虚拟机虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言(含少量JNI代码)实现的API集合。
    服务性代理以HotSpot内部的数据结构为参照物进行设计,把这些C++的数据抽象出Java模型对象,相当于HotSpot的C++代码的一个镜像。
    通过服务性代理的API,可以在一个独立的Java虚拟机的进程里分析其他HotSpot虚拟机的内部数据,或者从HotSpot虚拟机进程内存中dump出来的转储快照里还原出它的运行状态细节。
    本次借助JHSDB来分析一下代码清单4-6中的代码,并通过实验来回答一个简单问题:staticObj、instanceObj、localObj这三个变量本身(而不是它们所指向的对象)存放在哪里?
  • 代码清单4-6 JHSDB测试代码
讯享网/ * staticObj、instanceObj、localObj存放在哪里? */ public class JHSDB_TestCase { 
    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 JHSDB_TestCase.Test(); test.foo(); } } 
  • 通过前面两张学习的理论知识得出,staticObj随着Test的类型信息存放在方法区,instanceObj随着Test的对象实例存放在Java堆,
    localObj则是存放在foo()方法栈帧的局部变量表中。现在通过JHSDB来实践验证这一点。
  • 首先,要确保这三个变量已经在内存中分配好,然后将程序暂停下来,一遍有空隙进行实验,这只要把断电设置在代码中加粗的打印语句上,
    然后在调试模式下运行程序即可。为了后续操作时可以加快在内存中搜索对象的速度,建议限制一下Java堆的大小。
    本例中,采用的运行参数如下:
-Xmx10m -XX:+UseSerialGC -XX:-UseCompressedOops 
  • 程序执行后通过jps查询到测试程序的进程ID,具体如下:
讯享网 jps -l 2032 jdk.jcmd/sun.tools.jps.Jps 1492 org.jetbrains.idea.maven.server.RemoteMavenServer36 2022 org.jetbrains.jps.cmdline.Launcher 2023 ch3.JHSDB_TestCase 1471 
  • 使用以下命令进入JHSDB的图形化模式,并使其附加进程2023:
jhsdb hsdb --pid 2023 
  • 命令打开的JHSDB的界面如图4-4所示。
    在这里插入图片描述
  • 图4-4 JHSDB的界面
  • 阅读代码清单4-6可知,运行至断点位置一共会创建三个ObjectHolder对象的实例,只要是对象实例必然会在Java堆中分配,从这三个对象开始着手,
    先把它们从Java堆中找出来。
  • 首先点击菜单中的Tools -> Heap Parameters,结果如图4-5所示,因为运行参数中指定了使用的是Serial收集器,图中我们看到了典型的Serial的分代内存布局,
    Heap Parameters窗口中清楚列出了新生代的Eden、S1、S2和老年代的容量(单位为字节)以及它们的虚拟内存地址的起止范围。
    在这里插入图片描述
  • 图4-5 Serial收集器的堆布局
  • 注意图中各个区域的内容地址范围,后面还要用到它们。打开Windows -> Console 窗口,
    使用scanoops命令在Java堆的新生代(从Eden起始地址到To Survivor结束地址)范围内查找ObjectHolder的实例,结果如下所示:
讯享网hsdb>scanoops 0x0000000103c00000 0x0000000103f50000 JHSDB_TestCase$ObjectHolder 0x0000000103e97bb8 ch3/JHSDB_TestCase$ObjectHolder 0x0000000103e97be0 ch3/JHSDB_TestCase$ObjectHolder 0x0000000103e97bf0 ch3/JHSDB_TestCase$ObjectHolder 
  • 果然找到了三个实例的地址,而且它们的地址都落到了Eden的范围之内,算是顺带验证了一般情况下新对象在Eden中创建的分配规则。
    再使用Tools -> Inspector功能确认一下这三个地址中存放的对象,结果如图4-6所示。
    在这里插入图片描述
  • 图4-6 查看对象实例数据
  • Inspector展示了对象头和指向对象元数据的指针,里面包括了Java类型的名字、继承关系、实现接口关系,字段信息、方法信息、运行时常量池的指针、
    内嵌的虚方法表(vtable)以及接口方法表(itable)等。
  • 接下来要根据堆中对象实例地址找出引用它们的指针,使用如下命令:
hsdb> revptrs 0x0000000103e97bb8 null Oop for java/lang/Class @ 0x0000000103e96388 
  • 找到了一个引用该对象的地方,是在一个java.lang.Class的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,
    可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticObj的实例字段,如图4-7所示。
    在这里插入图片描述
  • 图4-7 Class对象
  • 接下来继续查找第二个对象实例:
讯享网hsdb> revptrs 0x0000000103e97be0 Oop for JHSDB_TestCase$Test @ 0x0000000103e97bc8 
  • 这次找到一个类型为JHSDB_TestCase$Test的对象实例,在Inspector中该对象实例显示如图4-8所示。
    在这里插入图片描述
  • 图4-8 JHSDB_TestCase$Test对象
  • 这个结果完全符合预期,第二个ObjectHolder的指针是在Java堆中JHSDB_TestCase$Test对象的instanceObj字段上。
    但是采用相同方法查找第三个ObjectHolder实例时,JHSDB返回了一个null,表示未查找到任何结果。
hsdb> revptrs 0x0000000103e97bf0 null 
  • 看来revptrs命令并不支持查找栈上的指针引用,不过因为测试代码足够简洁,可以人工完成这件事情。
    在Java Thread窗口中main线程后点击Stack Memory按钮查看该线程的内存,如图4-9所示。
    在这里插入图片描述
  • 图4-9 main线程的栈内存
  • 这个线程只有两个方法栈帧,尽管没有查找功能,但通过肉眼观察在地址 上的值正好就是0x0000000103e97bf0,而且JHSDB在旁边已经自动生成注释,
    说明这里确实是引用了一个来自新生代的JHSDB_TestCase$ObjectHolder对象。

4.3.2 JConsole:Java监视与管理控制台

  • JConsole(Java Monitoring and Management Console)是一款基于JMX(Java Management Extensions)的可视化监视、管理工具。
    它的主要功能是通过JMX的MBean(Managed Bean)对系统进行信息收集和参数动态调整。
1.启动JConsole
  • 通过JDK/bin目录下的jconsole.exe启动JConsole后,会自动搜索出本机运行的所有虚拟机进程,而不需要自己使用jps来查询,如图4-10所示。
    双击选择其中一个程序便可进入主界面开始监控。JMX支持跨服务器的管理,也可以使用下面的“远程进程”功能来连接远程服务器,对远程虚拟机进行监控。
    在这里插入图片描述
  • 图4-10 JConsole连接页面
  • 图4-10看到有三个本地虚拟机进程。双击MonitoringTest进入JConsole主界面,如图4-11所示。
    在这里插入图片描述
  • 图4-11 JConsole主界面
2.内存监控
  • "内存"页签的作用相当于可视化的jstat命令,用于监视被收集器管理的虚拟机内存(被收集器直接管理的Java堆和被间接管理的方法区)的变化趋势。
    我们通过运行代码清单4-7中的代码来体验一下它的监视功能。运行时设置的虚拟机参数为:
讯享网-Xms100m -Xmx100m -XX:+UseSerialGC 
  • 代码清单4-7 JConsole监视代码
/ * 内存占位符对象,一个OOMObject大约占64KB */ public class MonitoringTest { 
    static class OOMObject { 
    public byte[] placeholder = new byte[64 * 1024]; } public static void fillHeap(int num) throws InterruptedException { 
    List<OOMObject> list = new ArrayList<>(); for (int i = 0; i < num; i++) { 
    // 稍作延迟,令监视器的变化更加明显 Thread.sleep(50); list.add(new OOMObject()); } System.gc(); } public static void main(String[] args) throws Exception { 
    fillHeap(1000); } } 
  • 这段代码的作用是以64KB/50ms的速度向Java堆中填充数据,一共填充1000次,使用JConsole的“内存”页签进行监视,观察曲线和柱状指示图的变化。

  • 程序运行后,在“内存”页签中可以看到内存池Eden区的运行趋势呈现折线状,如图4-12所示。监视范围扩大到整个堆后,会发现曲线是一直平滑增长的。
    从柱状图可以看到,在1000次循环执行结束,运行了System.gc后,虽然整个新生代Eden区基本被清空了,但是代表老年代的柱状图仍然保持峰值状态,
    说明被填充进堆中的数据在System.gc()方法执行之后仍然存活。
    在这里插入图片描述
  • 图4-12 Eden区内存变化状况
3.线程监控
  • 如果说JConsole的"内存"页签相当于可视化的jstat命令的话,那"线程"页签的功能就相当于可视化的jstack命令了,
    遇到线程停顿的时候可以使用这个页签的功能进行分析。前面讲解jstack命令时提到线程长时间停顿的主要原因有等待外部资源
    (数据库资源、网络资源、设备资源等)、死循环、锁等待等,代码清单4-8将分别演示这几种情况:
  • 代码清单4-8 线程等待演示代码
讯享网 / * 线程死循环演示 */ public static void createBusyThread() { 
    Thread thread = new Thread(new Runnable() { 
    @Override public void run() { 
    while (true) { 
    ; } } }, "testBusyThread"); thread.start(); } / * 线程锁等待演示 */ public static void createLockThread(final Object lock) { 
    Thread thread = new Thread(new Runnable() { 
    @Override public void run() { 
    synchronized (lock) { 
    try { 
    lock.wait(); } catch (InterruptedException e) { 
    e.printStackTrace(); } } } }, "testLockThread"); thread.start(); } public static void main(String[] args) throws IOException { 
    BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); br.readLine(); createBusyThread(); br.readLine(); Object obj = new Object(); createLockThread(obj); } 
  • 程序运行后,首先在"线程"页签中选择main线程,如图4-13所示。堆栈追踪显示BufferedReader的readBytes()方法正在等待System.in的键盘输入,
    这时候线程为Runnable状态,Runnable状态的线程仍会被分配运行时间,但readBytes()方法检查到流没有更新就会立刻归还执行令牌给操作系统,
    这种等待只消耗很小的处理器资源。
    在这里插入图片描述
  • 图4-13 main线程
  • 接着监控testBusyThread线程,如图4-14所示。testBusyThread线程一直在执行空循环,从堆栈追踪中看到一直在MonitoringTest.java代码的41行停留,
    41行的代码为while(true)。这时候线程为Runnable状态,而且没有归还线程执行令牌的动作,所以会在空循环耗尽操作系统分配给它的执行时间,
    直到线程切换为止。
    在这里插入图片描述
  • 图4-14 testBysyThread线程
  • 图4-15显示testLockThread线程在等待lock对象的notify()或notifyAll()方法的出现,线程这时候处于WAITING状态,
    在重新唤醒前不会被分配执行时间。
    在这里插入图片描述
  • 图4-14 testLockThread线程
  • testLockThread线程正处于正常的活锁等待中,只要lock对象的notify()或notifyAll()方法被调用,这个线程便能激活继续执行。
    代码清单4-9演示了一个无法再被激活的死锁等待。
 / * 线程死锁等待演示 */ static class SynAddRunable implements Runnable { 
    int a, b; public SynAddRunable(int a, int b) { 
    this.a = a; this.b = b; } @Override public void run() { 
    synchronized (Integer.valueOf(a)) { 
    synchronized (Integer.valueOf(b)) { 
    System.out.println(a + b); } } } } public static void main(String[] args) { 
    for (int i = 0; i < 100; i++) { 
    new Thread(new SynAddRunable(1, 2)).start(); new Thread(new SynAddRunable(2, 1)).start(); } } 
  • 这段代码运行后会遇到线程死锁。造成死锁的根本原因是Integer.valueOf()方法出于减少对象创建次数和节省内存的考虑,
    会对数值为-128 ~ 127之间的Integer对象进行缓存,如果valueOf()方法传入的参数在这个方位之内,就直接返回缓存中的对象。
    也就是说代码中尽管调用了200次Integer.valueOf()方法,但一共只返回了两个不同的Integer对选哪个。
    假如某个线程的两个synchronized快之间发生了一次线程切换,那就会出现线程A在等待线程B持有的Integer.valueOf(),
    线程B又在等待被线程A持有的Integer.valueOf(),结果大家都跑不下去的情况。
  • 出现线程死锁之后,点击JConsole线程面板的“检测死锁”按钮,将出现一个新的“死锁”页签,如图4-16所示。
    在这里插入图片描述
  • 图4-16 线程死锁

4.3.3 VisualVM:多合-故障处理工具

  • VisualVM(All-in-One Java Troubleshooting Tool)是功能最强大的运行监视和故障处理程序之一。
1.VisualVM兼容范围与插件安装
  • VisualVM基于NetBeans平台开发工具,所以一开始它就具备了通过插件扩展功能的能力,有了插件扩展支持,VisualVM可以做到:
    • 显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。
    • 监视应用程序的处理器、垃圾收集、堆、方法区以及线程的信息(jstat、jstack)。
    • dump以及分析堆转储快照(jmap、jbat)。
    • 方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法。
    • 离线程序快照:收集程序的运行时配置、线程dump、内存dump等信息建立一个快照,可以将快照发送开发者处进行Bug反馈。
    • 其他插件带来的无限可能性。
  • VisualVM的插件可以手工进行安装,在网站上下载nbm包后,点击"工具->插件->已下载"菜单,然后再弹出对话框中指定nbm包路径便可完成安装。
    VisualVM的自动安装已可找到大多数所需的插件,在有网络连接的环境下,点击“工具->插件菜单”,弹出如图4-17所示的插件页签,
    在页签的“可用插件”及“已安装”中列举了当前版本VisualVM可以使用的全部插件,选中插件后在右边窗口会显示这个插件的基本信息,
    如开发者、版本、功能描述等。
    在这里插入图片描述
  • 图4-17 线程死锁
  • 读者可根据自己的工作需要和兴趣选择合适的插件,然后点击“安装”按钮,弹出如图4-18所示的下载进度窗口,
    跟着提示操作即可完成安装。
    在这里插入图片描述
  • 图4-18 VisualVM插件安装过程
  • 选择一个需要监视的程序就可以进入程序的主界面了,如图4-19所示。由于VisualVM的版本以及选择安装插件数量的不同,
    页签可能有所差别。
    在这里插入图片描述
  • 图4-19 VisualVM主界面
2.生成、浏览堆转储快照
  • 在VisualVM中生成堆转储快照文件有两种方式,可以执行下列任一操作:
    • 在“应用程序”窗口中右键单击应用程序节点,然后选择“dump”。
    • 在“应用程序”窗口中双击应用程序节点一打开应用程序标签,然后在”监视“标签中单击”堆Dump“。
  • 生成堆转储快照文件之后,应用程序页签会在该堆的应用程序下增加一个以[heap-dump]开头的子节点,并且在主页签中打开该转储快照,
    如图4-20所示。如果需要把堆转储快照保存或发送出去,就应在heapdump节点上右键选择”另存为“菜单,否则当VisualVM关闭时,
    生成的堆转储快照文件会被当做临时文件自动清理掉。要打开一个由已经存在的堆转储快照文件,通过文件菜单的”装入“功能,选择硬盘上的文件即可。
    在这里插入图片描述
  • 图4-20 浏览dump文件
  • 堆页签中的”摘要“面板可以看到应用程序dump时的运行参数、System.getProperties()的内容、线程堆栈等信息:”类“面板则是以类为统计口径统计类的实例数量、
    容量信息;”实例“面板不能直接使用,因为VisualVM在此时还无法确定用户想查看哪个类的实例,所以需要通过”类“面板进入,在”类“中选择一个需要查看的类,
    然后双击即可在”实例“里面看到此类的其中500个实例的具体属性信息;“OOL控制台”面板则是运行OOL查询语句的,同jhat中介绍的OOL功能一样。
3.分析程序性能
  • 在Profiler页签中,VisualVM提供了程序运行期间方法级的处理器执行时间分析以及内存分析。
  • 要开始性能分析,先选择“CPU”和“内存”按钮中的一个,然后切换到应用程序中对程序进行操作,VisualVM会记录这段时间中应用程序执行过的所有方法。
    如果是进行处理器执行时间分析,将会统计每个方法的执行次数、执行耗时;如果是内存分析,则会统计每个方法关联的对象以及这些对象所占的空间。
    等要分析的操作执行结束后,点击“停止”按钮结束监控过程、如图4-21所示。
    在这里插入图片描述
  • 图4-21 对应用程序进行CPU执行时间分析
4.BTrace动态日志跟踪
  • BTrace是一个很神奇的VisualVM插件,它本身也是一个可运行的独立程序。BTrace的作用是在不中断目标程序运行的前提下,
    通过HotSpot虚拟机的Instrument功能动态加入原本不存在的调试代码。这项功能对实际中的程序很有意义:如当程序出现问题时,
    排查错误的一些必要信息时(譬如方法参数、返回值等),在开发时并没有打印日志之中以至于不得不停掉服务时,都可以通过调试增量来加入日志代码一解决问题。
  • 在VisualVM中安装了BTrace插件后,在应用程序面板中右击要调试的程序,会出现“Trace Application…”菜单,点击将进入BTrace面板。
    这个面板看起来就像一个简单的Java程序开发环境,里面甚至已经有了一小段Javad代码,如图4-22所示。
    在这里插入图片描述
  • 图4-22 BTrace动态追踪
  • 现有一段简单的Java代码来演示BTrace的功能:产生两个1000以内的随机整数,输出这两个数字相加的结果,如代码清单4-10所示。
  • 代码清单4-10 BTrace跟踪演示
讯享网public class BTraceTest { 
    public int add(int a, int b) { 
    return a + b; } public static void main(String[] args) throws IOException { 
    BTraceTest test = new BTraceTest(); BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); for (int i = 0; i < 10; i++) { 
    reader.readLine(); int a = (int) Math.round(Math.random() * 1000); int b = (int) Math.round(Math.random() * 1000); System.out.println(test.add(a, b)); } } } 
  • 现在想要知道程序中生成的两个随机数是什么,但程序并没有在执行过程中输出这一点。此时,在VisualVM中打开该程序的监视,
    在BTrace页签填充TracingScript的内容,输入调试代码,如图清单4-11所示,即可在不中断程序运行的情况下做到这一点。
  • 代码清单 4-11 BTrace调试代码
/* BTrace Script Template */ import com.sun.btrace.annotations.*; import static com.sun.btrace.BTraceUtils.*; @BTrace public class TracingScript { 
    @OnMethod( clazz="ch3.BTraceTest", method="add", location=@Location(Kind.RETURN) ) public static void func(@Self ch3.BTraceTest instance, int a, int b, @Return int result) { 
    println("调用堆栈:"); jstack(); println(strcat("方法参数A:", str(a))); println(strcat("方法参数B:", str(b))); println(strcat("方法参数结果:", str(result))); } } 
  • 点击Start按钮后稍等片刻,编译完成后,Output面板中会出现“BTrace code successfully deployed”的字样。
    当程序运行时将会在Output面板输出如图4-23所示的调试信息。
    在这里插入图片描述
  • 图4-23 BTrace跟踪结果
  • BTrace的用途很广发,打印调用堆栈、参数、返回值只是它最基础的使用形式,在它的网站上有使用BTrace进行性能监视、
    定位连接泄漏、解决多线程竞争问题等的使用案例。
  • BTrace能够实现动态修改程序行为,是因为它是基于Java虚拟机的Instrument开发的。Instrument是Java虚拟机工具接口的重要组件,
    提供了一套代理(Agent)机制,使得第三方工具程序可以以代理的方式访问和修改Java虚拟机的内部的数据。
    阿里巴巴开源的诊断工具Arthas也通过Instrument实现了与BTrace类似的功能。

4.3.4 Java Mission Control:可持续在线的监控工具

  • 持续收集的JFR(Java Flight Recorder)是一套内建在HotSpot虚拟机里面的监控和基于事件的信息搜集框架,与其他的监控工具(如JProfiling)相比,
    Oracle特别强调它”可持续在线“的特性。JFR在JFR在生产环境中对吞吐量一般不会高于1%,而且JFR监控过程的开始、停止都是完全可动态的,
    即不需要重启应用。JFR的监控对应用也是完全透明的,即不需要对应用程序的源码做任何修改,或者基于特定的代理来运行。
  • JMC与虚拟机之间同样采取JMX协议进行通信,JMC一方面作为JMX控制台,显示来自虚拟机MBean提供的数据;另一方面作为JFR的分析工具,
    展示来自JFR的数据。启动后JMC的主界面如图4-24所示。
    在这里插入图片描述
  • 图4-24 JMC主界面
  • 在左侧的”JVM浏览器“面板中自动显示了通过JDP协议(Java Discovery Protocol)找到的本机正在运行的HotSpot虚拟机进程,
    如果需要监控其他服务器上的虚拟机,可在”文件->连接”菜单中创建远程连接,如图4-25所示。
    在这里插入图片描述
  • 图4-25 JMC建立连接界面
  • 这里要填写信息应该在被监控虚拟机进程启动的时候以虚拟机参数的形式指定,以下是一份被监控端的启动参数样例:
讯享网-Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Djava.rmi.server.hostname=192.168.31.4 -XX:+UnlockCommercialFeatures -XX:+FlightRecorder 
  • 本地虚拟机与远程虚拟机进程的差别只限于创建连接这个步骤,连接成功创建以后的操作就是完全一样的了。把”JVM浏览器”面板中的进程展开后,
    可以看到每个进程的数据都有MBean和JFR两个数据源。
  • 双击“飞行记录器”,将会出现“启动飞行记录”窗口,如图4-26所示。
    -在这里插入图片描述
  • 图4-26 启用飞行记录仪
  • 点击“完成”按钮后马上就会开始记录,记录时间结束以后会生成飞行记录报告,如图4-27所示。
    在这里插入图片描述
  • 图4-27 飞行记录仪报告
  • 飞行记录报告包含以下几类信息:
    • 一般信息:关于虚拟机、操作系统和记录的一些信息。
    • 内存:关于内存管理和垃圾收集的信息。
    • 代码:关于方法、异常错误、编译和类加载的信息。
    • 线程:关于应用程序中线程和锁的信息。
    • I/O:关于文件和套接字输入、输出的信息。
    • 系统:关于正在运行Java虚拟机的系统、进程和环境变量的信息。
    • 事件:关于记录中的事件类型的信息,可以根据线程或堆栈跟踪,按照日志或图形的格式查看。
  • JFR的基本工作逻辑是开启一系列的录制动作,当某个事件发生时,这个事件的所有上下文数据将会以循环日志的形式被保存至内存或者指定的某个文件当中,
    循环日志相当于数据流被保留在一个环形缓存中,所以只有最近发生的事件的数据才是可用的。JMC从虚拟机内存或者文件中读取并展示这些事件数据,
    并通过这些数据进行性能分析。

4.4 HotSpot虚拟机插件及工具

  • 在HotSpot虚拟机的研发过程中,开发团队曾经编写过不少虚拟机的插件和辅助工具。它们存放在HotSpot源码hotspot/src/share/tools目录下,包括:
    • Ideal Graph Visualizer:用于可视化展示C2即时编译器是如何将字节码转化为理想图,然后转化为机器码的。
    • Client Compiler Visualizer:用户查看C1即时编译器生成高级中间表示(HIR),转换成低级中间表示(LIR)和物理寄存器分配的过程。
    • MakeDeps:帮助处理HotSpot的编译依赖的工具。
    • Project Creator:帮忙生成Visual Studio的project文件的工具。
    • LogCompilation:将-XX:+LogCompilation输出的日志整理成更容易阅读的格式的工具。
    • HSDIS:即时编译器的反汇编组件。

HSDIS:JIT生成代码反汇编

  • HSDIS是一个被官方推荐的HotSpot虚拟机即时编译代码的反汇编插件,它包含在一个HotSpot虚拟机的源码当中。
  • HSDIS插件的作用是让HotSpot的-XX:+PrintAssembly指令调用它来把即时编译器动态生成的本地代码还原为汇编代码输出,
    同事还会自动产生大量非常有价值的注释,这样我们就可以通过输出的汇编代码来从最本质的角度分析问题。笔者以代码清单4-12中的测试代码为例简单演示一下如何使用这个插件。
  • 代码清单4-12 测试代码
public class Bar { 
    int a = 1; static int b = 2; public int sum(int c) { 
    return a + b + c; } public static void main(String[] args) { 
    new Bar().sum(3); } } 
  • 编译这段代码,并使用以下命令执行。如果使用的是Product版的HotSpot,则还需要加入一个-XX:+UnlockDiagnosticVMOptions参数才可以工作。
讯享网 java -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline, *Bar.sum -XX:CompileCommand=compileonly,*Bar.sum test.Bar 
  • 其中,参数-Xcomp是让虚拟机以编译模式执行代码,这样不需要执行足够次数来预热就能触发即使编译。
    两个-XX:CompileCommand的意思是让编译器不要内联sum()并且只编译sum(),-XX:+PrintAssembly就是输出反汇编内容。
  • JITWatch是HSIDS经常搭配使用的可视化的编译日志分析工具,为便于在JITWatch中读取,
    读者可使用以下参数把日志输出到logfile文件:
 -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:LogFile=/tmp/logfile.log -XX:+PrintAssembly -XX:+TraceClassLoading 
  • 在JITWatch中加载日志后,就可以看到执行期间使用过的各种类型和对应调用过的方法了,界面如图4-28所示。
    在这里插入图片描述
  • 图4-28 JITWatch主界面
  • 选择想要查看的类和方法,即可查看对应的Java源代码、字节码和即时编译器生成的汇编代码,如图4-29所示。
    在这里插入图片描述
  • 图4-29 查看方法代码

第5章 调优案例分析与实战

5.2 案例分析

5.2.1 大内存硬件上的程序部署策略

  • 一个很久前的案例,但今天仍然具有代表性。一个15万PV/日左右的在线文档类型网站最近更新了硬件系统,
    服务器的硬件为四路志强处理器、16GB物理内存,操作系统为64位CentOS 5.4,Resin作为Web服务器。
    整个服务器暂时没有部署别的应用,所有硬件资源都可以提供给这访问量并不算太大的文档网站使用。
    软件版本选用的是64位的JDK5,管理员启用了一个虚拟机实例,使用-Xmx和-Xms参数将Java堆大小固定在12GB。
    使用一段时间后发现服务器的运行效果十分不理想,网站经常不定期出现长时间失去响应。
  • 监控服务器运行状况后发现网站失去响应是由垃圾收集停顿所导致的,在该系统软硬件条件下,HotSpot虚拟机是以服务端模式运行,
    默认使用的是吞吐量优先收集器,回收12GB的Java堆,一次Full GC的停顿时间就高达14秒。由于程序设计的原因,
    访问文档时会把文档从磁盘提取到内存中,导致内存中出现很多文档序列化产生的大对象,这些大对象大多数在分配时就直接进入了老年代,
    没有在Minor GC中被清理掉。这种情况下即使有12GB的堆,内存也会很快被消耗殆尽,由此导致每隔几分钟出现十几秒的停顿就,
    令网站开发、管理员都对使用Java技术开发网站感到很失望。
  • 分析此案例的情况,程序代码问题这里不延伸讨论,程序部署上的主要问题显然是过大的内存进行回收时带来的长时间的停顿。
    经调查,更早之前的硬件使用的是32位操作系统,给HotSpot虚拟机只分配了1.5G的堆内存,当时用户确实感觉使用网站比较缓慢,
    但还不至于发生长达十几秒的明显停顿,后来将硬件升级到64位系统、16GB内存希望能提升程序效能,却反而出现了停顿问题,
    尝试过将Java堆分配的内存重新缩小到1.5GB或者2GB,这样的确可以避免长时间停顿,但是在硬件上的投资就显得非常浪费。
  • 目前单体应用在较大内存的硬件上主要的部署方式有两种:
    • 1)通过一个单独的Java虚拟机实例来管理大量的Java堆内存。
    • 2)同时使用若干个Java虚拟机,建立逻辑集群利用硬件资源。
  • 此案例中的管理员采用了第一种部署方式。对于用户交互性强、对停顿时间敏感、内容又较大的系统,
    并不是一定要使用Shenandoah、ZGC这些明确以控制延迟为目标的垃圾收集器才能解决问题,
    使用Parallel Scavenge/Old收集器,并且给Java虚拟机分配较大的堆内存也是有很多运行成功的案例的,

第3部分-虚拟机执行子系统

第7章 虚拟机类加载机制

7.1 概述

  • Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

7.2 类加载的时机

  • 一个类型从被加载到虚拟机内存中开始,到卸载初内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,
    其中验证、准备、解析三个部分统称为连接。这七个阶段的发生顺序如图7-1所示。
    在这里插入图片描述
  • 图7-1 类的生命周期
小讯
上一篇 2025-04-11 07:56
下一篇 2025-02-17 20:52

相关推荐

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请联系我们,一经查实,本站将立刻删除。
如需转载请保留出处:https://51itzy.com/kjqy/25964.html