并发编程之锁

1. 为什么需要锁?

在单线程世界里,代码按顺序执行,一切井然有序。但在多线程环境下,多个线程同时操作共享资源(比如一个变量、一个文件、一个数据库连接)时,就会引发一系列问题,最典型的就是:

  • 竞态条件:多个线程以非预期的顺序读写共享数据,导致最终结果依赖于线程执行的时序,是不可预测的。

    • 经典例子i++ 操作。它看似是原子操作,实则包含三步:
      1. 读取:从内存中读取 i 的值。
      2. 修改:将值加 1。
      3. 写入:将新值写回内存。
    • 如果两个线程 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)
    • 最终结果是 1,而不是我们期望的 2。这就是竞态条件。
  • 内存可见性问题:由于 CPU 缓存、指令重排等原因,一个线程对共享变量的修改,可能无法立即被其他线程看到。

    • 例子:线程 A 修改了 flag = true,但这个修改只存在于 A 的 CPU 缓存中,尚未刷新到主内存。线程 B 从主内存中读取 flag,得到的还是 false

锁,就是为了解决这些问题而生的。 它的核心目标是:在并发环境下,保证对共享资源访问的“原子性”、“可见性”和“有序性”。


2. 锁是什么?

你可以把锁想象成一个**“通行证”“卫生间钥匙”**。

  • 临界区:需要被保护的、操作共享资源的代码段,就像那个“卫生间”。
  • :就是进入“卫生间”的唯一那把“钥匙”。

工作流程:

  1. 加锁:一个线程(线程 A)想要进入临界区,必须先获取锁(拿到钥匙)。如果锁是空闲的,线程 A 就能成功获取锁,并进入临界区。
  2. 持有锁:线程 A 在临界区内执行代码。在此期间,锁被它独占。
  3. 解锁:线程 A 执行完临界区代码后,必须释放锁(归还钥匙)。
  4. 阻塞与竞争:如果在线程 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) 操作。
      1. 读取:从内存中读取一个值 V。
      2. 计算:基于 V 计算出新值 N。
      3. CAS:尝试将内存中的值从 V 更新为 N。这个操作是原子的,由 CPU 指令保证。
      4. 检查:在执行 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 做的优化,锁有四种状态,会随着竞争情况逐渐升级(不可逆):

  1. 无锁状态:没有线程竞争时,对象处于无锁状态。
  2. 偏向锁
    • 目的:优化只有一个线程反复获取锁的场景。
    • 机制:当一个线程获取锁后,锁会“偏向”这个线程。之后该线程再进入同步块时,无需任何 CAS 操作,直接检查锁是否偏向自己即可,开销极低。
    • 升级:当另一个线程尝试获取这个偏向锁时,偏向模式就会结束,升级为轻量级锁。
  3. 轻量级锁
    • 目的:优化多个线程交替获取锁,但竞争不激烈的场景。
    • 机制:线程在获取锁时,会使用 CAS 操作尝试将锁对象的 Mark Word 指向自己线程的栈帧。如果成功,就获取到锁。如果失败(说明有竞争),它会尝试自旋一段时间,等待锁释放。
    • 升级:如果自旋一定次数后仍未获取到锁,说明竞争激烈,就会升级为重量级锁。
  4. 重量级锁
    • 目的:处理真正激烈的锁竞争。
    • 机制:这就是我们传统意义上的锁。未获取到锁的线程会被阻塞,并放入一个等待队列中,需要操作系统介入进行线程调度和唤醒。开销最大。
B. 锁消除

JIT 编译器在运行时,通过逃逸分析发现一段代码中加锁的对象,永远不会被其他线程访问到(即对象没有“逃逸”出当前线程),那么 JIT 就会自动将这个锁消除掉。

C. 锁粗化

如果一系列操作都在对同一个对象反复加锁和解锁(例如在一个循环体内),JIT 编译器会将锁的范围“粗化”,扩展到整个循环外部,只进行一次加锁和解锁,从而减少频繁加锁解锁的性能开销。


6. 锁的级别与粒度

  • 锁的级别:通常指锁在软件栈中的位置。

    • 用户级锁:在用户空间实现的锁,如 pthread_mutex,Java 的各种锁。获取失败时,通常需要通过系统调用陷入内核态进行线程阻塞。
    • 内核级锁:在操作系统内核中使用的锁,用于保护内核数据结构。如自旋锁、互斥体。
  • 锁的粒度:指锁保护的数据范围大小。

    • 粗粒度锁:用一个大的锁保护一大片数据或整个对象。实现简单,但并发性差,容易成为瓶颈。
    • 细粒度锁:用多个小锁分别保护数据的不同部分。例如,ConcurrentHashMap 使用分段锁(或更精细的桶锁),不同的线程可以并发地修改不同段的数据,大大提高了并发度。
    • 权衡:粒度越细,并发性越高,但实现越复杂,管理锁的开销也越大,且容易发生死锁。

