如何在Java中設(shè)計(jì)線程安全的類(lèi)?
等一下,但是什么是线程安全的类呢?当一个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 当前的值
- 加一或减一
- 把新值写回到 count
当有两个线程同时调用 increment()
时,且 count 为 0 的时候,可能发生的情况如下:
- 线程 A 读取 count,发现值为 0
- 线程 B 读取 count,发现值为 0
- 线程 A 将 count 加 1 并写回 1
- 线程 B 将 count 加 1 并写回 1
- 最终结果是 1,而不是我们期望的 2
这种情况被称为竞态条件,它使我们的计数器处于不一致的状态,。解决方案是使该类线程安全。
构建线程安全的类的策略: 1. 无状态类(NoState):无状态,就不是问题如果一个类没有属性,它自然就是线程安全的。毕竟,既然没有东西可以修改,也就没有东西可以破坏。
public class MathHelper {
// No fields = no shared state
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; } // 获取X坐标
public int getY() { return y; } // 获取Y坐标
// 创建新对象,不修改当前对象
public ImmutablePoint translate(int dx, int dy) {
return new ImmutablePoint(x + dx, y + dy);
}
}
在Java中,String
类是一个不可变且线程安全类的完美例子。这就是为什么在使用字符串时你无需担心同步问题,因为它们是不可变的。尽可能多地使用 final
关键字(这是许多Java专家的建议),这样可以防止意外修改,并且有助于提高线程安全性。
对于需要可变状态的类来说,良好的封装结合同步机制至关重要。
第一步:将字段私有化公开可访问的字段可能会引发线程安全问题。
// 封装不好 - 不支持多线程
public class UnsafeCounter {
public int count; // 可以直接访问,可以被任意线程修改
}
步骤 2:识别非原子操作步骤并同步它们
一旦你的字段被设为私有,你就需要确保这些修改状态的方法以原子操作的方式进行。
public class SafeCounter {
private int count; // 不可从外部访问
public synchronized void 增加() {
count++;
}
public synchronized void 减少() {
count--;
}
public synchronized int 获取计数() {
return count;
}
}
synchronized
关键字确保同一时刻只有一个线程能执行这些方法在特定实例上,从而避免了竞争条件。不过,加锁会带来一些开销,也会对性能造成一些影响。
有时候,你不需要完全的同步,但确实需要确保一个线程所做的更改能够被其他线程看到。在这种情况下,这样做更符合中文口语表达的习惯。
public class StatusChecker {
private volatile boolean 运行状态 = true;
public void stop() {
运行状态 = false;
}
public void performTask() {
while (运行状态) {
// 执行任务方法
// 执行一些任务
}
}
}
volatile
关键字确保对该变量所做的更改立即对其他线程可见,以避免可见性问题,虽然解决了可见性问题,但并不能帮助保证原子性。
别害怕“粗粒度”和“细粒度”这两个词。哈哈!简单点说,“粗粒度锁”就是锁住大块区域,“细粒度锁”就是锁住小块区域。
整个方法同步确实有效,但会对性能产生影响。它会锁定整个方法的执行,如果执行时间较长,那么其他许多线程将不得不等待较长时间才能进入该方法。
粗粒度锁是指使用较少但更粗粒度的锁来保护代码或数据结构的部分。例如,只需要修改一个元素时却锁定了整个列表,或者只需要同步方法的一部分却锁定了整个方法。
细粒度锁定是指使用许多更小且更具体的锁来保护特定的组件或操作的执行。就像只锁定你正在修改的那个特定列表元素一样。
// 使用粗粒度锁机制
public synchronized void 转账(Account from, Account to, int amount) {
from.扣款(amount);
to.存款(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的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
有时候,避免共享的最佳方式是根本不共享。这意味每个线程都有独立的数据副本:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
添加了Java并发库中的ExecutorService和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.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 防御性日历类 {
private final Date 起始日期;
public 防御性日历类(Date start) {
// 为了防止调用者修改我们的状态,这里进行了防御性拷贝
this.起始日期 = new Date(start.getTime());
}
public Date 获取起始日期() {
// 为了防止调用者修改我们的状态,这里进行了防御性拷贝
return new Date(起始日期.getTime());
}
}
没有这些副本,调用者甚至在将 Date 对象传递给你的类之后,仍然可以修改此对象,从而破坏封装性,甚至可能影响线程安全性。
结论篇或简单地写为 "结论:"
最后
在Java中编写线程安全的类需要仔细考虑你的类在并发环境下的使用方式。下面我们就来总结一下关键策略:
- 无状态类 完全避免了共享状态。
- 不可变类 防止在构建后进行修改
- 适当的封装 结合 同步机制 来保护可变状态。
- 线程安全库 提供构建复杂类所需的构建块。
- 线程限制策略 将状态限制在单个线程中。
- 防御性复制 保护免受外部修改的影响。
- 锁粒度 选择以平衡安全性和性能为目标。
线程安全类的视觉指导
共同學(xué)習(xí),寫(xiě)下你的評(píng)論
評(píng)論加載中...
作者其他優(yōu)質(zhì)文章
100積分直接送
付費(fèi)專(zhuān)欄免費(fèi)學(xué)
大額優(yōu)惠券免費(fèi)領(lǐng)