synchronized与volatile是如何保证原子、可见、有序的?(博客重写计划Ⅰ)

862 阅读18分钟

博客重写计划-更新日志:2021.4.24 1. 优化监视器对象的具体描述,优化对于线程状态变化的具体描述; 2. 加入对于锁如何保障多线程安全需要考虑的三个层面-原子性、可见性、有序性的论述; 3. 优化博客讲述逻辑,便于理解。 优化主要参考-《Java多线程编程实战指南》

为什么要出现锁?

多线程场景下因为处理器、处理器缓存模块它们本身的算法、之间的交互方式会产生三种竞态问题-原子性问题、可见性问题、有序性问题,而锁-内部锁、显示锁、volatile的出现本质上都是解决这三个层面的问题。

可见性问题

可见性问题的产生是因为CPU并不是直接操作内存,而是通过处理器缓存模块来进行内存的读取与更新(包括高速缓存、写缓冲器、无效化队列),并且处理器缓存模块是每个CPU私有的,因此在多线程场景下,如果一个线程对共享变量更新后写入到写缓冲区,那么此时其他线程对于该共享变量的改变是无感知的,即一个线程对数据所做的更新可能并不能体现在后续线程对该数据的读取上

为了解决可见性问题,我们需要保证一个线程对于共享变量的操作对于其他线程是可以感知的

首先我们通过分析处理器缓存的结构来分析可见性问题在线程间产生的原因,如下图所示:

image.png

高速缓存

每个处理器都有自己的高速缓存,高速缓存以Cache Line(缓存行)为基本数据单位,每个缓存行中存储着Key-Value对<内存地址,内存数据>; 而为了保证处理器间的缓存一致性,在缓存之间采用了缓存一致性方案-MESI,MESL即为高速缓存中缓存行(Cache Line)的四种状态:

  1. M(Modify):表示当前缓存行已经被当前处理器上的线程更新过;

  2. E(Exclusive):表示当前缓存行被当前处理器的所独占-即其他处理器上的高速缓存中没有该缓存;

  3. S(Shared):表示当前缓存行是被多个处理器共享的,即没有被某个处理器独占;

  4. I(Invalid): 表示当前缓存行没有数据或数据是无效,即缓存行的初始状态和过期状态。

其中处理器缓存行的状态通过MESI消息通过相应的转换规则在读写场景下进行转换:

处理器读高速缓存:

读高速缓存时,如果没有命中缓存(Read Miss),则当前处理器会向总线发送一条Read消息->

如果其他处理器中存在不为I状态的目标数据,则会回复Read Response消息到当前处理器缓存中,这样也可以保证在其他处理器中修改过的共享变量对于当前处理器也是可见的,共享完成后两个处理器中缓存行的状态都会变为S(shared)

如果其他处理器中不存在不为I状态的目标数据,则内存会回复Read Response消息到当前处理器缓存中。

处理器写高速缓存:

写高速缓存时,和读写锁类似,只可以由一个线程修改,并且其他线程无法读取。

因此线程写缓存行时要求当前缓存行是被当前处理器独占的E(Exclusive) or M(Modify),如果当前缓存行的状态为S,则当前处理器需要向总线发出一条Invalidate消息等待其他处理器中的相应缓存行状态变为I(Invalid)其他处理器收到I消息后无效化该处理器中对应的缓存并回复Invalidate Ack至当前处理器,这样当前处理器中该缓存行的状态可以修改为E,因此当前线程可以接着更新当前处理器高速缓存。

如果写缓存时没有命中缓存(Write Miss),则需要先发送Read消息到总线拉取缓存。

性能问题->增加写缓冲区与无效化队列,导致可见性问题

引入无效化队列: 由写高速缓存的过程可以看到,如果命中缓存时,当前处理器需要发送Invalidate消息至总线,并等待其他处理器回复Invalidate ACK,则在其他处理器回复期间,当前处理器需要同步等待回复,因此我们在每个处理器中增加无效化队列从而使得处理器收到Invalidate消息后直接放入队列后立即返回Invalidate Ack,然后该处理器会异步读取无效化队列以同步当前高速缓存。

