1. 为什么需要锁?
在单线程世界里,代码按顺序执行,一切井然有序。但在多线程环境下,多个线程同时操作共享资源(比如一个变量、一个文件、一个数据库连接)时,就会引发一系列问题,最典型的就是:
-
竞态条件:多个线程以非预期的顺序读写共享数据,导致最终结果依赖于线程执行的时序,是不可预测的。
- 经典例子:
i++操作。它看似是原子操作,实则包含三步:- 读取:从内存中读取
i的值。 - 修改:将值加 1。
- 写入:将新值写回内存。
- 读取:从内存中读取
- 如果两个线程 A 和 B 同时执行
i++(假设i初始为 0):- A 读取
i(0) - B 读取
i(0) - A 修改
i(0 -> 1) - B 修改
i(0 -> 1) - A 写入
i(1) - B 写入
i(1)
- A 读取
- 最终结果是 1,而不是我们期望的 2。这就是竞态条件。
- 经典例子:
-
内存可见性问题:由于 CPU 缓存、指令重排等原因,一个线程对共享变量的修改,可能无法立即被其他线程看到。
- 例子:线程 A 修改了
flag = true,但这个修改只存在于 A 的 CPU 缓存中,尚未刷新到主内存。线程 B 从主内存中读取flag,得到的还是false。
- 例子:线程 A 修改了
锁,就是为了解决这些问题而生的。 它的核心目标是:在并发环境下,保证对共享资源访问的“原子性”、“可见性”和“有序性”。
2. 锁是什么?
你可以把锁想象成一个**“通行证”或“卫生间钥匙”**。
- 临界区:需要被保护的、操作共享资源的代码段,就像那个“卫生间”。
- 锁:就是进入“卫生间”的唯一那把“钥匙”。
工作流程:
- 加锁:一个线程(线程 A)想要进入临界区,必须先获取锁(拿到钥匙)。如果锁是空闲的,线程 A 就能成功获取锁,并进入临界区。
- 持有锁:线程 A 在临界区内执行代码。在此期间,锁被它独占。
- 解锁:线程 A 执行完临界区代码后,必须释放锁(归还钥匙)。
- 阻塞与竞争:如果在线程 A 持有锁期间,另一个线程(线程 B)也尝试获取锁,它会发现锁已被占用。于是,线程 B 会被阻塞,进入等待状态,直到线程 A 释放锁。操作系统调度器会暂时停止执行线程 B,让它不再消耗 CPU。
通过这个机制,锁确保了同一时间,只有一个线程能进入临界区,从而将并发的操作变成了串行的操作,彻底解决了竞态条件问题。同时,锁的获取和释放通常包含内存屏障,可以保证可见性和有序性。
3. 锁的主要类型
A. 按特性分类
1. 互斥锁
这是最基本、最常见的一种锁。它的核心就是“互斥”,即保证在任何时刻,只有一个线程能持有该锁。
- 特点:独占、排他。
- 行为:
- 线程 A 获取锁后,其他尝试获取该锁的线程都会被阻塞,直到线程 A 释放锁。
- 被阻塞的线程在锁被释放后,会竞争锁,谁能抢到是不确定的(由操作系统调度决定)。
- 例子:Java 中的
synchronized关键字和ReentrantLock类(在非公平模式下默认行为),Python 的threading.Lock。
2. 读写锁
互斥锁虽然简单,但有些粗暴。如果多个线程只是读取共享数据,它们之间其实并不需要互斥,完全可以并发进行。读写锁就是为了优化这种“读多写少”的场景。
- 特点:将锁分为两种状态:读锁和写锁。
- 规则:
- 读锁(共享锁):多个线程可以同时持有读锁,用于读取数据。它们之间不互斥。
- 写锁(排他锁):只有一个线程能持有写锁,用于修改数据。
- 互斥规则:
- 读-写不共存:如果一个线程持有写锁,其他任何线程(无论是想读还是想写)都会被阻塞。
- 写-写不共存:如果一个线程已经持有写锁,其他想获取写锁的线程会被阻塞。
- 读-读可共存:多个线程可以同时持有读锁。
- 优点:在读多写少的场景下,能极大提高并发性能。
- 缺点:实现比互斥锁复杂,且可能引起“写饥饿”(Starvation):如果读操作持续不断,写线程可能长时间无法获取到锁。
- 例子:Java 中的
ReentrantReadWriteLock。
3. 自旋锁
前面提到的互斥锁和读写锁,在获取锁失败时,线程会阻塞,这涉及到操作系统的线程上下文切换,成本较高(用户态 -> 内核态)。自旋锁则采取了另一种策略。
- 特点:获取锁失败时,线程不会阻塞,而是进入一个忙循环(自旋),不断地检查锁是否已被释放。
- 适用场景:锁被持有的时间非常短。因为自旋会持续消耗 CPU,如果锁持有时间长,自旋就会浪费大量 CPU 资源。
- 优点:避免了线程上下文切换的开销,响应速度快。
- 缺点:在锁持有时间长或竞争激烈时,会严重浪费 CPU 资源。
- 例子:在 Java 中,
synchronized在轻量级锁阶段就使用了自旋策略。许多操作系统内核也大量使用自旋锁。
4. 可重入锁
也叫递归锁。它允许同一个线程多次获取同一把锁。
- 为什么需要? 考虑一个场景:一个方法被
synchronized修饰,在这个方法内部,它又调用了另一个也被synchronized修饰的方法(且是同一个锁对象)。- 如果锁是不可重入的,线程在第一次进入方法时获取了锁,当它尝试第二次获取锁时,会因为锁已被自己持有而失败,导致死锁。
- 特点:内部维护一个计数器。线程每获取一次锁,计数器加一;每释放一次锁,计数器减一。只有当计数器归零时,锁才真正被释放,其他线程才能获取。
- 优点:避免了死锁,使得代码逻辑更清晰、更灵活。
- 例子:Java 中的
synchronized和ReentrantLock都是可重入锁。Python 的threading.RLock。
4. 锁的底层实现与概念
A. 乐观锁 vs. 悲观锁
这是两种设计思想,而不是具体的锁类型。
-
悲观锁:
- 思想:总是假设最坏的情况,认为数据在操作时一定会被其他线程修改。所以在操作数据前,必须先加锁,把数据锁住,确保操作期间数据不会被其他线程干扰。
- 代表:前面提到的所有锁(互斥锁、读写锁等)都属于悲观锁。
- 适用场景:写操作多,竞争激烈的场景。
-
乐观锁:
- 思想:总是假设最好的情况,认为数据在操作时不会被其他线程修改。所以不加锁,只在更新数据时,检查一下在此期间数据是否被其他线程修改过。
- 实现机制:通常使用 CAS (Compare-And-Swap) 操作。
- 读取:从内存中读取一个值 V。
- 计算:基于 V 计算出新值 N。
- CAS:尝试将内存中的值从 V 更新为 N。这个操作是原子的,由 CPU 指令保证。
- 检查:在执行 CAS 时,CPU 会检查内存中的当前值是否仍然等于 V。
- 如果是,说明没有其他线程修改过,则更新为 N,操作成功。
- 如果不是(说明已被其他线程修改为 V’),则 CAS 操作失败,什么都不做。
- 失败处理:如果 CAS 失败,通常会进行重试(自旋),重新从步骤 1 开始,直到成功为止。
- ABA 问题:CAS 的一个经典问题。如果一个值原来是 A,被其他线程改成了 B,然后又改回了 A。当前线程用 CAS 检查时,发现值还是 A,就认为它没被修改过。这可能导致问题。解决方案是加上版本号(或时间戳),每次修改都递增版本号,CAS 时同时比较值和版本号。
- 代表:Java 中的
Atomic系列类(如AtomicInteger),数据库中的乐观并发控制。 - 适用场景:读多写少,竞争不激烈的场景。能避免加锁带来的开销,性能更高。
B. 公平锁 vs. 非公平锁
这是针对锁的获取策略而言的。
-
公平锁:
- 思想:严格按照线程请求锁的先后顺序来分配锁。先到先得,就像排队一样。
- 优点:所有线程都有机会获取锁,不会发生“饥饿”。
- 缺点:实现复杂,需要维护一个等待队列,并且线程切换开销大,吞吐量较低。
-
非公平锁:
- 思想:不保证获取锁的顺序。一个新来的线程可以直接尝试获取锁,如果刚好此时锁被释放,它就能“插队”成功,而不用在队列中等待。
- 优点:实现简单,减少了线程挂起和唤醒的开销,吞吐量更高。
- 缺点:可能导致后到的线程先获取锁,队列中的线程可能长时间等待,发生“饥饿”。
- 例子:Java 的
ReentrantLock可以在构造时指定公平性。synchronized是非公平锁。
5. 锁的优化
锁是保证并发安全的利器,但也是性能杀手。因此,现代操作系统和编程语言都对锁做了大量优化。
A. 锁升级(以 Java synchronized 为例)
这是 JVM 对 synchronized 做的优化,锁有四种状态,会随着竞争情况逐渐升级(不可逆):
- 无锁状态:没有线程竞争时,对象处于无锁状态。
- 偏向锁:
- 目的:优化只有一个线程反复获取锁的场景。
- 机制:当一个线程获取锁后,锁会“偏向”这个线程。之后该线程再进入同步块时,无需任何 CAS 操作,直接检查锁是否偏向自己即可,开销极低。
- 升级:当另一个线程尝试获取这个偏向锁时,偏向模式就会结束,升级为轻量级锁。
- 轻量级锁:
- 目的:优化多个线程交替获取锁,但竞争不激烈的场景。
- 机制:线程在获取锁时,会使用 CAS 操作尝试将锁对象的 Mark Word 指向自己线程的栈帧。如果成功,就获取到锁。如果失败(说明有竞争),它会尝试自旋一段时间,等待锁释放。
- 升级:如果自旋一定次数后仍未获取到锁,说明竞争激烈,就会升级为重量级锁。
- 重量级锁:
- 目的:处理真正激烈的锁竞争。
- 机制:这就是我们传统意义上的锁。未获取到锁的线程会被阻塞,并放入一个等待队列中,需要操作系统介入进行线程调度和唤醒。开销最大。
B. 锁消除
JIT 编译器在运行时,通过逃逸分析发现一段代码中加锁的对象,永远不会被其他线程访问到(即对象没有“逃逸”出当前线程),那么 JIT 就会自动将这个锁消除掉。
C. 锁粗化
如果一系列操作都在对同一个对象反复加锁和解锁(例如在一个循环体内),JIT 编译器会将锁的范围“粗化”,扩展到整个循环外部,只进行一次加锁和解锁,从而减少频繁加锁解锁的性能开销。
6. 锁的级别与粒度
-
锁的级别:通常指锁在软件栈中的位置。
- 用户级锁:在用户空间实现的锁,如
pthread_mutex,Java 的各种锁。获取失败时,通常需要通过系统调用陷入内核态进行线程阻塞。 - 内核级锁:在操作系统内核中使用的锁,用于保护内核数据结构。如自旋锁、互斥体。
- 用户级锁:在用户空间实现的锁,如
-
锁的粒度:指锁保护的数据范围大小。
- 粗粒度锁:用一个大的锁保护一大片数据或整个对象。实现简单,但并发性差,容易成为瓶颈。
- 细粒度锁:用多个小锁分别保护数据的不同部分。例如,
ConcurrentHashMap使用分段锁(或更精细的桶锁),不同的线程可以并发地修改不同段的数据,大大提高了并发度。 - 权衡:粒度越细,并发性越高,但实现越复杂,管理锁的开销也越大,且容易发生死锁。
7. 锁的替代方案与最佳实践
锁虽然强大,但容易引发死锁、活锁、性能瓶颈等问题。现代并发编程提倡“能不用锁就不用锁”。
A. 无锁编程
- 核心:使用原子操作(如 CAS)和内存屏障来构建线程安全的数据结构。
- 代表:
java.util.concurrent.atomic包下的所有类,Disruptor高性能框架。 - 优点:避免了锁的开销和风险,性能极高。
- 缺点:实现非常复杂,容易出错,思维模型与传统的锁不同。
B. 不可变对象
- 核心:对象一旦创建,其内部状态就永不改变。
- 原理:如果一个对象的状态不会变,那么它天生就是线程安全的,可以被任意多个线程共享,无需任何同步措施。
- 代表:Java 中的
String类,所有基本类型的包装类(Integer,Long等)。 - 优点:简单、安全、易于推理。
- 缺点:每次修改都需要创建一个新对象,可能会有一定的性能和内存开销。
C. Thread-Local Storage (线程局部变量)
- 核心:为每个线程都创建一个独立的变量副本。每个线程只访问和修改自己的副本,因此不存在共享,也就不需要同步。
- 代表:Java 中的
ThreadLocal类。 - 适用场景:需要将对象与线程进行关联的场景,如数据库连接、Session 管理、SimpleDateFormat(非线程安全)等。
- 缺点:可能引起内存泄漏(因为线程池中的线程生命周期很长,
ThreadLocal的弱引用被回收后,其 value 可能无法被回收),需要手动调用remove()清理。
D. 最佳实践
- 最小化锁的范围:只把真正需要保护的临界区代码块锁起来,不要把不相关的代码也放进去。
- 最小化锁的粒度:如果可能,使用多个细粒度锁代替一个粗粒度锁。
- 避免嵌套锁:这是导致死锁最常见的原因。如果必须嵌套,确保所有线程都以相同的顺序获取锁。
- 使用更高级的并发工具:优先使用
java.util.concurrent包下的工具类,如ConcurrentHashMap,CountDownLatch,Semaphore等,它们经过高度优化,比手动使用synchronized更安全、更高效。 - 文档化同步策略:如果你的类是线程安全的,一定要在文档中清晰地说明它的同步策略、使用了哪些锁、调用方需要遵守什么约定等。
8. 总结
| 特性/类型 | 核心思想 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 独占访问,同一时间只有一个线程能进入临界区。 | 简单、直观,能保证绝对安全。 | 性能较低,可能导致线程阻塞和上下文切换。 | 通用场景,写操作频繁或临界区执行时间较长。 |
| 读写锁 | 读共享,写独占。 | 读多写少时,并发性能远高于互斥锁。 | 实现复杂,可能导致写饥饿。 | 读操作远多于写操作的场景,如缓存。 |
| 自旋锁 | 获取锁失败时忙等待,不阻塞线程。 | 避免了上下文切换,响应快。 | 浪费 CPU 资源,可能造成活锁。 | 锁持有时间极短,且不希望线程被阻塞的场景。 |
| 可重入锁 | 允许同一个线程多次获取同一把锁。 | 避免死锁,代码逻辑更灵活。 | 实现稍复杂(需维护计数器)。 | 递归调用或需要在一个已加锁的方法中调用另一个加锁方法。 |
| 悲观锁 | 假设总会发生冲突,先加锁再操作。 | 安全性高,实现简单。 | 性能开销大,可能引起阻塞。 | 写操作多,竞争激烈的场景。 |
| 乐观锁 | 假设不会发生冲突,更新时检查冲突。 | 无锁,性能高,不阻塞线程。 | 实现复杂,有 ABA 问题,可能因重试消耗 CPU。 | 读多写少,竞争不激烈的场景。 |
| 公平锁 | 按请求顺序分配锁,先到先得。 | 无饥饿,所有线程公平。 | 吞吐量低,实现复杂。 | 对公平性要求严格的场景。 |
| 非公平锁 | 允许插队,不保证顺序。 | 吞吐量高,实现简单。 | 可能导致线程饥饿。 | 对吞吐量要求高,对公平性要求不高的通用场景。 |
并发锁是并发编程的基石,理解其原理、类型和优化策略是编写高质量、高性能并发程序的关键。 在实际开发中,应根据具体场景,权衡安全性与性能,选择最合适的同步机制,甚至优先考虑无锁、不可变等更现代的并发方案。



903

被折叠的 条评论
为什么被折叠?



