JVM线上排查:FullGC、OOM和CPU飙高怎么定位

JVM 知识最终要落到线上排查。供应链系统通常链路长、依赖多、峰值明显,一次订单高峰可能同时触发接口变慢、消息堆积、GC 频繁、CPU 飙高。排查时不能只看某一个指标,而要把 JVM、业务流量、线程、内存和日志串起来。

整体流程图

JVM 线上排查流程

必须掌握的工具

常用 JVM 排查工具包括:

  1. jps:查看 Java 进程。
  2. jstat:查看 GC、类加载、编译等统计。
  3. jstack:导出线程栈,定位死锁、阻塞、CPU 高。
  4. jmap:查看堆概要、导出 heap dump。
  5. jcmd:JDK 7 以后推荐的综合诊断工具。
  6. JFR:低开销运行时事件记录,适合分析 CPU、锁、IO、GC。
  7. Arthas:线上 Java 诊断工具,适合查看方法调用、耗时、类加载、对象信息。

生产环境使用这些工具要遵守权限和变更规范。导出堆 dump 可能造成磁盘压力,jmap -dump 也可能让进程短暂停顿,必须评估影响。

场景一:Full GC 频繁

假设订单服务在大促期间频繁 Full GC,接口 P99 明显升高。第一步先确认 GC 情况:

1
2
jps -l
jstat -gcutil <pid> 1000 10

重点看 YGC、YGCT、FGC、FGCT、O。如果老年代使用率长期接近 100%,并且 Full GC 后下降不明显,说明很多对象仍然可达。

继续导出堆直方图:

1
jcmd <pid> GC.class_histogram | head -n 40

如果发现大量订单上下文对象:

1
2
3
num     #instances         #bytes  class name
1 1250000 180000000 com.example.order.OrderContext
2 980000 120000000 com.example.promotion.PromotionResult

就要回到业务代码查缓存、静态集合、消息重试队列、批处理暂存区。供应链系统常见原因是把订单处理上下文放入本地缓存,但没有过期时间或清理机制。

1
2
3
4
5
6
7
public class BadOrderContextCache {
private static final Map<String, OrderContext> CACHE = new ConcurrentHashMap<>();

public void put(OrderContext context) {
CACHE.put(context.getOrderNo(), context);
}
}

这类代码应该替换成有容量和过期策略的缓存,或者把上下文持久化到 Redis、数据库,并按业务状态清理。

场景二:OOM

OOM 发生时,先看异常类型:

1
2
3
4
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: Metaspace
java.lang.OutOfMemoryError: Direct buffer memory
java.lang.OutOfMemoryError: unable to create native thread

建议生产默认开启:

1
2
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump

如果是 Java heap space,使用 MAT、VisualVM 或 JProfiler 分析 dump,重点看 Dominator Tree 和最大保留对象。如果是 Metaspace,检查动态类、代理、脚本、热部署。如果是 Direct buffer memory,检查 Netty、NIO、文件导入导出。如果是 unable to create native thread,检查线程池是否无界、容器 pids 限制和 -Xss。

供应链报表导出里的典型 OOM:

1
2
3
4
5
public byte[] exportAllOutboundOrders() {
List<OutboundOrder> orders = outboundRepository.findAll();
Workbook workbook = excelWriter.write(orders);
return workbook.toByteArray();
}

这个实现同时把全量订单和完整 Excel 放在内存里。更合理的方式是流式查询和流式写出:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void exportOutboundOrders(OutputStream outputStream) {
try (StreamingExcelWriter writer = new StreamingExcelWriter(outputStream)) {
long lastId = 0L;
while (true) {
List<OutboundOrder> page = outboundRepository.findPage(lastId, 1000);
if (page.isEmpty()) {
break;
}
writer.writeRows(page);
lastId = page.get(page.size() - 1).getId();
}
}
}

场景三:CPU 飙高

CPU 高时,不能只重启。要先找到高 CPU 线程:

1
2
3
4
top -Hp <pid>
printf "%x\n" <tid>
jstack <pid> > /tmp/order-service.jstack
grep -n "<hexTid>" /tmp/order-service.jstack

如果线程栈显示大量时间消耗在价格计算、促销匹配、库存路由,可以继续结合业务日志和方法耗时定位。常见原因包括死循环、递归层级过深、正则表达式回溯、JSON 大对象序列化、锁竞争导致线程忙等。

