JVM思考

Java虚拟机系列——检视阅读(三)

2020-10-14  本文已影响0人  卡斯特梅的雨伞

方法调用——多态

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作。在Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。

一、方法解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一可确定的调用版本,并且这个方法的调用版本是运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中,符合“编译期可知,运行期不可变”这个要求的方法有静态方法和私有方法两大类,前者与类型直接相关联,后者在外部不可被访问,这两种方法都不可能通过继承或者别的方式重写出其它版本,因此它们都适合在类加载阶段进行静态解析。

与之相对应,在Java虚拟机里提供了四条方法调用字节码指令,分别是:

  1. invokestatic:调用静态方法

  2. invokespecial:调用实例构造器<init>方法,私有方法和父类方法。

  3. invokevirtual:调用虚方法。

  4. invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

只要能invokestaticinvokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器和父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以统称为非虚方法,与之相反,其它方法就称为虚方法(除去final方法)。

Java中的非虚方法除了使用invokestaticinvokespecial指令调用的方法之后还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无须对方法接收都进行多态选择,又或者说多态选择的结果是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

解析调用一定是个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派与多分派。这两类分派方式两两组合就构成了静态单分派,静态多分派,动态单分派与动态多分派情况。

二、分派

1.静态分派——重载

下面是一段程序代码:

<pre class="md-fences md-end-block" lang="java" contenteditable="false" cid="n1099" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
package com.xtayfjpk.jvm.chapter8;

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 man) {
System.out.println("hello man...");
}
public void sayHello(Woman woman) {
System.out.println("hello woman...");
}

public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}
}</pre>

执行结果为:

hello guy...hello guy...

但为什么会选择执行参数为Human的重载呢?在这之前,先按如下代码定义两个重要的概念:Human man = new Man();

上面代码中的“Human”称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么?如下面的代码:

<pre class="md-fences md-end-block" lang="java" contenteditable="false" cid="n1109" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
//实际类型变化
Human man = new Man();
man = new Woman();

//静态类型变化
sd.sayHello((Man)man);
sd.sayHello((Woman)man);</pre>

解释了这两个概念,再回到上述代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sd”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意定义了两个静态类型相同,实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型在编译期是可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法的两条invokevirual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但是很多情况下,这个重载版本并不是“唯一的”,往往只能确定一个“更适合的”版本。这种模糊的结论在0和1构成的计算机世界中算是个比较“稀罕”的事件,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

2.动态分派——重写

动态分派与重写(Override)有着很密切的关联。如下代码:

<pre class="md-fences md-end-block" lang="java" contenteditable="false" cid="n1117" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
package com.xtayfjpk.jvm.chapter8;

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();
}
}</pre>

这里显然不可能是根据静态类型来决定的,因为静态类型都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原是是这两个变量的实际类型不同。那么Java虚拟机是如何根据实际类型来分派方法执行版本的呢,我们使用javap命令输出这段代码的字节码,结果如下:

<pre class="md-fences md-end-block" lang="java" contenteditable="false" cid="n1120" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #16 // class com/xtayfjpk/jvm/chapter8/DynamicDispatchMan 3: dup 4: invokespecial #18 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatchMan."<init>":()V
7: astore_1
8: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatchWoman 11: dup 12: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatchWoman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatchHuman.sayHello:()V 20: aload_2 21: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatchHuman.sayHello:()V
24: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatchWoman 27: dup 28: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatchWoman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
36: return</pre>

0-15行的字节码是准备动作,作用是建立man和woman的内存空间,调用Man和Woman类的实例构造器,将这两个实例的引用存放在第1和第2个局部变量表Slot之中,这个动作对应了代码中这两句:

<pre class="md-fences md-end-block" lang="java" contenteditable="false" cid="n1123" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
Human man = new Man();
Human woman = new Woman();</pre>

