并发
多线程程序在更低一层扩展了多任务的概念:单个程序看起来在同时完成多个任务。每个任务在一个线程中执行,线程是控制线程的简称。如果一个程序可以同时运行多个线程,则称这个程序是多线程的(multithreaded)。
多进程与多线程的本质区别在于每个进程拥有自己的一整套变量,而线程则共享数据。共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。
线程状态
线程可以有如下6种状态:
- New(新建)
- Runnable(可运行)
- Blocked(阻塞)
- Waiting(等待)
- Timed waiting(计时等待)
- Terminated(终止)
新建线程
当用new操作符创建一个新线程时,如new Thread(r),这个线程还没有开始运行。这意味着它的状态是新建(new)。当一个线程处于新建状态时,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做。
可运行线程
一旦调用start方法,线程就处于可运行(runnable)状态。一个可运行的线程可能正在运行也可能没有运行。要由操作系统为线程提供具体的运行时间。(不过,Java规范没有将正在运行作为一个单独的状态。一个正在运行的线程仍然处于可运行状态。)
一旦一个线程开始运行,它不一定始终保持运行。事实上,运行中的线程有时需要暂停,让其他线程有机会运行。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完时,操作系统剥夺该线程的运行权,并给另一个线程一个机会来运行。当选择下一个线程时,操作系统会考虑线程的优先级。
现在所有的桌面以及服务器操作系统都使用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度。在这样的设备中,一个线程只有在调用yield方法或者被阻塞或等待时才失去控制权。
在有多个处理器的机器上,每个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器还是需要分配时间片。
记住,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行。
阻塞和等待线程
当线程处于阻塞或等待状态时,它暂时是不活动的。它不运行任何代码,而且消耗最少的资源。要由线程调度器重新激活这个线程。
- 当一个线程试图获取一个内部对象锁 ,而这个锁目前被其他线程占有,该线程就会被阻塞 。当所有其他线程都释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将变成非阻塞状态 。
- 当线程等待另一个线程通知调度器出现一个条件时,这个线程会进入等待状态 。调用Object.wait方法或Thread.join方法,或者是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。实际上,阻塞状态与等待状态并没有太大区别。
- 有几个方法有超时参数,调用这些方法会让线程进入计时等待 (timed waiting)状态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有Thread.sleep和计时版的Object.wait、Thread.join、Lock.tryLock以及Condition.await。
上图展示了线程可能的状态以及从一个状态到另一个状态可能的转换。当一个线程阻塞或等待时(或终止时),可以调度另一个线程运行。当一个线程被重新激活(例如,因为超时期满或成功地获得了一个锁),调度器检查它是否具有比当前运行线程更高的优先级。如果是这样,调度器会剥夺某个当前运行线程的运行权,选择一个新线程运行。
终止线程
线程会由于以下两个原因之一而终止:
- run方法正常退出,线程自然终止。
- 因为一个没有捕获的异常终止了run方法,使线程意外终止。
具体来说,可以调用线程的stop方法杀死一个线程。该方法抛出一个ThreadDeath错误对象,这会杀死线程。不过stop方法已经废弃,不要在你自己的代码中调用这个方法。
线程属性
中断线程
当线程的run方法执行方法体中最后一条语句后再执行return语句返回时,或者出现了方法中没有捕获的异常时,线程将终止。
除了已经废弃的stop方法,没有办法可以强制线程终止。不过,interrupt方法可以用来请求终止一个线程。
当对一个线程调用interrupt方法时,就会设置线程的中断状态。这是每个线程都有的boolean标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。
要想得出是否设置了中断状态,首先调用静态的Thread.currentThread方法获得当前线程,然后调用isInterrupted方法:
while (!Thread.currentThread().isInterrupted() && ...) {
...
}
但是,如果线程被阻塞,就无法检查中断状态。这里就要引入InterruptedException异常。当在一个被sleep或wait调用阻塞的线程上调用interrupt方法时,那个阻塞调用(即sleep或wait调用)将被一个InterruptedException异常中断。
没有任何语言要求被中断的线程应当终止。中断一个线程只是要引起它的注意。被中断的线程可以决定如何响应中断。某些线程非常重要,所以应该处理这个异常,然后再继续执行。但是,更普遍的情况是,线程只希望将中断解释为一个终止请求。这种线程的run方法具有如下形式:
Runnable r = () -> {
try {
...
while (!Thread.currentThread().isInterrupted() && ...) {
...
}
}
catch (InterruptedExceptino 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 (...) {
...
Thread.sleep(delay);
}
}
catch(InterruptedException e) {
// thread was interrupted during sleep
}
finally {
//cleanup if required
}
// exiting the run method terminated the thread
};
interrupted方法是一个静态方法,它检查当前线程是否被中断。而且,调用interrupted方法会清除该线程的中断状态。另一方面,,isInterrupted方法是一个实例方法,可以用来检查是否有线程被中断。调用这个方法不会改变你中断状态。
不要在代码底层抑制InterruptedException异常:
void mySubTask() {
...
try { sleep(delay); }
catch(InterruptedException e) {}
...
}
不要这样做,有两种合理的选择:
- 在catch子句中调用Thread.currentThread().interrupt()来设置中断状态。这样一来调用者就可以检测中断状态。
void mySubTask() {
...
try { sleep(delay); }
catch(InterruptedException e) { Thread.currentThread().interrupt(); }
...
}
- 或者,更好的选择是,用throw InterruptedException标记你的方法,去掉try语句块。这样一来调用者(或者最终的run方法)就可以捕获这个异常。
void mySubTask() throws InterruptedException {
...
sleep(delay);
...
}
守护线程
可以通过调用:
t.setDaemon(true);
将一个线程转换为守护线程(daemon thread)。守护线程的唯一用途是为其他线程提供服务。当只剩下守护线程时,虚拟机就会退出。因为如果只剩下守护线程,就没必要继续运行程序了。
线程名
可以用setName方法为线程设置任何名字:
var t = new Thread(runnable);
t.setName("aQ");
未捕获异常的处理器
线程的run方法不能抛出任何检查型异常,但是,非检查型异常可能会导致线程终止。在这种情况下,线程会死亡。
不过对于可以传播的异常,并没有catch子句。实际上,在线程死亡之前,异常会传递到一个用于处理未捕获异常的处理器。
这个处理器必须属于一个实现了Thread.UncaughtExceptionHandler接口的类。这个接口只有一个方法。
void uncaughtException(Thread t, Throwable e)
可以用setUncaughtExceptionHandler方法为任何线程安装一个处理器。也可以用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有线程安装一个默认的处理器。
如果没有安装默认处理器,默认处理器则为null。但是,如果没有为单个线程安装处理器,那么处理器就是该线程的ThreadGroup对象。
线程组是可以一起管理的线程的集合。默认情况下,创建的所有线程都属于同一个线程组,但是也可以建立其他的组。由于现在引入了更好的特性来处理线程集合,所以建议不要在自己的程序中使用线程组。
线程优先级
在Java中,每个线程有一个优先级。默认情况下,一个线程会继承构造它的那个线程的优先级。可以用setPriority方法提高或降低任何一个线程的优先级。可以将优先级设置为MIN_PRIORITY(在Thread类中定义为1)与MAX_PRIORITY(定义为10)之间的任何值。NORM_PRIORITY定义为5。
每当线程调度器有机会选择新线程时,它首先选择具有较高优先级的线程。但是,线程优先级高度依赖于系统。当虚拟机依赖于宿主机平台的线程实现时,Java线程的优先级会映射到宿主机平台的优先级,平台的线程优先级别可能比上述的10个级别多,也可能更少。
例如,Windows有7个优先级别。Java的一些优先级会映射到同一个操作系统优先级。在Oracle为Linux提供的Java虚拟机中,会完全忽略线程优先级——所有线程都有相同的优先级。
建议不要使用线程优先级。
同步
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,会发生什么?可以想到,这两个对象会相互覆盖。取决于线程访问数据的次数,可能会导致对象被破坏。这种情况通常称为竞态条件 。
竞态条件的一个例子
下面例子是没有使用同步的情况
package concurrency;
import java.util.Arrays;
public class Bank {
private final double[] accounts;
public Bank(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
}
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());
}
public double getTotalBalance() {
double sum = 0;
for (double a:accounts) {
sum += a;
}
return sum;
}
public int size() {
return accounts.length;
}
}
package concurrency;
public class UnsynchBankTest {
public static final int DELAY = 10;
public static final int NACCOUNTS = 100;
public static final double MAX_AMOUNT = 1000;
public static final double INITIAL_BALANCE = 1000;
public static void main(String[] args) {
Bank bank = new Bank(NACCOUNTS,INITIAL_BALANCE);
for (int i = 0;i < NACCOUNTS;i++) {
int fromAccount = i;
Runnable r = () -> {
int toAccount = (int) (bank.size() * Math.random());
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount,toAccount,amount);
try {
Thread.sleep((int) (DELAY*Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(r);
thread.start();
}
}
}
竞态条件详解
在上面程序中,其中有几个线程会更新银行账户余额。一段时间后,不知不觉地出现了错误,可能有些钱会丢失,也可能几个账户同时有钱进账。当两个线程试图同时更新同一个账户时,就会出现这个问题。假设两个线程同时执行指令:
accounts[to] += amount;
问题在于这不是原子操作。这个指令可能如下处理:
- 将accounts[to]加载到寄存器。
- 增加amount。
- 将结果写回accounts[to]。
现在,假定第1个线程执行步骤1和2,然后,它的运行权被抢占。再假设第2个线程被唤醒,更新account数组中的同一个元素。然后,第1个线程被唤醒并完成其第3步。这个动作会抹去第2个线程所做的更新。这样一来,总金额就不再正确了。
在一个有多个内核的现代处理器上,出问题的风险率相当高,我们将打印语句和更新余额语句交错执行,以提高观察到这种问题的概率。
如果删除打印语句,出问题的风险会降低,因为每个线程在再次休眠之前所做的工作很少,调度器不太可能在线程的计算过程中抢占它的运行权。
真正的问题是transfer方法可能会在执行中间时被中断。如果能够确保线程失去控制之前方法已经运行完成,那么银行账户对象的状态就不会被破坏。
锁对象
有两种机制可防止并发访问代码块。Java语言提供了一个synchronized关键字来达到这一目的,另外Java 5引入了ReentrantLock类。synchronized关键字会自动提供一个锁以及相关的“条件”,对于大多数需要显式锁的情况,这种机制功能很强大,也很便利。
用ReentrantLock保护代码块的基本结构如下:
myLock.lock(); // a ReentrantLock object
try {
critical section
}
finally {
myLock.unlock(); //make sure the lock is unlock even if an exception is thrown
}
这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程锁定了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们会暂停,直到第一个线程释放这个锁对象。
要把unlock操作包括在finally子句中,这一点至关重要。如果在临界区的代码抛出一个异常,锁必须释放。否则,其他线程将永远阻塞。
使用锁时,就不能使用try-with-resources语句
使用一个锁来保护Bank的transfer方法。
package concurrency.reentrantlock;
import java.util.Arrays;
import java.util.concurrent.locks.ReentrantLock;
public class Bank1 {
private final double[] accounts;
private ReentrantLock bankLock = new ReentrantLock();
public Bank1(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
}
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();
}
}
public double getTotalBalance() {
double sum = 0;
for (double a:accounts) {
sum += a;
}
return sum;
}
public int size() {
return accounts.length;
}
}
package concurrency.reentrantlock;
public class BankTest {
public static final int DELAY = 10;
public static final int NACCOUNTS = 100;
public static final double MAX_AMOUNT = 1000;
public static final double INITIAL_BALANCE = 1000;
public static void main(String[] args) {
Bank1 bank = new Bank1(NACCOUNTS,INITIAL_BALANCE);
for (int i = 0;i < NACCOUNTS;i++) {
int fromAccount = i;
Runnable r = () -> {
int toAccount = (int) (bank.size() * Math.random());
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount,toAccount,amount);
try {
Thread.sleep((int) (DELAY*Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(r);
thread.start();
}
}
}
输出:
注意每个Bank对象都有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank对象,那么锁可以用来保证串行化访问。不过,如果两个线程访问不同的Bank对象,每个线程会得到不同的锁对象,两个线程都不会阻塞。本该如此,因为线程在操作不同的Bank实例时,线程之间不会相互影响。
这个锁称为重入锁 ,因为线程可以反复获得已拥有的锁。锁有一个持有计数(hold count)来跟踪对lock方法的嵌套调用。线程每一次调用lock后都要调用unlock来释放锁。由于这个特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。
例如,transfer方法调用getTotalBalance方法,这也会封锁bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出时,持有计数变回1.当transfer方法退出的时候,持有计数变为0,线程释放锁。
通常我们可能希望保护会更新或检查共享对象的代码块,从而能确信当前操作执行完之后其他线程才能使用同一个对象。
条件对象
通常,线程进入临界区后却发现只有满足了某个条件之后它才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。
现在优化银行的模拟程序。如果一个账户没有足够的资金转账,我们不希望从这样的账户转出资金。注意不能使用类似下面代码:
if (bank.getBalance(from) >= amount)
bank.transfer(from,to,amount);
在成功通过这个测试之后,但在调用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方法获得一个条件对象。习惯上会给每个条件对象一个合适的名字来反映它表示的条件。
class Bank {
private Condition sufficientFunds;
...
public Bank() {
...
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呢?从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll。例如,当一个账户余额发生改变时,就应该再给等待的线程一个机会来检查余额。
public void transfer(int from, int to, int amount) {
bankLock.lock();
try {
while (accounts[from] < amount) {
sufficientFunds.await();
}
// tranfer funds
...
sufficientFunds.signalAll();
}
finally {
bankLock.unlock();
}
}
注意signalAll调用不会立即激活一个等待的线程。它只是解除等待线程的阻塞,使得这些线程可以在当前线程释放锁之后竞争访问对象。
另一个方法signal只是随机选择等待集中的一个线程,并解除这个线程的阻塞状态。这比解除所有线程的阻塞更高效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,它就会再次阻塞。如果没有其他线程再次调用signal,系统就会进入死锁。
只有当线程拥有一个条件的锁时,它才能在这个条件上调用await、signalAll或signal方法。
package concurrency.condition;
import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class Bank2 {
private final double[] accounts;
private ReentrantLock bankLock;
private Condition sufficientFunds;
public Bank2(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
bankLock = new ReentrantLock();
sufficientFunds = bankLock.newCondition();
}
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();
}
}
public double getTotalBalance() {
bankLock.lock();
try {
double sum = 0;
for (double a:accounts) {
sum += a;
}
return sum;
}
finally {
bankLock.unlock();
}
}
public int size() {
return accounts.length;
}
}
package concurrency.condition;
public class BankTest {
public static final int DELAY = 10;
public static final int NACCOUNTS = 100;
public static final double MAX_AMOUNT = 1000;
public static final double INITIAL_BALANCE = 1000;
public static void main(String[] args) {
Bank2 bank = new Bank2(NACCOUNTS,INITIAL_BALANCE);
for (int i = 0;i < NACCOUNTS;i++) {
int fromAccount = i;
Runnable r = () -> {
int toAccount = (int) (bank.size() * Math.random());
double amount = MAX_AMOUNT * Math.random();
try {
bank.transfer(fromAccount,toAccount,amount);
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
Thread.sleep((int) (DELAY*Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(r);
thread.start();
}
}
}
对锁和条件的要点做一个总结:
- 锁用来保护代码片段,一次只能有一个线程执行被保护的代码。
- 锁可以管理试图进入被保护代码段的线程。
- 一个锁可以有一个或多个相关联的条件对象。
- 每个条件对象管理那些已经进入被保护代码段但不能运行的线程。
synchronized关键字
Lock和Condition接口允许程序员充分控制锁定。不过,大多数情况下,你并不需要那样控制,完全可以使用Java语言内置的一种机制。从1.0版开始,Java中的每个对象都有一个内部锁 。如果一个方法声明时有synchronized关键字,那么对象的锁将保护整个方法。也就是说,要调用这个方法,线程必须获得内部对象锁。
换句话说,
public synchronized void method() {
...
}
等价于
public void method() {
this.intrinsicLock.lock();
try {
...
}
finally {
this.intrinsicLock.unlock();
}
}
内部对象锁只有一个关联条件。wait方法将一个线程增加到等待集中,notifyAll/notify方法可以解除等待线程的阻塞。换句话说,调用wait或notifyAll等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();
wait、notifyAll以及notify方法是Object类的final方法。Condition方法必须命名为await、signalAll和signal,从而不会与那些方法发生冲突。
例如:
class Bank {
private double[] accounts;
public synchronized void transfer(int from, int to, int amount) throws InterruptedException {
while (accounts[from] < amount)
wait();
accounts[from] -= amount;
accounts[to] += amount;
notifyAll();
}
public synchronized double getTotalBalance() { ... }
}
可以看到,使用synchronized关键字可以得到更为简洁的代码。当然,要理解这个代码,你必须知道每个对象都有一个内部锁,并且这个锁有一个内部条件。这个锁会管理试图进入synchronized方法的线程,这个条件可以管理调用wait的线程。
将静态方法声明为同步方法也是合法的。如果调用这样一个方法,它会获得相关类对象的内部锁。例如,如果Bank有一个静态同步方法,那么当调用这个方法时,Bank.class对象锁会锁定。因此,没有其他线程可以调用这个类的该方法或任何其他同步静态方法。
内部锁和条件存在一些限制。包括:
- 不能中断一个正在尝试获得锁的线程。
- 不能指定尝试获得锁时的超时时间。
- 每个锁仅有一个条件可能是不够的。
在代码中应该使用Lock和Condition还是同步方法?
- 最好既不使用Lock/Condition也不使用synchronized关键字。在许多情况下,可以使用java.util.concurrent包中的某种机制,它会为你处理所有的锁定。
- 如果synchronized关键字适合你的程序,那么尽量使用这种做法,这样可以减少编写的代码量,还能减少出错概率。
- 如果特别需要Lock/condition结构提供的额外能力,则使用Lock/Condition。
package concurrency.sync1;
import java.util.Arrays;
public class Bank3 {
private final double[] accounts;
public Bank3(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
}
public synchronized void transfer(int from, int to, double amount) throws InterruptedException {
while (accounts[from] < amount)
wait();
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();
}
public synchronized double getTotalBalance() {
double sum = 0;
for (double a:accounts) {
sum += a;
}
return sum;
}
public int size() {
return accounts.length;
}
}
package concurrency.sync1;
public class BankTest {
public static final int DELAY = 10;
public static final int NACCOUNTS = 100;
public static final double MAX_AMOUNT = 1000;
public static final double INITIAL_BALANCE = 1000;
public static void main(String[] args) {
Bank3 bank = new Bank3(NACCOUNTS,INITIAL_BALANCE);
for (int i = 0;i < NACCOUNTS;i++) {
int fromAccount = i;
Runnable r = () -> {
int toAccount = (int) (bank.size() * Math.random());
double amount = MAX_AMOUNT * Math.random();
try {
bank.transfer(fromAccount,toAccount,amount);
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
Thread.sleep((int) (DELAY*Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(r);
thread.start();
}
}
}
同步块
每个Java对象都有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁:即进入一个同步块。当线程进入如下形式的块中:
synchronized (obj) //this is the syntax for a synchronized block
{
critical section
}
它会获得obj的锁。
volatile字段
有时,如果只是为了读写一两个实例字段而使用同步,所带来的开销好像有些划不来。
- 有多处理器的计算机能够暂时在寄存器或本地内存缓存中保存内存值。其结果是,运行在不同处理器上的线程可能看到同一个内存位置有不同的值。
- 编译器可以改变指令执行的顺序以使吞吐量最大化。编译器不会选择可能改变代码语义的顺序,但是编译器有一个假定,认为内存值只在代码中有显式修改指令时才会改变。然而,内存值有可能被另一个线程改变。
如果你使用锁来保护可能被多个线程访问的代码,那么不存在这种问题。编译器被要求在必要的时候刷新本地缓存来支持锁,而且不能不相应地重新排列指令顺序。
volatile关键字为实例字段的同步访问提供了一种免锁机制。如果声明一个字段为volatile,那么编译器和虚拟机就知道该字段可能被另一个线程并发更新。
private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }
编译器会插入适当的代码,以确保如果一个线程对done变量做了修改,这个修改对读取这个变量的所有其他线程都可见。
volatile 不能保证原子性。
final变量
还有一种情况可以安全地访问一个共享字段,即这个字段声明为final时。考虑以下声明:
final Map<String,Double> accounts = new HashMap<>();
其他线程会在构造器完成构造之后才看到这个accounts变量。
如果不使用final,就不能保证其他线程看到的是accounts更新后的值,它们可能都只是看到null,而不是新构造的HashMap。
当然,对这个映射的操作并不是线程安全的。如果有多个线程更改和读取这个映射,仍然需要进行同步。
原子性
假设对共享变量除了赋值之外并不做其他操作,那么可以将这些共享变量声明为volatile。
java.util.concurrent.atomic包中有很多类使用了很高效的机器指令(而没有使用锁)来保证其他操作的原子性。
线程局部变量
有时可能要避免共享变量,使用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 = new ThreadLocal.withInitial(()->new SimpleFateFormat("yyyy-MM-dd"));
要访问具体的格式化方法,可以调用:
String dateStamp = dateFormat.get().format(new Date());
在一个给定线程中首次调用get时,会调用构造器中的lambda表达式。在此之后,get方法会返回属于当前线程的那个实例。
线程安全的集合
阻塞队列
很多线程问题可以用一个或多个队列以优雅而安全的方式来描述。生产者线程向队列插入元素,消费者线程则获取元素。使用队列作为一种同步机制,可以安全地从一个线程向另一个线程传递数据。
当试图向队列添加元素而队列已满,或是想从队列移出元素而队列为空的时候,阻塞队列(blocking queue)将导致线程阻塞。在协调多个线程之间的合作时,阻塞队列是一个有用的工具。工作线程可以周期性地将中间结果存储在阻塞队列中。其他工作线程移除中间结果,并进一步修改。队列会自动平衡负载。
阻塞队列方法分为以下3类,这取决于当队列满或空时它们完成的动作。如果使用队列作为线程管理工具,将要用到put和take方法。当试图向满队列添加元素或想从空队列得到队头元素时,add、remove和element操作会抛出异常。当然,在一个多线程程序中,队列可能会在任何时候变空或变满,因此,应当使用offer、poll和peek方法作为替代。如果不能完成任务,这些方法只是给出一个错误提示而不会抛出异常。
java.util.concurrent包提供了阻塞队列的几个变体。默认情况下,LinkedBlockingQueue的容量没有上界,但是,也可以选择指定一个最大容量。LinkedBlockingDeque是一个双端队列。ArrayBlockingQueue在构造时需要指定容量,并且有一个可选的参数来指定是否需要公平性。若设置了公平参数,那么等待了最长时间的线程会优先得到处理。通常,公平性会降低性能,只有在确实非常需要时才使用公平参数。
PriorityBlockingQueue是一个优先队列,而不是先进先出队列。元素按照它们的优先级顺序移除。这个队列没有容量上界。
高效的映射、集和队列
java.util.concurrent包提供了映射、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet和ConcurrentLinkedQueue。
这些集合使用复杂的算法,通过允许并发地访问数据结构中的不同部分尽可能减少竞争。
任务和线程池
构造一个新的线程开销有些大,因为这涉及与操作系统的交互。如果你的程序中创建了大量的生命周期很短的线程,那么不应该把每个任务映射到一个单独的线程,而应该使用线程池(thread pool)。线程池中包含许多准备运行的线程。为线程池提供一个Runnable,就会有一个线程调用run方法。当run方法退出时,这个线程不会死亡,而是留在池中准备为下一个请求提供服务。
Callable与Future
Runnable封装一个异步运行的任务,可以把它想象成一个没有参数和返回值的异步方法。Callable与Runnable类似,但是有返回值。Callable接口是一个参数化的类型,只有一个方法call。
public interface Callable<V> {
V call() throws Exception;
}
类型参数是返回值的类型。例如,Callable<Integer>表示一个最终返回Integer对象的异步计算。
Future保存异步计算的结果。可以启动一个计算,将Future对象交给某个线程,然后忘掉它。这个Future对象的所有者在结果计算好之后就可以获得结果。
Future<V>接口的方法:
V get()
V get(long timeout, TimeUnit unit)
void cancel(boolean mayInterrupt)
boolean isCancelled()
boolean isDone()
第一个get方法的调用会阻塞,直到计算完成。第二个get方法也会阻塞,不过如果在计算完成之前调用超时,会抛出一个TimeoutException异常。如果运行该计算的线程被中断,这两个方法都将抛出InterruptedException。如果计算已经完成,那么get方法立即返回。
如果计算还在进行,isDone方法返回false;如果已经完成,则返回true。
可以用cancel方法取消计算。如果计算还没有开始,它会被取消而且不再开始。如果计算正在进行,那么如果mayInterrupt参数为true,它就会被中断。
执行Callable的一种方法是使用FutureTask,它实现了Future和Runnable接口,所以可以构造一个线程来运行这个任务:
Callable<Integer> task = ...;
var futureTask = new FutureTask<Integer>(task);
var t = new Thread(futureTask);
t.start();
...
Integer res = task.get();
更常见的情况是,可以将一个Callable传递到一个执行器。
执行器
执行器(Executors)类有许多静态工厂方法,用来构造线程池,如下:
方法 | 描述 |
---|---|
newCachedThreadPool | 必要时创建新线程;空闲线程会保留60秒 |
newFixedThreadPool | 池中包含固定数目的线程;空闲线程会一直保留 |
newWorkStealingPool | 一种适合“fork-join”任务的线程池,其中复杂的任务会分解为更简单的任务,空闲线程会“密取”较简单的任务 |
newSingleThreadExecutor | 只有一个线程的“池”,会顺序执行所提交的任务 |
newScheduledThreaPool | 用于调度执行的固定线程池 |
newSingleThreadScheduledExecutor | 用于调度执行的单线程“池” |
newCacheThreadPool方法构造一个线程池,会立即执行各个任务,如果有空闲线程可用,就使用现有空闲线程执行任务;如果没有可用的空闲线程,则创建一个新线程。newFixedThreadPool方法构造一个具有固定大小的线程池。如果提交的任务数多于空闲线程数,就把未得到服务的任务放到队列中。当其他任务完成以后再运行这些排队的任务。newSingleThreadExecutor是一个退化了的大小为1的线程池:由一个线程顺序地执行所提交的任务(一个接着一个执行)。这三个方法返回实现了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或isCancelled。但是,get方法在完成的时候只是简单地返回null。
第三个版本的submit也生成一个Future,它的get方法在完成的时候返回指定的result对象。
使用完一个线程池时,调用shutdown。这个方法启动线程池的关闭序列。被关闭的执行器不再接受新的任务。当所有任务都完成时,线程池中的线程死亡。另一种方法是调用shutdownNow。线程池会取消所有尚未开始的任务。
下面总结了在使用连接池时所做的工作:
- 调用Executor类的静态方法newCachedThreadPool或newFixedThreadPool。
- 调用submit提交Runnable或Callable对象。
- 保存好返回的Future对象,以便得到结果或者取消任务。
- 当不想再提交任何任务时,调用shutdown。
ScheduledExecutorService接口为调度执行或重复执行任务提供了一些方法。这是对支持建立线程池的java.util.Timer的泛化。Executor类的newScheduledThreadPool和newSingleThreadScheduledExecutor方法返回实现ScheduledExecutorService接口的对象。
控制任务组
由上述可知使用一个执行器服务作为线程池可以提高任务执行的效率。有时,使用执行器有更策略性的原因:需要控制一组相关的任务。例如,可以在执行器中使用shutdownNow方法取消所有的任务。
invokeAny方法提交一个Callable对象集合中的所有对象,并返回某个已完成任务的结果。我们不知道返回的究竟是哪个任务的结果,这往往是最快完成的那个任务。对于搜索问题,如果我们愿意接受任何一种答案,就可以使用这个方法。
invokeAll方法提交一个Callable对象集合中的所有对象,这个方法会阻塞,直到所有任务都完成,并返回表示所有任务答案的一个Future对象列表。得到计算结果后,还可以像下面这样对结果进行处理:
List<Callable<T>> task = ...;
List<Future<T>> results = executor.invokeAll(tasks);
for (Future<T> result : results)
processFurther(result.get());
在for循环中,第一个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.take().get());
}