欢迎光临
我们一直在努力

JVM 征服手册:从 CRUD 到性能调优的完整指南(一)- 基础入门

你有没有遇到过这样的场景——线上服务突然变慢,CPU 飙到 100%,你却只能重启了事?面试官问你"讲讲 JVM 内存结构",你只记得堆和栈两个词,然后陷入尴尬的沉默?

别焦虑,你不是一个人!大多数 Java 开发者的日常都是在和 Spring Boot、MyBatis 打交道,JVM 就像每天呼吸的空气——看不见摸不着,但一旦出了问题,你就知道它有多重要。更微妙的是,面试官也清楚初级开发者不可能精通 JVM 调优,他们真正想考察的,是你对这门语言底层机制的好奇心和学习潜力。

本文是 JVM 系列的上篇,我们会从最基础的概念讲起,不要求你有任何 JVM 前置知识。当你读完本文,你将能自信地画出 JVM 内存结构图、解释类加载过程、讲清楚垃圾回收的核心原理——这些都是面试中最基础也最高频的考点。


分层导读:不同阶段的 Java 开发者需要学什么?

在正式进入内容之前,我们先做一个"自我定位"。不同阶段对 JVM 的要求差异很大,你不必一次性掌握全部内容:

阶段核心关注点学完本文能做什么
初级(0-2 年) JVM 是什么、类加载、运行时数据区 面试能画内存结构图,能解释堆、栈、方法区
中级(2-5 年) GC 原理、GC 日志、JVM 参数调优 OOM 不再只会重启,能定位问题并说清原因
高级(5 年+) JIT 编译、字节码、JMM、性能诊断 主导线上故障排查和架构级性能优化
面试冲刺 高频考点 + 典型面试题 有条理地回答 JVM 问题,不再支支吾吾

本文重点覆盖初级到中级前半段的内容。关于垃圾收集器实战、诊断工具、JIT 编译和面试题精讲,请阅读本系列下篇。


第一章:JVM 到底是什么?——先让你心里有张地图

1.1 "一次编写,到处运行"的秘密

几乎所有 Java 程序员的第一课都会听到这句话:Write Once, Run Anywhere。

Windows 的 .exe 只能跑在 Windows 上,Linux 的二进制文件只能跑在 Linux 下,凭什么 Java 的 .class 文件能到处跑?答案就在 JVM。

JVM(Java Virtual Machine,Java 虚拟机)本质上是一个"翻译官"——把 .class 文件中的字节码翻译成操作系统能理解的机器指令。不同操作系统有不同的 JVM 实现,但它们面对同一份 .class 文件时,执行逻辑完全一致。

1.2 JDK、JRE、JVM 到底什么关系?

这是面试高频考点,但很多工作了两三年的人也说不清楚。三者的关系其实很简单:

┌─────────────────────────────────────┐
│ JDK │
│ ┌─────────────────────────────┐ │
│ │ JRE │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ JVM │ │ │
│ │ │ (HotSpot / OpenJ9) │ │ │
│ │ └──────────────────────┘ │ │
│ │ 核心类库 (rt.jar / lib) │ │
│ └─────────────────────────────┘ │
│ 开发工具 (javac, javap, jstack…) │
└─────────────────────────────────────┘

  • JVM:最底层,负责执行字节码。你写的 HelloWorld 能跑起来靠的就是它。
  • JRE(Java Runtime Environment)= JVM + 核心类库。光有 JVM 不够,String、HashMap 这些基础类都在核心类库里。
  • JDK(Java Development Kit)= JRE + 开发工具。javac(编译器)、javap(反编译)、jstack(线程分析)都在这。

打个比方:JVM 是发动机,JRE 是能跑的车,JDK 是带全套修车工具的车。普通用户只需要车(JRE),而我们开发者需要能造车也能修车的工具(JDK)。

1.3 HotSpot 是什么?常见的 JVM 实现有哪些?

