并发编程中涉及到哪些锁
本文最后更新于 2025年5月20日 14:36
为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了偏向锁 和 轻量级锁 的概念,对 synchronized
做了一次重大的升级,升级后的synchronized
性能可以说上了一个新台阶。
在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
锁的存放位置
每一个 Java 对象都有一个对象头,64 位 Java 虚拟机中的一个字宽是 64 位,32 位 Java 虚拟机中一个字宽是 32 位,非数组对象的对象头占用两个字宽,数组对象的对象头占用三个字宽(其中一个字宽用来存储数组的长度)。
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的 hashCode 或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
MarkWord的存储格式
锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 偏向的线程 ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC 标记 | 此时这一位不用于标识偏向锁 | 11 |
偏向锁
大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
实现原理
首次访问时(线程 A):
- 当 线程 A 首次访问同步代码块时,JVM 会检查对象头的
MarkWord
,发现锁没有被占用(或者是空闲状态),于是线程 A 会 将自己的线程 ID 写入到对象的MarkWord
中,表示该锁现在偏向于线程 A。 - 此时,
MarkWord
中会保存线程 A 的线程 ID,表示此对象的锁是偏向于 A 线程的。 - 线程 A 后续再次进入该同步代码块时,JVM 会直接从
MarkWord
中读取线程 ID,发现是自己,就不需要再进行加锁操作。
后续访问时(线程 B):
- 当 线程 B 访问该同步代码块时,JVM 会检查
MarkWord
,发现该锁已经偏向了线程 A(即MarkWord
中保存的是线程 A 的线程 ID)。 - 由于线程 B 的线程 ID 与
MarkWord
中的线程 ID 不同,JVM 会 触发偏向锁的撤销,并将锁状态升级为 轻量级锁 或 重量级锁(通常是轻量级锁,具体依赖于 JVM 的实现和锁竞争的情况)。 - 撤销偏向锁时,JVM 会通过 CAS 操作,尝试将对象头中的
MarkWord
更新为 轻量级锁 或 重量级锁 的状态。 - 线程 B 会进入 自旋 或 阻塞,尝试竞争该锁。
轻量级锁
多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。
具体来说,偏向锁升级为轻量级锁的过程大致分为以下几个步骤:
线程 A 获取偏向锁
- 线程 A 第一次访问某个同步代码块时,JVM 会检查对象头中的 Mark Word。
- 由于此时没有其他线程竞争,JVM 会将 Mark Word 更新为线程 A 的 ID,从而标记该对象为偏向锁状态,并允许线程 A 进入同步代码块。
线程 B 尝试获取偏向锁
当线程 B 尝试访问相同的同步代码块时,JVM 会首先检查 Mark Word,发现该对象的锁已经被线程 A 持有(通过线程 ID 判断)。
偏向锁撤销与轻量级锁创建
撤销偏向锁:由于线程 B 发现当前锁为偏向锁,并且该偏向锁并未偏向自己,JVM 会尝试 撤销偏向锁,并将锁升级为轻量级锁(并不一定会升级成功)。
Mark Word 进入轻量级锁状态:JVM 会把对象头中的 Mark Word 中存储的线程 ID 清除。对象头的 Mark Word 的标志位将会被标记为轻量级锁。然后,JVM 会使线程 B 尝试用 CAS 操作 把 Mark Word 的内容(存储原来的锁状态,如分代年龄、CMS_free、hashCode等固有属性)复制到当前线程的栈帧中的 锁记录 区域,而对象头中则存储一个指向该区域的指针。
自旋和竞争
如果多个线程在此时尝试获取锁(比如线程 C 也尝试获取该锁),JVM 会继续使用 CAS 操作来竞争该锁。
- 如果 CAS 操作成功,则线程 C 获得锁并继续执行同步代码块。
- 如果 CAS 操作失败,说明线程 C 没有成功获得轻量级锁,此时轻量级锁会升级为 重量级锁,进入 阻塞状态,需要使用操作系统的互斥锁(Mutex)来保证同步。
重量级锁
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
重量级锁的效率很低,因为重量级锁依赖于操作系统的互斥锁,而操作系统中线程间状态的转换需要相对较长的时间。如果轻量级锁锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
锁升级
对java锁升级,你是否还停留在表面的理解?7000+字和图解带你深入理解锁升级的每个细节-云社区-华为云
第一步,检查 MarkWord 里面是不是放的自己的 ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
第二步,如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空。
第三步,两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作, 把锁对象的 Markword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。
第四步,第三步中成功执行 CAS 的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败 …
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
公平锁和非公平锁
公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
乐观锁和悲观锁
乐观锁:认为对共享变量的每次操作都不会与其他线程竞争,线程可以不停地执行,无需加锁也无需等待,但是在提交的时候需要验证对应的资源是否已经被其他线程修改过(可以通过版本号机制或者CAS来实现)
悲观锁:认为对共享变量的每次操作都会与其他线程竞争,所以在每次获取资源时都会上锁。也就是说共享资源每次只会给一个线程使用,其他线程阻塞,用完再将资源转让给其他线程。