从 JVM 内存模型到 GC 调优:Java 服务性能优化实战

摘要

Java 服务运行一段时间后出现响应变慢、频繁 Full GC、内存持续上涨甚至 OOM,并不一定是"堆内存太小"。问题可能来自对象分配过快、缓存没有上限、线程栈过多、元空间增长、直接内存耗尽、锁竞争或垃圾回收器参数不匹配。

要解决 JVM 性能问题,首先需要建立正确的内存模型。本文会区分 JVM 运行时数据区和 Java 内存模型,介绍堆、虚拟机栈、程序计数器、元空间和直接内存的作用,然后解释年轻代、老年代、对象晋升、垃圾回收算法以及常用收集器的工作方式。

在此基础上,文章通过 Spring Boot 服务示例、JVM 启动参数、GC 日志和监控指标,展示如何定位内存问题、分析 GC 停顿、设置合理的堆大小,并给出一套从现象观察、数据采集到参数调整的性能优化流程。

读完本文后,你应该能够:

  • 区分 JVM 内存区域与 Java 内存模型;
  • 理解对象创建、存活、晋升和回收过程;
  • 判断堆、栈、元空间和直接内存问题;
  • 读懂常见 GC 日志和关键指标;
  • 了解 G1、ZGC 等收集器的适用方向;
  • 设计合理的 JVM 启动参数;
  • 使用 jmap、jcmd、jstat 和 JFR 进行排查;
  • 避免只靠增加堆内存掩盖真实问题。

一、背景与问题

1. Java 服务为什么会越跑越慢

一个 Java 服务刚启动时通常运行正常,但经过一段时间后可能出现:

  • 接口 P99 延迟逐渐升高;
  • Young GC 频率越来越高;
  • Full GC 偶尔出现长时间停顿;
  • 堆内存回收后仍然保持高位;
  • 老年代不断增长;
  • CPU 使用率突然升高;
  • 线程数量持续增加;
  • 最终出现 OutOfMemoryError。

这些现象可能互相关联:

text 复制代码
请求量增加
  -> 短命对象创建速度变快
  -> Young GC 频率提高
  -> 部分对象存活并晋升老年代
  -> 老年代回收压力增加
  -> GC 停顿和 CPU 消耗上升
  -> 请求处理变慢
  -> 请求堆积并创建更多对象
  -> 内存压力继续扩大

但也可能是完全不同的问题:

text 复制代码
堆使用正常
元空间持续增长
  -> 动态生成类过多
  -> 元空间耗尽

或者:

text 复制代码
Java 堆正常
直接内存持续增长
  -> NIO Buffer 未及时释放
  -> 进程内存超过容器限制

因此,JVM 调优不能只盯着堆使用率。

2. JVM 内存模型容易被混淆

"JVM 内存模型"在实际讨论中经常有两种含义:

JVM 运行时数据区

它描述 JVM 进程在运行 Java 程序时有哪些内存区域:

  • 堆;
  • 虚拟机栈;
  • 本地方法栈;
  • 程序计数器;
  • 方法区或元空间;
  • 运行时常量池。
Java Memory Model

Java 内存模型,也就是 JMM,主要描述多线程环境下:

  • 线程如何读写共享变量;
  • 可见性;
  • 原子性;
  • 有序性;
  • happens-before 关系;
  • volatile、synchronized 和 final 的内存语义。

两者不是同一个概念。本文重点讨论 JVM 运行时数据区和垃圾回收,但也会用一节说明 JMM 与性能问题的关系。

3. 性能问题必须先测量

JVM 调优最常见的错误是直接修改参数:

text 复制代码
内存不够
  -> 把 -Xmx 调大

GC 很频繁
  -> 把年轻代调大

接口变慢
  -> 把线程数调大

这种做法有时能够暂时缓解问题,但可能导致:

  • 容器内存超限;
  • GC 停顿更长;
  • 问题被延后;
  • 真实内存泄漏被掩盖;
  • 系统在高峰期更难恢复。

更可靠的流程是:

text 复制代码
现象
  -> 指标
  -> 日志
  -> 线程和堆分析
  -> 找到主要瓶颈
  -> 小幅调整
  -> 压测和对比
  -> 灰度发布

4. 性能优化的目标

JVM 调优不是让某个数字越小越好,而是要在以下目标之间找到平衡:

  • 吞吐量;
  • 响应时间;
  • 最大停顿时间;
  • 内存使用;
  • CPU 使用;
  • 稳定性;
  • 启动速度;
  • 运维复杂度。

例如,批处理服务更关注吞吐量和总执行时间,在线交易服务更关注 P99 延迟和暂停时间,数据分析服务可能更愿意使用更大的堆换取更高吞吐。

二、核心概念

1. JVM 运行时数据区

