文章

synchronized和Monitor

粗略了解一下Java的锁是怎么用c++的代码实现的,以期获取一些Java默认的锁优化的知识。

  1. 锁的功能
  2. 锁的原理
    1. monitorenter/monitorexit
    2. 线程竞争锁
    3. wait
    4. notify
    5. notifyAll
  3. Java Header
  4. 锁优化
    1. 无锁
    2. 偏向锁:做个标记,有人占了
      1. 废弃
    3. 轻量级锁:用CAS
    4. 重量级锁:人太多了,用队列慢慢来吧
    5. 常见锁优化方式
  5. 感想

锁的功能

锁之所以被称为monitor,是因为可以用它来monitor“线程访问资源”。锁的功能有两个:

锁的原理

提到锁就不得不提monitor。jdk里有一些涉及到同步的api,比如Object#wait会提及monitor的概念。

monitor是和Java object关联的一个对象,简而言之,Java对象(可以作为锁)由对象头(由jvm描述)、实例数据(使用Java编程时所关心的对象存储的数据)等组成。对象头里有指针,指向ObjectMonitor对象。

当把一个对象作为锁的时候,实际上使用的是它的对象头指向的ObjectMonitor对象,这个才是真正的锁对象

ObjectMonitor在jvm的代码ObjectMonitor.hpp里(c++):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
   _header = NULL;
   _count = 0; //记录个数
   _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 ;
}

ObjectMonitor拥有的数据结构包括但不限于:

  • _owner:该monitor所属的线程;
  • _recursions:如果该monitor是可重入的,标记重入次数;
  • _EntryList
  • _WaitSet:调用wait方法的线程的等待队列;

因此,锁会有entry set和wait set两个概念,这两个set里放的都是线程

所有获取锁的线程,如果没有获取成功,需要放入entry set等待。所有获取锁但执行条件不满足的线程,需要放入wait set等待。

如果形象类比:

  • 被锁资源:一个只能有一个线程进入的exclusive屋子;
  • entry set:类似于进入exclusive屋子前的大厅。所有要获取锁的线程如果获取失败,都要在此阻塞;
  • wait set:已获取锁的线程如果条件不满足,无法继续执行,调用wait方法,从exclusive屋子进入的wait room,只有被唤醒时才能有继续执行的可能。

二者的区别在于:entry set的线程是就绪状态,一旦可以抢到锁,就开始执行;wait set的线程是block状态,在等待资源,只能等待条件满足之后被唤醒(或者时间到了由os唤醒)

wait set听起来像epoll里介绍的等待socket的进程阻塞时,被放入的专属于socket的等待队列。看起来就是:被等资源都有一个自己的队列,专门用来放等自己就绪的线程/进程

现在再看wait/notify方法的调用就很合理了:通过monitor object去调用wait/notify,因为线程存放在monitor object的entry set和wait set里,所以由monitor object去阻塞/唤醒他们

monitorenter/monitorexit

Java的锁在字节码层面是monitorentermonitorexit两个指令。

monitorenter:每个对象都与一个 monitor 相关联。当且仅当 monitor 对象有一个所有者时才会被锁定。执行 monitorenter 的线程试图获得与 objectref 关联的 monitor 的所有权,如下所示:

  • 若与 objectref 相关联的 monitor 计数为 0,线程进入 monitor 并设置 monitor 计数为 1,这个线程成为这个 monitor 的拥有者。
  • 如果该线程已经拥有与 objectref 关联的 monitor,则该线程重新进入 monitor,并增加 monitor 的计数
  • 如果另一个线程已经拥有与 objectref 关联的 monitor,则该线程将阻塞直到 monitor 的计数为零,该线程才会再次尝试获得 monitor 的所有权。

monitorexit

  • 执行 monitorexit 的线程必须是与 objectref 引用的实例相关联的 monitor 的所有者
  • 线程将与 objectref 关联的 monitor 计数减一。如果计数为 0,则线程退出并释放这个 monitor。其他因为该 monitor 阻塞的线程可以尝试获取该 monitor。

monitorenter对应着jvm的实现代码(c++)里的ObjectMonitor#enter

线程竞争锁

实际是ObjectMonitor#enter,使用原子的compare and exchange尝试将当前ObjectMonitor的owner改成自己。如果成功,还要判断之前的owner是不是也是自己,是的话说明“重入”了,_recursions++。

wait

ObjectMonitor#wait,显然已经抢到锁了。所以线程获取锁对象的ObjectMonitor,将当前线程包装为一个ObjectWaiter,放入ObjectMonitor的_WaitSet挂起线程是使用park方法做的

notify

ObjectMonitor#notify,同样线程获取锁对象的ObjectMonitor,从它的_WaitSet取出一个线程,扔到_EntrySet(或者根据策略不同,扔到_cxq队列中,优先级比entry set高,但是不用涉及这么细),它又可以竞争锁了。或者直接把线程unpark唤醒

notifyAll

调用for循环取出wait set里的所有线程。

Ref:

  • https://www.cnblogs.com/kundeg/p/8422557.html

Java Header

说锁优化之前,先来了解一下java对象的header。

我们在对象里设置的东西,实际上是java对象的body。之所以object也有header(而且对Java使用者不可见),就像http的header一样,是为了保存一些内部使用的信息。

比如64bit jvm里,header结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|------------------------------------------------------------------------------------------------------------|--------------------|
|                                            Object Header (128 bits)                                        |        State       |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                                  Mark Word (64 bits)                         |    Klass Word (64 bits)     |                    |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |    OOP to metadata object   |       Normal       |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2 |    OOP to metadata object   |       Biased       |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                       ptr_to_lock_record:62                         | lock:2 |    OOP to metadata object   | Lightweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                     ptr_to_heavyweight_monitor:62                   | lock:2 |    OOP to metadata object   | Heavyweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                                                                     | lock:2 |    OOP to metadata object   |    Marked for GC   |
|------------------------------------------------------------------------------|-----------------------------|--------------------|

