基本概念
高速缓存
计算机在执行程序时,每条指令都是在 CPU 中执行的,在指令执行的过程中,都会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存里,这时就存在一个问题: 从内存读取数据和向内存写入数据的过程跟 CPU 执行指令的速度比起来要慢的多,如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在 CPU 里面就有了高速缓存。

当程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
比如下面的这段代码:
i = i + 1;
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后 CPU 执行指令对 i 进行加 1 操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
缓存一致性问题
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核 CPU 中,每条线程可能运行于不同的 CPU 中,因此每个线程运行时有自己的高速缓存
如果有两个线程执行这段代码,我们希望初始值为0的i经过执行后变成2。但结果可能不会如我们所想。因为可能会出现一种情况: 初始时两个线程分别读取i的值存入各自所在的 CPU 的高速缓存当中,然后线程 1 进行加 1 操作,然后把i的最新值 1 写入到内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为1,然后线程 2 把 i 的值写入内存。最终i的值是1而不是2
这种问题的一个解决办法是缓存一致性协议。最出名的就是 Intel 的 MESI 协议,MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
并发编程的三个问题
原子性
一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 一个典型的例子是银行转账: 从账户 A 向账户 B 转 1000 元,这里面包括2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元。 如果这 2 个操作不具备原子性,可能会出现: 从账户 A 减去 1000 元之后,操作突然中止。然后又从 B 取出了 500 元,取出 500 元之后,再执行往账户 B 加上 1000 元 的操作。这样就会导致账户 A虽然减去了 1000 元,但是账户 B 没有收到这个转过来的 1000 元。
可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 来看一段代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。 此时线程 2 执行 j = i,它会先去主存读取i的值并加载到 CPU2 的缓存当中,注意此时内存当中i的值还是 0,那么就会使得 j 的值为 0,而不是 10。 这就是可见性问题: 线程 1 对变量 i 修改了之后,线程 2 没有立即看到线程 1 修改的值。
有序性
有序性是指程序执行的顺序按照代码的先后顺序执行。如下代码所示:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面的代码中语句 1 是在语句 2 前面的,但JVM 在真正执行这段代码时不一定保证语句 1 一定会在语句 2 前面执行吗,因为这里可能会发生指令重排序。
指令重排序
为优化程序性能,编译器和处理器对原有的指令执行顺序进行优化重新排序。重排序可能发生在多个阶段,比如编译重排序、CPU重排序等。
这段代码可能的一个执行顺序是:
语句2->语句1->语句4->语句3
为什么指令重排序可以提高性能?
每个指令包含若干个步骤,每个步骤可能使用不同的硬件。因此便有了流水线技术:指令1还未执行完,可以开始执行指令2,而不是等到指令1执行结束后才执行指令2。
以下面代码为例,先加载b,c,由于加载是需要一定时间的,这就增加了停顿,后面的指令也会依次有停顿,这会降低执行效率。为了减少停顿,我i们可以先加载e,f,然后再加载b,c,这样不仅对(串行)程序没有影响的,还可以减少停顿。
a = b + c;
d = e - f ;
数据依赖性
若两个操作访问同一变量,且两个操作中有一个为写操作,则这两个操作存在数据依赖性。此处的数据依赖性仅针对单处理器中执行的指令序列和单线程中执行的操作, 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令 Instruction 2 必须用到Instruction 1的结果,那么处理器会保证 Instruction 1会在 Instruction 2 之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是会影响到线程并发执行的正确性。
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
在上面的代码中,由于语句1和语句 2 没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程 1 执行过程中先执行语句 2,而此时线程 2 会以为初始化工作已经完成,那么就会跳出 while 循环,去执行doSomethingwithconfig(context)方法,而此时 context 并没有被初始化,就会导致程序出错。
总结的说:要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
Java内存模型
在 Java 虚拟机规范中定义了Java内存模型JMM来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
它定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。(本地内存只是JMM的一个抽象概念,并不真实存在。它包含了缓存缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。)
JMM模型与计算机硬件模型直接的对照关系可简化为下图:
需要说明的是,JMM中的主内存、工作内存与JVM中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
主内存
主要存放Java实例对象,所有线程创建的实例对象(无论是成员变量还是局部变量)都存放在主内存中。
本地内存
主要存储当前方法的所有本地变量信息和主内存中的变量副本拷贝。每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的本地内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。
举例子说明:
i = 10;
执行线程必须先在自己的工作线程中对变量 i 所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值 10 写入主存当中。
原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
我们来看一段代码:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
- 语句1是原子性操作。执行语句的线程直接将数值10写入工作内存中。
- 语句2不是原子性操作。执行语句的线程首先读取x的值,再将x的值写入工作内存
- 语句3和语句4都不是原子性操作。它们包含3个操作:读取 x 的值,进行加 1 操作,写入新的值。
可见性
Java提供了 volatile 关键字来保证可见性。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。普通的共享变量是不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
此外通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
在Java里可以通过volatile 关键字来保证一定的有序性,此外还可以通过 synchronized 和 Lock 来保证有序性。
happens-before原则
另外,Java内存模型具备一些先天的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
这个原则的定义如下:
(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。
总的来说,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。
下面来看一下在Java里天然的happens-before关系:
(1)一个线程中的每一个操作,总是happens-before于该线程中的任意后续操作。
(2)对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)如果线程A通过ThreadB.start()来启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
volatile
volatile的两层语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止volatile变量与普通变量重排序
(1)内存可见性
首先来看一段代码:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
我们在中断线程时可能都会采用这种标记办法,但实际上,代码不一定会将线程中断。原因就是内存可见性:当线程1将stop变量值拷贝搭配自己的工作内存中,如果线程2修改了stop 变量的值,但还没来得及写入主存当中,此时线程1由于不知道线程2对stop变量的修改而一直循环下去。
如果用 volatile 修饰stop变量后:当线程 2 进行修改时,会导致线程 1 的工作内存中缓存变量 stop 的缓存行无效(反映到硬件层的话,就是CPU 的 L1 或者 L2 缓存中对应的缓存行无效)然后线程 1 再次读取变量 stop 的值时会去主存读取,这样线程 1 读取到的就是最新的正确的值。
(2)禁止重排序
volatile 关键字禁止指令重排序有两层意思:
- 当程序执行到 volatile 变量的读操作或者写操作operate()时,在operate()前面的操作肯定全部已经进行,且前面操作的结果已经对后面的操作是可见的;在operate()后面的操作肯定还没有进行;
- 在进行指令优化时,不能把对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
来看一段代码:
//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 是可见的。
volatile 的原理
观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令。
lock 前缀指令实际上相当于一个内存屏障:
- 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

