学synchronized锁升级过程,吊打面试官

482 阅读13分钟

哈喽,大家好,我是IT老哥,我们今天来讲讲synchronized这个锁,可能你们第一印象是这个锁太笨了,太重了,谁用谁是傻子,如果你是这样想的话,那么面试的时候基本上就是凉凉了。因为jdk1.6之后对它进行了优化,它已经不是你们曾经认识的它了,中国有句古话,叫:士别三日当刮目相待,今天我们就来看看这个锁到底做了哪些优化。希望看到视频的你们动动小手给老哥点个赞。

synchronized有如下3种使用方式

  1. 普通同步方法,锁是当前实例对象
  2. 静态同步方法,锁是当前类的class对象
  3. 同步方法块,锁是括号里面的对象

当一个线程访问同步代码块时,需要获得锁才能执行,当退出或者抛出异常的时候要释放锁。那么是怎么实现的呢,我们来看一段代码

public class SynchronizedTest {
    public synchronized void test1(){


    }


    public void test2(){
        synchronized (this){


        }
    }
}

 我们用javap来分析一下编译后的class文件,看看synchronized如何实现的

从这个截图上可以看出,同步代码块是使用monitorenter和monitorexit指令实现的, monitorenter插入到代码块开始的地方,monitorexit插入到代码块结束的地方,当monitor被持有后,就处于锁定状态,也就是上锁了。

      下面我们深入分析一下synchronized实现锁的两个重要的概念:Java对象头和monitor

Java对象头:

 synchronized的锁是存在对象头里的,对象头由两部分数据组成:Mark Word(标记字段)、Klass Pointer(类型指针)

Mark Word存储了对象自身运行时数据,如hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID等等。是实现轻量级锁和偏向锁的关键,Klass Pointer是Java对象指向类元数据的指针,jvm通过这个指针确定这个对象是哪个类的实例

monitor:

每个Java对象从娘胎里出来就带着一把看不见的锁, 叫做内部锁或者monitor锁,我们可以把它理解成一种同步机制,

它是线程私有的数据结构,

         


monitor的结构如下:

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
  • EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
  • RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
  • Nest:用来实现重入锁的计数。
  • HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。 

锁优化篇:****

JDK1.6引入了大量的优化,如:自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁。锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。但是有一点,不可以进行锁降级

一、自旋锁:

线程频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,会给系统带来很大的压力。同时很多锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。  所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁,如果释放了,就可以抢到锁。那怎么等待呢?其实就是执行一段无意义的循环,大家是不是瞬间觉得好low,原来就是执行一段for循环,别急着下结论,我们继续来分析

执行一段无意义的循环。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。但是如果自旋很久都没抢到锁,那自旋就是浪费资源,说的难听点就是占着茅坑不拉屎。所以说,自旋等待的时间或者次数必须要有一个限度,如果超过了定义的时间仍然没有获取到锁,则把它挂起。

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;但是无论你怎么调整这些参数,都无法满足不可预知的情况。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

二、适应自旋锁

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

三、锁消除

锁消除用大白话来讲,就是在一段程序里你用了锁,但是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内部的加锁操作消除。

四、锁粗化

众所周知在使用锁的时候,要让锁的作用范围尽量的小,这样是为了在锁内执行代码尽可能少,缩短持有锁的时间,其他等待锁的线程能尽快拿到锁。在大多数的情况下这样做是正确的。但是连续加锁解锁操作,可能会导致不必要的性能损耗,比如下面这个for循环:

锁粗化前:
for (...) {
  synchronized (obj) {
    // 一些操作
  }
}
锁粗化后:
synchronized (this) {
 for (...) {
   // 一些操作
 }
}

大家应该能看出锁粗化大概是什么意思了。就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。即加锁解锁操作会移到for循环之外。

五、偏向锁

当我们创建一个对象时,该对象的部分Markword关键数据如下。

bit fields是否偏向锁锁标志位
hash001

