Java线程安全类设计思路总结
记录一下怎么在Java中设计一个线程安全类。
1. 什么是线程安全类?
所谓线程安全类,是指当多个线程并发访问某个类的同一个实例时,无需额外同步措施,也能保证行为正确、结果一致。换句话说,这个类在多线程环境下不会出现竞态条件(Race Condition)或数据不一致的问题。
注意:只有在类的对象可能被多个线程同时访问的情况下,才需要考虑线程安全。如果某个类在实际使用中不会被并发访问,那就没必要为了“线程安全”而引入额外的同步开销。
1.1 竞态条件
这里补充一下对这个术语的说明。从定义上来说,竞态条件(Race Condition)是指程序中某段逻辑的执行结果依赖于多个线程执行的相对时序。换句话说,程序的行为取决于“谁先执行”,而不是业务逻辑本身。这种不确定性,往往会导致程序出现难以复现、难以调试的 Bug。
Race condition的两种常见模式
- Check-Then-Act(检查再执行)
这是最常见的一类模式,典型结构是:先判断某个条件,再基于判断结果执行某个操作。但在并发场景下,检查和执行之间存在“时间窗”,其他线程可能在这段间隙中修改了状态,导致最终行为和预期不符。 - Read-Modify-Write(读取-修改-写入)
在Java中,对变量的更新并不是原子操作,而是拆分成读取 → 修改 → 写入
三个步骤。如果多个线程同时对同一个变量执行这三步中的任意一环,就可能造成竞态,尤其当至少一个线程涉及“修改”时,比如下面这个例子。
public class UnsafeClass {private int value = 0;public void incre() {// 非原子操作value++;}
}
/*
假设初始 value = 0,两个线程A和B同时调用incre()可能会发生以下情况:
1. 线程A读取value(0)
2. 线程B读取value(0)
3. 线程A对value加1并写入value=1
4. 线程BA对value加1并写入value=1
最终:在并发下,两个线程执行这段代码会互相覆盖对方的写入,导致数值错误。
*/
3. 构建线程安全类的策略
3.1 无状态类
所谓无状态类,是指这个类不包含任何实例字段,也就意味着它不维护任何可以被多个线程同时访问和修改的状态。换句话说,它的行为完全由传入的参数决定,而不是依赖于内部数据。正因为“无状态”,所以它天然是线程安全的。
// 这个类没有任何字段,方法只是执行操作,不依赖也不修改类的内部状态,多个线程调用它不会有任何冲突。
public class MathUtils {public int add(int a, int b) {return a + b;}
}
3.2 不可变类
不可变性是实现线程安全的一种十分有效的方式。原理非常简单:如果一个对象在创建之后就不能再被修改,那自然也就没有“并发修改”的问题了。正所谓:“只读,最安全”。
Java 中最有名的不可变类就是 String。你可以放心地在多个线程中共享字符串实例,而不必担心线程安全问题——这是因为 String 被final关键字修饰,在构造后就无法更改(通常的更改会创建一个新的String对象)。
// 一个例子
public final class User {private final String username;private final int age;public User(String username, int age) {this.username = username;this.age = age;}public String getUsername() { return username; }public int getAge() { return age; }//修改返回一个新对象,而不是修改现有状态public User updateAge(int newAge) {return new User(this.username, newAge);}
}
3.3. 封装和同步
对于字段值需要变化的类,可以通过适当的封装 + 同步来保障线程安全。主要是以下两个步骤
3.3.1 两步操作保障线程安全
Step1:将字段设为private
字段设置成public
是违背线程安全的,比如下面的例子。
public class UnsafeClass {// 可直接访问,可被任何线程修改,非线程安全public int value;
}
Step2:识别非原子性操作并增加同步措施
类字段设置成私有后,还需要确保修改字段的方法是原子性的。
public class SafeClass {private int value; // 使用synchronized加锁同步,确保只有一个线程可以在特定实例上执行这个方法public synchronized void incre() {value++;}
}
3.3.2 对同步的进一步说明
- volatile关键字
如果你的程序只需要确保一个线程所做的更改对其他线程立即可见,可以理解成“部分同步”,那可以将这个字段用volatile关键字修饰。但是这只能解决可见性问题,不能解决原子性问题。
2. 粗粒度锁 vs 细粒度锁
- 粗粒度锁:锁的范围大,通常保护整个对象、整个方法或一大段代码。
- 细粒度锁:锁的范围小,尽量只锁住真正需要同步的那部分代码或数据。
- 主要区别如下表,实际开发需要根据需求在简单性和性能要求之间取得平衡
对比项 | 粗粒度锁 | 细粒度锁 |
---|---|---|
并发性能 | 冲突大,容易成为瓶颈 | 更灵活,允许更高并发和吞吐量 |
实现复杂度 | 实现简单,不易出错 | 实现复杂,需要精细设计,容易出现死锁等问题 |
资源开销 | 管理成本低,但线程等待成本可能高 | 管理成本高,但可降低线程之间的互斥冲突 |
3.4 线程安全库
在构建线程安全类时,不一定非得自己造锁。Java 的 java.util.concurrent 包已经为我们提供了大量高性能、线程安全的工具类,它们通常通过细粒度锁机制或无锁算法,帮助我们提高并发性能、降低开发复杂度。
/*
SafeUserManager没有显式加锁,却是线程安全的。
ConcurrentHashMap 内部采用了分段锁或 CAS 技术,支持高并发访问。
AtomicInteger 通过底层 Unsafe 类实现无锁自增操作,避免了传统的 synchronized。
*/
public class SafeUserManager {// java.util.concurrent包提供的线程安全HashMapprivate final ConcurrentHashMap<String, User> userMap = new ConcurrentHashMap<>();// 线程安全的原子类计数器private final AtomicInteger userCount = new AtomicInteger();public void register(String id, User user) {userMap.put(id, user);userCount.incrementAndGet();}
}
Java中常用的线程安全组件
类型 | 代表类 | 场景用途 |
---|---|---|
集合类 | ConcurrentHashMap 、CopyOnWriteArrayList | 读多写少、读写并发访问 |
原子变量 | AtomicInteger 、AtomicLong 、AtomicReference | 原子计数器、状态控制 |
并发队列 | LinkedBlockingQueue 、ArrayBlockingQueue | 生产者-消费者模型,线程通信 |
同步工具类 | CountDownLatch 、CyclicBarrier 、Semaphore | 线程协调、限流、批处理等场景 |
3.5 线程封闭
当前线程的变量不与其他线程共享,只在自己的线程中使用。也就是每个线程都有自己的副本。
Java中使用线程封闭技术的两个典型例子
- Swing 的可视化组件和数据模型对象:这二者都不是线程安全的,Swing 通过将它们封闭到 Swing 的事件分发线程中来实现线程安全性;为了进一步简化对 Swing 的使用,Swing 还提供了 invokeLater 机制,用于将一个 Runnable 实例调度到事件线程中执行。
- JDBC 的 Connection 对象:在典型的服务器应用程序中,线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。在这个过程中,大多数请求(例如 Servlet 请求 或 EJB 调用)都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池不会再将它分配给其他线程。也就是说,这种连接管理模式在处理请求时隐含地将 Connection 对象封闭在线程中。
一个传统的线程封闭工具是ThreadLocal
,它为每个线程维护独立副本。但它的生命周期不透明(容易引起内存泄露)并且语义不清晰(值是全局静态字段绑定,容易被误用)。在Java 21中引入了ScopedValue
作为是ThreadLocal的替代品,提供了更好的性能和更清晰的语义,特别是对于虚拟线程。
// ScopedValue用法示例
private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();public static void main(String[] args) {ScopedValue.where(CURRENT_USER, "Alice").run(() -> {// 输出:Current user is AlicelogCurrentUser(); });
}private static void logCurrentUser() {System.out.println("Current user is " + CURRENT_USER.get());
}
3.6 防御性拷贝
简单来说,就是在将可变对象暴露给外部之前,先拷贝一份副本,这样即使外部修改了返回的对象,也不会影响类的内部状态。
public final class Person {private final Date birthDate;public Person(Date birthDate) {// 场景1:构造函数中防御性拷贝this.birthDate = new Date(birthDate.getTime());}public Date getBirthDate() {// 场景2:getter方法中防御性拷贝return new Date(birthDate.getTime());}
}
如果不使用防御性复制,调用者即使在将Date对象传递给你的类后也可以修改它,同时破坏了封装性和线程安全性。
4. 总结
在 Java 中构建线程安全的类,关键在于理解并发访问可能带来的风险,并根据类的使用场景选择合适的防护策略。以下是对前面所讲内容的总结。
- 无状态类:从根源上消除共享状态,天然线程安全。
- 不可变类:对象一旦创建即不可更改,避免并发修改带来的不确定性。
- 封装 + 同步:通过封装可变状态,并在必要处加锁,保护共享数据不被并发破坏。
- 线程安全库:使用 ConcurrentHashMap、AtomicInteger 等工具类,简化并发控制。
- 线程封闭(Thread Confinement):将数据限制在当前线程内部,避免共享。
- 防御性拷贝:对传入和返回的可变对象进行复制,防止外部修改。