引入写缓冲区: 由写高速缓存的过程可以看到,如果没有命中缓存,当前处理器需要首先发送Read消息至总线,并等待其他处理器 or 内存进行回复Read Response如果只有内存中存在当前需要的数据,则读取效率会大大下降,因此对于直接写( 没有数据依赖性,如 x=2,不是++i )的操作时,处理器会直接将数据写入 写缓冲区中,并发送Read消息,异步等待回复后修改高速缓存中对应的缓存行

引入写缓冲区和无效化队列的代价-带来了可见性与有序性问题!!

因为写缓冲区是处理器私有的,并且不受MESI协议约束,因此如果一个线程将共享变量的修改写入了写缓冲区并且还没有同步至高速缓存,则对于其他处理器上的线程是无感知的,因而会导致可见性问题

同理,因为我们写入无效化队列后并没有立即同步高速缓存,如果此时线程读取对应的缓存行,则会读取到旧数据,也会造成可见性问题

解决方案

我们知道了导致可见性问题的根源-写缓冲区、无效化队列

因此对于在线程中共享的变量的写入完成时,我们需要进行冲刷写缓冲区,即将写缓冲区中的数据同步到高速缓存中使得其他线程对于该数据可感知。

对于共享的变量的读取时,我们需要进行清空无效化队列,即将无效化队列中的数据同步到高速缓存中使得当前处理器上的线程知道该数据已经失效需要重新读取。

JMM中,对应于冲刷写缓冲区的操作即为存储屏障,对应于清空无效化队列的操作即为加载屏障

我们会在有序性问题中提到禁止重排序的内存屏障。

有序性问题

有序性问题由重排序导致,重排序分为两种:指令重排序与存储子系统重排序。

指令重排序

指令重排序分为两种:

编译器貌似串行规则下对源代码顺序编译为对应程序顺序(比如Java代码顺序->字节码的顺序)时改变了源代码中访问内存的操作顺序;

处理器在运行时动态改变程序顺序来提高处理器访问内存的效率。

存储子系统重排序

存储子系统重排序是由指令作用于写缓冲区与高速缓存中产生的对于处理器所感知到的其他处理器的执行序列不一致的现象:

比如(场景描述:其中xready都是共享变量,在线程1与线程2之间共享,其中处理器1的高速缓存中有ready的值):

线程1(处理器1)线程2(处理器2)
x = 1 ; ready = trueif (ready = true) { println(x); }

按照正确的源代码顺序来看,对于处理器2来讲,如果ready值由默认的false变为了true,则x已经被赋予了1。但是由于存在写缓冲区,在处理器触发了WriteMiss后,会将x的值直接写入写缓冲区中,但是ready的值会被写入高速缓存中,因而对于线程2来讲,这与它的以为它会看到指令顺序的结果不再相同,而这也导致了程序的执行与我们预期的不同。

解决方案

对于存储子系统重排序和可见性问题,都是由于引入了写缓冲区与无效化队列导致的,因此我们同样可以通过内存屏障针对程序中load 和 Store指令进行处理。

内存屏障为X|Y型,X与Y分别为Store or Load,并且在Y操作开始前保证X操作的结果已经可以在处理器之间同步、可见-(禁止存储子系统重排序,将Y操作重新排序到X操作前);

StoreLoadStoreStore都是Store在前,即该类屏障保证屏障前的所有数据在下次Load Or Store操作前都已经同步至高速缓存中对其他线程可见。

volatile关键字

我们可以通过volatile保证在多线程下的变量读写原子性、可见性、有序性,本质上亦是通过内存屏障解决的。

Volatile:,我们在进行写操作时,为了保证有序性,需要在写volatile前增加释放屏障-LoadStore+StoreStore,以保证其他线程看到volatile变量值修改后,在其之前的所有变量值已经被提交并在处理器之间可见;

还是对于这个例子:

线程1(处理器1)线程2(处理器2)
x = 1 ; ready = trueif (ready = true) { println(x); }

如果readyvolatile修饰,那么对于线程2来讲,只要其观察到ready的值被线程1修改为true,那么x的值一定已经被赋予了1并在多个处理器之间可见,因此对于线程2来讲,编写程序时要遵守-volatile的读操作先于对于非volatile变量的读取。

但是这仍然要求线程2读取ready值前需要重新刷新无效化队列、禁止读写、读读重排序:

因为处理器2中可能存在ready与x的失效缓存,并且指令重排序可能会导致对于ready的读取后于对于x的读取,因此在需要在读取Volatile后增加获取屏障-LoadStore+LoadLoad,来保证Load Volatile的操作先于后续的所有Store和Load操作-禁止将对于x的读取提前到ready前。