接下来的第16-21行是关键部分,第16和第20两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将执行的sayHello()方法的所有者,称为接收者(Receiver),第17和第21两行是方法调用指令,单从字节码的角度来看,这两条调用指令无论是指令(都是invokevirtual)还是参数(都是常量池中Human.sayHello()的符号引用)都完全一样,但是这两条指令最终执行的目标方法并不相同,其原因需要从invokevirutal指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象实际类型,记作C。

  2. 如果在类型C中找到与常量中描述符和简单名称都相同的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;不通过则返回java.lang.IllegalAccessError错误。

  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索与校验过程。

  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError错误。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

3.单分派与多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派与多分派两种。单分派是根据一个宗量来对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

在编译期的静态分派过程选择目标方法的依据有两点:一是静态类型;二是方法参数,所以Java语言的静态分派属于多分派类型。在运行阶段虚拟机的动态分派过程只有接收者的实际类型一个宗量作为目标方法选择依据,所以Java语言的动态分派属于单分派类型。所在Java语言是一门静态多分派,动态单分派语言

4.虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要在运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真的进行如此频繁的搜索。面对这种情况,最常用的优化手段就是在类的方法区中建立一个虚方法表(Virtual Method Table,也称vtable,与此对应,在invokeinterface执行时也会用到接口方法表,Interface Method Table,也称itable),使用虚方法表索引来代替元数据据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会被替换为指向子类实现版本的地址入口。

疑问:

Q: 而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派与多分派。这两类分派方式两两组合就构成了静态单分派,静态多分派,动态单分派与动态多分派情况。什么叫宗量数?

内存模型JMM——定义程序中各个变量的访问规则

主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variable)与Java编译中所说的变量略有区别,它包括了实例字段,静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不存在竞争的问题。为了获得比较好的执行效率,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码执行顺序这类权限。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝(内存交互规则:)1、线程对变量的所有操作(读取,赋值等)都必须是工作内存中进行,而不能直接读写主内存中的变量。2、不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图:

image

内存间交互操作

一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成。

  1. lock(锁定):作用于主内存变量,它把一个变量标识为一条线程独占的状态。

  2. unlock(解锁):作用于主内存变量,它把一个处理锁定的状态的变量释放出来,释放后的变量才可以被其它线程锁定,unlock之前必须将变量值同步回主内存。

  3. read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  4. load(载入):作用于工作内存变量,它把read操作从主内存中得到的值放入工作内存的变量副本中。

  5. use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将会执行这个操作。

  6. assign(赋值):作用于工作内存变量,它把一个从执行引擎接到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  7. store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的值放入主内存的变量中。

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。Java内存模型只是要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是说read与load之间、store与write之间是可以插入其它指令的,如果对主在内中的变量a,b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了执行上述八种基础操作时必须满足如下规则:

  1. 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写但主内存不接受的情况出现。

  2. 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变(为工作内存变量赋值)了之后必须把该变化同步回主内存。

  3. 不允许一个线程无原因地(没有发生任何assign操作)把数据从线程的工作内存同步回主内存中。

  4. 一个新变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load和assign)的变量,换话说就是一个变量在实施use和store操作之前,必须先执行过了assign和load操作。

  5. 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  7. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其它线程锁定的变量。

  8. 对一个变量执行unloack之前,必须把此变量同步回主内存中(执行store和write操作)

疑问:

Q: unlock(解锁):作用于主内存变量,它把一个处理锁定的状态的变量释放出来,释放后的变量才可以被其它线程锁定,unlock之前必须将变量值同步回主内存。unlock之前必须将变量值同步回主内存是指调用unlock之前必须先调用store,然后调用write操作,还是说unlock之前会自动调用store,然后调用write操作?

volatile变量的特殊规则——保证变量对所有线程的可见性及禁止指令重排序优化

当一个变量定义成volatile之后,它将具备两种特性:第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其它线程是可以立即得知的,变量值在线程间传递均需要通过主内存来完成,如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见。

