(拉钩)Android工程师进阶34讲-09:Java线程优化之偏向锁,轻量级所锁、重量级锁

0. 前言

Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到核心态,而状态转换需要花费很多处理器的时间。

比如下面代码:

1
2
3
4
5
6
7
8
private Object lock = new Object();
private int value;

public void setValue(){
synchronized(lock){
value++;
}
}

value++因为被关键字synchronized修饰,所以会在各个线程之间同步执行。但是value++消耗的时间很有可能比线程状态转换消耗的时间还要短,所以说synchronized是Java语言的重量级操作。

1. Synchronized实现原理

先了解两个基础概念:对象头和Monitor。

1.1 对象头

《大话Java对象在虚拟机中是什么样子?》中提到了Java对象在内存中的布局分为三部分:对象头、实例数据、对齐填充。当在Java代码中,使用new创建一个对象时,JVM会在堆中创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

instanceOopDesc的基类是oopDesc。结构如下:

1
2
3
4
5
6
7
8
9
class oopDesc{
friend class VMStructs;
private:
volatile markOop _mark;
union _metadata{
wideKlassOop _klass;
narrowOop _compressed_klass;
} _metadata;
}

其中_mark_meatdata一起组成对象头。_metadata主要保存了类元数据,不需要做过多介绍。这里重点看一下_mark属性,_markmarkOop类型数据,一般称它为标记字段(Mark Word),其中主要存储了对象的hashCode、分代年龄、锁标志位、是否偏向锁等。

用一张图来表示32位Java虚拟机的Mark Word的默认存储结构如下:

默认情况下,没有线程进行加锁操作,所以锁对象中的Mark Word处于无锁状态。但是考虑到JVM的空间效率,Mark Word被设计成一个非固定的数据结构,以便存储更多的有效数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

从图中可以看出,根据“锁标志位”以及“是否为偏向锁”,Java中的锁可以分为以下几种状态:

是否为偏向锁 锁标志位 锁状态
0 01 无锁
1 01 偏向锁
0 00 轻量级锁
0 10 重量级锁
0 11 GC标记

Java6之前,并没有轻量级锁和偏向锁,只有重量级锁,也就是通常所说的synchronized的对象锁,锁标记位为10。从图中的描述可以看出:当锁是重量级锁时,对象头中Mark Word会用30 bit来指向一个“互斥量”,而这个互斥量就是Monitor

1.2 Monitor

可以把它理解成是一个同步工具,也可以描述为一种同步机制。实际上,它是一个保存在对象头中的一个对象。在markOop中有如下代码:

1
2
3
4
5
6
7
8
9
bool has_monitor() const{
return ((value() & monitor_value) != 0);
}

ObjectMonitor* monitor() const{
assert(has_monitor(), "check");
// Use xor instead of &~ to provide one extra tag-bit check.
return (ObjectMonitor*) (value() ^ monitor_value);
}

通过monitor()创建一个ObjectMonitor对象,而ObjectMonitor就是Java虚拟机中的Monitor的具体实现。因此Java中每个对象都会有一个对应的ObjectMonitor对象,这也是Java中所有Object都可以作为锁对象的原因。

ObjectMonitor是如何实现同步机制的?

首先看ObjectMonitor的结构:

其中几个比较关键的属性:

  • _owner:指向持有ObjectMonitor对象的线程。
  • _WaitSet:存放处于wait状态的线程队列。
  • _EntryList:存放处于等待锁block状态的线程队列。
  • _recursions:锁的重入次数。
  • _count:用来记录该线程获取锁的次数。

当多个线程访问同一段同步代码时,首先会进入_EntryList队列中,当某个线程通过竞争获取到对象的monitor后,monitor会把_owner变量设置为当前线程,同时monitor中的计数器_count加1,即获得对象锁。

若持有monitor的线程调用wait()方法,将释放当前持有的monitor_owner变量恢复为null,_count减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

1.3 实例演示

比如以下代码通过三个线程分别执行以下同步代码块:

1
2
3
4
5
6
7
private Object lock = new Object();

public void syncMethod(){
synchronized(lock){
// do something
}
}

锁对象是lock对象,在JVM中会有一个ObjectMonitor对象与之呼应。如下图:

分别使用三个线程来执行以上同步代码块。默认情况下,三个线程都会进入ObjectMonitor中的EntrySet队列中,如下:

假设线程2首先通过竞争获取到锁对象,则ObjectMonitor中的Owner指向线程2,并将count加1。结果如下:

上图中Owner指向线程2表示它已经成功获取到锁(Monitor)对象,其他线程只能处于阻塞(blocking)状态。如果线程2在执行过程中调用wait()操作,则线程2会释放(Monitor)对象,以便其他线程进入获取锁(Monitor)对象,Owner变量恢复为null,count做减1操作,同时线程2会添加到WaitSet集合,进入等待(waiting)状态并等待被唤醒。结果如下:

