# 同步机制

读书笔记:黄文海.Java多线程编程实战指南(核心篇)(Java多线程编程实战系列)(Kindle位置1032).电子工业出版社.Kindle版本.

# 锁概述

锁可以理解为对共享数据进行保护的许可证。对于同一个许可证所保护的共享数据而言,任何线程访问这些共享数据前必须先持有该许可证。一个线程只有在持有许可证的情况下才能够对这些共享数据进行访问;并且,一个许可证一次只能够被一个线程持有;许可证的持有线程在其结束对这些共享数据的访问后必须让出(释放)其持有的许可证,以便其他线程能够对这些共享数据进行访问。

一个线程在访问共享数据前必须申请相应的锁(许可证),线程的这个动作被称为锁的获得(Acquire)。一个线程获得某个锁(持有许可证),我们就称该线程为相应锁的持有线程(线程持有许可证),一个锁一次只能被一个线程持有。锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放(Release)相应的锁。锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区(CriticalSection)。因此,共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。

锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有。因此,这种锁被称为排他锁或者互斥锁(Mutex)。这种锁的实现方式代表了锁的基本原理。

image-20210918094500193

按照Java虚拟机对锁的实现方式划分,Java平台中的锁包括内部锁(IntrinsicLock)和显式锁(ExplicitLock)。内部锁是通过synchronized关键字实现的;显式锁是通过java.concurrent.locks.Lock接口的实现类(如java.concurrent.locks.ReentrantLock类)实现的。

# 锁的作用

锁能够保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。锁是通过互斥保障原子性的。所谓互斥(MutualExclusion),就是指一个锁一次只能被一个线程持有。因此一个线程持有一个锁的时候,其他线程无法获得该锁,而只能等待其释放该锁后再申请。这就保证了临界区代码一次只能够被一个线程执行。因此,一个线程执行临界区期间没有其他线程能够访问相应的共享数据,这使得临界区代码所执行的操作自然而然地具有不可分割的特性,即具备了原子性。从互斥的角度来看,锁其实是将多个线程对共享数据的访问由本来的并发(未使用锁的情况下)改为串行(使用锁之后)。因此,虽然实现并发是多线程编程的目标,但是这种并发往往是并发中带有串行的局部并发。

可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。在Java平台中,锁的获得隐含着刷新处理器缓存这个动作,这使得读线程在执行临界区代码前(获得锁之后)可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被“推送”到该线程执行处理器的高速缓存中,从而对读线程可同步。因此,锁能够保障可见性。

锁的互斥性及其对可见性的保障合在一起,可保证临界区内的代码能够读取到共享数据的最新值。由于锁的互斥性,同一个锁所保护的共享数据一次只能够被一个线程访问,因此线程在临界区中所读取到共享数据的相对新值(锁对保障可见性的结果)同时也是最新值。

锁不仅能够保障临界区中的代码能够读取到共享变量的最新值。对引用型共享变量,锁还可保障临界区中的代码能够读取到该变量所引用对象的字段(实例变量和静态变量)的最新值。这点可以推广到数组变量,即如果共享变量是个数组,那么锁能够保障临界区中的代码可以读取到该数组中各个元素的最新值。

锁能够保障有序性。写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即读线程对这些操作的感知顺序与源代码顺序一致。这是锁对原子性和可见性的保障的结果。设写线程在临界区中更新了b、c和flag这3个共享变量,如下代码片段所示:

b=a+1;

c=2;

flag=true;

由于锁对可见性的保障,写线程在临界区中对上述任何一个共享变量所做的更新都对读线程可见。并且,由于临界区内的操作具有原子性,因此写线程对上述共享变量的更新会同时对读线程可见,即在读线程看来这些变量就像是在同一刻被更新的。因此读线程并无法(也没有必要)区分写线程实际上是以什么顺序更新上述变量的,这意味着读线程可以认为写线程是依照源代码顺序更新上述共享变量的,即有序性得以保障。

这是锁对原子性和可见性的保障的结果。设写线程在临界区中更新了b、c和flag这3个共享变量,如下代码片段所示:b=a+1;c=2;flag=true;由于锁对可见性的保障,写线程在临界区中对上述任何一个共享变量所做的更新都对读线程可见。并且,由于临界区内的操作具有原子性,因此写线程对上述共享变量的更新会同时对读线程可见,即在读线程看来这些变量就像是在同一刻被更新的。因此读线程并无法(也没有必要)区分写线程实际上是以什么顺序更新上述变量的,这意味着读线程可以认为写线程是依照源代码顺序更新上述共享变量的,即有序性得以保障。

# 与锁相关的几个概念

# 可重入性

一个线程在其持有一个锁的时候能否再次(或者多次)申请该锁。如果一个线程持有一个锁的时候还能够继续成功申请该锁,那么我们就称该锁是可重入的(Reentrant),否则我们就称该锁为非可重入的(Non-reentrant)。伪代码如下:

void methodA(){
acquireLock(lock);

methodB();

releaseLock(lock);
}

void methodB(){
acquireLock(lock);
//其他逻辑
releaseLock(lock);
}

方法methodA使用了锁lock,该锁引导的临界区代码又调用了另外一个方法methodB,而方法methodB也使用了lock。那么,这就产生了一个问题:methodA的执行线程持有锁lock的时候调用了methodB,而methodB执行的时候又去申请锁lock,而lock此时正被当前线程持有(未被释放)。那么,此时methodB究竟能否获得(申请成功)lock呢?可重入性就描述了这样一个问题。

可重入锁是如何实现的?

可重入锁可以被理解为一个对象,该对象包含一个计数器属性。计数器属性的初始值为0,表示相应的锁还没有被任何线程持有。每次线程获得一个可重入锁的时候,该锁的计数器值会被增加1。每次一个线程释放锁的时候,该锁的计数器属性值就会被减1。一个可重入锁的持有线程初次获得该锁时相应的开销相对大,这是因为该锁的持有线程必须与其他线程“竞争”以获得锁。可重入锁的持有线程继续获得相应锁所产生的开销要小得多,这是因为此时Java虚拟机只需要将相应锁的计数器属性值增加1即可以实现锁的获得。

# 锁的争用与调度

锁可以被看作多线程程序访问共享数据时所需持有的一种排他性资源。因此,资源的争用、调度的概念对锁也是适用的。Java平台中锁的调度策略也包括公平策略和非公平策略,相应的锁就被称为公平锁和非公平锁。内部锁属于非公平锁,而显式锁则既支持公平锁又支持非公平锁。

# 锁的粒度

一个锁实例可以保护一个或者多个共享数据。一个锁实例所保护的共享数据的数量大小就被称为该锁的粒度(Granularity)。一个锁实例保护的共享数据的数量大,我们就称该锁的粒度粗,否则就称该锁的粒度细。锁粒度的粗细是相对的,锁的粒度过粗会导致线程在申请锁的时候需要进行不必要的等待。锁的粒度过细会增加锁调度的开销。

# 锁的开销及其可能导致的问题

锁的开销包括锁的申请和释放所产生的开销,以及锁可能导致的上下文切换的开销。这些开销主要是处理器时间。