JVM 运行时数据区可以抽象为:
#mermaid-svg-FE4GfAAZFV96vCEQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FE4GfAAZFV96vCEQ .error-icon{fill:#552222;}#mermaid-svg-FE4GfAAZFV96vCEQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FE4GfAAZFV96vCEQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FE4GfAAZFV96vCEQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FE4GfAAZFV96vCEQ .marker.cross{stroke:#333333;}#mermaid-svg-FE4GfAAZFV96vCEQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FE4GfAAZFV96vCEQ p{margin:0;}#mermaid-svg-FE4GfAAZFV96vCEQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FE4GfAAZFV96vCEQ .cluster-label text{fill:#333;}#mermaid-svg-FE4GfAAZFV96vCEQ .cluster-label span{color:#333;}#mermaid-svg-FE4GfAAZFV96vCEQ .cluster-label span p{background-color:transparent;}#mermaid-svg-FE4GfAAZFV96vCEQ .label text,#mermaid-svg-FE4GfAAZFV96vCEQ span{fill:#333;color:#333;}#mermaid-svg-FE4GfAAZFV96vCEQ .node rect,#mermaid-svg-FE4GfAAZFV96vCEQ .node circle,#mermaid-svg-FE4GfAAZFV96vCEQ .node ellipse,#mermaid-svg-FE4GfAAZFV96vCEQ .node polygon,#mermaid-svg-FE4GfAAZFV96vCEQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FE4GfAAZFV96vCEQ .rough-node .label text,#mermaid-svg-FE4GfAAZFV96vCEQ .node .label text,#mermaid-svg-FE4GfAAZFV96vCEQ .image-shape .label,#mermaid-svg-FE4GfAAZFV96vCEQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-FE4GfAAZFV96vCEQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FE4GfAAZFV96vCEQ .rough-node .label,#mermaid-svg-FE4GfAAZFV96vCEQ .node .label,#mermaid-svg-FE4GfAAZFV96vCEQ .image-shape .label,#mermaid-svg-FE4GfAAZFV96vCEQ .icon-shape .label{text-align:center;}#mermaid-svg-FE4GfAAZFV96vCEQ .node.clickable{cursor:pointer;}#mermaid-svg-FE4GfAAZFV96vCEQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FE4GfAAZFV96vCEQ .arrowheadPath{fill:#333333;}#mermaid-svg-FE4GfAAZFV96vCEQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FE4GfAAZFV96vCEQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FE4GfAAZFV96vCEQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FE4GfAAZFV96vCEQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FE4GfAAZFV96vCEQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FE4GfAAZFV96vCEQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FE4GfAAZFV96vCEQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FE4GfAAZFV96vCEQ .cluster text{fill:#333;}#mermaid-svg-FE4GfAAZFV96vCEQ .cluster span{color:#333;}#mermaid-svg-FE4GfAAZFV96vCEQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FE4GfAAZFV96vCEQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FE4GfAAZFV96vCEQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-FE4GfAAZFV96vCEQ .icon-shape,#mermaid-svg-FE4GfAAZFV96vCEQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FE4GfAAZFV96vCEQ .icon-shape p,#mermaid-svg-FE4GfAAZFV96vCEQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FE4GfAAZFV96vCEQ .icon-shape .label rect,#mermaid-svg-FE4GfAAZFV96vCEQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FE4GfAAZFV96vCEQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FE4GfAAZFV96vCEQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FE4GfAAZFV96vCEQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JVM 进程
程序计数器
虚拟机栈
本地方法栈

元空间
直接内存

不同区域的生命周期和错误类型不同。

区域 线程关系 主要内容 常见问题
程序计数器 线程私有 当前字节码指令位置 通常不会成为主要瓶颈
虚拟机栈 线程私有 栈帧、局部变量、操作数栈 StackOverflowError、线程过多
本地方法栈 线程私有 Native 方法调用状态 本地调用异常
线程共享 Java 对象实例和数组 GC 压力、堆 OOM
元空间 进程共享 类元数据、常量、方法信息 Metaspace OOM
直接内存 进程级资源 堆外缓冲区 Direct buffer memory、容器 OOM

2. 堆内存

堆是大多数 Java 对象分配的主要区域。对象通过 new 创建后,通常会在堆中分配:

java 复制代码
Order order = new Order();
List<OrderItem> items = new ArrayList<>();
byte[] payload = new byte[1024 * 1024];

堆的特点:

  • 由多个线程共享;
  • 受垃圾回收器管理;
  • 可以配置初始和最大大小;
  • 包含对象和数组;
  • 是最常见的内存问题来源。

堆大小由以下参数控制:

text 复制代码
-Xms:初始堆大小
-Xmx:最大堆大小

例如:

bash 复制代码
java -Xms2g -Xmx2g -jar app.jar

将 Xms 和 Xmx 设置为相同值,可以减少运行期间堆扩缩容带来的波动,但不代表一定更优。容器环境仍然需要为非堆内存、线程栈、直接内存和本地库保留空间。

3. 新生代和老年代

传统分代垃圾回收器通常把堆划分为年轻代和老年代。

年轻代中又常见:

  • Eden;
  • Survivor From;
  • Survivor To。

对象分配过程可以简化为:

text 复制代码
新对象
  -> Eden
  -> Young GC
  -> Survivor 区复制
  -> 达到年龄阈值或 Survivor 放不下
  -> 老年代

为什么要分代?因为许多对象具有"朝生夕死"的特点:

java 复制代码
public Response handle(Request request) {
    Map<String, Object> temporary =
        buildTemporaryData(request);

    return createResponse(temporary);
}

temporary 只在一次请求中使用,很快就没有引用。把这类对象集中在年轻代,可以通过较低成本的复制和清理回收。

老年代通常保存存活时间更长的对象,例如:

  • 长期缓存;
  • 会话对象;
  • Spring 单例;
  • 连接池;
  • 配置对象;
  • 长生命周期集合。

4. 对象可达性

垃圾回收器不是简单地判断对象是否"被使用过",而是从 GC Roots 出发判断对象是否仍然可达。