然后线程1和线程3再次通过竞争获取到锁(Monitor)对象,则重新将Owner指向成功获取到锁的线程。假设线程1获取到锁,如下:

如果在线程1执行过程中调用notify操作将线程1唤醒,则当前处于WaitSet中的线程2会被重新添加到EntrySet集合中,并尝试重新回去竞争锁(Monitor)对象。但是notify操作并不会是线程1释放锁(Monitor)对象。结果如下:

当线程1中的代码执行完毕以后,同样会自动释放锁,以便其他线程再次获取锁对象。

实际上,ObjectMonitor的同步机制是JVM对操作系统级别的 Mutex Lock(互斥锁)的管理过程,其间都会转入操作系统内核态。也就是说synchronized实现锁,在“重量级锁”状态下,当多个线程之间切换上下文时,还是一个比较重量级的操作。

2. Java虚拟机对synchronized的优化

从Java 6开始,虚拟机对synchronized关键字做了多方面的优化,主要目的是,避免ObjectMonitor的访问,减少“重量级锁”的使用次数,并最终减少线程上下文切换的频率。其中主要做了以下几个优化:锁自旋、轻量级锁、偏向锁。

2.1 锁自旋

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,会给系统的并发性能带来很大的压力,所以Java引入了自旋锁的操作。实际上自旋锁在Java 1.4就被引入了,默认关闭,但是可以使用参数-XX:UseSpinning将其开启。但是从Java 6以后,默认开启。

所谓自旋,就是让该线程等待一段时间,不会被立即乖挂起,看当前持有锁的线程是否很快释放锁。而所谓的等待就是执行一段无意义的循环即可(自旋)。

自旋锁的缺陷:自旋锁要占用CPU,如果锁竞争的时间比较长,那么自旋通常不能获得锁,浪费自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景,此时应主动禁用自旋锁。

2.2 轻量级锁

有时Java虚拟机会出现这种情形:对于一块同步代码块,虽然有多个不同线程去执行,但是这些线程是在不同额时间段交替请求这把锁对象,也就是不存在竞争锁的现象。这时,锁会保持轻量级所得状态,避免重量级锁的阻塞和唤醒操作。

要了解轻量级锁的工作流程,需要再次看下对象头中的Mark Work。上文中已经提到,锁的标志位包含几种情况:00代表轻量级锁、01代表无锁(或偏向锁)、10代表重量锁、11是跟垃圾回收算法的标记有关。

当线程执行某段同步代码时,Java虚拟机会在当前线程的栈帧中开辟一块空间(Lock Record)作为该锁的记录,如下图所示:

然后Java虚拟机会尝试使用CAS(Compare And Swap)操作,将锁对象的Mark Word拷贝到这块空间中,并且将锁记录中的owner指向Mark Word。结果如下:

当线程再次执行此同步代码块时,判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占,这时轻量级锁需要膨胀为重量级锁。

轻量级锁适应的场景是线程交替执行同步快的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

2.3 偏向锁

轻量级锁是在没有锁竞争情况的锁状态,但是有时锁不仅存在多线程的竞争,而且总有由同一个线程获得。因此为了让线程获得锁的代价更低,引入了偏向锁。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或退出同一个同步代码块,不需要再次进行抢占和释放锁的操作。偏向锁可以通过-XX:UseBiasedLocking开始或关闭。

偏向锁的具体实现就是在锁对象的对象头中有个ThreadId字段,默认情况下这个字段是空的,当第一次获取锁的时候,就将自身的ThreadId写入锁对象的Mark Word中的ThreadId字段内,将是否偏向锁的状态置位为01。这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致,如果一致,则认为当前线程已经获取了锁,因此不需要再获取锁,略过轻量级锁和重量级锁的加锁阶段,提高效率。

其实偏向锁并不适合所有应用场景,因为一旦锁竞争,偏向锁会被撤销,并膨胀为重量级锁,而撤销锁操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的synchronized块时,才能体现出明显改善;因此实践中,还需要考虑具体业务场景,并测试后,在决定是否开启偏向锁。

对于锁的几种状态转换的源码分析,可以参考:源码分析Java虚拟机中锁膨胀的过程

3. 总结

本节主要介绍Java中锁的几种状态,其中偏向锁和轻量级锁都是通过自旋等技术避免真正的加锁,而重量级锁才是获取锁和释放锁,重量级锁通过对象内部的监听器(ObjectMonitor)实现,其本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,成本很高。实际上Java对锁的优化还是“锁消除”,但“锁消除”是基于Java对象逃逸分析的,可以查看Java逃逸分析

  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信