Java并发编程的基础理论

前言:本文的演示代码已经进行了压缩,以便减少代码量并重点突出与内容相关的部分,完整的代码示例放在码云上了,码云地址:https://gitee.com/reminis_com/java-concurrency-in-practice.git

并发简史

  在早期的计算机中不包含操作系统,它们从头到尾只执行一个程序,并且这个程序能访问计算机中的所有资源。在这种裸机环境中,不仅很难编写和运行程序,而且每次只能运行一个程序,这对于昂贵并且稀有的计算机资源来说也是一种浪费。

  操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行∶操作系统为各个独立执行的进程分配各种资源,包括内存,文件句柄以及安全证书等。如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括∶套接字、信号处理器、共享内存、信号量以及文件等。

  之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于以下原因∶

  • 资源利用率:在某些情况下,程序必须等待某个外部操作执行完成,例如输入操作或输出操作等,而在等待时程序无法执行其他任何工作。因此,如果在等待的同时可以运行另一个程序,那么无疑将提高资源的利用率。
  • 公平性:不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源,而不是由一个程序从头运行到尾,然后再启动下一个程序。
  • 便利性:通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。

  在早期的分时系统中,每个进程相当于一台虚拟的冯·诺依曼计算机,它拥有存储指令和数据的内存空间,根据机器语言的语义以串行方式执行指令,并通过一组I/O指令与外部设备通信。对每条被执行的指令,都有相应的“下一条指令”,程序中的控制流是按照指令集的规则来确定的。当前,几乎所有的主流编程语言都遵循这种串行编程模型,并且在这些语言的规范中也都清晰地定义了在某个动作完成之后需要执行的“下一个动作”。

  这些促使进程出现的因素(资源利用率、公平性以及便利性等)同样也促使着线程的出现。线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源,例如内存句柄和文件句柄,但每个线程都有各自的程序计数器(Program Counter)、栈以及局部变量等。线程还提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行。

  线程也被称为轻量级进程。在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程。如果没有明确的协同机制,那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量并在同一个堆上分配对象,这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,那么当一个线程正在使用某个变量时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。

线程安全性

  要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。
  从非正式的意义上来说,对象的状态是指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其他依赖对象的域。例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。在对象的状态中包含了任何可能影响其外部可见行为的数据。

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

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

什么是线程安全性?

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

示例:一个无状态的Servlet
  我们来看一个简单的示例————一个基于Servlet的因数分解服务,并逐渐扩展它的功能,同时确保它的线程安全性。如下示例给出了一个简单的因数分解Servlet,这个Servlet从请求中提取出数值,执行因数分解,然后将结果封装到该Servlet响应中。

@ThreadSafe
public class StatelessFactorizer implements Servlet {

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

  与大多数Servlet相同,StatelessFactorizer是无状态的∶它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问 StatelessFactorizer的线程不会影响另一个访问同一个StatelessFactorizer的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。

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

  大多数Servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性。只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

原子性

  当我们在无状态的对象中增加一个状态时,会出现什么情况?假设我们希望增加一个“命中计数器”来统计所处理的请求数量,一种直观的方法是在Servlet中增加一个long类型的域,并且每处理一个请求就将这个值加1,如UnsafeCountingFactorizer示例所示:

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;

    public long getCount() {
        return count;
    }

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
}

  不幸的是,UnsafeCountingFactorizer 并非线程安全的,尽管它在单线程环境中能正确运行。但在多线程环境中这个类很可能会丢失一些更新操作。虽然递增操作++count是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作∶读取count的值,将值加1,然后将计算结果写入count。这是一个"读取一修改一写入"的操作序列,并且其结果状态依赖于之前的状态。

竞态条件

  在UnsafeCountingFactorizer中存在多个竞态条件,从而使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。最常见的竞态条件类型就是“先检查后执行”操作。即通过一个可能失效的观测结果来决定下一步的动作。

  在实际情况中经常会遇到竞态条件。例如,假定你计划中午在University Avenue的星巴克与一位朋友会面。但当你到达那里时,发现在University Avenue上有两家星巴克,并且你不知道说好碰面的是哪一家。在12∶10时,你没有在星巴克A看到朋友,那么就会去星巴克B看看他是否在那里,但他也不在那里。这有几种可能∶你的朋友迟到了,还没到任何一家星巴克;你的朋友在你离开后到了星巴克A;你的朋友在星巴克B,但他去星巴克A找你,并且此时正在去星巴克A的途中。我们假设是最糟糕的情况,即最后一种可能。现在是12∶15,你们两个都去过了两家星巴克,并且都开始怀疑对方是否失约了。现在你会怎么做?回到另一家星巴克?来来回回要走多少次?除非你们之间约定了某种协议,否则你们整天都在University Avenue上走来走去,倍感沮丧。
  在“我去看看他是否在另一家星巴克”这种方法中,问题在于∶当你在街上走时,你的朋友可能已经离开了你要去的星巴克。你首先看了看星巴克A,发现“他不在”,并且开始去找他。你可以在星巴克B中做同样的选择,但不是同时发生。两家星巴克之间有几分钟的路程,而就在这几分钟的时间里,系统的状态可能会发生变化。

  在星巴克这个示例中说明了一种竞态条件,因为要获得正确的结果(与朋友会面),必须取决于事件的发生时序(当你们到达星巴克时,在离开并去另一家星巴克之前会等待多长时间……)。当你迈出前门时,你在星巴克A的观察结果将变得无效,你的朋友可能从后门进来了,而你却不知道。这种观察结果的失效就是大多数竞态条件的本质————基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”∶首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。

