资讯动态

CPU飙升100%?这8种代码写法是元凶,附排查与修复指南

发布时间:2026/10/15 22:32:31 来源:尧图企业网站定制
CPU 使用率100%你见过CPU 一路飙升到100%然后整个服务卡死你也肯定见过。之前帮某个业务团队处理过类似的线上故障监控图里CPU曲线像一个垂直上升的登山者几分钟就封顶重启、加CPU核完全无效。排查之后才发现真正的元凶不在基础设施层而是代码里的几行循环、几次字符串拼接和一把位置没放对的锁。这篇不是课堂讲义而是一次线上事故复盘后的整理。下面这8种写法我每一种都在真实系统里亲手遇到过也亲眼见过它们把一台机器的CPU从20%推到100%。每一种我都会给出症状、代码、原因和修复方式希望能帮你少走点弯路。1. 第一种写法只顾循环不记得退出条件死循环大概是所有人学编程时遇到的第一只拦路虎。但线上问题里的死循环往往不像嵌套循环刷屏那样明显它经常看起来是一段很正常的代码只是漏写了一个退出条件或者退出的变量永远不会被更新。1.1 一段空转代码的现场还原某次排查某个队列消费服务现象很经典一台4核机器CPU持续跑满一个核但业务请求量并不高日志也看不出明显报错。用工具把线程快照打下来发现有一个名为consumeQueue的线程一直卡在轮询队列的位置。当时的代码大概长这样public void consumeQueue() { while (true) { QueueTask task taskQueue.poll(); if (task ! null) { handle(task); } // 队列为空时什么都没发生直接回到循环顶部 } }问题一眼就能看出来队列为空时poll返回null但代码没有else也没有sleep线程立刻回到循环顶部重新尝试。任务量低的时候这个线程表面上“在等任务”实际上以最快速度空转白白烧掉一个CPU核心。这类问题最迷惑的地方在于系统看起来没宕机接口还能正常响应但CPU占用已经悄悄拉到顶。如果你只看慢查询和报错日志很难察觉。1.2 同一个坑的另一些马甲循环里依赖某个外部状态退出但状态变量没被正确更新。遍历链表时用了while (node.next ! null)但循环体里却忘写node node.next。给循环条件设了边界边界变量却在循环体被其他逻辑意外改大。尤其是第三种最隐蔽。你看代码时觉得条件没问题实际运行里某个值被并发线程改掉循环条件永远成立一旦条件被越改越远CPU就跟着越烧越高。1.3 怎么改才是正确姿势至少要做到两点一是热点循环必须有明确的退出路径二是空转时要让出CPU。public void consumeQueue() { while (running.get()) { QueueTask task taskQueue.poll(); if (task ! null) { handle(task); continue; } try { Thread.sleep(10); // 队列空时睡一下让出CPU } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }如果对实时性要求高还可以用阻塞队列或者信号量去等待不忙转。我个人在写任何while(true)之前都会先问自己退出条件在哪等待的时候怎么让出CPU这两个问题想不清楚宁可拆成定时任务也别硬写循环。2. 第二种写法用轮询替代通知和死循环不同轮询代码通常会有个sleep看起来不像在空转。但致命的点在于你设置了极短的轮询间隔或者虽然间隔不短但实例数量极多两者叠加起来照样能把CPU打成一条直线。2.1 轮询是如何把CPU吃掉的看过一段Python写的状态检查脚本负责不断同步某个远端服务的执行状态import time while True: result check_remote_status() if result ok: break time.sleep(0.01) # 你以为你在让出CPU其实每秒一百次当时这个脚本被部署在几十台机器上每台机器同时跑十几个进程。每个进程每10毫秒拉一次远端接口一台机器一秒内就产生上千次HTTP调用。CPU大量消耗在网络连接的建立、解析和切换上对端服务也被拖垮整个链路雪崩。这里的核心问题是在“轮询”和“通知”之间我选择了最消耗资源的那条路。状态变化本来就是低频事件我却用高频检查去捕捉它。2.2 什么时候你觉得轮询很合理依赖别人的回调接口但别人迟迟不回调。外部系统不支持推送只能主动拉取。怕漏消息所以把间隔调到极限。这些理由我都听过也能理解。但你得算一笔账假设你有100个线程轮询同一批数据每个线程每500毫秒查一次10秒内就是2000次查询。如果其中绝大部分查询返回的都是“没变化”你实际上是在用几千次无用计算换取一次状态更新。2.3 更稳的方案能走事件、消息队列的不要自己写轮询。必须轮询时先计算你能接受的“最大感知延迟”把间隔尽量拉大。轮询结果变化不大时用指数退避提升间隔也就是连续几次没变化就往上涨间隔。多线程共享状态时不要每个线程都轮询让一个线程代表所有人去查再通过本地缓存发通知。我见过一个做得好的例子某系统从“每个请求都实时查配置”改成“本地缓存后台固定10秒刷新一次”CPU直接降了60%。轮询本身不是原罪滥用才是。3. 第三种写法只认O(n^2)的暴力匹配算法题里大家都听过“时间复杂度”但真实的业务代码往往是另一番景象数据量从1万涨到100万代码没换跑起来CPU直接翻几十倍。这种“代码看着没问题数据一多就崩”的场景几乎都是低效匹配造成的。3.1 隐藏在Arrays.asList里的性能炸弹某个报表服务需要根据一批订单ID去过滤订单列表代码写得很直白ListString orderIds loadOrderIds(); // 假设20万个ID ListString blacklist loadBlacklist(); // 假设2000个ID for (String orderId : orderIds) { if (blacklist.contains(orderId)) { continue; } // 处理订单 }这里blacklist.contains(orderId)的内部原理是遍历整个ArrayList最坏情况下要跟2000个元素逐一比较。外层循环20万次两层循环乘起来就是4亿次比较。如果过滤操作再加深一点每次都跑一遍不热门才怪。换成HashSet以后SetString blacklist new HashSet(loadBlacklist()); for (String orderId : orderIds) { if (blacklist.contains(orderId)) { continue; } // 处理订单 }HashSet.contains的期望复杂度是O(1)大概只需要计算哈希再定位一次。外层再怎么循环每次判断也就两次哈希运算的量完全不在一个量级。3.2 比复杂度更重要的事很多人觉得算法题里的复杂度只是理论但真实业务里它就是决定CPU生死线的东西。我常用的快速估算1万条数据O(n^2) 就是 1亿次操作几十毫秒到几百毫秒。100万条数据O(n^2) 就是 1万亿次操作几分钟甚至更久。但 O(n log n) 的算法通常只比 O(n) 多一个不到20的系数工程上很划算。还有一个更隐蔽的问题很多人喜欢在循环里调用工具类方法但工具类方法内部用的是数组遍历。看起来只是一行someUtils.match(order)实际却在每个订单上都做了一遍全表扫描。写代码时要留意“方法内部的实现”而不是只看方法名尤其是数据量上到十万、百万级的场景。3.3 经验做法能建索引的匹配先建索引Java里就是HashMap/HashSet能排序的用二叉树或者排序数组二分。两重循环前先想想是否能用一层循环集合/映射替代大数据量的循环体里避免使用涉及遍历的方法像List.contains、indexOf、Array.indexOf这类都要警惕。顺手估算一下最坏情况和常见情况的操作次数超过千万级就要停下来思考。4. 第四种写法循环里反复拼接字符串字符串拼接看起来是基本功但在Java这类语言里一个不小心就能制造大量临时对象拖垮整个内存和CPU。很多人写代码时只盯着业务逻辑没注意到底层在疯狂做拷贝。4.1 循环体里的有多贵某个导出模块需要生成一段自定义报表代码是把每行记录拼进一个大字符串String result ; for (Order order : orders) { result order.toJson() \n; }订单量少的时候很顺畅。订单量涨到几十万时内存疯狂上涨CPU使用率也跟着飙到100%。原因在于String是不可变对象每次都会创建一个全新的String然后复制旧的字符串内容和新增内容。几十万次拼接复制总量加起来是平方级的字节数GC压力大得吓人。更严重的场景是循环里不断用拼接日志、SQL、JSONString sql SELECT * FROM orders WHERE id IN (; for (Long id : orderIds) { sql id ,; } sql sql.substring(0, sql.length() - 1) );这种写法还会引入另一个问题一旦循环中间发生异常前面拼接好的大字符串全变成垃圾对象GC先跑一会儿然后内存又继续涨CPU就在GC和拼接之间反复横跳。4.2 正确打开方式StringBuilder sb new StringBuilder(); for (Order order : orders) { sb.append(order.toJson()).append(\n); } String result sb.toString();StringBuilder内部用可变字符数组不够了就扩容不会反复复制整个字符串。几千次拼接的性能差距也许不明显几十万次就会差出几十倍。4.3 什么时候该用字符串模板其实现在很多语言都有更优雅的写法。JavaScript 里用模板字符串let result ; for (const order of orders) { result ${order.toJson()}\n; // 这个写法没有Java那么伤但大数据量下也要注意 }Python 里用.join()lines [order.to_json() for order in orders] result \n.join(lines)Java 也有String.join和更现代的文本块。如果精简不复杂用StringBuilder永远是安全牌如果拼接内容很多段考虑先把每段切片放进集合最后一次性用join合并。总之别让字符串拼接出现在深度循环里。5. 第五种写法每次都重新编译正则表达式正则表达式是文本处理的利器也是CPU杀手。我见过很多系统在同一个循环里反复执行同样模式的匹配但每次匹配都会重新编译一次正则相当于每来一个请求就重复造一遍匹配引擎CPU不飙才怪。5.1matches的隐形开销这段代码在很多校验场景里非常常见for (String logLine : logLines) { if (logLine.matches(\\d{4}-\\d{2}-\\d{2}.*device.*)) { process(logLine); } }logLine.matches()内部会调用Pattern.matches(regex, input)每次都会用传入的正则重新编译出一个Pattern对象。编译正则要做词法分析、语法分析、生成匹配状态机十分昂贵。几万行日志跑下来编译开销比匹配本身还高。5.2 预编译的正确姿势private static final Pattern LOG_PATTERN Pattern.compile(\\d{4}-\\d{2}-\\d{2}.*device.*); for (String logLine : logLines) { if (LOG_PATTERN.matcher(logLine).find()) { process(logLine); } }把正则作为静态常量编译一次之后所有匹配都复用同一个Pattern对象。这里注意匹配方法的选择find()是部分匹配matches()是全量匹配含义完全不同。Python 里同样要避免在循环里直接写re.match(pattern, line)正确做法是pattern re.compile(r...)放到循环外。5.3 比重新编译更恐怖的是灾难性回溯复杂正则还有个问题是灾难性回溯。比如^(a)$去匹配一段由一堆a再加一个!构成的字符串如果输入的不匹配字符很长回溯的次数会呈指数级爆炸。正则匹配从原本的微秒级直接变成秒级、分钟级CPU被打到100%是很常见的。遇到比较复杂的校验我的建议是先看能否用字符串的startsWith、contains、split拆解。再用简单的正则尽量避免嵌套量词。使用正则之前给pattern一个最大输入长度限制避免超长文本触发灾难性回溯。线上日志里如果发现某次匹配耗时飙升先怀疑不是机器性能问题而是正则本身出了问题。6. 第六种写法递归不加缓存还指数级调用递归写起来很直观但有些人忽略了递归的时间复杂度。经典斐波那契数列就是最典型的例子看着只有两行代码实际跑起来能把CPU烧穿。6.1 教科书版本的翻车现场int fib(int n) { if (n 1) return n; return fib(n - 1) fib(n - 2); }这个写法逻辑完全正确但效率极其糟糕。计算fib(45)可能要执行数亿次函数调用耗时几十秒。计算fib(50)会执行数百亿次调用CPU直接顶满内存也在疯狂创建栈帧。原因不是“递归”本身而是没有缓存中间结果。fib(n-1)和fib(n-2)两个分支里有大量子任务重复计算形成一个指数级膨胀的计算树。6.2 用记忆化或者迭代int fib(int n) { if (n 2) return n; int prev 0, curr 1; for (int i 2; i n; i) { int next prev curr; prev curr; curr next; } return curr; }或者用备忘录递归MapInteger, Integer memo new HashMap(); int fib(int n) { if (n 1) return n; if (memo.containsKey(n)) return memo.get(n); int result fib(n - 1) fib(n - 2); memo.put(n, result); return result; }两者都能把计算量从指数级降到线性级fib(50)从几百亿次降到几十次。6.3 业务项目里的同类问题真实业务里面最常遇到的是树形结构遍历和组合枚举。比如做权限树、组织架构树时递归内频繁访问数据库或外部接口每一层都重复拉同一批数据还层层返回相同的子节点。数据不多时没问题一旦树的深度和宽度扩大CPU和接口调用量一起爆炸。处理这类问题的通用策略递归里尽量避免重复计算和重复查询能传缓存就传缓存。对树形结构提前做好深度限制和循环检测防止节点互相引用形成死循环。能用迭代 栈模拟递归的业务尽量用迭代替代规避栈溢出风险。递归深度超过几百层时需要先检查是否存在合理的基线条件和剪枝逻辑。7. 第七种写法用异常驱动正常逻辑异常机制设计出来是为了处理“异常情况”但很多人把它当成流程控制的一部分。如果代码里正常流程也会频繁抛异常那CPU就要反复干一件很重的事构建异常栈、填充堆栈信息、捕获再处理。7.1 一段用异常当if的代码某批处理系统需要根据用户ID列表查询用户信息其中相当一部分用户已经注销查询不到。原代码是这样for (String userId : userIds) { try { User user userService.find(userId); handle(user); } catch (UserNotFoundException e) { log.warn(用户不存在{}, userId); } }从外层看这很合理找不到用户就记一条日志继续处理。但对底层实现来说每一次find()都因为用户不存在而抛出一个异常系统每秒可能要处理上千个异常。每个异常都会收集调用栈打印堆栈信息这个开销比正常方法调用高几十倍甚至上百倍CPU消耗会非常稳定地居高不下。7.2 不要用异常表达业务判断在正常逻辑里判断数据是否存在应该用返回值、布尔值、空集合等手段for (String userId : userIds) { OptionalUser userOpt userService.findSafely(userId); if (userOpt.isEmpty()) { log.warn(用户不存在{}, userId); continue; } handle(userOpt.get()); }如果接口是你自己设计的尽量让“查不到”走空值或返回码而不是抛异常。异常就留给真正意外的情况比如数据库连接断开、参数非法、状态冲突。7.3 打日志也是CPU黑洞还有一种情况是把日志打印当调试工具循环里每处理一条记录都要记一行INFO甚至把整个对象toString打出来。在高并发场景下日志输出本身就要做字符串拼接、格式化、写文件或网络传输这些都吃CPU并不会因为“只是日志”就没有代价。我的实践标准热路径里禁止打印大对象。日志里能不打就不打真需要调试时动态开启。日志级别至少区分INFO和DEBUG不要把DEBUG当成常规输出。异常日志要控制数量避免同一个错误每秒刷几千条尤其是循环里抛异常后马上打印。8. 第八种写法把锁范围拉得特别大并发控制里有一句老话锁是万恶之源。很多系统CPU打到100%不是算法问题而是锁的竞争在调度层面吃掉了大量计算资源。代码本身业务逻辑不重但线程因为抢同一把锁疯狂阻塞和唤醒整个系统的有效工作量变得极低。8.1 锁住了一大段无关逻辑某金融接口处理请求时为了安全直接在方法上加锁synchronized (lock) { ListString configs queryConfigFromDb(); // 网络I/O20ms processLargeFile(configs); // CPU密集200ms sendNotify(); // 外部调用未知耗时 }这段代码只对configs这个共享字段有真实并发写需求却把网络请求、大文件处理和外部通知全部包在锁里。请求量一高大量线程排队等待同一个锁。锁竞争不光让请求变慢还会让CPU忙于线程调度、上下文切换和锁状态维护整体CPU使用率直线上升。更要命的是如果锁内代码调用了外部接口而且外部接口超时时间设得较长线程会长时间占住锁不放。其他线程全部排队最坏情况下系统表现为“CPU高、请求超时、业务无响应”三重故障同时出现。8.2 尽量缩小锁的粒度正确做法是只保护共享资源或者唯一的临界区ListString configsCopy; synchronized (lock) { configsCopy new ArrayList(sharedConfigs); // 只加锁做一个快照 } processLargeFile(configsCopy); sendNotify();拷贝本身可能也会有小开销但只要临界区变得足够短锁竞争几乎不存在。进一步的优化方向用ReadWriteLock做读写分离读多写少时能大幅提升并发吞吐。用并发容器比如ConcurrentHashMap、CopyOnWriteArrayList替代外部锁。尽量用无锁数据结构或者原子类替代重量级锁。不要在持锁时做数据库查询和网络调用这是并发编程里最能保命的一条规则。8.3 看到信号量过量也要警惕锁的范围太大是一种问题但锁本身太多也是。有些系统什么都加锁导致大量线程在锁上自旋等待。Java里的偏向锁、轻量级锁、重量级锁升级过程在处理高竞争时本身也有CPU开销锁竞争越激烈切换成本越高。判断是不是锁的问题有一个非常直接的信号CPU使用率很高但业务线程几乎都在等待状态磁盘和网络I/O并不忙。这种情况基本就是锁竞争或者线程频繁切换造成的。9. 当CPU已经被写成100%怎么自救上面8种写法介绍的是“病因”但等到CPU真的100%时你需要的是一套可以立刻上手的排查流程。这些步骤来自我自己的线上排障经验不敢说是最优但按这个顺序走基本能在几分钟内锁定问题。9.1 先确认现象别急着重启用top看整机CPU占用再按CPU排序找到最耗资源的进程PID。对Java进程用jstack PID把线程快照打印出来重点找RUNNABLE状态且停在业务代码的线程。对Python进程可以用py-spy dump --pid拿到调用栈C/C 用gdb或者perf top定位。如果多个线程呈“Waiting for monitor enter”状态基本可以怀疑锁竞争。如果是GC线程占用明显看是不是内存分配过于频繁再倒回去查代码里的循环和字符串拼接。9.2 常见场景和定位方向速查表现场特征大概率原因排查重点CPU满一个核其他核空闲单线程死循环或忙轮询线程栈的循环调用位置所有核都满接口RT暴涨并发线程集体空转/锁竞争锁等待线程数、阻塞方法GC线程占用大内存上涨快循环中创建大量对象字符串拼接、多余装箱日志量大磁盘也在飙升异常驱动日志刷屏异常打印频率、热路径日志网络请求量异常高轮询太频繁轮询线程数量、调用间隔9.3 修复后要做的事CPU降下来不算完。我一般会要求把热点代码优化后压测至少半小时确认新代码不回头。如果能顺手把相关监控指标加上最好在代码里预设一个线程占用的告警一旦同一个线程长时间占住CPU超过阈值就立刻报警。写在最后踩过这些坑之后我个人对CPU 100%这件事有了完全不同的态度。CPU被打满不一定是机器不够大多数时候是代码在用最原始的方式疯狂打磨与其盲目扩容不如停下来看看循环、轮询、字符串拼接和锁的用法。现在写代码时我心里会反复过几个问题这个循环有明确的退出条件吗等待状态有没有让出CPU数据量上来后复杂度会不会失控锁的范围能不能再小一点这几个问题如果能形成条件反射你大概率不会再见到那根垂直向上的CPU曲线。我的建议是把你最容易踩的那类问题做成开发规范在代码评审阶段就拦住它们。毕竟线上出问题时可没有人在旁边帮你默念“注意复杂度”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