作者:周志明
整理者GitHub:https://github.com/starjuly/UnderstandingTheJVM
第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指令,虚拟机的步骤:
- 检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先把这个类加载进内存;
- 类加载检查通过后,虚拟机将为新对象分配内存,类加载完就可以确定存储这个对象所需的内存大小;
- 将分配到的内存空间初始化为零值;
- 设置对象头(Object Header)中的数据,包括这个对象是哪个类的实例、如何才能找到类的元数据、对象的哈希码(实际在调用Object::hashCode()方法才计算)、对象的GC分代年龄等;
- 此时从虚拟机的角度看,对象已经产生,但从 Java 程序的角度看,构造函数还没有执行。执行完初始化函数,一个真正的对象才算完全构造出来。
在第二步中,为对象分配内存,就是在内存划分一块确定大小的空闲内存,但存在两个问题:
-
如何划分空闲内存和已被使用的内存?
- 假设Java堆中内存是绝对规整的,空闲内存和被使用内存被分到两边,中间放置指针作为分界点的指示器,那分配内存就是把指针向空闲内存一定一段,这种方式成为“指针碰撞(Bump The Pointer)”。
- 但如果Java堆内存不是规整的,那就没有办法简单地进行指针碰撞了,虚拟机需要维护一个列表,记录哪些内存块可以使用,在分配内存的时候,找到一块足够打的内存划分给对象实例,并更新列表上的记录,这种方式被称为“空闲列表(Free List)”。
- 事实上,这由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Seria、parNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存。
-
如何处理多线程下,内存分配问题?
- 对分配内存空间的动作进行同步处理,实际上虚拟机采用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,
除此以外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是他们因为互相引用着对方,导致他们的引用计数都不为领,引用计数算法也就无法回收它们。
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
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;
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
运行结果:
[GC (System.gc()) [PSYoungGen: 8034K->624K(76288K)] 8034K->632K(251392K), 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(175104K)] 632K->394K(251392K), [Metaspace: 3161K->3161K(1056768K)], 0.0045513 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 76288K, used 1966K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
eden space 65536K, 3% used [0x000000076ab00000,0x000000076aceb9e0,0x000000076eb00000)
from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
to space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
ParOldGen total 175104K, used 394K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
object space 175104K, 0% used [0x00000006c0000000,0x00000006c00629d0,0x00000006cab00000)
Metaspace used 3173K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 349K, capacity 388K, committed 512K, reserved 1048576K
从运行结果可以清楚看到内存回收日志中包含"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()被执行,但是它仍然可以存活。
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();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("No, I am dead :(");
}
SAVE_HOOK = null;
System.gc();
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: 0x0000000700000000, 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(1062912K)
[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(1062912K)
[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(1062912K)
[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 [0x0000000700000000, 0x0000000800000000)
[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 1062912K
[0.619s][info ][gc,heap,exit ] class space used 1528K, capacity 1655K, committed 1664K, reserved 1048576K
- 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 8388608 Initial heap 268435456 Maximum heap 4294967296
[0.652s][debug][gc,heap] GC(0) Heap before GC invocations=0 (full 0): garbage-first heap total 262144K, used 14336K [0x0000000700000000, 0x0000000800000000)
[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 1062912K
[0.652s][debug][gc,heap] GC(0) class space used 1526K, capacity 1623K, committed 1664K, reserved 1048576K
[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 [0x0000000700000000, 0x0000000800000000)
[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 1062912K
[0.657s][debug][gc,heap] GC(0) class space used 1526K, capacity 1623K, committed 1664K, reserved 1048576K
[0.657s][debug][gc,heap] GC(1) Heap before GC invocations=1 (full 1): garbage-first heap total 20480K, used 2285K [0x0000000700000000, 0x0000000800000000)
[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 1062912K
[0.657s][debug][gc,heap] GC(1) class space used 1526K, capacity 1623K, committed 1664K, reserved 1048576K
[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 [0x0000000700000000, 0x0000000800000000)
[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 1062912K
[0.664s][debug][gc,heap] GC(1) class space used 1526K, capacity 1623K, committed 1664K, reserved 1048576K
[0.664s][debug][gc,heap] GC(2) Heap before GC invocations=2 (full 2): garbage-first heap total 10240K, used 2285K [0x0000000700000000, 0x0000000800000000)
[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 1062912K
[0.664s][debug][gc,heap] GC(2) class space used 1526K, capacity 1623K, committed 1664K, reserved 1048576K
[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 [0x0000000700000000, 0x0000000800000000)
[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 1062912K
[0.674s][debug][gc,heap] GC(2) class space used 1526K, capacity 1623K, committed 1664K, reserved 1048576K
- 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.3610630 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.1483016 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(就是3145728,这个参数不能与-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=3145728
*/
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执行样例
jps -l
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 2817601 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 [0x0000700009845000]
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"%>
服务器线程信息
<%
for (Map.Entry 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");
}
}
%>
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测试代码
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
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
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调试代码
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
第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 类的生命周期
-
对于初始化阶段,《Java虚拟机规范》严格规定了有且只有六种情况必须对类进行“初始化”:
- 1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
- 使用new关键字进行实例化对象的时候。
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
- 调用一个类型的静态方法的时候。
- 2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个类。
- 5)当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 6)当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
-
以上六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会出发初始化,称为被动引用。以下三点说明何为被动引用。
- 1)通过子类引用父类的静态字段,不会导致子类初始化。
- 2)通过数组定义来引用类,不会触发类的初始化。
- 3)常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量类的初始化。
7.3 类的加载过程
7.3.1 加载
- 在加载阶段,Java虚拟机需要完成以下三件事情:
- 1)通过一个类的全限定名来获取定义此类的二进制字节流。
- 2)讲这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
- 对于数组类而言,情况就有所不同,数组类本身不通过类的加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载仍然有很密切的关系,
因为数组类的元素类型最终还是要靠类加载来完成加载,一个数组类(下面简称为C)创建的过程遵循以下规则:
- 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上。
- 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联。
- 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类的接口访问到。
- 加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实行自定义。
类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
7.3.2 验证
- 验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,
保证这些信息被当作代码运行不会危害虚拟机自身的安全。
- 验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
1.文件格式验证
- 第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
- 是否以魔数0xCAFEBABE开头。
- 主、次版号是否在当前Java虚拟机接受范围之内。
- 常量池的常量中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型常量中是否不符合UTF-8编码的数据。
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
- …
- 该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。
这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,
所以后面的三个验证阶段全部都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
2.元数据验证
- 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
- …
- 第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息。
3.字节码验证
- 第三阶段主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。该阶段对类的方法体(Class文件中的Code属性)进行分析,
保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于"在操作栈放置了一个int类型的数据,
使用时却按long类型来加载入本地变量表中"这样的情况。
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,
甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
- …
4.符号引用验证
- 最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段————解析阶段中发生。
符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,
该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。
- …
7.3.3 准备
- 准备阶段是正式为类中的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
- 关于准备阶段,有两个概念需要着重强调,首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,
实例变量将会在对象实例化的时候随着对象一起分配在Java堆中。其次是这里所说的初始值"通常情况"下是数据类型的零值,假设一个类变量定义为:
public static int value = 123;
- 那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序编译后,
存放于类构造器()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。
- 某些"特殊情况"下初始值不是零值:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,
假设上面类变量value的定义修改为:
public static final int value = 123;
- 编译时Javac将会为value生成ConstatntValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
7.3.4 解析
- 解析阶段是Java虚拟机讲常量池内的符号引用替换为直接引用的过程。直接引用与符号引用有什么关联关系?
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到内存及内存当中的内容。各种虚拟机实现的内存布局可以各不相同,
但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
- 直接引用:直接引用是可以直接指向目标的指针、针对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,
同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
- 对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,
譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。无论是否真正执行了多次解析动作,
Java虚拟机都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;
同样地,如果第一次解析失败了,其他指令对这个符号的解析也应该收到同样地异常,哪怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
7.3.5 初始化
- 直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
- 初始化阶段就是执行类构造器()方法的过程。()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物,
但我们非常有必要了解这个方法具体是如何产生的,以及其可能影响程序运行行为的细节。
- ()方法是由编译器自动收集类中的所有变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,
编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,
在前面的静态语句块可以赋值,但是不能访问,如代码清单7-5所示。
- 代码清单7-5 非法前向引用变量
public class Test {
static {
i = 0;
System.out.println(i);
}
static int i = 1;
}
- ()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显式地调用父类构造器,
Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行完毕。
因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。
- 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于类的变量赋值操作,如代码清单7-6中,
字段B的值将会是2而不是1。
- 代码清单7-6 ()方法执行顺序
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
- ()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。
- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。但接口与类不同的是,
执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。
此外,接口的实现类在初始化时也一样不会执行接口的()方法。
- Java虚拟机必需保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,
那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。
如果在一个类中的()方法中有耗时较长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
代码清单7-7演示了这种场景。
- 代码清单7-7 字段解析
public class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (true) {
}
}
}
}
class DeadLoopClassTest {
public static void main(String[] args) {
Runnable script = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread() + "start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + "run over");
}
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}
- 运行结果如下,一条线程在死循环以模拟长时间操作,另外一条线程在阻塞等待:
Thread[Thread-1,5,main]start
Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]init DeadLoopClass
7.4 类加载器
- Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名类获取描述该类的二进制字节流"这个动作放到Java虚拟机外部去实现,
以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称作为"类加载器"(Class Loader)。
7.4.1 类与类加载器
- 对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
这句话可以表达得更通俗一些:比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,
被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
- 这里所指的"相等",包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,
也包括了使用instanceof关键字做对象所属关系判定等各种情况。代码清单7-8中演示了不同的类加载器对instanceof关键字运算的结果的影响。
- 代码清单7-8 不同的类加载器对instanceof关键字运算的结果的影响
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("org.fenixsoft.classloading.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
}
}
class org.fenixsoft.classloading.ClassLoaderTest
false
- 两行结果输出中,从第一行可以看到这个对象确实是类org.fenixsoft.classloading.ClassLoaderTest实例化出来的,
但在第二行的输出中却发现这个对象与类org.fenixsoft.classloading.ClassLoaderTest所属类型检查的时候返回了false。
这是因为Java虚拟机中同时存在了两个ClassLoaderTest类,一个是由虚拟机的应用程序类加载器所加载的,另外一个是由我们自定义的类加载器加载的,
虽然它们都来自同一个Class文件,但在Java虚拟机中仍然是两个相互独立的类,做对象所属类型检查时的结果自然为false。
7.4.2 双亲委派模型
-
本节内容将针对JDK8及之前版本的Java来介绍什么事三层类加载器,以及什么是双亲委派模型。对于这个时期的Java应用,
绝大多数Java程序都会使用到以下3个系统提供的类加载器进行加载。
- 启动类加载器:这个类加载器负责加载存放在\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,
而且是Java虚拟机能够识别的类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,
如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。
- 扩展类加载器:这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载\lib\ext目录中,
或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据"扩展类加载器"这个名称,就可以推断这是一种Java系统类库的扩展机制。
由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
- 应用程序类加载器:这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,
所以有些场合中也称它为"系统类加载器"。它负责加载用户路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。
如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
-
图7-2 类加载器双亲委派模型
-
图7-2中展示的各种类加载器之间的层次关系被称为类加载器的"双亲委派模型"。双亲委派模型要求除了顶层的启动类加载器外,
其余的类加载器都应该有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
-
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,
每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,
子加载器才会尝试自己去完成加载。
-
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载进行加载,
因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,
如果用户自己也编写了一个名为java.lang.Object的类,并且放在程序中的ClassPath中,那系统中就会出现多个不同的Object类,
Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。
-
双亲委派模型对于保证Java程序中的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委派的代码只有短短十余行,
全部集中在java.lang.ClassLoader的loadClass()方法之中,如代码清单7-10所示。
protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
- 这段代码逻辑:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。
加入父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用自己的findClass()方法尝试进行加载。
7.4.3 破坏双亲委派机制
- 在Java模块化出现之前,双亲委派模型主要出现过3次较大规模"被破坏"的情况。
- 第一次"被破坏"是在双亲委派模型出现之前——即JDK1.2面世以前的"远古"时代。类加载器的概念和抽象来java.lang.ClassLoader则在Java的第一个版本中就已经存在,
面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有的代码,
无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),
并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。
- 第二次"被破坏"是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),
基础类型之所以被称为"基础"是因为它们总是作为被用户代码继承、调用的API存在,如果有基础类型又要调回用户的代码,那该怎么办呢?
- 这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载。
但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供接口(Service Provider Interface, SPI)的代码,
但启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?
- 为了解这个困境,Java的设计团队引入一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。
这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,
如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
- 有了线程上下文类加载器,程序可以做一些"舞弊"的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,
这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,
已经违背了双亲委派模型的一般性原则。
- 第三次"被破坏"是由于用户对程序动态性的追求而导致的,这里所说的"动态性"指的是一些非常"热门"的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。
- OSGi实现模块化热部署的关键是它自定义的类加载机制的实现,每一个程序模块都由自己的类加载器,当需要更换一个Bundle时,
就把Bundle连同类加载器一个换掉以实现代码的热替换。在OSGi环境下,类加载器不再以双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。
7.5 Java模块化系统
- JDK9的模块不仅仅像之前的JAR包那样知识简单地充当代码的容器,除了代码外,Java的模块定义还包括以下内容:
- 依赖其他模块的列表。
- 导出的包列表,即其他模块可以使用的列表。
- 开放的包列表,即其他模块可反射访问的列表。
- 使用的服务列表。
- 提供服务的实现列表。
7.5.1 模块的兼容性
- 为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK9提出了与"类路径"(ClassPath)相对应的"模块路径"(ModulePath)的概念。
简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。只要是放在类路径上的JAR文件,无论其中是否包含模块化信息,
它都会被当作传统的JAR包来对待;相应地,只要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文件,
它也仍然会被当作一个模块来对待。
7.5.2 模块下的类加载器
-
为了保证兼容性,JDK9并没有从根本上动摇三层类加载器架构以及双亲委派模型。但为了模块化系统的顺利施行,
模块化下的类加载器仍然发生了一些应该被注意到的变动,主要包括以下几个方面。
-
扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。
-
平台类加载器和应用程序类加载器不再派生自java.net.URLClassLoader。
-
JDK9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,
在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,
也许这可以算是对双亲委派的第四次破坏。在JDK9以后的三层类加载器的架构如图7-7所示,可以对照图7-2进行比较。
-
图7-7 JDK9后的类加载器委派关系
-
第8章 虚拟机字节码执行引擎
-
代码的编译结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
8.1 概述
- 执行引擎是Java虚拟机核心的组成部分之一。"虚拟机"是一个相对于"物理机"的概念,这两种机器都由代码执行能力,
其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,
因此可以不受物理条件制约地定制指令集和执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
- 所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果,
本章将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
-
Java虚拟机以方法作为最基本的执行单元,"栈帧"则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,
它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
-
每一个栈帧都包含了局部变量表、操作数栈、动态连接、方法返回地址和一些额外附加信息。在编译Java程序源码的时候,
栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。换言之,一个栈帧需要分配多少内存,
并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
-
一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。
而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈顶才是生效的,其被称为"当前栈帧",
与这个栈帧所关联的方法被称为"当前方法"。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图8-1所示。
-
图8-1 栈帧的概念结构
第5部分-高效并发
第12章 Java内存模型与线程
12.2 硬件的效率与一致性
- 基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入一个新的问题:
缓存一致性(Cache Coherence)。在多路处理系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),
这种系统称为共享内存多核系统(Shared Memory Multiprocessors System),如图12-1所示。当多个处理器的运算任务都涉及同一块主内存区域时,
将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存该以谁的缓存数据为准呢?为了解决一致性的问题,
需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI,MESI(Illinois Protocol)、MOSI、
Synapse、Firefly及Dragon Protocol等。从本章开始,我们将会频繁见到"内存模型"一词,它可以理解为在特定的操作协议下,
对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,
并且与这里介绍的内存访问操作及硬件的缓存访问操作具有高度的可类比性。
图12-1 处理器、高速缓存、主内存间的交互关系
- 处理增加高速缓存之外,为了使处理器内部的运算单元尽量被充分利用,处理器可能会输入代码进行乱序执行(Out-Of-Order Execution)优化,
处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,
因此如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,
Java虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化。
12.3 Java内存模型
12.3.1 主内存与工作内存
- Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中去除变量值这样的底层细节。
为了获取更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行这类优化措施。
- Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory,可与前面讲的高速缓存类比),
线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,
而不能直接读写内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,
线程、主内存、工作内存三者的交互关系如图12-2所示,注意与图12-1进行对比。
图12-2 线程、主内存、工作内存三者的交互关系
12.3.2 内存间交互操作线程状态转换关系
- 关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,
Java内存模型中定义了以外8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,
load、store、read和write操作在某些平台上允许有例外,这个问题在12.3.4节会专门讨论)。
- load(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,一遍随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受的值赋给工作内存的变量,每当虚拟机遇到一个变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存中的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存中,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
- 如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,
就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read和load之间、
store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时、一种可能出现的顺序是read a、read b、load b、 load a。
除此之外,Java内存模型还规定了再执行上述8中基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,
只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行share、write操作)。
- 以上8种内存访问操作较为繁琐,后来将Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言上的等价化简,Java内存模型的基础设计并未改变。
12.3.3 对于volatile型变量的特殊规则
- 当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改了一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。
- volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立即反映到其他线程之中。换句话说,volatile变量在各个线程中是一致的,
但不能说基于volatile变量的运算在并发下是线性安全的。
- 由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
- 使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能够获取到正确的结果,
而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
12.3.4 针对long和double型变量的特殊规则
Java内存模型对于64位的数据类型(long和double)定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,
即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定“(Non-Atomic Treatment of double and long Variables)。
12.3.5 原子性、可见性与有序性
-
- 原子性(Atomicity)
- 由Java内存模型来直接保存的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。
- 如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。
这两个指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。
-
- 可见性(Visibility)
- 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,
在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。
普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
- 除了volatile之外,synchronized和final也能实现可见性。同步块的可见性是由”对一个变量执行unlock操作之前,
必须先把次变量同步回主内存中(执行store、write操作)“这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且把构造器没有把”this“的引用传递出去(this引用逃逸是一件很危险的事情,
其他线程有可能通过这个引用访问到”初始化了一半“的对象),那么在其他线程中就能看见final字段的值。
-
- 有序性(Ordering)
- 如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指”线程内表现为串行的语义“(Within-Thread As-If-Serial Semantics),后半句是指”指令重排序“现象和”工作内存与主内存同步延迟“现象。
- Java语言提供了valotile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由”一个变量在同一个时刻只允许一条线程对其进行lock操作“这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
12.3.6 线性发生原则
- 先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,
操作A产生的影响能被操作B观察到,”影响“包括修改了内存中共享变量的值、发送了消息、调用了方法等。
- 下面是Java内存模型下一些”天然的“先行发生关系,这些先行发生关系无须任何同步协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,
并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作线性发生于书写在后面的操作。
注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。
这里必须强调的是”同一个锁“,而”后面“是指时间上的先后。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,
这里的”后面“同样是指时间上的先后。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、
Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,
可以通过Thread::interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
12.4 Java与线程
12.4.1 线程的实现
-
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。
目前线程是Java里面进行处理器资源调度的最基本单位,不过如果日后Loom项目能够成为Java引入纤程(Fiber)的话,可能就会改变这一点。
-
实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。
-
1.内核线程实现
-
使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,
内核通过操作调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,
这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。
-
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都有一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如图12-3所示。
图12-3 轻量级线程与内核线程之间1:1的关系
-
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。轻量级进程也具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。
而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
-
2.用户线程实现
- 使用用户线程实现的方式被称为1:N实现。广义上讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,
因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作中都要进行系统调用,
因此效率会受到限制,并不具备通常意义上的用户线程的优点。
- 而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、
销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低耗的,
也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为1对多的线程模型,如图12-4所示。
图12-4 进程与用户线程之间1:N的关系
-
3.混合实现
- 内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,及存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,
因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,
这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。
在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系,如图12-5所示,这种就是多对多的线程模型。
图12-5 用户线程与轻量级线程之间M:N的关系
-
4.Java线程的实现
- 以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,
所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、
该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统权限决定的。
12.4.2 Java线程调度
- 线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。
- 如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上去。
协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完之后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。
- 如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定的。譬如在Java中,
有Thread:yield()方法可以主动让出执行时间,但是如果想要主动获取执行时间,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。
Java使用的线程调度方式就是抢占式调度。
- 虽然说Java线程调度是系统自动完成的,但是我们仍然可以"建议"操作系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点——
这项操作是通过设置线程优先级来完成的。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。
在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
12.4.3 状态转换
- Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中一种状态,并且可以通过特定的方法在不同状态之间转换。
这6种状态分别是:
- 新建(New):创建后尚未启动的线程处于这种状态。
- 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,
也有可能正在等待着操作系统为它分配执行时间。
- 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
- 没有设置Timeout参数的Object:wait()方法;
- 没有设置Timeout参数的Thread:join()方法;
- LockSupport::park()方法
- 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无需等待被其他线程显式唤醒,
在一定时间之后它们会由系统自动唤醒。一下方式会让线程进入限期等待状态:
- Thread::sleep() 方法;
- 设置了Timeout参数的Object::wait()方法;
- 设置了Timeout参数的Thread::join()方法;
- LockSupport::parkNanos()方法;
- LockSupport::parkUntil()方法;
- 阻塞(Blocked)线程被阻塞了,"阻塞状态"与"等待状态"的区别是"阻塞状态"在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;
而"等待状态"则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
- 上述6种状态在遇到特定事件发生的时候将会互相转换,它们的转换关系如图12-6所示。
图12-6 线程状态转换关系
12.5 Java与协程
12.5.1 内核线程的局限
- 1:1的内核线程模型是如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然上的缺陷是切换、调度成本高昂,
系统能容纳的线程数量也很有限。以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、
数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。
12.5.2 协程的复苏
- 在早期,就已经出现了今天被称为栈纠缠(Stack Twine)的、由用户自己模拟多线程、自己保护恢复现场的工作模式。其大致原理是通过在内存里划出一片额外空间来模拟调用栈,
只要其他"线程"中方法压栈、退栈时遵守规则,不破坏这片空间即可,这样多段代码执行时就会像相互缠绕着一样,非常形象。
- 到后来,操作系统开始提供多线程支持,靠应用自己模拟多线程的做法自然是变少了许多,但也并没有完全消失,而是演化为用户线程继续存在。
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine)。
又由于这时候的协程会完整地做调用栈的保护、恢复工作,所以今天也被称为"有栈协程"(Stackfull Coroutine)。
- 协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。
- 协程当然也有它的局限,需要在应用层面实现内容(调用栈、调度器)特别多。
12.5.3 Java的解决方案
- 对于有栈协程,有一种特例实现名为纤程(Fiber)。OpenJDK在2018年创建了Loom项目,这是Java用来应对本节开篇所列场景的官方解决方案,
根据目前公开的信息,如无意外,日后该项目为Java语言引入的、与现在线程模型平行的新并发编程机制中应该也会采用"纤程"这个名字。
- Loom项目背后的意图是重新提供对用户线程的支持,但与过去的绿色线程不同,这些新功能不是为了取代当前基于操作系统的线程实现,
而是会有两个并发编程模型在Java虚拟机中并存,可以在程序中同时使用。新模型有意地保持了与目前线程模型相似的API设计,它们甚至可以拥有一个共同的基类,
这样现有的代码就不需要为了使用纤程而进行过多改动,甚至不需要知道背后采用了哪个并发编程模型。
- 在新并发模型下,一段使用纤程并发的代码会被分为两部分——执行过程(Continuation)和调度器(Scheduler)。执行过程主要用于维护执行现场,
保护、恢复上下文状态,而调度器则负责编排所有要执行的代码的顺序。将调度器程序与执行过程分离的好处是,用户可以选择自行控制其中的一个或者多个,
而且Java中现有的调度器也可以被直接重用。事实上,Loom中默认的调度器就是原来已存在的用于任务分解的Fork/Join池(JDK7中加入的ForkJoinPool)。
本章小结
- 本章中,我们了解了虚拟机Java内存模型的结构及操作,并且讲解了原子性、可见性、有序性在Java内存模型中的体现,介绍了先行发生原则的规则与使用。
另外,我们还了解了线程在Java语言之中是如何实现的,以及代表Java未来多线程发展的新并发模型的工作原理。