常见 GC Roots 包括:

  • 活跃线程栈中的引用;
  • 静态字段;
  • JNI 引用;
  • 已加载类相关引用;
  • 同步锁持有的对象。

对象引用关系可以表示为:

text 复制代码
GC Root
  -> ApplicationContext
  -> CacheManager
  -> HashMap
  -> UserSession

如果缓存 Map 一直被全局对象引用,Map 中的对象就可能一直存活,即使业务已经不再需要它们。

内存泄漏在 Java 中通常不是"忘记 free",而是"对象仍然被某个不该持有它的引用链持有"。

5. 虚拟机栈和栈帧

每个 Java 线程都有自己的虚拟机栈。方法调用时,会创建一个栈帧,包含:

  • 局部变量表;
  • 操作数栈;
  • 动态链接;
  • 方法返回信息。

例如:

java 复制代码
public int calculate(int a, int b) {
    int result = a + b;
    return result;
}

方法执行期间,a、b 和 result 等局部变量位于当前栈帧中。对象本身通常在堆中,局部变量保存的是对象引用。

递归过深可能导致:

text 复制代码
java.lang.StackOverflowError

线程数量过多也会消耗大量栈内存。栈大小可以通过:

bash 复制代码
-Xss512k

进行调整,但不能简单地把 Xss 设置得非常小。栈太小可能导致正常调用链无法执行,尤其是框架层级深、递归或复杂代理调用场景。

6. 元空间

JDK 8 之后,类元数据主要存放在元空间,替代了永久代。

元空间可能包含:

  • 类结构;
  • 方法元数据;
  • 字段信息;
  • 注解信息;
  • 常量信息;
  • JIT 编译相关数据的一部分。

常见参数:

bash 复制代码
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

元空间增长的常见原因:

  • 动态生成大量代理类;
  • 类加载器没有释放;
  • 热部署或脚本引擎反复加载类;
  • 插件系统类加载器泄漏;
  • 大量不同的 Lambda 或字节码增强;
  • 依赖库生成类过多。

元空间问题不能通过增加 Java 堆来解决。

7. 直接内存

直接内存位于 Java 堆之外,常见于 NIO、Netty、压缩库和部分序列化组件。

例如:

java 复制代码
ByteBuffer buffer =
    ByteBuffer.allocateDirect(1024 * 1024);

直接内存的优势是减少某些 IO 场景中的数据复制,但它由进程整体内存承担,不等同于 -Xmx。

可以设置上限:

bash 复制代码
-XX:MaxDirectMemorySize=512m

容器环境下,进程总内存大致包括:

text 复制代码
进程总内存
  = Java 堆
  + 元空间
  + 线程栈
  + 直接内存
  + JIT Code Cache
  + GC 辅助结构
  + 本地库和其他 Native 内存

因此,如果容器内存限制为 2 GB,不能把 Xmx 也设置成 2 GB。

8. Java 内存模型 JMM

JMM 关注的是多线程之间的内存可见性和指令重排序,不是 JVM 堆的空间划分。

例如:

java 复制代码
class FlagHolder {
    private boolean ready;
    private int value;

    public void write() {
        value = 42;
        ready = true;
    }

    public void read() {
        if (ready) {
            System.out.println(value);
        }
    }
}

没有同步措施时,一个线程可能看不到另一个线程对 ready 或 value 的更新,也可能观察到重排序带来的非预期结果。

使用 volatile:

java 复制代码
private volatile boolean ready;

可以保证对 ready 的写入对其他线程可见,并建立相关的 happens-before 关系。

使用 synchronized、Lock 或并发容器,也可以建立内存可见性和互斥关系。

JMM 问题通常表现为:

  • 数据不一致;
  • 状态更新延迟;
  • 线程偶发读到旧值;
  • 自旋不退出;
  • 并发条件下结果错误。

这类问题不能通过调整 GC 参数解决。

三、工作原理

1. 对象分配过程

一个简化的对象分配流程:
#mermaid-svg-30eucHZYaEfzHhHE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-30eucHZYaEfzHhHE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-30eucHZYaEfzHhHE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-30eucHZYaEfzHhHE .error-icon{fill:#552222;}#mermaid-svg-30eucHZYaEfzHhHE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-30eucHZYaEfzHhHE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-30eucHZYaEfzHhHE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-30eucHZYaEfzHhHE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-30eucHZYaEfzHhHE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-30eucHZYaEfzHhHE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-30eucHZYaEfzHhHE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-30eucHZYaEfzHhHE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-30eucHZYaEfzHhHE .marker.cross{stroke:#333333;}#mermaid-svg-30eucHZYaEfzHhHE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-30eucHZYaEfzHhHE p{margin:0;}#mermaid-svg-30eucHZYaEfzHhHE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-30eucHZYaEfzHhHE .cluster-label text{fill:#333;}#mermaid-svg-30eucHZYaEfzHhHE .cluster-label span{color:#333;}#mermaid-svg-30eucHZYaEfzHhHE .cluster-label span p{background-color:transparent;}#mermaid-svg-30eucHZYaEfzHhHE .label text,#mermaid-svg-30eucHZYaEfzHhHE span{fill:#333;color:#333;}#mermaid-svg-30eucHZYaEfzHhHE .node rect,#mermaid-svg-30eucHZYaEfzHhHE .node circle,#mermaid-svg-30eucHZYaEfzHhHE .node ellipse,#mermaid-svg-30eucHZYaEfzHhHE .node polygon,#mermaid-svg-30eucHZYaEfzHhHE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-30eucHZYaEfzHhHE .rough-node .label text,#mermaid-svg-30eucHZYaEfzHhHE .node .label text,#mermaid-svg-30eucHZYaEfzHhHE .image-shape .label,#mermaid-svg-30eucHZYaEfzHhHE .icon-shape .label{text-anchor:middle;}#mermaid-svg-30eucHZYaEfzHhHE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-30eucHZYaEfzHhHE .rough-node .label,#mermaid-svg-30eucHZYaEfzHhHE .node .label,#mermaid-svg-30eucHZYaEfzHhHE .image-shape .label,#mermaid-svg-30eucHZYaEfzHhHE .icon-shape .label{text-align:center;}#mermaid-svg-30eucHZYaEfzHhHE .node.clickable{cursor:pointer;}#mermaid-svg-30eucHZYaEfzHhHE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-30eucHZYaEfzHhHE .arrowheadPath{fill:#333333;}#mermaid-svg-30eucHZYaEfzHhHE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-30eucHZYaEfzHhHE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-30eucHZYaEfzHhHE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-30eucHZYaEfzHhHE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-30eucHZYaEfzHhHE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-30eucHZYaEfzHhHE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-30eucHZYaEfzHhHE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-30eucHZYaEfzHhHE .cluster text{fill:#333;}#mermaid-svg-30eucHZYaEfzHhHE .cluster span{color:#333;}#mermaid-svg-30eucHZYaEfzHhHE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-30eucHZYaEfzHhHE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-30eucHZYaEfzHhHE rect.text{fill:none;stroke-width:0;}#mermaid-svg-30eucHZYaEfzHhHE .icon-shape,#mermaid-svg-30eucHZYaEfzHhHE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-30eucHZYaEfzHhHE .icon-shape p,#mermaid-svg-30eucHZYaEfzHhHE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-30eucHZYaEfzHhHE .icon-shape .label rect,#mermaid-svg-30eucHZYaEfzHhHE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-30eucHZYaEfzHhHE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-30eucHZYaEfzHhHE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-30eucHZYaEfzHhHE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是





创建对象
线程本地分配缓冲区有空间
快速分配
Eden 是否有空间
分配到 Eden
触发 Young GC
回收后是否有空间
尝试老年代分配或扩容

实际 JVM 会使用线程本地分配缓冲区 TLAB,减少多个线程同时分配对象时的锁竞争。

可以通过日志观察对象分配和 GC 行为,但不建议一开始就修改 TLAB 参数。大多数应用首先应该解决对象创建过多、临时集合过大或缓存无界等问题。

2. Young GC

Young GC 主要回收年轻代对象。典型过程:

text 复制代码
Eden 满
  -> 标记存活对象
  -> 将存活对象复制到 Survivor
  -> 清空 Eden
  -> Survivor 区交换角色
  -> 对象年龄增加

如果 Survivor 空间不足,部分对象可能提前晋升到老年代。过早晋升可能加重老年代压力。

Young GC 通常停顿时间较短,但频率过高会消耗 CPU 和影响尾延迟。

3. 老年代回收

老年代回收通常成本更高,因为存活对象比例可能较高。传统收集器可能需要:

  • 标记存活对象;
  • 清理不可达对象;
  • 压缩内存;
  • 修复对象引用;
  • 暂停应用线程。

当老年代空间不足或并发回收跟不上分配速度时,可能触发 Full GC 或类似的长停顿过程。

4. 垃圾回收算法

标记-清除

先标记存活对象,再清除垃圾对象。

优点是实现简单,缺点是容易产生内存碎片。

复制算法

把存活对象复制到另一块区域,然后整体清空原区域。

优点是回收后空间连续,缺点是需要额外的复制空间,适合存活率较低的年轻代。

标记-整理

标记存活对象后,将对象移动到一端,清理边界之外的空间。

优点是减少碎片,缺点是对象移动成本较高。

分代回收

根据对象生命周期差异,把年轻对象和老对象分开处理。现实中的收集器通常会组合多种算法。

5. Stop-The-World

垃圾回收过程中,某些阶段需要暂停应用线程,这就是 Stop-The-World,简称 STW。

STW 并不一定意味着整个 GC 都是单线程执行,而是应用线程暂时停止,GC 线程进行必要工作。

停顿时间受以下因素影响:

  • 堆大小;
  • 存活对象数量;
  • 根扫描范围;
  • 卡表和记忆集;
  • 并发标记速度;
  • CPU 资源;
  • 对象晋升速度;
  • GC 算法和收集器。

在线服务通常更关注 P99 延迟,因此要关注最大停顿和尾延迟,而不只是平均 GC 时间。

6. G1 收集器

G1 将堆划分为多个 Region,不再严格按连续的年轻代和老年代物理区域组织,而是根据对象年龄和回收策略动态选择 Region。

G1 的基本流程包括:

text 复制代码
年轻代回收
  -> 并发标记
  -> 识别垃圾较多的 Region
  -> 优先回收收益较高的 Region
  -> 在停顿目标内完成回收

G1 的特点:

  • 面向大堆;
  • 支持可预测的停顿目标;
  • 通过 Region 管理堆;
  • 具备并发标记阶段;
  • 需要维护记忆集;
  • 仍可能出现 Full GC 或退化情况。

常见参数示例:

bash 复制代码
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45

MaxGCPauseMillis 是目标,不是严格保证。设置得过于激进,可能导致收集器更频繁工作,影响吞吐量。