7. 锁的替代方案与最佳实践

锁虽然强大,但容易引发死锁、活锁、性能瓶颈等问题。现代并发编程提倡“能不用锁就不用锁”。

A. 无锁编程
  • 核心:使用原子操作(如 CAS)和内存屏障来构建线程安全的数据结构。
  • 代表java.util.concurrent.atomic 包下的所有类,Disruptor 高性能框架。
  • 优点:避免了锁的开销和风险,性能极高。
  • 缺点:实现非常复杂,容易出错,思维模型与传统的锁不同。
B. 不可变对象
  • 核心:对象一旦创建,其内部状态就永不改变。
  • 原理:如果一个对象的状态不会变,那么它天生就是线程安全的,可以被任意多个线程共享,无需任何同步措施。
  • 代表:Java 中的 String 类,所有基本类型的包装类(IntegerLong 等)。
  • 优点:简单、安全、易于推理。
  • 缺点:每次修改都需要创建一个新对象,可能会有一定的性能和内存开销。
C. Thread-Local Storage (线程局部变量)
  • 核心:为每个线程都创建一个独立的变量副本。每个线程只访问和修改自己的副本,因此不存在共享,也就不需要同步。
  • 代表:Java 中的 ThreadLocal 类。
  • 适用场景:需要将对象与线程进行关联的场景,如数据库连接、Session 管理、SimpleDateFormat(非线程安全)等。
  • 缺点:可能引起内存泄漏(因为线程池中的线程生命周期很长,ThreadLocal 的弱引用被回收后,其 value 可能无法被回收),需要手动调用 remove() 清理。
D. 最佳实践
  1. 最小化锁的范围:只把真正需要保护的临界区代码块锁起来,不要把不相关的代码也放进去。
  2. 最小化锁的粒度:如果可能,使用多个细粒度锁代替一个粗粒度锁。
  3. 避免嵌套锁:这是导致死锁最常见的原因。如果必须嵌套,确保所有线程都以相同的顺序获取锁。
  4. 使用更高级的并发工具:优先使用 java.util.concurrent 包下的工具类,如 ConcurrentHashMapCountDownLatchSemaphore 等,它们经过高度优化,比手动使用 synchronized 更安全、更高效。
  5. 文档化同步策略:如果你的类是线程安全的,一定要在文档中清晰地说明它的同步策略、使用了哪些锁、调用方需要遵守什么约定等。

8. 总结

特性/类型核心思想优点缺点适用场景
互斥锁独占访问,同一时间只有一个线程能进入临界区。简单、直观,能保证绝对安全。性能较低,可能导致线程阻塞和上下文切换。通用场景,写操作频繁或临界区执行时间较长。
读写锁读共享,写独占。读多写少时,并发性能远高于互斥锁。实现复杂,可能导致写饥饿。读操作远多于写操作的场景,如缓存。
自旋锁获取锁失败时忙等待,不阻塞线程。避免了上下文切换,响应快。浪费 CPU 资源,可能造成活锁。锁持有时间极短,且不希望线程被阻塞的场景。
可重入锁允许同一个线程多次获取同一把锁。避免死锁,代码逻辑更灵活。实现稍复杂(需维护计数器)。递归调用或需要在一个已加锁的方法中调用另一个加锁方法。
悲观锁假设总会发生冲突,先加锁再操作。安全性高,实现简单。性能开销大,可能引起阻塞。写操作多,竞争激烈的场景。
乐观锁假设不会发生冲突,更新时检查冲突。无锁,性能高,不阻塞线程。实现复杂,有 ABA 问题,可能因重试消耗 CPU。读多写少,竞争不激烈的场景。
公平锁按请求顺序分配锁,先到先得。无饥饿,所有线程公平。吞吐量低,实现复杂。对公平性要求严格的场景。
非公平锁允许插队,不保证顺序。吞吐量高,实现简单。可能导致线程饥饿。对吞吐量要求高,对公平性要求不高的通用场景。

并发锁是并发编程的基石,理解其原理、类型和优化策略是编写高质量、高性能并发程序的关键。 在实际开发中,应根据具体场景,权衡安全性与性能,选择最合适的同步机制,甚至优先考虑无锁、不可变等更现代的并发方案。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值