主存、工作内存
在了解什么是线程可见性前,我们先来简单了解下 Java内存模型 的主存、工作内存抽象概念
主存: 存储的是一些共享资源的存储位置(例如静态变量等)
工作内存: 每个线程对应的栈内存对应的私有局部资源的存储位置
我们来分析一个小案例:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// 。。。
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
为什么会有这种情况呢?让我们结合 Java内存模型 来分析下底层的原理:
什么是线程可见性?
内存可见性(memory visibility)——是指当某个线程正在使用对象状态而另一个线程在同时修改该状态,需要确保当一个线程修改了状态后,其他线程能够立即看到发生的状态变化。
由于线程之间的交互都发生在主存中,但对于变量的修改又发生在自己的工作内存中,经常会造成读写共享变量的错误,我们也叫可见性错误。
可见性错误
指当读操作与写操作在不同的线程中执行时,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。
解决方案:
针对前面的代码例子讲解,我们发现,之所以会导致可见性问题是因为一个线程在自己的工作内存中更新了该共享变量的副本,但是没有同步到主存中,而在另一个线程中也没有在主存中拉去最新的状态来进行刷新自己工作内存中的共享变量的副本,因此我们的解决方案就是围绕着 “修改了共享资源的线程要将自己的更改刷新到主存中,并且让该共享资源在其他线程的工作内存中失效,强制要求其拉去主存中的最新状态来实现同步”。对此,有以下三种主要的解决方式:volatile关键字、ReentrantLock、Synchronized
volatile关键字
因此,正对共享的资源,我们通过添加 volatile关键字 就可以确保线程间的一致性
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// 。。。
}
});
t.start();
sleep(1);
run = false; // 线程t会如预想的停下来
}
volatile保证可见性的原理
当JVM将.class
文件编译为具体的CPU执行指令(也就是机器码)后,观察这些指令,我们会发现只要是加了 volatile
修饰的共享变量 ,都会在指令前面加上一个以lock
为前缀的指令,lock
前缀的指令会引发两件事情:
- 将当前处理器缓存行(工作内存)的数据写回到系统内存(主存)
- 一个处理器的缓存回写到内存会导致其他处理器的缓存(工作内存)失效,要拉取最新
第一点的实现:lock
信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。如果访问的内存区域没有缓存在处理器内部,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
第二点的实现:IA-32 CPU
和 Intel 64 CPU
使用 MESI
(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性,避免在总线加lock
锁。CPU
使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。具体解决思路为:
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会重新从内存中读取这个变量
。
Synchronized
针对可见性问题,我们也可以通过Synchronized来进行解决,代码如下:
// 易变
static boolean run = true;
// 锁对象
final static Object Lock = new Object();
public static void main(Stringl] args) throws InterruptedException {
Thread t = new Thread(()->{
while(true){
// 。。。。
synchronized (Lock) {
if(!run) {break;}
}
}
});
t.start();
sleep(1);
log.debug("停止 t");
synchronized (lock){
run = false;
}
}
Synchronized保证可见性的原理
如果线程A要和线程B通讯(发送数据),需要经过两个步骤
首先A需要将副本写到主内存中去,B再去主内存中读取数据,就可以读取到A更新过的共享变量了。这个过程是由我们JVM去控制的。主内存是A和B沟通的桥梁
JVM正是通过控制主内存与每个线程的本地内存之间的交互来为我们java程序员提供内存可见性的保证。
在了解了java内存模型之后呢,我们来学习下Synchronized是如何做到可见性的实现的?
在释放锁之前一定会将数据写回主内存:
一旦一个代码块或者方法被Synchronized所修饰,那么它执行完毕之后,被锁住的对象所做的任何修改都要在释放之前,从线程内存写回到主内存。也就是说他不会存在线程内存和主内存内容不一致的情况。
在获取锁之后一定从主内存中读取数据:
同样的,线程在进入代码块得到锁之后,被锁定的对象的数据也是直接从主内存中读取出来的,由于上一个线程在释放的时候会把修改好的内容回写到主内存,所以线程从主内存中读取到数据一定是最新的。
就是通过这样的原理,Synchronized关键字保证了我们每一次的执行都是可靠的,它保证了可见性。
Reentrantlock
synchronized 和 ReentrantLock(包括AQS的其他Lock),都能保证线程间的可见性,但实现方式有区别,以下是Reentrantlock保证线程可见性的示例代码:
public class Main {
private static boolean run = true;
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (true) {
lock.lock();
try {
if (!run) {
break;
}
// 其他操作...
} finally {
lock.unlock();
}
}
});
t.start();
Thread.sleep(1);
lock.lock();
try {
run = false;
} finally {
lock.unlock();
}
}
}
ReentrantLock保证可见性的原理
在 lock.lock() 和 lock.unlock() 时,都会操作 AbstractQueuedSynchronizer类 中的一个变量 state,这个变量是 volatile 修饰的,volatile变量的语句对应的汇编码指令中会多加一行lock addl $0x0, (%esp),这一行的作用是:
(1)将工作内存修改了的缓存(不仅仅是该变量的缓存)都强制刷新回主内存
(2)把其他CPU对应缓存行标记为invalid状态,那么在读取这一部分缓存时,必须回主内存读取。这样也就保证了线程间的可见性
具体来说,当一个线程获取ReentrantLock锁时,它会将自己工作内存中的数据刷新到主内存中,这样其他线程就能够看到最新的值。而当一个线程释放ReentrantLock锁时,它会将主内存中的数据刷新到自己的工作内存中,这样其他线程就能够读取到最新的值。