Ahab's Studio.

Java 并发编程知识点总结

字数统计: 5.4k阅读时长: 18 min
2020/10/07 Share

目录:

  1. 线程基础
  2. 线程池
  3. 各种各样的锁
  4. 并发容器
  5. 原子类
  6. Java 内存模型
  7. 线程协作
  8. AQS 框架

一、线程基础

1. 为什么继承 runnable 接口比继承 Thread 类的线程实现方式好?

  • 可以把不同的执行内容解耦,全责分明
  • 某些情况可以减少开销,提高性能(比如可用线程池中已有的线程去执行 runnable,而不用重新创建线程)
  • 继承 Thread 类的单继承特性会限制代码的扩展性

2. 线程是如何在 6 种状态之间转化的?

  • 线程的 6 种状态:New(新创建)、Runnable(可运行)、Blocked(被阻塞)、Waiting(等待)、Timed_waiting(计时等待)、Terminated(被终止)
  • 新创建线程处于 New 状态,调用 Thread#start 方法后进入 Runnable 状态,Runnable 对应操作系统的 Running 和 Ready 状态,代表可能正在被执行或正在等待 CPU 分配资源
  • 当要进入 synchronized 方法或代码块时却没抢到 monitor 锁,会由 Runnable 状态进入 Blocked 状态,获取到 monitor 锁后会进入 Runnable 状态
  • 执行 Object#wait 或 LockSupport#park 会进入 Waiting 状态;执行带 timeOut 参数的 Object#wait 或 LockSupport#park 会进入 Timed_waiting 状态
  • 调用 LockSupport#unpark 、被中断或超时时间到会由 Waiting/Timed_waiting 状态进入 Runnable 状态
  • 被 notify/notifyAll 唤醒,会由 Waiting/Timed_waiting 状态进入 Blocked 状态
  • run 方法执行完或异常终止会进入 Terminated 状态
  • 一个线程只会经历一次 New 和 Terminated 状态,中间状态才可以相互转换

3. 如何理解锁池和等待池?

  • 如果某对象的锁已被一个线程占有,其他线程调用此对象的 sychronized 代码块时无法获取到锁,就会进入此对象的锁池,锁池中的线程会竞争该对象的锁
  • 如果一个线程调用了 Object#wait 方法,此线程就会进入此对象的等待池,等待池中的线程不会去竞争该对象的锁
  • 调用 notify 方法会随机唤醒一个等待池中的线程,并移到锁池中;调用 notifyAll 方法会唤醒等待池中所有的线程,并全移到锁池中

4. 为什么 Object#wait 要写在 while(condition) 循环中?

  • 规避虚假唤醒导致的问题,虚假唤醒是指线程可能在未调用 notify/notifyAll、未被中断和等待超时的情况下被意外唤醒,所以 wait 要写在 while(condition) 循环中,保证在发生虚假唤醒时程序的正确性

5. 如何正确的中断线程?

  • 调用 Thread#interrupt 方法给线程发送中断信号,线程中通过 Thread#isInterrupted 方法判断是否被中断,若被中断则停止当前执行任务
  • 线程中通过 Thread#sleep 或 BlockingQueue#put 等方法休眠时,若被中断则会抛出 InterruptedException 异常并清除中断标记位,所以要捕获处理此异常,或再调用 Thread#interrupt 标记中断使后续代码能处理中断
  • 使用 volatile 标记位变量中断线程是错误的,因为不能中断 Thread#sleep 或 BlockingQueue#put 等方法进入的休眠状态

6. Object#wait 和 Thread#sleep 方法的异同?

  • 相同点:都可以让线程阻塞;都可以响应线程中断
  • 区别:wait 方法必须写在 synchronized 代码块;wait 方法会主动释放 monitor 锁;sleep 方法必须传入 timeout 参数

二、线程池