锁可能导致上下文切换。我们知道,多个线程争用排他性资源可能导致上下文切换,因此,锁作为一种排他性资源,一旦被争用就可能导致上下文切换,而没有被争用的锁则可能不会导致上下文切换。

此外,锁的不正确使用也会导致如下一些线程活性故障。

  • 锁泄漏(LockLeak)。锁泄漏是指一个线程获得某个锁之后,由于程序的错误、缺陷致使该锁一直无法被释放而导致其他线程一直无法获得该锁的现象。因此,锁泄漏会导致同步在该锁上的所有线程都无法进展。锁泄漏的危害性体现在其不易被发现:可重入锁在争用程度比较低的情况下极有可能只有一个线程反复申请该锁,此时即使这个线程持有该锁之后就一直不释放也不妨碍其后续再次获得该锁(这是由可重入锁本身来保证的);然而,随着争用程度的提高,其他线程也加入申请该锁的行列,这时先前的线程一直未释放锁,这会导致这些线程永远无法获得锁。不幸的是,此时发现问题可能为时已晚——系统可能已经上线运行了!因此,锁泄漏更像是“地雷”,一旦埋下则随时可能会被人踩中而爆炸!
  • 锁的不正确使用还可能导致死锁、锁死等线程活性故障。

# 内部锁——synchronized

Java平台中的任何一个对象都有唯一一个与之关联的锁。这种锁被称为监视器(Monitor)或者内部锁(IntrinsicLock)。内部锁是一种排他锁,它能够保障原子性、可见性和有序性。

内部锁是通过synchronized关键字实现的。synchronized关键字可以用来修饰方法以及代码块(花括号“{}”包裹的代码)。

synchronized关键字修饰的方法就被称为同步方法(SynchronizedMethod)。synchronized修饰的静态方法就被称为同步静态方法,synchronized修饰的实例方法就被称为同步实例方法。同步方法的整个方法体就是一个临界区。

synchronized关键字修饰的代码块被称为同步块(SynchronizedBlock),其语法如下所示:

synchronized(锁句柄){
//在此代码块中访问共享数据
}

synchronized关键字所引导的代码块就是临界区。锁句柄是一个对象的引用(或者能够返回对象的表达式)。例如,锁句柄可以填写为this关键字(表示当前对象)。习惯上我们也直接称锁句柄为锁。锁句柄对应的监视器就被称为相应同步块的引导锁。相应地,我们称呼相应的同步块为该锁引导的同步块。

作为锁句柄的变量通常采用final修饰。这是因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁,从而导致竞态。有鉴于此,通常我们会使用private修饰作为锁句柄的变量。

作为锁句柄的变量通常采用private final修饰,如:

private final Object lock = new Object();

# 显示锁——Lock接口

显式锁是自JDK1.5开始引入的排他锁。作为一种线程同步机制,其作用与内部锁相同。它提供了一些内部锁所不具备的特性,但并不是内部锁的替代品。

显式锁(ExplicitLock)是java.util.concurrent.lcoks.Lock接口的实例。该接口对显式锁进行了抽象,如图所示,类java.util.concurrent.lcoks.ReentrantLock是Lock接口的默认实现类。

image-20210918105751625

一个Lock接口实例就是一个显式锁对象,Lock接口定义的lock方法和unlock方法分别用于申请和释放相应Lock实例表示的锁。显式锁的使用方法如下所示:

private final Lock lock = ...;// 创建一个Lock接口实例
...

//申请锁lock
lock.lock();

try {
//临界区
} finally {
// 保证释放锁,避免锁泄露
lock.unlock();// 释放锁lock
}

显式锁的使用包括以下几个方面。

  • 创建Lock接口的实例。如果没有特别的要求,我们就可以创建Lock接口的默认实现类ReentrantLock的实例作为显式锁使用。从字面上可以看出ReentrantLock是一个可重入锁。
  • 在访问共享数据前申请相应的显式锁。这一步,我们直接调用相应Lock.lock()即可。
  • 在临界区中访问共享数据。Lock.lock()调用与Lock.unlock()调用之间的代码区域为临界区。不过,一般我们视上述的try代码块为临界区。因此,对共享数据的访问都仅放在该代码块中。
  • 共享数据访问结束后释放锁。虽然释放锁的操作通过调用Lock.unlock()即可实现,但是为了避免锁泄漏,我们必须将这个调用放在finally块中执行。这样,无论是临界区代码执行正常结束还是由于其抛出异常而提前退出,相应锁的unlock方法总是可以被执行,从而避免了锁泄漏。可见,显式锁不像内部锁那样可以由编译器代为规避锁泄漏问题。

# 显示锁的调度

ReentrantLock既支持非公平锁也支持公平锁。ReentrantLock的一个构造器的签名如下:ReentrantLock(booleanfair)该构造器使得我们在创建显式锁实例的时候可以指定相应的锁是否是公平锁(fair参数值true表示是公平锁)。公平锁保障锁调度的公平性往往是以增加了线程的暂停和唤醒的可能性,即增加了上下文切换为代价的。因此,公平锁适合于锁被持有的时间相对长或者线程申请锁的平均间隔时间相对长的情形。总的来说使用公平锁的开销比使用非公平锁的开销要大,因此显式锁默认使用的是非公平调度策略。

# 显式锁与内部锁的比较

两者只是适用场景不同,而不是相互替代,孰优孰劣的存在。

内部锁是基于代码块的锁,因此其使用基本无灵活性可言,但其基于代码块的这个特征也使其具有一个优势:简单易用,不会导致锁泄露;

显式锁是基于对象的锁,其使用可以充分发挥面向对象编程的灵活性,劣势也很明显,如果使用不当,很容易出现锁泄露的问题,因此使用显示锁必须注意将锁的释放操作放在finally块中

内部锁仅支持非公平锁,而显式锁既支持非公平锁,又支持公平锁。

显示锁提供了方法可以用来对锁的相关信息进行监控,而内部锁不支持。RentrantLock中定义的方法isLocked()可用于检测相应锁是否被某个线程持有,getQueueLength()方法可用于检查相应锁的等待线程的数量

显示锁和内部锁在性能方面的差异主要包括:

  • Java6/7对内部锁做了一些优化,包括锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、偏向锁(Biased Lock)和适配性锁(Adaptive Lock)

  • Java5中高争用的情况下,内部锁的性能急剧下降,而显示锁的性能下降少很多。但Java6之后随着对内部锁的优化,显示锁与内部锁之间的可伸缩性差异已经变得非常小了

# 读写锁

对于同步在同一锁之上的线程而言,对共享变量仅进行读取而没有进行更新的线程被称为只读线程,简称读线程。对共享变量进行更新(包括先读取后更新)的线程就被称为写线程。

读写锁(Read/WriteLock)是一种改进型的排他锁,也被称为共享/排他(Shared/Exclusive)锁。读写锁允许多个线程可以同时读取(只读)共享变量,但是一次只允许一个线程对共享变量进行更新(包括读取后再更新)。任何线程读取共享变量的时候,其他线程无法更新这些变量;一个线程更新共享变量的时候,其他任何线程都无法访问该变量。

