多线程中的锁
在 Java 多线程编程中,锁是用来控制多个线程对共享资源访问的重要机制。本文介绍几种常见的锁概念。
乐观锁和悲观锁
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用算法实现,如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。比如 Java 里面的同步原语 synchronized 关键字就是悲观锁。
// 悲观锁示例:synchronized
public synchronized void updateData() {
// 获取锁后才能执行,其他线程必须等待
count++;
}
// 乐观锁示例:CAS(Compare And Swap)
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
// CAS 自旋,预期值为旧值,更新为新值
int oldValue;
do {
oldValue = count.get();
} while (!count.compareAndSet(oldValue, oldValue + 1));
}
独享锁和共享锁
独享锁(X 锁)和共享锁(S 锁)同样也是一种概念。独享锁也叫排他锁(写锁),是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。这就保证了其他线程在 T 线程释放 A 上的锁之前不能再读取和修改 A。共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。共享锁下其它线程可以并发读取,查询数据。但不能修改,增加,删除数据。实现了资源共享。
// ReentrantReadWriteLock 实现独享锁和共享锁
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 共享锁(读锁):多个线程可以同时读取
public void readData() {
rwLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在读取数据");
} finally {
rwLock.readLock().unlock();
}
}
// 独享锁(写锁):只有一个线程能写入
public void writeData() {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在写入数据");
} finally {
rwLock.writeLock().unlock();
}
}
自旋锁和适应性自旋锁
自旋锁:如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!
JVM 对于自旋周期的选择,jdk1.5 这个限度是一定的写死的,在 JDK1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
// 手写自旋锁示例
public class SpinLock {
private AtomicReference
<Thread> owner = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
// 自旋等待,直到获取到锁
while (!owner.compareAndSet(null, current)) {
// 空转等待
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
无锁、偏向锁、轻量级锁和重量级锁
这四种锁是指锁的状态,专门针对 synchronized 的。他们级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
- 无锁:对资源没有锁定,所有的线程都能访问并修改该资源,但同时只有一个线程能修改成功。
- 偏向锁:一段同步代码一直被一个线程所访问,该线程会自动获取锁,降低获取锁的代价。
- 轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
- 重量级锁:当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
// synchronized 锁升级过程演示
public class LockEscalationDemo {
private int count = 0;
// 初始为偏向锁,当有其他线程竞争时升级为轻量级锁
// 竞争激烈时再升级为重量级锁
public synchronized void add() {
count++;
}
}
公平锁和非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
// ReentrantLock 支持公平锁和非公平锁
// 公平锁:按线程申请顺序获取锁
ReentrantLock fairLock = new ReentrantLock(true);
// 非公平锁(默认):线程直接尝试获取锁
ReentrantLock unfairLock = new ReentrantLock(false);
// 对比:synchronized 是非公平锁,无法设置为公平锁
可重入锁和非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。Java 中 synchronized 就是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
如果是一个不可重入锁,那么当前线程在调用 doOthers() 之前需要将执行 doSomething() 时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
// 可重入锁示例:synchronized
public class ReentrantDemo {
public synchronized void doSomething() {
System.out.println("外层方法获取锁");
// 同一线程可以直接进入内层方法,无需再次竞争锁
doOthers();
}
public synchronized void doOthers() {
System.out.println("内层方法自动获取锁(可重入)");
}
}
// ReentrantLock 也是可重入锁
public class ReentrantLockDemo {
private ReentrantLock lock = new ReentrantLock();
public void outer() {
lock.lock();
try {
inner(); // 同一线程可重入
} finally {
lock.unlock();
}
}
public void inner() {
lock.lock();
try {
System.out.println("内层方法重入成功");
} finally {
lock.unlock();
}
}
}

总结:
以上六种锁是从不同角度对锁进行分类,它们之间并不互斥:
- synchronized 是悲观锁、可重入锁、非公平锁。
- ReentrantLock 是悲观锁、可重入锁,可公平可非公平。
- 乐观锁通常通过 CAS 实现,如 Atomic 系列。
- ReentrantReadWriteLock 的读锁是共享锁,写锁是独享锁。
- 锁的状态(偏向/轻量级/重量级)是 JVM 层面对 synchronized 的优化。
