口语化讲解JUC

前言

本期讲了CAS、LockSupport还有以这两个为基础构建的AQS。再就是聊到了特别常用的并发map,ConcurrentHashMap,顺着讲到了线程池和新的forkJoinPool,最后收尾提到了ThreadLocal。

正文

什么是CAS?CAS会有哪些问题对应提供了什么解决方案?

CAS,全拼Compare-And-Swap,直译就是比较与替换。通常来说有三个参数内存地址V、预期原值A、新值B,方法逻辑是用内存地址V上的值与A比较,如果相同则值替换为B。

CAS有三个问题,分别是ABA问题、循环开销时间长只能保证单个变量的原子操作。ABA问题可以采用增加版本号或者其他标识的方法解决。循环开销时间长的问题通过适应性自旋,在适当时机停止循环。单个变量的问题,也可以通过合并变量,比如变量A和B拼在一起将AB传入CAS比较。

CAS的原始方法在unsafe类下有提供,unsafe类提供调用底层和不安全操作的方法,比如访问系统资源,操作内存。

CAS常和volatile搭配使用于Atomic原子类、ConCurrentHashMap和ConcurrentLinkedQueue这种并发类中。

了解LockSupport吗?

LockSupport主要提供park和unpark两个方法,用于加锁和解锁。其核心设计原理是许可证,抽象来讲就是unpark提供许可证,park消费许可证,当调用了park方法但是没有许可证可以消费或者锁定时间没有到期之前都会被阻塞。底层原理还是调用操作系统的互斥锁mutex,但是增加了一个条件变量condition,通过设置这个条件变量的0和1来表示是否持有许可证。补充一点,unpark可以先于park执行,也就是允许提前持有许可证(wait notify不行,必须先后),但是最多只有一个许可证,拥有许可证的情况下调用park方法会直接消费该许可继续执行不阻塞。

AQS是什么,核心思想了解吗?可以说说具体是怎么实现的吗?

AQS全称抽象队列同步器,是一个用来构建锁和同步器的框架,比如ReentrentLock、Semaphore(信号量)。核心实现依赖于CAS和LockSupport。

AQS的核心思想是当多个线程竞争同一个资源时,根据公平或非公平原则允许其中一个线程获取该资源,并将其他竞争失败的线程放入一个虚拟双向队列中等待资源释放。所谓的虚拟双向队列指的是没有队列实例,但是结点之前存在关联关系。

具体实现是,AQS内部维护一个状态变量state,当有线程请求资源成功时,通过CAS修改状态为1,同时标记当前线程ID,其他竞争失败的线程放入队列自旋等待锁释放。释放资源时,状态改为0,同时清除线程标记。

聊一聊AQS派生的组件

AQS提供了两种资源获取方式,分别是独占和共享模式。独占模式下还可以分为公平和非公平模式。通过模板模式的设计方法,AQS派生了大量的组件,比如ReentrentLock、Semaphore(信号量)、CountDownLatch (倒计时器)、CyclicBarrier(循环栅栏)。

**Semaphore(信号量)**实现了共享模式,内部维护一个阈值,可以同时允许不超过阈值的线程访问同一个资源,超过该阈值的将被阻塞。可以用来构建资源池,当阈值设为1时,成为二元信号量,此时使用效果类似于ReentrentLock。

**CountDownLatch (倒计时器)CyclicBarrier(循环栅栏)**比较相似,效果都是让多个线程阻塞直到某个条件达成后才继续执行下去。

其主要区别是内部实现原理不同,CountDownLatch是减法计数,初始有N个执行的线程,各个线程调用countDown方法后N-1然后开始阻塞等待其他线程,一直减到0说明全部达标,才结束等待,全部往下执行。

CyclicBarrier是加法计数,刚好和CountDownLatch相反,初始值设置为0,各个线程调用await方法后N+!开始阻塞等待,计数达到N说明全部达标。不过CyclicBarrier相比于CountDownLatch有个优势,它有个重载方法,允许指定达标一定数量就可以继续执行,而不用全部达标。