获得条件 排他性 作用
读锁 相应的写锁未被任何线程持有 对读线程是共享的,对写线程是排他的 允许多个读线程可以同时读取共享变量,并保障读线程读取共享变量期间没有其他任何线程能够更新这些共享变量
写锁 该写锁未被其他任何线程持有并且相应的读锁未被其他任何线程持有 对写线程和读线程都是排他的 使得写线程能够以独占的方式访问共享变量

java.util.concurrent.locks.ReadWriteLock接口是对读写锁的抽象,其默认实现类是java.util.concurrent.locks.ReentrantReadWriteLockReadWriteLock接口定义了两个方法:readLock()writeLock()

image-20210918115103600

public class ReadWriteLockUsage {
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    
    // 读线程执行该方法
    public void reader() {
        // 申请读锁
        readLock.lock();
        
        try {
            // 读取共享变量
        } finally {
            // 总是在finally块中释放锁,以避免泄露
            readLock.unLock();
        }
    }
    
    // 写线程执行该方法
    public void writer() {
        // 申请写锁
        writeLock.lock(); 
        try {
            // 读写共享变量
        } finally {
            // 此处释放锁,避免锁泄露
            writeLock.unLock();
        }
    }
}

与普通的排他锁(如内部锁和ReentrantLock)相比,读写锁在排他性方面比较弱(这是我们所期望的)。在原子性、可见性和有序性保障方面,它所起到的作用与普通的排他锁是一致的。写线程释放写锁所起到的作用相当于一个线程释放一个普通排他锁;读线程获得读锁所起到的作用相当于一个线程获得一个普通排他锁。由于读写锁内部实现比内部锁和其他显式锁要复杂得多,因此读写锁适合于在以下条件同时得以满足的场景中使用:

  • 只读操作比写(更新)操作要频繁得多;
  • 读线程持有锁的时间比较长。

只有同时满足上面两个条件的时候,读写锁才是适宜的选择。否则,使用读写锁会得不偿失(开销)。ReentrantReadWriteLock所实现的读写锁是个可重入锁。ReentrantReadWriteLock支持锁的降级(Downgrade),即一个线程持有读写锁的写锁的情况下可以继续获得相应的读锁,锁降级示例如下:

public class ReadWriteLockDowngrade { 
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); 
    private final Lock readLock = rwLock.readLock(); 
    private final Lock writeLock = rwLock.writeLock();
    public void operationWithLockDowngrade() { 
        boolean readLockAcquired = false; 
        writeLock.lock(); // 申请 写 锁 
        try { 
            // 对 共享 数据 进行 更新 
            // ... 
            // 当前 线程 在 持 有写 锁 的 情况下 申请 读 锁 readLock 
            readLock.lock(); 
            readLockAcquired = true; 
        } finally { 
            // 释放 写 锁 
            writeLock.unlock(); 
           
        } 
        if (readLockAcquired) { 
            try { 
                // 读取 共享 数据 并 据此 执行 其他 操作
                // ...
            } finally { 
                readLock.unlock(); // 释放 读 锁 
            } 
        } else {
            // ...
        }
    }
}

锁的降级的反面是锁的升级(Upgrade),即一个线程在持有读写锁的读锁的情况下,申请相应的写锁。ReentrantReadWriteLock并不支持锁的升级。读线程如果要转而申请写锁,需要先释放读锁,然后申请相应的写锁。

# 锁的适用场景

锁是Java线程同步机制中功能最强大、适用范围最广泛,同时也是开销最大、可能导致的问题最多的同步机制。多个线程共享同一组数据的时候,如果其中有线程涉及如下操作,那么我们就可以考虑使用锁:

  • check-then-act操作:一个线程读取共享数据并在此基础上决定其下一个操作是什么。
  • read-modify-write操作:一个线程读取共享数据并在此基础上更新该数据。如某些像自增操作(“count++”)这种简单的read-modify-write操作。
  • 多个线程对多个共享数据进行更新:如果这些共享数据之间存在关联关系,那么为了保障操作的原子性我们可以考虑使用锁。例如,关于服务器的配置信息可能包括主机IP地址、端口号等。一个线程如果要对这些数据进行更新,则必须要保障更新操作的原子性,即主机IP地址和端口号总是一起被更新的,否则其他线程可能看到一个并真实存在的主机IP地址和端口号组合所代表的服务器。

# 内存屏障

线程获得和释放锁时所分别执行的两个动作:刷新处理器缓存冲刷处理器缓存

对于同一个锁所保护的共享数据而言,前一个动作保证了该锁的当前持有线程能够读取到前一个持有线程对这些数据所做的更新,后一个动作保证了该锁的持有线程对这些数据所做的更新对该锁的后续持有线程可见。

Java虚拟机底层实际上是借助内存屏障(MemoryBarrier,也称Fence)来实现上述两个动作的。内存屏障是对一类仅针对内存读、写操作指令(Instruction)的跨处理器架构(比如x86、ARM)的比较底层的抽象(或者称呼)。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性。它在指令序列(如指令1;指令2;指令3)中就像是一堵墙(因此被称为屏障)一样使其两侧(之前和之后)的指令无法“穿越”它(一旦穿越了就是重排序了)。但是,为了实现禁止重排序的功能,这些指令也往往具有一个副作用——刷新处理器缓存、冲刷处理器缓存,从而保证可见性。不同微架构的处理器所提供的这样的指令是不同的,并且出于不同的目的使用的相应指令也是不同的。例如对于“写—写”(写后写)操作,如果仅仅是为了防止(禁止)重排序而对可见性保障没有要求,那么在x86架构的处理器下使用空操作就可以了(因为x86处理器不会对“写—写”操作进行重排序)。而如果对可见性有要求(比如前一个写操作的结果要在后一个写操作执行前对其他处理器可见),那么在x86处理器下需要使用LOCK前缀指令或者sfence指令、mfence指令;在ARM处理器下则需要使用DMB指令。

由于内部锁的申请与释放对应的Java虚拟机字节码指令分别是monitorenter和monitorexit,因此习惯上我们用MonitorEnter表示锁的申请,用MonitorExit表示锁的释放。

按照内存屏障所起的作用来划分将内存屏障划分为以下几种:

  • 按照可见性保障来划分,内存屏障可分为:

    • 加载屏障(LoadBarrier),加载屏障的作用是刷新处理器缓存。Java虚拟机会在MonitorEnter(申请锁)对应的机器码指令之后临界区开始之前的地方插入一个加载屏障,这使得读线程的执行处理器能够将写线程对相应共享变量所做的更新从其他处理器同步到该处理器的高速缓存中

    • 存储屏障(StoreBarrier),存储屏障的作用冲刷处理器缓存。Java虚拟机会在MonitorExit(释放锁)对应的机器码指令之后插入一个存储屏障,这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的;

      因此,可见性的保障是通过写线程和读线程成对地使用存储屏障和加载屏障实现的。

  • 按照有序性保障来划分,内存屏障可以分为:

    • 获取屏障(AcquireBarrier),获取屏障的使用方式是在一个读操作(包括Read-Modify-Write以及普通的读操作)之后插入该内存屏障,其作用是禁止该读操作与其后的任何读写操作之间进行重排序,这相当于在进行后续操作之前先要获得相应共享数据的所有权(这也是该屏障的名称来源)。

    • 释放屏障(ReleaseBarrier),释放屏障的使用方式是在一个写操作之前插入该内存屏障,其作用是禁止该写操作与其前面的任何读写操作之间进行重排序。这相当于在对相应共享数据操作结束后释放所有权(这也是该屏障的名称来源)。

      Java虚拟机会在MonitorEnter(它包含了读操作)对应的机器码指令之后临界区开始之前的地方插入一个获取屏障,并在临界区结束之后MonitorExit(它包含了写操作)对应的机器码指令之前的地方插入一个释放屏障。因此,这两种屏障就像是三明治的两层面包片把火腿夹住一样把临界区中的代码(指令序列)包起来

