6.5 Java对并发编程的支持
线程的作用是提供一个轻量级执行单元——虽比进程小,但仍能执行任何 Java 代码。一般情况下,对操作系统来说,一个线程是一个完整的执行单元,但仍属于一个进程,进程的地址空间在组成该进程的所有线程之间共享。也就是说,每个线程都可以独立调度,而且有自己的栈和程序计数器,但会和同个进程中的其他线程共享内存和对象。
Java 平台从第一版开始就支持多线程编程,并向开发者开放了创建新线程的功能。创建新线程往往很简单,如下所示:
Thread t = new Thread(() -> {System.out.println("Hello Thread");});t.start();
这段简短的代码创建并启动一个新线程,然后执行 lambda 表达式的主体,最后退出。如果你是使用过旧版 Java 的程序员,我告诉你,lambda 表达式其实会被转换成 Runnable 接口的实例,然后再传给 Thread 类的构造方法。
线程机制允许新线程和原有的应用线程以及 JVM 为了不同目的而创建的多个线程一起并发运行。
在大多数 Java 平台的实现中,应用线程都能访问操作系统调度程序控制的 CPU。调度程序是操作系统原生的一部分,用于管理处理器时间的时间片(也能禁止应用线程超出分配给它的时间)。
在最近几版 Java 中,越来越流行使用运行时管理的并发。因为基于很多原因,由开发者自行管理线程已经不能满足需求了。而运行时应该提供“发后不理”能力,让程序指定需要做什么,但怎么做这样的低层细节交给运行时完成。
这种观点从 java.util.concurrent 包含的并发工具包中可以窥探一二,本书不会详细介绍这个包,有兴趣的读者可以阅读 Brian Goetz 等人写的 Java Concurrency in Practice 一书(Addison-Wesley 出版)。
本章剩下的内容会介绍 Java 平台提供的低层并发机制,每个 Java 开发者都应该对此有所了解。
6.5.1 线程的生命周期
我们先来看看应用线程的生命周期。不同的操作系统看待线程的视角有所不同,因此在某些细节上可能有所不能(不过,站在一定高度上,大多数情况下基本类似)。Java 做了很多工作,力求把这些细节抽象化。Java 提供了一个名为 Thread.State 的枚举类型,囊括了操作系统看到的线程状态。Thread.State 中的值概述了一个线程的生命周期。
NEW
已经创建线程,但还没在线程对象上调用 start() 方法。所有线程一开始都处于这个状态。
RUNNABLE
线程正在运行,或者当操作系统调度线程时可以运行。
BLOCKED
线程中止运行,因为它在等待获得一个锁,以便进入声明为 synchronized 的方法或代码块。本节后面会详细介绍声明为 synchronized 的方法和代码块。
WAITING
线程中止运行,因为它调用了 Object.wait() 或 Thread.join() 方法。
TIMED_WAITING
线程中止运行,因为它调用了 Thread.sleep() 方法,或者调用了 Object.wait() 或 Thread.join() 方法,而且传入了超时时间。
TERMINATED
线程执行完毕。线程对象的 run() 方法正常退出,或者抛出了异常。
这些是常见的线程状态(至少对主流操作系统来说如此),线程的生命周期如图 6-4 所示。

图 6-4:线程的生命周期
使用 Thread.sleep() 方法可以让线程休眠。这个方法有一个参数,指定线程休眠的时长,单位为毫秒,如下所示:
try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}
参数中指定的休眠时长是对操作系统的请求,而不是要求。例如,休眠的时间可能比请求的长。具体休眠多久,取决于负载和运行时环境相关的其他因素。
本章后面会介绍 Thread 类的其他方法,不过在此之前,我们要介绍一些重要的理论,学习线程如何访问内存,了解为什么多线程编程如此之难,会给开发者带来很多问题。
6.5.2 可见性和可变性
在 Java 中,其实一个进程中的每个 Java 应用线程都有自己的栈(和局部变量),不过这些线程共用同一个堆,因此可以轻易在线程之间共享对象,毕竟需要做的只是把引用从一个线程传到另一个线程,如图 6-5 所示。

