操作系统的引入
之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于以下原因:
- 资源利用率
- 在某些情况下,程序必须等待某个外部操作执行完成,例如输入操作或输出操作等,而在等待时程序无法执行其他任何工作。因此,如果在等待的同时可以运行另一个程序,那么无疑将提高资源的利用率。
- 公平性
- 不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源,而不是由一个程序从头运行到尾,然后再启动下一个程序。
- 便利性
- 通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。
线程
- 进程:
- 正在运行的程序,是系统进行资源分配和调用的独立(基本)单位。
- 每一个进程都有它自己的内存空间和系统资源。
- 线程:
- 是进程中的单个顺序控制流,是一条执行路径,程序使用CPU的基本单位
- 一个进程如果只有一条执行路径,则称为单线程程序。
- 一个进程如果有多条执行路径,则称为多线程程序。
对于单核计算机来讲,游戏进程和音乐进程是同时运行吗
不是。
因为CPU在某个时间点上只能做一件事情,计算机是在游戏进程和音乐进程间做着频繁切换,且切换速度很快,所以,我们感觉游戏和音乐在同时进行,其实并不是同时执行的。
多进程的作用不是提高执行速度,而是提高CPU的使用率。
多进程有什么意义
能在同一个时间段
内执行多个任务。提高CPU的使用率。
多线程有什么意义
多线程的作用不是提高执行速度,而是为了提高应用程序的使用率。(程序抢占CPU时间片几率更大)
而多线程却给了我们一个错觉:让我们认为多个线程是并发执行的。其实不是。
因为多个线程共享同一个进程的资源(堆内存和方法区),但是栈内存是独立的,一个线程一个栈。所以他们仍然是在抢CPU的资源执行。一个时间点上只有能有一个线程执行。而且谁抢到,这个不一定,所以,造成了线程运行的随机性。
什么是并发
大家注意两个词汇的区别:并行和并发。
前者是逻辑上同时发生,指在某一个时间内同时运行多个程序。
后者是物理上同时发生,指在某一个时间点同时运行多个程序。
那么,我们能不能实现真正意义上的并发呢,是可以的,多个CPU就可以实现,不过你得知道如何调度和控制它们。
JVM虚拟机的启动是单线程的还是多线程的?
JVM启动至少启动了垃圾回收线程和主线程,所以是多线程的。
多线程的实现方案
继承Thread类
步骤
- 自定义类MyThread继承Thread类。
- MyThread类里面重写run()
- 创建对象
- 启动线程
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("thread running");
}
}
线程能不能多次启动
// MyThread my = new MyThread();
// my.start();
// // IllegalThreadStateException:非法的线程状态异常
// // 为什么呢?因为这个相当于是my线程被调用了两次。而不是两个线程启动。
// my.start();
// 创建两个线程对象
MyThread my1 = new MyThread();
MyThread my2 = new MyThread();
my1.start();
my2.start();
实现Runnable接口
步骤
- 自定义类MyRunnable实现Runnable接口
- 重写run()方法
- 创建MyRunnable类的对象
- 创建Thread类的对象,并把C步骤的对象作为构造参数传递
public class MyRunnable implements Runnable {
@Override
public void run() {
for (int x = 0; x < 100; x++) {
// 由于实现接口的方式就不能直接使用Thread类的方法了,但是可以间接的使用
System.out.println(Thread.currentThread().getName() + ":" + x);
}
}
}
MyRunnable my = new MyRunnable();
// 创建Thread类的对象,并把C步骤的对象作为构造参数传递
// Thread(Runnable target)
// Thread t1 = new Thread(my);
// Thread t2 = new Thread(my);
// t1.setName("林青霞");
// t2.setName("刘意");
// Thread(Runnable target, String name)
Thread t1 = new Thread(my, "林青霞");
Thread t2 = new Thread(my, "刘意");
t1.start();
t2.start()
如何获取和设置线程名称
通过Thread.currentThread()获取当前线程后获取或设置线程名
实现接口方式的好处
- 可以避免由于Java单继承带来的局限性。
- 适合多个相同程序的代码去处理同一个资源的情况,把线程同程序的代码,数据有效分离,较好的体现了面向对象的设计思想。
多线程两种方式比较及区别
查看Thread类的run()方法的源代码,可以看到其实这两种方式都是在调用Thread对象的run方法,如果Thread类的run方法没有被覆盖,并且为该Thread对象设置了一个Runnable对象,该run方法会调用Runnable对象的run方法。
// Thread.java
private Runnable target;
public void run() {
if (target != null) {
target.run();
}
}
如果在Thread子类覆盖的run方法中编写了运行代码,也为Thread子类对象传递了一个Runnable对象,那么,线程运行时的执行代码是子类的run方法的代码?还是Runnable对象的run方法的代码?
子类的
实现Callable接口或者Runable接口(和线程池结合)
- 好处:
- 可以有返回值
- 可以抛出异常
线程调度
- 分时调度模型
- 所有线程轮流使用 CPU 的使用权,平均分配每个线程占用 CPU 的时间片
- 抢占式调度模型
- 优先让优先级高的线程使用 CPU,如果线程的优先级相同,那么会随机选择一个,优先级高的线程获取的 CPU 时间片相对多一些。
Java使用的是抢占式调度模型。
// 设置getPriority()和获取setPriority()线程优先级 默认是5 范围是1-10
// 线程优先级高仅仅表示线程获取的 CPU时间片的几率高
// 但是要在次数比较多,或者多次运行的时候才能看到比较好的效果。
Thread thread = Thread.currentThread();
System.out.println(thread.getPriority());// 5
线程操作
- 线程休眠
- public static void sleep(long millis)
- 线程加入(等待某线程执行结束后才继续执行)
- public final void join()
ThreadJoin tj1 = new ThreadJoin();
ThreadJoin tj2 = new ThreadJoin();
ThreadJoin tj3 = new ThreadJoin();
tj1.setName("李渊");
tj2.setName("李世民");
tj3.setName("李元霸");
tj1.start();
try {
tj1.join();//tj1执行结束后继续执行下面的start()语句
} catch (InterruptedException e) {
e.printStackTrace();
}
tj2.start();
tj3.start();
- 线程礼让(暂停当前正在执行的线程对象,并执行其他线程,让多个线程的执行更和谐,但是不能靠它保证一人一次。)
- public static void yield()
- 后台线程(守护线程,主进程结束后守护进程也结束)
- public final void setDaemon(boolean on)
- 中断线程
- public final void stop() 强制中断
- public void interrupt() 中断线程。 把线程的状态终止,并抛出一个InterruptedException。
- 获取线程名
Thread myThread = Thread.currentThread();
String name = myThread.getName();//通过setName()方法设置线程名
System.out.println(name);//main
sleep()和wait()的区别
- sleep() 必须指时间;不释放锁。
- wait() 可以不指定时间,也可以指定时间;释放锁。
线程生命周期
新建 – 就绪 – 运行 – 死亡
新建 – 就绪 – 运行 – 阻塞 – 就绪 – 运行 – 死亡
线程安全性
安全性
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
- 在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。
- 无状态对象一定是线程安全的。
原子性
竞态条件
在并发编程中,由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(Race Condition)。
最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。
“先检查后执行”:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。
使用“先检查后执行”的一种常见情况就是延迟初始化。
复合操作
复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。
在实际情况中,应尽可能地使用现有的线程安全对象(例如AcomicLong)来管理类的状态。 与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
或者使用下面的加锁机制
加锁机制
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。
线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。
获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
用锁来保护状态
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
- 每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
- 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。
活跃性与性能
- 我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁。
- 同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源的死锁。
在安全性和活跃性之间通常存在着某种制衡。
- 当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。
- 当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。
死锁
多个线程由于存在环路的锁依赖关系而永远等待下去。
在数据库系统的设中考虑了检测死锁以及从死锁中恢复。检测到死锁时,将选择一个牺牲者并放弃这个任务
锁顺序死锁
两个线程试图以不同的顺序来获得相同的锁。
如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。
动态的锁顺序死锁
所有的线程似乎都是按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给transferMoney的参数顺序,而这些参数顺序又取决于外部输入。
如何检查这种死锁——查看是否存在嵌套的锁获取操作。由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中按照这个顺序来获取锁。
在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又会引入死锁。为了避免这种情况,可以使用“加时赛”锁。
如果在Account中包含一个唯一的,不可变的并且具备可比性的键值,例如账号,那么要制定锁的顺序就更加容易了,通过键值对对象进行排序,因而不需要使用“加时赛”锁。
public class InduceLockOrder {
private static final Object tieLock = new Object();
public void transferMoney(final Account fromAcct,
final Account toAcct,
final DollarAmount amount)
throws InsufficientFundsException {
class Helper {
public void transfer() throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
} else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
}
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
if (fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
} else if (fromHash > toHash) {
synchronized (toAcct) {
synchronized (fromAcct) {
new Helper().transfer();
}
}
} else {
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
}
}
}
interface DollarAmount extends Comparable<DollarAmount> {}
interface Account {
void debit(DollarAmount d);
void credit(DollarAmount d);
DollarAmount getBalance();
int getAcctNo();
}
class InsufficientFundsException extends Exception {}
}
在协作对象之间发生的死锁
如果在持有锁的情况下调用某个外部方法,那么就需要警惕死锁。
如果在持有锁时调用某个外部方法,那么将出现活跃性问题。 在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
因为setLocation和notifyAvailable都是同步方法,因此调用setLocation的线程将首先获取Taxi的锁,然后获取Dispatcher的锁。 同样,调用getImage的线程将首先获取Dispatcher的锁,然后再获取每一个Taxi的锁(每次获取一个)。 这与LeftRightDeadlock中的情况相同,两个线程按照不同的顺序来获取两个锁,因此就可能产生死锁。
public class CooperatingDeadlock {
// Warning: deadlock-prone!
class Taxi {
private final Dispatcher dispatcher;
@GuardedBy("this")
private Point location, destination;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination)) {
dispatcher.notifyAvailable(this);
}
}
public synchronized Point getDestination() {
return destination;
}
public synchronized void setDestination(Point destination) {
this.destination = destination;
}
}
class Dispatcher {
@GuardedBy("this")
private final Set<Taxi> taxis;
@GuardedBy("this")
private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public synchronized Image getImage() {
Image image = new Image();
for (Taxi t : taxis) {
image.drawMarker(t.getLocation());
}
return image;
}
}
class Image {
public void drawMarker(Point p) {
}
}
}
开放调用
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call)。
依赖于开放调用的类通常能表现出更好的行为,并且在与那些在调用方法时需要持有锁的类相比,也更易于编写。通过尽可能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁。
这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:
虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易地多。
同理,分析一个完全依赖于开发调用的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能地使用开放调用,将更需要于找出那些需要获得多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁。
在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。
// 开放调用需要使代码块仅被用于保护那些涉及共享状态的操作
class CooperatingNoDeadlock {
@ThreadSafe
class Taxi {
private final Dispatcher dispatcher;
@GuardedBy("this")
private Point location, destination;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
boolean reachedDestination;
synchronized (this) {
this.location = location;
reachedDestination = location.equals(destination);
}
if (reachedDestination) {
dispatcher.notifyAvailable(this);
}
}
public synchronized Point getDestination() {
return destination;
}
public synchronized void setDestination(Point destination) {
this.destination = destination;
}
}
@ThreadSafe
class Dispatcher {
@GuardedBy("this")
private final Set<Taxi> taxis;
@GuardedBy("this")
private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public Image getImage() {
Set<Taxi> copy;
synchronized (this) {
copy = new HashSet<Taxi>(taxis);
}
Image image = new Image();
for (Taxi t : copy) {
image.drawMarker(t.getLocation());
}
return image;
}
}
class Image {
public void drawMarker(Point p) {
}
}
}
注意事项:有时候,在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变为非原子操作。在许多情况下,使某个操作失去原子性是可以接受的。
Tips:解决原子性引发的错误,可以在将服务的状态更新为”关闭“之前一直持有锁,依赖于构造的一些协议(而不是通过加锁)来防止其他线程进入代码的临界区。
资源死锁
正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
另一种基于资源的死锁形式就是线程饥饿死锁,参考《饥饿死锁》部分。
死锁的避免与诊断
尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些文档。
在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:
首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。
尽可能地使用开放调用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。
支持定时的锁
显式使用Lock类中的定时tryLock功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制权。
其他活跃性危险
饥饿
当线程无法访问它所需要的资源而不能继续执行时,就会发生。引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者持有锁时执行一些无法结束的结构,那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级。
糟糕的响应性
CPU密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争CPU的时钟周期。
不良的锁管理也可能导致糟糕的相应性。如果某个线程长时间占有一个锁,而其他想要访问这个容器的线程就必须等待很长时间。
活锁
活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断执行相同的操作,而且总会失败。
活锁通常发生在处理事务消息的应用程序中:
- 如果不能成功地处理某个消息,那么消息处理机制将会回滚整个事务,并将它重新放到队列的开头。
- 如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到错误的处理器时,都会发生事务回滚。
- 由于这条消息又被放到队列开头,因此处理器将被反复调用,并返回相同的结果。
虽然处理消息的线程并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码导致,因为它错误地将不可修复的错误作为可修复的错误。
当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。
要解决这种活锁问题,需要在重试机制中引入随机性。在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。
对性能的思考
性能与可伸缩性
可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力响应地增加。
当进行性能调优时,其目的通常是用更小的代价完成相同的工作,在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多地计算资源来完成更多的工作。
评估各种性能权衡因素
避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它还运行得不够快。
以测试为基准,不要猜测。
- “更快”的含义是什么?
- 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
- 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
- 在其他不同条件的环境中能否使用这里的代码?
- 在实现这种性能提升时需要付出哪些隐含地代价,例如增加开发风险或维护开销?这种权衡是否合适?
Amdahl定律
Amdahl定律描述的是:
在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决与程序中可并行组件与串行组件所占的比重。 假定F时必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:
在串行部分所占不同比例下的最高利用率:
在所有并发程序中都包含一些串行部分。
多个线程反复地从一个共享Queue中取出元素进行处理,处理步骤只需执行线程本地的计算。 如果某个线程发现队列为空,那么它将把一组新元素放入队列,因而其他线程在下一次访问时不会没有元素可供处理。
降低锁粒度的两种技术:
锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)。
当通过Amdahl定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。 锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。 当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。
线程引入的开销
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保护数据结构的一致性。在多个线程的调度和协调过程总都需要一定的性能开销:
对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。
另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。
这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的上下文设置为当前上下文。
当上下文切换的开销并不只是包含JVM和操作系统的开销,当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。
这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:
在大多数通用的处理器中,上下文切换的开销相当于5000~10000个时钟周期,也就是几微秒。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。
如果线程频繁发生阻塞,那么它们将无法使用完整的调度时间片。
在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文交换,从而增加调度开销,并因此而降低吞吐量。
UNIX系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在内核中执行时间所占比例等信息。
如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由I/O或竞争锁导致的阻塞引起的。
内存同步
同步操作的性能开销包括多个方面。 在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。 内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓存,以及停止执行管道。 内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的)。
一个“快速通道”的非竞争同步将消耗20~250个时钟周期。虽然无竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微。
现代的JVM能够通过优化去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。
synchronized (new Object()) {
// 执行一些操作...
}
一些更完备的JVM能通过逸出分析来找出不会发布到堆的本地变量引用(因此这个引用是线程本地的)。
即使不进行逸出分析,编译器也可以执行锁粒度粗化,操作,即将邻近的同步代码块用同一个锁合并起来。这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而可能实现进一步的优化。
public String getStoogeNames() {
// JVM通常会分析这些调用,从而使stooges及其内部状态不会逸出,因此可以去掉这4次锁获取操作。
List<String> stooges = new Vector<String>();//Vector是线程安全的
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。 因此,我们应该将优化重点放在那些发生锁竞争的地方。
某个线程的同步可能会影响其他线程的性能。同步会增加共享总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
阻塞
非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。
JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。
这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。
- 如果等待的时间较短,则适合采用自旋等待方式
- 而如果等待的时间较长,则适合采用线程挂起方式
有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。
减少锁的竞争
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。
如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重的影响。
然而,如果在锁上的请求量很高,那么需要获取该锁的线程被阻塞并等待。
在极端的情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有3种方式可以降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁,这些机制允许更高的并发性
缩小锁的范围(“快进快出“)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。
例如,可以将一些与锁无关的代码移除同步代码块,尤其是那些开销比较大的曹组,以及可能被阻塞的操作,例如I/O操作。
根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少
public synchronized boolean userLocationMatches(String name, String regexp) {
String key = "users." + name + ".location";
String location = attributes.get(key);
if (location == null) {
return false;
} else {
return Pattern.matches(regexp, location);
}
}
public boolean userLocationMatches(String name, String regexp) {
String key = "users." + name + ".location";
String location;
// 降低锁的范围,可以通过线程安全性委托给并发库中的类进一步提升性能
synchronized (this) {
location = attributes.get(key);
}
if (location == null) {
return false;
} else {
return Pattern.matches(regexp, location);
}
}
尽管缩小同步代码块能提高可伸缩性,但同步代码快也不能过小——一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。
此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。
在分解同步代码块时,理想的平衡点将与平台相关(虚拟机执行锁力度粗化操作,可能将分解的同步块重新合并起来),在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。
减小锁的粒度
另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。
这可以通过锁分解和锁分段技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。
这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也越高。
锁分解
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
- 如果在锁上存在适中而不是激烈的竞争,通过将一个锁分解为两个锁,能最大限度地提升性能。
- 对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。
- 如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点值。
@ThreadSafe
public class ServerStatusBeforeSplit {
@GuardedBy("this")
public final Set<String> users;
@GuardedBy("this")
public final Set<String> queries;
public ServerStatusBeforeSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
public synchronized void addUser(String u) {
users.add(u);
}
public synchronized void addQuery(String q) {
queries.add(q);
}
public synchronized void removeUser(String u) {
users.remove(u);
}
public synchronized void removeQuery(String q) {
queries.remove(q);
}
}
// 每个状态由自己的锁保护
// 还可以将Set委托给线程安全的Set,继续分解锁粒度
@ThreadSafe
public class ServerStatusAfterSplit {
@GuardedBy("users")
public final Set<String> users;
@GuardedBy("queries")
public final Set<String> queries;
public ServerStatusAfterSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
public void removeUser(String u) {
synchronized (users) {
users.remove(u);
}
}
public void removeQuery(String q) {
synchronized (users) {
queries.remove(q);
}
}
}
锁分段
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。
在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。
假设散列函数具有合理的分布性,并且关键字能够均匀分布,那么这大约能把对于锁的请求减少到原来的1/16,正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。
要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。
锁分段的一个劣势在于:
与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。 通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器。
当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段锁集合中的所有锁(要获取内置锁的一个集合,唯一的方式是递归)。
如果程序采用锁分段或分解技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。
@ThreadSafe
public class StripedMap {
// Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++) {
locks[i] = new Object();
}
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next) {
if (m.key.equals(key)) {
return m.value;
}
}
}
return null;
}
public void clear() {
// 非原子操作
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
private static class Node {
Node next;
Object key;
Object value;
}
}
线程安全的Map性能比较
在ConcurrentHashMap的实现中假设,大多数常用的操作都是获取某个已经存在的值,因此它对各种get操作进行了优化从而提供了最高的性能和并发性。
- 在同步Map的实现中,可伸缩性的最主要阻碍在于整个Map中只有一个锁,因此每次只有一个线程能够访问这个Map。
- 不同的是,ConcurrentHashMap对于大多数读操作并不会加锁,并且在写入操作以及其他一些需要锁的读取操作中使用了锁分段技术。因此,多个线程能并发地访问这个Map而不会发生阻塞。
不同Map实现的可伸缩性比较:
每次运行时,将有N个线程并发地执行一个紧凑的循环:选择一个随机的键值,并尝试获取这个键值相对应的值。 如果不存在对应的值,那么将这个值增加到Map的概率为p=0.6; 如果存在对应的值,那么将这个值删除的概率为p=0.02。
避免热点域
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面
一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些”热点域“,而这些热点域往往会限制可伸缩性。
当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。
一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在put和remove等方法中略微增加了一些开销,以确保计数器是最新的值,但这把size方法的开销从O(n)降低到O(1)。
使用一个独立的计算器能很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享的计数器。
即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。
一个看似性能优化的措施——缓存size操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。
为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护一个独立的计数,并通过每个分段的锁来维护这个值。
一些替代独占锁的方法
第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:
- 如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源
- 但在执行写入操作时必须以独占方式来获取锁
对于读取操作占多数的数据结构,ReadWriteLock能够提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。
原子变量提供了一种方式来降低更新“热点域”时的开销,例如竞态计数器、序列发生器、或者对链表数据结构中头节点的引用。
原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换)。
如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代他们能提高可伸缩性。
通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。
监测CPU的利用率
当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如UNIX系统上的vmstat和mpstat,或者Windows系统上的perfmon,都能给出处理器的“忙碌“状态。
如果所有CPU的利用率并不均匀(有些CPU在忙碌地运行,而其他CPU却并非如此),那么你的首要目标就是进一步找出程序中的并行性。 不均匀的利用率表明大多数计算都是由一小组线程完成的,并且应用程序没有利用其他的处理器。
如果CPU没有得到充分利用,那么需要找出其中的原因。通常由以下几种原因:
- 负载不充足
- 测试的程序中可能没有足够多的负载,因而还可以在测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。
- I/O密集
- 可以通过iostat或perfmon来判断某个应用程序是否是磁盘I/O密集型的,或者通过监测网络的通信流量级别来判断它是否需要高带宽。
- 外部限制
- 如果应用程序依赖于外部服务,例如数据库或Web服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间。
- 锁竞争
- 使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在”激烈的竞争“。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如”waiting to lock monitor…“。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程在等待获取它,因此在线程转储中频繁出现。
如果应用程序正在使CPU保持忙碌状态,那么可以使用监视工具来判断是否能通过增加额外的CPU来提升程序的性能。
如果一个程序只有4个线程,那么可以充分利用一个4路系统的计算能力,但当移植到8路系统上时,却未必能获得性能提升,因为可能需要更多的线程才会有效利用剩余的剩余的处理器。(可以通过重新配置程序将工作负载分配给更多的线程,例如调整线程池的大小。)
在vmstat命令的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的CPU)的线程数量。
如果CPU的利用率很高,并且总会有可运行的线程在等待CPU,那么当增加更多地处理器时,程序的性能可能会得到提升。
向对象池说“不”
通常,对象分配操作的开销比同步的开销更低。
如果线程从对象池中请求一个对象,那么就需要通过某种同步来协调对象池数据结构的访问,从而使某个线程被阻塞。 如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩性瓶颈。 (即使是一个非竞争的同步,所导致的开销也会比分配一个对象的开销大。)
减少上下文切换的开销
在许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。
在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。
通过将I/O操作从处理请求的线程分离出来,可以缩短处理请求的平均服务时间。
对象共享
可见性
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
有一种简单的方法能避免这些复杂的问题:只要有数据在多个线程之间共享,就使用正确的同步。
失效数据
由于内存可见性问题导致线程获取到数据是失效数据
仅对set方法进行同步是不够的,调用get的线程仍然会看见失效值。
非原子的64位操作
非volatile类型的64位数值变量(double和long)。
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。
当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。
因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
参考虚拟机部分的,现代虚拟机对这部分的实现做了优化(处理器支持了64位数值的原子操作),所以可以保证操作原子性
加锁与可见性
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
同步的可见性保证:
Volatile变量
volatile变量是一种比sychronized关键字更轻量级的同步机制。(在当前大多数处理器架构上,读取volatile变量的开销只比读取非volatile变量的开销略高一些。)
并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。 如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。
volatile变量的正确使用方式包括:
确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(例如,初始化或关闭)。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
Tips:对于服务器应用程序,无论在开发阶段还是在测试阶段,当启动JVM时一定都要指定-server命令行选项。server模式的JVM将比client模式的JVM进行更多的优化,
发布与逸出
“发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用
当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)
封装能够使得对程序的正确性进行分析变得可可能,并使得无意中破坏设计约束条件变得更难。
- 不要在构造过程中使this引用逸出。this引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。
- 在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start或initialize方法来启动。
- 在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是终结方法),同样会导致this引用在构造过程中逸出。
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
// unsafe this escape
doSomething(e);
}
});
}
void doSomething(Event e) {}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {}
}
上述代码是一个构造方法,构造方法是用来创建对象的,但是构造方法里面有内部类,内部类有隐式引用,也就是说外部类在构造过程中被其构造方法里的内部类引用了,本身构造过程还没完就引用了自己肯定是有问题的。
与此类似的还有,在构造方法中新启线程,使用线程可以,注意不要执行start()方法,否则会逸出。
使用一个私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
void doSomething(Event e) {}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {}
}
线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。 这种技术被称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。
Ad-hoc线程封闭
Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。
Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象的引用通常保存在公有变量中。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。 在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。
在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”的操作。 在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。
由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术(例如,栈封闭或ThreadLocal类)。
栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。
正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。 局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。 栈封闭(也被称为线程内部使用或者线程局部使用)比Ad-hoc线程封闭更易于维护,也更加健壮。
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// 只有一个引用指向集合animals,这个引用被封闭在局部变量中,因此也被封闭在执行线程中
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
ThreadLocal类
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来
ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享,这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。
ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。
不可变性
满足同步需求的另一种方法是使用不可变对象(Immutable Object)
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能修改
- 对象的所有域都是final类型(引用的对象也要保证不逸出)
- 对象是正确创建的(在对象的创建期间,this引用没有逸出)
final域
final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。 然而,在Java内存模型中,final域还有着特殊的语义。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。
即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。仅包含一个或两个可变状态的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为final类型,也相当于告诉维护人员这些域是不会变化的。
正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯。
使用Volatile类型来发布不可变对象:
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i, BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i)) {
return null;
} else {
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
}
public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
// Doesn't really factor
return new BigInteger[]{i};
}
}
安全发布
不安全的发布
参考《发布与逸出》部分
各种安全技术,它们的安全性都来自于JMM提供的保证,而造成不正确发布的真正原因,就是在”发布一个共享对象“与”另一个线程访问该对象“之前缺少一种Happens-Before排序。
当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。
在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。
同样,在发布一个引用时也需要写入一个变量,即新对象的引用。
如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。
在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造对象。
除了不可变对象之外,使用被另一个线程初始化的对象通常是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。
安全的发布
安全发布常用模式可以确保被发布对象对于其他线程是可见的,因为它们保证发布对象的操作将在使用对象的线程开始使用该对象的引用之前执行。
借助于类库中现在的同步容器、使用锁保护共享变量、或都使用共享的volatile类型变量,都可以保证对该变量的读取和写入是按照happens-before排序的。
happens-before事实上可以比安全发布承诺更强的可见性与顺序保证。 如果X对象从A线程到B线程是安全发布的,安全发布可以保证X本身的状态是可见的,但是不包括A所依赖的其他对象(即A中还包含了其他对象,不只是基本类型变量)的状态; 但是如果X对象是在同步的情况下由线程A生产,然后由线程B消费,B不仅仅能看到A中所有直接状态域,而且还能看见A所依赖的甚至是更深一层的状态域所做的更改。
安全初始化模式
参看《设计模式》的单例模式
使用静态域来初始化,会有的额外的线程安全性保证。 静态初始化是JVM完成的,发生在类的初始化阶段(加载、链接、类的初始化),即类被加载后到类被任意线程使用之前。 JVM会在初始化期间获得一个锁,这个锁每个线程都至少会获取一次,来确保类是否已被加载; 这个锁也保证了静态初始化期间,内存写入的结果自动地对照所有线程都是可见的。 所以静态初始化的对象,无论是构造期间还是被引用的时候,都不需要显试地进行同步。 然而,这仅仅适用于构造当时的状态——如果对象是可变的,为了保证后续修改的可见性,仍然需要同步。
@ThreadSafe
public class SafeLazyInitialization {
private static Resource resource;
public synchronized static Resource getInstance() {
if (resource == null)
resource = new Resource();
return resource;
}
static class Resource {
}
}
通过使用提前初始化(Eager Initialization),避免了在每次调用SafeLazyInitialization中的getInstance时所产生的同步开销。 通过将这项技术和JVM的延迟加载机制结合起来,可以形成一种延迟初始化技术,从而在常见的代码路径中不需要同步。
@ThreadSafe//主动初始化
public class EagerInitialization {
private static Resource resource = new Resource();
public static Resource getResource() { return resource; }
}
“延迟初始化占位类(Static Holder)模式”中使用了一个专门的类来初始化Resource。 JVM将推迟ResourceHolder的初始化操作,知道开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需要额外的同步。 当任何一个线程第一次调用getResource时,都会使ResourceHolder被加载和被初始化,此时静态初始化器执行Resource的初始化操作。
@ThreadSafe
public class ResourceFactory {
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceHolder.resource ;
}
}
双重检查加锁
在常见的代码路径中——获取一个已构造好的Resource引用,并没有使用同步。这就是问题所在,线程可能看到一个仅被部分构造的Resource。
DCL的真正问题在于:当在没有同步的情况下读取一个共享对象时,可能发生的最糟糕事情只是看到一个失效值(在这种情况下是一个空值),此时DCL方法将通过在持有锁的情况下再次尝试避免这种风险。 然而,实际情况远比这种情况糟糕——线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于无效或错误的状态。
@NotThreadSafe
public class DoubleCheckedLocking {
private static Resource resource;
public static Resource getInstance() {
if (resource == null) {
synchronized (DoubleCheckedLocking.class) {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
static class Resource {}
}
在JMM的后续版本(Java5.0以及更高的版本)中,如果把resource声明为volatile类型,那么就能启用DCL,并且这种方式对性能影响很小,因为volatile变量读取操作的性能通常只是略高于非volatile变量的读取操作的性能。
然而,DCL的这种使用方法已经被广泛地废弃了——促使该模式出现的驱动力(无竞争同步的执行速度很慢,以及JVM启动时很慢)已经不复存在,因而它不是一种高效地优化措施。延迟初始化占位类模式能带来同样的优势,并且更容易理解。
初始化过程中的安全性
如果能确保初始化过程的安全性,那么就可以使得被正确构造的不可变对象在没有同步的情况下也能安全地在多个线程之间共享,而不管它们是如何发布的,甚至通过某种数据竞争来发布。
如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本应为不可变对象(例如String)的值将会发生改变。(为了确保初始化的安全,所以在1.5中String中的域都已修改成了final了)
初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个final域设置的正确值,而不管采用何种方式来发布对象。 而且对于可以通过被正确构造对象中某个final域到达的任意变量(例如某个final数组中的元素,或者由一个final域引用的HashMap的内容)将同样对于其他线程是可见的。 (这仅仅适用于那些在构造过程中从对象的final域出发可以到达的对象)
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。 当构造函数完成时,构造函数对final域的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作,都将被“冻结”,并且人设获得该对象的引用的线程都至少能确保看到被冻结的值。 对通过final域可到达的初始变量的写入操作,将不会域构造过程后的操作一起被重排序。
初始化安全性意味着,下面的SafeStates可以安全地发布,即便通过不安全的延迟初始化,或者在没有同步的情况下将SafeStates的引用放到一个公有的静态域,或者没有使用同步以及依赖于非线程安全的HashSet。
@ThreadSafe
public class SafeStates {
private final Map<String, String> states;
public SafeStates() {
states = new HashMap<String, String>();
states.put("alaska", "AK");
states.put("alabama", "AL");
/*...*/
states.put("wyoming", "WY");
}
public String getAbbreviation(String s) {
return states.get(s);
}
}
然而,许多对SafeStates的细微修改都可能破坏它的线程安全性。
- 如果states不是final类型,或者存在除构造函数以外的其他方法能修改states,那么初始化安全性将无法确保在缺少同步的情况下安全地访问SafeStates。
- 如果在SafeStates中还有其他的非final域,那么其他线程仍然可能看到这些域上的不正确的值。
这也导致了对象在构造函数中逸出,从而使初始化安全性的保证无效。
初始化安全性只能保证通过final域可达的值从构造过程完成时可见性。对于通过非final域可达的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。
不可变对象与初始化安全性
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。
这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。
安全发布的常用模式
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中
- 将对象的引用保存到某个正确构造对象的final类型域中
- 将对象的引用保存到一个由锁保护的域中
- 线程安全库中的容器类提供了以下的安全发布保证:
- 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
- 通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
- 通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
- 类库中的其他数据传递机制(例如Future和Exchanger)同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。
- 静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布
事实不可变对象
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
可变对象
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
安全地共享对象
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
- 线程封闭
- 线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
- 只读共享
- 在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享
- 线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
- 保护对象
- 被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
如果一个数据模型必须被多个线程共享,而且由于阻塞、一致性或复杂度等原因而无法实现一个线程安全的模型时,可以考虑使用分解模型设计(DTO)。
对象的组合
设计线程安全的类
通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。
在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问管理策略
同步策略(Synchronization Policy)定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。 同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。 要确保开发人员可以对这个类进行分析与维护,就必须将同步策略写为正式文档。
收集同步需求
这些相关的变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后释放锁并再次获得锁,然后再更新其他的变量。 因为释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性与封装性。
依赖状态的操作
- 在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。
- 但在并发程序中,先验条件可能会由于其他线程执行的操作而变成真。在并发程序中要一直等到先验条件为真,然后再执行该操作。
在Java中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确地使用它们并不容易。 要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如阻塞队列[Blocking Queue]或信号量[Semaphore])来实现依赖状态的行为。
状态的所有权
许多情况下,所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(例如,同步容器封装器的工厂方法)。
实例封闭
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
在Java平台的类库中还有很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。
一些基本的容器类并非线程安全的,例如ArrayList和HashMap,但类库提供了包装器工厂方法(例如Collections.synchronizedList及其类似方法),使得这些非线程安全的类可以在多线程环境中安全地使用。
这些工厂方法通过“装饰器(Decorator)”模式(Gamma et al.,1995)将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。
只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。在这些方法的Javadoc中指出,对底层容器对象的所有访问必须通过包装器来进行。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
Java监视器模式
从线程封闭原则及其逻辑推论可以得出Java监视器模式。遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。
在许多类中都使用了Java监视器模式,例如Vector和Hashtable。Java监视器模式的主要优势就在于它的简单性。
@ThreadSafe
public class MonitorVehicleTracker {
@GuardedBy("this") private final Map<String, MutablePoint> locations;
public MonitorVehicleTracker(Map<String, MutablePoint> locations) {
this.locations = deepCopy(locations);
}
public synchronized Map<String, MutablePoint> getLocations() {
// 占用锁时间长,效率低的做法
return deepCopy(locations);
}
public synchronized MutablePoint getLocation(String id) {
MutablePoint loc = locations.get(id);
return loc == null ? null : new MutablePoint(loc);
}
public synchronized void setLocation(String id, int x, int y) {
MutablePoint loc = locations.get(id);
if (loc == null)
throw new IllegalArgumentException("No such ID: " + id);
loc.x = x;
loc.y = y;
}
private static Map<String, MutablePoint> deepCopy(Map<String, MutablePoint> m) {
Map<String, MutablePoint> result = new HashMap<String, MutablePoint>();
for (String id : m.keySet()) {
result.put(id, new MutablePoint(m.get(id)));
}
return Collections.unmodifiableMap(result);
}
}
@NotThreadSafe
public class MutablePoint {
public int x, y;
public MutablePoint() {
x = 0;
y = 0;
}
public MutablePoint(MutablePoint p) {
this.x = p.x;
this.y = p.y;
}
}
线程安全性的委托
大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合为一个类时,Java监视器模式是非常有用的。
但是,如果类中的各个组件都已经是线程安全的,是否需要再增加一个额外的线程安全层?答案是“视情况而定”。
// 将线程安全委托给ConcurrentHashMap
@ThreadSafe
public class DelegatingVehicleTracker {
private final ConcurrentMap<String, Point> locations;
private final Map<String, Point> unmodifiableMap;
public DelegatingVehicleTracker(Map<String, Point> points) {
locations = new ConcurrentHashMap<String, Point>(points);
unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map<String, Point> getLocations() {
return unmodifiableMap;
}
public Point getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (locations.replace(id, new Point(x, y)) == null){
throw new IllegalArgumentException("invalid vehicle name: " + id);
}
}
public Map<String, Point> getLocationsAsStatic() {
return Collections.unmodifiableMap(
new HashMap<String, Point>(locations));
}
}
// 使用不可修改的Point对象防止封装性被破坏
@Immutable
public class Point {
public final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
// 如果需要返回一个不发生变化的车辆视图,可以返回一个浅拷贝
public Map<String, Point> getLocations() {
return Collections.unmodifiableMap(new HashMap<String,Point>(locations));
}
独立的状态变量
还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即组合而成的类并不会在其包含的多个状态变量上增加任何不变性条件。
// CopyOnWriteArrayList来保存各个监听器列表。它是一个线程安全的链表,特别适用于管理监听器列表。
// 每个链表都是线程安全的,此外,由于各个状态之间不存在耦合关系,因此VisualComponent可以将它的线程安全性委托给mouseListeners和keyListeners等对象。
public class VisualComponent {
private final List<KeyListener> keyListeners = new CopyOnWriteArrayList<KeyListener>();
private final List<MouseListener> mouseListeners = new CopyOnWriteArrayList<MouseListener>();
public void addKeyListener(KeyListener listener) {
keyListeners.add(listener);
}
public void addMouseListener(MouseListener listener) {
mouseListeners.add(listener);
}
public void removeKeyListener(KeyListener listener) {
keyListeners.remove(listener);
}
public void removeMouseListener(MouseListener listener) {
mouseListeners.remove(listener);
}
}
委托失效
如果某个类含有复合操作,那么仅靠委托并不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作,除非整个复合操作都可以委托给状态变量。
如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
发布底层的状态变量
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。
@ThreadSafe
public class SafePoint {
@GuardedBy("this")
private int x, y;
// 这是私有构造函数捕获模式(Private Constructor Capture Idiom)的一个实例。
private SafePoint(int[] a) {
this(a[0], a[1]);
}
// 如果将拷贝构造函数实现为this(p.x, p.y),那么会产生竞态条件,而私有构造函数则可以避免这种竞态条件。
public SafePoint(SafePoint p) {
this(p.get());
}
// get方法同时获得x和y的值,并将二者放在一个数组中返回
// 如果为x和y分别提供get方法,那么在获得这两个不同坐标的操作之间,x和y的值发生变化,从而导致调用者看到不一致的值
public SafePoint(int x, int y) {
this.set(x, y);
}
public synchronized int[] get() {
return new int[]{x, y};
}
public synchronized void set(int x, int y) {
this.x = x;
this.y = y;
}
}
// 将其线程安全性委托给底层的ConcurrentHashMap,只是Map中的元素是线程安全的且可变的Point,而并非不可变的。
@ThreadSafe
public class PublishingVehicleTracker {
private final Map<String, SafePoint> locations;
private final Map<String, SafePoint> unmodifiableMap;
public PublishingVehicleTracker(Map<String, SafePoint> locations) {
this.locations = new ConcurrentHashMap<String, SafePoint>(locations);
this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
}
// getLocation方法返回底层Map对象的一个不可变副本
// 调用者不能增加或删除车辆,但却可以通过修改返回Map中的SafePoint值来改变车辆的位置
public Map<String, SafePoint> getLocations() {
return unmodifiableMap;
}
public SafePoint getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (!locations.containsKey(id))
throw new IllegalArgumentException("invalid vehicle name: " + id);
locations.get(id).set(x, y);
}
}
在现有的线程安全类中添加功能
添加代码或者扩展类
- 要添加一个新的原子操作,最安全的方法是修改原始的类,但这通常无法做到,因为你可能无法访问或修改类的源代码。
- 要想修改原始的类,就需要理解代码中的同步策略,这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码仍然处于一个源代码文件中,从而更容易理解与维护。
- 另一种方法是扩展这个类,假定在设计这个类时考虑了可扩展性。
- “扩展”方法比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。
- 如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么子类会被破坏,因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。
在Vector的规范中定义了它的同步策略,因此BetterVector遵循这个规范就不存在被破坏问题。
@ThreadSafe
public class BetterVector <E> extends Vector<E> {
static final long serialVersionUID = -3963416950630760754L;
public synchronized boolean putIfAbsent(E x) {
boolean absent = !contains(x);
if (absent) {
add(x);
}
return absent;
}
}
客户端加锁机制
对于由Collections.synchronizedList封装的ArrayList,这两种方法在原始类中添加一个方法或者对类进行扩展都行不通,
因为客户代码并不知道在同步封装器工厂方法中返回的List对象的类型。
第三种策略是扩展类的功能,但并不是扩展类本身,而是将扩展代码放入一个“辅助类”中。
要想使这个方法能正确执行,必须使实现客户端加锁或外部加锁时使用同一个锁。客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户代码。要使用客户端加锁,你必须知道对象X使用的是哪一个锁。
@ThreadSafe
class GoodListHelper<E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
public boolean putIfAbsent(E x) {
// 使用内部锁对应的this对象,而不是在方法上使用synchronized而使用不同的锁对象
synchronized (list) {
boolean absent = !list.contains(x);
if (absent) {
list.add(x);
}
return absent;
}
}
}
- 通过添加一个原子操作来扩展类是脆弱的,因为它将类的加锁代码分布到多个类中。
- 然而,客户端加锁却更加脆弱,因为它将类C的加锁代码放到与C完全无关的其他类中。当在那些并不承诺遵循加锁策略的类上使用客户端加锁时,要特别小心。
客户端加锁机制与扩展类机制有许多共同点,二者都是将派生类的行为与基类的实现耦合在一起。正如扩展会破坏实现的封装性,客户端加锁同样会破坏同步策略的封装性。
组合
当为现有的类添加一个原子操作时,有一种更好的方法:组合(Composition)。ImprovedList通过自身的内置锁增加了一层额外的加锁。
它并不关心底层的List是否是线程安全的,即使List不是线程安全的或者修改了它的加锁实现,ImprovedList也会提供一致的加锁机制来实现线程安全性。虽然额外的同步层可能导致轻微的性能损失(性能损失很小,因为在底层List上的同步不存在竞争,所以速度很快),但与模拟另一个对象的加锁策略相比,ImprovedList更为健壮。
事实上,我们使用了Java监视器模式来封装现有的List,并且只要在类中拥有指向底层List的唯一外部引用,就能确保线程安全性。
public class ImprovedList<T> implements List<T> {
private final List<T> list;
// PRE: list argument is thread-safe.
public ImprovedList(List<T> list) {
this.list = list;
}
public synchronized boolean putIfAbsent(T x) {
boolean contains = list.contains(x);
if (contains) {
list.add(x);
}
return !contains;
}
//……按照类似的方式委托List的其他方法
}
将同步策略文档化
在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。
synchronized、volatile或者任何一个线程安全类都对应于某种同步策略,用于在并发访问时确保数据的完整性。 这种策略是程序设计的要素之一,因此应该将其文档化。当然,设计阶段是编写设计决策文档的最佳时间。
在设计同步策略时需要考虑多个方面:
- 将哪些变量声明为volatile类型
- 哪些变量用锁来保护
- 哪些锁保护哪些变量
- 哪些变量必须是不可变的或者被封闭在线程中的
- 哪些操作必须是原子操作等。
其中某些方面是严格的实现细节,应该将它们文档化以便于日后的维护。还有一些方面会影响类中加锁行为的外在表现,也应该将其作为规范的一部分写入文档。
最起码,应该保证将类中的线程安全性文档化。
- 它是否是线程安全的
- 在执行回调时是否持有一个锁
- 是否有某些特定的锁会影响其行为
不要让客户冒着风险去猜测。如果你不想支持客户端加锁也是可以的,但一定要明确地指出来。
基础构建模块
同步容器类
- 同步容器类包括Vector和Hashtable,二者是早期JDK的一部分
- 此外还包括在JDK l.2中添加的一些功能相似的类,这些同步的封装器类是由Collections.synchronizedXxx等工厂方法创建的
- 这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态
同步容器类的问题
// 在使用客户端加锁的Vector上的复合操作,使用同一个锁对象
public static Object getLast(Vector list) {
synchronized (list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
// 带有客户端加锁的迭代
// 这同样会导致其他线程在迭代期间无法访问它,因此降低了并发性。
synchronized (list) {
for (int i = 0; i < list.size(); i++) {
doSomething(list.get(i));
}
}
迭代器与ConcurrentModificationException
如果在迭代期间计数器被修改,那么hasNext或next将抛出ConcurrentModificationException(在单线程代码中也可能抛出ConcurrentModificationException异常。当对象直接从容器中删除而不是通过Iterator.remove来删除时,就会抛出这个异常)。
然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。
这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。
要想避免出现ConcurrentModificationException,就必须在迭代过程持有容器的锁。
如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么这些线程将长时间等待。 即使不存在饥饿或者死锁等风险,长时间地对容器加锁也会降低程序的可伸缩性。持有锁的时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁被释放,那么将极大地降低吞吐量和CPU的利用率
如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。 由于副本被封闭在线程内,因此其他线程不会在选代期间对其进行修改,这样就避免了抛出ConcurrentModificationException(在克隆过程中仍然需要对容器加锁)。 在克隆容器时存在显著的性能开销。这种方式的好坏取决于多个因素,包括容器的大小,在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。
隐藏迭代器
所有这些间接的迭代操作都可能抛出ConcurrentModificationException:
- 容器的toString、hashCode和equals等方法会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。
- 同样,containsAll、removeAll和retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。
正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。
public class HiddenIterator {
@GuardedBy("this")
private final Set<Integer> set = new HashSet<Integer>();
public synchronized void add(Integer i) {
set.add(i);
}
public synchronized void remove(Integer i) {
set.remove(i);
}
public void addTenThings() {
Random r = new Random();
for (int i = 0; i < 10; i++) {
add(r.nextInt());
}
// 这里必须使用同步synchronized(this),因为会toString会调用Iterator
System.out.println("DEBUG: added ten elements to " + set);
}
}
并发容器
通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。
同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减低。
Java 5.0提供了多种并发容器类来改进同步容器的性能。
并发容器是针对多个线程并发访问设计的。 在Java 5.0中增加了ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的List。 在新的ConcurrentMap接口中增加了对一些常见复合操作的支持,例如“若没有则添加”、替换以及有条件删除等。
Java 5. 0增加了两种新的容器类型:Queue和BlockingQueue。
Queue用来临时保存一组等待处理的元素。
它提供了几种实现,包括:ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发的)优先队列。 Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为——事实上,正是通过LinkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List的随机访问需求,从而实现更高效的并发。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。 如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。 如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者-消费者”这种设计模式中,阻塞队列是非常有用的。
正如ConcurrentHashMap用于代替基于散列的同步Map, Java 6也引入了ConcurrentSkipListMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发替代品(例如用synchronizedMap包装的TreeMap或TreeSet)。
ConcurrentHashMap
同步容器类在执行每个操作期间都持有一个锁。
与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。
ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁(Lock Striping)。
在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map。
ConcurrentHashMap带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。
ConcurrentHashMap返回的迭代器具有弱一致性(Weakly Consistent),而并非“及时失败”。
弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
尽管有这些改进,但仍然有一些需要权衡的因素。对于一些需要在整个Map上进行计算的方法,例如size和isEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。
由于size返回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许size返回一个近似值而不是一个精确值。
虽然这看上去有些令人不安,但事实上size和isEmpty这样的方法在并发环境下的用处很小,因为它们的返回值总在不断变化。
因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,包括get、put、containsKey和remove等。
- 在ConcurrentHashMap中没有实现对Map加锁以提供独占访问。
- 在Hashtable和synchronizedMap中,获得Map的锁能防止其他线程访问这个Map。
在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射,或者对Map迭代若干次并在此期间保持元素顺序相同。然而,总体来说这种权衡还是合理的,因为并发容器的内容会持续变化。
与Hashtable和synchronizedMap相比,ConcurrentHashMap有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性。
只有当应用程序需要加锁Map以进行独占访问(或者需要依赖于同步Map带来的一些其他作用)时,才应该放弃使用ConcurrentHashMap。
额外的原子Map操作
由于ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作, 但是,一些常见的复合操作都已经实现为原子操作并且在ConcurrentMap的接口中声明
public interface ConcurrentMap<K, V>extends Map<K, V>{
//仅当K没有相应的映射值时才插入
V putIfAbsent(K key, V value);
//仅当K被映射到V时才移除
boolean remove(K key, V value);
//仅当K被映射到oldValue时才替换为newValue
boolean replace(K key, V oldValue, V newValue);
//仅当K被映射到某个值时才替换为newValue
V replace(K key, V newValue);
}
CopyOnWriteArrayList
CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet的作用是替代同步Set。)
“写入时复制(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。
“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。
因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。
“写入时复制”容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。
这个准则很好地描述了许多事件通知系统:在分发通知时需要迭代已注册监听器链表,并调用每一个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作。
阻塞队列和生产者-消费者模式
在类库中包含了BlockingQueue的多种实现:
- 其中,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列
- 二者分别与LinkedList和ArrayList类似,但比同步List拥有更好的并发性能。
- PriorityBlockingQueue是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。
- 正如其他有序的容器一样,PriorityBlockingQueue既可以根据元素的自然顺序来比较元素(如果它们实现了Comparable方法),也可以使用Comparator来比较。
- 最后一个BlockingQueue实现是SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。
- 因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。
阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。
如果队列已经满了,那么put方法将阻塞直到有空间可用;
如果队列为空,那么take方法将会阻塞直到有元素可用。
队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put方法也永远不会阻塞。
生产者-消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性, 此外,该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同。
BlockingQueue简化了生产者-消费者设计的实现过程,它支持任意数量的生产者和消费者。 一种最常见的生产者-消费者设计模式就是线程池与工作队列的组合,在Executor任务执行框架中就体现了这种模式
阻塞队列同样提供了一个offer方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理负荷过载的情况, 例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
开发人员总会假设消费者处理工作的速率能赶上生产者生成工作项的速率,因此通常不会为工作队列的大小设置边界,但这将导致在之后需要重新设计系统架构。
因此,应该尽早地通过阻塞队列在设计中构建资源管理机制—这件事情做得越早,就越容易。
在许多情况下,阻塞队列能使这项工作更加简单,如果阻塞队列并不完全符合设计需求,那么还可以通过信号量(Semaphore)来创建其他的阻塞数据结构。
生产者-消费者模式同样能带来许多性能优势。生产者和消费者可以并发地执行。如果一个是I/O密集型,另一个是CPU密集型,那么并发执行的吞吐率要高于串行执行的吞吐率。
public class IndexingService {
private static final int CAPACITY = 1000;
private static final File POISON = new File("");
private final IndexerThread consumer = new IndexerThread();
private final CrawlerThread producer = new CrawlerThread();
private final BlockingQueue<File> queue;
private final FileFilter fileFilter;
private final File root;
public IndexingService(File root, final FileFilter fileFilter) {
this.root = root;
this.queue = new LinkedBlockingQueue<File>(CAPACITY);
this.fileFilter = new FileFilter() {
public boolean accept(File f) {
return f.isDirectory() || fileFilter.accept(f);
}
};
}
private boolean alreadyIndexed(File f) {
return false;
}
class CrawlerThread extends Thread {
@Override
public void run() {
try {
crawl(root);
} catch (InterruptedException e) { /* fall through */
} finally {
while (true) {
try {
queue.put(POISON);
break;
} catch (InterruptedException e1) { /* retry */
}
}
}
}
private void crawl(File root) throws InterruptedException {
File[] entries = root.listFiles(fileFilter);
if (entries != null) {
for (File entry : entries) {
if (entry.isDirectory()) {
crawl(entry);
} else if (!alreadyIndexed(entry)) {
queue.put(entry);
}
}
}
}
}
class IndexerThread extends Thread {
@Override
public void run() {
try {
while (true) {
File file = queue.take();
if (file == POISON) {
break;
} else {
indexFile(file);
}
}
} catch (InterruptedException ignored) {
}
}
public void indexFile(File file) {
/*...*/
}
}
public void start() {
producer.start();
consumer.start();
}
public void stop() {
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
串行线程封闭
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。
线程封闭对象只能由单个线程拥有,但可以通过安全地发布该对象来“转移”所有权。
在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。
这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象将被封闭在新的线程中。
新的所有者线程可以对该对象做任意修改,因为它具有独占的访问权。
对象池利用了串行线程封闭,将对象“借给”一个请求线程。只要对象池包含足够的内部同步来安全地发布池中的对象,并且只要客户代码本身不会发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全地在线程之间传递所有权。
我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象。阻塞队列简化了这项工作。 除此之外,还可以通过ConcurrentMap的原子方法remove或者AtomicReference的原子方法compareAndSet来完成这项工作。
双端队列与工作密取
Java 6增加了两种容器类型,Deque(发音为“deck”)和BlockingDeque,它们分别对Queue和BlockingQueue进行了扩展。 Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。
正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Work Stealing)。
- 在生产者-消费者设计中,所有消费者有一个共享的工作队列
- 而在工作密取设计中,每个消费者都有各自的双端队列。
如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。 密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。 在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争。 当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
工作密取非常适用于既是消费者也是生产者问题——当执行某个工作时可能导致出现更多的工作。
当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态。
例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。类似的还有许多搜索图的算法,例如在垃圾回收阶段对堆进行标记,都可以通过工作密取机制来实现高效并行。
阻塞方法与中断方法
线程可能会阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程的计算结果。
当线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCKED、WAITING或TIMED_WAITING)。
当某个外部事件发生时,线程被置回RUNNABLE状态,并可以再次被调度执行。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。
当线程A中断B时,A仅仅是要求B在执行到某个可以暂停的地方停止正在执行的操作—前提是如果线程B愿意停止下来。
虽然在API或者语言规范中并没有为中断定义任何特定应用级别的语义,但最常使用中断的情况就是取消某个操作。
方法对中断请求的响应度越高,就越容易及时取消那些执行时间很长的操作。
当在代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应。对于库代码来说,有两种基本选择:
- 传递InterruptedException
- 避开这个异常通常是最明智的策略——只需把InterruptedException传递给方法的调用者。传递InterruptedException的方法包括,根本不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作后再次抛出这个异常。
- 恢复中断
- 有时候不能抛出InterruptedException,例如当代码是Runnable的一部分时。在这些情况下,必须捕获InterruptedException,并通过调用当前线程上的interrupt方法恢复中断状态,这样在调用栈中更高层的代码将看到引发了一个中断
public class TaskRunnable implements Runnable {
BlockingQueue<Task> queue;
public void run() {
try {
processTask(queue.take());
} catch (InterruptedException e) {
// restore interrupted status
Thread.currentThread().interrupt();
}
}
void processTask(Task task) {
// Handle the task
}
interface Task {
}
}
还可以采用一些更复杂的中断处理方法,但上述两种方法已经可以应付大多数情况了。 然而在出现InterruptedException时不应该做的事情是,捕获它但不做出任何响应。 这将使调用栈上更高层的代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。 只有在一种特殊的情况中才能屏蔽中断,即对Thread进行扩展,并且能控制调用栈上所有更高层的代码。
同步工具类
所有的同步工具类都包含一些特定的结构化属性:
它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。
闭锁和CountDownLatch
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。
闭锁可以用来确保某些活动直到其他活动都完成后才继续执行,如:
- 确保某个计算在其需要的所有资源都被初始化之后才继续执行
- 确保某个服务在其依赖的所有其他服务都已经启动之后才启动
- 等待直到某个操作的所有参与者都就绪再继续执行
CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task) throws InterruptedException {
final CountDownLatch startGate = new CountDownLatch(1);
final CountDownLatch endGate = new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
@Override
public void run() {
try {
startGate.await();
try {
task.run();
} finally {
endGate.countDown();
}
} catch (InterruptedException ignored) {
}
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end - start;
}
}
FutureTask
FutureTask也可以做闭锁,它实现了Future语义,表示一种抽象的可生成结果的计算。
FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:等待运行(Waiting to run)、正在运行(Running)和运行完成(Completed)。
“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会停止在这个状态上。
Future.get的行为取决于任务的状态,如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。
FutureTask将计算结果从执行计算的线程到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。
// 提前加载稍后要使用的数据
public class Preloader {
ProductInfo loadProductInfo() throws DataLoadException {
return null;
}
private final FutureTask<ProductInfo> future =
new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
public ProductInfo call() throws DataLoadException {
return loadProductInfo();
}
});
private final Thread thread = new Thread(future);
public void start() {
thread.start();
}
public ProductInfo get() throws DataLoadException, InterruptedException {
try {
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof DataLoadException) {
throw (DataLoadException) cause;
} else {
throw LaunderThrowable.launderThrowable(cause);
}
}
}
interface ProductInfo {}
}
class DataLoadException extends Exception {}
Callable表示的任务可以抛出受检查的或未受检查的异常,并且任何代码都可能抛出一个Error。无论任务代码抛出什么异常,都会被封装到一个ExecutionException中,并在Future.get中被重新抛出。
这将使调用get的代码变得复杂,因为它不仅需要处理可能出现的ExecutionException(以及未检查的CancellationException),而且还由于ExecutionException是作为一个Throwable类返回的,因此处理起来并不容易。
public class LaunderThrowable {
public static RuntimeException launderThrowable(Throwable t) {
if (t instanceof RuntimeException) {
// 直接抛出给调用者
return (RuntimeException) t;
} else if (t instanceof Error) {
// 如果是错误直接抛出错误
throw (Error) t;
} else {
// 非RuntimeException表示这是一个逻辑错误
throw new IllegalStateException("Not unchecked", t);
}
}
}
信号量
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放即可。 如果没有许可,那么aquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。 (在这种实现中不包含真正的许可对象,并且Semaphore也不会将许可与线程关联起来,因此在一个线程中获得的许可可以在另一个线程中释放。可以将acquire操作视为是消费一个许可,而release操作是创建一个许可,Semaphore并不受限于它在创建时的初始许可数量。)
计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用作互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
// 设置容器边界
public class BoundedHashSet<T> {
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound) {
this.set = Collections.synchronizedSet(new HashSet<T>());
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if (!wasAdded) {
sem.release();
}
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if (wasRemoved) {
sem.release();
}
return wasRemoved;
}
}
栅栏CyclicBarrier和Exchanger
栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。
栅栏与闭锁的关键区别在于:
所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:
这种算法通常将一个问题拆分成为一系列相互独立的自问题。
- 当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有的线程到达栅栏位置。
- 如果所有的线程都到达栅栏位置,那么栅栏将打开,此时所有的线程都被释放,而栅栏将被重置一遍下次使用。
- 如果对await方法的调用超时,或者await阻塞的线程被中断,那么栅栏就认为是被打破了,所有阻塞的await都将终止并且抛出BorkenBarrierException。
- 如果成功的通过栅栏,那么await将会为每个线程都返回一个唯一的索引号,我们可以利用这些索引来“选举”产生一个“领导线程”,并在下一次迭代中由该领导线程执行一些特殊的工作。
CycleBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
// 通过CyclicBarrier协调细胞自动衍生系统中的计算
// 将问题分解成一定数量的子问题,为每个子问题分配一个线程来求解,之后再将所有的结果合并起来
public class CellularAutomata {
private final Board mainBoard;
private final CyclicBarrier barrier;
private final Worker[] workers;
public CellularAutomata(Board board) {
this.mainBoard = board;
int count = Runtime.getRuntime().availableProcessors();
this.barrier = new CyclicBarrier(count,
new Runnable() {
public void run() {
mainBoard.commitNewValues();
}
});
this.workers = new Worker[count];
for (int i = 0; i < count; i++) {
workers[i] = new Worker(mainBoard.getSubBoard(count, i));
}
}
private class Worker implements Runnable {
private final Board board;
public Worker(Board board) {
this.board = board;
}
public void run() {
while (!board.hasConverged()) {
for (int x = 0; x < board.getMaxX(); x++) {
for (int y = 0; y < board.getMaxY(); y++) {
board.setNewValue(x, y, computeValue(x, y));
}
}
try {
barrier.await();
} catch (InterruptedException ex) {
return;
} catch (BrokenBarrierException ex) {
return;
}
}
}
private int computeValue(int x, int y) {
// Compute the new value that goes in (x,y)
return 0;
}
}
public void start() {
for (Worker worker : workers) {
new Thread(worker).start();
}
mainBoard.waitForConvergence();
}
interface Board {
int getMaxX();
int getMaxY();
int getValue(int x, int y);
int setNewValue(int x, int y, int value);
void commitNewValues();
boolean hasConverged();
void waitForConvergence();
Board getSubBoard(int numPartitions, int index);
}
}
另一种形式的栅栏是Exchanger,它是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用
当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全的发布给另一方。
使用同步工具类构造高效且可伸缩的缓存
看上去简单的缓存,可能会将性能瓶颈转变成伸缩性瓶颈,即使缓存是用来提高单线程性能的。
public class Memoizer<A, V> implements Computable<A, V> {
private final ConcurrentMap<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
private final Computable<A, V> c;
public Memoizer(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws InterruptedException {
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(eval);
f = cache.putIfAbsent(arg, ft);
if (f == null) {
f = ft;
ft.run();
}
}
try {
return f.get();
} catch (CancellationException e) {
cache.remove(arg, f);
} catch (ExecutionException e) {
throw LaunderThrowable.launderThrowable(e.getCause());
}
}
}
}
并发技巧清单
- 可变状态是至关重要的(It’s the mutable state stupid)。
- 所有问题都可以归结为如何协调对并发状态的访问。可变状态越少就越容易确保线程安全性。
- 尽量将域声明为final类型,除非需要他们是可变的。
- 不可变对象一定是线程安全的
- 不可变对象能极大的降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或者保护性复杂等机制。
- 封装有助于管理复杂性
- 在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但是不建议这么做?将数据封装在对象中,更易于维护不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
- 用锁来保护每个可变变量
- 当保护同一个不变性条件中所有变量时,要使用同一个锁。
- 在执行复合操作期间,要持有锁。
- 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
- 不要故作聪明地推断出不需要使用同步。
- 再设计过程中考虑线程安全,或者在文档中明确指出它不是线程安全的。
- 将同步策略文档化。
任务执行
在线程中执行任务
当围绕“任务执行”来设计应用程序结构时,第一步就是要找出清晰的任务边界。
独立有利于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。为了在调度与负载均衡等过程中实现更高的灵活性,每项任务还应该表示应用程序的一小部分处理能力。
在理想情况下,各个任务之间是相互独立的:任务不依赖于其他任务的状态、结果或边界效应。
- 在正常的负载下,服务器应用程序应该同时表现出良好的吞吐量和快速的响应性。
- 应用程序提供商希望程序支持尽可能多的用户,从而降低每个每个用户的服务成本,而用户则希望获得尽快的响应。
- 而且,当负荷过载时,应用程序的性能应该是逐渐降低,而不是直接失败。
要实现上述目标,应该选择清晰的任务边界以及明确的任务执行策略。
大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界。
Web服务器、邮件服务器、文件服务器、EJB容器以及数据库服务器等,这些服务器都能通过网络接受远程客户的连接请求。将独立的请求作为任务边界,既可以实现任务的独立性,又可以实现合理的任务规模。
显示地为任务创建线程
主循环都将创建一个新线程来处理请求,而不是在主循环中进行处理。由此可得出3个主要结论:
- 任务处理程序从主线程中分离出来,使得主循环能够更快地重新等待下一个到来的请求。
- 这使得程序在完成前面的请求之前可以接受新的请求,从而提高响应性。
- 任务可以并行处理,从而能同时服务多个请求。
- 如果有多个处理器,或者任务由于某种原因被阻塞,例如等待I/O完成,获取锁或者资源可用性等,程序的吞吐量将得到提高。
- 任务处理代码必须是线程安全的,因为当有多个任务时会并发地调用这段代码。
在正常的负载情况下,“为每个任务分配一个线程”的方法能提升串行执行的性能。只要请求的到达率不超出服务器的请求处理能力,那么这种方法可以同时带来更快地响应性和更高的吞吐率。
无限制创建线程的不足
在生产环境中,“为每个任务分配一个线程”这种方法存在一些缺陷,尤其是当需要创建大量的线程时:
- 线程生命周期的开销非常高
- 线程的创建与销毁并不是没有代价的。根据平台的不同,实际的开销也有所不同,但线程的创建过程都会需要时间,延迟处理的请求,并且需要JVM和操作系统提供一些辅助操作。如果请求的到达率非常高且请求的处理过程是轻量级的,例如大多数服务器应用程序就是这种情况,那么为每个请求创建一个新线程将消耗大量的计算资源。
- 资源消耗
- 活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量多于可用处理器的数量,那么有些线程会消耗系统资源,尤其是内存。如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾回收期带来压力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。如果你已经拥有足够多的线程使CPU保持忙碌状态,那么再创建更多地线程反而会降低性能。
- 稳定性
- 在可创建线程的数量上存在一个限制。这个限制值将随着平台的不同而不同,并且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么很可能抛出OutOfMemoryError异常,要想从这种错误中恢复过来是非常危险的,更简单的办法是通过构造程序来避免超出这些限制。
Executor框架
- 串行执行的问题在于其糟糕的响应性和吞吐量
- “为每个任务分配一个线程”的问题在于资源管理的复杂性
java.util.concurrent提供了一种灵活的线程池实现作为Executor框架的一部分
public interface Executor {
void execute(Runnable command);
}
Executor接口为灵活且强大的异步任务执行框架提供了基础,该框架能支持多重不同类型的任务执行策略。
它提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并用Runnable来表示任务。
Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制。
Executor基于生产者—消费者模式,提交任务的操作相当于生产者(生成待完成的工作单元),执行任务的线程则相当于消费者(执行完成这些工作单元)。 如果要在程序中实现一个生产者—消费者的设计,那么最简单的方式通常就是使用Executor。
执行策略
通过将任务的提交与执行解耦开来,从而无须太大的困难就可以为某种类型的任务指定和修改执行策略。在执行策略中定义了任务执行的“What、Where、When、How“等方面,包括:
- 在什么(What)线程中执行任务?
- 任务按照什么(What)顺序执行(FIFO、LIFO、优先级)?
- 有多少个(How Many)任务能并发执行?
- 在队列中有多少个(How Many)任务在等待执行?
- 如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个(Which)任务?另外,如何(How)通知应用程序有任务被拒绝?
- 在执行一个任务之前或之后,应该进行哪些(What)动作?
每当看到下面这种形式的代码时: new Thread(runnable).start() 并且你希望获得一种更灵活的执行策略时,请考虑使用Executor来代替Thread。
线程池Executor
线程池是与工作队列密切相关的,其中在工作队列中保存了所有等待执行的任务。
工作者线程的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。
优势:
- 通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。
- 另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而延迟任务的执行,从而提高了响应性。
- 通过适当调整线程池的大小,可以创建足够多得线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。
可以通过调用Executors中的静态工厂方法之一来创建一个线程池:
- newFixedThreadPool
- newFixedThreadPool将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程)。
- newCachedThreadPool
- newCachedThreadPool将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
- newSingleThreadExecutor
- newSingleThreadExecutor是一个单线程的Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能够确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)。
- 单线程的Executor还提供了大量的内部同步机制,从而确保了任务执行的任何内存写入操作对于后续任务来说都是可见的,这意味着,即使这个线程会不时地被另一个线程替代,单对象总是可以安全的封闭在“任务线程”中
- newScheduledThreadPool
- newScheduledThreadPool创建一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。
newFixedThreadPool与newCachedThreadPool两个工厂方法返回通用的ThreadPoolExecutor实例。
通过使用Executor,可以实现各种调优、管理、监视、记录日志、错误报告和其他功能。
参看《Jdk1.5 Executors工厂类来产生线程池》部分
Executor的生命周期
Executor的实现通常会创建线程来执行任务。但JVM只有在所有非守护线程全部终止后才会退出。因此,如果无法正确的关闭Executor,那么JVM将无法结束。
为了解决执行服务的声明周期问题,Executor扩展了ExecutorService接口,添加了一些用于声明周期管理的方法。
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException;
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;
<T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}
ExecutorService的生命周期有3种状态:运行、关闭、已终止。初始创建时处于运行状态。
- shutdown方法将执行平缓的关闭过程
- 不再接受新的任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务
- shutdownNow方法将执行粗暴的关闭过程
- 它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始的任务
在ExecutorService关闭后提交的任务由拒绝执行处理器RejectedExecutionHandler处理,它会抛弃任务,或者使得execute方法抛出一个未受检的RejectedExecutionException。
等所有任务都完成后,ExecutorService将转入终止状态。可以调用awaitTermination来等待ExecutorService到达终止状态,或者通过调用isTerminated来轮询ExecutorService是否已经终止。
通常在调用awaitTermination后立即调用shutdown,从而产生同步关闭ExecutorService的效果。
public class LifecycleWebServer {
// guava包ThreadFactoryBuilder
private ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d").build();
private ExecutorService exec = new ThreadPoolExecutor(
5,
200,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(1024),
namedThreadFactory,
new ThreadPoolExecutor.AbortPolicy());
public void start() throws IOException {
try (ServerSocket socket = new ServerSocket(80)) {
while (!exec.isShutdown()) {
try {
final Socket conn = socket.accept();
exec.execute(new Runnable() {
@Override
public void run() {
handleRequest(conn);
}
});
} catch (RejectedExecutionException e) {
if (!exec.isShutdown()) {
log("task submission rejected", e);
}
}
}
}
}
public void stop() {
exec.shutdown();
}
private void log(String msg, Exception e) {
Logger.getAnonymousLogger().log(Level.WARNING, msg, e);
}
void handleRequest(Socket connection) {
Request req = readRequest(connection);
if (isShutdownRequest(req)) {
stop();
} else {
dispatchRequest(req);
}
}
private Request readRequest(Socket s) {
return null;
}
private void dispatchRequest(Request r) {}
private boolean isShutdownRequest(Request r) {
return false;
}
interface Request {}
}
延迟任务与周期任务
Timer类负责管理延迟任务和周期任务。然后,Timer类存在一些缺陷(Timer支持基于绝对时间而不是相对时间的调度机制,因此任务的执行对系统时钟变化很敏感,而ScheduledThreadPoolExecutor只支持基于相对时间的调度),因此应该考虑使用ScheduledThreadPoolExecutor来代替。
可以通过ScheduledThreadPoolExecutor的构造函数或newScheduledThreadPool工厂方法来创建该类对象。
Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行事件过长,那么将破坏其他TimerTask的定时精确性。
线程池能弥补这个缺陷,它可以提供多个线程来执行延时任务和周期任务。
Timer线程并不捕获异常,如果TimerTask抛出了一个未受检的异常,那么将终止定时线程。 这种情况下,Timer不会恢复线程的运行,而是会错误的认为整个Timer都被取消了。 因此,已经被调度但尚未执行的TimerTask将不会执行,新的任务也不能被调度。(这个问题被称之为“线程泄漏”)
因此在Java5.0之后的版本,将很少使用Timer。
如果要构建自己的调度服务,可以使用DelayQueue,它实现了BlockingQueue,并为ScheduledThreadPoolExecutor提供调度功能。
DelayQueue管理一组Delayed对象。每个Delayed对象都有一个相应的延迟时间:
在DelayQueue中,只有某个元素与气候,才能从DelayQueue中执行take操作。从DelayQueue中返回的对象将根据它们的延迟时间进行排序。
任务执行策略之间的隐性耦合
有些类型的任务需要明确地执行执行策略,包括:
- 依赖性任务:
- 当线程池中运行任务都是独立的时,我们可以随意地修改池的长度与配置,这不会影响到性能以外的任何事情。
- 但如果你提交到线程池中的任务依赖于其他的任务,这就会隐式地给执行策略带来了约束。
- 使用线程封闭机制的任务:
- 与线程池相比,单线程的Executor能够对并发性作出更强的承诺。它们能够确保任务不会并发的执行,使你能够放宽代码对线程安全的要求。
- 如果将Executor从单线程环境改为线程池环境,那么将失去线程安全性。
- 对响应时间敏感的任务:
- 如果将一个运行时间长的任务提交给单线程的Executor中,或者将多个运行时间较长的任务提交给一个只包含少量线程的线程池中,那么将降低由该excutor管理的服务响应性。
- 使用ThreadLocal的任务:
- Threadlocal使每个线程都可以拥有某个变量的一个私有“版本”。然而,只要条件允许,Executor可以自由的重用这些线程。
- 在标准的Executor实现中,当执行需求较低时将回收空闲线程,而当需求增加时将添加新的线程。
- 只有当线程本地值得生命周期受限于任务的生命周期时,在线程池的线程中使用Threadlocal才有意义,而在线程池中不应该使用Threadlocal在任务之间传递值。
只有当任务都是同类型并且相互独立时,线程池的性能才能达到最佳。如果将运行时间较长的任务与运行时间较短的任务混合在一起,那么除非线程池很大,否则将可能造成“拥塞”。如果提交的任务依赖于其他任务,那么除非线程池无限大,否则将可能造成死锁。
在一些任务中,需要拥有或排除某种特定的执行策略。
如果某些任务依赖于其它的任务,那么会要求线程池足够大,从而确保它们依赖任务不会被放入等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。
通过将这些需求写入文档,将来的代码维护人员就不会由于使用了某种不合适的执行策略而破坏安全性或活跃性。
线程饥饿死锁
在一个线程池中,如果一个任务依赖于其他任务的执行,就可能产生死锁。
- 对应一个单线程话的Executor,一个任务将另一个任务提交到相同的Executor中,并等待新提交的任务的结果,这总会引发死锁。第二个任务滞留在工作队列中,直到第一个任务完成,但是第一个任务不会完成,因为它在等待第二个任务的完成。
- 同样在一个大的线程池中,如果所有线程执行的任务都阻塞在线程池中,等待着仍然处于同一个工作队列中的其他任务,那么会发生同样的问题。
这就是线程饥饿死锁。
public class ThreadDeadlock {
public static ExecutorService exec = Executors.newSingleThreadExecutor();
public static class LoadFileTask implements Callable<String> {
private final String fileName;
public LoadFileTask(String fileName) {
this.fileName = fileName;
}
@Override
public String call() throws Exception {
// Here's where we would actually read the file
System.out.println("execute");
return fileName;
}
}
public static class RenderPageTask implements Callable<String> {
@Override
public String call() throws Exception {
Future<String> header, footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec.submit(new LoadFileTask("footer.html"));
String page = renderBody();
// Will deadlock -- task waiting for result of subtask
return header.get() + page + footer.get();
}
private String renderBody() {
// Here's where we would actually render the page
return "";
}
}
public static void main(String[] args) throws Exception {
// 主任务依赖次要任务,但是没有足够的线程执行次要任务造成饥饿死锁
Future<String> submit = ThreadDeadlock.exec.submit(new RenderPageTask());
System.out.println(submit.get());// 一直阻塞
ThreadDeadlock.exec.shutdown();
ThreadDeadlock.exec.awaitTermination(10, TimeUnit.SECONDS);
}
}
运行时间较长的任务
如果任务阻塞时间过长,那么即使不出现死锁,线程池的响应性也会变得糟糕。 执行时间较长的任务不仅会造成线程池堵塞,甚至会增加执行时间较短任务的服务时间。 如果线程池中的数量远小于在稳定状态下执行时间较长任务的数量,那么到最后可能所有线程都会运行这些执行时间较长的任务,从而影响整体的响应性。
缓解这个问题的技术就是限定等待资源的时间,而不是无限制等待。
例如Thraed.join(),BlockingQueue.put()、CountDownLatch.await()等,如果等待超时,可以把任务标识为失败,然后终止任务或将任务重新放回队列以便随后执行。
设值线程池的大小
线程池的理想大小取决于被提交任务的类型以及所部属系统的特性。在系统中一般不固定线程池的大小,而是通过某种配置机制来提供。或者根据Runtime.getRuntime().availableProcessors()来决定。
对于设置线程池的大小我们需要考虑的问题有:
- 机器有多少个CPU?
- 多大内存?
- 任务是计算密集型还是IO密集型?
- 是否需要像JDBC这种稀缺资源?
计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时通常能实现最优利用率。
对于IO密集型的任务,由于包括IO和阻塞操作的任务,线程不会一直执行即不会一直用到CPU,所以需要更多的线程,具体的多少得根据一些资源需求和计算公式得到。
线程池大小=CPU个数 CPU期望利用率 (1 + 任务等待时间 / 任务处理时间)
线程等待时间所占比例越高,需要越多线程。
线程CPU时间所占比例越高,需要越少线程。
当然,CPU周期并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄和数据库连接等。计算这些资源对线程池的约束条件是更容易的:
计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,所得结果就是线程池大小的上限。
当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大小将会互相影响。
如果每个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的大小
同样,当线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小又将限制连接池的大小
配置ThreadPoolExecutor
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
线程的创建与销毁
线程池的基本大小corePoolSize(池中所保留的线程数量,即使是空闲线程)、最大大小maximumPoolSize(池中允许最大的线程数量)、存活时间keepAliveTime(当线程数大于corePoolSize时,允许多余的空闲线程所等待新任务的最长时间,如果超过个时间将会被停止掉)等因素共同负责线程的创建与销毁。
- 基本大小也就是线程池的目标大小,即在没有任务执行时线程池的大小(在创建ThreadPoolExecutor初期,线程并不会立即启动,而是等到有任务提交时才会启动,除非调用prestartAllCoreThreads),并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
- 如果线程池中的线程数量等于线程池的基本大小,那么仅当在工作队列已满的情况下ThreadPoolExecutor才会创建新的线程。因此,如果线程池的基本大小为零并且其工作队列有一定的容量,那么当把任务提交给该线程池时,只有当线程池的工作队列被填满后,才会开始执行任务,而这种行为通常不是我们所希望的。
- 在Java6中,可以通过allowCoreThreadTimeOut来使线程池中的所有线程超时。对于一个大小有限的线程池并且在该线程池中包含了一个工作队列,如果希望和这个线程池在没有任务的情况下能销毁所有的线程,那么可以启用这个特性并将基本大小设置为零。
- 线程池的最大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收的,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
ThreadPoolExecutor中的策略:
- newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。
- newCachedThreadPool工厂方法将线程池的最大大小设置为Integer.MAX_VALUE,而将基本大小设置为零,并将超时时间设置为1分钟,这种方法创建出来的线程池可以被无限扩展,并且当需求降低时会自动收缩。其他形式的线程池可以通过显式地ThreadPoolExecutor构造函数来构造。
管理队列任务
通过一个Runnable和一个链表节点来表现一个等待中的任务,当然比使用线程来表示的开销低很多,但如果客户提交给服务器请求的速率超过了服务器的处理速率,那么仍可能会耗尽资源。
即使请求的平均到达速率很稳定,也仍然会出现请求突增的情况。尽管队列有助于缓解任务的突增问题,但如果任务持续高速地到来,那么最终还是会抑制请求的到达率以避免耗尽内存。甚至在耗尽内存之前,响应性也将随着任务队列的增长而变得越来越糟。
ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务排队方法由3种:无界队列、有界队列和同步移交。队列的选择与其他的配置参数有关,例如线程池的大小等。
newFixedThreadPool和newSingleThreadPool在默认情况下将使用一个无界的LinkedBlockingQueue。如果所有工作者线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续快速地到达,并且超过了线程池处理它们的速度,那么队列将无限制地增加。
一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue、有界的LinkedBlockingQueue、PriorityBlockingQueue。
有界队列有助于避免资源耗尽的情况发生,但它又带来了新的问题:当队列填满后,新的任务该怎么办?
对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。 在newCachedThreadPool工厂方法中就使用了SynchronousQueue。
SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接受这个元素。 如果没有线程正在等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建一个新的线程,否则根据饱和策略,这个任务将被拒绝。 使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被首先放在队列中,然后由工作者线程从队列中提取该任务。 只有当线程池是无界的活着可以拒绝任务时,SynchronousQueue才有实际价值。
当使用像LinkedBlockingQueue或ArrayBlockingQueue这样的FIFO(先进先出)队列时,任务的执行顺序与它们的到达顺序相同。
如果想进一步控制任务执行顺序,还可以使用PriorityBlockingQueue,这个队列将根据优先级来安排任务。任务的优先级是通过自然顺序或Comparator(如果任务实现了Comparable)来定义的。
对于Executor,newCachedThreadPool工厂方法是一种很好的默认选择,它能提供比固定大小的线程池更好的排队性能(由于SynchronousQueue的高性能)。 当需要限制当前任务的数量以满足资源管理需求时,那么可以选择固定大小的线程池,就像在接受网络客户请求的服务器应用和程序中,如果不进行限制,那么很容易发生过载问题。
只有当任务相互独立时,为线程池或工作队列设置界限才是合理的。
- 如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程“饥饿”死锁问题。此时应该使用无界的线程池,例如newCachedThreadPool。
- 还有一种可选的配置策略:使用一个受限的线程池,工作队列选用SynchronousQueue,饱和策略选择“调用者运行(Caller-Runs)”策略。
饱和策略
当有界队列被填满后,饱和策略开始发挥作用。(如果某个任务被提交到一个已被关闭的Executor时,也会用到饱和策略。)
ThreadPoolExecutor的饱和策略可以通过调用setRectedExecutionHandler来修改。JDK提供了几种不同的RejectedExecutionHandler实现,每种实现都包含有不同的饱和策略:
AbortPolicy、CallerRunsPolicy、DiscardPolicy和DiscardOldestPolicy。
“中止(Abort)“策略是默认的饱和策略,该策略将抛出未检查的RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
当新提交的任务无法保存到队列中等待执行时,“抛弃”策略会悄悄抛弃该任务。“抛弃最旧的”策略则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。
如果工作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将“抛弃最旧的”饱和策略和优先队列放在一起使用。
“调用者运行”策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。
由于执行任务需要一定的时间,因此主线程至少在一段时间内不能提交任何任务,从而使得工作者线程有时间来处理完正在执行的任务。
在这期间,主线程不会调用accept,因此到达的请求将被保存在TCP层的队列中而不是在应用程序的队列中。
如果持续过载,那么TCP层将最终发现它的请求队列被填满,因此同样会开始抛弃请求。
当服务器过载时,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终到达客户端,导致服务器在高负载下实现一种平缓的性能降低。
使用Semaphore(信号量)来限制任务的到达率,来阻塞execute。
@ThreadSafe
public class BoundedExecutor {
private final Executor exec;
private final Semaphore semaphore;
public BoundedExecutor(Executor exec, int bound) {
this.exec = exec;
this.semaphore = new Semaphore(bound);
}
public void submitTask(final Runnable command) throws InterruptedException {
semaphore.acquire();
try {
exec.execute(new Runnable() {
@Override
public void run() {
try {
command.run();
} finally {
semaphore.release();
}
}
});
} catch (RejectedExecutionException e) {
semaphore.release();
}
}
}
线程工厂
默认的线程工厂方法将创建一个新的、非守护的线程,并且不包含特殊的配置信息。通过指定一个线程工厂方法,可以定制线程池的配置信息。
可以使用guava库提供的ThreadFactoryBuilder或者自定义ThreadFactory
private ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d").build();
public class MyThreadFactory implements ThreadFactory {
private final String poolName;
public MyThreadFactory(String poolName) {
this.poolName = poolName;
}
@Override
public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable, poolName);
}
}
// 定制了线程名字,设置了UncaughtExceptionHandler,维护了一些统计信息及日志记录
public class MyAppThread extends Thread {
public static final String DEFAULT_NAME = "MyAppThread";
private static final AtomicInteger created = new AtomicInteger();
private static final AtomicInteger alive = new AtomicInteger();
private static final Logger log = Logger.getAnonymousLogger();
private static volatile boolean debugLifecycle = false;
public MyAppThread(Runnable r) {
this(r, DEFAULT_NAME);
}
public MyAppThread(Runnable runnable, String name) {
super(runnable, name + "-" + created.incrementAndGet());
setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t,
Throwable e) {
log.log(Level.SEVERE,"UNCAUGHT in thread " + t.getName(), e);
}
});
}
public static int getThreadsCreated() {
return created.get();
}
public static int getThreadsAlive() {
return alive.get();
}
public static boolean getDebug() {
return debugLifecycle;
}
public static void setDebug(boolean b) {
debugLifecycle = b;
}
@Override
public void run() {
// Copy debug flag to ensure consistent value throughout.
boolean debug = debugLifecycle;
if (debug) {
log.log(Level.FINE, "Created " + getName());
}
try {
alive.incrementAndGet();
super.run();
} finally {
alive.decrementAndGet();
if (debug) {
log.log(Level.FINE, "Exiting " + getName());
}
}
}
}
如果在应用和程序中需要利用安全策略来控制对某些特殊代码库的访问权限,那么可以通过Executors中的privilegedThreadFactory工厂来定制自己的线程工厂。
通过这种方式创建出来的线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限、AccessControlContext和contextClassLoader。
如果不使用privilegedThreadFactory,线程池创建的线程将从在需要新线程时调用execute或submit的客户端代码中继承访问权限,从而导致令人困惑的安全性异常。
在构造后再定制ThreadPoolExecutor
在Executors中包含一个unconfigurableExecutorService工厂方法,该方法对一个现有的ExecutorService进行包装,使其只暴露出ExecutorService的方法,因此不能对它进行配置。 如果将ExecutorService暴露给不信任的代码,又不希望对其进行修改,就可以通过unconfigurableExecutorService来包装它。
newSingleThreadExecutor返回按这种方式封装的ExecutorService,而不是最初的ThreadPoolExecutor。虽然单线程的Executor实际上被实现为一个只包含唯一线程的线程池,但它同样确保了不会并发地执行任务。如果在代码中增加单线程Executor的线程池大小,那么将破坏它的执行语义。
扩展ThreadPoolExecutor
ThreadPoolExecutor是可扩展的,它提供了几个“钩子”方法可以在子类化中改写:beforeExecute、afterExecute和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。
在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志、计时、监视或统计信息收集的功能。
无论任务是从run中正常返回,还是抛出一个异常而返回,afterExecute都会被调用。(如果任务在完成后带有一个Error,那么就不会调用afterExecute。)
如果beforeExecute抛出了一个RuntimeException,那么任务将不会被执行,并且afterExecute也不会被调用。
在线程池完成关闭操作时地暗涌terminated,也就是所有任务都已经完成并且所有工作者线程也已经关闭后。
terminated可以用来释放Executor在其生命周期里分配的各种资源,此外,还可以执行发送通知、记录日志、或者收集finalize统计信息等操作
public class TimingThreadPool extends ThreadPoolExecutor {
private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
private final Logger logger = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
public TimingThreadPool() {
super(1, 1, 0L, TimeUnit.SECONDS, null);
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
logger.fine(String.format("Thread %s: start %s", t, r));
startTime.set(System.nanoTime());
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
try {
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
numTasks.incrementAndGet();
totalTime.addAndGet(taskTime);
logger.fine(String.format("Thread %s: end %s, time=%dns", t, r, taskTime));
} finally {
super.afterExecute(r, t);
}
}
@Override
protected void terminated() {
try {
logger.info(String.format("Terminated: avg time=%dns", totalTime.get() / numTasks.get()));
} finally {
super.terminated();
}
}
}
递归算法的并行化
当串行循环中的各个迭代操作彼此独立时,并行每个迭代操作执行的工作亮比管理一个新任务带来的开销更多,那么这个串行循环就适合并行化。
public <T> void sequentialRecursive(List<Node<T>> nodes, Collection<T> results) {
for (Node<T> n : nodes) {
results.add(n.compute());
sequentialRecursive(n.getChildren(), results);
}
}
// 在每个迭代操作中都不需要来自于后续递归迭代的结果
public <T> void parallelRecursive(final Executor exec, List<Node<T>> nodes,
final Collection<T> results) {
for (final Node<T> n : nodes) {
exec.execute(new Runnable() {
@Override
public void run() {
results.add(n.compute());
}
});
parallelRecursive(exec, n.getChildren(), results);
}
}
// 创建一个特定于遍历过程的Executor,并使用shutdown和awaitTermination等方法,等待上面并行运行的结果
public <T> Collection<T> getParallelResults(List<Node<T>> nodes) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
Queue<T> resultQueue = new ConcurrentLinkedQueue<T>();
parallelRecursive(exec, nodes, resultQueue);
exec.shutdown();
exec.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
return resultQueue;
}
谜题框架
谜题定义:包含了一个初始位置,一个目标位置,以及用户判断是否有效移动的规则集
规则集包含两部分:计算从指定位置开始的所有合法移动,以及每次移动的结果位置
// P和M代表位置类和移动类
public interface Puzzle<P, M> {
P initialPosition();
boolean isGoal(P position);
Set<M> legalMoves(P position);
P move(P position, M move);
}
@Immutable
public class PuzzleNode<P, M> {
final P pos;
final M move;
final PuzzleNode<P, M> prev;
public PuzzleNode(P pos, M move, PuzzleNode<P, M> prev) {
this.pos = pos;
this.move = move;
this.prev = prev;
}
List<M> asMoveList() {
List<M> solution = new LinkedList<M>();
for (PuzzleNode<P, M> n = this; n.move != null; n = n.prev) {
solution.add(0, n.move);
}
return solution;
}
}
// 串行版本的程序执行深度优先搜索,搜索过程受限于栈的大小
// 并发版本执行广度优先搜索,因此不会受栈大小限制
public class ConcurrentPuzzleSolver<P, M> {
protected final ValueLatch<PuzzleNode<P, M>> solution = new ValueLatch<PuzzleNode<P, M>>();
private final Puzzle<P, M> puzzle;
private final ExecutorService exec;
private final ConcurrentMap<P, Boolean> seen;
public ConcurrentPuzzleSolver(Puzzle<P, M> puzzle) {
this.puzzle = puzzle;
this.exec = initThreadPool();
this.seen = new ConcurrentHashMap<P, Boolean>();
if (exec instanceof ThreadPoolExecutor) {
ThreadPoolExecutor tpe = (ThreadPoolExecutor) exec;
tpe.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
}
}
private ExecutorService initThreadPool() {
return Executors.newCachedThreadPool();
}
public List<M> solve() throws InterruptedException {
try {
P p = puzzle.initialPosition();
exec.execute(newTask(p, null, null));
// block until solution found
PuzzleNode<P, M> solnPuzzleNode = solution.getValue();
return (solnPuzzleNode == null) ? null : solnPuzzleNode.asMoveList();
} finally {
exec.shutdown();
}
}
protected Runnable newTask(P p, M m, PuzzleNode<P, M> n) {
return new SolverTask(p, m, n);
}
protected class SolverTask extends PuzzleNode<P, M> implements Runnable {
SolverTask(P pos, M move, PuzzleNode<P, M> prev) {
super(pos, move, prev);
}
@Override
public void run() {
if (solution.isSet() || seen.putIfAbsent(pos, true) != null) {
return; // already solved or seen this position
}
if (puzzle.isGoal(pos)) {
solution.setValue(this);
} else {
for (M m : puzzle.legalMoves(pos)) {
exec.execute(newTask(puzzle.move(pos, m), m, this));
}
}
}
}
}
// 携带结果的闭锁
@ThreadSafe
public class ValueLatch<T> {
private final CountDownLatch done = new CountDownLatch(1);
@GuardedBy("this")
private T value = null;
public boolean isSet() {
return (done.getCount() == 0);
}
public T getValue() throws InterruptedException {
done.await();
synchronized (this) {
return value;
}
}
public synchronized void setValue(T newValue) {
if (!isSet()) {
value = newValue;
done.countDown();
}
}
}
// 在寻找失败时优雅的停止
public class PuzzleSolver<P, M> extends ConcurrentPuzzleSolver<P, M> {
private final AtomicInteger taskCount = new AtomicInteger(0);
PuzzleSolver(Puzzle<P, M> puzzle) {
super(puzzle);
}
@Override
protected Runnable newTask(P p, M m, PuzzleNode<P, M> n) {
return new CountingSolverTask(p, m, n);
}
class CountingSolverTask extends SolverTask {
CountingSolverTask(P pos, M move, PuzzleNode<P, M> prev) {
super(pos, move, prev);
taskCount.incrementAndGet();
}
@Override
public void run() {
try {
super.run();
} finally {
if (taskCount.decrementAndGet() == 0) {
solution.setValue(null);
}
}
}
}
}
找出可利用的并行性
携带结果的任务Callable与Future
Callable
Executor框架使用Runnable作为其基本的任务表示形式。Runnable是一种有很大局限的抽象,它不能返回一个值或抛出一个受检查的异常。
对于存在延迟的计算,Callable是一种更好的抽象:它认为主入口点将返回一个值,并可能抛出一个异常。(可以使用Callable<Void>
表示无返回值的任务)。Executor中包含了一些辅助方法将其他类型的任务封装为一个Callable。
Future
Future表示一个任务的生命周期,并提供了相应的方法判断是否已经完成或取消,以及获取任务的结果和取消任务。
Future规范中包含的隐含意义是,任务的生命周期只能前进,不能后退。当某个任务完成后,就永远停留在“完成”状态上。
get方法的行为取决于任务的状态:
- 已完成的任务,get会立刻返回或抛出Exception。任务没完成,get将阻塞到任务完成。
- 任务抛出异常时,get将该异常封装为ExecutionException重新抛出。
- 如果get抛出ExecutionException,可以通过getCause来获得被封装的初始异常。
- 任务取消时get抛出CancellationException。
ExecutorService返回Future:
ExecutorService中的所有submit方法都返回一个Future,从而将一个Runnable或Callable提交给Executor,并得到一个Future用来获得任务的执行结果或取消任务。 还可以显式的为某个指定的Runnable或Callable实例化一个FutureTask。FutureTask实现了Runnable,可以被提交给Executor执行,或直接调用run()方法。
Java6开始,ExecutorService实现可以改写AbstractExecutorService中的newTaskFor方法,从而根据已提交的Runnable或Callable来控制Future的实例化过程。在默认实现中仅创建了一个新的FutureTask。
在将Runnable或Callable提交到Executor的过程中,包含了一个安全发布过程,即将Runnable或Callable从提交线程发布到最终执行任务的线程。 在设置Future结果的过程中也包含了一个安全发布,即将这个结果从计算它的线程发布到任何通过get获得它的线程。
get方法拥有“状态依赖”的内在特性,因而调用者不需要知道任务的状态,此外在任务提交和获得结果中包含的安全发布属性也确保了这个方法是线程安全的。
Future.get的异常处理代码将处理两个可能的问题:
任务遇到一个Exception,或者调用get的线程在获得结果之前被中断。
// 异步获取图片和渲染文本
public abstract class FutureRenderer {
// 这里使用自定义ExecutorService
private final ExecutorService executor = ...;
void renderPage(CharSequence source) {
final List<ImageInfo> imageInfos = scanForImageInfo(source);
// 分离下载线程
Callable<List<ImageData>> task =
new Callable<List<ImageData>>() {
@Override
public List<ImageData> call() {
List<ImageData> result = new ArrayList<ImageData>();
for (ImageInfo imageInfo : imageInfos) {
result.add(imageInfo.downloadImage());
}
return result;
}
};
Future<List<ImageData>> future = executor.submit(task);
renderText(source);
try {
// 下载结束后渲染所有
List<ImageData> imageData = future.get();
for (ImageData data : imageData) {
renderImage(data);
}
} catch (InterruptedException e) {
// Re-assert the thread's interrupted status
Thread.currentThread().interrupt();
// We don't need the result, so cancel the task too
future.cancel(true);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
abstract void renderText(CharSequence s);
abstract List<ImageInfo> scanForImageInfo(CharSequence s);
abstract void renderImage(ImageData i);
interface ImageData {}
interface ImageInfo {
ImageData downloadImage();
}
}
在异构任务并行化中的局限
通过对异构任务进行并行化来获得重大的性能提升是很困难的。
如果没有找出相似的任务之间找出细粒度的并行性,那么这种方法带来的好处将减少。为了使任务分解提高性能,任务协调开销不能高于并行实现的提升。
只有当大量相互独立且同构的任务可以并发进行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正性能提升
CompletionService:Executor与BlockingQueue
CompletionService将Executor和BlockingQueue的功能融合在一起。 可以将Callable任务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获得已完成的结果,这些结果在完成时会封装成Future。 ExecutorCompletionService实现了CompletionService,并将计算部分委托给一个Executor。
ExecutorCompletionService的实现非常简单。在构造函数中创建一个BlockingQueue来保存计算完成的结果。计算完成时,调用Future-Task中的done方法。 当提交某个任务时,该任务将首先包装为一个QueueingFuture,这是FutureTask的一个子类,然后再改写子类的done方法,并将结果放入BlockingQueue中。 take和poll方法委托给了BlockingQueue,行为与BlockingQueue一致。
多个ExecutorCompletionService可以共享一个Executor,因此可以创建一个对于特定计算私有,又能共享一个公共Executor的ExecutorCompletionService。 因此,CompletionService的作用就相当于一组计算的句柄,这与Future作为单个计算的句柄类似。 通过记录提交给CompletionService的任务数量,并计算出已经获得的已完成结果的数量,即使使用一个共享的Executor,也能知道已经获得了所有任务结果的时间
public abstract class Renderer {
private final ExecutorService executor;
Renderer(ExecutorService executor) {
this.executor = executor;
}
void renderPage(CharSequence source) {
final List<ImageInfo> info = scanForImageInfo(source);
CompletionService<ImageData> completionService =
new ExecutorCompletionService<>(executor);
for (final ImageInfo imageInfo : info) {
// 分配单独线程下载图片
completionService.submit(new Callable<ImageData>() {
@Override
public ImageData call() {
return imageInfo.downloadImage();
}
});
}
renderText(source);
try {
for (int t = 0, n = info.size(); t < n; t++) {
// 获取结果直接渲染
Future<ImageData> f = completionService.take();
ImageData imageData = f.get();
renderImage(imageData);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
abstract void renderText(CharSequence s);
abstract List<ImageInfo> scanForImageInfo(CharSequence s);
abstract void renderImage(ImageData i);
interface ImageData {}
interface ImageInfo {
ImageData downloadImage();
}
}
为任务设置时间
有时,如果某个任务无法在指定时间内完成,那么将不再需要它的结果,此时可以放弃这个任务。
在支持时间限制的Future.get中支持这种需求:当结果可用时,它将立即返回,如果在指定时限内没有计算出结果,将抛出TimeoutException。
在使用限时任务是需要注意,当这些任务超时后应该立即停止,从而避免为计算一个不再使用的结果而浪费计算资源。
要实现这个功能,可以由任务本身来管理它的限时事件,并在超时后终止执行或取消任务。 此时可再次使用Future,如果一个限时的get方法抛出了TimeoutException,那么可以通过Future来取消任务。 如果编写的任务是可取消的,那么可以提前终止它,以免消耗过多的资源。
// 超时加载默认广告并取消任务
public class RenderWithTimeBudget {
private static final Ad DEFAULT_AD = new Ad();
private static final long TIME_BUDGET = 1000;
private static final ExecutorService exec = Executors.newCachedThreadPool();
Page renderPageWithAd() throws InterruptedException {
long endNanos = System.nanoTime() + TIME_BUDGET;
Future<Ad> future = exec.submit(new FetchAdTask());
// Render the page while waiting for the ad
Page page = renderPageBody();
Ad ad;
try {
// Only wait for the remaining time budget
long timeLeft = endNanos - System.nanoTime();
ad = future.get(timeLeft, TimeUnit.NANOSECONDS);
} catch (ExecutionException e) {
ad = DEFAULT_AD;
} catch (TimeoutException e) {
ad = DEFAULT_AD;
future.cancel(true);
}
page.setAd(ad);
return page;
}
Page renderPageBody() {
return new Page();
}
static class Ad {}
static class Page {
public void setAd(Ad ad) {
}
}
static class FetchAdTask implements Callable<Ad> {
@Override
public Ad call() {
return new Ad();
}
}
}
invokeAll
invokeAll方法的参数为一组任务,返回一组Future。这两个集合有着相同的结构。 invokeAll按照任务集合中迭代器的顺序将所有的Future添加到返回的集合中,从而使调用者能将Future与其表示的Callable关联起来。 当所有任务都执行完毕、或调用线程被中断、或超时时,invokeAll将返回。当超时后,任何还未完成的任务都会取消。 invokeAll返回后,每个任务要么正常的完成,要么被取消,客户端代码可以调用get或isCancelled判断情况。
interface TravelCompany {
TravelQuote solicitQuote(TravelInfo travelInfo) throws Exception;
}
interface TravelQuote {}
interface TravelInfo {}
public class TimeBudget {
private static ExecutorService exec = Executors.newCachedThreadPool();
public List<TravelQuote> getRankedTravelQuotes(
TravelInfo travelInfo,
Set<TravelCompany> companies,
Comparator<TravelQuote> ranking,
long time,
TimeUnit unit)
throws InterruptedException {
List<QuoteTask> tasks = new ArrayList<QuoteTask>();
for (TravelCompany company : companies) {
tasks.add(new QuoteTask(company, travelInfo));
}
List<Future<TravelQuote>> futures = exec.invokeAll(tasks, time, unit);
List<TravelQuote> quotes =
new ArrayList<TravelQuote>(tasks.size());
Iterator<QuoteTask> taskIter = tasks.iterator();
for (Future<TravelQuote> f : futures) {
QuoteTask task = taskIter.next();
try {
quotes.add(f.get());
} catch (ExecutionException e) {
quotes.add(task.getFailureQuote(e.getCause()));
} catch (CancellationException e) {
quotes.add(task.getTimeoutQuote(e));
}
}
Collections.sort(quotes, ranking);
return quotes;
}
}
class QuoteTask implements Callable<TravelQuote> {
private final TravelCompany company;
private final TravelInfo travelInfo;
public QuoteTask(TravelCompany company, TravelInfo travelInfo) {
this.company = company;
this.travelInfo = travelInfo;
}
TravelQuote getFailureQuote(Throwable t) {
return null;
}
TravelQuote getTimeoutQuote(CancellationException e) {
return null;
}
@Override
public TravelQuote call() throws Exception {
return company.solicitQuote(travelInfo);
}
}
取消与关闭
Java没有提供任何机制来安全地终止线程(Thread.stop 和 suspend 等方法存在着一些严重缺陷,应该避免使用),但它提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的工作。
立即停止会使共享的数据结构处于不一致的状态,需要停止时,发出中断请求,被要求中断的线程处理完他当前的任务后会自己判断是否停下来
一个行为良好的软件与勉强运行的软件之间的最主要区别就是,行为良好的软件能很完善的处理失败、关闭和取消等过程。
任务取消
如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的。
取消某个操作的原因很多:
- 用户请求取消
- 有时间限制的操作
- 应用程序事件
- 多线程任务中一个已经得到结果,其他程序应该取消
- 错误
- 如磁盘满无法继续写入
- 关闭
- 服务关闭
在Java中没有一种安全的抢占方法来停止线程,因此没有安全的抢占式方法停止任务,只有协作式的机制。
其中一种协作机制是设置某个 已取消请求Cancellation requested标志,任务定期的查看该标志。
取消策略:
详细地定义取消操作的“How”、“When”以及“What”,即其他代码如何(How)请求取消该任务,任务在何时(When)检查是否已经请求了取消,以及在响应取消请求时应该执行哪些(What)操作
// 客户代码通过cancel取消请求
// 每次操作前首先检查是否存在取消请求,如果存在就推出
@ThreadSafe
public class PrimeGenerator implements Runnable {
private static ExecutorService exec = Executors.newCachedThreadPool();
@GuardedBy("this")
private final List<BigInteger> primes = new ArrayList<BigInteger>();
private volatile boolean cancelled;
static List<BigInteger> aSecondOfPrimes() throws InterruptedException {
PrimeGenerator generator = new PrimeGenerator();
exec.execute(generator);
try {
SECONDS.sleep(1);
} finally {
generator.cancel();
}
return generator.get();
}
@Override
public void run() {
BigInteger p = BigInteger.ONE;
while (!cancelled) {
p = p.nextProbablePrime();
synchronized (this) {
primes.add(p);
}
}
}
public void cancel() {
cancelled = true;
}
public synchronized List<BigInteger> get() {
return new ArrayList<BigInteger>(primes);
}
}
注意:这是一个有问题的取消方式,若线程阻塞在add操作后,那么即使设置了取消状态,它也不会运行到检验阻塞状态的代码,因此会永远阻塞
中断
在Java的API或语言规范中,并没有将中断与任何取消语义关联起来,但实际上,如果在取消之外的其他操作中使用中断,都是不合适的,并且很难支撑起更大的应用。
每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置为true。在Thread中包含了中断线程以及查询线程中断状态的方法。 interrupt方法能中断目标线程,isInterrupted方法能返回目标线程的中断状态。static的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法
阻塞库方法,例如Thread.sleep和Object.wait等,都会检查线程何时中断,并且在发现时提前返回。
它们在响应中断时执行的操作包括 :
清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。
当线程在非阻塞状态时,它的中断状态将被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。
通过这样的方法,中断操作将变得“有粘性”——如果不触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。
调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
对于中断操作的正确理解是:
它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时候中断自己。
- wait、join、sleep等将严格处理这种请求,他们收到中断请求或者在开始执行时发现某个已被设置好的终端状态时,将会抛出一个异常
- 设计良好的方法应严格的处理这种请求,如果方法能使调用代码对中断请求进行某种处理,那么完全可以忽略这种请求。
- 但在无法处理的时候,应该抛出InterruptedException,以便其他代码可以正确的处理中断请求。
使用 interrupted时应注意。如果在调用interrupted时返回了true,那么除非想屏蔽这个中断,否则必须对它进行处理。可以抛出InterruptedException,或者通过再次调用interrupt来恢复中断状态。
public class Thread {
// 中断目标线程,恢复中断状态
public void interrupt() { ... }
// 返回目标线程的中断状态
public boolean isInterrupted() { ... }
// 清除当前线程的中断状态,并返回它之前的值(用于已经设置了中断状态,但还尚未相应中断)
public static boolean interrupted() { ... }
...
}
interrupt方法用于中断线程。调用该方法的线程的状态为将被置为”中断”状态。
注意:线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。 支持线程中断的方法(也就是线程中断后会抛出interruptedException的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常。
通常,中断是实现取消的最合理方式
// 存在自定义取消机制无法与可阻塞的库函数实现良好交互的问题
// 如果任务带么能够响应中断,那么可以使用中断作为取消机制,并且利用许多类库中提供的中断支持
class BrokenPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
private volatile boolean cancelled = false;
BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!cancelled) {
queue.put(p = p.nextProbablePrime());
}
} catch (InterruptedException consumed) {
}
}
public void cancel() {
cancelled = true;
}
}
解决上面的不协调问题:
// 使用中断而不是boolean类型请求取消
// 由于调用了queue的put方法,所以并不需要使用显示检测
// 但是显示检测可以提高程序的响应性
public class PrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
PrimeProducer(BlockingQueue<BigInteger> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!Thread.currentThread().isInterrupted()) {
queue.put(p = p.nextProbablePrime());
}
} catch (InterruptedException consumed) {
/* Allow thread to exit */
}
}
public void cancel() {
interrupt();
}
}
中断策略
中断策略规定线程如何解释某个中断请求——当发现中断请求时,应该做哪些工作(如果需要的话),哪些工作单元对于中断来说是原子操作,以及以多快的速度来响应中断。
区分任务和线程对中断的反应是很重要的。一个中断请求可以有一个或多个接受者——中断线程池中的某个工作者线程,同事意味着“取消当前任务”和“关闭工作者线程”
这就是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断响应。它们永远不会在某个由自己拥有的线程中运行,因此它们为任务或库代码实现了最合理的取消策略:
尽快退出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。
当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断请求,并直到某个更合适的时候。 因此需要记住中断请求,并在完成当前任务后抛出InterruptedException或者表示已收到中断请求。 这项技术能够确保在更新过程中发生中断时,数据结构不会被破坏。
任务并不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。
无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。 如果除了将InterruptedException传递给调用者外还需要执行其他操作,那么应该在捕获InterruptedException之后恢复中断状态:
Thread.currentThread().interrupt();
正如任务代码不应该对其执行所在的线程的中断策略做出假设,执行取消操作的代码也不应该对线程的中断策略做出假设。 线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,例如shutdown方法
由于每个线程拥有各自的中断策略,所以除非知道中断对该线程的含义,否则就不应该中断这个线程。
响应中断
当调用可中断的阻塞函数时,有两种实用策略可用于处理InterruptedException
- 传递异常(可能在执行某个特定于任务的清除操作之后),从而使得你的方法也成为可中断的阻塞方法。
- 恢复中断状态,从而使得调用栈中的上层代码能够对其处理。
// 传递异常
public Task getNextTask(BlockingQueue<Task> queue) throws InterruptedException {
// ...
}
如果你不想或者无法传递InterruptedException(或许通过Runnable来定义任务),一个标准的做法就是通过调用interrupt来恢复中断状态。
只有实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。
对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是捕获 InterruptedException 时恢复状态
public class NoncancelableTask {
public Task getNextTask(BlockingQueue<Task> queue) {
boolean interrupted = false;
try {
while (true) {
try {
return queue.take();
} catch (InterruptedException e) {
interrupted = true;
// fall through and retry
}
}
} finally {
if (interrupted) {
// 向上传递中断状态
Thread.currentThread().interrupt();
}
}
}
interface Task {
}
}
不要在外围线程中安排中断,因为不了解外围线程的中断策略。在单独的线程中中断任务:
// 依赖于一个限时的join,存在join的不足:无法获知正常退出还是join超时返回
public class TimedRun {
private static final ScheduledExecutorService cancelExec = newScheduledThreadPool(1);
public static void timedRun(final Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
class RethrowableTask implements Runnable {
private volatile Throwable t;
@Override
public void run() {
try {
r.run();
} catch (Throwable t) {
this.t = t;
}
}
void rethrow() {
if (t != null) {
throw LaunderThrowable.launderThrowable(t);
}
}
}
RethrowableTask task = new RethrowableTask();
final Thread taskThread = new Thread(task);
taskThread.start();
cancelExec.schedule(new Runnable() {
@Override
public void run() {
// 尝试中断taskThread,有Runnable对象r处理中断响应
taskThread.interrupt();
}
}, timeout, unit);
// 线程加入(等待某线程执行结束后才继续执行)
taskThread.join(unit.toMillis(timeout));
task.rethrow();
}
}
通过Future来实现取消
Future是对管理任务的生命周期、处理异常以及实现取消的一种抽象机制。
ExecutorService.submit将返回一个Future来描述任务。
Future拥有一个cancel方法,该方法带有一个boolean类型的参数mayInterruptIfRunning,表示取消操作是否成功(是否中断正在运行的任务)。(这只是表示任务是否能够接收中断,而不是表示任务是否能检测并处理中断。)
- 如果mayInterruptIfRunning为true并且任务当前正在某个线程中运行,那么这个线程能被中断。
- 如果这个参数为false,那么意味着“若任务还没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。
public class TimedRun {
private static final ExecutorService taskExec = Executors.newCachedThreadPool();
public static void timedRun(Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
Future<?> task = taskExec.submit(r);
try {
task.get(timeout, unit);
} catch (TimeoutException e) {
// task will be cancelled below
} catch (ExecutionException e) {
// exception thrown in task; rethrow
throw launderThrowable(e.getCause());
} finally {
// Harmless if task already completed
task.cancel(true); // interrupt if running
}
}
}
当Future.get抛出InterruptedException或TimeoutException时,如果你知道不再需要结果,那么就可以调用Future.cancel来取消任务。
处理不可中断的阻塞
在Java库中,许多可阻塞的方法都是通过提前返回或者抛出InterruptedException来响应中断请求的,从而使开发人员更容易构建出能响应取消请求的任务。
然而,并非所有的可阻塞方法或者阻塞机制都能响应中断;如果一个线程由于执行同步的Socket I/O或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。
对于那么由于执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因。
- Java.io包中的同步Socket I/O
- 虽然InputStream和OutputStream中的read和write等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行read或write等方法而被阻塞的线程抛出一个SocketException。
- Java.io包中的同步I/O
- 当中断一个正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptedException)并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出ClosedByInterruptedException)。当关闭一个InterruptibleChannel时,将导致所有在链路操作上阻塞的线程抛出AsynchronousCloseException。大多数标准的Channel都实现了InterruptibleChannel。
- Selector的异步I/O
- 如果一个线程在调用Selector.select方法(在java.nio.channels中)时阻塞了,那么调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回。
- 获取某个锁
- 如果一个线程由于等待某个内置锁而被阻塞,那么将无法响应中断,因为线程认为它肯定获得锁,所以将不会理会中断请求。但是,在Lock类中提供了lockInterruptibly方法,该方法允许在等待一个锁的同时仍能响应中断。
public class ReaderThread extends Thread {
private static final int BUFSZ = 512;
private final Socket socket;
private final InputStream in;
public ReaderThread(Socket socket) throws IOException {
this.socket = socket;
this.in = socket.getInputStream();
}
@Override
public void interrupt() {
try {
// 中断socket
socket.close();
} catch (IOException ignored) {
} finally {
// 向上层传递中断状态
super.interrupt();
}
}
@Override
public void run() {
try {
byte[] buf = new byte[BUFSZ];
while (true) {
int count = in.read(buf);
if (count < 0) {
break;
} else if (count > 0) {
processBuffer(buf, count);
}
}
} catch (IOException e) { /* Allow thread to exit */
}
}
public void processBuffer(byte[] buf, int count) {
}
}
采用newTaskFor来封装非标准的取消
当把一个Callable提交给ExecutorService时,submit方法会返回一个Future,可以通过这个Future取消任务。
Java 6在ThreadPoolExecutor中新增了newTaskFor方法。newTaskFor是一个工厂方法,它将创建Future来代表任务。newTaskFor还能返回一个RunnableFuture接口,该接口扩展了Future和Runnable,并由FutureTask实现。
通过定制表示任务的Future可以改变Future.cancel的行为。
interface CancellableTask<T> extends Callable<T> {
void cancel();
RunnableFuture<T> newTask();
}
@ThreadSafe
class CancellingExecutor extends ThreadPoolExecutor {
// ...
@Override
protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
if (callable instanceof CancellableTask) {
return ((CancellableTask<T>) callable).newTask();
} else {
return super.newTaskFor(callable);
}
}
}
public abstract class SocketUsingTask<T> implements CancellableTask<T> {
@GuardedBy("this")
private Socket socket;
protected synchronized void setSocket(Socket s) {
socket = s;
}
@Override
public synchronized void cancel() {
try {
if (socket != null) {
socket.close();
}
} catch (IOException ignored) {
}
}
@Override
public RunnableFuture<T> newTask() {
return new FutureTask<T>(this) {
@Override
public boolean cancel(boolean mayInterruptIfRunning) {
try {
// 关闭socket
SocketUsingTask.this.cancel();
} finally {
return super.cancel(mayInterruptIfRunning);
}
}
};
}
}
停止基于线程的服务
正确的封装原则是:除非拥有某个线程,否则不能对该线程进行操控。
线程由Thread对象表示,并且像其他对象一样可以被自由共享。然而,线程有一个相应的所有者,即创建该线程的类。 因此线程池是其工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。
与其他封装对象一样,线程的所有权是不可传递的:
- 应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。
- 相反,服务应该提供生命周期方法来关闭它自己以及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。
对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。
public class LogService {
private final BlockingQueue<String> queue;
private final LoggerThread loggerThread;
private final PrintWriter writer;
@GuardedBy("this")
private boolean isShutdown;
@GuardedBy("this")
private int reservations;
public LogService(Writer writer) {
this.queue = new LinkedBlockingQueue<String>();
this.loggerThread = new LoggerThread();
this.writer = new PrintWriter(writer);
}
public void start() {
loggerThread.start();
}
public void stop() {
synchronized (this) {
isShutdown = true;
}
loggerThread.interrupt();
}
public void log(String msg) throws InterruptedException {
synchronized (this) {
if (isShutdown) {
throw new IllegalStateException();
}
++reservations;
}
queue.put(msg);
}
private class LoggerThread extends Thread {
@Override
public void run() {
try {
while (true) {
try {
synchronized (LogService.this) {
if (isShutdown && reservations == 0) {
break;
}
}
String msg = queue.take();
synchronized (LogService.this) {
--reservations;
}
writer.println(msg);
} catch (InterruptedException e) {
// retry
}
}
} finally {
writer.close();
}
}
}
}
关闭ExecutorService
ExecutorService提供了两种关闭方法:
使用shutdown正常关闭,以及使用shutdownNow 强行关闭。在进行强行关闭时, shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。
这两种关闭方式的差别在于各自的安全性和响应性:
- 强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;
- 而正常关闭虽然速度慢,但却更安全,因为ExecutorService会一直等到队列中的所有任务都执行完成后才关闭。在其他拥有线程的服务中也应该考虑提供类似的关闭方式以供选择。
public class CheckForMail {
public boolean checkMail(Set<String> hosts, long timeout, TimeUnit unit)
throws InterruptedException {
// 私有的Executor,生命周期受限于方法调用
ExecutorService exec = Executors.newCachedThreadPool();
// final类型防止被修改所以不用boolean类型
final AtomicBoolean hasNewMail = new AtomicBoolean(false);
try {
for (final String host : hosts) {
exec.execute(new Runnable() {
@Override
public void run() {
if (checkMail(host)) {
hasNewMail.set(true);
}
}
});
}
} finally {
exec.shutdown();
exec.awaitTermination(timeout, unit);
}
return hasNewMail.get();
}
private boolean checkMail(String host) {
// Check for mail
return false;
}
}
“毒丸”对象
“毒丸”是指一个放在队列上的对象,其含义是:“当得到这个对象时,立即停止。
”在FIFO 队列中,“毒丸”对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交“毒丸”对象之前提交的所有工作都会被处理,而生产者在提交了“毒丸”对象后,将不会在提交任何工作。
- 解决方案可以扩展到多个生产者:只需每个生产者都向队列放入一个“毒丸”对象,并且消费者仅当在接收到N个生产者的”毒丸“对象时才停止。
- 这种方法也可以扩展到多个消费者的情况,只需生产者将N个”毒丸“对象放入队列。然而,当生产者和消费者的数量较大时,这种方法将变的难以使用。
只有在无界队列中,“毒丸”对象才能可靠地工作,有界队列会根据策略reject溢出的任务。
public class IndexingService {
private static final int CAPACITY = 1000;
private static final File POISON = new File("");
private final IndexerThread consumer = new IndexerThread();
private final CrawlerThread producer = new CrawlerThread();
private final BlockingQueue<File> queue;
private final FileFilter fileFilter;
private final File root;
public IndexingService(File root, final FileFilter fileFilter) {
this.root = root;
this.queue = new LinkedBlockingQueue<File>(CAPACITY);
this.fileFilter = new FileFilter() {
@Override
public boolean accept(File f) {
return f.isDirectory() || fileFilter.accept(f);
}
};
}
private boolean alreadyIndexed(File f) {
return false;
}
public void start() {
producer.start();
consumer.start();
}
public void stop() {
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join();
}
class CrawlerThread extends Thread {
@Override
public void run() {
try {
crawl(root);
} catch (InterruptedException e) { /* fall through */
} finally {
while (true) {
try {
queue.put(POISON);
break;
} catch (InterruptedException e1) { /* retry */
}
}
}
}
private void crawl(File root) throws InterruptedException {
File[] entries = root.listFiles(fileFilter);
if (entries != null) {
for (File entry : entries) {
if (entry.isDirectory()) {
crawl(entry);
} else if (!alreadyIndexed(entry)) {
queue.put(entry);
}
}
}
}
}
class IndexerThread extends Thread {
@Override
public void run() {
try {
while (true) {
File file = queue.take();
if (file == POISON) {
break;
} else {
indexFile(file);
}
}
} catch (InterruptedException ignored) {
}
}
public void indexFile(File file) {
/*...*/
}
}
}
shutdownNow的局限性
当通过shutdownNow来强行关闭ExecutorService时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务写入日志或者保存起来以便之后进行处理。 (shutdownNow返回的Runnable对象可能与提交给ExecutorService的Runnable并不相同:它可能是被封装过的提交任务)
然而,我们无法通过常规方法来找出哪些任务已经开始但尚未结束。这意味着这我们无法在关闭过程中知道正在执行的任务的状态,除非任务本身会执行某种检查。
要知道哪些任务还没有完成,你不仅需要知道哪些任务还没有开始,而且还需知道当Executor关闭时哪些任务正在执行。
(然而,在关闭过程中只会返回尚未开始的任务,而不会返回正在执行的任务。如果能返回所有这两种类型的任务,那么就不需要这种不确定的中间状态)
public class TrackingExecutor extends AbstractExecutorService {
private final ExecutorService exec;
private final Set<Runnable> tasksCancelledAtShutdown =
Collections.synchronizedSet(new HashSet<Runnable>());
public TrackingExecutor(ExecutorService exec) {
this.exec = exec;
}
@Override
public void shutdown() {
exec.shutdown();
}
@Override
public List<Runnable> shutdownNow() {
return exec.shutdownNow();
}
@Override
public boolean isShutdown() {
return exec.isShutdown();
}
@Override
public boolean isTerminated() {
return exec.isTerminated();
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
return exec.awaitTermination(timeout, unit);
}
public List<Runnable> getCancelledTasks() {
if (!exec.isTerminated()) {
throw new IllegalStateException();
}
return new ArrayList<Runnable>(tasksCancelledAtShutdown);
}
@Override
public void execute(final Runnable runnable) {
exec.execute(new Runnable() {
@Override
public void run() {
try {
runnable.run();
} finally {
if (isShutdown() && Thread.currentThread().isInterrupted()) {
tasksCancelledAtShutdown.add(runnable);
}
}
}
});
}
}
// 在任务执行最后一条指令以及线程池将任务记录为“结束”两个状态之间,线程池可能被关闭
// 这将造成一些认为已取消的任务实际上已经完成,需要程序的幂等性保证不会出问题
public abstract class WebCrawler {
private static final long TIMEOUT = 500;
private static final TimeUnit UNIT = MILLISECONDS;
@GuardedBy("this")
private final Set<URL> urlsToCrawl = new HashSet<URL>();
private final ConcurrentMap<URL, Boolean> seen = new ConcurrentHashMap<URL, Boolean>();
private volatile TrackingExecutor exec;
public WebCrawler(URL startUrl) {
urlsToCrawl.add(startUrl);
}
public synchronized void start() {
exec = new TrackingExecutor(Executors.newCachedThreadPool());
for (URL url : urlsToCrawl) {
submitCrawlTask(url);
}
urlsToCrawl.clear();
}
public synchronized void stop() throws InterruptedException {
try {
saveUncrawled(exec.shutdownNow());
if (exec.awaitTermination(TIMEOUT, UNIT)) {
saveUncrawled(exec.getCancelledTasks());
}
} finally {
exec = null;
}
}
protected abstract List<URL> processPage(URL url);
private void saveUncrawled(List<Runnable> uncrawled) {
for (Runnable task : uncrawled) {
urlsToCrawl.add(((CrawlTask) task).getPage());
}
}
private void submitCrawlTask(URL u) {
exec.execute(new CrawlTask(u));
}
private class CrawlTask implements Runnable {
private final URL url;
private int count = 1;
CrawlTask(URL url) {
this.url = url;
}
boolean alreadyCrawled() {
return seen.putIfAbsent(url, true) != null;
}
void markUncrawled() {
seen.remove(url);
System.out.printf("marking %s uncrawled%n", url);
}
@Override
public void run() {
for (URL link : processPage(url)) {
if (Thread.currentThread().isInterrupted()) {
return;
}
submitCrawlTask(link);
}
}
public URL getPage() {
return url;
}
}
}
处理非正常的线程终止
导致线程提前死亡的最主要原因就是RuntimeException,由于这些异常表示出现了某种编程错误或者其他不可修复的错误,因此它们通常不会被捕获。 它们不会在调用栈逐层传递,而是默认地在控制台中输出栈追踪信息,并终止线程。
任何代码都可能抛出一个RuntimeExecption,每当调用另一个方法时,都要对它的行为保持怀疑,不要盲目地认为它一定会抛出在方法原型中声明的某个已检查异常。 对调用的代码越不熟悉,就越应该对其代码行为保持怀疑。
线程应该在try-catch块中调用这些任务,这样就能捕获那些未检查的异常了。 或者也可以使用try-finally代码块来确保框架能够知道线程非正常退出的情况,并做出正确的响应。 你或者会捕获RuntimeException异常,即当通过Runnable这样的抽象机制来调用未知和不可信的代码时。 (当线程抛出一个未受检异常时,整个程序都可能受到影响,但关闭整个程序通常是更不切实际的)
未捕获异常的处理
在Thread API中同样提供了UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。
当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器,如果没有提供,那么默认的行为是将栈追踪信息输出到System.err
在Java5.0之前,控制UncaghtExceptionHandler的唯一方法就是对ThreadGroup进行子类化。
在Java5.0及之后的版本中,可以通过Thread.setUncaghtExceptionHandler为每个线程设置一个UncaghtExceptionHandler,还可以使用setDefaultUncaghtExceptionHandler来设置默认的UncaghtExceptionHandler。
然而,在这些异常处理器中,只有其中一个将被调用——JVM首先会搜索每个线程的异常处理器,然后再搜索一个ThreadGroup的异常处理器。ThreadGroup中默认异常处理器实现将异常处理工作逐层委托给它的上层ThreadGroup,直至其中某个ThreadGroup的异常处理器能够处理该未捕获异常,否则将一直传递到顶层的ThreadGroup。
顶层ThreadGroup的异常处理器委托给默认的系统处理器(如果存在,在默认情况下为空),否则将把栈追踪信息输出到控制台。
public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
public class UEHLogger implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
Logger logger = Logger.getAnonymousLogger();
logger.log(Level.SEVERE, "Thread terminated with exception: " + t.getName(), e);
}
}
在运行时间较长的应用程序中,通常会为所有的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中。
注意:Executor框架中的任务未捕获异常处理器只有捕获处理通过execute提交的任务,而通过submit提交的任务,抛出的任何异常,都会封装在任务Future里,如果有一个以submit提交的任务以异常而终止,这个异常会被Future.get重新抛出,这些异常并包装在ExecutionException。
JVM关闭
JVM既可通过正常手段来关闭,也可强行关闭。
- 正常关闭
- 当最后一个“正常(非守护)”线程结束时、当有人调用了System.exit时、或者通过其他特定于平台的方法关闭时(例如发送了SIGINT信号或键入Ctrl-C)。
- 强行关闭
- Runtime.halt(应小心使用此方法。与 exit 方法不同,此方法不会启动关闭挂钩,并且如果已启用退出终结,此方法也不会运行未调用的终结方法。如果已经发起关闭序列,那么此方法不会等待所有正在运行的关闭挂钩或终结方法完成其工作)、杀死JVM操作系统进程(比如在 Unix 上使用 SIGKILL 信号)。这种强行关闭方式将无法保证是否将运行关闭钩子。
关闭钩子
在正常关闭中,JVM首先调用所有已注册的关闭钩子(Shutdown Hook)。
关闭钩子是指通过Runnable.addShutdownHook注册的但尚未开始的线程。
JVM并不能保证关闭钩子的调用顺序。在关闭应用程序线程时,如果有(守护或非守护)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。
当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器(finalize),然后再停止。
JVM并不会停止或中断任何在关闭时仍然运行的应用程序线程。当JVM最终结束时,这些线程将被强行结束。
如果关闭钩子或终结器没有执行完成,那么正常关闭进程“挂起”并且JVM必须被强行关闭。当被强行关闭时,只是关闭JVM,而不会运行关闭钩子。
关闭钩子应该是线程安全的:
它们在访问共享数据时必须使用同步机制,并且小心地避免发生死锁,这与其他并发代码的要求相同。
关闭钩子不应该对应用程序的状态(例如,其他服务是否已经关闭,或者所有的正常线程是否已经执行完成)或者JVM的关闭原因做出任何假设。
关闭钩子必须尽快退出,因为它们会延迟JVM的结束时间,而用户可能希望JVM能尽快终止。
由于用户注销或系统关闭而终止虚拟机时,底层的操作系统可能只允许在固定的时间内关闭并退出。
关闭钩子可以用于实现服务或应用程序清理工作。
关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。 实现这种功能的一种方式是对所有服务使用同一个关闭钩子(而不是每个服务使用一个不同的关闭钩子),并且在该关闭钩子中执行一系列的关闭操作。 这确保了关闭操作在单个线程中串行执行,从而避免了在关闭操作之间出现竞态条件或死锁问题。
public void start() {
loggerThread.start();
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
try {
LogService.this.stop();
} catch (InterruptedException ignore) {
}
}
});
}
守护线程
当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程。
当一个线程退出时,JVM会检查其他正在运行的线程,如果这些线程都是守护线程,那么JVM会正常退出操作。 当JVM停止时,所有仍然存在的守护线程都将被抛弃——既不会执行finally代码块,也不会执行回卷栈,而JVM只是直接退出。
我们应尽可能少地使用守护线程——很少有操作能够在不进行清理的情况下被安全地抛弃。 特别是,如果在守护线程中执行可能包含I/O操作的任务时,那么将是一种危险的行为。 守护线程最好用于执行“内部”任务,列入周期性地从内存缓存中移除逾期的数据。
守护线程通常不能用来替代应用程序管理程序中各个服务的生命周期。
终结器
避免使用终结器finalize,它只能作为try-finally关闭外的辅助
判断是否有问题
- 是否是多线程环境
- 是否有共享数据
- 是否有多条语句操作共享数据
参考虚拟机部分的《先行发生原则》
解决多线程安全问题
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
- 不在线程之间共享该状态变量
- 将状态变量修改为不可变的变量
- 在访问状态变量时使用同步
线程安全类(类或状态变量不可变)
当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
使用同步
让程序没有安全问题的环境,把多个语句操作共享数据的代码给锁起来,让任意时刻只能有一个线程执行即可
同步代码块
synchronized(对象){需要同步的代码;}
同步方法
把同步关键字加到方法上
这里的锁对象是this
静态同步方法
这里的锁对象是当前类的字节码文件对象
class Outputer{
public void output1(String name){
synchronized (Outputer.class){
}
}
public synchronized void output2(String name){
}
public static synchronized void output3(String name){
}
}
// 1和3同步
// 2和3不同步,因为2锁定的是this对象,而3锁定的是当前类的字节码对象
同步方法选择
如果锁对象是this,就可以考虑使用同步方法。
否则能使用同步代码块的尽量使用同步代码块。
同步弊端
- 当线程相当多时,因为每个线程都会去判断同步上的锁,这是很耗费资源的,无形中会降低程序的运行效率。
- 如果出现了同步嵌套,就容易产生死锁问题
Jdk1.5 使用Lock锁
Lock与ReentrantLock
与内置加锁机制不同的是,Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显示的。
在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性方面可以有所不同。
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
Lock比传统线程模型中的synchronized方式更加面向对象,与生活中的锁类似,锁本身也应该是一个对象。两个线程执行的代码片段要实现同步互斥的效果,它们必须用同一个Lock对象。
ReentrantLock实现了Lock接口,并提供了与synchronized 相同的互斥性和内存可见性。除了进入和退出语义外,ReentrantLock还提供了与synchronized一样的可重入的加锁语义,与synchronized相比他还为处理锁机制不可用性问题提供了更高的灵活性
synchronized与Lock比较
参考《在synchronized和ReentrantLock之间进行选择》
- 缺点
- 无法中断一个正在等待获取锁的线程,或者无法请求获取一个锁时无限地等待下去
- 无法实现非阻塞结构的加锁机制
- 优点
- 与异常处理操作实现了很好的交互
- 简化了编码工作,如所释放等
Lock更“危险”,因为程序执行控制离开被保护的代码块时,不会自动清除锁。所以必须在finally块中释放锁,否则,如果在被保护的代码中抛出了异常,那么这个锁永远无法释放。
Lock lock = new ReentrantLock();
lock.lock();
try{
// do something
}finally{
lock.unlock();
}
// 定义票
private int tickets = 100;
// 定义锁对象
private Lock lock = new ReentrantLock();//ReentrantLock是Lock的实现类
@Override
public void run() {
while (true) {
try {// try finally防止中间代码异常锁不被释放
// 加锁
lock.lock();
if (tickets > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()
+ "正在出售第" + (tickets--) + "张票");
}
} finally {
// 释放锁
lock.unlock();
}
}
}
轮询锁与定时锁
可定时的与可轮询的锁获取模式是由tryLock方法实现的,与无条件的锁获取模式相比,它具有更完善的错误恢复机制。
如果不能获得所有需要的锁,那么可以使用可定时的锁或可轮询的锁获取方式,从而使你重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有锁。
public boolean transferMoney(Account fromAcct,
Account toAcct,
DollarAmount amount,
long timeout,
TimeUnit unit)
throws InsufficientFundsException, InterruptedException {
// random wait time
long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
long randMod = getRandomDelayModulusNanos(timeout, unit);
long stopTime = System.nanoTime() + unit.toNanos(timeout);
// retry after failure
while (true) {
if (fromAcct.lock.tryLock()) {
try {
if (toAcct.lock.tryLock()) {
try {
if (fromAcct.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
} else {
fromAcct.debit(amount);
toAcct.credit(amount);
return true;
}
} finally {
toAcct.lock.unlock();
}
}
} finally {
fromAcct.lock.unlock();
}
}
if (System.nanoTime() < stopTime) {
return false;
}
NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}
当在带时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定时间内给出结果,那么使程序提前结束。
public boolean trySendOnSharedLine(String message, long timeout, TimeUnit unit)
throws InterruptedException {
long nanosToLock = unit.toNanos(timeout) - estimatedNanosToSend(message);
// 如果在时限内没有锁定成功,则返回false
if (!lock.tryLock(nanosToLock, NANOSECONDS)) {
return false;
}
try {
return sendOnSharedLine(message);
} finally {
lock.unlock();
}
}
可中断的锁获取操作
可中断的锁同样能在可取消的操作中使用锁。
可中断的锁获取操作的标准结构比普通的锁获取操作略微复杂一些,因为需要两个try块。 如果在可中断的锁获取操作中抛出了InterruptedException,那么可以使用标准的try-finally加锁模式
private Lock lock = new ReentrantLock();
public boolean sendOnSharedLine(String message)
throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException {
/* send something */
return true;
}
非块结构的加锁
在遍历或者修改链表时,我们必须持有该节点上的这个锁,直到获得了下一个节点的锁,只有这样,才能释放前一个节点上的锁。这种行为称之为连锁式加锁(Hand-Over-Hand Locking)或者锁耦合(Lock Coupling)
性能考虑因素
Jdk1.5中ReentrantLock的伸缩性优于同步代码块,Jdk1.6种性能持平
公平性
在ReentrantLock的构造函数中提供了两种公平性的选择:创建一个非公平的锁(默认)或者一个公平的锁。
在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:
当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有等待线程并获得这个锁。(在Semaphore中同样可以选择采用公平或非公平的获取顺序)。
- 在公平的锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中
- 即使对于公平锁而言,可轮询的tryLock任然会“插队”
- 在非公平的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中
公平性由于在线程挂起和恢复过程时存在开销而极大地降低性能。在大多数情况下,非公平的锁的性能要高于公平锁的性能。
在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:
在恢复一个被挂起的线程与该线程真正开始运行之间存在严重的延迟。
- 假设线程A持有一个锁,并且线程B请求这个锁。由于这个锁已被线程A持有,因此B将被挂起。
- 当A释放锁时,B将被唤醒,因此会再次尝试获取锁。
- 与此同时,如果C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。
这样的情况是一种”双赢“的局面:B获得锁的时刻并没有推迟,C更早地获得了锁,并且吞吐量也获得了提高。
当持有锁的时间相对较长,或者请求锁的平局时间间隔较长,那么应该使用公平锁。在这些情况下,“插队”带来的吞吐量提升(当锁处于可用状态时,线程却还未处于被唤醒的过程中)则可能不会出现。
与默认的ReentrantLock一样,内置加锁并不会提供确定的公平性保证,但在大多数情况下,在锁实现上实现统计的公平性保证已经足够了。ReentrantLock并没有进一步降低锁的公平性,而只是使一些已经存在内容更明显。
在synchronized和ReentrantLock之间进行选择
在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。 当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可立刻返回获取结果的、可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。 否则,还是应该优先使用synchronized。
ReentrantLock的非块状结构特性仍然意味着,获取锁的操作不能与特定的栈帧关联起来,而内置锁可以。
未来更可能会提升synchronized的性能,因为它是JVM的内置属性,并且能执行一些优化。如对锁消除,锁粗化等
Condition
参考《条件队列》部分
Lock是一种广义的内置锁,Condition也是一种广义的内置条件队列。
public interface Condition {
void await() throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
内置的条件队列存在一些缺陷。每个内置锁都只能有一个相关联的条件队列。 当多个线程可能在同一条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。 这些因素都使得无法满足在调用notifyAll时所有等待线程为同一类型的需求。 如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性之外的更多控制权,就可以使用显式的Lock和Condition而不是内置锁和条件队列,这是一种更灵活的选择。
要创建一个Condition,可以在相关联的Lock上调用Lock.newCondition方法。Condition同样比内置条件队列提供了更丰富的功能:
在每个锁上可存在多个等待、条件等待可以是可中断的或不可中断的、基于时限的等待,以及公平的或非公平的队列操作。
与内置条件队列不同的是,对于每个Lock,可以有任意数量的Condition对象。 Condition对象继承了相关的Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放。
注意事项:在Condition对象中,与wait、notify和notifyAll方法对应的分别是await、signal和signalAll。但是,Condition对Object进行了扩展,因而它也包含了wait和notify方法。一定要确保使用正确的版本——await和signal。
@ThreadSafe
public class ConditionBoundedBuffer<T> {
private static final int BUFFER_SIZE = 100;
protected final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: notFull (count < items.length)
private final Condition notFull = lock.newCondition();
// CONDITION PREDICATE: notEmpty (count > 0)
private final Condition notEmpty = lock.newCondition();
@GuardedBy("lock")
private final T[] items = (T[]) new Object[BUFFER_SIZE];
@GuardedBy("lock")
private int tail, head, count;
// BLOCKS-UNTIL: notFull
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await();
}
items[tail] = x;
if (++tail == items.length) {
tail = 0;
}
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// BLOCKS-UNTIL: notEmpty
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await();
}
T x = items[head];
items[head] = null;
if (++head == items.length) {
head = 0;
}
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}
Condition使其满足单次通知的需求,signal比signalAll更高效,他能减少上下文切换和锁请求的次数。
如果使用高级功能,则选用Condition,否则选用synchronized。
在等待Condition时,允许发生“虚假唤醒”,这通常作为对基础平台语义的让步。
对于大多数应用程序,这带来的实际影响很小,因为 Condition 应该总是在一个循环中被等待,并测试正被等待的状态声明。
某个实现可以随意移除可能的虚假唤醒,但建议应用程序程序员总是假定这些虚假唤醒可能发生,因此总是在一个循环中等待。
一个锁内部可以有多个Condition,即有多路等待和通知
//condition条件唤醒实现阻塞队列
class BoundedBuffer {
final Lock lock = new ReentrantLock();
final Condition notFull = lock.newCondition();
final Condition notEmpty = lock.newCondition();
final Object[] items = new Object[100];
int putptr, takeptr, count;
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length){
notFull.await();
}
items[putptr] = x;
if (++putptr == items.length) {
putptr = 0;
}
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0){
notEmpty.await();
}
Object x = items[takeptr];
if (++takeptr == items.length) {
takeptr = 0;
}
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}
AQS await()源码
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();// 插入等待节点
int savedState = fullyRelease(node);// 清除持有的锁
int interruptMode = 0;
while (!isOnSyncQueue(node)) {// 未进入队列则park
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// 从新获取锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
private Node addConditionWaiter() {
Node t = lastWaiter;
// If lastWaiter is cancelled, clean out.
if (t != null && t.waitStatus != Node.CONDITION) {
unlinkCancelledWaiters();
t = lastWaiter;
}
Node node = new Node(Thread.currentThread(), Node.CONDITION);
// await前提就是持有锁,所以线程安全
if (t == null)
firstWaiter = node;
else
t.nextWaiter = node;
lastWaiter = node;
return node;
}
读-写锁
一个资源可以被多个读操作访问,或者被一个写操作访问,但是两者不能同时进行。
分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,写锁与写锁互斥,这是由jvm自己控制的,你只要上好相应的锁即可。如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁;如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。总之,读的时候上读锁,写的时候上写锁!
ReadWriteLock中暴露了两个Lock对象,其中一个用于读操作,而另一个用于写操作。要读取由ReadWriteLock保护的数据,必须首先获得读取锁,当需要修改ReadWriteLock保护的数据时,必须首先获得写入锁。尽管这两个锁看上去是彼此独立的,但读取锁和写入锁只是读—写锁对象的不同视图。
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
在读 — 写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。与Lock一样,ReadWriteLock可以采用多种不同的实现方式,这些方式在性能,调度保证,获取优先性,公平性以及加锁语义等方面可能有所不同。
在读写锁和写入锁之间的交互可以采用多种实现方式。ReadWriteLock中的一些可选实现包括:
- 释放优先
- 当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?
- 读线程插队
- 如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但却可能造成写线程饥饿问题。
- 重入性
- 读取锁和写入锁是否是可重入的?
- 降级
- 如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源。
- 升级
- 读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读 - 写锁实现中并不支持升级,因为如果没有显式地升级操作,那么很容易造成死锁。(如果两个读线程试图同时升级为写入锁,那么二者都不会释放读取锁。)
与ReentrantLock类似,ReentrantReadWriteLock在构造时也可以选择是一个非公平的锁(默认)还是一个公平的锁。
- 在公平的锁中,等待时间最长的线程将优先获得锁。如果这个锁由读线程持有,而另一个线程请求写入锁,那么其他读线程都不能获得读取锁,直到写线程使用完并且释放了写入锁。
- 在非公平的锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程是可以的,当从读线程升级为写线程这是不可以的(这样会导致死锁)。
当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,那么读 - 写锁能提高并发性。
public class ReadWriteMap<K, V> {
private final Map<K, V> map;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock r = lock.readLock();
private final Lock w = lock.writeLock();
public ReadWriteMap(Map<K, V> map) {
this.map = map;
}
//... 其他方法
public V put(K key, V value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
public V get(Object key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
}
private Map<String, Object> cache = new HashMap<String, Object>();
private ReadWriteLock rwl = new ReentrantReadWriteLock();
public Object getData(String key){
rwl.readLock().lock();
Object value = null;
try{
value = cache.get(key);
if(value == null){
// 释放读锁,因为不支持锁升级
rwl.readLock().unlock();
rwl.writeLock().lock();
try{
if(value==null){//这里的判断防止别的线程在第一个write线程后恢复获取写锁重复写数据
value = "aaaa";//实际是queryDB();
}
}finally{
rwl.writeLock().unlock();
}
// 在最后防止释放时未锁定抛出IllegalMonitorStateException
rwl.readLock().lock();
}
}finally{
rwl.readLock().unlock();
}
return value;
}
Semaphore
Semaphore可以维护当前访问自身的线程个数,并提供了同步机制。使用Semaphore可以控制同时访问资源的线程个数,例如,实现一个文件允许的并发访问数。
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
final Semaphore sp = new Semaphore(3);
for(int i=0;i<10;i++){
Runnable runnable = new Runnable(){
public void run(){
try {
sp.acquire();
} catch (InterruptedException e1) {
e1.printStackTrace();
}
System.out.println("线程" + Thread.currentThread().getName() +
"进入,当前已有" + (3-sp.availablePermits()) + "个并发");
try {
Thread.sleep((long)(Math.random()*3000));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程" + Thread.currentThread().getName() +
"即将离开");
sp.release();
//下面代码有时候执行不准确,因为其没有和上面的代码合成原子单元
System.out.println("线程" + Thread.currentThread().getName() +
"已离开,当前已有" + (3-sp.availablePermits()) + "个并发");
}
};
service.execute(runnable);
}
service.shutdown();
}
其他线程同步工具类
- CyclicBarrier
- 表示大家彼此等待,大家集合好后才开始出发,分散活动后又在指定地点集合碰面
- CountDownLatch
- 犹如倒计时计数器,调用CountDownLatch对象的countDown方法就将计数器减1,当计数到达0时,则所有等待者或单个等待者开始执行
- Exchanger
- 用于实现两个线程之间的数据交换,每个线程在完成一定的事务后想与对方交换数据,第一个先拿出数据的线程将一直等待第二个线程拿着数据到来时,才能彼此交换数据。
CyclicBarrier
public void timeCount1(Executor executor, int concurrent, final Runnable runnable) throws InterruptedException {
CyclicBarrier cyclicBarrier = new CyclicBarrier(concurrent);
IntStream.rangeClosed(1, concurrent).forEach(
i -> {
executor.execute(() -> {
System.out.println("prepared " + i);
try {
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
runnable.run();
});
}
);
}
CountDownLatch
public long timeCount(Executor executor, int concurrent, final Runnable runnable) throws InterruptedException {
CountDownLatch prepare = new CountDownLatch(concurrent);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrent);
IntStream.rangeClosed(1, concurrent).forEach(
i -> {
executor.execute(() -> {
System.out.println("prepared " + i);
prepare.countDown();
try {
start.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
runnable.run();
done.countDown();
});
}
);
long begin = System.nanoTime();
prepare.await();
System.out.println("begin");
start.countDown();
done.await();
System.out.println("done");
return System.nanoTime() - begin;
}
线程通信
唤醒机制
- wait():等待
- 将来醒过来的时候,是从wait()方法向下执行
- notify():唤醒单个线程 立即释放锁。
- 唤醒并不表示你立马可以执行,必须还得抢CPU的执行权。
- notifyAll():唤醒所有线程
public static void main(String[] args) {
final Business business = new Business();
new Thread(
new Runnable() {
@Override
public void run() {
for(int i=1;i<=50;i++){
business.sub(i);
}
}
}
).start();
for(int i=1;i<=50;i++){
business.main(i);
}
}
// 锁和同步机制放在锁对象身上,不用每个调用线程中去写同步代码
class Business {
private boolean bShouldSub = true;
public synchronized void sub(int i){
while(!bShouldSub){// JDK中有阐述使用while防止伪唤醒,即被唤醒以后还是检查是否满足条件
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for(int j=1;j<=10;j++){
System.out.println("sub thread sequence of " + j + ",loop of " + i);
}
bShouldSub = false;
this.notify();
}
public synchronized void main(int i){
while(bShouldSub){
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for(int j=1;j<=100;j++){
System.out.println("main thread sequence of " + j + ",loop of " + i);
}
bShouldSub = true;
this.notify();
}
}
线程的状态转换
为什么wait(),notify(),notifyAll()等方法都定义在Object类中
- 这些方法存在与同步中。
- 使用这些方法时必须要标识所属的同步的锁。
- 锁可以是任意对象,所以任意对象调用的方法一定定义Object类中。
ThreadLocal
作用和目的
用于实现线程内的数据共享,即对于相同的程序代码,多个模块在同一个线程中运行时要共享一份数据,而在另外线程中运行时又共享另外一份数据。
原理分析
ThreadLocal{
HashMap hashMap = new HashMap();
void set(Object obj){
hashMap.put(Thread.currentThread(),obj);
}
object get(){
return hashMap.get(Thread.currentThread());
}
}
特点
一个ThreadLocal代表一个变量,故其中里只能放一个数据,你有两个变量都要线程范围内共享,则要定义两个ThreadLocal对象。如果有一个百个变量要线程共享呢?那请先定义一个对象来装这一百个变量,然后在ThreadLocal中存储这一个对象。
private static ThreadLocal<Integer> x = new ThreadLocal<Integer>();
public static void main(String[] args) {
for(int i=0;i<2;i++){
new Thread(new Runnable(){
@Override
public void run() {
int data = new Random().nextInt();
System.out.println(Thread.currentThread().getName()
+ " has put data :" + data);
x.set(data);
new A().get();
new B().get();
}
}).start();
}
}
static class A{
public void get(){
int data = x.get();
System.out.println("A from " + Thread.currentThread().getName()
+ " get data :" + data);
}
}
static class B{
public void get(){
int data = x.get();
System.out.println("B from " + Thread.currentThread().getName()
+ " get data :" + data);
}
}
// Thread-1 has put data :2094430671
// Thread-0 has put data :-2114425688
// A from Thread-1 get data :2094430671
// A from Thread-0 get data :-2114425688
// B from Thread-1 get data :2094430671
// B from Thread-0 get data :-2114425688
封装写法
class MyThreadScopeData{
private MyThreadScopeData(){}
public static MyThreadScopeData getThreadInstance(){
MyThreadScopeData instance = map.get();
if(instance == null){
instance = new MyThreadScopeData();
map.set(instance);
}
return instance;
}
private static ThreadLocal<MyThreadScopeData> map = new ThreadLocal<MyThreadScopeData>();
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
public static void main(String[] args) {
for(int i=0;i<2;i++){
new Thread(new Runnable(){
@Override
public void run() {
int data = new Random().nextInt();
System.out.println(Thread.currentThread().getName()
+ " has put data :" + data);
MyThreadScopeData.getThreadInstance().setName("name" + data);
MyThreadScopeData.getThreadInstance().setAge(data);
new A().get();
new B().get();
}
}).start();
}
}
static class A{
public void get(){
MyThreadScopeData myData = MyThreadScopeData.getThreadInstance();
System.out.println("A from " + Thread.currentThread().getName()
+ " getMyData: " + myData.getName() + "," +
myData.getAge());
}
}
static class B{
public void get(){
MyThreadScopeData myData = MyThreadScopeData.getThreadInstance();
System.out.println("B from " + Thread.currentThread().getName()
+ " getMyData: " + myData.getName() + "," +
myData.getAge());
}
}
// Thread-0 has put data :1764488084
// Thread-1 has put data :-1977536471
// A from Thread-1 getMyData: name-1977536471,-1977536471
// A from Thread-0 getMyData: name1764488084,1764488084
// B from Thread-1 getMyData: name-1977536471,-1977536471
// B from Thread-0 getMyData: name1764488084,1764488084
多个线程访问共享对象和数据的方式
- 将共享数据封装在另外一个对象中,然后将这个对象逐一传递给各个Runnable对象。每个线程对共享数据的操作方法也分配到那个对象身上去完成,这样容易实现针对该数据进行的各个操作的互斥和通信。
public static void main(String[] args) {
ShareData data = new ShareData();
new Thread(new MyRunnable1(data)).start();
new Thread(new MyRunnable2(data)).start();
}
class MyRunnable1 implements Runnable{
private ShareData data;
public MyRunnable1(ShareData data){
this.data = data;
}
int count = 10;
@Override
public void run() {
while(count-->0){
data.increment();
}
}
}
class MyRunnable2 implements Runnable{
private ShareData data;
public MyRunnable2(ShareData data){
this.data = data;
}
int count = 10;
@Override
public void run() {
while(count-->0){
data.decrement();
}
}
}
class ShareData {
private int j = 0;
public synchronized void increment(){
j++;
System.out.println("j++:"+j);
}
public synchronized void decrement(){
j--;
System.out.println("j--:"+j);
}
}
- 将这些Runnable对象作为某一个类中的内部类,共享数据作为这个外部类中的成员变量,每个线程对共享数据的操作方法也分配给外部类,以便实现对共享数据进行的各个操作的互斥和通信,作为内部类的各个Runnable对象调用外部类的这些方法。
public static void main(String[] args) {
final ShareData data = new ShareData();
new Thread(new Runnable(){
@Override
public void run() {
int count = 10;
while(count-->0){
data.increment();
}
}
}).start();
new Thread(new Runnable(){
int count = 10;
@Override
public void run() {
while(count-->0){
data.decrement();
}
}
}).start();
}
class ShareData{
private int j = 0;
public synchronized void increment(){
j++;
System.out.println("j++:"+j);
}
public synchronized void decrement(){
j--;
System.out.println("j--:"+j);
}
}
- 上面两种方式的组合:将共享数据封装在另外一个对象中,每个线程对共享数据的操作方法也分配到那个对象身上去完成,对象作为这个外部类中的成员变量或方法中的局部变量,每个线程的Runnable对象作为外部类中的成员内部类或局部内部类。
public class Test {
public static void main(String[] args) {
Test test = new Test();
new Thread(test.new MyRunnable1()).start();
new Thread(test.new MyRunnable2()).start();
}
private int j = 0;
public synchronized void increment(){
j++;
System.out.println("j++:"+j);
}
public synchronized void decrement(){
j--;
System.out.println("j--:"+j);
}
class MyRunnable1 implements Runnable{
int count = 10;
@Override
public void run() {
while(count-->0){
increment();
}
}
}
class MyRunnable2 implements Runnable{
int count = 10;
@Override
public void run() {
while(count-->0){
decrement();
}
}
}
}
线程组
Java中使用ThreadGroup来表示线程组,它可以对一批线程进行分类管理,Java允许程序直接对线程组进行控制。
默认情况下,所有的线程都属于主线程组。
设置线程分组
Thread(ThreadGroup group, Runnable target, String name)
// ThreadGroup(String name)
ThreadGroup tg = new ThreadGroup("这是一个新的组");
MyRunnable my = new MyRunnable();
// Thread(ThreadGroup group, Runnable target, String name)
Thread t1 = new Thread(tg, my, "林青霞");
Thread t2 = new Thread(tg, my, "刘意");
System.out.println(t1.getThreadGroup().getName());
System.out.println(t2.getThreadGroup().getName());
//通过组名称设置后台线程,表示该组的线程都是后台线程
tg.setDaemon(true);
// 通过结果我们知道了:线程默认情况下属于main线程组
// 通过下面的测试,你应该能够看到,默任情况下,所有的线程都属于同一个组
System.out.println(Thread.currentThread().getThreadGroup().getName());
线程池
程序启动一个新线程成本是比较高的,因为它涉及到要与操作系统进行交互。而使用线程池可以很好的提高性能,尤其是当程序中要创建大量生存期很短的线程时,更应该考虑使用线程池。
- 线程池里的每一个线程代码结束后,并不会死亡,而是再次回到线程池中成为空闲状态,等待下一个对象来使用。
- 在JDK5之前,我们必须手动实现自己的线程池,从JDK5开始,Java内置支持线程池
Jdk1.5 Executors工厂类来产生线程池
- public static ExecutorService newCachedThreadPool() 创建一个具有缓存功能的线程池
- public static ExecutorService newFixedThreadPool(int nThreads) 创建一个可重用的,具有固定线程数的线程池
- public static ExecutorService newSingleThreadExecutor() 创建一个只有单线程的线程池,相当于上个方法的参数是1
//Callable:是带泛型的接口。
//这里指定的泛型其实是call()方法的返回值类型。
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int x = 0; x < 100; x++) {
sum += x;
}
return sum;
}
}
public class MyRunnable implements Runnable {
@Override
public void run() {
for (int x = 0; x < 100; x++) {
System.out.println(Thread.currentThread().getName() + ":" + x);
}
}
}
// 创建一个线程池对象,控制要创建几个线程对象。
// public static ExecutorService newFixedThreadPool(int nThreads);// 固定数目的线程池
// ExecutorService threadPool = Executors.newCachedThreadPool();// 创建缓存线程池。线程数可随需求变化,不设上限直接交给线程处理
// ExecutorService threadPool = Executors.newSingleThreadExecutor();// 创建单一线程池 线程死掉后会重启一个替代线程,保证至少有一个线程
ExecutorService pool = Executors.newFixedThreadPool(2);// 创建固定大小的线程池
// 可以执行Runnable对象或者Callable对象代表的线程
pool.submit(new MyRunnable());
pool.submit(new MyRunnable());
Future<Integer> result = pool.submit(new MyCallable());
Integer i = result.get();
//结束线程池
pool.shutdown();
线程池启动定时器
支持间隔重复任务的定时方式,不直接支持绝对定时方式,需要转换成相对时间方式。
public class test {
public static void main(String[] args) {
//ExecutorService service = Executors.newFixedThreadPool(3);
ExecutorService service = Executors.newCachedThreadPool();
for(int i=1;i<=2;i++){
final int sequence = i;
//仔细品味runnable对象放到循环里面和外面的区别,为了让每个对象有自己独立的编号
service.execute(new Runnable(){
public void run() {
try{Thread.sleep(200);}catch(Exception e){}
for(int j=1;j<=3;j++){
System.out.println(Thread.currentThread().getName() + " is serving "
+ sequence + " task:" + "loop of " + j);
}
}
});
}
/*
用下面这句代码来说明上面的代码是在提交任务,并且所有的任务都已经提交了,但任务是什么时候执行的,则是由线程池调度的!
*/
System.out.println("all task have committed!");
//注意与service.shutdownNow()的区别。
service.shutdown();
ScheduledExecutorService scheduledService = Executors.newScheduledThreadPool(1);// 按时间间隔定时调用
scheduledService.scheduleAtFixedRate(
new Runnable(){
public void run() {
System.out.println("bomb!!!");
}},
5,
1,
TimeUnit.SECONDS);
}
}
// all task have committed!
// pool-1-thread-2 is serving 2 task:loop of 1
// pool-1-thread-1 is serving 1 task:loop of 1
// pool-1-thread-2 is serving 2 task:loop of 2
// pool-1-thread-1 is serving 1 task:loop of 2
// pool-1-thread-2 is serving 2 task:loop of 3
// pool-1-thread-1 is serving 1 task:loop of 3
// bomb!!!
// bomb!!!
// bomb!!!
// ...
Callable&Future
- Future取得的结果类型和Callable返回的结果类型必须一致,这是通过泛型来实现的。
- Callable要采用ExecutorSevice的submit方法提交,返回的future对象可以取消任务。
- CompletionService用于提交一组Callable任务,其take方法返回已完成的一个Callable任务对应的Future对象。
public static void main(String[] args) {
// Future获取结果
ExecutorService threadPool = Executors.newSingleThreadExecutor();
Future<String> future =
threadPool.submit(
new Callable<String>() {
public String call() throws Exception {
Thread.sleep(2000);
return "hello";
};
}
);
System.out.println("等待结果");
try {
System.out.println("拿到结果:" + future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
// 批量获取结果
ExecutorService threadPool2 = Executors.newFixedThreadPool(10);
CompletionService<Integer> completionService = new ExecutorCompletionService<Integer>(threadPool2);
for(int i=1;i<=10;i++){
final int seq = i;
completionService.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
Thread.sleep(new Random().nextInt(5000));
return seq;
}
});
}
for(int i=0;i<10;i++){
try {
System.out.println(
completionService.take().get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
// 等待结果
// 拿到结果:hello
// 8
// 2
// 5
// 4
// 10
// 1
// 6
// 7
// 9
// 3
定时器
Timer
public class TimerDemo {
public static void main(String[] args) {
// 创建定时器对象
Timer t = new Timer();
// 3秒后执行爆炸任务第一次,如果不成功,每隔2秒再继续炸
t.schedule(new MyTask(t), 3000, 2000);
}
}
// 做一个任务
class MyTask extends TimerTask {
private Timer t;
public MyTask(){}
public MyTask(Timer t){
this.t = t;
}
@Override
public void run() {
System.out.println("beng,爆炸了");
t.cancel();// 爆炸成功结束任务
}
}
ScheduledThreadPoolExecutor
优于Timer
ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(3);
scheduledThreadPoolExecutor.schedule(
() -> System.out.println("hello"),
1,
TimeUnit.SECONDS
);
阻塞队列
阻塞队列与Semaphore有些相似,但也不同,阻塞队列是一方存放数据,另一方释放数据,Semaphore通常则是由同一方设置和释放信号量。
- ArrayBlockingQueue
- 只有put方法和take方法才具有阻塞功能
- SynchronousQueue
- 只有有线程take时才能put,否则阻塞
public static void main(String[] args) {
final BlockingQueue queue = new ArrayBlockingQueue(3);//设置上限三个,满了以后put阻塞
for(int i=0;i<2;i++){
new Thread(){
public void run(){
while(true){
try {
Thread.sleep((long)(Math.random()*1000));
System.out.println(Thread.currentThread().getName() + "准备放数据!");
queue.put(1);
System.out.println(Thread.currentThread().getName() + "已经放了数据," +
"队列目前有" + queue.size() + "个数据");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
}
new Thread(){
public void run(){
while(true){
try {
//将此处的睡眠时间分别改为100和1000,观察运行结果
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + "准备取数据!");
queue.take();
System.out.println(Thread.currentThread().getName() + "已经取走数据," +
"队列目前有" + queue.size() + "个数据");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
}
// Thread-0准备放数据!
// Thread-0已经放了数据,队列目前有1个数据
// Thread-1准备放数据!
// Thread-1已经放了数据,队列目前有2个数据
// Thread-1准备放数据!
// Thread-1已经放了数据,队列目前有3个数据
// Thread-2准备取数据!
// Thread-2已经取走数据,队列目前有2个数据
// Thread-0准备放数据!
// Thread-0已经放了数据,队列目前有3个数据
// Thread-1准备放数据!
// Thread-0准备放数据!
// Thread-2准备取数据!
// Thread-2已经取走数据,队列目前有2个数据
// Thread-1已经放了数据,队列目前有3个数据
// ....
两个具有1个空间的队列可以实现同步通知的功能
构建自定义的同步工具(AQS和CAS)
状态依赖的管理
对于并发对象上依赖状态的方法,虽然有时候在提前条件不满足的情况下不会失败,但通常有一种更好的选择,即等待前提条件变为真。
依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现起来要更为方便且不易出错。内置的条件队列可以使线程一直阻塞,直到对象进入某个线程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。
通过轮询与休眠等方式来(勉强地)解决状态依赖性问题,并没有高效地条件等待机制的有价值。
可阻塞的状态依赖操作的结构:
acquire lock on object state
while (precondition does not hold)
{
release lock
wait until precondition might hold
optionally fail if interrupted or timeout expires
reacquire lock
}
perform action
release lock
当前提条件未满足时,依赖状态的操作可以抛出一个异常或返回一个错误状态(使其成为调用者的一个问题),也可以保持阻塞知道对象进入正确的状态。
示例代码基类
@ThreadSafe
public abstract class BaseBoundedBuffer<V> {
@GuardedBy("this")
private final V[] buf;
@GuardedBy("this")
private int tail;
@GuardedBy("this")
private int head;
@GuardedBy("this")
private int count;
protected BaseBoundedBuffer(int capacity) {
this.buf = (V[]) new Object[capacity];
}
protected synchronized final void doPut(V v) {
buf[tail] = v;
if (++tail == buf.length) {
tail = 0;
}
++count;
}
protected synchronized final V doTake() {
V v = buf[head];
buf[head] = null;
if (++head == buf.length) {
head = 0;
}
--count;
return v;
}
public synchronized final boolean isFull() {
return count == buf.length;
}
public synchronized final boolean isEmpty() {
return count == 0;
}
}
将前提条件失败信息传递给调用者
当条件不满足时,不执行相应的操作。调用者需要做好处理异常的准备,并在操作失败后重试
@ThreadSafe
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
public GrumpyBoundedBuffer() {
this(100);
}
public GrumpyBoundedBuffer(int size) {
super(size);
}
public synchronized void put(V v) throws BufferFullException {
if (isFull()) {
throw new BufferFullException();
}
doPut(v);
}
public synchronized V take() throws BufferEmptyException {
if (isEmpty()) {
throw new BufferEmptyException();
}
return doTake();
}
}
客户端:
while (true) {
try {
String item = buffer.take();
// use item
break;
} catch (BufferEmptyException e) {
Thread.sleep(SLEEP_GRANULARITY);
}
}
一种变化形式是,在缓存处于错误状态时,返回一个错误值。但这种方法并没有解决根本问题:调用者必须自行处理前提条件失败的情况。
客户代码必须要在二者之间进行选择:
- 要么容忍自旋导致的CPU时钟周期浪费
- 要么容忍由于休眠而导致的低响应性
- 除了忙等待与休眠之外,还有一种选择就是调用Thread.yield
- 这相当于给调度器一个提示:现在需要让出一定的时间使另一个线程运行。假如正在等待另一个线程执行工作,那么如果选择让出处理器而不是消耗完整个调度时间片,那么可以使整体的执行过程变快。
通过轮询与休眠来实现简单的阻塞
要选择合适的休眠时间间隔,就需要在响应性与CPU使用率之间进行权衡。休眠的间隔越小,响应性就越高,但消耗的CPU资源也越高。
@ThreadSafe
public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
int SLEEP_GRANULARITY = 60;
public SleepyBoundedBuffer() {
this(100);
}
public SleepyBoundedBuffer(int size) {
super(size);
}
public void put(V v) throws InterruptedException {
while (true) {
synchronized (this) {
if (!isFull()) {
doPut(v);
return;
}
}
Thread.sleep(SLEEP_GRANULARITY);
}
}
public V take() throws InterruptedException {
while (true) {
synchronized (this) {
if (!isEmpty()) {
return doTake();
}
}
Thread.sleep(SLEEP_GRANULARITY);
}
}
}
如果条件立即为真,则存在不不要的休眠时间。
对调用者提出了一个新的需求,处理InterruptedException。
条件队列
“条件队列”这个名字来源于:它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。
正如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,并且Object中的wait、notify和notifyAll方法就构成了内部条件队列的API。
对象的内置锁与其内部条件队列是相互关联的,要调用对象X中条件队列的任何一个方法,必须持有对象X上的锁。
这是因为“等待由状态构成的条件”与“维护状态一致性”这两种机制必须被紧密地绑定在一起:
只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。
Object.wait会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并且修改对象的状态。当被挂起的线程醒来时,它将在返回之前重新获取锁。
使用wait和nitifyAll来实现了一个有界缓存。这比使用“休眠轮询”的有界缓存更简单,并且更高效(当缓存状态没有发生变化时,线程醒来的次数将更少),响应性也更高(当发生特定状态变化时将立即醒来)。
这是一个较大的改进,但要注意:
与使用“休眠”的有界缓存相比,条件队列并没有改变原来的语义。它只是在多个方面进行了优化:CPU效率、上下文切换开销和响应性等。
如果某个功能无法通过“轮询和休眠”来实现,那么使用条件队列也无法实现,但条件队列使得在表达和管理状态依赖时更加简单和高效。
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
// CONDITION PREDICATE: not-full (!isFull())
// CONDITION PREDICATE: not-empty (!isEmpty())
public BoundedBuffer() {
this(100);
}
public BoundedBuffer(int size) {
super(size);
}
// BLOCKS-UNTIL: not-full
public synchronized void put(V v) throws InterruptedException {
while (isFull()) {
wait();
}
doPut(v);
notifyAll();
}
// BLOCKS-UNTIL: not-empty
public synchronized V take() throws InterruptedException {
while (isEmpty()) {
// wait将释放锁,阻塞当前线程,等待被中断或者唤醒
// 唤醒后重新获取锁
wait();
}
V v = doTake();
notifyAll();
return v;
}
// BLOCKS-UNTIL: not-full
// Alternate form of put() using conditional notification
public synchronized void alternatePut(V v) throws InterruptedException {
while (isFull()) {
wait();
}
boolean wasEmpty = isEmpty();
doPut(v);
if (wasEmpty) {
notifyAll();
}
}
}
使用条件队列
条件谓词
条件谓词是使某个操作成为状态依赖的前提条件,是由类中各个状态变量构成的表达式。
要想正确地使用条件队列,关键是找出对象在哪个条件谓词上等待。条件谓词将在等待与通知等过程中导致许多困惑,事实上,在Java语言规范或Javadoc中根本就没有直接提到它。但如果没有条件谓词,条件等待机制将无法发挥作用。
将与条件队列相关联的条件谓词以及在这些条件谓词上等待的操作都写入文档。
在条件等待中存在一种重要的三元关系,包括加锁、wait方法和一个条件谓词。 在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须先持有这个锁。 锁对象与条件队列对象(即调用wait和notify等方法所在的对象)必须是同一个对象。
每一次wait调用都会隐式地把特定的条件谓词关联起来。当调用某个特定条件谓词的wait方法时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。
过早唤醒
内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用notifyAll而醒来时,并不意味该线程正在等待的条件谓词已经变成真了。 另外,wait方法还可以“假装”返回,而不是由于某个线程调用notify。
由于线程在条件谓词不为真的情况下也可以反复地醒来,因此必须在一个循环中调用wait,并在每次迭代中都测试条件谓词。下面给出了条件等待的标准形式:
void stateDependentMethod() throws InterruptedException {
// 必须通过一个锁来保护条件谓词
synchronized(lock) {
while(!condietionPredicate()){
lock.wait();
}
// 现在对象处于合适的状态
}
}
当使用条件等待时(例如Object.wait或Condition.await):
- 通常都有一个条件谓词——包含一些对象状态的测试,线程在执行前必须首先通过这些测试。
- 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。
- 在一个循环中调用wait。
- 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
- 当调用wait、notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
- 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
丢失的信号
线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词,线程将等待一个已经发过的事件。
通知
每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。
由于在调用notifyAll或notify时必须持有条件队列对象的锁,而如果这些等待中线程此时不能重新获得锁,那么无法从wait返回,因此发出通知的线程应该尽快地释放锁,从而确保正在等待的线程尽可能快地解除阻塞。
由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用notify而不是notifyAll,那么将是一种危险的行为,因为单一的通知很容易导致类似于信号丢失的问题。
只有同时满足以下两个条件时,才能用单一的notify而不是notifyAll:
- 所有等待线程的类型都相同
- 只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后将执行相同的操作
- 单进单出
- 在条件变量上的每次通知,最多只能唤醒一个线程来执行
由于大多数类并不满足这些需求,因此普遍认为的做法是优先使用notifyAll而不是notify。虽然notifyAll可能比notify更低效,但却更容易确保类的行为是正确的。
仅当缓存从空变为非空,或者从满转为非满时,才需要释放一个线程。并且,仅当put或take影响到这些状态的转换时,才发出通知。这也被称为“条件通知”。虽然“条件通知”可以提升性能,但却很难实现(而且还会使子类的实现变得复杂),因此在使用时应该谨慎。
public synchronized void alternatePut(V v) throws InterruptedException {
while (isFull()) {
wait();
}
boolean wasEmpty = isEmpty();
doPut(v);
if (wasEmpty) {
notifyAll();
}
}
单次通知和条件通知都属于优化措施。通常,在使用这些优化措施时,应该遵循“首选使程序正确地执行,然后才使其运行得更快”这个原则。
//可打开可关闭的阀门
public class ThreadGate {
// 条件谓词:opened-since(n) (isOpen||generation>n)
private boolean isOpen;
private int generation;
public synchronized void close() {
isOpen = false;
}
public synchronized void open() {
++generation;
isOpen = true;
notifyAll();
}
public synchronized void await() throws InterruptedException {
/*
* 在检测等待条件前先将generation记录下来,以便在唤醒并获取锁后再次
* 检测条件isOpen不满足时(即关闭),被阻塞的线程还是能继续通过。因为
* 如果没有定义generation变量,当调用都打开阀门后又立即关闭,这会导致
* 唤醒过来的线程不能正常通过阀门,所以确保只要是阻塞在该阀门上的所有线
* 程在获得打开后的通知时就一定能通过,不会再被阻塞
*/
int arrivalGeneration = generation;
while (!isOpen && arrivalGeneration == generation)
wait();
}
}
当增加一个新的状态依赖操作是,可能需要对多条修改对象的代码路径就行改动,才能正确地执行通知。
子类的线程安全问题
如果在实施子类化时违背了条件通知或单词通知的某个需求,那么在子类中可以增加合适的通知机制来代表基类。
对于状态依赖的类,要么将其等待和通知等协议完全向子类公开(并且写入正式文档),要么完全阻止子类参与到等待和通知等过程中。(这是对”要么围绕着继承来设计和文档化,要么禁止使用继承“这条规则的一种扩展。)
- 当设计一个可被继承的状态依赖类时,至少需要公开条件队列和锁,并且将条件谓词和同步策略都写入文档。此外,还可能需要公开一些底层的状态变量。
- 另外一种选择就是完全禁止子类化,例如将类声明为final类型,或者将条件队列、锁和状态变量等隐藏起来,使子类看不见它们。否则,如果子类破坏了在基类中使用notify的方式,那么基类需要修复这种破坏。
封装条件队列
通常,我们应该把条件队列封装起来,因而除了使用条件队列的类,就不能在其他地方访问它。否则,调用者会自以为理解了在等待和通知上使用的协议,并且采用一种违背设计的方式来使用条件队列。
不幸的是,这条建议——将条件队列对象封装起来,与线程安全的最常见设计模式并不一致,在这种模式中建议使用对象的内置锁来保护对象自身的状态。 在BoundedBuffer中给出了这种常见的设计模式,即缓存对象自身既是锁,又是条件队列。 然而,可以很容易将BoundedBuffer重新设计为使用私有的锁对象和条件队列,唯一不同之处在于,新的BoundedBuffer不再支持任何形式的客户端加锁。
入口协议与出口协议
入口协议与出口协议用来描述wait和notify方法的使用。对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议和出口协议。
- 入口协议就是该操作的条件谓词
- 出口协议则包括,检查被该操作修改的所有状态变量,并确认它们是否使某个其他的条件谓词变为真,如果是,则通知相关的条件队列。
在AbstractQueuedSynchronizer中使用出口协议。 这个类并不是由同步器类执行自己的通知,而是要求同步器方法返回一个值来表示该类的操作是否已经解除了一个或多个等待线程的阻塞。 这种明确的API调用需求使得更难以”忘记“在某些状态转换发生时进行通知。
Condition
参考《Lock与ReentrantLock的Condition》部分
AbstractQueuedSynchronizer
AQS解决了在实现同步器时涉及的大量细节问题,例如等待线程采用FIFO队列操作顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。
在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。
- 获取操作是一种依赖状态的操作,并且通常会阻塞。
- 当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或者许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态
- 在使用CountDownLatch时,“获取”操作意味着“等待并直到闭锁到达结束状态”
- 使用FutureTask时,则意味着“等待并直到任务已经完成”
- “释放”并不是一个可阻塞的操作时,当执行“释放”操作时,所有在请求时被阻塞的线程都会开始执行。
- AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getState,setState以及compareAndSetState等protected类型方法来进行操作
- 这个整数可以用于表示任意状态
- ReentrantLock用它来表示所有线程已经重复获取该锁的次数
- Semaphore用它来表示剩余的许可数量
- FutureTask用它来表示任务的状态(尚未开始、正在运行、已完成以及已取消)
- 在同步器类中还可以自行管理一些额外的状态变量
- ReentrantLock保存了锁的当前所有者的信息,这样就能区分某个获取操作是重入的还是竞争的。
一个获取操作包括两部分。
- 首先,同步器判断当前状态是否允许获得操作,如果是,则允许线程执行,否则获取操作将阻塞或失败。这种判断是由同步器的语义决定的。
- 对于锁来说,如果它没有被某个线程持有,那么他就能被成功地获取
- 对于闭锁来说,如果它处于结束状态,那么也能被成功地获取
- 其次,就是更新同步器的状态,获取同步器的某个线程可能会对其他线程能否也获取该同步器造成影响。
- 当获取一个锁后,锁的状态将从“未被持有”变成“已被持有”
- 从Semaphore中获取一个许可后,将把剩余许可的数量减1。然而,当一个线程获取闭锁时,并不会影响其他线程能够获取它,因此获取闭锁的操作不会改变闭锁的状态。
AQS中获取操作和释放操作的标准形式:
boolean acquire() throws InterruptedException {
while (当前状态不允许获取操作) {
if (需要阻塞获取请求) {
如果当前线程不在队列中,则将其插入队列
阻塞当前线程
}
else{
返回失败
}
}
可能更新同步器的状态
如果线程位于队列中,则将其移出队列
返回成功
}
void release() {
更新同步器的状态
if (新的状态允许某个被阻塞的线程获取成功){
解除队列中一个或多个线程的阻塞状态
}
}
AQS的设计基于模版方法模式,使用者通过继承AQS类并重写指定的方法,可以实现不同功能的锁。可重写的方法主要包括:
方法名称 | 描述 |
---|---|
boolean tryAcquire(int arg) | 独占式获取同步状态,需要CAS |
boolean tryRelease(int arg) | 独占式释放同步状态,需要CAS |
int tryAcquireShared(int arg) | 共享式获取同步状态,需要CAS,返回>0则获取成功 |
boolean tryReleaseShared(int arg) | 共享式释放同步状态,需要CAS |
- 如果某个同步器支持独占的获取操作,那么需要实现一些保护方法,包括tryAcquire、tryRelease和isHeldExclusively等
- 对于tryRelease和tryReleaseShared方法来说,如果释放操作使得所有在获取同步器时被阻塞的线程恢复执行,那么这两个方法应该返回true。
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- 而对于支持共享获取的同步器,则应该实现tryAcquireShared和tryReleaseShared等方法。
- 如果tryAcquireShared返回一个负值,那么表示获取操作失败,返回零值表示同步器通过独占方式被获取,返回正值则表示同步器通过非独占方式被获取。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false。
AQS中的acquire、acquireShared、release和releaseShared等方法都调用这些方法在子类中带有前缀try的版本(也就是需我们重写的这些保护方法)来判断某个操作是否能执行。
在同步器的子类中,可以根据其获取操作和释放操作的语义,使用getState、setState以及compareAndSetState来检查和更新状态,并通过返回的状态值来告知基类“获取”或“释放”同步器的操作是否成功。
为了使支持条件队列的锁(例如ReentrantLock)实现起来更简单,AQS还提供了一些机制来构造与同步器相关联的条件变量。
@ThreadSafe
public class OneShotLatch {
private final Sync sync = new Sync();
public void signal() {
sync.releaseShared(0);
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(0);
}
private class Sync extends AbstractQueuedSynchronizer {
@Override
protected int tryAcquireShared(int ignored) {
// Succeed if latch is open (state == 1), else fail
return (getState() == 1) ? 1 : -1;
}
@Override
protected boolean tryReleaseShared(int ignored) {
setState(1); // Latch is now open
return true; // Other threads may now be able to acquire
}
}
}
- 在OneShotLatch中,AQS状态用来表示闭锁状态——关闭(0)或者打开(1)。
- await方法调用AQS的acquireSharedInterruptibly,然后接着调用OneShotLatch中的tryAcquireShared方法。
- 在tryAcquireShared的实现中必须返回一个值来表示该获取操作能否执行。如果之前已经打开了闭锁,那么tryAcquireShared将返回成功并允许线程通过,否则就会返回一个表示获取操作失败的值。
- acquireSharedInterrupted方法在处理失败的方式,是把这个线程放入等待线程队列中。
- 类似地,signal将调用releaseShared,接下来又会调用tryReleaseShared 。
- 在tryReleaseShared中将无条件地把闭锁的状态设置为打开,(通过返回值)表示该同步器处于完全被释放的状态。
- 因而AQS让所有等待中的线程都尝试重新请求该同步器,并且由于tryAcquireShared将返回成功,因此现在的请求操作将成功。
也可以通过扩展AQS来实现,而不是将一些功能委托给AQS,但这种做法并不合理,原因有很多。 这样做将破坏OneShotLatch接口(只有两个方法)的简洁性,并且虽然AQS的公共方法不允许调用者破坏闭锁的状态,但调用者仍可以很容易地误用它们。 java.util.concurrent中的所有同步器类都没有直接扩展AQS,而是都将它们的相应功能委托给私有的AQS子类来实现。
AQS构成
- volatile int state,用来表示锁的状态。
- state = 0 表示锁空闲,>0 表示锁已被占用。
- 独占锁时,这个值通常为1或者0,如果独占锁可重入时,即一个线程可以多次获取这个锁时,每获取一次,state就加1。
- 一旦有线程想要获得锁,就可以通过对state进行CAS增量操作,即原子性的增加state的值,其他线程发现state不为0,这时线程已经不能获得锁(独占锁),就会进入AQS的队列中等待。
- 释放锁是仍然是通过CAS来减小state的值,如果减小到0就表示锁完全释放(独占锁)
- state = 0 表示锁空闲,>0 表示锁已被占用。
- FIFO双向队列,用来维护等待获取锁的线程。
- 模式分为共享和独占。
- Node
- waitStatus
- 对于处在队列中的节点来说,前一个节点有唤醒后一个节点的任务,所以对与当前节点的前一个节点来说
- Node的正常状态是0。
- 如果waitStatus > 0, 则节点处于cancel状态,应踢出队列
- 如果waitStatus = 0, 则将waitStatus改为-1(Node.SIGNAL)
- 因此队列中节点的状态应该为-1,-1,-1,0
- waitStatus
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements Serializable {
static final class Node {
// 共享模式,表示可以多个线程获取锁,比如读写锁中的读锁
static final Node SHARED = new Node();
// 独占模式,表示同一时刻只能一个线程获取锁,比如读写锁中的写锁
static final Node EXCLUSIVE = null;
// Link to next node waiting on condition
Node nextWaiter;
// 节点等待状态
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
// waitStatus
/** waitStatus value to indicate thread has cancelled */
static final int CANCELLED = 1;
/** waitStatus value to indicate successor's thread needs unparking */
static final int SIGNAL = -1;
/** waitStatus value to indicate thread is waiting on condition */
static final int CONDITION = -2;
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
*/
// 无条件传播给下一节点,当doReleaseShared时当前状态为0时,设置为次状态,无条件唤醒下一节点
static final int PROPAGATE = -3;
// 0: None of the above 正常状态,入队后等待获取锁
}
// AQS类内部维护一个FIFO的双向队列,负责同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等构造成一个节点Node并加入同步队列;
// 当同步状态释放时,会把首节点中线程唤醒,使其再次尝试同步状态
private transient volatile Node head;
private transient volatile Node tail;
// 状态,主要用来确定lock是否已经被占用;在ReentrantLock中,state=0表示锁空闲,>0表示锁已被占用;
// 可以自定义,改写tryAcquire(int acquires)等方法即可
private volatile int state;
}
AbstractQueuedSynchronizer类的数据结构:
使用Node实现FIFO队列(CLH):
- sync queue:同步队列,head节点主要负责后面的调度
- Condition queue:单向链表,不是必须的的,也可以有多个
注意:head初始时是一个空节点(所谓的空节点意思是节点中没有具体的线程信息),之后表示的是获取了锁的节点。因此实际上head->next(即node1)才是同步队列中第一个可用节点。
源码解析
acquire(包含cancelAcquire)
如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。 这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
private Node addWaiter(Node mode) {
// 以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// 尝试快速方式直接放到队尾。
Node pred = tail;
if (pred != null) {
// 正常入队
node.prev = pred;
if (compareAndSetTail(pred, node)) {
// 双向队列非原子
pred.next = node;
return node;
}
}
// Inserts node into queue, initializing if necessary.
enq(node);
return node;
}
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 正常入队
// 这里不能保证原子性,所以unpark()Node的next为空时从tail向前查找到第一个等待的节点
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 获取前驱
final Node p = node.predecessor();
// 如果发现当前节点的前一个节点为head,那么尝试获取锁,成功之后删除head节点并将自己设置为head,退出循环;
// 如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
if (p == head && tryAcquire(arg)) {
// 拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
setHead(node);
// setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队
p.next = null; // help GC
failed = false;
return interrupted;
}
// 为了避免无意义的自旋,同步队列中的线程会通过park(this)方法用于阻塞当前线程
// 如果当前节点为阻塞状态,需要unpark()唤醒,release()方法会执行唤醒操作
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 如果出现异常确保释放资源
if (failed)
cancelAcquire(node);
}
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
//如果已经告诉前驱拿完号后通知自己一下,那就可以park
return true;
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
// 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
// 如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。
// 有可能失败,正在处理时会置0后处理next,这里也会return false重试一次
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
// 找到安全休息点后park,等待被唤醒或者interrupt
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
// 清除中断状态在外围返回被中断,最终在acquire中恢复中断状态
return Thread.interrupted();
}
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
// 取消
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
node.thread = null;
// Skip cancelled predecessors
Node pred = node.prev;
// 找到正常的节点
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
// 记录正常节点的next用于CAS
Node predNext = pred.next;
// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
// 标记当前为取消
node.waitStatus = Node.CANCELLED;
// If we are the tail, remove ourselves.
// 如果是尾节点,重新标记尾节点为正常节点
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
// 去除取消节点后重连正常节点和下一个节点
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
acquire()流程:
- 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回
- 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式
- acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false
- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
acquireQueued()流程:
- 结点进入队尾后,检查状态,找到安全休息点;
- 调用park()进入waiting状态,等待unpark()或interrupt()唤醒自己;
- 被唤醒后,看自己是不是有资格能拿到号。如果拿到,head指向当前结点,并返回从入队到拿到号的整个过程中是否被中断过;如果没拿到,继续流程1。
release
它会释放指定量的资源,根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了,如果已经彻底释放资源(state=0),要返回true,否则返回false。 如果彻底释放了,它会唤醒等待队列里的其他线程来获取资源。 这也正是unlock()的语义,当然不仅仅只限于unlock()。
public final boolean release(int arg) {
// 根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源
if (tryRelease(arg)) {
Node h = head;//找到头结点
if (h != null && h.waitStatus != 0)
// 查找并唤醒等待队列里的下一个线程
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0)
// 置0当前线程所在的结点状态,允许失败。
// shouldParkAfterFailedAcquire设置状态后还会重试一次,这里也会继续释放next
compareAndSetWaitStatus(node, ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
// 否则会尝试从tail开始往前找到一个没被取消,不是header结点且非null的结点,将它进行释放。
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
// 主要在这里找到下一个需要通知的节点,如果node.next就是需要通知的节点,则直接通知;
// 否则,可能node.next == null(参考enq方法,原因是双向链表设置b.pre = a和a.next = b的时候不能保证原子性,只能保证b.pre = a成功,这时候另一条线程可能看到a.next == null)
// 或者s.waitStatus > 0(原因是线程等不及被取消了static final int CANCELLED = 1;),
// 这个时候就要从队尾tail开始找,找到离队头head最近的一个需要通知的节点Node。
// 注意这里没有break,直到倒序查找到第一个正在等待唤醒的Node
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
unparkSuccessor()唤醒等待队列中最前边的那个未放弃线程:
- 和acquireQueued()联系起来,s被唤醒后,进入if (p == head && tryAcquire(arg))的判断
- 如果p!=head,它会再进入shouldParkAfterFailedAcquire()寻找一个安全点。既然s已经是等待队列中最前边的那个未放弃线程,那么通过shouldParkAfterFailedAcquire()的调整,s也必然会跑到head的next结点,下一次自旋p==head就成立,
- 然后s把自己设置成head标杆结点,表示自己已经获取到资源了,acquire()也返回了
acquireShared
它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。
public final void acquireShared(int arg) {
// 负值代表获取失败;0代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。
if (tryAcquireShared(arg) < 0){
// 失败进入队列,直到获取到资源为止才返回。unpark()后如果资源有剩余会唤醒后面的线程来尝试
doAcquireShared(arg);
}
// 成功放行
}
// 参考acquireQueued方法解析
private void doAcquireShared(int arg) {
// 进入队列
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
// 释放并传播,将head指向自己,还有剩余资源可以再唤醒之后的线程
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
// 否则找到安全点后park
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus either before
* or after setHead) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
// 如果还有剩余量,继续唤醒下一个线程
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
// 如果愿意共享,就向后传递释放,如果不愿意共享,就不释放后面的Thread
if (s == null || s.isShared())
// 参考releaseShared部分
doReleaseShared();
}
}
- tryAcquireShared()尝试获取资源,成功则直接返回。
- 失败则通过doAcquireShared()进入等待队列park(),直到被unpark()/interrupt()并成功获取到资源才返回。整个等待过程也是忽略中断的。
跟独占模式比,还有一点需要注意的是:当前线程获取资源成功后,如果还有剩余资源,那么还会唤醒后面的线程来尝试获取资源。
releaseShared
- 独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;
- 而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。
自定义同步器可以根据需要决定tryReleaseShared()的返回值。
public final boolean releaseShared(int arg) {
// 尝试释放资源,根据需求判断是否需要完全释放来返回结果
if (tryReleaseShared(arg)) {
// 唤醒后面的节点
doReleaseShared();
return true;
}
return false;
}
private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
// 释放节点
unparkSuccessor(h);
}
// 设置为传播状态
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
注意事项:值得注意的是,acquire()和acquireSahred()两种方法下,线程在等待队列中都是忽略中断的,设置中断状态交由外围处理。AQS也支持响应中断的,acquireInterruptibly()/acquireSharedInterruptibly()即是,抛出InterruptedException。
CLH队列
AQS里面的CLH队列是CLH同步锁的一种变形。其主要从两方面进行了改造:
- 节点的结构与节点等待机制。
- 在结构上引入了头结点和尾节点,他们分别指向队列的头和尾,尝试获取锁、入队列、释放锁等实现都与头尾节点相关,并且每个节点都引入前驱节点和后后续节点的引用;
- 在等待机制上由原来的自旋改成阻塞唤醒。
CLH算法实现
- CLH队列中的结点QNode中含有一个locked字段,该字段若为true表示该线程需要获取锁,且不释放锁,为false表示线程释放了锁。
- 结点之间是通过隐形的链表相连,之所以叫隐形的链表是因为这些结点之间没有明显的next指针,而是通过myPred所指向的结点的变化情况来影响myNode的行为。
- CLHLock上还有一个尾指针,始终指向队列的最后一个结点。
public class CLHLock implements Lock {
AtomicReference<QNode> tail;
ThreadLocal<QNode> myPred;
ThreadLocal<QNode> myNode;
public CLHLock() {
tail = new AtomicReference<QNode>(new QNode());
myNode = new ThreadLocal<QNode>() {
@Override
protected QNode initialValue() {
return new QNode();
}
};
myPred = new ThreadLocal<QNode>() {
@Override
protected QNode initialValue() {
return null;
}
};
}
@Override
public void lock() {
QNode qnode = myNode.get();
qnode.locked = true;
QNode pred = tail.getAndSet(qnode);
myPred.set(pred);
while (pred.locked) {
}
}
@Override
public void unlock() {
QNode qnode = myNode.get();
qnode.locked = false;
myNode.set(myPred.get());
}
private static class QNode {
private boolean locked;
}
}
interface Lock {
void lock();
void unlock();
}
- 当一个线程需要获取锁时,会创建一个新的QNode,将其中的locked设置为true表示需要获取锁,然后线程对tail域调用getAndSet方法,使自己成为队列的尾部,同时获取一个指向其前趋的引用myPred,然后该线程就在前趋结点的locked字段上旋转,直到前趋结点释放锁。
- 当一个线程需要释放锁时,将当前结点的locked域设置为false,同时回收前趋结点。
线程A需要获取锁,其myNode域为true,些时tail指向线程A的结点,然后线程B也加入到线程A后面,tail指向线程B的结点。
然后线程A和B都在它的myPred域上旋转,一量它的myPred结点的locked字段变为false,它就可以获取锁扫行。
线程A的myPred locked域为false,此时线程A获取到了锁。
优缺点
CLH队列锁的优点是空间复杂度低(如果有n个线程,L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+n),n个线程有n个myNode,L个锁有L个tail)。
唯一的缺点是在NUMA系统结构下性能很差,在这种系统结构下,每个线程有自己的内存,如果前趋结点的内存位置比较远,自旋判断前趋结点的locked域,性能将大打折扣,但是在SMP系统结构下该法还是非常有效的。一种解决NUMA系统结构的思路是MCS队列锁。
MCS算法实现
MSC与CLH最大的不同并不是链表是显示还是隐式,而是线程自旋的规则不同:
CLH是在前趋结点的locked域上自旋等待,而MCS是在自己的结点的locked域上自旋等待。 正因为如此,它解决了CLH在NUMA系统架构中获取locked域状态内存过远的问题。
- 队列初始化时没有结点,tail=null
- 线程A想要获取锁,于是将自己置于队尾,由于它是第一个结点,它的locked域为false
- 线程B和C相继加入队列,a->next=b,b->next=c。且B和C现在没有获取锁,处于等待状态,所以它们的locked域为true,尾指针指向线程C对应的结点
- 线程A释放锁后,顺着它的next指针找到了线程B,并把B的locked域设置为false。这一动作会触发线程B获取锁
public class MCSLock {
public static class MCSNode {
volatile MCSNode next;
// 默认是在等待锁
volatile boolean isBlock = true;
}
// 指向最后一个申请锁的MCSNode
volatile MCSNode tail;
private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER =
AtomicReferenceFieldUpdater.newUpdater(MCSLock.class, MCSNode.class, "tail");
public void lock(MCSNode currentThread) {
// step 1
MCSNode predecessor = UPDATER.getAndSet(this, currentThread);
if (predecessor != null) {
// step 2
predecessor.next = currentThread;
// step 3
while (currentThread.isBlock) {
}
}
}
public void unlock(MCSNode currentThread) {
// 锁拥有者进行释放锁才有意义
if (currentThread.isBlock) {
return;
}
// 检查是否有人排在自己后面
if (currentThread.next == null) {
// step 4
if (UPDATER.compareAndSet(this, currentThread, null)) {
// compareAndSet返回true表示确实没有人排在自己后面
return;
} else {
// 突然有人排在自己后面了,可能还不知道是谁,下面是等待后续者
// 这里之所以要忙等是因为:step 1执行完后,step 2可能还没执行完
// step 5
while (currentThread.next == null) {
}
}
}
currentThread.next.isBlock = false;
// for GC
currentThread.next = null;
}
}
java.util.concurrent同步器类中的AQS
ReentrantLock
ReentrantLock只支持独占方式的获取操作,因此它实现了tryAcquire、tryRelease和isHeldExeclusively。
ReentrantLock将同步状态用于保存锁获取操作的次数,并且还维护一个owner变量来保存当前所有者线程的标识符,只有在当前线程刚刚获取到锁,或者正要释放锁的时候,才会修改这个变量。 在tryRelease中检查owner域,从而确保当前线程在执行unlock操作之前已经获取了锁:在tryAcquire中将使用这个域来区分获取操作是重入的还是竞争的。
非公平版本的tryAcquire:
protected boolean tryAcquire(int ignored) {
final Thread current = Thread.currentThread();
int c = getState();
if(c == 0) {
if(compareAndSetState(0,1)) {
owner = current;
return true;
}
}else if(current == owner) {
setState(c + 1);
return true;
}
return false;
}
当一个线程尝试获取锁时,tryAcquire将首先检查锁的状态。如果锁未被持有,那么它将尝试更新锁的状态以表示已经被持有。 由于状态可能在检查后被立即修改,因此tryAcquire使用compareAndSetState来原子地更新状态,表示这个锁已经被占有,并确保状态在最后一次检查以后就没有被修改过。 如果锁状态表明它已经被持有,并且如果当期那线程是锁的拥有者,那么获取计数会递增,如果当前线程不是锁的拥有者,那么获取操作将失败。
ReentrantLock还利用了AQS对多个条件变量的和多个等待线程集的内置支持。Lock.newCondition将返回一个新的ConditionObject实例,这是AQS的一个内部类。
公平与非公平同步对比:
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
// 非公平锁的lock()方法会直接尝试获取锁,无视同步队列
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else{
// 失败后调用nonfairTryAcquire
acquire(1);
}
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 不检查同步队列
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 检查同步队列
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
Semaphore与CountDownLatch
Semaphore将AQS的同步状态用于保存当前可用许可的数量。
tryAcquireShared首先计算剩余许可的数量,如果没有足够的许可,那么会返回一个值表示获取操作失败。 如果还有剩余的许可,那么tryAcquireShared会通过compareAndSetState以原子方式来降低许可的计数。 如果这个操作成功(这意味着许可的计数自从上一次读取后就没有被修改过),那么将返回一个值表示获取操作成功。
在返回值中还包含了表示其他共享获取操作能够成功的信息,如果成功,那么其他等待的线程同样会解除阻塞。
当没有足够的许可,或者当tryAcquireShared可以通过原子方式来更新许可的计数以响应获取操作时,while循环将终止。 虽然对compareAndSetState的调用可能由于与另一个线程发生竞争而失败,并使其重新尝试,但在经过了一定次数的重试操作以后,在这两个结合结束条件中有一个会变为真。
tryReleaseShared将增加许可数,这可能会解除等待中线程的阻塞状态,并且不断地重试直到更新操作成功。 tryReleaseShared的返回值表示在这次释放操作中解除了其他线程的阻塞。
protected int tryAcquireShared(int acquires) {
while (true) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining)) {
return remaining;
}
}
}
protected boolean tryReleaseShared(int releases) {
while (true) {
int p = getState();
if (compareAndSetState(p, p + releases)) {
return true;
}
}
}
// fair
protected int tryAcquireShared(int acquires) {
for (;;) {
if (hasQueuedPredecessors())
return -1;
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
// nonfair
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))
return true;
}
}
CountDownLatch使用AQS的方式与Semaphore很相似,在同步状态中保存的是当前的计数值。 countDown方法调用release,从而导致计数值递减,并且当计数值为零时,解除所有等待线程的阻塞。 await调用acquire,当计数器为零时,acquire将立即返回,否则将阻塞。
FutureTask
Future.get的语义非常类似于闭锁的语义——如果发生了某个事件(有FutureTask表示的任务执行完成或被取消),那么线程就可以恢复执行,否则这些线程将停留在队列中并直到该事件发生。
在FutureTask中,AQS同步状态被用来保存任务的状态,例如,正在运行、已完成或已取消。 FutureTask还维护一些额外的状态变量,用来保存计算结果或者抛出的异常。 此外,它还维护了一个引用,指向正在执行计算任务的线程(如果它当前处于运行状态),因而如果任务取消,该线程就会中断。
注意事项:新版的FutureTask采用CAS状态字段和用来保存线程的栈来重写,避免用户取消时保留了中断状态的问题
ReentrantReadWriteLock
ReadWriteLock接口表示存在两个锁:一个读取锁和一个写入锁,但在基于AQS实现的ReentrantReadWriteLock中,单个AQS子类将同时管理读取加锁和写入加锁。
ReentrantReadWriteLock使用了一个16位的状态来表示写入锁的计数,并且使用了另一个16位的状态来表示读取锁的计数。
在读取锁上的操作将使用共享的获取方法与释放方法,在写入锁上的操作将使用独占的获取方法与释放方法。
AQS在内部维护了一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问。 在ReentrantReadWriteLock中,当锁可用时,如果位于队列头部的线程执行写入操作,那么线程会得到这个锁, 如果位于队列头部的线程执行读取操作,那么队列中的第一个写入之前的所有线程都将获得这个锁。
原子变量和非阻塞同步机制
近年来,在并发算法领域的大多数研究都侧重于非阻塞算法,这种算法用底层的原子机器指令来代替锁来确保数据在并发访问中的一致性,非阻塞算法被广泛应用于OS和JVM中实现线程/进程调度机制和GC以及锁,并发数据结构中。
与基于锁的方案相比,非阻塞算法在设计和实现上都要复杂得多,但它们在可伸缩性和活跃性上却拥有巨大的优势。 由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大地减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。 在基于锁的算法中,如果一个线程在休眠或自旋的同时持有一个锁,那么其他线程都无法执行下去,而非阻塞算法不会受到单个线程失败的影响。
从jdk5开始,可以使用原子变量类来构建高效的非阻塞算法。即使原子变量没有用于非阻塞算法的开发,它们也可以用做一种“更好的volatile类型变量”。 原子变量提供了与volatile类型变量相同的内存语义,此外还支持原子的更新操作,从而使它们更加适用于实现计数器、序列发生器和统计数据收集等,同时还能比基于锁的方法提供更高的可伸缩性。
锁的劣势
现代的许多jvm都对非竞争锁获取和锁释放等操作进行了极大的优化,但如果有多个线程同时请求锁,那么jvm就需要借助操作系统的功能。 如果出现了这种情况,那么一些线程将被挂起并且在稍后恢复运行(当线程在锁上发生竞争时,智能的jvm不一定会挂起线程,而是根据之前获取操作中对锁的持有时间长短来判断是使此线程挂起还是自旋等待)。 当线程恢复执行时,必须等待其他线程执行完它们的时间片以后,才能被调度执行。在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。
如果在基于锁的类中包含有细粒度的操作(例如同步容器类,在其大多数方法中只包含了少量操作),那么当在锁上存在着激烈的竞争时,调度开销和工作开销的比值会非常高。
与锁相比,volatile变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或线程调度等操作。然而volatile变量同样存在一些局限:
虽然它们提供了相似的可见性保证,但不能用于构建原子的复合操作。因此,当一个变量依赖其他的变量时,或者当变量的新值依赖于旧值时,就不能使用volatile变量。这些都限制了volatile变量的使用,因此它们不能用来实现一些常见的工具,例如计数器或互斥体(mutex)。
锁定还存在其他一些缺点。当一个线程正在等待锁时,它不能做任何其他事情。 如果一个线程在持有锁的情况下被延迟执行(例如发生了缺页错误、调度延迟或者其他类似情况),那么所有需要这个锁的线程都无法执行下去。 如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么这将是一个严重的问题—也被称为优先级反转。 即使高优先级的线程可以抢先执行,但仍然需要等待锁被释放,从而导致它的优先级会降至低优先级线程的级别。 如果持有锁的线程被永久地阻塞(例如由于出现了无限循环,死锁,活锁或者其他的活跃性故障),所有等待这个锁的线程就永远无法执行下去。
即使忽略这些风险,锁定方式对于细粒度的操作(例如递增计数器)来说仍然是一种高开销的机制。 在管理线程之间的竞争时应该有一种粒度更细的技术,类似于volatile变量的机制,同时还要支持原子的更新操作。 幸运的是,在现代的处理器中提供了这种机制。
硬件对并发的支持
早期的处理器中支持原子的测试并设置(Test-and-Set),获取并递增(Fetch-and-Increment)以及交换(Swap)等指令,这些指令足以实现各种互斥体,而这些互斥体又可以实现一些更复杂的并发对象。
现在,几乎所有的现代处理器中都包含了某种形式的原子读-改-写指令,例如比较并交换(Compare-and-Swap)或者关联加载(Load-Linked)/条件存储(Store-Conditional)。 操作系统和jvm使用这些指令来实现锁和并发的数据结构,但在Java5之前,在Java类中还不能直接使用这些指令。
比较并交换(CAS)
简单讲就是cmpxchg+lock的原子操作
CAS包含了3个操作数—需要读写的内存位置V、进行比较的值A和拟写入的新值B。
CAS的含义是:“我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少”。
CAS是一项乐观的技术,它希望能成功地执行更新操作,并且如果有另一个线程在最近一次检查后更新了该变量,那么CAS能检测到这个错误。
模拟CAS:
@ThreadSafe
public class SimulatedCAS {
@GuardedBy("this")
private int value;
public synchronized int get() {
return value;
}
public synchronized int compareAndSwap(int expectedValue, int newValue) {
int oldValue = value;
if (oldValue == expectedValue) {
value = newValue;
}
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue, int newValue) {
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都将失败。 然而,失败的线程不会被挂起,而是被告知在这次竞争中失败,并可以再次尝试。 由于一个线程在竞争CAS时失败不会阻塞,因此它可以决定是否重新尝试,或者执行一些恢复操作,也或者不执行任何操作。 这种灵活性就大大减少了与锁相关的活跃性风险(尽管存在ABA问题)。
CAS的典型使用模式是:
首先从V中读取值A,并根据A计算新值B,然后再通过CAS以原子的方式将V中的值由A变成B(只要在这期间没有任何线程将V的值修改为其他值)。由于CAS能检测到来自其他线程的干扰,因此即使不使用锁也能实现原子的读-改-写操作序列。
非阻塞的计数器
反复地重试是一种合理的策略,但是在一些竞争很激烈的情况下,更好的方式是在重试值钱首先等待一段时间或者回退,从而避免造成活锁问题。
@ThreadSafe
public class CasCounter {
private SimulatedCAS value;
public int getValue() {
return value.get();
}
public int increment() {
int v;
do {
v = value.get();
} while (v != value.compareAndSwap(v, v + 1));
return v + 1;
}
}
CAS的主要缺点是:它将使得调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞)。
在大多数处理器上,在无竞争的锁获取和释放的“快速代码路径”上的开销,大约是CAS开销的两倍。
JVM对CAS的支持
在Java5中引入了底层的支持,在int、long和对象的引用等类型上都公开了CAS操作,并且jvm把它们编译为底层硬件提供的最有效方法。 在支持CAS的平台上,运行时把它们编译为相应的(多条)机器指令。在最坏的情况下,如果不支持CAS指令,那么jvm将使用自旋锁。
在原子变量类(例如java.util.concurrent.atomic中的AtomicXxx)中使用了这些底层的jvm支持为数字类型和引用类型提供一种高效的CAS操作, 而在java.uti.concurrent中的大多数类在实现时则直接或间接地使用了这些原子变量类。
原子变量类
原子变量比锁的粒度更细,量级更轻,并且对于在多处理器系统上实现高性能的并发代码来说是非常关键的。 原子变量将发生竞争的范围缩小到单个变量上,这是你获得的粒度最细的情况(假设算法能够基于这种粒度来实现)。 在使用基于原子变量而非锁的算法中,线程在执行时更不易出现延迟,并且如果遇到竞争,也更容易恢复过来。
原子变量类相当于一种泛化的volatile变量,能够支持原子的和有条件的读-改-写操作。
共有12个原子变量类,可分为4组:标量类、更新器类、数组类和复合变量类。
最常用的原子变量就是标量类:AtomicInteger、AtomicLong、AtomicBoolean以及AtomicReference。所有这些类都支持CAS。
原子数组类AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray中的元素可以实现原子更新。原子数组类为数组的元素提供了volatile类型的访问语义,这是普通数组所不具备的特性—volatile类型的数组仅在数组引用上具有volatile语义,而在其元素上则没有。
注意事项:在原子变量类中没有重新定义hashCode和equals方法,每个实例都是不同的,他们不适合做基于散列的容器中的键值。
原子变量是一种“更好的volatile”
通过对指向不可变对象(其中保存了下界和上界)的引用进行原子更新以避免竞态条件。
CasNumberRange中使用了AtomicReference和IntPair来保存状态,并通过使用compareAndSet,使它在更新上界和下界时能避免NumberRange的竞态条件。
使用CAS避免多元的不变约束:
@ThreadSafe
public class CasNumberRange {
private final AtomicReference<IntPair> values = new AtomicReference<IntPair>(new IntPair(0, 0));
public int getLower() {
return values.get().lower;
}
public void setLower(int i) {
while (true) {
IntPair oldv = values.get();
if (i > oldv.upper) {
throw new IllegalArgumentException("Can't set lower to " + i + " > upper");
}
IntPair newv = new IntPair(i, oldv.upper);
if (values.compareAndSet(oldv, newv)) {
return;
}
}
}
public int getUpper() {
return values.get().upper;
}
public void setUpper(int i) {
while (true) {
IntPair oldv = values.get();
if (i < oldv.lower) {
throw new IllegalArgumentException("Can't set upper to " + i + " < lower");
}
IntPair newv = new IntPair(oldv.lower, i);
if (values.compareAndSet(oldv, newv)) {
return;
}
}
}
@Immutable
private static class IntPair {
// INVARIANT: lower <= upper
final int lower;
final int upper;
public IntPair(int lower, int upper) {
this.lower = lower;
this.upper = upper;
}
}
}
性能比较:锁与原子变量
在高度竞争的情况下,锁的性能将超过原子变量的性能,但在更真实的竞争情况下,原子变量的性能将超过锁的性能。
- 锁在发生竞争时会挂起线程,从而降低了CPU的使用率和共享内存总线上的同步通信量(这类似于生产者–消费者设计中的可阻塞生产者,它能降低消费者上的工作负载,使消费者的处理速度赶上生产者的处理速度。)
- 另一方面,如果使用原子变量,那么发出调用的类负责对竞争进行管理。与大多数基于CAS的算法一样,AtomicPseudoRandom在遇到竞争时将立即重试,这通常是一种正确的方法,但在激烈竞争环境下却导致了更多的竞争。
任何一个真实的程序都不会除了竞争锁或原子变量,其它什么工作都不做。在实际情况中,原子变量在可伸缩性上要高于锁,因为在应对常见的竞争程度时,原子变量的效率会更高。
在单cpu的系统上,基于CAS的算法在性能上同样会超过基于锁的算法,因为CAS在单cpu的系统上通常能执行成功,只有在偶然情况下,线程才会在执行读-改-写的操作规程中被其他线程抢占执行。
在竞争程度较高情况下的Lock与AtomicInteger的性能:
在竞争程度适中情况下的Lock与AtomicInteger的性能:
上两个图中都包含了第三条曲线,它是一个使用了ThreadLocal来保存PRNG状态的PseudoRandom。这种实现方法改变了类的行为,即每个线程都只能看到自己私有的伪随机数字序列,而不是所有线程共享同一个随机数序列,这说明了,如果能够避免使用共享状态,那么开销将会更小。 我们可以通过提高处理竞争的效率来提高可伸缩性,但只有完全消除竞争,才能实现真正的可伸缩性。
非阻塞算法
- 如果在某个算法中,一个线程的失败或挂起不会引起其它线程也失败或挂起,那么这个算法就被称为非阻塞算法;
- 如果在算法的每个步骤中都存在每个线程能够执行下去,那么这种算法称为无锁算法(Lock-Free)。
如果在算法中仅将CAS用于协调线程之间的操作,并且能正确的实现,那么它即是一种非阻塞算法,也是一种无锁算法。
在非阻塞算法中通常不会出现死锁和优先级反转问题,但可能出现饥饿和活锁问题,因为在算法中会反复的重试。
构建一个非阻塞算法的关键在于找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。
非阻塞算法的特性:某项工作的完成具有不确定性,必须重新执行。
非阻塞的栈
在链式容器中(例如队列),有时候无须将状态转换操作表示为对节点链接的修改,也无须使用AtomicReference来表示每个必须采用原子操作来更新的链接。
public class ConcurrentStack<E> {
AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null) {
return null;
}
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node<E> {
public final E item;
public Node<E> next;
public Node(E item) {
this.item = item;
}
}
}
非阻塞的链表
一个链表队列比栈更加复杂,因为它需要支持首尾(从尾插入,从首取出)的快速访问,为了实现,它会维护独立的队首指针和队尾指针。
有两个指针指向位于尾部的节点:当前最后一个元素的next指针,以及尾节点。当成功地插入一个新元素时,这两个指针都需要采用原子操作来更新。 初看起来,这个操作无法通过原子变量来实现。在更新这两个指针时需要不同的CAS操作,并且如果第一个CAS成功,但第二个CAS失败,那么队列将处于不一致的状态。 而且,即使这两个CAS都成功了,那么在执行这两个CAS之间,仍可能有另一个线程会访问这个队列。 因此,在为链表队列构建非阻塞算法时,需要考虑到这两种情况。
我们需要使用一些技巧:
第一个技巧是:即使在一个包含多个操作的更新操作中,也要确保数据总是处于一致的状态。 这样,当线程B到达时,如果发现线程A正在执行更新,那么线程B就可以知道有一个操作已部分完成,并且不能立即开始执行自己的更新操作。 然后,B可以等待(通过反复检查队列的状态)并直到A完成更新,从而使两个线程不会相互干扰。
虽然这种方法能够使不同的线程“轮流”访问数据结构,并且不会造成破坏,但如果一个线程在更新操作中失败了,那么其他的线程都无法再访问队列。 要使得该算法成为一个非阻塞算法,必须确保当一个线程失败时不会妨碍其他线程继续执行下去。
因此,第二个技巧是:如果当B到达时发现A正在修改数据结构,那么在数据结构中应该有足够多的信息,使得B能完成A的更新操作。 如果B“帮助”A完成了更新操作,那么B可以执行自己的操作,而不用等待A的操作完成。 当A恢复执行后再试图完成其操作时,会发现B已经替它完成了。
在下面的程序中,给出了Michael-Scott提出的非阻塞连界队列算法中的插入部分,在ConcurrentLinkedQueue中使用的正是该算法。 在许多队列算法中,空队列通常都包含一个“哨兵节点”或者“哑(Dummy)节点”,并且头节点和尾节点在初始化时都指向该哨兵节点。 尾节点通常要么指向哨兵节点(如果队列为空),即队列的最后一个元素,要么(当有操作正在执行更新时)指向倒数第二个元素。
@ThreadSafe
public class LinkedQueue<E> {
private final LinkedQueue.Node<E> dummy = new LinkedQueue.Node<E>(null, null);
private final AtomicReference<LinkedQueue.Node<E>> head
= new AtomicReference<LinkedQueue.Node<E>>(dummy);
private final AtomicReference<LinkedQueue.Node<E>> tail
= new AtomicReference<LinkedQueue.Node<E>>(dummy);
public boolean put(E item) {
LinkedQueue.Node<E> newNode = new LinkedQueue.Node<E>(item, null);
while (true) {
LinkedQueue.Node<E> curTail = tail.get();
LinkedQueue.Node<E> tailNext = curTail.next.get();
if (curTail == tail.get()) {
// 步骤A 检查队列是否处于中间状态
if (tailNext != null) {
// Queue in intermediate state, advance tail
// 步骤B 推进尾节点
tail.compareAndSet(curTail, tailNext);
} else {
// In quiescent state, try inserting new node
// 步骤C 新节点链接到队列尾部
if (curTail.next.compareAndSet(null, newNode)) {
// Insertion succeeded, try advancing tail
// 步骤D
tail.compareAndSet(curTail, newNode);
return true;
}
}
}
}
}
private static class Node<E> {
final E item;
final AtomicReference<LinkedQueue.Node<E>> next;
public Node(E item, LinkedQueue.Node<E> next) {
this.item = item;
this.next = new AtomicReference<LinkedQueue.Node<E>>(next);
}
}
}
处于正常状态(或者说稳定状态)的包含两个元素的队列:
当插入一个新的元素时,需要更新两个指针。首先更新当前最后一个元素的next指针,将新节点链接到队列队尾,然后更新尾节点,将其指向这个新元素。 在两个操作之间,队列处于一种中间状态。在等二次更新完成后,队列将再次处于稳定状态。
在插入过程中队列处于中间状态:
在插入操作完成后,队列再次处于稳定状态:
队尾指针tail可能指向哨兵节点(如果队列为空)、也可能指向最后一个节点(队列处于非空且稳定状态时)、也可能指向倒数第二个元素(队列处于插入时中间状态)。
实现这个两个技巧的关键点在于:
对队列处于稳定状态时,尾节点的next域将为空,如果队列处于中间状态,那么tail.next将为非空。
因此,任何线程都能够通过检查tail.next来获取队列当前的状态。
而且,当队列处于中间状态时,可以通过将尾节点向前移动一个节点,从而结束其他线程正在执行的插入元素操作,并使得队列恢复为稳定状态。
LinkedQueue.put方法在插入新元素之前,将首先检查队列是否处于中间状态(步骤A),如果是,那么有另一个线程正在插入元素(在步骤C和D之间)。
此时线程不会等待其他线程执行完成,而是帮助它完成操作,并将尾节点向前推进一个节点(步骤B)。
然后,它将重复执行这种检查,以免另一个线程已经开始插入新元素,并继续推进尾节点,直到它发现队列处于稳定状态后,才会开始执行自己的插入操作。
由于步骤C中的CAS将把新节点链接到队列尾部,因此如果两个线程同时插入元素,那么这个CAS将失败。在这样的情况下,并不会造成破坏:不会发生任何变化,并且当前的线程只需重新读取尾节点并再次重试。
如果步骤C成功了,那么插入操作将生效,第二个CAS(步骤D)被认为是一个“清理操作”因为它既可以由执行插入操作的线程来执行,也可以由其他任何线程来执行。
如果步骤D失败,那么执行插入操作的线程将返回,而不是重新执行CAS,因为不再需要重试——另一个线程已经在步骤B中完成了这个工作。
这种方式能够工作,因为在任何线程尝试将一个新节点插入到队列之前,都会首先通过检查tail.next是否非空来判断是否需要清理队列。 如果是,它首先会推进为尾节点(可能需要执行多次),直到队列处于稳定状态。
原子的域更新器
ConcurrentLinkedQueue真正的实现与上面的表述略有区别,并未使用原子化的引用(AtomicReference),而是使用普通的volatile引用来代替下一个节点next字段,并通过基于反射实现的AtomicReferenceFieldUpdater来进行更新next字段的
private class Node<E> {
private final E item;
private volatile Node<E> next;
public Node(E item) {
this.item = item;
}
boolean casNext(Node<E> cmp, Node<E> val) {
return nextUpdater.compareAndSet(this, cmp, val);
}
void setNext(Node<E> val) {
nextUpdater.set(this, val);
}
}
private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater//用来对next字段进行更新
= AtomicReferenceFieldUpdater.newUpdater(Node.class, Node.class, "next");
注意事项:Jdk6后中实现换为UNSAFE.compareAndSwapObject操作。
原子的域更新器类表示现有volatile域的一种基于反射的“视图”,从而能够在已有的volatile域上使用CAS。 在更新器类中没有构造函数,要创建一个更新器对象,可以调用newUpdater工厂方法,并制定类和域的名字。 域更新器类没有与某个特定的实例关联在一起,因而可以更新目标类的任何实例中的域。 更新器类提供的原子性保证比普通原子类更弱一些,因为无法保证底层的域不被直接修改——compareAndSet以及其他算术方法只能确保其他使用原子域更新器方法的线程的原子性。
在ConcurrentLinkedQueue中,使用nextUpdater的compareAndSet方法来更新Node的next域。这个方法有点繁琐,但完全是为了提升性能。 对于一些频繁分配并且生命周期短暂的对象,例如队列的链接节点,如果能去掉每个Node的AtomicReference的创建过程,那么将极大地降低插入操作的开销。 然而,几乎在所有情况下,普通原子变量的性能都很不错,只有在很少的情况下才需要使用原子的域更新器。 (如果在执行原子操作的同时还需要维护现有类的串行化形式,那么原子的域更新器将非常有用。即当你想要保留现有类的序列化模式时)
ABA问题
在某些算法中,如果V的值首先由A变成B,再由B变成A,那么仍然被认为是发生了变化,并需要重新执行算法中的某些步骤。
如果算法中采用了自己的方式来管理节点对象的内存,那么可能出现ABA问题。
在这种情况下,即使链表的头节点仍然指向之前观察到的节点,那么也不足以说明链表的内容没有发生改变。
如果通过垃圾回收器来管理链表节点仍然无法避免ABA问题,那么还有一个相对比较简单的解决方案:
不是更新某个引用的值,而是更新两个值,包括一个引用和一个版本号。即使这个值由A变为B,然后又变为A,版本号也将是不同的。
AtomicStampedReference(以及AtomicMarkableReference)支持在两个变量上执行原子的条件更新。
AtomicStampedReference将更新一个“对象-引用”二元组,通过在引用上加上“版本号”,从而避免ABA问题。
类似地,AtomicMarkableReference将更新一个“对象引用-布尔值”二元组,在某些算法中将通过这种二元组使节点保存在链表中同时又将其标记为“已删除的节点”。
并发程序的测试
在测试并发程序时,所面临的主要挑战在于:潜在错误的发生并不具有确定性,而是随机的。要在测试中将这些故障暴露出来,就需要比普通的串行程序测试覆盖更广的范围并且执行更长的时间。
- 在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致
- 与活跃性相关的是性能测试。性能测试可以通过多个方面来衡量
- 吞吐量:指一组并发任务中已完成任务所占的比例。
- 响应性:指请求从发出到完成之间的时间(也称为延迟)。
- 可伸缩性:指在增加更多资源的情况下(通常指CPU),吞吐量(或者缓解短缺)的提升情况。
正确性测试
首先找出需要检查的不变性条件和后验条件
对阻塞操作的测试
JSR 166专家组创建了一个基类(guava/guava-tests/test/com/google/common/util/concurrent/JSR166TestCase.java), 其中定义了一些可以在tearDown期间(主要实现测试完成后的垃圾回收等工作)传递和报告失败信息,并遵循一个约定:每个测试必须等待它所创建的全部线程结束以后才能完成。
如果某方法需要在某些特定条件下阻塞,那么当测试这种行为时,只有当线程不再继续执行时,测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以正常返回,那么就意味着测试失败。
在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功阻塞后,还必须使用方法接触阻塞。实现这个功能的一种简单方式就是使用中断——在一个单独的线程中启动一个阻塞操作。
必须估算执行这些指令可能需要多长时间,并且等待的时间会更长,如果估计不准确就会看到伪测试失败,那么应该增大估算值。
开发人员会尝试使用Thread.getState来验证线程能否在一个条件等待上阻塞,但这种方法并不可靠。Thread.getState的返回结果不能用于并发控制,它将限制测试的有效性——其主要作用还是作为测试信息的来源。
被阻塞线程并不需要进入WAITING或TIMED_WAITING等状态,因此JVM可以选择通过自旋等待来实现阻塞。
类似地,由于在Object.wait或Condition.await等方法上存在伪唤醒,因此,即使一个线程等待的条件尚未成真,也可能从WAITING或TIMED_WAITING等状态临时性地转换到RUNNABLE状态。
即使忽略这些不同实现之间的差异,目标线程在进入阻塞状态时也会消耗一定的时间。
安全性测试
在构建对并发类的安全性测试中,需要解决的关键问题在于,要找出那些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码人为地限制并发性。 理想的情况是,在测试属性中不需要任何同步机制。
这些测试应该放在多处理器的系统上运行,从而进一步测试更多形式的交替运行。 然而,CPU的数量越多并不一定会使测试越高效。要最大程度地检测出一些对执行时序敏感的数据竞争,那么测试中的线程数量应该多于CPU数量, 这样在任意时刻都会有一些线程在运行,而另一些被交换出去,从而可以检查线程间交替行为的可预测性。
在一些测试中通常要求执行完一定数量的操作后才能停止运行,如果在测试代码中出现了一个错误并抛出一个异常,那么这个测试将永远不会结束。最常见的解决方法是:
让测试框架放弃那些没有在规定时间内完成的测试,具体要等待多长的时间,则要凭经验来确定,并且要对故障进行分析以确保所出现的问题并不是由于没有等待足够长的时间而造成的。
public class PutTakeTest extends TestCase {
protected static final ExecutorService pool = Executors.newCachedThreadPool();
protected CyclicBarrier barrier;//为了尽量做到真正并发,使用屏障
protected final BoundedBuffer<Integer> bb;
protected final int nTrials, nPairs;//元素个数、生产与消费线程数
protected final AtomicInteger putSum = new AtomicInteger(0);//放入元素检验和
protected final AtomicInteger takeSum = new AtomicInteger(0);//取出元素检验和
public static void main(String[] args) throws Exception {
new PutTakeTest(10, 10, 100000).test(); // sample parameters
pool.shutdown();
}
public PutTakeTest(int capacity, int npairs, int ntrials) {
this.bb = new BoundedBuffer<Integer>(capacity);
this.nTrials = ntrials;
this.nPairs = npairs;
this.barrier = new CyclicBarrier(npairs * 2 + 1);
}
void test() {
try {
for (int i = 0; i < nPairs; i++) {
pool.execute(new Producer());//提交生产任务
pool.execute(new Consumer());//提交消费任务
}
barrier.await(); // 等待所有线程都准备好
barrier.await(); // 等待所有线程完成,即所有线程都执行到这里时才能往下执行
assertEquals(putSum.get(), takeSum.get());//如果不等,则会抛异常
} catch (Exception e) {
throw new RuntimeException(e);
}
}
class Producer implements Runnable {
public void run() {
try {
//等待所有生产-消费线程、还有主线程都准备好后才可以往后执行
barrier.await();
// 种子,即起始值
int seed = (this.hashCode() ^ (int) System.nanoTime());
int sum = 0;//线程内部检验和
for (int i = nTrials; i > 0; --i) {
bb.put(seed);//入队
/*
* 累计放入检验和,为了不影响原程序,这里不要直接使用全局的
* putSum来累计,而是等每个线程试验完后再将内部统计的结果一
* 次性存入
*/
sum += seed;
seed = xorShift(seed);//根据种子随机产生下一个将要放入的元素
}
//试验完成后将每个线程的内部检验和再次累计到全局检验和
putSum.getAndAdd(sum);
//等待所有生产-消费线程、还有主线程都完成后才可以往后执行
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
class Consumer implements Runnable {
public void run() {
try {
//等待所有生产-消费线程、还有主线程都准备好后才可以往后执行
barrier.await();
int sum = 0;
for (int i = nTrials; i > 0; --i) {
sum += bb.take();
}
takeSum.getAndAdd(sum);
//等待所有生产-消费线程、还有主线程都完成后才可以往后执行
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
/*
* 测试时尽量不是使用类库中的随机函数,大多数的随机数生成器都是线程安全的,
* 使用它们可能会影响原本的性能测试。在这里我们也不必要使用高先是的随机性。
* 所以使用简单而快的随机算法在这里是必要的。
*/
static int xorShift(int y) {
y ^= (y << 6);
y ^= (y >>> 21);
y ^= (y << 7);
return y;
}
}
资源管理的测试
对于任何持有或管理其他对象的对象,都应该在不需要这些对象时销毁对它们的引用。这种存储资源泄漏不仅会妨碍垃圾回收器回收内存(或者线程、文件句柄、套接字、数据库连接或其他有限资源),而且还会导致资源耗尽以及应用程序失败。
通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易地测试出对内存的不合理占用,许多商用和开源的堆分析工具中都支持这种功能。
产生更多的交替操作
有一种有用的方法可以提高交替操作的数量,以便能有效地搜索程序的状态空间:
在访问共享状态的操作中,使用Thread.yield将产生更多的上下文切换。(这项技术的有效性与具体的平台相关,因为JVM可以将Thread.yield作用一个空操作。如果使用一个睡眠时间较短的sleep,那么虽然慢些,但却更可靠。)
public synchronized void transferCredits(Account from, Account to, int amount) {
from.setBalance(from.getBalance() - amount);
if (random.nextInt(1000) > THRESHOLD) {
Thread.sleep(300);//切换到另一线程
}
to.setBalance(to.getBalance() + amount);
}
性能测试
- 性能测试将衡量典型测试用例中的端到端性能。通常,要获得一组合理地使用场景并不容易,理想情况下,在测试中应该反映被测试对象在应用程序中的实际用法。
- 第二个目标就是根据经验值来调整各种不同的限值,例如线程数量,缓存容量等等,这些限值都依赖于平台特性,我们通常要合理的选择这些值,使程序能够在更多的系统上良好的运行。
计时
多种算法的比较
测试结果表明,LinkedBlockgingQueue的可伸缩性要高于ArrayBlockingQueue, 初看起来这个结果似乎有些奇怪,链表队列在每次插入元素时,都必须分配一个链表节点对象,这似乎比基于数组的队列执行了更多的工作。 然而,虽然它拥有更好的内存分配和GC等开销,单与基于数组的队列相比,链表队列的put和take等方法支持并发性更高的访问, 因为一些优化后的链接队列算法能将队列头节点的更新操作与尾节点的更新操作分离开来。 因此如果算法能通过多执行一些内存分配操作来降低竞争程度,那么这种算法通常具有更高的可伸缩性。
响应性衡量
多缓存:
单元素缓存:
如果缓存过小,那么将导致非常多的上下文切换次数,这即使在非公平模式中也会导致很低的吞吐量,因此在几乎每个操作中都会执行上下文切换。
公平性开销主要是由于线程阻塞造成的,这种情况下公平性并不会使平均完成时间变长,或者使变动性变小。
因此,除非线程由于密集的同步需求而被持续地阻塞,否则非公平的信号量通常能实现更好的吞吐量,而公平的信号量则实现更低的变动性。
避免性能测试的陷阱
垃圾回收
垃圾回收的执行时序是无法预测的,因此在执行测试时,垃圾回收器可能在任何时刻运行。
有两种策略可以防止垃圾回操作对测试结果产生偏差。
- 第一种策略是确保垃圾回收操作在整个运行其间都不会执行(-verbose:gc)来判断是否执行了垃圾回收操作
- 第二种策略是,确保垃圾回收操作在测试期间执行多次,这样测试程序就能充分反映出运行期间的内存分配与垃圾回收等开销。
- 这种更好,要求更长的测试时间,更能反应实际情况下的性能
动态编译
如果一个方法执行的次数足够多,那么动态编译器会将它编译成为机器代码,当编译完成后,代码的执行方式将从解释执行变成直接执行。
这种编译的执行时机是无法预测的。
基于各种原因,代码还可能被退回解释执行以及重新编译。例如加载了一个会使编译假设无效的类,或者在收集够了足够的分析信息后,决定采用不同的优化措施来重新编译某条代码路径。
- 可以使程序运行足够长的时间,这样解释运行只占一小部分
- 可以使代码先运行一段时间并不计算其性能,这样开始计时时代码已经被完全编译了(-xx:+PrintCompilation来观察编译)
对代码路径的不真实采样
编译优化时会才有一些临时有效的优化策略,提高代码执行路径覆盖率可以消除这些优化策略带来的影响。
无用的代码消除
要编写有效的性能测试程序,就需要告诉优化器不要将基准测试当作无用的代码而优化掉。这就要求在程序中对每个计算结果都要通过某种方式来使用,这种方式不需要同步或者大量的计算。
不真实的竞争程度
并发性能测试应该尽量模拟典型应用程序中的线程本地计算量以及并发协调开销。
如果真实应用程序的各个任务中执行的工作,与测试程序中执行的工作截然不同,那么测试出的性能瓶颈位置将是不正确的。
其他的测试方法
- 代码审查
- 静态分析工具
- 面向方面(AOP)的测试技术
- 分析与检测工具
Java内存模型
参考《深入理解Java虚拟机》
什么是内存模型,为什么需要它
在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会还会把变量保存在寄存器而不是内存中;
处理器可以采用乱序或并行的方式来执行指令;缓存可能会改变将写入变量提交到主内存的次序;
而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。
这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行——如果没有使用正确的同步。
Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。
计算的性能提升很大程度上要归功于指令重排。
在多线程环境中,维护程序的串行性将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。
只有当多个线程要共享数据时,才必须协调它们之间的操作,并且JVM依赖程序通过同步操作来找出这些协调操作将在何时发生。
JMM规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对其他线程可见。
平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。 在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证, 即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。操作系统、编译器以及运行时(有时甚至包括应用程序)需要弥补这种在硬件能力与线程安全需求之间的差异。
为了使Java开发人员无须关心不同架构上内存模型之间的差异,Java还提供了自己的内存模型,并且JVM通过在恰当的位置上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。
在现代支持共享内存的多处理器(和编译器)中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这些情况的发生。幸运的是,Java程序不需要指定内存栅栏的位置,而只需通过正确地使用同步来找出何时将访问共享状态。
重排序
在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。 更糟的是,JMM还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序将变得更加复杂。 各种使操作延迟或者看似乱序执行的不同原因,都可以归为重排序。
由于每个线程的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行。 即使这些操作按照顺序执行,但在将缓存刷新到主内存的不同时序中也可能出现这种情况,从线程B的角度看,线程A中的赋值操作可能以相反的次序执行。
内存级的重排序会使程序的行为变得不可预测。如果没有同步,那么推断出执行顺序将是非常困难的,而要确保在程序中正确地使用同步却是非常容易的。 同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。
Java内存模型简介
Java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放锁操作,以及线程的启动和合并操作。
JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。
如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。 在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。
Happens-Before的规则包括:
参考《深入理解Java虚拟机 先行发生原则》部分
- 程序顺序规则
- 如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。
- 监视器锁规则
- 在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。(显式锁和内置锁在加锁和解锁等操作上有着相同的内存语义)
- volatile变量规则
- 对volatile变量的写入操作必须在对该变量的读操作之前执行。(原子变量与volatile变量在读操作和写操作上有着相同的语义)
- 线程启动规则
- 在线程上对Thread.start的调用必须在该线程中执行任何操作之前执行。
- 线程结束规则
- 线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false。
- 中断规则
- 当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted)。
- 终结器规则
- 对象的构造函数必须在启动该对象的终结器之前执行完成。
- 传递性
- 如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
在Java内存模型中说明Happens-Before关系:
在线程A内部的所有操作都按照它们在源程序中的先后顺序来排序,在线程B内部的操作也是如此。由于A释放了锁M,并且B随后获得了锁M,因此A中所有在时候放锁之前的操作,也就位于B中请求锁之后的所有操作之前。 如果这两个线程是在不同的锁上进行同步的,那么就不能推断它们之间的动作顺序,因为在这两个线程的操作之间并不存在Happens-Before关系。
借助同步
由于Happens-Before的排序功能很强大,因此有时候可以”借助(Piggyback)“现有同步机制的可见性属性。
这需要将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则或者volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。 这项技术由于对语句的顺序非常敏感,因此很容易出错。它是一项高级技术,并且只有当需要最大限度地提升某些类(例如ReentrantLock)的性能时,才应该使用这项技术。
在FutureTask的保护方法AbstractQueuedSynchronizer中说明了如何使用这种”借助“技术。 AQS维护了一个表示同步器状态的整数,FutureTask用这个整数来保存任务的状态:正在运行,已完成和已取消。但FutureTask还维护了其他一些变量,例如计算的结果。 当一个线程调用set来保存结果并且另一个线程调用get来获取该结果时,这两个线程最好按照Happens-Before进行排序。 这可以通过将执行结果的引用声明为volatile类型来实现,但利用现有的同步机制可以更容易地实现相同的功能。
FutureTask在设计时能够确保,在调用tryAcquireShared之前总能成功地调用tryReleaseShared。 tryReleaseShared会写入一个volatile类型的变量,而tryAcquireShared将读取这个变量。
下面程序清单给出了innerSet和innerGet等方法,在保存和获取result时将调用这些方法。 由于innerSet将在调用releaseShared(这又将调用tryReleaseShared)之前写入result,并且innerGet将在调用acquireShared(这又将调用tryReleaseShared)之后读取result, 因此将程序顺序规则与volatile变量规则结合在一起,就可以确保innerSet中的写入操作在innerGet中的读取操作之前。
private final class Sync extends AbstractQueuedSynchronizer {
/** State value representing that task is running */
private static final int RUNNING = 1;
/** State value representing that task ran */
private static final int RAN = 2;
/** State value representing that task was cancelled */
private static final int CANCELLED = 4;
/** The result to return from get() */
private V result;
/** The exception to throw from get() */
private Throwable exception;
void innerSet(V v) {
for (;;) {
int s = getState();
if (s == RAN){
return;
}
if (s == CANCELLED) {
// aggressively release to set runner to null,
// in case we are racing with a cancel request
// that will try to interrupt runner
releaseShared(0);
return;
}
if (compareAndSetState(s, RAN)) {
result = v;
releaseShared(0);
done();
return;
}
}
}
V innerGet() throws InterruptedException, ExecutionException {
acquireSharedInterruptibly(0);
if (getState() == CANCELLED){
throw new CancellationException();
}
if (exception != null){
throw new ExecutionException(exception);
}
return result;
}
}
之所以将这项技术称为“借助”,是因为它使用了一种现有的Happens-Before顺序来确保对象X的可见性,而不是专门为了发布X而创建一种Happens-Before顺序。
在FutureTask中使用的“借助”技术很容易出错,因此要谨慎使用。但在某些情况下,这种“借助”技术是非常合理的。
例如,当某个类在其规范中规定它的各个方法之间必须遵守一种Happens-Before关系,基于BlockingQueue实现的安全发布就是一种“借助”。
如果一个线程将对象置入队列并且另一个线程随后获取这个对象,那么这就是一种安全发布,因为在BlockingQueue的实现中包含有足够的内部同步来确保入列操作在出列操作之前执行。
在类库中提供的其他Happens-Before排序包括:
- 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。
- 在CountDownLatch上的倒数操作将在线程从闭锁上的await方法返回之前执行。
- 释放Semaphore许可的操作将在从该Semaphore上获得一个许可之前执行。
- Future表示的任务的所有操作将在从Future.get中返回之前执行。
- 向Executor提交一个Runnable或Callable的操作将在任务开始执行之前执行。
- 一个线程到达CyclicBarrier或Exchange的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。
发布
参考《安全发布》部分
以上概念总结于传智播客Java基础课程、Java并发编程实战