image-20210918135504425

由于获取屏障禁止了临界区中的任何读、写操作被重排序到临界区之前的可能性,而释放屏障又禁止了临界区中的任何读、写操作被重排序到临界区之后的可能性,因此临界区内的任何读、写操作都无法被重排序到临界区之外。在锁的排他性的作用下,这使得临界区中执行的操作序列具有原子性。因此,写线程在临界区中对各个共享变量所做的更新会同时对读线程可见,即在读线程看来各个共享变量就像是“一下子”被更新的,于是这些线程无从(也无必要)区分这些共享变量是以何种顺序被更新的。这使得写线程在临界区中执行的操作自然而然地具有有序性——读线程对这些操作的感知顺序与源代码顺序一致。

可见,锁对有序性的保障是通过写线程和读线程配对使用释放屏障与加载屏障实现的。为了保障线程安全,我们需要使用Java线程同步机制,而内存屏障则是Java虚拟机在实现Java线程同步机制时所使用的具体“工具”。

# 锁与重排序

为了使锁能够起到其预定的作用并且尽量避免对性能造成“伤害”,编译器(基本上指JIT编译器)和处理器必须遵守一些重排序规则,这些重排序规则禁止一部分的重排并且允许另外一部分的重排序(以便不“伤害”性能)。总的来说,与锁有关的重排序规则可以理解为语句(指令)相对于临界区的“许进不许出”,临界区外的语句可以被(编译器)重排序到临界区之内(“许进”),而临界区内的操作无法被(编译器或者处理器)重排到临界区之外(“不许出”);临界区内、临界区前和临界区后这3个区域内的任意两个操作都可以在各自的区域范围内进行重排序(只要相应的重排序能够满足貌似串行语义)。

image-20210918140832994

无论是编译器还是处理器,均还需要遵守以下重排序规则。

  • 规则1——临界区内的操作不允许被重排序到临界区之外(即临界区前或者临界区后)。
  • 规则2——临界区内的操作之间允许被重排序。
  • 规则3——临界区外(临界区前或者临界区后)的操作之间可以被重排序。
  • 规则4——锁申请(MonitorEnter)与锁释放(MonitorExit)操作不能被重排序。
  • 规则5——两个锁申请操作不能被重排序。
  • 规则6——两个锁释放操作不能被重排序。
  • 规则7——临界区外(临界区前、临界区后)的操作可以被重排到临界区之内。

# 轻量级同步机制——volatile

volatile关键字表示被修饰的变量的值容易变化(即被其他线程更改),因而不稳定。volatile变量的不稳定性意味着对这种变量的读和写操作都必须从高速缓存或者主内存(也是通过高速缓存读取)中读取,以读取变量的相对新值。因此,volatile变量不会被编译器分配到寄存器进行存储,对volatile变量的读写操作都是内存访问(访问高速缓存相当于主内存)操作。

volatile关键字常被称为轻量级锁,其作用与锁的作用有相同的地方:保证可见性和有序性。不同的是,在原子性方面它仅能保障写volatile变量操作的原子性,但没有锁的排他性;其次,volatile关键字的使用不会引起上下文切换(这是volatile被冠以“轻量级”的原因)。因此,volatile更像是一个轻量级简易(功能比锁有限)锁。

# 作用

  • 保障可见性
  • 保障有序性
  • 保障long/double型变量读写操作的原子性

访问同一个volatile变量的线程被称为同步在这个变量之上的线程,其中读取这个变量的线程被称为读线程,更新这个变量的线程被称为写线程。一个线程可以既是读线程又是写线程。

volatile关键字能够保障对long/double型变量的写操作具有原子性。

在Java语言中,对long型和double型以外的任何类型的变量的写操作都是原子操作。

volatile仅仅保障对其修饰的变量的写操作(以及读操作)本身的原子性,而这并不表示对volatile变量的赋值操作一定具有原子性。例如,如下对volatile变量count1的赋值操作并不是原子操作:

count1 = count2 + 1;

如果变量count2也是一个共享变量,那么该赋值操作实际上是一个read-modify-write操作。其执行过程中其他线程可能已经更新了count2的值,因此该操作不具备不可分割性,也就不是原子操作。如果变量count2是一个局部变量,那么该赋值操作就是一个原子操作。

一般而言,对volatile变量的赋值操作,其右边表达式中只要涉及共享变量(包括被赋值的volatile变量本身),那么这个赋值操作就不是原子操作。要保障这样操作的原子性,我们仍然需要借助锁。

又如这样一个赋值操作:

volatile Map aMap = new HashMap();

可以分解为如下伪代码所示的几个子操作:

objRef = allocate(HashMap.class); //子操作①:分配对象所需的存储空间

invokeConstructor(objRef);//子操作②:初始化objRef引用的对象

aMap = objRef;//子操作③:将对象引用写入变量aMap

虽然volatile关键字仅保障其中的子操作③是一个原子操作,但是由于子操作①和子操作②仅涉及局部变量而未涉及共享变量,因此对变量aMap的赋值操作仍然是一个原子操作。

volatile关键字在原子性方面仅保障对被修饰的变量的读操作、写操作本身的原子性。如果要保障对volatile变量的赋值操作的原子性,那么这个赋值操作不能涉及任何共享变量(包括被赋值的volatile变量本身)的访问。

写线程对volatile变量的写操作会产生类似于释放锁的效果。读线程对volatile变量的读操作会产生类似于获得锁的效果。因此,volatile具有保障有序性和可见性的作用。 对于volatile变量的写操作,Java虚拟机会在该操作之前插入一个释放屏障,并在该操作之后插入一个存储屏障

image-20210918143200996

其中,释放屏障禁止了volatile写操作与该操作之前的任何读、写操作进行重排序,从而保证了volatile写操作之前的任何读、写操作会先于volatile写操作被提交,即其他线程看到写线程对volatile变量的更新时,写线程在更新volatile变量之前所执行的内存操作的结果对于读线程必然也是可见的。这就保障了读线程对写线程在更新volatile变量前对共享变量所执行的更新操作的感知顺序与相应的源代码顺序一致,即保障了有序性。

volatile虽然能够保障有序性,但是它不像锁那样具备排他性,所以并不能保障其他操作的原子性,而只能够保障对被修饰变量的写操作的原子性。因此,volatile变量写操作之前的操作如果涉及共享可变变量,那么竞态仍可能产生。这是因为共享变量被赋值给volatile变量的时候其他线程可能已经更新了该共享变量的值。

