知识点概要
学习笔记
多线程
一个程序同时执行多个任务。每一个任务称为一个线程(thread),它是线程控制的简称。可以同时运行一个以上线程的程序称为多线程程序(multithreaded)。
多进程与多线程的区别
本质区别在于每个进程拥有自己的一整套变量,而线程则共享数据。
共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。
中断线程
当线程的run方法执行方法体中最后一条语句后,并经由执行return语句返回时,或者出现了在方法中没有捕获的异常时,线程将终止。
没有可以强制线程终止的方法。然而,interrupt方法可以用来请求终止线程。当对一个线程调用interrupt方法时,线程的中断状态(boolean标志)降被置位。每个线程都应该不时地检查这个标志,以判断线程是否被中断。
判断中断状态是否被置位:Thread.currentThread().islnterrupted()
如果线程被阻塞,就无法检测中断状态。当在一个被阻塞的线程(调用sleep或wait)上调用interrupt方法时,阻塞调用将会被Interrupted Exception异常中断。
如果在每次工作迭代之后都调用 sleep 方法 (或者其他的可中断方法,) islnterrupted 检测
既没有必要也没有用处。 如果在中断状态被置位时调用 sleep 方法, 它不会休眠。 相反, 它 将清除这一状态(丨)并拋出 IntemiptedException。因此, 如果你的循环调用 sleep, 不会检 测中断状态。 相反, 要如下所示捕获 InterruptedException 异常:
Runnable r = () -> {
try {
...
while(more work to do) {
do more work
Thread.sleep(delay);
}
} catch (InterruptedException e){
// Thread was interrupted during sleep
} finally {
cleanup, if required
}
// exiting the run method terminates the thread
};
有两个非常类似的方法, interrupted 和 islnterrupted。Interrupted 方法是一个静态 方 法, 它检测当前的线程是否被中断。 而 且, 调 用 interrupted 方法会清除该线程的中断 状态。 另一方面, islnterrupted 方法是一个实例方法, 可用来检验是否有线程被中断。调 用这个方法不会改变中断状态。
对InterruptedException异常处理方法:
不要像如下处理:
try{ sleep(delay); }
catch(InterruptedException e){}
正确方法:
(1) 在catch中调用Thread.currentThread().interrupt()来设置中断状态为true。于是,调用者可以对其进行检测。
try{ sleep(delay); }
catch(InterruptedException e){ Thread.currentThread().interrupt(); }
(2) 用throws InterruptedException标记方法,不采用try语句块捕获异常。于是,嗲用着可以捕获这一异常。
void mySubTask() throws InterruptedException {
sleep(delay);
}
线程状态
6中状态:New(新创建)、Runnable(可运行)、Blocked(被阻塞)、Waiting(等待)、Timed waiting(计时等待)、Terminated(被终止)
新创建线程
用new操作符创建,如new Thread(r), 线程未开始运行。
可运行线程
调用start方法后,线程处于runnable状态,是否运行取决于操作系统给线程提供运行的时间。
一旦一个线程开始运行,它不必始终保持运行。运行中的线程被中断的目的是为了让其他线程获得运行机会。线程调度的细节依赖于操作系统提供的服务。抢占式调用系统给每个可运行线程一个时间片来执行任务,当时间片用完,操作系统会剥夺该线程的运行权,并给另一个线程运行机会。当选择下一个线程时,操作系统考虑线程的优先级。
在任何一个时刻,一个可运行的线程可能正在运行也可能没有运行(为什么这个状态称为可运行而不是运行)
被阻塞线程和等待线程
线程处于被阻塞活等待状态时,它暂时不活动,不运行任何代码且消耗最少的资源。直到线程调度器重新激活它。细节取决于它是怎样达到非活动状态的。
当一个线程时图获取一个内部对象锁(而不是java.until.concurrent库中的锁),而该锁被其他线程持有,则该线程进入阻塞状态。当所有其他线程释放该锁,并且线程调度器允许被线程持有该锁的时候,该线程将变为非阻塞状态。
当线程等待另一个线程通知调度器一个条件时,它自己会进入等待状态。在调用Object.wait方法或Thread.join方法,或者是等待java.util.concurrent库中的Lock或Conditon时,就会出现这种情况。被阻塞状态和等待状态是由很大不同的。
有几个方法有超时参数,调用它们时线程进入计时等待状态。这一状态将一直保持到超市期满或者接收到适当的通知。带有超时参数的方法有:Thread.sleep、Object.wait、Thread.join、Lock.tryLock、Condition.await
下图展示了线程可以具有的状态以及从一个状态到另一个状态可能的转换:
java.lang.Thread
void join() 等待终止指定的线程
void join(long millis) 等待指定的线程死亡或者经过指定的毫秒数
Thread.State getState():获取线程状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED
以下方法已经过时:
void stop() 停止线程
void suspend() 暂停线程的执行
void resume() 恢复线程,仅仅在调用suspend方法之后执行
线程属性
包括:线程优先级、守护线程、线程组、处理未捕获异常的处理器。
线程优先级
默认情况下,线程继承它的父线程的优先级。可以使用setPriority方法提高或降低任何一个线程的优先级。
MIN_PRIORITY: 1, MAX_PRIORITY: 10, NORM_PRIORITY: 5
void setPriority(int new Priority): 设置线程的优先级
static int MIN_PRIORITY: 最小优先级,值为1
static int MAX_PRIORITY: 最大优先级,值为10
static int NORM_PRIORITY: 默认优先级,值为5
static void yield(): 导致当前执行线程处于让步状态。如果有其他的可运行线程具有至少与此线程同样高的优先级,那么这些线程接下来会被调度。
守护线程
通过调用t.setDaemon(true);将线程转换为守护线程(daemon thread)
守护线程的唯一用途是为其他线程提供服务。当只剩下守护线程时,虚拟机就退出来。
守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。
void setDaemon(boolean isDaemon)
标识该线程为守护线程或用户线程,必须在线程启动前调用。
为捕获异常处理器
线程的run方法不能抛出任何受查异常,但是,非受查异常会导致线程终止,这种情况下,线程就死亡了。
但是,不需要任何catch子句来处理可以被传播的异常,相反,在线程死亡之前,异常被传递到一个用于未捕获异常的处理器。该处理器必须属于一个实现了Thread.UncaughtExceptionHandler接口的类,这个接口只有一个方法:
void uncaughtException(Thread t, Throwable e)
可以用setUncaughtExceptionHandler方法为任何线程安装一个处理器,也可以用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有线程安装一个默认的处理器。如果不安装处理器,默认的处理器为空。但是,如果不为线程安装处理器,此时的处理器就是该线程的ThreadGroup对象。
线程组是一个可以同意管理的线程集合。默认情况下,创建的所有线程数语相同的线程组,但是,也有可能会建立其他的组。现在引入了更好的特性用户线程集合的操作,所有建议不要在自己的程序中使用线程组。
ThreadGroup类实现Thread.UncaughtExceptionHandler即可偶,它的uncaughtException方法做如下操作:
(1) 如果该线程组有父线程组,那么父线程组的uncaughtException方法被调用
(2) 否则,如果Thread.getDefaultExceptionHandler方法返回一个非空的处理器,则调用该处理器
(3) 否则,如果Throwable是ThreadDeath的一个实例,什么都不做
(4) 否则,线程的名字以及Throwable的栈轨迹被输出到System.err上。
java.lang.Thread
static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler)
static Thread.UncaughtExceptionHandler getDefaultUncaughtExceptionHandler()
设置或获取未捕获异常的默认处理器
void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler)
Thread.UncaughtExceptionHandler getUncaughtExceptionHandler()
设置或获取未捕获异常的处理器。如果没有安装处理器,则将线程组对象作为处理器。
java.lang.Thread.UncaughtExceptionHandler
void uncaughtException(Thread t, Throwable e)
当一个线程因未捕获异常而终止,按规定要将客户报告记录到日志中
参数:
t: 由于未捕获异常而终止的线程
e: 未捕获的异常对象
java.lang.ThreadGroup
void uncaughtException(Thread t, Throwable e)
如果有父线程组,调用父线程组的这一方法;或者,如果Thread类有默认的处理器,调用该处理器,否则输出栈轨迹到标准错误流上。
同步
竞争条件:两个或两个以上的线程存取相同的对象,并且每个线程都调用了一个修改改对象状态的方法,可能会产生讹误的对象。
竞争条件详解
当两个线程试图同时更新一个账户的时候,就可能出现问题,假定两个线程同时执行指令:
accounts[to] += amount;
问题在于这不是原子操作。该指令北处理如下:
(1) 将accounts[to]加载到寄存器
(2) 增加amount
(3) 将结果写回accounts[to]
假定第一个线程执行步骤1和2后被剥夺了运行权,第二个线程被唤醒并修改了accounts数组中的同一项,然后,第一个线程被唤醒并完成第三步,这样,这一动作擦去了第二个线程所做的更新,于是,总金额不再正确。
锁对象
有两种机制防止代码块收到并发访问的干扰:
(1) synchronized关键字
(2) ReentrantLock类
myLock.lock();
try {
critical sestion
} finally {
myLock.unlock(); //放到此处,防止抛出异常后导致未释放锁
}
这一结构确保任何时刻只有一个线程进入临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们被阻塞,直到第一个线程释放锁对象。
java.util.concurrent.locks.Lock
void lock(): 获取这个锁;如果锁同时被另一个线程拥有,则发生阻塞。
void unlock(): 释放锁
java.util.concurrent.locks.ReentrantLock
ReentrantLock(): 构建一个可以被用来保护临界区的可重入锁
ReentrantLock(boolean fair): 构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能。默认情况下,锁没有被强制为公平的。
条件对象
通常,线程进入临界区,却发现在某一条件满足后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。由于历史原因,条件对象经常被称为条件变量(conditional variable)
一个锁对象可以有一个活多个相关的条件对象。可以用newCondition方法获取一个条件对象。习惯上给每个条件对象命名为可以反映它所表达条件的名字。
class Bank {
private Condition sufficientFunds;
...
public Bank(){
...
sufficientFunds = bankLock.newCondition();
}
}
如果transfer方法发现余额不足,调用sufficientFunds.await()方法,当前线程被阻塞并放弃锁,这样,另外一个线程可以进行增加账户余额的操作。
等待获得锁的线程和调用await方法的线程存在本质上的不同。一旦一个线程调用await方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。想法,它处于阻塞状态,知道另一个线程调用统一条件上的signalAll方法为止。
当另一个线程转账时,它应该调用:sufficientFunds.signalAll(); 这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进入该对象,一旦锁成为可用的,它们中的某个将从await调用返回,获得该锁并从被阻塞的地方继续执行。此时,线程应该再次测试条件。由于无法确保该条件被满足,signalAll方法仅仅是通知正在等待的线程:此时有可能已经满足条件,值得再次去检测该条件。
通常,对await的调用应该加在如下形式的循环体中:
while(!(ok to proceed)){
condition.await();
}
至关重要的是最终需要在某个其他线程调用signalAll方法。当一个线程调用await时,它无法重新激活自身,而是寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不会在运行,这将导致死锁(deadlock)现象。如果所有其他线程被阻塞,最后一个活动的线程在解除其他线程线程的阻塞状态前调用了await方法,那么它也被阻塞,没有任何线程可以挤出其他线程的阻塞,那么该程序就挂起了。
应该在何时调用signalAll呢?经验上讲,在对象的状态有利于等待线程的方向改变时调用signalAll。例如,当一个账户余额发生改变时,等待的线程会应该有机会检查余额。
public void transfer(int from, int to, double amount){
bankLock.lock();
try{
while(accounts[from] < amount){
sufficientFunds.await();
}
// transfer funds
...
sufficientFunds.signalAll();
} finally {
bankLock.unlock();
}
}
注意调用signalAll方法不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,一边这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。
signal方法时随机解除等待集中的某个线程的阻塞状态。如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。如果没有其他线程再次调用signal方法,那么系统就死锁了。
java.util.concurrent.locks.Lock
Condition newCondition(): 返回一个与该锁相关的条件对象
java.util.concurrent.locks.Condition
void await(): 将该线程放入条件等待集中
void signalAll(): 解除该条件的等待集中的所有线程的阻塞状态
void signal(): 从该条件的等待集中随机地选择一个线程,解除其阻塞状态
【总结】锁和条件的关键之处
(1) 锁用来保护代码片段,任何时刻只有一个线程执行被保护的代码
(2) 锁可以管理试图进入被保护代码片段的线程
(3) 锁可以拥有一个活多个相关的条件对象
(4) 每个条件兑现管理那些已经进入被保护的代码段单还不能运行的线程
synchronized关键字
Java中的每个对象都有一个内部锁,如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
public synchronized void mehtod(){
method body
}
等价于:
public void method(){
this.intrinsicLock.lock();
try {
method body
} finally {
this.intrinsicLock.unlock();
}
}
内部对象锁只有一个相关条件。wait方法添加一个线程到等待集中,notifyAll/notify方法解除等待线程的阻塞状态。调用wait或notifyAll等价于:
intrinsicCondition.await();
intrinsicCondition.signalAll();
每个对象有一个内部锁,并且该锁有一个内部条件,由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。
将静态方法声明为synchronized也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果Bank类有一个静态同步的方法,那么当该方法被调用时,Bank.class对象的锁被锁住,因此,没有其他线程可以调用同一个类的这个活任何其他的同步静态方法。
内部锁和条件存在的局限:
(1) 不能中断一个正在试图获得锁的线程
(2) 试图获得锁时不能设定超时
(3) 每个锁仅有单一的条件,可能是不够的
使用Lock和Condition对象还是synchronized同步方法的建议:
(1) 最好既不使用Lock/Condition也不使用synchronized关键字。在许多情况下可以使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁。如使用阻塞队列来同步完成一个共同任务的线程,还应当研究一下并行流。
(2) 如果synchronized关键字适用于你的程序, 请尽量使用它。这样可以减少写代码的数量,减少出错的几率。
(3) 如果特别需要Lock/Condition结构提供的独有特性时,才使用Lock/Condition。
java.lang.Object
- void notifyAll(): 解除那些在该对象上调用wait方法的线程的阻塞状态,该方法只能在同步方法或同步块内部调用。如果当前线程不是对象锁的持有者,将抛出IllegalMonitorStateException异常。
- void notify(): 随机选择一个在该对象上调用wait方法的线程,解除其阻塞状态。该方法只能在同步方法或同步块内部调用。如果当前线程不是对象锁的持有者,将抛出IllegalMonitorStateException异常。
- void wait(): 导致线程进入等待状态直到它被通知。该方法只能在同步方法或同步块内部调用。如果当前线程不是对象锁的持有者,将抛出IllegalMonitorStateException异常。
- voidwait(long millis)
- void wait(long millis, int nanos)
导致线程进入等待状态直到它被通知或经过指定的时间。该方法只能在同步方法或同步块内部调用。如果当前线程不是对象锁的持有者,将抛出IllegalMonitorStateException异常。
同步阻塞
synchronized(obj){
...
}
这种形式下,线程获得obj的锁。
有时使用一个对象的锁来实现额外的原子操作,实际上称为客户端锁定(client-side locking)。例如,考虑Vector类,它的方法时同步的。现在,假定在Vector<Double>中存储银行余额:
public void transfer(Vector<Double> accounts, int from , int to, int amount){
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
...
}
Vector类的get和set方法时同步的。在第一次对get的调用已经完成后1,一个线程完全有可能在transfer方法中被剥夺运行权,于是,另一个线程可能在相同的存储位置存入不同的值。但是我们可以截获这个锁:
public void transfer(Vector<Double> accounts, int from , int to, int amount){
synchronize(accounts){
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
}
...
}
这个方法可以工作,但是完全依赖于Vector类对自己的所有可修改方法都使用内部锁,然而Vector类的文档并没有给出这样的承诺。
客户端锁定是非常脆弱的,通常不推荐使用。
监视器概念
锁和条件是线程同步的强大工具,但严格地讲,它们并不是面向对象的。
监视器(monitor)解决的问题:不需要考虑如何加锁的情况下,保证多线程的安全性。
监视器的特性:
- 监视器是只包含私有域的类。
- 每个监视器类的对象有一个相关的锁。
- 使用该锁对所有的方法进行加锁。换句话说,如果客户端调用obj.method(),那么obj对象的锁是在方法调用开始时自动获得,并且当方法返回时自动释放该锁。因为所有的域都是私有的,这样的安排可以确保一个线程在对对象操作时,没有其他线程能访问该域。
- 该锁可以有任意多个相关条件。
Volatile域
volatile关键字为实例域的同步访问提供了一种免锁机制,如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
volatile变量不能提供原子性,不能保证读取、翻转和写入不被中断。
final变量
将一个域声明为final,可以安全地访问这个共享域。
final Map<String, Double> accounts = new HashMap<>();
其他线程会在构造函数完成构造之后才能看到这个accounts变量。如果不使用final,就不能保证其他线程看到的是accounts更新后的值,它们可能都只是看到null,而不是构造新的HashMap。
当然,对这个映射表的操作并不是线程安全的,如果多个线程在读写这个映射表,仍然需要进行同步。
原子性
java.util.concurrent.atomic包中有很多类使用了高效的机器级指令(而不是使用锁)来保证其他操作的原子性。
public static AtomicLong largest = new AtomicLong();
largest.updateAndGet(x -> Math.max(x, observed));
或
largest.updateAndGet(observed, Math::max);
如果有大量的线程访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。JavaSE8提供了LongAdder和LongAccumulator类来解决这个问题。
LongAdder包含多个变量(加数),其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下,只有当所有的工作都完成之后才需要总和的值,对于这种情况,这种方法会很高效,性能会有显著提升。
如果认为存在大量的竞争,只需要使用LongAdder而不是AtomicLong。调用increment让计数器自增,或者调用add来增加一个量,调用sum来获取总和:
final LongAdder adder = new LongAdder();
for(...){
pool.submit(() -> {
while(...){
...
if(...){
adder.increment();
}
}
});
}
LongAccumulator将这种思想推广到任意的累加操作。在构造器中,可以提供这个操作以及它的零元素,要加入新的值,可以调用accumulate。调用get来获得当前值。下面的代码可以得到与LongAdder同样的效果:
LongAccumulator adder = new LongAccumulator(Long::sum, 0);
// In some thread...
adder.accumulate(value);
。每个变量d的初始值为零元素(这个例子中零元素为0)。调用accumulator并提供v值
时,其中一个变量会以原子方式更新为, 这里op是中缀形式的累加操作。
get的结果是a1op a2op a3op ... an。
死锁
每个线程都被阻塞。
线程局部变量
使用ThreadLocal辅助类为各个线程提供各自的实例, 避免共享变量。
如SimpleDateFormat类不是线程安全的,要为每个线程构造一个实例,可以使用如下代码:
public static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String dataStamp = dateForamt.get().format(new Date());
在给定线程中首次调用get方法时,会调用initialValue方法,之后,get方法会返回属于当前线程的那个实例。
java.lang.ThreadLocal<T>
- T get(): 得到这个线程的当前值。如果是首次调用get, 会调用initialize来得到这个值。
- protected initialize(): 应该覆盖这个方法来提供一个初始值。默认情况下,这个方法返回null
- void set(T t): 为这个线程设置一个新值
- void remove(): 删除对应这个线程的值
- static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier): 创建一个线程局部变量,起初始值通过调用给定的supplier生成。
java.util.concurrent.ThreadLocalRandom
- static ThreadLocalRandom current(): 返回特定于当前线程的Random类实例。
锁测试与超时
线程在调用lock方法来获得另一个线程持有的锁时,很可能会发生阻塞。使用tryLock方法试图申请一个锁,在成功后返回true,否则,立即返回false,而且线程可以立即离开去做其他事情。
if(myLock.tryLock()){
// now the thread owns the lock
try{...}
finally { myLock.unlock(); }
}else{
}
tryLock可以使用超时参数:myLock.tryLock(100, TimeUnit.MILLISECONDS);
lock方法不能被中断,如果一个线程在等待获得一个锁时被中断,终端线程在获得锁之前一直处于阻塞状态。如果出现死锁,那么lock方法就无法终止。然而,调用带有超时参数的tryLock方法,那么如果线程在等待期间被中断,则会抛出InterruptedException。
等待一个条件时,也可以提供一个超时:
myCondition.await(100, TimeUnit.MILLISECONDS)
如果一个线程被另一个线程通过调用signalAll或signal激活,或者超时时限已达到,或者线程被中断,那么await方法将返回。如果等待的线程被中断,await方法会抛出InterruptedException异常。
java.util.concurrent.locks.Lock
- boolean tryLock(): 尝试获得锁而并没有发生阻塞;如果成功返回true。这个方法会抢夺可用的锁,即使该锁有公平加锁策略,即便其他线程已经等待很久也是如此。
- boolean tryLock(long time, TimeUnit unit): 尝试获得锁,阻塞时间不会超过给定的值,如果成功,返回true。
- void lockInterruptibly(): 获得锁,但是会不确定地发生阻塞。如果线程被中断,抛出InterruptedException。
java.util.concurrent.locks.Condition
- boolean await(long time, TimeUnit unit): 进入该条件的等待集,只到线程从等待集中移出或等待了指定的时间之后才解除阻塞。如果因为等待时间到了而返回就返回false, 否则返回true。
- voidawaitUninterruptibly(): 进入该条件的等待集,知道线程从等待集中移出才解除阻塞。如果线程被中断,该方法不会抛出InterruptedException。
读/写锁
如果很多线程从同一个数据结构读取数据而很少线程修改其中读数据,则用ReentrantReadWriteLock,在这种情况下,允许对读者线程共享访问是合适的,写者线程必须是互斥访问的。
使用读/写锁的必要步骤:
- 构造一个ReentrantReadWriteLock对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
- 抽取读锁和写锁:
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();
- 对所有获取方法加读锁:
public double getTotalBalance(){
readLock.lock();
try{...}
finally { readLock.unlock();}
}
- 对所有修改方法加写锁:
public void transfer(){
writeLock.lock();
try{...}
finally { writeLock.unlock(); }
}
java.util.concurrent.locks.ReentrantReadWriteLock
- Lock readLock(): 得到一个可以被多个读操作共用的读锁,但会排斥所有写操作
- Lock writeLock(): 得到一个写锁,排斥所有其他的读操作和写操作。
为什么弃用stop和suspend方法
stop和suspend方法都试图控制一个给定线程的行为。
stop方法天生就不安全,suspend方法会经常导致死锁。
使用stop方法终止线程时,会终止所有未结束的方法,包括run方法。当线程被终止,立即释放被它锁住的所有对象,会导致对象处于不一致的状态。当线程要终止另一个线程时,无法知道什么时候调用stop方法是安全的,什么时候导致对象被破坏,所以stop被弃用了。
suspend方法不会破坏对象,到那时如果用suspend挂起一个持有锁的线程,那么该锁在恢复之前是不可用的。如果调用suspend方法的线程试图获得同一个锁,那么程序死锁:被挂起的线程等待被恢复,而将其挂起的线程等待获得锁。
如果要安全地挂起线程,引入一个变量suspendRequested并在run方法的某个安全的地方测试他,安全的地方是指该线程没有封锁其他线程需要的对象的地方。当该线程发现suspendRequested变量已经被设置时,将会保持等待状态只到它再次获得为止。
阻塞队列
对于许多线程问题,可以通过使用一个或多个队列以优雅且安全的形式将其形式化。生产者线程向队列插入元素,消费者线程取出元素。使用队列可以安全地从一个线程向另一个线程传递数据。
当试图向队列添加元素而队列已满,或是向从队列移出元素而队列为空时,阻塞队列(blocking queue)会导致线程阻塞。
队列会自动地负载均衡,如果第一个线程集运行的比第二个线程集慢,那么第二个线程集在等待时会阻塞。如果第一个运行较快,它将等待第二个线程集赶上来。
阻塞队列方法分为三类,取决于当队列满或空时他们的处理方式,具体如下:
阻塞队列的几个变种:
- LinkedBlockingQueue: 容量没有上边界,但是可以指定最大容量
- LinkedBlockingDeque: 双端队列
- ArrayBlockingQueue: 在构造时需要指定容量,并且有一个可选参数来指定是否需要公平性。若设置了公平性参数,那么等待了最长时间的线程会得到优先处理。
- PrirityBlockingQueue: 优先级队列,按照元素的优先级顺序被移出,而不是先进先出。无容量限制,但是为空时取元素会阻塞
- DelayQueue: 包含实现了Delay接口的对象,Delay的getDelay方法返回对象的残留延迟,负值表示延迟已结束,元素只有在延迟用完的情况下才从DelayQueue中移出。还必须实现compareTo方法,DelayQueue使用该方法对元素进行排序。
- TransferQueue接口:允许生产者线程等待,直到消费者准备就绪,可以接收元素。LinkedTransferQueue实现了这个接口。
线程安全的集合
高效的映射、集和队列
java.util.concurrent包提供了映射、有序集和队列的高效实现:
ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、ConcurrentLinkedQueue
这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化。与大多数集集合不同,size方法不必在常量时间内操作。确定这样的集合大小通常需要遍历。JavaSE8引入来mappingCount方法可以把大小作为long返回。
集合返回弱一致性(weakly consistent)的迭代器,者意味着迭代器不一定能反映出它们被构造之后的所有修改,但是,它们不会讲同一个值返回两次,也不会抛出ConcurrentModificationException。
集合如果在迭代器狗仔之后发生改变,java.util包中的迭代器将抛出ConcurrentModificationException。
并发的散列映射表,可高效地支持大量的读者和一定数量的写者。默认情况下,假定可以有多达16个写者线程同时执行,可以有更多的写者线程,但是,如果同一时间多于16个,其他线程将暂时被阻塞。
散列映射将具有相同散列码的所有条目放在同一个“桶”中。如果应用使用的散列函数不当,会导致所有条目放到很少的桶中,从而导致性能低下。
java.util.concurrent.ConcurrentLinkedQueue<E> 5.0
- ConcurrentLinkedQueue<E>(): 构造一个可以被多线程安全访问的无边界阻塞队列
java.util.concurrent.ConcurrentLinkedQueue<E> 6
- ConcurrentLinkedSkipListSet<E>()
- ConcurrentLinkedSkipListSet<E>(Comparator<? super E> comp)
构造一个可以被多线程安全访问的有序集。第二个构造器要求元素实现Comparable接口。
java.util.concurrent.ConcurrentSkipHashMap<K, V> 5.0
java.util.concurrent.ConcurrentSkipListMap<K, V> 6
- ConcurrentHashMap<K, V>
- ConcurrentHashMap<K, V>(int initialCapacity)
- ConcurrentHashMap<K, V>(int initialCapacity, float loadFactor, int concurrencyLevel)
构造一个可以被多线程安全访问的散列映射表。
initialCapacity: 集合的初始容量,默认为16
initialCapacity: 控制调整,如果每一个桶的平均负载超过这个因子,表大小会被重新调整,默认为0.75
concurrencyLevel: 并发写者线程的估计数目
- ConcurrentSkipListMap<K, V>
- ConcurrentSkipListMap<K, V>(Comparator<? super K> comp)
构造一个可以被多线程安全访问的有序映射表,第二个构造器要求键实现Comparable接口。
映射条目的原子更新
ConcurrentHashMap非线程安全代码:
long oldValue = map.get(word);
long newValue = oldValue == null ? 1 : oldValue + 1;
map.put(word, newValue);
原因:没有保证操作的原子性
线程安全代码:
do {
oldValue = map.get(word);
newValue = oldValue == null ? 1 : oldValue + 1;
}while(!map.replace(word, oldValue, newValue));
也可以使用:ConcurrentHashMap<String, AtomicLong>或ConcurrentHashMap<String, LongAddr>
map.putIfAbsent(word, new LongAdder());
map.get(word).increment();
第一个语句确保有一个LongAddr可以完成原子自增。由于putIfAbsent返回映射的值(可能是原来的值,或者是新设置的值),所以两个语句可以组合:map.putIfAbsent(word, new LongAddr()).increment();
computeIfPresent和computeIfAbsent方法,分别只在已经有原值的情况下计算新值,或者只有没有原值的情况下计算新值。
map.computeIfAbsent(word, k -> new LongAdder()).increment();
merge方法有一个参数表示键不存在时使用的初始值,否则就会调用提供的函数来结合原值与初始值。
map.merge(word, 1L, (existingValue, newValue) -> existingValue + newValue)
或者:map.merge(word, 1L, Long::sum)
注意:如果传入compute或merge的参数返回null,将从映射表中删除现有的条目。
警告:使用compute或merge时,提供的函数不能做太多的工作,这个函数运行时,可能会阻塞对映射表的其他更新。当然,这个函数也不能更新映射的其他部分。
对并发散列映射的批操作
JavaSE 8为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安全地执行。批操作会遍历映射,处理遍历过程中找到的元素。无须冻结当前映射的快照。除非你恰好知道批操作运行时映射不会被修改,否则就要把结果看作是映射状态的一个近似。
三种操作:
- 搜索(search)为每个键或值提供一个函数,直到函数生成一个非null的结果。然后搜索终止,返回这个函数的结果。
- 归约(reduce)组合所有键或值,这里要使用锁提供的一个累加函数。
- forEach为所有键或值提供一个函数
每个操作都有4个版本:
- operationKeys: 处理键
- operationValues: 处理值
- operation: 处理键和值
- operationEntries: 处理Map.Entry对象
对于上述各个操作,需要指定一个参数化阀值(parallelism threshold)。如果结果包含的元素多余这个阀值,就会并行完成批操作。如果希望批操作在一个线程中运行,可以使用阀值Long.MAX_VALUE。如果希望用尽可能多的线程进行批操作,阀值使用1。
search:
String result = map.search(threshold, (k, v) -> v > 1000 ? k : null);
forEach:
- 只为各个映射条目提供一个消费者函数:
map.forEach(threshold,
(k, v) -> System.out.println(k + " -> " + v));
- 增加一个转换器函数, 这个函数要先提供,其结果会传递到消费者:
map.forEach(threshold,
(k, v) -> k + " -> " + v, // Transformer
System.out::println // Consumer
);
转换器可以作为一个过滤器,只要转换器返回null, 这个值就会被跳过。例如:
map.forEach(threshold,
(k, v) -> v > 1000 ? k + " -> " + v : null,
System.out::println
);
reduce:
reduce操作用一个累加函数组合其输入。例如:
long sum = map.reduceVlaues(threshold, Long::sum)
也可以提供一个转换器函数。例如计算最长的键的长度:
Integer maxLength = map.reduceKeys(threshold,
String::length, // Transfomer
Integer::max // Accumulator
)
统计有多少个条目的值>1000:
Long count = map.reduceValues(threshold,
v -> v > 1000 ? 1L : null,
Long::sum
);
对于int、long、double输出还有相应的特殊操作,分别有后缀ToInt, ToLong和ToDouble。需要把输入转换为一个基本类型值,并制定一个默认值和一个累加器函数。映射为空时返回默认值。
long sum = map.reduceValuesToLong(
threshold,
Long::longValue, // Transformer to primitive type
0L, // default value for empty map
Long::sum //primitive type accumulator
);
并发集视图
静态方法newKeySet会生成一个Set<K>, 这实际上是ConcurrentHashMap<K, Boolean>的一个包装器。(所有映射值都为Boolean.TRUE, 因为用作一个集,所以不关心具体的值)
Set<String> words = ConcurrentHashMap.<String>newKeySet();
如果原来有一个映射,keySet方法可以生成这个映射的键集,这个集是可变的。如果删除这个集的元素,这个键会从映射中删除。
不过不能向键集增加元素,因为没有相应的值可以增加。Java SE 8为ConcurrentHashMap增加了第二个keySet方法,包含一个默认值,可以在为集增加元素时使用:
Set<String> words = map.keySet(1L);
words.add("Java");
写数组的拷贝
CopyOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合,其中所有修改线程对底层数据进行复制。当构建一个迭代器的时候,它包含一个对当前数组的引用。如果数组后来被修改了,迭代器仍然饮用旧数组,但是集合的数组已经被替换了。因而,旧的迭代器拥有一致的(可能过时的)视图,访问它无须任何同步开销。
并行数组算法
Arrays.parallelSort方法: 可以对一个基本类型值或对象的数组排序。
Arrays.parallelSort(words);
Arrays.parallelSort(words, Comparator.comparing(String::length)); // 提供Comparator
Arrays.parallelSort(values.length / 2, values.length); // 提供边界范围
parallelSetAll方法会有一个函数计算得到的值填充一个数组,这个函数接收元素的索引,然后计算相应位置上的值。
Arrays.parallelSetAll(values, i -> i % 10); //Fills values with 0 1 2 3...
parallelPrefix方法会用对应一个给定结合操作的前缀的累加结果替换各个数组元素。
[1, 2, 3, 4, ...]和x操作:
Arrays.parallelPrefix(values, (x, y) -> x * y)
结果:[1, 1*2, 1*2*3, 1*2*3*4, ...]
较早的线程安全集合
Vector和Hashtable提供了线程安全的动态数组和散列表的实现,但是已经被弃用了。取而代之的是ArrayList和HashMap类。
任何集合类都可以通过使用同步包装器(synchronization wrapper)变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K, V> synchHashMap = Collections.synchronizedMap(new HashMap<K, V>());
如果在另一个线程可能进行修改时要对集合进行迭代,仍然需要使用客户端锁定:
synchronized(synchHashMap){
Iterator<K> iter = synchHashMap.keySet().iterator();
...
}
Callable与Feture
Runnable封装一个异步运行的任务,可以把它想象成为一个没有参数和返回值的异步方法。Callable与Runnable类似,但是有返回值。Callable接口是一个参数化的类型,只有一个方法call。
public interface Callable<V> {
V call() throws Exception;
}
类型参数是返回值的类型。例如,Callable<Integer>表示一个最终返回Integer对象的异步计算。
Future接口具有下面的方法:
public interface Future<V> {
V get() throws ...; //调用被阻塞,直到计算完成
V get(long timeout, TimeUnit unit) throws ...; // 调用被阻塞,直到计算完成或超时,超时抛出TimeoutException
void cancel(boolean mayInterrupt); // 取消该计算。如果计算还没开始,则被取消而且不再开始。如果计算处于运行中,如果mayInterrupt参数为true, 它就被中断。
boolean isCancelled(); //
boolean isDone(); // 如果计算还在进行,返回false, 否则返回true
}
FutureTask包装器是一种非常便利的机制,可将Callable转换成Future和Runnable,它同时实现二者的接口。例如:
Callable<Integer> myComputation = ...;
FutureTask<Integer> task = new FetureTask<Integer>(myComputation);
Thread t = new Thread(task); // it's a Runnable
t.start();
...
Integer result = task.get(); // it's a Future
执行器(Executor)
如果程序中创建了大量的生命周期很短的线程,应该使用线程池(thread pool)。一个线程池包含许多准备运行的空闲线程。将Runnable对象交给线程池,就会有一个线程调用run方法。当run方法退出时,线程不会死亡,而是在池中准备为下一个请求提供服务。另一个使用线程池的理由是减少并发线程的数目。创建大量线程会大大降低性能甚至使虚拟机崩溃。如果有一个会创建许多线程的算法,应该使用一个线程数“固定的”线程池以限制并发线程的总数。
执行器(Executor)类有许多静态工厂方法用来构建线程池。
线程池
newCachedThreadPool: 对于每个任务,如果有空闲线程可用,立即让他执行任务,如果没有,则创建新线程。
newFixedThreadPool: 创建一个具有固定大小的线程池。如果提交任务数多余空闲的线程数,那么把得不到服务的任务放置到队列中,当其他任务完成以后再运行它们。
newSingleThreadExecutor: 大小为1的线程池,有一个线程执行任务。
以上三个方法返回实现了ExecutorService接口的ThreadPoolExecutor类的对象。可以用一下方法之一将一个Runnable对象或Callable对象提交给ExecutorService:
Future<?> submit(Runnable task)
可以使用返回对象来调用isDone、cancel、isCancelled,但是get方法在完成的时候返回null。
Future<T> submit(Runnable task, T result)
返回指定的result对象
Future<T> submit(Callbale<T> task)
提交一个Callable,并且返回的Future对象将在计算结果准备好的时候得到它。
当用完一个线程池的时候应该关闭它:
shutdown: 启动线程池的关闭序列,被关闭的执行器不再接受新的任务。当所有任务都完成后,线程池中的线程死亡。
shotdownNow: 取消尚未开始的所有任务并试图中断正在运行的线程。
使用链接池时应该做的事:
- 调用Executors类中的静态方法newCachedThreadPool或newFixedThreadPool
- 调用submit提交Runnable或Callable对象
- 如果想要取消一个任务,或如果提交的事Callable对象,那就要保存好返回的Future对象
- 当不再条任何任务时,调用shutdown
预定执行
ScheduledExccutorService接口具有为预定执行或重复执行任务设计的方法。他是一种允许使用线程池极致的java.util.Timer的泛化。Executors类的newScheduledThreadPool和newSingleThreadScheduledExecutor方法将返回实现了ScheduledExecutorService接口的对象。可以预定Runnable或Callable在初始的延迟之后执行一次,也可以预定一个Runnable对象周期性的运行。
控制任务组
在执行器中使用shutdownNow方法取消所有任务。
invokAny方法提交所有对象到一个Callable对象的集合中,并返回某个已完成了的任务的结果,但无法知道返回的究竟是哪个任务的结果。对于搜索问题,如果你接受任何一种解决方案的话,就可以使用这个方法。
invokAll方法提交所有对象到一个Callable对象集的合中,并返回一个Future对象列表,代表所有任务的解决方案。当计算结果可获得时,可以像下面这样对结果进行处理:
List<Callable> tasks = ...;
List<Future<T>> results = executor.invokAll(tasks);
for(Future<T> result : results){
processFurther(result.get());
}
这个方法的缺点时如果第一个任务需要很长时间,则可能不得不进行等待。
如果需要将结果按照可获得的顺序保存起来,可以使用ExecutorCompletionService来进行排列。用常规方法获得一个执行器,然后构建一个ExecutorCompletionService,提交任务给完成任务(completion service)。该任务管理Future对象的阻塞队列,其中包含已提交任务的执行结果(当这些结果成为可用时)。
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());
}
java.util.concurrent.ExecutorService
- T invokAny(Collection<Callable<T>> tasks)
- T invokAny(Collection<Callable<T>> tasks, long timeout, TimeUnit unit)
执行给定的任务,并返回其中一个任务的结果。第二个方法如果超时,则抛出TimeoutExcepiton。
- List<Future<T>> invokAll(Collection<Callable<T>> tasks)
- List<Future<T>> invokAll(Collection<Callable<T>> tasks, long timeout, TimeUnit unit)
执行给定的任务,返回所有任务的结果。第二个方法如果超时,则抛出TimeoutExcepiton。
java.util.concurrent.ExecutorCompletionService
- ExecutorCompletionService(Executor e)
构建一个执行器完成服务,来收集给定执行器的结果。
- Future<V> submit(Callable<V> task)
- Future<V> submit(Runnable task, V result)
提交一个任务给底层的执行器
- Future<V> take()
移除下一个已完成的结果,如果没有任何已完成的结果可用则阻塞。
- Future<V> poll()
- Future<V> poll(long time, TimeUnit unit)
移除下一个已完成的结果,如果没有任何已完成结果可用,则返回null。第二个方法将等待给定的时间。
Fork-Join框架
有些应用使用了大量线程,但其中大多数都是空闲的,如Web服务器可能会为每个链接使用一个线程。另外一些应用可能会对每个处理器内核分别使用一个线程,来完成计算密集型任务,如图像或视频处理。JavaSE 7引入了Fork-Join框架,专门用来处理后一种应用。假设有一个处理任务,它可以很自然地分解为自任务,如下所示:
if ( problemSize < threshold ){
slove problem directly
} else {
break problem into subproblems
recursively solve each subproblem combine the results
combine the results
}
在后台Fork-Join框架使用了一种智能方法来平衡可用工作线程的负载,这种方法称为工作密取(work stealing)。每个工作线程都有一个双端队列(deque)来完成任务。一个工作线程将自任务压入双端队列的对头(只有一个线程可以访问对头,所有不需要加锁)。一个工作线程空闲时,它会从双端队列的队尾“密取”一个任务。由于大的自任务都在队尾,这种“密取”现象很少出现。
可完成Future
Java SE 8的CompletableFuture类提供了一种候选方法,与时间处理器不同,可完成Future可以组合(Composed)。
同步器
java.util.concurrent包包含了几个能帮助我们管理相互合作的线程集的类,这些机制具有为线程之间的共用集结点模式提(common rendezvous partterns)供的预置功能。如果一个相互合作的线程集满足这些行为模式之一,那么应该冲用合适的类库而不要试图土工手工的锁与条件的集合。
信号量
概念上讲,一个信号量管理许多许可证。为了通过信号量,线程调用acquire请求许可。实际上没有实际的许可对象,信号量仅维护一个计数。许可的数目是固定的,由此限制了通过的线程数量。其他线程可以调用release释放许可,而且许可不是必须由获取它的线程释放。任何线程都可以释放任意数量的许可,这可能会增加许可数目以致于超出初始许可数目。
通常,信号量不必映射到通用场景。
倒计时门栓
一个倒计时门栓(CountDownLatch)让一个线程集等待只到计数变为0。倒计时门栓是一次性的,一旦计数为0,就不能再重用。
障栅
CyclicBarrier实现了一个集结点(rendezvous)成为障栅(barrier)。
考虑大量线程运行在一次计算的不同部分的情形。当所有部分都准备好时,需要把所有结果都准备在一起。当一个线程完成了它的那部分工作后,就让这个线程运行到障栅处,一旦所有线程都到达了障栅,障栅就撤销,线程就可以继续运行。
构造一个障栅,并给出参与到线程数:
CyclicBarrier barrier = new CyclicBarrier(ntheads);
每个线程做一些工作,然后在障栅上调用await:
public void run (){
dowork();
barrier.await();
...
}
await方法可以有一个可选的超时参数:barrier.await(100, TimeUnit.SECONDS);
如果任何一个在障栅上等待的线程离开了障栅(如调用await设置了超时时间,或者线程被中断),那么障栅就被破环了,这种情况下,其他所有线程的await方法抛出BrokenBarrierException,那些已经在等待的线程将立即终止await调用。
可以提供一个可选的障栅动作,当所有线程到达障栅的时候,就会执行这一动作:
Runnable barrierAction = ...;
CyclicBarrier barrier = new CyclicBarrier(ntheads, barrierAction);
该动作可以收集那些单个线程的运行结果。
障栅可以在所有等待线程释放后被重用。
Phaser类类似于障栅,但允许改变不同阶段中参与线程的个数。
交换器
当两个线程在同一个数据缓冲区的两个实例上工作时,就可以使用交换器(Exchanger)。典型的情况是,一个线程向缓冲区填入数据,另一个线程消费这些数据,当它们都完成以后,相互交换缓冲区。
同步队列
同步队列是一种将生产者与消费者线程配对的机制。当一个线程调用SynchronousQueue的put方法时,它会阻塞只到另个线程调用take方法为止,反之亦然。与Exchanger不同,数据仅沿一个方向传递,从生产者到消费者。
即使SynchronousQueue实现了BlockingQueue接口,从概念上讲,它依然不是一个队列,它不包含任何元素,它的size方法总是返回0。