示例:延迟初始化中的竞态条件
  延迟初始化的目的是将对象的初始化操作延迟到实际被使用时才进行,同时确保只被初始化一次。如下示例:getInstance()方法首先判断ExpensiveObject是否已经被初始化,如果已经初始化则返回现有的实例,否则,它将创建一个新的实例,并返回一个引用,从而在后来的调用中就无需再执行这段高开销的代码路径。

@NotThreadSafe
public class LazyInitRace {
    private ExpensiveObject instance = null;

    public ExpensiveObject getInstance() {
        if (instance == null)
            instance = new ExpensiveObject();
        return instance;
    }
}

  在LazyInitRace中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getInstance()。A看到 instance 为空,因而创建一个新的ExpensiveObject 实例。B同样需要判断 instance是否为空。此时的instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化ExpensiveObject并设置instance。如果当B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果,即使getInstance通常被认为是返回相同的实例。

复合操作

  LazylnitRaceUnsafeCountingFactorizer都包含一组需要以原子方式执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

  为了确保线程安全性,“先检查后执行”(例如延迟初始化)和“读取-修改-写入”(例如自增运算)等操作必须是原子的。我们将“先检查后执行”和“读取-修改-写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性,如下示例,我们将使用原子类AtomicLong来统计已处理请求请求的数量,确保线程安全:

@ThreadSafe
public class CountingFactorizer implements Servlet {

    private final AtomicLong count = new AtomicLong(0);

    public long getCount() { return count.get(); }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
}

  在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于Servlet的状态就是计数器的状态,并且计数器是线程安全的,因此这里的Servlet也是线程安全的。

   在实际情况中,应尽可能地使用现有的线程安全对象(例如AcomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为客易,从而也更容易维护和验证线程安全性。

加锁机制

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

内置锁

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

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

  每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
  Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去。
  由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义————一组语句作为一个不可分割的单无被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

重入

  当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。

  重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在如下的示例代码中,子类重写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于WidgetLoggingWidget doSomething()方法都是synchronized方法,因此每个doSomething()方法在执行前都会获取Widget上的锁。然而,如果内置锁不是可重入的,那么在调用super.doSomething()时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。

public class Widget {
    public synchronized void doSomething() {
      // ...
    }
}

public class LoggingWidget extends Widget {
    public synchronized void doSomething() {
        System.out.println(toString() + ": calling doSomething");
        super.doSomething();
    }
}

用锁来保护状态

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

  由于锁能使其保护的代码路径以串行形式中来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
  访问共享状态的复合操作,例如命中计数器的递增操作(读取一修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
  一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此。

对象的共享

  我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。同步还有另一个重要的方面∶内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。

可见性

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

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;
    }
}

  NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入mumber的值,这种现象被称为“重排序(Reordering)”。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。O当主线程首先写入number,然后在没有同步的情况下写入 ready,那么读线程看到的顺序可能与写入的顺序完全相反。

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

  NoVisibility是一个简单的并发程序,只包含两个线程和两个共享变量,但即便如此,在判断程序的执行结果以及是否会结束时仍然很容易得出错误结论。要对那些缺乏足够同步的并发程序的执行情况进行推断是十分困难的。这听起来有点恐怖,但实际情况也确实如此。幸运的是,有一种简单的方法能避免这些复杂的问题∶只要有数据在多个线程之间共享,就使用正确的同步。

