深入理解synchronized锁升级过程

1.简介

在Java高并发系统中,我们常常需要使用多线程技术来提高系统的运行速度,而多线程带来的数据安全问题就是我们必须要解决的问题。在Java中,可以使用synchronized关键字来实现多线程并发中的数据安全问题。

这里简单介绍下synchronized的三种用法:

  • 修饰实例方法:以实例对象作为锁,进入同步代码前需要获得当前实例对象的锁
  • 修饰类方法(static修饰的方法):以类对象为锁,进入同步代码块前需要获得当前类对象的锁
  • 修饰代码块:需要指定一个锁对象(既可以是实例对象,也可以是)

即同步方法默认用 this 或者当前类 class 对象作为锁;同步代码块可以选择以什么来加锁,比同步方法要更细颗粒度,我们可以选择只同步会发生同步问题的部分代码而不是整个方法。

 

2.synchronized的升级

这里先介绍下用户态和内核态的概念,方便大家理解。

2.1用户态和内核态

上面提到了,重量级锁获取锁和释放锁需要经过操作系统,这是一个重量级操作,这句话是什么意思呢?

  • 内核态:其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行
  • 用户态:用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。

2.2用户态到内核态的切换

用户程序都是运行在用户态的,但是有时候程序确实需要做一些内核的事情,例如从硬盘读取数据,或者从硬盘获取输入,而唯一可以做这些事情的就是操作系统即内核态(synchronized中依赖的monitor也需要依赖操作系统完成,因此需要用户态到内核态的切换)所以程序就需要先向操作系统请求以程序的名义来执行这些操作。

这时候就需要:将用户态程序切换到内核态,但是不能控制在内核态中执行的命令 这部分先不做太多解释,需要知道的是synchronized是依赖操作系统实现的,因此在使用synchronized同步锁的时候需要进行用户态到内核态的切换。

2.3synchronized 内核态切换

简单来说在JVM中synchronized重量级锁的底层原理monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。

2.3为什么优化synchronized 

在JDK1.5之前,synchronized是重量级锁,1.6以后对其进行了优化,有了一个 无锁-->偏向锁-->自旋锁-->重量级锁 的锁升级的过程,而不是一上来就是重量级锁了,为什么呢?因为重量级锁获取锁和释放锁需要经过操作系统,是一个重量级的操作。对于重量锁来说,一旦线程获取失败,就要陷入阻塞状态,并且是操作系统层面的阻塞,这个过程涉及用户态到核心态的切换,是一个开销非常大的操作。而研究表明,线程持有锁的时间是比较短暂的,也就是说,当前线程即使现在获取锁失败,但可能很快地将来就能够获取到锁,这种情况下将线程挂起是很不划算的行为。所以要对"synchronized总是启用重量级锁"这个机制进行优化。

 

3.synchronized升级原理

3.1 Java对象在内存中的布局

在Java虚拟机中,普通对象在内存中分为三块区域:对象头、实例数据、对齐填充数据,而对象头包括markword(8字节)和类型指针(开启压缩指针4字节,不开启8字节,如果是32g以上内存,都是8字节),实例数据就是对象的成员变量,padding就是为了保证对象的大小为8字节的倍数,将对象所占字节数补到能被8整除。数组对象比普通对象在对象头位置多一个数组长度。

如下图:

 

例如,Object o = new Object();(16g,开启指针压缩)在内存中占了 8(markWord)+4(classPointer)+4(padding)=16字节。

3.2 Mark Word

我们今天关注的重点是对象头中的markWord。它里面到底存了哪些信息呢?

请看下图(Hotspot 64位虚拟机的实现):

 

3.3 synchronized升级

3.3.1 无锁态
偏向锁位、锁标志位的值为:0 01,此时对象是没有做任何同步限制的,为什么会有这个状态在下面的偏向锁中会跟大家介绍。

3.3.2 偏向锁
偏向锁位、锁标志位的值为:1 01

有研究表明,其实在大部分场景都不会发生锁资源竞争,并且锁资源往往都是由一个线程获得的。如果这种情况下,同一个线程获取这个锁都需要进行一系列操作,比如说CAS自旋,那这个操作很明显是多余的。偏向锁就解决了这个问题。其核心思想就是:一个线程获取到了锁,那么锁就会进入偏向模式,当同一个线程再次请求该锁的时候,无需做任何同步,直接进行同步区域执行。这样就省去了大量有关锁申请的操作。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。

偏向锁加锁过程:

 

 

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则判断线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁的适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁。在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。所以一般JVM并不是一开始就开启偏向锁的,而是有一定的延迟,这也就是为什么会有无锁态的原因。可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁。偏向锁撤销导致的stw

通过加偏向锁的方式可以看到,对象中记录了获取到对象锁的线程ID,这就意味如果短时间同一个线程再次访问这个加锁的同步代码或方法时,该线程只需要对对象头Mark Word中去判断一下是否有偏向锁指向它的ID,有的话就继续执行逻辑了,没有的话,会CAS尝试获得锁,如果持有锁的线程在全局安全点检查时,不需要再使用该锁了则获取成功,程序继续执行,反之则获取锁失败,撤销偏向状态,升级为轻量级锁,即自旋锁。

3.3.3 轻量级锁(自旋锁)

当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,销偏向锁状态,将锁对象markWord中62位修改成指向自己线程栈中Lock Record的指针(CAS抢)执行在用户态,消耗CPU的资源(自旋锁不适合锁定时间长的场景、等待线程特别多的场景),此时锁标志位为:00