关于volatile变量的可见性,很多人误以为以下描述成立:“volatile对所有线程是立即可见的,对volatile变量所有的写操作都能立即返回到其它线程之中,换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是其论据并不能得出“基于基于volatile变量的运算在并发下是安全的”这个结论。volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不致的情况,因此可以认为不存在一致性问题),但是Java里的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的

由于volatile变量只能保证可见性,在不符合以下条件规则的两处场景中,仍然需要通过加锁来保证原子性。

这两种场景下volatile变量可以保证安全:

  1. 运算结果不依赖变量的当前值,或者能确保只有单一的线程改变变量的值

  2. 变量不需要与其它的状态变量共同参与不变约束

使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方能获取到正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是Java内存模型中描述的所谓的”线程内表现为串行的语义“(Within-Thread As-If-Serial Sematics)。

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n1249" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized = false;
//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后
//将initialized设置为true来通知其它线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

//假设以下代码在线程B中执行
//等线程A待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized) {
sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();</pre>

上面为一段伪代码,其中描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句的代码”initialized = true“被提前执行,这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。

Java内存模型中对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read、load、use、assign、store、write操作时需要满足如下的规则:

  1. 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load操作。线程T对变量V的use操作可以认为是与线程T对变量V的load和read操作相关联的,必须一起连续出现。这条规则要求在工作内存中,每次使用变量V之前都必须先从主内存刷新最新值,用于保证能看到其它线程对变量V所作的修改后的值。

  2. 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store操作;并且,只有当线程T对变量V执行的后一个动作是store操作的时候,线程T才能对变量V执行assign操作。线程T对变量V的assign操作可以认为是与线程T对变量V的store和write操作相关联的,必须一起连续出现。这一条规则要求在工作内存中,每次修改V后都必须立即同步回主内存中,用于保证其它线程可以看到自己对变量V的修改。

  3. 假定操作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相应的对变量V的read或write操作。如果A先于B,那么P先于Q。这条规则要求valitile修改的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。

原子性、可见性、有序性

volatile只能保证可见性和有序性;

synchronized可以保证原子性,可见性以及有序性。

Java内存模型是围绕着并发过程中如何处理原子性、可见性、有序性这三个特征来建立的,下面是这三个特性的实现原理:

1.原子性(Atomicity)——原子性变量操作包括read、load、use、assign、store和write六个

由Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock与unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐匿地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块---synchronized关键字,因此在synchronized块之间的操作也具备原子性。

2.可见性(Visibility)——volatile、synchronized、final

可见性就是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每使用前立即从内存刷新。因为我们可以说volatile保证了线程操作时变量的可见性,而普通变量则不能保证这一点。除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值。

3.有序性(Ordering)

Java内存模型中的程序天然有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则来获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