volatile的原子性
首先来看一段代码:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
我们期望程序的输出结果是1000,但事实上每次的运行结果都不一致,都是一个小于 10000 的数字。
这是因为volatile无法保证原子性,而自增操作是不具备原子性的,这可能会出现一种情况:
- 某个时刻变量 inc 的值为10
- 线程 1 从主存中读取inc的值,然后被阻塞
- 线程2被唤醒,从主存中读取inc的值,加1,然后被阻塞
- 线程1被唤醒,inc值加1,然后同步到主存中
- 线程2被唤醒,把最新值赋值给inc,同步到主存中。(此时的线程2已经处理过inc了,因此只把新值赋值给inc,不会再去主存读取inc。)
也就是说:两个线程分别进行了一次自增操作后,inc 只增加了 1。
也许你会好奇:前面不是说了volatile会让缓存行无效吗?然后其他线程去读就会读到新的值。这句话是没错,但需请注意:
- 线程 1 对变量进行读取操作之后,然后被阻塞,此时并没有对 inc 值进行修改。
- 线程2对Inc的值进行修改,使得线程1的缓存行失效。但是线程1唤醒后是继续往后执行,而不是重新从内存里再get一次
那么为什么在**(1)内存可见性里的例子中,volatile修饰stop变量是可行的呢?这是因为那个例子里线程1没有对stop变量进行修改,它只读,,每一次循环,它都是先从内存/缓存行里读取stop的值,然后doSomething。而线程2就是对stop变量进行修改,并且立刻**将stop变量更新到内存中,同时线程1里的缓存行也失效,再次读取就是从内存里读取最新的值。
针对这种原子性问题,可以使用锁synchronized ,Lock和原子操作类AtomicInteger。
volatile的应用场景
synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而 volatile 关键字在某些情况下性能要优于 synchronized,但由于volatile 关键字无法保证操作的原子性,因此无法替代synchronized 关键字的。
通常来说,使用 volatile 必须具备以下 2 个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
也就是说:需要保证操作是原子性操作,才能保证使用 volatile 关键字的程序在并发时能够正确执行。
class 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;
}
}