文章阅读与实践 - OOM/时间精度/步数排行实现/故障复盘
OOM后JVM一定会退出吗?为什么?
文章来源:阿里一面:OOM后JVM一定会退出吗?为什么?
OOM (OutOfMemoryError
) 本身不会直接导致 JVM 退出。JVM 是否退出的唯一决定性因素是:是否还有任何非守护线程(Non-Daemon Thread)在运行。 OOM 只是导致单个线程终止的一种错误,其影响力仅限于抛出该错误的线程。
关键概念解析
-
JVM 退出条件:
JVM 的设计规范决定了其退出的时机:当所有非守护线程都终止时,JVM 才会正常退出。 -
守护线程 (Daemon Thread) vs. 非守护线程 (Non-Daemon Thread):
-
非守护线程:通常由应用程序创建的主线程(
main
)和工作线程。它们的生命周期独立于 JVM,只要还有一个非守护线程在运行,JVM 就会保持存活。例如:main
线程、线程池的核心线程(默认)。 -
守护线程:为其他线程提供服务的后台线程(如垃圾回收线程)。它们的生存依赖于 JVM,当所有非守护线程结束时,JVM 会忽略并终止所有守护线程,然后退出。
-
-
OOM (
OutOfMemoryError
):属于Error
,是Throwable
的一种。它表示 Java 虚拟机因内存不足而无法继续执行操作。和所有Throwable
一样,OOM 的影响范围是线程级的。如果一个线程抛出的 OOM 未被捕获,该线程会终止,但不会波及其他线程或直接导致 JVM 退出。
实验现象与分析 (来自原文)
-
实验场景:
线程池中的一个工作线程(ThreadPool-Thread
)因任务需要大量内存而触发 OOM。 -
观察结果:
-
ThreadPool-Thread
线程终止,其状态变为WAITING
(实际上,更准确的描述可能是该线程因 OOM 终止,而从线程池的角度看,这个工作线程实例被移除,线程池可能会尝试创建新的线程,但受限于内存可能失败)。 -
main
线程(非守护线程)依然在运行,持续打印“我还活着”。 -
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 无此机制) |
知识点总结与启示
-
线程隔离性:Java 中错误的传播是线程隔离的。一个线程的崩溃(如 OOM)不会直接导致整个进程崩溃。
-
线程池的健壮性:线程池的设计很好地体现了这一点。一个任务的异常(包括 OOM)只会导致执行该任务的工作线程受影响,而线程池本身(作为一组管理线程)和其他工作线程可以继续运行,接收新任务。
-
问题排查:
-
遇到 JVM 进程僵死(不退出也不工作),可以检查是否还有非守护线程在阻塞或循环等待。
-
遇到 JVM 进程突然消失(特别是 Linux 环境下),可以查看
/var/log/messages
或使用dmesg
命令检查是否有 OOM Killer 的日志记录。
-
-
设计考量:对于可能发生 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);
初步排查:
-
排除代码覆盖:确认项目中设置解封时间的代码仅此一处。
-
询问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 日期时间类型
特性 | DATETIME | TIMESTAMP |
---|---|---|
存储范围 | 1000-01-01 到 9999-12-31 | 1970-01-01 到 2038-01-19 (2038问题) |
时区处理 | 不感知时区,存什么读什么 | 感知时区,存入和读取时会自动转换为会话时区 |
存储空间 | 8 字节 | 4 字节(范围小的原因) |
自动更新 | 不支持(除非显式设置) | 支持 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE |
推荐场景 | 存储固定的时间点(如生日、订单创建时间) | 记录与时间线强相关的时间(如最后登录、更新时间) |
3. 适用场景建议
-
Java 开发:优先使用
java.time
包(如LocalDateTime
,ZonedDateTime
)。 -
数据库选择:
-
如果业务需要时区自动转换(如跨国应用),用
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
目标: 抵御写入海啸,保证系统稳定性和接口响应速度。
步骤解析:
-
用户上报:用户 App 将
{userId, steps, timestamp}
数据上报至业务服务(如 Java 服务)。 -
快速响应:业务服务不对数据做复杂处理,仅进行基本校验,然后立即将消息写入消息队列(如 Kafka/RocketMQ)。之后立刻返回成功给用户。MQ在此处扮演了“蓄水池”的角色,成功将瞬时流量削峰填谷。
-
异步消费:下游的消费服务以自己能承受的速度从 MQ 中消费消息。
-
更新排行榜:消费服务使用 Redis 的
ZADD
命令,将用户步数更新到当天的有序集合(ZSet)中。-
Key:
leaderboard:2025-09-12
(按日期区分) -
Score:
steps
(步数) -
Member:
userId
-
优势:
ZADD
时间复杂度为 O(logN),性能极高,且能自动按分数排序。
-
此方案使写入接口响应极快,后端处理能力与前台请求解耦,系统吞吐量取决于消费服务的处理能力,可以水平扩展。
第二板斧:查询流程 —— “动静分离,内存计算”
用户查询 -> 业务服务 -> [MySQL取好友列表] + [Redis取步数] -> 内存排序 -> 返回结果
目标: 实现好友排行榜的毫秒级实时查询。
核心概念:
-
静:好友关系。变化不频繁,存储在 MySQL(需分库分表)。
-
动:步数数据。实时变化,存储在 Redis。
步骤解析:
-
查询关系:用户 A 请求排行榜时,业务服务先去 MySQL 中查询他的好友 ID 列表(例如 200 个)。
-
批量取数:业务服务拿到好友 ID 列表后,使用 Redis 的
Pipeline
或并发方式,一次性从当日的 ZSet Key 中批量获取这些好友的步数(Score)。绝对避免循环单个查询! -
内存计算:此时服务端内存中只有约 200 条数据,对其进行排序(
ORDER BY steps DESC
)的计算开销极小,速度极快。 -
组装返回:将排序后的列表,补充上用户昵称、头像等信息(可从二级缓存如 Redis Cache 或 MySQL 中获取),返回给前端。
小结: 将耗时操作分解,“动”“静”数据分离获取,最终在内存中完成轻量级的合并计算,保证查询效率。
追问1:如何处理“几百万好友的大V用户”?
问题: 实时查询流程中,第1步从 MySQL 拉取百万好友ID列表和第2步从 Redis 获取百万分数,都会非常慢。
解决方案: 预计算 + 缓存降级
-
为这类“热点用户”启动一个离线定时任务(例如每分钟一次)。
-
任务提前为他计算好好友排行榜的前 N 名(如 Top 1000)。
-
将计算结果直接缓存到一个特定的 Key 中(如
precompute:leaderboard:${大VuserId}
)。 -
当大V查询时,直接返回这个预计算好的缓存结果,绕过实时计算流程。
追问2:Redis 挂了数据会不会丢?
解决方案: 高可用架构 + 数据恢复能力
-
高可用:线上 Redis 必须部署为主从复制 + 哨兵(Sentinel)模式或集群模式。主节点宕机,从节点会自动切换为主,保证服务可用性。
-
数据可恢复:即使整个 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造成重大影响。
排查的过程:
-
第一阶段:无效的常规操作
-
看日志:发现一些无关紧要的NPE(空指针异常),排除。
-
怀疑死锁:分析线程快照(
jstack
),未发现死锁,排除。 -
重启大法:短暂有效,但新流量进来后立即复发。
-
紧急扩容:新扩机器同样迅速被高负载和GC拖垮,治标不治本。
-
-
第二阶段:深入肌体——找到关键线索
-
保留现场:保留一台故障机,转储(dump)堆内存和线程栈。
-
分析堆内存:发现老年代使用率极高,Full GC频繁。内存中驻留了大量与“万豪活动配置”相关的
char[]
数组,暗示有一个巨大的活动配置对象无法被回收。 -
分析线程栈:发现大量线程(246个)处于
RUNNABLE
状态,且堆栈信息都卡在com.alibaba.fastjson.toJSONString(...)
方法上。 -
大胆假设:一个巨大的对象正在被疯狂、反复地序列化,这个CPU密集型操作耗尽了所有线程资源。
-
-
第三阶段:定位元凶——“一行好代码”
-
根据线程栈定位到
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线程,服务雪崩。
-
解决措施:紧急回滚了这段“循环序列化”的代码,集群恢复。
-
核心反思与工程法则
-
法则一:任何脱离了容量评估的“优化”,都是在“耍流氓”。
-
教训:为解决“读压力”而设计的20个散列Key本是“优化”,但未评估“写放大”的代价,反而成为故障导火索。
-
启示:任何技术方案的设计和优化,必须进行全面的、量化的容量评估(包括CPU、内存、网络、中间件负载等),心存敬畏,而非盲目“炫技”。
-
-
法则二:监控的终点,是“代码块耗时”。
-
教训:拥有机器、接口、中间件监控,但缺乏方法级/代码块级的APM(应用性能监控),导致无法快速定位问题代码。
-
启示:建设精细化的链路追踪和能力,能够快速定位到耗时的具体代码行,是提升排查效率的关键。
-
-
法则三:技术债,总会在你最想不到的时候“爆炸”。
-
教训:文中使用的Tair LDB是一个老旧、无人维护的中间件,其性能脆弱性是隐藏的“技术债”,在极端流量下被引爆。
-
启示:对于系统中存在的老旧组件、临时方案(TODO/FIXME)、破窗代码,必须定期梳理和偿还。技术债如同蟑螂,平时看不见,关键时刻致命。
-