面试题 | 徒手写一个非阻塞线程安全队列 ConcurrentLinkedQueue?(下)

简介: 面试题 | 徒手写一个非阻塞线程安全队列 ConcurrentLinkedQueue?

死循环修正版


这下尾插入算是线程安全了,但这个算法还差点东西,假设某线程在 CAS 的竞争中失败,而另一个线程赢得了竞争,成功地在链尾插入了新结点。因为轮询的存在失败线程会继续尝试,但往后一切的尝试都将失败,因为预期值已经不再是 null,程序陷入了死循环。


为了解决死循环,修改逻辑如下:


// 入队版本三
public boolean offer(E e) {
    Node<E> newNode = createNode(e)
    // 循环之初新增工作指针 p 指向尾结点
    for (Node p = tail;;) {
        // 工作指针 q 表示 p 的后续结点
        Node q = p.next;
        // 找到真正的尾结点,进行 CAS 尾插入
        if (q == null) {
            if (casNext(p, null, newNode)) { 
                for(;;) {
                    if (casTail(tail, newNode)) return true;
                }
            }
        } else {
            p = q; // p 指针向后移动一个结点
        }
    }
}


新增了两个工作指针 p 和 q,它们相互配合往后遍历队列,目的是找到真正的链尾结点。


当某线程 CAS 竞争失败,另一个获胜线程成功地在链尾插入了新结点,因为轮询的存在,失败线程会继续走到if (q == null),但此时 q 必然不为 null(因为另一个线程已经成功插入新结点),就会走到 else 分支执行 p 结点后移,即使另一个线程偷偷地插入了 n 个新结点也不要紧,工作指针会一直往后移动,直到找到了新的尾结点,此刻 if (q == null) 再次成立,将再次尝试 CAS 竞争。


失败者窘境优化版


第二个版本的尾插入算法看上去好了很多,但还有可以优化的地方。


考虑下面这个场景,失败者的窘境若某线程 CAS 竞争失败,另一获胜线程在链尾插入 10 个结点。不巧,失败线程一直没被调度执行,该过程中,其他线程疯狂地往链尾插入了 90 个结点。终于失败线程恢复了执行,难道还要往后遍历 100 个结点才能再次 CAS 竞争吗?在这 100 次遍历过程中又被其他线程抢先插入新结点怎么样?岂不是得无穷无尽地遍历?感觉失败线程将永远得不到机会插入新结点。。。


解决方案是 “抄近路”。竞争失败后,工作指针 p 往后遍历的目的是 “找到真正的尾结点”,既然别的线程已成功插入了新结点,tail 必然已被更新,那直接将 p 指向 tail 不是可以抄近路吗?


// 入队版本三
public boolean offer(E e) {
    Node<E> newNode = createNode(e)
    // 循环之初新增工作指针 t 保存 tail 的副本
    for (Node t = tail, p = t;;) {
        Node q = p.next;
        if (q == null) {
            if (casNext(p, null, newNode)) { 
                for(;;) {
                    if (casTail(tail, newNode)) return true;
                }
            }
        } else {
            // 性能优化:若其他线程偷偷地插入新结点,则直接指向最新的链尾结点
            p = t != (t = tail) ? t : q;
        }
    }
}


新增了链尾指针 t,它是链尾指针 tail 在当前线程内存中的副本,为啥要保存一份副本?因为 tail 是线程共享变量,当别的线程修改 tail 之后,就可对比 t 和 tail 是否相等来判断链尾是否发生了后移。


这个对比在程序中表现为t != (t = tail),当发生“失败者的窘境”时,tail 和 t 的值必然不相等,此时可直接将工作指针 p 抄近路到最新的链表尾 t。


性能优化版


出队算法采用“滞后的头结点”以减少 CAS 竞争,入队算法也如法炮制“滞后的尾结点”:


// 入队版本四
public boolean offer(E e) {
    Node<E> newNode = createNode(e)
    for (Node t = tail, p = t;;) {
        Node q = p.next;
        // 当 p 是尾结点时,进行 CAS 尾插入
        if (q == null) {
            if (casNext(p, null, newNode)) { 
                // 只有当 tail 落后于真正链尾时才更新它
                if (p!=t) 
                    casTail(t, newNode);
                return true; 
            }
        } else {
            p = t != (t = tail) ? t : q;
        }
    }
}


在更新链尾指针外加了一个条件,即 p 和 t 相等的情况下不更新。啥情况下它俩会相等?只有当进入循环时 tail 指向的是真正的链尾时,它俩才会相等。所以该条件可以理解为 “当在链尾插入一个新结点时,发现 tail 指针落后于真正的队尾时,才更新链尾指针。”


更新 tail 的过程如下图所示:


image.png


