并发编程(六)java中的monitor机制和Sychronized锁升级的过程

管程/监视器

上一篇文章提到了Sychronized重量级锁的时候是基于操作系统metux,其实Java中sychronized是一种monitor机制来保证并发的。可以称为管程或监视器。

同步方法和同步代码块底层都是通过monitor来实现同步的。每个对象都与一个monitor相关联。

上篇也提到同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。可以参考官网对于sychronized的描述:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.11.8

                1.同步方法和同步代码块都是通过monitor锁实现的。

      2.两者的区别:同步方式是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit指令来实现

      3.每个java对象都会与一个monitor相关联,可以由线程获取和释放。

      4.如果线程没有获取到monitor会被阻塞。

      5.monitor通过维护一个计数器来记录锁的获取,重入,释放情况。

管程首先由霍尔(C.A.R.Hoare)和汉森(P.B.Hansen)两位大佬提出,是一种并发控制机制,由编程语言来具体实现。它负责管理共享资源以及对共享资源的操作,并提供多线程环境下的互斥和同步,以支持安全的并发访问。“共享资源以及对共享资源的操作”在操作系统理论中称为critical section,即临界区。

管程能够保证同一时刻最多只有一个线程访问与操作共享资源(即进入临界区)。在临界区被占用时,其他试图进入临界区的线程都将等待。如果线程不能满足执行临界区逻辑的条件(比如资源不足),就会阻塞。阻塞的线程可以在满足条件时被唤醒,再次试图执行临界区逻辑。

我们上篇中看到sychronized反汇编之后的monitorenter,monitorexit管控的区域就是临界区。

操作系统原生提供了信号量(Semaphore)和互斥量(Mutex),开发者用它们也能实现与管程相同的功能,那为什么还需要管程呢?因为信号量和互斥量都是低级原语,使用它们时必须手动编写wait和signal逻辑,所以要特别小心。一旦wait/signal逻辑出错,分分钟造成死锁。管程就可以对开发者屏蔽掉这些细节,在语言内部实现,更加简单易用。

管程并不像它的名字所说的一样是个简单的程序,而是由以下3个元素组成:

  • 临界区; 就是需要加锁的共享区域,在java中是sychronized区域汇编之后是monitorenter,monitorexit管控的区域。
  • 条件变量,用来维护因不满足条件而阻塞的线程队列。注意,条件由开发者在业务代码中定义,条件变量只起指示作用,亦即条件本身并不包含在条件变量内;
  •                   例如调用wait方法的判断条件就是条件变量,它需要在加锁的前提下使用也就是在线程获取锁之后并不会马上进行业务操作还要判断这个条件是否满足,如果不满足就释放锁进入到等待队列(有些称为阻塞队列其实不完全一样)
  •                   注意wait方法是在while条件下而不是if下,后面会说明。
  • Monitor对象,维护管程的入口、临界区互斥量(即锁)、临界区和条件变量,以及条件变量上的阻塞和唤醒操作。 每个java对象都会与一个monitor对象关联,这个java对象就是我们sychronized锁定的对象,同步代码块中是我们传入的对象,同步                          方法是当前对象。

Monitor对象

那这个Monitor对象在哪里呢。java对象又是怎么和这个监视器对象关联的呢?这里牵涉到对象布局的知识了,之前记录JVM知识的时候也提到过。

对象分为:对象头,实例数据,对齐填充。其中对象头又包括:Mark Word, 类型指针,如果是数组还有数组长度。可以参考之前的博客:blog.csdn.net/A7_A8_A9/article/details/105730007

 

 

我们主要看对象头:

HotSpot虚拟机的对象头包括两部分内容,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有锁,偏向线程id,偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启指针压缩)中分别为32bit,64bit,官方称为“ Mark Word”。对象需要存储的运行时数据很多,其实已经超过32位,64位BitMap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit中25bit用于存储对象哈希码值,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0。然而在64位机中存储位数见下图:

 

  通过内存布局结果的输出可以看下对象头中存储的内容:

 

 输出的第一行就是Mark Word ,但是后面的 32位2进制数据,最后两位却是 00,按照上面的说明就是轻量级锁了,其实这里输出的结果顺序和正常的顺序是反的,因为计算机中有大端小端的说法,所以正常输出的第一个字节应该是是最后的一个字节,就是最后两位正常是01,也就是无锁的状态,至于为什么hashcode位都是0,这是因为hashcode在jvm中是懒加载的。

 如果加锁之后输出会是什么样呢?

 

 发现加锁前后,锁标识位都是01,是否都为偏向锁是1,加锁之后是这样容易理解,是因为有线程获取锁了,但是加锁之前为什么也是这样呢?注意看下加锁之前的线程id的位置都是0,也就是没有偏向的线程Id叫匿名偏向,这里是偏向锁的状态,只是表示这个锁是可以可以想偏向锁转移。

 

 

 计算了hashcode之后,再次获取锁,锁就会变成轻量级锁。。。。。

 

 在不同的锁标志位的时候,MarkWord存储的值是不同的。我们先从重量级锁看起来,因为在jdk1.6之前sychronized上来就是重量级锁,偏向锁,轻量级锁都是后来优化后的。