先行发生原则:Happen-Before:——线程执行节点的不确定便不能保证先行发生原则如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很啰嗦,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个“先行发生”(Happen-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的主要依赖。先行发生原则是指Java内存模型中定义的两项操作之间的依序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。它意味着什么呢?如下例:

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n1290" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
//线程A中执行
i = 1;

//线程B中执行
j = i;

//线程C中执行
i = 2;</pre>

假设线程A中的操作”i=1“先行发生于线程B的操作”j=i“,那么我们就可以确定在线程B的操作执行后,变量j的值一定是等于1,结出这个结论的依据有两个,一是根据先行发生原则,”i=1“的结果可以被观察到;二是线程C登场之前,线程A操作结束之后没有其它线程会修改变量i的值。现在再来考虑线程C,我们依然保持线程A和B之间的先行发生关系,而线程C出现在线程A和B操作之间,但是C与B没有先行发生关系,那么j的值可能是1,也可能是2,因为线程C对应变量i的影响可能会被线程B观察到,也可能观察不到,这时线程B就存在读取到过期数据的风险,不具备多线程的安全性。

下面是Java内存模型下一些”天然的“先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

  1. 程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。

  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。

  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。

  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作

  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。

  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。

  7. 对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。

  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生“呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与先生发生原则之间基本没有什么关系,所以衡量并发安全问题一切必须以先行发生原则为准。

疑问:

Q: final关键字的可见性是指:被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值。final关键字保证可见性的原因是因为其不可变吧?怎么理解这句话?

Hotspot JVM的常用选项

本文将介绍Hotspot JVM的常用选项。

选项的分类

Hotspot JVM提供以下三大类选项:

标准选项:这类选项的功能是很稳定的,在后续版本中也不太会发生变化。运行java或者java -help可以看到所有的标准选项。所有的标准选项都是以-开头,比如-version, -server等。X选项:比如-Xms。这类选项都是以-X开头,可能由于这个原因它们被称为X选项。运行java -X命令可以看到所有的X选项。这类选项的功能还是很稳定,但官方的说法是它们的行为可能会在后续版本中改变,也有可能不在后续版本中提供了。XX选项:这类选项是属于实验性,主要是给JVM开发者用于开发和调试JVM的,在后续的版本中行为有可能会变化。

XX选项的语法

如果是布尔类型的选项,它的格式为-XX:+flag或者-XX:-flag,分别表示开启和关闭该选项。针对非布尔类型的选项,它的格式为-XX:flag=value在了解这些约定的规范后,我们就可以来看看一些比较常用的选项了。

指定JVM的类型:-server,-client

Hotspot JVM有两种类型,分别是server和client。它们的区别是Server VM的初始堆空间会大一些,默认使用的是并行垃圾回收器。Client VM相对来讲会保守一些,初始堆空间会小一些,使用串行的垃圾回收器,它的目标是为了让JVM的启动速度更快。

JVM在启动的时候会根据硬件和操作系统会自动选择使用Server还是Client类型的JVM。

在32位Windows系统上,不论硬件配置如何,都默认使用Client类型的JVM。在其他32位操作系统上,如果机器配置有2GB及以上的内存同时有2个以上的CPU,则默认会使用Server类型的JVM64位机器上只有Server类型的JVM。也就是说Client类型的JVM只在32位机器上提供。你也可以使用-server和-client选项来指定JVM的类型,不过只在32位的机器上有效,原因见上面一条。详细内容请参见:http://docs.oracle.com/javase/7/docs/technotes/guides/vm/server-class.html

指定JIT编译器的模式:-Xint,-Xcomp,-Xmixed

我们知道Java是一种解释型语言,但是随着JIT技术的进步,它能在运行时将Java的字节码编译成本地代码。以下是几个相关的选项:

-Xint表示禁用JIT,所有字节码都被解释执行,这个模式的速度最慢的。****-Xcomp表示所有字节码都首先被编译成本地代码,然后再执行。****-Xmixed,默认模式,让JIT根据程序运行的情况,有选择地将某些代码编译成本地代码。-Xcomp和-Xmixed到底谁的速度快,针对不同的程序可能有不同的结果,基本还是推荐用默认模式。

-version和-showversion

-version就是查看当前机器的java是什么版本,是什么类型的JVM(Server/Client),采用的是什么执行模式。比如,在我的机器上的结果如下:

$ java -versionjava version "1.7.0_71"Java(TM) SE Runtime Environment (build 1.7.0_71-b14)Java HotSpot(TM) 64-Bit Server VM (build 24.71-b01, mixed mode)表示我机器上java是运行在mixed模式下的Server VM。

-showversion的作用是在运行一个程序的时候首先把JVM的版本信息打印出来,这样便于问题诊断。个人建议Server类型的程序都把这个选项打开,这样可以发现一些配置问题,比如程序需要JDK1.7才能运行,而有的机器上装有多个JDK的版本,打开这个选项可以避免使用了错误版本的Java。

查看XX选项的值: -XX:+PrintCommandLineFlags, -XX:+PrintFlagsInitial和-XX:+PrintFlagsFinal

与-showversion类似,-XX:+PrintCommandLineFlags可以让在程序运行前打印出用户手动设置或者JVM自动设置的XX选项,建议加上这个选项以辅助问题诊断。比如在我的机器上,JVM自动给配置了初始的和最大的HeapSize以及其他的一些选项:

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n2994" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
$ java -XX:+PrintCommandLineFlags -version

-XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:+PrintCommandLineFlags -XX:+UseCompressedOops -XX:+UseParallelGC

java version "1.7.0_71"

Java(TM) SE Runtime Environment (build 1.7.0_71-b14)

Java HotSpot(TM) 64-Bit Server VM (build 24.71-b01, mixed mode)
​</pre>

相关另外两个选项:-XX:+PrintFlagsInitial表示打印出所有XX选项的默认值,-XX:+PrintFlagsFinal表示打印出XX选项在运行程序时生效的值。

内存大小相关的选项

-Xms 设置初始堆的大小,也是最小堆的大小,它等价于:-XX:InitialHeapSize-Xmx 设置最大堆的大小,它等价于-XX:MaxHeapSize。比如,下面这条命令就是设置堆的初始值为128m,最大值为2g。

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n3226" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
java -Xms128m -Xmx2g MyApp</pre>

如果堆的初始值和最大值不一样的话,JVM会根据程序的运行情况,自动调整堆的大小,这可能会影响到一些效率。针对服务端程序,一般是把堆的最小值和最大值设置为一样来避免堆扩展和收缩对性能的影响。****-XX:PermSize 用来设置永久区的初始大小****-XX:MaxPermSize 用来设置永久区的最大值永久区是存放类以及常量池的地方,如果程序需要加载的class数量非常多的话,就需要增大永久区的大小。-Xss 设置线程栈的大小,线程栈的大小会影响到递归调用的深度,同时也会影响到能同时开启的线程数量

OutofMemory(OOM)相关的选项

如果程序发生了OOM后,JVM可以配置一些选项来做些善后工作,比如把内存给dump下来,或者自动采取一些别的动作。

-XX:+HeapDumpOnOutOfMemoryError 表示在内存出现OOM的时候,把Heap转存(Dump)到文件以便后续分析,文件名通常是java_pid<pid>.hprof,其中pid为该程序的进程号。****-XX:HeapDumpPath=<path>: 用来指定heap转存文件的存储路径,需要指定的路径下有足够的空间来保存转存文件。****-XX:OnOutOfMemoryError 用来指定一个可行性程序或者脚本的路径,当发生OOM的时候,去执行这个脚本。

比如,下面的命令可以使得在发生OOM的时候,Heap被转存到文件/tmp/heapdump.hprof,同时执行Home目录中的cleanup.sh文件。

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n3019" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
$ java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:OnOutOfMemoryError ="sh ~/cleanup.sh" MyApp</pre>

个人觉得几个选项还是非常有用的,它可以使得你有相关的信息来分析OOM的根源。

新生代相关的选项

在介绍新生代相关的选项前,先简要介绍下Hotspot VM的Heap分代的背景知识。很多面向对象程序在运行时都具有如下两点特征:

新创建的对象通常不会存活很长时间,也就是夭折了。很少有老对象引用到新对象。基于这里两点,把新老对象分别放在不同的区域(分别叫做新生代和老生代)可以针对新老对象的特点使用不同的回收算法,同时在回收新对象的时候不用遍历老对象,从而提高垃圾回收的效率。

在Hotspot JVM中,它进一步地将新生代分成了三个区域,一个稍大的区域Eden和两个较小但大小相等的Survivor区域(分别叫做From和To)。一般来讲,新对象首先分配在Eden区域,当Eden区域满的时候,会执行一次Minor GC。MinorGC使用的是标记-复制算法。垃圾回收器会首先标记Eden和From区域中还存活的对象,然后把它们全部移动到To区域,这样Eden和From区域的空间就可以全部回收了,最后再将指向From和To区域的指针交换一下。

下图展示了MinorGC的流程,绿色区域表示空闲空间,红色表示活动对象,黄色表示可以回收的对象。

image

简要总结一下,对象在新生代的生命周期是,它首先在Eden区域诞生,如果对象在MinorGC时还存活的话,就移动到Survivor区域。在后续的MinorGC的时候,如果对象还继续存活的话,就在两个Survivor区域将倒腾。那对象什么时候会被移动到老生代呢?有以下条件:

1、Survivor区域中存活对象占用Survivor空间达到了指定的阈值。2、对象在Survivor空间每倒腾一次其年龄就加1,如果一个对象的年龄达到了一个阈值,也会被移动到老生代。3、大对象会在创建的时候就会被直接放到老生代。由此可见,新生代的空间大小很重要:如果新生代空间过小,就会导致对象很快就被移动到老生代,从而使得某些原本可以及时回收的对象存活的时间过长,而且老生代回收的代价更大。那相反,如果新生代空间过大,就会使得某些存活时间长的对象在新生代倒腾了很多次,影响到新生代回收垃圾的效率。这就需要根据应用的特点,找到一个合适的值。Hotspot提供了如下一些选项来调节新生代的参数:

-XX:NewSize和-XX:MaxNewSize分别用来设置新生代的最小和最大值。需要注意的是,新生代是JVM堆的一部分,新生代的空间大小不能大于老生代的大小,因为在极端的情况下,新生代中对象可能会被全部移到老生代,因此-XX:MaxNewSize最大只能设为-Xmx的一半。****-XX:NewRatio用来设置老生代和新生代大小的比例,比如-XX:NewRatio=2表示1/3的Heap是新生代,2/3的Heap是老生代。使用这个选项的好处是新生代的大小也能随着Heap的变化而变化。

-XX:+PrintTenuringDistribution

-XX:+PrintTenuringDistribution让JVM在每次MinorGC后打印出Survivor空间中的对象的年龄分布。比如:

Desired survivor size 75497472 bytes, new threshold 15 (max 15)

接下来的一行,表示年龄为1的对象约19M,年龄为2的对象约79k,年龄为3的对象约为2.9M,每行后面的数值表示所有小于等于该行年龄的对象的总共大小,比如最后一行就表示所有年龄小于等于3的对象的总共大小为约22M(等于所有年龄对象大小的和)。因为目前Survivor空间中对象的大小22M小于期望Survivor空间的大小72M,所以没有对象会被移到老年代。

假设下一次MinorGC后的输出结果为:

Desired survivor size 75497472 bytes, new threshold 2 (max 15)

相关的调整选项有:

-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空间小了,使得新生成的对象很快就被移到了老年代了。

吞吐量优先收集器的相关选项

衡量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

CMS收集器

CMS收集器(ConcurrentMarkandSweep),是一个关注系统停顿时间的收集器。它的主要思想是把收集器分成了不同的阶段,其中某些阶段是可以用户程序并行的,从而减少了整体的系统停顿时间。它主要分成了以下几个阶段:

初始标记 initial mark并发标记 concurrent mark重新标记 remark并发清理 concurrent clean并发重置 concurrent reset凡是名字以并发开头的阶段都是可以和用户线程并行的,其他阶段也是要暂停用户程序线程。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

GC日志相关的选项

分析GC问题不可避免地要查看GC日志,下面是一些GC日志相关的选项:

-XX:+PrintGC,等同于-verbose:gc 表示打开简化的GC日志,相关输出如下:

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n3132" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
[GC 425355K->351685K(506816K), 0.2175300 secs]

[Full GC 500561K->456058K(506816K), 0.6421920 secs]</pre>

其中以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的输出

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n3142" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">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]
​</pre>

