【Java并发.3】对象的共享

  本章将介绍如何共享和发布对象,从而使他们能够安全地由多个线程同时访问。这两章合在一起就形成了构建线程安全类以及通过 java.util.concurrent 类库来构建开发并发应用程序的重要基础。

3.1  可见性

  可见性是一种复杂的属性,因为可见性中的错误总是违背我们的直觉。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

  在下面的清单中 NoVisibility 说明了当多个线程在没有同步的情况下共享数据出现的错误。主线程启动读线程,然后将 number 设为 42,并将 ready 设为 true。读线程一直循环直到发现 ready 的值变为 true,然后输出 number 的值。虽然看起来会输出 42,但事实上可能输出 0,或者根本无法终止。这是因为代码中没有使用足够的同步机制,因此无法保证主线程写入的ready 值和 nunber 值对于读线程来说是可见的。

public class NoVisibility {                    【皱眉脸-不要这样做】
private static boolean ready;
private static int number; public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
private static class ReaderThread extends Thread {
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}
}
}

  NoVisibility 可能会持续循环下去,因为读线程可能永远都看不到 ready 值。一种更奇怪的现象是,NoVisibility 可能会输出 0,因为读线程可能看到了写入 ready 值,但却没有看到之前写入 number 值,这种现象称为“重排序(Reordering)”。(注释:这看上去似乎是一种失败的设计,但却是使 JVM 充分地利用现代多核处理器的强大性能。)

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

3.1.1  失效数据

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

  失效数据还可能导致一些令人困惑的故障,例如意料之外的异常、被破坏的数据结构、不精确的计算以及无限循环等。

  在如下程序清单 Mutableinteger 不是线程安全的,因为 get 和 set 都是没有同步的情况下访问 value 的。如果某个线程调用了 set,那么另一个在调用的get 线程可能会看到更新后的值,也可能看不到。

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

  在程序清代 SynchronizedInteger 中,通过对 get 和 set 方法进行同步,可以使MutableInteger 成为一个线程安全的类。仅对 set 方法进行同步时不够的,调用 get 线程仍然会看到失效值。

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

3.1.2  非原子的64位操作

  忽略。。。

3.1.3  加锁与可见性

  内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。对于同一个锁,后面进入锁的线程可以看到之前线程在锁中的所有操作结果(加锁可以保证可见性)。

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

3.1.4  Volatile变量

  对于volatile 关键字的详细介绍,建议大家去仔细观看 volatile关键字解析 ,所以在这不做介绍。

3.2  发布与逸出

  “发布(Publish)”一个对象的一起是指,是对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性,则可能需要同步。当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。

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

public class KnownSecrets {
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrets = new HashSet<Secret>();
}
}

  程序清单:是内部的可变状态逸出:

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

  如何按照上述方式来发布 states,就会出现问题,因为任何调用者都能修改这个数组的内容。在这个实例中,数组 states 已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。

  当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。

3.3  线程封闭

  当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不同享数据。如果仅在单线程内访问数据,就不需要同步。这种技术称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。

  线程封闭技术的常见应用时 JDBC 的 Connection 对象。线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将 Connection 对象封闭在线程中。

3.3.1  Ad-hoc线程封闭

  略...

3.3.2  栈封闭

  栈封闭式线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的 ThreadLocal 混淆)。

  对于基本类型的局部变量,如下程序清单中 loadTheArk 方法的 numPairs,无论如何都不会破坏栈封闭性,由于任何方法都无法获得基本类型的引用,因此Java 语言的这种语义就确保了基本来兴的局部变量始终封闭在线程内。

public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Aniaml> animals;
int numPairs = 0; //基本类型的局部变量
Aniaml candidate = null;
// animals 被封闭在方法中,不要使它们逸出
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
numPairs++;
}
return numPairs;
}

3.3.3  ThreadLocal 类

  维持线程封闭性的一种更规范方法就是使用 ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal 提供了 get 和 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。

  ThreadLocal 对象通常用于放置对可变的单实例变量(Singleton)或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个 Connection 对象。

  private static ThreadLocal<Connection> connectionThreadLocal = new ThreadLocal<>() {
@Override
protected Object initialValue() {
return DriverManager.getConnection(URL);
}
} public static Connection getConnection() {
return connectionThreadLocal.get();
}

  当某个线程初次调用 ThreadLocal.get 方法时,就会调用 initialValue 来获取初始值。从概念上看,你可以将 ThreadLocal<T> 视为包含了 Map<Thread, T> 对象,其中保存了特定于该线程的值,但 ThreadLocal 的实现并非如此。这些特定于线程的值保存在 Thread 对象,当线程终止后,这些值会作为垃圾回收。