7. ZGC 收集器

ZGC 面向低停顿目标,通过并发处理和着色指针等机制,将大部分工作与应用线程并发执行。

它的特点:

  • 停顿时间目标较低;
  • 适合较大堆和低延迟场景;
  • 对 JDK 版本和运行环境有要求;
  • 仍然需要足够 CPU 并发;
  • 低停顿不代表没有吞吐量成本。

选择 ZGC 时需要验证:

  • 当前 JDK 版本支持情况;
  • 应用是否适合并发 GC;
  • CPU 是否有余量;
  • 堆外内存是否受控;
  • 压测下 P99 是否改善;
  • GC 日志和监控是否完善。

8. GC 日志

现代 JVM 可以使用统一日志参数:

bash 复制代码
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

它可以记录:

  • GC 类型;
  • GC 开始和结束时间;
  • 暂停时长;
  • 回收前后堆使用;
  • 老年代变化;
  • Safepoint;
  • 并发阶段;
  • 失败或退化信息。

GC 日志需要和业务指标结合起来看:

text 复制代码
接口 P99 升高
  -> 对比 GC pause 时间
  -> 对比线程池队列
  -> 对比数据库响应时间
  -> 判断是否为 GC 主因

不能看到一次长 GC 就断定所有慢请求都由 GC 导致。

四、实战示例

1. Spring Boot 服务的基础启动参数

一个在线服务可以从如下参数开始:

bash 复制代码
java \
  -Xms2g \
  -Xmx2g \
  -Xss512k \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:MaxMetaspaceSize=512m \
  -XX:MaxDirectMemorySize=512m \
  -Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M \
  -jar app.jar

这些参数不是通用最佳值,需要根据:

  • 容器内存;
  • CPU 核数;
  • 请求量;
  • 对象分配率;
  • 延迟目标;
  • 数据库和下游资源;
  • 压测结果;

进行调整。

2. 容器内存的估算

假设容器内存限制为 4 GB,可以预留:

text 复制代码
Java 堆:2.5 GB
元空间:256 MB
直接内存:512 MB
线程栈:300 MB
JIT、GC 和 Native:400 MB
系统余量:约 100 MB

实际配置可以是:

bash 复制代码
-Xmx2560m
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m
-Xss512k

线程栈预算与线程数相关:

text 复制代码
线程栈总量
  ≈ 线程数量 × Xss

如果服务有 500 个线程,Xss 设置为 1 MB,仅栈空间理论上就可能需要约 500 MB,还没有计入线程对象和 Native 开销。

3. 使用 jcmd 查看 JVM 信息

查看 Java 进程:

bash 复制代码
jcmd

查看进程参数:

bash 复制代码
jcmd <pid> VM.command_line
jcmd <pid> VM.flags

查看堆信息:

bash 复制代码
jcmd <pid> GC.heap_info

查看类统计:

bash 复制代码
jcmd <pid> GC.class_histogram

查看线程:

bash 复制代码
jcmd <pid> Thread.print

在生产环境执行诊断命令需要考虑:

  • 命令耗时;
  • 输出大小;
  • 对应用的暂停影响;
  • 权限;
  • 容器 PID;
  • 是否需要在低峰期执行。

4. 使用 jstat 观察 GC

查看 GC 统计:

bash 复制代码
jstat -gcutil <pid> 1000 20

常见字段包括:

  • S0:Survivor 区使用率;
  • S1:Survivor 区使用率;
  • E:Eden 使用率;
  • O:老年代使用率;
  • M:元空间使用率;
  • CCS:压缩类空间使用率;
  • YGC:Young GC 次数;
  • YGCT:Young GC 总耗时;
  • FGC:Full GC 次数;
  • FGCT:Full GC 总耗时;
  • GCT:GC 总耗时。

例如:

text 复制代码
 S0    S1      E       O      M     YGC  YGCT  FGC  FGCT   GCT
 0.00  72.40  85.00  68.20  84.30   120  2.1    1   0.8    2.9

如果 E 快速增长、YGC 频繁但 O 稳定,可能是短命对象分配较多;如果 O 持续增长且 Full GC 后也不明显下降,需要重点检查长生命周期引用和缓存。

5. 使用堆转储分析内存泄漏

生成堆转储:

bash 复制代码
jcmd <pid> GC.heap_dump /tmp/app-heap.hprof

或者:

bash 复制代码
jmap -dump:live,format=b,file=/tmp/app-heap.hprof <pid>

堆转储可能很大,生成过程也可能影响服务。分析时重点关注:

  • 实例数量最多的类;
  • 占用浅堆和深堆最大的对象;
  • 大集合;
  • Map 中的 Key 和 Value;
  • ThreadLocal;
  • 静态集合;
  • 类加载器;
  • 缓存对象;
  • 请求上下文和会话对象。

常见泄漏链:

text 复制代码
静态 Map
  -> 用户 ID
  -> Session
  -> 大量业务对象

或者:

text 复制代码
ThreadLocal
  -> 请求上下文
  -> 大对象
  -> 线程长期存活

ThreadLocal 使用完后要及时清理:

java 复制代码
private static final ThreadLocal<RequestContext>
    CONTEXT = new ThreadLocal<>();

public void handle(Request request) {
    try {
        CONTEXT.set(buildContext(request));
        process(request);
    } finally {
        CONTEXT.remove();
    }
}

6. 排查无界缓存

错误示例:

java 复制代码
private final Map<String, UserProfile> cache =
    new ConcurrentHashMap<>();