存储屏障具有冲刷处理器缓存的作用,因此在volatile变量写操作之后插入的一个存储屏障就使得该存储屏障前所有操作的结果(包括volatile变量写操作及该操作之前的任何操作)对其他处理器来说是可同步的。

对于volatile变量读操作,Java虚拟机会在该操作之前插入一个加载屏障(LoadBarrier),并在该操作之后插入一个获取屏障(AcquireBarrier)

image-20210918143654001

其中,加载屏障通过冲刷处理器缓存,使其执行线程(读线程)所在的处理器将其他处理器对共享变量(可能是多个变量)所做的更新同步到该处理器的高速缓存中。读线程执行的加载屏障和写线程执行的存储屏障配合在一起使得写线程对volatile变量的写操作以及在此之前所执行的其他内存操作的结果对读线程可见,即保障了可见性。因此,volatile不仅仅保障了volatile变量本身的可见性,还保障了写线程在更新volatile变量之前执行的所有操作的结果对读线程可见。这种可见性保障类似于锁对可见性的保障,与锁不同的是volatile不具备排他性,因而它不能保障读线程读取到的这些共享变量的值是最新的,即读线程读取到这些共享变量的那一刻可能已经有其他写线程更新了这些共享变量的值。另外,获取屏障禁止了volatile读操作之后的任何读、写操作与volatile读操作进行重排序。因此它保障了volatile读操作之后的任何操作开始执行之前,写线程对相关共享变量(包括volatile变量和普通变量)的更新已经对当前线程可见。

另外,volatile关键字也可以被看作给JIT编译器的一个提示,它相当于告诉JIT编译器相应变量的值可能被其他处理器更改,从而使JIT编译器不会对相应代码做出一些优化而导致可见性问题。

volatile在有序性保障方面也可以从禁止重排序的角度理解,即volatile禁止了如下重排序:

  • 写volatile变量操作与该操作之前的任何读、写操作不会被重排序;
  • 读volatile变量操作与该操作之后的任何读、写操作不会被重排序。

综上所述,我们知道volatile关键字的作用体现在对其所修饰的变量的读、写操作上。

如果被修饰的变量是个数组,那么volatile关键字只能够对数组引用本身的操作(读取数组引用和更新数组引用)起作用,而无法对数组元素的操作(读取、更新数组元素)起作用。

对数组的操作可分为读取数组元素、写数组元素和读取数组引用这几种类型:

int i = anArray[0]; // 操作类型类型①:读取数组元素

anArray[1] = 1; // 操作类型②:写数组元素

volatile int[] anotherArray = anArray; // 操作类型③:读取数组引用

在上述操作中,类型①操作可以分解为两个子步骤:先读取数组引用anArray,接着读取数组中的第0个元素。这里,第1个子步骤实际上是读取一个引用(相当于相应数组的内存地址,或者干脆理解为C语言中的指针),该子步骤是个volatile变量读取操作,它保障了当前线程能够读取到数组引用本身的相对新值;而第2个子步骤则是在指定的数组引用(内存地址)基础上计算偏移量来读取数组元素,它与volatile关键字没有关系。因此,它不能保障其读取到的值是相对新值。也就是说,在类型①操作中,volatile关键字起到的作用是保障当前线程能够读取到的数组引用的相对新值,这个值仅仅代表相应数组的内存地址而已,而该操作所读取到的数组元素值是否是相对新值则无法通过volatile关键字保障。类似地,在类型②操作中,volatile关键字起到的作用只是保障读取到的数组引用是一个相对新值,而对相应数组元素的写操作则没有可见性保障。类型③的操作是将一个数组的引用写入另外一个数组,这相当于更新另外一个数组的引用(内存地址),这里的赋值操作是能够触发volatile关键字的所有作用的。

如果要使对数组元素的读、写操作也能够触发volatile关键字的作用,那需要了解类AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray。

类似地,对于引用型volatile变量,volatile关键字只是保证读线程能够读取到一个指向对象的相对新的内存地址(引用),而这个内存地址指向的对象的实例/静态变量值是否是相对新的则没有保障。

# volatile变量的开销

volatile变量的开销包括读变量和写变量两个方面。volatile变量的读、写操作都不会导致上下文切换,因此volatile的开销比锁要小。写一个volatile变量会使该操作以及该操作之前的任何写操作的结果对其他处理器是可同步的,因此volatile变量写操作的成本介于普通变量的写操作和在临界区内进行的写操作之间。读取volatile变量的成本也比在临界区中读取变量要低(没有锁的申请与释放以及上下文切换的开销),但是其成本可能比读取普通变量要高一些。这是因为volatile变量的值每次都需要从高速缓存或者主内存中读取,而无法被暂存在寄存器中,从而无法发挥访问的高效性。

# 应用场景

  • 场景一 使用volatile变量作为状态标志。在该场景中,应用程序的某个状态由一个线程设置,其他线程会读取该状态并以该状态作为其计算的依据(或者仅仅读取并输出这个状态值)。此时使用volatile变量作为同步机制的好处是一个线程能够“通知”另外一个线程某种事件(例如,网络连接断连之后重新连上)的发生,而这些线程又无须因此而使用锁,从而避免了锁的开销以及相关问题。

  • 场景二 使用volatile保障可见性。在该场景中,多个线程共享一个可变状态变量,其中一个线程更新了该变量之后,其他线程在无须加锁的情况下也能够看到该更新。

  • 场景三 使用volatile变量替代锁。volatile关键字并非锁的替代品,但是在一定的条件下它比锁更合适(性能开销小、代码简单)。多个线程共享一组可变状态变量的时候,通常我们需要使用锁来保障对这些变量的更新操作的原子性,以避免产生数据不一致问题。利用volatile变量写操作具有的原子性,我们可以把这一组可变状态变量封装成一个对象,那么对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象引用赋值给相应的引用型变量来实现。在这个过程中,volatile保障了原子性和可见性,从而避免了锁的使用。

volatile关键字并非锁的替代品,volatile关键字和锁各自有其适用条件。前者更适合于多个线程共享一个状态变量(对象),而后者更适合于多个线程共享一组状态变量。某些情形下,我们可以将多个线程共享的一组状态变量合并成一个对象,用一个volatile变量来引用该对象,从而使我们不必要使用锁。

  • 场景四 使用volatile实现简易版读写锁。在该场景中,读写锁是通过混合使用锁和volatile变量而实现的,其中锁用于保障共享变量写操作的原子性,volatile变量用于保障共享变量的可见性。因此,与ReentrantReadWriteLock所实现的读写锁不同的是,这种简易版读写锁仅涉及一个共享变量并且允许一个线程读取这个共享变量时其他线程可以更新该变量(这是因为读线程并没有加锁)。因此,这种读写锁允许读线程可以读取到共享变量的非最新值。该场景的一个典型例子是实现一个计数器
public class Counter {
    private volatile long count; 
    public long vaule() { 
        return count; 
    } 
    public void increment() { 
        synchronized (this) { 
            count++; 
        } 
    } 
}

