对象的共享

上一章《线程安全性》介绍了如何通过同步来避免多个线程同时访问相同的数据,本章将介绍如何共享和发布对象,从而使之能够安全地有多个线程访问。

可见性

当多个线程在没有同步机制的情况下共享数据,可能出现读线程不能按照预期地读取到写线程对共享数据的修改。

重排序:在没有同步的情况下,编译期、处理器以及运行时等,都可能对操作的顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,想要对内存操作的执行顺序进行判断,几乎无法得出正确的结论。

失效数据

除非在每次访问变量时都使用同步,否则很有可能获得该变量的一个失效值。

1
2
3
4
5
6
7
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this")
private int value;
public synchronized int get() {return value;}
public synchronized void set(int value) {this.value = value;}
}

非原子的64位操作

当线程在没有同步的情况读取变量,可能得到一个失效值,但至少这个值至少是之前某个线程设置的值,而不是一个随机值:这种安全性保证也被称为最低安全性。

但是非volatile的64位数值(double/long)可能无法保证最低安全性,可能读取到某个值的高32位和另一个值的低32位。

加锁与可见性

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

volatile变量

volatile变量上的操作不会与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此读取volatile变量总是会返回最新值。

访问volatile变量不会加锁,不会使线程堵塞,是一种比synchronized更轻量级的同步机制。

仅当volatile能简化代码的实现以及对同步策略的验证时,才应该使用。如果在验证正确性时,需要对可见性进行复杂的判断,那么就不要使用volatile。

典型用法:检查某个状态标识以判断是否退出循环。

1
2
3
4
5
volatile boolean asleep;
...
while (!asleep) {
countSheep();
}

volatile不能保证i++的原子性,除非你确保只有一个线程对变量执行写操作。

使用volatile必须满足以下所有条件:

  • 对变量的写入操作不依赖变量的当前值,或者能确定只有单个线程更新变量。
  • 该变量不与其他变量一起纳入不变性条件。
  • 不需要加锁。

发布与逸出

发布(Publish):使对象在当前作用域之外的代码使用。如:

  • 将一个指向该对象的引用保存到其他代码能访问的地方
  • 在一个非私有方法中返回该引用
  • 将引用传递到其他类的方法中

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

当某个对象逸出后,你必须假设某个类或线程会误用该对象。这正是使用封装的主要原因:封装使得对程序的正确性分析变得可能,并使无意中破坏设计约束条件变得很难。

线程封闭

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

栈封闭

只通过局部变量访问对象。

ThreadLocal类

ThreadLocal通常用于防止对可变的单实例变量或全局变量共享。

不变性

不可变对象一定是线程安全的。

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

  • 对象创建以后其状态不能修改
  • 所有域都是final
  • 对象是正确创建的(创建期间this引用没有逸出)

保存在不可变对象中的状态仍然可以更新,即通过一个保存新状态的实例来替换原有的不可变对象。

final域

除非需要某个域是可变的,否则将其声明为final域,是很好的编程习惯。

安全发布

1
2
3
4
5
public Holder holder;

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

这种不正确的发布导致其他线程看到尚未构建完成的对象。

不正确的发布:正确的对象被破坏

你不能指望一个尚未被完全创建的对象拥有完整性。

1
2
3
4
5
6
7
8
9
10
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.");
}
}
}

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

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

安全发布的常用模式

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

  • 在静态初始化函数中初始化一个对象引用。

    public static Holder holder = new Holder();
    

    静态初始化器在jvm类的初始化阶段执行,由于jvm内部存在同步机制,这种对象可以安全发布。

  • 将对象的引用保存到volatile的域或者AtomicReference对象中。

  • 将对象的引用保存到某个正确构造对象的final域中。
  • 将对象的引用保存到一个由锁保护的域中。
    • 将键或值放入HashTable、synchronizedMap、ConcurrentMap中
    • 将元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList、synchronizedSet中
    • 放入BlockingQueue、ConcurrentLinkedQueue中

事实不可变对象

与上面定义的技术不可变对象一样,任何线程都可以在不需要额外同步的情况下安全地访问事实不可变对象。

可变对象

要安全地共享可变对象,这些对象必须安全发布,并且必须是线程安全的,或者由某个锁保护起来。

总结

在并发程序中使用共享变量,可以使用如下策略:

  • 线程封闭
  • 只读共享。 包括不可变对象和事实不可变对象
  • 线程安全共享。 线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来访问而不需要进一步的同步。
  • 保护对象。 被保护的对象只能通过持有特定的锁来访问。包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。