您的位置:首页 > 编程语言 > Java开发

Java Concurrency in Practice ---线程安全性

2016-12-01 18:08 274 查看

1. 什么是线程安全性

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。再解释一下就是:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

无状态对象一定是线程安全的,而大多数 Servlet 都是无状态的,从而极大地降低了在实现 Servlet 线程安全时的复杂性。只有当 Servlet 在处理请求时需要保存一些信息,线程安全才会成为一个问题。

2. 原子性

在多线程中进行
count++
的操作是很不安全的。这看上去仅仅是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作来执行。

实际上,它可以分解为三个独立的操作:读取 count 的值,将其值加 1,再将结果写入 count。即:
读取-->修改-->写入
的操作序列,使得结果状态依赖前面的状态。

因此,如果在两个线程没有同步的情况下,发生了如下状态:



也就是如果多个线程的操作交替执行,那么会导致这两个线程读取到的值相同的,同时执行加 1 操作,得到的结果也就是相同的。而这不是我们希望的结果。

原子性也可以被解释为不可分割的操作。

2.1 竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。也就是说正确的结果需要靠运气。因此,竞态条件并不总是会产生错误,而是在某种不恰当的执行时序情况下会。最常见的竞态条件是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步动作。

举个例子:延迟初始化中的竞态条件

延迟初始化的目的是将对象的初始化操作推迟到实际被使用是才进行,同时要确保只被初始化一次。

@NotThreadSafe
public class LazyInitRace{
private ExpensiveObject instance = null;

public ExpensiveOvject getInstance(){
if (instance == null)
instance = new ExpensiveObject();
return instance;
}
}


上面的 LazyInitRace 类说明了这个问题。首先 getInstance 方法将判断 ExpensiveObject 是否已经被初始化过,如果有则直接返回实例,反之创建一个 ExpensiveObject 实例。

现在试想,如果线程 A 和线程 B 同时执行 getInstance 方法,A 先看到 instance 为空,因此它创建一个 ExpensiveObject 实例。B 此时也需要判断 instance 是否为空,但是判断的结果却取决于不可测的时序。比如线程的调度方式,A 花费多长的时间来初始化 ExpensiveObject 并设置 instance 等等。所以 A 和 B 调用完毕可能会产生不同的结果。而这种现象就是竞态条件。

2.2 复合操作

原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式(不可分割)执行的操作。

因此,复合操作是指,包含了一组以原子方式执行的操作以确保线程安全性。加锁机制是 Java 中用于确保原子性的内置机制。此外,在
java.util.concurrent.atomic
包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。

3. 加锁机制

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是安全的。但是当状态的数量由一变为多个时,并不会像状态变量数量由零个变为一个那样简单。

Java 提供了一种内置的锁机制来支持原子性:同步代码块。同步代码块包括两部分:一个是作为锁的对象引用,一个作为由这个锁保护的代码块。

首先,举一个无状态变量的例子:

这个例子是一个简单的因数分解 Servlet。Servlet 从请求中能提取出数值,执行因数分解,然后将结果封装到该 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);
}
}


上面的 StatelessFactorizer 是无状态的:它即不包括任何域,也不包括任何对其他类中域的引用。访问 StatelessFactorizer 的线程不会影响到另一个访问同一个 StatelessFactorizer 的线程的计算结果。无状态对象一定是线程安全的。

现在,我们在无状态变量中增加一个状态。假设增加了一个计数器来统计所处理的请求数量。

于是,就有了状态变量从 0 到 1 的例子:

这个例子使用了 AtomicLong 类型的变量来统计已处理请求的数量。

@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);

public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}


上面的操作使用了
java.util.concurrent.atomic
包中的
AtomicLong
类来替代 Long 类型的计数器,能够确保所有对计数器的访问操作都是原子的。

上面这个例子得出的结论就是,当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是安全的。

接着,如果状态变量从 1 个变为多个呢?

所以,举一个状态变量从 1 到 多个 的例子:

这个例子是假设我们需要将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。可以看出来,要实现该缓存策略,需要保存两个状态,一是最近执行因数分解的数值,二是分解结果。

@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
private BigInteger   lastNumber;
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 类的对象。假设线程 A 现在持有 service 方法的锁,当线程 B 尝试获取这个锁时,线程 B 必须等待或者阻塞,直到线程 A 释放该锁。换句话说,最多只有一个线程能够执行 service 方法。这样就避免了竞态条件,维护了线程安全性。

上面的这种方法虽然线程安全,但是却显得非常极端。因为多个客户端无法同时使用因数分解 Servlet,服务响应性非常低。

最后,我们优化一下这个方法:

由于 service 方法是一个 synchronized 方法,这就意味着每次只有一个线程可以执行。为了避免这样的低效率,我们不将 service 方法设置成 synchronized 方法,而是选择在 service 方法中加入两个同步代码块,来躲避有竞态条件的两个地方。

第一个代码块负责判断是否需要创建对象,能不能重复用上一次的结果。

第二个代码块负责更新缓存数值和分解结果。

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


在上面的操作中我们在统计请求数量是使用的是 Long 类型的变量而非 AtomicLong 类型的变量,原因是我们已经使用了同步代码块来构造原子操作,如果此时再使用 AtomicLong 类型的变量来构造原子操作,这样就同时使用了两种同步机制,在一定程度上就会造成混乱。

同时,需要注意到我们在第一各同步代码块中更新了 hits 和 cacheHits 两个变量,那么这两个变量是属于可共享状态的一部分,因此在所有访问它们的位置上都要使用同步。

4. 用锁来保护状态

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

由于锁能够使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。

一种常见的加锁约定为:将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。

Vector 和其他的同步集合类都使用了这种模式。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: