相关概念
可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到,我们就说这个变量是可见的。
共享变量:如果一个变量被多个线程使用到了,此时这个变量就会在每一个使用它的线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。
计算机多核并发内存架构
最早是CPU直接和主内存打交道,数据空间是放在内存中的,CPU在运行的过程中是需要数据的,但是CPU的运行速度是非常快的,比内存的运行速度快得多:
现代计算机会在CPU和主内存之间加一个缓存(多级):
比如在我的电脑中:
CPU不再直接与主内存打交道,而是与CPU缓存打交道,避免主内存成为CPU运行速度的一个瓶颈。
数据一开始是存在于主内存中,然后CPU运行的时候会把主内存的数据加载到CPU缓存中去,计算完成后会把最终结果值从CPU缓存中刷新到主内存中去。
比如在主内存中有一个count=0变量,现在线程1和线程2都对count执行了+1的操作,首先线程1和线程2肯定会将count=0load到CPU缓存中去,然后再执行count++操作,然后再刷新主内存,这样就会有线程安全问题,会出现count的值运算结果是有问题的。
后来就出现了缓存一致性协议解决多个CPU缓存中的数据不一致的情况,比如英特尔公司的MESI。
JMM
并发程序要比串行程序复杂很多,其中一个重要原因是并发程序下的数据访问的一致性,因此需要定义一种规则,保证多个线程间可以有效地、正确地协同工作。而JMM就是为此而生的。JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。
线程在执行的时候会将主内存中的变量拷贝一份到工作内存中,之后线程的运行只与工作内存打交道。
指令重排序
对于一个线程的执行代码而言,我们总是习惯地认为代码的执行是从先往后依次执行的,这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样,但是在并发时,程序的执行可能会出现乱序,而这就是指令的重排序。重排序是编译器或处理器为了提高程序性能而做的优化。
重排序主要分为以下三种:
- 编译器优化的重排序(编译器优化)
- 指令级并行重排序(处理器优化)
- 内存系统的重排序(处理器优化)
这里有一个简单的例子:
有一个值得注意的地方是,对于一个线程来说,它“看到”的指令执行顺序一定是一致的(否则的话我们的应用根本就无法工作),也就是说指令重排序有一个基本的前提,指令重排序可以保证串行语义一致;但是没有义务保证多线程间的语义也一致。多线程中程序交错执行时,重排序可能会造成内存可见性问题。
final除了我们平时所理解的语义之外,其实还蕴含着禁止把构造器final变量的赋值重排序到构造器外面,实现方式就是在final变量的写之后插入一个store-store barrier。
可见性
JMM描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节:
- 所有的变量都存储在主内存中
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝
程序中有一个公用的主内存中,所有的变量都存在于主内存中,每个线程都有一个自己的工作内存,工作内存负责与线程和主内存交互(线程不能直接与主内存进行交互)。在下图中有三个线程,在主内存中有一个共享变量X,那么在三个线程的工作内存中分别会存在X的一个副本:
在JMM中有这样两条规定:
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写,要想与主内存进行交互,就必须经过自己的工作内存,然后工作内存再与主内存进行交互;
- 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成;
共享变量可见性实现的原理
线程1对共享变量的修改要想被线程2及时看到,必须要经过以下两个步骤:
- 把工作内存1中更新过的共享变量刷新到主内存中;
- 将主内存中最新的共享变量的值更新到工作内存2中;
要实现共享变量的可见性,必须保证两点:
- 线程修改后的共享变量值能够及时从工作内存刷新到主内存;
- 其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中;
Java从语言层面实现可见性的实现方式
synchronized
synchronized能够实现互斥锁(同步),能够保证只有一个线程在操作锁里面代码,synchronized除了能够实现原子性,还可以实现可见性。
JMM关于synchronized的两条规定:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中;
- 线程加锁时,将清空当前线程工作内存中共享变量的值,从而当前线程使用共享变量时需要从主内存中重新读取最新的值(注意:加锁和解锁需要是同一把锁);
也就是说线程解锁前对共享变量的修改在下次加锁时对其他线程可见。
线程执行互斥代码的过程如下:
这里有一段简单的代码示例:
这个代码会有多种情况,因为1.1和1.2,2.1和2.2都有可能发生重排序。如果执行顺序为1.1-2.1-2.2-1.2,即第一个线程在执行了1.1的代码后(且对ready的修改能够及时刷新至主内存),让出了CPU的资源,那么第二个线程在执行2.1的时候,此时ready为true,则会执行2.2的代码,此时number并不是3,而是1,所以result输出的值反而是3。
所以一个解决方式就是在write()和read()方法上加上synchronized关键字。
这里共享变量不可见的原因主要有三个:
- 线程的交叉执行
- 重排序结合线程交叉执行
- 共享变量未及时更新
而synchronized就相当于加了一把锁,让锁内部的代码保持原子性,可以防止线程的交叉执行。而由于锁内部的代码保持了原子性,这时候无论锁内部的代码是否发生重排序都不会对结果造成影响。而结合“JMM关于synchronized的两条规定”,synchronized是可以保证共享变量及时更新的,synchronized也就解决了上述造成共享变量不可见的三个问题。
volatile
在Java中使用了一些特殊的操作或者关键字来申明、告诉虚拟机,在这个地方,要小心了,不能随意变动优化目标指令。其中就包含volatile关键字。
volatile是通过加入内存屏障和禁止重排序优化来实现可见性的:
- 对volatile变量写操作时,会在写操作后加入一条store屏障指令,将本地内存中的共享变量刷新到主内存;
- 对volatile变量读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量;
简单点说就是,volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样任何时刻不同的线程总能看到该变量的最新值。
但是要注意的是volatile能够保证volatile变量的可见性,但是不能保证volatile变量复合操作的原子性。
比如在下面的例子中,volatile并不能保证i++是原子操作:
执行结果:
一般使用volatile需要同时满足:
- 对变量的写操作不依赖其当前值(但是Boolean变量是可以适用的);
- 该变量没有包含在具有其他变量的不变式中;
synchronized和 volatile比较
- volatile不需要加锁,比 synchronized更轻量级,不会阻塞线程;
- 从内存可见性角度将,volatile读相当于加锁,volatile写相当于解锁;
- synchronized既能保证可见性,又能保证原子性,而 volatile只能保证可见性,无法保证原子性;