JVM,你都会了吗
1.概述
JVM(全称Java Virtual Machine)也叫Java虚拟机,它是一种抽象化的计算机。有句话叫 java语言是跨平台的,一次编译,多处运行。也就是说java代码只需要一次编译即可放到不同操作系统上进行运行,这完全依赖JVM,它将编译的class文件转化为对应操作系统所能运行的二进制文件。
2.JVM内存结构图
2.1内存结构图
jvm共包含5个部分:堆、Java栈、本地方法栈、方法区(元空间,JDK1.7前叫方法区,JDK1.8后叫元空间)及程序计数器。
堆:实例化的对象都会放在堆中,以及数组也在堆中分配内存。(线程共享)
Java栈:全称是Java虚拟机栈,也叫线程栈,每个方法在执行时都会创建一个帧栈,用于存储局部变量表、操作数、动态链接和方法返回等信息。(线程私有)
本地方法栈:保存native方法信息,当一个jvm创建的线程调用native方法后,不会在Java栈为该线程创建帧栈,而是简单的动态链接并直接调用该方法。(线程私有)
方法区:存放已被加载的类信息,常量、静态变量、即时编译器编译后的代码缓存。
程序计数器:当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址。(线程私有,不会内存溢出,执行本地方法计数器的值为空)
一个java代码要想运行,就必须先编译成.class文件,然后交给JVM去执行。JVM会通过类装载子系统把字节码文件加载到内存区中,然后字节码执行引擎会运行内存区中的代码。
那么具有它们之间是什么关系?
1)字节码执行引擎会去执行方法区中的代码,当当前线程被其他线程抢占CPU时,字节码执行引擎会运行完当前一行的代码,然后把下一行将要运行的代码地址记录下来,并去修改程序计数器中当前线程的指令指定。
2)由于堆中存储的是对象,故当方法区中的静态变量包含对象时,则这些变量的值实际上是对象在堆中的首地址。同理,在Java栈中也会有变量是对象,其指向的也是对象在堆中的地址。
2.2Java栈
为了弄清楚Java栈的作用,这里以下面的代码进行说明
package com.zxh.demo; import java.util.HashMap; import java.util.Map; public class JvmTest1 { //静态变量 public static final int sum = 15; //静态对象 public static Map<String, String> map = new HashMap<>(); public int add() { int a = 3; int b = 8; int c = (a + b) * 10; return c; } public static void main(String[] args) { JvmTest1 jvmTest1 = new JvmTest1(); int add = jvmTest1.add(); System.out.println(add); } }
将上面的代码进行编译后会生成class文件,如果打开文件,显示是字节码信息是完全看不懂的,因为这些代码是虚拟机运行的代码
当然也有另一种表现形式的代码可以查看,在class文件目录打开cmd,执行命令
javap -c JvmTest1.class > t1.txt
对该文件进行反汇编
命令执行后,会生成指定名称的txt文件,内容如下:
要看懂这些代码,需要查看参考文档,其中部分指令说明如下:
iload 将指定的int型本地变量推送至栈顶 iload_0 将第一个int型本地变量推送至栈顶 iload_1 将第二个int型本地变量推送至栈顶 iload_2 将第三个int型本地变量推送至栈顶 iconst_0 将int类型常量0压入操作数栈 iconst_1 将int类型常量1压入操作数栈 iconst_2 将int类型常量2压入操作数栈 istore_0 将栈顶int型数值存入局部变量0(通常可视为this) istore_1 将栈顶int型数值存入局部变量1(局部变量x表中元素的唯一标识,相当于索引) istroe_2 将栈顶int型数值存入局部变量2 iadd 将栈顶两int型数值相加并将结果压入栈顶 imul 将栈顶两int型数值相乘并将结果压入栈顶 ireturn 从当前方法返回int bipush 将一个8位带符号整数压入栈 lload 将指定的long型本地变量推送至栈顶 fload 将指定的float型本地变量推送至栈顶 dload 将指定的double型本地变量推送至栈顶 aload 将指定的引用类型本地变量推送至栈顶 lload_0 将第一个long型本地变量推送至栈顶 fload_0 将第一个float型本地变量推送至栈顶 dload_0 将第一个double型本地变量推送至栈顶 aload_0 将第一个引用类型本地变量推送至栈顶
根据上述的命令,对反汇编的结果进行分析,以add方法为例,其帧栈信息如下
局部变量表:存储基本数据类型和对象引用(reference类型)及returnAddress类型,这些数据类型在局部变量表中以局部变量插槽Slot为单位,一个Slot具体大小有虚拟机决定。
操作数栈:是供操作数在进行加减乘除操作过程中临时存放的内存空间。
动态链接:在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里,如描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
1)首先将int类型的常量3压入操作数栈
2)将栈顶int类型数组存入局部变量1,也就是定义了一个局部变量a,为其开辟了一块存储空间,给其赋值为3
3)将int类型的常量8压入操作数栈
4)将栈顶int类型数组存入局部变量2,也就是定义了一个局部变量b,为其开辟了一块存储空间,给其赋值为8(这里遵循的是先进后出的原则)
5)将int类型局部变量1(a)推送到操作数栈
6)将int类型局部变量2(b)推送到操作数栈
7)将两个int类型的值相加,重新放入操作数栈
8)将int类型的常量10压入操作数栈
9)将两个int类型的值相乘,重新放入操作数栈
10)将栈顶int类型数组存入局部变量3,也就是定义了一个局部变量c,为其开辟了一块存储空间,给其赋值为121
11)将int类型局部变量3(c)推送到操作数栈
12)将操作数栈的数返回
2.3堆
堆的内存结构图如下
Eden、S0、S1归为Young区(Young Gen),即新生代,执行new时大部分对象在此分配内存,经过一定GC次数(默认15次)后进入Old区。
- 默认情况下,新生代与老年代比例为1:2。可以通过参数
-XX:NewRatio
修改,NewRatio默认值是2。如果NewRatio修改成3,那么年轻代与老年代比例就是1:3 - 默认情况下Eden、S0、S1的比例是8:1:1。可以通过参数
-XX:SurvivorRatio
修改,SurvivorRatio默认值是8,如果SurvivorRatio修改成4,那么其比例就是4:1:1
3.垃圾回收机制
3.1判断垃圾是否需要回收的算法
3.1.1引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
优点:原理简单,判定效率高。
缺点:占用一定额外内存,难以解决循环引用问题。所谓循环引用问题是指两个不再被使用的对象相互引用,造成两个对象的计数器都不为0,无法判定是否需要回收。
3.1.2可达性分析算法
将GC Roots的根对象作为起始节点集,沿着这些对象根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain)。如果某个对象到GC Roots间没有任何引用链相连则被视为不可达。如下图所示,只要按规则搜索的到,即为存活的对象,否则就是可回收的对象。
而在Java体体系中,固定可作为GC Roots的对象主要包括以下几种:
- 在Java栈(栈帧中的局部变量表)中引用的对象;
- 在方法区中类静态属性引用的对象、常量引用的对象;
- 在本地方法栈中JNI(Native方法)引用的对象;
除了上述几种固定的对象外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还有其他“临时性对象”的加入,共同构成完整GC Roots集合。
注:即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
3.2四种引用类型
1)强引用
是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
2)软引用
描述有用但非必须的对象。被软引用关联的对象,在系统发生内存溢出前,将这些对象列为二次回收的范围。(如SoftReference<String> s=new SoftReference("1233"))
3)弱引用
描述非必须对象。只能生存到下次垃圾回收为止,也就是说,当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象(用WeakReference实现,如ThreadLocal)
4)虚引用
对象是否有虚引用不会对其生存时间构成影响,也无法通过虚引用获得对象实例。虚引用的唯一作用是对象被收集器回收时收到系统通知。(用PhantomReference实现)、
3.3垃圾回收算法
垃圾回收的算法目前有四种,分别是标记-清除算法、标记-复制算法、标记-整理算法、分代收集算法。
3.3.1标记-清除算法(Mark-Sweep)
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。其回收的过程如下图:
可以看出,其缺点也很明显,面对大量可回收对象时执行效率低。而且标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致后续在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。
3.3.2标记-复制算法(Copying)
标记-复制算法也叫作复制算法。其采用的原理是半区复制,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的一块的内存空间一次清理掉。对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。但如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。可以看出,其缺点是可用内存少,且若多数对象存活,则复制效率明显降低。因此在老年代一般不能使用复制算法。
3.3.3标记-整理算法(Mark-Compact)
标记过程仍与“标记-清除”过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。
3.3.4分代收集算法(Generational Collection)
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。根据对象存活周期的不同将内存划分为几块,根据每块内存区间的特点,使用不同的回收算法。新生代采用标记-复制算法,老年代采用标记-整理算法。
大部分对象在Eden区中生成,当Eden区满时,会触发Minor GC,还存活的对象将被复制到S0区,此时Eden区变成空的。当Eden区再次用完,会再次触发Minor GC。但在Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,包括垃圾对象,这个步骤叫做空间分配担保。
在进行Minor GC时,如果S1能够容纳,则将还存活的对象复制到S1区。而S0区还在被使用的对象会根据他们的年龄值来决定去向,年龄达到阈值(默认是 15 岁)的对象会进入老年代,没有达到阈值的则被复制到S1区,并将这些对象的年龄设置为1,然后清除所有的Eden区和S0区。S0与S1角色互换,后续每进行一次Minor GC时,这些存在于Survivor区的被使用的对象年龄+1。对于大对象则直接进入老年代。
如果S1不能够容纳,则多的部分进入老年代。
因此对于老年代的对象来源就有以下几种:
①大部分来自于新生代,对象在新生代存活时间过阀值,被复制到老年代。
②新生代中部分对象虽然未超过阀值,但是因为survivor区已满,由空间担保机制复制到老年代。
③部分大对象直接在老年代创建,不经历新生代。
3.4空间担保机制
3.4.1什么是空间担保机制
在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,然后做对应的处理,这个过程就叫做空间担保机制。具体处理情况如下图
3.4.2空间担保机制的作用是什么?
新生代采用的是复制算法进行垃圾回收,当进行Minor GC后仍活有大量的对象存活时,就会使用空间担保机制,把Survivor无法容纳的对象放到老年代,因为Survivor区空间较小。而老年代要进行空间担保的前提是得有足够的空间去容纳这些对象,但回收后共有多少对象在内存存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。如果空间不足就会触发Full GC。
3.5GC是怎么触发的?
GC分为两种,Minor GC和Full GC。Minor GC触发的条件是Eden区满时;而Full GC触发的条件有多种,主要如下:
1)老年代空间不足
2)老年代最大可用连续空间小于历次晋升到老年代的对象的平均大小
3)调用System.gc() 时,系统建议执行Full GC,但是不必然执行
4)在Eden区存在的大对象向Survivor区复制时,对象大小大于Survivor区可用大小
5)方法区(持久代)空间不足;
4.JVM调优
JVM调优的目的是为了减少GC和STW(stop the word),提高服务器的性能。STW是指JVM在做垃圾收集时,会暂停用户线程,待垃圾收集完成后再让用户线程继续执行。若GC时间过长,就会出现用户使用时常卡顿甚至无响应的现象。
4.1调优的常用的诊断工具
4.1.1 jvisualvm
jdk自带诊断工具,在cmd直接输入 "jvisualvm" 即可打开
4.1.2 arthas
阿里巴巴开发的诊断工具,它可以快速解决以下一些问题
这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception? 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了? 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗? 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现! 是否有一个全局视角来查看系统的运行状况? 有什么办法可以监控到JVM的实时运行状态? 怎么快速定位应用的热点,生成火焰图?
其使用方式也很简单,下载jar后运行即可。参考地址
github: https://github.com/alibaba/arthas gitee: http://arthas.gitee.io/
下载地址:https://github.com/alibaba/arthas/releases,选择需要的版本下载jar(),这里直接在Windows上进行演示,Linux也是一样的
解压后在文件夹里有arthas-boot.jar
,直接用java -jar
的方式启动
java -jar arthas-boot.jar
启动时会寻找本地所有的JVM进程,,需要选择应用 java 进程,这里先启动了一个main方法
package com.zxh.demo; public class JvmTest2 { public static void main(String[] args) { //模拟CPU过高运行 cpuHigh(); } public static void cpuHigh() { new Thread(() -> { while (true) { } }).start(); } }
输入要监控的进程的编号,启动成功会有提示
可查看仪表盘信息
dashboard
里面显示的信息都是动态的,包含线程使用CPU情况,栈和堆区使用情况
可以看到,名为"Thread-0"的线程运行状态正常,但其CPU的占用率总是高达99%以上,说明此线程是有问题的,需要查询问题并优化。
查看出现问题的线程的信息
thread 线程id
这里线程id是14
可以看到指出了问题代码的行数,查看代码发现果然是有问题的,里面是一个死循环,自此已经找到问题代码,就可以优化代码了。
可以通过反编译来查看代码是否已更新
jad 类的全路径
比如这里对JvmTest2进行反编译
jad com.zxh.demo.JvmTest2
可以看到编译后的源码
当然,也可以通过命令查看线程的运行情况
thread
其中测试线程死锁的代码如下
package com.zxh.demo; public class JvmTest2 { private static Object resource1 = new Object();//资源 1 private static Object resource2 = new Object();//资源 2 public static void main(String[] args) { new Thread(() -> { synchronized (resource1) { System.out.println(Thread.currentThread() + "get resource1"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + "waiting get resource2"); synchronized (resource2) { System.out.println(Thread.currentThread() + "get resource2"); } } },"t1").start(); new Thread(() -> { synchronized (resource2) { System.out.println(Thread.currentThread() + "get resource2"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + "waiting get resource1"); synchronized (resource1) { System.out.println(Thread.currentThread() + "get resource1"); } } },"t2").start(); } }
那么查看的信息如下
可以看到上述有两个线程状态是BLOCKED,也就是产生了死锁,通过参数查看死锁的线程的原因
thread -b
截图如下
指出了出现死锁的代码行数,可以根据此内容进行问题的整改。
对于arthas,其常用命令如下表
命令 | 说明 |
dashboard | 查看系统的实时数据面板 |
thread | 查看当前 JVM 的线程堆栈信息 |
quit | 退出当前 Arthas 客户端,其他 Arthas 客户端不受影响 |
shutdown / stop | 关闭 Arthas 服务端,所有 Arthas 客户端全部退出 |
4.2调优案例
1)参数说明
参数 | 说明 |
-Xms | jvm启动时分配的内存大小 |
-Xmx | jvm运行过程中分配的最大内存 |
-Xss | jvm启动的每个线程分配的内存大小 |
-Xmn | 年轻代内存大小 |
2)案例说明
上图是模拟一个亿级流量电商的服务图。
假设初始JVM堆参数是:-Xms2048M -Xmx2048M。也就是堆内存分配2G,下面进行垃圾回收的分析(根据上述的堆的内存结构图):
新生代占600多M(视为700M),那么Eden区占560M,S0和S1各占70M。对于上述的电商网站,当活动开启后,每秒产生60M对象,那么Eden区10秒不到就满了,就会触发Minor GC,那么此时就会把这一秒钟产生的60M对象放入S0区,由于S0区只有70M,通过动态年龄判断机制这是大对象(超过50%的对象视为大对象),因此这60M堆中最终会被移入老年代。而老年代默认使用比例超过92%会触发Full GC。老年代按1300M计算,那么大概20次Minor GC就会触发Full GC,耗时200秒左右。正确情况下是几天或者更长时间才触发一次Full GC,而这里Full GC非常的频繁,就会造成时常卡顿,导致用户体验极差。
分析原因可知,在进行Minor GC时对象没有进入Survivor区,而是直接进入了老年代,其实这些对象都是朝生夕死的,不应该直接进入老年代。那么解决方法就是加大新生代的容量,让Survivor区能存入Minor GC回收时Eden区的对象;同时降低对象进入老年代的阈值,让那些真正老不死的对象提前进入老年代,腾出Survivor区空间给Eden区复制时使用。
重设一下JVM参数,看看会是什么样的效果:-Xms3072M -Xmx3072M -Xmn2048M。这个配置堆内存分配了3G,新生代分配了2G,那么Eden区占1600M,S0和S1各占200M。对于Eden区大概27秒才会触发Minor GC,由于S0区只有200M,对于60M的对象来说,不算大对象,故会直接放入S0区而不放入老年代,那么老年代就不会那么快被填满,也就不会频繁进行Full GC,这样问题就解决了。
5.JMM(Java内存模型)
5.1什么是JMM
5.1.1定义
JMM是Java内存模型( Java Memory Model)的简称,也可以叫做Java多线程内存模型。它其实在一个抽象的概念,并不是真实存在的,只是为了方便使用。实际上它描述的是一种和多线程相关的一组规范。通过这组规范,定义了程序中对各个变量的访问方式。要求每个JVM都必须遵守这些规范,通过这种规范的保障,对于并发程序运行在不同的虚拟机时得到的结果才是安全可靠可信赖的。
需要注意的是,JMM指的是Java内存模型,是与多线程相关的一组规范,并不是Java内存结构!
5.1.2主内存与工作内存
1)背景介绍
计算机在执行程序时,每条指令都是在CPU中执行的。那么在执行指令的过程中必定会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程,跟CPU执行指令的速度比起来要慢的多(硬盘 < 内存 < CPU)。因此如果每次对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。
解决的方式就是在CPU与主存之间中加一层高速缓存。当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存中,那么CPU进行计算时,就可以直接从它的高速缓存中读取数据或向其写入数据。当运算结束之后,再将高速缓存中的数据刷新到主存中。
2)主内存和工作内存
Java内存模型也是采用的这种思想。它分为主内存(也叫主存储器,Main Memory)和工作内存(工作存储器,Working Memory)两种。它们之间的关系如下图:
①主内存
主内存存储的是Java对象实例,属于数据共享区域,多线程并发操作会出现线程安全问题。对象实例包括成员变量、类信息、常量、静态变量等,但是不包括局部变量和方法参数。
②工作内存
工作内存存储当前方法的所有本地变量信息,每个线程只能访问自己的工作内存,每个线程工作内存的本地变量对其他线程不可见。而且属于线程私有数据区域,不存在线程安全问题。
③两者的关系
对于两者的关系,JMM有明确的规定:
-
所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
-
线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,其他线程即可看到本次修改后的值;
-
主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。
5.3JMM八大原子操作
5.3.1八大原子操作
为了支持 JMM,Java 定义了8种原子操作,用来控制主存与工作内存之间的交互
操作 | 作用 | 作用区域 | 说明 |
read | 读取 | 主内存 | 将共享变量从主内存传送到线程的工作内存中 |
load | 载入 | 工作内存 | 把 read 读取的值放到工作内存中的副本变量中 |
store | 存储 | 工作内存 | 把工作内存中的变量传送到主内存中 |
write | 写入 | 主内存 | 把从工作内存中 store 传送过来的值写到主内存的变量中 |
use | 使用 | 工作内存 | 把工作内存的值传递给执行引擎,当虚拟机遇到一个需要使用这个变量的指令时,就会执行此操作 |
assign | 赋值 | 工作内存 | 把执行引擎获取到的值赋值给工作内存中的变量,当虚拟机栈遇到给变量赋值的指令时,就执行此操作 |
lock | 锁定 | 主内存 | 把变量标记为线程独占状态 |
unlock | 解锁 | 主内存 | 它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
看起来比较难理解,下面通过一个示例进行说明。
package com.zxh.demo; public class JvmTest3 { private static Boolean isFlag = false; public static void main(String[] args) throws InterruptedException { new Thread(() -> { System.out.println("线程开始执行"); while (!isFlag) { } System.out.println("线程执行完成"); }).start(); Thread.sleep(2000); new Thread(() -> changeStatus()).start(); } public static void changeStatus() { System.out.println("开始改变状态"); isFlag = true; System.out.println("结束改变状态"); } }
上述代码,本意是使用两个线程,当线程2将变量修改后那么线程1应该结束循环停止执行。但实际运行时,会发现线程1一直在运行,并没有停止。这就是多线程造成的线程安全问题,输出结果和预想的结果不一致。此时只有给变量加上 volatile 修饰即可达到期待的结果。至于volatile关键字在后续的章节中进行详细的描述。
那么对于这样一个变量的操作,在多线程时是如何执行的呢?
5.3.2八大操作图解与分析
缓存一致性协议:多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。原子操作如下图:
分析:
线程1会使用变量的值,故先从使用read把变量isFlag从主内存中传送到线程1的工作内存,再使用load把read传送过来的变量保存到线程1的工作内存的副本中。由于此时isFlag的值是false,故会一直循环。
线程2会修改变量的值,故先从使用read把变量isFlag从主内存中传送到线程2的工作内存(①),再使用load把read传送过来的变量保存到线程2的工作内存的副本中(②)。在工作内存将值修改为true后,再通过store把变量传送到主内存(③),然后再使用write把传送过来的值写到主内存中,也就是把内容修改为true(④)。
由于这里使用了volatile关键字,故会在总线中使用缓存一致性协议。因为包含线程2的CPU修改了变量的值,故修改后会马上同步到主内存,这个已经满足。对于其他的CPU,就会通过总线嗅探机制监听数据的变化,一旦数据发生了变化,那么当前线程中工作内存的变量会立即失效。当工作内存发现使用的数据失效后会重新从主内存中加载最新的数据,此时获取的值即为true,那么循环也会结束。
5.3.3八大操作规范
JMM对八大操作也有明确的规范:
-
不允许 read 和 load 、store 和 write 操作之一单独出现。即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
-
不允许线程丢弃它最近的 assign 操作。即工作内存中的变量数据改变之后,必须告知主存。
-
不允许线程将没有 assign 的数据从工作内存同步到主内存。
-
一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施 use、store 操作之前,必须经过 load 和 assign 操作。
-
一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
-
如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作以初始化变量的值。
-
如果一个变量没有被 lock,就不能对其进行 unlock 操作,也不能 unlock 一个被其他线程锁住的变量。
-
对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)。
5.4JMM三大特性
JMM的三大特性是:原子性、可见性、有序性。
5.4.1原子性
一个或多个操作,要么全部执行,要么全部不执行。换句话说,就是一个操作是不能分割,不可中断的,一个线程在执行时不会被其他线程干扰。
5.4.2可见性
只要有一个线程对共享变量的值做了修改,其他线程都将马上收到通知,立即获得最新的值。
这里就使用 volatile 关键字提供可见性的,除此之外,synchronized 和 final 也能实现可见性。
- synchronized 其原理是在执行完,进行unlock之前,必须把共享变量同步到主内存;
- final 它主要用来修饰字段,一旦初始化完成,如果没有对象逸出,那么对其他线程都是可见的。(对象逸出:如果一个类还没有初始化结束就已经提供给了外部代码一个对象引用)
5.4.3有序性
在代码顺序结构中,我们会直观的指定代码的执行顺序,即从上到下按序执行。处理器为了提高程序的运行效率,提高并行效率,可能会对代码进行优化。编译器认为,重排序后的代码执行效率更优。所以编译器和CPU处理器会根据自己的决策(遵循JMM两大原则 as-if-serial与happens-before),对我们编写的代码的执行顺序进行重新排序,优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序(指令重排序)。在单线程情况下,最终结果看起来没什么变化。但在多线程的环境下,由于执行语句重排序后,代码的执行顺序就未必是编写代码时候的顺序,可能会出错,导致计算结果与预期不符。这就是编译器的编译优化给并发编程带来的程序有序性问题。
Java 语言提供了 volatile 或 synchronized 关键字来保证多线程之间的有序性。
- volatile 使用内存屏障来禁止指令重排序,以保证有序性。
- synchronized 其是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”规则得出的,此规则决定了持有同一个对象锁的两个同步块只能串行进入。
5.5volatile关键字
其底层实现主要通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存。它的主要作用是保证线程间变量的可见性华润禁止 CPU 进行指令重排序,但不一定能保证线程安全。
5.5.1可见性
当对volatile修饰的变量做写操作时,JMM会把该线程中工作内存里对应的变量刷新到主内存;
当对volatile修饰的变量做读操作时,JMM会把该线程中工作内存里对应的变量置为失效,以至于只能从主内存重新获取共享变量。
5.5.2禁止指令重排序
它主要使用内存屏障让CPU禁止指令重排。
5.6JMM两大原则
两大原则分别是as-if-serial、happens-before。
5.6.1 as-if-serial原则
不管怎么排序,单线程程序的执行结果不能被改变(顺序一致性原则)。编译器和处理器不会对存在数据依赖关系的操作做重排序。
5.6.2 happens-before原则
用来解决可见性问题,说简单点就是说,某些操作必须发生在某些操作之前。其有八条规则:
(1)程序顺序规则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
(2)锁规则: 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加的动作必须在解锁动作之后(同一个锁)。
(3)volatile规则: volatile变量的写操作先行发生于后面对这个变量的读操作,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是够看到该变量的最新值。
(4)线程启动规则: 线程的start()方法先行发生 于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么线程B执行start方法时,线程A对共享变量的修改对线程B可见
(5)线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。Thread.join()方法的作用是等待当前执行的线程终止。
(6)线程中断规则:对线程 interupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
(7)对象终结规则:对象的构造函数执行结束先行发生于它的finalize()方法
(8)传递性:操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作A必然先行发生于操作C
5.7内存屏障
Java 内存模型通过内存屏障来禁止重排序。对于即时编译器来说,它会针对前面提到的每一个 happens-before
关系, 向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障,这些内存屏障会限制即时编译器的重排序操作。至于内存屏障是如何限制重排序是C++底层实现的。
Java语言规范定义内存屏障可分为读屏障和写屏障,用于控制可见性。
屏障类型 | 指令示例 | 说明 |
LoadLoad | Load1; LoadLoad; Load2 | 保证load1的读取操作在load2及后续读取操作之前执行 |
StoreStore | Store1; StoreStore; Store2 | 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore | Load1; LoadStore; Store2 | 在stroe2及其后的写操作执行前,保证load1的读操作已读取结束 |
StoreLoad | Store1; StoreLoad; Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
5.8双重检测锁DCL对象半初始化问题
下面是创建单例对象的案例,使用的双重检测锁。也就是在获取实例对象时,进行了两次的非空判断,而且在两次判断之间加了同步锁。
public class DoubleCheckLockSingleton { private Integer code = 200; public static DoubleCheckLockSingleton instance = null; private DoubleCheckLockSingleton() { } //获取单例对象,双重检测锁 public static DoubleCheckLockSingleton getInstance() { if (instance == null) { synchronized (DoubleCheckLockSingleton.class) { if (instance == null) { instance = new DoubleCheckLockSingleton(); } } } return instance; } }
而对象的初始化不是原子操作,大致分为三步:
①分配内存空间;②初始化对象;③将对象分配的内存地址赋值给变量instance。那么既然不是原子操作,就会进行重排序,可能出现①③②的顺序,也就是说对象还未初始化完成。
假设线程A先调用getInstance()方法,由于此时变量instance是null,按照①③②的执行顺序,当①③执行完成需要执行②时,线程B正好调用getInstance()方法,由于变量instance不是null就直接返回了对象,但此时这个对象是半初始化的状态,对象还未初始化完成,那么code就还是默认值0而不是200,会造成数据的错误。
羊毛出现在羊身上,解铃还须系铃人,是由于重排序造成的问题,那么使用关键字volatile 修饰 instance 变量,禁止指令重排序即可。
public volatile static DoubleCheckLockSingleton instance = null;
参考文章: