深入理解synchronized和锁优化

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),元数据前面有介绍过,这里不做详细介绍了。_markmarkWord 类型数据,markWord描述了对象的头部,其中主要存储了对象的hashCode、分代年龄、锁标志位、是否偏向锁等相关信息。

下图表示32 位 JVM的 Mark Word 的默认存储结构如下:

object_head.png

默认情况下,没有线程进行加锁操作,对象中的 Mark Word·处于无锁状态(锁标志位为01,是否偏向锁为0)。
JVM为了空间效率,mark word 被设计成为一个非固定的数据结构,以便存储更多的有效数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 mark word 默认存储结构外,还有如下可能变化的结构:

object_head2.png

从图中可以看出,根据"锁标志位”以及"是否为偏向锁",Java的内置锁可以分为以下几种状态:
lock_state.png

在jdk6之前,并没有轻量级锁偏向锁,只有重量级锁,也就是通常所说 synchronized对象锁锁标志位为 10

从图中的描述可以看出:当锁是重量级锁时,对象头中 mark word会用 30 bit 来指向一个“互斥量”,而这个互斥量就是Monitor,也就是说对象头中会保存一个指针指向ObejctMonitor对象

实际上 synchronized 关键字在字节码中,对应的是 monitorentermonitorexit指令,当 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对象与之对应,如下图所示:

1.png

分别使用 3 个线程来执行以上同步代码块。默认情况下,3 个线程都会先进入 ObjectMonitor 中的 _EntrySet 队列中,如下所示:

2.png

假设线程 2首先通过竞争获取到了锁对象,则ObjectMonitor 中的 _owner 指向 线程 2,并将 _recursions加 1。结果如下:

3.png

上图中 _owner 指向线程 2 表示它已经成功获取到锁(Monitor)对象,其他线程只能处于阻塞(blocking)状态。如果线程 2在执行过程中调用 wait 方法,则线程 2释放锁(Monitor)对象,以便其他线程进入获取锁(Monitor)对象_owner变量恢复为 null,_recursions 做减 1 操作,同时线程 2 会添加到_WaitSet 集合,进入等待(waiting)状态并等待被唤醒。结果如下:

4.png

然后线程 1线程 3再次通过竞争获取到锁(Monitor)对象,则重新将_owner 指向成功获取到锁的线程。假设线程 1获取到锁,如下:

5.png

如果在线程 1 执行过程中调用 notify方法操作将线程 2 唤醒,则当前处于 _WaitSet 中的线程 2 会被重新添加到 _EntrySet 集合中,并尝试重新获取竞争锁(Monitor)对象。但是 notify操作并不会是使程 1 释放锁(Monitor)对象。结果如下:

6.png

线程 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)作为该锁的记录,如下图所示:
轻量级锁.png

然后 jvm会尝试使用 CAS(Compare And Swap)操作,将锁对象``的 Mark Word拷贝到这块空间中,并且将锁记录中的 owner指向 Mark Word。结果如下:

轻量级锁.png

当线程再次执行此同步代码块时,判断当前对象的 Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁。

轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁

关于锁膨胀过程图如下:


锁膨胀.png

假如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实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,成本非常高。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容