一、介绍
计算机用户理所当然地认为他们的系统一次可以做不止一件事。 他们假定他们可以继续在文字处理器中工作,而其他应用程序下载文件、管理打印队列和流音频。 甚至是一个应用程序也常常被期望一次做不止一件事。 例如,流媒体音频应用程序必须同时从网络读取数字音频,对其进行解压、管理回放和更新其显示。 即使是文字处理程序也应该随时准备响应键盘和鼠标事件,不管它是如何忙于重新格式化文本或更新显示。 能够做这些事情的软件称为并发软件。
Java平台从头开始设计,支持并发编程,并在Java编程语言和Java类库中提供基本的并发支持。 从5.0版本开始 这里介绍平台的基本并发支持,并总结了java.util.concurrent包中的一些高级api。
二、进程和线程
在并发编程中,有两个基本的执行单元:进程和线程。在Java编程语言中,并发编程主要关注线程。然而,进程也很重要。
计算机系统通常有许多活动进程和线程。即使在只有一个执行核心的系统中也是如此,因此在任何给定时刻只有一个线程实际执行。 单个内核的处理时间通过一个称为时间片的OS特性在进程和线程之间共享。
计算机系统拥有多个处理器或多个执行核心的处理器越来越普遍。 这极大地增强了系统并发执行进程和线程的能力——但是即使在没有多个处理器或执行核心的简单系统上,并发也是可能的。
1、进程
进程有独立的执行环境。 进程通常具有一组完整的、私有的基本运行时资源; 尤其是,每个进程都有自己的内存空间。
进程通常被视为程序或应用的同义词。 用户所看到的单个应用程序实际上可能是一组协作进程。 为了促进进程之间的通信,大多数操作系统都支持进程间通信(IPC)资源,比如管道和套接字。 IPC不仅用于同一系统上的进程之间的通信,而且用于不同系统上的进程之间的通信。
Java虚拟机的大多数实现都作为单个进程运行。Java应用程序可以使用ProcessBuilder对象创建其他进程。
2、线程
线程有时称为轻量级进程。 进程和线程都提供了执行环境,但是创建新线程所需的资源比创建新进程少。
线程存在于一个进程中——每个进程至少有一个线程。 线程共享进程的资源,包括内存和共享的文件。 这有助于高效的通信,但也有潜在的问题。
多线程执行是Java平台的一个基本特性。每个应用程序至少有一个或多个线程,如果将执行内存管理和信号处理等任务的“系统”线程计算在内的话。 但是从应用程序程序员的角度来看,您只从一个线程开始,称为主线程。这个线程能够创建额外的线程
三、线程对象
每个线程都与Thread类的一个实例相关联。 使用Thread对象创建并发应用程序有两种基本策略。
-
直接控制线程的创建和管理,只需在应用程序每次需要启动异步任务时实例化Thread即可。
-
从应用程序的其余部分抽象线程管理,将应用程序的任务传递给执行器。
1、定义和启动线程
创建Thread实例的应用程序必须提供将在该线程中运行的代码。有两种方法可以做到这一点:
-
供一个Runnal对象。Runnable接口定义了一个run()方法,用于包含在线程中执行的代码。Runnable对象被传递给Thread构造函数,就像在HelloRunnable例子中:
public class HelloRunnable implements Runnable {
public void run() {
System.out.println("Hello from a thread!");
}
public static void main(String args[]) {
(new Thread(new HelloRunnable())).start();
}
}
- 继承Thread。Thread类本身实现Runnable,但是它的run()方法什么也不做。应用程序可以继承Thread,提供自己的run实现,如HelloThread示例:
public class HelloThread extends Thread {
public void run() {
System.out.println("Hello from a thread!");
}
public static void main(String args[]) {
(new HelloThread()).start();
}
}
注意,这两个示例都调用Thread.start(),以启动新线程。
这两个你应该用哪一个?第一个习惯用法是使用Runnable对象,它更加通用,因为Runnable对象可以子类化非Thread的类。 第二个习惯用法在简单的应用程序中更容易使用,但受限于任务类必须是Thread的子类这一事实。 本课重点介绍第一种方法,它将可运行任务与执行任务的Thread对象分离开来。 这种方法不仅更加灵活,而且适用于稍后介绍的高级线程管理api。
Thread类定义了许多对线程管理有用的方法。 这些方法包括静态方法,它提供有关调用该方法的线程的信息,或影响该线程的状态。 其他方法是被涉及管理thread和thread对象的其他线程中调用的。
2、Sleep方法暂停执行
Thread.sleep()方法导致当前线程在指定的时间内暂停执行。 这是一种有效的方法,使处理器时间可用于应用程序的其他线程或可能在计算机系统上运行的其他应用程序。 sleep()方法还可以用于调整速度,如下面的示例所示,以及等待另一个线程,该线程的任务被认为是有时间需求的,就像后面小节中的SimpleThreads示例一样。
提供了两个重载的sleep()版本:一个指定睡眠时间为毫秒,另一个指定睡眠时间为纳秒。 然而,这些睡眠时间并不能保证是精确的,因为它们受到底层OS提供的功能的限制。此外,睡眠期间可以通过中断来终止,我们将在后面的小节中看到这一点。 在任何情况下,都不能假定调用sleep将挂起线程的确切时间。 SleepMessages示例使用sleep以4秒为间隔打印消息:、
public class SleepMessages {
public static void main(String[] args) throws InterruptedException {
String msgs[] = {"chen", "zi", "xuan", "24"};
for (int i = 0; i < msgs.length; i++) {
Thread.sleep(4000);
System.out.println(msgs[i]);
}
}
}
注意,main声明它抛出InterruptedException。这是一个异常,当另一个线程在当前线程休眠活动时中断当前线程时,会引发抛出该异常。 于这个应用程序没有定义另一个线程来引起中断,所以它不需要捕捉InterruptedException。
3、中断
中断是线程应该停止正在执行的操作并执行其他操作的指示。 由程序员决定线程如何响应中断,但是线程终止是很常见的做法。这就是本课所强调的用法。
线程通过调用Thread对象上的interrupt()方法来发送一个中断,以便被中断的线程可以被中断。 为了使中断机制正确工作,被中断的线程必须支持自己的中断。
(1)中断支持
线程如何支持自己的中断?这取决于它目前在做什么。 如果线程频繁地调用抛出InterruptedException的方法,那么它只在捕获该异常后从run方法返回。 例如,假设SleepMessages示例中的中心消息循环位于线程的Runnable对象的run方法中。然后它可能被修改如下,以支持中断:
public class SleepMessages {
public static void main(String[] args){
String msgs[] = {"chen", "zi", "xuan", "24"};
for (int i = 0; i < msgs.length; i++) {
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(msgs[i]);
}
}
}
许多抛出InterruptedException的方法,比如sleep,都被设计成取消当前操作,并在收到中断时立即返回。
如果一个线程运行了很长时间而没有调用抛出InterruptedException的方法怎么办? 它必须周期性地调用thread . interrupted(),如果接收到中断,则返回true。例如:
for (int i = 0; i < inputs.length; i++) {
heavyCrunch(inputs[i]);
if (Thread.interrupted()) {
// We've been interrupted: no more crunching.
return;
}
}
在这个简单的例子中,代码只是简单地测试中断,如果已经接收到中断,则退出线程。 在更复杂的应用程序中,抛出InterruptedException可能更有意义:
if (Thread.interrupted()) {
throw new InterruptedException();
}
这允许中断处理代码集中在catch子句中。
(2)中断状态标志
中断机制使用称为中断状态的内部标志来实现。调用Thread.interrupt()设置此标志。当线程通过调用静态方法thread .interrupted()检查中断时,中断状态将被清除。一个线程用来查询另一个线程的中断状态的非静态isinterrupted()方法不会更改中断状态标志。
按照惯例,任何通过抛出InterruptedException退出的方法都会在抛出时清除中断状态。 但是,总是有可能中断状态会立即被另一个调用中断的线程重新设置。
4、join
join方法允许一个线程等待另一个线程的完成。如果t是线程正在执行的Thread对象。
t.join();
导致当前线程暂停执行,直到t的线程终止。 join的重载方法允许程序员指定一个等待期。但是,与sleep一样,join的计时也依赖于操作系统,所以您不应该假设join会等待您指定的时间。 与sleep类似,join通过使用InterruptedException退出来响应中断。
5、SimpleThreads示例
下面的例子综合了本节的一些概念。 SimpleThreads由两个线程组成。第一个是每个Java应用程序都具有的主线程。 主线程从Runnable对象(MessageLoop)创建一个新线程,并等待它完成。如果MessageLoop线程耗时太长,主线程就会中断它。
MessageLoop线程打印出一系列消息。如果在打印所有消息之前中断,MessageLoop线程将打印一条消息并退出。
public class SimpleThreads {
static void threadMessage(String message) {
String threadName = Thread.currentThread().getName();
System.out.format("%s: %s%n", threadName, message);
}
private static class MessageLoop implements Runnable {
@Override
public void run() {
String importantInfo[] = {"Mares eat oats", "Does eat oats", "Little lambs eat ivy", "A kid will eat ivy too"
};
try {
for (int i = 0; i < importantInfo.length; i++) {
// Pause for 4 seconds
Thread.sleep(4000);
// Print a message
threadMessage(importantInfo[i]);
}
} catch (InterruptedException e) {
threadMessage("I wasn't done!");
}
}
}
public static void main(String args[])
throws InterruptedException {
// 中断等待时间
long patience = 1000 * 6;
// 如果控制台有输入时间,获取输入台输入的时间.
if (args.length > 0) {
try {
patience = Long.parseLong(args[0]) * 1000;
} catch (NumberFormatException e) {
System.err.println("Argument must be an integer.");
System.exit(1);
}
}
threadMessage("Starting MessageLoop thread");
long startTime = System.currentTimeMillis();
Thread t = new Thread(new MessageLoop());
t.start();
threadMessage("Waiting for MessageLoop thread to finish");
// 循环直至MessageLoop退出
while (t.isAlive()) {
threadMessage("Still waiting...");
// 等待1秒,让MessageLoop完成任务
t.join(1000);
if (((System.currentTimeMillis() - startTime) > patience)
&& t.isAlive()) {
threadMessage("Tired of waiting!");
t.interrupt();
// 永久等待
t.join();
}
}
threadMessage("Finally!");
}
}
结果:
四、同步
线程主要通过共享对字段和对象引用字段的访问进行通信。 这种通信形式非常有效,但是可能出现两种错误:线程干扰和内存一致性错误。 防止这些错误所需要的工具是同步。
但是,同步可能会引入线程争用,当两个或多个线程试图同时访问相同的资源时,会导致Java运行时执行一个或多个线程的速度变慢,甚至挂起它们的执行。 饥饿与活锁是线程争用的两种形式。
本节涵盖以下主题:
-
线程干扰描述了当多个线程访问共享数据时如何引入错误。
-
内存一致性错误描述共享内存不一致视图导致的错误。
-
同步方法描述了一个简单的习惯用法,可以有效地防止线程干扰和内存一致性错误。
-
隐式锁和同步描述了一个更通用的同步习惯用法,并描述了同步是如何基于隐式锁的。
-
原子访问讨论了不能被其他线程干扰的操作的一般思想。
1、线程干扰
考虑一个简单的类Counter
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
Counter的设计使得每次 increment 调用都会在c中添加1,每次调用 decrement 都会从c中减去1 。 但是,如果从多个线程引用Counter对象,线程之间的干扰可能会阻止这种情况的发生。
当两个操作在不同的线程中运行,但作用于相同的数据时,会发生干涉。这意味着这两个操作由多个步骤组成,并且步骤序列重叠。
Counter实例上的操作似乎不可能交错,因为c上的两个操作都是单个、简单的语句。然而,即使是简单的语句也可以被虚拟机转换成多个步骤。我们将不研究虚拟机所采取的具体步骤—只要知道单个表达式c++可以分解为三个步骤就足够了:
-
检索c的当前值。
-
将检索到的值增加1。
-
将增加的值存储回c中。
表达式c--可以用同样的方法分解,只是第二步是递减的,而不是递增的。 假设线程A调用 increment 与线程B调用 decrement的时间差不多。如果c的初值为0,它们的交错动作可能是这样的:
-
线程A:检索c。
-
线程B:检索c。
-
线程A:增量检索值;结果是1。
-
线程B:递减检索值;结果是-1。
-
线程A:在c中存储结果;c现在是1。
-
线程B:将结果存储在c中;c现在是-1。
线程A的结果丢失,被线程b覆盖。这种特殊的交错只是其中一种可能。在不同的情况下,丢失的可能是线程B的结果,或者根本没有错误。因为线程干扰bug是不可预测的,所以很难检测和修复。
2、内存一致性错误
当不同线程对应该是相同数据的内容有不一致的视图时,就会发生内存一致性错误。 内存一致性错误的原因很复杂,超出了本教程的范围。幸运的是,程序员不需要详细了解这些原因。我们所需要的只是一种避免它们的策略。
避免内存一致性错误的关键是理解 happens-before关系。 这种关系只是一个保证,一个特定语句写的内存对另一个特定语句是可见的。 要了解这一点,请考虑下面的示例。假设定义并初始化了一个简单的int字段:
int counter = 0;
counter字段被两个线程A和b共享。 假设线程A递增counter:
counter++;
然后,不久之后,线程B打印出计数器:
System.out.println(counter);
如果在同一个线程中执行了这两条语句,那么可以安全地假设输出的值为“1”。但是,如果这两个语句在单独的线程中执行,输出的值很可能是“0”,因为不能保证线程A对counter的更改对线程B可见——除非程序员在这两个语句之间建立了happens-before关系。
例如:
@Test
public void testCounter() {
Counter counter = new Counter();
Runnable r = () -> {
counter.increment();
System.out.println(Thread.currentThread().getName() + "给counter递增1");
};
new Thread(r).start();
System.out.println(counter.value());
}
结果:
有几个动作会建立 happens-before关系。其中之一是同步,我们将在下面的部分中看到。
我们已经看过建立 happens-before关系的两个动作。
-
当语句调用Thread.start(),与该语句具有happens-before关系的每个语句与新线程执行的每个语句也具有happens-before关系。 效果是创建新线程的代码对新线程是可见的。
-
当线程终止并引发另一个线程的 Thread.join()返回,那么终止线程执行的所有语句都与成功join之后的所有语句具有happens-before关系。
3、同步方法
Java编程语言提供了两个基本的同步习惯用法:同步方法和同步语句。 下一节将描述比较复杂的一个,同步语句。本节是关于同步方法的。
要使方法同步,只需将synchronized关键字添加到它的声明中:
public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
如果count是SynchronizedCounter的一个实例,那么使这些方法同步有两个效果:
-
不可能在同一个对象上对同步方法进行两次交叉调用。 当一个线程在一个对象上执行一个同步方法时,所有在这个对象来调用同步方法的其它线程调将会阻塞(暂停执行),直到第一个线程完成为止。
-
其次,当同步方法退出时,它会自动地与随后对同一对象的同步方法的调用建立happens-before关系。 这保证对对象状态的更改对所有线程都是可见的。
注意:构造函数不能同步——将synchronized关键字与构造函数一起使用是一个语法错误。同步构造函数没有意义,因为只有创建对象的线程才应该在构造对象时访问它。
警告: 在构造将在线程之间共享的对象时,要非常小心,以免对该对象的引用过早地“泄漏”。 例如,假设您想维护一个名为instances的List,其中包含类的每个实例。您可能想在构造函数中添加以下:
instances.add(this);
但是其他线程可以在对象构造完成之前使用instances来访问对象。
同步方法支持防止线程干扰和内存一致性错误的简单策略:如果一个对象对多个线程可见,那么对该对象变量的所有读写都是通过同步方法完成的。( 一个重要的例外:构造对象之后无法修改的final字段可以通过非同步方法安全地读取,只要构造了对象) 。这个策略是有效的,但是会在活性中出现问题,我们将在本节课的后面看到。
4、 内部锁和同步
同步是围绕一个称为内在锁或监视器锁的内部实体构建的。( API规范通常将此实体简单地称为“监视器”。) 内在锁在同步的两个方面都发挥作用:强制对对象状态的独占访问和建立对可见性至关重要的happens-before关系。
每个对象都有一个与其相关联的内部锁。按照惯例,需要对对象字段进行排他性和一致性访问的线程必须在访问对象的内部锁之前获得该对象的内部锁,然后在使用它们时释放该内部锁。 线程在获取锁和释放锁时间段拥有固有锁。 只要一个线程拥有一个内部锁,其他线程就不能获得相同的锁。当另一个线程试图获取锁时,它将阻塞。
当线程释放一个内部锁时,在该操作与随后获得的任何相同锁之间建立happens-before关系。
(1) 同步方法中的锁
当线程调用同步方法时,它自动获取该方法对象的内部锁,并在方法返回时释放它。即使返回是由未捕获异常引起的,也会发生锁释放。
您可能想知道调用静态同步方法时会发生什么,因为静态方法与类而不是对象相关联。在本例中,线程获取与类关联的Class对象的内部锁。因此,对类的静态字段的访问由一个与类的任何实例的锁不同的锁控制。
(2)同步语句
创建同步代码的另一种方法是使用同步语句。与同步方法不同,同步语句必须指定提供内部锁的对象:
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
在本例中,addName方法需要同步对lastName和nameCount的更改,但也需要避免对其他对象方法的同步调用。(从同步代码调用其他对象的方法会产生一些问题,这些问题将在关于活性的部分中描述。) 没有同步语句,有一个单独的、非同步的方法,用于调用nameList .add()方法。
同步语句对于使用细粒度同步改进并发性也很有用。 如,假设类MsLunch有两个实例字段c1和c2,它们从来没有一起使用过。这些字段的所有更新都必须同步,但是没有理由阻止c1的更新与c2的更新交织在一起——这样做会创建不必要的阻塞,从而降低并发性。我们没有使用同步方法,也没有使用与this关联的锁,而是创建两个对象来提供锁。
public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object();
public void inc1() {
synchronized(lock1) {
c1++;
}
}
public void inc2() {
synchronized(lock2) {
c2++;
}
}
}
小心使用这个方式。您必须绝对确定交叉访问受影响的字段确实是安全的。
(3) 可重入同步
回想一下,一个线程不能获得另一个线程拥有的锁。 但是线程可以获得它已经拥有的锁。 允许一个线程多次获得相同的锁可以实现可重入同步。 这描述了这样一种情况:同步代码直接或间接地调用同样包含同步代码的方法,并且两组代码使用相同的锁。 如果没有可重入同步,同步代码将不得不采取许多额外的预防措施,以避免线程导致自身阻塞。
5、原子访问
在编程中,原子操作是一次性有效地发生的操作。 原子操作不能中途停止:它要么完全发生,要么根本不发生。 原子操作的副作用在操作完成之前是不可见的。 我们已经看到,增量表达式(如c++)并不描述原子操作。即使是非常简单的表达式也可以定义可以分解为其他操作的复杂操作。 但是,您可以指定原子操作:
-
对于引用变量和大多数基本变量(除了long和double之外的所有类型),读写都是原子性的。
-
对于所有声明为volatile的变量(包括长变量和双变量),读写都是原子性的
原子操作不能交错,因此可以使用它们而不用担心线程干扰。 然而,这并没有消除同步原子操作的所有需要,因为内存一致性错误仍然是可能的。 使用 volatile 变量可以降低内存一致性错误的风险,因为对 volatile 变量的任何写操作都会与该变量的后续读操作建立一个happens-before关系。 这意味着对volatile变量的更改对其他线程总是可见的。 更重要的是,这还意味着当线程读取volatile变量时,它不仅会看到volatile的最新更改,还会看到导致更改的代码的副作用。
用简单的原子变量访问比通过同步代码访问这些变量更高效,但是需要程序员更小心地避免内存一致性错误。额外的工作是否值得取决于应用程序的大小和复杂性。
java.util.concurrent中的一些类。并发包提供不依赖于同步的原子方法。我们将在高级并发对象一节中讨论它们。
五、活性
并发应用程序及时执行的能力称为其活性。 本节描述最常见的一种活性问题,死锁,然后简要描述另外两个活性问题,饿死和活性锁。
1、死锁
死锁描述了两个或多个线程永远被阻塞,等待对方的情况。这是一个例子。
Alphonse 和 Gaston 是朋友,都非常相信礼貌。一个严格的礼貌原则是,当你向朋友鞠躬时,你必须一直鞠躬,直到你的朋友有机会还礼为止。不幸的是,这条规则并不能解释两个朋友同时向对方鞠躬的可能性。这个示例应用程序,DeadLock,模拟了这种可能性:
public class DeadLock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return name;
}
public synchronized void bow(Friend friend) {
System.out.println(String.format("%s:%s" + "has bowed to me%n", this.name, friend.getName()));
friend.bowBack(this);
}
public synchronized void bowBack(Friend friend) {
System.out.println(String.format("%s:%s" + "has bowed to me%n", this.name, friend.getName()));
}
}
public static void main(String[] args) {
Friend alphone = new Friend("Alphone");
Friend gaston = new Friend("Gaston");
new Thread(() -> {alphone.bow(gaston);}).start();
new Thread(() -> {gaston.bow(alphone);}).start();
}
}
当DeadLock运行时,这两个线程极有可能在试图调用bowBack时阻塞。这两个方块永远不会结束,因为每个线程都在等待另一个退出 bow()方法。
2、 饿死和活锁
与死锁相比,饿死和活锁的问题要少得多,但仍然是每个并发软件设计者都可能遇到的问题。
-
饿死
饿死描述线程无法获得对共享资源的常规访问,也无法取得进展的情况。 当共享资源被“贪婪”线程长时间占用时,就会发生这种情况。 例如,假设一个对象提供了一个同步方法,通常需要很长时间才能返回。如果一个线程频繁地调用这个方法,那么同样需要对同一对象频繁同步访问的其他线程常常会被阻塞。
-
活锁
一个线程经常对另一个线程的动作作出响应。如果另一个线程的动作也是对另一个线程的动作的响应,那么可能会产生活锁。 与死锁一样,被激活的线程无法取得进一步的进展。 而,这些线程并没有被阻塞——它们只是忙于相互响应而无法恢复工作。 这就好比两个人在走廊里试图通过对方:Alphonse向左移动让Gaston通过,而Gaston向右移动让Alphonse通过。Alphonse看到他们仍然在互相阻挡,就向右移动,Gaston则向左移动。它们还在互相阻挡,所以……
六、保护块
线程通常必须协调它们的操作。最常见的协调方式是保护块。这样的块首先轮询一个条件,该条件必须为true,然后才能继续执行。要正确地做到这一点,需要遵循许多步骤。
例如,假设guardedJoy()是一个方法,在另一个线程设置了共享变量joy之前,该方法不能继续执行。从理论上讲,这样的方法可以简单地循环,直到满足条件为止,但是这个循环是浪费的,因为它在等待的时候是连续执行的。
public void guardedJoy() {
// Simple loop guard. Wastes
// processor time. Don't do this!
while(!joy) {}
System.out.println("Joy has been achieved!");
}
更有效的保护调用 Object.wait()方法挂起当前线程。wait()调用不会返回,直到另一个线程发出一个通知,表明可能发生了一些特殊的事件——尽管不一定是这个线程正在等待的事件:
public synchronized void guardedJoy() {
// This guard only loops once for each special event, which may not
// be the event we're waiting for.
while(!joy) {
try {
wait();
} catch (InterruptedException e) {}
}
System.out.println("Joy and efficiency have been achieved!");
}
注意: 一直在循环中调用wait,测试正在等待的条件。不要假设中断是针对您正在等待的特定条件,或者该条件一直为真。
与许多挂起执行的方法一样,wait可以抛出InterruptedException。在这个例子中,我们可以忽略这个例外——我们只关心joy的值。
为什么这个版本的guardedJoy是同步的?假设d是我们用来调用wait的对象。当线程调用d.wait()时,它必须拥有d的内部锁——否则将抛出一个错误。在同步方法中调用wait是获取内部锁的一种简单方法。
当调用wait时,线程释放锁并挂起执行。在将来的某个时候,另一个线程将获得相同的锁并调用Object.notifyAll(),通知所有等待锁的线程发生了重要的事情:
public synchronized notifyJoy() {
joy = true;
notifyAll();
}
第二个线程释放锁之后的一段时间内,第一个线程重新获得锁并通过调用wait返回继续。
让我们使用保护块来创建一个生产者-消费者应用程序。这种应用程序在两个线程之间共享数据:创建数据的生产者和使用数据的使用者。 这两个线程使用一个共享对象进行通信。协调是必要的:消费者线程在生产者线程交付数据之前不能获取数据,如果消费者没有获取旧数据,生产者线程也不能传递新数据。
在这个例子中,数据是一系列的文本消息,它们通过Drop类型的对象共享:
public class Drop {
/**
* 生产者发送给消费者的消息
*/
private String message;
/**
* true:消费者等待生产者生产消息
* false:生产者等待消费者消费消息
*/
private boolean empty;
public synchronized String take() {
//等待,直到消息被提供
if (empty) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//置换状态
empty = true;
//通知生产者状态已经被改变
notifyAll();
return message;
}
public synchronized void put(String message) {
//等待,直到消息被消费
if (!empty) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//置换状态
empty = false;
this.message = message;
//通知消费者状态已经被改变
notifyAll();
}
}
在Producer中定义的producer线程发送一系列熟悉的消息。字符串“DONE”表示所有消息都已发送。为了模拟真实应用程序不可预测的特性,生产者线程会在消息之间随机间隔暂停
public class Producter implements Runnable {
private Drop drop;
public Producter(Drop drop) {
this.drop = drop;
}
@Override
public void run() {
Random random = new Random();
String msg[] = {"chen", "zi", "xuan", "niubi"};
for (int i = 0; i < msg.length; i++) {
drop.put(msg[i]);
try {
Thread.sleep(random.nextInt(5000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
drop.put("DONE");
}
}
在Consumer中定义的使用者线程只获取消息并打印出来,直到获取到“DONE”字符串。这个线程也会随机暂停。
public class Consumer implements Runnable {
private Drop drop;
public Consumer(Drop drop) {
this.drop = drop;
}
@Override
public void run() {
Random random = new Random();
for (String msg = drop.take(); !"DNOE".equals(msg); msg = drop.take() ) {
System.out.println(String.format("获取信息:%s%n", msg));
try {
Thread.sleep(random.nextInt(5000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
最后,这里是主线程,在ProducerConsumerExample中定义,它启动生产者和消费者线程。
public class ProducerConsumerExample {
public static void main(String[] args) {
Drop drop = new Drop();
new Thread(new Producter(drop)).start();
new Thread(new Consumer(drop)).start();
}
}
结果:
注意:Drop类是为了演示保护块而编写的。为了避免重复劳动,在尝试编写自己的数据共享对象之前,请检查Java Collections框架中现有的数据结构。
七、不可变对象
如果对象的状态在构造后不能更改,则认为该对象是不可变的。对于创建简单、可靠的代码,最大程度地依赖不可变对象被广泛接受为一种可靠的策略。
不可变对象在并发应用程序中特别有用。因为它们不能改变状态,所以它们不能被线程干扰破坏或在不一致的状态下观察到。
程序员通常不愿意使用不可变对象,因为他们担心创建新对象的成本,而不是更新现有对象。 对象创建的影响常常被高估,并且可以被与不可变对象相关的一些效率所抵消。 这包括由于垃圾收集而减少的开销,以及消除保护可变对象免受损坏所需的代码。
下面的子节使用一个实例是可变的类,并从中抽取一个具有不可变实例的类。在此过程中,它们给出了这种转换的一般规则,并演示了不可变对象的一些优点。
1、同步类示例
SynchronizedRGB类定义了表示颜色的对象。每个对象都描述三个代表主颜色值的整数和一个给出颜色名称的字符串。
public class SynchronizedRGB {
// Values must be between 0 and 255.
private int red;
private int green;
private int blue;
private String name;
private void check(int red,int green,int blue) {
if (red < 0 || red > 255|| green < 0 || green > 255|| blue < 0 || blue > 255) {
throw new IllegalArgumentException();
}
}
public SynchronizedRGB(int red, int green, int blue, String name) {
check(red, green, blue);
this.red = red;
this.green = green;
this.blue = blue;
this.name = name;
}
public void set(int red, int green, int blue, String name) {
check(red, green, blue);
synchronized (this) {
this.red = red;
this.green = green;
this.blue = blue;
this.name = name;
}
}
public synchronized int getRGB() {
return ((red << 16) | (green << 8) | blue);
}
public synchronized String getName() {
return name;
}
public synchronized void invert() {
red = 255 - red;
green = 255 - green;
blue = 255 - blue;
name = "Inverse of " + name;
}
}
必须谨慎使用SynchronizedRGB,以避免在不一致的状态下被看到。例如,假设一个线程执行以下代码:
SynchronizedRGB color =
new SynchronizedRGB(0, 0, 0, "Pitch Black");
...
int myColorInt = color.getRGB(); //Statement 1
String myColorName = color.getName(); //Statement 2
如果另一个线程调用 color.set(),在语句1之后,但在语句2之前,myColorInt的值将与myColorName的值不匹配。为了避免这种结果,这两种说法必须结合在一起:
synchronized (color) {
int myColorInt = color.getRGB();
String myColorName = color.getName();
}
这种不一致只可能发生在可变对象上——对于SynchronizedRGB的不可变版本来说,这不是问题。
2、 定义不可变对象的策略
以下规则定义了创建不可变对象的简单策略。 并不是所有被记录为“ immutable”的类都遵循这些规则。 这并不一定意味着这些类的创建者是草率的——他们可能有很好的理由相信他们的类的实例在构造之后不会更改。然而,这种策略需要复杂的分析,并不适合初学者。
1、 不要提供“setter”方法——修改字段或字段引用的对象的方法。
2、 使所有字段为private和final。
3、 不允许子类重写方法。最简单的方法是将类声明为final。更复杂的方法是使构造函数私有,并在工厂方法中构造实例。
4、 如果实例字段包含对可变对象的引用,则不允许更改这些对象:
-
不要提供修改可变对象的方法。
-
不要共享对可变对象的引用。永远不要存储对传递给构造函数的外部可变对象的引用;如果需要,创建副本,并存储对副本的引用。类似地,在必要时创建内部可变对象的副本,以避免在方法中返回原始对象。
将此策略应用到SynchronizedRGB中,可以完成以下步骤:
1、这个类中有两个setter方法。第一个是set,它任意转换对象,在类的不可变版本中没有位置。 第二种方法是invert,可以通过让它创建一个新对象而不是修改现有对象来进行调整
2、有字段都已经是private;他们将进一步改为final。
3、 类本身被声明为final。
4、 只有一个字段引用一个对象,而该对象本身是不可变的。因此,没有必要防止更改“包含的”可变对象的状态。
在这些变化之后,我们有了ImmutableRGB:
final public class ImmutableRGB {
// Values must be between 0 and 255.
final private int red;
final private int green;
final private int blue;
final private String name;
private void check(int red,int green,int blue) {
if (red < 0 || red > 255|| green < 0 || green > 255|| blue < 0 || blue > 255) {
throw new IllegalArgumentException();
}
}
public ImmutableRGB(int red, int green, int blue,String name) {
check(red, green, blue);
this.red = red;
this.green = green;
this.blue = blue;
this.name = name;
}
public synchronized int getRGB() {
return ((red << 16) | (green << 8) | blue);
}
public synchronized String getName() {
return name;
}
public ImmutableRGB invert() {
return new ImmutableRGB(255 - red, 255 - green, 255 - blue, "Inverse of " + name);
}
}
八、高级并发对象
到目前为止,我们的重点一直放在底层api上,这些api从一开始就是Java平台的一部分。这些api对于非常基本的任务是足够的,但是对于更高级的任务则需要更高级别的构建块。对于充分利用当今多处理器和多核系统的大规模并发应用程序,尤其如此。
在本节中,我们将研究Java平台5.0版本引入的一些高级并发特性。这些特性中的大多数都是在新的java.util.concurrent包中实现的。Java集合框架中也有新的并发数据结构。
-
锁对象支持锁的习惯用法,简化了许多并发应用程序。
-
执行器定义了用于启动和管理线程的高级API。由 java.util.concurrent包提供执行器实现,执行器提供适合大型应用程序的线程池管理。
-
并发集合使管理大型数据集合变得更容易,并且可以大大减少同步的需要。
-
原子变量具有最小化同步和帮助避免内存一致性错误的特性。
-
ThreadLocalRandom(在JDK 7中)提供了从多个线程有效生成伪随机数的方法。
1、锁对象
同步代码依赖于一种简单的可重入锁。这种锁使用方便,但有很多限制。 java.util.concurrent.locks支持更复杂的锁的习惯用法。我们不会详细研究这个包,而是将重点放在它最基本的接口Lock上。
Lock对象的工作原理非常类似于同步代码使用的隐式锁。与隐式锁一样,一次只能有一个线程拥有锁对象。锁对象还通过其关联的Conditional对象支持等待/通知机制。
Lock对象相对于隐式锁的最大优势是它们能够退出获取锁的尝试。 如果锁不是立即可用的,或者超时过期之前(如果指定),tryLock方法将回退。 如果另一个线程在获取锁之前发送中断,则lockInterruptibly()方法将退出。
让我们使用Lock对象来解决我们在活性中看到的死锁问题。 Alphonse 和 Gaston已经训练自己注意朋友什么时候要鞠躬。我们通过要求我们的Friend对象在继续鞠躬之前必须为两个参与者都获取锁来模拟这种改进。 下面是改进模型,Safelock的源代码。为了展示这个习语的多样性,我们假设Alphonse 和Gaston是如此痴迷于他们新发现的安全鞠躬的能力,以至于他们无法停止互相鞠躬:
public class Safelock {
static class Friend {
private final String name;
private final ReentrantLock lock = new ReentrantLock();
public Friend(String name) {
this.name = name;
}
public String getName() {
return name;
}
public boolean impendingBow(Friend bower) {
Boolean myLock = false;
Boolean yourLock = false;
try {
myLock = lock.tryLock();
yourLock = bower.lock.tryLock();
} finally {
if (!(myLock && yourLock)) {
if (myLock) {
lock.unlock();
}
if (yourLock) {
bower.lock.unlock();
}
}
}
return myLock && yourLock;
}
public void bow(Friend bower) {
if (impendingBow(bower)) {
try {
System.out.println(String.format("%s:%s向我鞠躬", this.getName(), bower.getName()));
bower.backBow(this);
} finally {
this.lock.unlock();
bower.lock.unlock();
}
}
}
private void backBow(Friend bower) {
System.out.println(String.format("%s:%s向我回鞠躬", this.name, bower.getName()));
}
static class LoopBow implements Runnable {
private Friend bowee;
private Friend bower;
public LoopBow(Friend bowee, Friend bower) {
this.bowee = bowee;
this.bower = bower;
}
@Override
public void run() {
Random random = new Random();
for (; ; ) {
try {
Thread.sleep(random.nextInt(10));
} catch (InterruptedException e) {
e.printStackTrace();
}
bowee.bow(bower);
}
}
public static void main(String[] args) {
Friend alphonse = new Friend("Alphonse");
Friend gaston = new Friend("Gaston");
new Thread(new LoopBow(alphonse, gaston)).start();
new Thread(new LoopBow(gaston, alphonse)).start();
}
}
}
}
结果:
2、执行器
在前面的所有示例中,新线程(由其Runnable对象定义)执行的任务与线程本身(由Thread对象定义)之间有密切的联系。 这对于小型应用程序很有效,但是在大型应用程序中,将线程管理和创建与应用程序的其他部分分开是有意义的。 封装这些函数的对象称为执行器。下面的小节将详细描述执行器。
-
Executor接口定义了三种Executor对象类型。
-
线程池是最常见的一种执行器实现。
-
Fork/Join是一个框架(JDK 7中新增的),用于利用多个处理器。
(1)执行器接口
java.util.concurrent 包定义了三个执行器接口:
-
Executor,一个支持启动新任务的简单接口。
-
ExecutorService,Executor的子接口,它添加了一些特性,帮助管理各个任务和Executor本身的生命周期。
-
ScheduledExecutorService,ExecutorService的子接口,支持未来和/或定期执行任务。
通常,引用executor对象的变量被声明为这三种接口类型之一,而不是使用executor类类型。
1) Executor接口
Executor接口提供了一个名为execute的方法,它被设计为一个普通线程创建习惯用法的替代方法。如果r是一个 Runnable 对象,而e是一个 Executor 对象。可以将:
(new Thread(r)).start();
替换为:
e.execute(r);
然而, execute ()定义不那么具体。低级习惯用法创建一个新线程并立即启动它。 根据Executor实现的不同,execute()可能执行相同的操作,但更可能使用现有的工作线程运行r,或者将r放入队列中,等待工作线程可用。
java.util.concurrent包中的执行器实现,旨在充分利用更高级的ExecutorService和ScheduledExecutorService接口,尽管它们也以基本的Executor接口工作。
2) ExecutorService接口
ExecutorService接口补充了使用类似但更通用的 submit()方法。 与execute()一样,submit()接受Runnable对象,但也接受Callable对象,这允许任务返回一个值。 submit()方法返回一个Future对象,该对象用于检索 Callable的返回值,并管理 Callable和 Runnable任务的状态。
ExecutorService还提供了提交大量Callable对象集合的方法。最后,ExecutorService提供了一些方法来管理executor的关闭。为了支持立即关机,任务应该正确地处理中断。
3) ScheduledExecutorService接口
ScheduledExecutorService接口使用schedule()补充其父ExecutorService的方法,后者在指定的延迟之后执行 Runnable 或Callable的任务。此外,该接口还定义了scheduleAtFixedRate()和scheduleWithFixedDelay(),它们以定义的时间间隔重复执行指定的任务。
(2)线程池
java.util.concurrent中的大多数执行器实现使用线程池,它由工作线程组成。这种线程独立于它执行的可运行任务和可调用任务,通常用于执行多个任务。
使用工作线程将线程创建带来的开销最小化。线程对象使用大量内存,在大型应用程序中,分配和释放许多线程对象会产生大量内存管理开销。
一种常见的线程池类型是固定线程池。这种类型的池总是有指定数量的线程在运行;如果一个线程在使用期间以某种方式终止,那么它将被一个新线程自动替换。任务通过内部队列提交到池中,当活动任务多于线程时,内部队列将保存额外的任务。
固定线程池的一个重要优点是,使用它的应用程序可以优雅地降级。 要理解这一点,请想象一个web服务器应用程序,其中每个HTTP请求都由一个单独的线程处理。 如果应用程序只是为每个新的HTTP请求创建一个新线程,而系统接收到的请求超过了它能够立即处理的数量,那么当所有这些线程的开销超过系统的容量时,应用程序将突然停止响应所有请求。 由于限制了可以创建的线程的数量,应用程序将不会像传入时那样快速地处理HTTP请求,但它将以系统能够承受的最快速度处理这些请求。
创建使用固定线程池的执行器的一个简单方法是调用java.util.concurrent.Executors 中的newFixedThreadPool工厂方法。这个类还提供了以下工厂方法:
-
newCachedThreadPool方法使用可扩展线程池创建执行器。此执行器适用于启动许多短期任务的应用程序。
-
newSingleThreadExecutor方法创建一个每次执行单个任务的执行器
-
几个工厂方法是 executors的ScheduledExecutorService版本。
如果上述工厂方法提供的执行器都不能满足您的需求,那么构造java.util.concurrent.ThreadPoolExecutor,或者java.util.concurrent.ScheduledThreadPoolExecutor将为您提供额外的选项。
(3)Fork/Join
fork/join框架是ExecutorService接口的实现,它可以帮助您利用多个处理器。它是为那些可以递归分解成更小块的工作而设计的。目标是使用所有可用的处理能力来提高应用程序的性能。
与任何ExecutorService实现一样,fork/join框架将任务分配给线程池中的工作线程。fork/join框架是不同的,因为它使用了一个工作窃取算法。空闲的工作线程可以从其他仍然繁忙的线程中窃取任务。
fork/join框架的核心是ForkJoinPool类,它是AbstractExecutorService类的扩展。ForkJoinPool实现了核心的工作窃取算法,可以执行ForkJoinTask的处理。
1)基础用法
使用fork/join框架的第一步是编写执行部分工作的代码。你的代码应该看起来像下面的伪代码:
if (my portion of the work is small enough)
do the work directly
else
split my work into two pieces
invoke the two pieces and wait for the results
将这段代码封装在一个ForkJoinTask子类中,通常使用它的一个更专门化的类型RecursiveTask(它可以返回结果)或RecursiveAction。
在您的forkjoinask子类准备好之后,创建一个表示所有要做的工作的对象,并将其传递给ForkJoinPool实例的invoke()方法。
2) 模糊的清晰
为了帮助您理解fork/join框架是如何工作的,请考虑下面的示例。假设您想要模糊图像。原始源图像由一个整数数组表示,其中每个整数包含单个像素的颜色值。模糊的目标图像也用与源相同大小的整数数组表示。 执行模糊是通过每次处理一个像素的源数组来完成的。对每个像素及其周围像素求平均值(对红色、绿色和蓝色组件求平均值),并将结果放在目标数组中。 由于图像是一个大数组,这个过程可能会花费很长时间。通过使用fork/join框架实现算法,您可以利用多处理器系统上的并发处理。下面是一种可能的实现:
public class ForkBlur extends RecursiveAction {
private int[] mSource;
private int[] mDestination;
private int mStart;
private int mLength;
private int mBlurWidth = 15;
public ForkBlur(int[] mSource, int mStart, int mLength, int[] mDestination) {
this.mSource = mSource;
this.mStart = mStart;
this.mLength = mLength;
this.mDestination = mDestination;
}
public void computeDirectly() {
int sidePixels = (mBlurWidth - 1) / 2;
for (int index = mStart; index < mStart + mLength; index++) {
int rt = 0, gt = 0, bt = 0;
for (int mi = -sidePixels; mi < sidePixels; mi++) {
int mindex = Math.max(Math.min((mStart + mi), 0), mSource.length - 1);
int pixel = mSource[mindex];
rt += (float)((pixel & 0x00ff0000) >> 16)
/ mBlurWidth;
gt += (float)((pixel & 0x0000ff00) >> 8)
/ mBlurWidth;
bt += (float)((pixel & 0x000000ff) >> 0)
/ mBlurWidth;
}
int dpixel = (0xff000000 ) |
(((int)rt) << 16) |
(((int)gt) << 8) |
(((int)bt) << 0);
mDestination[index] = dpixel;
}
}
@Override
protected void compute() {
}
}
现在实现抽象compute()方法,该方法要么直接执行模糊,要么将模糊分解为两个较小的任务。一个简单的数组长度阈值有助于确定工作是执行还是分割。
protected static int sThreshold = 100000;
@Override
protected void compute() {
if (mLength < sThreshold) {
computeDirectly();
return;
}
int split = mLength / 2;
invokeAll(new ForkBlur(mSource, mStart, split, mDestination),
new ForkBlur(mSource, mStart + split, mLength - split, mDestination));
}
如果前面的方法在RecursiveAction类的子类中,那么设置要在ForkJoinPool中运行的任务是很简单的,需要执行以下步骤:
1、创建一个表示所有要完成的工作的任务。
// source image pixels are in src
// destination image pixels are in dst
ForkBlur fb = new ForkBlur(src, 0, src.length, dst);
2、创建运行该任务的ForkJoinPool。
ForkJoinPool pool = new ForkJoinPool();
3、运行任务。
pool.invoke(fb);
3) 标准实现
除了使用fork/join框架为要在多处理器系统上并发执行的任务实现自定义算法(如前一节中的 ForkBlur.java示例)之外,Java SE中还有一些通常有用的特性已经使用fork/join框架实现了。 Java SE 8介绍的 java.util.Arrays为parallelSort()方法引入了一个这样的实现。 这些方法与sort()类似,但是通过fork/join框架利用并发性。 在多处理器系统上运行时,大型数组的并行排序比顺序排序快。但是,这些方法如何正确地利用fork/join框架超出了Java教程的范围。有关此信息,请参阅Java API文档。
fork/join框架的另一个实现由java.util.streams包中的方法使用。
3、并发集合
-
LockingQueue定义了一个先入先出的数据结构,当您试图添加到满队列或从空队列检索时,它将阻塞或超时。
-
ConcurrentMap是java.util.Map的子接口。它定义了有用的原子操作。这些操作仅在键存在时删除或替换键值对,或者仅在键不存在时添加键值对。使这些操作原子化有助于避免同步。ConcurrentMap的标准通用实现是ConcurrentHashMap,它是HashMap的并发模拟。
-
ConcurrentNavigableMap是ConcurrentMap的一个子接口,支持近似匹配。ConcurrentNavigableMap的标准通用实现是ConcurrentSkipListMap,它是TreeMap的并发模拟。
所有这些集合都通过定义将对象添加到集合的操作与随后访问或删除该对象的操作之间的happens-before关系来帮助避免内存一致性错误。
4、原子变量
java.util.concurrent.atomic包定义了支持单变量原子操作的类。 所有类都有get和set方法,其工作原理类似于对volatile变量进行读写。 也就是说, set与同一变量上的任何后续get具有happens-before关系。 原子compareAndSet方法也具有这些内存一致性特性,适用于整数原子变量的简单原子算术方法也是如此。
要查看这个包是如何使用的,让我们回到最初用来演示线程干扰的Counter类:
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
使计数器免受线程干扰的一种方法是使它的方法同步,如SynchronizedCounter:
class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
对于这个简单的类,同步是一个可接受的解决方案。但是对于更复杂的类,我们可能希望避免不必要的同步对活动的影响。用AtomicInteger替换int字段可以避免线程干扰,而不需要同步,就像在AtomicCounter中一样:
public class AtomicCounter {
private AtomicInteger c = new AtomicInteger(0);
public void increment() {
c.incrementAndGet();
}
public void decrement() {
c.decrementAndGet();
}
public int value() {
return c.get();
}
}
5、并行随机数
在JDK 7中,java.util.concurrent包含一个方便的类ThreadLocalRandom,用于希望使用多线程或ForkJoinTask中的随机数的应用程序。
对于并发访问,使用ThreadLocalRandom而不是Math.random()可以减少争用,并最终提高性能。
您需要做的就是调用ThreadLocalRandom.current(),然后调用它的一个方法来检索随机数。这里有一个例子:
int r = ThreadLocalRandom.current() .nextInt(4, 77);
九、推荐阅读
-
《Java并发编程:设计原则与模式》(第二版),由一位领先的专家(他也是Java平台并发性框架的架构师)完成的全面工作。
-
《Java并发编程实践》,Brian Goetz、Tim Peierls、Joshua Bloch、Joseph Bowbeer、David Holmes和Doug Lea著。一个实用的指南,旨在使初学者易于理解。
-
《Java高效编程指南》(第二版),Joshua Bloch著。虽然这是一个通用的编程指南,但是它关于线程的章节包含了并发编程的基本“最佳实践”。
十、 问题和练习:并发
问题1:
能否将Thread对象传递给Executor.execute()?这样的调用有意义吗?
答案:
Thread实现Runnable接口,因此可以将Thread实例传递给Executor.execute。然而,以这种方式使用Thread对象是没有意义的。如果对象是直接从Thread实例化的,那么它的run方法不做任何事情。您可以使用一个有用的run方法定义Thread的子类——但是这样的类将实现执行器不会使用的特性。
问题2:
一段代码:
public class BadThread {
static String message;
static class CorrectorThread extends Thread {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Key statement 1:
message = "CorrectorThread的线程消息";
}
}
public static void main(String[] args) throws InterruptedException {
new CorrectorThread().start();
message = "主线程的消息";
Thread.sleep(2000);
// Key statement 2
System.out.println(message);
}
}
应用程序应该打印出“CorrectorThread的线程消息”。它保证总是这样做吗?如果没有,为什么没有?改变这两种睡眠方式的参数会有帮助吗?如何保证对消息的所有更改对主线程都是可见的?
答案:
程序几乎总是打印出“CorrectorThread的线程消息”。但是,这个结果不能得到保证,因为“Key statement 1”和“Key statment 2”之间没有happens-before关系。即使“Key statement 1”实际上在“Key statement 2”之前执行——记住,happens-before关系是关于可见性的,而不是序列的。
有两种方法,你可以保证所有的变化的消息将对主线程可见:
-
在主线程中,保留对CorrectorThread实例的引用。然后在引用 message之前对该实例调用join()。
public class BadThread {
static String message;
static class CorrectorThread extends Thread {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Key statement 1:
message = "CorrectorThread的线程消息";
}
}
public static void main(String[] args) throws InterruptedException {
CorrectorThread correctorThread = new CorrectorThread();
correctorThread.start();
message = "主线程的消息";
Thread.sleep(500);
correctorThread.join()
// Key statement 2
System.out.println(message);
}
}
结果:
-
用同步方法将message封装在对象中。除非通过这些方法,否则不引用message。
这两种技术都建立了必要的happens-before关系,使对message的更改可见。
第三种技术是简单地将消息声明为volatile。这保证了任何对消息的写操作(如“Key statement 1”)都将与随后的任何消息读操作(如“Key statement 2”)保持happens-before关系。 但是它不能保证“Key statement 1”会在“Key statement 2”之前发生。它们很可能是按顺序发生的,但由于调度的不确定性和 sleep的时间未知,这并不能保证。
改变两个 sleep的参数也没有帮助,因为这并不能保证 happens-before 关系。
问题3:
在 Guarded Blocks 中修改生产者-消费者示例,以使用标准库类代替Drop类。
答案:
java.util.concurrent.BlockingQueue接口定义了一个get方法,如果队列是空的,它将阻塞;如果队列是满的,它将阻塞put方法。这些操作与Drop定义的操作实际上是相同的——只不过Drop不是队列!然而,还有另一种看待Drop的方法:它是一个容量为零的队列。由于队列中没有空间容纳任何元素,所以每个get阻塞相当于take,每个take阻塞相当于get。 有一个BlockingQueue的实现具有这些行为:java.util.concurrent.SynchronousQueue。
BlockingQueue几乎可以替代Drop。生产者中的主要问题是使用BlockingQueue时,put和get方法抛出InterruptedException。这意味着现有的try必须向上移动一个级别:
public class Producter implements Runnable {
private BlockingQueue<String> drop;
public Producter(BlockingQueue drop) {
this.drop = drop;
}
@Override
public void run() {
Random random = new Random();
String msg[] = {"chen", "zi", "xuan", "niubi"};
try {
for (int i = 0; i < msg.length; i++) {
drop.put(msg[i]);
Thread.sleep(random.nextInt(5000));
}
drop.put("DONE");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
消费者也需要作出类似的改变:
public class Consumer implements Runnable {
private BlockingQueue<String> drop;
public Consumer(BlockingQueue drop) {
this.drop = drop;
}
@Override
public void run() {
Random random = new Random();
try {
for (String msg = drop.take(); !"DNOE".equals(msg); msg = drop.take() ) {
System.out.println(String.format("获取信息:%s%n", msg));
Thread.sleep(random.nextInt(5000));
}
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}
对于ProducerConsumerExample,我们只需更改drop对象的声明:
public class ProducerConsumerExample {
public static void main(String[] args) {
BlockingQueue<String> drop = new SynchronousQueue<String>();
new Thread(new Producter(drop)).start();
new Thread(new Consumer(drop)).start();
}
}
结果: