当前位置: 首页 > news >正文

文章阅读与实践 - OOM/时间精度/步数排行实现/故障复盘

OOM后JVM一定会退出吗?为什么?

文章来源:阿里一面:OOM后JVM一定会退出吗?为什么?

OOM (OutOfMemoryError) 本身不会直接导致 JVM 退出。JVM 是否退出的唯一决定性因素是:是否还有任何非守护线程(Non-Daemon Thread)在运行。 OOM 只是导致单个线程终止的一种错误,其影响力仅限于抛出该错误的线程。

关键概念解析

  1. JVM 退出条件
    JVM 的设计规范决定了其退出的时机:当所有非守护线程都终止时,JVM 才会正常退出

  2. 守护线程 (Daemon Thread) vs. 非守护线程 (Non-Daemon Thread)

    • 非守护线程:通常由应用程序创建的主线程(main)和工作线程。它们的生命周期独立于 JVM,只要还有一个非守护线程在运行,JVM 就会保持存活。例如:main 线程、线程池的核心线程(默认)。

    • 守护线程:为其他线程提供服务的后台线程(如垃圾回收线程)。它们的生存依赖于 JVM,当所有非守护线程结束时,JVM 会忽略并终止所有守护线程,然后退出。

  3. OOM (OutOfMemoryError):属于 Error,是 Throwable 的一种。它表示 Java 虚拟机因内存不足而无法继续执行操作。和所有 Throwable 一样,OOM 的影响范围是线程级的。如果一个线程抛出的 OOM 未被捕获,该线程会终止,但不会波及其他线程或直接导致 JVM 退出。

实验现象与分析 (来自原文)

  • 实验场景
    线程池中的一个工作线程(ThreadPool-Thread)因任务需要大量内存而触发 OOM。

  • 观察结果

    1. ThreadPool-Thread 线程终止,其状态变为 WAITING(实际上,更准确的描述可能是该线程因 OOM 终止,而从线程池的角度看,这个工作线程实例被移除,线程池可能会尝试创建新的线程,但受限于内存可能失败)。

    2. main 线程(非守护线程)依然在运行,持续打印“我还活着”。

    3. JVM 进程一直没有退出

  • 实验结论
    即使某个线程因 OOM 而终止,只要还存在至少一个活跃的非守护线程(如 main 线程或线程池中存活的核心线程),JVM 就不会退出。这完美印证了 JVM 的退出机制。

OOM 导致 JVM “间接退出” 的两种特殊场景

我们之所以常感觉“OOM 了程序就挂了”,其实是以下两种场景造成的错觉,而非 OOM 本身直接杀死 JVM。

场景机制根本原因
场景一:所有非守护线程均因 OOM 终止JVM 内所有非守护线程在申请内存时都抛出了未捕获的 OOM,导致所有非守护线程相继终止。满足了 JVM 的自发退出条件JVM 正常的生命周期结束。因为没有了非守护线程,JVM 自然退出。
场景二:被操作系统强制终止 (Linux OOM Killer)JVM 进程因内存泄漏或疯狂申请内存,占用了大量系统内存,触发了 Linux 内核的 OOM Killer 机制。内核会选择“最坏”的进程(通常是耗内存最多的 JVM 进程)并强制杀死(kill -9)。操作系统的自我保护行为。JVM 是被外部力量“杀死的”,它自身还未来得及处理或退出。(Windows 无此机制)

知识点总结与启示

  1. 线程隔离性:Java 中错误的传播是线程隔离的。一个线程的崩溃(如 OOM)不会直接导致整个进程崩溃。

  2. 线程池的健壮性:线程池的设计很好地体现了这一点。一个任务的异常(包括 OOM)只会导致执行该任务的工作线程受影响,而线程池本身(作为一组管理线程)和其他工作线程可以继续运行,接收新任务。

  3. 问题排查

    • 遇到 JVM 进程僵死(不退出也不工作),可以检查是否还有非守护线程在阻塞或循环等待。

    • 遇到 JVM 进程突然消失(特别是 Linux 环境下),可以查看 /var/log/messages 或使用 dmesg 命令检查是否有 OOM Killer 的日志记录。

  4. 设计考量:对于可能发生 OOM 的关键任务,应考虑捕获 OutOfMemoryError(或更通用的 Throwable),在线程终止前进行必要的日志记录和资源清理,避免整个应用处于一种“部分瘫痪”的僵死状态。

