线程安全的类是我们很希望达到的特性,那怎么才能判断以及设计一个线程安全的类,是我们需要关注的问题。
(一)线程安全的类的定义
首先我们关注如何定义一个线程安全的类,通俗的说,只要一个类能够在多线程环境中,无论怎样进行使用,都能够按照我们主观认为的方式“正确的”运行,那它就是线程安全的。《Java并发编程实战》中做了如下定义:
“当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的.”
定义想必都能理解,如果一个正确实现的类,在任何操作中都不会违背其功能设想,并且客户代码不需要任何同步措施,那么它就是线程安全的。可是,光有定义是不够的,我们希望的是找到可以分析的、可以理解的性质,或者说一个线程安全类到底应该具有哪些特性,只要保持了哪些特性我们就可以认为它是线程安全的。
(二)线程安全的类的特性
1、特性1——避免竞态条件(Race Condition)
竞态条件指的是在做一些操作的过程中,某个不具有原子性的操作在执行中,由于不恰当的执行顺序导致数据完整性的问题,常见的竞态条件类型有“读取-修改-写入”操作和“先检查后执行”操作。
“读取-修改-写入”操作:
count++;
这句简单的代码是我们经常会使用的,但实际上它包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count。那么,在某些不正确的执行顺序中,两个线程都读取到了当前的count值为5,接着依次执行了写入操作,那么数据就偏差了1,那么这个操作就不会按照我们设计的功能正确的执行了。
“先检查后执行”操作:
if(instance == null)
instance = new Singleton();
这段代码在单线程情况下的单例模式中经常被使用,这就是一个典型的“先检查后执行”的操作,在多线程情况下,当你观测完成之后(执行if语句之后),观测的结果有可能随时会失效(另一个线程很有可能更改了instance),从而导致两个instance实例被创建。而且我们之前也说过DCL(Double Check Lock)的问题(详见我的另一篇博客),那就是一个更深层次的观测结果失效,有可能你观测的结果处于正在执行的阶段,但你却误以为它是完整的。
2、特性2——保证不变性条件
其实不变性条件的破坏也是由于竞态的产生而导致的,但为什么要单独归为一个特性,是因为它在线程安全中真的非常重要,很多多线程问题就是因为不变性条件得不到保持而出现的。许多类都定义了自身的一些不变性条件,比如一个存储范围的两个int值,那要保证存储min的值小于等于max的值。我们来看一个例子:
@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);
}
}
}
这是一个将输入数值进行因数分解的Servlet,我们通过将计算结果缓存起来从而提高性能,lastNumber缓存输入的值,lastFactors缓存因式分解结果。我们使用了能保证原子性的线程安全类来存储缓存值,可是这段代码中就隐含了一个不可变条件:
lastNumber.set(i);
lastFactors.set(factors);
这两句中我们是不是默认lastNumber对应的就是lastFactors中的结果?想必大家觉得这是理所当然的,可是在多线程情况下,线程A写入了lastNumber的值,这时切换到线程B,它写入了这两个值,再切换到线程A,它仍然认为lastNumber是自己之前写入的值,但其实这时候,不可变条件已经被破坏了。当下次进行计算时,由于错误的缓存,很有可能得到一个错误的结果!
3、特性3——保证共享对象的可见性
讲了前两点特性,我们已经意识到了线程安全类最重要的问题就是对象的共享,而1、2两点特性针对的主要是对象修改的问题(多个线程修改同一个对象,出现数据完整性问题),而我们这里的特性3针对的是当一个线程修改了对象,另一个线程读取时出现的可见性问题,让我们来看书上给出的例子:
public class NoVisibility{
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while(!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args){
new RenderThread().start();
number = 42;
ready = true;
}
}
我们看了这段代码觉得很容易理解,主线程先启动读者线程,读者线程一直循环直到ready为true,输出number,而主线程写入值并且将ready设为true。但事实上很有可能输出的值为0,而不是我们预想的42,因为读线程很有可能看到了ready为true,但却看不到number为42,这个原因称之为“重排序”。
虽然这是一个让人非常摸不着头脑的现象,但我们可以用一个简单的方法避免这个问题:只要存在共享数据,就使用正确的同步以保证其可见性。
4、特性4——保证对象安全的发布
之前的三点特性针对的都是在类内部的操作处理,虽然是处于共享状态的对象,但共享出去的对象的操作都是类内部进行定义了的,我们保证内部的方法进行了正确的同步就能保证线程安全性。但如果我们的对象能被外部代码所使用,那么如何保证安全性呢?
class UnsafeStates{
private String[] states = new String[] { "AK", "AL" };
public String[] getStates(){ return states; }
}
我们都知道引用在Java中的地位,当你返回引用时,你一定要注意,你很有可能做了一个不安全的发布!当你的引用对象被返回(上面代码中的states),得到此引用的线程很有可能进行一些操作,而这个操作是不可控的,那么竞态条件的风险就始终存在!那这个类就不是线程安全的类了。如果要做到线程安全,那么发布的对象必须是安全的,受保护的。