This the multi-page printable view of this section. Click here to print.
JVM 核心
- 1: JVM Core
- 1.1: CH01-JVM概览
- 1.2: CH02-JVM字节码
- 1.3: CH03-JVM类加载
- 1.4: CH04-JVM内存结构
- 1.5: CH05-JVM内存模型-1
- 1.6: CH06-JVM内存模型-2
- 1.7: CH07-JVM垃圾回收
- 1.8: CH08-JVM-G1
- 1.9: CH09-JVM-ZGC
- 1.10: CH10-JVM调优参数
- 1.11: CH11-JVM-OOM
- 1.12: CH12-JVM线程Dump
- 1.13: CH13-JVM调试命令
- 1.14: CH14-JVM调试工具
- 1.15: CH15-JVM动态调试
- 2: JSR 133
- 3: JMM 规范
- 3.1: CH01-JMM规范
- 3.2: CH02-JMM Explain
- 3.3: CH03-JMM原则
- 3.4: CH04-JSR133-FAQ
- 3.5: CH05-JSR133-Cook
- 4: JVM 深入理解 V2
- 4.1: CH01-走近 Java
- 4.2: CH02-内存区域与溢出
- 4.3: CH03-垃圾收集与分配策略
- 4.4: CH04-性能监控与故障处理
- 4.5: CH05-调优案例
- 4.6: CH06-类文件结构
- 4.7: CH07-类加载
- 4.8: CH08-字节码执行引擎
- 4.9: CH09-案例与实战
- 4.10: CH10-编译期优化
- 4.11: CH11-运行时优化
- 4.12: CH12-内存模型与线程
- 4.13: CH13-线程安全与锁优化
- 4.14: Endix-B-字节码指令
- 4.15: Endix-C-虚拟机参数
- 5: JVM 深入理解 V3
1 - JVM Core
1.1 - CH01-JVM概览
JVM 结构
JVM 调试
1.2 - CH02-JVM字节码
多语言编译为 JVM 能够执行的字节码
计算机是不能直接运行java代码的,必须要先运行java虚拟机,再由java虚拟机运行编译后的java代码。这个编译后的java代码,就是本文要介绍的java字节码。
为什么jvm不能直接运行java代码呢,这是因为在cpu层面看来计算机中所有的操作都是一个个指令的运行汇集而成的,java是高级语言,只有人类才能理解其逻辑,计算机是无法识别的,所以java代码必须要先编译成字节码文件,jvm才能正确识别代码转换后的指令并将其运行。
Java代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的JVM虚拟机去读取执行,从而实现一次编写,到处运行的目的。
JVM 不仅支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。
Java字节码文件
class文件本质上是一个以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在class文件中。jvm根据其特定的规则解析该二进制数据,从而得到相关信息。
Class文件采用一种伪结构来存储数据,它有两种类型:无符号数和表。
Class文件的结构属性
在理解之前先从整体看下java字节码文件包含了哪些类型的数据:
实例
下面以一个简单的例子来逐步讲解字节码。
//Main.java
public class Main {
private int m;
public int inc() {
return m + 1;
}
}
通过以下命令, 可以在当前所在路径下生成一个Main.class文件。
javac Main.java
以文本的形式打开生成的class文件,内容如下:
cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4d61 696e 2e6a 6176 610c
0007 0008 0c00 0500 0601 0010 636f 6d2f
7268 7974 686d 372f 4d61 696e 0100 106a
6176 612f 6c61 6e67 2f4f 626a 6563 7400
2100 0300 0400 0000 0100 0200 0500 0600
0000 0200 0100 0700 0800 0100 0900 0000
1d00 0100 0100 0000 052a b700 01b1 0000
0001 000a 0000 0006 0001 0000 0003 0001
000b 000c 0001 0009 0000 001f 0002 0001
0000 0007 2ab4 0002 0460 ac00 0000 0100
0a00 0000 0600 0100 0000 0800 0100 0d00
0000 0200 0e
文件开头的4个字节(“cafe babe”)称之为
魔数
,唯有以"cafe babe"开头的class文件方可被虚拟机所接受,这4个字节就是字节码文件的身份识别。0000是编译器jdk版本的次版本号0,0034转化为十进制是52,是主版本号,java的版本号从45开始,除1.0和1.1都是使用45.x外,以后每升一个大版本,版本号加一。也就是说,编译生成该class文件的jdk版本为1.8.0。
反编译字节码文件
输入命令javap -verbose -p Main.class
查看输出内容:
Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class
Last modified 2018-4-7; size 362 bytes
MD5 checksum 4aed8540b098992663b7ba08c65312de
Compiled from "Main.java"
public class com.rhythm7.Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#19 // com/rhythm7/Main.m:I
#3 = Class #20 // com/rhythm7/Main
#4 = Class #21 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/rhythm7/Main;
#14 = Utf8 inc
#15 = Utf8 ()I
#16 = Utf8 SourceFile
#17 = Utf8 Main.java
#18 = NameAndType #7:#8 // "<init>":()V
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/rhythm7/Main
#21 = Utf8 java/lang/Object
{
private int m;
descriptor: I
flags: ACC_PRIVATE
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/rhythm7/Main;
}
SourceFile: "Main.java"
字节码文件信息
开头的7行信息包括:Class文件当前所在位置,最后修改时间,文件大小,MD5值,编译自哪个文件,类的全限定名,jdk次版本号,主版本号。
然后紧接着的是该类的访问标志:ACC_PUBLIC, ACC_SUPER,访问标志的含义如下:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为Public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语义. |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
常量池
常量池可以理解成Class文件中的资源仓库。主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量类似于java中的常量概念,如文本字符串,final常量等,而符号引用则属于编译原理方面的概念,包括以下三种:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符号(Descriptor)
- 方法的名称和描述符
不同于C/C++, JVM是在加载Class文件的时候才进行的动态链接,也就是说这些字段和方法符号引用只有在运行期转换后才能获得真正的内存入口地址。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。 直接通过反编译文件来查看字节码内容:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#4 = Class #21 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#18 = NameAndType #7:#8 // "<init>":()V
#21 = Utf8 java/lang/Object
第一个常量是一个方法定义,指向了第4和第18个常量。以此类推查看第4和第18个常量。最后可以拼接成第一个常量右侧的注释内容:
java/lang/Object."<init>":()V
这段可以理解为该类的实例构造器的声明,由于Main类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了Main类的直接父类是Object。 该方法默认返回值是V, 也就是void,无返回值。
第二个常量同理可得:
#2 = Fieldref #3.#19 // com/rhythm7/Main.m:I
#3 = Class #20 // com/rhythm7/Main
#5 = Utf8 m
#6 = Utf8 I
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/rhythm7/Main
此处声明了一个字段m,类型为I, I即是int类型。关于字节码的类型对应如下:
标识字符 | 含义 | |
---|---|---|
B | 基本类型byte | |
C | 基本类型char | |
D | 基本类型double | |
F | 基本类型float | |
I | 基本类型int | |
J | 基本类型long | |
S | 基本类型short | |
Z | 基本类型boolean | |
V | 特殊类型void | |
L | 对象类型,以分号结尾,如Ljava/lang/Object; |
对于数组类型,每一位使用一个前置的[
字符来描述,如定义一个java.lang.String[][]
类型的维数组,将被记录为[[Ljava/lang/String;
方法表集合
在常量池之后的是对类内部的方法描述,在字节码中以表的集合形式表现,暂且不管字节码文件的16进制文件内容如何,我们直接看反编译后的内容。
private int m;
descriptor: I
flags: ACC_PRIVATE
此处声明了一个私有变量m,类型为int,返回值为int
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;
这里是构造方法:Main(),返回值为void, 公开方法。
code内的主要属性为:
stack: 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1
locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。方法参数(包括实例方法中的隐藏参数this),显示异常处理器的参数(try catch中的catch块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的。
args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this
attribute_info: 方法体内容,0,1,4为字节码"行号",该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的"java/lang/Object.““😦)V”, 然后执行返回语句,结束方法。
LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。可以使用 -g:none 或-g:lines选项来取消或要求生成这项信息,如果选择不生成LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是arg0, arg1这样的占位符。 start 表示该局部变量在哪一行开始可见,length表示可见行数,Slot代表所在帧栈位置,Name是变量名称,然后是类型签名。
同理可以分析Main类中的另一个方法"inc()”:
方法体内的内容是:将this入栈,获取字段#2并置于栈顶, 将int类型的1入栈,将栈内顶部的两个数值相加,返回一个int类型的值。
类名
SourceFile: "Main.java"
典型问题:try-catch-finally
public class TestCode {
public int foo() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
}
public int foo();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=5, args_size=1
0: iconst_1 //int型1入栈 ->栈顶=1
1: istore_1 //将栈顶的int型数值存入第二个局部变量 ->局部2=1
2: iload_1 //将第二个int型局部变量推送至栈顶 ->栈顶=1
3: istore_2 //!!将栈顶int型数值存入第三个局部变量 ->局部3=1
4: iconst_3 //int型3入栈 ->栈顶=3
5: istore_1 //将栈顶的int型数值存入第二个局部变量 ->局部2=3
6: iload_2 //!!将第三个int型局部变量推送至栈顶 ->栈顶=1
7: ireturn //从当前方法返回栈顶int数值 ->1
8: astore_2 // ->局部3=Exception
9: iconst_2 // ->栈顶=2
10: istore_1 // ->局部2=2
11: iload_1 //->栈顶=2
12: istore_3 //!! ->局部4=2
13: iconst_3 // ->栈顶=3
14: istore_1 // ->局部1=3
15: iload_3 //!! ->栈顶=2
16: ireturn // -> 2
17: astore 4 //将栈顶引用型数值存入第五个局部变量=any
19: iconst_3 //将int型数值3入栈 -> 栈顶3
20: istore_1 //将栈顶第一个int数值存入第二个局部变量 -> 局部2=3
21: aload 4 //将局部第五个局部变量(引用型)推送至栈顶
23: athrow //将栈顶的异常抛出
Exception table:
from to target type
0 4 8 Class java/lang/Exception //0到4行对应的异常,对应#8中储存的异常
0 4 17 any //Exeption之外的其他异常
8 13 17 any
17 19 17 any
在字节码的4,5,以及13,14中执行的是同一个操作,就是将int型的3入操作数栈顶,并存入第二个局部变量。这正是我们源码在finally语句块中内容。也就是说,JVM在处理异常时,会在每个可能的分支都将finally语句重复执行一遍。
通过一步步分析字节码,可以得出最后的运行结果是:
- 不发生异常时: return 1
- 发生异常时: return 2
- 发生非Exception及其子类的异常,抛出异常,不返回值
1.3 - CH03-JVM类加载
类的生命周期
其中类加载的过程包括了加载
、验证
、准备
、解析
、初始化
五个阶段。在这五个阶段中,加载
、验证
、准备
和初始化
这四个阶段发生的顺序是确定的,而解析
阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
类的加载: 查找并加载类的二进制数据
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
- 从本地系统中直接加载
- 通过网络下载.class文件
- 从zip,jar等归档文件中加载.class文件
- 从专有数据库中提取.class文件
- 将Java源文件动态编译为.class文件
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class
类的对象,这样便可以通过该对象访问方法区中的这些数据。
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
连接
验证: 确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
文件格式验证
: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE
开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。元数据验证
: 对字节码描述的信息进行语义分析(注意: 对比javac
编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object
之外。字节码验证
: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。符号引用验证
: 确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用
-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备: 为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
这时候进行内存分配的仅包括类变量(
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在Java代码中被显式地赋予的值。假设一个类变量的定义为:
public static int value = 3
;那么变量value在准备阶段过后的初始值为0
,而不是3
,因为这时候尚未开始执行任何Java方法,而把value赋值为3的put static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
另外:
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
- 对于同时被
static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。 - 对于引用数据类型
reference
来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
。 - 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
- 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。假设上面的类变量value被定义为:
public static final int value = 3;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final
常量在编译期就将其结果放入了调用它的类的常量池中
解析: 把类中的符号引用转换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类
或接口
、字段
、类方法
、接口方法
、方法类型
、方法句柄
和调用点
限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。
直接引用
就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
- 声明类变量时指定初始值
- 使用静态代码块为类变量指定初始值
JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机: 只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如Class.forName(“com.pdai.jvm.Test”))
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
使用
类访问方法区内的数据结构的接口,对象是Heap区的数据。
卸载
Java虚拟机将结束生命周期的几种情况
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
类加载器
类加载器的层次
注意: 这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的。
站在Java虚拟机的角度来讲,只存在两种不同的类加载器:
- 启动类加载器: 它使用C++实现(这里仅限于
Hotspot
,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分; - 所有其他的类加载器: 这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类
java.lang.ClassLoader
,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
站在Java开发人员的角度来看,类加载器可以大致划分为以下三类 :
启动类加载器
: Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
扩展类加载器
: Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader
实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器
: Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader
来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:
- 在执行非置信代码之前,自动验证数字签名。
- 动态地创建符合用户特定需要的定制化构建类。
- 从特定的场所取得java class,例如数据库中和网络中。
寻找类加载器
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println(loader);
System.out.println(loader.getParent());
System.out.println(loader.getParent().getParent());
// output:
sun.misc.Launcher$AppClassLoader@64fef26a
sun.misc.Launcher$ExtClassLoader@1ddd40f3
null
从上面的结果可以看出,并没有获取到ExtClassLoader
的父Loader,原因是BootstrapLoader
(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null
。
类的加载
类加载有三种方式:
1、命令行启动应用时候由JVM初始化加载
2、通过Class.forName()方法动态加载
3、通过ClassLoader.loadClass()方法动态加载
Class.forName()和ClassLoader.loadClass()区别?
- Class.forName(): 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
- ClassLoader.loadClass(): 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
- Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
JVM 类加载机制
全盘负责
,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入父类委托
,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类缓存机制
,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效双亲委派机制
, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。- 系统类防止内存中出现多份同样的字节码
- 保证Java程序安全稳定运行
双亲委派机制
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name, false);
}
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 {
//如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
自定义类加载器
自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。下面我们通过一个示例来演示自定义类加载器的流程:
package com.pdai.jvm.classloader;
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("D:\\temp");
Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.pdai.jvm.classloader.Test2");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。
这里有几点需要注意 :
1、这里传递的文件名需要是类的全限定性名称,即com.pdai.jvm.classloader.Test2
格式的,因为 defineClass 方法是按这种格式进行处理的。
2、最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
3、这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把com/pdai/jvm/classloader/Test2.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
1.4 - CH04-JVM内存结构
运行时数据区
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。
下图是 JVM 整体架构,中间部分就是 Java 虚拟机定义的各种运行时数据区域。
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
- 线程私有:程序计数器、栈、本地栈
- 线程共享:堆、堆外内存(Java7的永久代或JDK8的元空间、代码缓存)
程序计数器
程序计数寄存器(Program Counter Register),Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的线程信息,CPU 只有把数据装载到寄存器才能够运行。
这里,并非是广义上所指的物理寄存器,叫程序计数器(或PC计数器或指令计数器)会更加贴切,并且也不容易引起一些不必要的误会。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
作用
PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
进入class文件所在目录,执行 javap -v xx.class
反解析(或者通过 IDEA 插件 Jclasslib
直接查看,上图),可以看到当前类对应的Code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。
概述
通过下面两个问题,理解下PC计数器:
使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
- 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
PC寄存器为什么会被设定为线程私有的?
- 多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。
总结:
它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)
它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
它是唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError
情况的区域
虚拟机栈
概述
Java 虚拟机栈(Java Virtual Machine Stacks),早期也叫 Java 栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。
作用
主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈(进栈/压栈),方法执行结束出栈
- 栈不存在垃圾回收问题
相关异常
Java 虚拟机规范允许 Java虚拟机栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个OutOfMemoryError异常
可以通过参数-Xss
来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
存储单位
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都各自有对应的一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
运行原理
- JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
- Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出
IDEA 在 debug 时候,可以在 debug 窗口看到 Frames 中各种方法的压栈和出栈情况:
内部结构
每个栈帧(Stack Frame)中存储着:
局部变量表(Local Variables)
操作数栈(Operand Stack)(或称为表达式栈)
动态链接(Dynamic Linking):指向运行时常量池的方法引用
方法返回地址(Return Address):方法正常退出或异常退出的地址
一些附加信息
局部变量表
局部变量表也被称为局部变量数组或者本地变量表
是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的
maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束
槽 Slot:
局部变量表最基本的存储单元是 Slot(变量槽)
在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slot
- byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true
- long 和 double 则占据两个 Slot
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上
如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量,不允许采用任何方式单独访问其中的某一个 Slot)
如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列(这里就引出一个问题:静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。(下图中,this、a、b、c 理论上应该有 4 个变量,c 复用了 b 的槽)
- 在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈(Expression Stack)
操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
相关解释:
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的
max_stack
数据项中栈中的任何一个元素都可以是任意的 Java 数据类型
- 32bit 的类型占用一个栈单位深度
- 64bit 的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
栈顶缓存—基于寄存器
HotSpot 的执行引擎采用的并非是基于寄存器的架构,但这并不代表 HotSpot VM 的实现并没有间接利用到寄存器资源。寄存器是物理 CPU 中的组成部分之一,它同时也是 CPU 中非常重要的高速存储资源。一般来说,寄存器的读/写速度非常迅速,甚至可以比内存的读/写速度快上几十倍不止,不过寄存器资源却非常有限,不同平台下的CPU 寄存器数量是不同和不规律的。寄存器主要用于缓存本地机器指令、数值和下一条需要被执行的指令地址等数据。
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接:指向运行时常量池的方法引用
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
JVM 是如何执行方法调用的
方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关:
- 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
- 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
- 晚期绑定:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称为晚期绑定。
虚方法与非虚方法:
- 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法
- 其他方法称为虚方法
虚方法表
在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。
方法返回地址
用来存放调用该方法的 PC 寄存器的值。一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口
一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。
在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口
- 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
附加信息
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。
本地方法栈
本地方法接口
简单的讲,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。
为什么需要本地方法(Native Method)?
Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来也不容易,或者我们对程序的效率很在意时,问题就来了
- 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
- 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互, JVM 的一些部分就是 C 语言写的。
- Sun’s Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分都是用 Java 实现的,它也通过一些本地方法与外界交互。比如,类
java.lang.Thread
的setPriority()
的方法是用Java 实现的,但它实现调用的是该类的本地方法setPrioruty()
,该方法是C实现的,并被植入 JVM 内部。
本地方法栈(Native Method Stack)
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用
本地方法栈也是线程私有的
允许线程固定或者可动态扩展的内存大小
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java虚拟机将会抛出一个
OutofMemoryError
异常
本地方法是使用 C 语言实现的
它的具体做法是
Mative Method Stack
中登记 native 方法,在Execution Engine
执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈
在 Hotspot JVM 中,直接将本地方栈和虚拟机栈合二为一
- 栈是运行时的单位,堆是存储的单位。
- 栈解决程序运行的问题,即程序如何执行,或者说如何处理数据。
- 堆解决的是数据存储的问题,即数据怎么放,放在那。
堆内存
内存划分
对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存在逻辑上分为 3 个区域(分代的唯一原因就是优化 GC 性能):
- 新生代(年轻代):新对象和没达到一定年龄的对象都都在新生代。
- 老年代(老年区):被长时间使用的对象,老年代的内存空间应该要比新生代更大。
- 元空间(永久代):如一些方法中操作的临时对象,1.8 之前使用 JVM 内存,1.8 之后直接使用物理内存。
Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx
和 -Xms
控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError
异常。
新生代:Young Generation
- 新生代是存储所有新建对象的区域。
- 当填充(满)新生代时,执行垃圾收集。
- 这种垃圾收集称为 Minor GC。
- 新生代又被分为三个部分:
- 一个伊甸园(Eden Memory)和两个幸存区(Survivor Memory, from/to, s0/s1)
- 三个部分的比例是 8:1:1
- 大多数新建的对象都位于伊甸园空间内。
- 当伊甸园空间被对象填充(满)时,执行 Minor GC,并将所有幸存者对象移动到一个幸存区
- Minor 检查幸存者对象,并将它们移动到另一个幸存区,因此每次 Minor GC 之后会有一个空幸存区
- 经过多次 Minor GC 循环后仍然存活下来的对象被移动到老年代。
- 这是通过设置年轻代的年龄阈值来实现的,到达一定年龄后才有资格进入老年代。
老年代:Old Generation
- 旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。
- 通常,垃圾收集是在老年代内存满时触发执行。
- 老年代垃圾收集称为主 GC——Major GC,通常需要更长时间。
- 大型对象会直接被放入老年代,大型对象是指需要大量连续内存空间的的对象,以避免在新生代的伊甸园和幸存区之间出现大量的内存拷贝。
元空间/永久代
- 即 Java 虚拟机规范中方法区的实现。
- 虽然 Java 虚拟机规范将方法区描述为堆的一个逻辑部分,但是它却有一个别名——Non-Heap(非堆),目的是与 Java 堆区分开。
设置内存大小与 OOM
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了:
-Xmx
用来表示堆的起始内存,等价于-XX:InitialHeapSize
-Xms
用来表示堆的最大内存,等价于-XX:MaxHeapSize
如果堆的内存大小超过 -Xms
设定的最大内存, 就会抛出 OutOfMemoryError
异常。
我们通常会将 -Xmx
和 -Xms
两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。
- 默认情况下,初始堆内存大小为:电脑内存大小/64
- 默认情况下,最大堆内存大小为:电脑内存大小/4
我们可以通过代码获取启动时设置的值,当然也可以模拟 OOM:
//返回 JVM 堆大小
long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
//返回 JVM 堆的最大内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;
System.out.println("-Xms : "+initalMemory + "M");
System.out.println("-Xmx : "+maxMemory + "M");
System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");
System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");
查看 JVM 堆内存分配
- 在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小
- 默认情况下新生代和老年代的比例是 1:2,可以通过
–XX:NewRatio
来配置 - 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过
-XX:SurvivorRatio
来配置 - 若在 JDK 7 中开启了
-XX:+UseAdaptiveSizePolicy
,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄- 此时
–XX:NewRatio
和-XX:SurvivorRatio
将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy
- 在 JDK 8中,不要随意关闭
-XX:+UseAdaptiveSizePolicy
,除非对堆内存的划分有明确的规划
- 此时
- 每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小
- 计算依据是GC过程中统计的GC时间、吞吐量、内存占用量
java -XX:+PrintFlagsFinal -version | grep HeapSize
uintx ErgoHeapSizeLimit = 0 {product}
uintx HeapSizePerGCThread = 87241520 {product}
uintx InitialHeapSize := 134217728 {product}
uintx LargePageHeapSizeThreshold = 134217728 {product}
uintx MaxHeapSize := 2147483648 {product}
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)
jmap -heap 进程号
对象在堆中的生命周期
在 JVM 内存模型的堆中,堆被划分为新生代和老年代
- 新生代又被进一步划分为 Eden区 和 Survivor区
- Survivor 区由 From Survivor 和 To Survivor 组成
当创建一个对象时,对象会被优先分配到新生代的 Eden 区
- 此时 JVM 会给对象定义一个对象年轻计数器(
-XX:MaxTenuringThreshold
)
- 此时 JVM 会给对象定义一个对象年轻计数器(
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
- JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
- 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
如果分配的对象超过了
-XX:PetenureSizeThreshold
,对象会直接被分配到老年代
对象的分配过程
为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。
- new 的对象先放在伊甸园区,此区有大小限制
- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 然后将伊甸园中的剩余对象移动到幸存者 from 区
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 to 区,如果没有回收,就会放到幸存者 to 区
- 如果再次经历垃圾回收,此时会重新放回幸存者 from 区,接着再去幸存者 to 区
- 什么时候才会去老年代呢? 默认是 15 次回收标记
- 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
- 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常
GC 垃圾回收简介
Minor、Major、Full
JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)。
部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 目前,只有 CMS GC 会有单独收集老年代的行为
- 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
- 目前只有 G1 GC 会有这种行为
整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾
TLAB:Thread Local Allocation Buffer
什么是 TLAB
从内存模型而不是垃圾回收的角度,对伊甸园继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在伊甸园内
多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计
为什么使用 TLAB
堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
在程序中,可以通过
-XX:UseTLAB
设置是否开启 TLAB 空间。默认情况下,TLAB 空间的内存非常小,仅占有整个伊甸园的 1%,我们可以通过
-XX:TLABWasteTargetPercent
设置 TLAB 空间所占用伊甸园的百分比大小。
一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在伊甸园中分配内存。
值能在堆中存储对象吗
随着 JIT 编译期的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
逃逸分析
逃逸分析(Escape Analysis)*是目前 Java 虚拟机中比较前沿的优化技术*。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。
- 例如作为调用参数传递到其他地方中,称为方法逃逸。
参数设置
- 在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析
- 如果使用较早版本,可以通过
-XX"+DoEscapeAnalysis
显式开启
开发中使用局部变量,就不要在方法外定义。
使用逃逸分析,编译器可以对代码做优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器
JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递
代码优化:同步省略/锁消除
线程同步的代价是相当高的,同步的后果是降低并发性和性能
在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。
这个取消同步的过程就叫做同步省略,也叫锁消除。
代码优化:标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
相对的,那些的还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。
在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。
通过 -XX:+EliminateAllocations
可以开启标量替换,-XX:+PrintEliminateAllocations
查看标量替换情况。
代码优化:栈上分配
我们通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给 GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
总结
关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。
方法区—元空间—永久代
- 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
- 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。
- 运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是
String.intern()
方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryErro
r 异常。
- 方法区大小的设定方式与堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误
- JVM 关闭后方法区即被释放
元空间、永久代、方法区?
- 方法区(method area)*只是 **JVM 规范**中定义的一个*概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)**是 **Hotspot** 虚拟机特有的概念, Java8 的时候又被**元空间**取代了,永久代和元空间都可以理解为方法区的落地实现。
- 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)
- Java7 中我们通过
-XX:PermSize
和-xx:MaxPermSize
来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
用来设置元空间参数 - 存储内容不同:元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中
- 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出
OutOfMemoryError
- JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)
所以对于方法区,Java8 之后的变化:
- 移除了永久代(PermGen),替换为元空间(Metaspace);
- 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
- 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
- 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)
设置空间大小
JDK8 及以后:
- 元数据区大小可以使用参数
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定,替代上述原有的两个参数 - 默认值依赖于平台。Windows 下,
-XX:MetaspaceSize
是 21M,-XX:MaxMetaspacaSize
的值是 -1,即没有限制 - 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常
OutOfMemoryError:Metaspace
-XX:MetaspaceSize
:设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的-XX:MetaspaceSize
的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize
时,适当提高该值。如果释放空间过多,则适当降低该值- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将
-XX:MetaspaceSize
设置为一个相对较高的值。
内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final 的某个子集)
- 这个类型直接接口的一个有序列表
字段信息
JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)
方法信息
JVM 必须保存所有方法的:
- 方法名称
- 方法的返回类型
- 方法参数的数量和类型
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
方法区与堆、栈
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,理解运行时常量池的话,我们先来说说字节码文件(Class 文件)中的常量池(常量池表)。
字节码常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。
为什么需要常量池
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。
如下,我们通过 jclasslib 查看一个只有 Main 方法的简单类,字节码中的 #2 指向的就是 Constant Pool
常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池
在加载类和结构到虚拟机后,就会创建对应的运行时常量池
常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定在编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
intern()
方法就是这样的
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定在编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。
方法区实现的演进
只有 HotSpot 才有永久代的说法:
jdk1.6及之前 | 有永久代,静态变量存放在永久代上 |
---|---|
jdk1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
jdk1.8及之后 | 取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中 |
移除永久代的原因
为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。如果某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现 OOM。
而元空间和永久代最大的区别在于,元空间不在虚拟机中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地内存限制
对永久代进行调优较困难
方法区垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
判定一个类型是否属于“不再被使用的类”,需要同时满足三个条件:
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc
参数进行控制,还可以使用 -verbose:class
以及 -XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
1.5 - CH05-JVM内存模型-1
内存结构 & 内存模型
堆栈
JVM 内部通过内存结构在线程栈和堆之间划分内存。 此图从逻辑角度说明了Java内存结构:
堆栈中的内容
线程堆栈还包含正在执行的每个方法的所有局部变量(调用堆栈上的所有方法)。 线程只能访问它自己的线程堆栈。 由线程创建的局部变量对于创建它的线程以外的所有其他线程是不可见的。 即使两个线程正在执行完全相同的代码,两个线程仍将在每个自己的线程堆栈中创建该代码的局部变量。 因此,每个线程都有自己的每个局部变量的版本。
基本类型的所有局部变量(boolean,byte,short,char,int,long,float,double)完全存储在线程堆栈中,因此对其他线程不可见。 一个线程可以将一个基本类型变量的副本传递给另一个线程,但它不能共享原始局部变量本身。
堆包含了在Java应用程序中创建的所有对象,无论创建该对象的线程是什么。 这包括基本类型的包装类(例如Byte,Integer,Long等)。 无论是创建对象并将其分配给局部变量,还是创建为另一个对象的成员变量,该对象仍然存储在堆上。
局部变量可以是基本类型,在这种情况下,它完全保留在线程堆栈上。
局部变量也可以是对象的引用。 在这种情况下,引用(局部变量)存储在线程堆栈中,但是对象本身存储在堆(Heap)上。
对象的成员变量与对象本身一起存储在堆上。 当成员变量是基本类型时,以及它是对象的引用时都是如此。
静态类变量也与类定义一起存储在堆上。
线程如何访问堆上的对象?
所有具有对象引用的线程都可以访问堆上的对象。 当一个线程有权访问一个对象时,它也可以访问该对象的成员变量。 如果两个线程同时在同一个对象上调用一个方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本。
两个线程有一组局部变量。 其中一个局部变量(局部变量2)指向堆上的共享对象(对象3)。 两个线程各自对同一对象具有不同的引用。 它们的引用是局部变量,因此存储在每个线程的线程堆栈中(在每个线程堆栈上)。 但是,这两个不同的引用指向堆上的同一个对象。
注意共享对象(对象3)如何将对象2和对象4作为成员变量引用(由对象3到对象2和对象4的箭头所示)。 通过对象3中的这些成员变量引用,两个线程可以访问对象2和对象4.
该图还显示了一个局部变量,该变量指向堆上的两个不同对象。 在这种情况下,引用指向两个不同的对象(对象1和对象5),而不是同一个对象。 理论上,如果两个线程都引用了两个对象,则两个线程都可以访问对象1和对象5。 但是在上图中,每个线程只引用了两个对象中的一个。
线程栈访问堆对象
那么,什么样的Java代码可以导致上面的内存图? 好吧,代码就像下面的代码一样简单:
public class MyRunnable implements Runnable() {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 =
MySharedObject.sharedInstance;
//... do more with local variables.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... do more with local variable.
}
}
public class MySharedObject {
//static variable pointing to instance of MySharedObject
public static final MySharedObject sharedInstance =
new MySharedObject();
//member variables pointing to two objects on the heap
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member1 = 67890;
}
如果两个线程正在执行run()方法,则前面显示的图表将是结果。 run()方法调用methodOne(),methodOne()调用methodTwo()。
methodOne()声明一个局部基本类型变量(类型为int的localVariable1)和一个局部变量,它是一个对象引用(localVariable2)。
执行methodOne()的每个线程将在各自的线程堆栈上创建自己的localVariable1和localVariable2副本。 localVariable1变量将完全相互分离,只存在于每个线程的线程堆栈中。 一个线程无法看到另一个线程对其localVariable1副本所做的更改。
执行methodOne()的每个线程也将创建自己的localVariable2副本。 但是,localVariable2的两个不同副本最终都指向堆上的同一个对象。 代码将localVariable2设置为指向静态变量引用的对象。 静态变量只有一个副本,此副本存储在堆上。 因此,localVariable2的两个副本最终都指向静态变量指向的MySharedObject的同一个实例。 MySharedObject实例也存储在堆上。 它对应于上图中的对象3。
注意MySharedObject类还包含两个成员变量。 成员变量本身与对象一起存储在堆上。 两个成员变量指向另外两个Integer对象。 这些Integer对象对应于上图中的Object 2和Object 4。
另请注意methodTwo()如何创建名为localVariable1的局部变量。 此局部变量是对Integer对象的对象引用。 该方法将localVariable1引用设置为指向新的Integer实例。 localVariable1引用将存储在执行methodTwo()的每个线程的一个副本中。 实例化的两个Integer对象将存储在堆上,但由于该方法每次执行该方法时都会创建一个新的Integer对象,因此执行此方法的两个线程将创建单独的Integer实例。 在methodTwo()中创建的Integer对象对应于上图中的Object 1和Object 5。
另请注意类型为long的MySharedObject类中的两个成员变量,它们是基本类型。 由于这些变量是成员变量,因此它们仍与对象一起存储在堆上。 只有局部变量存储在线程堆栈中。
JMM 与硬件内存结构
硬件内存结构简介
现代硬件内存架构与内部Java内存模型略有不同。 了解硬件内存架构也很重要,以了解Java内存模型如何与其一起工作。 本节介绍了常见的硬件内存架构,后面的部分将介绍Java内存模型如何与其配合使用。
这是现代计算机硬件架构的简化图:
现代计算机通常有2个或更多CPU。 其中一些CPU也可能有多个内核。 关键是,在具有2个或更多CPU的现代计算机上,可以同时运行多个线程。 每个CPU都能够在任何给定时间运行一个线程。 这意味着如果您的Java应用程序是多线程的,线程真的在可能同时运行.
每个CPU基本上都包含一组在CPU内存中的寄存器。 CPU可以在这些寄存器上执行的操作比在主存储器中对变量执行的操作快得多。 这是因为CPU可以比访问主存储器更快地访问这些寄存器。
每个CPU还可以具有CPU高速缓存存储器层。 事实上,大多数现代CPU都有一些大小的缓存存储层。 CPU可以比主存储器更快地访问其高速缓存存储器,但通常不会像访问其内部寄存器那样快。 因此,CPU高速缓存存储器介于内部寄存器和主存储器的速度之间。 某些CPU可能有多个缓存层(级别1和级别2),但要了解Java内存模型如何与内存交互,这一点并不重要。 重要的是要知道CPU可以有某种缓存存储层。
计算机还包含主存储区(RAM)。 所有CPU都可以访问主内存。 主存储区通常比CPU的高速缓存存储器大得多。同时访问速度也就较慢.
通常,当CPU需要访问主存储器时,它会将部分主存储器读入其CPU缓存。 它甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。 当CPU需要将结果写回主存储器时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某些时候将值刷新回主存储器。
JMM 与硬件内存连接
如前所述,Java内存模型和硬件内存架构是不同的。 硬件内存架构不区分线程堆栈和堆。 在硬件上,线程堆栈和堆都位于主存储器中。 线程堆栈和堆的一部分有时可能存在于CPU高速缓存和内部CPU寄存器中。 这在图中说明:
当对象和变量可以存储在计算机的各种不同存储区域中时,可能会出现某些问题。 两个主要问题是:
- Visibility of thread updates (writes) to shared variables.
- Race conditions when reading, checking and writing shared variables. 以下各节将解释这两个问题。
JMM与硬件内存连接 - 对象共享后的可见性
如果两个或多个线程共享一个对象,而没有正确使用volatile声明或同步,则一个线程对共享对象的更新可能对其他线程不可见。
想象一下,共享对象最初存储在主存储器中。 然后,在CPU上运行的线程将共享对象读入其CPU缓存中。 它在那里对共享对象进行了更改。 只要CPU缓存尚未刷新回主内存,共享对象的更改版本对于在其他CPU上运行的线程是不可见的。 这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的CPU缓存中。
下图描绘了该情况。 在左CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其count变量更改为2.对于在右边的CPU上运行的其他线程,此更改不可见,因为计数更新尚未刷新回主内存中.
要解决此问题,您可以使用Java的volatile关键字。 volatile关键字可以确保直接从主内存读取给定变量,并在更新时始终写回主内存。
JMM与硬件内存连接 - 竞态条件
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能会出现竞态。
想象一下,如果线程A将共享对象的变量计数读入其CPU缓存中。 想象一下,线程B也做同样的事情,但是进入不同的CPU缓存。 现在,线程A将一个添加到count,而线程B执行相同的操作。 现在var1已经增加了两次,每个CPU缓存一次。
如果这些增量是按先后顺序执行的,则变量计数将增加两次并将原始值+ 2写回主存储器。
但是,两个增量同时执行而没有适当的同步。 无论线程A和B中哪一个将其更新后的计数版本写回主存储器,更新的值将仅比原始值高1,尽管有两个增量。
该图说明了如上所述的竞争条件问题的发生:
要解决此问题,您可以使用Java synchronized块。 同步块保证在任何给定时间只有一个线程可以进入代码的给定关键部分。 同步块还保证在同步块内访问的所有变量都将从主存储器中读入,当线程退出同步块时,所有更新的变量将再次刷新回主存储器,无论变量是不是声明为volatile。
1.6 - CH06-JVM内存模型-2
基础
并发编程模型
在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
Java 内存模型抽象
在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下:
从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。
重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
- 对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
- 对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
处理器重排序与内存屏障指令
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:
// Processor A
a = 1; //A1
x = b; //A2
// Processor B
b = 2; //B1
y = a; //B2
// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0
假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。
下面是常见处理器允许的重排序类型的列表:
Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 |
---|---|---|---|---|
sparc-TSO | N | N | N | Y |
x86 | N | N | N | Y |
ia64 | Y | Y | Y | Y |
PowerPC | Y | Y | Y | Y |
上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。
从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。
- ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
- ※注 2:上表中的 x86 包括 x64 及 AMD64。
- ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
- ※注 4:数据依赖性后文会专门说明。
为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,先于 Load2 及所有后续装载指令的装载。 |
StoreStore | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),先于 Store2 及所有后续存储指令的存储。 |
LoadStore | Load1; LoadStore; Store2 | 确保 Load1 数据装载,先于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),先于 Load2 及所有后续装载指令的装载。 |
- StoreLoad 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
- StoreLoad 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
happens-before
从 JDK5 开始,java 使用新的 JSR -133 内存模型。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens- before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。
happens-before 与 JMM 的关系如下图所示:
如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。
重排序
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial 语义
as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三个操作的数据依赖关系如下图所示:
如上图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序:
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
程序顺序规则
根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。
这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。在第一章提到过,如果 A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。
重排序对多线程的影响
现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?
答案是:不一定能看到。
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果? 请看下面的程序执行时序图:
如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!
※注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。
下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:
在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。
从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
顺序一致性
数据竞争与顺序一致性保证
当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:
- 在一个线程中写一个变量,
- 在另一个线程读同一个变量,
- 而且写和读没有通过同步来排序。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
- 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)– 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(马上我们将会看到,这对于程序员来说是一个极强的保证)。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。
顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。 +(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读 / 写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 / 写操作串行化。
为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。
假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
假设这两个线程使用监视器来正确同步:A 线程的三个操作执行后释放监视器,随后 B 线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。
同步程序的顺序一致性效果
下面我们对前面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具有顺序一致性。
请看下面的示例代码:
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() {
a = 1;
flag = true;
}
public synchronized void reader() {
if (flag) {
int i = a;
……
}
}
}
上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。
和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
- JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。
第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。下面让我们通过一个示意图来说明总线的工作机制:
如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。
在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。
当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:
如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A“写了一半“的无效值。
总结
处理器内存模型
顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:
- 放松程序中写 - 读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
- 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
- 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。
注意,这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。
下面的表格展示了常见处理器内存模型的细节特征:
内存模型名称 | 对应的处理器 | Store-Load 重排序 | Store-Store 重排序 | Load-Load 和 Load-Store 重排序 | 可以更早读取到其它处理器的写 | 可以更早读取到当前处理器的写 |
---|---|---|---|---|---|---|
TSO | sparc-TSO X64 | Y | Y | |||
PSO | sparc-PSO | Y | Y | Y | ||
RMO | ia64 | Y | Y | Y | Y | |
PowerPC | PowerPC | Y | Y | Y | Y | Y |
在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序,原因在第一章以说明过:它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。
上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:
如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。
JMM,处理器内存模型与顺序一致性内存模型之间的关系
JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:
从上图我们可以看出:常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。
JMM 的设计
从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。下面让我们看看 JSR-133 是如何实现这一目标的。
为了具体说明,请看前面提到过的计算圆面积的示例代码
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面计算圆的面积的示例代码存在三个 happens- before 关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
由于 A happens- before B,happens- before 的定义会要求:A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。 但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。也就是说,上面这 3 个 happens- before 关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM 对这两种不同性质的重排序,采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。
下面是 JMM 的设计示意图:
从上图可以看出两点:
- JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens- before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
- JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
JMM 的内存可见性保证
Java 程序的内存可见性保证按程序类型可以分为下列三类:
- 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:
只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。
JSR-133 对旧内存模型的修补
JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:
- 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。
- 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。
1.7 - CH07-JVM垃圾回收
判断对象是否可被回收
引用计数法
给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。
正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
可达性分析
通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。
Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
方法区回收
因为方法区主要存放永久代(元空间)对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:
- 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。
finalize
finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。
引用类型
无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java 具有四种强度不同的引用类型。
强引用
被强引用关联的对象不会被回收。
使用 new 一个新对象的方式来创建强引用。
软引用
被软引用关联的对象只有在内存不够的情况下才会被回收。
使用 SoftReference 类来创建软引用。
弱引用
被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
使用 WeakReference 类来实现弱引用。
虚引用
又称为幽灵引用或者幻影引用。
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。
使用 PhantomReference 来实现虚引用。
回收算法
标记-清除
将存活的对象进行标记,然后清理掉未被标记的对象。
不足:
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
标记-整理
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
复制
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要不足是只使用了内存的一半。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。
分代收集
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代使用: 复制算法
- 老年代使用: 标记 - 清除 或者 标记 - 整理 算法
垃圾收集器
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
- 单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
- 串行与并行:
- 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;
- 并形指的是垃圾收集器和用户程序同时执行。
- 除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
Serial 收集器
Serial 翻译为串行,也就是说它以串行的方式执行。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作。
它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。
Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。
ParNew 收集器
它是 Serial 收集器的多线程版本。
是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。
Parallel Scavenge 收集器
与 ParNew 一样是多线程收集器。
其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
缩短停顿时间是以牺牲吞吐量和新生代空间来换取的: 新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
可以通过一个开关参数打卡 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 。
Serial Old 收集器
是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
CMS 收集器
CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。
分为以下四个流程:
- 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除: 不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
具有以下缺点:
- 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
G1 收集器
G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记: 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收: 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
具备如下特点:
- 空间整合: 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
内存分配与回收策略
Minor GC 与 Full GC
Minor GC: 发生在新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
Full GC: 发生在老年代上,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
内存分配策略
1. 对象优先在 Eden 分配
- 大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。
2. 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。
3. 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold 用来定义年龄的阈值。
4. 动态对象年龄判定
- 虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
5. 空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。
Full GC 触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
1. 调用 System.gc()
- 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
2. 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。
除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。
还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
3. 空间分配担保失败
- 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
4. JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
5. Concurrent Mode Failure
- 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
1.8 - CH08-JVM-G1
G1垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方在ZGC还没有出现时也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。
概述
G1是一个分代的,增量的,并行与并发的标记-复制垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低停顿时间(pause time),同时兼顾良好的吞吐量。
G1回收器和CMS比起来,有以下不同:
- G1垃圾回收器是 compacting 的,因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。如需要更大的堆空间,更多的floating garbage。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用bump-the-pointer的方式;
- G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region,每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的;
- G1还有一个及其重要的特性:软实时(soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。
G1 内存模型
分区概念
分区 Region
G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。
卡片 Card
在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
堆 Heap
G1同样可以通过-Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。
分代模型
分代垃圾收集
分代垃圾收集可以将关注点集中在最近被分配的对象上,而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。虽然分区使得内存分配不再要求紧凑的内存空间,但G1依然使用了分代的思想。与其他垃圾收集器类似,G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。
整个年轻代内存会在初始空间-XX:G1NewSizePercent
(默认整堆5%)与最大空间(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis
(默认200ms)、需要扩缩容的大小以-XX:G1MaxNewSizePercent
及分区的已记忆集合(RSet)计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。
本地分配缓冲 Local allocation buffer (Lab)
值得注意的是,由于分区的思想,每个线程均可以"认领"某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。
- 其中,应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;
- 而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;
- 对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。
分区模型
G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。
巨形对象 Humongous Region
一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。
巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受TLab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
已记忆集合Remember Set (RSet)
在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。
事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。
Per Region Table (PRT)
RSet在内部使用Per Region Table(PRT)记录分区的引用情况。由于RSet的记录要占用分区的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:
- 稀少:直接记录引用对象的卡片索引
- 细粒度:记录引用对象的分区索引
- 粗粒度:只记录引用情况,每个分区对应一个比特位
由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。
收集集合 (CSet)
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
年轻代收集集合 CSet of Young Collection
应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。
同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio(默认50%)、最大任期阈值-XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
混合收集集合 CSet of Mixed Collection
年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。
为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。
并发标记算法(三色标记法)
CMS和G1在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。白色是未标记;灰色自身被标记,引用的对象未标记;黑色自身与引用对象都已标记。
GC 开始前所有对象都是白色,GC 一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色。然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色。当 GC 结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾。
漏标问题
在remark过程中,黑色指向了白色,如果不对黑色重新扫描,则会漏标。会把白色D对象当作没有新引用指向从而回收掉。
并发标记过程中,Mutator删除了所有从灰色到白色的引用,会产生漏标。此时白色对象应该被回收
产生漏标问题的条件有两个:
- 黑色对象指向了白色对象
- 灰色对象指向白色对象的引用消失
所以要解决漏标问题,打破两个条件之一即可:
- 跟踪黑指向白的增加 incremental update:增量更新,关注引用的增加,把黑色重新标记为灰色,下次重新扫描属性。CMS采用该方法。
- 记录灰指向白的消失 SATB snapshot at the beginning:关注引用的删除,当灰–>白消失时,要把这个 引用 推到GC的堆栈,保证白还能被GC扫描到。G1采用该方法。
为什么G1采用SATB而不用incremental update?
因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。
也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB配合RSet浑然天成。
G1 的活动周期
G1 垃圾收集活动汇总
RSet 的维护
由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)。
栅栏Barrier
我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。
写前栅栏 Pre-Write Barrrier
即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。
写后栅栏 Post-Write Barrrier
当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。
起始快照算法Snapshot at the beginning (SATB)
Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。
SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。
并发优化线程Concurrence Refinement Threads
G1中使用基于Urs Hölzle的快速写栅栏,将栅栏开销缩减到2个额外的指令。栅栏将会更新一个card table type的结构来跟踪代间引用。
当赋值语句发生后,写后栅栏会先通过G1的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与SATB类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。
并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads
(默认等于-XX:ParellelGCThreads
)设置。并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone
,G1会用分层的方式调度,使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。
并发标记周期 Concurrent Marking Cycle
并发标记周期是G1中非常重要的阶段,这个阶段将会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。
当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent
(老年代占整堆比,默认45%)时,便会触发并发标记周期。整个并发标记周期将由初始标记(Initial Mark)、根分区扫描(Root Region Scanning)、并发标记(Concurrent Marking)、重新标记(Remark)、清除(Cleanup)几个阶段组成。其中,初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。
并发标记线程 Concurrent Marking Threads
要标记存活的对象,每个分区都需要创建位图(Bitmap)信息来存储标记数据,来确定标记周期内被分配的对象。G1采用了两个位图Previous Bitmap、Next Bitmap,来存储标记数据,Previous位图存储上次的标记数据,Next位图在标记周期内不断变化更新,同时Previous位图的标记数据也越来越过时,当标记周期结束后Next位图便替换Previous位图,成为上次标记的位图。同时,每个分区通过顶部开始标记(TAMS),来记录已标记过的内存范围。同样的,G1使用了两个顶部开始标记Previous TAMS(PTAMS)、Next TAMS(NTAMS),记录已标记的范围。
在并发标记阶段,G1会根据参数-XX:ConcGCThreads
(默认GC线程数的1/4,即-XX:ParallelGCThreads/4
),分配并发标记线程(Concurrent Marking Threads),进行标记活动。每个并发线程一次只扫描一个分区,并通过"手指"指针的方式优化获取分区。并发标记线程是爆发式的,在给定的时间段拼命干活,然后休息一段时间,再拼命干活。
每个并发标记周期,在初始标记STW的最后,G1会分配一个空的Next位图和一个指向分区顶部(Top)的NTAMS标记。Previous位图记录的上次标记数据,上次的标记位置,即PTAMS,在PTAMS与分区底部(Bottom)的范围内,所有的存活对象都已被标记。那么,在PTAMS与Top之间的对象都将是隐式存活(Implicitly Live)对象。在并发标记阶段,Next位图吸收了Previous位图的标记数据,同时每个分区都会有新的对象分配,则Top与NTAMS分离,前往更高的地址空间。在并发标记的一次标记中,并发标记线程将找出NTAMS与PTAMS之间的所有存活对象,将标记数据存储在Next位图中。同时,在NTAMS与Top之间的对象即成为已标记对象。如此不断地更新Next位图信息,并在清除阶段与Previous位图交换角色。
初始标记 Initial Mark
初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
根分区扫描 Root Region Scanning
在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
并发标记 Concurrent Marking
和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads
(默认GC线程数的1/4,即-XX:ParallelGCThreads/4
)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark
会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。
存活数据计算 Live Data Accounting
存活数据计算(Live Data Accounting)是标记操作的附加产物,只要一个对象被标记,同时会被计算字节数,并计入分区空间。只有NTAMS以下的对象会被标记和计算,在标记周期的最后,Next位图将被清空,等待下次标记周期。
重新标记 Remark
重新标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
清除 Cleanup
紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行以下操作:
- RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。参数
-XX:+PrintAdaptiveSizePolicy
可以开启打印启发式算法决策细节; - 整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;
- 识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。
年轻代收集/混合收集周期
年轻代收集和混合收集周期,是G1回收空间的主要活动。当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent
(老年代占整堆比,默认45%)时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区,前文已述。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)。
GC工作线程数
GC工作线程数 -XX:ParallelGCThreads
JVM可以通过参数-XX:ParallelGCThreads
进行指定GC工作的线程数量。参数-XX:ParallelGCThreads
默认值并不是固定的,而是根据当前的CPU资源进行计算。如果用户没有指定,且CPU小于等于8,则默认与CPU核数相等;若CPU大于8,则默认JVM会经过计算得到一个小于CPU核数的线程数;当然也可以人工指定与CPU核数相等。
年轻代收集 Young Collection
每次收集过程中,既有并行执行的活动,也有串行执行的活动,但都可以是多线程的。在并行执行的任务中,如果某个任务过重,会导致其他线程在等待某项任务的处理,需要对这些地方进行优化。
并行活动
外部根分区扫描 Ext Root Scanning
:此活动对堆外的根(JVM系统目录、VM数据结构、JNI线程句柄、硬件寄存器、全局变量、线程对栈根)进行扫描,发现那些没有加入到暂停收集集合CSet中的对象。如果系统目录(单根)拥有大量加载的类,最终可能其他并行活动结束后,该活动依然没有结束而带来的等待时间。更新已记忆集合 Update RS
:并发优化线程会对脏卡片的分区进行扫描更新日志缓冲区来更新RSet,但只会处理全局缓冲列表。作为补充,所有被记录但是还没有被优化线程处理的剩余缓冲区,会在该阶段处理,变成已处理缓冲区(Processed Buffers)。为了限制花在更新RSet的时间,可以设置暂停占用百分比-XX:G1RSetUpdatingPauseTimePercent(默认10%,即-XX:MaxGCPauseMills/10)。值得注意的是,如果更新日志缓冲区更新的任务不降低,单纯地减少RSet的更新时间,会导致暂停中被处理的缓冲区减少,将日志缓冲区更新工作推到并发优化线程上,从而增加对Java应用线程资源的争夺。RSet扫描 Scan RS
:在收集当前CSet之前,考虑到分区外的引用,必须扫描CSet分区的RSet。如果RSet发生粗化,则会增加RSet的扫描时间。开启诊断模式-XX:UnlockDiagnosticVMOptions后,通过参数-XX:+G1SummarizeRSetStats可以确定并发优化线程是否能够及时处理更新日志缓冲区,并提供更多的信息,来帮助为RSet粗化总数提供窗口。参数-XX:G1SummarizeRSetStatsPeriod=n可设置RSet的统计周期,即经历多少此GC后进行一次统计代码根扫描 Code Root Scanning
:对代码根集合进行扫描,扫描JVM编译后代码Native Method的引用信息(nmethod扫描),进行RSet扫描。事实上,只有CSet分区中的RSet有强代码根时,才会做nmethod扫描,查找对CSet的引用。转移和回收 Object Copy
:通过选定的CSet以及CSet分区完整的引用集,将执行暂停时间的主要部分:CSet分区存活对象的转移、CSet分区空间的回收。通过工作窃取机制来负载均衡地选定复制对象的线程,并且复制和扫描对象被转移的存活对象将拷贝到每个GC线程分配缓冲区GCLAB。G1会通过计算,预测分区复制所花费的时间,从而调整年轻代的尺寸。终止 Termination
:完成上述任务后,如果任务队列已空,则工作线程会发起终止要求。如果还有其他线程继续工作,空闲的线程会通过工作窃取机制尝试帮助其他线程处理。而单独执行根分区扫描的线程,如果任务过重,最终会晚于终止。GC外部的并行活动 GC Worker Other
:该部分并非GC的活动,而是JVM的活动导致占用了GC暂停时间(例如JNI编译)。
串行活动
代码根更新 Code Root Fixup
:根据转移对象更新代码根。代码根清理 Code Root Purge
:清理代码根集合表。清除全局卡片标记 Clear CT
:在任意收集周期会扫描CSet与RSet记录的PRT,扫描时会在全局卡片表中进行标记,防止重复扫描。在收集周期的最后将会清除全局卡片表中的已扫描标志。选择下次收集集合 Choose CSet
:该部分主要用于并发标记周期后的年轻代收集、以及混合收集中,在这些收集过程中,由于有老年代候选分区的加入,往往需要对下次收集的范围做出界定;但单纯的年轻代收集中,所有收集的分区都会被收集,不存在选择。引用处理 Ref Proc
:主要针对软引用、弱引用、虚引用、final引用、JNI引用。当Ref Proc占用时间过多时,可选择使用参数-XX:ParallelRefProcEnabled
激活多线程引用处理。G1希望应用能小心使用软引用,因为软引用会一直占据内存空间直到空间耗尽时被Full GC回收掉;即使未发生Full GC,软引用对内存的占用,也会导致GC次数的增加。引用排队 Ref Enq
:此项活动可能会导致RSet的更新,此时会通过记录日志,将关联的卡片标记为脏卡片。卡片重新脏化 Redirty Cards
:重新脏化卡片。回收空闲巨型分区 Humongous Reclaim
:G1做了一个优化:通过查看所有根对象以及年轻代分区的RSet,如果确定RSet中巨型对象没有任何引用,则说明G1发现了一个不可达的巨型对象,该对象分区会被回收。释放分区 Free CSet
:回收CSet分区的所有空间,并加入到空闲分区中。其他活动 Other
:GC中可能还会经历其他耗时很小的活动,如修复JNI句柄等。
并发标记周期后的年轻代收集 Young Collection Following Concurrent Marking Cycle
当G1发起并发标记周期之后,并不会马上开始混合收集。G1会先等待下一次年轻代收集,然后在该收集阶段中,确定下次混合收集的CSet(Choose CSet)。
混合收集周期 Mixed Collection Cycle
单次的混合收集与年轻代收集并无二致。根据暂停目标,老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。
转移失败的担保机制 Full GC
转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。
G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:
- 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
- 从老年代分区转移存活对象时,无法找到可用的空闲分区
- 分配巨型对象时在老年代无法找到足够的连续分区
由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。
总结
G1是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的GC暂停目标,就能得到不错的性能;同时,我们也看到G1对内存空间的浪费较高,但通过首先收集尽可能多的垃圾(Garbage First)的设计原则,可以及时发现过期对象,从而让内存占用处于合理的水平。
虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。
- G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
- G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
- G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
- G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
1.9 - CH09-JVM-ZGC
1.10 - CH10-JVM调优参数
JVM 参数
-Xms
:堆的最小值-Xmx
:堆的最大值- 通常与
-Xms
相同以避免空间不足时动态扩容带来的影响
- 通常与
-Xmn
:新生代大小-Xss
:每个线程的堆栈大小- JDK 5 以上每个每个线程堆栈为 1M,之前是 256KB
- 相同物理内存下,减少该值可以增加线程数量,但仍受 OS 限制
-XX:NewRatio
:新生代与老年代比值- 4 表示新生代比老年代为
1:4
,新生代占堆的 1/5,与-Xmn
作用类似
- 4 表示新生代比老年代为
-XX:Persize
:元空间初始值,默认是物理空间的 1/64-XX:MaxPermSize
:元空间最大值,默认是物理空间 1/4-XX:MaxTenuringThreshold
:新生代对象年龄,默认 15- 若对象在伊甸园,经过 Minor GC 还活着,则年龄 +1,移动到幸存区
- 如果在幸存区,每次 Minor GC 还活着,年龄+1
- 到达 15 或移动到老年代
-XX:SurvivorRatio
:伊甸园与幸存区的比值,默认为 8- 两个幸存区与一个伊甸园的比值为
2:8
- 每个幸存区占新生代的 1/10
- 两个幸存区与一个伊甸园的比值为
-XX:+UseFastAccessorMethods
:原始类型快速优化-XX:+AggressiveOpts
:编译速度加快-XX:PretenureSizeThreshold
:大对象直接分配在老年代的阈值
实践
- 整个堆大小的计算公式: JVM 堆大小 = 年轻代大小+年老代大小+元空间大小。
- 增大新生代大小就会减少对应的年老代大小,设置-Xmn值对系统性能影响较大,所以如果设置新生代大小的调整,则需要严格的测试调整。而新生代是用来存放新创建的对象,大小是随着堆大小增大和减少而有相应的变化,默认值是保持堆大小的十五分之一,-Xmn参数就是设置新生代的大小,也可以通过-XX:NewRatio来设置新生代与年老代的比例,java 官方推荐配置为3:8。
- 新生代的特点就是内存中的对象更新速度快,在短时间内容易产生大量的无用对象,如果在这个参数时就需要考虑垃圾回收器设置参数也需要调整。
- 推荐使用: 复制清除算法和并行收集器进行垃圾回收,而新生代的垃圾回收叫做初级回收。
- StackOverflowError和OutOfMemoryException。
- 当线程中的请求的栈的深度大于最大可用深度,就会抛出前者;
- 若内存空间不够,无法创建新的线程,则会抛出后者。
- 栈的大小直接决定了函数的调用最大深度,栈越大,函数嵌套可调用次数就越多。
经验值
- Xmn用于设置新生代的大小。过小会增加Minor GC频率,过大会减小老年代的大小。一般设为整个堆空间的1/4或1/3.
- XX:SurvivorRatio用于设置新生代中survivor空间(from/to)和eden空间的大小比例; XX:TargetSurvivorRatio表示,当经历Minor GC后,survivor空间占有量(百分比)超过它的时候,就会压缩进入老年代(当然,如果survivor空间不够,则直接进入老年代)。默认值为50%。
- 为了性能考虑,一开始尽量将新生代对象留在新生代,避免新生的大对象直接进入老年代。因为新生对象大部分都是短期的,这就造成了老年代的内存浪费,并且回收代价也高(Full GC发生在老年代和方法区Perm).
- 当Xms=Xmx,可以使得堆相对稳定,避免不停震荡
- 一般来说,MaxPermSize设为64MB可以满足绝大多数的应用了。若依然出现方法区溢出,则可以设为128MB。若128MB还不能满足需求,那么就应该考虑程序优化了,减少动态类的产生。
垃圾回收
垃圾回收算法 :
- 引用计数法: 会有循环引用的问题,古老的方法;
- Mark-Sweep: 标记清除。根可达判断,最大的问题是空间碎片(清除垃圾之后剩下不连续的内存空间);
- Copying: 复制算法。对于短命对象来说有用,否则需要复制大量的对象,效率低。如Java的新生代堆空间中就是使用了它(survivor空间的from和to区);
- Mark-Compact: 标记整理。对于老年对象来说有用,无需复制,不会产生内存碎片
GC考虑的指标
- 吞吐量: 应用耗时和实际耗时的比值;
- 停顿时间: 垃圾回收的时候,由于Stop the World,应用程序的所有线程会挂起,造成应用停顿。
- 吞吐量和停顿时间是互斥的。
- 对于后端服务(比如后台计算任务),吞吐量优先考虑(并行垃圾回收);
- 对于前端应用,RT响应时间优先考虑,减少垃圾收集时的停顿时间,适用场景是Web系统(并发垃圾回收)
垃圾收集器参数
-XX:+UseSerialGC
:串行垃圾回收,现在基本很少使用。-XX:+UseParNewGC
:新生代使用并行,老年代使用串行;-XX:+UseConcMarkSweepGC
:新生代使用并行,老年代使用CMS- CMS是Concurrent Mark Sweep的缩写,并发标记清除,一看就是老年代的算法,所以,它可以作为老年代的垃圾回收器。
- CMS不是独占式的,它关注停顿时间
-XX:ParallelGCThreads
:指定并行的垃圾回收线程的数量,最好等于CPU数量-XX:+DisableExplicitGC
:禁用System.gc(),因为它会触发Full GC,这是很浪费性能的,JVM会在需要GC的时候自己触发GC。-XX:CMSFullGCsBeforeCompaction
:在多少次GC后进行内存压缩,这个是因为并行收集器不对内存空间进行压缩的,所以运行一段时间后会产生很多碎片,使得运行效率降低。-XX:+CMSParallelRemarkEnabled
:降低标记停顿-XX:+UseCMSCompactAtFullCollection
:在每一次Full GC时对老年代区域碎片整理,因为CMS是不会移动内存的,因此会非常容易出现碎片导致内存不够用的-XX:+UseCmsInitiatingOccupancyOnly
:使用手动触发或者自定义触发cms 收集,同时也会禁止hostspot 自行触发CMS GC-XX:CMSInitiatingOccupancyFraction
:使用CMS作为垃圾回收,使用70%后开始CMS收集-XX:CMSInitiatingPermOccupancyFraction
:设置perm gen使用达到多少%比时触发垃圾回收,默认是92%-XX:+CMSIncrementalMode
:设置为增量模式-XX:+CmsClassUnloadingEnabled
:CMS是不会默认对永久代进行垃圾回收的,设置此参数则是开启-XX:+PrintGCDetails
:开启详细GC日志模式,日志的格式是和所使用的算法有关-XX:+PrintGCDateStamps
将时间和日期也加入到GC日志中
JDK 默认收集器
java -XX:+PrintCommandLineFlags -version
查看当前 Java 的启动参数
- Java 7 - Parallel GC
- Java 8 - Parallel GC
- 年轻代:Parallel Scavenge
- 老年代:Parellel Old
- Java 9 - G1 GC
- Java 10 - G1 GC
- Java 11 - G1 GC
1.11 - CH11-JVM-OOM
堆内存溢出
在 Java 堆中只要不断的创建对象,并且
GC-Roots
到对象之间存在引用链,这样JVM
就不会回收对象。只要将
-Xms(最小堆)
,-Xmx(最大堆)
设置为一样禁止自动扩展堆内存。当使用一个
while(true)
循环来不断创建对象就会发生OutOfMemory
,还可以使用-XX:+HeapDumpOutofMemoryErorr
当发生 OOM 时会自动 dump 堆栈到文件中。当出现 OOM 时可以通过工具(如 JProfiler)来分析
GC-Roots
,查看对象和GC-Roots
是如何进行关联的,是否存在对象的生命周期过长,或者是这些对象确实改存在的,那就要考虑将堆内存调大了。
元空间溢出
JDK8
中将永久代移除,使用MetaSpace
来保存类加载之后的类信息,字符串常量池也被移动到 Java 堆。
- JDK 8 中将类信息移到到了本地堆内存(Native Heap)中,将原有的永久代移动到了本地堆中成为
MetaSpace
,如果不指定该区域的大小,JVM 将会动态的调整。 - 可以使用
-XX:MaxMetaspaceSize=10M
来限制最大元数据。这样当不停的创建类时将会占满该区域并出现OOM
。
1.12 - CH12-JVM线程Dump
概览
什么是Thread Dump
每一个Java虚拟机都有及时生成所有线程在某一点状态的thread-dump的能力,虽然各个 Java虚拟机打印的thread dump略有不同,但是 大多都提供了当前活动线程的快照,及JVM中所有Java线程的堆栈跟踪信息,堆栈信息一般包含完整的类名及所执行的方法,如果可能的话还有源代码的行数。
Thread Dump特点
- 能在各种操作系统下使用;
- 能在各种Java应用服务器下使用;
- 能在生产环境下使用而不影响系统的性能;
- 能将问题直接定位到应用程序的代码行上;
Thread Dump 抓取
一般当服务器挂起,崩溃或者性能低下时,就需要抓取服务器的线程堆栈(Thread Dump)用于后续的分析。在实际运行中,往往一次 dump的信息,还不足以确认问题。为了反映线程状态的动态变化,需要接连多次做thread dump,每次间隔10-20s,建议至少产生三次 dump信息,如果每次 dump都指向同一个问题,我们才确定问题的典型性。
- 获取 Java 进程 PID:
jps -l
- 获取 Java 进程 Thread Dump:
jstack [-l] <pid> | tee -a jstack.log
Thread Dump 分析
信息结构
- 头部信息:时间,JVM信息
2011-11-02 19:05:06
Full thread dump Java HotSpot(TM) Server VM (16.3-b01 mixed mode):
- 线程INFO信息块:
1. "Timer-0" daemon prio=10 tid=0xac190c00 nid=0xaef in Object.wait() [0xae77d000]
# 线程名称:Timer-0;线程类型:daemon;优先级: 10,默认是5;
# JVM线程id:tid=0xac190c00,JVM内部线程的唯一标识(通过java.lang.Thread.getId()获取,通常用自增方式实现)。
# 对应系统线程id(NativeThread ID):nid=0xaef,和top命令查看的线程pid对应,不过一个是10进制,一个是16进制。(通过命令:top -H -p pid,可以查看该进程的所有线程信息)
# 线程状态:in Object.wait();
# 起始栈地址:[0xae77d000],对象的内存地址,通过JVM内存查看工具,能够看出线程是在哪儿个对象上等待;
2. java.lang.Thread.State: TIMED_WAITING (on object monitor)
3. at java.lang.Object.wait(Native Method)
4. -waiting on <0xb3885f60> (a java.util.TaskQueue) # 继续wait
5. at java.util.TimerThread.mainLoop(Timer.java:509)
6. -locked <0xb3885f60> (a java.util.TaskQueue) # 已经locked
7. at java.util.TimerThread.run(Timer.java:462)
Java thread statck trace:是上面2-7行的信息。到目前为止这是最重要的数据,Java stack trace提供了大部分信息来精确定位问题根源。
堆栈信息应该逆向解读:程序先执行的是第7行,然后是第6行,依次类推。
- locked <0xb3885f60> (a java.util.ArrayList)
- waiting on <0xb3885f60> (a java.util.ArrayList)
也就是说对象先上锁,锁住对象0xb3885f60,然后释放该对象锁,进入waiting状态。为啥会出现这样的情况呢?看看下面的java代码示例,就会明白:
synchronized(obj) {
.........
obj.wait();
.........
}
如上,线程的执行过程,先用 synchronized
获得了这个对象的 Monitor(对应于 locked <0xb3885f60>
)。当执行到 obj.wait()
,线程即放弃了 Monitor的所有权,进入 “wait set”队列(对应于 waiting on <0xb3885f60>
)。
在堆栈的第一行信息中,进一步标明了线程在代码级的状态,例如:
java.lang.Thread.State: TIMED_WAITING (parking)
参考线程的状态说明:
|blocked|
> This thread tried to enter asynchronized block, but the lock was taken by another thread. This thread isblocked until the lock gets released.
|blocked (on thin lock)|
> This is the same state asblocked, but the lock in question is a thin lock.
|waiting|
> This thread calledObject.wait() on an object. The thread will remain there until some otherthread sends a notification to that object.
|sleeping|
> This thread calledjava.lang.Thread.sleep().
|parked|
> This thread calledjava.util.concurrent.locks.LockSupport.park().
|suspended|
> The thread's execution wassuspended by java.lang.Thread.suspend() or a JVMTI agent call.
Thread 状态分析
线程的状态是一个很重要的东西,因此thread dump中会显示这些状态,通过对这些状态的分析,能够得出线程的运行状况,进而发现可能存在的问题。线程的状态在Thread.State这个枚举类型中定义:
public enum State
{
/**
* Thread state for a thread which has not yet started.
*/
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
* <ul>
* <li>{@link Object#wait() Object.wait} with no timeout</li>
* <li>{@link #join() Thread.join} with no timeout</li>
* <li>{@link LockSupport#park() LockSupport.park}</li>
* </ul>
*
* <p>A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called <tt>Object.wait()</tt>
* on an object is waiting for another thread to call
* <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
* that object. A thread that has called <tt>Thread.join()</tt>
* is waiting for a specified thread to terminate.
*/
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
* <ul>
* <li>{@link #sleep Thread.sleep}</li>
* <li>{@link Object#wait(long) Object.wait} with timeout</li>
* <li>{@link #join(long) Thread.join} with timeout</li>
* <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
* <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
* </ul>
*/
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
NEW:
每一个线程,在堆内存中都有一个对应的Thread对象。Thread t = new Thread();当刚刚在堆内存中创建Thread对象,还没有调用t.start()方法之前,线程就处在NEW状态。在这个状态上,线程与普通的java对象没有什么区别,就仅仅是一个堆内存中的对象。
RUNNABLE:
该状态表示线程具备所有运行条件,在运行队列中准备操作系统的调度,或者正在运行。 这个状态的线程比较正常,但如果线程长时间停留在在这个状态就不正常了,这说明线程运行的时间很长(存在性能问题),或者是线程一直得不得执行的机会(存在线程饥饿的问题)。
BLOCKED:
线程正在等待获取java对象的监视器(也叫内置锁),即线程正在等待进入由synchronized保护的方法或者代码块。synchronized用来保证原子性,任意时刻最多只能由一个线程进入该临界区域,其他线程只能排队等待。
WAITING:
处在该线程的状态,正在等待某个事件的发生,只有特定的条件满足,才能获得执行机会。而产生这个特定的事件,通常都是另一个线程。也就是说,如果不发生特定的事件,那么处在该状态的线程一直等待,不能获取执行的机会。比如:
A线程调用了obj对象的obj.wait()方法,如果没有线程调用obj.notify或obj.notifyAll,那么A线程就没有办法恢复运行; 如果A线程调用了LockSupport.park(),没有别的线程调用LockSupport.unpark(A),那么A没有办法恢复运行。 TIMED_WAITING:
J.U.C中很多与线程相关类,都提供了限时版本和不限时版本的API。TIMED_WAITING意味着线程调用了限时版本的API,正在等待时间流逝。当等待时间过去后,线程一样可以恢复运行。如果线程进入了WAITING状态,一定要特定的事件发生才能恢复运行;而处在TIMED_WAITING的线程,如果特定的事件发生或者是时间流逝完毕,都会恢复运行。
TERMINATED:
线程执行完毕,执行完run方法正常返回,或者抛出了运行时异常而结束,线程都会停留在这个状态。这个时候线程只剩下Thread对象了,没有什么用了。
关键状态分析
Wait on condition:The thread is either sleeping or waiting to be notified by another thread.
该状态说明它在等待另一个条件的发生,来把自己唤醒,或者干脆它是调用了 sleep(n)。
此时线程状态大致为以下几种:
- java.lang.Thread.State: WAITING (parking):一直等那个条件发生;
- java.lang.Thread.State: TIMED_WAITING (parking或sleeping):定时的,那个条件不到来,也将定时唤醒自己。
Waiting for Monitor Entry 和 in Object.wait()
:The thread is waiting to get the lock for an object (some other thread may be holding the lock). This happens if two or more threads try to execute synchronized code. Note that the lock is always for an object and not for individual methods.
在多线程的JAVA程序中,实现线程之间的同步,就要说说 Monitor。Monitor是Java中用以实现线程之间的互斥与协作的主要手段,它可以看成是对象或者Class的锁。每一个对象都有,也仅有一个 Monitor 。下面这个图,描述了线程和 Monitor之间关系,以及线程的状态转换图:
如上图,每个Monitor在某个时刻,只能被一个线程拥有,该线程就是 “ActiveThread”,而其它线程都是 “Waiting Thread”,分别在两个队列“Entry Set”和“Wait Set”里等候。在“Entry Set”中等待的线程状态是“Waiting for monitor entry”,而在“Wait Set”中等待的线程状态是“in Object.wait()”。
先看“Entry Set”里面的线程。我们称被 synchronized保护起来的代码段为临界区。当一个线程申请进入临界区时,它就进入了“Entry Set”队列。对应的 code就像:
synchronized(obj) {
.........
}
这时有两种可能性:
- 该 monitor不被其它线程拥有, Entry Set里面也没有其它等待线程。本线程即成为相应类或者对象的 Monitor的 Owner,执行临界区的代码。
- 该 monitor被其它线程拥有,本线程在 Entry Set队列中等待。
在第一种情况下,线程将处于 “Runnable”的状态,而第二种情况下,线程 DUMP会显示处于 “waiting for monitor entry”。如下:
"Thread-0" prio=10 tid=0x08222eb0 nid=0x9 waiting for monitor entry [0xf927b000..0xf927bdb8]
at testthread.WaitThread.run(WaitThread.java:39)
- waiting to lock <0xef63bf08> (a java.lang.Object)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
临界区的设置,是为了保证其内部的代码执行的原子性和完整性。但是因为临界区在任何时间只允许线程串行通过,这和我们多线程的程序的初衷是相反的。如果在多线程的程序中,大量使用 synchronized,或者不适当的使用了它,会造成大量线程在临界区的入口等待,造成系统的性能大幅下降。如果在线程 DUMP中发现了这个情况,应该审查源码,改进程序。
再看“Wait Set”里面的线程。当线程获得了 Monitor,进入了临界区之后,如果发现线程继续运行的条件没有满足,它则调用对象(一般就是被 synchronized 的对象)的 wait() 方法,放弃 Monitor,进入 “Wait Set”队列。只有当别的线程在该对象上调用了 notify() 或者 notifyAll(),“Wait Set”队列中线程才得到机会去竞争,但是只有一个线程获得对象的Monitor,恢复到运行态。在 “Wait Set”中的线程, DUMP中表现为: in Object.wait()。如下:
"Thread-1" prio=10 tid=0x08223250 nid=0xa in Object.wait() [0xef47a000..0xef47aa38]
at java.lang.Object.wait(Native Method)
- waiting on <0xef63beb8> (a java.util.ArrayList)
at java.lang.Object.wait(Object.java:474)
at testthread.MyWaitThread.run(MyWaitThread.java:40)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
综上,一般CPU很忙时,则关注runnable的线程,CPU很闲时,则关注waiting for monitor entry的线程。
JDK 5.0 的 Lock
上面提到如果 synchronized和 monitor机制运用不当,可能会造成多线程程序的性能问题。在 JDK 5.0中,引入了 Lock机制,从而使开发者能更灵活的开发高性能的并发多线程程序,可以替代以往 JDK中的 synchronized和 Monitor的 机制。但是,要注意的是,因为 Lock类只是一个普通类,JVM无从得知 Lock对象的占用情况,所以在线程 DUMP中,也不会包含关于 Lock的信息, 关于死锁等问题,就不如用 synchronized的编程方式容易识别。
关键状态示例
显示BLOCKED状态
package jstack;
public class BlockedState
{
private static Object object = new Object();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
synchronized (object)
{
long begin = System.currentTimeMillis();
long end = System.currentTimeMillis();
// 让线程运行5分钟,会一直持有object的监视器
while ((end - begin) <= 5 * 60 * 1000)
{
}
}
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
先获取object的线程会执行5分钟,这5分钟内会一直持有object的监视器,另一个线程无法执行处在BLOCKED状态:
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x1314 waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7a800 nid=0x1350 waiting for monitor entry [0x2833f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at jstack.BlockedState$1.run(BlockedState.java:17)
- waiting to lock <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x27d79400 nid=0x1338 runnable [0x282ef000]
java.lang.Thread.State: RUNNABLE
at jstack.BlockedState$1.run(BlockedState.java:22)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
通过thread dump可以看到:t2线程确实处在BLOCKED (on object monitor)。waiting for monitor entry 等待进入synchronized保护的区域。
显示WAITING状态
package jstack;
public class WaitingState
{
private static Object object = new Object();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
synchronized (object)
{
long begin = System.currentTimeMillis();
long end = System.currentTimeMillis();
// 让线程运行5分钟,会一直持有object的监视器
while ((end - begin) <= 5 * 60 * 1000)
{
try
{
// 进入等待的同时,会进入释放监视器
object.wait();
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x1734 waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7e000 nid=0x17f4 in Object.wait() [0x2833f000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x1cfcdc00> (a java.lang.Object)
at java.lang.Object.wait(Object.java:485)
at jstack.WaitingState$1.run(WaitingState.java:26)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x27d7d400 nid=0x17f0 in Object.wait() [0x282ef000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x1cfcdc00> (a java.lang.Object)
at java.lang.Object.wait(Object.java:485)
at jstack.WaitingState$1.run(WaitingState.java:26)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
可以发现t1和t2都处在WAITING (on object monitor),进入等待状态的原因是调用了in Object.wait()。通过J.U.C包下的锁和条件队列,也是这个效果,大家可以自己实践下。
显示TIMED_WAITING状态
package jstack;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TimedWaitingState
{
// java的显示锁,类似java对象内置的监视器
private static Lock lock = new ReentrantLock();
// 锁关联的条件队列(类似于object.wait)
private static Condition condition = lock.newCondition();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
// 加锁,进入临界区
lock.lock();
try
{
condition.await(5, TimeUnit.MINUTES);
} catch (InterruptedException e)
{
e.printStackTrace();
}
// 解锁,退出临界区
lock.unlock();
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x169c waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7d800 nid=0xc30 waiting on condition [0x2833f000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x1cfce5b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:196)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2116)
at jstack.TimedWaitingState$1.run(TimedWaitingState.java:28)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x280d0c00 nid=0x16e0 waiting on condition [0x282ef000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x1cfce5b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:196)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2116)
at jstack.TimedWaitingState$1.run(TimedWaitingState.java:28)
at java.lang.Thread.run(Thread.java:662)
可以看到t1和t2线程都处在java.lang.Thread.State: TIMED_WAITING (parking),这个parking代表是调用的JUC下的工具类,而不是java默认的监视器。
案例分析
CPU飙高,load高,响应很慢
- 一个请求过程中多次dump;
- 对比多次dump文件的runnable线程,如果执行的方法有比较大变化,说明比较正常。如果在执行同一个方法,就有一些问题了;
查找占用CPU最多的线程
用命令:top -H -p pid(pid为被测系统的进程号),找到导致CPU高的线程ID,对应thread dump信息中线程的nid,只不过一个是十进制,一个是十六进制;
在thread dump中,根据top命令查找的线程id,查找对应的线程堆栈信息;
CPU使用率不高但是响应很慢
进行dump,查看是否有很多thread struck在了i/o、数据库等地方,定位瓶颈原因;
请求无法响应
多次dump,对比是否所有的runnable线程都一直在执行相同的方法,是则有可能出现了死锁。
死锁
死锁经常表现为程序的停顿,或者不再响应用户的请求。从操作系统上观察,对应进程的CPU占用率为零,很快会从top或prstat的输出中消失。
比如在下面这个示例中,是个较为典型的死锁情况:
"Thread-1" prio=5 tid=0x00acc490 nid=0xe50 waiting for monitor entry [0x02d3f000
..0x02d3fd68]
at deadlockthreads.TestThread.run(TestThread.java:31)
- waiting to lock <0x22c19f18> (a java.lang.Object)
- locked <0x22c19f20> (a java.lang.Object)
"Thread-0" prio=5 tid=0x00accdb0 nid=0xdec waiting for monitor entry [0x02cff000
..0x02cff9e8]
at deadlockthreads.TestThread.run(TestThread.java:31)
- waiting to lock <0x22c19f20> (a java.lang.Object)
- locked <0x22c19f18> (a java.lang.Object)
在 JAVA 5中加强了对死锁的检测。线程 Dump中可以直接报告出 Java级别的死锁,如下所示:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x0003f334 (object 0x22c19f18, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x0003f314 (object 0x22c19f20, a java.lang.Object),
which is held by "Thread-1"
热锁
热锁,也往往是导致系统性能瓶颈的主要因素。其表现特征为:由于多个线程对临界区,或者锁的竞争,可能出现:
- 频繁的线程的上下文切换:从操作系统对线程的调度来看,当线程在等待资源而阻塞的时候,操作系统会将之切换出来,放到等待的队列,当线程获得资源之后,调度算法会将这个线程切换进去,放到执行队列中。
- 大量的系统调用:因为线程的上下文切换,以及热锁的竞争,或者临界区的频繁的进出,都可能导致大量的系统调用。
- 大部分CPU开销用在“系统态”:线程上下文切换,和系统调用,都会导致 CPU在 “系统态 ”运行,换而言之,虽然系统很忙碌,但是CPU用在 “用户态 ”的比例较小,应用程序得不到充分的 CPU资源。
- 随着CPU数目的增多,系统的性能反而下降。因为CPU数目多,同时运行的线程就越多,可能就会造成更频繁的线程上下文切换和系统态的CPU开销,从而导致更糟糕的性能。
上面的描述,都是一个 scalability(可扩展性)很差的系统的表现。从整体的性能指标看,由于线程热锁的存在,程序的响应时间会变长,吞吐量会降低。
那么,怎么去了解 “热锁 ”出现在什么地方呢?
一个重要的方法是 结合操作系统的各种工具观察系统资源使用状况,以及收集Java线程的DUMP信息,看线程都阻塞在什么方法上,了解原因,才能找到对应的解决方法。
JVM 重要线程
Attach Listener
Attach Listener 线程是负责接收到外部的命令,而对该命令进行执行的并把结果返回给发送者。通常我们会用一些命令去要求JVM给我们一些反馈信息,如:java -version、jmap、jstack等等。 如果该线程在JVM启动的时候没有初始化,那么,则会在用户第一次执行JVM命令时,得到启动。
Signal Dispatcher
前面提到Attach Listener线程的职责是接收外部JVM命令,当命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并且返回处理结果。signal dispather线程也是在第一次接收外部JVM命令时,进行初始化工作。
CompilerThread0
用来调用JITing,实时编译装卸class 。 通常,JVM会启动多个线程来处理这部分工作,线程名称后面的数字也会累加,例如:CompilerThread1。
Concurrent Mark-Sweep GC Thread
并发标记清除垃圾回收器(就是通常所说的CMS GC)线程, 该线程主要针对于老年代垃圾回收。ps:启用该垃圾回收器,需要在JVM启动参数中加上:-XX:+UseConcMarkSweepGC。
DestroyJavaVM
执行main()的线程,在main执行完后调用JNI中的 jni_DestroyJavaVM() 方法唤起DestroyJavaVM 线程,处于等待状态,等待其它线程(Java线程和Native线程)退出时通知它卸载JVM。每个线程退出时,都会判断自己当前是否是整个JVM中最后一个非deamon线程,如果是,则通知DestroyJavaVM 线程卸载JVM。
Finalizer Thread
这个线程也是在main线程之后创建的,其优先级为10,主要用于在垃圾收集前,调用对象的finalize()方法;关于Finalizer线程的几点:1) 只有当开始一轮垃圾收集时,才会开始调用finalize()方法;因此并不是所有对象的finalize()方法都会被执行;2) 该线程也是daemon线程,因此如果虚拟机中没有其他非daemon线程,不管该线程有没有执行完finalize()方法,JVM也会退出;3) JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收;4) JVM为什么要单独用一个线程来执行finalize()方法呢?如果JVM的垃圾收集线程自己来做,很有可能由于在finalize()方法中误操作导致GC线程停止或不可控,这对GC线程来说是一种灾难;
Low Memory Detector
这个线程是负责对可使用内存进行检测,如果发现可用内存低,分配新的内存空间。
Reference Handler
JVM在创建main线程后就创建Reference Handler线程,其优先级最高,为10,它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题 。
VM Thread
这个线程就比较牛b了,是JVM里面的线程母体,根据hotspot源码(vmThread.hpp)里面的注释,它是一个单个的对象(最原始的线程)会产生或触发所有其他的线程,这个单个的VM线程是会被其他线程所使用来做一些VM操作(如:清扫垃圾等)。
1.13 - CH13-JVM调试命令
文本操作
文本查找:grep
# 基本使用
grep yoursearchkeyword f.txt #文件查找
grep 'KeyWord otherKeyWord' f.txt cpf.txt #多文件查找, 含空格加引号
grep 'KeyWord' /home/admin -r -n #目录下查找所有符合关键字的文件
grep 'keyword' /home/admin -r -n -i # -i 忽略大小写
grep 'KeyWord' /home/admin -r -n --include *.{vm,java} #指定文件后缀
grep 'KeyWord' /home/admin -r -n --exclude *.{vm,java} #反匹配
# cat + grep
cat f.txt | grep -i keyword # 查找所有keyword且不分大小写
cat f.txt | grep -c 'KeyWord' # 统计Keyword次数
# seq + grep
seq 10 | grep 5 -A 3 #上匹配
seq 10 | grep 5 -B 3 #下匹配
seq 10 | grep 5 -C 3 #上下匹配,平时用这个就妥了
参数解释:
--color=auto:显示颜色;
-i, --ignore-case:忽略字符大小写;
-o, --only-matching:只显示匹配到的部分;
-n, --line-number:显示行号;
-v, --invert-match:反向显示,显示未匹配到的行;
-E, --extended-regexp:支持使用扩展的正则表达式;
-q, --quiet, --silent:静默模式,即不输出任何信息;
-w, --word-regexp:整行匹配整个单词;
-c, --count:统计匹配到的行数; print a count of matching lines;
-B, --before-context=NUM:print NUM lines of leading context 后#行
-A, --after-context=NUM:print NUM lines of trailing context 前#行
-C, --context=NUM:print NUM lines of output context 前后各#行
文本分析:awk
# 基本使用
awk '{print $4,$6}' f.txt
awk '{print NR,$0}' f.txt cpf.txt
awk '{print FNR,$0}' f.txt cpf.txt
awk '{print FNR,FILENAME,$0}' f.txt cpf.txt
awk '{print FILENAME,"NR="NR,"FNR="FNR,"$"NF"="$NF}' f.txt cpf.txt
echo 1:2:3:4 | awk -F: '{print $1,$2,$3,$4}'
# 匹配
awk '/ldb/ {print}' f.txt #匹配ldb
awk '!/ldb/ {print}' f.txt #不匹配ldb
awk '/ldb/ && /LISTEN/ {print}' f.txt #匹配ldb和LISTEN
awk '$5 ~ /ldb/ {print}' f.txt #第五列匹配ldb
内建变量:
NR
: NR表示从awk开始执行后,按照记录分隔符读取的数据次数,默认的记录分隔符为换行符,因此默认的就是读取的数据行数,NR可以理解为Number of Record的缩写。FNR
: 在awk处理多个输入文件的时候,在处理完第一个文件后,NR并不会从1开始,而是继续累加,因此就出现了FNR,每当处理一个新文件的时候,FNR就从1开始计数,FNR可以理解为File Number of Record。NF
: NF表示目前的记录被分割的字段的数目,NF可以理解为Number of Field。
文本处理:sed
# 文本打印
sed -n '3p' xxx.log #只打印第三行
sed -n '$p' xxx.log #只打印最后一行
sed -n '3,9p' xxx.log #只查看文件的第3行到第9行
sed -n -e '3,9p' -e '=' xxx.log #打印3-9行,并显示行号
sed -n '/root/p' xxx.log #显示包含root的行
sed -n '/hhh/,/omc/p' xxx.log # 显示包含"hhh"的行到包含"omc"的行之间的行
# 文本替换
sed -i 's/root/world/g' xxx.log # 用world 替换xxx.log文件中的root; s==search 查找并替换, g==global 全部替换, -i: implace
# 文本插入
sed '1,4i hahaha' xxx.log # 在文件第一行和第四行的每行下面添加hahaha
sed -e '1i happy' -e '$a new year' xxx.log #【界面显示】在文件第一行添加happy,文件结尾添加new year
sed -i -e '1i happy' -e '$a new year' xxx.log #【真实写入文件】在文件第一行添加happy,文件结尾添加new year
# 文本删除
sed '3,9d' xxx.log # 删除第3到第9行,只是不显示而已
sed '/hhh/,/omc/d' xxx.log # 删除包含"hhh"的行到包含"omc"的行之间的行
sed '/omc/,10d' xxx.log # 删除包含"omc"的行到第十行的内容
# 与find结合
find . -name "*.txt" |xargs sed -i 's/hhhh/\hHHh/g'
find . -name "*.txt" |xargs sed -i 's#hhhh#hHHh#g'
find . -name "*.txt" -exec sed -i 's/hhhh/\hHHh/g' {} \;
find . -name "*.txt" |xargs cat
文件操作
文件监听:tail
# 基本使用
tail -f xxx.log # 循环监听文件
tail -300f xxx.log #倒数300行并追踪文件
tail +20 xxx.log #从第 20 行至文件末尾显示文件内容
# tailf使用
tailf xxx.log #等同于tail -f -n 10 打印最后10行,然后追踪文件
tail -f 与 tail F 与 tailf 三者区别:
tail -f
等于–follow=descriptor,根据文件描述进行追踪,当文件改名或删除后,停止追踪。tail -F
等于 –follow=name ==retry,根据文件名字进行追踪,当文件改名或删除后,保持重试,当有新的文件和他同名时,继续追踪tailf
等于tail -f -n 10(tail -f或-F默认也是打印最后10行,然后追踪文件),与tail -f不同的是,如果文件不增长,它不会去访问磁盘文件,所以tailf特别适合那些便携机上跟踪日志文件,因为它减少了磁盘访问,可以省电。
参数解释:
-f 循环读取
-q 不显示处理信息
-v 显示详细的处理信息
-c<数目> 显示的字节数
-n<行数> 显示文件的尾部 n 行内容
--pid=PID 与-f合用,表示在进程ID,PID死掉之后结束
-q, --quiet, --silent 从不输出给出文件名的首部
-s, --sleep-interval=S 与-f合用,表示在每次反复的间隔休眠S秒
文件查找:find
sudo -u admin find /home/admin /tmp /usr -name \*.log(多个目录去找)
find . -iname \*.txt(大小写都匹配)
find . -type d(当前目录下的所有子目录)
find /usr -type l(当前目录下所有的符号链接)
find /usr -type l -name "z*" -ls(符号链接的详细信息 eg:inode,目录)
find /home/admin -size +250000k(超过250000k的文件,当然+改成-就是小于了)
find /home/admin f -perm 777 -exec ls -l {} \; (按照权限查询文件)
find /home/admin -atime -1 1天内访问过的文件
find /home/admin -ctime -1 1天内状态改变过的文件
find /home/admin -mtime -1 1天内修改过的文件
find /home/admin -amin -1 1分钟内访问过的文件
find /home/admin -cmin -1 1分钟内状态改变过的文件
find /home/admin -mmin -1 1分钟内修改过的文件
pgm
批量查询vm-shopbase满足条件的日志:
pgm -A -f vm-shopbase 'cat /home/admin/shopbase/logs/shopbase.log.2017-01-17|grep 2069861630'
网络
查看所有网络接口的属性
ifconfig
查看防火墙设置
iptables -L
查看路由表
route -n
netstat
- 查看所有监听端口:
netstat -lntp
- 查看所有已经建立的连接:
netstat -antp
- 查看当前连接:
netstat -nat|awk '{print $6}'|sort|uniq -c|sort -rn
- 查看网络统计信息进程:
netstat -s
进程
查看所有进程
ps -ef | grep java
top
top -H -p pid
存储
内存用量
free -m
磁盘用量
df -h
目录大小
du -sh
内存总量
grep MemTotal /proc/meminfo
内存空闲
grep MemFree /proc/meminfo
系统负载
cat /proc/loadavg
挂载分区状态
mount | column -t
所有分区
fdisk -l
所有交换分区
swapon -s
硬盘大小
fdisk -l | grep Disk
用户
活动用户
w
指定用户
id
登录日志
last
所有用户
cut -d: -f1 /etc/passwd
所有用户组
cut -d: f1 /etc/group
查看服务
# 查看当前用户的计划任务服务
crontab -l
# 列出所有系统服务
chkconfig –list
# 列出所有启动的系统服务程序
chkconfig –list | grep on
# 查看所有安装的软件包
rpm -qa
# 列出加载的内核模块
lsmod
查看系统
# 常用
env # 查看环境变量资源
uptime # 查看系统运行时间、用户数、负载
lsusb -tv # 列出所有USB设备的linux系统信息命令
lspci -tv # 列出所有PCI设备
head -n 1 /etc/issue # 查看操作系统版本,是数字1不是字母L
uname -a # 查看内核/操作系统/CPU信息的linux系统信息命令
# /proc/
cat /proc/cpuinfo :查看CPU相关参数的linux系统命令
cat /proc/partitions :查看linux硬盘和分区信息的系统信息命令
cat /proc/meminfo :查看linux系统内存信息的linux系统命令
cat /proc/version :查看版本,类似uname -r
cat /proc/ioports :查看设备io端口
cat /proc/interrupts :查看中断
cat /proc/pci :查看pci设备的信息
cat /proc/swaps :查看所有swap分区的信息
cat /proc/cpuinfo |grep "model name" && cat /proc/cpuinfo |grep "physical id"
tsar
tsar是淘宝开源的的采集工具。很好用, 将历史收集到的数据持久化在磁盘上,所以我们快速来查询历史的系统数据。当然实时的应用情况也是可以查询的啦。大部分机器上都有安装。
tsar ##可以查看最近一天的各项指标
tsar --live ##可以查看实时指标,默认五秒一刷
tsar -d 20161218 ##指定查看某天的数据,貌似最多只能看四个月的数据
tsar --mem
tsar --load
tsar --cpu ##当然这个也可以和-d参数配合来查询某天的单个指标的情况
1.14 - CH14-JVM调试工具
基本工具
jps
jps是jdk提供的一个查看当前java进程的小工具, 可以看做是JavaVirtual Machine Process Status Tool的缩写。
常用命令:
jps # 显示进程的ID 和 类的名称
jps –l # 输出输出完全的包名,应用主类名,jar的完全路径名
jps –v # 输出jvm参数
jps –q # 显示java进程号
jps -m # main 方法
jps -l xxx.xxx.xx.xx # 远程查看
参数解释:
-q:仅输出VM标识符,不包括classname,jar name,arguments in main method
-m:输出main method的参数
-l:输出完全的包名,应用主类名,jar的完全路径名
-v:输出jvm参数
-V:输出通过flag文件传递到JVM中的参数(.hotspotrc文件或-XX:Flags=所指定的文件
-Joption:传递参数到vm,例如:-J-Xms512m
实现原理:
java程序在启动以后,会在java.io.tmpdir指定的目录下,就是临时文件夹里,生成一个类似于hsperfdata_User的文件夹,这个文件夹里(在Linux中为/tmp/hsperfdata_{userName}/),有几个文件,名字就是java进程的pid,因此列出当前运行的java进程,只是把这个目录里的文件名列一下而已。 至于系统的参数什么,就可以解析这几个文件获得。
jstack
jstack是jdk自带的线程堆栈分析工具,使用该命令可以查看或导出 Java 应用程序中线程堆栈信息。
jstack常用命令:
# 基本
jstack 2815
# java和native c/c++框架的所有栈信息
jstack -m 2815
# 额外的锁信息列表,查看是否死锁
jstack -l 2815
jstack参数:
-l 长列表. 打印关于锁的附加信息,例如属于java.util.concurrent 的 ownable synchronizers列表.
-F 当’jstack [-l] pid’没有相应的时候强制打印栈信息
-m 打印java和native c/c++框架的所有栈信息.
-h | -help 打印帮助信息
jinfo
jinfo 是 JDK 自带的命令,可以用来查看正在运行的 java 应用程序的扩展参数,包括Java System属性和JVM命令行参数;也可以动态的修改正在运行的 JVM 一些参数。当系统崩溃时,jinfo可以从core文件里面知道崩溃的Java应用程序的配置信息。
jinfo常用命令:
# 输出当前 jvm 进程的全部参数和系统属性
jinfo 2815
# 输出所有的参数
jinfo -flags 2815
# 查看指定的 jvm 参数的值
jinfo -flag PrintGC 2815
# 开启/关闭指定的JVM参数
jinfo -flag +PrintGC 2815
# 设置flag的参数
jinfo -flag name=value 2815
# 输出当前 jvm 进行的全部的系统属性
jinfo -sysprops 2815
jinfo参数:
no option 输出全部的参数和系统属性
-flag name 输出对应名称的参数
-flag [+|-]name 开启或者关闭对应名称的参数
-flag name=value 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性
jmap
命令jmap是一个多功能的命令。它可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。
两个用途
# 查看堆的情况
jmap -heap 2815
# dump
jmap -dump:live,format=b,file=/tmp/heap2.bin 2815
jmap -dump:format=b,file=/tmp/heap3.bin 2815
# 查看堆的占用
jmap -histo 2815 | head -10
jmap参数
no option: 查看进程的内存映像信息,类似 Solaris pmap 命令。
heap: 显示Java堆详细信息
histo[:live]: 显示堆中对象的统计信息
clstats:打印类加载器信息
finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
dump:<dump-options>:生成堆转储快照
F: 当-dump没有响应时,使用-dump或者-histo参数. 在这个模式下,live子参数无效.
help:打印帮助信息
J<flag>:指定传递给运行jmap的JVM的参数
jstat
jstat -gcutil 2815 1000
jdb
jdb可以用来预发debug,假设你预发的java_home是/opt/java/,远程调试端口是8000.那么
jdb -attach 8000
出现以上代表jdb启动成功。后续可以进行设置断点进行调试。
CHLSDB
实现 jstack和jmap的基础。
java -classpath /opt/taobao/java/lib/sa-jdi.jar sun.jvm.hotspot.CLHSDB
进阶工具
- btrace
- greys
- arthas
- javOSize
- JProfiler
- dmesg
- IDEA remote debugger
1.15 - CH15-JVM动态调试
动态调试要解决的问题
断点调试是我们最常使用的调试手段,它可以获取到方法执行过程中的变量信息,并可以观察到方法的执行路径。但断点调试会在断点位置停顿,使得整个应用停止响应。在线上停顿应用是致命的,动态调试技术给了我们创造新的调试模式的想象空间。本文将研究Java语言中的动态调试技术,首先概括Java动态调试所涉及的技术基础,接着介绍我们在Java动态调试领域的思考及实践,通过结合实际业务场景,设计并实现了一种具备动态性的断点调试工具Java-debug-tool,显著提高了故障排查效率。
Java Agent技术
JVMTI (JVM Tool Interface)是Java虚拟机对外提供的Native编程接口,通过JVMTI,外部进程可以获取到运行时JVM的诸多信息,比如线程、GC等。Agent是一个运行在目标JVM的特定程序,它的职责是负责从目标JVM中获取数据,然后将数据传递给外部进程。加载Agent的时机可以是目标JVM启动之时,也可以是在目标JVM运行时进行加载,而在目标JVM运行时进行Agent加载具备动态性,对于时机未知的Debug场景来说非常实用。下面将详细分析Java Agent技术的实现细节。
Agent的实现模式
JVMTI是一套Native接口,在Java SE 5之前,要实现一个Agent只能通过编写Native代码来实现。从Java SE 5开始,可以使用Java的Instrumentation接口(java.lang.instrument)来编写Agent。无论是通过Native的方式还是通过Java Instrumentation接口的方式来编写Agent,它们的工作都是借助JVMTI来进行完成,下面介绍通过Java Instrumentation接口编写Agent的方法。
通过Java Instrumentation API
- 实现Agent启动方法
Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数,如果需要在目标JVM启动的同时加载Agent,那么可以选择实现下面的方法:
[1] public static void premain(String agentArgs, Instrumentation inst);
[2] public static void premain(String agentArgs);
JVM将首先寻找 1,如果没有发现 1,再寻找 2。如果希望在目标JVM运行时加载Agent,则需要实现下面的方法:
[1] public static void agentmain(String agentArgs, Instrumentation inst);
[2] public static void agentmain(String agentArgs);
这两组方法的第一个参数AgentArgs是随同 “– javaagent”一起传入的程序参数,如果这个字符串代表了多个参数,就需要自己解析这些参数。inst是Instrumentation类型的对象,是JVM自动传入的,我们可以拿这个参数进行类增强等操作。
- 指定Main-Class
Agent需要打包成一个jar包,在ManiFest属性中指定“Premain-Class”或者“Agent-Class”:
Premain-Class: class
Agent-Class: class
- 挂载到目标JVM
将编写的Agent打成jar包后,就可以挂载到目标JVM上去了。如果选择在目标JVM启动时加载Agent,则可以使用 “-javaagent:[=
com.sun.tools.attach.VirtualMachine 这个类代表一个JVM抽象,可以通过这个类找到目标JVM,并且将Agent挂载到目标JVM上。下面是使用com.sun.tools.attach.VirtualMachine进行动态挂载Agent的一般实现:
private void attachAgentToTargetJVM() throws Exception {
List<VirtualMachineDescriptor> virtualMachineDescriptors = VirtualMachine.list();
VirtualMachineDescriptor targetVM = null;
for (VirtualMachineDescriptor descriptor : virtualMachineDescriptors) {
if (descriptor.id().equals(configure.getPid())) {
targetVM = descriptor;
break;
}
}
if (targetVM == null) {
throw new IllegalArgumentException("could not find the target jvm by process id:" + configure.getPid());
}
VirtualMachine virtualMachine = null;
try {
virtualMachine = VirtualMachine.attach(targetVM);
virtualMachine.loadAgent("{agent}", "{params}");
} catch (Exception e) {
if (virtualMachine != null) {
virtualMachine.detach();
}
}
}
首先通过指定的进程ID找到目标JVM,然后通过Attach挂载到目标JVM上,执行加载Agent操作。VirtualMachine的Attach方法就是用来将Agent挂载到目标JVM上去的,而Detach则是将Agent从目标JVM卸载。关于Agent是如何挂载到目标JVM上的具体技术细节,将在下文中进行分析。
启动时加载Agent
参数解析
创建JVM时,JVM会进行参数解析,即解析那些用来配置JVM启动的参数,比如堆大小、GC等;本文主要关注解析的参数为-agentlib、 -agentpath、 -javaagent,这几个参数用来指定Agent,JVM会根据这几个参数加载Agent。下面来分析一下JVM是如何解析这几个参数的。
// -agentlib and -agentpath
if (match_option(option, "-agentlib:", &tail) ||
(is_absolute_path = match_option(option, "-agentpath:", &tail))) {
if(tail != NULL) {
const char* pos = strchr(tail, '=');
size_t len = (pos == NULL) ? strlen(tail) : pos - tail;
char* name = strncpy(NEW_C_HEAP_ARRAY(char, len + 1, mtArguments), tail, len);
name[len] = '\0';
char *options = NULL;
if(pos != NULL) {
options = os::strdup_check_oom(pos + 1, mtArguments);
}
#if !INCLUDE_JVMTI
if (valid_jdwp_agent(name, is_absolute_path)) {
jio_fprintf(defaultStream::error_stream(),
"Debugging agents are not supported in this VM\n");
return JNI_ERR;
}
#endif // !INCLUDE_JVMTI
add_init_agent(name, options, is_absolute_path);
}
// -javaagent
} else if (match_option(option, "-javaagent:", &tail)) {
#if !INCLUDE_JVMTI
jio_fprintf(defaultStream::error_stream(),
"Instrumentation agents are not supported in this VM\n");
return JNI_ERR;
#else
if (tail != NULL) {
size_t length = strlen(tail) + 1;
char *options = NEW_C_HEAP_ARRAY(char, length, mtArguments);
jio_snprintf(options, length, "%s", tail);
add_init_agent("instrument", options, false);
// java agents need module java.instrument
if (!create_numbered_property("jdk.module.addmods", "java.instrument", addmods_count++)) {
return JNI_ENOMEM;
}
}
#endif // !INCLUDE_JVMTI
}
上面的代码片段截取自hotspot/src/share/vm/runtime/arguments.cpp中的 Arguments::parse_each_vm_init_arg(const JavaVMInitArgs* args, bool* patch_mod_javabase, Flag::Flags origin) 函数,该函数用来解析一个具体的JVM参数。这段代码的主要功能是解析出需要加载的Agent路径,然后调用add_init_agent函数进行解析结果的存储。下面先看一下add_init_agent函数的具体实现:
// -agentlib and -agentpath arguments
static AgentLibraryList _agentList;
static void add_init_agent(const char* name, char* options, bool absolute_path)
{ _agentList.add(new AgentLibrary(name, options, absolute_path, NULL)); }
AgentLibraryList是一个简单的链表结构,add_init_agent函数将解析好的、需要加载的Agent添加到这个链表中,等待后续的处理。
这里需要注意,解析-javaagent参数有一些特别之处,这个参数用来指定一个我们通过Java Instrumentation API来编写的Agent,Java Instrumentation API底层依赖的是JVMTI,对-JavaAgent的处理也说明了这一点,在调用add_init_agent函数时第一个参数是“instrument”,关于加载Agent这个问题在下一小节进行展开。到此,我们知道在启动JVM时指定的Agent已经被JVM解析完存放在了一个链表结构中。下面来分析一下JVM是如何加载这些Agent的。
执行加载操作
在创建JVM进程的函数中,解析完JVM参数之后,下面的这段代码和加载Agent相关:
// Launch -agentlib/-agentpath and converted -Xrun agents
if (Arguments::init_agents_at_startup()) {
create_vm_init_agents();
}
static bool init_agents_at_startup() {
return !_agentList.is_empty();
}
当JVM判断出上一小节中解析出来的Agent不为空的时候,就要去调用函数create_vm_init_agents来加载Agent,下面来分析一下create_vm_init_agents函数是如何加载Agent的。
void Threads::create_vm_init_agents() {
AgentLibrary* agent;
for (agent = Arguments::agents(); agent != NULL; agent = agent->next()) {
OnLoadEntry_t on_load_entry = lookup_agent_on_load(agent);
if (on_load_entry != NULL) {
// Invoke the Agent_OnLoad function
jint err = (*on_load_entry)(&main_vm, agent->options(), NULL);
}
}
}
create_vm_init_agents这个函数通过遍历Agent链表来逐个加载Agent。通过这段代码可以看出,首先通过lookup_agent_on_load来加载Agent并且找到Agent_OnLoad函数,这个函数是Agent的入口函数。如果没找到这个函数,则认为是加载了一个不合法的Agent,则什么也不做,否则调用这个函数,这样Agent的代码就开始执行起来了。对于使用Java Instrumentation API来编写Agent的方式来说,在解析阶段观察到在add_init_agent函数里面传递进去的是一个叫做”instrument”的字符串,其实这是一个动态链接库。在Linux里面,这个库叫做libinstrument.so,在BSD系统中叫做libinstrument.dylib,该动态链接库在{JAVA_HOME}/jre/lib/目录下。
instrument动态链接库
libinstrument用来支持使用Java Instrumentation API来编写Agent,在libinstrument中有一个非常重要的类称为:JPLISAgent(Java Programming Language Instrumentation Services Agent),它的作用是初始化所有通过Java Instrumentation API编写的Agent,并且也承担着通过JVMTI实现Java Instrumentation中暴露API的责任。
我们已经知道,在JVM启动的时候,JVM会通过-javaagent参数加载Agent。最开始加载的是libinstrument动态链接库,然后在动态链接库里面找到JVMTI的入口方法:Agent_OnLoad。下面就来分析一下在libinstrument动态链接库中,Agent_OnLoad函数是怎么实现的。
JNIEXPORT jint JNICALL
DEF_Agent_OnLoad(JavaVM *vm, char *tail, void * reserved) {
initerror = createNewJPLISAgent(vm, &agent);
if ( initerror == JPLIS_INIT_ERROR_NONE ) {
if (parseArgumentTail(tail, &jarfile, &options) != 0) {
fprintf(stderr, "-javaagent: memory allocation failure.\n");
return JNI_ERR;
}
attributes = readAttributes(jarfile);
premainClass = getAttribute(attributes, "Premain-Class");
/* Save the jarfile name */
agent->mJarfile = jarfile;
/*
* Convert JAR attributes into agent capabilities
*/
convertCapabilityAttributes(attributes, agent);
/*
* Track (record) the agent class name and options data
*/
initerror = recordCommandLineData(agent, premainClass, options);
}
return result;
}
上述代码片段是经过精简的libinstrument中Agent_OnLoad实现的,大概的流程就是:先创建一个JPLISAgent,然后将ManiFest中设定的一些参数解析出来, 比如(Premain-Class)等。创建了JPLISAgent之后,调用initializeJPLISAgent对这个Agent进行初始化操作。跟进initializeJPLISAgent看一下是如何初始化的:
JPLISInitializationError initializeJPLISAgent(JPLISAgent *agent, JavaVM *vm, jvmtiEnv *jvmtienv) {
/* check what capabilities are available */
checkCapabilities(agent);
/* check phase - if live phase then we don't need the VMInit event */
jvmtierror = (*jvmtienv)->GetPhase(jvmtienv, &phase);
/* now turn on the VMInit event */
if ( jvmtierror == JVMTI_ERROR_NONE ) {
jvmtiEventCallbacks callbacks;
memset(&callbacks, 0, sizeof(callbacks));
callbacks.VMInit = &eventHandlerVMInit;
jvmtierror = (*jvmtienv)->SetEventCallbacks(jvmtienv,&callbacks,sizeof(callbacks));
}
if ( jvmtierror == JVMTI_ERROR_NONE ) {
jvmtierror = (*jvmtienv)->SetEventNotificationMode(jvmtienv,JVMTI_ENABLE,JVMTI_EVENT_VM_INIT,NULL);
}
return (jvmtierror == JVMTI_ERROR_NONE)? JPLIS_INIT_ERROR_NONE : JPLIS_INIT_ERROR_FAILURE;
}
这里,我们关注callbacks.VMInit = &eventHandlerVMInit;这行代码,这里设置了一个VMInit事件的回调函数,表示在JVM初始化的时候会回调eventHandlerVMInit函数。下面来看一下这个函数的实现细节,猜测就是在这里调用了Premain方法:
void JNICALL eventHandlerVMInit( jvmtiEnv *jvmtienv,JNIEnv *jnienv,jthread thread) {
// ...
success = processJavaStart( environment->mAgent, jnienv);
// ...
}
jboolean processJavaStart(JPLISAgent *agent,JNIEnv *jnienv) {
result = createInstrumentationImpl(jnienv, agent);
/*
* Load the Java agent, and call the premain.
*/
if ( result ) {
result = startJavaAgent(agent, jnienv, agent->mAgentClassName, agent->mOptionsString, agent->mPremainCaller);
}
return result;
}
jboolean startJavaAgent( JPLISAgent *agent,JNIEnv *jnienv,const char *classname,const char *optionsString,jmethodID agentMainMethod) {
// ...
invokeJavaAgentMainMethod(jnienv,agent->mInstrumentationImpl,agentMainMethod, classNameObject,optionsStringObject);
// ...
}
看到这里,Instrument已经实例化,invokeJavaAgentMainMethod这个方法将我们的premain方法执行起来了。接着,我们就可以根据Instrument实例来做我们想要做的事情了。
运行时加载Agent
比起JVM启动时加载Agent,运行时加载Agent就比较有诱惑力了,因为运行时加载Agent的能力给我们提供了很强的动态性,我们可以在需要的时候加载Agent来进行一些工作。因为是动态的,我们可以按照需求来加载所需要的Agent,下面来分析一下动态加载Agent的相关技术细节。
AttachListener
Attach机制通过Attach Listener线程来进行相关事务的处理,下面来看一下Attach Listener线程是如何初始化的。
// Starts the Attach Listener thread
void AttachListener::init() {
// 创建线程相关部分代码被去掉了
const char thread_name[] = "Attach Listener";
Handle string = java_lang_String::create_from_str(thread_name, THREAD);
{ MutexLocker mu(Threads_lock);
JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);
// ...
}
}
我们知道,一个线程启动之后都需要指定一个入口来执行代码,Attach Listener线程的入口是attach_listener_thread_entry,下面看一下这个函数的具体实现:
static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
AttachListener::set_initialized();
for (;;) {
AttachOperation* op = AttachListener::dequeue();
// find the function to dispatch too
AttachOperationFunctionInfo* info = NULL;
for (int i=0; funcs[i].name != NULL; i++) {
const char* name = funcs[i].name;
if (strcmp(op->name(), name) == 0) {
info = &(funcs[i]); break;
}}
// dispatch to the function that implements this operation
res = (info->func)(op, &st);
//...
}
}
整个函数执行逻辑,大概是这样的:
- 拉取一个需要执行的任务:AttachListener::dequeue。
- 查询匹配的命令处理函数。
- 执行匹配到的命令执行函数。
其中第二步里面存在一个命令函数表,整个表如下:
static AttachOperationFunctionInfo funcs[] = {
{ "agentProperties", get_agent_properties },
{ "datadump", data_dump },
{ "dumpheap", dump_heap },
{ "load", load_agent },
{ "properties", get_system_properties },
{ "threaddump", thread_dump },
{ "inspectheap", heap_inspection },
{ "setflag", set_flag },
{ "printflag", print_flag },
{ "jcmd", jcmd },
{ NULL, NULL }
};
对于加载Agent来说,命令就是“load”。现在,我们知道了Attach Listener大概的工作模式,但是还是不太清楚任务从哪来,这个秘密就藏在AttachListener::dequeue这行代码里面,接下来我们来分析一下dequeue这个函数:
LinuxAttachOperation* LinuxAttachListener::dequeue() {
for (;;) {
// wait for client to connect
struct sockaddr addr;
socklen_t len = sizeof(addr);
RESTARTABLE(::accept(listener(), &addr, &len), s);
// get the credentials of the peer and check the effective uid/guid
// - check with jeff on this.
struct ucred cred_info;
socklen_t optlen = sizeof(cred_info);
if (::getsockopt(s, SOL_SOCKET, SO_PEERCRED, (void*)&cred_info, &optlen) == -1) {
::close(s);
continue;
}
// peer credential look okay so we read the request
LinuxAttachOperation* op = read_request(s);
return op;
}
}
这是Linux上的实现,不同的操作系统实现方式不太一样。上面的代码表面,Attach Listener在某个端口监听着,通过accept来接收一个连接,然后从这个连接里面将请求读取出来,然后将请求包装成一个AttachOperation类型的对象,之后就会从表里查询对应的处理函数,然后进行处理。
Attach Listener使用一种被称为“懒加载”的策略进行初始化,也就是说,JVM启动的时候Attach Listener并不一定会启动起来。下面我们来分析一下这种“懒加载”策略的具体实现方案。
// Start Attach Listener if +StartAttachListener or it can't be started lazily
if (!DisableAttachMechanism) {
AttachListener::vm_start();
if (StartAttachListener || AttachListener::init_at_startup()) {
AttachListener::init();
}
}
// Attach Listener is started lazily except in the case when
// +ReduseSignalUsage is used
bool AttachListener::init_at_startup() {
if (ReduceSignalUsage) {
return true;
} else {
return false;
}
}
上面的代码截取自create_vm函数,DisableAttachMechanism、StartAttachListener和ReduceSignalUsage这三个变量默认都是false,所以AttachListener::init();这行代码不会在create_vm的时候执行,而vm_start会执行。下面来看一下这个函数的实现细节:
void AttachListener::vm_start() {
char fn[UNIX_PATH_MAX];
struct stat64 st;
int ret;
int n = snprintf(fn, UNIX_PATH_MAX, "%s/.java_pid%d",
os::get_temp_directory(), os::current_process_id());
assert(n < (int)UNIX_PATH_MAX, "java_pid file name buffer overflow");
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == 0) {
ret = ::unlink(fn);
if (ret == -1) {
log_debug(attach)("Failed to remove stale attach pid file at %s", fn);
}
}
}
这是在Linux上的实现,是将/tmp/目录下的.java_pid{pid}文件删除,后面在创建Attach Listener线程的时候会创建出来这个文件。上面说到,AttachListener::init()这行代码不会在create_vm的时候执行,这行代码的实现已经在上文中分析了,就是创建Attach Listener线程,并监听其他JVM的命令请求。现在来分析一下这行代码是什么时候被调用的,也就是“懒加载”到底是怎么加载起来的。
// Signal Dispatcher needs to be started before VMInit event is posted os::signal_init(); 这是create_vm中的一段代码,看起来跟信号相关,其实Attach机制就是使用信号来实现“懒加载“的。下面我们来仔细地分析一下这个过程。
void os::signal_init() {
if (!ReduceSignalUsage) {
// Setup JavaThread for processing signals
EXCEPTION_MARK;
Klass* k = SystemDictionary::resolve_or_fail(vmSymbols::java_lang_Thread(), true, CHECK);
instanceKlassHandle klass (THREAD, k);
instanceHandle thread_oop = klass->allocate_instance_handle(CHECK);
const char thread_name[] = "Signal Dispatcher";
Handle string = java_lang_String::create_from_str(thread_name, CHECK);
// Initialize thread_oop to put it into the system threadGroup
Handle thread_group (THREAD, Universe::system_thread_group());
JavaValue result(T_VOID);
JavaCalls::call_special(&result, thread_oop,klass,vmSymbols::object_initializer_name(),vmSymbols::threadgroup_string_void_signature(),
thread_group,string,CHECK);
KlassHandle group(THREAD, SystemDictionary::ThreadGroup_klass());
JavaCalls::call_special(&result,thread_group,group,vmSymbols::add_method_name(),vmSymbols::thread_void_signature(),thread_oop,CHECK);
os::signal_init_pd();
{ MutexLocker mu(Threads_lock);
JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
// ...
}
// Handle ^BREAK
os::signal(SIGBREAK, os::user_handler());
}
}
JVM创建了一个新的进程来实现信号处理,这个线程叫“Signal Dispatcher”,一个线程创建之后需要有一个入口,“Signal Dispatcher”的入口是signal_thread_entry:
这段代码截取自signal_thread_entry函数,截取中的内容是和Attach机制信号处理相关的代码。这段代码的意思是,当接收到“SIGBREAK”信号,就执行接下来的代码,这个信号是需要Attach到JVM上的信号发出来,这个后面会再分析。我们先来看一句关键的代码:AttachListener::is_init_trigger():
bool AttachListener::is_init_trigger() {
if (init_at_startup() || is_initialized()) {
return false; // initialized at startup or already initialized
}
char fn[PATH_MAX+1];
sprintf(fn, ".attach_pid%d", os::current_process_id());
int ret;
struct stat64 st;
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == -1) {
log_trace(attach)("Failed to find attach file: %s, trying alternate", fn);
snprintf(fn, sizeof(fn), "%s/.attach_pid%d", os::get_temp_directory(), os::current_process_id());
RESTARTABLE(::stat64(fn, &st), ret);
}
if (ret == 0) {
// simple check to avoid starting the attach mechanism when
// a bogus user creates the file
if (st.st_uid == geteuid()) {
init();
return true;
}
}
return false;
}
首先检查了一下是否在JVM启动时启动了Attach Listener,或者是否已经启动过。如果没有,才继续执行,在/tmp目录下创建一个叫做.attach_pid%d的文件,然后执行AttachListener的init函数,这个函数就是用来创建Attach Listener线程的函数,上面已经提到多次并进行了分析。到此,我们知道Attach机制的奥秘所在,也就是Attach Listener线程的创建依靠Signal Dispatcher线程,Signal Dispatcher是用来处理信号的线程,当Signal Dispatcher线程接收到“SIGBREAK”信号之后,就会执行初始化Attach Listener的工作。
运行时加载Agent的实现
我们继续分析,到底是如何将一个Agent挂载到运行着的目标JVM上,在上文中提到了一段代码,用来进行运行时挂载Agent,可以参考上文中展示的关于“attachAgentToTargetJvm”方法的代码。这个方法里面的关键是调用VirtualMachine的attach方法进行Agent挂载的功能。下面我们就来分析一下VirtualMachine的attach方法具体是怎么实现的。
public static VirtualMachine attach(String var0) throws AttachNotSupportedException, IOException {
if (var0 == null) {
throw new NullPointerException("id cannot be null");
} else {
List var1 = AttachProvider.providers();
if (var1.size() == 0) {
throw new AttachNotSupportedException("no providers installed");
} else {
AttachNotSupportedException var2 = null;
Iterator var3 = var1.iterator();
while(var3.hasNext()) {
AttachProvider var4 = (AttachProvider)var3.next();
try {
return var4.attachVirtualMachine(var0);
} catch (AttachNotSupportedException var6) {
var2 = var6;
}
}
throw var2;
}
}
}
这个方法通过attachVirtualMachine方法进行attach操作,在MacOS系统中,AttachProvider的实现类是BsdAttachProvider。我们来看一下BsdAttachProvider的attachVirtualMachine方法是如何实现的:
public VirtualMachine attachVirtualMachine(String var1) throws AttachNotSupportedException, IOException {
this.checkAttachPermission();
this.testAttachable(var1);
return new BsdVirtualMachine(this, var1);
}
BsdVirtualMachine(AttachProvider var1, String var2) throws AttachNotSupportedException, IOException {
int var3 = Integer.parseInt(var2);
this.path = this.findSocketFile(var3);
if (this.path == null) {
File var4 = new File(tmpdir, ".attach_pid" + var3);
createAttachFile(var4.getPath());
try {
sendQuitTo(var3);
int var5 = 0;
long var6 = 200L;
int var8 = (int)(this.attachTimeout() / var6);
do {
try {
Thread.sleep(var6);
} catch (InterruptedException var21) {
;
}
this.path = this.findSocketFile(var3);
++var5;
} while(var5 <= var8 && this.path == null);
} finally {
var4.delete();
}
}
int var24 = socket();
connect(var24, this.path);
}
private String findSocketFile(int var1) {
String var2 = ".java_pid" + var1;
File var3 = new File(tmpdir, var2);
return var3.exists() ? var3.getPath() : null;
}
findSocketFile方法用来查询目标JVM上是否已经启动了Attach Listener,它通过检查”tmp/“目录下是否存在java_pid{pid}来进行实现。如果已经存在了,则说明Attach机制已经准备就绪,可以接受客户端的命令了,这个时候客户端就可以通过connect连接到目标JVM进行命令的发送,比如可以发送“load”命令来加载Agent。如果java_pid{pid}文件还不存在,则需要通过sendQuitTo方法向目标JVM发送一个“SIGBREAK”信号,让它初始化Attach Listener线程并准备接受客户端连接。可以看到,发送了信号之后客户端会循环等待java_pid{pid}这个文件,之后再通过connect连接到目标JVM上。
load命令的实现
下面来分析一下,“load”命令在JVM层面的实现:
static jint load_agent(AttachOperation* op, outputStream* out) {
// get agent name and options
const char* agent = op->arg(0);
const char* absParam = op->arg(1);
const char* options = op->arg(2);
// If loading a java agent then need to ensure that the java.instrument module is loaded
if (strcmp(agent, "instrument") == 0) {
Thread* THREAD = Thread::current();
ResourceMark rm(THREAD);
HandleMark hm(THREAD);
JavaValue result(T_OBJECT);
Handle h_module_name = java_lang_String::create_from_str("java.instrument", THREAD);
JavaCalls::call_static(&result,SystemDictionary::module_Modules_klass(),vmSymbols::loadModule_name(),
vmSymbols::loadModule_signature(),h_module_name,THREAD);
}
return JvmtiExport::load_agent_library(agent, absParam, options, out);
}
这个函数先确保加载了java.instrument模块,之后真正执行Agent加载的函数是 load_agent_library ,这个函数的套路就是加载Agent动态链接库,如果是通过Java instrument API实现的Agent,则加载的是libinstrument动态链接库,然后通过libinstrument里面的代码实现运行agentmain方法的逻辑,这一部分内容和libinstrument实现premain方法运行的逻辑其实差不多,这里不再做分析。至此,我们对Java Agent技术已经有了一个全面而细致的了解。
动态替换类字节码技术
动态字节码修改的限制
上文中已经详细分析了Agent技术的实现,我们使用Java Instrumentation API来完成动态类修改的功能,在Instrumentation接口中,通过addTransformer方法来增加一个类转换器,类转换器由类ClassFileTransformer接口实现。ClassFileTransformer接口中唯一的方法transform用于实现类转换,当类被加载的时候,就会调用transform方法,进行类转换。在运行时,我们可以通过Instrumentation的redefineClasses方法进行类重定义,在方法上有一段注释需要特别注意:
* The redefinition may change method bodies, the constant pool and attributes.
* The redefinition must not add, remove or rename fields or methods, change the
* signatures of methods, or change inheritance. These restrictions maybe be
* lifted in future versions. The class file bytes are not checked, verified and installed
* until after the transformations have been applied, if the resultant bytes are in
* error this method will throw an exception.
这里面提到,我们不可以增加、删除或者重命名字段和方法,改变方法的签名或者类的继承关系。认识到这一点很重要,当我们通过ASM获取到增强的字节码之后,如果增强后的字节码没有遵守这些规则,那么调用redefineClasses方法来进行类的重定义就会失败。那redefineClasses方法具体是怎么实现类的重定义的呢?它对运行时的JVM会造成什么样的影响呢?下面来分析redefineClasses的实现细节。
重定义类字节码的实现细节
上文中我们提到,libinstrument动态链接库中,JPLISAgent不仅实现了Agent入口代码执行的路由,而且还是Java代码与JVMTI之间的一道桥梁。我们在Java代码中调用Java Instrumentation API的redefineClasses,其实会调用libinstrument中的相关代码,我们来分析一下这条路径。
public void redefineClasses(ClassDefinition... var1) throws ClassNotFoundException {
if (!this.isRedefineClassesSupported()) {
throw new UnsupportedOperationException("redefineClasses is not supported in this environment");
} else if (var1 == null) {
throw new NullPointerException("null passed as 'definitions' in redefineClasses");
} else {
for(int var2 = 0; var2 < var1.length; ++var2) {
if (var1[var2] == null) {
throw new NullPointerException("element of 'definitions' is null in redefineClasses");
}
}
if (var1.length != 0) {
this.redefineClasses0(this.mNativeAgent, var1);
}
}
}
private native void redefineClasses0(long var1, ClassDefinition[] var3) throws ClassNotFoundException;
这是InstrumentationImpl中的redefineClasses实现,该方法的具体实现依赖一个Native方法redefineClasses(),我们可以在libinstrument中找到这个Native方法的实现:
JNIEXPORT void JNICALL Java_sun_instrument_InstrumentationImpl_redefineClasses0
(JNIEnv * jnienv, jobject implThis, jlong agent, jobjectArray classDefinitions) {
redefineClasses(jnienv, (JPLISAgent*)(intptr_t)agent, classDefinitions);
}
redefineClasses这个函数的实现比较复杂,代码很长。下面是一段关键的代码片段:
可以看到,其实是调用了JVMTI的RetransformClasses函数来完成类的重定义细节。
// class_count - pre-checked to be greater than or equal to 0
// class_definitions - pre-checked for NULL
jvmtiError JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {
//TODO: add locking
VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);
VMThread::execute(&op);
return (op.check_error());
} /* end RedefineClasses */
重定义类的请求会被JVM包装成一个VM_RedefineClasses类型的VM_Operation,VM_Operation是JVM内部的一些操作的基类,包括GC操作等。VM_Operation由VMThread来执行,新的VM_Operation操作会被添加到VMThread的运行队列中去,VMThread会不断从队列里面拉取VM_Operation并调用其doit等函数执行具体的操作。VM_RedefineClasses函数的流程较为复杂,下面是VM_RedefineClasses的大致流程:
- 加载新的字节码,合并常量池,并且对新的字节码进行校验工作
// Load the caller's new class definition(s) into _scratch_classes.
// Constant pool merging work is done here as needed. Also calls
// compare_and_normalize_class_versions() to verify the class
// definition(s).
jvmtiError load_new_class_versions(TRAPS);
- 清除方法上的断点
// Remove all breakpoints in methods of this class
JvmtiBreakpoints& jvmti_breakpoints = JvmtiCurrentBreakpoints::get_jvmti_breakpoints();
jvmti_breakpoints.clearall_in_class_at_safepoint(the_class());
- JIT逆优化
// Deoptimize all compiled code that depends on this class
flush_dependent_code(the_class, THREAD);
- 进行字节码替换工作,需要进行更新类itable/vtable等操作
- 进行类重定义通知
SystemDictionary::notice_modification();
VM_RedefineClasses实现比较复杂的,详细实现可以参考 RedefineClasses的实现。
Java-debug-tool设计与实现
Java-debug-tool是一个使用Java Instrument API来实现的动态调试工具,它通过在目标JVM上启动一个TcpServer来和调试客户端通信。调试客户端通过命令行来发送调试命令给TcpServer,TcpServer中有专门用来处理命令的handler,handler处理完命令之后会将结果发送回客户端,客户端通过处理将调试结果展示出来。下面将详细介绍Java-debug-tool的整体设计和实现。
Java-debug-tool整体架构
Java-debug-tool包括一个Java Agent和一个用于处理调试命令的核心API,核心API通过一个自定义的类加载器加载进来,以保证目标JVM的类不会被污染。整体上Java-debug-tool的设计是一个Client-Server的架构,命令客户端需要完整的完成一个命令之后才能继续执行下一个调试命令。Java-debug-tool支持多人同时进行调试,下面是整体架构图:
下面对每一层做简单介绍:
- 交互层:负责将程序员的输入转换成调试交互协议,并且将调试信息呈现出来。
- 连接管理层:负责管理客户端连接,从连接中读调试协议数据并解码,对调试结果编码并将其写到连接中去;同时将那些超时未活动的连接关闭。
- 业务逻辑层:实现调试命令处理,包括命令分发、数据收集、数据处理等过程。
- 基础实现层:Java-debug-tool实现的底层依赖,通过Java Instrumentation提供的API进行类查找、类重定义等能力,Java Instrumentation底层依赖JVMTI来完成具体的功能。
在Agent被挂载到目标JVM上之后,Java-debug-tool会安排一个Spy在目标JVM内活动,这个Spy负责将目标JVM内部的相关调试数据转移到命令处理模块,命令处理模块会处理这些数据,然后给客户端返回调试结果。命令处理模块会增强目标类的字节码来达到数据获取的目的,多个客户端可以共享一份增强过的字节码,无需重复增强。下面从Java-debug-tool的字节码增强方案、命令设计与实现等角度详细说明。
Java-debug-tool的字节码增强方案
Java-debug-tool使用字节码增强来获取到方法运行时的信息,比如方法入参、出参等,可以在不同的字节码位置进行增强,这种行为可以称为“插桩”,每个“桩”用于获取数据并将他转储出去。Java-debug-tool具备强大的插桩能力,不同的桩负责获取不同类别的数据,下面是Java-debug-tool目前所支持的“桩”:
- 方法进入点:用于获取方法入参信息。
- Fields获取点1:在方法执行前获取到对象的字段信息。
- 变量存储点:获取局部变量信息。
- Fields获取点2:在方法退出前获取到对象的字段信息。
- 方法退出点:用于获取方法返回值。
- 抛出异常点:用于获取方法抛出的异常信息。
通过上面这些代码桩,Java-debug-tool可以收集到丰富的方法执行信息,经过处理可以返回更加可视化的调试结果。
字节码增强
Java-debug-tool在实现上使用了ASM工具来进行字节码增强,并且每个插桩点都可以进行配置,如果不想要什么信息,则没必要进行对应的插桩操作。这种可配置的设计是非常有必要的,因为有时候我们仅仅是想要知道方法的入参和出参,但Java-debug-tool却给我们返回了所有的调试信息,这样我们就得在众多的输出中找到我们所关注的内容。如果可以进行配置,则除了入参点和出参点外其他的桩都不插,那么就可以快速看到我们想要的调试数据,这种设计的本质是为了让调试者更加专注。下面是Java-debug-tool的字节码增强工作方式:
如图4-2-1所示,当调试者发出调试命令之后,Java-debug-tool会识别命令并判断是否需要进行字节码增强,如果命令需要增强字节码,则判断当前类+当前方法是否已经被增强过。上文已经提到,字节码替换是有一定损耗的,这种具有损耗的操作发生的次数越少越好,所以字节码替换操作会被记录起来,后续命令直接使用即可,不需要重复进行字节码增强,字节码增强还涉及多个调试客户端的协同工作问题,当一个客户端增强了一个类的字节码之后,这个客户端就锁定了该字节码,其他客户端变成只读,无法对该类进行字节码增强,只有当持有锁的客户端主动释放锁或者断开连接之后,其他客户端才能继续增强该类的字节码。
字节码增强模块收到字节码增强请求之后,会判断每个增强点是否需要插桩,这个判断的根据就是上文提到的插桩配置,之后字节码增强模块会生成新的字节码,Java-debug-tool将执行字节码替换操作,之后就可以进行调试数据收集了。
经过字节码增强之后,原来的方法中会插入收集运行时数据的代码,这些代码在方法被调用的时候执行,获取到诸如方法入参、局部变量等信息,这些信息将传递给数据收集装置进行处理。数据收集的工作通过Advice完成,每个客户端同一时间只能注册一个Advice到Java-debug-tool调试模块上,多个客户端可以同时注册自己的Advice到调试模块上。Advice负责收集数据并进行判断,如果当前数据符合调试命令的要求,Java-debug-tool就会卸载这个Advice,Advice的数据就会被转移到Java-debug-tool的命令结果处理模块进行处理,并将结果发送到客户端。
Advice的工作方式
Advice是调试数据收集器,不同的调试策略会对应不同的Advice。Advice是工作在目标JVM的线程内部的,它需要轻量级和高效,意味着Advice不能做太过于复杂的事情,它的核心接口“match”用来判断本次收集到的调试数据是否满足调试需求。如果满足,那么Java-debug-tool就会将其卸载,否则会继续让他收集调试数据,这种“加载Advice” -> “卸载Advice”的工作模式具备很好的灵活性。
关于Advice,需要说明的另外一点就是线程安全,因为它加载之后会运行在目标JVM的线程中,目标JVM的方法极有可能是多线程访问的,这也就是说,Advice需要有能力处理多个线程同时访问方法的能力,如果Advice处理不当,则可能会收集到杂乱无章的调试数据。下面的图片展示了Advice和Java-debug-tool调试分析模块、目标方法执行以及调试客户端等模块的关系。
Advice的首次挂载由Java-debug-tool的命令处理器完成,当一次调试数据收集完成之后,调试数据处理模块会自动卸载Advice,然后进行判断,如果调试数据符合Advice的策略,则直接将数据交由数据处理模块进行处理,否则会清空调试数据,并再次将Advice挂载到目标方法上去,等待下一次调试数据。非首次挂载由调试数据处理模块进行,它借助Advice按需取数据,如果不符合需求,则继续挂载Advice来获取数据,否则对调试数据进行处理并返回给客户端。
Java-debug-tool的命令设计与实现
命令执行
上文已经完整的描述了Java-debug-tool的设计以及核心技术方案,本小节将详细介绍Java-debug-tool的命令设计与实现。首先需要将一个调试命令的执行流程描述清楚,下面是一张用来表示命令请求处理流程的图片:
图4-3-1简单的描述了Java-debug-tool的命令处理方式,客户端连接到服务端之后,会进行一些协议解析、协议认证、协议填充等工作,之后将进行命令分发。服务端如果发现客户端的命令不合法,则会立即返回错误信息,否则再进行命令处理。命令处理属于典型的三段式处理,前置命令处理、命令处理以及后置命令处理,同时会对命令处理过程中的异常信息进行捕获处理,三段式处理的好处是命令处理被拆成了多个阶段,多个阶段负责不同的职责。前置命令处理用来做一些命令权限控制的工作,并填充一些类似命令处理开始时间戳等信息,命令处理就是通过字节码增强,挂载Advice进行数据收集,再经过数据处理来产生命令结果的过程,后置处理则用来处理一些连接关闭、字节码解锁等事项。
Java-debug-tool允许客户端设置一个命令执行超时时间,超过这个时间则认为命令没有结果,如果客户端没有设置自己的超时时间,就使用默认的超时时间进行超时控制。Java-debug-tool通过设计了两阶段的超时检测机制来实现命令执行超时功能:首先,第一阶段超时触发,则Java-debug-tool会友好的警告命令处理模块处理时间已经超时,需要立即停止命令执行,这允许命令自己做一些现场清理工作,当然需要命令执行线程自己感知到这种超时警告;当第二阶段超时触发,则Java-debug-tool认为命令必须结束执行,会强行打断命令执行线程。超时机制的目的是为了不让命令执行太长时间,命令如果长时间没有收集到调试数据,则应该停止执行,并思考是否调试了一个错误的方法。当然,超时机制还可以定期清理那些因为未知原因断开连接的客户端持有的调试资源,比如字节码锁。
获取方法执行视图
Java-debug-tool通过下面的信息来向调试者呈现出一次方法执行的视图:
- 正在调试的方法信息。
- 方法调用堆栈。
- 调试耗时,包括对目标JVM造成的STW时间。
- 方法入参,包括入参的类型及参数值。
- 方法的执行路径。
- 代码执行耗时。
- 局部变量信息。
- 方法返回结果。
- 方法抛出的异常。
- 对象字段值快照。
图4-3-2展示了Java-debug-tool获取到正在运行的方法的执行视图的信息。
Java-debug-tool与同类产品对比分析
Java-debug-tool的同类产品主要是greys,其他类似的工具大部分都是基于greys进行的二次开发,所以直接选择greys来和Java-debug-tool进行对比。
总结
本文详细剖析了Java动态调试关键技术的实现细节,并介绍了我们基于Java动态调试技术结合实际故障排查场景进行的一点探索实践;动态调试技术为研发人员进行线上问题排查提供了一种新的思路,我们基于动态调试技术解决了传统断点调试存在的问题,使得可以将断点调试这种技术应用在线上,以线下调试的思维来进行线上调试,提高问题排查效率。
参考资料
2 - JSR 133
2.1 - CH01-指令重排
对于编译器的编写者来说,JMM 主要是由“禁止指令重排的规则”所组成的,其中包括了字段(包括数组中的元素)的存取指令和监视器(锁)的控制指令。
Volatile 与监视器
JMM 中关于 volatile 和监视器的主要规则可以被看做是一个矩阵。这个矩阵中的单元格表示当存在一些特定的后续关联指令的情况下,指令不能被重排。下面的表格并非 JMM 所包含的内容,而是一个用来观察 JMM 对编译器和运行系统所造成的影响的工具。
能否重排 | 下个操作 | 下个操作 | 下个操作 |
---|---|---|---|
第一个操作 | Nomal Load/Nornal Store | Volatile Load/Moitor Enter | Volatile Store/Monitor Exit |
Nomal Load/Nornal Store | NO | ||
Volatile Load/Moitor Enter | NO | NO | NO |
Volatile Store/Monitor Exit | NO | NO |
术语说明:
- Normal Load 指令包括:对非 volatile 字段的读取,getField/getStatic/arrayLoad。
- Normal Store 指令包括:对非 volatile 字段的存储,putFiled/putStatic/arrayStore。
- Volatile Load 指令包括:对多线程环境的 volatile 变量的读取,getField/getStatic。
- Volatile Store 指令包括:对多线程环境的 volatile 变量的存储,putField/putStatic。
- Monitor Enter(包括进入同步块 synchronized 方法)是用于多线程环境的锁对象。
- Monitor Exist(包括离开同步块 synchronized 方法)是用于多线程环境的锁对象。
在 JMM 中,Normal Load 指令与 Nornal Store 指令的规则是一致的,类似的还有 Volatile Load 指令与 Monitor Enter 指令,以及 Volatile Store 指令和 Monitor Exit 指令,因此这几对指令的单元格在上面的表格中都被合并在了一起(但是在后续的表格中,会在必要的时候将其展开)。在这个小节中,我们仅仅考虑那些被当做原子单元的可读写的变量,也就是说那些没有位域(bit fields)、非对齐访问(unaligned acesses)、或者超过平台最大字长(word size)的访问。
任意数量的指令操作都可被表示成这个表格中的“第一个操作”或“下一个操作”。例如在单元格 “Normal Store, Volatile Store” 中,有一个 NO,就表示任何非 volatile 字段的 store 指令操作不能与后面任何一个 vaolatile store 指令重排,如果出现任何这样的重排就会使得多线程程序的行为发生变化。
JSR 133 规范规定上述关于 volatile 和监视器的规则仅仅适用于可能会被多线程访问的变量或对象。因此,如果一个编译器可以最终证明(这往往需要很大的努力)一个锁仅被单线程访问,那么这个锁就可以被移除。与之类似,一个 volatile 变量只被单线程访问也可以被当做是普通的变量。还有进一步耕细粒度的分析与优化,例如:那些被证明在一段时间内对多线程不可访问的字段。
在上表中,空白的单元格代表在不违反 Java 的基本语义下重排是允许的(详细可参考 JLS 中的说明)。例如,即使上表中没有说明,但是也不能对同一内存地址上的 load 指令和之后紧跟着的 store 指令进行重排。但是你可以对两个不同内存地址上的 load 和 store 指令进行重排,而且往往还在很多编译器转换和优化中会这么做。这往往就包括了一些不被认为是指令重排的例子,如:重用一个基于已加载的字段的计算后的值,而不是像第一次指令重排那样去重新加载并重新计算。然而,JMM 规范允许编译器经过一些转换后消除这些可以避免的依赖,使其可以支持指令重排。
在任何情况下,即使是开发者错误的使用了同步读取,指令重排的结果也必须达到最基本的 Java 安全要求,所有的显式字段都必须要么被设定成 0 或 null 这样的与构造值,要么被其他线程设置值。这通常必须把所有存储在堆内存里的对象在其被构造函数使用前进行归零操作,并且从来不对归零 store 指令进行重排。一种比较好的方式是在垃圾回收中对回收的内存进行归零操作。可以参考 JSR 133 规范中其他情况下的一些关于安全保证的规则。
这里描述的规则和属性都是适用于读取 Java 环境的字段。在实际的应用中,这些都可能会另外与读取内部的一些记账字段和数据交互,例如对象头,GC 表和动态生成的代码。
final 字段
Final 字段的 load 和 store 指令相对于有锁的或者 volatile 字段来说,就跟 Normal load 和 Normal store 的存取是一样的,但是需要加入两条附加的指令重排规则:
- 如果在构造函数中有一条 fianl 字段的 store 指令,同时这个字段是一个引用,那么它将不能与构造函数外后续可以让持有这个 final 字段的对象被其他线程访问的指令重排。例如,你不能重排下列语句:
x.finalField = v;
...;
sharedRef = x;
这条规则会在下列情况下生效,例如当你内联一个构造函数时,正如“…”的部分表示的该构造函数的逻辑边界那样。你不能把这个构造函数中的对这个 final 字段的 store 紫菱移动到构造函数外的一条 store 指令之后,因为这可能会使这个对象对其他线程可见。(正如你将在下面看到的,这样的操作还需要声明一个内存屏障)。类似的,你不能把下面的前两条指令与第三条指令进行重排:
x.afield = 1;
x.finalField = v;
...;
sharedRef = x;
- 一个 final 字段的初始化 load 指令不能与包含该字段的对象的初始化 load 指令进行重排。在下面的情况中,这条规则就会生效:
x = shareRef;...;x=x.finalField
。由于这两条指令是依赖的,编译器不能对这样的指令进行重排。但是,这条规则会对某些处理器有影响。
上述规则,要求对于带有 fianl 字段的对象的 load 本身是 synchronized、volatile、final 或来自类似的 load 指令,从而确保 Java 开发者对于 fianl 字段的正确使用,并最终使构造函数中初始化的 store 指令和构造函数外的 store 指令排序。
2.2 - CH02-内存屏障
编译器和处理器必须同时遵守重排规则。由于单核处理器能确保与“顺序执行”相同的一致性,所以在单核处理器上并不需要做什么处理就可以保证正确的执行顺序。但是在多核处理器上通常需要使用内存屏障指令来确保这种一致性。即使编译器优化掉了一个字段访问(比如因为一个读入的值未被使用),这种情况下还是需要产生内存屏障,就好像该访问仍然需要被保护一样。
内存屏障仅仅与内存模型中“获取”、“释放”这些高层次概念有间接的关系。内存屏障并非“同步屏障”,内存屏障也与在一些垃圾回收机制中的“写屏障(write barriers)”概念无关。内存屏障指令仅仅直接控制 CPU 与缓存之间,CPU 与其准备将数据写入主存或写入等待读取、预测指令执行的缓冲中的写缓冲之间的相互操作。这些操作可能导致缓冲、主存和其他处理器执行进一步的交互。但在 Java 内存模型规范中,没有强制处理之间的交互方式,只要数据最终变为全局可用,即在所有处理器中均可见,并当这些数据可见时可以获得它们。
内存屏障的种类
几乎所有处理器都至少支持一种粗粒度的屏障指令,通常被称为“栅栏(fence)”,它保证栅栏前初始化的 load 和 store 指令,能够严格有序的在栅栏后的 load 和 store 指令之前执行。无论在何种处理器上,这几乎是最耗时的操作之一(与原子指令差不多、甚至更加消耗资源),所以大部分处理器支持更细粒度的屏障指令。
内存屏障的一个特性是将它们运用于内存之间的访问。尽管在一些处理器上有一些名为屏障的指令,但是正确的、最好的屏障使用取决于内存访问的类型。下面是一些屏障指令的通用分类,它们正好能够对应上常用处理器上的特定指令(有时这些指令会导致空操作)。
LoadLoad 屏障
Load1, LoadLoad, Load2
确保 Load1 所要读入的数据能够在被 Load2 和后续的 load 指令访问前读入。通常执行预加载指令或/和支持乱序处理的处理器中需要显式声明该 LoadLoad 屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。而对于总是能保证处理顺序的处理器,设置该屏障相当于空操作。
StoreStore 屏障
Store1, StoreStore, Store2
确保 Store1 的数据在 Store2 及后续 store 指令操作相关数据之前对其处理器可见(如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其他处理器和主存中按顺序刷新数据,那么就需要使用 StoreStore 屏障。
LoadStore 屏障
Load1, LoadStore, Store2
确保 Load1 的数据在 Store2 和后续 store 指令被刷新之前读取。在等待 store 指令可以越过 load 指令的乱序处理器上需要使用 LoadStore 屏障。
StoreLoad 屏障
Store1, LoadStore, Load2
确保 Store1 的数据在被 Load2 及后续的 load 指令读取之前对其他处理器可见。StoreLoad 屏障可以放置一个后续的 load 指令不正确的使用 Store1 的数据,而不是另一个处理器在相同内存位置写入一个新数据。真因为如此,所以下面所讨论的处理器为了在屏障前读取同样内存位置存过的数据时,必须使用一个 StoreLoad 屏障将存储指令和后续的加载指令分开。StoreLoad 屏障在几乎所有的现代处理器中都需要使用,但通常它的开销也是最昂贵的。它们昂贵的部分原因是它们必须关闭通常的“略过缓存直接从写缓冲区读取数据”的机制。这可能通过让一个缓冲区进行充分刷新,以及它的延迟的方式来实现。
在下面讨论的所有处理器中,执行 StoreLoad 指令也会同时获得其他三种屏障效果。所以 StoreLoad 可以作为最通用(但通常也是最耗性能)的一种 fence。(这是基于经验得出的结论,并非必然)。反之则不成立,为了达到 StoreLoad 的效果而组合使用其他屏障的情况并不多见。
排序规则
下表显示了这些屏障如何符合 JSR 133 的排序规则:
需要的屏障 | 下个操作 | 下个操作 | 下个操作 | 下个操作 |
---|---|---|---|---|
第一个操作 | Nomal Load | Nornal Store | Volatile Load Moitor Enter | Volatile Store Monitor Exit |
Nomal Load | LoadStore | |||
Nornal Store | StoreStore | |||
Volatile Load Moitor Enter | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store Monitor Exit | StoreLoad | StoreStore |
另外,特殊的 final 字段规则在下列代码中需要一个 StoreStore 屏障:
x.finalField = v;
StoreStore;
sharedRef = x;
下面的例子解释了如何放置屏障:
Class X {
int a, b;
volatil int v, u;
void f() {
int i, j;
i = a;// load a
j = b;// load b
i = v;// load v
// LoadLoad
j = u;// load u
// LoadStore
a = i;// store a
b = j;// store b
// StoreStore
v = i;// store v
// StoreStore
u = j;// store u
// StoreLoad
i = u;// load u
// LoadLoad
// LoadStore
j = b;// load b
a = i;// store a
}
}
数据依赖与屏障
一些处理器为了保证依赖指令的交互次序需要使用 LoadLoad 和 LoadStore 屏障。在一些(大部分)处理器中,一个 load 指令或者一个依赖于之前加载值的 store 指令被处理器排序,并不需要一个显式的屏障。这通常发生于两种情况:
- 间接取值(indirection):
Load x; Load x.field
- 条件控制(control):
Load x; if(predicate(x)) Load or Store y;
但特别的是不遵循间接排序的处理器,需要为 final 字段设置屏障,使它能通过共享引用来访问最初的引用。
x = sharedRef;
...;
LoadLoad;
i = x.finalField;
相反的,如下讨论,确定遵循数据依赖的处理器,提供了几种优化掉 LoadLoad 和 LoadStore 屏障指令的机会。(尽管如此,在任何处理器上,对于 StoreLoad 屏障不会自动清除依赖关系)
与原子指令交互
屏障在不同处理器上还需要与 MonitorEnter 和 MonitorExit 实现交互。加解锁通常必须使用原子条件更新操作 CampareAndSwap(CAS) 指令或 LoadLinked/StoreConditional(LL/SC),就如执行一个 volatile store 之后紧跟 volatile load 的语义一样。CAS 或者 LL/SC 能够满足最小功能,一些处理器还需要提供其他的原子操作(如,一个无条件交换),这在某些时候它可以替代或者与原子条件更新操作结合使用。
在所有处理器中,原子操作可以避免在正被读取/更新的内存位置进行“写后读(read-after-write)”。(否则标准的循环直到成功的结构体(loop-until-success)无法正常工作)。但处理器是否在为原子操作提供比隐式的 StoreLoad 更一般的屏障特性上表现不同。一些处理器上这些指令可以为 MonitorEnter/Exit 原生的生成屏障;其他处理器中一部分或全部屏障必须显式的指定。
为了分清这些影响,我们必须把 volatile 和 monitor 分开:
需要的屏障 | 下个操作 | 下个操作 | 下个操作 | 下个操作 | 下个操作 | 下个操作 |
---|---|---|---|---|---|---|
第一个操作 | Nomal Load | Nornal Store | Volatile Load | Volatile Store | Moitor Enter | Monitor Exit |
Nomal Load | LoadStore | LoadStore | ||||
Nornal Store | StoreStore | StoreExit | ||||
Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore | LoadEnter | LoadExit |
Volatile Store | StoreLoad | StoreStore | StoreEnter | StoreExit | ||
Moitor Enter | EnterLoad | EnterStore | EnterLoad | EnterStore | EnterEnter | EnterExit |
Monitor Exit | ExitLoad | ExitStore | ExitEnter | ExitExit |
同样,特殊的 final 字段规则需要一个 StoreLoad 屏障:
x.finalField = v;
StoreStore;
sharedRef = x;
在该表中,“Enter” 与 “Load” 相同,“Exit” 与 “Store” 相同,除非被原子性指令的使用和特性覆盖。特别是:
- EnterLoad 在进入任何需要执行 Load 指令的同步块/方法时都需要。这与 LoadLoad 相同,除非在 MonitorEnter 时候使用了原子指令并且它本身提供一个至少有 LoadLoad 属性的屏障。如果是这种情况,相当于空操作。
- StoreExit 在退出任何执行 Store 指令的同步方法块时都需要。这与 StoreStore 一致,除非 MonitorExit 使用原子操作,并且提供了一个至少拥有 StoreStore 属性的屏障,如果是这种情况,相当于空操作。
- ExitEnter 和 StoreLoad 一样,除非 MonitorExit 使用了原子指令,并且/或者 MonitorEnter 至少提供一种屏障,该屏障具有 StoreLoad 的属性,如果是这种情况,相当于没有操作。
在编译时不起作用或者导致处理器上不产生操作的指令比较特殊。例如,当没有交替的 laod 和 store 指令时,EnterEnter 用于分离嵌套的 MonitorEnter。下面的例子说明了如何使用这些指令类型:
class X {
int a;
volatile int v;
void f() {
int i;
synchronized (this) { // enter EnterLoad EnterStore
i = a;// load a
a = i;// store a
}// LoadExit StoreExit exit ExitEnter
synchronized (this) {// enter ExitEnter
synchronized (this) {// enter
}// EnterExit exit
}// ExitExit exit ExitEnter ExitLoad
i = v;// load v
synchronized (this) {// LoadEnter enter
} // exit ExitEnter ExitStore
v = i; // store v
synchronized (this) { // StoreEnter enter
} // EnterExit exit
}
}
Java 层次的对原子条件更新的操作将在 JDK 1.5 中发布(JSR 166),因此编译器需要发布相应的代码,综合使用上表中对 MonitorEnter 和 MonitorExist 的方式,从语义上说,有时在实践中,这些 Java 中的原子更新操作,就如同他们被锁所包围一样。
2.3 - CH03-多处理器
本文总结了在多处理器(MPs)中常用的的处理器列表。这不是一个完全详细的列表,但已经包括了我所知道的在当前或者将来Java 实现中所使用的多核处理器。
略。
2.4 - CH04-开发指南
单处理器
如果能够保证要生成的代码仅会运行在单个处理器上,那就可以跳过本节的其余部分。因为单处理器保持着明确的顺序一致性,除非对象内存以某种方式与可异步访问的 IO 内存共享,否则永远都不需要插入屏障指令。采用了特殊映射的 java.nio buffers 可能会出现这种情况,但也许只会影响内部的 JVM 支持代码,而不会影响 Java 代码。而且,可以想象,如果上下文切换时不要求充分的同步,那就需要使用一些特殊的屏障了。
插入屏障
当程序执行时遇到了不同类型的存取,那就需要屏障指令。几乎无法找到一个“最理想”的位置,能将屏障执行总次数讲到最小。编译器不知道指定的 load 或 store 指令是先于还是后于需要一个屏障指令的另一个 load 或 store 指令,如:当 volatile store 后面是一个 return 时。最简单保守的策略是为任何一个给定的 load、store、lock 或 unlock 生成代码时,都假设该类型的存取需要“最重量级”的屏障:
- 在每条 volatile store 指令之前插入一个 StoreStore 屏障。
- 如果一个类包含 final 字段,在该类每个构造器的全部 store 指令之后、return 指令之前插入一个 StoreStore 屏障。
- 在每条 volatile store 指令之后插入一条 StoreStore 屏障。注意,虽然也可以在每条 volatile load 指令之前插入一个 StoreStore 屏障,但对于使用 volatile 的典型程序来说则会更慢,因为读操作会大大超过写操作。或者如果可以的话,将 volatile store 实现成一条原子指令,就可以省略掉屏障操作。如果原子指令比 StoreLoad 屏障成本低,这种方式就会更加高效。
- 在每条 volatile load 指令之后插入 LoadLoad 和 LoadStore 屏障。在持有数据依赖的处理器上,如果下一条存取指令依赖于 volatile load 出来的值,就不需要插入屏障。特别是,在 load 一个 volatile 引用之后,如果后续指令是 null 检查或 load 此引用所指对象中的某个字段,此时就无需屏障。
- 在每条 MonitorEnter 指令之前或在每条 MonitorExit 指令之后插入一个 ExitEnter 屏障。(根据上面的讨论,如果 MonitorExit 或 MonitorEnter 使用了相当于 StoreLoad 屏障的原子指令,ExitEnter 可以是个空操作(no-op)。其余步骤中,其他涉及 Enter 和 Eixt 的屏障也是如此。)
- 在每条 MonitorEnter 指令之后插入 EnterLoad 和 EnterStore 屏障。
- 在每条 MonitorExit 指令之后插入 StoreExit 和 LoadExit 屏障。
- 如果在未内置直接间接 load 顺序的处理器上,可以在 final 字段的每条 load 指令之前插入一个 LoadLoad 屏障。
这些屏障中的有一些通常会简化成空操作。实际上,大部分都会简化成空操作,只不过是在不同处理器的锁模式下使用了不同的方式。最简单的例子,在 x86 或 sparc-TSO 平台上使用 CAS 实现锁,仅相当于在 volatile store 后面放了一个 StoreLoad 屏障。
移除屏障
上面的保守策略对有些程序来说也许还可以接受。volatile 的主要性能问题出在 store 指令相关的 StoreLoad 屏障上。这些应当是相对罕见的——将 volatile 主要用于避免并发程序里读操作中锁的使用,仅当读操作大大超过写操作才会有问题。但是至少能在以下几个方面改进这种策略:
- 移除冗余的屏障。可以根据前面章节的表格来消除屏障:
Original | => | Transformed | ||||
---|---|---|---|---|---|---|
1st | ops | 2nd | => | 1st | ops | 2nd |
LoadLoad | no loads | LoadLoad | => | no loads | LoadLoad | |
LoadLoad | no loads | StoreLoad | => | no loads | StoreLoad | |
StoreStore | no stores | StoreStore | => | no stores | StoreStore | |
StoreStore | no stores | StoreLoad | => | no stores | StoreLoad | |
StoreLoad | no loads | LoadLoad | => | StoreLoad | no loads | |
StoreLoad | no stores | StoreStore | => | StoreLoad | no loads | |
StoreLoad | no volatile loads | StoreLoad | => | no volatile loads | StoreLoad |
类似的屏障消除也可以用于锁的交互,但要依赖于锁的实现方式。使用循环、调用及分支来实现这一切的工作就作为读者练习吧。
- 重排代码(在允许的范围内)以进一步移除 LoadLoad 和 LoadStore 屏障,这些屏障因处理器维持着数据依赖顺序而不再需要。
- 移动指令流中屏障的位置以提高调度效率,只要在该屏障被需要的时间内最终仍会在某处执行即可。
- 移除那些没有多线程依赖因此不再需要的屏障,例如,某个 volatile 变量被证实只会对单个线程可见。而且,如果能证明线程仅能对某些特定字段执行 store 指令或仅能执行 load 指令,则可以移除这里面使用的屏障。但是所有这些通常都需要进行大量的分析。
杂记
JSR 133 也讨论了在更为特殊的情况下可能需要屏障的其他几个问题:
- Thread.start 需要屏障来确保该已启动的线程能够看到在调用时刻对调用者可见的所有 store 的内容。相反,Thread.join 需要屏障来确保调用者能看到正在终止的线程所 store 的内容。实现 Thread.start 和 Thread.join 时需要同步,这些屏障通常是通过这些同步来产生的。
- static final 初始化需要 StoreStore 屏障,遵守 Java 类加载和初始化规则的那些机制需要这些屏障。
- 确保默认的 0/null 初始字段值时通常需要屏障、同步或/和垃圾收集器里的底层缓存控制。
- 在构造器之外或静态初始化器之外设置 System.in、System.out、System.err 的 JVM 私有例程需要特别注意,因为它们是 JMM final 字段规则的遗留例外情况。
- 类似的,JVM 内部反序列化设置 final 字段的代码通常需要一个 StoreStore 屏障。
- 终结方法可能需要屏障(垃圾收集器里)来确保 Object.finalize 中的代码能够看到某个对象不再被引用之前 store 到该对象所有字段的值。这通常是通过同步来确保的,这些同步用于在 reference 队列中添加和删除 reference。
- 调用 JNI 例程以及从 JNI 例程中返回可能需要屏障,尽管看起来是实现方面的问题。
- 大多数处理器都设计有其他专用于 IO 或 OS 操作的同步指令。他们不会直接影响 JMM 的这些问题,但是有可能与 IO、类加载及动态代码生成紧密相关。
3 - JMM 规范
3.1 - CH01-JMM规范
The Java ® Language Specification SE 8 - Chapter 17 Threads and Locks
Java 内存模型定义了线程之间如何通过内存进行交互,是深入学习 Java 并发编程的必要前提。
Java 虚拟机可以支持多线程同时执行。这些线程可以独立的对驻留在内存中的值和对象执行操作代码。对这些线程的支持,可以是多硬件处理器,也可以是单硬件处理器的时间片机制,或者是多硬件处理器的时间片机制。
线程由 Thread 类表示。对用户来说,创建线程的唯一方式就是创建该类的对象,每个线程都和一个这样的对象关联。在 Thread 类的对象上调用 start 方法将会启动对应的线程。
在进行不正确的同步操作时,可能会引起线程行为的混淆与反常。本章描述的是多线程的语义,其中包含以下规则:若主存(内存)是由多个线程更新的,那么对主存的读操作可以看到哪些值。 因为这些规范与针对不同硬件架构的“内存模型”类似,因此也被称为“Java 编程语言内存模型”。当不会产生任何混淆时,我们将直接称这些规则为“内存模型”。
这些语义并未规定多线程程序应该如何执行,它们描述的是多线程程序允许展示出来的行为。
同步
Java 语言为线程间通信提供了多种机制,这些方法中最简单的就是“同步(synchronization)”,它是使用“监视器(monitor)”实现的。Java 中每个对象都与一个可以被线程锁定或解锁的监视器相关联。在任何时刻,只有一个线程可以持有某个监视器上的锁。任何其他试图锁定该监视器的线程都将被阻塞,直至它们可以获得该监视器上的锁。一个线程可以多次锁定某个指定的监视器,每个解锁操作都会抵消前一次锁定。
synchronized
语句计算的是对象的引用,然后试图锁定该对象的监视器,并且在锁定动作完成之前,不会执行下一个动作。在锁定动作执行之后,synchronized
语句体被执行。如果该语句体执行结束,无论是正常结束还是猝然结束,都会在之前锁定的监视器上执行解锁操作。
synchronized
方法在被调用时会自动执行锁定动作,它的方法体在该锁定动作完成之前是不会被执行的。如果该方法是实例方法,那么会锁定调用该实例方法的对象所关联的监视器。如果该方法是静态方法,那么它会锁定定义该方法的类的 Class 对象相关联的监视器。如果该方法体执行结束,无论是正常结束还是猝然结束,都会在之前锁定的监视器上执行解锁操作。
Java 编程语言既不阻止也不要求对死锁情况的探测。线程(间接或直接)持有多个对象上的锁的的程序应该使用避免死锁的惯用技术,如果必要的话,可以创建更高级别的不会产生死锁的锁定原语。
其他机制,如 volatile
变量的读写和对 JUC 包中的类的使用,都提供了可替代的同步方式。
等待集合通知
每个对象除了拥有关联的监视器,还拥有关联的“等待集”,即一个线程集。
当对象最先被创建时,它的等待集为空。向等待集中添加或移除线程的基础动作都是原子性的。等待集只能通过 Object.wait
、Object.notify
、Object.notifyAll
方法进行操作。
等待集的操作还会受到线程的中断和 Thread 类中用于处理中断的方法的影响。另外,Thread 类中用于睡眠和连接其他线程的方法也具有从等待和通知动作中导出的属性。
等待
调用 wait()
时,或者调用具有定时机制的 wait(long millisecs)
和 wait(long millisecs, int nanosecs)
时会发生“等待动作”。
向带有定时机制的 wait 方法传入参数值 0 等同于调用没有定时机制的 wait 方法。
如果线程返回时没有抛出 InterruptedException 异常,那么该线程就是正常返回的。
设线程 t 在对象 m 上执行 wait 方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:
- 如果 n 是 0(即线程 t 还没有锁定目标 m),那么会抛出 IllegalMonitorStateException。
- 如果是定时的等待,并且 nanosecs 的范围不在 0~999999 范围内,或者 millisecs 是负数,那么将会抛出 IllegalArgumentException。
- 如果线程 t 被中断,那么会抛出 InterruptedException,并且 t 的中断状态会被设为 false。
- 除此之外,会执行下面的序列:
- 线程 t 被添加到对象 m 的等待集,并且在 m 上执行 n 个解锁动作。
- 线程 t 不执行任何更进一步的指令,直到它从 m 的等待集中移除。线程 t 可以因下列任何一个动作而从等待集中移除,并在之后某个时刻继续执行:
- 在 m 上执行 notify 动作,在该动作中 t 被选中并从等待集中移除。
- 在 m 上执行 notifyAll 动作。
- 在 t 上执行 interrupt 动作。
- 如果是定时等待,那么在从该等到动作开始至少 millisecs 毫秒加上 nanosecs 纳秒的时间流逝之后,一个内部动作将 t 从 m 的等待集中移除。
- 内部动作由 Java 编程语言的实现执行。我们允许但不鼓励 Java 编程语言的实现执行的是“欺骗性唤醒”,即将线程从等待集中移除,由此无需显式指令就可以使得线程能够继续执行。
- 线程 t 在 m 上执行 n 个锁定操作。
- 如果线程 t 在第 2 步因为竞争而从 m 的等待集中被移除,那么 t 的中断状态会被设置为 false,并且 wait 方法会抛出中断异常。
@@@ note
第 2 条款迫使开发者必须循序以下 Java 编码习惯:对于在线程等待某个逻辑条件满足时才会终止的循环,才适合使用 wait。
@@@
每个线程必须确定可以引发从等待集中被移除的事件顺序。该顺序不必与其他排序方式一致,但是线程的行为必须是看起来就像这些事件是按照这个顺序发生的一样。
例如,如果线程 t 在 m 的等待集中,并且 t 的中断和 m 的通知都发生了,那么这些事件必然有一个顺序。如果中断被认为首先发生,那么 t 最终会抛出中断异常而从 wait 返回,并且位于 m 的等待集的另一个线程(如果在发通知时存在的话)必须收到这个通知;如果通知被认为首先发生,那么 t 最终会从 wait 中正常返回,而中断将被悬挂。
通知
调用 notify 或 notifyAll 时发生通知动作。
设线程 t 在对象 m 上执行这两个方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:
- 如果 n = 0,将会抛出非法监视器状态异常。这种情况表示线程 t 还没有处理目标 m 的锁。
- 如果 n > 0,并且是 notify 动作,那么如果 m 的等待集不为空,那么作为 m 的当前等待集中的成员线程 u 将被选中并从等待集中移除。不能保证等待集中哪个线程会被选中。从等待集中移除使得 u 可以在等待动作中继续。但是,需要注意 u 在继续执行时的加锁动作只有在 t 完全解锁 m 的监视器之后的某个时刻才能成功。
- 如果 n > 0,并且是 notifyAll 动作,那么所有线程都会从 m 的等待集中移除,因此也就都可以继续执行。
但是需要注意,其中每次仅有一个线程可以在等待过程中锁定所需的监视器。
中断
调用 Thread.interrupt 或 ThreadGoup.interrupt 方法时,发生中断动作。
设 t 是调用 u.interrupt 的线程,其中 u 是某个线程,t 和 u 可以相同。该调用动作会使得 u 的中断状态被设置为 true。
另外,如果存在某个对象 m,其等待集合包含 u,那么 u 会从 m 的等待集合中移除。这使得 u 从等待动作中恢复,在这种情况下,这个等待在重新锁定 m 的监视器之后,会抛出中断异常。
调用 Thread.isInterrupted 可以确定线程的中断状态。静态方法 Thread.interrupted 可以被线程调用以观察和清除自身的中断状态。
等待、通知、中断之间的交互
如果线程在等待时被通知了然后又被中断,那么它可以:
- 从 wait 中正常返回,尽管仍然具有悬挂的中断。
- 从 wait 中抛出中断异常。
线程不可以重置它的中断状态并从对 wait 的调用中返回。类似的,通知不能因中断而丢失。假设线程集 s 在对象 m 的等待集中,另一个线程在 m 上执行另一个 notify,那么:
- s 中至少有一个线程必须从 wait 中正常返回。
- s 中所有线程都必须抛出中断异常并退出 wait。
@@@ note
如果一个线程被 notify 中断和唤醒,并且该线程以抛出中断异常的方式从 wait 返回,那么在等待集中的其他线程必须必通知。
@@@
睡眠与让步
Thread.sleep 会导致当前运行的线程睡眠(暂时中止执行)指定的一段时间,具体时间取决于系统定时器和调度器的精确度。睡眠的线程不会丧失对任何监视器的所有权,而继续执行的时机则依赖于执行该线程的处理器的调度时机和可用性。
注意到这一点很重要:无论是 Thread.sleep 还是 Thread.yield 都没有任何同步语义。特别是,编译器不必在调用 Thread.sleep 或 Thread.yield 之前将寄存器中缓存的写操作冲刷到共享内存中,也不必在调用 Thread.sleep 或 Thread.yield 之后重新加载寄存器中缓存的值。
例如在下面的代码中,假设 this.done 是非 volatile 的 boolean 域:
while(!this.done)
Thread.sleep(1000);
编译器可以只读取 this.done 一次,并且在循环中的每次迭代中重用缓存的值。这意味着即使另一个线程修改了 this.done 的值,该循环永远也不会停止。
内存模型
给定一个程序和该程序的执行轨迹,“内存模型”可以描述该执行轨迹是否是该程序的一次合法执行。Java 编程语言的内存模型是通过以下方式实现的:查验执行轨迹中的每个读操作,并依据特定的规则检查该读操作所观察到的写操作是否有效。
内存模型描述了程序的潜在行为。Java 语言的实现可以按照其喜好来产生任何代码,只要程序的执行过程都会产生内存模型可以预测的结果。
这为 Java 语言的实现者提供了很大的自由度去执行大量的代码转换,包括重排序动作和移除不必要的同步。
示例:不正确的同步程序会展示惊人的行为
Java 编程语言的语义允许编译器和微处理器执行优化,与未正确同步的代码进行交互,而这种交互方式可能会产生看起来很荒谬的行为。下面的几个示例展示了未正确同步的程序可能会展示出惊人的行为。
例如,考虑下表中展示的样例程序轨迹。该程序使用了局部变量 r1 和 r2、共享变量 A 和 B。最初 A==B==0:
Thread 1 | Thread 2 |
---|---|
1: r2 = A; | 3: r1 = B; |
2: B =1; | 4: A = 2; |
看起来好像不可能产生 r2==2 和 r1==1 这样的结果。直觉上,在某次执行中,要么是指令 1,要么是指令 3 先到。如果是指令 1 先到,那么它应该看不到指令 4 的写操作。如果指令 3 先到,那么它应该看不到指令 2 的写操作。
如果某次执行确实展示了这种行为,即产生 r2==2 和 r1==1 这样的结果,那么我们就知道指令的顺序是 4、1、2、3,这表面上看起来很荒谬。
但是,编译器可以对其中一个线程的指令进行重排序,只要重排序不会影响该线程单独执行时的效果即可。如果指令 1、2 进行重排序,就像下表中展示的轨迹顺序,那么就很容易看到 r2==2 和 r1==1 这样的结果。
Thread 1 | Thread 2 |
---|---|
1: B = A; | 3: r1 = B; |
2: r2 =A; | 4: A = 2; |
对有些开发者而言,这种行为看起来像是“受损了”。但是应该注意到,这种代码实际上只是没有进行正确的同步:
- 在一个线程中存在对一个变量的写操作。
- 在另一个线程中存在对同一个变量的读操作。
- 而写操作和读操作并未通过同步进行排序。
这种情况是“数据竞争”的一个实例。当代码包含数据竞争时,经常会产生有悖直觉的结果。
很多机制都可以产生上述重排序。Java 虚拟机实现中的即时编译器(JIT)可以重新安排代码或处理器。另外,对于 Java 虚拟机的实现而言,其架构的内存层次结构使得代码看起来就像是被重排序过一样。在本章中,我们将任何可以重排序代码的事物都归类为“编译器”。
另一个会出现惊人结果的例子可以在下表中看到。最初,p==q 且 p.x==0。该程序也未进行正确的同步,它对其中的写操作没有进行任何强制排序就向共享内存执行了写操作。
Thread 1 | Thread 2 |
---|---|
1: r1 = P; | 1: r6 = p; |
2: r2 = r1.x; | 2: r6.x = |
3: r3 = q; | |
4: r4 = r3.x; | |
4: r5 = r1.x; |
一项常见的编译器优化是,在对 r5 执行读操作时,复用了对 r2 执行读操作后所获得的值,因为它们都是在没有任何具有干扰效果的写操作时对 r1.x 的读操作。
现在请考虑这样的情况:在 Thread 2 中对 r6.x 的赋值发生在 Thread 1 中对 r1.x 的第一次读操作和对 r3.x 的读操作之间。如果编译器决定对 r5 重用 r2 的值,那么 r2 和 r5 的值就都是 0,而 r4 的值将是 3。从开发者的角度看,在 p.x 中存储的值从 0 变成了 3,之后又变回去了。
Thread 1 | Thread 2 |
---|---|
1: r1 = P; | 1: r6 = p; |
2: r2 = r1.x; | 2: r6.x = 3; |
3: r3 = q; | |
4: r4 = r3.x; | |
4: r5 = r1.x; |
线程内语义
内存模型可以确定程序中的每个点可以读取什么值。每个线程单独的动作必须由该线程的语义来管制其行为,但是每个读取操作看到的值是由内存模型决定的。当我们提到这一点时,就称程序遵循“线程内语义”。线程内语义是用于单线程程序的语义,并且允许对线程行为进行完整的预测,而该行为是基于该线程内的读动作所能看到的值的。为了确定在程序执行中线程 t 的动作是否是合法的,我们可以直接计算线程 t 的实现,因为它将在单线程上下文中执行,就像在本规范其他部分中定义的那样。
每当线程 t 的计算会生成线程间动作时,它必须匹配为在程序顺序中紧接着到来的 t 的线程间动作 a。如果 a 是读操作,那么 t 的进一步计算将使用由内存模型确定的 a 所看到的值。
本节将提供 Java 编程语言内存模型的规范,但是不包括处理 final 域的话题,它们将在下一节进行描述。
这里描述的内存模型不基于 Java 编程语言的面向对象特性。为了保持例子的简洁性和简单性,我们经常展示的是没有类或方法定义或显式引用的代码片段。大多数例子都包括两个或多个线程,它们包含对对象的局部变量、共享全局变量、实例域的访问语句。典型情况是,我们将使用 r1 和 r2 这样的变量名来表示方法或线程的局部变量。这种变量对其他线程是不可访问的。
共享变量
可以在线程间共享的内存被称为“共享内存”或“堆内存”。所有实例域、静态域、数组元素都存储在堆内存。在本章,我们将使用“变量”来指代这些域或数组元素。
局部变量、形式方法参数、异常处理参数永远都不会在线程间共享,因此也就不受内存模型的影响。
如果至少有一个访问是写操作,两个对相同变量的访问(读或写)被称为是“冲突的”。
动作
“线程间动作”是指一个线程执行的动作可以被另一个线程探测到或者直接受另一个线程影响。程序可以执行的线程间动作有如下几种:
- 读(正常读或对非 volatile 的读):对变量的读。
- 写(正常写或对非 volatile 的写):对变量的写。
- 同步动作,包括:
- volatile 读:对变量的 volatile 读。
- volatile 写:对变量的 volatile 写。
- 锁定:锁定监视器。
- 解锁:解锁监视器。
- 线程(合成)的第一个和最后一个动作。
- 启动线程或探测线程是否已被终止的动作。
- 外部动作:可以从“执行外部”观察到的动作,其结果基于“执行外部”的环境。
- 线程分岔动作:它只能由在不执行任何内存、同步或外部动作的无限循环中的线程执行。如果一个线程执行了线程分岔动作,那么它后续会跟随无限数量的线程分岔动作。
- 引入线程分岔动作是为了对一个线程可能会如何导致所有其他线程停顿或不能有所进展的情况进行建模。
本规范只关注线程间的动作,我们不需要关心线程内的工作(如将两个局部变量加起来存储到第三个局部变量中)。如前所述,所有线程都需要遵守正确的 Java 程序线程内语义。我们通常将线程间动作更简洁的称为“动作”。
一个动作 a 由元组 <t, k, v, u> 构成,其中:
- t 是执行动作的线程。
- k 是动作的种类。
- v 是动作涉及的变量和监视器。
- 对于锁定动作,v 是被锁定的监视器;对于解锁动作,v 是被解锁的监视器。
- 如果动作是(volatile 或非 volatile)的读,那么 v 就是被读取的变量。
- 如果动作是(volatile 或非 volatile)的写,那么 v 就是被写入的变量。
- u 是该动作的任意唯一的标识符。
外部动作元组还包含额外的组成部分,它包含执行该动作的线程可以感知到的该外部动作的结果,可以是表示该动作成功或失败的信息,以及该动作读取的值。
外部动作的参数(如哪些字节要写到哪些 socket)不是外部动作元组的组成部分。这些参数由线程内的其他动作设置,并且可以通过检查线程内语义而确定。它们在内存模型中没有专门的讨论。
在不终止的执行中,不是所有的外部动作都是可观察的。
程序与程序顺序
在每个线程 t 执行的所有线程动作中,t 的程序顺序是一种全序,反映了这些动作按照 t 的线程内语义执行的顺序。
动作集是连续一致的,如果其所有动作都按照与程序顺序一致的全序(执行顺序)发生,并且每个对变量 v 的读操作 r 都可以看到由对 v 的写操作 w 写入的值,使得:
- w 在执行顺序中在 r 之前到来。
- 没有任何其他写操作 w’ 使得在执行顺序中 w 在 w’ 之前到来、而且 w’ 在 r 之前到来。
连续一致性是对程序执行中的可见性和排序做出的非常强的保障。在连续一致的执行中,在所有单独的动作(如读操作和写操作)之上存在全序(total order),它与程序的顺序一致,并且每个单独的动作都是原子性的,且对每个线程都是立即可见的。
如果程序中没有任何数据竞争,那么程序的所有执行看起来都是连续一致的。
对于由“需要被原子性的感知”和“不需要被原子性的感知”的操作构成的组,连续一致性和不存在的数据竞争仍旧不能保证这样的组中不会产生错误。
@@@ note
如果我们要使用连续一致性作为我们的内存模型,那么我们讨论过的编译器和处理器的很多优化都是非法的。
@@@
同步顺序
每次执行都有一个同步顺序。同步顺序是执行中所有同步动作之上的全序。对于每个线程 t,t 中的同步动作的同步顺序与 t 的程序顺序是一致的。
同步动作可以归纳出动作上的“被同步”关系,定义如下:
- 在监视器 m 上的解锁动作会同步所有后续的在 m 上的锁定动作(其中“后续”是按照同步顺序定义的)。
- 对于 volatile 变量 v 的写操作会同步所有后续由任何线程执行的对 v 的读操作(其中“后续”是按照同步顺序定义的)。
- 启动线程的动作会同步它所启动的线程中的第一个动作。
- 对每个变量写入缺省值(0、false、null)的操作会同步每个线程中的第一个动作。
- 尽管看起来有点怪,在给包含变量的对象分配内存之前就对该变量写入了缺省值,但是在概念上,每个对象是在程序开始时使用缺省的初始化值创建的。
- 线程 T1 中的最后一个动作会与探测到 T1 已经终止的另一个线程 T2 中的任何动作同步。
- T2 可以通过调用 T1.isAlive 或 T1.join 来实现这种探测。
- 如果线程 T1 中断了线程 T2,那么对于代码中的任何点,只要其他任何线程(包含 T2)能够确定 T2 已经被中断(可以通过抛出中断异常、调用 Thread.interrupted、Thread.isInterrupted 方法来实现),那么 T1 执行的中断动作就会在这些点进行同步。
在表示同步关系的边中,源头称为释放,目的地称为获取。
Happens-Before 顺序
两个动作可以通过 “Happens-Before” 关系进行排序。如果一个动作在另一个动作之前发生,那么第一个动作对第二个动作就是可见的,并且排在第二个动作之前。
如果我们有两个动作 x 和 y,那么我们写作 hb(x,y) 来表示 x 在 y 之前发生。
- 如果 x 和 y 是同一个线程的动作,并且按照程序顺序 x 在 y 之前到来,那么 hb(x,y)。
- 从对象的构造器的末尾到该对象的终结器的开头,存在一条表示 “Happens-Before” 的边。
- 如果动作 x 会同步接下来的动作 y,那么我们也可以得出 hb(x,y)。
- 如果 hb(x,y) 且 hb(y,z),那么 hb(x,z)。
Object 类的 wait 方法与其相关联的锁定和解锁动作,它们之间的 “Happens-Before” 关系是由这些关联的动作定义的。
@@@ note
应该注意,两个动作之间存在 “Happens-Before” 关系并不意味着在代码实现中它们必须按照该顺序发生。如果重排序产生的结果与合法的执行是一致的,那么它就并不是非法的。
@@@
例如,对由某线程构造的对象的每个域写入其缺省值的操作,就不必非要在该线程的开始之前发生,只要没有任何读操作可以观察到这个事实即可。
更具体的说,如果两个动作共享 “Happens-Before” 关系,那么对于不和它们共享 “Happens-Before” 关系的任何代码来说,它们不必看起来非要是以该顺序发生的。例如,如果在一个线程中的写操作会与在另一个线程中的读操作产生数据竞争,那么这些写操作对那些读操作来说可以看起来像是乱序发生的。
在数据竞争发生时,需要定义 “Happens-Before” 关系。
由同步的边组成集合 S 是充分的,如果他是一个最小集,使得带有程序顺序的 S 的传递闭包可以确定在执行中的所有 “Happens-Before” 的边。这个集是唯一的。
由上面的定义可以得出下面的内容:
- 在监视器上的解锁动作在每个后续在该监视器上的锁定操作之前发生。
- 对 volatile 域的写操作在每个后续对该域的读操作之前发生。
- 在线程上对 start() 的调用在被启动线程的所有动作之前发生。
- 一个线程中的所有动作在任何其线程成功的从该线程上的 join 发生之前发生。
- 任何对象的缺省值初始化在程序中的其他任何动作(除了缺省的写操作)之前发生。
如果一个程序包含了两个互相冲突且没有 “Happens-Before” 排序关系的访问操作,那么就称该程序包含“数据竞争”。
对于不是线程间动作的操作,例如对数组长度的读操作、受检强制类型转换的执行和对虚拟方法的调用,其语义不受数据竞争的直接影响。
- 因此,数据竞争不能引发不正确的行为,例如返回错误的数组长度。
- 当且仅当所有连续一致的执行都没有数据竞争,程序则是正确同步的。
- 如果程序是正确同步的,那么该程序的所有执行看起来都是连续一致的。
这对开发者来说是很好的保障。开发者不需要推断重排序方式以确定他们的代码是否包含数据竞争,因此也就不需要在确定他们的代码是否被正确的同步时推断重排序方式。一旦确定了代码是正确同步的,开发者就不需要担心重排序是否会影响他们的代码。
程序必须被正确同步以避免各种在代码重排序时会被观察到的反常行为。正确的同步并不能确保程序的整体行为是正确的,但是它使得开发者可以以简单的方式推断程序可能的行为。对于正确同步的程序,其行为对可能的重排序形成的依赖要少的多。没有正确的同步,就可能会产生非常奇怪的、混乱和反常的行为。
我们称变量 v 的读操作 r 允许观察对 v 的写操作 w,如果在执行轨迹的 “Happens-Before” 的偏序(partial-order)关系中:
- r 的排序不在 w 之前,即非 hb(r,w)。
- 中间没有介入任何对 v 的写操作 w’,即没有任何对 v 的写操作 w’ 使得 hb(w,w’) 和 hb(w’,r) 同时成立。
非正式的讲,读操作 r 允许看到写操作 w 结果,如果没有任何 “Happens-Before” 排序会阻止该操作。
如果对该动作集 A 中的每个读操作 r,用 w(r) 表示 r 可以看到的写操作,都不满足 hb(r, w(r)),或 A 中不存在些操作 w 使得 w.v = r.v、hb(w(r), w) 和 hb(w, r) 同时成立,那么动作集 A 具有 “Happens-Before” 一致性。
在具有 “Happens-Before 一致性” 的工作集中,每个读操作看到的写操作都是 “Happens-Before” 排序机制允许看到的写操作。
示例: Happens-Before 一致性
对于下图中的轨迹,初始时 A==B==0。该轨迹可以观察到 r2==0 和 r1==0,并且在 “Happens-Before” 上仍旧保持一致性,因为执行顺序允许每个读操作看到恰当的写操作。
Thread 1 | Thread 2 |
---|---|
1: B = 1; | 1: A = 2; |
2: r2 = A; | 2: r1 = B; |
因为没有任何同步,所有每个读操作都可以看到写入初始值的写操作或由另一线程执行的写操作。下面的执行顺序展示了这种行为:
1: B=1;
2: A=2;
3: r2=A; // seen initial write of 0
4: r1=B; // seen initial write of 0
另一种具有 “Happens-Before 一致性” 的执行顺序为:
1: r2=A; // seen write of A=2
2: r1=B; // seen write of B=1
3: B=1;
4: A=2;
在该执行中,读操作看到的是在执行顺序之后发生的写操作。这看起来很反常,但是是 “Happens-Before一致性” 所允许的。允许读操作看到之后发生的写操作有时可能会产生不可接受的行为。
执行
执行 E 可以用元组 <P, A, po, so, W, V, sw, hb> 表示,其构成为:
- P:程序。
- A:动作集。
- po:程序顺序,对于每个线程 t,是在 A 中由 t 执行的所有动作上的全序。
- so:同步顺序,即 A 中所有同步动作上的全序。
- W:“被看到的写动作”函数,对 A 中的每个读操作 r,会给出 W(r),即在 E 中 r 看到的写动作。
- V:“被写入的值”函数,对 A 中的每个写操作 w,会给出 V(w),即在 E 中 w 写入的值。
- sw:与….同步,即同步关系上的偏序。
- hb:之前发生,即动作上的偏序。
@@@ note
“与….同步”和“之前发生”元素是由执行中的其他组成部分以及有关良构执行的规则唯一确定的。
@@@
执行具有 “Happens-Before 一致性”,如果它的工作集具有“Happens-Before 一致性”。
良构执行
我们只考虑良构(Well-Formed)的执行。如果下面的条件都为 true,则执行 E = <P, A, po, so, W, V, sw, hb> 是良构的:
- 每个读操作看到的都是在该执行中对同一个变量的写操作。
- 所有对 volatile 变量的读操作和写操作都是 volatile 动作。对于 A 中的所有读操作,其 W(r) 都在 A 内,且 W(r).v = r.v。变量 r.v 是 volatile 的,当前仅当 r 是 volatile 读操作;w.v 是 volatile 的,当且仅当 W 是 volatile 的。
- “之前发生”顺序是偏序。
- “之前发生”顺序是由“与….同步”边和程序顺序的传递闭包给出的。它必须是有效的偏序:自反的、传递的且反对称的。
- 该执行遵守线程内一致性。
- 对每个线程 t,在 A 中由 t 执行的动作与该线程在单独执行时的程序顺序中生成的动作相同,如果每个读操作 r 看到的值都是 v(w(r)),那么每个写操作都会写入 v(w)。每个读操作看到的值是由内存模型确定的。所给出的程序顺序必须反映按照 P 的线程内语义执行动作的程序顺序。
- 该执行是 “Happens-Before 一致性”。
- 该执行遵循同步顺序一致性。
- 对所有 A 中的 volatile 读操作,即不存在 so(r, W(t)),也不存在 A 中的写操作 W 使得 w.v = r.v、so(w(r), w) 和 so(w,r)同时成立。
执行和因果关系要求
我们使用 Fd 表示这样的函数:将 F 的域限定为 d。对于所有在 d 中的 x,Fd(x) = F(x),并且对于所有不在 d 中的 x,Fd(x) 无定义。
我们使用 Pd 表示将偏序 P 限定为 d 中的元素。对于所有在 d 中的 x 和 y,P(x,y) 成立当且仅当 Pd(x,y)。如果 x 和 y 不在 d 中,那么就不存在 Pd(x,y)。
良构 E = <P, A, po, so, W, V, sw, hb> 是由 A 中的“提交动作”所验证的。如果 A 中的所有动作都能够提交,那么该执行就满足 Java 编程语言内存模型有关因果关系的要求。
从空集 C0 开始,我们执行一系列步骤,将动作从动作集 A 中取出,并将其添加到提交动作集 Ci 中,以得到新的提交集 C i+1。为了证明这种方式的合理性,对于每个 Ci,我们需要证明包含 Ci 的执行 E 满足特定的条件。
形式化的将,执行 E 满足 Java 编程语言内存模型的因果关系要求当且仅当存在:
- 动作集 C0、Ci、….,使得:
- C0 是空集。
- Ci 是 C i+1 的真子集。
- A = U(C0, C1, …)。
- 如果 A 是有限集,那么 C0、C1、….Cn 序列是有限的,以 Cn=A 结尾。
- 如果 A 是无限集,那么 C0、C1、….Cn、… 序列也可能是无限的,并且必须满足这个无限序列中的所有元素的并集等于 A。
- 良构的执行 E0、…、Ei、…,其中 Ei = <P, Ai, poi, soi, Wi, Vi, swi, hbi>
- 给定这些动作集 C0、… 和执行 Ei、…,每个在 Ci 中的动作必须是 Ei 的动作之一。所有在 Ci 中的动作必须共享与 Ei 和 E 中相同的相对的之前发生顺序和同步顺序。形式化的讲:
- Ci 是 Ai 的子集。
- hbi|ci = hb|ci
- soi|ci = so|ci
- 由 Ci 中的写操作写入的值必须与在 Ei 和 E 中写入的值相同。只有在 Ci-1 中的读操作才要求在 Ei 中看到的写操作与在 E 中看到的写操作相同。形式化的讲:
- Vi|ci = V|ci
- Wi|ci-1 = W|ci-1
- 所有在 Ei 中但不在 Ci-1 中的读操作必须看到在它们知己去哪发生的写操作。每个在 Ci - Ci-1 中的读操作 r 都必须在 Ei 和 E 中看到在 Ci-1 中的写操作,但是在 Ei 中看到的写操作可以在 E 中看到的写操作不同。形式化的讲:
- 对于任何在 Ai - Ci 中的读操作 r,都有 hbi(Wi(r), r)。
- 对于任何在 (Ci - Ci-1) 中的读操作 r,都有 Wi(r) 在 Ci-1 中并且 W(r) 在 Ci-1 中。
- 给定 Ei 的充分的“与…同步”边的集合,如果有一个“释放-获取”对在你正在提交的动作之前发生,那么该操作对必须在所有的 Ej 中都存在,其中 j >= i。形式化的讲:
- 设 SSWi 是在 hbi 的传递归约中但不在 po 中的 SWi 的边。我们称是 SSWi 为 “Ei 的充分的与…同步的边”。如果 SSWi(x,y) 和 hbi(y,z),且 z 在 Ci 中,那么对所有的 j>=i,都有 SWj(x,y)。
- 如果动作 y 被提交,那么所有在 y 之前发生的所有外部动作也都会被提交。
- 如果 y 在 Ci 中,x 是外部动作,且有 hbi(x,y),那么 x 在 Ci 中。
- 给定这些动作集 C0、… 和执行 Ei、…,每个在 Ci 中的动作必须是 Ei 的动作之一。所有在 Ci 中的动作必须共享与 Ei 和 E 中相同的相对的之前发生顺序和同步顺序。形式化的讲:
示例:“Happens-Before 一致性” 是不充分的
“Happens-Before 一致性” 是必要的但不是充分的约束集。仅仅强制实现 “Happens-Before 一致性” 仍旧会允许不可接受的行为发生,这些行为会违反我们已经为程序实现的需求。例如,“Happens-Before 一致性”使得值看起来像是“无中生有”的。通过对下表的轨迹进行详细检查就会看到这一点。
Thread 1 | Thread 2 |
---|---|
1: r1 = x; | 1: r2 = y |
2: if(r1 != 0) y = 1; | 2: if(r2 != 0) x = 1; |
上表中展示的代码是正确同步的。这看起来很令人惊讶,因为它没有执行任何同步动作。但是请记住,当且仅当在它以连续一致的方式执行时程序是正确同步的,不会有任何数据竞争。如果这段代码是以连续一致的方式执行的,每个动作都按照程序顺序发生,每个写操作都不会发生。因为不会发生任何写操作,所以不会有任何数据竞争,因此:该程序是正确同步的。
既然这个程序是正确同步的,那么我们唯一允许其产生的行为只能是连续一致的行为。但是,这个程序存在这样一种执行:它是“Happens-Before 一致”的,但不是连续一致的:
r1 = x; // sees write of x = 1
y = 1;
r2 = y; // sees write of y = 1
x = 1;
这个结果是“Happens-Before 一致”的:没有任何“Happens-Before”关系会阻止它发生。但是很明显,这个结果不可接受:没有任何连续一致性的执行会产生这种行为。因此,由于我们允许读操作看到在执行顺序中之后到来的写操作,所以有时就会产生这种不可接受的行为。
尽管允许“读操作看到在执行顺序中之后到来的写操作”有时并不是我们想要的,但是它有时又是必须的。就像我们在上一个示例的表中看到的,其中的轨迹就要求某些读操作要看到在执行顺序中之后到来的写操作。由于在每个线程中,读操作总是先到,所以在执行顺序中第一个动作必然是读操作。如果该读操作不能看到之后发生的写操作,那么它就看不到它所读取的变量初始值之外的任何其他值。很明显,这将无法反映所有的行为。
我们将“读操作何时可以看到将来的写操作”的问题称为“因果关系”,因为这些问题与上表中所展示的情况类似。在那种情况中,读操作导致写操作发生,而写操作又导致读操作发生。对于这些动作而言,没有“首因”。因此,我们在内存模型中需要一种一致的方式,以确定哪些读操作可以提前看到写操作。
诸如上个示例的表中证明了本规范在描述读操作是否可以看到执行中之后发生的写操作时,必须格外小心(一定要记住,如果一个读操作可以看到执行中之后发生的读操作,那么就表示该写操作实际上是之前就执行过的)。
内存模型将给定的执行和程序作为输入,确定该执行是否是该程序的和合法执行。它是通过渐进的构建“提交”动作集来实现这一目的的,该动作集反映了程序执行了哪些动作。 通常,下一个被提交的动作将表现为在连续一致执行中可以执行的下一个动作。但是,为了表现需要看到之后发生的写操作的读操作,我们允许某些动作的提交时机早于在它们之前发生的工作的提交时机。
很明显,某些动作可以被提早提交,但是某些动作则不行。如本例的表中的某个写操作是在对该变量的读操作之前提交的,那么读操作将看到写操作,因为会产生“无中生有”的结果。非形式化的讲,我们允许某个动作早提交,前提是我们知道该动作的发生不会导致任何数据竞争。在上表中,两个写操作都不能提早执行,因为除非读操作可以看到数据竞争的结果,否则这些写操作就不能发生。
可观察的行为和不终止的执行
对于总是会在某个边界的有限时间内终止的程序,它们的行为可以直接根据它们允许的执行而被(以非形式化的方式)理解。对于不会在有限时间段内终止的程序,会产生更多微妙的问题。
程序的可观察的行为是用该程序可以执行的外部动作的有限集来定义的。例如,只是打印“Hello”的程序可以用这样的行为集描述:对于任何非负整数 i,该行为集包含打印 “Hello” i 次的行为。
“终止”不会被显式的建模为行为,但是程序可以很容易的扩展为生成额外的外部动作 executionTermination,该动作在所有线程被终止时发生。
我们还定义了一个特殊的悬挂(hand)动作。如果行为是用包含悬挂动作的外部动作集描述的,那么它表示的行为是:在外部动作被观察到之后,程序可以在时间上无界的运行,而不需要执行任何额外的外部动作或不需要终止。程序可以悬挂,如果所有线程都被阻塞,或者该程序可以执行在数量上无界的动作,而不需要执行任何外部动作。
线程可以在各种各样的环境中被阻塞,例如当它视图获取锁或者执行依赖于外部数据的外部动作(诸如读操作)时。
执行可以导致某个线程被无限阻塞,并且该执行不会终止。在这种情况下,被阻塞线程所产生的动作必须由该线程到被阻塞时为止所产生的所有动作构成,包括导致该线程被阻塞的动作,并且不包含在导致阻塞的动作之后该线程所产生的动作。
为了推断可观察到的行为,我们需要谈谈可观察动作集。
如果 o 是执行 E 的可以观察动作集,那么 o 必须是 E 的动作集 A 的子集,并且必须包含有限数量的动作,即使 A 包含无限数量的动作也是如此。并且,如果 y 是在 o 中的动作,并且有 hb(x,y) 或 so(x,y),那么 x 在 o 中。
@@@ note
可观察动作集并没有被限制为仅能包含外部动作,但是只有在动作集中的外部动作才会被当做可观察的外部动作。
@@@
行为 B 是程序 P 允许的行为,当且仅当 B 是有限外部动作集,并且:
- 存在 P 的执行 E 和 E 的可观察动作集 O,使得 B 是 O 中的外部动作集(如果 E 中的任何线程都归于阻塞状态,并且 O 包含 E 中的所有动作,那么 B 也可以包含悬挂动作)。
- 存在动作集 O,使得 B 由悬挂动作和所有 O 中的外部动作构成,并且对于所有 k >= |O|,都存在带有动作集 的 P 的执行 E,以及动作集 O’,使得:
- O 和 O’ 都是 A 的子集,且它们满足可观察动作集的要求。
- O <= O’ <= A。
- |O’| >= k。
- O’ - O 不包含任何外部动作。
@@@ note
行为 B 没有描述 B 中的外部动作被观察到的顺序,但是其他外部动作应该如何被生成和执行的(内部)约束条件可以被施加这种限制。
@@@
final 域的语义
声明为 final 的域只会被初始化一次,但是在正常情况下永远都不会再变更。final 域的详细语义与普通域的语义有些不同。特别是,编译器在同步栅栏和对任意或未知方法的调用之间可以有很大的自由度去移动对 final 域必须被重载的场景中,也不会从内存从载它。
final 域还使得开发者无需同步而实现线程安全的不可变对象。线程安全的不可变对象可以被所有线程看做是不可变的,即使数据竞争被用来在线程间传递不可变对象的引用,也是如此。这可以提供安全保障,以防止通过不正确或有恶意的代码误用不可变类。final 域必须被正确使用,以提供不可变性的保障。
对象在其构造器执行完成时被认为是完全初始化的。对于只能在对象完全初始化之后才能看到对该对象的引用的线程,可以保证它看到该对象的 fianl 域是被正确初始化的值。
final 域的使用模型非常简单:在对象的构造器中设置 fianl 域,并且不要在另一个线程可以在该对象的构造器质性完成之前看到它的地方,对该对象的引用执行写操作。如果遵循了这一点,那么当该对象被另一个线程看到时,这个线程就总是会看到该对象的 final 域的正确构造版本,并且对于任何被这些 fianl 域引用的对象或数组,这个线程也会看到它们至少与这些 final 域同样新的版本。
示例:Java 内存模型中的 fianl 域
下面的程序展示了 final 域与普通域的比较:
class FinalFiledExample {
final int x;
int y;
static final FinalFieldExample f;
punlic FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if(f != null) {
int i = f.x; // guranteed to see 3
int j = f.y; // cound see 0
}
}
}
一个线程可能会执行该类的 wirter 方法,而另一个线程可能会执行其 reader 方法。
因为 writer 方法在该对象构造器执行完成之后会写入 f,因此可以保证 reader 方法能正确看到 f.x 的初始化值:3。但是,f.y 不是 final 的,因此不能保证 reader 方法会看到它的值为 4。
示例:用于安全目的的 final 域
final 域被设计用来保证必要的安全性。请考虑下面的程序,其中一个线程(称为线程 1)会执行:
Global.s = "/tmp/user".substring(4);
而另一个线程(2)执行:
Strin myS = Global.s;
if(myS.equals("/tmp")) System.out.println(myS);
String 对象被设计为不可变的,并且字符串操作也不执行同步。尽管 String 的实现没有任何数据竞争,但是其他涉及使用 String 对象的代码也许会有数据竞争,并且内存模型对具有数据竞争的程序只提供弱保证。特别是,如果 String 类的域不是 final 的,那么它就有可能出现这样的情况(尽管不大可能):线程 2 最初会看到字符串对象的偏移量为缺省值 0,使得可以将它与 “/tmp” 进行比较看它们是否相等。而稍后在 String 对象上的操作可能会看到正确的偏移量 4,使得该 String 对象可以被感知到是 “/usr”。Java 编程语言的很多安全特性都依赖于 String 对象被感知为真的不可变,即使恶意代码可以利用数据竞争在线程间传递 String 引用也是如此。
final 域的语义
设 o 是对象,c 是 o 的构造器,在 c 中 final 域 f 会被写入。在 o 的 final 域上的“冻结”动作会在 c 退出时发生,无论是正常退出还是猝然退出。
注意,如果一个构造器调用了另一个构造器,并且被调用的构造器设置了 final 域,那么该 final 域的冻结就会在被调用的构造器的结尾处发生。
对于每次执行,读操作的行为会受到两个额外的偏序关系影响,即解引用链 dereferences() 和内存链 mc(),它们被认为是执行的一部分(因此被认为对任何特定执行都是不变的)。这些偏序必须满足下面的约束条件(不必有唯一解决方案):
- 解引用链:如果线程 t 没有初始化对象 o,但是动作 a 是由线程 t 执行的对对象 o 的某个域或元素的读操作或写操作,那么必然存在某个由线程 t 执行的可以看到 o 的地址的读操作 r,使得 r dereferences(r,a) 成立。
- 内存链:在内存链排序上有若干约束条件。
- 如果 r 是可以看到写操作 w 的读操作,那么必然有 mc(w,r)。
- 如果 r 和 a 是使得 dereferences(r,a) 成立的动作,那么必然有 mc(r,a)。
- 如果线程 t 没有初始化对象 o,但是 w 是由线程 t 执行的对对象 o 的地址的写操作,那么必然存在某个线程 t 执行的可以看到 o 的地址读操作 r,使得 r mc(r,w) 成立。
假设没有写操作 w、冻结动作 f、动作 a(不是对 final 域的读操作)、对由 f 冻结的 final 域的读操作 r1,以及读操作 r2,使得 hb(w,f)、hb(f,a)、mc(a,r1) 和 dereferences(r1,r2) 成立,那么在确定哪些值可以被 r2 看到时,我们认为 hb(w,r2)。(这个“之前发生”排序与其他的“之前发生”排序没有构成产地闭包)
注意:dereferences 顺序是自反的,并且 r1 可以和 r2 相同。
对于对 final 域的读操作,只有被认为在这个对 final 域的读操作之前到来的写操作才是可以通过 final 域语义导出的操作。
在构造阶段读 final 域
如果某个对象位于构造它的线程中,那么对这个对象的 final 域的读操作是根据通常的 “Happens-Before” 规则,针对该域的初始化而排序的。如果该读操作出现在该域在构造器中被设置之后,那么它就会看到该 final 域已经赋过的值,否则,它会看到缺省值。
对 final 域的后续修改
在某些情况下,例如反序列化,系统需要在对象构造之后修改其 final 域。final 域可以通过反射和其他依赖于 Java 具体实现的方式被修改。唯一能够是这种修改具有合理语义的模式,就是允许先构造对象,然后再修改对象的 fianl 域的模式。这种对象不应该对其其他线程是可见的,而 final 域也不应该被读取,直至所有该对象的 final 域的更新都结束。final 域的冻结可以发生在设置该 fianl 域的构造器的末尾,或者在紧挨每个通过反射或其他特殊机制修改该 final 域的操作之后。
即使如此,还存在大量的复杂性。如果 final 域被初始化为域声明中的编译时常量表达式,那么对该 final 域的修改可能不会被观察到,因为对该 final 域的使用在编译器时就已经替换成了该常量表达式。
另一个问题是本规范允许对 final 域进行积极优化。在线程中,允许重排序对 final 域的读操作和对不再构造器中发生的对该域的修改操作。
示例:对 final 域的积极优化
class A {
final int x;
A() {
x = 1;
}
inf f() {
return d(this,this);
}
int d(A a1, A a2) {
int i = a1.x;
g(a1);
int i = a2.x;
return j - i;
}
static void g(A A) {
// use reflection to change a.x to 2
}
}
在方法 d 中,编译器可以任意对 x 的读操作和对 g 的调用进行重排序。因此,new A().f() 可能会返回 -1、0、1。
Java 编程语言的实现可以提供一种方式,用来在 final 域安全的上下文中执行代码块。如果某个对象是在 final 域安全的上下文中出现的对该 final 域的修改操作进行重排序。
final 域安全的上下文具有额外的保护措施。如果一个线程已经看到了未正确发布的对某个对象的引用,该线程通过该引用可以看到 final 域的缺省值,并且之后在 final 域安全的上下文中读取了正确发布的对该对象的引用,那么可以保证该线程可以看到该 final 域的正确的值。在形式上,在 final 域安全的上下文中执行的代码会被当做单独的线程处理(这种处理仅仅只针对 final 域的语义)。
在 Java 编程语言的实现中,编译器不应该将对 final 域的访问操作移入或移出 final 域安全的上下文(尽管它可以围绕着这种上下文的执行而移动,只要该对象不是在该上下文中构造的)。
有一种场景适合使用 fianl 域安全的上下文,即在执行器或线程池中。通过执行在彼此分离的 final 域安全的上下文中的每一个 Runnable 对象,执行器可以保证某个 Runnable 对象 o 的不正确访问将不会影响对由同一个执行器处理的其他 Runnable 做出的对 final 域的保证。
写受保护的域
正常情况下,是 final 且是 static 的域不能被修改。
但是,因历史遗留问题,System.in、System.out 和 System.err 虽然是 static final 域,但是它们必须通过 System.setIn、System.setOut、System.setErr 方法进行修改。我们将这些域称为“写受保护”的域,以便与普通 final 域区分。
编译器需要将这些域与其他 final 域区别对待。例如,对普通 final 域的读操作对同步是“免疫的”:涉及锁或 volatile 读的屏障不会影响从 final 域中读出的值。但是,由于我们可以看到对写受保护的域的值所做的变更,所以同步事件应该对它们有影响。因此,由于我们可以看到对写受保护的域的值所做的变更,所以同步事件应该对它们有影响。因此,其语义要求这些域应该被当做不能由用户代码修改的普通域进行处理,除非用户代码在 System 类中。
字撕裂
对 Java 虚拟机的实现有一种考虑,即每个域和数组元素都被认为是有区别的,对一个域或元素的更新不必与其他域或元素的读或更新操作交互。特别是,分别更新字节数组中毗邻元素的两个线程必定不会互相干涉或交互,因此也就不需要同步以确保连续的一致性。
某些处理器并不提供对单个字节进行写操作的能力。在这种处理器上通过直接读取整个字、更新恰当的字节,然后将整个字写回内存的方式来实现字节数组的更新是非法的。这个问题有时被称为字撕裂,在不能很容易的单独更新单个字节的处理器上,需要其他的实现方式。
示例:探测字撕裂
public class WordTearing extends Thread {
static final int LENGTH = 8;
static final int ITERS = 1000000;
static byte[] counts = new byte[LENGTH];
static Thread[] threads = new Thread[LENGTH];
final int id;
WordTearing(int i) {
id = i;
}
public void run() {
byte b = 0;
for(int i=0; i < ITERS; i++) {
byte v2 = counts[id];
if(v != v2) {
System.err.pringln("Word-Tearing foung: " +
"counts[" + id + "] =" + v2 +
", shoube be " + v);
return;
}
v++;
counts[id] = v;
}
}
}
这里的关键是字节必须不能被写操作覆盖为毗邻的字节。
double 和 long 的非原子化处理
考虑到 Java 编程语言的内存模型,对非 volatile 的 long 或 double 的单个写操作会当做两个分离的写操作处理:每个操作处理 32 位。这会导致一种情况:一个线程会看到由某个写操作写入的 64 位值的头 32 位、由另一个写操作写入的后 32 位。
对 volatile 的 long 或 double 值的读操作和写操作总是原子性的。
对引用的读操作和写操作总是原子性的,无论它们被实现为 32 位还是 64 位的值。
某些实现会发现将单个对 64 位的 long 或 double 值的写动作分成两个毗邻的 32 位值的写动作会更方便。由于效率的原因,这种行为是实现相关的,Java 虚拟机的实现可以自由选择对 long 或 double 值的写操作是原子性的还是分成两部分。
我们鼓励 Java 虚拟机的实现应该避免将 64 位值分开,并鼓励开发者将共享的 64 位值声明为 volatile 的,或者正确的同步使用它们的程序以避免可能出现的复杂性。
3.2 - CH02-JMM Explain
多任务和高并发的内存交互
多任务和高并发是衡量一台计算机的处理能力的重要指标之一。一般衡量一个服务器性能的高低好坏,使用每条事务处理数(Transcations Per Second, TPS),该指标比较能够说明问题,它代表着一秒内服务器平均能够响应的请求数,而 TPS 值与程序的并发能力有着非常密切的关系。“物理机”的并发问题与“虚拟机”中的情况有很多相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
由于计算机的存储设备与处理器的运算能力之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的“高速缓存(cache)”来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能够快速运行;当运算结束后再将数据从缓存同步会内存之中,这样一来处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是引入了一个新的问题:“缓存一致性(Cache Coherence)”。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性(及多个处理器看到相同的数据),这类协议有 MSI/MESI/MOSI/Dragon Protocl 等。
除此之外,为了使得处理器内部的运算单元能尽可能被充分利用,处理器可能会对输入代码进行“乱序执行(Out-Of-Order Execution)”优化,处理器会在计算之后将对乱序执行的代码进行结果重组,以保证结果的准确性。与处理器的乱序执行优化类似,Java 虚拟机的即时编译器(JIT)中也有类似的“指令重排序(Instruction Recorder)”优化。
Java 内存模型
内存模型可以理解为在特定的操作协议下,对特定的内存或告诉缓存进行读写访问的过程抽象,不同架构下的物理机拥有不同的内存模型,Java 虚拟机也有自己的内存模型,即“Java 内存模型(JMM)”。在 C/C++ 语言中则是直接使用物理硬件和操作系统的内存模型,导致不同平台下并发访问出错,需要进行多平台的兼容。而 JMM 的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,使得 Java 程序能够“一次编写,到处运行”。
主内存与工作内存
Java 内存模型的目的主要是“定义程序中各个变量的访问规则”,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与进行 Java 编程时所说的变量不同,包括了实例字段、静态字段、数组元素,但是不包括局部变量与方法参数,因为后者是线程私有的,永远不会被共享。
Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面所说的处理器的高速缓存类比),线程的工作内存中使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存和工作内存之间的交互关系如下图:
@@@ note
这里的主内存、工作内存与 Java 内存区域的 Java 堆、栈、方法区不是同一层次的内存划分,两者之间没有关系。
@@@
内存交互操作
由上面的交互关系可知,关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下 8 种操作来完成:
- lock:作用于主内存的变量,把一个变量标识为由一条线程独占的状态。
- unlock:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read:作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- load:作用于工作内存变量,把 read 操作从主内存得到的变量值放入工作内存的变量副本中。
- use:作用于工作内存变量,把工作内存变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行该操作。
- assign:作用于工作内存变量,它把一个从执行引擎接收到的值赋值给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行该操作。
- store:作用于工作内存变量,把工作内存变量值传送给主内存,以便随后的 write 操作。
- write:作用于主内存的变量,它把 store 操作从工作内存中得到的变量值传送到主内存的变量中。
如果要把一个变量从主内存复制到工作内存,就需要按序执行 read 和 load 操作;如果把变量从工作内存中同步到主内存中,就需要按序执行 store 和 write 操作。JMM 只要求上述两个操作“必须按序执行,而没有保证必须是连续执行”。也就是说在 read 和 load 之间、store 和 write 之间可以插入其他指令,如对主内存中的变量 a、b 进行访问时,可能顺序是 read a、read b、load b、load a。JMM 还规定了在执行上述 8 种基本操作时,必须满足如下规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现。
- 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须同步回主内存中。
- 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load、assign)的变量。即在对一个变量实施 use 和 store 操作之前,必须已经对该变量执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作来初始化该变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对其执行 unlock 从操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把该变量同步到主内存中(执行 store 和 write操作)。
这 8 中内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens before)原则来确定一个内存访问在并发环境下是否安全。
volatile 变量
关键字 volatile 是 JVM 中最轻量级的同步机制。volatile 变量具有两种特性:
- 保证变量的可见性。对一个 volatile 变量的读,总是能看到(任意线程)该 volatile 变量最后的写入,这个新值对于其他线程来说是立即可见的。
- 屏蔽指令重排序。指令重排序是编译器和处理器为了执行效率而对程序执行的优化手段,后文有详细分析。
volatile 语义并不能保证变量的原子性。对任意单个 volatile 变量的读/写具有原子性,但类似自增、自减这种复合操作不具有原子性,因为自增运算包括取值、加 1、重新赋值这 3 步操作,并不具备原子性。
由于 volatile 只能保证变量的可见性和屏蔽指令重排,只有满足以下两条规则时,才能使用 volatile 来保证并发安全,否则就需要加锁(使用 synchronized、lock、JUC 中的 Atomic 原子类)来保证并发中的原子性:
- 运算结果不存在数据依赖(重排序的数据依赖性),或者仅有单一的线程修改变量的值(重排序的 as-if-serial 语义)。
- 变量不需要与其他的状态变量共同参与不变约束。
因为需要在本地代码中插入许多内存屏障指令来屏蔽特定条件下的重排序,volatile 变量的写操作与读操作相比慢一些,但是其性能开销比锁低很多。
long/double 非原子协定
JMM 要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都必须具有原子性,但对于 64 位的数据类型 long 和 double,具有非原子协定:允许虚拟机经没有被修饰为 volatile 的 64 位数据的读写操作划分为两次 32 位的操作进行。(于此类似的是,在栈帧结构的局部变量表中,long 和 double 类型的局部变量可以使用 2 个能存储 32 位变量的变量槽来存储,详见“深入理解 Java 虚拟机” 第 8 章)
如果多个线程共享一个没有声明为 volatile 的 long 或 double 变量,并且同时执行存取操作,某些线程可能会读到一个即非原值、又非其他线程修改了的代表了“半个变量”的数值。不过这种情况十分罕见。因为非原子协定换句话说,同样允许 long 和 double 的读写操作实现为原子操作,并且目前绝大多数虚拟机都是这样做的。
原子性、可见性、有序性
原子性
JMM 保证的原子性变量操作包括 read、load、assign、use、store、write,而 long 和 double 非原子协定导致的非原子性操作基本可以忽略。如果需要对更大范围的代码实行原子性操作,则需要使用 JMM 提供的 lock、unlock、synchronized。
可见性
前面分析 volatile 语义时已经提到,可见性是指当一个线程修改了变量的值,其他线程能够立即得知这个修改。JMM 在变量被修改后将新值重新同步回主内存,依赖主内存作为媒介,在变量被线程读取前从内存刷新变量新值,保证变量的可见性。普通变量和 volatile 变量都是如此,只不过 volatile 的特殊规则保证了这种可见性是立即得到的,而普通变量并不具备这样严格的可见性。除了 volatile 外,synchronized 和 final 也能保证可见性。
有序性
JMM 的有序性表现为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句指“线程内表现为串行语义(as-if=serial)”,后半句指“指令重排序”和变量的“工作内存与主内存的同步延迟”现象。
重排序
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。从硬件架构上来说,指令重排序是指 CPU 采用了允许将多条指令不再按照程序规定的顺序,分开发送给相应电路单元处理器,而不是将指令任意重排。重排序分为 3 种类型:
- 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排程序的执行顺序。
- 指令级并行重排序。先来处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
JMM 重排序屏障
从 Java 源代码到最终实际执行的指令序列,会经过 3 种重排序。但是为了保证内存的可见性,Java 编译器会在生成的指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序。对于编译器的重排序,JMM 会根据重排序规则禁止特定类型的编译器重排序;对于处理器重排序,JMM 会插入特定类型的内存屏障,通过内存的屏障指令来禁止特定类型的处理器重排序。这里讨论 JMM 对处理器的重排序,为了更深刻理解 JMM 对处理器重排序的处理,先来认识一下常见处理器的重排序规则:
其中的 N 表示处理器不允许两个操作进行重排序,Y 表示允许。可以看出:常见处理器你对 StoreLoad 都是允许重排的,并且常见处理器都不允许对存在数据依赖的操作进行重排序。另外,对应数据转换这一列都为 N,所以处理器均不允许这种重排序。
那么这个结论有什么用呢?比如第一点:处理器允许 StoreLoad 操作的重排序,那么在并发编程中读线程可能读到一个未被初始化或 null 值,出现不可预知的错误,基于这一点,JMM 会在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。
内存屏障指令共有 4 类:LoadLoad、StoreStore、LoadStore、StoreLoad。详细释义参考 JMM 规范。
数据依赖性
根据上面的表格,处理器不会对存在数据依赖性的操作进行重排序。这里数据依赖性的准确定义是:如果两个操作访问同一个变量,其中一个操作是写,此时两个操作就构成了数据依赖性。常见的具有这种特点的操作有自增、自减。如果改变了具有数据依赖性的两个操作的执行顺序,那么最后的执行结果就会被改变。这也就是不能进行重排序的原因。
- 写后读:
a = 1; b = a;
- 写后写:
a = 1; a = 2;
- 读后写:
a = b; b = 1;
重排序遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行属性怒。但是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial 语义
as-if-serial 语义指的是:“无论怎么重排序,(单线程)程序的执行结果不能被改变”。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的开发者穿件了一个幻觉:单线程程序是按程序编写的顺序来执行的。as-if-serial 语义使单线程开发者无需单行重排会干扰他们,也无需担心内存可见性问题。
重排序对多线程的影响
如果代码中存在控制依赖,会影响指令序列执行的并行度。因此,编译器和处理器会采用猜测(speculation)执行来克服控制的相关性。所以重排序破坏了程序的顺序规则(该规则是说指令顺序与实际代码的执行顺序是一致的,但是处理器和编译器会进行重排序,只要最后的结果不变,重排序就是合理的)。
先行发生原则(happens-before)
前面所说的内存交互原则都必须满足一定的规则,而 happens-before 就是定义这些规则时的一个等效判断的原则。happens-before 是 JMM 定义的、两个操作之间的偏序关系:如操作 A 线程发生于操作 B,则 A 产生的影响能够被 B 观察到,“影响”包括了修改了内存中共享变量的值、发送了消息、调用了方法等。如果两个操作满足 happens-before 原则,那么就不需要进行同步操作,JVM 能够保证操作具有顺序性,此时不能随意进行重排序。否则,无法保证顺序性,就能进行指令的重排序。
happens-before 原则主要包括:
- 程序次序原则
- 管理锁定原则
- volatile 变量规则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性
详细释义参见 JMM 规范。
3.3 - CH03-JMM原则
Reference
3.1 Java内存模型的基础
3.1.1 并发编程模型的两个关键问题
在并发编程中,需要处理两个关键问题:线程之间的通信与同步。(这里所说的线程是指并发执行的活动实体)。通信是指线程之间如何交换信息。在命命令式编程中,线程之间的通信机制有两种:共享内存与消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,通过读写内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
同步是指程序中用于控制不同线程间操作所发生的相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送操作一定是在消息的接收操作之前,因此同步是隐式进行的。
Java 的并发实现采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
3.1.2 Java 内存模型的抽象结构
在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”来代指实例域、静态域和数组元素)。局部变量、方法参数、异常处理器参数不会在线程之间共享(会被保存在对应执行线程的栈上),因此不存在内存可见性问题,也不受内存模型的影响。
Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都自己的私有本地内存,本地内存中存储了该线程用以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。 JMM 的抽象示意如图所示。
从上图来看,如果线程 A 与线程 B 之间要通信的话,必须要经历下面 2 个步骤:
- 线程 A 将本地内存 A 中被更新过的共享变量刷新到主内存中。
- 线程 B 到主内存中去读取由线程 A 之前更新过的共享变量。
下面通过示意图来说明这两个步骤。
如上图所示,本地内存 A 和本地内存 B 持有主内存中共享变量 x 的副本。假设初始时,这 3 个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。
3.1.3 从源代码到指令序列的重排序
为了在执行程序时提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句 的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上 去可能是在乱序执行。
从 Java 源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
上述第 1 步属于编译器重排序,2~3 步属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(并非所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
3.1.4 并发编程模型的分类
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,从而减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所属的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了具体说明,请看下面的表。
假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终可能得到 x=y=0 的结果。具体的原因如图所示。
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3, B3)。当以这种时序执行时,程序就可以得到 x=y=0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是 A2→A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样)。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此 现代的处理器都会允许对写-读操作进行重排序。
下表是常见处理器允许的重排序类型的列表。
元格中的 “N” 表示处理器不允许两个操作重排序,“Y” 表示允许重排序。
从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作应用重排序。sparc-TSO 和 X86 拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。
为了保证内存可见性,Java 编译器在所生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为4类,如表所示。
StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他 3 个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障的开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
3.1.5 happens-before 简介
从 JDK 5 开始,Java 使用新的 JSR-133 内存模型。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
与程序员密切相关的 happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
注意:两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens-before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。
happens-before 与 JMM 的关系如图所示。
如图所示,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。对于 Java 程序员来说,happens-before 规则简单易懂,它避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
3.2 重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
3.2.1 数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列 3 种类型,如表 3-4 所示。
上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
前面提到过,编译器和处理器可能会对操作应用重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
3.2.2 as-if-serial 语义
as-if-serial 语义是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、运行时和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
上面 3 个操作的数据依赖关系如图 3-6 所示。
如图 3-6 所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。 图 3-7 是该程序的两种执行顺序。
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、运行时和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
3.2.3 程序顺序规则
根据 happens-before 的程序顺序规则,上面计算圆的面积的示例代码存在 3 个 happens-before 关系:
- A happens-before B。
- B happens-before C。
- A happens-before C。
这里的第 3 个 happens-before 关系,是根据 happens-before 的传递性推导出来的。
这里 A happens-before B,但在实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果 A happens-before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens-before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序合法,因此 JMM 允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵从这一目标,从 happens-before 的定义我们可以看出,JMM 同样遵从这一目标。
3.2.4 重排序对多线程的影响
现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码。
class RecordExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if(flag) { // 3
int i = a * a; // 4
}
}
}
flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A 首先执行 writer 方法,随后 B 线程接着执行 reader 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入呢?答案是不一定。
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系(有控制依赖关系),编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果?请看下面的程序执行时序图,如图 38 所示。
如图 3-8 所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!
下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序执行的时序图,如图 3-9 所示。
在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a * a
,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。
从图 3-9 中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
3.3 顺序一致性
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
3.3.1 数据竞争与顺序一致性
当程序未正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义下:
- 在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。马上我们就会看到,这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile、final)的正确使用。
顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内 存模型中,每个操作都必须原子执行且立刻对所有线程可见。
顺序一致性内存模型为程序员提供的视图如图 3-10 所示。
在概念上,顺序一致性模型有一个单一的全局内存,该内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上 面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。
为了更好进行理解,下面通过两个示意图来对顺序一致性模型的特性做进一步的说明。
假设有两个线程 A 和 B 并发执行。其中A线程有 3 个操作,它们在程序中的顺序是: A1→A2→A3。B线程也有3个操作,它们在程序中的顺序是:B1→B2→B3。
假设这两个线程使用监视器锁来正确同步:A 线程的 3 个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如图 3-11 所示。
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图,如图 3-12 所示。
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是: B1→A1→A2→B2→A3→B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操 作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。
3.3.3 同步程序的顺序一致性效果
下面,对前面的示例程序 ReorderExample 用锁来同步,看看正确同步的程序如何具有顺序一致性。
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() {
a = 1;
flag = true;
}
public synchronized void reader() {
if(flag) {
int i = a;
...
}
}
}
在上面示例代码中,假设 A 线程执行 writer 方法后,B 线程执行 reader 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图,如图 3-13 所示。
顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程 A 在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程 B 根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
从这里我们可以看到,JMM 在具体实现上的基本方针为:在不改变(正确同步的)程序执 行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。
3.3.4 未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的 值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM 保证线程读操作读取到的值不会无中生有的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM 需要禁止大量的处理器和编译器的优化,这对程序的执行 性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没什么意义。
未同步程序在 JMM 中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异:
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
- 字撕裂。JMM 不保证对 64 位的 long/double 型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。
第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 I/O 设备执行内存的读/写。下面,让我们通过一个示意图来说明总线的工作机制,如图3-14所示。
由图可知,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其他两个处理器则要等待 处理器 A 的总线事务完成后才能再次执行内存访问。假设在处理器 A 执行总线事务期间(无论读写),处理器 D 向总线发起了总线事务,此时处理器 D 的请求会被总线禁止。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写 操作具有原子性。
在一些 32 位的处理器上,如果要求对 64 位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语 言规范鼓励但不强求 JVM 对 64 位的 long/double 型变量的写操作具有原子性。当 JVM 在这种处理器上运行时,可能会把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行。这两个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写操作将不具有原子性。
当单个内存操作不具有原子性时,可能会产生意想不到后果。请看示意图,如图 3-15 所示。
如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时,处理器 B 中 64 位的读操作被分配到单个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A “写了一半”的无效值。
注意,在 JSR-133 之前的旧内存模型中,一个 64 位 long/double 型变量的读/写操作可以被拆分为两个 32 位的读/写操作来执行。从 JSR-133 内存模型开始(JDK5),仅仅只允许把一个 64 位l ong/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
3.4 volatile 的内存语义
当声明共享变量为 volatile 后,对这个变量的读/写将会很特别。为了揭开 volatile 的神秘面纱,下面将介绍 volatile 的内存语义及其实现。
3.4.1 volatile 的特性
理解 volatile 特性的一个好方法是把对 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下。
假设有多个线程分别调用上面程序的 3 个方法,这个程序在语义上和下面程序等价。
如上面示例程序所示,一个 volatile 变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。
锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入值。
锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是 64 位的 long 型和double 型变量,只要它是 volatile 变量,对该变量的读/写就具有原子性。如果是多个 volatile 操作或类似于 volatile++ 这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile 变量自身具有下列特性:
- 可见性。对一个 volatile 变量的读,总是能看到任意线程对这个 volatile 变量最后的写入值。
- 原子性。对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
3.4.2 volatile 写-读建立的 happens-before 关系
上面讲的是 volatile 变量自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,也更需要我们去关注。
从 JSR-133 开始,volatile 变量的写-读可以实现线程之间的通信。
从内存语义的角度来说,volatile 的写-读与锁的释放-获取有相同的内存效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。
请看下面使用 volatile 变量的示例代码。
假设线程 A 执行 writer 方法之后,线程 B 执行 reader 方法。根据 happens-before 规则,这个过程建立的 happens-before 关系可以分为 3 类:
- 根据程序次序规则,1 happens-before 2,3 happens-before 4。
- 根据 volatile 规则,2 happens-before 3。
- 根据 happens-before 的传递性规则,1 happens-before 4。
上述 happens-before 关系的图形化表现形式如下。
在上图中,每一个箭头链接的两个节点,代表了一个 happens-before 关系。黑色箭头表示 序顺序规则;橙色箭头表示 volatile 规则;蓝色箭头表示组合这些规则后提供的 happens-before 保证。
这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。
volatile 写-读的内存语义
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer 方法,随后线程 B 执行 reader 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。图 3-17 是线程 A 执行 volatile 写后,共享变量的状态示意图。
如图 3-17 所示,线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值被刷新到主内存中。此时,本地内存 A 和主内存中的共享变量的值是一致的。
volatile 读的内存语义如下。
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
图 3-18 为线程 B 读同一个 volatile 变量后,共享变量的状态示意图。
如图所示,在读 flag 变量后,本地内存 B 包含的值已经被置为无效。此时,线程 B 必须从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共享变量的值变成一致。
如果我们把 volatile 写和 volatile 读两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。
下面对 volatile 写和 volatile 读的内存语义做个总结。
- 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
- 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程B发送消息。
3.4.4 volatile 内存语义的实现
前文提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。表 3-5 是 JMM 针对编译器制定的 volatile 重排序规则表。
举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。
从表 3-5 我们可以看出。
- 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
- 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。
下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图,如图 3-19 所示。
图 3-19 中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。
这里比较有意思的是,volatile 写后面的 StoreLoad 屏障。此屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面是否需要插入一个 StoreLoad 屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现 volatile 的内存语义,JMM 在采取了保守策略:在每个 volatile 写的后面,或者在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 最终选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写-读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图,如图 3-20 所示。
图 3-20 中的 LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序。
上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。
针对 readAndWrite 方法,编译器在生成字节码时可以做如下的优化。
注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器通常会在这里插入一个 StoreLoad 屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 X86 处理器为例,图 3-21 中除最后的 StoreLoad 屏障外,其他的屏障都会被省略。
前面保守策略下的 volatile 读和写,在 X86 处理器平台可以优化成如图 3-22 所示。
前文提到过,X86 处理器仅会对写-读操作做重排序。X86 不会对读-读、读-写和写-写操作做重排序,因此在 X86 处理器中会省略掉这 3 种操作类型对应的内存屏障。在 X86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写-读的内存语义。这意味着在 X86 处理器中,volatile 写的开销比volatile 读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
JSR-133 为什么要增强 volatile 的内存语义
在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量重排序。在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行,如图 3-23 所示。
在旧的内存模型中,当 1 和 2 之间没有数据依赖关系时,1 和 2 之间就可能被重排序(3 和 4 类似)。其结果就是:读线程 B 执行 4 时,不一定能看到写线程 A 在执行 1 时对共享变量的修改。
因此,在旧的内存模型中,volatile 的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用 volatile 代替锁,请一定谨慎,具体详情请参 阅 Brian Goetz 的文章。
3.5 锁的内存语义
众所周知,锁可以让临界区互斥执行。这里将介绍锁的另一个同样重要,但常常被忽视的 功能:锁的内存语义。
3.5.1 锁的释放-获取建立的 happens-before 关系
锁是 Java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
下面是锁释放-获取的示例代码。
假设线程 A 执行 writer 方法,随后线程 B 执行 reader 方法。根据 happens-before 规则,这个过程包含的 happens-before 关系可以分为 3 类。
- 根据程序次序规则,1 happens-before 2, 2 happens-before 3, 4 happens-before 5, 5 happens-before 6。
- 根据监视器锁规则,3 happens-before 4。
- 根据 happens-before 的传递性,2 happens-before 5。
上述 happens-before 关系的图形化表现形式如图 3-24 所示。
在图 3-24 中,每一个箭头链接的两个节点,代表了一个 happens-before 关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的 happens-before 保证。
图 3-24 表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens-before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。
3.5.2 锁的释放和获取的内存语义
当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。以上 面的 MonitorExample 程序为例,A 线程释放锁后,共享数据的状态示意图如图 3-25 所示。
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的 临界区代码必须从主内存中读取共享变量。图 3-26 是锁获取的状态示意图。
对比锁释放-获取的内存语义与 volatile 写-读的内存语义可以看出:锁释放与 volatile 写有着相同的内存语义;锁获取与 volatile 读有相同的内存语义。
下面对锁释放和锁获取的内存语义做个总结。
- 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了(线程 A 对共享变量所做修改的)消息。
- 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。
3.5.3 锁内存语义的实现
本文将借助 ReentrantLock 的源代码,来分析锁内存语义的具体实现机制。请看下面的示例代码。
在 ReentrantLock 中,调用 lock 方法获取锁;调用 unlock 方法释放锁。
ReentrantLock 的实现依赖于 Java 同步器框架AbstractQueuedSynchronizer。AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态,马上我们会看到,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。
图 3-27 是 ReentrantLock 的类图(仅画出与本文相关的部分)。
ReentrantLock 分为公平锁和非公平锁,我们首先分析公平锁。使用公平锁时,加锁方法 lock 调用轨迹如下。
- ReentrantLock:lock()。
- FairSync:lock()。
- AbstractQueuedSynchronizer:acquire(int arg)。
- ReentrantLock:tryAcquire(int acquires)。
在第 4 步真正开始加锁,下面是该方法的源代码。
从上面源代码中我们可以看出,加锁方法首先读 volatile 变量 state。在使用公平锁时,解锁方法 unlock 调用轨迹如下。
- ReentrantLock:unlock()。
- AbstractQueuedSynchronizer:release(int arg)。
- Sync:tryRelease(int releases)。
在第 3 步真正开始释放锁,下面是该方法的源代码。
从上面的源代码可以看出,在释放锁的最后写 volatile 变量 state。
公平锁在释放锁的最后写 volatile 变量 state,在获取锁时首先读这个 volatile 变量。根据 volatile 的 happens-before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变得对获取锁的线程可见。
现在我们来分析非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法 lock 调用轨迹如下。
- ReentrantLock:lock()。
- NonfairSync:lock()。
- AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
在第 3 步真正开始加锁,下面是该方法的源代码。
该方法以原子操作的方式更新 state 变量,本文把 Java 的 compareAndSet 方法调用简称为 CAS。JDK 文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义。
这里我们分别从编译器和处理器的角度来分析,CAS 如何同时具有 volatile 读和 volatile 写的内存语义。
前文我们提到过,编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现 volatile 读和 volatile 写的内存语义,编译器不能对 CAS 与 CAS 前面和后面的任意内存操作重排序。
下面我们来分析在常见的 intel X86 处理器中,CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的。
下面是 sun.misc.Unsafe 类的 compareAndSwapInt() 方法的源代码。
public final native boolean compareAndSwapInt(Object o, long offset)
可以看到,这是一个本地方法调用。
如上面源代码所示,程序会根据当前处理器的类型来决定是否为 cmpxchg 指令添加 lock 前缀。如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀(Lock Cmpxchg)。反之,如果程序是在单处理器上运行,就省略 lock 前缀(单处理器自身会维护单处理器内的顺序一致性,不需要 lock 前缀提供的内存屏障效果)。
intel 的手册对 lock 前缀的说明如下。
- 确保对内存的读-改-写操作原子执行。在 Pentium 及 Pentium 之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium 4、Intel Xeon 及 P6 处理器开始,Intel 使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低 lock 前缀指令的执行开销。
- 禁止该指令,与之前和之后的读和写指令重排序。
- 把写缓冲区中的所有数据刷新到内存中。
上面的第 2 点和第 3 点所具有的内存屏障效果,足以同时实现 volatile 读写的内存语义。
经过上面的分析,现在我们终于能明白为什么 JDK 文档说 CAS 同时具有 volatile 读写的内存语义了。
现在对公平锁和非公平锁的内存语义做个总结。
- 公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。
- 公平锁获取时,首先会去读 volatile 变量。
- 非公平锁获取时,首先会用 CAS 更新 volatile 变量,这个操作同时具有 volatile 读写的内存语义。
从本文对 ReentrantLock 的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式。
- 利用 volatile 变量的写-读所具有的内存语义。
- 利用 CAS 所附带的 volatile 读写的内存语义。
3.5.4 JUC 包的实现
由于 Java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了下面 4 种方式。
- A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量。
- A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
- A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
- A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。
Java 的 CAS 会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile 变量的读/写和 CAS 可以实现线程之间的通信。把这些特性整合在一起,就形成了整个 JUC 包得以实现的基石。如果我们仔细分析 JUC 包的源代码实现,会发现一个通用化的实现模式。
- 首先,声明共享变量为 volatile。
- 然后,使用 CAS 的原子条件更新来实现线程之间的同步。
- 同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。
AQS,非阻塞数据结构和原子变量类,这些 JUC 包中的基础类都是使用这种模式来实现的,而 JUC 包中的高层类又是依赖于这些基础类来实现的。从整体来看,JUC 包的实现示意图如 3-28 所示。
3.6 final域的内存语义
与前面介绍的锁和 volatile 相比,对 final 域的读和写更像是普通的变量访问。下面将介绍 final 域的内存语义。
3.6.1 final 域的重排序规则
对于 final 域,编译器和处理器要遵守两个重排序规则。
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
下面通过一些示例性的代码来分别说明这两个规则。
这里假设一个线程 A 执行 writer 方法,随后另一个线程 B 执行 reader 方法。下面我们通过这两个线程的交互来说明这两个规则。
3.6.2 写 final 域的重排序规则
写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面。
- JMM 禁止编译器把 final 域的写重排序到构造函数之外。
- 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
现在让我们分析 writer 方法。writer 方法只包含一行代码:finalExample=new FinalExample()
。这行代码包含两个步骤,如下。
- 构造一个 FinalExample 类型的对象。
- 把这个对象的引用赋值给引用变量 obj。
假设线程 B 读对象引用与读对象的成员域之间没有重排序,图 3-29 是一种可能的执行时序。
在图 3-29 中,写普通域的操作被编译器重排序到了构造函数之外,读线程 B 错误地读取了普通变量 i 初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则“限定”在了构造函数之内,读线程 B 正确地读取了 final 变量初始化之后的值。
写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程 B “看到”对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值 1 还 没有写入普通域 i)。
3.6.3 读 final 域的重排序规则
读 final 域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器的。
reader 方法包含 3 个操作。
- 初次读引用变量 obj。
- 初次读引用变量 obj 指向对象的普通域 j。
- 初次读引用变量 obj 指向对象的 final 域 i。
现在假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,图 3-30 所示是一种可能的执行时序。
在图 3-30 中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该 域还没有被写线程 A 写入,这是一个错误的读取操作。而读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。
读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了。
3.6.4 final域为引用类型
上面我们看到的 final 域是基础数据类型,如果 final 域是引用类型,将会有什么效果?请看下列示例代码。
本例 final 域为一个引用类型,它引用一个 int 型的数组对象。对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
对上面的示例程序,假设首先线程 A 执行 writerOne() 方法,执行完后线程 B 执行 writerTwo() 方法,执行完后线程 C 执行 reader() 方法。图 3-31 是一种可能的线程执行时序。
在图 3-31 中,1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。
JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看得到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。
如果想要确保读线程 C 看到写线程B对数组元素的写入,写线程B和读线程 C 之间需要使用同步原语(lock/volatile)来确保内存可见性。
3.6.5 为什么 final 引用不能从构造函数内“溢出”
前面我们提到过,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面的示例代码。
假设一个线程 A 执行 writer() 方法,另一个线程 B 执行 reader() 方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且在程序中操作 2 排在操作 1 后面,执行 read() 方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如图 3-32 所示。
从图 3-32 可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。
3.6.6 final 语义在处理器中的实现
现在我们以 X86 处理器为例,说明 final 语义在处理器中的具体实现。
上面我们提到,写 final 域的重排序规则会要求编译器在 final 域的写之后,构造函数 return 之前插入一个 StoreStore 障屏。读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 LoadLoad 屏障。
由于 X86 处理器不会对写-写操作做重排序,所以在 X86 处理器中,写 final 域需要的 StoreStore 障屏会被省略掉。同样,由于 X86 处理器不会对存在间接依赖关系的操作做重排序,所以在 X86 处理器中,读 final 域需要的 LoadLoad 屏障也会被省略掉。也就是说,在 X86 处理器中,final 域的读/写不会插入任何内存屏障!
3.6.7 JSR-133 为什么要增强 final 的语义
在旧的 Java 内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整型 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变。
为了修补这个漏洞,JSR-133 专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则,可以为 Java 程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。
3.7 happens-before
happens-before 是 JMM 最核心的概念。对应 Java 程序员来说,理解 happens-before 是理解 JMM 的关键。
3.7.1 JMM 的设计
首先,让我们来看 JMM 的设计意图。从 JMM 设计者的角度,在设计 JMM 时,需要考虑两个关键因素。
- 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于 一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越 好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。下面让我们来看 JSR-133 是如何实现这一目标的。
上面计算圆的面积的示例代码存在 3 个 happens-before 关系,如下。
- A happens-before B。
- B happens-before C。
- A happens-before C。
在 3 个 happens-before 关系中,2 和 3 是必需的,但 1 是不必要的。因此,JMM 把 happens-before 要求禁止的重排序分为了下面两类。
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM对这两种不同性质的重排序,采取了不同的策略,如下。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求(JMM 允许这种 重排序)。
图 3-33 是 JMM 的设计示意图。
从图 3-33 可以看出两点,如下。
- JMM 向程序员提供的 happens-before 规则能满足程序员的需求。JMM 的happens-before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens-before B)。
- JMM 对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个 volatile 变量只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些 优化既不会改变程序的执行结果,又能提高程序的执行效率。
3.7.2 happens-before 的定义
happens-before 的概念最初由 Leslie Lamport 在其一篇影响深远的论文(Time,Clocks and the Ordering of Events in a Distributed System)中提出。Leslie Lamport 使用 happens-before 来定义分布式系统中事件之间的偏序关系(partial ordering)。Leslie Lamport 在这篇论文中给出了一个分布式算法,该算法可以将该偏序关系扩展为某种全序关系。
JSR-133 使用 happens-before 的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证(如果 A 线程的写操作 a 与 B 线程的读操作 b 之间存在 happens-before 关系,尽管 a 操作和 b 操作在不同的线程中执行,但 JMM 向程序员保证 a 操作将对 b 操作可见)。
JSR-133 对 happens-before 关系的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
上面的第一点是 JMM 对程序员的承诺。从程序员的角度来说,可以这样理解 happens-before 关系:如果 A happens-before B,那么 Java 内存模型将向程序员保证—— A 操作的结果将对 B 可见, 且A的执行顺序排在 B 之前。注意,这只是 Java 内存模型向程序员做出的保证!
上面的第二点是 JMM 对编译器和处理器重排序的约束原则。正如前面所言,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。
- as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的。
as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
3.7.3 happens-before 规则
JSR-133 定义了如下 happens-before 规则。
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- start() 规则:如果线程A执行操作 ThreadB.start()(启动线程B),那么 A 线程的 ThreadB.start() 操作 happens-before 于线程B中的任意操作。
- join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回。
这里的规则 1~4 前面都讲到过,这里再做个总结。由于 2 和 3 情况类似,这里只以1、3 和 4 为例来说明。图 3-34 是 volatile 写-读建立的 happens-before 关系图。
结合图 3-34,我们做以下分析。
- 1 happens-before 2 和 3 happens-before 4 由程序顺序规则产生。由于编译器和处理器都要遵守 as-if-serial 语义,也就是说,as-if-serial 语义保证了程序顺序规则。因此,可以把程序顺序规则看成是对 as-if-serial 语义的“封装”。
- 2 happens-before 3 是由 volatile 规则产生。前面提到过,对一个 volatile 变量的读,总是能看到“任意线程”之前对这个 volatile 变量最后的写入。因此,volatile 的这个特性可以保证实现 volatile 规则。
- 1 happens-before 4 是由传递性规则产生的。这里的传递性是由 volatile 的内存屏障插入策略和 volatile 的编译器重排序规则共同来保证的。
下面我们来看 start() 规则。假设线程 A 在执行的过程中,通过执行 ThreadB.start() 来启动线程 B;同时,假设线程 A 在执行 ThreadB.start() 之前修改了一些共享变量,线程 B 在开始执行后会 读这些共享变量。图 3-35 是该程序对应的 happens-before 关系图。
在图 3-35 中,1 happens-before 2 由程序顺序规则产生。2 happens-before 4 由 start() 规则产 生。根据传递性,将有 1 happens-before 4。这实意味着,线程 A 在执行 ThreadB.start() 之前对共享变量所做的修改,接下来在线程 B 开始执行后都将确保对线程B可见。
下面我们来看 join() 规则。假设线程A在执行的过程中,通过执行 ThreadB.join() 来等待线程 B 终止;同时,假设线程 B 在终止之前修改了一些共享变量,线程 A 从 ThreadB.join() 返回后会 读这些共享变量。图 3-36 是该程序对应的 happens-before 关系图。
在图 3-36 中,2 happens-before 4 由 join() 规则产生;4 happens-before 5 由程序顺序规则产生。根据传递性规则,将有 2 happens-before 5。这意味着,线程 A 执行操作 ThreadB.join() 并成功返回后,线程 B 中的任意操作都将对线程 A 可见。
3.8 双重检查锁定与延迟初始化
在 Java 多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。本文将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。
3.8.1 双重检查锁定的由来
在 Java 程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码。
在 UnsafeLazyInitialization 类中,假设A线程执行代码 1 的同时,B 线程执行代码 2。此时,线程 A 可能会看到 instance 引用的对象还没有完成初始化。
对于 UnsafeLazyInitialization 类,我们可以对 getInstance() 方法做同步处理来实现线程安全的延迟初始化。示例代码如下。
由于对 getInstance() 方法做了同步处理,synchronized 将导致性能开销。如果 getInstance() 方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果 getInstance() 方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
在早期的 JVM 中,synchronized(甚至是无竞争的 synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。
如上面代码所示,如果第一次检查 instance 不为 null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低 synchronized 带来的性能开销。上面代码表面上看起来,似乎两全其美。
- 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
- 在对象创建好之后,执行 getInstance() 方法将不需要获取锁,直接返回已创建好的对象。
双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第 4 行,代码读取到 instance 不为 null 时,instance 引用的对象有可能还没有完成初始化。
3.8.2 问题的根源
前面的双重检查锁定示例代码的第 7 行创建了一个对象。这一行代码可以分解为如下的 3 行伪代码。
上面 3 行伪代码中的 2 和 3 之间,可能会被重排序(在一些 JIT 编译器上,这种重排序是真实发生的,详情见参考文献 1 的“Out-of-order writes”部分)。2 和 3 之间重排序之后的执行时序如下。
根据 JLS,所有线程在执行 Java 程序时必须要遵守 intra-thread semantics。intra-thread semantics 保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics 允许那些在单线程内、不会改变单线程程序执行结果的重排序。上面 3 行伪代码的 2 和 3 之间虽然被重排序了,但这个重排序并不会违反 intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。
为了更好地理解 intra-thread semantics,请看如图 3-37 所示的示意图(假设一个线程 A 在构造对象后,立即访问这个对象)。
如图 3-37 所示,只要保证 2 排在 4 的前面,即使 2 和 3 之间重排序了,也不会违反 intra-thread semantics。
下面,再让我们查看多线程并发执行的情况。如图 3-38 所示。
由于单线程内要遵守 intra-thread semantics,从而能保证 A 线程的执行结果不会被改变。但是,当线程 A 和 B 按图 3-38 的时序执行时,B 线程将看到一个还没有被初始化的对象。
回到本文的主题,DoubleCheckedLocking 示例代码的第 7 行如果发生重排序,另一个并发执行的线程B就有可能在第 4 行判断 instance 不为 null。线程 B 接下来将 访问 instance 所引用的对象,但此时这个对象可能还没有被 A 线程初始化!表 3-6 是这个场景的具体执行时序。
这里 A2 和 A3 虽然重排序了,但 Java 内存模型的 intra-thread semantics 将确保 A2 一定会排在 A4 前面执行。因此,线程 A 的 intra-thread semantics 没有改变,但 A2 和 A3 的重排序,将导致线程 B 在 B1 处判断出 instance 不为空,线程 B 接下来将访问 instance 引用的对象。此时,线程 B 将会访问到一个还未初始化的对象。
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
- 不允许 2 和 3 重排序。
- 允许 2 和 3 重排序,但不允许其他线程“看到”这个重排序。
后文介绍的两个解决方案,分别对应于上面这两点。
3.8.3 基于volatile的解决方案
对于前面的基于双重检查锁定来实现延迟初始化的方案,只需要做一点小的修改(把 instance 声明为 volatile 型),就可以实现线程安全的延迟初始化。请看下面的示例代码。
当声明对象的引用为 volatile 后,3.8.2 节中的 3 行伪代码中的 2 和 3 之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行,如图 3-39 所示。
这个方案本质上是通过禁止图 3-39 中的 2 和 3 之间的重排序,来保证线程安全的延迟初始化。
3.8.4 基于类初始化的解决方案
JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为 Initialization On Demand Holder idiom)。
假设两个线程并发执行 getInstance() 方法,下面是执行的示意图,如图 3-40 所示。
这个方案的实质是:允许 3.8.2 节中的 3 行伪代码中的 2 和 3 重排序,但不允许非构造线程(这里指线程 B)“看到”这个重排序。
初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据 Java 语言规范,在首次发生下列任意一种情况时,一个类或接口类型 T 将被立即初始化。
- T 是一个类,而且一个T类型的实例被创建。
- T 是一个类,且 T 中声明的一个静态方法被调用。
- T 中声明的一个静态字段被赋值。
- T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
- T 是一个顶级类(Top Level Class),而且一个断言语句嵌套在 T 内部被执行。
在 InstanceFactory 示例代码中,首次执行 getInstance() 方法的线程将导致 InstanceHolder 类被初始化(符合情况 4)。
由于 Java 语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用 getInstance() 方法来初始化 InstanceHolder 类)。因此,在Java 中初始化一个类或者接口时,需要做细致的同步处理。
Java 语言规范规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,Java 语言规范允许 JVM 的具体实现在这里做一些优化,见后文的说明)。
对于类或接口的初始化,Java 语言规范制定了精巧而复杂的类初始化处理过程。Java 初始化一个类或接口的处理过程如下。这里对类初始化处理过程的说明,省略了与本文无关的部分;同时为了更好的说明类初始化过程中的同步处理机制,笔者人为的把类初始化的处理过程分为了 5 个阶段。
第1阶段:通过在 Class 对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
假设 Class 对象当前还没有被初始化(初始化状态 state,此时被标记为 state=noInitialization),且有两个线程 A 和 B 试图同时初始化这个 Class 对象。图 3-41 是对应的示意图。
表 3-7 是这个示意图的说明。
第 2 阶段:线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 上等待。
表 3-8 是这个示意图的说明。
第 3 阶段:线程 A 设置 state=initialized,然后唤醒在 condition 中等待的所有线程。
表 3-9 是这个示意图的说明。
第 4 阶段:线程B结束类的初始化处理。
线程 A 在第 2 阶段的 A1 执行类的初始化,并在第 3 阶段的 A4 释放初始化锁;线程 B 在第 4 阶段的 B1 获取同一个初始化锁,并在第 4 阶段的 B4 之后才开始访问这个类。根据 Java 内存模型规范的锁规则,这里将存在如下的 happens-before 关系。
这个 happens-before 关系将保证:线程 A 执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程 B 一定能看到。
第 5 阶段:线程 C 执行类的初始化的处理。
表 3-11 是这个示意图的说明。
在第 3 阶段之后,类已经完成了初始化。因此线程 C 在第 5 阶段的类初始化处理过程相对简单一些(前面的线程 A 和 B 的类初始化处理过程都经历了两次锁获取-锁释放,而线程 C 的类初始化处理只需要经历一次锁获取-锁释放)。
线程 A 在第 2 阶段的 A1 执行类的初始化,并在第 3 阶段的 A4 释放锁;线程 C 在第 5 阶段的 C1 获取同一个锁,并在在第 5 阶段的 C4 之后才开始访问这个类。根据 Java 内存模型规范的锁规则,将存在如下的 happens-before 关系。
这个 happens-before 关系将保证:线程 A 执行类的初始化时的写入操作,线程 C 一定能看到。
通过对比基于 volatile 的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于 volatile 的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于 volatile 的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。
3.9 Java 内存模型综述
前面对 Java 内存模型的基础知识和内存模型的具体实现进行了说明。下面对 Java 内存模型的相关知识做一个总结。
3.9.1 处理器的内存模型
顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会以顺 序一致性内存模型为参照。在设计时,JMM 和处理器内存模型会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型的读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分 为如下几种类型。
- 放松程序中写-读操作的顺序,由此产生了 Total Store Ordering 内存模型,简称为TSO。
- 在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了 Partial Store Order 内存模型,简称为PSO。
- 在前面两条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了 Relaxed Memory Order 内存模型(简称为 RMO)和 PowerPC 内存模型。
注意,这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的。因 为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序。
表 3-12 展示了常见处理器内存模型的细节特征如下。
从表 3-12 中可以看到,所有处理器内存模型都允许写-读重排序,原因在第 1 章已经说明过:它们都使用了写缓存区。写缓存区可能导致写-读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区。由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓存区中的写。
表 3-12 中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计得会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
由于常见的处理器内存模型比JMM要弱,Java 编译器在生成字节码时,会在执行指令序 列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱不同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。图 3-48 展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图。
JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 Java 程序员呈现了一个一致的内存模型。
3.9.2 各种内存模型之间的关系
JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存 模型是一个理论参考模型。下面是语言内存模型、处理器内存模型和顺序一致性内存模型的 强弱对比示意图,如图 3-49 所示。
从图中可以看出:常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计得会越弱。
3.9.3 JMM 的内存可见性保证
按程序类型,Java 程序的内存可见性保证可以分为下列 3 类。
- 单线程程序。单线程程序不会出现内存可见性问题。编译器、运行时和处理器会共同确 保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行 结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步/未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
注意,最小安全性保障与 64 位数据的非原子性写并不矛盾。它们是两个不同的概念,它 们“发生”的时间点也不同。最小安全性保证对象默认初始化之后(设置成员域为0、null或 false),才会被任意线程使用。最小安全性“发生”在对象被任意线程使用之前。64 位数据的非原子性写“发生”在对象被多个线程使用的过程中(写共享变量)。当发生问题时(处理器 B 看到仅仅被处理器 A “写了一半”的无效值),这里虽然处理器 B 读取到一个被写了一半的无效值,但这个值仍然是处理器 A 写入的,只不过是处理器 A 还没有写完而已。最小安全性保证线程读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。但最小安全性并不保证 线程读取到的值,一定是某个线程写完后的值。最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。
图 3-50 展示了这 3 类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同。
只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与 该程序在顺序一致性内存模型中的执行结果一致。
3.9.4 JSR-133 对旧内存模型的修补
JSR-133 对 JDK 5 之前的旧内存模型的修补主要有两个。
- 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写-读和锁的释放-获取具有相同的内存语义。
- 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。在保证 final 引用不会从构造函数内逸出的情况下,final 具有了初始化安全性。
3.4 - CH04-JSR133-FAQ
什么是内存模型
在多核系统中,处理器一般拥有一层或多层缓存,这些缓存通过加速数据访问(因为数据距离处理器更近)和减少共享内存在总线上的通讯(因为通过本地缓存来满足许多内存操作)来提高 CPU 性能。缓存能够大大提升性能,但同时也带来了很多挑战。例如,当两个 CPU 同时检查相同的内存地址时会发生什么呢?在什么样的条件下它们能够看到相同的值?
在处理器层面,内存模型定义了一个充要条件,“让当前的处理器可以看到其他处理器写入到内存的数据”,以及“其他处理器可以看到当前处理器写入到内存的数据”。有些处理器拥有很强的内存模型(strong memory model),能够让所有处理器在任何时候从任何指定的内存地址上看到完全相同的值。而另外一些处理器则有较弱的内存模型(weaker memory model),在这种处理器中,必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作,或者让其他处理器看到当前处理器的写操作。这些内存屏障通常在 lock 和 unlock 操作的时候完成。内存屏障在高级语言中对开发者是不可见的。
在强内存模型下,编写程序有时会很容易,因为减少了对内存屏障的依赖。但是即使在一些最强的内存模型下,内存屏障仍然是必须的。设置内存屏障往往与我们的直觉并不一致。近来处理器设计的趋势更偏向于弱的内存模型,因为弱内存模型削弱了缓存一致性,所以在多处理器平台和更大容量的内存下可以实现更好的可伸缩性。
“一个线程的写操作对其他线程可见”,该问题是因为编译器对代码进行重排序导致的。例如,只要代码的移动不会改变程序的语义,当编译器认为向下移动一个写操作会更有效的时候,编译器就会对代码执行移动。如果编译器推迟执行一个操作,其他线程可以能在这个操作执行完成之前都不会看到该操作的结果,这反映了缓存的影响。
此外,写入内存的操作能够被移动到程序中更靠前的位置。这种情况下,其他线程在程序中可能看到一个比实际发生更早的写操作。所有这些灵活性的设计都是为了通过给编译器、运行时或硬件提供灵活性以使其能够在最佳顺序的情况下来执行操作。在内存模型的限定下,我们能够获得更高的性能。
考虑如下示例:
ClassReordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
假设在两个并发线程中执行这段代码,读取变量 Y 将会得到 2。因为这个写入比写入到变量 X 的操作更晚一些,程序员可能认为读取变量 X 将一定会得到 1。但是,写入操作可能被重排序。如果发生了重排序,那么,就能发生对变量 Y 的写入操作,读取两个变量的操作紧随其后,而且写入到变量 X 的操作能够发生。程序的结果可能是 r1==2, r2==0。
Java 内存模型描述了在多线程中哪些行为是合法的,以及线程如果通过内存进行交互。它描述了“程序中的变量”与“从内存或寄存器获取或存储它们的底层细节”之间的关系。Java 内存模型通过使用各种硬件和编译器优化来正确实现以上能力。
Java 包含了几个语言级别的关键字,包括:volatile、final、synchronized,目的是为了帮助开发者向编译器描述一个程序的并发需求。Java 内存模型定义了 volatile 和 synchronized 的行为,更重要的是,保证了通过的 Java 程序在所有测处理器架构下都能正确运行。
其他语言有内存模型吗
大部分其他的语言,如 C/C++,都没有被设计成直接支持多线程。这些语言对于发生在编译器和处理器平台架构的重排序行为的保护机制会严重依赖于程序中使用的线程库(如 pthreads)、编译器,以及代码所运行的硬件平台所提供的保障,尤其是 CPU 架构。
JSR 133 是什么
从 1997 年以来,人们不断发现 Java 语言规范的第 17 章定义的 Java 内存模型中存在一些严重的缺陷。这些缺陷会导致一些使人迷惑的行为(如 final 字段会被观察到值的变化)和破坏编译器常见的优化功能。
Java 内存模型是一个雄心勃勃的计划,它是编程语言规第一次尝试合并一个能够在各种处理器架构中为并发提供一致语义的内存模型。不过,定义一个既一致又直观的内存模型远比想象的要难。JSR 133 和 Java 语言定义了一个新的内存模型,它修复了早期内存模型中的缺陷。为了实现 JSR133,final 和 volatile 的语义需要被重新定义。
完整的定义见文档,但是正式的语义不是小心翼翼的,它是令人惊讶的清醒的,目的是让人们意识到一些看似简单的概念(如同步)其实有多复杂。幸运的是,你不需要懂得这些正式定义的细节——JSR 133 的目的是创建一组正式语义,这些语义提供了描述 volatile、synchronized、final 如何正确工作的直观框架。
JSR 133 的目标包括:
- 保留已经存在的安全保证(如类型安全)并强化其他的安全保证。例如,变量值不能凭空创建:线程观察到的每个变量的值必须是被其他线程合理创建的。
- 正确同步的程序的语义应该尽量简单和直观。
- 应该定义未完成或为正确同步的程序的语义,主要是为了把潜在的安全危害降到最低。
- 开发者应该能够自信的推断多线程如何与内存进行交互。
- 能够在现在许多流行的硬件架构中设计正确且高性能的 JVM 实现。
- 应该能够提供“安全初始化”保证。如果一个对象正确的进行了构建(指它的引用没有在构建时逸出),那么所有能够看到该对象的引用的线程,在不进行同步的情况下,也将能够看到在构造方法中设置的 fianl 字段的值。
- 应该尽量不影响现有的代码。
重排序意味着什么
在很多情况下,访问一个程序变量(对象实例字段、类静态字段、数组元素)可能会使用不同的执行顺序,而不是程序语义所指定的顺序执行。编译器能够自由的以优化的名义去改变指令顺序。在特定环境下,处理器可能会次序颠倒的执行指令。数据可能在寄存器、缓冲区或主存中以不同的次序移动,而不是按照程序指定的顺序。
例如,如果一个线程写入值到字段 a、然后写入值到字段 b,并且 b 的值不依赖于 a 的值,那么,处理器能够自由的调整它们的执行顺序,而且缓冲区能够在 a 之前刷新 b 的值到主内存。有许多潜在的重排序来源,例如编译器、JIT 及缓冲区。
编译器、运行时和硬件被期望一起协力创建看似是按顺序执行的语义的假象,这意味着在单线程程序中,程序应该不能观察到重排序的影响。但是,重排序在没有正确同步的多线程程序中会产生作用,在这些多线程程序中,一个线程能够观察到其他线程的影响,也可能检测到其他线程将会以一种不同于程序语义所规定的执行顺序来访问变量。
大部分情况下,一个线程不会关注到其他线程在做什么,但是当它需要关注的时候,就需要使用(正确的)同步了。
旧内存模型的缺陷有哪些?
旧的内存模型中有几个严重的问题。这些问题很难理解,因此被广泛的违背。例如,旧的存储模型在许多情况下,不允许 JVM 发生各种重排序行为。旧的内存模型中让人产生困惑的因素造就了 JSR 133 的诞生。
例如,一个被广泛认可的概念就是:如果使用 final 字段,那么就没有必要在多个线程间使用同步来保证其他线程能够看到这个字段的值。尽管这是一个合理的假设和明显的行为,也是我们所期待的结果。实际上,在旧的内存模型中,我们想让程序正确运行起来却是不行的。在旧的内存模型中,final 字段并没有同其他字段进行区别对待——这意味着同步是保证所有线程看到一个在构造方法中初始化为 final 字段的唯一方法。结果——如果没有正确进行同步,对一个线程来说,它可能看到一个(final)字段的默认值,然后在稍后的时间里,又能看到构造方法中设置的值。这意味着,一些不可变的对象,例如 String,能够改变它们的值——这是在是让人很郁闷。
旧的内存模型允许 volatile 变量的写操作和非 volatile 变量的读写操作一起进行重排序,这和大多数的开发人员对于 volatile 变量的直观感受是不一致的,因此会造成迷惑。
最后,我们将看到的是,开发者对于“程序没有被正确同步的情况下将会发生什么”的直观感受通常是错误的。JSR 133 的目的之一就是要引起这方面的注意。
没有正确同步的含义是什么
没有正确同步的代码对于不同的人来说可能会有不同的理解。在 Java 内存模型这个语义环境下,我们谈到“没有正确同步”时指的是:
- 一个线程中存在对一个变量的写操作。
- 另外一个线程存在对该变量的读操作。
- 而且写操作和读操作之间没有通过同步来保证顺序。
当违反这些规则时,我们就说在这个变量上有一个“数据竞争”。一个拥有数据竞争的程序就是一个没有正确同步的程序。
同步会做些什么
同步有几个方面的作用。最广为人知的就是“互斥”——一次只有一个线程能够获得一个监视器,因此,在一个监视器上同步意味着一旦一个线程进入到该监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。
但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或之中的一个内存写入操作以可预知的方式对其他拥有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,该监视器拥有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器拥有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其他线程对共享变量的修改对当前线程来说就变得可见了。
基于缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的系统。但是,重排序效果能够在单一处理器上很容易的观察到。对于编译器来说,在获取之前或者释放之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作时,我们使用了简述的方式来描述大量可能的影响。
新的内存模型语义在内存操作(读写字段、加解锁)以及其他线程的操作(start/join)中创建了一个部分(partial,相对于整个程序的语义顺序)排序,在这些操作中,一些操作被称为 happens before 其他操作。当一个操作在另一个操作之前发生,第一个操作保证能够被排到前面并且对第二个操作可见。这些排序的规则如下:
- 线程中的每个操作 happenns before 该线程中在程序顺序上后续的每个操作。
- 解锁一个监视器的操作 happens before 随后对相同监视器进行的解锁操作。
- 对 volatile 字段的写操作 happens before 后续对相同 volatile 字段的读取操作。
- 在线程上调用 start 方法 happens before 这个线程启动后的任何操作。
- 一个线程中的所有操作都 happens before 从这个线程的 join 方法成功返回的任何其他线程。(即其他线程等待一个线程的 join 方法完成,那么,这个线程中的所有操作 happens before 其他线程中的所有操作)
这意味着:对于任何内存操作,该内存操作在退出一个同步块之前对一个线程是可见的,对任何线程在它进入一个被相同的监视器保护的同步块之后都是可见的,因为所有内存操作 happens before 释放监视器、(当前线程)释放监视器 happens before (其他线程)获取监视器。
其他类似如下模式的实现,被一些人用来强迫实现一个内存屏障,不会生效:
synchronized (new Object()) {}
这段代码其实不会执行任何操作,编译器会将其完全移除,因为编译器知道没有其他线程会使用相同监视器进行同步。要看到其他线程的结果,你必须为一个线程建立 happens before 关系。
@@@ note
对两个线程来说,为了正确建立 happens before 关系而在相同监视器上面进行同步是非常重要的。以下观点是错误的:当线程 A 在对象 X 上面同步的时候,所有东西对线程 A 可见,线程 B 在对象 Y 上面进行同步的时候,所有东西对线程 B 也是可见的。释放监视器和获取监视器必须匹配(即要在相同监视器上面完成这两个操作),否则,代码就会存在数据竞争。
@@@
final 字段是如何改变其值的
我们可以通过分析 String 类的实现细节来展示一个 final 变量是如何可以改变的。
String 对象包含 3 个字段:一个 character 数组、一个数据的 offset、一个 length。实现 String 类的基本原理是:它不仅仅拥有 character 数组,而且为了避免多余的对象分配和拷贝,多个 String 和 StringBuffer 对象都会共享相同的 character 数组。因此,String.substring 方法能够通过改变 length 和 offset,通过共享原始的 character 数组来创建一个新的 String 对象。对于一个 String 来说,这些字段都是 final 型的字段。
String s1 = "/usr/tmp";
String s2 = s1.substring(4);
s2 的 offset 值为 4,length 的值为 4。但是,在旧的内存模型下,对于其他线程来说,有机会看到 offset 拥有默认的值 0,而且,在稍后一点时间会看到正确的值 4,好像字符串的值从 “/usr” 变成了 “/tmp” 一样。
旧的 Java 内存模型允许这些行为,部分 JVM 已经展现出这样的行为了。而在新的 Java 内存模型中,这是非法的。
在新的 JMM 下 final 是如何工作的
一个对象的 final 字段值是在对象的构造方法中设置的。假设对象被正确的构造了,一旦对象被构造,在构造方法里面设置 final 字段的值在没有同步的情况下会对所有其他线程可见。另外,引用这些 final 字段的对象或数组都会看到其最新值。
对于一个对象来说,被正确构造又是什么意思?简单来说,它意味着这个正在构造的对象的引用在构造期间没有被允许逸出(参见安全构造技术)。换句话说,不要让其他线程在其他地方能够看见一个处于构造期的对象引用。不要指派给一个静态字段,不要作为一个 listener 注册给其他对象等。这些操作应该在构造方法完成之后进行,而不是构造方法中进行。
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
上面的类展示了 fianl 字段应该如何使用。一个正在执行 reader 方法的线程保证看到 f.x 的值为 3,因为它是 final 字段。它不保证看到 f.y 的值为 4,因为 f.y 不是 final 字段。如果 FinalFieldExample 的构造方法是如下这样:
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
那么从 global.obj 中读取 this 引用线程不会保证读取到的 x 的值 3。
能够看到字段的正确构造的值固然不错,但是,如果字段本身就是一个引用,那么,你还是希望你的代码能够看到引用所指向的这个对象(或数组)的最新值。如果你的字段是 final 字段,那就就是能够保证的。因此,当一个 final 指针指向一个数组,你无需担心线程能够看到引用的最新值却看不到引用所指向的数组的最新值。重复一下,这里的“正确的”是指“对象构造方法结尾的最新的值”而不是“最新可用的值”。
现在,在讲了如上的片段之后,如果在一个线程构造了一个不可变对象之后(对象仅包含 final 字段),你希望保证该对象被其他线程正确的查看,你仍然需要使用同步才行。例如,没有其他的方式可以保证不可变对象的引用将被第二个线程看到。使用 final 字段的程序应该仔细调试,这需要深入而且仔细的理解并发在你的代码中是如被被管理的。
如果你使用 JNI 来改变你的 final 字段,这方面的行为是没有定义的。
volatile 的作用
Volatile 字段是用于线程间通讯的特殊字段。每次读 volatile 字段都会看到其他线程写入该字段的最新值;实际上,开发者之所以要定义 volatile 字段是因为在某些情况下由于缓存和重排序所看到的陈旧的变量值是不可接受的,编译器和运行时禁止在寄存器里分配它们。它们还必须保证,在它们被写好之后,它们被从缓冲区刷新到主存中,因此,它们能够对其他线程立即可见。同样,在读取一个 volatile 字段之前,缓冲区必须失效,因为值是存在于主存中而不是本地处理器的缓冲区。在重排序访问 volatile 变量的时候还有其他的限制。
在旧的内存模型下,访问 volatile 变量不能被重排序,但是,它们可能和访问非 volatile 变量一起被重排序。这破坏了 volatile 字段从一个线程到另一个线程作为一个信号条件的手段。
下新的内存模型下,volatile 变量仍然不能彼此重排序。和旧模型不同的是,volatile 周围的普通字段也不再能随便的重排序了。写入一个 volatile 字段和释放监视器拥有相同的内存影响,而且读取 volatile 字段和获取监视器也有相同的内存影响。事实上,因为新的内存模型在重排序 volatile 字段访问上面和其他字段(volatile 或非 volatile)访问上面拥有更加严格的约束。当线程 A 写入一个 volatile 字段 f 的时候,如果这时线程 B 读取 f 的值,那么对线程 A 可见的任何东西都变得对线程 B 可见了。
下面的例子展示了应该如何使用 volatile 字段:
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}
假设一个名为 “writer” 的线程和一个名为 “reader” 的线程。对变量 v 的写操作会等到变量 x 被写入到内存之后,然后读线程才能看到 v 的值。因此,如果 reader 线程看到了 v 的值为 true,那么,这也保证能够看到之前发生的“向 x 写入 42”这个操作。而在旧的内存模型中未必如此。如果 v 不是 volatile 变量,那么,编译器可以在 writer 线程中重排序写入操作,然后 reader 线程中读取 x 变量的操作可能会看到其值为 0。
实际上,volatile 的语义已经被加强了,已经块达到同步的级别了。为了可见性的原因,每次读取和写入一个 volatile 字段已经像是半个同步操作了。
@@@ note
对于两个线程来说,为了正确的设置 happens before 关系,访问相同的 volatile 变量是很重要的。以下结论是不正确的:当线程 A 写 volatile 字段 f 的时候,线程 A 可见的所有东西,在线程 B 读取字段 g 的时候,都变得对线程 B 可见了。释放操作和获取操作必须匹配(也就是在同一个 volatile 字段上完成)。
@@@
新的 JMM 是否修复了双重锁检查问题
臭名昭著的双重锁检查(也称多线程单例模式)是一个骗人的把戏,它用来支持 lazy 初始化,同时避免过度使用同步。在非常早的 JVM 中,同步非常慢,开发人员非常系统删掉它。双重锁检查代码如下:
// double-checked-locking - don't do this!
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
这看起来好像非常聪明——在公用代码中避免了同步。这段代码只有一个问题——它不能正常工作。为什么呢?最明显的原因是,初始化实例的写入操作和实例字段的写入操作能够被编译器或缓冲区重排序,重排序可能会导致返回部分构造的一些东西。就是我们会读取到一个没有被初始化的对象。这段代码还有很多其他的错误,以及为什么对这段代码的算法修正是错误的。在旧的内存模型下无法将其修复。更多深入的信息参见:Double-checkedlocking: Clever but broken 和 The “Double-Checked Locking is Broken” Declaration。
很多人认为使用 volatile 关键字能够消除双重锁检查模式的问题。在 1.5 的 JVM 之前,volatile 并不能保证这段代码能够正常工作(因环境而定)。在新的内存模型中,实例字段使用 volatile 可以解决双重锁检查问题,因为在构造线程来初始化一些东西和读取线程返回它的值之间有 happens before 关系。
然后,对于喜欢使用双重锁检查的人来说(我们真的希望没有人喜欢这么用),仍然不是好消息。双重锁检查的重点是为了避免过度使用同步导致性能问题。从 Java 1.0 开始,同步不仅会有昂贵的性能开销,而在新的内存模型下,使用 volatile 的性能开销也有所上升,几乎达到了和同步一样的性能开销。因此,使用双重锁检查来实现单例模式仍然不是一个好的选择(注意这里需要修正,在大多数平台下,volatile 的性能开销还是比较低的)。
使用 IODH 来实现多线程模式下的单例会更加易读:
private static class LazySomethingHolder {
public static Something something = new Something();
}
public static Something getInstance() {
return LazySomethingHolder.something;
}
这段代码是正确的,因为初始化由 static 字段来保证。如果一个子弹设置在 static 舒适化中,对其他访问这个类的线程来说是能够正确的保证其可见性的。
如何实现一个 VM
参考 The JSR-133 Cookbook for Compiler Writers。
为什么要关注 JMM
为什么你需要关注java内存模型?并发程序的bug非常难找。它们经常不会在测试中发生,而是直到你的程序运行在高负荷的情况下才发生,非常难于重现和跟踪。你需要花费更多的努力提前保证你的程序是正确同步的。这不容易,但是它比调试一个没有正确同步的程序要容易的多。
Reference
3.5 - CH05-JSR133-Cook
从最初的探索至今已经有十年了。在此期间,很多关于处理器和语言的内存模型的规范和问题变得更清楚,更容易理解,但还有一些没有研究清楚。本指南一直在修订、完善来保证它的准确性,然而本指南部分内容展开的细节还不是很完整。想更全面的了解, 可以特别关注下 Peter Sewell 和 Cambridge Relaxed Memory Concurrency Group 的研究工作。
这是一篇用于说明在 JSR 133 中制定的新 Java 内存模型(JMM) 的非官方指南。这篇指南提供了在最简单的背景下各种规则存在的原因,而不是这些规则在指令重排、多核处理器屏障指令和原子操作等方面对编译器和 JVM 所造成的影响。它还包括了一系列如何遵守 JSR 133 的指南。本指南是“非官方”的文档,因为它还包括特定处理器性能和规范的解释,我们不能保证所有的解释都是正确的。此外,处理器的规范和实现也可能会随时改变。
Reference
4 - JVM 深入理解 V2
4.1 - CH01-走近 Java
Java 技术体系
Java 技术体系包括以下几个组成部分:
- Java程序设计语言
- 各种硬件平台上的Java虚拟机
- Class文件格式
- Java API类库
- 来自商业机构和开源社区的第三方Java类库
我们可以把Java程序设计语言、Java虚拟机、Java API类库这三部分统称为JDK(Java Development Kit),JDK是用于支持Java程序开发的最小环境,在后面的内容中,为了讲解方便,有一些地方会以JDK来代替整个Java技术体系。另外,可以把Java API类库中的Java SE API子集和Java虚拟机这两部分统称为JRE(Java Runtime Environment),JRE是支持Java程序运行的标准环境。图1-2展示了Java技术体系所包含的内容,以及JDK和JRE所涵盖的范围。
以上是根据各个组成部分的功能来进行划分的,如果按照技术所服务的领域来划分,或者说按照Java技术关注的重点业务领域来划分,Java技术体系可以分为4个平台,分别为:
- Java Card:支持一些Java小程序(Applets)运行在小内存设备(如智能卡)上的平台。
- Java ME(Micro Edition):支持Java程序运行在移动终端(手机、PDA)上的平台,对Java API有所精简,并加入了针对移动终端的支持,这个版本以前称为J2ME。
- Java SE(Standard Edition):支持面向桌面级应用(如Windows下的应用程序)的Java平台,提供了完整的Java核心API,这个版本以前称为J2SE。
- Java EE(Enterprise Edition):支持使用多层架构的企业应用(如ERP、CRM应用)的Java平台,除了提供Java SE API外,还对其做了大量的扩充并提供了相关的部署支持,这个版本以前称为J2EE。
虚拟机发展史
- Sun Classic/Exact VM,只能使用纯解释方式来执行 Java 代码,如果需要使用 JIT 编译期,就需要进行外挂。一旦外挂 JIT,解释器便被完全接管。
- Exact VM,已具备现代高性能虚拟机的雏形,具备两级即时编译期、编译期与解释器混合工作模式,使用精准式内存管理。
- HotSpot,名称指的是热点代码探测技术,准确式 GC。
- Sun Mobile-Embedded VM/Meta-Circular VM
- JRockit VM,专注于服务器端应用,它可以不太关注程序启动速度,因此内部不包含解析器实现,全部代码都靠即时编译器编译后执行。除此之外,J垃圾收集器和MissionControl服务套件等部分的实现,在众多Java虚拟机中也一直处于领先水平。
- IBM J9 VM,它是一款设计上从服务器端到桌面应用再到嵌入式都全面考虑的多用途虚拟机,J9的开发目的是作为IBM公司各种Java产品的执行平台,它的主要市场是和IBM产品(如IBM WebSphere等)搭配以及在IBM AIX和z/OS这些平台上部署Java应用。
- Azul VM,基于 HotSpot 改进,用于专有硬件Vega系统上的 Java 虚拟机,每个 Azul 实例可与管理至少数十个 CPU 和数百GB内存的硬件资源,并提供在巨大内存范围内实现可供 GC 时间的垃圾回收器,为专有硬件优化的线程调度。
- BEA Liquid VM,即现在的 JRockit VE,不需要操作系统的支持,自身实现了一个专用操作系统的必要功能。由虚拟机越过通用操作系统直接控制硬件可以获得很多好处,如在线程调度时不需要进程内核态/用户态的切换等,以最大限度的发挥硬件的能力。
- Apache Harmony,很多优秀代码被吸纳进 JDK 7 和 Google Android SDK 中,尤其对 Android 的发展起到了很大的推动作用。
- Google Android Dalvik VM,Andriod 平台的核心组件,不遵循 JVM 规范,不能直接执行 Class 文件,使用的是寄存器架构而非 JVM 中常见的架构。其执行文件可以通过 Class 文件转化而来,可以使用 Java 来编写应用并调用大部分的 Java API。随着 Android 的迅猛发展得以快速发展。
- Microsoft JVM,Windows 平台特定 JVM,最终因侵权终止。
- GraalVM,支持多种编程语言的通用虚拟机,低负载、高性能。
热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。 如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。
未来展望
- 模块化。解决应用系统与技术平台越来越复杂、越来越庞大的一个重要功能。
- 混合语言。当单一的Java开发已经无法满足当前软件的复杂需求时,越来越多基于Java虚拟机的语言开发被应用到软件项目中,Java平台上的多语言混合编程正成为主流,每种语言都可以针对自己擅长的方面更好地解决问题。
- 多核并行。ForkJoin 和 Lambda 帮助 Java 顺利过渡到多核,对多核的深入支持有助于稳定 Java 的引领低位。
- 丰富语法。
- 64 位虚拟机。由于指针膨胀和各种数据类型对齐补白的原因,运行于64位系统上的Java应用需要消耗更多的内存,通常要比32位系统额外增加10%~30%的内存消耗;其次,多个机构的测试结果显示,64位虚拟机的运行速度在各个测试项中几乎全面落后于32位虚拟机,两者大约有15%左右的性能差距。
源码结构
如何阅读 HotSpot 源码
什么时候不读 HotSpot 源码
- 不同 JVM 具体实现相当复杂。
- 基础概念不扎实时。
- 硬读实现复杂的源码对理解基础概念的帮助不大。
- 繁琐的实现细节反而会掩盖一些抽象概念。
- 已有现成的阅读资料时。
- 读资料比读源码更容易吸收自己需要的信息。
- 因人而异。
不明就里读源码的坏处
- 加深误解
- 浪费时间/精力
- 读了但全然无法理解,还不如先不读
- 有些细节知道了也没用
在读源码之前
- 仅为理解 Java 程序的行为?
- 是否已经了解 Java 语言层面的规定?
- Java 语言规范
- 是否已经了解 JVM 的抽象概念?
- JVM 规范
- 已确定需要关注的行为是特定与某个实现?
- 回到上面两点
- 是否有关于该实现的内部细节的文字描述?
- 优先选择阅读文字描述。
- 是否已经了解 Java 语言层面的规定?
- 仍然想深入学习 VM 的内部知识?
- 阅读相关背景知识的书、论文、博客等。
- 能够在阅读源码之前事先了解术语。
- 知道术语便于找到更多资料。
- 阅读一些更加简单的 VM 实现的源码。
- 循序渐进。
- 自己动手编写简单的编译器、VM
- 实践是检验真理最有效途径。
- 最后,如果真的有空才去读 HotSpot 源码。
- 阅读相关背景知识的书、论文、博客等。
- 工作的内容就是开发 HotSpot VM?
- 需要非常仔细的阅读。
- 优先选择动态调试。
- 上手顺序:文档——读代码——做实验——调试。
其他 VM
- KVM:简单小巧的 JVM 实现。
- 优点:
- 包含 JVM 的最核心组件
- 实现方式与 JVM 规范所描述的抽象比较接近
- 缺点:
- 是 Java ME CLDC VM,而不是 Java SE VM
- 未实现反射、浮点计算等功能
- 优点:
- Maxine VM:纯 Java 实现的 JVM。
- 可在 IDE 中开发调试
- 二进制兼容性,可使用 Oracle JDK/OpenJDK 的类库,兼容主流的 Java 应用。
- VMKit/J3:使用线程组件搭建的 JVM。
其他资源
4.2 - CH02-内存区域与溢出
Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。
2.1 概述
在 C/C++ 中,开发人员即拥有每一个对象的“所有权”,又负担着每个对象生命开始到终结的维护责任。
在 Java 中,在虚拟机自动内存管理机制的帮助下,开发人员不需要为每个 new 操作去编写与之配对的 delete/free 代码,不容易出现内存泄露和内存溢出问题。
2.2 运行时数据区域
JVM 在执行 Java 程序时会将它所管理的内存划分为不若干个不同的数据区域。这些区域都有各自的用途、创建和销毁实现,有的区域随着虚拟机进程的启动而存在,有些则依赖于用户线程的启动和结束而创建和销毁。
2.2.1 程序计数器
程序计数器是一块较小的内存空间,可以被看作是当前线程所执行字节码的“行号指示器”。在虚拟机的概念模型中,字节码解释器工作时就是通过改变该计数器的值来选取下一条要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖于该计数器来完成。
由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说指一个核)都只会执行一个条线程中的指令。因此,为了线程切换后恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,该计数器记录的是正在执行的虚拟机字节码指令的地址:如果正在执行的是 Native 方法,该计数器值则为空(undefined)。此内存区域是唯一一个在 JVM 规范中没有规定任何 OOM 情况的区域。
2.2.2 JVM 栈
与程序计数器一样,JVM 栈(stack)也是线程私有的,其声明周期与线程相同。它描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应这一个栈帧在 JVM 栈中入栈到出栈的过程。
经常有人把 Java 内存区域分为堆内存和栈内存,这种分类方式比较粗糙,Java 内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的区域是这两块。其中所指的“栈”就是这里所讲的 JVM 栈,或者说是局部变量表部分。
局部变量表存放了编译期可知的各种数据类型(8 种基本类型)、对象引用(引用类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与该对象相关的位置)、returnAddress 类型(指向一条字节码指令的地址)。
其中 64 位长度的 long 和 double 型数据会占用 2 个局部两两空间(slot),其余的数据类型仅占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,该方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在 JVM 规范中,对该区域规定了两种异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出栈溢出异常。
- 如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,将抛出内存溢出异常。
当前大部分虚拟机实现都可以动态扩展,只不过规范中也允许固定长度的虚拟机栈。
2.2.3 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈相似,区别在于:
- 虚拟机栈用于虚拟机执行 Java 方法,为字节码服务。
- 本地方法栈用于为虚拟机本身用到的 Native 方法服务。
虚拟机规范中没有强制规定本地方法栈中方法需要使用的语言、使用方式、数据结构,因此具体的虚拟机可以自由实现。甚至有虚拟机把直接把本地方法栈和虚拟机栈合二为一,如 Sun HotSpot 虚拟机。
可能会抛出栈溢出或内存溢出异常。
Java 堆
Java Heap 是 JVM 所管理内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放对象实例,几乎所有的对象实例都在这里分配内存。
规范中的描述为:所有的对象实例和数组都要在堆上分配。但是随着 JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换等优化技术将会带来一些微秒的变化,所有对象都在堆上分配也慢慢变得不那么“绝对”了。
堆是 GC 管理的主要区域。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以堆可以被细分为新生代和老年代,再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的堆中可能划分出多个线程私有的分配缓冲区。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储依然都是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
根据虚拟机规范的规定,堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,这类似于磁盘空间。在实现时,既可以实现为固定大小的、也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展的方式来实现的(通过 -Xmx
和 -Xms
控制)。如果在堆中没有内存完成实例分配,并且堆也无法再进行扩展时,将抛出内存溢出异常。
2.2.5 方法区
方法区与堆一样,被各个线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。虽然规范中将方法区描述为堆的一个逻辑部分,但是他却有一个别名叫做“非堆(Non-Heap)”,目的应该是为了与堆进行区分。
对于习惯于在 HotSpot 虚拟机实现上开发的程序员来说,很多人更愿意称方法区为“永久带”,本质上两者并不等价,仅仅是因为 HotSpot 的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久带来实现方法区而已,这样 HotSpot 的垃圾收集器可以像管理堆一样来管理这部分内存,这能够省去专门为方法区编写内存管理代码的工作。其他虚拟机(JRockit/J9)则不存在永久带的概念。原则上,如何实现方法区属于虚拟机的实现细节,不受规范的约束,但是用永久带来实现方法区,现在看来并非一个好主意,因为这样更容易遇到内存溢出问题,而且极少有方法会因为这个原因导致不同虚拟机下出现不同的表现。因此对于 HotSpot 来说,根据官方发布的路线图信息,目前也有放弃永久带并逐步改为采用 Native Memory 来实现方法区的规划了。在目前已经发布的 JDK 1.7 中,已经把原本放在永久带的字符串常量池移出。
规范对方法区的限制非常宽松,除了和堆一样不需要连续的内存、可以选择固定大小和可扩展之外,还可以选择实现垃圾收集机制。相对堆而言,垃圾收集行为在该区域较少出现,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。该区域的内存回收目标主要是针对常量池和类的卸载,一般来说,该区域的回收“成绩”比较难以令人满意,尤其是对类的卸载,条件相当苛刻,但是该区域的回收是确实必要的。
根据规范规定,当方法区无法满足内存分配需求时,将抛出内存溢出异常。
2.2.6 运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项就是常量池,用于存放在编译期生成的各种字面量和符合引用,该部分内容将在类加载后被存放到方法区的运行时常量池。
JVM 对 Class 文件的各个部分的格式都有严格的规定,每个字节用于存放哪种数据都必须符合规范中的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,规范中没有任何细节的要求,不同的供应商实现的虚拟机可以按照自己的需求来实现该内存区域。但一般来说,除了保存 Class 文件中描述的符号引用之外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 并不要求常量只能在编译期产生,也就是并非预置入 Class 文件中常量池的内存才能进入方法区运行时常量池,运行区间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的便是 String 类的 intern()
方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池中无法申请到内存时将抛出内存溢出异常。
2.2.7 直接内存
直接内存并非虚拟机运行时数据的一部分,也不是 JVM 规范中定义的内存区域。但是这部分内存也被频繁的使用,而且可能导致内存溢出异常,因此有必要介绍。
在 JDK 1.4 中引入例如 NIO 类,引入了一种基于 Channel 和 Buffer 的 IO 方式,可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提升性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机内存的分配不会受到 Java 堆大小的限制,但是会收到本机总内存大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx
等参数信息,但经常会忽略直接内存,使得各个内存区域的总和大于实际的物理内存限制,从而导致扩展时出现内存溢出异常。
2.3 HotSpot 对象探秘
2.3.1 对象的创建
虚拟机遇到一条 new 指令时,首先回去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并且检查该符号引用代表的类是否已经被加载、解析且初始化。如果没有,必须首先执行对相关类的加载过程。
如果类加载检查通过,接下来虚拟机将为新生对象分配内存。对象所需要的内存大小在类加载时便可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从堆中划分出来。假设堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,所分配的内存就仅仅是把该指针向空闲空间一侧挪动一段与新生对象的大小相等的距离,这种分配方式成为“指针碰撞”。如果堆中的内存是不规整的,已用内存与空闲内存相互交错,虚拟机就需要维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间来分配给新生对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。堆是否规整来决定了使用哪种分配方式,而堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能来决定。因此,在使用 Serial、ParNew 等带有 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 MarkSweep 算法的收集器时,通常会采用空闲列表方式。
除了如何划分可用空间之外,还有另外一个需要考虑的问题是,在虚拟机中对象创建是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决该问题有两种方案,一种是对内存分配动作进行同步——实际上虚拟机采用 CAS 加上失败重试的方式保证更新操作的原子性;另一种方式是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称之为本地线程分配缓冲(TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步。虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB
参数来设定。
内存分配完成后,虚拟机需要将分配的内存空间都初始化为零值(不包括对象头),如果使用 TLAB,这一过程也可以被提前至分配 TLAB 时进行。该操作保证了对象的实例字段在 Java 代码中可以不赋值初始值就能直接被使用,程序将访问到这些字段的类型所对应的零值。
接下来,虚拟机要对对象重新进行必要的设置,比如该对象是哪个类的实例、如何才能找到类的元信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁,对象头会有不同的设置方式。
在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的角度来看,对象创建才刚刚开始——init
方法还没有执行,所有的字段都还为零值。所以,一般来说(根据字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着执行 init
方法,把对象安装程序员的意愿进行初始化,这样一个真正可用的对象才算创建完成。
下面的代码清单是 HotSpot 中 butecodeInterpreter.cpp
的片段:
// 确保常量池中存放的是已解释的类
if(!constants->tag_at(index).is_unresolved_kclass()) {
// 断言确保是 klassOop 和 instanceKlassOop
oop entry = (klassOop)*constants->obj_at_addr(index);
assert(entry->is_klass(), "Should be resolved klass");
klassOop k_entry=(klassOop)entry;
assert(k_entry->klass_part()->oop_is_instance(), "Should be instan
instanceKlass * ik=(instanceKlass*)k_entry->klass_part();
// 确保对象所属类型已经经过初始化阶段
if(ik->is_initialized()&&ik->can_be_fastpath_allocated()) {
//取对象长度
size_t obj_size=ik->size_helper();
oop result=NULL;
//记录是否需要将对象所有字段置零值
bool need_zero=!ZeroTLAB;
//是否在TLAB中分配对象
if(UseTLAB){
result=(oop)THREAD->tlab().allocate(obj_size);
}
if(result==NULL){
need_zero=true;
//直接在eden中分配对象
retry:
HeapWord * compare_to=*Universe:heap()->top_addr();
HeapWord * new_top=compare_to+obj_size;
/*cmpxchg是x86中的CAS指令, 这里是一个C++方法, 通过CAS方式分配空间, 如果并发失败, 转到retry中重试, 直至成功分配为止*/
if(new_top<=*Universe:heap()->end_addr()){
if(Atomic:cmpxchg_ptr(new_top,Universe:heap()->top_addr(), compare_to)!=compare_to){
goto retry;
}
result=(oop)compare_to;
}
}
if(result!=NULL){
//如果需要, 则为对象初始化零值
if(need_zero){
HeapWord * to_zero=(HeapWord*)result+sizeof(oopDesc)/oopSize;
obj_size-=sizeof(oopDesc)/oopSize;
if(obj_size>0){
memset(to_zero, 0, obj_size * HeapWordSize);
}
}
//根据是否启用偏向锁来设置对象头信息
if(UseBiasedLocking){
result->set_mark(ik->prototype_header());
}else{
result->set_mark(markOopDesc:prototype());
}
result->set_klass_gap(0);
result->set_klass(k_entry);
//将对象引用入栈, 继续执行下一条指令
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
}
2.3.2 对象的内存布局
在 HotSpot 中,对象在内存中存储的布局可以分为三个区域:
- 对象头
- 实例数据
- 对齐填充
对象头包括两部分。第一部分用于存储对象自身的运行时数据,如哈希值、GC 年龄分代、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 位和 64 位,官方称其为 “Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间。如,在 32 位的 HotSpot 中,如果对象处于未被锁定的状态下,那么 MarkWord 的 32 位存储空间中的 25 位用于存储对象哈希值,4位用于存储对象年龄分代,2位用于存储标志位。1位固定为0,而在其他状态(轻量级锁、重量级锁、GC 标记、可偏向)下对象的存储内容见下表:
对象的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过该指针来确定这个对象的所属的类。并不是所有虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个数组,拿在对象头中还必须有一块用于记录数组长度的信息,因为虚拟机可以通过普通 Java 对象的元数据信息来确定 Java 的对象的大小,但是从数组的元数据中无法确定数组的大小。
代码清单 2-2 为 HotSpot 中 markOop.cpp
的代码片段,描述了 32 位下 MarkWord 的存储状态:
//Bit-format of an object header(most significant first,big endian layout below):
//32 bits:
//--------
//hash:25------------>|age:4 biased_lock:1 lock:2(normal object)
//JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2(biased object)
//size:32------------------------------------------>|(CMS free block)
//PromotedObject*:29---------->|promo_bits:3----->|(CMS promoted object)
接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承而来,而是在子类中定义的,都需要记录下来。这部分的存储顺序会收到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 代码中定义顺序的影响。HotSpot 默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。从分配策略可看出,相同宽度的字段总是会被分配在一起。在满足该前提条件的情况下,在父类中定义的字段会出现在子类定义的字段之前。如果 CompactFields 参数为真(默认为真),那么子类中较窄的字段也可能会插入到父类字段的空隙中。
第三部分对齐填充并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。由于 HotSpot 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,即对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节数的倍数,因此当对象实例数据部分没有对齐时,就需要对齐填充来补全。
2.3.3 对象的访问定位
Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在虚拟机规范中国之规定了一个指向对象的引用,并没有定义该引用应该通过哪种方式去定位、访问堆中对象的具体位置,所以对象访问的方式也取决于具体虚拟机的实现。目前主流的方向是使用句柄或直接指针。
如果使用句柄来访问,那么堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。如下图:
如果使用直接指针访问,那么堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。如下图:
这两种对象访问方式各有优势,句柄方式访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(如垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
直接指针的方式的最大好处就是速度快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的 HotSpot 而言,它使用直接指针的方式来访问对象,但从整体软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。
2.4 实战内存溢出异常
2.4.1 堆溢出
堆用于存储对象实例,只要不断创建对象,并且保证 GC Roots 到对象之间的存在可达路径来避免 GC 清除这些对象,那么在对象数量达到堆的最大容量时就会产生堆内存溢出异常。
/**
*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());
}
}
}
要解决该区域的内存溢出异常,一般的手段是先通过内存映像分析工具(如 MAT)对 dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,即要分清楚到底是出现了内存泄露还是内存溢出。
如果是内存泄露,可以进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象是通过怎样的路径与 RC Roots 相关联并导致垃圾收集器无法将其回收。掌握了泄露对象的类型信息及其 GC Roots 引用链就能比较准确的定位出泄露代码的位置。
如果不存在泄露,就是内存中的对象确实都是需要存活的,那么就应当检查虚拟机的堆参数,与机器物理内存相比是否还能调大,从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
2.4.2 虚拟机栈和本地方法栈溢出
由于在 HotSpot 中并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 来说虽然 -Xoss
参数(用于设置本地方法栈的大小)存在,但实际上是无效的,栈容量仅能通过 -Xss
参数来设定。关于虚拟机栈和本地方法栈,JVM 规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出栈溢出异常。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出内存溢出异常。
这里把异常分为两种情况看似更加严谨,但却存在着一些互相重叠的地方:当占空间无法继续分配时,到底是内存太小、还是已使用的栈深度太大,其本质上只是对同一件事情的两种描述而已。
在笔者的实验中,将实验范围限定于单线程的操作中,尝试了下面两种方式均无法然虚拟机产生内存溢出异常,尝试的结果都是栈溢出异常:
- 使用
-Xss
参数减少栈内存容量,结果抛出栈溢出异常,异常出现时输出的栈深度相应缩小。 - 定义了大量的本地变量,以增大此方法帧中本地变量表的长度。结果抛出栈溢出异常时输出的栈深度相应缩小。
/**
*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;
}
}
}
实验结果表明,在单个线程中,无论是由于栈帧太大还是虚拟机栈容量太小,当无法分配内存的时候,抛出的都是栈溢出异常。
如果测试时不限于单线程,通过不断创建线程的方式倒是可以产生内存溢出异常。但是这样产生的内存溢出异常与栈空间十分足够大并不存在任何联系,或者准确的说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
具体原因不难理解,操作系统分配给每个进程的内存是有限制的,比如 32 位的 Windows 限制为 2GB。虚拟机提供了参数来控制 Java 堆和方法区的内存最大值。剩余的内存为 2GB 减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小可以忽略。如果虚拟机进程本身消耗的内存不算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以创建的线程数量就越少,创建新的线程时就越容易把剩下的内存耗尽。
这一点读者需要在开发多线程的应用时特别注意,出现栈溢出异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在。而且,如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。
代码清单 2-5 创建线程导致内存溢出异常:
/**
*VM Args:-Xss2M(这时候不妨设置大些)
*@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();
}
}
2.4.3 方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。前面提到JDK 1.7开始逐步“去永久代”的事情,在此就以测试代码观察一下这件事对程序的实际影响。
String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过 -XX:PermSize
和 -XX:MaxPermSize
限制方法区大小,从而间接限制其中常量池的容量,如代码清单 2-6 所示。
/**
*VM Args:-XX:PermSize=10M-XX:MaxPermSize=10M
*@author zzm
*/
public class RuntimeConstantPoolOOM{
public static void main(String[]args){
//使用List保持着常量池引用, 避免Full GC回收常量池行为
List<String>list=new ArrayList<String>();
//10MB的PermSize在integer范围内足够产生OOM了
int i=0;
while(true){
list.add(String.valueOf(i++).intern());
}
}
}
而使用 JDK 1.7 运行这段程序就不会得到相同的结果,while 循环将一直进行下去。关于这个字符串常量池的实现问题,还可以引申出一个更有意思的影响,如代码清单 2-7 所示。
public class RuntimeConstantPoolOOM{
public static void main(String[]args){
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);
}
}
}
这段代码在 JDK 1.6 中运行,会得到两个 false,而在 JDK 1.7 中运行,会得到一个 true 和一个 false。产生差异的原因是:在 JDK 1.6中,intern() 方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder 创建的字符串实例在 Java 堆上,所以必然不是同一个引用,将返回false。而 JDK 1.7(以及部分其他虚拟机,例如JRockit)的 intern() 实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此 intern() 返回的引用和由 StringBuilder 创建的那个字符串实例是同一个。对 str2 比较返回 false 是因为"java"这个字符串在执行 StringBuilder.toString() 之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回 true。
方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用 Java SE API 也可以动态产生类(如反射时的 GeneratedConstructorAccessor 和动态代理等),但在本次实验中操作起来比较麻烦。在代码清单 2-8中,笔者借助 CGLib 直接操作字节码运行时生成了大量的动态类。
/**
*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{
}
}
值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如 Spring、Hibernate,在对类进行增强时,都会使用到 CGLib 这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的 Class 可以加载入内存。另外,JVM 上的动态语言(例如Groovy等)通常都会持续创建类来实现语言的动态性,随着这类语言的流行,也越来越容易遇到与代码清单 2-8 相似的溢出场景。
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了 CGLib 字节码增强和动态语言之外,常见的还有:大量JSP 或动态产生 JSP 文件的应用(JSP第一次运行时需要编译为Java类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
2.4.4 本机直接内存溢出
DirectMemory 容量可通过 -XX:MaxDirectMemorySize
指定,如果不指定,则默认与 Java 堆最大值(-Xmx指定)一样,代码清单 2-9 越过了 DirectByteBuffer 类,直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe() 方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)。因为,虽然使用 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);
}
}
}
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果读者发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,那就可以考虑检查一下是不是这方面的原因。
2.5 本章小结
通过本章的学习,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然 Java 有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,本章只是讲解了各个区域出现内存溢出异常的原因,第3章将详细讲解 Java 垃圾收集机制为了避免内存溢出异常的出现都做了哪些努力。
4.3 - CH03-垃圾收集与分配策略
3.1 概述
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的(动态内存分配与内存回收)技术实施必要的监控和调节。
第 2 章介绍了 Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由 JIT 编译器进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,本章后续讨论中的“内存”分配与回收也仅指这一部分内存。
3.2 对象已死?
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
引用技术法
很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。作者面试过很多的应届生和一些有多年工作经验的开发人员,他们对于这个问题给予的都是这个答案。
客观地说,引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软公司的 COM(Component Object Model)技术、使用 ActionScript 3 的FlashPlayer、Python 语言和在游戏脚本领域被广泛应用的 Squirrel 中都使用了引用计数算法进行内存管理。但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决 对象之间相互循环引用 的问题。
举个简单的例子,请看代码清单 3-1 中的 testGC() 方法:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。
/**
*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();
}
}
从运行结果中可以清楚看到,GC 日志中包含"4603K->210K",意味着虚拟机并没有因为这两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。
3.2.2 可达性分析算法
在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。如图 3-1 所示,对象 object 5、object 6、object 7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为是可回收的对象。
在Java语言中,可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的Native方法)引用的对象。
3.2.3 再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在 JDK 1.2 以前,Java 中的引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这 4 种引用强度依次逐渐减弱。
强引用就是指在程序代码之中普遍存在的,类似"Object obj=new Object()“这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1. 2之后,提供了 WeakReference 类来实现弱引用。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了 PhantomReference 类来实现虚引用。
3.2.4 生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在 finalize() 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。从代码清单 3-2 中我们可以看到一个对象的 finalize() 被执行,但是它仍然可以存活。
/**
*此代码演示了两点:
*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 mehtod executed!");
FinalizeEscapeGC.SAVE_HOOK=this;
}
public static void main(String[]args)throws Throwable{
SAVE_HOOK=new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK=null;
System.gc();
//因为finalize方法优先级很低, 所以暂停0.5秒以等待它
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no,i am dead:(");
}
//下面这段代码与上面的完全相同, 但是这次自救却失败了
SAVE_HOOK=null;
System.gc();
//因为finalize方法优先级很低, 所以暂停0.5秒以等待它
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no,i am dead:(");
}
}
}
从代码清单3-2的运行结果可以看出,SAVE_HOOK 对象的 finalize() 方法确实被 GC 收集器触发过,并且在被收集前成功逃脱了。
另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。
需要特别说明的是,上面关于对象死亡时 finalize() 方法的描述可能带有悲情的艺术色彩,笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对这个方法用途的一种自我安慰。finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉 Java 语言中有这个方法的存在。
3.2.5 回收方法区
很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java 虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做"abc"的,换句话说,就是没有任何 String 对象引用常量池中的"abc"常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个"abc"常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是“无用的类”:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及-XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息,其中-verbose:class
和-XX:+TraceClassLoading
可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading
参数需要 FastDebug 版的虚拟机支持。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
3.3 垃圾回收算法
3.3.1 标记-清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记—清除算法的执行过程如图 3-2 所示。
3.3.2 复制算法
为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,代价未免太高了一点。复制算法的执行过程如图3-3所示。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉Eden和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10% 的内存会被“浪费”。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivo r空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
内存的分配担保就好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。关于对新生代进行分配担保的内容,在本章稍后在讲解垃圾收集器执行规则时还会再详细讲解。
3.3.3 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如图3-4所示。
3.3.4 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
3.4 HotSpot 算法实现
3.2 节和 3.3 节从理论上介绍了对象存活判定算法和垃圾收集算法,而在 HotSpot 虚拟机上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。
3.4.1 枚举根节点
从可达性分析中从 GC Roots 节点找引用链这个操作为例,可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有 Java 执行线程(Sun将这件事情称为"Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
由于目前的主流 Java 虚拟机使用的都是准确式 GC(这个概念在第 1 章介绍Exact VM对 Classic VM 的改进时讲过),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在 HotSpot 的实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC 在扫描时就可以直接得知这些信息了。下面的代码清单 3-3 是 HotSpot Client VM 生成的一段 String.hashCode() 方法的本地代码,可以看到在 0x026eb7a9 处的 call 指令有 OopMap 记录,它指明了 EBX 寄存器和栈中偏移量为 16 的内存区域中各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从 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
3.4.2 安全点
在 OopMap 的协助下,HotSpot 可以快速且准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外空间,这样 GC 的空间成本将会变得很高。
实际上,HotSpot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。Safepoint 的选定既不能太少以致于让 GC 等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生 Safepoint。
对于 Sefepoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),其中抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。
而主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。下面代码清单 3-4 中的 test 指令是 HotSpot 生成的轮询指令,当需要暂停线程时,虚拟机把 0x160100 的内存页设置为不可读,线程执行到 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.4.3 安全区域
使用 Safepoint 似乎已经完美地解决了如何进入 GC 的问题,但实际情况却并不一定。Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配 CPU 时间,典型的例子就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全的地方去中断挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。
在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,那样,当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了。在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。
到此,笔者简要地介绍了 HotSpot 虚拟机如何去发起内存回收的问题,但是虚拟机如何具体地进行内存回收动作仍然未涉及,因为内存回收如何进行是由虚拟机所采用的 GC 收集器决定的,而通常虚拟机中往往不止有一种 GC 收集器。下面继续来看 HotSpot 中有哪些 GC 收集器。
3.5 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于 JDK 1.7 Update 14 之后的 HotSpot 虚拟机(在这个版本中正式提供了商用的 G1 收集器,之前 G1 仍处于实验状态),这个虚拟机包含的所有收集器如图 3-5 所示。
图 3-5 展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。接下来笔者将逐一介绍这些收集器的特性、基本原理和使用场景,并重点分析 CMS 和 G1 这两款相对复杂的收集器,了解它们的部分运作细节。
在介绍这些收集器各自的特性之前,我们先来明确一个观点:虽然我们是在对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的收集器出现,更加没有万能的收集器,所以我们选择的只是对具体应用最合适的收集器。这点不需要多加解释就能证明:如果有一种放之四海皆准、任何场景下都适用的完美收集器存在,那 HotSpot 虚拟机就没必要实现那么多不同的收集器了。
3.5.1 Serial 收集器
Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。大家看名字就会知道,这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World"这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。读者不妨试想一下,要是你的计算机每运行一个小时就会暂停响应 5 分钟,你会有什么样的心情?图 3-6 示意了 Serial/Serial Old 收集器的运行过程。
对于"Stop The World"带给用户的不良体验,虚拟机的设计者们表示完全理解,但也表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个性质的,但实际上肯定还要比打扫房间复杂得多啊!
从 JDK 1.3 开始,一直到现在最新的 JDK 1.7,HotSpot 虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到 Concurrent Mark Sweep(CMS)乃至 GC 收集器的最前沿成果 Garbage First(G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括 RTSJ 中的收集器)。寻找更优秀的垃圾收集器的工作仍在继续!
写到这里,笔者似乎已经把 Serial 收集器描述成一个“老而无用、食之无味弃之可惜”的鸡肋了,但实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。
3.5.2 ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio
、-XX:PretenureSizeThreshold
、-XX:HandlePromotionFailure
等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如图 3-7 所示。
ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器——CMS 收集器(Concurrent Mark Sweep,本节稍后将详细介绍这款收集器),这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,用前面那个例子的话来说,就是做到了在你的妈妈打扫房间的时候你还能一边往地上扔纸屑。
不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。ParNew 收集器也是使用 -XX:+UseConcMarkSweepGC
选项后的默认新生代收集器,也可以使用 -XX:+UseParNewGC
选项来强制指定它。
ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。当然,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(譬如32个,现在 CPU 动辄就4核加超线程,服务器超过 32 个逻辑 CPU 的情况越来越多了)的环境下,可以使用 -XX:ParallelGCThreads
参数来限制垃圾收集的线程数。
注意 从 ParNew 收集器开始,后面还会接触到几款并发和并行的收集器。在大家可能产生疑惑之前,有必要先解释两个名词:并发和并行。这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下。
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。
3.5.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器……看上去和 ParNew 都一样,那它有什么特别之处呢?
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis
参数以及直接设置吞吐量大小的 -XX:GCTimeRatio
参数。
MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不过大家不要认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集 300MB 新生代肯定比收集 500MB 快吧,这也直接导致垃圾收集发生得更频繁一些,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
GCTimeRatio 参数的值应当是一个大于 0 且小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为 19,那允许的最大GC时间就占总时间的 5%(即1/(1+19)),默认值为 99,就是允许最大 1%(即1/(1+99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge 收集器还有一个参数 -XX:+UseAdaptiveSizePolicy
值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio
)、晋升老年代对象年龄(-XX:PretenureSizeThreshold
)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)。如果读者对于收集器运作原来不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如 -Xmx 设置最大堆),然后使用 MaxGCPauseMillis 参数(更关注最大停顿时间)或 GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。
3.5.4 Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。这两点都将在后面的内容中详细讲解。Serial Old 收集器的工作过程如图 3-8 所示。
3.5.5 Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择(还记得上面说过 Parallel Scavenge 收集器无法与 CMS 收集器配合工作吗?)。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。
直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作过程如图3-9所示。
3.5.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含"Mark Sweep”)上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。通过图 3-10 可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的时间。
CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,Sun 公司的一些官方文档中也称之为并发低停顿收集器(Concurrent Low Pause Collector)。但是 CMS 还远达不到完美的程度,它有以下 3 个明显的缺点:
CMS 收集器对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU数量+3)/4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(譬如2个)时,CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的 CMS 收集器变种,所做的事情和单 CPU 年代 PC 机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记、清理的时候让 GC 线程、用户线程交替运行,尽量减少 GC 线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,也就是速度下降没有那么明显。实践证明,增量时的 CMS 收集器效果很一般,在目前版本中, i-CMS 已经被声明为 “deprecated”,即不再提倡用户使用。
CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Failure"失败而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。在 JDK 1.5 的默认设置下,CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数 -XX:CMSInitiatingOccupancyFraction
的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在 JDK 1.6 中,CMS 收集器的启动阈值已经提升至 92%。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数 -XX:CM SInitiatingOccupancyFraction
设置得太高很容易导致大量"Concurrent Mode Failure"失败,性能反而降低。
还有最后一个缺点,在本节开头说过,CMS 是一款基于“标记—清除”算法实现的收集器,如果读者对前面这种算法介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection
开关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction
,这个参数是用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。
3.5.7 G1 收集器
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,早在 JDK 1.7 刚刚确立项目目标,Sun 公司给出的 JDK 1.7 RoadMap 里面,它就被视为 JDK 1.7 中 HotSpot 虚拟机的一个重要进化特征。从 JDK 6u14 中开始就有 Early Access 版本的 G1 收集器供开发人员实验、试用,由此开始 G1 收集器的"Experimental"状态持续了数年时间,直至 JDK 7u4,Sun 公司才认为它达到足够成熟的商用程度,移除了"Experimental"的标识。
G1 是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点。
并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
空间整合:与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。
在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
G1 把内存“化整为零”的思路,理解起来似乎很容易,但其中的实现细节却远远没有想象中那样简单,否则也不会从 2004 年 Sun 实验室发表第一篇 G1 的论文开始直到今天(将近10年时间)才开发出 G1 的商用版。笔者以一个细节为例:把 Java 堆分为多个 Region 后,垃圾收集是否就真的能以 Region 为单位进行了?听起来顺理成章,再仔细想想就很容易发现问题所在:Region 不可能是孤立的。一个对象分配在某个 Region 中,它并非只能被本 Region 中的其他对象引用,而是可以与整个 Java 堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个 Java 堆才能保证准确性?这个问题其实并非在 G1 中才有,只是在 G1 中更加突出而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象时也面临相同的问题,如果回收新生代时也不得不同时扫描老年代的话,那么 Minor GC 的效率可能下降不少。
在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
对 CMS 收集器运作过程熟悉的读者,一定已经发现 G1 的前几个步骤的运作过程和 CMS 有很多相似之处。初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,从 Sun 公司透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。通过图 3-11 可以比较清楚地看到 G1 收集器的运作步骤中并发和需要停顿的阶段。
由于目前 G1 成熟版本的发布时间还很短,G1 收集器几乎可以说还没有经过实际应用的考验,网络上关于 G1 收集器的性能测试也非常贫乏,到目前为止,笔者还没有搜索到有关的生产环境下的性能测试报告。强调“生产环境下的测试报告”是因为对于垃圾收集器来说,仅仅通过简单的 Java 代码写个 Microbenchmark 程序来创建、移除 Java 对象,再用 -XX:+PrintGCDetails
等参数来查看 GC 日志是很难做到准确衡量其性能的。因此,关于 G1 收集器的性能部分,笔者引用了 Sun 实验室的论文《Garbage-First Garbage Collection》中的一段测试数据。
此处略去对 G1 的性能度量部分。
3.5.8 理解 GC 日志
阅读 GC 日志是处理 Java 虚拟机内存问题的基础技能,它只是一些人为确定的规则,没有太多技术含量。在本书的第 1 版中没有专门讲解如何阅读分析 GC 日志,为此作者收到许多读者来信,反映对此感到困惑,因此专门增加本节内容来讲解如何理解 GC 日志。
每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,例如以下两段典型的 GC 日志:
33.125:[GC[DefNew:3324K->152K(3712K), 0.0025925 secs]3324K->152K(11904K), 0.0031680 secs]
100.667:[Full GC[Tenured:0 K->210K(10240K), 0.0149142secs]4603K->210K(19456K), [Perm:2999K->2999K(21248K)], 0.0150007 secs][Times:user=0.01 sys=0.00, real=0.02 secs]
最前面的数字“33.125:”和“100.667:”代表了 GC 发生的时间,这个数字的含义是从 Java 虚拟机启动以来经过的秒数。
GC日志开头的 [GC
和 [Full GC
说明了这次垃圾收集的停顿类型,而不是用来区分新生代 GC 还是老年代 GC 的。如果有"Full",说明这次 GC 是发生了 Stop-The-World 的,例如下面这段新生代收集器 ParNew 的日志也会出现 [Full GC
(这一般是因为出现了分配担保失败之类的问题,所以才导致 STW)。如果是调用System.gc() 方法所触发的收集,那么在这里将显示 [Full GC(System)
。
[Full GC 283.736:[ParNew:261599K->261599K(261952K), 0.0000288 secs]
接下来的 [DefNew
、[Tenured
、[Perm
表示 GC 发生的区域,这里显示的区域名称与使用的 GC 收集器是密切相关的,例如上面样例所使用的 Serial 收集器中的新生代名为"Default New Generation",所以显示的是 [DefNew
。如果是 ParNew 收集器,新生代名称就会变为 [ParNew
,意为"Parallel New Generation"。如果采用 Parallel Scavenge 收集器,那它配套的新生代称为"PSYoungGen",老年代和永久代同理,名称也是由收集器决定的。
后面方括号内部的"3324K->152K(3712K)“含义是“GC 前该内存区域已使用容量->GC 后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的"3324K->152K(11904K)“表示“ GC 前 Java 堆已使用容量->GC 后 Java 堆已使用容量(Java 堆总容量)”。
再往后,“0.0025925 secs"表示该内存区域 GC 所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如 [Times:user=0.01 sys=0.00,real=0.02 secs]
,这里面的 user、sys 和 real 与 Linux 的 time 命令所输出的时间含义一致,分别代表用户态消耗的 CPU 时间、内核态消耗的 CPU 时间和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU 时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘 I/O、等待线程阻塞,而 CPU 时间不包括这些耗时,但当系统有多 CPU 或者多核的话,多线程操作会叠加这些 CPU 时间,所以读者看到 user 或 sys 时间超过 real 时间是完全正常的。
3.5.9 垃圾收集器参数总结
JDK 1.7 中的各种垃圾收集器到此已全部介绍完毕,在描述过程中提到了很多虚拟机非稳定的运行参数,在表 3-2 中整理了这些参数供读者实践时参考。
3.6 内存分配与回收策略
Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于回收内存这一点,我们已经使用了大量篇幅去介绍虚拟机中的垃圾收集器体系以及运作原理,现在我们再一起来探讨一下给对象分配内存的那点事儿。
对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过 JIT 编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。
接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。本节下面的代码在测试时使用 Client 模式虚拟机运行,没有手工指定收集器组合,换句话说,验证的是在使用 Serial/Serial Old 收集器下(ParNew/Serial Old 收集器组合的规则也基本一致)的内存分配和回收的策略。读者不妨根据自己项目中使用的收集器写一些程序去验证一下使用其他几种收集器的内存分配策略。
3.6.1 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC
。
虚拟机提供了 -XX:+PrintGCDetails
这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析,不过本实验的日志并不多,直接阅读就能看得很清楚。
代码清单 3-5 的 testAllocation() 方法中,尝试分配 3 个 2MB 大小和 1 个 4MB 大小的对象,在运行时通过 -Xms20M、-Xmx20M、-Xmn10M 这 3 个参数限制了 Java 堆大小为 20MB,不可扩展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。-XX:SurvivorRatio=8
决定了新生代中 Eden 区与一个 Survivor 区的空间比例是 8:1,从输出的结果也可以清晰地看到"eden space 8192K、from space 1024K、to space 1024K"的信息,新生代总可用空间为 9216KB(Eden 区+1个 Survivor 区的总容量)。
执行 testAllocation() 中分配 allocation4 对象的语句时会发生一次 Minor GC,这次 GC 的结果是新生代 6651KB 变为 148KB,而总内存占用量则几乎没有减少(因为 allocation1、allocation2、allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次 GC 发生的原因是给 allocation4 分配内存的时候,发现 Eden 已经被占用了 6MB,剩余空间已不足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC。GC 期间虚拟机又发现已有的 3 个 2MB 大小的对象全部无法放入 Survivor 空间(Survivor 空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老年代去。
这次 GC 结束后,4MB 的 allocation4 对象顺利分配在 Eden 中,因此程序执行完的结果是 Eden 占用 4MB(被 allocation4 占用),Survivor 空闲,老年代被占用 6MB(被 allocation1、allocation2、allocation3 占用)。通过 GC 日志可以证实这一点。
注意:作者多次提到的 Minor GC 和 Full GC 有什么不一样吗?
- 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
- 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
代码清单3-5 新生代 Minor GC:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails
-XX:SurvivorRatio=8
*/
public static void testAllocation(){
byte[]allocation1, allocation2, allocation3, allocation4;
allocation1=new byte[2*_1MB];
allocation2=new byte[2*_1MB];
allocation3=new byte[2*_1MB];
allocation4=new byte[4*_1MB];//出现一次Minor GC
}
执行结果:
[GC[DefNew:6651K->148K(9216K), 0.0070106 secs]6651K->6292K(19456K),
0.0070426 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K,used 4326K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 14%used[0x032d0000, 0x032f5370, 0x033d0000)
to space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
tenured generation total 10240K,used 6144K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 60%used[0x033d0000, 0x039d0030, 0x039d0200, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
3.6.2 大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的 byte[]
数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(替 Java 虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个 -XX:PretenureSizeThreshold
参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。
执行代码清单 3-6 中的 testPretenureSizeThreshold() 方法后,我们看到 Eden 空间几乎没有被使用,而老年代的 10MB 空间被使用了 40%,也就是 4MB 的 allocation 对象直接就分配在老年代中,这是因为 PretenureSizeThreshold 被设置为 3MB(就是 3145728,这个参数不能像 -Xmx 之类的参数一样直接写 3MB),因此超过 3MB 的对象都会直接在老年代进行分配。注意 PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效,Parallel Scavenge 收集器不认识这个参数,Parallel Scavenge 收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑 ParNew 加 CMS 的收集器组合。
代码清单 3-6 大对象直接进入老年代:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8
*-XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold(){
byte[]allocation;
allocation=new byte[4*_1MB];//直接分配在老年代中
}
运行结果:
Heap
def new generation total 9216K,used 671K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 8%used[0x029d0000, 0x02a77e98, 0x031d0000)
from space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 40%used[0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
compacting perm gen total 12288K,used 2107K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fdefd0, 0x03fdf000, 0x049d0000)
No shared spaces configured.
3.6.3 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
设置。
读者可以试试分别以 -XX:MaxTenuringThreshold=1
和 -XX:MaxTenuringThreshold=15
两种设置来执行代码清单 3-7 中的 testTenuringThreshold() 方法,此方法中的 allocation1 对象需要 256KB 内存,Survivor 空间可以容纳。当 MaxTenuringThreshold=1 时,allocation1 对象在第二次 GC 发生时进入老年代,新生代已使用的内存 GC 后非常干净地变成 0KB。而 MaxTenuringThreshold=15 时,第二次 GC 发生后,allocation1 对象则还留在新生代 Survivor 空间,这时新生代仍然有 404KB 被占用。
代码清单3-7 长期存活的对象进入老年代:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=1
*-XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold(){
byte[]allocation1, allocation2, allocation3;
allocation1=new byte[_1MB/4];
//什么时候进入老年代取决于XX:MaxTenuringThreshold设置
allocation2=new byte[4*_1MB];
allocation3=new byte[4*_1MB];
allocation3=null;
allocation3=new byte[4*_1MB];
}
以 MaxTenuringThreshold=1 参数来运行的结果:
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 1(max 1) -age 1:414664 bytes, 414664 total :4859K->404K(9216K), 0.0065012 secs]4859K->4500K(19456K), 0.0065283 secs [Times:user=0.02 sys=0.00, real=0.02 secs]
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 1(max 1):4500K->0K(9216K), 0.0009253 secs]8596K->4500K(19456K),0.0009458 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000,0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4500K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 43%used[0x033d0000, 0x03835348, 0x03835400, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
以MaxTenuringThreshold=15参数来运行的结果:
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 15(max 15) -age 1:414664 bytes, 414664 total:4859K->404K(9216K), 0.0049637 secs]4859K->4500K(19456K), 0.0049932 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 15(max 15) -age 2:414520 bytes, 414520 total :4500K->404K(9216K), 0.0008091 secs]8596K->4500K(19456K), 0.0008305 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K,used 4582K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 39%used[0x031d0000, 0x03235338, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 40%used[0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
3.6.4 动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
执行代码清单 3-8 中的 testTenuringThreshold2() 方法,并设置 -XX:MaxTenuringThreshold=15
,会发现运行结果中 Survivor 的空间占用仍然为 0%,而老年代比预期增加了 6%,也就是说,allocation1、allocation2 对象都直接进入了老年代,而没有等到 15 岁的临界年龄。因为这两个对象加起来已经到达了 512KB,并且它们是同年的,满足同年对象达到 Survivor 空间的一半规则。我们只要注释掉其中一个对象 new 操作,就会发现另外一个就不会晋升到老年代中去了。
代码清单3-8 动态对象年龄判定:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=15
*-XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2(){
byte[]allocation1, allocation2, allocation3, allocation4;
allocation1=new byte[_1MB/4];
//allocation1+allocation2大于survivo空间一半
allocation2=new byte[_1MB/4];
allocation3=new byte[4*_1MB];
allocation4=new byte[4*_1MB];
allocation4=null;
allocation4=new byte[4*_1MB];
}
运行结果:
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1(max 15)
-age 1:676824 bytes, 676824 total
:5115K->660K(9216K), 0.0050136 secs]5115K->4756K(19456K), 0.0050443 secs][Times:user=0.00 sys=0.01, real=0.01 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15(max 15)
:4756K->0K(9216K), 0.0010571 secs]8852K->4756K(19456K), 0.0011009 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4756K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 46%used[0x033d0000, 0x038753e8, 0x03875400, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe09a0, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure 开关打开,避免 Full GC 过于频繁,参见代码清单3-9,请读者在 JDK 6 Update 24 之前的版本中运行测试。
代码清单3-9 空间分配担保:
private static final int_1MB=1024*1024;
/**
*VM参数:-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:-HandlePromotionFailure
*/
@SuppressWarnings("unused")
public static void testHandlePromotion(){
byte[]allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
allocation1=new byte[2*_1MB];
allocation2=new byte[2*_1MB];
allocation3=new byte[2*_1MB];
allocation1=null;
allocation4=new byte[2*_1MB];
allocation5=new byte[2*_1MB];
allocation6=new byte[2*_1MB];
allocation4=null;
allocation5=null;
allocation6=null;
allocation7=new byte[2*_1MB];
}
以HandlePromotionFailure=false参数来运行的结果:
[GC[DefNew:6651K->148K(9216K), 0.0078936 secs]6651K->4244K(19456K), 0.0079192 secs][Times:user=0.00 sys=0.02, real=0.02 secs]
[GC[DefNew:6378K->6378K(9216K), 0.0000206secs][Tenured:4096K->4244K(10240K), 0.0042901 secs]10474K->4244K(19456K), [Perm:2104K->2104K(12288K)], 0.0043613 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
以HandlePromotionFailure=true参数来运行的结果:
[GC[DefNew:6651K->148K(9216K), 0.0054913 secs]6651K->4244K(19456K), 0.0055327 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
[GC[DefNew:6378K->148K(9216K), 0.0006584 secs]10474K->4244K(19456K), 0.0006857 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
在 JDK 6 Update 24 之后,这个测试结果会有差异,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化(见代码清单3-10),虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。
代码清单3-10 HotSpot中空间分配检查的代码片段:
bool TenuredGeneration:promotion_attempt_is_safe(size_t
max_promotion_in_bytes)const{
//老年代最大可用的连续空间
size_t available=max_contiguous_available();
//每次晋升到老年代的平均大小
size_t av_promo=(size_t)gc_stats()->avg_promoted()->padded_average();
//老年代可用空间是否大于平均晋升大小, 或者老年代可用空间是否大于当此GC时新生代所有对象容量
bool res=(available>=av_promo)||(available>=
max_promotion_in_bytes);
return res;
}
3.7 本章小结
本章介绍了垃圾收集的算法、几款 JDK 1.7 中提供的垃圾收集器特点以及运作原理。通过代码实例验证了 Java 虚拟机中自动内存分配及回收的主要规则。
内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求、实现方式选择最优的收集方式才能获取最高的性能。没有固定收集器、参数组合,也没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。因此,学习虚拟机内存知识,如果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数。在接下来的两章中,作者将会介绍内存分析的工具和调优的一些具体案例。
4.4 - CH04-性能监控与故障处理
4.1 概述
给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括:运行日志、异常堆栈、GC 日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。经常使用适当的虚拟机监控和分析的工具可以加快我们分析数据、定位解决问题的速度,但在学习工具前,也应当意识到工具永远都是知识技能的一层包装,没有什么工具是“秘密武器”,不可能学会了就能包治百病。
4.2 JDK 命令行工具
Java 开发人员肯定都知道 JDK 的 bin 目录中有"java.exe"、“javac.exe"这两个命令行工具,但并非所有程序员都了解过JDK的bin目录之中其他命令行程序的作用。每逢JDK更新版本之时,bin 目录下命令行工具的数量和功能总会不知不觉地增加和增强。bin目录的内容如图 4-1 所示。
说起 JDK 的工具,比较细心的读者,可能会注意到这些工具的程序体积都异常小巧。假如以前没注意到,现在不妨再看看图 4-1 中的最后一列“大小”,几乎所有工具的体积基本上都稳定在 27KB 左右。并非 JDK 开发团队刻意把它们制作得如此精炼来炫耀编程水平,而是因为这些命令行工具大多数是 jdk/lib/tools.jar
类库的一层薄包装而已,它们主要的功能代码是在 tools 类库中实现的。读者把图 4-1 和图 4-2 两张图片对比一下就可以看得很清楚。
假如读者使用的是Linux版本的JDK,还会发现这些工具中很多甚至就是由Shell脚本直接写成的,可以用vim直接打开它们。
JDK开发团队选择采用Java代码来实现这些监控工具是有特别用意的:当应用程序部署到生产环境后,无论是直接接触物理服务器还是远程Telnet到服务器上都可能会受到限制。借助tools.jar类库里面的接口,我们可以直接在应用程序中实现功能强大的监控分析功能。
需要特别说明的是,本章介绍的工具全部基于Windows平台下的JDK 1.6 Update 21,如果JDK版本、操作系统不同,工具所支持的功能可能会有较大差别。大部分工具在JDK 1.5中就已经提供,但为了避免运行环境带来的差异和兼容性问题,建议读者使用JDK 1.6来验证本章介绍的内容,因为JDK 1.6的工具可以正常兼容运行于JDK 1.5的虚拟机之上的程序,反之则不一定。表4-1中说明了JDK主要命令行监控工具的用途。
注意 如果读者在工作中需要监控运行于JDK 1.5的虚拟机之上的程序,在程序启动时请添加参数”-Dcom.sun.management.jmxremote"开启JMX管理功能,否则由于部分工具都是基于JMX(包括4.3节介绍的可视化工具),它们都将会无法使用,如果被监控程序运行于JDK 1.6的虚拟机之上,那JMX管理默认是开启的,虚拟机启动时无须再添加任何参数。
- jps:JVM Process Status Tool,显示指定系统内所有的 HotSpot 进程。
- jstat:JVM Statiistics Monitoring Tool,用于收集 HotSpot 各方面的运行数据。
- jinfo:Configuration Info for Java,显示虚拟机配置信息。
- jmap:Memory Map for Java,生成虚拟机的内存转储快照。
- jhat:JVM Heap Dump Browser,用于分析 heandump 文件。
- jstack:Stack Trace for Java,显示虚拟机的线程快照。
4.2.1 jps:虚拟机进程状况工具
可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(Local Virtual Machine Identifier,LVMID)。虽然功能比较单一,但它是使用频率最高的JDK命令行工具,因为其他的JDK工具大多需要输入它查询到的LVMID来确定要监控的是哪一个虚拟机进程。对于本地虚拟机进程来说,LVMID与操作系统的进程ID(Process Identifier,PID)是一致的,使用Windows的任务管理器或者UNIX的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就只能依赖jps命令显示主类的功能才能区分了。
执行方式:
jps[options][hostid]
jps可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,hostid为RMI注册表中注册的主机名。jps的其他常用选项见表4-2。
4.2.2 jstat:虚拟机统计信息显示工具
jstat 是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。
命令格式为:
jstat[option vmid[interval[s|ms][count]]]
对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与LVMID是一致的,如果是远程虚拟机进程,那VMID的格式应当是:
[protocol:][//]lvmid[@hostname[:port]/servername]
参数interval和count代表查询间隔和次数,如果省略这两个参数,说明只查询一次。假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:
jstat-gc 2764 250 20
选项option代表着用户希望查询的虚拟机信息,主要分为3类:类装载、垃圾收集、运行期编译状况,具体选项及作用请参考表4-3中的描述。
4.2.3 jinfo:Java配置信息工具
作用是实时地查看和调整虚拟机各项参数。使用jps命令的-v参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参数的系统默认值,除了去找资料外,就只能使用jinfo的-flag选项进行查询了(如果只限于JDK 1.6或以上版本的话,使用java-XX:+PrintFlagsFinal查看参数默认值也是一个很好的选择),jinfo还可以使用-sysprops选项把虚拟机进程的System.getProperties()的内容打印出来。这个命令在JDK 1.5时期已经随着Linux版的JDK发布,当时只提供了信息查询的功能,JDK 1.6之后,jinfo在Windows和Linux平台都有提供,并且加入了运行期修改参数的能力,可以使用-flag[+|-]name
或者-flag name=value
修改一部分运行期可写的虚拟机参数值。JDK 1.6中,jinfo对于Windows平台功能仍然有较大限制,只提供了最基本的-flag选项。
命令格式:
jinfo[option]pid
执行样例:查询CMSInitiatingOccupancyFraction参数值。
C:\>jinfo-flag CMSInitiatingOccupancyFraction 1444
-XX:CMSInitiatingOccupancyFraction=85
4.2.4 jmap:Java内存映像工具
用于生成堆转储快照(一般称为heapdump或dump文件)。如果不使用jmap命令,要想获取Java堆转储快照,还有一些比较“暴力”的手段:譬如在第2章中用过的-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在OOM异常出现之后自动生成dump文件,通过-XX:+HeapDumpOnCtrlBreak参数则可以使用 Ctrl+Break 键让虚拟机生成dump文件,又或者在Linux系统下通过Kill-3命令发送进程退出信号“吓唬”一下虚拟机,也能拿到dump文件。
jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。
和jinfo命令一样,jmap有不少功能在Windows平台下都是受限的,除了生成dump文件的-dump选项和用于查看每个类的实例、空间占用统计的-histo选项在所有操作系统都提供之外,其余选项都只能在Linux/Solaris下使用。
命令格式:
jmap[option]vmid
option选项的合法值与具体含义见表4-4。
代码清单4-2是使用jmap生成一个正在运行的Eclipse的dump快照文件的例子,例子中的3500是通过jps命令查询到的LVMID。
C:\Users\IcyFenix>jmap-dump:format=b,file=eclipse.bin 3500
Dumping heap to C:\Users\IcyFenix\eclipse.bin……
Heap dump file created
4.2.5 jhat:虚拟机堆转储快照分析工具
Sun JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,可以在浏览器中查看。不过实事求是地说,在实际工作中,除非笔者手上真的没有别的工具可用,否则一般都不会去直接使用jhat命令来分析dump文件,主要原因有二:一是一般不会在部署应用程序的服务器上直接分析dump文件,即使可以这样做,也会尽量将dump文件复制到其他机器上进行分析,因为分析工作是一个耗时而且消耗硬件资源的过程,既然都要在其他机器进行,就没有必要受到命令行工具的限制了;另一个原因是jhat的分析功能相对来说比较简陋,后文将会介绍到的VisualVM,以及专业用于分析dump文件的Eclipse Memory Analyzer、IBM HeapAnalyzer等工具,都能实现比jhat更强大更专业的分析功能。
命令格式:
jhat[heapdump.file]
4.2.6 jstack:Java堆栈跟踪工具
jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者等待着什么资源。
命令格式:
jstack[option]vmid
option选项的合法值与具体含义见表4-5。
4.2.7 HSDIS:JIT生成代码反汇编
在Java虚拟机规范中,详细描述了虚拟机指令集中每条指令的执行过程、执行前后对操作数栈、局部变量表的影响等细节。这些细节描述与Sun的早期虚拟机(Sun Classic VM)高度吻合,但随着技术的发展,高性能虚拟机真正的细节实现方式已经渐渐与虚拟机规范所描述的内容产生了越来越大的差距,虚拟机规范中的描述逐渐成了虚拟机实现的“概念模型”——即实现只能保证规范描述等效。基于这个原因,我们分析程序的执行语义问题(虚拟机做了什么)时,在字节码层面上分析完全可行,但分析程序的执行行为问题(虚拟机是怎样做的、性能如何)时,在字节码层面上分析就没有什么意义了,需要通过其他方式解决。
分析程序如何执行,通过软件调试工具(GDB、Windbg等)来断点调试是最常见的手段,但是这样的调试方式在Java虚拟机中会遇到很大困难,因为大量执行代码是通过JIT编译器动态生成到CodeBuffer中的,没有很简单的手段来处理这种混合模式的调试(不过相信虚拟机开发团队内部肯定是有内部工具的)。因此,不得不通过一些特别的手段来解决问题,基于这种背景,本节的主角——HSDIS插件就正式登场了。
HSDIS是一个Sun官方推荐的HotSpot虚拟机JIT编译代码的反汇编插件,它包含在HotSpot虚拟机的源码之中,但没有提供编译后的程序。在Project Kenai的网站也可以下载到单独的源码。它的作用是让HotSpot的-XX:+PrintAssembly指令调用它来把动态生成的本地代码还原为汇编代码输出,同时还生成了大量非常有价值的注释,这样我们就可以通过输出的代码来分析问题。读者可以根据自己的操作系统和CPU类型从Project Kenai的网站上下载编译好的插件,直接放到JDK_HOME/jre/bin/client和JDK_HOME/jre/bin/server目录中即可。如果没有找到所需操作系统(譬如Windows的就没有)的成品,那就得自己使用源码编译一下。
还需要注意的是,如果读者使用的是Debug或者FastDebug版的HotSpot,那可以直接通过-XX:+PrintAssembly指令使用插件;如果使用的是Product版的HotSpot,那还要额外加入一个-XX:+UnlockDiagnosticVMOptions参数。笔者以代码清单4-6中的简单测试代码为例演示一下这个插件的使用。
测试代码:
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:Compi leCommand=compileonly, *Bar.sum test.Bar
其中,参数-Xcomp是让虚拟机以编译模式执行代码,这样代码可以“偷懒”,不需要执行足够次数来预热就能触发JIT编译。两个 -XX:CompileCommand
意思是让编译器不要内联sum()并且只编译sum(),-XX:+PrintAssembly
就是输出反汇编内容。如果一切顺利的话,那么屏幕上会出现类似下面代码清单4-7所示的内容。
[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
上段代码并不多,下面一句句进行说明。
- mov%eax,-0x8000(%esp):检查栈溢。
- push%ebp:保存上一栈帧基址。
- sub$0x18,%esp:给新帧分配空间。
- mov 0x8(%ecx),%eax:取实例变量a,这里0x8(%ecx)就是ecx+0x8的意思,前面"Constants"节中提示了"this:ecx=‘test/Bar’",即ecx寄存器中放的就是this对象的地址。偏移0x8是越过this对象的对象头,之后就是实例变量a的内存位置。这次是访问“Java堆”中的数据。
- mov$0x3d2fad8,%esi:取test.Bar在方法区的指针。
- mov 0x68(%esi),%esi:取类变量b,这次是访问“方法区”中的数据。
- add%esi,%eax和add%edx,%eax:做两次加法,求a+b+c的值,前面的代码把a放在eax中,把b放在esi中,而c在 Constants 中提示了,“parm0:edx=int”,说明c在edx中。
- add$0x18,%esp:撤销栈帧。
- pop%ebp:恢复上一栈帧。
- test%eax,0x2b0100:轮询方法返回处的SafePoint。
- ret:方法返回。
4.3 JDK的可视化工具
4.3.1 JConsole:Java监视与管理控制台
JConsole 是一种基于JMX的可视化监视、管理工具。它管理部分的功能是针对JMX MBean进行管理,由于MBean可以使用代码、中间件服务器的管理控制台或者所有符合JMX规范的软件进行访问,所以本节将会着重介绍JConsole监视部分的功能。
4.3.2 VisualVM:多合一故障处理工具
VisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随JDK发布的功能最强大的运行监视和故障处理程序,并且可以预见在未来一段时间内都是官方主力发展的虚拟机故障处理工具。官方在VisualVM的软件说明中写上了"All-in-One"的描述字样,预示着它除了运行监视、故障处理外,还提供了很多其他方面的功能。如性能分析(Profiling),VisualVM的性能分析功能甚至比起JProfiler、YourKit等专业且收费的Profiling工具都不会逊色多少,而且VisualVM的还有一个很大的优点:不需要被监视的程序基于特殊Agent运行, 不需要被监视的程序基于特殊Agent运行,因此它对应用程序的实际性能的影响很小,使得它可以直接应用在生产环境中。这个优点是JProfiler、YourKit等工具无法与之媲美的。
4.4 本章小结
本章介绍了随JDK发布的6个命令行工具及两个可视化的故障处理工具,灵活使用这些工具可以给问题处理带来很大的便利。
除了JDK自带的工具之外,常用的故障处理工具还有很多,如果读者使用的是非Sun系列的JDK、非HotSpot的虚拟机,就需要使用对应的工具进行分析,如:
- IBM的Support Assistant、Heap Analyzer、Javacore Analyzer、Garbage Collector Analyzer适用于IBM J9 VM。
- HP的HPjmeter、HPjtune适用于HP-UX、SAP、HotSpot VM。
- Eclipse的Memory Analyzer Tool(MAT)适用于HP-UX、SAP、HotSpot VM,安装IBM DTFJ插件后可支持IBM J9 VM。
- BEA的JRockit Mission Control适用于JRockit VM。
4.5 - CH05-调优案例
5.1 概述
除了知识与工具外,经验同样是一个很重要的因素。
5.2 案例分析
5.2.1 高性能硬件上的程序部署策略
例如,一个 15 万PV/天左右的在线文档类型网站最近更换了硬件系统,新的硬件为 4 个 CPU、16GB 物理内存,操作系统为 64 位 CentOS 5.4,Resin 作为 Web 服务器。整个服务器暂时没有部署别的应用,所有硬件资源都可以提供给这访问量并不算太大的网站使用。管理员为了尽量利用硬件资源选用了 64 位的 JDK 1.5,并通过 -Xmx 和 -Xms 参数将 Java 堆固定在 12GB。使用一段时间后发现使用效果并不理想,网站经常不定期出现长时间失去响应的情况。
监控服务器运行状况后发现网站失去响应是由 GC 停顿导致的,虚拟机运行在 Server 模式,默认使用吞吐量优先收集器,回收 12GB 的堆,一次 Full GC 的停顿时间高达 14 秒。并且由于程序设计的关系,访问文档时要把文档从磁盘提取到内存中,导致内存中出现很多由文档序列化产生的大对象,这些大对象很多都进入了老年代,没有在 Minor GC 中清理掉。这种情况下即使有 12GB 的堆,内存也很快被消耗殆尽,由此导致每隔十几分钟出现十几秒的停顿,令网站开发人员和管理员感到很沮丧。
这里先不延伸讨论程序代码问题,程序部署上的主要问题显然是过大的堆内存进行回收时带来的长时间的停顿。硬件升级前使用 32 位系统 1.5GB 的堆,用户只感觉到使用网站比较缓慢,但不会发生十分明显的停顿,因此才考虑升级硬件以提升程序效能,如果重新缩小给 Java 堆分配的内存,那么硬件上的投资就显得很浪费。
在高性能硬件上部署程序,目前主要有两种方式:
- 通过 64 位 JDK 来使用大内存。
- 使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。
此案例中的管理员采用了第一种部署方式。对于用户交互性强、对停顿时间敏感的系统,可以给 Java 虚拟机分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低,至少要低到不会影响用户使用,譬如十几个小时乃至一天才出现一次 Full GC,这样可以通过在深夜执行定时任务的方式触发 Full GC 甚至自动重启应用服务器来保持内存可用空间在一个稳定的水平。
控制 Full GC 频率的关键是看应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。
在大多数网站形式的应用里,主要对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对很少。只要代码写得合理,应当都能实现在超大堆中正常使用而没有 Full GC,这样的话,使用超大堆内存时,网站响应速度才会比较有保证。除此之外,如果读者计划使用 64 位 JDK 来管理大内存,还需要考虑下面可能面临的问题:
- 内存回收导致的长时间停顿。
- 现阶段,64 位 JDK 的性能测试结果普遍低于 32 位 JDK。
需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生十几 GB 乃至更大的 HeapDump 文件),哪怕产生了快照也几乎无法进行分析。
相同程序在 64 位 JDK 消耗的内存一般比 32 位 JDK 大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。
上面的问题听起来有点吓人,所以现阶段不少管理员还是选择第二种方式:使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。具体做法是在一台物理机器上启动多个应用服务器进程,每个服务器进程分配不同端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。读者不需要太过在意均衡器转发所消耗的性能,即使使用 64 位 JDK,许多应用也不止有一台服务器,因此在许多应用中前端的均衡器总是要存在的。
考虑到在一台物理机器上建立逻辑集群的目的仅仅是为了尽可能利用硬件资源,并不需要关心状态保留、热转移之类的高可用性需求,也不需要保证每个虚拟机进程有绝对准确的均衡负载,因此使用无 Session 复制的亲合式集群是一个相当不错的选择。我们仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(一般根据 SessionID 分配)将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可,这样程序开发阶段就基本不用为集群环境做什么特别的考虑了。
当然,很少有没有缺点的方案,如果读者计划使用逻辑集群的方式来部署程序,可能会遇到下面一些问题:尽量避免节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致 IO 异常。
很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余。尽管可以使用集中式的 JNDI,但这个有一定复杂性并且可能带来额外的性能开销。
各个节点仍然不可避免地受到 32 位的内存限制,在 32 位 Windows 平台中每个进程只能使用 2GB 的内存,考虑到堆以外的内存开销,堆一般最多只能开到 1.5GB。在某些 Linux 或 UNIX 系统(如 Solaris)中,可以提升到 3GB 乃至接近 4GB 的内存,但 32 位中仍然受最高 4GB(2^{32})内存的限制。
大量使用本地缓存(如大量使用 HashMap 作为 K/V 缓存)的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份缓存,这时候可以考虑把本地缓存改为集中式缓存。
介绍完这两种部署方式,再重新回到这个案例之中,最后的部署方案调整为建立 5 个 32 位 JDK 的逻辑集群,每个进程按 2GB 内存计算(其中堆固定为1.5GB),占用了 10GB 内存。另外建立一个 Apache 服务作为前端均衡代理访问门户。考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,CPU 资源敏感度较低,因此改为 CMS 收集器进行垃圾回收。部署方式调整后,服务再没有出现长时间停顿,速度比硬件升级前有较大提升。
5.2.2 集群间同步导致的内存溢出
例如,有一个基于 B/S 的 MIS 系统,硬件为两台 2 个 CPU、8GB 内存的 HP 小型机,服务器是 WebLogic 9.2,每台机器启动了 3 个 WebLogic 实例,构成一个 6 个节点的亲合式集群。由于是亲合式集群,节点之间没有进行 Session 同步,但是有一些需求要实现部分数据在各个节点间共享。开始这些数据存放在数据库中,但由于读写频繁竞争很激烈,性能影响较大,后面使用 JBossCache 构建了一个全局缓存。全局缓存启用后,服务正常使用了一段较长的时间,但最近却不定期地出现了多次的内存溢出问题。
在内存溢出异常不出现的时候,服务内存回收状况一直正常,每次内存回收后都能恢复到一个稳定的可用空间,开始怀疑是程序某些不常用的代码路径中存在内存泄漏,但管理员反映最近程序并未更新、升级过,也没有进行什么特别操作。只好让服务带着 -XX:+HeapDumpOnOutOfMemoryError
参数运行了一段时间。在最近一次溢出之后,管理员发回了 heapdump 文件,发现里面存在着大量的 org.jgroups.protocols.pbcast.NAKACK
对象。
JBossCache 是基于自家的 JGroups 进行集群间的数据通信,JGroups 使用协议栈的方式来实现收发数据包的各种所需特性自由组合,数据包接收和发送时要经过每层协议栈的 up() 和 down() 方法,其中的 NAKACK 栈用于保障各个包的有效顺序及重发。JBossCache 协议栈如图5-1所示。
由于信息有传输失败需要重发的可能性,在确认所有注册在 GMS(Group Membership Service)的节点都收到正确的信息前,发送的信息必须在内存中保留。而此 MIS 的服务端中有一个负责安全校验的全局 Filter,每当接收到请求时,均会更新一次最后操作时间,并且将这个时间同步到所有的节点去,使得一个用户在一段时间内不能在多台机器上登录。在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。
这个案例中的问题,既有 JBossCache 的缺陷,也有 MIS 系统实现方式上缺陷。JBossCache 官方的 maillist 中讨论过很多次类似的内存溢出异常问题,据说后续版本也有了改进。而更重要的缺陷是这一类被集群共享的数据要使用类似 JBossCache 这种集群缓存来同步的话,可以允许读操作频繁,因为数据在本地内存有一份副本,读取的动作不会耗费多少资源,但不应当有过于频繁的写操作,那样会带来很大的网络同步的开销。
5.2.3 堆外内存导致的溢出错误
例如,一个学校的小型项目:基于 B/S 的电子考试系统,为了实现客户端能实时地从服务器端接收考试数据,系统使用了逆向 AJAX 技术(也称为 Comet 或者 Server Side Push),选用 CometD 1.1.1 作为服务端推送框架,服务器是 Jetty 7.1.4,硬件为一台普通 PC 机,Core i5 CPU,4GB 内存,运行 32 位 Windows 操作系统。
测试期间发现服务端不定时抛出内存溢出异常,服务器不一定每次都会出现异常,但假如正式考试时崩溃一次,那估计整场电子考试都会乱套,网站管理员尝试过把堆开到最大,而 32 位系统最多到 1.6GB 就基本无法再加大了,而且开大了基本没效果,抛出内存溢出异常好像还更加频繁了。加入 -XX:+HeapDumpOnOutOfMemoryError
,居然也没有任何反应,抛出内存溢出异常时什么文件都没有产生。无奈之下只好挂着 jstat 并一直紧盯屏幕,发现 GC 并不频繁,Eden 区、Survivor 区、老年代以及永久代内存全部都表示“情绪稳定,压力不大”,但就是照样不停地抛出内存溢出异常,管理员压力很大。最后,在内存溢出后从系统日志中找到异常堆栈,如代码清单5-1所示。
[org.eclipse.jetty.util.log]handle failed java.lang.OutOfMemoryError:null
at sun.misc.Unsafe.allocateMemory(Native Method)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:99)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)
at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init>
……
如果认真阅读过本书的第 2 章,看到异常堆栈就应该清楚这个抛出内存溢出异常是怎么回事了。大家知道操作系统对每个进程能管理的内存是有限制的,这台服务器使用的 32 位 Windows 平台的限制是 2GB,其中划了 1.6GB 给 Java 堆,而 Direct Memory 内存并不算入 1.6GB 的堆之内,因此它最大也只能在剩余的 0.4GB 空间中分出一部分。在此应用中导致溢出的关键是:垃圾收集进行时,虚拟机虽然会对 Direct Memory 进行回收,但是 Direct Memory 却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了后 Full GC,然后“顺便地”帮它清理掉内存的废弃对象。否则它只能一直等到抛出内存溢出异常时,先 catch 掉,再在 catch 块里面“大喊”一声:“System.gc()!"。要是虚拟机还是不听(譬如打开了 -XX:+DisableExplicitGC
开关),那就只能眼睁睁地看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。而本案例中使用的 CometD 1.1.1 框架,正好有大量的 NIO 操作需要使用到 Direct Memory 内存。
从实践经验的角度出发,除了 Java 堆和永久代之外,我们注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制。
Direct Memory:可通过 -XX:MaxDirectMemorySize
调整大小,内存不足时抛出 OutOfMemoryError 或者 OutOfMemoryError:Direct buffer memory。
线程堆栈:可通过 -Xss 调整大小,内存不足时抛出 StackOverflowError(纵向无法分配,即无法分配新的栈帧)或者 OutOfMemoryError:unable to create new native thread(横向无法分配,即无法建立新的线程)。
Socket 缓存区:每个 Socket 连接都 Receive 和 Send 两个缓存区,分别占大约 37KB 和 25KB 内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能会抛出 IOException:Too many open files 异常。
JNI 代码:如果代码中使用 JNI 调用本地库,那本地库使用的内存也不在堆中。
虚拟机和 GC:虚拟机、GC 的代码执行也要消耗一定的内存。
5.2.4 外部命令导致系统缓慢
这是一个来自网络的案例:一个数字校园应用系统,运行在一台 4 个 CPU 的 Solaris 10 操作系统上,中间件为 GlassFish 服务器。系统在做大并发压力测试的时候,发现请求响应时间比较慢,通过操作系统的 mpstat 工具发现 CPU 使用率很高,并且系统占用绝大多数的 CPU 资源的程序并不是应用系统本身。这是个不正常的现象,通常情况下用户应用的 CPU 占用率应该占主要地位,才能说明系统是正常工作的。
通过 Solaris 10 的 Dtrace 脚本可以查看当前情况下哪些系统调用花费了最多的 CPU 资源,Dtrace 运行后发现最消耗 CPU 资源的竟然是"fork"系统调用。众所周知,“fork"系统调用是 Linux 用来产生新进程的,在 Java 虚拟机中,用户编写的 Java 代码最多只有线程的概念,不应当有进程的产生。
这是个非常异常的现象。通过本系统的开发人员,最终找到了答案:每个用户请求的处理都需要执行一个外部 shell 脚本来获得系统的一些信息。执行这个 shell 脚本是通过Java 的 Runtime.getRuntime().exec() 方法来调用的。这种调用方式可以达到目的,但是它在 Java 虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也非常可观。Java 虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是 CPU,内存负担也很重。
用户根据建议去掉这个 Shell 脚本执行的语句,改为使用 Java 的 API 去获取这些信息后,系统很快恢复了正常。
5.2.5 服务器 JVM 进程崩溃
例如,一个基于 B/S 的 MIS 系统,硬件为两台 2 个 CPU、8GB 内存的 HP 系统,服务器是 WebLogic 9.2(就是5.2.2节中的那套系统)。正常运行一段时间后,最近发现在运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下了一个 hs_err_pid???.log
文件后,进程就消失了,两台物理机器里的每个节点都出现过进程崩溃的现象。从系统日志中可以看出,每个节点的虚拟机进程在崩溃前不久,都发生过大量相同的异常,见代码清单 5-2。
java.net.SocketException:Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:168)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:218)
at java.io.BufferedInputStream.read(BufferedInputStream.java:235)
at org.apache.axis.transport.http.HTTPSender.readHeadersFromSocket(HTTPSender.java:583)
at org.apache.axis.transport.http.HTTPSender.invoke(HTTPSender.java:143)……99 more
这是一个远端断开连接的异常,通过系统管理员了解到系统最近与一个 OA 门户做了集成,在 MIS 系统工作流的待办事项变化时,要通过 Web 服务通知 OA 门户系统,把待办事项的变化同步到 OA 门户之中。通过 SoapUI 测试了一下同步待办事项的几个 Web 服务,发现调用后竟然需要长达 3 分钟才能返回,并且返回结果都是连接中断。
由于 MIS 系统的用户多,待办事项变化很快,为了不被 OA 系统速度拖累,使用了异步的方式调用 Web 服务,但由于两边服务速度的完全不对等,时间越长就累积了越多 Web 服务没有调用完成,导致在等待的线程和 Socket 连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。解决方法:通知 OA 门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。
5.2.6 不恰当数据结构导致内存占用过大
例如,有一个后台 RPC 服务器,使用 64 位虚拟机,内存配置为 -Xms4g-Xmx8g-Xmn1g
,使用 ParNew+CMS 的收集器组合。平时对外服务的 Minor GC 时间约在 30 毫秒以内,完全可以接受。但业务上需要每 10 分钟加载一个约 80MB 的数据文件到内存进行数据分析,这些数据会在内存中形成超过 100 万个 HashMap<Long,Long>Entry
,在这段时间里面 Minor GC 就会造成超过 500 毫秒的停顿,对于这个停顿时间就接受不了了,具体情况如下面 GC 日志所示。
{Heap before GC invocations=95(full 4):
par new generation total 903168K,used 803142K[0x00002aaaae770000, 0x00002aaaebb70000, 0x00002aaaebb70000)
eden space 802816K, 100%used[0x00002aaaae770000, 0x00002aaadf770000, 0x00002aaadf770000)
from space 100352K, 0%used[0x00002aaae5970000, 0x00002aaae59c1910, 0x00002aaaebb70000)
to space 100352K, 0%used[0x00002aaadf770000, 0x00002aaadf770000, 0x00002aaae5970000)
concurrent mark-sweep generation total 5845540K,used 3898978K[0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000)
2011-10-28 T11:40:45.162+0800:226.504:[GC226.504:[ParNew:803142K->100352K(903168K), 0.5995670 secs]4702120K->4056332K(6748708K), 0.5997560 secs][Times:user=1.46 sys=0.04, real=0.60 secs]
Heap after GC invocations=96(full 4):
par new generation total 903168K,used 100352K[0x00002aaaae770000, 0x00002aaaebb70000, 0x00002aaaebb70000)
eden space 802816K, 0%used[0x00002aaaae770000, 0x00002aaaae770000, 0x00002aaadf770000)
from space 100352K, 100%used[0x00002aaadf770000, 0x00002aaae5970000,
0x00002aaae5970000)
to space 100352K, 0x00002aaaebb70000)0%used[0x00002aaae5970000, 0x00002aaae5970000,
concurrent mark-sweep generation total 5845540K,used 3955980K[0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000)
}
Total time for which application threads were stopped:0.6070570 seconds
观察这个案例,发现平时的 Minor GC 时间很短,原因是新生代的绝大部分对象都是可清除的,在 Minor GC 之后 Eden 和 Survivor 基本上处于完全空闲的状态。而在分析数据文件期间,800MB 的 Eden 空间很快被填满从而引发 GC,但 Minor GC 之后,新生代中绝大部分对象依然是存活的。我们知道 ParNew 收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到 Survivor 并维持这些对象引用的正确就成为一个沉重的负担,因此导致 GC 暂停时间明显变长。
如果不修改程序,仅从 GC 调优的角度去解决这个问题,可以考虑将 Survivor 空间去掉(加入参数-XX:SurvivorRatio=65536
、-XX:MaxTenuringThreshold=0
或者 -XX:+AlwaysTenure
),让新生代中存活的对象在第一次 Minor GC 后立即进入老年代,等到 Major GC 的时候再清理它们。这种措施可以治标,但也有很大副作用,治本的方案需要修改程序,因为这里的问题产生的根本原因是用 HashMap<Long,Long>
结构来存储数据文件空间效率太低。
下面具体分析一下空间效率。在 HashMap<Long,Long>
结构中,只有 Key 和 Value 所存放的两个长整型数据是有效数据,共 16B(2×8B)。这两个长整型数据包装成 java.lang.Long
对象之后,就分别具有 8B 的 MarkWord、8B 的 Klass 指针,在加 8B 存储数据的 long 值。在这两个 Long 对象组成 Map.Entry
之后,又多了 16B 的对象头,然后一个 8B 的 next 字段和 4B 的 int 型的 hash 字段,为了对齐,还必须添加 4B 的空白填充,最后还有 HashMap 中对这个 Entry 的 8B 的引用,这样增加两个长整型数字,实际耗费的内存为(Long(24B)×2)+Entry(32B)+HashMap Ref(8B)=88B,空间效率为 16B/88B=18%,实在太低了。
5.2.7 由 Windows 虚拟内存导致的长时间停顿
例如,有一个带心跳检测功能的 GUI 桌面程序,每 15 秒会发送一次心跳检测信号,如果对方 30 秒以内都没有信号返回,那就认为和对方程序的连接已经断开。程序上线后发现心跳检测有误报的概率,查询日志发现误报的原因是程序会偶尔出现间隔约一分钟左右的时间完全无日志输出,处于停顿状态。
因为是桌面程序,所需的内存并不大(-Xmx256m),所以开始并没有想到是 GC 导致的程序停顿,但是加入参数 -XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDateStamps-Xloggc:gclog.log
后,从 GC 日志文件中确认了停顿确实是由 GC 导致的,大部分 GC 时间都控制在 100 毫秒以内,但偶尔就会出现一次接近 1 分钟的 GC。
Total time for which application threads were stopped:0.0112389 seconds
Total time for which application threads were stopped:0.0001335 seconds
Total time for which application threads were stopped:0.0003246 seconds
Total time for which application threads were stopped:41.4731411 seconds
Total time for which application threads were stopped:0.0489481 seconds
Total time for which application threads were stopped:0.1110761 seconds
Total time for which application threads were stopped:0.0007286 seconds
Total time for which application threads were stopped:0.0001268 seconds
从 GC 日志中找到长时间停顿的具体日志信息(添加了 -XX:+PrintReferenceGC
参数),找到的日志片段如下所示。从日志中可以看出,真正执行 GC 动作的时间不是很长,但从准备开始 GC,到真正开始 GC 之间所消耗的时间却占了绝大部分。
除 GC 日志之外,还观察到这个 GUI 程序内存变化的一个特点,当它最小化的时候,资源管理中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生 GC 时就有可能因为恢复页面文件的操作而导致不正常的 GC 停顿。
在 MSDN 上查证后确认了这种猜想,因此,在 Java 的 GUI 程序中要避免这种现象,可以加入参数”-Dsun.awt.keepWorkingSetOnMinimize=true"来解决。这个参数在许多 AWT 的程序上都有应用,例如 JDK 自带的 Visual VM,用于保证程序在恢复最小化时能够立即响应。在这个案例中加入该参数后,问题得到解决。
5.3 实战:Eclipse运行速度调优
很多 Java 开发人员都有这样一种观念:系统调优的工作都是针对服务端应用而言,规模越大的系统,就越需要专业的调优运维团队参与。这个观点不能说不对,5.2 节中笔者所列举的案例确实都是服务端运维、调优的例子,但服务端应用需要调优,并不说明其他应用就不需要了,作为一个普通的 Java 开发人员,前面讲的各种虚拟机的原理和最佳实践方法距离我们并不遥远,开发者身边很多场景都可以使用上面这些知识。下面通过一个普通程序员日常工作中可以随时接触到的开发工具开始这次实战。
5.3.1 调优前的程序运行状态
笔者使用 Eclipse 作为日常工作中的主要 IDE 工具,由于安装的插件比较大(如Klocwork、ClearCase LT等)、代码也很多,启动 Eclipse 直到所有项目编译完成需要四五分钟。一直对开发环境的速度感觉不满意,趁着编写这本书的机会,决定对 Eclipse 进行“动刀”调优。
笔者机器的 Eclipse 运行平台是 32 位 Windows 7 系统,虚拟机为 HotSpot VM 1.5 b64。硬件为 ThinkPad X201,Intel i5 CPU,4GB 物理内存。在初始的配置文件 eclipse.ini 中,除了指定 JDK 的路径、设置最大堆为 512MB 以及开启了JMX 管理(需要在 VisualVM 中收集原始数据)外,未做其他任何改动,原始配置内容如代码清单5-3所示。
-vm
D:/_DevSpace/jdk1.5.0/bin/javaw.exe
-startup
plugins/org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
--launcher.library
plugins/org.eclipse.equinox.launcher.win32.win32.x86_1.0.200.v20090519
-product
org.eclipse.epp.package.jee.product
--launcher.XXMaxPermSize
256M
-showsplash
org.eclipse.platform
-vmargs
-Dosgi.requiredJavaVersion=1.5
-Xmx512m
-Dcom.sun.management.jmxremote
为了要与调优后的结果进行量化对比,调优开始前笔者先做了一次初始数据测试。测试用例很简单,就是收集从 Eclipse 启动开始,直到所有插件加载完成为止的总耗时以及运行状态数据,虚拟机的运行数据通过 VisualVM 及其扩展插件 VisualGC 进行采集。测试过程中反复启动数次 Eclipse 直到测试结果稳定后,取最后一次运行的结果作为数据样本(为了避免操作系统未能及时进行磁盘缓存而产生的影响),数据样本如图5-2所示。
Eclipse 启动的总耗时没有办法从监控工具中直接获得,因为 VisualVM 不可能知道 Eclipse 运行到什么阶段算是启动完成。为了测试的准确性,笔者写了一个简单的 Eclipse 插件,用于统计 Eclipse 的启动耗时。由于代码很简单,并且本书不是 Eclipse RCP 开发的教程,所以只列出代码清单 5-4 供读者参考,不再延伸讲解。如果读者需要这个插件,可以使用下面代码自行编译或者发电子邮件向笔者索取。
ShowTime.java代码:
import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.ui.IStartup;
/**
*统计Eclipse启动耗时
*@author zzm
*/
public class ShowTime implements IStartup{
public void earlyStartup(){
Display.getDefault().syncExec(new Runnable(){
public void run(){
long eclipseStartTime=Long.parseLong(System.getProperty("eclipse.startTime"));
long costTime=System.currentTimeMillis()-eclipseStartTime;
Shell shell=Display.getDefault().getActiveShell();
String message="Eclipse启动耗时:"+costTime+"ms";
MessageDialog.openInformation(shell, "Information", message);
}
});
}
}
plugin.xml 代码:
<?xml version="1.0"encoding="UTF-8"?>
<?eclipse version="3.4"?>
<plugin>
<extension
point="org.eclipse.ui.startup">
<startup class="eclipsestarttime.actions.ShowTime"/>
</extension>
</plugin>
上述代码打包成 jar 后放到 Eclipse 的 plugins 目录,反复启动几次后,插件显示的平均时间稳定在 15 秒左右。
根据 VisualGC 和 Eclipse 插件收集到的信息,总结原始配置下的测试结果如下。
- 整个启动过程平均耗时约15秒。
- 最后一次启动的数据样本中,垃圾收集总耗时 4.149 秒,其中:
- Full GC 被触发了 19 次,共耗时 3.166 秒。
- Minor GC 被触发了 378 次,共耗时 0.983 秒。
- 加载类 9115 个,耗时 4.114 秒。
- JIT编译时间为 1.999 秒。
虚拟机 512MB 的堆内存被分配为 40MB 的新生代(31.5 的 Eden 空间和两个 4MB 的 Surviver 空间)以及 472MB 的老年代。
客观地说,由于机器硬件还不错(请读者以 2010 年普通 PC 机的标准来衡量),15秒的启动时间其实还在可接受范围以内,但是从 VisualGC 中反映的数据来看,主要问题是非用户程序时间(图5-2中的 Compile Time、Class Load Time、GC Time)非常之高,占了整个启动过程耗时的一半以上(这里存在少许夸张成分,因为如 JIT 编译等动作是在后台线程完成的,用户程序在此期间也正常执行,所以并没有占用了一半以上的绝对时间)。虚拟机后台占用太多时间也直接导致 Eclipse 在启动后的使用过程中经常有不时停顿的感觉,所以进行调优有较大的价值。
5.3.2 升级 JDK 1.6 的性能变化及兼容问题
对 Eclipse 进行调优的第一步就是先把虚拟机的版本进行升级,希望能先从虚拟机版本身上得到一些“免费的”性能提升。
每次 JDK 的大版本发布时,开发商肯定都会宣称虚拟机的运行速度比上一版本有了很大的提高,这虽然是个广告性质的宣言,经常被人从升级列表或者技术白皮书中直接忽略过去,但从国内外的第三方评测数据来看,版本升级至少某些方面确实带来了一定的性能改善,以下是一个第三方网站对 JDK 1.5、1.6、1.7 三个版本做的性能评测,分别测试了以下4个用例:
- 生成 500 万个的字符串。
- 500 万次
ArrayList<String>
数据插入,使用第一点生成的数据。 - 生成 500 万个
HashMap<String,Integer>
,每个键-值对通过并发线程计算,测试并发能力。 - 打印 500 万个
ArrayList<String>
中的值到文件,并重读回内存。
三个版本的 JDK 分别运行这 3 个用例的测试程序,测试结果如图5-4所示。
从这 4 个用例的测试结果来看,JDK 1.6 比 JDK 1.5 有大约 15% 的性能提升,尽管对 JDK 仅测试这 4 个用例并不能说明什么问题,需要通过测试数据来量化描述一个 JDK 比旧版提升了多少是很难做到非常科学和准确的(要做稍微靠谱一点的测试,可以使用 SPECjvm2008 来完成,或者把相应版本的 TCK 中数万个测试用例的性能数据对比一下可能更有说服力),但我还是选择相信这次“软广告”性质的测试,把 JDK 版本升级到 1.6 Update 21。
与所有小说作者设计的故事情节一样,获得最后的胜利之前总是要经历各种各样的挫折,这次升级到 JDK 1.6 之后,性能有什么变化先暂且不谈,在使用几分钟之后,笔者的 Eclipse 就和前面几个服务端的案例一样非常“不负众望”地发生了内存溢出。
这次内存溢出完全出乎笔者的意料之外:决定对 Eclipse 做调优是因为速度慢,但开发环境一直都很稳定,至少没有出现过内存溢出的问题,而这次升级除了 eclipse.ini 中的 JVM 路径改变了之外,还未进行任何运行参数的调整,进到 Eclipse 主界面之后随便打开了几个文件就抛出内存溢出异常了,难道 JDK 1.6 Update 21 有哪个 API 出现了严重的泄漏问题吗?
事实上,并不是 JDK 1.6 出现了什么问题,根据前面章节中介绍的相关原理和工具,我们要查明这个异常的原因并且解决它一点也不困难。打开 VisualVM,监视页签中的内存曲线部分如图5-6和图5-7所示。
在 Java 堆中监视曲线中,“堆大小”的曲线与“使用的堆”的曲线一直都有很大的间隔距离,每当两条曲线开始有互相靠近的趋势时,“最大堆”的曲线就会快速向上转向,而“使用的堆”的曲线会向下转向。“最大堆”的曲线向上是虚拟机内部在进行堆扩容,运行参数中并没有指定最小堆(-Xms)的值与最大堆(-Xmx)相等,所以堆容量一开始并没有扩展到最大值,而是根据使用情况进行伸缩扩展。“使用的堆”的曲线向下是因为虚拟机内部触发了一次垃圾收集,一些废弃对象的空间被回收后,内存用量相应减少,从图形上看,Java 堆运作是完全正常的。但永久代的监视曲线就有问题了,“PermGen大小”的曲线与“使用的PermGen”的曲线几乎完全重合在一起,这说明永久代中没有可回收的资源,所以“使用的PermGen”的曲线不会向下发展,永久代中也没有空间可以扩展,所以“PermGen大小”的曲线不能向上扩展。这次内存溢出很明显是永久代导致的内存溢出。
再注意到图 5-7 中永久代的最大容量:“67,108,864个字节”,也就是 64MB,这恰好是 JDK 在未使用 -XX:MaxPermSize
参数明确指定永久代最大容量时的默认值,无论 JDK 1.5 还是 JDK 1.6,这个默认值都是 64MB。对于 Eclipse 这种规模的 Java 程序来说,64MB 的永久代内存空间显然是不够的,溢出很正常,那为何在 JDK 1.5 中没有发生过溢出呢?
在 VisualVM 的“概述-JVM参数”页签中,分别检查使用 JDK 1.5 和 JDK 1.6 运行 Eclipse 时的 JVM 参数,发现使用 JDK 1.6 时,只有以下 3 个 JVM 参数,如代码清单5-5所示。
-Dcom.sun.management.jmxremote
-Dosgi.requiredJavaVersion=1.5
-Xmx512m
而使用 JDK 1.5 运行时,就有 4 条 JVM 参数,其中多出来的一条正好就是设置永久代最大容量的 -XX:MaxPermSize=256M
,如代码清单5-6所示。
代码清单5-6 JDK 1.5的Eclipse运行期参数
-Dcom.sun.management.jmxremote
-Dosgi.requiredJavaVersion=1.5
-Xmx512m
-XX:MaxPermSize=256M
为什么会这样呢?笔者从 Eclipse 的 Bug List 网站上找到了答案:使用 JDK 1.5 时之所以有永久代容量这个参数,是因为在 eclipse.ini 中存在”–launcher.XXMaxPermSize 256M"这项设置,当 launcher——也就是 Windows 下的可执行程序 eclipse.exe,检测到假如是 Eclipse 运行在 Sun 公司的虚拟机上的话,就会把参数值转化为 -XX:MaxPermSize
传递给虚拟机进程,因为三大商用虚拟机中只有 Sun 系列的虚拟机才有永久代的概念,也就是只有 HotSpot 虚拟机需要设置这个参数,JRockit 虚拟机和 IBM J9 虚拟机都不需要设置。
在2009年4月20日,Oracle 公司正式完成了对 Sun 公司的收购,此后无论是网页还是具体程序产品,提供商都从 Sun 变为了 Oracle,而 eclipse.exe 就是根据程序提供商判断是否为 Sun 的虚拟机,当 JDK 1.6 Update 21 中 java.exe、javaw.exe 的"Company"属性从"Sun Microsystems Inc.“变为"Oracle Corporation"之后,Eclipse 就完全不认识这个虚拟机了,因此没有把最大永久代的参数传递过去。
了解原因之后,解决方法就简单了,launcher 不认识就只好由人来告诉它,即在 eclipse.ini 中明确指定 -XX:MaxPermSize=256M
这个参数就可以了。
5.3.3 编译时间和类加载时间的优化
从 Eclipse 启动时间上来看,升级到 JDK 1.6 所带来的性能提升是……嗯?基本上没有提升?多次测试的平均值与 JDK 1.5 的差距完全在实验误差范围之内。
各位读者不必失望,Sun JDK 1.6 性能白皮书描述的众多相对于 JDK 1.5 的提升不至于全部是广告,虽然总启动时间没有减少,但在查看运行细节的时候,却发现了一件很值得注意的事情:在 JDK 1.6 中启动完 Eclipse 所消耗的类加载时间比 JDK 1.5 长了接近一倍,不要看反了,这里写的是 JDK 1.6 的类加载比 JDK 1.5 慢一倍,测试结果如代码清单5-7所示,反复测试多次仍然是相似的结果。
使用JDK 1.6的类加载时间:
C:\Users\IcyFenix>jps
3552
6372 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
6900 Jps
C:\Users\IcyFenix>jstat-class 6372
Loaded Bytes Unloaded Bytes Time
7917 10190.3 0 0.0 8.18
使用JDK 1.5的类加载时间:
C:\Users\IcyFenix>jps
3552
7272 Jps
7216 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
C:\Users\IcyFenix>jstat-class 7216
Loaded Bytes Unloaded Bytes Time
7902 9691.2 3 2.6 4.34
在本例中,类加载时间上的差距并不能作为一个具有普遍性的测试结果去说明 JDK 1.6 的类加载必然比 JDK 1.5 慢,笔者测试了自己机器上的 Tomcat 和 GlassFish 启动过程,并未没有出现类似的差距。在国内最大的 Java 社区中,笔者发起过关于此问题的讨论,从参与者反馈的测试结果来看,此问题只在一部分机器上存在,而且 JDK 1.6 的各个 Update 版之间也存在很大差异。
多次试验后,笔者发现在机器上两个 JDK 进行类加载时,字节码验证部分耗时差距尤其严重。考虑到实际情况:Eclipse 使用者甚多,它的编译代码我们可以认为是可靠的,不需要在加载的时候再进行字节码验证,因此通过参数 -Xverify:none
禁止掉字节码验证过程也可作为一项优化措施。加入这个参数后,两个版本的 JDK 类加载速度都有所提高,JDK 1.6 的类加载速度仍然比 JDK 1.5 慢,但是两者的耗时已经接近了许多,测试数据如代码清单5-8所示。关于类与类加载的话题,譬如刚刚提到的字节码验证是怎么回事,本书专门规划了两个章节进行详细讲解,在此不再延伸讨论。
使用JDK 1.6的类加载时间:
C:\Users\IcyFenix>jps
5512 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
5596 Jps
C:\Users\IcyFenix>jstat-class 5512
Loaded Bytes Unloaded Bytes Time
6749 8837.0 0 0.0 3.94
使用JDK 1.5的类加载时间:
C:\Users\IcyFenix>jps
4724 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
5412 Jps
C:\Users\IcyFenix>jstat-class 4724
Loaded Bytes Unloaded Bytes Time
6885 9109.7 3 2.6 3.10
在取消字节码验证之后,JDK 1.5 的平均启动下降到了 13 秒,而 JDK 1.6 的测试数据平均比 JDK 1.5 快 1 秒,下降到平均 12 秒左右,如图5-8所示。在类加载时间仍然落后的情况下,依然可以看到 JDK 1.6 在性能上比 JDK 1.5 稍有优势,说明至少在 Eclipse 启动这个测试用例上,升级 JDK 版本确实能带来一些“免费的”性能提升。
前面说过,除了类加载时间以外,在 VisualGC 的监视曲线中显示了两项很大的非用户程序耗时:编译时间(Compile Time)和垃圾收集时间(GC Time)。垃圾收集时间读者应该非常清楚了,而编译时间是什么呢?程序在运行之前不是已经编译了吗?虚拟机的 JIT 编译与垃圾收集一样,是本书的一个重要部分,后面有专门章节讲解,这里先简单介绍一下:编译时间是指虚拟机的 JIT 编译器(Just In Time Compiler)编译热点代码(Hot Spot Code)的耗时。我们知道 Java 语言为了实现跨平台的特性,Java 代码编译出来后形成的 Class 文件中存储的是字节码(ByteCode),虚拟机通过解释方式执行字节码命令,比起 C/C++ 编译成本地二进制代码来说,速度要慢不少。为了解决程序解释执行的速度问题,JDK 1.2 以后,虚拟机内置了两个运行时编译器,如果一段Java 方法被调用次数达到一定程度,就会被判定为热代码交给 JIT 编译器即时编译为本地代码,提高运行速度(这就是 HotSpot 虚拟机名字的由来)。甚至有可能在运行期动态编译比 C/C++ 的编译期静态译编出来的代码更优秀,因为运行期可以收集很多编译器无法知道的信息,甚至可以采用一些很激进的优化手段,在优化条件不成立的时候再逆优化退回来。所以 Java 程序只要代码没有问题(主要是泄漏问题,如内存泄漏、连接泄漏),随着代码被编译得越来越彻底,运行速度应当是越运行越快的。Java 的运行期编译最大的缺点就是它进行编译需要消耗程序正常的运行时间,这也就是上面所说的“编译时间”。
虚拟机提供了一个参数 -Xint 禁止编译器运作,强制虚拟机对字节码采用纯解释方式执行。如果读者想使用这个参数省下 Eclipse 启动中那 2 秒的编译时间获得一个“更好看”的成绩的话,那恐怕要失望了,加上这个参数之后,虽然编译时间确实下降到 0,但 Eclipse 启动的总时间剧增到 27 秒。看来这个参数现在最大的作用似乎就是让用户怀念一下 JDK 1.2 之前那令人心酸和心碎的运行速度。
与解释执行相对应的另一方面,虚拟机还有力度更强的编译器:当虚拟机运行在 -client 模式的时候,使用的是一个代号为 C1 的轻量级编译器,另外还有一个代号为 C2 的相对重量级的编译器能提供更多的优化措施,如果使用 -server 模式的虚拟机启动 Eclipse 将会使用到 C2 编译器,这时从 VisualGC 可以看到启动过程中虚拟机使用了超过 15 秒的时间去进行代码编译。如果读者的工作习惯是长时间不关闭 Eclipse 的话,C2 编译器所消耗的额外编译时间最终还是会在运行速度的提升之中赚回来,这样使用 -server 模式也是一个不错的选择。不过至少在本次实战中,我们还是继续选用 -client 虚拟机来运行 Eclipse。
5.3.4 调整内存设置控制垃圾收集频率
三大块非用户程序时间中,还剩下 GC 时间没有调整,而 GC 时间却又是其中最重要的一块,并不只是因为它是耗时最长的一块,更因为它是一个稳定持续的过程。由于我们做的测试是在测程序的启动时间,所以类加载和编译时间在这项测试中的影响力被大幅度放大了。在绝大多数的应用中,不可能出现持续不断的类被加载和卸载。在程序运行一段时间后,热点方法被不断编译,新的热点方法数量也总会下降,但是垃圾收集则是随着程序运行而不断运作的,所以它对性能的影响才显得尤为重要。
在Eclipse启动的原始数据样本中,短短 15 秒,类共发生了 19 次 Full GC 和 378 次 Minor GC,一共 397 次 GC 共造成了超过 4 秒的停顿,也就是超过 1/4 的时间都是在做垃圾收集,这个运行数据看起来实在太糟糕了。
首先来解决新生代中的 Minor GC,虽然 GC 的总时间只有不到 1 秒,但却发生了 378 次之多。从 VisualGC 的线程监视中看到,Eclipse 启动期间一共发起了超过 70 条线程,同时在运行的线程数超过 25 条,每当发生一次垃圾收集动作,所有用户线程都必须跑到最近的一个安全点(SafePoint)然后挂起线程等待垃圾回收。这样过于频繁的 GC 就会导致很多没有必要的安全点检测、线程挂起及恢复操作。
新生代 GC 频繁发生,很明显是由于虚拟机分配给新生代的空间太小而导致的,Eden 区加上一个 Survivor 区还不到 35MB。因此很有必要使用 -Xmn 参数调整新生代的大小。
再来看一看那 19 次 Full GC,看起来 19 次并“不多”(相对于 378 次 Minor GC 来说),但总耗时为 3.166 秒,占了 GC 时间的绝大部分,降低 GC 时间的主要目标就要降低这部分时间。从 VisualGC 的曲线图上可能看得不够精确,这次直接从 GC 日志中分析一下这些 Full GC 是如何产生的,代码清单5-9中是启动最开始的 2.5 秒内发生的 10 次 Full GC 记录。
0.278:[GC 0.278:[DefNew:574K->33K(576K), 0.0012562 secs]0.279:[Tenured:1467K->997K(1536K), 0.0181775 secs]1920K->997K(2112K), 0.0195257 secs]
0.312:[GC 0.312:[DefNew:575K->64K(576K), 0.0004974 secs]0.312:[Tenured:1544K->1608K(1664K), 0.0191592 secs]1980K->1608K(2240K), 0.0197396 secs]
0.590:[GC 0.590:[DefNew:576K->64K(576K), 0.0006360 secs]0.590:[Tenured:2675K->2219K(2684K), 0.0256020 secs]3090K->2219K(3260K), 0.0263501 secs]
0.958:[GC 0.958:[DefNew:551K->64K(576K), 0.0011433 secs]0.959:[Tenured:3979K->3470K(4084K), 0.0419335 secs]4222K->3470K(4660K), 0.0431992 secs]
1.575:[Full GC 1.575:[Tenured:4800K->5046K(5784K), 0.0543136 secs]5189K->5046K(6360K), [Perm:12287K->12287K(12288K)], 0.0544163 secs]
1.703:[GC 1.703:[DefNew:703K->63K(704K), 0.0012609 secs]1.705:[Tenured:8441K->8505K(8540K), 0.0607638 secs]8691K->8505K(9244K), 0.0621470 secs]
1.837:[GC 1.837:[DefNew:1151K->64K(1152K), 0.0020698 secs]1.839:[Tenured:14616K->14680K(14688K), 0.0708748 secs]15035K->14680K(15840K), 0.0730947 secs]
2.144:[GC 2.144:[DefNew:1856K->191K(1856K), 0.0026810 secs]2.147:[Tenured:25092K->24656K(25108K), 0.1112429 secs]26172K->24656K(26964K), 0.1141099 secs]
2.337:[GC 2.337:[DefNew:1914K->0K(3136K), 0.0009697 secs]2.338:[Tenured:41779K->27347K(42056K), 0.0954341 secs]42733K->27347K(45192K), 0.0965513 secs]
2.465:[GC 2.465:[DefNew:2490K->0K(3456K), 0.0011044 secs]2.466:[Tenured:46379K->27635K(46828K), 0.0956937 secs]47621K->27635K(50284K), 0.0969918 secs]
括号中加粗的数字代表老年代的容量,这组 GC 日志显示了 10 次 Full GC 发生的原因全部都是老年代空间耗尽,每发生一次 Full GC 都伴随着一次老年代空间扩容:1536KB->1664KB->2684KB……42056KB->46828KB,10 次 GC 以后老年代容量从起始的 1536KB 扩大到 46828KB,当 15 秒后 Eclipse 启动完成时,老年代容量扩大到了 103428KB,代码编译开始后,老年代容量到达顶峰 473MB,整个 Java 堆到达最大容量 512MB。
日志还显示有些时候内存回收状况很不理想,空间扩容成为获取可用内存的最主要手段,譬如语句"Tenured:25092K->24656K(25108K),0.1112429 secs”,代表老年代当前容量为 25108KB,内存使用到 25092KB 的时候发生 Full GC,花费 0.11 秒把内存使用降低到 24656KB,只回收了不到 500KB 的内存,这次 GC 基本没有什么回收效果,仅仅做了扩容,扩容过程相比起回收过程可以看做是基本不需要花费时间的,所以说这 0.11 秒几乎是白白浪费了。
由上述分析可以得出结论:Eclipse 启动时,Full GC 大多数是由于老年代容量扩展而导致的,由永久代空间扩展而导致的也有一部分。为了避免这些扩展所带来的性能浪费,我们可以把 -Xms 和 -XX:PermSize
参数值设置为 -Xmx 和 -XX:MaxPermSize
参数值一样,这样就强制虚拟机在启动的时候就把老年代和永久代的容量固定下来,避免运行时自动扩展。
根据分析,优化计划确定为:把新生代容量提升到 128MB,避免新生代频繁 GC;把 Java 堆、永久代的容量分别固定为 512MB 和 96MB,避免内存扩展。这几个数值都是根据机器硬件、Eclipse 插件和工程数量来决定的,读者实践的时候应根据 VisualGC 中收集到的实际数据进行设置。改动后的 eclipse.ini 配置如代码清单5-10所示。
-vm
D:/_DevSpace/jdk1.6.0_21/bin/javaw.exe
-startup
plugins/org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
--launcher.library
plugins/org.eclipse.equinox.launcher.win32.win32.x86_1.0.200.v20090519
-product
org.eclipse.epp.package.jee.product
-showsplash
org.eclipse.platform
-vmargs
-Dosgi.requiredJavaVersion=1.5
-Xverify:none
-Xmx512m
-Xms512m
-Xmn128m
-XX:PermSize=96m
-XX:MaxPermSize=96m
现在这个配置之下,GC 次数已经大幅度降低,图5-9是 Eclipse 启动后 1 分钟的监视曲线,只发生了 8 次 Minor GC 和 4 次 Full GC,总耗时为 1.928 秒。
这个结果已经算是基本正常,但是还存在一点瑕疵:从 Old Gen 的曲线上看,老年代直接固定在 384MB,而内存使用量只有 66MB,并且一直很平滑,完全不应该发生 Full GC 才对,那 4 次 Full GC 是怎么来的?使用 jstat-gccause 查询一下最近一次 GC 的原因,见代码清单5-11。
C:\Users\IcyFenix>jps
9772 Jps
4068 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
C:\Users\IcyFenix>jstat-gccause 4068
S0 S1 E O P YGC YGCT FGC FGCT GCT LGCC GCC
0.00 0.00 1.00 14.81 39.29 6 0.422 20 5.992 6.414
System.gc()No GC
从 LGCC(Last GC Cause)中看到,原来是代码调用 System.gc()
显式触发的 GC,在内存设置调整后,这种显式 GC 已不符合我们的期望,因此在 eclipse.ini 中加入参数 -XX:+DisableExplicitGC
屏蔽掉 System.gc()
。再次测试发现启动期间的 Full GC 已经完全没有了,只有 6 次 Minor GC,耗时 417 毫秒,与调优前 4.149 秒的测试样本相比,正好是十分之一。进行 GC 调优后 Eclipse 的启动时间下降非常明显,比整个 GC 时间降低的绝对值还大,现在启动只需要 7 秒多。
5.3.5 选择收集器降低延迟
现在 Eclipse 启动已经比较迅速了,但我们的调优实战还没有结束,毕竟 Eclipse 是拿来写程序的,不是拿来测试启动速度的。我们不妨再在 Eclipse 中测试一个非常常用但又比较耗时的操作:代码编译。图5-11是当前配置下 Eclipse 进行代码编译时的运行数据,从图中可以看出,新生代每次回收耗时约 65 毫秒,老年代每次回收耗时约 725 毫秒。对于用户来说,新生代 GC 的耗时还好,65 毫秒在使用中无法察觉到,而老年代每次 GC 停顿接近 1 秒钟,虽然比较长时间才会出现一次,但停顿还是显得太长了一些。
再注意看一下编译期间的 CPU 资源使用状况。图 5-12 是 Eclipse 在编译期间的 CPU 使用率曲线图,整个编译过程中平均只使用了不到 30% 的 CPU 资源,垃圾收集的 CPU 使用率曲线更是几乎与坐标横轴紧贴在一起,这说明 CPU 资源还有很多可利用的余地。
列举 GC 停顿时间、CPU 资源富余的目的,都是为了接下来替换掉 Client 模式的虚拟机中默认的新生代、老年代串行收集器做铺垫。
Eclipse 应当算是与使用者交互非常频繁的应用程序,由于代码太多,笔者习惯在做全量编译或者清理动作的时候,使用"Run in Backgroup"功能一边编译一边继续工作。回顾一下在第 3 章提到的几种收集器,很容易想到 CMS 是最符合这类场景的收集器。因此尝试在 eclipse.ini 中再加入这两个参数 -XX:+UseConcMarkSweepGC
、-XX:+UseParNewGC
(ParNew 收集器是使用 CMS 收集器后的默认新生代收集器,写上仅是为了配置更加清晰),要求虚拟机在新生代和老年代分别使用 ParNew 和 CMS 收集器进行垃圾回收。指定收集器之后,再次测试的结果如图5-13所示,与原来使用串行收集器对比,新生代停顿从每次 65 毫秒下降到了每次 53 毫秒,而老年代的停顿时间更是从 725 毫秒大幅下降到了 36 毫秒。
当然,CMS 的停顿阶段只是收集过程中的一小部分,并不是真的把垃圾收集时间从 725 毫秒变成 36 毫秒了。在 GC 日志中可以看到 CMS 与程序并发的时间约为 400 毫秒,这样收集器的运作结果就比较令人满意了。
到此,对于虚拟机内存的调优基本就结束了,这次实战可以看做是一次简化的服务端调优过程,因为服务端调优有可能还会存在于更多方面,如数据库、资源池、磁盘I/O等,但对于虚拟机内存部分的优化,与这次实战中的思路没有什么太大差别。即使读者实际工作中接触不到服务器,根据自己工作环境做一些试验,总结几个参数让自己日常工作环境速度有较大幅度提升也是很划算的。
5.4 本章小结
Java 虚拟机的内存管理与垃圾收集是虚拟机结构体系中最重要的组成部分,对程序的性能和稳定性有非常大的影响,在本书的第 2~5 章中,笔者从理论知识、异常现象、代码、工具、案例、实战等几个方面对其进行了讲解,希望读者有所收获。
本书关于虚拟机内存管理部分到此为止就结束了,后面将开始介绍Class文件与虚拟机执行子系统方面的知识。
4.6 - CH06-类文件结构
6.1 概述
越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。
6.2 无关性的基石
Java在刚刚诞生之时曾经提出过一个非常著名的宣传口号:“一次编写,到处运行(Write Once,Run Anywhere)”,这句话充分表达了软件开发人员对冲破平台界限的渴求。
“与平台无关”的理想最终实现在操作系统的应用层上:Sun公司以及其他虚拟机提供商发布了许多可以运行在各种不同平台上的虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石,但本节标题中刻意省略了“平台”二字,那是因为笔者注意到虚拟机的另外一种中立特性——语言无关性正越来越被开发者所重视。
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。基于安全方面的考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束,但任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。作为一个通用的、机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为语言的产品交付媒介。
Java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此,有一些Java语言本身无法有效支持的语言特性不代表字节码本身无法有效支持,这也为其他语言实现一些有别于Java的语言特性提供了基础。
6.3 Class 类文件结构
注意 任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。
根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础,所以这里要先介绍这两个概念。
- 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以
_info
结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表,它由表6-1所示的数据项构成。
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合。
Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(Byte Ordering,Class文件中字节序为Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。接下来我们将一起看看这个表中各个数据项的具体含义。
6.3.1 魔数与 Class 文件的版本
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK 1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
package org.fenixsoft.clazz;
public class TestClass{
private int m;
public int inc(){
return m+1;
}
}
图6-2显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚地看见开头4个字节的十六进制表示是0xCAFEBABE,代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值为0x0032,也即是十进制的50,该版本号说明这个文件是可以被JDK 1.6或以上版本虚拟机执行的Class文件。
表6-2列出了从JDK 1.1到JDK 1.7,主流JDK版本编译器输出的默认和可支持的Class文件版本号。
6.3.2 常量池
紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的,如图6-3所示,常量池容量(偏移地址:0x00000008)为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况就可以把索引值置为0来表示。Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始的。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
常量池中每一项常量都是一个表,在JDK 1.7之前共有11种结构各不相同的表结构数据,在JDK 1.7中为了更好地支持动态语言调用,又额外增加了3种(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info)。
这14种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位(tag,取值见表6-3中标志列),代表当前这个常量属于哪种常量类型。这14种常量类型所代表的具体含义见表6-3。
之所以说常量池是最烦琐的数据,是因为这14种常量类型各自均有自己的结构。
回头看看图6-3中常量池的第一项常量,它的标志位(偏移地址:0x0000000A)是0x07,查表6-3的标志列发现这个常量属于CONSTANT_Class_info类型,此类型的常量代表一个类或者接口的符号引用。CONSTANT_Class_info的结构比较简单,见表6-4。
tag是标志位,上面已经讲过了,它用于区分常量类型;name_index是一个索引值,它指向常量池中一个CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名,这里name_index值(偏移地址:0x0000000B)为0x0002,也即是指向了常量池中的第二项常量。继续从图6-3中查找第二项常量,它的标志位(地址:0x0000000D)是0x01,查表6-3可知确实是一个CONSTANT_Utf8_info类型的常量。CONSTANT_Utf8_info类型的结构见表6-5。
length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。UTF-8缩略编码与普通UTF-8编码的区别是:从’\u0001’到’\u007f’之间的字符(相当于1~127的ASCII码)的缩略编码使用一个字节表示,从’\u0080’到’\u07ff’之间的所有字符的缩略编码用两个字节表示,从’\u0800’到’\uffff’之间的所有字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示。
顺便提一下,由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,将会无法编译。
本例中这个字符串的length值(偏移地址:0x0000000E)为0x001D,也就是长29字节,往后29字节正好都在1~127的ASCII码范围以内,内容为"org/fenixsoft/clazz/TestClass",有兴趣的读者可以自己逐个字节换算一下,换算结果如图6-4选中的部分所示。
到此为止,我们分析了TestClass.class常量池中21个常量中的两个,其余的19个常量都可以通过类似的方法计算出来。为了避免计算过程占用过多的版面,后续的19个常量的计算过程可以借助计算机来帮我们完成。在JDK的bin目录中,Oracle公司已经为我们准备好一个专门用于分析Class文件字节码的工具:javap,代码清单6-2中列出了使用javap工具的-verbose参数输出的TestClass.class文件字节码内容(此清单中省略了常量池以外的信息)。前面我们曾经提到过,Class文件中还有很多数据项都要引用常量池中的常量,所以代码清单6-2中的内容在后续的讲解过程中还要经常使用到。
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;
从代码清单6-2中可以看出,计算机已经帮我们把整个常量池的21项常量都计算了出来,并且第1、2项常量的计算结果与我们手工计算的结果一致。仔细看一下会发现,其中有一些常量似乎从来没有在代码中出现过,如"I"、“V”、“<init>”、“LineNumberTable”、“LocalVariableTable"等,这些看起来在代码任何一处都没有出现过的常量是哪里来的呢?
这部分自动生成的常量的确没有在Java代码里面直接出现过,但它们会被后面即将讲到的字段表(field_info)、方法表(method_info)、属性表(attribute_info)引用到,它们会用来描述一些不方便使用“固定字节”进行表达的内容。譬如描述方法的返回值是什么?有几个参数?每个参数的类型是什么?因为Java中的“类”是无穷无尽的,无法通过简单的无符号字节来描述一个方法用到了什么类,因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达。这部分内容将在后面进一步阐述。最后,笔者将这14种常量项的结构定义总结为表6-6以供读者参考。
6.3.3 访问标志
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。具体的标志位以及标志的含义见表6-7。
access_flags中一共有16个标志位可以使用,当前只定义了其中8个,没有使用到的标志位要求一律为0。以代码清单6-1中的代码为例,TestClass是一个普通Java类,不是接口、枚举或者注解,被public关键字修饰但没有被声明为final和abstract,并且它使用了JDK 1.2之后的编译器进行编译,因此它的ACC_PUBLIC、ACC_SUPER标志应当为真,而ACC_FINAL、ACC_INTERFACE、ACC_ABSTRACT、ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM这6个标志应当为假,因此它的access_flags的值应为:0x0001|0x0020=0x0021。从图6-5中可以看出,access_flags标志(偏移地址:0x000000EF)的确为0x0021。
6.3.4 类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。图6-6演示了代码清单6-1的代码的类索引查找过程。
对于接口索引集合,入口的第一项——u2类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。代码清单6-1中的代码的类索引、父类索引与接口表索引的内容如图6-7所示。
从偏移地址0x000000F1开始的3个u2类型的值分别为0x0001、0x0003、0x0000,也就是类索引为1,父类索引为3,接口索引集合大小为0,查询前面代码清单6-2中javap命令计算出来的常量池,找出对应的类和父类的常量,结果如代码清单6-3所示。
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;
6.3.5 字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。一个字段可以包括的信息有:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。表6-8中列出了字段表的最终格式。
字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数据类型,其中可以设置的标志位和含义见表6-9。
很明显,在实际情况中,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则所决定的。
跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。现在需要解释一下“简单名称”、“描述符”以及前面出现过多次的“全限定名”这三种特殊字符串的概念。
全限定名和简单名称很好理解,以代码清单6-1中的代码为例,“org/fenixsoft/clazz/TestClass"是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。简单名称是指没有类型和参数修饰的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别是"inc"和"m”。
相对于全限定名和简单名称来说,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见表6-10。
对于数组类型,每一维度将使用一个前置的 [
字符来描述,如一个定义为 java.lang.String[][]
类型的二维数组,将被记录为:[[Ljava/lang/String;
,一个整型数组 int[]
将被记录为 [I
。
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为”()V",方法java.lang.String toString()的描述符为"()Ljava/lang/String;",方法 int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)
的描述符为 ([CII[CIII)I
。
对于代码清单6-1中的TestClass.class文件来说,字段表集合从地址0x000000F8开始,第一个u2类型的数据为容量计数器fields_count,如图6-8所示,其值为0x0001,说明这个类只有一个字段表数据。接下来紧跟着容量计数器的是access_flags标志,值为0x0002,代表private修饰符的ACC_PRIVATE标志位为真(ACC_PRIVATE标志的值为0x0002),其他修饰符为假。代表字段名称的name_index的值为0x0005,从代码清单6-2列出的常量表中可查得第5项常量是一个CONSTANT_Utf8_info类型的字符串,其值为"m",代表字段描述符的descriptor_index的值为0x0006,指向常量池的字符串"I",根据这些信息,我们可以推断出原代码定义的字段为:“private int m;"。
字段表都包含的固定数据项目到descriptor_index为止就结束了,不过在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息,字段都可以在属性表中描述零至多项的额外信息。对于本例中的字段m,它的属性表计数器为0,也就是没有需要额外描述的信息,但是,如果将字段m的声明改为"final static int m=123;",那就可能会存在一项名称为ConstantValue的属性,其值指向常量123。关于attribute_info的其他内容,将在6.3.7节介绍属性表的数据项目时再进一步讲解。
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。
6.3.6 方法表集合
如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项,见表6-11。这些数据项目的含义也非常类似,仅在访问标志和属性表集合的可选项中有所区别。
因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对的,synchronized、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值可参见表6-12。
我们继续以代码清单6-1中的Class文件为例对方法表集合进行分析,如图6-9所示,方法表集合的入口地址为:0x00000101,第一个u2类型的数据(即是计数器容量)的值为0x0002,代表集合中有两个方法(这两个方法为编译器添加的实例构造器<init>和源码中的方法inc())。第一个方法的访问标志值为0x001,也就是只有ACC_PUBLIC标志为真,名称索引值为0x0007,查代码清单6-2的常量池得方法名为"<init>”,描述符索引值为0x0008,对应常量为"()V",属性表计数器attributes_count的值为0x0001就表示此方法的属性表集合有一项属性,属性名称索引为0x0009,对应常量为"Code",说明此属性是方法的字节码描述。
与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器"<clinit>"方法和实例构造器"<init>"方法。
在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。
6.3.7 属性表集合
属性表(attribute_info)在前面的讲解之中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松了一些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文件,《Java虚拟机规范(第2版)》中预定义了9项虚拟机实现应当能识别的属性,而在最新的《Java虚拟机规范(Java SE 7)》版中,预定义属性已经增加到21项,具体内容见表6-13。下文中将对其中一些属性中的关键常用的部分进行讲解。
对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足表6-14中所定义的结构。
Code 属性
Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性,如果方法表有Code属性存在,那么它的结构将如表6-15所示。
attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为"Code",它代表了该属性的属性名称,attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共为6字节,所以属性值的长度固定为整个属性表长度减去6个字节。
max_stack代表了操作数栈(Operand Stacks)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度。
max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用1个Slot,而double和long这两种64位的数据类型则需要两个Slot来存放。方法参数(包括实例方法中的隐藏参数"this")、显式异常处理器的参数(Exception Handler Parameter,就是try-catch语句中catch块所定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存放。另外,并不是在方法中用到了多少个局部变量,就把这些局部变量所占Slot之和作为max_locals的值,原因是局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的Slot可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小。
code_length和code用来存储Java源程序编译后生成的字节码指令。code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。既然叫字节码指令,那么每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及参数应当如何理解。我们知道一个u1数据类型的取值范围为0x00~0xFF,对应十进制的0~255,也就是一共可以表达256条指令,目前,Java虚拟机规范已经定义了其中约200条编码值对应的指令含义,编码与指令之间的对应关系可查阅本书的附录B“虚拟机字节码指令表”。
关于code_length,有一件值得注意的事情,虽然它是一个u4类型的长度值,理论上最大值可以达到2^{32}-1,但是虚拟机规范中明确限制了一个方法不允许超过65535条字节码指令,即它实际只使用了u2的长度,如果超过这个限制,Javac编译器也会拒绝编译。一般来讲,编写Java代码时只要不是刻意去编写一个超长的方法来为难编译器,是不太可能超过这个最大值的限制。但是,某些特殊情况,例如在编译一个很复杂的JSP文件时,某些JSP编译器会把JSP内容和页面输出的信息归并于一个方法之中,就可能因为方法生成字节码超长的原因而导致编译失败。
Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个Class文件中,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。了解Code属性是学习后面关于字节码执行引擎内容的必要基础,能直接阅读字节码也是工作中分析Java代码语义问题的必要工具和基本技能,因此笔者准备了一个比较详细的实例来讲解虚拟机是如何使用这个属性的。
继续以代码清单6-1的TestClass.class文件为例,如图6-10所示,这是上一节分析过的实例构造器"<init>"方法的Code属性。它的操作数栈的最大深度和本地变量表的容量都为0x0001,字节码区域所占空间的长度为0x0005。虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并根据字节码指令表翻译出所对应的字节码指令。翻译"2A B7 00 0A B1"的过程为:
- 读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个Slot中为reference类型的本地变量推送到操作数栈顶。
- 读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的方法符号引用。
- 读入00 0A,这是invokespecial的参数,查常量池得0x000A对应的常量为实例构造器"<init>"方法的符号引用。
- 读入B1,查表得0xB1对应的指令为return,含义是返回此方法,并且返回值为void。这条指令执行后,当前方法结束。
这段字节码虽然很短,但是至少可以看出它的执行过程中的数据交换、方法调用等操作都是基于栈(操作栈)的。我们可以初步猜测:Java虚拟机执行字节码是基于栈的体系结构。但是与一般基于堆栈的零字节指令又不太一样,某些指令(如invokespecial)后面还会带有参数,关于虚拟机字节码执行的讲解是后面两章的重点,我们不妨把这里的疑问放到第8章去解决。
我们再次使用javap命令把此Class文件中的另外一个方法的字节码指令也计算出来,结果如代码清单6-4所示。
//原始Java代码
public class TestClass{
private int m;
public int inc(){
return m+1;
}
}
C:\>javap-verbose TestClass
//常量表部分的输出见代码清单6-1, 因版面原因这里省略掉
{
public org.fenixsoft.clazz.TestClass();
Code:
Stack=1, Locals=1, Args_size=1
0:aload_0
1:invokespecial#10;//Method java/lang/Object."<init>":()V
4:return
LineNumberTable:
line 3:0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lorg/fenixsoft/clazz/TestClass;
public int inc();
Code:
Stack=2, Locals=1, Args_size=1
0:aload_0
1:getfield#18;//Field m:I
4:iconst_1
5:iadd
6:ireturn
LineNumberTable:
line 8:0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lorg/fenixsoft/clazz/TestClass;
}
如果大家注意到javap中输出的"Args_size"的值,可能会有疑问:这个类有两个方法——实例构造器<init>()和inc(),这两个方法很明显都是没有参数的,为什么Args_size会为1?而且无论是在参数列表里还是方法体内,都没有定义任何局部变量,那Locals又为什么会等于1?如果有这样的疑问,大家可能是忽略了一点:在任何实例方法里面,都可以通过"this"关键字访问到此方法所属的对象。这个访问机制对Java程序的编写很重要,而它的实现却非常简单,仅仅是通过Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。这个处理只对实例方法有效,如果代码清单6-1中的inc()方法声明为static,那Args_size就不会等于1而是等于0了。
在字节码指令之后的是这个方法的显式异常处理表(下文简称异常表)集合,异常表对于Code属性来说并不是必须存在的,如代码清单6-4中就没有异常表生成。
异常表的格式如表6-16所示,它包含4个字段,这些字段的含义为:如果当字节码在第start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任意异常情况都需要转向到handler_pc处进行处理。
异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常及finally处理机制。
代码清单6-5是一段演示异常表如何运作的例子,这段代码主要演示了在字节码层面中try-catch-finally是如何实现的。在阅读字节码之前,大家不妨先看看下面的Java源码,想一下这段代码的返回值在出现异常和不出现异常的情况下分别应该是多少?
//Java源码
public int inc(){
int x;
try{
x=1;
return x;
}catch(Exception e){
x=2;
return x;
}finally{
x=3;
}
}
//编译后的ByteCode字节码及异常表
public int inc();
Code:
Stack=1, Locals=5, Args_size=1
0:iconst_1//try块中的x=1
1:istore_1
2:iload_1//保存x到returnValue中, 此时x=1
3:istore 4
5:iconst_3//finaly块中的x=3
6:istore_1
7:iload 4//将returnValue中的值放到栈顶, 准备给ireturn返回
9:ireturn
10:astore_2//给catch中定义的Exception e赋值, 存储在Slot 2中
11:iconst_2//catch块中的x=2
12:istore_1
13:iload_1//保存x到returnValue中, 此时x=2
14:istore 4
16:iconst_3//finaly块中的x=3
17:istore_1
18:iload 4//将returnValue中的值放到栈顶, 准备给ireturn返回
20:ireturn
21:astore_3//如果出现了不属于java.lang.Exception及其子类的异常才会走到这里
22:iconst_3//finaly块中的x=3
23:istore_1
24:aload_3//将异常放置到栈顶, 并抛出
25:athrow
Exception table:
from to target type
0 5 10 Class java/lang/Exception
0 5 21 any
10 16 21 any
编译器为这段Java源码生成了3条异常表记录,对应3条可能出现的代码执行路径。从Java代码的语义上讲,这3条执行路径分别为:
- 如果try语句块中出现属于Exception或其子类的异常,则转到catch语句块处理。
- 如果try语句块中出现不属于Exception或其子类的异常,则转到finally语句块处理。
- 如果catch语句块中出现任何异常,则转到finally语句块处理。
返回到我们上面提出的问题,这段代码的返回值应该是多少?对Java语言熟悉的读者应该很容易说出答案:如果没有出现异常,返回值是1;如果出现了Exception异常,返回值是2;如果出现了Exception以外的异常,方法非正常退出,没有返回值。我们一起来分析一下字节码的执行过程,从字节码的层面上看看为何会有这样的返回结果。
字节码中第0~4行所做的操作就是将整数1赋值给变量x,并且将此时x的值复制一份副本到最后一个本地变量表的Slot中(这个Slot里面的值在ireturn指令执行前将会被重新读到操作栈顶,作为方法返回值使用。为了讲解方便,笔者给这个Slot起了个名字:returnValue)。如果这时没有出现异常,则会继续走到第5~9行,将变量x赋值为3,然后将之前保存在returnValue中的整数1读入到操作栈顶,最后ireturn指令会以int形式返回操作栈顶中的值,方法结束。如果出现了异常,PC寄存器指针转到第10行,第10~20行所做的事情是将2赋值给变量x,然后将变量x此时的值赋给returnValue,最后再将变量x的值改为3。方法返回前同样将returnValue中保留的整数2读到了操作栈顶。从第21行开始的代码,作用是变量x的值赋为3,并将栈顶的异常抛出,方法结束。
尽管大家都知道这段代码出现异常的概率非常小,但并不影响它为我们演示异常表的作用。如果大家到这里仍然对字节码的运作过程比较模糊,其实也不要紧,关于虚拟机执行字节码的过程,本书第8章中将会有更详细的讲解。
Exceptions 属性
这里的Exceptions属性是在方法表中与Code属性平级的一项属性,读者不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常。它的结构见表6-17。
Exceptions属性中的number_of_exceptions项表示方法可能抛出number_of_exceptions种受查异常,每一种受查异常使用一个exception_index_table项表示,exception_index_table是一个指向常量池中CONSTANT_Class_info型常量的索引,代表了该受查异常的类型。
LineNumberTable 属性
LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。LineNumberTable属性的结构见表6-18。
line_number_table是一个数量为line_number_table_length、类型为line_number_info的集合,line_number_info表包括了start_pc和line_number两个u2类型的数据项,前者是字节码行号,后者是Java源码行号。
LocalVariableTable 属性
LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,它也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。LocalVariableTable属性的结构见表6-19。
其中,local_variable_info项目代表了一个栈帧与源码中的局部变量的关联,结构见表6-20。
start_pc和length属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围覆盖的长度,两者结合起来就是这个局部变量在字节码之中的作用域范围。
name_index和descriptor_index都是指向常量池中CONSTANT_Utf8_info型常量的索引,分别代表了局部变量的名称以及这个局部变量的描述符。
index是这个局部变量在栈帧局部变量表中Slot的位置。当这个变量数据类型是64位类型时(double和long),它占用的Slot为index和index+1两个。
顺便提一下,在JDK 1.5引入泛型之后,LocalVariableTable属性增加了一个“姐妹属性”:LocalVariableTypeTable,这个新增的属性结构与LocalVariableTable非常相似,仅仅是把记录的字段描述符的descriptor_index替换成了字段的特征签名(Signature),对于非泛型类型来说,描述符和特征签名能描述的信息是基本一致的,但是泛型引入之后,由于描述符中泛型的参数化类型被擦除掉,描述符就不能准确地描述泛型类型了,因此出现了LocalVariableTypeTable。
SourceFile 属性
SourceFile属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以分别使用Javac的-g:none或-g:source选项来关闭或要求生成这项信息。在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名。这个属性是一个定长的属性,其结构见表6-21。
sourcefile_index数据项是指向常量池中CONSTANT_Utf8_info型常量的索引,常量值是源码文件的文件名。
ConstantValue 属性
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。类似"int x=123"和"static int x=123"这样的变量定义在Java程序中是非常常见的事情,但虚拟机对这两种变量赋值的方式和时刻都有所不同。对于非static类型的变量(也就是实例变量)的赋值是在实例构造器<init>方法中进行的;而对于类变量,则有两种方式可以选择:在类构造器<clinit>方法中或者使用ConstantValue属性。目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量(按照习惯,这里称“常量”更贴切),并且这个变量的数据类型是基本类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在<clinit>方法中进行初始化。
虽然有final关键字才更符合"ConstantValue"的语义,但虚拟机规范中并没有强制要求字段必须设置了ACC_FINAL标志,只要求了有ConstantValue属性的字段必须设置ACC_STATIC标志而已,对final关键字的要求是Javac编译器自己加入的限制。而对ConstantValue的属性值只能限于基本类型和String,不过笔者不认为这是什么限制,因为此属性的属性值只是一个常量池的索引号,由于Class文件格式的常量类型中只有与基本属性和字符串相对应的字面量,所以就算ConstantValue属性想支持别的类型也无能为力。ConstantValue属性的结构见表6-22。
从数据结构中可以看出,ConstantValue属性是一个定长属性,它的attribute_length数据项值必须固定为2。constantvalue_index数据项代表了常量池中一个字面量常量的引用,根据字段类型的不同,字面量可以是CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、CONSTANT_Integer_info、CONSTANT_String_info常量中的一种。
InnerClasses 属性
InnerClasses属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。该属性的结构见表6-23。
数据项number_of_classes代表需要记录多少个内部类信息,每一个内部类的信息都由一个inner_classes_info表进行描述。inner_classes_info表的结构见表6-24。
inner_class_info_index和outer_class_info_index都是指向常量池中CONSTANT_Class_info型常量的索引,分别代表了内部类和宿主类的符号引用。
inner_name_index是指向常量池中CONSTANT_Utf8_info型常量的索引,代表这个内部类的名称,如果是匿名内部类,那么这项值为0。
inner_class_access_flags是内部类的访问标志,类似于类的access_flags,它的取值范围见表6-25。
Deprecated/Synthetic 属性
Deprecated和Synthetic两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。
Deprecated属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用@deprecated注释进行设置。
Synthetic属性代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的,在JDK 1.5之后,标识一个类、字段或者方法是编译器自动产生的,也可以设置它们访问标志中的ACC_SYNTHETIC标志位,其中最典型的例子就是Bridge Method。所有由非用户代码产生的类、方法及字段都应当至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项,唯一的例外是实例构造器"<init>"方法和类构造器"<clinit>"方法。
其中attribute_length数据项的值必须为0x00000000,因为没有任何属性值需要设置。
StackMapTable 属性
StackMapTable属性在JDK 1.6发布后增加到了Class文件规范中,它是一个复杂的变长属性,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用(见7.3.2节),目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。
这个类型检查验证器最初来源于Sheng Liang(听名字似乎是虚拟机团队中的华裔成员)为Java ME CLDC实现的字节码验证器。新的验证器在同样能保证Class文件合法性的前提下,省略了在运行期通过数据流分析去确认字节码的行为逻辑合法性的步骤,而是在编译阶段将一系列的验证类型(Verification Types)直接记录在Class文件之中,通过检查这些验证类型代替了类型推导过程,从而大幅提升了字节码验证的性能。这个验证器在JDK 1.6中首次提供,并在JDK 1.7中强制代替原本基于类型推断的字节码验证器。关于这个验证器的工作原理,《Java虚拟机规范(Java SE 7版)》花费了整整120页的篇幅来讲解描述,并且分析证明新验证方法的严谨性,笔者在此不再赘述。
StackMapTable属性中包含零至多个栈映射帧(Stack Map Frames),每个栈映射帧都显式或隐式地代表了一个字节码偏移量,用于表示该执行到该字节码时局部变量表和操作数栈的验证类型。类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束。StackMapTable属性的结构见表6-27。
《Java虚拟机规范(Java SE 7版)》明确规定:在版本号大于或等于50.0的Class文件中,如果方法的Code属性中没有附带StackMapTable属性,那就意味着它带有一个隐式的StackMap属性。这个StackMap属性的作用等同于number_of_entries值为0的StackMapTable属性。一个方法的Code属性最多只能有一个StackMapTable属性,否则将抛出ClassFormatError异常。
Signature 属性
Signature属性在JDK 1.5发布后增加到了Class文件规范之中,它是一个可选的定长属性,可以出现于类、属性表和方法表结构的属性表中。在JDK 1.5中大幅增强了Java语言的语法,在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为它记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,在字节码(Code属性)中,泛型信息编译(类型变量、参数化类型)之后都通通被擦除掉。使用擦除法的好处是实现简单(主要修改Javac编译器,虚拟机内部只做了很少的改动)、非常容易实现Backport,运行期也能够节省一些类型所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得到泛型信息。Signature属性就是为了弥补这个缺陷而增设的,现在Java的反射API能够获取泛型类型,最终的数据来源也就是这个属性。关于Java泛型、Signature属性和类型擦除,在第10章介绍编译器优化的时候会通过一个具体的例子来讲解。Signature属性的结构见表6-28。
其中signature_index项的值必须是一个对常量池的有效索引。常量池在该索引处的项必须是CONSTANT_Utf8_info结构,表示类签名、方法类型签名或字段类型签名。如果当前的Signature属性是类文件的属性,则这个结构表示类签名,如果当前的Signature属性是方法表的属性,则这个结构表示方法类型签名,如果当前Signature属性是字段表的属性,则这个结构表示字段类型签名。
BootstrapMethods 属性
BootstrapMethods属性在JDK 1.7发布后增加到了Class文件规范之中,它是一个复杂的变长属性,位于类文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符。《Java虚拟机规范(Java SE 7版)》规定,如果某个类文件结构的常量池中曾经出现过CONSTANT_InvokeDynamic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性,另外,即使CONSTANT_InvokeDynamic_info类型的常量在常量池中出现过多次,类文件的属性表中最多也只能有一个BootstrapMethods属性。BootstrapMethods属性与JSR-292中的InvokeDynamic指令和java.lang.Invoke包关系非常密切,要介绍这个属性的作用,必须先弄清楚InovkeDynamic指令的运作原理,笔者将在第8章专门用1节篇幅去介绍它们,在此先暂时略过。
目前的Javac暂时无法生成InvokeDynamic指令和BootstrapMethods属性,必须通过一些非常规的手段才能使用到它们,也许在不久的将来,等JSR-292更加成熟一些,这种状况就会改变。BootstrapMethods属性的结构见表6-29。
其中引用到的bootstrap_method结构见表6-30。
BootstrapMethods属性中,num_bootstrap_methods项的值给出了bootstrap_methods[]数组中的引导方法限定符的数量。而bootstrap_methods[]数组的每个成员包含了一个指向常量池CONSTANT_MethodHandle结构的索引值,它代表了一个引导方法,还包含了这个引导方法静态参数的序列(可能为空)。bootstrap_methods[]数组中的每个成员必须包含以下3项内容。
bootstrap_method_ref:bootstrap_method_ref项的值必须是一个对常量池的有效索引。常量池在该索引处的值必须是一个CONSTANT_MethodHandle_info结构。
num_bootstrap_arguments:num_bootstrap_arguments项的值给出了bootstrap_arguments[]数组成员的数量。
bootstrap_arguments[]:bootstrap_arguments[]数组的每个成员必须是一个对常量池的有效索引。常量池在该索引处必须是下列结构之一:CONSTANT_String_info、CONSTANT_Class_info、CONSTANT_Integer_info、CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、CONSTANT_MethodHandle_info或CONSTANT_MethodType_info。
6.4 字节码指令简介
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构(这两种架构的区别和影响将在第8章中探讨),所以大多数的指令都不包含操作数,只有一个操作码。
字节码指令集是一种具有鲜明特点、优劣势都很突出的指令集架构,由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条;又由于Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机处理那些超过一个字节数据的时候,不得不在运行时从字节中重建出具体数据的结构,如果要将一个16位长度的无符号整数使用两个无符号字节存储起来(将它们命名为byte1和byte2),那它们的值应该是这样的:(byte1<<8)|byte2
。
这种操作在某种程度上会导致解释执行字节码时损失一些性能。但这样做的优势也非常明显,放弃了操作数长度对齐,就意味着可以省略很多填充和间隔符号;用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由Java语言设计之初面向网络、智能家电的技术背景所决定的,并一直沿用至今。
如果不考虑异常处理的话,那么Java虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解,这个执行模型虽然很简单,但依然可以有效地工作:
do{
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置, 从字节码流中取出操作码;
if(字节码存在操作数)从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码流长度>0);
6.4.1 字节码与数据类型
在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在Class文件中它们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的。
由于Java虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码就为指令集的设计带来了很大的压力:如果每一种与数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那指令的数量恐怕就会超出一个字节所能表示的数量范围了。因此,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,换句话说,指令集将会故意被设计成非完全独立的(Java虚拟机规范中把这种特性称为"Not Orthogonal",即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
表6-31列举了Java虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令。如果在表中指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如,load指令有操作int类型的iload,但是没有操作byte类型的同类指令。
注意,从表6-31中可以看出,大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型(Computational Type)。
在本章中,受篇幅所限,无法对字节码指令集中每条指令进行逐一讲解,但阅读字节码作为了解Java虚拟机的基础技能,是一项应当熟练掌握的能力。笔者将字节码操作按用途大致分为9类,按照分类来为读者概略介绍一下这些指令的用法。如果读者需要了解更详细的信息,可以参考阅读笔者翻译的《Java虚拟机规范(Java SE 7版)》的第6章。
6.4.2 加载与存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈(见第2章关于内存区域的介绍)之间来回传输,这类指令包括如下内容。
将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
扩充局部变量表的访问索引的指令:wide。
存储数据的操作数栈和局部变量表主要就是由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如iload_<n>),这些指令助记符实际上是代表了一组指令(例如iload_<n>,它代表了iload_0、iload_1、iload_2和iload_3这几条指令)。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们省略掉了显式的操作数,不需要进行取操作数的动作,实际上操作数就隐含在指令中。除了这点之外,它们的语义与原生的通用指令完全一致(例如iload_0的语义与操作数为0时的iload指令语义完全一致)。这种指令表示方法在本书以及《Java虚拟机规范》中都是通用的。
6.4.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虚拟机的指令集直接支持了在《Java语言规范》中描述的各种对整数及浮点数操作(参见《Java语言规范(第3版)》中的4.2.2节和4.2.4节)的语义。数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能会是一个负数,这种数学上不可能出现的溢出现象,对于程序员来说是很容易理解的,但其实Java虚拟机规范没有明确定义过整型数据溢出的具体运算结果,仅规定了在处理整型数据时,只有除法指令(idiv和ldiv)以及求余指令(irem和lrem)中当出现除数为零时会导致虚拟机抛出ArithmeticException异常,其余任何整型数运算场景都不应该抛出运行时异常。
Java虚拟机规范要求虚拟机实现在处理浮点数时,必须严格遵循IEEE 754规范中所规定的行为和限制。也就是说,Java虚拟机必须完全支持IEEE 754中定义的非正规浮点数值(Denormalized Floating-Point Numbers)和逐级下溢(Gradual Underflow)的运算规则。这些特征将会使某些数值算法处理起来变得相对容易一些。
在把浮点数转换为整数时,Java虚拟机使用IEEE 754标准中的向零舍入模式,这种模式的舍入结果会导致数字被截断,所有小数部分的有效字节都会被丢弃掉。向零舍入模式将在目标数值类型中选择一个最接近但是不大于原值的数字来作为最精确的舍入结果。
另外,Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是Java语言中的异常,请读者勿与IEEE 754规范中的浮点异常互相混淆,IEEE 754的浮点异常是一种运算信号),当一个操作产生溢出时,将会使用有符号的无穷大来表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回NaN。
在对long类型数值进行比较时,虚拟机采用带符号的比较方式,而对浮点数值进行比较时(dcmpg、dcmpl、fcmpg、fcmpl),虚拟机会采用IEEE 754规范所定义的无信号比较(Nonsignaling Comparisons)方式。
6.4.4 类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理本节开篇所提到的字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
Java虚拟机直接支持(即转换时无需显式的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversions,即小范围类型向大范围类型的安全转换):
- int类型到long、float或者double类型。
- long类型到float、double类型。
- float类型到double类型。
相对的,处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单地丢弃除最低位N个字节以外的内容,N是类型T的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。这点很容易理解,因为原来符号位处于数值的最高位,高位被丢弃之后,转换结果的符号就取决于低N个字节的首位了。
在将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,将遵循以下转换规则:
- 如果浮点值是NaN,那转换结果就是int或long类型的0。
- 如果浮点值不是无穷大的话,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v,如果v在目标类型T(int或long)的表示范围之内,那转换结果就是v。
- 否则,将根据v的符号,转换为T所能表示的最大或者最小正数。
从double类型到float类型的窄化转换过程与IEEE 754中定义的一致,通过IEEE 754向最接近数舍入模式舍入得到一个可以使用float类型表示的数字。如果转换结果的绝对值太小而无法使用float来表示的话,将返回float类型的正负零。如果转换结果的绝对值太大而无法使用float来表示的话,将返回float类型的正负无穷大,对于double类型的NaN值将按规定转换为float类型的NaN值。
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
6.4.5 对象创建与访问指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令(在第7章会讲到数组和普通类的类型创建过程是不同的)。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令如下。
- 创建类实例的指令: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.4.6 操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:
- 将操作数栈的栈顶一个或两个元素出栈:pop、pop2。
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
- 将栈最顶端的两个数值互换:swap。
6.4.7 控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改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。
在Java虚拟机中有专门的指令集用来处理int和reference类型的条件分支比较操作,为了可以无须明显标识一个实体值是否null,也有专门的指令用来检测null值。
与前面算术运算时的规则一致,对于boolean类型、byte类型、char类型和short类型的条件分支比较操作,都是使用int类型的比较指令来完成,而对于long类型、float类型和double类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp,见6.4.3节),运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。由于各种类型的比较最终都会转化为int类型的比较操作,int类型比较是否方便完善就显得尤为重要,所以Java虚拟机提供的int类型的条件分支指令是最为丰富和强大的。
6.4.8 方法调用和返回指令
方法调用(分派、执行过程)将在第8章具体讲解,这里仅列举以下5条用于方法调用的指令。
- invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
- invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic指令用于调用类方法(static方法)。
- invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
6.4.9 异常处理指令
在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常情况之外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在前面介绍的整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。
而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成的。
6.4.10 同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持,譬如代码清单6-6中所示的代码。
void onlyMe(Foo f){
synchronized(f){
doSomething();
}
}
编译后,这段代码生成的字节码序列如下:
Method void onlyMe(Foo)
0 aload_1//将对象f入栈
1 dup//复制栈顶元素(即f的引用)
2 astore_2//将栈顶元素存储到局部变量表Slot 2中
3 monitorenter//以栈顶元素(即f)作为锁, 开始同步
4 aload_0//将局部变量Slot 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
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。
从代码清单6-6的字节码序列中可以看到,为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令。
6.5 共有设计和私有实现
Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统及具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看做是程序在各种Java平台实现之间互相安全地交互的手段。
理解公有设计与私有实现之间的分界线是非常有必要的,Java虚拟机实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。拿着Java虚拟机规范一成不变地逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足虚拟机规范的约束下对具体实现做出修改和优化也是完全可行的,并且虚拟机规范中明确鼓励实现者这样做。只要优化后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整的保持,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何处理Class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可。
虚拟机实现者可以使用这种伸缩性来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的可移植性,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么。虚拟机实现的方式主要有以下两种:
- 将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集。
- 将输入的Java虚拟机代码在加载或执行时翻译成宿主机CPU的本地指令集(即JIT代码生成技术)。
精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机应被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的、新的、有趣的解决方案。
6.6 Class 文件结构的发展
Class文件结构自Java虚拟机规范第1版订立以来,已经有十多年的历史。这十多年间,Java技术体系有了翻天覆地的改变,JDK的版本号已经从1.0提升到了1.7。相对于语言、API以及Java技术体系中其他方面的变化,Class文件结构一直处于比较稳定的状态,Class文件的主体结构、字节码指令的语义和数量几乎没有出现过变动,所有对Class文件格式的改进,都集中在向访问标志、属性表这些在设计上就可扩展的数据结构中添加内容。
如果以《Java虚拟机规范(第2版)》为基准进行比较的话,那么在后续Class文件格式的发展过程中,访问标志里新加入了ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM、ACC_BRIDGE、ACC_VARARGS共5个标志。而属性表集合中,在JDK 1.5到JDK 1.7版本之间一共增加了12项新的属性,这些属性大部分用于支持Java中许多新出现的语言特性,如枚举、变长参数、泛型、动态注解等。还有一些是为了支持性能改进和调试信息,譬如JDK 1.6的新类型校验器的StackMapTable属性和对非Java代码调试中用到的SourceDebugExtension属性。
Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。
本章小结
Class文件是Java虚拟机执行引擎的数据入口,也是Java技术体系的基础构成之一。了解Class文件的结构对后面进一步了解虚拟机执行引擎有很重要的意义。
本章详细讲解了Class文件结构中的各个组成部分,以及每个部分的定义、数据结构和使用方法。通过代码清单6-1的Java代码与它的Class文件样例,以实战的方式演示了Class的数据是如何存储和访问的。从第7章开始,我们将以动态的、运行时的角度去看看字节码流在虚拟机执行引擎中是怎样被解释执行的。
4.7 - CH07-类加载
7.1 概述
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译期执行连接过程的语言不同,Java 中类型的加载、连接、初始化过程均在运行时进行。这种类加载机制会为运行时增加一些性能开销,但也给 Java 提供了高度的灵活性:基于这种运行时动态加载和连接的能力,Java 中天生就可以动态扩展语言的特性。
7.2 类加载的时机
类的完整生命周期:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析这三个部分被合称为连接。
- 大致固定的“开始顺序”:加载、验证、准备、初始化、卸载。
- 各个阶段开始后会以交叉混合方式进行,即在一个阶段的执行过程中调用、激活另一个阶段。
- 解析阶段的开始时机不一定,它在某些情况下可以在初始化节点之后进行,这是为了支持 Java 的运行时绑定(或称动态绑定、晚期绑定)。
JVM 规范并没有定义开始加载一个类的时机,因此取决于具体的 JVM 实现。但对于初始化阶段,JVM 规范严格规定了 有且仅有 5 种情况 必须立即对类执行“初始化”(因此初始化的前置阶段必须在之前开始):
- 遇到 new、getstatic、putstatic、invokestatic 这四种字节码指令时,如果没有对类进行初始化,则需要先触发对应类的初始化。
- 使用
java.lang.reflect
中的方法对类进行反射调用。 - 当初始化一个类的时候,发现其父类尚未被初始化。
- 当虚拟机启动时,用户需要制定一个将要执行的主类(包含 main 方法的类)。
- (JDK 7)如果一个
MethodHandle
实例最后的解析结果是REF_getStatic
、REF_putStatic
、REF_invikeStatic
的方法句柄对应类类没有被初始化。
这 5 种场景中的行为成为对一个类进行主动引用。除此之外,所有引用类的行为都不会触发初始化,称为被动引用。
被动引用场景
通过子类引用父类的静态字段
不会导致子类初始化。会引起父类的初始化和子类的加载。
通过数组定义来引用类
不会触发此类的初始化。
public class NotInitialization{
public static void main(String[]args){
SuperClass[]sca=new SuperClass[10];
}
}
这段代码不会触发 SuperClass 类的初始化,但会触发一个名为 [Lorg.fenixsoft.classloading.SuperClass
类的初始化,这是一个由 JVM 自动生成的、直接继承于 java.lang.Object
的子类,创建动作由字节码指令 newarray 触发。
在 JVM 内部,自动生成了一个类来封装数组数据(因此数组操作在 Java 中比在 C/C++ 中安全,后者是直接移动指针),该类值暴露了共有的 length 属性和 clone 方法。
应用类的常量字段
public class ConstClass{
static{
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD="hello world";
}
public class NotInitialization{
public static void main(String[]args){
System.out.println(ConstClass.HELLOWORLD);
}
}
该示例中并不会引起类 ConstClass 的加载。因为在编译阶段通过常量传播优化,已经将此常量的值存储到了类 NotInitialization 常量池中,以后 NotInitialization 对该常量的引用实质上是对自身常量池的引用。即,NotInitialization 的 Class 文件中并没有对类 ConstClass 的符号引用入口,这两个类在编译成 Class 之后便不存在任何关联了。
接口初始化
接口也有初始化过程。在类中一般使用静态块来显示初始化信息,而接口中不能使用静态块,但编译器仍然会为接口生成 <clinit>()
类构造器,用于初始化接口中所定义的成员变量。
接口与类真正有所区别的地方在于上面所述 5 种主动引用情况的第 3 种:当一个类在初始化时,要求其父类全部都已经初始化过了;但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时(如引用父接口中的常量)才会将其初始化。
7.3 类的加载过程
7.3.1 加载
加载阶段,虚拟机要完成的 3 件事情:
- 通过一个类的完全限定名来获取该类的二进制字节流。
- 将二进制字节流表示的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为在方法区中该类的各种信息的访问入口。
在 JVM 规范中并没有具体规定一定要从哪获取、怎样获取二进制字节流,因此具有很大的灵活性。基于这一点有很多有意义的实现:
- 从 ZIP 包中获取,这最终成为了日后 JAR/EAR/WAR 格式的基础。
- 从网络中获取,典型应用是 Applet。
- 运行时计算生成,应用最多的就是动态代理技术。
- 由其他文件生成,典型应用是 JSP。
- 从数据库中获取。
- 等等。
相对于类加载过程的其他阶段,一个非数组类在加载阶段的获取二进制字节流操作是可控性最强的,因为既可以使用系统提供的“引导类加载器”来加载,也可以由用户自定义的类加载器来加载,开发人员可以通过自定义的类加载器来控制字节流的获取方式。
数组类本省不能通过类加载器创建,它是由 JVM 直接创建的。但数组类仍与类加载器有着密切的联系,因为数组类的元素类型最终需要靠类加载器来创建。一个数组类(简称 C)的创建过程遵循以下规则:
- 如果数组的元素类型是引用类型,就递归采用本节中定义的加载过程来加载该元素类型,数组 C 将在加载元素类型的类加载器的名称空间上被标识(一个类必须与其加载器来确定唯一性)。
- 如果数组的元素类型不是引用类型,JVM 将会把数组 C 标记位与引导类加载关联。
- 数组类的可见性与元素类型的可见性一致,如果元素类型不是引用类型,则数组类的可见性为 public。
加载阶段与连接阶段是交叉进行的(如一部分字节码文件格式校验动作),加载阶段尚未完成,连接阶段可能已经开始,但这些夹杂在加载阶段的动作仍然属于连接阶段,这两个阶段的开始时间依然保持着固定的先后顺序。
7.3.2 验证
连接节点的第一步,为了确保 Class 文件的字节码中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。
Class 文件并不一定由 Java 源码编译而来。在字节码语言层面上,Java 代码无法做到的(比较危险的)事情都是可以实现的。虚拟机如果不检查输入的字节流而对其完全信任的话,很有可能因为载入了有害的字节流而导致系统崩溃。
验证阶段是非常重要的,该阶段是否严谨,决定了 JVM 是否能够承受恶意代码的攻击。从执行性能的角度来看,验证阶段的工作量在类加载子系统中占有相当大一部分。
验证阶段主要可以分为以下 4 个检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
详细细节可以参考虚拟机规范。
7.3.3 准备
正式为类变量(静态变量)分配内存设置初始值(零值),这些变量所使用的内存将被分配在方法区。
假设一个类变量的定义为 public static int value=123;
,那么该字段在准备阶段之后的值仍然为 int 类型的零值,即 0。因为在该阶段尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令会在程序被编译后放在类构造器的 <clinit>()
方法中,并在初始化节点执行。
一种特殊情况:如果类字段的字段属性表存在 ConstantValue 属性,那么在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值。
假设将上面的定义修改为 public static final int value=123;
,这是 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段 JVM 就会根据 ConstantValue 的设置将 value 赋值为 123。
基本数据类型的零值:
7.3.4 解析
将常量池中的符号引用替换为直接引用的过程。
符号引用:以一组符号来描述所引用的目标,符合可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
JVM 规范中并未规定解析阶段发生的具体时间,只要求在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic 这 16 个用于操作符号引用的字节码指令之前,先对其使用的符号引用进行解析。所有虚拟机实现可以根据需要来判断是要在类加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用时再去解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点,这 7 类符号进行,分别对应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 这 7 种常量类型。
这里介绍前 4 种引用的解析过程,后 3 种与动态语言的支持相关,会在第 8 章介绍完 invokedynamic 指令的语义之后再做介绍。
1. 类或接口
假设当前类为 D,如果要把一个未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,虚拟机要完成的解析过程包含以下 3 个步骤:
- 如果 C 不是一个数组类型,虚拟机将会把代表 N 的完全限定名传递给 D 的类加载器来加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,如该类的父类或父接口。一旦该过程出现任何异常,解析过程宣告失败。
- 如果 C 是一个数组类型,并且数组的元素类型为对象,即 N 的描述符会以类似
[Ljava/lang/Integer
的形式,那将会按照第一步的规则首先加载素组元素的类型。完成后由虚拟机生成一个代表次数组维度和元素的数组对象。 - 如果上述步骤没有出现任何异常,那么 C 在虚拟机中实际已经称为了一个有效的类或接口了,但在解析完成之前要进行符号引用验证,以确认 D 是否具备对 C 的访问权限。如果不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。
2. 字段解析
首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果该过程中出现异常将会导致本解析过程失败;如果解析成功完成,那么将该字段所属的类或接口用 C 表示,虚拟机规范中要求按照如下步骤对 C 进行后续字段的搜索:
- 如果 C 本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
- 否则,如果在 C 中实现了接口,将会安装继承关系从下往上递归搜索各个接口及父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
- 否则,如果 C 不是
java.lang.Object
的话,将会安装继承关系递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。 - 否则,查找是被,抛出
java.lang.NoSuchFieldError
异常。
如果查找过程成功返回了引用,将会对该字段进行权限验证,如果发现不具备对该字段的访问权限,将抛出 java.lang.IllegalAccessError
异常。
在实际应用中,虚拟机的编译器实现可能会比上述规范更加严格,如果一个字段同时出现在 C 的接口和父类中,或者同时出现在自己或父类的多个实现接口中,那么编译器可能会直接拒绝编译。
3. 类方法解析
类方法解析的第一个步骤与字段解析一样,也需要解析出类方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用 C 表示该类,接下来虚拟机会按照如下步骤进行后续的类方法搜索:
- 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法中发现 class_index 中索引的 C 是个接口,那就直接抛出
java.lang.IncompatibleClassChangeError
异常。 - 如果通过了第 1 步,在类 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
类为止(包括该类),查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,结束查找。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
由于接口中所有的方法默认都是 public 访问权限,因此不存在访问权限问题,即也不会抛出 java.lang.IllegalAccessError
异常。
7.3.5 初始化
这是类加载过程的最后一步。前面已介绍的过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才开始真正执行类中定义的 Java 程序字节码。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,收集顺序取决于语句在代码源文件中出现的顺序,静态语句块只能访问到定义在静态语句块之前的变量,但可以为定义在其后的变量赋值而不能访问。
<clinit>()
方法与类的构造函数(类实例构造器) <init>()
方法不同,它不需要显式的调用父类构造器,虚拟机会保证在子类的 <clinit>()
方法执行之前,父类的 <clinit>()
方法已经执行完毕。因此在虚拟机中第一个被执行的 <clinit>()
方法一定属于 java.lang.Object
。
如果一个类中没有静态语句块会对变量的赋值操作,那么编译器就不会为其生成该方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()
方法。但与类不同的是,执行接口的 <clinit>()
方法不需要首先执行父接口的 <clinit>()
方法。因为当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不会执行父接口的 <clinit>()
方法。
虚拟机会保证一个类的 <clinit>()
方法在多线程环境中能够被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行该类的 <clinit>()
方法,其他线程都有阻塞等待,直到活动线程执行 <clinit>()
方法完毕。如果在一个类的 <clinit>()
方法中有耗时很长的操作,就可能会造成多个进程阻塞。
7.4 类加载器
虚拟机设计团队把类加载阶段中:“通过一个类的完全限定名来获取描述此类的二进制字节流”,这样一个动作放到了 JVM 外部来实现,以便让应用程序自己决定如何获取需要的类。实现这个动作的代码模块被称为“类加载器”。
7.4.1 类与类加载器
在一个 JVM 实例内,对于任意一个类,都需要由加载它的类加载器和这个类本身来共同确立其唯一性,每个类加载器都有一个独立的类名称空间。
两个相等的类,意味着由同一个虚拟机加载。相等性包括 Class 对象的 equals 方法、isAssignableFrom 方法、isInstance 方法返回的结果,也包括使用 isinstanceof 关键字对对象所属的类进行关系判定等情况。
7.4.2 双亲委派模型
从 JVM 的角度来看,只有两种不同的类加载器:一种是启动类加载器,由 C++ 语言实现,是 JVM 的一部分;另一种就是所有其他的加载器,均由 Java 语言实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
。
从应用开发的角度看,绝大部分的 Java 程序都会用到以下 3 种类加载器:
启动类加载器:负责将存放在 <JAVA_HOME>/lib
目录中、或者被 -Xbootclasspath
参数指定的路径中的、并且是虚拟机识别的(以文件名识别,如 rt.jar
)类库加载到虚拟机内存中。该加载器无法被 Java 程序直接引用,如果在编写自定义加载器时需要将加载请求委派给引导类加载器,可以直接使用 null 作为自定义加载器的父加载器。
扩展类加载器:由 sun.misc.Launcher$ExtClassLoader
实现,负责加载 <JAVA_HOME>/lib/ext
目录中的、或者被 java.ext.dirs
系统变量指定的路径中的所有类库,可以被开发者直接使用。
应用类加载器:由 sun.misc.Launcher$AppClassloader
实现。是 ClassLoader.getSystemClassLoader
的返回值,一般也称为系统类加载器。负责加载用户类路径(ClassPath)中所有的类库,可以被开发者直接使用。如果应用中没有自定义实现任何加载器,一般情况下就是程序中默认的加载器。
上图展示了这几种加载器之间的关系,称为类加载器的“双亲委派模型”。该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有字节的父类加载器。这里所指的类加载器之间的父子关系不使用类继承形式来实现,而是使用组合关系来将加载请求为派给父类加载器。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,他首先不会自己去尝试执行加载,而是把该请求委派给父加载器去完成加载,每一个层次的加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成该加载请求时(在搜索范围内无法找到需要的类),子加载器才会尝试自己去加载。
这种模型的好处就是,Java 类随着其类加载器一起具备了一种优先级层次关系。比如 Objcet 类,它被存放在 rt.jar
中,无论哪个加载器要加载该类,最终都是委派给处于模型最顶层的启动类加载器进行加载,因此 Object 类在程序的各个类加载器中都是同一个类。如果没有使用双亲委派模型,而是由各个类加载器自己去加载的话,如果用户字节编写了一个名为 java.lang.Object
的类,Java 类型体系中的最基础行为就无法得到保证。
7.4.3 破坏双亲委派模型
双亲委派模型并非一个强制性的约束模型,而 Java 设计者推荐给开发者的类加载器实现方式。
下面是种对这种模型的“破坏(创新)”形式。
1. 历史遗留
第一次“被破坏”其实发生在双亲委派模型出现之前,即 JDK 1.2 发布之前。由于双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader
在早在 JDK 1.0 时代就已经存在,面对已经存在的用户自定义加载器实现,Java 设计者在引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader
添加了一个新的 protected 方法 findClass。在此之前,用户继承 ClassLoader 时唯一的目的就是重新 loadClass 方法,因为虚拟机在进行类加载时会调用加载器的私有方法 loadClassInternal,而该方法的唯一逻辑就是调用 loadClass 方法。
在 JDK 1.2 之后已经不提倡覆写 loadClass 方法,而应当把自己的类加载逻辑编写在 findClass 方法中,在 loadClass 方法的逻辑里如果父类记载失败,则会调用自己的 findClass 方法来完成加载,这样就可以保证新编写的类加载器都符合双亲委派模型。
2. 模型缺陷
双亲委派模型很好的解决了各个加载器对基础类的统一问题(越基础的类越由上层的加载器完成加载),基础类之所以被称为“基础”,是因为它们总是作为被用户代码调用的 API。但有时候基础类也需要调用用户代码。
如 JNDI 服务,它的代码由启动类加载器完成加载(rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序 ClassPath 下的 JNDI 接口提供者(SPI)代码,但启动类加载器并不认识这些代码。
为了解决该问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文加载器。该加载器可以通过 Thread 类的 setContextClassLoader 方法进行设置,如果线程创建时还未设置,它将会从父线程中继承一个加载器,如果在应用程序的全局范围内都没有设置过,那么就使用应用程序类加载器。
JDNI 使用该加载器来加载所需的 SPI 代码,也就是父类加载器请求子类加载器来完成对类的加载操作,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,也违背了双亲委派模型的一般性原则。Java 中的所有 SPI 的加载动作基本都采用这种方式。
3. 动态性
这里所说的动态性指的是:代码热替换、模块热部署等。OSGI 已经成为了业界“事实上”的 Java 模块化标准,而 OSGI 实现模块化热部署的关键就是它自已定义的类加载器机制。每个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起替换掉以实现代码的热替换。
在 OSGI 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGI 将按照下面的顺序来搜索类:
- 将以
java.*
开头的类委派给父类加载器。 - 否则,将委派列表名单内的类委派给父类加载器。
- 否则,将 import 列表中的类委派给 Export 这个类所属的 Bundle 的类加载器。
- 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器。
- 否则,查找类师傅在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器。
- 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器。
- 否则,类查找失败。
以上查找顺序中只有开头两点符合双亲委派模型,其余的类查找都在平级的类加载器中机进行。
7.5 本章小结
本章介绍了类加载过程的“加载”、“验证”、“准备”、“解析”和“初始化”5个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
4.8 - CH08-字节码执行引擎
8.1 概述
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果,本章将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。每个方法调用从开始到执行完成的过程,都对应着一个栈帧在虚拟机站里面从入栈到出栈的过程。
每个栈帧中都包含了局部变量表、操作数栈、动态连接、方法返回地址等信息。栈帧中需要多大的局部变量表、多深的操作数栈会在 编译期确定,并且写入到方法表的 Code 属性中,因此一个栈帧需要分配多少内存。不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称作 当前栈帧,与该栈帧关联的方法称为 当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示。
8.2.1 局部变量表
局部变量表是一组 变量值存储空间,用于存放 方法参数 和方法内部定义的 局部变量。在 Java 程序被编译为 Class 文件时,就在方法的 Code 属性中的 max_locals 数据项中确定了该方法需要分配的局部变量表的最大容量。
局部变量表以槽(slot)为最小单位,虚拟机规范中没有明确指定一个 Slot 应该占用多大的内存空间,但是很有导向性的说到每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、reference、returnAddress 类型的数据,这 8 种数据类型,都可以用 32 位或更小的物理内存来存放,允许 Slot 的长度随着处理器、操作系统或虚拟机的不同而发生变化。只要保证即使在 64 位虚拟机中使用 64 位的物理内存空间来实现一个 slot,虚拟机仍要使用对齐和补白的手段让 slot 在外观上看起来与 32 位的虚拟机中一致。
上面提到的前 6 种数据类型无需多说,而第 7 种 reference 类型表示对一个对象实例的引用,虚拟机规范没有明确说明其长度和结构。但一般来说,虚拟机实现至少都应当能通过该引用做当两点:从此引用中直接或间接的查找到对象在 Java 堆中的数据存放的起始地址索引;此引用中直接或间接的查找到对象所属数据类型在方法区中存储的类型信息,否则无法实现 Java 语言规范中定义语法约束。
第 8 种 returnAddress 类型目前已经很少见了,它为字节码指令 jsr、jsr_w、ret 服务,指向了一条字节码指令的地址,远古 JVM 曾使用这几条指令来实现处理,现在已经由异常表代替。
对于 64 位的数据类型,虚拟机会以高位补齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的(reference 类型则可能是 32/64 位) 64 位数据类型目前只有 long、double。值得一提的是,这里把 long、double 分割存储的方法与“非原子性协定”中对这两种数据类型分割为两次读写的方式类似。不过,由于局部变量表建立在线程的堆栈上,是线程私有数据,无论读写两个连续的 slot 是否为原子操作,都不会引起数据安全性问题。
虚拟机通过索引定位的方式使用局部变量表,索引值从 0 开始只局部变量表的最大 slot 数量。如果访问的是 32 位数据类型的变量,则索引 N 就表示使用第 N 个 Slot;如果是 64 位的数据类型变量,则会同时使用 N 和 N+1 来表示两个 Slot。对于两个相邻的用于存放一个 64 位数据的 slot,不允许采用任何单独的方式来单独访问其中一个,否则将在类加载的校验阶段抛出异常。
在方法执行时,虚拟机使用局部变量表来完成参数值到参数变量列表的传递过程。如果执行的是实例方法,局部变量表中第 0 位索引的 slot 默认用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 来方法该隐含参数。其余参数则按照参数列表顺序排列,占用从索引值 1 开始的局部变量 slot。参数列表分配完成之后,再根据方法体内部定义的变量顺序和作用域来分配其余的 slot。
为了尽可能的节省栈空间,局部变量表中的 slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那么该变量对应的 slot 就可以交给其他变量使用。不过,这样的设计出了节省栈帧空间之外,还会伴随着一些额外的副作用,如在某些情况下,slot 的复用会直接影响到系统的垃圾收集行为。
slot 复用如何影响 GC?
“如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那么该变量对应的 slot 就可以交给其他变量使用。” 但是,如果并没有其他变量复用该 slot,该 slot 中原有的值会继续保持,所以作为 GC-Roots 一部分的局部变量表仍然保持着对该 slot 中的变量值的关联。 这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为 null 值这会变得有意义。 但赋值为 null 的操作可能被 JIT 优化掉,因此不能依赖这种操作。
局部变量不像前面介绍的类变量那样存在“准备阶段”。通过第 7 章的讲解,我们已经知道类(静态)变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为 Java 中任何情况下都存在诸如整型变量默认为 0,布尔型变量默认为 false 等这样的默认值。编译器会报告这种错误。
8.2.2 操作数栈
即操作栈,是一个 LIFO 栈。与局部变量表一样,其最大深度也在编译期写入到 Code 属性的 max_stacks 数据项中。操作数栈的每个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的占容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时刻,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值。
当一个方法刚刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈、出栈操作。比如,在做算术运算的时候是通过操作数栈完成的,又或者在调用其他方法的时候是通过操作数栈完成参数传递的。
举个例子,整数加法的字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行 iadd 指令时,会将栈顶的两个 int 值出栈并相加,然后再将相加的结果入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验的数据流分析中还要再次验证这一点。
再以上面 iadd 指令为例,该指令用于整型加法,它在执行时,最接近栈顶的两个元素的数据类型必须都是 int 型,不能出现一个 long 和一个 float 使用 iadd 命令相加的情况。
另外,在概念模型中,两个栈顶作为虚拟机栈的元素,是完全互相独立的。但在大多虚拟机的实现中会做一些优化处理,使两个栈帧出现一部分重叠。让下面栈帧的部分操作数与上面栈帧的局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递,重叠的过程如图 8-2 所示。
Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈。
8.2.3 动态链接
每个栈帧都包含一个指向运行时常量池中该栈所属方法的引用,持有该引用是为了支持方法调用过程中的动态连接。通过第六章的讲解,我们知道 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候被转换为直接引用,这时候的转换被称为静态解析。另外一部分将在每次运行期间转换为直接引用,这部分称为动态连接。
8.2.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出该方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(即调用当前方法的方法),是否有返回值和返回值的类型将根据具体的方法返回指令来确定,这种退出方式称为正常完成出口。
另一种退出方式是在方法执行过程中遇到了异常,并且该异常没有在方法体内得到处理,无论是 JVM 内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。这种退出方式不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它上层的方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器值可以作为返回地址,栈帧中可能会保存该计数器值。而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
8.2.5 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧中,如调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再描述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
8.3 方法调用
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即要调用哪个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件中存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。该特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
8.3.1 解析
所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的版本,并且该方法的调用版本在运行期是不可改变的。即,调用目标在程序代码编写完成、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
在 Java 语言中符合“编译期可知、运行期不可变”这种要求的方法,主要包括静态方法和私有方法两大类,前者直接与类相关联,后者不可被外部访问,这两种方法各自的特点都决定了它们都不可能通过继承或别的方式被重写为其他版本,因此它们都适合在类加载阶段完成解析。
与之对应的是,在 JVM 中提供了 5 个方法调用字节码指令,分别是:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器
<init>
方法、私有方法、父类方法。 - invokevirtual:调用所有的虚方法。
- invikeinterface:调用接口方法,会在运行时确定一个实现该接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的 4 条调用指令,分派逻辑是固化在 JVM 内部的,而 invokedynamic 指令的分派逻辑是由用户指定的引导方法决定的。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合该条件的只有静态方法、私有方法、实例构造器、父类方法 4 种,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法则被称为虚方法(final 方法除外)。
Java 中的非虚方法除了使用 invokestatic 和 invokespecial 调用的方法之外还有一种,即被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖、不会存在其他版本,所以也无需对方法接收者进行多态选择,又或者说多态选择的结果一定是唯一的。在 Java 语言规范中明确说明了 fianl 方法是一种非虚方法。
解析调用移动是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转换为可确定的直接引用,不会延迟到运行期再进行。而分派(dispatch)调用则可能是静态或动态的,根据分派所依据的宗量数有但分派和多分派。这两类分派方式来那个量组合就构成了静态但分派、静态多分派、动态单分派、动态多分派这 4 种分派组合的情况。
8.3.2 分派
Java 是一种 OO 语言,因为它具备 OO 的 3 个基本特征:封装、继承、多态。这里讲解的分派调用过程将会揭示多态特性的一些最基本体现,如“重载”和“重写”在 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();
StaticDispatch sr=new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
// 执行结果:
// hello,guy!
// hello,guy!
可以发现两次方法调用均选择了以 Human 的参数类型的重载方法。要理解该问题,我们先按如下定义来理解两个重要的概念。
Human man = new Man();
我们将上面代码中的 “Human” 称为变量的静态类型,或者叫做外观类型,后面的 “Man” 则称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
再回到最初的例子中。main 中的两次 sayHello 方法调用,在方法接收者已经确定是对象 sr 的前提下,使用哪个重载版本,就完全取决于传入参数的数量的类型。代码中刻意的定义了两个静态类型相同但实际类型不同的变量,但 虚拟机(更准确的说是编译器)在重载时是通过参数的静态类型而非实际类型作为判断依据的。并且静态类型是编译期可知的,因此在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本的方法,因此选择了 sayHello(Human)
作为调用目标,并把该方法的符号引用写入到了 main 方法里的两条 invokevirtual 指令的参数中。
所有依赖静态类型进行方法版本定位的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译期,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个“更加合适的”版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则来理解和推断。下面的例子演示了“更合适的版本”是什么。
package org.fenixsoft.polymorphic;
public class Overload{
public static void sayHello(Object arg){
System.out.println("hello Object");
}
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(char arg){
System.out.println("hello char");
}
public static void sayHello(char……arg){
System.out.println("hello char……");
}
public static void sayHello(Serializable arg){
System.out.println("hello Serializable");
}
public static void main(String[]args){
sayHello('a');
}
}
// 输出
// hello char
这很好理解,'a'
是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉对应 char 参数类型的方法 sayHello(char arg)
,则输出会不变为 hello int
。
这时发生了一次自动类型转换,'a'
除了可以代表一个字符串,还可以代表数字 97(a 的 Unicode 数值为十进制的 97)。因此参数类型为 int 的重载也是合适的。如果继续去掉参数类型为 int 的方法 sayHello(int arg)
,输出则变为 hello long
。
这时发生了两次自动类型转换,'a'
转型为整数 97 之后,进一步转型为长整型 97L,匹配了参数类型为 long 的重载。例子中没有提供其他类型如 float、double 等重载方法,不过实际上自动转型还能继续发生多次,按照 char、int、long、float、double 的顺序依次转型进行匹配。但不会匹配到 byte 和 short 类型的重载,因为 char 到 byte 或 short 的转型是不安全的。继续注释掉参数类型为 long 的方法 sayHello(long arg)
,输出则变为 hello character
。
这时发生了一次自动装箱操作,'a'
被包装为它的封装类型 java.lang.Character
,所以匹配到了参数类型为 Character 的重载,继续注释掉参数类型为 Character 类型的方法 sayHello(Character arg)
,输出则变为 hello Seralizable
。
这个输出可能会让人感到难以理解,一个字符或数字与序列化有什么关系呢?出现这种结果的原因是 java.lang.Serializable
是 java.lang.Character
的一个接口,当它自动装箱之后发现还是找不到装箱类,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转型。char 可以转换为 int,但是 Character 是绝对不会转型为 Integer 的,它只能安全的转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable<Character>
,如果同时还出现了两个参数类型分别为 Serializable
和 Comparable<Character>
的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,因此会提示模糊类型,拒绝编译。程序必须在调用时显式的指定字面量的静态类型,如:sayHello((Comparable<Character>) 'a')
,才能编译通过。如果继续将参数类型为 Comparable<Character>
的方法去掉,结果变为:hello Object
。
这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接靠上层的优先级越低。即使方法调用传入的参数值为 null,该规则仍然适用。当把参数类型为 Object 的方法去掉之后,输出将变为 hello char...
。
7 个重载方法已经被移除的只剩下一个类,可变变长参数的重载优先级是最低的,这时候 'a'
被当做一个数组元素。这里使用的是 char 类型的变长参数,读者在验证时还可以选择 int、Character、Object 类型等的变长参数重载来重新演示上面的例子。但要注意的是,有一些在单个参数中能成立的自动转型,如 char 到 int,在变长参数中是不成立的。
上面的例子演示了编译期间选择静态分派目标的过程,该过程也是 Java 语言实现方法重载的本质。比较容易混淆的是,解析与分派的关系并非二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。如前所述,静态方法会在类加载期间进行解析,而静态方法也可以拥有重载版本,选择重载版本的过程就是通过静态分派完成的。
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();
woman.sayHello();
man=new Woman();
man.sayHello();
}
}
// 结果
// man say hello
// woman say hello
// woman say hello
这里,虚拟机不再根据静态类型来选择要调用的方法,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,JVM 是如何根据实际类型来分派方法执行版本呢?这里使用 javap 命令输出这段代码的字节码,尝试从中寻找答案,输出结果如下:
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0:new#16;//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $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/Dynamic-
Dispatch $Woman
11:dup
12:invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispa
tch $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/Dynamic-
Dispatch $Woman
27:dup
28:invokespecial#21;//Method org/fenixsoft/polymorphic/Dynam
icDispatch $Woman."<init>":()V
31:astore_1
32:aload_1
33:invokevirtual#22;//Method org/fenixsoft/polymorphic/
DynamicDispatch $Human.sayHello:()V
36:return
0~15 行的代码是准备动作,作用是建立 man 和 women 的内存空间、调用 Man 和 Women 类型的实例构造器,将这两个实例的应用存放在 1、2 两个局部变量表的 slot 之中,该动作对应代码中的两行代码:
Human man=new Man();
Human woman=new Woman();
接下来 16~21 行是关键部分,16、20 两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的 sayHello 方法的持有者,称为接收者;17、21 是方法调用指令,这两条调用指令但从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第 22 项的常量,注释还显示了该常量是 Human.sayHello 的符号引用)完全一样,但是这两行指令最终执行的目标方法是不同的。原因就需要从 invokevirtual 指令的多态查找过程说起,invokevirtual 指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 C。
- 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果校验通过则返回该方法的直接引用,查找过程结束;如果不通过,则返回非法访问异常。
- 否则,按照继承关系从下往上依次对 C 的各个父类机执行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出抽象方法异常。
由于 invokevirtual 指令指定的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,该过程就是 Java 中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3. 但分派与多分派
方法的接收者与方法的参数统称为方法的宗量,该定义最早来自《Java 与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多个宗量对目标方法进行选择。
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
在 main 函数中调用了两次 hardChoice 方法,这两次方法调用的选择结果在程序输出中已经显示的很清晰了。
在编译器编译期间的静态分派过程中,选择方法主要依据两点:一是静态类型是 Father 还是 Son,二是方法参数是 QQ 还是 360。这次选择的最终产物是产生了两条 invokevirtual 指令,两条指令的参数分别是常量池中指向 Father.hardChoice(360)
和 Father.hardChoice(QQ)
方法的符号引用。因为根据两个宗量进行选择,所以 Java 的静态分派属于多分派。
再看看运行时阶段虚拟机的选择,也就是动态分派过程。在执行 son.hardChoice(QQ)
这行代码对应的 invokevirtual 指令时,由于编译器已经决定目标方法的签名必须是 hardChoice(QQ)
,虚拟机此时不关心传递过来的参数是 QQ 的哪个子类实现,因为这时参数是静态类型、实际类型都对方法的选择不构成影响,唯一可以印象虚拟机选择的因素只有此方法的接收者的实际类型是 Father 还是 Son。意味只有一个宗量作为选择依据,所以 Java 的动态分派属于单分派。
根据上述论证的结果,我们可以总结出:现在的 Java(1.8之前) 是一种静态多分派、动态单分派的语言。
按照目前 Java 语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如 JavaScript)执行引擎的方式来满足动态性的需求。但是 JVM 层面上则不同,在 JDK 1.7 中实现的 JSR-292 里面就已经开始提供对动态语言的支持了,JDK 1.7 中新增的 invokedynamic 指令也成为了最复杂的一条方法调用字节码指令。
4. 虚拟机动态分派实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个续方法表(vtable,于此对应,invokeinterface 执行时也会用到接口方法表,即 itable),使用虚方法表索引来代替元数据查找以提高性能。
下图是虚方法表的数据结构示例:
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了父类的方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。在上图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承而来的方法都指向了 Object 的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完成。
上文中说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存和基于“类型继承关系分析”技术的守护内联两种非稳定的“激进优化”手段来获得更高的性能,关于这两种优化技术的原理和运作过程,可参考本书第 11 章。
8.3.3 动态类型语言支持
从 Sun 公司的第一款 JVM 问世至 JDK7 来临之前的十余年时间里,JVM 的字节码指令集的数量一直都没有发生过变化。随着 JDK7 的发布,字节码指令集终于迎来了第一位新成员——invokedynamic 指令。这条新增加的指令是 JDK7 实现“动态类型语言”支持而进行的改进之一,也是为 JDK8 可以顺利实现 Lambda 表达式做出技术准备。
1. 动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而非编译期。相对的,在编译期进行类型检查的过程的语言就是常说的静态类型语言。
静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题在编码时就能发现,利于稳定性以及代码达到较大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,有动态类型语言来实现可能会更加清晰简洁,这也就意味着开发效率的提升。
2. JDK7 与动态类型
JVM 层面对动态类型语言的支持一直有所欠缺,主要变现在方法调用方面:JDK7 之前的字节码指令集中,4 条方法调用指令的第一个参数都是被调用方法的符号引用。前面已经说过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在 JVM 上实现的动态类型语言就不得不使用其他方式(如在编译期留一个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必会增加动态类型语言的实现难度,也可能会带来额外的性能或内存开销。尽管可以利用一些方法(如 Call site Caching)让这些开销尽可能变小,但这种底层问题终归是应当在虚拟机层次上来解决才最合适,因此在 JVM 层面上提供动态类型的直接支持就成为了 Java 平台的发展趋势之一,这就是 JDK7 中 JSR-292 invokedynamic 指令以及 java.lang.invoke 包出现的技术背景。
3. java.lang.invike 包
JDK7 实现了 JSR-292,新加入的 java.lang.invoke 包就是 JSR-292 的一个重要组成部分,该包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式之外,提供一种新的动态确定目标方法的机制,称为 MethodHandle。
这类似于 C/C++ 中的函数指针,或者 C# 中的 Delegate 类。比如我们要实现一个带谓词的排序函数,在 C/C++ 中的做法是把谓词定义为函数,用函数指针把谓词传递给排序方法:
void sort(int list[], const int size,int(*compare)(int,int))
Java 无法把方法作为一个参数进行传递。普遍的做法是设计一个带有 compare 方法的 Comparator 接口,以实现了这个接口的对象作为参数,如 Colleciton.sort 就是这样定义的:
void sort(List list,Comparator c)
在拥有 MethodHandle 之后,Java 也可以拥有类似函数指针或委托的方法别名工具了。但在看完 MethodHandle 的用法之后大家可能会有疑惑,相同的事情,反射不是已经早就实现了吗?确实,仅站在 Java 语言的角度来看,MethodHandle 的使用方法和效果与 Reflection 有着众多相似之处,不过,它们还是有以下区别:
从本质上讲,Reflection 和 MethodHandle 都是在模拟方法调用,但 Refection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。在 MethodHandle.lookup 中的 3 个方法——findStatic、findVirtual、findSpecial 正是为了对应于 invokestatic、invokevirtual/invokeinterface、invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API 是不需要关心的。
Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象包含的信息多。前者是在 Java 一端的全面镜像,包含了方法签名、描述符以及方法属性表中各种属性的 Java 端表示方式,还包含执行权限等运行时信息。而后者仅仅包含与执行该方法相关的信息。即,Reflection 属于重量级 API,而 MethodHandle 属于轻量级。
由于 MethodHandle 是对字节码指令指令调用的模拟,所以理论上虚拟机在这方面做得各种优化(如方法内联),在 MethodHandle 上也应当可以采用类似的思路来支持(但目前还不完善)。而通过反射调用则行不通。
MethodHandle 与 Reflection 除了上面列举的区别外,最关键的一点还在于去掉前面讨论事假的前提“仅站在 Java 语言的角度来看”:Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成客服务于所有 JVM 之上的语言,其中也包含 Java。
4. invokedynamic 指令
在某种程度上,invokedynamic 指令与 MethodHandle 机制的作用一样,都是为了解决原有 4 条 invoke 指令方法分派规则规划在虚拟机中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体的用户代码中,让用户(或其他语言的设计者)拥有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层 Java 代码和 API 来实现;另一个用字节码和 Class 中的属性、常量来完成。
每一处含有 invokedynamic 指令的位置都称作“动态调用点”,该指令的第一个参数不再是代表方法符号引用的 CONSTANT_Methodref_info 常量,而是变为 JKD7 新加入的 CONSTANT_InvokeDynamic_info 常量,从这个新常量中可以得到 3 项信息:引导方法(存放在新增的 BootstrapMethods 属性中)、方法类型、方法名称。引导方法有固定的参数,且返回值是 java.lang.invoke.CallSite 对象,该对象代表要真正执行的目标方法调用。根据 CONSTANT_InvokeDynamic_info 中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个 CallSite 对象,最终滴啊用要执行的目标方法。
前面讲过,由于 invokedynamic 指令面向的使用者并非只有 Java 语言,而是其他 JVM 上的动态语言,因此紧靠 Java 语言的编译器 javac 没有办法生成带有 invokedynamic 指定的字节码,所以需要使用 Java 语言来演示 invokedynamic 指令只能用一些变通办法。John Rose 编写了一个把程序的字节码转换为使用 invokedynamic 指令的简单工具 INDY 来完成这件事情,我们我们可以使用该工具来生成最终需要的字节码。
5. 掌控方法分派规则
invokedynamic 指令与前面 4 条 invoke 指令的最大差别就是它的分派逻辑不是由 JVM 决定的,而是有开发者决定。下面是一个简单的例子:
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"
}
}
在 Java 程序中,可以通过 super 关键字来滴啊用父类中的方法,但是如何调用始祖类的方法呢?
在 JDK7 之前,使用纯粹的 Java 语言很难处理这个问题(直接生产字节码很简单,如使用 ASM 字节码生产工具),原因是在 Son 类的 thinking 方法中无法获取一个实际类型是 GrandFather 的对象引用,而 invokevirtual 指令的分派逻辑就是按照方法接收者的实际类型来进行分派的,该逻辑是固化在 JVM 中的,开发者无法改变。在 JDK7 中,可以使用以下代码来解决该问题。
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();
}
}
8.4 基于栈的字节码解释执行引擎
上面介绍了 JVM 是如何调用方法的,下面接着介绍 JVM 是如何执行方法中的字节码指令的。很多 JVM 的执行引擎在执行 Java 代码时都有解释执行(通过解释器解释字节码来执行)和编译执行(通过编译器将字节码便以为本地代码来执行)两种选择。
8.4.1 解释执行
Java 语言经常被人们定位为解释执行的语言,在 JDK1 时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器之后,Class 文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断问题。再后来,Java 也发展出了可以直接生成本地代码的编译器,而 C/C++ 也出现了通过解释器执行的版本,这时候就再笼统的说解释执行,对于整个 Java 语言来说就成了无意义的概念,只有确定过了谈论对象是某种具体的 Java 实现版本和执行引擎的运行模式时,对解释执行和编译执行的讨论才是有意义的。
无论是解析还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。
如今,基于物理机、JVM、或者非 Java 的其他高级语言虚拟机的语言,大多都会遵循这种现代编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器来实现,这类代表语言是 C/C++。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表就是 Java。又或者把这些步骤和执行引擎全部集中封装到一个封闭的黑匣子中,如大多数的 JavaScript 执行器。
Java 中,javac 编译器完成了程序代码经过词法分、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这部分动作是在 JVM 之外进行的,而解释器位于 JVM 内部,所以 Java 的编译就是半独立实现的。
8.4.2 基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 X86 的二地址指令集,通俗的说,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
举个简单的例子,分别使用两种指令集架构来实现 1+1
,基于栈的指令集会是这样:
iconst_1
iconst_1
iadd
istore_0
两条 iconst_1 指令连续把两个常量 1 压入栈,iadd 指令把栈顶的两个值出栈、相加,然后把结果放入栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 slot 中。
如果是基于寄存器架构,程序会是这种形式:
mov eax, 1
add eax, 1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把该值加 1,结果就保存在 EAX 寄存器中。
基于栈的指令集主要优点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件的约束。如,现在 32 位 80x86 体系的处理器中提供了 8 个 32 位的寄存器,而 ARM 体系的 CPU 则提供了 16 个 32 位的通用寄存器。如果使用基于栈的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中已获得尽量好的性能,这样实现起来也更加简单。栈架构的指令集还有一些其他优点,如代码相对更加紧凑(字节码中每个字节都对应一条指令,而多地址指令集中还需要存放参数)、编译器更加简单(不需要考虑空间分配问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会慢一点。所有主流物理机的指令集都是寄存器架构。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
8.4.3 基于栈的解释器执行过程
一个简单的算术示例:
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
}
字节码指令中显示,需要深度为 2 的操作数栈和 4 个 slot 的局部变量空间,如下一系列的图中,展示了该段代码在执行过程中代码、操作数栈、局部变量表的变化情况。
上面的执行过程仅仅是一种概念模型,虚拟机会对执行过程做一些优化来提高运行性能,实际的运作过程不一定完全符合概念模型的描述。更加准确的说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器对输入的字节码进行优化,如,在 HotSpot 虚拟机中,有很多以 “fast_” 开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。
不过,我们从这段程序的执行中也可以看出站结构指令集的一般运行过程,整个运算过程的中间变量都以操作数出入栈为信息交换途径,符合我们在前面分析的特点。
8.5 本章小结
本章中,我们分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法中的字节码,以及执行代码时涉及的数据结构。
4.9 - CH09-案例与实战
9.1 概述
在Class文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不太多,Class文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路,这些思路后来成为了许多常用功能和程序实现的基础。
9.2 案例分析
四个分别关于类加载器和字节码的案例。
9.2.1 Tomcat:正统的类加载器架构
主流的 Java Web 服务器都实现了自定义加载器,因为一个功能健全的 Web 服务器要解决如下几个问题。
部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离。这是最基本的要求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应该保证两个应用程序的类库可以互相独立使用。
部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以互相共享。这个需求也很常见,如,用户可能有 10 个使用 Spring 组织的应用程序部署在同一台服务器上,如果把 10 份 Spring 分别存放在各个应用程序的隔离目录中,将会有很大的资源浪费——主要不是浪费磁盘的问题,而是指类库在使用时要被加载器服务器内存,如果类库不能共享,虚拟机的方法区很容易出现过度膨胀的风险。
服务器要尽可能的保证自身的安全,而不受所部署的 Web 应用程序的影响。目前,很多主流的 Java Web 服务器自身也是使用 Java 语言实现的。因此,服务器本身也有类库依赖的问题,一般基于安全因素考虑,服务器所使用的类库应该与应用程序使用的类库相互独立。
支持 JSP 应用的 Web 服务器,大多都需要支持 HotSwap 功能。我们知道,JSP 文件最终要编译成 Java Class 才能由虚拟机执行,但 JSP 文件由于其纯文本存储的特性,运行时修改的概率要远远大于第三方类库或程序自身的 Class 文件。而且 ASP、PHP、JSP 这些网页应用也把修改后无需重启作为一个很大的优势来看待,因此“主流”的 Web 服务器都会支持 JSP 生成类的热替换,当然也有非主流的,如运行在生产模式下的 WebLogic 服务器默认就不会处理 JSP 文件的变化。
由于上述问题的存在,在部署 Web 应用时,单独的一个 ClassPath 就无法满足需求了,所以各种 Web 服务器都“不约而同”的提供了好几个 ClassPath 路径来供用户存放第三方类库,这些路径一般都以 lib 或 classes 命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每个目录都会有一个相应的自定义类加载器来加载放置在其中的 Java 类库。以下以 Tomcat 服务器为例来介绍它是如何规划用户类库结构和类加载器的。
在 Tomcat 目录结构中,有 3 组目录(/common/*, /server/*, /shared/*
)可以用来存放 Java 类库,另外还可以加上 Web 应用程序自身目录的 /WEB-INF/*
,一共 4 组,把 Java 类库放置在这些目录中的含义分别如下。
- common:类库可以被 Tomcat 和所有 Web 应用共同使用。
- server:类库可以被 Tomcat 使用,对所有的 Web 应用都不可见。
- shared:类库可以被所有 Web 应用使用,但对 Tomcat 自己不可见。
- WEB-INF:尽可以被指定 Web 应用可见,但对 Tomcat 和其他所有 Web 应用都不可见。
为了支持这套目录结构,并对目录中的类库进行加载和隔离,Tomcat 定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如下图所示:
灰色部分为 JDK 默认提供的类加载器,这里不再赘述。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebappClassLoader 则是 Tomcat 自己定义的类加载器,它们分别用于加载上面介绍的 4 个目录中的类库。通常 WebApp 和 JSP 类加载器会存在多个实例,每个 Web 应用对应一个 WebApp 类加载器,每个 JSP 文件对应一个 JSP 类加载器。
从上图中的委派模型可以看出,CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 自己能加载的类则与对方相互隔离。WebappClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebappClassLoader 实例之间相互隔离。而 JasperLoader 的加载范围仅仅是该 JSP 文件所编译出来的那个 Class,它出现的目的就是为了被丢弃:当服务器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过在建立一个新的 JSP 类加载器来实现 JSP 文件的 HotSwap 功能。
9.2.2 OSGI:灵活的类加载器架构
OSGI 中的每个模块(Bundle)与普通的 Java 类库区别并不大,两者一般都以 Jar 格式进行封装,并且内部存储的都是 Java Package 和 Class。但是一个 Bundle 可以声明它所依赖的 Java Package,也可以声明它允许导出发布的 Java Package。在 OSGI 里面,Bundle 之间的依赖关系从传统的上层模块依赖底层模块转换为平级别模块之间的依赖(之上表面上看是如此),而且类库的可见性能得到非常精确的控制,一个模块里只有被 Export 过的 Package 才能被外界访问,其他的 Package 和 Class 会隐藏起来。除了更加精确的模块划分和可见性控制之外,引入 OSGI 的另外一个重要的理由是,基于 OSGI 的程序很可能(并不一定会)可以实现模块级的热插拔功能,当程序升级或者调试除错时,可以只停用、重新安装然后启用程序的其中一部分,这对企业级程序开发来说是一个非常有诱惑力的特性。
OSGI 之所以能有上述优点,要归功于它灵活的类加载器架构。OSGI 和 Bundle 类加载器之间只有规则,没有固定的委派关系。如,某个 Bundle 声明了一个它依赖的 Package,如果有其它 Bundle 声明发布了这个 Package,那么所有对这个 Package 的类加载动作都会委派给发布它的 Bundle 类加载器来完成。当不涉及摸个具体的 Package 时,各个 Bundle 加载器都是平级关系,只有具体使用某个 Package 和 Class 时,才会根据 Package 的导入导出定义来构造 Bundle 之间的委派和依赖关系。
另外,一个 Bundle 类加载器为其他 Bundle 提供服务时,会根据 Export-Package 列表严格控制访问范围。如果一个类存在于 Bundle 的类库中但是没有被 Export,那么该 Bundle 的类加载器能找到这个类,但是不会提供给其他 Bundle 使用,而 OSGI 平台也不会该其他 Bundle 的类加载请求分配给这个 Bundle 来处理。
我们可以举一个更具体一些的简单例子,假设存在 Bundle A、B、C 三个模块,并且这三个 Bundle 定义的依赖关系如下:
- A:声明发布了 PackageA,依赖了 java.* 包。
- B:声明依赖了 PackageA 和 PackageC,同时也依赖了 java.* 包。
- C:声明发布了 PackageC,依赖了 PackageA。
那么,这三个 Bundle 之间的类加载器以及父类加载器之间的关系如图所示:
由于没有牵扯到具体 OSGI 实现,上图中没有指明具体的类加载器实现,只是一个体现了加载器之间关系的概念模型,并且只是体现了 OSGI 中最简单的加载器委派关系。一般来说,在 OSGI 中,加载一个类可能发生的查找行为和委派发生关系会比上图中显示的过程复杂的多,类加载时可能进行的查找规则如下。
- 以 java.* 开头的类,委派给父类加载器。
- 否则,委派列表名单内的类,委派给父类加载器加载。
- Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。
- 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。
- 否则,查找是否在自己的 Fragment Bundle 中,如果是,则委派给 Fragment Bundle 的类加载器来加载。
- 否则,查找 Dynamic Import 列表中的 Bundle,委派给对应 Bundle 的类加载器加载。
- 否则,类查找失败。
从上图中可以看出,在 OSGI 中,加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成了一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更好的灵活性的同时,也可能会产生很多新的隐患。
9.2.3 字节码生成技术与动态代理实现
“字节码生成”并不是什么高深的技术,读者在看到“字节码生成”这个标题时也先不必去想诸如 Javassist、CGLib、ASM 之类的字节码类库,因为 JDK 里面的 javac 命令就是字节码生成技术的“老祖宗”,并且 javac 也是一个由 Java 语言写成的程序,它的代码存放在 OpenJDK 的 langtools/src/share/classes/com/sun/tools/javac
目录中。要深入了解字节码生成,阅读 javac 的源码是个很好的途径,不过 javac 对于我们这个例子来说太过庞大了。在 Java 里面除了 javac 和字节码类库外,使用字节码生成的例子还有很多,如 Web 服务器中的 JSP 编译器,编译时植入的 AOP 框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。
相信许多Java开发人员都使用过动态代理,即使没有直接使用过 java.lang.reflect.Proxy 或实现过 java.lang.reflect.InvocationHandler 接口,应该也用过 Spring 来做过 Bean 的组织管理。如果使用过 Spring,那大多数情况都会用过动态代理,因为如果 Bean 是面向接口编程,那么在 Spring 内部都是通过动态代理的方式来对 Bean 进行增强的。动态代理中所谓的“动态”,是针对使用 Java 代码实际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点工作量,而是 实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。
下面展示了一个简单的动态代理用法,原始的逻辑是打印一句"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);
}
@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();
}
}
上述代码里,唯一的“黑匣子”就是 Proxy.newProxyInstance()
方法,除此之外再没有任何特殊之处。该方法返回一个实现了 IHello 接口、且代理了 new Hello()
实例的行为的对象。跟踪该方法的源码,可以看到程序进行了验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤并不是我们关注的重点,而在最后它调用了 sun.misc.ProxyGenerator.generateProxyClass()
方法来完成字节码的生成动作,该方法可以在运行时产生一个描述代理类的字节码 byte[]
数组。如果想看一看这个在运行时产生的代理类的内容,可以在 main 方法中添加如下代码:
System.getProperties().put(
"sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
加入该代码后再次运行,磁盘中将会产生一个名为 $Proxy().class
的代理类 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个方法的内容与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());
}
}
}
该代理类的实现很简单,它为传入接口中的每个方法,以及从 java.lang.Object
中继承来的 equals/hashCode/toString
方法都生成了对应的实现,并且统一调用了 InvocationHandler 对象的 invoke 方法(代码中的 this.h
即使父类 Proxy 中保存的 InvocationHandler 实例变量)来实现这些方法的内容,各个方法的区别不过是传入的参数和 Method 对象有所不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行 InvocationHandler.invoke 中的代理逻辑。
这个例子中并没有讲到 generateProxyClass() 方法具体是如何产生代理类 “$Proxy0.class” 的字节码的,大致的生成过程其实就是根据 Class 文件的格式规范去拼装字节码,但在实际开发中,以 byte 为单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果读者对动态代理的字节码拼装过程很感兴趣,可以在 OpenJDK 的 jdk/src/share/classes/sun/misc
目录下找到 sun.misc.ProxyGenerator
的源码。
9.2.4 Retrotanslator:跨越 JDK 版本
Retrotranslator 的作用是将 JDK 1.5 编译出来的 Class 文件转变为可以在 JDK 1.4 或 1.3 上部署的版本,它可以很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持 JDK 1.5 中新增的集合改进、并发包以及对泛型、注解等的反射操作。了解了 Retrotranslator 这种逆向移植工具可以做什么以后,现在关心的是它是怎样做到的?
要想知道 Retrotranslator 如何在旧版本JDK中模拟新版本JDK的功能,首先要弄清楚 JDK 升级中会提供哪些新的功能。JDK 每次升级新增的功能大致可以分为以下 4 类:
在编译器层面做的改进。如自动装箱拆箱,实际上就是编译器在程序中使用到包装对象的地方自动插入了很多 Integer.valueOf()、Float.valueOf() 之类的代码;变长参数在编译之后就自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经擦除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。
对 Java API 的代码增强。譬如 JDK 1.2 时代引入的 java.util.Collections 等一系列集合类,在 JDK 1.5 时代引入的 java.util.concurrent 并发包等。
需要在字节码中进行支持的改动。如 JDK 1.7 里面新加入的语法特性:动态语言支持,就需要在虚拟机中新增一条 invokedynamic 字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对比较稳定的状态,这种需要在字节码层面直接进行的改动是比较少见的。
虚拟机内部的改进。如 JDK5 中实现的 JSR-133 规范重新定义的 Java 内存模型、CMS 收集器之类的改动。这类改动对于程序员编写的代码来说基本是透明的,但会对程序运行时产生影响。
上述 4 种功能中,Retrotranslator 只能模拟前两类,对于后面两类直接在虚拟机内部实现的改进,一般所有的逆向移植工具都是无能为力的,至少不能完整的或者在可接收的效率上完成全部模拟,否则虚拟机设计团队也没有必要舍近求远的改动处于 JDK 底层的虚拟机。在可以模拟的两类功能中,第二类模拟相对容易实现一些,如 JDK5 引入 JUC 包,实际上是由多线程大师 Doug Lea 开发的一套并发包,在 JDK5 出现之前就已经存在,所以要在旧 JDK 中支持这部分功能,以独立类库的方式便可实现。Retrotranslator 中附带了一个名为 “backport-util-concurrent.jar” 的类库来代替 JDK5 并发包。
至于JDK在编译阶段进行处理的那些改进,Retrotranslator 则是使用 ASM 框架直接对字节码进行处理。由于组成 Class 文件的字节码指令数量并没有改变,所以无论是 JDK 1.3、JDK 1.4 还是 JDK 1.5,能用字节码表达的语义范围应该是一致的。当然,肯定不可能简单地把 Class 的文件版本号从 49.0 改回 48.0 就能解决问题了,虽然字节码指令的数量没有变化,但是元数据信息和一些语法支持的内容还是要做相应的修改。以枚举为例,在 JDK 1.5 中增加了 enum 关键字,但是 Class 文件常量池的 CONSTANT_Class_info 类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增加过 “CONSTANT_Enum_info” 之类的“枚举符号引用”常量。所以使用 enum 关键字定义常量,虽然从 Java 语法上看起来与使用 class 关键字定义类、使用 interface 关键字定义接口是同一层次的,但实际上这是由 Javac 编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于 java.lang.Enum、自动生成了 values() 和 valueOf() 方法的普通 Java 类而已。
Retrotranslator 对枚举所做的主要处理就是把枚举类的父类从 java.lang.Enum 替换为它运行时类库中包含的 “net.sf.retrotranslator.runtime.java.lang.Enum_",然后再在类和字段的访问标志中抹去 ACC_ENUM 标志位。当然,这只是处理的总体思路,具体的实现要复杂得多。可以想象既然两个父类实现都不一样,values 和 valueOf 方法自然需要重写,常量池需要引入大量新的来自父类的符号应用,这些都是实现细节。下图使用 JDK5 编译的枚举类与被 Retrotranslator 转换处理后的字节码进行对比。
9.3 实战:实现远程执行功能
不知道读者在编写程序时是否遇到过如下情景:在排查问题过程中,想查看内存中的一些参数值,却又没有方法把这些值输出到界面或日志中,又或者定位到某个缓存数据有问题,但缺少缓存的统一万里界面,不得不重启服务才能清理该缓存。类似的需求有一个共同的特点,就是只要在服务中执行一段程序代码,就可以定位或排除问题,但就是找不到可以让服务器执行代码的途径,这时候就会希望 Java 服务器中也有提供类似 Groovy Console 的功能。
JDK6 之后提供了 Compiler API,可以动态的编译 Java 程序,虽然这样达不到动态语言的灵活度,但让服务器执行临时代码的需求就可以得到解决了。在 JDK6 之前,也可以通过其他方式实现,比如编写一个 JSP 文件上传到服务器,然后在浏览器中运行,或者在服务器程序中插入一个额 BeanShell Script、JavaScript 等语言的执行引擎来执行动态脚本。
9.3.1 目标
首先,在实现“在服务端执行临时代码”这个需求之前,先来明确一下本次实战的具体目标,我们希望最终的产品是这样的:
- 不依赖具体的 JDK 版本,能在目前普遍使用的 JDK 中部署,也能在 JDK 4~7 中部署。
- 不改变原有服务端程序的部署方式,不依赖任何三方类库。
- 不侵入原有程序,即无需改动原有程序的任何代码,也不会对原有程序的运行带来任何影响。
- 考虑到 BeanShell Script 或 JavaScript 等脚本编写起来不太方便,“临时代码”需要直接支持 Java 语言。
- “临时代码”应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里写的是“不需要”而不是“不可以”,当“临时代码”需要引用其他类库的时也没有限制,只要服务端程序能够使用,临时代码应当都能被直接引用。
- “临时代码”的执行结果能够返回客户端,执行结果可以包括程序中输出的信息以及抛出的异常等等。
9.3.2 思路
在程序实现的过程中,我们需要解决如下 3 个问题:
- 如何编译提交到服务器的 Java 代码?
- 如何执行编译之后的 Java 代码?
- 如何收集 Java 代码的执行结果?
对于第一个问题,我们有两种思路可供选择,一种是使用 tools.jar 中的 com.sun.tools.javac.Main
类来编译 Java 文件,这其实和使用 javac 命令编译一行。这种思路的缺点是引入了额外的 Jar 包,而且把程序“绑死”在了 Sun 的 JDK 上,要部署到其他公司的 JDK 中还得把这个 tools.jar 带上。另外一种思路是直接在客户端编译好,把字节码而不是 Java 源代码直接传到服务端,这听起来有点投机取巧,一般来说确实不应该假设客户端一定具有编译代码的能力,但是然程序员会写 Java 代码给服务端来排查问题,那么很难想象他的机器上会连编译 Java 代码的环境都没有。
对于第二个问题,简单的一想:要执行编译后的 Java 代码,让类加载器加载这个类生成一个 Class 对象,然后反射代用某个方法就可以了。但我们还应该考虑的周全一点:一段程序往往不是编写、运行一次就能达到效果,同一个类可能要反复的修改、提交、执行。提交上去的类还有可能需要访问其他的类库。此外,既然提交的是临时代码,那么提交的类应该在执行完成后卸载并回收。
最后一个问题,我们想把程序往标准输出和标准错误输出中但因的信息收集起来,但标准输出是整个虚拟机进程全局共享的资源,如果使用 System.setout、System.setErr 方法把输出流重定向到自定义的 PrintStream 对象上固然可以收集到输出信息,但也会对原有程序产生影响:会把其他线程输出的信息也一并收集。虽然这并不是不能解决问题,不过为了达到完全不影响原有程序的目的,我们可以采用另外一种办法,即直接在执行的类中把 System.out 的符号引用替换为我们准备好的 PrintStream 符号引用,基于前面学习的知识,做到这一点并不难。
9.3.3 实现
首先看看实现过程中需要用到的 4 个支持类。第一个类用于实现“同一个类的代码可以被加载多次”这个需求:
/**
*为了多次载入执行类而加入的加载器<br>
*把 defineClass 方法开放出来, 只有外部显式调用的时候才会使用到loadByte方法
*由虚拟机调用时, 仍然按照原有的双亲委派规则使用loadClass方法进行类加载
*/
public class HotSwapClassLoader extends ClassLoader{
public HotSwapClassLoader(){
super(HotSwapClassLoader.class.getClassLoader());
}
public Class loadByte(byte[]classByte){
return defineClass(null,classByte, 0, classByte.length);
}
}
HotSwapClassLoader 完成的工作仅仅是公开父类中的 protected 方法 defineClass,我们将使用该方法来把提交执行的 Java 类的字节数组转换为 Class 对象。HotSwapClassLoader 中并没有重写 loadClass 或 findClass 方法,因此如果不算外部手动调用 loadByte 的话,该类加载器的类查找范围与其父类加载器是完全一致的,在被虚拟机调用时,它会按照双亲委派模型交给父类加载。构造函数中指定为加载 HotSwapClassLoader 类的类加载器作为父类加载器,这一步是实现提交的执行代码可以访问服务端引用类库的关键。
第二个类是实现 java.lang.System 替换为自定义的 PrintStream 类的过程,它直接修改符合 Class 文件格式的字节数组中的常量池部分,将常量池中指定内容的 CONSTANT_Utf8_info 常量替换为新的字符串,具体代码如代码清单所示。ClassModifier 中涉及对字节数组操作的部分,主要是将字节数组与 int 和 String 互相转换,以及把对字节数组的替换操作封装在代码清单所示的 ByteUtils 中。
/**
*修改Class文件, 暂时只提供修改常量池常量的功能
*@author zzm
*/
public class ClassModifier{
/**
*Class文件中常量池的起始偏移
*/
private static final int CONSTANT_POOL_COUNT_INDEX=8;
/**
*CONSTANT_Utf8_info常量的tag标志
*/
private static final int CONSTANT_Utf8_info=1;
/**
*常量池中11种常量所占的长度, CONSTANT_Utf8_info型常量除外, 因为它不是定长的
*/
private static final int[]CONSTANT_ITEM_LENGTH={-1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5};
private static final int u1=1;
private static final int u2=2;
private byte[]classByte;
public ClassModifier(byte[]classByte){
this.classByte=classByte;
}
/**
*修改常量池中CONSTANT_Utf8_info常量的内容
*@param oldStr修改前的字符串
*@param newStr修改后的字符串
*@return修改结果
*/
public byte[]modifyUTF8Constant(String oldStr,String newStr){
int cpc=getConstantPoolCount();
int offset=CONSTANT_POOL_COUNT_INDEX+u2;
for(int i=0;i<cpc;i++){
int tag=ByteUtils.bytes2Int(classByte,offset,u1);
if(tag==CONSTANT_Utf8_info){
int len=ByteUtils.bytes2Int(classByte,offset+u1, u2);
offset+=(u1+u2);
String str=ByteUtils.bytes2String(classByte,offset,len);
if(str.equalsIgnoreCase(oldStr)){
byte[]strBytes=ByteUtils.string2Bytes(newStr);
byte[]strLen=ByteUtils.int2Bytes(newStr.length(), u2);
classByte=ByteUtils.bytesReplace(classByte,offset-u2, u2, strLen);
classByte=ByteUtils.bytesReplace(classByte,offset,len,strBytes);
return classByte;
}else{
offset+=len;
}
}else{
offset+=CONSTANT_ITEM_LENGTH[tag];
}
}
return classByte;
}
/**
*获取常量池中常量的数量
*@return常量池数量
*/
public int getConstantPoolCount(){
return ByteUtils.bytes2Int(classByte,CONSTANT_POOL_COUNT_INDEX,u2);
}
}
/**
*Bytes数组处理工具
*@author
*/
public class ByteUtils{
public static int bytes2Int(byte[]b,int start,int len){
int sum=0;
int end=start+len;
for(int i=start;i<end;i++){
int n=((int)b[i])&0xff;
n<<=(--len)*8;
sum=n+sum;
}
return sum;
}
public static byte[]int2Bytes(int value,int len){
byte[]b=new byte[len];
for(int i=0;i<len;i++){
b[len-i-1]=(byte)((value>>8*i)&0xff);
}
return b;
}
public static String bytes2String(byte[]b,int start,int len){
return new String(b,start,len);
}
public static byte[]string2Bytes(String str){
return str.getBytes();
}
public static byte[]bytesReplace(byte[]originalBytes,int offset,int len,byte[]replaceBytes){
byte[]newBytes=new byte[originalBytes.length+(replaceBytes.length-len)];
System.arraycopy(originalBytes, 0, newBytes, 0, offset);
System.arraycopy(replaceBytes, 0, newBytes,offset,replaceBytes.length);
System.arraycopy(originalBytes,offset+len,newBytes,offset+replaceBytes.length,originalBytes.length-offset-len);
return newBytes;
}
}
经过 ClassModifer 处理后的字节数组才会传给 HotSwapClassLoader.loadByte 方法来执行类加载,字节数组在这里替换符号引用之后,与客户端直接在 Java 代码中引用 HackSystem 类在编译生成的 Class 完全一样。这样的实现避免了客户端编写临时代码时要依赖特定的类,又避免了服务端修改标准输出后影响其他程序的输出。
最后一个类是前面提到过的用来替换 java.lang.System 的 HackSystem,该类中的方法看起来不少,但其实除了把 out 和 err 两个静态变量替换为使用 ByteArrrayOutputStream 作为打印目标的同一个 PrintStream 对象,以及增加了读取、清理 ByteArrayOutputStream 中内容的 getBufferString 和 clearBuffer 方法外,就再没有其他新鲜的内容了。其余的方法全部都来自于 System 类的 public 方法,方法名称、参数、返回值都完全一样,并且实现也是直接调转了 System 类的对应方法而已。保留这些方法的目的是为了在 System 在被替换为 HackSystem 之后,执行代码中调用的 System 的其余方法仍然可以继续使用,HackSystem 的实现如下所示。
/**
*为JavaClass劫持java.lang.System提供支持
*除了out和err外, 其余的都直接转发给System处理
*
*@author zzm
*/
public class HackSystem{
public final static InputStream in=System.in;
private static ByteArrayOutputStream buffer=new ByteArrayOutputStream();
public final static PrintStream out=new PrintStream(buffer);
public final static PrintStream err=out;
public static String getBufferString(){
return buffer.toString();
}
public static void clearBuffer(){
buffer.reset();
}
public static void setSecurityManager(final SecurityManager s){
System.setSecurityManager(s);
}
public static SecurityManager getSecurityManager(){
return System.getSecurityManager();
}
public static long currentTimeMillis(){
return System.currentTimeMillis();
}
public static void arraycopy(Object src,int srcPos,Object dest,int destPos,int length){
System.arraycopy(src,srcPos,dest,destPos,length);
}
public static int identityHashCode(Object x){
return System.identityHashCode(x);
}
//下面所有的方法都与java.lang.System的名称一样
//实现都是字节转调System的对应方法
//因版面原因, 省略了其他方法
}
我们来看看最后一个类 JavaClassExecutor,它是提供给外部调用的入口,调用前面几个直接类组装逻辑,完成类加载工作。JavaClassExecutor 只有一个 execute 方法,用输入的符合 Class 文件格式的字节数组替换 java.lang.System 的符号引用后,使用 HotSwapClassLoader 加载生成一个 Class 对象,由于每次执行 execute 方法都会生成一个新的类加载器实例,因此同一个类可以实现重复加载。然后,发射调用这个 Class 对象的 main 方法,如果期间出现任何异常,将异常信息打印到 HackSystem.out 中,最后把缓冲区中的信息作为方法的结果返回。JavaClassExecutor 的实现代码如代码清单所示。
/**
*JavaClass执行工具
*
*@author zzm
*/
public class JavaClassExecuter{
/**
*执行外部传过来的代表一个Java类的byte数组<br>
*将输入类的byte数组中代表java.lang.System的CONSTANT_Utf8_info常量修改为劫持后的HackSystem类
*执行方法为该类的static main(String[]args)方法, 输出结果为该类向System.out/err输出的信息
*@param classByte代表一个Java类的byte数组
*@return执行结果
*/
public static String execute(byte[]classByte){
HackSystem.clearBuffer();
ClassModifier cm=new ClassModifier(classByte);
byte[]modiBytes=cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem");
HotSwapClassLoader loader=new HotSwapClassLoader();
Class clazz=loader.loadByte(modiBytes);
try{
Method method=clazz.getMethod("main", new Class[]{String[].class});
method.invoke(null,new String[]{null});
}catch(Throwable e){
e.printStackTrace(HackSystem.out);
}
return HackSystem.getBufferString();
}
}
9.3.4 验证
如果只是测试的话,那么可以任意下一个 Java 类,内容无所谓,只要向 System.out 输出信息即可,取名为 TestClass,同时放到服务器 C 盘的根目录中,然后建立一个 JSP 文件并加入如下清单中的代码,就可以在浏览器中看到这个类的运行效果了。
<%@page import="java.lang.*"%>
<%@page import="java.io.*"%>
<%@page import="org.fenixsoft.classloading.execute.*"%>
<%
InputStream is=new FileInputStream("c:/TestClass.class");
byte[]b=new byte[is.available()];
is.read(b);
is.close();
out.println("<textarea style='width:1000;height=800'>");
out.println(JavaClassExecuter.execute(b));
out.println("</textarea>");
%>
当然,上面的做法只是用于测试和演示,实际使用这个 JavaExecutor 执行器的时候,如果还要手工复制一个 Class 文件到服务器上就没有什么意义了。笔者给这个执行器写了一个外壳,是一个 Eclipse 插件,可以把 Java 文件编译后传输到服务器中,然后把执行器的返回结果输出到 Eclipse 的 Console 窗口中,这样就可以在有灵感的时候随时写几行调试代码,当道测试环境的服务器上立即运行了。虽然实现简单,但效果很不错,对调试问题也非常有帮助,如下图所示:
9.4 本章小结
本书 6~9 章介绍了 Class 文件格式、类加载和虚拟机执行引擎,这些内容是虚拟机中必不可少的组成部分,只有了解了虚拟机如何执行程序,才能更好的理解怎样才能写出优秀的代码。
4.10 - CH10-编译期优化
从计算机程序出现的第一天起,对效率的追求就是程序员天生的鉴定信仰,这个过程犹如一场没有终点、永不停歇的 F1 方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车。
10.1 概述
Java 语言的“编译期”其实是一段不确定的操作过程,因为它可能是指一个前端编译器把 java 文件转变成 class 文件的过程;也可能是值虚拟机的后端运行时编译器把字节码转换为机器码的过程;还可能是指使用静态提前编译器(AOT 编译器)直接把 java 文件编译为本地机器码的过程。下面列举了这 3 类编译过程中一些比较有代表性的编译器。
- 前端编译器:Sun 的 javac、Eclipse JDT 中的增量式编译器 ECJ。
- JIT 编译器:HotSpot VM 的 C1/C2 编译器。
- AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。
这 3 类过程中最符合大家对 Java 程序编译认知的应该是第一类,在本章的后续介绍中,笔者提到的编译期和编译器都仅限于第一类编译过程,把第二类编译过程留到下一章中讨论。限制了编译范围后,我们对于“优化”二字的定义就需要宽松一些,因为 javac 这类编译器对代码的运行效率几乎没有任何优化措施。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由 javac 产生的 Class 文件也同样能够享受到编译器优化带来的好处。但是 javac 做了很多针对 Java 语言编码过程的优化措施来改善程序员的编码风格和编码效率。相当多新生的 Java 语法特性,都是靠编译器的语法糖来实现的,而不是依赖虚拟机的底层改进来支持的,可以说,Java 中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化对于程序编码来说关系更加密切。
10.2 Javac 编译器
分析源码是了解一项技术实现内幕的最有效手段,Javac 编译器不像 HotSpot 虚拟机那样使用 C++ 实现,他本身就是一个由 Java 语言编写的程序,这位纯 Java 程序员了解它的编译过程带来了很大的便利。
10.2.1 Javac 的源码与调试
Javac 的源码存放在 JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac
中,除了 JDK 自身的 API 外,就只引用了 JDK_SRC_HOME/langtools/src/share/classes/com/sun/*
里面的代码,调试环境建立起来简单方便,因为基本上不需要处理依赖关系。
虚拟机规范严格定义了 Class 文件的格式,但是“JVM 规范 2”中,虽然有专门的一章“Compiling for the Java Virtual Machine”,但多是以举例的形式描述,并没有对如何把 Java 源文件编译为 Class 文件的编译过程进行十分严格的定义,这导致 Class 文件编译在某种程度上与具体 JDK 实现相关,在一些极端情况下,可能出现对于同一段代码, Javac 编译器可以编译,而 ECJ 编译器就无法编译的问题。从 Sun Javac 的代码来看,编译过程大致可以分为 3 个过程,分别是:
- 解析与填充符号表过程。
- 插入式注解处理器的注解处理过程。
- 分析与字节码生成过程。
这 3 个步骤之间的关系与交互顺序如下图所示:
Javac 编译动作的入口是 com.sun.tools.javac.main.JavaCompiler
类,上述 3 个过程的代码逻辑集中在这个类的 compile 和 compile2 方法中,其中主体代码如下图所示,真个编译最关键的处理就是图中标注的 8 个方法来完成,下面我们具体看一下这 8 个方法实现了什么功能。
10.2.2 解析与填充符号表
解析步骤由上图中的 parseFiles 方法完成,解析步骤包括了经典程序编译原理中的词法分析和语法分析两个过程。
1. 词法分析,语法分析
词法分析是将源代码的字符流转换为标记(token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以称为标记。如“int a = b + 2” 这个语句包含了 6 个标记,分别是 int、a、=、b、+、2,虽然关键字 int 由 3 个字符构成,但它只是一个 token,不可在被拆分。在 Javac 源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner
类来实现。
语法分析是根据 token 序列构造抽象语法树的过程,抽闲语法树是一种用来描述程序代码语法及饿哦股的树形表示方式,语法树的每个节点都代表着程序代码中的一个语法结构,如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。
下图根据 Eclipse AST View 插件分析出来的某段代码的抽象语法树视图,读者可以通过这张图对抽象语法树有一个直观的认识。在 Javac 源码中,语法分析过程由 com.sun.tools.javac.parser.Parser
类实现,这个阶段产生的抽象语法树由 com.sun.tools.javac.tree.JCTree
类表示,经过这个步骤之后,编译器就基本不会再对代码的源文件进行操作了,后续的操作都建立在该抽象语法树之上。
2. 填充符号表
完成了语法分析和词法分析之后,下一步就是填充符号表的过程,也就是 enterTrees 方法完成的工作。符号表是由一组符号地址和符号信息构成的表格,可以把它想象成哈希表中 KV 对的形式。符号表中所记录的信息在编译的不同阶段都要用到。在语义分析中,符号表锁记录的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
在 Javac 源码中,填充符号表的过程由 com.sun.tools.javac.comp.Enter
类实现,此过程的出口是一个待处理列表,包含了一个编译单元的抽象语法树的顶级节点,以及 package-info.java 的顶级节点(如果有的话)。
10.2.3 注解处理器
在 JDK5 之后,Java 语言提供了对注解的支持,这些注解与普通的 Java 代码一样,是在运行期间发挥作用的。在 JDK6 中实现了 JSR-269 规范,提供了一组插入式注解处理器的标准 API 在编译期间对注解进行处理,可以把它看做是一组编译器插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将会回到解析及填充符号表的过程重新处理,每次循环称为一个 Round,也就是前面图示中的回环过程。
有了编译器注解处理器的标准 API 之后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员可以使用插入式注解处理器来实现很多原本只能在编码中完成的事情,本章最后会给出一个使用插入式注解处理器的简单实战。
在 Javac 源码中,插入式注解处理器的初始化过程是在 initPorcessAnnotations 方法中完成的,而它的执行过程则是在 processAnnotations 方法中完成的,该方法判断是否还有新的注解处理器需要执行,如果有的话,通过 com.sun.tools.javac.processing.JavacProcessingEnvironment 类的 doProcessing 方法生成一个新的 JavaCompiler 对象对编译的后续步骤进行处理。
10.2.4 语义分析与字节码生成
语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。举个例子,假设有如下 3 个变量定义语句:
int a=1;
boolean b=false;
char c=2;
后续可能出现的赋值运算:
int d=a+c;
int d=b+c;
char d=a+c;
后续代码中如果出现了如上 3 种赋值运算的话,那它们都能构成结构正确的语法树,但是只有第 1 种的写法在语义上是没有问题的,能够通过编译,其余两种在 Java 语言中是不合逻辑的,无法编译。
1. 标注检查
Javac 在编译过程中,语义分析过程中分为标注检查和数据与控制流分析两个步骤,分别由 attribute 和 flow 方法完成。
标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。在标注检查步骤中,还有一个重要的动作称为常量折叠,如果我们在代码中写了如下定义:
int a=1+2;
那么在语法树上仍然能看到字面量 1、2 和操作符 +,但是在经过常量折叠之后,它们将会被折叠为字面量 3,如下图所示,这个插入式表达式的值已经在语法树上标注出来了。由于编译期进行了常量折叠,所以在代码中的定位直接变为a=3
,并不会增加程序运行期间哪怕是仅仅一个 CPU 指令的运算量。
标注检查步骤在 Javac 源码中的实现类是 com.sun.tools.javac.comp.Attr
类和 com.sun.tools.javac.comp.Check
类。
2. 数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上是一致的,但校验范围有所区别,有一些校验项只有在编译期或运行期才能进行。下面举一个关于 final 修饰符的数据及控制流分析的例子,见如下代码清单。
//方法一带有final修饰
public void foo(final int arg){
final int var=0;
//do something
}
//方法二没有final修饰
public void foo(int arg){
int var=0;
//do something
}
在这两个 foo 方法中,第一种方法的参数和局部变量定义使用了 final 修饰符,而第二种方法则没有,在代码编写时程序肯定会受到 final 修饰符的影响,不能再改变 arg 和 var 变量的值,但是这两段代码编译出来的 Class 文件没有任何区别,通过第 6 章的学习我们已经知道,局部变量与字段是有区别的,它在常量池中没有 CONSTANT_Fieldref_info 的符号引用,自然就没有访问标志信息,甚至可能连名称都不会保留下来,自然在 Class 文件中不能知道一个局部变量是不是声明为 fianl 了。因此,将局部变量声明为 final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期保障。在 Javac 源码中,数据以及控制流分析的入口是 flow 方法,具体操作由 com.sun.tools.javac.comp.Flow
类来完成。
3. 解语法糖
语法糖是由英国计算机科学家 Peter J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员的使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的几乎。
Java 中最常用的语法糖主要是前面提到过的泛型、变长参数、自动装拆箱,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。Java 的这些语法糖被解除后是什么样子,将在 10.3 节详细描述。
在 Javac 的源码中,解语法糖的过程是由 desugar 方法触发,在 com.sun.tools.javac.comp.TransTypes
类和 com.sun.tools.javac.comp.Lower
中完成。
4. 字节码生成
字节码生成是 Javac 编译过程的最后一个阶段,在 Javac 源码里面由 com.sun.tools.javac.jvm.Gen
类完成。字节码生成阶段不仅仅是把前面各个步骤生成的信息(语法树、符号表)转换成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。
例如,前面章节中多次提到的实例构造器 <init>()
方法和类构造器 <clinit>()
方法就是在该阶段添加到语法树之中的,这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对于实例构造器而言是 {}
块,对于类构造器而言是 static{}
块)、变量初始化、调用父类的实例构造器等操作收敛到 <init>()
和 <clinit>()
方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后再执行语句块的顺序进行,上面所述的动作由 Gen.normalizeDefs()
方法来实现。除了生成构造器之外,还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加发操作替换为 StringBuilder 的 append 操作。
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter
类,由这个类的 writeClass 方法输出字节码,生成最终的 Class 文件,到此为止整个编译过程宣告结束。
10.3 Java 语法糖的味道
几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。不过也有一种观点认为语法糖并不一定都是有益的,大量添加和使用“含糖”的语法,容易让程序员产生依赖,无法看清语法糖的糖衣背后,程序代码的真实面目。
总而言之,语法糖可以看做是编译器实现的一些“小把戏”,这些“小把戏”可能会使得效率“大提升”,但我们也应该去了解这些“小把戏”背后的真实世界,那样才能利用好它们,而不是被它们所迷惑。
10.3.1 泛型与类型擦除
泛型是 JDK 1.5 的一项新增特性,它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。
泛型思想早在 C++ 语言的模板中就开始生根发芽,在 Java 语言处于还没有出现泛型的版本时,只能通过 Object 是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如,在哈希表的存取中,JDK 1.5 之前使用HashMap 的 get() 方法,返回值就是一个 Object 对象,由于 Java 语言里面所有的类型都继承于 java.lang.Object,所以 Object 转型成任何对象都是有可能的。但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个 Object 到底是个什么类型的对象。在编译期间,编译器无法检查这个 Object 的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多 ClassCastException 的风险就会转嫁到程序运行期之中。
泛型技术在 C# 和 Java 之中的使用方式看似相同,但实现上却有着根本性的分歧,C# 里面泛型无论在程序源码中、编译后的 IL 中,或是运行期的 CLR 中,都是切实存在的,List<int>
与 List<String>
就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。
Java 语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型了,并且在相应的地方插入了强制转型代码,因此,对于运行期的 Java 语言来说,ArrayList<int>
与 ArrayList<String>
就是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。
当初 JDK 设计团队为什么选择类型擦除的方式来实现 Java 语言的泛型支持呢?是因为实现简单、兼容性考虑还是别的原因?我们已不得而知,但确实有不少人对 Java 语言提供的伪泛型颇有微词,当时甚至连《Thinking in Java》一书的作者 Bruce Eckel 也发表了一篇文章《这不是泛型!》来批评 JDK 1.5 中的泛型实现。
在当时众多的批评之中,有一些是比较表面的,还有一些从性能上说泛型会由于强制转型操作和运行期缺少针对类型的优化等从而导致比 C# 的泛型慢一些,则是完全偏离了方向,姑且不论 Java 泛型是不是真的会比 C# 泛型慢,选择从性能的角度上评价用于提升语义准确性的泛型思想就不太恰当。但笔者也并非在为 Java 的泛型辩护,它在某些场景下确实存在不足,笔者认为通过擦除法来实现泛型丧失了一些泛型思想应有的优雅,例如代码清单 10-4 的例子。
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)");
}
}
请想一想,上面这段代码是否正确,能否编译执行?也许你已经有了答案,这段代码是不能被编译的,因为参数 List<Integer>
和 List<String>
编译之后都被擦除了,变成了一样的原生类型 List<E>
,擦除动作导致这两种方法的特征签名变得一模一样。初步看来,无法重载的原因已经找到了,但真的就是如此吗?只能说,泛型擦除成相同的原生类型只是无法重载的其中一部分原因,请再接着看一看代码清单 10-5 中的内容。
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>());
}
}
// result
// invoke method(List<String>list)
// invoke method(List<Integer>list)
代码清单 10-5 与代码清单 10-4 的差别是两个 method 方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行了。这是对 Java 语言中返回值不参与重载选择的基本认知的挑战吗?
代码清单 10-5 中的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个 method() 方法加入了不同的返回值后才能共存在一个 Class 文件之中。第 6 章介绍 Class 文件方法表的数据结构时曾经提到过,方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在 Class 文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个 Class 文件中的。
由于 Java 泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。因此,JCP 组织对虚拟机规范做出了相应的修改,引入了诸如 Signature、LocalVariableTypeTable 等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别 49.0 以上版本的 Class 文件的虚拟机都要能正确地识别 Signature 参数。
从上面的例子可以看到擦除法对实际编码带来的影响,由于 List<String>
和 List<Integer>
擦除后是同一个类型,我们只能添加两个并不需要实际使用到的返回值才能完成重载,这是一种毫无优雅和美感可言的解决方案,并且存在一定语意上的混乱,譬如上面脚注中提到的,必须用 Sun JDK 1.6 的 Javac 才能编译成功,其他版本或者 ECJ 编译器都可能拒绝编译。
另外,从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
10.3.2 自动装箱、拆箱与遍历循环
从纯技术的角度来讲,自动装箱、自动拆箱与遍历循环(Foreach循环)这些语法糖,无论是实现上还是思想上都不能和上文介绍的泛型相比,两者的难度和深度都有很大差距。专门拿出一节来讲解它们只有一个理由:毫无疑问,它们是 Java 语言里使用得最多的语法糖。我们通过代码清单 10-6 和代码清单 10-7 中所示的代码来看看这些语法糖在编译后会发生什么样的变化。
public static void main(String[]args){
List<Integer>list=Arrays.asList(1, 2, 3, 4);
//如果在JDK 1.7中, 还有另外一颗语法糖[1]
//能让上面这句代码进一步简写成List<Integer>list=[1, 2, 3, 4];
int sum=0;
for(int i:list){
sum+=i;
}
System.out.println(sum);
}
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);
}
代码清单 10-6 中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数 5 种语法糖,代码清单 10-7 则展示了它们在编译后的变化。泛型就不必说了,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的 Integer.valueOf() 与 Integer.intValue() 方法,而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。
这些语法糖虽然看起来很简单,但也不见得就没有任何值得我们注意的地方,代码清单 10-8 演示了自动装箱的一些错误用法。
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);
System.out.println(e==f);
System.out.println(c==(a+b));
System.out.println(c.equals(a+b));
System.out.println(g==(a+b));
System.out.println(g.equals(a+b));
}
阅读完代码清单 10-8,读者不妨思考两个问题:一是这 6 句打印语句的输出是什么?二是这 6 句打印语句中,解除语法糖后参数会是什么样子?这两个问题的答案可以很容易试验出来,笔者就暂且略去答案,希望读者自己上机实践一下。无论读者的回答是否正确,鉴于包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们 equals() 方法不处理数据转型的关系,笔者建议在实际编码中尽量避免这样使用自动装箱与拆箱。
10.3.3 条件编译
许多程序设计语言都提供了条件编译的途径,如 C/C++ 中使用预处理器指示符(#ifdef)来完成条件编译。C/C++ 的预处理器最初的任务是解决编译时的代码依赖关系(如非常常用的 #include 预处理命令),而在 Java 语言之中并没有使用预处理器,因为 Java 语言天然的编译方式(编译器并非一个个地编译 Java 文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用预处理器。那 Java 语言是否有办法实现条件编译呢?
Java 语言当然也可以进行条件编译,方法就是使用条件为常量的 if 语句。如代码清单 10-9 所示,此代码中的 if 语句不同于其他 Java 代码,它在编译阶段就会被“运行”,生成的字节码之中只包括 System.out.println("block 1");
一条语句,并不会包含 if 语句及另外一个分子中的 System.out.println("block 2");
。
public static void main(String[]args){
if(true){
System.out.println("block 1");
}else{
System.out.println("block 2");
}
}
上述代码编译后 Class 文件的反编译效果:
public static void main(String[]args){
System.out.println("block 1");
}
只能使用条件为常量的 if 语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如代码清单 10-10 所示的代码就会被编译器拒绝编译。
public static void main(String[]args){
//编译器将会提示"Unreachable code"
while(false){
System.out.println("");
}
}
Java 语言中条件编译的实现,也是 Java 语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower 类中)完成。由于这种条件编译的实现方式使用了 if 语句,所以它必须遵循最基本的 Java 语法,只能写在方法体内部,因此它只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个 Java 类的结构。
除了本节中介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,Java 语言还有不少其他的语法糖,如内部类、枚举类、断言语句、对枚举和字符串的 switch 支持、try 语句中定义和关闭资源等,读者可以通过跟踪 Javac 源码、反编译 Class 文件等方式了解它们的本质实现,囿于篇幅,笔者就不再一一介绍了。
10.4 实战:插入式注解处理器
JDK 编译优化部分在本书中并没有设置独立的实战章节,因为我们开发程序,考虑的主要是程序会如何运行,很少会有针对程序编译的需求。也因为这个原因,在 JDK 的编译子系统里面,提供给用户直接控制的功能相对较少,除了第 11 章会介绍的虚拟机 JIT 编译的几个相关参数以外,我们就只有使用 JSR-296 中定义的插入式注解处理器 API 来对 JDK 编译子系统的行为产生一些影响。
但是笔者并不认为相对于前两部分介绍的内存管理子系统和字节码执行子系统,JDK 的编译子系统就不那么重要。一套编程语言中编译子系统的优劣,很大程度上决定了程序运行性能的好坏和编码效率的高低,尤其在 Java 语言中,运行期即时编译与虚拟机执行子系统非常紧密地互相依赖、配合运作。了解 JDK 如何编译和优化代码,有助于我们写出适合 JDK 自优化的程序。下面我们回到本章的实战中,看看插入式注解处理器 API 能实现什么功能。
10.4.1 实战目标
通过阅读 Javac 编译器的源码,我们知道编译器在把 Java 程序源码编译为字节码的时候,会对 Java 程序源码做各方面的检查校验。这些校验主要以程序“写得对不对”为出发点,虽然也有各种 WARNING 的信息,但总体来讲还是较少去校验程序“写得好不好”。有鉴于此,业界出现了许多针对程序“写得好不好”的辅助校验工具,如 CheckStyle、FindBug、Klocwork 等。这些代码校验工具有一些是基于 Java 的源码进行校验,还有一些是通过扫描字节码来完成,在本节的实战中,我们将会使用注解处理器 API 来编写一款拥有自己编码风格的校验工具:NameCheckProcessor。
当然,由于我们的实战都是为了学习和演示技术原理,而不是为了做出一款能媲美 CheckStyle 等工具的产品来,所以 NameCheckProcessor 的目标也仅定为对 Java 程序命名进行检查,根据《Java语言规范(第3版)》中第 6.8 节的要求,Java 程序命名应当符合下列格式的书写规范。
- 类:符合驼峰命名法,首字母大写。
- 方法:符合驼式命名法,首字母小写。
- 类或实例变量:符合驼式命名法,首字母小写。
- 常量:要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。
10.4.2 代码实现
要通过注解处理器 API 实现一个编译器插件,首先需要了解这组 API 的一些基本知识。
我们实现注解处理器的代码需要继承抽象类 javax.annotation.processing.AbstractProcessor
,这个抽象类中只有一个必须覆盖的 abstract 方法:“process()",它是 Javac 编译器在执行注解处理器代码时要调用的过程,我们可以从这个方法的第一个参数"annotations"中获取到此注解处理器所要处理的注解集合,从第二个参数"roundEnv"中访问到当前这个 Round 中的语法树节点,每个语法树节点在这里表示为一个 Element。在 JDK 1.6 新增的 javax.lang.model
包中定义了 16 类 Element,包括了 Java 代码中最常用的元素,如:
- PACKAGE
- ENUM
- CLASS
- ANNOTATION_TYPE
- INTERFACE
- ENUM_CONSTANT
- FIELD
- PARAMETER
- LOCAL_VARIABLE
- EXCEPTION_PARAMETER
- METHOD
- CONSTRUCTOR
- STATIC_INIT
- INSTANCE_INIT
- TYPE_PARAMETER
- OTHER
除了 process() 方法的传入参数之外,还有一个很常用的实例变量"processingEnv”,它是 AbstractProcessor 中的一个 protected 变量,在注解处理器初始化的时候(init() 方法执行的时候)创建,继承了 AbstractProcessor 的注解处理器代码可以直接访问到它。它代表了注解处理器框架提供的一个上下文环境,要创建新的代码、向编译器输出信息、获取其他工具类等都需要用到这个实例变量。
注解处理器除了 process() 方法及其参数之外,还有两个可以配合使用的 Annotations:@SupportedAnnotationTypes
和 @SupportedSourceVersion
,前者代表了这个注解处理器对哪些注解感兴趣,可以使用星号 *
作为通配符代表对所有的注解都感兴趣,后者指出这个注解处理器可以处理哪些版本的 Java 代码。
每一个注解处理器在运行的时候都是单例的,如果不需要改变或生成语法树的内容,process() 方法就可以返回一个值为 false 的布尔值,通知编译器这个 Round 中的代码未发生变化,无须构造新的 JavaCompiler 实例,在这次实战的注解处理器中只对程序命名进行检查,不需要改变语法树的内容,因此 process() 方法的返回值都是 false。关于注解处理器的 API,笔者就简单介绍这些,对这个领域有兴趣的读者可以阅读相关的帮助文档。下面来看看注解处理器 NameCheckProcessor 的具体代码,如代码清单 10-11 所示。
//可以用"*"表示支持所有Annotations
@SupportedAnnotationTypes("*")
//只支持JDK 1.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;
}
}
从上面代码可以看出,NameCheckProcessor 能处理基于 JDK 1.6 的源码,它不限于特定的注解,对任何代码都“感兴趣”,而在 process() 方法中是把当前 Round 中的每一个 RootElement 传递到一个名为 NameChecker 的检查器中执行名称检查逻辑,NameChecker 的代码如代码清单 10-12 所示。
/**
*程序名称规范的编译器插件:<br>
*如果程序命名不合规范, 将会输出一个编译器的WARNING信息
*/
public class NameChecker{
private final Messager messager;
NameCheckScanner nameCheckScanner=new NameCheckScanner();
NameChecker(ProcessingEnvironment processsingEnv){
this.messager=processsingEnv.getMessager();
}
/**
*对Java程序命名进行检查, 根据《Java语言规范(第3版)》第6.8节的要求, Java程序命名应当符合下列格式:
*
*<ul>
*<li>类或接口:符合驼式命名法, 首字母大写。
*<li>方法:符合驼式命名法, 首字母小写。
*<li>字段:
*<ul>
*<li>类、实例变量:符合驼式命名法, 首字母小写。
*<li>常量:要求全部大写。
*</ul>
*</ul>
*/
public void checkNames(Element element){
nameCheckScanner.scan(element);
}
/**
*名称检查器实现类, 继承了JDK 1.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;
}
}else
conventional=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;
}else
previousUpper=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 的代码看起来有点长,但实际上注释占了很大一部分,其实即使算上注释也不到 190 行。它通过一个继承于 javax.lang.model.util.ElementScanner6
的 NameCheckScanner 类,以 Visitor 模式来完成对语法树的遍历,分别执行 visitType()、visitVariable() 和 visitExecutable() 方法来访问类、字段和方法,这 3 个 visit 方法对各自的命名规则做相应的检查,checkCamelCase() 与 checkAllCaps() 方法则用于实现驼式命名法和全大写命名规则的检查。
整个注解处理器只需 NameCheckProcessor 和 NameChecker 两个类就可以全部完成,为了验证我们的实战成果,代码清单 10-13 中提供了一段命名规范的“反面教材”代码,其中的每一个类、方法及字段的命名都存在问题,但是使用普通的Javac编译这段代码时不会提示任何一个 Warning 信息。
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;
}
}
10.4.3 运行与测试
我们可以通过 Javac 命令的"-processor"参数来执行编译时需要附带的注解处理器,如果有多个注解处理器的话,用逗号分隔。还可以使用 -XprintRounds
和 -XprintProcessorInfo
参数来查看注解处理器运作的详细信息,本次实战中的 NameCheckProcessor
的编译及执行过程如代码清单 10-14 所示。
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(){
^
10.4.4 其他应用案例
NameCheckProcessor 的实战例子只演示了 JSR-269 嵌入式注解处理器 API 中的一部分功能,基于这组 API 支持的项目还有用于校验 Hibernate 标签使用正确性的Hibernate Validator Annotation Processor、自动为字段生成 getter 和 setter 方法的 Project Lombok 等,读者有兴趣的话可以参考它们官方站点的相关内容。
10.5 本章小结
在本章中,我们从编译器源码实现的层次上了解了 Java 源代码编译为字节码的过程,分析了 Java 语言中泛型、主动装箱/拆箱、条件编译等多种语法糖的前因后果,并实战练习了如何使用插入式注解处理器来完成一个检查程序命名规范的编译器插件。如本章概述中所说的那样,在前端编译器中,“优化”手段主要用于提升程序的编码效率,之所以把 Javac 这类将 Java 代码转变为字节码的编译器称做“前端编译器”,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于虚拟机内部的“后端编译器”完成了从字节码生成本地机器码的过程,即前面多次提到的即时编译器或JIT编译器,这个编译器的编译速度及编译结果的优劣,是衡量虚拟机性能一个很重要的指标。
4.11 - CH11-运行时优化
11.1 概述
在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT)。
即时编译器并不是虚拟机必需的部分,Java 虚拟机规范并没有规定 Java 虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。在本章中,我们将走进虚拟机的内部,探索即时编译器的运作过程。
由于 Java 虚拟机规范没有具体的约束规则去限制即时编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现相关的内容,如无特殊说明,本章提及的编译器、即时编译器都是指 HotSpot 虚拟机内的即时编译器,虚拟机也是特指 HotSpot 虚拟机。不过,本章的大部分内容是描述即时编译器的行为,涉及编译器实现层面的内容较少,而主流虚拟机中即时编译器的行为又有很多相似和相通之处,因此,对其他虚拟机来说也具有较高的参考意义。
11.2 HotSpot VM 内的即时编译器
在本节中,我们将要了解 HotSpot 虚拟机内的即时编译器的运作过程,同时,还要解决以下几个问题:
- 为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?
- 为何 HotSpot 虚拟机要实现两个不同的即时编译器?
- 程序何时使用解释器执行?何时使用编译器执行?
- 哪些程序代码会被编译为本地代码?如何编译为本地代码?
- 如何从外部观察即时编译器的编译过程和编译结果?
11.2.1 解释器与编译器
尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行(部分没有解释器的虚拟机中也会采用不进行激进优化的 C1 编译器担任“逃生门”的角色),因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作,如图 11-1 所示。
HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Compiler 和 Server Compiler,或者简称为 C1 编译器和 C2 编译器(也叫Opto编译器)。目前主流的 HotSpot 虚拟机(Sun系列JDK 1.7及之前版本的虚拟机)中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用"-client"或"-server"参数去强制指定虚拟机运行在 Client 模式或 Server 模式。
无论采用的编译器是 Client Compiler 还是 Server Compiler,解释器与编译器搭配使用的方式在虚拟机中称为“混合模式”,用户可以使用参数"-Xint"强制虚拟机运行于“解释模式”,这时编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数"-Xcomp"强制虚拟机运行于“编译模式”,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程,可以通过虚拟机的"-version"命令的输出结果显示出这3种模式,如代码清单 11-1 所示,请注意黑体字部分。
C:\>java-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal, 19.0-b04-internal,mixed mode)
C:\>java-Xint-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal, 19.0-b04-internal,interpreted mode)
C:\>java-Xcomp-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal, 19.0-b04-internal,compiled mode)
由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机还会逐渐启用分层编译的策略,分层编译的概念在 JDK 1.6 时期出现,后来一直处于改进阶段,最终在 JDK 1.7 的 Server 模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 0,程序解释执行,解释器不开启性能监控功能(Profiling),可触发第 1 层编译。
- 1,也称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
- 2,也称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
实施分层编译后,Client Compiler 和 Server Compiler 将会同时工作,许多代码都可能会被多次编译,用 Client Compiler 获取更高的编译速度,用 Server Compiler 来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。
11.2.2 编译对象与触发条件
上文中提到过,在运行过程中会被即时编译器编译的“热点代码”有两类,即:
- 被多次调用的方法。
- 被多次执行的循环体。
前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。而后者则是为了解决一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体的问题,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。
对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。
读者可能还会有疑问,在上面的文字描述中,无论是“多次执行的方法”,还是“多次执行的代码块”,所谓“多次”都不是一个具体、严谨的用语,那到底多少次才算“多次”呢?还有一个问题,就是虚拟机如何统计一个方法或一段代码被执行过多少次呢?解决了这两个问题,也就回答了即时编译被触发的条件。
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种,分别如下。
基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。
在 HotSpot 虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
我们首先来看看方法调用计数器。顾名思义,这个计数器就用于统计方法被调用的次数,它的默认阈值在 Client 模式下是 1500 次,在 Server 模式下是 10 000 次,这个阈值可以通过虚拟机参数 -XX:CompileThreshold
来人为设定。当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。整个 JIT 编译的交互过程如图 11-2 所示。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay
来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用 -XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒。
现在我们再来看看另外一个计数器——回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。
关于回边计数器的阈值,虽然 HotSpot 虚拟机也提供了一个类似于方法调用计数器阈值 -XX:CompileThreshold
的参数 -XX:BackEdgeThreshold
供用户设置,但是当前的虚拟机实际上并未使用此参数,因此我们需要设置另外一个参数 -XX:OnStackReplacePercentage
来间接调整回边计数器的阈值,其计算公式如下。
虚拟机运行在 Client 模式下,回边计数器阈值计算公式为:方法调用计数器阈值(CompileThreshold) × OSR 比率(OnStackReplacePercentage) /100。
其中 OnStackReplacePercentage 默认值为 933,如果都取默认值,那 Client 模式虚拟机的回边计数器的阈值为 13995。
虚拟机运行在 Server 模式下,回边计数器阈值的计算公式为:
方法调用计数器阈值(CompileThreshold) × (OSR比率(OnStackReplacePercentage) - (解释器监控比率(InterpreterProfilePercentage) / 100。
其中 OnStackReplacePercentag e默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那 Server 模式虚拟机回边计数器的阈值为 10700。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加 1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图 11-3 所示。
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
最后需要提醒一点,图 11-2 和图 11-3 都仅仅描述了 Client VM 的即时编译方式,对于 Server VM 来说,执行情况会比上面的描述更复杂一些。从理论上了解过编译对象和编译触发条件后,我们再从 HotSpot 虚拟机的源码中观察一下,在 MethodOop.hpp (一个methodOop对象代表了一个Java方法)中,定义了 Java 方法在虚拟机中的内存布局,如下所示:
11.2.3 编译过程
在默认设置下,无论是方法调用产生的即时编译请求,还是 OSR 编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过参数 -XX:-BackgroundCompilation
来禁止后台编译,在禁止后台编译后,一旦达到 JIT 的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。
那么在后台执行编译的过程中,编译器做了什么事情呢?Server Compiler 和 Client Compiler 两个编译器的编译过程是不一样的。对于 Client Compiler 来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion, HIR)。HIR 使用静态单分配(Static Single Assignment, SSA)的形式来代表代码值,这可以使得一些在 HIR 的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成 HIR 之前完成。
在第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation, LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等,以便让 HIR 达到更高效的代码表示形式。
最后阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器代码。Client Compiler 的大致执行过程如图 11-4 所示。
而 Server Compiler 则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到 GNU C++ 编译器使用 -O2 参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除、空值检查消除等。另外,还可能根据解释器或 Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等。本章的下半部分将会挑选上述的一部分优化手段进行分析和讲解。
Server Compiler 的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译的标准来看,Server Compiler 无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler 编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用 Server 模式的虚拟机运行。
在本节中,涉及了许多编译原理和代码优化中的概念名词,没有这方面基础的读者,阅读起来会感觉到抽象和理论化。有这种感觉并不奇怪,JIT 编译过程本来就是一个虚拟机中最体现技术水平也是最复杂的部分,不可能以较短的篇幅就介绍得很详细,另外,这个过程对 Java 开发来说是透明的,程序员平时无法感知它的存在,还好 HotSpot 虚拟机提供了两个可视化的工具,让我们可以“看见” JIT 编译器的优化过程,在稍后笔者将演示这个过程。
11.2.4 查看及分析即时编译结果
一般来说,虚拟机的即时编译过程对用户程序是完全透明的,虚拟机通过解释执行代码还是编译执行代码,对于用户来说并没有什么影响(执行结果没有影响,速度上会有很大差别),在大多数情况下用户也没有必要知道。但是虚拟机也提供了一些参数用来输出即时编译和某些优化手段(如方法内联)的执行状况,本节将介绍如何从外部观察虚拟机的即时编译行为。
本节中提到的运行参数有一部分需要 Debug 或 FastDebug 版虚拟机的支持,Product 版的虚拟机无法使用这部分参数。如果读者使用的是根据本书第 1 章的内容自己编译的 JDK,注意将 SKIP_DEBUG_BUILD 或 SKIP_FASTDEBUG_BUILD 参数设置为 false,也可以在 OpenJDK 网站上直接下载 FastDebug 版的 JDK。注意,本节中所有的测试都基于代码清单 11-2 所示的 Java 代码。
public static final int NUM=15000;
public static int doubleValue(int i){
//这个空循环用于后面演示JIT代码优化过程
for(int j=0;j<100000;j++);
return i*2;
}
public static long calcSum(){
long sum=0;
for(int i=1;i<=100;i++){
sum+=doubleValue(i);
}
return sum;
}
public static void main(String[]args){
for(int i=0;i<NUM;i++){
calcSum();
}
}
首先运行这段代码,并且确认这段代码是否触发了即时编译,要知道某个方法是否被编译过,可以使用参数 -XX:+PrintCompilation
要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来,如代码清单 11-3 所示(其中带有“%”的输出说明是由回边计数器触发的 OSR 编译)。
VM option'+PrintCompilation'
310 1 java.lang.String:charAt(33 bytes)
329 2 org.fenixsoft.jit.Test:calcSum(26 bytes)
329 3 org.fenixsoft.jit.Test:doubleValue(4 bytes)
332 1%org.fenixsoft.jit.Test:main@5(20 bytes)
从代码清单 11-3 输出的确认信息中可以确认 main()、calcSum() 和 doubleValue() 方法已经被编译,我们还可以加上参数 -XX:+PrintInlining
要求虚拟机输出方法内联信息,如代码清单 11-4 所示。
VM option'+PrintCompilation'
VM option'+PrintInlining'
273 1 java.lang.String:charAt(33 bytes)
291 2 org.fenixsoft.jit.Test:calcSum(26 bytes)
@9 org.fenixsoft.jit.Test:doubleValue inline(hot)
294 3 org.fenixsoft.jit.Test:doubleValue(4 bytes)
295 1%org.fenixsoft.jit.Test:main@5(20 bytes)
@5 org.fenixsoft.jit.Test:calcSum inline(hot)
@9 org.fenixsoft.jit.Test:doubleValue inline(hot)
从代码清单 11-4 的输出中可以看到方法 doubleValue() 被内联编译到 calcSum() 中,而 calcSum() 又被内联编译到方法 main() 中,所以虚拟机再次执行 main() 方法的时候(举例而已,main()方法并不会运行两次),calcSum() 和 doubleValue() 方法都不会再被调用,它们的代码逻辑都被直接内联到 main() 方法中了。
除了查看哪些方法被编译之外,还可以进一步查看即时编译器生成的机器码内容,不过如果虚拟机输出一串 0 和 1,对于我们的阅读来说是没有意义的,机器码必须反汇编成基本的汇编语言才可能被阅读。虚拟机提供了一组通用的反汇编接口,可以接入各种平台下的反汇编适配器来使用,如使用 32 位 80x86 平台则选用 hsdis-i386 适配器,其余平台的适配器还有 hsdis-amd64、hsdis-sparc 和 hsdis-sparcv9 等,可以下载或自己编译出反汇编适配器,然后将其放置在 JRE/bin/client 或 /server 目录下,只要与 jvm.dll 的路径相同即可被虚拟机调用。在为虚拟机安装了反汇编适配器之后,就可以使用 -XX:+PrintAssembly
参数要求虚拟机打印编译方法的汇编代码了,具体的操作可以参考本书 4.2.7 节。
如果没有 HSDIS 插件支持,也可以使用 -XX:+PrintOptoAssembly
(用于Server VM)或 -XX:+PrintLIR
(用于Client VM) 来输出比较接近最终结果的中间代码表示,代码清单 11-2 被编译后部分反汇编(使用 -XX:+PrintOptoAssembly
)的输出结果如代码清单 11-5 所示。从阅读角度来说,使用 -XX:+PrintOptoAssembly
参数输出的伪汇编结果包含了更多的信息(主要是注释),利于阅读并理解虚拟机 JIT 编译器的优化结果。
……
000 B1:#N1<-BLOCK HEAD IS JUNK Freq:1
000 pushq rbp
subq rsp, #16#Create frame
nop#nop for patch_verified_entry
006 movl RAX,RDX#spill
008 sall RAX, #1
00a addq rsp, 16#Destroy frame
popq rbp
testl rax, [rip+#offset_to_poll_page]#Safepoint:poll for GC
……
前面提到的使用 -XX:+PrintAssembly
参数输出反汇编信息需要 Debug 或者 FastDebug 版的虚拟机才能直接支持,如果使用 Product 版的虚拟机,则需要加入参数 -XX:+UnlockDiagnosticVMOptions
打开虚拟机诊断模式后才能使用。
如果除了本地代码的生成结果外,还想再进一步跟踪本地代码生成的具体过程,那还可以使用参数 -XX:+PrintCFGToFile
(使用Client Compiler) 或 -XX:PrintIdealGraphFile
(使用Server Compiler) 令虚拟机将编译过程中各个阶段的数据(例如,对 C1 编译器来说,包括字节码、HIR 生成、LIR 生成、寄存器分配过程、本地代码生成等数据)输出到文件中。然后使用 Java HotSpot Client Compiler Visualizer(用于分析Client Compiler) 或 Ideal Graph Visualizer(用于分析Server Compiler) 打开这些数据文件进行分析。以 Server Compiler 为例,笔者分析一下 JIT 编译器的代码生成过程。
Server Compiler 的中间代码表示是一种名为 Ideal 的 SSA 形式程序依赖图,在运行 Java 程序的 JVM 参数中加入 -XX:PrintIdealGraphLevel=2-XX:PrintIdealGraphFile=ideal.xml
,编译后将产生一个名为 ideal.xml 的文件,它包含了 Server Compiler 编译代码的过程信息,可以使用 Ideal Graph Visualizer 对这些信息进行分析。
Ideal Graph Visualizer 加载 ideal.xml 文件后,在 Outline 面板上将显示程序运行过程中编译过的方法列表,如图 11-5 所示。这里列出的方法是代码清单 11-2 中的测试代码,其中 doubleValue() 方法出现了两次,这是由于该方法的编译结果存在标准编译和 OSR 编译两个版本。在代码清单 11-2 中,笔者特别为 doubleValue() 方法增加了一个空循环,这个循环对方法的运算结果不会产生影响,但如果没有任何优化,执行空循环会占用 CPU 时间,到今天还有许多程序设计的入门教程把空循环当做程序延时的手段来介绍,在 Java 中这样的做法真的能起到延时的作用吗?
展开方法根节点,可以看到下面罗列了方法优化过程的各个阶段(根据优化措施的不同,每个方法所经过的阶段也会有所差别)的 Ideal 图,我们先打开"After Parsing"这个阶段。上文提到,JIT 编译器在编译一个 Java 方法时,首先要把字节码解析成某种中间表示形式,然后才可以继续做分析和优化,最终生成代码。“After Parsing"就是 Server Compiler 刚完成解析,还没有做任何优化时的 Ideal 图表示。在打开这个图后,读者会看到其中有很多有颜色的方块,如图 11-6 所示。每一个方块就代表了一个程序的基本块,基本块的特点是只有唯一的一个入口和唯一的一个出口,只要基本块中第一条指令执行了,那么基本块内所有执行都会按照顺序仅执行一次。
代码清单 11-2 的 doubleValue() 方法虽然只有简单的两行字,但是按基本块划分后,形成的图形结构要比想象中复杂得多,这一方面是要满足 Java 语言所定义的安全需要(如类型安全、空指针检查)和 Java 虚拟机的运作需要(如 Safepoint 轮询),另一方面是由于有些程序代码中一行语句就可能形成好几个基本块(例如循环)。对于例子中的 doubleValue() 方法,如果忽略语言安全检查的基本块,可以简单理解为按顺序执行了以下几件事情:
- 程序入口,建立帧栈。
- 设置 j=0,进行 Safepoint 轮询,跳转到 4 的条件检查。
- 执行 j++。
- 条件检查,如果 j<100000,跳转到 3。
- 设置
i=i*2
,进行 SafePoint 检查,函数返回。
以上几个步骤,反映到 Ideal Graph Visualizer 的图上,就是如图 11-7 所示的内容。这样我们要看空循环是否优化,或者何时优化,只要观察代表循环的基本块是否消除,或者何时消除就可以了。
要观察到这一点,可以在 Outline 面板上右键点击"Difference to current graph”,让软件自动分析指定阶段与当前打开的Ideal图之间的差异,如果基本块被消除了,将会以红色显示。对"After Parsing"和"PhaseIdealLoop 1"阶段的 Ideal 图进行差异分析,发现在"PhaseIdealLoop 1"阶段循环操作被消除了,如图 11-8 所示,这也就说明空循环实际上是不会被执行的。
从"After Parsing"阶段开始,一直到最后的"Final Code"阶段,可以看到 doubleValue() 方法的 Ideal 图从繁到简的变迁过程,这也是 Java 虚拟机在尽力优化代码的过程。到了最后的"Final Code"阶段,不仅空循环的开销消除了,许多语言安全和 Safepoint 轮询的操作也一起消除了,因为编译器判断即使不做这些安全保障,虚拟机也不会受到威胁。
最后提醒一下读者,要输出 CFG 或 IdealGraph 文件,需要一个 Debug 版或 FastDebug 版的虚拟机支持,Product 版的虚拟机无法输出这些文件。
11.3 编译优化技术
Java 程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器之中,因此一般来说,即时编译器产生的本地代码会比 Javac 产生的字节码更加优秀。下面,笔者将介绍一些 HotSpot 虚拟机的即时编译器在生成代码时采用的代码优化技术。
11.3.1 优化技术概览
在 Sun 官方的 Wiki 上,HotSpot 虚拟机设计团队列出了一个相对比较全面的、在即时编译器中采用的优化技术列表(见表 11-1),其中有不少经典编译器的优化手段,也有许多针对 Java 语言(准确地说是针对运行在 Java 虚拟机上的所有语言)本身进行的优化技术,本节将对这些技术进行概括性的介绍,在后面几节中,再挑选若干重要且典型的优化,与读者一起看看优化前后的代码产生了怎样的变化。
上述的优化技术看起来很多,而且从名字看都显得有点“高深莫测”,虽然实现这些优化也许确实有些难度,但大部分技术理解起来都并不困难。为了消除读者对这些优化技术的陌生感,笔者举一个简单的例子,即通过大家熟悉的 Java 代码变化来展示其中几种优化技术是如何发挥作用的(仅使用Java代码来表示而已)。首先从原始代码开始,如代码清单 11-6 所示。
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y=b.get();
//……do stuff……
z=b.get();
sum=y+z;
}
首先需要明确的是,这些代码优化变换是建立在代码的某种中间表示或机器码之上,绝不是建立在 Java 源码之上的,为了展示方便,笔者使用了 Java 语言的语法来表示这些优化技术所发挥的作用。
代码清单 11-6 的代码已经非常简单了,但是仍有许多优化的余地。第一步进行方法内联,方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最靠前位置。内联后的代码如代码清单 11-7 所示。
public void foo(){
y=b.value;
//……do stuff……
z=b.value;
sum=y+z;
}
第二步进行冗余访问消除,假设代码中间注释掉的"dostuff……“所代表的操作不会改变 b.value 的值,那就可以把"z=b.value"替换为"z=y”,因为上一句"y=b.value"已经保证了变量 y 与 b.value 是一致的,这样就可以不再去访问对象 b 的局部变量了。如果把 b.value 看做是一个表达式,那也可以把这项优化看成是公共子表达式消除,优化后的代码如代码清单 11-8 所示。
public void foo(){
y=b.value;
//……do stuff……
z=y;
sum=y+z;
}
第三步我们进行复写传播,因为在这段程序的逻辑中并没有必要使用一个额外的变量"z",它与变量"y"是完全相等的,因此可以使用"y"来代替"z"。复写传播之后程序如代码清单 11-9 所示。
public void foo(){
y=b.value;
//……do stuff……
y=y;
sum=y+y;
}
第四步我们进行无用代码消除。无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此,它又形象地称为"Dead Code",在代码清单 11-9 中,“y=y"是没有意义的,把它消除后的程序如代码清单 11-10 所示。
public void foo(){
y=b.value;
//……do stuff……
sum=y+y;
}
经过四次优化之后,代码清单 11-10 与代码清单 11-6 所达到的效果是一致的,但是前者比后者省略了许多语句(体现在字节码和机器码指令上的差距会更大),执行效率也会更高。编译器的这些优化技术实现起来也许比较复杂,但是要理解它们的行为对于一个普通的程序员来说是没有困难的,接下来,我们将继续查看如下的几项最有代表性的优化技术是如何运作的,它们分别是:
- 语言无关的经典优化技术之一:公共子表达式消除。
- 语言相关的经典优化技术之一:数组范围检查消除。
- 最重要的优化技术之一:方法内联。
- 最前沿的优化技术之一:逃逸分析。
11.3.2 公共子表达式消除
公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替 E 就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。举个简单的例子来说明它的优化过程,假设存在如下代码:
int d=(c * b)*12+a+(a+b * c);
如果这段代码交给 Javac 编译器则不会进行任何优化,那生成的代码将如代码清单 11-11 所示,是完全遵照 Java 源码的写法直译而成的。
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
当这段代码进入到虚拟机即时编译器后,它将进行如下优化:编译器检测到 c * b
与 b * c
是一样的表达式,而且在计算期间 b 与 c 的值是不变的。因此,这条表达式就可能被视为:
int d=E*12+a+(a+E);
这时,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化:代数化简,把表达式变为:
int d=E*13+a*2;
表达式进行变换之后,再计算起来就可以节省一些时间了。如果读者还对其他的经典编译优化技术感兴趣,可以参考《编译原理》中对应的章节。
11.3.3 数组边界检查消除
数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。我们知道 Java 语言是一门动态安全的语言,对数组的读写访问也不像 C、C++ 那样在本质上是裸指针操作。如果有一个数组 foo[]
,在 Java 语言中访问数组元素 foo[i]
的时候系统将会自动进行上下界的范围检查,即检查 i 必须满足 i>=0 && i<foo.length
这个条件,否则将抛出一个数组越界异常。这对软件开发者来说是一件很好的事情,即使程序员没有专门编写防御代码,也可以避免大部分的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。
无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界检查是不是必须在运行期间一次不漏地检查则是可以“商量”的事情。例如下面这个简单的情况:数组下标是一个常量,如 foo[3]
,只要在编译期根据数据流分析来确定 foo.length
的值,并判断下标“3”没有越界,执行的时候就无须判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间 [0,foo.length)
之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。
将这个数组边界检查的例子放在更高的角度来看,大量的安全检查令编写 Java 程序比编写 C/C++ 程序容易很多,如数组越界会得到数组越界异常,空指针访问会得到空指针异常,除数为零会得到除零异常等,在 C/C++ 程序中出现类似的问题,一不小心就会出现 Segment Fault 信号或者 Windows 编程中常见的“xxx内存不能为Read/Write”之类的提示,处理不好程序就直接崩溃退出了。但这些安全检查也导致了相同的程序,Java 要比 C/C++ 做更多的事情(各种检查判断),这些事情就成为一种隐式开销,如果处理不好它们,就很可能成为一个 Java 语言比 C/C++ 更慢的因素。要消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提到编译期完成的思路之外,另外还有一种避免思路——隐式异常处理,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()),这样当 foo 不为空的时候,对 value 的访问是不会额外消耗一次对 foo 判空的开销的。代价就是当 foo 真的为空时,必须转入到异常处理器中恢复并抛出 NPE,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。当 foo 极少为空的时候,隐式异常优化是值得的,但假如 foo 经常为空的话,这样的优化反而会让程序更慢,还好 HotSpot 虚拟机足够“聪明”,它会根据运行期收集到的 Profile 信息自动选择最优方案。
与语言相关的其他消除操作还有不少,如自动装箱消除、安全点消除、消除反射等,笔者就不再一一介绍了。
11.3.4 方法内联
在前面的讲解之中我们提到过方法内联,它是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,如代码清单 11-12 所示的简单例子就揭示了内联对其他优化手段的意义:事实上 testInline() 方法的内部全部都是无用的代码,如果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何"Dead Code”,因为如果分开来看,foo() 和 testInline() 两个方法里面的操作都可能是有意义的。
public static void foo(Object obj){
if(obj!=null){
System.out.println("do something");
}
}
public static void testInline(String[]args){
Object obj=null;
foo(obj);
}
方法内联的优化行为看起来很简单,不过是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上 Java 虚拟机中的内联过程远远没有那么简单,因为如果不是即时编译器做了一些特别的努力,按照经典编译原理的优化理论,大多数的 Java 方法都无法进行内联。
无法内联的原因其实在第 8 章中讲解 Java 方法解析和分派调用的时候就已经介绍过。只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法以及使用 invokestatic 指令进行调用的静态方法才是在编译期进行解析的,除了上述 4 种方法之外,其他的 Java 方法调用都需要在运行时进行方法接收者的多态选择,并且都有可能存在多于一个版本的方法接收者(最多再除去被 final 修饰的方法这种特殊情况,尽管它使用 invokevirtual 指令调用,但也是非虚方法,Java 语言规范中明确说明了这点),简而言之,Java 语言中默认的实例方法是虚方法。
对于一个虚方法,编译期做内联的时候根本无法确定应该使用哪个方法版本,如果以代码清单 11-7 中把"b.get()“内联为"b.value"为例的话,就是不依赖上下文就无法确定 b 的实际类型是什么。假如有 ParentB 和 SubB 两个具有继承关系的类,并且子类重写了父类的 get() 方法,那么,是要执行父类的 get() 方法还是子类的 get() 方法,需要在运行期才能确定,编译期无法得出结论。
由于 Java 语言提倡使用面向对象的编程方式进行编程,而 Java 对象的方法默认就是虚方法,因此 Java 间接鼓励了程序员使用大量的虚方法来完成程序逻辑。根据上面的分析,如果内联与虚方法之间产生“矛盾”,那该怎么办呢?是不是为了提高执行性能,就要到处使用 final 关键字去修饰方法呢?
为了解决虚方法的内联问题,Java 虚拟机设计团队想了很多办法,首先是引入了一种名为“类型继承关系分析”(CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。
编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是有稳定前提保障的。如果遇到虚方法,则会向 CHA 查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个“逃生门”(Guard条件不成立时的Slow Path),称为守护内联。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。
如果向 CHA 查询出来的结果是有多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。
所以说,在许多情况下虚拟机进行的内联都是一种激进优化,激进优化的手段在高性能的商用虚拟机中很常见,除了内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。
11.3.5 逃逸分析
逃逸分析是目前 Java 虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示。
栈上分配:Java 虚拟机中,在 Java 堆上分配创建对象的内存空间几乎是 Java 程序员都清楚的常识了,Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
标量替换:标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java 中的对象就是最典型的聚合量。如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。
关于逃逸分析的论文在 1999 年就已经发表,但直到 Sun JDK 1.6 才实现了逃逸分析,而且直到现在这项优化尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是不能保证逃逸分析的性能收益必定高于它的消耗。如果要完全准确地判断一个对象是否会逃逸,需要进行数据流敏感的一系列复杂分析,从而确定程序各个分支执行时对此对象的影响。这是一个相对高耗时的过程,如果分析完后发现没有几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成逃逸分析。还有一点是,基于逃逸分析的一些优化手段,如上面提到的“栈上分配”,由于 HotSpot 虚拟机目前的实现方式导致栈上分配实现起来比较复杂,因此在 HotSpot 中暂时还没有做这项优化。
在测试结果中,实施逃逸分析后的程序在 MicroBenchmarks 中往往能运行出不错的成绩,但是在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)有所下降,所以在很长的一段时间里,即使是 Server Compiler,也默认不开启逃逸分析,甚至在某些版本(如 JDK 1.6 Update 18)中还曾经短暂地完全禁止了这项优化。
如果有需要,并且确认对程序运行有益,用户可以使用参数 -XX:+DoEscapeAnalysis
来手动开启逃逸分析,开启之后可以通过参数 -XX:+PrintEscapeAnalysis
来查看分析结果。有了逃逸分析支持之后,用户可以使用参数 -XX:+EliminateAllocations
来开启标量替换,使用 +XX:+EliminateLocks
来开启同步消除,使用参数 -XX:+PrintEliminateAllocations
查看标量的替换情况。
尽管目前逃逸分析的技术仍不是十分成熟,但是它却是即时编译器优化技术的一个重要的发展方向,在今后的虚拟机中,逃逸分析技术肯定会支撑起一系列实用有效的优化技术。
11.4 Java 与 C/C++ 的编译器对比
大多数程序员都认为 C/C++ 会比 Java 语言快,甚至觉得从 Java 语言诞生以来“执行速度缓慢”的帽子就应当扣在它的头顶,这种观点的出现是由于 Java 刚出现的时候即时编译技术还不成熟,主要靠解释器执行的 Java 语言性能确实比较低下。但目前即时编译技术已经十分成熟,Java 语言有可能在速度上与 C/C++ 一争高下吗?要想知道这个问题的答案,让我们从两者的编译器谈起。
Java 与 C/C++ 的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比,很大程度上也决定了 Java 与 C/C++ 的性能对比的结果,因为无论是 C/C++ 还是 Java 代码,最终编译之后被机器执行的都是本地机器码,哪种语言的性能更高,除了它们自身的 API 库实现得好坏以外,其余的比较就成了一场“拼编译器”和“拼输出代码质量”的游戏。当然,这种比较也是剔除了开发效率的片面对比,语言间孰优孰劣、谁快谁慢的问题都是很难有结果的争论,下面我们就回到正题,看看这两种语言的编译器各有何种优势。
Java 虚拟机的即时编译器与 C/C++ 的静态优化编译器相比,可能会由于下列这些原因而导致输出的本地代码有一些劣势:
第一,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点。
第二,Java 语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。从实现层面上看,这就意味着虚拟机必须频繁地进行动态检查,如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等。对于这类程序代码没有明确写出的检查行为,尽管编译器会努力进行优化,但是总体上仍然要消耗不少的运行时间。
第三,Java 语言中虽然没有 virtual 关键字,但是使用虚方法的频率却远远大于 C/C++ 语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于 C/C++ 语言,也意味着即时编译器在进行一些优化(如前面提到的方法内联)时的难度要远大于 C/C++ 的静态优化编译器。
第四,Java 语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局的优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。
第五,Java 语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。而 C/C++ 的对象则有多种内存分配方式,既可能在堆上分配,又可能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。另外,C/C++ 中主要由用户程序代码来回收分配的内存,这就不存在无用对象筛选的过程,因此效率上(仅指运行效率,排除了开发效率)也比垃圾收集机制要高。
上面说了一大堆 Java 语言相对 C/C++ 的劣势,不是说 Java 就真的不如 C/C++ 了,相信读者也注意到了,Java 语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些“拖后腿”的特性都为 Java 语言的开发效率做出了很大贡献。
何况,还有许多优化是 Java 的即时编译器能做而 C/C++ 的静态优化编译器不能做或者不好做的。例如,在 C/C++ 中,别名分析的难度就要远高于 Java。Java 的类型安全保证了在类似如下代码中,只要 ClassA 和 ClassB 没有继承关系,那对象 objA 和 objB 就绝不可能是同一个对象,即不会是同一块内存两个不同别名。
void foo(ClassA objA,ClassB objB){
objA.x=123;
objB.y=456;
//只要objB.y不是objA.x的别名, 下面就可以保证输出为123
print(objA.x);
}
确定了 objA 和 objB 并非对方的别名后,许多与数据依赖相关的优化才可以进行(重排序、变量代换)。具体到这个例子中,就是无须担心 objB.y 其实与 objA.x 指向同一块内存,这样就可以安全地确定打印语句中的 objA.x 为 123。
Java 编译器另外一个红利是由它的动态性所带来的,由于 C/C++ 编译器所有优化都在编译期完成,以运行期性能监控为基础的优化措施它都无法进行,如调用频率预测、分支频率预测、裁剪未被选择的分支等,这些都会成为 Java 语言独有的性能优势。
11.5 本章小结
第 10~11 两章分别介绍了 Java 程序从源码编译成字节码和从字节码编译成本地机器码的过程,Javac 字节码编译器与虚拟机内的 JIT 编译器的执行过程合并起来其实就等同于一个传统编译器所执行的编译过程。
本章中,我们着重了解了虚拟机的热点探测方法、HotSpot 的即时编译器、编译触发条件,以及如何从虚拟机外部观察和分析JIT编译的数据和结果,还选择了几种常见的编译期优化技术进行讲解。对 Java 编译器的深入了解,有助于在工作中分辨哪些代码是编译器可以帮我们处理的,哪些代码需要自己调节以便更适合编译器的优化。
4.12 - CH12-内存模型与线程
12.1 概述
多任务处理在现代计算机操作系统中几乎已经以必备功能了。在许多情况下,让计算机同时去做几件事情,不仅是因为计算机的运算能力变得强大了,还有一个很重要的原因是计算机的运算速度,与它的存储和通讯子系统的速度,两者之间的差距越来越大:大部分时间都花在了磁盘 IO、网络通讯和数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的状态,就必须使用一些手段去把处理器的运算能力压榨出来,否则就会造成很大的浪费,而让计算机同时处理几项任务则是最容易想到、也被证明是非常有效的“压榨”手段。
除了充分利用计算机处理器的能力之外,一个服务端同时对多个客户端提供服务则是另一个更加具体的并发应用场景。衡量一个服务的性能的高低好坏,每秒事务处理数(TPS)是最重要的指标之一,它代表着一秒内服务平均能够响应的请求总数,而 TPS 值与程序的并发能力又有着密切的关系。对于计算量相同的任务,程序并发协调的越有条不紊,效率自然就会越高;反之,线程之间频繁阻塞甚至死锁,将会大大降低程序的并发能力。
服务端是 Java 语言最擅长的领域之一,这个领域占有了 Java 应用中最大的一块份额,不过如何写好并发应用程序却是程序开发的难点之一,处理好并发方面的问题通常需要很多经验。幸好 Java 语言和虚拟机提供了很多工具,把并发编程的门槛降低了不少。另外,各种中间件服务器、各类框架都努力的替程序员处理尽可能多的线程并发细节,使得程序员在编码时能更关注业务逻辑,而不是花费大部分时间来关注服务会同时被多少人调用。但是无论语言、中间件和框架如何先进,我们都不能期望它们能够独立完成并发处理的所有事情,了解并发的内幕也是称为一个高级程序员不可缺少的课程。
12.2 硬件效率与一致性
在正式讲解 JVM 并发相关的知识之前,我们先花费一点时间来了解物理计算机中的并发问题,物理机遇到的并发问题与 JVM 中的情况有不少相似之处,物理机解决并发的方式对 JVM 的实现由很大的参考意义。
“让计算机并发执行多个运算任务”与“充分利用计算机处理器的效能”之间的因果关系看起来顺理成章,实际上并没有想象中那么容易实现,因为所有的运算任务都不可能仅靠处理器的“计算”就能完成,至少要与内存交互,如读取运算数据、存储运算结果等,仅靠寄存器也无法实现。由于计算机存储设备的访问速度与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为处理器与内存之间的缓冲:将运算中用到的数据复制到缓存中以加速运算,当运算结束后在从缓存同步回内存,以避免处理器等待缓慢的内存访问。
基于高速缓存的存储交互很好的解决了处理器与内存之间的速度矛盾,但是也引入了新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又都共享同一内存,如下图所示。当多个处理器的运算任务都涉及同一块内存区域时,将可能导致搁置的缓存数据不一致,如果真的发生这种情况,将各个处理器缓存中的数据同步回内存时又以谁的缓存为准呢?为了解决一致性问题,需要各个处理器访问缓存时都遵循一定的协议,在读写时要根据协议来进程操作,这类协议有 MSI、MESI、MOSI、Firefly、Dragon Protocol 等。JVM 内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的。
除此之外,为了使得处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结构一致,但并不保证程序中各个语句计算的顺序与代码输入时的顺序一致,因此如果一个计算任务依赖于另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,JVM 的即时编译器中也有类似的指令重排优化。
12.3 Java 内存模型
JVM 规范中试图定义一种内存模型(JMM,JSR-133)来屏蔽掉各种硬件和操作系统在内存访问上的差异,以实现 Java 程序在各种平台下都保持一致的并发效果。在此之前,主流语言(如 C/C++)直接使用物理硬件(或者说是操作系统的内存模型),因此,会由于不同平台的内存差异导致程序在一些平台上的并发效果正常,但在另一些平台上出现非预期的并发效果,因此需要经常针对不同的平台来编写程序。
定义 JMM 并非易事,该模型必须定义的足够严谨,才能让 Java 的并发操作不会产生歧义:但是,也必须定义的足够宽松,使得虚拟机的实现能够由足够的自由空间去利用硬件的各种特性(如寄存器、高速缓存等)来获得更好的执行速度。经过长时间的验证和修补,在 JDK 1.5(实现了 JSR-133)发布后,JMM 就已经成熟和完善起来了。
12.3.1 主内存与工作内存
JMM 的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里所说的变量与 Java 编程中所说的变量略有区别,它包括实例字段、静态字段和构造数组对象的元素,不包括局部变量和方法参数,因为后者是“线程私有”的,不会在线程间共享,自然也就不存在竞争问题。为了获得更好的执行效能,JMM 并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制 JIT 调整代码执行顺序的这类权利。
线程私有:如果局部变量是一个引用类型,它引用的对象在堆中可被各个线程共享,但是引用本身位于 Java 栈的局部变量表中,它是线程私有的。
JMM 规定了所有的变量都存储在主内存中(这里是 JVM 内存的一部分,类似于物理机中的内存)。每条线程还有自己的工作内存(类比于 CPU 的高速缓存,但属于线程所有),线程的工作内存中保存了被该线程使用到的变量,这些变量是来自主内存的副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写“主内存中的变量”。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存之间的交互关系如下图。
主内存中的变量:根据 JVM 规范,volatile 变量依然用工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如图直接操作主内存一样。但 volatile 并不例外。
这里所讲的主内存、工作内存与本书第二章所讲的 Java 内存区域中的 Java 堆、栈、方法区等并非同一层次的内存划分。如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要是对应于 Java 堆中对象的实例数据部分,而工作内存则对应于 JVM 栈中的部分区域。从更低的层次来说,主内存就是硬件的内存,而为了获得更好的运行速度,JVM 及硬件系统可能会让工作内存优先存储在寄存器或高速缓存中。
12.3.2 内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,JMM 中定义了以下 8 种“操作”来完成:
- lock:作用于主内存中的变量,将变量标识为被一条线程独占。
- unlock:作用于主内存中的变量,将被标识为被一条线程独占状态的变量从该线程释放,释放后该变量可以被其他线程 lock。
- read:作用于主内存中的变量,将变量的值从主内存传输到工作内存,以便后续的 load 使用。
- load:作用于工作内存中的变量,将 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
- use:作用于工作内存中的变量,将工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将执行该操作。
- assign:作用于工作内存中的变量,将一个从执行引擎接收到的值赋值给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时将执行该操作。
- store:作用于主内存中的变量,将工作内存中一个变量的值传送到主内存,以便随后的 write 操作使用。
- write:作用于主内存中的变量,将 store 操作从工作内存中得到的变量值放入主内存的变量中。
操作:实现虚拟机时必须保证每一种“操作”都是原子的、不可再分的。对于 double 和 long 类型的变量来说,在操作主内存时会出现字撕裂。
如果要把一个变量从主内存复制到工作内存,就要按顺序执行 read、load 操作;如果要把变量从工作内存同步回主内存,就要按顺序执行 store、write 操作。注意,JMM 只要求上述两个操作必须按顺序执行,而没有要求必须是连续执行,即 read 和 load 之间、store 和 write 之间是可以插入其他指令的(如对另外一些变量的存取操作指令)。除此之外,JMM 还规定了在执行上述 8 种基本操作时必须满足以下规则:
- read 和 laod、store 和 write 必须成对出现。
- 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无故(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存。
- 一个新的变量必须在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load/assign)的变量,即,对一个变量实施 use 和 store 操作之前,必须已经执行过 assign 和 load 操作。
- 一个变量在同一时刻仅允许一个线程对其进行 lock,但 lock 可以被同一线程重复执行多次,多次执行 lock 之后,只有执行相同次数的 unlock 才能完成解锁。
- 如果对一个变量执行 lock,将会清空工作内存中该变量的值而直接使用主内存中该变量的值(lock 引用在主内存),在执行引擎使用该变量前,需要重新执行 load 或 assign 来初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对其执行 unlock;也不允许 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 前,必须先把此变量同步回主内存。
12.3.3 volatile 变量的特殊规则
关键字 volatile 可以说是 JVM 提供的最轻量级的同步机制,但是它并不容易被正确完整的理解。
当一个变量被定义为 volatile,它将具备两种特性,第一是保证此变量对所有线程可见:一旦一条线程修改了该变量,新值对于所有其他线程来说是可以立即得知的。而普通变量无法做到这一点,变量值在线程间的传递需要通过主内存来完成,如:线程 A 修改一个普遍变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成之后再从主内存读取该变量的新值,这时新值才对线程 B 可见。
这种可见性经常被误解,认为以下描述成立:“volatile 对所有线程是立即可见的,对 volatile 变量的所有写操作都能立即反应到其他线程之中,换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是安全的”。这句话的论据部分没有错,但是其论据并不能得出“基于 volatile 变量的运算在并发下是安全的”这个结论。volatile 变量在各个线程的工作内存中不存在一致性的问题(在各个线程的工作内存中 volatile 变量也会存在不一致的情况,由于每次使用 volatile 变量之前都会先进行刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),“但是 Java 中的运算并非原子操作”,导致 volatile 变量的运算在并发下变得不安全。下面看一个例子。
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);
}
}
如果上述代码正确的话,最终的输出结果应该是 200000。但实际的运行结果并非如此。问题就出现在自增运算 race++
之中,我们用 javap 反编译这段代码后会得到下面的代码清单,发现只有一行代码的 increase() 方法在 Class 文件中是由 4 条字节码指令构成的,从字节码层面很容易就分析出并发失败的原因了:当 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
实事求是的说,笔者在此使用字节码来分析并发问题,仍然是不严谨的,因为即便编译出来只有一条字节码指令,也并不意味着执行这条指令就是原子操作。一条字节码指令在解释执行时,解释器将要运行许多代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转换成若干条本地机器码指令,此处使用 -XX:+PrintAssembly 参数输出反汇编来分析会更加严谨一些,但是考虑到读者阅读的方便,并且字节码已经能够说明问题,所以此处使用字节码来分析。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(会用 synchronized 或 JUC 中的原子类)来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够保证只有单一的线程修改变量的值。
- 变量不需要与其他状态变量共同参与不变约束。
而在像如下代码清单中所示的这类场景就很适合使用 volatile 变量来控制并发,当 shutdown() 方法被调用时,能够保证所有线程中执行的 doWork() 方法都立即停下来。
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while(!shutdownRequested) {
// do stuff
}
}
使用 volatile 变量的第二个语义是禁止指令重排序优化,普通变量仅仅会保证在该方法执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是 JMM 中描述的所谓“线程内表现为串行的语义”。
如下伪代码所示:
Map configOptions;
char[] configText;
// 此时变量必须定义为 volatile
volatile boolean initialized = false;
// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后
// 将 initialized 设置为 true 来通知其他线程配置已经可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
inintialized = true;
// 假设以下代码在线程 B 中执行
// 等待 initialized 为 true,代表线程 A 已经把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用线程 A 初始化好的配置信息
doSomethingWithConfig();
以上描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义 initialized 变量时没有使用 volatile 修饰,就可能由于指令重排的优化,导致位于线程 A 中最后一句的代码“initialized = true”被提前执行,这样在线程 B 中使用配置信息的代码就可能出现问题,而 volatile 关键字则可以避免此类请求的发生。
解决了 volatile 语义的问题,再来看看在众多保障并发安全的工具中选用 volatile 的意义——它能让我们的代码比使用其他同步工具时更快吗?确实在某些情况下,volatile 同步机制的性能要优于锁(即 synchronized 或 JUC 中的锁),但是由于虚拟机对锁实行的很多消除和优化,使得我们很难量化的说 volatile 就是会比 synchronized 快上多少。如果让 volatile 自己与自己比较,则可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作可能会满上一些,因为它需要在本地代码中插入许多内存屏障(Memory Barrier)指令来保证处理器不会发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁低,我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。
本节的最后,我们再来看看 JMM 中对 volatile 变量定义的特殊规则。假设 T 表示一个线程,V 和 W 分别表示两个 volatile 变量,那么在执行 read、load、use、assign、store、write 操作时需要满足如下规则:
- 只有线程 T 对变量 V 执行的前一个动作是 load 的时候,T 才能对 V 执行 use 操作;并且,只有当 T 对 V 执行的后一个动作是 use 时,T 才能对 V 执行 load 动作。T 对 V 的 use 动作可以认为是与 T 对变量 V 的 load 和 read 动作是相关联的,必须一起“连续出现”。(该规则要求在工作内存中,每次使用 V 之前都先从主内存刷新最新值,用于保证能看见其他线程对 B 执行修改后的值)
- 只有当线程 T 对变量 V 执行的前一个操作是 assign 时,T 才能对 V 执行 store 操作;并且,只有当 T 对 V 执行的后一个动作是 store 时,T 才能对 V 执行 assign 操作。T 对 V 的 assign 动作可以认为是 T 对 V 的 store 和 write 动作相关联的,必须一起连续出现。(该规则要求在工作内存中,每次修改 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。(这条规则要求 volatile 修饰的变量不会被指令重排序优化,保证代码执行的顺序与程序代码的顺序相同)
12.3.4 对于 long 和 double 型变量的特殊规则
JMM 要求 lock、unlock、read、load、assign、use、store、write 这 8 种操作都具有原子性,但是对于 64 位的数据类型,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证数据类型为 long 和 double 的非原子性协定。
如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读到一个即非原值又非其他线程修改值的“半个变量”数值。
不过这种读取到“半个变量”的情况非常罕见,因为 JMM 虽然允许虚拟机不把 long 和 double 变量的读写操作实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这么做。在实际开发中,目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作实现为原子操作,因此我们在编写代码时一般不需要把 long 的 double 变量专门声明为 volatile。
12.3.5 原子性、可见性、有序性
介绍完 JMM 相关的操作和规则,我们再整体回顾一下该模型的特征。JMM 是围绕着在并对过程中如何处理原子性、可见性、有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性。
“原子性(Atomicity)”:由 JMM 来直接保证的原子性变量操作包括 read、load、assign、use、store、write 这 6 个,我们大致认为基本数据类型的访问读写是具备原子性的(long 和 double 除外)。
如果应用场景需要一个更大范围的原子性保证,JMM 还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式的使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
“可见性(Visibility)”:可见性就是指当一个线程修改了共享变量的值,其他线程能够立即获得这些最新的值。上文在讲解 volatile 变量的时候我们已经详细讨论过这一点。JMM 是通过在变量修改后将新值同步回主内存、在变量去读前从主内存刷新变量值这个种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与 volatile 变量的区别在于 volatile 的特殊规则保证了新值能够立即同步主内存,以及每次使用前立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了 volatile 之外,Java 还提供了两个关键字实现可见性,即 synchronized 和 final。同步块的可见性是由“对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中(store & write)”这条规则获得的;而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this 引用逸出是一件很危险的事情,其他线程可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看到final 字段的值。如下代码所示,变量 i 与 j 都具备可见性,它们无需同步就能被其他线程正确的访问。
public static final int i;
public finl int j;
static {
i = 0;
j = 0; // 也可以选择则构造函数中初始化 j
}
“有序性(Ordering)”:JMM 的有序性前面讲解 volatile 时也详细讨论过,Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一时刻仅允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。
介绍完并发的三种重要特性,读者有没有发现 synchronized 关键字在需要这三种特性时都可以作为一种解决方案?看起来是万能的?的确,大部分的开发控制操作都能通过使用 synchronized 来完成。synchronized 的“万能”间接造就了它被开发者滥用的局面,越“万能”的并发控制,通常也伴随着越大的性能影响。
12.3.6 先行发生(happens-before)原则
如果 JMM 中所有的有序性都只靠 volatile 和 synchronized 来完成,那么有一些操作就会变得很啰嗦,但是我们在编写 Java 并发代码的时候并没有感觉到这一点,这是因为 Java 语言中有一个“先行发生”原则。该原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据。基于该原则,我们可以通过几条规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题。
先行发生是 JMM 中定义的两项操作之间的偏序关系,如果说操作 A 先行发生于操作 B,其实就是说在发生 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括对内存中共享变量值的修改、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们可以举个例子来说明,如下所示的三行伪代码:
// 线程 A
i = 1;
// 线程 B
j = i;
// 线程 C
i = 2;
假设线程 A 中的操作线性发生于线程 B 中的操作,那么我们就可以确定在线程 B 的操作执行之后,变量 j 的值一定为 1。得出该结论的依据有两个,一是根据现行发生原则,“i=1”的结果可以被观察到;二是线程 C 登场之前,线程 A 操作结束之后没有其他线程会修改变量 i 的值。现在再来考虑线程 C,我们依然保持线程 A 和 B 之前的线性发生关系,而 C 出现在线程 A 和 B 的操作之间,但是 C 与 B 没有先行发生关系,那么 j 的值会是多少呢?答案是不确定。1 或是 2,因为线程 C 对变量 i 的影响可能会被线程 B 观察到,也可能不会,这时候线程 B 就存在读取到过期数据的风险,不具备多线程安全性。
下面是 JMM 中一些“天然的”线性发生关系,这些线性发生关系无需任何同步协助就已经存在,可以直接在编码时使用。如果两个操作之间的关系不在次列,并且也无法从下列规则中推导出来的话,它们就没有顺序性保证,虚拟机可以对他们进行随意重排。
- 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于发生在后面的操作。准确的说应该是控制流程序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管理锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样指的是时间上的先后顺序。
- 线程启动规则:Thread 对象的 start 方法先行发生于此线程内的所有动作。
- 线程终止规则:线程内的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。
- 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize 方法的开始。
- 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,则操作 A 先行发生于操作 C。
Java 语言无需任何同步手段保障就能成立的先行发生规则就只有上面这些了,笔者演示一下如何使用这些规则去判定操作间是否具有顺序性,对于读写共享变量的操作来说,就是线程是否安全,读者还可以从下面的例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。
private int value = 0;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
上述代码显示的是一组再普通不过的 getter/setter 方法,假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue(),那么线程 B 收到的返回值是什么?
我们依次分析一下先行发生规则,由于两个方法分别是由线程 A 和 B 调用,不再一个线程中,所以程序次序在这里不适用;由于没有同步块,自然就不会发生 lock 和 unlock 操作,所以管理程序规则不适用;由于 value 变量没有被 volatile 修饰,所以 volatile 变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和代码中的操作扯不上关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程 A 的操作在时间上先于线程 B,但是无法确定 B 中 getValue 的返回结果,换句话说,这里的操作不是线程安全的。
那么如何修复这个问题呢?我们至少有两种比较简单的方案可供选择:呀么把 getter/setter 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则;要么把 value 定义为 volatile 变量,由于 setter 方法对 value 的修改不依赖于 value 的原值,满足了 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生关系。
通过上面的例子,我们可以得出一个结论:一个操作“时间上先行发生”不代表这个操作会是“先行发生”,那么如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的场景就是多次提到的“指令重排序”,如下代码所示:
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;
根据程序次序规则,第一行的操作先先行发生于第二行的操作,但是第二行代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程中没有办法感知到这一点。
上面两个例子综合得出一个结论:时间上的先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
12.4 Java 与线程
并发不一定要依赖多线程(如 PHP 中很常见的多进程并发),但是在 Java 里面谈论并发,大多数与线程脱不开关系。因此我们就从 Java 线程在虚拟机中的实现讲起。
12.4.1 线程的实现
我们知道,线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源(内存地址、文件 IO 等),又可以独立调度(线程是 CPU 调度的最基本单位)。
主流的操作系统都提供了线程实现,Java 语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个 java.lang.Thread 类的实例就代表了一个线程。不过 Thread 类与大部分的 Java API 有着显著的区别,它的所有关键方法都被实现为 Native。在 Java API 中一个 Native 方法可能意味着这个方法没有使用或无法使用平台无关的手段来实现(当然也可能为了执行效率而使用 Native 方法,不过通常最高效率的手段也就是平台相关的手段)。正因为如此,作者把本节标题定位“线程的实现”,而非“Java 线程的实现”。
实现线程主要有三种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。
1. 使用内核线程实现
内核线程(KLT)就是直接由操作系统内核支持的线程,这些线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程都可以看做是内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LWP)。轻量级进程就是我们通常意义上所说的线程,由于每个轻量级进程都有一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1:1 的冠以称为“一对一的线程模型”,如下图所示:
由于内核线程的支持,每个轻量级进程都称为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种进程操作,如创建、撤销及同步都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换。其次,每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
2. 使用用户线程实现
广义来讲,一个线程只要不是内核线程,就可以被认为是用户线程(UT),因此从这个定义上来讲轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核线程之上的,许多操作都需要使用系统调用,因此效率也会受限制。
而狭义上的用户线程值得是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之前是 1:N 的关系称为 “一对多的线程模型”,如下图:
使用用户线程的优势在于不需要内核支援,劣势在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换、调度都是需要考虑的问题,而且由于操作系统只能把处理器资源分配到进程级别,那些诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其它处理器上”这类问题解决起来都会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,处理以前在不支持多线程的操作系统中的多线程程序与少数具有特殊需求的程序之外,现在使用用户线程的程序越来越少了,Java、Ruby 等语言都曾经使用过用户线程,但最终都放弃了。
3. 混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现外,还有一种将内核线程与用户线程结合的实现方式。在这种混合实现中,既存在用户线程,也存在轻量级继承。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、销毁等操作依然廉价,并且可以支持大规模的用户级线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以利用内核提供的线程调度功能及处理器映射功能,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了进程被阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比例是不一定的,是 M:N 的关系,被称为“多对多线程模型”,如下图:
许多 UNIX 系列的操作系统,如 Solaris、HP-UX 等都提供了 M:N 的线程模型实现。
4. Java 线程的实现
Java 线程在 JDK 1.2 之前,是基于名为“绿色线程(Green Threads)” 的用户线程实现的,而在 JDK 1.2 中,线程模型被替换为基于操作系统原生线程模型来实现。因此在目前版本的 JDK 中,操作系统支持怎样的线程模型,在很大程度上决定了 JVM 虚拟机的线程是怎样的映射的,这点在不同平台上没有办法达成一致,虚拟机规范中也并未限定 Java 线程需要使用哪些线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对 Java 程序的编码和运行过程来说,这些差异都是透明的。
对于 Sun JDK 来说,它的 Windows 与 Linux 版都是使用一对一的线程模型来实现的,一条 Java 线程就映射到一条轻量级线程之中,因为 Windows 和 Linux 系统提供的线程模型就是一对一的(尽管存在一些多对多模型的实现,但没有成为主流)。
而在 Solaris 平台中,由于操作系统的线程特性可以同时支持一对一(通过 Bound Threads 或 Alternate Libthread)和多对多(通过 LMP/Thread Based Synchronization 实现)的线程模型,因此在 Solaris 版的 JDK 中也对应提供了两个平台专有的虚拟机参数:-XX:+UseLWPSynchronization(默认值) 和 -XX:+UseBoundThreads 来明确指定虚拟机要使用的线程模型。
12.4.2 Java 线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,协同式(Cooperative)和抢占式(Preemptive)。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情完成之后才会进行线程切换,切换操作对线程自己来说是可以预知的,所以没有什么线程同步的问题。Lua 语言中的“协同例程”就是这类实现。它的缺点很明显:线程执行时间不可控,甚至如果一个线程编写有问题,一直不告诉系统进行线程切换,那么程序会一直阻塞在那里。很久以前的 Windows 3.x 系统就是使用协同式来实现多进程多任务的,那是相当的不稳定,一个进程坚持不让出 CPU 执行时间就会导致整个系统崩溃。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在 Java 中,Thread.yield 可以让出执行时间,但是线程本身没办法主动获得执行时间)。在这种实现线程调度的方式下,线程的执行实现是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式调度。与前面所说的 Windows 3.x 的例子相对,在 Windows 9x/NT 内核中就是使用抢占式来实现的多进程,当一个进程出了问题,我们还可以使用任务管理器来把这个进程杀掉,而不至于导致系统崩溃。
虽然说 Java 多线程调度是由系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一些执行时间,另外的一些线程多则可以少分配一些——这项操作可以通过设置线程优先级来完成。Java 语言一共设置了 10 个级别的线程优先级,在两个线程同时处于 Read 状态时,优先级越高的线程越容易被系统选择执行。
但是线程优先级并不是很靠谱,原因是 Java 的线程是被映射到系统的原生线程(内核线程)上来实现的,所以线程调度最终还是由操作系统说了算,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与 Java 线程的优先级实现一一对应,如 Solaris 中有 2147483648 种优先级,但 Windows 中仅有 7 种,比 Java 线程优先级多的系统还好说,中间留下一点空位就是了,但比 Java 线程优先级少的系统,就不得不出现几个优先级相同的情况了,下表展示了 Java 线程优先级与 Window 线程优先级之间的对应关系,Windows 平台的 JDK 中使用了除 THREAD_PRIORITY_IDLE 之外的其余 6 种线程优先级。
上文说到“线程优先级并不是很靠谱”,不仅仅是说在一些平台上不同的优先级实际会变得相同这一点,还有其他情况让我们不能太依赖优先级:优先级可能会被系统自动改变。例如在 Windows 系统中存在一个名为“优先级推进器”的功能,它的大致作用即使当系统发现一个线程被执行的“特别勤奋努力”的话,可能会越过线程优先级来为其分配执行时间。因此我们不能在程序中通过优先级来完全准确的判断一组状态都为 Ready 的线程中哪个会先执行。
12.4.3 状态转换
Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态:
- New:创建后但尚未启动。
- Runnable:包括了操作系统线程状态的 Running 和 Ready,也就是处于这种状态的线程有可能在执行,也可能正在等着操作系统为其分配执行时间。
- Waiting:无限期等待,不会被分配 CPU 执行时间,需要等待被其他线程显式的唤醒。以下方法会让线程陷入 Waiting 状态:
- Object.wait()
- Thread.join()
- LockSupport.park()
- Timed Waiting:期限等待,不会被分配 CPU 执行时间,在一定时间后它们会由操作系统自动唤醒。以下方法会让线程陷入 Timed Waiting 状态:
- Thread.sleep()
- Object.wait(time)
- Thread.join(time)
- LockSupport.parkNanos(nanos)
- LockSupport.parkUntil(time)
- Blocked:线程被阻塞。“阻塞状态”与“等待状态”的区别是,“阻塞状态”在等着获得一个排他锁,这个事件将在另一个线程放弃该锁的时候发生;而“等待状态”则是在等待一段时间、发生唤醒动作。在程序等待进入同步区域的时候,线程将进入这种状态。
- Terminated:线程已被终止,线程已被结束执行。
上述 5 种状态在约到特定事件时会发生切换,它们之间的转换关系如下图:
12.5 总结
本章中,我们了解了虚拟机 JMM 的结构及操作,并讲解了原子性、可见性、有序性在 JMM 中的体现,介绍了先行发生原则的规则及应用。另外,我们还了解了线程在 Java 语言中的实现方式。
4.13 - CH13-线程安全与锁优化
13.1 概述
在软件业发展的初期,程序的编写都是以算法为核心,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式是直接站在计算机的角度去抽象和解决问题,称为面向过程的编程思想。于此相对,面向对象的思想则是站在现实世界的角度去抽象和解决问题,它把数据和行为都看作是对象的一部分,这样可以让程序员以符合现实世界的思维方式来编写和组织程序。
面向过程的编程思想极大的提升了现代软件开发的生产效率和软件可以达到的规模,但是现实世界与计算机世界之间不可避免的存在一些差异。例如,人们很难想象现实中的对象在一项工作进行期间,会被不停的中断可切换,对象的属性可能会在中断期间被修改和变脏,而这些事件在计算机世界中则是很正常的现象。有时候,良好的设计原则不得不向现实做出一些让步,我们必须让程序在计算机中正确无误的运行,然后在考虑如何将代码组织的更好,让程序运行的更快。对于这部分的主题“高效并发”来将,首先需要保证并发的正确性,然后在此基础上实现高效。
13.2 线程安全
“线程安全”这个名词,相信稍有经验的开发者都有听过,甚至在编写和走查代码的时候还会经常挂在嘴边,但是如何找到一个不太拗口的概念来定义线程安全却不是一件容易的事情。
笔者认为 “Java Concurrency In Practice” 的作者 Brian Goetz 对“线程安全”有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外同步,或者在调用方进行任务其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的”。
这个定义很严谨,它要求了线程安全的代码必须都具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无需关心多线程问题,更无须自己实现任何措施来保证多线程的正确调用。这点并不容易做到,在大多数场景中,我们都会将这个定义弱化一些,如果把“调用这个对象的行为”限定为“单次调用”,这个定义的其他描述也能成立的话,我们就可以称之为线程安全了。
13.2.1 Java 中的线程安全
为了更深入理解线程安全,我们可以不把线程安全当做一个非真即假的二元排他选项来看待,安装线程安全的“安全程度”由强至弱来排序,我们可以将 Java 语言中各种操作共享的数据分为以下五类:
1. 不可变
在 Java 语言里面,不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不再需要进行任何的线程安全保障措施,在上一章中我们谈到的 final 关键字带来的可见性时曾提到过这一点,只要一个不可变的对象被正确的构建出来(没有发生 this 引用逸出的情况),则其外部的可见状态永远都不会改变,永远也不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性也是最简单最纯粹的。
Java 语言中,如果共享的数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,比如 String 类。
保证对象行为不影响自身状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为 final,这样在构造函数结束之后它就是不可变的。
在 Java API 中符合不可变要求的类型,除了上面提到的 String 之外,常用的还有枚举类型,以及 java.lang.Number 的部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型;但同为 Number 子类的原子类 AtomicInteger 和 AtomicLong 则是可变的。
2. 绝对线程安全
绝对的线程安全完全满足 Brian Goetz 对线程安全的定义,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的代价。在 Java API 中标注为线程安全的类中,大多数都不是绝对的线程安全。我们可以通过 Java API 中一个不是“绝对线程安全”的线程安全类来看看这类的“绝对”是什么意思。
java.util.Vector 是一个线程安全的容器,因为它所有类似 add、get 的方法都被 synchronized 修饰,尽管这样效率很低,但是确实是安全的。但是,即使它所有的方法都被修饰为同步,也不意味着调用它的时候就无需考虑同步了,如下代码示例:
private static Vector<Integer> vector = new Vector<>();
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);
}
}
运行后将会抛出数组索引越界异常。很明显,尽管这里使用到的 get、remove、size 方法都是同步的,但是在多线程环境中,如果不再方法调用端做任何同步措施,使用这段代码仍然不安全,因为如果另一个线程恰好的时间删除了一个元素,导致序号 i 已经不再可用的话,get(i) 将会抛出数组越界异常。如果要保证这段代码能够正确运行,则要进行如下修改:
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));
}
}
}
});
3. 相对线程安全
相对线程安全就是我们通常意义上所说的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。上面的例子可以作为说明。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
4. 线程兼容
线程兼容指的是对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中安全的使用,我们平常说一个类不是线程安全的,绝大多数指的是这种情况。Java API 中大部分类都是线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap。
4. 线程对立
线程对立这得是不管调用段是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具有多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。
一个线程对立的例子是 Thread 类的 suspend 和 resume 方法,如果有两个线程同时持有一个 Thread 对象,一个尝试去中断线程、一个尝试去恢复线程,如果并发执行这种操作,无论调用时是否进行了同步,目标线程都会存在死锁风险。如果 suspend 中断的线程就是即将要执行 resume 的那个线程,那就肯定要产生死锁了。常见的线程对立的操作还有 System.setIn()、System.setOut()、System.runFinalizersOnExit() 等。
13.2.2 实现线程安全
1. 互斥同步
互斥同步是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量、信号量都是主要的互斥实现方式。因此在这四个字面中,互斥是因,通过是果,互斥是方法,同步是目的。
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象参数,那就是这个对象的 reference;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是类方法,来取对应的对象实例或 Class 对象作为对象锁。
根据 JVM 规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经持有了该对象的锁,把锁的计数器加 1;相应的,在执行 monitorexit 指令时会将锁的计数器减 1,当计数器为 0 时,所就被释放了。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
在 JVM 规范对 monitorenter 和 monitorexit 的行为描述中,有两点是需要特别关注的。首先,synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。上一章讲过,Java 线程是映射到操作系统原生线程上的,如果要阻塞或唤醒一条线程,都需要依赖操作系统调用,这就需要从用户态切换到内核态,因此状态转换需要耗费很多处理器时间。对于代码中简单的同步块(如被 synchronized 修饰的 getter/setter 方法),状态切换消耗的时间可能比用户代码执行的时间还要长。所以 synchronized 是 Java 语言中一个重量级操作,有经验的程序员都会在确实必要的情况才使用这种操作。而虚拟机本身也会进行一些优化,比如在通知操作系统阻塞线程之前先加入一段自旋等待过程,避免频繁的切入内核态。
除了 synchronized 之外,我们还可以使用 JUC 中的重入锁来实现同步,在基本用法上,重入锁与 synchronized 很相似,它们都具备一样的线程重入特性,只是代码写法上有些区别,一个表现为 API 层面的互斥锁(lock 和 unlock 方法配合 try/finally 语句块来完成),一个表现为原生语法层面的互斥锁。不过重入锁比 synchronized 增加了一些高级功能,主要有以下三项:
- “等待可中断”是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理器其他任务,可中断特性对处理的执行时间非常长的同步块很有帮助。
- “公平锁”是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized 中的锁是非公平的,重入锁默认情况下也是非公平的,但可以通过构造参数设置。
- “锁绑定多个条件”是指一个重入锁对象可以同时绑定多个 Condition 对象,而在 synchronized 中,锁对象的 wait 和 notify、notifyAll 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外的添加一个锁,而重入锁无需这么做,只需要多次调用 newCondition 方法即可。
如果需要使用到上述功能,选用重入锁是一个很好的选择,那如果是基于性能考虑呢?关于 synchronized 和重入锁的性能问题,Brian Goetz 对这两种锁在 JDK 1.5、单核处理器及双 Xeon 处理器环境做了一组吞吐量对比试验,如下图:
从上面两组试验结构可以看出,多线程环境下 synchronized 的吞吐量下降的非常严重,而重入锁则能基本保持在同一个比较稳定的水平上。与其说重入锁性能好,倒不如说 synchronized 还有非常大的优化空间。后续的技术发展也证明了这一点,JDK 1.6 中加入了很多针对锁的优化措施,这时两者的性能基本持平。因此性能因素不再是选择重入锁的理由了,JVM 在未来的性能改进中肯定也会更加偏向于原生的 synchronized,所以还是提倡在 synchronized 能够实现需求的情况下有限选择。
2. 非阻塞同步
互斥同步最主要的问题就是进行线程阻塞和换新所带来的性能问题,因此这种同步也被称为阻塞同步。另外,它属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(即加锁),那就肯定会出问题,无论共享数据是否真的会出现竞争,它都要求进行加锁(这里说的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态与内核态切换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。随着硬件指令集的发展,我们有了另一种选择:基于冲突检测的乐观并发策略,通俗的说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据被争用,这时就产生了冲突,然后再进行其他补偿措施(最常见的补偿措施就是不断重试直到成功),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步。
为什么说使用乐观并发策略需要“硬件指令集的发展”才能进行呢?因为我们需要操作和冲突检测这两个步骤具有原子性,靠什么来保证呢?如果这里再使用互斥同步来保证就是去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置,TAS
- 获取并增加,FAI
- 交换,Swap
- 比较并交换,CAS
- 加载链接/条件存储,LL/SC
其中,前面的三条是上个世纪就存在于大多数指令集中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能是类似的。在 IA64、x86 指令集中通过 cmpxchg 指令完成 CAS 功能,在 sparc-STO 中也有 casa 指令实现,而在 ARM 和 PowerPC 架构下,则需要使用一对 ldrex/strex 指令来完成 LL/SC 功能。
CAS 指令需要有三个操作数,分别是内存位置(即 Java 中变量的内存地址,V)、旧的预期值(A)、新值(B)。CAS 指令执行时,当且仅当 V 符合旧的预期值 A 时,处理器用新值 B 更新 V 的值,否知它就不执行更新,但是不管是否更新了 V 的值,都会返回 旧 V 的值,上述的处理过程是一个原子操作。
在 JDK 1.5 之后,Java 程序中才可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类中的 compareAndSwap 和 compareAndSwapLong 等几个方法提供,JVM 内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用的过程,或者可以认为是无条件内联进去了。
由于 Unsafe 类不是提供给用户程序调用的类(Unsafe.getUnsafe 的代码中限制了只有启动类加载器加载的类才可以访问它),如果不采用反射手段,我们只能通过其他 Java API 来间接访问它,如 JUC 包中的整数原子类。
尽管 CAS 看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景,并且 CAS 从语义上来说并不完美,存在这样一个逻辑漏洞:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查它仍然是 A 值,那我们就能说它的值没有被其他线程改变过吗?如果在这段时间内它的值曾经被改为了 B,后来又被改回了 A,那 CAS 操作就会误认为它从来没有被改变过。该漏洞被称为 CAS 操作的 ABA 问题。JUC 为了解决该问题,提供了一个带有标记的原子引用类 AtomicStampedReference,它可以通过控制变量值的版本来保证 CAS 的正确性。不过目前来说这个类比较鸡肋,大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更有效。
3. 无同步方案
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保障共享数据争用时的正确性手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施来保证正确性,因此会有一些代码天生就是线程安全的。
“可重入代码”:这种代码也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它自身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
可重入代码有一些共同的特征:比如不依赖存储在堆上的数据和公共的系统资源、用到的状态都由参数传入、内部不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具有可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性的要求,当然它就是线程安全的。
“线程本地存储”:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也能保证线程间不出现数据争用的问题。
符合这种特点的应用很常见,大部分使用消费队列的架构模式(如生产者——消费者)都会将产品的消费过程尽量在一个线程中消费完,其中最重要的一个应用实例就是经典 Web 交互模式中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式,这种处理方式的广泛应用使得 Web 服务器的很多应用都可以使用线程本地存储来解决线程安全问题。
Java 语言中,如果一个变量要被多线程访问,可以使用 volatile 关键字声明它为“易变的”;如果一个变量要被某个线程独享,因为 Java 中没有类似 C++ 中 __declspec(thread)
这样的关键字,不过可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 键值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K-V 键值对中找回对应的本地线程变量。
13.3 锁优化
HotSpot JVM 开发团队在 JDK 1.6 版本上花费了大量精力来实现各种锁的优化技术,所有这些优化都是为了在线程间更加高效的共享数据,以解决竞争问题,从而提高程序的执行效率。
13.3.1 自旋锁与自适应自旋
前面我们讨论同步的时候,提到了互斥同步对性能影响最大的是阻塞式的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统并发性带来了很大的压力。同时,虚拟机开发团队也注意到在很多应用上,共享数据的锁定状态一般会持续很短一段时间,为了这段很短的时间去挂起和恢复线程很不值得。如果物理机有一个以上的处理器,能让两个或两个以上的线程同时并行执行,我们就可以让后面请求锁的线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就能释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这就是所谓的自旋锁。
自旋锁自 JDK 1.4.2 引入,只不过是默认关闭的,可以使用 -XX:UseSpinning 参数来开启,到了 1.6 就已经改为默认开启了。自旋锁不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它要是要占用处理器执行时间的,所以如果被占用的时间很短,自旋等待的效果会很好,反之,自旋的线程只会白白消耗掉处理器资源,造成性能浪费。因此自旋等待的时间必须要有一个限度,如果自旋超过了限定次数仍然没有获得锁,那就应当使用传统的方式去挂起线程。自旋次数的默认值是 10,用户可以使用参数 -XX:PreBlockSpin 来修改。
在 JDK 1.6 中引入了自适应自旋锁。自适应意味着自旋的次数不再固定,而是由前一次在同一个锁上自旋的时间及锁的拥有者线程的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很有可能会再次成功,进而它将允许自旋等待持续相对更长的时间,比如 100 个循环。另一方面,如果对于某个锁,自旋很少获得成功,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器时间。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况监控就会越来越准确,虚拟机就会变得越来越聪明了。
13.3.2 锁消除
锁消除指的是虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在数据竞争的锁进行消除。锁消除的主要判断依据来自逃逸分析和数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然也就无需运行。
也许读者会有疑问,变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据竞争的情况下要求同步呢?答案是有许多同步措施并非由程序员加入,同步代码在 Java 程序中的普遍程度也许超过了大部分读者的想象。比如下面一段代码:
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
由于 String 是一个不可变类,对字符串的连接操作总是通过生成新的 String 对象进行的,因此 javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前,会转换为 StringBuffer 对象的连续 append 操作,在 JDK 1.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 对象。虚拟机观察变量 sb,很快就发现它的动态作用域被限制在 concatString 方法内部。即 sb 的所有引用永远不会逃逸到 concatString 方法的外部,其他线程也就无法访问它,所以这里虽然有锁,但是可以被安全的消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
13.3.3 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也可以尽快得到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续读操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁的进行同步操作也会带来不必要的性能损耗。
上述代码中连续 append 方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以上述字符串连接为例,就是扩展到第一个 append 操作之前直至最后一个 append 操作之后,这样一来仅需加锁一次就够了。
13.3.4 轻量级锁
轻量级锁是 JDK 1.6 中加入的新型锁机制,“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此后者也可以被称为“重量级锁”。首先需要强调的一点是,轻量级锁并非用来替代重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用系统互斥量带来的性能损耗。
要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须从 HotSpot 虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpot 虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄等,这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32 个和 64 个 Bits,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象的类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定数据及饿哦股以便在极小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机中对象未被锁定的状态下,Mark Word 的 32 个 Bits 空间中的 25 Bits 用于存储对象哈希码,4 Bits 用于存储对象的分代年龄,2 Bits 用于存储锁标志位,1 Bit 固定为 0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下表:
在代码进入同步块时,如果此同步对象没有被锁定(锁标志位为 01 状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这分拷贝加了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如下图所示。
然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果该更新动作成功,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位(Mark Word 的最后两 Bits)将转变为 “00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈及对象头的状态如下图所示。
如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两个以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
上面买描述的是轻量级锁的加锁过程,它的解锁过程也是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能够提高程序同步性能的依据是,“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一条经验数据。如果没有竞争,轻量级锁使用 CAS 操作就避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
13.3.5 偏向锁
偏向锁也是 JDK 1.6 引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的执行性能。如果说轻量级锁是在无竞争情况下使用 CAS 操作来消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。
偏向锁的“偏”类似于“偏心”、“偏袒”中的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再次进行同步。
如果读者读懂了前面轻量级锁中关于对象头 Mark Word 与线程之前的操作过程,那偏向锁的原理理解起来就会很简单。假设当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取时,虚拟机就会把对象头中的标志位设置为 “01”,即偏向模式。同时使用 CAS 操作把获取到该锁的线程 ID 记录在对象头 Mark Word 中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(如 Locking、Unlocking 及对 Mark Word 的 Update 等)。
当另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁定对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为“01”)或轻量级锁定状态(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。偏向锁、轻量级锁的状态转换及对象 Mark Word 的关系如下图所示。
偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡性质的优化,即并不一定总是对程序的运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数 -XX:UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。
13.4 本章小结
本章介绍了线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,并且介绍了虚拟机为了实现高效并发所采取的一系列锁优化措施。
许多资深的程序员都说过,能够写出高伸缩性的并发程序是一门艺术,而了解并发在系统底层是如何实现的,则是掌握这门艺术的前提条件,也是成长为高级程序员的必备知识之一。
4.14 - Endix-B-字节码指令
4.15 - Endix-C-虚拟机参数
使用 -XX:+PrintFlagsFinal
可以输出所有参数的名称和默认值。应用参数的方式有以下 3 种:
-XX:+<option>开启option参数
-XX:-<option>关闭option参数
-XX:<option>=<value>将option参数的值设置为value
以下是 JDK6 中常用的参数。
内存管理参数
参数 | 默认值 | 使用介绍 |
---|---|---|
DisableExplicitGC | 默认关闭 | 忽略来自 System.gc() 方法触发的垃圾回收 |
ExplicitGCInvokesConcurrent | 默认关闭 | 当收到 System.gc() 方法提交的垃圾回收申请时,使用 CMS 收集器进行收集 |
UseSerialGC | Client 模式 的虚拟机默认开启,其他模式默认关闭 | 虚拟机运行在 Client 模式下的默认值,打开此开关后,使用 Serial+Serial Old 的收集器组合进行内存回收 |
UseParNewGC | 默认关闭 | 打开此开关后,使用 ParNew+Serial Old 的收集器组合进行内存回收 |
UserConcMarkSweepGC | 默认关闭 | 打开此开关后,使用 ParNew+CMS+Serial Old 的收集器组合进行内存回收。如果 CMS 收集器出现 Concurrent Mode Failure,则 Serial Old 收集器将作为后备收集器 |
UserParallelGC | Server 模式的虚拟机默认开启,其他模式默认关闭 | 虚拟机运行在 Server 模式下的默认值,打开此开关后,使用 Parallel Scavenge+Serial Old 的收集器组合进行内存回收 |
UseParallelOldGC | 默认关闭 | 打开此开关后,使用 Parallel Scavenge+Parallel Old 的收集器组合进行内存回收 |
ServivorRatio | 默认为 8 | 新生代中 Eden 区域与 Survivor 区域的容量比值 |
PretenureSizeThreshold | 无默认值 | 直接晋升到老年代的对象大小,设置该参数后,大于这个参数的对象将直接在老年代分配 |
MaxTenuringThreshold | 默认为 15 | 晋升到老年代的对象年龄。每个对象在坚持过一次 Mirror GC 之后,年龄就加 1,当超过该参数值时进入老年代 |
UseAdaptiveSizePolicy | 默认开启 | 动态调整 Java 堆中各个区域的大小及进入老年代的年龄 |
HandlePromotionFailure | JDK 1.5 及之前的版本默认关闭,JDK 1.6 之后默认开启 | 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个 Eden 和 Survivor 区的所有对象都存活的极端情况 |
ParallelGCThreads | 少于或等于 8 个 CPU 时默认为 CPU 的数量值,多余 8 个 CPU 时比 CPU 的数量之小 | 设置并行 GC 时进行内存回收的线程数 |
GCTimeRatio | 默认为 99 | GC 时间占总时间的比例,默认允许 1% 的 GC 时间。仅在使用 Parallel Scavenge 收集器时生效 |
MaxGCPauseMillis | 无默认值 | 设置 GC 的最大停顿时间。仅在使用 Parallel Scavenge 收集器时生效 |
CMSInitiatingOccupancyFraction | 默认 68 | 设置 CMS 收集器在老年代空间被使用多少后触发垃圾回收,仅在使用 CMS 收集器时生效 |
UseCMSCompactAtFullCollection | 默认开启 | 设置 CMS 收集器在完成垃圾收集后是否需要进行一次内存碎片整理。仅在使用 CMS 收集器时生效 |
CMSFullGCsBeforeCompaction | 无默认值 | 设置 CMS 收集器在进行若干次垃圾收集后在启动一次内存碎片整理。仅在使用 CMS 收集器时生效 |
ScavengeBeforeFullGC | 默认开启 | 在 Full GC 发生之前触发一次 Mirror GC |
UseGCOverheadLimit | 默认开启 | 禁止 GC 过程无限制的执行,如果过于频繁,就直接发生 OutOfMemory 异常 |
UseTLAB | Server 模式默认开启 | 优先在本地线程缓冲区中分配对象,避免分配内存时的锁定过程 |
MaxHeapFreeRatio | 默认为 70 | 当 Xmx 比 Xms 值大时,堆可以动态收缩和扩展,该参数控制当堆空闲大于指定比例时自动收缩 |
MinHeapFreeRatio | 默认为 70 | 当 Xmx 比 Xms 值小时,对可以动态收缩或扩展,该参数控制当对空闲小于指定比率时自动扩展 |
MaxPermSize | 大部分情况下默认为 64MB | 永久代的最大值 |
即时编译参数
参数 | 默认值 | 使用介绍 |
---|---|---|
CompileThreshold | Client 模式下默认为 1500,Server 模式下为 1000 | 触发方法即时编译的阈值 |
OnStackReplaceRercentage | Client 模式下为 933,Server 模式下为 140 | OSR 比率,它是 OSR 即时编译阈值计算公式的一个参数,用于代替 BckEdgeThreshold 参数控制回边计数器的实际溢出阈值 |
ReversedCodeCacheSize | 大部分情况下是 32MB | 即时编译器编译的代码缓存的最大值 |
类型加载参数
参数 | 默认值 | 使用介绍 |
---|---|---|
UseSplitVerifer | 默认开启 | 使用依赖 StackMapTable 信息的类型检查代替数据流分析,以加快字节码校验速度 |
FailOverToOldVerifier | 默认开启 | 当类型校验失败时,是否允许回到老的类型推导校验方式重新校验,如果开启则允许 |
RelaxAccessControlCheck | 默认关闭 | 在检验阶段放松对类型访问性的限制 |
多线程相关参数
参数 | 默认值 | 使用介绍 |
---|---|---|
UseSpinning | JDK 1.6 默认开启,JDK 1.5 默认关闭 | 开启自旋锁以避免线程频繁挂起和唤醒 |
PreBlockSpin | 默认为 10 | 使用自旋锁时默认的自旋次数 |
UseThreadPriorities | 默认开启 | 使用本地线程优先级 |
UseBiasedLocking | 默认开启 | 使用使用偏向锁 |
UseFastAccessorMethods | 默认开启 | 当频繁反射执行某个方法时,生成字节码来加快反射的执行速度 |
性能参数
参数 | 默认值 | 使用介绍 |
---|---|---|
AggressiveOpts | JDK 1.6 默认开启,JDK 1.5 默认关闭 | 使用激进的优化特性,这些特性一般是具备正面和负面双重影响的,需要根据具体应用特点来分析才能判定是否对性能有益 |
UseLargePages | 默认开启 | 如果可能,使用大内存分页,该特性需要操作系统的支持 |
LargePageSizeInBytes | 默认为 4MB | 使用指定大小的内存分页,该特性需要操作系统的支持 |
StringCache | 默认开启 | 使用使用字符串缓存 |
调试参数
参数 | 默认值 | 使用介绍 |
---|---|---|
HeapDumpOnOutOfMemoryError | 默认关闭 | 在发生内存溢出时是否生成堆转储快照 |
OnOutOfMemoryError | 无默认值 | 当虚拟机抛出内存溢出异常时,执行指定的命令 |
OnError | 无默认值 | 当虚拟机抛出 ERROR 异常时,执行指定的命令 |
PrintClassHistogram | 默认关闭 | 使用 [ctrl]-[break] 快捷键输出类统计状态,相当于 jmap-histo 的功能 |
PrintConcurrentLocks | 默认关闭 | 打印 JUC 中锁的状态 |
PrintCommandLineFlags | 默认关闭 | 打印启动虚拟机时输入的非稳定参数 |
PrintCompilation | 默认关闭 | 打印方法的即时编译信息 |
PrintGC | 默认关闭 | 打印 GC 信息 |
PrintGCDetails | 默认关闭 | 打印 GC 详细信息 |
PrintGCtimeStamps | 默认关闭 | 打印 GC 停顿耗时 |
PrintTenuringDistribution | 默认关闭 | 打印 GC 后新生代各个年龄对象的大小 |
TracClassLoading | 默认关闭 | 打印类加载信息 |
TraceClassUnloading | 默认关闭 | 打印类卸载信息 |
PringInlining | 默认关闭 | 打印方法的内联信息 |
PrintCFGToFile | 默认关闭 | 将 CFG 图信息输出到文件,只有 DEBUG 版虚拟机才支持该参数 |
PrintIdealGraphFile | 默认关闭 | 将 Ideal 图信息输出到文件,只有 DEBUG 版虚拟机才支持该参数 |
UnlockDiagnosticVM Options | 默认关闭 | 让虚拟机进入诊断模式,一些参数(如 PrintAssembly)需要在诊断模式下才能使用 |
PrintAssembly | 默认关闭 | 打印即时编译后的二进制信息 |