0
点赞
收藏
分享

微信扫一扫

12. 面试官不问, 你也不得不知道的锁知识!

前言

hello, 大家好, 我是咸鱼君, 擅长各种Java摸鱼姿势.

恰逢2020年“金九”月,

今天, 我们就来说一说面试中出现频率极高锁知识!

ps:说不定可以帮到部分有“想法”的同学

锁的优化

JDK6开始, synchronized的实现机制进行了较大调整,除了使用JDK5引进的CAS自旋之外, 还增加了以下的优化策略.

  • 自适应的CAS自旋
  • 锁消除
  • 锁粗化
  • 偏向锁
  • 轻量级锁
  • ...

这些优化使得synchronized性能获得了极大提高, 所以, 情况允许下, 推荐使用synchronized关键字.

今天, 我们好好了解下synchronized锁进行的那些优化, 这样我们使用时才能清晰的明白优劣, 作出合适的选择.

锁的基本状态

锁主要存在以下四种状态:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

锁可以从偏向锁 升级到 轻量级锁, 再 升级至 重量级锁.
不过锁的升级是单向的,
也就是说只能从低到高, 不会出现锁的降级.

自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,
频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力.
另外, 其实在许多应用上面, 对象锁的锁状态只会持续很短一段时间,
为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的.

所以引入自旋锁.

何谓自旋锁?

自旋锁适用于锁保护的临界区很小的情况,
临界区很小的话,锁占用的时间就很短.

自旋等待不能替代阻塞,
虽然它可以避免线程切换带来的开销, 但是它占用了CPU处理器的时间.
如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,
反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费.

所以说,自旋等待的时间(自旋的次数)必须要有一个限度,
如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起.

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用
-XX:+UseSpinning开启,
在JDK1.6中默认开启. 同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整.

如果通过参数-XX:PreBlockSpin来调整自旋锁的自旋次数,会带来诸多不便.
假如将参数调整为10,
但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁),是不是很尴尬.
于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明.

适应性自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁.
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定.

那它如何进行适应性自旋呢?

有了自适应自旋锁, 随着程序运行和性能监控信息的不断完善,
虚拟机对程序锁的状况预测会越来越准确, 虚拟机会变得越来越聪明.

锁消除

为了保证数据的完整性,
在进行操作时需要对部分操作进行同步控制,
但是在有些情况下,JVM检测到不可能存在共享数据竞争,
此时, JVM会对这些同步锁进行锁消除.

逃逸分析

如果不存在竞争,为什么还需要加锁呢?
所以, 锁消除可以节省毫无意义的请求锁的时间.

变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定的,
对于开发人员来说, 一些情况是可以主观分析出来的,
比如,不存在数据竞争的代码块不需要加上同步!

但是, 有时候程序并不是我们所想的那样,
虽然没有显示使用锁, 但是在使用一些JDK的内置API时,如

  • StringBuffer
  • Vector
  • HashTable
  • 其它...

这个时候会存在隐形的加锁操作.

比如StringBuffer的append()方法, Vector的add()方法:

public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}

在运行这段代码时,
JVM可以明显检测到变量vector没有逃逸出vectorTest()方法外,
所以JVM可以大胆地将vector内部的加锁操作消除.

锁粗化

为什么要锁粗化?

还举个上文中的例子

public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}

偏向锁

偏向锁是JDK6中引入的重要概念.
HotSpot虚拟机作者经过研究实践发现,
在大多数情况下, 锁不仅不存在多线程竞争, 而且总是由同一线程多次获得;
为了让线程获得锁的代价更低, 所以引进了偏向锁.

偏向锁是在单线程执行代码块时使用的机制.
如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁.

在JDK5中, 偏向锁默认是关闭的;
而到了JDK6中, 偏向锁已经默认开启.
如果并发数较大,并且同步代码块执行时间较长时,
则被多个线程同时访问的概率就很大,
此时就可以使用参数-XX:-UseBiasedLocking来禁止偏向锁(但这是个JVM参数,不能针对某个对象锁来单独设置).

引入偏向锁主要目的:

为了在没有多线程竞争的情况下,
尽量减少不必要的轻量级锁执行路径.
因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的;
而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗).

那么偏向锁是如何来减少不必要的CAS操作呢?

首先我们看下无竞争下锁存在什么问题:

CAS为什么会引入本地延迟?

这要从SMP(对称多处理器)架构说起,下图大概表明了SMP的结构:

而CAS的全称为Compare-And-Swap,是一条CPU的原子指令;
其作用是让CPU比较后, 原子地更新某个位置的值;
其实现方式是基于硬件平台的汇编指令,
也就是说CAS是靠硬件实现的,JVM只是封装了汇编调用;
那些AtomicInteger类便是使用了这些封装后的接口.

举个例子

而CAS恰好会导致Cache一致性流量,
如果有很多线程都共享同一个对象,
当某个Core CAS成功时必然会引起总线风暴,
这就是所谓的本地延迟,
本质上偏向锁就是为了消除CAS,降低Cache一致性流量.

Cache一致性:

Cache一致性流量的例外情况:

NUMA(Non Uniform Memory Access Achitecture)架构:

所以,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:

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

轻量级锁

引入轻量级锁的主要目的是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗.当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

  1. 在线程进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word.此时线程堆栈与对象头的状态如下图所示:


  2. 拷贝对象头中的Mark Word复制到锁记录(Lock Record)中;

  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word.如果更新成功,则执行步骤(4),否则执行步骤(5);

  4. 如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,此时线程堆栈与对象头的状态如下图所示:


  5. 如果这个更新操作失败了,虚拟机首先会检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行.否则说明多个线程竞争锁,进入自旋执行(3),若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态.

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢.

  1. 为什么升级为轻量锁时要把对象头里的Mark Word复制到线程栈的锁记录中呢?
  1. 为什么会尝试CAS不成功以及什么情况下会不成功?

此处,如何理解“轻量级”?“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的.但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗.

重量级锁

Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的.但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的.而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因.因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”.

重量级锁、轻量级锁和偏向锁之间转换

锁的优劣

各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的.每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程.

在第3种情况下进入同步代码块就 要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁,最终还是得靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大不少了.所以使用哪种技术,一定要看其所处的环境及场景,在绝大多数的情况下,偏向锁是有效的,这是基于HotSpot作者发现的“大多数锁只会由同一线程并发申请”的经验规律.

参考文章: 源码架构

都看到这了, 不点个“赞”就想跑?

欢迎关注我

技术公众号 “CTO技术”


举报

相关推荐

0 条评论