synchronized 实现原理
要理解清楚synchronized
的原理首先要理解对象头
和Monitor
。
当某个线程执行到 synchronized
也就是monitorenter指令时,jvm会执行相关的由C++代码实现的获取锁的逻辑,若判断锁是重量级锁,则将线程加入Monitor的_EntryList中竞争Monitor,若成功获取Monitor就直接执行synchronized
中的代码。
对象头
Java 对象在内存中的布局分为 3 部分:对象头、实例数据、对齐填充
,整个对象头占12个字节,分为markword(标志字段)
和Class pointer(类型指针)
,markword
占8个字节,关于锁和GC等信息都记录在markword
字段中。类型指针表明这个对象到底属于哪个Class,而Class则是保存着类的元数据,占4个字节,实例数据
指对象中的成员变量。
当我们在 Java 代码中,使用 new 创建一个对象的时候,JVM 会在堆中创建一个 instanceOopDesc 的基类为 oopDesc对象,这个对象中包含了对象头
以及实例数据
。它的结构如下:
// instanceOop.hpp
class instanceOopDesc : public oopDesc { };
//jdk/src/hotspot/share/oops/oop.hpp
class oopDesc {
friend class VMStructs;
friend class JVMCIVMStructs;
private:
volatile markWord _mark;//标志字段
union _metadata {// 元数据
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
}
其中_mark
和 _metadata
组成对象头。_metadata
主要保存了类元数据(klass),元数据前面有介绍过,这里不做详细介绍了。_mark
是 markWord 类型数据,markWord
描述了对象的头部,其中主要存储了对象的hashCode、分代年龄、锁标志位、是否偏向锁
等相关信息。
下图表示32 位 JVM的 Mark Word
的默认存储结构如下:
默认情况下,没有线程进行加锁操作,对象中的 Mark Word·
处于无锁状态(锁标志位为01,是否偏向锁为0)。
JVM为了空间效率,mark word
被设计成为一个非固定的数据结构
,以便存储更多的有效数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 mark word
默认存储结构外,还有如下可能变化的结构:
从图中可以看出,根据"锁标志位”以及"是否为偏向锁",Java的内置锁可以分为以下几种状态:
在jdk6之前,并没有轻量级锁
和偏向锁
,只有重量级锁,也就是通常所说 synchronized
的对象锁
,锁标志位为 10
。
从图中的描述可以看出:当锁是重量级锁时,对象头中 mark word
会用 30 bit 来指向一个“互斥量”,而这个互斥量就是Monitor
,也就是说对象头中会保存一个指针指向ObejctMonitor对象
。
实际上 synchronized
关键字在字节码中,对应的是 monitorenter
和 monitorexit
指令,当 jvm 解析到 monitorenter
指令,jvm 将会执行由C++实现的代码对monitorenter
执行获取锁的操作,过程中可能会存在锁膨胀的过程。
当锁为重量级锁
时,通过Monitor
实现的;当锁为偏向锁、轻量级锁时是通过java的对象头实现的,接下来看看Monitor
机制。
Monitor
Monitor
可以把它理解为JVM的实现同步的工具,也可以描述为一种同步机制。实际上,它是一个保存在对象头中的一个对象
。在 markWord
中有如下代码:
class markWord {
ObjectMonitor* monitor() const {
assert(has_monitor(), "check");
// Use xor instead of &~ to provide one extra tag-bit check.
return (ObjectMonitor*) (value() ^ monitor_value);
}
}
通过 monitor() 方法创建一个ObjectMonitor 对象,而 ObjectMonitor
就是JVM中的 ·Monitor· 的具体实现。因此 Java 中每个对象都会有一个对应的 ObjectMonitor
对象,这也是 Java 中所有的 Object 都可以作为锁对象的原因。
通常所说的对象的内置锁,是对象头Mark Word中的重量级锁指针指向的monitor对象
,该对象是在HotSpot底层C++语言编写的(openjdk里面看)。首先看下 ObjectMonitor 的结构:
ObjectMonitor::ObjectMonitor(oop object) :
_header(markWord::zero()),
_object(_oop_storage, object),
_owner(NULL), // 指向持有ObjectMonitor对象的线程
_previous_owner_tid(0),
_next_om(NULL),
_recursions(0), // 锁重入次数
_EntryList(NULL), // 处于等待锁block状态的线程,会被加到_EntryList列表中
_cxq(NULL),
_succ(NULL),
_Responsible(NULL),
_Spinner(0),
_SpinDuration(ObjectMonitor::Knob_SpinLimit),
_contentions(0),
_WaitSet(NULL), // 处于wait状态的线程,会被加到_WaitSet集合中
_waiters(0),
_WaitSetLock(0)
{ }
其中有几个比较关键的属性:
-
_owner
指向持有ObjectMonitor对象的线程。内部是通过CAS去替换_owner
,类似AQS的state表示该线程已拿到锁。 -
_recursions
锁重入次数。同一个线程多次得到锁。 -
_EntryList
存放等待锁处于block状态的线程队列。 -
_WaitSet
存放处于wait状态的线程队列。
当多个线程同时访问一段synchronized
代码时,首先会进入 _EntryList
队列中(当然这是描述的重量级锁),当某个线程通过竞争获取到对象的 monitor
后,monitor
会把_owner
变量设置为当前线程,同时monitor
中的_recursions
加 1,即获得对象锁。没有得到锁对象就一直在block在 _EntryList
队列中。
若持有monitor
的线程调用wait
方法,将释放当前线程持有的monitor
,会将_owner
变量恢复为 null, _recursions
自减 1,同时该线程进入_WaitSet
队列中等待被唤醒。
若当前线程执行完毕也将释放 monitor(锁)
并复位变量的值,以便其他线程进入获取monitor(锁)
。
实例演示
为了讲解方便以下代码都被视为申请重量级锁,比如以下代码通过 3 个线程分别执行以下同步代码块:
private final Object object = new Object();
public void incCount() {
synchronized (lock) {
}
}
这段代码的锁对象是 object 对象头markword
字段中指针指向的 Monitor
对象,在 JVM 中会有一个 ObjectMonitor
对象与之对应,如下图所示:
分别使用 3 个线程来执行以上同步代码块。默认情况下,3 个线程都会先进入 ObjectMonitor
中的 _EntrySet
队列中,如下所示:
假设线程 2
首先通过竞争获取到了锁对象
,则ObjectMonitor
中的 _owner
指向 线程 2
,并将 _recursions
加 1。结果如下:
上图中 _owner
指向线程 2
表示它已经成功获取到锁(Monitor)对象
,其他线程只能处于阻塞(blocking)
状态。如果线程 2
在执行过程中调用 wait
方法,则线程 2
会释放锁(Monitor)对象
,以便其他线程进入获取锁(Monitor)对象
,_owner
变量恢复为 null,_recursions
做减 1 操作,同时线程 2
会添加到_WaitSet
集合,进入等待(waiting)
状态并等待被唤醒。结果如下:
然后线程 1
和 线程 3
再次通过竞争获取到锁(Monitor)对象
,则重新将_owner
指向成功获取到锁的线程。假设线程 1
获取到锁,如下:
如果在线程 1
执行过程中调用 notify
方法操作将线程 2
唤醒,则当前处于 _WaitSet
中的线程 2
会被重新添加到 _EntrySet
集合中,并尝试重新获取竞争锁(Monitor)对象
。但是 notify
操作并不会是使程 1 释放锁(Monitor)对象。结果如下:
当线程 1
中的代码执行完毕以后,同样会自动释放锁 将_owner
重置为null,以便其他线程再次获取锁对象。其实Monitor
是通过 _owner
表示是否已经得到锁了,这点类似AQS的state同步状态。
Jvm对 synchronized 的优化
从 jdk 6 开始,jvm对 synchronized
关键字做了多方面的优化,主要目的就是,避免 ObjectMonitor 的访问,减少“重量级锁”的使用次数,并最终减少线程上下文切换的频率 。其中主要做了以下几个优化:偏向锁、轻量级锁、重量级锁。
偏向锁
偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。偏向锁可以通过 -XX:+UseBiasedLocking 开启或者关闭。
偏向锁的具体实现就是在锁对象
的对象头中有个ThreadId
字段,默认情况下这个字段是空的,当第一次获取锁的时候,就将自身的 ThreadId
写入锁对象
对象头的 Mark Word
中的 ThreadId
字段内,将是否偏向锁的状态置为 01。这样下次获取锁的时候,直接检查ThreadId
是否和自身线程 Id
一致,如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段。提高了效率。由于偏向锁不会主动撤销,此时若有其他线程也获取锁对象,并且此时持有锁对象的线程没有执行完同步块,那么偏向锁将升级为轻量级锁。
其实偏向锁并不适合所有应用场景, 因为一旦出现锁竞争,偏向锁会被撤销,并膨胀成轻量级锁,而撤销操作(revoke)是比较重的行为,他会造成jvm 中Stop the word,即等待所有用户线程进入安全点后并阻塞,做一些全局性操作的行为。
只有当存在较多不会真正竞争的 synchronized 块时,才能体现出明显改善;因此实践中,还是需要考虑具体业务场景,并测试后,再决定是否开启/关闭偏向锁。
轻量级锁
有时候 JVM中会存在这种情形:对于同一块同步代码,虽然有多个不同线程会去执行,但是这些线程是在不同的时间段交替请求这把锁对象
,也就是不存在锁竞争
的情况。在这种情况下,锁会保持在轻量级锁的状态,从而避免重量级锁的阻塞和唤醒操作。
轻量级锁的工作流程,还是需要再次看下对象头中的 Mark Word。上文中已经提到,锁的标志位包含几种情况:00 代表轻量级锁、01 代表无锁(或者偏向锁)、10 代表重量级锁、11 则跟垃圾回收算法的标记有关。
当线程执行某同步代码时,JVM会在当前线程的·栈帧中开辟一块空间(Lock Record)作为该锁的记录,如下图所示:然后 jvm会尝试使用 CAS(Compare And Swap)操作
,将锁对象``的 Mark Word
拷贝到这块空间中,并且将锁记录中的 owner
指向 Mark Word
。结果如下:
当线程再次执行此同步代码块时,判断当前对象的 Mark Word
是否指向当前线程的栈帧
,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁。
轻量级锁
所适应的场景是线程交替执行同步块
的场合,如果存在同一时间访问同一锁
的场合,就会导致轻量级锁
膨胀为重量级锁
。
关于锁膨胀过程图如下:
假如T1线程和T2线程同时访问锁对象并通过CAS操作替换ThreadId,若T1线程CAS操作替换ThreadId,表明T1获得锁对象开始执行同步块,同时也表明T2线程CAS操作替换锁对象的ThreadId失败,T2线程竞争锁对象失败。此时JVM会暂停所有用户线程(stop the word),并判定持有锁对象的T1线程是否已释放锁对象(线程已经死亡或同步块执行退出
):
1、若T1线程已释放锁对象,则将锁对象撤销为原始的无锁状态,锁重新偏向新的线程。
2、若T1线程仍持有锁对象,那么T1线程所持有的偏向锁将升级为轻量级锁。T1线程的锁升级轻量级锁通过CAS操作自旋尝试获取锁对象,自旋是次数限定的,若在限定次数内不能获取到锁对象,则锁对象升级为重量级锁。
偏向锁使用了一种等到竞争出现才释放锁
的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
最后 偏向锁 只要出现线程竞争就会撤销,轻量级锁则是长时间竞争就会撤销。
总结
其中偏向锁
是通过CAS操作mark word
中的ThreadId(持有锁ThreadId)
避免真正的加锁,而轻量级锁
是通过自旋
等技术避免真正的加锁,获得锁表示mark word
中的owner指向持有锁线程栈Lock Record
,而重量级锁
才是获取锁
和释放锁
,重量级锁
通过对象内部的监视器(ObjectMonitor)
实现,其本质是依赖于底层操作系统的 Mutex Lock
实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,成本非常高。