并发与多线程

本讲主要内容是 Java 的多线程和并发。重点知识有线程的状态转换、线程的同步与互斥、线程池的运作机制详解,以及 JUC 中常用的工具类。

多线程知识点


多线程协作时,因为对资源的锁定与等待会产生死锁,这里需要了解产生死锁的四个基本条件,要明白竞争条件与临界区的概念,知道可以通过破坏造成死锁的 4 个条件来防止死锁。

 

前面讲过进程间的通信方式,这里还要知道线程间的通信方式,通信主要指线程之间的协作机制,例如 wait、notify 等。

 

还需要知道 Java 为多线程提供的一些机制,例如 ThreadLocal 用来保存线程独享的数据, Fork/Join 机制用于大任务的分割与汇总,Volatile 对多线程数据可见性的保证,以及线程的中断机制。

 

其他还有:ThreadLocal 的实现机制。Fork/Join 的工作窃取算法等内容。

详解线程状态转换

线程是 JVM 执行任务的最小单元,理解线程的状态转换是理解后续多线程问题的基础。在 JVM 运行中,线程一共有 NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED 六种状态,这些状态对应 Thread.State 枚举类中的状态。

 

如下图所示,当创建一个线程时,线程处在 NEW 状态,运行 Thread 的 start 方法后,线程进入 RUNNABLE 可运行状态。


      

这时,所有可运行状态的线程并不能马上运行,而是需要先进入就绪状态等待线程调度,如图中间所示的 READY 状态。在获取 CPU 后才能进入运行状态,如图中所示的 RUNNING。运行状态可以随着不同条件转换成除 NEW 以外的其他状态。

 

如图左侧所示,在运行态中的线程进入 synchronized 同步块或者同步方法时,如果获取锁失败,则会进入到 BLOCKED 状态。当获取到锁后,会从 BLOCKED 状态恢复到就绪状态。

 

如图右侧所示,运行中的线程还会进入等待状态,这两个等待一个是有超时时间的等待,例如调用 Object.wait、Thread.join 等;另外一个是无超时的等待,例如调用 Thread.join 或者 Locksupport.park 等。这两种等待都可以通过 notify 或 unpark 结束等待状态并恢复到就绪状态。

 

最后是线程运行完成结束时,如图下侧所示,线程状态变成 TERMINATED。

详解 CAS 与 ABA 问题

这部分内容详解线程的同步与互斥,解决线程同步与互斥的主要方式是 CAS、synchronized 和 lock。

CAS

CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。这是一种乐观策略,认为并发操作并不总会发生。


      

比较并写回的操作是通过操作系统原语实现的,保证执行过程中不会被中断。

ABA

CAS 容易出现 ABA 问题,就是如下面时序图所示,如果线程 T1 读取值 A 之后,发生两次写入,先由线程 T2 写回了 B,又由 T3 写回了 A,此时 T1 在写回比较时,值还是 A,就无法判断是否发生过修改。


ABA 问题不一定会影响结果,但还是需要防范,解决的办法可以增加额外的标志位或者时间戳。JUC 工具包中提供了这样的类。

详解 synchronized

synchronized 是最常用的线程同步手段之一,它是如何保证同一时刻只有一个线程可以进入临界区呢?

 

synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头、实例数据和对齐填充。在对象头中保存了锁标志位和指向 monitor 对象的起始地址,如下图所示,右侧就是对象对应的 Monitor 对象。当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。另外 Monitor 中还有两个队列,用来存放进入及等待获取锁的线程。


synchronized 应用在方法上时,在字节码中是通过方法的 ACC_SYNCHRONIZED 标志来实现的,synchronized 应用在同步块上时,在字节码中是通过 monitorenter 和 monitorexit 实现的。

 

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

详解 AQS 与 Lock

