Alejandro Ugarte 2024-06-11
1. 概述
Java 原生支持多线程。这意味着 JVM 能够通过在独立的工作线程中并发执行字节码,从而提升应用程序的性能。
尽管多线程是一项强大的功能,但它也伴随着代价。在多线程环境中,我们需要以线程安全的方式编写实现代码。也就是说,多个线程可以同时访问相同的资源,而不会引发错误行为或产生不可预测的结果。这种编程方法被称为“线程安全”。
在本教程中,我们将探讨实现线程安全的不同方法。
2. 无状态实现
在大多数情况下,多线程应用程序中的错误源于多个线程之间不正确地共享状态。
因此,我们首先探讨的方法是使用无状态实现来实现线程安全。
为了更好地理解这种方法,让我们考虑一个简单的工具类,其中包含一个用于计算数字阶乘的静态方法:
public class MathUtils {
public static BigInteger factorial(int number) {
BigInteger f = new BigInteger("1");
for (int i = 2; i <= number; i++) {
f = f.multiply(BigInteger.valueOf(i));
}
return f;
}
}
factorial() 方法是一个无状态的确定性函数:给定特定输入,它总是产生相同的输出。
该方法既不依赖外部状态,也不维护任何内部状态。因此,它被认为是线程安全的,可以被多个线程同时安全调用。
所有线程都可以安全地调用 factorial() 方法,并获得预期结果,彼此之间不会相互干扰,也不会影响该方法为其他线程生成的输出。
因此,无状态实现是实现线程安全最简单的方式。
3. 不可变实现
如果我们需要在不同线程之间共享状态,可以通过将类设计为**不可变(immutable)**来创建线程安全的类。
不可变性是一种强大且与语言无关的概念,在 Java 中实现起来也相当容易。
简单来说,当一个类的实例在其构造完成后其内部状态无法再被修改时,该实例就是不可变的。
在 Java 中创建不可变类的最简单方式是:将所有字段声明为 private 和 final,并且不提供 setter 方法:
public class MessageService {
private final String message;
public MessageService(String message) {
this.message = message;
}
// 标准 getter 方法
}
MessageService 对象在构造完成后其状态就无法更改,因此它是有效不可变的,也是线程安全的。
此外,即使 MessageService 实际上是可变的,但只要多个线程仅对其具有只读访问权限,它仍然是线程安全的。
由此可见,不可变性是实现线程安全的另一种方式。
4. 线程局部(Thread-Local)字段
在面向对象编程(OOP)中,对象通常需要通过字段维护状态,并通过一个或多个方法实现行为。
如果我们确实需要维护状态,可以通过让字段成为**线程局部(thread-local)**的方式来创建不在线程间共享状态的线程安全类。
我们可以通过在 Thread 子类中定义私有字段,轻松创建字段为线程局部的类。
例如,我们可以定义一个 Thread 子类,它存储一个整数列表:
public class ThreadA extends Thread {
private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
@Override
public void run() {
numbers.forEach(System.out::println);
}
}
与此同时,另一个线程类可能持有字符串列表:
public class ThreadB extends Thread {
private final List<String> letters = Arrays.asList("a", "b", "c", "d", "e", "f");
@Override
public void run() {
letters.forEach(System.out::println);
}
}
在这两个实现中,每个类都拥有自己的状态,但这些状态不会与其他线程共享。因此,这些类是线程安全的。
类似地,我们也可以通过将 ThreadLocal 实例赋值给字段来创建线程局部变量。
考虑以下 StateHolder 类:
public class StateHolder {
private final String state;
// 标准构造函数 / getter
}
我们可以轻松将其变为线程局部变量:
public class ThreadState {
public static final ThreadLocal<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {
@Override
protected StateHolder initialValue() {
return new StateHolder("active");
}
};
public static StateHolder getState() {
return statePerThread.get();
}
}
线程局部字段与普通类字段非常相似,区别在于:每个访问它们的线程都会获得一个独立初始化的副本,从而每个线程都拥有自己的状态。
5. 同步集合(Synchronized Collections)
我们可以使用 Java 集合框架中提供的**同步包装器(synchronization wrappers)**轻松创建线程安全的集合。
例如,我们可以使用其中一个同步包装器来创建线程安全的集合:
Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();
需要注意的是,同步集合在每个方法内部使用了内置锁(intrinsic locking)(我们稍后会讨论内置锁)。
这意味着一次只能有一个线程访问这些方法,其他线程将被阻塞,直到第一个线程释放锁。
因此,由于同步访问的底层逻辑,同步会带来性能开销。
6. 并发集合(Concurrent Collections)
除了同步集合,我们还可以使用**并发集合(concurrent collections)**来创建线程安全的集合。
Java 提供了 java.util.concurrent 包,其中包含多种并发集合,例如 ConcurrentHashMap:
Map<String, String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");
与同步集合不同,并发集合通过将数据划分为多个段来实现线程安全。例如,在 ConcurrentHashMap 中,多个线程可以同时对不同的 map 段加锁,从而允许多个线程同时访问 Map。
由于支持并发线程访问,并发集合的性能远优于同步集合。
值得注意的是:同步集合和并发集合仅保证集合本身是线程安全的,并不保证集合中元素的内容是线程安全的。
7. 原子对象(Atomic Objects)
我们还可以使用 Java 提供的一组原子类来实现线程安全,包括 AtomicInteger、AtomicLong、AtomicBoolean 和 AtomicReference。
原子类允许我们在不使用同步的情况下执行原子操作(atomic operations),这些操作是线程安全的。原子操作是指在单个机器级指令中完成的操作。
为了理解这个问题的解决方案,让我们看下面的 Counter 类:
public class Counter {
private int counter = 0;
public void incrementCounter() {
counter += 1;
}
public int getCounter() {
return counter;
}
}
假设在竞态条件下,两个线程同时访问 incrementCounter() 方法。
理论上,counter 字段的最终值应为 2。但我们无法保证结果,因为两个线程同时执行同一代码块,而 += 操作不是原子的。
现在,我们使用 AtomicInteger 对象创建一个线程安全的 Counter 实现:
public class AtomicCounter {
private final AtomicInteger counter = new AtomicInteger();
public void incrementCounter() {
counter.incrementAndGet();
}
public int getCounter() {
return counter.get();
}
}
这是线程安全的,因为虽然 ++ 操作需要多个步骤,但 incrementAndGet() 是原子操作。
8. 同步方法(Synchronized Methods)
前面的方法非常适合处理集合和基本类型,但有时我们需要更精细的控制。
因此,另一种常见的实现线程安全的方法是使用同步方法。
简而言之,一次只能有一个线程访问同步方法,其他线程将被阻塞,直到第一个线程执行完毕或方法抛出异常。
我们可以通过在方法签名前加上 synchronized 关键字,创建同步方法:
public synchronized void incrementCounter() {
counter += 1;
}
由于一次只能有一个线程访问同步方法,因此线程将依次执行 incrementCounter() 方法,不会发生重叠执行。
同步方法依赖于“内置锁(intrinsic locks)”或“监视器锁(monitor locks)”。内置锁是与特定类实例关联的隐式内部实体。
在多线程上下文中,“监视器(monitor)”一词指的是锁在关联对象上所扮演的角色——它强制对一组指定方法或语句的独占访问。
当线程调用同步方法时,它会获取内置锁;方法执行完毕后,它会释放锁,从而允许其他线程获取锁并访问该方法。
我们可以对**实例方法、静态方法和代码块(synchronized statements)**实现同步。
9. 同步代码块(Synchronized Statements)
有时,对整个方法进行同步可能是过度的,如果我们只需要使方法中的一小段代码线程安全。
为了说明这种用例,让我们重构 incrementCounter() 方法:
public void incrementCounter() {
// 其他不需要同步的操作
synchronized(this) {
counter += 1;
}
}
这个例子虽然简单,但它展示了如何创建同步代码块。假设该方法现在执行一些额外操作(不需要同步),我们只需将修改状态的相关部分包裹在 synchronized 块中即可。
与同步方法不同,同步代码块必须显式指定提供内置锁的对象,通常是 this 引用。
由于同步是有开销的,这种方式使我们能够仅同步方法中相关的部分。
9.1 使用其他对象作为锁
我们可以通过使用另一个对象作为监视器锁(而不是 this)来略微改进 Counter 类的线程安全实现。
这不仅能在多线程环境中协调对共享资源的访问,还能使用外部实体来强制独占访问:
public class ObjectLockCounter {
private int counter = 0;
private final Object lock = new Object();
public void incrementCounter() {
synchronized(lock) {
counter += 1;
}
}
// 标准 getter
}
这里我们使用一个普通的 Object 实例来强制互斥访问。这种实现略优,因为它在锁级别上提升了安全性。
如果使用 this 作为内置锁,攻击者可能通过获取该内置锁并触发拒绝服务(DoS)条件,造成死锁。
相反,使用私有对象作为锁时,该实体对外部不可见,使得攻击者难以获取锁并造成死锁。
9.2 注意事项
尽管我们可以使用任意 Java 对象作为内置锁,但应避免使用字符串作为锁:
public class Class1 {
private static final String LOCK = "Lock";
// 使用 LOCK 作为内置锁
}
public class Class2 {
private static final String LOCK = "Lock";
// 使用 LOCK 作为内置锁
}
乍看之下,这两个类似乎使用了两个不同的对象作为锁。但由于**字符串驻留(string interning)**机制,这两个 "Lock" 值实际上可能指向字符串池中的同一个对象。也就是说,Class1 和 Class2 共享了同一个锁!
这在并发上下文中可能导致意外行为。
除了字符串,我们还应避免使用可缓存或可复用的对象作为内置锁。例如,Integer.valueOf() 方法会缓存小整数。因此,即使在不同类中调用 Integer.valueOf(1),也会返回同一个对象。
10. volatile 字段
同步方法和代码块有助于解决线程间的变量可见性问题。即便如此,普通类字段的值仍可能被 CPU 缓存。因此,即使更新是同步的,其他线程也可能看不到后续的更新。
为避免这种情况,我们可以使用 volatile 类字段:
public class Counter {
private volatile int counter;
// 标准构造函数 / getter
}
使用 volatile 关键字后,我们指示 JVM 和编译器将 counter 变量存储在主内存中。这样可以确保每次 JVM 读取 counter 时,都是从主内存读取,而不是 CPU 缓存;同样,每次写入也会直接写入主内存。
此外,使用 volatile 变量还能确保:对该线程可见的所有变量都会从主内存中读取。
考虑以下示例:
public class User {
private String name;
private volatile int age;
// 标准构造函数 / getter
}
在这种情况下,每当 JVM 将 age(volatile 变量)写入主内存时,也会将非 volatile 的 name 变量写入主内存。这确保了两个变量的最新值都存储在主内存中,因此后续更新对其他线程自动可见。
同样,如果一个线程读取了 volatile 变量的值,那么该线程可见的所有变量也会从主内存中读取。
这种由 volatile 变量提供的扩展保证被称为 “完整的 volatile 可见性保证(full volatile visibility guarantee)”。
11. 可重入锁(Reentrant Locks)
Java 提供了一组改进版的 Lock 实现,其行为比上述内置锁更为灵活。
使用内置锁时,锁的获取模型较为僵化:一个线程获取锁 → 执行方法或代码块 → 释放锁 → 其他线程才能获取。
该模型没有机制检查等待队列中的线程,也无法优先授予等待时间最长的线程。
而 ReentrantLock 实例正好可以做到这一点,从而防止排队线程出现某些类型的资源饥饿:
public class ReentrantLockCounter {
private int counter;
private final ReentrantLock reLock = new ReentrantLock(true);
public void incrementCounter() {
reLock.lock();
try {
counter += 1;
} finally {
reLock.unlock();
}
}
// 标准构造函数 / getter
}
ReentrantLock 构造函数接受一个可选的 fairness(公平性)布尔参数。当设为 true 时,如果有多个线程尝试获取锁,JVM 会优先授予等待时间最长的线程。
12. 读写锁(Read/Write Locks)
另一种强大的线程安全机制是使用 ReadWriteLock 实现。
ReadWriteLock 实际上使用一对关联的锁:一个用于只读操作,另一个用于写操作。
因此,只要没有线程在写入资源,就可以允许多个线程同时读取该资源。而一旦有线程开始写入,就会阻止其他线程读取。
以下是使用 ReadWriteLock 的示例:
public class ReentrantReadWriteLockCounter {
private int counter;
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public void incrementCounter() {
writeLock.lock();
try {
counter += 1;
} finally {
writeLock.unlock();
}
}
public int getCounter() {
readLock.lock();
try {
return counter;
} finally {
readLock.unlock();
}
}
// 标准构造函数
}
13. 结论
在本文中,我们学习了 Java 中的线程安全概念,并深入探讨了实现线程安全的各种方法:
- 无状态实现
- 不可变对象
- 线程局部变量
- 同步集合与并发集合
- 原子类
- 同步方法与同步代码块
volatile字段- 可重入锁与读写锁
根据具体场景选择合适的方法,可以有效提升程序的并发性能与可靠性。