1. 简介
JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。 虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。 常见的虚拟机:JVM、VMwave、Virtual Box。JVM 是一台被定制过的现实当中不存在的计算机。
2. JVM 和其他两个虚拟机的区别
- VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
- JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。
3. JVM 发展史
Sun Classic VM ---> Exact VM ---> HotSpot VM ---> JRockit ---> J9 JVM ---> Taobao JVM
4. JVM 四大分区
- 类加载器(ClassLoader)
- 运行时数据区(Runtime Data Area)
- 执行引擎(Execution Engine)
- 本地库接口(Native Interface)
5. JVM 运行流程
程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式 类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area) ,而字节码 文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调 用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部 分的职责与功能。
6. JVM 运行时数据区内存分布
6.1 堆(线程共享)
- 程序中创建的所有对象都在保存在堆中。
- 堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定 GC 次数之后还存活的对象 会放入老生代。
- 新生代还有 3 个区域:一个 Endn + 两个 Survivor(S0/S1)。垃圾回收的时候会将 Endn 中存活的对象放到一个未使用的 Survivor 中,并把当前的 Endn 和正在使用 的 Survivor 清楚掉。
6.2 Java虚拟机栈(线程私有)
6.2.1 什么是线程私有?
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时 刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能 恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存 储。我们就把类似这类区域称之为"线程私有"的内存。
6.2.2 Java虚拟机栈作用
Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法执行的 内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数 栈、动态链接、方法出口等信息。常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。
6.2.3 Java虚拟机栈组成部分
局部变量表:存放方法参数和局部变量。 存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表 所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。
操作栈:每个方法会生成一个先进后出的操作栈。
动态链接:指向运行时常量池的方法引用。
方法返回地址:PC 寄存器的地址。
6.3 本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用的。
6.4 程序计数器(线程私有)
6.4.1 作用
用来记录当前线程执行的行号。程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。 如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是一个Native方法,这个计数器值为空。程序计数器内存区域是唯一一个在JVM规范中没有规定任何OOM情况的区域!
6.5 方法区(线程共享)
在《Java虚拟机规范中》把此区域称之为"方法区",而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域 叫做永久代(PermGen),JDK 8 中叫做元空(Metaspace)。
6.5.1 作用
用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
6.5.2 JDK 1.8 元空间的变化
- 对于 HotSpot 来说,JDK 8 元空间的内存属于本地内存,这样元空间的大小就不在受 JVM 最大内 存的参数影响了,而是与本地内存的大小有关。
- JDK 8 中将字符串常量池移动到了堆中。
6.5.3 运行时常量池
运行时常量池是方法区的一部分,存放字面量与符号引用。
字面量 : 字符串(JDK 8 移动到堆中) 、final常量、基本数据类型的值。
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
6.6 内存布局中的异常问题
6.6.1 Java堆溢出
Java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免来 GC清除这些对象,那么在对象数量达到最大堆容量后就会产生内存溢出异常。
示例:
- 设置JVM参数-Xms:设置堆的最小值、-Xmx:设置堆最大值.
java
JVM 参数为:-Xmx20m -Xms20m XX:+HeapDumpOnOutOfMemoryError
2.运行代码
java
import java.util.ArrayList;
import java.util.List;
public class Test {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
-
运行结果
Java堆内存的OOM异常是实际应用中最常见的内存溢出情况。当出现Java堆内存溢出时,异常堆栈信 息"java.lang.OutOfMemoryError"会进一步提示"Java heap space"。当出现"Java heap space"则很明 确的告知我们,OOM发生在堆上。
此时要对Dump出来的文件进行分析,分析问题的产生到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)- 内存泄漏 : 泄漏对象无法被GC
- 内存溢出 : 内存对象确实还应该存活。此时要根据JVM堆参数与物理内存相比较检查是否还应该把JVM 堆内存调大;或者检查对象的生命周期是否过长。
6.6.2 虚拟机栈和本地方法栈溢出
出现StackOverflowError异常时有错误堆栈可以阅读,比较好找到问题所在。如果使用虚拟机默认参 数,栈深度在多多数情况下达到1000-2000完全没问题,对于正常的方法调用(包括递归),完全够 用。如果是因为多线程导致的内存溢出问题,在不能减少线程数的情况下,只能减少最大堆和减少栈容量的 方式来换取更多线程。
示例:
java
// JVM参数为:-Xss128k
public class SOF {
private void donStop() {
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
donStop();
}
});
thread.start();
}
}
public static void main(String[] args) {
SOF sof = new SOF();
sof.stackLeakByThread();
}
}
6.7 处理Java堆内存
7. JVM 类加载
7.1 类加载过程
- 加载
"加载"(Loading)阶段是整个"类加载"(Class Loading)过程中的一个阶段,它和类加载 Class Loading 是不同的,一个是加载 Loading 另一个是类加载 Class Loading,所以不要把二者搞混了。
<1> 通过一个类的全限定名来获取定义此类的二进制字节流。
<2> 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
<3> 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
- 验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机 规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。验证选项: 文件格式验证,字节码验证,符号引用验证...
- 准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
java// 比如此时有这样一行代码: public static int value = 123; // 它是初始化 value 的 int 值为 0,而非 123。
- 解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
- 初始化
初始化阶段,Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程。
<1> 双亲委派模型
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父 类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启 动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类) 时,子加载器才会尝试自己去完成加载。
- 启动类加载器:加载 JDK 中 lib 目录中 Java 的核心类库,即$JAVA_HOME/lib目录。 扩展类加载 器。加载 lib/ext 目录下的类。
- 应用程序类加载器:加载我们写的应用程序。
- 自定义类加载器:根据自己的需求定制类加载器。
站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有的 类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
站在 Java 开发人员的角度来看,类加载器就应当划分得更细致一 些。自 JDK 1.2 以来,Java 一直保持 着三层类加载器、双亲委派的类加载架构器。
双亲委派模型的优点:
- 避免重复加载类:
比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那 么在 B 类进行加载时就不需要在重复加载 C 类了。- 安全性:
使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模 型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户 自己提供的因此安全性就不能得到保证了。
<2> 破坏双亲委派模型
8. 垃圾回收
对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其 生命周期与相关线程有关,随线程而生,随线程而灭。以下有关内存分配和 回收关注的为Java堆与方法区这两个区域。
在 Java 中,所有的对象都是要存在内存中的(也可以说内存中存储的是一个个对象),因此我们将内存回收,也可以叫做死亡对象的回收。
8.1 死亡对象的判断算法
8.1.1 引用计数算法
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任 何时刻计数器为0的对象就是不能再被使用的,即对象已"死",引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。
在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的 循环引用问题示例:循环引用
java
// JVM参数 :-XX:+PrintGC
public class GCTest {
public Object instance = null;
private static int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
GCTest test1 = new GCTest();
GCTest test2 = new GCTest();
test1.instance = test2;
test2.instance = test1;
test1 = null;
test2 = null;
// 强制jvm进行垃圾回收
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
8.1.2 可达性分析算法
Java采用"可达性分析"来判断对象是否存活。
- 对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。
- GC Roots的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native方法)引用的对象。
- 引用:
- 强引用 :
强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类 的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。- 软引用 :
软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。- 弱引用 :
弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的 对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。- 虚引用 :
虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。
8.2 垃圾回收算法
8.2.1 标记-清除算法
算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的 对象,在标记完成后统一回收所有被标记的对象。
"标记-清除"算法的不足:
- 效率问题 : 标记和清除这两个过程的效率都不高。
- 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中 需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。
8.2.2 复制算法
"复制"算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使 用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后 再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配 时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。
现在的商用虚拟机都是采用这种收集算法来回收新生代:
新生代中98%的对象都是"朝生夕死"的,所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新 生代内存)分为一块较大的Eden(伊甸园)空间和两块较小的Survivor(幸存者)空间,每次使用Eden和其中 一块Survivor(两个Survivor区域一个称为From区,另一个称为To区域)。当回收时,将Eden和 Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor 空间。当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。 HotSpot默认Eden与Survivor的大小比例是8 : 1,也就是说Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对 象。
- HotSpot实现的复制算法流程:
- 当Eden区满的时候,会触发第一次Minor gc,把还活着的对象拷贝到Survivor From区;当Eden区再次触发Minor gc的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过 这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。
- 当后续Eden又发生Minor gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到 From区域,并将Eden和To区域清空。
- 部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数 MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代。
8.2.3 标记-整理算法
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用 复制算法。 针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步 骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
8.2.4 分代算法
通过区域划分,实现不同区域和不同的垃圾回收策略, 从而实现更好的垃圾回收。根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。
- 一般创建的对象都会进入新生代;
- 老年代:大对象和经历了 N 次(一般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代 移动到老年代。
- Minor GC和Full GC这两种GC有什么不一样?
- Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
- Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
8.3 垃圾收集器
垃圾收集器就是内存回收的具体实现,垃圾收集器是为了保证程序能够正常、持久运行的一种技术,它是将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间。
HotSpot 虚拟机随着不同版本推出的重要的垃圾收集器:
如果两个收集器之间存在连线,就说明他们之间可以搭配使用,所处的区域,表示它是属于新生代收集器还是老年代收集器。
- 并行(Parallel) : 指多条垃圾收集线程并行工作,用户线程仍处于等待状态。
- 并发(Concurrent) : 指用户线程与垃圾收集线程同时执行(不一定并行,可能会交替执行),用户程序继续运行,而垃圾收集程序在另外一个CPU上。
- 吞吐量:就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。
吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
例如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
9. JMM
JVM定义了一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差 异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中 取出变量这样的底层细节。此处的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后两者是线程私有的,不会被线程共享。
9.1 主内存与工作内存
Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中 保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作 内存进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量, 线程间变量值的传递均需要通过主内存来完成。
9.2 内存间交互操作
- lock(锁定) : 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁) : 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可 以被其他线程锁定。
- read(读取) : 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入) : 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用) : 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。
- assign(赋值) : 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
- store(存储) : 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便后续的 write操作使用。
- write(写入) : 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变 量中。
9.2.1 Java内存模型的三大特性
- 原子性 : 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和read。大致可以认为,基本数据类型的访问读写是具备原子性的。如若需要更大范围的原子性, 需要synchronized关键字约束(即一个操作或者多个操作 要么全部执行并且执行的过程不会被任 何因素打断,要么就都不执行)。
- 可见性 : 可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。 volatile、synchronized、final三个关键字可以实现可见性。
- 有序性 : 如果在本线程内观察,所有的操作都是有序的;如果在线程中观察另外一个线程,所有的操作都是无序的。前半句是指"线程内表现为串行",后半句是指"指令重序"和"工作内存与主内存同步延迟"现象。
happens-before 原则:
Java内存模型具备一些先天的"有序性",即不需要通过任何手段就能够得到保证的有序性。如果两个操作的执行次序无法从happens-before原则推导出来,那么它 们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作 。
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作 。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发 生于操作C。
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作 。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生 。
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法 结束、Thread.isAlive()的返回值手段检测到线程已经终止执行 。
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保 证,就有可能会导致程序运行不正确。
9.3 volatile
9.3.1 保证此变量对所有线程的可见性
当一条线程修改了这个变量的值,新值 对于其他线程来说是可以立即得知的。而普通变量做不到这一点,普通变量的值在线程间传递均需要通 过主内存来完成。例如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A 回写完成之后再从主内存进行读取操作,新值才会对线程B可见。
volatile变量在各个线程中是一致的,但是 volatile变量的运算在并发下一样是不安全的,原因在于Java里面的运算并非原子操作。例如多线程并发对一个volatile修饰的静态变量num执行 num++,实际上num++等同于num = num+1。volatile关键字保证了num的值在取值时是正确的,但是在执行num+1的时候,其他线程可能已经把num值增大了,这样在+1后会把较小的数值同步回主内存之中。
由于volatile关键字只保证可见性,在不符合以下两条规则的运算场景中,我们仍然需要通过加锁 (synchronized或者lock)来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值 。
- 变量不需要与其他的状态变量共同参与不变约束。
如下代码这类场景就特别适合使用volatile来控制并发:
javavolatile boolean shutdownRequested; public void shutdown() { shutdownRequested = true; } public void work() { >while(!shutdownRequested) { //do stuff } }
9.3.2 使用volatile变量的语义是禁止指令重排序
普通的变量仅仅会保证在该方法的执行过程中所有 依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序和程序代码中执行的顺序一致。
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行, 且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
示例:
java//x、y为非volatile变量 //flag为volatile变量 x = 2; //语句1 y = 0; //语句2 flag = true; //语句3 x = 4; //语句4 y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句>1、语句2 前面,也不会将语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序 是不作任何保证的。并且volatile关键字能保证,执行到语句3,语句1和语句2必定是执行完毕了的,且语句1和语句2的 执行结果对语句3、语句4、语句5是可见的。
单例模式中的Double Check双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为 双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。
javapublic static Singleton getSingleton(){ if(instance==null){ //Single Checked synchronized (Singleton.class){ if(instance==null){ //Double Checked instance=new Singleton(); } } } return instance; }
这段代码看起来很完美,很可惜,它是有问题。主要在于instance = new Singleton()这句,这并非是一 个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。 给 instance 分配内存 调用 Singleton 的构造函数来初始化成员变量 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了) 但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不 能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之 前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。 我们只需要将 instance 变量声明成 volatile 就可以了。
javaclass Singleton{ // 确保产生的对象完整性 private volatile static Singleton instance = null; private Singleton() {} public static Singleton getInstance() { if(instance==null) { // 检查对象是否初始化 synchronized (Singleton.class) { if(instance==null) // 确保多线程情况下对象只有一个 instance = new Singleton(); } } return instance; } }