深入理解Java虚拟机--下#
参考:https://www.zybuluo.com/jewes/note/57352
第10章 早期(编译期)优化
10.1 概述
Java语言的“编译期”,可能是指一个前端编译器把.java文件转变成.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler,即时编译器)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把*.java 文件编译成本地机器代码的过程。下面列举了这3类编译过程中一些比较有代表性的编译器:
- 前端编译器:Sun的Javac。
- JIT编译器:HotSpot VM的C1、C2编译器。
- AOT编译器:GNU Compiler for the Java(GCJ)。
这3类过程中最符合大家对Java程序编译认知的应该是第一类,在本章的后续文字里,笔者提到的“编译期”和“编译器”都仅限于第一类编译过程,把第二类编译过程留到下一章中讨论。Javac这类编译器对代码的运行效率几乎没有任何优化措施,但是做了许多针对Java语言编码过程的优化措施来改善程序员的编码风格和提高编码效率,相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由Javac产生的Class文件(如JRuby、Groovy等语言的Class文件)也同样能享受到编译器优化所带来的好处。可以说,Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。
10.2 Javac编译器
是一个由Java语言编写的程序。编译过程大致可以分为3个过程,分别是:
- 解析与填充符号表过程。
- 插入式注解处理器的注解处理过程。
- 语义分析与字节码生成过程。
10.2.2 解析与填充符号表
解析步骤包括了词法分析和语法分析两个过程。
1.词法、语法分析:
词法分析是将源代码的字符流转变为标记(Token)集合,关键字、变量名、字面量、运算符都可以成为标记,如“int a=b+2”这句代码包含了6个标记,分别是int、a、=、b、+、2。
语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构(Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。经过这个步骤之后,编译器就基本不会再对源码文件进行操作了,后续的操作都建立在抽象语法树之上。
2.填充符号表:
符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,包含了每一个编译单元的抽象语法树的*节点,以及package-info.java(如果存在的话)的*节点。符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
10.2.3 注解处理器
在JDK 1.5之后,Java语言提供了对注解(Annotation)的支持,这些注解与普通的Java代码一样,是在运行期间发挥作用的。在JDK 1.6中提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。
由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员可以使用插入式注解处理器来实现许多原本只能在编码中完成的事情,比如lombok。
10.2.4 语义分析与字节码生成
抽象语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,分为标注检查以及数据及控制流分析两个步骤。
1.标注检查:
包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。在标注检查步骤中,还有一个重要的动作称为常量折叠,比如定义int a = 1+2;
,在语法树上仍然能看到字面量“1”、“2”以及操作符“+”,但是在经过常量折叠之后,它们将会被折叠为字面量“3”。由于编译期间进行了常量折叠,所以在代码里面定义“a=1+2”比起直接定义“a=3”,并不会增加程序运行期哪怕仅仅一个CPU指令的运算量。
2.数据及控制流分析:
对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。将局部变量声明为final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。
3.解语法糖:
语法糖,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。Java中最常用的语法糖:泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。
4.字节码生成:
字节码生成是Javac编译过程的最后一个阶段,不仅仅是把前面各个步骤所生成的信息(抽象语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。比如实例构造器方法和类构造器方法就是在这个阶段添加到语法树之中的,这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对于实例构造器而言是“{}”块,对于类构造器而言是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器(仅仅是实例构造器,方法中无须调用父类的方法,虚拟机会自动保证父类构造器的执行)等操作收敛到和方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行。除了生成构造器以外,还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为StringBuffer或StringBuilder的 append()操作等。
10.3 Java语法糖的味道
语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。
10.3.1 泛型与类型擦除
泛型是JDK 1.5的一项新增特性,它的本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。
泛型技术在C#和Java之中的使用方式看似相同 ,但实现上却有着根本性的分歧,C#里面泛型是切实存在的,List<int>与List<String>就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。
Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型,并且在原生类型前插入强制转型代码(Obejct),因此,对于运行期的Java语言来说,ArrayList<int>与ArrayList<String>就是同一个类,并且在输出数据时再强制转换回去。Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。
10.3.2 自动装箱、拆箱与遍历循环
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
// 如果在JDK 1.7中,还有另外一颗语法糖 , 能让上面这句代码进一步简写成List<Integer> list = [1, 2, 3, 4];
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
上面代码一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数5种语法糖,下面代码则展示了它们在编译后的变化。泛型就不必说了,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数。
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.3.3 条件编译
Java语言可以进行条件编译,方法就是使用条件为常量的if语句。如下代码中的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");
}
}
只能使用条件为常量的if语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,比如:
public static void main(String[] args) {
// 编译器将会提示“Unreachable code”
while (false) {
System.out.println("");
}
}
Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成。
第11章 晚期(运行期)优化
11.1 概述
高级语言所编制的程序不能直接被计算机识别,必须经过转换才能被执行,按转换方式可将它们分为两类:
解释执行:它将源语言书写的源程序作为输入,解释一句后就提交计算机执行一句,并不形成目标程序。
编译执行:它把高级语言源程序作为输入,进行翻译转换,产生出机器语言的目标程序,然后再让计算机去执行这个目标程序,得到计算结果。
编译程序工作时,先分析,后综合,从而得到目标程序。所谓分析,是指词法分析和语法分析;所谓综合是指代码优化,存储分配和代码生成。
Java 编程语言(Java programming language) 与众不同之处在于:Java 程序既是编译型的(compiled)(前端编译器javac:将程序源代码编译为一种称为 java字节码的中间语言),又是解释型的(interpreted)(JVM 对字节码进行解释和运行)。编译只进行一次,而解释在每次运行程序时都会进行。
解释执行效率不高,为了解决这个问题,使用JIT(Just In Time Compiler)编译器,即后端运行期的即时编译器。
在部分的商用虚拟机(Sun HotSpot、IBM J9,注意JRockit内部没有解释器,但它主要是面向服务端的应用,不会重点关注启动时间)中,Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT编译器)。
11.2 HotSpot虚拟机内的即时编译器
11.2.1 解释器与编译器
解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化时,可以通过逆优化退回到解释状态继续执行,因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作,如图11-1所示。
HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称为C1编译器(编译速度快)和C2编译器(编译质量高)。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启用分层编译的策略:
- 第0层,程序解释执行,解释器不开启性能监控功能(Profiling),可触发第1层编译。
- 第1层,也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
- 第2层(或2层以上),也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
11.2.2 编译对象与触发条件
在运行过程中会被即时编译器编译的“热点代码”有两类,即:
- 被多次调用的方法。
- 被多次执行的循环体,当一个方法只被调用过少量的几次,但是方法体内部存在循环次数较多的循环体。
栈上替换:即方法栈帧还在栈上,字节码的方法就被替换为编译后的方法。
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测,目前主要的热点探测判定方式有两种:
- 基于采样的热点探测:周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。好处:实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点:很难精确地确认一个方法的热度,因为容易受到线程阻塞或别的外界因素的影响。
- 基于计数器的热点探测:为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。
在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法。
11.3 编译优化技术
Java程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器之中。
11.3.1 优化技术概览
在这里举一个例子,不做深入了解。优化前的原始代码:
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源码之上的;同时,上面的代码并不规范,只是为了说明情况。
第一步进行方法内联(Method Inlining),方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。内联后的代码:
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看做是一个表达式,那也可以把这项优化看成是公共子表达式消除。
第三步我们进行复写传播(Copy Propagation),因为在这段程序的逻辑中并没有必要使用一个额外的变量“z”,它与变量“y”是完全相等的,因此可以使用“y”来代替“z”。
public void foo() {
y = b.value;
// ……do stuff……
y = y;
sum = y + y;
}
第四步我们进行无用代码消除。无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此,它又形象地称为“Dead Code”,在上面的代码中,“y=y”是没有意义的。优化后省略了许多语句(体现在字节码和机器码指令上的差距会更大),执行效率也会更高。
11.3.2 公共子表达式消除
如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
11.3.3 数组边界检查消除
如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界的范围检查,即检查i必须满足i>=0&&i<foo.length这个条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException。但每次数组元素的读写都带有一次隐含的条件判定操作,增大了系统的负担。数组边界检查肯定是必须做的,但数组边界检查是不是必须在运行期间一次不漏地检查则是可以“商量”的事情。比如:数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。
11.3.4 方法内联
看起来是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用即可。但是由于很多方法调用,到运行期才可以确定方法版本,比如子类方法覆盖父类,实际判断很复杂。
11.3.5 逃逸分析
它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
其实就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示:
- 栈上分配:如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁,减小垃圾收集系统的压力。
- 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,对这个变量实施的同步措施也就可以消除掉。
- 标量替换:标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量,Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复为原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。
第12章 Java内存模型与线程
12.2 硬件的效率与一致性
**CPU的内部结构 **:
CPU的根本任务就是执行指令,对计算机来说最终都是一串由“0”和“1”组成的序列。CPU从逻辑上可以划分成3个模块,分别是控制单元(Control Unit) 、算术逻辑单元ALU和存储单元(寄存器组),这三部分由CPU内部总线连接起来。
- 控制单元:控制单元是整个CPU的指挥控制中心,由指令寄存器IR(Instruction Register)、指令译码器ID(Instruction Decoder)和操作控制器OC(Operation Controller)组成。它根据用户预先编好的程序,依次从存储器中取出各条指令,放在指令寄存器IR中,通过指令译码(分析)确定应该进行什么操作,然后通过操作控制器OC,按确定的时序,向相应的部件发出微操作控制信号。
- 算术逻辑单元ALU:是运算器的核心。它是以全加器为基础,辅之以移位寄存器及相应控制逻辑组合而成的电路,在控制信号的作用下可完成加、减、乘、除四则运算和各种逻辑运算。
- 存储单元:包括CPU片内缓存和寄存器组,是CPU中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU访问寄存器所用的时间要比访问内存的时间短。采用寄存器,可以减少CPU访问内存的次数,从而提高了CPU的工作速度。但因为受到芯片面积和集成度所限,寄存器组的容量不可能很大。寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,分别寄存相应的数据。而通用寄存器用途广泛并可由程序员规定其用途,通用寄存器的数目因微处理器而异。
- 总线实际上是一组导线,是各种公共信号线的集合,用于作为电脑中所有各组成部分传输信息共同使用的“公路”。其中包括: 数据总线DB(Data Bus)、地址总线AB(Address Bus) 、控制总线CB(Control Bus)。数据总线用来传输数据信息;地址总线用于传送CPU发出的地址信息;控制总线用来传送控制信号、时序信号和状态信息等。
控制单元在时序脉冲的作用下,将指令计数器里所指向的指令地址(这个地址是在内存里的)送到地址总线上去,然后CPU将这个地址里的指令读到指令寄存器进行译码。对于执行指令过程中所需要用到的数据,会将数据地址也送到地址总线,然后CPU把数据读到CPU的内部存储单元(就是内部寄存器)暂存起来,最后命令运算单元对数据进行处理加工,然后将加工后的数据写入内存。
为了保证每个操作准时发生,CPU需要一个时钟,时钟控制着CPU所执行的每一个动作。时钟就像一个节拍器,它不停地发出脉冲,决定CPU的步调和处理时间,这就是我们所熟悉的CPU的标称速度,也称为主频。主频数值越高,表明CPU的工作速度越快。
单核单CPU:
所谓“并发执行”,在单核情形下并不是各线程同时执行(占有CPU),在任意时刻还是只能有一个线程占用CPU,只不过它们彼此轮换CPU的很快,感觉上似乎都在运行。
多核单CPU:
简单地说就是在一块CPU基板上集成两个或两个以上处理器核心,并通过并行总线将各处理器核心连接起来。
cpu多核技术的开发源于这样的认识:在单核芯片中,仅仅提高CPU的时钟频率会导致芯片功耗过大,产生过多的热量。多核处理器是单片芯片,能够直接插入单一的处理器插槽中,但操作系统可以将它的每个执行内核作为独立的逻辑处理器使用。通过在多个执行内核之间划分任务,多核处理器可以在特定的时钟周期内执行更多的任务。
核心有独立的一级缓存,同时共享二级缓存。核心之间通过芯片内部总线进行通信,因此线程之间通信很快。
单核多CPU:
每一个CPU都需要有较为独立的电路支持,有自己的高速缓存,他们之间通过板上的总线进行通信。那么每一个线程就要跑在一个独立的CPU上,线程间的所有通信协作都要通过主内存,相比多核更慢,而共享的数据更是有可能要在好几个Cache里同时存在。缓存一致性怎么保证,即同步回到主内存时以谁的缓存数据为准呢?
此时不同处理器之间不共享任何缓存,他们通过主内存进行通信。为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,这类协议有MSI、MESI等。
除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(指CPU允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。 与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化。比如:指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排——(A+10)2与A2+10显然不相等,但指令3 可以重排到指令1、2之前或者中间,只要保证CPU执行后面依赖到A、B值的操作时能获取到正确的A和B值即可。
12.3 Java内存模型
Java内存模型(Java Memory Model,JMM)是为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
12.3.1 主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
Java内存模型规定了所有的变量都存储在主内存(Main Memory,可与硬件的主内存类比)中。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图12-2所示。
这里所讲的主内存、工作内存与本书第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。
12.3.2 内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎。
- assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量。
- store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、store与write之间是可插入其他指令的。比如read a、read b、load b、load a。
Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
12.3.3 对于volatile型变量的特殊规则
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。当一个变量定义为volatile之后,它将具备两种特性:
1.保证此变量对所有线程的可见性
这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。
volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反应到其他线程之中,那么为什么不能保证并发的安全性?
volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。比如:
public static volatile int race = 0;
public static void increase() {
race++;
}
我们用Javap反编译这段代码,发现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成的(return 指令不是由race++产生的,这条指令可以不计算),从字节码层面上很容易就分析出并发失败的原因了:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1(将int型1推送到栈顶)、iadd(将栈顶两个int值相加并将结果压入栈顶)这些指令的时候,其他线程可能已经把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
再比如:private volatile int count = 0;
,假设现在有两条线程分别对count执行加1操作,那么期待的结果最后count2,但是看下边的分析:
假设有如下流程:
1)线程a获取了count0;
2)线程b获取了count0;
3)线程b对count+1,之后写入主内存count1;
4)线程a对count+1,之后写入主内存count1;
结果count1而非count==2,原因就是线程a获取count后,volatile不能实现原子性,这个时候b也能去操作count。想要实现原子性,使用synchronized去锁住相应的方法或者代码块,或者使用ReentrantLock去锁住相应的代码;当然,以上场景使用AtomicInteger更好。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
如果对变量的更改依赖于现有值,就是一个race condition(资源竞争)操作,需要使用其他同步手段如synchronized将race condition操作转换为原子操作,而volatile对原子性是无能为力的。但是如果能够确保只会在单一线程中修改变量的值, 那么除了当前线程外,其他线程不能更改变量的值,此时race condition就不可能发生。
- 变量不需要与其他的状态变量共同参与不变约束。
比如start和end变量都被声明为volatile, 并且start和end组成不变约束start<end,这样的不变约束是存在并发问题的:
private Date start;
private Date end;
public void setInterval(Date newStart, Date newEnd) {
// 检查start<end是否成立, 在给start赋值之前不变式是有效的
start = newStart;
// 但是如果另外的线程在给start赋值之后给end赋值之前时检查start<end, 该不变式是无效的 ,即一个函数中的 start和另一个函数中的end进行比较了
end = newEnd;
// 给end赋值之后start<end不变式重新变为有效
}
volatile变量的典型应用场景是作为标记使用,即控制并发的开始或者结束:
public class SocketThread extends Thread {
public volatile boolean running = true;
@Override
public void run() {
while (running) {
// ...
}
}
}
2.禁止指令重排序优化
如概述中所说,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。比如:
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
由于指令重排序,主线程中将ready赋值为true的操作可能发生在对number的赋值之前, 因此ReaderThread的输出结果可能为0。又由于可见性, ReaderThread线程可能无法获知主线程对ready的修改,那么ReaderThread的循环将不会停止。也许在特定的机器上,以上的"异常情况"很难出现,实际上这取决于处理器架构和JVM实现,以及运气。此时,用volatile修饰number,可以保证对一个volatile变量的写操作先行发生于后面对这个变量的读操作,也就是读到42。
volatile如何保证变量的可见性
假定T 表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、 store和write操作时需要满足如下规则:
- 线程T对变量V的use动作和线程T对变量V的read、load动作相关联,必须连续一起出现(即在工作内存中,每次使用V前都必须从主内存获得最新的值)。
- 线程T对变量V的assign动作和线程T对变量V的store、write动作相关联,必须连续一起出现(即在工作内存中,每次修改V后都必须立刻同步回主内存中)。
- 假定动作A是线程T对变量V实施的use或assign动作,P是和A相应的对变量V的read或write动作;类似的,假定B是线程T对变量W实施的use或assign动作,Q是和B相应的对变量W的read或write动作。如果A先于B,那么P先于Q(即要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)。
如何选择volatile和synchronized:
volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障(指重排序时不能把后面的指令重排序到内存屏障之前的位置)指令来保证处理器不发生乱序执行,并且不会造成阻塞。大多数场景下volatile的总开销比锁低,我们在volatile与锁之中选择的唯一依据仅仅是:volatile的语义能否满足使用场景的需求。
12.3.4 对于long和double型变量的特殊规则
JVM规范允许虚拟机将long和double类型的非volatile数据的读写操作划分为2次32位的操作来进行。 如果多个线程共享一个非volatile的long或double变量,并且同时对该变量进行读取和修改,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了"半个变量"的数值。幸好几乎所有平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,否则java程序员就需要在用到long和double变量时声明变量为volatile。
12.3.5 原子性、可见性与有序性
Java内存模型其实是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。
- 原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。对于Java内存模型的8种操作都是原子性的。尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。
-
可见性:是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、 write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去,那在其他线程中就能看见final字段的值。
this逃逸是指在构造函数返回之前其他线程就持有该对象的引用。调用尚未构造完全的对象的方法可能引发令人疑惑的错误。this逃逸经常发生在构造函数中启动线程或注册监听器时, 如:
public class ThisEscape {
public ThisEscape() {
new Thread(new EscapeRunnable()).start();
// ...
}
private class EscapeRunnable implements Runnable {
@Override
public void run() {
// 通过ThisEscape.this就可以引用外围类对象, 但是此时外围类对象可能还没有构造完成, 即发生了外围类的this引用的逃逸
}
}
}
在构造函数中创建Thread对象是没有问题的, 但是不要启动Thread. 可以提供一个init方法, 如:
public class ThisEscape {
private Thread t;
public ThisEscape() {
t = new Thread(new EscapeRunnable());
// ...
}
public void init() {
t.start();
}
private class EscapeRunnable implements Runnable {
@Override
public void run() {
// 通过ThisEscape.this就可以引用外围类对象, 此时可以保证外围类对象已经构造完成
}
}
}
- 有序性:即程序执行的顺序按照代码的先后顺序执行。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。
12.3.6 先行发生原则(Happens-Before原则)
有序性不仅仅靠volatile和synchronized来完成,Java语言中有一个“先行发生”(happens-before)的原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否可能存在冲突的所有问题。
先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存*享变量的值、发送了消息、调用了方法等。
下面是Java内存模型已经存在的先行发生关系,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序:
- 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作(其实由于在处理器中指令的重排序,只能保证对同一变量操作的顺序执行)。
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock 操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile变量规则(这就是volatile的重排序规则):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.isAlive()的返回值等手段检测到线程已经终止执行。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
例子1:
private int value=0;
pubilc void setValue(int value){
this.value=value;
}
public int getValue(){
return value;
}
假设存在线程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;
两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点。时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
12.4 Java与线程
并发不一定要依赖多线程(如PHP中很常见的多进程并发),但是在Java里面谈论并发,大多数都与线程脱不开关系。多进程和多线程
实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。
12.4.1 线程的实现
1.使用内核线程实现:
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如图所示。
但是轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(KernelMode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
用户模式和内核模式:用于限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。处理器通过控制寄存器中的一个模式位来提供这个功能。设置了模式位后,进程就运行在内核模式中(有时也叫超级用户模式)。内核模式下的进程可以执行指令集的任何指令,访问系统所有存储器的位置。没有设置模式位时,进程运行在用户模式。用户模式不允许程序执行特权指令。比如停止处理器,改变模式位,发起一个I/O操作。 不允许用户模式的进程直接引用地址空间的内核区代码和数据。 任何尝试都会导致保护故障。 用户通过系统调用间接访问内核代码和数据。进程从用户模式转变为内核模式的方法:通过中断,故障,陷阱这样的异常。 在异常处理程序中会进入内核模式。退出后,又返回用户模式。
2.使用用户线程实现:
从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(UserThread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。
而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,如图所示。
但是由于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,操作异常困难,已被Java放弃使用。
3.使用用户线程加轻量级进程混合实现:
在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系,如图所示,这种就是多对多的线程模型。许多UNIX系列的操作系统,如Solaris、HP-UX等都提供了N:M的线程模型实现。
4.Java线程的实现:
线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是透明的,因此不必过于关注。对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。
12.4.2 Java线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。
协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。但是线程执行时间不可控制,如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。
我们可以设置线程优先级来建议优先执行某些线程,Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。不过,线程优先级并不是太靠谱,原因是Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Windows中就有7种,比Java线程优先级多的系统还好说,中间留下一点空位就可以了,但比Java线程优先级少的系统,就不得不出现几个优先级相同的情况了。同时优先级可能会被系统自行改变。例如,在Windows系统中存在一个称为“优先级推进器”(Priority Boosting,当然它可以被关闭掉)的功能,它的大致作用就是当系统发现一个线程执行得特别“勤奋努力”的话,可能会越过线程优先级去为它分配执行时间。因此,我们不能在程序中通过优先级来完全准确地判断一组状态都为Ready的线程将会先执行哪一个。
12.4.3 状态转换
Java语言定义了5种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态:
- 新建(New): 当用new操作符创建一个线程时, 例如new Thread(r),线程还没有开始运行,此时线程处在新建状态。
- 运行(Runable):Runable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。
- 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:没有设置Timeout参数的Object.wait()方法;没有设置Timeout参数的Thread.join()方法;LockSupport.park()方法。
- 限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:Thread.sleep()方法;设置了Timeout参数的Object.wait()方法;设置了Timeout参数的Thread.join()方法;LockSupport.parkNanos()方法;LockSupport.parkUntil()方法。
- 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者等待唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
其中,两种等待算一种状态。当然也有一些书中另外5种,新建状态(New);就绪状态(Runnable);运行状态(Running);阻塞状态(Blocked);死亡状态(Dead),我们记上面的。
第13章 线程安全与锁优化
13.2 线程安全
线程安全:一般说来,一个函数被称为线程安全的,当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。
13.2.1 Java语言中的线程安全
将Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立:
1.不可变:
不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施,比如用final修饰的基本数据类型;java.lang.String不可变类,我们调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象,当然利用反射可以消除String类对象的不可变特性。
2.绝对线程安全:
这一点其实很难达到。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过Java API中一个不是“绝对线程安全”的线程安全类来看看这里的“绝对”是什么意思。java.util.Vector是一个线程安全的容器,因为它的add()、get()和size()等方法都是被synchronized修饰的。但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段了,比如:
Vector<String> test = Vector<String>();
.......
.......
for (i = 0; i < test.size(); i++)
{
/////////////////在此处当前线程时间片到期
///线程重新获取时间片,但test内元素有可能已经被其他线程修改或者删除
System.out.println(test.get(i));
}
其实线程安全的集合对象都不是很安全,因为他们是基于单个方法的同步,可以使用synchronized把整个代码片段包起来。
synchronized(test)
{
...
}
比如:
Vector<String> test = Vector<String>();
synchronized(test){
for (i = 0; i < test.size(); i++){
System.out.println(test.get(i));
}
}
再比如,JDK1.5之前,字符串的连续相加会转化为StringBuffer(线程安全)对象的连续append操作,在JDK1.5及以后的版本,会转化为StringBuilder(非线程安全)对象的连续append操作。
3.相对线程安全:
在Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。
4.线程兼容:
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
5.线程对立:
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。一个线程对立的例子是Thread类的suspend()和resume()方法,假设两个线程A、B和一个资源P,B锁定了资源P,A调用suspend()方法中断线程B,但同时A又想获取资源P。对任何线程来说,如果它们想中断目标线程,同时又试图使用这个线程锁定的资源,就会造成死锁,也就是暂停未释放锁。但如果暂停释放了锁,又可能出现不同步问题,比如线程B正在修改P中的数据,刚修改一半被suspend了,然后他释放了P的锁,A线程获得P会发现P中的数据不一致。也正是由于这个原因,suspend()和resume()方法已经被JDK声明废弃(@Deprecated)了。
如何正确的挂起一个线程:可以在Thread实例外设置一个volatile 修饰的boolean变量,指出线程应该活动还是挂起。若标志指出线程应该挂起,便用 wait()命其进入等待状态。若标志指出线程应当恢复,则用一个notify()重新启动线程。同时 ,wait() 和 notify() 这一对方法必须在 synchronized 方法或块中调用,理由也很简单,只有在 synchronized 方法或块中当前线程才占有锁,才有锁可以释放。
13.2.2 线程安全的实现方法
1.互斥同步:
互斥同步(Mutual Exclusion&Synchronization)是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(CriticalSection)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。所以synchronized是Java语言中一个重量级的操作。当然虚拟机本身会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。
java.util.concurrent包中的重入锁(ReentrantLock)也可以实现同步,需要lock()和unlock()方法配合try/finally语句块来完成。相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:
- 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
- 锁可以绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在 synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。
如何选择:只要不使用重入锁的高级功能,提倡选择synchronized。
2.非阻塞同步:
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题。
基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。
3.无同步方案:
同步只是保证共享数据争用时保证正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的,笔者简单地介绍其中的两类:
- 可重入代码(Reentrant Code):可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
- 线程本地存储(Thread Local Storage):比如通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的 threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
13.3 锁优化
13.3.1 自旋锁与自适应自旋
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。但在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
但自旋等待不能代替阻塞,因为自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。JDK1.6中默认开启:自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。
在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。否则就设置很少的自旋。
13.3.2 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步但是被检测到不可能存在共享数据竞争的锁进行消除。当然这项技术基于逃逸分析的数据支持。
13.3.3 锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
13.3.4 轻量级锁
轻量级锁(Lightweight Locking):轻量级锁能提高程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态。在无锁竞争的情况下,可以使用CAS操作避免调用操作系统层面的重量级互斥锁,避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。轻量级锁其实并不会真正的加锁,更多的还是标识一下。
13.3.5 偏向锁
如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了,即一个线程获得了一个偏向锁后,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。
HotSpot虚拟机常用选项
选项的分类
Hotspot JVM提供以下三大类选项:
- 标准选项:这类选项的功能是很稳定的,在后续版本中也不太会发生变化。运行java或者java -help可以看到所有的标准选项。所有的标准选项都是以-开头,比如-version, -server等。
- X选项:比如-Xms。这类选项都是以-X开头。运行java -X命令可以看到所有的X选项。这类选项的功能还是很稳定,但官方的说法是它们的行为可能会在后续版本中改变,也有可能不在后续版本中提供了。
- XX选项:这类选项是属于实验性,主要是给JVM开发者用于开发和调试JVM的,在后续的版本中行为有可能会变化。
XX选项的语法
-XX:+<option>开启option参数。
-XX:-<option>关闭option参数。
-XX:<option>=<value>将option参数的值设置为value。
选项详解
1. 指定JVM的类型:-server,-client
JVM在启动的时候会根据硬件和操作系统自动选择使用Server还是Client类型的JVM。
- 在32位Windows系统上,不论硬件配置如何,都默认使用Client类型的JVM。
- 在其他32位操作系统上,如果机器配置有2GB集群以上的内存同时有2个以上的CPU,则默认会使用Server类型的JVM。
- 64位机器上只有Server类型的JVM。也就是说Client类型的JVM只在32位机器上提供。
- 你也可以使用-server和-client选项来指定JVM的类型,不过只在32位的机器上有效。
2.指定JIT编译器的模式:-Xint,-Xcomp,-Xmixed
我们知道Java是一种解释型语言,但是随着JIT技术的进步,它能在运行时将Java的字节码编译成本地代码。以下是几个相关的选项:
- -Xint表示禁用JIT,所有字节码都被解释执行,这个模式的速度最慢的。
- -Xcomp表示所有字节码都首先被编译成本地代码,然后再执行。
- -Xmixed,默认模式,让JIT根据程序运行的情况,有选择地将某些代码编译成本地代码。
- -Xcomp和-Xmixed到底谁的速度快,针对不同的程序可能有不同的结果,基本还是推荐用默认模式。
3.-version和-showversion
- -version就是查看当前机器的java是什么版本,是什么类型的JVM(Server/Client),采用的是什么执行模式。
- -showversion的作用是在运行一个程序的时候首先把JVM的版本信息打印出来,这样便于问题诊断。个人建议Server类型的程序都把这个选项打开,这样可以发现一些配置问题,比如程序需要JDK1.7才能运行,而有的机器上装有多个JDK的版本,打开这个选项可以避免使用了错误版本的Java。
比如,在我的机器上的结果如下:
C:\Users\xie>java -version
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)
表示我机器上java是运行在mixed模式下的Server VM。
3.查看XX选项的值: -XX:+PrintCommandLineFlags, -XX:+PrintFlagsInitial和-XX:+PrintFlagsFinal
与-showversion类似,-XX:+PrintCommandLineFlags可以让在程序运行前打印出用户手动设置或者JVM自动设置的XX选项,建议加上这个选项以辅助问题诊断。比如在我的机器上,JVM自动给配置了初始的和最大的HeapSize以及其他的一些选项:
C:\Users\xie>java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=132399808 -XX:MaxHeapSize=2118396928 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)
相关另外两个选项:-XX:+PrintFlagsInitial表示打印出所有XX选项的默认值,-XX:+PrintFlagsFinal表示打印出XX选项在运行程序时生效的值。
4.内存大小相关的选项
- -Xms 设置初始堆的大小,也是最小堆的大小,它等价于:-XX:InitialHeapSize
- -Xmx 设置最大堆的大小,它等价于-XX:MaxHeapSize。
比如,下面这条命令就是设置堆的初始值为128m,最大值为2g。
java -Xms128m -Xmx2g MyApp
如果堆的初始值和最大值不一样的话,JVM会根据程序的运行情况,自动调整堆的大小,这可能会影响到一些效率。针对服务端程序,一般是把堆的最小值和最大值设置为一样来避免堆扩展和收缩对性能的影响。
- -XX:PermSize 用来设置永久区的初始大小
- -XX:MaxPermSize 用来设置永久区的最大值
永久区是存放类以及常量池的地方,如果程序需要加载的class数量非常多的话,就需要增大永久区的大小。
- -Xss 设置线程栈的大小,线程栈的大小会影响到递归调用的深度,同时也会影响到能同时开启的线程数量。
5.OutofMemory(OOM)相关的选项
如果程序发生了OOM后,JVM可以配置一些选项来做些善后工作,比如把内存给dump下来,或者自动采取一些别的动作。
- -XX:+HeapDumpOnOutOfMemoryError 表示在内存出现OOM的时候,把Heap转存(Dump)到文件以便后续分析,文件名通常是java_pid.hprof,其中pid为该程序的进程号。
- -XX:HeapDumpPath=: 用来指定heap转存文件的存储路径,需要指定的路径下有足够的空间来保存转存文件。
- -XX:OnOutOfMemoryError 用来指定一个可行性程序或者脚本的路径,当发生OOM的时候,去执行这个脚本。
比如,下面的命令可以使得在发生OOM的时候,Heap被转存到文件/tmp/heapdump.hprof,同时执行Home目录中的cleanup.sh文件。
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:OnOutOfMemoryError ="sh ~/cleanup.sh" MyApp
个人觉得几个选项还是非常有用的,它可以使得你有相关的信息来分析OOM的根源。
6.新生代相关的选项
新生代的空间大小很重要:如果新生代空间过小,就会导致对象很快就被移动到老生代,从而使得某些原本可以及时回收的对象存活的时间过长,而且老生代回收的代价更大。那相反,如果新生代空间过大,就会使得某些存活时间长的对象在新生代倒腾了很多次,影响到新生代回收垃圾的效率。这就需要根据应用的特点,找到一个合适的值。Hotspot提供了如下一些选项来调节新生代的参数:
- -XX:NewSize和-XX:MaxNewSize分别用来设置新生代的最小和最大值。需要注意的是,新生代是JVM堆的一部分,新生代的空间大小不能大于老生代的大小,因为在极端的情况下,新生代中对象可能会被全部移到老生代,因此-XX:MaxNewSize最大只能设为-Xmx的一半。
- -XX:NewRatio用来设置老生代和新生代大小的比例,比如-XX:NewRatio=2表示1/3的Heap是新生代,2/3的Heap是老生代。使用这个选项的好处是新生代的大小也能随着Heap的变化而变化。
- -XX:SurvivorRatio用来设置新生代中Eden和Survivor空间大小的比例,需要注意的是有两个Survivor。比如-XX:SurvivorRatio=8表示Eden区域在新生代的8/10,两个Survivor分别占1/10。调节Survivor空间的时候也注意要折中,如果Survivor空间小的话,那么很有可能在一次MinorGC的时候Survivor空间就满了,从而对象就被移到了老生代;如果Survivor空间大的话,那么Eden区域就小了,从而导致MinorGC的发生得更频繁。
总得来说,调节新生代的目标是:1)避免对象过早地被移到了老生代 2)也要避免需要长期存活的对象在新生代呆的时间过长,这会提高MinorGC发生的频率以及增加单次MinorGC的时间。这需要针对程序的运行情况做一些分析。接下来就介绍一个参数来分析新生代对象年龄的分布。
7.-XX:+PrintTenuringDistribution
- -XX:+PrintTenuringDistribution让JVM在每次MinorGC后打印出Survivor空间中的对象的年龄分布。比如:
Desired survivor size 75497472 bytes, new threshold 15 (max 15)
- age 1: 19321624 bytes, 19321624 total
- age 2: 79376 bytes, 19401000 total
- age 3: 2904256 bytes, 22305256 total
从第一行中可以看出JVM期望的Survivor空间占用为72M,对象被移到老年代中的年龄阈值为15。其中期望的Survivor空间大小为Survivor空间大小 乘以 -XX:TargetSurvivorRatio的值。
接下来的一行,表示年龄为1的对象约19M,年龄为2的对象约79k,年龄为3的对象约为2.9M,每行后面的数值表示所有小于等于该行年龄的对象的总共大小,比如最后一行就表示所有年龄小于等于3的对象的总共大小为约22M(等于所有年龄对象大小的和)。因为目前Survivor空间中对象的大小22M小于期望Survivor空间的大小72M,所以没有对象会被移到老年代。
假设下一次MinorGC后的输出结果为:
Desired survivor size 75497472 bytes, new threshold 2 (max 15)
- age 1: 68407384 bytes, 68407384 total
- age 2: 12494576 bytes, 80901960 total
- age 3: 79376 bytes, 80981336 total
- age 4: 2904256 bytes, 83885592 total
上次MinorGC后还存活的对象在这次MinorGC年龄都增加了1,可以看到上次年龄为2和3的对象(对应在这次GC后的年龄为3和4)依然存在(大小未变),而一部分上次对象年龄为1的对象在这次GC时被回收了。同时可以看到这次新增了约68M的新对象。这次MinorGC后Survivor区域中对象总的大小为约83M,大于了期望的Survivor空间的大小72M,因此它就把对象移到老年代的年龄的阈值调整为2,在下次MinorGC时一部分对象就会被移到老年代了。
相关的调整选项有:
- -XX:InitialTenuringThreshold 表示对象被移到老年代的年龄阈值的初始值
- -XX:MaxTenuringThreshold 表示对象被移到老年代的年龄阈值的最大值
- -XX:TargetSurvivorRatio 表示MinorGC结束了Survivor区域中占用空间的期望比例。
这些参数的调节没有统一的标准,但是有两点可以借鉴:
- 如果Survivor中对象的年龄分布显示很多对象在经历了多次GC最终年龄达到了-XX:MaxTenuringThreshold才被移到老年代,这可能说明-XX:MaxTenuringThreshold设置得过大,也有可能是Survivor的空间过大。
- 如果-XX:MaxTenuringThreshold的值大于1,但是很多对象年龄都不大于1,那就得关注一下期望的Survivor空间。如果每次GC后Survivor中对象的大小都没有超过期望的Survivor空间大小,则说明GC工作得很好。反之,则说明可能Survivor空间小了,使得新生成的对象很快就被移到了老年代了。
8.GC日志相关的选项
分析GC问题不可避免地要查看GC日志,下面是一些GC日志相关的选项:
- -XX:+PrintGC,等同于-verbose:gc 表示打开简化的GC日志,相关输出如下:
[GC 425355K->351685K(506816K), 0.2175300 secs]
[Full GC 500561K->456058K(506816K), 0.6421920 secs]
其中以GC开头的行表示发生了一次Minor GC,后面的数字表示收集前后Heap空间的占用量,圆括号里面表示Heap大小,最后的数字表示用了多少时间。比如:上面的例子中,表示在这次GC新生代空间占用从425355K降到了351685K,总的新生代空间为506816K,这次GC耗时0.22秒。
通过这个选项只能看到一些基本信息,而且所有收集器的输出在这个模式下都是一样的。
- -XX:+PrintGCDetails 这个选项会打印出更多的GC日志,不同的收集器产生的日志会不一样。
- -XX:+PrintGCTimeStamps and -XX:+PrintGCDateStamps 这两个选项把GC的时间戳显示在GC的日志中。其中,-XX:+PrintGCTimeStamps打印GC发生的时间相对于JVM启动的时间,-XX:+PrintGCDateStamps表示打印出GC发生的具体时间。
比如,以下是-XX:+PrintGCTimeStamps的输出
0,185: [GC 66048K->53077K(251392K), 0,0977580 secs]
0,323: [GC 119125K->114661K(317440K), 0,1448850 secs]
0,603: [GC 246757K->243133K(375296K), 0,2860800 secs]
以下是两个都打开后的输出
2014-12-26T17:52:38.613-0800: 3.395: [GC 139776K->58339K(506816K), 0.1442900 secs]
- -Xloggc: 表示把GC日志写入到一个文件中去,而不是打印到标准输出中。
需要注意的是:这些和GC日志相关的选项可以在JVM已经启动后再开启,可以通过jinfo这个工具去设置。具体可以参见jinfo的帮助文件。这样就可以在需要诊断问题的时候再开启GC日志。
9.吞吐量优先收集器的相关选项
衡量JVM垃圾收集器的两个基本指标是吞吐量和停顿时间。吞吐量是指执行用户代码的时间占总的时间的比例,总的时间包括执行用户代码的时间和垃圾回收占用的时间。在垃圾回收的时候执行用户代码的线程必须暂停,这会导致程序暂时失去响应。停顿时间就是衡量垃圾回收时造成的用户线程暂停的时间。这两个指标在一定程度是相互矛盾的,不可能让一个程序的吞吐量很高的同时停顿时间也短,只能以优先选择一个目标或者折中一下。因此,不同的垃圾回收器会有不同的侧重点。
在Hotspot JVM中,侧重于吞吐量的垃圾回收器是Parallel Scavenge,它的相关选项如下:
- -XX:+UseParallelOldGC 表示新生代和老生代都使用并行回收器,其中的Old表示老生代的意思,而不是旧的意思。
- -XX:ParallelGCThreads=n 表示配置多少个线程来回收垃圾。默认的配置是如果处理器的个数小于8,那么就是处理器的个数;如果处理器大于8,它的值就是3+5N/8。也可以根据程序的需要去设置这个值,比如你的机器有16核,上面有4个Java程序,那么设置将这个值设置为4比较合理,因为JVM不会去探测同一机器上有多少个Java程序。
- -XX:UseAdaptiveSizePolicy 表示是否开启自适应策略,打开这个开关后,JVM自动调节JVM的新生代大小,Eden和Survivor的比例等参数。用户只需要设置期望的吞吐量(-XX:GCTimeRatio)和期望的停顿时间(-XX:MaxGCPauseMillis)。然后,JVM会尽量去向用户期望的方向去优化。
此外,如果机器只有一个核的话,采用并行回收器可能得不偿失,因为多个回收线程会争抢CPU资源,反而造成更大的消耗。这时,就最好采用串行回收器,相关的参数是-XX:+UseSerialGC
10.CMS收集器
CMS收集器(ConcurrentMarkandSweep),是一个关注系统停顿时间的收集器。它的主要思想是把收集器分成了不同的阶段,其中某些阶段是可以用户程序并行的,从而减少了整体的系统停顿时间。
CMS虽然能减少系统的停顿时间,但是它也有其缺点:
- 从它的名字可以看出,它是一个标记-清除收集器,也就说运行了一段时间后,内存会产生碎片,从而导致无法找到连续空间来分配大对象。
- CMS收集器在运行过程中会占用一些内存,同时系统还在运行,如果系统产生新对象的速度比CMS清理的速度快的话,会导致CMS运行失败。
当上面的任何一种情况发生的时候,JVM就会触发一次Full GC,会导致JVM停顿较长时间。
它的相关选项如下:
- -XX:+UseConcMarkSweepGC 表示老年代开启CMS收集器,而新生代默认会使用并行收集器。
- -XX:ConcGCThreads 指定用多少个线程来执行CMS的并非阶段。
- -XX:CMSInitiatingOccupancyFraction 指定在老生代用掉多少内存后开始进行垃圾回收。与吞吐量优先的回收器不同的是,吞吐量优先的回收器在老生代内存用尽了以后才开始进行收集,这对CMS来讲是不行的,因为吞吐量优先的垃圾回收器运行的时候会停止所有用户线程,所以不会产生新的对象,而CMS运行的时候,用户线程还有可能产生新的对象,所以不能等到内存用光后才开始运行。比如-XX:CMSInitiatingOccupancyFraction=75表示老生代用掉75%后开始回收垃圾。默认值是68。
- -XX:+ExplicitGCInvokesConcurrent 如果在代码里面显式调用System.gc(),那么它还是会执行Full GC从而导致用户线程被暂停。采用这个选项使得显式触发GC的时候还是使用CMS收集器。
- -XX:+DisableExplicitGC 一个相关的选项,这个选项是禁止显式调用GC