0. 感觉最近有点犯难了…
《Thinking in java 4th》第21章 并发
并发看起来充满了危险,如果你对它有些畏惧,这可能是件好事…
1. 并发的多面性
作者在“性能优化”和“设计可管理性”的角度上的讨论让人印象深刻(Atom类、显式锁…)
cpu
更好的利用多核处理器,同样也提升了单核处理器的程序性能
单核处理器上运行并发程序,会带来额外的上下文切换的代价
考虑阻塞的存在,单核处理器此时可以“腾出手来”,去做其他的事情,保证了程序一定的可响应性
并发显著提升单核处理器的程序性能的案例,即 事件驱动编程
进程不同于线程,进程各自拥有隔离的地址空间,严格来说,进程之间没有彼此通信的需要,这样可以做到互不影响(例如MQ的脚本语言Erlang)
2.改进代码设计
java的线程机制是抢占式的,这表示调度机制会周期性中断线程,将上下文切换到另一个线程,从而为每个线程都提供时间片。
协作式系统(互相让步)带来的上下文切换的开销要比抢占式系统低廉,理论上对并发的线程数量没有限制。
3. 使用Executor
执行器,客户端&任务之间的中间层对象,无需显示的管理线程的生命周期
ExecutorService 具有生命周期的Executor
shutdown()
4. Thread Pool
线程池,可维护的线程集合,省去冗余的操作、反复创建/释放线程的开销
FixThreadPool 有界的xxx
CacheThreadPool 创建与所需线程数等同的线程
SingleThreadPool 线程数量为1的xxx,提交的多个任务将排队,均使用相同的线程走完
5. 从任务中产生返回值
ExecutorService.submit(Callable) -Future
get()
isDone()
6. 编码的变体
在类构造中执行Thread.start(),将导致在类未初始化完成之前,被访问。
这也是建议使用Executor的原因
7. 术语
在Java中,Thread类本身并不执行任何操作,它只是驱动(start())所赋予它的任务(比如说Runnable、Callable),但是线程研究总是不变地使用“线程执行某项动作”这类语言。因此,你得到的印象就是“线程就是任务”,但执行的动作即“任务”,唯有讨论到驱动任务的具体机制时,才会使用"线程"这类术语。
8. 加入一个线程
对join()方法的调用可以被中断,做法是在调用线程上调用interrupt(),这也会需要声明try-catch捕获InterruptException
9. 共享受限资源
Java的递增程序自身也需要多个步骤,也就是说递增过程中可能会发生线程被挂起,即Java的递增不是原子性的
10. 解决共享资源竞争
基本上所有并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案(给定时刻只能有一个任务可以访问共享资源)。
通常由 锁机制 实现,使用锁语句将产生互斥的效果,这种机制又被称为"互斥量"(mutex)。
Java对象都自动含有单一的锁(也称为监视器)。
JVM负责跟踪对象被加锁的次数,如果一个对象被完全的解锁,其计数将变为0,此时其他的任务才能访问这个资源。
读写线程必须使用相同的监视器同步,即持有相同的锁。
11. 使用显式的Lock对象
只有在解决特殊问题的时候,才使用显式的Lock对象,例如,使用synchronized关键字不能尝试获取锁且最终获取锁会失败,或者尝试着获取锁一段时间,然后再放弃它。
锁耦合的场景下,显式的Lock对象也很有用。例如遍历列表的时候,需要传递加锁机制,这时候就需要在释放当前节点的锁的同时,去获取下一个节点的锁。
12. 原子性 & 易变性
原子操作一旦执行,是不能被线程调度机制所中断的,直至执行完毕。但这不意味它并不需要同步,因为它有可能在上下文切换之前执行完毕。
当你使用volatile声明基础类型时,这将相当于告诉编译器不要执行任何移除读取、写入操作的优化(保证了更精准的同步效果),并且将会获得具有原子性、可视性的变量,不同的JVM能提供不同层度的支持。
volatile的可视性:只要对这个域产生了写操作,那么所有读操作都可以看到这个修改。即便是使用了本地缓存,volatile域将立即被写入到主存中,而读操作就发生在主存中。
当一个变量需要依赖它自己历史的值、其他域的值的时候,volatile将无法正常工作。
多核处理器系统相对于单核处理器系统而言,可视性的问题远比原子性的问题多得多。
13. 临界区(synchronized代码块)
synchronized关键字不属于方法特征签名的组成部分,因此在覆盖方法的时候,可以直接加上去。
14. 线程本地存储
防止任务在共享资源上产生冲突的第二种方式——根除对变量的共享。
15. 终结任务
当存在多个任务同时读写同一个变量的时候,过多的yield让步可能导致程序更容易的崩溃。
在线程可能被中断的代码处做double check,可以更好的监视值是否随上下文切换而修改
16.中断
中断自己
Thread.interrupt()底层由native实现,将设置线程的中断状态
因此当处于阻塞状态前/中时,将抛出中断异常(同时将复位中断状态)
Thread.interrupted()将复位线程的中断状态
借助Executor&Future<>对象可以避免直接操作interrupt()
Executor.shutdownNow() 给所有它所启动的线程发送一个interrupt()
Future.cancel() 支持中断某一个线程
当你打断被阻塞的任务时,需要清理资源。
BIO操作具有锁住你的多线程程序的可能。
ReentrantLock提供了锁的嵌套能力
不同于内建的sync锁,ReentrantLock可以被interrupt()所中断(即interrupt()打断了互斥带来的阻塞)
17. 检查中断
阻塞前/中 -> 阻塞发生 -> try-catch(InterruptException)-finally
无阻塞 -> while(!Thread.isInterrupted())
程序需要兼顾这两种情况,保持代码的健壮
18. 线程之间的协作
建议使用while(检查外部是否感兴趣的调价){wait()}
在执行操作之前,需要再次考虑感兴趣的条件是否再次发生改变,如是,再次挂起
如等待你所持有的锁的动机较多,建议notifyAll(),然后再判断动机,如对不上,则再次挂起
19. 错失的信号
notify(notifyAll)/wait() 协作的时候,可能出现错失信号,可能导致死锁。
先执行了notify,再执行wait()
可以反向推得到 <= 被钻了cpu时间片的漏洞 <= notify()/wait()所处的同步代码块设计的不够"严丝缝合" <= 存在线程共享变量(最终导致代码没有按照我们预想的执行)的逻辑判断被泄露到同步块之外了
书上的说法是:存在竞争条件
notifyAll()并不会唤醒所有挂起的线程,而是唤醒了所有的、已挂起的、等待其持有的锁的线程
20. 使用显式的Lock和Condition对象
Condition对象需要在Lock.lock()和Lock.unlock()的域中使用
Condition在程序设计上也更加符合面对对象的抽象化理念,如同Lock的设计一般,Condition实现的线程通信、协作将更加细粒度、安全、高效,因此常用于复杂的线程设计以及性能优化场景中。
21. 生产者与消费者模式
wait()、notifyAll()是一种低级的线程协作方式,即每次交互都需要"握手"。(这里说的“握手”即线程之间的直接通信)。我们可以通过程序设计,抽象化更加高级的协作模型——通过 同步队列 来间接实现线程通信。
从代码样例来看,我们通过 同步队列 来间接的操作诸如Lock.lock()、Lock()、unlock()、Condition.signalAll()、Condition.await()等“动作”。
从程序设计的演进的角度来看,假若我们在此基础上把消息抽象出来,那么是不是有一种朝 面向消息编程 趋近的意味呢?
BlockingQueue是一种同步的阻塞队列的规范(接口),常见的实现:ArrayBlockingQueue(有界)、LinkedBlockingQueue(无界)。(我常常可以联想到书上使用BlockingQueue实现吐司制作的样例)
(虽然并不见得比BlockingQueue常用) j.u.c包还提供了线程间通信可以使用的管道组件,诸如:PipedReader、PipedWriter。相较IO,管道的读取可以被中断。(放在产消模型中,可以理解为将中间件——同步队列拆分成读、写两个管道)
22. 死锁
先通过书中的经典小故事—— 一桌要么用餐要么思考哲学家 来引入 引发死锁的必要条件。
(两人之间仅有一根筷子,当然,用餐需要两根筷子…)
(引发死锁的必要条件也可以理解为,当程序逻辑误入了“哲学家”的窘境的时候,此时将引发死锁)
靠近的两个哲学家不可能同时用餐 -> 他们中间的筷子仅有一根 -> 互斥
如果一个哲学家想要用餐了,必须等待旁边的哲学家放下筷子 -> 等待资源
贵为哲学家,当然是不能动用武力的... -> 不能抢走
既然刚好围坐,那么左右都是有人的啦 -> 成环
不仅如此,我们还可以借助这个样例,想的更远:
如果哲学家们都更加讲礼节、更加不争不抢、更多的思考、更少的用餐 -> 对 筷子 的竞争将减少 -> 死锁的发生概率也会相应的减少。
23. 新类库的构件
j.u.c 还提供了一些非常有意思的组件:CountDownLatch、CyclicBarrier(有点像ES6的Promise给我的感觉)。
CountDownLatch、CyclicBarrier两者内部都维护了一个计数变量,记录了阻塞线程的个数,当置0的时候,即已经没有阻塞线程的任务了。
不同的话,CountDownLatch 如同他的名字一般,只能慢慢减小至0,不能重置值;
CyclicBarrier像是一道’栅栏",可将并发的任务在“栅栏”前列队,当内置量减小到0时,将根据队列串行的执行,并且内置量支持复用。
除了前面提及的同步队列(均是FIFO的),这里j.u.c还提供了两种非FIFO,而是支持定制顺序的同步队列:DelayQueue & PriorityBlockingQueue(两者可以说是相通的)。
两者实现定制顺序的能力是由Comparable接口提供的。
DelayQueue将等待时间最长的元素置于队列头。
PriorityBlockingQueue则是更加直接——通过传入的优先级比较来决定顺序。
ScheduledExecutor 支持指定执行周期的执行器,这个"scheduled"让我难免的联想到Springboot提供cron表达式实现定时任务的注解以及Quartz架构中的任务调度器。
Semaphore,计数信号量
一般的锁,只允许一个资源由一个任务独享,其余任务不得访问
像是一个资源对外分发(签入使用完毕的资源、签出被申请的资源)“许可证”的变量
建议将其维护在一个泛型的对象池中(包含了一定数量、用于分发的资源)
冗余的签入将自动被忽略
Exchanger
两个任务交换对象的时候,如果对象的构造将带来不小的开销,那么使用Exchanger将使得对象在构造的时候直接被消费
下面是Exchanger在 产、消模型 中的简笔画:
24.性能调优
Atomic对象只能在其修改不涉及其他变量的场景应用,因此更多情况下我们应该使用互斥。
考虑到代码的阅读次数远大于编写次数,因此人与人之间的交流显得比人与计算机的交流,这也造就了使用synchronized替代Lock对象的倾向。
25. 免锁容器
保证其读写并发的通常策略是:只要写入者只在一个不可视的副本(部分或是完整的)上修改,那么将不会影响到读取者(读取者可以在写入完成之后看到这个修改) => 读、写可以发生。
CopyOnWriteArrayList
写入时将创建一个完整的源数组的副本,待到修改结束之后,将使用一个原子操作将新数组换入。基于整体替代,该类支持在迭代的时候,发起修改操作,而不会抛出并发修改异常。(CopyOnWriteArraySet也是如此)
ConcurrentHashMap & ConcurrentLinkedQueue
相较CopyOnWriteArrayList,他们在写入的时候,拷贝的是局部的副本。
26. 乐观锁
通过对比免锁容器相较synchronized而言的高效读取(毕竟省下了获取/释放锁的开销),同时我们也需要斟酌免锁容器在"写入"任务的并发达到何种程度下,程序性能会不再占据性能的优势。
书中通过多组不同数量的读取者、写入者任务给免锁容器性能带来的动摇来展开免锁容器性能的讨论。
ConcurrentHashMap在添加写入者任务所受的影响要小于CopyOnWriteArrayList。
Atomic对象的操作一般是原子的,但是有些个Atomic对象支持乐观的加锁方式,这意味着在执行计算的时候,不会被加锁,并将计算得到的结果与原来的值一并提交给compareAndSet(),如果旧值与Atomic对象中的值不一样的时候,意味着操作期间已经被修改了,此次操作将失败。这需要思考在compareAndSet()失败之后需要做什么?
27. ReadWriteLock
是对于频繁读取、较少的写入场景中的一种优化方案。
如果写入锁被持有,那么所有读取者均无法访问,只能等待写入完成
28. 活动对象
何为活动对象?比较贴近 面向过程编程 的抽象模型,即每个对象都维护自己的线程、消息队列,并且所有关于对象的请求只能同时执行一个,其他的请求都将列队,这样可以帮助我们串行化消息,基于其有序,因此我们不再需要关心中断的问题了。
Futrue对象如何实现将并发程序的焦点由任务线程转移到消息本身?Futrue作为承载消息的对象提供了返回任务结果、获取当前任务执行状态的能力 => 这就好比预设了一个结果,并可以通过获取执行状态暴露实时进度 => 那么程序设计可以站在一个"将来式"的角度上,像是JavaScript的回调一般 => 此时编写的方法就如同传递消息一般,返回一个Future对象。
由于活动对象的工作线程在一个时刻只会执行一个消息,那么竞争下也将不会发生死锁,同步消息也将通过方法调用排队。
可以通过 代理编程 来简化活动对象解决方案带来的编码的繁琐。代理编程具有网络和机器的透明性。
29. 总结
多线程的缺点:
等待资源的性能降低
需要处理线程的额外CPU花费
糟糕的程序设计容易导致不必要的复杂度
存在一些病态的行为:饿死、竞争、死锁、活锁(多个运行各自任务的线程导致整体无法运行)