乐观锁和悲观锁是并发控制的两种不同策略,用于处理多个线程同时访问共享资源的情况。它们的主要区别在于对并发冲突的处理方式。
通过锁定资源,其他线程需要等待锁被释放才能继续访问。
悲观锁常用于对共享资源进行长时间占用的场景,如数据库中的表锁和行锁。悲观锁可能会导致性能下降,特别是在高并发情况下,因为它会阻塞其他线程的操作。
如果没有冲突,则更新成功;如果冲突,则返回用户错误的信息,让用户决定如何去做,需要进行回滚或重新尝试。
乐观锁常用于对共享资源进行短时间占用的场景,如线程间的读写操作冲突。乐观锁可以避免锁的开销,提高性能,但在并发冲突较频繁的情况下可能需要频繁的回滚和重试。
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
通过自动切换策略,synchronized可以根据实际情况调整使用的锁策略,在竞争较少时提供较高的并发性能,而在竞争激烈时保证线程安全。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
读写锁就是把读操作和写操作区分对待.
读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。读写锁可以提高并发性能,使得多个线程可以同时读取数据,而在写操作时保持独占性,保证数据的一致性和完整性。
读写锁由两个部分组成:读锁和写锁。在读锁下,多个线程可以同时获取读锁,读取共享资源没有互斥的限制。而在写锁下,只有一个线程可以获取写锁,其他线程无法获取读锁或写锁,保证了写操作的原子性和独占性。
读写锁的特点如下:
读写锁适用于读多写少的场景,可以有效地提高系统的并发性能。对于读操作比写操作频繁的情况,使用读写锁可以减少线程争抢和等待的时间,提高系统的响应速度。
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
其中,
注意:Synchronized 不是读写锁.
首先我们要知道:锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的:

注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
重量级锁和轻量级锁是Java中用于实现同步的两种不同机制。它们的主要区别在于锁的获取和释放的开销。
重量级锁(Heavyweight Lock):
轻量级锁(Lightweight Lock):
如何理解用户态 vs 内核态: 想象去银行办业务. 在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.
在窗口内,工作人员做, 这是内核态. 内核态的时间成本是不太可控的. 如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.
总结:
注意:synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
自旋锁和挂起等待锁是多线程编程中常用的两种锁策略,用于解决线程之间的竞争条件。
自旋锁是一种忙等待锁策略,它在获取锁时,使用循环来反复检查锁的状态,直到锁被释放。如果锁的状态为被占用,则当前线程会一直处于循环等待的状态,直到其他线程释放了锁。
自旋锁适用于锁竞争激烈但等待锁时间较短的情况。好处是线程不会进入阻塞状态,避免了线程切换的开销,但同时也会占用CPU资源。
挂起等待锁是一种在获取锁失败时,将线程置为休眠状态等待锁释放的策略。当一个线程尝试获取锁时,如果锁已被其他线程占用,当前线程会被挂起,不会再占用CPU资源,直到锁被释放并唤醒线程。
挂起等待锁适用于锁竞争不激烈或等待锁时间较长的情况。它可以有效地减少CPU资源的使用,但也引入了线程切换和上下文切换的开销。
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了
陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,这个很长的时间间隔里,女神可能已经换了好几个男票了).
死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种典型的 轻量级锁 的实现方式.
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢?
注意:
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构,来记录线程们的先后顺序.
公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized 是非公平锁.
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
而 Linux 系统提供的 mutex 是不可重入锁.
CAS(Compare and Swap)是一种并发算法,用于解决多线程环境下的原子性操作问题。它是一种乐观锁的实现方式,通过比较共享变量的当前值与期望值是否相等来确定是否进行更新操作。
CAS操作包含三个参数:共享变量的内存地址、期望值和新值。它的执行步骤如下:
CAS操作是原子性的,它不需要使用锁来保护共享变量,因此减少了锁的开销。同时,CAS操作的执行是非阻塞的,没有线程被挂起,增加了系统的并发性能。
然而,CAS操作也存在一些限制:
为了解决ABA问题,通常使用版本号或标记位来标识共享变量的修改次数。每次修改时都会对版本号进行更新,即使值没有实际变化,也能保证CAS操作的正确性。
标准库中提供了 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 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)

线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.

线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环,在循环里重新读取 value 的值赋给 oldValue


通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.
自旋锁伪代码:
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;
}
}
ABA 的问题:假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A,接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.

这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机。
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.:
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:
异常的过程
解决方案:给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
真正修改的时候:
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
对比理解上面的转账例子:假设 滑稽老哥有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.我们期望一个线程执行 -50 成功, 另一个线程 -50 失败,为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。

偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)。
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销,但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
举个栗子理解偏向锁:
假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证结婚(避免了高成本操作), 也可以一直幸福的生活下去.
但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作完成了, 让女配死心.
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源,因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了,也就是所谓的 “自适应”
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.
锁的粒度: 粗和细

实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁,但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
举个栗子理解锁粗化:
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
方式二:
显然, 方式二是更高效的方案.
可以看到, synchronized 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂,也不至于写出特别慢的程序.
Callable 是一个接口 . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
代码示例: 创建线程计算 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 操作, 代码复杂, 容易出错.
代码示例: 创建线程计算 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:
理解 FutureTask:
FutureTask是RunnableFuture接口的一个实现类,并且实现了Runnable接口,RunnableFuture接口继承了Runnable和Future接口。由于FutureTask实现了Runnable接口,因此它可以被提交给线程池执行,同时又可以获取任务的返回结果。