在重量级锁的时候,MarkWord只有锁标志位和一个指向互斥量的指针就是monitor对象(也称为管程或监视器锁)的起始地址。就是当前与对象关联的Monitor 对象。

 monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的),还有一个objectWaiter是对等待线程的封装.

 下面解释objectMonitor中属性的含义:

_header

定义:

volatile markOop   _header;       // displaced object header word - mark

说明:

  _header是一个markOop类型,markOop就是对象头中的Mark Word

 _count  定义:
volatile intptr_t  _count;        // reference count to prevent reclaimation/deflation
                                    // at stop-the-world time.  See deflate_idle_monitors().
                                    // _count is approximately |_WaitSet| + |_EntryList|
说明:抢占该锁的线程数 约等于 WaitSet.size + EntryList.size
 _waiters

 定义:

volatile intptr_t  _waiters;      // number of waiting threads

说明:等待线程数

_recursions   

定义:

volatile intptr_t  _recursions;   // recursion count, 0 for first entry

说明:锁重入次数

 _object  

定义:

void*     volatile _object;       // backward object pointer - strong root

说明:监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中

_owner 

定义:

void *  volatile _owner;          // pointer to owning thread OR BasicLock

说明:

指向获得ObjectMonitor对象的线程或基础锁

 _WaitSet

定义:

ObjectWaiter * volatile _WaitSet; // LL of threads wait()ing on the monitor

说明:处于wait状态的线程,被加入到这个linkedList

 _WaitSetLock

定义:

volatile int _WaitSetLock;        // protects Wait Queue - simple spinlock

说明:protects Wait Queue - simple spinlock ,保护WaitSet的一个自旋锁(monitor大锁里面的一个小锁,这个小锁用来保护_WaitSet更改)

_Responsible 

定义:

Thread * volatile _Responsible

说明:未知 参考:https://www.jianshu.com/p/09de11d71ef8

 _succ

定义:

  Thread * volatile _succ ;          // Heir presumptive thread - used for futile wakeup throttling

说明:当锁被前一个线程释放,会指定一个假定继承者线程,但是它不一定最终获得锁。参考:https://www.jianshu.com/p/09de11d71ef8

 _cxq

定义:

  ObjectWaiter * volatile _cxq ;    // LL of recently-arrived threads blocked on entry.
                                    // The list is actually composed of WaitNodes, acting
                                    // as proxies for Threads.

说明:ContentionList 参考:https://www.jianshu.com/p/09de11d71ef8

 FreeNext  

定义:

ObjectMonitor * FreeNext ;        // Free list linkage

说明:未知

_EntryList   

定义:

ObjectWaiter * volatile _EntryList ;     // Threads blocked on entry or reentry.

说明:未获取锁被阻塞或者被wait的线程重新进入被放入entryList中

 _SpinFreq  

定义:

volatile int _SpinFreq ;          // Spin 1-out-of-N attempts: success rate

说明:未知 可能是获取锁的成功率

 _SpinClock

定义:

volatile int _SpinClock ;

说明:未知

 OwnerIsThread

定义:

int OwnerIsThread ;               // _owner is (Thread *) vs SP/BasicLock

说明:当前owner是thread还是BasicLock

 _previous_owner_tid

定义:

volatile jlong _previous_owner_tid; // thread id of the previous owner of the monitor

说明:当前owner的线程id

 

第二个图解释如下:

当多个线程一起访问某个对象监视器的时候,对象监视器会将这些请求存储在不同的容器中。

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

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

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

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

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

