Java并发编程实战读书笔记-Chapter2
2.1 什么是线程安全
当多个线程访问某个类时,这个类始终都能表现出正确的行为(某个类的行为与其规范完全一致),那么就称这个类是线程安全的。
示例1:无状态的Servlet(这个Servlet从请求中提取数值,执行因数分解,然后将结果封装到该Servlet的响应中)
public class StatelessFactorizer implements Servlet{
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
StatelessFactorizer 是无状态的,计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问StatelessFactorizer 的线程不会影响另一个访问同一个StatelessFactorizer 的线程的计算结果,因为这两个线程没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
无状态对象一定是线程安全的。
2.2 原子性
示例2:在没有同步的情况下统计已处理请求数量的Servlet(不要这么做)
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}
UnsafeCountingFactorizer 不是线程安全的。递增操作++count是一种紧凑的语法,但这个操作并不是原子。它包含了三个独立的操作:读取count的值,将值+1,然后将计算结果写入count,并不会作为一个不可分割的操作来执行。
在并发编程中,这种由于不恰当的执行时许而出现不正确的结果是一种非常重要的情况,被称为竞态条件。
2.2.1 竞态条件
在UnsafeCountingFactorizer 中存在多个竞态条件,从而使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时许时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步动作。
2.2.2 延迟初始化中的竞态条件
示例3:延迟初始化,同时确保对象只被初始化一次
class ExpensiveObject {
ExpensiveObject() {
System.out.println(this);
}
}
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null) {
instance = new ExpensiveObject();
}
return instance;
}
public static void main(String[] args) {
final LazyInitRace initRace = new LazyInitRace();
final ExecutorService executorService = Executors.newCachedThreadPool();
//每进行一次输出就是一个新的instance实例
for (int i = 0; i < 10; i++) {
executorService.execute(initRace::getInstance);
}
executorService.shutdown();
}
}
在LazyInitRace 中包含了一个竞态条件,他可能会破坏这个类的正确性。假定线程A和线程B同时执行getInstance,A看到instance为空,创建一个新的ExpensiveObject实例,线程B也需要判断instance是否为空。此时instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化ExpensiveObject并设置instance。如果当B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果。
2.2.3 复合操作
LazyInitRace和UnsafeCountingFactorizer都包含一组需要以原子方式执行的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
为了确保线程安全性,“先检查后执行”和读取-修改-写入等操作必须是原子的。我们将“先检查后执行”以及读取-修改-写入等操作成为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。
示例4:使用线程安全类型的变量来统计已处理请求的数量
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public long getCount() {
return count.get();
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.accumulateAndGet()
encodeIntoResponse(resp, factors);
}
}
通过用AtomicLong来替代long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于Servlet的状态就是计数器的状态,并且计数器是线程安全的,因此这里Servlet也是线程安全的。
当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。
2.3 加锁机制
我们希望提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,无须重复计算。要实现该缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果。
示例5:在没有足够原子性保证的情况下对最近计算结果进行缓存(不要这么做)
public class UnsafeCachingFactorizer implements Servlet{
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>(Integer);
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时许或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,在更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
在某些执行时序中,UnsafeCachingFactorizer可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors。同样,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。
要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3.1内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视器锁。Java内置锁相当于一种互斥体(互斥锁),最多只有一个线程能持有这种锁。由于每次只能有一个线程执行内置锁保护的代码块,因此由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。这种同步机制使得要确保因数分解Servlet的线程安全性变得更简单。
示例6:Servlet能正确地缓存最新的计算结果,但并发性却非常糟糕(不要这么做)
public class SynchronizedFactorizer {
@GuardedBy("this") private AtomicReference<BigInteger> lastNumber = new AtomicReference<>(Integer);
@GuardedBy("this") private AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁时可重入的,因此如果某个线程试图获得一个已经由他自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种是实现方法是:为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程推出同步代码块时,计数器会相应的递减。当计数器值为0时,这个锁将被释放。
示例7:如果内置锁不是可重入的,那么这段代码将发生死锁
public class Widget {
public synchronized void doSth() {
System.out.println(Thread.currentThread().getName() + "==> 进入 父类的doSth");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "==> 结束 父类的doSth");
}
}
public class LoggingWidget extends Widget {
@Override
public synchronized void doSth() {
System.out.println(Thread.currentThread().getName() + "进入 子类的doSth");
super.doSth();
System.out.println(Thread.currentThread().getName() + "结束 子类的doSth");
}
public static void main(String[] args) {
Widget widget = new LoggingWidget();
new Thread(widget :: doSth).start();
}
}
如果内置锁是不可重入的,那么在调用super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。
2.5 活跃性与性能
SynchronizedFactorizer中的同步方法,代码的执行性能非常糟糕。SynchronizedFactorizer的同步策略是对整个service方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。为了保证代码的执行性能,应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态,提高代码的执行性能。
示例8:
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long hits;
@GuardedBy("this") 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;
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);
}
}
CachedFactorizer中不再使用AtomicLong类型的命中计数器,而是使用了一个long类型的变量。对在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,因此在这里不使用原子变量。
在获取与释放锁等操作上都需要一定的开销,因此如果将同步代码块分解的过细,通常并不好,尽管这样做不会破坏原子性。