随着计算机的飞速发展,cpu从单核到四核,八核。在2020年中国网民数预计将达到11亿人。这些数据都意味着,做为一名java程序员,必需要掌握多线程开发,谈及多线程,绕不开的是对JMM(Java 内存模型)。那么什么是JMM?什么是可见性、原子性、有序性?如何解决?本文将从CPU的缓存开始谈起,深度解剖JMM底层原理。java
学过操做系统的同窗都应该知道CPU缓存。那么为何要弄这么一个CPU缓存呢?这是由于缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,由于CPU运算速度要比内存读写速度快不少,这样会使CPU花费很长时间等待数据到来或把数据写入内存。所以若是任什么时候候对数据的操做都要经过和内存的交互来进行,会大大下降指令执行的速度。所以在CPU里面就有了高速缓存。也就是,当程序在运行过程当中,会将运算须要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就能够直接从它的高速缓存读取数据和向其中写入数据,当运算结束以后,再将高速缓存中的数据刷新到主存当中git
如图,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一致性问题。github
为了正确性,一旦一个CPU更新了内存中的内容,硬件就必需要保证其余的核心可以读到更新后的数据。目前大多数硬件采用的策略或协议是MESI或基于MESI的变种:
M表明更改(modified),表示缓存中的数据已经更改,在将来的某个时刻将会写入内存;
E表明排除(exclusive),表示缓存的数据只被当前的CPU所缓存;
S表明共享(shared),表示缓存的数据还被其余CPU缓存;
I表明无效(invalid),表示缓存中的数据已经失效,即其余CPU更改了数据。
单个CPU对缓存中数据进行了改动,须要通知给其它CPU,也就是意味着,CPU处理要控制本身的读写操做,还要监听其余CPU发出的通知,从而保证最终一致。web
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