对比表格:普通认知 vs. 实际原理

普通认知实际原理
程序抛出 OOM,JVM 就会崩溃退出。OOM 只终止当前线程。JVM 退出与否取决于非守护线程是否全部终止。
OOM 是导致 JVM 退出的直接原因。OOM 是间接原因。它通过导致所有非守护线程终止来满足 JVM 的退出条件。
JVM 进程被杀死一定是代码bug。也可能是被Linux OOM Killer等外部机制强制终止。

关于“时间精度不一致导致解封日期错误”的问题分析

文章来源:时间设置的是23点59分59秒,数据库却存的是第二天00:00:00

一、 问题概述

  • 问题现象:运营反馈,用户被封禁2天后应解封,但系统提示的解封时间却是3天后。

  • 具体案例:6月16日封禁,预期解封时间为6月18日 23:59:59,但系统提示和数据库实际存储的却是6月19日 00:00:00。

  • 数据特征:数据库中近一半的数据正确(23:59:59),另一半错误(00:00:00),且问题随机出现。

二、 代码逻辑与初步分析

LocalDateTime currentTime = LocalDateTime.now();
LocalDateTime futureTime = currentTime.plus(2, ChronoUnit.DAYS); // 加2天
// 设置为当天的最后一秒
LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);BlackAccount entity = new BlackAccount();
// 关键转换:将LocalDateTime(纳秒精度)转换为java.util.Date(毫秒精度)
// 实体字段类型为Date,数据库是timestamp
entity.setDeblockTime(Date.from(resultTime.atZone(ZoneId.systemDefault()).toInstant()));
blackAccountService.save(entity);

初步排查:

  1. 排除代码覆盖:确认项目中设置解封时间的代码仅此一处。

  2. 询问AI:AI指出了夏令时(DST) 和时区转换的潜在风险,但根据问题数据“任何时间点都存在两种情况”的特征,排除了这两种系统性偏差的可能性。

三、 问题定位与复现

  • 排查方法:编写批量插入测试代码,模拟高频次的数据写入。

  • 复现结果:成功复现问题!插入100条数据中,约一半是预期的 23:59:59,另一半则变成了次日的 00:00:00

  • 根本原因

    • Java端精度LocalDateTime 的精度是纳秒withSecond(59) 后,其纳秒字段(Nano)是一个很大的随机数(例如 59.123456789秒)。

    • 转换损耗java.util.Date 的精度是毫秒。在调用 Date.from(...) 转换时,纳秒会被舍入到毫秒(除以1000000)。

    • 数据库精度:数据库 TIMESTAMP 字段的默认精度为秒。当毫秒值 >= 500 时,数据库存入时会进行四舍五入,进位到下一秒。

    • 问题链LocalDateTime(nanos) -> Date(millis) -> Database(seconds)。当纳秒转换后的毫秒数 >= 500ms 时,入库后秒数 +1,于是 23:59:59.500 就变成了 00:00:00

四、 解决方案

方案一(推荐):在代码中统一精度

// 修改前:
LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);
// 修改后:清空纳秒部分,确保毫秒和秒级精度稳定
LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59).withNano(0);

方案二:调整数据库精度
将数据库 TIMESTAMP 字段的精度调整为至少毫秒级(如 TIMESTAMP(3)),使其与 Java 的 Date 对象精度匹配。但这可能影响现有数据库 schema。

五、 知识扩展与对比

1. Java 时间类特性

特性java.util.Date (旧)java.time.LocalDateTime (新)
精度毫秒 (ms)纳秒 (ns)
时区内部基于UTC,但行为不直观无时区,纯本地时间
可变性可变(非线程安全)不可变(线程安全)
API 易用性差(需与 Calendar 配合)优秀,方法链式调用
推荐场景旧系统维护所有新项目,用于表示本地日期时间

2. MySQL 日期时间类型

特性DATETIMETIMESTAMP
存储范围1000-01-01 到 9999-12-311970-01-01 到 2038-01-19 (2038问题)
时区处理不感知时区,存什么读什么感知时区,存入和读取时会自动转换为会话时区
存储空间8 字节4 字节(范围小的原因)
自动更新不支持(除非显式设置)支持 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE
推荐场景存储固定的时间点(如生日、订单创建时间)记录与时间线强相关的时间(如最后登录、更新时间)