从图中可以看出,偏向锁的标志位是“01”,状态是“0”,表示该对象还没有被加上偏向锁。(“1”是表示被加上偏向锁)。该对象被创建出来的那一刻,就有了偏向锁的标志位,这也说明了所有对象都是可偏向的,但所有对象的状态都为“0”,也同时说明所有被创建的对象的偏向锁并没有生效。

不过,当线程执行到临界区(critical p)时,此时会利用CAS(Compare and Swap)操作,将线程ID插入到Markword中,同时修改偏向锁的标志位。

所谓临界区,就是只允许一个线程进去执行操作的区域,即同步代码块。CAS是一个原子性操作

此时的Mark word的结构信息如下:

bit fields是否偏向锁锁标志位
threadIdepoch101

此时偏向锁的状态为“1”,说明对象的偏向锁生效了,同时也可以看到,哪个线程获得了该对象的锁。

偏向锁是jdk1.6引入的一项锁优化,其中的“偏”是偏心的偏。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。也就是说:在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不再需要去进行加锁或者解锁操作,而是会做以下的步骤:

  1. Load-and-test,也就是简单判断一下当前线程id是否与Markword当中的线程id是否一致.
  2. 如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码.
  3. 如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁”标志位的值。
  4. 如果还未偏向,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作。

释放锁 偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态或者轻量级锁的状态; 

安全点会导致stw(stop the word),导致性能下降,这种情况下应当禁用; 

查看停顿–安全点停顿日志

要查看安全点停顿,可以打开安全点日志,通过设置JVM参数 -

XX:+PrintGCApplicationStoppedTime 会打出系统停止的时间,

添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 这两个参数会打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多;

注意:安全点日志不能一直打开: 
1. 安全点日志默认输出到stdout,一是stdout日志的整洁性,二是stdout所重定向的文件如果不在/dev/shm,可能被锁。 
2. 对于一些很短的停顿,比如取消偏向锁,打印的消耗比停顿本身还大。 
3. 安全点日志是在安全点内打印的,本身加大了安全点的停顿时间。

所以安全日志应该只在问题排查时打开。 
如果在生产系统上要打开,再再增加下面四个参数: 
-XX:+UnlockDiagnosticVMOptions -XX: -DisplayVMOutput -XX:+LogVMOutput -XX:LogFile=/dev/shm/vm.log 
打开Diagnostic(只是开放了更多的flag可选,不会主动激活某个flag),关掉输出VM日志到stdout,输出到独立文件,/dev/shm目录(内存文件系统)。

此日志分三部分:
第一部分是时间戳,VM Operation的类型 


第二部分是线程概况,被中括号括起来 

total: 安全点里的总线程数 
initially_running: 安全点时开始时正在运行状态的线程数 
wait_to_block: 在VM Operation开始前需要等待其暂停的线程数

第三部分是到达安全点时的各个阶段以及执行操作所花的时间,其中最重要的是vmop

spin: 等待线程响应safepoint号召的时间;
block: 暂停所有线程所用的时间;
sync: 等于 spin+block,这是从开始到进入安全点所耗的时间,可用于判断进入安全点耗时;
cleanup: 清理所用时间;
vmop: 真正执行VM Operation的时间。
可见,那些很多但又很短的安全点,全都是RevokeBias, 高并发的应用会禁用掉偏向锁。

jvm开启/关闭偏向锁

开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

六、轻量级锁

自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record(Lock Record:JVM检测到当前对象是无锁状态,则会在当前线程的栈帧中创建一个名为LOCKRECOD表空间用于copy Mark word 中的数据),如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。

缺点:同自旋锁相似:如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁,那么维持轻量级锁的过程就成了浪费。

七、重量级锁

        轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁
当轻量级所经过锁撤销等步骤升级为重量级锁之后,它的Markword部分数据大体如下

bit fields锁标志位
指向Mutex的指针10

为什么说重量级锁开销大呢

主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
这就是说为什么重量级线程开销很大的。

互斥锁(重量级锁)也称为阻塞同步悲观锁

八、总结

        偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。

        一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个
线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将
对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。


一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。


轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

给个[在看],是对IT老哥最大的支持