JVM

"java 虚拟机"

Posted by ming on August 30, 2019

“Some things are not to see to insist, but insisted the will sees hope.”

1. Java内存区域

对于Java程序员来说,在虚拟机自动内存管理的机制下,不需要像C/C++程序开发这样为一个new操作去写对应的delete/free操作,不容易出现内存泄漏和内存溢出问题。正是因为Java程序员把内存控制权利交给Java虚拟机。一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个艰巨的任务。

1.1 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它管理的内存划分成若干个不同的数据区域。(给出jdk 1.6版本的运行时数据区域图)

javaMemory.png

这些组成部分一些是线程私有的,其他则是线程共享的。我们由上图可以看出:

线程私有的主要包括:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

而线程共享的主要包含:

  • 方法区
  • 直接内存

下面我们来详细地介绍以上提到的各个部分。

1.程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看成是当前线程所执行的字节码的行号解释器。在虚拟机的概念模型里,字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们称这类内存区域为“线程私有”的内存。

从上面的介绍我们可以知道程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪里。

注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

2.Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。(实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

局部变量表主要存放了编译器可知的各种数据类型(boolean,byte,char,short,int,float,long,double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址).

Java虚拟机栈会出现两种异常:StackOverFlowErrorOutOfMemoryError.

  • StackOverFlowError:若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若Java虚拟机栈的内存大小允许动态扩展,只不过当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

Java虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

3.本地方法栈

本地方法栈(Native Method Stack)和虚拟机栈所发挥的作用是非常相似的,它们之间的区别在于:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在HotSpot虚拟机中和Java虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

4.堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域。其在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是垃圾回收器管理的主要区域,因此也被称为GC堆(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

java_heap

Attention:在 JDK1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

5.方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

HotSpot虚拟机中方法区也常被称为“永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。在 JDK1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域,为此下面我们了解一下永久代与元空间的概念。

  1. PermGen(永久代)

绝大部分 Java 程序员应该都见过 “java.lang.OutOfMemoryError: PermGen space “这个异常。这里的 “PermGen space”其实指的就是方法区。不过方法区和“PermGen space”又有着本质的区别。前者是 JVM 的规范,而后者则是 JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。我们现在通过动态生成类来模拟 “PermGen space”的内存溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class PermGenOonMock {

    public static void main(String[] args) {
        URL url = null;
        List<ClassLoader> classLoaderList = new ArrayList<>();
        try{
            url = new File("/tmp").toURI().toURL();
            URL[] urls = {url};
            while (true){
                ClassLoader classLoader = new URLClassLoader(urls);
                classLoaderList.add(classLoader);
                classLoader.loadClass("com.paddx.test.memory.Test");
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

运行结果如下:

永久代

本例中使用的 JDK 版本是 1.7,指定的 PermGen 区的大小为 8M。通过每次生成不同URLClassLoader对象来加载Test类,从而生成不同的类对象,这样就能看到我们熟悉的 “java.lang.OutOfMemoryError: PermGen space “ 异常了。这里之所以采用 JDK 1.7,是因为在 JDK 1.8 中, HotSpot 已经没有 “PermGen space”这个区间了,取而代之是一个叫做 Metaspace(元空间) 的东西。下面我们就来看看 Metaspace 与 PermGen space 的区别。

  1. Metaspace(元空间)

其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。我们可以通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别,以字符串常量为例:

1
2
3
4
5
6
7
8
9
10
11
12
public class StringOomMock {

    static String base = "string";
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        for (int i=0;i< Integer.MAX_VALUE;i++){
            String str = base + base;
            base = str;
            list.add(str.intern());
        }
    }
}

这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存。我们通过 JDK 1.6、JDK 1.7 和 JDK 1.8 分别运行:

JDK 1.6的运行结果:

JDK 1.6

JDK 1.7的运行结果:

JDK 1.7

JDK 1.8的运行结果:

JDK 1.8

从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。现在我们看看元空间到底是一个什么东西?

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  • XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  • XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

除了上面两个指定大小的选项之外,还有两个与GC相关的属性:

  • XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

现在我们在 JDK 8下重新运行一下之前在上一小节的代码,不过这次不再指定 PermSize 和 MaxPermSize。而是指定 MetaSpaceSize 和 MaxMetaSpaceSize的大小。输出结果如下:

元空间溢出

从输出结果,我们可以看出,这次不再出现永久代溢出,而是出现了元空间的溢出。

6.运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(其用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放)。运行时常量池因为是方法区的一部分,所以它也受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

字面量与符号引用

7.直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutofMemoryError异常出现。

在JDK 1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道与缓存区的I/O方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

1.2 HotSpot虚拟机对象

通过上面的介绍我们大致了解了虚拟机的内存分布概况,下面我们来深入探讨一下HotSpot虚拟机在Java堆中的对象分配、布局和访问的全过程。

1. 对象的创建

给出Java对象的创建过程如下图所示:

Java创建对象的过程.png

Step1:类加载检查

虚拟机遇到一条new指令的时候,首先会去检查这个指令的参数是否能够在常量池中定位到这个类的符号引用,并且检查这个符号引用所代表的的类是否已经被加载、解析和初始化过。如果没有,那必须执行相应的类加载过程。

step2:分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。其分配方式有“指针碰撞”和“空闲列表”两种,选择哪一种分配方式是由Java堆是否规整所决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配的两种方式:(Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的)

  1. 指针碰撞:
    • 使用场合:堆内存规整(即没有内存碎片)的情况下
    • 原理:用过的内存全部整合到一边,没有用过的在另一边,中间放着一个指针作为分界点的指示器,只需要向着没有用过的内存方向将该指针移动对象内存大小位置即可。
    • GC收集器:Serial、ParNew
  2. 空闲列表:
    • 使用场合:堆内存不规整的情况下
    • 原理:虚拟机会维护一个列表,该列表中记录了哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
    • GC收集器:CMS

除了如何划分可用空间外,还需要考虑是线程安全的问题,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的(因为可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针分配内存的情况),通常来说,虚拟机采用了两种方式来保证线程安全:

  • CAS + 失败重试:CAS是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB(本地线程分配缓冲):把内存分配的动作按照线程划分在不同的空间之中进行,即为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁,对象头会有不同的设置方式。

step5:执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从java程序的角度来看,对象创建才刚开始,方法还没有执行,所有的字段都还为零。所以一般来说,执行new指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正的对象才算完全产生出来。

2.对象的内存布局

在Hotspot虚拟机中,对象在内存中的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

Hotspot虚拟机的对象头主要包括两部分信息,第一部分用于存储对象自身的运行时数据(例如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向ID、偏向时间戳等等等等,官方称它为Mark Word),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

第三部分对齐填充不是必然存在的,也没什么特别的含义,仅仅起占位作用。因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

3.对象的访问定位

建立对象的目的就是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的实现方式有使用句柄直接指针两种:

  1. 句柄:如果使用句柄访问的话,那么Java堆中将会划分出来一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据域类型数据各自的具体地址信息。

对象的访问定位-使用句柄.png

  1. 直接指针:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象的地址。

对象的访问定位-直接指针.png

这两种对象访问方式各有优势。而使用句柄来访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

2. Java垃圾回收

2.1 JVM GC回收哪个区域的垃圾?

JVM垃圾回收算法

需要注意的是,JVM GC只回收堆区和方法区内的对象。而栈区的数据,在超出作用域后会被JVM自动释放掉,所以其不在JVM GC的管理范围内。

Java方法区在Sun HotSpot虚拟机中被称为永久代,很多人认为该部分的内存是不用回收的,java虚拟机规范也没有对该部分内存的垃圾收集做规定,但是方法区中的废弃常量和无用的类还是需要回收以保证永久代不会发生内存溢出。

判断废弃常量的方法:如果常量池中的某个常量没有被任何引用所引用,则该常量是废弃常量。

判断无用的类:

  1. 该类的所有实例都已经被回收,即Java堆中不存在该类的实例对象;
  2. 加载该类的类加载器已经被回收;
  3. 该类所对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射机制访问该类的方法。

2.2 JVM GC怎样判断对象可以被回收了?

  1. 对象没有引用
  2. 作用域发生未捕获异常
  3. 程序在作用域正常执行完毕
  4. 程序执行发生了System.exit()
  5. 程序发生意外终止(被杀线程等)

2.3 JVM GC什么时候执行?

eden区空间不够存放新对象的时候,执行Minro GC。升到老年代的对象大于老年代剩余空间的时候执行Full GC,或者小于的时候被HandlePromotionFailure 参数强制Full GC 。

调优主要是减少 Full GC 的触发次数,可以通过 NewRatio 控制新生代转老年代的比例,通过MaxTenuringThreshold 设置对象进入老年代的年龄阀值(后面会介绍到)。

1
java -Xmx3550m -Xms3550m -Xmn2g -Xss128k
  • -Xmx3550m:设置JVM最大可用内存为3550m。
  • -Xms3550m: 设置JVM初始内存为3550m。此值可以设置与 -Xmx 相同,以避免每次垃圾回收完成后JVM重新分配内存。
  • -Xmn2g: 设置年轻代大小为2G。整个堆大小=年轻代大小+老年代大小+永久代大小。永久代一般固定大小为64m,所以增大年轻代后,将会减小老年代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
  • -Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256k。根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
1
java -Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0
  • -XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与老年代的比值(除去永久代)。设置为4,则年轻代与老年代所占比值为1:4,年轻代占整个堆栈的1/5。
  • -XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6。
  • -XX:MaxPermSize=16m:设置永久代大小为16m。
  • -XX:MaxTenuringThreshold=0: 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入老年代。对于老年代比较多的应用,可以提高效率。如果此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代被回收的概率。MaxTenuringThreshold这个参数用于控制对象能经历多少次Minor GC才晋升到老年代,默认值是15。

2.4 按代的回收机制

新生代(Young generation):绝大多数最新被创建的对象都会被分配到这里,由于大部分在创建后很快变得不可达,很多对象被创建在新生代,然后“消失”。对象从这个区域“消失”的过程我们称之为:Minor GC 。

老年代(Old generation):对象没有变得不可达,并且从新生代周期中存活了下来,会被拷贝到这里。其区域分配的空间要比新生代多。也正由于其相对大的空间,发生在老年代的GC次数要比新生代少得多。对象从老年代中消失的过程,称之为:Major GC 或者 Full GC。

持久代(Permanent generation)也称之为 方法区(Method area):用于保存类常量以及字符串常量。注意,这个区域不是用于存储那些从老年代存活下来的对象,这个区域也可能发生GC。发生在这个区域的GC事件也被算为 Major GC 。只不过在这个区域发生GC的条件非常严苛,必须符合以下三种条件才会被回收:

  1. 所有实例被回收
  2. 加载该类的ClassLoader 被回收
  3. Class 对象无法通过任何途径访问(包括反射)。

2.5 如果老年代的对象需要引用新生代的对象,会发生什么呢?

为了解决这个问题,老年代中存在一个 card table ,它是一个512byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询 card table 来决定是否可以被回收,而不用查询整个老年代。这个 card table 由一个write barrier 来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但完全是值得的。

默认的新生代(Young generation)、老年代(Old generation)所占空间比例为 1 : 2。

2.6 新生代空间的构成与逻辑

  1. 一个伊甸园空间(Eden)
  2. 两个幸存者空间(From Survivor、To Survivor)

默认新生代空间的分配:Eden : From : To = 8 : 1 : 1。

刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;最初一次,当Eden区满的时候,执行Minor GC,将消亡(不可达)的对象清理掉,并将剩余(存活)的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);下次Eden区满了,再执行一次Minor GC,将消亡的对象清理掉,将存活的对象复制到Survivor1中,然后清空Eden区;将Survivor0中消亡的对象清理掉,将其中可以晋级的对象晋级到Old区,将存活的对象也复制到Survivor1区,然后清空Survivor0区;当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。

内存空间

  • 新生代:
    • 大多数新生的对象在Eden区分配,当Eden区没有足够空间进行分配时,虚拟机就会进行一次MinorGC。
    • 在方法中new一个对象,方法调用完毕,对象就无用,这就是典型的新生代对象。(新生对象在Eden区经历过一次MinorGC并且被Survivor容纳的话,对象年龄为1,并且每熬过一次MinorGC,年龄就会加1,直到15,就会晋升到老年代)。
    • 注意动态对象的判定:Survivor空间中相同年龄的对象大小总和大于Survivor空间的一半,大于或者等于该年龄的对象就可以直接进入老年代。
  • 老年代:
    • 在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到老年代中,而且大对象(占用大量连续内存空间的java对象如很长的字符串及数组)直接进入老年代。
    • 当survivor空间不够用时,需要依赖老年代进行分配担保。
  • 永久代:
    • 方法区
    • 主要存放Class和Meta的信息,Class在被加载的时候被放入永久代。 它和存放对象的堆区域不同,GC(Garbage Collection)不会在主程序运行期对永久代进行清理,所以如果你的应用程序会加载很多Class的话,就很可能出现PermGen space错误。

2.7 GC分类

  1. MinorGC:是指清理新生代。
  2. MajorGC:是指清理老年代(很多MajorGC是由MinorGC触发的)。
  3. FullGC:是指清理整个堆空间包括年轻代和永久代。

2.8 JVM GC算法

首先,我们先来了解一下如何判断一个对象是否还存在引用的查找算法。

根搜索算法

根搜索算法是从离散数学中的图论引入的,程序把所有引用关系看作一张图,从一个节点GC ROOT 开始,寻找对应的引用节点,找到这个节点后,继续寻找这个节点的引用节点。当所有的引用节点寻找完毕后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。目前在Java中可以作为GC ROOT的对象有:

  1. Java虚拟机栈中引用的对象(栈帧中的本地变量表)
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI本地方法引用的对象(Native对象)

1. 标记-清除算法

标记-清除算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象进行直接回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活的对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,并没有对还存活的对象进行整理,因此会导致内存碎片。

2. 复制算法

复制算法将内存划分为两个区间,使用此算法时,所有动态分配的对象都只能分配在其中一个区间(活动区间),而另外一个区间(空间区间)则是空闲的。复制算法在存活对象比较少的时候,极为高效,但是带来的成本是牺牲一半的内存空间用于进行对象的移动。

3. 标记-整理算法

标记-整理算法采用 标记-清除 算法一样的方式进行对象的标记、清除,但在回收不存活的对象占用的空间后,会将所有存活的对象往左端空闲空间进行移动,并更新对应的指针。标记-整理 算法是在 标记-清除 算法之上,又进行了对象的移动的排序整理,因此成本更高,但是却解决了内存碎片的问题。

2.9 垃圾回收器简介

1. Serial(-XX:+uSerialGC)

从名字我们可以看出,这是一个串行收集器。Serial收集器是Java虚拟机中最基本、历史最悠久的收集器。在JDK1.3之前Java虚拟机新生代收集器的唯一选择。目前也是ClientVM下ServerVM 4核4GB以下机器默认垃圾回收器。Serial收集器是一个单线程的收集器,但是“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集的工作,更重要的是当JVM需要进行垃圾回收的时候,需要暂停所有的用户线程,直到回收结束。虽然它存在着上述所说的劣势,但是它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比)

其使用的垃圾回收算法是复制算法

2. SerialOld (-XX:+UseSerialGC)

SerialOld是Serial收集器的老年代收集器版本,它同样是一个单线程收集器,这个收集器目前主要用于Client模式下使用。如果在Server模式下,它主要还有两大用途:一个是在JDK1.5及以前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

其使用的垃圾回收算法是标记-整理算法

Serial/Serial Old垃圾回收器

3. ParNew (-XX:+UseParNewGC)

ParNew收集器其实就是Serial收集器的多线程版本。除了Serial收集器外,只有它能够与CMS收集器配合工作。

其使用的垃圾回收算法是:复制算法

ParNew/Serial Old垃圾回收器

4.ParallelScavenge (-XX:+UseParallelGC)

ParallelScavenge又称为吞吐量优先收集器,和ParNew收集器类似,是一个新生代收集器。其使用的垃圾回收算法也是复制算法。但是Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。

所谓的吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾回收时间)。如果虚拟机总运行了100分钟,其中垃圾收集花了1分钟,那么吞吐量就是99%。

5. Parallel Old (-XX:+UseParallelOldGC)

ParallelOld是并行收集器,和SerialOld一样,ParallelOld是一个老年代收集器,是老年代吞吐量优先的一个收集器。这个收集器在JDK1.6之后才开始提供的,在此之前,ParallelScavenge只能选择SerialOld来作为其老年代的收集器,这严重拖累了ParallelScavenge整体的速度。而ParallelOld的出现,“吞吐量优先”收集器才名副其实。

其使用的算法:标记-整理算法

6. CMS(-XX:+UseConcMarkSweepGC)

CMS(Concurrent Mark-Sweep)是以牺牲吞吐量为代价来获取最短回收停顿时间的垃圾回收器,是一个老年代收集器。它是JDK 1.4后期开始引用的的新GC收集器,在JDK1.5、1.6中得到了进一步的改进。它是对于响应时间的重要性需求大于吞吐量要求的收集器。对于要求服务器响应速度高的情况下,使用CMS非常适合。

CMS的一大特点,就是用两次短暂的暂停来代替串行或并行标记整理算法时候的长暂停。其分为4个步骤:初始标记、并发标记、重新标记和并发清除。

初始标记仅仅只是标记一下GC Root能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些。

CMS虽然有着并发收集、低停顿的优点,但是其主要有以下3个明显的缺点:

  1. CMS收集器对CPU资源特别敏感。
  2. CMS收集器无法处理“浮动垃圾”,可能出现“Concurrent Mode Failure”失败而导致另外一次Full GC的产生。
  3. CMS基于标记-清除算法,可能在收集结束时会出现大量的空间碎片。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

其使用的算法: 标记-整理算法

7. GarbageFirst(G1)

这是一个新的垃圾回收器,既可以回收新生代也可以回收老年代。与其他GC收集器相比较,G1具备如下特点:

  • 并行与并发:G1能够充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集。
  • 空间整合。
  • 可预测的停顿。

2.10 CMS收集器具体介绍

CMS的使用场景为:GC过程短暂停,适合对时延要求较高的服务,用户线程不允许长时间的停顿。

其触发条件为:

  1. 如果没有设置-XX: +UseCMSInitiatingOccupancyOnly,虚拟机会根据收集的数据决定是否触发(建议线上环境带上这个参数,不然会加大问题排查的难度)
  2. 老年代使用率达到阈值(CMSInitiatingOccupancyFraction, 默认92%)
  3. 永久代的使用率达到阈值(CMSInitiatingPermOccupancyFraction, 默认92%),前提是开启 CMSClassUnloadingEnabled。
  4. 新生代的晋升担保失败。

晋升担保失败

老年代是否有足够的空间来容纳全部的新生代对象或历史平均晋升到老年代的对象,如果不够的话,就提早进行一次老年代的回收,防止下次进行YGC的时候发生晋升失败。

CMS的执行过程如下所示:

  • 初始标记(STW initial mark): 在这个阶段,需要虚拟机停顿正在执行的应用线程,官方的说法叫STW(Stop the World)。这个过程从根对象扫描直接关联的对象,并作标记。这个过程会很快的完成。
  • 并发标记(Concurrent marking): 这个阶段紧随初始标记阶段,在“初始标记”的基础上继续向下追溯标记。注意这里是并发标记,表示用户线程可以和GC线程一起并发执行,这个阶段不会暂停用户的线程。
  • 并发与预清理(Concurrent precleaning): 这个阶段依旧是并发的,JVM查找正在执行“并发标记”阶段时候进入老年代的对象(可能这个时候会有对象才能够新生代晋升到老年代,或被分配到老年代)。通过重新扫描,减少在一个阶段“重新标记”的工作,因为下一个阶段会STW。
  • 重新标记(STW remark):这个阶段会再次暂停正在执行应用线程,重新从根对象开始查找并标记并发阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致),并处理对象关联。这一次耗时会比“初始标记”更长,并且这个阶段可以并行标记。
  • 并发清理(Concurrent sweeping): 这个阶段是并发的,应用线程和GC清除线程可以一起并发执行。
  • 并发重置(Concurrent reset): 这个阶段依旧是并发的,重置CMS收集器的数据结构,等待下一次垃圾回收。

CMS执行过程

CMS的缺点

  1. 内存碎片。由于使用了标记-清理 算法,导致内存空间中会产生内存碎片。不过CMS收集器做了一些小的优化,就是把未分配的空间汇总成一个列表,当有JVM需要分配内存空间的时候,会搜索这个列表找到符合条件的空间来存储这个对象。但是内存碎片的问题依然存在,如果一个对象需要3块连续的空间来存储,因为内存碎片的原因,寻找不到这样的空间,就会导致Full GC。
  2. 需要更多的CPU资源。由于使用了并发处理,很多情况下都是GC线程和应用线程并发执行的,这样就需要占用更多的CPU资源,也是牺牲了一定吞吐量的原因。
  3. 需要更大的堆空间。因为CMS标记阶段应用程序的线程还是执行的,那么就会有堆空间继续分配的问题,为了保障CMS在回收堆空间之前还有空间分配给新加入的对象,必须预留一部分空间。CMS默认在老年代空间使用92%时候启动垃圾回收。可以通过-XX:CMSinitiatingOccupancyFraction=n来设置这个阀值。

3. Java内存模型

在介绍java内存模型之前,我们先来了解一下几个基础概念:内存屏障(memory Barriers), 指令重排序,happens-before规则, as-if-serial语义。

3.1 什么是内存屏障

内存屏障,又称为内存栅栏,是一个CPU指令,基本上它是一条这样的指令:

  1. 保证特定操作的执行顺序 影响某些数据(或者某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

Memory Barrier所做的另外一件事情就是强制刷出各种CPU Cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入cache的数据,因此,任何CPU上的线程都能够读取到这些数据的最新版本。

Memory Barrier

这和java有什么关系?volatile是基于Memory Barrier实现的。如果一个变量被volatile修饰,JMM会在写入这个字段之后插入一个Write-Barrier指令,并在读取这个字段之前插入一个Read-Barrier指令。

volatile

这就意味着,如果写入一个volatile变量a,可以保证:

  1. 一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
  2. 在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。

3.2 happens-before原则

从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。

在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这两个操作既可以在同一个线程,也可以在不同的线程中。

与程序员密切相关的happens-before规则如下:(详细的规则查看volatile关键字的详解)

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
  2. 监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
  3. volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
  4. 传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。

注意:两个操作之间具有happens-before操作,并不意味着前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。

3.3 指令重排序

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

  1. 编译器优化重排序:编译器在不改变单线程程序语义的情况下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能在乱序执行。

数据依赖性:如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

3.4 as-if-serial语义

不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

3.5 Java内存模型

Java内存模型(Java Memory Model, JMM)是在硬件内存模型的基础上更高层次的抽象,它屏蔽了各种硬件和操作系统对内存访问的差异性,从而实现让Java程序在各种平台下都能达到一致的并发效果。

Java内存模型定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。这里所说的变量包括实例字段、静态字段,但不包括局部变量和方法参数,因为它们是线程私有的,它们不会被共享,自然不存在竞争问题。

为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码的执行顺序等这类权利。

Java内存模型规定了所有的变量都存储在主内存中,这里的主内存跟介绍硬件时所用的名字一样,两者可以类比,但此处仅指虚拟机中内存的一部分。除了主内存,每个线程还有自己的工作内存,此处可以与CPU的高速缓存进行类比。工作内存中保须在工作内存中进行,包括读取和赋值等,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存来完成。存着该线程使用到的变量的主内存副本的拷贝,线程对该变量的操作都必须通过主内存来完成。

线程、工作内存、主内存三者间的关系如下图所示:

JMM内存关系图

注意:这里所说的主内存、工作内存跟Java虚拟机内存区域划分中的堆、栈是不同层次的内存划分,如果两者一定要勉强对应起来,主内存主要对应于堆中对象的实例部分,而工作内存主要对应与虚拟机栈中的部分区域。

从更低层次来说,主内存主要对应硬件内存部分,工作内存主要对应于CPU的高速缓存和寄存器部分,但也不是绝对的,主内存也可能存在于高速缓存和寄存器中,工作内存也可能存在于硬件内存中。

内存对应

3.5.1 内存间的交互操作

关于主内存与工作内存之间具体的交互协议,Java内存模型定义了以下8种具体的操作来完成:

  1. lock,锁定,作用于主内存的变量,它把主内存中的变量标识为一条线程独占状态;
  2. unlock,解锁,作用于主内存的变量,它把锁定的变量释放出来,释放出来的变量才可以被其它线程锁定;
  3. read,读取,作用于主内存的变量,它把一个变量从主内存传输到工作内存中,以便后续的load操作使用;
  4. load,载入,作用于工作内存的变量,它把read操作从主内存得到的变量放入工作内存的变量副本中;
  5. use,使用,作用于工作内存的变量,它把工作内存中的一个变量传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
  6. assign,赋值,作用于工作内存的变量,它把一个从执行引擎接收到的变量赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时使用这个操作;
  7. store,存储,作用于工作内存的变量,它把工作内存中一个变量的值传递到主内存中,以便后续的write操作使用;
  8. write,写入,作用于主内存的变量,它把store操作从工作内存得到的变量的值放入到主内存的变量中。

如果要把一个变量从主内存复制到工作内存,那就要按顺序地执行read和load操作,同样地,如果要把一个变量从工作内存同步回主内存,就要按顺序地执行store和write操作。注意,这里只说明了要按顺序,并没有说一定要连续,也就是说可以在read与load之间、store与write之间插入其它操作。比如,对主内存中的变量a和b的访问,可以按照以下顺序执行:

read a -> read b -> load b -> load a

此外,Java内存模型还定义了执行上述8种操作的基本规则:

  1. 不允许read和load、store和write操作之一单独出现,即不允许出现从主内存读取了而工作内存不接受,或者从工作内存回写了但主内存不接受的情况出现;
  2. 不允许一个线程丢弃它最近的assign操作,即变量在工作内存变化了必须把该变化同步回主内存;
  3. 不允许一个线程无原因地(即未发生过assign操作)把一个变量从工作内存同步回主内存;
  4. 一个新的变量必须在主内存中诞生,不允许工作内存中直接使用一个未被初始化(load或assign)过的变量,换句话说就是对一个变量的use和store操作之前必须执行过load和assign操作;
  5. 一个变量同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一个线程执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才能被解锁。
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值;
  7. 如果一个变量没有被lock操作锁定,则不允许对其执行unlock操作,也不允许unlock一个其它线程锁定的变量;
  8. 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作。

注意:这里的lock和unlock是实现synchronized的基础,Java并没有把lock和unlock操作直接开放给用户使用,但是却提供了两个更高层次的指令来隐式地使用这两个操作,即moniterenter和moniterexit。

3.5.2 原子性、可见性、有序性

Java内存模型就是为了解决多线程环境下共享变量的一致性问题,那么一致性包含哪些内容呢?

一致性主要包含三大特性:原子性、可见性、有序性,下面我们就来看看Java内存模型是怎么实现这三大特性的。

原子性

原子性是指一段操作一旦开始就会一直运行到底,中间不会被其它线程打断,这段操作可以是一个操作,也可以是多个操作。

由Java内存模型来直接保证的原子性操作包括read、load、user、assign、store、write这两个操作,我们可以大致认为基本类型变量的读写是具备原子性的。

如果应用需要一个更大范围的原子性,Java内存模型还提供了lock和unlock这两个操作来满足这种需求,尽管不能直接使用这两个操作,但我们可以使用它们更具体的实现synchronized来实现。

因此,synchronized块之间的操作也是原子性的。

可见性

可见性是指当一个线程修改了共享变量的值,其它线程能立即感知到这种变化。

Java内存模型是通过在变更修改后同步回主内存,在变量读取前从主内存刷新变量值来实现的,它是依赖主内存的,无论是普通变量还是volatile变量都是如此。

普通变量与volatile变量的主要区别是是否会在修改之后立即同步回主内存,以及是否在每次读取前立即从主内存刷新。因此我们可以说volatile变量保证了多线程环境下变量的可见性,但普通变量不能保证这一点。

除了volatile之外,还有两个关键字也可以保证可见性,它们是synchronized和final。

synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作”这条规则获取的。这里,我们具体的讲述一下。一个线程在获取到监视器锁(Monitor)以后才能进入synchronized控制的代码块,一旦进入代码块,首先,该线程对于共享变量的缓存就会失效,因此synchronized代码块中对于共享变量的读取需要从主内存中重新获取,也就能获取到最新的值。其在退出代码块的时候,会将该线程写缓冲区中的数据刷新到主内存中,所以在synchronized代码块之前或synchronized代码块中对于共享变量的操作随着该线程退出synchronized块,会立即对其他线程可见(这句话的前提是其他读取共享变量的线程会从主内存读取最新值)。

为此,我们可以总结一下:线程a对于进入synchronized块之前或在synchronized中对于共享变量的操作,对于后续的持有同一个监视器锁的线程b可见。注意一点,在进入synchronized的时候,并不会保证之前的写操作刷入到主内存中,synchronized主要是保证退出的时候能将本地内存的数据刷新到主内存。

final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,那么其它线程中就能看见这个final字段了。(在对象的构造方法中设置 final 属性,同时在对象初始化完成前,不要将此对象的引用写入到其他线程可以访问到的地方(不要让引用在构造函数中逸出)。如果这个条件满足,当其他线程看到这个对象的时候,那个线程始终可以看到正确初始化后的对象的 final 属性。)

有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。

前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。Java中提供了volatile和synchronized两个关键字来保证有序性。volatile天然就具有有序性,因为其禁止重排序。

我们来看看单例模式中的双重检查的一个具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {

    private static Singleton instance = null;

    private int v;
    private Singleton() {
        this.v = 3;
    }

    public static Singleton getInstance() {
        if (instance == null) { // 1. 第一次检查
            synchronized (Singleton.class) { // 2
                if (instance == null) { // 3. 第二次检查
                    instance = new Singleton(); // 4
                }
            }
        }
        return instance;
    }
}

很多人都知道上述的写法是不对的,但是可能会说不清楚到底为什么不对。我们假设有两个线程 a 和 b 调用 getInstance() 方法,假设 a 先走,一路走到 4 这一步,执行 instance = new Singleton() 这句代码。

instance = new Singleton()这句代码首先会申请一段空间,然后将各个属性初始化为零值(0/null),执行构造方法中的属性赋值[1],将这个对象的引用赋值给 instance[2]。在这个过程中,[1] 和 [2] 可能会发生重排序。(具体细节可以看我的volatile关键字的分析)

此时,线程 b 刚刚进来执行到 1(看上面的代码块),就有可能会看到 instance 不为 null,然后线程 b 也就不会等待监视器锁,而是直接返回 instance。问题是这个 instance 可能还没执行完构造方法(线程 a 此时还在 4 这一步),所以线程 b 拿到的 instance 是不完整的,它里面的属性值可能是初始化的零值(0/false/null),而不是线程 a 在构造方法中指定的值。为此,就需要用volatile关键字修饰instance变量禁止重排序,来解决这个问题,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {

    private static volatile Singleton instance = null;

    private int v;
    private Singleton() {
        this.v = 3;
    }

    public static Singleton getInstance() {
        if (instance == null) { // 1. 第一次检查
            synchronized (Singleton.class) { // 2
                if (instance == null) { // 3. 第二次检查
                    instance = new Singleton(); // 4
                }
            }
        }
        return instance;
    }
}

synchronized的有序性是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获取的。(这句话并不是表明synchronized代码块中的代码是不允许进行重排序的,它的有序性是指synchronized 保证了释放监视器锁之前的代码一定会在释放锁之前被执行),不妨我们来给出这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static Singleton getInstance() {
    if (instance == null) { //
        Singleton temp;
        synchronized (Singleton.class) { //
            temp = instance;
            if (temp == null) { //
                synchronized (Singleton.class) { // 内嵌一个 synchronized 块
                    temp = new Singleton();
                }
                instance = temp; //
            }
        }
    }
    return instance;
}

上面这个代码很有趣,想利用 synchronized 的内存可见性语义来解决单例模式的问题,不过这个解决方案还是失败了,我们分析下:

前面说了,synchronized 在退出的时候,能保证 synchronized 块中对于共享变量的写入一定会刷入到主内存中。也就是说,上述代码中,内嵌的 synchronized 结束的时候,temp 一定是完整构造出来的,然后再赋给 instance 的值一定是好的。

可是,synchronized 保证了释放监视器锁之前的代码一定会在释放锁之前被执行(如 temp 的初始化一定会在释放锁之前执行完 ),但是没有任何规则规定了,释放锁之后的代码不可以在释放锁之前先执行。也就是说,代码中释放锁之后的行为 instance = temp 完全可以被提前到前面的 synchronized 代码块中执行,那么前面说的重排序问题就又出现了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static Singleton getInstance() {
    if (instance == null) {
        Singleton temp;
        synchronized (Singleton.class) {
            temp = instance;
            if (temp == null) {
                synchronized (Singleton.class) {
                    temp = new Singleton(); // 1
                    instance = temp; // 2
                }
            }
        }
    }
    return instance;
}

很显然,又回到前面的问题,temp 在赋给 instance 的时候,temp 引用的对象本身还没初始化完成,其他线程看到 instance 不等于 null,可是这个对象其实是不靠谱的。

关于这个问题的讨论,具体参考文章:

Java 并发基础之内存模型

3.5.3 先行发生原则

如果Java内存模型的有序性都只依靠volatile和synchronized来完成,那么有一些操作就会变得很啰嗦,但是我们在编写Java并发代码时并没有感受到,这是因为Java语言天然定义了一个“先行发生”原则,这个原则非常重要,依靠这个原则我们可以很容易地判断在并发环境下两个操作是否可能存在竞争冲突问题。

先行发生,是指操作A先行发生于操作B,那么操作A产生的影响能够被操作B感知到,这种影响包括修改了共享内存中变量的值、发送了消息、调用了方法等。

下面我们看看Java内存模型定义的先行发生原则有哪些:

  1. 程序次序原则:在一个线程内,按照程序书写的顺序执行,书写在前面的操作先行发生于书写在后面的操作,准确地讲是控制流顺序而不是代码顺序,因为要考虑分支、循环等情况。
  2. 监视器锁定原则:一个unlock操作先行发生于后面对同一个锁的lock操作。
  3. volatile原则:对一个volatile变量的写操作先行发生于后面对该变量的读操作。
  4. 线程启动原则:对线程的start()操作先行发生于线程内的任何操作。
  5. 线程终止原则:线程中的所有操作先行发生于检测到线程终止,可以通过Thread.join()、Thread.isAlive()的返回值检测线程是否已经终止。
  6. 线程中断原则:对线程的interrupt()的调用先行发生于线程的代码中检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否发生中断。
  7. 对象终结原则:一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性原则:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

这里说的“先行发生”与“时间上的先发生”没有必然的关系。

3.5.6 Synchronization Order

java语言规范对于同步定义了一系列的规则:17.4.4. Synchronization Order,包括了如下同步关系:

  • 对于监视器m的解锁与所有后续操作对于m的加锁同步;
  • 对于volatile变量v的写入,与所有其他线程操作后续对v的读同步;
  • 启动线程的操作与线程中的第一个操作同步。
  • 对于每个属性写入默认值(0,false,null)与每个线程对其进行的操作同步。(尽管在创建对象完成之前对对象属性写入默认值有点奇怪,但是从概念上来说,每个对象都是在程序启动时用默认值初始化来创建的。)
  • 线程T1的最后操作与线程T2发现线程T1已经结束同步。(线程T2可以通过T1.isAlive()或T1.join()方法来判断T1是否已经终结)
  • 如果线程T1中断了T2,那么线程T1的中断操作与其他所有线程发现T2被中断了同步(通过抛出InterruptedException异常,或者调用Thread.interrupted或Thread.isInterrupted)

#####