第二章 线程安全性
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。
Java中的主要同步机制是关键字synchronized、volatile、显示锁以及原子变量。
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。
有以下三种方式可以修复这个问题:
- 不在线程之间共享该状态变量
- 将状态变量修改为不可变的变量
- 在访问状态变量时使用同步
2.1 什么是线程安全性
线程安全定义:某个类的行为与其规范完全一致
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替之行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
无状态对象一定是线程安全的
示例:一个无状态的Servlet
程序清单 2-1
@ThreadSafe
public class StatelessFactorizer implements Servlet {
@Override
public void service(ServletRequest req, ServletResponse resp) throws ServletException, IOException {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
2.2 原子性
如果在无状态的对象中增加一个状态时,就会导致线程不安全的问题发生。
假设需要统计请求数量
程序清单 2-2
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount(){
return count;
}
@Override
public void service(ServletRequest req, ServletResponse resp) throws ServletException, IOException {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count = getCount();
++count;
encodeIntoResponse(resp, factors);
}
}
该方法包含了三个操作“读取 - 修改 - 写入”,并且其结果状态依赖于之前的状态
在并发编程中,这种由于不恰当的执行时序而出现不正确的结果叫竞态条件(RaceCondition)
2.2.1 竞态条件
当正确的结果取决于运气时,就属于竞态条件。
最常见的类型就是“先检查后执行”
2.2.2 示例:延迟初始化中的竞态条件
延迟初始化:将对象的初始化操作推迟到实际使用时才进行,同时要确保只被初始化一次。
程序清单 2-3
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance(){
if (instance == null)
instance = new ExpensiveObject();
return instance;
}
}
2.2.3 复合操作
要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程。
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作来说,这个操作是一个原子操作。
使用AtominLong来保证原子操作
程序清单 2-4
@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicInteger count = new AtomicInteger(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);
}
}
在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。
2.3 加锁机制
假设我们希望提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。
程序清单 2-5
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
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中缓存的数值。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3.1 内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)
synchronized(lock){
//访问或修改由锁保护的共享状态
}
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。
Java的内置锁相当于一种互斥体(或互斥锁),最多只有一个线程能持有这种锁。
由于每次只能有一个线程执行内置锁保护的代码块,所以可以用这种机制确保因数分解Servlet的线程安全性。然而,这种方法过于极端,服务的响应性非常低。
程序清单2-6
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);
}
}
}
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
重入意味着获取锁的操作的粒度是“线程”,而不是“调用”。
具体实现:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。
程序清单 2-7
public class Xttblog extends SuperXttblog {
public static void main(String[] args) {
Xttblog child = new Xttblog();
child.doSomething();
}
public synchronized void doSomething() {
System.out.println("child.doSomething()" + Thread.currentThread().getName());
doAnotherThing(); // 调用自己类中其他的synchronized方法
}
private synchronized void doAnotherThing() {
super.doSomething(); // 调用父类的synchronized方法
System.out.println("child.doAnotherThing()" + Thread.currentThread().getName());
}
}
class SuperXttblog {
public synchronized void doSomething() {
System.out.println("father.doSomething()" + Thread.currentThread().getName());
}
}
2.4 用锁来保护状态
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。
锁保护:对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要同一个锁来保护
2.5 活跃性与性能
程序清单2-6中给出的代码,每次只有一个线程可以执行。背离了Servlet框架的初衷,需要同时处理多个请求。如果Servlet在对某个大数值进行因数分解时需要很长的执行时间,那么其他客户端就一直等待,知道Servlet处理完当前的请求,才能开始另一个新的因数分解运算。
应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
程序清单2-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(req, resp);
}
}
当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。