并发执行的进程数目并不受限于CPU数目。操作系统会为每个进程分配CPU时间片,给人并行处理的感觉。
多线程程序在更低一层扩展了多任务的概念:单个程序看起来在同时完成多个任务。每一个任务在一个线程中执行。
多进程和多线程的区别在于,每个进程都拥有自己的一整套变量,而线程则共享数据。虽然有风险,但是共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更轻量级,创建、撤销一个线程比启动新进程的开销要小得多。
12.2线程状态
线程可以有如下6种状态:
- NEW(新建)
- RUNNABLE(可运行)
- BLOCKED(阻塞)
- WAITING(等待)
- TIMED_WAITING(计时等待)
- TERMINATED(终止)
要确定一个线程的当前状态,只需要调用
getState
方法。
12.2.1新建线程
当用
new
创建一个新线程时,这个线程还没有开始运行。这意味着它的状态是新建,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做。
12.2.2可运行线程
一旦调用
start
方法,线程就处于可运行状态。一个可运行的线程可能正在运行也可能没有运行。要由操作系统为线程提供具体的运行时间。一旦一个线程开始运行,它不一定始终保持运行。事实上,运行中的线程有时需要暂停,让其他线程有机会运行。线程调度的细节依赖于操作系统提供的服务。
抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完时,操作系统剥夺该线程的运行权,并给另一个线程一个机会来运行。手机这样的小型设备可能使用协作式调度。即,一个线程只有在调用yield
方法或者被阻塞或等待时才失去控制权。当选择下一个线程时,操作系统会考虑线程的优先级。
在有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器还是需要分配时间片。
12.2.3阻塞和等待线程
当线程处于阻塞或等待状态时,它暂时是不活动的。它不运行任何代码,而且消耗最少的资源。要由线程调度器重新激活这个线程。具体细节取决于它是怎样到达非活动状态的。
- 当一个线程试图获取一个内部的对象锁(而不是
java.util.concurrent
库中的Lock
),而这个锁目前被其他线程占有,该线程就会被阻塞。当所有其他线程都释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将变成非阻塞状态。- 当线程等待另一个线程通知调度器出现一个条件时,这个线程会进入等待状态。调用
Object.wait
方法或Thread.join
方法,或者是等待java.util.concurrent
库中的Lock
或Condition
时,就会出现这种情况。实际上,阻塞状态与等待状态并没有太大区别。- 有几个方法有超时参数,调用这些方法会让线程进入计时等待状态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有
Thread.sleep
和计时版的Object.wait
、Thread.join
、Lock.tryLock
以及Condition.await
。
当一个线程阻塞或等待时(或终止时),可以调度另一个线程运行。当一个线程被重新激活(例如,因为超时期满或成功地获得了一个锁),调度器检查它是否具有比当前运行线程更高的优先级。如果是这样,调度器会剥夺某个当前运行线程的运行权,选择一个新线程运行。
12.2.4终止线程
线程会由于以下两个原因之一而终止:
run
方法正常退出,线程自然终止。- 因为一个没有捕获的异常终止了
run
方法,使线程意外终止。具体来说,可以调用线程的
stop
方法杀死一个线程。该方法抛出一个ThreadDeath
错误对象,这会杀死线程。不过,stop
方法已经废弃,不要使用。
12.3线程属性
12.3.1中断线程
当线程的
run
方法执行方法体中最后一条语句后再执行return
语句返回时,或者出现了方法中没有捕获的异常时,线程将终止。
除了已经废弃的stop
方法,没有办法可以强制线程终止。不过,interrupt
方法可以用来请求终止一个线程。
当对一个线程调用interrupt
方法时,就会设置线程的中断状态。这是每个线程都有的boolean
标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。
要想得出是否设置了中断状态,首先调用静态的Thread.currentThread
方法获得当前线程,然后调用isInterrupted
方法:
while (!Thread.currentThread().isInterrupted() && more work to do) {
// do more work
}
但是,如果线程被阻塞,就无法检查中断状态。这里就要引入
InterruptedException
异常。当在一个被sleep
或wait
调用阻塞的线程上调用interrupt
方法时,那个阻塞调用(即sleep
或wait
调用)将被一个InterruptedException
异常中断。
没有任何语言要求被中断的线程应当终止。中断一个线程只是要引起它的注意。被中断的线程可以决定如何响应中断。某些线程非常重要,所以应该处理这个异常,然后再继续执行。但是,更普遍的情况是,线程只希望将中断解释为一个终止请求。这种线程的run
方法具有如下形式:
Runnable r = () -> {
try {
// ...
while (!Thread.currentThread().isInterrupted() && more work to do) {
// do more work
}
} catch(InterruptedException e) {
// thread was interrupted during sleep or wait
} finally {
// cleanup, if required
}
// exiting the run method terminates the thread
};
如果在每次工作迭代之后都调用
sleep
方法(或者其他可中断方法),isInterrupted
检查既没有必要也没有用处。如果设置了中断状态,此时倘若调用sleep
方法,它不会休眠。实际上,它会清除中断状态(!)并抛出InterruptedException
。因此,如果循环调用了sleep
,不要检测中断状态,而应当捕获InterruptedException
异常:
Runnable r = () -> {
try {
// ...
while (more work to do) {
// do more work
Thread.sleep(delay);
}
} catch(InterruptedException e) {
// thread was interrupted during sleep or wait
} finally {
// cleanup, if required
}
// exiting the run method terminates the thread
};
注意:有两个非常相似的方法,
interrupted
和isInterrupted
。interrupted
方法是一个静态方法,它检查当前线程是否被中断。而且,该方法会清除该线程的中断状态。另一方面,isInterrupted
方法是一个实例方法,可以用来检查是否有线程被中断。调用这个方法不会改变中断状态。
可能会发现很多发布的代码在底层抑制了InterruptedException
异常:
void mySubTask() {
// ...
try {
sleep(delay);
} catch(InterruptedException e) {
// don't ignore
}
// ...
}
不要这样做!如果想不出在
catch
子句中可以做什么有意义的工作,仍然有两种合理的选择:
- 在
catch
子句中调用Thread.currentThread().interrupt()
来设置中断状态。这样一来,调用者就可以检测中断状态。
void mySubTask() {
// ...
try {
sleep(delay);
} catch(InterruptedException e) {
Thread.currentThread().interrupt()
}
// ...
}
- 或者,更好的选择是,用
throws InterruptedException
来标记方法,去掉try
语句块。这样一来,调用者(或者最终的run
方法)就可以捕获这个异常。
void mySubTask() throws InterruptedException {
// ...
sleep(delay);
// ...
}
12.3.2守护线程
可以通过调用:
t.setDaemon(true); // 必须在线程启动之前调用
将一个线程转换为守护线程。唯一的用途是为其他线程提供服务。当只剩下守护线程时,虚拟机就会退出。
12.3.3线程名
可以用
setName
方法为线程设置任何名字:
var t = new Thread(runnable);
t.setName("Web crawler");
这在线程转储时可能很有用。
12.3.4未捕获异常的处理器
线程的
run
方法不能抛出任何检查型异常,但是,非检查型异常可能会导致线程终止。这种情况下,线程会死亡。
不过,对于可以传播的异常,并没有任何catch
子句。实际上,在线程死亡之前,异常会传递到一个用于处理未捕获异常的处理器。
这个处理器必须属于一个实现了Thread.UncaughtExceptionHandler
接口的类。这个接口只有一个方法:
void uncaughtException(Thread t, Throwable e)
可以用
setUncaughtExceptionHandler
方法为任何线程安装一个处理器。也可以用Thread
类的静态方法setDefaultUncaughtExceptionHandler
为所有线程安装一个默认的处理器。替代处理器可以使用日志API将未捕获异常的报告发送到一个日志文件。
如果没有安装默认处理器,则为null
。但是,如果没有为单个线程安装处理器,那么处理器就是该线程的ThreadGroup
对象。
线程组是可以一起管理的线程的集合。默认情况下,创建的所有线程都属于同一个线程组,但是也可以建立其他的组。由于现在引入了更好的特性来处理线程集合,所以建议不要在自己的程序中使用线程组。
ThreadGroup
类实现了Thread.UncaughtExceptionHandler
接口。它的uncaughtException
方法执行以下操作:
- 如果该线程组有父线程组,那么调用父线程组的
uncaughtException
方法。- 否则,如果
Thread.getDefaultExceptionHandler
方法返回一个非null
的处理器,则调用该处理器。- 否则,如果
Throwable
是ThreadDeath
的一个实例,什么都不做。- 否则,将线程的名字以及
Throwable
的栈轨迹输出到System.err
。
12.3.5线程优先级
默认情况下,一个线程会继承构造它的那个线程的优先级。
每当线程调度器有机会选择新线程时,它首先会选择具有较高优先级的线程。但是,线程优先级高度依赖于系统。当虚拟机依赖于宿主机平台的线程实现时,java线程的优先级会映射到宿主机平台的优先级,平台的线程优先级别可能更多,也可能更少。
目前的话,最好不要使用线程优先级了。
12.4同步
取决于线程访问数据的次序,可能会导致对象被破坏,这种情况通常称为竞态条件。
12.4.1竞态条件的一个例子
为了避免多线程破坏共享数据,必须学习如何同步存取。
/**
* A bank with a number of bank accounts.
*/
public class Bank {
private final double[] accounts;
/**
* Constructs the bank.
* @param n the number of accounts
* @param initialBalance the initial balance for each account
*/
public Bank(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
}
/**
* Transfers money from one account to another.
* @param from the account to transfer from
* @param to the account to transfer to
* @param amount the amount to transfer
*/
public void transfer(int from, int to, double amount) {
if (accounts[from] < amount) {
return;
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}
/**
* Gets the sum of all account balances.
* @return the total balance
*/
public double getTotalBalance() {
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
}
/**
* Gets the number of accounts in the bank.
* @return the number of accounts
*/
public int size() {
return accounts.length;
}
}
/**
* This program shows data corruption when multiple threads access a data structure.
*/
public class UnsynchBankTest {
public static final int NACCOUNTS = 5;
public static final double INITIAL_BALANCE = 1000;
public static final double MAX_AMOUNT = 1000;
public static final int DELAY = 10;
public static void main(String[] args) {
var bank = new Bank(NACCOUNTS, INITIAL_BALANCE);
for (int i = 0; i < NACCOUNTS; i++) {
int fromAccount = i;
Runnable r = () -> {
try {
while (true) {
int toAccount = (int) (bank.size() * Math.random());
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount, toAccount, amount);
Thread.sleep((int) (DELAY * Math.random()));
}
} catch (InterruptedException e) {
}
};
new Thread(r).start();
}
}
}
12.4.2竞态条件详解
假设两个线程同时执行指令:
accounts[to] += amount;
问题在于这不是原子操作。这个指令可能如下处理:
- 将accounts[to]加载到寄存器。
- 增加amount。
- 将结果写回accounts[to]。
现在,假定第1个线程执行步骤1和2,然后,它的运行权被抢占。再假设第2个线程被唤醒,更新
account
数组中的同一个元素。然后,第1个线程被唤醒并完成其第3步。
这个动作会抹去第2个线程所做的更新。这样一来,总金额就不再正确了。
12.4.3锁对象
有两种机制可防止并发访问代码块。Java语言提供了一个
synchronized
关键字来达到这一目的,另外java5引入了ReentrantLock
类。synchronized
关键字会自动提供一个锁以及相关的"条件",对于大多数需要显示锁的情况,这种机制功能很强大,也很便利。
用ReentrantLock
保护代码块的基本结构如下:
myLock.lock(); // a ReentrantLock object
try {
// critical section
} finally {
myLock.unlock(); // make sure the lock is unlocked even if an exception is thrown
}
这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程锁定了锁对象,其他任何线程都无法通过
lock
语句。当其他线程调用lock
时,它们会暂停,直到第一个线程释放这个锁对象。
使用锁时,就不能使用try-with-resources语句。首先,解锁方法名不是close
。不过,即使将它重命名,try-with-resources语句也无法正常工作。它的首部希望声明一个新变量。但是如果使用一个锁,可能想使用多个线程共享的那个变量(而不是新变量)。
使用一个锁来保护
Bank
类的transfer
方法:
public class Bank {
private ReentrantLock bankLock = new ReentrantLock(); // 可重入锁
// ...
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
if (accounts[from] < amount) {
return;
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
} finally {
bankLock.unlock();
}
}
}
注意:每个
Bank
对象都有自己的ReentrantLock
对象。如果两个线程试图访问同一个Bank
对象,那么锁可以用来保证串行化访问。不过,如果两个线程访问不同的Bank
对象,每个线程会得到不同的锁对象,两个线程都不会阻塞。
这个锁称为重入锁,因为线程可以反复获得已拥有的锁。锁有一个持有计数来跟踪对lock
方法的嵌套调用。线程每一次调用lock
后都要调用unlock
来释放锁。由于这个特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。
通常可能希望保护会更新或检查共享对象的代码块,从而能确信当前操作执行完之后其他线程才能使用同一个对象。
要注意确保临界区中的代码不要因为抛出异常而跳出临界区。如果在临界区代码结束之前抛出了异常,
finally
子句将释放锁,但是对象可能处于被破坏的状态。
12.4.4条件对象
通常,线程进入临界区后却发现只有满足了某个条件之后它才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。
先来看一个例子。如果一个账户没有足够的资金转账,不希望从这样的账户转出资金。注意,不能使用类似下面的代码:
if (bank.getBalance(from) >= amount) {
bank.transfer(from, to, account);
}
在成功地通过这个测试之后,但在调用
transfer
方法之前,当前线程完全有可能被中断。在线程再次运行前,账户余额可能已经低于提款金额。必须确保在检查余额与转账活动之间没有其他线程修改余额。为此,可以使用一个锁来保护这个测试和转账操作:
public void transfer(int from, int to, int amount) {
bankLock.lock();
try {
while (accounts[from] < amount) {
// wait
// ...
}
// transfer funds
// ...
} finally {
bankLock.unlock();
}
}
现在,当账户中没有足够的资金时,需要等待,直到另一个线程向账户中增加了资金。但是,这个线程刚刚获得了对
bankLock
的排他性访问权,因此别的线程没有存款的机会。这里就要引入条件对象。
一个锁可以有一个或多个相关联的条件对象。可以用newCondition
方法获得一个条件对象。习惯上会给每个条件对象一个合适的名字来反映它表示的条件:
public class Bank {
private Condition sufficientFunds;
// ...
public Bank(int n, double initialBalance) {
// ...
sufficientFunds = bankLock.newCondition();
}
}
如果
transfer
方法发现资金不足,它会调用:sufficientFunds.await();
。
当前线程现在暂停,并放弃锁。这就允许另一个线程执行,这时希望它能增加账号余额。
等待获得锁的线程和已经调用了await
方法的线程存在本质上的不同。一旦一个线程调用了await
方法,它就进入了这个条件的等待集(wait set)。当锁可用时,该线程并不会变为可运行状态。实际上,它仍然保持非活动状态,直到另一个线程在同一条件上调用signalAll
方法。
当另一个线程完成转账时,它应该调用:sufficientFunds.signalAll();
。
这个调用会重新激活等待这个条件的所有线程。当这些线程从等待集中移出时,它们再次成为可运行的线程,调度器最终将再次将它们激活。同时,它们会尝试重新进入该对象。一旦锁可用,它们中的某个线程将从await
调用返回,得到这个锁,并从之前暂停的地方继续执行。
此时,线程应当再次测试条件。不能保证现在一定满足条件——signalAll
方法仅仅是通知等待的线程:现在有可能满足条件,值得再次检查条件。因此,通常await
调用应该放在如下形式的循环中:
while (!(OK to proceed)) {
condition.await();
}
最终需要有某个其他线程调用
signalAll
方法,这一点至关重要。当一个线程调用await
时,它没有办法重新自行激活。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁现象。如果所有的其他线程都被阻塞,最后一个活动线程调用了await
方法但没有先解除其他线程的阻塞,程序会永远挂起。
从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll
。例如,当一个账户余额发生改变时,就应该再给等待的线程一个机会来检查余额:
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
while (accounts[from] < amount) {
sufficientFunds.await();
}
// transfer funds
// ...
sufficientFunds.signalAll();
} finally {
bankLock.unlock();
}
}
注意,
signalAll
调用不会立即激活一个等待的线程。它只是解除等待线程的阻塞,使这些线程可以在当前线程释放锁之后竞争访问对象。
另一个方法signal
只是随机选择等待集中的一个线程,并解除这个线程的阻塞状态。这比解除所有线程的阻塞更高效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,它就会再次阻塞。如果没有其他线程再次调用signal
,系统就会进入死锁。
需要注意的是,只有当线程拥有一个条件的锁时,它才能在这个条件上调用await
、signalAll
或signal
方法。
/**
* A bank with a number of bank accounts that uses locks for serializing access.
*/
public class Bank {
private final double[] accounts;
private Lock bankLock;
private Condition sufficientFunds;
/**
* Constructs the bank.
* @param n the number of accounts
* @param initialBalance the initial balance for each account
*/
public Bank(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
bankLock = new ReentrantLock();
sufficientFunds = bankLock.newCondition();
}
/**
* Transfers money from one account to another.
* @param from the account to transfer from
* @param to the account to transfer to
* @param amount the amount to transfer
*/
public void transfer(int from, int to, double amount) throws InterruptedException {
bankLock.lock();
try {
while (accounts[from] < amount) {
sufficientFunds.await();
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
sufficientFunds.signalAll();
} finally {
bankLock.unlock();
}
}
/**
* Gets the sum of all account balances.
* @return the total balance
*/
public double getTotalBalance() {
bankLock.lock();
try {
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
} finally {
bankLock.unlock();
}
}
/**
* Gets the number of accounts in the bank.
* @return the number of accounts
*/
public int size() {
return accounts.length;
}
}
12.4.5synchronized
关键字
先对锁和条件的要点做一个总结:
- 锁用来保护代码片段,一次只能有一个线程执行被保护的代码。
- 锁可以管理试图进入被保护代码段的线程。
- 一个锁可以有一个或多个相关联的条件对象。
- 每个条件对象管理那些已经进入被保护代码段但还不能运行的线程。
Lock
和Condition
接口允许程序员充分控制锁定。不过,大多数情况下,并不需要那样控制,完全可以使用java语言内置的一种机制。
Java中每个对象都有一个内部锁,如果一个方法声明时有synchronized
关键字,那么对象的锁将保护整个方法。也就是说,要调用这个方法,线程必须获得内部对象锁。换句话说:
public synchronized void method() {
// method body
}
等价于:
public void method() {
this.intrinsicLock.lock();
try {
// method body
} finally {
this.intrinsicLock.unlock();
}
}
内部对象锁只有一个关联条件。
wait
方法将一个线程增加到等待集中,notifyAll/notify
方法可以解除等待线程的阻塞。
public class Bank {
private final double[] accounts;
public synchronized void transfer(int from, int to, double amount) throws InterruptedException {
while (accounts[from] < amount) {
wait(); // wait on intrinsic object lock's single condition
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
notifyAll(); // notify all threads waiting on the condition
}
public synchronized double getTotalBalance() {
// ...
}
}
必须知道每个对象都有一个内部锁,并且这个锁有一个内部条件。这个锁会管理试图进入
synchronized
方法的线程,这个条件可以管理调用了wait
的线程。
将静态方法声明为同步也是合法的。如果调用这样一个方法,它会获得相关类对象的内部锁。例如,如果
Bank
类有一个静态同步方法,那么当调用这个方法时,Bank.class
对象的锁会锁定。因此,没有其他线程可以调用这个类的该方法或任何其他同步静态方法。
内部锁和条件存在一些限制。包括:
- 不能中断一个正在尝试获得锁的线程。
- 不能指定尝试获得锁时的超时时间。
- 每个锁仅有一个条件可能是不够的。
在代码中使用锁的建议:
- 最好既不使用
Lock/Condition
也不使用synchronized
关键字。在许多情况下,可以使用java.util.concurrent
包中的某种机制,它会处理所有的锁定。- 如果
synchronized
关键字适合程序,那么尽量使用这种做法,这样可以减少编写的代码量,还能减少出错的概率。- 如果特别需要
Lock/Condition
结构提供的额外能力,则使用它。
12.4.6同步块
还有另一种机制可以获得锁:即进入一个同步块。当线程进入如下形式的块时:
synchronized (obj) { // this is the syntax for a synchronized block
// critical section
}
有时会发现一些"专用"锁,例如:
public class Bank {
private double[] accounts;
private Object lock = new Object();
// ...
public void transfer(int from, int to, int amount) {
synchronized (lock) { // an ad-hoc lock
accounts[from] -= amount;
accounts[to] += amount;
}
System.out.println(...);
}
}
在这里,创建
lock
对象只是为了使用每个java对象拥有的锁。
有时,程序员使用一个对象的锁来实现额外的原子操作,这种做法称为客户端锁定。例如,考虑Vector
类,这是一个列表,它的方法是同步的。现在,假设将银行余额存储在一个Vector<Double>
中。下面是transfer
方法的一个原生实现:
public void transfer(Vector<Double> accounts, int from, int to, int amount) { // ERROR
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
System.out.println(...);
}
Vector
类的get
和set
方法是同步的,但是,这并没有什么帮助。在第一次get
调用完成之后,一个线程完全可能在transfer
方法中被抢占。然后另一个线程可能会在相同的位置存储不同的值。不过,可以截获这个锁:
public void transfer(Vector<Double> accounts, int from, int to, int amount) {
synchronized (accounts) {
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
}
System.out.println(...);
}
这个方法是可行的,但是完全依赖于这样一个事实:
Vector
类会对自己的所有更改方法使用内部锁。不过,Vector
类的文档没有给出这样的承诺。必须仔细研究源代码,而且还得希望将来的版本不会引入非同步的更改方法。可以看到,客户端锁定是非常脆弱的,通常不推荐使用。
12.4.8volatile
字段
有时,如果只是为了读写一两个实例字段而使用同步,所带来的开销好像有些划不来。但是,使用现代的处理器与编译器,出错的可能性很大。
- 有多处理器的计算机能够暂时在寄存器或本地内存缓存中保存内存值。其结果是,运行在不同处理器上的线程可能看到同一内存位置有不同的值。
- 编译器可以改变指令执行的顺序以使吞吐量最大化。编译器不会选择可能改变代码语义的顺序,但是编译器有一个假定,认为内存值只在代码中有显示的修改指令时才会改变。然而,内存值有可能被另一个线程改变。
如果使用锁来保护可能被多个线程访问的代码,那么不存在这种问题。编译器被要求在必要的时候刷新本地缓存来支持锁,而且不能随意(不正当)地重新排列指令顺序。
volatile
关键字为实例字段的同步访问提供了一种免锁机制。如果声明一个字段为volatile
,那么编译器和虚拟机就知道该字段可能被另一个线程并发更新。
例如,假设一个对象有一个boolean
标记done
,它的值由一个线程设置,而由另一个线程查询,正如讨论的那样,可以使用锁:
private boolean done;
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }
或许使用内部对象锁不是个好主意。如果另一个线程已经对该对象加锁,
isDone
和setDone
方法可能会阻塞。如果这是个问题,可以只为这个变量使用一个单独的锁。但是,这会很麻烦。
在这种情况下,将字段声明为volatile
就很合适:
private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }
编译器会插入适当的代码,以确保如果一个线程对
done
变量做了修改,这个修改对读取这个变量的所有其他线程都可见。
需要注意的是,volatile
变量不能提供原子性。例如:
public void flipDone() { done = !done; } // not atomic
// 不能确保翻转字段中的值。不能保证读取、翻转和写入不被中断
12.4.9final
变量
还有一种情况可以安全地访问一个共享字段,即这个字段声明为
final
时。
final var accounts = new HashMap<String, Double>();
其他线程会在构造器完成构造之后才看到这个
accounts
变量。
如果不使用final
,就不能保证其他线程看到的是accounts
更新后的值,它们可能都只是看到null
,而不是新构造的HashMap
。
当然,对这个映射的操作并不是线程安全的。如果有多个线程更改和读取这个映射,仍然需要进行同步。
12.4.10原子性
假设对共享变量除了赋值之外并不做其他操作,那么可以将这些共享变量声明为
volatile
。
java.util.concurrent.atomic
包中有很多类使用了很高效的机器级指令(而没有使用锁)来保证其他操作的原子性。例如,AtomicInteger
类提供了方法inrementAndGet
和decrementAndGet
,它们分别以原子方式将一个整数进行自增或自减。
例如,可以安全地生成一个数值序列:
public static AtomicLong nextNumber = new AtomicLong();
// in some thread...
long id = nextNumber.incrementAndGet();
inrementAndGet
方法以原子方式将AtomicLong
自增,并返回自增后的值。也就是说,获得值,增1并设置然后生成新值的操作不会中断。可以保证即使是多个线程并发地访问同一个实例,也会计算并返回正确的值。
有很多方法可以以原子方式设置和增减值,不过,如果希望完成更复杂的更新,就必须使用compareAndSet
方法。例如,假设希望跟踪不同线程观察的最大值。下面的代码是不可行的:
public static AtomicLong largest = new AtomicLong();
// in some thread...
largest.set(Math.max(largest.get(), observed)); // ERROR--race condition!
这个更新不是原子的。实际上,可以提供一个lambda表达式更新变量,它会完成更新:
largest.updateAndGet(x -> Math.max(x, observed));
// 或者是
largest.accumulateAndGet(observed, Math::max);
accumulateAndGet
方法利用一个二元操作符来合并原子值和所提供的参数。
还有getAndUpdate
和getAndAccumulate
方法可以返回原值。
如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。
LongAdder
和LongAccumulator
类解决了这个问题。LongAdder
包括多个变量(加数),其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下,只有当所有工作都完成之后才需要总和的值,对于这种情况,这种方法会很高效。性能会有显著的提升。
如果预期可能存在大量竞争,只需要使用LongAdder
而不是AtomicLong
。方法名稍有区别。要调用increment
让计数器自增,或者调用add
来增加一个量,另外调用sum
来获取总和。
var adder = new LongAdder();
for (...) {
pool.submit(() -> {
while (...) {
// ...
if (...) adder.increment();
}
});
}
// ...
long total = adder.sum();
当然,
increment
方法不会返回原值。这样做会消除将求和分解到多个加数锁带来的性能提升。
LongAccumulator
将这种思想推广到任意的累加操作。在构造器中,可以提供这个操作以及它的零元素。要加入新的值,可以调用accumulate
。调用get
来获得当前值。下面的代码可以得到与LongAdder
同样的效果:
var adder = new LongAccumulator(Long::sum, 0);
// in some thread...
adder.accumulate(value);
在内部,这个累加器包含变量a1,a2,…,a2。每个变量初始化为零元素(这个例子中零元素为0)。
调用accumulate
并提供值v时,其中一个变量会以原子方式更新为ai = ai op v,这里op是中缀形式的累加操作。在这个例子中,调用accumulate
会对某个i计算ai = ai + v。
get
的结果是a1 op a2 op … op an。在这个例子中,这就是累加器的总和:a1 + a2 + … + an。
如果选择一个不同的操作,可以计算最小值或最大值。一般来说,这个操作必须满足结合律和交换律。这说明,最终结果必须不依赖于以什么顺序结合这些中间值。
另外,DoubleAdder
和DoubleAccumulator
也采用同样的方式,只不过处理的是double
值。
12.4.11死锁
锁和条件不能解决多线程中可能出现的所有问题。考虑下面的情况:
- 账户1:$200。
- 账户2:$300。
- 线程1:从账户1转$300到账户2。
- 线程2:从账户2转$400到账户1。
线程1和线程2显然都被阻塞。因为账户1以及账户2中的余额都不足以进行转账,两个线程都无法执行下去。
有可能会因为每一个线程要等待更多的钱款存入而导致所有线程都被阻塞。这样的状态称为死锁。
还有一种做法会导致死锁,让第i个线程负责向第i个账户存钱,而不是从第i个账户取钱。这样一来,有可能所有线程都集中到一个账户上,每一个线程都试图从这个账户中取出大于该账户余额的钱。
还有一种很容易导致死锁的情况:将signalAll
修改为signal
。
遗憾的是,java中没有任何东西可以避免或打破这种死锁。必须仔细设计程序,确保不会出现死锁。
12.4.12线程局部变量
有时可能要避免共享变量,使用
ThreadLocal
辅助类为各个线程提供各自的实例。例如,SimpleDateFormat
类不是线程安全的。假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
如果两个线程都执行以下操作:
String dateStamp = dateFormat.format(new Date());
结果可能很混乱,因为
dateFormat
使用的内部数据结构可能会被并发的访问所破坏。当然可以使用同步,但开销很大;或者也可以在需要时构造一个局部的SimpleDateFormat
对象,不过这也太浪费了。
要为每个线程构造一个实例,可以使用以下代码:
public static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
要访问具体的格式化方法,可以调用:
String dateStamp = dateFormat.get().format(new Date());
在一个给定的线程中首次调用
get
时,会调用构造器中的lambda表达式。在此之后,get
方法会返回属于当前线程的那个实例。
在多个线程中生成随机数也存在类似的问题。
java.util.Random
类是线程安全的,但是如果多个线程需要等待一个共享的随机数生成器,这会很低效。
可以使用ThreadLocal
辅助类为各个线程提供一个单独的生成器,不过java7还另外提供了一个便利类:
int random = ThreadLocalRandom.current().nextInt(upperBound);
ThreadLocalRandom.current()
调用会返回特定于当前线程的Random
类的实例。
12.5线程安全的集合
12.5.1阻塞队列
很多线程问题可以使用一个或多个队列以优雅而安全的方式来描述。生产者线程向队列插入元素,消费者线程则获取元素。使用队列,可以安全地从一个线程向另一个线程传递数据。
例如,考虑银行转账程序,转账线程将转账指令对象插入一个队列,而不是直接访问银行对象。另一个线程从队列中取出指令完成转账。只有这个线程可以访问银行对象的内部。因此不需要同步(当然,线程安全的队列类的实现者必须考虑锁和条件,但那是他们的问题)。
当试图向队列添加元素而队列已满,或者是想从队列移出元素而队列为空的时候,阻塞队列将导致线程阻塞。在协调多个线程之间的合作时,阻塞队列是一个有用的工具。工作线程可以周期性地将中间结果存储在阻塞队列中。其他工作线程移除中间结果,并进一步修改。队列会自动地平衡负载。如果第一组线程运行得比第二组慢,第二组在等待结果时会阻塞。如果第一组线程运行得更快,队列会填满,直到第二组赶上来。
阻塞队列方法分为以下3类,这取决于当队列满或空时它们完成的动作。
如果使用队列作为线程管理工具,将要用到put
和take
方法。
当试图向满队列添加元素或者想从空队列得到队头元素时,add
、remove
和element
操作会抛出异常。
当然,在一个多线程程序中,队列可能会在任何时候变空或变满,因此,应当使用offer
、poll
和peek
方法(poll
和peek
返回null
来指示失败。因此,向这些队列中插入null
值是非法的)作为替代。如果不能完成任务,这些方法只是给出一个错误提示而不会抛出异常。
还有带有超时时间的
offer
和poll
方法:
// 尝试在100毫秒的时间内在队尾插入一个元素。如果成功返回true;否则,如果超时,则返回false
boolean success = q.offer(x, 100, TimeUnit.MILLISECONDS);
// 尝试在100毫秒的时间内移除队头元素;如果成功返回队头元素,否则,如果超时,则返回null
Object head = q.poll(100, TimeUnit.MILLISECONDS);
如果队列满,则
put
方法阻塞;如果队列空,则take
方法阻塞。它们与不带超时参数的offer
和poll
方法等效。
java.util.concurrent
包提供了阻塞队列的几个变体。
默认情况下,LinkedBlockingQueue
的容量没有上界,但是,也可以选择指定一个最大容量。LinkedBlockingQueue
是一个双端队列。
ArrayBlockingQueue
在构造时需要指定容量,并且有一个可选的参数来指定是否需要公平性。若设置了公平参数,那么等待了最长时间的线程会优先得到处理。通常,公平性会降低性能,只有在确实非常需要时才使用公平参数。
PriorityBlockingQueue
是一个优先队列,而不是先进先出队列。元素按照它们的优先级顺序移除。这个队列没有容量上限,但是,如果队列是空的,获取元素的操作会阻塞。
DelayQueue
包含实现了Delayed
接口的对象:
interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
getDelay
方法返回对象的剩余延迟。负值表示延迟已经结束。元素只有在延迟结束的情况下才能从DelayQueue
移除。还需要实现compareTo
方法。DelayQueue
使用该方法对元素排序。
Java7增加了一个TransferQueue
接口,允许生产者线程等待,直到消费者准备就绪可以接收元素。如果生产者调用:q.transfer(item);
,这个调用会阻塞,直到另一个线程将元素删除。LinkedTransferQueue
类实现了这个接口。
/**
* 在一个目录及其所有子目录下搜索所有文件,打印出包含指定关键字的行。注意,这里不需要显式的线程同步,
* 在这个应用程序中,使用队列数据结构作为一种同步机制
*/
public class BlockingQueueTest {
private static final int FILE_QUEUE_SIZE = 10;
private static final int SEARCH_THREADS = 100;
private static final Path DUMMY = Path.of("");
private static BlockingQueue<Path> queue = new ArrayBlockingQueue<>(FILE_QUEUE_SIZE);
public static void main(String[] args) {
try (var in = new Scanner(System.in)) {
System.out.print("Enter base directory (e.g. /opt/jdk-9-src): ");
String directory = in.nextLine();
System.out.print("Enter keyword (e.g. volatile): ");
String keyword = in.nextLine();
// 生产者线程枚举指定目录及其所有子目录下的所有文件并把它们放到一个阻塞队列中。
// 这个操作很快,如果队列没有上限的话,很快就会包含文件系统中的所有文件
Runnable enumerator = () -> {
try {
enumerate(Path.of(directory));
queue.put(DUMMY);
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
};
new Thread(enumerator).start();
// 消费者线程
for (int i = 1; i <= SEARCH_THREADS; i++) {
Runnable searcher = () -> {
try {
var done = false;
while (!done) {
// 移出并返回头元素,如果队列为空,则阻塞
Path file = queue.take();
// 搜索线程取到虚拟对象时,将其放回并终止
if (file == DUMMY) {
queue.put(file);
done = true;
} else {
search(file, keyword);
}
}
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
};
new Thread(searcher).start();
}
}
}
/**
* 递归枚举给定目录及其子目录中的所有文件
* @param directory the directory in which to start
* @throws IOException
* @throws InterruptedException
*/
public static void enumerate(Path directory) throws IOException, InterruptedException {
try (Stream<Path> children = Files.list(directory)) {
for (Path child : children.collect(Collectors.toList())) {
if (Files.isDirectory(child)) {
enumerate(child);
} else {
queue.put(child);
}
}
}
}
/**
* 在文件中搜索给定关键字并打印所有匹配行
* @param file the file to serach
* @param keyword the keyword to search for
* @throws IOException
*/
public static void search(Path file, String keyword) throws IOException {
try (var in = new Scanner(file, StandardCharsets.UTF_8)) {
int lineNumber = 0;
while (in.hasNextLine()) {
lineNumber++;
String line = in.nextLine();
if (line.contains(keyword)) {
System.out.printf("%s:%d:%s%n", file, lineNumber, line);
}
}
}
}
}
12.5.2高效的映射、集和队列
java.util.concurrent
包提供了映射、有序集和队列的高效实现:ConcurrentHashMap
、ConcurrentSkipListMap
、ConcurrentSkipListSet
和ConcurrentLinkedQueue
。
与大多数集合不同,这些类的size
方法不一定在常量时间内完成操作。确定这些集合的当前大小通常需要遍历。
有些应用使用庞大的并发散列映射,这些映射太过庞大,以至于无法用size
方法得到它的大小,因为这个方法只能返回int
。这个时候需要使用mappingCount
,将大小作为long
返回。
集合返回弱一致性的迭代器。这意味着迭代器不一定能反映出它们构造之后的所有更改。但是,它们不会将同一个值返回两次,也不会抛出
ConcurrentModificationException
异常。与之形成对照的是,对于java.util
包中的集合,如果集合在迭代器构造之后发生改变,集合的迭代器将抛出一个ConcurrentModificationException
异常。
并发散列映射可以高效地支持大量阅读器和一定数量的书写器。默认情况下认为可以有至多16个同时运行的书写器线程。当然可以有更多的书写器线程,但是,同一时间如果多于16个,其他线程将暂时阻塞。可以在构造器中指定更大数目,不过,通常都没有这种必要。
12.5.3映射条目的原子更新
ConcurrentHashMap
原来的版本只有为数不多的方法可以实现原子更新,这使得编程有些麻烦。例如,多个线程遇到单词时统计它们的频率:
Long oldValue = map.get(word);
Long newValue = oldValue == null ? 1 : oldValue + 1;
map.put(word, newValue); // ERROR--might not replace oldValue
可能会有另一个线程在同时更新同一个计数。
有些程序员很奇怪为什么原本线程安全的数据结构会允许非线程安全的操作。有两种完全不同的情况。如果多个线程修改一个普通的
HashMap
,它们可能会破坏内部结构(一个链表数组)。有些链接可能丢失,或者甚至会构成破坏,使得这个数据结构不再可用。对于ConcurrentHashMap
绝对不会发生这种情况。在上面的例子中,get
和put
代码永远不会破坏数据结构。不过,由于操作序列不是原子的,所以结果不可预知。
在老版本的java中,必须使用
replace
操作,它会以原子方式用一个新值替换原值,前提是之前没有其他线程把原值替换为其他值。必须一直这么做,直到替换成功:
do {
oldValue = map.get(word);
newValue = oldValue == null ? 1 : oldValue + 1;
} while (!map.replace(word, oldValue, newValue));
或者,可以使用一个
ConcurrentHashMap<String, AtomicLong>
:
map.putIfAbsent(word, new AtomicLong());
map.get(word).incrementAndGet();
很遗憾,这会每个自增构造一个新的
AtomicLong
,而不管是否需要。
如今提供了一些新方法,可以更方便地完成原子更新。调用
compute
方法时可以提供一个键和一个计算新值的函数。这个函数接收键和相关联的值(如果没有值,则为null
),它会计算新值。例如,可以如下更新一个整数计数器的映射:
map.compute(word, (k, v) -> v == null ? 1 : v + 1);
需要注意的是,
ConcurrentHashMap
中不允许有null
值。很多方法都使用null
值来指示映射中某个给定的键不存在。
另外还有computeIfPresent
和computeIfAbsent
方法,它们分别只在已经有原值的情况下计算新值,或者只在没有原值的情况下计算新值。可以如下更新一个LongAdder
计数器映射:
map.computeIfAbsent(word, k -> new LongAdder().increment());
这与之前看到的
putIfAbsent
调用几乎是一样的,不过LongAdder
构造器只在确实需要一个新的计数器时才会调用。
首次增加一个键时通常需要做些特殊处理。利用merge
方法可以非常方便地做到这一点。这个方法有一个参数表示键不存在时使用的初始值。否则,就会调用提供的函数来结合原值与初始值(与compute
不同,这个函数不处理键)。
map.merge(word, 1L, (existingValue, newValue) -> existingValue + newValue);
或者,更简单地可以写为:
map.merge(word, 1L, Long::sum);
注释:如果传入
compute
或merge
的函数返回null
,将从映射中删除现有的条目。并且,使用这两个方法时,要记住提供的函数不能做太多的工作。这个函数运行时,可能会阻塞对映射的其他更新。当然,这个函数也不能更新映射的其他部分。
/**
* 这个程序演示了ConcurrentHashMap,用来统计一个目录树的java文件中的所有单词
*/
public class CHMDemo {
public static ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>();
/**
* 将给定文件中的所有单词添加到ConcurrentHashMap
* @param file a file
*/
public static void process(Path file) {
try (var in = new Scanner(file)) {
while (in.hasNext()) {
String word = in.next();
map.merge(word, 1L, Long::sum);
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 返回给定目录的所有子体
* @param rootDir the root directory
* @return a set of all descendants of the root directory
* @throws IOException
*/
public static Set<Path> descendants(Path rootDir) throws IOException {
try (Stream<Path> entries = Files.walk(rootDir)) {
return entries.collect(Collectors.toSet());
}
}
public static void main(String[] args) throws IOException, InterruptedException {
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(processors);
Path pathToRoot = Path.of(".");
for (Path p : descendants(pathToRoot)) {
if (p.getFileName().toString().endsWith(".java")) {
executor.execute(() -> process(p));
}
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
map.forEach((k, v) -> {
if (v >= 10) {
System.out.println(k + " occurs " + v + " times");
}
});
}
}
12.5.4对并发散列映射的批操作
Java API为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安全地执行。批操作会遍历映射,处理遍历过程中找到的元素。这里不会冻结映射的当前快照。除非恰好知道批操作运行时映射不会被修改,否则就要把结果看作是映射状态的一个近似。
有三种不同的操作
search
(搜索)为每个键或值应用一个函数,直到函数生成一个非null
的结果。然后搜索终止,返回这个函数的结果。reduce
(归约)组合所有键或值,这里要使用所提供的一个累加函数。forEach
为所有键或值应用一个函数。每个操作都有四个版本:
- operationKeys:处理键。
- operationValues:处理值。
- operation:处理键和值。
- operationEntries:处理
Map.Entry
对象。对于上述各个操作,需要指定一个参数化阈值。如果映射包含的元素多于这个阈值,就会并行完成批操作。如果希望批操作在一个线程中运行,可以使用阈值
Long.MAX_VALUE
。如果希望用尽可能多的线程运行批操作,可以使用阈值1。
下面先来看search方法。有以下版本:
public <U> U searchKeys(long parallelismThreshold, Function<? super K, ? extends U> searchFunction);
public <U> U searchValues(long parallelismThreshold, Function<? super V, ? extends U> searchFunction);
public <U> U search(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> searchFunction);
public <U> U searchEntries(long parallelismThreshold, Function<Map.Entry<K,V>, ? extends U> searchFunction);
例如,假设希望找出第一个出现次数超过1000次的单词。需要搜索键和值:
String result = map.search(threshold, (k, v) -> v > 1000 ? k : null);
// result会设置为第一个匹配的单词,或者如果搜索函数对所有输入都返回null,则返回null
forEach
方法有两种形式。第一种形式只对各个映射条目应用一个消费者函数,例如:
map.forEach(threshold, (k, v) -> System.out.println(k + " -> " + v));
第二种形式还有一个额外的转换器函数作为参数,要先应用这个函数,其结果会传递到消费者:
map.forEach(threshold,
(k, v) -> k + " -> " + v, // transformer
System.out::println); // consumer
转换器可以用作一个过滤器。只要转换器返回
null
,这个值就会被悄无声息地跳过。例如,只打印值很大的条目:
map.forEach(threshold,
(k, v) -> v > 1000 ? k + " -> " + v : null, // filter and transformer
System.out::println); // the nulls are not passed to the consumer
reduce
操作用一个累加函数组合其输入。例如,计算所有值的总和:
Long sum = map.reduceValues(threshold, Long::sum);
与
forEach
类似,也可以提供一个转换器函数。例如,计算最长的键的长度:
Integer maxLength = map.reduceKeys(threshold,
String::length, // transformer
Integer::max); // accumulator
转换器可以作为一个过滤器,通过返回
null
来排除不想要的输入。例如,统计多少个条目的值大于1000:
Long count = map.reduceValues(threshold, v -> v > 1000 ? 1L : null, Long::sum);
如果映射为空,或者所有条目都被过滤掉,
reduce
操作会返回null
。如果只有一个元素,则返回其转换结果,不会应用累加器。
对于
int
、long
和double
输出还有相应的特殊化操作,分别有后缀ToInt
、ToLong
和ToDoulbe
。需要把输入转换为一个基本类型值,并指定一个默认值和一个累加器函数。映射为空时返回默认值。
long sum = map.reduceValuesToLong(threshold,
Long::longValue, // transformer to primitive type
0, // default value for empty map
Long::sum); // primitive type accumulator
这些特殊化版本与对象版本的操作有所不同,对于对象版本的操作,只需要考虑一个元素。这里不是返回转换得到的元素,而是要与默认值相加。因此,默认值必须是累加器的零元素。
12.5.5并发集视图
假设想要的是一个很大的线程安全的集而不是映射,并没有
ConcurrentHashSet
类,而且肯定不想自己创建这样一个类。当然,可以使用包含假值的ConcurrentHashMap
,不过这会得到一个映射而不是集,而且不能应用Set
接口的操作。
静态newKeySet
方法会生成一个Set<K>
,这实际上是ConcurrentHashMap<K, Boolean>
的一个包装器(所有映射值都为Boolean.TRUE
,不过因为只是要把它用作一个集,所以并不关心映射值)。
Set<String> words = ConcurrentHashMap.<String>newKeySet();
当然,如果原来有一个映射,
keySet
方法可以生成这个映射的键集。这个集是可以更改的。如果删除这个集的元素,键(以及相应的值)也会从映射中删除。不过,向键集增加元素没有意义,因为没有相应的值可以增加。ConcurrentHashMap
还有第二个keySet
方法,它包含一个默认值,为集增加元素时可以使用这个方法:
Set<String> words = map.keySet(1L);
words.add("Java");
12.5.6写数组的拷贝
CopyOnWriteArrayList
和CopyOnWriteArraySet
是线程安全的集合,其中所有更改器会建立底层数组的一个副本。如果迭代访问集合的线程数超过更改集合的线程数,这样的安排是很有用的。当构造一个迭代器的时候,它包含当前数组的一个引用。如果这个数组后来被更改了,迭代器仍然引用旧数组,但是,集合的数组已经替换。因而,原来的迭代器可以访问一致的(但可能过时的)视图,而且不存在任何同步开销。
12.5.7并行数组算法
Arrays
类提供了大量并行化操作。静态Arrays.parallelSort
方法可以对一个基本类型值或对象的数组排序。例如:
var contents = new String(Files.readAllBytes(Path.of("alice.txt")), StandardCharsets.UTF_8); // read file into string
String[] words = contents.split("[\\P{L}]+"); // split along nonletters
Arrays.parallelSort(words);
对对象排序时,可以提供一个
Comparator
:
Arrays.parallelSort(words, Comparator.comparing(String::length));
对于所有方法都可以提供一个范围的边界:
// 查阅发现这个地方好像有点问题
// values.parallelSort(values.length / 2, values.length);
Arrays.parallelSort(words, words.length / 2, words.length);
parallelSetAll
方法会用由一个函数计算得到的值填充一个数组。这个函数接收元素索引,然后计算相应位置上的值。
Arrays.parallelSetAll(values, i -> i % 10);
// fills values with 0 1 2 3 4 5 6 7 8 9 0 1 2...
显然,并行化对这个操作很有好处。这个操作对于所有基本类型数组和对象数组都有相应的版本。
最后还有一个parallelPrefix
方法,它会用一个给定结合操作的相应前缀的累加结果替换各个数组元素。例如,考虑数组[1, 2, 3, 4, ...]
和*
操作。执行Arrays.parallelPrefix(values, (x, y) -> x * y)
之后,数组将包含:
[1, 1
* 2, 1
* 2
* 3, 1
* 2
* 3
* 4, ...]
12.5.8较早的线程安全集合
实际上,集合库中提供了一种不同的机制。任何集合类都可以通过使用同步包装器变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K, V> synchHashMap = Collections.synchronizedMap(new HahsMap<K, V>());
结果集合的方法使用锁加以保护,可以提供线程安全的访问。
应该确保没有任何线程通过原始的非同步方法访问数据结构。要确保这一点,最容易的方法是确定不保存原始对象的任何引用,简单地构造一个集合并立即传递给包装器。
如果希望迭代访问一个集合,同时另一个线程仍有机会更改这个集合,那么仍然需要使用客户端锁定:
synchronized (synchHashMap) {
Iterator<K> iter = synchHashMap.keySet().iterator();
while (iter.hasNext()) ...;
}
如果使用for-each循环,就必须使用同样的代码,因为循环使用了一个迭代器。注意:在迭代过程中,如果另一个线程更改了集合,迭代器会失效,抛出
ConcurrentModificationException
异常。同步仍然是需要的,这样才能可靠地检测出并发修改。
通常最好使用java.util.concurrent
包中定义的集合,而不是同步包装器。特别是,ConcurrentHashMap
经过了精心实现,假如多个线程访问的是不同的桶,它们都能访问ConcurrentHashMap
而不会相互阻塞。经常更改的数组列表是一个例外。在这种情况下,同步的ArrayList
要胜过CopyOnWriteArrayList
。
12.6任务和线程池
构造一个新的线程开销有些大,因为这涉及与操作系统的交互。如果程序中创建了大量的生命期很短的线程,那么不应该把每个任务映射到一个单独的线程,而应该使用线程池。
线程池中包含许多准备运行的线程。为线程池提供一个Runnable
,就会有一个线程调用run
方法。当run
方法退出时,这个线程不会死亡,而是留在池中准备为下一个请求提供服务。
12.6.1Callable
和Future
Runnable
封装一个异步运行的任务,可以把它想象成一个没有参数和返回值的异步方法。Callable
和Runnable
类似,但是有返回值。Callable
接口是一个参数化的类型,只有一个方法call
:
public interface Callable<V> {
V call() throws Exception;
}
Future
保存异步计算的结果。可以启动一个计算,将Future
对象交给某个线程,然后忘掉它。这个Future
对象的所有者在结果计算好之后就可以获得结果:
// 调用会阻塞,直到计算完成
V get();
// 也会阻塞,不过如果在计算完成之前调用超时,会抛出TimeoutException
// 如果运行该计算的线程被中断,这两个方法都将抛出InterruptedException。如果计算已完成,则立即返回
V get(long timeout, TimeUnit unit);
// 如果计算还没有开始,它会被取消而且不再开始。如果计算正在进行,那么当mayInterrupt参数为true时,它就会被中断
void cancel(boolean mayInterrupt);
// 如果任务在完成之前被取消,则返回true
boolean isCanceled();
// 如果计算还在进行,返回false;如果已完成,则返回true
boolean isDone();
取消一个任务涉及两个步骤。必须找到并中断底层线程。另外任务实现(在
call
方法中)必须感知到中断,并放弃它的工作。如果一个Future
对象不知道任务在哪个线程中执行,或者如果任务没有监视执行该任务的线程的中断状态,那么取消任务没有任何效果。
执行
Callable
的一种方法是使用FutureTask
,它实现了Future
和Runnable
接口,所以可以构造一个线程来运行这个任务:
Callable<Integer> task = ...;
var futureTask = new FutureTask<Integer>(task);
var t = new Thread(futureTask); // it's a Runnable
t.start();
// ...
Integer result = task.get(); // it's a Future
更常见的情况是,可以将一个
Callable
传递到一个执行器。
12.6.2执行器
执行器类有许多静态工厂方法,用来构造线程池。
方法 | 描述 |
---|---|
newCachedThreadPool | 必要时创建新线程;空闲线程会保留60秒 |
newFixedThreadPool | 该池包含固定数量的线程;空闲线程会一直被保留 |
newWorkStealingPool | 一种适合fork-join任务的线程池,其中复杂的任务会分解为更简单的任务,空闲线程会密取较简单的任务 |
newSingleThreadExecutor | 只有一个线程的池,该线程顺序执行每一个提交的任务 |
newScheduledThreadPool | 用于调度执行的固定线程池 |
newSingleThreadScheduledExecutor | 用于调度执行的单线程池 |
newCachedThreadPool
方法构造一个线程池,会立即执行各个任务,如果有空闲线程可用,就使用现有空闲线程执行任务;如果没有可用的空闲线程,则创建一个新线程。
newFixedThreadPool
方法构造一个具有固定大小的线程池。如果提交的任务数多于空闲线程数,就把未得到服务的任务放到队列中,当其他任务完成以后再运行这些排队的任务。
newSingleThreadExecutor
是一个退化了的大小为1的线程池:由一个线程顺序地执行所提交的任务(一个接着一个执行)。这3个方法返回实现了ExecutorService
接口的ThreadPoolExecutor
类的对象。如果线程生存期很短,或者大量时间都在阻塞,那么可以使用一个缓存线程池。不过,如果线程工作量很大而且并不阻塞,肯定不希望运行太多线程。
为了得到最优的运行速度,并发线程数等于处理器内核数。在这种情况下,就应当使用固定线程池,即并发线程总数有一个上限。
单线程执行器对于性能分析很有帮助。如果临时用一个单线程池替换缓存或固定线程池,就能测量不使用并发的情况下应用的运行速度会慢多少。
可用下面的方法之一将
Runnable
或Callable
对象提交给ExecutorService
:
Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
Future<T> submit(Runnable task, T result);
线程池会在方便的时候尽早执行提交的任务。调用
submit
时,会得到一个Future
对象,可用来得到结果或者取消任务。
第二个submit
方法返回一个看起来有些奇怪的Future<?>
。可以使用这样一个对象来调用isDone
、cancel
或isCanceled
。但是,get
方法在完成的时候只是简单地返回null
。
第三个版本的submit
也生成一个Future
。它的get
方法在完成的时候返回指定的result
对象。
使用完一个线程池时,调用
shutdown
。这个方法启动线程池的关闭序列。被关闭的执行器不再接受新的任务。当所有任务都完成时,线程池中的线程死亡。另一种方法是调用shutdownNow
。线程池会取消所有尚未开始的任务。
使用连接池时所做的工作:
- 调用
Executors
类的静态方法newCachedThreadPool
或newFixedThreadPool
。- 调用
submit
提交Runnable
或Callable
对象。- 保存好返回的
Future
对象,以便得到结果或者取消任务。- 当不想再提交任何任务时,调用
shutdown
。
ScheduledExecutorService
接口为调度执行或重复执行任务提供了一些方法。这是对支持建立线程池的java.util.Timer
的泛化。Executors
类的newScheduledThreadPool
和newSingleThreadScheduledExecutor
方法返回实现了ScheduledExecutorService
接口的对象。
可以调度Runnable
或Callable
在一个初始延迟之后运行一次。也可以调度Runnable
定期运行。
12.6.3控制任务组
有时,使用执行器有更策略性的原因:需要控制一组相关的任务。例如,可以在执行器中使用
shutdownNow
方法取消所有的任务。
invokeAny
方法提交一个Callable
对象集合中的所有对象,并返回某个已完成任务的结果。不知道返回的究竟是哪个任务的结果,这往往是最快完成的那个任务。对于搜索问题,如果愿意接受任何一种答案,就可以使用这个方法。例如,假定需要对一个大整数进行因数分解,这是RSA解码时需要完成的一种计算。可以提交很多任务,每个任务尝试对不同范围内的数进行分解。只要其中一个任务得到了答案,计算就可以停止了。
invokeAll
方法提交一个Callable
对象集合中的所有对象,这个方法会阻塞,直到所有任务都完成,并返回表示所有任务答案的一个Future
对象列表。得到计算结果后,还可以对结果进行处理:
List<Callable> tasks = ...;
List<Future> results = executor.invokeAll(tasks);
// 在for循环中,第一个result.get()调用会阻塞,直到第一个结果可用。
// 如果所有任务几乎同时完成,这不会有问题
for (Future<T> result : results) {
processFurther(result.get());
}
可以利用
ExecutorCompletionService
来管理计算出结果的顺序。
首先以通常的方式得到一个执行器。然后构造一个ExecutorCompletionService
。将任务提交到这个完成服务。该服务会管理Future
对象的一个阻塞队列,其中包含所提交任务的结果(一旦结果可用,就会放入队列)。因此,要完成之前的计算,以下组织更为高效:
var service = new ExecutorCompletionService<T>(executor);
for (Callable<T> task : tasks) {
service.submit(task);
}
for (int i = 0; i < tasks.size(); i++) {
processFurther(service.taks().get());
}
/**
* This program demonstrates the Callable interface and executors.
*/
public class ExecutorDemo {
/**
* 统计文件中给定单词的出现次数
* @param word
* @param path
* @return the number of times the word occurs in the given word
*/
public static long occurrences(String word, Path path) {
try (var in = new Scanner(path)) {
int count = 0;
while (in.hasNext()) {
if (in.next().equals(word)) {
count++;
}
}
return count;
} catch (IOException e) {
e.printStackTrace();
return 0;
}
}
/**
* 返回给定目录的所有子体
* @param rootDir the root directory
* @return a set of all descendants of the root directory
* @throws IOException
*/
public static Set<Path> descendants(Path rootDir) throws IOException {
try (Stream<Path> entries = Files.walk(rootDir)) {
return entries.filter(Files::isRegularFile).collect(Collectors.toSet());
}
}
/**
* 生成在文件中搜索单词的任务
* @param word the word to search
* @param path the file in which to search
* @return the search task that yields the path upon success
*/
public static Callable<Path> searchForTask(String word, Path path) {
return () -> {
try (var in = new Scanner(path)) {
while (in.hasNext()) {
if (in.next().equals(word)) {
return path;
}
// 当一个任务成功时,其他任务就要取消。因此,要监视中断状态。如果底层线程被中断,
// 搜索任务在终止之前要打印一个消息,使我们能看到其他任务确实已经取消
if (Thread.currentThread().isInterrupted()) {
System.out.println("Search in " + path + " canceled.");
return null;
}
}
// 不希望一个任务失败时就停止搜索,实际上,失败的任务要抛出一个异常
throw new NoSuchElementException();
}
};
}
public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {
try (var in = new Scanner(System.in)) {
System.out.print("Enter base directory (e.g. /opt/jdk-9-src): ");
String start = in.nextLine();
System.out.print("Enter keyword (e.g. volatile): ");
String word = in.nextLine();
Set<Path> files = descendants(Path.of(start));
// 第一部分:统计一个目录树中包含一个给定单词的文件的个数
var tasks = new ArrayList<Callable<Long>>();
// 为每个文件建立一个单独的任务
for (Path file : files) {
Callable<Long> task = () -> occurrences(word, file);
tasks.add(task);
}
// 将任务传递到一个执行器服务
ExecutorService executor = Executors.newCachedThreadPool();
// 改为使用单线程执行器,以查看多个线程是否加快搜索速度
// ExecutorService executor = Executors.newSingleThreadExecutor();
Instant startTime = Instant.now();
List<Future<Long>> results = executor.invokeAll(tasks);
// 为了得到组合后的统计结果,要将所有结果相加,这个工作会阻塞,直到所有结果都可用
long total = 0;
for (Future<Long> result : results) {
total += result.get();
}
Instant endTime = Instant.now();
System.out.println("Occurrences of " + word + ": " + total);
System.out.println("Time elapsed: " + Duration.between(startTime, endTime).toMillis() + " ms");
// 第二部分:搜索包含指定单词的第一个文件
var searchTasks = new ArrayList<Callable<Path>>();
for (Path file : files) {
searchTasks.add(searchForTask(word, file));
}
// 一旦有任务返回,invokeAny方法就会终止,所以不能让搜索任务返回一个boolean来指示成功或失败
Path found = executor.invokeAny(searchTasks);
System.out.println(word + " occurs in: " + found);
if (executor instanceof ThreadPoolExecutor) { // The single thread executor isn't
// 为了提供更多信息,这个程序会打印执行期间线程池的大小,这个信息无法由ExecutorService接口提供。
// 出于这个原因,必须强制转换
System.out.println("Largest pool size: " + ((ThreadPoolExecutor) executor).getLargestPoolSize());
}
executor.shutdown();
}
}
}
12.6.4fork-join框架
有些应用使用了大量线程,但其中大多数都是空闲的。例如,一个web服务器可能会为每个连接分别使用一个线程。另外一些应用可能对每个处理器内核分别使用一个线程,以完成计算密集型任务,如图像或视频处理。Java7中新引入了fork-join框架,专门用来支持后一类应用。
图像处理就是这样一个例子。要增强一个图像,可以变换上半部分和下半部分。如果有足够多空闲的处理器,这些操作可以并行运行。
假设,想统计一个数组中有多少个元素满足某个特定的属性。可以将这个数组一分为二,分别对这两部分进行统计,再将结果相加。
要采用框架可用的一种方式完成这种递归计算,需要提供一个扩展RecursiveTask<T>
的类(如果计算会生成一个类型为T的结果)或者提供一个扩展RecursiveAction
的类(如果不生成任何结果)。再覆盖compute
方法来生成并调用子任务,然后合并其结果。
/**
* This program demonstrates the fork-join framework.
*/
public class ForkJoinTest {
public static void main(String[] args) {
final int SIZE = 10000000;
var numbers = new double[SIZE];
for (int i = 0; i < SIZE; i++) {
numbers[i] = Math.random();
}
var counter = new Counter(numbers, 0, numbers.length, x -> x > 0.5);
var pool = new ForkJoinPool();
pool.invoke(counter);
System.out.println(counter.join());
}
}
class Counter extends RecursiveTask<Integer> {
public static final int THRESHOLD = 1000;
private double[] values;
private int from;
private int to;
private DoublePredicate filter;
public Counter(double[] values, int from, int to, DoublePredicate filter) {
this.values = values;
this.from = from;
this.to = to;
this.filter = filter;
}
@Override
protected Integer compute() {
if (to - from < THRESHOLD) {
int count = 0;
for (int i = from; i < to; i++) {
if (filter.test(values[i])) {
count++;
}
}
return count;
} else {
int mid = (from + to) / 2;
var first = new Counter(values, from, mid, filter);
var second = new Counter(values, mid, to, filter);
invokeAll(first, second);
return first.join() + second.join();
}
}
}
在后台,fork-join框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方法称为工作密取。每个工作线程都有一个双端队列来完成任务。一个工作线程将子任务压入其双端队列的队头(只有一个线程可以访问队头,所以不需要加锁)。一个工作线程空闲时,它会从另一个双端队列的队尾密取一个任务。由于大的子任务都在队尾,这种密取很少出现。
fork-join池是针对非阻塞工作负载优化的。如果向一个fork-join池增加很多阻塞任务,会让它无法有效工作。可以让任务实现ForkJoinPool.ManagedBlocker
接口来解决这个问题,不过这是一种高级技术。