第一部分 走近Java
第一章 走近Java
本章仅用来扩宽视野,复习时可以直接跳过。
1.1 概述
Java的一些突出优点:
- 平台无关:“一次编写,到处运行”。
- 内存管理:提供了一种相对 安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题。
- 运行时编译优化:实现了热点代码检测和运行时编译及优化,这使得Java应用能随着运行时间的增长而获得更高的性能。
- 生态强大:有一套完善的应用程序接口,还有无数来自商业机构和开源社区的第三方类库。
1.2 Java技术体系
JCP(Java Community Process Java技术社区)定义的Java技术体系,包括以下范围:
- Java程序设计语言
- Java虚拟机实现(各种硬件平台上)
- Class文件格式
- Java类库API
- 第三方Java类库(来自商业机构和开源社区)
以下是整体示意图:
1.3 Java发展史
- JDK 1.4:正则表达式、异常链、NIO、日志类、XML解析器和XSLT转换器
- JDK 1.5:语法特性层面,加入自动装箱、泛型、动态注解、枚举、可变长参数、遍历循环(foreach循环)等。虚拟机和API层面,改进了Java的内存模型(Java Memory Model,JMM)、提供了java.util.concurrent并发包等。
- JDK 1.6:提供初步的动态语言支持(通过内置Mozilla JavaScript Rhino引擎实现)、提供编译期注解处理器和微型HTTP服务器API。虚拟机内部做了大量改进,包括锁与同步、垃圾收集、类加载等方面的实现。
- JDK 1.7:新的G1收集器(直至2012年4月的Update 4中才正式商用)、加强对非 Java语言的调用支持(JSR-292,这项特性在到JDK 11还有改动)、可并行的类加载架构。
- JDK 1.8:Lambda表达式、内置Nashorn JavaScript引擎的支持、新的时间日期API、彻底移除HotSpot的永久代。
- JDK 1.9:Jigsaw模块化,增强了若干工具(JS Shell、JLink、JHSDB等),整顿了HotSpot各个模块各自为战的日志系统,支持HTTP 2客户单API等91个JEP。
- JDK 1.10:2018.3.20。主要研发目标是内部重构,诸如统一源仓库、统一垃圾收集器接口、统一即时编译器接口(JVMCI在JDK 9已经有了,这里是引入新的Graal即时编译器)等。
- JDK 1.11:2018.9.25,革命性的垃圾收集器ZGC,类型推断加入Lambda语法,谣言:“Java要开始收费啦!
- JDK 1.12:2019.3.20,Switch表达式、Java微测试套件 (JMH)等新功能。加入了由RedHat领导开发的Shenandoah垃圾收集器。Shenandoah作为首个由非Oracle开发的垃圾收集器,其目标又与Oracle在JDK 11中发布的ZGC几乎完全 一致,两者天生就存在竞争。被Oracle抵制,进入了OpenJDK发布清单,但在OracleJDK中无法使用。
1.4 Java虚拟机家族
- Sun Classic:Sun/Oracle公司研发,世界上第一款商用Java虚拟机,已淘汰。
- HotSpot VM:Sun/Oracle公司研发,是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。HotSpot虚拟机的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。
- Mobile/Embedded VM:Sun/Oracle公司研发,面对移动和嵌入式市场。
- BEA JRockit/IBM J9 VM:BEA System公司的JRockit与 IBM公司的IBM J9。BEA已被Oracle收购,JRockit不再更新。IBM J9直至今天仍旧非常活跃,IBM J9虚拟机的职责分离与模块化做得比HotSpot更优秀,它是一款在设计上全面考虑服务端、桌面应用,再到嵌入式的多用途虚 拟机,开发J9的目的是作为IBM公司各种Java产品的执行平台。
- BEA Liquid VM/Azul VM:与特定硬件平台绑定、软硬件配合工作的专有虚拟机,往往能够实现更高的执行性能,成本太高,逐渐没落。
- Apache Harmony/Google Android Dalvik VM:Dalvik虚拟机曾经是Android平台的核心组成部分之一,它没有遵循《Java虚拟机规范》,不能直接执行Java的 Class文件,使用寄存器架构而不是Java虚拟机中常见的栈架构。但是它与Java却又有着千丝万缕的联系,它执行的DEX(Dalvik Executable)文件可以通过Class文件转化而来,使用Java语法编写应用程序,可以直接使用绝大部分的Java API等。到了Android 4.4时代,支持提前编译(Ahead of Time Compilation,AOT)的ART虚拟机迅速崛起,在Android 5.0里ART就全面代替了Dalvik虚拟机。
- Microsoft JVM及其他 :微软自己开发的JVM,只有Windows平台的版本,时Windows系统下性能最好的Java虚拟机。
- 其他非主流领域:KVM在 Android、iOS等智能手机操作系统出现前曾经在手机平台上得到非常广泛应用。JCVM(Java Card VM) 极度精简用于银行信用卡等。JavaInJava试图以Java语言来实现Java语言本身的运行环境,它必须运行在另外一个宿主虚拟机之上。
1.5 展望Java技术的未来
Java未来能否继续壮大发展,某种 程度上取决于如何应对当下已出现的挑战,本节将按照这个脉络来组织,介绍现在仍处于 Oracle Labs中的Graal VM、Valhalla、Amber、Loom、Panama等面向未来的研究项目。
1.无语言倾向
2018年4月,Oracle Labs公开的和科技:Graal VM,这个虚拟机上可以跑任何语言。
Graal VM这是一个在HotSpot虚拟机基础上增强而成 的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、 Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等。Graal VM可以无额外开销地混合使用这些编程语言。
Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式 (例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation, IR),譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为程 序特化(Specialized,也常被称为Partial Evaluation)。Graal VM提供了Truffle工具集来快速构建面向一 种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。
对于一些本来就不以速度见长的语言运行环境,由于Graal VM 本身能够对输入的中间表示进行自动优化,在运行时还能进行即时编译优化,因此使用Graal VM实现 往往能够获得比原生编译器更优秀的执行效率,譬如Graal.js要优于Node.js。
2.新一代即时编译器
代码优化质量更高的编译器。
对需要长时间运行的应用来说,由于经过充分预热,热点代码会被HotSpot的探测机制准确定位捕获,并将其编译为物理硬件可直接执行的机器码,在这类应用中Java的运行效率很大程度上取决于即时编译器所输出的代码质量。
HotSpot虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2),通常它们 会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统。
Graal编译器是以C2编译器替代者的身份登场的,而Graal编译器本身就是由Java语言写成,实现时又刻意与C2采用了同一种名为“Sea-of-Nodes”的高级中间表示(High IR)形式,使其能够更容易借鉴C2的优点。性能更高。
3.向Native迈进
Substrate VM显著降低内存占用及启动时间。
近几年在从大型单体应用架构向小型微服务应用架构发展的技术潮流下,Java表现出来的不适应。在微服务架构的视角下,应用拆分后,单个微服务很可能就不再需要面对数十、数百GB乃至TB 的内存,有了高可用的服务集群,也无须追求单个服务要7×24小时不间断地运行,它们随时可以中断和更新;但相应地,Java的启动时间相对较长,需要预热才能达到最高性能等特点就显得相悖于这样的应用场景。
4.灵活的胖子
重构HotSpot。
HotSpot的定位是面向各种不同应用场景的全功能Java虚拟机。HotSpot开发团队正在持续地重构着HotSpot的架构,让它具有模块化的能力和足够的开放性。 模块化方面原本是HotSpot的弱项,监控、执行、编译、内存管理等多个子系统的代码相互纠缠。 重构后更利于扩展及功能裁剪。
5.语言语法持续增强
语言的功能特性和语法放到最后来讲,因为它是相对最不重要的改进点。
- 各种语法糖。
- Project Loom:现在的Java做并发处理的最小调度单位是线程,Java线程的调度是直接由操作系统内核提供的(这方面的内容可见本书第12章),会有核心态、用户态的切换开销。而很多其他语言都提供了更加轻量级的、由软件自身进行调度的用户线程。
- Project Valhalla:提供值类型和基本类型的泛型支持,并提供明确的不可变类型和非引用类型的声明。基本类型的范型支持是指在泛型中引用基本数据类型不需要自动装箱和拆箱,避免性能损耗。
- Project Panama:目的是消弭Java虚拟机与本地代码之间的界线。现在Java代码可以通过JNI来调用本地代码,Panama项目的目标就是提供更好的方式让Java代码与本地代码进行调用和传输数据。
1.6 实战:自己编译JDK
略。
第二部分 自动内存管理
第2章 Java内存区域与内存溢出异常
2.1 概述
相比较于C/C++,Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问 题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。所以我们要熟悉JVM的内存管理。
本章将从概念上介绍Java虚拟机内存的各个区域,讲解这些区域的作用、服务对象以及其中可能产生的问题。
2.2 运行时数据区域
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如图所示。
1.程序计数器
线程私有,用来指示当前线程执行的字节码行号,即存储当前线程正在执行的 Java 方法的 JVM 指令地址。(其实就类似于具体硬件中的CPU执行寄存器?)
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,因此它是程序控制流的指示器,包括分支、循环、跳转、异常处理、线程恢复等。
在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。所以它是线程私有的。
- 如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;
- 如果正在执行本地(Native)方法,这个计数器值则应为空(Undefined)。
此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
2.虚拟机栈
线程私有,生命周期与线程相同。用来存储方法的栈帧。
每个方法被执行的时候,JVM会同步创建一个栈帧。每一个方法被线程调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
每一个栈帧存储该线程一个方法的:
- 局部变量表
- 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用、和returnAddress类型(指向了一条字节码指令的地址)。
- 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和 double类型的数据会占用两个变量槽,其余的数据类型只占用一个。
- 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。
- 操作数栈
- 动态连接
- 方法出口。 即方法正常或异常退出的定义等。
- 其他信息
在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
- 如果Java虚拟机栈容量可以动态扩展(Hotspot不支持),当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
补充测试:
1. **线程栈越大,越深**:-Xss256k,栈深度1556溢出。-Xss512k,3249溢出。
2. **栈帧越小,越深**:以方法参数个数为例,0个参数,1556溢出。1个String参数,1318溢出。
虽然HotSpot虚拟机上不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常(因为压根就不支持动态扩展,只要线程申请栈空间成功了就不会有OOM),但如果申请时就失败,仍会出现OOM异常,后面的实战中也演示了这种情况。
3.本地方法栈
与虚拟机栈作用类似,区别只是是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
《Java虚拟机规范》对本地方法栈中方法使用的语言等没有任何规定,JVM可以自由实现。甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
4.堆
堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。用来存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。(所以对象是可以被线程共享的)
在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”。上文中用“几乎”是因为:
- 由于即时编译技术尤其是逃逸分析技术的进步,栈上分配、标量替换优化手段。例如理论上JVM 可以在栈上分配那些不会逃逸的对象,不过Hotspot JVM 中并未这么做,而是分配到堆上。
- 日后可能出现值类型的支持。以上两点导致了Java对象实例都分配在堆上不是那么绝对。
Java堆是垃圾收集器管理的内存区域。
在G1收集器出现前,HotSpot内部的垃圾收集器全部基于分代收集理论设计,需要新生代、老年代收集器搭配才能工作。因此有“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等说法。G1之后,HotSpot里面也出现了不采用分代设计的新垃圾收集器。
如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。
《Java虚拟机规范》规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上连续。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java堆可以被实现成固定大小的,也可以是可扩展的。当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
5.方法区
方法区是各个线程共享的内存区域。
它用于存储已被虚拟机加载的:用于存储所谓的元(Meta)数据,例如类结构信息,以及对应的运行时常量池、字段、方法代码等:
- 运行时常量池
- 类型信息、
- 静态变量、常量、
- 即时编译器编译后的代码缓存
- 其他数据。
非堆:《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分。但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。
永久代:JDK 8以前,HotSpot虚拟机设计团队使用永久代来实现方法区,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码工作。所以之前方法区也叫“永久代”。
用永久代实现方法区有缺陷:1.更容易内存溢出,因为有XX:MaxPermSize的上限。而不用永久代实现的话,只要未达到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题。2.有极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。
因此,HotSpot开发团队改为采用本地内存(Native Memory)来实现方法区:
- JDK6,方法区是用永久代实现。
- JDK7,把永久代的符号引用移至native heap。运行时常量池、字符串常量池、static静态变量移到Java堆(Class对象也在Java堆)。Intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
- JDK8,把永久代中剩余的内容(主要是类型信息,即类的元数据信息)全部移到元空间,完全废弃永久代。元空间大小设置参数:
-XX:MetaspaceSize
。元空间存储初始加载的类的描述信息, 意味着它只存储原始类的信息。 - Class对象是根据类型信息产生的。方法区是一个逻辑区域。JDK6及之前,方法区确实是由永久代实现。JDK7及以后,就纯粹是一个逻辑上的表述了。因为1.7就字符串常量池等就移到堆上了。
《Java虚拟机规范》对方法区的约束非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。
方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说对这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。 (要让方法区溢出可真是有点难呀~)
6.运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。
运行时常量池用来存储编译期的Class文件中的常量池表、运行期间的直接引用、运行期间的新常量:
- Class文件的常量池表:即字面量与符号引用:Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量,以及需要在运行时决定的符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 运行期间的直接引用:运行时由存放的符号引用翻译而来的直接引用:对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
- 运行期间的新常量:运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言的常量不仅在编译期可以产生,在运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
补充:
- 简单介绍下jvm (1.7版本)分为:方法区、堆、栈、本地方法栈、程序计数器。而到了1.8版本,稍微做了些调整,使用了元数据区替换了方法区,并且元数据区并不在jvm的控制范围内,其使用的是本地内存,那么jvm中就只有堆、栈、本地方法栈、程序计数器。
- 在java中,为了避免字符串常量分配的性能消耗,java在初始化字符串的时候,会为字符串放置到字符串常量池中。而字符串常量池在不同的版本实际上存放的位置也是不同的,在1.6版本时放在方法区,在1.7版本又移到了堆中,而到了1.8版本被移动到元空间(存疑。本书的说法是1.7把方法区常量池移动到堆,1.8把方法区剩余的内容移动到元数据区。网上的实验也证明,1.8字符串常量池是在堆上。这个问题争议比较多,目前建议按本书的说法来。)。Jvm的知识后续还会重点介绍,在这里,主要是说明字符串在jvm中存储位置,方便理解上面的题目。方法区从1.7及以后就只是逻辑概念了,因为1.7开始常量池等就进行了移动。
运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。 常量池1.7之前在永久代,1.7及之后被移到Java堆,1.8之后移到元数据区。
资料补充:
Java的三种常量池:
- 字符串常量池
- 运行时常量池:程序运行到某个类时,class文件中的信息解析到方法区的运行时常量池。每个类都有一个运行时常量池。
- class文件常量池:存在class文件里面,含编译器生成的各种字面量和符号引用。
7.直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
异常抛出:对于Hotspot,程序计数器不会抛异常。虚拟机栈和本地方法栈合二为一。由于不支持栈容量动态扩展,因此不会由于栈无法扩容抛出OOM异常,但会因为申请时就失败而抛出OOM。请求的栈深度大于允许深度时(深度多少?算一下?),抛出栈溢出异常。堆在没内存分配也没法扩容的情况下,报OOM。早期的方法区放在堆的永久代(有XX:MaxPermSize限制),最新的方法区已经放到本地内存,只考虑本地内存的限制即可。方法区无法分配内存时报OOM。方法区主要是对常量的回收和类型的卸载。运行时常量池存Class文件的常量池表(放编译期生成的各种字面量与符号引用)、符号引用翻译的直接引用、运行时生成的常量。不回收也可能OOM。最后是直接内存,由于Java1.4可以使用堆的对象间接控制外部内存,减少Java堆和Native堆的复制开销。指定JVM内存大小时,如果全部把机器的内存占满,不给这部分直接内存留余地,那么也会OOM。
2.3 HotSpot虚拟机对象探秘
前文已经了解了JVM的内存模型。下面我们来具体了解内存中数据的其他细节。以下内容限制在,Hotspot虚拟机,以及Java堆内存区域。深入探讨一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
1.对象的创建
在语言层面,对象的创建就是new。下面我们探讨虚拟机层面,对象是怎样创建的(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)。
JVM遇到一条字节码new指令时,做以下动作:
先检查是否有该类,再检查该类是否初始化:先检查该指令的参数是否能在常量池中定位到一个类的符号引用,再检查这个符号引用代表的类是否已被加载、解析和初始化。
为新生对象分配内存:对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间本质上就是把一块确定大小的内存块从Java堆中划分出来。内存分配完成之后,JVM必须将分配到的内存空间(但不包括对象头)都初始化为零值。
- 内存是否规整:取决于GC内存管理方式
- Java堆是规整的:指针碰撞。采用了Serial、ParNew等带压缩整理过程的收集器,所以内存规整。空闲和非空闲内存只是用一个指针隔开,所以移动指针就行了。
- Java堆是不规整的:空闲列表。采用了CMS等基于清除算法的收集器,内部不规整。JVM需要维护一个列表,记录哪些内存空闲。分配时从列表中选一个足够大的内存进行分配。
- 如何保证线程安全:即多个线程同时分配内存怎么保证线程安全?
- 同步:CAS + 失败重试保证操作原子性
- 本地线程分配缓冲:(Thread Local AllocationBuffer,TLAB)。每个线程在Java堆中预先分配一小块内存,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配。只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
- 内存是否规整:取决于GC内存管理方式
设置对象头信息:例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
按构造函数初始化对象:前三步完成了JVM的对象初始化,还没根据Java程序员的意愿初始化。构造函数,即Class文件中的<init>()方法还没有执行。通常情况,Java编译器会在遇到new关键字的地方同时生成这
new
和init
两条字节码指令(如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>()方法,
扩展:在CMS的实现里面,为了能在多数情况下分配得更快,设计了一个叫作LinearAllocation Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式来分配。
2.对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
一、对象头
包含两部分信息:
1.对象自身的运行时数据
也叫“Mark Word”。如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。长度32或64bit(32或64位机)。为节省空间,Mark Word的数据结构是动态的,根据不同的状态存储不同的内容。如下:
2.类型指针
即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。不过不是所有的JVM实现都必须在对象数据上保留类型指针,因为查找对象的元数据信息并不一定要经过对象本身。
如果对象是一个Java数组,那对象头中还必须记录数组长度。因为JVM需要通过普通Java对象的元数据信息确定Java对象的大小,如果没有数组长度,将无法推断出数组大小。
二、实例数据
存储代码中定义的各种类型字段。子类和父类的字段都有。默认的分配策略是:顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(OrdinaryObject Pointers,OOPs)。即:1.相同宽度的分配到一起。2.然后父类的在子类前面。3.再参考源码中的定义顺序。
分配策略可以按参数调节:1.如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。2.存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。
三、对齐填充
仅仅起着占位符的作用。由于HotSpot的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即任何对象的大小都必须是8字节的整数倍。对象头部分已被精心设计成正好是8字节的倍数(1倍或者2倍)。若对象实例数据部分没有对齐,则需要通过对齐填充来补全。
3.对象的访问定位
前面已经创建好对象了,那么如何使用它呢?如何定位、访问到堆中对象的具体位置?
Java程序是通过栈上的的reference数据来操作堆上的具体对象的。而《Java虚拟机规范》指规定了它是一个对象的引用,没有规定访问方式。所以访问方式根据JVM的具体实现来定。
主流的访问方式有两种:
- 使用句柄:Java堆中划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中存储了对象实例数据的地址信息和类型数据的地址信息。好处是:1.对象移动时,修改句柄即可,reference不用动。例如GC时,reference中存储的是稳定句柄地址,只需要更改句柄中的数据指针即可,reference本身不用动。2.句柄中还存了类型数据的地址信息。坏处是:多了一次间接访问。
- 直接指针:reference中存储的直接就是对象地址。好处是:如果是访问对象本身,那么直接访问,速度很快,节省了一次指针定位的时间。坏处是:1.对象移动时,需要维护reference数据。2.Java堆中对象的内存布局需要考虑怎么放置访问类型数据的相关信息。
Hotspot主要使用直接指针方式访问。(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,具体可参见第3章)。不过在各种语言、框架中使用句柄来访问的情况也十分常见。
2.4 实战:OOM异常
我们的目的是模拟OOM异常。除了程序计数器,其他地方都可能出现OOM。我们通过模拟OOM,熟悉日常工作中OOM的处理,同时也熟悉下JVM的各个参数。
1.堆溢出
不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免GC即可。
Java参数:
- -Xms20m:最小堆20M
- -Xmx20m:最大堆20M
- -XX:+HeapDumpOnOutOfMemoryError:OOM时Dump出当前的内存堆转储快照
/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* @author zzm
*/
public class HeapOOM {
static class OOMObject { }
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
// 运行结果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3404.hprof ...
Heap dump file created [22045981 bytes in 0.663 secs]
排查思路:
出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。
首先通过内存映像分析工具(如Eclipse MemoryAnalyzer)对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(MemoryOverflow)。
内存泄漏:找到Bug源头。可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们。
内存溢出:调大内存并优化代码。如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
2.JVM栈溢出或本地方法栈溢出
1.总述
关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:
- 线程请求的栈深度大于虚拟机所允许的最大深度,抛出StackOverflowError。
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
Java参数设置:
- -Xoss:设置本地方法栈大小
- -Xss:设置栈容量
对于Hotspot来说:
- Hotspot不支持栈内存动态扩展,所以栈的OOM只会在一开始新建线程就申请不到栈内存时抛出,不会在线程运行时抛出。
- Hotspot合并了本地方法栈和JVM栈,因此栈容量只能通过-Xss设置。
2.实验
下面我们用实验来验证:
实验一:单线程:减少虚拟机栈容量:使用-Xss参数减少栈内存容量,然后方法无限递归调用自己。
结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
实验二:单线程:增大栈帧:定义大量的本地变量,以增大方法帧本地变量表的大小,然后方法无限递归调用自己。
结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
实验三:多线程:把进程内存吃满:无限申请新的线程,新的线程需要栈内存空间,用这个把进程的内存撑爆。
结果:抛出OOM。
分析:OS分配给每个进程的内存是有限制的,例如32位的windows是2G。HotSpot有参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小可以忽略,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。把它撑爆就OK。
注意几点:
- 不同的JVM版本或操作系统,-Xss参数的最小值限制不一样。主要是因为操作系统的内存分页大小不同(最小只能小到一个分页大小?)。Linux下是228K。
- 对于实验二,如果是支持栈容量扩展的虚拟机,会报OOM。Hotspot不支持扩容,只会报StackOverflowError。
- 实验三虽然在Hotspot上产生了OOM,不过这个OOM不是因为线程本身栈空间不够产生的,而是新的线程申请不到内存空间引起的。这个时候甚至反而需要减小栈容量(节流)或者减小堆容量(开源)来避免OOM。
实验代码:
实验一:
/**
* VM Args:-Xss128k * @author zzm
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
// 结果
stack length:2402 Exception in thread "main" java.lang.StackOverflowError at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:20) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21) ……后续异常堆栈信息省略
实验二:
/*** @author zzm */
public class JavaVMStackSOF {
private static int stackLength = 0;
public static void test() {
long unused1,unused2, unused3, unused4, unused5,
unused6, unused7, unused8, unused9, unused10,
unused11, unused12, unused13, unused14, unused15,
unused16, unused17, unused18, unused19, unused20,
unused21, unused22, unused23, unused24, unused25,
unused26, unused27, unused28, unused29, unused30,
unused31, unused32, unused33, unused34, unused35,
unused36, unused37, unused38, unused39, unused40,
unused41, unused42, unused43, unused44, unused45,
unused46, unused47, unused48, unused49, unused50,
unused51, unused52, unused53, unused54, unused55,
unused56, unused57, unused58, unused59, unused60,
unused61, unused62, unused63, unused64, unused65,
unused66, unused67, unused68, unused69, unused70,
unused71, unused72, unused73, unused74, unused75,
unused76, unused77, unused78, unused79, unused80,
unused81, unused82, unused83, unused84, unused85,
unused86, unused87, unused88, unused89, unused90,
unused91, unused92, unused93, unused94, unused95,
unused96, unused97, unused98, unused99, unused100;
stackLength++;
test();
unused1 = unused2 = unused3 = unused4 = unused5
= unused6 = unused7 = unused8 = unused9 = unused10
= unused11 = unused12 = unused13 = unused14 = unused15
= unused16 = unused17 = unused18 = unused19 = unused20
= unused21 = unused22 = unused23 = unused24 = unused25
= unused26 = unused27 = unused28 = unused29 = unused30
= unused31 = unused32 = unused33 = unused34 = unused35
= unused36 = unused37 = unused38 = unused39 = unused40
= unused41 = unused42 = unused43 = unused44 = unused45
= unused46 = unused47 = unused48 = unused49 = unused50
= unused51 = unused52 = unused53 = unused54 = unused55
= unused56 = unused57 = unused58 = unused59 = unused60
= unused61 = unused62 = unused63 = unused64 = unused65
= unused66 = unused67 = unused68 = unused69 = unused70
= unused71 = unused72 = unused73 = unused74 = unused75
= unused76 = unused77 = unused78 = unused79 = unused80
= unused81 = unused82 = unused83 = unused84 = unused85
= unused86 = unused87 = unused88 = unused89 = unused90
= unused91 = unused92 = unused93 = unused94 = unused95
= unused96 = unused97 = unused98 = unused99 = unused100
= 0;
}
public static void main(String[] args) {
try {
test();
} catch (Error e) {
System.out.println("stack length:" + stackLength);
throw e;
}
}
}
// 结果
stack length:3716 java.lang.OutOfMemoryError at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:27) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28) ……后续异常堆栈信息省略
实验三:
/*** VM Args:-Xss2M (这时候不妨设大些,请在32位系统下运行) * @author zzm */
public class JavaVMStackOOM {
private void dontStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
dontStop();
}
});
thread.start();
}
}
public static void main(String[] args) throws Throwable {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}
}
// 结果
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
3.方法区和运行时常量池溢出
一、运行时常量池溢出测试
运行时常量池属于方法区,我们可以一起测。
由于JSP等大量类信息的情景容易引起永久代OOM。JDK1.7开始去永久代,JDK1.8全部移除永久代。JDK1.7,符号引用被移至native heap,字面量(interned strings)(字符串常量池?)和静态变量(class statics)被移至java heap。JDK1.8,完全废弃永久代,把JDK 7中永久代剩余的内容(主要是类型信息)全部移到元空间中,改为在本地内存中实现的元空间(Meta-space)来代替。
下面我们用String的intern()
来测试字符串常量池溢出。intern()
是一个本地方法,若字符串常量池有该字符串,则返回字符串常量池中该字符串对象的引用。如果没有,则先将该字符串加入常量池,然后再返回该字符串对象的引用。
JVM参数:
- -XX:PermSize=6M:限制永久代大小,JDK1.6及以前适用。
- -XX:MaxPermSize=6M:限制永久代大小。
- -XX:MaxMetaspaceSize:限制元空间大小。元空间就是Java线程中,JVM管辖范围外的进程内存空间。
实验过程:
JDK1.6:限制永久代大小,然后往字符串常量池加入大量字符串。
结果:OOM
/*** VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M * @author zzm */ public class RuntimeConstantPoolOOM { public static void main(String[] args) { // 使用Set保持着常量池引用,避免Full GC回收常量池行为 Set<String> set = new HashSet<String>(); // 在short范围内足以让6MB的PermSize产生OOM了 short i = 0; while (true) { set.add(String.valueOf(i++).intern()); } } } // 结果: Exception in thread "main" java.lang.OutOfMemoryError: PermGen space at java.lang.String.intern(Native Method) at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)
JDK1.7:使用-Xmx限制堆大小。然后往字符串常量池加入大量字符串。
结果:几M的常量装得下,很多的话就会OOM。此时常量池是被放到Java堆里了。
String::intern的“首次遇到”原则:1.7以后,由于字符串常量池已移到Java堆,无需再将字符串实例拷贝到永久代,只需记录下首次遇到的实例的引用即可。
public class RuntimeConstantPoolOOM { public static void main(String[] args) { String str1 = new StringBuilder("计算机").append("软件").toString(); System.out.println(str1.intern() == str1); String str2 = new StringBuilder("ja").append("va").toString(); System.out.println(str2.intern() == str2); } } // 结果 // 1.6 都是false // 1.7 第一个true,第二个false (首次遇到原则) // 第二个false是因为之前已经遇到过java字符串
二、方法区其他数据溢出测试
方法区主要用来存储类型信息,如类名、访问修饰符、常量池、字段描述、方法描述等。这部分的数据,我们的测试思路是是运行时产生大量的类去填满方法区,直到溢出为止。
运行时动态产生类有很多方法,如JavaSE的如反射时GeneratedConstructorAccessor和动态代理,CGLib等。这里为了方便,我们使用CGLib直接操作字节码运行时生成了大量的动态类
实际场景中,类似这样的代码也可能会存在方法区OOM。如Spring、Hibernate对类进行增强时,增强的类很多的话,也会生成大量的新类型,这个时候就需要一个大的方法区。
实验代码,使用JDK1.7:
/*** VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M * @author zzm */
public class JavaMethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
}
static class OOMObject {
}
}
// 结果
Caused by: java.lang.OutOfMemoryError: PermGen space at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632) at java.lang.ClassLoader.defineClass(ClassLoader.java:616) ... 8 more
在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。比如:AOP增强、动态语言、大量的JSP(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
JDK1.8后,类型信息是存到元空间。元空间的设置,官方推荐默认即可,很难出现OOM。不过如果要进行限制的话,可以用以下参数:
- -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
- -XX:MetaspaceSize:指定元空间的初始大小,以字节为单位,达到该值就触发垃圾收集进行类型卸载。同时收集器会对该值进行调整:如果释放的空间多,就适当降低该值;如果释放的空间少,那么在不超过最大值的情况下,适当提高该值。
- -XX:MinMetaspaceFreeRatio:在垃圾收集之后控制最小的元空间剩余容量所占百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:MaxMetaspaceFreeRatio,用于控制最大的元空间剩余容量所占百分比。
三、方法区各数据存储位置总结
方法区主要用来存储类型信息,如类名、访问修饰符、常量池、字段描述、方法描述等。各信息存储如下:
- native堆:符号引用(Symbol)
- Java堆:字面量(interned strings)、静态变量(class statics)
- 元空间:类型信息(类名、访问修饰符、常量池、字段描述、方法描述等)
4.本机直接内存溢出
直接内存(Direct Memory)的大小可通过*-XX:MaxDirectMemorySize*参数来指定,默认与Java堆最大值(由-Xmx指定)一致。
JVM参数:
- -XX:MaxDirectMemorySize:设置直接内存(Direct Memory)大小。
一般分配直接内存是通过DirectByteBuffer
类,不过它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常。
所以这里我们绕过DirectByteBuffer
,直接用Unsafe::allocateMemory()
来申请直接内存。
/*** VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M * @author zzm */
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
// 结果
Exception in thread "main" java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory(Native Method) at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)
一般由直接内存导致的OOM,Heap Dump文件中看不到什么明显的异常。如果Dump文件很小,程序中又直接或间接用到了DirectMemory
(典型的间接使用就是NIO),那么就要考虑下是不是直接内存溢出。
2.5 本章小结
本章讲了JVM的内存是如何划分的,以及各个区域出现内存溢出异常的原因,下一章将详细讲解Java垃圾收集机制是怎么避免出现内存溢出异常的。
提问
JVM内存有哪些区域?
各区域作用?存储什么数据?
各区域作用生命周期?在什么时候被创建?
各区域会抛出什么异常?在什么情况下抛出?
方法区有哪些东西?各存在哪里?
new一个对象发生了什么?
Hotspot的Java堆中的对象,包含了哪些信息?内存布局是怎么样的?对象头存储什么信息?实例数据存储顺序?为什么要有对齐填充?
如何定位、访问到堆中对象的具体位置?有哪些访问方式?各有什么区别优势劣势?Hotspot使用的哪种?
模拟堆、栈(本地方法栈/JVM栈)、方法区(方法区/运行时常量池)、本地内存OOM?JVM参数?怎么模拟?遇到这个问题怎么排查处理?
所有的对象都是在堆上的吗?类对象在方法区,字符串常量对象在堆上,常量池保存了首次遇到的该字符串的引用。
方法区内存怎么回收?包括卸载类、常量
直接内存什么情况下容易OOM?如何模拟?
第3章 垃圾收集器与内存分配策略
3.1 概述
垃圾回收需要完成的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
我们为什么要了解垃圾收集和内存分配?
- 排查各种内存溢出、内存泄漏问题
- 避免垃圾收集成为系统达到更高并发量的瓶颈
Java内存运行时区域的各个部分,对于栈而言,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,这几个区域的内存分配和回收都具备确定性,无需过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理,
本文后续讨论中的“内存”分配与回收也仅仅特指Java堆和方法区的内存。
3.2 如何回收对象?
首先确定需要干死哪些对象(两种算法),然后再确定怎么干死对象(两次标记)。
确定哪些对象需要回收主要是两种算法:
- 引用计数算法
- 可达性分析算法
1.引用计数算法
算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。为零时,对象就可以回收了。
优点:虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高。Python语言就是用的引用计数。
缺点:该算法需要由很多例外情况判定,要配合大量额外处理。例如:循环引用问题。主流的Java虚拟机都没有用它来实现。
循环引用示例:
两个对象都没有任何引用了,但是由于还在相互引用者对方,两个对象的引用计数都不为零,无法回收:
/*** testGC()方法执行后,objA和objB会不会被GC呢? * @author zzm */
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/*** 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过 */
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}
// 结果:被正常回收了。如下内存回收日志中包含“4603K->210K”,
[Full GC (System) [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] Heap
JVM成功回收了连个对象,可见JVM不是通过引用计数算法来判断对象是否存活的。
2.可达性分析算法
算法:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索引用链,如果某个对象到GC Roots间没有任何引用链相连,即从GC Roots到这个对象不可达,则证明此对象不可能再被使用。
在Java技术体系中,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象:如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- 在方法区中类静态属性引用的对象:譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象:譬如字符串常量池(String Table)里的引用。
- Java虚拟机内部的引用:如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
- 所有被同步锁(synchronized关键字)持有的对象。
- 临时GC Roots:根据用户所选用的垃圾收集器以及当前回收的内存区域不同,可能还有其他对象“临时性”地加入GC Roots集合。例如分代收集和局部回收(Partial GC)时,需要考虑到其他区域对本区域内的对象引用。
OpenJDK中的G1、Shenandoah、ZGC以及Azul的PGC、C4等新款的收集器都具备局部回收特征。
3.“引用”有哪些类型
前文中两种算法,判断对象是否存活都和“引用”离不开关系。
在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。
这种定义下一个对象只有“被引用”或者“未被引用”两种状态。但是我们希望对象的引用有更丰富的描述,例如:有些对象是,空间不足再回收,足够的话可以再放放。典型的就是各种缓存。
所以,JDK1.2之后,改进了Java的引用概念。引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种。
- 强引用:最传统的引用定义。类似“Object obj=new Object()”这种引用关系。GC就永远不会回收强引用的对象。
- 软引用:还有用,但非必须的对象。在系统将要发生内存溢出异常前,才会考虑回收它们。在JDK 1.2版之后提供了SoftReference类来实现软引用。
- 弱引用:还有用,但非必须的对象。只能生存到下一次GC发生。GC发生后,就会回收。在JDK 1.2版之后提供了WeakReference类来实现软引用。
- 虚引用:虚引用的唯一目的只是为了能在对象被GC时收到一个系统通知。虚引用不会对对象的生存时间有任何影响,也无法通过虚引用来取得一个对象实例。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
4.怎么干死对象?
被可达性分析算法判定不可达,此时对象还没死。要干掉一个对象,至少要经历两次标记过程:
- 第一次标记:在进行可达性分析后发现没有与GC Roots相连接的引用链。
- 第二次标记:无需执行finalize()方法,即对象没有覆盖finalize()方法,或者finalize()方法已经被JVM调用过。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。
Finalizer线程仅仅是触发finalize()方法运行,并不承诺一定会等待它运行结束,主要是为了某防止个对象的finalize()方法执行缓慢导致F-Queue队列中其他对象永久等待或整个内存回收子系统崩溃。
稍后收集器将对F-Queue中的对象进行第二次小规模的标记。如果对象成功拯救自己即和引用链上的对象建立关联,那么第二次标记时它将被移出“即将回收”的集合。否则就要被回收。
需要注意的是,任何一个对象的finalize()方法都只会被系统自动调用一次,第二次不会调用,对象会被直接进行二次标记进行回收。
正是由于finalize()的不确定性(只能调用一次,且Finalizer线程只负责不会等它结束),*Java官方并不推荐使用finalize()*。它只是早期为了让C++程序员适应Java的历史遗留。所以忘记这个方法吧。
以下是程序实例:
/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
* @author zzm
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
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 :(");
}
}
}
5.回收方法区
《Java虚拟机规范》中提到可以不要求虚拟机在方法区中实现垃圾收集。也确实有未实现或未完整实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载)。因为方法区GC性价比比较低,因为判定条件苛刻,能回收的内存又少。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
1.判断常量废弃:
与回收Java堆中的对象非常类似,以字符串常量为例,需同时满足:
- 系统中没有引用:没有任何字符串对象引用常量池中的“java”常量
- JVM中没有引用:且虚拟机中也没有其他地方引用这个字面量
常量池中其他类(接口)、方法、字段的符号引用也与此类似。
2.判断类型废弃:
需要同时满足以下3个条件:
- 该类所有的实例都已经被回收:即Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收:这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.控制参数
关于是否要对类型进行回收,可以通过参数控制,HotSpot虚拟机提供了以下参数:
-Xnoclassgc
参数:是否进行类型回收-verbose:class
以及-XX:+TraceClass-Loading
、-XX:+TraceClassUnLoading
:查看类加载和卸载信息
其中-verbose:class
和-XX:+TraceClassLoading
可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading
参数需要FastDebug版的虚拟机支持。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要JVM具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3.3 垃圾收集算法
垃圾收集算法涉及大量细节。本节只重点介绍分代收集理论和几种算法思想及其发展过程。
垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,也常被称作“直接垃圾收集”和“间接垃圾收集”。由于主流JVM不涉及引用计数式垃圾收集算法,所以本节只介绍追踪式GC。
1.分代收集理论
这个理论实际上是经验法则,建立在两个分代假说上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
由以上假说,很多常用的GC收集器给出了一样的设计原则:Java堆应该分成不同的区域,对象根据年龄分配到不同的区域存储。根据不同区域的特点,使用不同的策略各区域独立回收,从而提高回收效率和空间使用率。
不同的策略包括:“Minor GC”“Major GC”“Full GC”不同回收类型的划分,“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的GC算法等。
具体到Hotspot的实现,一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。每次新生代GC存活下来的对象,逐步晋升到老年代。
分代理论存在一个问题,单独回收新生代或老年代时,存在跨代引用需要处理。例如,单独回收新生代时,有些对象是老年代引用了它的,这个时候需要把这些老年代的对象也加入GC Roots。这个时候需要进行老年代全体扫描,开销比较大。
解决的方法是:只扫描一小部分存在跨代引用的老年代。在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),它把老年代划分成很多小块,然后把存在跨代引用的内存标出来。GC时只要把这一小部分内存的对象加入GC Roots扫描即可。这个方法在对象引用关系改变时需要维护数据结构,不过比起全部扫描老年代,开销很小。
以上方法的理论依据是
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
也就是,跨代引用只是一小部分,不用全部扫描老年代。因为加入新生代的对象被老年代引用,那么它也很快会晋升到老年代。
另外,通常能单独发生收集行为的只是新生代,所有GC收集器里面,单独收集老年代的只有CMS。
下面对各种收集进行统一的定义:
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外注意“Major GC”这个说法现在有点混淆,在不同资料上有可能指老年代的收集或整堆收集,自己进行判断。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
2.标记-清除算法
最早最基础的算法。
后续的算法基本上都是以它为基础,然后改进它的缺点。
算法:分为两个阶段:
- 标记:首先标记出所有需要回收的对象。标记用的就是前面提到的可达性算法。
- 回收:标记完成后,统一回收对象。
缺点:
- 更适合回收少量对象的情况:如果大部分对象是要回收的,那么将产生大量的标记清除动作,效率降低。
- 容易产生内存碎片:清除后容易产生大量不连续的内存碎片,分配大对象时找不到足够的连续内存。
以下是算法示意图:
3.标记-复制算法
一般用于新生代回收。
用来解决“标记-清除”算法需要回收大量对象时,效率低的问题。
算法:
- 将内存分为大小相等的两块,每次只用一块。
- 标记:标记需要继续存活的对象
- 复制:把继续存活的对象复制到另外一个内存区域,并整理整齐。然后本区域全部清零。
优点:
- 不会有内存碎片的问题。内存分配简单,直接移动指针。
缺点:
- 可用内存缩小了一半。
- 更适合回收大量对象的情况:如果大多数对象都不用回收,那么将产生大量的复制操作,降低效率。
以下是算法示意图:
改进:
前述算法,复制备用的区域太浪费了,占了一半。IBM有一项研究,98%的对象熬不过第一次GC,所以备用区域用不到50%那么大。
改进后的算法:Eden + Survivor + 老年代担保:新生代分为一块较大的Eden空间和两块较小的Survivor空间(Hotspot默认8:1:1),每次用Eden加一块Survivor,回收时把回收的对象放到另一块Survivor,Eden和本块Survivor清空,然后继续用Eden加另一块Survivor。另外为了防止回收时Survivor装不下所有的对象,老年代提供了担保,多余装不下的对象直接进入老年代。
现在的新生代基本都是使用这种改进后的“标记-复制”算法。
4.标记-整理算法
老年代可选用“标记-清除”算法和“标记-整理”算法。
算法:
- 标记:标记过程仍然与“标记-清除”算法一样。
- 整理:清除时不是直接清除,而是让所有存活对象向内存空间一侧移动,然后清除边界外的内存。
算法示意图:
是否需要标记整理是需要考虑的:因为老年代有大量的对象存活,而且移动对象时,用户应用程序必须暂停,即“Stop the world”。不过虽然整理耗时,但是加快了整体的访问速度(因为没有复杂的内存访问方式),所以虽然加大了延迟,但总体吞吐量是提升的。
但是如果使用标记清除,不移动的话,内存碎片问题必须用更复杂的内存分配器访问器来解决(类似磁盘维护一个分区空闲分配链表),这个会降低内存访问的效率,这样虽然降低了延迟(因为不用Stop the world),但是也降低了吞吐量(因为内存访问更耗时了,总体上是更耗时的)。
基于以上考虑,HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的。
还有一种“和稀泥式”解决方案:平时多数时间都采用标记-清除算法,内存碎片太多,就用用标记-整理算法收集一次。基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
另外标记整理算法不一定要“Stop the world”:最新的ZGC和Shenandoah收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行,稍后将会介绍这种收集器的工作原理。然后“标记-清除”算法其实也是需要停顿用户线程来进行标记、清除,只不过这个时间开销很小。
3.4 Hotspot算法细节
1.根节点枚举
根节点枚举就是从GC Roots开始寻找所有对象。另外也可以通过准确式内存管理收集对象的信息来快速完成枚举。
做为GC Roots的固定节点主要是全局性的引用(例如常量或类静态属性)和执行上下文(例如栈帧中的本地变量表)。
所有的GC收集器,在“枚举根节点”这一步都需要暂停用户线程(在一个快照中),因为需要保证分析过程中,根节点集合的对象引用关系不要变化。不过耗时最长的“查找引用链”已经可以做到和用户线程并发。
由于主流的JVM都使用的准确式内存管理,即指虚拟机可以知道内存中某个位置的数据具体是什么类型。因此JVM可以直接得到哪些地方存放着对象引用,这样就无需再去一个个从方法区等GC Roots来寻找对象。
Hotspot实现准确式内存管理的方式:使用一组称为OopMap(普通对象指针Ordinary Object Pointer,OOP)的数据结构。类加载动作完成后, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来。此外,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。
OopMap的概念:
在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。
可以把oopMap简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。
如下是String.hashCode()方法编译后的本地代码示例。可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用,有效范围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止:
[Verified Entry Point]
0x026eb730: mov %eax,-0x8000(%esp)
…………
;; ImplicitNullCheckStub slow case
0x026eb7a9: call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=142}
; *caload
; - java.lang.String::hashCode@48 (line 1489)
; {runtime_call}
0x026eb7ae: push $0x83c5c18 ; {external_word}
0x026eb7b3: call 0x026eb7b8
0x026eb7b8: pusha
0x026eb7b9: call 0x0822bec0 ; {runtime_call}
0x026eb7be: hlt
2.安全点
有一个问题,就是导致OopMap内容变化(也就是引用关系变化)的指令非常多,如果每一条指令都生成对应的OopMap的话,会需要很多内存。
那么我们只有在特定的位置生成OopMap了,这些位置就是安全点(Safepoint)。那么也就是说,只能在安全点才能进行GC,因为只有安全点有OopMap信息,枚举需要根据OopMap来判断哪些变量是引用类型,从而快速找到对象。
怎么选取安全点位置呢?安全点不能太多也不能太少,太多内存受不了,太少则GC等待时间会过长。所以选取的标准是,选在“程序长时间执行”的地方(尽量避免GC长时等待)。指令执行时间其实都很短,那么长时间执行的话只可能是指令序列复用的地方,例如方法调用、循环跳转、异常跳转等。
GC时如何等待程序到达安全点呢?有两种方式:
- 抢先式中断:在GC时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程,让它一会再重新中断,直到跑到安全点上。现在几乎没有JVM采用这种方式。
- 主动式中断:在GC时,更改一个标志位。各个线程不断轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的位置设在安全点、创建对象的地方、以及其他需要在Java堆上分配内存的地方。这样做是为了在分配内存前先GC,从而空出需要的内存。
轮询是个频繁的操作,那么如何保证轮询高效?Hotspot使用了内存保护陷阱的方式,并把轮询操作精简到只有一条汇编指令。具体实现:简单来讲,就是标志位改变时,把某内存页设置为不可读,然后用线程去读这个内存页,那么此时线程就会在预先注册的异常处理器中挂起,从而用一条指令实现了轮询和线程中断的触发。也就是下例中的test指令:
0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460}
; *invokeinterface size
; - Client1::main@113 (line 23)
; {virtual_call}
0x01b6d62c: nop ; OopMap{[60]=Oop off=461}
; *if_icmplt
; - Client1::main@118 (line 23)
0x01b6d62d: test %eax,0x160100 ; {poll}
0x01b6d633: mov 0x50(%esp),%esi
0x01b6d637: cmp %eax,%esi
3.安全区域
安全点可以让正在执行的程序挂起,那么没有在执行的程序呢?比如线程在Sleep,此时它无法响应虚拟机的中断请求,无法自己去安全点中断挂起自己。
此时就用到了安全区域。安全区域是指在这一段代码片段之中,引用关系不会发生变化。那么这个区域里任何地方都可以GC。
当用户线程进到安全区域,它会标识自己已进入安全区。此时GC看到它已经声明自己进入安全区,就不会去管它了。而如果线程要离开安全区,那么它需要检查JVM是否已经完成根节点枚举,如果还没完成,它就要一直等待,直到收到可以离开安全区域的信号。
4.记忆集与卡表
为了解决跨代引用,GC收集器会在新生代建立记忆集(Remembered Set),避免把整个老年代加入GC Roots扫描范围。除了新生代和老年代,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题。因此我们要研究下记忆集的原理。
如何实现记忆集?
记忆集最简单的实现方式就是,把所有非收集区含跨代引用的对象存到数组里。不过这样开销太大,JVM只需要通过记忆集判断某一块非收集区有没有跨代引用,并不需要知道指针细节。所以我们粒度可以再粗一点。下面是一些精度:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。
如何实现卡变?
卡表最简单的形式可以只是一个字节数组,Hotspot就是这样实现的,以下是实现代码:
// 地址右移9位,相当于总地址数除以512(2的9次幂)。也就是一个卡页有512字节。
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如 果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块[4],如图所示
通常一个卡页中不止一个对象。只要卡页内有对象的字段有跨代引用,那么卡表对应的数组元素就标识为1。GC时,把变脏的内存加入GC Roots中扫描即可。
5.写屏障
卡表实现了记忆集,缩小了GC Roots扫描范围。那么卡表元素如何维护呢?例如它们何时变脏、谁来把它们变脏等?
变脏当然是引用类型字段赋值的时候就变脏。那么此时怎么更新卡表呢?如果是解释执行的字节码,JVM负责每条字节码指令的执行,随时可以介入。如果是编译执行的场景中呢?此时执行的是机器指令流。那么必须提供一个手段,在机器码层面,加入维护卡表的动作。
HotSpot通过写屏障来实现编译执行时卡表的维护。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP环形切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。在Hotspot虚拟机中,G1收集器出现之前,其他收集器都只用到了写后屏障。
卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。什么是“伪共享”(False Sharing)问题?现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。解决方法是增加一次卡表标记判断,卡表元素未被标记过时才将其标记为变脏:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
6.并发的可达性分析
可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。
目前确定GC Roots的时间已经是短暂且固定的(OopMap等优化手段),但从GC Roots往下遍历对象的时间则会随着堆的容量变大而增长。那么如何缩短这段时间?
我们先搞清楚,为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了解释清楚这个问题,我们引入三色标记来作为推导工具。我们把遍历过程中遇到的对象,分成三种颜色:
- 白色:表示对象还没被垃圾收集器访问过。显然在刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
其实整个可达性分析的扫描过程,就是把所有对象从白变灰再变黑的过程,灰色是扫描交界点。
此时如果用户线程和扫描并发执行,会产生两种结果:
- 是把原本消亡的对象错误标记为存活:这个是可以接受的,只是产生了少量浮动垃圾。
- 把原本存活的对象错误标记为已消亡:这个无法接受。
以下是示意:
Wilson于1994年在理论上证明了,只有同时满足以下两个条件,才会产生“对象消失”问题:
- 黑色新增了指向了白色的连线:赋值器插入了一条或多条从黑色对象到白色对象的新引用;简单说,就是黑色新增了白色节点。
- 灰色指向白色的连线被干掉了:赋值器删除了全部从灰色到白色对象的直接或间接引用。简单说,就同时灰色删除了白色节点。
例子就是上图的例子。其实很好理解:1.黑色指向白色,由于黑色不会再被扫描,因此白色没有机会通过黑色加入保留区。2.灰色指向白色的线又被全部干掉了,这下白色彻底没有机会进入保留区了。
那么要解决对象消失问题,只需破坏上面两个条件中的一个就行,包括两种解决方案,选一个就行:
- 增量更新:破坏第一个条件,黑色指向白色后,它自己要变回灰色。具体解释,当黑色对象要插入新的指向白色对象的引用关系时,就将这个新插入的引用关系记录下来,等并发扫描结束之后,再以这些记录过的引用关系中的黑色对象为根,重新扫描一次。也就时前面说的黑色变回灰色,第二次扫描当然会被扫描一次。
- 原始快照:破坏第二个条件,只按原始快照扫描,后面删除的线都不认。具体解释,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用关系记录下来,在并发扫描结束之后,再以这些记录过的引用关系中的灰色对象为根,重新扫描一次。也就是前面说的,灰色删除了还是没变色,第二次扫描当然会被扫描一次。
以上对引用关系记录的插入或是删除,虚拟机都是通过写屏障来实现记录操作的。
以上两种方式都有使用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。
至此,HotSpot机如何发起内存回收、如何加速内存回收,以及如何保证回收正确性等问题已讲完。下文讲是虚拟机如何具体地进行内存回收,因为这个是由GC收集器决定的。
3.5 经典垃圾收集器
收集算法是内存回收的方法论,而垃圾收集器就是内存回收的实践者。
《Java虚拟机规范》中对垃圾收集器应该如何实现并没有做出任何规定,因此完全看不同厂商、版本的实现。
这里“经典”的意思是,在JDK 7 Update 4之后(在这个版本中正式提供了商用的G1收集器,此前G1仍处于实验状态)、JDK 11正式发布之前,OracleJDK中的HotSpot虚拟机[1]所包含的全部可用的垃圾收集器。
使用“经典”二字是为了与几款目前仍处于实验状态,但执行效果上有革命性改进的高性能低延迟收集器区分开来。
不存在万能的收集器,只是根据不同的场景选取最合适的收集器。后文着重讲解CMS和G1这两个复杂但广泛使用的GC收集器。
以下是Hotspot中目前最常用的7种收集器,分为年轻代和老年代,连线代表可搭配使用,JDK9废弃了两个搭配:
1.Serial收集器
最基础GC收集器,新生代收集器,JDK1.3前Hotspot新生代唯一能选择的GC。
看名字就知道,它是一个单线程工作的收集器。它的Serial强调的不仅仅是单线程,更重要的是它进行GC时必须是连续的,也就是必须暂停其他所有工作线程,直到它收集结束。
收集示意图如下,新生代“标记-复制”算法、老年代“标记-整理”算法,都需要暂停用户线程:
从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC ,HotSpot团队一直在努力。
Serial收集器现在还在广泛运用,现在它依然是HotSpot运行在客户端模式下的默认新生代收集器。原因是因为它简单高效,没有线程交互的开销,它也是消耗额外内存最小的GC。而且现在微服务广泛使用的前提下,分配给虚拟机管理的内存一般来说并不会特别大,收集一两百兆的新生代,GC的停顿时间完全可以控制在十几、几十毫秒。
Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
2.ParNew收集器
新生代收集器。Serial收集器的多线程版本。除使用多线程GC之外,其余行为和控制参数与Serial完全一致。
示意图如下:
它是很多运行在服务端模式下的HotSpot虚拟机首选的新生代收集器。很重要的原因是,除了Serial收集器外,目前只有它能与CMS 收集器配合工作。
CMS是JDK1.5推出的划时代的老年代收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。ParNew是激活CMS后默认的新生代收集器。
G1是一个面向全堆的收集器,它不再需要其他新生代收集器的配合工作。JDK 8 Update 40后成熟,官方希望它替代ParNew加CMS收集器的组合。自JDK 9开始,ParNew只能和CMS搭配使用。
ParNew收集器在单核情况下肯定干不过Serial,不过多核情况下就可以充分利用系统资源。它默认开启的收集线程数与处理器核心数量相同,可以通过参数调节。
另外提一下在本书谈论垃圾收集器的情景下,并发和并行的区别。因为后续会多次提到这两个概念:
并行(Parallel):描述多条垃圾收集器线程间的关系。说明它们在协同工作,通常默认此时用户线程处于等待状态。
并发(Concurrent):描述的垃圾收集器线程与用户线程之间的关系。说明此时它们都在运行。用户线程并未被冻结,应用程序仍然能响应服务请求。不过由于GC占用了资源,程序处理的吞吐量会受到一定影响。
3.Parallel Scavenge收集器
是一款新生代收集器,基于标记-复制算法,也是并行收集的多线程收集器。
从表面上看和ParNew非常相似。它又被称为“吞吐量优先收集器”。它具有两个鲜明的特点:
- 关注点是可控制的吞吐量,不太关心用户线程的停顿时间。
- 可以自适应调节。
1.吞吐量
CMS等收集器关注的是GC时减少用户线程停顿时间,更适用于交互多的程序。而Parallel Scavenge关注的则是吞吐量可控,因此可以最高效率地利用处理器资源,适合在后台运算而不需要太多交互的分析任务。
吞吐量的概念:
吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值, 如下图:
Parallel Scavenge提供了两个参数用于精确控制吞吐量:
- 控制最大垃圾收集停顿时间:-XX:MaxGCPauseMillis
- 直接设置吞吐量大小:-XX:GCTimeRatio
对于-XX:MaxGCPauseMillis,调小它可能会增加垃圾回收的频率。例如原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。这样虽然停顿时间变小了,但吞吐量也会下降。
-XX:GCTimeRatio则是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
2.自适应调节
Parallel Scavenge还提供了自适应调节:
- -XX:+UseAdaptiveSizePolicy:是否开启自适应调节
该参数被激活后,虚拟机会根据现有的堆大小、设置的最大停顿时间或吞吐量优化模板(就是前面的两个参数)、监控的性能信息,来自动动态地调节各个参数细节。如新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等。
4.Serial Old收集器
Serial收集器的老年代版本,单线程收集器,使用标记-整理算法。
供客户端模式下的HotSpot虚拟机使用。
在服务端模式下,它也可能有两种用途,后续会更具体的讲:
- 在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用。
- 作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
收集器原理如下图:
5.Parallel Old收集器
Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法。
JDK 6时开始提供。JDK5之前,Parallel Scavenge的老年代都只能用SerialOld配合,吞吐量上不去,比较鸡肋。Parallel Old的出现终于解决了这种尴尬。
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge + Parallel Old。
工作原理如下图:
6.CMS收集器
CMS(Concurrent Mark Sweep)是老年代收集器,目标是获取最短回收停顿时间。
基于“标记-清除”算法。(从名字就可以看出来:Mark Sweep)
跟其他收集器比起来,CMS复杂一些。整个过程分为4个步骤:
- 初始标记(CMS initial mark): 仅仅标记一下GC Roots能直接关联到的对象,速度很快。用户停顿。
- 并发标记(CMS concurrent mark) :耗时较长,但无需停顿,可与用户线程并发运行。
- 重新标记(CMS remark):修正并发标记期间,因用户程序继续运作而导致标记变动的那部分对象。(黑灰白理论)用户停顿。
- 并发清除(CMS concurrent sweep) :清理删除掉标记阶段判断的已经死亡的对象,标记清除,无需移动存活对象。
需要停顿的初始标记和重新标记的停顿时间都很短,而耗时最长的并发标记和并发清除阶段都可以与用户线程一起工作。所以CMS的优点是并发收集,低停顿。
示意图:
缺点:
- 并发阶段会占用线程:占用线程会让用户程序变慢。默认CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是4核以上不超25%。
- 无法处理“浮动垃圾”及“并发失败”:1.并发阶段用户程序还在产生垃圾,新增的浮动垃圾无法处理,只能留到下一次处理。2.并发阶段要留一部分空间给用户程序使用,所以CMS不能等到老年代快满了才进行回收。如果没有足够内存供用户程序使用,那么就“并发失败”,此时启用后备方案,冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样会有很长的停顿时间。参数-XX:CMSInitiatingOccu-pancyFraction可以设置CMS触发的老年代百分比,默认老年代68%激活回收。根据具体环境权衡。
- 内存碎片问题:CMS为了并发清除阶段不移动存活对象,采用的是“并发-清除”算法,长时间下去会产生大量内存碎片,无法找到足够大的连续空间来分配当前对象,从而触发Full GC(即STW,整个Java堆回收)。有两个参数可以用来调节,JDK9已废弃:1.
-XX:+UseCMS-CompactAtFullCollection
开关参数,默认开启,用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理,此时必须移动存活对象,无法并发。2.数-XX:CMSFullGCsBefore-Compaction
参数,要求CMS如果N次没执行FullGC,下一次进入FullGC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。
7.Garbage First收集器
简称G1。里程碑式的成果,开创了面向局部收集的设计思路和基于Region的内存布局形式。
JDK 8 Update 40后全面成熟。
面向服务端应用,期望用来替换JDK5的CMS。
JDK 9发布后,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。
G1的设计目标是“停顿时间模型”(Pause Prediction Model),即支持指定在M毫秒的时间片内,GC上花费的时间大概率不超过N毫秒。
G1设计思想的转变:面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。
具体设计思路
- 基于Region的堆内存布局:不再用固定大小以及固定数量的分代来划分区域。把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理。
- 大对象的处理:Region中还有一类特殊的Humongous区域,专门用来存储大对象。超级大对象用N个连续的Humongous Region来存储。
- 根据Region来实现新生代和老年代:新生代和老年代不再是固定的,它们都是一系列区域(不需要连续)的动态集合。
- 根据价值回收垃圾:将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍。G1收集器会跟踪各个Region里面的垃圾堆积的“价值”(回收所得空间及所需时间的经验值)大小,维护一个优先级列表,然后根据用户设定的允许停顿的时间(参数-XX:MaxGCPauseMillis,默认200ms),优先处理回收价值收益最大的那些Region。
示意图如下,E、S、H分别是Eden、Survivor、Humongous:
需要解决的问题
- 跨Region引用对象如何解决:使用记忆集。每个Region都维护有自己的记忆集,记录别的Region 指向自己的指针,以及这些指针的卡页范围。由于每个Region都有,因此内存占用很高,经验值可以达到Java堆的10%-20%。G1的记忆集是双向的,在存储结构的本质上是一 种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)。
- 并发标记阶段如何隔离收集线程与用户线程:1.先保证用户线程改变对象引用关系时不会打破GC的对象图结构: CMS收集器采用增量更新算法实现,而G1 收集器则是通过原始快照(SATB)算法。2.再保证GC线程能留足够的内存给用户线程来分配新对象:G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配。新对象在这两个指针位置以上,G1默认它们存活,不纳入回收范围。3.内存回收的速度赶不上内存分配的速度怎么办:冻结用户线程执行,Full GC,“Stop The World”。
- 怎样建立起可靠的停顿预测模型:统计的信息只能代表最近的Region的平均状态。通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益,这个模型怎么建立?
G1具体的运作过程
大致可划分为以下四个步骤,有点像CMS:
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让用户线程并发时有内存分配对象。用户停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,耗时较长,不过可以并发。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。(原始快照式,为何还要再次标记?因为灰色没变色)。用户停顿。
- 筛选回收(Live Data Counting and Evacuation):更新Region的统计数据,对对各个Region的回收价值和成本进行排序,根据用户的期望停顿时间,选取N个Region构成回收集,把这些Region中存活的对象复制到空Region中,清空旧Region。由于操作涉及存活对象的移动,是用户停顿。。
上述阶段,除了并发标记,其他阶段都要暂停。不过G1的目前并不是最低延迟,而是是在延迟可控的情况下获得尽可能高的吞吐量,所以还是合理的。
由于可以设置不同的期望停顿时间,所以G1可以在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
从G1开始,最先进的垃圾收集器的设计理念都变更为追求“回收的内存能够满足应用的内存分配速率即可”(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。
G1和CMS的比较
- G1没有内存碎片:G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,这两种算法都意味着G1运作期间不会产生内存空间碎片。而且G1的内存分配也更灵活。
- G1的内存占用太高:但G1的卡表实现更为复杂,双向卡表,而且每个Region都要有一个卡表。这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间。CMS则只需要一个卡表,且从老年代指向新生代是单向的。
- G1消耗更多运算资源:CMS用写后屏障来更新维护卡表;而G1也用写后屏障来维护卡表(双向的)。为了实现原始快照搜索(SATB)算法,还需要用写前屏障来跟踪并发时的指针变化情况。这样,虽然SATB避免了CMS在最终标记时的长时间停顿,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担(写前屏障跟踪变化)。
目前按经验来说,在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间。
总结
7个经典收集器主要配对:
- Serial - SerialOld, ParNew - CMS, Parallel Scavenge - Parallel Old(吞吐量优先,用于服务端多),G1(内存资源占用高)
- CMS- SerialOld:并发阶段要留一部分空间给用户程序使用,内存不够时,STW,SerialOld给CMS兜底。
- Parallel Scavenge - SerialOld:之前没有 Parallel Old,只有用 SerialOld 将就。
更新时间线及总结:
- JDK1.3:Serial -> Serial Old
- ParNew -> CMS(目前CMS的唯一选择,G1后续要取代它们)
- Parallel Scavenge -> Serial Old (此时还没有Parallel Scavenge)
- JDK1.5:Parallel Scavenge -> Parallel Old
- 此外,Serial Old作为CMS并发回收失败的兜底保险。
- 新生代用的都是“标记-复制”。老年代除了CMS是标记清除(碎片太多不得已时“标记-整理”一次),其他用的都是“标记-整理”。G1整体是“标记-整理”,局部是“标记-复制”。
3.6 低延迟垃圾收集器
衡量收集器好坏最重要的三个指标:内存占用、吞吐量、延迟。三者共同构成了一个“不可能三角,最多可以同时达成其中两项。
现在硬件越来越强,我们可以提供更多的内存。内存的提升,可以提升吞吐量(因为降低了收集器运行时对应用程序的影响),但是却对延迟有负面影响(因为要回收的内存更多了)。
各收集器的停顿情况:下图浅色表示挂起用户线程,深色表示并发。可以看到CMS和G1之前都有STW的停顿。CMS和G1使用增量更新和原始快照,标记可以并发,但是标记之后的处理还有问题。CMS用“标记-清除”,最后还是会因为内存碎片过多产生STW的Full-GC。G1虽然按更小粒度回收,但局部“标记-复制”时还是需要暂停。
注意到最后两款GC收集器,几乎不需要停顿。只有初始标记、最终标记阶段有短暂的、基本上固定的停顿。实际上,它们在可管理的堆容量下(ZGC是4T),可以做到停顿10ms以内。他们目前还处于实验状态,官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。
1.Shenandoah收集器
1.总述:与G1比较
第一款不由Oracle团队开发的HotspotGC收集器。OracleJDK(商业版)不支持,OpenJDK(开源版)才支持。由RedHat开发,2014年贡献给OpenJDK,OpenJDK12开始支持。
Shenandoah代码上大量借鉴和共享了G1。
Shenandoah与G1的相同点:
- 也是使用基于Region的堆内存布局。
- 同样有着用于存放大对象的Humongous Region。
- 在初始标记、并发标记等许多阶段的处理思路上 都高度一致。
- 默认的回收策略也同样是优先处理回收价值最大的Region。
Shenandoah与G1明显的不同:
- 支持并发回收。而G1回收阶段只支持并行,需要挂起用户线程。用“Brooks Pointers”转发指针解决并发回收带来的引用更新问题。
- 用“连接矩阵”(Connection Matrix)的全局数据结构取代了G1消耗大量内存和计算资源的记忆集,用来记录跨Region的引用关系。
- 默认不使用分代收集。
连接矩阵:由于连接矩阵是全局的,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题(见3.4.4节)的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记。如下图所示:
2.工作阶段
Shenandoah收集器的工作过程大致可以划分为以下九个阶段:
- 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象。需要STW。但停顿时间只与GC Roots的数量相关,与堆大小无关。
- 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象。该阶段是并发,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
- 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region构成一组回收集(Collection Set)。会有一小段短暂的停顿。
- 并发清理(Concurrent Cleanup):清理整个区域都可回收的Region。(即一个存活对象都没有找到的Region,被称为Immediate Garbage Region)。
- 并发回收(Concurrent Evacuation):该阶段是与之前HotSpot中其他收集器的核心差异。并发的把回收集里的存活对象复制到其他未使用的Region中。难点在于复制的时候老对象也在被访问,复制完成后,如何更新引用地址到新对象。即如何引用更新?这个问题Shenandoah通过读屏障和被称为“Brooks Pointers”的转发指针来解决。该阶段运行时间取决于回收集大小。
- 初始引用更新(Initial Update Reference):该阶段实际上并未做什么具体的处理。设立它只是为了建立一个线程集合点,确保前面并发回收阶段中所有的收集器线程都已完成对象移动任务。该阶段更新时间很短,会产生一个非常短暂的停顿。
- 并发引用更新(Concurrent Update Reference):真正开始进行引用更新。该阶段是并发的,时间长短取决于内存中涉及的引用数量。与并发标记不同,并发引用更新无需要沿着对象图搜索,只需按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
- 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用(取代记忆集的连接矩阵中的?)。该阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
- 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再把这些处理完的Region回收即可。
以上最关键的就是三个并发阶段:并发标记、并发回收、并发引用更新。
回收的示意图如下:
- 黄色:被选入回收集的Region。
- 绿色:表还存活的对象。
- 蓝色:用户线程可以用来分配对象的内存Region。
注意到并发标记阶段如何找出回收对象确定回收集,并发回收阶段如何移动回收集中的存活对象,并发引用更新阶段如何将指向回收集中存活对象的所有引用全部修正。
3.Brooks Pointer
Brooks Pointer是Shenandoah用以支持并行整理的核心概念。Brooks是发明人的名字。
1.如何实现对象移动与用户程序的并发?
- 内存保护陷阱:老的方案。在被移动对象原有的内存上设置内存保护陷阱,一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。缺点是:需要OS的支持,而且会导致用户态频繁切换到核心态,代价很大。
- Brooks Pointer:Brooks提出的新方案。在原有对象布局结构的最前面统一增加一个新的引用字段,正常情况下,指向自己。并发复制时,收集器把它改到指向新对象。
2.Brooks Pointer的实现:
如上图,在对象头前面统一加了转发指针,平时指向自己。类似于早期Java虚拟机使用过的句柄定位。差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。
缺点是每次对象访问会带来一次额外的转向开销(尽管已经优化到1条汇编)。不过比起“内存陷阱”用户态和核心态的重量操作,性能还是好了很多。
当对象拥有一份新的副本时,只需要修改旧对象上转发指针的引用位置,使其指向新对象,即可完成访问转发。
3.需要注意两个问题:
- 收集器和用户程序的多线程竞争问题。
- 写屏障执行频率过高。
a.多线程竞争问题:
如果收集器与用户程序并发读对象,那么读到新老对象都没问题。如果并发写,那么一定必须保证写操作只能发生在新复制的对象上。也就是说,必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功。Shenandoah收集器是通过CAS来实现的。
下图是收集器与用户程序并发示意:
三个操作同时进行,必须保证2不在1和3直接执行:
- 收集器线程复制了新的对象副本;
- 用户线程更新对象的某个字段;
- 收集器线程更新转发指针的引用值为新副本地址。
b.执行频率的问题
对象头上的Brooks Pointer来保证并发时原对象与复制对象的访问一致性。但是“对象访问”是包含很多操作的,如对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等。要覆盖全部对象访问操作,Shenandoah不得不同时设置读、写屏障去拦截。
为了实现Brooks Pointer,Shenandoah在读、写屏障中都加入了额外的转发处理。写屏障使用频率低一点,前文已经用来维护卡表、实现并发标记等。但是在各种代码中读屏障使用的频率是非常高的,必须很谨慎,不允许任何的重量级操作。开发者也意识到数量庞大的读屏障会带来问题,准备在JDK13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier)的实现,即内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写。这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗
4.Shenandoah在实际应用中的性能表现
停顿时间比其他几款收集器确实有了质的飞跃,吞吐量方面则出现了很明显的下降(总运行时间最长)。以下图表体现了Shenandoah的弱项(高运行负担使得吞吐量下降)和强项(低延迟时间)。
2.ZGC收集器
JDK11新加入的实验性质的收集器。
和G1一样,目标是望在尽可能对吞吐量影响不太大的前提下[2],实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。不过实现思路和G1完全不一样,ZGC大量解决了C4和PGC的思路。
ZGC的主要特征:ZGC收集器是一款基于不同大小的Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。下面进行详细讲解。
1.内存布局
ZGC也采用基于Region的堆内存布局。特点是:ZGC的Region具有动态性:动态创建和销毁,动态的区域容量大小。在x64硬件平台下,ZGC的Region分为大中小三种容量:
- 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
- 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
- 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象(所以大型Region可能小于中型Region)。大型Region在ZGC的实现中是不会被重分配。
以下是堆内存布局示意图:
2.染色指针技术
Shenandoah使用转发指针和读屏障来实现并发整理,ZGC也是用的读屏障,不过用法比较巧妙。
ZGC的标志性设计是,采用染色指针技术(Colored Pointer)。
a.为什么需要染色指针:
之前,如果我们要在对象上存储一些额外的、只供收集器或者JVM自己使用的数据,一般会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等。这种方式会有一个问题,就是对象是可能被移动的,有可能访问不到。如果我们想不依赖对象,从其他地方拿到一些对象的信息呢?这个想法是有用的,比如并发标记阶段的白灰黑标记,对象间的关系,Serial直接访问对象头是比较重的,G1直接使用单独的数据结构BitMap来记录,这些都比较重。而ZGC则通过染色指针来实现,它直接把标记信息记在引用对象的指针上。直接通过指针就能确定对象引用图,效率非常高。
b.染色指针是什么:
染色指针是一种直接将少量额外的信息存储在指针上的技术。
我们知道64位系统,基于成本和性能的考虑,一般不会全部支持64位的寻址。例如,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间。那么,多出来的位数其实可以用来做其他事情。
Linux高18位不用来寻址。不过ZGC没有动这18位,而是在剩下的46位指针宽度中,取高4位来存储四个标志信息,所以ZGC只支持64/16=4TB的内存管理。这高4位可以用来存储引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问等。
以下是示意图:
c.染色指针的三大优势
虽然ZGC只能管理4TB内存,不支持32位,不能压缩指针等。不过有以下3大优势:
- 一旦某个Region的存活对象被移走之后,该Region就能立即释放。而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。Shenandoah还需要等,而ZGC理论上只要还有一个空闲Region,就能完成收集。
- 可大幅减少在GC过程中内存屏障的使用数量。写屏障一般是为了记录对象引用的变动情况,现在记录变动记录到了指针上,就不在需要写屏障。ZGC目前未使用任何写屏障(目前不支持分代用不到),只使用了读屏障。
- 以后还很有扩展潜力:目前LInux还有前18位没有使用,可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
d.硬件和OS如何支持染色指针
JVM只是一个普通进程,何以操作指针?
在Solaris/SPARC平台上比较容易,为SPARC硬件层面本身就支持虚拟地址掩码,设置之后其机器指令直接就可以忽略掉染色指针中的标志位。
在x86-64平台上没有这种特定支持,只能采取其他间接的实现方式。方案是虚拟内存映射。
e.虚拟内存映射如何实现染色指针?
在远古时代,x86计算机所有进程用同一块物理内存,如果一个进程污染了另一个进程的内存,只能整个系统复位。为此,从Intel 80386处理器开始,提供了“保护模式”用于隔离进程。此时已不同于之前实模式下的物理内存寻址了,处理器会使用分页管理机制把线性地址空间和物理地址空间分别划分为大小相同的块,这样的内存块被称为“页”(Page)。通过在线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换。
不同层次的虚拟内存到物理内存的转换关系可以在硬件层面、操作系统层面或者软件进程层面实现。Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了。如图所示,原理很清晰:
3.工作阶段
主要是以下4个并发阶段,并发阶段间的停顿,如初始化GC Root的初始标记等,和签名G1一样,不再赘述。
- 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图的阶段。前后有初始标记、最终标记很小的停顿。ZGC的并发标记是在指针上做的,会更新染色指针中的Marked 0、Marked 1标志位。
- 并发预备重分配(Concurrent Prepare for Relocate):根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。标记过程是针对全堆的。
- 并发重分配(Concurrent Relocate):把重分配集里的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。指针的自愈:从指针就可以知道某个对象是否处于重分配集。此时用户线程访问对象,根据指针判断出该对象处于重分配集,那么这次访问就会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象,同时修正更新该引用的值,让它指向新对象。也就是说,由于有染色指针的存在,只会在第一次访问旧对象时进行转发,后续都不会,只慢一次,而Shenandoah是每次都慢。立即释放:自愈的好处还有,一旦重分配集里某个Region的存活对象都复制完毕后,这个Region就可以立即释放(但是转发表还得留着),后续访问可以全部通过“自愈”访问到新对象。
- 并发重映射(Concurrent Remap):修正整个堆里指向重分配集里面旧对象的所有引用。ZGC很巧妙地把这部分工作合并到了下一次GC的并发标记阶段里执行,因为它们都要遍历所有对象。这样节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放了。
ZGC的设计理念与Azul System公司的PGC和C4收集器一模一样。
4.优缺点
缺点:
- 能承受的对象分配速率不会太高:ZGC是全堆扫描。如果它扫描的时间很长,而这段时间里应用的对象分配速率很高,新建大量对象,那么这些新对象很难进入当次收集的标记范围,一般都是全部当做存活对象。那么此时就会产生大量浮动垃圾,因为这些新对象大多数都是朝生夕灭的。目前的解决方法只能是加大堆容量。未来还是需要引入分代才能从根本上解决。
优点:
- ZGC没有使用记忆集:G1需要通过写屏障来维护记忆集,才能处理跨代指针,从而实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担。
- ZGC没有分代:给用户线程带来的运行负担小。连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障。
ZGC还有一个优点是支持“NUMA-Aware”的内存分配。NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。多核的情况下,原本在北桥芯片中的内存控制器被集成到了处理器内核中,每个内核都有自己内存管理器所管理的内存,如果要访问被其他处理器核心管理的内存,就必须通过Inter-Connect通道来完成,这要比访问处理器的本地内存慢得多。在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。
ZGC的性能比G1还厉害。吞吐量超过G1,达到以吞吐量见长的Parallel Scavenge的99%。低延迟则直接是拉开Parallel Scavenge、G1两个数量级,停顿很短很短。
ZGC作为一款商用GC收集器,由于Oracle调整许可证授权的契机,在JDK11把所有商业特性都开源给了OpenJDK,让传说中的“Azul式的垃圾收集器”一下子飞入寻常百姓家。它成熟之后,将会成为服务端、大内存、低延迟应用的首选收集器的有力竞争者。
3.7 选择合适的垃圾收集器
“垃圾收集器”的本质应该是“自动内存管理子系统”。
GC收集器除了GC,还有其他工作需要完成:堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等。至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持。
1.Epsilon收集器
从JDK 10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监控等子系统的关系,RedHat提出了垃圾收集器的统一接口,Epsilon是这个接口的有效性验证和参考实现,同时也用于需要剥离垃圾收集器影响的性能测试和压力测试。
也就是说,Epsilon是一个不收集垃圾的GC收集器。
这个实现是有意义的,因为现在Java往微服务、无服务方向发展,对短时间、小规模的服务形式来说,只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。
2.收集器如何选型
一般如何选择适合自己的GC收集器呢?可以从以下三点考虑:
- 应用程序的主要关注点是什么:是吞吐量(充分压榨服务器性能尽快算出结果),还是低延迟(web访问等为主),还是低内存占用(客户端或嵌入式应用内存很少)。
- 基础设施怎么样:系统架构是x86-32/64、SPARC还是ARM/Aarch64?处理器多少?内存多少?操作系统是啥?等等。
- 什么版本的JDK:JDK的发行商和版本号,JDK对应《Java虚拟机规范》哪个版本等。
3.虚拟机及垃圾收集器日志
当我们要处理Java虚拟机内存问题时,就要知道怎么分析JVM和GC收集器的日志了。
每个收集器的日志格式都可能不一样,随开发者心情来定。。
在JDK 9以前,HotSpot没有提供统一的日志处理框架,虚拟机各个功能模块的日志开关分布在不同的参数上。JDK9以后,才把所有功能的日志都收归到了“-Xlog”参数上。
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
最关键的参数是选择器(Selector),它由标签(Tag)和日志级别(Level)共同组成。
支持的Tag如下,收集器是gc:
add,age,alloc,annotation,aot,arguments,attach,barrier,biasedlocking,blocks,bot,breakpoint,bytecode,census,class,classhisto,cleanup,compaction,comparator,constraints,constantpool,coops,cpu,cset,data,defaultmethods,dump,ergo,event,exceptions,exit,fingerprint,freelist,gc,hashtables,heap,humongous,ihop,iklass,init,itables,jfr,jni,jvmti,liveness,load,loader,logging,mark,marking,metadata,metaspace,method,mmu,modules,monitorinflation,monitormismatch,nmethod,normalize,objecttagging,obsolete,oopmap,os,pagesize,parser,patch,path,phases,plab,preorder,promotion,protectiondomain,purge,redefine,ref,refine,region,remset,resolve,safepoint,scavenge,scrub,setting,stackmap,stacktrace,stackwalk,start,startuptime,state,stats,stringdedup,stringtable,subclass,survivor,sweep,system,task,thread,time,timer,tlab,unload,update,verification,verify,vmoperation,vtables,workgang
日志级别有6个,类似SLF4j:
Trace,Debug,Info,Warning,Error,Off
还可以使用修饰器(decorator)来要求每行日志输出都附加上额外的内容,支持附加在日志行上的信息包括:
time:当前日期和时间。
uptime:虚拟机启动到现在经过的时间,以秒为单位。
timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。 uptimemillis:虚拟机启动到现在经过的毫秒数。
timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。
uptimenanos:虚拟机启动到现在经过的纳秒数。
pid:进程ID。
tid:线程ID。
level:日志级别。
tags:日志输出的标签集。
如果不指定,默认值是uptime、level、tags这三个,此时日志输出类似于以下形式:
[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s
以下是全部在JDK 9中被废弃的日志相关参数及它们在JDK9后使用-Xlog的代替配置形式:
4.垃圾收集器参数总结
虚拟机垃圾收集相关的常用参数,供参考:
3.8 实战:内存分配与回收策略
Java技术体系的自动内存管理,最根本的目标是两个:自动给对象分配内存以及自动回收分配给对象的内存。
自动回收前面已经讲得很详细。下面我们探讨对象分配。
对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配,后续11章会讲)。
最重要的是验证的思路,要验证的规则反而不重要。验证的思路基本上就是,设定好堆等参数,设置参数打印堆栈信息和GC收集情况。
限于篇幅,验证过程不再给出。具体可以参看书本。以下是书本中验证的规则,使用的GC收集器是Serial加Serial Old:
- 对象优先在Eden分配。
- 大对象直接进入老年代 。
- 长期存活的对象将进入老年代。
- 动态对象年龄判定:并不是根据参数定死的,会参考Survivor总体年龄。
- 空间分配担保:新生代的Survivor空间不够由老年代担保。
3.9 本章小结
本章介绍了垃圾收集的算法、若干款HotSpot虚拟机中提供的垃圾收集器的特点以及运作原理。通过代码实例验证了Java虚拟机中自动内存分配及回收的主要规则。
JVM只有根据实际应用需求、实现方式选择最优的收集方式才能获取最好的性能。没有固定收集器、参数组合,没有最优的调优方法。因此学习虚拟机内存知识,如果要到实践调优阶段,必须了解每个具体收集器的行为、优势劣势、调节参数。在接下来的两章中,作者将会介绍内存分析的工具和一些具体调优的案例。
提问
如何判断对象是否能回收?有哪些算法?有啥优缺点?
GC Roots的对象包含哪些?
Java中有哪些引用类型?各有什么区别和用途?
对象被回收的过程?几次标记?finalize()方法有什么用?
方法区GC主要回收哪些内容?如何判定需要回收?是否有必要回收?
Java堆为什么要分代?基于什么理论?跨代引用怎么解决?
GC回收算法有哪些?具体实现?各有什么优缺点?
详细列举Hotspot标记的过程,包括根节点枚举、安全点、安全区域、记忆集、写屏障、可达性分析等?
为什么会有安全点?安全点安全在哪?怎么选取安全点位置?GC时如何等待程序到达安全点?如何保证轮询标志高效?
安全区域用来干什么的?如何实现?
记忆集用来干什么?如何实现?什么是卡表?
如何保证编译执行时能够维护卡表?伪共享问题怎么解决?
并发的可达性分析如何实行?如何防止用户线程和分析并发时,需要的对象被误删除?
常用的7种经典GC收集器?它们各自的原理特点?适用于哪些场景?哪些可以相互搭配使用?
Shenandoah与G1的异同?工作阶段?如何实现并发回收?Brooks Pointer是什么?
ZGC最具有特色的技术是啥?染色指针如何实现?工作阶段?
如何进行GC收集器选型?
第4章 虚拟机性能监控、故障处理工具
4.1 概述
我们可以用工具可以得到各种数据,然后根据数据去进行分析。这些数据包括不限于:异常堆栈、虚拟机运行日志、垃圾收集器日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。
4.2 基础故障处理工具
JDK的bin目录中的小工具,如java,javac等。根据授权,主要可分为三类:
- 商业授权工具:主要是JMC(Java Mission Control)及它要使用到的JFR(Java Flight Recorder)。JMC原本来自于JRockit,是运维监控套件。
- 正式支持工具
- 实验性工具:可能会转正或移除。不过都很强大且稳定。
这些命令行工具大多仅是一层薄包装而已,真正的功能代码是实现在JDK的工具类库中的。之所以选择在JDK包实现它们,是为了在生产环境,能够借助这些工具类库里面的接口和实现代码,直接在应用程序中提供功能强大的监控分析功能。
1.jps:虚拟机进程状况工具
列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)。
如果是本地虚拟机进程,那么LVMID与操作系统的进程ID(PID,Process Identifier)一致,使用ps命令也可以一样查询到虚拟机进程的LVMID。但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就必须依赖jps命令显示主类的功能才能区分了。
命令格式:
jps [ options ] [ hostid ]
执行样例:
jps -l
2388 D:\Develop\glassfish\bin\..\modules\admin-cli.jar
2764 com.sun.enterprise.glassfish.bootstrap.ASMain
3788 sun.tools.jps.Jps
如果远程JVM开启了RMI,还可以通过RMI协议远程查看。上述参数hostid为RMI注册表中的主机名。
其他常用选项:
2.jstat:虚拟机统计信息监视工具
jstat(JVM Statistics Monitoring Tool)用来监控各种运行状态信息。
可远程监控,JDK中提供了jstatd工具可以很方便地建立远程RMI服务器。
命令格式:
jstat [ option vmid [interval[s|ms] [count]] ]
参数解释:
vmid:如果是本地虚拟机进程,VMID与LVMID一致;如果是远程虚拟机进程,那VMID的格式应当是:
[protocol:][//]lvmid[@hostname[:port]/servername]
interval和count:查询间隔和次数。省略则代表只查一次。以下是示例:
# 假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是: jstat -gc 2764 250 20
option:用户希望查询的虚拟机信息。主要分三类:类加载、垃圾收集、运行期编译状
况。主要选项如下图:
执行样例:
# 监视一台刚刚启动的 GlassFish v3服务器的内存状况
jstat -gcutil 2764
S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 0.00 6.20 41.42 47.20 16 0.105 3 0.472 0.577
# 结果解析:
# 2个Survivor为空,Eden用了6.2%,老年代47.20%,永久代47.20%。
# 程序运行以来共发生Minor GC(YGC,表示Young GC)16次,总耗时0.105秒;
# 发生Full GC(FGC,表示Full GC)3次,总耗时(FGCT,表示Full GC Time)为0.472秒;
# 所有GC总耗时(GCT,表示GC Time)为0.577秒。
3.jinfo:Java配置信息工具
jinfo(Configuration Info for Java):实时查看和调整虚拟机各项参数。
命令格式:
jinfo [ option ] pid
参数设置:
-v:查看虚拟机启动时显式指定的参数列表。
-flag:查看未被显式指定的参数的系统默认值。
-sysprops:打印虚拟机进程的系统参数System.getProperties()内容。
JDK6之后,可以使用-flag[+|-]name
或者-flag name=value
在运行期修改一部分运行期可写的 虚拟机参数值。
执行样例:
jinfo -flag CMSInitiatingOccupancyFraction 1444
-XX:CMSInitiatingOccupancyFraction=85
4.jmap:Java内存映像工具
jmap(Memory Map for Java)用于生成堆转储快照(一般称为heapdump或dump文件)。分析内存溢出OOM等问题。
其他可以拿到dump文件的方式,比较“暴力”:
-XX:+HeapDumpOnOutOfMemoryError
:让虚拟机在内存溢出异常出现之后自动生成堆转储快照文件。-XX:+HeapDumpOnCtrlBreak
:使用[Ctrl]+[Break]键让虚拟机生成堆转储快照文件。Kill -3
命令:在Linux系统下发送进程退出信号“恐吓”一下虚拟机,也能顺利拿到堆转 储快照。
jmap还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。
命令格式:
jmap [ option ] vmid
主要选项:
执行样例:
# 使用jmap生成dump文件
jmap -dump:format=b,file=eclipse.bin 3500
Dumping heap to C:\Users\IcyFenix\eclipse.bin ...
Heap dump file created
5.jhat:虚拟机堆转储快照分析工具
jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,用于分析jmap生成的堆转储快照。
jhat内置了一个微型的HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看。jhat的分析功能相对来说比较简陋,而且一般也不会直接在dump的服务器上分析。所以jhat比较少用。一般用VisualVM等其他强大的分析工具。
执行样例:
# 分析前一小节得到的dump文件
jhat eclipse.bin
Reading from eclipse.bin...
Dump file created Fri Nov 19 22:07:21 CST 2010
Snapshot read, resolving...
Resolving 1225951 objects...
Chasing references, expect 245 dots....
Eliminating duplicate references...
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.
# 屏幕显示“Server is ready.”的提示后,用户在浏览器中输入http://localhost:7000/可以看到分析结果
6.jstack:Java堆栈跟踪工具
jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。分析线程长时间停顿问题。
线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等。通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源。
命令格式:
jstack [ option ] vmid
主要选项:
执行样例:
# 使用jstack查看线程堆栈(部分结果)
jstack -l 3500
2010-11-19 23:11:26
Full thread dump Java HotSpot(TM) 64-Bit Server VM (17.1-b03 mixed mode):
"[ThreadPool Manager] - Idle Thread" daemon prio=6 tid=0x0000000039dd4000 nid= 0xf50 in Object.wait() [0x000000003c96f000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x0000000016bdcc60> (a org.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor)
at java.lang.Object.wait(Object.java:485)
at org.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor.run (Executor. java:106)
- locked <0x0000000016bdcc60> (a org.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor)
Locked ownable synchronizers:
- None
从JDK 5起,java.lang.Thread类新增了一个*getAllStackTraces()*方法用于获取虚拟机中所有线程的StackTraceElement对象。使用这个方法可以通过简单的几行代码完成jstack的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈。
<!-- 查看线程状况的JSP页面 -->
<%@ page import="java.util.Map"%>
<html>
<head> <title>服务器线程信息</title></head>
<body> <pre>
<% for (Map.Entry<Thread, StackTraceElement[]> stackTrace : Thread.getAllStack-Traces().entrySet()) { Thread thread = (Thread) stackTrace.getKey(); StackTraceElement[] stack = (StackTraceElement[]) 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>
7.基础工具总结
以下是JDK附带的全部工具(包括新版本已经被移除的):
基础工具:用于支持基本的程序创建和运行
安全工具:用于程序签名、设置安全测试等
国际化:用于创建本地语言文件
远程方法调用:用于跨Web或网络的服务交互
Java IDL与RMI-IIOP:在JDK 11中结束了十余年的CORBA支持,这些工具不再提供
部署工具:用于程序打包、发布和部署
Java Web Start
性能监控和故障处理:用于监控分析Java虚拟机运行信息,排查问题
WebService工具:与CORBA一起在JDK 11中被移除
4.3 可视化故障处理工具
主要包括JConsole、JHSDB、VisualVM和JMC四个。
JConsole和JHSDB是JDK自带。
VisualVM功能强大,在JDK 6 Update 7中首次发布,直到JRockit Mission Control与OracleJDK的融合工作完成之前,Oracle都主推主推它。现在已经已经从OracleJDK中分离出来独立开源。已经不是JDK的正式成员,不过还是可以下载,并且免费。
JMC商业环境使用需要付费。
1.JHSDB:基于服务性代理的调试工具
JDK中提供了JCMD和JHSDB两个集成式的多功能工具箱,整合了上节的所有基础工具。包括jmap、jinfo等。也支持命令行模式。根据PID来分析。
JHSDB是于服务性代理(Serviceability Agent,SA)实现的进程外调试工具。服务性代理是Hotspot的一套API集合,用来映射JVM的运行信息,主要是基于Java(少量JNI)实现。也就是说,通过服务性代理的API,可以在一个独立的JVM的进程里,分析其他HotSpot虚拟机的内部数据。或者从HotSpot虚拟机进程内存中dump出来的转储快照里,还原出它的运行状态细节。
JHSDB功能很多,具体可视化使用参加书本。
2.JConsole:Java监视与管理控制台
JConsole(Java Monitoring and Management Console)是一款基于JMX(Java Manage-ment Extensions)来实现可视化监视、管理。是通过JMX的MBean(Managed Bean)对系统进行信息收集和参数动态调整。
JMX不仅JVM本身可以用,用户自己的程序也能调用。比如很多中间件的管理与监控就是基于JMX来实现的。只要符合JMX规范,就可以用代码调用API访问MBean。
JConsole常用功能:
- 内存监控:相当于可视化的jstat命令
- 线程监控:相当于可视化的jstack命令
具体用法参加书本。
3.VisualVM:多合-故障处理工具
功能很强大,用来进行运行监视和故障处理。以前是Oracle主推的JVM故障处理工具。
它除了常规的运行监视、故障处理外,还将提供其他方面的能力,譬如性能分析(Profiling)。支持插件扩展,插件扩展是它最精华的部分。
VisualVM可以做到:
- 显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。
- 监视应用程序的处理器、垃圾收集、堆、方法区以及线程的信息(jstat、jstack)。
- dump以及分析堆转储快照(jmap、jhat)。
- 方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法。
- 离线程序快照:收集程序的运行时配置、线程dump、内存dump等信息建立一个快照,可以将快照发送开发者处进行Bug反馈。
- 其他插件带来的无限可能性。
比较厉害的插件,例如BTrace插件,可以在不中断程序的前提下,利用过HotSpot虚拟机的Instrument功能,动态加入调试代码(HotSpot虚拟机允许在不停止运行的情况下,更新已经加载的类的代码)。
Instrument是Java虚拟机工具接口(Java Virtual Machine Tool Interface,JVMTI)的重要组件,提供了一套代理(Agent)机制,使得第三方工具程序可以以代理的方式访问和修改Java虚拟机内部的数据。阿里巴巴开源的诊断工具Arthas也通过Instrument实现了与BTrace类似的功能。
4.Java Mission Control:可持续在线的监控工具
JFR是一套内建在HotSpot虚拟机里面的监控和基于事件的信息搜集框架,在生产环境中对吞吐量的影响一般不会高于1%,而且JFR监控过程的开始、停止无需重启应用。监控对应用程序透明,无需对源码做任何修改。
JMC搭配JFR使用,同样采取JMX协议进行通信。JMC一方面作为JMX控制台,显示来自虚拟机MBean提供的数据;另一方面作为JFR的分析工具,展示来自JFR的数据。
JFR提供的信息很详细,而且相比于从MBean得到的结果快照,它是可以看到过程的,对于排查问题应用价值很高。可提供以下信息:
- 一般信息:关于虚拟机、操作系统和记录的一般信息。
- 内存:关于内存管理和垃圾收集的信息。
- 代码:关于方法、异常错误、编译和类加载的信息。
- 线程:关于应用程序中线程和锁的信息。
- I/O:关于文件和套接字输入、输出的信息。
- 系统:关于正在运行Java虚拟机的系统、进程和环境变量的信息。
- 事件:关于记录中的事件类型的信息,可以根据线程或堆栈跟踪,按照日志或图形的格式查看。
具体使用参见书本。
4.4 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:即时编译器的反汇编插件。
Ideal Graph Visualizer
和Client Compiler Visualizer
11章会讲。HSDIS
对第四部分“程序编译与代码优化”有用,我们讲一下。其他三个工具略去。
HSDIS:JIT生成代码反汇编
本节后续学完第4部分后还要回来完善。
编译的顺序:Java源代码 -> 字节码 -> 即时编译代码。要看虚拟机做了什么,看字节码。要看虚拟机具体怎么做,看汇编代码。HSDIS用来把即时编译的机器码反汇编为汇编代码。
字节码可以通过编译的方式以一大块为单位(称为“编译单元”)翻译为机器码并缓存起来,后面执行这个已经翻译好的代码;也可以以解释的方式一条条字节码指令跑固定的代码。
随着虚拟机的发展,《Java虚拟机规范》中的规定逐渐成为Java虚拟机实现的“概念模型”,即实现只保证与规范描述等效,而不一定是按照规范描述去执行。因此,我们在讨论程序的执行语义问题(虚拟机做了什么)时,在字节码层面上分析完全可行。但讨论程序的执行行为问题(虚拟机是怎样做的、性能如何)时,在字节码层面上分析就没有意义了,必须通过其他途径解决。
由于因为大量执行代码是通过即时编译器动态生成为本地代码放到代码缓存中的,打断点不好处理,因此,我们需要用HSDIS把即时编译器动态生成的本地代码还原为汇编代码输出,从而通过汇编代码来从最本质的角度分析问题。
HSDIS通过-XX:+PrintAssembly
参数调用
使用示例:
测试代码:
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); }
}
使用命令:
java -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*Bar.sum -XX:Compile-Command=compileonly,*Bar.sum test.Bar
# 其中,参数-Xcomp是让虚拟机以编译模式执行代码,这样不需要执行足够次数来预热就能触发即 时编译。
# 两个-XX:CompileCommand的意思是让编译器不要内联sum()并且只编译sum(),
# -XX: +PrintAssembly就是输出反汇编内容。
输出如下:
[Disassembling for mach='i386']
[Entry Point]
[Constants]
# {method} 'sum' '(I)I' in 'test/Bar'
# this: ecx = 'test/Bar'
# parm0: edx = int
# [sp+0x20] (sp of caller)
……
0x01cac407: cmp 0x4(%ecx),%eax 0x01cac40a: jne 0x01c6b050 ; {runtime_call} [Verified Entry Point]
0x01cac410: mov %eax,-0x8000(%esp)
0x01cac417: push %ebp
0x01cac418: sub $0x18,%esp ; *aload_0
; - test.Bar::sum@0 (line 8)
;; block B0 [0, 10]
0x01cac41b: mov 0x8(%ecx),%eax ; *getfield a ; - test.Bar::sum@1 (line 8)
0x01cac41e: mov $0x3d2fad8,%esi ; {oop(a 'java/lang/Class' = 'test/Bar')}
0x01cac423: mov 0x68(%esi),%esi ; *getstatic b ; - test.Bar::sum@4 (line 8)
0x01cac426: add %esi,%eax 0x01cac428: add %edx,%eax 0x01cac42a: add $0x18,%esp
0x01cac42d: pop %ebp 0x01cac42e: test %eax,0x2b0100 ; {poll_return}
0x01cac434: ret
正式环境中,反汇编的代码比较多,需要搭配JITWatch可视化日志分析工具来使用。在JITWatch里面选择想要查看的类和方法,即可查看对应的Java源代码、字节码和即时编译器生成的汇编代码。
4.5 本章小结
本章介绍了随JDK发布的6个命令行工具与4个可视化的故障处理工具,灵活使用这些工具,可以为处理问题带来很大的便利。
提问
常用本地工具作用?jps, jstat, jmap, jhat, jstack, jinfo?jps查看虚拟机线程ID,jstat查看堆和GC分配,jmap导出堆快照dump,jhat分析dump,jstack查看栈线程状态和锁,jinfo虚拟机配置等。
JDK 7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中。在JDK 7以前,即还没有开始“去永久代”行动时,这些静态变量是存放在永久代上的,JDK 7起把静态变量、字符常量这些从永久代移除出去。
常用的可视化分析工具?JHSDB
HSDIS:JIT生成代码反汇编有什么用?
第5章 调优案例分析与实战 (略)
5.1 概述
在前面3章笔者系统性地介绍了处理Java虚拟机内存问题的知识与工具。
本章分析一些具有代表性的案例。看下实际工作中如何进行故障处理、调优。
5.2 案例分析
本章案例一部分是实际处理过的,一部分是网上比较有代表性的。
有些案例是在设计开发的时候就能提前解决的,不过本章的重点是部署后怎么解决。
待补充:本章先略过,后续回来补。曾经处理过,因直接内存不够,rabbitMQ假死不消费的问题?。
第三部分 虚拟机执行子系统
第6章 类文件结构
6.1 概述
以前程序都需要编译成0和1的二进制格式。随着虚拟机和跑在虚拟机上的语言大力发展,现在把程序编译成与平台无关的格式也成为一种选择(字节码)。
6.2 无关性的基石
Java语言最初是为了解决跨平台的问题。即跨指令集、跨操作系统。
字节码(Byte Code)是构成无关性的基石。这种无关性包括,平台无关性(支持各种平台)、语言无关性(除Java外还支持各种语言)。
技术规范也是拆成《Java语言规范》和《Java虚拟机规范》两部分的,所以实现语言无关。
实现语言无关性的基础是虚拟机和字节码存储格式。JVM只和字节码关联,不与任何语言绑定,包括Java。而字节码格式是图灵完备的,可以表示任意一门功能性语言。
字节码指令所能提供的语言描述能力比Java语言本身更加强大,有一些特性Java语言本身不支持,但是字节码却可以表达。所以字节码可以支持其他语言。各种语言编译成.class格式,直接交给JVM执行即可。
6.3 Class类文件的结构
Class文件结构很稳定,所以Java一直能保持良好的向后兼容性。
本章所讲的Class文件结构内容,基本上在第一版的《Java虚拟机规范》就定义好了,至今几乎没有变动。要更新也只是少量的增量更新。
任何一个Class文件都对应着唯一的一个类或接口的定义信息(除了package-info.class、module-info.class等完全描述性的文件),但是类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。
Class文件是一组以8个字节为基础单位的二进制流,各个数据项按照顺序紧凑排列,没有分隔符。遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储。
Class文件格式只包含两种数据类型:
- 无符号数:基本的数据类型。以u1、u2、u4、u8来分别代表1、2、4;8个字节的无符号数。用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
- 表:是由多个无符号数或者表构成的复合数据类型。所有表的命名都习惯性地以“_info”结尾。
整个Class文件本质上也可以视作是一张表,总体表结构如下:
集合:当需要描述类型一样但数量不定的多个数据时,经常会使用“1个前置的容量计数器 + n个连续数据项”的形式,这个时候就把这一堆连续的数据称为该类型的“集合”。
再次强调,Class的结构,各数据项的顺序、数量等各种细节,都是被严格限定的。哪个字节代表什么含义,长度是多少,先后顺序如何,全部都不允许改变。
下面我们来一一列举上图中的各个数据项。
1.魔数与Class文件的版本
文件的头4个字节是魔数,用来识别该文件是Class文件。类似于扩展名,很多文件格式都这么玩。.class文件的魔数是“0xCAFEBABE”
。
魔术后面的4个字节存储版本号。第5和第6个字节是次版本号(MinorVersion),第7和第8个字节是主版本号(Major Version)。JVM会拒绝执行超过它版本号的.class文件。JDK主版本号从45开始。
以下是代码示例,本周后续的.class文件结构都用它讲解:
package org.fenixsoft.clazz;
public class TestClass {
private int m;
public int inc() { return m + 1; }
}
.class文件格式,魔数和版本号:
2.常量池
常量池在主、次版本号之后。是Class文件里的资源仓库。
常量池是一个表,所以前面加了一个u2来表示容量计数。
常量池的计数从1开始,0表示“不引用任何一个常量池项目”。Class文件结构中除了常量池,其他集合类型都是从0开始计数。
如下图,0016等于22,表示这个常量池有21项常量,索引值范围为1~21:
常量池中主要存放两大类常量:
- 字面量(Literal):接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
- 符号引用(Symbolic References):属于编译原理方面的概念,主要包括下面几类常量:
- 被模块导出或者开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
C和C++编译时有“连接”这一个步骤,用来保证各个方法、字段最终在内存中的布局信息。而Java代码在javac编译时是没有“连接”这一个步骤的。也就是说,在Class文件中不会保存各个方法、字段最终在内存中的布局信息。需要先把这些这些字段、方法的符号引用存起来,然后等到JVM在运行期把它们转换成内存入口。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。类的创建和动态链接这部分内容下一章讲。
常量池中的每一项常量都是一个表,总共17种21个。包括支持Java的11种,支持其他动态语言的4种,支持Java模块化的2种。
这17类表结构起始的第一位都是1个u1类型的标志位,表示它属于那种常量类型。具体如下图:
这17种常量类型各自有着完全独立的数据结构,两两间没有任何联系。
表里面又会指到其他常量项。例如,CONSTANT_Class_info
常量表的数据结构如下:
其中name_index会指向CONSTANT_Utf8_info
字符表。
从而我们可以知道该类的名字。
顺便提一下,由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译。
我们把前文的代码翻译为.class文件如下,仅保留了常量池部分:
C:\>javap -verbose TestClass
Compiled from "TestClass.java"
public class org.fenixsoft.clazz.TestClass extends java.lang.Object
SourceFile: "TestClass.java"
minor version: 0
major version: 50
Constant pool:
const #1 = class #2; // org/fenixsoft/clazz/TestClass
const #2 = Asciz org/fenixsoft/clazz/TestClass;
const #3 = class #4; // java/lang/Object
const #4 = Asciz java/lang/Object;
const #5 = Asciz m;
const #6 = Asciz I;
const #7 = Asciz <init>;
const #8 = Asciz ()V;
const #9 = Asciz Code;
const #10 = Method #3.#11; // java/lang/Object."<init>":()V
const #11 = NameAndType #7:#8;// "<init>":()V
const #12 = Asciz LineNumberTable;
const #13 = Asciz LocalVariableTable;
const #14 = Asciz this;
const #15 = Asciz Lorg/fenixsoft/clazz/TestClass;;
const #16 = Asciz inc;
const #17 = Asciz ()I;
const #18 = Field #1.#19; // org/fenixsoft/clazz/TestClass.m:I
const #19 = NameAndType #5:#6; // m:I
const #20 = Asciz SourceFile;
const #21 = Asciz TestClass.java;
注意到编译器自动生成了“I”“V”“
17种常量项的结构定义总结如下:
3.访问标志(access_flags)
紧跟在常量池之后。是一个u2。
用于识别类或者接口层次的访问信息。包括:这个Class是类、接口、注解、枚举、模块;是否定义为public类型;是否为abstract;是否final(仅适用于类);是否不是用户产生等等。如下图:
4.类索引、父类索引与接口索引集合
这三项数据用来确定该类型的继承关系。
类索引(this_class)和父类索引(super_class)都是1个u2,接口索引集合(interfaces)是1组u2类型。
注意到父类索引只有1个u2,因为Java不允许多重继承。另外,接口索引组按implements关键字后面的声明顺序从左到右排列到集合中。
类索引和父类索引指向一个CONSTANT_Class_info
的类描述符常量,再通过该常量的索引,找到定义在CONSTANT_Utf8_info
类型的常量中的全限定名字符串。如图所示:
5.字段表集合 (略)
用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
字段表结构如图:
详细略。
6.方法表集合(略)
方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。
方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目,将在下一节中详细讲解。
方法表结构如图:
如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器“<clinit>()”方法和实例构造器“<init>()”方法
.class文件特征签名范围更大,支持不同返回值。在java里,要重载一个方法,需要与原方法名称一致,但特征签名不一致。Java代码的方法特征签名只包括方法名称、参数顺序及参数类型,而字节码的特征签名还包括方法返回值以及受查异常表。也就是说,.class文件中支持两个方法名字参数一致,但返回值不一致的方法共存。
详细略。
7.属性表集合(略)
Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息,而且属性表都放到了最后面(提升扩展性)。 前文中字段表等集合里面都可以看到attribute_info
。属性表提供了强大的扩展性。
《Java虚拟机规范》对属性表的要求比较宽松:
- 不要求属性具有严格的顺序:这点和Class文件中其他数据项要求严格的顺序、长度和内容不同。
- 支持自定义属性:只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。
《Java虚拟机规范》最初定义了9项属性,现在最新的JavaSE12版本,已经增加到29项。具体如图,后文挑选一些关键常用的属性进行讲解:
对于每一个属性,它的名称要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表结构要满足如下定义,也就是说,属性表的第1个u2和紧跟后面的u4都用来描述属性名和属性长度,再后面才跟属性信息:
比较常用重要的属性:
- Code属性:方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。Code属性属于方法表。
- Exceptions属性:与Code属性平级,列举出方法中可能抛出的受查异常(Checked Excepitons),
- LineNumberTable属性:于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。不是运行时必须。
- LocalVariableTable及LocalVariableTypeTable属性:LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系。不是运行时必须。
- SourceFile及SourceDebugExtension属性:记录生成这个Class文件的源码文件名称。这个属性也是可选的,一般类名和文件名是一致的,除了内部类等特殊情况。
- ConstantValue属性:通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。类似“int x=123”和“static int x=123”这样的变量,对非static类型的变量(也就是实例变量)的赋值是在实例构造器
()方法中 进行的;而对于类变量,则有两种方式可以选择:在类构造器()方法中 或者使用ConstantValue属性。Oracle公司实现的Javac编译器的选择是,如果变量同时使用final和static来修饰,且数据类型是基本类型或者java.lang.String,那么生成ConstantValue属性来进行初始化。否则在()方法中进行初始化。 - InnerClasses属性:记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。
- Deprecated及Synthetic属性:标志类型的布尔属性,只存在有和没有的区别,没有属性值。Deprecated属性用于表示某个类、字段或者方法已经被标记废弃,可以通过代码中使用“@deprecated”注解进行设置。Synthetic属性代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的,可能是由于优化等目的。
- StackMapTable属性:它是一个相当复杂的变长属性,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用(详见第7章字节码验证部分),目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。
- Signature属性:可选的定长属性,可以出现于类、字段表和方法表结构的属性表中。JDK5之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variable)或参数化类型(Parameterized Type),则Signature属性会为它记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,字节码(Code属性)中所有的泛型信息编译(类型变量、参数化类型)在编译之后都通通被擦除掉(Object)。坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得泛型信息。Signature属性就是为了弥补这个缺陷而增设的,现在Java的反射API能够获取的泛型类型,最终的数据来源也是这个属性。关于Java泛型、Signature属性和类型擦除,在第10章讲编译器优化的时候我们会通过一个更具体的例子来讲解。
- BootstrapMethods属性:是一个复杂的变长属性,位于类文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符。InovkeDynamic指令的运作原理,第8章会介绍,先暂时略过。
- MethodParameters属性:用在方法表中的变长属性。用于记录方法的各个形参名称和信息。 JDK8加入,因为以前的.class文件都不记录形参名,这个属性可以让编译器把方法名称等写进.class文件。然后它是方法表的属性,与Code属性平级的,可以运行时通过反射API获取。
- 模块化相关属性:JDK 9的一个重量级功能是Java的模块化功能,因为模块描述文件(module-info.java)最终是要编译成一个独立的Class文件来存储的,所以,Class文件格式也扩展了Module、ModulePackages和 ModuleMainClass三个属性用于支持Java模块化相关功能。
- 运行时注解相关属性:用于支持注解。
Code属性是Class文件中最重要的一个属性,所以我们重点讲解一下。其他属性的详细讲解略过。
Code属性
Code属性里存放了字节码指令,它的字节码指令是通过Javac编译器来编译方法体里的代码得到的。
所以方法表的属性表里可以放Code属性,不过抽象方法、接口都没有Code属性,这个好理解。
Code属性表的结构如下图所示:
各个数据项及表的含义:
- attribute_name_index:头个u2,表示属性名称。指向
CONSTANT_Utf8_info型常量
,固定为“Code” - attribute_length:紧跟的u4,表示属性值长度。
- max_stack:u2,操作数栈(Operand Stack)深度的最大值。JVM运行时根据这个值来分配栈帧中的操作栈深度。
- max_locals:u2,局部变量表所需要的存储空间,单位是槽(Slot)。是JVM为局部变量分配内存最小单位,byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型占1个槽,double和long这两种64位的数据类型则占2个槽。方法入参、显式异常处理程序参数(即catch的入参)、局部变量都用局部变量表来存放。为了节省内存,避免不必要的操作栈深度和局部变量表插槽,JVM会重用变量槽。当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量槽可以被其他局部变量所使用(例如for循环里定义的变量?)。Javac编译器会根据同时生存的最大局部变量数量以及它们的类型来计算出max_locals的大小。
- code_length和code:存储Java源程序编译后生成的字节码指令。code是一个u1的单字节,知道指令后就能知道它后面要不要跟随参数。目前已经定义了大概200条指令。最多可定义256条。code_length是一个u4,但是《Java虚拟机规范》中明确限制了一个方法不允许超过65535条字节码指令,所以code_length只用了u2。所以,某些特殊情况,例如在编译一个很复杂的JSP文件时,某些JSP编译器会把JSP内容和页面输出的信息归并于一个方法之中,就有可能因为方法生成字节码超长的原因而导致编译失败。
- exception_table_length和exception_table:异常信息。前者为1个u2。《JVM规范》规定编译器要用异常表来实现异常处理,而不是跳转指令。包含四个字段,这些字段的含义为:如果当字节码从第start_pc(u2)行到第end_pc(u2)行之间(不含第end_pc行)出现了类型为catch_type(u2)或者其子类的异常 (catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc(u2)行继续处理。当 catch_type的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。
- attribute_count和attributes:属性表。前者是1个u2,后者是属性表。
如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个Class文件里,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。
属性字节码解读举例,如下图:
翻译“2A B7000A B1”的过程为:
- 读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个变量槽中为reference类型的本地变量推送到操作数栈顶。
- 读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的符号引用。
- 读入000A,这是invokespecial指令的参数,代表一个符号引用,查常量池得0x000A对应的常量 为实例构造器“
()”方法的符号引用。 - 读入B1,查表得0xB1对应的指令为return,含义是从方法的返回,并且返回值为void。这条指令执行后,当前方法正常结束。
this的实现机制:在Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法 。所以在局部变量表里,实例方法会预留出第1个变量槽位来存放对象实例的引用,实例方法参数值从1开始计算。
6.4 字节码指令简介
JVM的指令格式:操作码(Opcode,1个字节长度)+ 操作数(0个至n个)。
由于Java虚拟机采用的是面向操作数栈架构,而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
字节码指令集很有特点:
- 限制了Java虚拟机操作码的长度为一个字节(即0~255):因此指令集的操作码总数不能够超过256条。
- 放弃了操作数长度对齐:由于 Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构。放弃了操作数长度对齐,就意味着可以省略掉大量的填充和间隔符号。
JVM解释器可以使用以下伪代码作为最基本的执行模型来理解(不考虑异常处理):
do {
自动计算PC寄存器的值加1;
根据PC寄存器指示的位置,从字节码流中取出操作码;
if (字节码存在操作数) 从字节码流中取出操作数;
执行操作码所定义的操作;
} while (字节码流长度 > 0);
1.字节码与数据类型
在JVM指令集中,一般指令都会包括它对应的操作数数据类型。例如iload
用来用局部变量表加载int
数据到操作数栈。
对于与数据类型相关的命令,有以下一些数据类型命名约定:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。
从数据类型和命令两个维度去扩展的话,命令会很多。所以做了精简,指令集被故意设计成非完全独立的:有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
JVM里与数据类型相关的字节码指令如下,把T换成前面的命名约定就是相对应的数据类型:
注意到大部分指令都不支持整数类型byte、char、short,以及boolean。编译器会在编译器或运行期,把byte和short类型的数据带符号扩展(Sign-Extend)为int类型,将boolean和char类型数据零位扩展(Zero-Extend)为int类型。处理上述4种类型的数组时,也会相应的转换为int数组。最后都用相应的对int类型作为运算类型(Computational Type)来操作。
后文根据字节码操作的用途,大致将指令分为9类,进行简要介绍。
2.加载和存储指令
用于局部变量表和操作数栈(它们都存在栈帧中)之间传输数据。
包括:
将1个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、 dload_<n>、aload、aload_<n>。
将1个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、
fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
将1个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、
iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
扩充局部变量表的访问索引的指令:wide
上述指令,iload_\<n\>
就相当于带操作数的iload n
。
操作数栈和局部变量表主要由加载和存储指令进行操作。除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
3.运算指令
用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
大体分为两种:对整型数据的运算指令与对浮点型数据的运算指令。两种指令在溢出和被零除的时候结果不同。
所有运算指令都是用使用Java虚拟机的算术类型来计算,也就是说,byte、short、char和boolean类型的算术用操作int类型的指令代替。
所有的算术指令包括:
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 取反指令:ineg、lneg、fneg、dneg
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
- 局部变量自增指令:iinc
- 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
对整数的运算处理:
- 溢出:《Java虚拟机规范》中没有明确定义整型数据溢出具体会得到什么计算结果。JVM可能会得到一个负数。
- 异常:《Java虚拟机规范》规定,除法指令(idiv和ldiv)以及求余指令(irem和lrem)中除数为零时抛出ArithmeticException异常,其余任何整型数运算场景都不抛出运行时异常。
对浮点数的运算处理,全部遵循IEEE 754规范:
- “非正规浮点数值”(Denormalized Floating-Point Number)和“逐级下溢”(Gradual Underflow)运算规则:所有的运算结果都必须舍入到适当的精度,非精确的结果必须舍入为可被表示的最接近的精确值;
- 最接近数舍入模式。:如果有两种可表示的形式与该值一样接近,那将优先选择最低有效位为零的。
- 向零舍入模式:在把浮点数转换为整数时,丢弃所有小数部分。选择一个最接近的但是不大于原值的数字作为舍入结果。
- 异常:Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常(Java的异常)。溢出时,使用有符号的无穷大来表示;如果结果没有明确的数学定义, 使用NaN(Not a Number)表示。所有使用NaN值的算术操作,结果都会返回NaN。
- 比较:对long类型数值进行比较时,JVM采用带符号的比较。对浮点数值进行比较时(dcmpg、dcmpl、fcmpg、fcmpl),JVM采用IEEE 754规定的无信号比较(Nonsignaling Comparison)。
4.类型转换指令
用于转换两种不同的数值类型。一般用来实现用户代码中的显式类型转换操作,或是处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
宽化类型转换,即小范围向大范围转换,转换时无须显式的转换指令:
- int类型到long、float或者double类型。int -> float可能丢失精度。
- long类型到float、double类型。long -> float、long -> double都可能丢失精度。
- float类型到double类型。
窄化类型转换,必须显式地使用转换指令来完成,指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。可能产生不同的正负号、不同的数量级、精度丢失。窄化转换行为如下:
“整型 -> 整型”窄化转换:
- int或long类型 -> 更小范围的整数T:保留低位,丢弃高位。可能导致不同的正负号。
“浮点型 -> 整型T(限定为int或long)”窄化转换:
- 浮点值是NaN:int或long类型的0。
- 浮点值不是无穷大:使用IEEE 754的向零舍入模式取整,获得整数值V。V如果在表示范围外,则转换为T所能表示的最大或者最小正数。
“double -> float类型”窄化转换,与IEEE 754中定义一致,通过IEEE 754向最接近数舍入模式舍入得到一个可以使用float类型表示的数字:
- 转换结果的绝对值太小、无法使用float来表示:返回float类型的正负零。
- 转换结果的绝对值太大、无法使用float来表示:返回float类型的正负无穷大。
- 输入是double类型的NaN值:按规定转换为float类型的NaN值。
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是《Java虚拟机规范》中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
5.对象创建与访问指令
虽然类实例和数组都是对象,但JVM对类实例和数组的使用了不同的创建与操作字节码指令(在下一章会讲到数组和普通类的类型创建过程是不同的)。
对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令包括:
- 创建类实例的指令:new
- 创建数组的指令:newarray、anewarray、multianewarray
- 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的 指令:getfield、putfield、getstatic、putstatic
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
- 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、 dastore、aastore
- 取数组长度的指令:arraylength
- 检查类实例类型的指令:instanceof、checkcast
6.操作数栈管理指令
用于直接操作操作数栈。包括:
- 出栈:将操作数栈的栈顶一个或两个元素出栈:pop、pop2
- 复制:复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
- 交换:将栈最顶端的两个数值互换:swap
7.控制转移指令
让JVM有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序。本质就是有条件或无条件地修改PC寄存器的值。控制转移指令包括:
- 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
- 复合条件分支:tableswitch、lookupswitch
- 无条件分支:goto、goto_w、jsr、jsr_w、ret
在JVM中有专门的指令集用来处理int和reference类型的条件分支比较操作,也有专门的指令用来检测null值。
对于boolean类型、byte类型、char类型和short类型的条件分支比较操作,都使用int类型的比较指令来完成。
对于long类型、float类型和double类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp,见6.4.3节),运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。
由于各种类型的比较最终都会转化为int类型的比较操作,而JVM提供了最为丰富、强大的int类型的条件分支指令。
8.方法调用和返回指令
方法调用(分派、执行过程)将在第8章具体讲解,这里仅列举以下五条指令用于方法调用:
- invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
- invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic指令:用于调用类静态方法(static方法)。
- invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面
前四条调用指令的分派逻辑都固化在JVM无法改变,而invokedynamic指令的分派逻辑则由用户所设定的引导方法决定。
方法调用指令与数据类型无关,但返回指令是根据返回值的类型区分的。包括:
- ireturn:返回值是boolean、byte、char、short和int类型时使用
- lreturn
- freturn
- dreturn
- areturn
- return:供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。
9.异常处理指令
在JVM中,处理异常(catch语句)采用异常表来实现的,而不是由字节码指令实现的。
显式抛出异常的操作(throw语句)都由athrow指令来实现。运行时异常则由JVM检测到时自动抛出。例如,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。
10.同步指令
JVM可以支持方法级的同步和方法内一段指令序列的同步,都用管程(Monitor,或称为锁)实现。
a.方法级同步的实现:
方法级的同步是隐式,不用字节码指令声明。JVM可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志判断该方法是不是同步方法。如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成 还是非正常完成)时释放管程。
如果一个同步方法在执行期间抛出了异常,且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
b.方法内代码的同步实现:
由synchronized语句块表示。JVM的指令集中有monitorenter
和monitorexit
两条指令来支持synchronized关键字的语义。
编译器会确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须有其对应的monitorexit指令。
例如,下例中,编译器会自动产生一个可处理所有的异常的处理程序,它的目的就是用来执行monitorexit指令。注意末尾的异常声明:
void onlyMe(Foo f) { synchronized(f) { doSomething(); } }
Method void onlyMe(Foo) 0 aload_1
Method void onlyMe(Foo)
0 aload_1 // 将对象f入栈
1 dup // 复制栈顶元素(即f的引用)
2 astore_2 // 将栈顶元素存储到局部变量表变量槽 2中
3 monitorenter // 以栈定元素(即f)作为锁,开始同步
4 aload_0 // 将局部变量槽 0(即this指针)的元素入栈
5 invokevirtual #5 // 调用doSomething()方法
8 aload_2 // 将局部变量Slow 2的元素(即f)入栈
9 monitorexit // 退出同步
10 goto 18 // 方法正常结束,跳转到18返回
13 astore_3 // 从这步开始是异常路径,见下面异常表的Taget 13
14 aload_2 // 将局部变量Slow 2的元素(即f)入栈
15 monitorexit // 退出同步
16 aload_3 // 将局部变量Slow 3的元素(即异常对象)入栈
17 athrow // 把异常对象重新抛出给onlyMe()方法的调用者
18 return // 方法正常返回
Exception table:
FromTo Target Type
4 10 13 any
13 16 13 any
6.5 公有设计,私有实现
《Java虚拟机规范》描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。
一个优秀的虚拟机实现,在满足《Java虚拟机规范》的约束下对具体实现做出修改和优化也是完全可行的,并且《Java虚拟机规范》中明确鼓励实现者这样去做。虚拟机在后台如何处理Class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可。
虚拟机实现的方式主要有以下两种:
- 将输入的Java虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集;
- 将输入的Java虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译器代码生成技术)。
6.6 Class文件结构的发展
Class文件结构自《Java虚拟机规范》初版订立以来,主体结构、字节码指令的语义和数量几乎没有出现过变动:
- 老改动:JDK1.0.2时改动过 invokespecial指令的语义,JDK 7增加了invokedynamic指令,禁止了ret和jsr指令
- 新特性:访问标志新加入了ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM、ACC_BRIDGE、ACC_VARARGS共五个标志。属性表集合中,在JDK 5到JDK 12发展过程中一共增加了20项新属性,这些属性大部分是用于支持Java中许多新出现的语言特性,如枚举、变长参数、泛型、动态注解等。还有一些是为了支持性能改进和调试信息,譬如JDK 6的新类型校验器的StackMapTable属性和对非Java代码调试中用到的SourceDebugExtension属性。
Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。
6.7 本章小节
Class文件是Java虚拟机执行引擎的数据入口,本章详细讲解了Class文件结构中的各个组成部分,以及每个部分的定义、数据结构和使用方法。了解了Class的数据是如何存储和访问的。
下一章,我们将从动态的角度去看下字节码流在虚拟机执行引擎中是如何被解释执行的。
提问
为什么要有.class文件?java直接像js一样解释源代码执行也可以呀?自己的思考:1.提高性能,js生产中一般也是压缩的。将代码转为字节码。2.解耦java和jvm,实现jvm语言无关性。
.class文件有几类数据项?.class文件的结构?这张大表包含哪些数据项和表?
u1的u是代表字节还是字?
可编译的最大方法、字段名长度?为什么?理论上.class可支持的最大栈深度?最多字段量?方法代码最多多长?UTF8为u2,Code属性表栈深度和字段槽长度为u2,65536。code_length为u4,实际只用了u2。
常量池里面存什么东西?有哪些表?
属性表用来做什么?举个例子。Java泛型是伪泛型,是Object,那么反射是,信息从哪里获取?方法的代码就是编译成字节码存放到方法表的code属性表里。
Signature属性用来做什么?Code属性主要由哪些数据项和表?
byte、char、short,boolean在虚拟机中当什么类型处理?
字节码指令最多有几条?有哪些种类?各用来干什么?列举几条常用的?
异常用哪条指令抛出?两种情况
同步用哪条指令实现?两种情况。方法级:在方法表上用字段标注。同步块:monitorenter + monitorexit。
第7章 虚拟机类加载机制
7.1 概述
上一章讲了.class文件的结构。本章就讲虚拟机怎么加载这些Class文件,以及Class文件中的信息进入到虚拟机后会发生什么变化。
虚拟机的类加载机制:Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的过程。
运行期类加载:与编译时要进行连接的语言不一样,在Java中,类型的加载、连接和初始化过程都是在程序运行期间完成的。
运行期加载的缺点:
- 缺点:进行提前编译会有额外的困难
- 类加载时会稍微增加一些性能开销
运行期加载的优点:
- 运行期动态加载和动态连接:天生可以动态扩展。
例如:编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。
为了内容表述准确,本章的两条预先约定:
- “类型”都表示指“类或接口”:在实际情况中,每个Class文件都有可能代表一个类或接口,后文中直接对“类型”的描述都同时蕴含着类和接口的可能性,需要对类和接口分开描述的场景,会特别指明。
- “Class文件”都指的是字节流:和前面介绍Class文件格式时的约定一样,本章所提到的“Class文件”也并非特指某个存在于具体磁盘中的文件,而应当是一串二进制字节流,包括但不限于磁盘文件、网络、数据库、内存或者动态产生等。
7.2 类加载的时机
类型的生命周期:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。其中验证、准备、解析三个部分统称为连接(Linking)。如下图所示:
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序开始。注意,只是按照顺序开始。这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
何时进行解析?解析阶段的顺序则不一定:它有时可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
何时进行加载?《JVM规范》中没有强制规定,根据虚拟机的具体实现来自由把握。
何时进行初始化?《JVM规范》严格规定了对一个类型进行主动引用的情况下必须立即对类进行“初始化”。有且只有六种情况是主对类进行主动引用,此时若类型未初始化,则进行初始化:
遇到new、getstatic、putstatic或invokestatic这四条字节码指令时。生成这四条指令的典型Java代码场景有:
- 用new新建对象
- 读取或设置一个类型的
static
字段(final static常量除外) - 调用一个类型的
static
方法
使用java.lang.reflect包的方法对类型进行反射调用时。
当初始化类的时候,先初始化父类。
当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
若接口定义了default方法,且该接口的实现类发生了初始化,则该接口要在其之前被初始化。
除了对以上6种情况下对类型进行主动引用外,其他所有引用类型称为被动引用。被动引用不会触发初始化,
以下是三个被动引用的例子:
- 通过子类引用父类的静态字段,不会导致子类初始化
- 通过数组定义来引用类,不会触发此类的初始化
- 通过类引用常量,不会触发定义常量的类的初始化
接口的加载过程与类加载过程稍有不同,具体为前面6种情况中的第3种:当一个类在初始化时,要求其父类全部已经初始化。但接口在初始化时,并不要求其父接口全部完成初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
7.3 类加载的过程
1.加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段。完成3件事情:
- 全限定名 -> 字节流:通过一个类的全限定名来获取定义此类的二进制字节流。
- 字节流 -> 方法区运行时数据结构:将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 生成.class对象作为方法区访问入口:在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
《JVM规范》对以上三点没有具体要求,特别是,没有指明从哪里获取二进制字节流。所以有很多实现方式:
- 从ZIP压缩包中读取,后来发展为jar包。
- 从网络中获取:Web Applet。
- 运行时计算生成:动态代理,在java.lang.reflect.Proxy中为特定接口生成代理类二进制字节流。
- 从其他文件生成:如JSP。
- 从数据库读取:如SAP代码分发。
- 从加密文件获取:保密需要。
类加载可以分为两种文件,非数组类和数组类。两种类,加载方式不一样。
非数组类:
- 加载阶段可用JVM内置的引导类加载器来完成。
- 加载阶段也可以用用户自定义类加载器来完成。重写一个类加载器的 findClass() 或 loadClass() 方法,即可按自己的想法来获取运行代码。
数组类:
数组类本身由JVM直接在内存中动态构造,不通过类加载器创建。但数组类的元素类型(什么玩意?就是数组中具体元素的类型,比如,水果数组)由类加载器加载。数组类的创建遵循以下规则:
- 组件类型是引用类型(Object[]):数组C将被标识在加载该组件类型的类加载器的类名称空间上(唯一性)。
- 组件类型不是引用类型(int[]):把数组C标记为与引导类加载器关联。
- 数组类的可访问性与它的组件类型的可访问性一致,如果是非引用类型,则默认为public。(可访问性?就是权限啦,如public。数组类和数组的元素类不是一个东西)
加载阶段完成之后,二进制字节流就转化成了特定的类型数据格式存于方法区。该格式由JVM自己实现,规范未规定。
类型数据放在方法区后(元空间?),会在Java堆内存中实例化一个java.lang.Class类的对象,该对象将作为程序访问方法区类型数据的外部接口。(Class对象和静态变量在Java堆)
加载阶段与连接阶段的开始时间是按固定的先后顺序来的。之后两个阶段的部分动作是交叉进行的(如一部分字节码文件格式验证动作),加载阶段尚未完成,连接阶段可能已经开始。
2.验证
验证阶段的目的是确保Class文件的字节流信息符合《JVM规范》要求,防止代码到威胁JVM安全。
因为Java本身是相对安全的,数组越界等编译器都会拒绝编译。但是*.class文件并不全是由Java代码编译而来*。
验证阶段占用了虚拟机的类加载过程中相当大的比重的工作量。
验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
1.文件格式验证
格式验证:保证字节流符合Class文件格式规范,且能被正确地解析存储于方法区内。例如:
- 是否以魔数0xCAFEBABE开头。
- 主、次版本号是否在当前Java虚拟机接受范围之内。
- 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
......
之后3个验证阶段全部基于方法区上的存储结构,不再直接读取操作字节流。
2.元数据验证
类的元数据语义验证:保证是对类的元数据信息符合《Java语言规范》的要求。例如:
- 这个"类是否有父类"(除了java.lang.Object之外,所有的类都应当有父类)。
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个"类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法"。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
......
3.字节码验证
类的方法体语义验证:保证类的方法体(Class文件中的Code属性)在运行时不会危害虚拟机安全。
主要是通过类方法体数据流和控制流的分析,这个阶段是整个验证最复杂的阶段。对方法体校验,主要包括:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以"把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型",甚至把对象赋值给与它毫无继承关系、完全不相干的一个 数据类型,则"危险和不合法"的。
……
字节码验证不能保证验证到所有安全问题:离散数学的停机问题:不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。即释是通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来准确判定一段程序是否存在Bug。
为了减少字节码验证的性能消耗,JDK 6之后进行了优化,把尽可能多的校验辅助措施挪到Javac编译器里进行。给方法体Code属性的属性表中新增“StackMapTable”属性,该属性描述了开始时本地变量表和操作栈应有的状态。所以JVM进行字节码验证时,只需检查StackMapTable属性中的记录是否合法即可,无需再进行实时推导。
理论上恶意代码也可以在修改Code属性的同时,也生成相应的StackMapTable属性来骗过虚拟机的类型校验。这个问题是虚拟机设计者需要考虑的。
4.符号引用验证
这一步是发生在后面的连接的第三个阶段-解析阶段。发生在虚拟机将符号引用转化为直接引用的时候。对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,看下是不是都有且都有访问权限。例如:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(private、protected、public、<package>)是否可被当前类访问。
……
典型的报错就是:java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
验证阶段不是必须的,如果全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,那么在生产环境可以考虑使用-Xverify:none参数关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
3.准备
该阶段为static变量分配内存并设初始值。实例变量这个阶段不管,要等到对象实例化时才会和对象一起放在Java堆。
static变量和class对象都放Java堆上,逻辑上来讲是放方法区上。在JDK 7及之前,HotSpot使用永久代来实现方法区。在JDK 8及之后,方法区就完全是一种逻辑概念了。
以下赋值示例:
public static int value = 123;
静态变量,一般在准备阶段都是赋0值。然后具体的赋值操作,把“value赋值为123的putstatic指令”,是被编译后存放在类构造器<clinit>()方法之中,要到初始化阶段才会执行。
静态变量如果是常量(final static),那么在准备阶段就会直接被初始化为ConstantValue属性指定的初始值。ConstantValue属性存在类字段的字段属性表中。
基本数据类型的零值如下图:
4.解析
解析阶段JVM将常量池内的符号引用替换为直接引用。
符号引用在在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,分别对应类、字段、方法引用等。
符号引用和直接引用两个概念如下:
- 符号引用(Symbolic References):以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。《JVM规范》的Class文件格式明确定义了符号引用的字面量形式。
- 直接引用(Direct References):可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。和虚拟机实现的内存布局直接相关,有了直接引用,则引用的目标必定已经在虚拟机的内存中存在。
解析阶段何时发生:《JVM规范》并未规定具体时间,只规定了对17个操作符号引用的字节码指令之前,需要对它们使用的符号引用进行解析。所以,JVM根据需要来自行判断,可以在类被加载器加载时就对常量池中的符号引用进行解析,也可以等到符号引用被使用前才去解析它。
对方法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、 private、<package>)进行检查,这个属于前面的验证阶段。
JVM可以对符号引用的解析结果进行缓存:因为同一个符号引用可能会有多次解析请求。上面提到,有17个指令用到了符号引用。相对于这些指令,符号引用的解析分为了两种情况:
- 非invokedynamic指令:可以缓存解析结果。因为这些指令触发的符号引用是等效的,JVM会保证,第一次解析成功,以后都能解析成功。第一次解析失败,以后都是解析失败。
- invokedynamic指令:不缓存解析结果。因为不同invokedynamic指令触发的符号引用不等效。原因是,因为invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符(Dynamically-Computed Call Site Specifier)”,必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶 段,还没有开始执行代码时就提前进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符这7类符号引用进行。它们分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info、CONSTANT_InvokeDynamic_info 8种常量类型。其中,后4种主要用于动态语言支持,我们后面第8章介绍动态语言调用时再讲。Java是静态类型语言,我们先讲前4种。
1.类或接口的解析
假设当前所处类为D,符号引用为N,类或接口C是N的直接引用,解析的步骤如下:
如果C不是数组类型:JVM会把N的全限定名传给D的类加载器,类加载器再加载类C。加载过程中会验证元数据、字节码,然后又可能触发其他类加载,比如父类。
如果是数组类型,且元素类型为对象:按照前面的规则加载元素类型,然后JVM再生成一个代表该维度和元素的数组对象。比如是N的描述符是“[Ljava/lang/Integer”,那么就加载java.lang.Integer。
访问权限验证:如果前两步正常,那么此时C已经是JVM的一个有效类。最后一步则是要验证访问权限,确定D可以访问C。没权限就抛java.lang.IllegalAccessError异常。
关于模块的权限验证:
JDK9以后引入了模块化,public并不代表所有类都可以访问它。如果D对C有访问权限,则需要满足以下至少1个条件:
- C是public,且C和D在同一个模块。
- C是public,和D不在同一个模块,但是C所在的模块允许D的模块访问。
- C不是public,那么C和D要在同一个包。
2.字段解析
解析字段的符号引用时,先解析字段所在的类或接口的符号引用(也就是字段表内class_index项中索引的CONSTANT_Class_info符号引用)。解析完成后,假设这个字段所在的类是C,那么还要按如下顺序进行后续字段搜索,先在C找,再在接口找,再在父类找:
- C已经包含:C本身已经包含了简单名称和字段描述符都匹配的字段,直接返回该字段直接引用,查找结束。
- C未包含字段,但C实现了接口:按继承关系从下往上递归搜索各接口和它的父接口,若接口中包含了简单名称和字段描述符都匹配的字段,则返回该字段直接引用,查找结束。
- C未包含字段、也没有实现接口:若C不是Object,则按继承关系从下往上递归搜索其父类,若父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 以上都未找到,则抛java.lang.NoSuchFieldError异常。
若成功查到到了引用,则再这个字段进行权限验证。验证失败则抛java.lang.IllegalAccessError异常。
实际情况中,javac编译器执行的更加严格。例如,自己和父类中都有同样名字的字段出现,虽然可以确定唯一访问,但javac可能不会编译通过。如下代码:
// 作者说Oracle公司实现的javac编译不通过。
// 不过我在IDEA上编译通过了。
package org.fenixsoft.classloading;
public class FieldResolution {
interface Interface0 {int A = 0;}
interface Interface1 extends Interface0 {int A = 1;}
interface Interface2 {int A = 2;}
static class Parent implements Interface1 {public static int A = 3;}
static class Sub extends Parent implements Interface2 {public static int A = 4;}
public static void main(String[] args) {
System.out.println(Sub.A);
}
}
3.方法解析
Class文件格式中方法符号引用的常量类型定义,在类和接口中是分开的,不在一起。
第一步和字段解析一样,先解析出方法表的class_index[项中索引的方法所在的类或接口的符号引用。
之后我们假如用C表示这个类,那么按如下步骤搜索方法:
- 方法说自己的老大是接口:如果在类的方法表中发现class_index中索引的C是个接口,直接抛java.lang.IncompatibleClassChangeError异常。
- 在本类中查找:在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 在父类中查找:在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 在接口中查找:找到就说明该类实现了接口但没实现方法,也就说明该类是个抽象类:在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常。
- 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
查找成功返回直接引用后,同样会对这个方法进行权限验证。没有访问权限则抛出java.lang.IllegalAccessError异常。
4.接口方法解析
第一步和同样的,先解析出方法表的class_index项中索引的方法所在的类或接口的符号引用。
之后我们假如用C表示这个类,那么按如下步骤搜索方法:
- 方法说自己的老大是类:如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出java.lang.IncompatibleClassChangeError异常。
- 在本接口中查找:在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 在父接口中查找:在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 父接口中查找到多个怎么办:在第3步中,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找。《JVM规范》没有明确规定。和前面字段解析一样,不同发行商的Javac编译器有可能会按更严格的约束来拒绝编译这种代码,以免带来不确定性。
- 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
最后同样是对访问权限做验证。JDK9之前,所有接口方法都是public,不可能有访问权限问题。JDK9以后,接口中增加了接口的静态私有方法,也有了模块化的访问约束,接口方法也有可能无权限访问。
5.初始化
类的初始化阶段是类加载过程的最后一个步骤。
在前面的阶段里,用户程序只能在加载阶段通过自定义类加载器局部参与加载过程。直到初始化阶段,应用程序才取得主动权,JVM才真正开始执行类中编写的Java程序代码。
初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()
方法是Javac编译器的自动生成的。
以下是生成及执行逻辑:
如何生成
<clinit>()
方法:由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生。收集的顺序是按语句在源文件中出现的顺序决定。所以static语句块无法访问定义在它之后的变量。*
<clinit>()
方法无需显示调用父类的<clinit>()
*:JVM会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已执行完毕。这点和实例构造器<init>()
方法不同。所以JVM中第一个被执行的<clinit>()
方法的类型肯定是java.lang.Object
。父类static语句块优先于子类的变量赋值:这一点可以从前一点推导出来。
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); } // 输出2
类或接口不一定会生成
<clinit>()
方法:如果类中没有静态语句块,也没有对变量的赋值操作,那么就不会生成。接口无需先执行父类
<clinit>()
方法:1.虽然接口没有static语句块,但是它有变量初始化赋值操作,所以会生成<clinit>()
方法。2.和类的不同:执行方法前无需先执行父接口<clinit>()
方法,因为父接口在变量被使用到时才会被初始化。3.所以,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。多线程执行
<clinit>()
方法会保证同步:多个线程初始化类,JVM会保证只有一个线程会执行到<clinit>()
方法。其他线程阻塞,醒来后不会再去执行。同一个类加载器下,一个类型只会被初始化一次。另外,在一个类的<clinit>()
方法中如果有耗时很长的操作,那就可能造成多个进程阻塞,这个问题比较隐蔽,注意识别。
7.4 类加载器
类加载器用来做什么:Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。
最早类加载器是用来实现Java Applet的,现在已经很少用。不过这个创新却在类层次划分、OSGi、程序热部署、代码加密等领域大放异彩。
1.类与类加载器
每一个类加载器,都拥有一个独立的类名称空间。每一个类,必须由加载它的类加载器和这个类本身共同确立其在JVM中的唯一性。
两个类即使来源于同一个.class文件,在同一个JVM,只要加载它们的类加载器不一样,它们就不相等。
这里相等包括:Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法,对象使用instanceof关键字做所属关系判定等。
例如,以下我们自定义一个类加载器:
/*** 类加载器与instanceof关键字演示 @author zzm */
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);
}
}
// 运行结果,注意点对象虽然属于类,但是instanceof结果却为false
// class org.fenixsoft.classloading.ClassLoaderTest
// false
2.双亲委派模型
从JVM的角度,类加载器可以分为两种类型:
- 启动类加载器(Bootstrap ClassLoader):由C++实现(JDK9后用Java回调JNI),是JVM自身的一部分。
- 其他类加载器:由Java实现。独立存在于JVM外部。且全都继承自抽象类 java.lang.ClassLoader。
从Java开发人员的角度来说,类加载器分得更细。JDK1.2之后,Java的类加载用的是三层类加载器、双亲委派的架构。
以下进行详细介绍:
一、三层类加载器:
启动类加载器(Bootstrap Class Loader):
实现方式:C++实现,JVM自身的一部分。
加载路径:负责加载存放在*
\lib目录,或者-Xbootclasspath参数指定的路径中存放的类库。这些类库要能被JVM识别*才会被加载(根据文件名)。如rt.jar、tools.jar。名字不符合,放在正确的路径也不会被加载。 如何引用:约定直接以null引用该加载器:由于C++实现,无法被Java程序直接引用。约定用户在编写自定义类加载器时,如需把加载请求委派给引导类加载器处理,则直接以null代替即可。如下:
public ClassLoader getClassLoader() { ClassLoader cl = getClassLoader0(); if (cl == null) return null; SecurityManager sm = System.getSecurityManager(); if (sm != null) { ClassLoader ccl = ClassLoader.getCallerClassLoader(); if (ccl != null && ccl != cl && !cl.isAncestor(ccl)) { sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION); } } return cl; }
扩展类加载器(Extension Class Loader):
- 实现方式:由
sun.misc.Launcher$ExtClassLoader
以Java实现。 - 加载路径:负责加载*
\lib\ext目录中,或者被java.ext.dirs系统变量指定的路径中所有的类库。这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能*。 - 如何引用:可直接在程序中引用使用该类加载器来加载Class文件。
- 实现方式:由
应用程序类加载器(Application Class Loader):
- 实现方式:由
sun.misc.Launcher$AppClassLoader
以Java实现。 - 加载路径:负责加载*用户类路径(ClassPath)*上所有的类库。
- 如何引用:直接在代码中使用这个类加载器即可。由于该加载器是**
ClassLoader
类中的getSystemClassLoader()
方法的返回值,所以有些场合中也称它为“系统类加载器”*。如果应用程序中没有自定义过自己的类加载器,那么一般情况下它就是程序中默认的类加载器*。
- 实现方式:由
自定义类加载器:JDK9前,以上三种加载器即可完成工作。如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的Class文件来源,或者通过类加载器实现类的隔离、重载等功能。
二、双亲委派架构模型
上述4中类加载器,一般以以下层次关系来搭配使用,该结构也被称为双亲委派模型,如下图:
双亲委派模型具体实现:
- 加载器必须要有爹:除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。父子关系一般推荐组合实现,不推荐继承实现。
- 类先给爹加载:类加载器收到了类加载请求后,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。所以所有加载请求最初都是在启动类加载器尝试加载的。
- 不是强制的要求:只是Java设计者们推荐给开发者的一种类加载器实现的最佳实践。
使用双亲委派模型的好处:
- 可以顺带保证Java类的优先级:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它在rt.jar类库中,无论哪个类加载器要加载,都可以保证它都是通过启动类加载器加载的。这也保证了Object类在程序的各种类加载器环境中都是同一个类。如果我们也实现一个Object类,会发现它可以编译通过,但永远不会被加载。
双亲委派模式如何实现:
实现双亲委派的代码很少,全部集中在java.lang.ClassLoader的loadClass()
方法之中,如下:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
3.破坏双亲委派模型
双亲委派模型只是建议的最佳实践,也有被破坏的时候。这里的“破坏”不是贬义词。
直到Java模块化出现为止,总共有3次较大规模破坏的情况:
一、为了兼容历史代码
双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在。推出双亲委派模型后,就要对已经存在的用户自定义类加载器的代码进行兼容。
双亲委派模式是在java.lang.ClassLoader
的loadClass()方法实现的,占用了之前用户写加载逻辑的loadClass()方法。为了避免loadClass()被子类覆盖,在JDK 1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户在编写的类加载逻辑时尽可能去重写这个方法。按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载。所以把自己实现的加载逻辑写到findClass()方法里。
简单来说,重写findclass()还是符合双亲委派机制,但是如果直接重写loadclass()的话,那基本就破坏了规则了。本来双亲委派机制的逻辑就是在loadclass()里面的,都被你重写了,当然就被破坏啦。
二、解决父加载器想调子加载器的需求
按照双亲委派模型,类肯定是先由父加载器加载,再由子加载器加载。越是基础的类型越是由越上层的加载器加载。
但是有基础类型需要调用回用户的代码的需求怎么办呢?这个需求是存在的。例如JNDI(Java Naming and Directory Interface)服务,JNDI的作用就是管理和调用其他产商的SPI(Service provider interface)代码,而rt类加载器肯定是不认识这些代码的。它的代码由启动类加载器来完成加载,但它存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码。典型的就是JDBC。
为了解决这个问题,Java的设计团队引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread
类的setContextClassLoader()
方法进行设置。创建线程时没有设置的话会从父线程继承,默认是应用程序类加载器。
JNDI服务使用线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为。如果有多个SPI,则代码需要根据硬编码判断。在JDK 6时,JDK提供了java.util.ServiceLoader
类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
三、实现对程序动态性的追求
这里的动态就是类似于代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。希望可以不需要重启服务,直接进行热部署来更新程序代码。
典型的就是OSGi。OSGi是JDK9前最普及的模块化系统,它还可以实现模块化的热部署。它实现热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器是网状的。加载类的顺序如下:
1)将以java.*开头的类,委派给父类加载器加载。
2)否则,将委派列表名单内的类,委派给父类加载器加载。
3)否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
7)否则,类查找失败。
上述查找顺序中,只有开头两点符合双亲委派模型原则,其余的类查找都是在平级的类加载器中进行的。
7.5 模块化系统
JDK9引入了Java模块化系统,目的是实现可配置的封装隔离机制。为此JVM的类加载架构也进行了调整。
JDK9的模块不仅仅是代码的容器,还包括:
·依赖其他模块的列表。
·导出的包列表,即其他模块可以使用的列表。
·开放的包列表,即其他模块可反射访问模块的列表。
·使用的服务列表。
·提供服务的实现列表。
1.模块的兼容性
传统的是“类路径查找机制”,JDK9是“可配置的封装隔离机制”。为了兼容老的方式,JDK9提出了“模块路径”(ModulePath)的概念,与“类路径”(ClassPath)相对应。简单来说,就是。只要在类路径上的JAR文件都会被当作传统的JAR包来对待(无论是否包含module-info.class文件模块化信息)。只要放在模块路径上的JAR文件都会被当作一个模块来对待(即使JMOD后缀,甚至不包含module-info.class文件)。
JDK9已经用模块来封装了JavaSE标准类库。不过通过以下规则保持了兼容性:
- JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块(Unnamed Module)里。该模块几乎没有任何隔离。
- 模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖的模块和包,访问不到匿名模块(即看不见传统JAR包的内容)。
- JAR文件在模块路径的访问规则:Jar包会变成一个自动模块(Automatic Module),它会默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,它也默认导出自己所有的包。
Java模块化系统目前不支持在模块定义中加入版本号来管理和约束依赖。这点比较蛋疼,因为没法像OSGi那样支持多版本模块并存、支持运行时热替换、热部署模块了。模块的运行时部署、替换能力没有内置在Java模块化系统和JVM之中,仍然必须通过类加载器去实现。其实class文件里是有版本号信息的,似乎是为了表示不和OSGi抢饭碗,所以没有在更高的层面支持模块版本化。
2.模块化下的类加载器
JDK 9并没有从根本上动摇三层类加载器架构以及双亲委派模型,但是进行了一些改动:
一、用平台类加载器(Platform Class Loader)取代扩展类加载器(Extension Class Loader)因为整个JDK都基于模块化重构了,原来的rt.jar和tools.jar被拆分成数十个JMOD文件,已天然地满足了可扩展的需求。类似地,在新版的JDK中也取消了
二、平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全都继承自jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
三、启动类加载器由“C++和JVM内部改为“JVM和Java类库”共同协作实现。新增了BootClassLoader的Java类,不过保持兼容,所有在获取启动类加载器的场景(譬如Object.class.getClassLoader())中仍然会返回null来代替,而不会得到BootClassLoader的实例。
四、第四次破坏了双亲委派模型:当平台类加载器及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。
JDK9的委派关系如下图:
三个类加载器负责各自加载的模块,归属关系如下:
# 启动类加载器负责加载的模块:
java.base java.security.sasl
java.datatransfer java.xml
java.desktop jdk.httpserver
java.instrument jdk.internal.vm.ci
java.logging jdk.management
java.management jdk.management.agent
java.management.rmi jdk.naming.rmi
java.naming jdk.net
java.prefs jdk.sctp
java.rmi jdk.unsupported
# 平台类加载器负责加载的模块:
java.activation* jdk.accessibility
java.compiler* jdk.charsets
java.corba* jdk.crypto.cryptoki
java.scripting jdk.crypto.ec
java.se jdk.dynalink
java.se.ee jdk.incubator.httpclient
java.security.jgss jdk.internal.vm.compiler*
java.smartcardio jdk.jsobject
java.sql jdk.localedata
java.sql.rowset jdk.naming.dns
java.transaction* jdk.scripting.nashorn
java.xml.bind* jdk.security.auth
java.xml.crypto jdk.security.jgss
java.xml.ws* jdk.xml.dom
java.xml.ws.annotation* jdk.zipfs
# 应用程序类加载器负责加载的模块:
jdk.aot jdk.jdeps
jdk.attach jdk.jdi
jdk.compiler jdk.jdwp.agent
jdk.editpad jdk.jlink
jdk.hotspot.agent jdk.jshell
jdk.internal.ed jdk.jstatd
jdk.internal.jvmstat jdk.pack
jdk.internal.le jdk.policytool
jdk.internal.opt jdk.rmic
jdk.jartool jdk.scripting.nashorn.shell
jdk.javadoc jdk.xml.bind*
jdk.jcmd jdk.xml.ws*
jdk.jconsole
7.6 本章小结
本章介绍了类加载过程的“加载”“验证”“准备”“解析”和“初始化”这5个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
第6、7章我们已经知道了如何在Class文件中定义类,以及如何将类加载到虚拟机中。下面第8章, 我们看看虚拟机如何执行定义在Class文件里的字节码。
提问
你觉得Java语言最大的特点?1.虚拟机自动垃圾回收。2.运行期加载。
运行期加载的优缺点?
类加载的顺序及生命周期?何时进行加载?何时进行解析(为何)?何时进行初始化?接口与类的初始化有何不同?
加载阶段完成什么工作?整体流程?哪里有扩展点?数组和非数组类加载有何区别?
为什么需要验证?验证阶段完成哪些工作?分哪几个验证阶段?
准备阶段用来干啥?public int a=1, public static int a=1,public static final int a=1赋值了几次?分别在哪完成?1..2两次,准备及初始化的构造方法中。3一次,常量属性里。
解析阶段何时发生?需要解析哪些内容?如何解析查找?
类初始化阶段的过程?
类加载器如何判断两个类相等?
类加载器有哪些分类?什么是双亲委派模型?解决了什么问题?碰到类先给爹加载
有哪些情况下没有用到双亲委派模型?
模块化的目的是什么?OSGi用来干啥的?JDK9用哪些规则保证了兼容性?双亲委派模型进行了哪些更改?
第8章 虚拟机字节码执行引擎
8.1 概述
“虚拟机”是一个相对于“物理机”的概念。
物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的。虚拟机的执行引擎则是由软件自行实现。
在《JVM规范》中制定了JVM字节码执行引擎的概念模型。不同厂商实现的虚拟机不一样:
- 解释执行
- 编译执行
- 解释执行和编译执行两者兼备
- 还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎
不过输入和输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
本章主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持方法调用和方法执行的数据结构。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。
栈帧需要的内存,在Java程序源码编译阶段就已被确定。编译时,栈帧中局部变量表的大小、操作数栈的深度就已被分析计算,并且写入到方法表的Code属性之中。
对于执行引擎来讲,在活动线程中,只有栈顶的方法才是运行的,只有栈顶的栈帧才是生效的。栈顶的栈帧被称为“当前栈帧”(Current Stack Frame),与这个栈帧关联的方法被称为“当前方法”(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
典型的栈帧结构如下图:
下面我们来详细了解栈帧中的局部变量表、 操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。
1.局部变量表
用于存放方法入参和局部变量。
Java程序编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法局部变量表的最大容量。
一、如何存储数据:
局部变量表的容量以变量槽(Variable Slot)为最小单位进行存储。《Java虚拟机规范》没有规定槽占几个字节,只规定了1个槽要能放下1个以下数据结构,共8种:
- boolean、byte、char、short、int、float:简单的当Java里的类型理解。不过要注意Java语言和JVM中的基本数据类型是存在本质差别的。
- reference:引用。《JVM规范》没有规定它的长度和结构(它的长度与实际使用32位还是64位虚拟机有关,如果是64位虚拟机,还与是否开启某些对象指针压缩的优化有关)。但一般引用应该要能完成两件事情:
- 查找对象地址:根据引用可以直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引。
- 查找对象类型信息:根据引用可以直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
- returnAddress:目前已经很少见,早期用于处理异常。为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址。现在这几天指令的异常处理跳转已经全部改为采用异常表实现。
对于64位的数据(Java中明确的64位的数据类型只有long和double两种),JVM会以高位对齐的方式为其分配2个连续的变量槽。由于栈帧是线程私有的,所有不存在数据竞争和线程安全问题。
JVM通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果变量是32位的,则索引N代表第N个槽位。如果变量是64位的,则会同时使用第N和N+1个变量槽。虚拟机不允许单独访问64位变量两个变量槽中的某一个。
数据存储顺序:如果执行的是实例方法,局部变量表中第0位索引的变量槽用来存储this对象的引用,其余入参从第1位存储,最后再根据声明顺序存储局部变量。
二、局部变量表的槽重用
局部变量表的变量槽可重用。局部变量超出作用域后,它的槽将被其他变量重用。从概念模型上来说,如果没有被其他变量重用,GC不会回收它。因为局部变量表还会保持对它的关联,由于局部变量表是GC Roots的一部分,所以GC也不敢回收它。以下是代码示例:
// 我们填充64MB,看是否能被回收。
public static void main(String[] args)() {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}// 结果:未被回收。因为还没离开作用域,GC不敢回收。
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}// 结果:未被回收。虽然离开了作用域,但还未被其他变量复用。所以局部变量表仍然保持对它的关联。而局部变量表是GC Roots的一部分。
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}// 结果:成功被回收。
针对以上实验,《Practical Java》有一天推荐的编码规则“不使用的对象应手动赋值为null”(代替上面int a=0的作用)。
不过作者并不认同上述规则,因为这个优化是建立在对字节码执行引擎概念模型之上的,实际上,Java使用编译器执行时,与概念模型差别比较大。经过即时编译,会加入各种优化措施。赋null可能会被判别无效。且编译优化后,对GC Roots枚举也会和解释执行有较大差异,实际上上述第2步,即时编译就能完成GC的优化了。
关于局部变量表,还有一点,它不同于类变量有“准备阶段”先赋0再初始化,局部变量表不赋初始值根本没法执行。不过Java编译器会对这个进行检查。即使编译器没检查,字节码校验也通过不了。
2.操作数栈
操作数栈也被称为操作栈。
和局部变量表一样,它的最大深度也在编译的时候就被计算好后写入到Code属性的max_stacks数据项中。32位数据类型所占的栈容量为1,64位为2。
方法刚开始执行时,操作数栈是空的。执行过程中,有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。例如,算术运算将涉及的操作数栈压入栈顶后调用运算指令,又如在调用其他方法时是通过操作数栈来进行方法参数的传递。
操作数栈中元素的数据类型须与字节码指令的序列严格匹配,在编译程序代码和校验阶段的数据流分析,都会进行验证。例如,不能出现一个long和一个float使用iadd命令相加的情况。
操作数栈的优化:在概念模型中,两个不同栈帧是属于不同方法的,是完全相互独立的。实际实现时,会进行优化,两个栈帧会重叠一部分。下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠,这样在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递。而且还节约了一些空间。如图所示:
3.动态连接
每个栈帧都有一个引用指向运行时常量池中该栈帧所属的方法。持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。动态连接就是符号引用需要在每一次运行期间都转化为直接引用。
我们知道字节码的方法调用指令是以常量池中指向方法的符号引用作为参数的。这些符号引用有两种解析方式:
- 静态解析:类加载阶段或者第一次使用的时候就被转化为直接引用。
- 动态连接:在每一次运行期间都转化为直接引用。
4.方法返回地址
方法执行开始后,有两种方式可以退出:
- 正常调用完成:执行引擎遇到任意一个方法返回的字节码指令时退出。可能会有返回值传递给上层的方法调用者(也叫主调方法)
- 异常调用完成:方法执行的过程中遇到了异常(JVM内部或athrow指令产生),且要在本方法的异常表中没有搜索到匹配的异常处理器,此时方法退出。
无论怎么退出,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行。方法返回时也可能需要在栈帧中保存一些信息来帮助主调方法恢复执行状态。
方法退出时需要保存返回地址:方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存PC计数器的值。
方法退出相当于当前栈帧出栈。执行的操作可能有:
- 恢复上层方法的局部变量表和操作数栈
- 把返回值(如果有的话)压入调用者栈帧的操作数栈中
- 调整PC计数器的值以指向方法调用指令后面的一条指令等
5.附加信息
《JVM规范》允许JVM在具体实现时,在栈帧中增加一些规范里没有描述的信息,例如与调试、性能收集相关的信息等,取决于具体的虚拟机实现。
在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
8.3 方法调用
方法调用不代表方法中的代码被执行。方法调用阶段的唯一任务是确定调用哪一个方法(即被调用方法的版本 ),暂时还不涉及方法内部的具体运行过程。
由于Class文件的编译没有“连接”步骤,一切方法调用在Class文件里面存储的都只是一个常量池中的方法符号引用,而不是直接引用(即方法在实际运行时内存布局中的入口地址)。某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。不过这也为Java带来了更强大的动态扩展能力。
1.解析
所有被调用的方法,在Class文件里面都是一个常量池中的符号引用。
一、解析与分派
这个符号引用,可能会在两个阶段转化为直接引用:
- 类加载的解析阶段:如果方法符合“编译期可知,运行期不可变”的要求(即在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的),那么可以在类加载阶段就把符合引用转化为直接引用。这类方法的调用也被称为解析。
- 运行阶段:这些方法需要在运行期才能确定版本,比如重写、多态等。这类方法的调用称为“分派”。分派要复杂许多,它可能是静态或动态的,按照分派依据的宗量数可分为单分派和多分派。两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派。这个下一节讲。
二、方法调用指令及虚方法
字节码指令集有以下5条指令来调用不同的方法:
- invokestatic。用于调用静态方法。
- invokespecial。用于调用实例构造方法<init>()、私有方法和父类中的方法。
- invokevirtual。用于调用所有的虚方法。(历史设计原因,包括被final修饰的方法)
- invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。
根据以上调用指令,把方法分为了虚方法和非虚方法:
- 非虚方法:所有能被invokestatic和invokespecial指令调用的方法(共有静态方法、实例构造方法、私有方法、父类方法4种),再加上被final修饰的方法(由于历史原因它用invokevirtual指令调用)。它们没有任何途径可以覆盖或隐藏。
- 虚方法:除了非虚方法外的方法。
非虚方法会被解析。
下一节讲分派。
2.分派
根据静态或动态,宗量数单分派和多分派。两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种。
本节讲解的分派调用过程,主要涉及“重载”和“重写”在JVM中是如何实现等。
1.静态分派
一、静态分派的概念
以下是代码示例:
public class StaticDispatch {
static abstract class Human {}
static class Man extends Human {}
static class Woman extends Human {}
public void sayHello(Human guy) { System.out.println("hello,guy!"); }
public void sayHello(Man guy) {System.out.println("hello,gentleman!"); }
public void sayHello(Woman guy) {System.out.println("hello,lady!"); }
public static void main(String[] args) {
// 静态类型是父类,实际类型是子类
Human man = new Man();
Human woman = new Woman();
// 示例1:静态类型是父类,实际类型是子类。入参按父类分派重载。
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man); // hello,guy!
sr.sayHello(woman); // hello,guy!
// 示例2:静态类型是父类,实际类型是子类。入参进行了强转,按子类分派重载。
sr.sayHello((Man)man); // hello,gentleman!
sr.sayHello((Woman)woman); // hello,lady!
Man man2 = new Man();
Woman woman2 = new Woman();
// 示例3:静态类型是子类,实际类型是子类。入参进行了强转,按子类分派重载。
StaticDispatch sr2 = new StaticDispatch();
sr2.sayHello(man2); // hello,gentleman!
sr2.sayHello(woman2); // hello,lady!
}
}
先阐明两个概念,静态类型和实际类型:
Human man = new Man();
上述代码中,Human为静态类型。Man为实际类型,也叫运行时类型。
实际运行时,静态类型和实际类型在程序中都可能会发生变化,但最终的静态类型是在编译期可知的,而实际类型变化的结果在运行期才可确定。举例如下:
// 实际类型变化:多态赋值
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化:强转
sr.sayHello((Man) human) sr.sayHello((Woman) human)
虽然静态类型也可以变化(强转),但是这个变化编译期是可知的。所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本。所以上述示例中,不强转执行父类方法,强转后执行了子类方法。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载:方法入参决定。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。
二、静态分派只是选择一个“相对更合适的”版本
虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更合适的”版本。
原因是字面量天生具有模糊性,比如说’a’,我可以把它看做char、Character、Object、Object[]等等。所以字面量的静态类型,javac编译器只能通过语言、语法的规则去理解和推断。
以下是示例:
public class Overload {
public static void sayHello(char arg) { System.out.println("hello char"); }
public static void sayHello(int arg) { System.out.println("hello int"); }
public static void sayHello(long arg) { System.out.println("hello long"); }
public static void sayHello(Character arg) { System.out.println("hello Character"); }
public static void sayHello(Serializable arg) { System.out.println("hello Serializable"); }
public static void sayHello(Object arg) { System.out.println("hello Object"); }
public static void sayHello(char... arg) { System.out.println("hello char ..."); }
public static void main(String[] args) {
sayHello('a'); // "hello char"
}
}
如果我们从上到下把方法一一注释,那么我们会发现方法会一一重载为最接近的方法。字面量’a’的判断顺序是:char -> int -> long -> Character -> Serializable -> Object ->char[]
。
Serializable
被执行是因为Character
实现了该接口。Character
也实现了Comparable<Character>
接口。如果我们在Serializable
方法后面再加一个Comparable<Character>
的方法,那么编译器会提示“类型模糊”(Type Ambiguous),并拒绝编译。不过如果我们通过修改字节码绕过编译器,我们发现,JVM仍然是可以正常执行的。但是会选择Serializable还是Comparable<Character>的重载方法则并不能事先确定。这是《Java虚拟机规范》所允许的,在第7章介绍接口方法解析过程时曾经提到过。
最后提一点:解析与分派并不是二选一的关系,它们是在不同层次上去筛选、确定目标方法的过程。比如前面说过静态方法会在编译期确定、在类加载期就进行解析。而静态方法有重载的话,重载版本则是通过静态分派完成的。
2.动态分派
动态分派与重写(Override)有着很密切的关联。(动态分派根据动态类型来决定方法执行版本?)
一、动态分派举例
下面还是用和前一节相似的代码来举例:
public class DynamicDispatch {
static abstract class Human { protected abstract void sayHello();}
static class Man extends Human {
@Override protected void sayHello() { System.out.println("man say hello"); }
}
static class Woman extends Human {
@Override protected void sayHello() {System.out.println("woman say hello"); }
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello(); // man say hello
woman.sayHello(); // woman say hello
man = new Woman();
man.sayHello(); // woman say hello
}
}
可以看到,方法的行为是根据对象的动态类型来决定的。
JVM是如何根据实际类型来判断应该调用哪个方法版本的呢?
二、动态分配执行的字节码指令是一样的
我们把上述代码用javap输出为字节码。可以看到man.sayHello();
和woman.sayHello();
执行的字节码指令是一样的。说明动态分配只跟字节码指令invokespecial
具体执行的逻辑有关,而不是通过使用不同的字节码指令或参数来实现的。
以下是字节码指令:
public static void main(java.lang.String[]);
Code:Stack=2, Locals=3, Args_size=1
0: new #16; //class org/fenixsoft/polymorphic/DynamicDispatch$Man
3: dup
4: invokespecial #18; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Man."<init>":()V
7: astore_1
8: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
11: dup
12: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
24: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
27: dup
28: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
36: return
可以看到,0-15行是初始化。16、20行是把对象压入栈顶,17、21行是调用invokevirtual
指令执行方法。可以看到,两行调用的指令和符号引用都是一模一样的。
三、具体的invokevirtual解析过程
解析即符号引用转变为直接引用的过程。man.sayHello();
和woman.sayHello();
,它们的字节码指令invokevirtual
和符号引用都是一模一样的,但是产生了不一样的结果。说明动态分配是根据invokevirtual
指令具体的解析和执行来实现的。
《JVM规范》规定的invokevirtual
指令运行时解析,大致有以下几步:
- 找到对象的实际类型:找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 在实际类型中找匹配方法:如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则在父类中找匹配方法:按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
- 没找到则抛异常:如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
invokevirtual
指令第一步就是在运行期确定主调对象的实际类型,这个过程就是Java语言中方法重写的本质。
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派,如方法重写:方法具体调用者决定
四、动态分派只对方法有效,对字段无效
上文我们知道方法重写的多态的本质是invokevirtual
指令,而invokevirtual
指令是调用方法的指令。也就是说,动态分派只对方法有效,对字段无效。
也就是说,字段永远不参与多态。如果子类声明了和父类同名的字段,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的字段。
举例:
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
this.showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + this.money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
this.showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + this.money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
// 加this的所有地方,加不加结果都一样。
// 输出:
// I am Son, i have $0
// I am Son, i have $4
// This gay has $2
}
上述程序中,父类构造方法里,对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法。
3.单分派与多分派
方法的宗量:执行方法的对象(所有者)称为方法的接收者。方法的接收者与方法的参数统称为方法的宗量。即:1.哪个对象调用它。2.入参对象什么类型。
单分派与多分派:根据分派基于多少种宗量,分派划分为单分派和多分派两种。单分派根据一个宗量选择目标方法。多分派根据多个宗量选择。
以下结合例子理解:
public class Dispatch {
static class QQ {}
static class _360 {}
public static class Father {
public void hardChoice(QQ arg) { System.out.println("father choose qq"); }
public void hardChoice(_360 arg) { System.out.println("father choose 360"); }
}
public static class Son extends Father {
public void hardChoice(QQ arg) { System.out.println("son choose qq"); }
public void hardChoice(_360 arg) { System.out.println("son choose 360"); }
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
// 结果
// father choose 360
// son choose qq
Son son2 = new Son();
son2.hardChoice(new QQ()); // son choose qq
}
}
Java是静态多分派,动态单分派的语言(到目前JDK13为止):
首先明确上述father.hardChoice(new _360());
指令的执行过程,静态分派在编译阶段,动态分派在连接的解析阶段:
- 静态分派:生成
invokevirtual
指令,以及指令中方法的符号引用。 - 动态分派:执行
invokevirtual
指令,执行时实时将符号引用解析为直接引用。
以father.hardChoice(new _360());
的方法分派,我们来分析一下:
- 静态分派阶段,有两个宗量可以影响分派:一是静态类型。二是方法参数类型。我们生成
invokevirtual
指令,方法的**符号引用是Father::hardChoice(360)
**。可以看到,静态类型和方法入参类型都是两个变量,可以影响方法的符号引用签名。所以静态分派是多分派。 - 动态分派阶段,只有一个宗量,即主调对象类型可以影响。因为动态分派是依据*
invokevirtual
指令的解析及执行来实现的。而指令解析过程,也就是符号引用变为直接引用的过程,只与具体的主调对象的动态类型有关,和主调对象的静态类型以及方法入参类型*都没有关系。对于360入参,JVM根本不关心它是奇虎360还是京东360。所以动态分派是单分派。
自己总结补充:
静态分派与动态分派区别:
- 静态分派根据类的静态类型分派,在编译阶段就分派,因为可以提前确定对象类型。
- 动态分派根据对象具体类型分派,在运行时才分派,因为可以要到运行时才知道对象类型。
- 两个宗量:方法的调用者,方法的入参。
Java静态多分派,动态单分派:
- 静态多分派:调用者静态类型,入参静态类型(包括强制指定),都可以影响具体的方法选择。
- 动态单分派:只有调用者类型能影响具体的方法选择。
var与dynamic:
C#之前也是静态多分派动态单分派,但4.0中引入了dynamic类型后,C#实现动态多分派。JDK10的var和dynamic不是一个概念。var是在编译时根据声明语句中赋值符右侧的表达式类型来静态地推断类型,这本质是一种语法糖;而dynamic在编译时完全不关心类型是什么,等到运行的时候再进行类型判断。
安装目前Java语言的趋势,它没有直接变为动态语言的迹象,而是通过内置动态语言(如 JavaScript)执行引擎等方式来满足动态性需求。
但是JVM层面,早在JDK 7中就开始提供对动态语言的方法调用支持了,JDK 7中新增的invokedynamic指令也成为最复杂的一条方法调用的字节码指令。后文会讲。
4.虚拟机动态分派的实现
那么JVM具体是如何做到分派的呢?不同JVM的实现可能不同。可以用虚方法表等优化。
动态分派的时候,我们需要到调用方法的实际对象的类型的方法元数据里面去匹配方法。
频繁的搜索类型元数据,效率很低。所以我们给出了优化手段:是为类型在方法区中建立一个虚方法表,直接使用虚方法表的索引来进行查找。如下图:
虚方法表中存放着各个方法的实际入口地址。如果方法没被子类重写,那么子类的虚方发表中地址入口与父类地址入口一致,都指向父类的实现入口。如果方法被子类重写,那么该地址会替换为子类实现版本的入口地址。如上图,Son重写了所有方法,所以没有指向Father。
然后为了程序实现方便,签名相同的方法,在父类、子类的虚方法表中的索引序号也一样。这样,当类型变更时,只要变更要查找的方法表就可以了。
虚方法表一般在类加载的连接阶段进行初始化,类的变量初始值后,类的虚方法表也一同初始化完毕。
Java对象里面的方法,除了final方法,其他默认都是虚方法。除了虚方法表的优化,还有其他更非稳定的激进的优化方式:使用类型继承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(InlineCache)等。后面11章分析。
8.4 动态类型语言支持
为了支持动态类型语言(Dynamically Typed Language),JDK7引入了invokedynamic
指令。这也是为JDK8实现Lambda表达式而做的技术储备。
1.动态类型语言
我们先来看下什么是动态类型语言?它与Java语言、Java虚拟机有什么关系?
动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待。
一、动态类型与静态类型语言的差别:
动态类型语言:在运行期才进行类型检查的主体过程。变量obj本身并没有类型,变量obj的值才具有类型
静态类型语言:在编译期就进行类型检查过程。变量obj本身有类型
动态类型语言只看方法不看对象的静态类型或动态类型。
二、什么是类型检查
1.连接时与运行时:
如下代码:
public static void main(String[] args) {
int[][][] array = new int[1][0][-1];
}
Java可正常编译,但是运行时抛运行时异常。C在编译时就抛连接时异常。
在编译期还是运行期进行某种检查行为,主要看语言约定,和动态静态语言关系不大。
2.类型检查
如下代码:
obj.println("hello world");
假设代码是在Java里,且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是是PrintStream的子类。否则,哪怕有同样签名的方法,也不能运行,因为类型检查不合法。
假设代码是在JS里,不管obj是啥类型,只要它方法定义里有这个方法,就能运行。
产生这种差别产生的根本原因是Java语言在编译期间就已将println(String)方法完整的符号引用生成出来,并作为方法调用指令的参数存储到Class文件中(本例中为一项CONSTANT_InterfaceMethodref_info常量)。
而JS等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型。
静态类型语言与动态类型语言比较,没有最好的语言,只有合适不合适:
- 静态类型语言:能够在编译期确定变量类型,编译器可提供全面严谨的类型检查,利于发现潜在问题,利于稳定性及让项目容易达到更大的规模。
- 动态类型语言:在运行期才确定类型,这可以为开发人员提供极大的灵活性,提升开发效率。
2.Java与动态类型
我们来看看Java语言、JVM与动态类型语言之间有什么关系?目前已经有Groovy、Jython和JRuby等动态语言跑在JVM上。
JDK7为什么要引入invokedynamic指令?
JDK7引入invokedynamic
指令是在JVM层面上对动态类型进行直接支持。我们来看下假如没有invokedynamic
指令,JVM应该如何实现动态类型语言?
动态类型语言就是说,只有到了运行期,才会知道是哪个对象调用的方法,也才会知道对象是哪种类型。
我们先来看静态类型语言:我们知道,方法调用的4条指令(invokevirtual、invokespecial、invokestatic、invokeinterface),第一个参数都是被调用的方法的符号引用。这个符号引用是编译时就产生的。比如说,符号引用是Father::buySomething(Money);
。可以看到,我们分派的时候,是知道主调对象的静态类型是Father类型的(不过是它还是它的子类),那么这个时候,可以根据Father类型这个提示,很快速的找到分派方法,也可灵活运用方法内联等进行优化。方法内联第11章会讲。
再来看动态类型:动态类型语言只有在运行期才能确定方法的主调对象,之前根本不知道它是什么类型。实现复杂度很高:为了实现动态类型在运行期才识别,我们必须通过一些间接的手段来支持,比如说,编译时留个占位符类型,运行时动态生成字节码来进行具体类型和占位符类型的适配。这样实现复杂度会很高。无法进行有效优化:由于无法确定调用对象的静态类型,方法内联这个最重要的优化方式也无法进行。
所以,我们需要引入invokedynamic
指令来进行直接支持。所以JDK 7实现了JSR-292提案的invokedynamic指令以及java.lang.invoke包。
举个例子:
var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){ item.sayHello(); }
这个代码,动态类型语言是支持的。JVM间接实现的话,编译器只能不停编译它所遇见的每一个sayHello()方法,并缓存起来供执行时选择、调用和内联。如果arrays数组中不同类型的对象很多,就会对内联缓存产生很大的压力。
3.java.lang.invoke包:方法句柄
invoke包用来干嘛?之前要确定调用的目标方法,都是单纯依靠符号引用来确定。invoke包提供了一种新的动态确定目标方法的机制,这个机制叫“方法句柄”(Method Handle)。
.invoke包是为了解决原有4 条“invoke”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。虚拟机要固化分派规则的话,支持动态类型语言类型时要缓存所有遇到的某方法,累死它。而转变为让用户来指定的话会更轻松,不用全部缓存。
一、方法句柄是什么?怎么用?
方法句柄是什么?我们知道动态类型语言的分派是和对象的静态类型无关的。而方法句柄就可以理解为单纯的对最终调用方法的一个“引用”,和主调对象无关。
我们知道,Java是没有办法单独把一个函数作为参数进行传递的。一般的做法是设计一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数传入。例如Java类库中的Collections::sort():
void sort(List list, Comparator c)
但是,有了方法句柄,Java也可以拥有类似于函数指针这样的工具了。下面是基本用法示例,无论obj是哪种类型(临时定义的ClassA,或实现 PrintStream接口的实现类System.out),都可以正确调用到println()方法。
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
/*** JSR 292 MethodHandle基础用法演示 * @author zzm */
public class MethodHandleTest {
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
// 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。
getPrintlnMH(obj).invokeExact("icyfenix");
}
private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
// MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)
// 和具体参数(methodType()第二个及以后的参数)。
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,
// 并且符合调用权限的方法句柄。
// 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,
// 也即this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo() 方法来完成这件事情。
return MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}
}
方法getPrintlnMH()中实际上是模拟了invokevirtual指令的执行过程,只不过它的分派逻辑没有固化在Class文件的字节码上,而是通过用户设计的Java方法实现。
有了方法句柄,我们就可以写出类似传递函数的代码了:
void sort(List list, MethodHandle compare)
二、方法句柄与反射的区别?
如果想把方法作为形参,反射也可以实现上述类似的功能。那么方法句柄和反射有什么区别呢?Reflection中的java.lang.reflect.Method对象和MethodHandle机制中的java.lang.invoke.MethodHandle有啥区别?
- 模拟的层次不一样:Reflection和MethodHandle机制都是在模拟方法调用,但反射是模拟Java代码层次的方法调用,方法句柄是模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial。
- Reflection是重量级,而MethodHandle是轻量级。java.lang.reflect.Method对象者方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等运行期信息。而MethodHandle仅包含执行该方法的相关信息。
- MethodHandle方便优化支持:由于MethodHandle是对字节码的方法指令调用的模拟,那么各种JVM的优化(如内联优化)也可以在MethodHandle上支持。而反射调用方法则不可能直接支持各类调用点优化措施。
- MethodHandle的对象是全部JVM语言:Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已,
4.invokedynamic指令
invokedynamic用来做什么?原有的4 条“invoke*”
指令,分派方法的规则是完全固化在虚拟机中的。invokedynamic
用来把“查找目标方法”的逻辑由虚拟机转移到具体的用户代码中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。
invokedynamic
和MethodHandle
的关系?两者都是为了达成同一个目的,只是一个用上层代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。
invokedynamic
指令的实现方式?在指令第一个参数里包含了方法信息。每个含有invokedynamic指令的位置被称作“动态调用点”(Dynamically-Computed Call Site)。该指令第一个参数和其他invoke指令不同。之前的4个invoke方法,第一次参数是表示方法符号引用的CONSTANT_Methodref_info
常量。该指令,第一个参数变成了JDK7新加入的CONSTANT_InvokeDynamic_info
常量。从这个常量可以得到3个信息:
- 引导方法(Bootstrap Method):这个方法存放在新加的BootstrapMethods属性中,可能会有多个引导方法,这里会标记处要引用的方法的索引号。引导方法有固定的参数,返回值规定是
java.lang.invoke.CallSite
对象,调用点CallSite对象代表了真正要执行的目标方法的调用,可以根据它找到真正要执行的目标方法。 - 方法类型(MethodType):包含方法的入参类型和返回值类型。
- 方法名称:方法的名称。
以上逻辑示例如下,与前面MethodHandleTest的作用基本一样:
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class InvokeDynamicTest {
public static void main(String[] args) throws Throwable {
INDY_BootstrapMethod().invokeExact("icyfenix");
}
private static MethodHandle INDY_BootstrapMethod() throws Throwable {
CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(
lookup(),
"testMethod",
MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V",
null));
return cs.dynamicInvoker();
}
private static MethodHandle MH_BootstrapMethod() throws Throwable {
return MethodHandles.lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
}
private static MethodType MT_BootstrapMethod() {
return MethodType.fromMethodDescriptorString(
"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String; Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
null);
}
public static void testMethod(String s) {
System.out.println("hello String:" + s);
}
public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
}
}
在JDK 7时,光靠Java语言的编译器Javac甚至还完全没有办法生成带有invokedynamic指令的字节码。到JDK 8引入了Lambda表达式和接口默认方法后,Java语言才算享受到了一点invokedynamic指令的好处。
我们用Lambda表达式来解释invokedynamic指令会比较奇怪。所以我们用一个简单的工具,INDY,来把上述程序的字节码转换为使用invokedynamic实现。上述代码的逻辑,转换为字节码,在转换为invokedynamic
指令实现如下:
Constant pool:
#121 = NameAndType #33:#30 // testMethod:(Ljava/lang/String;)V
#123 = InvokeDynamic #0:#121 // #0:testMethod:(Ljava/lang/String;)V
public static void main(java.lang.String[]) throws java.lang.Throwable;
Code:
stack=2, locals=1, args_size=1
0: ldc #23 // String abc
2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod: (Ljava/lang/String;)V
7: nop
8: return
public static java.lang.invoke.CallSite BootstrapMethod(java.lang.invoke.Method Handles$Lookup, java.lang.String, java.lang.invoke.MethodType) throws java.lang.Throwable;
Code:
stack=6, locals=3, args_size=3
0: new #63 // class java/lang/invoke/ConstantCallSite
3: dup
4: aload_0
5: ldc #1 // class org/fenixsoft/InvokeDynamicTest
7: aload_1
8: aload_2
9: invokevirtual #65 // Method java/lang/invoke/MethodHandles$ Lookup.findStatic:(Ljava/lang/Class;Ljava/ lang/String;Ljava/lang/invoke/Method Type;)Ljava/lang/invoke/MethodHandle;
12: invokespecial #71 // Method java/lang/invoke/ConstantCallSite. "<init>":(Ljava/lang/invoke/MethodHandle;)V
15: areturn
可以看到调用invokedynamic指令的代码为:
2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod: (Ljava/lang/String;)V
参数为#123,第二个参数是占位用的。
#123常量是“#123=InvokeDynamic#0:#121”
。也就是说,该常量是CONSTANT_InvokeDynamic_info
类型。呼应前面的,我们从第一个参数得到以下信息:
- #0:引导方法,取Bootstrap Methods属性表的第0项,即BootstrapMethod(),因为只有它一个引导方法。
- #121:方法名和描述符:即后面输出的“testMethod:(Ljava/lang/String;)V”
BootstrapMethod()方法,由INDY产生,逻辑都是调用MethodHandles$Lookup的findStatic()方法,产生testMethod()方法的 MethodHandle,然后用它创建一个ConstantCallSite对象。最后,ConstantCallSite对象返回给invokedynamic指令来实现对testMethod()方法的调用,invokedynamic指令的调用过程到此就宣告完成了。
5.实战:掌控方法分派规则
invokedynamic指令与此前4条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。
这一节我们来举一个例子,说明掌控方法分派规则之后,我们能做什么以前无法做到的事情。
如下举例,孙子怎么调用到爷爷的方法?
class GrandFather {
void thinking() {System.out.println("i am grandfather");}
}
class Father extends GrandFather {
void thinking() {System.out.println("i am father");}
}
class Son extends Father {
void thinking() {
// 请在这里填入适当的代码(不能修改其他地方的代码)
// 实现调用祖父类的thinking()方法,打印"i am grandfather"
}
}
由于孙子只能用super获取到爹的对象,无法获取到爷爷的对象。而invokevirtual指令的分派逻辑是固定的,只能安装主调对象的实际类型来分派,这个是固化在JVM里面的逻辑。所以我们没办法调用到爷爷的方法。
在JDK7 Update9之前,我们可以用方法句柄来绕过这个限制:
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
class Test {
class GrandFather {
void thinking() { System.out.println("i am grandfather"); }
}
class Father extends GrandFather {
void thinking() {System.out.println("i am father");}
}
class Son extends Father {
void thinking() {
try {
// 注意这里的逻辑
MethodType mt = MethodType.methodType(void.class);
MethodHandle mh = lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
mh.invoke(this);
} catch (Throwable e) {
}
}
}
public static void main(String[] args) {
(new Test().new Son()).thinking();
}
}
// 结果:
// JDK7 Upate9及之前:
// i am grandfather
// JDK7 Upate10之后:
// i am father
这个逻辑JDK 7 Update 9被修复了,目的是为了保证“findSpecial()查找方法的执行版本时受到的访问约束”和“使用 invokespecial指令”精确对等。上述场景,它只能访问父类中的方法。
那么新版本中如何实现呢?我们直接用反射来屏蔽掉访问保护。
原理:上述的访问保护是通过一个allowedModes的参数来控制的,这个参数位于MethodHandles.Lookup类的代码里,我们用反射把该参数设置成“TRUSTED”即可绕开所有的保护措施。
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup) lookupImpl
.get(null))
.findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
mh.invoke(this);
} catch (Throwable e) {
}
}
8.5 基于栈的字节码解释执行引擎
前文讲述了,JVM如何调用方法?如何进行方法分派。
接下来我们探讨,JVM是如何执行方法里的字节码指令的。
JVM执行Java代码由两种选择,解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地执行代码)。本节分析概念模型下JVM解释执行字节码的工作原理。
1.解释执行
Java早期JDK1.0只能解释执行,之后主流的JVM都包含了即时编译器,再后来Java有了可直接生成本地代码的编译器。而C/C++也发展出了可解释执行的版本。
以下是经典编译原理的编译流程,中间分支就是解释执行,下面的分支就是程序代码到目标机器代码的生成过程(编译执行?)。
针对上述流程,编译可以分为几种:
- 完全独立的编译器:词法、语法分析以至后面的优化器和目标代码生成器都独立于执行引擎,编译器是完全独立的。如*C/C++*。
- 半独立的编译器:把其中一部分步骤实现为一个半独立的编译器。如Java,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。而解释器则是在JVM内部。
- 完全是一个黑盒:把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如:大多数 javascript 编译器。
2.基于栈的指令集与基于寄存器的指令集
一、两种指令集
- 栈指令集:Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构。基本上的意思是说,大多数是零地址的,Java不全部设置为零地址是考虑了代码的可校验性
- 寄存器指令集:x86的二地址指令集是基于寄存器的指令集。主流的PC机物理硬件直接支持,依赖于寄存器工作。
举例,假如计算“1+1”。栈指令集如下:
# 通常不带参数。运算输入是栈顶的数据。运算结果也是存在操作数栈中。
iconst_1
iconst_1
iadd
istore_0
寄存器指令集如下:
# 将EAX寄存器值设为1,然后在用add指令加1。
mov eax, 1
add eax, 1
二、优缺点
栈指令集:
- 优点:1.可移植。JVM不会直接用到硬件寄存器,JVM也可以选择把一些频繁访问的数据(栈顶缓存、程序计数器)放到寄存器。2.代码更紧凑。一个字节对应一条指令。3.编译器实现更加简单。不需要考虑空间分配的问题,所需空间都在栈上操作。
- 缺点:理论上执行速度相对慢一些。由于所有操作都是咱栈上,会有很多出栈入栈操作,造成大量的内存访问,而内存访问相对于CPU来说是速度瓶颈。当然,是局限在解释执行的状态下。如果经过即时编译器输出成物理机上的汇编指令流,那就和指令集架构没关系了。
3.基于栈的解释器执行过程
下面用一个执行例子来看,JVM里面是怎么解释执行字节码的,是“难度很大”>_<的四则运算:
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
直接javap编译:
public int calc();
Code:Stack=2, Locals=4, Args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
}
注意上述的Code提示,操作数栈深度为2,局部变量槽空间为4个变量槽。
以下是执行过程,很简单:
1.执行偏移地址为0的指令,Bipush指令的作用是将单字节的整型常量值(-128~127)推入 操作数栈顶,这里是100。
2.执行偏移地址为2的指令,istore_1指令将栈顶整型值出栈并存放到第1个局部变量槽中。后续4个指令一样的。
3.执行偏移地址为11的指令,iload_1指令将局部变量表第1个变量槽中的整型值复制到操作数栈顶。
4.执行偏移地址为12的指令,iload_2指令将第2个变量槽的整型值入栈。
5.执行偏移地址为13的指令,iadd指令将操作数栈中头两个栈顶元素出栈,做整型加法,然后结果重新入栈。
6.执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量槽中的300入栈到操作数栈。下一条指令imul将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果重新入栈。
7.执行偏移地址为16的指令,ireturn指令将结束方法执行,并将操作数栈顶的整型值返回给该方法的调用者。到此为止,这段方法执行结束。
最后再次强调,以上只是概念模型。JVM实现时,解析器和即时编译器都会对字节码进行各种优化。即使解释器中也不是按照字节码指令去逐条执行。
8.6本章小结
本章分析了JVM如何找到正确的方法,如何执行方法内的字节码,以及执行代码时涉及的内存结构。
第6~8章我们系统的介绍了Java程序是如何存储的、如何载入(创建)的、以及如何执行的。
第9章我们看一下这些理论知识在具体开发中的典型应用。
提问
栈帧的结构组成?
局部变量表存了什么?变量槽怎么重用?
操作数栈用来做什么?有什么优化?
方法符号引用何时转化为直接引用?具体方法调用指令有哪些?什么是虚方法?哪些方法会被解析?哪些会被分派?
重载和重写有什么区别?前者是静态分派(有些资料也归为解析),后者是动态分派。
详细解释静态分派与动态分派?
字段参与多态吗?动态分派对字段有效吗?为什么?
Java是单分派还是多分派?静态动态都是单分派吗?
动态分派有哪些优化?虚方法表用来干什么?实现原理?
什么是动态类型语言?与动态语言、弱类型语言概念一样吗?
为什么要有invokedynamic指令?用老的指令实现有什么困难?
java.lang.invoke包用来做什么?方法句柄方式引用方法和反射方式引用方法有什么区别?Java里想把方法作为形参传入,怎么做?
invokedynamic指令用来做什么?参数是什么意思?包含了哪些信息?和普通的invoke*指令有什么区别?
孙子想引用爷爷的方法,怎么引用?其他代码不改变。
解释执行和编译执行的区别?结合编译原理流程说明及距离。
栈和寄存器指令集有何区别?
简要说说字节码在JVM的解释执行过程?有哪些部分需要参与?用四则运算举例?
第9章 类加载及执行子系统的案例与实战
9.1 概述
在Class文件格式与执行引擎这一部分,Class文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行 为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能。本章我们来看下有哪些有趣的玩法。
9.2 案例分析
1.Tomcat:正统的类加载器架构
Tomcat是完全按照JDK官方推荐的使用方式去使用类加载器的。
一、为什么要自定义类加载器
一般主流的Java Web服务器,如Tomcat、Jetty都会实现自己定义的类加载器。因为它们要解决以下问题:
- 使用的Java类库可以隔离:部署在同一个服务器上的两个Web应用程序,它们可能会同时依赖一个第三方库,但是依赖的是不同的版本。所以有这种需求。
- 使用的Java类库可以共享:和前面正好相反。例如,10个应用程序都用到了Spring Framework。如果它们都隔离的话,运行时会加载10份一模一样的类库到方法区。
- 服务器类库要和应用程序相对独立:应用程序的代码不能影响到服务器自身的安全。要尽可能地保证自身的安全不受部署的Web应用程序影响。如果服务器本身也有类库依赖的话,要和应用程序的类库相互独立。
- 需要支持热替换:例如支持JSP的Web服务器,一般都有支持JSP生成类热替换的需求,因为JSP随时有可能被修改且要求无需重启。
二、自定义类加载器的实现方式
由于存在上述问题,所以一般web服务器都需要多个ClassPath路径,来给用户存放第三方类库,这些路径一般会以“lib”或“classes”命名。通常每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。
下面以Tomcat为例,看下tomcat如何规划用户类库和类加载器。Tomcat把类库分为4组,如下:
- /common目录。类库Tomcat和所有Web应用程序都可见。
- /server目录。类库仅Tomcat可见。对其他所有的Web应用程序都不可见。
- /shared目录。类库所有的Web应用程序可见,但Tomcat自己不可见。
- /WebApp/WEB-INF目录。类库仅被当前Web应用程序可见,对Tomcat和其他Web应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器, 这些类加载器按照经典的双亲委派模型来实现:
目录和类加载器对应关系如下:
- Common类加载器:/common/*
- Catalina类加载器(也称Server类加载器):/server/*
- Shared类加载器:/shared/*
- Webapp类加载器:/WebApp/WEB-INF/*
其中,WebApp类加载器和JSP类加载器通常还会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个JasperLoader类加载器。
类加载器关系梳理:
- Common类加载器能加载的类都可以被Catalina类加载器和Shared类加载器使用
- Catalina类加载器和Shared类加载器自己能加载的类则与对方相互隔离
- WebApp类加载器可以使用Shared类加载器加载到的类,但各WebApp类加载器实例之间相互隔离
- JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class文件,它存在的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的JSP类加载器来实现JSP文件的HotSwap功能。
Tomcat6之后,为了易用性,进行了简化:
- Catalina类加载器、Shared类加载器默认被Common类加载器的实例代替:只有指定了
tomcat/conf/catalina.properties
配置文件的server.loader
和share.loader
项后才会真正建立它们。 - /common、/server和/shared这3个目录默认合并变成1个/lib目录:对应前一项的改变。这个目录里的类库相当于以前/common目录中类库的作用。
用户可以通过修改配置文件指定server.loader和share.loader的方式重新启用原来完整的加载器架构。
思考提问:如果有10个Web应用程序都是用Spring来进行组织和管理的,可以把Spring放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的。那么被Common类加载器或 Shared 类加载器加载的 Spring 如何访问并不在其加载范围内的用户程序呢?
2.OSGi:灵活的类加载器架构
OSGi对类加载器的运用很有独到之处。
OSGi是什么?OSGi(Open Service Gateway Initiative)是OSGi联盟(OSGi Alliance)制订的一个基于Java语言的动态模块化规范(在JDK 9引入的JPMS是静态的模块系统)。
现在已经成为Java中“事实上”的动态模块化标准,并且已经有了Equinox、Felix等成熟的实现。Eclipse IDE就是具体的应用案例。
OSGi中的每个模块(称为Bundle)和普通的Java类库类似:
- 相同点:两它们般都用JAR格式封装,且内部存的都是Java的Package和Class。
- 不同点:
- 静态模块化:现在已经和Java新出的模块化系统功能重叠。一个Bundle可以声明它所依赖的Package(通过Import-Package描述),也可以声明它允许导出发布的Package(通过Export-Package描述)。Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖。且一个模块里只有被Export过的Package才可能被外界访问,其他的Package和Class将会被隐藏。
- 动态模块化:现在是OSGi特有。实现模块级的热插拔。程序升级更新或调试时,可以只停用、重装程序的一部分。例如Eclipse热插拔插件。
以上重要的动态模块化功能,就是通过灵活的类加载器架构来实现的。
Bundle加载器间是网状关系,而不是树状关系:OSGi的Bundle类加载器之间规则比较灵活,没有固定的委派关系。各个Bundle加载器都是平级的关系,然后根据声明的导入导出规则来构建Bundle直接的委派和依赖。简单的来说,就是哪个Bundle发布的包,就由哪个Bundle的类加载器来加载。例如,假如3个Bundle关系如下:
- BundleA: 发布packageA,声明依赖java.*包。
- BundleB:声明依赖packageA、packageC,以及java.*包。
- BundleC: 发布packageC,声明依赖packageA。
那么关系图如下:
具体的来讲,OSGi的类加载时委派类加载器的规则是很复杂的,如下:
- 如果是以java.*开头的类,委派给父类加载器加载。
- 否则,委派列表名单内的类,委派给父类加载器加载。
- 否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
- 否则,查找当前Bundle的Classpath,使用自己的类加载器加载。
- 否则,查找类是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。
- 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
- 否则,类查找失败。
复杂类加载器关系带来的死锁问题:例如,Bundle A依赖Bundle B的Package B,而Bundle B又依赖了Bundle A的Package A,那么这两个Bundle进行类加载时就有很高的概率发生死锁。原因是Bundle会先锁定自己的加载器再去请求其他的加载器。解决方法:
- 用户可以启用osgi.classloader.singleThreadLoads参数来强制按单线程串行化的方式进行类加载。
- 然后,JDK7时,官方提出了解决方法,在ClassLoader中增加了registerAsParallelCapable方法对可并行的类加载进行注册声明,把锁的级别从ClassLoader对象本身,降低为要加载的类名这个级别。
OSGi在提供的功能很强大,但也引入了非常高的复杂度。对于单个虚拟机下的应用,从开发初期就建立在OSGi上是一个很不错的选择,这样便于约束依赖。其他场景,如微服务等场景,则不尽然。
3.字节码生成技术与动态代理的实现
Java除了用Javac生成字节码外,还要很多其他方式可以生成字节码:
- Web服务器的JSP编译器
- 编译时进行织入的AOP框架
- 动态代理技术
- 反射时JVM运行时生成以提高执行速度
- ……
动态代理的意义:相对于静态代理而言,节省代码量都是其次,最主要的是解耦了代理类和原始类,代理类就可以灵活的运用到不同场景。
一、动态代理举例
我们根据具体例子来分析:以下例子,原始代码逻辑是打印一句“hello world”,代理类的逻辑是在原始类方法执行前打印一句“welcome”。
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() { System.out.println("hello world"); }
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
// 返回代理对象
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
}
// 重写invoke方法
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}
// 结果:
// welcome
// hello world
上面的关键代码就是Proxy::newProxyInstance()
。
跟踪这个方法的源码,我们可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作。这里我们只关心最后一步,即它最后调用sun.misc.ProxyGenerator::generateProxyClass()
方法来完成生成字节码的动作。这个方法会在运行时产生一个装代理类的字节码byte[]数组。可以加参数把生成的代码保留下来,生成的代理类Class文件,,反编译后如下:
package org.fenixsoft.bytecode;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
public final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello {
private static Method m3;
private static Method m1;
private static Method m0;
private static Method m2;
public $Proxy0(InvocationHandler paramInvocationHandler) throws {
super(paramInvocationHandler);
}
public final void sayHello() throws {
try {
this.h.invoke(this, m3, null);
return;
} catch (RuntimeException localRuntimeException) {
throw localRuntimeException;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
// 此处由于版面原因,省略equals()、hashCode()、toString()3个方法的代码
// 这3个方法的内容与sayHello()非常相似。
static {
try {
m3 = Class.forName("org.fenixsoft.bytecode.DynamicProxyTest$IHello").getMethod("sayHello", new Class[0]);
m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[]{Class.forName("java.lang.Object")});
m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
return;
} catch (NoSuchMethodException localNoSuchMethodException) {
throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
} catch (ClassNotFoundException localClassNotFoundException) {
throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
}
}
}
原理很简单,从类加载器拿到class对象,再跟进class对象拿到接口方法对象。在动态代理对象对应的方法里,调用invoke方法即可(把接口方法对象、代理实例作为参数传入)。
大概逻辑就是,它为传入接口中的每一个方法,以及从java.lang.Object中继承来的equals()、hashCode()、toString()方法都生成了对应的实现,也就是m1、m2、m3、m4。然后统一调用了InvocationHandler对象的invoke()方法(代码中的“this.h”就是父类Proxy中保存的InvocationHandler实例变量)来实现这些方法的内容即可。各个方法的区别只是传入的参数和Method对象不同而已。
字节码具体如何生成呢:大致的生成过程其实就是根据Class文件的格式规范去拼装字节码,如果有想大量操作字节码,可以用封装好的字节码类库。
4.Backport工具:Java的时光机器
Backport工具用来干啥?用来把高版本的代码放到低版本的JDK环境中部署。即“Java逆向移植”的工具(Java Backporting Tools),典型的如 Retrotranslator 和 Retrolambda。
实现思路:
JDK的升级无非以下5中情况:
- 对Java类库API的代码增强。例如JDK5引入JUC,JDK7引入invoke。
- 在前端编译器层面做的改进。这种改进被称作语法糖。例如:a.自动装箱拆箱插入Integer.valueOf()、Float.valueOf()之类的代码。b.变长参数转化为数组来完成参数传递。c.泛型信息在编译阶段就已经被擦除(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。
- 需要在字节码中进行支持的改动。如JDK 7新加入的语法特性——动态语言支持,需要新增一条invokedynamic字节码。不过字节码指令一直很稳定。
- 需要在JDK整体结构层面进行支持的改进,典型的如JDK 9时引入的Java模块化系统,它就涉及了JDK结构、Java语法、类加载和连接过程、Java虚拟机等多个层面。
- 集中在虚拟机内部的改进。如JDK 5中实现的JSR-133[4]规范重新定义的Java内存模型。这个对于代码是透明的。
以上5中,1和2可以解决,3invokedynamic指令不是必须的,5不需要解决。所以还是可以解决大多数兼容性问题。
另外IntelliJ IDEA等IDE工具可能会反过来运用这个过程,即把低版本的代码显示为高版本的语法,从而提高可读性。
9.3 实战:自己动手实现远程执行功能(略)
我们在工作中,常常有“在服务端执行临时代码”的需求。不想要服务器停机,但是又需要临时执行代码。例如,想看下内存数据,想写一个测试代码。解决思路有以下几种:
1)动态修改程序代码:使用BTrace这类JVMTI工具去动态修改程序的运行代码,相关介绍见第4章,类似的工具还有阿里巴巴的Arthas等。
2)使用动态编译的Java程序:JDK 6之后提供了Compiler API,可动态地编译Java程序,虽然达不到动态语言的灵活度,但还是可以让服务器执行临时代码。
3)也可以通过“曲线救国”的方式来做到,譬如写一个JSP文件上传到服务器,然后在浏览器中运行它,或者在服务端程序中加入一个BeanShell Script、JavaScript等的执行引擎(如Mozilla Rhino[3])去执行动态脚本。
4)在应用程序中内置动态执行的功能。
具体实现:略。
9.4 本章小结
第6章至第9章介绍了Class文件格式、类加载及虚拟机执行引擎这几部分内容,虚拟机执行子系统的介绍就到此为止。
6-9章我们用概念模型描绘了一个虚拟机应该如何运行Class文件,具体的JVM实现可能会有差异,但是执行结果会和概念模型保持一致。
从第10章开始,我们将把目光从概念模型转到具体实现,去探索虚拟机在语法上和运行性能 上,做出的各种优化。
提问
列举一个正常使用自定义类加载器的例子?Tomcat为什么要进行类库分类?它的类库目录和自定义类加载器实现?Spring如何访问到不在它加载范围内的用户程序?
列举一个灵活使用自定义类加载器的例子?OSGi的静态、动态模块化简要介绍下?
动态代理如何实现?Java层面Api?字节码层面?
Backport工具原理?大概说一下
在服务器端执行临时代码,有哪些思路?
第四部分 程序编译与代码优化
第10章 前端编译与优化
10.1 概述
编译器根据编译阶段的不同,可以分为三类编译器:
- 前端编译器:把.java文件转变成.class文件。
- 即时编译器(也叫JIT编译器,Just In Time Compiler):把字节码转变成本地机器码。
- 提前编译器(也叫AOT编译器,Ahead Of Time Compiler):直接把程序编译成与目标机器指令集相关的二进制代码。
三类编译器的典型产品如下:
- 前端编译器:JDK的Javac编译器
- 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
- 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。
本章我们只讲前端编译器。
前端编译器和即时编译器的优化重点不一样:
- 前端编译器:主要是优化开发效率。主要是通过Javac编译器来实现各种“语法糖”,新的语法特性,来提升程序员的编码效率和幸福感。
- 即时编译器:主要是优化运行效率。为了让不是Javac产生的Class文件(如JRuby、Groovy等语言的Class文件)也能享受到编译器优化的性能红利,Java虚拟机团队把对性能的优化全部集中到了运行期即时编译器中。
10.2 Javac编译器
Javac编译器是用Java实现的,我们可以通过源码来研究它。(作为对比,HotSpot是用C++和少量C语实现)
1.Javac的源码与调试
Javac在JDK6之后晋升为标准Java类库,JDK9被挪到jdk.compiler模块。
Javac除了自身的标准类型外,就只引用了JDK_SRC_HOME/langtools/src/share/classes/com/sun/*里面的代码,所以我们的代码编译环境建立时基本无须处理依赖关系。
导入了Javac的源码后,就可以运行com.sun.tools.javac.Main的main()方法来执行编译了,可以使用的参数与命令行中使用的Javac命令没有任何区别。
《Java虚拟机规范》严格定义了Class文件的格式。但是对于“如何把Java源码编译为Class文件”的规定则很宽松。所以,Class文件的编译过程很大程度上决定于由具体的JDK及编译器实现。
从Javac代码的总体结构来看,前端编译可大致分为1个准备过程和3个处理过程,分别如下:
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程:
- 解析:词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。
- 注解处理过程:插入式注解处理器的执行阶段。
- 分析与字节码生成过程
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。
- 字节码生成。将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号。如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号。前端编译总体流程如下图:
上述处理过程对应到代码中,Javac编译动作的入口是 com.sun.tools.javac.main.JavaCompiler
类,上述3个过程的代码逻辑集中在这个类的*compile()和compile2()*方法里,其中主体代码如图所示,整个编译过程主要的处理由图中标注的8个方法来完成。
接下来,我们对照Javac的源代码,逐项讲解上述过程。
2.解析与填充符号表
解析由上图的parseFiles()方法(过程1.1)完成,包括词法分析和语法分析两步。
1.词法、语法分析
词法分析及语法分析概念:
词法分析:将源代码字符流转换为标记(Token)集合的过程。由
com.sun.tools.javac.parser.Scanner
类来实现。例如:将“int a=b+2”转换为6个标记,分别是int、a、=、b、+、2。语法分析:根据标记序列构造抽象语法树。树的每一个节点都代表着程序代码中的一个语法结构(SyntaxConstruct)。语法分析过程由
com.sun.tools.javac.parser.Parser
类实现,结果用com.sun.tools.javac.tree.JCTree
类表示。
经过词法和语法分析生成语法树以后,后续的操作都基于抽象语法树。编译器不再对源码字符流进行操作。
2.填充符号表
生成抽象语法树后开始填充符号表。填充符号表由上图的enterTrees()方法(过程1.2)完成。
符号表(Symbol Table)是一组由符号地址和符号信息构成的类似键值对的数据结构,类似Hashmap的键值对,存储形式多样。
符号表的内容后面会用到,例如语义分析时,用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码。生成目标代码,需要对符号名进行地址分配时,符号表是地址分配的直接依据。
填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,返回的结果是是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点。
3.注解处理器
JDK5加入了注解,当时注解只在程序运行期间发挥作用。JDK6之后加入了“插入式注解处理器”的API,可提前至编译期对特定注解进行处理,从而影响前段编译器的工作过程。
插入式注解处理器是什么?它可以看做是一组编译器的插件,它们可以许读取、修改、添加抽象语法树中的任意元素。当它们处理注解时修改了语法树,则编译器将重新处理解析及填充符号表过程,直到所有的插入式注解处理器对语法树的修改都已完成。每一次循环过程称为一个轮次(Round),对应前图的回环过程。
有了编译器注解处理的标准API后,就可以干涉编译器的行为了。由于插件可以访问语法树所有的元素,甚至是注释,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。例如Lombok,可以做很多事情。后续会针对如何使用插入式注解处理器有一个简单的实战。
插入式注解处理器的初始化过程在initPorcessAnnotations()
方法中完成,执行过程在processAnnotations()
方法中完成。该方法会判断是否还有新的注解处理器需要执行,有的话,通过com.sun.tools.javac.processing.JavacProcessing-Environment
类的doProcessing()
方法来生成一个新的JavaCompiler对象,对编译的后续步骤进行处理。
4.语义分析与字节码生成
语义分析用来干啥?经过前面的语法分析,我们已经得到了一颗抽象语法树。抽象语法树能保证源程序是结构正确的,但无法保证源程序的语义符合逻辑。语义分析的主要任务是对结构正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等。
例如以下代码:
int a = 1;
boolean b = false;
char c = 2;
int d = a + c; // 合法
int d = b + c; // 非法
char d = a + c; // 非法
后面的赋值运算,本身的结构是没有问题的,但是只有结合上下文才能推断它们合不合逻辑。
IDE 由红线标注的错误提示,绝大部分都是来源于语义分析阶段的检查。
Javac在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤,分别由前图的attribute()和flow()方法(分别对应图中的过程3.1和过程3.2)完成。
1.标注检查
标注检查主要包括变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配,等等。
在标注检查中,还会顺便进行一个称为常量折叠的代码优化(Constant Folding)。这是前端编译的极少量优化措施之一。
优化举例,例如下面的代码:
int a = 1 +2;
则在抽象语法树里还是可以看到字面量“1”“2”和操作符“+”号,经过常量折叠优化之后,它们将会被折叠为字面量“3”。如下图所示(ConstantExpressionValue:3):
标注检查步骤在Javac源码中的实现类是com.sun.tools.javac.comp.Attr
类和com.sun.tools.javac.comp.Check
类。
2.数据及控制流分析
数据流分析和控制流分析对程序上下文逻辑进行更进一步的验证,它可以检查出的问题例如:
- 程序局部变量在使用前是否有赋值
- 方法的每条路径是否都有返回值
- 是否所有的受查异常都被正确处理了
等等。
编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可看作是一致的,但校验范围会有所区别,有一些校验项只有在编译期或运行期才能进行。
下面列举一个数据及控制流分析的例子,是典型的只能在编译期而不能在运行期检查的情况:
// 方法一带有final修饰
public void foo(final int arg) { final int var = 0; // do something }
// 方法二没有final修饰
public void foo(int arg) { int var = 0; // do something }
上述两个方法,一个有final,一个没有。但是它们编译出来的字节码是一模一样的。说明把局部变量声明为final,对运行期是完全没有影响,局部变量的不变性仅仅由Javac编译器在编译期间来保障。
分析一下,我们知道局部变量与类的字段(实例变量、类变量)的存储是有显著差别的,它在常量池中并没有CONSTANT_Fieldref_info的符号引用,所以更不可能会存储访问标志(access_flags)final的信息,甚至可能连变量名称都不一定会被保留(取决于编译时的 编译器的参数选项)。所以局部变量的不变性就只能在编译期而不是在运行期检查。
3.解语法糖
语法糖(Syntactic Sugar),指的是在计算机语言中添加某种语法,这种语法对语言的编译结果和功能没有实际影响,但能更方便程序员使用该语言。
Java语法糖比较少,所以它相比其他语言会显得比较“啰嗦”。
Java常见的语法糖有:
- 泛型(仅限于Java中是语法糖)
- 变长参数
- 自动装箱拆箱
等等。它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。后面10.3节会详细描述。
在Javac源码中,解语法糖的过程由desugar()
方法触发,在com.sun.tools.javac.comp.TransTypes
类和com.sun.tools.javac.comp.Lower
类中完成。
4.字节码生成
最后再一次对语法树进行遍历和调整,然后生成Class文件。
字节码生成是Javac编译过程的最后一个阶段,由com.sun.tools.javac.jvm.Gen
类完成。
除了把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘,编译器还进行了少量的代码添加和转换工作。举例:
一、生成构造器
先对语法树进行遍历和调整:
如前面提到的实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段被添加到语法树之中的。
这里的实例构造器和默认构造器不一样:
- 默认构造器:如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性(public、protected、private或<package>)与当前类型一致的默认构造函数,这个工作在填充符号表阶段中就已经完成。
- <init>()和<clinit>()两个构造器:它们产生实际上是一种代码收敛的过程。编译器会把语句块(实例构造器是“{}”块,类构造器是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器等操作收敛到<init>()和 <clinit>()方法中。然后保证无论源码中出现的顺序如何,一定按以下顺序执行:先执行父类的实例构造器,再初始化变量,最后执行语句块。上面所述的动作由Gen::normalizeDefs()方法来实现。
关于调用父类的实例构造器:仅调用父类的<init>()实例构造器即可。无须调用父类的<clinit>()方法,JVM会自动保证父类的<clinit>()方法正确执行。不过在<clinit>()方法中经常会生成调用java.lang.Object的<init>()方法的代码。
二、字符串操作转换
除了生成构造器以外,还有其他的一些代码替换工作来优化程序,如把字符串的加操作替换为StringBuffer或StringBuilder(取决于代码版本大于或等于JDK 5)的append()操作,等等。
三、最终完成编译
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交到com.sun.tools.javac.jvm.ClassWriter
类手上,由这个类的writeClass()
方法输出字节码,生成最终的Class文件,到此,整个编译过程宣告结束。
10.3 Java语法糖的味道
语法糖可以看作是前端编译器实现的一些“小把戏”,这些“小把戏”可能会使效率得到“大提升”。
1.泛型
泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用。即可以把要操作的数据的类型作为参数来声明。相当于又多了一层抽象。
泛型可以让程序员能够针对抽象泛化的数据类型来编写相同的算法,从而极大地增强了编程语言的类型系统和抽象能力。
Java和C#实现泛型的思路完全不一样。Java泛型的实现有一些缺陷,不过这是出于当时语言现状的权衡。
1.Java与C#的泛型
Java和C#实现泛型的方式不一样:
- Java使用“类型擦除式泛型”(Type Erasure Generics):该泛型只在程序源码中存在,在编译后的字节码文件中,泛型全部都被替换为原来的裸类型(Raw Type,稍后会讲)了,然后在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList<int>与ArrayList<String>其实是同一个类型,
- C#使用“具现化式泛型”(Reified Generics):该泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的。List<int>与 List<string>就是两个不同的类型,它们由系统在运行期生成,有自己独立的虚方法表和类型数据。
Java泛型的缺点:
使用效果很差:以下代码C#可以完全支持,而Java里却是违法的。只要记住,泛型只知道它是一个裸类型。
public class TypeErasureGenerics<E> { public void doSomething(Object item) { if (item instanceof E) {} // 不合法,无法对泛型进行实例判断 ... E newItem = new E(); // 不合法,无法使用泛型创建对象 E[] itemArray = new E[10]; // 不合法,无法使用泛型创建数组 } }
运行效率也差:因为在使用平台提供的容器类型(如List<T>,Dictionary<TKey,TValue>)时,需要不厌其烦地拆箱和装箱。
Java泛型的唯一优点:
- 它的实现几乎只需修改Javac编译器即可,无需改动字节码、Java虚拟机,也保证了之前没有使用泛型的库可以直接运行在Java 5.0之上。
Java之所以选择这么蛋疼的泛型实现,主要是考虑到了当时的历史背景。下面我们来了解一下。
2.泛型的历史背景
一、背景
96年,Martin Odersky(后来Scala语言的缔造者)想设计一门能够支持函数式编程的程序语言,他在刚发布一年的Java基础上,实现了函数式编程的3大特性:泛型、高阶函数和模式匹配,形成了Scala语言的前身Pizza语言。
后来,Java的开发团队找到了Martin Odersky,提出把Pizza语言的泛型单独拎出来移植到Java语言上。
移植的过程受到很很多阻碍。最困难的是,《Java语言规范》明确规定Java需要保证二进制向后兼容性(注意是二进制向后,即,1.仅保证编译成二进制码后兼容。2.仅保证老代码运行在新JDK,不保证新代码运行在老JDK。)。
所以,为了保证上述承诺,权衡利弊,选择了现在的泛型实现方式。
例如,没有泛型的时候,Java中的数组是支持协变(Covariant)的。对应的集合类也可以存入不同类型的元素,如下这样的代码尽管不提倡,但是完全可以正常编译成Class文件。
Object[] array = new String[10];
array[0] = 10; // 编译期不会有问题,运行时会报错
ArrayList things = new ArrayList();
things.add(Integer.valueOf(10)); //编译、运行时都不会报错
things.add("hello world");
二、具体抉择
为了让上述编译出来的.class文件在新的JDK5继续运行,有两种选择:
- 需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型。
- 直接把已有的类型泛型化,不添加任何平行于已有类型的泛型版。
C#选择了第一种,添加了一组System.Collections.Generic的新容器。它才发布两年,历史负担小。
Java选择了第二种,它已经发布10年,有历史包袱。而且之前容器已经实现了两套,Vector(老)和ArrayList(新)、有Hashtable(老)和HashMap(新)等。再来一套,用户会疯掉。
但是第二种方式也有更好的实现方式,类型擦除不是唯一选择。所以有了今天的Valhalla项目来还以前泛型偷懒留下的技术债。
下面我们来看当时到底是怎么做的类型擦除,偷懒在哪里。
3.类型擦除
继续以ArrayList为例来介绍Java泛型的类型擦除具体实现。
一、什么是裸类型
Java选择的是兼容老的类型,原地泛型化。也就是说,老类型(如ArrayList)在原地泛型化后(ArrayList<T>),要保证能兼容以前使用老类型(ArrayList)的代码。
为了实现上述效果,就必须让所有泛型化的实例类型(如ArrayList<Integer>、ArrayList<String>),全部自动成为老类型(ArrayList)的子类型,否则类型转换就是不安全的。
裸类型的概念:裸类型就是所有该类型泛型化实例的共同父类型(Super Type)。也就是老类型(?)。
经过以上处理,以下从子类到父类的转型才是安全的:
// 裸类型赋值
ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list; // 裸类型
list = ilist;
list = slist;
二、如何实现裸类型
如何实现裸类型:
- 方式一:在运行期由Java虚拟机来自动地、真实地构造出ArrayList<Integer>这样的类型,并且自动实现从ArrayList<Integer>派生自ArrayList的继承关系,以此来满足裸类型的定义。
- 方式二:简单粗暴,直接在编译时把ArrayList<Integer>还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令。
最终选择了方式二。以下是代码示例,可以看到,1.泛型类型都变回了裸类型。2.只在元素访问时插入了从Object到String的强制转型代码。
// 泛型擦除前
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}
// 泛型擦除后
// 前述的代码编译成.class文件,再反编译。
public static void main(String[] args) {
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}
三、类型擦除带来的缺陷
以下举3个例子做具体说明:
1.不支持原始类型(Primitive Types)的泛型。
// 原始类型的泛型(目前的Java不支持)
ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list;
list = ilist;
list = llist;
因为需要插入强制转换,而原始类型是不支持与Object之间强转的。所以只能用包装类。所以Java泛型使用时,多了无数构造包装类和装箱、拆箱的开销,这成为Java泛型慢的重要原因,也成为今天Valhalla项目要重点解决的问题之一。
2.运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦。
如下代码,由于不能获取到泛型类型信息,我们不得不再用一个参数传入泛型的类型。
public static <T> T[] convert(List<T> list, Class<T> componentType) {
T[] array = (T[])Array.newInstance(componentType, list.size());
...
}
3.重载等情况下会模棱两可
以下代码不可以重载:
public class GenericTypes {
public static void method(List<String> list) {
System.out.println("invoke method(List<String> list)");
}
public static void method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
}
}
上述代码原因是由于类型擦除导致两个方法的特征签名一模一样。
但是把两个方法的返回值改一下,又可以重载:
public class GenericTypes {
public static String method(List<String> list) {
System.out.println("invoke method(List<String> list)");
return "";
}
public static int method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
return 1;
}
public static void main(String[] args) {
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}
}
原因是,两个method()方法加入了不同的返回值后才能共存在一个Class文件中。虽然Java层面返回值不参与重载。但是在字节码层面,只要描述符不是完全一致的两个方法就可以共存。
也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的。Java方法特征签名只包括了方法名称、参数顺序及参数类型,而在字节码中的特征签名还包括方法返回值及受查异常表。
不过上述代码也只有JDK6的Javac才会编译通过,其他版本或者是ECJ编译器都有可能拒绝编译。
Signature属性:
为了解决Java泛型引入带来的新需求,《Java虚拟机规范》做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性,用来解决伴随泛型而来的参数类型识别问题。
Signature是其中最重要的一项属性,它的用来存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型信息,即泛型的类型信息。
4.值类型与未来的泛型
Oracle建立了一个名为Valhalla的语言改进项目, 希望改进Java语言留下的各种缺陷,解决泛型的缺陷就是项目主要目标其中之一。
目前该项目只有少数目标实现,如VarHandle。本节内容未来可能还会发生变动。
一、Valhalla项目中几种不同的新泛型实现方案
被称为Model 1到Model 3:
- 泛型类型有可能被具现化
- 继续维持类型擦除以保持兼容,参数化类型可以选择不被完全地擦除掉,而是相对完整地记录在Class文件中,能够在运行期被使用,也可以指定编译器默认要擦除哪些类型。
二、值类型的支持
Java的原生数据类型,在泛型和回收判断等地方有一些缺点。
C#并没有Java意义上的原生数据类型,在C#中也可以定义自己值类型,只要继承于 ValueType类型即可。而ValueType也是统一基类Object的子类,所以并不会遇到Java那样int不自动装箱就无法转型为Object的尴尬。
值类型:
- 值类型可以与引用类型一样,具有构造函数、方法或是属性字段,等等。
- 值类型与引用类型的区别在于它在赋值的时候通常是整体复制,而不是像引用类型那样传递引用的。
- 值类型的实例很容易实现分配在方法的调用栈上的,这意味着值类型会随着当前方法的退出而自动释放,不会给垃圾收集子系统带来任何压力。
在Valhalla项目中,Java的值类型方案被称为“内联类型”,计划通过一个新的关键字inline来定义,字节码层面也有专门与原生类型对应的以Q开头的新的操作码(譬如iload对应qload)来支撑。
即时编译器场景中是使用逃逸分析优化(见第11章)来处理内联类型的,通过编码时标注以及内联类实例所具备的不可变性,可以很好地解决逃逸分析面对传统引用类型时难以判断对象是否逃逸的问题(没有足够的信息,或者没有足够的时间做全程序分析)。
2.自动装箱、拆箱与遍历循环
就纯技术的角度而论,自动装箱、自动拆箱与遍历循环(for-each循环)这些语法糖,实现复杂度和设计思想都是无法和泛型相提并论的。专门拿出一节来讲解它们只是因为它们是Java用得比较多的语法糖。
以下是示例代码:
// 自动装箱、拆箱与遍历循环
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
编译为class文件再反编译之后:
public static void main(String[] args) {
List list = Arrays.asList( new Integer[] {
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4) });
int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
int i = ((Integer)localIterator.next()).intValue(); sum += i;
}
System.out.println(sum);
}
上述代码展示了泛型、自动装箱、自动拆箱、遍历循环与变长参数5种语法糖。转换逻辑如下:
- 泛型:类型擦除与强制类型转换。
- 自动装箱、拆箱:对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法。
- 遍历循环:还原成迭代器的实现。这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。
- 变长参数:使用一个数组类型的参数实现。
使用自动装箱拆箱是要注意:
- 包装类的“==”运算在不遇到算术运算时不会自动拆箱
- 包装类的equals()方法不处理自动数据转型关系(如long和int)
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); // true!缓存
System.out.println(e == f); // false
System.out.println(c == (a + b)); // true
System.out.println(c.equals(a + b));// true!
System.out.println(g == (a + b)); // true!
System.out.println(g.equals(a + b));// false
}
3.条件编译
根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉。Java语言中条件编译的实现,也是Java语言的一颗语法糖。
- C、C++中使用预处理器指示符(#ifdef)来完成条件编译。预处理器最初的任务是解决编译时的代码依赖关系,如极为常用的#include预处理命令。(用来按条件加入需要编译的文件?)
- Java语言之中并没有使用预处理器:因为Java语言天然的编译方式无须使用到预处理器。Javac编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息。
以下是条件编译代码举例:
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}
编译为class,再反编译,发现else分支直接被去掉了:
public static void main(String[] args) {
System.out.println("block 1");
}
只能使用条件为常量的if语句才能达到上述效果。如果搭配了其他条件判断语句,则则可能在控制流分析中提示错误,被拒绝编译:
public static void main(String[] args) {
// 编译器将会提示“Unreachable code”
while (false) { System.out.println(""); }
}
Java的条件编译的实现方式使用了if语句,所以只能写在方法体内部,它只能实现语句基本块(Block)级别的条件编译,无法实现根据条件调整整个Java类的结构。
其他语法糖还有:
- 内部类
- 枚举类
- 断言语句
- 数值字面量
- 对枚举和字符串的switch支持
- try语句中定义和关闭资源(这3个从JDK 7开始支持)
- Lambda表达式(从JDK 8开始支持,Lambda不能算是单纯的语法糖,但在前端编译器中做了大量的转换工作)
等等。不在赘述。
10.4 实战:插入式注解处理器
我们开发程序,主要还是考虑程序如何运行。针对程序编译的特殊需求会很少。所以,JDK的编译子系统里面,暴露给用户直接控制的功能相对很少。只有:
- 虚拟机即时编译的若干相关参数。第11章介绍
- 使用插入式注解处理器API来对Java编译子系统的行为施加影响(JSR-296中定义)
其在Java语言中,运行期即时编译与虚拟机执行子系统非常紧密的依赖(11章介绍),所以了解JDK如何编译和优化代码,还是有助于我们写出适合Java虚拟机自优化的程序。
下面我们来看我们可以用插入式注解处理器API做什么。
1.实战目标
前端编译器在把Java源码编译成字节码时,会对Java源码做各种检查校验,这些校验主要是解决程序“写得对不对”的问题。
作为补充,业界有很多校验程序“写得好不好”的辅助校验工具,例如CheckStyle、FindBug、阿里的p3c等等。它们有些是基于Java源码校验,有些则是基于字节码扫描来校验。
我们实战的目标是实现一个简单的校验工具:NameCheckProcessor。 用来校验代码命名符合驼峰命名规范。规范如下:
- 类(或接口):符合驼式命名法,首字母大写。
- 方法:符合驼式命名法,首字母小写。
- 字段:
- 类或实例变量。符合驼式命名法,首字母小写。
- 常量。要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。
2.代码实现
一、API介绍
首先简要介绍注解处理器API的使用方法。
1.process()方法及其参数:
继承
AbstractProcessor
抽象类并实现process()
方法:实现注解处理器的代码需要继承抽象类javax.annotation.processing.AbstractProcessor
,该类只有一个方法需要实现,即process()方法。该方法是是Javac编译器在执行注解处理器代码时要调用的过程。方法的第一个参数“
annotations
”:可以从该参数获取到此注解处理器所要处理的注解集合。方法的第二个参数“
roundEnv
”:可以访问到当前这个轮次(Round)中的抽象语法树节点,每个语法树节点在这里都表示为一个Element
。Element
有18类,在javax.lang.model.ElementKind
中定义,已经包括了Java代码中可能出现的全部元素:包(PACKAGE)、枚举(ENUM)、类(CLASS)、 注解 (ANNOTATION_TYPE)、接口(INTERFACE)、枚举值(ENUM_CONSTANT)、 字段 (FIELD)、参数(PARAMETER)、本地变量(LOCAL_VARIABLE)、 异常 (EXCEPTION_PARAMETER)、方法(METHOD)、构造函数(CONSTRUCTOR)、 静态语句块 (STATIC_INIT,即static{}块)、实例语句块(INSTANCE_INIT,即{}块)、参数化类型 (TYPE_PARAMETER,泛型尖括号内的类型)、 资源变量(RESOURCE_VARIABLE,try-resource 中定义的变量)、模块(MODULE)和未定义的其他语法树节点(OTHER)
processingEnv
变量:是AbstractProcessor
中的一个protected变量。在注解处理器初始化的时候(init()方法执行的时候)创建。代表了注解处理器框架提供的一个上下文环境,要创建新的代码、向编译器输出信息、获取其他工具类等都需要用到这个实例变量。
2.两个配合使用的注解:
@SupportedAnnotationTypes
:表示这个注解处理器对哪些注解感兴趣,可以使用星号“*”作为通配符表示对所有的注解都感兴趣。@SupportedSourceVersion
:这个注解处理器可以处理哪些 版本的Java代码。
每一个注解处理器在运行时都是单例的,如果无需改变或添加抽象语法树中的内容,process()方法就可以返回一个值为false的布尔值。我们要实现的命名校验插件无需改变抽象语法树结构,所以全部返回为false。
注解处理器代码如下:
// 可以用"*"表示支持所有Annotations
@SupportedAnnotationTypes("*")
// 只支持JDK 6的Java代码
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class NameCheckProcessor extends AbstractProcessor {
private NameChecker nameChecker;
/*** 初始化名称检查插件 */
@Override
public void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
nameChecker = new NameChecker(processingEnv);
}
/*** 对输入的语法树的各个节点进行名称检查 */
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (!roundEnv.processingOver()) {
for (Element element : roundEnv.getRootElements()) nameChecker.checkNames(element);
}
return false;
}
}
可以看到,在process()方法中,我们从roundEnv上下文获取到了所有的节点element,然后把当前轮次中的每一个rootElement传递到一个名为NameChecker的检查器中进行检查即可。
检查器实现如下:
/**
* 程序名称规范的编译器插件:<br>
* 如果程序命名不合规范,将会输出一个编译器的WARNING信息
*/
public class NameChecker {
private final Messager messager;
NameCheckScanner nameCheckScanner = new NameCheckScanner();
NameChecker(ProcessingEnvironment processsingEnv) {
this.messager = processsingEnv.getMessager();
}
/**
* 对Java程序命名进行检查,根据《Java语言规范》第三版第6.8节的要求,Java程序命名应当符合下列格式:
*
* <ul>
* <li>类或接口:符合驼式命名法,首字母大写。
* <li>方法:符合驼式命名法,首字母小写。
* <li>字段:
* <ul>
* <li>类、实例变量: 符合驼式命名法,首字母小写。
* <li>常量: 要求全部大写。
* </ul>
* </ul>
* /
* public void checkNames(Element element) {
* nameCheckScanner.scan(element);
* }
* <p>
* /*** 名称检查器实现类,继承了JDK 6中新提供的ElementScanner6<br>
* 将会以Visitor模式访问抽象语法树中的元素
*/
private class NameCheckScanner extends ElementScanner6<Void, Void> {
/*** 此方法用于检查Java类 */
@Override
public Void visitType(TypeElement e, Void p) {
scan(e.getTypeParameters(), p);
checkCamelCase(e, true);
super.visitType(e, p);
return null;
}
/*** 检查方法命名是否合法 */
@Override
public Void visitExecutable(ExecutableElement e, Void p) {
if (e.getKind() == METHOD) {
Name name = e.getSimpleName();
if (name.contentEquals(e.getEnclosingElement().getSimpleName()))
messager.printMessage(WARNING, "一个普通方法 “" + name + "”不应当与类名重复,避免与构造函数产生混淆", e);
checkCamelCase(e, false);
}
super.visitExecutable(e, p);
return null;
}
/*** 检查变量命名是否合法 */
@Override
public Void visitVariable(VariableElement e, Void p) {
// 如果这个Variable是枚举或常量,则按大写命名检查,否则按照驼式命名法规则检查
if (e.getKind() == ENUM_CONSTANT || e.getConstantValue() != null || heuristicallyConstant(e))
checkAllCaps(e);
else
checkCamelCase(e, false);
return null;
}
/*** 判断一个变量是否是常量 */
private boolean heuristicallyConstant(VariableElement e) {
if (e.getEnclosingElement().getKind() == INTERFACE)
return true;
else if (e.getKind() == FIELD && e.getModifiers().containsAll(EnumSet.of(PUBLIC, STATIC, FINAL)))
return true;
else {
return false;
}
}
/*** 检查传入的Element是否符合驼式命名法,如果不符合,则输出警告信息 */
private void checkCamelCase(Element e, boolean initialCaps) {
String name = e.getSimpleName().toString();
boolean previousUpper = false;
boolean conventional = true;
int firstCodePoint = name.codePointAt(0);
if (Character.isUpperCase(firstCodePoint)) {
previousUpper = true;
if (!initialCaps) {
messager.printMessage(WARNING, "名称“" + name + "”应当以小写字母开头", e);
return;
}
} else if (Character.isLowerCase(firstCodePoint)) {
if (initialCaps) {
messager.printMessage(WARNING, "名称“" + name + "”应当以大写字母开头", e);
return;
}
}
elseconventional = false;
if (conventional) {
int cp = firstCodePoint;
for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) {
cp = name.codePointAt(i);
if (Character.isUpperCase(cp)) {
if (previousUpper) {
conventional = false;
break;
}
previousUpper = true;
}
elsepreviousUpper = false;
}
}
if (!conventional) messager.printMessage(WARNING, "名称“" + name + "”应当符合驼式命名法(Camel Case Names)", e);
}
/*** 大写命名检查,要求第一个字母必须是大写的英文字母,其余部分可以是下划线或大写字母 */
private void checkAllCaps(Element e) {
String name = e.getSimpleName().toString();
boolean conventional = true;
int firstCodePoint = name.codePointAt(0);
if (!Character.isUpperCase(firstCodePoint)) conventional = false;
else {
boolean previousUnderscore = false;
int cp = firstCodePoint;
for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) {
cp = name.codePointAt(i);
if (cp == (int) '_') {
if (previousUnderscore) {
conventional = false;
break;
}
previousUnderscore = true;
} else {
previousUnderscore = false;
if (!Character.isUpperCase(cp) && !Character.isDigit(cp)) {
conventional = false;
break;
}
}
}
}
if (!conventional) messager.printMessage(WARNING, "常量“" + name + "”应当全部以大写字母或下划线命名,并且以字母开头", e);
}
}
}
NameChecker它通过一个继承于javax.lang.model.util.ElementScanner6
的NameCheckScanner
类,以Visitor模式来完成对语法树的遍历,分别执行visitType()、visitVariable()和visitExecutable()方法来访问类、字段和方法,这3个visit*()方法对各自的命名规则做相应的检查,checkCamelCase()与checkAllCaps()方法则用于实现驼式命名法和全大写命名规则的检查。
整个注解处理器只需NameCheckProcessor和NameChecker两个类就可以全部完成。
以下是检验代码样例:
public class BADLY_NAMED_CODE {
enum colors {red, blue, green;}
static final int _FORTY_TWO = 42;
public static int NOT_A_CONSTANT = _FORTY_TWO;
protected void BADLY_NAMED_CODE() {
return;
}
public void NOTcamelCASEmethodNAME() {
return;
}
}
3.运行与测试
可以通过Javac命令的“-processor”参数来执行编译时需要附带的注解处理器,如果有多个注解 处理器的话,用逗号分隔。
还可以使用-XprintRounds和-XprintProcessorInfo参数来查看注解处理器运作的详细信息。
最终测试结果如下:
D:\src>javac org/fenixsoft/compile/NameChecker.java
D:\src>javac org/fenixsoft/compile/NameCheckProcessor.java
D:\src>javac -processor org.fenixsoft.compile.NameCheckProcessor org/fenixsoft/compile/BADLY_NAMED_CODE.java
org\fenixsoft\compile\BADLY_NAMED_CODE.java:3: 警告:名称“BADLY_NAMED_CODE”应当符合驼式命名法(Camel Case Names)
public class BADLY_NAMED_CODE {
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:5: 警告:名称“colors”应当以大写字母开头
enum colors {
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6: 警告:常量“red”应当全部以大写字母或下划线命名,并且以字母开头
red, blue, green;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6: 警告:常量“blue”应当全部以大写字母或下划线命名,并且以字母开头
red, blue, green;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6: 警告:常量“green”应当全部以大写字母或下划线命名,并且以字母开头
red, blue, green;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:9: 警告:常量“_FORTY_TWO”应当全部以大写字母或下划线命名,并且以字母开头
static final int _FORTY_TWO = 42;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:11: 警告:名称“NOT_A_CONSTANT”应当以小写字母开头
public static int NOT_A_CONSTANT = _FORTY_TWO;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:13: 警告:名称“Test”应当以小写字母开头
protected void Test() {
^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:17: 警告:名称“NOTcamelCASEmethodNAME”应当以小写字母开头
public void NOTcamelCASEmethodNAME() {
4.其他应用案例
其他基于这组API支持的比较有名的项目还有:
- 用于校验Hibernate标签使用正确性的
Hibernate Validator Annotation Processor
(本质上与NameCheckProcessor所做的事情差不多) - 自动为字段生成getter和setter方法等辅助内容的Lombok
等等。
10.5 本章小结
我们参照Javac编译器的源码实现,
- 学习了Java源代码编译为字节码的过程
- 分析了Java语言中泛型、主动装箱拆箱、条件编译等多种语法糖的前因后果
- 实战练习了如何使用插入式注解处理器来完成一个检查程序命名规范的编译器插件。
在前端编译器中,“优化”手段主要用于提升程序的编码效率。它只完成了从程序到抽象语法树或中间字节码的生成。
在此之后,还有一组内置于JVM的“后端编译器”来完成代码优化以及从字节码生成本地机器码的过程。
即即时编译器或提前编译器,这个后端编译器的编译速度及编译结果质量高低,是衡量JVM性能最重要的一个指标。11章我们讲对后端编译器的运作和优化进行探讨。
提问
编译器主要有哪三种?各有什么区别?
Javac编译器用什么语言实现?
前端编译可以分为哪几个过程?各阶段主要做什么?1个准备,3个处理。结合那张图说一下即可。
解析的目的?填充符号表的目的?抽象语法树
插入式注解处理器可以用来干啥?程序员修改语法树
语义分析的目的?分为哪两个过程?举个例子?前面的过程保证语法树结构正确,本大爷则保证结合上下文分析来保证语义逻辑正确
<init>()方法和类构造器<clinit>()方法在哪个阶段添加到语法树的?它们都需要调用父类的构造器吗?
Java泛型和C#泛型的实现方式区别?有啥缺点和优点?
Java的泛型类型擦除如何实现?什么是裸类型?Java的泛型仅仅在编译器层面实现。
什么是值类型?未来泛型实现的方式思路?
泛型、自动装箱、自动拆箱、遍历循环、变长参数、条件编译的底层实现方式?使用包装类时,它们的”==”和”equals()”有何特点?
除了以上,还有哪些语法糖?
如何实现一个代码命名规范校验工具?讲讲思路。插入式注解处理器API。
第11章 后端编译与优化
11.1 概述
后端编译过程:如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR),那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。
后端编译有提前编译(Ahead Of Time,AOT)和即时编译(Just In Time,JIT)两种。早年主要是以即时编译为主流,近些年提前编译也开始被重视起来。
后端编译器完全取决于具体虚拟机实现:《Java虚拟机规范》中没有规定过虚拟机内部必须要包含提前编译器抑或即时编译器。而后端编译器编译性能的好坏、代码优化质量的高低却是衡量JVM是否优秀的关键指标之一。
由于后端编译器全部取决于具体的JVM实现,本章的即时编译器都是特指HotSpot内置的即时编译器,虚拟机也是特指HotSpot虚拟机。不过主流的后端编译器,实现思想都是相通的。
11.2 即时编译器
什么是即时编译器?最初的虚拟机实现中,Java都是解释执行的。当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,JVM将会把这些热点代码编译成本地机器码,并用各种手段进行代码优化。运行时完成这个任务的后端编译器就被称为即时编译器。
本节我们来介绍Hotspot内的即时编译器运作过程。另外,我们还将解决以下问题:
- 为什么HotSpot要使用解释器与即时编译器并存的架构?
- 为什么HotSpot要实现两个(或三个)不同的即时编译器?
- 程序什么时候使用解释器执行?什么时候使用编译器执行?
- 哪些程序代码会被编译为本地代码?如何编译本地代码?
- 怎么从外部观察即时编译器的编译过程和编译结果?
1.解释器与编译器
一、解释器与编译器
解释器与编译器两者各有优势:
解释器:
- 启动快,但运行效率低:支持程序迅速启动执行,省去编译的时间。
- 节省内存,但效率差:内存资源限制很大时,使用解释执行来节约内存。
- 可作为编译器激进优化失败的逃生门:可作为编译器激进优化时后备的“逃生门”。(情况允许时,HotSpot也会用不进行激进优化的客户端编译器充当)
编译器:
- 启动慢,但运行效率高:提高执行效率。启动后将代码编译为本地代码,减少解释器中间损耗,提高执行效率。
- 占内存大,但效率高,:但可以提升执行效率。
- 可使用激进优化,但可能会优化失败:可以选择一些但大多数时候都能提升运行速度的优化手段。当激进优化的假设不成立时,如加载了新类后类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时。可以通过逆优化(Deoptimization)退回到解释状态继续执行
如上我们可以看到,编译器和解释器是互补的。所以一般是把它们一起搭配使用,发挥各自长处。两者配合工作示意如下图:
Hotspot内置了两个(或三个)即时编译器:
- C1编译器:也叫“客户端编译器”(Client Compiler)。将字节码编译为本地代码,另外可选择开启“零监控、有限的、全部的”三个级别的性能监控。
- C2编译器:也叫“服务端编译器”(Server Compiler)。在C1基础上,会有更多的编译耗时更长的优化,且会根据性能监控信息进行一些不可靠的激进优化。
- Graal编译器:用于取代C2。JDK10出现,目前还是实验状态。
二、运行模式
运行模式:
a.分层编译出现前
- 混合模式:解释器和编译器搭配。然后根据解释器和哪个编译器搭配,又分为客户端模式还是服务端模式。 Hotspot可根据自己版本及硬件情况自动选择运行模式。用户也可以使用“-client”或“-server”参数强制指定。
- 解释模式:仅解释器工作。可使用参数“-Xint”强制指定。
- 编译模式:优先编译器工作。但解释器仍需在编译无法进行的情况下介入执行。
b.分层编译出现后
分层编译属于混合模式,JDK7作为服务端模式JVM的默认编译策略。
分层编译目的:为了在程序启动响应速度与运行效率之间达到最佳平衡。我们知道,想要获得编译优化程度更高的代码,需要更长的编译时间,而且还可能需要解释器帮编译器收集性能监控信息,而拖累解释执行的速度。所以提出了分层编译的概念。用C1、C2两个编译器分层负担工作量,C1用来进行简单可靠优化,以及不同层次的性能监控。C2用来进行更深层次的优化,以及激进优化。这样就兼顾了响应速度和效率,同时也不同拖累解释器。
分层如下:
- 第0层:纯解释执行。不开启性能监控(Profiling)。
- 第1层:C1编译为本地代码,简单可靠优化,不开启性能监控。
- 第2层:C1编译为本地代码。开启有限性能监控(方法及回边次数统计等)。
- 第3层:C1编译为本地代码。开启全部性能监控(除第2层外,还会收集如分支跳转、虚方法调用版本等)
- 第4层:C2编译为本地代码。比起C1,会有更多的更耗时的优化,还会根据性能监控信息进行一些不可靠的激进优化。
然后,分层编译时,解释器、C1、C2是同时工作的,热点代码都可能会被多次编译。C1提供了更快的编译速度顶上,C2提供更好的编译质量,然后解释器也无需参与性能监控信息收集。总体工作过程如下图:
2.编译对象与触发条件
一、热点代码
前文中提到了即时编译器编译的目标是“热点代码”。
热点代码主要有两类:
- 被多次调用的方法。
- 被多次执行的循环体。
上述代码,编译的对象都是整个方法体,而不会是单独的循环体。
第一种情况,编译动作由方法调用触发编译,编译对象是整个方法,这是JVM标准的即时编译方式。
第二种情况,编译动作由循环体所触发,编译对象还是整个方法。这种情况由于编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。 此时执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。
二、热点探测
某个方法要执行多少次才算多次呢?JVM又如何统计方法或代码执行次数?
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(HotSpot Code Detection)。
热点探测不一定要指定调用的具体次数。目前主流的探测方式有以下两种:
- 基于采样的热点探测(Sample Based Hot Spot Code Detection):周期性地检查各线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那么这个方法就是“热点方法”。优点:实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可)。缺点:很难精确地确认一个方法的热度,容易受线程阻塞等外界因素影响。
- 基于计数器的热点探测(Counter Based Hot Spot Code Detection):为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。优点:统计更精确。缺点:实现麻烦。需为每个方法建立并维护计数器,且不能直接获取到方法的调用关系。
以上两种方式,商业虚拟机都有用到。J9用的是第一种。而Hotspot用的是第二种。
三、Hotspot的热点探测方式
HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
a.方法调用计数器
计数逻辑:方法调用时,优先按本地代码来执行。如果没有已被编译过的版本,则该方法调用计数器值加一。超过阈值后,向即时编译器提交该方法编译请求。编译工作完成后,该方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了。
默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold
来人为设定。
客户端模式JVM执行逻辑如下图:
方法调用计数器热度的衰减(Counter Decay):在默认设置下,统计的不是绝对次数,而是一段时间之内方法被调用的次数。且当超过一定的时间限度还未编译,则该方法的调用计数器就会被减少一半。该时间被称为此方法统计的半衰周期(Counter Half Life Time)。热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的。
可以使用虚拟机参数-XX:-UseCounterDecay
关闭热度衰减,让方法计数器统计绝对次数。此外还可以使用-XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒。
b.回边计数器
作用:统计一个方法中循环体代码执行的次数,然后触发栈上的替换编译。在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,
如何调整参数:用参数-XX:OnStackReplacePercentage
来间接调整回边计数器的阈值,其计算公式有如下两种:
- 在客户端模式下:方法调用计数器阈值(-XX:CompileThreshold)乘以OSR比率(-XX:OnStackReplacePercentage)除以100。其中-XX:OnStackReplacePercentage默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为13995。
- 在服务端模式下:方法调用计数器阈值(-XX:CompileThreshold)乘以(OSR比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX:InterpreterProfilePercentage)的差值)除以100。其中-XX:OnStack ReplacePercentage默认值为140,-XX:InterpreterProfilePercentage默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为10700。
执行逻辑:解释器遇到一条回边指令时,优先执行已经编译好的版本,没有的话回边计数器的值加一,超过阈值的话,将会提交一个栈上替换编译请求,并把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。客户端模式JVM总体逻辑如下图:
回边计数器没有计数热度衰减的过程,统计的是循环执行的绝对次数。计数器溢出的时候,还会把方法计数器的值也调整到溢出状态。
服务端模式虚拟机来说,方法计数器和回边计数器的执行情况都会比上面两个图更复杂一些。
3.编译过程
默认是后台编译:无论是标准编译请求,还是栈上替换编译请求,JVM在完成编译之前,都仍然将按照解释方式继续执行代码。不过用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,此时执行线程会一直阻塞等待至编译完成。
接来下说下C1(客户端编译器)和C2(服务端编译器)编译器的区别。它们追求不同,C1追求快速编译,C2追求深度优化,所以过程也不同。
一、C1编译器
是一个相对简单快速的三段式编译器,主要是局部优化,耗时较长的全局优化手段都舍弃掉:
- 第一阶段:在字节码上完成一部分基础优化,如方法内联、常量传播等。然后一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,从而让一些在HIR的构造过程之中及之后的优化动作更容易实现。
- 第二阶段:在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。然后一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示)。
- 第三阶段:是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。
总体过程如下图,比上面的描述清晰很多:
二、C2编译器
是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度。
以下是一些C2编译器的优化动作。其中一些后续会将,所以这里不做展开:
- 执行大部分经典优化动作:无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等。
- 一些与Java语言特性密切相关的优化技术:范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。
- 一些不稳定的预测性激进优化:主要是根据解释器或客户端编译器提供的性能监控信息来展开。如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。
服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如 RISC)上的大寄存器集合。
尽管C2的编译速度比较慢,但依然远远超过传统的静态优化编译器,且它输出的高质量本地代码也抵消掉额外的编译时间开销,所以也有很多非服务端的应用也选择使用服务端模式的HotSpot来运行。
一般来说,编译过程对Java是透明的。不过HotSpot提供了两个可视化的工具,让我们可以“看见”即时编译器的优化过程。下面我们将实践演示这个过程。
4.实战:查看及分析即时编译结果(略)
大多数情况下用户没必要知道编译过程。但是为了满足调试和调优的需要,HotSpot虚拟机还是提供了一些参数用来输出即时编译和某些优化措施的运行状况。本节将通过实战说明如何从外部观察Java虚拟机的即时编译行为。
工具参考:
使用参数-XX:+PrintCFGToFile(用于客户端编译器)或-XX:PrintIdealGraphFile
(用于服务端编译器)要求Java虚拟机将编译过程中各个阶段的数据(譬如对客户端编译器来说包括字节码、HIR生成、LIR生成、寄存器分配过程、本地代码生成等数据)输出到文件中。然后使用Java HotSpot Client CompilerVisualizer用于分析客户端编译器)或Ideal Graph Visualizer(用于分析服务端编译器)打开这些数据文件进行分析。
具体实战内容略。
11.3 提前编译器
提前编译器出现的也很早。1996年JDK 1.0发布,1996年7月发布的JDK 1.0.2增加了即时编译器,几个月后IBM就推出了用于Java的提前编译器。在1998年,GNU组织公布了GCJ,使用的比较广泛。
之后并没有流行起来。大家考虑的都是平台中立性,所以此后15年,GCJ等基本没有大的发展。
Android带来了改变。2013年,在Android的世界,使用提前编译的ART(Android Runtime)横空出世,执行性能很高,仅经过Android 4.4一个版本,就把使用即时编译的Dalvik虚拟机干死了。
Android虽然不等同于Java,但和Java很有渊源。提前编译在Androi的崛起震撼了Java。所以在某些领域,只要能获得更好的执行性能,什么平台中立性、字节膨胀、动态扩展,一切皆可舍弃。
不过,唯一的问题就只有“提前编译真的会是获得更高性能的银弹吗?”
1.提前编译的优劣得失
本节分析提前编译相对于即时编译的优势及不足。
一、提前编译实现的两个分支
目前提前编译主要有两个方向:
- 纯静态编译:与C++类似,把程序代码编译成机器码。
- 即时编译缓存:把原本即时编译器在运行期执行的工作提前编译并保存,到时候直接加载即可。也叫动态提前编译。
1.第一种方式,纯静态编译的优劣
它就是用来解决即时编译器的硬伤的:即即时编译需要占用程序运行时间和运算资源。即使可以用分层编译,先用低质量的编译顶上来减少响应时间,但是无论怎么优化,肯定会耗费运算资源。
以“过程间分析”举例:
它是编译最耗时的过程之一,包括“某个变量的值是否一定为常量、某段代码块是否永远不可能被使用、在某个点调用的某个虚方法是否只能有单一版本”等分析。
要得到这些信息,必须要在全程序范围内做大量极耗时的计算。
为了减少耗时,现在JVM对过程间分析的支持很有限,实现方式,要么是借助大规模的方法内联以过程内分析来模拟过程间分析的部分效果。要么是用可假设的激进优化,不求精确的结果,只求按最可能的状况来优化,有问题再退回来解析执行。
但如果是用纯静态的提前编译的话,耗时优化可以放心大胆地进行全程序优化。例如ART,不过也会造成程序安装时间过长。所以从Android 7.0版本又起重新启用了解释执行和即时编译,等空闲时系统再在后台自动进行提前编译。
2.第二种方式,即时编译缓存的优劣
这种方式,本质是给即时编译器做缓存加速,来改善Java程序的启动时间。这种方式的提前编译已经完全被主流的商用JDK支持。
例如,OpenJDK/OracleJDK 9中所带的Jaotc提前编译器,可以针对目标机器为应用程序进行提前编译。HotSpot运行时可以直接加载这些编译的结果,减少程序达到全速运行状态所需的时间。比如说,各种Java应用都会用到的Java的标准类库,如java.base等模块,就可以提前将这个类库编译好。
不过这种方式实际应用起来并不是那么容易,因为这种方式不仅要和目标机器相关,甚至还必须与HotSpot虚拟机的运行时参数绑定,例如不同的GC收集器。
二、提前编译器不是银弹
即时编译相对于提前编译的天然优势:
1.性能分析制导优化(Profile-Guided Optimization,PGO)。即可根据性能监控信息来进行特定优化。例如,如果一个条件分支的某一条路径执行很频繁,而其他路径执行很少,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。
2.激进预测性优化(Aggressive Speculative Optimization)。即可进行激进优化。静态优化无论如何都必须保证优化后所有的程序外部可见影响(不仅仅是执行结果)与优化前是等效的。但是即时编译可以大胆地按照高概率的假设激进优化,大不了退回到低级编译器甚至解释器上去执行。例如,部分C、C++程序员会说虚方法是不能内联的,但Java可以通过类继承关系分析等一系列激进的猜测去做去虚拟化(Devitalization),以保证绝大部分有内联价值的虚方法都可以顺利内联。
3.链接时优化(Link-Time Optimization,LTO)。即可跨动态链接库优化。Java语言天生就是动态链接的,所以它可以跨动态链接库优化,例如跨动态链接库进行方法内联。这在C/C++来看一难以想象的,因为C/C++的主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译、优化自己的代码。这些代码的作者、编译的时间,甚至编译器都很可能是不同的。所以很难做到跨动态库优化。
所以,提前编译器也并不是银弹。
2.实战:Jaotc的提前编译(略)
JDK 9引入了用于支持对Class文件和模块进行提前编译的工具Jaotc,以减少程序的启动时间和到达全速性能的预热时间。
但由于这项功能必须针对特定物理机器和目标虚拟机的运行参数来使用,加之限制太多,Java开发人员对此了解、使用普遍比较少。
本节我们将用Jaotc来编译Java SE的基础库[1](java.base模块),以改善本机Java环境的执行效率。
直接把代码编译成静态链接库。运行时直接运行静态链接库,而不是用Javac运行.class文件。
目前状态的Jaotc还不够成熟,还有许多需要完善的地方,能比较顺利编译的也只有java.base模块而已。不过随着Graal编译器的逐渐成熟,相信Jaotc前途还是可期的。
同样有发展潜力的Substrate VM也不应被忽视。Jaotc做的提前编译属于本节开头所说的“第二条分支”,即做即时编译的缓存;而Substrate VM则是选择的“第一条分支”,做的是传统的静态提前编译。
具体操作略。
11.4 编译器的优化技术
编译器的关键点不是输出机器码,而是如何输出优化质量高的代码。
本节将介绍几种HotSpot即时编译器使用的代码优化技术,整体了解下编译器代码优化的思路。
1.优化技术概览
一、整体优化技术列表
我们先进行概览,后续选择几个重要的典型的讲讲。
以下是Hotspot团队列举出的即时编译器优化技术列表,一些是经典编译器的优化手段,一些是JVM特有的,内容相对全面。如下图:
二、优化举例
以下用一个例子来进行直观的优化体验。为了表示方便,直接用Java源码表示。实际操作是简历在代码的中间表示、或者机器码之上的。
如下代码:
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
方法内联优化:主要目的有两个:1.去除方法调用的成本(如查找方法版本、建立栈帧等);2.为其他优化建立良好的基础。因为方法膨胀后便于在更大范围进行后续的优化。所以很多编译器把方法内联作为最靠前的优化。
方法内联优化后如下:
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
冗余访问消除(Redundant Loads Elimination):假设代码中间注释掉的“…do stuff…”所代表的操作不会改变b.value的值,那么就可以把“z=b.value”替换为“z=y”。这样就不用再去访问b变量。这个操作可以看作一种公共子表达式消除。
冗余访问消除优化后如下:
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}
复写传播(Copy Propagation):该代码中没有必要使用一个额外的变量z,它与变量y是完全相等的,因此我们可以使用y来代替z。
复写传播优化后如下:
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}
无用代码消除(Dead Code Elimination):就是把可能是永远不会被执行的代码,或者完全没有意义的代码干掉。
无用代码消除优化后如下:
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}
以上。
三、重要的优化方式
下面我们来了解4种很有代表性的优化方式:
- 最重要的优化技术之一:方法内联。
- 最前沿的优化技术之一:逃逸分析。
- 语言无关的经典优化技术之一:公共子表达式消除。
- 语言相关的经典优化技术之一:数组边界检查消除。
2.方法内联
最重要的优化技术之一。
一、内联和虚方法是矛盾的
方法内联是编译器最重要的优化手段,没有之一。它除了消除方法调用的成本外,更重要的是为其他优化手段建立了良好的基础。
方法内联的本质就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。
看似很简单,其实复杂的地方在于,Java语言中默认的实例方法是虚方法,即Java方法调用时,除非调用的是非虚方法是固定的、可以在编译期解析,其他调用的方法都是虚方法、方法可能有多个版本:
- 使用invokespecial指令调用的私有方法、实例构造器、父类方法
- 使用invokestatic指令调用的静态方法
- 被final修饰的方法(尽管它使用invokevirtual指令调用)
所以,某种程度上来讲,内联和虚方法是矛盾的。
二、如何解决内联和虚方法的矛盾
对于C和C++来讲,默认的方法是非虚方法,如果需要用到多态,就用virtual关键字来修饰。
对于Java来讲,Java选择了在虚拟机中解决这个问题。
Java引入了为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术来解决这个问题。
类型继承关系分析即在整个应用程序范围内进行类型分析,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。
分析完成后,编译时根据不同情况进行处理:
- 非虚方法:直接内联即可。
- 虚方法:向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择:
- 守护内联:如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联。不过由于Java是动态连接的,后面随时可能会加载到新的类型从而改变CHA结论,因此这属于激进预测性优化,需要留好“逃生门”。若加载了导致继承关系发生变化的新类,则抛弃已编译的代码,退回到解释状态进行执行,或者重新进行编译。
- 内联缓存:如果查询到有多个版本,则即时编译器会再抢救一下,使用内联缓存。大概原理:内联缓存是建立在目标方法正常入口之前的缓存。在未发生方法调用之前,内联缓存状态为空。在第一次调用后,缓存记录下方法接收者的版本,且每次进行方法调用时都比较接收者的版本。比较后又分以下两种情况:
- 单态内联缓存:每次调用的方法版本都一样。此时调用已缓存起来,通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次判断方法类型的开销。
- 超多态内联缓存:真的出现方法接收者不一致,说明用到了虚方法的多态特性。此时没有用到缓存,开销相当于真正查找虚方法表来进行方法分派。
所以,在多数情况下JVM的方法内联都是一种激进优化。
除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”。反正有逃生门兜底,遇到特殊情况可以回到解释状态重新执行。
3.逃逸分析
最前沿的优化技术之一。
一、逃逸分析概念
逃逸分析和类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
基本原理是:分析对象动态作用域。当一个对象在方法里面被定义后。它可能被其他地方引用。有以下三种情况:
- 不逃逸:对象就在方法里,没有外部的引用。
- 方法逃逸:可能被外部方法所引用,例如作为调用参数传递到其他方法中。
- 线程逃逸:还有可能被外部线程访问到,例如赋值给可以在其他线程中访问的实例变量。
以上三个逃逸程度由低到高。
二、基于逃逸分析的编译优化
可以根据不同的逃逸分析结果,对程序进行优化。
- 栈上分配(Stack Allocations):栈上分配内存。如果一个对象不会逃逸到线程外,那么可以让这个对象在栈上分配内存。我们知道,进行GC时,标记筛选可回收对象、回收整理内存都是要耗费大量资源的工作。栈上分配可以避免这种GC消耗。对象随着代码进出方法,自动的进行创建和销毁,从而减轻GC子系统压力。而且,在一般应用中,不逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的。由于一些原因,Hotspot暂时没实现这个。(栈内存使用过大怎么解决?)
- 标量替换(Scalar Replacement):拆对象。如果一个对象不会逃逸到方法外,那么可以不去创建这个对象,直接创建它的若干个被这个方法使用的成员变量来代替即可。此时,a.少了一次对象创建的过程,b.而且对象的成员变量可以在栈上分配和读写来提高读写性能。c.还可以为后续进一步的优化手段创建条件。
- 标量:若一个数据已无法再分解成更小的数据来表示,那么这些数据就可以被称为标量。如JVM中的原始数据类型(int、long等数值类型及reference类型等)。
- 聚合量:如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),如Java中的对象。
- 标量替换:把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。
- 同步消除(Synchronization Elimination):去除同步锁。如果一个对象不会逃逸到线程外,那么对这个变量实施的同步措施可以全部安全地消除。线程同步本身是一个相对耗时的过程,不逃逸到线程外,当然不会被其他线程访问,也就没有同步的必要。
基于逃逸分析的优化技术目前还不够成熟,主要原因是逃逸分析的计算成本非常高,甚至不能保证带来的性能收益高于它的消耗。所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成逃逸分析。
inline内联类型:C和C++语言里面原生就支持了栈上分配(不使用new操作符即可),而C#也支持值类型,可以很自然地做到标量替换(但并不会对引用类型做这种优化)。但在灵活运用栈内存方面,Java是比较弱鸡的。所以现在仍处于实验阶段的Valhalla项目里,设计了新的inline关键字用于定义Java的内联类型,目的是实现与C#中值类型相对标的功能。有了这个标识与约束,逃逸分析会简单很多。
三、逃逸分析运用举例
以下用Java伪代码表示。
原始代码如下:
// 完全未优化的代码
// Point类是一个坐标类POJO,代码省略。
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
第一步,将Point的构造函数和getX()方法进行内联优化:
// 步骤1:构造函数内联后的样子
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
p.x = xx; // Point构造函数被内联后的样子
p.y = 42;
return p.x; // Point::getX()被内联后的样子
}
第二步,经过逃逸分析,发现Point对象实例不会有方法逃逸。所以直接进行标量替换:
// 步骤2:标量替换后的样子
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42;
return px;
}
第三步,通过数据流分析,发现py的值没啥用。直接进行无用代码消除:
// 步骤3:做无效代码消除后的样子
public int test(int x) {
return x + 2;
}
其实一开始在大型程序中实施逃逸分析的优化效果还不稳定,一直到JDK 7时,才成为服务端编译器才默认开启基于逃逸分析的优化。用户可以使用参数-XX:+DoEscapeAnalysis
来手动开启逃逸分析, 开启之后可以通过参数-XX:+PrintEscapeAnalysis
来查看分析结果。有了逃逸分析支持之后,可以使用参数-XX:+EliminateAllocations
来开启标量替换,使用+XX:+EliminateLocks
来开启同步消除,使用参数-XX:+PrintEliminateAllocations
查看标量的替换情况。
逃逸分析技术是即时编译器优化技术的一个重要前进方向。
4.公共子表达式消除
语言无关的经典优化技术之一。
公共子表达式:如果一个表达式E之前已经被计算过了,且从先前的计算到现在,E中所有变量的值都没有发生变化,那么此次出现的E就称为公共子表达式。
公共子表达式消除:对于公共子表达式,无需重新计算,只接用前面计算过的结果代替即可。
- 局部公共子表达式消除(Local Common Subexpression Elimination):这种优化仅限于程序基本块内。
- 全局公共子表达式消除(Global Common Subexpression Elimination):这种优化的范围涵盖了多个基本块。
举例说明:
假如有如下代码:
int d = (c * b) * 12 + a + (a + b * c);
假设编译器不进行任何优化,那么字节码编译结果如下:
iload_2 // b
imul // 计算b*c
bipush 12 // 推入12
imul // 计算(c * b) * 12
iload_1 // a
iadd // 计算(c * b) * 12 + a
iload_1 // a
iload_2 // b
iload_3 // c
imul // 计算b * c
iadd // 计算a + b * c
iadd // 计算(c * b) * 12 + a + a + b * c
istore 4
该字节码进入JVM即时编译器后,会进行如下公共子表达式优化:编译器检测到c*b与b*c是一样的表达式,而且在计算期间b与c的值是不变的。 因此这条表达式就可能被视为:
int d = E * 12 + a + (a + E);
编译器还可能进行代数化简优化(Algebraic Simplification):在E本来就有乘法运算的前提下,把表达式变为:
int d = E * 13 + a + a;
如此,就节省了表达式计算的时间。
如果对其他的经典编译优化技术感兴趣,可以参考《编译原理》(俗称龙书)中的相关章节。
5.数组边界检查消除
语言相关的经典优化技术之一。
一、数组边界检查消除
Java有自动的数组越界检查:我们知道Java语言是一门动态安全的语言,对数组的读写访问也不像C、C++那样实质上就是裸指针操作。Java语言中访问数组元素时候系统会自动进行上下界的范围检查,越界则抛出java.lang.ArrayIndexOutOfBoundsException
异常。
但是这样的话,每次访问数组元素,都会隐含一次越界判断,也是一个性能负担。
那么其实我们在运行期不一定每一次都需要检查,例如,如果数组访问发生在循环中,且使用循环变量来进行数组的访问,那么编译器只需通过数据流分析就可判定循环变量的取值范围永远在区间[0,foo.length)之内。这样就省去了每次判断越界的开销。
二、其他检查消除
视觉再拉高一点。Java提供大量的检查,来帮助程序员写出正确的程序。但同时这些检查也会导致一些隐式开销。
这些隐式开销,其实好多都可以通过数据流分析等方式来消除。一般消除的话有两个思路:
- 将运行期检查提前到编译期完成:如数组边界检查优化等。
- 隐式异常处理:如中空指针检查和算术运算中除数为零的检查。
隐式异常处理检查举例:
假设访问一个对象(假设对象叫foo)的某个属性(假设属性叫value),那以Java伪代码来表示虚拟机访问foo.value的过程为:
if (foo != null) {
return foo.value;
} else {
throw new NullPointException();
}
在使用隐式异常优化之后,虚拟机会把上面的伪代码所表示的访问过程变为如下伪代码:
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}
虚拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap(),务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器),这样当foo不为空的时候,对value的访问是不会有任何额外对foo判空的开销的。代价就是当foo真的为空时,必须转到异常处理器中恢复中断并抛出NullPointException异常。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。HotSpot虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。
与语言相关的其他消除操作还有不少,如自动装箱消除(Autobox Elimination)、安全点消除 (Safepoint Elimination)、消除反射(Dereflection)等,不再一一介绍。
11.5 实战:深入理解Graal编译器 (略)
从JDK 10起,HotSpot就同时拥有C1、C2、Graal三个即时编译器。Graal未来用于取代C2。
1.历史背景
Graal虚拟机以及Graal编译器仍在实验室中尚未商用,但未来其有望代替或成为HotSpot下一代技术基础。
Oracle Labs希望它最终能够成为一款高编译效率、高输出质量、支持提前编译和即时编译,同时支持应用于包括HotSpot在内的不同虚拟机的编译器。
为了将编译器与JVM解耦,JDK 9时发布了JEP 243:Java虚拟机编译器接口(Java-Level JVM Compiler Interface,JVMCI)。JVMCI使得Graal可以从HotSpot的代码中分离出来。JVMCI主要提供如下三种功能:
- 响应HotSpot的编译请求,并将该请求分发给Java实现的即时编译器。
- 允许编译器访问HotSpot中与即时编译相关的数据结构,包括类、字段、方法及其性能监控数据等,并提供了一组这些数据结构在Java语言层面的抽象表示。
- 提供HotSpot代码缓存(Code Cache)的Java端抽象表示,允许编译器部署编译完成的二进制机器码。
综合利用上述三项功能,我们就可以把一个在HotSpot虚拟机外部的、用Java语言实现的即时编译器(不局限于Graal)集成到HotSpot中,响应HotSpot发出的最顶层的编译请求,并将编译后的二进制代码部署到HotSpot的代码缓存中。
Graal和JVMCI的出现,为不直接从事Java虚拟机和编译器开发,但对Java虚拟机技术充满好奇心的读者们提供一条窥探和尝试编译器技术的良好途径。甚至C2的作者也很推荐Graal编译器。
下面我们基于Graal来实战HotSpot虚拟机的即时编译与代码优化过程。
具体操作略。
11.6 本章小结
本章我们学习了与提前编译和即时编译器两大后端编译器相关的知识,了解了提前编译器重新兴起的原因及其优劣势;还有与即时编译器相关的热点探测方法、编译触发条件及如何从虚拟机外部观察和分析即时编译的数据和结果;还选择了几种常见的编译器优化技术进行讲解。
对Java编译器的深入了解,有助于在工作中分辨哪些代码是编译器可以帮我们处理的,哪些代码需要自己调节以便更适合编译器的优化。
提问
什么是后端编译器?JVM规范有规定如何实现吗?
什么是即时编译器?完成什么功能?
为什么HotSpot要使用解释器与即时编译器并存的架构?它们是互补的。。。
(为什么HotSpot要实现两个(或三个)不同的即时编译器?)
(程序什么时候使用解释器执行?什么时候使用编译器执行?)
(哪些程序代码会被编译为本地代码?如何编译本地代码?)
(怎么从外部观察即时编译器的编译过程和编译结果?)
什么是解释器、C1、C2,它们有什么区别?如何配合工作?
什么是热点代码?如何进行热点探测?有哪些方法?优缺点?Hotspot用的哪个?
Hotspot方法调用计数器的执行逻辑?回边计数器的执行逻辑?
C1编译器如何编译代码?C2编译器如何编译代码?
提前编译有哪些思路?两个思路
比起提前编译,即时编译有哪些无法替代的优点?3个
列举几个常用的经典的编译器优化技术?各自说说他们的特点
方法内联是什么?JVM如何解决方法内联和虚方法的矛盾?
什么是逃逸分析?可以根据它的结果来做什么优化?各有什么特点?
常见的检查消除有哪些?检查消除有哪些思路?2个
第五部分 高效并发
第12章 Java内存模型与线程
随着Amdahl定律代替摩尔定律成为计算机性能发展源动力,并发处理被广泛应用。这两个定律的更替代表了近年来硬件发展从追求处理器频率到追求多核心并行处理的发展过程
Amdahl定律:通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算加速能力。
摩尔定律:用于描述处理器晶体管数量与运行效率之间的发展关系。
12.1 概述
需要多任务处理的原因:
- 一是计算机的运算速度与它的存储和通信子系统的速度差距太大,大量的时间都花费在磁盘I/O、网络通信或者数据库访问上。为了不让处理器在大部分时间里都处于等待状态,所以让计算机同时处理几项任务去压榨它的性能。
- 二是服务端要同时对多个客户端提供服务的应用场景。
TPS(Transactions Per Second):代表着一秒内服务端平均能响应的请求总数,是衡量一个服务性能好坏的重要指标。
处理好并发方面的问题通常需要更多的编码经验来支持,虽然有各种中间件服务器、各类框架帮我们隐藏并发的细节,但了解并发的内幕仍然是成为一个高级程序员不可缺少的课程。
本部分将会介绍虚拟机如何实现多线程、多线程之间由于共享和竞争数据而导致的一系列问题及解决方案。
12.2 硬件的效率与一致性
我们先了解一下物理计算机中的并发问题,它与JVM的情况有很多相似之处。
高速缓存:绝大多数计算不仅仅靠处理器运算就能完成,还需要与内存交互,如读取运算数据、 存储运算结果等,这个I/O操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。为了弥补存储设备与处理器的运算速度成几个数量级的差异,所以现代计算机系统都不得不加入一层或多层的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中。
缓存一致性(Cache Coherence):高速缓存带来的是缓存一致性问题。在多路处理器系统中,每个处理器都有自己的高速缓存,它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。这个时候就需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
内存模型:它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,JVM也有自己的内存模型,且与上述介绍的内存访问操作及硬件的缓存访问操作高度类似。
处理器、高速缓存、主内存交互关系如下图:
乱序执行优化(Out-Of-Order Execution):为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,在计算之后将乱序执行的结果重组。处理器只能保证该结果与顺序执行的结果一致,但不保证各个语句计算的顺序与输入代码中的顺序一致。因此,如果一个计算任务依赖另外一个计算任务的中间结果,则其顺序性不能靠代码的先后顺序来保证。JVM的即时编译器也有指令重排序优化,和该优化类似。
12.3 Java内存模型
在Java内存模型之前,主流程序语言(如C和C++等)都是直接使用物理硬件和操作系统的内存模型。
《Java虚拟机规范》试图定义一种“Java内存模型”。定义该模型并非易事,因为,模型要定义得足够严谨,才能让Java的并发内存访 问操作不会产生歧义;但是也必须定义得足够宽松,才能让JVM的实现能有足够的自由空间去根据硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)进行优化。
直到JDK5发布后,Java才拥有了成熟完善的内存模型。
1.主内存与工作内存
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在JVM中把变量值存储到内存和从内存中取出变量值这样的底层细节。
此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
为了更好的执行性能,Java内存模型没有限制执行引擎需要使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。也就是说,和主内存交互的寄存器或缓存是可以选择的,还可以选择编译时调整代码执行顺序。
一、Java内存模型
Java内存模型关于线程、主内存、工作内存等规定如下:
- 所有的变量都存储在主内存中(Main Memory)。(此处的主内存物理上仅是虚拟机内存的一部分,不像硬件中的主内存,是全部内存)
- 每条线程有自己的工作内存。(Working Memory,可与前面讲的处理器高速缓存类比)
- 线程的工作内存中保存了被该线程使用的变量的主内存副本。(仅复制这个对象的引用、对象中某个在线程访问到的字段,不会把整个对象复制一次)
- 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的数据。(volatile变量也是有工作内存的拷贝的,不过由于它特殊的操作顺序性规定,所以看起来和直接在主内存中读写访问一样而已)
- 不同的线程之间无法直接访问对方工作内存中的变量。
- 线程间变量值的传递均需要通过主内存来完成。
线程、主内存、工作内存三者的交互关系如图所示,注意与前图进行对比:
二、内存模型类比
这里所讲的主内存、工作内存与第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。
如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。
2.内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。
Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量,load、store、read和write操作在某些平台上允许有例外,后续专门讨论)
主内存与工作内存之间具体的交互包括以下8种操作,都是原子性的:
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中,以便随后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作。如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意到,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的。
总体执行属性:
read(读取)-》load(载入)–work-》use(使用)-engine–》assign(赋值)–work-》store(存储)-》write(写入)
除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。即read->load,或store->write必须成对出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
这8种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对volatile的一些特殊规定,就能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。
不过上述的规则太多太复杂,Java设计团队大概也意识到了这个问题,将Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变。
为了进一步方便理解,后续会介绍介绍上述定义的一个等效判断原则——先行发生原则,用来确定一个操作在并发环境下是否安全的。
3.对于volatile型变量的特殊规则
关键字volatile可以说是JVM提供的最轻量级的同步机制。
Java内存模型为volatile专门定义了一些特殊的访问规则。
一、通俗介绍
下面我们先用通俗易懂的语言来介绍下volatile变量的语义。
当一个变量被定义成volatile之后,它将具备两项特性:1.保证此变量对所有线程的可见性。2.禁止指令重排序优化。
1.保证此变量对所有线程的可见性。
这里的“可见性”是说,当一条线程修改了该变量的值,新值对于其他线程来说是立即可见的。普通变量无法做到这一点,因为普通变量的值在线程间传递需要通过主内存来完成。要先写回主内存,另外一个变量再从主内存读。
注意volatile只能保证可见性,不能保证“基于volatile变量的运算在并发下是线程安全的”。
- 保证可见性:从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,所以可以认为不存在一致性问题。
- 不保证线程操作是线程安全的:因为Java里面的运算操作符不是原子操作, 这就导致volatile变量的运算在并发下一样是不安全的。
以下是线程不安全的代码示例:
/*** volatile变量自增运算测试 ** @author zzm */
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++; // 这里不是原子性操作
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
// 等待所有累加线程都结束
while (Thread.activeCount() > 1) Thread.yield();
System.out.println(race);
}
}
// 这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,但最终结果并不是200000。
上述代码,问题就出在,race++
不是原子性操作。
从字节码层面上分析:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这些指令时,其他线程可能已经改变了race的值,此时操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存之中。
当然,即使编译出来只有一条字节码指令,也并不意味执行这条指令就是一个原子操作。
以下是字节码翻译:
public static void increase();
Code:
Stack=2, Locals=0, Args_size=0
0: getstatic #13; //Field race:I
3: iconst_1
4: iadd
5: putstatic #13; //Field race:I
8: return
LineNumberTable:
line 14: 0
line 15: 8
除非是以下两个场景,否则volatile变量只保证可见性。原子性需要加锁解决:
- 当前值不影响运算结果:运算结果不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 仅使用当前值进行不变约束:变量不需要与其他的状态变量共同参与不变约束。
例如如下代码就很合适使用volatile变量来控制并发:
/*** volatile变量自增运算测试 ** @author zzm */
public class VolatileTest {
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// 代码的业务逻辑
}
}
}
2.禁止指令重排序优化
普通的变量仅会保证能获取到正确的赋值结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
a.示例1
以下是代码示例:
Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;
// 假设以下代码在线程A中执行
// 模拟读取配置信息,当读取完成后
// 将initialized设置为true,通知其他线程配置可用
configOptions =new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized =true;
// 假设以下代码在线程B中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized){
sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
上述代码是说,A线程加载配置。完成后B线程运行。如果initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一条代码“initialized=true”被提前执行。(虽然是Java伪代码,不过所指的重排序优化是机器级的优化操作,提前执行是指这条语句对应的汇编代码被提前执行)
b.示例2
以下用单例模式再举例,下面是一个双检锁:
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
编译后,这段代码对instance变量赋值的部分如下:
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000
0x01a3de1a: shr $0x9,%esi ;...c1ee09
0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00
;*putstatic instance
; - Singleton::getInstance@24
可以看到,赋值后(0x01a3de1d)多执行了一个lock操作(0x01a3de24),这个操作的作用相当于一个内存屏障(指重排序时不能把后面的指令重排序到内存屏障之前的位置,注意不要与第3章中介绍的垃圾收集器用于捕获变量访问的内存屏障互相混淆)。
底层内存屏障原理:一个处理器没问题。但有多个处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性。上述指令addl $0x0,(%esp)
(把ESP寄存器的值加0)显然是一个空操作,这条指令关键就在于配合lock指令。这个空操作指令的作用是把本处理器的缓存写入了内存,从而也让别的内核中的缓存失效。相当于对缓存中的变量做了一次前面 介绍Java内存模式中所说的“store和write”操作,从而让前面volatile变量的修改对其他处理器立即可见。
指令重排序无法越过内存屏障原理:指令重排序是指处理器允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理,前提是不能影响运算结果。lock addl$0x0,(%esp)
指令把修改同步到内存时,也就是把运算结果同步回内存,也就意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
volatile与锁怎么选:由于JVM对锁实行的许多消除和优化,很难确切地说volatile就会比synchronized快上多少。volatile变量读操作的性能与普通变量几乎无差别,但写操作会慢一点,因为需要在本地代码中插入许多内存屏障指令。不过大多数场景下volatile的总开销仍然要比锁低,volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。
二、Java内存模型中对volatile变量定义的特殊规则的定义
以下是具体的特殊规则定义:
假定T表示
一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作
时需要满足如下规则:
线程T对变量V执行use动作的前一个动作必须是load;且线程T对变量V执行的后一个动作是use的时候,才能执行load动作。可以认为use是与load、read关联的,必须连续且一起出现。 即load->use必须成对出现,
这条规则要求在工作内存中,每次使用V前都必须从主内存刷新最新值,用于保证能看见其他线程对变量V所做的修改。
线程T对变量V执行store动作的前一个动作必须是assign;且线程T对变量V执行的后一个动作是store的时候,才能执行assign动作。可以认为assign是与store、write动作相关联的,必须连续且一起出现。即assign->store必须成对出现。
这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。
假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;与此类似,假定动作B是线程T对变量W 实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作。如果A先于B,那么P先于Q。即如果线程对变量A的use/assign先于对变量B的use/assign,那么线程对变量A的read/write先于对变量B的read/write。
这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。
总体执行顺序参考:
read(读取)-》load(载入)–work-》use(使用)-engine–》assign(赋值)–work-》store(存储)-》write(写入)
底层8大操作的逻辑,以及这里对volatile变量的规定,后续可以再捋一下。。
4.针对long和double型变量的特殊规则
Java内存模型规定,lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性。
long和double的非原子性协定:Java内存模型规定,对于没有被volatile修饰的64位的数据类型(long和double),允许JVM把读写操作划分为两次32位的操作来进行。
多线程同时对一个未被volatile修饰的64位的数据类型进行读写操作时,可能会读到“半个变量”的值。也就是说,既不是原值,也不是被其他线程修改后的值。
经过实际测试,安全性如下:
- 在目前主流平台下商用的64位JVM中不会有非原子性访问行为。
- 对于32位的JVM,long类型的数据操作有非原子性访问风险。例如32位x86下的HotSpot。
- double类型在32和64位JVM中都不会有非原子性访问的问题。因为一般现代中央处理器都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit,FPU),用来专门处理单、双精度的浮点数据。
JDK9开始,加了一个实验性参数-XX:+AlwaysAtomicAccesses
来强制JVM对所有的数据类型进行原子性访问。
在实际开发中,除非该数据有明确可知的线程竞争,否则一般不需要刻意把用到的long和double变量专门声明为volatile。
5.原子性、可见性与有序性
Java内存模型是围绕并发过程中的原子性、可见性和有序性这三个特征来建立的,我们逐一看下由哪些操作实现了这三个特性。
1.原子性(Atomicity)
- 基本数据类型的访问、读写:由read、load、assign、use、store和write这六个变量操作完成,它们的原子性都是由Java内存模型来直接保证的。我们大致可以认为基本数据的读写操作都是具备原子性的(除了long和double的非原子性协定,不过几乎不会发生)。
- 更大范围的原子性保证:Java内存模型提供了lock和 unlock操作。这两个操作JVM没有直接提供给用户,不过提供了更高层次的字节码指令*
monitorenter
和monitorexit
来隐式地使用,反应到更高层级就是Java代码的同步块synchronized
*关键字。所以在synchronized块之间的操作也具备原子性的。
2.可见性(Visibility)
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
Java内存模型如何实现可见性:是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种以主内存作为传递媒介的方式来实现可见性的。
Java有三个关键字可以实现可见性:volatile、synchronized和final。
- volatile的可见性原理:普通变量和volatile变量它们都是通过上述原理实现可见性。区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。普通变量则没有这个规则。
- synchronized同步块的可见性原理:依靠操作规则:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
- final的可见性原理:被final修饰的字段在构造器中一旦被初始化完成,且没有this引用逃逸,那么在其他线程中就能看见final字段的值。(this引用逃逸即构造器把“this”的引用传递出去,这是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象)
final初始化示例:
public static final int i;
public final int j;
static {
// 在静态代码块中初始化
i = 0;
// 省略后续动作
}
{
// 也可以选择在构造函数中初始化 j = 0;
// 省略后续动作
}
3.有序性(Ordering)
Java程序中天然的有序性可以总结为:
如果在本线程内观察,所有的操作都是有序的。即“线程内似表现为串行的语义”(Within-Thread As-If-Serial
Semantics)
如果在一个线程中观察另一个线程,所有的操作都是无序的。即“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java有两个关键字来保证线程间操作的有序性:volatile和synchronized。
- volatile原理:本身就包含了禁止指令重排序的语义。底层通过设置内存屏障及赋值使结果硬件层面存入内存来实现
- synchronized原理:是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得。该规则决定了持有同一个锁的两个同步块只能串行地进入。
通过以上三个特性的介绍,我们发现synchronized很“万能”。这也造成了一定程度上synchronized关键字的滥用。不过越“万能”的并发控制,通常会伴随着越大的性能影响,我们将在下一章讲解虚拟机锁优化时再细谈。
6.先行发生原则
一、什么是先行发生原则
为什么需要先行发生原则:Java内存模型对线程安全操作的定义很复杂。因此提供了简化版描述,即先行发生原则。
先行发生原则用来做什么:可以通过几条很简单的规则来判断数据是否存在竞争,线程是否安全。
“先行发生”原则是什么:先行发生是Java内存模型中定义的两项操作之间的偏序关系。其实是一个可见性关系。比如说:操作A先行发生于操作B,指的是B可以看到A操作的结果。“结果”包括:修改了内存中共享变量的值、发送了消息、调用了方法等。
以下代码示例:
// A操作:在线程A中执行
i = 1;
// B操作:在线程B中执行
j = i;
// C操作:在线程C中执行
i = 2;
//假设没有C线程。A操作先行发生与B操作:那么可以保证B执行后,j一定等于1。
//假设C在A和B之间。A操作先行发生与B操作:B执行后,j可能为1可能为2。因为B和C没有先行发生关系,线程B就存在读取到过期数据的风险,不具备多线程安全性
二、具体有哪些先行发生原则
JVM提供的默认先行发生关系,如果两个操作之间的关系不在此列,则JVM可以对它们随意地进行重排序。以下是8条先行发生原则:
- 顺序性规则(Program Order Rule):按控制流顺序,对于同一线程内,前面操作对后面操作可见。在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 传递性(Transitivity):A对B可见,B对C可见,则A对C可见。如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
- 锁规则(Monitor Lock Rule):对于同一个锁,先解锁对后加锁可见。一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
- volatile规则(Volatile Variable Rule):对于同一个volatile变量,写操作对后续读操作可见。对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。
- 线程启动规则(Thread Start Rule):线程的start()方法对后续所有操作可见。Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程中断规则(Thread Interruption Rule):*线程的interrupt()方法对中断检测可见(当然,指的是被中断线程的检测方法)。对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
- 线程终止规则(Thread Termination Rule):线程的所有操作对线程的终止检测可见。线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过*
Thread::join()
方法是否结束、Thread::isAlive()
的返回值*等手段检测线程是否已经终止执行。 - 对象终结规则(Finalizer Rule):对象的初始化完成对
finalize()
方法可见。一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
1个线程内的,2个关键字的,1个传递性,1个对象终结的特殊的finalize(),3个线程启动/中断/终止的。
三、先行发生原则应用举例
以下演示,如何使用这些规则来判线程是否安全。
示例1:
// 假设有两个线程。A先调用setValue(1),那么B调用同一个对象的getValue()返回什么?
private int value = 0;
pubilc void setValue(int value) { this.value = value; }
public int getValue() { return value; }
分析如下:由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线 程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规 则,所以最后一条传递性也无从谈起,因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的。
如何修复?
- 方案一:把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则。
- 方案二:把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则。
关于操作,“时间上的先发生”与“先行发生”没有任何因果关系:
通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”。
相反的,如果一个操作“先行发生”,也不能推导出这个操作必定是“时间上的先发生”。因为有指令重排序。示例如下:
// 以下操作在同一个线程中执行。CPU完全可以先处理int j = 2,因为对最终结果没有影响。
int i = 1;
int j = 2;
12.4 Java与线程
并发不一定要依赖多线程(如PHP中很常见的多进程并发)。
但是Java的并发是用多线程实现的。所以我们先来看下JVM是如何实现Java线程的。
1.线程的实现
线程是比进程更轻量级的调度执行单位。
为什么要有线程?线程的引入,可以把一个进程的资源分配和执行调度分开。这样各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。(我们知道分配进程是很重的操作)
目前线程是Java里面进行处理器资源调度的最基本单位,后续Loom项目计划引入纤程(Fiber)。
Java屏蔽了底层的硬件和OS差异。在Java中,每个已经调用过start()方法且还未结束的java.lang.Thread
类的实例就代表着一个线程。
但是我们发现,Thread类的所有关键方法都是Native
的。Native
就是说,是用平台相关的手段来实现的(或是没法用平台无关的手段实现,或是为了提升执行效率)。
既然Java的多线程实现是平台相关的,那么我们也就先把把Java的技术背景放下,以一个通用的应用程序的角度来看看线程是如何实现的。也因此,本小节的标题是“线程的实现”而不是“Java线程的实现”
实现线程主要有三种方式:
- 使用内核线程实现(1:1实现)
- 使用用户线程实现(1:N实现)
- 使用用户线程+轻量级进程混合实现(N:M实现)
下面来详细讲解。
补充参考:关于用户态和内核态:
- 用户态和内核态概念:对于Linux来说,用户态和内核态是两个权限级别。用户态切换到内核态,可以理解为在用户权限的操作用到了内核权限的操作。
- 切换本质是一种软件中断:用户态到内核态的切换是一种软件中断,当用户态的程序用到内核态操作时,需要进行软件中断,然后通过系统调用来进行内核态操作。所以用户态到内核态的切换是有中断的。系统调用是内核态操作的接口封装。
- 某些程序不用切换:某些程序可以一直运行在用户态而无需切换,例如只用到了CPU资源的算法。
- 中断是需要代价的:因为中断需要将现在用户态的cpu状态保存下来,然后切换到内核态执行,得到结果后又要切换回用户态。所以中断是重量级操作。
- 进程与线程的区别联系:线程是最小的资源调度单位,进程是最小的资源分配单位。线程的调度是在同一个进程内的,都是在用户态。进程间的调度需要由用户态到内核态切换。当然,对linux底层调度器来说,无论是线程还是进程,调度器只认task_struct对象,所以进程调度和线程调度是一回事。区别是上下文切换保存的内容不同,进程的话会保存打开的文件描述符和信号处理程序,地址空间的位置之类的。线程则保存程序计数器的值和寄存器和栈的状态就可以了。
- Linux里线程的本质是轻量级进程:在linux里面fork创建进程和pthread_create创建线程都是调用clone()系统调用,但他们的权限不一样,可以理解成线程是一种受限的进程(轻量级的进程)。
1.内核线程实现
一、实现方式
内核线程实现的方式也被称为1:1实现。用户线程( 轻量级进程 ): 内核线程 = 1 : 1
。
内核线程(Kernel-Level Thread,KLT):就是直接由OS内核(Kernel)支持的线程,这种线程由内核来完成线程切换。内核通过操纵调度器(Scheduler)来调度线程,并将线程的任务映射到各处理器。支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。
轻量级进程(Light Weight Process,LWP):也就是我们通常意义上所讲的线程。线程是内核线程的一种高级接口。在Linux里进程和线程的区别参考前文。程序一般不直接使用内核线程,而是用线程来调用。
每1个轻量级进程都是由1个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种模型称为1对1模型,如下图所示:
二、优缺点
优点:
- 某个线程阻塞不会影响到整个进程:由于内核线程的1对1的支持,每个轻量级进程都是一个独立的调度单元。当其中某一个轻量级进程在系统调用中被阻塞时,不会影响整个进程继续工作。
缺点:
- 线程操作代价高:由于是基于内核线程实现的,所以各种线程操作(如创建、析构及同步),都需要进行系统调用。而系统调用需要在用户态和内核态中来回切换,代价相对较高。
- 需要消耗内核资源:由于每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
2.用户线程实现
一、实现方式
用户线程实现的方式被称为1:N实现。这里的用户线程说的是狭义的用户线程。
什么是用户线程(User Thread,UT):
- 广义的定义:广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程的一种。那么此时轻量级进程(内核线程的高级接口)也属于用户线程。不过由于轻量级进程的实现是基于内核的,很多操作都要进行系统调用,因此效率会有影响,并不具备通常意义上的用户线程的优点。
- 狭义的定义:完全建立在用户空间线程库上的线程。此时系统内核不能感知到用户线程的存在,用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核参与。
用户线程实现方式如下图:
进程直接调用CPU,然后进程自己管理线程。
二、优缺点
优点:
- 性能好消耗少:由于无需系统内核线程支持,因此无需切换到内核态,所以操作非常快速且消耗很低,还可以支持规模更大的线程数量。
部分高性能数据库中的多线程就是由用户线程实现的。
缺点:
- 实现困难甚至难以实现:也是因为没有系统内核线程的支持,因此所有的线程操作都需要由用户程序自己去处理,用户必须考虑到线程的创建、销毁、切换和调度等。而且由于OS只把处理器资源分配到进程,对于“如何将线程映射到其他处理器上”这类问题将会非常困难甚至无法实现。
除了有明确的需求,如以前在不支持多线程的OS中的多线程程序、需要支持大规模线程数量的应用等,一般的应用程序都不倾向于使用用户线程。
Java和Ruby之前用过用户线程,不过后来放弃了。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等。
3.混合实现
一、实现方式
混合实现:除了上述单独的实现,还可以将内核线程与用户线程一起使用来实现,被称为N:M实现。
在混合实现下,既存在用户线程,也存在轻量级进程:
- 用户线程还是完全建立在用户空间:因此用户线程的创建、切换、析构等操作依然廉价,且可支持大规模并发。
- 轻量级进程则作为用户线程和内核线程之间的桥梁:此举是为了使用内核提供的线程调度功能及处理器映射。且用户线程的系统调用需要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。
在此模式下,用户线程与轻量级进程的数量比是不定的,是N:M的关系。如下图所示:
许多UNIX系列的操作系统,如Solaris、HP-UX等都提供了M:N的线程模型实现。
4.Java线程的实现
一、实现方式
JVM规范并没有规定Java线程的实现方式。因此取决于具体JVM实现。
早期的Classic虚拟机上(JDK 1.2以前),是基于一种被称为“绿色线程”(Green Threads)的用户线程实现。
从JDK 1.3起,主流的JVM都基于OS原生线程模型来实现,即采用内核线程实现,即1:1的线程模型。
二、Hotspot实现方式
Hotspot也是采用内核线程实现,每一个Java线程都是直接映射到一个OS的原生线程来实现。
Hotspot不干涉线程调度(仅仅可以给建议),线程的调度都是由操作系统全权决定。
三、其他实现方式
一些“非主流”的JVM实现不一样。两个典型例子:
1.Java ME的CLDC HotSpot Implementation(CLDC-HI,介绍见第1章)同时支持两种线程模型:
- 默认使用1:N由用户线程实现的线程模型,所有Java线程都映射到一个内核线程上。
- 也支持另一种特殊的混合模型:Java线程仍全部映射到一个内核线程上,但当Java线程要执行一个阻塞调用时,会为该调用单独开一个内核线程,并执行其他Java线程,待阻塞调用完成之后,再回来重新调度之前的Java线程。
2.在Solaris平台的HotSpot虚拟机:同时支持“内核线程实现”和“用户线程实现”:
- 由于操作系统的线程特性本来就可以同时支持1:1(通过Bound Threads或Alternate Libthread实现)及N:M(通过LWP/Thread Based Synchronization 实现)的线程模型
OS支持的线程模型,在很大程度上会影响JVM的线程如何映射,因此《JVM规范》未限定Java线程需要使用哪种线程模型实现。
线程模型只影响并发规模和操作成本,对程序的编码和运行是透明的。
2.Java线程调度
一、两种线程调度方式
线程调度就是系统分配CPU使用权。有两种方式:协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。
1.协同式线程调度
线程的执行时间由自己控制,执行完成之后主动通知系统切换至另外一线程。应用举例:Lua语言中的“协同例程”。
优点:
- 实现简单。
- 由于切换操作对线程可知,一般没有线程同步问题。因为线程要把自己的事情干完才会主动通知系统切换。
缺点:
- 如果某个线程一直阻塞,那么整个程序都会被阻塞。因为线程执行时间不可控。例如,Windows 3.x系统,只要有一个进程坚持不让出CPU执行时间,就可能会导致整个系统崩溃。
2.抢占式调度
线程的执行时间由系统分配,自己无法决定是否进行线程切换。例如,Thread::yield()
方法可以主动让出执行时间,但是没法主动获取执行时间。
优点:
- 不会有因一个线程阻塞而导致整个进程乃至整个系统阻塞的问题。因为线程的执行时间是由系统控制的。所以与Windows 3.x相对,Windows 9x/NT使用抢占式来实现多进程。当一个进程出问题,我们可以在任务管理器直接把它干掉。
代价:
- 实现比较复杂
二、线程优先级
线程优先级:Java使用了抢占式调度。虽然我们没法控制线程调度,但是我们仍可“建议”OS给某些线程多分配一点执行时间。也就是设置线程优先级。
Java共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。两个线程处于Ready时,优先级高的更容易被系统选上。
Java设置的线程优先级是不稳定的,原因有二:
- OS的优先级数量与Java优先级对应不上:例如Solaris中线程有2^31个优先级,可以和Java对应上。但是Windows有7个优先级,对应到Java的10个优先级,就会让Java的几个优先级变得等效。在Windows下设置线程优先级为1和2、3和4、6和7、8和9的效果是完全相同的。
- 系统可能会自行改变优先级:例如Windows的“优先级推进器”功能,在某个线程执行得特别频繁时,可能会忽略线程优先级,给它分配更多的执行时间,从而减少因为线程频繁切换带来的性能损耗。
综上,当两个线程都是Ready状态时,我们也没法根据线程优先级来很准确的判断哪个会被执行。
3.状态转换
Java定义了6种线程状态。线程在同一个时间点只会有一个状态。各个状态通过特定方法来转换。
以下是6种状态:runnable waiting timedwaiting stop new block
- 新建(New):已创建但尚未启动。(此时OS还未感知到?)
- 运行(Runnable):对应OS的Ready和Running。线程有可能在执行,也有可能在等待OS为它分配执行时间。Java把OS的两个状态合到了一起。
- 无限期等待(Waiting):此时线程不会分配到CPU执行时间,需要等其他线程显示唤醒它。以下方法会让线程进入该状态:
- 没有设置Timeout参数的
object::wait()
方法; - 没有设置Timeout参数的
thread::join()
方法; LockSupport::park()
方法。
- 没有设置Timeout参数的
- 限期等待(Timed Waiting):此时线程不会分配到CPU执行时间,不过在一定时间之后就会被系统自动唤醒。以下方法会让线程进入该状态:
- 设置了Timeout参数的
object::wait()
方法; - 设置了Timeout参数的
thread::join()
方法; LockSupport::parkNanos()
方法;LockSupport::parkUntil()
方法。Thread::sleep()
方法;
- 设置了Timeout参数的
- 阻塞(Blocked):线程被阻塞。在程序等待进入同步区域时,线程将进入阻塞状态。“阻塞状态”与“等待状态”的区别:“阻塞状态”是在等待着获取一个排它锁,该锁由另外一个线程释放。而“等待状态”则是在等待唤醒动作,或是等待一段时间。从OS的角度来说,都是休眠状态。
- 结束(Terminated):线程已经结束执行。
上述6种状态在遇到特定事件的时就会互相转换,转换关系如图:
12.5 Java与协程
在Java时代早期,Java屏蔽了底层OS和硬件的差异,提供了统一的线程接口,这曾经是一大优势。
但是在当今时代,某些使用场景下,Java已经显示出疲态。
1.内核线程的局限
当今的应用,比起从前,最重要的变化是,拥有更庞大的请求量和更复杂的业务。
一方面业务量的增长带来的更大量的请求,另一方面,复杂业务也拆分细化为更多的微服务分布到不同的机器上。这样带来的最显著的变化就是,服务的数量增加了,且服务的响应时间变短了。
这就和现在主流JVM的1:1内核线程模型产生了矛盾。这种模式,线程是直接映射到OS上的,天然的缺陷就是切换、调度成本高。
线程切换的占比时间变大了:在以前,处理一个单体请求时可以有比较长的时间,所以这点线程切换时间无伤大雅。但是现在的趋势是服务数量变多而服务处理时间变短,就导致了有时候用户线程切换的开销甚至可能会接近于计算本身的开销。
传统的Web服务器,线程池一般是几十个到两百,当我们把百万数量级的请求往线程池里面灌,带来的切换损耗是相当可观的。
2.协程的复苏
协调式切换-抢占式切换,内核线程-用户线程。
上节我们说了,内核线程的实现比起用户线程的实现,缺点是切换成本高。那么为什么会切换成本高?
一、为什么内核线程调度切换起来成本会更高
内核线程的调度成本是来自用户态与核心态间的状态转换。这种转换的开销主要又是来自于响应中断、保护和恢复执行现场的成本。
如下发生一次线程切换:
线程A -> 系统中断 -> 线程B
程序运行时,肯定有上下文。切换时需要保存上下文:
- 以程序员的角度来看:是方法调用过程中的各种局部变量与资源。
- 以线程的角度来看:是方法的调用栈中存储的各类信息。
- 以OS和硬件的角度来看:是存储在内存、缓存和寄存器中的一个个具体数值。
当发生线程切换时,OS先要把线程A的上下文数据保存好,然后再把寄存器、内存分页等恢复到线程B挂起时的状态,所以肯定是重量级操作。
二、使用用户线程的意义
栈纠缠运行模式(StackTwine):是用户线程的前身。一些古老的OS是单人单工的,那么如何在上面模拟多线程、自动保护恢复线程的工作模式呢?大致原理是:在内存里划出一片额外空间来模拟调用栈,只要其他“线程”中方法压栈、退栈时遵守规则,不破坏这片空间即可。这样多段代码执行时就会像相互缠绕着一样,所以叫栈纠缠,非常形象。
后来OS提供了所线程的支持,就不用自己模拟多线程了。栈纠缠等靠自己模拟的方式,逐渐演化为用户线程。
最初多数用户线程是用“协调式线程调度”设计的,所以用户线程也叫“协程”。但是也并不是说,它们之间就一定以协同调度的方式工作。
- 有栈协程:一般意义上的协程,会做完整的调用栈的保护、恢复工作。
- 无栈协程:本质上是一种有限状态机,状态保存在闭包里,自然比有栈协程恢复调用栈要轻量得多,但功能也相对更有限。它的典型应用,是各种语言中的await、async、yield这类关键字。
三、协程的优势与劣势
优势就是轻量。无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。
量化比较:
- 线程的内存是1MB+16KB:如果不显式设置-Xss或-XX:ThreadStackSize,则在64位Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗16KB内存。
- 协程一般就几KB:一个协程的栈通常在几百个字节到几KB之间。
这也就导致了,一般的服务器中,JVM线程池到200就差不多了,而协程却可以达到几十万。
劣势就是:
- 需要在应用层面实现的内容(调用栈、调度器这些)特别多。
- 在很多语言和框架中会被设计成协同式调度:这样在语言运行平台或者框架上的调度器就可以做得非常简单。劣势则前文也提过了。不过也不是所有语言中都会被设计成协同式调度。
在Java中的劣势:
除了上述劣势,在HotSpot中也需要考虑:
HotSpot中,Java调用栈跟本地调用栈是做在一起的。如果在协程中调用了本地方法,需要考虑还能否正常切换协程而不影响整个线程?
如果协程中遇传统的线程同步措施怎么处理?譬如Kotlin提供的协程实现,一旦遭遇synchronize关键字,那挂起
来的仍将是整个线程。
3.Java的解决方案
Java的实现方式是纤程(Fiber)。
纤程(Fiber):有栈协程的一种特例实现。该词最早由微软提出。OpenJDK在2018年创建了Loom项目,用来解决目前Java的内核线程切换成本过高的问题。是与现在线程模型平行的新并发编程机制,目前还未上线。
Loom项目的目的是重新提供对用户线程的支持,与现有并发编程模型共存。新模型有意地保持了与目前线程模型相似的API设计,它们甚至可以拥有一个共同的基类。
性能测试:Loom团队在JVMLS 2018大会上,对Jetty基于纤程改造,在5000QPS的压力下,对比“容量为400的线程池的传统模式”和“每个请求配以一个纤程的新并发处理模式”,前者请求响应延迟在10000至20000毫秒之间,而后者的延迟普遍在200毫秒以下。
新并发模型结构:在新并发模型下,一段使用纤程并发的代码会被分为两部分——执行过程(Continuation)和调度器(Scheduler):
- 执行过程:主要用于维护执行现场,保护、恢复上下文状态。
- 调度器:负责编排所有要执行的代码的顺序。Loom中默认的调度器就是原来已存在的用于任务分解的Fork/Join池(JDK 7中加入的ForkJoinPool)。
Loom项目目前仍然在进行当中,还没有明确的发布日期,可以在项目中使用Quasar协程库,它是独立于JVM实现的。
Quasar的实现原理:原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。不过这种不依赖JVM的现场保护虽然能工作,但很影响性能,对即时编译器的干扰也很大,且必须要求用户手动标注每一个函数是否会在协程上下文被调用,这些都是未来Loom项目要解决的问题。
和JUC类似,Loom的领导者者RonPressler就是Quasar的作者。以后可能直接将Quasar引入JDK,加上JVM优化即可。
12.6 本章小结
本章中,我们介绍了以下内容:
- 虚拟机Java内存模型的结构及操作
- 原子性、可见性、有序性在Java内存模型中的体现
- 先行发生原则的规则及使用
- 线程在Java语言之中是如何实现的,
- 新并发模型的工作原理,它是Java未来多线程发展的方向
关于“高效并发”,本章主要介绍了虚拟机如何实现“并发”。在下一章中,我们的主要介绍虚拟机如何实现“高效”,以及虚拟机如何优化我们编写的并发代码。
提问
为什么要用多线程?
什么是缓存一致性?什么是乱序执行优化?CPU能保证一致性和顺序性吗?只能按协议读写,按结果保证。
说说Java的内存模型?画图表述线程、主内存、工作内存怎么交互的?
大概描述性Java内存模型中主内存与工作内存之间具体的交互协议?包括8种操作,及其对操作先后性的要求,及其对操作需要同时满足的要求,及其对volatile的要求。
volatile关键字的含义?基于volatile变量的操作是线程安全的吗?具体怎么生效?底层的操作规定怎么保证volatile的语义?可见性、不可指令重排。不安全,运算符不是原子操作。底层是内存屏障,硬件层面把当前cpu最新缓存写入内存,从而让其他cpu的缓存失效,然后同时lock锁定禁止重排序。再底层是几个对操作的规则,比较复杂。
对long和double型变量的特殊规则怎么处理?分别在32/64位有风险吗?实际编程是是否都要加上volatile?
原子性、可见性、有序性分别如何实现?哪些关键字可以实现?底层是哪些操作以及哪些规则来支持?
先行发生是什么意思?先行发生原则用来做什么?
有哪些先行发生原则?“时间上的先发生”与“先行发生”有关系吗?
并发和多线程什么关系?
什么是用户态什么是内核态?进程和线程有什么区别?
线程的实现方式有哪些?各有什么优缺点?
线程调度有哪些方式?各有什么优缺点?线程优先级是稳定的吗?
Java的线程有几种状态?各个状态如何转换?
哪些动作会到时有限等待?哪些动作会导致无限等待?等待和阻塞有什么区别?
为什么要用到协程?
内核线程为什么切换成本高?
协程的优势和劣势?一个2G内存的服务器,大概最多可以开多少个线程?多少个协程?
内核式切换、抢占式切换有什么区别?内核线程、用户线程、协程、栈纠缠、有栈线程、无栈线程、纤程有什么区别?
纤程是什么?性能进步有多大?Java纤程新并发模型结构?Quasar的实现原理?
第13章 线程安全与锁优化
13.1 概述
两种编程思想:
- 面向过程的编程思想:程序编写以算法为核心,程序员把数据和过程分别作为独立的部分来考虑,直接站在计算机的角度去抽象问题和解决问题。
- 面向对象的编程思想:它把数据和行为都看作对象的一部分,站在现实世界的角度去抽象和解决问题。
面向对象极大地提升了软件的开发效率和可达到的规模。
不过现实世界与计算机世界是存在差异的。在计算机世界,对象在工作时,会被不停地中断和切换,它的属性(数据)也可能会在中断期间被修改和变脏。所以我们必须考虑如何保证程序能正确无误的运行,然后再考虑代码组织的问题。
对于本章的主题“高效并发”来说,就是首先需要保证并发的正确性,然后在此基础上来实现高效。我们就先从如何保证并发的正确性及如何实现线程安全说起。
13.2 线程安全
什么是线程安全:《Java并发编程实战(Java Concurrency In Practice)》的作者Brian Goetz做的“线程安全”定义比较恰当: 当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”
简单来说,就是要代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。不过我们一般会把“调用这个对象的行为”限定为“单次调用”,为什么要弱化,后续讨论。
1.Java语言中的线程安全
我们来讨论,在Java中,线程安全具体如何体现?当然,我们讨论线程安全的前提是多个线程之间存在共享数据访问。如果没有多线程、或者没有共享数据访问,那么根本不会有线程安全问题。
这里为了更深入了理解线程安全,我们按照线程安全的“安全程度”由强至弱来,把将Java语言中各种操作共享的数据分为了以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
这种划分方法也是由Brian Goetz在IBM developer上面的一篇论文提出来的。
1.不可变
在JDK5之后,不可变对象一定是线程安全的。
如何保证对象是不可变:
- 共享数据是基本数据类型:加final关键字即可。
- 共享数据是一个对象:需要对象保证它自己的行为不会对自己的状态产生影响。因为Java里没有值类型,只能这样。
如何保证行为不影响自己状态:
- 把对象里面带有状态的变量都声明为final即可,这样在构造函数结束之后,它就是不可变的。
- 其他方式:如下例
Java类库API中不可变的类型:
- String类型:典型的不可变对象,用户调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
- 枚举类型
- java.lang.Number的部分子类:如Long和Double等数值包装类型、BigInteger和BigDecimal等大数据类型。
但同为Number子类型的原子类AtomicInteger和AtomicLong则是可变的。(为什么?看源码)
不可变对象示例:Integer类
// 直接把所有字段声明为final
/*** The value of the <code>Integer</code>. * @serial */
private final int value;
/**
* Constructs a newly allocated <code>Integer</code> object that
* represents the specified <code>int</code> value.
*
* @param value the value to be represented by the
* <code>Integer</code> object. */
public Integer(int value) { this.value = value; }
2.绝对线程安全
绝对的线程安全:能够完全满足Brian Goetz给出的线程安全的定义,“不管运行时环境如何,调用者都不需要任何额外的同步措施”。
在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全的。因为绝对的线程安全成本很高。
绝对线程安全主要的难点就在于,需要保证在某些多次调用的情况下也是线程安全的。所以可能需要维护快照等,成本会很大。
以下举例:
// 对Vector线程安全的测试
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
System.out.println((vector.get(i)));
}
}
});
removeThread.start();
printThread.start();
//不要同时产生过多的线程,否则会导致操作系统假死
while (Thread.activeCount() > 20) ;
}
}
// 运行结果:
// Exception in thread "Thread-132" java.lang.ArrayIndexOutOfBoundsException:
// Array index out of range: 17
// at java.util.Vector.remove(Vector.java:777)
// at org.fenixsoft.mulithread.VectorTest$1.run(VectorTest.java:21)
// at java.lang.Thread.run(Thread.java:662)
可以看到,虽然Vector的get()、remove()和size()方法都是同步的,但多线程下如果不在的方法调用端做额外的同步措施,使用这段代码仍然是不安全的。
要线程安全,必须改为下面这样:
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
System.out.println((vector.get(i)));
}
}
}
});
假如Vector一定要做到绝对的线程安全,那就必须在它内部维护一组一致性的快照访问才行,每次对其中元素进行改动都要产生新的快照,这样要付出的时间和空间成本都是非常大的。
3.相对线程安全
相对线程安全:通常意义上的线程安全。只需要保证对这个对象单次的操作是线程安全的即可。对于一些特定顺序的连续调用,可能需要在调用端用锁来保证调用的正确性。例如上面的例子。
在Java中,大部分声称线程安全的类都属于这种类型:如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。(HashMap是绝对线程安全的吗?)
4.线程兼容
线程兼容:通常意义上的线程不安全。对象本身不是线程安全的,但可以通过加锁来保证对象并发时线程安全。
Java类库API中,大部分的类都是线程兼容的,如与前面Vector和HashTable相对应的集合类ArrayList和HashMap等。
5.线程对立
线程对立:不管调用端是否采取了同步措施,都无法保证线程安全。
由于Java语言天生就支持多线程的特性,这种排斥多线程的代码很少出现。
举例:是*Thread类的suspend()和resume()*方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险。正因如此,suspend()和resume()方法都已经被声明废弃了。
常见的线程对立的操作还有*System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()*等。
2.线程安全的实现方法
该如何实现线程安全?这与与代码编写有很大的关系,但JVM提供的同步和锁机制也起到了至关重要的作用。本节两方面都会涉及,相对而言更偏重后者一些。
本节讲三种方法:互斥同步 、非阻塞同步(CAS)、无同步方案。
1.互斥同步
主要是两种方案:synchronized、JUC。
同步:是指多线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用(或是一些, 当使用信号量时)。
互斥:是实现同步的一种手段,常见的实现方式有临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)。
一、synchronized
synchronized关键字:Java里最基本的互斥同步手段。原理:synchronized关键字经过Javac编译后,会在同步块前后分别形成monitorenter
和monitorexit
这两个字节码指令。两条指令都需要一个reference类型的参数来指明要锁定和解锁的对象。若synchronized指定了,则用它指定的实例。如果没有指定,则如果是在实例方法中,则使用所在的对象实例。如果是在static方法中,则使用Class对象实例。
锁定执行逻辑:根据《JVM规范》要求,1.在执行monitorenter指令时,先要去尝试获取对象的锁。若该对象未被锁定,或当前线程已持有该对象的锁,则锁的计数器的值增加一。2.在执行monitorexit指令时,锁计数器的值减一。3.计数器的值为零时,锁被释放。4.若获取对象锁失败,则当前线程被阻塞等待,直至请求的锁被释放。
从上述的规定,我们容易得到两个关于synchronized的很重要的推论:
- synchronized锁是可重入的:被synchronized修饰的同步块对同一个线程来说是可重入的,所以同一线程反复进入同步块不会自己把自己锁死。
- 获取不到锁时会被阻塞:这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
持有锁是重量级操作:因为Java线程是内核线程的映射,会有用户态到核心态的转换。如果锁定代码的逻辑简单,甚至会出现状态转换的耗时比用户代码本身执行的时间还要长的情况。不过JVM本身也做了一些优化,如在通知操作系统阻塞线程之前加入一段自旋等待过程,以避免频繁地切入核心态中。其他措施后续介绍。
二、JUC
JDK6加入了JUC,互斥同步可以在在类库层面去实现了。也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。
重入锁(ReentrantLock):Lock接口最常见的实现,与synchronized一样是可重入的。(另一种实现是重入读写锁ReentrantReadWriteLock,这里不再介绍)
ReentrantLock也与synchronized相比,主要加入了三项高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件:
- 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待。
- 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的。不过公平锁会导致ReentrantLock的性能急剧下降。
- 锁可以绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象,多次调用时直接newCondition()方法即可。而对于synchronized来说,锁对象的wait()跟它的notify()或者notifyAll()方法配合只能实现一个隐含的条件,如果要和多个的条件关联,则只能用多把锁。
在JDK6优化以后,synchronized的性能和JUC已经不相上下。
不过个人推荐优先考虑使用synchronized,理由如下:
- synchronized语法简单清晰:J.U.C中的Lock接口用起来更复杂一些。基本的同步功能,用synchronized就够了。
- synchronized会自动释放锁:避免出现代码未考虑周全,导致同步的代码块中抛出异常,永远不会释放锁的Bug。
- synchronized更便于在JVM层面优化:JVM更容易针对synchronized进行优化,因为JVM可以在线程和对象的元数据中记录synchronized中锁的相关信息。而使用J.U.C中的Lock的话,JVM是很难得知具体哪些锁对象是由特定线程锁持有的。
2.非阻塞同步
一、非阻塞同步
互斥同步属于一种悲观的并发策略:互斥同步时,线程阻塞和唤醒带来的性能开销太大。而且每次都悲观的假设会有竞争,所以无论共享的数据是否真的会出现竞争,它都会进行加锁。涉及到的耗时操作有:用户态到核心态转换、维护锁计数器、检查是否有被阻塞的线程需要被唤醒等。
非阻塞同步是一种乐观的并发策略:不管风险,先进行操作。1.若共享数据没有被争用,则操作成功。2.若共享数据被争用,则进行其他的补偿措施,最常用的补偿措施是不断重试。这种策略无需将线程阻塞挂起,所以被称为非阻塞同步。这种方式也叫无锁编程。
乐观操作的前提:必须要求操作和冲突检测这两个步骤具备原子性。这个原子性由硬件来实现。因为如果还是用互斥同步来保证的话,就失去意义了,就不是非阻塞。
硬件保证从语义上需要多次进行的操作,仅通过一条处理器指令即可完成。这些指令有:
- 测试并设置(Test-and-Set);
- 获取并增加(Fetch-and-Increment);
- 交换(Swap);
- 比较并交换(Compare-and-Swap,下文称CAS)
- 加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。
前三条是20世纪就普遍存在的处理器指令,后两条是现代处理器新增的。
后两条指令的目的和功能类似。
- 在IA64、x86指令集中,有用cmpxchg指令完成的CAS功能。
- 在SPARC-TSO中,也有用casa指令实现。
- 在ARM和PowerPC架构下,则需要使用一对ldrex/strex指令来完成LL/SC的功能。
二、CAS指令
CAS指令需要有三个操作数:分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。
CAS指令的执行过程:以下过程,由硬件保证是原子的:1.V符合(内存地址的值)A时,用B更新V值,否则不更新。2.不管符合不符合,都返回V的旧值。
CAS指令在Java内的支持:
- 在JDK 5之后,Java类库开始使用CAS,该操作由
sun.misc.Unsafe
类里面的compareAndSwapInt()
和compareAndSwapLong()
等几个方法包装提供。 - 上述方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了,
- 在JDK 9之前只有Java类库可以使用CAS:因为Unsafe类在设计上就不是提供给用户程序调用的类(Unsafe::getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它)。如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。用户程序若要使用,就只能采用反射手段突破Unsafe的访问限制。或是通过Java的API间接使用。
- JDK9之后,Java类库在VarHandle类里开放了面向用户程序使用的CAS操作。
以下是通过CAS操作避免阻塞同步的代码示例:
// Atomic的原子自增运算
import java.util.concurrent.atomic.AtomicInteger;
/*** Atomic变量自增运算测试 ** @author zzm */
public class AtomicTest {
public static AtomicInteger race = new AtomicInteger(0);
public static void increase() {
race.incrementAndGet();
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) Thread.yield();
System.out.println(race);
}
}
// 结果正确:
200000
上述代码就是使用AtomicInteger代替了int,从而避免使用以前的加锁方法。
这都要归功于incrementAndGet()方法的原子性。它的实现其实非常简单:
/*** Atomic变量自增运算测试 ** @author zzm */
public class AtomicTest {
/*** Atomically increment by one the current value. * @return the updated value */
public final int incrementAndGet() {
for (; ; ) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next)) return next;
}
}
}
可以看到,incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大一的新值赋值给自己,直到成功。
三、ABA问题
J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference
,它可以通过控制变量值的版本来保证CAS的正确性。
不过目前这个类比较鸡肋,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效。
3.无同步方案
要保证线程安全,不一定非要进行阻塞或非阻塞同步。另外一个选择是无同步方案。
无同步方案:同步是为了有共享数据争用时保障正确性。那如果能让一个方法本来就不涉及共享数据的话,这个方法当然就是线程安全的了。两种方式:
- 函数式方法。
- 线程本地存储。
一、函数式方法
可重入代码(Reentrant Code):又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行其他代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。
可重入代码是线程安全代码的一个真子集:在不涉及信号量等因素时,所有可重入的代码都是线程安全的,但并非所有的线程安全的代码都是可重入的。
可重入代码的特征:不依赖全局变量、存储在堆上的数据和公用的系统资源, 用到的状态量都由参数中传入,不调用非可重入的方法等。
如何判断代码是否是可重入代码:如果一个方法的返回结果是可以预测的,相同的输入数据,必能返回相同的结果,那它就是可重入的,当然也就是线程安全的。(函数式?)
二、线程本地存储
线程本地存储(Thread Local Storage):如果所有共享数据的代码都能保证在同一个线程中执行,那就没有数据争用问题,也就线程安全。例如:1.大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完。2.是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式。
Java中,如果一个变量要被多线程访问,可以使用volatile关键字。而如果一个变量只要被某个线程独享,我们可以通过java.lang.ThreadLocal类来实现线程本地存储。
每一个线程的thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode
为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个 ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
13.3 锁优化
高效并发是从JDK 5升级到JDK 6后一项重要的改进项。Hotspot加入了大量的锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等。
这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。
1.自旋锁与自适应自旋
为什么要用自旋锁?1.我们知道,互斥同步时,阻塞是最影响性能的,因为挂起和恢复线程的操作都需要转到内核态,是重量级操作。2.其实在很多应用上,共享数据只要锁很短的一段时间就可以了,我们为了这一小段时间去挂起和恢复线程其实是不值得的。3.那么,有没有办法,让后来的线程先“稍等一会”呢,这样就不用去挂起线程。答案就是自旋锁。
自旋锁如何实现:在线程申请的锁被占用时,先让该线程执行一个忙循环(自旋)“稍等一会儿”,看下锁是不是会很快被释放。
JDK 1.4.2中就已引入自旋锁,不过默认关闭。JDK6后默认开启。
自旋锁的适用场景:自选等待不能代替阻塞。因为自旋是要消耗CPU运行时间的。因此,自旋锁适用于锁被占用的时间很短的情况。而如果锁被占用的时间很长,那么自旋锁就是白白消耗处理器资源。
自旋次数如下:
- 通过参数规定自旋次数:可以通过参数-XX:PreBlockSpin规定自旋次数。
- 自适应自旋锁:JDK6引入的优化。自旋的次数不是固定的,由前一次在同一个锁上的自旋时间,以及锁的拥有者的状态来决定。如果在该锁是刚刚通过自旋等待获得的,且获得该锁的线程正在运行,那么说明这次自旋也很可能成功,就允许等待相对更长的时间,比如持续100次忙循环。如果某个锁,自旋很少成功获得过锁,那么有可能直接省略掉自旋过程。
有了自适应自旋,随着程序运行时间的增长,以及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,JVM就会变得越来越“聪明”。
2.锁消除
什么是锁消除?指JVM即时编译器在运行时,对一些代码要求同步,但是检测到不可能存在共享数据竞争的锁进行消除。
为什么会需要锁消除?因为一般程序员不会蠢到到处乱加同步。其实答案是,有许多同步措施是编译器加入的。所以锁消除用来消除Javac编译器加入的无用锁。
如何实现锁消除?锁消除的主要判定依据来源于逃逸分析的数据支持(第11章已经讲解过逃逸分析技术),如果判断到一段代码中,在堆上的所有数据都不会逃逸出去而被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
锁消除举例:
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
在JDK 5之前,字符串加法会转化为StringBuffer对象的连续append()操作,在JDK 5及以后,会转化为StringBuilder对象的连续append()操作:
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
我们知道每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。
JVM观察到sb对象的动态作用域被限制在concatString()方法内部的,所以可以直接安全地消除掉锁。
在解释执行时这里仍然会加锁,但C2即时编译后,就会忽略所有同步措施了。
3.锁粗化
一般情况下,我们肯定是想把锁的范围尽量控制得小,这样是为了减少同步操作的数量,目的可以更快的让出锁。
但是假如一系列连续的操作,都是对同一个对象反复加解锁,甚至是在同一个循环体中,这个时候即使没有线程竞争,也会造成性能损耗。所以我们进行锁粗化。
上述代码的append操作就是这种情况。我们只需把所有append操作整合到一个锁里就足够了。
4.轻量级锁
轻量级锁是JDK6新加入的。使用OS互斥量来实现的传统锁即是重量级锁。轻量级锁不依靠OS互斥量来实现。
轻量级锁不是用来代替重量级锁的。它是用来在没有多线程竞争的前提下,减少传统的重量级锁使用OS互斥量产生的消耗。
简单点来说,就是把markword复制到栈帧,然后用CAS将对象头指向栈帧即可。之后同一个线程调用时,都会判断到是自己的栈帧,直接进入。假如有其他线程申请锁,就马上升级为重量锁,把对象头指向重量级锁的互斥量。之后解锁的时候,轻量级锁是直接把原来的markword写回去,CAS写失败时,说明是重量级锁的地址,此时还需要再唤醒重量级锁的线程就是了(原来markword可以存到重量级锁的ObjectMonitor对象里)。
要了解轻量级锁,以及后面的偏向锁,我们需要先了解对象头。
一、对象头布局
我们知道,Hotspot的对象头分为两部分:
- 第一部分,MarkWord:用于存储对象自身的运行时数据,如如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据在32/64位JVM中分别占32/64比特。
- 第二部分:存储指向方法区对象类型数据的指针。如果是数组对象的话,还会有一个额外的部分存储数组长度。
对象头的信息是和对象自身定义的数据无关的。为了节省空间,Mark Word被设计成一个非固定的动态数据结构。对象状态不同时,存储的信息不一样。
例如:对象未锁定时,对于Mark Word的32位比特,其中25位用于存对象哈希码,4位存储对象分代年龄,2位存储锁标志位,1位固定为0表示未进入偏向模式。
对象锁定时,又有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态,存储的数据又不一样。
总体存储情况如下表,这个结构非常重要,有助于后面各个锁的理解:
二、轻量级锁
轻量级锁加锁工作过程:
1.在代码即将进入同步块时:若此同步对象未被锁定(锁标志位为“01”),则先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于之后存储锁对象当前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)
此时堆栈和对象头状态如下图:
2.用CAS操作将该对象的Mark Word更新为指向Lock Record的指针。其实就是看下能不能将对象头中锁的指针指向当前线程的栈帧内地址。
- 若成功,则当前线程获取轻量级锁成功,将锁标志位更新为“00”。此时状态如下图。(此时CAS期待值是“hash,age,01” ?)
- 若失败,则证明已有线程持有轻量级锁(可能还未来得及更新锁状态?否则就直接按轻量级锁的状态处理了,不会进到这里)。此时看下持有锁的线程是不是自己,即检查对象的Mark Word是否指向当前线程的栈帧:
- 如果是当前栈帧,则说明是自己在用(两次不同的LockRecord地址?),直接重入进入同步块即可。
- 如果不是当前栈帧,则说明不是自己在用,也就是此时有两个以上的线程在争这把锁,此时轻量级锁失效,膨胀改为加重量级锁,标志位变为“10”(因为轻量级锁是专门针对一个线程的,用CAS避免了每次都用OS的排斥量进行重量级操作)。然后Mark Word中存入指向重量级锁(互斥量)的指针。后面等待锁的线程看到了重量级标志位,也就进入了阻塞状态。
轻量级锁解锁工作过程:
也是通过CAS来实现。
- 没有其他线程尝试获取该锁:如果对象的Mark Word仍然指向线程栈帧的锁记录,就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。替换成功,则解锁成功。
- 有其他线程尝试过获取该锁:替换失败(锁的标志位变掉了),则说明有其他线程在尝试获取该锁,也就是该锁已经膨胀为重量级锁,此时就需要在释放锁的同时,唤醒被挂起的线程。
轻量级锁优劣:
轻量级锁提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这个经验法则。如果没有竞争,轻量级锁便通过CAS操作避免了使用互斥量的开销;但是如果确实存在锁竞争,则除了互斥量的本身开销外,还额外发生了CAS操作的开销。所以在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
5.偏向锁
一、偏向锁概念
偏向锁也是JDK 6中引入的锁优化措施。目的是消除数据在无竞争情况下的同步原语。
偏向锁和轻量级锁区别:如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
偏向锁原理:偏向锁是偏心的意思,这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
偏向锁在JVM启用了启用参数-XX:+UseBiased Locking
(JDK6默认)时生效。
二、偏向锁实现
1.首次获取时变更状态并记录线程ID:(1)在锁对象第一次被线程获取的时候,把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。(2)同时用CAS获取到这个锁的线程的ID记录到对象的Mark Word之中。如果CAS操作成功,则持有偏向锁的线程以后每次进入这个锁相关的同步块时,都可以不再进行任何同步操作。(3)偏向锁是一直持有的,只有程序在不在同步块内运行的区别。
2.另一个线程获取锁时取消偏向模式:此时CAS设置线程ID时肯定会失败,因为另一个线程CAS的旧值是0,是把锁当做从来没用过的情况去看待的。此时偏向锁失效。如果锁对象目前是处于锁定状态,则STW,将偏向锁更新为轻量级锁。如果锁对象未锁定,则转移至未锁定状态。此时未锁定但不可偏向。
(根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作按轻量级锁那样去执行。)
偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图所示:
关于hashcode:我们注意到,使用偏向锁的时候,存储hashcode的位置被null或threadID取代了。那么hashcode放哪里呢?
Object::hashCode()
方法默认返回的是对象的一致性哈希码(Identity Hash Code),这个值就是对象头里面的hashcode,它第一次计算完成后永远不会变。(Java里原则上对象的hashcode一旦确定就不可改变,否则很多依赖对象哈希码的API可能会出错。虽然用户可以重写hashCode()方法,不过那个已经不是Object::hashCode()
方法。- 当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;
- 当一个对象当前正处于偏向锁状态,又需要计算其一致性哈希码时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。此时对象头指向重量级锁,重量级锁ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word。上图可以开辟到第三条路径。
偏向锁可以提高有同步但无竞争的程序性能,但如果程序中大多数的锁都是被多个线程访问的,那偏向模式就是多余的。
Java15取消了偏向锁。
三、偏向锁详解补充
当JVM启用了偏向锁模式(JDK6以上默认开启),新创建对象的Mark Word中的Thread Id为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)。可结合前图理解。
大前提:偏向锁拿到之后是不会主动释放的,只有程序进出同步块的区别。只可能在撤销偏向锁时,进行重偏向,恢复到偏向锁可用的初始状态。另外如果计算过hashcode,则偏向锁再也无法使用。
偏向锁逻辑:
1.判断锁类型是不是“未锁定或偏向锁”:线程A第一次访问同步块时,先检测对象头Mark Word中的标志位是否为01,依此判断此时对象锁是否处于无所状态或者偏向锁状态(匿名偏向锁);如果不是,则按对应的锁状态处理,轻量级锁和重量级锁。
2.如果锁类型是偏向锁,即偏向锁可用:然后判断偏向锁标志位是否为1,如果不是,则进入轻量级锁逻辑(使用CAS竞争锁),如果是,则进入下一步流程;
3.判断偏向锁是当前线程在使用,此时直接重入:判断是偏向锁时,检查对象头Mark Word中记录的Thread Id是否是当前线程ID,如果是,则表明当前线程已经获得对象锁,以后该线程进入同步块时,不需要CAS进行加锁,只会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,用来统计重入的次数(如图为当对象所处于偏向锁时,当前线程重入3次,线程栈帧中Lock Record记录)。
退出同步块释放偏向锁时,则依次删除对应Lock Record,但是不会修改对象头中的Thread Id;
注:偏向锁撤销是指在获取偏向锁的过程中因不满足条件导致要将锁对象改为非偏向锁状态,而偏向锁释放是指退出同步块时的过程。
4.判断偏向锁还未被使用,此时直接使用偏向锁:如果对象头Mark Word中Thread Id不是当前线程ID,则进行CAS操作,企图将当前线程ID替换进Mark Word。如果当前对象锁状态处于匿名偏向锁状态(可偏向未锁定),则会替换成功(将Mark Word中的Thread id由匿名0改成当前线程ID,在当前线程栈中找到内存地址最高的可用Lock Record,将线程ID存入),获取到锁,执行同步代码块;
5.判断偏向锁已被其他线程占用,此时撤销偏向锁:如果对象锁已经被其他线程占用,则会替换失败,开始进行偏向锁撤销,这也是偏向锁的特点,一旦出现线程竞争,就会撤销偏向锁;
6.持锁的线程存活且还在同步块内(已被”锁定”?),升级为轻量级锁:偏向锁的撤销需要等待全局安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的),暂停持有偏向锁的线程,检查持有偏向锁的线程状态(遍历当前JVM的所有线程,如果能找到,则说明偏向的线程还存活),如果线程还存活,则检查线程是否在执行同步代码块中的代码,如果是,则升级为轻量级锁,进行CAS竞争锁;锁定打引号是因为偏向锁是不会主动释放的,这里的意思是线程存活且还在同步块内运行。
注:每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record,并设置偏向线程ID;每次解锁(即执行monitorexit)的时候都会从最低的一个Lock Record移除。所以如果能找到对应的Lock Record说明偏向的线程还在执行同步代码块中的代码。
7.持锁的线程已挂或不在同步块内(未被”锁定”?),且不允许重偏向:转移至未锁定不可偏向状态:如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则进行校验是否允许重偏向(看下有没有Hashcode?),如果不允许重偏向,则撤销偏向锁,将Mark Word设置为无锁状态(未锁定不可偏向状态),然后升级为轻量级锁,进行CAS竞争锁;
8.持锁的线程已挂或不在同步块内(未被”锁定”?),且允许重偏向:转移至偏向锁初始状态:如果允许重偏向,设置为匿名偏向锁状态,CAS将偏向锁重新指向线程A(在对象头和线程栈帧的锁记录中存储当前线程ID);
9.撤销偏向锁完成,唤醒线程继续从安全点执行:唤醒暂停的线程,从安全点继续执行代码。
以上便是偏向锁的整个逻辑了。整体参照这一张自己画的流程图:
四、延申知识:批量重偏向与批量撤销
批量重偏向与批量撤销渊源:从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。
解决场景
- 批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。
- 批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
原理
以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。
每次发生批量重偏向时,该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。
当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
1.总述
- 批量重偏向:如果一个类的大量对象被一个线程T1执行了同步操作,也就是大量对象先偏向了T1,T1同步结束后,另一个线程也将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作。
- 批量撤销:当一个偏向锁如果撤销次数到达40的时候就认为这个对象设计的有问题;那么JVM会把这个对象所对应的类所有的对象都撤销偏向锁;并且新实例化的对象也是不可偏向的。
可以通过命令java -XX:+PrintFlagsFinal -version|grep 'BiasedLocking'
来看JVM中的默认配置:
intx BiasedLockingBulkRebiasThreshold = 20 {product}
intx BiasedLockingBulkRevokeThreshold = 40 {product}
intx BiasedLockingDecayTime = 25000 {product}
intx BiasedLockingStartupDelay = 4000 {product}
bool TraceBiasedLocking = false {product}
bool UseBiasedLocking = true {product}
123456
- BiasedLockingBulkRebiasThreshold:偏向锁批量重偏向的默认阀值为20次。
- BiasedLockingBulkRevokeThreshold:偏向锁批量撤销的默认阀值为40次。
- BiasedLockingDecayTime:距上次批量重偏向25秒内,撤销计数达到40,就会发生批量撤销。每隔(>=)25秒,会重置在[20, 40)内的计数,这意味着可以发生多次批量重偏向。
2.批量重偏向
批量重偏向会以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
- 当一个线程t1运行结束后,所有的对象都偏向t1。
- 线程t2只对前30个对象进行了同步,0-18的对象会偏向锁(101)升级为轻量级锁(00),19-29的对象由于撤销次数达到20,触发批量重偏向,偏向线程t2。
- t2结束后,0-18的对象由轻量级锁释放后变成了无锁,19-29的对象偏向t2,30-49的对象还是偏向t1。
测试代码:
package com.morris.concurrent.syn.batch;
import org.openjdk.jol.info.ClassLayout;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
public class BulkBias {
private static Thread t1, t2;
public static void main(String[] args) throws InterruptedException {
// 延时产生可偏向对象
TimeUnit.SECONDS.sleep(5);
List<B> objects = new ArrayList<>(); // 创建50个对象,锁状态为101,匿名偏向锁
for (int i = 0; i < 50; i++) {
objects.add(new B());
}
t1 = new Thread(() -> {
for (int i = 0; i < objects.size(); i++) {
synchronized (objects.get(i)) { // 50个对象全部偏向t1 101
}
}
LockSupport.unpark(t2);
});
t2 = new Thread(() -> {
LockSupport.park();
//这里面只循环了30次!!!
for (int i = 0; i < 30; i++) {
Object a = objects.get(i);
synchronized (a) {
//分别打印第19次和第20次偏向锁重偏向结果
if (i == 18 || i == 19) {
System.out.println("第" + (i + 1) + "次偏向结果");
System.out.println((ClassLayout.parseInstance(a).toPrintable())); // 第19次轻量级锁00,第20次偏向锁101,偏向t2
}
}
}
});
t1.start();
t2.start();
t2.join();
System.out.println("打印list中第11个对象的对象头:");
System.out.println((ClassLayout.parseInstance(objects.get(10)).toPrintable())); // 01 无锁
System.out.println("打印list中第26个对象的对象头:");
System.out.println((ClassLayout.parseInstance(objects.get(25)).toPrintable())); // 101 偏向t2
System.out.println("打印list中第41个对象的对象头:");
System.out.println((ClassLayout.parseInstance(objects.get(40)).toPrintable())); // 101 偏向t1
}
}
class B {
}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
运行结果如下:
第19次偏向结果
com.morris.concurrent.syn.batch.B object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 40 f3 bc 1d (01000000 11110011 10111100 00011101) (498922304)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
第20次偏向结果
com.morris.concurrent.syn.batch.B object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 69 6c 1e (00000101 01101001 01101100 00011110) (510421253)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
打印list中第11个对象的对象头:
com.morris.concurrent.syn.batch.B object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
打印list中第26个对象的对象头:
com.morris.concurrent.syn.batch.B object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 69 6c 1e (00000101 01101001 01101100 00011110) (510421253)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
打印list中第41个对象的对象头:
com.morris.concurrent.syn.batch.B object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 60 6c 1e (00000101 01100000 01101100 00011110) (510418949)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
3.批量撤销:
批量重偏向和批量撤销是针对类的优化,和对象无关。偏向锁重偏向一次之后不可再次重偏向。当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利。
- t1执行完后,0-59的对象偏向t1。
- t2执行完后,0-18的对象为无锁,19-59偏向t2。// 批量重偏向
- t3执行时由于之前执行过批量重偏向了,所以这里会升级为轻量级锁。
- t4休眠前对象65为匿名偏向状态,t4休眠后,由于触发了批量撤销,所以锁状态变为轻量级锁,所以批量撤销会把正在执行同步的对象的锁状态由偏向锁变为轻量级锁,而不在执行同步的对象的锁状态不会改变(如对象66)。
测试代码如下:
package com.morris.concurrent.syn.batch;
import org.openjdk.jol.info.ClassLayout;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
public class BulkBiasAndRevoke {
private static Thread t1, t2, t3, t4;
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5); // 等待偏向延迟时间到达
List<L> list = new ArrayList<>();
for (int i = 0; i < 80; i++) {
list.add(new L());
}
t1 = new Thread(() -> {
for (int i = 0; i < 60; i++) {
L l = list.get(i);
synchronized (l) {
}
}
LockSupport.unpark(t2);
}, "t1");
t2 = new Thread(() -> {
LockSupport.park();
for (int i = 0; i < 60; i++) {
L l = list.get(i);
synchronized (l) {
}
}
}, "t2");
t3 = new Thread(() -> {
LockSupport.park();
System.out.println("t3");
for (int i = 0; i < 60; i++) {
L l = list.get(i);
// 0-18 01
// 19-59 101 偏向t2
synchronized (l) {
// 0-59 00
}
// 0-59 01
}
}, "t3");
t4 = new Thread(() -> {
synchronized (list.get(65)) {
System.out.println("t4 begin" + ClassLayout.parseInstance(list.get(65)).toPrintable()); // 101
LockSupport.unpark(t3);
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t4 end" + ClassLayout.parseInstance(list.get(65)).toPrintable()); // 00
System.out.println("t4 end" + ClassLayout.parseInstance(list.get(66)).toPrintable()); // 101
}
}, "t1");
t4.start();
t1.start();
t2.start();
t3.start();
t3.join();
t4.join();
System.out.println(ClassLayout.parseInstance(new L()).toPrintable()); // 01
}
}
class L {
}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
运行结果如下:
t4 begincom.morris.concurrent.syn.batch.L object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 61 79 1e (00000101 01100001 01111001 00011110) (511271173)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
t4 endcom.morris.concurrent.syn.batch.L object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 28 ef c2 1d (00101000 11101111 11000010 00011101) (499314472)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
t4 endcom.morris.concurrent.syn.batch.L object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
com.morris.concurrent.syn.batch.L object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
13.4 本章小结
本章介绍了线程安全所涉及的概念和分类、同步实现的方式、虚拟机的底层运作原理,并且介绍了虚拟机为实现高效并发所做的一系列锁优化措施。
最后,三个锁适用场景:
- 偏向锁:适用于只有一个线程访问同步块。
- 轻量级锁:使用于同步块的运行时间非常短。
- 重量级锁:适用于同步块运行时间比较长,线程竞争比较激烈。
提问
为什么要考虑线程安全问题?因为对于面向对象编程来说,计算机和现实世界有差异,会有中断和切换,而且对象属性(数据)还可能会在中断期间被修改和变脏。
什么是线程安全?
Java的线程安全有哪些级别或情景?5种。
不可变对象怎么实现?Java类库API中不可变的类型有哪些?原子类AtomicInteger和AtomicLong是不可变的吗?为什么?
什么是绝对线程安全?举个例子?关键点是多次调用,可能需要快照维护
什么是相对线程安全?HashMap是绝对线程安全的吗?
什么是线程兼容?哪些API是线程兼容的?
什么是线程对立?举个例子?
如何实现线程安全?有哪些方法?
synchronized关键字底层是怎么实现的?可重入吗?
自旋锁的作用?避免频繁切换到核心态
有了synchronized为什么还要JUC?两个怎么选择?用哪个?
什么是非阻塞同步?底层原理是什么?Java哪些类用到了?ABA问题怎么解决?
如何实现无同步方案?
为什么要用自旋锁?如何实现?自旋锁的适用场景?什么事自适应自旋锁?
什么是锁消除?为什么会有锁消除?如何实现锁消除?
SpringBuffer和SpringBuild区别?
什么是锁粗化?为什么会有锁粗化?如何实现锁粗化?
对象头的信息分布?
轻量级锁用来做什么?实现原理?使用场景?轻量级锁优劣?
偏向锁用来做什么?偏向锁和轻量级锁区别?实现原理?使用场景?偏向锁优劣?
偏向锁、轻量级锁、重量级锁有什么区别?适用场景有啥不一样?
几个锁流转状态的逻辑,画出来?
什么是批量重偏向?批量撤销?
转载请注明来源