Java> Java核心卷读书笔记 - 并发&线程
什么是线程
一个程序执行多个任务,每个任务称为一个线程(Thread),是线程控制简称。
可以同时运行 > 1个线程的程序称为多线程(multithreaded)。
多线程与多进程区别
- 每个进程都有自己独立的一套变量,而线程之间共享数据。进程是OS分配资源的最小单位,而线程是OS调度的最小单位。
- 共享变量使线程之间通信更有效,更容易,不过也带来了安全风险。
- 线程更轻量,创建和撤销一个线程比启动新进程的开销要小得多。
使用线程给其他任务提供机会
一个单独的线程中执行一个任务的简单过程:
- 任务移到Runnable的run方法中;
- 由Runnable创建Thread对象;
- 启动线程;
示例:
// 步骤1
Runnable r = ()-{
task code
}
// 步骤2
Thread t = new Thread(r);
// 步骤3
r.start();
注意:直接调用Thread或Runnable的run方法只会执行其任务代码,而不会真正创建新线程,创建线程请使用Thread的start方法。
中断线程
中断线程指其他线程通过当前线程的Thread.interrupt()方法向当前线程发送中断请求,请求停止当前其他工作,转入处理中断请求。至于什么时候响应中断,如何处理中断由程序自行决定。通常,可以用中断线程来终止线程。
线程终止的情形:
1)线程run方法最后一条语句执行完,由return返回;
2)出现没有捕获的异常时,抛出异常,线程将终止;
3)interrupt方法可以用于请求终止线程(非强制,需要程序设计决定);
注:java早期有stop方法可以终止线程,但是目前已被弃用。也就是说,没有强制线程终止的方法,只有请求方法。
对于情形4),调用Thread的interrupt()将导致中断状态置位,可以用isInterrupted()查询。前提是线程未阻塞(如调用Thread.sleep处于睡眠状态的线程),阻塞时无法检测中断状态,查询会导致产生InterruptedException异常。
下面是一个简单是将中断当做一个终止的请求的示例:
Runnable r = ()->{
try {
// 情形3:处理interrupt请求
while(!Thread.currentThread().isInterrupted() && more work to do ){
do more work
}
}
catch (InterruptedException e)
{
//情形2: 线程在slee或者wait时被中断
}
finally {
}
// 情形1:退出run方法,线程终止
}
中断线程API
// java.lang.Thread 1.0
void interrupt(); // 向线程发送中断请求。线程的中断状态将被设置为true。如果目前线程被一个sleep调用阻塞,那么,InterruptedException异常被抛出
static boolean isInterrupted(); // 测试当前线程是否被中断。副作用:会导致当前线程中断状态置为false
boolean isInterrupted(); // 测试线程是否被中断。无副作用
static Thread currentThread(); // 返回当前线程所代表的Thread对象
线程状态
6大状态(注意与操作系统进程状态区别):
- New(新建)
- Runnable(可运行)
- Blocked(被阻塞)
- Waiting(等待)
- Timed waiting(计时等待)
- Terminated(被终止)
测试方法:Thread的getState()查询线程状态
新建线程New
new Thread(r)之后,线程start启动运行之前,线程处于新建状态。
可运行线程Runnable
调用start之后,线程处于Runnable状态。是否运行取决于OS是否有提供运行资源,对应于进程状态的就绪态或运行态。
被阻塞线程Blocked和等待线程Waiting
线程处于被阻塞状态和等待状态时,暂时不活动,消耗资源最少,直到激活。
进入非活动状态情形:
- 试图获取的内部对象锁被其他线程持有,进入阻塞状态,需要等到锁被释放重新进入可运行状态;
- 当线程等待另外一个线程调度器的一个条件时,进入等待状态。如调用Object.wait,Thread.join方法,或者等待java.util.concurrent库中的Lock或Condition时;
- 调用带有超时参数的方法,将导致线程进入计时等待状态,直到超时或接收到适当通知。带有超时参数方法:Thread.sleep,Object.wait,Thread.join,Lock.tryLock和Condition.wait的计时版本;
被终止线程
前面中断线程已经提到过,线程终止情形有三种,不过本质是2种:
- run方法退出而自然死亡;
- 未捕获异常导致run意外死亡;
线程状态切换
状态图
线程状态API
void join(); // 等待终止指定的线程
void join(long millis); // 等待指定的线程死亡或经过指定的毫秒数
Thread.State getState(); // 5.0 得到线程6状态之一
void stop(); // 停止该线程,已经废弃
void suspend(); // 暂停这一线程执行,已经废弃
void resume(); // 恢复线程,仅仅用在调用suspend()方法之后
线程属性
线程属性包括:线程优先级、守护线程、线程组及处理未捕获异常的处理器。
线程优先级
java每个线程都有一个优先级,默认继承自父线程的优先级。可用setPriority()修改。
Thread类中,线程优先级范围:最低优先级MIN_PRIORITY = 1,最高优先级MAX_PRIORITY=10。NORM_PRIORITY = 5
在宿主主机平台中的优先级个数,取决于具体的系统。不要将程序构建功能的正确性依赖于优先级。
线程优先级API
// java.lang.Thread 1.0
void setPriority(int newPriority); // 设置线程优先级,范围:Thread.MIN_PRIORITY到Thread.MAX_PRIORITY之间。通常使用NORM_PRIORITY
static int MIN_PRIORITY; // 最低优先级,值为1
static int MAX_PRIORITY; // 最高优先级,值为10
static int NORM_PRIOPRITY; // 默认优先级,值为5
static void yield(); // 向调度器暗示愿意放弃当前使用CPU机会,可能导致当前线程让出CPU资源。具体是否得到放弃执行,取决于调度器和当时的环境(是否有其他优先级更高或相等的可运行状态线程)
守护线程
守护线程唯一用途:为其他线程提供服务。如计时线程,定时发送“计时器嘀嗒”信号给其他线程或清空过时的高速缓存项的线程。
只剩守护进程时,虚拟机会退出。
将普通线程转化为守护线程:
t.setDaemon(true); // t是一个线程对象,该方法必须在线程启动(start)前调用
注意:不要在守护线程中,去访问固有资源,如文件、数据库,因为守护线程可能会在任何时候中断。
未捕获异常处理器
由于run方法不能抛出任何非受检异常(uncheckedException,即RuntimeException和Error),而随着异常产生,线程会死亡,线程外部无法捕获run内部产生的异常。
可以通过实现Thread.UncaughtExceptionHanlder接口方法,将异常传递到处理器的uncaughtException()进行处理。
@FunctionalInterface // 表明接口是函数式接口,接口方法只能有一个,否则编译会报错
public interface UncaughtExceptionHandler { // Thread内部定义的接口
void uncaughtException(Thread t, Throwable e); // 实现类中必须实现的处理未捕获异常的方法
}
安装处理器
为线程安装处理器,有2种方式:使用Thread的setUncaughtExceptionHandler()为任意线程安装处理器;用Thread.setDefaultUncaughtExceptionHandler()为所有线程安装一个默认的处理器。
不安装默认的处理器,默认的处理器就为空。不为独立线程安装处理器,处理器就是该线程的ThreadGroup对象。
线程组ThreadGroup
线程组是一个可以统一管理的线程集合。默认情况下,创建的所有线程属于相同的线程组,不过也可能建立别的组。建议不要在自己的程序中使用线程组。
线程组ThreadGroup类实现Thread.UncaughtExceptionHandler接口,实现了的uncaughtException方法操作:
1)如果该线程组有父线程组,那么父线程组的uncaughtException方法被调用;
2)否则,如果Thread.getDefaultExceptionHandler方法返回非空处理器,则调用该处理器;
3)否则,如果Throwable是ThreadDeath的一个实例,什么都不做;
4)否则,线程的名字以及Throwable的栈轨迹被输出到System.err上。
ThreadGroup实现Thread.UncaughtExceptionHandler接口方法如下:
public void uncaughtException(Thread t, Throwable e) {
if (parent != null) {
parent.uncaughtException(t, e);
} else {
Thread.UncaughtExceptionHandler ueh =
Thread.getDefaultUncaughtExceptionHandler();
if (ueh != null) {
ueh.uncaughtException(t, e);
} else if (!(e instanceof ThreadDeath)) {
System.err.print("Exception in thread \""
+ t.getName() + "\" ");
e.printStackTrace(System.err);
}
}
}
未捕获异常API
// java.lang.Thread 1.0
static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler) 5.0
// 设置未捕获异常的默认处理器
static Thread.UncaughtExceptionHandler getDefaultUncaughtExceptionHandler() 5.0
// 获取未捕获异常的默认处理器
void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler) 5.0
// 设置未捕获异常的处理器,如果没有安装,则将线程组对象作为处理器
Thread.UncaughtExceptionHandler getUncaghtExceptionHandler() 5.0
// 获取未捕获异常的处理器
// java.lang.Thread.UncaughtExceptionHandler 5.0
void uncaughtException(Thread t, Throwable e)
/* 当一个线程因未捕获的异常而终止,按规定要将客户报告记录到日志中
t 由于未捕获异常而终止的线程
e 未捕获的异常对象
*/
// java.lang.ThreadGroup 1.0
void uncaughtException(Thread t, Throwable e)
// 见上文提到的父线程实现的uncaughtException步骤
同步
2个线程同时修改同一个对象,会产生讹误的对象,数据可能不再有效,从而产生“脏数据”。这种情况通常成为竞争条件(race condition)。为避免多线程引起对共享数据的讹误,相应进行同步存取。
例如,生产者和消费者问题,银行转账问题。
锁对象
2种防止代码块受并发访问的干扰的机制:synchronized关键字和ReentrantLock类。
为便于理解,先讲ReentrantLock类,再讲synchronized关键字
ReentrantLock类
ReentrantLock属于Lock接口的具体实现类,有以下关键特点:
- 锁用来保护代码片段,任何时刻只有有一个线程执行被保护的代码; -- 排他性
- 锁可以管理试图进入被保护代码段的线程; -- 锁与线程(取得锁/放弃锁)
- 锁可以拥有一个或多个相关的条件对象;-- 预防死锁
- 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程; -- 条件对象与线程(放弃锁/唤醒)
由ReentrantLock保护代码块的基本结构,如下:
ReentrantLock locker= new ReentrantLock(); // 创建ReentrantLock对象
lokcer.lock(); // 加锁
try {
// 临界区
}
finally {
lokcer.unlock(); // 释放锁,如果有try语句,必须将unlock操作放在finally中确保能正常释放锁资源,否则将造成等待该锁资源的线程无限期等待
}
注意:如果使用锁,就不能使用带资源的try语句。
对象锁API
// java.util.concurrent.locks.Lock 5.0
void lock() // 获取锁
void unlock() // 释放锁
// java.util.concurrent.locks.ReentrantLock 5.0
ReentrantLock() // 构建可以用来保护临界区的可重入资源
ReentrantLock(boolean fair) // 构建待公平策略的锁,公平锁一般偏爱等待时间最长的线程,不过会大大降低性能。 默认不使用公平锁
条件对象
如果获得锁的线程发现继续执行下去,其他条件不满足怎么办? 比如转账时,线程A取得了锁对象,但是发现余额不足,不足以继续执行转账动作。
此时,可以利用条件对象Condition,条件不满足时,提供Condition的await()方法主动释放锁,进入阻塞状态,让别的线程有机会执行;当别的线程执行完转账动作时,在释放锁前,通过Condition的signal()或signalAll()方法激活阻塞线程,尝试重新取得锁对象,进而继续执行临界区代码。
// 一个银行转账的例子
Lock locker = new TeentrantLock();
Condition sufficientFunds = locker.newCondition();
public void transfer (int from, int to, double amount) throws InterruptedException {
locker.lock();
try {
while (account[from] < amount) { // 余额不足
sufficientFunds .await();
}
// 执行转账操作
account[from] -= amount;
account[to] += amount;
sufficientFunds.signalAll(); // 唤醒其他阻塞线程重新竞争锁对象
}
finally {
locker.unlock();
}
}
条件对象API
// java.lang.concurrent.locks.Lock 5.0
Condition newCondition() // 返回一个与该锁相关的条件对象
// java.lang.concurrent.locks.Condition 5.0
void await() // 将该线程放到条件的等待集中
void signalAll() // 解除该条件的等待集中的所有线程的阻塞状态
void signal() // 从该条件的等待集中随机选择一个线程,解除其阻塞状态
sychronized 关键字
关键字自动提供一个锁以及相关的“条件”,对于大多数需要显示锁的情况很方便,不必用Lock和Condition对代码段进行复杂的锁定制。因为sychronized机制在每个对象内部嵌入了一个内部锁。
如果一个方法用sychronized声明,则该内部锁会保护整个方法。如
public void sychronized void func() {
函数主体
}
等价于
public void func() {
this.instrinsicLock.lock();
try {
函数主体
}
finally {
this.instrinsicLock.unlock();
}
}
内部锁只有一个相关条件,wait让线程放弃锁资源进入阻塞,notify/notifyAll解除线程阻塞状态。用java实现上面银行转账的例子:
public synchronized void transfer (int from, int to, double amount) {
while(account[from] < amount) // 存款不够,也就不足以继续执行临界区代码
wait(); // wait和notify, notifyAll是Object的final方法,await/signal/signalAll是Condition的方法,都是让线程放弃锁资源,进入阻塞状态
account[from] -= amount;
account[to] += amount;
notifyAll(); // 唤醒之前进入阻塞状态的线程
}
如果一个static方法用synchronized声明,方法将获得类对象的内部锁,方法被调用时,对应.class被锁住,其他线程也无法调用类的任何静态方法。
内部锁和条件的局限:
- 不能中断一个正在获得锁的线程;
- 试图获得锁不能设置超时;(可能无限期等待,而不退出)
- 每个锁仅有一个单一条件,可能不够;
如果选择使用Lock和Condition,还是synchronized同步方法?建议:
- 最好都不使用;
- 如果sychronized可以,优先使用,因为简单,减少编写错误几率;
- 如果特别需要Lock/Condition独有特性时,才使用;
sychronized关键字相关API
void notifyAll() // 解除所有在该对象上调用wait方法的线程进入阻塞状态
void notify() // 随机解除一个线程阻塞状态
void wait() // 导致调用wait的方法的线程进入阻塞状态
void wait(long millis) // 线程进入计时等待状态, millis:毫秒数
void wait(long millis, int nanos) // 线程进入计时等待状态,millis:毫秒数;nanos:纳秒数
同步阻塞
线程通过调用同步方法(sychronized修饰的方法)获得锁,也可以锁定一个对象来进入同步阻塞。
例如,直接修饰对象
public Bank{
private double[] accounts;
private Object lock = new Object();
public void transfer(int from, int to, double amount) {
sychronized(lock) {// 获得对象lock的锁
accounts[from] -= amount;
accounts[to] += amount;
}
}
}
监视器Monitor
监视器是一种同步机制,通常描述为一个对象,具有特点:
- 监视器类的所有域和对象方法都是private的(除了构造函数);
- 每个监视器类的对象都有一个相关的锁(java对象内部锁);
- 使用该锁对所有方法进行加锁;
- 该锁可以有多个任意相关条件;
监视器有关知识,可以参考这篇文章java的monitor对象
Volatile域
- 多处理器能暂时在本地缓存或者寄存器保存内存中的值,运行在不同处理器上的线程,可能在同一位置取到不同的值;
- 多线程可以改变指令顺序,以使吞吐量最大。编译器假定的内存值仅仅在代码中有显式的修改指令时,才会改变,然而另外一个线程可能会修改内存的值。
如果向一个变量写入值,而这个变量接下来可能会被另外一个线程读取,或者从一个变量读值,而这个变量可能是之前被另外一个线程写入的,此时必须使用同步。
实例域的同步可以使用加锁机制,也可以使用volatile免加锁。volatile强制CPU从内存中读取最新的值,并不保证操作的原子性,相反可以被中断。
假设同步对一个对象的boolean 变量done进行同步读写,
使用synchronized加锁:
private boolean done;
public synchronized boolean isDone() { return done; }
public synchronized void setDone(){ done = true;}
使用volatile关键字:
private volatile boolean done;
public boolean isDone() {return done; }
public void setDone() { done = true; }
final变量
除了加锁和volatile修饰,final是第三种方法确保安全的访问一个共享域。final是让变量成为常量,程序无法直接修改final修饰的变量,否则会编译报错。
/*
* 其他线程在构造之后才会看到accounts的值,如果不用final修饰,可能看到null值
*/
final Map<String, Double> accounts = new HashMap<>();
原子性
如果对共享变量的操作只有读取和赋值,那么可以将这些变量声明为volatile。
如果要对共享变量以原子方式进行高级的更新操作,比如自增,自减,可以使用java.util.concurrent.atomic包中的原子类型。
public static AtomicLong nextNumber = new AtomicLong(); // 默认值0
// In some thread...
long id = nextNumber.incrementAndGet(); // 以原子方式自增nextNumber ,然后返回自增后的值,确保自增过程不被中断
java.util.concurrent.atomic包中的原子类型只能确保其方法是原子的,外部对其对象的方法嵌套调用不能保证是原子性的,如下面就不是原子性的:
public static AtomicLong largest = new AtomicLong();
// In some thread...
largest.set(Math.max(largest.get(), observed)); // 错误,不能保证largest的更新是原子性的,因为get返回之后,largest值可能会被别的线程改变
针对这种情况,要确保值的原子性,可以使用compareAndSet + 循环结构:
do {
oldValue = largest.get();
newValue = Math.max(largest.get(), observed);
}while(!largest.compareAndSet(oldValue, newValue)); // 只有oldValue和newValue相等时,才会更新largest值
Java 8可以不使用上面的循环结构,转而使用lambda表达式:
largest.updateAndGet(x -> Math.max(x, observed));
或
largest.accumlateAndGet(observed, Math::max);
如果存在大量线程竞争访问相同原子值(写操作较多读操作较少),使用AtomicLong等原子类型性能会大幅下降,可以考虑使用LongAdder和LongAccumulator等类。
LongAdder包括多个变量(加数),其总和为当前值,可以用不同线程更新不同加数,线程数增加时加数的数量也会自动增加。当所有的工作都完成后,才需要总和值。这种情况下使用LongAdder性能会显著提高。LongAdder会单独为每个线程分配一个Cell[]元素,每个线程只对自己对应那个元素进行操作,只有在求sum的时候,才会将所有Cell[]元素进行求和,这样确保最终的sum值是安全的。
关于LongAddr详细原理,这篇文章解释的比较通俗面试官问我LongAdder,我惊了...
LongAccumulator是将LongAdder这种思想(多个线程更新多个加数,最后再统一更新总和值)推广到任意累加操作。
LongAdder用法:
final LongAdder adder = new LongAdder();
// in some thread..
adder.increment(); // +1
// or adder.add(100);
LongAccumulator用法:
LongAccumulator adder = new LongAccumulator(Long::sum, 0);
// In some thread...
adder.accumulate(value); // 加入新值
在内部,这个累加器包含变量a1,a2,...,an,初值0.
调用adder.accumulate(value),其中一个变量会以原子方式更新为ai = ai op value,这个例子中op是加法。也就是说,ai = ai + value。
最后get的结果是a1 op a2 op ... op an,例子就是计算累加器总和:a1 + a2 + ... + an
死锁
锁和条件不能解决多线程中的所有问题。锁主要是解决资源竞争问题,临界资源访问问题,同步问题,确保数据的有效性,但也是产生死锁的来源之一。
例如:
账户1:$200
账号2:$300
线程1:从账户1转账$300到账户2
线程2:从账户2转账$400到账户1
线程1、线程2都会被阻塞,因为账户1/账户2中的余额都不足以进行转账,2个线程都无法继续执行下去,进入阻塞状态。
Java没有任何语言层级的东西能避免死锁,需要根据实际应用场景小心设计程序,确保不会死锁。
线程的局部变量
线程之间共享变量,可能导致长期阻塞或死锁,需要尽量避免共享变量,一种有效方法是使用ThreadLocal辅助类为各个线程提供各自的实例。
如SimpleDateFormat线程不安全,假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
如果2个线程都执行下面操作,结果可能混乱,因为SimpleDateFormat内部数据结构可能因并发执行而遭破坏。如果使用同步,开销很大,性能可能大大降低。也可以在需要时构造一个局部SimpleDateFormat对象,不过这样也很浪费。
dateFormat.format(new Date());
解决办法:使用ThreadLocal为每个线程创建实例,每个线程用拥有的实例进行访问
// 为每个线程创建实例
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辅助类为每个线程提供一个单独的生成器。
// 2种方法
// 方法1:通用方法, 使用ThreadLocal
public static final ThreadLocal<Random> r = ThreadLocla.withInitial(() -> new Random() );
int a = r.get().nextInt(100);
// 方法2:使用便捷类ThreadLocalRandom
int random = ThreadLocalRandom.current().nextInt(100); // ThreadLocalRandom.current()返回特定于当前线程的Random类实例
线程的局部变量API
// java.lang.ThreadLocal<T> 1.2
T get() // 得到这个线程的当前值,如果首次调用,会调用initialize来得到这个值
protected initialize() // 应该覆盖这个方法提供一个初始值。默认返回null
void set(T t) // 为这个线程设置一个新值
void remove() // 删除对应这个线程的值
static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) 8 // 创建一个线程局部变量,初值通过调用给定的supplier生成
// java.util.concurrent.ThreadLocalRandom 7
static TreadLocalRandom current() // 返回特定于当前线程的Random类实例
锁测试与超时
tryLock试图申请一个锁,成功返回true,失败返回false
if(myLock.tryLock()){ // <=> lockInterruptibly(), 超时无限的tryLock
// 线程拥有锁
try{...}
finally{
myLock.unlock();
}
}
else {
// 线程未拥有锁,做其他事情
}
if(myLock.tryLock(100, TimeUnit.MILLISECONDS) { // 也可以设置tryLock超时时间
...
}
lock方法无法被中断。中断线程之前一直处于阻塞状态。如果出现死锁,lock方法将无法终止。
如果调用带有超时参数的tryLock,那么如果线程在等待时被中断,将抛出InterruptedException异常,这是重要的允许打破死锁的特性。
条件也可以设置超时等待时间
myCondition.await(100, TimeUnit.MILLISECONDS); // 如果希望被中断后没有及时获得锁,还是继续等待,可以使用awaitUninterruptibly
锁测试与超时API
// java.util.concurrent.locks.Lock 5.0
boolean tryLock() // 尝试获得锁而没有发生阻塞。如果成功返回true,失败返回false。会抢夺可用锁
boolean tryLock(long time, TimeUnit unit) // 尝试获得锁,阻塞时间不会超过给定的值。如果成功返回true
void lockInterruptibly() // 获得锁,但是会不确定地发生阻塞。如果线程被中断,抛出InterruptedException异常
// java.util.concurrent.locks.Condition 5.0
boolean await(long time, TimeUnit unit) // 进入该条件的等待集,直到线程从等待集中移出或等待了指定的时间之后才解除阻塞。如果超时返回就返回false,否则返回true
void awaitUninterruptibly() // 进入该条件等待集,直到线程从等待集中移出才解除阻塞。如果线程被中断,不会抛出InterruptedException异常
读写锁
java.util.concurrent.locks包定义了2个锁类:ReentrantLock类和ReentrantReadWriteLock类。
ReentrantLock类:适用于多线程多写操作(通用);
ReentrantReadWriteLock类:适用于多线程多读少写情形;
使用读/写锁步骤:
// 1. 构造一个ReentrantReadWriteLock类对象
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 2. 抽取读和写锁
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();
// 3. 对所有的获取方法加读锁
public double getBalance() {
readLock.lock();
try{...}
finally{ readLock.unlock(); }
}
// 4. 对所有的写方法加写锁
public void transfer(int from, int to, double amount) {
writeLock.lock();
try{...}
finally{ writeLock.unlock(); }
}
读写锁API
Lock readLock() // 得到一个可被多个读操作共用的读锁,但会排斥所有的写操作
Lock writeLock() // 得到一个写锁,排斥所有其他的读操作和写操作
为什么弃用stop和suspend方法
初始java版本定义了stop方法来终止一个线程,suspend方法来阻塞线程直至另一个线程调用resume恢复线程。这3个控制线程的方法不安全。
stop缺陷:会导致对象处于不一致状态。比如从一个账户转账至另外一个账户,转账过程中被stop终止,钱款已经转出,但是目标账户却没有收到,银行对象就被破坏了。因为被终止的线程锁已经释放了。
suspend缺陷:suspend不会破坏对象,但是容易造成死锁。比如一个持有锁的线程阻塞,而负责resume唤醒阻塞线程的线程,如果也请求锁,就会形成死锁。
阻塞队列
使用队列,可用安全的从一个线程向另外一个线程传递数据。例如,银行转账将转账指令对象插入一个队列中,而不是直接访问银行对象。另一个线程从队列中取出指令执行转账。只有该线程能访问该内部银行对象。因此不需要同步,不够线程安全队列需要考虑锁和条件。
当试图向满队列添加元素,或者从空队列移出元素时,阻塞队列(blocking queue)将导致线程阻塞。
阻塞队列作用:
- 传递数据;
- 平衡负载;
阻塞队列的方法:
add 添加一个元素。 如果队列满,则抛出IllegalStateException异常
element 返回队列的头元素。 如果队列空,抛出NoSuchElementException异常
offer 添加一个元素并返回true。如果队列满,返回false
peek 返回队列的头元素。 如果队列空,返回null
poll 移出并返回队列的头元素。 如果队列空,返回null
put 添加一个元素。 如果队列满,则阻塞
remove 移出并返回头元素。 如果队列空,则抛出NoSuchElementException异常
take 移出并返回头元素。如果队列空,则阻塞
阻塞队列方法分3类,取决于队列满或空时的响应方式。
- 如果将队列当线程管理工具来用,将用到put和take方法;
- 当试图向满的队列中添加或空的队列中移出元素时,add/remove和element抛出异常;
- 多线程环境使用offer, poll, peek方法替代2提到会抛出异常的方法,因为队列可能在任何时候空或满。
注意:不能向阻塞队列插入null值,因为poll和peek方法返回空来指示失败。
offer和poll方法还可以带超时参数,如
boolean success = q.offer(x, 100, TimeUnit.MILLISECONDS); // 尝试在100ms内在队列尾部插入一个元素。成功返回true;失败返回false
Object head = q.poll(100, TimeUnit.MILLISECONDS); // 尝试100ms内移出队列头元素。成功返回头元素;失败返回null
不带超时参数时,offer、poll等效于put、take。
java.util.concurrent包提供的阻塞队列变种:
- LinkedBlockingQueue,容量没有上界,不过也可选择指定最大容量。是一个双端队列版本;
- ArrayBlockingQueue,构造时需要指定容量,且有一个可选参数来指定是的需要公平性。若设置了公平参数,则等待时间最初的线程优先处理。不过,公平性会降低性能,只有非常需要的时候才使用。
- PriorityBlockingQueue带优先级的队列,不是FIFO队列,底层数据结构是堆(大顶堆/小顶堆)。元素按优先级顺序被移出。而非加入队列时间。队列没有容量上限,对空队列取元素会阻塞。
- DelayQueue 包含了实现Delayed接口的对象。元素只有在延迟用完的情况下,才能从DelayQueue移除。必须实现compareTo方法,以便对元素排序。
interface Delay extends Comparable<Delayed>
{
long getDelay(TimeUnit unit); // 返回对象的残留延迟,负值表示延迟已经结束。
}
- TransferQueue 允许生产者线程等待,直到消费者就绪可用接收一个元素
如果生产者调用q.transfer(item)
,会阻塞,直到另外一个线程将元素(item)删除。LinkedTransferQueue类实现了这个接口。
阻塞队列数据结构及API
// java.util.concurrent.ArrayBlockingQueue<E> 5.0
ArrayBlockingQueue(int capacity);
ArrayBlockingQueue(int capacity, boolean fair);
// 构造一个带有指定容量和公平性设置的阻塞队列,用循环数组实现
// java.util.concurrent.LinkedBlockingQueue<E> 5.0
// java.util.concurrent.LinkedBlockingDeque<E> 6
LinkedBlockingQueue();
LinkedBlockingDeque();
// 构造一个无上限的阻塞队列或双向队列,用链表实现
LinkedBlockingQueue(int capacity);
LinkedBlockingDeque(int capacity);
// 根据指定容量构建一个有限的阻塞队列或双向队列,用链表实现
// java.util.concurrent.DelayQueue<E extends Delay> 5.0
DelayQueue();
// 构造一个包含Delayed元素的无界的阻塞时间有限的阻塞队列。只有已超时的元素可用从队列中移出。
// java.util.concurrent.Delayed 5.0
long getDelay(TimeUnit unit); // 得到该对象的延迟,用给定的时间的那位进行度量
// java.util.concurrent.PriorityBlockingQueue<E> 5.0
PriorityBlockingQueue();
PriorityBlockingQueue(int initialCapacity);
PriorityBlockingQueue(int initialCapacity, Comparator<? super E> comparator);
// 构造一个无边界阻塞优先队列,用堆实现。
// initialCapacity 优先队列的初始容量,默认11
// comparator 用来堆元素进行比较的比较器,如果没有指定,则元素必须实现Comparable接口
// java.util.concurrent.BlockingQueue<E> 5.0
void put(E element); // 添加元素,在必要时阻塞
E take(); // 移除并返回头元素,必要时阻塞
boolean offer(E element, long time, TimeUnit unit);
// 添加给定的元素,如果成功返回true,必要时阻塞,直至元素已经被添加或超时。
E poll(long time, TimeUnit unit);
// 移除并返回头元素,必要时阻塞,直至元素可用或超时用完。失败返回null
// java.util.concurrent.TransferQueue<E> 7
void transfer(E element);
boolean tryTransfer(E element, long time, TimeUnit unit);
// 传输一个值,或者尝试在给定的时间内传输这个值,该调用将阻塞,直到另一个线程将元素删除。第二个方法成功时返回true
线程安全的集合
多线程并发修改数据结构,很容易造成数据混乱。实现线程安全的数据结构有2种方式:1)加锁保护共享数据结构;2)选择线程安全的的数据结构;
高效映射、集和队列
java.util.concurrent提供了映射、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、ConcurrentLinkedQueue。
这些集合使用复杂的算法,通过允许并发的访问数据结构的不同部分,来使竞争极小化。size方法不必在常量时间内操作(通常需要遍历)。
注:有些应用使用并发散列映射,数据量庞大 > 20亿,32bit的size()无法返回其值,可用使用Java8引入的mappingCount方法(使用long)返回其大小。
集合返回弱一致性(weakly consistent)的迭代器,不一定能返回构造后所有的修改,但是不会将同一个值返回两次,也不会抛出ConcurrentModificationException异常。
集合如何在迭代器构造之后发生改变,java.util包中的迭代器将抛出一个ConcurrentModificationException异常。
并发的散列映射表可高效支持大量的读者和一定数量的写者。默认可支持16个写线程,如果同一时间多余16个,其他线程暂时将阻塞。
高效映射、集和队列API
// java.util.concurrent.ConcurrentLinkedQueue<E> 5.0
ConcurrentLinkedQueue<E>()
// 构造一个可以被多线程安全访问的无边界非阻塞的队列
// java.util.concurrent.ConcurrentLinkedQueue<E> 6
ConcurrentSkipListSet<E>()
ConcurrentSkipListSet<E>(Comparator<? super E> comp)
// 构造一个可以被多线程安全访问的有序集。第一个构造器要求元素实现Comparable接口
// java.util.concurrent.ConcurrentHashMap<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
// loadFactor 负载因子,默认值0.75
// concurrencyLevel 并发写线程的估计数目
ConcurrentSkipListMap<K,V>() // key需要实现Comparable接口,以便于排序
ConcurrentSkipListSet<K,V>(Comparator<? super K> comp)
// 构造一个可以被多线程安全访问的有序的映像表,第一个构造器要求键实现Comparable接口
映射条目的原子更新
ConcurrentHashMap 的方法是原子更新,不过并不能完全解决线程安全问题。
如,使用ConcurrentHashMap<String, Long>()对单词进行频率统计,考虑让计数器自增。然而,下面代码不是线程安全:
Long oldValue = map.get(word);
Long newValue = oldValue == null? 1: oldValue + 1;
map.put(word, newValue); // 错误,可能写newValue值时,oldValue已经被修改
虽然ConcurrentHashMap<String, Long> map的方法是原子的,但是操作get、put的操作序列却不是原子的,中间如果有其他线程进行写操作,就会导致更新的数据失效。
解决办法:
- 传统是使用replace操作(替代put),该方法会以原子方式用一个新值替换原值,前提是之前没有其他线程把原值替换。
// 代码框架类似于更新LongAdder
do {
oldValue = map.get(word);
newValue = oldValue == null ? 1: oldValue + 1;
} while(!map.replace(word, oldValue, newValue));
- 使用ConcurrentHashMap<String, AtomicLong>或者ConcurrentHashMap<String, LongAdder>
map.putIfAbsent(word, new LongAdder()); // 确保有一个LongAdder可以完成原子自增
map.get(word).increment(); // 原子自增
<=> 组合后
map.putIfAbsent(word. new LongAdder()).increment();
- 调用compute方法(更方便)
需要提供键和计算新值的函数。
// ConcurrentHasMap不允许有null值
map.compute(word, (k,v) -> v == null ? 1: v+1);
- computeIfPresent和computeIfAbsent方法,分别只在已经有原值的情况下计算新值,或者只有没有原值的情况下计算新值
map.computeIfAbsent(word, k -> new LongAdder()).increment(); // 与前面的putIfAbsent几乎一样,不敢LongAdder构造器只在确实需要一个新的计数器时才会调用
首次增加一个键时,通常需要做特殊处理。merge()可以非常方便做到这一点。
map.merge(word, 1L, (existingValue, newValue) -> existingValue + newValue); // word是key,1L是表示键不存在时使用的初始值。如果存在,就用后面lambda表达式提供函数来结合原值与初始值
<=>简单写法
map.merge(word, 1L, Long::sum);
对并发散列映射的批操作
批操作会遍历映射,处理遍历过程找到元素。无需冻结当前映射的快照。除非恰好知道批操作运行时映射不会被修改,否则就要把结果看作是映射状态的一个近似。
有3种不同的操作:
- 搜索(search)为每个键或值提供一个函数,直到函数生成一个非null的结果,然后搜索弘治,返回该函数结果;
- 归约(reduce)组合所有键或值,这里要使用所提供的一个累加函数;
- forEach为所有键或值提供一个函数;
每个操作都有4个版本:
- operationKeys:处理键;
- operationValues:处理值;
- operation:处理键和值;
- operationEntries:处理Map.Entry对象;
上述各操作,需要指定一个参数化阈值(parallelism threshold)。
如果映射包含的元素多余这个阈值,就会并行完成操作。
如果希望批操作在一个线程中运行,可以使用阈值Long.MAX_VALUE。
如果希望尽可能多的线程(并行)运行批操作,可以使用阈值1。
search
search方法不同版本:
U searchKeys(long threshold, BiFunction<? super K, ? extends U> f);
U searchValues(long threshold, BiFunction<? super K, ? extends U> f);
U search(long threshold, BiFunction<? super K, ? super V, ? extends U> f);
U searchEntries(long threshold, BinFunction<Map.Entry<K, V>, ? extends U> f);
例如,假设希望找出第一个出现次数超过1000次的单词。需要搜索键和值:
String result = map.search(threshold, (k,v) -> v > 1000? k : null); // result 为第一个匹配的单词,如果所有输入都返回null,result为null
forEach
forEach 方法有两种形式:
- 只为各个映射条目提供一个消费者函数
map.forEach(threshold, (k,v) -> System.out.println(k + "->" + v));
- 还有一个转换器函数,结构会传递到消费者
转换器可以用作为一个过滤器,只要过滤器返回null,这个值就会被悄无声息地跳过。
// 打印所有(k,v)键值对
map.forEach(threshold,
(k,v) -> k + "->" + v, // Transformer
System.out::println); // Consumer
// 打印大于1000值的所有条目
map.forEach(threashold,
(k,v) -> v > 1000? k + " -> " + v: null, // filter and transformer
System.out::println);
reduce
reduce操作用一个累加函数组合其输入。示例:
// 计算所有值的总和
Long sum = map.reduceValues(threshold, Long::sum);
// 提供转换器函数,计算最长的键的长度
Integer maxLength = map.reduceKeys( threshold,
String::length, // Transformer
Integer::max); // Accumulator
// 转换器可以作为一个过滤器,通过返回null来排除不想要的输入。
Long count = map.reduceValues(threshold,
v -> v > 1000? 1L: null,
Long::sum);
// int、long、long输出的特殊化操作
// 需要把输入转换成一个基本类型值,并指定一个默认值和一个累加器函数,映射为空时返回默认值
long sum = map.reduceValuesToLong( threshold,
Long::longvalue, // Transformer to primitive type
0, // Default value for empty map
Long::sum); // Primitive type accumulator
并发集视图
没有类似于线程安全的集ConcurrentHashSet类(不存在),不敢可以使用ConcurrentHashMap.newKeySet生成Set
Set<String> words = ConcurrentHashMap.<String>newKeySet();
如果原来有一个映射,keySet方法可以生成这个映射的键集。如果删除其元素,K-V会成映射中删除。不过不能向键集添加元素,因为没有相应值可以增加。使用第二个keySet方法(含默认值),可以在为集增加元素时使用:
// 如果words中不存在"Java",就添加一个键值对"Java" - 1
Set<String> words = map.keySet(1L);
words.add("Java");
写数组的拷贝
CopyOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合,其中所有的修改线程对底层数组进行复制。如果在集合上进行迭代的线程数超过修改线程数,这将会很有用。
当构建一个迭代器时,它包含一个对当前数组的引用。如果数组后来被修改,迭代器仍然应用旧数组,但是集合的数组已经被替换。因此,旧迭代器拥有一致的视图(可能过时),访问无须任何同步开销。
并行数组算法
- parallelSort方法
Arrays类提供大量并行化操作。静态Arrays.parallelSort方法可以对一个基本类型值或对象的数组排序。如,
String contents = new String(Files.readAllBytes(
Paths.get("alice.txt")), StandardCharsets.UTF-8); // Read file into string
String[] words = contents.split("[\\P{L}]+"); // split along nonletters
Arrays.parallelSort(words);
// 排序时,也可以提供Comparator
Arrays.parallelSort(words, Comparator.comparing(String::length));
// 对于所有方法,也可以提供一个(数组索引的)范围边界
values.parallelSort(values.length / 2, values.length); // 排序后半部元素
- parallelSetAll 方法
会用由一个函数计算得到的值填充一个数组,这个函数接收数组元素索引,然后计算相应位置上的值。
Arrays.parallelSetAll(values, i -> i % 10); // 这里的i是values数组的索引,这个函数会对每个索引i=0..length-1调用int applyAsInt(int operand),相当于values[i] = i % 10, i = 0..length-1
// 填充值0 1 2 3 4 5 6 7 8 9
- parallelPrefix方法
对应一个给定结合操作的前缀累加结果替换各个数组元素。
比如,将数组[1,2,3,4,...]和x(乘法)操作,执行Arrays.parallelPrefix(values, (x,y) -> x * y)之后,数组将包含:
[1, 1x2, 1x2x3, 1x2x3x4, ...]
步骤示意:
步骤一:结合相邻元素
[1, 1x2, 3, 3x4, 4, 4x5, 5, 5x6, 7, 7x8, ...]
奇数索引对应值不变,然后在不同的数组区中并行完成该计算。
步骤二:通过将所指示的元素与下面一个或两个位置上的元素相乘来更新这些元素
[1, 1x2, 1x2x3, 1x2x3x4, 5, 5x6, 5x6x7, 5x6x7x8, ...]
并行log(n)步之后,过程结束。这个用法适合用在多处理器硬件环境,会极大提升计算效率。
较早的线程安全集合
Vector和HashTable类提供线程安全的动态数组和散列表实现,不过现在被弃用了,取而代之的是ArrayList, HashMap类(2个都是非线程安全)。任何集合类都可以使用同步包装器(sychronization 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();
while(iter.hasNext){ ... } ;
}
使用forEach也是使用迭代器对集合进行迭代,也需要使用同样代码,“客户端”锁定。
注意:如果在迭代过程中,别的线程修改集合,迭代器会失效,抛出ConcurrentModificationException异常。也就是说,并发的修改可以被异常可靠检测出来。
建议:使用java.util.concurrent包中定义的集合,而不是使用同步包装器中的。例如ConcurrentHashMap是已经精心实现了的桶,多线程访问不会彼此阻塞。 例外情况:经常被修改的数组列表,同步ArrayList可胜过CopyOnWriteArrayList。
集合同步API
// java.util.Collections 1.2
static <E> Collection<E> synchronizedCollection(Collection<E> c)
static <E> List synchronizedList(List<E> c)
static <E> Set synchronizedSet(Set<E> c)
static <E> SortedSet synchronizedSortedSet(SortedSet<E> c)
static <K, V> Map<K, V> synchronizedMap(Map<K, V> c)
static <K, V> SortedMap<K, V> synchronizedSortedMap(SortedMap<K, V> c)
// 构建集合视图,集合的方法是同步的
Callable与Future
Callable相当于带返回值的Runnable,封装了异步计算的方法。
Future保存异步计算的结果。可以启动一个计算,然后Future对象交给某个线程,然后忘掉它(什么意思?不再使用?)
public interface Callable<V> {
V call() throws Exception; // 有返回值
}
public interface Runnable {
public abstract void run();
}
public inteface Future {
// 如果计算线程被中断,抛出InterruptedException异常
V get() throws ...; // 调用阻塞,直到计算完成
V get(long time, TimeUnit unit) thorws ...; // 计算完成之前,如果超时抛出TimeoutException异常
void cancel(boolean mayInterrupt);
boolean isCancelled();
boolean isDone(); // 如果计算还在进行,返回false;如果已经完成,返回true
}
public class FutureTask<V> implements RunnableFuture<V> {
...
}
public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}
FutureTask 包装器是一种便利机制,能将Callable转换成Future和Runnable,同时实现二者接口。可以看到FutureTask既实现了Runnable接口,也实现了Future接口。
下面的例子,是通过FutureTask将Callable转换成Runnable进计算,同时也通过FutrueTask转换成Future取得所需结果。
Callable<Integer> myCompution = new Callable<>() {
@override
public Integer call() throws Exception {
int res = 1;
// 计算过程
...
return res;
}
};
FutureTask<Integer> task = new FutureTask<>(myCompution);
Thread t = new Thread(task);
t.start();
int a = task.get(); // 阻塞,直至call() 计算完成得到返回值
System.out.println(a); // 打印1
执行器 Executor
新建、释放线程涉及与OS交互,若程序创建了大量生命周期很短的线程,应该使用线程池(thread pool)。一个线程池包含需要准备运行的空闲线程,将Runnable交给线程池,就会有一个线程调用run方法,run调用完毕时,线程不会死亡,而是在回到线程池准备为下一个请求提供服务。
使用线程池的重要优势:
减少频繁的线程创建、销毁,降低系统资源开销; -- 频繁的创建、销毁线程,降低系统性能
减少并发线程数目; -- 过多并发线程,回降低性能,可能导致虚拟机崩溃
Excutor类构建线程池的工厂方法
newCachedThreadPool // 必要时创建新线程;空闲线程会被保留60秒
newFixedThreadPool // 该池包含固定数量的线程;空闲线程一直会被保留
newSingleThreadExecutor // 只有一个线程的“池”,该线程顺序执行每个提交的任务
newScheduledThreadPool // 用于预定执行而构建的固定线程池,替代java.util.Timer
newSingleThreadScheduledExecutor // 用于预定执行而构建的单线程“池”
线程池
线程池的创建
-
newCachedThreadPool
构建一个线程池,对每个任务,如果空闲线程可用,立即执行任务;如果没有可用空闲线程,则创建一个新线程。 -
newFixedThreadPool
构建一个固定大小的线程池。如果提交的任务数多余空闲的线程数,那么把得不到服务的任务放置到队列中。其他任务完成后,再运行。 -
newSingleThreadExecutor
一个大小为1的线程池:由一个线程池执行提交任务,one by one。
newCachedThreadPool,newFixedThreadPool 和newSingleThreadExecutor 都返回实现了ExecutorService接口的ThreadPoolExecutor类对象。
将一个Runnable对象或Callable对象提交给ExecutorService:
// submit提交任务,得到Future对象可用来查询该任务的状态
Future<?> submit(Runnable task) // Future<?> 对象可调用isDone, cancel或isCancelled,但get完成时只返回nil
Future<T> submit(Runnable task, T result) // Future的get完成时,返回指定的result对象
Future<T> submit(Callable<T> task) // Future对象在计算结果准备好时得到它
线程池的死亡
-
shutdown
当用完一个线程池时,调用shutdown,启动该池的关闭序列。被关闭的执行器不再接受新任务。当所有任务都完成后,线程池中线程死亡。 -
shutdownNow
取消尚未开始的所有任务,并试图中断正在运行的线程。
使用线程池步骤
- 调用Executors类静态方法newCachedThreadPool或newFixedThreadPool;
- 调用submit提交Runnable或Callable对象;
- 如果想要取消一个任务,或者如果提交Callable对象,那就要保存好返回的Future对象;
- 当不再提交任何任务时,调用shutdown;
预定执行 Scheduled Execution
类似于S32K单片机的PDB(Programmable Delay Block,可编程延时模块),ScheduledExecutorService接口能指定预定执行(一次)或重复执行任务(多次)。
Executors类的newScheduledThreadPool和newSingleThreadScheduledExecutor方法返回实现了ScheduledExecutorService接口的对象。
简单来说,就是可以让任务Runnable或者Callable在指定延时时间后,执行一次或多次。
// java.util.concurrent.Executors 5.0
ScheduledExecutorService newScheduledThreadPool(int threads)
// 返回一个线程池,使用给定的线程数来调度任务
ScheduledExecutorService newSingleThreadScheduleExecutor()
// 返回一个执行器,在一个单独线程中调度任务
// java.util.concurrent.ScheduledExecutorService 5.0
ScheduledFuture<V> schedule(Callable<V> task, long time, TimeUnit unit)
ScheduledFuture<?> schedule(Runnable task, long time, TimeUnit unit)
// 预定在指定的时间之后执行任务
ScheduleFuture<?> scheduleAtFixedRate(Runnable task, long initialDelay, long period, TimeUnit unit)
// 预定在初始的延迟结束后,周期性地运行给定的任务,周期长度period
ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, long initialDealy, long delay, TimeUnit unit)
// 预定在初始的延迟结束后周期性运行给定的任务,在一次调用完成和下一次调用开始之间有长度为delay的延迟
控制任务组
执行器服务作为线程池使用,可以提高执行任务效率,降低系统开销。而作为控制任务组使用,可以控制任务的执行,比如执行顺序、取消任务、随机执行任务。
invokeAny 提交所有对象到一个Callable对象集合,返回某个已经完成的任务结构。无法指定返回的具体是哪个任务结果。
invokeAll 提交所有对象到Callable对象集合,返回一个Future对象列表,代表所有任务的解决方案。处理结果方式:
List<Callable<T>> tasks = ...;
List<Future<T>> results = executors.invokeAll(tasks);
for (Future<T> result : results) {
processFurther(result.get());
}
缺点:如果第一个任务花费很多时间,可能不得不进行等待(get方法等待)。
改善方式:将结果按获得顺序保存,使用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());
}
控制任务组API
// java.util.concurrent.ExecutorCompletionService<V> 5.0
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 框架
Fork/Join框架详情,可以参考下这篇文章RecursiveTask和RecursiveAction的使用 以及java 8 并行流和顺序流
简单讲,Fork/Join框架是处理并行任务的框架。是把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
Fork -- 把大任务切分成若干子任务并行的执行;
Join -- 合并这些子任务的执行结果,最后得到大任务的结果;
ForkJoinPool -- 支持将一个任务拆分成多个“小任务”并行计算,再把多个“小任务”的结果合成总的计算结果。ForkJoinPool是ExecutorService的实现类
处理一个任务,分解成子任务、再合并的代码模型:
if (problemSize < threshold)
solve problem directly
else {
break problem into subproblems
recursively solve each subproblem
combine the results
}
例子:假设想统计一个数组种有多少个元素满足某个特定的属性。可以将这个数组一分为二,分别对2部分进行统计,再将结果相加。
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(first, second); // 接收到很多任务并阻塞,直到所有这些任务都已经完成
return first.join() + second.join(); // join方法生成结果, 这里返回总和
}
}
}
CompletableFuture可完成Future
CompletableFuture是处理非阻塞调用的可选方案。传统方法是使用事件处理器,需要先注册一个事件监听器,实现对应事件处理方法,事件发生时,接收事件的对象会激活,并调用事件处理方法。
所谓非阻塞方法,就是执行某个方法后,不会阻塞,等所需要的资源达成后,对应线程重新进入就绪状态,得到执行。
CompletableFuture与事件处理器的不同在于,CompletableFuture可以组合(composed)。
例如,假设希望从一个Web页面抽取所有链接来建立一个网络爬虫,
CompletableFuture<String> contents = readPage(URL); // 从URL读取页面内容,当URL可用时生成页面的文本(String)
CompletableFuture<List> links = contents.thenApply(Parser::getLinks); // thenApply方法不会阻塞,会返回一个Future,第一个Future完成时,其结果会提供给getLinks方法,这个方法返回值就是最终的结果
CompletedFuture
方法 | 参数 | 描述 |
---|---|---|
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 |
说明:T->U代表Function<? super T, U> |
例如,调用thenApply,会返回一个future,可用时对future的结果应用f。第二个调用会在另一个线程中运行f。
CompletableFuture<U> future.thenApply(f); CompletableFuture<U> future.thenApplyAsync(f);
同步器
java.util.concurrent提供了几个能帮助管理相互合作的线程集的类,具有为线程之间共用集结点模式(common rendezvous patterns)提供的“预置功能”(canned functionality)。
建议是:如果能重用这些库,尽量不要用手工的锁和条件的集合。
类 | 功能描述 | 说明 |
---|---|---|
CyclicBarrier | 允许线程集等待直至其中预定数目的线程到达一个公共障栅(barrier),然后可用选择执行一个处理樟栅的动作 | 当大量线程需要在其结果可用前完成时 |
Phaser | 类似于循环樟栅,不过有一个可变的计数 | java 7引入 |
CountDownLatch | 允许线程集等待直到计数器减为0 | 当一个或多个线程需要等待直到指定数目的事件发生 |
Exchanger | 允许2个线程在交换的对象准备好时交换对象 | 当2个线程工作在同一数据结构的2个实例上的时候,一个向实例添加数据而另外一个从实例清楚数据 |
Semaphere | 允许线程集等待直到被允许继续运行为止 | 限制访问资源的线程总数,如果许可数=1,常常阻塞线程直到另一个线程给出许可为止 |
SynchronousQueue | 允许一个线程把对象交给另一个线程 | 在没有显式同步的情况下,当2个线程准备好一个对象从一个线程传递到另一个时 |
信号量
信号量是同步原语。通过维护计数,来管理许多许可证(permit)。线程通过acquire请求许可,其他线程调用release释放许可。许可不是必须由获取它的线程释放。因此,也可能或导致许可数目超出初始数目。
倒计时门栓
倒计时门栓CountDownLatch让一个线程集等待直到计数为0,倒计时门栓是一次性的,一旦计数为0,就不能再使用。
樟栅
CyclicBarrier类实现了一个集结点(rendezvous),称为樟栅(barrier)。大量线程允许在一次计算的不同部分时,当所有部分都准备好时,需要把结果组合在一起。当一个线程完成了自己那部分任务后,运行到樟栅处,一旦所有线程都到达了这个樟栅,樟栅就取消,线程就可用继续运行。
注意:樟栅是循环的,因为可用在所有等待线程被释放后被重用。这点有别于CountDownLatch, CountDownLatch只能使用一次。
CyclicBarrier barrier = new CyclicBarrier(nthreads);
// 每个线程做一些工作,完成后在樟栅上调用await
public void run() {
doWork();
barrier.await(); // 也可以使用带有可选的超时参数 barrier.await(100, TimeUnit.MILLISECONDS);
...
}
如果任何一个在樟栅上等待的线程离开了樟栅,樟栅就被破坏了(离开可能是因为await超时,或者被中断)。此时,所有其他线程的await方法抛出BrokenBarrierException异常,并且立即终止await调用。
- 樟栅动作(barrier action)
可选,当所有线程达到樟栅时执行该动作。
Runnable barrierAction = ...;
CyclicBarrier barrier = new CyclicBarrier(nthreads, barrierAction);
交换器
当2个线程在同一个数据缓冲区的2个实例上工作时,可用使用交换器(Exchanger)。典型应用:一个线程向缓冲区填入数据,另一个线程消耗这些数据。都完成后,相互交换缓冲区。
同步队列
同步队列是一种将生产者、消费者线程配对的机制。当一个线程调用SynchronousQueue的put方法时,会阻塞直到另一个线程调用take方法为止。
与Exchanger不同的是,同步队列的数据仅沿一个方向传递,从生产者到消费者。
线程与Swing
略