Kotlin 随记 (1)

news/2025/2/27 10:30:29

最近在使用Kotlin做科研项目开发,这里随手记录下开发过程中遇到的问题与积累的经验。

ConcurrentSkipListSet 大坑

ConcurrentSkipListSet是Java实现的一个线程安全的Set,说到底,它是直接服务于Java那套线程部署方式的,而在Kotlin中,我们习惯使用协程来完成高并发工作。在开发过程中,我发现Kotlin中的并发问题有很多都是因为线程与协程混用导致的。这里就是一个典型的例子。

请看下面两段代码:

kotlin">coroutineScope {
    list.map { ivt -> async(dispatcher) {
        val currentResult = doSomething()
        results.addAll(currentResult)
    } }.awaitAll()
}

coroutineScope {
    list.map { ivt -> async(dispatcher) {
        results.addAll(doSomething())
    } }.awaitAll()
}

这里的doSomething为某种耗时操作,dispatcher定义如下:

kotlin">val dispatcher = ForkJoinPool(threadNumber).asCoroutineDispatcher()

乍一看,这两段代码似乎没啥区别,就是通过协程来并发处理一个列表,来加速多次耗时操作的调用。但如果将其运行起来,你会发现两者的性能截然不同。

第一段代码,只要CPU核心数量够多,threadNumber设多少就可以用多少个核来跑doSomething,非常符合我们对高并发的要求,而第二段代码,你会发现无论你设置多少threadNumber,最终的CPU占用率始终高不过200%,也就是说,这里的并发变成了假的!!!

这看上去很匪夷所思,因为按照常规理解,嵌套的函数调用必然首先需要调用内层函数获取其返回值,然后才能将返回值作为参数传入外层参数,既然doSomething先于addAll执行,为什么addAll的并发控制还能够影响到doSomething

这个问题如此反直觉当然是有原因的,以至于我问了DeepSeek一周时间,换了无数种解决方案,最终还是自己无意识的一次修改发现了问题,而当我将问题抛给DeepSeek之后,它用了好几次回答还是没有办法完全让我理解这个问题的根本成因。

下面是DeepSeek给出的回答,如果有大佬知道这个问题的根本原因是什么,还请在评论里不吝赐教:

DeepSeek 调教后的解答

核心差异:锁持有期间的协程调度行为:

代码版本锁占用阶段协程可调度性
第一段代码(显式保存结果)仅 addAll 期间可能占用锁doSomething() 执行期间协程可被挂起并释放线程,其他协程能复用线程进行计算
第二段代码(链式调用)整个 doSomething() + addAll 期间隐式持锁doSomething() 执行期间协程无法让出线程,导致线程池资源耗尽

🧠 现象本质:锁的隐式持有范围被意外扩大

尽管语言规范保证 doSomething() 先于 addAll() 执行,但 ConcurrentSkipListSet.addAll 的实现会导致 计算阶段(doSomething())与写入阶段(addAll)在协程调度层面产生耦合:

1. JVM锁的粒度问题

ConcurrentSkipListSet 的线程安全是通过 细粒度锁(CAS + synchronized块) 实现的,但以下两种场景会触发全局锁:
跳跃表层级调整:当插入元素导致跳跃表层级变化时,需要短暂获取全局锁
批量写入竞争:多个线程同时调用 addAll 时,CAS操作失败率飙升 → 退化为悲观锁

2. 协程调度器的线程饥饿效应

以 Dispatchers.Default(线程数=CPU核心数)为例:

第一段代码:

kotlin">  val currentResult = doSomething()  // 协程在此处可挂起,释放线程给其他协程
  matchResults.addAll(currentResult) // 锁竞争期间线程被阻塞,但此时大部分计算已完成

计算阶段协程频繁挂起/恢复 → 线程池利用率高

第二段代码:

kotlin">  matchResults.addAll(doSomething()) // 编译器可能优化为连续操作

JIT编译器可能将 doSomething() 与 addAll 编译为连续机器码,减少挂起点

协程在计算阶段无法让出线程 → 所有线程被占用在计算或锁等待中

📊 动态竞争模型(考虑不同耗时)
假设有 N 个协程,其 doSomething() 耗时分别为 t₁, t₂,… tₙ(服从随机分布):

阶段第一段代码第二段代码
计算阶段协程在各自线程执行 tᵢ,可自由挂起/调度协程因锁竞争提前进入等待队列,线程被阻塞无法释放
写入阶段短耗时协程快速完成,释放线程给长耗时协程所有协程的写入操作线性堆积,形成排队链
吞吐量瓶颈受限于最长 tᵢ + 锁竞争时间受限于锁处理速率(与 tᵢ 无关,趋于单线程性能)