非原子的64位操作
  当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-air safety)。
  最低安全性适用于绝大多数变量,但是存在一个例外∶非volatile类型的64位数值变量(double和long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

加锁与可见性

  内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果,如图3-1所示。当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到。换句话说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。

  现在,我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。

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

Volatitle变量

  Java语言提供了一种削弱的同步机制,即Volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
  volatile变量对可见性的影响比volatile变量本身更为重要。当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量后,对B也是可见的。因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。然而,我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。
   仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile 变量的正确使用方式包括∶确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(例如,初始化或关闭)。

  虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用作某个操作完成、发生中断或者状态的标志。尽管volatile变量也可以用于表示其它的状态信息,但在使用时要非常小心。例如,volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。
  加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

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

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

发布与逸出

  “发布(Publish)”一个对象的意思是指,使对象能够在当前作用城之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象构造完成之前就发布该对象,就会破坏线程安全性。当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。后面会介绍如何安全发布对象的一些方法。现在,我们首先来看看一个对象是如何逸出的。

  发布对象的最简单方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象,如下示例代码所示。在initialize()方法中实例化一个新的HashSet对象,并将对象的引用保存到 knownSecrets 中以发布该对象。

public static Set<Secret> knownSecrets;

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

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

public class UnsafeStates {
    private String[] states = new String[]{
            "AK", "AL" /*...*/
    };

    public String[] getStates() {
        return states;
    }
}

  如果按照上述方式来发布states,就会出现问题,因为任何调用者都能修改这个数组的内容。在这个示例中,数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。
  当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。
  假定有一个类C,对于C来说,"外部(Alien)方法"是指行为并不完全由C来规定的方法,包括其他类中定义的方法以及类C中可以被改写的方法(既不是私有【private】方法也不是终结【final】方法)。当把一个对象传递给某个外部方法时,就相当于发布了这个对象。你无法知道哪些代码会执行,也不知道在外部方法中究竟会发布这个对象,还是会保留对象的引用并在随后由另一个线程使用。
  无论其他的线程会对已发布的引用执行何种操作,其实都不重要,因为误用该引用的风险始终存在亏。当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的最主要原因∶封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
  最后一种发布对象或其内部状态的机制就是发布一个内部的类实例,如代码ThisEscape所示。当ThisEscape发布EventListener时,也隐含地发布了ThisEscape实例本身,因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用。

public class ThisEscape {

    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {

            @Override
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
}

  在ThisEscape中给出了逸出的一个特殊示例,即 this 引用在构造函数中逸出。当内部的EventListener实例发布时,在外部封装的ThisEscape实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造
不要在构造过程中使this引用远出。
在构造过程中使 this 引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显式创建(通过将它传给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start或initialize方法来启动(请参见第7章了解更多关于服务生命周期的内容)。在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是终结方法),同样会导致this引用在构造过程中逸出。
如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程,如程序SafeListener所示:

public class SafeListener {
    private final EventListener listener;

    private SafeListener() {
        listener = new EventListener() {

            @Override
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }

    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

线程封闭

  当访问共享的可变数据时,通常需要使用同步。一种避免同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭。它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

  线程封闭技术的一种常见应用是JDBC(Java Database Connectivity)的Connection对象。JDBC规范并不要求Connection对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求(例如Servlet请求或EJB调用等)都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中。
  在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。

Ad-hoc线程封闭

  Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是十分脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上,事实上,对线程封闭对象的引用通常保存在公有变量中。由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少使用它,在可能的情况下,应该使用更强的线程封闭技术(例如,栈封闭或ThreadLocal类)。

栈封闭

  栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal混淆)比Ad-hoc线程封闭更易于维护,也更加健壮。
  对于基本类型的局部变量,例如程序loadTheArk()方法的numPairs,无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此Java语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。

    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;

        // animals 被封闭在方法中,不要使它们逸出
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a)) {
                candidate = a;
            } else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }

  在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。在loadTheArk()中实例化一个TreeSet对象,并将指向该对象的一个引用保存到animals中。此时,只有一个引用指向集合animals,这个引用被封闭在局部变量中,因此也被封闭在执行线程中。然而,如果发布了对集合 animals(或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对象 animals的逸出。
  如果在线程内部上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确地说明这些需求,那么后续的维护人员很容易错误地使对象逸出。

ThreadLocal类

  维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
  ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接,如程序ConnectionHolder所示:

    private ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {

        @Override
        public Connection initialValue() {
            try {
                return DriverManager.getConnection(DB_URL);
            } catch (SQLException e) {
                throw new RuntimeException("Unable to acquire Connection, e");
            }
        };
    };

    public Connection getConnection() {
        return connectionHolder.get();
    }

  当某个线程初次调用ThreadLocal.get()方法时,就会调用initialValue()方法来获取初始值,从概念上看,你可以将ThreadLocal视为包含了Map<Thread, T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。
  开发人员经常滥用ThreadLocal,例如将所有全局变量都作为ThreadLocal对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。

不变性

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

  如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。

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

  不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。在程序设计中,一个最困难的地方就是判断复杂对象的可能状态。然而,判断不可变对象的状态却很简单。
  同样,不可变对象也更加安全。如果将一个可变对象传递给不可信的代码,或者将该对象发布到不可信代码可以访问它的地方,那么就很危险——不可信代码会改变它们的状态,更糟的是,在代码中将保留一个对该对象的引用并稍后在其他线程中修改对象的状态。另一方面,不可变对象不会像这样被恶意代码或者有问题的代码破坏,因此可以安全地共享和发布这些对象,而无须创建保护性的副本。

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

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

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

示例:使用Volatile类型来发布不可变对象
  因式分解Servlet将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要对一组相关数据以原子方式去执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如程序OneValueCache所示:

@Immutable
public class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i,
                         BigInteger[] factors) {
        lastNumber = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }

    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i))
            return null;
        else
            return Arrays.copyOf(lastFactors, lastFactors.length);
    }
}

  对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么就必须使用锁来确保原子性。如果是一个不可变对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。

  程序VolatileCachedFactorizer使用了OneValueCache 来保存缓存的数值及其因数。当一个线程将volatile类型的cache值设置为一个新的OneValueCache 时,其他线程就会立即看到新缓存的数据。

@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache = new OneValueCache(null, null);

    @Override
    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);
    }
}

  与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个 volatile类型的引用来确保可见性,使得 VolatileCachedFactorizer在没有显式地使用锁的情况下仍然是线程安全的。

安全发布

  到目前为止,我们重点讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。然而,如果只是像程序StuffIntoPublic那样将对象引用保存到公有域中,那么还不足以安全地发布这个对象。

public class StuffIntoPublic {
    // 不安全的发布
    public Holder holder;

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

  你可能会奇怪,这个看似没有问题的示例何以会运行失败。由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态,即便在该对象的构造函数中已经正确地构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。

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

  你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。事实上,如果程序Holder使用程序StuffIntoPublic中的不安全发布方式,那么另一个线程在调用 assertSanity()时将抛出AssertionError。

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对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是 assertSainty()抛出 AssertionError的原因。
  如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。

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

安全发布的常用模式

  可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。现在,我们将重点介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,并稍后介绍如何在对象发布后对其可见性进行修改。

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

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

