Java 线程同步解析
一、线程同步的必要性
在单线程环境中,程序的执行顺序是严格按照代码的编写顺序进行的,具有明确的执行路径和确定性的结果。这种环境下的资源访问是完全可控的,不会出现多个执行单元同时操作同一资源的情况。但在多线程编程场景下,情况就变得复杂得多 - 多个线程可以并发执行,同时访问和修改共享的内存区域、变量、文件句柄、数据库连接等资源,此时就会产生竞态条件(Race Condition)这一典型的并发编程问题。
竞态条件的本质在于多个线程对共享资源的访问顺序是不可预测的。举例来说,假设有两个线程(线程A和线程B)同时对一个共享的计数器变量进行自增操作,理论上每个线程执行一次自增后,计数器应该增加2。但实际运行时可能出现以下交错执行的场景:
- 线程A从内存中读取计数器的当前值为10
- 在线程A还未完成加1操作并写回内存前,线程调度器切换到线程B
- 线程B也读取到计数器的值为10
- 线程B完成加1操作并将11写回内存
- 线程A恢复执行,基于之前读取的10完成加1操作,也将11写回内存
最终的结果是:两个线程各执行了一次自增操作,但计数器只增加了1(从10变为11),而不是预期的12。这就是典型的因缺乏同步机制导致的数据不一致问题。
线程同步的核心目标就是通过特定的机制来控制多个线程对共享资源的访问顺序,确保在同一时刻只有一个线程能够执行特定的临界区(Critical Section)代码。这可以通过多种同步原语实现,例如:
- 互斥锁(Mutex):最基本的同步机制,保证同一时间只有一个线程能持有锁
- 信号量(Semaphore):控制同时访问资源的线程数量
- 条件变量(Condition Variable):用于线程间的通信和协调
- 原子操作(Atomic Operation):不可中断的单一指令操作
这些同步机制的正确使用可以避免竞态条件,保证多线程环境下数据的准确性和一致性,是构建可靠并发系统的关键所在。在实际开发中,还需要注意避免由此可能引发的死锁、活锁等新的并发问题。
二、线程同步的实现方式
synchronized 关键字
synchronized
是 Java 中最基本且常用的线程同步机制,它通过获取对象的锁来实现同步,主要有以下三种使用方式:
1. 修饰实例方法
此时锁是当前对象实例,多个线程访问同一个对象的该方法时,会进行同步;但访问不同对象的该方法时,不会同步。
public class Counter {private int count = 0;// 同步实例方法public synchronized void increment() {count++; // 临界区代码}
}
应用场景:当多个线程需要操作同一个对象实例的状态时,使用同步实例方法可以确保线程安全。
2. 修饰静态方法
锁是当前类的 Class 对象,由于 Class 对象在 JVM 中是唯一的,所以多个线程访问该静态方法时,无论操作的是哪个对象实例,都会进行同步。
public class Counter {private static int staticCount = 0;// 同步静态方法public static synchronized void staticIncrement() {staticCount++; // 临界区代码}
}
应用场景:当需要保护静态变量或类级别资源时,使用同步静态方法。
3. 修饰代码块
可以指定锁对象,灵活性更高。当只需要对方法中的部分代码进行同步时,使用同步代码块能减少锁的持有时间,提高程序性能。
public class Counter {private int count = 0;private final Object lock = new Object(); // 专用锁对象public void syncBlock() {// 非临界区代码// 可以执行一些不需要同步的操作synchronized (this) { // 或者使用 lock 对象// 临界区代码count++;}// 非临界区代码}
}
应用场景:
- 当只有部分代码需要同步时
- 需要更细粒度的锁控制时
- 使用不同的锁对象保护不同的资源时
synchronized 的底层实现
synchronized
的底层实现依赖于 JVM 的对象监视器(Monitor),其工作流程如下:
- 线程进入同步代码时,尝试获取 Monitor 的锁
- 如果锁未被占用,当前线程获取锁并进入临界区
- 如果锁已被占用,当前线程进入阻塞状态,放入 Monitor 的 EntryList 中等待
- 持有锁的线程执行完同步代码后释放锁,并唤醒 EntryList 中的等待线程
在 JDK 1.6 之后,synchronized
进行了大量优化,引入了以下锁升级机制:
- 偏向锁:适用于只有一个线程访问同步块的场景,避免CAS操作
- 轻量级锁:当有多个线程交替访问同步块时使用,通过CAS自旋尝试获取锁
- 重量级锁:当竞争激烈时升级为重量级锁,线程会进入阻塞状态
锁升级过程是不可逆的,目的是减少性能开销,使得在低竞争情况下有更好的性能表现。
ReentrantLock
ReentrantLock
是 JDK 1.5 引入的基于 AQS(AbstractQueuedSynchronizer)实现的可重入锁,它提供了比 synchronized
更灵活的同步操作,主要特点如下:
主要特性
可中断:通过
lockInterruptibly()
方法,线程在获取锁的过程中可以响应中断,避免线程无限期等待。try {lock.lockInterruptibly();// 临界区代码 } catch (InterruptedException e) {// 处理中断 } finally {lock.unlock(); }
可超时:
tryLock(long timeout, TimeUnit unit)
方法允许线程在指定时间内获取锁,如果超时仍未获取则返回 false,可有效避免死锁。if (lock.tryLock(1, TimeUnit.SECONDS)) {try {// 临界区代码} finally {lock.unlock();} } else {// 获取锁失败的处理 }
公平锁:可以通过构造函数
ReentrantLock(boolean fair)
指定为公平锁,公平锁会按照线程请求锁的顺序来分配锁,避免线程饥饿,但会带来一定的性能损耗;默认情况下为非公平锁。ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
条件变量:通过
newCondition()
方法获取 Condition 对象,可实现更精细的线程等待/通知机制,一个ReentrantLock
可以创建多个 Condition 对象,分别对应不同的等待队列。Condition condition = lock.newCondition();// 等待方 lock.lock(); try {while (!conditionSatisfied) {condition.await(); // 释放锁并等待}// 处理业务 } finally {lock.unlock(); }// 通知方 lock.lock(); try {// 改变条件condition.signalAll(); // 唤醒所有等待线程 } finally {lock.unlock(); }
基本使用流程
ReentrantLock lock = new ReentrantLock();try {lock.lock(); // 获取锁// 临界区代码count++;
} finally {lock.unlock(); // 释放锁,必须在finally中执行,确保锁能被释放
}
应用场景
- 需要可中断的锁获取操作
- 需要尝试获取锁的超时机制
- 需要公平锁机制
- 需要多个条件谓词(Condition)的复杂同步场景
- 需要可轮询的锁获取方式
原子类
java.util.concurrent.atomic
包下提供了一系列原子类,这些类利用 CAS(Compare-And-Swap)操作实现了无锁的线程安全,适用于简单的计数器、累加器等场景。
主要原子类
基本类型原子类:
AtomicInteger
:整型原子类AtomicLong
:长整型原子类AtomicBoolean
:布尔型原子类
AtomicInteger atomicInt = new AtomicInteger(0); int current = atomicInt.get(); // 获取当前值 atomicInt.set(10); // 设置新值 atomicInt.compareAndSet(expect, update); // CAS操作 atomicInt.incrementAndGet(); // 原子自增
引用类型原子类:
AtomicReference
:普通对象引用原子类AtomicStampedReference
:带有版本号的引用原子类AtomicMarkableReference
:带有标记位的引用原子类
AtomicReference<String> atomicRef = new AtomicReference<>("initial");// 解决ABA问题的示例 AtomicStampedReference<String> stampedRef = new AtomicStampedReference<>("initial", 0);int[] stampHolder = new int[1]; String value = stampedRef.get(stampHolder); // 同时获取值和版本号 stampedRef.compareAndSet("initial", "updated", stampHolder[0], stampHolder[0]+1);
数组类型原子类:
AtomicIntegerArray
:整型数组原子类AtomicLongArray
:长整型数组原子类AtomicReferenceArray
:引用类型数组原子类
AtomicIntegerArray array = new AtomicIntegerArray(10); array.set(0, 100); // 设置数组元素 int value = array.getAndIncrement(0); // 获取并自增
字段更新器:
AtomicIntegerFieldUpdater
AtomicLongFieldUpdater
AtomicReferenceFieldUpdater
class MyClass {volatile int count; }AtomicIntegerFieldUpdater<MyClass> updater = AtomicIntegerFieldUpdater.newUpdater(MyClass.class, "count");MyClass obj = new MyClass(); updater.incrementAndGet(obj); // 原子更新字段
CAS 原理
CAS(Compare-And-Swap)操作是一种乐观锁机制,它包含三个操作数:
- 内存位置(V)
- 预期原值(A)
- 新值(B)
当且仅当 V 的值等于 A 时,才会将 V 的值更新为 B,否则不做任何操作。CAS 操作是原子的,由 CPU 指令直接支持(如 x86 的 cmpxchg
指令),因此不需要加锁就能保证线程安全。
CAS 的伪代码表示:
boolean compareAndSwap(V, A, B) {if (V == A) {V = B;return true;}return false;
}
ABA 问题及解决方案
ABA 问题是指:
- 线程1读取变量值为 A
- 线程2将变量从 A 改为 B,然后又改回 A
- 线程1执行 CAS 操作,发现值仍然是 A,认为没有变化,但实际上已经发生了变化
解决方案:
- 使用
AtomicStampedReference
添加版本号 - 使用
AtomicMarkableReference
添加标记位
// 使用 AtomicStampedReference 解决 ABA 问题
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);// 线程1获取初始值和版本号
int[] stampHolder = new int[1];
String initial = ref.get(stampHolder);
int initialStamp = stampHolder[0];// 线程2修改了两次值 A->B->A,但版本号增加了
ref.compareAndSet("A", "B", initialStamp, initialStamp+1);
ref.compareAndSet("B", "A", initialStamp+1, initialStamp+2);// 线程1尝试更新,虽然值还是A,但版本号不匹配,更新失败
boolean success = ref.compareAndSet(initial, "C", initialStamp, initialStamp+1);
原子类应用场景
- 计数器、序列号生成器
- 状态标志位
- 实现非阻塞算法
- 简单的共享变量更新
其他同步工具
1. Semaphore(信号量)
用于控制同时访问特定资源的线程数量。通过 acquire()
方法获取许可,release()
方法释放许可。
Semaphore semaphore = new Semaphore(5); // 允许5个线程同时访问try {semaphore.acquire(); // 获取许可// 访问共享资源
} finally {semaphore.release(); // 释放许可
}
应用场景:
- 限流
- 资源池管理(如数据库连接池)
- 控制并发访问数量
2. CountDownLatch(倒计时器)
允许一个或多个线程等待其他线程完成一系列操作。初始化时指定计数,线程完成操作后调用 countDown()
方法 decrement 计数,等待的线程调用 await()
方法阻塞直到计数为 0。
CountDownLatch latch = new CountDownLatch(3); // 需要等待3个任务完成// 工作线程
new Thread(() -> {// 执行任务latch.countDown(); // 完成任务,计数减1
}).start();// 主线程等待所有任务完成
latch.await(); // 阻塞直到计数为0
System.out.println("所有任务已完成");
应用场景:
- 多线程任务完成后汇总结果
- 并行计算后等待所有子任务完成
- 服务启动时等待依赖服务初始化完成
3. CyclicBarrier(循环屏障)
让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障时,所有被阻塞的线程才会继续执行。它与 CountDownLatch
的区别在于 CyclicBarrier
可以重复使用。
CyclicBarrier barrier = new CyclicBarrier(3, () -> {System.out.println("所有线程已到达屏障"); // 可选的回调
});for (int i = 0; i < 3; i++) {new Thread(() -> {// 执行任务第一部分barrier.await(); // 等待其他线程// 执行任务第二部分}).start();
}
应用场景:
- 多阶段任务需要同步
- 并行计算需要多次同步
- 多线程测试场景
4. ReadWriteLock(读写锁)
将对共享资源的访问分为读操作和写操作。多个线程可以同时进行读操作,但写操作是排他的,当有线程进行写操作时,其他线程的读和写操作都会被阻塞。ReentrantReadWriteLock
是其主要实现类。
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();// 读操作
readLock.lock();
try {// 执行读操作
} finally {readLock.unlock();
}// 写操作
writeLock.lock();
try {// 执行写操作
} finally {writeLock.unlock();
}
应用场景:
- 读多写少的共享数据结构
- 缓存系统
- 需要高并发读的场景
读写锁的升级与降级
// 锁降级示例(允许)
readLock.lock(); // 先获取读锁
writeLock.lock(); // 再获取写锁
// 执行写操作
writeLock.unlock(); // 释放写锁(降级为读锁)
// 仍然持有读锁
readLock.unlock();// 锁升级示例(不允许,会导致死锁)
readLock.lock();
writeLock.lock(); // 这里会阻塞,因为读锁未释放,无法获取写锁
// 导致死锁
三、线程同步的注意事项
死锁的预防与处理
死锁是指两个或多个线程相互持有对方所需要的资源,而又相互等待对方释放资源,导致所有线程都无法继续执行的状态。在并发编程中,死锁是常见的严重问题,可能导致系统完全停滞。为了避免死锁,可采取以下措施:
1. 按顺序获取锁
多个线程在获取多个锁时,必须约定严格的、一致的获取顺序。例如,如果线程需要获取锁A和锁B,所有线程都应该按照先获取A再获取B的顺序执行。这样可以避免A等待B的同时B也在等待A的循环等待情况。
示例:
// 正确的锁获取顺序
public void method1() {synchronized(lockA) {synchronized(lockB) {// 临界区代码}}
}public void method2() {synchronized(lockA) { // 与method1相同的获取顺序synchronized(lockB) {// 临界区代码}}
}
2. 定时释放锁
使用ReentrantLock
的tryLock()
方法可以设置获取锁的超时时间。当线程在指定时间内无法获取需要的锁时,可以主动释放已持有的锁,避免长时间等待。
实现方式:
ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();public void safeOperation() {try {if (lock1.tryLock(1, TimeUnit.SECONDS)) {try {if (lock2.tryLock(1, TimeUnit.SECONDS)) {// 成功获取两个锁} else {// 获取lock2失败,释放lock1lock1.unlock();}} catch (InterruptedException e) {lock1.unlock();Thread.currentThread().interrupt();}}} catch (InterruptedException e) {Thread.currentThread().interrupt();}
}
3. 确保锁的释放
使用try-finally
块确保锁一定会被释放,即使发生异常或程序提前返回。对于synchronized
关键字,JVM会自动处理锁的释放;对于显式锁(如ReentrantLock
),必须手动释放。
最佳实践:
Lock lock = new ReentrantLock();public void criticalSection() {lock.lock();try {// 临界区代码} finally {lock.unlock(); // 确保锁被释放}
}
锁粒度的优化
锁的粒度指的是同步代码块的大小。过大的同步范围会导致线程长时间持有锁,降低程序的并发性能。优化锁粒度可以显著提高系统吞吐量。
1. 细化同步范围
只对真正需要同步的临界区代码进行同步,而不是对整个方法同步。例如,在处理包含多个操作的方法时,若只有其中一个操作涉及共享资源,应只对该操作进行同步。
优化示例:
// 不推荐:对整个方法同步
public synchronized void processData() {// 非临界区代码// 临界区代码// 非临界区代码
}// 推荐:只同步临界区
public void processData() {// 非临界区代码synchronized(this) {// 临界区代码}// 非临界区代码
}
2. 锁分段技术
对于大型并发数据结构(如HashMap),可以采用锁分段技术,将数据分成多个段,每个段使用独立的锁。这样不同线程可以同时访问不同段的数据,提高并发度。
实现思路:
class Segment<K, V> {final Lock lock = new ReentrantLock();Map<K, V> map = new HashMap<>();
}class ConcurrentDictionary<K, V> {private final Segment<K, V>[] segments;public V get(K key) {int segmentIndex = key.hashCode() % segments.length;Segment<K, V> segment = segments[segmentIndex];segment.lock.lock();try {return segment.map.get(key);} finally {segment.lock.unlock();}}
}
避免过度同步
过度同步会导致性能下降,甚至可能引发死锁。合理使用同步机制是并发编程的关键。
1. 识别真正的共享资源
只有真正需要线程安全的共享资源才需要同步。对于以下情况不需要同步:
- 局部变量(每个线程有自己的栈空间)
- 只读的共享数据(不可变对象)
- 线程封闭的对象(如ThreadLocal变量)
2. 避免使用内置对象作为锁
使用String常量池和基本类型的包装类(如Integer)作为锁对象可能导致意外的同步问题,因为这些对象可能被JVM缓存和共享。
危险示例:
// 不推荐:使用String常量作为锁
private static final String LOCK = "LOCK";public void method() {synchronized(LOCK) { // 其他地方可能也使用相同的String常量作为锁// ...}
}
推荐做法:
// 专门创建锁对象
private static final Object lock = new Object();public void method() {synchronized(lock) {// ...}
}
锁的可重入性
可重入性是指线程可以再次获取它已经持有的锁。Java中的synchronized
和ReentrantLock
都是可重入锁,这在递归调用或多个同步方法相互调用的场景下非常重要。
1. 可重入锁的优势
- 避免线程自己阻塞自己
- 支持递归调用同步方法
- 支持一个同步方法调用另一个同步方法
示例:
public class ReentrantExample {public synchronized void methodA() {methodB(); // 可以调用另一个同步方法}public synchronized void methodB() {// ...}
}
2. 锁的获取与释放平衡
对于可重入锁,必须确保锁的释放次数与获取次数一致,否则可能导致锁无法正确释放或后续线程无法获取锁。
正确做法:
ReentrantLock lock = new ReentrantLock();public void recursiveMethod(int n) {lock.lock();try {if (n > 0) {recursiveMethod(n - 1); // 递归调用}} finally {lock.unlock(); // 每次lock()对应一次unlock()}
}
合理选择同步机制
Java提供了多种同步机制,应根据具体场景选择最合适的工具。
1. 同步机制对比
机制 | 适用场景 | 特点 |
---|---|---|
synchronized | 简单同步需求 | 使用简单,JVM自动管理锁的获取与释放 |
ReentrantLock | 需要高级功能 | 支持可中断、超时、公平锁、条件变量等 |
Atomic类 | 简单原子操作 | 无锁实现,性能高,如计数器、标志位等 |
Semaphore | 资源池管理 | 控制并发访问数量 |
CountDownLatch | 一次性栅栏 | 等待多个线程完成 |
CyclicBarrier | 可重用栅栏 | 线程集合点,所有线程到达后继续执行 |
2. 场景化选择建议
简单同步场景:
// 使用synchronized
public synchronized void simpleMethod() {// ...
}
需要高级功能时:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();public void advancedMethod() {lock.lock();try {while (!conditionMet) {condition.await(); // 使用条件变量}// ...} finally {lock.unlock();}
}
计数器场景:
// 使用原子类
private AtomicInteger counter = new AtomicInteger();public void increment() {counter.incrementAndGet(); // 无锁操作
}
并发控制:
// 使用Semaphore控制资源访问
private Semaphore semaphore = new Semaphore(10); // 允许10个并发public void limitedResource() throws InterruptedException {semaphore.acquire();try {// 访问受限资源} finally {semaphore.release();}
}
通过合理选择和组合这些同步机制,可以构建出既安全又高效的并发程序。记住,没有"最好"的同步机制,只有"最适合"当前场景的选择。