public void put(String userId, UserProfile profile) {
    cache.put(userId, profile);
}

如果用户数量不断增长,cache 也会不断增长。应使用有容量和过期策略的缓存:

java 复制代码
Cache<String, UserProfile> cache =
    Caffeine.newBuilder()
        .maximumSize(100_000)
        .expireAfterAccess(Duration.ofMinutes(30))
        .recordStats()
        .build();

缓存设计还要考虑:

  • 单个对象大小;
  • Key 数量;
  • 过期时间;
  • 淘汰策略;
  • 热点数据;
  • 重建并发;
  • 失效风暴;
  • 监控命中率。

7. 排查大对象分配

大对象会快速占用 Eden,甚至直接进入老年代,常见于:

  • 大 JSON;
  • 文件内容;
  • 批量查询结果;
  • 大数组;
  • 图片和压缩包;
  • 全量导出;
  • 一次性读取整张表。

错误方式:

java 复制代码
List<Order> allOrders =
    orderRepository.findAllByDate(date);

String json = objectMapper.writeValueAsString(
    allOrders
);

改进方向:

  • 分页查询;
  • 流式读取;
  • 分批序列化;
  • 限制返回数量;
  • 使用异步导出;
  • 通过文件或对象存储中转;
  • 避免在内存中同时保留多份副本。

8. 通过 GC 日志判断问题

假设日志表现为:

text 复制代码
Young GC 每秒多次
每次暂停几十毫秒
老年代长期保持 20% 到 30%
接口 P99 逐渐升高

可能说明对象分配速率过高,需要检查:

  • JSON 序列化;
  • 临时集合;
  • 字符串拼接;
  • 日志格式化;
  • ORM 查询对象;
  • 大量短生命周期 DTO;
  • 缓存反序列化。

另一种表现:

text 复制代码
Young GC 正常
老年代持续上涨
Full GC 后下降很少

可能说明:

  • 有内存泄漏;
  • 缓存没有过期;
  • 静态集合持有对象;
  • ThreadLocal 未清理;
  • 监听器或回调没有注销;
  • 类加载器无法回收。

还有一种表现:

text 复制代码
GC 次数不多
进程 RSS 持续增长
堆使用正常

需要重点排查:

  • 直接内存;
  • Native 内存;
  • 线程数量;
  • 元空间;
  • JNI 或第三方本地库;
  • 内存映射文件。

9. 线程和内存联合分析

查看线程数量:

bash 复制代码
jcmd <pid> Thread.print | findstr /C:"tid="

或者在 Linux 中:

bash 复制代码
ps -eLf | grep java

需要关注:

  • 线程总数;
  • 线程池线程数;
  • BLOCKED 状态;
  • WAITING 状态;
  • 线程栈是否重复;
  • 是否存在大量定时任务;
  • 是否有线程泄漏。

线程数量增长可能引起:

text 复制代码
线程栈增加
  -> Native 内存增加
  -> CPU 上下文切换增加
  -> GC 线程与业务线程竞争
  -> 响应时间上升

10. JFR 采样

Java Flight Recorder 可以用于采集:

  • CPU 热点;
  • 方法执行;
  • 锁竞争;
  • IO;
  • GC;
  • 分配;
  • 线程;
  • 类加载;
  • 网络事件。

启动一个短时间记录:

bash 复制代码
jcmd <pid> JFR.start \
  name=profile \
  settings=profile \
  duration=60s \
  filename=/tmp/app-profile.jfr

记录完成后,可以使用 JDK Mission Control 分析。JFR 的价值在于同时观察 CPU、内存、锁、线程和 GC,而不是只看单一指标。

五、常见问题与实践建议

1. 把 Xmx 设置得越大越好吗

不是。堆太小会频繁 GC,堆太大则可能:

  • 单次回收停顿变长;
  • 垃圾更多;
  • 容器更容易超限;
  • 内存泄漏暴露更晚;
  • GC 线程消耗更多 CPU;
  • 应用启动和预热更慢。

合理大小应该根据:

  • 存活对象集合;
  • 对象分配速率;
  • 延迟目标;
  • GC 收集器;
  • 容器内存;
  • 峰值流量;
  • 压测结果;

确定。

2. Full GC 一定是严重问题吗

偶发 Full GC 不一定代表故障,需要结合:

  • 发生频率;
  • 停顿时间;
  • 回收前后老年代;
  • 是否影响 P99;
  • 是否伴随分配失败;
  • 是否持续增长。

如果 Full GC 偶发且很快、业务无感,可能不需要立即调整。但如果频繁发生、回收后占用仍然很高,就必须定位根因。

3. GC 频繁是不是年轻代太小

可能是,但不能直接下结论。还可能是:

  • 请求对象分配速率过高;
  • 大量临时对象;
  • 批量任务;
  • 堆总量太小;
  • 业务流量突然增加;
  • 对象晋升速度过快;
  • 收集器并发线程不足。

调整年轻代前,应先查看:

  • 每秒分配速率;
  • Young GC 频率;
  • 每次回收前后使用量;
  • Survivor 使用情况;
  • 老年代晋升速率;
  • GC 停顿比例。

4. 为什么堆回收后使用率仍然很高

可能有三种情况:

正常存活对象较多

缓存、单例、连接池和业务状态本来就应该长期存活。

内存泄漏

对象不再需要,但仍然被引用链持有。

回收器尚未完成整理

并发收集器的某些阶段不会立即将所有空间压缩到最低。