我们通常说的"JVM",绝大多数时候指的是 HotSpot VM——Oracle 和 OpenJDK 默认使用的虚拟机,市场份额超过 90%。

JVM 实现特点使用场景
HotSpot Oracle/OpenJDK 默认 通用,99% 的 Java 应用
OpenJ9 IBM 开源,启动快、内存占用低 云原生、微服务容器化场景
GraalVM Oracle 出品,支持多语言混编 高性能计算、多语言项目
Zing/Azul C4 算法,超低延迟 GC(付费) 金融交易系统等

对于绝大多数开发者来说,掌握 HotSpot 就足够了,本文所有内容也基于 HotSpot 展开。


第二章:类加载机制——你的代码是怎么"活"起来的?

搞清楚了 JVM 的定位,接下来问一个具体问题:你写的 User user = new User(),这个 User 类到底是怎么被 JVM 认识的?

2.1 类加载三部曲:加载 → 链接 → 初始化

JVM 把类从 .class 文件变成可用的"活对象",经过三个大阶段:

关键细节——准备阶段 vs 初始化阶段,这几乎是面试必问的点:

// 假设类中有这个静态变量
public static int value = 123;

  • 准备阶段:JVM 给 value 分配内存,并设为默认值 0(还不是 123!)
  • 初始化阶段:执行 <clinit>() 方法,value 才被真正设置为 123

如果是 public static final int value = 123;,则编译期就会将常量值写入字段——这就是编译时常量优化。

2.2 双亲委派模型:为什么你写的 java.lang.String 不会生效?

你有没有好奇过:如果自己写了一个 java.lang.String 类放到 classpath 下,能替换掉 JDK 自带的 String 吗?

答案是不能。这背后的保护机制,就是双亲委派模型。

工作流程:当一个类需要被加载时,类加载器不会自己先动手,而是先把请求向上委托给父加载器。只有父加载器反馈"我找不到这个类"时,子加载器才会自己去加载。

就像审批链条:科长收到请求 → 找处长 → 处长找局长 → 局长说他不管 → 处长处理 → 处长也不管 → 科长自己处理。