通过某分布式系统的负载均衡模块的设计与实现这样的实战案例来进一步讲解上述应用场景。某分布式系统(以下简称为系统)在其业务处理过程中需要通过网络连接调用下游部件提供的服务,即发送请求给下游部件。下游部件是一个集群环境(即多台主机对外提供相同的服务)。因此,该系统调用其下游部件服务的时候需要进行负载均衡控制,即保障下游部件的各台主机上接收到的请求数分布均匀(统计意义上的均匀)。该系统在调用其下游部件时的负载均衡控制需要在不重启应用程序、服务器的情况下满足以下几点要求。

  • 要求1 需要支持多种负载均衡算法,如随机轮询算法和加权随机轮询算法等。
  • 要求2 需要支持在系统运行过程中动态调整负载均衡算法,如从使用随机轮询算法调整为使用加权随机轮询算法。
  • 要求3 在调用下游部件的过程中,下游部件中的非在线主机(如出现故障的主机)需要被排除在外,即发送给下游部件的请求不能被派发给非在线主机(因为那样会导致请求处理失败)。
  • 要求4 下游部件的节点信息可动态调整,如出于维护的需要临时删除一个节点过后又将其重新添加回来。

这个负载均衡模块会涉及比较多的volatile的使用。

该系统负责调用其下游部件服务的类为ServiceInvoker

public class ServiceInvoker {
  // 保存当前类的唯一实例
  private static final ServiceInvoker INSTANCE = new ServiceInvoker();
  // 负载均衡器实例,使用volatile变量保障可见性
  private volatile LoadBalancer loadBalancer;

  // 私有构造器
  private ServiceInvoker() {
    // 什么也不做
  }

  /**
   * 获取当前类的唯一实例
   */
  public static ServiceInvoker getInstance() {
    return INSTANCE;
  }

  /**
   * 根据指定的负载均衡器派发请求到特定的下游部件。
   *
   * @param request
   *          待派发的请求
   */
  public void dispatchRequest(Request request) {
    // 这里读取volatile变量loadBalancer
    Endpoint endpoint = getLoadBalancer().nextEndpoint();

    if (null == endpoint) {
      // 省略其他代码

      return;
    }

    // 将请求发给下游部件
    dispatchToDownstream(request, endpoint);

  }

  // 真正将指定的请求派发给下游部件
  private void dispatchToDownstream(Request request, Endpoint endpoint) {
    Debug.info("Dispatch request to " + endpoint + ":" + request);
    // 省略其他代码
  }

  public LoadBalancer getLoadBalancer() {
    // 读取负载均衡器实例
    return loadBalancer;
  }

  public void setLoadBalancer(LoadBalancer loadBalancer) {
    // 设置或者更新负载均衡器实例
    this.loadBalancer = loadBalancer;
  }
}

首先,我们使用LoadBalancer接口对负载均衡算法进行抽象,并为系统支持的每个负载均衡算法创建一个LoadBalancer实现类,从而满足了要求1。

public interface LoadBalancer { 
    void updateCandidate( final Candidate candidate); 
    Endpoint nextEndpoint(); 
}

接着,我们为ServiceInvoker设置一个实例变量loadBalancer用来保存LoadBalancer实例(即具体的负载均衡算法)。这里,我们使用volatile关键字修饰loadBalancer,就是属于volatile关键字的场景二的运用:ServiceInvoker的dispatchRequest方法会通过调用getLoadBalancer()方法来读取volatile变量loadBalancer,该方法运行在业务线程(即Web服务器的工作者线程)中。当系统的启动线程(即main线程)或者配置管理线程(负责配置数据的刷新)更新了变量loadBalancer的值之后,所有业务线程在无须使用锁的情况下也能够读取到更新后的loadBalancer变量值,这实现了对负载均衡算法的动态调整,即满足了要求2。

再看看具体的负载均衡算法是如何满足要求3的。这个实现过程会涉及volatile关键字的场景一的运用。

负载均衡算法抽象类AbstractLoadBalancer 源码

/**
 * 负载均衡算法抽象实现类,所有负载均衡算法实现类的父类
 *
 * @author Viscent Huang
 */
public abstract class AbstractLoadBalancer implements LoadBalancer {
  private final static Logger LOGGER = Logger.getAnonymousLogger();
  // 使用volatile变量替代锁(有条件替代)
  protected volatile Candidate candidate;
  protected final Random random;
  // 心跳线程
  private Thread heartbeatThread;

  public AbstractLoadBalancer(Candidate candidate) {
    if (null == candidate || 0 == candidate.getEndpointCount()) {
      throw new IllegalArgumentException("Invalid candidate " + candidate);
    }
    this.candidate = candidate;
    random = new Random();
  }

  public synchronized void init() throws Exception {
    if (null == heartbeatThread) {
      heartbeatThread = new Thread(new HeartbeatTask(), "LB_Heartbeat");
      heartbeatThread.setDaemon(true);
      heartbeatThread.start();
    }
  }

  @Override
  public void updateCandidate(final Candidate candidate) {
    if (null == candidate || 0 == candidate.getEndpointCount()) {
      throw new IllegalArgumentException("Invalid candidate " + candidate);
    }
    // 更新volatile变量candidate
    this.candidate = candidate;
  }

  /*
   * 留给子类实现的抽象方法
   *
   * @see io.github.viscent.mtia.ch3.volatilecase.LoadBalancer#nextEndpoint()
   */
  @Override
  public abstract Endpoint nextEndpoint();

  protected void monitorEndpoints() {
    // 读取volatile变量
    final Candidate currCandidate = candidate;
    boolean isTheEndpointOnline;

    // 检测下游部件状态是否正常
    for (Endpoint endpoint : currCandidate.iterator()) {
      isTheEndpointOnline = endpoint.isOnline();
      if (doDetect(endpoint) != isTheEndpointOnline) {
        endpoint.setOnline(!isTheEndpointOnline);
        if (isTheEndpointOnline) {
          LOGGER.log(java.util.logging.Level.SEVERE, endpoint
              + " offline!");
        } else {
          LOGGER.log(java.util.logging.Level.INFO, endpoint
              + " is online now!");
        }
      }
    }// for循环结束

  }

  // 检测指定的节点是否在线
  private boolean doDetect(Endpoint endpoint) {
    boolean online = true;
    // 模拟待测服务器随机故障
    int rand = random.nextInt(1000);
    if (rand <= 500) {
      online = false;
    }
    return online;
  }

  private class HeartbeatTask implements Runnable {
    @Override
    public void run() {
      try {
        while (true) {
          // 检测节点列表中所有节点是否在线
          monitorEndpoints();
          Thread.sleep(2000);
        }
      } catch (InterruptedException e) {
        // 什么也不做
      }
    }
  }// HeartbeatTask类结束
}

加权轮询负载均衡算法的实现类WeightedRoundRobinLoadBalancer

/**
 * 加权轮询负载均衡算法实现类
 *
 * @author Viscent Huang
 */
public class WeightedRoundRobinLoadBalancer extends AbstractLoadBalancer {
  // 私有构造器
  private WeightedRoundRobinLoadBalancer(Candidate candidate) {
    super(candidate);
  }

  // 通过该静态方法创建该类的实例
  public static LoadBalancer newInstance(Candidate candidate)
      throws Exception {
    WeightedRoundRobinLoadBalancer lb = new WeightedRoundRobinLoadBalancer(candidate);
    lb.init();
    return lb;

  }