例如库存路由中错误的递归:

1
2
3
4
5
6
7
public WarehouseRoute findRoute(String skuCode, String warehouseCode) {
WarehouseRoute route = routeRepository.find(skuCode, warehouseCode);
if (route == null) {
return findRoute(skuCode, fallbackWarehouse(warehouseCode));
}
return route;
}

如果 fallbackWarehouse() 在某些配置下返回原仓库编码,就会无限递归,最终 CPU 飙高或栈溢出。生产代码要增加访问集合和终止条件。

排查顺序建议

一次线上 JVM 故障可以按这个顺序处理:

  1. 先止血:限流、摘流量、扩容、暂停批处理,保护核心交易链路。
  2. 保留现场:保存 GC 日志、业务日志、线程 dump、必要时保存 heap dump。
  3. 判断类型:CPU、内存、GC、线程、类加载、IO 还是外部依赖。
  4. 缩小范围:结合发布时间、流量峰值、任务调度、业务操作。
  5. 验证根因:用 dump、日志、监控和代码路径互相印证。
  6. 修复并复盘:补监控、补限流、补压测、补容量评估。

JVM 排查能力的本质,是把现象变成证据,把证据映射到代码和业务。对供应链系统来说,订单高峰、仓储波次、库存同步、报表导出都可能触发 JVM 问题,排查时要同时看业务峰值和运行时状态。

JVM参数调优:供应链服务堆内存和GC怎么配置

JVM 调优不是把网上的参数复制到生产环境。真正的调优要从业务目标出发:接口延迟、吞吐量、内存成本、GC 停顿、故障恢复速度。供应链服务通常既有在线接口,也有批处理任务,二者对 JVM 参数的要求不同,不能使用同一套配置机械套用。

整体流程图

JVM 参数调优流程

调优前先明确目标

调 JVM 参数之前,至少要回答四个问题:

  1. 服务类型是什么:在线订单接口、库存查询、仓储波次、报表导出、定时批处理。
  2. 核心指标是什么:P99 延迟、吞吐量、任务完成时间、内存占用、稳定性。
  3. 当前瓶颈在哪里:GC 停顿、CPU、数据库、网络、锁竞争、对象分配过快。
  4. 有哪些证据:GC 日志、堆 dump、线程 dump、监控曲线、压测报告。

没有证据的调优很容易变成参数玄学。JVM 参数只能解决 JVM 层面的问题,不能替代 SQL 优化、缓存设计、批处理拆分和业务限流。

常用参数分类

堆内存相关:

1
2
3
4
-Xms2g
-Xmx2g
-XX:NewRatio=2
-XX:MaxMetaspaceSize=512m

GC 相关:

1
2
3
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45

线程栈和直接内存:

1
2
-Xss512k
-XX:MaxDirectMemorySize=512m

诊断相关:

1
2
3
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-Xlog:gc*,safepoint:file=/data/logs/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

容器相关要关注 JVM 对 cgroup 的识别能力。JDK 8 早期版本和 JDK 10 以后的行为不同,生产环境要确认基础镜像和 JDK 版本。

在线订单服务配置示例

在线订单服务关注接口延迟和稳定性。可以采用固定堆大小,减少运行时扩缩容带来的抖动:

1
2
3
4
5
6
7
8
9
10
11
12
JAVA_OPTS="
-Xms2g
-Xmx2g
-Xss512k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=40
-XX:MaxMetaspaceSize=512m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/order-service
-Xlog:gc*,safepoint:file=/data/logs/order-service/gc.log:time,uptime,level,tags:filecount=10,filesize=100M
"

这个配置适合中等规模的 Spring Boot 服务起步使用。实际值要根据容器内存、对象分配速率、流量峰值和压测结果调整。例如容器限制 3Gi 内存时,不能把 -Xmx 设置成 3g,因为元空间、线程栈、直接内存、JIT、JNI、系统开销也需要内存。

批处理服务配置示例

库存重算、订单归档、对账导出更关注吞吐和任务完成时间。批处理服务可以接受较长 GC 停顿,但要避免内存峰值导致 OOM:

1
2
3
4
5
6
7
8
9
10
11
JAVA_OPTS="
-Xms4g
-Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=500
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/reconcile-job
-Xlog:gc*:file=/data/logs/reconcile-job/gc.log:time,uptime,level,tags:filecount=5,filesize=200M
"

