ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java并发编程实战:线程池、CAS与锁升级的避坑指南

Java并发编程实战:线程池、CAS与锁升级的避坑指南 今天是我搞Java技术八股系统化学习的第33天正好把并发编程里最绕的一块整理完了。连续一个月每天固定抽两小时啃八股文最大的感受是很多知识点背的时候觉得懂了一写代码或者被面试官换个角度问立马露馅。这一篇不是单纯罗列面试题答案而是把我这几天反复踩坑、验证过的核心内容做个复盘重点放在线程池、CAS、锁升级以及几个线上高频报错的定位思路上。不管你是正在准备Java面试还是工作中经常跟并发、Redis、JVM类加载打交道这篇应该都能给你一些比背八股更实在的东西。1. 并发编程这块八股为什么越学越觉得“背不完”1.1 从一道库存扣减题说起今天整理资料时热搜词里反复出现“库存扣减八股”评论区也好多人在问。这道题确实是Java面试里并发场景的标配因为它把JMM、原子性、锁、性能这四件事全串起来了。我最开始看这道题第一反应就是加synchronized。但面试官接着问“压测的时候吞吐量掉得厉害怎么办”我就卡住了。后来又看到有人在网上分享说用Redis的incr做库存预扣减结果碰上序列化问题RedisTemplate调用increment()直接报not an integer or out of range。这些问题我在实际项目里都遇到过所以今天专门停下来把并发这整块重新捋了一遍。1.2 我给自己定的Day33学习目标搞懂线程池的核心参数到底是怎么协作的而不是只背七参数的名字。弄明白CAS和volatile在Java里是怎么配合的以及ABA问题在项目里什么时候真的会出问题。梳理从偏向锁到重量级锁的升级过程理解为什么synchronized现在性能不差。把Redis扣减库存、RedisTemplate自增报错、NoClassDefFoundError这几个高频问题做一次排查复盘。2. 线程池八股光背七大参数不够得会“算”2.1 七大参数之间的联动关系线程池的七个核心参数网上随便一搜都是核心线程数、最大线程数、空闲存活时间、时间单位、阻塞队列、线程工厂、拒绝策略。但我觉得八股真正要考的不是你背不背得出这七个名字而是给你一个场景你能不能说出每个参数该怎么定。我自己的理解是核心线程数决定的是“日常接待能力”最大线程数决定的是“高峰期极限接待能力”阻塞队列是中间的缓冲等待区拒绝策略就是实在接待不了时怎么办。这三个参数是联动的不能单独拍脑袋定。比如核心线程数设为4最大线程数设为8队列容量设为100。那任务的执行顺序是前4个任务直接由核心线程执行第5个到第104个任务进入队列等待如果核心线程一直没空闲队列也满了这时候第105个任务到来才会触发创建新线程最多加到8个如果连8个线程都忙不过来队列也满了才会触发拒绝策略。很多刚接触线程池的同事会误以为“最大线程数到了就会新建线程”其实不是。只有队列满了才会去创建非核心线程。这个顺序是面试里最容易挖的细节。2.2 核心线程数到底怎么算网上流传的公式很多什么CPU密集型用CPU核数加一IO密集型用CPU核数乘以二。但我在实际项目里发现这只能作为起点不能直接抄。我负责过一个数据同步服务主要是从消息队列拉数据再写数据库典型的IO密集型任务。按照公式算下来核心线程数大概是8到16但压测后发现线程太多反而导致数据库连接池被打满出现大量等待获取连接的超时。后来我把核心线程数降下来同时把队列调大让任务在队列里排队而不是疯狂创建线程整体吞吐量反而上去了。所以我的结论是CPU密集型任务核心线程数按CPU核数加一设置基本靠谱因为主要消耗CPU线程多了反而频繁切换。IO密集型任务公式只能作为参考值实际要结合下游的资源上限来定。如果下游是数据库要看连接池大小如果是第三方接口要看对方的QPS承受能力。压测是唯一的验证方式。先按公式算一个初始值再用压测工具逐步调优观察平均响应时间和TP99的变化。2.3 拒绝策略不只是四种默认的四种拒绝策略大家应该都背得出来。但我分享一个真实场景一个核心交易系统用的不是这四种里的任何一种而是实现了RejectedExecutionHandler接口把被拒绝的任务异步落库然后用一个定时任务去扫描重试。为什么这么设计因为交易类任务不像日志任务丢了是要出事故的。CallerRunsPolicy虽然不会丢任务但如果在高并发时让提交任务的线程去执行会直接阻塞业务线程可能把整个Tomcat线程池都拖垮。所以一个可靠的兜底方案就是把任务持久化再用补偿机制去处理。这里想提醒一下自定义拒绝策略一定要考虑幂等性。因为任务被拒绝后重新执行可能连续执行两次。如果业务操作不是幂等的比如直接扣钱而不是预扣减就会产生重复扣款。3. 从volatile到CAS到锁升级把并发底层的账算清楚3.1 volatile的可见性到底是怎么实现的volatile是Java面试里的常客但很多人只背结论不知道底层原因。volatile保证的是可见性和有序性不保证原子性。我用一个生活类比来理解假设一个共享变量的值在多核CPU各自的缓存里都有一份副本线程A在自己的CPU缓存里修改变量线程B在其他CPU核上读的时候如果没有特殊机制B看到的还是旧值。volatile做的事情就是在写变量时生成一条带有lock前缀的指令让这个变量的值从当前CPU缓存写回主内存同时让其他CPU核上的缓存行失效B再读的时候被迫从主内存重新加载。但这里有个容易忽略的点volatile的可见性保证建立在“读写的是同一个变量”的基础上。如果一个复合操作比如i包含读、加一、写三步中间可能被其他线程打断这就不是volatile能解决的了。这时候得靠CAS或者锁。另外一个实际项目里的常见误区是过度使用volatile。我见过有同事用volatile修饰HashMap以为加了volatile就线程安全了。volatile只能保证HashMap这个引用变量本身的可见性HashMap内部的put操作没有任何原子性保护并发写一样会出问题。这种情况应该用ConcurrentHashMap或者直接加锁保护整个操作。3.2 CAS与ABA问题不能只看概念CAS是很多无锁方案的基础AtomicInteger、LongAdder、ConcurrentHashMap的很多节点操作都依赖它。它的核心是一个循环读取当前内存值V计算新值B执行比较并交换只有当前值还是V时才把B写回去否则重试。这个机制最大的问题就是ABA。简单理解线程A读到共享变量的值是1准备修改成2在线程A修改前线程B先把这个值改成1再改回1对线程A来说它读到的值还是1CAS就认为没人动过这个变量实际上中间已经改过一轮了。我在实际项目里真正遇到ABA问题是在实现一个简单金额转账的时候。一个账户实例对象被并发修改CAS检查的是对象引用的内存地址。结果发现地址没变但对象的余额字段已经被其他人改过了。后来用了版本号字段每次更新时版本号加一CAS同时比较引用和版本号问题才解决。所以我的建议是面试答ABA问题时不要再只背“可以用版本号解决”这句空话。可以具体一点说在项目中用一个整型版本号参与CAS比较每次修改版本号加一。这样面试官一听就知道你是真的踩过坑。3.3 synchronized现在为什么这么快synchronized在Java老版本里性能差是出了名的但JDK 1.6之后做了大量优化现在的性能已经和很多锁机制差距不大。锁的升级过程是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。偏向锁的意思是如果一个线程从头到尾独占这个锁JVM会在对象头的Mark Word里记录这个线程的ID后续这个线程再进入同步块时不用每次做CAS操作直接检查线程ID匹配就行。只有出现争用偏向锁才会撤销升级为轻量级锁。轻量级锁通过自旋来避免线程阻塞短时间的锁竞争用自旋更划算。如果自旋超过阈值就会升级为重量级锁这时候线程真正挂起涉及用户态和内核态的切换成本很高。理解这个过程对于实际调优也有帮助。比如你发现某个同步块大部分时间只有一个线程访问那就别动它偏向锁表现很好。如果发现两个线程频繁交替争抢同一把锁每次都在自旋里消耗大量CPU可以考虑读写分离或者用ReentrantReadWriteLock把读操作和写操作区分开。这里我补充一个自己踩过的坑不要在同步块里做耗时操作比如远程调用或者大量I/O。锁的持有时间越长其他线程等待的时间就越长即使升级到轻量级锁自旋也会浪费大量CPU。我见过一个线上问题某接口平均耗时800毫秒排查后发现同步块内部包含了一个网络调用每次调用要花400毫秒左右把并发度完全拖垮了。4. 实战排查从Redis的increment报错到类加载异常4.1 RedisTemplate调用increment()报not an integer or out of range这个报错很多做Java开发的朋友应该都见过我在学习群和论坛里也经常看到有人问。报错信息一般是ERR value is not an integer or out of range遇到这个报错第一反应不要觉得是Redis数据的问题先想序列化器。RedisTemplate默认使用的是JdkSerializationRedisSerializer也就是会把对象的二进制数据存储到Redis里。当你用这样的RedisTemplate调用increment()方法时Redis会尝试把value当作字符串进行自增但实际存进去的是Java序列化后的二进制内容当然不是整数就会报这个错。我在项目里解决这个问题的标准做法是单独配置一个专门操作Long、Integer类型值的StringRedisTemplate。StringRedisTemplate默认使用StringRedisSerializer所有值都以字符串形式存储increment()正常工作。如果非要用同一个RedisTemplate那就显式设置key和value的序列化器为StringRedisSerializer再调用increment()。如果是线上已经写入了错误数据不能直接修改序列化器就完事因为旧数据还是二进制格式。需要写一个迁移脚本把旧key的值取出来重新转换后写入新key或者手动删除旧key让它重新生成。这里有个细节有的同学用Redis做扣减库存时会把increment()返回的剩余库存直接当商品余量展示。但increment()返回的是操作之后的新值如果你初始库存设置为5那么第一次扣减调用increment(-1)返回4这是正确的结果。反过来如果你先把库存存成5然后调用decrement()再查询可能因为查询走的是另一个缓存key最终展示的数据和实际扣减不是同一个值。这个在设计中要特别注意一致性。4.2 java.lang.NoClassDefFoundError: java/applet/Applet in thread看到这个报错先别慌这其实是个类加载问题。我最早是在一个旧系统迁移JDK版本时碰到的。应用启动后某个线程运行时突然报java.lang.NoClassDefFoundError: java/applet/Applet in thread main原因很直接JDK 9开始Applet API被标记为废弃并且不再打包到默认的运行时模块里。如果你的老代码或者第三方依赖里直接引用了Applet编译时可以过运行时就会报NoClassDefFoundError。解决思路有三种按优先级排最简单的方法升级依赖。当年很多老代码确实引用了Applet相关类来做一些图形界面功能现在都有替代方案。如果依赖是旧版本无法升级就需要在JVM启动参数里添加模块导出配置。如果确实还停留在JDK 8那这个问题不会出现但JDK 8本身也停止维护了建议还是尽早迁移。这个报错的排查过程给我最大的启发是遇到类加载相关的错误思路应该是“这个类在什么版本的JDK里存在”而不是先想代码哪里写错了。类加载是分模块、分版本的这一点在做技术升级时特别重要。4.3 Lombok的编译警告不是环境问题热搜词里还有一条是“you arent using a compiler supported by lombok, so lombok will not work”。这个警告我经常看到有人发出来以为是Lombok和JDK版本不兼容。实际上Lombok在新版本里已经内置了对最新JDK的支持但如果你用的Lombok版本太旧或者IDE里内置的编译器和命令行用的编译器版本不一致就会出现这个警告。遇到这个警告我的处理步骤是确认项目使用的JDK版本和Lombok版本。去Lombok的发行说明里查一下你用的版本是否支持当前的JDK。如果项目是Maven或Gradle构建确保依赖中的Lombok版本是最新的或已验证兼容的。IDE里检查是否手动指定了Lombok插件如果有确保插件版本和依赖版本保持一致。如果还是不行直接在IDE的编译器设置里把Annotation Processing开启并选择项目使用的JDK版本。这个问题的本质是“注解处理器与编译器版本的匹配”而不是代码问题。很多新手在这里浪费了很多时间实际上就是一个配置问题。5. 一张表总结今天的重点与避坑清单主题核心要点高发坑点我的建议线程池参数核心线程、队列、最大线程三者联动误以为队列满了才创建线程是顺序问题压测调参别套公式volatile可见性、有序性不保证原子性用它修饰集合或复合操作复合操作使用CAS或锁CAS与ABA版本号解决ABA问题面试时只会背概念不会说场景用转账或库存场景举例synchronized锁升级路径偏向锁到重量级同步块内做耗时I/O锁持有时长尽量短RedisTemplate.increment()序列化器决定value类型用默认序列化器存二进制导致自增失败用StringRedisTemplate或显式设置序列化器NoClassDefFoundError类加载时找不到依赖模块升级JDK后老代码引用废弃API检查依赖兼容性配置模块导出Lombok警告编译器与注解处理器版本匹配IDE与命令行编译器版本不一致统一JDK版本升级Lombok插件这里额外分享一个排查基本功遇到并发相关问题不要急着猜先在本地写一个多线程测试类把你的场景复现出来。复现不了就没法验证没法验证就只能靠猜。靠猜解决问题的概率太低了。6. 学习方法的补充建议和常见问题速查6.1 为什么八股文不能只背我一开始也是对着题库狂背后来发现一个规律面试官问ConcurrentHashMap的源码细节我背了能答出来但他一换场景说“如果给你一个电商订单表高并发下避免重复下单你用什么方案”我就卡住了。这就是典型的“背了但不会用”。八股的价值在于帮你建立知识地图让你知道有哪些技术选型有什么权衡取舍但真正的判断力来自实操。我现在的方法是每天给自己一个问题场景比如“非重入锁和可重入锁的区别是什么在什么场景下你会用哪一种”然后去写一段小代码验证而不是直接看答案。验证完再回到八股文里把相关的概念串一遍印象会深很多。6.2 面试官问并发题时最想听到什么结合这段时间的学习和别人分享的面经我发现面试官在问并发问题时最在意的是你能不能体现出“权衡思维”。比如问线程池时他想听的是你怎么确定核心线程数而不是听你报公式。问Redis扣库存时他想听的是你怎么保证原子性和最终一致而不是听你说用decrement。问锁升级时他想听的是你什么时候该用乐观锁什么时候该用悲观锁而不是听你背升级路径。所以我的建议是背八股时每个知识点旁边都加一个“面试官真正想考察什么”的标注。这样你在面试时就能主动把回答往自己擅长的实操经历上引而不是傻背概念。6.3 常见问题速查表问题排查思路快速解法线程池使用后线程一直不回收核心线程默认不会回收设置allowCoreThreadTimeOut(true)高并发下数据库连接耗尽线程池线程数大于连接池上限调小核心线程数或调大连接池Redis库存扣减变成负数increment没有前置判断在扣减前检查剩余值或使用Lua脚本保证原子性ConcurrentHashMap在JDK 8的分段锁还在吗已改为CASSynchronized锁节点不需要再按JDK 7的分段锁思路分析volatile修饰的变量的值还是脏的复合操作没有原子性保证改用AtomicInteger或加锁最后再说点实际的这一轮学到第33天我最大的体会是并发编程的知识如果只停留在能背的阶段在工作中遇到线上问题还是会手忙脚乱。真正有用的学习方式是拿真实场景去验证概念。比如自己写一个高并发的扣减库存脚本压测一下看看数据有没有错线程池监控指标有没有异常。这个过程比背十遍八股文都管用。另外想分享一个小技巧我每次学完一个知识点都会去整理一个“今天的避坑清单”用手机备忘录记着之后在群里看到有人问到相关问题直接把自己踩过的坑发出来。这种方式既帮助了别人也逼着我用更清楚的逻辑重新梳理知识。到今天为止我已经攒了六十多条这样的记录了。
返回列表