自旋策略
JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。

自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,叫做自适应自旋锁。他的自旋次数是会变的,我用大白话来讲一下,就是线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 大家现在觉得没这么low了吧。

轻量级锁的加锁过程:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
  2. 拷贝对象头中的Mark Word复制到锁记录中;
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word中的62位更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。此时为了提高获取锁的效率,线程会不断地循环去获取锁, 这个循环是有次数限制的, 如果在循环结束之前CAS操作成功, 那么线程就获取到锁, 如果循环结束依然获取不到锁, 则获取锁失败, 对象的MarkWord中的记录会被修改为指向互斥量(重量级锁)的指针,锁标志的状态值变为10,线程被挂起,后面来的线程也会直接被挂起。

轻量级锁的释放

释放锁线程视角:由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。因为重量级锁被修改了,所有display mark word和原来的markword不一样了。
怎么补救,就是进入mutex前,compare一下obj的markword状态。确认该markword是否被其他线程持有。此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。

尝试获取锁线程视角:如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。

从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,这里我不建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。

3.3.4 重量级锁

此时锁标志位为:10。前面我们提到的markWord,若是重量锁,对象头中还会存在一个监视器对象,也就是Monitor对象。这个Monitor对象就是实现synchronized的一个关键。

在Java虚拟机(HotSpot)中,Monitor对象其实就是ObjectMonitor对象,这个对象是一个C++对象,定义在虚拟机源码中。

ObjectMonitor有比较多的属性,但是比较重要的属性有四个:

  • _count:计数器。用来记录获取锁的次数。该属性主要用来实现重入锁机制。
  • _owner:记录着当前锁对象的持有者线程。
  • _WaitSet:队列。当一个线程调用了wait方法后,它会释放锁资源,进入WaitSet队列等待被唤醒。
  • _EntryList:队列。里面存放着所有申请该锁对象的线程。

所以一个线程获取锁对象的流程如下:

  1. 判断锁对象的锁标志位是重量级锁,于是想要获取Monitor对象锁。
  2. 如果Monitor中的_count属性是0,说明当前锁可用,于是把 _owner 属性设置为本线程,然后把 _count 属性+1。这就成功地完成了锁的获取。
  3. 如果Monitor中的_count属性不为0,再检查 _owner 属性,如果该属性指向了本线程,说明可以重入锁,于是把 _count 属性再加上1,实现锁的冲入。
  4. 如果 _owner 属性指向了其他线程,那么该线程进入 _EntryList 队列中等待锁资源的释放。
  5. 如果线程在持有锁的过程中调用了wait()方法,那么线程释放锁对象,然后进入 _WaitSet 队列中等待被唤醒。

4.synchronized可重入

synchronized是可重入锁,那么它是如何实现可重入的呢?其实上面详细的过程已经说过了,这里再总结一下(之前的判断逻辑就省略掉了):

  • 偏向锁:检查markWord中的线程ID是否是当前线程,如果是的话就获取锁,继续执行代码;
  • 轻量级锁:检查markWord中指向lockRecord的指针是否是指向当前线程的lockRecord,是的话继续执行代码;
  • 重量级锁:检查_owner属性,如果该属性指向了本线程,_count属性+1,并继续执行代码。

5.总结

synchronized的执行过程: 
1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁 
2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1 
3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。 
4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁 
5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。 
6. 如果自旋成功则依然处于轻量级状态。 
7. 如果自旋失败,则升级为重量级锁。

上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;

在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;

synchronized锁升级实际上是把本来的悲观锁变成了 在一定条件下 使用无锁(同样线程获取相同资源的偏向锁),以及使用乐观(自旋锁 cas)和一定条件下悲观(重量级锁)的形式。

偏向锁:适用于单线程适用锁的情况,如果线程争用激烈,那么应该禁用偏向锁。

轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似)

重量级锁:适用于竞争激烈的情况

6.锁优化

以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;

6.1锁消除 

锁消除用大白话来讲,就是在一段程序里你用了锁,但是jvm检测到这段程序里不存在共享数据竞争问题,也就是变量没有逃逸出方法外,这个时候jvm就会把这个锁消除掉

我们程序员写代码的时候自然是知道哪里需要上锁,哪里不需要,但是有时候我们虽然没有显示使用锁,但是我们不小心使了一些线程安全的API时,如StringBuffer、Vector、HashTable等,这个时候会隐形的加锁。比如下段代码:

    public void sbTest(){
        StringBuffer sb= new StringBuffer();
        for(int i = 0 ; i < 10 ; i++){
            sb.append(i);
        }
 
        System.out.println(sb.toString());
    }

上面这段代码,JVM可以明显检测到变量sb没有逃逸出方法sbTest()之外,所以JVM可以大胆地将sbTest内部的加锁操作消除。

6.2 减少锁的时间

不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

6.3减小锁的粒度

它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间(如ConcurrentHashMap、LinkedBlockingQueue、LongAdder);

6.4锁粗化

大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度; 在以下场景下需要粗化锁的粒度: 
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

6.5使用读写锁

ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。

参考文章:

https://zhuanlan.zhihu.com/p/69554144

https://www.cnblogs.com/linghu-java/p/8944784.html

偏向锁撤销导致stw

https://blog.csdn.net/hnjsjsac/article/details/105778846?utm_medium=distribute.pc_relevant_t0.none-task-blog-OPENSEARCH-1.compare&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-OPENSEARCH-1.compare

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 点我我会动 设计师:上身试试 返回首页