1. 常见的锁策略
以下介绍的锁策略不仅局限于 Java,这些性质通常也是给锁的实现者参考的。了解它们对使用锁也有帮助。
1.1 乐观锁与悲观锁
悲观锁: 总是假设最坏的情况,每次拿数据时都认为别人会修改,所以每次都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁: 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测。如果发现并发冲突了,则返回错误信息,让用户决定如何去做。
示例:同学 A 和 B 想请教老师问题。
- 同学 A(悲观):先发消息确认老师是否有空,得到肯定答复后才去问问题。如果否定则等待后再试。
- 同学 B(乐观):直接去找老师。如果老师忙,下次再来;如果闲则问题解决。虽然没加锁,但能识别出数据访问冲突。
Synchronized 初始使用乐观锁策略。当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。
1.2 轻量级锁与重量级锁
轻量级锁: 当一个线程尝试获取某个对象的锁时,如果该对象没有被其他线程锁定,则当前线程会将对象头中的 Mark Word 设置为指向当前线程栈帧的一个指针,这个过程称为'偏向锁'。如果多个线程同时竞争同一个锁,那么 JVM 会升级锁的状态,从偏向锁升级到轻量级锁。此时,每个线程都会尝试使用 CAS 操作来获取锁,如果成功则获得锁并进入临界区;如果失败,则自旋等待一段时间后再次尝试。
特点:
- 减少了操作系统上下文切换的开销。
- 在线程间竞争不激烈的情况下表现良好。
- 如果竞争过于激烈,可能会导致频繁的自旋,浪费 CPU 资源。
重量级锁: 传统的 Java 锁机制,如 synchronized 关键字所实现的锁,通常被称为重量级锁。当一个线程获取了某个对象的锁后,其他试图获取同一对象锁的线程会被阻塞,直到第一个线程释放锁为止。被阻塞的线程将进入等待队列,由操作系统负责管理这些线程的调度。
特点:
- 线程阻塞和唤醒的代价较高。
- 更适用于线程竞争激烈的场景,因为它可以避免 CPU 空转浪费资源。
- 相比轻量级锁,重量级锁的实现更加简单直接。
1.3 自旋锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU。这个时候就可以使用自旋锁来处理这样的问题,如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。
优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源。(而挂起等待的时候是不消耗 CPU 的)。
1.4 公平锁与非公平锁
假设三个线程 A, B, C。A 先尝试获取锁,获取成功。然后 B 再尝试获取锁,获取失败,阻塞等待;然后 C 也尝试获取锁,C 也获取失败,也阻塞等待。当线程 A 释放锁的时候,会发生什么呢?
公平锁: 遵守'先来后到'。B 比 C 先来的。当 A 释放锁之后,B 就能先于 C 获取到锁。
非公平锁: 不遵守'先来后到'。B 和 C 都有可能获取到锁。
注意:
- 操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制,锁就是非公平锁。如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序。
- 公平锁和非公平锁没有好坏之分,关键还是看适用场景。
- synchronized 是非公平锁。
1.5 可重入锁和不可重入锁
可重入锁: 简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去该竞争同一把锁的时候,不需要等待,只需要记录重入次数。在多线程并发编程里面,绝大部分锁都是可重入的,比如 Synchronized、ReentrantLock 等,但是也有不支持重入的锁,比如 JDK8 里面提供的读写锁 StampedLock。锁的可重入性,主要解决的问题是避免线程死锁的问题。
1.6 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。


