解"锁"疑惑:偏向锁为什么不是锁?锁升级又是什么?何时禁用偏向锁和轻量级锁?重量级锁怎么回事?

48 阅读7分钟

大家好,我是程序视点的小二哥!今天我们继续来聊聊Java中的锁!

探“锁”源头:synchronized、偏向锁与锁膨胀的秘密!今天我们来聊聊Java中的锁! synchronized怎 - 掘金 (juejin.cn)

  • synchronized怎么用?
  • 锁是什么?
  • 偏向锁是什么?
  • 锁如何升级?何为膨胀?
  • 自旋锁何解?
  • 互斥锁怎么来的?
  • 何时要禁用偏向锁和轻量级锁?

带着上面疑问,我们一起来解“锁”疑惑!以下是第二篇文章来讲,方便大家记忆!欢迎持续关注【程序视点】,这样就不会错过之后的精彩内容啦!

前言

前面说过,在大多数情况下,总是同一个线程去访问同步块代码,基于这样一个假设,引入了偏向锁,只需要用一个CAS操作和简单地判断比较,就可以让一个线程持续地拥有一个锁。 也正因为此假设,在Jdk1.6中,偏向锁的开关是默认开启的,适用于只有一个线程访问同步块的场景。 但一旦出现竞争,也即有另外一个线程也要来访问这一段代码,偏向锁就不适用于这种场景了。

锁膨胀

如果两个线程都是活跃的,会发生竞争,此时偏向锁就会发生升级,也就是我们常常听到的锁膨胀。

偏向锁会膨胀成轻量级锁(lightweight locking)。

锁撤销

偏向锁有一个不好的点就是,一旦出现多线程竞争,需要升级成轻量级锁,是有可能需要先做出锁撤销的操作。

而锁撤销的操作,相对来说,开销就会比较大,其步骤如下:

  1. 在一个安全点停止拥有锁的线程,就跟开始做GC操作一样。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Markword,使其变成无锁状态。
  3. 唤醒当前线程,将当前锁升级成轻量级锁。

轻量级锁

而本质上呢,其实就是锁对象头中的Markword内容又要发生变化了。

下面先简单地描述其膨胀的步骤:

  1. 线程在自己的栈桢中创建锁记录 LockRecord
  2. 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中
  3. 将锁记录中的Owner指针指向锁对象
  4. 将锁对象的对象头的MarkWord替换为指向锁记录的指针。

同样,我们还是利用Java开发人员提供的一张图来描述此步骤:请在此添加图片描述请在此添加图片描述

可以根据上面两图来印证上面几个步骤,但在这里,其实对象的Markword其实也是发生了变化的,其现在的内容结构如下:

bit fields锁标志位
指向LockRecord的指针00

说到这里,我们又通过偏向锁引入了轻量级锁的概念,那么轻量级锁是怎么个轻量级法,它具体的实现又是怎么样的呢?

就像偏向锁的前提,是同步代码块在大多数情况下只有同一个线程访问的时候。

而轻量级锁的前提则是,线程在同步代码块里面的操作非常快,获取锁之后,很快就结束操作,然后将锁释放出来。

但是不管再怎么快,一旦一个线程获得锁了,那么另一个线程同时也来访问这段代码时,怎么办呢?这就涉及到我们下面所说的锁自旋的概念了。

自旋锁/自适应自旋锁

来到轻量级锁,其实轻量级的叙述就来自于自旋的概念。

因为前提是线程在临界区的操作非常快,所以它会非常快速地释放锁,所以只要让另外一个线程在那里地循环等待,然后当锁被释放时,它马上就能够获得锁,然后进入临界区执行,然后马上又释放锁,让给另外一个线程。

所谓自旋,就是线程在原地空循环地等待,不阻塞,但它是消耗CPU的。

所以对于轻量级锁,它也有其限制所在:

  1. 因为消耗CPU,所以自旋的次数是有限的,如果自旋到达一定的次数之后,还获取不到锁,那这种自旋也就无意义。但在上述的前提下,这种自旋的次数还是比较少的(经验数据)。当然,一开始的自旋次数都是固定的,但是在经验代码中,获得锁的线程通常能够马上再获得锁,所以又引入了自适应的自旋,即根据上次获得锁的情况和当前的线程状态,动态地修改当前线程自旋的次数。
  2. 当另一个线程释放锁之后,当前线程要能够马上获得锁,所以如果有超过两个的线程同时访问这段代码,就算另外一个线程释放锁之后,当前线程也可能获取不到锁,还是要继续等待,空耗CPU。

从以上两点可以看出,当线程通过自旋获取不到锁了,比如临界区的操作太花时间了,或者有超过2个以上的线程在竞争锁了,轻量级锁的前提又不成立了。当虚拟机检查到这种情况时,又开始了膨胀的脚步。

互斥锁(重量级锁)

相比起轻量级锁,再膨胀的锁,一般称之为重量级锁,因为是依赖于每个对象内部都有的monitor锁来实现的,而monitor又依赖于操作系统的MutexLock(互斥锁)来实现,所以一般重量级锁也叫互斥锁。

由于需要在操作系统的内核态和用户态之间切换的,需要将线程阻塞挂起,切换线程的上下文,再恢复等操作,所以当synchronized升级成互斥锁,依赖monitor的时候,开销就比较大了,而这也是之前为什么说synchronized是一个很重的操作的原因了。

当然,升级成互斥锁之后,锁对象头的Markword内容也是会变化的,其内容如下:

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

每次检查当前线程是否获得锁,其实就是检查Mutex的值是否为0,不为0,说明其为其线程所占有,此时操作系统就会介入,将线程阻塞,挂起,释放CPU时间,等待下一次的线程调度。

好了,到这里,对于synchronized所修改的同步方法或者同步代码块,虚拟机是如何操作的,大家应该也有一个简单的印象了。

当使用synchronized关键字的时候,在java1.6之后,根据不同的条件和场景,虚拟机是一步一步地将偏向锁升级成轻量级锁,再最终升级成重量级锁的,而这个过程是不可逆的,因为一旦升级成重量级锁,则说明偏向锁和轻量级锁是不适用于当前的应用场景的,那再降级回去也没什么意义。

从这一点,也可以看出,如果我们的应用场景本身就不适用于偏向锁和轻量级锁,那么我们在程序一开始,就应该禁用掉偏向锁和轻量级锁,直接使用重量级锁,省去无谓的开销。

总结

在这里总结一下,在使用synchronized关键字的时候,本质上是否获得锁,是通过修改锁对象头中的markword的内容来标记是否获得锁,并由虚拟机来根据具体的应用场景来锁进行升级。

简单地将上述几个零散的markword变化合在一起,展示在下面:

锁状态bits1bit是否是偏向锁2bit锁标志位
无锁状态对象的hashCode001
偏向锁线程ID101
轻量级锁指向栈中锁记录的指针000
重量级锁指向互斥量的指针010

最后

【程序视点】助力打工人减负,从来不是说说而已!

后续小二哥会继续详细分享更多实用的工具和功能。持续关注,这样就不会错过之后的精彩内容啦!

如果这篇文章对你有帮助的话,别忘了【点赞】【分享】支持下哦~