1. 使用线程池相比手动创建线程有什么优点?

  • 频繁创建线程系统开销大,而线程池可用一些固定的工作线程反复执行任务,避免频繁创建线程
  • 过多线程会占用过多内存,而线程池可以控制线程的总数量,避免占用过多内存资源
  • 线程池可更方便的统筹管理任务执行和线程,避免手动创建线程难管理、难统计的问题

2. 线程池各个参数的含义?

  • corePoolSize 核心线程数:常驻的工作线程,初始化时核心线程数默认为 0,创建后不会被销毁
  • maximumPoolSize 最大线程数:当 workQueue 存放满时,线程池会进一步创建线程,可创建的最多数量为 maximumPoolSize
  • keepAliveTime/TimeUnit 空闲线程存活时间:当大于 corePoolSize 部分的线程空闲超过存活时间后,会被回收
  • threadFactory 用来创建线程的线程工厂:方便给线程自定义命名以及线程优先级
  • workQueue 存放任务的阻塞队列:当线程数超过 corePoolSize 后,会将任务存放到 workQueue 中等待执行
  • handler 任务被拒绝时的处理:当线程池已 shutdown 关闭或线程数达到 maximumPoolSize 时新提交的任务会被拒绝
  • 注意:当 workQueue 为无界队列时, maximumPoolSize 参数其实不会被用到,是没意义的

3. 线程池的四种拒绝策略?

  • AbortPolicy:抛出 RejectedExecutionException 异常,可根据业务进行重试等操作
  • DiscardPolicy:直接丢弃新提交的任务,不做其他反馈,有任务丢失风险
  • DiscardOldestPolicy:如果线程池未关闭,就丢弃队列中存活时间最长的任务,但不做其他反馈,有任务丢失风险
  • CallerRunsPolicy:如果线程池未关闭,就在提交任务的线程直接开始执行任务,任务不会被丢失,由于阻塞了提交任务的线程,相当于提供了负反馈

4. 有哪 6 种常见的线程池?

  • FixedThreadPool:固定线程数的线程池,核心线程数与最大线程数相同,任务存放队列为无界阻塞队列(LinkedBlockingQueue)
  • CachedThreadPool:可缓存线程池,核心线程数为 0,最大线程数为 Integer.MAX_VALUE,任务存放队列为中转阻塞队列(SynchronousQueue)
  • SingleThreadExecutor:单工作线程线程池,核心线程数为 1,任务存放队列为无界阻塞队列(LinkedBlockingQueue)
  • ScheduledThreadPool:定时或周期性任务线程池,任务存放队列为无界优先级阻塞队列(DelayedWorkQueue)
  • SingleThreadScheduledExecutor:定时或周期性任务单工作线程线程池,核心线程数为 1,任务存放队列为无界优先级阻塞队列(DelayedWorkQueue)
  • ForkJoinPool:适合执行可以产生并行子任务的任务,可方便的分裂(Fork)成子任务执行并汇总(Join)结果,任务存放队列为 WorkQueue,除了公用队列外,每个线程还有一个独立的队列来存放任务

5. 线程池常用的阻塞队列有哪些?

  • LinkedBlockingQueue 无界阻塞队列:任务队列容量为 Integer.MAX_VALUE,永远不会放满,所以对应线程池只会创建核心线程数量的工作线程,而最大线程数参数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程
  • SynchronousQueue 中转阻塞队列:不存放任务,一旦有任务被提交就直接转发给线程或者创建新线程来执行
  • DelayedWorkQueue 无界优先级阻塞队列:内部采用堆数据结构,按照延迟时间长短对任务进行排序,ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,正是因为它们本身是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行
  • ArrayBlockingQueue 有界队列:任务队列容量可配置,结合最大线程数与拒绝策略可有效的规避资源被耗尽的风险

