synchronized和Monitor
粗略了解一下Java的锁是怎么用c++的代码实现的,以期获取一些Java默认的锁优化的知识。
锁的功能
锁之所以被称为monitor,是因为可以用它来monitor“线程访问资源”。锁的功能有两个:
- 互斥:加锁的资源只能被互斥访问;
- 协同:wait/notify,参考生产者 - 消费者;
锁的原理
提到锁就不得不提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的锁在字节码层面是monitorenter
和monitorexit
两个指令。
monitorenter
:每个对象都与一个 monitor 相关联。当且仅当 monitor 对象有一个所有者时才会被锁定。执行 monitorenter 的线程试图获得与 objectref 关联的 monitor 的所有权,如下所示:
- 若与 objectref 相关联的 monitor 计数为 0,线程进入 monitor 并设置 monitor 计数为 1,这个线程成为这个 monitor 的拥有者。
- 如果该线程已经拥有与 objectref 关联的 monitor,则该线程重新进入 monitor,并增加 monitor 的计数。
- 如果另一个线程已经拥有与 objectref 关联的 monitor,则该线程将阻塞,直到 monitor 的计数为零,该线程才会再次尝试获得 monitor 的所有权。
- 执行 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 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁“所带来的性能开销。
- 减小锁粒度:这个是我们做的!我们在代码实现时,尽量减少锁粒度,也能够优化锁竞争。
感想
偏向锁:以前陪我看月亮的时候,叫人家小甜甜!现在新人胜旧人,叫人家牛夫人!
优化都是针对当下场景的,脱离了场景,优化可能就成了阻碍。
参考: