第12章 并发
你可能已经很熟悉多任务(multitasking),这是操作系统的一种能力,看起来可以在同一时刻运行多个程序。例如,在编辑或下载邮件的同时可以打印文件。如今,人们往往都有多 CPU 的计算机,但是,并发执行的进程数目并不受限于 CPU 数目。操作系统会为每个进程分配 CPU 时间片,给人并行处理的感觉。
我们可以在一个或多个 CPU 的操作系统中同时运行多个进程,每个进程 CPU 会分配时间片,给我们的感觉是这些任务都可以同时运行。
多线程程序在更低一层扩展了多任务的概念:单个程序看起来在同时完成多个任务。每个任务在一个线程(thread)中执行,线程是控制线程的简称。如果一个程序可以同时运行多个线程,则称这个程序是多线程的(multithreaded)。
什么样的程序才能称得上是多线程的,只有能控制多个线程的应用程序才可以。
那么,多进程与多线程有哪些区别呢?本质的区别在于每个进程都拥有自己的一整套变量,而线程则共享数据。这听起来似乎有些风险,的确也是这样,本章稍后将介绍这个问题。不过,共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小很多。
也就是说,进程的数据是隔离的,而线程是共享的。
在实际应用中,多线程非常有用。例如,一个浏览器可以同时下载几幅图片。一个 Web 服务器需要同时服务并发的请求。图形用户界面(GUI)程序用一个独立的线程从宿主操作环境收集用户界面事件。本章将介绍如何为 Java 应用程序添加多线程功能。
多线程可以在很多的场景中应用
温馨提示:多线程编程可能会变得相当复杂。本章涵盖了应用程序员可能需要的所有工具。尽管如此,对于更复杂的系统级程序设计,建议参看更高级的参考文献,例如,Brian Goetz 等撰写的《Java Concurrency in Pratice》(Addison-Wesly Professional,2006)。
这里学习的内容比较基础,学完这里之后可以看一下比较高级的书,我目前推荐《Java并发编程的艺术》。
12.1 什么是线程
首先来看一个使用了两个线程的简单程序。这个程序可以在银行账户之间完成资金转账。我们使用了一个 Bank 类,它可以存储给定数目的账户的余额。transfer 方法将一定金额从一个账户转移到另一个账户。具体实现见程序清单 12-2。
这里演示了有两个线程的例子,非常简单的资金转账的例子。
程序清单 12-1
package threads;
/**
* @author Cay Horstmann
* @version 1.30 2004-08-01
*/
public class ThreadTest {
public static final int DELAY = 10;
public static final int STEPS = 100;
public static final double MAX_AMOUNT = 1000;
public static void main(String[] args) {
Bank bank = new Bank(4, 100000);
Runnable task1 = () -> {
try {
for (int i = 0; i < STEPS; i++) {
double amount = MAX_AMOUNT * Math.random();
bank.transfer(0, 1, amount);
Thread.sleep((long) (DELAY * Math.random()));
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Runnable task2 = () -> {
try {
for (int i = 0; i < STEPS; i++) {
double amount = MAX_AMOUNT * Math.random();
bank.transfer(2, 3, amount);
Thread.sleep((long) (DELAY * Math.random()));
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
new Thread(task1).start();
new Thread(task2).start();
}
}
程序清单 12-2
package threads;
import java.util.Arrays;
/**
* 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;
}
}
这里先把代码放出来,以便理解,原文是将代码放的很靠后
在第一个线程中,我们将钱从账户 0 转移到账户 1。第二个线程将钱从账户 2 转移到账户 3。
两个线程分别实现了什么目标。
下面是在一个单独的线程中运行一个任务的简单过程:
- 将执行这个任务的代码放在一个类的 run 方法中,这个类要实现 Runnable 接口。Runnable 接口非常简单,只有一个方法:
public interface Runnable {
public abstract void run();
}
由于 Runnable 是一个函数式接口,可以用一个 lambda 表达式创建一个实例:
Runnable r = () -> { task code };
我一般是使用 IDEA 的 IDE 编辑器,写的时候直接将接口 new 出来,将里面要实现的接口实现了,这时候 IDE会提示你可以省略new 的过程,使用 IDE 自动功能可以方便我们实现 lambda,不需要去记忆写法。
- 从这个 Runnable 构造一个 Thread 对象:
Thread t = new Thread(r);
- 启动线程:
t.start();
为了建立单独的线程来完成转账,我们只需要把转账代码放在一个 Runnable 的 run 方法中,然后启动一个线程:
Runnable r = () -> {
try {
for (int i = 0; i < 100; i++) {
double amount = MAX_AMOUNT * Math.random();
bank.transfer(0, 1, amount);
Thread.sleep((long) (DELAY * Math.random()));
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread t = new Thread(r);
t.start();
上面的代码只是展示了部分代码。
对于给定的步骤数,这个线程会转账一个随机金额,然后休眠一个随机的延迟时间。
我们要捕获 sleep 方法有可能抛出的 InterruptException
异常。这个异常会在 12.3.1 节讨论。一般来说,中断用来请求终止一个线程。相应地,出现 InterruptedException
时,run 方法会退出。
程序还会启动第二个线程,它从账户 2 向账户 3 转账。运行这个程序时,可以得到类似这样的输出:
我们这里可以运行示例代码12-1和12-2的代码
Thread[Thread-1,5,main] 907.97 from 2 to 3 Total Balance: 400000.00
Thread[Thread-0,5,main] 883.06 from 0 to 1 Total Balance: 400000.00
Thread[Thread-1,5,main] 188.52 from 2 to 3 Total Balance: 400000.00
Thread[Thread-0,5,main] 469.69 from 0 to 1 Total Balance: 400000.00
Thread[Thread-0,5,main] 547.20 from 0 to 1 Total Balance: 400000.00
Thread[Thread-0,5,main] 942.84 from 0 to 1 Total Balance: 400000.00
Thread[Thread-0,5,main] 183.58 from 0 to 1 Total Balance: 400000.00
Thread[Thread-1,5,main] 501.09 from 2 to 3 Total Balance: 400000.00
Thread[Thread-1,5,main] 65.10 from 2 to 3 Total Balance: 400000.00
Thread[Thread-0,5,main] 324.28 from 0 to 1 Total Balance: 400000.00
Thread[Thread-1,5,main] 127.08 from 2 to 3 Total Balance: 400000.00
......
可以看到,两个线程的输出是交错的,这说明它们在并发运行。实际上,两个输出行交错显示时,输出有时会有些混乱。
这里只是告诉我们,证明了这两个线程是交错运行的。
你要了解的就是这些!现在你已经知道了如何并发地运行任务。这一章余下的部分会介绍如何控制线程之间的交互。
这里告诉我们这里只能证明线程是并行执行的,其他要等到后面才能告诉我们。
程序的完整代码见程序清单 12-1。
程序清单 12-1 已经在上面了。
注释:还可以通过建立 Thread 类的一个子类来定义线程,如下所示:
class MyThread extends Thread
{
public void run()
{
task code
}
}
然后可以构建这个子类的一个对象,并调用它的 start 方法。不过,现在不再推荐这种方法。应当把要并行运行的任务与运行机制解耦合。如果有多个任务,为每个任务分别创建一个单独的线程开销会太大。实际上,可以使用一个线程池,参见 12.6.2 节的介绍。
警告:不要调用 Thread 类或 Runnable 对象的 run 方法。直接调用 run 方法只会在同一个线程中执行这个任务——而没有启动新的线程。实际上,应当调用
Thread.start
方法,这会创建一个执行 run 方法的新线程。
12.2 线程状态
线程可以有如下 6 种状态:
12.3 线程属性
下面几节将讨论线程的各种属性,包括中断的状态、守护线程、未捕获异常的处理器以及不应使用的一些遗留特性。
12.3.1 中断线程
当线程的 run
方法执行方法体重最后一条语句后再执行 return
语句返回时,或者出现了方法中没有捕获的异常时,线程将终止。在 Java 的早期版本中,还有一个 stop
方法,其他线程可以调用这个方法来终止一个线程。但是这个方法现在已经被废弃了。12.4.13 节将讨论它被废弃的缘由。
这里解释了如果我们要让正在执行的方法中断,需要什么办法,我的理解,中断即结束,也就是说不能再恢复了,这里给我们提供了两种办法,第一种就是run 方法体中的代码完全执行完了,这很好理解,第二个就是在run 方法的执行中出现了未捕获的异常,会导致线程执行的中断。这里暂时不演示了,后面有详细的讲解。
除了已经废弃的 stop 方法,没有办法可以强制线程终止。不过,interrupt 方法可以用来请求终止一个线程。
我们没有办法强制一个线程停止运行了,通过外力的方式不行了,但是我们可以请求它终止,当然请求了也不一定终止,所以这得看情况了,后面会介绍具体情况。
当对一个线程调用 interrupt 方法时,就会设置线程的中断状态。这是每个线程都有的 boolean 标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。
这个标志是我们自己给当前线程或者是我们自己从外面给某个线程设置的标志,我们的线程在运行的时候,应该经常主动的通过代码来验证一下这个标志,以便达到让我们的程序自己停止的目的。
要想得出是否设置了中断状态,首先调用静态的 Thread.currentThread
方法获得当前线程,然后调用 isInterrupted
方法:
我们可以通过
Thread.currentThread
获取线程,再从这个线程中获取isInterrupted
方法来判断当前线程是否被设置了终止标志。
while (!Thread.currentThread().isInterrupted() && more work to od) {
do more work
}
但是,如果线程被阻塞,就无法检查中断状态。这里就要引入 InterruptedException
异常。当在一个被 sleep
或 wait
调用阻塞的线程上调用 interrupt
方法时,那个阻塞调用(即 sleep
或 wait
调用)将被一个 InterruptedException
异常中断。(有一些阻塞 I/O 调用不能被中断,对此应该考虑选择可中断的调用。有关细节请参看卷 2 的第 2 章和第 4 章。)
比如我们的代码像上面一样,只有
while
方法体中的代码执行完成后,才能检查中断标志,那代码很有可能阻塞在while
方法体中,而一直无法走到while
中的判断语句中。如果我们的线程正在被 sleep 或 wait 阻塞,我们调用 interrupt 方法的时候,就会抛出InterruptedException
异常了,其实我们可以看一下代码,在方法声明的时候其实都声明抛出InterruptedException
异常的,所以这里可以总结为,如果我们的线程被一些声明了InterruptedException
的方法阻塞了,那么调用 interrupt 方法,该线程就会直接抛出InterruptedException
异常了。所以,我们这节在开头的时候说了,如果线程在遇到未捕获的异常的时候,会终止运行,所以这个抛出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
}
这意思也就是说,即使我们调用了 interrupt 函数,线程也不是必须要停下来的。这只是为了告诉线程一个信号而已。就像上面的代码展示的那样,即使看到了标志是中断的,我们也可以不中断,当然也可以直接在代码中判断为终止,如果是比较重要的线程,就必须考虑在调用了 interrupt 函数以后会不会抛出
InterruptedException
异常了,所以这个时候我们应该是 catch 住这个异常,处理好后退出。
如果在每次工作迭代之后都调用 sleep 方法(或者其他可中断方法),isInterrupted
检查既没有必要也没有用处。如果设置了中断状态,此时倘若调用 sleep 方法,它不会休眠。实际上,它会清除中断状态(!)并抛出 InterruptedException
。因此,如果你的循环调用了 sleep,不要检测中断状态,而应当捕获 InterruptedException
异常,如下所示:
这里的意思是,如果我们的线程代码中有类似会抛出
InterruptedException
的代码,就没必要再去判断什么isInterrupted
的状态了,因为很可能是没有用的,还不如去捕获InterruptedException
异常。
Runnable r = () -> {
try {
...
while (more work to do) {
do more work
Thread.sleep(delay);
}
} catch (InterruptedException e) {
// thread was interrupted during sleep
} finally {
cleanup, if required
}
// exiting the run method terminates the thread
};
注释:有两个非常类似的方法,
interrupted
和isInterrupted
。interrupted
方法是一个静态方法,它检查当前线程是否被中断。而且,调用interrupted
方法会清除该线程的中断状态。另一方面,isInterrupted
方法是一个实例方法,可以用来检查是否有线程被中断。调用这个方法不会改变中断状态。
12.4 同步
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,会发生什么呢?可以想见,这两个线程会相互覆盖。取决于线程访问数据的次序,可能会导致对象被破坏。这种情况称为竞态条件(race condition)。
两个线程竞争同一块内存,操作同一块内存的数据,会产生竞争的关系。
12.4.1 竞态条件的一个例子
为了避免多线程破坏共享数据,必须学习如何同步存取。在本节中,你会看到如果没有使用同步会发生什么。在下一节中,你将会看到如何同步数据存取。
多线程有时候会破坏共享数据,如果没有很好的处理,会产生问题。
在下面的测试程序中,还是考虑我们模拟的银行。与 12.1 节中的例子不同,我们要随机地选择从哪个源账户转账到哪个目标账户。由于这会产生问题,所以下面再来仔细查看 Bank 类 transfer 方法的代码。
public void transfer(int from, int to, double amount) {
// CAUTIO: unsafe when called from multiple threads
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());
}
程序清单 12-3
package unsynch;
import threads.Bank;
/**
* This program shows data corruption when multiple threads access a data structure.
*
* @author Cay Horstmann
* @version 1.32 2018-04-10
*/
public class UnsynchBankTest {
public static final int NACCOUNTS = 100;
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) {
Bank bank = new Bank(NACCOUNTS, INITIAL_BALANCE);
for (int i = 0; i < NACCOUNTS; i++) {
int fromAccount = i;
Runnable r = new Runnable() {
@Override
public void run() {
try {
while (true) {
int toAccount = (int) (bank.size() * Math.random());
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount, toAccount, amount);
Thread.sleep((long) (DELAY * Math.random()));
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread t = new Thread(r);
t.start();
}
}
}
这里面 Bank 在初始化的时候,会为每个账户都分配 1000 块钱,然后循环运行 100 个线程,因为循环的个数和账户的个数是相等的,每个线程都基本是随机的从一个账户向另外的账户转账,运行可以看到结果账户总额不是总是正确的。
12.4.2 竞态条件详解
上一节中运行了一个程序,其中有几个线程会更新银行账户余额。一段时间之后,不知不觉地出现了错误,可能有些钱会丢失,也可能几个账户同时有钱进账。当两个线程试图同时更新同一个账户时,就会出现这个问题。假设两个线程同时执行指令
accounts[to] += amount;
上面运行的结果就可以看出来,同时运行的时候,账户余额是不对的。
注释:实际上可以查看执行这个类中每一个语句的虚拟机字节码。运行以下命令
javap -c -v Bank
实际上执行的命令是
javap -c -v Bank.class
这里我建议加上.class
对
Bank.class
文件进行反编译。例如,代码行
accounts[to] += amount;
会转换为下面的字节码:
D:\IdeaProjects\untitled1\target\classes\threads>javap -c -v Bank 警告: 文件 .\Bank.class 不包含类 Bank Classfile /D:/IdeaProjects/untitled1/target/classes/threads/Bank.class Last modified 2021年3月19日; size 1442 bytes SHA-256 checksum 81004a4d4033d9f6db32543f79b5fbc2ba904eb575f37a756d8aaf3c59385ee7 Compiled from "Bank.java" public class threads.Bank minor version: 0 major version: 59 flags: (0x0021) ACC_PUBLIC, ACC_SUPER this_class: #8 // threads/Bank super_class: #2 // java/lang/Object interfaces: 0, fields: 1, methods: 4, attributes: 1 Constant pool: #1 = Methodref #2.#3 // java/lang/Object."
":()V #2 = Class #4 // java/lang/Object #3 = NameAndType #5:#6 // " ":()V #4 = Utf8 java/lang/Object #5 = Utf8 #6 = Utf8 ()V #7 = Fieldref #8.#9 // threads/Bank.accounts:[D #8 = Class #10 // threads/Bank #9 = NameAndType #11:#12 // accounts:[D #10 = Utf8 threads/Bank #11 = Utf8 accounts #12 = Utf8 [D #13 = Methodref #14.#15 // java/util/Arrays.fill:([DD)V #14 = Class #16 // java/util/Arrays #15 = NameAndType #17:#18 // fill:([DD)V #16 = Utf8 java/util/Arrays #17 = Utf8 fill #18 = Utf8 ([DD)V #19 = Fieldref #20.#21 // java/lang/System.out:Ljava/io/PrintStream; #20 = Class #22 // java/lang/System #21 = NameAndType #23:#24 // out:Ljava/io/PrintStream; #22 = Utf8 java/lang/System #23 = Utf8 out #24 = Utf8 Ljava/io/PrintStream; #25 = Methodref #26.#27 // java/lang/Thread.currentThread:()Ljava/lang/Thread; #26 = Class #28 // java/lang/Thread #27 = NameAndType #29:#30 // currentThread:()Ljava/lang/Thread; #28 = Utf8 java/lang/Thread #29 = Utf8 currentThread #30 = Utf8 ()Ljava/lang/Thread; #31 = Methodref #32.#33 // java/io/PrintStream.print:(Ljava/lang/Object;)V #32 = Class #34 // java/io/PrintStream #33 = NameAndType #35:#36 // print:(Ljava/lang/Object;)V #34 = Utf8 java/io/PrintStream #35 = Utf8 print #36 = Utf8 (Ljava/lang/Object;)V #37 = String #38 // %10.2f from %d to %d #38 = Utf8 %10.2f from %d to %d #39 = Methodref #40.#41 // java/lang/Double.valueOf:(D)Ljava/lang/Double; #40 = Class #42 // java/lang/Double #41 = NameAndType #43:#44 // valueOf:(D)Ljava/lang/Double; #42 = Utf8 java/lang/Double #43 = Utf8 valueOf #44 = Utf8 (D)Ljava/lang/Double; #45 = Methodref #46.#47 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer; #46 = Class #48 // java/lang/Integer #47 = NameAndType #43:#49 // valueOf:(I)Ljava/lang/Integer; #48 = Utf8 java/lang/Integer #49 = Utf8 (I)Ljava/lang/Integer; #50 = Methodref #32.#51 // java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream; #51 = NameAndType #52:#53 // printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream; #52 = Utf8 printf #53 = Utf8 (Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream; #54 = String #55 // Total Balance: %10.2f%n #55 = Utf8 Total Balance: %10.2f%n #56 = Methodref #8.#57 // threads/Bank.getTotalBalance:()D #57 = NameAndType #58:#59 // getTotalBalance:()D #58 = Utf8 getTotalBalance #59 = Utf8 ()D #60 = Utf8 (ID)V #61 = Utf8 Code #62 = Utf8 LineNumberTable #63 = Utf8 LocalVariableTable #64 = Utf8 this #65 = Utf8 Lthreads/Bank; #66 = Utf8 n #67 = Utf8 I #68 = Utf8 initialBalance #69 = Utf8 D #70 = Utf8 transfer #71 = Utf8 (IID)V #72 = Utf8 from #73 = Utf8 to #74 = Utf8 amount #75 = Utf8 StackMapTable #76 = Utf8 a #77 = Utf8 sum #78 = Class #12 // "[D" #79 = Utf8 size #80 = Utf8 ()I #81 = Utf8 SourceFile #82 = Utf8 Bank.java { public threads.Bank(int, double); descriptor: (ID)V flags: (0x0001) ACC_PUBLIC Code: stack=3, locals=4, args_size=3 0: aload_0 1: invokespecial #1 // Method java/lang/Object." ":()V 4: aload_0 5: iload_1 6: newarray double 8: putfield #7 // Field accounts:[D 11: aload_0 12: getfield #7 // Field accounts:[D 15: dload_2 16: invokestatic #13 // Method java/util/Arrays.fill:([DD)V 19: return LineNumberTable: line 18: 0 line 19: 4 line 20: 11 line 21: 19 LocalVariableTable: Start Length Slot Name Signature 0 20 0 this Lthreads/Bank; 0 20 1 n I 0 20 2 initialBalance D public void transfer(int, int, double); descriptor: (IID)V flags: (0x0001) ACC_PUBLIC Code: stack=7, locals=5, args_size=4 0: aload_0 1: getfield #7 // Field accounts:[D 4: iload_1 5: daload 6: dload_3 7: dcmpg 8: ifge 12 11: return 12: getstatic #19 // Field java/lang/System.out:Ljava/io/PrintStream; 15: invokestatic #25 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread; 18: invokevirtual #31 // Method java/io/PrintStream.print:(Ljava/lang/Object;)V 21: aload_0 22: getfield #7 // Field accounts:[D 25: iload_1 26: dup2 27: daload 28: dload_3 29: dsub 30: dastore 31: getstatic #19 // Field java/lang/System.out:Ljava/io/PrintStream; 34: ldc #37 // String %10.2f from %d to %d 36: iconst_3 37: anewarray #2 // class java/lang/Object 40: dup 41: iconst_0 42: dload_3 43: invokestatic #39 // Method java/lang/Double.valueOf:(D)Ljava/lang/Double; 46: aastore 47: dup 48: iconst_1 49: iload_1 50: invokestatic #45 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 53: aastore 54: dup 55: iconst_2 56: iload_2 57: invokestatic #45 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 60: aastore 61: invokevirtual #50 // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream; 64: pop 65: aload_0 66: getfield #7 // Field accounts:[D 69: iload_2 70: dup2 71: daload 72: dload_3 73: dadd 74: dastore 75: getstatic #19 // Field java/lang/System.out:Ljava/io/PrintStream; 78: ldc #54 // String Total Balance: %10.2f%n 80: iconst_1 81: anewarray #2 // class java/lang/Object 84: dup 85: iconst_0 86: aload_0 87: invokevirtual #56 // Method getTotalBalance:()D 90: invokestatic #39 // Method java/lang/Double.valueOf:(D)Ljava/lang/Double; 93: aastore 94: invokevirtual #50 // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream; 97: pop 98: return LineNumberTable: line 31: 0 line 32: 11 line 34: 12 line 35: 21 line 36: 31 line 37: 65 line 38: 75 line 39: 98 LocalVariableTable: Start Length Slot Name Signature 0 99 0 this Lthreads/Bank; 0 99 1 from I 0 99 2 to I 0 99 3 amount D StackMapTable: number_of_entries = 1 frame_type = 12 /* same */ public double getTotalBalance(); descriptor: ()D flags: (0x0001) ACC_PUBLIC Code: stack=4, locals=8, args_size=1 0: dconst_0 1: dstore_1 2: aload_0 3: getfield #7 // Field accounts:[D 6: astore_3 7: aload_3 8: arraylength 9: istore 4 11: iconst_0 12: istore 5 14: iload 5 16: iload 4 18: if_icmpge 38 21: aload_3 22: iload 5 24: daload 25: dstore 6 27: dload_1 28: dload 6 30: dadd 31: dstore_1 32: iinc 5, 1 35: goto 14 38: dload_1 39: dreturn LineNumberTable: line 47: 0 line 49: 2 line 50: 27 line 49: 32 line 52: 38 LocalVariableTable: Start Length Slot Name Signature 27 5 6 a D 0 40 0 this Lthreads/Bank; 2 38 1 sum D StackMapTable: number_of_entries = 2 frame_type = 255 /* full_frame */ offset_delta = 14 locals = [ class threads/Bank, double, class "[D", int, int ] stack = [] frame_type = 248 /* chop */ offset_delta = 23 public int size(); descriptor: ()I flags: (0x0001) ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #7 // Field accounts:[D 4: arraylength 5: ireturn LineNumberTable: line 61: 0 LocalVariableTable: Start Length Slot Name Signature 0 6 0 this Lthreads/Bank; } SourceFile: "Bank.java" 我们可以自行找到 transfer 方法那一段的字节码,我们可以看到对数据的操作是分为很多行分别执行的。
这些代码的含义无关紧要。重要的是这个增加命令是由多条指令组成的,执行这些指令的线程可以在任何一条指令上被中断。
出现这种破坏的可能性有多大呢?在一个有多个内核的现代处理器上,出问题的风险相当高。我们将打印语句和更新余额的语句交错执行,以提高观察到这种问题的概率。
多个内核的CPU会放大这种效果,因为同事会有更多的线程执行打印输出可能会让执行线程陷入等待的几率增加,方便我们观察。
如果删除打印语句,出问题的风险会降低,因为每个线程在再次休眠之前所做的工作很少,调度器不太可能在线程的计算过程中抢占它的运行权。但是,产生破坏的风险并没有完全消失。如果在负载很重的机器上运行大量线程,那么,即使删除了打印语句,程序依然会出错。这种错误可能几分钟、几小时或几天后才出现。坦白地说,对程序员而言,最糟糕的事情莫过于这种不定期地出现错误。
这里我们可以认为的让风险出现的机会大大降低,但是不能根本性的解决问题,在并发度非常高的场景,还是会时不时的出现错误的状况。
真正的问题是 transfer 方法可能会在执行到中间被中断。如果能够确保线程失去控制之前方法已经运行完成,那么银行账户对象的状态就不会被破坏。
我们从代码上看,transfer 是不太可能从中间中断的,首先 stop 方法已经弃用了,我们可以调用 interrupt 方法,但是我们即使设置了这个中断线程也还可以不中断,继续执行,或者正好运行到了 sleep 处,那么会抛出异常停止该线程,但是也不会在 transfer 中间中断,我的理解是那种比较暴力的因素,比如说,oom了,整个 java 进程崩溃了,这种情况是有可能出现的,所以在这种情况下如何保证 transfer 不会从中间中断就是一个问题了。
12.4.3 锁对象
有两种机制可防止并发访问代码块。Java 语言提供了一个 synchronized
关键字来达到这一目的,另外 Java 5 引入了 ReentrantLock
类。synchronized 关键字会自动提供一个锁以及相关的“条件”,对于大多数需要显式锁的情况,这种机制功能很强大,也很便利。不过,我们相信在分别了解锁和条件的内容之后,就能更容易地理解 synchronized 关键字。java.util.concurrent
框架为这些基础机制提供了单独的类,有关内容会在本节以及 12.4.4 节解释。一旦理解了这些基础,我们会在 12.4.5 节介绍 synchronized 关键字。
目前有两种方式可以防止并发访问错误的产生,一种是加
synchronized
关键字,一种是ReentrantLock
类。
用 ReetrantLock
保护代码块的基本结构如下:
// a ReentrantLock object
myLock.lock();
try {
critical section
} finally {
// make sure the lock is unlocked even if an exception is thrown
myLock.unlock();
}
这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程锁定了对象,其他任何线程都无法通过 lock 语句。当其他线程调用 lock 时,它们会暂停,直到第一个线程释放这个锁对象。
myLock.lock();就是上锁的意思,在这里就开始锁住了资源的访问了,如果这个线程没有释放锁,其他线程是无法访问这些资源的。
警告:要把 unlock 操作包括在 finally 字句中,这一点至关重要。如果在临界区的代码抛出一个异常,锁必须释放。否则,其他线程将永远阻塞。
注释:使用锁时,就不能使用 try-with-resources 语句。首先,解锁方法名不是 close。不过,即使将它重命名,try-with-resources 语句也无法正常工作。它的首部希望声明一个新变量。但是如果使用一个锁,你可能想使用多个线程共享的那个变量(而不是新变量)。
首先是我们无法使用 JDK 1.7 引入的 try-with-resources 新特性,其次,我们在使用的时候,要保证线程使用的是同一把锁,如果不是一把锁,锁就失去了意义。
下面使用一个锁来保护 Bank 类的 transfer 方法。
package threads;
import java.util.Arrays;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* A bank with a number of bank accounts.
*/
public class Bank {
private final double[] accounts;
private Lock bankLock = new ReentrantLock();
/**
* 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) {
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();
}
}
/**
* 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;
}
}
假设一个线程调用了 transfer,但是在执行结束前被抢占。再假设第二个线程也调用了 transfer,由于第二个线程不能获得锁,将在调用 lock 方法时被阻塞。它会暂停,必须等待第一个线程执行完 transfer 方法。当第一个线程释放锁时,第二个线程才能开始运行(见图 12-3)。
也就是说,第一个线程如果获取了锁,第二个线程就无法再执行锁代码块内的程序了,当然,这是对于同一个对象而言的。
通常我们可能希望保护会更新或检查共享对象的代码块,从而能确信当前操作执行完之后其他线程才能使用同一个对象。
如果多个对象访问修改同一块数据,那么我们就需要把它锁起来。
12.4.4 条件对象
通常,线程进入临界区后却发现只有满足了某个条件之后它才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。在这一节里,我们会介绍 Java 库中条件对象的实现(由于历史原因,条件对象经常被称为条件变量(conditional variable))。
这里我也无法正确的理解什么是使用一个条件来管理那些已经获得了一个锁却不能做有用工作的线程是什么含义,可能还要往后看才可以。
现在来优化银行的模拟程序。如果一个账户没有足够的资金转账,我们不希望从这样的账户转出资金。注意不能使用类似下面的代码:
if (bank.getBalance(from) >= amount) {
bank.transfer(from, to, amount);
}
之前有个案例就是转账,现在要转账之前,我们都应该要判断一下账户里面是不是还有足够的资金,如果没有资金我们就不应该从这样的账户中转出资金了。
public double getBalance(int account) { return accounts[account]; }
getBalance 方法的代码应该是与上面的代码类似,直接获取数组中索引位置的浮点数值,并返回。这里跟我们说不能使用这段代码来判断,目前我所理解的是,这段代码判断和转出操作不是原子的,会带来线程安全问题。
在成功地通过这个测试之后,但在调用 transfer 方法之前,当前线程完全有可能被中断。
看来这里与我之前推测的原因一致
if (bank.getBalance(from) >= amount) {
// thread might be deactivated at this point
bank.transfer(from, to, amount);
}
上面的代码注释告诉我们,在判断完金额以后,线程可能会被中断。
在线程再次运行前,账户余额可能已经低于提款金额。必须确保在检查余额与转账活动之间没有其他线程修改余额。为此,可以使用一个锁来保护这个测试和转账操作:
我们必须保证这个操作是原子的,在互联网中我们一般使用分布式锁来实现,因为一般服务器上都是集群的,光锁一个进程用处不打,别的进程还能执行。所以这个锁的场景我用的不多,不知道为什么企业很爱考这个,我自己开发中用的是真不多。分布式锁用的比较多。