6. 为什么不建议使用常见的线程池?

  • FixedThreadPool 和 SingleThreadExecutor 任务存放队列为无界队列(LinkedBlockingQueue),任务过多时会占用大量内存并导致 OOM
  • CachedThreadPool 虽然不存储任务,但线程数没有上限,任务过多时会创建非常多的线程,导致超过线程数量上限或 OOM
  • ScheduledThreadPool 和 SingleThreadScheduledExecutor 任务存放队列为无界队列(DelayedWorkQueue),任务过多时会占用大量内存并导致 OOM
  • 手动创建可以根据业务选择合适的线程数量,制定拒绝策略,避免资源耗尽的风险

7. 合适的线程数量是多少?

  • CPU 密集型任务无需设置过多线程数,因为此类任务需占用大量 CPU 资源,设置过多线程数会导致多个线程都去抢占 CPU 资源,产生不必要的上下文切换,从而造成整体性能下降
  • IO 密集型任务可设置较多线程数,因为此类任务 IO 操作较耗时,但不会占用太多 CPU 资源,设置过少线程数会导致 CPU 资源空闲,导致 CPU 资源的浪费
  • 所以 CPU 耗时所占比例越高,就需要越少的线程;IO 耗时所占比例越高,就需要越多的线程
  • 通用公式:线程数 = CPU 核心数 * (1 + IO 耗时/CPU 耗时)
  • 例如 8 核机器执行一个 CPU 耗时 5ms,DB 耗时 100ms 的任务,线程数 = 8*(1+100/5) = 168 个
  • QPS(req pre second) 即一秒可执行次数,上例中 QPS = 168(1000/105) = 1600 。若 DB 最大 QPS 限制为 1000,则按比例减少线程数为 168(1000/1600) = 105 个
  • 如果不同任务的 CPU 耗时和 IO 耗时各不相同,可对所有任务的 CPU 耗时和 IO 耗时求个平均值进行计算;

8. 如何正确的关闭线程池?

  • shutdown():调用后会在执行完正在执行任务和队列中等待任务后才彻底关闭,并会根据拒绝策略拒绝后续新提交的任务
  • shutdownNow():调用后会给正在执行任务线程发送中断信号,并将任务队列中等待的任务转移到一个 List 中返回,后续会根据拒绝策略拒绝新提交的任务
  • isShutdown():判断是否开始关闭线程池,即是否调用了 shutdown() 或 shutdownNow() 方法
  • isTerminated():判断线程池是否真正终止,即线程池已关闭且所有剩余的任务都执行完了
  • awaitTermination():阻塞一段时间等待线程池终止,返回 true 代表线程池真正终止否则为等待超时

9. 线程池线程复用的原理?

  • 线程池将线程和任务解耦,一个线程可以从任务队列中获取多个任务执行
  • 关键类为 ThreadPoolExecutor 内部的 Worker 类,对应于一个线程,其内部会从任务队列中获取多个任务执行

三、各种各样的锁

1. 悲观锁/乐观锁

  • 悲观锁指在操作同步资源前必须先拿到锁;而乐观锁利用 CAS 理念,在不独占资源的情况下对资源进行修改
  • 悲观锁适合用于并发写入多、临界区代码复杂、竞争激烈等场景,这种场景下悲观锁可以避免大量的无用的反复尝试等消耗
  • 乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高

2. 可重入锁/非可重入

  • 可重入是如果指线程已经持有锁,则能在不释放这把锁的情况下,再次获取这把锁
  • Java 中的 ReentrantLock 和 synchronized 都是可重入锁

3. 共享锁/独占锁

  • 共享锁指同一把锁可以同时被多个线程获取,而独占锁指一把锁只能同时被一个线程获取
  • ReentrantReadWriteLock 的读锁就是共享锁,可以同时被多个线程读取;写锁则为独占锁,同时只能被一个线程写

4. 自旋锁/非自旋锁

  • 自旋是指拿不到锁时不陷入阻塞,而是循环尝试获取锁
  • 自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率
  • 如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源