  // 在该方法中实现相应的负载均衡算法
  @Override
  public Endpoint nextEndpoint() {
    Endpoint selectedEndpoint = null;
    int subWeight = 0;
    int dynamicTotoalWeight;
    final double rawRnd = super.random.nextDouble();
    int rand;

    // 读取volatile变量candidate
    final Candidate candiate = super.candidate;
    dynamicTotoalWeight = candiate.totalWeight;
    for (Endpoint endpoint : candiate.iterator()) {
      // 选取节点以及计算总权重时跳过非在线节点
      if (!endpoint.isOnline()) {
        dynamicTotoalWeight -= endpoint.weight;
        continue;
      }
      rand = (int) (rawRnd * dynamicTotoalWeight);
      subWeight += endpoint.weight;
      if (rand <= subWeight) {
        selectedEndpoint = endpoint;
        break;
      }
    }
    return selectedEndpoint;
  }
}

WeightedRoundRobinLoadBalancer在选取下游部件节点(Endpoint)的时候会先判断相应节点是否在线,它会跳过非在线的节点。

Endpoint类的源码

/**
 * 表示下游部件的节点
 * 
 * @author Viscent Huang
 */
public class Endpoint {
	public final String host;
	public final int port;
	public final int weight;
	private volatile boolean online = true;

	public Endpoint(String host, int port, int weight) {
		this.host = host;
		this.port = port;
		this.weight = weight;
	}

	public boolean isOnline() {
		return online;
	}

	public void setOnline(boolean online) {
		this.online = online;
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((host == null) ? 0 : host.hashCode());
		result = prime * result + port;
		result = prime * result + weight;
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Endpoint other = (Endpoint) obj;
		if (host == null) {
			if (other.host != null)
				return false;
		} else if (!host.equals(other.host))
			return false;
		if (port != other.port)
			return false;
		if (weight != other.weight)
			return false;
		return true;
	}

	@Override
	public String toString() {
		return "Endpoint [host=" + host + ", port=" + port + ", weight=" + weight
		    + ", online=" + online + "]\n";
	}

}

这里Endpoint的online实例变量是个volatile变量,它用来表示相应节点的服务状态:是否在线。所有负载均衡算法实现类的抽象父类AbstractLoadBalancer内部会维护一个心跳线程(heartbeatThread)来定时检测下游部件各个节点的状态,并根据检测的结果来更新相应Endpoint的online实例变量,心跳线程根据检测结果更新volatile变量online的值,而具体的负载均衡算法实现类(如WeightedRoundRobinLoadBalancer)则根据变量online的值决定其动作(跳过还是不跳过相应节点),从而满足了要求3。这个过程涉及了volatile关键字的场景一的运用。

再看看要求4的满足与volatile关键字的场景三的运用之间的关系。从WeightedRoundRobinLoadBalancer的源码可以看出,负载均衡算法的nextEndpoint方法选取下游部件节点的时候会用到一个关键的volatile实例变量candidate,该变量由负载均衡算法的抽象父类AbstractBalancer定义,其类型为Candidate。

Candidate类源码

public final class Candidate implements Iterable<Endpoint> {
  // 下游部件节点列表
  private final Set<Endpoint> endpoints;
  // 下游部件节点的总权重
  public final int totalWeight;

  public Candidate(Set<Endpoint> endpoints) {
    int sum = 0;
    for (Endpoint endpoint : endpoints) {
      sum += endpoint.weight;
    }
    this.totalWeight = sum;
    this.endpoints = endpoints;
  }

  public int getEndpointCount() {
    return endpoints.size();
  }

  @Override
  public final Iterator<Endpoint> iterator() {
    return ReadOnlyIterator.with(endpoints.iterator());
  }

  @Override
  public String toString() {
    return "Candidate [endpoints=" + endpoints + ", totalWeight=" + totalWeight
        + "]";
  }
}

Candidate类包含了下游部件的节点列表(endpoints)以及列表中所有节点的总权重(totalWeight)。这里的实例变量totalWeight作为一个冗余信息,其作用是避免负载均衡算法每次都要计算总权重。如果我们要变更下游部件的节点信息(如删除一个节点),那么配置管理器(一个单独的工作者线程)只需要调用AbstractBalancer子类的updateCandidate方法即可。updateCandidate方法会直接更新candidate变量的值,这里volatile保障了这个操作的原子性和可见性。这就是volatile关键字的场景三的运用。相反,如果我们采用下面的设计:

public abstract class AbstractLoadBalancer implements LoadBalancer {

	// candidate变量不 更新 
    protected final Candidate candidate; 
    public AbstractLoadBalancer( Candidate candidate) { 
        this. candidate = candidate; 
    } 
    // 省略其他相同的代码 
} 
public class Candidate { 
    // 使 endpoints 及 totalWeight 这 两个 实例 变量 可更改 
    public Set< Endpoint> endpoints; 
    public int totalWeight; 
    // 省略其他相同的代码 
}

那么,变更下游部件节点信息的时候配置管理器需要更新Candidate实例(候选节点信息)的endpoints实例变量以及totalWeight实例变量的值,如下伪代码所示:

endpoints=newEndpoints;//操作①

totalWeight=calculateTotalWeight(newEndpoints);//操作②

即便是先撇开可见性问题不谈,我们也必须使上述操作成为原子操作,否则这种更新可能导致数据不一致。当配置管理器(线程)执行完操作①而未执行完操作②的时候,业务线程可能已经通过nextEndpoint()来读取Candidate实例的endpoints属性和totalWeight属性的值了,此时这些业务线程读取到的endpoints属性是配置管理器更新过的新值,而totalWeight属性的值则仍然是endpoints属性前一个值(旧值)计算出来的一个(旧)值,即业务线程读取到的是一个错误的配置。因此,我们并没有采用上述(操作①和操作②)方案来更新节点列表及相应的总权重,而是巧妙地使用了volatile变量来保障这个更新操作的原子性(以及可见性)。

# CAS与原子变量

CAS(CompareandSwap)是对一种处理器指令(例如x86处理器中的cmpxchg指令)的称呼。

CAS好比一个代理人(中间人),共享同一个变量V的多个线程就是它的客户。当客户需要更新变量V的值的时候,它们只需要请求(即调用)代理人代为修改,为此,客户要告诉代理人其看到的共享变量的当前值A及其期望的新值B。CAS作为代理人,相当于如下伪代码所示的函数:

boolean compareAndSwap(Variable V,Object A,Object B) { 
  if(A==V.get()) {//check:检查变量值是否被其他线程修改过
    V.set(B);// act:更新变量值
    return true;
    //更新成功
  }
  return false;//变量值已被其他线程修改,更新失败
}

注意

CAS仅保障共享变量更新操作的原子性,它并不保障可见性。

# 原子操作工具:原子变量类

原子变量类(Atomics)是基于CAS实现的能够保障对共享变量进行read-modify-write更新操作的原子性和可见性的一组工具类。这里所谓的read-modify-write更新操作,是指对共享变量的更新不是一个简单的赋值操作,而是变量的新值依赖于变量的旧值,例如自增操作“count++”。由于volatile无法保障自增操作的原子性,而原子变量类的内部实现通常借助一个volatile变量并保障对该变量的read-modify-write更新操作的原子性,因此它可以被看作增强型的volatile变量。

