Synchronized原理

718 阅读13分钟

本文章用于个人梳理Synchronized相关知识和个人理解。

使用及注意事项

实例方法

作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

静态方法

也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员

代码块

指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁

实现原理

Synchronized是管程模型在Java语言中的实现形式。管程模型是主要由:锁标识(信号量)、等待队列、条件队列、wait、notify等功能组成的,用于实现互斥和同步功能的并发编程工具。

锁状态

Java对象头

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。 其中Mark Word用于存储对象自身的运行时数据,它是实现不同锁状态的重点。

Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。

image.png 考虑到虚拟机开销问题,,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,即表现为Mark Word会不同的状态表现出不同的结构。

Java管程

管程的详细介绍参见操作系统同步相关知识

Monitor

synchronized 关键字在使用的时候,往往需要指定一个对象与之关联,例如:synchronized(this),或者 synchronized(ANOTHER_LOCK),synchronized 如果修饰的是实例方法,那么其关联的对象实际上是 this,如果修饰的是类方法,那么其关联的对象是 this.class。总之,synchronzied 需要关联一个对象,而这个对象就是 monitor object。 monitor 的机制中,monitor object 充当着维护 mutex以及定义 wait/signal API 来管理线程的阻塞和唤醒的角色,即管程本身。

前面提到管程由信号量、等待队列、wait、notify等功能组成,在Java中,信号量由MarkWord中的锁标识实现;同时,java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于一个叫 ObjectMonitor 模式的实现,这是 JVM 内部基于 C++ 实现的一套机制,基本原理如下所示:

image.png

当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 monitor 机制中称为条件变量。

image.png

ObjectMonitor结构

在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的,其大概结构如下:

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 ;
  }

此处基于C++提供的ObjectMonitor结构实现的Object的wait、notify等方法和Mark Word的标记完整地支持了管程(即Java的重量级锁)

Lock Record

lock record在线程尝试获得轻量级锁时,在Interpretered Frame上(解释帧)分配生成的结构,是线程私有的结构。 用于:

  • 持有displaced word和锁住对象的元数据;
  • 解释器使用lock record来检测非法的锁状态;
  • 隐式地充当锁重入机制的计数器; 用于支持轻量级锁的实现

Lock Record结构如下:

class BasicObjectLock VALUE_OBJ_CLASS_SPEC {
 private:
  BasicLock _lock;                        // the lock, must be double word aligned
  oop       _obj;                         // object holds the lock;
};
class BasicLock VALUE_OBJ_CLASS_SPEC {
 private:
  volatile markOop _displaced_header;
};

包含Mark Word和锁对象obj指针

Lock Record依靠重复生成实现可重入,对与大于一次的充入,生成的Lock Record 中markword = NULL,只存obj指针。释放锁的时候,依靠指针比较。

JVM内部细节之一:synchronized关键字及实现细节(轻量级锁Lightweight Locking)

Java 中的 Monitor 机制 - SegmentFault 思否

锁优化

JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;

在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁偏向锁轻量级锁重量级锁,它会随着竞争情况逐渐升级,HotSpot JVM 支持锁降级,但是锁升降级效率较低,频繁升降级的话对性能就会造成很大影响。重量级锁降级发生于 STW 阶段,降级对象为仅仅能被 VMThread 访问而没有其他 JavaThread 访问的对象。

优化规则和具体实现与不同虚拟机实现有关,没有具体规范

四种锁状态

不同锁状态的关键在于Mark Word。

image.png

偏向锁

对于总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争的情况,使用偏向锁减少竞争。

当一个线程访问同步快并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁。只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。

仅在一个线程进入临界区时(包括多个轮流进入),允许偏向,一旦出现冲突立即膨胀为轻量级锁。

偏向撤销和重偏向

image.png

重偏向

锁对象Class一开始的情况下,是没有开启重偏向的,意思就是 “是否开启重偏向? ” 这个分支刚开始的时候是一直走  “否”  的,即会一直撤销偏向锁 ,当达到BiasedLockingBulkRebiasThreshold(20)次数的时候,允许重偏向。即当多次发生偏向撤销时,认为此部分代码会进行获取大量对象锁的操作,且这些大量对象已偏向,偏向线程已退出,此时运行直接修改偏向线程,以降低撤销,加轻量级锁的开销。

批量撤销

在重偏向之后,该区域代码仍多次发生撤销,频繁撤销偏向锁,于是该类下的锁对象都不支持偏向锁了,正在运行的也撤销。之后使用轻量级锁。

以上区域指Class范围。

计数实现

BiasedLockingBulkRebiasThreshold:偏向锁批量重偏向的默认阀值为20次。 BiasedLockingBulkRevokeThreshold:偏向锁批量撤销的默认阀值为40次。 BiasedLockingDecayTime:距上次批量重偏向25秒内,撤销计数达到40,就会发生批量撤销。

epoch机制

撤销偏向本身是一个消耗很大的事情,因为它必须挂起线程,遍历栈找到并修改lock records(锁记录)

epoch是一个时间戳,用来表明偏向的合法性,只要这个数据接口是可偏向的,那么就会在mark word上有一个对应的epoch bit位

这个时候,一个对象被认为已经偏向了线程T必须满足两个条件,

  1. mark word中偏向所有这的标记必须是这个线程

  2. 实例的epoch必须是和数据结构的epoch相等

通过这种方式,类C的bulk rebiasing操作会少去很多的花销。具体操作如下

  1. 增大类C的epoch,它本身是一个固定长度的integer,和对象头中的epoch拥有一样的bit位数

  2. 扫描所有的线程栈来定位当前类C的实例中已经锁住的,更新他们的epoch为类C的新的epoch或者是,根据启发式策略撤销偏向

实现设计

批量撤销本身存在着性能问题,一般的解决方式如下

  1. 添加epoch机制

  2. 线程第一次获取的时候不偏向,而是在执行一定数量后都有同一个线程获取再偏向

  3. 允许锁具有永远改变(或者很少)的固定偏向线程,并且允许非偏向线程获取锁而不是撤销锁。

    这种方式必须确保获取锁的线程必须确保进去临界区之前没有其它线程持有锁,并且不能使用 read-modify-write的指令,只能使用read和write

轻量级锁

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

轻量级锁允许一定时间的自旋等待,(认为即使发生冲突,也能在短时间内获得锁),一旦自旋超时,就膨胀为重量级锁。

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

锁升级过程

Lock Record的变化
Mark Word的变化

image.png

获取偏向的过程

image.png 只需要检查是否为偏向锁、锁标识为以及ThreadID,处理流程如下: 获取锁

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块

释放锁 偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
  2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;
轻量级锁膨胀

image.png

步骤如下: 获取锁

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

释放锁 轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;

其他优化

自适应自旋锁

如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

锁粗化

一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。 锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

锁消除

在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。

synchronized的锁强度究竟可以降级吗? - SegmentFault 思否

偏向锁的【批量重偏向与批量撤销】机制 - SegmentFault 思否

偏向锁状态转移原理 - SegmentFault 思否