Java并发_2_对象的共享

引:为了能够安全地由多个线程同时访问某个对象,我们就需要学会在共享和发布对象时,构建一个线程安全类或者通过java.util.concurrent类库来构建。

可见性

在读操作和写操作在不同线程执行时,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,所谓“不可见”。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

先看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
}

这段代码不能保证输出42,可能输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入的number的值,这种现象称为“重排序”,它的意思是代码的顺序可能因为优化而发生重排序。

失效数据

失效数据:当读线程查看一个变量是,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能得到某个变量的最新值,而获得另一个变量的失效值。

下面的代码不是线程安全的:

1
2
3
4
5
6
7
8
9
10
11
12
13
@NonThreadSafe
public class MutableInteger {

private int value;

public int get() {
return value;
}

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

get和set都是在没有同步的情况下访问value,所以失效值问题很容易出现:如果某个线程在调用了get,那么另一个正在调用get的线程可能会看到更新后的value值,也可能看不到。要使MutableInteger成为一个线程安全的类,必须对set和get都进行同步。

非原子的64位操作

最低安全性(out-of-thin-air-safety):当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。

例外:对于非volatile类型的long和double变量,JVM允许64位的读操作和写操作分解为两个32位的操作。那么很有可能会读到某个值的高32位和另一个值的低32位。所以在多线程程序中使用共享且可变的long和double等类型的变量是不安全的,除非使用关键字volatile来声明他们,或者用锁保护起来。

volatile变量

volatile变量可以确保将变量的更新操作通知到其他线程。并且会禁止重排序,因此在读取volatile类型的变量时总会返回最新写入的值。volatile通常用作某个操作完成。发生中断或者状态的标志。它只能保证可见性,但是不能保证原子性。

当且仅当满足所有条件时,才应该使用volatile变量:

  1. 对变量的写入操作不依赖变量的当前值(比读到的还要新的值),或者你能保证只有当个线程更新变量的值。
  2. 该变量不会与其他状态变量一起纳入不变性条件中。
  3. 在访问变量时不需要加锁。

发布和逸出

“发布(Publish)”对象:使对象能够在当前作用域之外的代码中使用。例如:将一个指向该对象的引用保存到其他代码可以访问的地方(公有的静态变量中)

“逸出(Escape)”:当某个不应该发布的对象被发布。例如:在对象构造完成之前就发布对象

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

1
2
3
4
5
public static Set<Secret> knownSecrets;

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

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

1
2
3
4
5
6
class UnsafeStates {
private String[] states = new String[] {"AK","AL"...};
public String[] getStates() {
return states;
}
}

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

1
2
3
4
5
6
7
8
9
10
11
public class ThisEscape {

public public ThisEscape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event o) {
doSomething(o);// 由于这个线程是异步的,所有EventSource可能还没有构造完
}
});
}
}

当内部EventListener实例发布时,在外部封装的ThisEscape实例也逸出了,当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。一个常见的使this引用在构造过程中逸出的错误是在构造函数中启动一个线程。如果想在构造函数中注册一个事件监听器或启动线程,可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}
}

线程封闭

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

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

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

Ad-hoc线程封闭

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

栈封闭

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)出去,线程封闭性就会破化。

ThreadLocal类

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

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

1
2
3
4
5
6
7
8
9
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视为包含了Map对象,其中保存了只属于该线程的值。当线程终止后,这些值就会作为垃圾被回收掉。

不变性

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

当满足一下条件的时候,对象才是不可变的:

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的(在对象的创建过程中,this引用没有逸出)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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);
}

}

Final域

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

使用Volatile类型来发布不可变对象

为了保证操作的原子性,可以将多个状态转化为包含多个状态的不可变对象,然后使用volatile来保持可见性,从而保证了线程安全。如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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);
}
}
}

安全发布

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

不正确的发布:可见性出现问题

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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域可能是一个失效值。

不可变对象与初始化安全性

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

安全发布的常用模式

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

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

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

1
public static Holder holder = new Holder(42);

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

事实不可变对象

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

可变对象

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

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

安全的共享对象

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

  • 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
  • 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和实际不可变对象。
  • 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
  • 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

总结

由于对象需要共享,所以要注意发布的安全性,以及对不可以变对象的合理应用。

参考

  1. 《Java并发编程实战》
  2. java并发编程实践学习(四)对象的发布和逸出之this逃逸