在介绍 Lock 前,先介绍 AQS,也就是队列同步器,这是实现 Lock 的基础。下图就是 AQS 的结构图,从图中可以看出,AQS 有一个 state 标记位,值为1  时表示有线程占用,其他线程需要进入到同步队列等待。同步队列是一个双向链表。


      

当获得锁的线程需要等待某个条件时,会进入 condition 的等待队列,等待队列可以有多个。当 condition 条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。ReentrantLock 就是基于 AQS 实现的,如下图所示,ReentrantLock 内部有公平锁和非公平锁两种实现,差别就在于新来的线程是否比已经在同步队列中的等待线程更早获得锁。


和 ReentrantLock 实现方式类似,Semaphore 也是基于 AQS 的,差别在于 ReentrantLock 是独占锁,Semaphore 是共享锁。


      

详解线程池

线程池通过复用线程,避免线程频繁地创建和销毁。Java 的 Executors 工具类中提供了 5 种类型的线程池创建方法,如下图所示,来看它们的特点和适用场景。


  1. 固定大小线程池,特点是线程数固定,使用无界队列,适用于任务数量不均匀的场景、对内存压力不敏感但系统负载比较敏感的场景;

  2. Cached 线程池,特点是不限制线程数,适用于要求低延迟的短期任务场景;

  3. 单线程线程池,就是一个线程的固定线程池,适用于需要异步执行但需要保证任务顺序的场景;

  4. Scheduled 线程池,适用于定期执行任务场景,支持按固定频率定期执行和按固定延时定期执行两种方式;

  5. 工作窃取线程池,使用的是 ForkJoinPool,是固定并行度的多任务队列,适合任务执行时长不均匀的场景。

详解线程池参数

线程池除了工作窃取线程池外,都是通过 ThreadPoolExecutor 的不同初始化参数来创建的。

 

创建参数列表如下图所示。


      

  • 第一个参数设置核心线程数。默认情况下核心线程会一直存活。

  • 第二个参数设置最大线程数。决定线程池最多可以创建的多少线程。

  • 第三个参数和第四个参数用来设置线程空闲时间,和空闲时间的单位,当线程闲置超过空闲时间就会被销毁。可以通过 allowCoreThreadTimeOut 方法来允许核心线程被回收。

  • 第五个参数设置缓冲队列,上图中左下方的三个队列是设置线程池时常使用的缓冲队列。其中 ArrayBlockingQueue 是一个有界队列,就是指队列有最大容量限制。LinkedBlockingQueue 是无界队列,就是队列不限制容量。最后一个是 SynchronousQueue,是一个同步队列,内部没有缓冲区。

  • 第六个参数设置线程池工厂方法,线程工厂用来创建新线程,可以用来对线程的一些属性进行定制,例如线程的 group、线程名、优先级等。一般使用默认工厂类即可。

  • 第七个参数设置线程池满时的拒绝策略。如上图右下方所示有四种策略,Abort 策略在线程池满后,提交新任务时会抛出 RejectedExecutionException,这个也是默认的拒绝策略。Discard 策略会在提交失败时对任务直接进行丢弃。CallerRuns 策略会在提交失败时,由提交任务的线程直接执行提交的任务。DiscardOldest 策略会丢弃最早提交的任务。


再来看前面的几种线程池都是使用怎样的参数来创建的。

  • 固定大小线程池创建时核心和最大线程数都设置成指定的线程数,这样线程池中就只会使用固定大小的线程数。

  • 队列使用无界队列 LinkedBlockingQueue。

  • Single 线程池就是线程数设置为 1 的固定线程池。

  • Cached 线程池的核心线程数设置为 0,最大线程数是 Integer.MAX_VALUE,主要是通过把缓冲队列设置成 SynchronousQueue,这样只要没有空闲线程就会新建。

  • Scheduled 线程池与前几种不同的是使用了 DelayedWorkQueue,这是一种按延迟时间获取任务的优先级队列。

详解线程池执行流程