3. 适用场景建议

  • Java 开发:优先使用 java.time 包(如 LocalDateTimeZonedDateTime)。

  • 数据库选择

    • 如果业务需要时区自动转换(如跨国应用),用 TIMESTAMP

    • 如果业务需要存储一个绝对不变的时间点(如活动开始时间),用 DATETIME

    • 如果时间需要超过 2038年,必须使用 DATETIME

亿级“微信步数”排行榜设计

文章来源:https://mp.weixin.qq.com/s/V2DoDhCMCyqSZO9TC_ru_w

设计一个支持上亿用户的步数排行榜系统,要求能实时查看好友间的排名。直接使用 MySQL,建表存储用户步数,查询时使用 ORDER BY steps DESC LIMIT ...。这个方案在亿级数据和高并发场景下会立刻崩溃。存在的挑战如下:

  • 写入地狱(海啸):亿级用户可能在相近时间点(如睡前)集中上报数据,产生极高的写入并发峰值。

  • 查询噩梦:对亿级数据表进行实时排序和分页操作,是数据库的噩梦,会导致 CPU 飙升和响应极慢。

  • 存储黑洞:数据量巨大(每月30亿条),直接使用 MySQL 存储成本高昂且效率低下。

  • 关系迷宫:排行榜是动态且个性化的,基于每个人的好友关系实时计算,无法预先生成一个全局排行榜。

核心设计思想:异步解耦、冷热分离、动静分离

第一板斧:写入流程 —— “异步解耦,削峰填谷”

用户App -> 业务服务 -> 消息队列(MQ) -> 消费服务 -> Redis

目标: 抵御写入海啸,保证系统稳定性和接口响应速度。

步骤解析:

  1. 用户上报:用户 App 将 {userId, steps, timestamp} 数据上报至业务服务(如 Java 服务)。

  2. 快速响应:业务服务不对数据做复杂处理,仅进行基本校验,然后立即将消息写入消息队列(如 Kafka/RocketMQ)。之后立刻返回成功给用户。MQ在此处扮演了“蓄水池”的角色,成功将瞬时流量削峰填谷。

  3. 异步消费:下游的消费服务以自己能承受的速度从 MQ 中消费消息。

  4. 更新排行榜:消费服务使用 Redis 的 ZADD 命令,将用户步数更新到当天的有序集合(ZSet)中。

    • Key: leaderboard:2025-09-12 (按日期区分)

    • Score: steps (步数)

    • MemberuserId

    • 优势ZADD 时间复杂度为 O(logN),性能极高,且能自动按分数排序。

 此方案使写入接口响应极快,后端处理能力与前台请求解耦,系统吞吐量取决于消费服务的处理能力,可以水平扩展。

第二板斧:查询流程 —— “动静分离,内存计算”

用户查询 -> 业务服务 -> [MySQL取好友列表] + [Redis取步数] -> 内存排序 -> 返回结果

目标: 实现好友排行榜的毫秒级实时查询。

核心概念:

  • 静:好友关系。变化不频繁,存储在 MySQL(需分库分表)。

  • 动:步数数据。实时变化,存储在 Redis

步骤解析:

  1. 查询关系:用户 A 请求排行榜时,业务服务先去 MySQL 中查询他的好友 ID 列表(例如 200 个)。

  2. 批量取数:业务服务拿到好友 ID 列表后,使用 Redis 的 Pipeline 或并发方式,一次性从当日的 ZSet Key 中批量获取这些好友的步数(Score)。绝对避免循环单个查询!

  3. 内存计算:此时服务端内存中只有约 200 条数据,对其进行排序(ORDER BY steps DESC)的计算开销极小,速度极快。

  4. 组装返回:将排序后的列表,补充上用户昵称、头像等信息(可从二级缓存如 Redis Cache 或 MySQL 中获取),返回给前端。

小结: 将耗时操作分解,“动”“静”数据分离获取,最终在内存中完成轻量级的合并计算,保证查询效率。

追问1:如何处理“几百万好友的大V用户”?

