在并发编程的世界里,没有真正的"银弹",只有踩不完的坑和填不完的坑。从最初上手并发工具的懵懂,到应对线程泄露、死锁、任务阻塞等各类问题的从容,我耗费了大量脑细胞,也积累了一箩筐实战经验。今天就把这些思考整理出来,希望能帮正在并发路上摸索的你,少走一些弯路。
先说说我踩过的并发相关开发场景
接触并发开发以来,我陆续实操过这些相关内容:CFFU并发支持库、任务耗时监控、线程泄露追踪、动态线程池及调优、fail-fast实现、请求级版本配置实现(单次加载)、本地缓存single flight优化实现、线程池死锁检测、多任务并发控制、任务取消传播与CancellationToken实现。
也正是这些实打实的实操,让我深刻体会到:书本上的并发知识是基础,但真正落地到业务中,差距远比想象中大。
实战vs书本:两个颠覆认知的核心差别
很多人刚接触并发时,会一头扎进书本的理论和源码里,但实战久了就会发现,有两个认知偏差必须纠正:
- 数据量化优于定性分析:书本上的理论多是定性描述,比如"线程池能提高并发效率",但实际开发中,"提高多少效率""核心参数设多少合适""阻塞队列满了该怎么处理",都需要靠真实的业务数据来量化判断,而非凭感觉定性。
- 算法思想与文档比源码分析更重要:很多人把"读源码"当成进阶的必经之路,却忽视了文档注释的价值。比如AQS,绝大多数人一辈子都不会直接用到,过分纠结它的源码细节,反而会陷入"只见树木不见森林"的误区。OOP的核心是封装,好的组件必然会通过文档清晰说明用法和边界,如果需要靠读源码才能解决问题,大概率是技术方案本身就有问题。而且并发相关的源码往往复杂,对象状态多,很容易产生似是而非的理解,得不偿失。
实战总结:并发编程的核心原则与经验
基于无数次踩坑复盘,我整理了一套自己的并发编程准则,核心可以概括为:优先抽象、聚焦业务、区分场景、规避风险,具体拆解如下:
一、工具选型:优先抽象工具,远离底层原语
核心原则:优先使用「高度抽象的并发工具」(回调Future、JUC容器、线程池、MQ等),仅在极致性能/定制化场景下使用底层原语。
本质原因很简单:抽象工具已经封装了行业最佳实践,能最大程度减少人为错误,既提升性能又提高开发效率,让你能聚焦业务逻辑,而非纠结并发细节。
这里要明确一个边界:抽象工具是「底层原语的最佳组合」,不是替代关系。90%的业务场景用抽象工具就足够,只有遇到极致性能需求或特殊定制化场景时,再考虑底层原语。
延伸建议:能用封装好的,就别手动拼;能聚焦业务的,就别纠结技术细节。很多时候,并发不是必须的------多数组件都支持批量操作,比如Redis Pipeline,用批量操作替代并发调用,能少很多麻烦。
二、场景区分:IO密集型与CPU密集型别混用
这是最容易踩坑的点之一:IO任务和CPU密集型任务的处理逻辑完全不同,混用会导致严重问题。
我的简单判断方法:所有IO任务用回调Future封装。曾遇到过一个"弄巧成拙"的案例:线程池里混用了IO任务和CPU密集型任务,导致简单的参数校验方法被挤到阻塞队列里,迟迟无法执行,反而不如直接用当前线程执行高效。
对应建议:创建不同的线程池,采用舱壁模式(Bulkhead Pattern)隔离不同类型的任务,避免相互干扰。
三、模式与工具选型:选对的,不选复杂的
在并发模式和工具选择上,我的优先级排序如下,供参考:
- 结构化并发优于其他并发方式
- 生产者-消费者模式优于耦合式实现
- 支持回调的Future优于原生Future实现(FutureTask);回调Future选型优先级:ListenableFuture >> CFFU >> CompletableFuture
- Future模式优于同步器实现(CountDownLatch、CyclicBarrier)
- JUC并发集合优于同步集合(synchronized collection)------JUC并发集合的性能和安全性都很强,这一点上Java的优势很明显
- 递归、极致性能追求时,再考虑ForkJoinPool
特别提醒:使用Future.get()时要注意------抛出超时异常后,原任务可能还在后台执行,需要做好后续的任务取消和资源清理。
四、风险规避:锁、异常、资源释放别忽视
并发编程的风险多集中在锁、异常处理和资源释放上,这几点要格外注意:
- 慎用锁:锁的使用具有"传染性",如果对象新增字段,所有访问该字段的地方都需要持有锁,不符合开闭原则;迭代过程中锁的范围还容易扩大,很容易出bug。推荐用@GuardBy注解明确锁的持有关系,减少混乱。锁的选型优先级:CycleDetectingLock >> ReentrantLock >> 隐式锁(synchronized)
- 无锁算法有时优于有锁算法------能不用锁就不用锁,减少死锁、活性问题的风险
- 阻塞操作务必添加超时时间:避免线程无限阻塞,导致资源泄露
- 原子类优先于volatile:虽然原子类有一定开销,但这往往不是系统瓶颈;用原子类能保证安全性,代码更不易出错
- 做好异常处理:多线程环境下,堆栈信息往往是片面的。比如Callable需要实现toString方法,方便调试;多个子任务出现异常时,要能捕捉到所有异常,避免遗漏
- 资源释放要及时:异步操作的资源释放容易被忽略,比如用ClosingFuture实现自动关闭资源;还要做好ThreadLocal的生命周期管理,避免内存泄露
五、其他实用经验:细节决定成败
除了上述核心原则,还有一些细节经验,能帮你规避不少小坑:
- 优先考虑fail-fast实现:尽早发现问题,比事后排查更高效
- 优先使用Executor框架:分离执行与线程职责,框架的性能、内存占用、监控能力通常更优
- 纯函数优于副作用:多个线程同时修改一个对象,不如各自计算结果后merge,减少线程安全问题
- 延迟初始化选型优先级:枚举 >> Guava Suppliers、lombok lazy >> 内部类实现 >> DCL(双重检查锁定)------DCL容易因指令重排出问题,非必要不使用
- 资源消耗大的操作/任务,优先实现取消与取消传播:取消多出现于IO密集型任务,可通过响应线程中断标志或自定义CancellationToken实现,需要手动编写检查点(checkpoint);如果线程池阻塞队列中有大量取消任务,考虑用purge操作释放资源
- 算法选择:优先简单、易理解的------比如我的项目中用固定窗口法实现分布式限流,虽然不是最精准的,但实现简单、性能足够,够用就好;无锁算法(如COW)、单线程处理任务也是不错的选择,能避免上下文切换和线程同步开销,性能可能更好,且调试更友好
- 参数传递:显式传参优于隐式传参,显式传递上下文优于隐式传递。上下文要考虑全局(请求级)上下文和子任务上下文,明确修改规则(如只读、只写一次、线程安全、固化freezing等)
- 重试策略:能重试的再重试(比如NPE就没必要重试),选择合适的重试策略,比如指数退避策略,避免无效重试浪费资源
总结
并发编程的核心不是"炫技",而是"稳妥"------用最简单的方式解决问题,用清晰的规则规避风险,让并发为业务服务,而不是成为业务的负担。
再重申几个核心观点:少看源码,多关注算法思想和文档;优先抽象工具,区分任务类型;慎用锁,做好异常处理和资源释放。
当然,这些经验都是基于我的实战总结,未必适用于所有场景。欢迎大家在评论区分享你的并发踩坑经历和解决方案,一起交流进步~