随着计算机的飞速发展,cpu从单核到四核,八核。在2020年中国网民数预计将达到11亿人。这些数据都意味着,作为一名java程序员,必须要掌握多线程开发,谈及多线程,绕不开的是对JMM(Java 内存模型)。那么什么是JMM?什么是可见性、原子性、有序性?如何解决?本文将从CPU的缓存开始谈起,深度解剖JMM底层原理。
学过操作系统的同学都应该知道CPU缓存。那么为什么要弄这么一个CPU缓存呢?这是因为缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快很多,这样会使CPU花费很长时间等待数据到来或把数据写入内存。因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中
如图,CPU缓存分为三层(L1,L2,L3 Cache),L1和L2 Cache都是每个CPU core独立拥有一个,而L3 Cache是几个Cores共享的,可以认为是一个更小但是更快的内存。CPU在做运算时需要先把内存(RAM)中的数据读取到缓存当中,经过运算后再将数据写回内存(RAM)中。这样的操作在单核CPU中当然是没有问题的,但是在多核CPU中会出现Cache一致性问题。
比如两个CPU(a和b)同时将内存中的同一个变量i=0加载到了CPU缓存(L1或L2)中,aCPU对变量i进行了++操作后回写到了内存中,此时内存中的i变量值变成了1,但是bCPU不知道,这是 bCPU在缓存中(L1或L2)的i变量还是0,这时bCPU对i变量进行i++运算后回写到内存中,这是内存中的i变量被覆盖,值还是1。这就是Cache一致性问题。
为了正确性,一旦一个CPU更新了内存中的内容,硬件就必须要保证其他的核心能够读到更新后的数据。目前大多数硬件采用的策略或协议是MESI或基于MESI的变种:
M代表更改(modified),表示缓存中的数据已经更改,在未来的某个时刻将会写入内存;
E代表排除(exclusive),表示缓存的数据只被当前的CPU所缓存;
S代表共享(shared),表示缓存的数据还被其他CPU缓存;
I代表无效(invalid),表示缓存中的数据已经失效,即其他CPU更改了数据。
单个CPU对缓存中数据进行了改动,需要通知给其它CPU,也就是意味着,CPU处理要控制自己的读写操作,还要监听其他CPU发出的通知,从而保证最终一致。
CPU在对性能的优化除了缓存之外还有运行时指令重排,当CPU写缓存时发现缓存区正被其他CPU占用(例如:三级缓存L3),为了提高CPU处理性能,可能将后面的读缓存命令优先执行。列如:
x = 6;
y = z;
这一段程序的正常执行顺序应该是:
但是经过CPU指令重排后的执行顺序可能是这样:
处理器提供了两个内存屏障(Memory Barrier)指令用于解决上述两个问题:
写内存屏障(Store Memory Barrier):在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。强制写入主内存,这种显示调用,CPU 就不会因为性能考虑而去对指令重排。
读内存屏障(Load Memory Barrier):在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制从新的主内存加载数据。强制读取主内存内容,让 CPU 缓存与主内存保持一致,避免了缓存导致的一致性问题。
好了,到这里总算是将CPU的缓存机制粗略的讲完了,接下来到了文章的重点部分:JMM,其实JMM的实现原理基本上就是照搬的CPU高速缓存的Cache一致性问题和CPU运行时的指令重排问题的解决策略。
JMM(Java内存模型Java Memory Model)本身是一种抽象的概念 并不真实存在,它是Java虚拟机规范中试图定义的一种模型或规范来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。,通过规范定制了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式.
JMM关于同步规定:
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(对应JVM内存区域的虚拟机栈),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量(这里指的变量为类的成员变量,方法中创建的临时变量不在其中,下同)都存储在主内存(对应JVM内存区域的堆),主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,线程之间的通讯(传值) 必须通过主内存来完成,其简要访问过程如下图:
在之前的CPU高速缓存中,我们讲解了Cache一致性问题,JMM规范中的可见性和Cache一致性问题是一样一样的。即:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
下面一段代码将描述变量的不可见性:
public class NoVisibility {
private static int NUM = 0;
public void numEqTen(){
NUM = 10;
}
public static void main(String[] args) {
final NoVisibility noVisibility = new NoVisibility();
// 第一个线程
new Thread(() -> {
try {
// 睡眠1秒钟,保证主线程得到执行
Thread.sleep(1000L);
noVisibility.numEqTen();
System.out.println(Thread.currentThread().getName() + "\t 执行完毕");
} catch (InterruptedException e) {
e.printStackTrace();
}
},"thread1").start();
while (noVisibility.NUM == 0) {
//如果myData的num一直为零,main线程一直在这里循环
}
System.out.println(Thread.currentThread().getName() + "\t 主线程执行完毕, num 值是 " + noVisibility.NUM);
}
}
该程序的运行结果是:输出thread1执行完毕,后一直停在了主线程的while循环中不能结束。下面解释一下这段代码为什么一直停留在while而无法执行完毕:
在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么主线程在运行的时候,会将num变量的值拷贝一份放在自己的工作内存当中。那么当线程1更改了num变量的值之后,主线程由于不知道线程1对num变量的更改,因此还会一直循环下去。
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。对变量的操作,如:i++,该操作是分为三个指令执行的:
public class NoAtomicity {
private int num;
public void numPlusPlus(){
num++;
}
public static void main(String[] args) {
NoAtomicity noAtomicity = new NoAtomicity();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
for (int j = 0; j <200 ; j++) {
noAtomicity.numPlusPlus();
}
} catch (Exception e) {
e.printStackTrace();
}
},"thread" + String.valueOf(i)).start();
}
// 等待上面的线程运行完毕
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t num的最终值是:" + noAtomicity.num);
}
}
我们都知道在理想情况下值应该是2000,然而因为num++不是原子性的,所以我执行出来的结果是:main num的最终值是:1600 当然,每次运行的结果可能都不一样。但基本上都是小于2000的。
在前面我们讲解了CPU运行时的指令重排,这里的有序性也是同样的问题。计算机在执行程序时,为了提高性能(原因在CPU运行时的指令重排有说),编译器和处理器常常会做指令重排,一把分为以下3中:
线程环境里面确保程序最终执行结果和代码顺序执行的结果一致,处理器在进行重新排序是必须要考虑指令之间的数据依赖性。多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,所以所得的结果无法预测。
重排代码实例:
声明变量:int a,b,x,y=0
线程1 | 线程2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
结 果 | x = 0 y=0 |
如果编译器对这段程序代码执行重排优化后,可能出现如下情况:
线程1 | 线程2 |
---|---|
b = 1; | a = 2; |
x= a; | y = b; |
结 果 | x = 2 y=1 |
这个结果说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面我们来解释一下前4条规则:
在了解了JMM规范后,那么如何保证变量的可见性、原子性和有序性呢?可爱的java为我们提供了一些关键字如:synchronized、volatile。还有一个诚意满满的类库:JUC,是不是很感动?哈哈~ 接下来我们来介绍几种实现。
谈及synchronized,这家伙在在JavaSE 1.6之前可是一个重量级锁,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。synchronized有三种使用方式:
当某部分被sychronized关键字修饰后,该部分在任意时刻只能有一个线程执行(得到锁的线程),既然只能有一个线程执行,那么JMM中的可见性,原子性它都能够保证了。那么有序性呢?sychronized还是不能阻止指令重排,在双重检验+锁实现单例模式时还是会出现空指针异常,这个我们后面会讲到。
volatile是Java虚拟机提供的轻量级的同步机制,作用在变量上(类成员变量、类的静态成员变量),它能对作用的变量保证可见性和禁止指令重排,但是并不能保证原子性。
可见性:
我们回到前面讲可见性时举的例子:
public class NoAtomicity {
private volatile int num;
public void numPlusPlus(){
num++;
}
public static void main(String[] args) {
NoAtomicity noAtomicity = new NoAtomicity();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
for (int j = 0; j <200 ; j++) {
noAtomicity.numPlusPlus();
}
} catch (Exception e) {
e.printStackTrace();
}
},"thread" + String.valueOf(i)).start();
}
// 等待上面的线程运行完毕
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t num的最终值是:" + noAtomicity.num);
}
}
通过之前的分析,我们知道主线程会在while循环中一直循环下去出不来,那么,如果在num变量前面加上关键字volatile修饰,情况就不一样了:
那么在线程1修改num值时(当然这里包括2个操作,修改线程1工作内存中的值,然后将修改后的值写入内存),会使得主线程的工作内存中缓存变量num的缓存行无效,然后主线程读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。那么主线程读取到的就是最新的正确的值。
有序性
在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:
我们前面讲了CPU运行时的指令重排底层原理其实是内存屏障,volatile关键字禁止指令重排其实就是利用了内存屏障的原理:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也叫内存栅栏),内存屏障会提供3个功能:
原子性:
在前面我们讲原子性的时候已经讲过,比举了一个例子,现在我们再对刚才那个例子进行讲解:
public class NoAtomicity {
private volatile int num;
public void numPlusPlus(){
num++;
}
public static void main(String[] args) {
NoAtomicity noAtomicity = new NoAtomicity();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
for (int j = 0; j <200 ; j++) {
noAtomicity.numPlusPlus();
}
} catch (Exception e) {
e.printStackTrace();
}
},"thread" + String.valueOf(i)).start();
}
// 等待上面的线程运行完毕
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t num的最终值是:" + noAtomicity.num);
}
}
之前我们讲过,在变量num没有加上volatile关键字修饰时,最后num的结果会是小于2000,那么加上之后呢?我们来分析分析:假设此时num的值为10
此时可以发现,两次自增操作下来,由于num++不是原子操作,从而导致变量num只增加了1。
那么如何保证原子性?有三种解决办法:
理解了volatile和sychronized关键字后,我们来举个常用的懒汉式双重判断+锁的单例模式的实现:
public class Singleton {
private static volatile Singleton instance;
private Singleton(){ }
public Singleton getInstance(){
if (instance == null){
synchronized (Singleton.class){
if (instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
这里将变量instance使用volatile修饰的原因是为了防止指令重排,导致空指针异常,具体原因:
在 instance = new Singleton();这个操作不是原子操作,可能存在着指令重排,正常顺序是:
然而出现指令重排后,可能的顺序会变成132,这样就会导致线程1在执行到第3步时线程1被阻塞,这时虽然第2步还没有执行,但是instance已经不为null了
然后线程2获得执行,在if判断时,因为instance不为null了,此时将会直接返回instance。这时线程2在通过instance访问其成员变量时(如:instance.getName())就会报空指针异常。
这里使用的双重if判断的原因:
好了,我们回到刚刚说的使用JUC.Atomic包下的AtomicInteger解决volatile关键字不能实现原子性而导致上面程序的结果不为2000的解决办法。那么何为AtomicInteger?
AtomicInteger类是java.util.concurrent.atomic下的类。java在atomic包下提供了基本变量和引用变量的原子类,支持单个变量上的无锁线程安全编程。使用AtmoicInteger + volatile关键字实现上面所提到的程序结果不为2000的程序:
public class Atomicity {
private volatile AtomicInteger num = new AtomicInteger(0);
public void numIncrement(){
num.getAndIncrement();
}
public int getNum(){
return num.get();
}
public static void main(String[] args) {
Atomicity atomicity = new Atomicity();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
for (int j = 0; j <200 ; j++) {
atomicity.numIncrement();
}
} catch (Exception e) {
e.printStackTrace();
}
},"thread" + String.valueOf(i)).start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t num的最终值是:" + atomicity.getNum());
}
}
运行此代码的结果是2000,那么为什么使用了AtomicInteger后就能保证原子性了呢?
我们翻看AtomicInteger的源码:
/**
* Atomically increments by one the current value.
*
* @return the previous value
*/
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
发现调用的是unsafe的方法,那么usafe又是什么呢?
UnSafe是CAS的核心类 由于Java 方法无法直接访问底层 ,需要通过本地(native)方法来访问,UnSafe相当于一个后面,基于该类可以直接操作特额定的内存数据.UnSafe类在于sun.misc包中,其内部方法操作可以向C的指针一样直接操作内存,因此Java中CAS操作依赖于UNSafe类的方法.
注意UnSafe类中所有的方法都是native修饰的,也就是说UnSafe类中的方法都是直接调用操作底层资源执行响应的任务。
好了,现在了解了UnSafe是CAS的核心类,那么CAS又是什么?
CAS的全称为Compare-And-Swap ,它是一条CPU并发原语.
它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的.
CAS并发原语提现在Java语言中就是sun.miscUnSafe类中的各个方法.调用UnSafe类中的CAS方法,JVM会帮我实现CAS汇编指令.这是一种完全依赖于硬件 功能,通过它实现了原子操作,再次强调,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许中断,也即是说CAS是一条原子指令,不会造成所谓的数据不一致的问题。
了解了CAS后,现在我们继续跟进unsafe.getAndAddInt(this, valueOffset, 1)方法:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
那么这个方法又是如何实现原子操作的呢?
先对方法的参数进行解读:
对这个方法的解读:
假设线程A和线程B两个线程同时执行getAndAddInt操作(分别在不同的CPU上):
好了,到这里就解释清楚了AtomicInteger是如何保证原子性的,但是它的缺点也很明显:
什么是ABA问题?简单点的回答就是:狸猫换太子!
因为CAS在取出主存中的数据,然后再进行比较,在这两个步骤中会有一个时间差,即这两个步骤不是原子性的。那么就有可能线程2在线程1取完数据A后,也将数据A取出并将它改为B然后又将它改回A写回内存。这是线程1在进行CAS操作时发现内存中的数据还是A,然后线程1就执行成功了。这就是ABA问题。
ABA问题程序实现:
public class ABA {
private static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
public static void main(String[] args) {
new Thread(() ->{
atomicReference.compareAndSet(100,101);
atomicReference.compareAndSet(101,100);
},"thread1").start();
new Thread(() ->{
try {
// 睡眠1秒,保证完成ABA
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicReference.compareAndSet(100,2020);
System.out.println(atomicReference.get());
},"thread1").start();
}
}
执行的最终结果为2020,没有解决ABA问题
那么如何解决ABA问题?
我们想想每次完成CAS操作后都给它加上一个版本号不就可以知道它有没有被改过了嘛?那既然我们都能想到,可爱的Java早就想到了并且为我们提供了一个叫AtomicStampedReference的类,它也是在JUC.atomic包下。
public class ABAResolve {
private static AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(100,1);
public static void main(String[] args) {
new Thread(()->{
int stamp = stampedReference.getStamp();
System.out.println(Thread.currentThread().getName()+"\t 第1次版本号"+stamp+"\t值是"+stampedReference.getReference());
// 睡眠1s让线程2获取值和版本号
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
stampedReference.compareAndSet(100,101,stampedReference.getStamp(),stampedReference.getStamp()+1);
System.out.println(Thread.currentThread().getName()+"\t 第2次版本号"+stampedReference.getStamp()+"\t值是"+stampedReference.getReference());
stampedReference.compareAndSet(101,100,stampedReference.getStamp(),stampedReference.getStamp()+1);
System.out.println(Thread.currentThread().getName()+"\t 第3次版本号"+stampedReference.getStamp()+"\t值是"+stampedReference.getReference());
},"thread1").start();
new Thread(()->{
int stamp = stampedReference.getStamp();
System.out.println(Thread.currentThread().getName()+"\t 第1次版本号"+stamp+"\t值是"+stampedReference.getReference());
//保证线程1完成1次ABA
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = stampedReference.compareAndSet(100, 2019, stamp, stamp + 1);
System.out.println(Thread.currentThread().getName()+"\t 修改成功否"+result+"\t最新版本号"+stampedReference.getStamp());
System.out.println("最新的值\t"+stampedReference.getReference());
},"thread2").start();
}
}
运行结果为:
thread1 第1次版本号1 值是100
thread2 第1次版本号1 值是100
thread1 第2次版本号2 值是101
thread1 第3次版本号3 值是100
thread2 修改成功否false 最新版本号3
最新的值 100
至此ABA问题解决。
本文所涉及的所有代码都在我的GitHub上:https://github.com/dave0824/jmm