java并发编程实践 3.5 安全发布

缘起

《java并发编程实践》 3.5小节

分析

前面讲线程封闭是为了不发布一个对象(限制一个对象在一根线程中),本节关注如何安全的发布一个对象. 因为有的时候我们还是希望能跨线程共享对象的.

下面的例子简单的将Holder对象的引用存储在public域中,其实还是不安全的发布了Holder属性holder.

1
2
3
4
5
6
7
public class XXX {
public Holder holder; // 这个public就是将holder属性发布出去了

public void initialize() {
holder = new Holder(43);
}
}

​ 源码1

这是因为一根线程A调用initialize方法,另一根线程B直接使用holder对象(反正不论是holder还是initialize方法都是public的). 那么B可能看到null或者不完整的holder对象(这是书中讲的”局部创建对象”,因为JMM并不保证B能看到完整的Holder对象).那么B对holder的使用就可能发生问题.

1
2
ps: 其实局部创建对象这个名字翻译的很烂~ 因为原文是 "partially constructed object",因
为创建对象并不是原子操作, 所以B线程才可能看到不完整的对象. 这里应该翻译成不完整的对象

其实如果像源码1那样发布holder对象的话,我们假设Holder类长下面这个样子

1
2
3
4
5
6
7
8
9
public class Holder {
private int n;
public Holder(int n){this.n = n;}
public void assertSanity() {
if(n!=n) {
throw new AssertionError("fault happens!");
}
}
}

​ 源码2

我们来看看源码1+源码2会发生什么错误. 考虑到下面的应用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private XXX xxx;
public class RunnableA implements Runnable {
private XXX xxx;
public void RunnableA(XXX xxx) {
this.xxx = xxx;
}
@Override
public void run() {
xxx.initialize();
}
}
public class RunnableB implements Runnable {
private XXX xxx;
public void RunnableA(XXX xxx) {
this.xxx = xxx;
}
@Override
public void run() {
xxx.holder.assertSanity();
}
}
main方法如下
new Thread(new RunnableA(xxx)).start(); // 线程A
new Thread(new RunnableB(xxx)).start(); // 线程B

​ 源码3

即两根线程(分别称为线程A和线程B)分别调用initialize方法进行发布, 以及调用assertSanity方法. 那么可能会遇到两种问题

  1. 因为holder实例没有同步, 所以线程B可能看到stale的数据(null或者过期的引用),而且如果现实开发中,线程A们(holder的生产者)可能不止一根,也就是说xxx.holder会不断的变化. 在没有任何同步的情况下,线程B们(holder的消费者,实际生产开发中可能也不止一根)看到的holder未必是最新的了——甚至是partially constructed object(不完整的对象).
  2. 线程B们看见的holder可能是最新的,但是此holder中的n属性(即holder的状态)可能是过期的. 所以线程B执行assertSanity方法的时候,首次读取n的时候可能是一个值,再次读取n的时候可能是过期值, 就会抛出AssertionError.

ps: 这里说一个小知识(与上面第二点有关):

一个对象(例如上面的holder)初始化之前,会调用Object的构造函数,而Object的构造 函数会先于子类的构造函数运行,并首先会向所有域写入默认值, 因此这些默认值就可能成为过期值.

首先定义一下什么是安全发布.

安全发布的定义就是一旦被发布的对象的引用被其他线程可见的时候,被发布的对象的状态对其他线程也可见了.

看看上面可能发生的两个问题, 其实就是当对象的引用对线程可见,但是对象的状态(即属性)未必对线程可见(所谓可见,就是指的是最新的.而不是过期的状态). 其实上面不完整的对象(partially constructed object)也是这个问题.

注意,其实发布线程安全发布holder这个对象和holder对象会不会及时的被其他线程观察到并无关联. 安全发布只是为了防止其他消费线程看到的引用以及引用的对象的状态能够一致. 例如3.4小节的OneValueCache属性cache——就算它不用volatile. 即其他线程可能看到了过期的cache属性(甚至是null),但是通过它看到的这个cache引用访问到的cache的属性(即cache的状态)却是和本cache引用是一致的(因为OneValueCache是不变对象,也就是下面要讲的第一种情形). 也就是别的线程依旧可以安全的使用该源源不断地发布出来的cache对象. 不会出现不一致的情形. 或者说看我们上面谈及的第二个问题,说的不就是这么回事儿么? 即消费线程获取到的holder引用不是最新的,这无所谓(不是安全发布关心的内容),但是现在消费线程获取到的holder引用和该引用的对象的状态处于不一致导致AssertionError.这才是安全发布关心的内容.