需要通过堆转储、类直方图和引用链确认,不能只看一张内存曲线。

5. 内存泄漏和内存溢出的区别

内存溢出是结果,内存泄漏是可能的原因。

内存泄漏:

text 复制代码
对象已经不需要
但仍然被引用
无法被 GC 回收
占用持续增长

内存溢出:

text 复制代码
申请内存失败
触发 OutOfMemoryError

内存溢出也可能是正常高峰、堆配置过小、大对象请求、直接内存限制或线程数量过多造成的,不一定是泄漏。

6. 为什么不要随意关闭显式 GC

某些应用会调用 System.gc(),可能触发不必要的 Full GC。可以考虑:

bash 复制代码
-XX:+DisableExplicitGC

但是否关闭要看第三方库和业务行为。某些场景确实依赖显式 GC 释放直接内存或进行特定资源回收,不能仅凭经验统一关闭。

应先通过代码搜索、JFR 或日志确认是否存在大量显式 GC 调用。

7. 显式调用 System.gc() 能解决内存问题吗

通常不能。它只是在请求 JVM 执行回收,不能解决:

  • 无界缓存;
  • 静态集合泄漏;
  • ThreadLocal 泄漏;
  • 类加载器泄漏;
  • 直接内存泄漏;
  • 大对象设计;
  • 线程过多。

如果对象仍然可达,调用 GC 也不会回收。

8. 为什么不能只根据平均响应时间调优

平均响应时间会掩盖长尾:

text 复制代码
99 个请求:20ms
1 个请求:5000ms
平均值:约 70ms

对于在线系统,这 1% 的慢请求可能正是用户投诉和级联故障的来源。因此应重点观察:

  • P95;
  • P99;
  • P999;
  • 最大暂停;
  • GC 暂停与慢请求时间对齐;
  • 下游超时;
  • 线程池排队时间。

9. Spring Boot 服务需要关注哪些内存指标

建议监控:

text 复制代码
jvm_memory_used_bytes
jvm_memory_max_bytes
jvm_gc_pause_seconds
jvm_gc_memory_allocated_bytes_total
jvm_gc_memory_promoted_bytes_total
jvm_threads_live
jvm_threads_peak
process_resident_memory_bytes
process_cpu_usage

同时补充:

  • 堆使用率;
  • Eden 使用率;
  • 老年代使用率;
  • 元空间;
  • 直接内存;
  • GC 次数;
  • GC 总耗时;
  • Full GC 次数;
  • 线程池队列;
  • 请求分配率。

10. 如何设置告警

示例告警:

text 复制代码
堆使用率连续 5 分钟超过 80%
老年代回收后使用率超过 70%
Full GC 每分钟超过 3 次
GC 暂停 P99 超过 300ms
元空间使用率超过 85%
线程数超过基线两倍
进程 RSS 接近容器限制
OOM 次数大于 0

告警阈值需要结合历史基线,不能只套用固定数字。重点是观察趋势、持续时间和业务影响。

11. 不要在生产环境随意生成大堆转储

堆转储可能:

  • 占用大量磁盘;
  • 引起明显暂停;
  • 影响应用响应;
  • 泄露敏感数据;
  • 在容器中写满临时目录。

执行前需要确认:

  • 磁盘空间;
  • 文件保存位置;
  • 数据脱敏和访问权限;
  • 业务低峰期;
  • 是否有替代的类直方图;
  • 是否允许短暂停顿。

12. 参数调整要小步进行

不要一次修改十几个参数,否则无法知道哪个参数真正产生了影响。建议:

text 复制代码
确定基线
  -> 每次只调整一组相关参数
  -> 运行相同压测
  -> 对比吞吐、P99、GC 和 RSS
  -> 保留结果
  -> 再决定下一步

参数必须和 JDK 版本、收集器及部署方式一起记录。

六、进阶思考

1. GC 调优的本质是控制对象生命周期

很多 GC 问题的根因不是收集器参数,而是对象生命周期设计不合理:

text 复制代码
请求范围对象
  -> 被缓存长期持有
  -> 生命周期被意外延长
  -> 老年代不断增长

优化方向包括:

  • 缩短对象引用链;
  • 限制缓存容量;
  • 避免把大对象放入会话;
  • 及时清理 ThreadLocal;
  • 控制批量查询大小;
  • 使用流式处理;
  • 减少不必要的对象复制;
  • 避免重复序列化和反序列化。

2. 分配速率比堆大小更重要

假设两个服务都使用 4 GB 堆:

text 复制代码
服务 A:每秒分配 100 MB,存活 10%
服务 B:每秒分配 1 GB,存活 1%

服务 B 可能产生更多 Young GC,因为垃圾产生速度更快,即使最终存活对象很少。

因此应关注:

text 复制代码
Allocation Rate:单位时间内新分配对象的大小
Promotion Rate:单位时间内晋升老年代的大小
Live Set:长期存活对象集合大小

如果分配速率很高,优先检查代码和业务负载,而不是只增加堆。

3. 存活对象集合决定基础内存

堆中必须容纳长期存活对象。可以把内存需求粗略表示为:

text 复制代码
最小堆需求
  >= 存活对象集合
  + GC 工作空间
  + 短期对象缓冲
  + 收集器辅助结构

如果存活集合已经接近 Xmx,GC 即使高频运行也没有足够空间应对短期对象。

通过堆转储可以估算主要存活对象类型,通过多次采样可以区分正常缓存和异常增长。