图 6-5:在线程之间共享内存
由此引出 Java 的一个一般设计原则——对象默认可见。如果我有一个对象的引用,就可以复制一个副本,然后将其交给另一个线程,不受任何限制。Java 中的引用其实就是类型指针,指向内存中的一个位置,而且所有线程都共用同一个地址空间,所以默认可见符合自然规律。
除了默认可见之外,Java 还有一个特性对理解并发很重要——对象是可变的(mutable),对象的内容(实例字段的值)一般都可以修改。使用 final 关键字可以把变量或引用声明为常量,但这种字段不属于对象的内容。
在阅读本章剩下内容的过程中,我们会发现,这两个特性(跨线程可见性和对象可变性)结合在一起,大大增加了理解 Java 并发编程的难度。
并发编程的安全性
如果我们想编写正确的多线程代码,得让程序满足一个重要的条件,即:
在一个程序中,不管调用什么方法,也不管操作系统如何调度应用线程,一个对象看到的任何其他对象都不处于非法或不一致的状态,这样的程序才称得上是安全的多线程程序。
在第 5 章,我们把安全的面向对象程序定义为,通过调用对象的存取方法,把对象从一个合法状态变成另一个合法状态。这个定义对单线程代码来说没问题,但延伸到并发程序,会遇到一个特别的难题。
在大多数主流场合中,操作系统会根据负载和系统中运行的其他程序作出决策,在不同的时期把线程调度到不同的处理器内核中运行。如果负载高,说明还有其他进程需要运行。
如果需要,操作系统会把 Java 线程从 CPU 内核中强制移出,不管线程正在做什么,哪怕某个方法正执行一半,都会立即挂起。可是,第 5 章说过,在方法执行的过程中,可以临时先把对象变成非法状态,等方法退出后再变成合法状态。
因此,即便程序遵守了安全规则,如果一个长时间运行的方法还没退出线程就被踢出了,也可能会让对象处于不一致状态。也就是说,虽然为单线程正确建模了数据类型,还是要考虑如何避免并发的影响。添加这层额外的保护措施之后,才能称为并发安全的代码。
下一节介绍获取这层安全性的主要方式,本章末尾还会介绍在某些情况下有用的其他机制。
6.5.3 互斥和状态保护
只要修改或读取对象的过程中,对象的状态可能不一致,这段代码就要受到保护。为了保护这种代码,Java 平台只提供了一种机制:互斥。
假如一个方法包含一连串操作,那么在执行过程中中断,就可能会导致某个对象处于不一致或非法状态。如果这个非法状态对另一个对象可见,代码的行为可能就会错乱。
例如,在 ATM 或其他柜员机的系统中可能有如下代码:
public class Account {private double balance = 0.0; // 必须>= 0// 假设还有其他字段,例如name,以及其他方法// 例如deposit()、checkBalance()和dispenseNotes()public Account(double openingBal) {balance = openingBal;}public boolean withdraw(double amount) {if (balance >= amount) {try {Thread.sleep(2000); // 模拟风险检查} catch (InterruptedException e) {return false;}balance = balance - amount;dispenseNotes(amount);return true;}return false;}}
withdraw() 方法中的一连串操作就可能会让对象处于不一致状态。具体来说是这样的,查看余额之后,在模拟风险检查阶段,第一个线程休眠时,可能会出现第二个线程继续执行代码,导致账户透支,违背 balance >= 0 这个约束条件。
在这个例子中,系统对对象的操作虽然在单线程中安全(因为在单线程中对象不可能变成非法状态,即 balance < 0),但并发时却不安全。
为了让这种代码在并发运行时也安全,Java 为开发者提供了 synchronized 关键字。这个关键字可以用在代码块或方法上,使用时,Java 平台会限制访问代码块或方法中的代码。
因为
synchronized关键字把代码包围起来,所以很多开发者认为,Java 的并发和代码有关。有些资料甚至把synchronized修饰的块或方法中的代码称为临界区,还认为临界区是并发的关键所在。其实不然,稍后会看到,其实我们要防范的是数据的不一致性。
Java 平台会为它创建的每个对象记录一个特殊的标记,这个标记叫监视器(monitor)。synchronized 使用这些监视器(或叫锁)指明,随后的代码可以临时把对象渲染成不一致的状态。synchronized 修饰的代码块或方法会发生一系列事件,详述如下:
(1) 线程需要修改对象时,会临时把对象变成不一致状态;
(2) 线程获取监视器,指明它需要临时互斥存储这个对象;
(3) 线程修改对象,修改完毕后对象处于一致的合法状态;
(4) 线程释放监视器。
如果在修改对象的过程中,其他线程尝试获取锁,Java 会阻塞这次尝试,直到拥有锁的线程释放锁为止。
注意,如果程序没有创建共享数据的多个线程,就无需使用 synchronized 语句。如果自始至终只有一个线程访问某个数据结构,就无需使用 synchronized 保护这个结构。
获取监视器不能避免访问对象,只能避免其他线程声称拥有这个锁——这一点至关重要。为了正确编写并发安全的代码,开发者要确保,修改或读取可能处于不一致状态的对象之前,得先获取对象的监视器。
换个角度来说,如果 synchronized 修饰的方法正在处理一个对象,并且把这个对象变成非法状态,那么读取这个对象的另一个方法(没使用 synchronized 修饰)仍能看到这个不一致的状态。
同步是保护状态的一种协助机制,因此非常脆弱。一个缺陷(需要使用
synchronized修饰的方法却没有使用)就可能为系统的整体安全性带来灾难性的后果。
之所以使用 synchronized 这个词作为“需要临时互斥存储”的关键词,除了说明需要获取监视器之外,还表明进入代码块时,JVM 会从主内存中重新读取对象的当前状态。类似地,退出 synchronized 修饰的代码块或方法时,JVM 会刷新所有修改过的对象,把新状态存入主内存。
如果不同步,系统中不同的 CPU 内核看到的内存状态可能不一样,而这种差异可能会破坏运行中程序的状态。前面的 ATM 示例就可能出现这种情况。
6.5.4 volatile关键字
Java 还提供了另一个关键字,用来并发访问数据——volatile。这个关键字指明,应用代码使用字段或变量前,必须重新从主内存读取值。同样,修改使用 volatile 修饰的值后,在写入变量之后,必须存回主内存。
volatile 关键字的主要用途之一是在“关闭前一直运行”模式中使用。编写多线程程序时,如果外部用户或系统需要向处理中的线程发出信号,告诉线程在完成当前作业后优雅关闭线程,那么就要使用 volatile。这个过程有时叫作“优雅结束”模式。下面看个典型示例,假设处理中的线程里有下述代码,而这段代码在一个实现 Runnable 接口的类中定义:
private volatile boolean shutdown = false;public void shutdown() {shutdown = true;}public void run() {while (!shutdown) {// ……处理其他任务}}
只要没有其他线程调用 shutdown() 方法,处理中的线程就会继续处理任务(经常和非常有用的 BlockingQueue 一起使用,BlockingQueue 接口用于分配工作)。一旦有其他线程调用 shutdown() 方法,处理中的线程就会发现 shutdown 的值变成了 true。这个变化并不影响运行中的作业,不过一旦这个任务结束,处理中的线程就不会再接受其他任务,而会优雅关闭。
6.5.5 Thread类中有用的方法
创建新应用线程时,程序员可以使用 Thread 类中的许多方法,减少劳动量。这里没有列出全部方法,Thread 类还有一些其他方法,但本节主要介绍较常用的方法。
getId()
这个方法返回线程的 ID 值,类型为 long。线程的 ID 在线程的整个生命周期中都不变。
getPriority()和setPriority()
这两个方法控制线程的优先级。调度程序处理线程优先级的策略之一是,如果有优先级高的线程在等待,就不运行优先级低的线程。不过,大多数情况下都无法影响调度程序解释优先级的方式。线程的优先级使用 1~10 之间的整数表示。
setName()和getName()
开发者使用这两个方法设定或取回单个线程的名称。为线程起名字是个好习惯,因为这样调试时更方便,尤其是使用 jvisualvm 等工具。13.2 节会介绍如何使用 jvisualvm。
getState()
返回一个 Thread.State 对象,说明线程处于什么状态。表示状态的各个值在 6.5.1 节介绍过。
isAlive()
用来测试线程是否还“活着”。
start()
这个方法用来创建一个新应用线程,然后再调用 run() 方法调度这个线程,开始执行。正常情况下,执行到 run() 方法的末尾或者执行 run() 方法中的一个 return 语句后,线程就会结束运行。
interrupt()
如果调用 sleep()、wait() 或 join() 方法时阻塞了某个线程,那么在表示这个线程的 Thread 对象上调用 interrupt() 方法,会让这个线程抛出 InterruptedException 异常(并把线程唤醒)。如果线程中涉及可中断的 I/O 操作,那么这个 I/O 操作会终止,而且线程会收到 ClosedByInterruptException 异常。即便线程没有从事任何可中断的操作,线程的中断状态也会被设为 true。
join()
在调用 join() 方法的 Thread 对象“死亡”之前,当前线程一直处于等待状态。可以把这个方法理解为一个指令,在其他线程结束之前,当前线程不会继续向前运行。
setDaemon()
用户线程是这样一种线程,只要它还“活着”,进程就无法退出——这是线程的默认行为。有时,程序员希望线程不阻止进程退出——这种线程叫守护线程。一个线程是守护线程还是用户线程,由 setDaemon() 方法控制。
setUncaughtExceptionHandler()
线程因抛出异常而退出时,默认的行为是打印线程的名称、异常的类型、异常消息和堆栈跟踪。如果这么做还不够,可以在线程中安装一个自定义的处理程序,处理未捕获的异常。例如:
// 这个线程直接抛出一个异常Thread handledThread =new Thread(() -> { throw new UnsupportedOperationException(); });// 给线程起个名字,有利于调试handledThread.setName("My Broken Thread");// 处理这个异常的处理程序handledThread.setUncaughtExceptionHandler((t, e) -> {System.err.printf("Exception in thread %d '%s':" +"%s at line %d of %s%n",t.getId(), // 线程的IDt.getName(), // 线程的名称e.toString(), // 异常名称和消息e.getStackTrace()[0].getLineNumber(),e.getStackTrace()[0].getFileName()); });handledThread.start();
这个方法在某些情况下很有用,例如,如果一个线程在监管一组其他工作线程,那么可以使用这种模式重启“死亡”的线程。
Thread类弃用的方法
Thread 类除了有一些有用的方法之外,还有一些危险的方法,开发者不应该使用。这些方法是 Java 线程 API 原来提供的,但很快就发现不适合开发者使用。可惜的是,因为 Java 要向后兼容,所以不能把这些方法从 API 中移除。开发者要知道有这些方法,而且在任何情况下都不能使用。
stop()
如若不违背并发安全的要求,几乎不可能正确使用 Thread.stop(),因为 stop() 方法会立即“杀死”线程,不会给线程任何机会把对象恢复成合法状态。这和并发安全等原则完全相悖,因此绝对不能使用 stop() 方法。
suspend()、resume()和countStackFrames()
调用 suspend() 方法挂起线程时,不会释放这个线程拥有的任何一个监视器,因此,如果其他线程试图访问这些监视器,这些监视器会变成死锁。其实,这种机制会导致死锁之间的条件竞争,而且 resume() 会导致这几个方法不能使用。
destroy()
这个方法一直没有实现,如果实现了,会遇到与 suspend() 方法一样的条件竞争。
开发者始终应该避免使用这些弃用的方法。为了达到上述方法的预期作用,Java 开发了一 些安全的替代模式。前面提到的“关闭前一直运行”模式就是这些模式的一例。
