面试题 | 徒手写一个非阻塞线程安全队列 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 面试题 | 哪些情况下表项会被回收到缓存池?


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


目录
相关文章
|
9月前
|
监控 Kubernetes Java
阿里面试:5000qps访问一个500ms的接口,如何设计线程池的核心线程数、最大线程数? 需要多少台机器?
本文由40岁老架构师尼恩撰写,针对一线互联网企业的高频面试题“如何确定系统的最佳线程数”进行系统化梳理。文章详细介绍了线程池设计的三个核心步骤:理论预估、压测验证和监控调整,并结合实际案例(5000qps、500ms响应时间、4核8G机器)给出具体参数设置建议。此外,还提供了《尼恩Java面试宝典PDF》等资源,帮助读者提升技术能力,顺利通过大厂面试。关注【技术自由圈】公众号,回复“领电子书”获取更多学习资料。
|
9月前
|
安全 Java 程序员
面试必看:如何设计一个可以优雅停止的线程?
嘿,大家好!我是小米。今天分享一篇关于“如何停止一个正在运行的线程”的面试干货。通过一次Java面试经历,我明白了停止线程不仅仅是技术问题,更是设计问题。Thread.stop()已被弃用,推荐使用Thread.interrupt()、标志位或ExecutorService来优雅地停止线程,避免资源泄漏和数据不一致。希望这篇文章能帮助你更好地理解Java多线程机制,面试顺利! 我是小米,喜欢分享技术的29岁程序员。欢迎关注我的微信公众号“软件求生”,获取更多技术干货!
226 53
|
8月前
|
数据采集 Java Linux
面试大神教你:如何巧妙回答线程优先级这个经典考题?
大家好,我是小米。本文通过故事讲解Java面试中常见的线程优先级问题。小明和小华的故事帮助理解线程优先级:高优先级线程更可能被调度执行,但并非越高越好。实际开发需权衡业务需求,合理设置优先级。掌握线程优先级不仅能写出高效代码,还能在面试中脱颖而出。最后,小张因深入分析成功拿下Offer。希望这篇文章能助你在面试中游刃有余!
133 4
面试大神教你:如何巧妙回答线程优先级这个经典考题?
|
8月前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
528 14
|
8月前
|
安全 Java 程序员
Java 面试必问!线程构造方法和静态块的执行线程到底是谁?
大家好,我是小米。今天聊聊Java多线程面试题:线程类的构造方法和静态块是由哪个线程调用的?构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节有助于掌握Java多线程机制。下期再见! 简介: 本文通过一个常见的Java多线程面试题,详细讲解了线程类的构造方法和静态块是由哪个线程调用的。构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节对掌握Java多线程编程至关重要。
159 13
|
8月前
|
缓存 安全 Java
面试中的难题:线程异步执行后如何共享数据?
本文通过一个面试故事,详细讲解了Java中线程内部开启异步操作后如何安全地共享数据。介绍了异步操作的基本概念及常见实现方式(如CompletableFuture、ExecutorService),并重点探讨了volatile关键字、CountDownLatch和CompletableFuture等工具在线程间数据共享中的应用,帮助读者理解线程安全和内存可见性问题。通过这些方法,可以有效解决多线程环境下的数据共享挑战,提升编程效率和代码健壮性。
265 6
|
9月前
|
安全 Java 程序员
面试直击:并发编程三要素+线程安全全攻略!
并发编程三要素为原子性、可见性和有序性,确保多线程操作的一致性和安全性。Java 中通过 `synchronized`、`Lock`、`volatile`、原子类和线程安全集合等机制保障线程安全。掌握这些概念和工具,能有效解决并发问题,编写高效稳定的多线程程序。
251 11
|
9月前
|
存储 监控 Java
JAVA线程池有哪些队列? 以及它们的适用场景案例
不同的线程池队列有着各自的特点和适用场景,在实际使用线程池时,需要根据具体的业务需求、系统资源状况以及对任务执行顺序、响应时间等方面的要求,合理选择相应的队列来构建线程池,以实现高效的任务处理。
393 12
|
10月前
|
并行计算 算法 安全
面试必问的多线程优化技巧与实战
多线程编程是现代软件开发中不可或缺的一部分,特别是在处理高并发场景和优化程序性能时。作为Java开发者,掌握多线程优化技巧不仅能够提升程序的执行效率,还能在面试中脱颖而出。本文将从多线程基础、线程与进程的区别、多线程的优势出发,深入探讨如何避免死锁与竞态条件、线程间的通信机制、线程池的使用优势、线程优化算法与数据结构的选择,以及硬件加速技术。通过多个Java示例,我们将揭示这些技术的底层原理与实现方法。
517 3
|
11月前
|
存储 缓存 算法
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
本文介绍了多线程环境下的几个关键概念,包括时间片、超线程、上下文切换及其影响因素,以及线程调度的两种方式——抢占式调度和协同式调度。文章还讨论了减少上下文切换次数以提高多线程程序效率的方法,如无锁并发编程、使用CAS算法等,并提出了合理的线程数量配置策略,以平衡CPU利用率和线程切换开销。
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!

热门文章

最新文章

下一篇
oss教程