实现无bug并发编程的4个简单规则






4.27/5 (8投票s)
本文通过一个例子展示了有效并发编程的规则。
介绍
编写正确的程序很难,但编写正确的并发程序则难得多。尽管如此,任何级别的 Java 开发人员都不能忽视这个问题。而且在过去的一年里,还有一个充分的理由需要更加关注多线程编程:CPU 频率的不断提高以及单个核心上集成晶体管数量的竞赛即将结束,因为 CPU 制造商在满足低功耗设备的要求时开始面临一些物理限制。如今,即使是低端设备,也已经可以找到许多拥有两个或更多核心的设备。
因此,程序正从并发(同时处理更多请求)转向并行(能够将复杂耗时请求的执行分解为多个子任务,以便同时运行以利用多处理器或多核系统)。这种场景为每个 Java 开发人员带来了新的(令人兴奋的)挑战,我们每个人都应该努力掌握必要的技能来赢得这些挑战。
基本示例
本文的目的是介绍和说明一些简单但非常重要的规则,用于编写有效的、无 bug 的 Java 多线程程序。这些规则背后的原理将通过分析和改进这个非常简单的 Java 类来证明
public class FactorialCalculator {
public BigInteger factorialOf(BigInteger i) {
BigInteger result = calculateFactorial(i);
return result;
}
private BigInteger calculateFactorial(BigInteger i) {
int compare = i.compareTo(BigInteger.ONE);
if (compare < 0) throw new IllegalArgumentException();
else if (compare == 0) return i;
BigInteger i1 = i.subtract(BigInteger.ONE);
return i.multiply(calculateFactorial(i1));
}
}
无状态即线程安全
这个类只有一个 public
方法,用于计算给定 BigInteger
参数的阶乘。如果我们通过不同的线程共享这个类的实例会发生什么?该实例是否线程安全?当然,答案是肯定的,原因非常简单:一个线程访问 FactorialCalculator
不会影响另一个线程访问同一对象的其他线程的结果,因为这两个线程不共享状态。这可能是最容易理解,同时也是最重要的多线程编程规则:无状态对象始终是线程安全的。
一致地修改状态
当然,拥有某种状态的对象通常是不可避免的。为了给第一个示例添加一点状态,让我们通过添加一个变量来计算 factorialOf()
方法被调用的次数,使其稍微复杂一点
public class FactorialCalculator {
private long count;
public BigInteger factorialOf(BigInteger i) {
BigInteger result = calculateFactorial(i);
++count;
return result;
}
public long getCount() { return count; }
}
现在线程安全性如何?由于我们的 FactorialCalculator
不再是无状态对象,因此我们必须更仔细地考虑多线程执行期间可能发生的情况。实际上,虽然增量操作(++count
)由于其紧凑的语法可能看起来像一个单一操作,但它不是原子的,这意味着它不是作为一个单一的、不可分割的操作来执行的。相反,它是三个离散操作的简写:获取当前值,加一,然后将新值写回。更糟糕的是,在这个例子中,我们不仅可能读取到过时的值,还可能读取到一个没有被任何线程写入的伪随机值。这就是为什么 Java 内存模型要求获取和存储操作是原子的,但对于非易失性的 long 和 double 变量,JVM 可以将 64 位读写视为两个独立的 32 位操作。这些考虑使得我们的对象现在容易受到所谓的“丢失更新”的影响,并且其线程安全性已完全失效。这个问题突显了无 bug 多线程程序的第二条简单规则:始终检查原子性。
了解你的库
修复我们简单代码中的原子性缺失问题非常容易。从 Java 5.0 开始,java.util.concurrent.atomic
包包含了原子变量类,用于对数字和对象引用进行原子状态转换。通过用 AtomicLong
替换 long 计数器,我们可以确保所有访问计数器状态的操作都是原子的
public class FactorialCalculator {
private final AtomicLong count = new AtomicLong(0);
public BigInteger factorialOf(BigInteger i) {
BigInteger result = calculateFactorial(i);
count.incrementAndGet();
return result;
}
public long getCount() { return count.get(); }
}
原子变量类只是 Java 5 相对于早期版本提供的众多改进之一,这些改进使得并发开发更加容易。Java 5 提供的其他基本功能是新的一组线程安全集合、用于运行不相关任务的执行器框架以及为了更好地支持诸如 Semaphore
、ReentrantLock
和 CountDownLatch
等高级并发设计而添加的几类其他类。对这些类及其 API 的良好掌握使我们能够在经过充分验证的线程安全库的基础上构建并发程序,而无需重复造轮子。
保持不变式
现在假设我们想进一步改进 FactorialCalculator
。特别是,我们发现出于某种原因,我们对象的客户端经常会多次请求某个数字的阶乘,因此我们决定通过缓存最后一个计算出的阶乘值来提高其性能,如下所示
public class FactorialCalculator { private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>(); private final AtomicReference<BigInteger> lastFactorial = new AtomicReference<BigInteger>(); public BigInteger factorialOf(BigInteger i) { if (i.equals(lastNumber.get())) return lastFactorial.get(); BigInteger result = calculateFactorial(i); lastNumber.set(i); lastFactorial.set(result); return result; } }
我们通过 AtomicReferences
更新 lastNumber
和 lastFactorial
的值,因此对象的线程安全性仍然得到保证,对吗?不幸的是,不是。这种方法不起作用,因为尽管原子引用本身是线程安全的,但 FactorialCalculator
仍然存在竞态条件,这可能导致它产生错误答案。
不变式是在为了保持多线程程序的正确性而需要在程序执行的每个原子操作之前和之后立即验证的条件。FactorialCalculator
的唯一不变式是缓存在 lastFactorial
中的阶乘值必须与缓存在 lastNumber
中的值相对应,并且只有当这个不变式始终成立时它才是正确的。这意味着使我们的并行程序避免 bug 的第三条规则是:确保不变式无论在多线程操作的任何时间或交错下都能得到保持。
使用小的同步块
如前所述,为了保持 FactorialCalculator
的不变式,我们需要同步修改其状态的代码,如下所示
public class FactorialCalculator {
private final AtomicReference<BigInteger> lastNumber =
new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger> lastFactorial =
new AtomicReference<BigInteger>();
public BigInteger factorialOf(BigInteger i) {
synchronized(this) {
if (i.equals(lastNumber.get()))
return lastFactorial.get();
}
BigInteger result = calculateFactorial(i);
synchronized(this) {
lastNumber.set(i);
lastFactorial.set(result);
}
return result;
}
}
我们类的最后一个版本通过使用相同的锁来保护对 lastNumber
和 lastFactorial
的读写操作,从而强制执行最后一个缓存结果的不变式。请注意,我们可以通过同步整个 factorialOf()
方法以一种更简单(更易读)的方式实现相同的结果,但这个糟糕的选择意味着该方法的所有调用都将被完全序列化,使我们编写一个在多线程环境中性能良好的线程安全类的努力变得毫无意义。从最后的考虑可以得出第四条规则:尽可能使同步块变小(但不要太小),以免过度损害我们代码的并行执行。
历史
- 2009 年 2 月 28 日:初始发布