4. JIT 编译与 Code Cache

Java 服务运行一段时间后,热点方法会被 JIT 编译为机器码。编译后的代码存放在 Code Cache 中。

相关信息可以通过:

bash 复制代码
jcmd <pid> Compiler.codecache

查看。Code Cache 过小可能导致:

  • 编译代码被回收;
  • 热点方法无法继续优化;
  • 性能波动;
  • 日志中出现编译相关警告。

这类问题通常不是 Java 堆问题,应单独观察。

5. 锁竞争也会表现为性能下降

如果线程大量处于 BLOCKED 或 WAITING,接口变慢可能与 GC 无关。

可以使用:

bash 复制代码
jcmd <pid> Thread.print -l

排查:

  • 哪些锁被大量等待;
  • 哪个线程持有锁;
  • 是否存在大粒度 synchronized;
  • 是否有数据库连接池等待;
  • 是否有线程池队列阻塞;
  • 是否有 Future.get 长时间等待。

JFR 可以帮助分析锁竞争和线程阻塞时间。

6. 性能优化需要结合业务压测

只压测一个简单接口,不能代表整个服务。压测场景至少要包含:

  • 真实请求比例;
  • 缓存命中和未命中;
  • 正常和异常流量;
  • 大小不同的请求;
  • 慢下游;
  • 数据库热点;
  • 并发用户数;
  • 持续时间;
  • 流量峰值。

同时记录压测期间:

text 复制代码
QPS
P50/P95/P99
GC 次数和停顿
堆使用
老年代晋升
CPU
线程
数据库连接
下游延迟
容器 RSS

7. JVM 参数需要版本化

建议把 JVM 参数和应用配置一起纳入版本管理:

yaml 复制代码
java:
  version: "17"
  options:
    - "-Xms2g"
    - "-Xmx2g"
    - "-XX:+UseG1GC"
    - "-XX:MaxGCPauseMillis=200"
    - "-XX:MaxDirectMemorySize=512m"

这样可以追踪:

  • 哪次发布修改了参数;
  • 哪个环境使用了不同配置;
  • 性能变化是否与参数相关;
  • 回滚时如何恢复。

8. JDK 升级也属于性能优化

新版本 JDK 可能带来:

  • GC 改进;
  • JIT 改进;
  • 类加载优化;
  • 容器感知改进;
  • 诊断工具增强;
  • 并发库优化;
  • 默认参数变化。

但升级不能只看基准测试,需要验证:

  • 依赖兼容性;
  • 启动参数;
  • 监控指标;
  • GC 行为;
  • 序列化和网络组件;
  • 线上流量模型;
  • 回滚方案。

9. 建立 JVM 性能基线

每个服务都应该有一份性能基线:

text 复制代码
正常 QPS
平均和 P99 延迟
CPU 使用率
堆使用率
老年代增长速度
Young GC 频率
Full GC 频率
GC 暂停 P99
线程数
进程 RSS
数据库连接池

出现问题时,先与基线对比,判断是流量变化、代码变化、依赖变化还是 JVM 参数变化。

结论

JVM 性能优化的第一步不是修改 GC 参数,而是建立正确的内存和执行模型。

需要区分:

  • 堆与非堆;
  • JVM 运行时数据区与 Java 内存模型;
  • Java 堆与直接内存;
  • 正常对象存活与内存泄漏;
  • GC 停顿与锁竞争;
  • 平均延迟与长尾延迟。

GC 调优的基本方法是:

text 复制代码
观察现象
  -> 建立基线
  -> 采集 GC、线程和内存数据
  -> 判断主要瓶颈
  -> 优先优化对象生命周期和业务代码
  -> 小步调整 JVM 参数
  -> 压测验证
  -> 灰度发布
  -> 持续监控

在实践中,应重点关注对象分配速率、老年代晋升、存活对象集合、堆外内存、线程数量和 GC 停顿,而不是只看 Xmx 或一次 Full GC。

当代码、缓存、线程池、数据库和 JVM 参数共同被纳入容量模型后,Java 服务的性能问题才真正具备可诊断、可验证和可持续优化的基础。

至此,系列一"Java 后端工程进阶之路"的 10 篇基础与进阶主题已经完成。后续可以围绕具体项目继续扩展,例如 JVM 源码分析、线上故障排查、MySQL 深度优化、Redis 集群和微服务可观测性。

相关推荐
Joolun商城源码_Java1 小时前
Java 商城技术栈怎么选:单体、微服务还是前后端分离多端?
java·开发语言·微服务
拒绝内耗。1 小时前
一个请求进入 Java 应用后,怎么找到我们写的方法?
java·开发语言
夕除1 小时前
redis--013
java·开发语言
码匠许师傅1 小时前
【设计模式精讲】28.访问者模式(Visitor)
java·设计模式·访问者模式
麻瓜code2 小时前
【LeetCode】两数相加:模拟加法竖式,用进位 flag 一次遍历解决
java
zzzll11113 小时前
Spring Boot 实现数据脱敏:自定义注解 + Jackson 序列化器
java·spring boot·后端
lhldsg10 小时前
全民健身解决方案小程序开发:从0到1的技术实战
java·数据库·需求分析
jason成都10 小时前
Spring WebFlux 适配达梦新方案|dm‑r2dbc:Netty 异步传输的实验性 R2DBC 驱动
java·后端·spring
泡海椒10 小时前
内置SPI函数库详解:JQuick-Java Builtin工具类实战用法
java·开发语言·python