《实战Java高并发程序设计》读书笔记

[TOC]

1. 走入并行世界

1.1 何去何从的并行计算

1.1.1 忘掉该死的并行

与串行程序不同,并行程序的设计和实现异常复杂,不仅体现在程序的功能分离上,多线程间的协调性、乱序性都会成为程序正确执行的障碍。混乱的程序难以阅读、难以理解,更难以调试。所谓并行,也就是把简单的问题复杂化的典型。
但是,也有两个特例,那就是图像处理和服务端程序是可以、也需要使用并行技术的。图像处理往往有极大的计算量,服务端程序要承受很重的用户访问压力。

1.1.2 可怕的摩尔定律

由英特尔的创始人之一戈登摩尔提出,摩尔定律:集成电路上可容纳的电晶体(晶体管)数目,约每隔24个月便会增加一倍。经常被引用的“18个月”,是由英特尔首席执行官大卫豪斯所说:预计18个月将芯片的性能提高一倍。
说的直白点,就是每18到24个月,我们的计算机性能就能翻一番。
但是,摩尔定律在CPU的计算性能上可能已经失效。

1.1.3 柳暗花明:不断地前进

摩尔定律在另外一个侧面又生效了,每过18到24个月,CPU的核心数就会翻一番。
有科学家评论:在我看来,这种现象(并发)或多或少是由于硬件设计者已经无计可施了导致的,他们将摩尔定律失效的责任推脱给软件开发者。

1.1.4 光明或是黑暗

摩尔定律本应该由硬件开发人员维持,但是很不幸,硬件开发人员似乎无计可施了,为了保持性能的高速发展,他们破天荒的想出了将多个CPU内核塞进一个CPU里的奇妙想法。由此,并行计算就被推广开来,而随之而来的问题也层出不穷。
简化的硬件设计方案必然带来软件设计的复杂性。所以,如何让多个CPU有效并且正确的工作也就成为了一门技术。

1.2 必须知道的几个概念

1.2.1 同步(Synchronous)和异步(Asynchronous)

同步和异步通常形容一次方法调用。同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作,而异步方法通常会在另外一个线程中“真实”地执行。如果异步调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。

1.2.2 并发(Concurrency)和并行(Parallelism)

并发和并行容易被混淆,它们都可以表示两个或多个任务一起执行,但是偏重点不同。
并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。
实际上,如果系统内只有一个CPU,使用多进程或多线程任务,真实环境中这些任务是不可能真实并行的,毕竟一个CPU一次只能执行一条指令,这种情况下多进程或多线程就是并发的,而不是并行的。真实的并行只可能出现在拥有多个CPU的系统中。

1.2.3 临界区

临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源,就必须等待。
在并行程序中,临界区资源是保护的对象。打印机一次只能执行一个任务,如果意外出现打印机同时执行两个打印任务,那么最可能的结果就是打印出来的文件是损坏的文件,既不是小王想要的,也不是小明想要的。

1.2.4 阻塞(Blocking)和非阻塞(Non-Blocking)

阻塞和非阻塞通常用来形容多线程间的相互影响。
比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中进行等待。等待导致线程挂起,这种情况就是阻塞。非阻塞与之相反,它强调没有一个线程可以妨碍其他线程执行,所有的线程都会尝试不断向前执行。

1.2.5 死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)

死锁、饥饿和活锁都属于多线程的活跃性问题,如果出现这几种情况,那么相关线程可能很难再继续执行了。
死锁是最糟糕的情况,多个线程互相占用彼此需要的资源,并且都不愿释放,那么这个状态就永远维持下去,形成了死锁。
饥饿指某一个或多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。例如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级的线程无法工作。另外一种可能是,某一个线程一直占着关键资源不放,导致其他需要这个资源的线程无法执行。与死锁相比,饥饿有可能会解决掉,例如高优先级的线程已完成任务。
活锁是一种有趣的现象,举例说明,你想从电梯出去,同时电梯门外有人想进来,于是你很绅士的靠右避让对方,同时对方也很绅士的靠左避让,你俩又撞上了,但以人类的智商相撞几次后就不会发生。但这种情况发生在线程之间的话,如果线程的智力不够,且都秉承“谦让”的原则,主动释放资源给他人使用,那么就会出现资源不断在两个线程间跳动,而没有一个线程可以同时拿到所有资源而正常执行,这种情况就是活锁。

1.3 并发级别

由于临界区的存在,多线程之间的并发必须受到控制,根据并发策略,并发级别大致分为阻塞、无饥饿、无障碍、无锁、无等待几种。

1.3.1 阻塞(Blocking)

一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用 synchronized 关键字,或者重入锁时,得到的是阻塞的线程。
无论是 synchronized 还是重入锁,都视图在执行后续代码之前,获得临界区的锁。如果得不到就会挂起等待,直到占有了所需资源为止。

1.3.2 无饥饿(Starvation-Free)

线程之间有优先级,如果允许高优先级的线程插队,这种非公平的锁会导致低优先级的线程产生饥饿。如果不允许插队,这种锁就是公平的,那么饥饿就不会产生。

1.3.3 无障碍(Obstruction-Free)

无障碍是一种最弱的非阻塞调度。两个无障碍执行的线程,都可以进入临界区,如果一起修改共享数据发生了数据丢失等错误,线程会回滚自己的修改。
如果说阻塞是一种悲观策略,系统认为线程间很可能发生冲突,一个线程占用了临界区资源,其他现在就必须挂起等待。那么相对的,非阻塞的调度就是一种乐观的策略,系统认为线程间发生冲突的几率不大,这样可以保证线程不会在临界区无线等待。
但是,无障碍的策略也不一定能顺畅运行,当临界区中存在严重冲突时,所有的线程会不断回滚自己的操作,而没有一个线程可以走出临界区。解决方法是可以增加一个“一致性标记”,线程在操作之前,先读取并保存这个标记,操作完成后,再次读取,检查这个标记是否被修改过,如果两者是一致的,说明资源访问没有冲突,如果不一致则说明与其他写线程有冲突。每次修改数据前,都需要更新这个一致性标记。

1.3.4 无锁(Lock-Free)

无锁的并行都是无障碍的,但是不同的是,无锁的并行保证必然有一个线程能够在有限步内完成操作离开临界区。
在无锁的调度中,有个典型特点就是可能包含无限循环,在循环中,线程会不断尝试修改共享变量。如果没有冲突则修改成功,线程退出,否则继续尝试。但无论如何,无锁的并行总保证有一个线程是可以胜出的,不至于全军覆没。
临界区中竞争失败的线程,则会不断重试直到自己获胜,如果运气不好总是竞争失败,则会出现饥饿的现象。

1.3.5 无等待(Wait-Free)

无锁只要求有一个线程可以在有限步内完成操作,而无等待要求所有的线程都必须在有限步内完成,这样就不会引起饥饿问题。如果限制这个步骤上限,还可以进一步分为有界无等待和线程数无关的无等待几种,它们之间的区别只是对循环次数的限制不同。
一种典型的无等待结构是 RCU(Read-Copy-Update),基本思想是,对数据的读可以不加控制,但在写数据时,先取得原始数据的副本,接着只修改副本数据(这就是为什么读可以不加控制),修改完成后,在合适的时机回写数据。

1.4 有关并行的两个重要定律

将串行程序改为并行或并发程序,一般来说会提高程序的性能,但究竟能提高多少,有两个定律可以解答。

1.4.1 Amdahl 定律

Amdahl 定律定义了串行系统并行化后的加速比的计算公式和理论上限。
加速比定义:加速比 = 优化前系统耗时 / 优化后系统耗时
加速比越高,则加速效果越明显。根据 Amdahl 定律,使用多核 CPU 对系统进行优化,优化的效果取决于 CPU 的数量以及系统中的串行化程序的比重。CPU 数量越多,串行化比重越低,则优化效果越好。仅提高 CPU 数量而不降低程序的串行化比重,也无法提高系统性能。

1.4.2 Gustafson 定律

Gustafson 定律也视图说明处理器个数、串行比例和加速比之间的关系,但与 Amdahl 定律的角度不同。从 Gustafson 定律中我们发现,如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要不断增加处理器,就能获得更快的速度。

1.5 回到Java:JMM

前面介绍的概念是与编程语言无关的,在本章的最后,讨论一下 Java 的内存模型(JMM)。并发程序的复杂度体现在,并发程序下数据访问的一致性和安全性。如何在并行机制的前提下,保证多个线程间可以有效地、正确地协同工作,JMM 就是为此而生。
JMM 的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的,先来了解这些概念。

1.5.1 原子性(Atomicty)

原子性指一个操作是不可中断的,即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
比如,一个静态全局变量 int i,两个线程同时对它赋值,线程A赋值1,线程B赋值-1,不管着两个线程如何工作,i的值要么是1,要么是-1,线程A和线程B之间是没有干扰的,这就是原子性的特点,不可被中断。
但如果使用 long 型数据而不是 int 型数据,可能就不一样了,对32位系统来说,long 型数据的读写不是原子性的(因为long型数据有64位)。也就是说,如果两个线程同时对 long 型数据进行写入(或读取),则对线程之间的结果是有干扰的。

MultiThreadLong 案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class MultiThreadLong {
public static long t = 0;
public static class ChangeT implements Runnable {
private long to;
public ChangeT(long to){
this.to = to;
}
@Override
public void run() {
while(true) {
MultiThreadLong.t = to;
Thread.yield();
}
}
}
public static class ReadT implements Runnable {
@Override
public void run() {
while(true) {
long tmp = MultiThreadLong.t;
if(tmp!=111L && tmp!=-999L && tmp!=333L && tmp!=-444L)
System.out.println(tmp);
Thread.yield();
}
}
}
public static void main(String[] args) {
new Thread(new ChangeT(111L)).start();
new Thread(new ChangeT(-999L)).start();
new Thread(new ChangeT(333L)).start();
new Thread(new ChangeT(-444L)).start();
new Thread(new ReadT()).start();
}
}

上述代码有4个线程对 long 型数据 t 进行赋值,分别赋值为111、-999、333、-444.然后,有一个读取线程读取这个 t 的值。一般来说,t 的值总会是这4个数值中的一个,但很不幸,在32位的 Java 虚拟机中,未必总是这样。
如果读取线程 ReadT 总是读取到合理的数据,那么这个程序没有任何输出。但是实际上,会大量输出以下信息(使用32位虚拟机):

1
2
3
4
5
......
-4294967185
4294966852
-4294967185
......

可以看到,读取线程竟然读到了两个似乎根本不可能存在的数值。因为在32位系统中 long 型数据的读和写都不是原子性的,多线程之间互相干扰了!
如果给出这几个数值的二进制标识,就更加清晰:

1
2
3
4
5
6
+111=0000000000000000000000000000000000000000000000000000000001101111
-999=1111111111111111111111111111111111111111111111111111110000011001
+333=0000000000000000000000000000000000000000000000000000000101001101
-444=1111111111111111111111111111111111111111111111111111111001000100
+4294966852=0000000000000000000000000000000011111111111111111111111001000100
-4294967185=1111111111111111111111111111111100000000000000000000000001101111

上面显示了这几个相关数字的补码形式,也就是在计算机内的真实存储内容。可以发现,这个奇怪的4294966852,其实是111或者333的前32位与-444的后32位夹杂后的数字。而-4294967185只是-999或者-444的前32位与111夹杂后的数字。换句话说,由于并行的关系,数字被写乱了,或者读的时候读串位了。

1.5.2 可见性(Visibility)

可见性指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。对串行程序来说,可见性问题不存在,但在并行程序中,如果一个线程修改了某一个全局变量,其他线程未必可以马上知道这个改动。
例如,在 CPU1 和 CPU2 上各运行一个线程,它们共享变量 t,由于编译器优化或者硬件优化,CPU1 上的线程将 t 变量缓存在cache或寄存器中,此时如果 CPU2 上的线程对该变量进行了修改,那么 CPU1 上的线程可能无法意识到这个改动,依然读取cache或寄存器中的值,就产生了可见性问题。
除了上面提到的缓存优化或者硬件优化会导致可见性问题以外,指令重排及编译器的优化,也有可能导致一个线程的修改不会立即被其他线程察觉。

看一个例子:

1
2
3
Thread 1     Thread 2
1: r2 = A; 3: r1 = B;
2: B = 1; 4: A = 2;

上述两个线程并行执行,分别有 1、2、3、4 四条指令。其中指令 1、2 属于线程 1,而指令 3、4 属于线程 2。
从指令的执行顺序来看,r2\==2 并且 r1\==1 似乎是不可能的。但实际上,这种情况有可能出现,因为编译器有可能将指令重排成:

1
2
3
Thread 1     Thread 2
B = 1; r1 = B;
r2 = A; A = 2;

在这种执行顺序中,就有可能出现刚才看似不可能出现的 r2\==2 并且 r1\==1 的情况了。
这个例子说明,在一个线程中观察另一个线程的变量,它们的值是否能观测到、何时能观测到是没有保证的。

1.5.3 有序性(Ordering)

我们总是习惯性的任务代码执行是从前往后、依次执行的,在一个线程内而言,确实会表现成这样。但在并发时,程序的执行就会出现乱序,给人直观的感受就是,写在前面的代码,会在后面执行。
有序性的问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。
CPU 中的一条指令是分为很多步骤执行的(汇编指令),所以为了提高 CPU 性能,CPU 中执行多条指令时会引入流水线机制(流水线似的执行汇编指令)。但流水线总是害怕中断的,一旦流水线上一条指令中的某个汇编指令中断(可能因为相应寄存器中的数据没准备好),后续的其他指令的执行会产生停顿。为了消除这种停顿,CPU 引入了指令重排机制,就是在一条指令的所有汇编指令中,把不受影响的汇编指令提前到中断的汇编指令之前执行(指令重排不会使串行的语义逻辑发生问题)。

虽然指令重排消除了指令中断引起的停顿,提高了 CPU 处理性能,但是也带来了乱序,就有了有序性问题。同时如果有乱序的修改,也可能引起可见性问题。

1.5.4 指令重排

一条指令的执行是可以分为很多步骤的,简单说可以分为以下几步:

  • 取指 IF
  • 译码和取寄存器操作数 ID
  • 执行或者有效地址计算 EX
  • 存储器访问 MEM
  • 写回 WB

由于每一个步骤都可能使用不同的硬件完成,因此,聪明的工程师们就发明了流水线技术来执行指令:

1
2
指令1: IF  ID  EX  MEM  WB
指令2: IF ID EX MEM WB

可以看到,当指令 2 执行时,指令 1 并未执行完,确切的说指令 1 还没开始执行,只是刚刚完成了取指操作而已。这样的好处很明显,假设每一步都需要花费 1 毫秒,如果指令 2 等待指令 1 完全执行后再执行,则需要等待 5 毫秒。而使用流水线后,指令 2 只需要等待 1 毫秒就可以执行了。极大的提高了性能,有了流水线,CPU 就可以高效执行了。

但是,流水线总是害怕中断的。流水线满载时,性能确实不错。但是一旦中断,所有的硬件设备都会进入一个停顿期,再次满载又要几个周期,因此性能损失会比较大。所有,我们必须要想办法不让流水线中断

那么答案就来了,之所以需要指令重排,就是为了尽量少的中断流水线。通过一个例子了解指令重排。
A = B + C 的处理:

1
2
3
4
LW  R1, B         IF  ID  EX  MEM  WB
LW R2, C IF ID EX MEM WB
ADD R3, R1, R2 IF ID 停 EX MEM WB
SW A, R3 IF 停 ID EX MEM WB

上述是 A=B+C 的执行过程,从上往下执行,左边是汇编指令,右边是流水线的情况。注意,在 ADD 指令的流水线中有一个“”字表示停顿,ADD 指令的流水线被中断。为什么 ADD 会在这里停顿呢,很简单,因为寄存器 R2 中的数据还没有准备好,所以 ADD 操作必须进行一次等待。而由于 ADD 的延迟,导致后面所有的指令都要慢一拍。

再看个更复杂的情况:

1
2
a = b + c
d = e - f

执行过程是这样:

1
2
3
4
5
6
7
8
LW  Rb, b         IF  ID  EX  MEM   WB
LW Rc, c IF ID EX MEM WB
ADD Ra, Rb, Rc IF ID 停 EX MEM WB
SW a, Ra IF 停 ID EX MEM WB
LW Re, e 停 IF ID EX MEM WB
LW Rf, f IF ID EX MEM WB
SUB Rd, Re, Rf IF ID 停 EX MEM WB
SW d, Rd IF 停 ID EX MEM WB

由于 ADD 和 SUB 指令都需要等待上一条指令的结果,因此,在这里产生了不少停顿。
那能不能消除这些停顿?显然是可以的,我们只需将 LW Re,eLW Rf,f 移动到前面执行即可。因为先加载 e 和 f 对程序是没有影响的。既然在 ADD 的时候一定要停顿一下,那么停顿的时候不如去做点有意义的事情。

1
2
3
4
5
6
7
8
LW  Rb, b         IF  ID  EX  MEM   WB
LW Rc, c IF ID EX MEM WB
LW Re, e IF ID EX MEM WB
ADD Ra, Rb, Rc IF ID EX MEM WB
LW Rf, f IF ID EX MEM WB
SW a, Ra IF ID EX MEM WB
SUB Rd, Re, Rf IF ID EX MEM WB
SW d, Rd IF ID EX MEM WB

重排后,最终结果如上述所示。可以看到,所有的停顿都消除了。需要等待 Rc 时由于先执行了 LW Re,e 有了个延迟所以 Rc 的数据已准备好,同理,需要等待 Rf 时由于先执行了 SW a,Ra 有了个延迟所以 Rf 的数据已经准备好。流水线可以顺畅执行了。
由此可见,指令重排对于提高 CPU 处理性能是十分必要的。虽然带来了乱序的问题,但是这点牺牲是值得的。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。

1.5.5 哪些指令不能重排:Happen-Before规则

前一小节介绍了指令重排,虽然 Java 虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置。
以下原则是指令重排不可违背的:

  • 程序顺序原则:一个线程内保证语义的串行性
  • volatile 原则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性
  • 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
  • 传递性:A 先于 B,B 先于 C,那么 A 必然先于 C
  • 线程的 start() 方法先于它的每一个动作
  • 线程的所有操作先于线程的终结(Thread.join())
  • 线程的中断(interrupt())先于被中断线程的代码
  • 对象的构造函数执行、结束先于 finalize() 方法

指令重排后不能改变原有的串行语义,例如:

1
2
a = 1;
b = a + 1;

由于第 2 条语句依赖第一条的执行结果,冒然交换两条语句的执行顺序,那么程序的语义就被修改。因此这种情况是不允许发生的,这就是指令重排的第一条原则。
第二条原则,valotile 变量的写 happen-before 变量的读,写先于读,就保证了 volatile 变量的可见性。
第三条原则,unlock 操作必须先于后续对同一个锁的 lock 操作。也就是说,如果对一个锁解锁后再加锁,那么加锁的动作绝对不能重排到解锁动作之前。显然这么做加锁操作是无法获得这把锁的。
其他原则也都类似,这些原则都是为了保证指令重排不会破坏原有的语义结构。

2. Java 并行程序基础

2.1 有关线程必须知道的事

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统的基础,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程是线程的容器,进程中可以容纳若干个线程。线程(Thread)是轻量级的进程,是程序执行的最小单位。

使用多线程而不是多进程去进行并发程序的设计,是因为线程间的切换和调度的成本远远小于进程。
一个线程的生命周期,如下图所示:

image

NEW 状态表示刚刚创建的线程,还没开始执行,等到线程的 start() 方法调用时才开始执行。当线程执行时,处于 RUNNABLE 状态,表示线程所需的一切资源都已准备好了。如果线程在执行过程中遇到了 synchronized 同步块,就会进入 BLOCKED 阻塞状态,这时线程就会暂停执行,直到获得请求的锁。WAITNG 和 TIMED_WAITING 都表示等待状态,它们的区别是 WAITING 会进入一个无时间限制的等待,TIMED_WAITING 会进行一个有时限的等待。一般来说,WAITING 的线程是在等待一个特殊的事件,比如 notify,而通过 join() 方法等待的线程则会等待目标线程的终止。一旦等到了期望事件,线程就会再次执行,进入 RUNNABLE 状态。当线程执行完毕后,则进入 TERMINATED 状态,表示结束。

注意:从 NEW 状态出发后,线程不能再回到 NEW 状态。同理,处于 TERMINATED 的线程也不能再回到 RUNNABLE 状态。

2.2 初始线程:线程的基本操作

2.2.1 新建线程

新建线程很简单,使用 new 关键字创建一个线程对象,并且将它 start() 起来即可:

1
2
Thread t1 = new Thread();
t1.start();

线程 start() 后会干什么呢?这才是关键。线程 Thread 中有一个 run() 方法,start() 方法会新建一个线程并让这个线程执行 run() 方法。

下面的代码也能编译也能运行,但却不能新建一个线程,而是在当前线程中调用 run() 方法,只是作为一个普通的方法调用:

1
2
Thread t1 = new Thread();
t1.run();

注意 start() 和 run() 的区别。run() 不会开启新线程,它只会在当前线程中串行执行 run() 中的代码。

Thread 的 run() 方法什么都没做,需要重载 run() 方法,把需要执行的代码填进去。

1
2
3
4
5
6
7
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("Hello, I am t1");
}
};
t1.start();

上述代码使用了匿名内部类,重载了 run() 方法。一般情况下都可以继承 Thread 类,然后重载 run() 方法来自定义线程。但由于 Java 单继承机制,继承本身也是宝贵的资源。因此还可以使用 Runnable 接口实现同样的操作。

1
2
3
public interface Runnable {
public abstract void run();
}

然后,通过 Thread 类的构造方法传入 Runnable 接口的实例,在 start() 方法调用时,新的线程就会执行 Runnable.run() 方法。

1
public Thread(Runnable target)

Thread 类的 run() 方法默认就是这么做的:

1
2
3
4
5
public void run() {
if(target != null) {
target.run();
}
}

使用 Java 8 的 lambda 表达式或 lambda 代码块替代匿名内部类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 匿名内部类写法
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello, I am t1");
}
}).start();

// Java 8 Lambda代码块写法
new Thread(
() -> {
System.out.println("Hello, I am t1");
}
).start();

// Java 8 Lambda表达式写法
new Thread(() -> System.out.println("Hello, I am t1")).start();

2.2.2 终止线程

一般情况下线程执行完毕后就会结束,无须手工关闭。但是一些服务端的后台线程可能会常驻系统,它们通常不会正常终结。Thread 类提供了一个 stop() 方法,用以强制终止线程。但是由于 stop() 太过暴力,已被标记为废弃方法,有可能在将来版本被移除。

使用 stop() 方法强制终止执行中的线程,会引起数据不一致的问题。
stop() 方法在结束线程时,会直接终止线程,并且会立即释放这个线程所持有的锁,而这些锁恰恰是用来维持对象一致性的。如果此时,写线程写入数据正写到一半,并强行终止,那么对象就会被写坏,同时由于锁已被释放,另外一个等待该锁的线程就顺理成章的读到了这个不一致的对象,产生了不一致问题。

不使用 stop() 方法,需要停止一个线程时,应该怎么做呢?可以增加一个标记,自行决定何时退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static class ChangeObjectThread extends Thread{
//终止线程标记
volatile boolean stopme = false;
public void stopMe() {
stopme = true;
}
@Override
public void run() {
while(true) {
if(stopme) {
System.out.println("exit by stop me");
break;
}
synchronized (u) {
u.setId(...);
......
}
Thread.yield();
}
}
}

定义了一个标记变量 stopme,用于指示线程是否退出。当 stopMe() 方法被调用,stopme 就被设置为 true,此时,在代码第10行检测到这个改动时,线程就自然退出了。使用这种方式退出线程,不会使对象u的状态出现错误,因为,ChangeObjectThread 以及没有机会“写坏”对象了,它会选择在一个合适的时间终止线程。

2.2.3 线程中断

在 Java 中,线程中断是一种重要的线程协作机制,是一套更完善的线程退出机制。
严格地讲,线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程,有人希望你退出啦!至于目标线程接到通知后如何处理,完全由目标线程自行决定。

与线程中断相关的有三个方法:

1
2
3
public void Thread.interrupt()              //中断线程
public boolean Thread.isInterrupted() //判断是否被中断
public static boolean Thread.interrupted() //判断是否被中断,并清除当前中断状态

Thread.interrupt() 是一个实例方法,它通知目标线程中断,会设置一个中断标志位,中断标志位表示当前线程已经被中断了。
Thread.isInterrupted() 也是实例方法,判断当前线程是否有被中断,检查中断标志位。
Thread.interrupted() 是一个静态方法,用来判断当前线程的中断状态,但同时会清除当前线程的中断标志位。

如果想要通过线程中断,来退出线程,除了调用 interrupt() 方法,还要在重载的 run() 方法中,增加中断处理代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Thread t1 = new Thread() {
@Override
public void run() {
while(true) {
//中断处理代码,检查中断标志位,若有中断则退出
if(Thread.currentThread().isInterrupted()) {
System.out.println("Interrupted!");
break;
}
try{
Thread.sleep(2000);
} catch (InterruptedException e) {
System.out.println("Interrupted When Sleep");
//设置中断状态
Thread.currentThread().interrupt();
}
Thread.yield();
}
}
};
t1.start();
Thread.sleep(2000);
t1.interrupt();

上述代码使用 Thread.isInterrupted() 函数判断当前线程是否被中断了,如果是,则退出循环体,结束线程。中断的功能比前面增加 stopme 标记的方法更为强大,比如,如果在循环体中出现 wait()、sleep() 这样的操作,则只能通过中断来识别了。

当线程在 sleep() 休眠时,如果被中断,则会抛出一个 InterruptedException 中断异常,同时清除中断标记,InterruptException 不是运行时异常,也就是说必须捕获并处理它。
捕获到 InterruptedException 后,我们可以立即退出线程。但我们没有这么做,因为也许在代码中,我们还必须进行后续处理,保证数据的一致性和完整性。所以在 catch 子句中再次中断自己,置上中断标记,当再循环到第6行的检查时,才能发现已被中断从而退出线程。

Thread.sleep() 方法被中断而抛出异常,同时它会清除中断标记位,如果不加处理,在下次循环开始时就无法捕获这个中断,所以在异常处理处,再次设置中断标记位。

2.2.4 等待(wait)和通知(notify)

为了支持多线程间协作,JDK 提供了两个非常重要的接口,wait() 等待方法和 notify() 通知方法。这两个方法并不是 Thread 类的,而是 Object 类的。意味着任何对象都可以调用这两个方法:

1
2
public final void wait() throws InterruptedException
public final native void notify()

当一个对象实例调用了 wait() 方法,当前线程就开始在这个对象上等待。例如,线程A中调用了 obj.wait() 方法后,线程A停止继续执行,转为等待状态,直到线程B调用了 obj.notify() 方法位置。对象 obj 就成为多个线程间通信的手段。
如果一个线程调用了 obj.wait(),那么该线程就进入 obj 对象的等待队列。这个等待队列中可能有多个线程,因为系统同时运行多个线程等待某一个对象。当 obj.notify() 被调用时,obj 对象就会从等待队列中随机唤醒一个线程,这是完全随机的。当 obj.notifyAll() 方法被调用,obj 对象会唤醒它的等待队列中的所有线程。

Object.wait() 方法并不能随便调用,它必须包含在对应的 synchronized 语句中,无论是 wait() 还是 notify() 都需要首先获得目标对象的一个监视器(可以理解为在目标对象上加锁),假设两个线程 T1 和 T2,有如下流程:

T1 T2
取得object监视器
object.wait()
释放object监视器
取得object监视器
object.notify()
等待object监视器 释放object监视器
重获object监视器
继续执行

线程 T1 在执行 wait() 方法前,必须首先获得 object 对象的监视器,而 wait() 方法执行后,会释放这个监视器(释放锁),这样做,其他等待在 object 对象上的线程不至于因为 T1 的休眠而全部无法执行。

线程 T2 在调用 notify() 方法前也必须首先获得 object 的监视器,此时线程 T1 释放了监视器,T2 得以顺利执行。接着 T2 执行了 notify() 方法尝试唤醒一个等待线程,线程 T1 正在等待,假设此时唤醒了 T1。线程 T1 被唤醒后,首先也要获得 object 对象的监视器,而这个监视器也就是 T1 在 wait() 时持有的那个,然后才会继续执行。

一个案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class SimpleWN {
final static Object object = new Object();
public static class T1 extends Thread {
public void run() {
synchronized (object) {
System.out.println(System.currentTimeMillis()+":T1 start!");
try{
System.out.println(System.currentTimeMillis()+":T1 wait for object");
object.wait();
} catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println(System.currentTimeMillis()+":T1 end!");
}
}
}
public static class T2 extends Thread {
public void run() {
synchronized (object) {
System.out.println(System.currentTimeMillis()+":T2 start! notify one thread");
object.notify();
System.out.println(System.currentTimeMillis()+":T2 end!");
try{
Thread.sleep(2000);
} catch(InterruptedException e) {}
}
}
}
public static void main(String[] args) {
Thread t1 = new T1();
Thread t2 = new T2();
t1.start();
t2.start();
}
}

注意,在代码的第6行,执行 wait() 方法前,T1 会先申请 object 的对象锁。因此,在执行 object.wait() 时,线程 T1 持有 object 的锁。wait() 方法执行后,T1 会进行等待,并释放 object 的锁。T2 在执行 notify() 之前也会先获取 object 的对象锁。T1 在得到 notify() 通知后,还是会先尝试重新获得 object 对象锁。
上述代码执行效果:

1
2
3
4
5
1425224592258:T1 start!
1425224592258:T1 wait for object
1425224592258:T2 start! notify one thread
1425224592258:T2 end!
1425224594258:T1 end!

注意打印的时间戳,T2 通知 T1 继续执行后,T1 并不能立即继续执行,而是要等待 T2 释放 object 的锁,重新获得锁后,T1 才继续执行。所以 “T2 end!” 和 “T1 end!” 间隔了2秒(T2休眠2秒)。

Object.wait() 和 Thread.sleep() 方法都可以让线程等待若干时间,除了 wait() 可以被唤醒外,另外一个主要区别就是 wait() 方法会释放目标对象的锁,而 Thread.sleep() 方法不会释放任何资源

2.2.5 挂起(suspend)和继续执行(resume)线程

Thread 类还定义了两个接口,挂起(suspend)和继续执行(resume)。这两个操作是一对相反的操作,被挂起的线程,必须要等到 resume() 操作后,才能继续执行。看起来是两个非常有用的方法,但是在 JDK 中已被标记为废弃,不推荐使用。

不推荐使用 suspend() 挂起线程的原因,是因为 suspend() 在导致线程暂停的同时,并不会释放任何锁资源,导致其他等待锁资源的线程无法运行,直到挂起线程执行 resume() 操作。但是,如果 resume() 操作意外地在 suspend() 前就执行了,那么被挂起的线程很难再继续执行,更可怕的是,它占用的锁不会被释放,因此很可能导致整个系统工作不正常。并且,被挂起的线程状态竟然是RUNNABLE,这也会严重影响我们的判断。

如果要实现线程挂起的效果,可以通过 wait() 和 notify() 在应用层面实现,实现举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class GoodSuspend {
public static Object u = new Object();
public static class ChangeObjectThread extends Thread {
volatile boolean suspendme = false;
public void suspendMe() {
suspendme = true;
}
public void resumeMe() {
suspendme = false;
synchronized (this) {
notify();
}
}
@Override
public void run() {
while(true) {
synchronized (this) {
while(suspendme) {
try{
wait();
} catch(InterruptedException e) {}
}
}
synchronized (u) {
System.out.println("in ChangeObjectThread");
}
Thread.yield();
}
}
}
public static class ReadObjectThread extends Thread {
@Override
public void run() {
while(true) {
synchronized (u) {
System.out.println("in ReadObjectThread");
}
Thread.yield();
}
}
}

public static void main(String[] args) throws InterruptedException {
ChangeObjectThread t1 = new ChangeObjectThread();
ReadObjectThread t2 = new ReadObjectThread();
t1.start();
t2.start();
Thread.sleep(1000);
t1.suspendMe();
Thread.sleep(2000);
t1.resumeMe();
}
}

2.2.6 等待线程结束(join)和谦让(yield)

很多时候,一个线程的输入非常依赖于另外一个或多个线程的输出,此时,这个线程就需要等待依赖线程执行完毕,才能继续执行。Thread 提供了 join() 方法来实现这个功能:

1
2
public final void join() throws InterruptedException
public final synchronized void join(long millis) throws InterruptedException

第一个 join() 方法表示无限等待目标线程(调用join方法的线程),会一直阻塞当前线程,直到目标线程执行完毕。
第二个 join() 方法给出了一个最大等待时间,如果到了这个时间目标线程还未执行完毕,当前线程则会继续执行。

英文 join 通常是加入的意思,在这里也很贴切,因为一个目标线程要加入当前线程,最好的方法就是当前线程等着目标线程一起走

join() 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JoinMain {
public volatile static int i=0;
public static class AddThread extends Thread {
@Override
public void run() {
for(i=0; i<10000000; i++);
}
}
public static void main(String[] args) throws InterruptedException {
AddThread at = new AddThread();
at.start();
//at为目标线程
at.join();
System.out.println(i);
}
}

上述代码,如果不使用 join() 方法等待 at,那么得到的 i 很可能是0或者很小的一个值。但在使用 join() 方法后,表示 at 要加入主线程,主线程愿意等待 at 执行完毕,所以打印的 i 值是 10000000。

join() 的实现原理,本质上是让调用线程 wait() 在当前线程对象上,用上面的代码说明,就是主线程中调用了 at 线程的 wait() 方法,主线程 wait() 在 at 线程对象上。
Thread 类 join() 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final synchronized void join(long millis) throws InterruptedException {
long base = System.currentTimeMillis();//获取当前时间
long now = 0;

if (millis < 0) {//判断不说了
throw new IllegalArgumentException("timeout value is negative");
}

if (millis == 0) {//这个分支是无限期等待直到 at 线程结束
while (isAlive()) {
//只要 at 线程还活着,就一直等
wait(0);//wati操作,那必然有synchronized与之对应
}
} else {//这个分支是等待固定时间,如果 at 没结束,那么就不等待了
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}

可以看到,调用线程会在当前线程对象上等待,也就是主线程会在 at 线程对象上等待,at 线程执行完成会在退出前调用 notifyAll() 方法,通知所有等待在 at 线程对象上的等待线程,主线程收到通知就继续执行。

注意,我们在编写应用程序时,不要在 Thread 对象实例上使用类似 wait() 或者 notify() 等方法,因为这很可能影响系统 API 的工作,或者被系统 API 所影响。

join() 方法加了 synchronized 说明是 synchronized(this),this 就是 at 线程对象,在主线程身体里等待 at 线程。有了 wait必然有 notify,要找到 join() 中的 wait 对应的 notify,就要看 JVM 源码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//一个C++函数:
void JavaThread::exit(bool destroy_vm, ExitType exit_type) ;

//这家伙是啥,就是一个线程执行完毕之后,jvm会做的事,做清理啊收尾工作,
//里面有一个贼不起眼的一行代码,眼神不好还看不到的呢,就是这个:
ensure_join(this);

//翻译成中文叫 确保_join(这个);代码如下:

static void ensure_join(JavaThread* thread) {
Handle threadObj(thread, thread->threadObj());
ObjectLocker lock(threadObj, thread);
thread->clear_pending_exception();
java_lang_Thread::set_thread_status(threadObj(), java_lang_Thread::TERMINATED);
java_lang_Thread::set_thread(threadObj(), NULL);

//就看这一句,thread就是当前退出的线程,就是上面说的at线程
lock.notify_all(thread);

thread->clear_pending_exception();
}

除了 join(),Thread 类中还有一个方法,就是 Thread.yield():

1
public static native void yield();

这是一个静态方法,一旦执行,它会使当前线程让出 CPU,但是,让出 CPU 不代表当前线程不执行了。当前线程在 让出 CPU 后,还会进行 CPU 资源的争夺,但是是否能再次被分配到就不一定了。yield() 的调用就好像在说:我已经完成一些最重要的操作了,稍微休息一下,可以给其他线程一些工作机会啦。
如果你觉得一个线程不那么重要,或者优先级非常低,而且又害怕它占用太多 CPU 资源,那么可以在适当的时候调用 Thread.yield(),给予其他重要线程更多的工作机会。

2.3 volatile 与 Java 内存模型(JMM)

在第 1 章,简单介绍了 Java 内存模型(JMM),Java 内存模型都是围绕原子性、有序性、可见性展开的。Java 使用了一些特殊的操作或者关键字来声明、告诉虚拟机,在这个地方,尤其注意,不能随意变动优化目标指令,关键字 volatile 就是其中之一。
volatile 单词的解释,是“易变的、不稳定的”,这也正是 volatile 的语义。

当用 volatile 去声明一个变量时,就等于告诉虚拟机,这个变量极有可能会被某些程序或者线程修改。为了保证这个变量被修改后,应用程序范围内的所有线程都能“看到”这个改动,虚拟机就必须采用特殊手段,来保证这个变量的可见性。

回顾一下 1.5.1 章节中的 MultiThreadLong 案例,怎么处理才能保证数据不写坏呢?最简单的就是加入 volatile:

1
2
3
4
5
public class MultiThreadLong {
public volatile static long t = 0;
public static class ChangeT implements Runnable {
private long to;
......

从这个案例可以看到,volatile 对于保证操作的原子性有很大帮助,但是 volatile 并不能代替锁它也无法保证一些复合操作的原子性,看下面的例子,volatile 无法保证 i++ 操作的原子性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static volatile int i = 0;
public static class PlusTask implements Runnable {
@Override
public void run() {
for(int k=0; k<10000; k++)
i++;
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for(int i=0; i<10; i++) {
threads[i] = new Thread(new PlusTask());
threads[i].start();
}
for(int i=0; i<10; i++) {
threads[i].join();
}
System.out.println(i);
}

执行上述代码,如果第 6 行的 i++ 是原子性的,那么最终的值应该是 100_000(10个线程各累加10000次)。但实际上,上述代码的输出总会小于 100000。因为 i++ 这种复合操作的原子性,volatile 是无法保证的,有可能多个线程同时写,所以说 volatile 不能代替锁。
此外,关键字 volatile 也能保证数据的可见性和有序性,看下面这个简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class NoVisibility {
private static boolean ready;
private static int number;

private static class ReaderThread extends Thread {
public void run() {
while(!ready);
System.out.println(number);
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
Thread.sleep(1000);
number = 42;
ready = true;
Thread.sleep(10000);
}
}

上述代码中,ReaderThread 线程只有在数据准备好时(ready为true),才会打印 number 的值,它通过 ready 变量判断是否应该打印。在主线程中,开启 ReaderThread 后,就为 number 和 ready 赋值,并期望 ReaderThread 能够看到这些变化并将数据输出。
在虚拟机的 Client 模式下,由于 JIT 没有做足够的优化,在主线程赋值之后,ReaderThread 可以发现这个改动,并退出程序。但是在 Server 模式下,由于系统优化,ReaderThread 线程无法“看到”主线程中的改动,导致 ReaderThread 永远无法退出。这个问题是一个典型的可见性问题。
和原子性一样,我们只需简单的使用 volatile 关键字,告诉 Java 虚拟机,这个变量可能会在不同的线程中修改,这样就可以解决这个可见性问题了。

2.4 分门别类的管理:线程组

如果线程数量较多,而且功能分配比较明确,就可以将相同功能的线程放置在一个线程组里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ThreadGroupName implements Runnable {
public static void main(String[] args) {
ThreadGroup tg = new ThreadGroup("PrintGroup");
Thread t1 = new Thread(tg, new ThreadGroupName(), "T1");
Thread t2 = new Thread(tg, new ThreadGroupName(), "T2");
t1.start();
t2.start();
System.out.println(tg.activeCount());
tg.list();
}
@Override
public void run() {
String groupAndName = Thread.currentThread().getThreadGroup().getName() + "-" + Thread.currentThread().getName();
while(true) {
System.out.println("I am" + groupAndName);
try {
Thread.sleep(3000);
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}

上述代码第3行,建立了一个名为“PrintGroup”的线程组,并将T1和T2两个线程加入这个线程组,第8、9两行,展示了线程组的两个功能。
activeCount() 可以获得活动的线程总数,但由于线程是动态的,这个值只是一个估计值,无法精确。
list() 方法可以打印出这个线程组中的所有线程信息。
线程组也有一个 stop() 方法,但跟 Thread.stop() 一样会造成数据不一致的问题,不建议使用。

2.5 驻守后台:守护线程(Daemon)

守护线程是一种特殊的线程,它是系统的守护者,在后台默默完成一些系统性的服务,比如垃圾回收线程、JIT线程。
与之相对的是用户线程,用户线程是系统的工作线程,它会完成这个程序的业务操作。如果用户线程全部结束,守护线程要守护的对象已经不存在了,那么整个应用程序就自然应该结束。
因此,当一个 Java 应用内,只有守护线程时,Java 虚拟机就会自然退出。

守护线程的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DaemonDemo {
public static class DaemonT extentd Thread {
public void run() {
while(true) {
System.out.println("I am alive");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
puyblic static void main(String[] args) throws InterruptedException {
Thread t = new DaemonT();
//设置为守护线程
t.setDaemon(true);
t.start();
Thread.sleep(2000);
}
}

上述代码中,t.setDaemon(true) 设置为守护线程,守护线程必须在线程 start() 之前设置,否则会得到一个异常告诉你守护设置失败。但是程序和线程依然可以运行,只是被当做了用户线程。
在这个例子中,由于 t 被设置为守护线程,系统中只有主线程 main 为用户线程,因此在 main 线程休眠 2 秒后退出时,整个程序随之结束。但如果不把线程 t 设置为守护线程,main 线程结束后,t 线程还会不停的打印,永远不会结束。

2.6 先干重要的事:线程优先级

Java 中的线程可以有自己的优先级,优先级高的线程在竞争资源时更有优势,但是个概率问题,也有可能抢占失败。
由于线程的优先级调度和底层操作系统有密切的关系,在各个平台表现不一,并且这种优先级产生的后果也不可预测,无法精准控制。比如一个优先级低的线程可能一直抢占不到资源,从而产生饥饿(虽然优先级低,但也不能饿死它呀)。
因此,在严格要求的场合,还是需要自己在应用层解决线程调度问题。

1
2
3
4
Thread t = new Thread();
//设置优先级
t.setPriority(Thread.MAX_PRIORITY);
t.start();

2.7 线程安全的概念与synchronized

线程安全是并行程序的根本和根基,如果程序并行化后,连基本的执行结果的正确性都无法保证,那么并行程序本身也就没有任何意义了。
回想一下 1.5.1 章节中的 MultiThreadLong 案例,就是一个典型的反例。在使用 volatile 关键字后,这种错误的情况有所改善。
但是,volatile 并不能真正的保证线程安全,它只能确保一个线程修改了数据后,其他线程能够看到这个改动。但当两个线程同时修改某一个数据时,却依然会产生冲突。
Java 中提供了关键字 synchronized 来解决这个问题。

关键字 synchronized 的作用是实现线程间的同步,对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证了线程间的安全性。
synchronized 有多种用法:

  • 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。
  • 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
  • 直接作用于静态方法:相对于对当前类加锁,进入同步代码前要获得当前类的锁。

下述代码,将 synchronized 作用于给定对象 instance,每次进入被 synchronized 包裹的代码,都需要请求 instance 实例的锁。如果有其他线程正在持有锁就必须等待,这样保证了每次只能有一个线程执行 i++ 操作。

1
2
3
4
5
6
7
8
9
10
11
12
public class AccountingSync implements Runnable {
static AccountingSync instance = new AccountingSync();
static int i = 0;
@Override
public void run() {
for(int j=0; j<10000000; j++) {
synchronized (instance) {
i++;
}
}
}
}

上述代码也可以写成如下形式,两者是等价的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AccountingSync implements Runnable {
static AccountingSync instance = new AccountingSync();
static int i = 0;
public synchronized void increase() {
i++;
}
@Override
public void run() {
for(int j=0; j<10000000; j++) {
increase();
}
}
public static void main(String[] args) throws InterruptException {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(i);
}
}

上述代码中,synchronized 作用于 increase() 方法,在进入increase() 方法前,线程必须获得当前对象实例的锁。
注意,main 方法中 Thread 的创建方式,使用了 Runnable 接口的方式,并且创建两个 Thread 时必须传入同一个 Runnable 接口实例(instance对象)。这样才能保证两个线程工作时,关注的是同一个对象的锁,从而保证线程安全。

一种典型的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class AccountingSync implements Runnable {
static int i = 0;
public synchronized void increase() {
i++;
}
@Override
public void run() {
for(int j=0; j<10000000; j++) {
increase();
}
}
public static void main(String[] args) throws InterruptException {
Thread t1 = new Thread(new AccountingSync());
Thread t2 = new Thread(new AccountingSync());
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(i);
}
}

上述代码就犯了一个严重的错误,虽然 synchronized 声明 increase() 是同步方法,但是两个线程都指向了不同的 Runnable 实例对象,两个线程都有自己的对象锁,两个线程使用的是两把不同的锁,线程安全无法保证。
但是简单的修改上述代码,就能使其正确执行。那就是使用 synchronized 的第三种用法,将其作用于静态方法,将 increase() 修改如下:

1
2
3
public static synchronized void increase() {
i++;
}

这样,即使两个线程指向不同的 Runnable 对象,但由于方法块需要请求的是当前类的锁,而非当前实例,线程间还是可以正确同步的。

除了用于线程同步、确保线程安全外,synchronized 还可以保证线程间的可见性有序性
从可见性的角度讲,synchronized 可以完全替代 volatile 的功能,只是使用上没有那么方便。就有序性而言,synchronized 限制每次只能有一个线程访问同步块,因此无论同步块内代码如何被乱序执行,只要保证串行语义一致,那么执行结果总是一样的。而其他线程,必须在获得锁之后才能进入代码块读取数据。所以,被 synchronized 限制的多个线程是串行执行的,从而有序性问题等到了解决。

2.8 程序中的幽灵:隐蔽的错误

修复 BUG 是程序员的日常工作之一,如果程序出错,看到了异常堆栈,问题通常是比较容易解决的。可怕的情况是,系统没有任何异常表现,没有日志,也没有异常堆栈,但是却给出了一个错误的执行结果。 0

2.8.1 无提示的错误案例

这里给出一个系统运行错误,却没有任何提示的案例。

1
2
3
4
5
6
int v1 = 1073741827;
int v2 = 1431655768;
System.outl.println("v1=" + v1);
System.outl.println("v2=" + v2);
int ave = (v1 + v2) / 2;
System.outl.println("ave=" + ave);

上述代码中,计算两个整数的平均值,乍看之下没有任何问题,但是执行代码却输出如下结果:

1
2
3
v1=1073741827
v2=1431655768
ave=-894784850

乍看之下一定非常吃惊,为什么均值竟然是一个负值。有经验的开发者会马上有所觉悟,这是一个典型的溢出问题!显然,v1+v2 的结果已经导致了 int 的溢出。
把这个问题单独拿出来看,你会觉得也挺简单。但是如果发生在一个复杂系统的内部。由于复杂的业务逻辑,很可能掩盖这个看起来微不足道的问题,再加上程序自始至终没有任何日志和异常,运气不好的话,这类问题会让你耗上几个通宵。
我们也希望程序在运行异常时,能够得到一个异常或相关的日志。但是,非常不幸的是,错误的使用并行,会非常容易的产生这类问题,它们难觅踪影,就如同幽灵一般。

2.8.2 并发下的 ArrayList

ArrayList 是一个线程不安全的容器,如果在多线程中使用 ArrayList,可能会导致程序出错。看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ArrayListMultiThread {
static ArrayList a1 = new ArrayList(10);
public static class AddThread implements Runnable {
@Override
public void run() {
for(int i=0; i<1000_000; i++) {
a1.add(i);
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new AddThread());
Thread t2 = new Thread(new AddThread());
t1.start();
t2.start();
t1.join(); t2.join();
System.out.println(a1.size());
}
}

上述代码中,t1 和 t2 两个线程同时向一个 ArrayList 中添加容器。它们各自添加100万个元素,因此我们期望最后可以有200万个元素在 ArrayList 中。但是如果执行这段代码,可能得到三种结果。

第一种,程序正常结束,ArrayList 的最终大小确实200万,这说明即使并行程序有问题,也未必会每次都表现出来。

第二种,程序抛出异常:

1
2
3
4
5
Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 22
at java.util.ArrayList.add(ArrayList.java:441)
at com.cellei.ArrayListMultiThread$AddThread.run(ArrayListMultiThread.java:12)
at java.lang.Thread.run(Thread.java:724)
1000015

这是因为 ArrayList 在扩容过程中,内部一致性被破坏,但由于没有锁的保护,另外一个线程访问到了不一致的内部状态,导致出现越界问题。

第三种,出现了一个非常隐蔽的错误,比如打印 ArrayList 的大小为如下值:

1
1793758

这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常的访问,同时两个线程也对 ArrayList 中的同一个位置进行赋值导致的。如果出现这种问题,你就得到了一个没有错误提示、未必能复现的问题。

改进的方法也很简单,使用线程安全的 Vector 代替 ArrayList 即可。

2.8.3 并发下诡异的 HashMap

HashMap 同样不是线程安全的,但它的问题比 ArrayList 更加诡异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class HashMapMultiThread {
static Map<String,String> map = new HashMap<String,String>();
public static class AddThread implements Runnable {
int start = 0;
public AddThread(int start) {
this.start = start;
}
@Overide
public void run() {
for(int i=start; i<100_000; i+=2) {
map.put(Integer.toString(i), Integer.toBinaryString(i));
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new HashMapMultiThread.AddThread(0));
Thread t2 = new Thread(new HashMapMultiThread.AddThread(1));
t1.start();
t2.start();
t1.join(); t2.join();
System.out.println(map.size());
}
}

上述代码使用 t1 和 t2 两个线程同时对 HashMap 进行 put() 方法操作。如果一切正常,则得到 map.size() 的值为 100000。但实际上,可能会得到以下三种情况(JDK7):

  • 第一种,程序正常结束,并且结果也是符合预期的,HashMap 的大小为 100000。
  • 第二种,程序正常结束,但结果不符合预期,而是一个小于 100000 的数字,比如 98868。
  • 第三种,程序永远无法结束。

前两种可能和 ArrayList 的情况非常相似。而对于第三种情况,为什么可能就结束不了呢?
打开任务管理器,可能发现这段代码占用了极高的 CPU,最有可能的表示是占用了两个 CPU 核,并使得这两个核的 CPU 使用率达到 100%,这非常类似死循环的情况。

使用 jps 命令得到 Java 进程ID,再使用 jstack 命令打印这个 Java 进程的内部线程及其堆栈。会发现 t1 和 t2 两个线程都是 RUNNABLE 状态,而 main 线程是 WAITING 状态。
并且在 t1 和 t2 的线程堆栈中,会发现这两个线程都在执行 HashMap.put() 方法,在执行 HashMap.java 代码的第 498 行代码,这块代码如下所示:

1
2
3
4
5
6
7
8
9
for(Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if(e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}

可以看到,当前这两个线程正在遍历 HashMap 的内部数据。当前所处循环乍看之下是一个迭代遍历,就如同遍历一个链表一样。但在此时此刻,由于多线程的冲突,这个链表的结构已经遭到了破坏,链表成环了。当链表成环时,上述迭代就是一个死循环。
JDK 8 已经对 HashMap 的内部实现做了大规模调整,上述现象在 JDK 8 中已经不会出现了。但即使这样,贸然在多线程环境下使用 HashMap 依然会导致内部数据不一致。最简单的解决方案就是使用 ConcurrentHashMap 代替 HashMap。

2.8.4 初学者常见问题:错误的加锁

在前面的 2.7 小节中已经介绍了一个错误加锁的案例,在本节中,将介绍一个更加隐晦的案例。
现在,假设我们需要一个计数器,这个计数器会被多个线程同时访问。为了确保数据正确性,我们自然会需要对计数器加锁,因此有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BadLockOnInteger implements Runnable {
public static Integer i=0;
static BadLockOnInteger instance = new BadLockOnInteger();
@Override
public void run() {
for(int j=0; j<10000000; j++) {
synchronized(i) {
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(i);
}
}

上述代码的第 7~9 行,为了保证计数器 i 的正确性,每次对 i 自增前,都先获得 i 的锁,以此保证 i 是线程安全的。从逻辑上看,这似乎并没有什么不对,这段代码正常应该打印 20 000 000。
但是运行代码后,会很惊讶的发现最终却得到一个比 20 000 000 小很多的数字,比如 15 992 526。代码并没有做到线程安全,这是为什么呢?似乎加锁的逻辑已经无懈可击。

要解释这个问题,先要了解不变对象。在 Java 中,Integer 属于不变对象,即对象一旦被创建,就不可能被修改。如果有一个 Integer 对象代表 1,那么它就永远代表 1,你不可能修改它的值使它为2。那如果你需要 2 怎么办?很简单,新建一个 Integer 对象,并让它表示 2 即可。

这样就明白问题所在了,i++ 每次自增都会新创建一个 Integer 对象,i 对象一直在变,因此,两个线程每次加锁可能都加在了不同的对象实例上,从而导致对临界区代码控制出现问题。
修正这个问题也很简单,只需要将下面代码:

1
synchronized(i) {

改为:

1
synchronized(instance) {

即可。

3. JDK 并发包

首先,介绍有关同步控制的工具,除了 synchronized,还有更加丰富多彩的多线程控制方法。
其次,详细介绍 JDK 中对线程池的支持,使用线程池将能在很大程度上提高线程调度的性能。
再次,介绍 JDK 的一些并发容器,这些容器专为并行访问设计,是高效、安全、稳定的实用工具。

3.1 多线程的团队协作:同步控制

同步控制是并发程序必不可少的重要手段,synchronized 关键字就是一种最简单的控制方法,它决定了一个线程是否可以访问临界区资源。同时,Object.wait() 方法和 Object.notify() 方法起到了线程等待和通知的作用。
下面首先介绍 synchronized、Object.wait()、Object.notify() 方法的替代品:重入锁。

3.1.1 synchronized的功能扩展:重入锁

重入锁可以完全替代关键字 synchronized,重入锁使用 java.util.concurrent.locks.ReentrantLock 类来实现。
下面是一段最简单的重入锁使用案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ReenterLock implements Runnable {
public static ReentrantLock lock = new ReentrantLock();
public static int i = 0;
@Override
public void run() {
for(int j=0; j<10000000; j++) {
lock.lock();
try{
i++;
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
ReenterLock rl = new ReenterLock();
Thread t1 = new Thread(rl);
Thread t2 = new Thread(rl);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(i);
}
}

上述代码第 7~12 行使用重入锁保护临界区资源 i,确保多线程对 i 操作的安全性。可以看到,与关键字 synchronized 相比,重入锁有着显示的操作过程。开发人员必须手动指定何时加锁,何时释放锁。也正因为这样,重入锁对逻辑控制的灵活性要远远优于关键字 synchronized。但需要注意,在退出临界区时,必须记得释放锁,否则其他线程就没有机会再访问临界区了。

从类的命名上看,Re-Entrant-Lock 翻译成重入锁非常贴切,是因为这种锁是可以反复进入的。当然,这里的反复仅仅局限于一个线程。上述代码的第 7~12 行,可以写成下面的形式:

1
2
3
4
5
6
7
8
lock.lock();
lock.lock();
try{
i++;
} finally {
lock.unlock();
lock.unlock();
}

在这种情况下,一个线程连续两次获得同一把锁是允许的。如果不允许这么操作,那么同一个线程在第 2 此获得锁时,将会和自己产生死锁。但要注意的是,如果同一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。如果释放锁的次数多了,会得到一个 java.lang.IllegalMonitorStateException 异常,反正,如果释放锁的次数少了,相当于线程还持有这个锁,因此,其他线程无法进入临界区。

除了使用上的灵活性以外,重入锁还提供了一些高级功能,比如,重入锁可以提供中断处理的能力。

1. 中断响应
对于synchronized 关键字来说,如果一个线程在等待锁,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,等待锁的线程是可以被中断的。在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候这么做是非常有必要的。比如,你和朋友约好去打球,你等了半个小时朋友还没有到,这时朋友打电话告诉你他来不了了,那么你一定打道回府了,不用傻傻的继续等待。
中断正是提供了一套类似的机制。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无须等待,可以马上停止工作了,这种情况对处理死锁很有帮助。
下面代码产生了一个死锁,但得益于锁中断,我们可以轻易地解决这个死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class IntLock implements Runnable {
public static ReentrantLock lock1 = new ReentrantLock();
public static ReentrantLock lock2 = new ReentrantLock();
int lock;
//控制加锁顺序,方便构造死锁
public IntLock(int lock) {
this.lock = lock
}
@Override
public void run() {
try{
if(lock == 1) {
lock1.lockInterruptibly();
try {
Thread.sleep(500);
} catch(InterruptedException e) {}
lock2.lockInterruptibly();
} else {
lock2.lockInterruptibly();
try {
Thread.sleep(500);
} catch(InterruptedException e) {}
lock1.lockInterruptibly();
}
} catch(InterruptedException e) {
e.printStackTrace();
} finally {
if(lock1.isHeldByCurrentThread())
lock1.unlock();
if(lock2.isHeldByCurrentThread())
lock2.unlock();
System.out.println(Thread.currentThread().getId()+":线程退出");
}
}
public static void main(String[] args) throws InterruptedException {
IntLock r1 = new IntLock(1);
IntLock r2 = new IntLock(2);
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r2);
t1.start(); t2.start();
Thread.sleep(1000);
//中断其中一个线程
t2.interrupt();
}
}

线程 t1 和 t2 启动后,t1 先占用lock1,再占用lock2;t2 先占用lock2,再请求lock1。因此,很容易形成 t1 和 t2 之间的相互等待,造成死锁。在这里,对锁的请求统一使用 lockInterruptibly() 方法,这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,可以响应中断。
主线程 main 中,启动两个线程后,主线程休眠1秒,此时 t1 和 t2 处于死锁状态。然后主线程中断 t2 线程,t2 会放弃对 lock1 的申请,同时释放已获得的 lock2。这样 t1 就可以顺利得到 lock2 而继续执行下去。
最终,主线程对 t2 线程进行了中断之后,两个线程都得以退出,死锁问题解决。但是真正完成工作的只有 t1,而 t2 线程则放弃其任务直接退出,释放资源。

2. 锁申请等待限时
除了外部通知(中断)之外,要避免死锁还有另外一种方法,那就是限时等待。给定一个等待时间,让线程自动放弃。使用 tryLock() 方法进行一次限时的等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TimeLock implements Runnable {
public static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
try {
if(lock.tryLock(5. TimeUnit.SECONDS)) {
Thread.sleep(6000);
} else {
System.out.println("get lock failed");
}
} catch(InterruptedException e) {
e.printStackTrace();
} finally { if(lock.isHeldByCurrentThread()) lock.unlock(); }
}
public static void main(String[] args) {
TimeLock tlock = new TimeLock();
Thread t1 = new Thread(tlock);
Thread t2 = new Thread(tlock);
t1.start();
t2.start();
}
}

上述代码中,tryLock() 方法接收两个参数,一个表示等待时长,另外一个表示计时单位。这里单位为秒,时长5秒。表示线程在这个锁的请求中最多等待 5 秒,如果超过 5 秒还没得到锁,就会返回 false。如果成功获得锁,则返回 true。
在本例中,由于占用锁的线程会持有锁长达 6 秒,故另一个线程无法在 5 秒的等待时间内获得锁,因此请求锁会失败。

ReentrantLock.tryLock() 方法也可以不带参数直接运行,这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁成功,立即返回 true。如果锁被其他线程占用,则当前线程不会等待,而是立即返回 false。这种模式不会引起线程等待,因此不会产生死锁

3. 公平锁
多数情况下,锁的竞争都是非公平的。也就是说,线程 1 首先请求了锁 A,接着线程 2 也请求了锁 A,那么当锁 A 可用时,是线程 1 获得锁还是线程 2 呢?这个是不一定的,系统只是从锁的等待队列中随机挑选一个,因此不能保证其公平性。
而公平的锁,它会按照时间的先后顺序,保证先到者先得,后到者后得。公平锁的一大特点是,它不会产生饥饿现象。只要你排队,最终还是可以等到资源的。
使用 synchronized 关键字进行锁控制,产生的锁就是非公平的。而重入锁允许对公平性进行设置,可以是公平锁。

1
public ReentrantLock(boolean fair)

当参数 fair 为 true 时,表示锁是公平的。公平锁会维护一个有序队列,因此公平锁的实现成本比较高,性能非常低下,因此默认情况下,锁是非公平的。公平锁和非公平锁的线程调度也不一样,公平锁的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FairLock implements Runnable {
public static ReentrantLock fairLock = new ReentrantLock(true);
@Override
public void run() {
while(true) {
try {
fairLock.lock();
System.out.println(Thread.currentThread().getName() + "获得锁");
} finally {
fairLock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
FairLock f = new FairLock();
Thread t1 = new Thread(f, "Thread_t1");
Thread t2 = new Thread(f, "Thread_t2");
t1.start(); t2.start();
}
}

上述代码第 2 行指定锁是公平的,由 t1 和 t2 两个线程分别请求这把锁,并且在得到锁后,进行控制的输出,表示自己得到了锁。在公平锁的情况下,得到的输出时,t1 和 t2 交替得到这把锁。
而在非公平锁的情况下,就不是交替获得锁了,可能是 t1 连续获得锁,然后 t2 再连续获得锁。

ReentrantLock重入锁总结
对上面提到过的 ReentrantLock 几个重要的方法整理如下:

  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功则返回 true,失败返回 false。该方法不等待,立即返回,不产生死锁。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁,有时限的等待。
  • unlock():释放锁。

在重入锁的实现中,主要包含三个要素。
第一,原子状态。原子状态使用 CAS 操作(后面第4章讲)来存储当前锁的状态,判断锁是否已经被别的线程持有了。
第二,等待队列。所有没有请求到锁的线程,会进入等待队列进行等待。待有线程释放锁后,系统就能从等待队列中唤醒一个线程,继续工作。
第三,阻塞原语 park() 和 unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。有关 park() 和 unpark() 的详细介绍,参考 3.1.7 节线程阻塞工具类:LockSupport。

3.1.2 重入锁的好搭档:Condition条件

如果理解了 Object.wait() 方法和 Object.notify() 方法,就能很容易地理解 Condition 对象了。它与 wait() 和 notify() 方法的作用是相同的。但是 wait() 与 notify() 要与 synchronized 关键字合作使用,而 Condition 是与重入锁相关联的。
通过 Lock 接口(重入锁实现了这一接口)的 newCondition() 方法可以生成一个与当前重入锁绑定的 Condition 实例。利用 Condition 对象,就可以让线程在合适的时间等等,或者在某一个特定的时刻得到通知,继续执行。

Condition 接口提供的方法如下:

1
2
3
4
5
6
7
void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();

以上方法含义如下:

  • await() 方法会使当前线程等待,同时释放当前锁,当其他线程中使用 signal() 方法或 signalAll() 方法时,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和 Object.wait() 方法相似。
  • awaitUninterruptibly() 方法与 await() 方法基本相同,但是它不会在等待过程中响应中断。
  • signal() 方法用于在 Condition 实例的等待队列中,唤醒一个在等待中的线程,signalAll() 方法会唤醒所有在等待中的线程。这和 Object.notify() 方法类似。调用 Condition 实例的 signal() 方法前要求先持有这把锁(与 Condition 实例绑定的锁)。

下面代码演示了 Condition 的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ReenterLockCondition implements Runnable{
public static ReentrantLock lock = new ReentrantLock();
public static Condition condition = lock.newCondition();
@Override
public void run() {
try {
lock.lock();
condition.await(); //释放lock锁,并在lock锁上等待
System.out.println("Thread is going on");
} catch (InterruptedException e) {
e.printStackTrace();
}finally{
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ReenterLockCondition tl=new ReenterLockCondition();
Thread t1=new Thread(tl);
t1.start();
Thread.sleep(2000);
//通知线程t1继续执行
lock.lock();
condition.signal(); //line23: 唤醒锁上的等待线程
lock.unlock();
}
}

上面第 3 行代码通过 lock 生成一个与之绑定的 Condition 对象。第 8 行代码要求线程在 Condition 对象上进行等待。第 23 行代码由主线程 main 发出通知,告知等待在 Condition 上的线程可以继续执行了。

与 Object.wait() 方法和 Object.notify() 方法一样,当线程使用 Condition.await() 方法时,要求线程持有相关的重入锁。在 Condition.await() 方法调用后,这个线程会释放这把锁。同理,在 Condition.signal() 方法调用时,也要求线程先获得相关的锁。在 signal() 方法调用后,系统会从当前 Condition 对象的等待队列中唤醒一个线程。一旦线程被唤醒,它会重新尝试获得与之绑定的重入锁,一旦成功获取,就可以继续执行。因而,在 signal() 方法调用之后,一般需要释放相关锁,让给被唤醒的线程。上述代码中,如果省略第 24 行,那么虽然唤醒了线程 t1,但是由于它无法重新获得锁,因而仍然无法继续执行。

需要注意,同一个重入锁可以生成多个 Condition 实例,每个实例有自己的等待队列,所以更加灵活。

3.1.3 允许多个线程同时访问:信号量(Semaphore)

信号量为多线程协作提供了更强大的控制方法。从广义上说,信号量是对锁的扩展。无论是内部锁 synchronized 还是重入锁 ReentrantLock,一次都只允许一个线程访问一个资源,而信号量却可以指定多个线程,同时访问某一个资源。
信号量主要提供了以下构造函数:

1
2
public Semaphore(int permits)
public Semaphore(int permits, boolean fair) //第二参数指定是否公平

在构造信号量对象时,必须指定信号量的准入数,即同时能申请多少个许可。当每个线程每次只申请一个许可时,这就相当于指定了同时有多少个线程访问某一个资源。
信号量的主要逻辑方法有:

1
2
3
4
5
public void acquire()
public void acquireUninterruptibly()
public boolean tryAcquire()
public boolean tryAcquire(long timeout, TimeUnit unit)
public void release()

acquire() 方法尝试获得一个准入的许可。若无法获得,则线程会等待,直到有线程释放一个许可,或者当前线程被中断。
acquireUninterruptibly() 方法和 acquire() 方法类似,但是不响应中断。
tryAcquire() 方法尝试获得一个许可,如果成功则返回 true,失败则返回 false,它不会进行等待,立即返回。
release() 方法用于在线程访问资源结束后释放一个许可,以使其他等待许可的线程可以进行资源访问。

一个信号量使用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SemapDemo implements Runnable {
final Semaphore semp = new Semaphore(5);
@Override
public void run() {
try {
semp.acquire();
Thread.sleep(2000);
System.out.println(Thread.currentThread().getId() + ":done!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semp.release(); //line:12
}
}
public static void main(String[] args) {
ExecutorService exec = Executors.newFixedThreadPool(20);
final SemapDemo demo = new SemapDemo();
for (int i = 0; i < 20; i++) {
exec.submit(demo);
}
}
}

第 2 行代码声明了一个包含 5 个许可的信号量。这就意味着同时可以有 5 个线程进入代码段第 6~8 行。第 6~8 行为临界区管理代码,程序会限制执行这段代码的线程数。申请信号量使用 acquire() 方法操作,在离开时,务必使用 release() 方法释放信号量(代码第12行)。这就和释放锁是一个道理。
如果不幸发生了信号量的泄漏(申请了但没有释放),那么可以进入临界区的线程数量就会越来越少,直到所有的线程均不可访问。
在本例中,同时开启 20 个线程,观察程序的输出,会发现系统以 5 个线程一组为单位,依次输出带有线程 ID 的提示文本。

3.1.4 ReadWriteLock 读写锁

ReadWriteLock 是 JDK5 中提供的读写分离锁。读写分离锁可以有效减少锁竞争,提升系统性能。
用锁分离机制提升性能非常容易理解,比如线程 A1、A2、A3 进行写操作,B1、B2、B3 进行读操作,如果使用重入锁或者内部锁,则理论上所有读之间、读与写之间、写和写之间都是串行操作。当 B1 读取时,B2、B3 则需要等待锁,由于读操作不改变数据,显然这种等待是不合理的。
读写锁允许多个线程同时读,使得 B1、B2、B3 之间真正并行,但是,写和写之间、读和写之间依然需要互相等待和持有锁的。如下所示:

非阻塞 阻塞
阻塞 阻塞
  • 读-读不互斥:读与读之间不阻塞。
  • 读-写互斥:读阻塞写,写也会阻塞读。
  • 写-写互斥:写写阻塞。

如果在系统中,读操作的次数远远大于写操作,则读写锁就可以发挥最大的功效,提示系统性能。
一个 ReadWriteLock 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class ReadWriteLockDemo {
private static Lock lock = new ReentrantLock();
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static Lock readLock = readWriteLock.readLock();
private static Lock writeLock = readWriteLock.writeLock();
private int value;
public Object handleRead(Lock lock) throws InterruptedException{
try{
lock.lock(); //模拟读操作
Thread.sleep(1000); //读操作的耗时越多,读写锁的优势就越明显
return value;
}finally{ lock.unlock(); }
}
public void handleWrite(Lock lock,int index) throws InterruptedException{
try{
lock.lock(); //模拟写操作
Thread.sleep(1000);
value=index;
}finally{ lock.unlock(); }
}

public static void main(String[] args) {
final ReadWriteLockDemo demo=new ReadWriteLockDemo();
Runnable readRunnale=new Runnable() {
@Override
public void run() {
try {
demo.handleRead(readLock);
// demo.handleRead(lock);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Runnable writeRunnale=new Runnable() {
@Override
public void run() {
try {
demo.handleWrite(writeLock,new Random().nextInt());
// demo.handleWrite(lock,new Random().nextInt());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for(int i=0;i<18;i++){
new Thread(readRunnale).start();
}
for(int i=18;i<20;i++){
new Thread(writeRunnale).start();
}
}
}

上述代码中,用 Thread.sleep() 模拟一个耗时的操作,让线程耗时1秒,分别对应读耗时和写耗时。readRunnale 是读线程,writeRunnale 是写线程。在这里,readRunnale 使用了读锁,writeRunnale 使用了写锁。最后,开启了 18 个读线程,2 个写线程。由于这里使用了读写分离锁,读线程完全并行,而写会阻塞读,因此,实际上这段代码运行大约 2 秒多就能结束(写线程之间串行)。而如果不使用读写分离锁,而使用普通的重入锁,所有读和写线程之间都必须相互等待,因此整个程序的执行时间将长达 20 余秒。

3.1.5 倒计数器:CountDownLatch

CountDownLatch 是一个非常实用的多线程控制工具类,“Count Down”在英文中意为倒计数,Latch 意为门闩的意思,在这里门闩的含义是把门锁起来,不让里面的线程跑出来。因此,CountDownLatch 通常用来控制线程等待,它可以让某一个线程等待直到倒计数结束,再开始执行。

倒计数器的一种典型场景就是火箭发射,在火箭发射前,为了确保万无一失,往往还要对各项设备、仪器进行检查。只有等待所有的检查都完成后,引擎才能点火。

CountDownLatch 的构造函数接收一个整数,即当前这个倒计数器的计数个数。CountDownLatch 提供的方法如下:

1
2
3
4
5
public CountDownLatch(int count)
public void await() throws InterruptedException
public boolean await(long timeout, TimeUnit unit)
public void countDown()
public long getCount()

一个 CountDownLatch 的简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CountDownLatchDemo implements Runnable {
static final CountDownLatch end = new CountDownLatch(10);
static final CountDownLatchDemo demo = new CountDownLatchDemo();
@Override
public void run() {
try {
//模拟检查任务
Thread.sleep(new Random().nextInt(10)*1000);
System.out.println("check complete");
end.countDown(); //line:10,计数减一
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newFixedThreadPool(10);
for(int i=0;i<10;i++){
exec.submit(demo);
}
//等待检查
end.await();
//发射火箭
System.out.println("Fire!");
exec.shutdown();
}
}

上述代码第 2 行生成一个 CountDownLatch 实例,计数数量为 10,这表示需要 10 个线程完成任务后,等待在 CountDownLatch 上的线程才能继续执行。代码第 10 行使用了 CountDownLatch 的 countDown() 方法,也就是通知 CountDownLatch,一个线程已经完成任务,倒计数器减 1。主线程中使用 CountDownLatch 的 await() 方法,表示主线程在 CountDownLatch 上等待,只有倒计数结束后,主线程才能继续执行。

注意,多个线程使用到的必须是同一个 CountDownLatch 实例,这里声明为了静态变量。

3.1.6 循环栅栏:CyclicBarrier

CyclicBarrier 是另一种多线程并发控制工具,和 CountDownLatch 非常类似,它也可以实现线程间的计数等待,但它的功能比 CountDownLatch 更复杂更强大。
CyclicBarrier 可以理解为循环栅栏。栅栏就是一种障碍物,阻止线程进来,也就是用来阻止线程继续执行,要求线程在栅栏外等待。前面 Cyclic 意为循环,也就是说这个计数器可以反复使用。比如,我们将计数器设置为 10,那么凑齐第一批 10 个线程后,计数器就会归零,接着凑齐下一批 10 个线程,这就是循环栅栏的含义。

CyclicBarrier 的使用场景比较丰富。比如,司令下达命令,要求 10 个士兵先集合报到,再一起去完成任务。当 10 个士兵都执行完任务,那么司令才对外宣布,任务完成。

CyclicBarrier 比 CountDownLatch 略微强大一些,它可以接收一个参数作为 barrierAction,barrierAction 就是当计数器每一次计数完成后,系统会执行的动作。
CyclicBarrier 提供的方法如下,构造函数中 parties 为计数总数,也就是参与的线程总数。

1
2
3
4
5
6
7
public CyclicBarrier(int parties)
public CyclicBarrier(int parties, Runnable barrierAction)
public int await() throws InterruptedException, BrokenBarrierException
public int await(long timeout, TimeUnit unit)
public boolean isBroken()
public void reset()
public int getNumberWaiting()

下面的示例使用 CyclicBarrier 演示了上述司令命令士兵完成任务的场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class CyclicBarrierDemo {
public static class Soldier implements Runnable {
private String soldier;
private final CyclicBarrier cyclic;
Soldier(CyclicBarrier cyclic, String soldierName) {
this.cyclic = cyclic;
this.soldier = soldierName;
}
public void run() {
try {
//等待所有士兵到齐,然后执行一次barrierAction
cyclic.await();
doWork();
//等待所有士兵完成工作,再执行一次barrierAction
cyclic.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}
void doWork() {
try {
Thread.sleep(Math.abs(new Random().nextInt()%10000));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(soldier + ":劳动完成");
}
}
//barrierAction
public static class BarrierRun implements Runnable {
boolean flag;
int N;
public BarrierRun(boolean flag, int N) {
this.flag = flag;
this.N = N;
}
public void run() {
if (flag) {
System.out.println("司令:[士兵" + N + "个,任务完成!]");
} else {
System.out.println("司令:[士兵" + N + "个,集合完毕!]");
flag = true;
}
}
}
public static void main(String args[]) throws InterruptedException {
final int N = 10;
Thread[] allSoldier = new Thread[N];
boolean flag = false;
CyclicBarrier cyclic = new CyclicBarrier(N, new BarrierRun(flag, N));
//设置屏障点,主要是为了执行这个方法
System.out.println("集合队伍!");
for (int i = 0; i < N; ++i) {
System.out.println("士兵 "+i+" 报道!");
allSoldier[i]=new Thread(new Soldier(cyclic, "士兵 " + i));
allSoldier[i].start();
// if(i==5){
// allSoldier[0].interrupt();
// }
}
}
}

上述代码,主函数中创建了 CyclicBarrier 实例,并将计数器设置为 10。要求在计数器达到指标时,执行 barrierAction 也就是 BarrierRun 的 run() 方法。每一个士兵线程都会执行 Soldier 的 run() 方法,在 Soldier 的 run() 方法中,等待了两轮。

第一轮,每个士兵线程都在 CyclicBarrier 实例上等待。每当有一个士兵第一次等待时,CyclicBarrier 计数一次,当所有士兵都进行了第一次等待,CyclicBarrier 完成了一轮循环计数,那么就会触发 barrierAction 执行,与此同时,每个士兵线程继续执行。

第二轮,每个士兵都完成任务,并且都重新在 CyclicBarrier 实例上等待。每当有一个士兵完成任务并开始等待,CyclicBarrier 计数一次,当所有士兵都完成任务且等待,CyclicBarrier 完成了第二轮循环计数,那么 barrierAction 开始执行,与此同时,每个士兵线程继续执行。

注意,所有线程使用的是同一个 CyclicBarrier 实例,在这里是主线程创建并传入士兵线程。

上述代码的执行输出如下:

1
2
3
4
5
6
7
8
9
集合队伍!
士兵 0 报道!
//省略其他几个士兵,0到9士兵顺序报道
士兵 9 报道!
司令:[士兵10个,集合完毕!]
士兵 7:任务完成
//省略其他几个士兵,多线程下士兵乱序完成任务
士兵 4:任务完成
司令:[士兵10个,任务完成!]

CyclicBarrier 实例的 await() 方法可能会抛出两个异常,一个 InterruptedException,也就是在等待过程中,线程被中断,是一个比较通用的异常,大部分迫使线程等待的方法都可能抛出此异常,使得线程可以响应外部紧急事件。另外一个异常这是 CyclicBarrier 特有的 BrokenBarrierException,一旦遇到这个异常,则表示当前 CyclicBarrier 实例已经破损了,可能系统已经没有办法等待所有线程到齐了。如果再继续等待,可能是徒劳无功的。

如果把上述代码中主函数里,最后注掉的三行代码放开,使得第 5 个士兵线程中断了第 1 个士兵线程,就很可能会得到 1 个 InterruptedException 和 9 个 BrokenBarrierException。由于第 1 个士兵被中断,所以它抛出了 InterruptedException。而由于第 1 个士兵被中断,这一轮循环计数无法完成,其他所有等待在 CyclicBarrier 上的线程都会抛出 BrokenBarrierException,这样可以避免其他线程进行永久的、无谓的等待(因为第1个士兵线程已经被中断,所以等待是没有结果的)。

3.1.7 线程阻塞工具类:LockSupport

LockSupport 是一个非常方便实用的线程阻塞工具,它可以在线程内任意位置让线程阻塞。与 Thread.suspend() 相比,它弥补了由于 resume() 方法发生导致线程无法继续执行的情况。和 Object.wait() 相比,它不需要先获得某个对象的锁,也不会抛出 InterruptedException 异常。
LockSupport 的静态负 park() 可以阻塞当前线程,类似的还有 parkNanos()、parkUntil() 等方法,它们实现了一个限时的等待。
用 LockSupport 重写 2.1.5 小节中 suspend() 导致线程永久卡死的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class LockSupportDemo {
public static Object u = new Object();
static ChangeObjectThread t1 = new ChangeObjectThread("t1");
static ChangeObjectThread t2 = new ChangeObjectThread("t2");

public static class ChangeObjectThread extends Thread {
public ChangeObjectThread(String name){
super.setName(name);
}
@Override
public void run() {
synchronized (u) {
System.out.println("in "+getName());
LockSupport.park(this);
}
}
}
public static void main(String[] args) throws InterruptedException {
t1.start();
Thread.sleep(100);
t2.start();
LockSupport.unpark(t1);
LockSupport.unpark(t2);
t1.join();
t2.join();
}
}

在这里,我们只是将原来的 suspend() 和 resume() 方法用 park() 和 unpark() 方法做了替换。当然,我们依然无法保证 unpark() 发生在 park() 方法之后,但是执行这段代码,你会发现它自始至终都可以正常地结束,不会因为 park() 方法而导致线程永久挂起。
这是因为,LockSupport 类使用类似信号量的机制,它为每一个线程准备了一个许可,如果许可可用,那么 park() 方法会立即返回,并且消费这个许可(也就是将许可变为不可用),如果许可不可用,就会阻塞,而 unpark() 方法则使得一个许可变为可用(但和信号量不同,许可不能累加,你不能拥有超过一个许可,它永远只有一个)。

这个特点使得,即使 unpark() 方法操作发生在 park() 方法之前,它也可以使下一次的 park() 方法操作立即返回。这就是上述代码可顺利结束的主要原因。

同时,处于 park() 方法挂起状态的线程不会像 suspend() 方法那样还给出一个令人费解的 Runnable 状态,它会非常明确给出一个 Waiting 状态,甚至还会标注是 park() 方法引起的。
这使得分析问题时格外方便。此外,如果使用 park(Object) 方法,那么还可以为当前线程设置一个阻塞对象,这个阻塞对象会出现在线程 Dump 中,这样分析问题就更方便。

除了支持定时阻塞,LockSupport.park() 方法也支持中断,但它的中断和其他接收中断的方法很不一样,LockSupport.park() 方法不会抛出 InterruptedException 异常。它只会默默返回,但是可以从 Thread.interrupted() 等方法中获得中断标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class LockSupportIntDemo {
public static Object u = new Object();
static ChangeObjectThread t1 = new ChangeObjectThread("t1");
static ChangeObjectThread t2 = new ChangeObjectThread("t2");

public static class ChangeObjectThread extends Thread {
public ChangeObjectThread(String name){
super.setName(name);
}
@Override
public void run() {
synchronized (u) {
System.out.println("in "+getName());
LockSupport.park();
if(Thread.interrupted()){
System.out.println(getName()+" 被中断了");
}
}
System.out.println(getName()+"执行结束");
}
}
public static void main(String[] args) throws InterruptedException {
t1.start();
Thread.sleep(100);
t2.start();
t1.interrupt();
LockSupport.unpark(t2);
}
}

上述代码中,主线程中断了 t1 线程,t1 可以响应这个中断。因为中断后 park() 方法直接返回,判断中断标记 Thread.interrupted() 来进行中断处理。t1 返回后,等在外面的 t2 才能进入临界区,最终主线程 unpark(t2) 使其结束运行。

3.1.8 Guava 和 RateLimiter 限流

Guava 是 Google 旗下的一个高质量工具类库,是对 JDK 标准库的补充,下面介绍 Guava 中的一款限流工具 RateLimiter。

一种简单的限流算法,就是给出一个单位时间,然后使用一个计数器,然后使用一个计数器 counter 统计单位时间内收到的请求数量,当请求数量超过门限时,余下的请求丢弃或等待。但这种简单的算法有一个严重的问题,就是很难控制边界时间上的请求。假设时间单位是 1 秒,每秒请求不超过 10 个。如果在这中间的一秒内,就会合理处理 20 个请求,这明显违反了限流的基本需求。这是一种简单粗暴的总数量限流,而不是平均限流。如下图所示:

image

因此,更为一般化的限流算法有两种:漏桶算法和令牌桶算法。

漏桶算法的基本思想是,利用一个缓存区,当有请求进入系统时,无论请求的速率如何,都先在缓存区内保存,然后以固定的流速流出缓存区进行处理。
漏桶算法的特点是无论外部请求压力如何,漏桶算法总是以固定的流速处理数据。漏桶的容积和流出速率是该算法的两个重要参数。漏桶算法如下图:

image

令牌桶算法是一种反向的漏桶算法。在令牌桶算法中,桶中存放的不再是请求,而是令牌。处理程序只有拿到令牌后,才能对请求进行处理。如果没有令牌,那么处理程序要么丢弃请求,要么等待可用的令牌。为了限制流速,该算法在每个单位时间产生一定量的令牌存入桶中。比如,要限速每秒只处理 1 个请求,那么令牌桶就会每秒产生 1 个令牌。通常,桶的容量是有限的,比如,当令牌没有被消耗掉时,只能累计有限单位时间内的令牌数量,原理如下图:

image

RateLimiter 正是采用了令牌桶算法,下例展示了 RateLimiter 的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RateLimiterDemo {
static RateLimiter limiter = RateLimiter.create(2);
public static class Task implements Runnable {
@Override
public void run() {
System.out.println(System.currentTimeMillis());
}
}
public static void main(String args[]) throws InterruptedException {
for (int i = 0; i < 50; i++) {
limiter.acquire();
new Thread(new Task()).start();
}
}
}

上述代码第 2 行限制了 RateLimiter 每秒只能处理两个请求,在第 11 行调用 RateLimiter 的 acquire() 方法来控制流量。当使用 acquire() 方法时,过剩的流量调用会等待(阻塞),直到有机会执行。

但在有些场景中,如果系统无法处理请求,为了保证服务质量,更倾向于直接丢弃过载请求,从而避免可能的崩溃,此时,则可以使用 tryAcquire() 方法,如下:

1
2
3
4
5
6
for (int i = 0; i < 50; i++) {
if(!limiter.tryAcquire()) {
continue;
}
new Thread(new Task()).start();
}

当请求成功时,tryAcquire() 方法返回 true,否则返回 false,该方法不会阻塞。在本段代码中,如果访问数量超过限制,那么超出部分则直接丢弃,不再进行处理。由于 limiter 被限定 1 秒两次调用,也就是每 500 毫秒产生一个令牌,显然 for 循环本身的效率很高,完全可以在 500 毫秒内完成,因此本段代码只产生一个输出,其余请求全部被丢弃。

3.2 线程复用:线程池

线程的 run() 方法结束后会自动回收该线程,简单应用中这样做可以,但在生产环境中,系统可能会开启很多线程来支撑其应用,当线程数量过大时,反而会耗尽CPU和内存资源。
虽然与进程比,线程是轻量的,但创建和关闭线程依然需要花费时间,如果为每一个小的任务都创建一个线程,可能创建销毁线程的时间大于真实工作的时间。
线程本身占用内存空间,大量线程抢占内存,处理不当会OutOfMemory,大量的线程回收给GC造成压力。
生成环境中,线程的数量必须得到控制,盲目的大量创建线程对系统性能有害。

3.2.1 什么是线程池

当系统需要线程时,并不创建一个新的线程,而是从线程池中取一个空闲的线程,当完成工作后,也并不销毁线程,而是放回到线程池。
类似于数据库连接池。

3.2.2 JDK 对线程池的支持

JDK 提供了一套 Executor 框架,帮助开发人员有效地进行线程控制,其本质是一个线程池。核心类的类图如下:

image

以上成员都在 java.util.concurrent 包中,是JDK并发包的核心类。其中 ThreadPoolExecutor 表示一个线程池,Executors 类扮演线程池工厂的角色,通过 Executors 可以取得一个拥有特定功能的线程池。从UML图中得知,ThreadPoolExecutor 类实现了 Executor 接口,因此通过这个接口,任何 Runnable 的对象都可以被 ThreadPoolExecutor 线程池调度。

Executors 提供了各种类型的线程池,主要有以下工厂方法:

  • newFixedThreadPool() 方法:返回一个固定线程数量的线程池,当有任务提交时,若有空闲线程则立即执行,若没有空闲线程,新的任务会被暂存在一个任务队列中,待有空闲线程时再处理。
  • newSingleThreadExecutor() 方法:返回一个只有一个线程的线程池,若多余一个任务提交,暂存在任务队列中,先进先出的处理。
  • newCachedThreadPool() 方法:返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,若有空闲线程则优先复用空闲线程,若没有空闲线程,则会创建新的线程。所有线程执行完当前任务,返回线程池等待复用。
  • newSingleThreadScheduledExecutor() 方法:返回一个 ScheduledExecutorService对象,线程池大小为1。ScheduledExecutorService接口在 ExecutorService 接口之上扩展了在给定时间执行某任务的功能,如在某个固定的延时之后执行,或者周期性执行某个任务。
  • newScheduledThreadPool() 方法:该方法也返回一个 ScheduledExecutorService 对象,但该线程池可以指定线程数量。

1. 固定大小的线程池
以 newFixedThreadPool() 为例,简单展示线程池使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ThreadPoolDemo {
public static class MyTask implements Runnable {
@Override
public void run() {
System.out.println(System.currentTimeMillis() + ":Thread ID:"
+ Thread.currentThread().getId());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
MyTask task = new MyTask();
ExecutorService es = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
es.submit(task);
}
}
}

上述代码中,Executors.newFixedThreadPool(5) 创建了固定大小的线程池,内有5个线程。然后通过 for 循环依次向线程池提交了10个任务。此后,线程池会安排调度这 10 个任务,最终的结果会打印线程ID,可以看到总共 5 个线程,每个线程执行了两个任务。

2. 计划任务
另一个值得注意的方法是 newScheduledThreadPool() 方法,它返回一个 ScheduledExecutorService 对象。可以根据时间需要对线程进行调度。ScheduledExecutorService 的一些主要方法如下:

1
2
3
public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit);
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);

与其他几个线程池不同,ScheduledExecutorService 并不一定会立即安排执行任务,它起到了计划任务的作用。它会在指定时间对任务进行调度。

针对上面 ScheduledExecutorService 的三个方法依次介绍。

  • schedule() 方法:会在指定时间,对任务进行一次调度。指定时间是指延时 delay 之后,仅调度一次。
  • scheduleAtFixedRate() 方法:以固定频率进行周期性调度。创建一个周期性任务,任务开始于给定的初始延时initialDelay,后续的任务按照给定的周期period进行:后续第一个任务将会在 initialDelay+period 时执行,后续第二个任务将在 initialDelay+2*period 时进行,以此类推。如果时间到了但前一个任务还未完成,则会在前一个任务结束后立即执行后续任务。
  • scheduleWithFixedDelay() 方法:在上一个任务结束后,再经过delay时间进行任务调度。创建一个周期性任务,任务开始于初始延时initialDelay,后续任务将会安装给定的延时delay进行:即上一个任务的结束时间到下一个任务的开始时间的时间差。

一个 scheduleAtFixedRate() 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ScheduledExecutorServiceDemo {
public static void main(String[] args) {
ScheduledExecutorService ses = Executors.newScheduledThreadPool(10);
//如果前面的任务没有完成,则调度也不会启动
ses.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
System.out.println(System.currentTimeMillis()/1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, 0, 2, TimeUnit.SECONDS);

}
}

执行上述代码,一种可能的输出是,时间间隔为2秒打印时间戳。但如果任务的执行时间超过调度时间会怎么样?比如在这里任务执行时间是 1 秒,调度周期是 2 秒,如果把执行时间改为 8 秒:

1
Thread.sleep(8000);

这样的话,会发现最终任务的执行周期不再是 2 秒,而是变成了 8 秒。也就是说,周期如果太短,那么任务就会在上一个任务结束后立即被调用

另外,调度程序实际上并不保证任务会无限期地持续调用,如果任务本身抛出了异常,那么后续任务的执行都会被中断,因此需要做好异常处理。

3.2.3 核心线程池的内部实现

核心的几个线程池,无论是 newFixedThreadPool()、newSingleThreadExecutor() 还是 newCachedThreadPool(),它们的内部实现都使用了 ThreadPoolExecutor 类。下面给出这三个线程池的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(
new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

由以上线程池的实现代码可以看到,它们都只是 ThreadPoolExecutor 类的封装。看一下 ThreadPoolExecutor 类最重要的构造函数:

1
2
3
4
5
6
7
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)

函数的参数含义如下:

  • corePoolSize:指定线程池中的线程数量。
  • maximumPoolSize:指定了线程池中的最大线程数量。
  • keepAliveTime:当线程池线程数量超过 corePoolSize 时,多余的空闲线程的存活时间,即超过 corePoolSize 的空闲线程,在多长时间内会被销毁。
  • unit:keepAliveTime 的时间单位。
  • workQueue:任务队列,被提交但尚未被执行的任务。
  • threadFactory:线程工厂,用于创建线程,一般用默认的即可。
  • handler:拒绝策略。当任务太多来不及处理时,如何拒绝任务。

以上大多数参数比较简单,只有 workQueue 和 handler 值得详细说明。

workQueue 参数是指被提交但未执行的任务队列,它是一个 BlockingQueue 接口的对象,仅用于存放 Runnable 对象。根据队列功能分类,在 ThreadPoolExecutor 类的构造函数中可使用以下几种 BlockingQueue 接口:

  • 直接提交的队列:由 SynchronousQueue 对象提供,SynchronousQueue 是一个特殊的 BlockingQueue。SynchronousQueue 没有容量,每一个插入操作都要等待相应的删除操作,每一个删除操作都要等待对应的插入操作。如果使用 SynchronousQueue,则提交的任务不会被真实的保存,而总是将新任务提交给线程执行,如果没有空闲线程,则尝试创建线程,如果线程数量达到最大值,则执行拒绝策略。使用 SynchronousQueue 时需要设置很大的 maximumPoolSize,否则很容易执行拒绝策略。

  • 有界的任务队列:有界的任务队列使用 ArrayBlockingQueue,它的构造函数必须带一个容量参数,表示队列最大容量。使用有界任务队列,当有新任务时,若线程池的实际线程数小于 corePoolSize,则会优先创建新的线程。若大于 corePoolSize,则会将新任务加入等待队列。若等待队列已满,则在总线程数不大于 maximumPoolSize 的前提下,创建新的线程执行任务。若在总线程数大于 maximumPoolSize,则执行拒绝策略。可见,有界队列仅当在任务队列装满时,才可能将线程数提升到 corePoolSize 以上,换言之,除非系统非常繁忙,否则要确保核心线程数维持在 corePoolSize。

  • 无界的任务队列:无界的任务队列使用 LinkedBlockingQueue,与有界队列相比,除非系统资源耗尽,否则无界的任务队列不存在任务入队失败的情况。当有新的任务到来,系统的线程数小于 corePoolSize 时,线程池会生成新的线程执行任务。但当系统的线程数达到 corePoolSize 后,就不会继续增加了。若后续仍有新任务,又没有空闲的线程,则任务直接进入队列等待。若任务创建的很快,而处理的速度很慢,则无界队列会保持快速增长,直到耗尽系统内存。

  • 优先任务队列:优先任务队列是带有执行优先级的队列,通过 PriorityBlockingQueue 实现,可以控制任务的执行先后顺序。它是一个特殊的无界队列。无论是有界队列 ArrayBlockingQueue,还是无界队列 LinkedBlockingQueue,都是按照先进先出的方式处理任务。而 PriorityBlockingQueue 则可以根据任务自身的优先级顺序先后执行,在确保系统性能的同时,也能有很好的质量保证(总确保高优先级的任务先执行)。

回顾 newFixedThreadPool() 方法的实现,它返回了一个 corePoolSize 和 maximumPoolSize 大小一样的,并且使用了 LinkedBlockingQueue 任务队列的线程池。作为固定线程池,线程数量不会动态变化,所以 corePoolSize 和 maximumPoolSize 可以相等。同时,它使用无界队列存放等待执行的任务,当任务提交非常频繁时,该队列可能迅速膨胀,从而耗尽系统资源。

newSingleThreadExecutor() 方法返回的单线程线程池,是 newFixedThreadPool() 方法的一种退化,只是简单的将线程数量设置为 1。

newCachedThreadPool() 方法返回 corePoolSize 为 0,maximumPoolSize 无穷大的线程池,这意味着,当没有任务时,线程池内无线程。当新任务提交时,该线程池会使用空闲的线程,若无空闲线程,则将任务加入 SynchronousQueue 队列,而 SynchronousQueue 队列是一种直接提交的队列,它总是迫使线程池增加新的线程执行任务。当任务执行完毕,由于 corePoolSize 为 0,因此空闲线程又会在指定时间内(60秒)被回收。

对于 newCachedThreadPool(),如果同时又大量任务被提交,任务执行的又比较慢,那么系统会开启等量的线程执行任务,这样可能会很快耗尽系统的资源。

使用自定义线程池
当使用 ThreadPoolExecutor 自定义线程池时,要根据应用的具体情况,选择合适的并发队列作为任务的缓冲,当线程资源紧张时,不同的并发队列对系统行为和性能的影响均不同。
这里给出 ThreadPoolExecutor 线程池的核心调度代码,充分体现了上述线程池的工作逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) { //line:5
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) { //line:10
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false)) //line:17
reject(command);
}

代码第 5 行的 workerCountOf() 函数取得了当前线程池的线程总数,当线程总数小于 corePoolSize 核心线程数时,会将任务通过 addWorkder() 方法直接调度执行。否则,则在第 10 行代码处 workQueue.offer() 进入等待队列。如果进入等待队列失败,比如有界队列到达上限或者使用了 SynchronousQueue,则会执行第 17 行,将任务直接提交给线程池。如果当前线程数已经达到 maximumPoolSize,则提交线程池失败,执行第 18 行的拒绝策略。
ThreadPoolExecutor 的调度逻辑:

1
2
3
4
5
6
7
graph TB
A[任务提交] -->|小于corePoolSize| B[分配线程执行]
A -->|大于corePoolSize| C[提交到等待队列]
C -->|成功| D[等待队列]
C -->|失败| E[提交线程池]
E -->|达到maximumPoolSize提交失败| F[拒绝执行]
E -->|未达到提交成功| G[分配线程执行]

3.2.4 超负载怎么办:拒绝策略

ThreadPoolExecutor 的最后一个参数 RejectedExecutionHandler handler 指定了拒绝策略。当任务数量超过系统承载能力时,就会用到拒绝策略。比如线程池中的线程已经用完,无法继续为新任务服务,同时等待队列中已经排满。
JDK 内置了四种拒绝策略,RejectedExecutionHandler 接口的四个实现类,都是以 ThreadPoolExecutor 中的静态内部类形式实现:

  • AbortPolicy:该策略会抛出异常,阻止系统正常工作。
  • CallerRunsPolicy:只要线程池未关闭,直接在调用者线程中运行当前被丢弃的任务。这样不会真的丢弃任务,但任务提交线程的性能可能急剧下降。
  • DiscardOldestPolicy:该策略将丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
  • DiscardPolicy:该策略默默丢弃无法处理的任务,不予任务处理。

以上四个内置策略,均实现了 RejectedExecutionHandler 接口。也可以自己扩展 RejectedExecutionHandler 接口,实现自定义的拒绝策略。RejectedExecutionHandler 接口定义如下:

1
2
3
public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

其中 r 为请求执行的任务,executor 为当前的线程池。

下面代码演示了自定义线程池和拒绝策略的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class RejectThreadPoolDemo {
public static class MyTask implements Runnable {
@Override
public void run() {
System.out.println(System.currentTimeMillis() + ":Thread ID:"
+ Thread.currentThread().getId());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) throws InterruptedException {
MyTask task = new MyTask();
ExecutorService es = new ThreadPoolExecutor(5, 5, //line:17
0L, TimeUnit.MILLISECONDS,
new SynchronousQueue<Runnable>(),
Executors.defaultThreadFactory(),
new RejectedExecutionHandler(){
@Override
public void rejectedExecution(Runnable r,
ThreadPoolExecutor executor) {
System.out.println(r.toString()+" is discard");
}
}); //line:27
for (int i = 0; i < Integer.MAX_VALUE; i++) {
es.submit(task);
Thread.sleep(10);
}
}
}

上述代码第 17~27 行自定义了一个线程池,有 5 个常驻线程,最大线程数量也是 5 个。这和固定线程池是一样的,但它却又一个 10 个容量的等待队列。因为使用无界队列有可能耗光系统资源,给一个合理的队列大小更合乎常理。同时,自定义了拒绝策略,不抛出异常,因为在任务提交端可能没有做异常处理,只是打印了将要被丢弃的任务信息。
由于 MyTask 执行消耗 100 毫秒,会导致大量的任务被直接丢弃。上述代码的输出中,可以看到在执行几个任务后,拒绝策略就开始生效了。实际应用中,可以将更相信的信息记录到日志,来分析系统的负载和任务丢失的情况。

3.2.5 自定义线程创建:ThreadFactory

线程池中的线程从哪来的?线程池的主要作用是为了线程复用,也就是避免线程的频繁创建。但是,最开始的线程从何而来呢?答案就是 ThreadFactory。
ThreadFactory 是一个接口,它只有一个用来创建线程的方法:

1
Thread newThread(Runnable r);

当线程池需要新建线程时,就会调用这个方法。

自定义线程创建我们能做不少事,比如可以跟踪线程池在何时创建了多少线程,可以自定义线程的名称、组以及优先级等信息,甚至可以任性的将所有线程设置为守护线程。总之,使用自定义线程创建可以更自由的设置线程池中线程的状态。
下面的案例使用了自定义的 ThreadFactory,一方面记录了线程的创建,一方面将所有线程设置为守护线程,这样,当主线程退出后,将会强制销毁线程池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static void main(String[] args) throws InterruptedException {
MyTask task = new MyTask();
ExecutorService es = new ThreadPoolExecutor(5, 5,
0L, TimeUnit.MILLISECONDS,
new SynchronousQueue<Runnable>(),
new ThreadFactory(){
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setDaemon(true);
System.out.println("create "+t);
return t;
}
},
new RejectedExecutionHandler(){
@Override
public void rejectedExecution(Runnable r,
ThreadPoolExecutor executor) {
System.out.println(r.toString()+" is discard");
}
});
for (int i = 0; i < Integer.MAX_VALUE; i++) {
es.submit(task);
Thread.sleep(10);
}
}

3.2.6 我的应用我做主:扩展线程池

虽然JDK已经帮我们实现了这个稳定高性能的线程池,但是有时我们需要自定义一些增强的功能,对线程池做一些扩展,比如监控每个任务执行的开始时间和结束时间。
ThreadPoolExecutor 是一个可以扩展的线程池。它提供了 beforeExecute()、afterExecute() 和 terminated() 三个接口方法用来对线程池进行控制。
以 beforeExecute()、afterExecute() 为例,它们在 ThreadPoolExecutor.runWorker(Workder w) 方法内部提供了这样的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
try {
beforeExecute(wt, task); //运行前
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown); //运行后
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}

ThreadPoolExecutor.Worker 是 ThreadPoolExecutor 的内部类,它是一个实现了 Runnable 接口的类。ThreadPoolExecutor 线程池中的工作线程也正是 Worker 实例。Worker.run() 方法会调用上述 ThreadPoolExecutor.runWorker(Workder w) 实现每一个工作线程的固有工作。

在默认的 ThreadPoolExecutor 实现中,提供了空的 beforeExecute() 和 afterExecute() 两个接口实现。在实际应用中,可以对其进行扩展实现对线程池运行状态的跟踪。
下面演示了对线程池的扩展,在这个扩展中,我们将记录每一个任务的执行日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class ExtThreadPool {
public static class MyTask implements Runnable {
public String name;
public MyTask(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("正在执行" + ":Thread ID:" + Thread.currentThread().getId()
+ ",Task Name=" + name);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()) {
@Override
protected void beforeExecute(Thread t, Runnable r) {
System.out.println("准备执行:" + ((MyTask) r).name);
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
System.out.println("执行完成:" + ((MyTask) r).name);
}
@Override
protected void terminated() {
System.out.println("线程池退出");
}
};
for (int i = 0; i < 5; i++) {
MyTask task = new MyTask("TASK-GEYM-" + i);
es.execute(task);
Thread.sleep(10);
}
es.shutdown();
}
}

上述代码 main() 函数中扩展了原有的线程池,实现了 beforeExecute()、afterExecute() 和 terminated() 三个方法。这三个方法分别用于记录一个任务的开始、结束和整个线程池的退出。之后向线程池提交了 5 个任务。

线程池 ExecutorService 中 submit() 方法和 execute() 方法的区别,将在“5.5 Future模式”中介绍。

提交完成后,调用 shutdown() 方法关闭线程池,这是一个比较安全的方法,如果当前正有线程在执行,shutdown() 方法并不会立即暴力地终止所有任务,它会等待所有任务执行完成后,再关闭线程池,但它不会等待所有线程执行完成后再返回,因此,shutdown() 方法可以简单理解成发送了一个关闭信号而已。但在 shutdown() 方法调用之后,这个线程池就不能再接受其他新的任务了。

3.2.7 合理的选择:优化线程池线程数量

线程池的大小对系统性能有一定影响,过大过小的线程数量都无法发挥最优的系统性能,但是线程池的大小也不需要非常精确,只需要避免极大和极小两种情况。
一般来说,确定线程池的大小需要考虑 CPU 数量、内存大小等因素。在《Java Concurrency in Practice》一书中给出了估算线程池大小的公式:

1
2
3
4
5
6
Ncpu = CPU的数量
Ucpu = 目标CPU的使用率,0 < Ucpu < 1
W/C = 等待时间与计算时间的比率

最优的线程池大小等于:
Nthreads = Ncpu * Ucpu * (1 + W/C)

Java 中,可以通过如下代码取得可用的 CPU 数量:

1
Runtime.getRuntime().availableProcessors()

3.2.8 堆栈去哪里了:在线程池中寻找堆栈

在上一章中,我们介绍了一些幽灵般的错误,多线程本身就非常容易引起这类错误,如果使用了线程池,那么就更加常见。
下面一个简单的案例,首先有一个 Runnable 接口,它用来计算两个数的商:

1
2
3
4
5
6
7
8
9
10
11
12
public class DivTask implements Runnable {
int a,b;
public DivTask(int a,int b){
this.a=a;
this.b=b;
}
@Override
public void run() {
double re=a/b;
System.out.println(re);
}
}

如果运行这个任务,我们期望它可以打印给定两个数的商。现在构造几个这样的任务:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
ThreadPoolExecutor pools = new TraceThreadPoolExecutor(0, Integer.MAX_VALUE,
0L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
for(int i=0;i<5;i++){
pools.submit(new DivTask(100,i));
}
}

上述代码将 DivTask 提交到线程池,我们期望得到 5 个结果,但真的运行后,只有 4 个输出:

1
2
3
4
33.0
50.0
100.0
25.0

只有 4 个输出,也就是说漏算了一组数据,但是程序没有任务日志和错误提示。在这个简单的案例中,当然可以发现作为除数的 i 取到了 0,这个缺失的值很可能是由于除以 0 导致的。但在稍复杂的业务场景中,这种错误足可以让你几天萎靡不振。

因此,线程池很有可能会“吃”掉程序抛出的异常,导致我们对程序的错误一无所知。

一种最简单的方法,就是放弃使用 submit() 方法,改用 execute() 方法,将上述的任务提交代码改成:

1
pools.execute(new DivTask(100,i));

或者使用下面的方法改造你的 submit() 方法:

1
2
Future re = pools.submit(new DivTask(100,i));
re.get();

上面两种方法都可以得到部分堆栈信息,注意,这里说的是部分,因为从这两个异常堆栈中我们只能知道异常是在哪里抛出的(这里是DivTask的第9行)。但是我们还希望知道,这个任务到底是在哪里提交的。而任务的提交位置已经被线程池完全淹没了。

扩展 ThreadPoolExecutor 线程池,让它在调度任务之前,先保存一下提交任务线程的堆栈信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class TraceThreadPoolExecutor extends ThreadPoolExecutor {
public TraceThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
@Override
public void execute(Runnable task) {
super.execute(wrap(task, clientTrace(), Thread.currentThread()
.getName()));
}
@Override
public Future<?> submit(Runnable task) {
return super.submit(wrap(task, clientTrace(), Thread.currentThread()
.getName()));
}
private Exception clientTrace() {
return new Exception("Client stack trace");
}
private Runnable wrap(final Runnable task, final Exception clientStack,
String clientThreadName) {
return new Runnable() {
@Override
public void run() {
try {
task.run();
} catch (Exception e) {
clientStack.printStackTrace();
throw e;
}
}
};
}
}

上述代码中的 wrap() 方法的第 2 个参数是一个异常,里面保存着提交任务的线程的堆栈信息。该方法将我们传入的 Runnable 任务进行一层包装,使之能处理异常信息。当任务发生异常时,这个异常会被打印。
现在使用自定义的 TraceThreadPoolExecutor 来执行这段代码:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
ThreadPoolExecutor pools=new TraceThreadPoolExecutor(0, Integer.MAX_VALUE,
0L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
for(int i=0;i<5;i++){
pools.execute(new DivTask(100,i));
}
}

执行上述代码,熟悉的异常又回来了。现在,不仅可以得到异常发生的 Runnable 实现内的信息,也知道了这个任务是在哪里提交的,帮助我们瞬间定位问题。

3.2.9 分而治之:Fork/Join框架

分而治之是一个非常有效的处理大量数据的方法,著名的 MapReduce 也是采用的分而治之的思想。简单说,分而治之就是如果你需要处理 1000 个数据,但你不具备处理 1000 个数据的能力,那么你可以只处理其中的 10 个,然后分阶段处理 100 次,最后将 100 次的结果合并。

Fork 一词有分叉的意思,在 Linux 中方法 fork() 用来创建子进程,在 Java 中沿用了类似的命名方式。join() 方法表示等待,fork() 方法后系统多了个执行分支(线程),所以需要等待这个分支执行完毕,才能得到最终结果。
实际使用中,如果毫无顾忌的使用 fork() 开启线程,会导致过多的线程从而影响性能。JDK中,给出了一个 ForkJoinPool 线程池,对于 fork() 方法并不急着开启线程,而是提交给 ForkJoinPool 线程池进行处理,以节省系统资源。

由于线程池的优化,任务和线程的数量不是一对一的,一个物理线程通常需要处理多个任务,每个线程有一个任务队列。如果线程 A 把自己的任务都执行完了,而线程 B 还有一堆任务等待处理,此时,线程 A 就会“帮助”线程 B,从线程 B 的任务队列中拿一个任务过来处理。注意,当一个线程视图“帮助”其他线程时,总是从任务队列的底部开始获取数据,而线程执行自己的任务时,则是从相反的顶部开始获取数据。因此,这种行为避免了数据竞争。

看一下 ForkJoinPool 线程池的一个重要接口:

1
public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task)

你可以向 ForkJoinPool 线程池提交一个 ForkJoinTask 任务,所谓 ForkJoinTask 任务就是支持 fork() 方法分解以及 join() 方法等待的任务。ForkJoinTask 有两个重要的子类,RecursiveAction类和RecursiveTask类。它们分别表示没有返回值的任务和带返回值的任务。
展示 Fork/Join 框架的使用方法,这里计算数列求和:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class CountTask extends RecursiveTask<Long>{
private static final int THRESHOLD = 10000;
private long start;
private long end;
public CountTask(long start,long end){
this.start=start;
this.end=end;
}
@Override
public Long compute(){
long sum = 0;
boolean canCompute = (end-start)<THRESHOLD;
if(canCompute){
for(long i=start;i<=end;i++){
sum +=i;
}
}else{
//分成100个小任务
long step = (start+end)/100;
ArrayList<CountTask> subTasks = new ArrayList<CountTask>();
long pos = start;
for(int i=0; i<100; i++){
long lastOne = pos+step;
if(lastOne>end)lastOne = end;
CountTask subTask = new CountTask(pos,lastOne);
pos += step+1;
subTasks.add(subTask);
subTask.fork(); //新启线程执行
}
for(CountTask t:subTasks){
sum += t.join(); //等待结果合并
}
}
return sum;
}
public static void main(String[]args){
ForkJoinPool forkJoinPool = new ForkJoinPool(); //line:37
CountTask task = new CountTask(0,200000000000L);
ForkJoinTask<Long> result = forkJoinPool.submit(task); //任务提交开始执行
try{
long res = result.get(); //line:41
System.out.println("sum="+res);
}catch(InterruptedException e){
e.printStackTrace();
}catch(ExecutionException e){
e.printStackTrace();
}
}
}

由于计算数列求和是需要函数返回值的,因此选择 RecursiveTask 作为任务模型。上述代码主函数中建立了 ForkJoinPool 线程池,接下来构造了一个 1 到 200,000 求和的任务,然后把任务提交给线程池,开始执行任务。线程池返回一个携带结果的任务,通过 get() 方法可以得到最终的结果(第41行)。如果执行 get() 方法时任务没有结束,那么主线程就会在 get() 方法时等待。
继续看一下 CountTask 的实现,首先它继承了 RecursiveTask 类,可以携带返回值,这里返回值是 long 类型。第 2 行定义了任务分解的规模,也就是如果需要求和的总数大于 THRESHOLD 个,那么任务就需要再次分解。实现了 RecursiveTask 类的 compute() 抽象方法,线程池会调用任务的此方法执行任务。compute() 方法中,判断任务是否需要分解,不需要直接结算,需要则分解成 100 个小任务,并使用 fork() 方法提交子任务,之后,等待所有子任务结束,并将结果再次求和,合并结果。

使用 Fork/Join 框架时注意,如果任务的划分层次很多,一直得不到返回,会出现两种情况。第二,线程数量越积越多,导致性能严重下降。第二,函数调用层次变多,最终导致栈溢出

此为,ForkJoin 线程池使用一个无锁的栈来管理空闲线程,如果一个工作线程取不到可用的任务,则会被挂起,挂起的线程被压入由线程池维护的栈中,待将来有任务可用时再从栈中唤醒。

3.2.10 Guava中堆线程池的扩展

除 JDK 内置的线程池意外,Guava 对线程池也进行了一定的扩展,主要体现在 MoreExecutors 工具类中。

1. 特殊的 DirectExecutor 线程池
MoreExecutors 中,提供了一个简单但是非常重要的线程池实现,即 DirectExecutor 线程池。DirectExecutor 没有真的创建线程,它总是将任务在当前线程中直接执行。
为什么需要一个这样的线程池?这是软件设计上的需要。抽象是软件设计的精髓,我们总是希望使用通用的代码来处理不同的场景,因此,这就需要对不同场景进行统一的抽象和建模
对于线程池来说,目的是为了复用线程以提高运行效率,但其业务需求却是去异步执行一段业务指令。但有时候,异步并不是必要的。因此当我们剥离线程池的技术细节,仅关注其使用场景便不难发现,任何一个可以运行 Runnable 实例的模块都可以视为线程池,即便它没有真的创建线程。这样就可以将异步执行和同步执行统一,使用统一的编码风格来处理同步和异步调用,进而简化设计。

1
2
3
4
public static void main(String[] args) {
Executor exceutor = MoreExecutors.directExecutor();
exceutor.execute(() -> System.out.println("I am running in " + Thread.currentThread().getName()));
}

上述代码向线程池执行一个 Runnable 接口,打印 Runnable 接口执行所在的线程,输出如下:

1
I am running in main

可以看到,这个 Runnable 接口在主线程中执行。
注入不同的 executor 的实现,例如使用固定大小线程池替代 DirectExecutor,无须修改代码便可以使程序拥有不同的行为,这正是 DirectExecutor 的用意所在。

2. Daemon 线程池
在 MoreExecutors 中还提供了将普通线程池转为 Daemon 线程池的方法。在很多场合,我们不希望后台线程池阻止程序的退出,当系统执行完成后,即便有线程池存在,依然希望进程结束执行。此时,就可以使用 MoreExecutors.getExitingExecutorService() 方法。

1
2
3
4
5
public static void main(String[] args) {
ThreadPoolExecutor exceutor = (ThreadPoolExecutor)Executors.newFixedThreadPool(2);
MoreExecutors.getExitingExecutorService(exceutor);
exceutor.execute(() -> System.out.println("I am running in " + Thread.currentThread().getName()));
}

上述代码输出“I am running in pool-1-thread-1”后,立即退出程序,若不使用 MoreExecutors.getExitingExecutorService() 方法对 executor 线程池进行设置,则该程序无法正常退出,除非手动关闭 executor 线程池。

3. 对 Future 模式的扩展
在 MoreExecutors 中还提供了对 Future 模式的扩展,这部分内容将在第 5.5 节 Future 模式中介绍。

3.3 不重复发明轮子:JDK的并发容器

除了提供如同步控制、线程池等基本工具外,为了提高开发人员的效率,JDK 还为大家准备了一大批好用的容器类,可以大大减少开发工作量。

3.3.1 并发集合简介

JDK 提供的这些容器大部分在 java.util.concurrent 包中,先简单介绍一下:

  • ConcurrentHashMap:高效的并发 HashMap,可以理解为一个线程安全的 HashMap。
  • CopyOnWriteArrayList:在读多写少的场合,这个 List 性能非常好,远远优于 Vector。
  • ConcurrentLinkedQueue:高效的并发队列,使用链表实现,可以看作一个线程安全的 LinkedList。
  • BlockingQueue:这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,适合作为数据共享的通道。
  • ConcurrentSkipListMap:跳表的实现,这是一个 Map,使用跳表的数据结构进行快速查找。

除以上并发包中的专有数据结构以外,java.util 下的 Vector 是线程安全的(性能较差),另外 Collections 工具类可以帮助我们将任意集合包装成线程安全的集合。

3.3.2 线程安全的 HashMap

签名的章节在,展示了在多线程环境中使用 HashMap 带来的问题,如果需要一个线程安全的 HashMap,应该怎么做?一种可行的方法是使用 Collections.synchronizedMap() 方法包装我们的 HashMap。如下代码:

1
public static Map m = Collections.synchronizedMap(new HashMap());

Collections.synchronizedMap() 方法会生成一个名为 SynchronizedMap 的 Map。它使用委托,将自己所有 Map 相关的操作传递给 HashMap 实现,而自己主要负责保证线程安全。

SynchronizedMap 的实现参考如下:

1
2
3
4
private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; //Backing Map
final Object mutex; //Object on whitch to synchronize

通过 mutex 实现对这个 m 的互斥操作,比如,对于 Map.get() 方法,它的实现如下:

1
2
3
public V get(Object key) {
synchronized (mutex) { return m.get(key); }
}

所有相关的 Map 操作都会使用这个 mutex 进行同步,从而实现线程安全。

虽然这个包装的 Map 满足线程安全的要求,但性能表现不太好,在高并发环境中,我们有必要寻求新的解决方案。
一个更加专业的并发 HashMap 就是 ConcurrentHashMap,它位于 java.util.concurrent 包内,专门为并发进行了性能优化,更适合多线程的场合。
关于 ConcurrentHashMap 的细节,下一章会详细说明它的实现细节。

3.3.3 有关 List 的线程安全

Java 中,ArrayList 和 Vector 都使用数组作为其内部实现,两者最大的不同在于 Vector 是线程安全的,而 ArrayList 不是。
此外,LinkedList 使用链表实现了 List,但它也不是线程安全的,不过可以参考前一节,使用 Collections.synchronizedMap() 对它进行包装,得到一个线程安全的 List 对象。

3.3.4 高效读写队列:ConcurrentLinkedQueue

ConcurrentLinkedQueue 是 JDK 提供的一个高效并发队列,使用链表作为其数据结构。根据性能测试,它算是高并发环境中性能最好的队列。它之所以能有很好的性能,是因为其内部复杂的实现。

不过在深入 ConcurrentLinkedQueue 类之前,建议先阅读一下第 4 章,补充一下有关无锁操作的知识。

ConcurrentLinkedQueue 的两个核心方法:

1
2
offer()  //向队列尾部添加元素
poll() //弹出队列头部元素

作为一个链表,自然要定义链表内的节点,在 ConcurrentLinkedQueue 类中,定义的节点 Node 核心如下:

1
2
3
private static class Node<E> {
volatile E item;
volatile Node<E> next;

其中 item 表示目标元素,next 表示当前 Node 的下一个元素,这样每个 Node 环环相扣,串在一起。

对 Node 进行操作时,使用了 CAS :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static class Node<E> {
volatile E item;
volatile Node<E> next;
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
void lazySetNext(Node<E> val) {
UNSAFE.putOrderedObject(this, nextOffset, val);
}
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
......
}

方法 casItem() 表示设置当前 Node 的 item 值,第一个参数 cmp 为期望值,第二个参数 val 为想要设置的目标值。若当前值等于 cmp 期望值时,就会将目标设置为 val。同样 casNext() 方法也是类似的,但它用于设置 next 字段,而不是 item。

ConcurrentLinkedQueue 类内部有两个字段 head 和 tail,分别表示链表的头部和尾部,它们都是 Node 类型。head 头部是准确的,通过 head 开始一定能遍历整个链表。但 tail 尾部不一定准确,因为 tail 的更新并不是及时的,如下图:

可以看到 tail 并不总是在更新。
下面就是在 ConcurrentLinkedQueue 类中向队列尾部添加元素的 offer() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public boolean offer(E e) {
checkNotNull(e);
final Node<E> newNode = new Node<E>(e);

for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
if (q == null) {
// p 是最后一个节点
if (p.casNext(null, newNode)) { //line:9
// 每两次更新一下 tail
if (p != t)
casTail(t, newNode);
return true;
}
// CAS 竞争失败,再次尝试
}
else if (p == q)
// 遇到哨兵节点,从 head 开始遍历
// 但如果 tail 被修改,则使用 tail(因为可能被修改正确了)
p = (t != (t = tail)) ? t : head;
else
// 取下一个节点或者最后一个节点
p = (p != t && t != (t = tail)) ? t : q;
}
}

值得注意的是,这个方法没有任何锁操作,线程安全完全由 CAS 操作和队列的算法来保证。整个方法的核心是 for 循环,循环没有出口,直到尝试成功,这也符合 CAS 操作的流程。
空链表的 tail 指向 head,当向空链表中添加第 1 个节点时,会进入第 9 行,新增 next 节点,并且由于 p==t 且均指向 head,而不会更新 tail。
添加第 2 个节点时,由于 p.next!=null(指向新增的第1个节点),会进入最后一个 else 代码块,取下一个节点。
所谓哨兵节点,就是 next 指向自己的节点,这种节点的意义不大,主要表示要删除的节点或者空节点。当遇到哨兵节点时,无法通过 next 取得后续节点,因此很可能直接返回 head,从头遍历。一旦遇到 tail 被其他线程修改,则使用新的 tail 作为尾部(进行一次打赌),避免重新查找 tail 的开销。

1
p = (t != (t = tail)) ? t : head;

上面这行代码,首先“!=”并不是原子操作,它可以被中断。在执行“!=”时,程序会先取得 t 的值,再执行 t=tail,并取得 t 的值,然后比较这两个值是否相等。单线程中 t!=t 肯定不会成立,但并发环境中,可能获得左边的 t 值后,右边的 t 值被其他线程修改。所有左边和右边的 t 值不同,表示 tail 被其他线程篡改。

下面看一下哨兵节点如何产生的。

1
2
3
ConcurrentLinkedQueue<String> q = new ConcurrentLinkedQueue<String>();
q.add("1");
q.poll();

上述代码第 3 行 poll() 方法弹出队列内的头部元素,poll() 的执行过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public E poll() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
if (item != null && p.casItem(item, null)) {
if (p != h)
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
else if (p == q)
continue restartFromHead;
else
p = q;
}
}
}

由于队列内只有一个元素,根据前文描述得知 tail 此时没有更新,而是指向和 head 相同的位置。此时,head 本身的 item 域为 null,其 next 为列表第一个元素。故在第 1 次循环,进入最后一个 else 语句,将 p 赋值为 q。而 q 其实就是 p.next,就是当前队列第一个元素。第 2 次循环中,p.item 不再是 null,进入第一个 if 代码块(CAS操作成功,把p的item赋值为null),p 和 h 此时不相等,执行 updateHead() 方法:

1
2
3
4
final void updateHead(Node<E> h, Node<E> p) {
if (h != p && casHead(h, p))
h.lazySetNext(h);
}

在 updateHead() 中将 p 作为新的链表头部(通过casHead()实现),而原有的 head 就被设置为哨兵(通过lazySetNext()方法实现),这样一个哨兵节点就产生了。

通过本节可以感觉到,不使用锁而单纯的使用 CAS 操作,会要求在应用层面保证线程安全,并处理一些可能存在的不一致问题,大大增加了程序设计和实现的难度。带来的好处就是使性能飞速提升,这就 ConcurrentLinkedQueue 几乎是性能最好的队列的原因。

3.3.5 高效读取:不变模式下的 CopyOnWriteArrayList

很多场景下,读远远大于写。因为读操作不修改原有数据,如果对于每次读取都加锁,其实是一种资源的浪费。根据读写锁的思想,读锁和读锁之间确实不冲突,但是,读操作会受到写操作的阻碍,当写发生,读就必须等待,否则就会读到不一致的数据。

为了将读取的性能发挥到极致,JDK 中提供了 CopyOnWriteArrayList 类,对它来说,读取是完全不用加锁的,并且写入也不会阻塞读取操作。只有写入和写入之间需要同步等待。
那它是怎么实现的呢?所谓 CopyOnWrite 就是在写入操作时,进行一次自我复制。当这个 List 需要修改时,我并不修改原有数据(保证当前在读线程的数据一致性),而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再用修改完的副本替换原来的数据,这样写操作就不会影响读了。

下面代码展示了读取的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CopyOnWriteArrayList<E> 
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private transient volatile Object[] array;
final Object[] getArray() {
return array;
}
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
}

注意,读取没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另一个 array 替换,因此可以保证数据的安全。参考“5.2 不变模式”会有更深的认识。

和简单的读取相比,写入操作就有些麻烦了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}

首先,写入操作使用锁,当然这个锁仅限于控制写-写的情况。其重点在于第 7 行代码进行了内部元素的完整复制。因此,会生成一个新的数组 newElements,将新的元素加入 newElements,然后使用新的数组替换旧的数组,修改就完成了。

3.3.6 数据共享通道:BlockingQueue

多线程的开发模式还会引入一个问题,如何进行多个线程之间的数据共享?比如,线程 A 希望给线程 B 发一条消息,用什么方式告知线程 B 是比较合理的呢?
我们希望整个系统是松散耦合的,我们希望线程 A 能够通知线程 B,又希望线程 A 不知道线程 B 的存在。BlockingQueue 就起到了中间的“意见箱”的作用(A给B写信,不需要直接找到B,只需要把信件放入意见箱)。

与之前提到的 ConcurrentLinkedQueue 类或者 CopyOnWriteArrayList 类不同,BlockingQueue 是一个接口,并非一个具体的实现。
BlockingQueue 接口的主要方法如下:

1
2
3
4
5
6
7
8
boolean add(E e);
boolean offer(E e);
void put(E e) throws InterruptedException;
E take() throws InterruptedException;
E poll(long timeout, TimeUnit unit) throws InterruptedException;
boolean remove(Object o);
public boolean contains(Object o);
int drainTo(Collection<? super E> c);

我们主要介绍 BlockingQueue 的两个实现类:ArrayBlockingQueue 和 LinkedBlockingQueue。
ArrayBlockingQueue 类基于数组实现,适合做有界队列,因为队列中可容纳最大元素需要在队列创建时指定(数组动态扩展不太方便)。而 LinkedBlockingQueue 类基于链表,适合做无界队列,或者边界值非常大的队列,因为内部元素可以动态增加,不会因为初始容量很大而占用很多内存。

BlockingQueue 的 blocking 是阻塞的意思,当服务线程读取队列中的消息时,BlockingQueue 会让服务线程在队列为空时进行等待,当有新的消息进入队列后,自动将线程唤醒

以 ArrayBlockingQueue 为例,看一下它的实现。ArrayBlockingQueue 类的内部元素都放置在一个对象数组中:

1
final Object[] items;

向队列尾部压入元素可以使用 offer() 和 put() 方法。对于 offer() 当队列已满时会返回 false。对于 put() 方法,如果队列已满,服务线程会一直等待,直到队列中有空闲的位置。所以,我们更关注 put() 方法。
从队列头部弹出元素可以使用 poll() 和 take() 方法。对于 poll() 当队列为空则直接返回 null。对于 take() 方法,如果队列为空则服务线程会等待,直到队列内有可用元素。所以,我们更关注 take() 方法。

因此,put() 和 take() 方法才是提现 BlockingQueue 的关键。为了做好等待和通知,在 ArrayBlockingQueue 类内部定义了以下一些字段:

1
2
3
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;

当执行 take() 时,如果队列为空,则在 notEmpty 上等待。新元素入队时,则进行一次 notEmpty 上的通知。
下面是 take() 方法的过程:

1
2
3
4
5
6
7
8
9
10
11
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}

上述第 6 行代码,要求当前线程进行等待。
当队列中加入新的元素时,等待的线程会得到一个通知:

1
2
3
4
5
6
7
8
private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
notEmpty.signal();
}

上述最后一行代码,当新元素进入队列后,通知等待在 notEmpty 上的线程,让它们继续工作。

同理,对于 put() 方法的操作也是一样的,就不展示源码了。put() 时如果队列已满,会在 notFull 上等待。而当有元素被取出时,会通知等待在 notFull 上的线程。

ArrayBlockingQueue 类在物理上是一个数组,但逻辑层面是一个环形结构。数组的容量在初始化时就已确定,无法动态调整。所以当有元素加入或离开时,总是会使用 takeIndex 和 putIndex 两个边路分别表示队列头部和尾部在数组中的位置。所以数组头尾相连,从而实现了环形数组

BlockingQueue 的使用非常普遍,通常用来解耦生产者和消费者线程。

3.3.7 随机数据结构:跳表SkipList

在 JDK 的并发包中,还实现了一种有趣的数据结构——跳表。
跳表是一种用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素快速查找。但区别是:对平衡树的插入删除会导致平衡树进行一次全局调整,而跳表只需对局部进行操作。好处是:保证平衡树的线程安全需要一个全局锁,而保证跳表的线程安全只需要部分锁。因此,跳表有更好的性能,跳表的时间复杂度是 O(log n),所以在并发数据结构在,JDK 使用跳表实现一个 Map。

跳表的另外一个特点是随机算法。跳表的本质是同时维护了多个链表,并且链表是分层的。如下图:

最底层的链表维护了跳表内所有元素,每上面一层都是下面一层链表的子集,一个元素插入哪些层是完全随机的。因此如果运气不好可能得到一个性能很差的结构,但实际工作中它的表现是非常好的。

跳表内所有链表的元素都是有序的,查找时,可以从顶层链表开始找,一旦发现查找元素大于当前链表中的取值,就会转入下一层链表继续找。也就是说,在查找过程中,搜索是跳跃式的。查找过程如下图:

因此,很显然,跳表是一种空间换时间的算法。

使用跳表实现的 Map 和使用哈希算法实现的 Map 的另一个不同就是,哈希是无序的,跳表是有序的。
实现跳表的类是 ConcurrentSkipListMap,下面展示了跳表的简单使用方法:

1
2
3
4
5
6
7
Map<Integer,Integer> map = new ConcurrentSkipListMap<Integer,Integer>();
for(int i=0; i<30; i++) {
map.put(i,i);
}
for(Map.Entry<Integer,Integer> entry : map.entrySet()) {
System.out.println(entry.getKey());
}

和 HashMap 不同,对跳表的遍历是有序的。

跳表的内部实现由几个关键的数据结构组成,首先是 Node,一个 Node 表示一个节点,里面含有 key 和 value(就是Map的key和value)。每个 Node 还会指向下一个 Node,因此还有一个元素 next。

1
2
3
4
static final class Node<K,V> {
final K key;
volatile Object value;
volatile Node<K,V> next;

对 Node 的所有操作,使用的都是 CAS 方法,方法 casValue() 用来设置 value 的值,casNext() 方法用来设置 next 字段。
另一个重要的结构是 Index,这表示一个索引,内部包装了 Node,同时增加了向下的引用和向右的引用:

1
2
3
4
static class Index<K,V> {
final Node<K,V> node;
final Index<K,V> down;
volatile Index<K,V> right;

整个跳表就是根据 Index 进行全网组织的。
此为,对于每一层的表头还需要记录当前处于那一层,还需要一个 HeadIndex 的数据结构,表示链表头部第一个 Index,它继承自 Index。

3.4 使用 JMH 进行性能测试

跳过本节

4. 锁的优化及注意事项

锁是最常用的同步方法之一,高并发环境下,激烈的锁竞争会导致程序的性能下降,因此要注意避免死锁、减小锁粒度、锁分离等。
多核时代,使用多线程可以提供系统的性能,但也会额外增加系统的开销。系统除了处理功能需求,还要维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。
因此,合理的并发,才能将多核 CPU 的性能发挥到极致。

4.1 有助于提高锁性能的几点建议

4.1.1 减少锁持有的时间

如果线程持有锁的时间越长,那么相对地,锁的竞争程度也就越激烈。应该尽可能地减少对某个锁的占有时间,以减少线程间互斥的可能。

以下面代码段为例:

1
2
3
4
5
public synchronized void syncMethod() {
othercode1();
mutexMethod();
othercode2();
}

在 syncMethod 方法中,假设只有 mutexMethod() 方法需要同步,而 othercode1() 和 othercode2() 方法并不需要同步控制。如果 othercode1() 和 othercode2() 方法是重量级的方法,花费较长时间,并发了较大时,这种对整个方法同步的方案,会导致线程大量增加。因为一个线程进入该方法获得内部锁,只有所有任务执行完毕,才会释放锁。

一个较为优化的方案是,只在必要时进行同步,这样减少锁的竞争,提高系统吞吐量:

1
2
3
4
5
6
7
public void syncMethod2() {
othercode1();
synchronized (this) {
mutexMethod();
}
othercode2();
}

改进的代码中只针对 mutexMethod() 做了同步,锁占用的时间较短,会有更高的并行度。

减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

4.1.2 减小锁粒度

对于 HashMap,最重要的是 get()、put() 方法,可以对整个 HashMap 加锁从而得到一个线程安全的对象。但这样做,加锁粒度太大,性能极差。
对于 ConcurrentHashMap 类,它内部细分了若干个小的 HashMap,称之为段(segment)。在默认情况下,一个 ConcurrentHashMap 类可以被细分为 16 个段。
如果在 ConcurrentHashMap 中添加一个新的元素,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该元素应该被存放在哪个段中,然后对该段加锁,并完成 put() 操作。在多线程环境下,如果多个线程同时进行 put(),只要被添加的元素不在同一个段中,线程间便可以做到真正的并行。
由于默认有 16 个段,最好的情况 ConcurrentHashMap 可以接受 16 个线程同时插入(都插入不同的段中),大大提升了吞吐量。
下面两行代码显示了 put() 操作的过程,第 5~6 行代码根据 key 获得对应段的序号,在第 9 行得到段,然后将数据插入指定段中:

1
2
3
4
5
6
7
8
9
10
11
public V put(K key, V value) {
Segment<K,V> s;
if(value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >> segmentShift) & segmentMask;
if((s = (Segment<K,V>)UNSAFE.getObject(segment,
(j<<SSHIFT)+SBASE)) == null)
s = ensureSegment(j);
return s.put(key, hash, value, false);
}

但是,减小锁粒度也带了一个问题,当系统需要获取全局锁时,需要获取所有段的锁,这样消耗的资源会比较多。比如 size() 方法,就有可能依次对所有段加锁,再求和。因此,只有类似 size() 方法获取全局信息的方法调用不频繁时,这种减小锁粒度的方法才能在真正意义上提高系统的吞吐量。

减小锁粒度,是指缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力。

4.1.3 用读写分离锁来替换独占锁

在读多写少的场景下,使用读写分离所 ReadWriteLock 可以提高系统的性能。使用读写分离锁来替代独占锁,是减小锁粒度的一种特殊情况。如果说减小锁粒度是通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割。

读多写少场景下,如果系统在读写数据时均只使用独占锁,那么读操作和写操作之间、读与读之间、写与写之间均不能做到真正的并发,并且都需要相互等待。而读操作本身不会影响数据的完整性和一致性,因此理论上可以允许多线程同时读,读写锁正是实现了这种功能。关于读写锁,可以回顾一下第 3 章。

4.1.4 锁分离

如果将读写锁的思想进一步延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。根据应用程序的功能特点,使用类似的思想,也可以对独占锁进行分离。一个典型的案例就是 LinkedBlockingQueue 的实现(前面章节讨论过它的近亲ArrayBlockingQueue的实现)。

在 LinkedBlockingQueue 的实现中,take() 方法和 put() 方法分别实现了从队列头部取数据,和往队列尾部增加数据的功能。虽然连个操作都对当前队列进行了修改,但由于 LinkedBlockingQueue 是基于链表的,且两个操作分别作用于队列的头部和尾部,理论上说,两者并不冲突。
如果使用独占锁,take() 和 put() 时都需要获取当前队列的独占锁,那么就不可能真正的并发,他们都需要等待对方释放锁。锁竞争会比较激烈,影响程序的并发性能。
因此,JDK 没有采取这种方式,而是用两把不同的锁分离了 take() 和 put() 操作:

1
2
3
4
private final ReentrantLock takeLock = new ReentrantLock(); //take()方法持有takeLock
private final Condition notEmpty = takeLock.newCondition();
private final ReentrantLock putLock = new ReentrantLock(); //put()方法持有putLock
private final Condition notFull = putLock.newCondition;

以上代码定义了 takeLock 和 putLock,它们分别在 take() 方法和 put() 方法中使用。因此,take() 和 put() 方法就此相互独立,它们之间不存在锁竞争关系,只需要在take()和take()之间、put()和put()之间分别对 takeLock 和 putLock 进行竞争,从而削弱了锁竞争的可能性。

take() 方法的实现如下,注释比较详细:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly(); //不能有两个线程同时取数据
try {
try {
while(count.get() == 0) //如果当前没有可用数据,则一直等待
notEmpty.await(); //等待put()方法操作的通知
} catch (InterruptedException e) {
notEmpty.signal(); //通知其他未中断的线程
throw e;
}
x = extract(); //取得第一个数据
c = count.getAndDecrement(); //数量减1,原子操作,因为会和put()同时访问count
//注意,c是count减1前的值
if(c > 1)
notEmpty.signal(); //通知其他take()方法操作
} finally {
takeLock.unlock(); //释放锁
}
if(c == capacity)
signalNotFull(); //通知put()方法操作,已有空余空间
return x;
}

put() 方法的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void put(E e) throws InterruptedException {
if(e == null) throw new NullPointerException();
int c = -1;
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly(); //不能有两个线程同时put()方法
try {
try {
while(count.get() == capacity) //如果队列已经满了
notFull.await(); //等待
} catch (InterruptedException e) {
notFull.signal(); //通知未中断的线程
throw e;
}
insert(e); //插入数据
c = count.getAndIncrement(); //更新总数,变量c是count加1前的值
if(c + 1 < capacity)
notFull.signal(); //有足够的空间,通知其他线程
} finally {
putLock.unlock(); //释放锁
}
if(c == 0)
signalNotEmpty(); //插入成功后,通知take()方法取数据
}

通过 takeLock 和 putLock 两把锁,LinkedBlockingQueue 实现了取数据和写数据的分离,使两者在真正意义上成为可并发的操作。

4.1.5 锁粗化

通常情况下,为了保证多线程的有效并发,会要求每个线程持有锁的时机尽量短(4.1.1节),但是凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统资源,反而不利于性能优化。
为此,虚拟机在遇到一连串的对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫锁的粗化

1
2
3
4
5
6
7
8
9
public void demoMethod() {
synchronized(lock) {
//do something
}
//做其他不需要同步的工作,但能很快执行完成
synchronized(lock) {
//do something
}
}

上面的代码段会被整合成如下形式:

1
2
3
4
5
6
7
public void demoMethod() {
//整合成一次锁请求
synchronized(lock) {
//do something
//做其他不需要同步的工作,但能很快执行完毕
}
}

实际开发中,应该有意识的在合理场合进行锁的粗化,例如在循环内请求锁时。比如:

1
2
3
4
5
for(int i=0; i<CIRCLE; i++) {
synchronized(lock) {
//do something
}
}

锁粗化后改为:

1
2
3
4
5
synchronized(lock) {
for(int i=0; i<CIRCLE; i++) {
//do something
}
}

锁粗化就是根据真实运行情况,对各个资源点进行权衡折中的过程。锁粗化的思想和减少锁的持有时间是相反的,但在不同的场合,它们的效果并不相同,要根据实际情况进行权衡。

4.2 Java虚拟机对锁优化所做的努力

JDK 内部也对提高并发时的系统吞吐量进行了优化,介绍几种 JDK 内部锁优化的策略。

4.2.1 锁偏向

锁偏向是一种针对加锁操作的优化手段。它的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了系统性能。
因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能都是同一个线程请求相同的锁。对于锁竞争激烈的场合,最坏情况是每次都是不同线程请求锁,这样偏向模式会失效,还不如不启用偏向锁。
使用 Java 虚拟机参数 -XX:+UseBiasedLocking 可以开启偏向锁。

4.2.2 轻量级锁

如果偏向锁失效,那么虚拟机并不会立即挂起线程,它还会使用一种称为轻量级锁的优化手段。轻量级锁的操作也很方便,它只是简单将对象头部作为指针指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。

4.2.3 自旋锁

锁膨胀后,为了避免线程真实的在操作系统层面挂起,虚拟机还会做最后的努力——自旋锁。
当前线程暂时无法获得锁,而且什么时候可以获得锁也是未知数,也许在几个CPU时钟周期后就可以得到锁。如果这样,简单粗暴的挂起线程可能得不偿失。系统会假设在不久的将来,线程可以得到这把锁。因此,虚拟机会让当前线程做几个空循环(这也就是自旋的含义),在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。如果还不能获得锁,才会真的将线程在操作系统层面挂起。

4.2.4 锁消除

锁消除是一种更彻底的锁优化。Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁的时间。
为什么会在不存在竞争的地方加上锁呢?很可能是在不存在并发的场合使用了自带线程安全的 Java API,比如在没有并发的场合使用了 Vector,而 Vector 内部使用了 synchronized 请求锁,比如下面的代码:

1
2
3
4
5
6
7
public String[] createStrings() {
Vector<String> v = new Vector<String>();
for(int i=0; i<100; i++) {
v.add(Integer.toString(i));
}
return v.toArray(new String[]{});
}

上述代码中的 Vector,变量 v 只在 createStrings() 方法中使用,因此它只是一个局部变量,局部变量在线程栈上分配,属于线程私有的数据,因此不可能被其他线程访问。这种情况下,Vector 内部所有加锁同步都是没必要的。如果虚拟机检测到这种情况,就会将无用的锁操作去除

锁消除涉及一项关键技术为逃逸分析。所谓逃逸分析就是观察一个变量是否会逃出某一个作用域。本例中,变量 v 显然没有逃出 createStrings() 方法之外。以此为基础,才能进行 v 内部的锁消除。如果 createStrings() 返回的是变量 v 本身,那么就认为变量 v 逃逸出了当前函数,也就是变量 v 有可能被其他线程访问。这样,虚拟机就不会消除 v 中的锁操作。
逃逸分析必须在 -server 模式下进行,可以使用 -XX:+DoEscapeAnalysis 参数打开逃逸分析,使用 -XX:+EliminateLocks 参数打开锁消除。

4.3 人手一支笔:ThreadLocal

除了控制资源的访问,还可以增加资源来保证所有对象的线程安全。比如,让 100 个人填写表格,如果只有一支笔,要控制笔的使用,大家得挨个写,不能一起哄抢这一支笔。从另一个角度出发,我们可以准备 100 支笔,人手一支,那么所有人很快就能填写完表格。
如果说锁使用的是第一种思路,那么 ThreadLocal 使用的就是第二种思路。

4.3.1 ThreadLocal 的简单使用

ThreadLocal 是一个线程的局部变量,只有当前线程能访问,自然是线程安全的。
一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ThreadLocalDemo {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static class ParseDate implements Runnable{
int i=0;
public ParseDate(int i){this.i=i;}
public void run() {
try {
Date t=sdf.parse("2015-03-29 19:29:"+i%60);
System.out.println(i+":"+t);
} catch (ParseException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ExecutorService es=Executors.newFixedThreadPool(10);
for(int i=0;i<1000;i++){
es.execute(new ParseDate(i));
}
}
}

上述代码在多线程中使用 SimpleDateFormat 对象实例来解析字符串类型的日期,执行上述代码,一般来说,很可能得到一些异常(NumberFormatException)。出现这个问题的原因是,SimpleDateFormat.parse() 方法并不是线程安全的。因此在线程池中共享这个对象必然导致错误。

一种可行的方案是在 parse() 方法前后加锁,这是一般处理思路。但这里不这么做,我们使用 ThreadLocal 为每一个线程创造一个 SimpleDateFormat 对象实例。
改造成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ThreadLocalDemo2 {
static ThreadLocal<SimpleDateFormat> tl=new ThreadLocal<SimpleDateFormat>();
public static class ParseDate implements Runnable{
int i=0;
public ParseDate(int i){this.i=i;}
public void run() {
try {
if(tl.get()==null){
tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
}
Date t=tl.get().parse("2015-03-29 19:29:"+i%60);
System.out.println(i+":"+t);
} catch (ParseException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ExecutorService es=Executors.newFixedThreadPool(10);
for(int i=0;i<1000;i++){
es.execute(new ParseDate(i));
}
}
}

上述代码中,如果当前线程不持有 SimpleDateFormat 对象实例,那么就新建一个并把它设置到当前线程中,如果已经持有,则直接使用。

可以看到,为每一个线程分配一个对象的工作并不是由 ThreadLocal 完成的,而是需要应用层保证。如果在应用上为每一个线程分配了相同的对象实例,那么 ThreadLocal 也不能保证线程安全,这点需要注意。

为每一个线程分配不同对象,这点需要在应用层面保证,ThreadLocal只起到了简单的容器作用。

4.3.2 ThreadLocal 的实现原理

ThreadLocal 如何保证这些对象只被当前线程访问呢?深入一下 ThreadLocal 的内部实现。
先从 ThreadLocal 的 set() 和 get() 说起,set() 的实现:

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if(map != null)
map.set(this, value);
else
createMap(t, value);
}

在 set() 时,先获得当前线程对象,然后拿到线程的 ThreadLocalMap,并将值存入 ThreadLocalMap。而 ThreadLocalMap可以理解为一个Map(虽然并不是),它是定义在 Thread 类内部的成员:

1
ThreadLocal.ThreadLocalMap threadLocals = null;

set() 方法中,就是写入了上面的 threadLocals,key 为 ThreadLocal 对象,value 就是我们需要的值。而 threadLocals 本身就保存了当前自己所在线程的所有“局部变量”,也就是一个 ThreadLocal 变量的集合。

ThreadLocal 在进行 get() 操作时,就是将这个 ThreadLocalMap 中的数据拿出来:

1
2
3
4
5
6
7
8
9
10
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if(map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if(e != null)
return (T)e.value;
}
return setInitialValue();
}

get() 方法先取得当前线程的 ThreadLocalMap 对象,然后通过将自己作为 key 取得内部实际数据。

了解了 ThreadLocal 的实现之后,会发现这些变量都维护在 Thread 类的内部(ThreadLocalMap定义在Thread类中)。这也就意味着,只要线程不退出对象的引用一直存在。
当线程退出时,Thread 类会进行一些清理工作,其中包括清理 ThreadLocalMap,清理代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 在线程退出前,由系统回调,进行资源清理
*/
private void exit() {
if (group != null) {
group.threadTerminated(this);
group = null;
}
target = null;
/* 清理ThreadLocalMap */
threadLocals = null;
inheritableThreadLocals = null;
inheritedAccessControlContext = null;
blocker = null;
uncaughtExceptionHandler = null;
}

因此,使用线程池,比如固定大小线程池,当中的线程一直存在,如果将一些大对象设置到线程的 ThreadLocal 中,实际保存在线程持有的 ThreadLocalMap 内,这样可能会出现内存泄露。因为你设置了对象到 ThreadLocal 中,但是不清理它,在使用几次之后不再使用了,但是它却无法被回收。
所以,不再使用后,最好使用 ThreadLocal.remove() 方法将这个变量移除,防止内存泄漏。

还有一种情况,JDK 可能允许你像释放普通变量一样释放 ThreadLocal,比如写出类似 obj=null 的代码,obj 指向的对象就会更容易的被垃圾回收器发现,从而加速回收。要了解这里的回收机制,需要进一步了解 Thread.ThreadLocalMap 的实现。之前我们说 ThreadLocalMap 是一个类似 HashMap 的东西,准确的说,它更加类似 WeakHashMap。
ThreadLocalMap 的实现使用了 弱引用。Java 虚拟机在垃圾回收时,如果发现弱引用,就会立即回收。
ThreadLocalMap 内部由一系列 Entry 构成,每一个 Entry 都是 WeakReference

1
2
3
4
5
6
7
static class Entry extends WeakReference<ThreadLocal> {
Object value;
Entry(ThreadLocal k, Object v) {
super(k);
value = v;
}
}

这里的参数 k 就是 Map 的 key,v 就是 Map 的value,其中 k 也是 ThreadLocal 实例。虽然这里 ThreadLocal 作为 Map 的key,但实际上它不真的持有 ThreadLocal 的引用。而当 ThreadLocal 的外部强引用被回收时,ThreadLocalMap 中的 key 就会变成 null,当系统进行 ThreadLocalMap 清理时,就会将这些垃圾数据回收。

4.3.3 对性能有何帮助

为每一个线程分配一个独立的对象有可能提升系统的性能,这也取决于共享对象的内部逻辑。如果共享对象对于竞争的处理容易引起性能损失,我们还是应该考虑使用 ThreadLocal 为每个线程分配单独的对象。
一个典型案例就是在多线程下产生随机数,首先我们定义一些全局变量:

1
2
3
4
5
6
7
8
9
10
11
public static final int GEN_COUNT = 10000000;
public static final int THREAD_COUNT = 4;
static ExecutorService exe = Executors.newFixedThreadPool(THREAD_COUNT);
public static Random rnd = new Random(123);

public static ThreadLocal<Random> tRnd = new ThreadLocal<Random>() {
@Override
protected Random initialValue() {
return new Random(123);
}
};

上述代码第 1 行定义了每个线程产生的随机数数量,第 2 行定义了参与工作的线程数量,第 3 行定义了一个线程池,第 4 行定义了被多线程共享的 Random 实例,用于产生随机数,第 6~11 行定义了由 ThreadLocal 封装的 Random。

定义一个工作线程的内部逻辑,它可以工作在两个模式,第一是多线程共享一个 Random(mode=0),第二是多个线程各分配一个 Random(mode=1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static class RndTask implements Callable<Long> {
private int mode = 0;

public RndTask(int mode) {
this.mode = mode;
}

public Random getRandom() {
if (mode == 0) {
return rnd;
} else if (mode == 1) {
return tRnd.get();
} else {
return null;
}
}

@Override
public Long call() { //line:19
long b = System.currentTimeMillis();
for (long i = 0; i < GEN_COUNT; i++) {
getRandom().nextInt();
}
long e = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName() + " spend " + (e - b) + "ms");
return e - b;
}
}

上述代码第 19~27 行定义了线程的工作内容。每个线程产生若干随机数,完成后打印消耗时间。

最好是我们的 main 函数,它分别对上述两种工作模式进行测试,打印测试时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(String[] args) throws InterruptedException, ExecutionException {
Future<Long>[] futs = new Future[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
futs[i] = exe.submit(new RndTask(0));
}
long totaltime = 0;
for (int i = 0; i < THREAD_COUNT; i++) {
totaltime += futs[i].get();
}
System.out.println("多线程访问同一个Random实例:" + totaltime + "ms");

//ThreadLocal的情况
for (int i = 0; i < THREAD_COUNT; i++) {
futs[i] = exe.submit(new RndTask(1));
}
totaltime = 0;
for (int i = 0; i < THREAD_COUNT; i++) {
totaltime += futs[i].get();
}
System.out.println("使用ThreadLocal包装Random实例:" + totaltime + "ms");
exe.shutdown();
}

运行完成后,可以很明显发现,在多线程共享一个 Random 实例时,总耗时达到 13 秒之多(4个线程的耗时总和)。而在 ThreadLocal 模式下,仅仅耗时 1.7 秒左右。

4.4 无锁

我们都知道乐观锁和悲观锁。对于乐观锁,他们总认为问题是不容易发送的、出错是小概率,因此大胆做事,如果真的遇到问题则努力解决。而对于悲观锁,他们总是担惊受怕,认为出错是一种常态,所以无论大小事都面面俱到。
对于并发控制,锁是一种悲观的策略。它总是假设每次一次的临界区操作会产生冲突。如果有多个线程同时访问临界区资源,则宁可牺牲性能让线程等待。
而无锁是一种乐观的策略,它假设对资源的访问是没有冲突的。既然没有冲突,自然不需要等待,所有线程都可以在不停顿的状态下继续执行。
那遇到冲突怎么办?无锁的策略使用一种叫作比较交换(CAS,Compare And Sweep)的技术来鉴别线程冲突,一旦检测到线程冲突产生,就重试当前操作直到没有冲突为止。

4.4.1 与众不同的并发策略:CAS

与锁相比,使用比较交换(CAS,Compare And Sweep)会使程序看起来复杂一些,但由于其非阻塞性,它对死锁问题天生免疫,并且线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争的系统开销,也没有线程间频繁调度带来的开销,因此,它比基于锁的方式有更优越的性能。

CAS 算法的过程是:它包含三个参数 CAS(V,E,N),其中 V 表示要更新的变量,E 表示预期值,N 表示新值。仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 不同,说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。
CAS 操作是抱着乐观的态度进行的,它总认为自己可以成功完成操作。当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出并成功更新,其余均会失败。失败的线程不会挂起,仅是被告知失败,并且允许再次尝试(幂等性),当然也允许失败的线程放弃操作。
基于这样的原理,即使 CAS 操作没有锁,也可以发现其他线程对当前线程的干扰,并进行处理。
CAS 也是一个处理器指令,现代处理器都已支持原子化的 CAS 指令。

4.4.2 无锁的线程安全整数:AtomicInteger

为了让 Java 程序员能收益于 CAS 等 CPU 指令,JDK 并发包中有一个 atomic 包,里面实现了一些直接使用 CAS 操作的线程安全的类型。
其中,最常用的一个类就是 AtomicInteger,可以把它看作一个整数,与 Integer 不同,它是可变的(Integer是不可变的),并且是线程安全的。对其进行修改的任何操作都是用 CAS 指令进行的。
就内部实现上说,AtomicInteger 中保存了一个核心字段:

1
private volatile int value;

它就代表了 AtomicInteger 的当前实际取值,此外还有一个:

1
private static final long valueOffset;

它保存着 value 字段在 AtomicInteger 对象中的偏移量。这个偏移量是实现 AtomicInteger 的关键。
AtomicInteger 的使用非常简单,示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AtomicIntegerDemo {
static AtomicInteger i = new AtomicInteger();
public static class AddThread implements Runnable{
public void run(){
for(int k=0; k<10000; k++)
i.incrementAndGet();
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] ts = new Thread[10];
for(int k=0; k<10; k++){
ts[k] = new Thread(new AddThread());
}
for(int k=0; k<10; k++){ts[k].start();}
for(int k=0; k<10; k++){ts[k].join();}
System.out.println(i);
}
}

上述第 6 行的 AtomicInteger.incrementAndGet() 方法会使用 CAS 操作将自己加 1,同时也会返回当前值(这里忽略了当前值)。执行这段代码,会看到程序输出了 100000。这说明程序正常执行。如果不是线程安全,那么 i 的值应该会小于 100000 才对。

使用 AtomicInteger 会比使用锁具有更好的性能。再来分析一下 incrementAndGet() 方法的内部实现(基于JDK1.7)。

1
2
3
4
5
6
7
8
public final int incrementAndGet() {
for(;;) {
int current = get();
int next = current + 1;
if(compareAndSet(current, next))
return next;
}
}

值得注意的是 incrementAndGet() 内的 for 循环,这是一个死循环,因为 CAS 操作未必是成功的,因此对于不成功的情况,我们就需要不断的进行尝试。第 3 行 get() 取当前值,接着加 1 后得到新值 next。这里,我们就得到了 CAS 必需的两个参数:期望值及新值。使用 compareAndSet() 方法将新值 next 写入,成功的条件是写入的时刻,当前值要等于刚刚取得的 current。如果不相等,这说明 AtomicInteger 的值在第 3 行到第 5 行之间被其他线程修改了,当前线程看到的是过期状态。因此 compareAndSet() 返回失败,需要下一次重试,直到成功。

以上就是 CAS 操作的基本思想,无论程序多么复杂,基本原理总是不变的。
和 AtomicInteger 类似的类还有:AtomicLong、AtomicBoolean、AtomicReference(表示对象引用)。

4.4.3 Java中的指针:Unsafe

接着上节继续看一下 incrementAndGet() 方法中 compareAndSet() 方法的实现。

1
2
3
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

在上面代码中,有一个特殊的变量 unsafe,它是 sun.misc,Unsafe 类型。Unsafe 封装了一些不安全的操作,Java 相比 C/C++ 去除了指针,Unsafe 类就是封装了一些类似指针的操作。compareAndSwapInt() 方法是一个 native 方法,它的几个参数如下:

1
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

第一个参数 o 为给定的对象,offset 为对象内的偏移量(其实就是一个字段到对象头部的偏移量,可以快速定位字段),expected 表示期望值,x 表示要设置的值。如果指定的字段的值等于 expected,那么就把它设置为 x。可以看出,compareAndSwapInt() 方法的内部必然是使用 CAS 原子指令来完成的。
此外,Unsafe 类还提供了一些方法,主要有以下几种(以 int 操作为例,其他数据类型是类似的):

1
2
3
4
5
6
7
8
9
10
11
12
//获得给定对象偏移量上的int值
public native int getInt(Object o, long offset);
//设置给定对象偏移量上的int值
public native void putInt(Object o, long offset, int x);
//获得字段在对象中的偏移量
public native long objectFieldOffset(Field f);
//设置给定对象的int值,使用volatile语义
public native void putIntVolatile(Object o, long offset, int x);
//获得给定对象的int值,使用volatile语义
public native int getIntVolatile(Object o, long offset);
//和putIntVolatile()一样,但是它要求被操作字段就是volatile类型的
public natice void putOrderedInt(Object o, long offset, int x);

前面 3.3.4 章节介绍的 ConcurrentLinkedQueue 类中的 Node 的一些 CAS 操作也是使用 Unsafe 类实现的。

这里可以看到,虽然 Java 抛弃了指针,但是在关键时刻,类似指针的技术还是必不可少的,这里底层的 Unsafe 类实现就是最好的例子。
但是 Unsafe 类是一个在 JDK 内部使用的专属类,我自己的应用程序是无法直接使用 Unsafe 类的。
用来获得 Unsafe 类实例的方法:

1
2
3
4
5
6
public static Unsafe getUnsafe() {
Class cc = Reflection.getCallerClass();
if(cc.getClassLoader() != null)
throw new SecurityException("Unsafe");
return theUnsafe;
}

它的实现中,如果调用者类的 ClassLoader 不为 null,就直接抛出异常,拒绝工作。
根据类加载机制,应用程序的类由 App Loader 加载,而系统核心类(如rt.jar中的类)由 Boostrap 类加载器加载。Boostrap 类加载器没有 Java 对象的对象,因此视图获得 Boostrap 类加载器会返回 null。所以,当一个类加载器为 null 时,说明它是由 Boostrap 类加载器加载的,而这个类就极有可能是 rt.jar 中的类。

4.4.4 无锁的对象引用:AtomicReference

AtomicReference 和 AtomicInteger 非常相似,不同之处在于 AtomicInteger 是对整数的封装,而 AtomicReference 则是对应普通的对象引用。也就是它可以保证你在修改对象引用时的线程安全性。在介绍 AtomicReference 之前,我先看一个有关 CAS 原子操作逻辑上的不足。

CAS 操作时,线程判断被修改对象是否可以正确写入的条件,是对象的当前值和期望值是否一致。但有个特殊情况,就是当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而且两次修改后变为了原来的值。这样,当前线程就无法判断这个对象究竟是否被修改过。
一般来说,这种情况发生概率很小,即使发生有时也不是大问题,因为很多时候修改的对象没有过程状态信息,不管修改几次只要当前值与期望值一致就可以更新。
但是现实中,有时我们是否能修改对象的值,不仅取决于当前值,还和对象的过程变化有关,这时 AtomicReference 就无能为力了。

模拟这个场景,使用 AtomicReference 实现这个功能,模拟客户账户余额:

1
2
3
static AtomicReference<Integer> money=new AtomicReference<Integer>();
//设置账户初始值小于20,这是一个需要充值的账户
money.set(19);

接着,我们需要若干后台线程,它们不断扫描数据,并为满足条件的客户充值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//模拟多个线程同时更新后台数据库,为用户充值
for(int i = 0 ; i < 3 ; i++) {
new Thread() {
public void run() {
while(true){
while(true){
Integer m=money.get();
if(m<20){
if(money.compareAndSet(m, m+20)){
System.out.println("余额小于20元,充值成功,余额:"+money.get()+"元");
break;
}
}else{
//System.out.println("余额大于20元,无需充值");
break ;
}
}
}
}
}.start();
}

上述第 8 行,判断账户余额并充值20元,如果已经被其他用户处理,那么当前线程就会失败。因此,可以确保用户只会被充值一次。
如果充值的同时,客户进行了一次消费使得余额又小于20,并且正好消费了20元,使得消费后、充值后的余额等于消费前、充值前的余额,那么后台的充值进程就会误以为这个账户还没充值,所以,存在多次充值的可能。
模拟这个消费线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//用户消费线程,模拟消费行为
new Thread() {
public void run() {
for(int i=0;i<100;i++){
while(true){
Integer m=money.get();
if(m>10){
System.out.println("大于10元");
if(money.compareAndSet(m, m-10)){
System.out.println("成功消费10元,余额:"+money.get());
break;
}
}else{
System.out.println("没有足够的金额");
break;
}
}
try {Thread.sleep(100);} catch (InterruptedException e) {}
}
}
}.start();

执行后可以看到,账户被先后反复多次充值,原因正是余额被反复修改,修改后的值等于原有的数值,使得 CAS 操作无法正确判断当前数据的状态。这就是一个是否应该修改,需要判断对象的过程状态的案例。

使用 JDK 提供的 AtomicStampedReference 就可以很好解决这个问题。

4.4.5 带有时间戳的对象引用:AtomicStampedReference

AtomicReference 无法解决前一节提到的问题的根本原因是,对象在修改过程中丢失了状态信息,对象值本身与状态被画上了等号。因此,我们只需要记录对象在修改过程中的状态值,就能解决对象被反复修改导致线程无法正确判断对象状态的问题。
AtomicStampedReference 正是这么做的,它内部不仅维护了对象值,还维护了一个时间戳(实际上可以使用任何一个整数表示状态值)。当 AtomicStampedReference 对应的数值被修改时,除了更新数据本身外,还必须更新时间戳。当 AtomicStampedReference 设置对象值时,对象值及时间戳都必须满足期望值,写入才会成功。所以即使反复修改对象值,只要时间戳变化,就能防止错误的写入。

AtomicStampedReference 的操作:

1
2
3
//比较设置,参数依次为:期望值、写入新值、期望时间戳、新时间戳
public boolean compareAndSet(V expectedReference, V newReference,
int expectedStamp, int newStamp)

使用 AtomicStampedReference 修正前一节修改余额的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class AtomicStampedReferenceDemo {
static AtomicStampedReference<Integer> money=new AtomicStampedReference<Integer>(19,0);
public static void main(String[] args) {
//模拟多个线程同时更新后台数据库,为用户充值
for(int i = 0 ; i < 3 ; i++) {
final int timestamp=money.getStamp();
new Thread() {
public void run() {
while(true){
while(true){
Integer m=money.getReference();
if(m<20){
if(money.compareAndSet(m, m+20,timestamp,timestamp+1)){
System.out.println("余额小于20元,充值成功,余额:"+money.getReference()+"元");
break;
}
}else{
//System.out.println("余额大于20元,无需充值");
break ;
}
}
}
}
}.start();
}

//用户消费线程,模拟消费行为
new Thread() {
public void run() {
for(int i=0;i<100;i++){
while(true){
int timestamp=money.getStamp();
Integer m=money.getReference();
if(m>10){
System.out.println("大于10元");
if(money.compareAndSet(m, m-10,timestamp,timestamp+1)){
System.out.println("成功消费10元,余额:"+money.getReference());
break;
}
}else{
System.out.println("没有足够的金额");
break;
}
}
try {Thread.sleep(100);} catch (InterruptedException e) {}
}
}
}.start();
}
}

上述代码我们使用 AtomicStampedReference 代替原来的 AtomicReference,第 6 行获得账户的时间戳,后续的充值以这个时间戳为依据。如果充值成功(第13行),则修改时间戳。消费线程也是类似,每次操作都使时间戳加 1(第36行),使之不可能重复。

4.4.6 数组也能无锁:AtomicIntegerArray

除了提供基本数据类型以外,JDK 还为我们准备了数组等复合结构。当前可用的原子数组有:AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,分别表示整数数组、long型数组和普通对象数组。

以 AtomicIntegerArray 为例,展示原子数组的使用方式。
AtomicIntegerArray 本质是对 int[] 类型的封装,使用 Unsafe 类通过 CAS 方式控制 int[] 在多线程下的安全性。它提供了以下几个核心 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//获得数组第 i 个下标的元素
public final int get(int i)
//获得数组的长度
public final int length()
//将数组第 i 个下标设置为 newValue,并返回旧的值
public final int getAndSet(int i, int newValue)
//进行CAS操作,如果第i个下标的元素等于expect,则设置为update,设置成功返回true
public final boolean compareAndSet(int i, int expect, int update)
//将第i个下标的元素加1
public final int getAndIncrement(int i)
//将第i个下标的元素减1
public final int getAndDecrement(int i)
//将第i个下标的元素增加delta(delta可以是复数)
public final int getAndAdd(int i, int delta)

下面一个示例,展示 AtomicIntegerArray 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AtomicIntegerArrayDemo {
static AtomicIntegerArray arr = new AtomicIntegerArray(10);
public static class AddThread implements Runnable{
public void run(){
for(int k=0;k<10000;k++)
arr.getAndIncrement(k%arr.length());
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] ts=new Thread[10];
for(int k=0;k<10;k++){
ts[k]=new Thread(new AddThread());
}
for(int k=0;k<10;k++){ts[k].start();}
for(int k=0;k<10;k++){ts[k].join();}
System.out.println(arr);
}
}

上面代码第 2 行声明了一个内含 10 个元素的数组,第 3 行定义的线程对数组内 10 个元素进行累加操作,每个元素各加 1000 次。主函数中,开启 10 个这样的线程。因此可预测,如果线程安全,数组内 10 个元素的值必然都是 10000。反之,如果线程不安全,则部分或者全部数值会小于 10000。
输出程序结果,最终可以看到,AtomicIntegerArray 的确保证了数组的线程安全。

4.4.7 让普通变量也享受原子操作:AtomicIntegerFieldUpdater

有时,由于初期考虑不周或者后期需求变化,一些普通变量也会有线程安全的需求。如果改动不大,可以逐个修改程序中每一处使用这个变量的地方,但这样就违背了开闭原则。
JDK 提供的原子包里,有一个实用的工具类 AtomicIntegerFieldUpdater,很好的解决了这类问题。它可以让你在不改动或极少改动原有代码的基础上,让普通的变量也享受 CAS 操作带来的线程安全性。

根据数据类型不同,Updater 有三种,分别是 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater,分别可以对 int、long、普通对象进行 CAS 操作。

模拟一个场景:某地要进行一次选举,模拟这个投票场景,如果选民投了候选人一票,就记为 1,否则记为 0。最终的选票显然就是所有数据的简单求和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class AtomicIntegerFieldUpdaterDemo {
public static class Candidate{
int id;
volatile int score;
}
public final static AtomicIntegerFieldUpdater<Candidate> scoreUpdater
= AtomicIntegerFieldUpdater.newUpdater(Candidate.class, "score");
//检查Updater是否工作正确
public static AtomicInteger allScore=new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
final Candidate stu=new Candidate();
Thread[] t=new Thread[10000];
for(int i = 0 ; i < 10000 ; i++) {
t[i]=new Thread() {
public void run() {
if(Math.random()>0.4){
scoreUpdater.incrementAndGet(stu);
allScore.incrementAndGet();
}
}
};
t[i].start();
}
for(int i = 0 ; i < 10000 ; i++) { t[i].join();}
System.out.println("score="+stu.score);
System.out.println("allScore="+allScore);
}
}

上述代码模拟了计票场景,候选人的得票数记录在 Candidate.score 中,它只是一个普通的 volatile 变量,而 volatile 变量并不是线程安全的。然后定义了 AtomicIntegerFieldUpdater 实例,用来对 Candidate.score 进行写入。然后又定义了一个 AtomicInteger 实例 allScore,allScore 是用来检查 AtomicIntegerFieldUpdater 的正确性,如果 AtomicIntegerFieldUpdater 保证了线程安全,那么最终 Candidate.score 和 allScore 的值必然是相等的。否则,就说明 AtomicIntegerFieldUpdater 根本没有确保线程安全写入。
主函数中模拟了计票过程,这里假设大约 60% 的人投赞成票,并且投票随机进行。线程内使用 Updater 修改 Candidate.score,然后使用 AtomicInteger 计数,作为参考基准。
运行这段程序后,会发现最终 Candidate.score 和 allScore 的值都是相等的,这说明 AtomicIntegerFieldUpdater 很好的保证了 Candidate.score 的线程安全。

使用 AtomicIntegerFieldUpdater 有几个注意事项:

  • Updater 只能修改它可见范围内的变量,因为 Updater 会使用反射得到这个变量。如果变量不可见,就会出错。
  • 为了确保变量被正确读取,它必须是 volatile 的。
  • 由于 CAS 操作会通过对象实例中的偏移量直接进行赋值,因此,它不支持 static 字段。原因是 Unsafe.objectFieldOffset() 方法不支持静态变量。

通过 AtomicIntegerFieldUpdater,我们可以更加随心所欲的对系统关键数据进行线程安全的保护。

4.4.8 挑战无锁算法:无锁的 Vector 实现

相对于有锁的方法,无锁的编程方式更加考验一个程序员的耐心和智力。但是,无锁带来的好处也是显而易见的,第一,它拥有更好的并发性能,第二,它天生就免疫死锁。

本节介绍使用无锁的方式实现 Vector,通过此案例更加深刻理解无锁的算法。
这个无锁的 Vector 来自并发包,它就是 LockFreeVector。它的特点是可以根据需求动态扩展内部空间。这里,我们使用二维数组来表示 LockFreeVector 的内部存储。

1
private final AtomicReferenceArray<AtomicReferenceArray<E>> buckets;

buckets 变量存放所有内部元素,从定义上看,它是一个保存着数组的数组,也就是二维数组。使用二维数组是为了动态扩展更方便,可以方便的增加新元素。
此外,为了更有序的读写数组,定了一个称为 Descriptor 的元素,它的作用是使用 CAS 操作写入新数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static class Descriptor<E> {
public int size;
volatile WriteDescriptor<E> writeop;
public Descriptor(int size, WriteDescriptor<E> writeop) {
this.size = size;
this.writeop = writeop;
}
public void completeWrite() {
WriteDescriptor<E> tmpOp = writeop;
if (tmpOp != null) {
tmpOp.doIt();
writeop = null; // this is safe since all write to writeop use
// null as r_value.
}
}
}
static class WriteDescriptor<E> {
public E oldV;
public E newV;
public AtomicReferenceArray<E> addr;
public int addr_ind;
public WriteDescriptor(AtomicReferenceArray<E> addr, int addr_ind,
E oldV, E newV) {
this.addr = addr;
this.addr_ind = addr_ind;
this.oldV = oldV;
this.newV = newV;
}
public void doIt() {
addr.compareAndSet(addr_ind, oldV, newV);
}
}

上述代码 Descriptor 构造函数接收两个参数,第一个是整个 Vector 的长度,第二个是一个 writer。最终,写入数据是通过 writer 进行的。
WriteDescriptor 的构造函数接收四个参数,第一个参数 addr 是要修改的原子数组(一维数组),第二个参数是要写入的数组索引位置,第三个是期望值,第四个是需要写入的值。
在构造 LockFreeVector 时,需要先将 buckets 和 descriptor 初始化,以下是 LockFreeVector 构造函数:

1
2
3
4
5
6
public LockFreeVector() {
buckets = new AtomicReferenceArray<AtomicReferenceArray<E>>(N_BUCKET);
buckets.set(0, new AtomicReferenceArray<E>(FIRST_BUCKET_SIZE));
descriptor = new AtomicReference<Descriptor<E>>(new Descriptor<E>(0,
null));
}

LockFreeVector 构造函数中,N_BUCKET 的值为30,也就是 buckets 二维数组的第一维的 size 是30(存放30个一维数组),并且将第一个数组的大小初始化为8(FIRST_BUCKET_SIZE值为8)。一维的数组大小是动态翻倍增长的,8、16、32这样递增。
当有元素需要加入 LockFreeVector 时,会调用它的 push_back() 方法,将元素压入 Vector 最后一个位置。这个操作显然就是 LockFreeVector 最核心的方法,也是最能体现 CAS 特点的方法,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void push_back(E e) {
Descriptor<E> desc;
Descriptor<E> newd;
do {
desc = descriptor.get();
desc.completeWrite();

int pos = desc.size + FIRST_BUCKET_SIZE; //line:8
int zeroNumPos = Integer.numberOfLeadingZeros(pos);
int bucketInd = zeroNumFirst - zeroNumPos;
if (buckets.get(bucketInd) == null) {
int newLen = 2 * buckets.get(bucketInd - 1).length();
if (debug)
System.out.println("New Length is:" + newLen);
buckets.compareAndSet(bucketInd, null,
new AtomicReferenceArray<E>(newLen));
}

int idx = (0x80000000>>>zeroNumPos) ^ pos;
newd = new Descriptor<E>(desc.size + 1, new WriteDescriptor<E>(
buckets.get(bucketInd), idx, null, e));
} while (!descriptor.compareAndSet(desc, newd));
descriptor.get().completeWrite();
}

可以看到,这个方法的主体是一个 do-while 循环,用来不断尝试对 descripotr 的设置,通过 CAS 保证 descriptor 的一致性。最后使用 Descriptor.completeWrite() 方法将数据真正写入数组中,而具体写入的数据由创建的 WriteDescriptor 对象决定。
由于要将元素 e 压入 Vector,所以我们必须知道要把 e 放入哪个位置,即 e 在二维数组中的坐标。
第 8~10 行通过当前 Vector 的大小(desc.size),计算新的元素应该放入哪个一维数组,这里使用了位运算。第 11 行,判断这个数组是否存在,如果不存在则创建这个数组,大小为前一个数组的两倍。

下面再来看一下 get() 方法的实现:

1
2
3
4
5
6
7
public E get(int index) {
int pos = index + FIRST_BUCKET_SIZE;
int zeroNumPos = Integer.numberOfLeadingZeros(pos);
int bucketInd = zeroNumFirst - zeroNumPos;
int idx = (0x80000000>>>zeroNumPos) ^ pos;
return buckets.get(bucketInd).get(idx);
}

在 get() 的实现中,首先使用相同的算法获得所需元素的数组,以及数组中的索引下标。这里通过 buckets 定位到对应的元素即可。
对于 Vector 最重要的两个方法以及实现了,其他方法也类似。

4.4.9 让线程互相帮助:SynchronousQueue的实现

前面章节对线程池的介绍中,提到了一个特殊的等待队列 SynchronousQueue。SynchronousQueue 的容量为0,任何一个对 SynchronousQueue 的写需要等待一个对 SynchronousQueue 的读,反之亦然。因此,SynchronousQueue 与其说是一个队列,不如说是一个数据交换的通道。那 SynchronousQueue 是如何实现的呢?
SynchronousQueue 和无锁的操作脱离不了关系,实际上 SynchronousQueue 内部也大量使用了无锁工具。
对 SynchronousQueue 来说,它将 put() 和 take() 两种方法抽象合并成一个共同的方法 Transfer.transfer()。它的完整签名如下:

1
Object transfer(Object e, boolean timed, long nanos)

当参数 e 非空时,表示当前操作传递给一个消费者,如果为空,则表示当前操作需要请求一个数据。timed 参数决定是否存在 timeout 时间,nanos 决定了 timeout 的时长。如果返回值非空,则表示数据已经接受或者正常提供。如果为空,则表示失败(超时或中断)。
SynchronousQueue 内部会维护一个线程等待队列。等待队列中会保存等待线程及相关数据的信息。比如,生产者将数据放入 SynchronousQueue 时,如果没有消费者接收,那么数据本身和线程对象都会打包在队列中等待(因为SynchronousQueue容积为0,没有数据可以正常放入)。

Transfer.transfer() 函数的实现是 SynchronousQueue 的核心,它大体分为三个步骤:

  1. 如果等待队列为空,或者队列中节点的类型和本次操作是一致的,那么将当前操作压入队列等待。比如,等待队列中是读线程等待,本次操作也是读,因此这两个读都需要等待。进入等待队列的线程可能会被挂起,它们会等待一个“匹配”操作。
  2. 如果等待队列中的元素和本次操作是互补的(比如等待操作是读,本次操作是写),那么就插入一个“完成”状态的节点,并且让它“匹配”到一个等待节点上。接着弹出这两个节点,并且使得对应的两个线程继续执行。
  3. 如果线程发现等待队列的节点就是“完成”节点,那么帮助这个节点完成任务,其流程和步骤2是一致的。

在 SynchronousQueue 中,参与工作的所有线程不仅仅是竞争资源的关系,更重要的是,它们彼此之间还会互相帮助。在一个线程内部,可能会帮助其他线程完成它们的工作。这种模式可以更大程度减少饥饿的可能,提高系统并行度。

4.5 有关死锁的问题

死锁就是两个或者多个线程相互占用对方需要的资源,而都不进行释放,导致彼此之间相互等待对方释放资源,产生了无限等待的现象。死锁一旦发生,如果没有外力介入,这种等待将永远存在,从而对程序产生严重影响。

在实际环境中,遇到死锁,通常的表现就是相关的进程不再工作,并且 CPU 占用率为0(因为死锁的线程不占用CPU),不过这种表面现象只能用来猜测问题。想进一步确认,需要使用 JDK 提供的一套专业工具。
首先,我们可以使用 jps 命令得到 Java 进程的进程ID,接着使用 jstack 命令得到线程的线程堆栈。
jstack 命令输出的线程堆栈信息中,能看到 deadlock 字样,还能看到哪几个线程之间发生了死锁,并且还能看到相互等待的锁的ID。同时,死锁的线程均处于 BLOCK 状态。

如果想要避免死锁,除了使用无锁的函数之外,还有就是使用第3章介绍的重入锁。通过重入锁的中断和限时等待,可以有效避免死锁带来的问题。

5. 并行模式与算法

由于并行程序设计比串行程序设计复杂得多,因此需要了解一些常见的设计方法。本章将重点介绍一些有关并行的设计模式及算法。都是前人经验的总结,大家可以在熟知其思想和原理的基础上,再根据自己的需求进行扩展。

5.1 探讨单例模式

单例模式是一种对象创建模式,用于产生一个对象的具体实例,并且确保系统中一个类只产生一个实例。
在 Java 中这样的行为能带来两大好处:

  1. 对于频繁使用的对象,可以省略 new 操作花费的时间,这对于那些重量级对象而言,是一笔客观的系统开销。
  2. 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 的压力,缩短 GC 停顿。

严格来说,单例模式与并行没有直接的关系,讨论这个模式,是因为它实在太常见了,我们不可避免会在多线程环境中使用它。并且,系统中使用单例的地方可能非常频繁,因此我们需要一种高效的单例实现。
下面给出一个简单的单例实现:

1
2
3
4
5
6
7
8
9
public class Singleton {
private Singleton() {
System.out.println("Singleton is create");
}
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}

使用上述方式创建单例有几点需要注意。因为我们要保证系统中不会有人意外创建多余的示例,因此,需要把 Singleton 的构造函数设置为 private,避免该类被错误创建。
首先,instance 对象必须是 private 的,避免其意外被修改为错误的值。其次,因为工厂方法 getInstance() 必须是 static 的,因此对应的 instance 也必须是 static。

这个单例性能不错,getInstance() 方法只是简单返回 instance,没有任何锁操作,因此在并行程序中会有良好表现。
但是这种方式有一个明显不足就是 Singleton 构造函数,或者说 Singleton 实例在什么时候创建是不受控制的。对于静态 instance,它会在类第一次初始化的时候被创建。这个时刻并不一定是 getInstance() 方法第一次被调用的时候。 比如,如果单例是这样的:

1
2
3
4
5
6
7
8
9
10
public class Singleton {
public static int STATUS = 1;
private Singleton() {
System.out.println("Singleton is create");
}
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}

注意,这个单例还包含一个静态成员 STATUS。此时,在任何地方引用这个 STATUS 都会导致 instance 实例被创建(任何对Singleton方法或者字段的引用,都会导致类初始化,并创建instance实例,但是类初始化只有一次,因此instance实例只创建一次),比如:

1
System.out.println(Singleton.STATUS);

上面代码在 getInstance() 之前执行,会打印出:

1
2
Singleton is create
1

可以看到,即使系统没有要求创建单例,new Singleton() 也会被调用。

如果你想控制 instance 的创建时间,这种方式就不友善了。我们需要一种新的方法,一种支持延迟加载的策略,它只会在 instance 第一次使用时创建对象,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
public class LazySingleton {
private LazySingleton() {
System.out.println("LazySingleton is create");
}
private static LazySingleton instance = null;
public static synchronized LazySingleton getInstance() {
if (instance == null)
instance = new LazySingleton();
return instance;
}
}

这个 LazySingleton 的核心思想是:最初,我们不需要实例化 instance,而当 getInstance() 方法被第一次调用时,创建单例对象。为了防止多次创建,不得不使用 synchronized 关键字进行方法同步。这种方式的好处是,利用了延迟加载,只在真正需要时创建对象。但坏处也很明显,并发环境下加锁,竞争激烈的场合影响性能。
此外,还有一种称为双重检查模式的方法可以用于创建单例,但由于它是一种非常丑陋、复杂且不能保证正确性的方法,不推荐使用。

上面介绍的两种单例实现各有千秋,有没有一种方法可以结合二者的优势呢?答案是肯定的。

1
2
3
4
5
6
7
8
9
10
11
public class StaticSingleton {
private StaticSingleton(){
System.out.println("StaticSingleton is create");
}
private static class SingletonHolder {
private static StaticSingleton instance = new StaticSingleton();
}
public static StaticSingleton getInstance() {
return SingletonHolder.instance;
}
}

上述代码实现了一个单例,并且同时拥有前两种方式的优点。首先 getInstance() 方法中没有锁,并发环境下性能优越。其次,只有在 getInstance() 方法第一次被调用时,StaticSingleton 的实例才会被创建。因为这种方法巧妙的使用了内部类和类的初始化方式。
内部类 SingletonHolder 被声明为 private,这使得不能在外部访问它,而我们只能在 getInstance() 方法内部对 SingletonHolder 类进行初始化,利用虚拟机的类初始化机制创建单例。

5.2 不变模式

当多线程对同一个对象进行读写操作时,为了保证对象数据的一致性和正确性,有必要对对象进行同步,但是同步操作对系统性能有损耗。为了尽可能的去除同步操作,提高并行程序性能,可以使用一种不可改变的对象。依靠对象的不变性,可以确保其在没有同步操作的多线程环境中,依然保持内部状态的一致性和正确性。这就是不变模式。

不变模式天生就是多线程友好的,它的核心思想是,一个对象一旦被创建,它的内部状态将永远不会发生改变。没有线程可以修改其内部状态和数据,同时其内部状态也不会自行改变。基于这个特性,对不变对象的多线程操作不需要进行同步控制。

注意,不变模式只读模式是有区别的。不变模式比只读模式具有更强的一致性和不变性。只读的对象本身不能被其他线程修改,但是对象的自身状态却可能自行修改。

不变模式的主要使用场景需要满足以下两个条件:

  • 当对象创建后,其内部状态和数据不再发生任何变化。
  • 对象需要被共享,被多线程频繁访问。

在 Java 中实现不变模式很简单,只需要注意以下四点:

  • 去除 setter 方法及所有修改自身属性的方法。
  • 将所有属性设置为私有,并用 final 标记,确保其不可修改。
  • 确保没有子类可以重载修改它的行为。
  • 有一个可以创建完整对象的构造函数。

以下代码实现了一个不变的产品对象,它拥有三个属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Product {
private final String no;
private final String name;
private final double price;
public Product(String no, String name, double price) {
super();
this.no = no;
this.name = name;
this.price = price;
}
public String getNo() { return no; }
public String getName() { return name; }
public double getPrice() { return double; }
}

在不变模式的实现中,final 关键字起到了关键作用。对属性的 final 定义确保了所有数据只能在对象被构建时赋值 1 次。对 class 的 final 确保了不会有子类。根据里氏代换原则,子类可以完全替代父类。为了防止子类的意外行为,这里干脆把子类禁用了。
JDK 中,不变模式应用很广泛,最典型的就是不变类有 String,还有基本类型的包装类。

不变模式通过回避问题而不是解决问题的态度,来处理多线程并发访问控制,不变对象是不需要进行同步操作的。由于并发同步会对性能产生不良的影响,因此,在需求允许的情况下,不变模式可以提高系统的并发性能和并发量。

5.3 生产者-消费者模式

生产者-消费者模式是一个经典的多线程设计模式,它为多线程间的协作提供了良好的解决方案。生产者线程负责提交用户请求,消费者线程则负责具体处理生产者提交的任务。生产者和消费者之间通过共享内存缓冲区进行通信。
生产者线程将任务提交到共享内存缓冲区,消费者线程不直接与生产者线程通信,而是在共享内存缓冲区中获取任务,并进行处理。
生产者-消费者模式中的内存缓冲区,主要功能是数据在多线程间的共享。它作为两者的通信桥梁,将生产者和消费者进行解耦。此外,通过该缓冲区,可以缓解生产者和消费者间的性能差。

生产者-消费者模式的主要角色:

  • 生产者:用于提交用户请求,提取用户任务,并装入内存缓冲区。
  • 消费者:在内存缓冲区中提取并处理任务。
  • 内存缓冲区:缓存生产者提交的任务或数据,供消费者使用。
  • 任务:生产者向内存缓冲区提交的数据结构。
  • Main:使用生产者和消费者的客户端。

image

上图显示了生产者-消费者模式的一种实现的具体结构。其中,BlockingQueue 充当了共享内存缓冲区,用于维护任务或数据队列(PCData对象)。建议先回顾一下第 3.3.6 章节的 BlockingQueue 的介绍。PCData 对象表示一个生产任务,或者相关任务的数据。生产者和消费者对象均引用同一个 BlockingQueue 实例。生产者负责创建 PCData 对象,并将它加入 BlockingQueue 队列中,消费者则从 BlockingQueue 队列中获取 PCData 对象。
基于上图,实现一个生产者-消费者模式的求整数平方的并行程序。
首先,生产者线程的实现如下,它构建 PCData 对象,并放入 BlockingQueue 队列中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Producer implements Runnable {
private volatile boolean isRunning = true;
private BlockingQueue<PCData> queue; //内存缓冲区
private static AtomicInteger count = new AtomicInteger(); //总数,原子操作
private static final int SLEEPTIME = 1000;

public Producer(BlockingQueue<PCData> queue) {
this.queue = queue;
}
public void run() {
PCData data = null;
Random r = new Random();

System.out.println("start producer id="+Thread.currentThread().getId());
try {
while (isRunning) {
Thread.sleep(r.nextInt(SLEEPTIME));
data = new PCData(count.incrementAndGet()); //构造任务数据
System.out.println(data+" is put into queue");
if (!queue.offer(data, 2, TimeUnit.SECONDS)) { //提交数据到缓冲区
System.err.println("failed to put data:" + data);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
public void stop() {
isRunning = false;
}
}

对应的消费者实现如下,它从 BlockingQueue 队列中取出 PCData 对象,并进行计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Consumer implements Runnable {
private BlockingQueue<PCData> queue; //缓冲区
private static final int SLEEPTIME = 1000;

public Consumer(BlockingQueue<PCData> queue) {
this.queue = queue;
}
public void run() {
System.out.println("start Consumer id="
+ Thread.currentThread().getId());
Random r = new Random(); //随机等待时间

try {
while(true){
PCData data = queue.take(); //提取任务
if (null != data) {
int re = data.getData() * data.getData(); //计算平方
System.out.println(MessageFormat.format("{0}*{1}={2}",
data.getData(), data.getData(), re));
Thread.sleep(r.nextInt(SLEEPTIME));
}
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}

PCData 对象作为生产者和消费者之间的共享数据模型,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final class PCData {
private final int intData;
public PCData(int d){
intData=d;
}
public PCData(String d){
intData=Integer.valueOf(d);
}
public int getData(){
return intData;
}
@Override
public String toString(){
return "data:"+intData;
}
}

在主函数中,创建三个生产者和三个消费者,并让它们协作运行。在主函数的实现中,定义 LinkedBlockingQueue 作为 BlockingQueue 队列的实现类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class PCMain {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<PCData> queue = new LinkedBlockingQueue<PCData>(10);
Producer producer1 = new Producer(queue);
Producer producer2 = new Producer(queue);
Producer producer3 = new Producer(queue);
Consumer consumer1 = new Consumer(queue);
Consumer consumer2 = new Consumer(queue);
Consumer consumer3 = new Consumer(queue);
ExecutorService service = Executors.newCachedThreadPool();
service.execute(producer1);
service.execute(producer2);
service.execute(producer3);
service.execute(consumer1);
service.execute(consumer2);
service.execute(consumer3);
Thread.sleep(10 * 1000);
producer1.stop();
producer2.stop();
producer3.stop();
Thread.sleep(3000);
service.shutdown();
}
}

生产者-消费者模式很好的对生产者进程、消费者进程进行解耦,优化了系统整体结构。同时,由于缓冲区的作用,允许生产者和消费者存在执行性能上的差异,从一定程度上缓解了性能瓶颈对系统性能的影响。

5.4 高性能生产者-消费者:无锁的实现

BlockingQueue 虽然能用来实现生产者-消费者,它可以作为生产者和消费者线程之间的内存缓冲区。但 BlockingQueue 不是一个高性能的实现,它使用锁和阻塞等待来实现线程间的同步。前面介绍过的 ConcurrentLinkedQueue 是一个高性能的队列,但 BlockingQueue 队列只是为了方便数据共享

ConcurrentLinkedQueue 队列实现高性能的秘诀就在于无锁的 CAS 操作。如果我们使用 CAS 来实现生产者-消费者模式,也会有可观的性能提升。使用 CAS 会提高编程的复杂度,但目前有一个现成的 Disruptor 框架,它已经帮助我们实现了这一功能。

5.4.1 无锁的缓存框架:Disruptor

Disruptor 是由 LMAX 公司开发的一款高效的无锁内存队列。它使用无锁的方式实现了一个环形队列(RingBuffer),非常适合生产者-消费者模式,比如事件和消息的发布。Disruptor 使用环形队列代替了普通线形队列,这个环形队列内部实现是一个普通的数组
对于一般队列,需要同步队列的两个指针 head 和 tail,用于出队和入队,这样就增加了线程协作的复杂度。但如果是环形队列,则只需要对外提供一个当前位置 cursor,利用这个指针既可以进行入队操作,也可以进行出队操作。
由于环形队列的总大小必须事先指定,不能动弹扩展。为了能够快速从一个序列(sequence)对应到数组的实际位置(每次有元素入队,序列就加1),Disruptor 框架要求必须将数组大小设为 2 的整数次方,这样通过 sequence & (queueSize-1)位运算就能立即定位到实际的元素位置 index,这比取余(%)快得多。

sequence & (queueSize-1)位运算简单说明一下,如果 queueSize 是 2 的整数次幂,则这个数字的二进制表示必然是 10、100、1000、10000 等形式。因此,queueSize-1 的二进制则是一个全 1 的数字,因此它可以将 sequence 限定在 queueSize-1 范围内,并且不会有任何一位是浪费的。

生产者向这个环形队列缓冲区中写入数据,而消费者从中读取数据。并且,生产者的写入和消费者的读取,都使用 CAS 操作进行数据保护。
这种固定大小的环形队列的另一个好处,就是可以做到完全的内存复用。在系统的运行过程中,不会有新的空间需要分配或者老的空间需要回收。

5.4.2 用 Disruptor 框架实现生产者-消费者的案例

本节演示一下 Disruptor 框架的基本使用和 API,使用的是 disruptor-3.3.2 版本。
这里,我们的生产者不断产生整数,消费者读取生产者数据,并计算其平方。
首先,需要一个代表数据的 PCData 对象。

1
2
3
4
5
6
7
8
9
public class PCData {
private long value;
public void set(long value) {
this.value = value;
}
public long get() {
return value;
}
}

消费者实现 WorkHandler 接口,它来自 Disruotor 框架。

1
2
3
4
5
6
7
public class implements WorkHandler<PCData> {
@Override
public void onEvent(PCData event) throws Exception {
System.out.println(Thread.currentThread().getId()+":Event: --"
+ event.get() * event.get() + "--");
}
}

消费者作用是读取数据进行处理。这里,数据的读取已经由 Disruptor 框架进行封装了,onEvent() 方法为 Disruptor 框架的回调方法。因此,这里只需要简单的进行数据处理即可。
还需要一个产生 PCData 对象的工厂类,它会在 Disruptor 框架系统初始化时,构造所有缓冲区中的对象实例,Disruptor 框架会预先分配空间。

1
2
3
4
5
public class PCDataFactory implements EventFactory<PCData> {
public PCData newInstance() {
return new PCData();
}
}

生产者的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Producer {
private final RingBuffer<PCData> ringBuffer;
public Producer(RingBuffer<PCData> ringBuffer) {
this.ringBuffer = ringBuffer;
}
public void pushData(ByteBuffer bb) {
long sequence = ringBuffer.next(); // Grab the next sequence
try {
PCData event = ringBuffer.get(sequence); // Get the entry in the Disruptor
// for the sequence
event.set(bb.getLong(0)); // Fill with data
} finally {
ringBuffer.publish(sequence);
}
}
}

生产者需要一个 RingBuffer 的引用,也就是环形缓冲区。它有一个重要方法 pushData() 将产生的数据推入缓冲区,pushData() 方法接收一个 ByteBuffer 对象。在 ByteBuffer 对象中可以用来包装任何数据类型。pushData() 方法的功能就是将传入 ByteBuffer 对象中的数据提取出来,并装载到环形缓冲区中。
上述代码,pushData() 方法中,首先通过 next() 得到下一个可用的序列号,通过序列号,取得下一个空闲可用的 PCData 对象,并且将 PCData 对象的数据设为期望值,这个值最终会传递给消费者。最后,publish() 进行数据发布,只有发布后的数据才会被消费者看见。

主函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static void main(String[] args) throws Exception {
Executor executor = Executors.newCachedThreadPool();
PCDataFactory factory = new PCDataFactory();
// Specify the size of the ring buffer, must be power of 2.
int bufferSize = 1024;
Disruptor<PCData> disruptor = new Disruptor<PCData>(factory,
bufferSize,
executor,
ProducerType.MULTI,
new BlockingWaitStrategy()
);
disruptor.handleEventsWithWorkerPool(
new Consumer(),
new Consumer(),
new Consumer(),
new Consumer());
disruptor.start();

RingBuffer<PCData> ringBuffer = disruptor.getRingBuffer();
Producer producer = new Producer(ringBuffer);
ByteBuffer bb = ByteBuffer.allocate(8);
for (long l = 0; true; l++) {
bb.putLong(0, l);
producer.pushData(bb);
Thread.sleep(100);
System.out.println("add data "+l);
}
}

上述代码,第 5 行设置缓冲区大小为 1024,2 的整数次幂。然后创建了 disruptor 对象,它封装了整个 Disruptor 库的使用,提供API。接着设置了用于处理数据的消费者,设置 4 个消费者实例。系统会把每一个消费者实例映射到一个线程中,也就是这里会有 4 个消费者线程。然后就启动并初始化 disruptor 系统。最后 for 循环中生产者不断向缓冲区存入数据。
程序执行后输出:

1
2
3
4
5
6
7
8
8:Event: --0--
add data 0
11:Event: --1--
add data 1
10:Event: --4--
add data 2
9:Event: --9--
add data 3

生产者和消费者正常工作。Disruptor 框架的性能要比 BlockingQueue 队列至少高一个数量级,值得尝试。

5.4.3 提高消费者的响应时间:选择合适的策略

当有新的数据在 Disruptor 框架的环形缓冲区中产生时,消费者如何知道这些新产生的数据呢?或者,消费者如何监控缓冲区中的信息?
为此,Disruptor 框架提供了几种策略,这些策略由 WaitStrategy 接口封装,主要有以下几种:

  • BlockingWaitStrategy:默认策略,和 BlockingQueue 相似,它们都使用锁和条件(Condition)进行数据监控和线程唤醒。因为涉及线程切换,BlockingWaitStrategy 策略最节省 CPU,但是它的性能表现最差。

  • SleepingWaitStrategy:这个策略对 CPU 的消耗与 BlockingWaitStrategy 类似。它会在循环中不断等待数据,它先进行自旋等待,如果不成功,则使用 Thread.yield() 方法让出 CPU,并最终使用 LockSupport.parkNanos(1) 进行线程休眠,以确保不占用太多 CPU。因此,这个策略对于数据处理可能会产生比较高的平均延时。它适合对延时要求不高的场合,好处是它对生产者线程的影响最小。典型应用场景是异步日志。

  • YieldingWaitStrategy:这个策略用于低延时场合。消费者线程会不断循环监控缓冲区的变化,在循环内部,它会使用 Thread.yield() 方法让出 CPU 给别的线程执行时间。如果需要高性能系统,并且对延时有严格要求,可以考虑这种策略。使用这种策略时,相当于消费者线程变成了一个内部执行了 Thread.yield() 方法的死循环。因此,你最好有多于消费者线程数量的逻辑 CPU 数量(逻辑CPU指“双核四线程”中的四线程)。

  • BusySpinWaitStrategy:这个是最疯狂的等待策略。它就是一个死循环!消费者线程会尽最大努力疯狂监控缓冲区的变化,因此,它会吃掉所有 CPU 资源。只有对延迟非常苛刻的场合需要考虑使用它。因为使用它,相当于开启了一个死循环监控,所以你的物理 CPU 数量必须要大于消费者线程数。这里说的是物理 CPU,不包括超线程等虚拟技术模拟的逻辑核。

5.4.4 CPU Cache 的优化:解决伪共享问题

除了使用 CAS 和提供各种不同的等待策略,来提高系统吞吐量之外,Disruptor 框架大有将优化进行到底的气势,它甚至尝试解决 CPU 缓存的伪共享问题。
什么是伪共享问题?我们知道,CPU 中有一个高速缓存 Cache,在高速缓存中,读写数据最小单位为缓存行(Cache Line),它是从主存(Memory)复制到缓存(Cache)的最小单位,一般为 32 字节到 128 字节。
当两个变量存放在一个缓存行时,在多线程访问下,可能会影响彼此的性能。

假设 CPU1 更新 X,CPU2 更新 Y,变量 X 和 Y 在同一缓存行,运行在 CPU1 上的线程更新了变量 X,那么 CPU2 上的缓存行就会失效,同一行的变量 Y 即使没有修改也会变成无效,导致 Cache 无法命中。接着,如果在 CPU2 上的线程更新了变量 Y,而导致 CPU1 上的缓存行失效。这种情况反复发生,严重影响性能,如果 CPU 经常不能命中缓存,系统吞吐量就会急剧下降。

为了避免这种情况,一种可行的做法就是在变量 X 的前后空间都先占据一定位置(把它叫做padding,用来填充)。这样,当内存被读入缓存时,这个缓存行中,只有 X 一个变量是实际有效的,因此就不会发生多个线程同时修改不同变量导致缓存行失效的情况。

在 Disruptor 框架中,它的核心组件 Sequence 会频繁访问(每次入队,它都会加1),其基本结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
class LhsPadding {
protected long p1,p2,p3,p4,p5,p6,p7;
}
class Value extends LhsPadding {
protected volatile long value;
}
class RhsPadding extends Value {
protected long p9,p10,p11,p12,p13,p14,p15;
}
public class Sequence extends RhsPadding {
//省略实现
}

虽然在 Sequence 中,主要使用的只有 value,但是通过 LhsPadding 和 RhsPadding 在这个 value 的前后安置了一些占位空间,使得 value 可以无冲突的存在于缓存中。
此外,对于 Disruptor 框架的环形缓冲区 RingBuffer,它内部的数组是通过以下语句构造的:

1
this.entries = new Object[sequencer.getBufferSize() + 2 * BUFFER_PAD];

实际产生的数组大小是缓冲区实际大小再加上两倍的 BUFFER_PAD。这就相当于在这个数组的头部和尾部各增加了 BUFFER_PAD 个填充,使得整个数组被载入 Cache 时不会因为受到其他变量的影响而失效。

5.5 Future 模式

Future 模式是多线程开发中非常常见的一种设计模式,它的核心思想是异步调用。当我们调用一个函数方法时,如果执行很慢我们需要等待,但有时并不急着要结果,因此,我们可以让被调者立即返回,让它在后台慢慢处理这个请求。对于调用者来说,则可以先处理一些其他任务,在真正需要数据的场合再去尝试获得数据。
对于 Future 模式来说,虽然它无法立即给出你需要的数据,但是它会返回一个契约给你,将来你可以凭借这个契约去重新获取你需要的数据。

下图显示了通过传统的同步方法调用一段比较耗时的程序,客户端发出 call 请求,这请求经过相当长的才能返回,客户端一直等待,直到数据返回再进行其他任务的处理。

image

使用 Future 模式替换原来的实现方式,可以改进其调用过程,如下图:

image

上图展示了 Future 模式的实现,从 Data Future 对象可以看到,虽然 call 本身仍然需要很长时间,但是,服务程序不等数据处理完成,便立即返回给客户端一个伪造的数据(相当于商品的订单,而不是商品本身),实现了 Future 模式的客户端在拿到这个返回结果后,不急于处理,而是去调用了其他业务逻辑,充分利用了等待时间,这就是 Future 模式核心所在。在完成了其他业务逻辑处理后,再使用返回比较慢的 Future 数据。
在整个调用过程中,不存在无谓的等待,充分利用了所有的时间片段,从而提高了系统的响应速度。

5.5.1 Future 模式的主要角色

为了更清晰的认识 Future 模式基本结构,这里给出一个简单的 Future 模式的实现,它的主要参与者有:

  • Main:系统启动,调用 Client 发出请求
  • Clent:返回 Data 对象,立即返回 FutureData,并开启 ClientThread 线程装配 RealData
  • Data:返回数据的接口
  • FutureData:Future 数据构造很快,但是它是一个虚拟的数据,需要装配 RealData
  • RealData:真实数据,构造比较慢

它的核心结构如图:

image

5.5.2 Future 模式的简单实现

在这个实现中,有个核心接口 Data,这就是客户端需要获取的数据。在 Future 模式中,Data 有两个重要实现,一个是 RealData,这是最终要获得的真实数据。另外一个是 FutureData,它是用来提取 RealData 的一个契约,因此 FutureData 可以立即返回。
下面是 Data 接口:

1
2
3
public interface Data {
public String getResult();
}

FutureData 实现了一个快速返回的 RealData 包装。它只是一个包装,或者说 RealData 的虚拟实现。因此,它可以快速构造并返回。当使用 FutureData 的 getResult() 方法时,如果实际的数据没有准备好,那么程序就会阻塞,等 RealData 准备好并注入 FutureData 中才最终返回数据。FutureData 是 Future 模式的关键,它是真实数据 RealData 的代理,封装了获取 RealData 的等待过程。

Future 模式在调用过程中不会阻塞线程,但获取结果还是有可能阻塞线程。Guava 增强了 Future 模式,使得 Future 完成时可以自动通知应用程序。另外,Netty 提供的 Promise 的实现是一种 Callback 模式,不会阻塞线程,从而更高效。Guava 实际上也是利用了 Callback 的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FutureData implements Data {
protected RealData realdata = null; //FutureData是RealData的包装
protected boolean isReady = false;
public synchronized void setRealData(RealData realdata) {
if (isReady) {
return;
}
this.realdata = realdata;
isReady = true;
notifyAll(); //RealData已经被注入,通知getResult()方法
}
public synchronized String getResult() { //等待RealData构造完成
while (!isReady) {
try {
wait(); //一直等待,直到RealData被注入
} catch (InterruptedException e) {
}
}
return realdata.result; //由RealData实现
}
}

RealData 是最终需要使用的数据,它的构造很慢,用 sleep() 模拟这个过程,简单模拟一个字符串的构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RealData implements Data {
protected final String result;
public RealData(String para) {
//RealData的构造可能很慢,需要用户等待很久
StringBuffer sb=new StringBuffer();
for (int i = 0; i < 10; i++) {
sb.append(para);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
result=sb.toString();
}
public String getResult() {
return result;
}
}

接下来是客户端程序,Client 实现了获取 FutureData,开启构造 RealData 的线程,并在接受请求后,立即返回 FutureData,即使 FutureData 内还没有真实数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public Data request(final String queryStr) {
final FutureData future = new FutureData();
// RealData的构建很慢,所以在单独的线程中进行
new Thread() {
public void run() {
RealData realdata = new RealData(queryStr);
future.setRealData(realdata);
}
}.start();
return future; //FutureData被立即返回,即使RealData还没准备好
}
}

最后,主函数 Main,它调用 Client 发起请求,并消费返回的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
public static void main(String[] args) {
Client client = new Client();
//这里会立即返回,因为得到的是FutureData而不是RealData
Data data = client.request("a");
System.out.println("请求完毕");
try {
//这里可以用一个sleep代替了对其它业务逻辑的处理
Thread.sleep(2000);
} catch (InterruptedException e) {
}
//使用真实的数据
System.out.println("数据 = " + data.getResult());
}
}

5.5.3 JDK 中的 Future 模式

Future 模式很常用,JDK 内部已经准备了一套完整的实现。本节简单介绍 JDK 中的 Future。

首先,JDK 中的 Future 模式基本结构如下图,其中 Future 接口类似于前文描述的订单或者契约。通过它可以得到真实的数据。RunnableFuture 继承了 Future 和 Runnable 两个接口,其中 run() 方法用于构造真实的数据。它有一个具体的实现 FutureTask 类。FutureTask 类有一个内部类 Sync,一些实质性工作委托 Sync 类实现。而 Sync 类最终会调用 Callable 接口,完成实际数据的组装工作。

image

Callable 接口只有一个方法 call(),它会返回需要构造的实际数据。要实现自己的业务系统,通常需要实现自己的 Callable 对象。此外,FutureTask 类也与应用密切相关,通常可以用 Callable 实例构造一个 FutureTask 实例,并将它提交给线程池。
下面展示这个内置的 Future 模式的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RealData implements Callable<String> {
private String para;
public RealData(String para){
this.para=para;
}
@Override
public String call() throws Exception {
StringBuffer sb=new StringBuffer();
for (int i = 0; i < 10; i++) {
sb.append(para);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
return sb.toString();
}
}

上述代码实现了 Callable 接口,它的 call() 方法会构造我们需要的真实数据并返回。使用 Thread.sleep() 模拟这个缓慢的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FutureMain {
public static void main(String[] args) throws InterruptedException, ExecutionException {
//构造FutureTask
FutureTask<String> future = new FutureTask<String>(new RealData("a"));
ExecutorService executor = Executors.newFixedThreadPool(1);
//执行FutureTask,相当于上例中的 client.request("a") 发送请求
//在这里开启线程进行RealData的call()执行
executor.submit(future);

System.out.println("请求完毕");
try {
//这里依然可以做额外的数据操作,这里使用sleep代替其他业务逻辑的处理
Thread.sleep(2000);
} catch (InterruptedException e) {
}
//相当于上例中得data.getContent(),取得call()方法的返回值
//如果此时call()方法没有执行完成,则依然会等待
System.out.println("数据 = " + future.get());
}
}

上述代码就是使用 JDK 自带 Future 的典型。首先,构造了 FutureTask 对象实例,表示这个任务是有返回值的。构造 FutureTask 时,使用 Callable 接口告诉 FutureTask 我们需要的数据该如何产生。然后,将 FutureTask 提交给线程池,这里不会阻塞,立即返回。接下来,我们不用关心数据如何产生的,可以去做其他事情,当需要时再通过 Future.get() 方法得到实际的数据。

除基本功能外,JDK 还为 Future 接口提供了一些简单的控制功能。

1
2
3
4
5
boolean cancel(boolean mayInterruptIfRunning);          //取消任务
boolean isCancelled(); //是否已取消
boolean isDone(); //是否已完成
V get() throws InterruptedException, ExecutionException;//取得返回对象
V get(long timeout, TimeUnit unit); //可设置超时时间

5.5.4 Guava 对 Future 模式的支持

JDK 自带的 Future 模式中,虽然可以使用 Future.get() 方法得到 Future 的处理结果,但是这个方法是阻塞的,因此不利于开发高并发应用。但在 Guava 中,增强了 Future 模式,增加了对 Future 模式完成时的回调接口,使得 Future 完成时可以自动通知应用程序进行后续处理。

使用 Guava 改写上一节的 FutureMain 可以得到更好的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FutrueDemo {
public static void main(String args[]) throws InterruptedException {
ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10));

ListenableFuture<String> task = service.submit(new RealData("x"));

task.addListener(() -> { //添加回调函数
System.out.print("异步处理成功:");
try {
System.out.println(task.get());
} catch (Exception e) {
e.printStackTrace();
}
}, MoreExecutors.directExecutor());

System.out.println("main task done.....");
Thread.sleep(3000);
}
}

上述代码中,首先使用 MoreExecutors.listeningDecorator() 方法将一个普通的线程池包装为一个包含通知功能的 Future 线程池。将 Callable 任务提交给线程池,并得到一个 ListenableFuture。与 Future 相比,ListenableFuture 拥有完成时的通知功能。第 7 行向 ListenableFuture 中添加回调函数,当 Future 执行完成后,则执行第 8 到 14 行的回调代码。
执行上述代码,得到:

1
2
main task done.....
异步处理成功:xxxxxxxxxx

可以看到,Future 的执行没有阻塞主线程,主线程很快正常结束。而当 Future 执行完成后,自动回调了第 8~14 行的业务代码,整个过程没有阻塞,可以很好的提升系统的并行度。

更一般的代码如下,增加了异常处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FutrueDemo2 {
public static void main(String args[]) throws InterruptedException {
ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10));
ListenableFuture<String> task = service.submit(new RealData("x"));

Futures.addCallback(task, new FutureCallback<String>() {
public void onSuccess(String o) {
System.out.println("异步处理成功,result=" + o);
}

public void onFailure(Throwable throwable) {
System.out.println("异步处理失败,e=" + throwable);
}
}, MoreExecutors.newDirectExecutorService());

System.out.println("main task done.....");
Thread.sleep(3000);
}
}

上述代码使用 Futures 工具类将 FutureCallback 接口注册到给定的 Future 中,从而增加了对 Future 的异常处理。

5.6 并行流水线

并发虽然能充分发挥多核 CPU 的性能,但并非所有的计算都可以改造成并发的形式。执行过程中,有数据相关性的运算都是无法完美并行化的。

假如计算 (B+C)B/2,这个运算就是无法并行的,因为如果 B+C 没有执行完成,则永远算不出 (B+C)B,这就是数据相关性。如果线程执行时,所需数据存在这种依赖关系,那么就没有办法将它们完美的并行化。
利用流水线思想来优化,即使 (B+C)*B/2 无法并行,但是如果需要计算一大堆 B 和 C 的值,依然可以将它流水化。
首先将计算过程拆分为三个步骤:

  • P1: A=B+C
  • P2: D=A*B
  • P3: D=D/2

上述步骤中的 P1、P2、P3 均在单独线程中计算,并且每个线程只负责自己的工作。此时,P3 的计算结果就是最终需要的答案。
P1 接收 B 和 C 的值并求和,将结果输入 P2。P2 求乘积后输入给 P3。P3 将 D 除以 2 得到最终值。一旦这条流水线建立,只需要一个计算步骤就可以得到 (B+C)*B/2 的结果。

为了实现这个功能,需要定义一个在线程间携带结果进行信息交换的载体:

1
2
3
4
5
public class Msg {
public double i;
public double j;
public String orgStr=null;
}

P1 计算加法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Plus implements Runnable {
public static BlockingQueue<Msg> bq=new LinkedBlockingQueue<Msg>();
@Override
public void run() {
while(true){
try {
Msg msg = bq.take();
msg.j = msg.i + msg.j;
Multiply.bq.add(msg);
} catch (InterruptedException e) {
}
}
}
}

上述代码中,P1 取得封装了两个操作数的 Msg,并进行求和,将结果传递给乘法线程 P2。当没有数据时,P1 进行等待。
P2 计算乘法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Multiply implements Runnable {
public static BlockingQueue<Msg> bq = new LinkedBlockingQueue<Msg>();
@Override
public void run() {
while (true) {
try {
Msg msg = bq.take();
msg.i = msg.i * msg.j;
Div.bq.add(msg);
} catch (InterruptedException e) {
}
}
}
}

和 P1 类似,P2 计算相乘结果后,将中间结果传递给除法线程 P3。
P3 计算除法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Div implements Runnable {
public static BlockingQueue<Msg> bq = new LinkedBlockingQueue<Msg>();
@Override
public void run() {
while (true) {
try {
Msg msg = bq.take();
msg.i = msg.i / 2;
System.out.println(msg.orgStr + "=" + msg.i);
} catch (InterruptedException e) {
}
}
}
}

P3 收到 P2 的结果后除以 2,输出最终结果。
最后是提交任务的主线程,我们提交100万个请求,让线程组进行计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PStreamMain {
public static void main(String[] args) {
new Thread(new Plus()).start();
new Thread(new Multiply()).start();
new Thread(new Div()).start();

for (int i = 1; i <= 1000; i++) {
for (int j = 1; j <= 1000; j++) {
Msg msg = new Msg();
msg.i = i;
msg.j = j;
msg.orgStr = "((" + i + "+" + j + ")*" + i + ")/2";
Plus.bq.add(msg);
}
}
}
}

主函数中,将数据提交给 P1 加法进程,开启流水线计算。在多核或者分布式场景中,这种设计思路可以有效的将有依赖关系的操作分配在不同的线程中进行计算,尽可能利用多核优势。

5.7 并行搜索

搜索几乎是每个软件必不可少的功能。对于有序数据,可以使用二分查找,对于无序数据,则只能逐个查找。本节讨论并行下的无序数组的搜索实现。

给定一个数组,需要查找满足条件的元素。串行程序只需要遍历数组。但并行方式中,则需要额外增加线程间的通信机制,是各个线程有效运行。
简单的策略是将原始数据集合按照线程数进行分割。如果我们计划使用两个线程进行搜索,那么可以把一个数组分割成两个。每个线程各自独立搜索,当有一个线程找到数据后,立即返回结果。

现在假设有一个整型数组,我们需要查找数组内的元素:

1
static int[] arr;

定义线程池、线程数量、结果变量 result,result 会保存符合条件元素在 arr 数组中的下标,默认为 -1,表示没有找到指定元素。

1
2
3
static ExecutorService pool = Executors.newCachedThreadPool();
static final int Thread_Num = 2;
static AtomicInteger result = new AtomicInteger(-1);

并行搜索要求每个线程查找 arr 中的一段,因此,搜索函数必须指定线程需要搜索的起始和结束位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static int search(int searchValue,int beginPos,int endPos){
int i=0;
for(i=beginPos;i<endPos;i++){
if(result.get()>=0){ //line:4
return result.get();
}
if(arr[i] == searchValue){ //line:7
//如果设置失败,表示其它线程已经先找到了
if(!result.compareAndSet(-1, i)){
return result.get();
}
return i;
}
}
return -1;
}

上述代码第 4 行,首先通过 result 判断是否已经有其他线程找到了需要的结果。如果已找到,则立即返回不再进行查找。如果没有,则进行下一步搜索。第 7 行代码成立,则表示当前线程找到了需要的数据,那么就将结果保存到变量 result 中。这里使用了 CAS 操作,result 是 AtomicInteger 原子类型。如果 result 设置失败,则表示其他线程已经先一步找到了结果,则直接返回结果。

定义一个线程进行查找,它会调用前面的 search() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
public static class SearchTask implements Callable<Integer>{
int begin,end,searchValue;
public SearchTask(int searchValue,int begin,int end){
this.begin=begin;
this.end=end;
this.searchValue=searchValue;
}
public Integer call(){
int re= search(searchValue,begin,end);
return re;
}
}

最后是 pSearch() 方法并行查找函数,它会根据线程数量对 arr 数组进行划分,并建立对应的任务提交给 线程池处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static int pSearch(int searchValue) throws InterruptedException, ExecutionException{
int subArrSize = arr.length/Thread_Num+1;
List<Future<Integer>> re = new ArrayList<Future<Integer>>();
for(int i=0; i<arr.length; i+=subArrSize){
int end = i+subArrSize;
if(end >= arr.length) end = arr.length;
re.add(pool.submit(new SearchTask(searchValue,i,end)));
}
for(Future<Integer> fu:re){
if(fu.get() >= 0) return fu.get();
}
return -1;
}

上述代码中使用了 JDK 内置的 Future 模式,其中第 4~8 行将原始数组 arr 划分为若干段,并根据划分结果建立子任务。每一个子任务都会返回一个 Future 对象,通过 Future 对象可以获得线程组得到的最终结果。在这里,由于线程之间会通过 result 共享信息,只要一个线程成功返回,其他线程都会立即返回。因此,不会出现由于排在前面的任务长时间无法结束而导致整个搜索结果无法立即获取的情况。

5.8 并行排序

对于大部分排序算法来说,都是串行执行的。当排序元素很多时,若使用并行算法代替串行算法,会更有效的利用CPU。但将串行算法改造成并行算法,会极大增加原有算法的复杂度。这里介绍几种相对简单的平行排序算法。

5.8.1 分离数据相关性:奇偶交换排序

介绍奇偶排序前,先看一下冒泡排序。假设要从小到大排序,冒泡排序类似水中气泡上浮,在冒泡过程中,如果数字较小,它会逐步被交换到前面去,相反,较大的数字则会下沉,交换到数组末尾。
冒泡排序一般算法如下:

1
2
3
4
5
6
7
8
9
10
11
public static void bubbleSort(int[] arrr) {
for(int i=arr.length-1; i>0; i--) {
for(int j=0; j<i; j++) {
if(arr[j] > arr[j+1]]) {
int temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}

在迭代过程中,由于每次交换的两个元素存在数据冲突。对于每个元素,它既可能与前面的元素交换,也可能和后面的元素交换,因此很难直接改造成并行算法。

如果能够解开这种数据的相关性,就可以比较容易的使用并行算法实现类似的排序,奇偶交换排序就是基于这种思想。
对于奇偶交换排序,它将排序过程分为两个阶段,奇交换和偶交换。对于奇交换,它总是比较奇数索引及其相邻的后续元素。而偶交换总是比较偶数索引和其相邻的后续元素。并且,奇交换和偶交换会成对出现,这样才能保证比较和交换涉及每个元素。
由于整个比较交换独立分隔为奇阶段和偶阶段,这样使得每个阶段内,所有的比较和交换是没有数据相关性的。因此,每一次比较和交换都可以独立执行,也就可以并行化了。

奇偶交换排序的串行实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void oddEvenSort(int[] arr) {
int exchFlag = 1, start = 0;
while (exchFlag == 1 || start == 1) {
exchFlag = 0;
for (int i = start; i < arr.length - 1; i += 2) {
if (arr[i] > arr[i + 1]) {
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
exchFlag = 1;
}
}
System.out.println(Arrays.toString(arr));
if (start == 0)
start = 1;
else
start = 0;
}
}

其中,exchFlag 用来记录当前迭代是否发生了数据交换。start 变量表示奇交换还是偶交换。初始时,start 为 0,表示进行偶交换,每次迭代结束后,切换 start 的状态。如果上一次比较发生了数据交换,或者当前正在进行奇交换,循环就不会停止。直到不再发生数据交换,并且当前进行的是偶交换为止(表示奇偶交换已经成对出现)。

虽然上述代码依然是串行代码,但已经能够轻松改造成并行模式了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
static int exchFlag=1;
static synchronized void setExchFlag(int v){
exchFlag=v;
}
static synchronized int getExchFlag(){
return exchFlag;
}

public static class OddEvenSortTask implements Runnable{
int i;
CountDownLatch latch;
public OddEvenSortTask(int i,CountDownLatch latch){
this.i=i;
this.latch=latch;
}
@Override
public void run() {
if (arr[i] > arr[i + 1]) { //line:18
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
setExchFlag(1);
}
latch.countDown();
}
}
public static void pOddEvenSort(int[] arr) throws InterruptedException {
int start = 0;
while (getExchFlag() == 1 || start == 1) {
setExchFlag(0);
//偶数的数组长度,当start为1时,只有len/2-1个线程
CountDownLatch latch = new CountDownLatch(arr.length/2-(arr.length%2==0?start:0));
for (int i = start; i < arr.length - 1; i += 2) {
pool.submit(new OddEvenSortTask(i,latch));
}
//等待所有线程结束
latch.await();
if (start == 0)
start = 1;
else
start = 0;
}
}

上述代码第 9 行定义了奇偶排序的任务类。该任务的主要工作是进行数据比较和必要的交换(第18~23行)。并行排序的主体是 pOddEvenSort() 方法,它使用 CountDownLatch 记录线程数量,对于每一次迭代,使用单独的线程对每一次元素比较和交换进行操作。在下一次迭代开始前,必须等待上次迭代的所有线程完成。

5.8.3 改进的插入排序:希尔排序

插入排序也是很常用的排序算法。它的基本思想是:一个未排序的数组(当然也可以是链表)可以分为两个部分,前半部分是已经排序的,后半部分是未排序的。在进行排序时,只需要在未排序的部分中选择一个元素,插入到前面有序的数组中即可。最终,未排序的部分越来越少,直到排序完成。初始时,假设已排序部分就是第一个元素。
插入排序的一般实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void insertSort(int[] arr) {
int length = arr.length;
int j, i, key;
for (i = 1; i < length; i++) {
//key为要准备插入的元素
key = arr[i];
j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
//找到合适的位置 插入key
arr[j + 1] = key;
System.out.println(Arrays.toString(arr));
}
}

上述代码第 6 行准备要插入的元素,也就是未排序部分的第一个元素。然后在已排序部分中找到这个元素的插入位置,进行插入即可。
简单的插入排序也很难并行化,因为这一次的数据插入依赖上一次插入得到的有序数据,也就依赖上一次的插入结果,所以多个步骤之间无法并行。为此,我们对插入排序进行扩展,这就是希尔排序。

希尔排序将这个数组根据间隔 h 分割为若干个子数组,它们互相穿插交织在一起,每一次排序时,分别对每一个子数组进行排序。

1
ABCABCABCABCA

上述字母 A 代表 A 数组,B 代表 B 数组,C 代表 C 数组,三个子数组的间隔为 3。每次排序时,总是交换间隔为 3 的两个元素。
在每一组排序完成后,可以递减 h 的值,进行下轮更加精细的排序。知道 h 的值为 1,此时等价于一次插入排序。
希尔排序的优点是,即使一个较小的元素在数组末尾,由于每次元素移动都以 h 为间隔进行,因此数组末尾的小元素可以在很少的交换次数下,就被置换到最接近正确位置的地方。

下面是希尔排序的串行实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void shellSort(int[] arr) {
// 计算出最大的h值
int h = 1;
while (h <= arr.length / 3) {
h = h * 3 + 1;
}
while (h > 0) {
System.out.println("h=" + h); //line:8
for (int i = h; i < arr.length; i++) {
if (arr[i] < arr[i - h]) {
int tmp = arr[i];
int j = i - h;
while (j >= 0 && arr[j] > tmp) {
arr[j + h] = arr[j];
j -= h;
}
arr[j + h] = tmp;
}
System.out.println(Arrays.toString(arr));
}
System.out.println(Arrays.toString(arr));
// 计算出下一个h值
h = (h - 1) / 3; //line:20
}
}

上述代码第4~6行计算一个合适的 h 值,接着正式进行希尔排序。第 8 行的 for 循环进行间隔为 h 的插入排序,每次排序结束后,递减 h 的值(第20行)。直到 h 为 1,退化为插入排序。

很显然,希尔排序每次针对不同的子数组进行排序,各子数组之间完全独立,因此很容易改造成并行模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public static class ShellSortTask implements Runnable {
int i = 0;
int h = 0;
CountDownLatch l;
public ShellSortTask(int i, int h, CountDownLatch latch) {
this.i = i;
this.h = h;
this.l = latch;
}
@Override
public void run() {
if (arr[i] < arr[i - h]) {
int tmp = arr[i];
int j = i - h;
while (j >= 0 && arr[j] > tmp) {
arr[j + h] = arr[j];
j -= h;
}
arr[j + h] = tmp;
}
l.countDown();
}
}

public static void pShellSort(int[] arr) throws InterruptedException {
// 计算出最大的h值
int h = 1;
CountDownLatch latch = null;
while (h <= arr.length / 3) {
h = h * 3 + 1;
}
while (h > 0) {
System.out.println("h=" + h);
if (h >= 4)
latch = new CountDownLatch(arr.length - h);
for (int i = h; i < arr.length; i++) {
// 控制线程数量
if (h >= 4) {
pool.execute(new ShellSortTask(i, h, latch));
} else {
if (arr[i] < arr[i - h]) {
int tmp = arr[i];
int j = i - h;
while (j >= 0 && arr[j] > tmp) {
arr[j + h] = arr[j];
j -= h;
}
arr[j + h] = tmp;
}
// System.out.println(Arrays.toString(arr));
}
}
// 等待线程排序完成,进入下一次排序
latch.await();
// 计算出下一个h值
h = (h - 1) / 3;
}
}

上述代码中定义 ShellSortTask 作为并行任务,一个 ShellSortTask 的作用是根据给定的起始位置和 h 对子数组进行排序。为控制线程数量,这里定义并行主函数 pShellSort() 在 h 大于等于 4 时使用并行线程,否则就退化为传统的插入排序。每次计算后,递减 h 的值。

5.9 并行算法:矩阵乘法

对于图像处理、神经网络、模式识别等领域来说,矩阵运行是其中必不可少的重要数学方法。在本节,我们介绍矩阵运算的典型代表——矩阵乘法的并行化实现。

在矩阵乘法中,第一个矩阵的列数和第二个矩阵的行数必须是相同的。例如,矩阵 A 和矩阵 B,矩阵 A 为 4 行 2 列,矩阵 B 为 2 行 4 列。它们相乘后,得到的是 4 行 4 列的矩阵。并且新矩阵中,每一个元素为矩阵 A 和 矩阵 B 对应行列的乘积求和,如下图所示。

image

如果需要进行并行计算,一种简单的策略就是将矩阵 A 进行水平分割,得到子矩阵 A1 和 A2,矩阵 B 进行垂直分割,得到子矩阵 B1 和 B2,此时,我们只要分别计算这些子矩阵的乘积,再将结果进行拼接就能得到原始矩阵 A 和 B 的乘积,如下图所示。

image

当然这个过程是可以反复进行的,为了计算矩阵 A1*B1,还可以进一步将矩阵 A1 和 B1 进行分解,直到矩阵到我们认为合适的大小。

具体的实现,可以使用 Fork/Join 框架实现并行矩阵相乘的想法。为了计算矩阵,还可以使用 jMatrices 开源项目的 API。

5.10 准备好了通知我:网络NIO

Java NIO 是 New IO 的简称,它是一种可以替代 Java IO 的一套新的 IO 机制。它提供了一套不同于 Java 标注 IO 的操作机制。严格来说,NIO 与并发没有直接关系,但是使用 NIO 技术可以大大提高线程的使用效率。
Java NIO 中设计的基础内容有通道(Channel)、缓冲区(Buffer)、文件IO和网络IO。本节重点介绍有关网络IO的内容,其他内容可参看本章的参考文献。
对于标准的网络IO来说,我们会使用 Socket 进行网络的读写。为了让服务器可以支持更多客户端连接,通常的做法是为每一个客户端连接开启一个线程。先回顾一下这方面的内容。

5.10.1 基于 Socket 的服务端多线程

这里以一个简单的 Echo 服务器的实现为例,服务端使用多线程进行处理时的结构如下:

image

服务端为每一个客户端启用一个线程,这个新的线程只为这个客户端服务。同时,为了接受客户端连接,服务器还会额外使用一个派发线程。

下面的代码实现了这个服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class MultiThreadEchoServer {
private static ExecutorService tp = Executors.newCachedThreadPool();
static class HandleMsg implements Runnable{
Socket clientSocket;
public HandleMsg(Socket clientSocket){
this.clientSocket = clientSocket;
}
public void run(){
BufferedReader is =null;
PrintWriter os = null;
try {
is = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
os = new PrintWriter(clientSocket.getOutputStream(), true);
// 从InputStream当中读取客户端所发送的数据
String inputLine = null;
long b = System.currentTimeMillis();
while ((inputLine = is.readLine()) != null) {
os.println(inputLine);
}
long e = System.currentTimeMillis();
System.out.println("spend:"+(e-b)+"ms");
} catch (IOException e) {
e.printStackTrace();
}finally{
try {
if(is!=null) is.close();
if(os!=null) os.close();
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String args[]) {
ServerSocket echoServer = null;
Socket clientSocket = null;
try {
echoServer = new ServerSocket(8000);
} catch (IOException e) {
System.out.println(e);
}
while (true) {
try {
clientSocket = echoServer.accept();
System.out.println(clientSocket.getRemoteSocketAddress() + " connect!");
tp.execute(new HandleMsg(clientSocket));
} catch (IOException e) {
System.out.println(e);
}
}
}
}

上述代码,首先定义了一个线程池。然后定义了 HandleMsg 线程,它由一个客户端 Socket 构造而成,它的任务就是读取这个 Socket 的内容并进行返回,返回成功后,任务完成,客户端 Socket 就被正常关闭。主线程 main 的作用就是在 8000 端口上等待,一旦有新的客户端连接,它就根据这个连接创建 HandleMsg 线程进行处理。

这就是一个支持多线程的服务端的核心内容。它的特点是,在相同可支持的线程范围内,尽量多的支持客户端的数量,同时和单线程服务器相比,它也可以更好的使用多核CPU。

这里再给出一个客户端的参考实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SocketClient {
public static void main(String[] args) throws IOException {
Socket client = null;
PrintWriter writer = null;
BufferedReader reader = null;
try {
client = new Socket();
client.connect(new InetSocketAddress("localhost", 8000));
writer = new PrintWriter(client.getOutputStream(), true);
writer.println("Hello!");
writer.flush();

reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
System.out.println("from server: " + reader.readLine());
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (writer != null)
writer.close();
if (reader != null)
reader.close();
if (client != null)
client.close();
}
}
}

上述代码,先是连接了服务器的 8000 端口,并发送字符串“Hello!”然后刷新。接着读取服务器的返回信息,并输出。

这种多线程的服务器开发模式很常用,对大多数应用,这种模式可以很好的工作。但是,这种模式有一个重大的弱点,它倾向于让CPU进行IO等待
比如,把客户端传输“Hello!”字符串的过程改为逐个字符的传输,每传输一个字符等待 1 秒,这样需要 6 秒客户端才能传输完成。在这个过程中,服务器端的 CPU 一直在等待客户端的 IO 完成,因为服务器要先读入客户端的输入。
如果每个请求都像这样拖慢了服务器的处理速度,那么服务端能够处理的并发数量就会很少。这个案例中,服务器处理速度慢完全是因为服务线程在等待 IO 而已,让高速运转的 CPU 去等待网络 IO 是非常不划算的行为。

那么,是不是有方法能让网络 IO 的等待时间从线程中分离出来呢?

5.10.2 使用 NIO 进行网络编程

使用 Java 的 NIO 就可以将上一节的网络 IO 等待时间,从业务处理线程中抽取出来。

要了解 NIO,首先要知道 NIO 中的一个关键组件 Channel,Channel 有点类似于流,一个 Channel 可以和文件或者网络 Socket 对应。如果 Channel 对应着一个 Socket,那么往这个 Channel 中写数据,就等于向 Socket 中写入数据。
和 Channel 一起使用的另外一个重要组件就是 Buffer,可以理解成一个内存区域或者 byte 数组。数据需要包装成 Buffer 的形式才能和 Channel 交互(写入或读取)。
另外一个与 Channel 密切相关的就是 Selector 选择器。Channel 有一个 SelectableChannel 实现,表示可被选择的通道。SelectableChannel 可以将自己注册到一个 Selector 中,一个 Selector 可以管理多个 SelectableChannel。当 SelectableChannel 的数据准备好时,Selector 就会接到通知,得到准备好的数据。
而 SocketChannel 就是 SelectableChannel 的一种。一个 Selector 由一个服务线程管理,而一个 SocketChannel 表示一个客户端连接。因此,这就构成了一个或极少线程来处理大量客户端连接的结构。客户端的数据没有准备好时,Selector 处于等待状态,一旦有一个 SelectableChannel 准备好数据,Selector 就能立即得到通知,进行处理。

下面使用 NIO 重新构造这个多线程的 Echo 服务器。
首先,定义一个 Selector 和线程池。

1
2
private Selector selector;
private ExecutorService tp = Executors.newCachedThreadPool();

Selector 用于处理所有的网络连接,每一个请求都会委托给线程池中的线程进行实际处理。
为了能够统计服务器线程在一个客户端上花费的时间,这里还需要定义一个与时间统计有关的类:

1
public static Map<Socket,Long> time_stat=new HashMap<Socket,Long>(10240);

它用于统计在某一个 Socket 上花费的时间,key 为 Socket,value 为开始的时间戳。
下面看一下 NIO 服务器的核心代码,startServer() 方法用于启动 NIO Server。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private void startServer() throws Exception {
selector = SelectorProvider.provider().openSelector();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);

InetSocketAddress isa = new InetSocketAddress(8000);
ssc.socket().bind(isa);

SelectionKey acceptKey = ssc.register(selector, SelectionKey.OP_ACCEPT); //line:9
for (;;) {
selector.select();
Set readyKeys = selector.selectedKeys();
Iterator i = readyKeys.iterator();
long e=0;
while (i.hasNext()) {
SelectionKey sk = (SelectionKey) i.next();
i.remove();
if (sk.isAcceptable()) {
doAccept(sk);
} else if (sk.isValid() && sk.isReadable()) {
if(!time_stat.containsKey(((SocketChannel)sk.channel()).socket()))
time_stat.put(((SocketChannel)sk.channel()).socket(),
System.currentTimeMillis());
doRead(sk);
} else if (sk.isValid() && sk.isWritable()) {
doWrite(sk);
e=System.currentTimeMillis();
long b=time_stat.remove(((SocketChannel)sk.channel()).socket());
System.out.println("spend:"+(e-b)+"ms");
}
}
}
}

上述代码首先通过工厂方法获得一个 Selector 对象实例。然后获得表示服务端的 ServerSocketChannel 实例,并设置为非阻塞模式。接着,把这个 Channel 绑定在 8000 端口。
第 9 行,把这个 ServerSocketChannel 绑定到 Selector 上,注册它感兴趣的事件为 Accept。当 Selector 发现 ServerSocketChannel 有新的客户端连接时,就会通知 ServerSocketChannel 进行处理。方法 register() 的返回值是一个 SelectionKey,SelectionKey 表示一对 Selector 和 Channel 的关系。当 Channel 注册到 Selector 上时,就确立了两者的关系,SelectionKey 就是这个关系的契约。
接着的 for 循环是一个无穷循环,它的任务就是等待-分发网络消息。
Selector.select() 方法是一个阻塞方法,如果当前没有任何数据准备好,它就会等待。一旦有数据可读,它就会返回,返回值是已经就绪的 SelectionKey 的数量。
Selector.selectedKeys() 方法获取准备好的 SelectionKey 的集合,之后会遍历这个集合,逐个处理所有的 Channel 数据。
while 循环就是迭代处理这个 SelectionKey 的集合,首先得到当前迭代的 SelectionKey,然后,将这个 SelectionKey 从集合中移除。这个移除操作非常重要,否则就会重复处理相同的 SelectionKey。
然后判断这个 SelectionKey 代表的 Channel 是否在 Acceptable 状态,如果是就开始接收客户端数据,也就是 doAccept() 方法。
while 循环中的第一个 else if 代码块,判断 Channel 是否已经可以读了(接收数据完毕),如果是就进行读取,使用 doRead() 方法。
while 循环中的第二个 else if 代码块,判断 Channel 是否准备好进行写。如果是就写入,使用 doWrite() 方法。同时在写入后,根据时间戳,计算处理这个 Socket 连接的耗时。

下面看一下 doAccept() 方法的实现,它与客户端建立连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void doAccept(SelectionKey sk) {
ServerSocketChannel server = (ServerSocketChannel) sk.channel();
SocketChannel clientChannel;
try {
clientChannel = server.accept();
clientChannel.configureBlocking(false);

// Register this channel for reading.
SelectionKey clientKey = clientChannel.register(selector, SelectionKey.OP_READ); //line:9
// Allocate an EchoClient instance and attach it to this selection key.
EchoClient echoClient = new EchoClient(); //line:11
clientKey.attach(echoClient);

InetAddress clientAddress = clientChannel.socket().getInetAddress();
System.out.println("Accepted connection from " + clientAddress.getHostAddress() + ".");
} catch (Exception e) {
System.out.println("Failed to accept new client.");
e.printStackTrace();
}
}

和 Socket 编程类似,当有一个新的客户端连接接入时,就会产生一个新的 Channel 来代表这个连接。上述代码第 5 行生成的 clientChannel 就表示和客户端通信的通道。第 6 行将这个 Channel 配置为非阻塞模式。也就是要求系统在准备好 IO 后,再通知线程来读取或者写入。
第 9 行代码很关键,它将新生成的 Channel 注册到选择器上,并告诉 Selector 现在对读操作感兴趣。这样,当 Selector 发现这个 Channel 已经准备好读时,就能给线程一个通知。
第 11 行新建了一个 EchoClient 实例,代表一个客户端。在第 12 行,将这个客户端实例作为附件,附加到表示这个连接的 SelectionKey 上。这样在整个连接的处理过程中,就可以共享这个 EchoClient 实例。

EchoClient 的定义很简单,它封装了一个队列,保存在需要回复给这个客户端的所有信息上,这样需要回复时,只要从 outq 对象中弹出元素即可。

1
2
3
4
5
6
7
8
9
10
11
12
class EchoClient {
private LinkedList<ByteBuffer> outq;
EchoClient() {
outq = new LinkedList<ByteBuffer>();
}
public LinkedList<ByteBuffer> getOutputQueue() {
return outq;
}
public void enqueue(ByteBuffer bb) {
outq.addFirst(bb);
}
}

下面看一下另外一个重要方法 doRead() 方法的实现,当 Channel 可以读取时,doRead() 方法就会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void doRead(SelectionKey sk) {
SocketChannel channel = (SocketChannel) sk.channel();
ByteBuffer bb = ByteBuffer.allocate(8192);
int len;

try {
len = channel.read(bb); //line:7
if (len < 0) {
disconnect(sk);
return;
}
} catch (Exception e) {
System.out.println("Failed to read from client.");
e.printStackTrace();
disconnect(sk);
return;
}

bb.flip(); //line:19
tp.execute(new HandleMsg(sk,bb));
}

方法 doRead() 接收一个 SelectionKey 对象,通过它可以得到当前的客户端 Channel。然后准备了 8K 的缓冲区读取数据,所有读取的数据存放在变量 bb 中(第7行)。读取完成后,重置缓冲区,为数据处理做准备(第19行)。然后使用线程池进行数据处理,这样如果数据处理很复杂,就会在单独线程中处理,而不用阻塞任务派发线程。

HandleMsg 的实现也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class HandleMsg implements Runnable{
SelectionKey sk;
ByteBuffer bb;
public HandleMsg(SelectionKey sk,ByteBuffer bb){
this.sk=sk;
this.bb=bb;
}
@Override
public void run() {
EchoClient echoClient = (EchoClient) sk.attachment();
echoClient.enqueue(bb); //line:11
sk.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
//强迫selector立即返回
selector.wakeup();
}
}

上述代码简单的将接收到的数据压入 EchoClient 的队列(第11行),如果需要处理业务逻辑,就可以在这里进行。数据处理完后,就可以准备将结果回写到客户端。因此,重新注册感兴趣的消息事件,将写操作(OP_WRITE)也作为感兴趣的事件进行提交。这样在通道准备好写入时,就能通知线程。

写入操作使用 doWrite() 函数实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void doWrite(SelectionKey sk) {
SocketChannel channel = (SocketChannel) sk.channel();
EchoClient echoClient = (EchoClient) sk.attachment();
LinkedList<ByteBuffer> outq = echoClient.getOutputQueue();
ByteBuffer bb = outq.getLast();
try {
int len = channel.write(bb);
if (len == -1) {
disconnect(sk);
return;
}
if (bb.remaining() == 0) {
// The buffer was completely written, remove it.
outq.removeLast();
}
} catch (Exception e) {
System.out.println("Failed to write to client.");
e.printStackTrace();
disconnect(sk);
}
if (outq.size() == 0) {
sk.interestOps(SelectionKey.OP_READ);
}
}

doWrite() 方法也接收一个 SelectionKey 参数,对同一个客户端,这个 SelectionKey 和 doRead() 方法拿到的是同一个 SelectionKey。因此通过 SelectionKey 就可以在这两个操作中共享 EchoClient 实例了。
上述代码中,先是取得了 EchoClient 实例及它发送的内容列表。然后第 5 行获取列表的顶部元素,准备写回客户端。SocketChannel.write() 执行写回操作。如果全部发送完成,则移除这个缓存对象(removeLast)。
doWrite() 方法中最重要的,是在全部数据发送完成后,也就是 outq 的长度为0时,将写事件从感兴趣的操作中移除(最后一行只注册了读)。如果不这么做,每次 Channel 准备好写时,都会执行 doWrite(),而实际上此时又无数据可写。因此这个操作很重要。

最后,使用 NIO 服务器处理上一节中的客户端访问,客户端也是 6 秒才能完成一次消息的发送。可以发现,使用 NIO 后,即使客户端迟钝或者出现了网络延时,并不会给服务器带来太大的问题。

5.10.3 使用 NIO 实现客户端

前面的案例中,我们使用 Socket 构建客户端,使用 NIO 实现服务端。实际上 NIO 也可以创建客户端。
它和构建服务器类似,核心元素也是 Selector、Channel、SelectionKey。
首先,初始化 Selector、Channel:

1
2
3
4
5
6
7
8
private Selector selector;
public void init(String ip, int port) throws IOException {
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
this.selector = SelectorProvider.provider().openSelector();
channel.connect(new InetSocketAddress(ip, port));
channel.register(selector, SelectionKey.OP_CONNECT);
}

上述代码第 3 行创建一个 SocketChannel 实例,并设置为非阻塞模式。第 5 行创建一个 Selector。然后第 6 行将 SocketChannel 绑定到 Socket 上,但由于当前 Channel 是非阻塞的,因此当 connect() 方法返回时,连接并不一定建立成功,后续使用连接时需要使用 finishConnect() 方法再次确认。第 7 行将这个 Channel 和 Selector 绑定,注册了感兴趣的事件为连接。
初始化完成后,就是程序的主要执行逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void working() throws IOException {
while (true) {
if (!selector.isOpen())
break;
selector.select();
Iterator<SelectionKey> ite = this.selector.selectedKeys().iterator();
while (ite.hasNext()) {
SelectionKey key = ite.next();
ite.remove();
// 连接事件发生
if (key.isConnectable()) {
connect(key);
} else if (key.isReadable()) {
read(key);
}
}
}
}

上述代码中,第 5 行通过 Selector 的 select() 得到准备好的事件,如果没有任何事件准备就绪,select() 方法就会阻塞。这里的处理机制和服务端类似,主要处理两个事件,表示连接就绪的 Connect 事件,以及表示通道可读的 Read 事件。为避免重复处理,记得 remove() 掉当前遍历的 SelectionKey。
函数 connect() 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
public void connect(SelectionKey key) throws IOException {
SocketChannel channel = (SocketChannel) key.channel();
// 如果正在连接,则完成连接
if (channel.isConnectionPending()) {
channel.finishConnect();
}
channel.configureBlocking(false);
channel.write(ByteBuffer.wrap(new String("hello server!\r\n")
.getBytes()));
channel.register(this.selector, SelectionKey.OP_READ);
}

上述 connect() 方法接收 SelectionKey 为参数。它首先判断是否连接已经建立,如果没有则调用 finishConnect() 方法完成连接。建立连接后,向 Channel 写入数据,并同时注册读事件为感兴趣的事件。
当 Channel 可读时,会执行 read() 方法,进行读取数据。

1
2
3
4
5
6
7
8
9
10
11
public void read(SelectionKey key) throws IOException {
SocketChannel channel = (SocketChannel) key.channel();
// 创建读取的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(100);
channel.read(buffer);
byte[] data = buffer.array();
String msg = new String(data).trim();
System.out.println("客户端收到信息:" + msg);
channel.close();
key.selector().close();
}

上述 read() 方法首先创建了 100 字节的缓冲区 Buffer,接着从 Channel 中读取数据,并打印在控制台。最后关闭 Channel 和 Selector。

5.11 读完再通知我:AIO

AIO 是异步 IO 的缩写,即 Asynchronized IO。虽然 NIO 在网络操作中提供了非阻塞的方法,但 NIO 的 IO 操作依然还是同步的。对于 NIO,我们的业务线程是在 IO 准备好时,得到通知,接着就由这个线程自行进行 IO 操作, IO 操作本身还是同步的。
但对于 AIO,更进了一步,它不是在 IO 准备好时再通知线程,而是在 IO 操作完成后,再给线程发出通知。因此,AIO 是完全不会阻塞的。这时,我们的业务逻辑将变成一个回调函数,等待 IO 操作完成后,由系统自动触发。
下面通过 AIO 实现一个简单的 EchoServer 及对应的客户端。

5.11.1 AIO EchoServer 的实现

异步 IO 需要使用异步通道,AsynchronousServerSocketChannel。

1
2
3
4
5
public final static int PORT = 8000;
private AsynchronousServerSocketChannel server;
public AIOEchoServer() throws IOException {
server = AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(PORT));
}

上述代码绑定了 8000 端口作为服务器端口,并使用 AsynchronousServerSocketChannel 异步 Channel 作为服务器,变量名为 server。
我们使用 server 进行客户端的接收和处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public void start() throws InterruptedException, ExecutionException, TimeoutException {
System.out.println("Server listen on " + PORT);
//注册事件和事件完成后的处理器
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
final ByteBuffer buffer = ByteBuffer.allocate(1024);
public void completed(AsynchronousSocketChannel result, Object attachment) {
System.out.println(Thread.currentThread().getName());
Future<Integer> writeResult=null;
try {
buffer.clear();
result.read(buffer).get(100, TimeUnit.SECONDS); //line:11
buffer.flip();
writeResult = result.write(buffer);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
} finally {
try {
server.accept(null, this); //line:20
writeResult.get();
result.close();
} catch (Exception e) {
System.out.println(e.toString());
}
}
}

@Override
public void failed(Throwable exc, Object attachment) {
System.out.println("failed: " + exc);
}
});
}

上述定义的 start() 方法开启了服务器。这个方法只调用了 server.accept(),之后的一大堆代码都是这个函数的参数。
AsynchronousServerSocketChannel.accept() 方法会立即返回,不会真的等待客户端,这个 accept() 方法的前面为:

1
public final <A> void accept(A attachment, CompletionHandler<AsynchronousSocketChannel,? super A> handler)

它第一个参数是一个附件,可以是任意类型,作用是让当前线程和后续的回调方法共享信息,它会在后续调用中传递给 handler。它第二个参数是 CompletionHandler 接口,这个接口有两个方法:

1
2
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);

这两个方法分别在异步操作 accept() 方法成功或者失败时被回调。
因此,AsynchronousServerSocketChannel.accept() 方法实际上做了两件事。第一,发起 accept 请求,告诉系统可以开始监听端口了。第二,注册 CompletionHandler 实例,告诉系统一旦有客户端前来连接,如果连接成功,就去执行 completed() 方法,如果连接失败,就去执行 failed() 方法。
所以,accept() 方法不会阻塞,它立即返回。

下面分析一下 CompletionHandler.completed() 方法的实现。当 completed() 方法被执行时,意味着已经有客户端成功连接了。它当中,使用 AsynchronousSocketChannel.read() 方法读取客户端数据,这个 read() 方法也是异步的,返回一个 Future。为了编程方便,我们这里直接调用了 Future.get() 方法进行等待,将这个异步方法变成了同步。因此,第 11 行执行完后,数据读取就已经完成了。
之后,将数据回写给客户端,调用的是 AsynchronousSocketChannel.write() 方法,这也是异步方法,会立即返回一个 Future 对象。
第 20 行,服务器进行下一个客户端连接的准备,同时关闭当前的客户端连接。关闭之前确保 write() 方法以及完成,使用 Future.get() 方法进行等待。
接下来,在主函数中使用 start() 方法就可以开启服务器了。

1
2
3
4
5
6
7
public static void main(String args[]) throws Exception {
new AIOEchoServer().start();
// 主线程可以继续自己的行为
while (true) {
Thread.sleep(1000);
}
}

主函数中,调用 start() 方法开启服务器,但由于 start() 方法里使用的都是异步方法,因此它会马上返回,它并不像阻塞方法那样会进行等待。因此,想要让程序驻守执行,while 循环是必需的。否则,start() 方法结束后,不等客户端到来,主线程就退出了。

5.11.2 AIO Echo 客户端的实现

在服务端实现中,我们使用 Future.get() 方法将异步调用转为了一个同步等待。在客户端实现中,我们将全部使用异步回调实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class AIOClient {
public static void main(String[] args) throws Exception {
final AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
client.connect(new InetSocketAddress("localhost", 8000), null, new CompletionHandler<Void, Object>() {
@Override
public void completed(Void result, Object attachment) {
client.write(ByteBuffer.wrap("Hello!".getBytes()), null, new //line:7 CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) { //line:10
try {
ByteBuffer buffer = ByteBuffer.allocate(1024);
client.read(buffer,buffer,new CompletionHandler<Integer, ByteBuffer>(){
@Override
public void completed(Integer result, ByteBuffer buffer) {
buffer.flip(); //line:15
System.out.println(new String(buffer.array()));
try {
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, Object attachment) {
}
});
}
@Override
public void failed(Throwable exc, Object attachment) {
}
}); //line:39
//由于主线程马上结束,这里等待上述处理全部完成
Thread.sleep(1000);
}
}

上述 AIO 客户端看起来很长,但实际上只有三个语句。
第一个语句为第 3 行,打开 AsynchronousSocketChannel 通道。第二个语句是第 4~39 行,它让客户端连接指定的服务器,并注册了一系列事件。第三个语句是第 41 行,让线程 sleep 1秒。虽然第二个语句很长,但它是异步的,因此会很快返回。所以 sleep 等待是必需的,不然客户端就直接退出了。
第 4 行,客户端进行网络连接,注册了回调函数 CompletionHandler<Void,Object>。连接成功后进入代码第 7 行,第 7 行进行数据写入,向服务端发送数据。这个过程也是异步的,会很快返回。写入完成后,会通知回调接口 CompletionHandler 进入第10行。第 10 行开始,准备进行数据读取,从服务端读取回写的数据。client.read() 也是立即返回的,成功读取数据后会回调 CompletionHandler 接口,进入第 15 行,然后打印接收的数据。

6. Java 8/9/10 与并发

Java 8 中新增的一些与并行相关的 API,以函数式编程的范式出现。

6.1 Java 8 的函数式编程简介

6.1.1 函数作为一等公民

函数作为一等公民时有几个特性:

  • 一个函数可以作为参数传递给另一个函数
  • 一个函数可以作为另外一个函数的返回值

6.1.2 无副作用

函数的副作用指的是,函数在调用过程中,除了给出返回值,还修改了函数外部的状态。函数式编程认为,函数的副作用应该被尽量避免。如果一个函数肆意修改全局或外部状态,那出现问题时很难定位是哪个函数引起的。如果函数都是显式函数,那么函数的执行显然不会受到外部或者全局信息的影响,因此对于调试和排错是有益的。

显式函数指函数与外界交换数据的唯一聚到就是参数和返回值,显式函数不会读取修改外部状态。与之对应的是隐式函数,隐式函数除参数和返回值外,还会读取或修改外部信息。

然而,完全的无副作用实际上是做不到的,系统总是需要读取或者修改外部信息,如果完全禁止副作用也是不令人愉快的。因此大部分函数式编程语言,都允许副作用的存在。但是在函数式编程里,这种函数调用的副作用需要进行有效的控制。

6.1.3 声明式的(Declarative)

函数式编程是声明式的编程方式。命令式(Imperative)程序设计喜欢大量使用可变对象和指令。比如我们习惯于创建对象和变量,并修改他们的状态或值,或者喜欢提供一系列指令要求程序执行。而在声明式的编程范式中,你不再需要提供明确的指令,所有的细节指令都被程序库封装,你要做的只是提出你的要求,声明你的用意即可。

6.1.4 不变的对象

函数式编程中,几乎所有传递的对象都不会被轻易的修改。
看以下代码:

1
2
3
4
static int[] arr = {1,2,3,4,5,6};
Arrays.stream(arr).map((x)->x=x+1).forEach(System.out::println);
System.out.println();
Arrays.stream(arr).forEach(System.out::println);

代码第2行看似修改了每个元素的值,但是在最好以后打印出来的,依然是原始的值。
在使用函数式编程时,这是一种常态,几乎所有的对象都拒绝被修改,这非常类似于不变模式。

6.1.5 易于并行

由于对象都是不变的状态,因此函数式编程更加易于并行。实际上,你甚至完全不用担心线程安全的问题。
我们之所以关注线程安全,是因为当多个线程对同一个对象进行写操作时,容易将这个对象“写坏”。但是,由于对象是不变的,因此,在多线程环境下,也就没有必要进行任何同步操作了。
这样有利于并行化,同时,在并行化后,由于没有同步和锁机制,性能也会比较好。

6.1.6 更少的代码

通常情况下,函数式编程更加简明扼要。一般来说,精简的代码更易于维护。
引入函数式编程范式后,可以使用更少的代码完成更多的工作。

6.2 函数式编程基础

本节会简单介绍一些 Java 8 的新特性。

6.2.1 FunctionalInterface 注解

Java 8 提出了函数式接口的概念,所谓函数式接口,简单说就是只定义了单一抽象方法的接口。
比如下面的定义:

1
2
3
4
@FunctionalInterface
public static interface IntHandler {
void handle(int i);
}

FunctionalInterface 注解用于表明这是一个函数式接口。IntHandler 接口只包含一个 handle() 抽象方法,因此它符合函数式接口的定义。
如果一个接口符合函数式接口的定义,即使不标注 FunctionalInterface 注解,也会被看作函数式接口。
需要注意的是,函数式接口只能有一个抽象方法,而不是只能有一个方法。并且,任何被 java.lang.Object 实现的方法,都不能视为抽象方法。例如下面的 NonFuc 接口就不是函数式接口,因为 equals() 方法在 java.lang.Object 中已经实现。

1
2
3
interface NonFunc {
boolean equals(Object obj);
}

而下面这个 IntHandler 接口,看上去不符合函数式接口,但实际上确实是一个函数式接口:

1
2
3
4
public static interface IntHandler {
void handle(int i);
boolean equals(Object obj);
}

函数式接口可以由方法引用,或者使用 lambda 表达式进行构造。

6.2.2 接口默认方法

Java 8 开始,接口也可以包含若干个实例方法。这一改进使得 Java 8 拥有了类似多继承的能力。一个对象实例,将拥有来自多个不同接口的实例方法。
定义接口的默认实例方法,需要使用 default 关键字。

6.2.3 lambda 表达式

lambda 表达式可以说是函数式编程的核心。lambda 表达式即匿名函数,它是一段没有函数名的函数体,可以作为参数直接传递给相关的调用者,lambda 表达式极大增强了 Java 语言的表达能力。
lambda 表达式中使用的变量,会被视为 final。否则编译不会通过。

6.2.4 方法引用

方法引用是 Java 8 中提出的用来简化 lambda 表达式的一种手段。它通过类名和方法名来定位一个静态方法或者实例方法。
方法引用在 Java 8 中的使用非常灵活,总的来说分为几种:

  • 静态方法引用:ClassName::methodName
  • 实例上的实例方法引用:instance:methodName
  • 超类上的实例方法引用:super::methodName
  • 类型上的实例方法引用:ClassName::methodName
  • 构造方法引用:Class::new
  • 数组构造方法引用:TypeName[]::new

方法引用使用“::”定义,”::”的前半部分表示类名或实例名,后半部分表示方法名,如果是构造函数则使用 new 表示。

6.3 走入函数式编程

本节简单介绍了 Java 8 的 Stream 的使用,以及与 lambda 一起使用。
就像这样:

1
2
3
4
static int[] arr = {1,2,3,4,5,6};
public static void main(String[] args) {
Arrays.stream(arr).forEach((x)->System.out.println(x));
}

6.4 并行流与并行排序

Java 8 可以在接口不变的情况下,将流改为并行流。这样,就可以自然的使用多线程进行集合中的数据处理。

6.4.1 使用并行流过滤数据

有一个简单的案例,统计 1~1000_000 内质数的数量。首先,需要一个判断质数的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PrimeUtil {
public static boolean isPrime(int number) {
int tmp = number;
if (tmp < 2) {
return false;
}
for (int i = 2; Math.sqrt(tmp) >= i; i++) {
if (tmp % i == 0) {
return false;
}
}
return true;
}
}

上述函数给定一个数字,如果这个数字是质数就返回true,否则返回false。
接着,使用函数式编程统计给定范围内的所有的质数。

1
IntStream.range(1, 1000000).filter(PrimeUtil::isPrime).count();

上述代码首先生成一个 1 到 1000_000 的数字流,接着使用过滤函数,只选择所有的质数,最后进行数量统计。
上述代码是串行,将它改造成并行计算很简单,只需要将流并行化即可:

1
IntStream.range(1, 1000000).parallel().filter(PrimeUtil::isPrime).count();

parallel() 方法得到一个并行流,然后在并行流上进行过滤,此时,PrimeUtil.isPrime() 函数会被多线程并发调用,应用于流中的所有元素。

6.4.2 从集合得到并行流

在函数式编程中,我们可以从集合得到一个流或者并行流。

1
2
List<Student> ss = new ArrayList<Student>();
double ave = ss.stream().mapToInt(s -> s.score).average().getAsDouble();

从集合对象 List 中,我们使用 stream() 方法可以得到一个流。如果希望并行化,可以使用 parallelStream() 方法获取并行流。

1
double ave = ss.parallelStream().mapToInt(s->s.score).average().getAsDouble();

可以看到,改造成并行化也是很简单的。

6.4.3 并行排序

除了并行流,对于简单的数组,Java 8 也提供了简单的并行功能。比如,对于数组排序,我们有 Arrays.sort() 方法,这是串行排序。Java 8 中可以使用新增的 Arrays.parallelSort() 方法直接使用并行排序。
比如,可以这样使用:

1
2
int[] arr = new int[10000000];
Arrays.parallelSort(arr);

除了并行排序,Arrays 中海增加了 API 用于数组中数据的赋值,比如:

1
public static void setAll(int[] array, IntUnaryOperator generator)

这是一个函数式风格很浓的接口,它第二个参数是一个函数式接口。如果想给数组中每个元素附上一个随机值,可以这么做:

1
2
Random r = new Random();
Arrays.setAll(arr, (i)->r.nextInt());

使用 parallelSetAll 代替 setAll 就可以实现并行,将它执行在多个CPU上:

1
2
Random r = new Random();
Arrays.parallelSetAll(arr, (i)->r.nextInt());

6.5 增强的 Future:CompletableFuture

CompletableFuture 是 Java 8 新增的一个超大型工具类。它实现了 Future 接口以及 CompletionStage 接口。CompletionStage 接口也是 Java 8 新增的,它拥有 40 多个方法,它是为函数式编程中的流式调用准备的。通过 CompletionStage 接口,可以在一个执行结果上进行多次流式调用,以得到最终结果。

6.5.1 完成了就通知我

CompletableFuture 和 Future 一样可以作为函数调用的契约。向 CompletableFuture 请求一个数据,如果数据还没准备好,请求线程就会等待。而让人惊喜的是,我们可以手动设置 CompletableFuture 的完成状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static class AskThread implements Runnable {
CompletableFuture<Integer> re = null;
public AskThread(CompletableFuture<Integer> re) {
this.re = re;
}
@Override
public void run() {
int myRe = 0;
try {
myRe = re.get() * re.get(); //line:10
} catch (Exception e) {
}
System.out.println(myRe);
}
}
public static void main(String[] args) throws InterruptedException {
final CompletableFuture<Integer> future = new CompletableFuture<>();
new Thread(new AskThread(future)).start();
// 模拟长时间其他调用
Thread.sleep(1000); //line:20
// 告知完成结果,future.get()得以继续执行
future.complete(60); //line:22
}

上述代码先定义了一个 AskThread 线程,它接收一个 CompletableFuture 为其构造参数,它的任务是计算 CompletableFuture 表示的数字的平方,并将其打印。
主函数中,创建一个 CompletableFuture 对象实例,将这个对象实例传递给 AskThread 线程,并启动线程。AskThread 线程在执行到第 10 行代码时会阻塞,因为 CompletableFuture 中根本没有它需要的数据。整个 CompletableFuture 处于未完成状态。主函数中第 20 行用于模拟长时间的计算过程。当计算完成后,可以将最终数据载入 CompletableFuture,并标记为完成状态。
当第 22 行执行结束后,同时载入了最终数据和告知了完成状态,因此 AskThread 得以继续执行。

6.5.2 异步执行任务

通过 CompletableFuture 提供的进一步封装,我们很容易实现 Future 模式的异步调用,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Integer calc(Integer para) {
try {
// 模拟一个长时间的执行
Thread.sleep(1000);
} catch (InterruptedException e) {
}
return para*para;
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
final CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> calc(50));
System.out.println(future.get());
}

上述代码主函数中,使用 CompletableFuture.supplyAsync() 方法构造一个 CompletableFuture 实例,在 supplyAsync() 函数中,它会在一个新的线程中执行传入的参数,这里他会执行 calc() 方法。而 calc() 方法可能执行比较慢,但是不影响 CompletableFuture 实例的构造速度,因此 supplyAsync() 方法立即返回。它返回的 CompletableFuture 对象实例就可以作为这次调研的契约,用于在将来获得最终的计算结果。最后一行代码试图获得 calc() 方法的计算结果,如果计算没有完成,调用 get() 的线程就会等待。
supplyAsync() 还有一个重载的方法可以接受一个 Executor 参数,这就可以让线程在指定的线程池中工作,如果不指定线程池,则在默认的系统公共的 ForkJoinPool.common 线程池中工作。

在 Java 8 新增了一个 ForkJoinPool.commonPool() 方法,它可以获得一个公共的 ForkJoin 线程池,这个公共线程池中的所有线程都是 Daemon 线程,这意味着如果主线程退出,线程池中的线程无论是否执行完毕,都会退出系统。

6.5.3 流式调用

前文中提到 CompletionStage 的 40 多个接口是为函数式编程做准备的,在这里,就让我们看一下,如何使用这些接口进行函数式的流式 API 调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static Integer calc(Integer para) {
try {
// 模拟一个长时间的执行
Thread.sleep(1000);
} catch (InterruptedException e) {
}
return para*para;
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
CompletableFuture<Void> fu = CompletableFuture.supplyAsync(() -> calc(50))
.thenApply((i)->Integer.toString(i))
.thenApply((str)->"\""+str+"\"")
.thenAccept(System.out::println);
fu.get();
}

上述代码使用 supplyAsync() 函数执行一个异步任务,接着连续使用流式调用对任务的处理结果进行再加工,直到最后的结果输出。
最后执行 CompletableFuture.get() 方法,目的是等待 calc() 方法执行完成。由于 CompletableFuture 异步执行的缘故,如果不等待,那么可能 calc() 还没执行完成主函数就退出了,随着主线程的结束,所有 Daemon 线程都立即退出,从而导致 calc() 方法无法正常完成。

6.5.4 CompletableFuture 中的异常处理

如果 CompletableFuture 在执行过程中遇到了异常,那么我们就可以使用函数式编程的风格来优雅的处理这些异常。CompletableFuture 提供了一个异常处理方法 exceptionally():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static Integer calc(Integer para) {
return para / 0;
}
public static void main(String[] args) throws InterruptedException,ExecutionException {
CompletableFuture<Void> fu = CompletableFuture
.supplyAsync(() -> calc(50))
.exceptionally(ex -> { //line:7
System.out.println(ex.toString());
return 0;
})
.thenApply((i) -> Integer.toString(i))
.thenApply((str) -> "\"" + str + "\"")
.thenAccept(System.out::println);
fu.get();
}

上述代码中第 7 行对当前的 CompletableFuture 进行异常处理,如果没有异常发生,则 CompletableFuture 就会返回原有的结果,如果遇到了异常,就可以在 exceptionally() 方法中处理异常,并返回一个默认值。

6.5.5 组合多个 CompletableFuture

CompletableFuture 还允许你将多个 CompletableFuture 进行组合,一种方法是使用 thenCompose() 方法,它的签名如下:

1
public <U> CompletableFuture<U> thenCompose(Function<? super T, ? extends CompletionStage<U>> fn)

一个 CompletableFuture 可以在执行完成后,将执行结果通过 Function 接口传递给下一个 CompletionStage 实例进行处理(Function接口返回新的CompletionStage实例):

1
2
3
4
5
6
7
8
9
10
public static Integer calc(Integer para) {
return para/2;
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
CompletableFuture<Void> fu =
CompletableFuture.supplyAsync(() -> calc(50))
.thenCompose((i)->CompletableFuture.supplyAsync(() -> calc(i)))
.thenApply((str)->"\"" + str + "\"").thenAccept(System.out::println);
fu.get();
}

上述代码第 7 行,将处理后的结果传递给 thenCompose() 方法,并进一步传递给后续新生成的 CompletableFuture 实例。上述代码输出:

1
"12"

另外一种组合多个 CompletableFuture 的方法是 thenCombine() 方法,它的签名如下:

1
2
pubic <U,V> CompletableFuture<V> thenCombine(CompletionStage<? extends U> other,
BiFunction<? super T, ? super U, ? extends V> fn)

方法 thenCombine() 首先完成当前 CompletableFuture 和 other 的执行,接着,将这两者的执行结果传递给 BiFunction(该接口接受两个参数,并有一个返回值),并返回代表 BiFunction 实例的 CompletableFuture 对象。

1
2
3
4
5
6
7
8
9
10
11
12
public static Integer calc(Integer para) {
return para / 2;
}
public static void main(String[] args) throws InterruptedException,ExecutionException {
CompletableFuture<Integer> intFuture = CompletableFuture.supplyAsync(() -> calc(50));
CompletableFuture<Integer> intFuture2 = CompletableFuture.supplyAsync(() -> calc(25));

CompletableFuture<Void> fu = intFuture.thenCombine(intFuture2, (i, j) -> (i + j))
.thenApply((str) -> "\"" + str + "\"")
.thenAccept(System.out::println);
fu.get();
}

上述代码中,首先生成两个 CompletableFuture 实例,接着使用 thenCombine() 方法组合这两个 CompletableFuture,将两者的执行结果进行累加,并将累加结果转为字符串实处,上述代码的输出是:

1
"37"

6.5.6 支持 timeout 的 CompletableFuture

在 JDK 9 以后,CompletableFuture 增加了 timeout 功能。如果一个任务在给定时间内没有完成,则直接抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static Integer calc(Integer para) {
return para / 2;
}
public static void main(String[] args) {
CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
return calc(50);
}).orTimeout(1, TimeUnit.SECONDS).exceptionally(e -> {
System.err.println(e); //line:12
return 0;
}).thenAccept(System.out::println);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
}

本例中,CompletableFuture.orTimeout() 方法指定 Future 的执行时间不能超过 1 秒,如果超过 1 秒,则抛出 TimeoutException 异常。在第 12 行的异常处理中,得到的异常正是由 orTimeout() 函数抛出的。本例的输出如下:

1
2
java.util.concurrent.TimeoutException
0

6.6 读写锁的改进:StampedLock

StampedLock 是 Java 8 引入的一种新的锁机制,可以认为是读写锁的一个改进版本。读写锁虽然分离了读和写的功能,使得读和读之间可以完全并发。但是,读和写之间依然是冲突的。读锁完全阻塞写锁,它使用的依然是悲观的锁策略,如果有大量的读线程,它也有可能引起写线程的“饥饿”。
而 StampedLock 则提供了一种乐观的读策略,这种乐观的锁非常类似无锁的操作,使得乐观锁完全不会阻塞写线程。

6.6.1 StampedLock 使用示例

StampedLock 的使用并不难,下面是 StampedLock 的使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();

void move(double deltaX, double deltaY) { //一个排他锁
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
double distanceFromOrigin() { //只读方法
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y; //line:16
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY); //line:26
}
}

上述代码出自 JDK 的官方文档。定义了一个 Point 类,内部有两个元素 x 和 y,表示点的坐标。第 3 行定义了 StampedLock 锁,distanceFromOrigin() 方法是一个只读方法,它只会读取 Point 的 x 和 y 坐标。在读取时,首先使用了 StampedLock.tryOptimisticRead() 方法,这个方法表示试图尝试一次乐观读。它会返回一个类似于时间戳的整数 stamp,这个 stamp 就可以视为这一次锁获取的凭证。
第 16 行读取 x 和 y 的值,这时我们并不确定这个 x 和 y 是否是一致的(在读取x的时候,可能其他线程改写了y的值,使得currentX和currentY处于不一致状态),因此,我们必须使用 validate() 方法判断这个 stamp 是否在读过程发生期间被修改过。如果 stamp 没有被修改过,则认为这次读取有效,就可以跳转到第 26 行进行数据处理。反之,如果 stamp 被修改过,有可能发生了脏读,我们可以像处理 CAS 操作那样在一个死循环中一直使用乐观读,直到成功为止。
本例中,我们升级乐观锁为悲观锁,在第 17 行判断乐观读失败后,第 18 行使用 readLock() 方法获得悲观的读锁,并进一步读取数据。如果当前对象正在被修改,则读锁的申请可能导致线程挂起。

可以看出,StampedLock 通过引入乐观读,来增加系统的并行度。

6.6.2 StampedLock 的小陷阱

StampedLock 内部实现时,使用类似于 CAS 操作的死循环反复尝试的策略。在它挂起线程时,使用的是 Unsafe.park() 函数,而 park() 函数在遇到线程中断时,会直接返回(注意,不同于Thread.sleep()方法,它不会抛出异常)。而在 StampedLock 的死循环逻辑中,没有处理有关中断的逻辑。因此,这就会导致阻塞在 park() 方法上的线程被中断后,再次进入循环。而当退出条件得不到满足时,就会发生疯狂占用 CPU 的缘故。这一点值得我们注意。

6.6.3 StampedLock 的实现思想

StampedLock 的内部实现是基于 CLH 锁的。CLH 锁是一种自旋锁,它保证没有饥饿发生,并且可以保证 FIFO(先进先出)的服务顺序。
CLH 锁的基本思想如下:锁维护一个等待线程队列,所有申请锁但是没有成功的线程都记录在这个队列中。每一个节点(一个节点代表一个线程)保存一个标记位(locked),用于判断当前线程是否已经释放锁。
当一个线程试图获得锁时,取得当前等待队列的尾部节点作为其前序节点,并使用类似如下代码判断前序节点是否已经成功释放锁:

1
2
while (pred.locked) {
}

如果前序节点没有释放锁,则表示当前线程还不能继续执行,因此会自旋等待。反之,如果前序线程已经释放锁,则当前线程可以继续执行。
释放锁时,也遵循这个逻辑,如果线程将自身节点的 locked 位置标记为 false,那么后续等待的线程就能继续执行了。
StampedLock 正是基于这种思想,但是实现上更为复杂。

6.7 原子类的增强

之前的章节中已经提到了原子类的使用,无锁的原子操作使用系统的 CAS 指令,有着远远超越锁的性能,是否有可能在性能上更上一层楼呢?答案是肯定的。
Java 8 引入了 LongAdder 类,它在 java.util.concurrent.atomic 包下,因此可以推测,它也使用了 CAS 指令。

6.7.1 更快的原子类:LongAdder

原子类 AtomicInteger 的机制就是在一个死循环内,不断尝试使用 CAS 操作修改目标值,直到修改成功。如果竞争激烈,大量修改失败时,这些原子操作会进行多次循环尝试,因此性能会受到影响。
那么当竞争激烈时,如何进一步提高系统性能呢?一种基本方案就是使用热点分离,将竞争的数据进行分解,基于这个思路,大家可以想到一种对传统 AtomicInteger 等原子类进行改进的方法。
虽然在 CAS 操作中没有锁,但是减少锁粒度这种分离热点的思想依然可以使用。一种可行的方案就是仿造 ConcurrentHashMap,将热点数据分离。比如,可以将 AtomicInteger 的内部核心数据 value 分离成一个数组,每个线程访问时,通过哈希等算法映射到其中一个数字进行计算,最终的计算结果则为这个数组的求和累加。
LongAdder 就是基于这种思想,热点数据 value 被分离为多个单元(cell),每个 cell 独自维护内部的值,当前对象的实际值由所有的 cell 累计合成,这样热点就进行了有效的分离,提高了并行度。
在实际操作中,LongAdder 并不会一开始就动用数组进行处理,而是将所有的数据先记录在一个 base 变量中。如果在多线程条件下,大家修改 base 都没有冲突(CAS操作),那么也没有必要扩展为 cell 数组。但是,一旦 base 修改发生冲突,就会初始化 cell 数组,使用新的策略。如果使用 cell 数组后,发现在某一个 cell 上的更新依然冲突,那么系统会尝试创建新的 cell,或者将 cell 的数量加倍,以减少冲突的可能性。
LongAdder 就是一个累加器,increment() 方法自增1,sum() 方法进行求和。下面简单分析一下 increment() 方法(该方法将LongAdder自增1)的内部实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void increment() {
add(1L);
}
public void add(long x) {
Cell[] as; long b,v; int m; Cell a;
if((as==cells)!=null || !casBase(b=base,b+x)) {
boolean uncontended = true;
if(as==null || (m = as.length-1)<0
|| (a = as[getProbe()&m]) == null
|| !(uncontended = a.cas(v=a.value,v+x))) {
longAccumulate(x, null, uncontended);
}
}
}

它的核心是add()方法,最开始 cells 为 null,因此数据会向 base 增加(第6行)。但是如果与 base 的操作冲突(CAS),则会进入第 7 行,并设置冲突标记 uncontended 为 ture。接着,如果判断 cells 数组不可用,或者当前线程对应的 cell 为 null,则直接进入 longAccumulate() 方法。否则会尝试使用 CAS 方法更新对应的 cell 数据,如果成功则退出,失败则进入 longAccumulate() 方法。
longAccumulate() 方法的大致内容是根据需要创建新的 cell 或者对 cell 数组进行扩容,以减少冲突。

LongAdder 的另一个优化手段是避免了伪共享(第5章介绍过伪共享问题)。LongAdder 并不是直接使用 padding 这种看起来比较碍眼的做法,而是引入了新的注解 @sun.misc.Contended,LongAdder 中每一个 cell 类上增加了此注解,Java虚拟机就会自动为 cell 解决伪共享问题。
在我们自己的代码中,也可以使用注解 @sun.misc.Contended 来解决伪共享问题,但是需要额外使用虚拟机参数 -XX:-RestrictContended,否则该注解会被忽略。

LongAdder 比 AtomicInteger 这种的传统原子类快很多。

6.7.2 LongAdder 功能的增强版:LongAccumulator

LongAccumulator 是 LongAdder 的亲兄弟,它们有公共的父类 Striped64。因此 LongAccumulator 内的优化方式和 LongAdder 是一样的。它们都将一个 long 型整数分割,并存储在不同变量中,以防止多线程竞争。
但是 LongAccumulator 是 LongAdder 的功能扩展,对于 LongAdder 来说,它只是每次对给定的整数执行一次加法,而 LongAccumulator 则可以实现任意函数操作。

可以使用构造函数创建一个 LongAccumulator 实例:

1
public LongAccumulator(LongBinaryOperator accumulatorFunction, long identity)

第 1 个参数 accumulatorFunction 就是需要执行的二元函数(接收两个long型参数并返回long),第 2 个参数是初始值。
下面例子展示了 LongAccumulator 的使用,它将通过多线程访问若干个整数,并返回遇到的最大的那个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws Exception {
LongAccumulator accumulator = new LongAccumulator(Long::max, Long.MIN_VALUE);
Thread[] ts = new Thread[1000];

for (int i = 0; i < 1000; i++) {
ts[i] = new Thread(() -> {
Random random = new Random();
long value = random.nextLong();
accumulator.accumulate(value);
});
ts[i].start();
}
for (int i = 0; i < 1000; i++) {
ts[i].join();
}
System.out.println(accumulator.longValue());
}

上述代码第 2 行构造了 LongAccumulator 实例,由于我们需要过滤最大值,因此传入 Long::max 函数句柄。当有数据通过 accumulate() 方法传入 LongAccumulator 后(第9行),LongAccumulator 会通过 Long::max 识别最大值并保存在内部(可能是cell数组内,也可能是base)。最后通过 longValue() 函数对所有的 cell 进行 Long::max 操作,得到最大值。

6.8 ConcurrentHashMap 的增强

JDK 1.8 以后,ConcurrentHashMap 有了一些 API 的增强,其中很多增强接口与 lambda 表达式有关,这些增强接口大大方便了应用的开发。

6.8.1 foreach 操作

新版本的 ConcurrentHashMap 增加了一些 foreach 操作,这些 foreach 操作的接口是一个 Consumer 或者 BiConsumer,用于对 Map 的数据进行消费。

6.8.2 reduce 操作

和 foreach 操作类似,reduce 操作对 Map 的数据进行处理的同时会将其转为另一种形式。
下面是一个 reduce 操作的示例,用于计算 ConcurrentHashMap 中所有 value 的总和。

1
2
3
4
5
6
7
8
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
for (int i = 1; i <= 100; i++) {
map.put(Integer.toString(i), i);
}
int count = map.reduceValues(2, (i, j) -> i + j);
System.out.println(count);
}

reduceValues() 方法第一个参数表示并行度,表示一个并行任务可以处理的元素个数(估算值)。如果设置为 Long.MAX_VALUE,则表示完全禁用并行,设置为1表示使用最大程度的并行。

6.8.3 条件插入

应用开发中,一个十分常见的场景是条件插入,即当元素不存在时需要创建并且将对象插入 Map 中,而当 Map 中已存在该元素时,则直接获取当前在 Map 中的元素,从而避免多次创建。这样可以起到对象复用的功能,对于大型重量级对象有很好的优化效果。
传统的实现如下:

1
2
3
4
5
6
7
8
public static HeavyObject getOrCreate(ConcurrentHashMap<String,HeavyObject> map, String key){
HeavyObject value = map.get(key);
if(value == null) {
value = new HeavyObject();
map.put(key, value);
}
return value;
}

但是,上面这段代码不是线程安全的。当多个线程访问 getOrCreate() 方法时,还是可能出现重复创建对象的情况。简单的处理方法是将 getOrCreate() 设置为同步方法,但这样会降低性能。因此,这种场合我们需要一种线程安全的高效方法,computeIfAbsent() 方法就是这样的方法:

1
2
3
public static HeavyObject getOrCreate(ConcurrentHashMap<String,HeavyObject> map, String key){
return map.computeIfAbsent(key, k -> new HeavyObject());
}

6.8.4 search 操作

基于 ConcurrentHashMap 还可以做并发搜索,search() 方法会在 Map 中找到第一个使得参数 Function 返回不为 null 的值,比如,下面的代码将找到 Map 中可以被 25 整除的一个数(由于Hash和并行的随机性,得到的结果也是随机的)。

1
2
3
4
5
6
int found = map.search(2, (str,i) -> {
if(i % 25 == 0) {
return i;
}
return null;
});

6.8.5 其他新方法

mappingCount()方法
返回 Map 中的条目总数,有别于 size() 方法,该方法返回的是 long 型数据,因此,当元素总数超过整数最大值时,应该使用这个方法。同时,该方法不返回精确值,如果在执行该方法时,同时存在并发的插入或者删除操作,则结果是不准确的。

newKeySet()方法
在JDK中,Set 的实现依赖于 Map,实际上,Set 是 Map 的一种特殊情况。如果需要一个线程安全的高效并发 HashSet,那么基于 ConcurrentHashMap 的实现是最好的选择。该方法是一个静态工厂方法,返回一个线程安全的 Set。

6.9 发布和订阅模式

在 JDK 9 中,引入了一种新的并发编程架构:反应式编程。
反应式编程用于处理异步流中的数据,每当应用收到数据项,便会对它进行处理。反应式编程以流的形式处理数据,因此其内存使用率会更高。
在反应式编程中,核心的两个组件是 Publisher 和 Subscriber。Publisher 将数据发布到流中,Subscriber 则负责处理这些数据。
以下是反应式编程的主要 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@FunctionalInterface
public static interface Flow.Publisher<T> {
public void subscribe(Flow.Subscriber<? super T> subscriber);
}
public static interface Flow.Subscriber<T> {
public void onSubscribe(Flow.Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
public static interface Flow.Subscription {
public void request(long n);
public void cancel();
}
public static interface Flow.Processor<T,R> extends Flow.Subscriber<T>, Flow.Publisher<R>{
}

其中,Subscriber 是订阅者,用来处理数据:

  • onSubscribe():订阅者注册后被调用的第一个方法。
  • onNext():当有下一个数据项准备好时,进行通知。
  • onError():当发生无法恢复的异常时被调用。
  • onComplete():当没有更多数据需要处理时被调用。

Subscription 表示对订阅数据的处理:

  • request():设定请求的数据个数。
  • cancel():Subscriber 停止接收新的消息。

6.9.1 简单的发布订阅例子

下面以一个简单的发布订阅模式的案例为例,先看一下订阅者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MySubscriber<T> implements Subscriber<T> {
private Subscription subscription;
@Override
public void onSubscribe(Subscription subscription) {
this.subscription = subscription;
subscription.request(1); //line:6
System.out.println(Thread.currentThread().getName()+" onSubscribe");
}
@Override
public void onNext(T item) {
System.out.println(Thread.currentThread().getName()+" Received: " + item);
subscription.request(1);
}
@Override
public void onError(Throwable t) {
t.printStackTrace();
synchronized("A") {
"A".notifyAll();
}
}
@Override
public void onComplete() {
System.out.println("Done");
synchronized("A") {
"A".notifyAll();
}
}
}

上面代码是一个订阅者,onSubscribe() 方法在注册后首先被调用,第 6 行代码请求一个数据流中的数据,这行代码很重要,没有它,订阅者将无法消费数据。
当数据流中有可用数据时,调用 onNext() 方法,处理完成后,通过 Subscription.request() 方法再次请求剩余的数据。当出现错误或者完成后,进行通知,结束程序。

下面是数据发布的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
SubmissionPublisher<String> publisher = new SubmissionPublisher<>();

MySubscriber<String> subscriber = new MySubscriber<>();
MySubscriber<String> subscriber2 = new MySubscriber<>();
publisher.subscribe(subscriber);
publisher.subscribe(subscriber2);

// Publish several data items and then close the publisher.
System.out.println("Publishing data items...");
String[] items = { "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
Arrays.asList(items).stream().forEach(i ->{
publisher.submit(i); //line:13
System.out.println(Thread.currentThread().getName()+" publish "+i);
});
publisher.close();

try {
synchronized("A") {
"A".wait();
}
} catch (InterruptedException ie) {
}

上述代码首先创建 SubmissionPublisher 对象,表示数据的发布者。然后向 SubmissionPublisher 对象中注册两个订阅者。第 13 行代码 publisher.submit() 方法将数据发布到 SubmissionPublisher 中,数据发布后,通过 close() 方法关闭发布者,最后,等待订阅者处理完毕。

6.9.2 数据处理链

发布者-订阅者模式还可以通过数据处理链对数据进行流式处理。
一个泛化的数据转换模块如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class TransformProcessor<T, R> extends SubmissionPublisher<R> implements Processor<T, R> {
private Function<? super T, ? extends R> function;
private Subscription subscription;
public TransformProcessor(Function<? super T, ? extends R> function) {
super();
this.function = function;
}
@Override
public void onSubscribe(Subscription subscription) {
this.subscription = subscription;
subscription.request(1);
}
@Override
public void onNext(T item) { //line:14
submit(function.apply(item));
subscription.request(1);
}
@Override
public void onError(Throwable throwable) {
throwable.printStackTrace();
}
@Override
public void onComplete() {
close();
}
}

其中,第 2 行的 function 包含数据转换的具体逻辑,在第 14 行 onNext() 方法中,使用该逻辑对数据进行处理,并同时将处理结果再次发布,以便进行后续处理。
下面代码建立针对数据流的处理链条:

1
2
3
4
5
6
7
8
9
10
11
12
SubmissionPublisher<String> publisher = new SubmissionPublisher<>();
MySubscriber<String> subscriber = new MySubscriber<>();
MySubscriber<String> subscriber2 = new MySubscriber<>();

TransformProcessor<String,String> toUpperCase = new TransformProcessor<>(String::toUpperCase);
TransformProcessor<String,String> toLowverCase = new TransformProcessor<>(String::toLowerCase);

publisher.subscribe(toUpperCase); //line:8
publisher.subscribe(toLowverCase);

toUpperCase.subscribe(subscriber);
toLowverCase.subscribe(subscriber2); //line:12

第 8~12 行代码建立数据处理链,这里的规则是,对于数据流中的数据进行两种不同的业务处理,在一条处理流中将字母转为大写,在另外一条数据流中,将字母转为小写。接着打印输出转换后的两类数据(第11~12行)。

7. 使用 Akka 构建高并发程序

Akka 是一款遵循 Apache 2 许可的开源项目,这意味着你可以无偿并且几乎没有限制的使用它,包括用于商业环境中。
Akka 是用 Scala 创建的,但由于 Scala 也是 JVM 上的语言,本质上两者没有任何不同。因此,我们也可以在 Java 中使用 Akka。本章将使用 Akka 2.11-2.3.7 作为演示,但本章不是 Akka 的使用手册,不会完整介绍它的 API。本章只是对 Akka 的主要功能进行简单的描述,帮助理解 Akka 的基本思想。
使用 Akka 能带来什么好处?
首先,Akka 提供了一种名为 Actor 的并发模型,其粒度比线程小,这意味着你可以在系统中启用大量的 Actor。
其次,Akka 中提供了一套容错机制,允许在 Actor 出现异常时,进行一些恢复或者重置操作。
再次,通过 Akka 不仅可以在单机上构建高并发程序,也可以在网络中构建分布式程序,并提供位置透明的 Actor 定位服务。

7.1 新并发模型:Actor

对于并发程序来说,线程始终都是并发程序的基本执行单元。但在 Akka 中,你可以完全忘记线程了。当你使用 Akka 时,你就有一个全新的执行单元:Actor。
简单地说,你可以把 Actor 比喻成一个人。人与人之间可以使用语言进行交流。比如,老师问同学 5 乘以 5 是多少呀?同学听到后想了想,回答说是 25。Actor 之间的通信方式和上述对话形式几乎是一模一样的。
传统 Java 并行程序是基于面向对象的方法。通过对象的方法调用进行信息的传递。这时,如果对象的方法会修改对象的状态,那么多线程情况下,就可能出现对象状态不一致,所以我们必须对这类方法调用进行同步。当然,同步往往是以牺牲性能为代价的。
在 Actor 模型中,我们失去了对象的方法调用,我们并不是通过调用 Actor 对象的某一个方法来告诉 Actor 做什么,而是给 Actor 发送一条消息。当一个 Actor 接收到消息后,它有可能根据消息的内容做出某些行为,包括更改自身状态。但是,这种情况下,这个状态的更改是 Actor 自己进行的,并不是由外界被迫进行的。

7.2 Akka 之 Hello World

在了解了 Actor 的基本行为模式后,我们通过简单的 Hello World 程序进一步了解 Akka 的开发。
先看一下第一个 Actor 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Greeter extends UntypedActor {
public static enum Msg {
GREET, DONE;
}
@Override
public void onReceive(Object msg) {
if(msg == Msg.GREET) {
System.out.println("Hello World!");
getSender().tell(Msg.DONE, getSelf());
} else {
unhandled(msg);
}
}
}

上述代码定义了一个欢迎者(Greeter)Actor,它继承自 UntypedActor(Akka的核心成员)。UntypedActor 就是我们所说的 Actor,之所以强调这是无类型的,是因为在 Akka 中,还支持一种有类型的 Actor。有类型的 Actor 可以使用系统中的其他类型构造,从而缓解了Java单继承问题。但如果没有这个需求,UntypedActor 应该是首选。
代码使用枚举定义了消息类型,这里只有 GREET 和 DONE 两种类型。当 Greeter 收到 GREET 消息时,就会在控制台打印“Hello World”,并且向消息发送方发送 DONE 消息。

与 Greeter 交流的另外一个 Actor 是 HelloWorld,它的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class HelloWorld extends UntypedActor {
ActorRef greeter;
@Override
public void preStart() {
greeter = getContext().actorOf(Props.create(Greeter.class), "greeter");
System.out.println("Greeter Actor Path:" + greeter.path());
greeter.tell(Greeter.Msg.GREET, getSelf());
}
@Override
public void onReceive(Object msg) {
if(msg == Greeter.Msg.DONE) {
greeter.tell(Greeter.Msg.GREET, getSelf());
getContext().stop(getSelf());
} else {
unhandled(msg);
}
}
}

上述代码实现了一个名为 HelloWorld 的 Actor。第 4 行 preStart() 方法是 Akka 的回调方法,在 Actor 启动前,会被 Akka 框架调用,完成一些初始化工作。在这里,我们在 HelloWorld 中创建了 Greeter 实例,并且向它发送 GREET 消息。此时,由于创建 Greeter 时使用的是 HelloWorld 的上下文,因此它是属于 HelloWorld 的子 Actor。
第 10 行定义的 onReceive() 方法是 HelloWorld 的消息处理函数。在这里,只处理 DONE 的消息,收到 DONE 消息后,它会再向 Greeter 发送 GREET 消息,接着将自己停止。
因此,Greeter 会收到前后两条 GREET 消息,打印两次“Hello World”。

最后,看一下主函数的实现:

1
2
3
4
5
6
7
public class HelloMainSimple {
public static void main(String[] args) {
ActorSystem system = ActorSystem.create("Hello", ConfigFactory.load("samplehello.conf"));
ActorRef a = system.actorOf(Props.create(HelloWorld.class),"helloWorld");
System.out.println("HelloWorld Actor Path:" + a.path());
}
}

主函数中,首先创建了 ActorSystem,表示管理和维护 Actor 的系统。一般来说,一个应用程序只需要一个 ActorSystem 就够用了。ActorSystem.create() 函数的第一个参数“Hello”为系统名称,第二个参数为配置文件。
第 4 行通过 ActorSystem 创建一个顶级的 Actor(HelloWorld)。
配置文件 samplehello.conf 的内容如下:

1
2
3
akka {
loglevel = INFO
}

这里只是简单配置了日志级别为 INFO。
执行上述代码,输出如下:

1
2
3
4
5
6
7
8
9
1 HelloWorld Actor Path:akka://Hello/user/helloWorld
2 Greeter Actor Path:akka://Hello/user/helloWorld/greeter
3 Hello World!
4 Hello World!
5 [INFO] [05/13/2015 21:15:01.299] [Hello-akka.actor.default-dispatcher-2]
[akka://Hello/user/helloWorld] Message [geym.akka.demo.hello.Greeter$Msg] from
Actor[akka://Hello/user/helloWorld/greeter#-1698722495] to
Actor[akka://Hello/user/helloWorld#-1915075849] was not delivered. [1] dead letters
encountered.

第 1 行打印了 HelloWorld Actor 的路径,它是系统内第一个被创建的 Actor。它的路径为 akka://Hello/user/helloWorld,其中 Hello 表示 ActorSystem 的系统名,我们构造 ActorSystem 时,传入的第一个参数就是 Hello。user 表示用户 Actor。所有的用户 Actor 都会挂载在 user 路径下。helloWorld 就是这个 Actor 的名字。
同理,第 2 个Greeter Actor 的路径结构和 HelloWorld 的路径结构是一致的。输出的第 3、4 行显示了 Greeter 打印的两条信息。第 5 行表示系统遇到了一条消息投递失败,失败的原因是 Hello World 将自己终止了,导致 Greeter 发送的信息无法投递。

当使用 Actor 进行并行程序开发时,我们的关注点已经不在线程上了。实际上,线程调度已经被 Akka 框架封装了,我们只需要关注 Actor 对象即可。而 Actor 对象之间的交流和普通对象的函数调用有明显区别,它们是通过显式的消息发送来传递信息的
当系统内有多个 Actor 存在时,Akka 会自动在线程池中选择线程来执行我们的 Actor。因此,多个不同的 Actor 可能会被同一个线程执行,同时,一个 Actor 也有可能被不同的线程执行。因此,一个值得注意的地方是:不要在一个 Actor 中执行耗时的代码,这样可能会导致其他 Actor 的调度出现问题。

7.3 有关消息投递的一些说明

整个 Akka 应用是由消息驱动的,消息是除 Actor 之外最重要的核心组件。作为并发程序中的核心组件,在 Actor 之间传递的消息应该满足不可变性,也就是不变模式。因为可变的消息无法高效的在并发环境中使用。
理论上 Akka 中的消息可以使用任何对象实例,但在实际使用中,强烈推荐使用不可变的对象。
一个典型的不可变的对象的实现如下:

1
2
3
4
5
6
7
8
public final class ImmutableMessage {
private final int sequenceNumber;
private final List<String> values;
public ImmutableMessage(int sequenceNumber, List<String> values) {
this.sequenceNumber = sequenceNumber;
this.values = Collections.unmodifiableList(new ArrayList<String>(values));
}
}

上述代码实现了一个不可变的消息。注意代码中对 final 的使用,它声明了当前消息中的几个字段都是常量,在消息构造完成后就不能再改变了。需要注意的是,对于 values 字段,final 关键字只能保证 values 引用的不可变性,无法保证 values 对象的不可变性。为了实现彻底的不可变性,代码第 6 行构造了一个不可变的 List 对象。

对于消息的投递,可以有三种不同的策略:

  • 第一种,称为至多一次投递。每一条消息最多会被投递一次,可能偶尔会出现消息投递失败,而导致消息丢失。
  • 第二种,称为至少一次投递。每一条消息至少被投递一次,直到成功为止。因此在一些偶然的场合,接受者可能会收到重复的消息,但不会发生消息丢失。
  • 第三种,称为精确的消息投递。也就是所有的消息保证被精确的投递并成功接收一次。既不会有丢失,也不会有重复接收。

很明显,第一种策略是最高性能、最低成本的,因为系统只要负责把消息送出去就可以了,不需要关注是否成功。第二种策略则需要保存消息投递的状态并不断充实。第三种策略则是成本最高且最不容易实现的。
那我们是否真的需要保证消息投递的可靠性呢?
答案是否定的。实际上,我们没有必要在 Akka 层保证消息的可靠性,这样做成本太高也没有必要。消息的可靠性应该从应用的业务层去维护,因为也许在有些时候,丢失一些消息完全是符合应用要求的。因此,在使用 Akka 时,需要在业务层对此进行保证。
此外,对于消息投递,Akka 可以在一定程度上保证顺序性。比如,Actor A1 向 Actor A2 顺序发送了 M1、M2、M3 三条消息,Actor A3 向 Actor A2 顺序发送了 M4、M5、M6 三条消息,那么系统可以保证:

  1. 如果 M1 没有丢失,那它一定先于 M2 和 M3 被 Actor A2 收到。
  2. 如果 M2 没有丢失,那它一定先于 M3 被 Actor A2 收到。
  3. 如果 M4 没有丢失,那它一定先于 M5 和 M6 被 Actor A2 收到。
  4. 如果 M5 没有丢失,那它一定先于 M6 被 Actor A2 收到。
  5. 对 Actor A2 来说,来自 Actor A1 和 Actor A3 的消息可能交织在一起,没有顺序保证。

值得注意的是,这种消息投递规则不具备可传递性,比如,Actor A 向 Actor C 发送了 M1,接着,Actor A 向 Actor B 发送了 M2,Actor B 将 M2 转发给了 Actor C,那么在这种情况下,Actor C 收到 M1 和 M2 的先后顺序是没有保证的。

7.4 Actor 的生命周期

Actor 在系统中产生后,也存在着“生老病死”的活动周期。Akka 框架提供了若干回调函数,让我们得以在 Actor 的活动周期内进行一些业务相关的行为。
Actor 的生命周期如下图所示。

image

一个 Actor 在 actorOf() 函数被调用后开始建立,Actor 实例创建后会回调 preStart() 方法。在这个方法里,可以进行一些资源的初始化工作。在 Actor 工作过程中可能会出现一些异常,这种情况下 Actor 要重启。当 Actor 被重启时,会回调 preRestart() 方法(在老的实例上),接着系统会创建一个新的 Actor 对象实例(虽然是新的但它们都表示同一个Actor)。当新的 Actor 实例创建后,会回调 postRestart() 方法,表示启动完成,同时新的实例将会代替旧的实例。停止一个 Actor 也有很多方式,可以调用 stop() 方法或者给 Actor 发送一个 PosionPill(毒药丸)。当 Actor 停止时,postStop() 方法会被调用,同时这个 Actor 的监视者会收到一个 Terminated 消息。

下面我们建立一个带有生命周期回调函数的 Actor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class MyWorker extends UntypedActor {
private final LoggingAdapter log = Logging.getLogger(getContext().system(), this);
public static enum Msg {
WORKING, DONE, CLOSE;
}
@Override
public void preStart() {
System.out.println("MyWorker is starting");
}
@Override
public void postStop() {
System.out.println("MyWorker is stopping");
}
@Override
public void onReceive(Object msg) {
if(msg == Msg.WORKING) {
System.out.println("I am working");
}
if(msg == Msg.DONE) {
System.out.println("Stop working");
}
if(msg == Msg.CLOSE) {
System.out.println("Stop working");
getSender().tell(Msg.CLOSE, getSelf());
getContext().stop(getSelf());
} else {
unhandled(msg);
}
}
}

上述代码定义了一个名为 MyWorker 的 Actor。它重载了 preStart() 和 postStop() 两个方法。一般来说,可以用 preStart() 方法来初始化一些资源,使用 postStop() 方法来进行资源的释放。这个 Actor 很简单,当它收到 WORKING 消息时,就打印“I am working”,收到 DONE 消息时,就打印“Stop working”。

接着,我们为 MyWorker 指定一个监视者,监视者如同一个监工,一旦 MyWorker 因为意外停止工作,监视者就会收到一个通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class WatchActor extends UntypedActor {
private final LoggingAdapter log = Logging.getLogger(getContext().system(), this);
public WatchActor(ActorRef ref) {
getContext().watch(ref);
}
@Override
public void onReceive(Object msg) {
if(msg instanceOf Terminated) {
System.out.println(String.format("%s has terminated, shutting down system",
((Terminated) msg).getActor().path()));
getContext().system().shutdown();
} else {
unhandled(msg);
}
}
}

上述代码定义了一个监视者 WatchActor,它本质上也是个 Actor,但不同的是,它会在它的上下文中 watch 一个 Actor(第4行)。如果将来这个被监视的 Actor 退出终止,WatchActor 就能收到一条 Terminated 消息。在这里,我们简单的打印终止消息 Terminated 中的相关 Actor 路径,然后关闭整个 ActorSystem。

主函数如下:

1
2
3
4
5
6
7
8
9
10
11
public class DeadMain {
public static void main(String[] args) {
ActorSystem system = ActorSystem.create("deadwatch",
ConfigFactory.load("samplehello.conf"));
ActorRef worker = system.actorOf(Props.create(MyWorker.class), "worker");
system.actorOf(Props.create(WatchActor.class, worker), "watcher");
worker.tell(MyWorker.Msg.WORKING, ActorRef.noSender());
worker.tell(MyWorker.Msg.DONE, ActorRef.noSender());
worker.tell(PoisonPill.getInstance(), ActorRef.noSender());
}
}

上述代码首先创建了 ActorSystem 全局实例,接着创建 MyWorker Actor 和 WatchActor。注意第 6 行 Props.create() 方法,它的第一个参数为要创建的 Actor 类型,第二个参数为这个 Actor 的构造函数的参数(这里就是WatchActor的构造函数)。接着,向 MyWorker Actor 先后发送了 WORKING 和 DONE 两条消息。最后第 9 行,发送一条特殊的消息 PoisonPill。PoisonPill 意为毒药丸,它会直接“毒死”接收方,让其终止。
从代码输出可以看到,MyWorker Actor 声明周期中的两个回调函数及消息处理函数都被正常调用。最后也输出了 WatchActor 正常监视到 MyWoker 的终止。

7.5 监督策略

如果一个 Actor 在执行过程中发生意外,比如因没有处理某些异常导致出错,那么这时应该怎么办?系统应该当做什么都没发生,继续执行,还是认为遇到了一个系统性的错误而重启了 Actor,甚至是它所有的兄弟 Actor 呢?

对于这种情况,Akka 框架给予了我们足够的控制权。在 Akka 框架内,父 Actor 可以对子 Actor 进行监督,监控 Actor 的行为是否有异常。监督策略可以分为两种:一种是 OneForOneStrategy 策略的监督,另一种是 AllForOneStrategy 策略的监督。
对于 OneForOneStrategy 策略,父 Actor 只会对出问题的子 Actor 进行处理,比如重启或者停止,而对于 AllForOneStrategy 策略,父 Actor 会对出问题的子 Actor 及它所有的兄弟都进行处理。很显然,对于 AllForOneStrategy 策略,它更加适合各个 Actor 联系非常紧密的场景,多个 Actor 间只要有一个 Actor 出现故障,就宣告整个任务失败。否则,在更多的场景中,应该使用 OneForOneStrategy 策略。当然了,OneForOneStrategy 策略也是 Akka 的默认策略。

在一个指定的策略中,我们可以对 Actor 的失败情况进行相应的处理,比如当失败时,我们可以无视这个错误,继续执行 Actor。或者可以重启 Actor,甚至可以让 Actor 彻底停止工作。要指定这些监督行为,只要构造一个自定义的监督策略即可。
下面让我们简单看一下 SupervisorStrategy 的使用和设置。首先,需要定义一个父 Actor,把它作为所有子 Actor 的监督者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Supervisor extends UntypedActor {
private static SupervisorStrategy strategy = new OneForOneStrategy(3,
Duration.create(1, TimeUnit.MINUTES),
new Fuction<Throwable, Directive>() {
@Override
public Directive apply(Throwable t) { //line:6
if(t instanceof ArithmeticException) {
System.out.println("meet ArithmeticException, just resume");
return SupervisorStrategy.resume();
} else if(t instanceof NullPointerException) {
System.out.println("meet NullPointerException,restart");
return SupervisorStrategy.restart();
} else if(t instanceof IllegalArgumentException) {
return SupervisorStrategy.stop();
} else {
return SupervisorStrategy.escalate();
}
}
}); //line:19

@Override
public SupervisorStrategy supervisorStrategy() { //line:22
return strategy;
}
public void onReceive(Object o) {
if(o instanceof Props) {
getContext().actorOf((Props) o, "restartActor"); //line:27
} else {
unhandled(o);
}
}
}

上述代码第 2~19 行定义了一个 OneForOneStrategy 策略的监督。在这个监督策略中,运行 Actor 遇到错误后,在 1 分钟内进行 3 次重试。如果超过这个频率,就直接杀死 Actor。具体的策略由第 6~17 行定义。这里的含义是,当遇到 ArithmeticException 异常时(比如除以0),继续指定这个 Actor,不做任何处理(resume()函数)。当遇到空指针时,进行 Actor 重启(restart()函数)。如果遇到 IllegalArgumentException 异常,则直接停止 Actor(stop()函数)。对于没有涉及到的异常,则向上抛出,由顶层的 Actor 处理(escalate()函数)。
第 22 行覆盖了父类的 supervisorStrategy() 方法,设置使用自定义的监督策略。
第 27 行用来新建一个名为 restartActor 的子 Actor,这个子 Actor 就由当前的 Supervisor 进行监督了。当 Supervisor 接收一个 Props 对象时,就会根据这个 Props 对象配置生成一个 restartActor。
RestartActor 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class RestartActor extends UntypedActor {
public enum Msg {
DONE, RESTART
}
@Override
public void preStart() {
System.out.println("preStart hashcode:" + this.hashcode());
}
@Override
public void postStop() {
System.out.println("postStop hashcode:" + this.hashcode());
}
@Override
public void postRestart(Throwable reason) throws Exception {
super.postRestart(reason);
System.out.println("postRestart hashcode:" + this.hashcode());
}
@Override
public void preRestart(Throwable reason, Option opt) throws Exception {
System.out.println("preRestart hashcode:" + this.hashcode());
}
@Override
public void onReceive(Object msg) {
if(msg == Msg.DONE) {
getContext().stop(getSelf());
} else if(msg == Msg.RESTART) {
System.out.println(((Object)null).toString()); //line:27
//抛出异常,默认会被restart,但这里会resume
double a = 0 / 0;
}
unhandled(msg);
}
}

上述代码中,定义了一些 Actor 生命周期的回调接口,目的是更好的观察 Actor 的活动情况。第 27~29 行模拟了一些异常情况,第 27 行会跑出 NullPointerException,而第 29 行因为除以零,所以会抛出 ArithmeticException。
主函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static void customStrategy(ActorSystem system) {
ActorRef a = system.actorOf(Props.create(Supervisor.class), "Supervisor");
a.tell(Props.create(RestartActor.class), ActorRef.noSender());
ActorSelection sel = system.actorSelection("akka://lifecycle/user/Supervisor/restartActor");
for(int i=0; i<100; i++) {
sel.tell(RestartActor.Msg.RESTART, ActorRef.noSender());
}
}
public static void main(String[] args) {
ActorSystem system = ActorSystem.create("lifecycle", ConfigFactory.load("lifecycle.conf"));
customStrategy(system);
}

上述代码中,创建了全局的 ActorSystem,接着在 customStrategy() 函数中创建了 Supervisor Actor,并且对 Supervisor 发送了一个 RestartActor 的 Props 对象(第3行,这个消息会使得Supervisor创建RestartActor)。
接着,选中 RestartActor 实例(第4行),向它发送 100 条 RESTART 消息,这会使得 RestartActor 抛出 NullPointerException。
最后,从程序的输出可以看出,Actor 重启后 HashCode 发生了变化,说明重启后会生成一个新的 Actor 实例,原有的实例因为重启而被回收,新的实例代替原有实例继续工作。重启前调用旧实例的 preRestart() 方法,重启后调用的是新实例的 postRestart() 方法。Actor 重启后的 preStart() 方法,也是在 postRestart() 方法中调用的(Actor父类的postRestart()方法会调用preStart()方法)。
经过 3 次重启后,超过了监督策略中单位时间内的重试上线,因此系统不再进行尝试,而是直接关闭 RestartActor。

7.6 选择 Actor

在一个 ActorSystem 中,可能存在大量的 Actor,如何才能有效的对大量 Actor 进行批量的管理和通信呢?Akka 为我们提供了一个 ActorSelection 类,用来进行批量消息发送。这里不再给出完整代码,示意代码如下:

1
2
3
4
5
for(int i=0; i<WORDER_COUNT; i++) {
workers.add(system.actorOf(Props.create(MyWorker.class,i), "worker_"+i));
}
ActorSelection selection = getContext().actorSelection("/user/worker_*");
selection.tell(5, getSelf());

上述代码先是一个 for 循环生成大量的 Actor,接着,我们给这些 worker 发送消息,通过 actorSelection() 方法提供的选择通配符,可以得到代表所有满足条件的 ActorSelection。最后,通过 ActorSelection 实例,便可以向所有 worker Actor 发送消息了。

7.7 消息收件箱 Inbox

所有 Actor 之间的通信都是通过消息来进行的,Akka 框架提供了一个名叫“收件箱”的组件,使用它可以方便的对 Actor 进行消息发送和接收,方便了应用程序和 Actor 之间的交互。
下面定义了当前示例中唯一的一个 Actor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyWorker extends UntypedActor {
private final LoggingAdapter log = Logging.getLogger(getContext().system(), this);
public static enum Msg {
WORKING, DONE, CLOSE;
}
@Override
public void onReceive(Object msg) {
if(msg == Msg.WORKING) {
log.info("I am working");
}
if(msg == Msg.DONE) {
log.info("Stop working");
}
if(msg == Msg.CLOSE) {
log.info("I will shutdown");
getSender().tell(Msg.CLOSE, getSelf());
getContext().stop(getSelf());
} else {
unhandled(msg);
}
}
}

上述代码中,MyWoker 会根据收到的消息打印自己的工作状态,当接收到 CLOSE 消息时,会关闭自己结束运行。
而在本例中,与这个 MyWorker Actor 交互的,并不是一个 Actor,而是一个邮箱 Inbox,Inbox 的使用很简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(String[] args) {
ActorSystem system = ActorSystem.create("inboxdemo", ConfigFactory.load("samplehello.conf"));
ActorRef worker = system.actorOf(Props.create(MyWorker.class), "worker");

final Inbox inbox = Inbox.create(system);
inbox.watch(worker);
inbox.send(worker, MyWorker.Msg.WORKING);
inbox.send(worker, MyWorker.Msg.DONE);
inbox.send(worker, MyWorker.Msg.CLOSE);

while(true) { //line:11
Object msg = inbox.receive(Duration.create(1, TimeUnit.SECOND));
if(msg == MyWorker.Msg.CLOSE) {
System.out.println("My Worker is Closing");
} else if(msg instanceof Terminated) {
System.out.println("My worker is dead");
system.shutdown(); //line:17
break;
} else {
System.out.println(msg);
} //line:21
}
}

上述代码第 5 行根据 ActorSystem 构造了一个与之绑定的邮箱 Inbox。然后使用 Inbox 监视 MyWorker Actor,这样就能在 MyWorker Actor 停止后得到一个消息通知。然后使用 Inbox 向 MyWoker 发送消息。
在第 11~21 行进行消息接收,如果发现 MyWorker 已经停止工作,则关闭整个 ActorSystem(第17行)。

7.8 消息路由

Akka 提供了非常灵活的消息发送机制。有时我们会使用一组 Actor 而不是一个 Actor 来提供一项服务,这一组 Actor 中所有的 Actor 都是对等的,也就是说任何一个 Actor 都能提供服务。这种情况下如何有效的找到合适的 Acotr 呢?或者说如何调度这些消息,才可以使负载更均衡的分配在这一组 Actor 中。
为了解决这个问题,Akka 使用了一个路由器组件(Router)来封装消息的调度。系统提供了几种实用的消息路由策略,比如,轮询选择 Actor 进行消息发送,随机消息发送,将消息发给最为空闲的 Actor,甚至是组内广播消息。
下面演示一下消息路由的使用方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class WatchActor extends UntypedActor {
private final LoggingAdapter log = Logging.getLogger(getContext().system(), this);
public Router router;
{
List<Routee> routees = new ArrayList<Routee>();
for(int i=0; i<5; i++) {
ActorRef worker = getContext().actorOf(Props.create(MyWorker.class), "worker_"+i);
getContext().watch(worker);
routees.add(new ActorRefRoutee(worker));
}
router = new Router(new RoundRobinRoutingLogic(), routees); //line:11
}
@Override
public void onReceive(Object msg) {
if(msg instanceof MyWorker.Msg) {
router.route(msg, getSender()); //line:16
} else if(msg instanceof Terminated) {
router = router.removeRoutee(((Terminated)msg).actor()); //line:18
System.out.println(((Terminated)msg).actor().path()
+" is closed,routees=" + router.routees().size());
if(router.routees().size()==0) {
System.out.println("Close system");
RouteMain.flag.send(false);
getContext().system().shutdown();
}
} else {
unhandled(msg);
}
}
}

上述代码中定义了 WatchActor。第 3 行是路由器组件 Router,在构造 Router 时,需要指定路由策略和一组被路由的 Actor(Routee),如第 11 行所示。这里使用了 RoundRobinRoutingLogic 路由策略,也就是对所有的 Routee 进行轮询消息发送。本例中,Routee 由 5 个 MyWorker Actor 组成。
当有消息需要传递给这 5 个 MyWorker 时,只需要将消息投递给这个 Router 即可(第16行)。Router 会根据消息路由策略进行消息投递。当有一个 MyWoker 停止工作时,还可以简单的将其从工作组中移除(第18行)。在这里,如果发现系统中没有可用的 Actor,就会直接关闭系统。
主函数比较简单,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RouteMain {
public void Agent<Boolean> flag = Agent.create(true, ExecutionContexts.global());
public static void main(String[] args) throws InterruptedException {
ActorSystem system = ActorSystem.create("route", ConfigFactory.load("samplehello.conf"));
ActorRef w = system.actorOf(Props.create(WatchActor.class), "watcher");
int i = 1;
while(flag.get()) {
w.tell(MyWorker.Msg.WORKING, ActorRef.noSender());
if(i%10==0) w.tell(MyWorker.Msg.CLOSE, ActorRef.noSender());
i++;
Thread.sleep(100);
}
}
}

上述代码向 WatchActor 发送大量消息,其中夹杂着几条关闭 Actor 的消息。这会使得 MyWorker Actor 逐一被关闭,最终程序退出。
WORKING 消息被轮流发送给这 5 个 worker。

7.9 Actor 的内置状态转换

很多场景下 Actor 的业务逻辑可能比较复杂,Actor 可能需要根据不同的状态对同一条消息做出不同的处理。Akka 已经考虑到这一点,一个 Actor 内部消息处理函数可以拥有多个不同的状态,在特定状态下,,可以对同一消息进行不同的处理,状态之间也可以任意切换。
现在我们模拟一个婴儿 Actor,假设婴儿拥有开心或者生气两种不同的状态。当你带他玩时他很开心,当你让他睡觉时他很生气。
这个简单场景中,我们给婴儿 Actor 发送睡觉和玩两种指令。
下面这个 Baby Actor 模拟了上述场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class BabyActor extends UntypedActor {
private final LoggingAdapter log = Logging.getLogger(getContext().system(), this);
public static enum Msg {
SLEEP, PLAY, CLOSE;
}
Procedure<Object> angry = new Procedure<Object>() {
@Override
public void apply(Object message) {
System.out.println("angryApply:" + message);
if(message == Msg.SLEEP) {
getSender().tell("I am already angry", getSelf());
System.out.println("I am already angry");
} else if(message == Msg.PLAY) {
System.out.println("I like palying");
getContext().become(happy);
}
}
};
Procedure<Object> happy = new Procedure<Object>() {
@Override
public void apply(Object message) {
System.out.println("happyApply:" + message);
if(message == Msg.PLAY) {
geSender().tell("I am already happy :-)", getSelf());
System.out.println("I am already happy :-)");
} else if(message == Msg.SLEEP) {
System.out.println("I don't want to sleep");
getContext().become(angry);
}
}
};
@Override
public void onReceive(Object msg) {
System.out.println("onReceive:" + msg);
if(msg == Msg.SLEEP) {
getContext().become(angry); //line:36
} else if(msg == Msg.PLAY) {
getContext().become(happy); //line:38
} else {
unhandled(msg);
}
}
}

上述代码使用 become() 方法切换 Actor 的状态(第36、38行),方法 become() 接收一个 Procedure 参数。Procedure 在这里可以表示一种 Actor 的状态,更重要的是它封装了在这种状态下的消息处理逻辑。
在这个 BabyActor 中定义了两个 Procedure,一个是 angry,一个是 happy。
在初始状态下,BabyActor 既不是生气状态也不是开心状态。因此 angry 和 happy 的处理函数都不会工作。当 BabyActor 接收到消息时,系统会调用 onReceive() 方法来处理这个消息。
当 onReceive() 函数处理 SLEEP 消息时,它会切换当前 Actor 的状态为 angry(第36行)。如果是 PLAY 消息,则切换状态为 happy。
一旦完成状态切换,当后续有新消息送达时,就不会再由 onReceive() 函数处理了。由于 angry 和 happy 本身就是消息处理函数,因此后续的消息就直接交由当前状态处理(angry或happy),从而很好的封装了 Actor 的多个不同处理逻辑。

7.10 询问模式:Actor 中的 Future

由于 Actor 是通过异步消息通信的,当你发送一个消息给一个 Actor 后,你通常只能等待 Actor 的返回。与同步方法不同,当你发送了异步消息之后,接受消息的 Actor 可能根本来不及处理你的消息,调用方就已经返回了。
这种模式与前面章节提到过的 Futur 模式非常像。不同之处只是在传统的异步调用中,我们进行的是函数调用,但在这里,我们发送了一条消息。
由于两者非常像,我们不禁会想,当我们需要有一个返回值时,Actor 是不是也应该给我们一个契约(Futur)呢?这样,就算当下我们没有办法立即获得 Actor 的处理结果,在将来,通过这个契约还是可以追踪到我们的请求的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import static akka.pattern.Patterns;

public class AskMain {
public static void main(String[] args) throws Exception {
ActorSystem system = ActorSystem.create("askdemo", ConfigFactory.load("samplehello.conf"));
ActorRef worder = system.actorOf(Props.create(MyWorder.class), "worder");
ActorRef printer = system.actorOf(Props.create(Printer.class), "printer");
system.actorOf(Props.create(WatchActor.class, worker), "watcher");

//等待 Future 返回
Future<Object> f = Patterns.ask(worker, 5, 1500); //line:11
int re = (int) Await.result(f, Duration.create(6, TimeUnit.SECONDS));
System.out.println("return:" + re);

//直接导向其他Actor,pipe不会等待
f = Patterns.ask(worker, 6, 1500); //line:16
Patterns.pipe(f, system.dispatcher()).to(printer);

worker.tell(PoisonPill.getInstance(), ActorRef.noSender());
}
}

上述代码给出了两处在 Actor 中使用 Future 的例子。
第 11 行使用 ask() 方法给 worker 发送消息,消息内容是 5,也就是说 worker 会收到一条 Integer 消息,值为 5。当 worker 接收到消息后,就可以进行计算处理,并且将结果返回给发送者。当然,这个处理可能需要一些时间。
Akka 提供的 Patterns 类的 ask() 方法不会等待 worker 处理完,会立即返回一个 Future 对象(第11行),但在第 12 行,我们使用 Await 等待 worker 的返回,并在第 13 行打印返回结果。
这里的 Await,间接的将一个异步调用转为了同步阻塞调用。这样虽然容易理解,但可能出现性能问题。另外一种更为有效的方法是使用 pipe() 函数。
代码第 16 行使用 ask() 方法再次询问 worker,并传递数值 6 给 worker,接着并不进行等待,而是使用 Patterns 类的 pipe() 函数将这个 Future 重定向到另外一个名为 printer 的 Actor 上。pipe() 函数不会阻塞程序,它立即返回。
这个 printer 的实现只是简单的输出数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void onReceive(Object msg) {
if(msg instanceof Integer) {
System.out.println("Printer:" + msg);
}
if(msg == Msg.DONE) {
log.info("Stop working");
}
if(msg == Msg.CLOSE) {
log.info("I will shutdown");
getSender().tell(Msg.CLOSE, getSelf());
getContext().stop(getSelf());
} else {
unhandled(msg);
}
}

上述就是 Printer Actor 的实现,它会通过 pipe() 方法得到 worker 的输出结果,并打印在控制台上。

在本例中,worker Actor 接收一个整数,计算它的平方并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void onReceive(Object msg) {
if(msg instanceof Integer) {
int i = (Integer) msg;
try {
Thread.sleep(1000);
} catch(InterruptedException e) {}
getSender().tell(i*i, getSelf());
}
if(msg == Msg.DONE) {
log.info("Stop working");
}
if(msg == Msg.CLOSE) {
log.info("I will shutdown");
getSender().tell(Msg.CLOSE, getSelf());
getContext().stop(getSelf());
} else {
unhandled(msg);
}
}

上述代码第 5~7 行模拟了一个耗时的调用,第 8 行计算给定数值的平方,并把它“告诉”请求者。

7.11 多个 Actor 同时修改数据:Agent

当多个 Actor 需要对同一个共享变量进行读写时,如何保证线程安全?
在 Akka 中,使用一种叫作 Agent 的组件来实现这个功能,一个 Agent 提供了对一个变量的异步更新。当一个 Actor 希望改变 Agent 的值时,它会向 Agent 下发一个动作(action)。当多个 Actor 同时改变 Agent 时,这些 action 会在 ExecutionContext 中被并发调度执行。在任意时刻,一个 Agent 最多只能执行一个 action,对于某一个线程来说,它执行 action 的顺序与它的发生顺序一致,但对于不同线程来说,这些 action 可能交织在一起。
Agent 的修改可以使用两种方法:send() 和 alter()。它们都可以向 Agent 发送一个修改动作。但 send() 方法没有返回值,而 alter() 返回一个 Future 对象,以便于跟踪 Agent 的执行。

下面我们模拟一个场景,有 10 个 Actor,它们一起对一个 Agent 执行累加操作,每个 Agent 累加 10000 次,那么最终 Agent 的值将是 100_000 次。如果 Actor 间的调度出现问题,这个值可能小于 100_000。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CounterActor extends UntypedActor {
Mapper addMapper = new Mapper<Integer, Integer>() {
@Override
public Integer apply(Integer i) {
return i+1;
}
};
@Override
public void onReceive(Object msg) {
if(msg instanceof Integer) {
for(int i=0; i<10000; i++) {
//我希望能够知道Future何时结束
Future<Integer> f = AgentDemo.counterAgent.alter(addMapper);
AgentDemo.futures.add(f); //line:14
}
getContext().stop(getSelf()); //line:16
} else {
unhandled(msg);
}
}
}

上述代码定义了一个累加的 Actor:CounterActor。第 2~7 行定义了累计动作 action addMapper。它的作用是对 Agent 的值进行修改,简单地加1。
在 CounterActor 的消息处理函数 onReceive() 中,对全局的 counterAgent 进行累加操作,alter() 方法指定了累加动作 addMapper。由于我们希望将来知道累加行为是否完成,因此在这里将返回的 Future 对象进行收集(第14行)。完成任务后,Actor 会自行退出(第16行)。
程序的主函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class AgentDemo {
public static Agent<Integer> counterAgent = Agent.create(0, ExecutionContexts.global());
static ConcurrentLinkedQueue<Future<Integer>> futures = new ConcurrentLinkedQueue<Future<Integer>>();

public static void main(String[] args) throws InterruptedException {
final ActorSystem system = ActorSystem.create("agentdemo",
ConfigFactory.load("samplehello.conf");
ActorRef[] counter = new ActorRef[10]; //line:8
for(int i=0; i<counter.length; i++) {
counter[i] = system.actorOf(Props.create(CounterActor.class), "counter_"+i);
}
final Inbox inbox = Inbox.create(system); //line:12
for(int i=0; i<counter.length; i++) {
inbox.send(counter[i], 1); //line:14
inbox.watch(counter[i]);
}

int closeCount = 0;
//等待所有Actor全部结束
while(true) { //line:20
Object msg = inbox.receive(Duration.create(1, TimeUnit.SECONDS));
if(msg instanceof Terminated) {
closeCount++;
if(closeCount == counter.length) {
break;
}
} else {
System.out.println(msg);
}
}
//等待所有的累加线程完成,因为它们是异步的
Futures.sequence(futures, system.dispatcher()).onComplete( //line:32
new OnComplete<Iterable<Integer>>() {
@Override
public void onComplete(Throwable arg0, Iterable<Integer> arg1) throws Throwable {
System.out.println("counterAgent=" + counterAgent.get());
system.shutdown();
}
}, system.dispathcer());
}
}

上述代码中,第 8~11 行创建了 10 个 CounterActor 对象。第 12~16 行使用 Inbox 与 CounterActor 进行通信。第 14 行的消息将会触发 CounterActor 进行累加操作。第 20 行的 while 循环等待 10 个 CounterActor 运行结束。执行完成后,我们就已经收集了所有的 Future。在第 32 行,将所有的 Future 进行串行组合(使用sequence()方法),构造了一个整体的 Future,并为它创建 onComplete() 回调函数。在所有的 Agent 操作执行完成后,onComplete() 方法就会被调用。
执行上述程序,将输出:

1
counterAgent=100000

7.12 像数据库一样操作内存数据:软件事务内存

在一些函数式编程语言中,支持一种叫作软件事务内存(STM)的技术。这里的事务和数据库中的事务非常类似,具有隔离性、原子性和一致性。与数据库事务不同的是,内存事务不具备持久性(很显然内存数据不会保存下来)。
很多场合,某一项工作需要由多个 Actor 协作完成。如果其中一个 Actor 处理失败,那么根据事务的原子性,其他 Actor 所进行的操作必须要回滚。下面看一个简单的案例。

假设有一个公司要发员工福利,公司账户里有100元。每次公司账户转给员工一笔钱,假设转账10元,那么公司账户应减去10元,同时员工账户增加10元。这两个操作必须同时完成,或者都不完成。
首先,看一下在主函数中如何启动一个内存事务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class STMDemo {
public static ActorRef company = null;
public static ActorRef employee = null;

public static void main(String[] args) throws Exception {
finan ActorSystem system = ActorSystem.create("transactionDemo",
ConfigFactory.load("samplehello.conf");
company = system.actorOf(Props.create(CompanyActor.class), "company");
employee = system.actorOf(Props.create(EmployeeActor.class), "employee");

Timeout timeout = new Timeout(1, TimeUnit.SECONDS);

for(int i=1; i<20; i++) {
company.tell(new Coordinated(i, timeout), ActorRef.noSender()); //line:13
Thread.sleep(200);
Integer companyCount = (Integer) Await.result(
ask(company,"GetCount",timeout), timeout.duration());
Integer employeeCount = (Integer) Await.result(
ask(employee,"GetCount",timeout), timeout.duration());

System.out.println("company count=" + companyCount);
System.out.println("employee count=" + employeeCount);
System.out.println("================");
}
}
}

上述代码中 CompanyActor 和 EmployeeActor 分别用于管理公司账户和雇员账户。在第 12~23 行中,我们尝试进行了 19 次汇款。
第 13 行新建一个 Coordinated 协调者,并且将这个协调者当作消息发送给 company。当 company 收到这个协调者消息后,自动成为这个事务的第一个成员。

下面是代表公司账户的 Actor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class CompanyActor extends UntypedActor {
private Ref.View<Integer> count = STM.newRef(100);

@Override
public void onReceive(Object msg) {
if(msg instanceof Coordinated) {
final Coordinated c = (Coordinated) msg;
final int downCount = (Integer) c.getMessage();
STMDemo.employee.tell(c.coordinate(downCount), getSelf()); //line:9
try {
c.atomic(new Runnable() {
@Override
public void run() {
if(count.get() < downCount) {
throw new RuntimeException("less than " + downCount);
}
STM.increment(count, -downCount);
}
});
} catch(Exception e) {
e.printStackTrace();
}
} else if("GetCount".equals(msg)) {
getSender().tell(count.get(), getSelf());
} else {
unhandled(msg);
}
}
}

在 CompanyActor 中,首先判断接收的 msg 是否是 Coordinated。如果是 Coordinated,则表示这是一个新事务的开始。第 9 行调用 Coordinated.coordinate() 方法,将 employee 加入当前事务中,这样这个事务就有两个参与者了。
第 11 行调用 Coordinated.atomic() 函数定义原子执行块作为这个事务的一部分。

作为转账接收方的雇员账户 Actor 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class EmployeeActor extends UntypedActor {
private Ref.View<Integer> count = STM.newRef(50);

@Override
public void onReceive(Object msg) {
if(msg instanceof Coordinated) {
final Coordinated c = (Coordinated) msg;
final int downCount = (Integer) c.getMessage();
try {
c.atomic(new Runnable() { //line:10
@Override
public void run() {
STM.increment(count, downCount);
}
});
} catch(Exception e) {
e.printStackTrace();
}
} else if("GetCount".equals(msg)) {
getSender().tell(count.get(), getSelf());
} else {
unhandled(msg);
}
}
}

上述代码,判断消息是否是 Coordinated,如果是,则当前 Actor 加入 Coordinated 指定的事务。第 10 行定义原子操作,在这个操作中将修改雇员账户余额。

两个 Actor 都加入同一个协调事务 Coordinated 中,因此当公司账户出现异常后,雇员账户的余额就会回滚。

8. 并行程序调试

并行程序调试要比串行程序调试复杂的多,但现在的 IDE 开发环境已经足够方便。
书里面本章介绍的是 Eclipse 的调试技巧,由于本人日常工作使用 IDEA,所以不重复介绍 Eclipse。

9. 多线程优化示例,Jetty核心代码分析

Jetty 是一个基于 Java 实现的 HTTP 服务器和 Servlet 容器。Jetty 是与 Tomcat 齐名的、使用广泛的 Java Web 容器之一。
本章从多线程优化角度切入,通过分析 Jetty 的核心代码,来一窥 Jetty 在高并发优化中所做的一些努力。这些编程技巧和软件设计方法也可以在工作实践中复用。

9.1 Jetty 简介与架构

整个 Jetty 的核心组件由 Server 和 Connector 两个组件构成,整个 Server 组件是基于 Handler 容器工作的,它类似 Tomcat 的 Container 容器。Jetty 中的 Connector 组件负责接收客户端的连接请求,并将请求分配给一个处理队列去执行。

下图展示了 Jetty 的总体架构:
image

其中 Server 可以说是整个 Jetty 的核心元素,大量工作都围绕 Server 展开。Server 与其他核心组件的关系,如下图所示:

image

Server 内维护着一组 Connector,每个 Connector 表示一个可用的服务,每一个客户端连接都是针对一个 Connector 发起的。Container 接口表示可以被 JMX 管理的对象。LifeCycle 接口定义了具有可管理生命周期的对象。Jetty 的所有组件的生命周期管理基于观察者模式,在 LifeCycle 接口中,又定义了 LifeCycle.Listener 接口作为观察者对象。

9.2 Jetty 服务器初始化

Jetty 的核心业务逻辑实现不在本书讨论范围内,本章着眼于 Jetty 在并发方面做出的努力和优化。本节讨论 Jetty 服务器初始化过程中和多线程相关的代码与实现。本书采用的是 Jetty 9.2.10 版本,虽然不同版本的代码实现有差异,但总体思想和架构是一致的。

9.2.1 初始化线程池

Jetty 服务器使用了自己实现的 QueuedThreadPool 线程池,“qtp”开头的线程就是它之内的线程。QueuedThreadPool 可以设置最大和最小线程数,以及线程空闲退出时间。默认最大线程数200,最小线程数8,线程空闲退出时间为 1 分钟。

与 JDK 自带线程池不同的是,QueuedThreadPool 没有核心线程数的概念,在不超过最大线程数的前提下,只要没有空闲线程处理新任务,就会立即开启新的线程。
类似于 JDK 自带线程池,QueuedThreadPool 依然将任务放在 BlockingQueue 中,不过是 Jetty 自己实现的 BlockingQueue —— BlockingArrayQueue。

JDK 自带的 BlockingQueue 有两个实现,ArrayBlockingQueue 和 LinkedBlockingQueue。ArrayBlockingQueue 类基于数组实现,适合做有界队列。LinkedBlockingQueue 类基于链表,适合做无界队列。
而 Jetty 自己实现的 BlockingArrayQueue 基于数组,但它既可以是有界的,也可以是无界动态扩展的。BlockingArrayQueue 有 3 个重要参数:初始大小、扩展增量、最大容量。当所需容量超过初始大小时,会以扩展增量来扩大容量。

1
Object[] elements = new Object[capacity + _growCapacity];

它的扩容机制比较保守,不像 Vector 那样成倍扩容,而是使用固定增量。
与 JDK 的 ArrayBlockingQueue 相比,BlockingArrayQueue 可以支持无限容量(Integer.MAX_VALUE),而且可以动态扩展。
与 JDK 的 LinkedBlockingQueue 相比,它采用数组形式,没有 next 指针,更节省内存。

与 ArrayBlockingQueue 类似,BlockingArrayQueue 也采用了环形数据结构。

9.2.2 初始化 ScheduledExecutorScheduler

在 Jetty 初始化过程中,为了实现任务调度,还要初始化 ScheduledExecutorScheduler。ScheduledExecutorScheduler 是对 JDK 线程池 ScheduledThreadPoolExecutor 的包装,通过适配器模式将 ScheduledThreadPoolExecutor 的接口适配到 Jetty 的框架体系中。

Java-Concurrent-19.jpg

Jetty 的 ScheduledExecutorScheduler 为 JDK 线程池 ScheduledThreadPoolExecutor 的包装,通过定制 ThreadFactory 将调度者线程实例暴露给 ScheduledExecutorScheduler,已完成线程管理操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
protected void doStart() throws Exception {
scheduler = new ScheduledThreadPoolExecutor(1, new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread thread = ScheduledExecutorScheduler.this.thread = new Thread(r,name);
thread.setDaemon(daemon);
thread.setContextClassLoader(classloader);
return thread;
}
});
scheduler.setRemoveOnCancelPolicy(true);
super.doStart();
}

上述代码在 ScheduledExecutorScheduler 中创建了 ScheduledThreadPoolExecutor。可以看到,该线程池只包含一个调度线程,并在第 6 行将其暴露给 ScheduledExecutorScheduler 进行管理。

9.2.3 初始化 ByteBufferPool

ByteBufferPool 是 Jetty 实现的一个 ByteBuffer 的对象池,作用是复用 ByteBuffer。复用 ByteBuffer 一定程度上会降低 GC 压力,同时由于不需要每次申请空间,也能节省对象创建的开销,特别是对于较难申请的直接内存,直接内存的申请比堆内存慢。

ByteBufferPool 的一个重要实现是 ArrayByteBufferPool。

1
public ArrayByteBufferPool(int minSize, int increment, int maxSize)

由于每次申请的 ByteBuffer 的大小是不一样的,因此 ByteBufferPool 和普通对象池不同,ByteBufferPool 中的对象并不等价,而是按照大小分类,并根据实际请求,返回一个合适的 ByteBuffer。ArrayByteBufferPool 的构造函数中,minSize 为最小的 ByteBuffer 大小,increment 表示 ByteBuffer 的增量,maxSize 为最大的大小。

ByteBufferPool 内部由一组 Bucket 组成,用于管理这些大小不一的 ByteBuffer。根据堆内存和直接内存,Bucket 分为两类:

1
2
3
4
5
6
7
8
9
10
11
12
_min = minSize;
_inc = increment;

_direct = new Bucket[maxSize/increment];
_indirect = new Bucket[maxSize/increment];

int size = 0;
for(int i=0; i<_direct.length; i++) {
size += _inc;
_direct[i] = new Bucket(size);
_indirect[i] = new Bucket(size);
}

由上述代码可看到,相同大小的 ByteBuffer 将由一个 Bucket 管理,不同大小的 ByteBuffer 存放在不同的 Bucket 中。
下图表示一个 ByteBufferPool,每个小方块表示其中的 Bucket,Bucket 中的是 ByteBuffer:

Java-Concurrent-20.jpg

Bucket 的实现,使用了高并发队列 ConcurrentLinkedQueue:

1
2
3
4
5
6
7
8
public static class Bucket {
public final int _size;
public final Queue<ByteBuffer> _queue = new ConcurrentLinkedQueue<>();

Bucket(int size) {
_size = size;
}
}

ByteBufferPool 初始化时,并不会预先申请 ByteBuffer 填充到对象池,而是在系统执行过程中对新建的 ByteBuffer 进行管理。也就是说,ByteBufferPool 既是一个对象池,也是一个工厂。
ByteBufferPool 的 acquire() 方法可以根据 ByteBuffer 的大小找到合适的 Bucket,如果 Bucket 中有合适的 ByteBuffer,那么直接返回 ByteBuffer,如果找不到则创建一个 ByteBuffer。
ByteBufferPool 在归还 ByteBuffer 时,如果找不到合适大小的 Bucket 则不进行归还。如果找到 Bucket 则重置 buffer,并且加入 Bucket 中。

ByteBufferPool 是一个设计巧妙,兼具工厂和对象池功能的内存管理组件,其设计意图是通过对内存的管理,减少 GC 压力,节省内存开销。

9.2.4 维护 ConnectionFactory

在初始化过程中, Jetty 还会维护一组 ConnectionFactory 对象,用来创建在一个 Connector 上的给定 Connection。每当服务器接收一个连接,Jetty 便会创建一个 Connection 对象来维护连接和相关数据。

9.2.5 计算 ServerConnector 的线程数量

在 ServerConnector 初始化过程中,Jetty 还要分配工作在 Accept 和 Select 上的线程数量。线程数量的计算是基于系统可用的 CPU 数量的,因此需要取得系统的 CPU 核心数。

1
int cores = Runtime.getRuntime().availableProcessors();

设置并分配 Accept 线程:

1
acceptors = Math.max(1, Math.min(4, cores/8));

可见 Accept 线程数量总是在 1 到 4 个之间,只有 32 核以上才会超过 4。
接着还会计算 Selector 的线程数量:

1
Math.max(1, Math.min(4, Runtime.getRuntime().availabeProcessors()/2))

Selector 的线程数量由 ServerConnectorManager 维护,它维护了一组 ManagedSelector,每次 ManagedSelector 表示一个 Selector 线程,即在此确定了 ManagedSelector 的数量。

9.3 启动 Jetty 服务器

初始化完成后,Jetty 服务器进入启动阶段。
Jetty 中的各个组件的生命周期都是通过 LifeCycle 接口管理的。Server 本身也是这个接口的实现。

9.3.1 设置启动状态

LifeCycle 接口有一个抽象类实现 AbstractLifeCycle,用以维护对象的基本运行状态。
下面代码显示了 start 阶段的状态变迁和回调:

1
2
3
4
5
6
7
private void setStarting() {
if(LOG.isDebugEnabled())
LOG.debug("starting {}", this);
_state = _STARTING;
for(Listener listener : _listeners)
listener.lifeCycleStarting(this);
}

9.3.2 注册 ShutDownMonitor

在启动过程中,Jetty 服务器会注册 ShutDownMonitor 服务用以接收远程关闭命令。ShutDownMonitor 是一个典型的单例模式,使用 Java 内部类实现。在 ShutDownMonitor 中,将开启一个本地端口用于监听系统关闭消息。
ShutDownMonitor 内部维护了一组 LifeCycle 对象,并使用独立的线程监听给定的本地端口。一旦收到指令,则通知这些 LifeCycle 对象(比如Server)退出系统。

9.3.3 计算系统的线程数量

在启动过程中,Jetty 会取得初始化过程中创建的 QueuedThreadPool,同时计算所需的 Acceptor 线程和 Selector 线程的总数。
这里将在初始化阶段已经计算好的线程数量进行合计,得到最终所需的线程数,保存到一个 needed 变量。
Jetty 中的 Acceptor 和 Selector 线程都是向 QueuedThreadPool 申请的,因此所需线程总数 needed 不能超过 QueuedThreadPool 的最大线程数,否则系统无法正常启动。默认情况下, QueuedThreadPool 支持的最大线程数为 200。

9.3.4 启动 QueuedThreadPool

如果前面的启动过程一切正常,那么 Jetty 将会启动 QueuedThreadPool,也就是 Jetty 的核心业务线程池。该线程池启动时,会创建 _minThreads 个线程(默认8个),且线程名以“qtp”开头。
QueuedThreadPool 启动初期,并不会有任务提交到线程池,因此都处于空闲状态。

9.3.5 启动 Connector

Connector 也是 Server 的核心,代表一个服务端的端口,ServerConnector 是该接口的一个重要实现,也是最重要的一个 Connector,本节无特殊说明提到的 Connector 都表示 ServerConnector。
启动 Connector 才真正代表 Jetty 可以对外提供服务。首先 Connector 要开启所有的 Selector 线程。

1
2
3
4
5
6
for(int i=0; i<_selectors.length; i++) {
ManagedSelector selector = newSelector(i);
_selectors[i] = selector;
selector.start();
execute(new NonBlockingThread(selector));
}

上述代码第 6 行将 selector 任务提交到 QueuedThreadPool 线程池。
Jetty 中每一个 Selector 线程都被封装为 ManagedSelector 进行管理,ManagedSelector 是一个 Runnable 接口的实现,最终被提交到 QueuedThreadPool 线程池执行。
在 Selector 线程启动完成后,Jetty 会尝试创建 Acceptor 线程。根据系统初始化时的设置,如果 Acceptor 线程数量大于 1,则会创建并管理相关线程。
Acceptor 线程被提交启动后,首先修改线程名字以标识其作用。然后会设置线程优先级,并将当前线程向 _acceptor 数组注册,最终在服务器端口上等待客户端连接,accept 时默认 channel 是阻塞的,但如果初始化过程中得到的 Acceptor 线程数量为 0,那么 channel 就会设置为非阻塞模式。
Connector 启动完成后,根据 AbstractLifeCycle 的统一管理,会设置 Connector 的内部状态为 STARTED,并发送 Started 事件通知给相关 Listener。

9.4 处理 HTTP 请求

当 Jetty 启动完成后,便会监听在服务端口等待客户端请求,当请求到达时进行处理。请求的处理从 Acceptor 线程开始,先由 Acceptor 线程接受用户请求,并选择合适的 Selector 线程进行处理。

9.4.1 Accept 成功

在 ServerConnector 中,当 Accept 成功后,便会着手处理这个客户端连接。

1
2
3
4
5
6
private void accepted(SocketChannel channel) throws IOException {
channel.configureBlocking(false);
Socket socket = channel.socket();
configure(socket);
_manager.accept(channel);
}

上述代码首先将对应的 channel 设置为非阻塞模式,然后配置 socket 相关的属性,最后,使用 SelectorManager 进行处理。
在 SelectorManager 中,会选择合适的 ManagedSelector 进行处理,分发的标准是尽量高效的将请求均匀分到所有的 ManagedSelector 中,如下所示:

1
2
3
4
5
private ManagedSelector chooseSelector() {
long s = _selectorIndex++;
int index = (int) (s % getSelectorCount());
return _selectors[index];
}

上述代码会在多个线程中被调用,它轮询选择一个可用的 Selector 线程。_selectorIndex 不是一个线程安全的原子操作,之所以不坚持使用一个安全的变量,是因为这里只需要 _selectorIndex 发生变化即可,并不要求计算的精确性。
在多线程程序设计中,这是一个非常重要的思想。我们必须深刻理解到:线程安全永远是相对于应用的,如果应用本身不要求精确的线程安全,那么力求绝对的线程安全就是一种资源浪费。因为几乎所有的线程安全的数据结构性能都大大低于非线程安全的数据结构的性能。
虽然 AtomicLong 比阻塞锁拥有更好的性能,但和原生的 long 相比依然慢了很多。

当得到所需的 Selector 线程后,将对应的任务提交到 ManagedSelector 中。

1
2
3
4
public void accept(SocketChannel channel, Object attachment) {
final ManagedSelector selector = chooseSelector();
selector.submit(selector.new Accept(channel, attachment));
}

下面是 Selector 线程的 submit 方法的一个片段,可以看到在 ManagedSelector 中维护了一个 _changes 任务集合,有关 _changes 的相关介绍将在下一节展开。

1
2
3
public void submit(Runnable change) {
_changes.offer(change);
}

9.4.2 请求处理

当任务被提交到 ManagedSelector 后,ManagedSelector 便开始处理提交的任务。在 ManagedSelector 中,使用一个特殊的 ConcurrentArrayQueue 队列来保存这些任务。

1
private final Queue<Runnable> _changes = new ConcurrentArrayQueue<>();

在 _changes 中,保存着需要当前 Selector 处理的所有任务,ConcurrentArrayQueue 并非 JDK 自带的高并发集合,而是由 Jetty 独立实现的。

ConcurrentArrayQueue 与 JDK 的 ConcurrentLinkedQueue 非常类似,也是一个无界队列,并且也是对高并发相当友好的一种实现。与 ConcurrentLinkedQueue 不同的是,ConcurrentArrayQueue 使用连续数组作为其内部结构,因此 ConcurrentArrayQueue 并不需要将存储元素封装为 Node,其所需的内存空间也将小于 ConcurrentLinkedQueue,是一种对 GC 更为友好的算法。
ConcurrentArrayQueue 内部使用若干个 Block 存储数据,一个 Block 为一个数组,两个 Block 之间以链表的方式连接。当已有 Block 都满载时,ConcurrentArrayQueue 会新建 Block 存储数据以达到动态扩展的目的。
此外,ConcurrentArrayQueue 的实现还有两个特点,第一,它充分考虑了伪共享的问题,对于常用的头部索引和尾部索引都使用一定量的填充,以确保这两个变量处于单独的缓存行中;第二,ConcurrentArrayQueue 大量使用 CAS 操作,完全在应用层处理线程冲突,是一种典型的无锁实现,因此对高并发特别友好。

当 ManagedSelector 从 _changes 中得到请求时,它并不会在 ManagedSelector 线程中具体处理该请求,而是将这个任务再次提交到 QueueThreadPool,由其他空闲线程处理,从而避免 Selector 线程产生阻塞。