线程安全性定义

在线程安全性的定义中,最核心的概念就是正确性
正确性的含义是,某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象操作的结果。在对正确性给出一个较为清晰的定义后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终能表现出正确的行为,那么就称这个类是线程安全的。

  • 示例:一个无状态的Servlet
@ThreadSafe
public class StatelessFactorizer implements Servlet {
	public void service(ServletRequest req, ServletResponse resp) {
		BigInteger i = extractFromRequest(req);
		BigInteger[] factors = factor(i);
		encodeIntoResponse(resp, factors);
	}
}

与大多数Servlet相同,StatelessFactorizer是无状态的,它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,且只能由正在执行的线程访问。访问StatelessFactorizer的线程不会影响另一个访问同一个StatelessFactorizer线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
无状态对象一定是线程安全的。
大多数Servle都是无状态的,只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。
###原子性
假设希望增加一个“命中计数器”(Hit Count)来统计所处理的请求数量。一种直观的方法是在Servlet中增加一个long类型的域,并且每处理一个请求就将这个值加1.

@NotThreadSafe
public class StatelessFactorizer 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);
	}
}

以上并非线程安全的,尽管它在单线程环境中可以正确运行。`++count并非为一种原子操作,包含了三个独立的操作:读取count的值,将值+1,然后将计算结果写入count,是一个“读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字———竞态条件

竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。

示例:延迟初始化中的竞态条件

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象初始化操作推迟到实际被使用时进行,同时要确保仅被初始化一次。

@NotThreadSafe
public class LazyInitRace {
	private ExpensiveObject instance = null;
	public ExpensiveObject getInstance() {
		if (instance == null)
			instance = new ExpensiveObject();
		return instance;
	}
}

虽然延迟初始化可以减少创建实例的高开销,但是包含了一个竞态条件,可能会破坏这个类的正确性。

复合操作

要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而保证其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。
使用一个现有的线程安全类来修复这个问题:

@ThreadSafe
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.incrementAndGet();
		encodeIntoResponse(resp, factors);
	}
}

java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。
在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。

加锁机制

假设我们想提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无需重新计算。要实现该缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果。
使用相似的AtomicReference来管理最近执行因数分解的数值以及分解结果:

@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);
		}
	}
}

当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某个变量时,需要在同一个原子操作中对其他变量同时进行更新。尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors。同时,我们也无法保证同时获取这两个值:在线程A获取这两个值的过程中,线程B可能会修改了它们。
要保持状态的一致性,就需要在单个原子操作中更新所有的状态变量

内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作锁的对象引用,另一个座位由这个锁保护的代码块。以关键字Synchronized来修饰的方法是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

synchronized (lock) {
//访问或修改由锁保护的共享状态
}

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制器路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这个锁。

@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
	@GuardedBy("this") private BigInteger lastNumber;
	@GuardedBy("this") private BigInteger[] lastFactors;
	public synchrnized 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);
		}
	}
}

能够正确缓存,但是并发性非常糟糕。

重入

当某个线程请求一个由其他线程持有的锁,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。重入意味着获取锁的操作粒度是“线程”,而不是“调用”。(这与pthread(POSIX)互斥体的默认加锁行为不同,pthread互斥体的获取操作是以“调用“为粒度的)。

用锁来保护状态

每个共享的或可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
如果不加区别地滥用synchronized,可能导致程序中国出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如Vector,那么并不足以确保Vector上复合操作都是原子的:

if (!vector.contains(element))
	vector.add(element);

将每个方法都作为同步方法还可能导致活跃性问题(Liveness)或性能问题(Performance)。

活跃性问题

由于service是一个sunchronized方法,因此每次只能有一个线程可以执行,这就背离了Servlet框架的初衷,即Servlet需要能同时处理多个请求,这在负载很高的情况下将给用户带来很糟的体验。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

@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 cachHits;
	public synchronized long getHits() { return hits;}
	public synchronized double getCacheHitRatio() {
		return (double) cacheHits / (double) hits;
	}
	public void service(ServletRequest req, ServletResponse res) {
		BigInteger i = extractFromRequest(req);
		BigIntege[] factors = null;
		synchronized (this) {
			++hits;
			if(i.equlas(lastNumber)) {
				++cacheHits;
				factors = lastFactors.clone();
			}
		}
		if(factors == null) {
			factors = factor(i);
			synchronized (this) { //在访问状态变量或复合操作前要持有锁,在执行时间较长的因数分解运算前要释放锁
				lastNumber = i;
				lastFactors = factors.clone();
			}
		}
		encodeIntoResponse(resp, factors);
	}
}

不使用AtomicLong类型的命中计数器,而是Long。在单个变量上,原子变量是很有用,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,在性能或安全性上不会带来任何好处。
重构之后的CachedFactorizer实现了简单性(对整个方法进行同步)和并发性(对尽可能短的代码路径进行同步)之间的平衡。
当执行时间较长的计算或者可能会无法快速完成的操作时,例如网络IO或控制台IO,一定不要持有锁。

Q.E.D.


南七技校练习时长四年的练习生