队列当前只有一个结点,所以它是头结点也是尾结点,tail 指针指向它。进入轮询时,工作指针 p 被赋值为 tail。因为此时 tail 指向的已经是真正的尾结点,所以 p 无需继续向后遍历,就地直接插入了新结点,此时 p 的值依然和 tail 相等,该情况下不会更新尾指针。


若在这种场景下继续插入新结点会怎么样?


image.png


进入轮询时,tail 结点已经滞后了(状态一),所以 p 得向后遍历,最终找到了真正的尾结点 Node2(状态二),执行插入新结点,此时 p 发生了偏移,所以其值必然和 tail 不同,该情况下会更新尾指针(状态三)。


以此类推,每插入两个结点,就会更新一次尾指针。


不及时更新链尾指针会导致算法出错吗?不会。因为该算法其实并不相信 tail,所以安排了工作指针 p 通过遍历来找到真正的链尾。


头脚颠倒修正版


在出入队可以并发的场景下,可能出现头指针越过尾指针的情况,比如: “链当前有 2 个结点,且 tail 指针滞后一个结点,线程 A 正准备往链尾追加新结点,但另一线程的出队操作捷足先登了,它一口气出队 2 个结点,此时线程 A 恢复执行。。。”


这种情况下,线程 A 该如何找到正确的插入位置?


image.png


这是线程 A 进入 offer() 轮询时的场景,还没等到往下执行就被另一个线程打断了:


image.png


上图左侧是出队线程进入 poll() 轮询时的初始状态,待成功将 Node1 出队后就变成右图所示状态,即 Node1.item 置空,head 指针未更新。


出队线程继续将 Node2 出队会发生什么?


image.png


因为此时 head 结点已经滞后(状态一),所以工作指针 p 会往后找到第一个 item 非空结点 Node2(状态二),将 Node2.item 置空后,继续将 Node1 结点置为哨兵结点(状态三)。此时线程 A 恢复执行,它面对的情形如下:


image.png


因为执行 poll() 方法时并不会更新 tail 指针,导致 head 指针越过了 tail 指针。


用版本四的算法模拟一下这个场景下会发生什么:q 是 p 的后续结点,尴尬的是 p.next 就是 p 本身,所以 q 永远不会为 null,程序就一直自旋,停不下来。


解决办法是,新增一个p == q的条件分支:


// 入队版本五
public boolean offer(E e) {
    Node<E> newNode = createNode(e)
    for (Node t = tail, p = t;;) {
        Node q = p.next;
        // 找到尾结点,CAS 竞争尾插入
        if (q == null) {
            if (casNext(p, null, newNode)) { 
                if (p!=t) casTail(t, newNode);
                return true; 
            }
        } 
        // 若 head 越过 tail 发生自旋
        else if (p == q) {
            // 重置 p 到正确的位置
            p = (t != (t = tail)) ? t : head;
        }
        // 一切正常,只是还未找到真正的尾结点
        else {
            p = t != (t = tail) ? t : q;
        }
    }
}


当发生自旋时,重置 p 到正确的位置。正确的位置有两种可能。当链尾没有插入新结点时,即 tail 没有发生变化时(t == tail),将 p 重置为 head,这就是上图阐述的场景,后续剧情如下:


image.png


当 p 意识到发生了自旋(状态一),就把自己指向 head(状态二),p 的后续结点为空,即找到了真正的链尾,遂在此插入 Node3,因为p!=t,即 tail 指针滞后了,所以同时更新 tail 指向最新的尾结点 Node3(状态3)。


自旋后还可能发生另一种场景,此时得从最新的 tail 开始遍历。


改写下头脚倒置的剧本: “链当前有 2 个结点,且 tail 指针滞后一个结点,线程 A 正准备往链尾追加新结点,但另一线程的出队操作捷足先登了,它一口气出队 2 个结点,紧接着它还进行了一次入队操作,此时线程 A 恢复执行。。。” 恢复执行的线程 A 面临这样的场景:


image.png


其实就是刚才剧情的续集,此时 p 发现进入了自旋,不同的是,tail 指针发生了变化,所以此时应该直接把 p 指向 tail,这比指向 head 效率要高。


阶段性总结:


非阻塞线程安全队列的入队算法总结如下:


  • 总是从尾指针出发往后遍历寻找真正的尾结点(next 为空),任何情况下都能找到,将其 next 域置为新结点(CAS机制保证线程安全)。


  • 滞后的尾指针:理论上每次入队都应该更新尾指针,为了提高性能,采用延迟更新策略,即两次入队对应一次更新尾指针。


  • 因为是非阻塞的,出队操作可能被其他线程打断:


  1. 若是被另一个入队线程打断,则可能发生链尾后移若干结点,此时采用 “抄近路” 策略实现快速定位到新链尾。


  1. 若是被另一个出队线程打断,则可能发生头脚颠倒,即头指针越过尾指针。此时需要基于最新的头或尾指针重新出发往后遍历寻找真正的尾结点再执行入队操作。


