前言

疫情期间看完了《Java并发编程实战》一书,看完之后觉得有些囫囵吞枣,没有留下深刻印象,因此写点小小总结,把握一下重点知识,尽量形成认知框架中的一部分。

原书的内容十分详细,也肯定写得比我好。我这里只做简单的概括,详细的还是去书里看比较好。

并发编程

Java并发编程的核心目的是识别出任务中必须串行和可以并行的部分,将可以并行的部分尽量交给多线程并行处理,高效利用多处理器,以提高程序整体的性能。同时,由于多线程并行会引起线程安全性、活跃性和性能等多方面的问题,如何规避这些问题同样十分重要。

多线程带来的风险:

  • 安全性问题:多线程的操作执行顺序不可预测,甚至会产生奇怪的结果,在没有充足同步的情况下,线程安全无法保证。解决线程安全性问题,则需要保证“永远不会发生糟糕的事情
  • 活跃性问题:“某件正确的事情最终会发生”,这也是由于多线程竞争和轮序执行会导致的问题。例如,线程A错误的无限循环可能导致线程B无法被执行。
    • 常见问题:死锁、饥饿、活锁
  • 性能问题:希望“正确的事情尽快发生”,解决活跃性问题并不能保证程序的性能
    • 常见问题:服务时间过长、响应不灵敏等

线程安全性

线程安全性:当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步和协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的

为了维护线程安全性,主要有2种思路:

  1. 避免对象发布,将对象实现为线程内部的变量,让其他线程无法访问,例如private修饰的变量,ThreadLocal修饰的变量
    • 适用于本身就无需于其他线程共享的变量,此时仅需要做好变量的管理,避免逸出
  2. 通过加锁、原子性变量、同步容器实现对象的安全发布
    • 适用于必然共享的变量,在发布之前确保其安全性

线程活跃性

导致线程活跃性问题的一般情况有死锁、饥饿、糟糕的响应性、活锁等

  • 饥饿:当线程无法访问它所需要的资源而不能继续执行时,就导致饥饿,最常见的资源就是时钟周期
    • 线程调度与线程优先级有关,操作系统会尽力去平衡
  • 糟糕的响应性:线程的长时间执行导致其他线程无法执行,导致其响应时间过长
  • 活锁:线程不会被阻塞,但也没法继续执行,因为会一直重复执行相同的操作(比如重复尝试拿锁)
    • 解决:在重试机制中引入随机性,以避免重复多次的竞争失败(只要每次有人成功,就能执行下去)

死锁:

  • 锁顺序死锁:线程拿锁的顺序不同,导致握有对方需要的锁同时需要对方握有的资源,引起死锁
    • 解决:对加锁行为进行全局分析,制定拿锁的顺序
  • 动态的锁顺序死锁:以transferMOney(Account fromAccount, Account toAccount)为例,看似拿锁顺序固定,但由于业务多变,传参时仍可能出现顺序死锁的可能性
    • 解决:对每个Account进行哈希,得到固定的大小顺序;若哈希值相同,则可以使用Tie-Breaking
  • 在协作对象之间发生的死锁:协作对象的方法都有加锁,在某个方法调用其他对象的加锁方法,就会出现顺序死锁的问题,但其十分隐晦
    • 如果在持有锁的情况下调用某个外部方法,就需要小心死锁
    • 解决:开放调用,缩小锁的粒度,不在方法级实现加锁,而在关键变量访问的地方加锁即可
  • 资源死锁:举例:线程A持有与数据库D1的连接,并等待与数据库D2的连接;线程B持有与数据库D2的连接,并等待与数据库D1的连接

死锁的判断:

  1. 两阶段策略:找出需要获取的锁,对这些实例进行分析,确保拿锁顺序的一致性
  2. 支持定时的锁:用显式锁Lock代替内置锁,LocktryLock功能,还可以设置超时时间Timeout
  3. 通过线程转储信息来分析死锁

线程性能

根据Amdahl定律,在包含N个处理器的机器中,最高加速比为:$Speedup \leq \frac{1}{F+\frac{(1-F)}{N}}$,当N趋于无穷大时,最大加速比仅为 $\frac{1}{F}$

公式说明,引入并行确实可以加速程序运行,但即便处理器数量真的无限,并行的部分也是有限的,且引入并行的过程中(如创建线程的过程)也会增加新的串行部分

  • 线程引入的开销
    • 上下文切换:线程调度过程中需要访问由操作系统和JVM共享的数据结构,且它们和用户线程使用同一组CPU,新线程创建时可能会有缓存缺失等等。这些都是上下文切换带来的开销
    • 内存同步:根据Java内存模型可知,为了保持多个线程共享的变量的一致性,对变量的读写都需要更新到主内存中,这在一定程度上降低了线程性能
    • 阻塞:当线程由于竞争锁失败或I/O操作等原因阻塞时,可以通过自旋等待或直接挂起的方式处理,具体选择取决于上下文切换的开销等。

串行操作会降低可伸缩性(即程序在多处理器下的性能增加程度),并且上下文切换会降低程序性能,而在锁上发生竞争时会同时导致这两种问题。如何减少锁的竞争:

  • 缩小锁的范围:将锁无关的代码移出同步代码块,实现“快进快出”
  • 减小锁的粒度:降低锁的请求频率,减小竞争发生的可能性
  • 锁分段
  • 避免热点域
  • 替代独占锁:并发容器、读写锁、不可变对象、原子变量

参考

原本打算把每个关键字和工具都记录一下的,正好看到了别人的记录,觉得挺好的,分享一下
Java并发编程相关