Java核心卷Ⅰ(原书第10版)笔记(下)
标签(空格分隔):Java核心卷 java
Java核心卷Ⅰ(原书第10版)笔记(上)
Java核心卷Ⅰ(原书第10版)笔记(中)
文章目录
第10章 图形程序设计(略)
第11章 事件处理(略)
第12章 Swing用户界面组件(略)
第13章 部署Java应用程序(略)
第14章 并发
- 操作系统中的多任务(multitasking): 在同一刻运行多个程序的能力。
- 多线程程序在较低的层次上扩展了多任务的概念:一个程序同时执行多个任务。
- 每一个任务称为一个线程(thread), 它是线程控制的简称。
- 可以同时运行一个以上线程的程序称为多线程程序(multithreaded)。
- 多进程与多线程有哪些区别呢?
- 每个进程拥有自己的一整套变量,而线程则共享数据。
- 在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。
- 更多内容《JAVA并发编程实践(Java Concurrency in Practice)》
14.1 创建线程
- 这种方法已不再推荐:可以通过构建一个
Thread
类的子类定义一个线程。而后构建此对象,并调用start
方法。应该将要并行运行的任务与运行机制解耦合。如果有很多任务,要为每个任务创建一个独立的线程所付出的代价太大了。可以使用线程池来解决这个问题,有关内容请参看第 14.9 节。 - 以下代码为推荐方法:
// 用 匿名类 或 lambda 实现 Runnable 接口,其中只有一个 run 方法需要实现。
Runnable r = () -> {
System.out.println("run run run ");
};
// 创建 Thread 对象放入 Runnable 对象。
Thread t = new Thread(r);
t.start();
- 不要调用
Thread
类或Runnable
对象的run
方法。 直接调用 run 方法,只会执行同一个线程中的任务, 而不会启动新线程。 应该调用 Thread.start 方法。这个方法将创建一个执行 run 方法的新线程。
14.2 中断线程
如果在每次工作迭代之后都调用 sleep
方法(或者其他的可中断方法), isInterrupted
检测既没有必要也没有用处。 如果在中断状态被置位时调用 sleep
方法,它不会休眠。 相反,它将清除这一状态(isInterrupted
变为 false)并拋出IntemiptedException
。因此,如果你的循环调用 sleep, 不会检测中断状态。
- 有两个非常类似的方法,
interrupted
(不是interrupt
) 和isInterrupted
。Interrupted
方法是一个静态方法, 它检测当前的线程是否被中断。 而且,调用interrupted
方法会清除该线程的中断状态。isInterrupted
方法是一个实例方法,可用来检验是否有线程被中断。
Runnable runnable = () -> {
System.out.println(Thread.currentThread().isInterrupted()); // false
System.out.println("run run run ");
Thread.currentThread().interrupt();
System.out.println(Thread.currentThread().isInterrupted()); // true
System.out.println(Thread.interrupted()); // true
System.out.println(Thread.currentThread().isInterrupted()); // false
};
不要这样做!
在很多发布的代码中会发现InterruptedException
异常被抑制在很低的层次上。如:
void mySubTask()
{
. . .
try { sleep(delay); }
catch (InterruptedException e) {} // Don't ignore!
. . .
}
有两种合理的选择:
1. 在 catch 中调用 Thread.currentThread().interrupt(); 来设置中断状态。调用者可以对其进行检测。
2. 用 throws InterruptedException 来标记方法,不采用 try 语句块捕获异常。让调用者可以捕获这一异常。
14.3 线程的状态
要确定一个线程的当前状态, 可调用
getState
方法。
- New(新建)
- Runnable(可运行)
- Blocked(被阻塞)
- Waiting(等待)
- Timed waiting(计时等待)
- Terminated(被终止)
新创建线程
当用 new 操作符创建一个新线程时,如
new Thread(r)
,该线程还没有开始运行(程序还没有开始运行线程中的代码)。这意味着它的状态是 new。
可运行线程
一旦调用
start
方法,线程处于runnable
状态。一个可运行的线桿可能正在运行也可能没有运行, 这取决于操作系统给线程提供运行的时间(这就是为什么将这个状态称为可运行而不是运行)。
被阻塞线程和等待线程
当线程处于被阻塞或等待状态时,它暂时不活动。它不运行任何代码且消耗最少的资源。直到线程调度器重新激活它。 细节取决于它是怎样达到非活动状态的。
-
当一个线程试图获取一个内部的对象锁,锁被其他线程持有,进入阻塞状态。其他线程释放锁,锁被本线程持有,变为非阻塞状态。
- 当一个线程试图获取一个内部的对象锁(而不是
java.uti.concurrent
库中的锁),而该锁被其他线程持有,则该线程进入阻塞状态(我们在 14.5.3 节讨论 java.util.concurrent 锁,在 14.5.5 节讨论内部对象锁)。 当所有其他线程释放该锁,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。
- 当一个线程试图获取一个内部的对象锁(而不是
-
当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态(等待被唤醒或其他操作)。
- 当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。我们在第 14.5.4 节来讨论条件。 在调用
Object.wait
方法或Thread.join
方法, 或者是等待java.util.concurrent
库中的Lock
或Condition
时, 就会出现这种情况。实际上,被阻塞状态与等待状态是有很大不同的。
- 当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。我们在第 14.5.4 节来讨论条件。 在调用
-
有几个方法有一个超时参数。调用它们导致线程进入计时等待(timed waiting) 状态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有
Thread.sleep
和Object.wait
、Thread.join
、Lock.tryLock
以及Condition.await
的计时版。
当一个线程被阻塞或等待时(或终止时),另一个线程被调度为运行状态。当一个线程被重新激活(例如, 因为超时期满或成功地获得了一个锁),调度器检查它是否具有比当前运行线程更高的优先级。如果是这样,调度器从当前运行线程中挑选一个,剥夺其运行权,选择一个新的线程运行。
个人理解: 当一个队员(线程)跑不动了(被阻塞、等待、终止),就抓一个能跑替补的上去(可运行转为运行)。当比较厉害的队员恢复体力了(被重新激活),抓一个场上最菜的(优先级)替换掉。这样就能保证场地都有人在跑(充分利用资源/性能)。
被终止的线程
线程因如下两个原因之一而被终止:
1. 因为run
方法正常退出而自然死亡。
2. 因为一个没有捕获的异常终止了run
方法而意外死亡。
stop
方法已过时,不要在自己的代码中调用这个方法。 调用线程的stop
方法杀死一个线程,该方法抛出ThreadDeath
错误对象,由此杀死线程。- stop、suspend、resume 均已过时
14.4 线程属性
14.4.1 线程优先级
在 Java 程序设计语言中,一个线程有一个优先级。默认情况下,一个线程继承它的父线程的优先级。可以用 setPriority 方法提高或降低任何一个线程的优先级。可以将优先级设置为在 MIN_PRIORITY (在 Thread 类中定义为 1 ) 与 MAX_PRIORITY (定义为 10 ) 之间的任何值。NORM_PRIORITY 被定义为 5。
- **线程优先级是高度依赖于系统的。**不要将程序构建为功能的正确性依赖于优先级。
- 当虚拟机依赖于宿主机平台的线程实现机制时, Java 线程的优先级被映射到宿主机平台的优先级上, 优先级个数也许更多,也许更少(例如,Windows 有 7 个优先级别。一些 Java 优先级将映射到相同的操作系统优先级。在 Oracle 为 Linux 提供的 Java 虚拟机中, 线程的优先级被忽略 (所有线程具有相同的优先级))。
14.4.2 守护线程
可以通过调用
t.setDaemon(true);
将线程转换为守护线程(daemon thread)。
- 守护线程的唯一用途是为其他线程提供服务。当只剩下守护线程时,虚拟机就退出了。
- 守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。
14.4.3 未捕获异常处理器
线程的
run
方法不能抛出任何受查异常,但是,非受査异常会导致线程终止。在这种情况下,线程就死亡了。不需要用任何 catch 子句来处理可以被传播的异常。在线程死亡之前,异常已经被传递到一个用于未捕获异常的处理器了。
-
该处理器必须属于一个实现
Thread.UncaughtExceptionHandler
接口的类。这个接口只有—个方法:void uncaughtException(Thread t, Throwable e)
-
可以用
setUncaughtExceptionHandler
方法为任何线程安装一个处理器。也可以用Thread
类的静态方法setDefaultUncaughtExceptionHandler
为所有线程安装一个默认的处理器。替换处理器可以使用日志 API 发送未捕获异常的报告到日志文件。 -
如果不安装默认的处理器,默认的处理器为空。但是,如果不为独立的线程安装处理器,此时的处理器就是该线程的
ThreadGroup
对象。
线程组(ThreadGroup)是一个可以统一管理的线程集合。默认情况下,创建的所有线程属于相同的线程组,但是,也可能会建立其他的组。现在引入了更好的特性用于线程集合的操作,所以建议不要在自己的程序中使用线程组。
ThreadGroup
类实现Thread.UncaughtExceptionHandler
接口。 它的uncaughtException
方法做如下操作:
- 如果该线程组有父线程组,那么父线程组的
uncaughtException
方法被调用。 - 否则,如果
Thread.getDefaultExceptionHandler
方法返回一个非空的处理器, 则调用该处理器。 - 否则,如果
Throwable
是ThreadDeath
的一个实例,什么都不做(ThreadDeath
对象由stop
方法产生, 而该方法已经过时)。 - 否则,线程的名字以及
Throwable
的栈轨迹被输出到System.err
上。
14.5 同步
竞争条件(race condition):两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,很可能会产生讹误的对象。
14.5.3 锁对象
有两种机制防止代码块受并发访问的干扰。Java语言提供一个
synchronized
关键字达到这一目的,并且 Java SE 5.0 引入了java.util.concurrent.locks.ReentrantLock
类。
// 用 ReentrantLock 保护代码块的基本结构如下:
myLock.lock(); // myLock 是一个 ReentrantLock 对象
try {
. . .
} finally {
myLock.unlock(); // 确保锁被释放,即便发生异常情况
}
-
如果使用锁,就不能使用带资源的
try
语句。 首先,解锁方法名不是close。不过,即使将它重命名,带资源的 try 语句也无法正常工作。它的首部希望声明一个新变量。但是如果使用一个锁,你可能想使用多个线程共享的那个变量(而不是新变量)。 -
锁是可重入的,因为线程可以重复地获得已经持有的锁。 锁保持一个持有计数(hold count) 来跟踪对 lock 方法的嵌套调用。线程在每一次调用
lock
都要调用unlock
来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。 -
要留心临界区中的代码,不要因为异常的抛出而跳出临界区。 如果在临界区代码结束之前抛出了异常,
finally
子句将释放锁,但会使对象可能处于一种受损状态。- 本书并没有对“临界区”的解释,从上下文来看,应该是指持有锁的代码块。
-
**公平锁:
ReentrantLock(boo1ean fair)
:**构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能。默认情况下,锁没有被强制为公平的。
听起来公平锁更合理一些,但是使用公平锁比使用常规锁要慢很多。只有当你确实了解自己要做什么并且对于你要解决的问题有一个特定的理由必须使用公平锁的时候,才可以使用公平锁。 即使使用公平锁, 也无法确保线程调度器是公平的。如果线程调度器选择忽略一个线程,而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了。
14.5.4 条件对象
通常 线程进入临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。由于历史的原因,条件对象经常被称为条件变量(conditional variable)。
-
死锁(deadlock): 如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁(deadlock) 现象。
-
程序挂起: 如果所有其他线程被阻塞,最后一个活动线程在解除其他线程的阻塞状态之前就调用
await
方法,那么它也被阻塞。没有任何线程可以解除其他线程的阻塞,那么该程序就挂起了。
class Bank{
// 一个锁对象可以有一个或多个相关的条件对象。例如,在此设置一个条件对象来表达“余额充足” 条件:
private Condition sufficientFunds;
. . .
public Bank() {
. . .
sufficientFunds = bankLock.newCondition();
}
}
// 如果 transfer 方法发现余额不足,它调用以下方法放弃锁并阻塞当前线程。
sufficientFunds.await();
// 当另一个线程转账时, 它应该调用以下方法,重新激活因为这一条件而等待的所有线程。
sufficientFunds.signalAll();
- 等待获得锁的线程和调用
await
方法的线程存在本质上的不同。一旦一个线程调用await
方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll
方法时为止。
个人理解: 一但调用条件对象(sufficientFunds)的
await
方法,线程将会阻塞,释放当前线程锁(bankLock)并进入该条件等待集。其他线程获得这个锁(bankLock)继续做其他事。就算 当前锁(bankLock) 是空闲的也跟当前线程无关,当前线程不会解除阻塞,直到另外一个线程用同一个条件(sufficientFunds)上的signalAll
方法为止。
- 当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进入该对象。一旦锁(bankLock)成为可用的,它们中的某个将从
await
调用返回,获得该锁并从被阻塞的地方继续执行。此时,线程应该再次测试该条件(余额够不够)。由于无法确保该条件被满足(signalAll
方法仅仅是通知正在等待的线程:此时有可能已经满足条件, 值得再次去检测该条件)。- 通常,对
await
的调用应该在如下形式的循环体中:
while ( !(ok to proceed )) condition.await();
- 应该何时调用
signalAll
呢?验上讲,在对象的状态有利于等待线程的方向改变时调用signalAll
。例如,当一个账户余额发生改变时,等待的线程会应该有机会检查余额。在下列例子中,当完成了转账时,调用 signalAll 方法。
public void transfer(int from, int to, int amount){
bankLock.lock();
try {
while (accounts[from] < amount)
sufficientFunds.await();
// 转账操作。。。
. . .
sufficientFunds.signalAll(); // 注意调用 signalAll 不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后, 通过竞争实现对对象的访问。
}
finally {
bankLock.unlock();
}
}
-
当一个线程拥有某个条件的锁时, 它仅仅可以在该条件上调用
await
、signalAll
或signal
方法。signal
, 是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的阻塞更加有效,但也存在危险。 如果随机选择的线程发现自己仍然不能运行, 那么它再次被阻塞。如果没有其他线程再次调用signal
, 那么系统就死锁了。
-
锁(Lock)和条件(Condition)的关键之处:
- 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
- 锁可以管理试图进入被保护代码段的线程。
- 锁可以拥有一个或多个相关的条件对象。
- 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
14.5.5 synchronized关键字
从 1.0 版开始,Java 中的每一个对象都有一个内部锁,这是一种嵌入到 Java 语言内部的机制。如果一个方法用 synchronized
关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。如下代码所示:
public synchronized void method(){
// method body
}
// 等价于:
public void method(){
this.intrinsicLock.lock();
try {
// method body
} finally {
this.intrinsicLock.unlock();
}
}
内部对象锁 只有一个相关条件。wait
方法添加一个线程到等待集中,notifyAll / notify
方法解除等待线程的阻塞状态。换句话说,调用 wait
或 notityAll
等价于
intrinsicCondition.await();
intrinsicCondition.signalAIT();
wait
、notifyAll
以及notify
方法是Object
类的final
方法。Condition
方法必须被命名为await
、signalAll
和signal
以便它们不会与那些方法发生冲突。
// synchronized 实现 transfer 方法:
public synchronized void transfer(int from,int to, int amount) throws InterruptedException {
while (accounts[from] < amount)
wait(); // 等待内部对象锁的单一条件
accounts[from] -= amount ;
accounts[to] += amount;
notifyAll(); // 通知所有等待该条件的线程(恢复条件与 signalAll 相同)
}
**将静态方法声明为 synchronized
也是合法的。**如果调用这种方法,该方法获得相关的类对象的内部锁。例如, 如果 Bank 类有一个静态同步的方法,那么当该方法被调用时, Bank.class
对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
-
内部锁和条件存在一些局限。包括:
- 不能中断一个正在试图获得锁的线程。
- 试图获得锁时不能设定超时。
- 每个锁仅有单一的条件,可能是不够的。
-
Lock
和Condition
对象还是同步方法
?下面是一些建议:- 最好既不使用
Lock/Condition
也不使用synchronized
关键字。在许多情况下你可以使用java.util.concurrent
包中的一种机制,它会为你处理所有的加锁。 - 如果
synchronized
关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。 - 如果特别需要
Lock/Condition
结构提供的独有特性时,才使用Lock/Condition
。
- 最好既不使用
14.5.6 同步阻塞
每一个 Java 对象有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。有时程序员使用一个对象的锁来实现额外的原子操作,实际上称为客户端锁定(clientside locking)。例如,考虑
Vector
类,一个列表, 它的方法是同步的:
public void transfer(Vector<Double> accounts, int from, int to, int amount){
// 如果不加锁,虽然 get 和 set 方法是同步的,但一个线程完全可能在调用 get 后,在 transfer 方法中被剥夺运行权。
synchronized (accounts) {
accounts.set(fron, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
}
Systen.out.println(. . .);
}
客户端锁定是非常脆弱的,通常不推荐使用。 上述代码中的方法可以工作,但是它完全依赖于这样一个事实,Vector
类对自己的所有可修改方法都使用内部锁。然而,Vector
类的文档并没有给出这样的承诺,所以即使开发人员获取了对象的锁,可能Vector
类的某个修改方法不需要内部锁(非同步方法)。这使得开发人员不得不仔细研究源代码并希望将来的版本能介绍非同步的可修改方法。
14.5.7 监视器概念
锁和条件是线程同步的强大工具,但是,严格地讲,它们不是面向对象的。多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。最成功的解决方案之一是监视器(monitor), 这一概念最早是由 Per Brinch Hansen 和 Tony Hoare 在 20 世纪 70 年代提出的。
-
监视器具有如下特性:
- 监视器是只包含私有域的类。
- 每个监视器类的对象有一个相关的锁。
- 使用该锁对所有的方法进行加锁。对象的锁是在方法调用开始时自动获得,并且当方法返回时自动释放该锁。
- 该锁可以有任意多个相关条件。
-
在下述的 3 个方面 Java 对象不同于监视器,从而使得线程的安全性下降:
- 域不要求必须是 private。
- 方法不要求必须是 synchronized。
- 内部锁对客户是可用的。
14.5.8 Volatile 域
Brian Goetz 给出了下述 “同步格言”:“如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取,或者,从一个变量读值,而这个变量可能是之前被另一个线程写入的,此时必须使用同步”。
使用现代的处理器与编译器,读写实例域(多线程中,不使用同步读写)出错的可能性很大。原因如下:
1. 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
2. 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变!
如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。编译器被要求通过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当地重新排序指令。
volatile
关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为 volatile
,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
// 例如,假定一个对象有一个布尔标记 done,它的值被一个线程设置却被另一个线程査询,如同我们讨论过的那样,你可以使用锁:
private boolean done;
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }
/**
* 或许使用内部锁不是个好主意。如果另一个线程已经对该对象加锁, isDone 和 setDone 方法可能阻塞。
* 如果注意到这个方面,一个线程可以为这一变量使用独立的 Lock。但是,这也会带来许多麻烦。
* 在这种情况下,将域声明为 volatile 是合理的:
*/
private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }
// Volatile 变量不能提供原子性。例如,方法:
// 不能确保翻转域中的值。不能保证读取、 翻转和写入不被中断。
public void flipDone() { done = !done; } // not atomic
14.5.9 final 变置
除了 volatile
还一种情况可以安全地访问一个共享域,即这个域声明为 final
时。如:
final Map<String, Double> accounts = new HashMap<>();
其他线程会在构造函数完成构造之后才看到这个 accounts
变量。如果不使用final
,就不能保证其他线程看到的是 accounts
更新后的值,它们可能都只是看到 null
, 而不是新构造的 HashMap
。
当然,对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要进行同步。
14.5.10 原子性
假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为 volatile。
java.util.concurrent.atomic
包中有很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。 例如, Atomiclnteger
类提供了方法 incrementAndGet
和 decrementAndGet
, 它们分别以原子方式将一个整数自增或自减。例如,可以安全地生成一个数值序列,如下所示:
public static AtomicLong nextNumber = new AtomicLong();
// In some thread...
// 以原子方式自增,获得值、增 1 并设置然后生成新值的操作不会中断,保证多线程并发访问时也能正常计算。
long id = nextNumber.increinentAndGet();
/**
* 有很多方法可以以原子方式设置和增减值,不过,如果希望完成更复杂的更新,就必须使用 `compareAndSet` 方法。
* 例如,假设希望跟踪不同线程观察的最大值。下面的代码是不可行的:
*/
public static AtomicLong largest = new AtomicLong();
// In some thread...
largest.set(Math ,max(largest,get(), observed)); // Error race condition! 这个更新不是原子的。
/**
* 实际上,应当在一个循环中计算新值和使用 compareAndSet:
* 如果另一个线程也在更新 largest,就可能阻止这个线程更新。这样一来,compareAndSet 会返回 false, 而不会设置新值。在这种情况下,循环会更次尝试,读取更新后的值,并尝试修改。最终,它会成功地用新值替换原来的值。这听上去有些麻烦, 不过 compareAndSet 方法会映射到一个处理器操作,比使用锁速度更快。
*/
do {
oldValue = largest.get();
newValue = Math.max(oldValue , observed);
} while (!largest.compareAndSet(oldValue, newValue));
在 Java SE 8 中,不再需要编写上述这样的循环样板代码。可以提供一个 lambda 表达式更新变量,它会为你完成更新。例如:
largest.updateAndGet(x -> Math.max(x, observed));
// 或者
largest.accumulateAndCet(observed , Math::max); // 此方法利用一个二元操作符来合并原子值和所提供的参数。
// 还有 getAndUpdate 和 getAndAccumulate 方法可以返回原值。
// PS: Atomic* 开头的类都提供了这些方法
LongAdder
、LongAccumulator
、DoubleAdder
、DoubleAccumulator
:- 如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新(先修改本地数据再更新数据,如果更新失败则回退)需要太多次重试。 Java SE 8 提供了
LongAdder
和LongAccumulator
类来解决这个问题。LongAdder
包括多个变量(加数),其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下,只有当所有工作都完成之后才需要总和的值,对于这种情况,这种方法会很高效。性能会有显著的提升。 - 如果认为可能存在大量竞争,只需要使用
LongAdder
而不是AtomicLong
。方法名稍有区别。调用increment
让计数器自增,或者调用add
来增加一个量,或者调用sum
来获取总和。
- 如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新(先修改本地数据再更新数据,如果更新失败则回退)需要太多次重试。 Java SE 8 提供了
final LongAdder adder = new LongAdder();
for (. . .){
pool.submit(() -> {
while (. . .) {
. . .
if (. . .){
// increment 方法不会返回原值。这样做会消除将求和分解到多个加数所带来的性能提升。
adder.increment();
}
}
});
}
. . .
long total = adder.sum();
LongAccumulator
将这种思想推广到任意的累加操作。
LongAccumulator adder = new LongAccumulator(Long::sum, 0);
// In some thread...
adder.accumulate(value);
在内部,这个累加器包含变量 a1, a2,…,an。每个变量会被初始化为0(new LongAccumulator
传入的是0),调用accumulate(value)
方法时,其中一个变量会以原子方式更新 ai = ai op value
,因为new LongAccumulator
时传入的是Long::sum
,所以上面例子是累加操作,则get
的结果是a1 op a2 op … op an
,在上述例子中,就是a1 + a2 + … + an
。
14.5.11 死锁
死锁和挂起的概念在“14.5.4 条件对象” 有提到。
14.5.12 线程局部变量
有时可能要避免共享变量,使用 ThreadLocal
辅助类为各个线程提供各自的实例。
// 例如,`SimpleDateFormat` 类不是线程安全的。假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
// 如果两个线程都执行以下操作:
String dateStamp = dateFormat.format(new Date());
// 结果可能很混乱,因为 dateFormat 使用的内部数据结构可能会被并发的访问所破坏。当然可以使用同步,但开销很大; 或者也可以在需要时构造一个局部 SimpleDateFormat 对象,不过这也太浪费了。
// 要为每个线程构造一个实例,可以使用以下代码:
public static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 要访问具体的格式化方法,可以调用:
String dateStamp = dateFormat.get().format(new Date());
// 在一个给定线程中首次调用 get 时, 会调用 initialValue 方法。在此之后, get 方法会返回属于当前线程的那个实例。
在多个线程中生成随机数也存在类似的问题。虽然 java.util.Random
类是线程安全的,但如果多个线程需要等待一个共享的随机数生成器,这会很低效。此时可以使用 ThreadLocal
辅助类为各个线程提供一个单独的生成器。不过 Java SE 7 还另外提供了一个便利类。只需要做以下调用:
// ThreadLocalRandom.current() 调用会返回特定于当前线程的 Random 类实例。
int random = ThreadLocalRandom.current().nextInt(upperBound);
14.5.13 锁测试与超时
- 线程在调用
lock
方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。- 因为
lock
方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁, 那么,lock
方法就无法终止。
- 因为
- 而
tryLock
方法试图申请一个锁, 在成功获得锁后返回true
, 否则, 立即返回false
, 而且线程可以立即离开去做其他事情。如下方代码所示:- 如果调用带有用超时参数的
tryLock
, 那么如果线程在等待期间被中断,将抛出InterruptedException
异常。这是一个非常有用的特性,因为允许程序打破死锁。 locklnterruptibly
方法相当于一个超时设为无限的tryLock
方法。
- 如果调用带有用超时参数的
/**
* 也可以调用 tryLock 时,使用超时参数,if (myLock.tryLock(100, TineUnit.MILLISECONDS))
* TimeUnit 是一个枚举类,其值有:SECONDS、MILLISECONDS, MICROSECONDS 和 NANOSECONDS。
*/
if (myLock.tryLock()) {
// now the thread owns the lock
try { . . . }
finally { myLock.unlock(); }
} else {
// do something else
}
- 在等待一个条件时,也可以提供一个超时,如:
myCondition.await(100, TineUniBILLISECONDS)
。- 如果一个线程被另一个线程通过调用
signalAll
或signal
激活,或者超时时限已达到,或者线程被中断, 那么await
方法将返回。 - 如果等待的线程被中断,
await
方法将抛出一个InterruptedException
异常。在你希望出现这种情况时线程继续等待(可能不太合理),可以使用awaitUninterruptibly
方法代替await
。
- 如果一个线程被另一个线程通过调用
14.5.14 读/写锁
java.util.concurrent.locks
包定义了两个锁类ReentrantLock
类和ReentrantReadWriteLock
类。 如果很多线程从一个数据结构读取数据而很少线程修改其中数据,ReentrantReadWriteLock
类是十分有用的。 在这种情况下,允许对读者线程共享访问是合适的。当然,写者线程依然必须是互斥访问的。下面是使用读/写锁的必要步骤:
// 1. 构造一个 ReentrantReadWriteLock 对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock():
// 2. 抽取读锁和写锁:
private Lock readLock = rwl.readLock() ;
private Lock writeLock = rwl.writeLock();
// 3. 对所有的获取方法加读锁:
public double getTotalBalance() {
readLock.lock();
try { . . . }
finally { readLock.unlock(); }
}
// 4. 对所有的修改方法加写锁:
public void transfer(. . .) {
writeLock.lock();
try { . . . }
finally { writeLock.unlock(); }
}
14.5.15 为什么弃用 stop 和 suspend 方法
初始的 Java 版本定义了一个
stop
方法用来终止一个线程, 以及一个suspend
方法用来阻塞一个线程直至另一个线程调用resume
。stop
和suspend
方法有一些共同点:都试图控制一个给定线程的行为。
stop
、suspend
和resume
方法已经弃用。
- stop
- 该方法终止所有未结束的方法, 包括
run
方法。当线程被终止,立即释放被它锁住的所有对象的锁。这会导致对象处于不一致的状态。(例如转账操作,先把钱扣了,再转入另一个账户,扣完钱的时候被 stop 就尴尬了) - 在希望停止线程的时候应该中断(interrupt)线程,被中断的线程会在安全的时候停止。(详情 14.2)
- 该方法终止所有未结束的方法, 包括
- suspend
- 如果用
suspend
挂起一个持有一个锁的线程,该锁在恢复之前是不可用的。如果调用suspend
方法的线程试图获得同一个锁, 那么程序将死锁。
- 如果用
14.6 阻塞队列
对于许多线程问题,可以通过使用一个或多个队列以优雅且安全的方式将其形式化。在协调多个线程之间的合作时,阻塞队列( blocking queue ) 是一个有用的工具。工作者线程可以周期性地将中间结果存储在阻塞队列中。其他的工作者线程移出中间结果并进一步加以修改。队列会自动地平衡负载。如果第一个线程集运行得比第二个慢, 第二个线程集在等待结果时会阻塞。 如果第一个线程集运行得快,它将等待第二个队列集赶上来。
- 阻塞队列方法:
- 分为以下三类,取决于当队列满或空时它们的响应方式。
- 如果将队列当作线程管理工具来使用,将要用到
put
和take
方法。 - 当试图向满的队列中添加或从空的队列中移出元素时,
add
、remove
和element
操作抛出异常。 - 在一个多线程程序中,队列会在任何时候空或满,此时要使用
offer
、poll
和peek
方法作为替代。
poll
和peek
方法返回空来指示失败。因此,向这些队列中插入null
值是非法的。offer
方法和poll
方法还有带有超时的变体。如:q.offer(x, 100, TimeUnit.MILLISECONDS);
方法 | 正常动作 | 特殊情况下的动作 |
---|---|---|
add | 添加一个元素 | 如果队列满,则抛出 IllegalStateException 异常 |
put | 添加一个元素 | 如果队列满,则阻塞 |
offer | 添加一个元素并返回 true | 如果队列满,则返回 false |
remove | 移出并返回头元素 | 如果队列空,则抛出 NoSuchElementException 异常 |
take | 移出并返回头元素 | 如果队列空,则阻塞 |
poll | 移出并返回队列的头元素 | 如果队列空,则返回 null |
element | 返回队列的头元素 | 如果队列空,则抛出 NoSuchElementException 异常 |
peek | 返回队列的头元素 | 如果队列空,则返回 null |
java.util.concurrent
包提供了阻塞队列的几个变种。LinkedBlockingQueue
:的容量是没有上边界的,但是,也可以选择指定最大容量。LinkedBlockingDeque
:是一个双端的版本(双端队列)。ArrayBlockingQueue
:在构造时需要指定容量,并且有一个可选的参数来指定是否需要公平性。若设置了公平参数,则那么等待了最长时间的线程会优先得到处理。通常,公平性会降低性能,只有在确实非常需要时才使用它。PriorityBlockingQueue
:是一个带优先级的队列,而不是先进先出队列。元素按照它们的优先级顺序被移出。该队列是没有容量上限,但是,如果队列是空的,取元素的操作会阻塞。DelayQueue
:包含实现Delayed
接口的对象,其中有一个getDelay
方法返回对象的残留延迟。 负值表示延迟已经结束。元素只有在延迟用完的情况下才能从DelayQueue
移除。还必须实现compareTo
方法。DelayQueue
使用该方法对元素进行排序。LinkedTransferQueue
:JavaSE 7增加了一个TransferQueue
接口,允许生产者线程等待,直到消费者准备就绪可以接收一个元素。如果生产者调用:
q.transfer(item);
这个调用会阻塞,直到另一个线程将元素(item) 删除。LinkedTransferQueue
类实现了这个接口。
14.7 线程安全的集合
14.7.1 高效的映射、集和队列
java.util.concurrent
包提供了映射、有序集和队列的高效实现:ConcurrentHashMap
、ConcurrentSkipListMap
、ConcurrentSkipListSet
和ConcurrentLinkedQueue
。
-
size
这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化。与大多数集合不同,size
方法不必在常量时间内操作。确定这样的集合当前的大小通常需要遍历。有些应用使用庞大的并发散列映射,这些映射太过庞大, 以至于无法用size
方法得到它的大小, 因为这个方法只能返回 int。对于一个包含超过 20 亿条目的映射该如何处理? JavaSE 8 引入了一个mappingCount
方法可以把大小作为long
返回。 -
迭代器
集合返回弱一致性(weakly consistent) 的迭代器(所有的集合,不只是本小节的四个集合)。这意味着迭代器不一定能反映出它们被构造之后的所有的修改(构造中可能被修改),但是,它们不会将同一个值返回两次,也不会拋出ConcurrentModificationException
异常。与之相对,集合如果在迭代器构造之后发生改变,java.util
包中的迭代器将抛出一个ConcurrentModificationException
异常。
- 并发的散列映射表,可高效地支持大量的读者和一定数量的写者。默认情况下,假定可以有多达 16 个写者线程同时执行。可以有更多的写者线程,但是,如果同一时间多于 16 个,其他线程将暂时被阻塞。可以指定更大数目的构造器,然而,恐怕没有这种必要。
- 散列映射将有相同散列码的所有条目放在同一个“桶”中。有些应用使用的散列函数不当,以至于所有条目最后都放在很少的桶中,这会严重降低性能。即使是一般意义上还算合理的散列函数, 如
String
类的散列函数, 也可能存在问题。例如, 攻击者可能会制造大量有相同散列值的字符串, 让程序速度减慢。在 JavaSE 8 中,并发散列映射将桶组织为树
, 而不是列表
, 键类型实现了Comparable
, 从而可以保证性能为O(log(n))
。
14.7.2 映射条目的原子更新
ConcurrentHashMap
原来的版本只有为数不多的方法可以实现原子更新。会导致一些问题,比如下面的代码就不是线程安全的:
Long oldValue = map.get(word);
Long newValue = oldValue == null ? 1: oldValue + 1;
map.put(word, newValue); // 可能会有另一个线程在同时更新同一个计数。
如果多个线程修改一个普通的 HashMap
,它们会破坏内部结构 (一个链表数组)。有些链接可能丢失, 或者甚至会构成循环,使得这个数据结构不再可用。对于 ConcurrentHashMap
绝对不会发生这种情况。在上面的例子中,get
和 put
代码不会破坏数据结构。不过,由于操作序列不是原子的,所以结果不可预知。
- 传统的做法:使用
replace
操作,它会以原子方式用一个新值替换原值,前提是之前没有其他线程把原值替换为其他值。必须一直这么做,直到replace
成功:
do {
oldValue = map.get(word);
newValue = oldValue = null ? 1 : oldValue + 1;
} while (!map.replace(word, oldValue, newValue));
- 或者,可以使用一个
ConcurrentHashMap<String,AtomicLong>
, 或者在 Java SE 8 中,还可以使用ConcurrentHashMap<String,LongAdder>
:
map.putlfAbsent(word, new LongAdder());
map.get(word).increment();
//第一个语句确保有一个 LongAdder 可以完成原子自增。由于 putlfAbsent 返回映射的的值(可能是原来的值, 或者是新设置的值,) 所以可以组合这两个语句:
map.putlfAbsent(word, new LongAdder()).increraent():
- Java SE 8 提供了一些可以更方便地完成原子更新的方法。调用
compute
方法时可以提供一个键和一个计算新值的函数。这个函数接收键和相关联的值(如果没有值,则为null
), 它会计算新值。例如,可以如下更新一个整数计数器的映射:ConcurrentHashMap
中不允许有null
值。有很多方法都使用null
值来指示映射中某个给定的键不存在。
map.compute(word , (k, v) -> v = null ? 1: v + 1);
- 首次增加一个键时通常需要做些特殊的处理。利用
merge
方法可以非常方便地做到这一点。这个方法有一个参数表示键不存在时使用的初始值。否则,就会调用你提供的函数来结合原值与初始值。(与 compute 不同,这个函数不处理键。)- 如果传入
compute
或merge
的函数返回null
, 将从映射中删除现有的条目。 - 使用
compute
或merge
时,要记住你提供的函数不能做太多工作。这个函数运行时,可能会阻塞对映射的其他更新。当然,这个函数也不能更新映射的其他部分。
- 如果传入
map.merge(word, 1L, (existingValue, newValue) -> existingValue + newValue) ;
// 或者,更简单地可以写为:
map.merge(word, 1L, Long::sum) ;
14.7.3 对并发散列映射的批操作
Java SE 8 为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安全地执行。批操作会遍历映射,处理遍历过程中找到的元素。无须冻结当前映射的快照。除非你恰好知道批操作运行时映射不会被修改, 否则就要把结果看作是映射状态的一个近似。
-
有 3 种不同的操作:
- 搜索(search) 为每个键或值提供一个函数,直到函数生成一个非
null
的结果。然后搜索终止,返回这个函数的结果。 - 归约(reduce) 组合所有键或值, 这里要使用所提供的一个累加函数。
- forEach 为所有键或值提供一个函数。
- 搜索(search) 为每个键或值提供一个函数,直到函数生成一个非
-
每个操作都有 4 个版本:
- operationKeys: 处理键。
- operatioriValues: 处理值。
- operation: 处理键和值。
- operatioriEntries: 处理 Map.Entry 对象。
- 对于上述各个操作,需要指定一个参数化阈值(parallelism threshold)。如果映射包含的元素多于这个阈值, 就会并行完成批操作。如果希望批操作在一个线程中运行, 可以使用阈值
Long.MAX_VALUE
。 如果希望用尽可能多的线程运行批操作,可以使用阈值1
。
-
搜索操作:
// 假设我们希望找出第一个出现次数超过 1000 次的单词。需要搜索键和值:
String result = map.search(threshold, (k, v) -> v > 1000 ? k : null);
// result 会设置为第一个匹配的单词,如果搜索函数对所有输入都返回 null, 则返回 null。
-
forEach
方法有两种形式:- 第一个只为各个映射条目提供一个消费者函数:
map.forEach(threshold, (k, v) -> System.out.println(k + " -> " + v));
- 第二种形式还有一个转换器函数,这个函数要先提供,其结果会传递到消费者,转换器(Transformer)可以用作为一个过滤器。 只要转换器返回
null
,这个值就会被悄无声息地跳过:
map.forEach(threshold, (k, v) -> v > 1000 ? k + " -> " + v : null , // Filter and transformer System.out::println); // Consumer
-
reduce
操作用一个累加函数组合其输入。操作与forEach
类似,如果映射为空,或者所有条目都被过滤掉,reduce
操作会返回null
。如果只有一个元素,则返回其转换结果,不会应用累加器。Long count = map. reduceValues(threshold, v -> v > 1000 ? 1L : null , // // Transformer Long::sum); // // Accumulator // 对于 int、long 和 double 输出还有相应的特殊化操作, 分别有后缀 Tolnt、ToLong 和 ToDouble 。需要把输入转换为一个基本类型值,并指定一个默认值和一个累加器函数。映射为空时返回默认值。 long sum = map.reduceValuesToLong(threshold, Long::longValue, // Transformer to primitive type 0, // Default value for empty map Long::sum); // Primitive type accumulator
14.7.4 并发集视图
静态 newKeySet
方法会生成一个 Set<K>
, 这实际上是 ConcurrentHashMap<K, Boolean>
的一个包装器。(所有映射值都为Boolean.TRUE
,不过因为只是要把它用作一个集,所以并不关心具体的值。)
// 当然,如果原来有一个映射,keySet 方法可以生成这个映射的键集。这个集是可变的。如果删除这个集的元素,这个键(以及相应的值)会从映射中删除。不过,不能向键集增加元素,因为没有相应的值可以增加。
Set<String> words = ConcurrentHashMap.<String>newKeySet();
// Java SE 8 为 ConcurrentHashMap 增加了第二个 keySet 方法,包含一个默认值,可以在为集增加元素时使用:
Set<String> words = map.keySet(1L);
words.add("java"); // 如果 "Java” 在 words 中不存在, 现在它会有一个值 1。
14.7.5 写数组的拷贝
CopyOnWriteArrayList
和CopyOnWriteArraySet
是线程安全的集合,其中所有的修改线程对底层数组进行复制。如果在集合上进行迭代的线程数超过修改线程数, 这样的安排是很有用的。当构建一个迭代器的时候,它包含一个对当前数组的引用。如果数组后来被修改了,迭代器仍然引用旧数组,但是,集合的数组已经被替换了。因而,旧的迭代器拥有一致的(可能过时的)视图,访问它无须任何同步开销。
可以查看此作者的文章:CopyOnWriteArrayList 详解
14.7.6 并行数组算法
在 Java SE 8中,
Arrays
类提供了大量并行化操作。
Arrays.parallelSort
:可以对一个基本类型值或对象的数组排序。
// String 并行排序
String contents = new String(Files.readAllBytes(
Paths.get("alice.txt")), StandardCharsets.UTFJ); // 读取一个文件至 String 中
String[] words = contents.split("[\\P{L}]+"); // 按单词拆分
Arrays.parallelSort(words);
// 对对象排序时,可以提供一个 Comparator。
Arrays.parallelSort(words, Comparator.comparing(String::length));
/**
* 对于所有方法都可以提供一个范围的边界,如:
*
* PS:书中的此处代码有误:values.parallelSort(values.length / 2, values,length);
* 数组对象没有这个方法,应该值的是 Arrays.parallelSort 的重载方法。
*/
Arrays.parallelSort(words, length / 2, words,length) ; // 排序后半部分
Arrays.parallelSetAll
:方法会用由一个函数计算得到的值填充一个数组。这个函数接收元素索引,然后计算相应位置上的值。
int[] values = new int[]{5, 6, 7, 8, 9, 10};
Arrays.parallelSetAll(values, i -> i % 10); // 此处的 i 是指数组下表索引
System.out.println(Arrays.toString(values)); // [0, 1, 2, 3, 4, 5]
// 显然,并行化对这个操作很有好处。这个操作对于所有基本类型数组和对象数组都有相应的版本。
Arrays.parallelPrefix
:它会用对应一个给定结合操作的前缀的累加结果替换各个数组元素。
// 考虑数组 [1,2, 3, 4, . . .] 和 x 操作。
int[] values = new int[]{1, 2, 3, 4, ...};
Arrays.parallelPrefix(values, (x, y)-> x * y) ;
// 以上操作后,此时数组将包含:
[1, 1 x 2, 1 x 2 x 3, 1 x 2 x 3 x 4, . . .]
/**
* 个人思路算法解析:
* 其实书中此部分算法已经讲的很透彻了,只需要注意书中本轮变动的数值是深色,不变动的是灰色即可。
* 以下是个人理解,如有不对,欢迎讨论:
*/
// 初始状态:
[1, 2, 3, 4, 5, 6, 7, 8]
// 第一轮:下标 0.2.4.6…作为标志元素,与之后的 1.3.5.7 做操作,此处是乘法:
[1, 【1 * 2】, 3, 【3 * 4】, 5, 【5 * 6】, 7, 【7 * 8】]
// 第二轮:下标 1.5 作为标志元素(从上一轮操作的中选0.2.4.6…位置的下标),与之后的两个内容,也就是 2.3.6.7 做乘法操作:
[1, 1 * 2, 【1 * 2 * 3】, 【1 * 2 * 3 * 4】, 5, 5 * 6, 【5 * 6 * 7】, 【5 * 6 * 7 * 8】]
// 第三轮:注意第二轮的结果可以发现,操作已经碰到最后一个元素(下标7),此时包括下标3之内的对象都已算好(上一轮操作中的1位置下标),只需要把下标3的内容与后续所有内容做操作即可:
[1, 1 * 2, 1 * 2 * 3, 1 * 2 * 3 * 4, 【1 * 2 * 3 * 4 * 5】, 【1 * 2 * 3 * 4 * 5 * 6】, 【1 * 2 * 3 * 4 * 5 * 6 * 7】, 【1 * 2 * 3 * 4 * 5 * 6 * 7 * 8】]
// log(n) 步之后, 这个过程结束。如果有足够多的处理器,这会远远胜过直接的线性计算。这个算法在特殊用途硬件上很常用, 使用这些硬件的用户很有创造力,会相应地调整算法来解决各种不同的问题。
14.7.7 较早的线程安全集合
从 Java 的初始版本开始,
Vector
和Hashtable
类就提供了线程安全的动态数组和散列表的
实现。现在这些类被弃用了, 取而代之的是AnayList
和HashMap
类。这些类不是线程安全的,而集合库中提供了不同的机制。任何集合类都可以通过使用同步包装器(synchronization wrapper) 变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K, V> synchHashMap = Collections.synchronizedMap(new HashMap<K, V>());
最好使用 java.util.concurrent
包中定义的集合,不使用同步包装器中的。concurrent
中的集合都有对多线程进行优化,如:ConcurrentHashMap
等。
应该确保没有任何线程通过原始的非同步方法访问数据结构,最便利的方法是确保不保存任何指向原始对象的引用,简单地构造一个集合并立即传递给包装器,就像上方的代码一样。如果在另一个线程可能进行修改时要对集合进行迭代,仍然需要使用“客户端” 锁定:
synchronized (synchHashMap){
Iterator<K> iter = synchHashMap.keySet().iterator();
while (iter.hasNext()) . . .;
}
// 如果使用“foreach” 循环必须使用同样的代码,因为循环使用了迭代器。
// 注意:如果在迭代过程中,别的线程修改集合,迭代器会失效,抛出 ConcurrentModificationException 异常。同步仍然是需要的,因此并发的修改可以被可靠地检测出来。
14.8 Callable 与 Future
Callable
Runnable
封装一个异步运行的任务,可以把它想象成为一个没有参数和返回值的异步方法。Callable
与Runnable
类似, 但是有返回值。Callable 接口是一个参数化的类型,只有一个方法call
。
public interface Callable<V> {
V call() throws Exception;
}
Future
Future
保存异步计算的结果。可以启动一个计算,将Future
对象交给某个线程,然后忘掉它。Future
对象的所有者在结果计算好之后就可以获得它。
// Future 接口具有下面的方法:
public interface Future<V> {
V get() throws . . .;
V get(long timeout, TimeUnit unit) throws . . .;
void cancel (boolean maylnterrupt);
boolean isCancelled();
boolean isDone();
}
- get
get()
方法的调用被阻塞, 直到计算完成。get(long timeout, TimeUnit unit)
方法超时会抛出TimeoutException
异常。- 如果运行该计算的线程被中断,上述两个
get
方法都将拋出IntermptedException
。
- cancel
- 可以用
cancel
方法取消该计算。如果计算还没有开始,它被取消且不再开始。如果计算处于运行之中,那么如果mayInterrupt
参数为true
, 它就被中断。
- 可以用
- isCancelled
- 任务是否被取消成功,如果在任务正常完成前被取消成功,则返回
true
,否则false
。
- 任务是否被取消成功,如果在任务正常完成前被取消成功,则返回
- isDone
- 如果计算还在进行,返回
false
; 如果完成了,则返回true
。
- 如果计算还在进行,返回
FutureTask 包装器
FutureTask
包装器是一种非常便利的机制,可将Callable
转换成Future
和Runnable
, 它同时实现二者的接口。Future
就是对于具体的Runnable
或者Callable
任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get
方法获取执行结果,该方法会阻塞直到任务返回结果。例如:
Callable<Integer> myComputation = . . .;
FutureTask<Integer> task = new FutureTask<Integer>(myComputation);
Thread t = new Thread(task); // it's a Runnable
t.start();
Integer result = task.get(); // it's a Future,对 get 的调用会发生阻塞,直到有可获得的结果为止。
14.9 执行器
为什么要使用线程池?
- 构建一个新的线程是有一定代价的,因为涉及与操作系统的交互。 如果程序中创建了大量的生命期很短的线程,应该使用线程池(thread pool)。一个线程池中包含许多准备运行的空闲线程。 将
Runnable
对象交给线程池, 就会有一个线程调用run
方法。 当run
方法退出时,线程不会死亡,而是在池中准备为下一个请求提供服务。 - 另一个使用线程池的理由是减少并发线程的数目。 创建大量线程会大大降低性能甚至使虚拟机崩溃。 如果有一个会创建许多线程的算法,应该使用一个线程数“固定的” 线程池以限制并发线程的总数
执行器
(Executor) 类用来构建线程池的静态工厂方法:
方法 | 描述 |
---|---|
newCachedThreadPool | 必要时创建新线程;空闲线程会被保留 60 秒 |
newFixedThreadPool | 该池包含固定数量的线程;空闲线程会一直被保留 |
newSingleThreadExecutor | 只有一个线程的“池”,该线程顺序执行每一个提交的任务(类似于 Swing 事件分配线程 |
newScheduledThreadPool | 用于预定执行而构建的固定线程池,替代 java.util.Timer |
newSingleThreadScheduledExecutor | 用于预定执行而构建的单线程 “池” |
线程池
newCachedThreadPool
:方法构建了一个线程池,对于每个任务,如果有空闲线程可用,立即让它执行任务, 如果没有可用的空闲线程,则创建一个新线程。newFixedThreadPool
:方法构建一个具有固定大小的线程池。 如果提交的任务数多于空闲的线程数, 那么把得不到服务的任务放置到队列中。当其他任务完成以后再运行它们。newSingleThreadExecutor
:是一个退化了的大小为 1 的线程池:由一个线程执行提交的任务,一个接着一个,按顺序执行任务。
以上 3 个方法返回实现了 ExecutorService
接口的 ThreadPoolExecutor
类的对象。可用下面的方法之一将一个 Runnable
对象或 Callable
对象提交给 ExecutorService
:
// 返回一个奇怪样子的 Future<?>。可以使用这样一个对象来调用 isDone、cancel 或 isCancelled。但是,get 方法在完成的时候只是简单地返回 null。
Future<?> submit(Runnable task)
// 提交一个 Runnable,并且 Future 的 get 方法在完成的时候返回指定的 result 对象。
Future<T> submit(Runnable task, T result)
// 提交一个 Callable, 并且返回的 Future 对象将在计算结果准备好的时候得到它。
Future<T> submit(Callable<T> task)
newScheduledThreadPool
和newSingleThreadScheduledExecutor
:方法将返回实现了ScheduledExecutorService
接口的对象。ScheduledExecutorService
:接口具有为预定执行(Scheduled Execution ) 或重复执行任务而设计的方法。它是一种允许使用线程池机制的java.util.Timer
的泛化。- 可以预定
Runnable
或Callable
在初始的延迟之后只运行一次。也可以预定一个Runnable
对象周期性地运行。
shutdown
:当用完一个线程池的时候,调用shutdown
。该方法启动该池的关闭序列。被关闭的执行器不再接受新的任务。当所有任务都完成以后,线程池中的线程死亡。- 另一种方法是调用
shutdownNow
。该池取消尚未开始的所有任务并试图中断正在运行的线程。
- 另一种方法是调用
如何使用线程池连接池:
- 调用
Executors
类中静态的方法newCachedThreadPool
或newFixedThreadPool
。 - 调用
submit
提交Runnable
或Callable
对象。 - 如果想要取消一个任务,或如果提交
Callable
对象,那就要保存好返回的Future
对象。 - 当不再提交任何任务时,调用
shutdown
。
14.9.3 任务控制组
-
executor.invokeAny
方法提交所有对象到一个Callable
对象的集合中, 并返回某个已经完成了的任务的结果。无法知道返回的究竟是哪个任务的结果, 也许是最先完成的那个任务的结果。对于搜索问题,如果你愿意接受任何一种解决方案的话,你就可以使用这个方法。例如,假定你需要对一个大整数进行因数分解计算来解码 RSA 密码。可以提交很多任务,每一个任务使用不同范围内的数来进行分解。只要其中一个任务得到了答案,计算就可以停止了。 -
executor.invokeAll
方法提交所有对象到一个Callable
对象的集合中,并返回一个Future
对象的列表,代表所有任务的解决方案。如下所示:
List<Callable<T>> tasks = . . .;
List<Future<T>> results = executor.invokeAll(tasks):
for (Future<T> result : results)
processFurther(result.get());
ExecutorCompletionService
:方法invokeAll
的缺点是如果第一个任务恰巧花去了很多时间,则可能不得不进行等待。将结果按可获得的顺序保存起来更有实际意义(先返回结果的排前面)。可以用ExecutorCompletionService
来进行排列。如下所示:
ExecutorCompletionService<T> service = new ExecutorCompletionService<>(executor);
for (Callable<T> task : tasks) service.submit(task);
for (int i = 0; i < tasks.size();i ++)
processFurther(service.take().get());
14.9.4 Fork-Join 框架
Java SE 7 中新引入了
fork-join
框架,专门用来支持一些:可能对每个处理器内核分别使用一个线程,来完成计算密集型任务的应用, 如图像或视频处理。
要采用框架可用的一种方式完成这种递归计算, 需要提供一个扩展 RecursiveTask<T>
的类(如果计算会生成一个类型为 T
的结果)或者提供一个扩展 RecursiveAction
的类(如果不生成任何结果)。再覆盖 compute
方法来生成并调用子任务, 然后合并其结果。
class Counter extends RecursiveTask<Integer> {
. . .
protected Integer compute() {
if (to - from < THRESHOLD) {
// solve problem directly
} else {
int mid = (from + to) / 2;
Counter first = new Counter(values, from, mid, filter);
Counter second = new Counter(values, mid, to, filter);
// invokeAll 方法接收到很多任务并阻塞, 直到所有这些任务都已经完成。
invokeAll (first, second);
// join 方法将生成结果。我们对每个子任务应用了join, 并返回其总和。
return first.join() + second.join();
}
}
}
// 还有一个 get 方法可以得到当前结果,不过一般不太使用,因为它可能抛出已检查异常, 而在 compute 方法中不允许抛出这些异常。
在后台,fork-join 框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方法称为 工作密取(work stealing)。每个工作线程都有一个 双端队列 ( deque ) 来完成任务。一个工作线程将子任务压入其双端队列的队头(只有一个线程可以访问队头,所以不需要加锁)。一个工作线程空闲时,它会从另一个双端队列的队尾“密取”一个任务。由于大的子任务都在队尾, 这种密取很少出现。
- 个人理解:可以概括为“闲里偷忙”:在“生产者-消费者”设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列(只有一个线程可以访问队头,所以不需要加锁)。一个工作线程将子任务压入其双端队列的队头。当一个消费者完成了自己的任务,就会从其他人队尾偷一个任务过来做。
14.9.5 可完成 Future
这一章节书中内容好抽象,仔细看了一会大概知道这一章节的意思。首先Future类如它的中文意思一般是“未来”的意思,可以理解为代表未来要做的事。但要考虑到多个Future执行的情况,你可能认为的顺序是步骤1、2、3,但因为线程不确定性,可能顺序是步骤3、1、2。
Java SE 8 的 CompletableFuture
类提供了一种候选方法。与事件处理器不同,“可完成 future" 可以“组合”(composed )。也就是你可以指定指定异步处理结果的处理顺序,保证步骤的正确。
CompletableFuture<T>
的API
本书作者使用了简写记法来表示复杂的函数式接口,这里会把
Function<? super T,U>
写为T -> U
。以下方法书中基本没有示例代码,留个映像以后用的时候查。
- 为
CompletableFuture<T>
对象增加一个动作
方法 | 参数 | 描述 |
---|---|---|
thenApply | T -> U | 对结果应用一个函数 |
thenCompose | T -> CompletableFuture | 对结果调用函数并执行返回的 future |
handle | (T, Throwable)-> U | 处理结果或错误 |
thenAccept | T -> void | 类似于 thenApply, 不过结果为 void |
whenComplete | (T, Throwable)-> void | 类似于 handle, 不过结果为 void |
thenRun | Runnable | 执行 Runnable, 结果为 void |
- 组合多个组合对象
方法 | 参数 | 描述 |
---|---|---|
thenCombine | CompletableFuture<U>, (T, U)-> V | 执行两个动作并用给定函数组合结果 |
thenAcceptBoth | CompletableFuture<U>, (T, U) -> void | 与 thenCombine 类似,不过结果为 void |
runAfterBoth | CompletableFuture<?>, Runnable | 两个都完成后执行 runnable |
applyToEither | CompletableFuture<T>, T-> V | 得到其中一个的结果时,传入给定的函数 |
acceptEither | CompletableFuture<T>, T-> void | 与 applyToEither 类似,不过结果为 void |
runAfterEither | CompletableFuture<?>, Runnable | 其中一个完成后执行 runnable |
static allOf | CompletableFuture<?> … | 所有给定的 future 都完成后完成,结果为 void |
static anyOf | CompletableFuture<?> … | 任意给定的 future 完成后则完成,结果为 void |
14.10 同步器(※)
- 这小节真的是神仙打架了,每一部分都很短,没有实例代码和详解,大部分笔记就是直接引用。希望在卷二上有深入的了解,没有就后续抽空自己学。
14.10.1 信号量
概念上讲,一个信号量管理许多的许可证(permit)。 为了通过信号量,线程通过调用
acquire
请求许可。其实没有实际的许可对象,信号量仅维护一个计数。许可的数目是固定的,由此限制了通过的线程数量。其他线程可以通过调用release
释放许可。而且,许可不是二必须由获取它的线程释放。事实上,任何线程都可以释放任意数目的许可,这可能会增加许可数目以至于超出初始数目。
信号量在 1968 年由 Edsger Dijkstra 发明, 作为同步原语(synchronization primitive)。Dijkstra 指出信号量可以被有效地实现,并且有足够的能力解决许多常见的线程同步问题。在几乎任何一本操作系统教科书中, 都能看到使用信号量实现的有界队列。当然,应用程序员不必自己实现有界队列。通常,信号量不必直接映射到通用应用场景。
14.10.2 倒计时门栓
一个倒计时门栓(CountDownLatch) 让一个线程集等待直到计数变为 0。倒计时门栓是一次性的。一旦计数为 0, 就不能再重用了。
举例来讲,假定一个线程集需要一些初始的数据来完成工作。工作器线程被启动并在门外等候。另一个线程准备数据。当数据准备好的时候,调用countDown
, 所有工作器线程就可以继续运行了。然后,可以使用第二个门栓检査什么时候所有工作器线程完成工作。用线程数初始化门栓。每个工作器线程在结束前将门栓计数减 1。 另一个获取工作结果的线程在门外等待,一旦所有工作器线程终止该线程继续运行。
14.10.3 障栅
CyclicBarrier
类实现了一个集结点(rendezvous) 称为障栅(barrier)。障栅被称为是循环的(cyclic),因为可以在所有等待线程被释放后被重用。在这一点上,有别于 CountDownLatch
, CountDownLatch
只能被使用一次。
考虑大量线程运行在一次计算的不同部分的情形。当所有部分都准备好时,需要把结果组合在一起。当一个线程完成了它的那部分任务后,我们让它运行到障栅处。一旦所有的线程都到达了这个障栅,障栅就撤销,线程就可以继续运行。
下面是其细节。首先, 构造一个障栅, 并给出参与的线程数:
CyclicBarrier barrier = new CyclicBarrier(nthreads);
// 每一个线程做一些工作,完成后在障栅上调用 await :
public void run() {
doWork();
barrier.await();
...
}
// await 方法有一个可选的超时参数:
barrier.await(100, TineUnit.MILLISECONDS);
如果任何一个在障栅上等待的线程离开了障栅, 那么障栅就被破坏了(线程可能离开是因为它调用 await
时设置了超时,或者因为它被中断了)。在这种情况下,所有其他线程的 await
方法抛出 BrokenBarrierException
异常。那些已经在等待的线程立即终止 await
的调用。
可以提供一个可选的障栅动作( barrier action), 当所有线程到达障栅的时候就会执行这一动作,该动作可以收集那些单个线程的运行结果:
Runnable barrierAction = . . . ;
CyclicBarrier barrier = new CyclicBarrier(nthreads, barrierAction);
14.10.4 交换器
当两个线程在同一个数据缓冲区的两个实例上工作的时候,就可以使用 交换器 (Exchanger) 典型的情况是, 一个线程向缓冲区填入数据,另一个线程消耗这些数据。当它们都完成以后,相互交换缓冲区。
14.10.5 同步队列
同步队列是一种将生产者与消费者线程配对的机制。当一个线程调用
SynchronousQueue
的put
方法时,它会阻塞直到另一个线程调用take
方法为止,反之亦然。与Exchanger
的情况不同, 数据仅仅沿一个方向传递,从生产者到消费者。
即使SynchronousQueue
类实现了BlockingQueue
接口,概念上讲,它依然不是一个队列。它没有包含任何元素,它的size
方法总是返回 0。