坦白


心细的您一定发现,这其实就是ConcurrentLinkedQueue的源码~~ , 这其实是一篇源码分析~~~


我尝试以“从零开始一步步徒手复写”的方式来讲解这个算法,不知能不能降低理解难度?欢迎留言~~


总结


  • 每个线程都有自己独立的内存空间(本地内存)


  • 多线程能同时访问的变量称为“共享变量”,它在主存中。出于效率的考量,线程会将主存中的共享变量拷贝到本地内存,后续对共享变量的操作都发生在本地内存。


  • 线程本地内存相互独立,导致它们无法感知别人对共享变量的操作,即当前线程对共享变量的操作对其他线程不可见。


  • volatile 关键词是 java 解决共享变量可见性问题的方案。


  • 被声明为 volatile 的变量,就是在告诉编译器该共享变量在线程本地内存中的副本是“易变的”,线程不该相信它。线程写共享变量后,总是应该立刻将最新值同步到主存中。线程读共享变量时,总是应该去主存拿最新值。


  • 多线程场景下,队列的头尾指针,即结点的数据域和指针域都需要保证可见性。


  • CAS是一种保证共享变量线程安全的方式,它是非阻塞的,即不会造成一个线程执行,其他线程阻塞并等待唤醒。它全称为compare and swap,即“比较再交换”。当要更新变量值时,需要提供三个参数:1.当前值,2.期望值,3.新值。只有当前值和期望值相等时,才用新值替换当前值。其中期望值就是线程被打断执行时暂存的值,当线程恢复执行后,用当前值和当时暂存值比较,如果相等,则表示被打断过程中别的线程没有修改过它,那此时更新它的值是安全的。


  • CAS 通常配合轮询一起使用。多线程同时竞争修改一共享变量值时,只有一个会胜出(返回true),其余失败(返回false),失败线程通常选择轮询,直到成功为止。


非阻塞线程安全队列的出队算法总结如下:


  • 总是从头指针出发向后寻找真正的头结点(item 非空),若找到,则将头结点 item 域置 null(CAS保证线程安全),表示结点出队,若未找到则返回 null。


  • 采用 “自旋” 的方式来实现脱链(结点指针域与链脱钩),即 next 域指向自己(CAS保证线程安全)。自旋形成了哨兵的效果,使得往后遍历寻找真正头结点的过程中可感知到结点脱链。


  • 滞后的头指针:“出队”、“脱链”、“更新头指针”不是配对的。理论上每次出队都应该脱链并更新头指针,为了提高性能,两次出队对应一次脱链+更新头指针,造成头结点会间歇性地滞后。


  • 因为是非阻塞的,出队操作可能被其他线程打断,若是被入队打断则基本无害,因为队头队尾是两块不同的内存地址,不会有线程安全问题。若是被另一个出队线程打断,就可能发生想出的结点被其他线程抢先出队的情况。通过检测自旋+从头开始来定位到新得头结点解决。


非阻塞线程安全队列的入队算法总结如下:


  • 总是从尾指针出发往后遍历寻找真正的尾结点(next 为空),任何情况下都能找到,将其 next 域置为新结点(CAS机制保证线程安全)。


  • 滞后的尾指针:理论上每次入队都应该更新尾指针,为了提高性能,采用延迟更新策略,即两次入队对应一次更新尾指针。


  • 因为是非阻塞的,出队操作可能被其他线程打断:


  1. 若是被另一个入队线程打断,则可能发生链尾后移若干结点,此时采用 “抄近路” 策略实现快速定位到新链尾。


  1. 若是被另一个出队线程打断,则可能发生头脚颠倒,即头指针越过尾指针。此时需要基于最新的头或尾指针重新出发往后遍历寻找真正的尾结点再执行入队操作。


推荐阅读


源码分析系列文章如下:


Kotlin 协程 | CoroutineContext 为什么要设计成 indexed set?(一) - 掘金 (juejin.cn)


Kotlin 源码 | 降低代码复杂度的法宝 - 掘金 (juejin.cn)


读源码长知识 | 原来可以这样扩大 View 点击区域 - 掘金 (juejin.cn)


RecyclerView 的滚动时怎么实现的?(二)| Fling - 掘金 (juejin.cn)


RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势 - 掘金 (juejin.cn)


RecyclerView 性能优化 | 是什么在破坏缓存机制? - 掘金 (juejin.cn)


RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池? - 掘金 (juejin.cn)


