从零开始学Java:第33章 JVM 基础:理解 Java 程序在内存里如何运行

📅 2026/6/30 1:11:32
从零开始学Java:第33章 JVM 基础:理解 Java 程序在内存里如何运行
第33章 JVM 基础理解 Java 程序在内存里如何运行你从第一阶段就知道 Java 程序会编译成.class然后运行在 JVM 上。现在项目已经写到并发、网络、数据库该回头更深入理解 JVM 了。JVM 不只是“运行 Java 的东西”。它负责加载 class 文件。创建对象。管理栈和堆。执行字节码。回收不用的对象。处理线程运行时的内存。本章不讲 JVM 源码而是建立工程直觉对象放在哪里局部变量放在哪里为什么会栈溢出为什么会内存溢出GC 到底在回收什么。一、JDK、JRE、JVM 再复习JDK 开发工具 JRE JRE 运行环境 JVM 标准库 JVM 执行字节码的虚拟机你写BookbooknewBook(001,Java入门,作者A,3);编译后不是直接变成某个操作系统的机器码而是变成字节码。JVM 执行字节码并在不同操作系统上提供相对统一的运行环境。这就是 Java “一次编写到处运行”的基础。二、栈和堆最重要的两个内存区域栈方法调用、局部变量、基本类型值、对象引用。 堆new 出来的对象。看代码publicvoiddemo(){intcount3;BookbooknewBook(001,Java入门,作者A,3);}大致理解栈 count 3 book 对象引用 堆 Book对象 isbn title author totalCopies变量book不是真正的对象它是指向堆中对象的引用。这解释了为什么多个变量可以指向同一个对象BookanewBook(001,Java入门,作者A,3);Bookba;b.borrowOneCopy();System.out.println(a.getAvailableCopies());a和b指向同一个堆对象。三、方法调用和栈帧每调用一个方法JVM 会在线程栈里创建一个栈帧。main-service.borrowBook-book.borrowOneCopy可以想象成栈顶borrowOneCopy 栈帧 borrowBook 栈帧 栈底main 栈帧方法执行完对应栈帧出栈。局部变量通常随着方法结束消失。但堆里的对象不一定马上消失。只要还有引用能找到它它就还活着。四、递归和 StackOverflowError错误递归publicstaticvoidcall(){call();}每调用一次都会创建新栈帧。没有结束条件栈越来越深最后StackOverflowError递归必须有结束条件publicstaticintsum(intn){if(n1){returnn;}returnnsum(n-1);}栈溢出不是异常处理能解决的业务问题通常是代码逻辑错了或递归太深。五、堆和 OutOfMemoryError不断创建对象并保存引用Listbyte[]listnewArrayList();while(true){list.add(newbyte[1024*1024]);}堆会越来越满最后可能OutOfMemoryError: Java heap space这里对象不能被 GC 回收因为list一直保存着引用。如果对象创建后没有任何引用能找到才可能被回收。六、GC 回收什么GC 是 Garbage Collection垃圾回收。它主要回收堆里不再可达的对象。不可达可以粗略理解为从线程栈、静态变量等根引用出发再也找不到这个对象。例子publicvoiddemo(){BookbooknewBook(001,Java入门,作者A,3);}方法执行完局部变量book消失。如果没有其他地方引用这个 Book 对象它就可以被 GC。GC 什么时候执行不由你精确控制。不要写依赖 GC 时机的业务代码。七、内存泄漏Java 有 GC但仍然可能内存泄漏。Java 里的内存泄漏通常指对象已经没用了但仍然被引用着导致 GC 无法回收。例子publicclassCache{privatestaticfinalListObjectDATAnewArrayList();publicstaticvoidadd(Objectobject){DATA.add(object);}}如果只加不清理DATA会越来越大。常见来源静态集合一直增长。缓存没有过期策略。监听器注册后没有移除。线程池任务引用大量对象。大对象被长生命周期对象持有。八、类加载JVM 运行类之前要加载 class。大致过程加载 - 验证 - 准备 - 解析 - 初始化你不需要背细节但要知道类不是程序启动时全部加载。通常用到时才加载。static 字段和 static 代码块在类初始化时执行。例子publicclassConfig{static{System.out.println(Config初始化);}publicstaticfinalStringAPP_NAMELibrary;}第一次主动使用Config时会触发初始化。九、static 和内存静态字段属于类不属于某个对象。publicclassIdGenerator{privatestaticintnextId1;publicstaticintnext(){returnnextId;}}nextId不是每个对象一份而是类级别一份。滥用 static 会让状态变成全局共享测试和并发都更难。工具方法可以 static。业务状态不要轻易 static。十、字符串常量池直觉StringaJava;StringbJava;System.out.println(ab);可能输出true因为字符串字面量可能放在字符串常量池中复用。但StringcnewString(Java);System.out.println(ac);通常是 false。所以字符串内容比较永远用a.equals(c)不要依赖常量池行为写业务判断。十一、常见 JVM 参数设置最大堆java-Xmx512m-jarapp.jar设置初始堆java-Xms256m-Xmx512m-jarapp.jar打印 GC 日志在不同 Java 版本写法不同。Java 9 常见java-Xlog:gc-jarapp.jar初学阶段先知道JVM 参数会影响内存和诊断。不要随便调大堆掩盖内存泄漏。内存泄漏调大堆只是延迟爆炸。十二、线程和栈每个线程都有自己的栈。多个线程共享堆。这解释了并发安全问题局部变量在各自线程栈里通常不共享。 对象在堆里多个线程拿到同一引用就会共享。所以减少共享可变对象能降低并发风险。十三、常见内存问题1. StackOverflowError递归太深或无限递归。2. OutOfMemoryError: Java heap space堆对象太多或内存泄漏。3. 频繁 GC程序不断创建大量临时对象GC 压力大。4. 线程过多每个线程都有栈线程过多也会占内存。十四、排查思路遇到内存问题看错误类型。看堆栈。看最近是否有大集合、缓存、批量读取。看是否一次性读入大文件。看线程数量是否异常。使用工具分析堆转储。常见工具jpsjstackjmapVisualVMJava Flight Recorder现阶段不要求熟练掌握但要知道这些工具存在。十五、GC Roots判断对象是否活着的起点GC 不是看对象“有没有用过”而是看对象还能不能从一组根引用找到。这些根引用可以粗略理解为 GC Roots。常见 GC Roots 包括当前线程栈里的局部变量引用。static 字段引用的对象。JNI/native 相关引用。正在被 JVM 使用的一些内部对象。例子publicclassGcRootDemo{privatestaticBookcachedBook;publicstaticvoidmain(String[]args){BooklocalBooknewBook(001,Java入门,作者A,3);cachedBooknewBook(002,代码整洁之道,作者B,2);}}localBook在 main 方法执行期间是栈上的局部变量引用它指向的对象可达。cachedBook是 static 字段引用它指向的对象也可达。main 方法结束后localBook消失如果没有别的引用指向那本书它就可以被回收。cachedBook指向的对象仍然被 static 字段引用不会被回收。这就是为什么静态集合容易造成内存泄漏。十六、分代回收的直觉很多对象创建后很快就没用了。例如StringmessageHello name;很多临时字符串、临时集合、临时 DTO 在方法结束后就不可达。JVM 的垃圾回收通常利用这个特点把堆按代管理新生代新创建的对象 老年代存活较久的对象粗略理解新对象先进入新生代。新生代 GC 比较频繁。多次 GC 后仍然存活的对象可能进入老年代。老年代 GC 通常成本更高。你不需要现在就调 GC 参数但要有一个工程直觉大量短命对象会增加 GC 压力。 长期持有大对象会增加老年代压力。比如一次性把 10GB 文件读成字符串StringtextFiles.readString(path);这会创建巨大对象可能直接导致内存问题。大文件应该流式处理。十七、对象生命周期例子看一个联系人导入publicvoidimportContacts(Pathpath,ContactManagermanager)throwsIOException{ListStringlinesFiles.readAllLines(path,StandardCharsets.UTF_8);for(Stringline:lines){ContactcontactparseContact(line);manager.addContact(contact);}}这里有几类对象lines列表会保存整个文件内容。每个line是一行字符串。contact创建后被manager保存。方法结束后lines如果没有外部引用可以回收。line字符串如果只被lines引用也可以回收。contact被manager保存不能回收。如果文件很大readAllLines会让所有行同时在内存里。改成逐行读取try(BufferedReaderreaderFiles.newBufferedReader(path,StandardCharsets.UTF_8)){Stringline;while((linereader.readLine())!null){ContactcontactparseContact(line);manager.addContact(contact);}}这样不会一次性把所有行放入内存。这就是 JVM 内存直觉对代码选择的影响。十八、类加载器的第一层理解类加载器负责把 class 加载进 JVM。常见类加载器Bootstrap ClassLoader加载核心 Java 类。Platform ClassLoader加载平台类。Application ClassLoader加载应用 classpath 下的类。你自己写的类通常由 Application ClassLoader 加载。类加载有一个重要机制叫双亲委派。粗略理解加载一个类时先让父加载器尝试加载。 父加载器加载不了子加载器再加载。这样可以避免你自己写一个假的java.lang.String替换 JDK 核心类。你现在不需要深入自定义 ClassLoader但要知道类不是随便从任何地方来的它来自 classpath并由类加载器加载。这也解释了很多错误ClassNotFoundException NoClassDefFoundError它们经常和 classpath、依赖、打包有关。十九、ClassNotFoundException 和 NoClassDefFoundErrorClassNotFoundException通常表示运行时尝试加载某个类但 classpath 里找不到。例如 JDBC 驱动没加依赖。NoClassDefFoundError常见于编译时存在运行时缺失。比如你用 Jackson 编译通过但运行 jar 时没有带 Jackson 依赖NoClassDefFoundError: com/fasterxml/jackson/databind/ObjectMapper这不是业务代码逻辑错而是运行环境缺类。排查依赖是否在 Maven/Gradle 中声明。打包方式是否包含依赖。运行 classpath 是否正确。版本是否冲突。二十、finalize 不要依赖早期 Java 有finalize方法试图在对象被回收前做清理。不要依赖它。原因GC 时间不确定。finalize执行时间不确定。可能影响性能。现代 Java 已经不推荐使用。资源释放应该用明确方式try(BufferedReaderreaderFiles.newBufferedReader(path)){}文件、连接、线程池都应该明确关闭不要等 GC。GC 管理内存不负责替你管理所有外部资源。二十一、常用诊断工具补充1. jps查看本机 Java 进程jps可以看到进程 ID。2. jstack查看线程栈jstackpid适合排查线程卡住、死锁。3. jmap查看堆信息或导出堆转储jmap-histopid可以看到对象数量和占用。4. VisualVM图形化工具可以观察堆内存。线程。GC。CPU。初学者用 VisualVM 建立直觉很不错。二十二、JVM 不是背面试题学习 JVM 很容易变成背诵堆、栈、方法区、程序计数器、本地方法栈这些概念有价值但更重要的是它们如何影响你写代码大文件不要一次性读入内存。不要让静态集合无限增长。线程不要无限创建。资源要明确关闭。字符串比较不要用。出现类找不到时检查 classpath 和依赖。内存问题要看对象是否还被引用。工程直觉比死背名词更重要。二十三、练习写一个递归没有结束条件的方法观察StackOverflowError。写一个不断往 List 添加大数组的程序观察堆内存错误。用两个变量指向同一个对象修改其中一个观察另一个看到变化。写 static 计数器观察多个对象共享同一份 static 字段。用-Xmx64m运行程序观察内存限制变化。用jps找到一个正在运行的 Java 程序。用 VisualVM 观察一个循环创建对象的程序。把大文件读取方式从readAllLines改成BufferedReader。二十四、本章小结你现在应该理解JVM 执行.class字节码。栈保存方法调用和局部变量。堆保存 new 出来的对象。对象变量保存引用不是对象本体。每个线程有自己的栈堆被多个线程共享。GC 回收不可达对象。Java 有 GC 也可能内存泄漏。StackOverflowError通常来自递归太深。OutOfMemoryError通常来自堆不够或对象无法回收。static 是类级别状态不属于某个对象。JVM 参数和诊断工具能帮助排查运行问题。GC 从根引用出发判断对象是否可达。分代回收利用了大多数对象朝生夕死的特点。类加载器负责从 classpath 加载类。类找不到通常要检查依赖、classpath 和打包方式。外部资源要明确关闭不要依赖 GC。下一章是第五阶段项目接口版学生管理系统。它会把 HTTP、数据库、异常、并发直觉和项目分层合成一个更接近真实应用的项目设计。