Thread Safety
要做到线程安全,核心是控制对状态(state)的访问。
(对象的)状态:通常是指它那些共享的(shared)、可变的(not final)的成员变量。
我们知道,线程之间是共享内存的(成员变量都分配在内存中)。所以它们有能力同时访问同一个 state ,这将破坏线程安全。我们需要某种机制进行访问的同步。
相比之下,由于线程之间各自持有堆栈,这些堆栈不是共享的。因此,当不同的线程访问同一个函数的局部变量时(局部变量都分配在堆栈中),是线程安全的。
2.1 What is thread safety?
来看书中的一个例子:
// 一个大整数提取因子,并返回
// 只访问了局部变量,所以是线程安全的
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
这是一个无状态(stateless)的例子:
不同的线程访问 service(req, resp) 时,在各自堆栈中生成一个 i 和 factors。正如之前所说,这些堆栈是相互隔离的(线程A的修改不会影响到线程B),是线程安全的。
也就是说,无状态的对象总是线程安全的。
2.2 Atomicity(原子性)
原子性的必要性 (read-modify-write)
再来看一个相近的例子:
// 一个大整数提取因子,并返回
// 访问了成员变量 count,存在隐患
@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);
}
}
在该例子中,仅仅是对成员变量 count进行修改,便不再是线程安全的。
原因在于,count++并非原子操作。
事实上,count ++ 可以分解为三条指令:
1. 读取到寄存器(cache): MOV cache, count;
2. 在寄存器中计算: ADD cache, 1;
3. 写回内存(count): MOV count,cache;
这3步应该保持原子性,不可被打断。一旦被线程切换打断,将会得到不可预测的错误答案。
这种读取-修改-写回的操作经常出现,书中称之为 read-modify-write 。
原子性的保持
我们已经知道保持原子性的必要性,但是我们应该怎么做呢?
好在 java.util.concurrent.* 中,为我们提供了解决方案。
从名称上看,这是一个关于并发的包,里面封装了一些线程安全的类。
让我们用线程安全的 AtomicLong 来替换原来的 long:
// 一个大整数提取因子,并返回
// 访问了线程安全的成员变量 count,保持了原子性
@ThreadSafe
public class CountingFactorizer implements Servlet {
//private long count = 0;
private final AtomicLong count = new AtomicLong(0);
//public long getCount() { return count; }
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
//++count;
count.incrementAndGet(); // 增加的计数变量
encodeIntoResponse(resp, factors);
}
}
感想
好奇地点开了 AtomicLong ,看了一下实现,有两点值得注意的:
1.它使用了 volatile 关键字:(对它比较陌生,有空可以了解一下)
private volatile long value;
2.从头至尾没有发现 synchronized 关键字。
那我就疑惑了,它是怎么保持同步的呢?
后来搜到了这篇文章:http://www.cnblogs.com/Mainz/p/3556430.html
里面提到了“CAS(Compare and Swap)无锁算法”,听上去好像不用锁的,先mark一下。
AtomicLong 确实调用了一个 compareAndSwapLong(),不过是 native 方法,暂时看不到源码。
2.3 Locking(上锁)
注意到BigInteger[] factors = factor(i);
这行,其中的factor()
可能是一个比较耗时的操作。很自然的,我们想到用缓存来优化,保存上一次的结果lastNumber
&&lastFactors
。于是,我们写出了如下代码:
@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())) // 1. 从缓存取 lastNumber
encodeIntoResponse(resp, lastFactors.get() ); // 2. 从缓存取 lastFactors
else {
BigInteger[] factors = factor(i);
lastNumber.set(i); // 3. 缓存 lastNumber
lastFactors.set(factors); // 4. 缓存 lastFactors
encodeIntoResponse(resp, factors);
}
}
}
To preserve state consistency, update related state variables in a single atomic operation.
很遗憾,尽管lastNumber
&&lastFactors
都是原子类,但是组合在一起却保证不了原子性。注意到它们须满足不变式(invariant):lastFactors = factor(lastNumber)
,这就要求对这两个变量的读和写必须各自是原子的。也就是说,步骤 1、2 是原子的(一起读),不允许切换线程。步骤 3、4 同理,否则就会破坏不变式。 那么如何解决呢,请听下文分解。
2.3.1. Intrinsic Locks (固有锁)
java 在语言层面上提供了锁机制:synchronized
关键字。该关键字可以修饰一个代码块(the synchronized block),以保证该 block 的原子性:
synchronized (lock) { // 这里的 lock 可以是任意的 Object
// synchronized block
// Access or modify shared state guarded by lock
}
顺便一提,synchronized
是可重入锁(Reentrant Lock),这点后面会详细解释。
一个小的变种是,synchronized
可以修饰函数,此时函数体即作为代码块。值得注意的是,此时被上锁的对象默认为函数调用者,即我们常见的synchronized (this){}
中的this;静态函数则对应class。
答案已经浮出水面了,我们加上 synchronized ,即可解决之前的同步问题:
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
// use synchronized
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.5. Liveness and Performance(性能)
回顾上边的例子,尽管线程安全了,但是性能却变得很差。无脑使用同步块,导致不同的 client 无法同时访问这个service()。每个线程排队等候,完全不是并发了!!!
注意到不是所有的步骤都需要同步,我们做了如下调整:
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronized (this) {
if (i.equals(lastNumber))
factors = lastFactors.clone(); // 1. 深拷贝, 防止调用 encodeIntoResponse 时, 数据已变
}
if (factors == null) {
factors = factor(i); // 2. factor 是耗时操作, 不要包在 synchronized 里
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
注意点
- 1处的深拷贝有点
ThreadLocal
的味道,用空间换时间。具体而言,多个线程访问 lastFactors 的时候,各自拷贝了一个备份,那么调用encodeIntoResponse(resp, factors);
的时候就不需要同步了。 - 正如文中所说,耗时操作不要放在同步块里,否则很影响性能:
Avoid holding locks during lengthy computations or operations at risk of not completing quickly such as network or console I/O.
总结
在保证线程安全的情况下,同时得考虑其性能。大的同步块想一想能不能分解成几个小的。在代码复杂性和性能上寻求一个平衡点。