ReentrentLock,直译为可重入锁,可重入是指同一个线程外层函数获取到锁之后,内层函数可以直接使用该锁,避免死锁。ReentrentLock有三个内部类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。ReentrentLock默认使用的是非公平锁,谁抢到就是谁的,而公平锁的策略是在每次获取锁之前在等待队列中查找有没有等待时间更长的线程。

讲讲ConcurrentHashMap的PUT和GET

ConcurrentHashMap-1.7-put流程

  1. 首先计算hash,定位到segement,如果segement为空就初始化
  2. 然后ReentrentLock获取锁,如果没有获取到锁,就适应性自旋,还不行就阻塞等待直到获取锁成功
  3. segement内部由数组+链表组成和1.7的hashmap相似,通过遍历hashEntry找到数组下标
  4. 判断当前是否有值,没有直接put新值。有值则需要判断key是否与当前值完全相同,相同根据参数决定是否覆盖
  5. 不同则遍历链表,如果都不同就追加一个链表节点

ConcurrentHashMap-1.7-get流程
get流程相对简单,也是通过计算hash定位到segement,再遍历内部的hashEntry数组。找到对应数组下标后,遍历链表取值即可。

这里get是不用加锁的,因为值和指针被volatile修饰,保证了可见性。
ConcurrentHashMap-1.8-put流程

  1. 首先计算hash,定位Node数组的下标
  2. 判断该下标的首节点f
    1. 如果为null,则通过CAS创建一个新的节点put新值
    2. 如果f.hash=MOVED=-1,说明其它线程正在扩容,参与一起扩容
    3. 如果不属于上述两种情况,则用synchronized锁住头节点f,判断头节点是否为树节点,根据具体类型遍历最后put
  3. 判断链表是否超过8,如果是的话还需要判断数组长度是否小于64,如果小于优先扩容,如果大于才转换成红黑树
  4. 调用addcount方法计算集合元素是否超过阈值,如果超过则开始扩容

ConcurrentHashMap-1.8-get流程
1.8的扩容同样不用加锁,流程也很简单。计算hash,定位Node数组下标,判断头节点情况来取值
ConcurrentHashMap-1.8-扩容
这个扩容机制的亮点在于多线程协作扩容,通过设置标记量和CAS大量减少了锁的使用,还不放过阻塞读或写的线程也要参与元素迁移

  1. 通过CPU核数和集合长度计算每一轮扩容需要多少个线程处理多少个桶,默认也是最少处理16个桶
  2. 修改transferIndex标志位,每个线程领取完任务就减去多少,比如初始大小是transferIndex = table.length = 64,每个线程领取的桶个数是16,第一个线程领取完任务后transferIndex = 48,也就是说第二个线程这时进来是从第48个桶开始处理,再减去16,依次类推,这就是多线程协作处理的原理
  3. 领取完任务之后就开始处理,如果桶为空就设置为ForwardingNode,如果不为空就加锁拷贝,拷贝完成之后也设置为ForwardingNode节点 、
  4. 如果某个线程分配的桶处理完了之后,再去申请,发现transferIndex = 0,这个时候就说明所有的桶都领取完了,但是别的线程领取任务之后有没有处理完并不知道,该线程会将sizeCtl的值减1,然后判断是不是所有线程都退出了,如果还有线程在处理,就退出
  5. 直到最后一个线程处理完,发现sizeCtl = rs<< RESIZE_STAMP_SHIFT,才会将旧数组干掉,用新数组覆盖,并且会重新设置sizeCtl为新数组的扩容点

以上过程简单概括下分为两部分

  1. 分配,这一步将桶数组分割成小份,由一个或多个线程来领取,如果第一轮线程全部领完还有剩,那就下一轮所有线程继续,直到领完
  2. 迁移,这一步需要首先在头节点加锁,阻塞其他线程的读或写,当然这些被阻塞的线程在看到正在迁移的标识后需要加入迁移过程而不是闲着等待。在某个桶迁移完毕后将节点设置为FowardingNode,标记为已完成迁移

线程池的作用,ThreadPoolExecutor的核心参数,操作逻辑,可以直接创建哪四种线程池,有什么问题