那么怎么解决安全发布一个对象的问题呢?

这得分你要发布的对象的可变性. 按照从不可变到可变,我们分下面三种情况进行讨论.

  1. 不可变对象

    不可变对象的定义参见3.4节

    我们从上面知道,(发布的)对象的引用对其他线程可见的时候,(发布的)对象的状态(即属性)未必对其他线程可见,如果要想做到这一点,就需要同步. 但是JMM保证了对于发布不可变对象可以不需要同步就能做到这一点。所以即便发布时没有使用同步(顶多引用更新了,其他线程未必能马上知道,但是一致性依旧是保持的),不可变对象依旧能被任意的线程进行安全的访问. 所以源码1只需要将n加一个修饰符final就不会抛出 AssertionError(因为final保证了引用可见的时候,引用的对象的状态也可见了).但是holder引用的过期性依旧存在, 只是就算是过期了的holder引用,也不会发生引用的对象的状态是最新的(从而抛出AssertionError)这种现象.

  2. 有效不可变对象

    所谓有效不可变对象指的是, 发布之后就不会被修改. 但并不是3.4定义的不可变对象, 则JMM就不作出上面的保证. 则想要安全的发布就必须要同步了. 但是这种对象因为发布之后就不会修改,所以发布之后的使用可以完全不同步(效率较高).即安全发布了有效不可变对象之后,该有效不可变对象就可以在不进行任何同步的情况下被任何线程使用了.

    那么怎么安全发布一个非不变对象(包括普通意义下的可变对象,以及这里讲的有效不可变对象)呢? 有以下几种手段

    1. 通过静态初始化代码初始化对象的引用,就像下面这个样子,这是最简单、最安全的方式

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

      因为静态初始化代码在JVM初始阶段执行. 优于JVM内在的同步,该机制确保了以这种方式初始化的对象可以被安全的发布.

    2. 将其引用以volatile修饰或者AtomicReference

    3. 将其引用由final修饰.

    4. 将其引用存入由锁保护的域中,例如将引用存入一些线程安全的集合(Vector、SynchronizedList等)中. 比如线程A将对象X置入某个线程安全的集合,线程B随后重新获取X.这时保证B看到的X的状态是A设置的. 尽管整个应用程序代码并没有显式的同步.

      常用的线程安全的容器提供如下保证

      4.1 置入HashTable、SynchronizedMap、ConcurrentHashMap中的键值对,会安全的发布到可以从这些容器中获取到它们的任意线程中去. 无论是直接通过还是通过迭代器获得.

      4.2 置入Vector, CopyOnWriteArrayList、CopyOnWriteArraySet、SynchronizedList、SynchronizedSet中的元素(X).会被安全的发布到可以从这些容器中获取它(X)的任意线程中.

      4.3 置入BlockingQueue或者ConcurrentLinkedQueue的元素, 会安全的发布到可以从队列中获取到它的任意线程中.

  3. 可变对象

    如果是一个普通意义下的可变对象的话. 则就不能仅仅像2中那样进行安全发布就行了的,因为它可变,所以还必须要在后续的共享中使用同步机制以保证线程安全.

综上

1
2
3
1. 如果是不可变对象,则可以随意发布
2. 如果是有效不可变对象的话,需要安全发布,但是一旦安全发布之后,不需要同步
3. 普通意义下的可变对象,不仅需要安全发布,还需要在后续的共享之中进行同步.

上面我们提及到的是安全的发布对象, 那么如何安全的共享对象呢?

在并发程序中,使用和共享对象的一些最佳实践如下

  1. 线程限制

    该对象只能被一根线程(占有它的线程)使用.

  2. 共享只读(shared read-only)

    不需要额外的同步,可以被多线程并发读,但是任何线程不能修改它. 共享只读对象包括不可变对象和有效不可变对象.

  3. 共享线程安全(shared thread-safe)

    一个线程安全的对象在内部进行同步,所以其他线程访问它无需额外同步, 就可以根据该对象暴露的对外接口进行访问

  4. Guarded.

    必须显式拿锁才能访问的对象.