  在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector或synchronizedList 时,将满足上述最后一条需求。如果线程A将对象X放入一个线程安全的容器,随后线程B 读取这个对象,那么可以确保B看到A设置的X状态,即便在这段读/写X的应用程序代码中没有包含显式的同步。尽管Javadoc在这个主题上没有给出很清晰的说明,但线程安全库中的容器类提供了以下的安全发布保证∶

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

  类库中的其他数据传递机制(例如Future和Exchanger)同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。
  通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:public static Holder holder - new Holder(42);。静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

事实不可变对象

  如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称之为“事实不可变对象”。
  在没有额外同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

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

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

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

可变对象

  如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。

  对象的发布需求取决于它的可变性

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

安全地共享对象

  当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁?是否可以修改它的状态,或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问方式。

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

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

对象的组合

  到目前为止,我们已经介绍了关于线程安全与同步的一些基础知识。然而,我们并不希望对每一次内存访问都进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。本章将介绍一些组合模式,这些模式能够使一个类更容易成为线程安全的,并且在维护这些类时不会无意中破坏类的安全性保证。

设计线程安全的类

  在线程安全的程序中,虽然可以将程序的所有状态都保存在公有的静态域中,但与那些将状态封装起来的程序相比,这些程序的线程安全性更难以得到验证,并且在修改时也更难以始终确保其线程安全性。通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。
  在设计线程安全类的过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量。
  • 找出约束状态变量的不变性条件。
  • 建立对象状态的并发访问管理策略。

  要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。程序Counter只有一个域value,因此这个域就是Counter的全部状态。对于含有n个基本类型域的对象,其状态就是这些域构成的n元组。例如,二维点的状态就是它的座标值(x,y)。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。例如,LinkedList的状态就包括该链表中所有节点对象的状态。

@ThreadSafe
public final class Counter {
    private long value = 0;

    public synchronized long getValue() {
        return value;
    }

    public synchronized long increment() {
        if (value == Long.MAX_VALUE)
            throw new IllegalStateException("counter overflow");
        return ++value;
    }
}

  同步策略(Synchronization Policy)定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析与维护,就必须将同步策略写为正式文档。
  如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性与封装性。

实例封闭

  如果某对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全地使用。你可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
  封装简化了线程安全类的实现过程,它提供了一种实例封闭机制(Instance Confinement),通常也简称为“封闭”。当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。

  将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
  被封闭对象一定不能超出它们既定的作用域。对象可以封闭在类的一个实例(例如作为类的一个私有成员)中,或者封闭在某个作用域内(例如作为一个局部变量),再或者封闭在线程内(例如在某个线程中将对象从一个方法传递到另一个方法,而不是在多个线程之间共享该对象)。当然,对象本身不会逸出———出现逸出情况的原因通常是由于开发人员在发布对象时超出了对象既定的作用域。

  程序PersonSet说明了如何通过封闭与加锁等机制使一个类成为线程安全的(即使这个类的状态变量并不是线程安全的)。PersonSet的状态由HashSet来管理的,而HashSet并非线程安全的。但由于mySet是私有的并且不会逸出,因此HashSet被封闭在PersonSet中。唯一能访问mySet的代码路径是addPerson与containsPerson,在执行它们时都要获得PersonSet上的锁。PersonSet的状态完全由它的内置锁保护,因而PersonSet是一个线程安全的类。

@ThreadSafe
public class PersonSet {
    private final Set<Person> mySet = new HashSet<Person>();

    public synchronized void addPerson(Person p) {
        mySet.add(p);
    }

    public synchronized boolean containsPerson(Person p) {
        return mySet.contains(p);
    }
}

  这个示例并未对Person的线程安全性做任何假设,但如果Person类是可变的,那么在访问从PersonSet中获得的Person对象时,还需要额外的同步。要想安全地使用Person对象,最可靠的方法就是使Person成为一个线程安全的类。另外,也可以使用锁来保护Person对象,并确保所有客户代码在访问Person对象之前都已经获得正确的锁。

  实例封闭是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有了更多的灵活性。在PersonSet中使用了它的内置锁来保护它的状态,但对于其他形式的锁来说,只要自始至终都使用同一个锁,就可以保护状态。实例封闭还使得不同的状态变量可以由不同的锁来保护。
  在Java平台的类库中还有很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。一些基本的容器类并非线程安全的,例如ArrayList和HashMap,但类库提供了包装器工厂方法(例如Collections.synchronizedList及其类似方法),使得这些非线程安全的类可以在多线程环境中安全地使用。这些工厂方法通过“装饰器(Decorator)”模式将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。在这些方法的Javadoc中指出,对底层容器对象的所有访问必须通过包装器来进行。
  当然,如果将一个本该被封闭的对象发布出去,那么也能破坏封闭性。如果一个对象本应该封闭在特定的作用域内,那么让该对象逸出作用域就是一个错误。当发布其他对象时,例如迭代器或内部的类实例,可能会间接地发布被封闭对象,同样会使被封闭对象逸出。
  封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。

线程安全性的委托

  在前面的CountingFactorizer类中,我们在一个无状态的类中增加了一个AtomicLong 类型的域,并且得到的组合对象仍然是线程安全的。由于CountingFactorizer的状态就是AtomicLong的状态,而AtomicLong是线程安全的,因此CountingFactorizer不会对counter的状态施加额外的有效性约束,所以很容易知道CountingFactorizer是线程安全的。我们可以说CountingFactorizer将它的线程安全性委托给AtomicLong来保证∶之所以CountingFactorizer是线程安全的,是因为AtomicLong是线程安全的。

独立的状态变量

