并发编程
追求极致性能的同时,处理好与有限资源的关系.
最大化的利用现有资源,以一种安全可靠、稳定、满足业务吞吐量和并发的技术手段保证服务的可用性.
线程安全
线程安全的定义
当多个线程访问某个类的时候,不管运行环境采用何种调度方式或者这些线程如何交替执行,并且在主掉代码中不需要任何额外的同步或协同,这个类都表现出正确的行为,那么就称这个类是线程安全的。
我们写的很多程序并非是线程安全的,一旦涉及到多线程的交互操作很有可能带来线程不安全,JVM中存在一些既定的线程顺序执行规则.实际编码中可以采用局部并发来处理可并行计算,串行化耗时短的任务(可通过闭锁,栅栏等实现).
不需要一个严格线程安全的类,这是不现实的,通过实时不可变,串行化,栈封闭,ThreadLocal等技术的组合实现核心服务的线程安全是第一要务。
原子性
同事物概念中的原子性一样,复合操作的结果要一致,且中间不允许
1. 竟态条件,并发编程中由于某种不恰当的执行时序而出现不正确的结果.某个计算的结果的正确性取决于多个线程的交替执行时序时会发生竟态条件.常见的竟态条件:先检查后执行
2. 复合操作,复合操作过程中不应该交出状态变量的控制权.java.util.concurrent包中包含了一些原子变量类,支持一些复合操作的原子操作.
这是一些经常容易出问题的地方,不少有若干开发经验的工程师任然不能注意到这点,多线程交叉执行的情况下,不保证复合操作的原子性是系统潜在的风险.我们应该用严谨的态度去重视一切可能的并发场景,开发人员要对此负责,同时对测试提出要求,也许测试根本不关注这一块.
加锁
使用锁来保证复合操作的原子性
1. 使用内置锁,synchronized
2. 内置锁是可重入锁,
加锁是是同步的重要手段,Java提供了内置的块级锁,显示的可重入锁,优化的读写锁,基础的同步容器、并发容器、同步组件等。
活跃性问题
在简单性与性能之间寻求平衡,频繁的加锁&释放锁,过大的锁范围都不一定能带来性能的提升;避免在长时间执行的计算中持有锁.活跃性问题包括死锁\饥饿\活锁等
这里说的活跃性问题可以理解为由于多线程环境中不确定的锁行为带来的影响服务性能\吞吐量\服务状态的情况。
对象共享
正确的并发程序的关键在于正确的共享可变状态.安全的访问一个对象的前提是对象被正确的共享和发布.
并发应用程序=正确的共享和发布对象+正确的访问共享状态+…
没毛病,要正确的共享一个对象,必须将一个对象安全的交付,好比房子没有盖完是不允许交付到客户手里的.
可见性
多线程环境下共享状态的操作对其它线程来说并不总是立即可见的,为了确保多个线程之间对内存写入操作的可见性必须使用同步.
1. 不正确同步的代码往往可能访问到失效数据,通过正确的同步可以处理。
2. 非volatile 类型的64位数值变量JVM允许对其操作分解为两个32位的操作,多线程环境中可能出现一个读取到某个值的高32位和另一个值的低32位的组合
3. 使用加锁来保证可见性,所有执行读写操作的线程都应该在同一个锁上同步.
4. Volatile变量,使用一种稍弱的同步机制保证内存可见性,禁止重排序行为,不会被缓存到寄存器或其它处理器不可见的地方。à保证变量对其它线程的可见性
JVM的内存模型(多个CPU分别使用了缓存)决定了对某个状态的修改并不能总是及时的对其它线程可见,但是!单核CPU也不能保证内存可见性!!! 具体的需要查看JVM的内存模型来理解,我们使用锁来保证内存可见性也正是JVM内存默写读写协议的一部分.
发布与逸出
发布使得对象能够在当前域之外的地方被访问,需要保证发布对象时的线程安全性(对象构造完成前就执行发布,会破坏线程安全性);某个不应该被发布的对象被发布时称为逸出.
发布一个对象时可能间接的发布其它对象,
安全的对象构造过程:不要在构造过程中使this引用逸出.可使用工厂方法等将需要在构造器中的操作延迟到外部对象创建完成后执行.
提前发布一个对象是不安全的,同样提前发布一个服务也是不安全,提前发布一个决策可能也是不正确的… 程序也要做到诚信交付… 给你的就是事实可用的安全发布的对象.
线程封闭
不共享数据,仅在单线程内共享数据,Swing中大量的使用了线程封闭技术.JDBC也是线程封闭的.线程池等.
1. Ad-hoc线程封闭,维护线程封闭性的职责完全由程序实现来承担
2. 栈封闭,只能通过局部变量来访问对象,局部变量的固有属性之一就是封闭在执行线程中,Java确保了基本类型的局部变量始终封闭在线程内.
3. ThreadLocal,使线程中的某个值与保存值的对象关联起来,使用get、set方法为每个使用变量的线程都存有一份独立的副本.
第一种相对来说编码要复杂一些,常见的Swing编程中会要求对UI空间属性的修改必须在主线程中,这就需要额外的编码保证.
栈封闭的对象是线程安全的,当然最好不要让它被逸出,你永远不知道离开了组织它会有什么行为,也许是另一位开发者,这里要注意在并发编程中一定要注意对
1. 可变对象的修改时机?
2. Spring中是否真的需要一个单例的对象呢? 千万注意不要误用了一个单例对象
不变性
不可变对象一定是线程安全的.
1. 对象创建后状态不能被修改
2. 对象的所有域都是final的
3. 对象是正确创建的,创建期间this引用没有逸出过.
Final域
final修饰的域是不可变的,如果final引用的对象是可变的,那么这些被引用的对象是可以被修改的.final域能保证初始化过程的安全性。
使用volatile类型来发布不可变对象:
不变是一个整体,包括直接或者间接引用的对象,对应的可变对象的所有状态应该是一个整体,强调整体可见性,通过 volatile来保证一个对象的整体可见性.
安全发布
java内存模型为不可变对象提供了一种特殊的初始化安全性保证,
安全发布的常用模式
1. 在静态初始化函数中初始化一个对象引用
2. 将对象的引用保存到voliatile类型或者AtomicReferance对象中
3. 将对象的引用保存到某个正确构造对象的final类型域中
4. 将对象的引用保存到一个由锁保护的域中
通常情况下我们发布一个对象都是在主线程中发布,留下足够的时间让其它线程同步数据,似乎并不是线程安全的,但是… 我们似乎都在这么做… 暂且记下来至少有这么些手段可以安全的发布一个对象.
事实不可变对象
如果对象在发布后不会被修改,对于其它在没有额外同步的情况下安全的访问这些对象的线程来说,安全发布是足够的.
这个很好理解了
可变对象
如果对象在发布后可以修改,安全发布只能确保“发布当时”状态的可见性.对于可变对象,不仅在发布对象时需要使用同步,而且在每次访问时同样需要使用同步来确保后续操作的可见性。
安全的共享对象
发布一个对象时必须明确的说明对象的访问方式.
并发程序中使用和共享对象时,可以使用一些实用的策
1. 线程封闭,对象只能有一个线程拥有,对象被封闭在一个线程中,只能由这个线程修改.
2. 只读共享,没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,任何线程都不能修改它,包括不可变对象和实时不可变对象.
3. 线程安全共享,在对象内部实现同步
4. 保护对象,只能通过持有特定的锁来访问。
共享允许修改,要保证多线程修改的原子性,一致性,以及对其它线程的可见性.
基础模块
同步容器类
常见的同步容器:
1. Vector, jdk1.0+,实现了java.util.List接口,使用synchronized实现同步语意,使用数组存储集集合元素,每次扩容为原来容量的两倍(没有没过指定扩容参数)。
à通常情况下应避免使用它
à性能较低,且由于出现较早存在若干不规范的地方(现有版本仍存在两套API)
Vector<String> v = new Vector<>();
v.addElement("1");
v.add("2");
v.forEach(item -> System.out.println(item));
2. Hashtable(),jdk1.0+,实现了java.util.Map接口,使用synchronized实现同步语意,内部使用数组存储节点数据,数组节点以链表的形式存储分配到同一个Hash Solt中的数据,每次扩容为原来容量的2倍+1,
à避免使用它
à性能低,存在若干不规范的地方,类名都不规范(哈哈),
3. 使用Collections.synchronized将非线程安全的同期边为线程安全的:将状态封装起来,对每个共有方法都进行同步…
同步容器的问题:
1. 无法保证复合操作的同步,对同步容器的复合操作需要额外的同步支持.
2. 同步容器的迭代器行为,同步容器在迭代过程中如果出现非法操作会及时失败,报ConcurrentModificationException异常,即不允许在迭代过程中不允许并发的修改容器.
3. 即便可以通过加锁操作类避免在迭代过程中的并发修改异常,在某些场景下迭代器并隐式的调用,比如迭代器对象的toString、容器对象的hashCode、equals、等等.
同步的容器类大部分都是使用Java内置的synchronized来保证同步,这是一个块级别的同步,对同步的容器来说通常都是对容器本身加锁.
并发容器类
同步的容器类将对容器元素的操作串行化,性能低下,并发容器用来改善同步容器的性能问题,并发容器针对多个线程并发访问设计.常见的并发容器类:
1. ConcurrentHashMap,key&value均不允许为空,使用分段锁提供并发性和伸缩性,迭代器不会抛出并发修改异常,不需要在迭代过程中加锁,ConcurrentHashMap返回的迭代器具有若一致性,并非及时失败的,
ConcurrentHashMap不能被加锁来实现独占访问(**对ConcurrentHashMap加锁和对内部元素操作的锁并不是同一个锁!!!**),因此无法使用客户端加锁来实现新的原子操作,ConcurrentHashMap中实现了若干复合的原子操作:
Ø putIfAbsent àkey没有响应的应设置时插入
Ø remove àkey 被映射到指定的值才移除
Ø replace àkey被映射到某个值才替换
2. CopyOnWriteArrayList,CopyOnWriteArraySet,同步的List、Set集合,写入操作同步加锁,读取操作通过复制一个新的数组元素来操作,消除了读锁,同时带来数组元素拷贝的性能开销.
使用更细粒度的锁,总是要避免对整个容器对象来加锁,比如分段锁等,或者直接拷贝一个快照对快照来进行操作.
阻塞队列,生产-消费者模式
Blocking Queue简化了生产者-消费者的实现过程,
1. LinkedBlockingQueue à基于链表实现的无界阻塞队列
2. ArrayBlockingQueue à基于数组实现的有界阻塞队列
3. PriorityBlockingQueue à基于数组实现的有界优先级队列,可以指定排序
4. SynchronousQueue à同步的,不维护存储空间而维护一组线程,put、take会一直阻塞
**串行线程封闭:将对象所有权从生产者转移到消费者,且只有一个消费者线程可以活得该对像,发布对象的线程不在访问它.常见的对象池等,需要保证安全的发布一个对象.
**双端队列&工作秘取
Deque和BlockingDeque,分别对Queue和BlockingQueue进行了扩展.Deque是一个双端队列,实现在队列头和尾部的高效插入和移除,实现有ArrayDeque, LinkedBlockingDeque.
工作密取:每个消费者都有自己的双端队列,一个消费者完成自己双端队列中的全部工作可以从其它消费者双端队列末秘密的获取工作.
阻塞&中断方法
线程阻塞/暂停的原因:
1. 等待IO
2. 等待锁
3. Thread.sleep()
4. 等待其它线程的计算结果等等.
线程阻塞时的状态:
1. Blocked à阻塞
2. WAITING à等待其它线程唤醒
3. TIMED-WAINGINT à限时等待
BlockingQueue 的put和take等方法会抛出受检查异常àInterrupted-Exception,某方法抛出Interrupted-Exception时,表示该方法是一个阻塞方法,如果该方法被中断将努力提前结束阻塞状态.
中断是一种协作机制,对中断的两种处理方式:
1. 传递,不处理该异常或者简单处理后继续抛出.
2. 恢复中断状态,当线程捕获了中断异常后,可以重新中断当前线程以使得中断被高层代码感知.
应该重视队列
同步工具类
同步工具类根据其自身状态来协调线程的控制流程,常见的信号量(Semaphore),栅栏(Barrier),闭锁(Latch),所有的同步工具类固有的属性:封装一些状态,这些状态决定执行同步工具类的线程是执行还是等待,此外提供一些操作状态的方法,
闭锁
延迟线程的进度直到使其达到终止状态,用于确保某些活动直到其它活动都完成后才继续执行.
Ø 确保某个计算所需要的所有资源都被初始化之后才继续执行.
Ø 确保某个服务在其依赖的服务启动后才启动.
Ø 等待直到某个操作的所有参与者都就绪在继续执行
CountDownLatch是一种灵活的闭锁实现,可以使一个或者多个线程等待一组事件的发生。
以下代码使用CountDownLatch实现线程同时启动:
CountDownLatch start = newCountDownLatch(1);
CountDownLatch end = newCountDownLatch(10);
for (int i = 0; i < 10;i++) {
new Thread() {
@Override
public void run() {
try {
//等待起始门
start.await();
System.out.println("thread:" +Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//资源就绪
end.countDown();
}
}
}.start();
}
long startT =System.currentTimeMillis();
start.countDown();
end.await();
long endT =System.currentTimeMillis();
System.out.println("tTime:"+ (endT - startT));
使用闭锁可以实现一个开关操作,常见的使用闭锁来挂起一个Spring进程而不让容器退出.使用闭锁来等待某些资源的初始化操作.使用闭锁来释放一批线程等等。
FutureTask
FutureTask实现了Future语意,表示一个抽象的可生成结果的计算,表示的计算通过Callable来实现, FutureTask一般配合ExecutorService来使用,也可以直接通过Thread来使用.实现了Runnable, Future<V>接口。
FutureTask将作业的提交,作业的执行进行隔离,并提供类型安全的发布,用于提前加载稍后需要的数据.
FutureTask<String>task = new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
return "100";
}
});
Thread thread = newThread(task);
thread.start();
try {
String result = task.get();
System.out.println(result);
} catch(InterruptedException e) {
e.printStackTrace();
} catch (ExecutionExceptione) {
e.printStackTrace();
}
使用场景也是很多了,一般使用Callable+Future的地方都可以使用FutureTask,提前执行一个计算直到需要结果的时候在去获取,执行结束最好,没有执行结束可以选择等待或者放弃该操作。
信号量
信号量用来控制同时访问的资源的操作数量,实现资源池、容器边界等.
管理一组虚拟许可,使用acquire操作获取许可,使用release操作释放许可
这个是比较容易理解的,内部的实现也都是自旋锁,可以用来做简单的并发控制等.
栅栏
说起这个单词感觉好陌生的,都怪语文学的不好好久竟然没能理解什么是“栅栏”…限时生活中的栅栏是用来挡人和动物的.JVM总的栅栏是用来阻挡线程的,只有当栅栏被打开的时候线程(一个或者多个)才可以通过.
闭锁是一次性对象,一旦进入终止状态不能被重置,
1. 所有线程必须同时到达栅栏位置,才能继续执行
2. 闭锁用于等待事件,栅栏用于等待其它线程
栅栏可以使一定数量的参与方反复地在栅栏位置汇集,在并行迭代算法中非常有用.
CyclicBarrier barrier = newCyclicBarrier(10);
for (; ; ) {
new Thread() {
@Override
public void run() {
try {
System.out.println("threadawait:" + Thread.currentThread().getName());
//等待其它线程都到达栅栏位置,栅栏放行后多个线程并发执行
int number = barrier.await();
//number 是唯一的,可根据number的值执行特殊的操作
System.out.println("threadgo:" + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e){
e.printStackTrace();
}
}
}.start();
}
栅栏是一个可重复使用的锁,比如打麻将,凑成一桌就开始玩,三缺一就等待。
任务执行
并发环境下关注任务的的边界以确定任务的执行单元,同时要保证服务器应用程序表现出良好的吞吐量和快速的响应能力.
在线程中执行任务
串行执行à每个任务创建一个线程
1. 串行执行的问题在于其糟糕的响应性和吞吐量
2. 为每个任务分配一个线程在问题在于资源管理的复杂性
Executor框架
Java中执行任务的主要抽象不是Thread而是Executor接口,提供了一种标准的方法将任务的提交过程与执行过程解耦开来,利用Runnable表示任务,并且实现了对生命周期的支持,统计收集、应用程序管理、性能监视等机制.
public interface Executor {
void execute(Runnable command);
}
基于生产者消费者模式。
执行策略
1. 在什么线程中执行?(单线程?多线程?线程池?)
2. 任务按照什么顺序执行?(FIFO,LIFO,优先级)
3. 有多少个任务能并发执行?
4. 队列中有多少个任务在等待执行?
5. 系统过载要拒绝一个任务,使用什么策略?应该拒绝哪一个?
6. 执行一个任务之前,之后应该执行哪些动作?
线程池
工作队列中存储了所有等待执行的任务,工作线程从工作队列获取任务,执行任务然后返回线程池.
Executors中的静态工厂方法提供了创建线程池并和Executor绑定的方法:
1. Executors.newFixedThreadPool(10) à创建一个固定长度的线程池,每提交一个任务创建一个线程,直到达到最大值,如果某个线程异常结束则补充一个.
2. Executors.newCachedThreadPool() à创建一个可缓存的线程池,可回收,
3. Executors.newSingleThreadExecutor(); à单线程的Executor
4. Executors.newScheduledThreadPool(10) à创建一个固定长度的线程池,以延迟或者定时方式来执行
线程池生命周期
运行à关闭à终止
1. shutdown à不接受新任务,等待已提交任务完成.
2. shutdownNow à尝试取消所有运行中的任务,不在启动队列中尚为开始的任务.
3. isShutdown
4. isTerminated
5. awaitTermination à发出shutdown后阻塞等待任务完成,或者捕获到异常
延迟任务与周期性调度
使用ScheduledExecutorService替代Timer类,
使用DelayQueue实现对象的延迟发布
找出可利用的并行性
1. 尽可能的避免任务的串行化
2. 使用携带结果的认为Callable& Future
3. 使用CompletionService(Executor+ Blocking Queue),提交任务到工作队列并使用类似与队列的方式返回执行结果.
4. 为任务设置超时时间,常见的为Future.get指定超时时间.
5. 使用ExecutorService的invokeAll返回一组执行结果.
取消与关闭
Java中没有提供任何机制来安全的终止线程,仅提供了中断使得一个线程可以中断另一个线程的当前工作.
任务取消
一个任务可以被置为完成状态则是可以取消的.
取消的因素
1. 用户请求取消
2. 有时间限制的操作
3. 应用程序事件
4. 错误
5. 关闭
Java中没有一种安全的抢占方式来停止线程,只有一些协作机制,使请求取消的任务和代码都遵循一种协商好的协议.
协作机制能设置某个”已请求取消”标志,任务通过定期的查看该标志来结束自己.
常见的在循环中使用volatile类型的状态变量来检查退出状态
中断
如果调用了一个阻塞的操作则使用volatile来检查退出可能会导致更严重的问题à永远不会检查取消标志.
每个线程都有一个boolean类型的中断状态,线程中断时线程中断状态被置为true,
1. public boolean isInterrupted() à测试线程是否已经被中断
2. public static booleaninterrupted() à清除中断状态
3. public void interrupt() à中断线程
阻塞的库方法,Thread.sleep,Object.wait等都会检查线程何时中断,并在发现中断时提前返回,响应中断时执行的操作包括:清楚中断状态,抛出InterruptedException异常.
并不会真正的中断一个正在运行的线程,只是发出中断请求.
实现良好的方法在检查到线程中断后将抛出一个异常,并重置中断状态,除非想屏蔽一个中断请求.
中断策略
以某种形式的线程级取消操作或服务级取消操作:尽快退出,在必要时清理,通知某个所有者线程已经退出.
响应中断
1. 传递异常
2. 恢复中断状态,从而使调用栈中的上层代码能够对其进行处理
只有实现了线程中断策略的代码才可以屏蔽中断请求,在常规的任务和代码库中都不应该屏蔽中断请求.
处理不可中断的阻塞
并非所有的阻塞操作或者阻塞机制都能够响应中断,
大部分实现合理的API允许通过抛出异常来来终止线程.