其中的State就标志着作为锁的这个对象当前的锁状态:

  • 偏向锁:markword保存thread id;
  • 轻量级锁:ptr_to_lock_record
  • 重量级锁:ptr_to_heavyweight_monitor这个heavy weight monitor,就是上面说的monitor

以前提及把“java object作为锁”,实际就是把java object的header里指向的monitor作为锁。1.6时有了锁优化,不到万不得已,不会使用monitor,此时的锁(偏向锁、轻量级锁)指的就是java object本身作为锁

锁优化

Java的线程是映射到os原生线程之上的,线程的阻塞和唤醒需要调用os的底层实现(对应到实现上,就是monitor实际使用了os的mutex来实现),涉及到用户态和核心态的切换,所以JDK1.6之前,synchronized不是很高效。1.6开始引入了锁优化,主要目的是不到迫不得已的时候不用锁,这样就能尽量减少系统调用的开销

当前monitor所用的锁机制,就记录在Java对象的对象头的Mark Word字段。

无锁

对象没有指向monitor,同时也没有使用偏向锁,就是没有任何锁的状态。没有锁的对象可以直接被修改。

偏向锁:做个标记,有人占了

对象没有指向monitor,且jvm开启了偏向锁(默认开启),则认为对象默认是拥有“偏向锁”。

所谓的偏向锁,面向的是无竞争的场景,即一直只有一个线程访问同步代码块。monitor用标志位标记一下这是个偏向锁,记录一下上次获得该锁的线程id。如果下次还是这个线程,就不用加锁,直接执行,永远不需要同步。这样就省去了entry set和wait set(毕竟此时根本不存在monitor),省去了同步的开销(没有monitor,也就用不到os的mutex)。直到有第二个线程尝试获取锁,这种状态被打破

代码为ObjectMonitor#fast_enter

偏向锁非常适合一直只有一个线程访问同步代码块的情况。

kafka consumer为了防止并发操作,使用一个单独的变量保存thread id,逻辑上几乎就是偏向锁的实现!

废弃

一个很好的问题是:为什么在无竞争的场景下,还要加锁?

主要是为了优化早期 Java 集合 API的程序(JDK 1.1),这些 API(Hasttable 和 Vector) 每次访问时都进行同步。但是JDK 1.2 引入了针对单线程场景的非同步集合(HashMap 和 ArrayList),JDK 1.5 针对多线程场景推出了性能更高的并发数据结构。经过这么多年的发展,使用Vector的代码应该少之又少了。

实际上,在无竞争条件下偏向锁确实能提升性能,但是现在应该很少会在无竞争下使用锁同步,需要使用锁的地方都是会出现竞争的地方,此时先加偏向锁再转向重量级锁反而多此一举,影响了性能

Furthermore, applications built around a thread-pool queue and worker threads generally perform better with biased locking disabled.

而且偏向锁的存在会使jvm的代码变得复杂,维护困难。所以从JDK 15起,偏向锁被废除了

偏向锁可以认为是时代的产物,针对早期jdk的同步集合进行优化。时过境迁,偏向锁反而成了阻碍。就像在高并发场景下使用乐观锁一样,反而会影响性能。不是乐观锁不好,而是没用对地方。

轻量级锁:用CAS

一个线程请求锁,发现monitor已经偏向过另一个人了……要使用锁竞争吗?先别,先使用CAS尝试一下,如果原来的线程结束了,不就可以顺利执行了嘛!尝试使用CAS将monitor的owner替换为自己:

  • 如果成功,变成了偏向自己的偏向锁;
  • 如果失败,说明另外那个线程还没有结束,此时偏向锁升级为轻量级锁

没指成功,自旋一小段,稍等一下,那边应该马上结束了。如果自旋一定次数还不行,说明事情没有这么简单,可能有不止两个线程都想竞争锁。此时,轻量级锁要升级为重量级锁

  • https://www.zhihu.com/question/53826114/answer/236363126

代码为ObjectMonitor#slow_enter

和偏向锁一样,CAS只有在用对的场景下才是高效的:在没有竞争的情况下,避免线程的阻塞和唤醒操作,减少线程切换的开销。当只有少数几个线程访问同一个对象时,轻量级锁能够提供较好的性能表现。但是如果存在大量线程竞争同一个对象的锁,轻量级锁的自旋过程会导致性能下降,还不如直接使用锁。

重量级锁:人太多了,用队列慢慢来吧

就是一开始JDK的锁实现,使用entry set和wait set。毕竟thread多的时候,只能用队列来存储这么多thread了。

其他参考:

  • https://segmentfault.com/a/1190000023315634
  • https://tech.meituan.com/2018/11/15/java-lock.html

常见锁优化方式

除了jdk做的上述锁优化努力,还有一些其他常见的锁优化方式:

  • 锁消除:编译器对锁的优化。JIT 编译器在动态编译同步块时,会使用逃逸分析技术,判断同步块的锁对象是否只能被一个对象访问,没有发布到其它线程。如果确认没有“逃逸”,JIT 编译器就不会生成 Synchronized 对应的锁申请和释放的机器码,就消除了锁的使用。
  • 锁粗化:JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁“所带来的性能开销。
  • 减小锁粒度:这个是我们做的!我们在代码实现时,尽量减少锁粒度,也能够优化锁竞争。

感想

偏向锁:以前陪我看月亮的时候,叫人家小甜甜!现在新人胜旧人,叫人家牛夫人!

优化都是针对当下场景的,脱离了场景,优化可能就成了阻碍。

参考:

本文由作者按照 CC BY 4.0 进行授权