  到目前为止,这些委托示例都仅仅委托给了单个线程安全的状态变量。我们还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即组合而成的类并不会在其包含的多个状态变量上增加任何不变性条件。
  程序VisualComponent是一个图形组件,允许客户程序注册监控鼠标和键盘等事件的监听器。它为每种类型的事件都备有一个已注册监听器列表,因此当某个事件发生时,就会调用相应的监听器。然而,在鼠标事件监听器与键盘事件监听器之间不存在任何关联,二者是彼此独立的,因此VisualComponent可以将其线程安全性委托给这两个线程安全的监听器列表。

public class VisualComponent {
    private final List<KeyListener> keyListeners = new CopyOnWriteArrayList<>();
    private final List<MouseListener> mouseListeners = new CopyOnWriteArrayList<>();

    public void addKeyListener(KeyListener listener) {
        keyListeners.add(listener);
    }

    public void addMouseListener(MouseListener listener) {
        mouseListeners.add(listener);
    }

    public void removeKeyListener(KeyListener listener) {
        keyListeners.remove(listener);
    }

    public void removeMouseListener(MouseListener listener) {
        mouseListeners.remove(listener);
    }

}

  VisualComponent使用CopyOnWriteArrayList来保存各个监听器列表。它是一个线程安全的链表,特别适用于管理监听器列表。每个链表都是线程安全的,此外,由于各个状态之间不存在耦合关系,因此VisualComponent可以将它的线程安全性委托给mouseListeners和keyListeners等对象。

当委托失效时

  大多数组合对象都不会像VisualComponent这样简单∶在它们的状态变量之间存在着某些不变性条件。程序NumberRange使用了两个AtomicInteger来管理状态,并且含有一个约束条件,即第一个数值要小于或等于第二个数值。

public class NumberRange {
    // 不变性条件: lower <= upper
    private final AtomicInteger lower = new AtomicInteger(0);
    private final AtomicInteger upper = new AtomicInteger(0);

    public void setLower(int i) {
        // 注意 -- 不安全的“先检查后执行”
        if (i > upper.get())
            throw new IllegalArgumentException("can't set lower to " + i + " > upper");
        lower.set(i);
    }

    public void setUpper(int i) {
        // 注意 -- 不安全的“先检查后执行”
        if (i < lower.get())
            throw new IllegalArgumentException("can't set upper to " + i + " < lower");
        upper.set(i);
    }

    public boolean isInRange(int i) {
        return (i >= lower.get() && i <= upper.get());
    }
}

  NumberRange不是线程安全的,没有维持对下界和上界进行约束的不变性条件。setLower() 和 setUpper() 等方法都尝试维持不变性条件,但却无法做到。setLower()和setUpper()都是“先检查后执行”的操作,但它们没有使用足够的加锁机制来保证这些操作的原子性。假设取值范围为(0,10),如果一个线程调用setLower(5),而另一个线程调用setUpper(4),那么在一些错误的执行时序中,这两个调用都将通过检查,并且都能设置成功。结果得到的取值范围就是(5,4),那么这是一个无效的状态。因此,虽然Atomiclnteger是线程安全的,但经过组合得到的类却不是。由于状态变量lower和upper不是彼此独立的,因此NumberRange不能将线程安全性委托给它的线程安全状态变量。
  NumberRange可以通过加锁机制来维护不变性条件以确保其线程安全性,例如使用一个锁来保护lower和upper。此外,它还必须避免发布lower和upper,从而防止客户代码破坏其不变性条件。
  如果某个类含有复合操作,例如NumberRange,那么仅靠委托并不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作,除非整个复合操作都可以委托给状态变量。
  如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
  即使NumberRange的各个状态组成部分都是线程安全的,也不能确保NumberRange的线程安全性。

基础构建模块

  Java平台类库包含了丰富的并发基础构建模块,例如线程安全容器类以及各种用于协调多个相互协作的线程控制流的同步工具类(Synchronizer)。下面将介绍一些最有用的并发构建构建模块,特别是在Java 5.0和Java 6中引入的一些新模块,以及在使用这些模块来构造并发应用程序时的一些常用模式。

并发容器

  Java 5.0提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减低。
  另一方面,并发容器是针对多个线程并发访问设计的。在Java 5.0中增加了ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的List。在新的ConcurrentMap接口中增加了对一些常见复合操作的支持,例如“若没有则添加”、替换以及有条件删除等。
  通过并发客器来代替同步客器,可以极大地提高仲编性并降低风险。

  Java 5.0增加了两种新的容器类型∶Queue和BlockingQueue。Queue用来临时保存一组等待处理的元素。它提供了几种实现,包括∶ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发的)优先队列。Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为————事实上,正是通过LinkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List的随机访问需求,从而实现更高效的并发。
  BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者一消费者”这种设计模式中,阻塞队列是非常有用的。
  正如ConcurrentHashMap用于代替基于散列的同步Map,Java6也引入了ConcurrentSkipListMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发替代品(例如用synchronizedMap包装的TreeMap或TreeSet)。

