1. 缓存导致的可见性问题
单核CPU不存在可见性问题
理解可见性问题我们还需要注意一点,那就是在单核CPU上不存在可见性问题。这是为什么呢?
因为在单核CPU上,无论创建了多少个线程,同一时刻只会有一个线程能够获取到CPU的资源来执行任务,即使这个单核的CPU已经添加了缓存。这些线程都是运行在同一个CPU上,操作的是同一个CPU的缓存,只要其中一个线程修改了共享变量的值,那另外的线程就一定能够访问到修改后的变量值。
由上图我们可以看出,由于只有一个CPU内核,线程A和线程B无论谁修改了CPU中的变量V,另一个线程读到的变量V一定是最新的值。
也就是说,在单核CPU中,不存在线程的可见性问题。大家可以记住这个结论。
在多核CPU中上述的结论就不成立了。因为在多核CPU中,每个CPU内核都有自己的缓存。当多个线程在不同的CPU核心上运行时,这些线程操作的是不同的CPU缓存。一个线程对缓存的写,对另外一个线程不一定可见。我们可以将多线程在多核CPU上修改变量的过程总结成如下图所示。
由上图我们可以看出,由于CPU是多核的,线程A操作的是CPU-01上的缓存,线程B操作的是CPU-02上的缓存,此时,线程A对变量V的修改对线程B是不可见的,反之亦然。
这里,我们可以使用一段Java代码来验证多线程的可见性问题。在下面的代码中,定义了一个long类型的成员变量count。有一个名称为addCount的方法,这个方法中对count的值累加1000次。同时execute方法中,启动两个线程,分别调用addCount方法,等待两个线程执行后,返回count的值。代码如下所示。
public class ThreadTest {
private long count = 0;
//对count的值累加1000次
private void addCount(){
for(int i = 0; i < 1000; i++){
count ++;
}
}
public long execute() throws InterruptedException {
//创建两个线程,执行count的累加操作
Thread threadA = new Thread(() ->{
addCount();
});
Thread threadB = new Thread(() ->{
addCount();
});
//启动线程
threadA.start();
threadB.start();
//等待线程执行结束
threadA.join();
threadB.join();
//返回结果
return count;
}
public static void main(String[] args) throws InterruptedException {
ThreadTest testThread = new ThreadTest();
long count = testThread.execute();
System.out.println(count);
}
}
在同一个线程中,连续调用两次addCount方法,count变量的值就是2000。但是实际上在两个线程中,调用addCount方法时,count的值最终是一个介于1000到2000之间的随机数。
我们一起来分析下这种情况:假设线程A和线程B同时执行,第一次都会将count = 0读取到各自的CPU缓存中,执行完count++之后,各自CPU缓存中的count的值为1。线程A和线程B同时将count写入内存后,内存中的count值为1,而不是2。在整个过程中,线程A和线程B都是基于各自CPU中缓存的count的值来进行计算的,最终会导致count的值小于或者等于2000。这就是缓存导致的可
见性问题。
我们也可以使用如图来简单描述下线程A和线程B对于count变量的修改过程。
实际上,如果将上述的代码由循环1000次修改为循环1亿次,你会发现最终count的值会接近于1亿,而不是2亿。如果只是循环1000次,count的值就会接近于2000。不信,你自己可以运行尝试。造成这种结果的原因就是两个线程不是同时启动的,中间存在一个时间差。
解决方法:
- 使用synchronized同步
private synchronized void addCount(){
for(int i = 0; i < 1000; i++){
count ++;
}
}
2.使用ReentrantLock同步
private static ReentrantLock lock = new ReentrantLock();
//对count的值累加1000次
private void addCount(){
try {
lock.lock();
for(int i = 0; i < 1000; i++){
count ++;
}
}finally {
lock.unlock();
}
}
我们一定都知道volatile也可以保证可见性,但是这里可见性为什么不生效,请见下面的解释以及后面问题的分析。
Java锁保证可见性的具体实现
1.Happens-before规则
从JDK 5开始,JSR-133定义了新的内存模型,内存模型描述了多线程代码中的哪些行为是合法的,以及线程间如何通过内存进行交互。
新的内存模型语义在内存操作(读取字段,写入字段,加锁,解锁)和其他线程操作上创建了一些偏序规则,这些规则又叫作Happens-before规则。它的含义是当一个动作happens before另一个动作,这意味着第一个动作被保证在第二个动作之前被执行并且结果对其可见。我们利用Happens-before规则来解释Java锁到底如何保证了可见性。
Java内存模型一共定义了八条Happens-before规则,和Java锁相关的有以下两条:
内置锁的释放锁操作发生在该锁随后的加锁操作之前
一个volatile变量的写操作发生在这个volatile变量随后的读操作之前
2.synchronized提供的可见性
synchronized有两种用法,一种可以用来修饰方法,另外一种可以用来修饰代码块。我们以synchronized代码块为例:
synchronized(Object) {
// code
}
因为synchronized代码块是互斥访问的,只有一个线程释放了锁,另一个线程才能进入代码块中执行。
由上述Happens-before规则第一条:
内置锁的释放锁操作发生在该锁随后的加锁操作之前
假设当线程a释放锁后,线程b拿到了锁并且开始执行代码块中的代码时,线程b必然能够看到线程a看到的所有结果,所以synchronized能够保证线程间数据的可见性。
3.Lock提供的可见性
3.1.volatile关键字的可见性
/**
* 根据上述Happens-before规则第二条:
* 一个volatile变量的写操作发生在这个volatile变量随后的读操作之前
* 假设线程a将ok的值设置为true,那么如果线程b看到ok的值为true,
* 一定可以保证输出的a的值是1。为什么说一定,也就是volatile的另一特点,防止指令重排
**/
volatile boolean ok = false
int a = 0
// Thread a
a = 1
ok = true
// Thread b
if (ok) {
// 确保a的值为1
System.out.println(a)
}
3.2ReentrantLock可见性保证的具体实现
// Lock接口定义了六个方法
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
在juc包中实现Lock接口的类主要有ReentrantLock和ReentrantReadWriteLock,下面以ReentrantLock为例来说明(ReentrantReadWriteLock原理相同)。
先来看ReentrantLock类的lock方法和unlock方法的实现:
public void lock() {
sync.lock();
}
// sync.lock()实现
final void lock() {
acquire(1);
}
public void unlock() {
sync.release(1);
}
lock方法和unlock方法的具体实现都代理给了sync对象,来看一下sync对象的定义:
abstract static class Sync extends AbstractQueuedSynchronizer
static final class FairSync extends Sync
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
根据ReentrantLock的构造参数,sync对象可以是FairSync(公平锁)或者是NonfairSync(非公平锁),我们以FairSync为例(NonfairSync原理类似)来说明。
从上面代码中可以看出,lock方法和unlock方法的具体实现都是由acquire和release方法完成的,而FairSync类中并没有定义acquire方法和release方法,这两个方法都是在Sync的父类AbstractQueuedSynchronizer类中实现的。
public final void acquire(int arg) {
// 只关注tryAcquire即可
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
public final boolean release(int arg) {
// 只关注tryRelease即可
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
acquire方法的大致步骤:tryAcquire会尝试获取锁,如果获取失败会将当前线程加入等待队列,并挂起当前线程。当前线程会等待被唤醒,被唤醒后再次尝试获取锁。
release方法的大致步骤:tryRelease会尝试释放锁,如果释放成功可能会唤醒其它线程,释放失败会抛出异常。
我们可以看出,获取锁和释放锁的具体操作是在tryAcquire和tryRelease中实现的,而tryAcquire和tryRelease在父类AbstractQueuedSynchronizer中没有定义,留给子类FairSync去实现。
我们来看一下FairSync类的tryAcquire和tryRelease的具体实现:
// state变量定义在AbstractQueuedSynchronizer中,表示同步状态。
private volatile int state;
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 读State
int c = getState();
if (c == 0) {
// 获取到锁会写state
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
// 写state
setState(nextc);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
// 读state
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
// 写state
setState(c);
return free;
}
从上面的代码中可以看到有一个volatile state变量,这个变量用来表示同步状态,获取锁时会先读取state的值,获取成功后会把值从0修改为1。当释放锁时,也会先读取state的值然后进行修改。也就是说,无论是成功获取到锁还是成功释放掉锁,都会先读取state变量的值,再进行修改。
将上面的代码做个简化,只留下关键步骤:
private volatile int state;
void lock() {
read state
if (can get lock)
write state
}
void unlock() {
write state
}
假设线程a通过调用lock方法获取到锁,此时线程b也调用了lock方法,因为a尚未释放锁,b只能等待。a在获取锁的过程中会先读state,再写state。当a释放掉锁并唤醒b,b会尝试获取锁,也会先读state,再写state。
我们注意到上述提到的Happens-before规则的第二条:
一个volatile变量的写操作发生在这个volatile变量随后的读操作之前
可以推测出,当线程b执行获取锁操作,读取了state变量的值后,线程a在写入state变量之前的任何操作结果对线程b都是可见的。
由此,我们可以得出结论Lock接口的实现类能实现和synchronized内置锁一样的内存数据可见性。
总结:ReentrantLock及其它Lock接口实现类实现内存数据可见性的方式相对比较隐秘,借助了volatile关键字间接地实现了可见性。其实不光是Lock接口实现类,因为juc包中大部分同步器的实现都是基于AbstractQueuedSynchronizer类来实现的,因此这些同步器也能够提供一定的可见性,有兴趣的可以尝试用类似的思路去分析。
2.线程切换带来的原子性问题
原子性是指一个或者多个操作在CPU中执行的过程不被中断的特性。
在如今的操作系统中,一般都提供了多进程和多线程的功能。操作系统允许进程执行一小段时间,过了这段时间,操作系统就会重新选择一个进程来执行。我们称这种情况叫做任务切换,这一小段时间被称为时间片。大部分的任务切换都是基于线程来执行的,我们也可以将任务切换叫作线程切换。
我们可以简单的使用下图来表示操作系统中的线程切换过程。
图中存在两个线程,分别为线程A和线程B,其中线程A和线程B中的每个小方块代表此时线程占有CPU并执行任务,每个虚线部分代表此时的线程不占用CPU资源。CPU会在线程A和线程B之间频繁切换。
Java并发程序是基于多线程来编写的,这也会涉及到CPU对任务的切换。正是CPU中对任务的切换机制,造成了并发编程中的第二个诡异的问题。
在并发编程中,往往Java中的一条简单的语句,对应着CPU中的多条指令。例如,我们编写的ThreadTest类中的如下代码。
public class ThreadTest {
private Long count;
public Long getCount(){
return count;
}
public void incrementCount(){
count++;
}
}
接下来,我们打开ThreadTest类的class文件所在的目录,在cmd命令行输入如下命令。
javap -c ThreadTest
得出如下的结果信息,如下所示。
d:>javap -c ThreadTest
Compiled from "ThreadTest.java"
public class io.mykit.concurrent.lab01.ThreadTest {
public io.mykit.concurrent.lab01.ThreadTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public java.lang.Long getCount();
Code:
0: aload_0
1: getfield #2 // Field count:Ljava/lang/Long;
4: areturn
public void incrementCount();
Code:
0: aload_0
1: getfield #2 // Field count:Ljava/lang/Long;
4: astore_1
5: aload_0
6: aload_0
7: getfield #2 // Field count:Ljava/lang/Long;
10: invokevirtual #3 // Method java/lang/Long.longValue:()J
13: lconst_1
14: ladd
15: invokestatic #4 // Method java/lang/Long.valueOf:(J)Ljava/lang/Long;
18: dup_x1
19: putfield #2 // Field count:Ljava/lang/Long;
22: astore_2
23: aload_1
24: pop
25: return
}
//主要关注下incrementCount()方法对应的CPU指令
public void incrementCount();
Code:
0: aload_0
1: getfield #2 // Field count:Ljava/lang/Long;
4: astore_1
5: aload_0
6: aload_0
7: getfield #2 // Field count:Ljava/lang/Long;
10: invokevirtual #3 // Method java/lang/Long.longValue:()J
13: lconst_1
14: ladd
15: invokestatic #4 // Method java/lang/Long.valueOf:(J)Ljava/lang/Long;
18: dup_x1
19: putfield #2 // Field count:Ljava/lang/Long;
22: astore_2
23: aload_1
24: pop
25: return
看似简单的一条count自增的代码,实际上对应着CPU中的多条指令。我们将CPU的指令简化成如下三步操作。
- 指令1:把变量count从内存加载的CPU寄存器。
- 指令2:在寄存器中执行count++操作。
- 指令3:将结果写入缓存(可能是CPU缓存,也可能是内存)。
在操作系统执行线程切换时,可能发生在任何一条CPU指令完成后,而不是程序中的某条语句完成后。如果线程A执行完指令1后,操作系统发生了线程切换,当两个线程都执行count++操作后,得到的结果是1而不是2。这里,我们可以使用下图来表示这个过程。
在上图中,线程A将count=0加载到CPU的寄存器后,发生了线程切换。此时内存中的count值仍然为0,线程B将count=0加载到寄存器,执行count++操作,并将count=1写到内存。此时,CPU切换到线程A,执行线程A中的count++操作后,线程A中的count值为1,线程A将count=1写入内存,此时内存中的count值最终为1。
综上:CPU能够保证的原子性是CPU指令级别的,而不是编程语言级别的。我们在编写高并发程序时,需要在编程语言级别保证程序的原子性。
3.编译优化带来的有序性问题
有序性是指程序按照代码的既定顺序执行。
编译器或者解释器为了优化程序的执行性能,有时会改变程序的执行顺序。但是,编译器或者解释器对程序的执行顺序进行修改,可能会导致意想不到的问题!
在Java程序中,一个经典的案例就是使用双重检查机制来创建单例对象。例如,在下面的代码中,在getInstance()方法中获取对象实例时,首先判断instance对象是否为空,如果为空,则锁定当前类的class对象,并再次检查instance是否为空,如果instance对象仍然为空,则为instance对象创建一个实例。
public class SingleInstance {
private static SingleInstance instance;
public static SingleInstance getInstance(){
if(instance == null){
synchronized (SingleInstance.class){
if(instance == null){
instance = new SingleInstance();
}
}
}
return instance;
}
}
如果编译器或者解释器不会对上面的程序进行优化,整个代码的执行过程如下所示。
假设此时有线程A和线程B两个线程同时调用getInstance()方法来获取对象实例,两个线程会同时发现instance对象为空,此时会同时对SingleInstance.class加锁,而JVM会保证只有一个线程获取到锁,这里我们假设是线程A获取到锁。则线程B由于未获取到锁而进行等待。接下来,线程A再次判断instance对象为空,从而创建instance对象的实例,最后释放锁。此时,线程B被唤醒,线程B再次尝试获取锁,获取锁成功后,线程B检查此时的instance对象已经不再为空,线程B不再创建instance对象。
上面的一切看起来很完美,但是这一切的前提是编译器或者解释器没有对程序进行优化,也就是说CPU没有对程序进行重排序。而实际上,这一切都只是我们自己觉得是这样的。
在真正高并发环境下运行上面的代码获取instance对象时,创建对象的new操作会因为编译器或者解释器对程序的优化而出现问题。
也就是说,问题的根源在于如下一行代码。
instance = new SingleInstance();
对于上面的一行代码来说,会有3个CPU指令与其对应。
1.分配内存空间。
2.初始化对象。
3.将instance引用指向内存空间。
正常执行的CPU指令顺序为1—>2—>3,CPU对程序进行重排序后的执行顺序可能为1—>3—>2。此时,就会出现问题。
指令重排:
编译器或者解释器为了优化程序的执行性能,有时会改变程序的执行顺序。但是,编译器或者解释器对程序的执行顺序进行修改,可能会导致意想不到的问题!
在单线程下,指令重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。
如果发生了指令重排序,则程序可能先执行第一行代码,再执行第三行代码,然后执行第二行代码,如下所示。
当CPU对程序进行重排序后的执行顺序为1—>3—>2时,我们将线程A和线程B调用getInstance()方法获取对象实例的两种步骤总结如下所示。
【第一种步骤】
(1)假设线程A和线程B同时进入第一个if条件判断。
(2)假设线程A首先获取到synchronized锁,进入synchronized代码块,此时因为instance对象为null,所以,此时执行instance = new SingleInstance()语句。
(3)在执行instance = new SingleInstance()语句时,线程A会在JVM中开辟一块空白的内存空间。
(4)线程A将instance引用指向空白的内存空间,在没有进行对象初始化的时候,发生了线程切换,线程A释放synchronized锁,CPU切换到线程B上。
(5)线程B进入synchronized代码块,读取到线程A返回的instance对象,此时这个instance不为null,但是并未进行对象的初始化操作,是一个空对象。此时,线程B如果使用instance,就可能出现问题!!!
【第二种步骤】
(1)线程A先进入if条件判断,
(2)线程A获取synchronized锁,并进行第二次if条件判断,此时的instance为null,执行instance = new SingleInstance()语句。
(3)线程A在JVM中开辟一块空白的内存空间。
(4)线程A将instance引用指向空白的内存空间,在没有进行对象初始化的时候,发生了线程切换,CPU切换到线程B上。
(5)线程B进行第一次if判断,发现instance对象不为null,但是此时的instance对象并未进行初始化操作,是一个空对象。如果线程B直接使用这个instance对象,就可能出现问题!!!
在第二种步骤中,即使发生线程切换时,线程A没有释放锁,则线程B进行第一次if判断时,发现instance已经不为null,直接返回instance,而无需尝试获取synchronized锁。
我们可以将上述过程简化成下图所示。
总结:
1、可见性是一个线程对共享变量的修改,另一个线程能够立刻看到,如果不能立刻看到,就可能会产生可见性问题。在单核CPU上是不存在可见性问题的,可见性问题主要存在于运行在多核CPU上的并发程序。归根结底,可见性问题还是由CPU的缓存导致的,所有第一点不能使用volatile是因为它不能保证原子性。
2、并发编程的三大特性:可见性、原子性和有序性。在并发编程领域中,对于各种问题的追本溯源我们可以总结出如下问题的根源。
- 缓存带来了可见性问题。
- 线程切换带来了原子性问题。
- 编译优化带来了有序性问题。