Java 的并发编程中,为了保证线程安全和高性能,采用了两种主要的同步手段:锁机制和无锁编程。以下是对锁机制、无锁编程、死锁及其避免的详细讲解。
一、无锁编程
无锁编程通过原子操作来避免传统锁,从而减少线程的上下文切换,提升性能。在 Java 中,通常使用 java.util.concurrent.atomic
包中的类来实现无锁操作。
1.1. 无锁编程的核心:CAS(Compare-And-Swap)
CAS 是无锁编程的核心机制,用来实现原子性更新。CAS 操作有三个参数:
- V:内存地址的变量值
- E:期望值
- N:新值
在进行 CAS 操作时,如果 V == E
,则 V
更新为 N
,如果不相等,表示有其他线程在操作这个值,操作失败。这样实现了原子性更新。
1.2. Java 中的无锁实现
-
原子变量:Java 提供了一些原子类,如
AtomicInteger
、AtomicLong
、AtomicReference
,它们通过底层的 CAS 实现来保证原子性。AtomicInteger atomicInteger = new AtomicInteger(0); atomicInteger.incrementAndGet(); // 原子性递增
-
自旋锁:通过不断循环检查某个条件来决定是否进入临界区。
CAS
属于一种自旋锁。Java 的ReentrantLock
提供tryLock
方法来实现非阻塞的加锁逻辑。 -
无锁集合:Java 提供了
ConcurrentLinkedQueue
、ConcurrentLinkedDeque
等无锁集合类,这些类基于 CAS 操作设计,支持高并发环境下的操作。
1.3. 无锁编程的优缺点
-
优点:
- 避免线程阻塞,减少上下文切换。
- 性能高,适合高并发环境。
-
缺点:
- 逻辑复杂,CAS 循环可能导致高开销。
- ABA 问题:CAS 判断时,如果变量的值由 A 变为 B,再变回 A,会误判未变化。Java 使用
AtomicStampedReference
来解决 ABA 问题。
二、锁机制详解和分类
Java 提供了多种锁机制,以 synchronized
和 ReentrantLock
为代表。锁机制分为多种类型,根据其特性可分为以下几类。
2.1 锁的分类
-
可重入锁(Reentrant Lock)
- 概念:允许同一线程在持有锁的情况下多次获得该锁。Java 中的
synchronized
和ReentrantLock
都是可重入锁。 - 实现:维护一个计数器记录同一线程重复获得锁的次数,解锁时减少计数,直至计数为零时释放锁。
- 优点:防止死锁,允许递归调用。
- 概念:允许同一线程在持有锁的情况下多次获得该锁。Java 中的
-
公平锁和非公平锁
- 公平锁:多个线程按照请求锁的顺序获得锁。
ReentrantLock
可以通过构造函数设置为公平锁。 - 非公平锁:线程获取锁的顺序不固定,可能出现“插队”,有时提高性能。
synchronized
和ReentrantLock
默认是非公平锁。 - 优缺点:公平锁保证了请求的顺序,避免了线程饥饿;非公平锁在高并发场景下能减少上下文切换,性能更高。
- 公平锁:多个线程按照请求锁的顺序获得锁。
-
独占锁和共享锁
- 独占锁:一次只能被一个线程持有,
synchronized
和ReentrantLock
是独占锁的典型代表。 - 共享锁:多个线程可以共享该锁,如
ReadWriteLock
,允许多个读线程同时访问,但写线程独占。 - 使用场景:共享锁适合读多写少的场景,避免独占锁的性能瓶颈。
- 独占锁:一次只能被一个线程持有,
-
悲观锁和乐观锁
- 悲观锁:认为每次操作都会引起冲突,因此上锁以避免冲突,
synchronized
和ReentrantLock
都是悲观锁。 - 乐观锁:假设冲突很少发生,因此不加锁,而是通过 CAS 来检测冲突,重试直到成功。这种机制用于无锁编程。
- 使用场景:乐观锁适用于读多写少的场景,悲观锁适合冲突频繁的场景。
- 悲观锁:认为每次操作都会引起冲突,因此上锁以避免冲突,
-
自旋锁
- 概念:线程获取锁时不会立即阻塞,而是采用“忙等”方式尝试获取锁。
- 优点:减少线程挂起和恢复的开销,但会消耗 CPU 资源。
- 使用场景:适用于锁等待时间短的情况,如 CAS 自旋机制。
2.2 锁的实现示例
synchronized
:Java 内置关键字,简单易用,具有可重入性。由 JVM 实现,不支持超时。ReentrantLock
:是 Java 并发包中更灵活的锁,可以实现公平锁、超时等待、响应中断。ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }
ReadWriteLock
:读写锁,读锁共享,写锁独占。ReentrantReadWriteLock
是常用实现。StampedLock
:支持乐观读锁的锁,可以提高读多写少场景下的性能。
三、死锁及其避免
死锁是指两个或多个线程相互等待对方释放资源,导致程序无法继续执行。发生死锁的条件包括:
- 互斥条件:一个资源一次只能被一个线程占用。
- 占有且等待:一个线程在持有资源的同时,仍在请求其他资源。
- 不可剥夺:资源不能被强制释放,只能由持有它的线程释放。
- 环形等待:多个线程形成一个循环等待链。
3.1 死锁示例
以下代码展示了两个线程死锁的情况:
class DeadlockDemo {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try { Thread.sleep(10); } catch (InterruptedException e) {}
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 2...");
}
}
}
public void method2() {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try { Thread.sleep(10); } catch (InterruptedException e) {}
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 1...");
}
}
}
}
这里,method1
和 method2
分别尝试获取 lock1
和 lock2
,导致两个线程相互等待对方释放锁,从而产生死锁。
3.2 避免死锁的方法
- 破坏环形等待条件:规定获取锁的顺序,避免多个线程在请求资源时形成环。
- 使用
tryLock
:在等待一段时间后自动放弃,避免长时间等待锁,ReentrantLock
提供了tryLock()
方法。if (lock.tryLock(1000, TimeUnit.MILLISECONDS)) { try { // 临界区代码 } finally { lock.unlock(); } }
- 避免嵌套锁:尽量减少锁的嵌套,或者统一加锁顺序。
- 使用超时机制:设置线程获取资源的等待时间,超时后主动释放锁并重试,避免无限期等待。
3.3 死锁检测工具
JVM 提供了 jstack
工具,可以用于分析线程堆栈信息,检查是否发生死锁。