线程同步与锁机制
在多线程编程中,同步与锁机制是保障数据一致性与线程安全的关键技术。线程同步是一种协调多线程访问共享资源的技术,目的是避免线程间竞争导致的数据不一致问题,下面举个例子:
public class DataInconsistencyDemo {
// 共享变量
private static int sharedCounter = 0;
public static void main(String[] args) throws InterruptedException {
// 创建两个线程
Thread thread1 = new Thread(new IncrementTask(), "Thread-1");
Thread thread2 = new Thread(new IncrementTask(), "Thread-2");
// 启动线程
thread1.start();
thread2.start();
// 等待线程完成
thread1.join();
thread2.join();
// 输出最终结果
System.out.println("Final counter value: " + sharedCounter);
}
// 线程任务:对共享变量进行递增操作
static class IncrementTask implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
sharedCounter++; // 这不是原子操作
}
System.out.println(Thread.currentThread().getName() + " finished. Counter: " + sharedCounter);
}
}
}
预期结果:两个线程各增加100,000次,最终结果应该是200,000
实际结果:最终结果通常会小于200,000,因为存在数据竞争
sharedCounter++ 不是一个原子操作,它实际上包含三个步骤:
- 读取当前值
- 增加1
- 写回新值
当多个线程同时执行这些步骤时,可能会发生以下情况:
- 线程A读取值(假设为100)
- 线程B也读取值(还是100)
- 线程A增加并写回(101)
- 线程B增加并写回(101)
- 结果应该是102,但实际上只增加了1
线程安全问题的根源
在 Java中,造成线程安全问题的根本原因是硬件结构,实际中为了消除 CPU 和主内存之间的硬件速度差,通常会在两者之间设置多级缓存(L1 ~ L3),如下图:
Java为了适配多级缓存的硬件构造,设计了一套与之对应的内存模型(JMM,Java memory model,包括主内存和工作内存,如下图:
主内存: 程序中所有的变量都存储在主内存中
工作内存: 每一个线程都有自己的工作内存,线程会将主内存的共享变量读取到自己的工作内存中,然后进行后续操作,最后再将工作内存中的变量写入到主内存
线程对主内存中变量的所有操作(读取、写入)都必须在自己的工作内存中进行,而不能直接操作主内存中的变量。线程之间的工作内存是相互隔离的,变量的传递需要通过主内存来完成
原子性
类似于在数据库事务ACID中原子性(Atomicity)的概念,它是指一个操作是不可分割的,即要么全部执行,要么全部不执行。Java 线程安全中的原子性与数据库事务中的原子性本质是一样的,只是它们应用的上下文和具体实现有所不同
Java 提供了多种方式来保证原子性,比如同步块、锁或者原子类
可见性
可见性是指如果一个线程对共享变量的做出修改操作,其他线程能立刻感知到。在Java中,volatile关键字可以保证变量的可见性
// 线程A
sharedFlag = true; // 可能仅写入CPU缓存,未刷新到主内存
// 线程B
while (!sharedFlag) { ... } // 可能永远看不到线程A的修改
需要注意的是:
被
volatile修饰的变量不能解决原子性问题,它只能保证可见性和禁止指令重排序,但无法保证复合操作的原子性,这是因为volatile无法锁定共享资源
volatile的核心作用:
- 可见性:强制线程每次读写变量时直接操作主内存,而非CPU缓存,确保修改对其他线程立即可见
- 禁止指令重排序:通过插入内存屏障(Memory Barrier)防止编译器和CPU优化重排序
有序性问题
有序性指的是 程序执行的顺序符合代码的预期逻辑顺序,尤其是在多线程环境下,确保指令不会被编译器或 CPU 因优化而重排序(Reordering) ,从而导致意外的结果
为什么需要有序性?
在单线程环境下,编译器和 CPU 可能会对指令进行重排序优化(在不改变程序最终结果的前提下,调整指令执行顺序以提高性能)。但在多线程环境下,这种优化可能导致线程间观察到的执行顺序不一致,从而引发线程安全问题
下面给个示例:
public class Singleton {
private static Singleton instance; // 未加 volatile
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(非同步)
synchronized (Singleton.class) { // 加锁
if (instance == null) { // 第二次检查(同步)
instance = new Singleton(); // 可能发生指令重排序!
}
}
}
return instance;
}
}
instance = new Singleton(); 并非原子操作,底层可能分解为:
- 分配内存空间
- 初始化对象(调用构造函数)
- 将引用赋值给
instance
如果 指令被重排序(如 3 → 2),其他线程可能在 instance 被完全初始化前就读取到非 null 但未初始化的对象,导致程序错误
同步方案对比
synchronized 关键字
原理:基于 JVM 实现的监视器锁(Monitor Lock),进入同步代码块前获取锁,退出时释放锁。
适用场景:方法或代码块级别的简单同步
需要注意的是: synchronized(this)和synchronized(.Class)的区别:
- 锁对象不同:
- synchronized(this):锁定当前对象实例(非静态方法锁)
- 锁的是调用该代码块的对象实例
- 不同对象实例之间不会互相阻塞
- synchronized(xxx.class) :锁定类的 Class 对象(静态方法锁)
- 锁的是类的 Class 对象(每个类在 JVM 中只有一个 Class 对象)
- 对所有该类的实例都有效,会阻塞所有访问该代码的线程
- 作用范围不同
- synchronized(this):
- 只影响同一个实例的多个线程
- 不同实例的线程可以同时执行同步代码块
- synchronized(xxx.class) :
- 影响该类的所有实例
- 所有线程(无论来自哪个实例)都会竞争同一把锁
ReentrantLock(可重入锁)
ReentrantLock 是 Java 并发包 (java.util.concurrent.locks) 中提供的一种可重入的互斥锁,它比传统的 synchronized 关键字提供了更灵活、更强大的锁机制
基本特性:
- 可重入性:同一个线程可以多次获取同一把锁而不会死锁
- 公平性选择:可以构造公平锁或非公平锁(默认非公平)
- 锁的获取与释放分离:需要显式调用
lock()和unlock() - 可中断的锁获取:提供了可响应中断的锁获取方式
- 超时获取锁:可以尝试在一定时间内获取锁
ReentrantLock 是怎么实现可重入的?
它的实现依赖于 AQS(AbstractQueuedSynchronizer) ,并通过 线程持有者(owner) 和 重入计数器(holdCount) 来管理锁的状态
查看 ReentrantLock 类的源码中的
tryLock方法可以发现:final boolean tryLock() { Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(current); return true; } } else if (getExclusiveOwnerThread() == current) { if (++c < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(c); return true; } return false; }
- 首先会通过getState()方法获取锁的状态
= 0:锁未被占用> 0:锁被占用,数值表示 重入次数(holdCount)- 然后判断锁是否被占用
- 如果没有被占用(state = 0),则会通过 CAS 将 state 设置为1,并且将字段
exclusiveOwnerThread设置为当前线程- 如果锁被占用,则会判断是不是当前线程
- 如果是当前线程,则会将 state 的值+1并重新赋值给 state
- 如果不是当前线程,则会直接返回false表示尝试加锁失败并进入阻塞队列等待
这里的 state 和 exclusiveOwnerThread 字段都不是 ReentrantLock 类本身维护,而是由 AbstractQueuedSynchronizer 类(AQS)维护,ReentrantLock 类中维护了一个抽象静态内部类 Sync 继承了 AbstractQueuedSynchronizer 然后调用 AQS 方法
ReadWriteLock(读写锁)
ReadWriteLock 是 Java 并发包 (java.util.concurrent.locks) 中提供的一种 读写分离锁,它允许多个线程同时读取共享资源,但写操作必须是独占的。这种锁适用于 读多写少 的场景,可以显著提高并发性能。
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
通过源码可以看到,ReadWriteLock是一个根接口,只提供了读锁和写锁两个方法(能力)
读锁(共享锁) :
- 可以被多个线程同时持有
- 只要没有线程持有写锁,多个线程可以同时获取读锁
写锁(独占锁) :
- 同一时间只能由一个线程持有
- 如果写锁被占用,其他线程(无论是读还是写)都必须等待
Java 提供的 ReadWriteLock 的主要实现是 ReentrantReadWriteLock,它具有以下特性:
-
可重入:同一个线程可以多次获取读锁或写锁(需对应释放相同次数)
-
公平性可选:
- 非公平模式(默认) :吞吐量更高,但可能造成线程饥饿
- 公平模式:按请求顺序分配锁,避免饥饿,但性能较低
-
锁降级:允许持有写锁的线程获取读锁,然后释放写锁,从而降级为读锁
-
不支持锁升级:不能直接从读锁升级为写锁(必须先释放所有读锁)
原子类
原子类是 Java 并发包(java.util.concurrent.atomic)提供的一组线程安全的工具类,用于实现 无锁(lock-free) 的线程安全操作。它们基于 CAS(Compare-And-Swap) 机制,比传统的 synchronized ReentrantLock 性能更高,适用于高并发场景
核心特性:
-
线程安全:无需加锁即可保证操作的原子性
-
高性能:基于 CAS(Compare-And-Swap) 实现,避免线程阻塞
-
内存可见性:所有操作遵循 happens-before 规则,确保多线程间的数据一致性
-
适用场景:
- 计数器(如
AtomicInteger) - 标志位(如
AtomicBoolean) - 对象引用更新(如
AtomicReference) - 数组操作(如
AtomicIntegerArray)
- 计数器(如
CAS
原子类的核心是 CAS,它是一种无锁算法,由 CPU 指令直接支持
public boolean compareAndSet(int expect, int update) { if (当前值 == expect) { // 检查当前值是否等于预期值 当前值 = update; // 如果是,更新为新值 return true; } return false;CAS 的三大问题
- ABA 问题:
- 线程 A 看到变量从
A → B → A,误以为没变化- 解决方案:
AtomicStampedReference(带版本号)或者时间戳- 循环时间长开销大
- 如果 CAS 失败,会一直自旋(
while循环),消耗 CPU- 只能保证单个变量的原子性
- 多个变量的原子操作需用
AtomicReference封装
CountDownLatch
CountDownLatch 是 Java 并发包 (java.util.concurrent) 中的一个同步工具类,它允许 一个或多个线程等待其他线程完成操作,然后再继续执行。它的核心思想是 倒计时计数,适用于 多线程任务协调 的场景
核心概念
- 初始化计数器:
CountDownLatch在创建时需要指定初始计数值(count) - 等待线程:调用
await()的线程会被阻塞,直到计数器归零 - 计数线程:调用
countDown()的线程会将计数器减 1,当计数器归零时,所有等待线程被唤醒
使用场景
- 主线程等待多个子线程完成任务
public class MainThreadWaits {
public static void main(String[] args) throws InterruptedException {
int threadCount = 5;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务");
latch.countDown(); // 任务完成,计数器减 1
}).start();
}
latch.await(); // 主线程阻塞等待所有子线程完成任务
System.out.println("所有任务完成,主线程继续执行");
}
}
- 多个线程等待一个信号后同时开始
public class ThreadsWaitForSignal {
public static void main(String[] args) throws InterruptedException {
int threadCount = 3;
CountDownLatch startSignal = new CountDownLatch(1); // 初始值为 1
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
startSignal.await(); // 所有线程在此等待
System.out.println(Thread.currentThread().getName() + " 开始执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
Thread.sleep(1000); // 模拟准备时间
System.out.println("准备完成,所有线程开始执行!");
startSignal.countDown(); // 释放所有等待线程
}
}
底层实现
CountDownLatch 基于 AQS(AbstractQueuedSynchronizer) 实现:
- 计数器(state) :AQS 的
state存储当前计数值 await():如果state != 0,线程进入等待队列countDown():state--,如果state == 0,唤醒所有等待线程