向线程提交任务时可以使用 execute 和 submit,区别就是 submit 可以返回一个 future 对象,通过 future 对象可以了解任务执行情况,可以取消任务的执行,还可获取执行结果或执行异常。submit 最终也是通过 execute 执行的。

 

向线程池提交任务时的执行顺序如下图所示。


      

  1. 向线程池提交任务时,会首先判断线程池中的线程数是否大于设置的核心线程数,如果不大于,就创建一个核心线程来执行任务。

  2. 如果大于核心线程数,就会判断缓冲队列是否满了,如果没有满,则放入队列,等待线程空闲时执行任务。

  3. 如果队列已经满了,则判断是否达到了线程池设置的最大线程数,如果没有达到,就创建新线程来执行任务。

  4. 如果已经达到了最大线程数,则执行指定的拒绝策略。

       这里需要注意队列的判断与最大线程数判断的顺序,不要搞反。

详解 JUC 工具类

JUC 是 Java 提供的用于多线程处理的工具类库,来看其中的常用工具类的作用,如下图所示。


      

如上图所示,第一行的类都是基本数据类型的原子类,包括 AtomicBoolean、AtomicLong、AtomicInteger 类。

  • AtomicLong 通过 unsafe 类实现,基于CAS。unsafe 类是底层工具类,JUC 中很多类的底层都使用到了 unsafe 包中的功能。unsafe 类提供了类似 C 的指针操作,提供 CAS 等功能。unsafe 类中的所有方法都是 native 修饰的。

  • LongAdder等 4 个类是 JDK1.8 中提供的更高效的操作类。LongAdder 基于 Cell 实现,使用分段锁思想,是一种空间换时间的策略,更适合高并发场景;LongAccumulator 提供了比 LongAdder 更强大的功能,能够指定对数据的操作规则,例如可以把对数据的相加操作改成相乘操作。


第二行中的类提供了对对象的原子读写功能,后两个类 AtomicStampedReference 和 AtomicMarkableReference 用于解决前面提到的 ABA 问题,分别基于时间戳和标记位来解决问题。

 

再看下图。

      


第一行的类主要是锁相关的类,例如前面介绍过的 Reentrant 重入锁。

  • 与 ReentrantLock 的独占锁不同,Semaphore 是共享锁,允许多个线程共享资源,适用于限制使用共享资源线程数量的场景,例如 100 个车辆要使用 20 个停车位,那么最多允许 20 个车占用停车位。

  • StampedLock 是JDK 1.8 改进的读写锁,是使用一种 CLH 的乐观锁,能够有效防止写饥饿。所谓写饥饿就是在多线程读写时,读线程访问非常频繁,导致总是有读线程占用资源,写线程很难加上写锁。

       第二行中主要是异步执行相关的类。

  • 重点了解 JDK 1.8 中提供的 CompletableFuture,可以支持流式调用,可以方便的进行多 future 的组合使用,例如可以同时执行两个异步任务,然后对执行结果进行合并处理。还可以很方便地设置完成时间。

  • 另外一个是 JDK 1.7 中提供的 ForkJoinPool,采用分治思想,将大任务分解成多个小任务处理,然后在合并处理结果。ForkJoinPool 的特点是使用工作窃取算法,可以有效平衡多任务时间长短不一的场景。


其他 JUC 常用工具如下图所示。

      


第一行是常用的阻塞队列,讲解线程池时已经简单介绍过了,这里再补充一些。

  • LinkedBlockingDeque 是双端队列,也就是可以分别从队头和队尾操作入队、出队。

  • ArrayBlockingQueue 单端队列,只能从队尾入队,队头出队。 


第二行是控制多线程协作时使用的类。

  • CountDownLatch 实现计数器功能,可以用来控制等待多个线程执行任务后进行汇总。

  • CyclicBarrier 可以让一组线程等待至某个状态之后,再全部同时执行,一般在测试时使用,可以让多线程更好的并发执行。

  • Semaphore 用来控制对共享资源的访问并发度。