但批处理的第一优化点仍然是分批。下面这种一次性加载全量库存的写法不适合生产:

1
2
3
4
5
6
7
public void rebuildInventorySnapshot() {
List<InventoryRecord> all = inventoryRepository.findAll();
List<InventorySnapshot> snapshots = all.stream()
.map(this::toSnapshot)
.collect(Collectors.toList());
snapshotRepository.batchSave(snapshots);
}

更合理的是按主键或时间窗口拆分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void rebuildInventorySnapshot() {
long lastId = 0L;
while (true) {
List<InventoryRecord> page = inventoryRepository.findPage(lastId, 5000);
if (page.isEmpty()) {
break;
}
List<InventorySnapshot> snapshots = page.stream()
.map(this::toSnapshot)
.collect(Collectors.toList());
snapshotRepository.batchSave(snapshots);
lastId = page.get(page.size() - 1).getId();
}
}

参数调优和代码优化要配合。否则即使堆从 2g 调到 8g,也只是延后 OOM 或把 Full GC 停顿变得更长。

G1 调优注意事项

G1 常用调优思路:

  1. 先设置合理的 -Xms 和 -Xmx,生产服务通常设置为相同值。
  2. 通过 MaxGCPauseMillis 给 JVM 一个目标,但它不是强制保证。
  3. 如果老年代增长过快,可以适当降低 InitiatingHeapOccupancyPercent,让并发标记更早开始。
  4. 注意 Humongous Object,大对象会占用连续 Region,可能引发额外回收压力。
  5. 不要轻易手动设置过多 G1 细节参数,先用 GC 日志验证问题。

调优闭环

一次合格的 JVM 调优应该形成闭环:

  1. 记录问题:接口 P99 从 200ms 上升到 2s,GC 停顿明显增加。
  2. 收集证据:GC 日志、堆使用曲线、对象分配热点、线程 dump。
  3. 提出假设:订单波次分组产生大量临时对象,新生代回收频繁。
  4. 小步调整:分页处理、降低对象峰值、调整堆大小或 G1 触发阈值。
  5. 压测验证:对比吞吐、延迟、GC 次数、停顿时间。
  6. 灰度发布:观察真实流量下的指标。

JVM 参数不是越多越专业。供应链系统的关键是把业务峰值、对象生命周期和 JVM 证据连起来,用最少的参数解决明确的问题。

垃圾收集器与GC日志:仓储高峰期如何看懂停顿

GC 日志是 JVM 调优和线上排查的第一手证据。接口变慢时,不能只看业务日志里的耗时,还要判断是否发生了 Stop The World、Full GC、老年代空间不足、晋升失败或大对象分配。供应链系统在大促、仓库波次下发、订单集中履约时对象创建速度很快,GC 日志能帮助我们把问题从“感觉慢”变成可量化的事实。

整体流程图

GC 日志分析流程

常见垃圾收集器

不同 JDK 版本和业务场景会使用不同收集器:

  1. Serial GC:单线程收集,适合小内存客户端程序,不适合高并发服务。
  2. Parallel GC:吞吐优先,适合批处理任务,例如夜间库存重算、历史订单归档。
  3. CMS:低停顿老年代收集器,JDK 9 后被标记废弃,JDK 14 移除。
  4. G1:面向服务端低停顿场景,JDK 9 以后成为默认收集器,适合大多数订单、库存、仓储服务。
  5. ZGC:低停顿收集器,适合大堆和低延迟服务,具体使用要结合 JDK 版本和生产验证。

如果没有特殊原因,现代 Spring Boot 服务通常优先使用 G1。它把堆划分为多个 Region,通过预测停顿时间选择回收集合,目标是在可控停顿下获得稳定吞吐。

仓储高峰期 Demo

假设 WMS 在晚上 8 点下发波次任务,订单服务要把 30 万个待出库订单按仓库、承运商、优先级分组:

