一:常见的锁策略
1.1 乐观锁 vs 悲观锁
乐观锁和悲观锁是并发控制的两种不同策略,它们的主要区别在于对并发冲突的处理方式。
- 乐观锁是一种较乐观的并发控制策略,它假设在整个事务过程中不会发生冲突,因此不会加锁。而是通过在更新共享资源时检查是否有其他线程同时修改该资源。
- 悲观锁是一种较保守的并发控制策略,它假设在整个事务过程中会发生冲突,因此在访问共享资源之前会先加锁。
悲观锁常用于对共享资源进行长时间占用的场景,悲观锁可能会导致性能下降,特别是在高并发情况下,因为它会阻塞其他线程的操作。
乐观锁常用于对共享资源进行短时间占用的场景,如线程间的读写操作冲突。乐观锁可以避免锁的开销,提高性能,但在并发冲突较频繁的情况下可能需要频繁的回滚和重试。
Synchronized 刚开始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
1.2 读锁 vs 写锁(读写锁)
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
读写锁由两个部分组成:读锁和写锁。
- 在读锁下,多个线程可以同时获取读锁,读取共享资源没有互斥的限制。
- 而在写锁下,只有一个线程可以获取写锁,其他线程无法获取读锁或写锁,保证了写操作的原子性和独占性。
读写锁的特点如下:
- 多个线程可以同时获取读锁,实现读并发性。
- 写锁是独占的,只有一个线程可以获取写锁,实现写的原子性和独占性。
- 读锁和写锁之间是互斥的,即当有线程获取写锁时,其他线程无法获取读锁或写锁。
- 读锁可以降级为写锁,即在获取读锁的情况下再获取写锁,而写锁无法升级为读锁。
读写锁适用于读多写少的场景,可以有效地提高系统的并发性能。对于读操作比写操作频繁的情况,使用读写锁可以减少线程争抢和等待的时间,提高系统的响应速度。
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock方法进行加锁解锁.
注意:Synchronized 不是读写锁.
1.3 重量级锁 vs 轻量级锁
首先我们要知道:锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的:
- CPU 提供了 “原子操作指令”.
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.
注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
重量级锁和轻量级锁的主要区别在于锁的获取和释放的开销。
-
重量级锁(Heavyweight Lock):
- 重量级锁是使用操作系统的互斥量(Mutex)来实现的
- 重量级锁的获取和释放需要涉及用户态和内核态之间的切换,这种切换会消耗较多的时间。
- 重量级锁适用于多个线程访问一个共享资源且访问时间较长的情况。
-
轻量级锁(Lightweight Lock):
- 轻量级锁是一种乐观锁策略,在 Java 中是通过对象头中的标志位来实现的。
- 当只有一个线程访问一个对象时,它会尝试用 CAS 操作将对象头中的标志位设置为轻量级锁。
- 如果 CAS 操作成功,那么线程就获得了轻量级锁,可以直接进入临界区进行操作,不需要进入内核态。
- 如果有多个线程进行竞争,那么轻量级锁会膨胀为重量级锁,此时涉及到内核态的互斥操作。
- 轻量级锁适用于多个线程访问一个共享资源且访问时间较短的情况。
如何理解用户态 vs 内核态: 想象去银行办业务. 在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的. 在窗口内,工作人员做, 这是内核态.
内核态的时间成本是不太可控的. 如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.
注意:synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
1.4 自旋锁 vs 挂起等待锁
- 自旋锁(Spin Lock)
自旋锁是一种忙等待锁策略,它在获取锁时,使用循环来反复检查锁的状态,直到锁被释放。如果锁的状态为被占用,则当前线程会一直处于循环等待的状态,直到其他线程释放了锁。
自旋锁适用于锁竞争激烈但等待锁时间较短的情况。好处是线程不会进入阻塞状态,避免了线程切换的开销,但同时也会占用CPU资源。
- 挂起等待锁(Suspension Lock)
挂起等待锁是一种在获取锁失败时,将线程置为休眠状态等待锁释放的策略。当一个线程尝试获取锁时,如果锁已被其他线程占用,当前线程会被挂起,不会再占用CPU资源,直到锁被释放并唤醒线程。
挂起等待锁适用于锁竞争不激烈或等待锁时间较长的情况。它可以有效地减少CPU资源的使用,但也引入了线程切换和上下文切换的开销。
1.4.1 理解自旋锁和挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了
挂起等待锁:陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,这个很长的时间间隔里,女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种典型的轻量级锁的实现方式,synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
1.5 公平锁 vs 非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢?
- 公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
- 非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注意:
-
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构,来记录线程们的先后顺序.
-
公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized 是非公平锁.
1.6 可重入锁 vs 不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的,而 Linux 系统提供的 mutex 是不可重入锁.
二:CAS
CAS(Compare and Swap)是一种并发算法,用于解决多线程环境下的原子性操作问题。它是一种乐观锁的实现方式,通过比较共享变量的当前值与期望值是否相等来确定是否进行更新操作。
CAS 操作包含三个参数:共享变量的值、期望值和新值。它的执行步骤如下:
- 读取共享变量的当前值。
- 比较当前值与期望值是否相等。
- 如果相等,将共享变量的值设为新值。
- 如果不相等,说明其他线程已经修改了共享变量的值,当前操作失败,需要重新读取最新的值,并重新进行比较和更新,回到步骤 1 进行重复。
CAS 操作是原子性的,它不需要使用锁来保护共享变量,因此减少了锁的开销。同时,CAS 操作的执行是非阻塞的,没有线程被挂起,增加了系统的并发性能。
然而,CAS 操作也存在一些限制:
- ABA问题:如果共享变量的值在CAS操作前后被修改为相同的值,例如A->B->A, CAS 操作无法检测到这种情况,可能会造成意外结果。
- 自旋次数过多:如果CAS操作失败,线程需要不断重试直到成功,过多的自旋会占用 CPU 资源。
为了解决 ABA 问题,通常使用版本号或标记位来标识共享变量的修改次数。每次修改时都会对版本号进行更新,即使值没有实际变化,也能保证CAS操作的正确性。
2.1 CAS的应用
2.1.1 实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的,典型的就是 AtomicInteger 类. 其中的 getAndIncrement 方法就相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
CAS(value, oldValue, oldValue+1)
这行代码的意思是:将当前的 value 和 oldValue 进行比较,如果相等,则将 value 的值设为 oldValue + 1。如果不相等,则循环继续执行直到比较成功。
假设两个线程同时调用 getAndIncrement:
- 两个线程都读取 value 的值到 oldValue 中.
- 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.
- 线程 2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环,在循环里重新读取 value 的值赋给 oldValue
- 线程 2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
- 线程 1 和 线程 2 返回各自的 oldValue 的值即可.
通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.
2.1.2 实现自旋锁
自旋锁伪代码:
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
2.2 ABA 问题
ABA 的问题:假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A,接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:
- 先读取 num 的值, 记录到 oldNum 变量中.
- 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机。
2.2.1 ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.:
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:
- 存款 100. 线程 1 获取到当前存款值为 100, 期望更新为 50; 线程 2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程 2 阻塞等待中.
- 轮到线程 2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
异常的过程
- 存款 100. 线程 1 获取到当前存款值为 100, 期望更新为 50; 线程 2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程 2 阻塞等待中.
- 在线程 2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!
- 轮到线程 2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼!!
解决方案:给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
- CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候:
-
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
-
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
对比理解上面的转账例子:假设 滑稽老哥有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.我们期望一个线程执行 -50 成功, 另一个线程 -50 失败,为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程 1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程 2 获取到存款值为 100,版本号为 1, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50, 版本号改为 2. 线程 2 阻塞等待中.
- 在线程 2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成 3.
- 轮到线程 2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本, 认为操作失败.
三:Synchronized 原理
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
3.1 Synchronized 加锁工作过程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。
- 偏向锁:第一个尝试加锁的线程, 优先进入偏向锁状态。
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程,如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)。
如果后续有其他线程来竞争该锁,那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销,但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
- 轻量级锁:随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态
此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源,因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了,也就是所谓的 “自适应”
- 重量级锁:如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
3.1.1 其他的优化操作
- 锁消除:编译器和 JVM 判断锁是否可消除. 如果可以, 就直接消除.
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.
- 锁粗化:一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁,但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
可以看到, synchronized 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂,也不至于写出特别慢的程序.
四 Callable 接口
Callable 是一个接口 . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
4.1 版本1
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本:
static class Result {
public int sum = 0;
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while (result.sum == 0) {
result.lock.wait();
}
System.out.println(result.sum);
}
}
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
4.2 版本2
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本:
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了.
理解 Callable:
- Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,Runnable,描述的是不带返回值的任务.
- Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果,因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
- FutureTask 就可以负责这个等待结果出来的工作.
理解 FutureTask:想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是 FutureTask.后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.