最后一行是比较常用的两个集合类,ConcurrentHashMap 我们前面的课程已经详细介绍过了,这里可以了解 CopyOnWriteArrayList,COW 通过写入数据时进行 copy 修改,然后更新引用的方式,来消除并行读写中的锁使用,比较适合读多写少,数据量比较小,但是并发非常高的场景。

考察点和加分项

考察点

讲解完本课时的知识点,总结下面试考察点。

  1. 要理解线程同步与互斥的原理,包括临界资源、临界区的概念,知道重量级锁、轻量级锁、自旋锁、偏向锁、重入锁、读写锁的概念。

  2. 要掌握线程安全相关机制,例如 CAS、synchronized、Lock 三种同步方式的实现原理、要明白 ThreadLocal 是每个线程独享的局部变量,了解 ThreadLocal 使用弱引用的 ThreadLocalMap 保存不同的 ThreadLocal 变量。

  3. 要了解 JUC 中的工具类的使用场景与主要的几种工具类的实现原理,例如 Reentrantlock,ConcurrentHashMap、LongAdder 等实现方式。

  4. 要熟悉线程池的原理、使用场景、常用配置,例如大量短期任务的场景适合使用 Cached 线程池;系统资源比较紧张时,可以选择固定线程池。另外注意慎用无界队列,可能会有 OOM 的风险。

  5. 要深刻理解线程的同步与异步、阻塞与非阻塞,同步和异步的区别在于任务是否是同一个线程执行,阻塞与非阻塞的区别在于异步执行任务时,线程是会阻塞等待结果,还是会继续执行后续逻辑。

加分项

掌握了上面这些内容,如果能做到这几点加分项,一定会给面试官留下更好的印象。

  1. 可以结合实际项目经验或者实际案例介绍原理,例如介绍线程池设置时,可以提到自己的项目中有一个需要高吞吐量的场景,使用了 Cached 的线程池。

  2. 如果有过解决多线程问题的经验或者排查思路的话会获得面试加分。

  3. 能够熟悉常用的线程分析工具与方法,例如会用 jstack 分析线程的运行状态,查找锁对象持有状况等。

  4. 了解 Java 8 对 JUC 工具类做了哪些增强,例如提供了 LongAdder 来替换 AtomicLong,更适合并发度比较高的场景。

  5. 了解 Reactive 异步编程思想,了解 back pressure 背压的概念与应用场景。

真题汇总

总结相关的面试真题,如下图所示,对重点题目提供一些思路。


  • 第 1 题如何实现一个生产者与消费者模型?可以尝试通过锁、信号量、线程通信、阻塞队列等不同方式实现。

  • 第 4 题 wait 与 sleep 的有什么不同?回答的要点四个:

    • wait 属于 Object 类,sleep 属于 Thread 类;

    • wait 会释放锁对象,而 sleep 不会;

    • 使用的位置不同,wait 需要在同步块中使用,sleep 可以在任意地方;

    • sleep 需要捕获异常,而 wait 不需要。

  • 第 6 题,读写锁适用于什么场景?可以回答读写锁适合读并发多,写并发少的场景,另外一个解决这种场景的方法是 copyonwrite。


真题第二部分如下,提供解题思路。


      

  • 第 7 题,线程之间如何通信?主要可以介绍一下 wait/notify 机制,共享变量的 synchronized 或者 Lock 同步机制等。

  • 第 8 题,保证线程安全的方法有哪些?可以提 CAS、synchronized、Lock,以及 ThreadLocal 等机制。

  • 第 9 题,如何尽可能提高多线程并发性能?可以从尽量减少临界区范围,使用 ThreadLocal,减少线程切换、使用读写锁或 copyonwrite 等机制这些方面来回答。

  • 第 10 题,ThreadLocal 用来解决什么问题?ThreadLocal 是如何实现的?可以重点回答 ThreadLocal 不是用来解决多线程共享变量的问题,而是用来解决线程数据隔离的问题。 




  • 17
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值