RecyclerView 面试题 | 滚动时表项是如何被填充或回收的? - 掘金 (juejin.cn)


RecyclerView 动画原理 | 如何存储并应用动画属性值? - 掘金 (juejin.cn)


RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系 - 掘金 (juejin.cn)


RecyclerView 动画原理 | 换个姿势看源码(pre-layout) - 掘金 (juejin.cn)


读原码长知识 | 就像讲话一样,写代码也要留有余地!? - 掘金 (juejin.cn)


读源码长知识 | Android卡顿真的是因为”掉帧“? - 掘金 (juejin.cn)


读源码长知识 | 更好的 RecyclerView 表项点击监听器 - 掘金 (juejin.cn)


Android触摸事件分发的“递”与“归”(一) - 掘金 (juejin.cn)


Android触摸事件分发的“递”与“归”(二) - 掘金 (juejin.cn)


内存优化:充满矛盾的SparseArray - 掘金 (juejin.cn)


Android自定义控件 | View绘制原理(画什么?) - 掘金 (juejin.cn)


Android自定义控件 | View绘制原理(画在哪?) - 掘金 (juejin.cn)


Android自定义控件 | View绘制原理(画多大?) - 掘金 (juejin.cn)


RecyclerView 缓存机制 | 回收到哪去? - 掘金 (juejin.cn)


RecyclerView 缓存机制 | 回收些什么? - 掘金 (juejin.cn)


RecyclerView 缓存机制 | 如何复用表项? - 掘金 (juejin.cn)


面试系列文章列表如下:


面试题 | 怎么写一个又好又快的日志库?(一)


面试题 | 怎么写一个又好又快的日志库?(二)


面试题 | 徒手写一个 ConcurrentLinkedQueue?


来讨论下 Android 面试该问什么类型的题目?


RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?


面试题 | 有用过并发容器吗?有!比如网络请求埋点


目录
相关文章
|
4月前
|
存储 安全 Java
【Java集合类面试二十五】、有哪些线程安全的List?
线程安全的List包括Vector、Collections.SynchronizedList和CopyOnWriteArrayList,其中CopyOnWriteArrayList通过复制底层数组实现写操作,提供了最优的线程安全性能。
|
1月前
|
存储 缓存 算法
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
本文介绍了多线程环境下的几个关键概念,包括时间片、超线程、上下文切换及其影响因素,以及线程调度的两种方式——抢占式调度和协同式调度。文章还讨论了减少上下文切换次数以提高多线程程序效率的方法,如无锁并发编程、使用CAS算法等,并提出了合理的线程数量配置策略,以平衡CPU利用率和线程切换开销。
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
|
1月前
|
开发框架 Java .NET
.net core 非阻塞的异步编程 及 线程调度过程
【11月更文挑战第12天】本文介绍了.NET Core中的非阻塞异步编程,包括其基本概念、实现方式及应用示例。通过`async`和`await`关键字,程序可在等待I/O操作时保持线程不被阻塞,提高性能。文章还详细说明了异步方法的基础示例、线程调度过程、延续任务机制、同步上下文的作用以及如何使用`Task.WhenAll`和`Task.WhenAny`处理多个异步任务的并发执行。
|
3月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
3月前
|
消息中间件 前端开发 NoSQL
面试官:线程池遇到未处理的异常会崩溃吗?
面试官:线程池遇到未处理的异常会崩溃吗?
83 3
面试官:线程池遇到未处理的异常会崩溃吗?
|
3月前
|
消息中间件 存储 前端开发
面试官:说说停止线程池的执行流程?
面试官:说说停止线程池的执行流程?
56 2
面试官:说说停止线程池的执行流程?
|
3月前
|
消息中间件 前端开发 NoSQL
面试官:如何实现线程池任务编排?
面试官:如何实现线程池任务编排?
43 1
面试官:如何实现线程池任务编排?
【多线程面试题 一】、 创建线程有哪几种方式?
创建线程的三种方式包括继承Thread类、实现Runnable接口和实现Callable接口,其中Runnable和Callable接口方式更受推荐,因为它们允许多重继承并更好地体现面向对象思想。
|
4月前
|
Java 调度
【多线程面试题 五】、 介绍一下线程的生命周期
线程的生命周期包括新建、就绪、运行、阻塞和死亡状态,线程状态会根据线程的执行情况在这些状态之间转换。
【多线程面试题 五】、 介绍一下线程的生命周期
|
4月前
|
存储 安全 Java
【多线程面试题十七】、如果不使用synchronized和Lock,如何保证线程安全?
这篇文章探讨了在不使用`synchronized`和`Lock`的情况下保证线程安全的方法,包括使用`volatile`关键字、原子变量、线程本地存储(`ThreadLocal`)以及设计不可变对象。