原子变量类
分组
基础数据型 AtomicInteger、AtomicLong、AtomicBoolean
数组型 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
字段更新器 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
引用型 AtomicReference、AtomicStampedReference、AtomicMarkableReference

CAS实现原子操作背后的一个假设是:共享变量的当前值与当前线程所提供的旧值相同,我们就认为这个变量没有被其他线程修改过。实际上,这个假设不一定总是成立,或者说它总是可以成立却是有条件的。例如,对于共享变量V,当前线程看到它的值为A的那一刻,其他线程已经将其值更新为B,接着在当前线程执行CAS的时候该变量的值又被其他线程更新为A,那么此时我们是否认为变量V的值没有被其他线程更新过呢,或者说这种结果是否可以接受呢?这就是ABA问题,即共享变量的值经历了A→B→A的更新。ABA问题是否可以接受或者可以容忍与要实现的算法有关,某些情形下我们无法容忍ABA问题。规避ABA问题也不难,那就是为共享变量的更新引入一个修订号(也称时间戳)。每次更新共享变量时相应的修订号的值就会被增加1。也就是说,我们将共享变量V的值“扩展”成一个由变量实际值和相应的修订号所组成的元组([共享变量实际值,修订号])。于是,对于初始实际值为A的共享变量V,它可能经历这样的变量更新:[A,0]→[B,1]→[A,1]。这里,虽然变量V的实际值仍然经历了A→B→A的更新,但是由于每次变量的更新都导致了相应修订号的增加,我们依然能够准确地判断究竟变量的值是否被其他线程修改过。AtomicStampedReference类就是基于这种思想而产生的。字段更新器(AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater)这3个类相对来说更加底层一点儿,可以将其理解为对CAS的一种封装,而原子变量类中的其他类都可以利用这几个类来实现。

# 对象的发布与逸出

对象发布是指使对象能够被其作用域之外的线程访问,常见的发布形式有以下几种:

  • 将对象引用存储到public变量中。

    从面向对象编程的角度来看,这种发布形式不太提倡,因为它违反了信息封装(InformationHiding)的原则,不利于问题定位。

  • 在非private方法(包括public、protected、package方法)中返回一个对象

    private Map<String,Integer> registry = new HashMap<String,Integer>();
    public Map<String,Integer> getRegistry() { 
      returnthis.registry;
    }
    
  • 创建内部类,使得当前对象(this)能够被这个内部类使用。

    public void startTask(final Object task) {
      Thread t = new Thread(new Runnable(){
        @Override
        public void run(){
          //省略其他代码
        }
      });
      t.start();
    }
    
    

    上述代码中的“new Runnable()”所创建的匿名类可用访问其外层类的当前实例this(通过“外层类名.this”这种语法访问),也就是说该匿名类的外层类发布了自身的当前实例。

  • 通过方法调用将对象传递给外部方法

# 对象的初始化安全:重访final与static

Java中类的初始化实际上也采取了延迟加载的技术,即一个类被Java虚拟机加载之后,该类的所有静态变量的值都仍然是其默认值(引用型变量的默认值为null,boolean变量的默认值为false),直到有个线程初次访问了该类的任意一个静态变量才使这个类被初始化——类的静态初始化块(“static{}”)被执行,类的所有静态变量被赋予初始值

public class ClassLazyInitDemo {
  public static void main(String[]args){
    Debug.info(Collaborator.class.hashCode());//语句①
    Debug.info(Collaborator.number);//语句②
    Debug.info(Collaborator.flag);
  }
  static class Collaborator{
    static int number = 1;
    static boolean flag = true;
    static {
      Debug.info("Collaboratorinitializing...");
    }
  }
}

Collaborator类本身(语句①)仅仅使该类被Java虚拟机加载,而并没有使其被初始化。当一个线程初次访问类Collaborator的静态变量(语句②)时这个类才被初始化。

static关键字仅仅保障读线程能够读取到相应字段的初始值,而不是相对新值。

由于重排序的作用,一个线程读取到一个对象引用时,该对象可能尚未初始化完毕,即这些线程可能读取到该对象字段的默认值而不是初始值(通过构造器或者初始化语句指定的值)。在多线程环境下final关键字有其特殊的作用:当一个对象被发布到其他线程的时候,该对象的所有final字段(实例变量)都是初始化完毕的,即其他线程读取这些字段的时候所读取到的值都是相应字段的初始值(而不是默认值)。而非final字段没有这种保障,即这些线程读取该对象的非final字段时所读取到的值可能仍然是相应字段的默认值。对于引用型final字段,final关键字还进一步确保该字段所引用的对象已经初始化完毕,即这些线程读取该字段所引用的对象的各个字段时所读取到的值都是相应字段的初始值。

当一个对象的引用对其他线程可见的时候,这些线程所看到的该对象的final字段必然是初始化完毕的。final关键字的作用仅是这种有序性的保障,它并不能保障包含final字段的对象的引用自身对其他线程的可见性。

# 安全发布与逸出

安全发布就是指对象以一种线程安全的方式被发布。当一个对象的发布出现我们不期望的结果或者对象发布本身不是我们所期望的时候,我们就称该对象逸出(Escape)。

构造器未执行结束意味着相应对象的初始化未完成,因此在构造器中将this关键字代表的当前对象发布到其他线程会导致这些线程看到的可能是一个未初始化完毕的对象,从而可能导致程序运行结果错误。一般地,如果一个类需要创建自己的工作者线程,那么我们可以为该类定义一个init方法(可以是private的),相应的工作者线程可以在该方法或者该类的构造器创建,但是线程的启动则是在init方法中执行的。然后我们再为该类定义一个静态方法(工厂方法),该工厂方法会创建该类的实例并调用该实例的init方法.

public class SafeObjPublishWhenStartingThread {
  private final Map<String,String> objectState;
  private SafeObjPublishWhenStartingThread(Map<String,String> objectState) {
    this.objectState = objectState;
    //不在构造器中启动工作者线程,以避免this逸出
  }
  private void init() { 
    //创建并启动工作者线程
    newThread(){
      @Override
      public void run(){
        //访问外层类实例的状态变量
        String value = objectState.get("someKey");
        Debug.info(value);//省略其他代码
      }
    }.start();
  }
  //工厂方法
  public static SafeObjPublishWhenStartingThreadnewInstance(Map<String,String>objState){
    SafeObjPublishWhenStartingThreadinstance = newSafeObjPublishWhenStartingThread(objState);
    instance.init();
    returninstance;
  }
}

一个对象在其初始化过程中没有出现this逸出,我们就称该对象为正确创建的对象(ProperlyConstructedObject)。要安全发布一个正确创建的对象,我们可以根据情况从以下几种方式中选择。

  • 使用static关键字修饰引用该对象的变量。
  • 使用final关键字修饰引用该对象的变量。
  • 使用volatile关键字修饰引用该对象的变量。
  • 使用AtomicReference来引用该对象。
  • 对访问该对象的代码进行加锁。