1
2
3
4
5
6
7
8
9
10
11
public class WaveDispatchService {
public List<WaveGroup> buildWave(List<OrderLine> lines) {
Map<String, List<OrderLine>> grouped = lines.stream()
.collect(Collectors.groupingBy(line ->
line.getWarehouseCode() + ":" + line.getCarrierCode()));

return grouped.entrySet().stream()
.map(entry -> new WaveGroup(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
}

这段代码业务上清晰,但在高峰期会创建大量临时对象:分组 key、Map 节点、List、Stream 中间对象、WaveGroup。如果堆空间偏小或对象晋升过快,GC 停顿会明显增加。

如何打开 GC 日志

JDK 8 常用参数:

1
2
3
4
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintTenuringDistribution \
-Xloggc:/data/logs/order-service/gc.log

JDK 9 及以后推荐使用统一日志:

1
-Xlog:gc*,safepoint:file=/data/logs/order-service/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

线上服务建议默认开启 GC 日志。日志文件滚动要配置好,否则长时间运行可能撑爆磁盘。

GC 日志重点看什么

分析 GC 日志时,不要只看有没有 GC,要看四类指标:

  1. 频率:Young GC、Mixed GC、Full GC 多久发生一次。
  2. 停顿时间:每次暂停多少毫秒,P95/P99 是否影响接口 SLA。
  3. 回收效果:GC 前后堆、老年代、元空间占用下降多少。
  4. 触发原因:Allocation Failure、Metadata GC Threshold、Humongous Allocation、System.gc() 等。

一段简化后的日志可能类似:

1
2
3
[2023-06-18T20:01:12.345+0800][info][gc] GC(42) Pause Young (Normal) 512M->180M(1024M) 35.7ms
[2023-06-18T20:03:44.210+0800][info][gc] GC(43) Pause Young (Concurrent Start) 760M->420M(1024M) 68.4ms
[2023-06-18T20:03:44.281+0800][info][gc] GC(44) Concurrent Mark Cycle

这说明 JVM 正在进行新生代回收,并启动并发标记。如果后面频繁出现 Full GC,且每次回收后老年代下降不明显,就要怀疑长生命周期对象过多或内存泄漏。

常见判断结论

GC 日志可以帮助形成明确结论:

  1. Young GC 频繁但停顿短:对象创建速度快,可能需要优化批处理对象分配,或者适当增大堆。
  2. Full GC 频繁且回收效果差:老年代长期占满,优先查缓存、静态集合、批量任务和大对象。
  3. 元空间触发 GC:检查动态代理、脚本引擎、热部署和类加载器泄漏。
  4. 大对象触发回收:检查一次性大数组、大 JSON、大 Excel 导出。
  5. 明确出现 System.gc():检查代码、第三方库或运维脚本是否主动触发 Full GC。

优化仓储波次的代码思路

对于前面的分组逻辑,可以从业务和代码两侧降压:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void dispatchWaveByPage(String waveNo) {
long lastId = 0L;
while (true) {
List<OrderLine> page = orderLineRepository.queryPage(waveNo, lastId, 2000);
if (page.isEmpty()) {
break;
}

Map<RouteKey, List<OrderLine>> grouped = groupByRoute(page);
waveRepository.saveGroups(grouped);
lastId = page.get(page.size() - 1).getId();

grouped.clear();
page.clear();
}
}

核心不是手动调用 GC,而是控制对象峰值:分页查询、分批落库、避免超大集合、减少无意义字符串拼接。GC 调优优先解决对象生命周期和分配速率,再调整 JVM 参数。

GC 日志的价值在于把性能问题证据化。对于供应链系统,订单高峰、仓储波次、库存同步都可能制造对象洪峰,只有把日志、业务峰值和代码路径结合起来看,才能得出可靠结论。

GC对象判定与回收算法:订单批处理对象如何被回收

GC 的第一步不是回收,而是判断哪些对象还活着。只有理解对象存活判定、引用类型和基础回收算法,才能解释为什么某些对象明明业务上已经不用了,却仍然无法被回收,也才能写出对批处理和高并发服务更友好的代码。

整体流程图

GC 对象判定与回收算法

对象如何被判定为存活

主流 JVM 使用可达性分析判断对象是否存活。它从一组 GC Roots 出发,沿着引用链向下搜索,能被搜索到的对象就是存活对象,搜索不到的对象就是可回收对象。

常见 GC Roots 包括:

  1. 虚拟机栈中局部变量引用的对象。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. Native 方法引用的对象。
  5. 活跃线程对象、类加载器、同步锁持有对象等。

供应链服务中,一个订单导入任务创建了很多临时对象。如果这些对象只存在于方法局部变量里,任务结束后大概率可以被回收。但如果它们被放入静态集合、长生命周期缓存、线程本地变量中,就可能继续可达,导致内存无法释放。

订单批处理 Demo

下面是一个简化的订单导入任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class OrderImportJob {
private final OrderRepository orderRepository;

public void importOrders(List<OrderRow> rows) {
List<OrderCommand> commands = new ArrayList<>(rows.size());

for (OrderRow row : rows) {
OrderCommand command = new OrderCommand(
row.getOrderNo(),
row.getSkuCode(),
row.getQuantity(),
row.getWarehouseCode()
);
commands.add(command);
}

orderRepository.batchCreate(commands);
}
}

commands 是局部变量。importOrders() 执行结束后,如果 batchCreate() 没有把这些对象保存到全局结构里,这批 OrderCommand 对象就会失去可达路径,后续 GC 可以回收。

下面这种写法就有风险:

1
2
3
4
5
6
7
8
9
10
public class BadOrderImportJob {
private static final List<OrderCommand> HISTORY = new ArrayList<>();

public void importOrders(List<OrderRow> rows) {
for (OrderRow row : rows) {
OrderCommand command = convert(row);
HISTORY.add(command);
}
}
}

HISTORY 是静态字段,属于 GC Roots 可达链的一部分。只要进程不退出,列表里的历史订单命令就一直可达。业务上即使导入完成,内存也不会自动释放。这类问题在导入、导出、报表、对账任务里很常见。

引用类型

Java 引用可以分为四类:

  1. 强引用:最常见,例如 new OrderCommand() 赋值给变量。只要强引用可达,GC 不会回收。
  2. 软引用:内存不足时可能被回收,可用于对内存敏感的缓存,但现代服务更推荐使用成熟缓存组件并设置容量。
  3. 弱引用:下一次 GC 发生时,只要没有强引用就会被回收,常见于 WeakHashMap。
  4. 虚引用:不能通过引用拿到对象,主要用于跟踪对象回收状态。

供应链系统里不要简单依赖软引用实现核心缓存。库存、价格、路由规则这类数据更适合用 Caffeine、Redis、本地缓存加版本号等方式,并明确容量、过期和刷新策略。

基础回收算法

常见 GC 算法有三类:

  1. 标记-清除:先标记可回收对象,再清除它们。问题是会产生内存碎片。
  2. 标记-复制:把存活对象复制到另一块区域,再清理原区域。适合存活对象少的新生代。
  3. 标记-整理:标记后把存活对象向一端移动,减少碎片。适合老年代。

JVM 分代回收的基本依据是弱分代假说:大多数对象朝生夕死,少数对象会长期存活。供应链接口里的请求 DTO、计算中间对象、临时报表行通常很快死亡,适合在新生代快速回收;缓存、连接池、规则表、单例服务通常会长期存活,最终进入老年代。

让 GC 更轻松的编码方式

GC 可以自动回收内存,但不能替开发者修正错误的引用关系。实践中要注意:

  1. 批处理按批次提交并释放引用,不要把全量数据放进一个大集合。
  2. 静态集合只放真正需要全局共享的数据,并设置上限。
  3. ThreadLocal 使用后及时 remove(),尤其在线程池场景。
  4. 缓存必须有容量和过期策略。
  5. 大对象和大数组要谨慎,避免频繁进入老年代。

一个更稳妥的导入方式是分片处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void importOrders(Stream<OrderRow> rowStream) {
List<OrderCommand> batch = new ArrayList<>(1000);
rowStream.forEach(row -> {
batch.add(convert(row));
if (batch.size() == 1000) {
orderRepository.batchCreate(batch);
batch.clear();
}
});
if (!batch.isEmpty()) {
orderRepository.batchCreate(batch);
batch.clear();
}
}

这段代码把峰值对象数量限制在一个批次内,既降低堆内存压力,也减少 GC 扫描和复制的成本。对于订单导入、库存同步、仓储流水归档,这类批处理方式通常比一次性全量加载更稳定。

Java内存结构:库存服务里的堆栈元空间

Java 内存结构是理解 JVM 问题的基础。线上服务出现 OOM、线程数过高、接口偶发慢、GC 频繁时,如果分不清堆、栈、元空间和直接内存,就很难判断问题属于对象太多、线程太多、类太多,还是 Netty 这类框架使用的堆外内存太多。

整体流程图

Java 内存结构流程

JVM 运行时数据区

JVM 运行时数据区可以从线程共享和线程私有两个角度理解:

  1. 程序计数器:线程私有,记录当前线程执行到哪条字节码指令。
  2. Java 虚拟机栈:线程私有,每次方法调用都会创建栈帧,保存局部变量表、操作数栈、返回地址等。
  3. 本地方法栈:线程私有,为 Native 方法服务。
  4. Java 堆:线程共享,绝大多数对象实例和数组都在这里分配,也是 GC 管理的重点区域。
  5. 方法区:线程共享,在 HotSpot 里 JDK 8 之后主要由元空间承载,保存类元数据、常量、方法信息等。
  6. 直接内存:不属于 JVM 运行时数据区规范的一部分,但大量框架会使用,例如 NIO、Netty、文件传输。

供应链服务里,订单对象、库存快照、分页结果、DTO 通常进入堆;每个请求线程都有自己的虚拟机栈;大量动态代理类、反射元数据会占用元空间;网关、消息队列客户端、RPC 框架可能使用直接内存。

库存查询 Demo

下面用一个库存查询例子说明堆和栈的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class InventoryQueryService {
private final InventoryRepository repository;

public InventoryQueryService(InventoryRepository repository) {
this.repository = repository;
}

public InventoryView query(String skuCode, String warehouseCode) {
InventoryRecord record = repository.find(skuCode, warehouseCode);
int available = record.getOnHand() - record.getLocked();
return new InventoryView(skuCode, warehouseCode, available);
}
}

当线程执行 query() 时:

  1. skuCode、warehouseCode、record、available 这些局部变量引用或基本类型值保存在当前线程的栈帧里。
  2. InventoryRecord 和 InventoryView 对象通常分配在堆上。
  3. InventoryQueryService.class、方法元数据、常量池等类信息在元空间里。
  4. 请求结束后,栈帧弹出,局部变量消失;如果返回对象不再被其他地方引用,后续 GC 可以回收它。

栈上的变量生命周期通常很短,堆上的对象生命周期由引用关系决定。理解这一点,才能解释为什么一个局部变量引用的大对象在方法结束后可以被回收,而放入静态集合或缓存后就可能长期占用内存。

对象在堆里的结构

HotSpot 中一个普通对象通常由三部分组成:

  1. 对象头:保存 Mark Word、类型指针等信息。
  2. 实例数据:业务字段,例如 skuCode、warehouseCode、availableQty。
  3. 对齐填充:为了满足内存对齐要求。

以库存对象为例:

1
2
3
4
5
6
7
8
9
10
11
public class InventoryView {
private String skuCode;
private String warehouseCode;
private int availableQty;

public InventoryView(String skuCode, String warehouseCode, int availableQty) {
this.skuCode = skuCode;
this.warehouseCode = warehouseCode;
this.availableQty = availableQty;
}
}

如果一次批量查询返回 100 万条库存视图,不只是 100 万个 InventoryView 对象占内存,里面引用的 String、字符数组、集合容器也会占内存。线上估算内存时不能只看字段数量,还要看对象图。

常见内存异常

不同内存区域对应不同异常:

1
2
3
4
5
6
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: GC overhead limit exceeded
java.lang.OutOfMemoryError: Metaspace
java.lang.OutOfMemoryError: Direct buffer memory
java.lang.StackOverflowError
java.lang.OutOfMemoryError: unable to create native thread

供应链项目里的典型触发原因:

  1. Java heap space:一次导出全量订单、库存快照缓存无限增长、消息堆积后一次性反序列化。
  2. Metaspace:动态生成类过多,或者热部署环境 ClassLoader 泄漏。
  3. Direct buffer memory:Netty、NIO、大文件传输使用堆外内存过多。
  4. StackOverflowError:递归解析 BOM 物料树没有终止条件。
  5. unable to create native thread:线程池无界增长,或者容器线程数限制太低。

业务实现建议

供应链系统经常面对大批量数据,内存结构设计要服务于吞吐和稳定性:

  1. 分页处理订单、库存、出入库流水,不要一次性加载全量数据。
  2. 缓存要设置容量、过期时间和淘汰策略,避免静态 Map 无限增长。
  3. 大文件导入导出使用流式处理,避免整个文件读入堆内存。
  4. 线程池大小要受控,因为每个线程都会消耗栈内存和操作系统资源。
  5. 使用 Netty 或 NIO 时,要同时监控堆内存和直接内存。

Java 内存结构的学习目标,是能把业务对象、线程、类元数据、IO 缓冲区分别映射到对应内存区域,并据此判断问题的根因和优化方向。