ConcurrentHashMap

  同步容器类在执行每个操作期间都持有一个锁。在一些操作中,例如HashMap.get()或List.contains(),可能包含大量的工作∶当遍历散列桶或链表来查找某个特定的对象时,必须在许多元素上调用equals()方法(而equals()本身还包含一定的计算量)。在基于散列的容器中,如果hashCode()不能很均匀地分布散列值,那么容器中的元素就不会均匀地分布在整个容器中。某些情况下,某个糟糕的散列函数还会把一个散列表变成线性链表。当遍历很长的链表并且在某些或者全部元素上调用equals()方法时,会花费很长的时间,而其他线程在这段时间内都不能访问该容器。
  与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改 Map。ConcurrentHashMap 带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
  ConcurrentHashMap与其他并发容器一起增强了同步容器类∶它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap 返回的迭代器具有弱一致性(Weakly Consistent),而并非"及时失败"。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会追历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。

  尽管有这些改进,但仍然有一些需要权衡的因素。对于一些需要在整个Map上进行计算的方法,例如size()和isEmpty(),这些方法的语义被略微减弱了以反映容器的并发特性。由于size()返回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许size()返回一个近似值而不是一个精确值。虽然这看上去有些令人不安,但事实上size()和isEmpty()这样的方法在并发环境下的用处很小,因为它们的返回值总在不断变化。因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,包括 get()、put()、containsKey()和remove()等。
  在ConcurrentHashMap中没有实现对Map加锁以提供独占访问。在Hashtable和synchronizedMap 中,获得 Map 的锁能防止其他线程访问这个Map。在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射,或者对Map迭代若干次并在此期间保持元素顺序相同。然而,总体来说这种权衡还是合理的,因为并发容器的内容会持续变化。
  与Hashtable和synchronizedMap相比,ConcurrentHashMap有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。

CopyOnWriteArrayList

  CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet的作用是替代同步Set。)
  “写入时复制(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写入时复制”容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。

  显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。这个准则很好地描述了许多事件通知系统∶在分发通知时需要迭代已注册监听器链表,并调用每一个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作。

阻塞队列和生产者 - 消费者模式

  阻塞队列提供了可阻塞的put()和take()方法,以及支持定时的offer()和poll()方法。如果队列已经满了,那么put()方法将阻塞直到有空间可用;如果队列为空,那么take()方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put()方法也永远不会阻塞。

  阻塞队列支持生产者一消费者这种设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离开来,并把工作项放入一个“待完成”列表中以便在随后处理,而不是找出后立即处理。生产者一消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同。
  在基于阻塞队列构建的生产者一消费者设计中,当数据生成时,生产者把数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者,而只需将数据放入队列即可。同样,消费者也不需要知道生产者是谁,或者工作来自何处。BlockingQueue简化了生产者-消费者设计的实现过程,它支持任意数量的生产者和消费者。一种最常见的生产者一消费者设计模式就是线程池与工作队列的组合,在Executor任务执行框架中就体现了这种模式。

  以两个人洗盘子为例,二者的劳动分工也是一种生产者一消费者模式∶其中一个人把洗好的盘子放在盘架上,而另一个人从盘架上取出盘子并把它们烘干。在这个示例中,盘架相当于阻塞队列。如果盘架上没有盘子,那么消费者会一直等待,直到有盘子需要烘干。如果盘架放满了,那么生产者会停止清洗直到盘架上有更多的空间。我们可以将这种类比扩展为多个生产者(虽然可能存在对水槽的竞争)和多个消费者,每个工人只需与盘架打交道。人们不需要知道究竟有多少生产者或消费者,或者谁生产了某个指定的工作项。
“生产者”和“消费者”的角色是相对的,某种环境中的消费者在另一种不同的环境中可能会成为生产者。烘干盘子的工人将“消费”洗干净的湿盘子,而产生烘干的盘子。第三个人把洗干净的盘子整理好,在这种情况中,烘干盘子的工人既是消费者,也是生产者,从而就有了两个共享的工作队列(每个队列都可能阻塞烘干工作的运行)。

  阻塞队列简化了消费者程序的编码,因为take()操作会一直阻塞直到有可用的数据。如果生产者不能尽快地产生工作项使消费者保持忙碌,那么消费者就只能一直等待,直到有工作可做。在某些情况下,这种方式是非常合适的(例如,在服务器应用程序中,没有任何客户请求服务),而在其他一些情况下,这也表示需要调整生产者线程数量和消费者线程数量之间的比率,从而实现更高的资源利用率(例如,在"网页爬虫【Web Crawler】"或其他应用程序中,有无穷的工作需要完成)。
  如果生产者生成工作的速率比消费者处理工作的速率快,那么工作项会在队列中累积起来,最终耗尽内存。同样,put()方法的阻塞特性也极大地简化了生产者的编码。如果使用有界队列,那么当队列充满时,生产者将阻塞并且不能继续生成工作,而消费者就有时间来赶上工作处理进度。
  阻塞队列同样提供了一个offer()方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理负荷过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
  在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具∶它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。

  在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,二者分别与LinkedList和ArrayList类似,但比同步List拥有更好的并发性能。PriorityBlockingQueue是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue既可以根据元素的自然顺序来比较元素(如果它们实现了Comparable方法),也可以使用Comparator 来比较。
  最后一个BlockingQueue实现是SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。如果以洗盘子的比喻为例,那么这就相当于没有盘架,而是将洗好的盘子直接放入下一个空闲的烘干机中。这种实现队列的方式看似很奇怪,但由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟。(在传统的队列中,在一个工作单元可以交付之前,必须通过串行方式首先完成入列【Enqueue】或者出列【Dequeue】等操作。)直接交付方式还会将更多关于任务状态的信息反馈给生产者。当交付被接受时,它就知道消费者已经得到了任务,而不是简单地把任务放入一个队列——这种区别就好比将文件直接交给同事,还是将文件放到她的邮箱中并希望她能尽快拿到文件。因为SynchronousQueue没有存储功能,因此 put()和 take() 会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。

双端队列与工作密取

  Java 6增加了两种容器类型,Deque(发音为"deck")和BlockingDeque,它们分别对Queue和BlockingQueue进行了扩展。Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。

  正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Work Stealing)。在生产者一消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者一消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
  工作密取非常适用于既是消费者也是生产者问题————当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。类似的还有许多搜索图的算法,例如在垃圾回收阶段对堆进行标记,都可以通过工作密取机制来实现高效并行。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态。