这样做的好处:

  • 避免核心类被篡改:你写的 java.lang.String 会先被启动类加载器拦截——它发现这个全限定名属于自己管辖范围,直接加载了官方的 String,你的版本永远不会被使用。
  • 避免重复加载:同一个类只会被加载一次。
  • 面试官可能会追问:"如何打破双亲委派?"答案是自定义 ClassLoader 并重写 loadClass()(JDK 1.2 之后更推荐重写 findClass())。Tomcat 的 WebappClassLoader 就是典型案例——不同 Web 应用之间需要隔离,所以打破了双亲委派。

    2.3 不得不打破的时候:SPI 机制怎么绕过去的?

    实际开发中,双亲委派并不是铁板一块。JDK 自身的 SPI(Service Provider Interface) 机制就需要打破它。以 JDBC 为例:

    // 这行代码为什么能加载到 MySQL 的驱动类?
    Connection conn = DriverManager.getConnection(url, user, password);

    DriverManager 由启动类加载器加载,但 MySQL 驱动在 classpath 下,由应用类加载器加载。按双亲委派,启动类加载器根本看不到 classpath 下的类,怎么办?

    JDK 的解决方案是线程上下文类加载器(Thread Context ClassLoader):

    // DriverManager 内部实际上这样获取了驱动
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    // cl 就是应用类加载器,可以加载 classpath 下的 MySQL 驱动

    这告诉我们:没有银弹。双亲委派解决了核心类安全问题,但在需要"父加载器访问子加载器的类"时(SPI 是典型),就必须绕过了。


    第三章:运行时数据区——JVM 的"五脏六腑"

    类加载让代码进入了 JVM,接下来代码运行时,数据放在哪里?这就是面试中最核心的知识点之一:JVM 运行时数据区(Runtime Data Area)。

    3.1 先给你一张全景图

    不要被术语吓到,我们先整体看一眼,然后逐个拆解:

    记忆口诀:"三私两公一堆外"——程序计数器、虚拟机栈、本地方法栈是线程私有的;堆和方法区是线程共享的;直接内存是堆外面的。

    3.2 程序计数器(PC Register):最简单也最不起眼

    它可能是 JVM 中最低调的一块内存——只存一条信息:当前线程正在执行的字节码指令行号。

    但它不可或缺。CPU 在多线程之间切换时,必须知道每个线程"执行到哪了"。程序计数器就是干这个的——记录执行位置,方便切换回来后接着执行。

    两个特点:它是 JVM 中唯一不会抛出 OutOfMemoryError 的区域;执行 native 方法时值为 undefined。

    3.3 Java 虚拟机栈(JVM Stack):方法调用的幕后玩家

    每个 Java 方法被调用时,JVM 都会同步创建一个栈帧(Stack Frame),压入虚拟机栈:

    方法执行完毕,栈帧出栈。如果方法里调了另一个方法,就把新栈帧压在上面——这正是"栈"的含义(后进先出)。

    你遇到过 StackOverflowError 吗?它就是因为方法调用层次太深(比如无限递归),虚拟机栈的容量不够了。设置栈大小的参数是 -Xss,如 -Xss1m。

    3.4 Java 堆(Heap):所有对象的故乡

    User user = new User(); // 这个 new User() 创建的对象,就放在堆里

    堆是 JVM 中最大的一块内存,也是 GC(垃圾回收)的主战场。几乎所有对象都在堆上分配。

    堆的内存结构(分代视角):

    为什么新生代要分 Eden 和 Survivor? 如果只有 Eden,第一次 Minor GC 能回收的对象还好说,但存活下来的对象去哪?直接放老年代会导致老年代被快速填满、触发代价高昂的 Full GC。Survivor 区就是给那些"刚出生还死不掉"的对象一个缓冲地带。

    3.5 方法区(Method Area):类的"档案馆"

    方法区存放的是类信息(类名、字段、方法、接口版本)、常量、静态变量、JIT 编译后的代码缓存。

    一个重要版本变更:

    版本方法区实现所在位置
    JDK 7 及以前 永久代(PermGen) 堆的一部分
    JDK 8 及以后 元空间(Metaspace) 本地内存(堆外)

    # JDK 7:永久代大小固定
    -XX:MaxPermSize=256m

    # JDK 8+:元空间默认无上限,但强烈建议设置上限
    -XX:MaxMetaspaceSize=256m

    这个改动非常实际——以前部署应用时经常因为加载的类太多导致 java.lang.OutOfMemoryError: PermGen space,换成元空间后好多了。

    3.6 运行时常量池 vs 字符串常量池

    这也是个容易混淆的点:

    • 运行时常量池:每个类都有一个常量池,类加载后成为运行时常量池,属于方法区的一部分。
    • 字符串常量池:JDK 7 开始被移到了堆中。

    String s1 = "hello"; // 字面量,在字符串常量池中
    String s2 = new String("hello"); // 在堆中创建新对象
    String s3 = s2.intern(); // 将 s2 的值放入字符串常量池并返回该引用

    System.out.println(s1 == s2); // false(常量池 vs 堆中不同对象)
    System.out.println(s1 == s3); // true (都指向常量池中的同一对象)

    这段代码是面试常客。理解它的关键是:new 一定在堆中创建新对象,intern() 操作的归宿是字符串常量池。


    第四章:垃圾回收基础——JVM 最精彩的篇章

    理解了内存是怎么划分的,下一个自然的问题就是:内存是有限的,不用的对象谁来清理?这一章我们聚焦在 **"怎么找到垃圾"**和 "怎么清理垃圾" 这两个核心问题上。

    4.1 怎么判断一个对象"死了"?

    回收垃圾的第一步是找到垃圾。

    (1)引用计数法——最直观但被淘汰的方案

    给每个对象加一个引用计数器,有人引用就 +1,引用失效就 -1,计数器归零就回收。听起来很合理,但有一个致命缺陷:循环引用。

    class Node {
    Node next;
    }
    Node a = new Node();
    Node b = new Node();
    a.next = b;
    b.next = a; // a 和 b 互相引用
    a = null;
    b = null; // 外部引用都没了,但 a 和 b 的计数器都不是 0 —— 永远无法回收!

    (2)可达性分析——JVM 实际采用的方法

    GC Roots 包括哪些?(面试必考)

    • 虚拟机栈中引用的对象(局部变量指向的对象)

    • 方法区中静态属性引用的对象

    • 方法区中常量引用的对象

    • 本地方法栈中引用的对象

    • 被 synchronized 锁持有的对象

    4.2 四种引用类型:强、软、弱、虚

    Java 提供了四种引用,让开发者能更精细地控制对象的生命周期:

    引用类型

    回收时机

    典型用途

    强引用 new Object()

    永不回收(除非不可达)

    99% 的场景

    软引用 SoftReference

    内存不足时回收

    图片缓存、网页缓存

    弱引用 WeakReference

    下次 GC 必定回收

    ThreadLocal 的 Key

    虚引用 PhantomReference

    无法通过它获取对象

    NIO DirectByteBuffer 清理

    // 软引用示例——内存敏感的缓存
    SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024 * 10]);
    byte[] data = cache.get();
    if (data == null) {
    // 缓存已被回收,重新加载
    data = loadFromDisk();
    cache = new SoftReference<>(data);
    }

    ThreadLocal 内存泄漏 的根因就藏在这里:ThreadLocalMap 的 Key 是弱引用,但 Value 是强引用。Key 被 GC 后变成 null,而 Value 仍然被 Map 持有、无法回收——除非显式调用 remove()。

    4.3 垃圾回收算法:从理论到实践

    找到垃圾之后,怎么清理?有三种经典算法:

    (1)标记-清除(Mark-Sweep)——最基础

    缺点:效率不高,且产生内存碎片。

    (2)标记-复制(Mark-Copy)——新生代主力

    没有碎片,但可用内存减半。为什么适合新生代? 因为新生代 98% 的对象"朝生夕死",需要复制的存活对象很少,复制成本极低。

    这也解释了为什么新生代有两个 Survivor:Eden 和一个 Survivor 作为"使用中"的内存,另一个 Survivor 是"闲置空间"来接收存活对象。两个 Survivor 交替使用,永远有一块是空的。

    (3)标记-整理(Mark-Compact)——老年代主力

    老年代存活率高,不适合复制算法(复制成本太高)。整理算法没有碎片,但需要移动对象,耗时较长。

    4.4 分代收集:为什么要"分代"?

    三种算法各有优劣,JVM 的策略是因地制宜——把一个堆分成不同区域,对不同区域用不同算法:

    弱分代假说:绝大多数对象是朝生夕死的,活得越久的对象越不容易死。

    基于这个经验事实:

    • 新生代:"死亡率"极高 → 用标记-复制(复制成本低,回收效率高)
    • 老年代:"存活率"高 → 用标记-清除或标记-整理(避免频繁复制)

    这就是分代收集的核心思想。理解了它,你就理解了 JVM 垃圾回收的一大半。


    下篇预告

    本文带你走完了 JVM 基础知识的"主干道":JVM 是什么 → 类怎么加载进来的 → 运行时数据放在哪里 → 垃圾怎么回收。

    在下篇中,我们会进入动手实战环节——认识 HotSpot 中真正干活的垃圾收集器(G1、CMS、ZGC 等),学习 JVM 核心参数和 GC 日志,掌握线上排障的诊断工具用法,并最终用十道面试精讲帮你把知识转化为面试中的得分点。

    建议:在阅读下篇之前,先把本文中的内存结构图和回收算法画一到两遍——肌肉记忆会帮你在面试时更自信。

    赞(0)
    未经允许不得转载:171主机测评 » JVM 征服手册:从 CRUD 到性能调优的完整指南(一)- 基础入门
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址