6、  !Owner:当前释放锁的线程

  具体流程: 

  1. 线程访问同步代码,需要获取monitor锁
  2. 线程被jvm托管
  3. jvm获取充当临界区锁的java对象
  4. 根据java对象对象头中的重量级锁 ptr_to_heavyweight_monitor指针找到objectMonitor
  5. 将当前线程包装成一个ObjectWaiter对象
  6. 将ObjectWaiter假如_cxq(ContentionList)队列头部
  7. _count++

  8. 如果owner是其他线程说明当前monitor被占据,则当前线程阻塞。如果没有被其他线程占据,则将owner设置为当前线程,将线程从等待队列中删除,count--。
  9. 当前线程获取monitor锁,如果条件变量不满足,则将线程放入WaitSet中。当条件满足之后被唤醒,把线程从WaitSet转移到EntrySet中。
  10. 当前线程临界区执行完毕
  11. Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交个OnDeck,OnDeck需要重新竞争锁

锁升级的过程

从上面的MarkWord中可以知道,synchronized锁有四种状态,无锁,偏向锁,轻量级锁,重量级锁,这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态

 

 

1:偏向锁

因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁原理和升级过程

当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头(Monitor Object)中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁撤销:

1:在一个安全点停止拥有锁的线程。

2:遍历线程栈,如果存在锁的记录的话,需要修复锁记录和MarkWord,使其变成无锁状态。

3:唤醒当前线程,将当前锁升级为轻量级锁。

2:轻量级锁

为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

 

轻量级锁原理和升级过程

线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;

如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。 自旋锁简单来说就是让线程2在循环中不断CAS

但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

几种锁的对比:

 


### 重量级锁降级机制的实现原理

**HotSpot VM内置锁的同步机制简述:**

HotSpot VM采用三中不同的方式实现了对象监视器——Object Monitor,并且可以在这三种实现方式中自动切换。偏向锁通过在Java对象的对象头markOop中install一个JavaThread指针的方式实现了这个Java对象对此Java线程的偏向,并且只有该偏向线程能够锁定Lock该对象。但是只要有第二个Java线程企图锁定这个已被偏向的对象时,偏向锁就不再满足这种情况了,然后呢JVM就将Biased Locking切换成了Basic Locking(基本对象锁)。Basic Locking使用CAS操作确保多个Java线程在此对象锁上互斥执行。如果CAS由于竞争而失败(第二个Java线程试图锁定一个正在被其他Java线程持有的对象),这时基本对象锁因为不再满足需要从而JVM会切换到膨胀锁 -ObjectMonitor。不像偏向锁和基本对象锁的实现,重量级锁的实现需要在Native的Heap空间中分配内存,然后指向该空间的内存指针会被装载到Java对象中去。这个过程我们称之为锁膨胀。

**降级的目的和过程:**

因为BasicLocking的实现优先于重量级锁的使用,JVM会尝试在SWT的停顿中对处于“空闲(idle)”状态的重量级锁进行降级(deflate)。这个降级过程是如何实现的呢?我们知道在STW时,所有的Java线程都会暂停在“安全点(SafePoint)”,此时VMThread通过对所有Monitor的遍历,或者通过对所有依赖于*MonitorInUseLists*值的当前正在“使用”中的Monitor子序列进行遍历,从而得到哪些未被使用的“Monitor”作为降级对象。

**可以降级的Monitor对象:**

重量级锁的降级发生于STW阶段,降级对象就是那些仅仅能被VMThread访问而没有其他JavaThread访问Monitor对象。

 

用锁的最佳实践

错误的加锁姿势1

synchronized (new Object())


每次调用创建的是不同的锁,相当于无锁

错误的加锁姿势2

private Integer count;
synchronized (count)


String,Boolean在实现了都用了享元模式,即值在一定范围内,对象是同一个。所以看似是用了不同的对象,其实用的是同一个对象。会导致一个锁被多个地方使用

Java常量池详解,秒懂各种对象相等操作

正确的加锁姿势

// 普通对象锁
private final Object lock = new Object();
// 静态对象锁
private static final Object lock = new Object();

 

详细锁升级过程:

 

 

 

 

 

 

 

 

 锁的粗化:

扩大加锁的范围,JVM会判断如果多个锁代码块用的是同一把锁,会把这些代码块合并到一起用一次锁。

锁的消除:

锁的对象如果是局部变量,那么这个锁也就没有了意义,JVM会把它优化掉,不用锁。

锁的膨胀过程。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

posted @ 2021-04-17 14:02  蒙恬括  阅读(886)  评论(1编辑  收藏  举报