以下是-XX:+PrintGCDateStamps打开后的输出

<pre class="md-fences md-end-block" lang="" contenteditable="false" cid="n3147" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 0.9em; white-space: pre; text-align: left; break-inside: avoid; display: block; background-image: inherit; background-position: inherit; background-size: inherit; background-repeat: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(221, 221, 221); border-radius: 3px; padding: 8px 1em 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; color: rgb(51, 51, 51); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">
2014-12-26T17:52:38.613-0800: 3.395: [GC 139776K->58339K(506816K), 0.1442900 secs]</pre>

-Xloggc:<file> 表示把GC日志写入到一个文件中去,而不是打印到标准输出中。需要注意的是:这些和GC日志相关的选项可以在JVM已经启动后再开启,可以通过jinfo这个工具去设置。具体可以参见jinfo的帮助文件。这样就可以在需要诊断问题的时候再开启GC日志。

疑问:

Q: Xint 中int的详细名词是什么?有没有所有参赛详细名词的全称,方便记忆?

Q: -showversion的作用是在运行一个程序的时候首先把JVM的版本信息打印出来,这样便于问题诊断

-showversion是配置在哪里,怎么使用?

Q: -XX:+PrintGC,等同于-verbose:gc 表示打开简化的GC日志。这个怎么使用,放在配置参数上?

jvm新生代中为什么要有Survivor区,且必须是2个

一、为什么会有年轻代

我们先来屡屡,为什么需要把堆分代?不分代不能完成他所做的事情么?其实不分代完全可以,分代的唯一理由就是优化GC性能。你先想想,如果没有分代,那我们所有的对象都在一块,GC的时候我们要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而我们的很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

二、年轻代中的GC

新生代大小=eden大小+1个survivor大小

新生代大小(PSYoungGen total 9216K)=eden大小(eden space 8192K)+1个survivor大小(from space 1024K)

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8(Eden):1(一个survivor),为啥默认会是这个比例,接下来我们会聊到。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片,因为新生代对象大部分是朝生夕死的,复制的对象很少,效率高。在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

image

三、一个对象的这一辈子

我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我15岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。

四、为什么要有Survivor区

先不去想为什么有两个Survivor区,第一个问题是,设置Survivor区的意义在哪里? image

如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。你也许会问,执行时间长有什么坏处?频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。

好,那我们来想想在没有Survivor的情况下,有没有什么解决办法,可以避免上述情况:

方案 优点 缺点
增加老年代空间 更多存活对象才能填满老年代。降低Full GC频率 随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长
减少老年代空间 Full GC所需时间减少 老年代很快被存活对象填满,Full GC频率增加

