关于Monitor对象在sychronized实现中的应用(转)

2020-01-07  本文已影响0人  施智沂

JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

4.1 Java对象头

锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。

长度

内容

说明

32/64bit

Mark Word

存储对象的hashCode或锁信息等。

32/64bit

Class Metadata Address

存储到对象类型数据的指针

32/64bit

Array length

数组的长度(如果当前对象是数组)

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下:

25 bit

4bit

1bit

是否是偏向锁

2bit

锁标志位

无锁状态

对象的hashCode

对象分代年龄

0

01

在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:

锁状态

25 bit

4bit

1bit

2bit

23bit

2bit

是否是偏向锁

锁标志位

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向互斥量(重量级锁)的指针

10

GC标记

11

偏向锁

线程ID

Epoch

对象分代年龄

1

01

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下: 

锁状态

25bit

31bit

1bit

4bit

1bit

2bit

cms_free

分代年龄

偏向锁

锁标志位

无锁

unused

hashCode

0

01

偏向锁

ThreadID(54bit) Epoch(2bit)

1

01

4.2 锁的升级

Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。

4.3 偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

4.4 轻量级锁

轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

对于上面的描述一直有几点不理解的:

1.第一段提到shronized实现利用的是Monitor对象,但是所有的对象都有Mark-word,所以sychronized利用的是Monior对象中的还是寄生对象(也就是与Monitor关联的对象)的?

2.后面提到的偏向锁,轻量级锁和重量级锁指定的锁对象都是Monior吗?

首先对于第一点,其实是由于没有搞清楚其实Monitor是一个辅助的锁对象,实际上是一个互斥量对象,也就是shcronized作为重量级锁使用的对象。对于第二点,是由于第一段起到了一定误导作用,JVM对于锁的优化才会出现偏向锁,轻量级锁和重量级锁三种情况,所以说shronized实现利用的是Monitor对象我感觉是很不妥的,因为只有在锁膨胀到重量级锁的时候对象头才会指向Monitor对象。

所以我更倾向于下面的说法:

synchronized的底层实现就用到了临界区和互斥锁(重量级锁的情况下)这两个概念。

出自一下博客:https://www.cnblogs.com/dennyzhangdd/p/6734638.html#_label2。

下面依照个人理解来描述下sychronized加锁和锁膨胀的流程:

1:由于java6之后偏向锁是开启的,也就是说当对一个代码块或者方法加上sychronized的时候先进入的是偏向锁获取状态。这时候依据4.1的步骤,通过CAS操作设置Mark-word格式如下

偏向锁

线程ID

Epoch

对象分代年龄

1

01

可见,偏向锁获取的阶段,只是指向了获取偏向锁的线程id,并没有用到Monitor对象。

2.当获取到某一个线程获取偏向锁的时候发现已经有线程获取了该对象的锁,当达到全局安全点(safepoint),获得偏向锁的线程被挂起,撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态;这取决于持有偏向锁的线程是否还处于活动状态,如果不处于活动状态,则转为无锁状态,如仍然活着这升级为轻量级锁,将Mark-word中的锁记录指向当前栈中的锁记录。

3.在轻量级锁获取阶段,Mark-word的格式如下,这时候Mark-word指向的对象也不是Monitor对象,而是线程栈帧中开辟的一块用于存储锁记录的空间,存储的内容为Mark-word中的内容(个人认为是无锁状态下对象的hashcode,分代年龄等信息),称为Displaced Mark-word。

轻量级锁

指向栈中锁记录的指针

00

4.在轻量级锁获取阶段,如果同时又其他线程竞争锁,这线程通过自旋来获取锁(循环CAS来修改Mark-word指向自己的锁记录地址),当自旋超时或者超过一定次数后,获取锁失败,导致锁膨胀为重量级锁,就是将Mark-word中的锁标志置为10,并指向Monitor对象,然后阻塞当前线程,当锁膨胀完成并返回对应的monitor时,并不表示该线程竞争到了锁。当持有轻量级锁的线程再次尝试进行CAS时,这时候已经被竞争线程修改,所以失败,释放锁,唤醒阻塞的线程,进入重量级锁阶段。

5.openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp源码如下:

1 ObjectMonitor() {

2    _header      = NULL;//markOop对象头

3    _count        = 0;

4    _waiters      = 0,//等待线程数

5    _recursions  = 0;//重入次数

6    _object      = NULL;//监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。

7    _owner        = NULL;//指向获得ObjectMonitor对象的线程或基础锁

8    _WaitSet      = NULL;//处于wait状态的线程,会被加入到wait set;

9    _WaitSetLock  = 0 ;

10    _Responsible  = NULL ;

11    _succ        = NULL ;

12    _cxq          = NULL ;

13    FreeNext      = NULL ;

14    _EntryList    = NULL ;//处于等待锁block状态的线程,会被加入到entry set;

15    _SpinFreq    = 0 ;

16    _SpinClock    = 0 ;

17    OwnerIsThread = 0 ;// _owner is (Thread *) vs SP/BasicLock

18    _previous_owner_tid = 0;// 监视器前一个拥有者线程的ID

19  }

以下引用自:https://blog.csdn.net/zqz_zqz/article/details/70233767

Monitor对象的结构如下:

Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;

Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;

Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;

OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;

Owner:当前已经获取到所资源的线程被称为Owner;

!Owner:当前释放锁的线程。

JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。

Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

综上:

JVM对Synchronized的优化,简单来说解决三种场景:

1)只有一个线程进入临界区,偏向锁

2)多个线程交替进入临界区,轻量级锁

3)多线程同时进入临界区,重量级锁

所以说Monitor对象只是实现sychronized对象的其中一部分(重量级锁部分)。

https://blog.csdn.net/super_x_man/article/details/81741073

https://baijiahao.baidu.com/s?id=1639857097437674576&wfr=spider&for=pc

https://www.jianshu.com/p/435c20a64da1

上一篇下一篇

猜你喜欢

热点阅读