文章目录
- 进程和线程
- 进程
- 线程
- Thread Objects
- 定义和启动一个线程
- Sleep:延迟执行
- interrupts:中断
- 支持中断
- 中断状态标识
- Join:连接
- 简单的线程示例
- 同步
- 线程干扰
- 内存一致性错误
- 同步方法
- 内部锁和同步
- 同步方法中的锁
- 同步语句(同步代码块)
- 可重入同步
- 原子访问
- 活跃度(Liveness)
- 死锁
- 饥饿和活锁
- 饥饿
- 活锁
- 监控代码块
- 不可变对象
- 一个同步类的例子
- 定义不可变对象的策略
- 高级并发对象
- Lock对象
- 调度器
- 调度器接口
- Executor
- ExecutorService
- ScheduledExecutorService
- 线程池
- Fork/Join(分叉/合并)
- 简单使用
- 模糊或者清晰
- 标准实现
- 并发集合
- 原子变量
- 并发随机数
进程和线程
在并发编程中有2个基本的执行单元:进程和线程。在java中,并发编程一般与线程相关联,然而进程也是十分重要的!
在计算机系统中通常会有多个活跃的进程和线程,即使是在单核心的系统中,在任意时刻也会有唯一一个线程在运行。单个内核的处理时间通过称为时间切片的OS特性在进程和线程之间共享。
进程
每个进程都有一个‘自包含’的运行环境。 进程通常具有一组完整的私有基本运行时资源。实际上,每个进程都有它自己的内存空间。
通常进程被看作与应用或者程序相同,然而,用户看到的应用实际上可能是一组协作的进程。为了进程间能够更好的通信,大多数操作系统支持IPC(Inter Process Communication),比如管道(pipes )和套接字(sockets)。IPC不仅用于同一系统上的进程之间的通信,而且用于不同系统上的进程之间的通信。
Java虚拟机的大多数实现都作为单个进程运行。Java应用程序可以使用ProcessBuilder对象创建其他进程。多进程应用程序超出了本课的范围。
线程
线程有时被称为轻量级进程,进程和线程都提供执行环境,但是创建新线程所需的资源比创建新进程所需的资源少。
线程存在于一个进程中——每个进程至少有一个线程。线程共享进程的资源,包括内存和打开的文件。这有助于有效但可能存在问题的通讯。
多线程执行是Java平台的一个基本特性。每个应用程序至少有一个或多个线程(如果您计算执行内存管理和信号处理等任务的“系统”线程的话)。但是从应用程序程序员的角度来看,您只需要从一个线程(称为主线程)启动程序。这个线程能够创建其它线程,我们将在下一节中演示。
Thread Objects
每个线程都与Thread类的一个实例相关联。使用线程对象创建并发应用程序有两种基本策略。
- 直接控制线程的创建和管理,只需在每次应用程序需要启动异步任务时实例化线程。
- 从应用程序的其余部分抽象话线程的管理,将应用程序的任务传递给Executor。
本节记录线程对象的使用。Executors 在高级并发对象中讨论。
定义和启动一个线程
应用程序创建线程有以下两种方式:
- 实现Runnable接口。Runnable接口中只定义了一个方法‘run’,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的后代。本课重点介绍第一种方法,它将可运行任务与执行任务的线程对象分离开来。这种方法不仅更加灵活,而且适用于后面介绍的高级线程管理api。
Thread类定义了许多对线程管理有用的方法。其中的静态方法,它们提供关于调用该方法的线程的信息,或影响该线程的状态。其他方法是从管理线程和Thread对象所涉及的其他线程调用的。在下面的部分中,我们将研究其中的一些方法。
Sleep:延迟执行
Thread.sleep使当前线程在指定的时间段内暂停执行。这是一种使应用程序的其它线程或可能在计算机系统上运行的其它应用程序的线程可以使用处理器时间的有效方法。sleep方法还可以用于调整速度,如下面的示例所示,并等待具有时间需求的职责的另一个线程,如后面小节中的SimpleThreads示例所示。
Thread提供了两种重载版本的sleep方法:一个指定毫秒级的睡眠时间,另一个指定纳秒级的睡眠时间。然而,这些睡眠时间不能保证是精确的,因为它们受到底层操作系统的限制。另外,睡眠周期可以通过中断终止,我们将在后面的章节中看到。在任何情况下,您都不能假定调用sleep将准确地在指定的时间段内挂起线程。
SleepMessages示例使用sleep以4秒为间隔打印消息:
public class SleepMessages {
public static void main(String args[])
throws InterruptedException {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
for (int i = 0;
i < importantInfo.length;
i++) {
//Pause for 4 seconds
Thread.sleep(4000);
//Print a message
System.out.println(importantInfo[i]);
}
}
}
注意main方法的声明中抛出了InterruptedException。这是另一个线程在sleep周期内中断当前线程时抛出的异常。由于这个应用程序没有定义另一个线程来引起中断,所以它不需要捕获InterruptedException。
interrupts:中断
中断是指线程应该停止正在做的事情,并执行其他操作。由程序员决定线程如何响应中断,但是线程终止是很常见的。这是本课中强调的用法。
线程通过调用Thread对象上的interrupt方法来发送中断,以便中断线程。要使中断机制正确工作,中断的线程必须支持自身的中断。
支持中断
线程如何支持自身的中断?这取决于它当前在做什么。如果线程频繁地调用抛出InterruptedException的方法,那么它在捕获该异常之后可以从run方法返回。例如,假设SleepMessages示例中的for循环位于线程的run方法中。通过以下的方式修改可以支持中断:
for (int i = 0; i < importantInfo.length; i++) {
// Pause for 4 seconds
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
// We've been interrupted: no more messages.
return;
}
// Print a message
System.out.println(importantInfo[i]);
}
许多抛出InterruptedException的方法(如sleep)被设计成取消当前操作并在接收到中断时立即返回。
如果一个线程没有调用抛出InterruptedException的方法,该怎么办?那么它必须周期性地调用Thread.interrupt进行检测,如果接收到一个中断,它将返回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子句处理中断信号了。
中断状态标识
中断机制是使用称为中断状态的内部标志来实现的。调用Thread.interrupt设置此标志。当线程通过调用静态方法Thread.interrupted来检查中断时,将清除中断状态。一个线程用来查询另一个线程的中断状态的非静态isInterrupted方法不会更改中断状态标志。
按照惯例,任何通过抛出InterruptedException而退出的方法都会清除中断状态。然而,中断状态总是有可能被另一个调用中断的线程再次设置。
Join:连接
join方法允许一个线程去等待另一个线程完成。如果t是线程当前正在执行的线程对象,
t.join();
将会使当前线程暂停直到t线程终止。重载的join方法允许程序员指定一个等待周期。但是,与sleep一样,join的时间依赖于操作系统,所以您不应该假设join一定会等待指定的时间。
与sleep类似,join通过抛出InterruptedException来响应中断。
简单的线程示例
下面的示例汇总了本节的一些概念。SimpleThreads由两个线程组成。第一个是每个Java应用程序的主线程。主线程通过Runnable对象MessageLoop创建一个新线程,并等待它完成。如果MessageLoop线程执行花费了太长时间,主线程就会中断它。
MessageLoop线程打印出一系列消息。如果在打印完所有消息之前被中断,MessageLoop线程将打印一条消息并退出。
public class SimpleThreads {
// Display a message, preceded by
// the name of the current thread
static void threadMessage(String message) {
String threadName =
Thread.currentThread().getName();
System.out.format("%s: %s%n",
threadName,
message);
}
private static class MessageLoop
implements Runnable {
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 {
// Delay, in milliseconds before
// we interrupt MessageLoop
// thread (default one hour).
long patience = 1000 * 60 * 60;
// If command line argument
// present, gives patience
// in seconds.
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");
// loop until MessageLoop
// thread exits
while (t.isAlive()) {
threadMessage("Still waiting...");
// Wait maximum of 1 second
// for MessageLoop thread
// to finish.
t.join(1000);
if (((System.currentTimeMillis() - startTime) > patience)
&& t.isAlive()) {
threadMessage("Tired of waiting!");
t.interrupt();
// Shouldn't be long now
// -- wait indefinitely
t.join();
}
}
threadMessage("Finally!");
}
}
同步
线程主要通过共享字段访问和对象引用来进行通信。这种形式的通信非常有效,但可能出现两种错误:线程干扰和内存一致性错误。防止这些错误所需的工具是同步。
但是,同步可能会导致线程竞争,当两个或多个线程试图同时访问相同的资源时,会导致Java运行时执行一个或多个线程的速度变慢,甚至会挂起线程。饥饿和活锁是线程争用的两种形式。有关更多信息,请参见活跃度一节。
本章节包含以下主题:
- 线程干扰描述了当多个线程访问共享数据时如何造成错误。
- 内存一致性错误描述由共享内存视图的不一致导致的错误。
- 同步方法描述了一个简单的用法,它可以有效地防止线程干扰和内存一致性错误。
- 隐式(内部)锁和同步描述了一种更通用的同步用法,并描述了隐式锁是如何实现同步的。
- 原子访问讨论的是不会被其他线程干扰操作的概念。
线程干扰
思考以下的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–亦可以按照这些步骤分解,只不过第二步是减1
假设线程A调用increment,而线程B调用decrement。如果c的初值为0,它们的交错动作可能遵循以下顺序:
- 线程A检索c的值
- 线程B检索c的值
- 线程A将检索值加1
- 线程B将检索值减1
- 线程A将检索值赋值给c,c为1
- 线程B将检索值赋值给c,c为-1
在不同的情况下,可能是线程B的结果丢失了,或者根本没有错误。由于线程干扰bug是不可预测的,因此很难检测和修复它们。
内存一致性错误
当不同线程对应该是相同数据的内容有不一致的视图时,就会发生内存一致性错误。内存一致性错误的原因非常复杂,超出了本教程的范围。幸运的是,程序员不需要详细了解这些原因。所需要的只是一种避免它们的策略。
避免内存一致性错误的关键是理解happens-before的关系。这种关系只是保证一个特定语句的内存写入对另一个特定语句是可见的。要了解这一点,请考虑下面的示例。假设定义并初始化了一个简单的int字段:
int counter = 0;
counter字段被线程A和B共享,假设线程A增加counter:
counter++;
然后,不久之后,线程B打印出counter的值:
System.out.println(counter);
如果这两个语句是在同一个线程中执行的,那么可以安全地假设输出的值是“1”。但是如果这两个语句在不同的线程中执行,输出的值很可能是“0”,因为不能保证线程A对counter的更改对线程B是可见的——除非程序员在这两个语句之间建立了happens-before关系。
有多种方式可以创建happens-before关系:
- 当一条语句执行Thread.start时,‘与该语句具有happens-before关系的每个语句‘和‘新线程执行的每条语句‘也具有happens-before关系。这导致创建新线程的代码的效果对新线程是可见的。
- 当一个线程A终止时,在另一个线程B中的Thread.join调用会结束,线程A中所有语句与线程B中成功执行完join方法后的所有语句有happens-before关系。线程A代码的执行结果对线程B来说是可见的。
同步方法
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的一个实例,那么使这些方法同步化有两个效果:
- 首先,同一对象上的两个同步方法调用不可能交错。当一个线程A执行了一个对象上的同步方法,所有其他线程只能在A执行完后才能执行拥有相同对象锁的方法。
- 其次,当同步方法退出时,它会自动与相同对象的同步方法的后续调用建立happens-before关系。这保证了对象状态的更改对所有线程都是可见的。
注意,构造函数不能同步——在构造函数中使用synchronized关键字是语法错误。同步构造函数没有意义,因为只有创建对象的线程才应该在构造对象时访问它。
注意:当构造一个将在线程之间共享的对象时,要非常小心,不要过早地“泄漏”对该对象的引用。例如,假设您希望维护一个名为instances的list,其中包含类的每个实例。您可能很想在构造函数中添加以下代码:
instances.add(this);
但是其他线程可以在对象构造完成之前使用instances访问对象。
同步方法为防止线程干扰和内存一致性错误提供了一种简单的策略:如果一个对象对多个线程可见,那么对该对象的变量的所有读写都是通过同步方法完成的。(一个重要的例外是:final字段,它在构造对象之后不能修改,一旦构造了对象,就可以通过非同步方法安全地读取它)这种策略是有效的,但是它会带来 活跃度的问题,我们将在本课后面看到。
内部锁和同步
同步是围绕一个称为内部锁(intrinsic lock)或监视器锁(monitor lock)的内部实体构建的。(API规范通常将此实体简单地称为“monitor”)内部锁在同步的两个方面都发挥作用:强制对对象的状态进行独占访问,以及建立对可见性至关重要的happens-before关系。
每个对象都有一个与其相关的内部锁。按照惯例,需要对对象的字段进行排他性和一致性访问的线程必须在访问之前获得该对象的内部锁,然后访问完成后释放该内部锁。通常称在获取锁和释放锁之间线程拥有内部锁。只要有一个线程拥有一个内部锁,其他线程就不能获得相同的锁。另一个线程在试图获取锁时将阻塞。
当线程释放一个固有锁时,在该操作和后续获取相同锁的操作之间建立了happens-before关系。
同步方法中的锁
当线程调用同步方法时,它会自动获取该方法所属对象的内部锁,并在该方法返回时释放锁。即使返回是由未捕获的异常引起的,也会释放内部锁。
您可能想知道调用静态同步方法时会发生什么,因为静态方法与类而不是对象相关联。在这种情况下,线程获取与类关联的固有锁。因此,对类的静态字段的访问是由一个与类的任何实例的锁都不同的锁控制的。
同步语句(同步代码块)
另一种创建同步代码的方式是通过同步语句。不同于同步方法,同步语句必须指定提供内部锁的对象(类的内部锁和对象的内部锁都可以)
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
在本例中,addName方法需要同步对lastName和nameCount的更改,但也需要避免同步对其他对象方法的调用(从同步代码中调用其他对象的方法可能会产生有关活跃度一节中描述的问题)。如果没有synchronized语句,就必须有一个单独的、不同步的方法来调用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++;
}
}
}
使用这种方式必须非常小心,你必须确保交错访问这些对象是安全的(意思是:同时访问c1和c2是安全的)。
可重入同步
回想一下,一个线程无法获取另一个线程拥有的锁。但是线程可以获得它已经拥有的锁。允许一个线程多次获得相同的锁可以实现可重入同步。思考一下情景:同步代码直接或间接调用也包含同步代码的方法,并且两组代码使用相同的锁的情况。如果没有可重入同步,同步代码将不得不采取许多额外的预防措施,以避免线程本身阻塞。
原子访问
在编程中,原子动作实际上是同时发生的。原子行为不能在中间停止:它要么完全发生,要么根本不发生。原子操作的副作用在完成之前是不可见的。
我们已经看到,增量表达式(如c++)并不是原子操作。即使是非常简单的表达式也可以定义为可以分解成其他操作的复杂操作。然而,您可以指定操作是原子类型的:
- 对于引用变量和大多数基本变量(除了long和double之外的所有类型),读写都是原子的。
- 读写对于所有声明为volatile的变量(包括long变量和double变量)都是原子的。
原子操作不能交叉,因此可以使用它们而不用担心线程干扰。然而,这并不意味着原子操作在所有情景下都不需要同步了,因为内存一致性错误仍然是可能的。使用volatile变量可以降低内存一致性错误的风险,因为对volatile变量的任何写操作都会与该变量的后续读操作建立happens-before关系。这意味着对volatile变量的更改对其他线程总是可见的。更重要的是,它还意味着当线程读取volatile变量时,它不仅会看到volatile的最新更改,还会看到导致更改的代码的副作用。
使用简单的原子变量访问比通过同步代码访问这些变量更有效率,但是程序员需要更小心地避免内存一致性错误。额外的工作是否值得取决于应用程序的大小和复杂性。
java.util.concurrent包中的一些类提供了一些不需要依赖同步的原子方法,我们将在 高级并发对象一节中讨论它们。
活跃度(Liveness)
并发应用程序及时执行的能力称为其活跃度。本节描述最常见的一种活跃度问题死锁,然后简要描述另外两种活跃度问题饥饿和活锁。
死锁
死锁描述的是两个或多个线程永远被阻塞,互相等待对方的情况。以下是一个例子。
Alphonse和gaston是朋友,是礼貌的忠实信徒。礼貌的一个严格规则是,当你向朋友鞠躬时,你必须一直鞠躬,直到你的朋友有机会还礼。不幸的是,这条规则没有考虑到两个朋友同时向对方鞠躬的可能性。这个示例应用程序Deadlock模拟了这种可能性:
public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s"
+ " has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s"
+ " has bowed back to me!%n",
this.name, bower.getName());
}
}
public static void main(String[] args) {
final Friend alphonse =
new Friend("Alphonse");
final Friend gaston =
new Friend("Gaston");
new Thread(new Runnable() {
public void run() { alphonse.bow(gaston); }
}).start();
new Thread(new Runnable() {
public void run() { gaston.bow(alphonse); }
}).start();
}
}
当死锁运行时,两个线程在尝试调用bowBack时极有可能阻塞。任何一个阻塞都不会结束,因为每个线程都在等待另一个执行bowBack。
饥饿和活锁
饥饿和活锁问题远没有死锁常见,但仍然是每个并发软件设计人员可能遇到的问题。
饥饿
饥饿描述的是线程无法获得对共享资源的常规访问,并且无法取得进展的情况。当“贪婪”线程使共享资源长时间不可用时,就会发生这种情况。例如,假设一个对象提供了一个通常需要很长时间才能返回的同步方法。如果一个线程频繁地调用此方法,其他也需要频繁同步访问同一对象的线程将经常被阻塞。
活锁
一个线程经常响应另一个线程的动作。如果另一个线程的动作也是对另一个线程动作的响应,则可能会导致livelock。与死锁一样,活锁线程无法取得进一步的进展。然而,线程并没有被阻塞——它们只是忙于相互响应而无法继续工作。这就好比两个人在走廊里试图通过对方:Alphonse向左移动让Gaston通过,而Alphonse向右移动让Gaston通过。看到他们还在互相阻挡,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!");
}
@注:上面的意思是,线程这种循环空转会不停的占用cpu时间片,浪费cpu资源
更加有效的方式是调用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调用的返回来恢复锁。
注:还有第二个通知方法notify,它唤醒单个线程。因为notify不允许指定某一个特定的线程被唤醒,所以它只在大规模的并发应用程序中有用——也就是说,具有大量线程的程序,它们都在执行类似的任务。在这样的应用程序中,您并不关心哪个线程被唤醒。
让我们使用监控代码块创建一个生产者-消费者应用,应用在两个线程之间共享数据,生产者产生数据,消费者也会利用这些数据做一些事情。两个线程使用共享对象进行通信。协调是必不可少的:消费者线程必须在生产者线程交付数据之前不尝试检索数据,如果使用者没有检索旧数据,生产者线程也不尝试交付新数据。
在本例中,数据是一系列文本消息,通过Drop类型的对象来进行共享:
public class Drop {
// Message sent from producer
// to consumer.
private String message;
// True if consumer should wait
// for producer to send message,
// false if producer should wait for
// consumer to retrieve message.
private boolean empty = true;
public synchronized String take() {
// Wait until message is
// available.
while (empty) {
try {
wait();
} catch (InterruptedException e) {}
}
// Toggle status.
empty = true;
// Notify producer that
// status has changed.
notifyAll();
return message;
}
public synchronized void put(String message) {
// Wait until message has
// been retrieved.
while (!empty) {
try {
wait();
} catch (InterruptedException e) {}
}
// Toggle status.
empty = false;
// Store message.
this.message = message;
// Notify consumer that status
// has changed.
notifyAll();
}
}
生产者线程定义在Producer,发送一系列熟悉的消息。字符串“DONE”表示所有消息都已发送。为了模拟真实应用程序不可预测的特性,生产这线程在消息之间的随机间隔内暂停。
import java.util.Random;
public class Producer implements Runnable {
private Drop drop;
public Producer(Drop drop) {
this.drop = drop;
}
public void run() {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
Random random = new Random();
for (int i = 0;
i < importantInfo.length;
i++) {
drop.put(importantInfo[i]);
try {
Thread.sleep(random.nextInt(5000));
} catch (InterruptedException e) {}
}
drop.put("DONE");
}
}
消费者线程定义在 Consume中,简单的检索消息并打印,直到它检索到‘DONE’字符串,这个线程同样也会暂停随机的周期。
import java.util.Random;
public class Consumer implements Runnable {
private Drop drop;
public Consumer(Drop drop) {
this.drop = drop;
}
public void run() {
Random random = new Random();
for (String message = drop.take();
! message.equals("DONE");
message = drop.take()) {
System.out.format("MESSAGE RECEIVED: %s%n", message);
try {
Thread.sleep(random.nextInt(5000));
} catch (InterruptedException e) {}
}
}
}
最后,主线程定义在ProducerConsumerExample中,用来启动生成者和消费者线程。
public class ProducerConsumerExample {
public static void main(String[] args) {
Drop drop = new Drop();
(new Thread(new Producer(drop))).start();
(new Thread(new Consumer(drop))).start();
}
}
注:Drop类是为了演示监控代码块而编写的,为了避免重复工作,在尝试编写自己的数据共享对象之前,请检查Java 集合框架中的现有数据结构。有关更多信息,请参阅问题和练习部分。
不可变对象
如果对象的状态在构造之后不能更改,则认为该对象是不可变的。对不可变对象的依赖最大化被广泛认为是一种创建简单、可靠代码的可靠策略。
不可变对象在并发应用程序中特别有用。由于它们不能改变状态,因此它们不能被‘线程干扰’所破坏或在‘内存不一致’状态下被观察到。
程序员通常不愿意使用不可变对象,因为他们担心相对于就地更新对象创建新对象所需要的成本。对象创建的影响常常被高估,并且会抵消不可变对象带来的一些性能提升。这些包括垃圾收集减少的开销,以及消除保护可变对象不受损坏所需的代码。
随后的内容介绍了:获取一个实例是可变的类,并从该类派生一个具有不可变实例的类。在此过程中,它们给出了这种转换的一般规则,并展示了不可变对象的一些优点。
一个同步类的例子
类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
如果另一个线程在statement1之后statement2之前执行了color.set,myColorInt 的值就不一定是myColorName对应的颜色的值,为了避免这种结果,这2个语句必须一起执行。
synchronized (color) {
int myColorInt = color.getRGB();
String myColorName = color.getName();
}
这种不一致性只可能发生在可变对象上——对于SynchronizedRGB的不可变版本来说,不会出现这种问题。
定义不可变对象的策略
下面的规则定义了创建不可变对象的一个简单策略。并非所有记录为“不可变”的类都遵循这些规则。这并不一定意味着这些类的创建者是草率的——他们可能有充分的理由相信类的实例在构造之后永远不会改变。然而,这种策略需要复杂的分析,不适合初学者。
- 不要提供“setter”方法——修改字段的方法或字段引用的对象的方法。
- 将所有字段设置为final和private。
- 不要允许子类重写方法。最简单的方法是将该类声明为final。一种更复杂的方法是使构造函数私有,并在工厂方法中构造实例。
- 如果实例字段包含对可变对象的引用,则不允许更改这些对象:
+不提供修改可变对象的方法
+不要共享对可变对象的引用。永远不要存储传递给构造函数的外部可变对象的引用;如果需要,创建副本,并存储副本的引用。类似地,在必要时创建内部可变对象的副本,以避免返回方法中的原始对象。
将此策略应用于SynchronizedRGB需要如下步骤:
- 这个类中有两个setter方法。第一个set任意海边对象,在类的不可变版本中完全不需要。第二个是invert,可以通过创建一个新对象来进行调整,而不是修改现有的对象。
- 所有字段都已经是私有的;它们进一步被限制为final。
- 类本身被声明为final
- 只有一个字段引用一个对象,而该对象本身是不可变的。因此,没有必要防止更改“包含的”可变对象的状态。
按照以上修改,得到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 int getRGB() {
return ((red << 16) | (green << 8) | blue);
}
public String getName() {
return name;
}
public ImmutableRGB invert() {
return new ImmutableRGB(255 - red,
255 - green,
255 - blue,
"Inverse of " + name);
}
}
高级并发对象
到目前为止,这一课主要关注从一开始就是Java平台一部分的底层api。这些api对于非常基本的任务来说已经足够了,但是对于更高级的任务,需要更高级别的构建块。对于充分利用当今多处理器和多核系统的大规模并发应用程序来说,尤其如此。
在本节中,我们将研究Java平台5.0版本引入的一些高级并发特性。这些特性中的大多数都是在新的java.util.concurrent包中实现的。Java集合框架中还有新的并发数据结构。
- Lock对象简化许多并发应用程序的同步锁习惯用法。
- 调度器定义用于启动和管理线程的高级API。由java.util.concurrent提供的Executors实现,提供适合大型应用程序的线程池管理。
- 并发集合使管理大型数据集合变得更容易,并且可以大大减少同步的需要。
- 原子变量具有最小化同步和帮助避免内存一致性错误的特性。
- ThreadLocalRandom (in JDK 7)提供了从多线程高效生成伪随机数的功能。
Lock对象
同步代码依赖于一种简单的可重入锁。这种锁使用方便,但有许多局限性。java.util.concurrent.locks 包支持更复杂的锁定方式。我们不会详细研究这个包,而是将重点放在它最基本的接口Lock上。
Lock对象的工作原理非常类似于同步代码使用的隐式锁。对于隐式锁,一次只能有一个线程拥有一个锁对象。锁对象还通过其关联的Condition对象支持等待/通知机制。
与隐式锁相比,Lock对象的最大优势是它们能够在获取锁的过程中退出。如果锁不能立即可用或超过过期时间(如果指定),tryLock方法将退出。如果另一个线程在获取锁之前发送一个中断,lockInterruptibly 方法将退出。
让我们使用Lock对象来解决在活跃度一节中看到的死锁问题。Alphonse 和Gaston训练自己注意朋友什么时候要鞠躬。我们通过要求我们的Friend对象在继续使用bow之前必须为两个参与者获取锁来模拟这种改进。下面是改进模型Safelock的源代码。为了证明这个方法的功能性,我们假设Alphonse和Gaston是如此痴迷于他们新获得的安全鞠躬的能力,以至于他们无法停止互相鞠躬:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.Random;
public class Safelock {
static class Friend {
private final String name;
private final Lock lock = new ReentrantLock();
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.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.format("%s: %s has"
+ " bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
} finally {
lock.unlock();
bower.lock.unlock();
}
} else {
System.out.format("%s: %s started"
+ " to bow to me, but saw that"
+ " I was already bowing to"
+ " him.%n",
this.name, bower.getName());
}
}
public void bowBack(Friend bower) {
System.out.format("%s: %s has" +
" bowed back to me!%n",
this.name, bower.getName());
}
}
static class BowLoop implements Runnable {
private Friend bower;
private Friend bowee;
public BowLoop(Friend bower, Friend bowee) {
this.bower = bower;
this.bowee = bowee;
}
public void run() {
Random random = new Random();
for (;;) {
try {
Thread.sleep(random.nextInt(10));
} catch (InterruptedException e) {}
bowee.bow(bower);
}
}
}
public static void main(String[] args) {
final Friend alphonse =
new Friend("Alphonse");
final Friend gaston =
new Friend("Gaston");
new Thread(new BowLoop(alphonse, gaston)).start();
new Thread(new BowLoop(gaston, alphonse)).start();
}
}
调度器
在前面的所有示例中,由新线程执行的任务(由其Runnable对象定义)与由线程对象定义的线程本身之间存在紧密的联系。这对于小型应用程序很有效,但是在大型应用程序中,将线程管理和创建与应用程序的其他部分分离是有意义的。封装这些函数的对象称为调度器。下面的小节将详细描述调度器。
- Executor接口定义三种executor对象类型。
- 线程池是最常见的一种调度器实现。
- Fork/Join是一个框架(JDK 7中的新框架),为了利用多个处理器。
调度器接口
java.util.concurrent包定义了三个执行器接口
- Executor,启动新任务的简单接口
- ExecutorService是Executor的一个子接口,它添加了有助于管理生命周期(包括单个任务和Executor本身)的特性。
- ScheduledExecutorService是ExecutorService的一个子接口,它支持‘将来’或‘定期’执行任务。
通常,引用executor对象的变量被声明为这三种接口类型之一,而不是执行器类类型。
Executor
Executor接口提供了一个单独的方法execute,它被设计成一般线程创建方式的替代方法。如果r是可运行对象,e是Executor对象可以替换
(new Thread(r)).start();
为
e.execute(r);
然而,execute的定义没有那么具体。低级用法创建一个新线程并立即启动它。根据Executor实现的不同,execute可能执行相同的操作,但更可能使用现有的工作线程来运行r,或者将r放入队列中以等待工作线程可用。(我们将在线程池一节中描述工作线程。)
在java.util.concurrent中的执行器实现被设计成旨在充分利用更高级的ExecutorService和ScheduledExecutorService接口,尽管它们也可以与基本Executor接口一起工作。
ExecutorService
ExecutorService接口使用类似但更通用的submit 方法来补充execute。与execute类似,submit接受Runnable对象,但也接受允许任务返回一个值的Callable对象。submit方法返回一个Future对象,该对象用于检索Callable返回值并管理Callable和Runnable任务的状态。
ExecutorService还提供了提交大量Callable对象集合的方法。最后,ExecutorService提供了许多方法来管理执行器的关闭。为了支持立即关闭,任务应该正确地处理interrupts。
ScheduledExecutorService
ScheduledExecutorService接口用schedule来补充其父类ExecutorService的方法,schedule在指定的延迟之后执行Runnable或Callable任务。此外,该接口还定义了scheduleAtFixedRate和scheduleWithFixedDelay,它们以定义的时间间隔重复执行指定的任务。
线程池
java.util.concurrent中的大多数执行器实现使用由工作线程组成的线程池。这种线程独立于它执行的可运行和可调用任务而存在,通常用于执行多个任务。
使用工作线程可以最小化由于创建线程而带来的开销。线程对象使用大量的内存,在大型应用程序中,分配和释放许多线程对象会产生大量的内存管理开销。
一种常见的线程池类型是固定线程池。这种类型的线程池总是有指定数量的线程在运行;如果线程在仍在使用时以某种方式终止,则会自动用新线程替换它。任务通过内部队列提交到池中,当活动任务多于线程时,该队列将保存额外的任务。
固定线程池的一个重要优点是,使用它的应用程序可以优雅地降级。要理解这一点,请考虑一个web服务器应用程序,其中每个HTTP请求都由一个单独的线程处理。如果应用程序只是为每个新的HTTP请求创建一个新线程,并且系统接收的请求超过了它能够立即处理的数量,那么当所有这些线程的开销超过系统的容量时,应用程序将突然停止响应所有请求。由于对可以创建的线程数量有限制,应用程序不会像HTTP请求进入时那样快地为它们提供服务,而是以系统能够承受的最快速度为它们提供服务。
创建使用固定线程池的调度器的一种简单方法是调用java.util.concurrent.Executors 中的工厂方法newFixedThreadPool。这个类还提供了以下工厂方法:
-
newCachedThreadPool方法使用可扩展的线程池创建一个调度器。此调度器适用于启动许多短期任务的应用程序。
newSingleThreadExecutor方法创建一个每次执行一个任务的调度器。 - 有几个工厂方法是上述调度器的ScheduledExecutorService版本。
如果上述工厂方法提供的调度器都不能满足您的需求,那么构造java.util.concurrent.ThreadPoolExecutor或java.util.concurrent.ScheduledThreadPoolExecutor的实例将提供其它选择。
Fork/Join(分叉/合并)
fork/join框架是ExecutorService接口的实现,它可以帮助您利用多个处理器。它被设计成可以递归地分解成更小的部分来进行作业。它的目的是为了使用所有可用的处理能力来提高应用程序的性能。
与任何ExecutorService实现一样,fork/join框架将任务分发给线程池中的工作线程。fork/join框架与众不同的地方是它使用了一个工作窃取(work-stealing)算法。没有事情可做的工作线程可能会从仍然繁忙的其他线程窃取任务。
fork/join框架的核心是ForkJoinPool类,它是AbstractExecutorService类的扩展。ForkJoinPool实现了核心的工作窃取算法,可以执行ForkJoinTask程序。
简单使用
使用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。
在您的ForkJoinTask子类准备好之后,创建表示所有要完成的工作的对象,并将其传递给ForkJoinPool实例的invoke()方法。
模糊或者清晰
为了帮助您理解fork/join框架是如何工作的,请思考下面的示例。假设您想要模糊图像。原图像由一个整数数组表示,其中每个整数包含单个像素的颜色值。模糊的目标图像也用与原图像相同大小的整数数组表示。
模糊图像是通过一次处理一个数组中的像素来完成的。将每个像素与其周围像素求平均值(对红色、绿色和蓝色求平均值),并将结果放置在目标数组中。由于图像是一个大数组,这个过程可能需要很长时间。通过使用fork/join框架实现该算法,您可以充分利用多处理器系统上的并发处理。这里有一个可能的实现:
public class ForkBlur extends RecursiveAction {
private int[] mSource;
private int mStart;
private int mLength;
private int[] mDestination;
// Processing window size; should be odd.
private int mBlurWidth = 15;
public ForkBlur(int[] src, int start, int length, int[] dst) {
mSource = src;
mStart = start;
mLength = length;
mDestination = dst;
}
protected void computeDirectly() {
int sidePixels = (mBlurWidth - 1) / 2;
for (int index = mStart; index < mStart + mLength; index++) {
// Calculate average.
float rt = 0, gt = 0, bt = 0;
for (int mi = -sidePixels; mi <= sidePixels; mi++) {
int mindex = Math.min(Math.max(mi + index, 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;
}
// Reassemble destination pixel.
int dpixel = (0xff000000 ) |
(((int)rt) << 16) |
(((int)gt) << 8) |
(((int)bt) << 0);
mDestination[index] = dpixel;
}
}
...
现在您可以实现抽象的compute()方法,该方法可以直接模糊图片,也可以将任务分割为两个较小的任务。一个简单的数组长度阈值有助于确定工作是执行还是分割。
protected static int sThreshold = 100000;
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中运行的任务是很简单的,并涉及以下步骤:
- 创建一个代表所有作业的任务。
// source image pixels are in src
// destination image pixels are in dst
ForkBlur fb = new ForkBlur(src, 0, src.length, dst);
- 创建运行任务的ForkJoinPool
ForkJoinPool pool = new ForkJoinPool();
- 运行任务
pool.invoke(fb);
有关完整的源代码,包括一些创建目标图像文件的额外代码,请参见ForkBlur示例。
标准实现
除了使用fork/join框架实现自定义的算法,在多处理器的系统上并发的执行任务(比如上节中提到的例子ForkBlur),Java SE中还有一些通常有用的特性已经使用fork/join框架实现。在Java SE 8中介绍了的一种这样的实现:java.util.Arrays中的parallelSort()方法。这些方法类似于sort(),但是通过fork/join框架利用并发性。在多处理器系统上运行时,大型数组的并行排序比顺序排序快。但是,这些方法如何利用fork/join框架超出了Java教程的范围。有关此信息,请参见Java API文档。
fork/join框架的另一个实现是java.util.streams包中的方法,它是计划用于Java SE 8版本的Lambda项目的一部分。有关更多信息,请参见Lambda表达式部分。
并发集合
java.util.concurrent包包含对Java集合框架的大量额外扩展。它们最容易按照提供的集合接口进行分类:
- BlockingQueue定义了一个先入先出的数据结构,当您试图向填满的队列中添加数据或从空队列检索数据时,该结构将阻塞或超时。
- ConcurrentMap是java.util.Map 的一个子接口。定义有用原子操作的映射。这些操作仅在键存在时删除或替换键值对,或仅在键不存在时添加键值对。使这些操作原子化有助于避免同步。ConcurrentMap的标准通用实现是ConcurrentHashMap,它是HashMap的并发模拟。
- ConcurrentNavigableMap是ConcurrentMap的一个子接口,它支持近似匹配。ConcurrentNavigableMap的标准通用实现是ConcurrentSkipListMap,它是TreeMap的并发模拟。
所有这些集合都通过定义将对象添加到集合的操作与后续的访问或删除该对象操作之间的happens-before关系来帮助避免内存一致性错误。
原子变量
com/javase/8/docs/api/java/util/concurrent/atomic/package-summary.html">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;
}
}
使Counter不受线程干扰的一种方法是使其方法同步,如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中所做的那样:
import java.util.concurrent.atomic.AtomicInteger;
class AtomicCounter {
private AtomicInteger c = new AtomicInteger(0);
public void increment() {
c.incrementAndGet();
}
public void decrement() {
c.decrementAndGet();
}
public int value() {
return c.get();
}
}
并发随机数
在JDK 7中,java.util.concurrent包括一个方便的类ThreadLocalRandom,用于希望在多个线程或ForkJoinTasks任务中使用随机数的应用程序。
对于并发访问,使用ThreadLocalRandom而不是Math.random()可以减少竞争,最终获得更好的性能。
您只需要调用ThreadLocalRandom.current(),然后调用其中一个方法来检索随机数。这里有一个例子:
int r = ThreadLocalRandom.current() .nextInt(4, 77);