synchronization并不仅仅是原子性和确定“临界区”,还有一个重要的方面就是“内存可见性(memory visibility)”。我们不仅想阻止一个线程去修改被另一个线程使用的变量,也想要确保一个线程修改了某个共享值之后其他线程可以看到这些改动。

一,可见性(Visibility)

为了确保多个线程对内存写操作的可见性,必须使用同步机制。

例如下边的例子,没有使用同步:

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可能会正确输出42,这个好理解。

NoVisibility可能会持续循环下去,因为ready的值的修改对reader线程来说可能永远不可见。

Novisibility还有可能会输出0,因为读线程可能看到了写入的ready值,但却没有看到之后的number的值,这种现象叫做重排序。不能保证在一个线程中的操作顺序会和程序中的顺序完全一致。在没有同步的情况下,编译器/处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。重排序参考:http://tech.meituan.com/java-memory-reordering.html

 

1,失效数据(stale data)

程序NoVisibility展示了缺乏同步的程序中可能产生错误结果的一种情况:失效数据。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。

Non-thread-safe Mutable Integer example:

package com.ivy.thread;

@NonThreadSafe
public class MutableInteger {

    private int value;
    
    public int get() {
        return value;
    }
    
    public void set(int value) {
        this.value = value;
    }
}

使用同步将上边的代码改造成线程安全的类:

package com.ivy.thread;

public class SynchronizedInteger {

    @GuardedBy("this") private int value;
    
    public synchronized int get() {
        return value;
    }
    
    public synchronized void set(int value) {
        this.value = value;
    }
}

2, 非原子性的64位操作

一个线程在缺乏同步的情况下读一个变量,有可能获得一个失效的值,但即便是个失效的值也肯定是其他线程赋值的,而不是一个随机数字。这种安全保证叫做out-of-thin-air safety.

out-of-thin-air safety适用于所有变量,只有一个例外: 没有声明为volatile的64位的数值变量(long or double)。Java内存模型要求,变量的读取和写入都必须是原子操作,但对于nonvolatile long and double variables, JVM把64位数字当作两个32位来操作。如果对一个变量进行读时,刚好有另一个线程在对这个变量的低32位进行修改,那就会拿到错误的值。

所以在多线程中使用共享的可变long或double变量也是不安全的,要么用volatile来声明它们,要么用锁保护起来。

3,加锁与可见性

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

4,Volatile变量(详细解释参考《Java核心技术》---- 多线程(7,volatile关键字))

当把变量声明为volatile类型后,编译器与运行时都回注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型变量时总会返回最新写入的值。

因为在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

从内存可见性角度分析,写入一个volatile变量相当于退出同步代码块,读取一个volatile变量相当于进入同步块。但是并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

适合使用volatile的场景必须满足下面的条件:

  1. 对一个变量的写并不依赖于当前值,或者能够保证只有一个单线程在对该变量做更新操作。
  2. 该变量不会与其他状态变量一起纳入不变性的条件中。
  3. 当访问该变量时不需要额外加锁。

二,发布与逸出(Publication and Escape)

发布一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。

逸出:某个不应该发布的对象被发布。

发布对象的最简单直白的方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。例如:

public static Set<Secret> knownSecrets;

public void initialize() {
    knownSecrets - new HashSet<Secret>();
}

当发布某个对象时,可能会间接地发布其他对象。如果将一个Secret对象添加到集合knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新Secret对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。

但是按照下例方式去发布,就会发生逸出:

class UnsafeStates {
    private String[] states = new String[] {"AK","AL"...};
    public String[] getStates() {
        return states;
    }
}

数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。

另一种将一个对象或者它内部的状态publish出去的方式就是publish这个对象所在类的内部类,如下例子,但是会将this对象的引用escape出去。因为当ThisEscape将EventListener publish出去,它就显示地将外部类ThisEscape实例对象也公布出去了,因为内部类实例保存了外部类实例的隐藏引用。所以会把this escape出去。

Implicitly Allowing the this Reference to Escape. Don't do this.

package com.ivy.thread;

import java.awt.Event;

public class ThisEscape {

    public public ThisEscape(EventSource source) {
        source.registerListener(
                new EventListener() {
                    public void onEvent(Event o) {
                        doSomething(o);
                    }
                });
    }
}

1,安全的构造函数实践

ThisEscape描述了逸出的一种很重要case-- this在构造函数中逸出了。只有等构造函数返回,构造出来的对象才完整,所以当在构造过程中publish出正在构造的对象,这个对象肯定是不完整的,即使publish步骤在构造函数的结尾处,也不能保证当前构造的对象是完整的。

所以不允许this引用在构造过程中逸出。

一个常见的使this引用在构造过程中逸出的错误是在构造函数中启动一个线程。

如果想在构造函数中注册一个事件监听器或启动线程,可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程:

package com.ivy.thread;

import java.awt.Event;

public class SafeListener {

    private final EventListener listener;
    
    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event o) {
                doSomething(o);
            }
        }
    }
    
    public static SafeListener newInstance(EventSource source) {
        SafeListener safeListener = new SafeListener();
        source.registerListener(safeListener);
        return safeListener;
    }
}

 

三,线程封闭(Thread Confinement)

当访问共享的可变数据时,通常需要同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭。

线程封闭的一个常见应用是从池中拿JDBC Connection。在典型的服务器应用中,一个线程从池中获取connection对象,用它来处理一个单独的请求,处理完后归还该connection,又放入池中。Connection池是不会把相同的connection对象分配给不同的线程的,这种模式就显式地将那个connection封闭在一个线程中。

局部变量和ThreadLocal类就是用来维护线程封闭特性的,但即便有这些现成的特性,程序员仍有义务去保证封闭在线程中的对象不会从线程中逸出。

 

1, Ad-hoc线程封闭

这种线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。这种技术很脆弱,因此程序中尽量少用它。

2, 栈封闭

栈封闭式线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭比Ad-hoc线程封闭更易于维护,也更加健壮。

基本类型局部变量与引用变量的线程封闭例子:

public int loadTheArk(Collections<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animals candidate = null;
        
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for(animals a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a)) {
                candidate = a;
            } else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }

numPairs不会破坏线程封闭性,因为任何方法都无法获得对基本类型的引用,所以基本类型的局部变量始终封闭在线程内。

但是对于对象引用的线程封闭,就需要一些额外的工作确保对象引用不会逸出。在上例中实例化了一个TreeSet,并用animals引用指向它,因为只有一个引用指向这个Set,而且这个引用是局部变量,所以这个对象引用也被封闭在线程中。但是如果把这个animals公布(publish)出去,线程封闭性就会破化。

3,ThreadLocal类

维护线程封闭性的一种更规范的方式是使用ThreadLocal. ThreadLocal提供了get和set方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接,如下:

private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<>() {
        public Connection initialValue() {
            return DriverManager.getConnection(DB_URL);
        }
    };
    
    public static Connection getConnection() {
        return connectionHolder.get();
    }

 当某个线程初次调用ThreadLocal.get方法时,会调用initialValue()方法来获取初始值。从概念上讲,可以将ThreadLocal<T>视为包含了Map<Thread, T>对象,其中保存了只属于该线程的值。当线程终止后,这些值就会作为垃圾被回收掉。

 

四,不变性(Immutability)

如果某个对象在创建之后状态就不能被修改,那这个对象就被称为不可变对象。不可变对象一定是线程安全的。不可变对象只有一种状态,而且这种状态由构造函数来控制。

不可变对象需要满足:

  • 构造完成后状态不能被修改
  • 所有的属性都要用final修饰
  • 对象在创建期间,this引用没有逸出。

不可变对象仍然可以在内部使用可变对象来管理它们的状态。如下例:

package com.ivy.thread;

import java.util.HashSet;
import java.util.Set;

@Immutable
public final class ThreeStooges {