显而易见,没有Survivor的话,上述两种解决方案都不能从根本上解决问题。

我们可以得到第一条结论:Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历15次Minor GC还能在新生代中存活的对象,才会被送到老年代

五、为什么要设置两个Survivor区

设置两个Survivor区最大的好处就是解决了碎片化,下面我们来分析一下。

为什么一个Survivor区不行?第一部分中,我们知道了必须设置Survivor区。假设现在只有一个survivor区,我们来模拟一下流程:刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。我绘制了一幅图来表明这个过程。其中色块代表对象,白色框分别代表Eden区(大)和Survivor区(小)。Eden区理所当然大一些,否则新建对象很快就导致Eden区满,进而触发Minor GC,有悖于初衷。

image

碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存。。。画面太美不敢看。。。这就好比我们爬山的时候,背包里所有东西紧挨着放,最后就可能省出一块完整的空间放相机。如果每件行李之间隔一点空隙乱放,很可能最后就要一路把相机挂在脖子上了。

那么,顺理成章的,应该建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到15次,该对象就会被送到老年代中。下图中每部分的意义和上一张图一样,就不加注释了。

image

上述机制最大的好处就是,整个过程中,永远有一个survivor space是空的,另一个非空的survivor space无碎片。

那么,Survivor为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor区再细分下去,每一块的空间就会比较小,很容易导致Survivor区满,因此,我认为两块Survivor区是经过权衡之后的最佳方案。

上一篇下一篇

猜你喜欢

热点阅读