Java线程安全
在 Java 中,线程安全是一个核心概念,指的是当多个线程并发访问某个类、对象或方法时,这个类、对象或方法始终能表现出正确的行为,而无需额外的同步或协调机制。
核心问题:共享数据的并发访问
- 线程不安全的根源在于多个线程同时读写共享的可变状态(数据)。
- 如果数据是只读的(不可变),或者每个线程操作的都是自己私有的数据副本,那么自然就是线程安全的。
- 问题在于:当多个线程尝试同时修改同一个变量、同一个集合、同一个文件等共享资源时,如果访问操作不是原子的,或者线程间的修改对其他线程不可见,就会导致数据不一致、逻辑错误、程序崩溃等不可预测的后果。
导致线程不安全的三大根源
- 原子性缺失: 一个操作(可能由多条字节码或机器指令组成)需要作为一个不可分割的整体执行。如果执行到一半被其他线程打断,就可能导致中间状态被其他线程观察到或修改,从而破坏一致性。
- 经典例子:
i++
。 它实际上包含读取i
的值、将值加1、将结果写回i
三个步骤。两个线程同时执行i++
可能导致只增加一次。
- 经典例子:
- 可见性问题: 一个线程对共享变量的修改,不能立即被其他线程看到。这主要是由现代计算机架构(多级缓存)和 Java 内存模型(JMM)允许线程将变量保存在本地寄存器或缓存中引起的。
- 经典例子: 线程 A 修改了变量
flag = true
,但线程 B 可能在一段时间内(甚至永远)看不到这个变化,仍然读取到旧的false
值。
- 经典例子: 线程 A 修改了变量
- 有序性问题: 程序代码的执行顺序不一定就是实际指令的执行顺序。编译器和处理器为了优化性能,可能会进行指令重排序。在单线程环境下,这通常不会影响结果;但在多线程环境下,如果重排序破坏了线程间的依赖关系,就可能出现意想不到的行为。
- 经典例子: 双重检查锁定(DCL)的单例模式实现(在旧版本 Java 中)可能因为重排序导致返回一个未完全初始化的对象。
如何实现线程安全
- 无状态: 最简单的方法。类不包含任何字段(状态),或者只包含常量字段。所有操作仅依赖于参数和局部变量,这些都在线程栈上,是线程私有的。(线程安全)
- 不可变对象: 对象的状态在构造完成后就不能再被修改。所有字段都是
final
的,并且如果字段是引用类型,它指向的对象也应该是不可变的(或安全发布)。线程只能读取,不存在修改冲突。(线程安全)String
,Integer
等是典型例子。 - 线程封闭: 将共享数据的访问限制在同一个线程内。
- 栈封闭: 局部变量是线程私有的。确保可变对象不逸出方法范围。
ThreadLocal
: 为每个线程创建变量的独立副本。常用于存储用户会话信息、数据库连接等。- 同步(Synchronization): 使用锁机制强制互斥访问共享数据。
synchronized
关键字:- 修饰实例方法:锁是当前对象实例 (
this
)。 - 修饰静态方法:锁是当前类的
Class
对象。 - 同步代码块 (
synchronized(obj) { ... }
):锁是指定的对象obj
。 - 作用:保证原子性(代码块内操作不可分割)和可见性(释放锁前将修改刷回主内存,获取锁后清空本地缓存)。它也会限制临界区内的重排序,间接保证有序性。
- 修饰实例方法:锁是当前对象实例 (
java.util.concurrent.locks.Lock
接口 (如ReentrantLock
):- 提供比
synchronized
更灵活的锁操作(可中断、尝试获取锁、公平锁等)。 - 同样保证原子性和可见性(通过内存屏障)。
- 提供比
volatile
变量:- 解决可见性问题:对
volatile
变量的写操作会立即刷新到主内存,读操作会直接从主内存读取。 - 解决部分有序性问题:禁止指令重排序优化(内存屏障)。写
volatile
之前的操作不会被重排序到写之后;读volatile
之后的操作不会被重排序到读之前。 - 不保证原子性!
volatile
不能替代synchronized
或锁来处理复合操作(如count++
)。 - 典型用途:状态标志位 (
boolean flag
),双重检查锁定(需要配合volatile
解决重排序问题)。
- 解决可见性问题:对
- 原子变量 (
java.util.concurrent.atomic
包):- 提供如
AtomicInteger
,AtomicLong
,AtomicReference
等类。 - 利用 CAS (Compare-And-Swap) 硬件指令实现单个变量的原子操作(如
getAndIncrement()
,compareAndSet()
)。 - 高效地解决原子性问题(针对单个变量),通常比锁性能更好。也提供可见性保证。
- 提供如
- 并发容器 (
java.util.concurrent
包):- 提供线程安全的集合类,如
ConcurrentHashMap
,CopyOnWriteArrayList
,ConcurrentLinkedQueue
,BlockingQueue
等。 - 内部通常结合了 CAS、锁、分段锁等机制实现高效的并发访问。优先使用这些容器代替手动同步
Collections.synchronizedXXX
包装的集合。
- 提供线程安全的集合类,如
- 避免共享: 设计上尽量降低共享数据的必要性(如使用线程局部变量、任务分发、Actor 模型等)。
线程安全的级别/分类
- 不可变: 最高级别的线程安全。
- 无条件的线程安全: 类的实例是可变的,但内部有足够的同步机制(如
synchronized
方法),客户端调用任何方法都不需要额外的同步(如ConcurrentHashMap
,AtomicInteger
)。 - 有条件的线程安全: 类本身是线程安全的,但某些复合操作(需要按特定顺序调用多个方法)需要客户端进行额外的同步(如
Collections.synchronizedMap
返回的 Map,其get
和put
单个方法是安全的,但迭代或putIfAbsent
这样的复合操作需要客户端加锁)。 - 非线程安全: 类不提供任何同步机制(如
ArrayList
,HashMap
)。客户端必须在并发访问时自行协调同步。 - 线程对立: 无论客户端是否同步,都无法安全地在多线程中使用(极其罕见,通常是设计错误)。
总结与关键点:
- 核心: 线程安全的核心在于管理对共享、可变状态的并发访问。
- 根源: 原子性缺失、可见性问题、有序性问题是导致线程不安全的三大根源。
- 策略:
- 避免共享: 无状态、不可变、线程封闭。
- 安全共享: 使用同步机制 (
synchronized
,Lock
) 保证原子性和可见性。 - 高效共享: 使用原子变量、并发容器(内部通常基于 CAS)。
- 可见性与有序性: 使用
volatile
或同步机制。
- 选择: 没有绝对最好的方案。选择哪种策略取决于具体的应用场景、性能要求、复杂性等因素。优先考虑使用 JDK 提供的并发工具 (
java.util.concurrent
)。 - 设计原则: 尽量设计无状态或不可变对象;最小化共享数据的范围;优先使用线程安全的组件;明确文档化类的线程安全属性。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 技术之路!
评论