第二章——线程安全性

2020-10-10  本文已影响0人  你可记得叫安可

2.1 什么是线程安全性

无状态对象一定是线程安全的

public class StatelessFactorizer implements Servlet {
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

上面的 StatelessFactorizer 是无状态的,它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。因此无状态对象是线程安全的。

2.2 原子性

2.2.1 竞态条件(Race condition)

由于不恰当的执行时序而出现不正确的结果,就叫竞态条件。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,正确的结果要取决于运气。

竞态条件非常容易与另一个术语“数据竞争(Data Race)” 相混淆。数据竞争是指,如果在访问共享的非 final 类型的域是没有采用同步来进行协同,那么就会出现数据竞争

最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。

public class LazyInitRace {
    private ExpensiveObject instance = null;
    
    public ExpensiveObject getInstance() {
        if (instance == null) {  // 可能多个线程同时判断
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

另一种常见的竞态条件类型就是“读取 - 修改 - 写入”操作,其结果依赖于之前的状态。

public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;
    
    public long getCount() {
        return count;
    }
    
    public void service(ServletRequest req, ServletRespose resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count; // 这里其实是3步操作
        encodeIntoResponse(resp, factors);
    }
}
2.2.3 复合操作

我们将“先检查后执行”以及“读取 - 修改 - 写入” 等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。对于 UnsafeCountingFactorizer 我们可以用线程安全对象(例如 AtomicLong) 来管理类的状态。

public class CountingFactorizer implements Servlet {
    private final AtomicLong count = new AtomicLong(0); // 替换为线程安全类
    
    public long getCount() {
        return count.get();
    }
    
    public void service(ServletRequest req, ServletRespose resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
}

使用 AtomicLong 来代替 long 类型的计数器,并通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全性。当在无状态类中添加一个状态时,如果说状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。但是,当状态变量的数量由一个变为多个时,并不会向状态变量数量由零个变为一个那样简单。

2.3 加锁机制

现在就来回答上面的问题:当类有多个状态时,是否只需添加多个线程安全状态变量就足够了?

public class UnsafeCachingFactorizer implements Servlet {
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>(); // 用 AtomicReference 保存上一次的输入
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>(); // 用 AtomicReference 保存上一次的结果
    
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromResult(req);
        if (i.equals(lastNumber.get())) {
            encodeIntoResponse(resp, lastFactors.get());
        } else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors); // 由于 lastNumber 和 lastFactors 是由关联性的,因此这两组 set 操作并不是原子的
            encodeIntoResponse(resp, factors);
        }
    }
}

上面的例子就有两个状态域,虽然都用线程安全类存储了状态,但是由于两个状态域之间是由关联性的,因此它们一起又形成了一组复合操作,这组操作并不是线程安全的。

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

2.3.1 内置锁

Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。每个 Java 对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock) 或 监视器锁(Monitor Lock)。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。Java 的内置锁相当于是一种互斥锁,这意味着最多只有一个线程能持有这种锁。
我们可以使用内置锁机制来使 UnsafeCachingFactorizer 类变成线程安全的:

public class SynchronizedFactorizer implements Servlet {
    private BigInteger lastNumber;
    private BigInteger[] lastFactors;

    // 这里添加了 synchronized 关键字,使同一时刻只有一个线程可以执行 service 方法
    public synchronized void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromResult(req);
        if (i.equals(lastNumber)) {
            encodeIntoResponse(resp, lastFactors);
        } else {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
    }
}

上面通过 synchronzed 关键字来保证了线程安全,但是却过于极端,因此为多个客户端无法同时使用因数分解 Servlet,服务的响应性非常低。但这是一个性能问题,而不是线程安全问题。同时,注意到使用 synchronized 关键字后,状态域 lastNumberlastFactors 也都可以不再使用线程安全类型了。这个涉及到 可见性 问题。

2.3.2 重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁时可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。重入意味着获取锁的操作粒度是“线程”,而不是“调用”。

这与 pthread(POSIX 线程)互斥体的默认加锁行为不同,pthread 互斥体的获取操作是以“调用”为粒度的。

重入的一种实现是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为 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(); // 重入了
    }
}

如果内置锁不是可重入的,那么上面代码将进入死锁。

2.5 性能与活跃性

public class CachedFactorizer implements Servlet {
    private BigInteger lastNumber;
    private BigInteger[] lastFactors;
    private long hits;
    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;
        // 同步代码块1:负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列
        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(resp, factors);
    }
}

我们前面的因数分解 service 使用 synchronized 关键字来达成对整个方法的同步,但是这样造成的问题就是多个客户端无法同时使用因数分解 service,造成性能下降。我们可以通过上面的代码改造,将其业务分为两个独立的同步代码块,每个同步代码块都只包含一小段代码。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需要同步。
上面重构代码的一个很大的优点就在于,在执行时间较长的因数分解函数 factor 时,并不在同步代码中,因此能够支持多个客户端同时访问。这样就实现了在简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。

上一篇下一篇

猜你喜欢

热点阅读