    private final Set<String> stooges = new HashSet<>();
    
    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }
    
    public boolean isStooge(String name){
        return stooges.contains(name);
    }
    
}

虽然Set是可变的,但是把set用final修饰,并且没有给出获得该get这个set的方法,所以这个set不会被外部类修改,因此ThreeStooges实例仍然是不可变的。

1, Final域

即使对象是可变的,通过江对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合 。仅包含一个或两个可变状态的基本不可变对象仍然比包含多个可变状态的对象简单。

正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”一样,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯。

2,用Volatile对象来发布不可变对象

当一组关联数据需要原子地进行操作时,可以考虑创建一个不可变的类来包含这些数据,例如:

package com.ivy.thread;

import java.math.BigInteger;
import java.util.Arrays;

public class VolatileCachedFactorizer 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);
    }
}

class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactos;
    
    public OneValueCache(BigInteger i, BigInteger[] factors) {
        lastNumber = i;
        lastFactos = Arrays.copyOf(factors, factors.length);
    }
    
    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i)) {
            return null;
        } else {
            return Arrays.copyOf(lastFactos, lastFactos.length);
        }
    }
}

先将lastNumber和lastFactors封装一个类中,用final修饰并且不会有getter,这样就保证了OneValueCache实例是不可变对象。然后在VolatileCachedFactorizer 类中用volatile修饰cache,如果更新了cache,最新的缓存值就会立即被其他线程看到。

 

五,安全发布

在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。

1,不正确的发布:

public Holder hoder;
public void initialize() {
    holder = new Holder(42);
}
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对象将处于不一致的状态。这种不正确的发布会导致其他线程在initialize之前就看到了holder对象,也就是看到了尚未创建完成的对象,可能看到一个空引用或一个旧值,但更糟糕的情况是,线程看到Holder引用的值是最新的,但是n值却不是最新的,就会出现某个线程在第一次读取域时得到失效的n值,再次读时得到一个更新值,这就会导致调用assertSanity时抛出AssertionException。

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。

2,安全发布的常用方式

要安全地发布一个对象,那它的引用和状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式安全地发布:

  • 在静态初始化函数中初始化对象的引用;
  • 把对象的引用保存在volatile类型的域或者AtomicReference对象中
  • 将对象的引用保存到某个正确构造的final对象的域中。
  • 将对象的引用保存到一个由锁保护的域中。

使用静态初始化函数通常是最简单最安全的发布方式:

public static Holder holder = new Holder(42);

静态初始化器由JVM在类的初始化阶段执行,由于JVM内部存在在同步机制,因此通过这种方式初始化的任何对象都哦可以被安全发布。

3,实际不可变对象(Effectively Immutable Objects)

如果对象在发布之后不会修改,那么对于其他在没有加额外同步的情况下安全地访问这些对象的线程来说,安全发布就足够了。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也是可见的。并且如果对象状态不会发生改变,那么就足以确保任何访问都是安全的。

如果对象在技术上来看是可变的,但其状态在发布之后不会再改变,那么这种对象成为“实际不可变对象”,在这些对象发布之后,程序之需要将它们视为不可变对象即可。所以如果确认某些对象是实际不可变对象,就可以简化开发减少同步从而提升性能。

4,可变对象

如果对象在构造后可以被修改,那么安全发布只能确保这个对象在发布当时状态的可见性,为了保证线程安全,就需要在每次对象访问时也使用同步来确保后续修改操作的可见性。对象的发布方式取决于它的可变性:

  • 不可变对象可以通过任何机制来发布; 
  • 实际不可变对象必须通过安全方式来发布;
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或由某个锁保护起来。

5,安全地共享对象

在并发程序中使用共享对象是,可以使用一些实用的策略:

  • 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
  • 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和实际不可变对象。  
  • 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
  • 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,已经已经发布的并且由某个特定锁保护的对象。
posted on 2017-05-03 09:41  coder为  阅读(865)  评论(0编辑  收藏  举报