Java 并发 随笔 1-初尝并发

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花费
糟糕的程序设计容易导致不必要的复杂度
存在一些病态的行为:饿死、竞争、死锁、活锁(多个运行各自任务的线程导致整体无法运行)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

肯尼思布赖恩埃德蒙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值