线程池能对线程进行统一分配、调优和监控。主要是三个优势,一是降低资源消耗,主要是减少线程频繁创建销毁带来的资源消耗。二是加快线程响应,线程池维持一定数量的活跃线程,随时都能响应。三是提升线程的可管理性,由线程池来管理线程的各种操作,同时避免无限制的申请资源。

ThreadPoolExecutor有七个核心参数,分别是corePoolSize核心线程数、workQueue工作队列、maximumPoolSize最大线程数、handler线程池的饱和策略、threadFactory创建线程的工厂、keepAliveTime存活时间、unit时间单位。workQueue工作队列默认使用LinkedBlockingQueue,一个由链表组成的有界阻塞队列,默认队列长度为Integer.MAX_VALUE(2的31次方-1),采用FIFO(先进先出)策略,对生产者和消费者做了独立的锁,因此有更强的并发性能。handler线程池的饱和策略一共有四种,分别是直接抛异常、不抛异常丢任务、由当前线程执行任务、丢弃阻塞队列最前的任务并执行当前任务,根据情况选择即可,默认是直接抛异常

ThreadPoolExecutor的执行策略是创建新线程时先判断核心线程数是否已满,再判断阻塞队列是否已满,最后判断最大线程数是否已满,如果都满了就执行拒绝策略
ThreadPoolExecutor提供了四种现成的线程池,分别是newCachedThreadPool可缓存线程池、newfixedThreadPool定长的线程池、newSingledThreadPool单一线程线程池、newScheduedThreadPool定时线程池。但是以上四种线程池的参数配置要么是队列最大数是Integer.MAX_VALUE要么就是最大线程数是Integer.MAX_VALUE,过多的线程数可能导致OOM问题,因此一般推荐是自建线程池。

怎么配置线程池

线程池的应用场景一般分为IO密集型和CPU密集型。CPU密集型指的是高并发,相对短时间的计算型任务,这种会占用CPU执行计算处理,因此核心线程数设置为CPU核数+1,减少线程的上下文切换,同时做个大的队列,避免任务被饱和策略拒绝。IO密集型指的是有大量IO操作,比如远程调用、连接数据库,因为IO操作不占用CPU,所以设置核心线程数为CPU核数的两倍,保证CPU不闲下来,队列相应调小一些。

当然更加现实的情况是需要我们动态调整线程池参数,配合监控找到最好的配置,如果有必要的话,可以加入动态线程池组件,比如开源的https://dynamictp.cn/

@Async默认线程池了解吗?知道Fork/Join框架吗?

@Async注解使用了Spring的ThreadPoolExecutor来创建线程池,核心线程数是8,队列用的默认的LinkedBlockingQueue,队列大小为Integer.MAX_VALUE,最大线程数是Integer.MAX_VALUE,线程过期时间是60s,拒绝策略是抛异常。以上线程池和ThreadPoolExecutor可直接创建的四种线程池问题类似,过大的最大线程数和队列容量,会导致OOM问题的产生。

Fork/Join框架是JDK1.7提供的处理并行任务的框架,核心有两个思想,分而治之以及工作窃取。分治法这个理解很简单,就是将大任务拆分成小任务交由不同线程执行。工作窃取的思想是尽可能最大程度利用线程,已完成小任务的线程,可以窃取其他线程的未完成任务来执行,同时为了避免锁竞争,通常采用双端队列,快慢线程各在一端

基于该框架生成了forkJoinPool.commonPool,这是所有CompletableFuture和并行stream流共用的线程池,这是一个计算型的线程池,核心线程数是CPU核数-1,因此IO密集型的任务最好新建一个线程池。

聊聊ThreadLocal,定义原理,可能会产生的问题以及解决手段

ThreadLocal是为多线程创建线程变量副本的类。ThreadLocal修饰变量后,会为每一个线程创建独立的线程副本,避免多线程操作共享变量导致的问题