3.4  不变性

  满足同步需求的另一种方法时使用不可变对象。到目前为止,我们介绍了许多与原子性和可见性相关的问题,例如得到失效数据,丢失更新操作或者观察到某个对象处于不一致的状态等等,都与多线程试图同时访问同一个可变的状态相关。如果对象的状态不会改变,那么这些问题与复杂性也就自然消失了。

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

  虽然在Java 语言规范和 Java 内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为 final 类型,即使对象中所有的域都是 final 类型的,这个对象也仍然是可变的,因为在 final 类型的域中可以保存对可变对象的引用。

当满足以下条件时,对象才是不可变的:
  • 对象创建以后其状态不可能修改。
  • 对象的所有域都是 final 类型。
  • 对象时正确创建的(在对象的创建期间, this 引用没有逸出)。

  看个例子:在可变对象基础上构建的不可变类

public class ThreeStooges {
private final Set<String> stooges = new HashSet<>();
public ThreeStooges() {
stooges.add("one");
stooges.add("two");
stooges.add("three");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}

3.4.1  Final 域

  在 Java 内存模型中,final 域还有着特殊的语义。final 域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。

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

3.4.2  示例:使用 volatile 类型来发布不可变对象

  对于volatile 关键字的详细介绍,建议大家去仔细观看 volatile关键字解析 ,所以在这不做过多介绍。贴一个代码:

public class VolatileCachedFactorizer implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest request, ServletResponse response) {
BigInteger i = extractFromRequest(request);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(response, factors);
}
}

3.5  安全发布

  到目前为止,我么重点讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望多个线程间共享对象,此时必须确保安全地进行共享。

  如下:在没有足够同步的情况下发布对象(不要这样做)

//不安全的发布
public Holder holder;
public void initialize() {
holder = new Holder(42);
}

  由于可见性问题,其他线程看到的 Holder 对象将处于不一致的状态,即便在该对象的构建函数中已经正确地构建了不便性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。

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

  你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。

  如下:由于未被正确发布,因此这个类可能出现故障

public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if(n != n) //这句没看懂,就算同步时会出现 n 很可能成为失效值,但是难道 (n != n)不是原子操作?求解。
throw new AssertionError("this statement is false");
}
}

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

  由于不可变对象是一种非常重要的对象,因此Java 内存模型为不可变对象的共享提供了一种特殊的初始化安全性保障。

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

3.5.3  安全发布的常用模式

    要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到 volatile 类型的域或者 AtomicReferance 对象中
  • 将对象的引用保存到某个正确构造对象的 final 类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

  线程安全库中的容器类提供了一下的安全发布保证:

  • 通过将一个键或者值放入 Hashtable、synchronizedMap 或者 ConcurrentMap 中,可以安全地将它发布给任何从这些同期中访问它的线程(无论是直接访问还是通过迭代器访问)
  • 通过将某个元素放入 Vector、CopyiOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或 synchronizedSet 中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
  • 通过将某个元素放入 BlockingQueue 或者 ConcurrentLinkedQueue 中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

  通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:

public static Holder holder = new Holder(42);

3.5.4  事实不可变对象

  如果对象在发布后不会被修改,那么 程序只需将它们视为不可变对象即可。

在没有额外的同步情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

  例如,Date 本身是可变的,但如果将它作为不可变对象来使用,那么在多个线程之间共享 Date 对象时,就可以省去对锁的使用。假设需要维护一个 Map 对象,其中保存了每位用户的最近登录时间:

public Map<String, Date> lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());

  如果Date对象的值在被放入Map 后就不会改变,那么 synchronizedMap 中的同步机制就足以使 Date 值被安全地发布,并且在访问这些 Date 值时不需要额外的同步。

3.5.5  可变对象

  对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。

    对象的发布需要取决于它的可变性:
  • 不可变对象可以通过任何机制来发布
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。

3.4.5  安全地共享对象

  当发布一个对象时,必须明确地说明对象的访问方式。

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


下一篇:Java并发--安全发布对象