Java锁机制
约 3510 字大约 12 分钟
2025-01-22
1.锁介绍
在计算机科学中,锁(lock)是一种同步机制,用于在有多线程环境中强制对资源的访问限制,锁要解决的问题是 线程安全问题
所谓线程安全,主要体现在三方面:原子性、可见性和有序性
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作。( Java的实现方法:synchronized、lock)
- 可见性:一个线程对主内存的修改可以及时被其他线程看到。( Java的实现方法:volatile)
- 有序性:一个线程观察其他线程的指令执行顺序,由于在JMM中允许编译器和处理器对指令重排序,因此该观察结果一般杂乱无序。( Java的实现方法:volatile)
2.锁的种类
以下将从实现功能、性能和线程安全、以及锁的状态三个角度来分,锁一共可分为以下几种类型
功能层面:共享锁、排他锁、读写锁
性能线程安全:
- 乐观锁、悲观锁
- 偏向锁、轻量级锁(自旋锁)、重量级锁(排他锁)
- 公平锁、非公平锁
锁的状态:死锁、活锁
2.1 实现功能
若从功能角度来看,锁可以分为三类:共享锁、排他锁、读写锁
共享锁:也叫读锁(例如 ReadWriteLock ),读锁的特点是在同一时刻允许多个线程抢占到锁。
排它锁:也叫写锁(例如 ReentrantLock 、 synchronized),写锁的特点是在同一时刻只允许一个线程抢占到锁。
读写锁:例如ReentrantReadWriteLock,其中低16位代表写锁,高16位代表读锁
- 该读写锁 ReentrantReadWriteLock提供了一个读锁,支持多个线程共享同一把锁。
- 它也提供了一把写锁,是排他锁,和其他读锁或者写锁互斥,表明只有一个线程能持有锁资源。
2.2 性能和线程安全
2.2.1 乐观锁、悲观锁
- 乐观锁
- 每次去读取数据时,都认为其他人不会修改该数据,因此不会加锁;修改数据,提交更新时候会判断在此期间是否有他人去更新此数据
- 实现方式:使用版本号机制、或者CAS算法
- 适合读多写少的场景,需要非常高的响应速度
- 悲观锁
- 总是假设最坏的情况,每次去 拿(读取/修改) 数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁
- 适合读少写多的场景
2.2.2 偏向锁、轻量级锁、重量级锁
偏向锁:
- 定义:可以让同一线程一直拥有同一个锁,直到出现竞争,才去释放锁
- 场景:应用于同步代码块在大多数情况下只有同一个线程访问
- 举例:所谓偏向锁就是当线程1进入锁的时候如果当前不存在竞争,那么它就会把这个锁偏向线程1,线程1下次再进入的时候,就不再需要竞争锁。
轻量级锁(自旋锁)
- 定义:轻量级锁即通过自旋方式不断尝试获取锁,而不是阻塞。当偏向锁被其他线程访问后,就会升级为轻量级锁。常见的轻量级锁即自旋锁
- 场景:前提是线程在临界区的操作非常快,所以它会非常快速地释放锁
重量级锁(排他锁):
- 定义:有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态
- 场景:有大量的线程参与锁的竞争,冲突性很高
2.2.3 公平锁、非公平锁
- 公平锁:每个线程获取锁的顺序是按照线程访问锁的先后顺序获取的;
- 非公平锁:每个线程获取锁的顺序是随机的,并不会遵循先来后到的规则,所有线程会竞争获取锁。
默认情况下锁都是非公平的,比如Synchronized(只能为非公平锁)、Reentrantlock(在创建Reentrantlock时可以手动指定成公平锁)
因为非公平锁的性能要比公平锁的性能更好,非公平锁意味着减少了锁的等待,减少了线程的阻塞和唤醒。
2.3 锁的状态
按照锁的状态分,可以分为死锁和活锁
死锁
定义:指两个或两个以上的线程在执行过程中,因争夺资源造成的一种互相等待的现象。当一个线程永久地持有一把锁后,其他线程将永久等待下去
四个条件:
- 互斥性:即线程占用的锁是互斥锁,不能被其他为占用的线程访问
- 不剥夺:即线程已经获得锁,在未主动释放之前,不会被其他线程剥夺
- 请求和保持:即有锁S1,S2,线程一持有了S1,又发起了对S2的持有请求。而同时有线程二持有了S2,又发起了对S1的持有请求。
- 环路等待:即死锁发生时,必然有一个环形链。如{p0,p1,p2,....pn}。p0等待p1释放资源,p1等待p2释放资源,p2等待p3释放资源,.... pn等待p0释放资源
解决方式:
- jstack定位死锁:https://www.cnblogs.com/chenpi/p/5377445.html
- 线上环境死锁,查看堆栈信息:使用 jps和jstack 命令分别查看JVM中运行的进程状态信息、以及java进程内线程的堆栈信息
死锁避免:
- 避免相反的获取锁的顺序
- 设置超时时间(lock类的 tryLock)
- 多使用并发类而不是自己设计锁
活锁
- 定义:活锁即线程并没有阻塞,也始终在运行,但是程序却得不到进展,因为线程始终重复做同样的事。本质原因是重试机制一样,始终互相谦让
- 案例:例如消息队列,若消息队列第一个一直消费失败,则会不断进行重试。而非第一个消息则会一直等待第一个消息被消费,造成了整个队列的罢工
- 解决方案:
- 增加随机因素
- 增加重试机制
3.乐观锁和悲观锁
3.1 悲观锁
- 定义:认为共享资源每次被访问的时候,都会被其他事务修改,所以每次在获取资源操作的时候都会上锁。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程
- 举例:Java的synchronized、ReentrantLock
- 问题:高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行
3.2 乐观锁
- 定义:认为共享资源每次被访问的时候,其他事务不会去修改该资源,访问不会加锁。在提交修改的时候去验证对应的资源是否被其它线程修改
- 举例:像 Java 中
java.util.concurrent.atomic
包下面的原子变量类(比如AtomicInteger
、LongAdder
)就是使用了乐观锁的一种实现方式:CAS 实现的 - 问题:高并发的场景下,乐观锁不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升
3.2.1 版本号机制
实现方式:一般是在数据表中加上一个数据版本号 version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。
当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功
3.2.2 CAS算法
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
CAS 涉及到的三个操作数:
- V:要更新的变量值(Var)
- E:预期值(Expected)
- N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
举例:
- 线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 原变量值(现在不一定为1),E=1,N=6,假设不存在 ABA 问题)
- V 与 E 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
- V与 E 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。
sun.misc
包下的Unsafe
类提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现的对Object
、int
、long
类型的 CAS 操作/** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);
CAS底层:依赖一个 Unsafe 类来直接调用操作系统底层的 CAS 指令
如下,ReentrantLock的一段CAS代码:this表示当前值,从数据库读取的值;expect表示期待的值;update表示修改后的值
protected final boolean compareAndSetState(int expect,int update){
return STATE.compareAndSet(this,expect,update);
}
3.4.3 ABA问题
问题描述:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
解决问题
ABA 问题的解决思路是在变量前面追加上版本号或者时间戳
JDK 1.5 以后的 AtomicStampedReference
类就是用来解决 ABA 问题的,其中的 compareAndSet()
方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
3.4.2 循环时间长
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:
- 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
- 可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空,从而提高 CPU 的执行效率
3.3 小结
- 悲观锁适用读少写多的场景,乐观锁适用于读多写少的场景
- 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些
- CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
- 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。
4.锁消除/锁膨胀
在 JDK 中,还引入了锁消除和锁膨胀,这是编译器层面的优化,主要优化加锁的性能。
- 锁消除:代码本身可能就没有线程安全问题,但是你又加了锁,然后ivm编译的时候发现这个地方加了锁,导致无效竞争,那么它就会把这个锁消除掉。
- 锁膨胀:因为控制的锁粒度太小,导致频繁加锁和释放锁,所以它就会把锁的范围扩大。