java支持并发编程,语言本身提供基础了并发支持,而java.util.concurrent
类库提供了一些高层的API。
进程和线程
并发编程有两个基本的执行单元:进程和线程。对于java,并发编程大部分时候和线程相关,但是进程也很重要。
进程拥有独立的执行环境和内存空间。进程通常被视为程序或应用的同义词,但是,一个应用实际上很可能是许多相互合作进程的集合。为了进程间的高效沟通,大部分操作系统支持IPC(Inter Process Communicatin),比如管道和套接字。IPC不仅用于同系统间的进程通信,也可以用于不同系统间的进程。
JVM的大部分实现以单进程运行。一个java应用可以使用ProcessBuilder
对象创建额外的进程。这里我们暂不讨论多进程应用。
线程有时被称为轻量级进程,进程和线程都提供一个独立的执行环境,但是创建线程的资源开销比进程少。线程在进程内存在,每个进程至少有一个线程。为了高效的通信,线程共享进程的资源,包括内存和文件,但也会带来一些潜在的问题。
多线程执行是java的基本特性,从程序员的角度看,你先以一个线程开始,叫main thread
主线程,该线程可以创建额外的线程。
线程对象
每个线程都是Thread
的实例,使用Tread
对象创建并发应用有两种基本策略:
- 直接控制线程的创建和管理,每次应用需要开始一个异步任务时就实例化线程对象。
- 将线程的管理从应用中抽象出来,将应用的任务传给执行器。
定义并运行一个线程
创建线程实例的应用必须提供线程要运行的代码,有两种方式:
-
提供一个
Runnable
对象。实现Runnable
接口中的run
方法,将该对象传给Thread
的构造器:public class HelloRunnable implements Runnable { public void run() { System.out.println("hell from a thread"); } public static void main(String[] args) { Thread thread = new Thread(new HelloRunnable()); thread.start(); } }
-
子类化
Thread
。Thread
类本身实现了Runnable
,但是其run
方法啥也不做,应用可以子类化Thread
,并提供run
实现: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
对象。子类化Thread
局限比较大,只适合简单的应用。
睡眠
Thread.sleep
方法可以暂停当前线程的执行,将处理器时间让给其他线程或应用。但是睡眠是可以被打断的,因此任何情况下,都不要假定调用sleep
就可以让某线程暂停特定的时长。
睡眠示例:
public class SleepMessage {
public static void main(String[] args) throws InterruptedException {
String[] message = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
for (String s : message) {
System.out.println(s);
Thread.sleep(2000);
}
}
}
注意,如果另一个线程中断了当前线程的睡眠状态,会抛出InterruptedException
异常。因为这里没有其他进程中断当前线程的运行,因此我们不必捕获该错误。
中断
中断(interrupt
)通知线程它该停止正在进行的事情,来做些其他事情。至于线程如何响应中断,取决于程序员。一个线程通过调用目标线程的interrupt
方法来发送中断信号。
要想中断机制正确的运作,被中断的线程必须支持中断。比如收到中断信号后,立即返回:
for (String s : message) {
System.out.println(s);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
return;
}
}
很多方法会抛出InterruptedException
异常,比如sleep
,被设计为一旦收到中断信号,立即取消当前操作并返回。但是如果线程一直不调用这些抛出InterruptedException
异常的方法怎么办?那么它必须周期性地调用Thread.interrupted
方法,来检测是否收到了中断信号,比如:
for (String s : message) {
System.out.println(s);
if (Thread.interrupted()) {
return;
}
}
这里是检测到中断信号后,直接返回。在复杂的应用中,收到中断信号后,抛出InterruptedException
更合理:
if (Thread.interrupted()) {
throw new InterruptedException();
}
这样就可以将处理中断的逻辑都放在catch
代码块中。
中断机制的实现是通过一个内部的标志位——中断状态。调用Thread.interrupt
可以设置这个标志。调用静态方法Thread.interrupted
可以检测中断。非静态方法isInterrupted
,用于一个线程查询另一个线程的中断状态,但是不会改变中断状态标志位。
任何抛出中断异常的方法都会清除中断状态。但是,只要另一个线程再次调用中断,就可以重新设置中断状态。
等待 join
t.join()
,令当前线程等待另一个线程(t)完成。它也可以接收参数,指定等待多久。就像sleep
,join
方法可以抛出中断异常来响应一个中断。
示例
public class SimpleThreads {
static void threadMessage(String msg) {
String threadName = Thread.currentThread().getName();
System.out.format("%s: %s%n", threadName, msg);
}
private static class MsgLoop implements Runnable {
@Override
public void run() {
String[] msg = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
try {
for (String s : msg) {
Thread.sleep(4000);
threadMessage(s);
}
} catch (InterruptedException e) {
threadMessage("i wasn't done");
}
}
}
public static void main(String[] args) throws InterruptedException {
long patience = 1000 * 10;
threadMessage("starting msgLoop thread");
long startTime = System.currentTimeMillis();
Thread t = new Thread(new MsgLoop());
t.start();
threadMessage("waiting for msgLoop thread to finish");
while (t.isAlive()) {
threadMessage("still waiting...");
t.join(1000);
if ((System.currentTimeMillis() - startTime) > patience && t.isAlive()) {
threadMessage("Tired of waiting");
t.interrupt();
t.join(); // 等t响应中断,否则会继续往下执行,打印 main: still waiting..., 然后才打印t线程中的中断信息
}
}
threadMessage("Finally");
}
}
打印结果如下:
main: starting msgLoop thread
main: waiting for msgLoop thread to finish
main: still waiting...
main: still waiting...
main: still waiting...
main: still waiting...
Thread-0: Mares eat oats
main: still waiting...
main: still waiting...
main: still waiting...
main: still waiting...
Thread-0: Does eat oats
main: still waiting...
main: still waiting...
main: Tired of waiting
Thread-0: i wasn't done
main: Finally
同步(Synchronization)
线程通信主要是通过共享对字段以及字段引用的对象的访问。这种通信及其高效,但是可能造成两种可能的错误:线程干扰和内存一致性错误。同步可以防止这些错误。
但是,同步会引入线程争抢,当多个线程同时访问同样的资源时会导致java运行时执行其中一些线程慢一些,甚至暂停执行。饥饿和活锁就是线程争抢的形式。
线程干扰 Thread Interference
下面看一个简单的计数器:
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
调用increment
,c加1, 调用decrement
,c减1。假如多个线程引用同一个计数器对象,线程干扰可能导致计数异常。
当运行在不同线程,但作用于同样数据的两个操作交错时,就会发生干扰。这意味着两个操作都包含多个步骤,且步骤的顺序发生了重叠。
这里似乎不可能发生交错,因为对c的操作都是单个简单语句。但是,即便是最简单的语句,虚拟机也要翻译为多个步骤。我们没必要测试虚拟机执行的具体步骤,只要知道表达式c++
可以分解为以下3步即可:
- 获取c的当前值
- 当前值加1
- 当前值赋值再赋值给c
c--
也是类似。假设线程A调用increment
,同时线程B调用decrement
,c的初始值是0,AB交错可能是下面的顺序:
- A:获取c的值
- B:获取c的值
- A:对返回值加1,结果是1
- B:对返回值减1,结果是-1
- A:结果赋值给c,c现在是1
- B:结果赋值给c,c现在是-1
线程A的结果丢了,被线程B的覆写了。这种交错只是一种可能,也有可能是B的结果丢失,或者完全不发生错误。正是因为不可预测,线程干扰的bug很难定位和修复。
内存一致性错误 Memery Consistency Errors
先看例子,首先声明一个如下的变量:
int counter = 0;
并且线程A和B共享这个counter变量。假设A自增counter:
counter++;
紧接着B打印counter:
System.out.println(counter);
如果以上两个语句在同一线程中执行,可以很安全地打印出“1”,但是如果在不同的线程中执行,那结果很可能是“0”,因为不能保证A的操作对B可见,除非程序员已经明确了这两个语句的事前关联。
有些行为可以用来确定事前关联(其中之一就是同步,这个稍后讨论),我们目前已经见过了两种:
- 当某个语句调用
Thread.start
时,每个与该语句有事前关联的语句,也会与新线程中执行的语句有事前关联。 - 当一个线程终止并导致另一个线程
Thread.join
时,终止线程的所有语句与join后的所有语句有事前关联。该线程代码的效果对于执行join的线程是可见的。
同步方法 Synchronized Methods
java提供了两种基本的同步用法:
- 同步方法 synchronized methods
- 同步语句 synchronized statements
后者要复杂一下,稍后再讨论。这里我们先看同步方法,要想使一个方法变成同步方法,只需在声明处添加synchronized
关键字即可:
public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
同步方法有以下作用:
- 首先,不可能交错调用同一对象的同步方法。当一个线程在执行一个对象的某个同步方法时,所有调用该对象任何同步方法的其他线程都会阻塞,直到第一个线程处理完毕。
- 其次,只要存在同步方法,就会与随后对该对象同步方法的调用自动确定事前关联。这样可以保证对该对象的改变对其他线程是可见的。
记住,构造器不能被同步化,在构造器上使用synchronized
关键字是句法错误,并且没有任何意义,因为对象被创建时,只有创建该对象的线程才会访问它。
同步方法提供了一种简单的策略来防止线程干扰和内存一致性错误。如果一个对象对多个线程可见,对该对象变量的所有读写都通过同步方法完成(除了final
字段,对象创建后便不可修改,因此可以安全地通过非同步方法读写)。但是同步方法也会导致活跃度(liveness),这个稍后讨论。
内在锁和同步 Intrinsic Locks and Synchronization
同步基于一个内部实体,称为内在锁(或监视锁)。内在锁强制对对象状态的互斥访问,并确定事前关联。每个对象都有一个和自身关联的内在锁。通常,一个线程如果需要对某对象字段的互斥和一致性访问,必须先获得该对象的内在锁,并在访问完成后释放锁。在获得锁和释放锁之间,我们说该线程拥有内在锁,这时其他线程无法获得同样的锁,要想获得需要阻塞等待。当一个线程释放锁时,该行为和后续获得锁的行为之间的事前关联就确定了。
同步方法中的锁
当线程调用某个同步方法时,它自动获得该方法对象的内在锁,并在方法返回(或抛出异常)后释放锁。另外,如果调用一个静态的同步方法,又会如何呢?由于静态方法和类关联,而不是对象,因此线程将获得类的内在锁。
同步语句
同步语句(synchronized statements)是另一种创建同步代码的方式。和同步方法不同的是,同步语句必须指定提供内在锁的对象。比如:
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name); // 不希望同步调用
}
在方法addName
中,对lastName
和nameCount
的修改需要保证同步,但是要避免对其他对象方法的同步调用。如果没有同步语句,就需要在一个单独的,非同步的方法中调用nameList.add
。
同步语句有助于在更细的同步粒度上提高并发。看下面的示例:
public class Foo {
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++;
}
}
}
在这个示例中,类Foo有两个实例字段,c1和c2,二者绝不会一起使用,且单独对任一字段的更新必须保证同步,但是不必在更新c2时阻止对c1的更新,否则不必要的阻塞会降低并发。这里我们既不使用同步方法,也通过this
使用锁,而是单独创建两个对象来提供锁。这样使用时必须十分小心,你必须百分百确定,交错访问这些字段是安全的。
重入同步
一个线程不能获得另一个线程拥有的锁,但是,它可以再次获得它已经拥有的锁,这叫做重入同步(reentrant synchronization)。它描述了一种情形,同步代码直接或间接地调用一个也包含同步代码的方法,并且两套代码使用同一把锁。如果没有重入同步,同步代码必须采取额外的预防措施,以避免线程自身阻塞。
原子访问
原子行为要么完全发生,要么完全不发生。
即便是很简单的表达式也可以由多个行为构成,比如c++
,并不是一个原子行为。但是有些行为可以指定为原子性:
- 对引用变量和大部分基本变量(除了
long
和double
)的读写是原子性的 - 对所有声明为
volatile
的变量的读写是原子性的(包括long
和double
变量)
原子行为不能被交错,所以使用时不用担心线程干扰。但是,这无法排除对同步原子行为的需要,因为内存一致性错误仍有可能发生。使用volatile
变量可以降低内存一致性错误的风险,因为任何对volatile
变量的写操作都会与后续的读操作确定事前联系。这意味对volatile
变量的修改对其他线程是可见的。另外,这也意味着,如果一个线程读取一个volatile
变量,它不仅看到的是最新的修改,而且也包含导致这个修改的代码的副作用。
使用简单的原子变量访问比通过同步代码访问更高效,但是需要开发人员注意避免内存一致性错误。
java.util.concurrent
包中提供了不依赖于同步的原子方法,这个我们稍后在高层并发中讨论。
活跃度 Liveness
一个并发应用程序及时执行的能力称之为活跃度。这部分讨论一种最常见的活跃度问题,死锁(deadlock),并简要地描述另外两种活跃度问题,饥饿(starvation)和活锁(livelock)。
死锁
死锁是指两个或多个线程永远阻塞,互相等待。下面我们看一个示例,Jerry和Tom都是非常礼貌的孩子,打招呼时行鞠躬礼,别人行礼时,他们也会回礼。并且只有当对方回礼时,另一个人才能起身。不过他们没有考虑到同时行礼的情况,于是就尴尬了:
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 friend) {
System.out.format("%s: 向%s鞠躬!%n", this.name, friend.getName());
friend.bowBack(this);
}
public synchronized void bowBack(Friend friend) {
System.out.format("%s: 向%s回礼!%n", this.name, friend.getName());
}
}
public static void main(String[] args) {
final Friend jerry = new Friend("Jerry");
final Friend tom = new Friend("Tom");
// 第一次见面jerry先向tom打招呼
jerry.bow(tom);
// 第二次见面,tom先向jerry打招呼
tom.bow(jerry);
}
}
以上代码顺序执行时,输出结果如下:
Jerry: 向Tom鞠躬!
Tom: 向Jerry回礼!
Tom: 向Jerry鞠躬!
Jerry: 向Tom回礼!
假如两人见面时同时鞠躬:
// A线程
new Thread(new Runnable() {
@Override
public void run() {
jerry.bow(tom);
}
}).start();
// B线程
new Thread(new Runnable() {
@Override
public void run() {
tom.bow(jerry);
}
}).start();
那么输出如下,然后程序卡永远卡住,无法继续执行下去:
Jerry: 向Tom鞠躬!
Tom: 向Jerry鞠躬!
谁都不能回礼,这是因为,线程A调用jerry的bow方法时,B同时调用tom的bow方法。二者都要互相调用对方的bowBack方法才能返回,但bow和bowBack都是同步方法,一个线程在调用时,另一个线程不得调用。对于A线程来说,线程A要执行tom的bowBack,但是B线程正在调用tom的bow方法,所以要等B先执行完,反之亦然。
饥饿和活锁
这两种情况比死锁要少见,但是也是开发者要考虑的问题。
饥饿(starvation)指的是一个线程无法获得对共享资源的正常访问而无法继续下去。当”贪婪“线程长时间使共享资源不可用时,就会发生这种情况。比如,一个对象有个同步方法,需要较长时间才能返回。如果某个线程频繁地调用该方法,其他需要同步访问该对象的线程就会经常被阻塞。
一个线程的行动是对另一个线程的响应,如果另一个线程的行动也是对其他线程的响应,就可能导致活锁(livelock)。就像死锁一样,活锁的的线程无法继续进行下去。但是,这些线程并没有被阻塞,它们只是忙于互相响应以至于无法恢复工作。就好比相对而行的两个人在走廊中要互相通过一样:A往自身的左边走,让B通过,同时呢,B往自己的右边走,让A通过,结果就是两人还是互相堵着。然后他们决定换个方向,A往自身的右边走,同时B往自身的左边走,还是互相堵着,然后。。。
守卫块 Guarded Blocks
线程之间经常需要互相合作,最常见的方式是使用guarded block,它循环检查一个条件,直到为真才继续往下执行。
示例:
public void guardedJoy() {
while(!joy) {}
System.out.println("Joy has been achieved!");
}
guardedJoy
方法如果要执行下去,需要检测到另一个线程设置共享变量joy
值为真。理论上,简单的while循环可以实现这一需求,但是,循环太浪费了,当前线程在等待期间也要不断地执行。
更高效的方式是调Object.wait
挂起当前线程,直到另一个线程发出事件通知, wait抛出中断异常:
public synchronized void guardedJoy() {
while(!joy) {
try {
wait(); // 当前线程在此处挂起,等待wait返回
} catch (InterruptedException e) {}
}
System.out.println("Joy and efficiency have been achieved!");
}
记住,一定要在循环体内调用wait方法,这样可以检测条件是否确实满足,因为通知的事件不一定是当前线程等待的。这样,比起简单循环,守卫块只有每次事件通知时才会循环一次。
和其他中断执行的方法一样(比如sleep, join),wait也会抛出InterruptedException。因为我们只是想在收到事件中断后判断joy的值,所以这里只捕获异常,但是不做任务处理。
一个线程要调用object.wait方法,必须先获得该对象的内部锁,而获得内部锁的最简单方法就是在同步方法中调用,这也就是为什么guardedJoy是同步的。调用wait后,当前线程释放锁并暂停执行。在未来某个时间,另一个线程会获得该锁,然后调用Object.notifyAll
,通知所有等待该锁的线程,有大事发生了,如下:
public synchronized notifyJoy() {
joy = true;
notifyAll();
}
重新获得该锁的线程从wait的调用处返回,恢复运行。
另外,除了notifyAll
,还有一个notify
方法,只会唤起一个线程,但是又不能指定唤醒哪个线程。对于有大量线程并行执行相似的任务,而你又不关心唤醒哪个线程时,可以使用notify。
了解完守卫块后,我们来写一个生产者消费者模型的应用:生产线程生成数据,消费线程消费数据,二者通过共享对象通信,并且消费线程在生产线程生产未就绪时,不得去取数据,如果消费线程还有旧的数据未消费,生产线程也不得生产新的数据。
假设数据是一串文本消息,并且通过Drop的对象共享:
package producer.demo;
public class Drop {
private String message;
private boolean empty = true; // 生产和消费的判断条件
public synchronized String take() {
while(empty) {
try {
wait();
} catch (InterruptedException e) {}
}
empty = true;
notifyAll();
return this.message;
}
public synchronized void put(String message) {
while (!empty) {
try {
wait();
} catch (InterruptedException e) {}
}
empty = false;
this.message = message;
notifyAll();
}
}
生产者:
package producer.demo;
import java.util.Random;
public class Producer implements Runnable{
private Drop drop;
public Producer(Drop drop) {
this.drop = drop;
}
public void run() {
String[] messages = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
Random random = new Random();
for (String msg : messages) {
this.drop.put(msg);
// 模拟生产间隔时间
try {
Thread.sleep(random.nextInt(5000));
} catch (InterruptedException e) {}
}
// DONE 告诉消费者所有消息已发送完成
this.drop.put("DONE");
}
}
消费者:
package producer.demo;
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 msg = this.drop.take(); !msg.equals("DONE"); msg = this.drop.take()) {
System.out.println(msg);
try {
Thread.sleep(random.nextInt(5000));
} catch (InterruptedException e) {}
}
}
}
主程序:
package producer.demo;
public class App {
public static void main(String[] args) {
Drop drop = new Drop();
(new Thread(new Producer(drop))).start();
(new Thread(new Consumer(drop))).start();
}
}
注意,这里使用自定义的Drop对象只是为了演示守卫块。在实际使用时,可以用java集合框架中提供的数据结构。
不可变对象
一个对象如果创建后状态无法修改,就是不可变对象(immutable)。
不可变对象对于构建简单,可靠的代码非常有帮助。在并发应用中,它们的状态无法被改变,不会被线程干扰,不会出现一致性错误。
开发人员通常不情愿使用不可变对象,他们担心创建新对象的开销要大于更新现有对象。其实这种开销通常被高估了,不可变对象带来的效率会抵消这种开销:它可以减少了垃圾回收的开销,并且省去为了确保可变对象不要发生错误的代码。
下面我们看看如何将一个可变对象的类转化为不可变对象的类,并演示使用不可变对象的优势。
代表颜色的一个同步类:
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
如果另一个线程在语句1和语句2之间调用了color.set方法,那么第一个线程获取的色值和色名就会匹配不上。为了避免这种情况出现,两个语句需要绑定在一起执行:
synchronized (color) {
int myColorInt = color.getRGB();
String myColorName = color.getName();
}
这种不一致只有可变对象才可能出现,而不可变对象无此问题。
以下规则适用于创建不可变对象:
- 不要提供可以修改字段或其引用对象的“setter”方法
- 将所有的字段声明为
final
和private
- 不要允许子类化来重写方法,最简单的方式是将类声明为
final
,更老到的做法是,将构造方法声明为private
,在工厂方法中构造实例。 - 如果实例字段包含对可变对象的引用,不要允许对这些对象的修改:
- 不提供修改可变对象的方法
- 不要共享对可变对象的引用。不要存储外部可变的对象的引用,如果构造方法必须要接受外部可变对象,创建这些可变对象的副本,然后存储对副本的引用。类似地,避免直接在方法中返回内部可变对象,必要时返回创建的副本。
应用以上规则,将之前的SynchronizedRGB变为不可变的ImmutableRGB:
// 声明类为final,不可被子类化
final public class ImmutableRGB {
// 所有字段fianl private
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;
}
// set 方法被移除
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,尤其是现在的高并发应用,需要充分利用多核处理器及操作系统。
接下来我们看一些java5引入的高层并发特性,这些特性大部分都在java.util.concurrent
包中有实现。java的集合框架中也定义了新的并发数据结构。
Lock
锁对象简化锁的使用Executors
为加载和管理线程定义的高层API,其实现提供了线程池- 并发集合使得管理大型集合更容易,极大地减少对同步的需要
- 原子变量可以最小化同步,并且避免内存一致性错误
ThreadLocalRandom
(JDK7)可以在多线程下高效地生成随机数
锁对象 Lock
同步代码依赖某种简单的可重入锁,它很易用,但是也有很多限制。java.util.concurrent.locks
包中提供了更好的锁用法,其中最基本的接口就是Lock
。Lock对象和之前同步代码中使用的内部锁很相似,就像内部锁一样,一次只能一个线程获得锁对象。通过关联的Condition
对象,Lock对象也支持wait/notify
机制。
比起内部锁,Lock对象的最大优势是在尝试获取锁时可以撤回。tryLock
方法在锁不可用或超时后,可以立即撤回。lockInterruptibly
方法在获得锁前如果收到另一个线程发送的中断也会撤回。
现在让我们利用锁对象解决死锁问题。jerry和tom学会了观察对方是否要鞠躬。我们来模拟这个改善,要求Friend对象在执行bow时必须获得双方的锁。这里使用的是Lock接口的重入锁实现ReentrantLock
:
import java.util.Random;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
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 backBower) {
boolean myLock = false;
boolean yourLock = false;
try {
// 获取自己的锁和对方的锁
myLock = this.lock.tryLock();
yourLock = backBower.lock.tryLock();
} finally {
// 只要有任一一方的锁未获得,便释放已获得的锁
if (! (myLock && yourLock)) {
if (myLock) {
lock.unlock();
}
if (yourLock) {
backBower.lock.unlock();
}
}
}
return myLock && yourLock;
}
public void bow(Friend backBower) {
// 只要同时获得自己和对方的锁,才能鞠躬;鞠躬完后释放锁
if (impendingBow(backBower)) {
try {
System.out.format("%s: 向%s鞠躬!%n", this.name, backBower.getName());
backBower.bowBack(this);
} finally {
this.lock.unlock();
backBower.lock.unlock();
}
} else {
System.out.format("%s: 我刚要对%s鞠躬,但是看到他已经向我鞠躬了%n", this.name, backBower.getName());
}
}
public void bowBack(Friend bower) {
System.out.format("%s: 向%s回礼%n", this.name, bower.getName());
}
}
static class BowLoop implements Runnable {
private Friend bower;
private Friend backBower;
public BowLoop(Friend bower, Friend bowee) {
this.bower = bower;
this.backBower = bowee;
}
public void run() {
Random random = new Random();
for(;;) {
try {
Thread.sleep(random.nextInt(10));
} catch (InterruptedException e) {}
bower.bow(backBower);
}
}
}
public static void main(String[] args) {
final Friend jerry = new Friend("Jerry");
final Friend tom = new Friend("tom");
(new Thread(new BowLoop(jerry, tom))).start();
(new Thread(new BowLoop(tom, jerry))).start();
}
}
执行器 Executors
在之前的例子中,任务(Runnable对象)和线程(Thread对象)紧密耦合。在小型应用中,这样没问题,但是在大型应用中,必须将线程的管理和创建与应用解藕,而封装这种功能的对象,就是执行器。接下来看看几种具体的执行器:
- Executor Interfaces 定义了三种执行器对象类型
- Thread Pools 最通用的执行器实现
- Fork/Join JDK7中新引用的框架,以充分利用多核处理器
####Executor Interfaces
java.util.concurrent
包中定义了三种执行器接口:
Executor
, 支持启动任务的简单接口ExecutorService
,Executor
的子接口,增加了对任务和执行器的生命周期管理ScheduleExecutorService
,ExecutorService
的子接口,支持任务的延迟或周期性之行
Executor Interface
Executor 接口只提供了一个方法execute
,用来替代线程创建,比如:
(new Thread(runnableObj)).start();
可以替换为:
e.execute(runnableObj);
execute
方法在不同的Executor实现中可能有所不同,低层的实现直接为任务创建新的线程,但更可能是利用现有的工作线程来运行任务,或者将任务放入队列等待可用的工作线程(我们在稍后的线程池中会再说工作线程)。
ExecutorService Interface
提供了更强大的submit
方法,除了可以接受Runnable对象,还可以接受Callable对象作为任务,后者可以有返回值。submit
方法返回Future
对象,用于获取Callable对象的返回值,也可以用于管理任务的状态。ExecutorService还提供了一些方法来管理执行器的关闭,如果要支持立即关闭,任务需要正确处理中断。
ScheduledExecutorService Interface
作为ExecutorService的子接口,其实现了父类中所有方法的schedule版,支持延时执行。而且,接口定义了scheduleAtFixedRate
和scheduleWithFixedDelay
,支持以指定的间隔重复执行任务。
线程池 Thread Pools
java.util.concurrent包中的大部分执行器实现都使用线程池,线程池由工作线程组成,这些线程和它要执行的任务是解藕的。任务通过一个内部队列提交到线程池,并且多余的任务可以暂存在队列中。这样,系统就可以有条不紊的处理大量任务,避免了线程数量过载造成的宕机。
要创建使用线程池的执行器,可以调用java.util.concurrent.Executors
的工厂方法:
newFixedThreadPool
创建使用固定线程池大小的执行器newCachedThreadPool
适合处理大量短时任务的应用newSingleThreadExecutor
一次执行一个任务- 上述执行器的schedule版
如果上述执行器没有你需要的,还可以使用java.util.concurrent.ThreadPoolExecutor
或者java.util.concurrent.ScheduleThreadPoolExecutor
。
Fork/Join
fork/join框架是ExecutorService接口的一种实现,旨在充分利用多核处理器的优势。它适合那些可以递归地分割为更小片段的工作。
和ExecutorService的其他实现一样,fork/join框架也是分发任务给线程池中的工作线程。但是fork/join的独特之处在于,它使用work-stealing工作窃取算法,完成任务的线程可以从其他繁忙的线程那里“偷取”任务。它的核心是ForkJoinPool
这个类(是AbstractExecutorService
的子类),它实现了work-stealing算法,可以执行ForkJoinTask
任务。
基本使用
使用fork/join框架的第一步是写执行单个片段的代码,伪代码如下:
if (工作足够小)
直接执行
else
切分工作为两个片段
调用两个片段,等待执行结果
将这段代码包装在ForkJoinTask
子类中,一般是RecursiveTask
(可以返回一个结果)或者RecursiveAction
,代表工作任务,将它传给ForkJoinPool实例的invoke
方法。
模糊图片的例子
假如图片由一个整数数组表示,每个整数代表一个像素的色值。如果要对图片做模糊,需要处理每个像素,这个处理过程相当耗时,通过fork/join框架,就可以并行处理。
package join.demo;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;
public class ForkBlur extends RecursiveAction {
private int[] source; // 原图片像素数组
private int start; // 起始像素位置(数组索引),一开始是0
private int end; // 结束像素位置,一开始是数组长度
private int[] destination; // 模糊后的像素数组
private static int max = 100000; // 最大像素处理数量
public ForkBlur(int[] src, int start, int length, int[] dst) {
this.source = src;
this.start = start;
this.end = length;
this.destination = dst;
}
protected void computeDirectly() {
// do something to calculate new pixel value and write to mDestination
;
}
// 实现compute()方法,它根据工作的大小来决定是直接执行,还是分解为更小的任务,直至任务分解到足够小,能够被直接执行
@Override
protected void compute() {
if (end < max) {
computeDirectly();
return;
}
int split = end / 2;
invokeAll(new ForkBlur(source, start, split, destination),
new ForkBlur(source, start + split, end - split, mDestination));
}
public static BufferedImage blur(BufferedImage srcImage) {
int w = srcImage.getWidth();
int h = srcImage.getHeight();
int[] src = srcImage.getRGB(0, 0, w, h, null, 0, w);
int[] dst = new int[src.length];
ForkBlur fb = new ForkBlur(src, 0, src.length, dst);
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(fb);
BufferedImage dstImage = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
dstImage.setRGB(0, 0, w, h, dst, 0, w);
return dstImage;
}
public static void main(String[] args) throws Exception {
String srcName = "demo.jpg";
File srcFile = new File(srcName);
BufferedImage image = ImageIO.read(srcFile);
BufferedImage blurredImage = blur(image);
String dstName = "blurred.jpg";
File dstFile = new File(dstName);
ImageIO.write(blurredImage, "jpg", dstFile);
}
}
标准实现中的并行计算
除了直接使用fork/join框架利用多核处理器作并行计算,java中一些常用特性的实现也利用了fork/join框架。比如java se 8中,java.util.Arrays
的parallelSort()方法
。
另外一些利用fork/join框架的实现在java.util.streams
包中。
并发集合
java.util.concurrent
包中还有许多额外的集合框架:
BlockingQueue
先进先出数据结构,队列满时或者空时,添加和获取操作会阻塞或超时。ConcurrentMap
接口定义了一些有用的原子性操作。只有键存在时,移除或者替换操作才会进行,只有键不存在时,添加键值对的操作才会进行。使这些操作原子化,避免了同步。它的通用实现是ConcurrentHashMap
,也就是HashMap
的并发模式。ConcurrentNavigableMap
是ConcurrentMap
的子接口,支持近似匹配。它的通用实现是ConcurrentSkipListMap
,也就是TreeMap
的并发模式。
所有这些集合都有助于避免内存一致性错误,它会在添加操作与之后的访问或删除操作之间,定义清楚事发前关系。
下面看看利用BlockingQueue
代替自定义的Drop类,简化生产者消费者模型:
生产者:
package producer2.demo;
import java.util.Random;
import java.util.concurrent.BlockingQueue;
public class Producer implements Runnable {
private BlockingQueue<String> drop;
public Producer(BlockingQueue<String> drop) {
this.drop = drop;
}
@Override
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();
try{
for (String msg: importantInfo) {
drop.put(msg);
Thread.sleep(random.nextInt(5000));
}
drop.put("DONE");
} catch (InterruptedException e) {}
}
}
消费者:
package producer2.demo;
import java.util.Random;
import java.util.concurrent.BlockingQueue;
public class Consumer implements Runnable{
private BlockingQueue<String> drop;
public Consumer(BlockingQueue<String> drop) {
this.drop = drop;
}
@Override
public void run() {
Random random = new Random();
try {
for (String msg = drop.take(); !msg.equals("DONE"); msg = drop.take()) {
System.out.format("msg received: %s%n", msg);
Thread.sleep(random.nextInt(5000));
}
} catch (InterruptedException e) {}
}
}
入口:
package producer2.demo;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.SynchronousQueue;
public class App {
public static void main(String[] args) {
BlockingQueue<String> drop = new SynchronousQueue<>();
(new Thread(new Producer(drop))).start();
(new Thread(new Consumer(drop))).start();
}
}
原子变量
java.util.concurrent.atomic
包中定义了一些类,支持对单个变量的原子性操作。所有的类都有get
和set
方法,类似于volatile
变量的读和写方法。就是说,对同一变量,set
和后续的get
之间有事发前关系。之前演示线程干扰的计数器示例,除了将计数器各方法变为同步方法外,更可取的方式是使用原子变量,这样可以避免不必要同步造成的活跃度影响。
import java.util.concurrent.atomic.AtomicInteger;
class AtomicCount {
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
中使用随机数。在并发访问中,使用它代替Math.random()
可以获得更好的性能:
int r = ThreadLocalRandom.current().nextInt(4, 77);