什么是线程安全的类?
什么是线程安全的类呢?当多个线程可以同时使用一个Java类,且不会导致竞态条件或不一致状态时,这个Java类就被认为是线程安全的。线程安全性确保即使多个线程正在访问线程安全类的同一个对象,该Java对象也会处于一致的状态。如果一个Java类对象不会被多个线程访问,那么就无需担心线程安全性。在这种情况下,你不需要线程安全的类。我在项目中多次遇到这样的情况,从理论上讲,某个Java类不是线程安全的,但它们不会同时被多个线程访问,因此在那种情况下该Java类不需要是线程安全的。
理解问题:竞态条件
让我们来看一个经典的非线程安全类,以便理解为什么线程安全性很重要:
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // 这不是一个原子操作!
}
public void decrement() {
count--; // 这不是一个原子操作!
}
public int getCount() {
return count;
}
}
你认为increment
和decrement
方法是线程安全的吗?不是,因为像count++
这样的操作不是原子性的——它们实际上包含三个独立的步骤:
- 读取
count
的当前值 - 加/减1
- 将新值写回
count
当count
为0且两个线程同时调用increment()
方法时,可能会发生以下情况:
- 线程A读取
count
(值为0) - 线程B读取
count
(值为0) - 线程A加1并写入1
- 线程B加1并写入1
- 最终结果是1,而不是预期的2
这被称为竞态条件,它会使我们的计数器处于不一致的状态。解决方案是将类设计为线程安全的。
构建线程安全类的策略
1. 无状态类:没有状态,就没有问题
如果一个类不维护任何状态(字段),那么它自动就是线程安全的。毕竟,如果没有什么可修改的,也就没有什么会被破坏!
public class MathHelper {
// 没有字段 = 没有共享状态
public int add(int a, int b) {
return a + b;
}
public static int multiply(int a, int b) {
return a * b;
}
public double calculateAverage(int[] numbers) {
int sum = 0;
for (int num : numbers) {
sum += num;
}
return numbers.length > 0? (double) sum / numbers.length : 0;
}
}
这个类本质上是线程安全的,因为每个方法只对其参数进行操作,并且在调用之间没有共享状态。
2. 不可变类:只读是你的好帮手
不可变性是实现线程安全的一种强大方式。如果一个对象在创建后不能被修改,那么就不存在并发修改的风险。
public final class ImmutablePoint {
private final int x;
private final int y;
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
// 操作会创建新对象,而不是修改当前对象
public ImmutablePoint translate(int dx, int dy) {
return new ImmutablePoint(x + dx, y + dy);
}
}
Java中的String
类是不可变且线程安全类的完美示例。这就是为什么在使用字符串时,你永远不必担心同步问题。
尽可能使用final
关键字(许多资深的Java专家都这么说),因为它有助于防止意外修改并保证线程安全。
3. 封装和同步
对于需要可变状态的类,恰当的封装与同步相结合是至关重要的:
步骤1:将字段设为私有
可公开访问的字段会破坏线程安全性:
// 封装性差 - 不是线程安全的
public class UnsafeCounter {
public int count; // 可直接访问,任何线程都可以修改
}
步骤2:识别非原子操作并对其进行同步
一旦你的字段是私有的,你需要确保修改状态的方法是以原子方式进行的:
public class SafeCounter {
private int count; // 对外部访问隐藏
public synchronized void increment() {
count++;
}
public synchronized void decrement() {
count--;
}
public synchronized int getCount() {
return count;
}
}
synchronized
关键字确保在任何给定时间,只有一个线程可以在特定实例上执行这些方法。这可以防止竞态条件,但由于加锁带来的开销,确实会有性能成本。
volatile关键字用于可见性
有时,你不需要完全的同步,但确实需要确保一个线程所做的修改对其他线程是可见的:
public class StatusChecker {
private volatile boolean running = true;
public void stop() {
running = false;
}
public void performTask() {
while (running) {
// 做一些事情
}
}
}
volatile
关键字确保对变量的修改会立即对其他线程可见,从而防止可见性问题,不过它对原子性没有帮助。
粗粒度锁与细粒度锁
别被“粗”和“细”这两个词吓到,哈哈!简单来说,粗粒度锁意味着锁定较大的区域,而细粒度锁意味着锁定较小的区域。
对整个方法进行同步是可行的,但会对性能产生影响。它会锁定整个方法,假设方法执行需要一些时间,那么许多其他线程将需要长时间等待才能进入该方法。
粗粒度锁 意味着使用较少、范围更广的锁,这些锁保护较大的代码段或数据结构。例如,当你只需要修改一个元素时却锁定了整个列表,或者当只有方法的一部分需要同步时却锁定了整个方法。
细粒度锁 意味着使用许多较小、有针对性的锁,这些锁保护特定的组件或操作。例如,只锁定你正在修改的特定列表元素。
// 粗粒度锁
public synchronized void transferMoney(Account from, Account to, int amount) {
from.debit(amount);
to.credit(amount);
}
// 细粒度锁
public void transferMoney(Account from, Account to, int amount) {
synchronized(from) {
synchronized(to) {
from.debit(amount);
to.credit(amount);
}
}
}
关键区别:
- • 性能:细粒度锁通常允许更高的并发性和吞吐量,因为多个线程可以同时访问不同的部分。粗粒度锁可能会造成瓶颈。
- • 复杂度:细粒度锁更难正确实现,并且会增加死锁的风险。粗粒度锁实现起来更简单、更安全。
- • 开销:细粒度锁在管理许多锁时可能会有更高的开销。粗粒度锁的锁管理开销较小,但竞争成本较高。
正确的方法取决于你特定的应用需求,需要在简单性和性能要求之间进行权衡。
4. 利用线程安全的库
为了实现细粒度锁,Java提供了许多线程安全的集合和实用工具,这些工具可以简化线程安全类的构建:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadSafeUserManager {
// 来自java.util.concurrent的线程安全映射
private final ConcurrentHashMap<String, User> users = new ConcurrentHashMap<>();
// 使用原子类的线程安全计数器
private final AtomicInteger userCount = new AtomicInteger(0);
public void addUser(String id, User user) {
users.put(id, user);
userCount.incrementAndGet();
}
public User getUser(String id) {
return users.get(id);
}
public int getTotalUsers() {
return userCount.get();
}
}
这种方法使用细粒度锁(在并发集合内部),而不是粗粒度锁(对整个方法进行同步),在有竞争的情况下可能会提高性能。
常见的线程安全组件包括:
- • 集合:
ConcurrentHashMap
、CopyOnWriteArrayList
、ConcurrentLinkedQueue
- • 原子变量:
AtomicInteger
、AtomicLong
、AtomicReference
- • 队列:
LinkedBlockingQueue
、ArrayBlockingQueue
- • 同步器:
CountDownLatch
、CyclicBarrier
、Semaphore
5. 线程封闭:每个线程有自己的副本
有时,避免共享的最佳方法就是根本不共享。线程封闭意味着确保每个线程都有自己独立的数据副本:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ScopedValue
(在Java 21中引入)是ThreadLocal
的现代替代品,它提供了更好的性能和更清晰的语义,特别是对于虚拟线程。
public class ScopedValueExample {
// 定义一个ScopedValue(Java 21+)
private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
public static void main(String[] args) {
// 使用绑定到ScopedValue的特定值运行代码
ScopedValue.where(CURRENT_USER, "Alice").run(() -> {
processRequest();
// 在下游方法中该值仍然可用
auditAction("data_access");
});
// 嵌套作用域中的多个绑定
ScopedValue.where(CURRENT_USER, "Bob").run(() -> {
System.out.println("外部作用域: " + CURRENT_USER.get());
// 可以在内部作用域中重新绑定
ScopedValue.where(CURRENT_USER, "Charlie").run(() -> {
System.out.println("内部作用域: " + CURRENT_USER.get());
});
// 外部绑定得以保留
System.out.println("回到外部: " + CURRENT_USER.get());
});
}
private static void processRequest() {
// 从另一个方法访问ScopedValue
System.out.println("为以下用户处理请求: " + CURRENT_USER.get());
}
private static void auditAction(String action) {
// 从ScopedValue获取用户,而无需将其作为参数传递
System.out.println("用户 " + CURRENT_USER.get() + " 执行了操作: " + action);
}
}
6. 防御性拷贝:保护内部数据
当你的类持有对可变对象的引用时,在接受或返回这些对象时,你应该考虑进行防御性拷贝:
public class DefensiveCalendar {
private final Date startDate;
public DefensiveCalendar(Date start) {
// 进行防御性拷贝,防止调用者修改我们的状态
this.startDate = new Date(start.getTime());
}
public Date getStartDate() {
// 进行防御性拷贝,防止调用者修改我们的状态
return new Date(startDate.getTime());
}
}
如果没有这些防御性拷贝,调用者甚至在将Date
对象传递给你的类之后仍可以修改它,这会破坏封装性,并可能影响线程安全性。
更多关于Java多线程教程可点击系列专栏:https://java.didispace.com/java-multithreading/
结论
在Java中构建线程安全的类需要仔细考虑你的类在并发环境中会如何被使用。让我们总结一下关键策略:
- 无状态类 完全消除了共享状态。
- 不可变类 在构造后防止被修改。
- 恰当的封装 与 同步 保护可变状态。
- 线程安全的库 为复杂类提供了构建模块。
- 线程封闭 将状态隔离到各个线程。
- 防御性拷贝 防止外部修改。
- 锁的粒度 选择在安全性和性能之间进行权衡。
线程安全类的可视化指南