CPU与缓存一致性问题
我们都应该知道线程是 CPU 调度的最小单位,线程中的字节码指令最终都是在 CPU 中执行的。CPU在执行的时候,免不了要和各种数据打交道,而 Java 中所有数据都是存放在主内存(RAM)当中的,这一过程可以参考下图:
但是随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。
为了提升CPU操作内存的性能,在 CPU 中添加了高速缓存 (cache)来作为缓冲。如下图:
在执行任务时,CPU 会先将运算所需要使用到的数据复制到高速缓存中,让运算能够快速进行,当运算完成之后,再将高速缓存中的结果刷回(flush back)主内存,这样 CPU 就不用等待主内存的读写操作了。
高速缓存提升了CPU 操作缓存的性能,但也带来了缓存一致性问题:每个CPU 都有自己的高速缓存,同时又共同操作同一块主内存,当多个处理器同时操作主内存时,可能导致数据不一致。如下图:
CPU1 和 CPU2 都将需要用到的数据从主内存拷贝到自己的高速缓存中,当 CPU1 更新与 CPU2 的共享数据到主内存时,CPU2 中高速缓存的数据并没有更新,这就会造成 CPU1 和 CPU2 对主内存中同一个数据缓存的内容不一致。
处理器优化和指令重排
上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。
指令重排案例:
代码如下:
a = 1;
b = 2;
a = a + 1;
编译之后的字节码指令如下:
0:iconst_1 // 将常量1压入操作数栈
1:istore_1 // 将栈顶元素保存到局部变量表下标1处
2:iconst_2 // 将常量2压入操作数栈
3:istore_2 // 将栈顶元素保存到局部变量表下标2处
4:iload_1 // 将局部变量表下标1处元素压入操作数栈
5:iconst_1 // 将常量1压入操作数栈
6:iadd // 将栈顶的两个元素相加,并将结果压入操作数栈
7:istore_1 // 将栈顶元素保存到局部变量表下标1处
可以看出在上述指令中,有两处指令表达的是同样的语义:istore_1,并且指令 7 并不依赖指令 2 和指令 3。这种情况下,CPU 会对指令的顺序做优化,如下图:
从 Java 语言的角度看这层优化就是:
也就是说在 CPU 层面,有时候代码并不会严格按照 Java 文件中的顺序去执行。可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。
什么是Java内存模型
Java内存模型(Java Memory Model),是一套共享内存系统中多线程读写操作行为的规范,这套规范屏蔽了底层各种硬件和操作系统的内存访问差异,解决了 CPU 多级缓存、CPU 优化、指令重排等导致的内存访问问题,从而保证 Java 程序(尤其是多线程程序)在各种平台下对内存的访问效果一致。
在 Java 内存模型中,我们统一用工作内存(working memory)来当作 CPU 中寄存器或高速缓存的抽象。线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有工作内存(类比 CPU 中的寄存器或者高速缓存),本地工作内存中存储了该线程读/写共享变量的副本。
在这套规范中,有一个非常重要的规则——happens-before。
happens-before 先行发生原则
happens-before 用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。它的定义如下:
如果一个操作 A happens-before 另一个操作 B,那么操作 A 的执行结果将对操作 B 可见。
上述定义我们也可以反过来理解:
如果操作 A 的结果需要对另外一个操作 B 可见,那么操作 A 必须 happens-before 操作 B。
用以下代码来举例:
private int value = 0;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
假设 setValue 就是操作 A,getValue 就是操作 B。如果我们先后在两个线程中调用 A 和 B,那最后在 B 操作中返回的 value 值是多少呢?有以下两种情况:
1. 如果 A happens-before B 不成立
也就是说当线程调用操作 B(getValue)时,即使操作 A(setValue)已经在其他线程中被调用过,并且 value 也被成功设置为 1,但这个修改对于操作 B(getValue)仍然是不可见的,value 值有可能返回 0,也有可能返回 1。
2. 如果 A happens-before B 成立
根据 happens-before 的定义,先行发生动作的结果,对后续发生动作是可见的。也就是说如果我们先在一个线程中调用了操作 A(setValue)方法,那么这个修改后的结果对后续的操作 B(getValue)始终可见。因此如果先调用 setValue 将 value 赋值为 1 后,后续在其他线程中调用 getValue 的值一定是 1。
那么在Java中如何让两个操作符合 happens-before 原则呢? JMM 中定义了以下几种情况是自动符合 happens-before 规则的:
-
程序次序规则
在单线程内部,如果一段代码的字节码顺序也隐式符合 happens-before 原则,那么逻辑顺序靠前的字节码执行结果一定是对后续逻辑字节码可见,只是后续逻辑中不一定用到而已。 -
锁定规则
无论是在单线程环境还是多线程环境,一个锁如果处于被锁定状态,那么必须先执行 unlock 操作后才能进行 lock 操作。 -
volatile变量规则
volatile 保证了线程可见性。通俗讲就是如果一个线程先写了一个 volatile 变量,然后另外一个线程去读这个变量,那么这个写操作一定是 happens-before 读操作的。 -
线程启动规则
假定线程 A 在执行过程中,通过执行 ThreadB.start() 来启动线程 B,那么线程 A 对共享变量的修改在线程 B 开始执行后确保对线程 B 可见。 -
线程中断规则
对线程interrupt()方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。 -
线程终结规则
线程中所有的操作都 happens-before 线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。 -
对象终结规则
一个对象的初始化完成 happens-before 他的finalize()方法的开始
Java 内存模型应用
上面介绍的 happens-before 原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,根据这个原则,我们能够解决在并发环境下操作之间是否可能存在冲突的所有问题。在此基础上,我们可以通过 Java 提供的一系列关键字,将我们自己实现的多线程操作“happens-before 化”。
比如我还是用上面的 setValue 和 getValue 举例,本来这两个操作是不符合 happens-before 原则的,但是我们可以通过以下两种方式,使它们符合 happens-before 原则。
使用 volatile 修饰 value,代码如下:
private volatile int value = 0;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
使用synchronized关键字修饰方法,代码如下:
private int value = 0;
synchronized public int getValue() {
return value;
}
synchronized public void setValue(int value) {
this.value = value;
}
同步机制
从修饰类型来看,同步机制可以分为3种:
- 修饰字段的同步机制
- 修饰方法的同步机制
- 修饰代码块
关于修饰字段的同步机制:
- volatile
关于修饰方法和代码块的同步机制:
- synchronized
volatile
volatile的中文意思是不稳定的,易变的,用volatile修饰变量是为了保证变量的可见性。
volatile作用
- 保证了不同线程对该volatile修饰的变量操作的内存可见性;
- 禁止进行指令重排序
保证可见性
保证了不同线程对该变量操作的内存可见性。
这里保证可见性是不等同于volatile变量并发操作的安全性,保证可见性具体一点解释:
线程写volatile变量的过程:
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
线程读volatile变量的过程:
- 从主内存中读取volatile变量的最新值到线程的工作内存中
- 从工作内存中读取volatile变量的副本
但是如果多个线程同时把更新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果。也就是说,=volatile的两点内存语义能保证可见性和有序性,但是不能保证原子性。
举个例子:
定义volatile int count = 0,2个线程同时执行count++操作,每个线程都执行500次,最终结果小于1000,原因是每个线程执行count++需要以下3个步骤:
- 步骤1 线程从主内存读取最新的count的值
- 步骤2 执行引擎把count值加1,并赋值给线程工作内存
- 步骤3 线程工作内存把count值保存到主内存
有可能某一时刻2个线程在步骤1读取到的值都是100,执行完步骤2得到的值都是101,最后刷新了2次101保存到主内存。
禁止进行指令重排序
具体一点解释,禁止重排序的规则如下:
- 当程序执行到 volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
普通的变量仅仅会保证该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操作的顺序与程序代码中的执行顺序一致。
volatile型变量实现原理
具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障,内存屏障提供了以下功能:
- 重排序时不能把后面的指令重排序到内存屏障之前的位置
- 使得本CPU的Cache写入内存
- 写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。
下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了volatile写操作之前,任何的读写操作都会先于volatile被提交。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
该屏障除了使volatile写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使volatile变量的写更新对其他线程可见。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
该屏障除了使volatile读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使volatile变量读取的为最新值。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
该屏障除了禁止了volatile读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程volatile变量的写更新对volatile读操作的线程可见。
volatile型变量使用场景
“一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量(不更新变量),并根据变量的新值执行相应逻辑。例如状态标志位更新,观察者模型变量值发布。
synchronized
通过 synchronized关键字包住的代码区域,对数据的读写进行控制:
-
读数据
当线程进入到该区域读取变量信息时,对数据的读取也不能从工作内存读取,只能从内存中读取,保证读到的是最新的值。 -
写数据
在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,保证更新的数据对其他线程的可见性。
synchronized 修饰实例方法
public class TestSynchronized {
private int total = 0;
public synchronized void addTotal(){
total++;
}
}
这种情况下的锁对象是当前实例对象,因此只有同一个实例对象调用此方法才会产生互斥效果,不同实例对象之间不会有互斥效果。比如如下代码:
public class Test {
public static void main(String[] args) {
final TestSynchronized ts1 = new TestSynchronized();
final TestSynchronized ts2 = new TestSynchronized();
Thread t1 = new Thread(){
public void run(){
ts1.print();
}
};
Thread t2 = new Thread(){
public void run(){
ts2.print();
}
};
t1.start();
t2.start();
}
}
public class TestSynchronized {
private int total = 0;
public synchronized void addTotal() {
total++;
}
public synchronized void print() {
try {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " is printing ----- " + i);
Thread.sleep(300);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
上述代码,在不同的线程中调用的是不同对象的 print 方法,因此彼此之间不会有排斥。运行效果如下:
可以看出,两个线程是交互执行的。
如果将代码进行如下修改,两个线程调用同一个对象的 print 方法:
则执行效果如下:
可以看出:只有某一个线程中的代码执行完之后,才会调用另一个线程中的代码。也就是说此时两个线程间是互斥的。
synchronized 修饰静态类方法
如果 synchronized 修饰的是静态方法,则锁对象是当前类的 Class 对象。因此即使在不同线程中调用不同实例对象,也会有互斥效果。
将 print 修改为静态方法,如下:
执行后的打印效果如下:
从执行的结果可以看出,这两个线程也是互斥的。
synchronized 修饰代码块
除了直接修饰方法之外,synchronized 还可以作用于代码块,如下代码所示:
执行后的打印效果如下:
synchronized 作用于代码块时,锁对象就是跟在后面括号中的对象,任何 Object 对象都可以当作锁对象。
synchronized 实现细节
使用 synchronized 作用于代码块:
使用 javap 查看上述 test1 方法的字节码,可以看出,编译而成的字节码中会包含 monitorenter 和 monitorexit 这两个字节码指令。如下所示:
上面字节码中有 1 个 monitorenter 和 2 个 monitorexit。这是因为虚拟机需要保证当异常发生时也能释放锁。因此 2 个 monitorexit 一个是代码正常执行结束后释放锁,一个是在代码执行异常时释放锁。
使用 synchronized 修饰方法:
从图中可以看出,被 synchronized 修饰的方法在被编译为字节码后,在方法的 flags 属性中会被标记为 ACC_SYNCHRONIZED 标志。当虚拟机访问一个被标记为 ACC_SYNCHRONIZED 的方法时,会自动在方法的开始和结束(或异常)位置添加 monitorenter 和 monitorexit 指令。
关于 monitorenter 和 monitorexit,可以理解为一把具体的锁。在这个锁中保存着两个比较重要的属性:计数器和指针。计数器代表当前线程一共访问了几次这把锁;指针指向持有这把锁的线程。
锁计数器默认为0,当执行monitorenter指令时,如锁计数器值为0 说明这把锁并没有被其它线程持有。那么这个线程会将计数器加1,并将锁中的指针指向自己。当执行monitorexit指令时,会将计数器减1。
网友评论