为保证多线程运行缓存一致性,可以使用Java提供的两种同步机制:同步方法/同步块、volatile变量。即synchronized(重量级锁)或Lock以及被volatile修饰的变量。
volatile修饰的变量禁止JVM在执行代码时对它们进行指令重排序,并保证多线程情况下,当某一线程修改了其本地内存中的这些变量并刷新进主存时,其他线程本地内存中存储的变量副本失效而不得不到主存中重新读取。
注意:volatile关键字只能修饰类变量和实例变量,对于方法参数、局部变量以及实例常量,类常量都不能进行修饰。
下面来深入理解volatile关键字,参考书籍《Java高并发编程详解》,图片来源于书籍,包含了我自己的理解。
private final static int MAX = 5;
public static int v = 0;
public static void main(String[] args) {
Thread t = new Thread(()->{
int local_vr = v;
while(local_vr<MAX) {
if(local_vr != v) {
System.out.println("**************重新获取:local_vr="+v);
local_vr = v;
}
}
}, "reader");
Thread t2 = new Thread(()->{
int local_vw = v;
while(local_vw < MAX) {
try {
System.out.println("工作:local_vw="+ ++v);
local_vw = v;
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
}
}, "worker");
t.start();
t2.start();
}
当变量v没有volatile关键字修饰时,我们得到如下结果,reader线程没有获得更新过后的v,进入了死循环。
当给变量v加上volatile关键字之后,两个线程都正常工作并结束。
计算机中所有的操作都是由CPU的寄存器来完成的,CPU指令的执行涉及数据的读取和写入,而CPU只能从计算机主存(通常指RAM,Random Access Memory)访问数据。CPU的处理速度非常快,但由于制造工艺和成本等限制,CPU的处理速度和内存访问速度有着较大的差距。
由于这样的差距,通过传统的FSB直连内存的访问方式在内存访问时耗费了更多的时间导致CPU资源的浪费,降低了CPU整体的吞吐量,为解决这样的问题,就有了在CPU和主存之间增加缓存的设计,引入CPU Cache模型。
(这里插入几个概念,包含计算机组成原理等,不做展开)
外频:指系统总线的工作频率(系统时钟频率)。
超频:指把一个电子配件的时脉速度提升至高于厂方所定的速度运作,从而提升性能的方法。
系统总线(BusSpeed):是一类信号线的集合,是模块间传输信息的公共通道,通过系统总线计算机各部件间可进行各种数据和命令的传送。
系统总线带宽:指单位时间内总线上传送的数据量,即每钞钟传送MB的最大稳态数据传输率。总线的带宽=总线的工作频率*总线的位宽/8。
系统总线位宽:指总线能同时传送的二进制数据的位数,或数据总线的位数,即32位、64位等总线宽度的概念。
系统总线工作频率:工作时钟频率以MHZ为单位,工作频率越高,总线工作速度越快,总线带宽越宽。
前端总线(FSB,Front Side Bus):指CPU与北桥芯片之间的数据传输总线。
北桥芯片:主要负责CPU与内存之间的数据交换,并控制AGP、PCI数据在其内部的传输,并与南桥芯片连接,是主板性能的主要决定因素。
南桥芯片:主要负责I/O接口等一些外设接口的控制、IDE设备的控制及附加功能等。
也就是说,100MHz外频特指数字脉冲信号在每秒钟震荡一亿次;而100MHz前端总线指的是每秒钟CPU可接受的数据传输量是100MHz×64bit/8=800MByte/s(1Byte=8bit)。
前端总线频率越大,代表着CPU与北桥芯片之间的数据传输能力越大,更能充分发挥出CPU的功能。
缓存的数量共有多级,分为L1,L2,L3等。由于程序指令和程序数据的行为和热点分布差异,把L1 Cache分为L1i(i即instruction)和L1d(d即data)。CPU Cache由多个Cache Line构成,Cache Line(n个字节)可以认为是Cache中的最小缓存单位。
程序运行过程中,会将运算所需的数据从主存复制一份到CPU Cache中,这样CPU进行计算时直接对CPU Cache中的数据进行读取和写入,运算结束后,再将CPU Cache中的数据刷新到主存中,极大地提高了CPU的吞吐能力。
Cache的出现极大地提高了CPU地吞吐能力,但是同时也引入了缓存不一致的问题。
比如i++操作,首先需要读取主存中的i到Cache中,然后对i进行+1,再将结果写回Cache,最后将Cache中的数据刷新到主存中。
这个操作在单线程的情况下不会有任何问题,但在多线程时,由于每个线程都有自己的本地工作栈,栈中的数据是不共享的,i会在多个线程的栈中存储一个i的副本。
假设i初始为0,多个线程在同时执行i++操作时,每一个线程都从主存中获取i的值存入Cache中,然后通过以上i++的步骤,到最后结果可能会是1,这就是缓存不一致的问题。
为解决缓存不一致的问题,有两种主流的解决方法:总线加锁,缓存一致性协议。
第一种方式是一种悲观的实现方式,CPU和其他组件的通信都是通过总线(数据总线、控制总线、地址总线)来进行的,如果对总线加锁,则会阻塞其他CPU对其他组件的访问,效率低下。
最有代表的缓存一致性协议是Intel的MESI协议,它保证了每一个缓存中使用的共享变量副本都是一致的。当CPU在操作Cache中的数据时会对共享变量进行如下两个操作:
(1)读取操作;(2)写入操作,发出信号通知其他CPU将该变量的Cache Line置为无效,这样在进行该变量的读取的时候不得不重新到主存中读取。
JMM指定了JVM如何与计算机RAM进行工作。
JMM决定了一个线程对共享变量的写入何时对其他线程可见,并定义了线程与主存之间的抽象关系:
(1)共享变量存储于主存中,每个线程都可以访问;
(2)每个线程都有私有的工作栈内存,且其中只存储该线程的共享变量副本;
(3)线程需要先操作工作内存才能写入主存,不能直接操作主存;
(4)工作内存和JMM只是一个抽象概念,它涵盖了缓存、寄存器、编译器优化以及硬件等。
假设主存共享变量为0,线程1和线程2分别拥有共享变量的x的副本,线程1将工作内存中的x改为1,并刷新到主存中,线程2要去使用副本时,发现改变量已经失效了,只能再次从主存读取到工作内存。这与CPU和CPU Cache之间的关系类似。
**计算机物理内存不存在堆内存和栈内存的划分,堆内存和虚拟机栈内存都会对应到物理的主内存,或者有一部分堆内存的数据可能会存入CPU Cache寄存器中。**当一个数据被分别存储到计算机的各个内存区域时,Java如何保证不同的线程对某个共享变量的可见性?如何解释前面加了volatile修饰后得到的不一样的运行效果?
(1)原子性:指在一次操作或者多次操作中,要么所有的操作都得到了执行并且不会受到任何因素的干扰或中断,要么所有的操作都不执行(All Or Nothing)。但两个原子性操作结合未必还是原子性的,比如i++。volatile关键字不保证数据的原子性,synchronized保证。
(2)可见性:当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。
(3)有序性:指代码在被执行过程中的先后顺序,由于Java编译器以及运行期的性能优化,导致代码的执行顺序未必是开发者编写代码时的顺序,即指令重排序(Instruction Recorder),但是这个执行顺序只会对同种不影响结果的操作进行,它必须保证代码得到预期的结果。
(1)JMM与原子性:
Java对基本数据类型的变量读取赋值操作都是原子性的,对引用类型的变量读取和赋值也是原子性的,但多个原子性的操作结合在一起就不再是原子性的了,JMM只保证基本读取和赋值操作的原子性,其他均不保证。例如,x=10是原子性的,但y=x由于需要读取x再把x赋值给y,如果在此期间有一个线程将其写为11,那么得到的y可能是10,也可能是11。对于++操作同理。
(2)JMM与可见性:
Java提供了三种方式来保证可见性:
1)volatile,当一个变量被volatile关键字修饰时,对于共享资源的读操作在主存进行。当它缓存到线程工作内存中,有线程对其工作内存中的共享资源进行了修改并刷新到主存中时,其他线程的相应共享资源将会失效,需要从主存中重新读取。对于共享资源的写操作,直接修改工作内存,修改结束后会立即刷新到主存中。
2)synchronized关键字,能保证同一时间只有一个线程获得锁,然后执行同步方法,并会确保在锁释放前,将相应的修改刷新到主存中。
3)使用Lock实现与synchronized同样的效果。
(3)JMM与有序性:
JMM具备天生的Happens-before原则:
1)程序次序规则:在一个线程内,由JVM按照代码编写时的次序执行,编写在后面的操作发生于编写在前面的操作之后,即依然会进行Instruction Recorder;
2)锁定规则:无论是单线程还是多线程,一把锁是lock状态必须先unlock才能再次lock;
3)volatile变量规则:一个线程的写操作必先发生于另一个线程对同一个变量的读操作;
4)传递规则:如果A操作先于B,B先于C,那么A必先于C;
5)线程启动规则:线程的启动必须是start( )方法;
6)线程中断规则:对线程执行interrupt( )方法打断一个线程要先于捕获到中断信号;
7)线程终结规则:线程的所有操作都在线程死亡之前;
8)对象终结规则:一个对象的初始化必先发生于finalize( )之前。
1)保证不同线程对共享变量操作时的可见性;
2)禁止对指令进行Instruction Recorder操作。
(1)理解volatile保证可见性:
根据happens-before原则的volatile变量规则,一个线程的写操作要早于另一个线程的读操作。在以上的例子中,两个线程先从主存中读取v=0到工作内存,然后worker线程v=v+1并刷新到主存,reader线程工作内存中的v立即失效(硬件上就是CPU的L1或L2的Cache Line失效),reader不得不重新从主存读取v。
(2)理解volatile保证顺序性:
即禁止JVM对volatile关键字修饰的指令重排序。对于非volatile关键字修饰的部分,并不能保证JVM不进行重排序。被volatile修饰的指令就像一个固定不动的标杆,JVM只需要保证程序运行到volatile关键字修饰的指令时必须是已运行的代码的预期结果即可。
(3)理解volatile不保证原子性:
因为多个原子性的操作结合并不是原子性的,多个线程同时对一个变量操作,比如i++操作,每个线程都要有三个步骤(从主存读取i并缓存至线程工作内存;在工作内存中i=i+1;将最新值i刷新到主存),由于只有在执行最后一步的时候其他线程的Cache Line才会失效,但是这些线程可能同时缓存同一个值到工作内存并且都进入就绪状态,由于CPU的执行速度非常快,前一个被执行的线程很可能还未将最新值刷新至主存,就已经执行完了下一个线程,这样会导致两个线程只对i进行了一次数值的修改变化。
OpenJDK的unsafe.cpp源码不是很看得懂(内存屏障),截图插个眼。
充分利用volatile的可见性及有序性特点。
(1)开关控制利用可见性的特点
private volatile static boolean start = true;
public static void Case2() {
Runnable r = new Runnable(){
@Override
public void run() {
while(start) {
System.out.println("======= working ======");
try {
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
}
}
};
Thread t = new Thread(r);
t.start();
Thread t2 = new Thread(()->{
try {
Thread.sleep(3000);
System.out.println("It is time to off work");
start = false;
} catch (Exception e) {
e.printStackTrace();
}
});
t2.start();
}
(2)状态标记利用顺序性特点
private static int i=0;
private static int j=0;
private volatile static boolean on_off = false;
public static void Case3() {
new Thread(()->{
while(i<10) {
if(!on_off) {
System.out.print("A");
i++;
on_off = true;
}
}
}, "A").start();
new Thread(()->{
while(j<10) {
if(on_off) {
System.out.print("B");
j++;
on_off = false;
}
}
}, "B").start();
}
得到结果:
(3)singleton设计模式的double-ckeck也利用了顺序性特点。
volatile关键字只能用于修饰实例变量或者类变量,变量可以为null,不能用于修饰方法以及方法参数和局部变量、常量等。
synchronized关键字不能修饰变量,只能用于修饰方法或者语句块,同步语句块内的monitor对象不能为null。
另外:
1)volatile不能保证原子性,synchronized修饰的同步代码块无法被打断,因而能够保证原子性。
2)volatile和synchronized都可以保证可见性,但它们的实现机制不同:
synchronized借助JVM指令monitor enter和monitor exit对同步代码串行化,在monitor exit时所有共享资源都会被刷新到主存中。
volatile使用机器指令(偏硬件)“lock”迫使其他线程工作内存中的数据失效,不得不到主存中重新读取。
3)volatile和synchronized都可以保证有序性。volatile关键字进制JVM编译器及处理器对共享资源的代码重排序。synchronized通过对程序串行化执行,虽然不保证代码指令不会发生重排序,但是最终得到的结果依然是预期的结果。
最后:
volatile不会使线程阻塞,而synchronized会。