文章目录
- JUC之Java内置锁的核心原理
- Java对象结构
- 对象头
- 对象体
- 对齐字节
- Mark Word的结构信息
- 64位Mark Word的构成
- 偏向锁
- 偏向锁的设置
- 偏向锁的重偏向
- 偏向锁的撤销
- 偏向锁的膨胀
- 轻量级锁
- 执行过程
- 轻量级锁的分类
- 普通自旋锁
- 自适应自旋锁
- 重量级锁
- 偏向锁、轻量级锁与重量级锁的对比
JUC之Java内置锁的核心原理
Java内置锁是一个互斥锁,这就意味着最多只有一个线程能够获 得该锁,当线程B尝试去获得线程A持有的内置锁时,线程B必须等待或 者阻塞,直到线程A释放这个锁,如果线程A不释放这个锁,那么线程B 将永远等待下去。
Java中每个对象都可以用作锁,这些锁称为内置锁。线程进入同 步代码块或方法时会自动获得该锁,在退出同步代码块或方法时会释 放该锁。获得内置锁的唯一途径就是进入这个锁保护的同步代码块或 方法。
Java对象结构
Java对象(Object实例)结构包括三部分:对象头、对象体和对齐字节。
结构如下:
对象头
对象头包括三个字段,第一个字段叫作Mark Word(标记字),用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态信息等。
第二个字段叫作Class Pointer(类对象指针),用于存放方法区Class对象的地址,虚拟机通过这个指针来确定这个对象是哪个类的实例。
第三个字段叫作Array Length(数组长度)。如果对象是一个Java数组,那这个字段必须要有,用于记录数组长度的数据;如果对象不是一个Java数组,那么此字段不存在,所以这是一个可选字段。
对象体
对象体包含对象的实例变量(成员变量),用于成员属性值,包括父类的成员属性值。这部分内存按4字节对齐。对象体是对象的主体部分,占用的内存空间大小取决于对象的属性数量和类型。
对齐字节
对齐字节也叫作填充对齐,其作用是用来保证Java对象所占内存 字节数为8的倍数HotSpot VM的内存管理要求对象起始地址必须是8字 节的整数倍。对象头本身是8的倍数,当对象的实例变量数据不是8的 倍数时,便需要填充数据来保证8字节的对齐。
对齐字节并不是必然存在的,也没有特别的含义,它仅仅起 着占位符的作用。当对象实例数据部分没有对齐(8字节的整数倍) 时,就需要通过对齐填充来补全。
Mark Word的结构信息
Java内置锁涉及很多重要信息,这些都存放在对象头的Mark Word字段中。Mark Word不会受到Oop指针压缩选项的影响。Java内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁。在JDK1.6之前只有重量级锁,之后才引入偏向锁和轻量级锁。4种锁状态会随着竞争的情况逐渐升级,而且不可逆,即只能进行锁升级,不会发生锁降级。
不同锁状态下32位Mark Work的结构信息:
不同锁状态下64位Mark Work的结构信息:
64位Mark Word的构成
- lock:锁状态标记位,占两个二进制位。该标记的 值不同,整个Mark Word表示的含义就不同。
- biased_lock:对象是否启用偏向锁标记,只占1个二进制 位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
- age:4位的Java对象分代年龄。在GC中,对象在Survivor区 复制一次,年龄就增加1。当对象达到设定的阈值时,将会晋升到老年 代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由 于age只有4位,因此最大值为15,这就是-XX:MaxTenuringThreshold 选项最大值为15的原因。
- identity_hashcode:31位的对象标识HashCode(哈希码) 采用延迟加载技术,当调用Object.hashCode()方法或者 System.identityHashCode()方法计算对象的HashCode后,其结果将被 写到该对象头中。当对象被锁定时,该值会移动到Monitor(监视器) 中。
- thread:54位的线程ID值为持有偏向锁的线程ID。
- epoch:偏向时间戳。
- ptr_to_lock_record:占62位,在轻量级锁的状态下指向栈帧中锁记录的指针。
- ptr_to_heavyweight_monitor:占62位,在重量级锁的状态下指向对象监视器的指针。
32位的Mark Word与64位的Mark Word结构相似
偏向锁
如果一个同步块(或方法)没有多个线程竞争, 而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤 醒CPU从用户态转为核心态,那么对于CPU是一种资源的浪费,为了解 决这类问题,就引入了偏向锁的概念。偏向锁主要解决无竞争下的锁性能问题,所谓的偏向就是偏心, 即锁会偏向于当前已经占有锁的线程。
原理:如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向锁结构, 锁对象的锁标志位(lock)被改为01,偏向标志位(biased_lock)被改为1,然后线程的ID记录在锁对象的Mark Word中(使用CAS操作完 成)。以后该线程获取锁时判断一下线程ID和标志位,就可以直接进 入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。
偏向锁的设置
如果开启了偏向锁(默认开启),那么对象创建后,markword值为0x05即最后3位为101,这时它的thread、epoch、age都为0;如果没有开启偏向锁,那么对象创建后,markword值为0x01即最后3位位001,这时它的hashcode、age都为0,第一次用到hashcode时才会赋值。使用-XX:-UseBiasedLocking可以禁用偏向锁。
偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数-XX:BiasedLockingStartupDelay=0 来禁用延迟。
偏向锁的重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T 1 的对象仍有机会重新偏向T 2 ,重偏向会重置对象的 Thread ID。当撤销偏向锁阈值超过20次后,(从第二十次开始)jvm之后在给这些对象加锁时重新加偏向锁至新的线程。
偏向锁的撤销
偏向锁的撤销过程:
- 在一个安全点停止拥有锁的线程。
- 遍历线程的栈帧,检查是否存在锁记录。如果存在锁记录, 就需要清空锁记录,使其变成无锁状态,并修复锁记录指向的Mark Word,清除其线程ID。
- 将当前锁升级成轻量级锁。
- 唤醒当前线程。
所以,如果某些临界区存在两个及两个以上的线程竞争,那么偏 向锁反而会降低性能。在这种情况下,可以在启动JVM时就把偏向锁的 默认功能关闭。
撤销偏向锁的情况:
- 多个线程竞争偏向锁。
- 调用偏向锁对象的hashcode()方法或者 System.identityHashCode()方法计算对象的HashCode之后,将哈希码 放置到Mark Word中,内置锁变成无锁状态,偏向锁将被撤销。
- 调用 wait/notify,这个只有重量级锁才有
当撤销偏向锁阈值超过40次后,(从第四十次开始)jvm会将整个类的所有对象都变为不可偏向的,新建的对象也会是不可偏向的。
偏向锁的膨胀
如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向 锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表 明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有 线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进 行重新偏向,偏向为抢锁线程。
如果JVM检查到原来的线程依然存活,就进一步检查占有线程的调 用堆栈是否通过锁记录持有偏向锁。如果存在锁记录,就表明原来的 线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁。
轻量级锁
使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁 (Mutex Lock)的概率,并不是要替代操作系统互斥锁。所以,在争 用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的 重量级锁。
执行过程
(1)在抢锁线程进入临界区之前,如果内置锁 (临界区的同步对象)没有被锁定,JVM首先将在抢锁线程的栈帧中建 立一个锁记录(Lock Record),用于存储对象目前Mark Word的拷 贝,这时的线程堆栈与内置锁对象头大致如图所示,
(2)然后抢锁线程将使用CAS自旋操作,尝试将内置锁对象头的Mark Word的ptr_to_lock_record(锁记录指针)更新为抢锁线程栈帧中锁 记录的地址,如果这个更新执行成功了,这个线程就拥有了这个对象锁。然后JVM将Mark Word中的lock标记位改为00(轻量级锁标志), 即表示该对象处于轻量级锁状态。
(3)抢锁成功之后,JVM会将Mark Word 中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的 Displaced Mark Word(可以理解为放错地方的Mark Word)字段中, 再将抢锁线程中锁记录的owner指针指向锁对象。
在轻量级锁抢占成功之后,锁记录和对象头的状态如图所示,
如果cas失败,有两种情况:
- 如果是其他线程已经持有了该Object的轻量级锁,这时表明有就竞争,进入锁膨胀过程
- 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
当退出 synchronized 代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。
当退出 synchronized 代码块(解锁时)锁记录的值不为null,这时使用 cas 将 Mark Word 的值恢复给对象头,成功则解锁成功,失败说明轻量级锁已经进行了锁膨胀或者已经升级为重量级锁,进入重量级锁的解锁流程。
轻量级锁的分类
轻量级锁主要有两种:普通自旋锁和自适应自旋锁。
普通自旋锁
普通自旋锁指当有线程来竞争锁时,抢锁线程会在原 地循环等待,而不是被阻塞,直到那个占有锁的线程释放锁之后,这 个抢锁线程才可以获得锁。
锁在原地循环等待的时候是会消耗CPU的,就相当于在执行一个 什么也不干的空循环。所以轻量级锁适用于临界区代码耗时很短的场 景,这样线程在原地等待很短的时间就能够获得锁了。默认情况下,自旋的次数为10次,用户可以通过XX:PreBlockSpin选项来进行更改。
自适应自旋锁
自适应自旋锁就是等待线程空循环的自旋次数并非是固定 的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由 前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
- 如果抢锁线程在同一个锁对象上之前成功获得过锁,JVM就 会认为这次自旋很有可能再次成功,因此允许自旋等待持续相对更长 的时间。
- 如果对于某个锁,抢锁线程很少成功获得过,那么JVM将可 能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
JDK 1.6的轻量级锁使用的是普通自旋锁,且需要使用XX:+UseSpinning选项手工开启。JDK 1.7后,轻量级锁使用自适应自旋 锁,JVM启动时自动开启,且自旋时间由JVM自动控制。
重量级锁
JVM中每个对象都会有一个监视器,监视器和对象一起创建、销 毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是 保证(同一时间)只有一个线程可以访问被保护的临界区代码块。本质上,监视器是一种同步工具,也可以说是一种同步机制,监听器主要有两个特点。
- 同步。监视器所保护的临界区代码是互斥地执行的。一个监 视器是一个运行许可,任一线程进入临界区代码都需要获得这个许 可,离开时把许可归还。
- 协作。监视器提供Signal机制,允许正持有许可的线程暂时 放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他 拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可 以重新获得许可并启动执行。
在Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的,ObjectMonitor的Owner(_owner)、WaitSet(_WaitSet)、 Cxq(_cxq)、EntryList(_EntryList)这几个属性比较关键。ObjectMonitor的WaitSet、Cxq、EntryList这三个队列存放抢夺重量 级锁的线程,而ObjectMonitor的Owner所指向的线程即为获得锁的线 程。
- Cxq:竞争队列(Contention Queue),所有请求锁的线程 首先被放在这个竞争队列中。
- EntryList:Cxq中那些有资格成为候选资源的线程被移动到 EntryList中。
- WaitSet:某个拥有ObjectMonitor的线程在调用 Object.wait()方法之后将被阻塞,然后该线程将被放置在WaitSet链 表中。
ObjectMonitor的内部抢锁过程如图所示:
(1) Cxq
Cxq并不是一个真正的队列,而是一个由Node及其next指针逻辑构成的虚拟队列。每 次新加入Node会在Cxq的队头进行,通过CAS改变第一个节点的指针为 新增节点,同时设置新增节点的next指向后续节点;从Cxq取得元素 时,会从队尾获取。Cxq结构是一个无锁结构。
在线程进入Cxq前,抢锁线程会先尝试通过CAS自旋获取锁,如果 获取不到,就进入Cxq队列,这明显对于已经进入Cxq队列的线程是不 公平的。因此,synchronized同步块所使用的重量级锁是不公平锁。
(2) EntryList
EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访 问,为了降低对Cxq队尾的争用,而建立EntryList。在Owner线程释放 锁时,JVM会从Cxq中迁移线程到EntryList,并会指定EntryList中的 某个线程(一般为Head)为OnDeck Thread(Ready Thread)。 EntryList中的线程作为候选竞争线程而存在。
(3) OnDeck Thread与Owner Thread
JVM不直接把锁传递给Owner Thread,而是把锁竞争的权利交给 OnDeck Thread,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平 性,但是能极大地提升系统的吞吐量,在JVM中,也把这种选择行为称 为“竞争切换”。
OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁 的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化(依然在队头)。
在OnDeck Thread成为Owner的过程中,还有一个不公平的事情, 就是后来的新抢锁线程可能直接通过CAS自旋成为Owner而抢到锁。
(4) WaitSet
如果Owner线程被Object.wait()方法阻塞,就转移到WaitSet队列 中,直到某个时刻通过Object.notify()或者Object.notifyAll()唤 醒,该线程就会重新进入EntryList中。
偏向锁、轻量级锁与重量级锁的对比
synchronized的执行过程:
- 线程抢锁时,JVM首先检测内置锁对象Mark Word中的 biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为 01,如果都满足,确认内置锁对象为可偏向状态。
- 在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中 的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状 态,抢锁线程快速获得锁,开始执行临界区代码。
- 如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操 作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线 程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码, 此时内置锁对象处于偏向锁状态。
- 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁, 进而升级为轻量级锁。
- JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录 指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线 程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自 旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
- 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为 重量级锁,后面等待锁的线程也要进入阻塞状态。
总体来说,偏向锁是在没有发生锁争用的情况下使用的;一旦有 了第二个线程争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激 烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级锁。
三种内置锁的对比如图: