对象的共享
对象的共享
要编写正确的并发程序,关键问题在于:在访问共享的可变状态需要进行正确的管理。
我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或确定“临界区(Critical Section)”。同步还有另一个重要的方面:内存可见性(Memory Visibility)
可见性:
可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。单线程中总是能得到正确的值,多线程的情况可能出现问题。
失效数据
在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看ready变量时,可能得到一个已经失效的值。除非在每次访问变量时使用同步,否则很可能获得该变量的一个失效值。
非线程安全的可变整数类:
@NotThreadSafe
public class MutableInteger {
private int value;
public int get(){return value;}
public void set(int value){this.value = value;}
}
这个类不是线程安全的,因为get和set都是没有同步的情况下访问value的。如果某个线程调用了set,那么另一个正在调用get的线程可能会看到更新后的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;}
}
仅仅对set方法进行同步是不够的,调用get的线程仍然会看见失效值。
非原子的64位操作
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,单至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被成为最低安全性(out-of-thin-airsafety).
最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。java的内存模型要求。变量的读取操作和写入操作都必须是原子操作,但是对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解成两个32位操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值得高32位和另一个值得低32位。因此在多线程程序中,共享且可变的double和long等类型的变量时不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
加锁与可见性
内置锁可以用于确保某个线程以一种可以预测的方式来查看另一个线程的执行结果。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了保证所有的线程都能看到共享变量的最新值,所有执行读操作或者写操作都必须在同一个锁上同步。
Volatile变量
java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量时共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量时一种比synchronized关键字更轻量级的同步机制。
我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标致一些重要的程序生命周期事件的发生。
volatile boolean asleep;
...
while(!asleep){
countSomeSheep();
}
虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用作某个操作完成、发生中断或者状态的标致。
volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足一下所有条件时,才应该使用volatile变量:
1、对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值
2、该变量不会与其他状态变量一起纳入不变性条件中
3、在访问变量时不需要加锁
发布与逸出
发布(publish)一个对象指的是:使对象能够在当前作用域之外的代码中使用。
如果在发布时要确保线程安全,则可能需要同步。发布内部状态可能会破坏封装安全性,并使得程序难以维持不变性条件。
当某个不应该发布的对象被发布时,这种情况就成为逸出(Escape)。
发布对象的最简单的方法是将对象的引用保存到一个公有的静态变量,以便任何类和线程都能看见该对象。例如:
public static Set<Secret> knowSecrets;
public void initialize(){
knownSecrets = new HashSet<Secret>();
}
当发布某个对象时,可能会间接地发布其他对象。
使内部的可变状态逸出
class UnsafeStates {
private String[] states = new String[] {
"AK", "AL", ...
}
public String[] getStates(){ return states; }
}
如果按照上面的方式来发布states,就会出现问题,因为任何调用者都能修改这个数组的内容。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。
当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的主要原因:封装能够使得对程序的正确性进行分析变的可能,并使得无意中破坏设计约束变得更难。
最后一种发布对象或其内部状态的机制就是发布一个内部的类实例。如下面的ThisEscape所示,当ThisEscape发布EventListener时,也隐含地发布了ThisEscape实例本身,因为在这个内部类中的实例中包含了对ThisEscape实例的隐含引用。
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(new EventListener(){
public void onEvent(Event e){
doSomething(e);
}
});
}
}
在构造过程中使用this引用逸出的一个常见错误时,在构造函数中启动一个县城。当对象在其构造函数中创建一个线程时,无论是显式创建或是隐式创建,this引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。在构造函数中创建线程并没有错误,单最好不要立即启动它,而是通过一个start或initialize方法来启动。在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是final方法),同样会导致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引用才应该从线程中逸出。构造函数可以将this引用保存到某个地方,只要其他线程不会再构造函数完成之前使用它即可。
线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方法就是不共享数据。这种技术称为线程封闭(Thread Confinement)。某个对象呗封闭在一个线程中时,这种用法将自动实现线程安全性,与对象本身是否线程安全无关。
比如Swing中和JDBC中都是用了这个技术,将对象封闭在线程中。比较通俗的应该是局部变量和ThreadLocal类的使用。
Ad-hoc 线程封闭
Ad-hoc线程封闭是指,维护线程封闭的职责完全由程序实现来承担。Ad-hoc线程封闭技术比较脆弱。
栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。对于基本类型的局部变量,无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用。因此java语言的这种语义就确保了基本类型的局部变量始终封闭在线程中。
public int localTheArk(Colletion<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
//animal 被封闭在方法中,不要使它们逸出!
animals = new TreeSet<Animal>(new SpecieGenderComparator());
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对象,并将该对象的一个引用保存在annimals中。此时只有一个引用指向集合animals,这个引用被封闭在局部变量中,因此也被封闭在执行线程中。然而,如果发布了对集合animals(或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对象animals逸出。
要小心,只有编写代码的人员才知道那些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确的说明这些需求,那么后续的维护人员很容易错误地使对象逸出。
ThreadLocal类
维持线程封闭性的一种更规范的方法是ThreadLocal,这个类能使线程中的某个值与保存值关联起来。ThreadLocal提供了get和set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值
ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){
public Connection initialValue(){
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection(){
returnconnectionHolder.get();
}
不变性
满足同步需求的另一种方法是使用不可变对象(Immutable Object)。前面介绍了原子性和可见性问题,如果对象的状态不会改变,那么这些问题就不存在了。
不可变变量一定是线程安全的。
虽然在java语言规范和java内存模型中都没有给出不可变性的正确定义,但不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final类型的,这个对象也仍然是可变的因为在final类型中的域中可以保存对可变对象的引用。
当满足以下条件时,对象才是不可变的:
对象创建以后其状态就不能修改
对象的所有域都是final类型
对象是正确创建的(在对象的创建期间,this引用没有逸出)
在不可变对象的内部任然可以使用可变对象来管理它们的状态。
在可变对象基础上构建的不可变类
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>();
public ThreeStooges(){
stooges.add("Moe");
stogges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}
Final域
final可以视为C++中const机制的一种受限版本,用于构造不可变性对象。final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。然而,在java内存模型中,final域还有特殊的语义、final域能确保初始化的过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无须同步。
除非需要更高的可见性,否则应将所有的域都声明为私有域是一个良好的而变成习惯,除非需要某个域是可变的,否则应将其声明为final域,也是一个良好的编程习惯
使用Volatile类型来发布不可变对象
在某些情况下,不可变对象能提供一种弱形式的原子性。
因式分解Servlet将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因式分解结果。当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如:
@Immutable
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);
}
}
}
对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么久必须使用锁来确保原子性。如归是一个不可变对象,那么当线程获取了对该对象的引用后,就不必担心另一个线程会修改对象的状态。
使用指向不可变容器对象的volatile类型引用以缓存最新的结果
@ThreadSafe
public class VolatileCacheFactorizer implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFormRequest(req);
BigInteger[] factors = cache.getFactors(i);
if(factors == null){
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
}
通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得Volatile Cached Factorizer在没有显式地使用锁的情况下仍然是线程安全的。
安全发布
前面讨论的是如何封闭对象。在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。然而如果像下面这样讲对象引用保存到公有域中,还不足以安全地发布这个对象。
在没有足够同步的情况下发布对象
//不安全的发布
public Holder holder;
public void initialize(){
holder = new Holder(42);
}
这种不正确的发布导致其他线程看到尚未创建完成的对象。
不正确的发布:正确的对象被破坏
你不能指望一个尚未被完全创建的对象拥有完整性、某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。事实上,如果程序清单中的Holder使用不安全发布方式,那么另一个线程在调用assertSanity时将抛出AsseretionError
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity(){
if(n != n){
throw new AssertionError("This sratement is false.");
}
}
}
由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此看到一个空引用或者之前的旧值。然而最糟糕的情况是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是assertSainty抛出AssertionError的原因
不可变对象与初始化安全性
由于不可变对象是一种非常重要的对象,因此java内存模型为不可变对象的共享提供了一种特殊的初始化安全保证。
这种保证还将延伸到正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域锁指向的对象的状态时仍然需要同步。
安全发布的常用模式
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。如何确保使用对象的线程能够看到该对象处于已发布的状况,如何在对象发布后对其可见性进行修改。
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方法来安全地发布:
在静态初始化函数中初始化一个对象引用
将对象的引用保存到volatile类型的域或者AtomicReference对象中
将对象的引用保存到某个正确构造对象的final类型域中
将对象的引用保存到一个由锁保护的域中
尽管javadoc在这个主题上没有给出很清晰的说明,但线程安全库中的容器类提供了一下的安全发布保证:
通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程
通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将元素安全地发布到任何从浙西额队列中访问该元素的线程。
通常,要发布一个静态构造的对象,最简单和最安全的方法是使用静态的初始化器:
public static Holder holder = new Holder(42);
静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全的发布。
事实不可变对象
如果对象在发布后不会被修改,那么对于其他在没有额外同步情况下安全地访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于任何线程也是可见的,并且如果对象状态不会再改变,那么久足以确保任何访问都是安全的。
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象成为事实不可变对象Effectively Immutable Object。
假如Date本身是可变的,如果将它作为不可变对象来使用,那么在多个线程之间共享Date对象,就可以省去对锁的而是用。假设需要维护一个Map对象,其中保存了美味用户的最近登录时间:
public Map<String, Date> lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());
如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值被安全地发布,并且在访问这些Daye值时不需要额外的同步
可变对象
如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。
安全地共享对象
当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁?是否可以修改它的状态,或者只能读取他?由于许多并发错误都是由于没有理解共享对象这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问方式。
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
线程封闭。线程封闭的对象只能由一个线程有用,对象被封闭在该线程中,并且只能由这个线程修改。
只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读包括不可变对象和事实不可变对象。
线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口云来进行访问而不需要进一步的同步。
保护对象。被保护的对象只能通过特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

浙公网安备 33010602011771号