尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java并发与Kotlin协程:高并发场景下的实战对比与选型指南

Java并发与Kotlin协程:高并发场景下的实战对比与选型指南 1. 两种语言同一场并发战争的两种打法做后端和Android开发这些年我越来越觉得“Java/Kotlin 与并发”这个话题值得单独拉出来聊透。市面上讲Java并发的书汗牛充栋讲Kotlin协程的教程也一抓一大把但很多人忽略了一个关键问题这两种语言在并发模型上的哲学是完全不同的而它们又在同一个生态里共存——你的服务端可能用Java写Android端用Kotlin写两边都在跟并发打交道思路却截然不同。我刚入行时用Java做高并发IM服务后来转Kotlin写客户端最深的感受是Java的并发是“管线程”Kotlin的并发是“管任务”。前者是操作系统级别的并发原语后者是语言层面的结构化并发抽象。搞清楚这两者的边界和优劣才算真正吃透了现代JVM生态的并发体系。这篇文章我不会从教科书定义讲起而是从我自己项目里踩过的坑和实际验证过的方案出发把两种语言各自的并发武器拆开揉碎顺带覆盖面试高频考点和线上实战注意事项——毕竟热搜里那堆“java并发”“kotlin学习”“高并发IM”“数据库并发锁”背后都是真刀真枪的业务场景。要理解这个主题先得建立一个大框架并发问题归根结底就是三个字——状态共享。多线程同时读写同一份数据一定会产生竞争条件要解决竞争要么锁住共享区域同步要么让数据不可变函数式要么把数据隔离线程封闭要么让任务错开异步编排。Java把前三者都做成了显式API而Kotlin协程则在第四点上玩出了花。2. 从线程模型看Java的并发根基AQS、锁升级与线程池的底层逻辑2.1 Java并发的地基是AQS不是synchronized面试总有人问synchronized和Lock的区别但真正的分水岭在于AQSAbstractQueuedSynchronizer。你去看ReentrantLock、CountDownLatch、Semaphore、CyclicBarrier的源码底层全挂在AQS上。AQS的核心是一个volatile int state变量加一个CLH变体等待队列获取锁就是CAS改state失败就进队列挂起。理解了这一层你才算真正理解了Java并发的骨架。synchronized在JDK 6之后做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级路径锁对象头里的Mark Word存的是锁状态。但注意synchronized的wait/notify只能配合synchronized块使用而Lock体系通过Condition对象实现了更精细的等待/通知控制比如ArrayBlockingQueue用两个Condition分别管理“notEmpty”和“notFull”效果比单管道的wait/notify高一个档次。写并发代码这么多年我的建议是新代码优先用java.util.concurrent包下的显式锁和并发容器synchronized留给简单互斥场景。为什么因为可读性、可测试性和超时控制能力完全是两个量级。lock.tryLock(3, TimeUnit.SECONDS)这种带超时的获取方式在真实分布式系统里太重要了——死锁检测和优雅降级都靠它。2.2 线程池的核心参数不是越多越好线程池是Java并发里最容易被用错的地方。很多人背了corePoolSize、maximumPoolSize、workQueue、handler四件套但真到了高并发场景就抓瞎。我见过一个真实事故某天线上服务抖动严重排查下来发现线程池的workQueue是LinkedBlockingQueue默认无界队列任务积压到几百万内存直接打爆。线程池的完整执行规则是这样的提交任务后先看当前线程数是否小于corePoolSize是则新建核心线程执行否则尝试丢进workQueue队列满了再看是否小于maximumPoolSize是则新建非核心线程最后才触发拒绝策略。这个顺序决定了它的行为特性——用有界队列还是无界队列直接决定了你的系统是“排队等待”还是“快速失败”。我个人在实践中常用的组合是new ThreadPoolExecutor(core, max, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(capacity), threadFactory, new ThreadPoolExecutor.CallerRunsPolicy())。CallerRunsPolicy的意思是线程池满了之后任务由提交任务的线程自己执行这样天然实现了背压——线程池扛不住时提交方也阻塞住不会无限堆积。相比DiscardPolicy或AbortPolicy这在业务上安全得多代价是吞吐量下降但至少不会静默丢消息或者把内存打爆。2.3 CountDownLatch、CyclicBarrier和Semaphore的真实使用场景这三个工具类面试天天问但我想说的是它们在实际项目里的定位。CountDownLatch用于“等待多个线程全部完成”——比如批量请求第三方接口后聚合结果主线程等所有子线程返回。CyclicBarrier用于“多个线程互相等待到达同一屏障点后继续”适合并行计算中的阶段同步而且它可以复用。Semaphore本质上是一个计数信号量控制同时访问某资源的线程数比如数据库连接池的手动实现、限流场景。举一个我做过的高并发IM消息推送例子要把一批消息推送给一万个在线用户串行推送太慢直接开一万个线程不现实。我用FixedThreadPool提交所有任务然后用CountDownLatch的countDown归零来统计整批推送的完成时间。再配合CompletableFuture的allOf可以拿到每个推送的成功失败明细比手动管理FutureList优雅得多。2.4 volatile与可见性问题一次线上事故的复盘讲一个我印象特别深刻的线上事故。某个服务里有一段代码一个boolean标志位控制缓存刷新的开关主线程定期检查这个标志决定是否reload缓存。另一个监控线程在发现配置变更时把标志位置为true。发布到线上后偶发出现缓存不刷新的问题重启才能恢复。排查过程很经典先看日志发现监控线程确实执行了赋值再看内存发现主线程读到的始终是false。这就是典型的可见性问题——两个线程在不同CPU核心上运行各自有一份变量副本没有内存屏障保证写入对其他线程可见。解决方式很简单给标志位加上volatile。或者用AtomicBoolean底层是CAS加volatile效果一样。从这以后我给自己立了条规矩跨线程共享的简单状态标记一律用volatile或Atomic类绝不用普通变量。而复合操作先读后写、check-then-act则必须用Atomic类或锁因为volatile不保证原子性。至于“volatile一定能保证线程安全吗”这种面试题答案是不一定它只保证可见性和有序性不保证复合操作的原子性这也是很多人踩坑的地方。3. Kotlin协程凭什么改写并发代码的写法从挂起函数到结构化并发3.1 协程不是线程是“可以被挂起的计算”第一次接触Kotlin协程时我最困惑的问题就是它跟线程到底什么关系后来才搞明白协程是运行在线程之上的轻量级任务协程本身不直接对应操作系统线程它的挂起和恢复由编译器生成的状态机实现。看一段代码就懂了。一个suspend函数在编译后会变成一个Continuation参数的方法内部用状态机管理执行到哪里。遇到挂起点比如delay或者网络请求就返回一个挂起状态线程跑去执行别的协程等条件满足再通过Continuation.resumeWith恢复执行。这个机制的本质是把异步回调扁平化成顺序代码同时不阻塞线程。我做过一个实验用for循环启动一百万个协程每个协程做一个delay(1000)操作发现内存占用非常低运行稳定。同样数量用Java线程来做大概率直接OOM。这就是协程在高并发IO场景下碾压线程的原因——它把“等待”的成本从操作系统线程级别降到了对象级别。3.2 Dispatchers的选择与线程切换的心智模型协程中用得最多的是Dispatchers.Default、Dispatchers.IO和Dispatchers.Main。Default是CPU密集型的线程数等于CPU核数IO是用于阻塞IO的底层是一个弹性线程池上限默认是64个线程Main是Android主线程。选错Dispatcher是新手最常见的错误之一——在Default上做Room数据库查询会卡在IO上做复杂计算会浪费线程切换。真正需要理解的是withContext的工作方式它切换上下文后内部的代码会跑到目标Dispatcher上执行执行完再切回来。这在Android上非常实用因为主线程不能做耗时操作。我写网络请求时常用模式就是一个suspend函数内部用withContext(Dispatchers.IO)包住IO部分调用方在协程作用域内直接调用完全不用关心线程切换的细节代码读起来像同步代码跑起来是异步的。另外补充一个容易忽略的点协程虽然是轻量级的但也不是免费的。每个协程都有状态机类和Continuation对象启动太频繁且生命周期很短的话反而会增加GC压力。所以在高频短任务场景选择性使用协程而不是无脑repeat。3.3 结构化并发的精髓父协程、子协程与取消传递“结构化并发”是Kotlin协程最核心也最容易被忽视的概念。它的含义是协程必须在某个作用域内启动作用域决定了协程的生命周期。父协程取消时所有子协程自动取消子协程抛异常时如果没捕获会向上传播取消父协程。这个设计直接解决了Java线程一个老大难问题线程的取消只能靠interrupt标志位配合响应式代码很容易写漏。而协程的取消机制是自动的只要协程在挂起点delay、withContext、suspendCancellableCoroutine等检查了CancellationException就能及时响应取消。我踩过一个坑某个下载任务的协程在Repository层调用了一个用suspendCancellableCoroutine封装的三方SDK回调但没有正确处理invokeOnCancellation导致任务虽然被取消底层SDK的回调还在执行数据状态错乱。血的教训是所有用suspendCancellableCoroutine包装的自定义挂起函数必须注册invokeOnCancellation做资源清理。3.4 Flow用数据流替代回调的地狱Flow是Kotlin协程生态里的响应式流实现跟RxJava类似但更自然地融入了协程的取消支持和操作符链。我第一次用Flow重写一个轮询接口时最大的感受是它的backpressure处理和生命周期安全——在Android上通过collectLatest、debounce这些操作符可以很好地处理搜索框防抖和列表加载。Flow还有一个杀手级应用room数据库返回的Flow数据在表数据变化时自动重新发射配合Flow的collect在生命周期安全范围内观察数据变化写出来的代码既声明式又不用手动管理取消。相比之下Java里用LiveData或RxJava做同样的事要么得处理Disposable的生命周期要么要小心背压策略心智负担重不少。但Flow也不是万能药。它的操作符链一旦复杂起来调试体验比命令式代码差不少——冷流不知道什么时候触发、流的建立和收集分两个阶段容易造成理解混乱。我在小团队里定过一个规范涉及多次异步串行工序的场景用Flow单次异步操作就用suspend函数不要为了用Flow而用Flow。4. 面试中最爱问的并发关键问题锁、原子性与可见性的底层真相4.1 synchronized的锁升级不是玄学是分代锁策略很多人背了“偏向锁→轻量级锁→重量级锁”的升级路径但没想过为什么这么设计。synchronized在JDK 6之后的实现其实是分代锁跟GC的分代回收是一个思路大多数锁在生命周期内只有少量线程竞争甚至只有一个线程反复获取那就没必要一上来就上操作系统级的重量级锁。偏向锁假设锁始终被同一线程获取只在对象头记录线程ID不再做CAS一旦有第二个线程来竞争就升级为轻量级锁通过自旋CAS抢锁如果自旋超过阈值重试或者CPU核心数过多才膨胀为重量级锁线程挂起进入等待队列。这个设计把绝大多数无竞争或低竞争场景的加锁成本压到了极低。不过JDK 15之后偏向锁被废弃和移除了——原因是现代应用里锁竞争普遍增多偏向锁的撤销逻辑本身有成本尤其是在大量对象作为锁对象的场景下。所以现在面试官如果还在问偏向锁的细节你该知道它已经是历史遗留知识点了但理解它背后“按竞争程度分级处理”的思想仍然有价值。4.2 CAS的ABA问题与AtomicStampedReferenceCASCompare And Swap是Java并发包的核心原语它通过比较并交换的方式实现了无锁编程。但CAS有一个经典的ABA问题线程1读取变量值为A线程2把它改成B又改回A线程1的CAS会认为值没变过于是更新成功——但在这期间状态已经被修改过。解决ABA问题的标准方案是加版本号。Java里对应的是AtomicStampedReference它内部维护一个[reference, stamp]对CAS时同时检查引用和版本号。但在实际业务代码里ABA问题很少会真的造成数据错误因为它要求“一个值变了又变回原样且这个中间过程会引发问题”的特殊场景——比如用链表做无锁栈时节点的连续复用会导致ABA。普通计数、标志位场景直接用AtomicInteger就够了不要过度设计。4.3 ThreadLocal的内存泄漏为什么总有人在这里栽跟头ThreadLocal在Java并发里经常被用来做线程隔离的变量传递比如SimpleDateFormat的安全使用、请求ID的透传。它的底层是每个Thread内部有ThreadLocalMapkey是ThreadLocalvalue是对象。问题出在如果ThreadLocal对象被回收了Map里的entry就变成key为null的脏数据而线程池里的线程是长时间存活的这些脏数据永远不会被回收造成内存泄漏。我见过一个线上的OOM事故就是ThreadLocal里存了大对象线程池线程复用又没及时remove。修复方式就是在finally块里手动调用remove()。做框架设计时我还常看到一种更隐蔽的坑用InheritableThreadLocal给子线程传值一旦配合线程池使用子线程是复用的后面提交的任务会读到上一次任务的值造成数据串线。如果业务确实需要跨线程传递上下文建议用TransmittableThreadLocal这类专门解决线程池传递的框架而不是自己用InheritableThreadLocal硬扛。4.4 不要在锁里做重活一个死锁案例的现场还原有一次排查系统卡死线程dump显示一堆线程BLOCKED状态互相持有对方需要的锁。经典的死锁条件互斥、不可剥夺、循环等待。复盘当时的代码发现一个controller里获取了DB连接池的锁又去调用另一个服务那个服务反过来获取同一个连接池的锁——两个线程互相等待谁也别想推进。解决死锁最实用的三板斧按固定的全局顺序加锁、不要以嵌套方式持有多个锁、给加锁操作设置超时apiClient调用时加了超时还有单独获取锁的超时。这个教训让我在写并发代码时形成了一种肌肉记忆锁的粒度越小越好锁内只做必须保护的操作IO和网络调用坚决不要放在锁内。如果你发现自己在一个synchronized方法里调用了另一个synchronized方法先在脑子里拉响警报。5. 高并发场景下的实战组合方案缓存、数据库锁与IM系统5.1 Redis缓存与高并发读写的正确姿势高并发业务绕不开缓存而缓存设计里最容易出问题的是三个“经典坑”缓存穿透、缓存击穿、缓存雪崩。穿透是请求了一个不存在的数据导致每次都打到数据库击穿是热点key在缓存过期的瞬间大量请求直接打到DB雪崩是大批量key同时过期或者Redis本身挂了流量瞬间压垮数据库。我的应对习惯是穿透用缓存空值加布隆过滤器双保险击穿用分布式锁在重建缓存时只让一个线程去查DB其他线程短暂等待后重试雪崩方案有两个维度——给缓存过期时间加一个随机偏移量避免大量key同时失效另外Redis做高可用部署配合本地进程缓存做多级降级。这些策略的优先级排列要看业务容忍度能接受短暂脏数据本地缓存层可以更激进不能接受就只能在DB层做限流。热点key的问题也是高并发系统里的常客。比如商品详情页某个SKU在促销时访问量暴涨一个key顶住几十万QPS。解法通常是用本地缓存加Redis多副本或者读写分离把压力分散到多个节点。顺带说一句Redis本身的单线程模型决定了它对单个key的读写是串行的当一个key过大时后续请求都会排队所以大key也是必须治理的对象。5.2 数据库并发锁乐观锁、悲观锁与条件更新的选择数据库并发这块很多人的第一反应是“用悲观锁吧select for update”但高并发场景下悲观锁其实是最贵的方案——它会让所有并发事务在锁上排队吞吐量直线下降。我的选择逻辑很简单冲突概率低就上乐观锁冲突概率高且需要强一致就上悲观锁能用手工条件更新解决的绝不引入显式锁。乐观锁的实现通常是在表里加一个version字段更新时where version 旧值如果影响行数为0就重试。我用这个方案做过库存扣减接口QPS从几百提升到几千代价是偶尔的更新失败需要客户端重试。还用过一种更轻的姿势update table set stock stock - N where id ? and stock N用条件本身保证不会超卖这种写法在秒杀场景特别实用——一句话就完成了“查库存校验扣减”的原子操作。常量池里阈值、token、积分这些高并发写场景也是同样的套路。核心原则是把数据不一致的风险控制在单条SQL的条件判断里让数据库的原子性来兜底而不是靠应用层的锁。5.3 高并发IM系统的线程模型与批量推送设计我做过的IM系统里最核心的并发挑战就是在线状态管理和消息推送。连接层用Netty是基本操作每个TCP连接一个Channel事件循环线程模型天然避免了每个连接一个线程的噩梦。但真正考验并发设计的是消息推送一条消息要发给一群人如果对每个用户都发一个推送任务系统负载会直线上升。我的方案是批量推送加分组延迟。把在线用户按Channel分组每个worker线程处理一组用批量writeAndFlush合并多个用户的相同内容消息再通过配置的延迟窗口做一次小的聚合窗口。这样设计下来单机能支撑的在线连接数和消息吞吐量都很可观。另外一个容易漏掉的细节是IM系统的“并发连接数”不等于“活跃消息数”大量连接是长连接但空闲心跳和重连逻辑的设计直接影响系统的稳定性。6. 并发压力测试与问题定位JMeter工具链和分布式环境下的实测心得6.1 JMeter压测的并发模型与参数设置用JMeter做并发测试最高频的场景就是并发登录和接口压测。JMeter的线程组设置里Number of Threads线程数就是模拟的并发用户数Ramp-Up Period是启动这些线程所用的时间Loop Count控制循环次数。5个用户并发登录听上去很简单但要注意的点是JMeter每个线程的Sampler是同步执行的如果你加了同步定时器Synchronizing Timer才能实现绝对意义上的“同时发起”不加的话线程是按Ramp-Up逐个启动的并不是真正的并发同时。压测结果主要看聚合报告里的Throughput吞吐量、响应时间分位数90% Line、99% Line和Error%。我一般会跑三档压测单用户冒烟、预期并发负载、2-3倍峰值压力拿到响应时间和错误率的拐点就知道系统什么时候开始性能劣化。还可以配合ServerAgent在压测时监控CPU、内存、IO和网络定位瓶颈是应用层还是数据库还是中间件。6.2 从压测结果定位并发瓶颈的排查链路压测发现吞吐量上不去先别急着调线程池。我惯用的排查顺序是先看监控确认CPU是用户态高还是内核态高内存有没有频繁GC然后再看数据库的慢查询和连接池使用率最后才回到应用层的锁竞争和线程状态。一个典型的排查过程是这样的JMeter压到1000并发时接口P99响应时间从50ms飙到2000ms吞吐量反而下降。查看线程dump发现大量线程BLOCKED在同一个对象的monitor上于是定位到代码里有一个Global锁保护了一个静态SimpleDateFormat。把SimpleDateFormat换掉或加线程隔离后性能立刻恢复了。这种问题在压测中特别常见——不是机器不够而是锁竞争导致线程全部排队表现出来就是CPU不高但响应时间猛涨。6.3 单机到分布式k8s容器环境下的高并发测试路径热搜词里提到的“单节点k8s上的微服务整套环境迁移到阿里云ECS再做高并发压测”是很多团队的真实动作。容器化部署的微服务在做压测时有一个和传统单机完全不同的坑Kubernetes的Service负载均衡是kube-proxy做的默认的iptables模式在连接数高时会存在性能瓶颈如果压测target是NodePort每个节点的iptables规则都会参与转发规则量大了会严重影响转发性能。做这类验证时我会先确认压测是走Ingress还是NodePort路径不同对并发承载能力的差异很大。另外还要注意Pod的CPU限制和JVM的GC配置是否匹配——很多Java服务在容器里默认读到的CPU核数是宿主机核数导致线程池参数和GC线程数配置超出了容器的实际配额压测一上来就出现频繁GC甚至OOM Kill。这类问题大概率不是程序逻辑的锅而是资源配额和JVM参数互相矛盾。7. 并发编程的选型与团队落地经验最终的心智模型写到这里我想聊一点带方法论味道的东西。Java和Kotlin两种语言的并发模型经常被拿来分个高下但我的体会是它们底层的心智模型完全互补。Java的并发体系是**“锁线程阻塞”强调的是并发底层的控制和容错适合服务端的高性能中间件、数据库连接等基础设施层Kotlin协程的并发体系是“作用域挂起结构化”**强调的是异步编排的易写性和安全性适合业务层、客户端层以及大部分涉及IO的应用逻辑。拿我自己负责的一个项目来说底层处理消息的组件用Java写原因是这部分需要精细控制线程和锁还需要和现成的Netty线程模型做深度集成上层的业务编排全部用Kotlin协程写因为业务链路长、依赖多顺序化的异步代码让新人也看得懂。这个组合用下来开发效率和线上稳定性都有了明显的提升。最后给看到这里的朋友几个建议都是我踩坑之后得到的经验并发代码里最简单的不一定是最好的但最容易被人理解的一定是走得最远的。用锁还是用CAS还是用协程先考虑团队里其他人能不能看懂再考虑性能指标。几乎所有的并发问题都能靠缩小共享状态的范围来缓解。毕竟不可变对象、线程封闭和单一写者这三种模型比任何锁都省心。在上线前做一次压测的成本远低于线上故障后的排查成本。即使没条件做完整压测单机跑一遍并发脚本也能帮你提前暴露锁竞争、线程池溢出这类基础问题。现在的JVM生态里还有一个不可忽视的新变量——Java 21的虚拟线程它跟Kotlin协程一样让“百万并发任务”不再是天方夜谭。它在语法上比Kotlin协程更“侵入性小”但在结构化并发和取消传播上Kotlin协程目前还是更成熟的。我个人倾向于把它们看作同一场并发革命的不同面向虚拟线程解决的是平台层面的阻塞成本协程解决的是语言层面的异步表达能力两者完全可以并存。以后写新服务时我会先把虚拟线程和协程各自的边界摸清楚再根据团队实际情况决定主打哪套方案。
返回列表