通过使用线程安全对象来管理Servlet的全部状态,可以维护Servlet的线程安全性,这样我们只能在Servlet中加入一个状态变量。但是我们如果想加入更多的状态,可以仅
public class UnsafeCachingFactorizer implements Servlet {
public class SynchronizedFactorizer implements Servlet {
仅加入更多的线程安全的状态变量吗?
想象下面的情形:我们缓存最新的计算结果,以应对两个连续的客户请求相同的数字进行因数分解,希望由此提高Servlet的性能。(这未必是一个有效的缓存策略;在5.6节我们会提供一个更好的。)要实现这个策略,我们需要记住两件事:最新请求的数字和它的因数。
我们在前面曾经是用AtomicLong,以线程安全的方式管理计数量的状态;我们还可以使用同系的AtomicReference6类型管理缓存的数字和它的因数吗?清单2.5中的UnsafeCachingFactorizer作了这种尝试:
清单2.5 没有正确原子化的Servlet试图缓存它的最新结果(不要这样做)
@NotThreadSafe
![](https://i-blog.csdnimg.cn/blog_migrate/08861faa8c8980fb01f30e0eef031b1d.png)
private final AtomicReference<BigInteger> lastNumber
= new AtomicReference<BigInteger>();
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);
}
}
}
很不幸,这种方法并不正确。尽管原子引用(atomic references)自身是线程安全的,不过UnsafeCachingFactorizer中存在竞争条件,导致它会产生错误的答案。
线程安全性的定义要求无论是多线程中的时序或交替操作,都要保证不破坏那些不变约束。UnsafeCachingFactorizer的一个不变约束是:缓存在lastFactors中的各个因子的乘积应该等于缓存在lastNumber中的数值。只有遵守这个不变约束,我们的Servlet才是正确的。当一个不变约束涉及多个变量时,变量间不是彼此独立的:某个变量的值会
制约其他几个变量的值。因此,更新一个变量的时候,要在同一原子操作中更新其他几个。
在一些特殊的时序中,UnsafeCachingFactorizer可能破坏这一不变约束。即使是用原子引用,并且每个set调用都是原子的,我们也无法保证会同时更新lastNumber和lastFactors;当某个线程只修改了一个变量而另一个还没有开始修改时,其他线程将看到Servlet违反了不变约束,这样会形成一个程序漏洞。类似地,也不能保证每个线程都会同时获得两个值:当线程
A尝试获取两个值的时间里,线程
B可能已经修改了它们,线程
A过后会观察到Servlet违反了不变约束。
为了保护状态的一致性,要在单一的原子操作中更新相互关联的状态变量。
|
2.3.1 内部锁
Java提供了强制原子性的内置锁机制:synchronized块。(第3章将介绍锁和同步机制的另一个重要方面:可见性)一个synchronized块有两部分:锁对象的引用,以及这个锁保护的代码块。synchronized方法是对跨越了整个方法体的synchronized块的简短描述,至于synchronized方法的锁,就是该方法所在的对象本身。(静态的synchronized方法从Class对象上获取锁。)
synchronized (lock) {
// 访问或修改被锁保护的共享状态
}
每个Java对象都可以隐式地扮演一个用于同步的锁的角色;这些内置的锁被称作内部锁(intrinsic locks)或监视器锁(monitor locks)。执行线程进入synchronized块之前会自动获得锁;而无论通过正常控制路径退出,还是从块中抛出异常,线程都在放弃对synchronized块的控制时自动释放锁。获得内部锁的唯一途径是:进入这个内部锁保护的同步块或方法。
内部锁在Java中扮演了互斥锁(mutual exclusion lock,也称作mutex)的角色,意味着至多只有一个线程可以拥有锁,当线程
A尝试请求一个被线程
B占有的锁时,线程
A必须等待或者阻塞,直到
B释放它。如果
B永远不释放锁,
A将永远等下去。
同一时间,只能有一个线程可以运行特定锁保护的代码块,因此,由同一个锁保护的synchronized块会各自原子地执行,不会相互干扰。在并发的上下文中,原子性的含义与它在事务性应用中相同——一组语句(statements)作为单独的,不可分割的单元运行。
执行synchronized块的线程,不可能看到会有其他线程能同时执行由同一个锁保护的synchronized块。
同步机制简化了恢复factoring servlet线程安全的工作。清单2.6中,我们将service方法声明为synchronized,所以同一时间内只有一个线程可以进入service方法。现在SynchronizedFactorizer又是线程安全的了;但是这种方法过于极端,它完全禁止多个用户同时使用factoring servlet——这导致糟糕的、令人无法接受的响应性。这个问题——一个性能问题,而非线程安全问题——将在2.5节中深入讨论。
清单2.6 缓存了最新结果的servlet,但响应性令人无法接受(不要这样做)
@ThreadSafe
![](https://i-blog.csdnimg.cn/blog_migrate/08861faa8c8980fb01f30e0eef031b1d.png)
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req,
ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber))
encodeIntoResponse(resp, lastFactors);
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}
2.3.2 重进入(Reentrancy)
当一个线程请求其他线程已经占有的锁时,请求线程将被阻塞。然而内部锁是可重进入的,因此线程在试图获得它自己占有的锁时,请求会成功。重进入意味着所的请求是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的7。重进入的实现是通过为每个锁关联一个请求计数(acquisition count)和一个占有它的线程。当计数为0时,认为锁是未被占有的。线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数置为1。如果同一线程再次请求这个锁,计数将递增;每次占用线程退出同
步块,计数器值将递减。直到计数器达到0时,锁被释放。
重进入方便了锁行为的封装,因此简化了面向对象并发代码的开发。清单2.7中,子类覆写了父类synchronized类型的方法,并调用父类中的方法。如果没有可重入的锁,这段看上去很自然的代码就会产生死锁。因为Widget和LoggingWidget中的doSomething方法都是synchronized类型的,都会在处理前试图获得Widget的锁。倘若内部锁不是可重入的,super.doSomething的调用者就永远无法得到Widget的锁,因为锁已经被占有,导致线程会永久地延迟,等待着一个永远无法获得的锁。重进入帮助我们避免了这种死锁。
清单2.7 如果内部锁不是可重入的,代码将死锁
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}