著名的缓存一致性问题:
计算机的内存模型:
CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。为了解决这个问题CPU厂商采用了缓存的解决方案,直到目前我们正在使用的多级的缓存结构。上图中的CPU Cache就是下图中的L1缓存,L2缓存,L3缓存。
架构图:
存储层次结构(金字塔):
- 寄存器 → L1缓存 → L2缓存 → L3缓存 → 主内存 → 本地磁盘 → 远程数据库。
- 越往上访问速度越快、成本越高,空间更小。越往下访问速度越慢、成本越低,空间越大。
多级缓存的工作原理:
- 在CPU和内存之间,引入了L1高速缓存、L2高速缓存、L3高速缓存,每一级缓存中所存储的数据全部都是下一级缓存中的一部分。
- 当CPU需要数据时,先从缓存中取,加快读写速度,提高CPU利用率。
缓存一致性问题引出:
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:
i = i - 1;
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对 i 进行减1操作,然后将数据写入高速缓存,最后将高速缓存中 i 最新的值刷新到主存当中。
这个代码在单线程中运行是没问题的,但是在多线程中运行就会有问题了,在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。
我们模拟一个卖票例子:
假如内存中总共剩余10张票(i=10),有4个售票窗口(4个线程),此时4个CPU Cache中的 i 都是10,售票口1(Thread1)卖了一张,所以cpu从cpu cache中读取到 i 的值后进行减一变成了9。这是线程2(Thread2)也执行了同样的操作,这个时候Thread1和Thread2都往内存中写入9,这个时候就导致卖出去两张票,但是实际内存中只减了1。这就是著名的缓存一致性问题。
缓存一致性问题的解决:
为了解决缓存不一致性问题,通常来说有以下2种解决方法:
- 通过在总线加LOCK#锁的方式
- 通过缓存一致性协议
这2种方式都是硬件层面上提供的方式。
总线锁:在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i - 1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下,所以就出现了缓存一致性协议。
缓存一致性协议(Volatile底层依赖这个协议):MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。
最出名的就是Intel 的MESI协议:
MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
底层实现主要是通过汇编Lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存。总的来说就是Lock指令会将当前处理器缓存行数据立即写回到系统内存从而保证多线程数据缓存的时效性。这个写回内存的操作同时会引起在其它CPU里缓存了该内存地址的数据失效(MESI协议)。
为了保证在从工作内存刷新回主内存这个阶段主内存数据的安全性,在store前会使用内存模型当中的lock操作来锁定当前主内存中的共享变量。当主内存变量在write操作后才会将当前lock释放掉,别的线程才能继续进来获取新的值。
那么缓存一致性协议有什么缺点呢?
总线风暴:由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。
所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。
在并发编程中的:原子性问题,可见性问题,有序性。
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
经典例子银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:
-
- 从账户A减去1000元。
- 往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止(比如操作的机器突然断电)。这个时候,账户B并没有收到A的转账,同时A也少了1000元。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
int i = 10;
int j = 11;
i = 20; //语句3
j = 21; //语句4
上面代码从顺序上看,语句3是在语句4前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上面的代码中,语句3和语句4谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句4先执行而语句3后执行。
处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令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语言本身对原子性、可见性、有序性提供了哪些保证呢?
1.原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
例子:请分析以下哪些操作是原子性操作?
int x = 10; //语句1
int y = x; //语句2
x++; //语句3
x = x + 1; //语句4
只有语句1是原子性操作,其他三个语句都不是原子性操作。
- 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
- 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
- x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
也就是说,在Java中只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized(参考:Synchronized 看一篇就够了)和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.有序性
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
这8条原则摘自《深入理解Java虚拟机》。
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面我们来解释一下前4条规则:
- 对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
- 第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
- 第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
- 第四条规则实际上就是体现happens-before原则具备传递性。
深入剖析volatile关键字
1.volatile关键字的两层语义
一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
Volatile的工作过程就是:
- 使用volatile关键字会强制将修改的值立即写入主存。
- 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效)。
- 由于线程1的工作内存中缓存变量的缓存行无效,所以线程1再次读取变量的值时会去主存读取。
2.volatile保证原子性吗?
Volatile无法保证对变量的操作是原子性的。
我们还是拿卖票这个例子来看下:
public class TestThreadUnsafe1 implements Runnable {
public static void main(String[] args) {
TestThreadUnsafe1 buyTicket = new TestThreadUnsafe1();
new Thread(buyTicket,"1").start();
new Thread(buyTicket,"2").start();
new Thread(buyTicket,"3").start();
new Thread(buyTicket,"4").start();
}
//总共有票100张
private volatile int ticketNum = 100;
//是否还有票
boolean flag = true;
@Override
public void run() {
while (flag){
try {
buy();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
//买票操作,也就是把余票进行-1
private void buy() throws InterruptedException {
if (ticketNum <= 0){
flag = false;
return;
}
Thread.sleep(10);
System.out.println(Thread.currentThread().getName() + "获得了第"+ ticketNum-- + "张票"); //第33行代码
}
}
总共有100张票,有4个窗口(线程)卖票,这段代码看起来结果应该是可以正常把票卖完,不会发生重复卖,以及超卖的事情,但事实是:
//这里只把最后的几行输出复制过来了,足够我们找出问题了
Thread4获得了第8张票
Thread3获得了第6张票
Thread1获得了第8张票 //这里票8重复了
Thread2获得了第7张票
Thread4获得了第5张票
Thread3获得了第3张票
Thread1获得了第4张票
Thread2获得了第2张票
Thread3获得了第-1张票 //这里超卖了
Thread2获得了第0张票
Thread1获得了第1张票
Thread4获得了第1张票 //这里票1重复了
我们看到结果中出现了两个问题:
- 票据重复卖(有两个票1,和票8)
- 超卖现象(本来到票到0就截止了,但是却卖的超过了0)
问题出在哪呢?出在第33行代码中的ticketNum--
上。
我们前面说过x++
这类操作在Java中不是原子性的操作,我们可以看下这个类的Java字节码。先用javac -encoding "UTF-8" TestThreadUnsafe1.java
命令把java文件编译为class文件,然后用javap -p -v -c TestThreadUnsafe1.class
命令来查看class文件。(字节码相关参考: 《JVM字节码指令》)
我们看下字节码中最后一段,buy方法的字节码:
private void buy() throws java.lang.InterruptedException;
descriptor: ()V
flags: ACC_PRIVATE
Code:
stack=6, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field ticketNum:I
4: ifgt 13
7: aload_0
8: iconst_0
9: putfield #3 // Field flag:Z
12: return
13: ldc2_w #16 // long 10l
16: invokestatic #18 // Method java/lang/Thread.sleep:(J)V
19: getstatic #19 // Field java/lang/System.out:Ljava/io/PrintStream;
22: new #20 // class java/lang/StringBuilder
25: dup
26: invokespecial #21 // Method java/lang/StringBuilder."<init>":()V
29: invokestatic #22 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
32: invokevirtual #23 // Method java/lang/Thread.getName:()Ljava/lang/String;
35: invokevirtual #24 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
38: ldc #25 // String 获得了第
40: invokevirtual #24 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
43: aload_0
44: dup
45: getfield #2 // Field ticketNum:I
48: dup_x1
49: iconst_1
50: isub
51: putfield #2 // Field ticketNum:I
54: invokevirtual #26 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
57: ldc #27 // String 张票
59: invokevirtual #24 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
62: invokevirtual #28 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
65: invokevirtual #29 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
68: return
LineNumberTable:
line 30: 0
line 31: 7
line 32: 12
line 34: 13
line 35: 19
line 36: 68
StackMapTable: number_of_entries = 1
frame_type = 13 /* same */
Exceptions:
throws java.lang.InterruptedException
}
SourceFile: "TestThreadUnsafe1.java"
上边的字节码中:
- 第26行,也就是
45: getfield #2
这一行,这行的意思是获取ticketNum的值(如果现在是100)放入栈顶。 - 忽略27行直接看28行,
iconst_1
的意思是把1压入栈,现在栈顶的前两个值分别是1和100。
-
- 假设:此时线程1执行到28行的时候阻塞了,然后线程2执行了,线程2执行完ticketNum变成99。线程1继续执行,这时线程1虽然知道到了ticketNum变成了99,但是100那个数字已经入栈了,当指令序列将操作数存入栈顶之后就不再会从缓存中取数据了,所以是继续以100往下进行计算的。
- 第29行的
isub
操作的含义是弹出栈中前两个值进行相减再放入到栈中,也就是取出1和100相减后得出99放入到栈中。
所以,volatile关键字能保证可见性没有错,能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。但是上面的程序错在没能保证原子性,自增或自减操作是不具备原子性的,它包括读取变量的原始值、进行减1操作、写入工作内存。也就是说ticketNum--
操作的三个子操作可能会分割开执行,就有可能导致上面这种情况出现。
那么怎么解决上面这个例子中出现的问题呢?
- synchronized:buy()方法上用synchronized。
- Lock:用ReentrantLock进行加锁。
- AtomicInteger:
AtomicInteger ticketNum=new AtomicInteger();
//用ReentrantLock进行加锁:
Lock lock = new ReentrantLock();
private void buy() throws InterruptedException {
lock.lock();
try {
if (ticketNum <= 0){
flag = false;
return;
}
Thread.sleep(10);
System.out.println(Thread.currentThread().getName() + "获得了第"+ ticketNum-- + "张票"); //第33行代码
} finally {
lock.unlock();
}
}
3.volatile能保证有序性吗?
在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
对于volatile前面的语句或者他们后面的语句,他们的顺序是无法保证的。
比如:
int x = 2; //语句1
int y = 0; //语句2
volatile boolean flag = true; //语句3
int x = 4; //语句4
int y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
Volitale的适用场景:
当某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如作为触发器,实现轻量级同步。
我们回到我们前面举的一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。