原子性问题

原子性问题产生原因是因为一组操作是一个整体,但是其他线程在这组操作的过程中感知到了变化

比如对于Ip:Port这种组合型的变量来讲,如果一个线程修改ipport的一组操作不是原子的,那么其他线程在修改Ip:Port的过程中可能会感知到先修改的Ip变量,但是port变量还是旧的,因此其他线程可能会通过错误的 Ip(新):Port(旧) 组合来访问目标主机,因此会导致错误的访问结果。

又比如在32位虚拟机上,我们对于long、double型变量进行操作,因为该类型的变量是64位的,因此会被拆分成两个操作,如果该操作不具有原子性,那么对于x=-1(初始为0,x为long型变量)而言,其他线程可能会看到0xffffffff 000000000x00000000 ffffffff的中间值(只修改了32位),显然导致了错误的访问结果。

因此原子性的定义即为:对于访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未开始执行,即其他线程不会观察到操作所导致的中间效果。

解决方案

我们通过对于共享变量访问操作互斥-即同一时刻只允许一个线程读取或修改共享变量来保证原子性,同一个时刻只有一个变量可以访问,自然也就不存在除有权限访问的线程外的其他线程会观察到中间效果的问题了。

Java中我们可以通过CAS+循环或者锁-内部or显式锁来实现互斥。

总结:

上文描述了锁出现的原因,解决可见性、有序性、原子性的多线程场景问题,并简单介绍了volatile的实现原理,接着我们来讲在Java中内部锁是如何实现的,首先我们应该先思考-如何锁住一个对象,即进行了什么操作后,该对象就被我们锁住了。

如何锁住一个对象?

我们经常说当使用synchronized修饰一个代码块时,编译后会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,而当虚拟机执行monitorenter指令时,会去尝试获取对象的锁,如果这个对象没有被锁定或者当前线程已经持有了这个对象的锁(可重入性),就会把锁的计数器加一,而在执行monitorexit指令时锁的计数器就会减一;

此时会很奇怪,我自己定义的对象哪来的锁呢?其实是因为在堆中存储的每个对象虚拟机都会为其生成一个对象头,对象头一般分为两部分(如果是数组对象则会分为三部分),而对这个问题最重要的是对象头的第一部分(Mark Word),一般为32bit或64bit(由虚拟机的位数决定)。

MarkWord

当该对象处于未被锁定的状态时,MarkWord中有25bit用来存储对象的hashCode,4bit用于存储对象的分代年龄(GC相关),2bit用于存储锁标志位,1bit默认为0;

而当对象被不同种类的锁锁定时其状态会变为:(注意此时是复用原有的空间的,并通过栈保存原有的MarkWord信息以便之后解除锁定的时候复原)

Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态(CAS操作实现)和重量级锁状态(内部锁),锁对应的状态会随着当前对象竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

因此锁住一个对象首先要改变该对象的MarkWord状态,并在MarkWord中存放对应的锁信息,让其他访问该对象的线程通过对象头了解到这个对象处于被锁定的状态以及找到对应的锁信息。

重量级锁

当对象的锁状态处于重量级锁时,其MarkWord中存储的即为指向当前对象对应重量级锁的指针,其具体内容如下:

// 是操作系统管程概念的实现
 ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  // 获取锁的线程每次调用monitorenter时该值会加1,保证锁的重入性
    _waiters      = 0, 
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; // 指向持有当前对象访问权的线程
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

重量级锁对象主要描述了:

  1. 当前对象的访问权限被哪个线程所持有(_owner);
  2. 等待(wait or block)获取该对象访问权限的线程有哪些(_WaitSet_EntryList)。

图片来源参考文章:blog.csdn.net/javazejian/…

MonitorEnter与MonitorExit

当线程尝试获取锁时会进入到MonitorEnter指令中:MonitorEnter为获取锁的指令,当线程进入到该指令时,会进入到锁对象的EntryList中,如果当前Owner指针没有指向并且Count值为0时,则当前线程获取到锁,将该锁对象的Owner指针指向自己,并增加Count值。

当线程自我wait或者出临界区时会进入到MonitorExit指令中:MonitorExit为释放锁的指令,当线程执行到该指令时,会释放Owner指针并将Counter值自减。

不过与线程出临界区不同,如果获取到锁的线程wait()后,会进入到WaitSet中,等待其他线程notify or notifyAll

PS:因此在线程协作方面,内部锁的锁粒度是对象层面的,即如果一个类中有两个synchronized方法,那么同一时刻两个方法不能被同时访问,即使这两个方法并不构成冲突;并且随机唤醒会导致过早唤醒的问题,因为如果两个方法等待的条件并不一致时,可能我们通过notify唤醒并获取对象访问权的线程并没有满足执行的条件。因此在Java中新增了Condition类用作唤醒条件维度的抽象,为每一个唤醒条件维护了一个阻塞队列从而解决了这个问题。

接着我们依据上面的信息描述线程访问被synchronized修饰方法的过程的宏观图像。

线程访问对象中synchronized方法宏观图像

synchronized修饰的代码块在编译器会对代码进行包裹:

会在同步块(临界区)前增加MonitorEnter这个阻塞字节码指令以及加载屏障(刷新处理器缓冲区)、获取屏障(LoadStore+LoadLoad)两个内存屏障;

会在同步块(临界区)后增加MonitorExit以及释放屏障(StoreStore+StoreLoad)、存储屏障(冲刷处理器缓冲区);

image.png

总结:synchronized通过上述的四个内存屏障保证可见性与有序性,而通过MonitorEnterMonitorExit保证原子性。

锁升级:

为什么会出现这个机制?

这种方案的提出主要是基于大部分时候,并发环境下的线程竞争比较少,所以可以使用乐观锁的想法去优化悲观锁的性能;

锁升级的过程:

偏向锁:

当一个对象第一次被一个线程访问时,会将对象的MarkWord修改为偏向锁,并将该线程Id使用CAS标识在MarkWord中(这里为什么要用CAS改,是为了防止两个线程同时修改MarkWord,导致线程冲突);所以偏向锁适用于预计只有一个线程访问的代码。

轻量级锁:

当另一个线程去访问被偏向锁锁定的对象时发现MarkWord中的线程ID并不是指向自己的,这个时候就会在安全点时Stop The Wrold,查看当前MarkWord中的线程是否还在运行,如果已经终止则将线程ID改为自己;如果之前的线程还没有停止运行,则需要解偏向锁,升级成轻量级锁;轻量级锁适用于保护的代码块执行速度很快,且预计不会发生线程冲突的场景

重量级锁:

在对象处于轻量级锁锁定时,其MarkWord中的指针指向持有该锁的线程的栈帧中建立的一个被称为锁记录(Lock Record)的空间,这个空间中只存放初始MarkWord(hashCode)及Owner指针,该指针指向持有轻量级锁的线程,用于告知其他访问该对象的线程该对象已经被持有!(可以看出轻量级锁在内存方面的开销也会小一些),如果当执行完同步代码块之后发现该指针已经变成指向monitor对象 的指针时,说明有另一个线程竞争了这个对象导致锁膨胀(在变成重量级锁前,竞争锁的对象会适当自旋给定的次数,避免频繁的线程挂起和唤醒因为Java虚拟机的线程是映射到操作系统内核态的线程的,所以每次对于线程的操作,都会需要将系统转至内核态,而这个开销是比较大的,而这也是重量级锁慢的主要原因),所以后面等待锁的线程也要进入阻塞状态;即在多个线程竞争轻量级锁,并且自旋失败后,会升级成重量级锁。

使用ReentrantLock实现公平锁和打断条件

与synchronized的区别:

  1. 等待可中断:即一个线程在等待获取锁的过程中如果超过了一定的时间,这个线程可以选择放弃等待,改为处理其他事情,而synchronized不可以;
  2. 可实现公平锁:当然ReentrantLock也可以是非公平的,构造的时候可以选择;
  3. 锁可以绑定多个条件:可以和Condition配合使用;
  4. 性能:synchronized在JDK1.6优化后性能与ReentrantLock相当;
  5. 实现方式:synchronized是基于JVM实现的,ReentrantLock是基于AQS实现的;

使用场景

除非是需要使用ReentrantLock的高级特性,否则还是使用synchronized比较好,首先是因为前者需要finally代码块中手动释放锁,而JVM会保证后者的锁释放;其次是因为现在两者的性能也比较接近。