synchronized与锁升级

Posted muuu520

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了synchronized与锁升级相关的知识,希望对你有一定的参考价值。

1 为什么需要synchronized?

当一个共享资源有可能被多个线程同时访问并修改的时候,需要用锁来保证数据的正确性。请看下图:

技术图片

线程A和线程B分别往同一个银行账户里面添加货币,A线程从内存中读取(read)当前账户金额($=0)到线程A的本地栈,进行+100的操作后,这时B线程也从内存中读取当前金额($=0)到线程B的本地栈,并且进行+200的操作后写回主存,线程B前脚刚写回之后,后脚线程A又把$=200写会到本地内存中。我们顺便来复习一下JMM内存模型的8个原子操作

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值赋值给主内存的变量。

我们知道,volatile关键字只能保证变量的有序性可见性,但是不能保证原子性。在这个例子中,即使给$变量加上volatile关键字也是不顶用的,原因可见volatile为什么不能保证原子性以及知乎提问:volatile为什么不能保证原子性

这时候就轮到我们的synchronized
关键字出场了,它可以在访问竞态资源时加锁,从而保证修改的时候不会出错。它有三种作用范围:

  • 在静态方法上加锁,锁住的是.class对象
  • 在非静态方法上枷锁,锁住的是当前对象this
  • 在代码块上加锁,锁住的是一个Object对象,比如monitor

2 JDK6之前 synchronized 的实现原理

在JDK6以前,synchronized还属于重量级锁,每次枷锁都依赖操作系统Mutex Lock实现,涉及到操作系统让线程从用户态切换到内核态,切换成本很高。在JDK6以后,研究人员引入了偏向锁和轻量级锁,因为Sun公司的程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,每次线程都加锁解锁,每次这么搞都要操作系统在用户态和内核态之前来回切,太耗性能了。

首先要了解synchronized的实现原理,需要理解二个预备知识:

  • Java对象头的结构。对象存储在堆中,主要分为三部分内容,对象头、对象实例数据和对齐填充(数组对象多一个区域:记录数组长度)。HotSpot虚拟机的对象头分为两部分,第一部分用来存储对象自身的运行时数据,如哈希码,GC分代年龄。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为"Mark Word"。这部分是实现轻量级锁和偏向锁的关键。另一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组的长度。
    考虑到Java虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。32位HotSpot虚拟机中,对象未被锁定的状态下,Mark Word的32个比特空间里的25个比特用来存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,还有1个比特固定为0(表示未进入偏向模式)。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同的状态Mark Word结构如下图所示:

技术图片

  • Monitor。每个对象都有一个与之关联的Monitor 对象;Monitor对象属性如下所示(Hospot 1.7 代码) 。
//下图详细介绍重要变量的作用
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;   // 重入次数
    _waiters      = 0,   // 等待线程数
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;  // 当前持有锁的线程
    _WaitSet      = NULL;  // 调用了 wait 方法的线程被阻塞 放置在这里
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 等待锁 处于block的线程 有资格成为候选资源的线程
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

对象关联的 ObjectMonitor 对象有一个线程内部竞争锁的机制,如下图所示:
技术图片

下面我们就来分析一下JDK6之前的synchronized具体的实现逻辑。

  1. 当有二个线程A、线程B都要开始给账户的经济money变量加钱,要进行操作的时候 ,发现方法上加了synchronized锁,这时线程调度到A线程执行,A线程就抢先拿到了锁。拿到锁的步骤为:
  • MonitorObject 中的_owner设置成 A线程
  • Mark Word 设置为 Monitor 对象地址,锁标志位改为10;
  • 将B线程阻塞放到ContentionList队列
  1. JVM 每次从Waiting Queue 的尾部取出一个线程放到OnDeck作为候选者,但是如果并发比较高,Waiting Queue会被大量线程执行CAS操作,为了降低对尾部元素的竞争,将Waiting Queue拆分成ContentionListEntryList 二个队列, JVM将一部分线程移到EntryList 作为准备进OnDeck的预备线程。另外说明几点:
  • 所有请求锁的线程首先被放在ContentionList这个竞争队列中;
  • Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
  • 任意时刻,最多只有一个线程正在竞争锁资源,该线程被称为 OnDeck;
  • 当前已经获取到所资源的线程被称为 Owner;
  • 处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的);
  1. 作为Owner的A线程执行过程中,可能调用wait释放锁,这个时候A线程进入 Wait Set , 等待被唤醒。

以上就是synchronized 在 JDK 6之前的实现原理。
另外,synchronized在在线程竞争锁时,首先做的不是直接进ContentionList队列排队,而是尝试自旋获取锁(可能ContentionList 有别的线程在等锁),如果获取不到才进入 ContentionList,这明显对于已经进入队列的线程是不公平的,所以synchronized是非公平锁。另一个不公平的是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。

3 JDK6之后synchronized优化

那么JDK6对synchronized做了哪些优化呢?

3.1 偏向锁

  • 如果当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,这是自JDK6起HotSpot虚拟机的默认值),那么当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设为01、把偏向模式设置为1,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时。虚拟机都可以不再进行任何同步操作(例如加锁、解锁以及对Mark Word的更新操作等)。
  • 一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设为0),撤销后标志位恢复到未锁定(01)或轻量级锁定(00)的状态,后续的同步操作就按照轻量级锁进行。

3.2 轻量级锁

3.2.1 加锁

  • 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word拷贝(官方为这份拷贝加了个前缀Displaced)。
  • 然后虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为00,表示此对象处于轻量级锁定状态。
  • 如果这个操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧。如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。当前线程会进行自旋(?)。如果出现两条以上的线程争用同一个锁,或者当前线程自旋失败(尝试到一定次数,默认10次)的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为10,此时Mark Word中存储的就是指向重量级锁监视器ObjectMonitor(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态
    下图是轻量级锁CAS操作前后堆栈与对象的状态:
    技术图片

3.2.2 解锁

轻量级锁的解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁(膨胀为重量级锁,Mark Word指向了互斥量),就要在释放重量级锁的同时,唤醒被挂起的线程

3.2.3 使用条件

轻量级锁能提升程序同步性能的依据是“对于绝大部分锁,在同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁通过CAS操作成功避免了使用互斥量的开销;但如果确实存在竞争,除了互斥量本身的开销之外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

整个锁升级的过程如下图所示:
技术图片

参考资料:

以上是关于synchronized与锁升级的主要内容,如果未能解决你的问题,请参考以下文章

Java多线程 synchronized 锁方法和块使用详解 锁竞争本质原理 只与锁对象有关与位置无关

2.1.4synchronized方法与锁对象

Java多线程---同步与锁

Java线程:线程的同步与锁

并发编程:锁重入与锁异常

原子性与锁