5. 公平锁/非公平锁

  • 公平锁是指各个线程公平平等,排队获取锁时等待的时间越长就会优先获取到锁,
  • 非公平锁是指线程可能存在插队现象,比如一个阻塞等待中的线程 A 和新来的线程 B 同时竞争一把锁时线程 B 会插队先获取到锁
  • 非公平锁整体执行速度为什么能更快:如上例,唤醒线程是需要耗时的,与其漫长的等待唤醒 A,不如直接先让 B 插队执行,这样可以跳过 B 阻塞、唤醒的状态切换
  • 非公平锁的优缺点:整体执行速度更快、吞吐量更大,但可能产生线程饥饿导致某个线程长时间得不到执行

6. 可中断锁/不可中断锁

  • 可中断指等待获取锁时可被中断从而取消等待;synchronized 是不可中断锁

7. 偏向锁/轻量级锁/重量级锁

  • 特指 synchronized 锁的几种状态
  • 锁的升级路径:无锁->偏向锁->轻量级锁->重量级锁
  • 偏向锁:当一个线程第一次尝试获取某个对象的锁时,仅记录这个线程为偏向锁的拥有者,后续获取锁时如果是同个线程,就可以直接获取锁,开销很小,当多线程发生实际竞争时会升级为轻量级锁
  • 轻量级锁:线程会通过自旋的方式尝试获取锁(自旋锁),不会阻塞,开销较小,当锁竞争时间较长时会膨胀为重量级锁
  • 重量级锁:利用操作系统同步机制实现,会让线程进入阻塞状态,开销较大

8. JVM 对 synchronized 锁做了哪些优化?

  • 锁的升级:无锁->偏向锁->轻量级锁->重量级锁
  • 锁消除:虚拟机编译时,对一些代码上使用 synchronized 同步,但是被检测到不可能存在共享数据竞争的锁进行削除
  • 锁粗化:把不间断、高频锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗

四、并发容器

1. Vector/HashTab

  • 内部使用 synchronized 方法级别的锁保证线程安全,锁的粒度比较大
  • 在并发量高的时候很容易发生竞争,并发效率比较低

2. ConcurrentHashMap

  • Java7 中基于普通的 HashMap 数组+链表结构,采用分段锁的机制
  • Java8 中基于数组+链表+红黑树结构,采用 CAS + synchronized 同步机制
  • 红黑树相比链表可以提高查找效率,复杂度为 O(log(n))
  • 为什么链表长度大于 8 时转换为红黑树?如果 hashCode 分布离散良好、链表符合泊松分布,那链表长度为 8 的概率小于千万分之一,红黑树更多的是一种保底策略,用来保证 hash 算法异常等极端情况下的查询效率
  • 为什么不采取仅数组+红黑树的结构?红黑树节点相比链表占用内存约大一倍,而链表较短时查找也很快,所以优先采取链表结构

3. CopyOnWriteArrayList

  • 基于 CopyOnWrite 机制,写入时会先创建一份副本,写完副本后直接替换原内容
  • 优点:比读写锁更近一步,只需写写互斥,读取不用加锁,对于读多写少的场景可以大幅提升性能
  • 缺点:写入时存在创建副本开销及副本所多占的内存,读写不互斥可能会导致数据无法及时保持同步

五、原子类

1. 基本类型原子类

  • 包括 AtomicInteger、AtomicLong、AtomicBoolean
  • 提供了基本类型的 getAndSet、compareAndSet 等原子操作
  • 底层基于 Unsafe#compareAndSwapInt、Unsafe#compareAndSwapLong 等实现

2. 数组类型原子类

  • 包括 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
  • 数组里的元素都可以保证其原子性,相当于把基本类型原子类聚合起来,组合成一个数组

3. 引用类型原子类

  • 包括 AtomicReference、AtomicStampedReference、AtomicMarkableReference
  • 用于让一个对象保证原子性,底层基于 Unsafe#compareAndSwapObject 等实现
  • AtomicStampedReference 是对 AtomicReference 的升级,在此基础上加了时间戳,用于解决 CAS 的 ABA 问题

