Java并发编程实践
[TOC]
第3章 共享对象
3.1 可见性
在一个单线程化的环境里,如果向一个变量先写入值,然后在没有写干涉的情况下读取这个变量,你希望能得到相同的返回值,这看起来是很自然的。但是当读和写发生在不同的线程中时,情况却根本不是这样,这种说法在刚开始听到时令人难以接受。通常,不能保证读线程及时地读取其他线程写入的值,甚至可以说根本不可能。 为了确保跨线程写入的内存可见性,你必须使用同步机制。
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 ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入number的值,这种现象被称为“重排序(Reordering)”。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。
3.1.1 过期数据
@NotThreadSafe
public class MutableInteger {
private int value;
public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
Mutablelnteger并不是线程安全的,因为get和set都访问了value域,却没有进行同步。在众多危害中,以下情况对过期数据尤为敏感:如果一个线程调用了set,而另一个线程此时正在调用get,它可能就看不到更新的数据了。我们可以通过同步化getter和setter,使Mutablelnteger成为线程安全的,就像在清单3.3的Synchronizedlnteger所表现的那样。仅仅是同步的setter是不够的:调用get的线程仍然能够看见过期值。
3.1.2 非原子的64位操作
long
和double
占用的字节数都是8,也就是64bits。在32
位操作系统上对64
位的数据的读写要分两步完成,每一步取32位数据。这样对double和long的赋值操作就会有问题,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位,这样将导致获取的64位数据是失效的数据。因此需要使用volatile关键字来防止此类现象(32
位操作系统对long
和double
的赋值操作不是原子性)。
3.1.3 锁和可见性
两个线程A和B,当B执行到与A相同的锁监视的同步块时,A在同步块之中或之前所做的每件事,对B都是可见的。如果没有同步,就没有这样的保证,当访问一个共享的可变变量时,为什么要求所有线程由同一个锁进行同步,我们现在可以给出另一个理由——为了保证一个线程对数值进行的写入,其他线程也都可见。另一方面,如果一个线程在没有恰当地使用锁的情况下读取了变量,那么这个变量很可能是一个过期的数据。
锁不仅仅是关于同步与互斥的,也是关于内存可见的。为了保证所有线程都能够看到共享的、可变变量的最新值,读取和写入线程必须使用公共的锁进行同步。
3.1.4 Volatile变量
volatile变量,它确保对一个变量的更新以可预见的方式告知其他的线程。当一个域声明为volatile类型后,编译器与运行时会监视这个变量:它是共享的,而且对它的操作不会与其他的内存操作一起被重排序。volatile变量不会缓存在寄存器或者缓存在对其他处理器隐藏的地方。所以,读一个volatile类型的变量时,总会返回由某一线程所写入的最新值。
只有当volatile变量能够简化实现和同步策略的验证时,才使用它们。当验证正确性必须推断可见性问题时,应该避免使用volatile变量。正确使用volatile变量的方式包括:用于确保它们所引用的对象状态的可见性,或者用于标识重要的生命周期事件(比如初始化或关闭)的发生。
volatile boolean asleep;
...
while(!asleep){
countSumeSheep();
}
尽管volatile也可以用来标示其他类型的状态信息,但是决定这样做之前请格外小心。比如,volatile的语义不足以使自增操作(count++
)原子化,除非你能保证只有一个线程对变量执行写操作。加锁可以保证可见性与原子性;volatile变量只能保证可见性。
只有满足了下面所有的标准后,你才能使用volatile
变量:
写入变量时并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;
变量不需要与其他的状态变量共同参与不变约束:
访问变量时不需要加锁。
3.2 发布和逸出
发布一个对象
的意思是使它能够被当前范围之外的代码所使用。
发布对象举例
-
将一个指向该对象的引用保存到其他代码可以访问的地方
public class Person(){ private String name; private int age; } public class Record{ Person p; }
-
或者在某一个非私有的方法中返回该引用
public class Person(){ private String name; private int age; public Person get(){ return new Person(); } }
-
或者将引用传递到其他类的方法中
public class Person(){ private String name; private int age; } public class Record{ private void getPersonMessage(Person p){ ..... } }
如果发布对象时,它还没有完成构造,同样危及线程安全。一个对象在尚未准备好时就将它发布,这种情况称作逸出
。
常见的对象逸出
-
内部的可变状态逸出
class UnsafeStates{ private String[] states = {"AK","AL",...}; public String[] getStates(){ return states; } }
数组
states
是私有的变量,但是以public公有的方式发布出去,导致states已经逸出了它所在的作用域,任何调用者都能修改这个数组的内容。 -
隐式地使this引用逸出
public class ThisEscape{ private int count; public ThisEscape(EventSource source){ source.registerListener( new EventListener(){ public void onEvent(Event e){ dosomething(e); } }); //在这里count初始化为1 count = 1; } void doSomething(Event e) { ... } }
这将导致this逸出,所谓逸出,就是在不该发布的时候发布了一个引用。在这个例子里面,当我们实例化ThisEscape对象时,会调用source的registerListener方法,这时便启动了一个线程,而且这个线程持有了ThisEscape对象(调用了对象的doSomething方法),但此时ThisEscape对象却没有实例化完成(还没有返回一个引用),所以我们说,此时造成了一个this引用逸出,即还没有完成的实例化ThisEscape对象的动作,却已经暴露了对象的引用。其他线程访问还没有构造好的对象,可能会造成意料不到的问题。
3.2.1 安全构建的实践
ThisEscape演示了一种重要的逸出特例this引用在构造时逸出。发布的内部EventListener实例是一个封装的ThisEscape中的实例。但是对象只有通过构造函数返回后,才处于可预言的、稳定的状态,所以从构造函数内部发布的对象,只是一个未完成构造的对象。甚至即使是在构造函数的最后一行发布的引用也是如此
。如果this引用在构造过程中逸出,这样的对象被认为是没有正确构建的
。
使用工厂方法防止this引用在构造期间逸出
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
//启动线程操作移到工厂方法中
source.registerListener(safe.listener);
return safe;
}
}
如果想在构造方法中注册监听器,可以使用一个私有的
构造函数和一个公共的工厂方法,这样可以避免this引用在构造期间的逸出。
3.3 线程封闭
访问共享的、可变的数据要求使用同步。一个可以避免同步的方式就是不共享数据。如果数据仅在单线程中被访问,就不需要任何同步。线程封闭技术是实现线程安全的最简单的方式之一。当对象封闭在一个线程中时,这种做法会自动成为线程安全的。
线程封闭技术的另一种常见应用是JDBC的Connection对象。JDBC规范并不要求Connection对象必须是线裎安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求(例如Servlet请求或EJB调用等)都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中。
在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal
类,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。
3.3.1 Ad-hoc线程封闭
Ad-hoc(点对点)线程封闭是指,维护线程封性的职责完全由程序实现来承担。
Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。
在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行读取一修改一写入
的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。
由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术(例如,栈封闭或ThreadLocal类)。
3.3.2 栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal混淆)比Ad-hoc线程封闭更易于维护,也更加健壮。
对于基本类型的局部变量,例如程序清单3-9中loadTheArk方法的numPairs,无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此Java语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。
程序清单3-9基本类型的局部变量与引用变量的线程封闭性:
public int loadTheArk(Collection candidates) {
SortedSet animals;
int numPairs = 0;
Animal candidate = null;
//animals被封闭在方法中,不要使它们逸出!
animals = new TreeSet(new SpeciesGenderComparator())
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a)) {
candidate = a;
} else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。在loadTheArk中实例化一个TreeSet对象,并将指向该对象的一个引用保存到animals中。此时,只有一个引用指向集合animals,这个引用被封闭在局部变量中,因此也被封闭在执行线程中。然而,如果发布了对集合animals(或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对彖animals的逸出。
如果在线程内部( Within-Thread)上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确地说明这些需求,那么后续的维护人员很容易错误地使对象逸出。
3.3.3 ThreadLocal类
维持线程封闭性的一种更规范的方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联来,ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,所以当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接,如程序清单所示:
private ThreadLocal connectionHolder
= new ThreadLocal() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
};
};
public Connection getConnection() {
return connectionHolder.get();
}
当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。例如,在Java 5.0之前,Integer.toString0方法使用ThreadLocal对象来保存一个12字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。当某个线程初次调用ThreadLocal.get方法时,就会调用initiaIValue来获取初始值。从概
念上看,你可以将ThreadLocal
在实现应用程序框架时大量使用了ThreadLocal。例如,在EJB调用期间,J2EE容器需要将一个事务上下文( Transaction Context)与某个执行中的线程关联起来。通过将事务上下文保存在静态的ThreadLocal对象中,可以很容易地实现这个功能:当框架代码需要判断当前运行的是哪一个事务时,只需从这个ThreadLocal对象中读取事务上下文。这种机制很方便,因为它避免了在调用每个方法时都要传递执行上下文信息,然而这也将使用该机制的代码与框架耦合在一起。
开发人员经常滥用ThreadLocal,例如将所有全局变量都作为ThreadLocal对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。
ThreadLocal其实就是一个工具类,用来操作线程局部变量,ThreadLocal 实例通常是类中的 private static 字段。ThreadLocal适用于资源共享但不需要维护状态的情况,也就是一个线程对资源的修改,不影响另一个线程的运行;这种设计是空间换时间,synchronized顺序执行是时间换取空间。
ThreadLocal部分源码:
public class ThreadLocal {
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
//静态内部类
static class ThreadLocalMap {
/**
* The initial capacity -- MUST be a power of two.
*/
private static final int INITIAL_CAPACITY = 16;
/**
* The table, resized as necessary.
* table.length MUST always be a power of two.
*/
private Entry[] table;
/**
* The number of entries in the table.
*/
private int size = 0;
/**
* The next size value at which to resize.
*/
private int threshold; // Default to 0
}
}
3.4 不变性
如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。
不可变对象一定是线裎安全的。
不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。在程序设计中,一个最困难的地方就是判断复杂对象的可能状态。然而,判断不可变对象的状态却很简单。同样,不可变对象也更加安全。如果将一个可变对象传递给不可信的代码,或者将该对象发布到不可信代码可以访问它的地方,那么就很危险——不可信代码会改变它们的状态,更糟的是,在代码中将保留一个对该对象的引用并稍后在其他线程中修改对象的状态。另一面,
不可变对象不会像这样被恶意代码或者有问题的代码破坏,因此可以安全地共享和发布这些对象,而无须创建保护性的副本。
虽然在Java语言规范和Java内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能以修改。
- 对象的所有域都是final类型。
- 对象是正确创建的(在对象的创建期间,this引用没有逸出)。
3.4.1 Final域
在不可变对象的内部仍可以使用可变对象来管理它们的状态,如程序清单3-11中的ThreeStooges所示。尽管保存姓名的Set对象是可变的,但从ThreeStooges的设计中可以看到,在Set对象构造完成后无法对其进行修改。‘stooges是一个final类型的引用变量,因此所有的对象状态都通过一个final域来访问。最后一个要求是“正确地构造对象”,这个要求很容易满足,因为构造函数能使该引用由除了构造函数及其调用者之外的代码来访问。
程序清单3-11 在可变对象基础上构建的不可变类:
public final class ThreeStooges {
private final Set stooges = new HashSet();
public ThreeStooges() {
stooges.add("Moe" );
stooges.add("Larry" );
stooges.add("Curly" );
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}
即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。仅包含一个或两个可变状态的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为final类型,也相当于告诉维护人员这些域是不会变化的。
3.4.2 Volatile类型发布不可变对象
因式分解Servlet将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如程序清单3-12中的OneValueCache 。
程序清单3-12对数值及其因数分解结果进行缓存的不可变容器类:
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i,BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么就必须使用锁来确保原子性。如果是一个不可变对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。
程序清单3-13中的VolatileCachedFactorizer使用了OneValueCache来保存缓存的数值及其因数。当一个线程将volatile类型的cache设置为引用一个新的OncValueCache时,其他线程就会立即看到新缓存的数据。
程序清单3-13使用指向不可变容器对象的volatile类型引用以缓存最新的结果:
@ThreadSafe
public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
// Doesn't really factor
return new BigInteger[]{i};
}
}
与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得Volatile Cached Factorizer在没有显式地使用锁的情况下仍然是线程安全的。
3.5 安全发布
3.5.1 不正确的发布
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}
由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是assertSainty抛出AssertionError的原因。
3.5.3 安全发布的常用模式
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。现在,我们将重点介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,并稍后介绍如何在对象发布后对其可见性进行修改。要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReferande对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到个由锁保护的域中。
3.5.4 不可变对象
如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的,并且如果对象状态不会再改变,那么就足以确保任何访问都是安全的。
例如,Date本身是可变的,但如果将它作为不可变对象来使用,那么在多个线程之间共享Date对象时:就可以省去对锁的使用。假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:
public Map iastLogin =
Collections.synchronizedMap (new HashMap() ;
如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。
3.5.5 可变对象
如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。
3.5.6 安全的共享对象
当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁?是否可以修改它的状态,或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问方式。
在并发程序中使用和共享对象时,可以使用策略:
-
线程封闭
线程封闭的对象被封闭在该线程中,并且只能由这个线程修改。
-
只读共享
在没有额外的同步情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。
-
线程安全共享
对象在其内部实现同步,多个线程可以通过对象的公有接口访问而不需要进一步的同步。
-
保护对象
被保护的对象只能通过持有特定的锁来访问。