synchronized 实现原理与内存屏障
锁概述
我们知道线程安全问题的产生前提是多个线程并发访问共享变量、共享资源(以下统称为共享数据)。于是,我们很容易想到保障线程安全的方法将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。锁(Lock)就是利用这种思路以保障线程安全的线程同步机制。
按照上述思路,锁可以理解为对共享数据进行保护的许可证。对于同一个许可证所保护的共享数据而言,任何线程访问这些共享数据前必须先持有该许可证。一个线程只有在持有许可证的情况下才能够对这些共享数据进行访问;并且,一个许可证一次只能够被一个线程持有;许可证的持有线程在其结束对这些共享数据的访问后必须让出(释放)其持有的许可证,以便其他线程能够对这些共享数据进行访问。
一个线程在访问共享数据前必须申请相应的锁(许可证),线程的这个动作被称为锁的获得(Acquire)。一个线程获得某个锁( 持有许可证 ),我们就称该线程为相应锁的持有线程 ( 线程持有许可证 ),一个锁一次只能被一个线程持有。锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放 ( Release ) 相应的锁。锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区( Critical Section )。因此,共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。
锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有。因此,这种锁被称为排他锁或者互斥锁 ( Mutex )。这种锁的实现方式代表了锁的基本原理,如图所示。
按照Java虚拟机对锁的实现方式划分,Java 平台中的锁包括内部锁( Intrinsic Lock )和显式锁 ( Explicit Lock )。内部锁是通过synchronized关键字实现的;显式锁是通过java.concurrent.locks.Lock接口的实现类(如 java.concurrent.locks.ReentrantLock 类 ) 实现的。
锁的作用
锁能够保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。
锁是通过互斥保障原子性的。所谓互斥 ( Mutual Exclusion ),就是指一个锁一次只能被一个线程持有。因此一个线程持有一个锁的时候,其他线程无法获得该锁,而只能等待其释放该锁后再申请。这就保证了临界区代码一次只能够被一个线程执行。因此,一个线程执行临界区期间没有其他线程能够访问相应的共享数据,这使得临界区代码所执行的操作自然而然地具有不可分割的特性,即具备了原子性。
从互斥的角度来看,锁其实是将多个线程对共享数据的访问由本来的并发( 未使用锁的情况下 )改为串行( 使用锁之后 )。因此,虽然实现并发是多线程编程的目标,但是这种并发往往是并发中带有串行的局部并发。这好比公路维修使得多股车道在某处被合并成一股小车道,从而使原本在多股车道上并驾齐驱的车辆不得不“鱼贯而行”。
我们知道,可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。在Java平台中,锁的获得隐含着刷新处理器缓存这个动作,这使得读线程在执行临界区代码前( 获得锁之后 ) 可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被“推送” 到该线程执行处理器的高速缓存中,从而对读线程可同步。因此,锁能够保障可见性。
锁能够保障有序性。写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行,即读线程对这些操作的感知顺序与源代码顺序一致。这是暂且对原子性和可见性的保障的结果。设写线程在临界区中更新了b 、c 和 flag 这 3 个共享变量,如下代码片段所示 :
由于锁对可见性的保障,写线程在临界区中对上述任何一个共享变量所做的更新都对读线程可见。并且,由于临界区内的操作具有原子性,因此写线程对上述共事变量的更新会同时对读线程可见,即在读线程看来这些变量就像是在同一刻被更新的。因此读线程并无法(也没有必要)区分写线程实际上是以什么顺序更新上述变量的,这意味着读线程可以认为写线程是依照源代码顺序更新上述共享变量的,即有序性得以保障。
尽管锁能够保障有序性,但是这并不意味着临界区内的内存操作不能够被重排序。临界区内的任意两个操作依然可以在临界区之内被重排序(即不会重排到临界区之外)。由于临界区内的操作具有的原子性,写线程在临界区内对各个共享数据的更新同时对读线程可见,因此这种重排序并不会对其他线程产生影响。
在理解,以及使用锁保证线程安全的时候,需要注意锁对可见性、原子性和有序性的保障是有条件的,我们要同时保证以下两点得以满足。
• 这些线程在访问同一组共享数据的时候必须使用同一个锁。
• 这些线程中的任意一个线程,即使其仅仅是读取这组共享数据而没有对其进行更新的话,也需要在读取时持有相应的锁。
上述任意一个条件未满足都会使原子性、可见性和有序性没有保障。可见,我们说锁能够保护共享数据其实是一种“协议” 的结果,这个协议就是任何访问该共享数据的写线程、 读线程都要满足上述条件。只要有任何一个线程没有遵守这个协议实际上就被打破,从而无法保障线程安全。这就好比交通规则( “协议” ) 要靠人人都遵守才能保障交通安全一样。
Java平台中的任何一个对象都有唯一一个与之关联的锁。这种锁被称为监视器 ( Monitor ) 或者内部锁 ( Intrinsic Lock )。内部锁是一种排他锁,它能够保障原子性、可见性和有序性。
内部锁是通过synchronized关键字实现的。synchronized 关键字可以用来修饰方法以及代码块( 花括号 “ { } ” 包裹的代码 )。
synchronized关键字修饰的方法就被称为同步方法( Synchronized Method )。synchronized 修饰的静态方法就被称为同步静态方法,synchronized 修饰的实例方法就被称为同步实例方法。同步方法的整个方法体就是一个临界区。
synchronized关键字所引导的代码块就是临界区。锁句柄是一个对象的引用(它或者能够返回对象的表达式)。例如,锁句柄可以填写为this关键字( 表示当前对象 )。习惯上我们也直接称锁句柄为锁。锁句柄对应的监视器就被称为相应同步块的引导锁。相应地,我们称呼相应的同步块为该锁引导的同步块。
作为锁句柄的变量通常采用final修饰。这是因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁,从而导致竞态。有鉴于此,通常我们会使用 private 修饰作为锁句柄的变量。
线程在执行临界区代码的时候必须持有该临界区的引导锁。一个线程执行到同步块(同步方法也可看作同步块)时必须先申请该同步块的引导锁,只有申请成功(获得)该锁的线程才能够执行相应的临界区。一个线程执行完临界区代码后引导该临界区的锁就会被自动释放。在这个过程中,线程对内部锁的申请与释放的动作由Java虚拟机负责代为实施,这也正是 synchronized 实现的锁被称为内部锁的原因。
内部锁的使用并不会导致锁世漏。这是因为Java编译器 ( javac ) 在将同步块代码编译为字节码的时候,对临界区中可能抛出的而程序代码中又未捕获的异常进行了特殊( 代为 )处理,这使得临界区的代码即使抛出异常也不会妨碍内部锁的释放。
内部锁的调度
Java虚拟机会为每个内部锁分配一个入口集 ( Entry Set ),用于记录等待获得相应内部锁的线程。多个线程申请同一个锁的时候,只有一个申请者能够成为该锁的持有线程( 即申请锁的操作成功 ),而其他申请者的申请操作会失败。这些申请失败的线程并不会抛出异常,而是会被暂停( 生命周期状态变为 BLOCKED ) 并被存入相应锁的入口集中等待再次申请锁的机会 。入口集中的线程就被称为相应内部锁的等待线程。当这些线程申请的锁被其持有线程释放的时候,该锁的入口集中的一个任意线程会被Java虚拟机唤醒,从而得到再次申请锁的机会。由于Java 虚拟机对内部锁的调度仅支持非公平调度,被唤醒的等待线程占用处理器运行时可能还有其他新的活跃线程 ( 处于RUNNABLE 状态,且未进入过入口集 ) 与该线程抢占这个被释放锁,因此被唤醒的线程不一定就能成为该锁的持有线程。另外,Java 虚拟机如何从一个锁的入口集中选择一个等待线程,作为下一个可以参与再次申请相应锁的线程,这个细节与 Java 虚拟机的具体实现有关:这个被选中的线程有可能是入口集中等待时间最长的线程,也可能是等待时间最短的线程,或者完全是随机的一个线程。因此,我们不能依赖这个具体的选择算法。
前文我们讲解锁是如何保证可见性的时候提到了线程获得和释放锁时所分别执行的两个动作:刷新处理器缓存和冲刷处理器缓存。对于同一个锁所保护的共享数据而言,前一个动作保证了该锁的当前持有线程能够读取到前一个持有线程对这些数据所做的更新,后一个动作保证了该锁的持有线程对这些数据所做的更新对该锁的后续持有线程可见。那么,这两个动作是如何实现的呢?弄清楚这个问题有助于我们学习和掌握包括锁在内的所有Java线程同步机制 。
Java虚拟机底层实际上是借助内存屏障( Memory Barrier ,也称 Fence )来实现上述两个动作的。内存屏障是对一类仅针对内存读、写操作指令 ( Instruction ) 的跨处理器架构 ( 比如 x86 、ARM )的比较底层的抽象( 或者称呼 )。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性。它在指令序列 ( 如指令 1 ;指令2 ;指令3 )中就像是一堵墙 ( 因此被称为屏障 )一样使其两侧 ( 之前和之后 )的指令无法“穿越”它 ( 一旦穿越了就是重排序了 )。但是,为了实现禁止重排序的功能,这些指令也往往具有一个副作用刷新处理器缓存、冲刷处理器缓存,从而保证可见性。不同微架构的处理器所提供的这样的指令是不同的,并且出于不同的目的使用的相应指令也是不同的。例如对于 “写-写” ( 写后写 ) 操作,如果仅仅是为了防止 ( 禁止 ) 重排序而对可见性保障没有要求,那么在x86架构的处理器下使用空操作就可以了( 因为 x86处理器不会对 “写-写” 操作进行重排序 )。而如果对可见性有要求(比如前一个写操作的结果要在后一个写操作执行前对其他处理器可见),那么在x86 处理器下需要使用LOCK 前缀指令或者sfence 指令、mfence 指令;在 ARM 处理器下则需要使用 DMB 指令。
按照内存屏障所起的作用来划分,将内存屏障划分为以下几种。
按照可见性保障来划分。内存屏障可分为加载屏障(Load Barrier)和存储屏障(Store Barrier)。加载屏障的作用是刷新处理器缓存,存储屏障的作用冲刷处理器缓存。Java虚拟机会在 MonitorExit ( 释放锁 ) 对应的机器码指令之后插入一个存储屏障,这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的。相应地,Java 虚拟机会在 MonitorEnter ( 申请锁 ) 对应的机器码指令之后临界区开始之前的地方插入一个加载屏障,这使得读线程的执行处理器能够将写线程对相应共享变量所做的更新从其他处理器同步到该处理器的高速缓存中。因此,可见性的保障是通过写线程和读线程成对地使用存储屏障和加载屏障实现的。
按照有序性保障来划分,内存屏障可以分为获取屏障(Acquire Barrier)和释放屏障 ( Release Barrier )。获 取 屏 障 的 使 用 方 式 是 在 一 个 读 操 作 ( 包括 Read-Modify-Write 以及普通的读操作 )之后插入该内存屏障,其作用是禁止该读操作与其后的任何读写操作之间进行重排序,这相当于在进行后续操作之前先要获得相应共享数据的所有权 ( 这也是该屏障的名称来源 )。释放屏障的使用方式是在一个写操作之前插入该内存屏障,其作用是禁止该写操作与其前面的任何读写操作之间进行重排序。这相当于在对相应共享数据操作结束后释放所有权( 这也是该屏障的名称来源 )。 Java虚拟机会在 MonitorEnter( 它包含了读操作 ) 对应的机器码指令之后临界区开始之前的地方插入一个获取屏障,并在临界区结束之后 MonitorExit ( 它包含了写操作 ) 对应的机器码指令之前的地方插入一个释放屏障。因此,这两种屏障就像是三明治的两层面包片把火腿夹住一样把临界区中的代码(指令序列)包括起来,如图所示。
由于获取屏障禁止了临界区中的任何读、写操作被重排序到临界区之前的可能性。而释放屏障又禁止了临界区中的任何读、写操作被重排序到临界区之后的可能性。因此临界区内的任何读、写操作都无法被重排序到临界区之外。在锁的排他性的作用下,这使得临界区中执行的操作序列具有原子性。因此,写线程在临界区中对各个共享变量所做的更新会同时对读线程可见,即在卖线程看来各个共享变量就像是“一下子” 被更新的,于是这些线程无从 ( 也无必要 ) 区分这些共享变量是以何种顺序被更新的。这使得写线程在临界区中执行的操作自然而然地具有有序性读线程对这些操作的感知顺序与源代码顺序一致。
可见,锁对有序性的保障是通过写线程和读线程配对使用释放屏障与加载屏障实现的。