剑指Offer(锁)——synchronized的底层实现原理

首先来说说实现synchronized的基础:

  1. Java对象头
  2. Monitor

然后接下来就对这二者进行详细的讲解。

Hospot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充。

这里主要来说说对象头。

对象头的结构如下:

在这里插入图片描述

  • Mark Word

在这里插入图片描述

然后再来介绍一下Monitor,Monitor是每一个Java对象都天生携带的一把锁,只不过这把锁是一把看不见的锁。被称作Monitor锁,管程,监视器锁,我们可以把它理解成一个同步工具,可以描述为一种同步机制。

而synchronized正是通过Monitor来获取到对象的锁的。

在这里插入图片描述

然后接下来,进一步的去说明一下Synchronized在底层方面的字节码实现原理。

在这里插入图片描述
我们就写一个简单的同步代码块和同步方法,使用javac去进行编译,再用javap -verbose查看编译出的.class文件的字节码。

<1>我们先来看同步代码块的字节码:

在这里插入图片描述

我们可以发现,monitor代表着获取到锁和锁释放的过程,而中间的过程便是获取到对象,进行引用计数,这时,当我们使用synchronized获取到对象的私有权的时候,就可以重入monitor

重入:从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会进入阻塞状态,但当一个线程再次请求自己持有的对象锁的临界资源的时候,这种情况就属于重入。

然后重新解释上面的话:

也就是说,如果其他的线程先当前线程获取到了monitor的使用权,那么当前线程就会阻塞,当monitor释放之后,此时计数器为0,其他线程才有机会重入进去,获取锁。

<2>我们再来看同步方法的字节码

在这里插入图片描述

我们这里可以发现没有monitor的获取和释放的过程,是因为这里有一个ACC_SYNCHRONIZED

如果一个方法被这个标志了,就代表拥有互斥锁,一次性只能由一个线程去访问这个同步方法,而如果这个方法会导致抛出异常的话,也会去释放锁,除此之外,都是正常结束之后释放锁。

到这里,sync在同步代码块和同步方法的字节码实现原理就解释完了。

那为什么有人会对synchronized嗤之以鼻呢?

  1. 早期版本中,synchronized属于重量级(监视器)锁,因为monitor是属于底层锁,依赖于MutexLock。所以使用代价很高。
  2. 线程之间的切换需要从用户态切换到内核态,开销很大。

后来Hospot JVM在JDK6进行了很大的提升,连同互斥锁等其他各种锁也进行了很高的性能提升。

例如:

  1. Adaptive Spinning 自适应自旋锁
  2. Lock Eilminate 锁消除
  3. Lock Coarsening 锁初始化
  4. Lightweight Locking 轻量级锁
  5. Biased Locking 偏斜锁

紧接着,我们先来说说自旋锁和自适应自旋锁

  • 自旋锁与自适应锁

自旋锁:

在许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,所以我们可以使用自旋锁,在不放弃CPU资源的情况下,让线程执行忙循环等待锁释放之后,再获取锁,缺点也很明显,如果长时间获取不到锁,就会带来很多性能上的消耗。

所以,为了改善这明显的不足,就出现了自适应自旋锁的存在。

自适应自旋锁:

在JDK6中出现了自适应自旋锁,这就代表,自旋的次数不再是固定的了,它会由前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定,比如说,前一个线程,自旋之后获取到了锁,那么就意味着,现在获取到锁的可能性会比较大,就会一直自旋,反之,就会放弃cpu资源,过会再请求。

  • 锁消除

这是另一种优化的锁,而且优化的更加彻底,在JIT即时编译的时候,会对运行的上下文进行扫描,去除掉不可能存在竞争的锁。

然后写段代码举例说明一下:

在这里插入图片描述

上面我们说了很多关于优化锁的典型案例,现在我们再说一个反面案例,锁粗化。

  • 锁粗化

我们在使用互斥锁的时候,都知道,要让锁的范围尽量小,因为加锁和释放锁的性能消耗是很大的,所以我们要注意这一点,但是也会有情况是希望在循环中加锁,这就会导致不可避免的麻烦,我们能想到的解决方法就是,让锁将整个循环都包起来,避免重复加锁和释放锁。

说完了以上几种经典的案例之后,我们再来说一个比较关键的问题,也就是关于synchronized的四种状态:无锁,偏斜锁,轻量级锁和重量级锁。也可以理解成四种level,因为当竞争级别上升的时候,锁的级别也会上升,反之,也会进行锁降级,如果有闲置的monitor就降级了其实。

我们将锁的升级,也称之为锁的膨胀:

锁膨胀的方向:无锁->偏斜锁->轻量级锁->重量级锁

这里主要来分析一下偏斜锁和轻量级锁。

  • 偏斜锁:减少同一线程获得锁的代价

在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得。

偏斜锁的核心思想是这样的:

如果一个线程获得了锁,那么锁就进行了偏斜模式,此时Mark Word的结构也变成偏斜锁结构,当该线程再次请求锁的时候,无需再做任何同步操作,即获取锁的过程只需要检查MarkWord的锁标记位为偏向锁以及当前线程id等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。

需要注意的是,我们大多数情况下还是同一个线程去申请锁,所以实际上,synchronized的默认级别也就是偏斜锁,而当锁竞争激烈的时候,锁就会进行膨胀成轻量级锁。

  • 轻量级锁

轻量级锁来源于偏斜锁。当偏斜锁运行在一个线程进入同步块的情况下,当第二个线程加入锁的竞争的时候,就会升级了。

适应的场景也不必多说,必然是线程需要交替执行同步块的时候。

偏斜锁的加锁过程是这样的:

  1. 当代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志为01标志),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,同于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word。这时候线程堆栈与对象头的状态如图:
    在这里插入图片描述
  2. 之后拷贝对象中的Mark Word复制到锁记录中
  3. 当拷贝成功之后,虚拟机将会使用CAS操作尝试将对象的Mark Word更细腻为指向Lock Record 的之战,并将Lock Record里的owner指针指向object mark word。如果更新成功,则执行4,否则执行5
  4. 如果这个更新成功了,那么线程就拥有了这个对象的锁,并且对象Mark Word的锁标志位设置为00,代表对象处于轻量级锁状态,这时候堆栈与对象头的状态如图:

在这里插入图片描述

  1. 如果这个更新失败了,JVM就会首先检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态变为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁。

而解锁的过程是这样的:

  1. 通过 CAS操作尝试把线程中复制的DIsplaced Mark Word对象替换当前的Mark Word。
  2. 如果替换成功,整个同步过程就完成了
  3. 如果替换成功,说明有其他线程尝试获取该锁(此时锁已膨胀),那就要在释放锁的同时唤醒被挂起的线程。

最后截个视频上的原图:
在这里插入图片描述

发布了296 篇原创文章 · 获赞 53 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_41936805/article/details/103378987
今日推荐