问题: 实时查询流程中,第1步从 MySQL 拉取百万好友ID列表和第2步从 Redis 获取百万分数,都会非常慢。

解决方案: 预计算 + 缓存降级

  • 为这类“热点用户”启动一个离线定时任务(例如每分钟一次)。

  • 任务提前为他计算好好友排行榜的前 N 名(如 Top 1000)。

  • 将计算结果直接缓存到一个特定的 Key 中(如 precompute:leaderboard:${大VuserId})。

  • 当大V查询时,直接返回这个预计算好的缓存结果,绕过实时计算流程。

追问2:Redis 挂了数据会不会丢?

解决方案: 高可用架构 + 数据恢复能力

  1. 高可用:线上 Redis 必须部署为主从复制 + 哨兵(Sentinel)模式集群模式。主节点宕机,从节点会自动切换为主,保证服务可用性。

  2. 数据可恢复:即使整个 Redis 集群宕机,数据也不会“彻底丢失”。

    • 原因:我们的数据源头是 MQ。通常 MQ 会设置消息保留时间(如 2-3 天)。

    • 恢复手段:可以编写一个应急恢复程序,从 MQ 中重新消费当天(或最近)的步数上报消息,即可将排行榜数据在新的 Redis 集群中完整重建,达到最终一致性。

一个TODO引发的P级故障与复盘

文章来源:https://mp.weixin.qq.com/s/AbY8pb0a74x3rsKP55zHVA

  • 时间:2021年,某S级“会员闪促”活动零点。

  • 现象:活动刚上线,促销服务集群(promotion-marketing)上百台机器CPU和Load垂直飙升,应用可用度暴跌至10%以下,活动入口全部因超时被降级。

  • 耗时:从故障发生到最终恢复,总共约30分钟

  • 影响:精心筹备的大促活动“上线即失踪”,对用户体验和GMV造成重大影响。

排查的过程:

  1. 第一阶段:无效的常规操作

    • 看日志:发现一些无关紧要的NPE(空指针异常),排除。

    • 怀疑死锁:分析线程快照(jstack),未发现死锁,排除。

    • 重启大法:短暂有效,但新流量进来后立即复发。

    • 紧急扩容:新扩机器同样迅速被高负载和GC拖垮,治标不治本。

  2. 第二阶段:深入肌体——找到关键线索

    • 保留现场:保留一台故障机,转储(dump)堆内存和线程栈。

    • 分析堆内存:发现老年代使用率极高,Full GC频繁。内存中驻留了大量与“万豪活动配置”相关的char[]数组,暗示有一个巨大的活动配置对象无法被回收。

    • 分析线程栈:发现大量线程(246个)处于RUNNABLE状态,且堆栈信息都卡在 com.alibaba.fastjson.toJSONString(...) 方法上。

    • 大胆假设:一个巨大的对象正在被疯狂、反复地序列化,这个CPU密集型操作耗尽了所有线程资源。

  3. 第三阶段:定位元凶——“一行好代码”

    • 根据线程栈定位到 XxxxxCacheManager.java 文件。

    • 发现一段上方标有 // TODO: 此处有性能风险,大促前需优化 注释的缓存写入代码。

