接上一篇该系列的博客继续说,当在Servlet中添加一个状态变量时,可以通过线程安全的对象来管理Servlet的状态以维护Servlet的线程安全性。但如果想在Servlet中添加更多的状态,那么是否只需添加更多线程安全状态变量就足够了?
假设我们希望提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。如下例(注:该Servlet在没有足够原子性保证的情况下对其最近计算结果进行缓存,这是错误的):
@NotThreadSafe public class UnsafeCachingFactorizer implements Servlet { 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); } } }
然而,这种方法并不正确。尽管这些原子引用本身都是线程安全的,但在UnsafeCachingFactorizer中存在竞态条件,这可能产生错误的结果。在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。只有确保了这个不变性条件不被破坏,上面的Servlet才是正确的。在某些执行时序中,UnsafeCachingFactorizer可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们。这样线程A也会发现不变性条件被破坏了。所以,要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
内置锁
java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字Synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。
synchronized (lock) { //访问或修改由锁保护的共享状态 }
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去。由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。
这种同步机制使得要确保因数分解Servlet的线程安全性变得简单。如下例:
@ThreadSafe public class SynchronizedFactorizer implements Servlet { @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); } } }
上例中,用了关键字synchronized来修饰service方法,因此在同一时刻只有一个线程可以执行service方法。现在的SynchronizedFactorizer是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解Servlet,服务的响应性非常低,无法令人接受。这是一个性能问题,而不是线程安全问题。注:后面我们会解决这个问题 。
重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的精度是“线程”,而不是“调用“。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为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(); } }
上例中,子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于Widget和LoggingWidget中doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁。然而,如果内置锁不是可重入的,那么在调用super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。