第二章——线程安全性
2.1 什么是线程安全性
无状态对象一定是线程安全的
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
上面的 StatelessFactorizer
是无状态的,它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。因此无状态对象是线程安全的。
2.2 原子性
2.2.1 竞态条件(Race condition)
由于不恰当的执行时序而出现不正确的结果,就叫竞态条件。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,正确的结果要取决于运气。
竞态条件非常容易与另一个术语“数据竞争(Data Race)” 相混淆。数据竞争是指,如果在访问共享的非 final 类型的域是没有采用同步来进行协同,那么就会出现数据竞争。
最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null) { // 可能多个线程同时判断
instance = new ExpensiveObject();
}
return instance;
}
}
另一种常见的竞态条件类型就是“读取 - 修改 - 写入”操作,其结果依赖于之前的状态。
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void service(ServletRequest req, ServletRespose resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count; // 这里其实是3步操作
encodeIntoResponse(resp, factors);
}
}
2.2.3 复合操作
我们将“先检查后执行”以及“读取 - 修改 - 写入” 等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。对于 UnsafeCountingFactorizer
我们可以用线程安全对象(例如 AtomicLong
) 来管理类的状态。
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0); // 替换为线程安全类
public long getCount() {
return count.get();
}
public void service(ServletRequest req, ServletRespose resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}
使用 AtomicLong
来代替 long
类型的计数器,并通过使用线程安全类 AtomicLong
来管理计数器的状态,从而确保了代码的线程安全性。当在无状态类中添加一个状态时,如果说状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。但是,当状态变量的数量由一个变为多个时,并不会向状态变量数量由零个变为一个那样简单。
2.3 加锁机制
现在就来回答上面的问题:当类有多个状态时,是否只需添加多个线程安全状态变量就足够了?
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>(); // 用 AtomicReference 保存上一次的输入
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>(); // 用 AtomicReference 保存上一次的结果
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromResult(req);
if (i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors); // 由于 lastNumber 和 lastFactors 是由关联性的,因此这两组 set 操作并不是原子的
encodeIntoResponse(resp, factors);
}
}
}
上面的例子就有两个状态域,虽然都用线程安全类存储了状态,但是由于两个状态域之间是由关联性的,因此它们一起又形成了一组复合操作,这组操作并不是线程安全的。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3.1 内置锁
Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。每个 Java
对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock) 或 监视器锁(Monitor Lock)。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。Java 的内置锁相当于是一种互斥锁,这意味着最多只有一个线程能持有这种锁。
我们可以使用内置锁机制来使 UnsafeCachingFactorizer
类变成线程安全的:
public class SynchronizedFactorizer implements Servlet {
private BigInteger lastNumber;
private BigInteger[] lastFactors;
// 这里添加了 synchronized 关键字,使同一时刻只有一个线程可以执行 service 方法
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromResult(req);
if (i.equals(lastNumber)) {
encodeIntoResponse(resp, lastFactors);
} else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}
上面通过 synchronzed
关键字来保证了线程安全,但是却过于极端,因此为多个客户端无法同时使用因数分解 Servlet,服务的响应性非常低。但这是一个性能问题,而不是线程安全问题。同时,注意到使用 synchronized
关键字后,状态域 lastNumber
和 lastFactors
也都可以不再使用线程安全类型了。这个涉及到 可见性 问题。
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁时可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。重入意味着获取锁的操作粒度是“线程”,而不是“调用”。
这与 pthread(POSIX 线程)互斥体的默认加锁行为不同,pthread 互斥体的获取操作是以“调用”为粒度的。
重入的一种实现是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为 0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将记下锁的持有者,并且将获取指数值置为 1。如果同一个线程再次获取这个锁,计数值降递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为 0 时,这个锁将被释放。
重入的设计进一步提升了加锁行为的封装性,简化了面向对象并发代码的开发。
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething(); // 重入了
}
}
如果内置锁不是可重入的,那么上面代码将进入死锁。
2.5 性能与活跃性
public class CachedFactorizer implements Servlet {
private BigInteger lastNumber;
private BigInteger[] lastFactors;
private long hits;
private long cacheHits;
public synchronized long getHits() {
return hits;
}
public synchronized double getCacheHitRatio() {
return (double) cacheHits / (double) hits;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
// 同步代码块1:负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列
synchronized (this) {
++hits;
if (i.equals(lastNumber)) {
++cacheHits;
factors = lastFactors.clone();
}
}
if (factors == null) {
// 在执行时间较长的因数分解之前需要释放锁
factors = factor(i);
// 负责确保对缓存的数值和因数分解结果为进行同步更新
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
我们前面的因数分解 service
使用 synchronized
关键字来达成对整个方法的同步,但是这样造成的问题就是多个客户端无法同时使用因数分解 service
,造成性能下降。我们可以通过上面的代码改造,将其业务分为两个独立的同步代码块,每个同步代码块都只包含一小段代码。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需要同步。
上面重构代码的一个很大的优点就在于,在执行时间较长的因数分解函数 factor
时,并不在同步代码中,因此能够支持多个客户端同时访问。这样就实现了在简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。