public void updateActivityXxxCache(Long sellerId, List<XxxDO> xxxDOList) {for (int index = 0; index < 20; index++) { // 设计20个散列Key以分散读压力tairCache.put(..., JSON.toJSONString(xxxDOList), ...); // 在循环内序列化巨大对象!}
}
  • 根因分析

    • 缓存击穿:零点活动生效,缓存无数据,请求穿透到DB后回写缓存。

    • 循环序列化:回写时,将一个1-2MB的大对象在for循环内序列化了20次,成为“CPU绞肉机”。

    • 中间件被打爆:放大20倍的写流量(20 x 1MB)打爆了性能脆弱的Tair LDB缓存中间件,触发其限流。

    • 恶性循环:Tair限流导致写入耗时飙升,进一步拉长了“CPU绞肉机”的单次操作时间,最终占满所有HSF线程,服务雪崩。

    • 解决措施:紧急回滚了这段“循环序列化”的代码,集群恢复。

核心反思与工程法则

  1. 法则一:任何脱离了容量评估的“优化”,都是在“耍流氓”。

    • 教训:为解决“读压力”而设计的20个散列Key本是“优化”,但未评估“写放大”的代价,反而成为故障导火索。

    • 启示:任何技术方案的设计和优化,必须进行全面的、量化的容量评估(包括CPU、内存、网络、中间件负载等),心存敬畏,而非盲目“炫技”。

  2. 法则二:监控的终点,是“代码块耗时”。

    • 教训:拥有机器、接口、中间件监控,但缺乏方法级/代码块级的APM(应用性能监控),导致无法快速定位问题代码。

    • 启示:建设精细化的链路追踪和能力,能够快速定位到耗时的具体代码行,是提升排查效率的关键。

  3. 法则三:技术债,总会在你最想不到的时候“爆炸”。

    • 教训:文中使用的Tair LDB是一个老旧、无人维护的中间件,其性能脆弱性是隐藏的“技术债”,在极端流量下被引爆。

    • 启示:对于系统中存在的老旧组件、临时方案(TODO/FIXME)、破窗代码,必须定期梳理和偿还。技术债如同蟑螂,平时看不见,关键时刻致命。


文章转载自:

http://xUhWJmEp.jpwkn.cn
http://wsdsSUBg.jpwkn.cn
http://QDff2RCo.jpwkn.cn
http://6dH2S6UX.jpwkn.cn
http://hEbMNDmr.jpwkn.cn
http://sKw6ktb5.jpwkn.cn
http://604pOaqa.jpwkn.cn
http://qMm1MaFj.jpwkn.cn
http://44ANUNHH.jpwkn.cn
http://ubUrpVih.jpwkn.cn
http://wbgqjp8u.jpwkn.cn
http://yq5miQxP.jpwkn.cn
http://Jtri4019.jpwkn.cn
http://3kEGyTuS.jpwkn.cn
http://vlpVQhae.jpwkn.cn
http://W81FlsM9.jpwkn.cn
http://jb1rmhPP.jpwkn.cn
http://Lz0zQKI0.jpwkn.cn
http://KRRQ5212.jpwkn.cn
http://320wEk6J.jpwkn.cn
http://D95VGGoX.jpwkn.cn
http://kwSyFTdI.jpwkn.cn
http://CdbLwEJ6.jpwkn.cn
http://vWwvR88k.jpwkn.cn
http://YlLgKW2H.jpwkn.cn
http://2fBs2heD.jpwkn.cn
http://tAJawfbY.jpwkn.cn
http://BLRrpVK0.jpwkn.cn
http://Cd25FLDX.jpwkn.cn
http://MbG2qCEY.jpwkn.cn
http://www.dtcms.com/a/382419.html

相关文章:

  • 第七章:AI进阶之------输入与输出函数(二)
  • html列表总结补充
  • 系统软中间件:连接软件与硬件的桥梁
  • 关于Bug排查日记的技术文章大纲
  • 【Ambari监控】— API请求逻辑梳理
  • Deepseek构建本地知识库
  • DAY 29 复习日:类的装饰器-2025.9.16
  • 2025.9.14英语红宝书【必背16-20】
  • 【CMake】环境变量
  • 贪心算法应用:广告投放优化问题详解
  • VSCode AI编程插件
  • 题解:P4711 「化学」相对分子质量
  • QGIS构建问题
  • 【飞书多维表格插件】
  • 云原生与多云策略:构建弹性、开放的数据底座
  • Java接口入门:从零掌握行为规范
  • Java基础常见知识点
  • Linux epoll 事件模型终极指南:深入解析 epoll_event 与事件类型
  • 简单学习HTML+CSS+JavaScript
  • 4 Python开发环境准备
  • 人源化抗体:从临床应用到未来趋势,3 大领域突破 + 4 大发展方向全解析
  • Scrapy框架入门:快速掌握爬虫精髓
  • 2.1线性表
  • Java 21 虚拟线程高并发落地:中间件适配、场景匹配与细节优化的技术实践
  • 炒股进阶理论知识
  • 07_Softmax回归、损失函数、分类
  • 复杂系统迭代中多变量测试的实施经验
  • 智能体综述:从 Agentic AI 到 AI Agent
  • MICAPS:气象信息综合分析与处理系统概述
  • Python中实现数据库事务回滚的方法