ThreadLocal依赖于其内部类ThreadLocalMap,这个类没有实现map接口,但是内部提供了相似的操作方法,以Thread的ThreadLocal对象为key,保证每个线程都有独立的空间。
ThreadLocal存在内存泄漏的原因是ThreadLocalMap的key也就是ThreadLocal被设置为弱引用,也就是当ThreadLocal改成null的时候,GC会回收掉。但是ThreadLocalMap中key为null的Entry,如果线程未被销毁,那么就不会被回收,多了就会造成内存泄漏。解决方法也很简单,使用完后调用ThreadLocal的remove方法即可

ThreadLocal同样也存在hash冲突问题,但是和常规的链表模式不同,它采用了线性探测方式来解决。所谓线性探测,就是根据初始key的hashcode值确定元素在table数组中的位置,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。ThreadLocalMap解决Hash冲突的方式就是简单的步长加1或减1,寻找下一个相邻的位置。

线程安全的List

  • 写多读少–加锁比如Collections.synchronizedList(list)
  • 读多写少–JUC下的CopyOnWriteArrayList

CopyOnWriteArrayList原理
Copy-On-Write(写时复制):当对 CopyOnWriteArrayList 进行修改操作(添加、删除等)时,并不直接在原始数组上进行操作,而是先复制一份原始数组,然后在复制的数组上进行修改。因此,在进行写操作时,会导致数据的复制,而不会影响到其他正在遍历原始数组的线程。
优势:

  1. 线程安全:CopyOnWriteArrayList 是线程安全的,不需要额外的同步手段来保证多线程环境下的数据一致性。
  2. 读操作高效:由于读操作不需要加锁,因此读取操作的性能很高。
  3. 遍历安全:即使在迭代过程中对列表进行修改,也不会导致 ConcurrentModificationException 异常。

劣势:

  1. 内存占用较高:由于每次写操作都需要复制整个数组,因此会消耗更多的内存空间。
  2. 不适用于频繁修改场景:如果列表的修改操作非常频繁,那么频繁的复制操作会导致性能下降。
  3. 迭代器的弱一致性:虽然在迭代时不会抛出异常,但是由于迭代器是基于快照的,所以迭代器遍历的内容可能不会反映最新的修改。这可能会导致一些数据一致性问题。

CompletableFuture原理

CompletableFuture实现了两个接口:Future、CompletionStage。Future表示异步计算的结果,CompletionStage用于表示异步执行过程中的一个步骤(Stage),这个步骤可能是由另外一个CompletionStage触发的,随着当前步骤的完成,也可能会触发其他一系列CompletionStage的执行。从而我们可以根据实际业务对这些步骤进行多样化的编排组合,CompletionStage接口正是定义了这样的能力,我们可以通过其提供的thenAppy、thenCompose等函数式编程方法来组合编排这些步骤。

CompletableFuture中包含两个字段:result和stack。result用于存储当前CF的结果,stack(Completion)表示当前CF完成后需要触发的依赖动作(Dependency Actions),去触发依赖它的CF的计算,依赖动作可以有多个(表示有多个依赖它的CF),以栈(Treiber stack)的形式存储,stack表示栈顶元素。注意stack属性,它是一个使用上是栈思路的属性,但实际数据结构是链表,只不过使用上是头插入头读取的。

CompletableFuture的多个操作,也就是多个CompletableFuture之间,如果上一个CompletableFuture未完成,则会将当前CompletableFuture动作添加到上一个CompletableFuture的stack数据结构中,在任务执行完毕之后,回执行对应stack中的Completion回调方法

CompletableFuture原理简单来讲,就是如何实现异步编排的功能,来源于两个接口,Future获取异步结果,CompletionStage会记录当前CF完成后需要触发的依赖动作。内部使用链表实现栈的功能,回执其他CF的的回调方法。

ConcurrentLinkedQueue

Java 提供的线程安全的 Queue 可以分为阻塞队列(加锁)和非阻塞队列(CAS),其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue。

ConcurrentLinkedQueue适用于高性能并发场景,且是一个无界队列,缺点是不支持阻塞操作并且只能实现先进先出队列。不能阻塞获取这点比较伤,一般编程题会用上这个特性,比如put和take方法。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值