线程安全完全指南:从 Java 到 Kotlin,一文吃透并发编程
多线程环境下,一行看似无害的
count++就可能导致结果出错。这篇文章从「问题根源」出发,系统梳理 Java 和 Kotlin 中的线程安全方案,帮你建立完整的知识体系,而不是零散地记 API。
一、从一个出错的计数器说起
先看一段看似人畜无害的代码:
java
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(counter::increment);
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println(counter.getCount()); // 期望 1000,实际可能是 998、987......
}
}
count++ 只有一行代码,为什么会出错?因为它不是原子操作,JVM 层面实际上是三步:
markdown
1. 读取 count 的值(read)
2. 将值加 1(modify)
3. 写回 count(write)
两个线程交错执行时,就会出现「丢失更新」。
线程不安全的三宗罪
所有的并发问题,本质上都可以归结为三个特性被破坏:
| 特性 | 含义 | 经典场景 |
|---|---|---|
| 原子性 | 操作不可中断,要么全做要么全不做 | count++、i = a + b |
| 可见性 | 一个线程修改后,其他线程能立即看到 | 主内存缓存导致读到旧值 |
| 有序性 | 指令不按代码顺序执行(编译器/CPU 重排序) | 单例 DCL 中对象半初始化 |
Java 内存模型(JMM)通过 happens-before 原则来约束这三个特性,而我们接下来的所有工具,都是在不同程度上提供这三个保证。
二、Java 线程安全工具箱
2.1 synchronized ------ 内置锁,万金油
synchronized 是 Java 最基础的同步手段,它能同时保证原子性、可见性、有序性:
java
public class SafeCounter {
private int count = 0;
public synchronized void increment() { // 实例锁
count++;
}
public static synchronized void staticMethod() { // 类锁(Class 对象)
// ...
}
public void blockLock() {
synchronized (this) { // 代码块锁,粒度更细
count++;
}
}
}
锁对象选择:
- 实例方法锁 →
this - 静态方法锁 →
Xxx.class - 代码块锁 → 自定义对象(推荐,减少锁范围)
JDK 6 之后 synchronized 经过大量优化(偏向锁 → 轻量级锁 → 重量级锁的升级路径),性能已经不再是「能不用就不用」的存在。简单场景优先用它,可读性最好。
2.2 volatile ------ 轻量级的可见性保证
volatile 只保证可见性 和有序性,不保证原子性:
java
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 如果没有 volatile,
} // 这里可能被重排序为:
} // 1.分配内存 2.引用指向 3.初始化
} // 其他线程可能拿到未初始化的对象!
return instance;
}
}
这是经典的 DCL(Double-Checked Locking)单例 。volatile 在这里的作用是禁止 new Singleton() 的指令重排序。
⚠️ 误区 :volatile int count; count++ 依然不是线程安全的!++ 不是原子操作,volatile 无能为力。
2.3 ReentrantLock ------ 功能更强的显式锁
相比 synchronized,ReentrantLock 提供了更灵活的能力:
java
public class LockDemo {
private final ReentrantLock lock = new ReentrantLock(true); // 公平锁
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // 必须放在 finally 中!
}
}
public void tryIncrement() {
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) { // 可超时,避免死锁
try {
count++;
} finally {
lock.unlock();
}
}
}
public void interruptibleIncrement() throws InterruptedException {
lock.lockInterruptibly(); // 可中断等待
try {
count++;
} finally {
lock.unlock();
}
}
}
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 获取锁 | 自动获取/释放 | 手动 lock/unlock |
| 可中断 | ❌ | ✅ |
| 超时获取 | ❌ | ✅ |
| 公平锁 | ❌(非公平) | ✅ 可选 |
| 条件变量 | 一个 wait/notify | 多个 Condition |
实践建议 :能用 synchronized 就用它,需要超时、中断、公平性时再用 ReentrantLock。
2.4 CAS 与 Atomic 家族 ------ 无锁编程
CAS(Compare-And-Swap)是一种乐观锁策略:先比较,一致才更新,失败就重试。
java
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 内部是 CAS 自旋,无锁、无阻塞
}
}
高并发下性能更优的还有 LongAdder(JDK 8+),它把热点值分散到多个 Cell 中,降低 CAS 竞争:
java
LongAdder adder = new LongAdder();
adder.increment();
long sum = adder.sum(); // 汇总所有 Cell
选择建议:
- 低并发:
AtomicInteger足够 - 高并发计数(统计、监控):
LongAdder性能碾压 - 需要复合操作(先读后改):CAS 的
updateAndGet()或回到锁
2.5 ThreadLocal ------ 线程隔离
有些场景不需要共享,每个线程各持有一份即可:
java
public class ThreadLocalDemo {
private static final ThreadLocal<SimpleDateFormat> FORMATTER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String format(Date date) {
return FORMATTER.get().format(date); // 每个线程用自己的,天然安全
}
}
⚠️ 内存泄漏坑 :线程池场景下线程会复用,ThreadLocal 用完必须 remove(),否则旧值一直残留,严重时 OOM:
java
try {
FORMATTER.get().format(date);
} finally {
FORMATTER.remove(); // 好习惯
}
2.6 并发容器 ------ 开箱即用
| 容器 | 替代 | 特点 |
|---|---|---|
ConcurrentHashMap |
Hashtable / 同步 Map |
分段锁(JDK7)→ CAS + synchronized(JDK8) |
CopyOnWriteArrayList |
同步 List | 写时复制,读多写少神器 |
BlockingQueue |
手动 wait/notify | 生产者-消费者模型标配 |
ConcurrentLinkedQueue |
同步 Queue | 无锁队列 |
⚠️ 注意:ConcurrentHashMap 保证的是单个方法的原子性,复合操作仍需额外同步:
java
// 错误:check-then-act 不是原子的
if (!map.containsKey(key)) {
map.put(key, value); // 两个线程可能同时进入这里
}
// 正确:用原子方法
map.putIfAbsent(key, value);
map.computeIfAbsent(key, k -> newValue());
2.7 不可变对象 ------ 终极方案
最好的线程安全,是根本不需要线程安全。不可变对象创建后状态不再改变,可以随意共享、随意跨线程传递:
java
public final class User { // 类不可继承
private final String name; // 字段 final,创建后不可改
private final int age;
private final List<String> tags;
public User(String name, int age, List<String> tags) {
this.name = name;
this.age = age;
// 防御性拷贝,防止外部引用改动内部状态
this.tags = Collections.unmodifiableList(new ArrayList<>(tags));
}
public String getName() { return name; }
public int getAge() { return age; }
public List<String> getTags() { return tags; }
}
Java 中常见的不可变实现:
final字段:保证引用不会被重新赋值String:本身就是不可变的Collections.unmodifiableXXX():包装成不可变视图- JDK 16+
record:一行代码声明不可变数据类
java
record Point(int x, int y) {} // 不可变,可直接多线程共享,无需任何同步
⚠️ 注意 :final 只保证引用的不可变,对象内部状态仍可能可变(比如上面的 List 字段),需要配合防御性拷贝。
2.8 JDK 21 虚拟线程(彩蛋)
java
Thread.startVirtualThread(() -> {
// 阻塞式写法,性能接近协程
});
虚拟线程让「一个请求一个线程」的模型重新变得可行,但它不改变线程安全的语义------共享可变状态依然需要同步。
三、Kotlin 线程安全方案
3.1 先明确:Kotlin 完全兼容 Java 方案
Kotlin 运行在 JVM 上,上面所有工具都能直接用,只是写法更简洁:
kotlin
class SafeCounter {
private var count = 0
@Synchronized // 等价于 Java 的 synchronized 方法
fun increment() {
count++
}
@Volatile
private var flag = false // 等价于 volatile 字段
fun withLock() {
synchronized(this) { // 注意:Kotlin 中 synchronized 是函数
count++
}
}
}
单例更是简单到令人发指:
kotlin
object Singleton { // 天然线程安全的单例,JVM 保证类加载线程安全
fun doSomething() {}
}
3.2 协程:换一条赛道
Kotlin 协程的最大优势是:用挂起代替阻塞,用结构化并发代替手动线程管理。很多传统并发问题,在协程里压根不会出现。
单线程调度器规避竞争
kotlin
val singleThreadDispatcher = Dispatchers.Default.limitedParallelism(1)
scope.launch(singleThreadDispatcher) {
// 这里面的代码天然串行执行,不需要任何锁
count++
updateUI(count)
}
Mutex ------ 协程世界的锁
kotlin
class CoroutineCounter {
private var count = 0
private val mutex = Mutex()
suspend fun increment() {
mutex.withLock { // 挂起等待,不阻塞线程!
count++
}
}
}
⚠️ 协程中使用普通锁的坑:
kotlin
val lock = ReentrantLock()
suspend fun bad() {
lock.lock() // ❌ 阻塞了整个线程!
delay(100) // 协程挂起,但线程还被锁占着
lock.unlock() // 其他协程全卡死了
}
suspend fun good() {
mutex.lock() // ✅ 挂起协程,释放线程
delay(100)
mutex.unlock()
}
核心区别:synchronized/ReentrantLock 阻塞线程 ,Mutex 挂起协程,线程可以转去执行其他协程。
并发在协程中的选择
kotlin
// 方案1:Atomic(适用于简单计数)
val count = AtomicInteger(0)
count.incrementAndGet() // 线程安全,但无法与挂起函数组合
// 方案2:Mutex(适用于需要保护挂起操作的场景)
val mutex = Mutex()
mutex.withLock {
delay(100) // 锁内可以调用挂起函数
count++
}
// 方案3:单线程调度器(适用于需要保证顺序执行的复杂逻辑)
val dispatcher = Dispatchers.Default.limitedParallelism(1)
3.3 StateFlow / SharedFlow ------ 状态流的并发安全
在 Android 开发中,StateFlow 是共享可变状态的最佳实践:
kotlin
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow() // 对外只读
fun updateName(name: String) {
// update 是原子的 CAS 操作,多线程并发安全
_uiState.update { current ->
current.copy(name = name)
}
}
}
关键设计:
- 对外暴露只读
StateFlow,防止外部篡改 - 使用
update {}而不是直接.value =,后者是「读-改-写」复合操作,并发下会丢更新 - 配合不可变的
data class,天然线程安全
3.4 不可变性 ------ Kotlin 的默认姿势
不可变对象创建后状态不再改变,可以随意共享,Kotlin 在语法层面让这件事变得极其自然:
kotlin
// Kotlin 默认鼓励不可变
val list = listOf(1, 2, 3) // 不可变 List
data class User(val name: String) // val + data class,深拷贝用 copy()
// 共享不可变对象,无需任何同步
val user = User("Tom")
scope.launch { println(user.name) } // 随便用,不会出问题
实践优先级:不可变对象 > 线程隔离(ThreadLocal/单线程调度器) > 同步(锁/Atomic)
四、常见误区自查清单
- ❌ 认为
volatile能保证原子性 → 它只管可见性和有序性 - ❌ 在协程里用
synchronized/ReentrantLock长时间持锁 → 会阻塞底层线程,拖垮整个协程调度器 - ❌ 认为
ConcurrentHashMap的复合操作是原子的 →get+put不是原子的,要用computeIfAbsent - ❌ 在
ThreadLocal用完不remove()→ 线程池场景内存泄漏 - ❌ 直接暴露
MutableStateFlow→ 外部可以随便改,破坏封装 - ❌ 用
list.add()更新StateFlow的值 → 应该用update { it.copy(...) }
五、总结
- Java 的核心思路:优先不可变对象(final/record)从源头避免共享可变状态,必要时用锁(synchronized/Lock)、原子类(Atomic)、并发容器(ConcurrentXxx)来保护
- Kotlin 的核心思路 :优先用协程的结构化并发 + 不可变性从源头避免共享,必要时用
Mutex、StateFlow等协程友好原语 - 通用最佳实践:不可变优先 → 线程隔离其次 → 加锁 / 原子类兜底