在多线程并发编程中Synchronized一直是元老级角色,很多人都会称它为重量级锁,但是随着 Java SE1.6 对 Synchronized 进行了各种优化之后,有些情况下它并不那么重了,本文详细介绍了 Java SE1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
首先我们以一张思维导图大概的了解一下锁状态,接下来将进行具体的阐述。
synchronized,所谓的重量级锁。Java中每一个对象都可以作为一个锁,表现为:
JVM基于进入和退出Monitor对象来实现方法同步和代码同步。方法同步是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块开始的位置,monitorexit是插在方法结束处和异常处。
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的可见性。对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入,对单个volatile变量的读写具有原子性。就是说,线程对volatile变量本地内存的写入会被更新到主内存,其他线程对同个volatile的读取,会先将本地的设为无效,必须从主内存中读取。
锁存在Java的对象头中的Mark Work。Mark Work默认不仅存放着锁标志位,还存放对象hashCode等信息。运行时,会根据锁的状态,修改Mark Work的存储内容。如果对象是数组类型,则虚拟机用3个字宽存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。
锁有四种状态:无锁状态、偏向锁、轻量级锁、重量级锁
随着锁的竞争,锁的状态会从偏向锁到轻量级锁,再到重量级锁。而且锁的状态只有升级,没有降级。也就是只有偏向锁->轻量级锁->重量级锁,没有重量级锁->轻量级锁->偏向锁。
锁状态的改变是根据竞争激烈程度进行的,在几乎无竞争的条件下,会使用偏向锁,在轻度竞争的条件下,会由偏向锁升级为轻量级锁, 在重度竞争的情况下,会升级到重量级锁。
下图展现了一个对象在创建(allocate)后,根据偏斜锁机制是否打开,对象 MarkWord 状态以不同方式转换的过程。
锁名称 | 描述 | 应用场景 |
---|---|---|
偏向锁 | 线程在大多数情况下并不存在竞争条件,使用同步会消耗性能,而偏向锁是对锁的优化,可以消除同步,提升性能。当一个线程获得锁,会将对象头的锁标志位设为01,进入偏向模式.偏向锁可以在让一个线程一直持有锁,在其他线程需要竞争锁的时候,再释放锁。 | 只有一个线程进入临界区 |
轻量级锁 | 当线程A获得偏向锁后,线程B进入竞争状态,需要获得线程A持有的锁,那么线程A撤销偏向锁,进入无锁状态。线程A和线程B交替进入临界区,偏向锁无法满足,膨胀到轻量级锁,锁标志位设为00。 | 多个线程交替进入临界区 |
重量级锁 | 当多线程交替进入临界区,轻量级锁hold得住。但如果多个线程同时进入临界区,hold不住了,膨胀到重量级锁 | 多个线程同时进入临界区 |
无锁 -> 偏向锁
从上图可以看到 , 偏向锁的获取方式是将对象头的 MarkWord 部分中, 标记上线程ID, 以表示哪一个线程获得了偏向锁。 具体的赋值逻辑如下:
// Indicates that the mark has the bias bit set but that it has not
// yet been biased toward a particular thread
bool is_biased_anonymously() const {
return (has_bias_pattern() && (biased_locker() == NULL));
}
has_bias_pattern() 返回 true 时代表 markword 的可偏向标志 bit 位为 1 ,且对象头末尾标志为 01。
biased_locker() == NULL 返回 true 时代表对象 Mark Word 中 bit field 域存储的 Thread Id 为空。
如果为可偏向状态, 则尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord
如果是已偏向状态, 则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID
从上面的偏向锁机制描述中,可以注意到
偏向锁的撤销(Revoke)
如上文提到的, 偏向锁的撤销(Revoke) 操作并不是将对象恢复到无锁可偏向的状态, 而是在偏向锁的获取过程中, 发现了竞争时, 直接将一个被偏向的对象“升级到” 被加了轻量级锁的状态。 这个操作的具体完成方式如下:
偏向锁的批量再偏向(Bulk Rebias)机制
偏向锁这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。
那么作为开发人员, 很自然会产生的一个问题就是, 如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗? 答案是可以, JVM 提供了批量再偏向机制(Bulk Rebias)机制
该机制的主要工作原理如下:
偏向锁 -> 轻量级锁
从之前的描述中可以看到, 存在超过一个线程竞争某一个对象时, 会发生偏向锁的撤销操作。 有趣的是, 偏向锁撤销后, 对象可能处于两种状态。
一种是不可偏向的无锁状态, 如下图(之所以不允许偏向, 是因为已经检测到了多于一个线程的竞争, 升级到了轻量级锁的机制)
另一种是不可偏向的已锁 ( 轻量级锁) 状态
之所以会出现上述两种状态, 是因为偏向锁不存在解锁的操作, 只有撤销操作。 触发撤销操作时:
轻量级加锁过程:
首先根据标志位判断出对象状态处于不可偏向的无锁状态( 如下图)
下图展示了两个线程竞争锁, 最终导致锁膨胀为重量级锁的过程。
注意: 下图中第一个标绿 MarkWord 的起始状态是错误的, 正确的起始状态应该是 ThreadId(空)|age|1|01, HashCode|age|0|01 是偏向锁未被启用时, 分配对象后的状态
轻量级锁-> 重量级锁
重量级锁依赖于操作系统的互斥量(mutex) 实现,该操作会导致进程从用户态与内核态之间的切换, 是一个开销较大的操作。
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU | 追求响应时间。同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量。同步块执行速度较长 |
Java中的偏向锁,轻量级锁, 重量级锁解析
锁的状态转换的过程需要认真的细究一下,加油。
感谢您的阅读~~