同步工具类

  同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。在平台类库中还包含其他一些同步工具类的类。
  所有的同步工具类都包含一些特定的结构化属性它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。

闭锁

  闭锁是一种同步工具类,可以延迟线程的进度直到其达到终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过。当达到结束状态时,这扇门会打开并允许所有的线程通过。当闭锁状态到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才能继续执行。
  CountDownLatch是一种灵活的闭锁实现,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化成一个正数,表示需要等待事件的数量。countDown()方法递减计数器,表示已经有一个事件发生了,而await()方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await()会一直阻塞到计数器的值为零,或者等待中的线程中断,或者等待超时。下面是一个测试n个线程并发执行某个任务所需要的时间示例:

public class TestHarness {

    public static void main(String[] args) throws InterruptedException {
        long time = timeTasks(5, () -> System.out.println("Mr.Sun"));
        System.out.println(time);
    }

    public static long timeTasks(int nThreads, final Runnable task) throws InterruptedException {
        // 起始门,初始值为1
        final CountDownLatch startGate = new CountDownLatch(1);
        // 结束门,初始值为工作线程数
        final CountDownLatch endGate = new CountDownLatch(nThreads);

        for (int i = 0; i < nThreads; i++) {
            Thread t = new Thread(() -> {
                try {
                    // 每个工作线程都会在启动门上等待,从而确保所有线程都就绪后才开始执行
                    startGate.await();
                    try {
                        task.run();
                    } finally {
                        // 每个线程要做的最后一件事就是调用结束门的countDown()方法减1
                        endGate.countDown();
                    }
                } catch (InterruptedException e) {
                }
            });
            t.start();
        }

        long start = System.nanoTime();
        // 起始门:使得主线程能够同时释放所有工作线程
        startGate.countDown();
        // 结束门:使主线程能够等待最后一个工作线程执行完成,而不是顺序地等待每个线程执行完成
        endGate.await();
        // 统计消耗时间
        long end = System.nanoTime();
        return end - start;
    }
}

FutureTask

  FutureTask实现了Future语义,表示一种抽象的可生成结果的计算。FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下三种状态:等待运行、正在运行和运行完成。“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,他会永远停止在这个状态上。
  Future.get()的行为取决于任务的状态。如果任务已完成,那么get()会立即返回结果,否则get()将阻塞直到任务进行完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
  FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些计算时间较长的计算,这些计算可以在使用结算结果之前启动。如下代码中的Preloader就使用了FutureTask来执行一个高开销的计算,并且计算结果将在稍后使用,通过提前启动结算,可以减少等待结果时需要的时间。

/**
 * @author: Mr.Sun
 * @create: 2022-11-14 09:54
 * @description: 使用FutureTask来提前加载稍后需要的数据
 **/
public class Preloader {
    private final FutureTask<ProductInfo> future = new FutureTask<>(new Callable<ProductInfo>() {
        @Override
        public ProductInfo call() throws DataLoadException {
            // 模拟 从数据库加载产品信息
            return loadProductInfo();
        }
    });

    private final Thread thread = new Thread(future);

    /**
     * 由于在构造函数或静态初始化方法中启动线程并不是一种好方法,因此提供了一个start()方法来启动线程
     */
    public void start() { thread.start(); }

    /**
     * 获取产品信息
     * 
     * @return 加载好的产品信息
     */
    public ProductInfo get() throws DataLoadException, InterruptedException {
        try {
            return future.get();
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            if (cause instanceof DataLoadException)
                throw (DataLoadException) cause;
            else
                throw LaunderThrowable.launderThrowable(cause);
        }
    }

    /**
     * 高开销计算实现逻辑
     * 
     * @throws DataLoadException 数据加载异常
     */
    private ProductInfo loadProductInfo() throws DataLoadException {
        
        return null;
    }
}

class ProductInfo {}

class DataLoadException extends Exception { }

信号量

  计数信号量用来控制同时访问某个特定的资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
  Semaphore中管理着一组虚拟的许可,许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire()将阻塞直到有许可(或者直到被中断或者操作超时)。release()方法将返回一个许可给信号量。计数信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用作互斥体(mutex),并具备不可重入的加锁含义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
  使用Semaphore可以将任何一种容器变成有界阻塞容器,如程序BoundedHashSet所示:

/**
 * @author: Mr.Sun
 * @create: 2022-11-14 10:22
 * @description: 使用Semaphore为容器设置边界
 **/
public class BoundedHashSet<T> {
    private final Set<T> set;
    private final Semaphore semaphore;

    public BoundedHashSet(int bound) {
        this.set = Collections.synchronizedSet(new HashSet<>());
        // 初始化信号量的计数值为容器容量的最大值
        this.semaphore = new Semaphore(bound);
    }