🔬 实验现象解释(128核场景)

第二段代码性能骤降的根本原因:

  1. 锁竞争引发的线程池枯竭
  • 即使 doSomething() 耗时不同,只要存在 部分协程同时进入 addAll
  • 这些协程的线程被阻塞在锁等待 → 可用线程数减少
  • 剩余协程的 doSomething() 因无空闲线程而无法启动 → 并行度塌缩
  1. 协程调度器的挂起策略 Kotlin协程在遇到阻塞操作(如锁竞争)时:
  • 若使用 Dispatchers.IO:会创建新线程,可能缓解问题
  • 若使用 Dispatchers.Default:严格限制线程数,导致饥饿

🔄 最终结论

性能差距根源:第二段代码因 锁竞争范围意外扩大至计算阶段,导致协程调度器无法有效复用线程,最终引发线程池枯竭(Thread Starvation)。

动态耗时分布的影响:即使 doSomething() 耗时不同,只要存在持续的新协程加入竞争,就会导致 锁等待队列长度指数增长,最终等效于串行化。

极端硬件放大效应:128核环境线程池更大,但锁竞争导致的线程阻塞会 成比例消耗更多线程资源,恶化程度远超小核环境。


http://www.niftyadmin.cn/n/5869952.html

相关文章

AI工具集合

设计相关 1. mastrtgo(暂时免费) :可以根据自然语言生成UI设计稿和前端代码 MasterGo 莫高设计 - AI 时代的数字界面生产平台 2. reddy.ai(暂时免费): 国外类似mastrtgo的平台 Readdy 3. midjourney (…

Prompt——绘制泳道图

请创建一个SVG格式的泳道图(Swimlane Diagram),需要满足以下规范: 1. 基础布局规范: a) 尺寸设置: - 根据泳道数量和复杂度确定合适的viewBox尺寸 - 泳道宽度根据内容量动态调整,但保持成比例 - 预留适当的顶部标题区域…

【星云 Orbit-F4 开发板】03g. 按键玩法七:矩阵键盘单个触发

【星云 Orbit-F4 开发板】03g. 按键玩法七:矩阵键盘单个触发 引言 矩阵键盘是一种常见的输入设备,广泛应用于各种嵌入式系统中。通过矩阵键盘,用户可以通过按键输入字符或控制信号。本文将详细介绍如何使用STM32F407的GPIO引脚实现矩阵键盘的…

SOME/IP-SD -- 协议英文原文讲解6

前言 SOME/IP协议越来越多的用于汽车电子行业中,关于协议详细完全的中文资料却没有,所以我将结合工作经验并对照英文原版协议做一系列的文章。基本分三大块: 1. SOME/IP协议讲解 2. SOME/IP-SD协议讲解 3. python/C举例调试讲解 5.1.3.1 E…

航空装配自动化神器Ethercat转profient网关搭配机器人精准控制

生产管理系统通过网关与装配机器人连接,加快航空器机身的装配速度,减少人为误差。 航空制造对装配线的精度和效率有着极高的要求。某航空制造厂使用的耐达讯Profinet转EtherCAT协议网关NY-PN-ECATM,将其生产管理系统与装配机器人连接&#xf…

【JavaEE进阶】Spring Boot 日志

欢迎关注个人主页:逸狼 创造不易,可以点点赞吗 如有错误,欢迎指出~ 目录 日志用途 1. 系统监控 2. 数据采集 3.⽇志审计 Spring Boot日志 打印⽇志 在程序中得到⽇志对象 日志框架 ⻔⾯模式(外观模式) ⻔⾯模式的优点 不引⼊⽇志⻔⾯ 存在问…

Java 内存泄漏排查指南:工具与实战技巧

内存泄漏是 Java 开发中常见的问题,会导致应用程序性能下降,甚至崩溃。本文将介绍 Java 内存泄漏的排查方法,包括常用工具和实战技巧。 一、内存泄漏概述 内存泄漏 是指程序在运行过程中,由于某些原因无法释放不再使用的对象&am…

深入Linux序列:进程的终止与等待

在之前的学习中,我们知道我们的进程在运行结束的时候,那么它并不会立即进入死亡状态,而是先进入僵尸状态,维持僵尸状态一段时间,那么此时在僵尸状态中的进程,那么它的内核数据已经移出内存被清理了&#xf…