4. 升级类型原子类

  • 包括 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
  • 对于非原子的基本或引用类型,在不改变其原类型的前提下,提供原子更新的能力
  • 适用于由于历史原因改动成本太大或极少情况用到原子性的场景

5. 累加器

  • 包括 LongAdder、DoubleAdder
  • 相比于基本类型原子类,累加器没有 compareAndSwap、addAndGet 等方法,功能较少
  • 设计原理:将 value 分散到一个数组中,不同线程只针对自己命中的槽位进行修改,减小高并发场景的线程竞争概率,类似于 ConcurrentHashMap 的分段锁思想
  • 可解决高并发场景 AtomicLong 的过多自旋问题

6. 积累器

  • 包括 LongAccumulator、DoubleAccumulator
  • 是 LongAdder、DoubleAdder 的功能增强版,提供了自定义的函数操作

7. 原子类与锁

  • 都是为了保证并发场景下线程安全
  • 原子类粒度更细,竞争范围为变量级别
  • 原子类效率更高,底层采取 CAS 操作,不会阻塞线程
  • 原子类不适用于高并发场景,因为无限循环的 CAS 操作会占用 CPU

8. 原子类与 volatile

  • volatile 具有可见性和有序性,但不具备原子性
  • volatile 修饰 boolean 类型通常保证线程安全,因为赋值操作具有原子性
  • volatile 修饰 int 类型通常无法保证线程安全,因为 int 类型的计算操作需要读取、修改、赋值回去,不是原子操作,这时需要使用原子类

六、Java 内存模型

1. 内存结构与内存模型

  • 内存结构描述了 JVM 运行时内存区域结构,包括:堆、方法区、虚拟机栈、本地方法栈、程序计数器、运行时常量池
  • 内存模型(JMM)是和多线程相关的一组规范,与 Java 并发编程有关

2. 主内存和工作内存

  • CPU 有多级缓存,会存在数据不同步的情况,JMM 屏蔽了 CPU 缓存的底层细节,抽象为主内存和工作内存
  • 工作内存中存在一份主内存数据的副本,每个线程只能接触工作内存,无法直接操作主内存

3. 内存可见性

  • 指一个线程修改了工作内存的值后,其他线程能正确感知到最新的值
  • 满足于 happens-before 关系的原则具备可见行,比如单线程、volatile、锁同步等规则

4. 指令重排序

  • 编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序
  • volatile 具备禁止重排序的特性
  • 单例模式的双重检查模式需要添加 volatile 修饰,规避指令重排序导致的对象引用判断不为 null,但对象仍未初始化完的问题

七、线程协作

1. Semaphore

  • 通过控制许可证的发放和归还实现统一时刻可执行某任务的最大线程数
  • 信号量可以被 FixedThreadPool 代替吗?不能,信号量具有可跨线程、跨线程池的特性,相比 FixedThreadPool 更灵活,更适合于限制并发访问的线程数

2. CountDownLatch

  • 用于并发流程控制,等到一个设定的数值达到之后,才能开始执行
  • 不可重用,若已完成倒数,则不能再重置使用

3. CyclicBarrier

  • 与 CountDownLatch 类似,都能阻塞一个或一组线程,直到某个预设的条件达成,再统一出发
  • CountDownLatch 作用于一个线程,CountDownLatch 作用于事件
  • 可重用,若已达成条件,可重置继续使用
  • 可定义条件达成后的自定义执行动作

八、AQS 框架

1. AQS 及存在的意义?

  • AQS 是一个用于构建锁、同步器等线程协作工具类的框架,即 AbstractQueuedSynchronizer 类
  • ReentrantLock、Semaphore、CountDownLatch 等工具类的工作都是类似的,AQS 就是这些类似工作提取出来的公共部分,比如阀门功能、调度线程等
  • AQS 可以极大的减少上层工具类的开发工作量,也可以避免上层处理不当导致的线程安全问题

