随着JVM的升级,几乎不需要修改代码,就可以直接享受JVM在内置锁上的优化成果。从简单的重量级锁,到逐渐膨胀的锁分配策略,使用了多种优化手段解决隐藏在内置锁下的基本问题。
我们来回顾一下,新版的锁的类型一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级.
锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
JDK 1.6 引入了偏向锁和轻量级锁,锁拥有了四个状态
tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了
下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息
轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态
无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
锁优化技术是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率
主要包括:锁消除,锁粗化,偏向锁,轻量级锁,自旋锁,自适应自旋锁,重量级锁
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除
比如:A方法,调用B方法,B方法将内部创建的一个局部对象,返回给了A,那么这个B中的变量就属于逃逸了,就存在被其他线程访问的可能,简单说除了你写代码之外,Java底层包括从编译器到JVM还有很多工作人员在忙活,人家通过算法一看,你这根本就没有必要使用同步,就会在实际执行的时候把你的同步去掉
当然在实际开发中,我们很清楚的知道那些地方是线程独有的,不需要加同步锁,你可能以为,我自己哪有写很多synchronized修饰的方法,但是在JDK提供的方法、别人提供的Jar包中的方法中有很多方法都是加了同步的synchronized,对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁
String 是一个不可变的类,Javac编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作
众所周知,StringBuidler是安全同步的,每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了
不一定是调用同一个对象的同一个方法, 假如一个A方法,中有三个对象b,c,d,分别调用他们的方法而且都是同步方法,而且是同一个锁也是一样的原理
JDK 1.6 引入偏向锁(Mark Word是实现轻量级锁和偏向锁的关键)
如果需要,使用参数-XX:-UseBiasedLocking禁止偏向锁优化(默认打开)
减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS
偏向锁的思想是偏向于让第一个获取锁对象的线程
这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01
同时使用 CAS 操作将线程 ID 记录到 Mark Word 中
如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)
会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着
如果线程不处于活动状态,直接将对象头设置为无锁状态
如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁
如果明显存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁(不过这个副作用已经小的多)
JDK 1.6 引入轻量级锁(Mark Word是实现轻量级锁和偏向锁的关键)
减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步
轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁
总结
- 对于轻量级锁,核心就是CAS操作,因为一旦出现竞争,MarkDown信息将会被修改,而CAS操作的原理就是新值与旧值的对比后再操作,所以CAS操作的成功与否,可以推断是否有竞争
- 有竞争那么就会升级为重量级锁,其他请求线程会被阻塞,该线程执行结束后会唤醒其他阻塞线程;否则无竞争就会释放退出
- 很显然,如果竞争激烈的场景,很快就会升级为重量级锁,而关于轻量级锁所有的一切都是徒劳的
- 不过幸运的是,实践表明,大多数场景并不会竞争激烈
JDK 1.4引入自旋锁
如果锁的粒度小,那么锁的持有时间比较短(尽管具体的持有时间无法得知,但可以认为,通常有一部分锁能满足上述性质)。那么,对于竞争这些锁的而言,因为锁阻塞造成线程切换的时间与锁持有的时间相当,减少线程阻塞造成的线程切换,能得到较大的性能提升
JDK 1.6引入自适应自旋锁
自适应自旋也没能彻底解决该问题,如果默认的自旋次数设置不合理(过高或过低),那么自适应的过程将很难收敛到合适的值
内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)
这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。
特别说明两点
- CAS记录owner时,expected == null,newValue == ownerThreadId,因此,只有第一个申请偏向锁的线程能够返回成功,后续线程都必然失败(部分线程检测到可偏向,同时尝试CAS记录owner)。
- 内置锁只能沿着偏向锁、轻量级锁、重量级锁的顺序逐渐膨胀,不能“收缩”。这基于JVM的另一个假定,“一旦破坏了上一级锁的假定,就认为该假定以后也必不成立”。
如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
原创不易,欢迎转发,关注公众号“码农进阶之路”,获取更多面试题,源码解读资料!