    /**
     * 向容器中添加一个元素
     * 
     * @param obj 代添加对象
     * @return 是否添加成功
     */
    public boolean add(T obj) throws InterruptedException {
        // 添加一个元素之前,需要先获得一个许可
        semaphore.acquire();
        boolean wasAdded = false;
        try {
            wasAdded = set.add(obj);
            return wasAdded;
        } finally {
            // 如果add操作没有添加任何元素,那么会立刻释放许可
            if (! wasAdded) {
                semaphore.release();
            }
        }
    }

    /**
     * 删除操作
     * 
     * @param obj 待删除对象
     * @return 是否删除成功
     */
    public boolean remove(T obj) {
        boolean wasRemoved = set.remove(obj);
        if (wasRemoved) {
            // 若删除一个元素,就会释放一个许可
            semaphore.release();
        }
        return wasRemoved;
    }
}

栅栏

  我们已经看到通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一旦进入终止状态,就不能被重置。
  栅栏(Barrier)类似于闭锁,他能阻塞一组线程直到某个事件发生。栅栏和闭锁的关键区别在于,所有的线程必须同时达到栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
  CyclicBarrier可以使一定数量的参与方反复在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程达到栅栏位置时将调用await()方法,这个方法将阻塞直到所有线程都达到栅栏位置。如果所有线程都达到了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用,如果对await()的调用超时,或者await()阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await()调用都将终止并抛出BrokenBarrierException。如果成功通过栅栏,那么await()将为每个线程返回一个唯一的到达索引号,我们可以利于这些索引号来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会执行它,但在阻塞线程被释放之前是不能执行的。

  在如下程序CellularAutomata中给出了如何通过栅栏来计算细胞的自动化模拟。再把模拟化过程进行并行化时,为每个细胞分配一个独立的线程是不现实的,因为这将产生过多的线程,而在协调这些线程上导致的开销将降低计算性能。合理的做法是,将问题分解成一定数量的子问题,为每个子问题分配一个线程进行求解,之后再将所有的结果合并起来。CellularAutomata将问题分解成\(N_{cpu}\)个子问题,其中\(N_{cpu}\)等于可用CPU数量,并将每个子问题分配给一个线程,在每个步骤中,工作线程都会为各自子问题中的所有细胞计算新值。当所有工作线程都到达栅栏时,栅栏会将这些新值提交给数据模型。在栅栏的操作执行完成后,工作线程将开始下一步的计算,包括调用isDone()方法判断是否需要进行下一次迭代。

/**
 * @author: Mr.Sun
 * @create: 2022-11-14 11:18
 * @description: 通过CyclicBarrier协调细胞自动衍生系统中的计算
 **/
public class CellularAutomata {

    private final Board mainBoard;
    private final CyclicBarrier barrier;
    private Worker[] workers;

    public CellularAutomata(Board board) {
        this.mainBoard = board;
        // 获取可用cpu数
        int count = Runtime.getRuntime().availableProcessors();
        this.barrier = new CyclicBarrier(count, new Runnable() {
            @Override
            public void run() {
                mainBoard.commitNewValues();
            }
        });
        this.workers = new Worker[count];
        for (int i = 0; i < count; i++) {
            workers[i] = new Worker(mainBoard.getSubBoard(count, i));
        }
    }

    public void start() {
        for (int i = 0; i < workers.length; i++) {
            new Thread(workers[i]).start();
        }
        mainBoard.waitForConvergence();
    }

    private class Worker implements Runnable {
        private final Board board;

        public Worker(Board board) {
            this.board = board;
        }

        @Override
        public void run() {
            while (!board.hasConverged()) {
                for (int x = 0; x < board.getMaxX(); x++)
                    for (int y = 0; y < board.getMaxY(); y++)
                        board.setNewValue(x, y, computeValue(x, y));
                try {
                    barrier.await(); //
                } catch (InterruptedException ex) {
                    return;
                } catch (BrokenBarrierException ex) {
                    return;
                }
            }
        }

        private int computeValue(int x, int y) {
            // Compute the new value that goes in (x,y)
            return 0;
        }
    }
}

  另一种形式的栅栏是Exchanger,他是一种两方栅栏,各方在栅栏位置上交换数据,当两方执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓存区中读取数据,这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区进行交换,当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全发布给另一方。

小结

  • 可变状态是至关重要的。所有的并发访问题都可以归结为如何协调对并发状态的访问,可变状态越少,就越容易确保线程安全性。
  • 尽量将域声明为final类型,除非需要它们是可变的。
  • 不可变对象一定是线程安全的。不可变对象能极大地降低并发编程的复杂性,它们更为简单而且安全,可以任意共享而且无需使用加锁或保护性复制等机制。
  • 封装有助于管理复杂性。在编写线程安全的程序时,虽然可以将所有的数据都保存在全局变量中,但为什么要这么做?将数据封装在对象中,更易于维持不变性条件;将同步机制封装在对象中,更易于遵循同步策略。
  • 用锁来保护每个可变变量。
  • 当保护同一个不变性条件中的所有变量时,要使用同一个锁。
  • 在执行复合操作期间,要持有锁。
  • 如果从多个线程中访问同一个可变变量时没有使用同步机制,那么程序会出问题。
  • 不要故作聪明地推断出不需要使用同步。
  • 在设计过程中考虑线程安全,或者在文档中指出它不是线程安全的。
  • 将同步策略文档化。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章