2. AQS 内部的关键原理

  • state 值:AQS 中具有一个 int 类型的 state 变量,在不同工具类中代表不同的含义,比如在 Semaphore 中代表剩余许可证的数量;在 CountDownLatch 中代表需要倒数的数量;在 ReentrantLock 中代表锁的占有情况,0 代表没被占有,1 代表被占有,大于 1 代表同个线程重入了
  • FIFO 队列:用于存储、管理等待的线程
  • 获取、释放锁:需工具类自行实现,比如 Semaphore#acquire、ReentrantLock#lock 为获取; Semaphore#release、ReentrantLock#unlock 为释放

原文作者:Ahab

原文链接:http://yhaowa.gitee.io/d55eb6b/

发表日期:October 7th 2020, 12:45:59 pm

更新日期:October 25th 2020, 8:53:38 pm

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 一、线程基础
    1. 1.0.1. 1. 为什么继承 runnable 接口比继承 Thread 类的线程实现方式好?
    2. 1.0.2. 2. 线程是如何在 6 种状态之间转化的?
    3. 1.0.3. 3. 如何理解锁池和等待池?
    4. 1.0.4. 4. 为什么 Object#wait 要写在 while(condition) 循环中?
    5. 1.0.5. 5. 如何正确的中断线程?
    6. 1.0.6. 6. Object#wait 和 Thread#sleep 方法的异同?
  • 2. 二、线程池
    1. 2.0.1. 1. 使用线程池相比手动创建线程有什么优点?
    2. 2.0.2. 2. 线程池各个参数的含义?
    3. 2.0.3. 3. 线程池的四种拒绝策略?
    4. 2.0.4. 4. 有哪 6 种常见的线程池?
    5. 2.0.5. 5. 线程池常用的阻塞队列有哪些?
    6. 2.0.6. 6. 为什么不建议使用常见的线程池?
    7. 2.0.7. 7. 合适的线程数量是多少?
    8. 2.0.8. 8. 如何正确的关闭线程池?
    9. 2.0.9. 9. 线程池线程复用的原理?
  • 3. 三、各种各样的锁
    1. 3.0.1. 1. 悲观锁/乐观锁
    2. 3.0.2. 2. 可重入锁/非可重入
    3. 3.0.3. 3. 共享锁/独占锁
    4. 3.0.4. 4. 自旋锁/非自旋锁
    5. 3.0.5. 5. 公平锁/非公平锁
    6. 3.0.6. 6. 可中断锁/不可中断锁
    7. 3.0.7. 7. 偏向锁/轻量级锁/重量级锁
    8. 3.0.8. 8. JVM 对 synchronized 锁做了哪些优化?
  • 4. 四、并发容器
    1. 4.0.1. 1. Vector/HashTab
    2. 4.0.2. 2. ConcurrentHashMap
    3. 4.0.3. 3. CopyOnWriteArrayList
  • 5. 五、原子类
    1. 5.0.1. 1. 基本类型原子类
    2. 5.0.2. 2. 数组类型原子类
    3. 5.0.3. 3. 引用类型原子类
    4. 5.0.4. 4. 升级类型原子类
    5. 5.0.5. 5. 累加器
    6. 5.0.6. 6. 积累器
    7. 5.0.7. 7. 原子类与锁
    8. 5.0.8. 8. 原子类与 volatile
  • 6. 六、Java 内存模型
    1. 6.0.1. 1. 内存结构与内存模型
    2. 6.0.2. 2. 主内存和工作内存
    3. 6.0.3. 3. 内存可见性
    4. 6.0.4. 4. 指令重排序
  • 7. 七、线程协作
    1. 7.0.1. 1. Semaphore
    2. 7.0.2. 2. CountDownLatch
    3. 7.0.3. 3. CyclicBarrier
  • 8. 八、AQS 框架
    1. 8.0.1. 1. AQS 及存在的意义?
    2. 8.0.2. 2. AQS 内部的关键原理