线程安全性

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变(Mutable)的状态的访问。

对象的状态是指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包含其他依赖对象的域。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

  • 不在线程之间共享该状态变量
  • 该状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

什么是线程安全性?

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么称这个类是线程安全的。

在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

无状态对象一定是线程安全的。

活跃性与性能问题

安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。

多线程中将遇到各种形式的活跃性问题,包括死锁、饥饿、以及活锁等。

与活跃性问题密切相关的是性能问题,活跃性意味着某件正确的事情最终会发生,但却不够好。因为我们通常希望正确的真情尽快发生。性能问题包括多个方面,例如服务时间过长、响应不灵敏、吞吐率过低、资源消耗过高、或者可伸缩性较低等。

原子性

假定有两个操作 A 和 B,如果从执行 A 的线程来看,当另一个线程执行 B 时,要么将 B 全部执行完,要么完全不执行 B,那么 A 和 B 对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。

我们将“先检查后执行”以及“读取 — 修改 — 写入”等操作统称为符合操作。

竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。

加锁机制

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

内置锁

Java 提供内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态方法的 synchronized 方法以 Class 对象作为锁。

synchronized (lock) {
  // 访问或修改由锁保护的共享状态
}

每个 Java 对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。

Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。

重入

由于**内置锁是可重入的,**因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。

重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为 0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将记下锁的持有者,并且将获取计数值置为 1 。如果同一个线程再次获取这个锁时,计数值将递增,而当线程退出同步代码块时,计数器会相应递减。当计数值为 0 时,这个锁将被释放。

用锁来保护状态

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们成状态变量是由这个锁保护的。

每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

活跃性与性能

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络 I/O 或控制台 I/O),一定不要持有锁。

对象的共享

可见性

同步代码块和同步方法不仅可以确保以原子的方式执行操作,同步还有另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防治某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。

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

失效数据

在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。

非原子的 64 位操作

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

最低安全性适用于绝大多数变量,但是存在一个例外:非 volatile 类型的 64 位数值变量(double 和 long)。

Java 内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非 volatile 类型的 long 和 double 变量, JVM 允许将 64 位的读操作或写操作分解为两个 32 位的操作。当读取一个非 volatile 类型的 long 变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高 32 位和另一个值的低 32 位。

加锁及可见性

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

Volatile 变量

Java 语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。

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

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

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其它状态变量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。

发布与逸出

“发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。当某个不应该发布的对象被发布时,这种情况被称为逸出(Escape)。

  • 发布对象最简单的方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看到该对象。
public static Set<Secret> knowSecrets;

public void initialize() { 
    knowSecrets = new HashSet<Secret>();  
}
  • 发布对象还会发布其非私有字段引用的任何对象。
class UnsafeStates {
    private String[] states = new String[] { "AK", "AL" ...};
    public String[] getStates() { return states; }
}

上述 getStates 方法发布对象,会导致任何对象都可以修改这个数组的内容。

  • 最后一种发布对象或其内部状态的机制就是发布一个内部的类实例。如 ThisEscape 所示,当 ThisEscape 发布 EventListener 时,也隐含地发布了 ThisEscape 实例本身,因为在这个内部类的实例中包含了对 ThisEscape 实例的隐含调用。
public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener( new ThisEscape.EventListener() {
            public void onEvent(Event e) { doSomething(e); }
        });
    }
}

安全的对象构造过程

在 ThisEscape 中给出了逸出的一种特殊示例,即 this 引用在构造函数中逸出。当且仅当对象的构造函数返回时,对象才处于可预测和一致的状态。如果 this 引用在构造过程中逸出,那么这种对象就被认为是不正确构造。

不要在构造函数中使 this 引用逸出。

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

public class SafeListener {
    private final EventListener listener;

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

线程封闭

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

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

Java 语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和 ThreadLocal 类,但程序员仍然需要确保封闭在线程中的对象不会从线程中逸出。

栈封闭

栈封闭是线程封闭的一种特例。在栈封闭中,只能通过变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中;它们位于执行线程的栈中,其他线程无法访问这个栈。

ThreadLocal

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

不变性

满足同步需求的另一种方法是使用不可变对象(Immutable Object)。如果某个对象在被创建后其状态就不能被修改,那么这个对象就被称为不可变对象。

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

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

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

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

Final 域

final 用于构造不可变性对象。final 类型的域是不能修改的。

如果 final 引用的是对象,则指引用不可变,对象的堆数据可以被修改;在 Java 内存模型中,final 域还有着特殊的语义。final 域能确保初始化过程的安全性,具体指类加载后准备阶段,如果没有 final,会先为类成员变量设置初始值-零值,编译器发现有 final 时类变量将被直接赋值。

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

安全发布

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

这种保证还将延伸到被正确创建对象中所有 final 类型的域。在没有额外同步的情况下,也可以安全地访问 final 类型的域。然而,如果 final 类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

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

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

在线程安全容器内部的同步意味着,将对象放入到某个容器,例如 Vector 或 synchronizedList 时,将满足上述最后一条需求。

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

public static Holder holder = new Holder(42);

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

事实不可变对象

如果对象从技术来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。

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

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

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