本文目录导读:
这是一个非常核心且重要的问题,当多个线程同时访问共享数据时,如果没有适当的保护机制,就会出现数据不一致、程序崩溃等难以排查的问题,这就是所谓的线程安全问题。
下面我会从问题的本质、核心原则、常见场景、解决方案以及现代编程的演进几个方面来系统性地讲解。
问题的本质:为什么会出现线程不安全?
线程安全问题的根源在于 “竞态条件”,当多个线程的执行顺序不确定,并且它们同时读写同一个共享变量时,最终的结果可能依赖于线程执行的“运气”,而不是代码逻辑本身。
最经典的例子就是 i++ 操作:
- 读取 (Read): 从内存读取
i的当前值,假设i = 0。 - 修改 (Modify): 在 CPU 中将该值加 1,得到
1。 - 写入 (Write): 将结果
1写回内存。
如果两个线程同时执行 i++,可能发生以下情况:
- 线程A 读取到
i=0。 - 线程B 读取到
i=0(还没等线程A写入)。 - 线程A 计算得到
1并写入。i变成1。 - 线程B 计算得到
1并写入。i从1变成1。
结果:i 只增加了 1,但理论上应该增加 2,这就是典型的非原子性操作引发的线程安全问题。
还有两个核心原因:
- 可见性问题:一个线程修改了共享变量的值,但其他线程可能立刻看不见这个修改,因为变量可能被缓存到 CPU 的高速缓存中(如 L1、L2 Cache),而不是立刻写回主内存。
- 指令重排序问题:为了提高性能,编译器和处理器可能会对指令进行重排,在单线程下,重排序保证最终结果一致,但在多线程下,重排序可能导致一个线程看到的操作顺序与另一个线程实际执行的顺序不一致。
核心原则:如何保证线程安全?
解决线程安全问题的核心,就是破坏竞态条件的某个环节,主要遵循以下两个原则:
- 原子性:确保一个操作或一组操作要么全部执行成功,要么全部不执行,在执行过程中不被其他线程干扰,就像数据库中的事务。
- 可见性:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。
- 有序性:禁止编译器和处理器对指令进行不合理的重排序,保证程序执行的顺序符合代码的意图。
常见场景:哪些代码需要特别小心?
- 操作共享的计数器、累加器:如
count++、sum += x。 - 读写共享的集合类:如
HashMap、ArrayList、List,它们内部有复杂的链表、数组结构,并发修改会引发死循环、空指针等严重问题。 - 延迟初始化中的“双重检查锁定”:
if (instance == null) { synchronized ... }这种模式,如果不用volatile或其它机制,就是一个经典的反模式。 - 使用非线程安全的工具类:如
SimpleDateFormat、Random等。 - 修改共享的、可变的对象的状态:如修改一个共享的 User 对象的 name 属性。
解决方案:工具箱里的武器
解决线程安全问题的技术手段有很多,根据不同场景选择合适的方案。
使用锁机制(最经典,但最易出错)
- synchronized:Java 中的关键字,可以加在方法或代码块上,它保证了原子性、可见性和有序性,缺点是性能开销较大,且容易导致死锁、阻塞等。
- Lock 接口及其实现:
- ReentrantLock:比
synchronized更灵活,支持公平锁、可中断、带超时时间的锁,但需要手动lock()和unlock(),容易忘记释放锁。 - ReadWriteLock / ReentrantReadWriteLock:读写分离锁,读读不互斥,读写、写写互斥,适用于读多写少的场景,能大大提高并发度。
- ReentrantLock:比
使用原子类(轻量级,无锁)
- AtomicInteger、AtomicLong、AtomicReference 等。
- 原理:依赖 CAS (Compare-And-Swap) 操作,这是一个 CPU 级别的原子指令,比锁更高效,但容易出现 ABA问题(AtomicStampedReference 可以解决),并且在大并发下可能会反复自旋,浪费 CPU。
使用 volatile 关键字(轻量级,但不保证原子性)
- 作用:保证可见性和有序性,但不保证原子性。
- 适用场景:作为状态标志,一个
boolean flag被一个线程修改,另一个线程需要立即看到,且对flag的只有简单的写操作(不是flag++这种复合操作)。
使用线程安全的容器(最省心)
- 简化版本(不推荐):
Collections.synchronizedMap(xxx)、Collections.synchronizedList(xxx),本质是在容器所有方法上加synchronized锁,性能差。 - 高并发版本(推荐):
- ConcurrentHashMap:分段锁、CAS 结合,是目前最常用的并发 Map。
- CopyOnWriteArrayList / CopyOnWriteArraySet:写时复制,读操作完全无锁,写操作时复制一份副本,适用于读多写极少的场景(如配置信息缓存)。
- ConcurrentLinkedQueue、LinkedBlockingQueue 等。
- 不可变对象:
String、Integer、Collections.unmodifiableXxx,最安全,因为不可变对象的状态一旦创建就不能被修改,根本不存在线程安全问题。
使用 ThreadLocal(彻底隔离)
- 思想:每个线程都拥有自己独立的副本,互不影响。
- 适用场景:存储与当前线程绑定的局部变量,如 HTTP 请求上下文、数据库连接
Session、SimpleDateFormat对象(避免非线程安全问题)。 - 注意:必须在使用完后手动
remove(),否则容易导致内存泄漏(因为 ThreadLocalMap 中的 key 是弱引用,value 是强引用)。
使用并发编程框架(更高层次)
- Fork/Join 框架:工作窃取算法。
- CompletableFuture:声明式地编排异步任务。
- Actor 模型:如 Akka,每个 Actor 处理自己的消息,互不共享状态。
- 函数式编程:如 Java 8 的 Stream(无状态操作部分),避免可变状态共享。
现代编程的演进:如何从设计上避免问题?
最好的线程安全是不需要考虑线程安全,现代编程思想更提倡:
- 避免共享可变状态:
- 无状态设计:方法内部只使用局部变量(每个线程都有自己的栈,局部变量线程私有)。
- 函数式编程:强调纯函数,输入确定,输出就确定,不修改外部状态。
- 事件驱动架构:消息传递,而不是共享内存。
- 使用不可变对象:如
String、LocalDate、ImmutableList。 - 利用现代语言特性:
- Kotlin / Swift 的
val/let更鼓励不可变。 - Rust 的 所有权系统 从根本上杜绝了数据竞争(其核心原则:一个数据要么有多个不可变引用,要么只有一个可变引用)。
- Kotlin / Swift 的
- 拥抱并发框架:使用 Actor、Reactor、Coroutine 等框架,让框架管理线程,开发者只关注业务逻辑。
一个检查清单
当你写多线程代码时,可以问自己以下几个问题:
- 数据是否被多个线程共享?
- 否 -> 安全(局部变量)
- 是 -> 继续
- 共享的数据是否可变?
- 否 -> 安全(不可变对象)
- 是 -> 继续
- 是否有写操作?
- 否 -> 安全(仅读共享,但用 volatile 保证可见性更好)
- 是 -> 必须加锁或用原子类
- 加锁后,锁的范围是否合理?
- 太大 -> 性能低,可能死锁
- 太小 -> 可能未保护到所有相关代码,导致竞态条件
- 是否使用了错误的同步策略?例如用
volatile保护了非原子操作。
一句话总结:线程安全问题的根源是共享的可变状态被并发访问,解决方案从低到高依次是:锁 -> CAS -> 线程安全容器 -> ThreadLocal -> 避免共享(如 Actor、函数式),而最高级的做法是从设计上避免数据竞争。
标签: 竞态条件