编者注：本文为历史博文归档；涉及 JDK、框架与工具链版本请以当前官方文档为准。引用外链图片可能失效，阅读时请注意时效性。摘要本文记录了一次 GC 调试的实验过程与结果分析。GC 知识要点回顾问题 1：为什么要调试 GC 参数？在 32 核处理器的系统上，10% 的 GC 时间导致 75% 的吞吐量损失。所以在大型系统上，调试 GC 是以小博大的不错选择。"Small improvements in reducing such a bottleneck can produce large gains in performance."问题 2：怎么样调试 GC？调试 GC，主要有 三个主要的参数：选择合适的 GC Collector整个 JVM Heap 堆的大小Young Generation 的大小（-Xmn 或 -XX:NewRatio=?）问题 3：有哪些不同的 GC Collector？Tony Printezis (JVM 大牛.html) 在 Garbage Collection in the Java HotSpot Virtual Machine 中有图为证，还有一篇更早的 Sun 开发人员介绍 GC 调试 也是有图为证。Neo4j 总结如下：GC shortnameGenerationCommand line parameterCommentCopyYoung-XX:+UseSerialGCThe Copying collectorMarkSweepCompactTenured-XX:+UseSerialGCThe Mark and Sweep CompactorConcurrentMarkSweepTenured-XX:+UseConcMarkSweepGCThe Concurrent Mark and Sweep CompactorParNewYoung-XX:+UseParNewGCThe parallel Young Generation Collector — can only be used with the Concurrent mark and sweep compactor.PS ScavengeYoung-XX:+UseParallelGCThe parallel object scavengerPS MarkSweepTenured-XX:+UseParallelGCThe parallel mark and sweep collector简而言之，Young 和 Tenured 代各有三种 Collector，分别是：Serial：单线程。Parallel：多线程并行，GC 线程和 App 线程取一运行，即 GC 要 Stop the (app.html) World。Concurrent：多线程并发，GC 线程和 App 线程可同时运行。（注：Young generation 没有 CMS，取而代之的是可和 CMS (Old.html) 一起运行的 ParNew）问题 4：如何选择 Collector？Serial 可以直接排除掉，现在最普通的服务器也有双核 64 位/8G 内存，默认的 Collector 是 PS Scavenge 和 PS MarkSweep。所以 Collector 主要在并行 (Parallel.html) 和并发 (Concurrent.html) 两者之间选择。问题 5：选择的标准 (参数指标.html) 是什么？如何得到这些参数值 (How to measure it.html)？指标主要是 Throughput（吞吐量） 和 Latency（延迟）。garbage-collection-in-java-part-3 从 GC 的耗时给出了吞吐量和响应速度的公式：Total Execution Time = Useful Time + Paused Time throughput = Useful Time / Total Execution Time latency = average paused time如何得到 Useful time 和 Paused Time？即如何得到 JVM 的 GC 时间，有以下几种方式：1. GC Log打印 GC log，在 Java 启动参数中加入下面的语句（本文为 Tomcat 应用）。GC Log 记录每次 GC 时间，可根据 GC Log 计算平均 GC 时间和累积 GC 时间。CATALINA_OPTS="$CATALINA_OPTS -verbose:gc -Xloggc:/usr/local/tomcat/gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps"2. JconsoleJDK 自带工具。在 Java 启动参数中加入下面的语句（本文为 Tomcat 应用），然后在监控端可以远程连接 1090 端口。在内存一项，有累积 GC 时间和次数。注意在以 min 为单位显示时，只显示整数部分，如 1min20s 显示为 1min。CATALINA_OPTS="$CATALINA_OPTS -Dcom.sun.management.jmxremote.port=1090 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false"3. VisualGCJVM 监控工具，未同 JDK 一起发布，可在 JVisualvm (JDK 自带.html) 中以插件的方式使用，本文为独立使用。有累积 GC 时间和次数，并有曲线图直观显示。首先在 Server 端启动 jstatd：vi jstatd.all.policy grant codebase "file:${java.home}/../lib/tools.jar" { permission java.security.AllPermission; }; jstatd -J-Djava.security.policy=jstatd.all.policy然后在监控端启动 VisualGC，远程连接服务端进程 id：visualgc 102592@remote.domain问题 6：应用请求的吞吐量和响应是否可以反映 JVM 的性能？这正是我们调优的目标。本文使用 Jmeter 来做压力测试，并给出吞吐量和响应 report。测试硬件环境项目值项目值操作系统Linux 2.6.18-53.el5物理内存总量8,175,632 Kb体系结构amd64可用物理内存1,140,520 Kb处理器的数目2交换空间总量8,594,764 Kb分配的虚拟内存2,680,408 Kb可用交换空间8,594,680 KbTest case使用 Jmeter 压力测试。共 6 个 client，每个 client 启动 30 个线程发送请求。每个请求从 16 种测试样例中随机挑选一个，发送到 server 端。测试持续 10min。参数值server 使用默认 GC (PS Scavenge 和 PS MarkSweep.html)。server 使用 CMS (-XX:+UseConcMarkSweepGC -XX:+UseParNewGC.html)。server 使用 CMS，设置 Young generation 的大小为 200m (-Xmn200m.html)。server 使用 CMS，设置 Young generation 的大小为 600m (-Xmn600m.html)。观察值Jmeter 请求的 summary report。server 端累积 GC 时间和次数。测试结果1) CMS 和 Parallel 比较1.1) 吞吐量和响应 (PS Scavenge 和 PS MarkSweep.html) (ParNew 和 CMS.html)从 Jmeter 的 report 中可以看出，使用 CMS 后吞吐量 (对应总的请求数.html) 下降 18%，而最大响应时间 (包括最小响应时间.html) 有近 30% 的提升 (变小.html)。这验证了 Tony Printezis 在 Step-by-Step: Garbage Collection Tuning in the Java HotSpot™ Virtual Machine 中说的：使用 CMS 应用的吞吐量会相对下降，但有更好的最差响应时间。Expect longer young GC timesDue to slower allocations into the old genExpect better worst-case latenciesCMS does its work mostly-concurrentlyShorter worst-case pausesExpect lower throughputCMS does more work在官方的 JVM 性能调优中给出的建议也是：如果你的应用对峰值处理有要求，而对一两秒的停顿可以接受，则使用 -XX:+UseParallelGC；如果应用对响应有更高的要求，停顿最好小于一秒，则使用 -XX:+UseConcMarkSweepGC。1.2) GC 累积时间和次数 (PS Scavenge 和 PS MarkSweep.html) (ParNew 和 CMS.html)PS 累积 GC 时间 (visualgc.html) 为 1min25s，其中 Eden 189 次，共 52s；Old 13 次，共 33s。CMS 累积 GC (visualgc.html) 为 2min2s，其中 Eden 2333 次，共 1min46s；Old 55 次，共 16s。(Jconsole 和 GC log 却显示没有 Full GC，从 understanding cms gc logs 和 jstat 显示的 full GC 次数与 CMS 周期的关系 中我推测 visualgc 与 jstat 显示一致，都是统计 Old 的回收次数；而 Full GC 则是 Young 和 Old 一起回收，在其他类型的 GC 里，Old 只有 Full GC 时才触发)。可以看到 PS 的 GC 频率相对低，但每次 GC 时间长，每次 Full 在 3s 左右徘徊，Young 在 0.3s 左右；CMS 则是短频快，频繁快速回收，Young 在 0.03s (<0.1s) 左右，Old <0.5s。从 JMeter 上，使用 PS GC，Request Report 会有间歇性的停顿，即 server 没有任何响应；CMS 则相对较少，停顿不那么明显。2) CMS 下不同 Xmn 的比较由于 CMS Young 太多频繁，又测试了分别调整 Xmn 为 200m 和 600m 之后的结果。200m 是仿照 cassandra 中 100m * cpu # 来设置 Young gen 的大小；600m 则是与 PS 下的 Young gen 一致。 200m 600m随着 Young gen 的增大 (40m -> 200m -> 600m.html)，Young 的回收次数减少，Old 的回收次数增加，总体 GC 累积时间下降，应用吞吐量上升，最差响应时间变慢 (即便和 PS 比较也更差，是我的测试有问题？.html)。结论App 停顿 3s 是不可接受的，因此倾向于使用 CMS；CMS 的 default young gen 相当小，于是设置 Xmn。对于更加 Prefer 响应的应用，下面配置是否是黄金标配：JVM_OPTS="$JVM_OPTS -XX:+UseParNewGC" JVM_OPTS="$JVM_OPTS -XX:+UseConcMarkSweepGC" JVM_OPTS="$JVM_OPTS -XX:+CMSParallelRemarkEnabled" JVM_OPTS="$JVM_OPTS -XX:SurvivorRatio=8" JVM_OPTS="$JVM_OPTS -XX:MaxTenuringThreshold=1" JVM_OPTS="$JVM_OPTS -XX:CMSInitiatingOccupancyFraction=75" JVM_OPTS="$JVM_OPTS -XX:+UseCMSInitiatingOccupancyOnly"说明：本文涉及的技术内容基于较早期的 JDK 版本（如 JDK 6/7/8）。请注意，CMS 收集器在 JDK 9 中被标记为废弃，并在 JDK 14 中正式移除。现代 JDK 版本推荐使用 G1、ZGC 或 Shenandoah 等垃圾收集器。文中提到的部分工具（如 VisualGC 插件）在新版 JDK 或 IDE 中可能需要额外配置或已不再适用，请以官方最新文档为准。
Java 编程的动态性，第 1 部分：类和类装入系列内容第 1 部分：类和类装入第 3 部分：应用反射第 5 部分：动态转换类第 6 部分：利用 Javassist 进行面向方面的更改第 7 部分：用 BCEL 设计字节码第 8 部分：用代码生成取代反射引言本文是这个新系列文章的第一篇，该系列将讨论我称之为 Java 编程的动态性 的一系列主题。这些主题的范围从 Java 二进制类文件格式的基本结构，以及使用反射进行运行时元数据访问，一直到在运行时修改和构造新类。贯穿整篇文章的公共线索是这样一种思想：在 Java 平台上编程要比使用直接编译成本机代码的语言更具动态性。如果您理解了这些动态方面，就可以使用 Java 编程完成那些在任何其它主流编程语言中不能完成的事情。本文中，我将讨论一些基本概念，它们是这些 Java 平台动态特性的基础。这些概念的核心是用于表示 Java 类的二进制格式，包括这些类装入到 JVM 时所发生的情况。本文不仅是本系列后续文章的基础，而且还演示了开发人员在使用 Java 平台时碰到的一些非常实际的问题。用二进制表示的类使用 Java 语言的开发人员在用编译器编译他们的源代码时，通常不必关心对这些源代码做了些什么样的细节处理。但是在本系列文章中，我将讨论从源代码到执行程序所涉及的许多幕后细节，因此我将首先探讨由编译器生成的二进制类。二进制类格式实际上是由 JVM 规范定义的。通常这些类表示是由编译器从 Java 语言源代码生成的，而且它们通常存储在扩展名为 .class 的文件中。但是，这些特性都无关紧要。已经开发了可以使用 Java 二进制类格式的其它一些编程语言，而且出于某些目的，还构建了新的类表示，并被立即装入到运行中的 JVM。就 JVM 而言，重要的部分不是源代码以及如何存储源代码，而是格式本身。那么这个类格式实际看上去是什么样呢？清单 1 提供了一个（非常）简短的类的源代码，还附带了由编译器输出的类文件的部分十六进制显示：清单 1. Hello.java 的源代码和（部分）二进制类文件public class Hello { public static void main(String[] args.html) { System.out.println("Hello, World!".html); } }0000: cafe babe 0000 002e 001a 0a00 0600 0c09 ................ 0010: 000d 000e 0800 0f0a 0010 0011 0700 1207 ................ 0020: 0013 0100 063c 696e 6974 3e01 0003 2829 .....<init>...() 0030: 5601 0004 436f 6465 0100 046d 6169 6e01 V...Code...main. 0040: 0016 285b 4c6a 6176 612f 6c61 6e67 2f53 ..([Ljava/lang/S 0050: 7472 696e 673b 2956 0c00 0700 0807 0014 tring;)V........ 0060: 0c00 1500 1601 000d 4865 6c6c 6f2c 2057 ........Hello, W 0070: 6f72 6c64 2107 0017 0c00 1800 1901 0005 orld!........... 0080: 4865 6c6c 6f01 0010 6a61 7661 2f6c 616e Hello...java/lan 0090: 672f 4f62 6a65 6374 0100 106a 6176 612f g/Object...java/ 00a0: 6c61 6e67 2f53 7973 7465 6d01 0003 6f75 lang/System...ou ...二进制类文件的内幕清单 1 显示的二进制类表示中首先是"cafe babe"特征符，它标识 Java 二进制类格式（并顺便作为一个永久的 — 但在很大程度上未被认识到的 — 礼物送给努力工作的 barista，他们本着开发人员所具备的精神构建 Java 平台）。这个特征符恰好是一种验证一个数据块 确实 声明成 Java 类格式的一个实例的简单方法。任何 Java 二进制类（甚至是文件系统中没有出现的类）都需要以这四个字节作为开始。该数据的其余部分不太吸引人。该特征符之后是一对类格式版本号（本例中，是由 1.4.1 javac 生成的次版本 0 和主版本 46 — 用十六进制表示就是 0x2e），接着是常量池中项的总数。项总数（本例中，是 26，或 0x001a）后面是实际的常量池数据。这里放着类定义所用的所有常量。它包括类名和方法名、特征符以及字符串（您可以在十六进制转储右侧的文本解释中识别它们），还有各种二进制值。常量池中各项的长度是可变的，每项的第一个字节标识项的类型以及对它解码的方式。这里我不详细探究所有这些内容的细节，如果感兴趣，有许多可用的参考资料，从实际的 JVM 规范开始。关键之处在于常量池包含对该类所用的其它类和方法的所有引用，还包含了该类及其方法的实际定义。常量池往往占到二进制类大小的一半或更多，但平均下来可能要少一些。常量池后面还有几项，它们引用了类本身、其超类以及接口的常量池项。这些项后面是有关字段和方法的信息，它们本身用复杂结构表示。方法的可执行代码以包含在方法定义中的 代码属性 的形式出现。用 JVM 的指令形式表示该代码，一般称为 字节码，这是下一节要讨论的主题之一。在 Java 类格式中，属性 被用于几个已定义的用途，包括已提到的字节码、字段的常量值、异常处理以及调试信息。但是属性并非只可能用于这些用途。从一开始，JVM 规范就已经要求 JVM 忽略未知类型的属性。这一要求所带来的灵活性使得将来可以扩展属性的用法以满足其它用途，例如提供使用用户类的框架所需的元信息，这种方法在 Java 派生的 C# 语言中已广泛使用。遗憾的是，对于在用户级利用这一灵活性还没有提供任何挂钩。字节码和堆栈构成类文件可执行部分的字节码实际上是针对特定类型的计算机 — JVM — 的机器码。它被称为 虚拟 机，因为它被设计成用软件来实现，而不是用硬件来实现。每个用于运行 Java 平台应用程序的 JVM 都是围绕该机器的实现而被构建的。这个虚拟机实际上相当简单。它使用堆栈体系结构，这意味着在使用指令操作数之前要先将它们装入内部堆栈。指令集包含所有的常规算术和逻辑运算，以及条件转移和无条件转移、装入／存储、调用／返回、堆栈操作和几种特殊类型的指令。有些指令包含立即操作数值，它们被直接编码到指令中。其它指令直接引用常量池中的值。尽管虚拟机很简单，但实现却并非如此。早期的（第一代）JVM 基本上是虚拟机字节码的解释器。这些虚拟机实际上 的确 相对简单，但存在严重的性能问题 — 解释代码的时间总是会比执行本机代码的时间长。为了减少这些性能问题，第二代 JVM 添加了 即时（just-in-time，JIT）转换。在第一次执行 Java 字节码之前，JIT 技术将它编译成本机代码，从而对于重复执行提供了更好的性能。当代 JVM 的性能甚至还要好得多，因为使用了适应性技术来监控程序的执行并有选择地优化频繁使用的代码。装入类诸如 C 和 C++ 这些编译成本机代码的语言通常在编译完源代码之后需要链接这个步骤。这一链接过程将来自独立编译好的各个源文件的代码和共享库代码合并起来，从而形成了一个可执行程序。Java 语言就不同。使用 Java 语言，由编译器生成的类在被装入到 JVM 之前通常保持原状。即使从类文件构建 JAR 文件也不会改变这一点 — JAR 只是类文件的容器。链接类不是一个独立步骤，它是在 JVM 将这些类装入到内存时所执行作业的一部分。在最初装入类时这一步会增加一些开销，但也为 Java 应用程序提供了高度灵活性。例如，在编写应用程序以使用接口时，可以到运行时才指定其实际实现。这个用于组装应用程序的 后联编 方法广泛用于 Java 平台，servlet 就是一个常见示例。JVM 规范中详细描述了装入类的规则。其基本原则是只在需要时才装入类（或者至少看上去是这样装入 — JVM 在实际装入时有一些灵活性，但必须保持固定的类初始化顺序）。每个装入的类都可能拥有其它所依赖的类，所以装入过程是递归的。清单 2 中的类显示了这一递归装入的工作方式。Demo 类包含一个简单的 main 方法，它创建了 Greeter 的实例，并调用 greet 方法。Greeter 构造函数创建了 Message 的实例，随后会在 greet 方法调用中使用它。清单 2. 类装入演示的源代码public class Demo { public static void main(String[] args.html) { System.out.println("**beginning execution**".html); Greeter greeter = new Greeter(); System.out.println("**created Greeter**".html); greeter.greet(); } } public class Greeter { private static Message s_message = new Message("Hello, World!".html); public void greet() { s_message.print(System.out); } } public class Message { private String m_text; public Message(String text.html) { m_text = text; } public void print(java.io.PrintStream ps.html) { ps.println(m_text.html); } }在 java 命令行上设置参数 -verbose:class 会打印类装入过程的跟踪记录。清单 3 显示了使用这一参数运行清单 2 程序的部分输出：清单 3. -verbose:class 的部分输出[Opened /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/sunrsasign.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/jsse.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/jce.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/charsets.jar] [Loaded java.lang.Object from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.io.Serializable from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.lang.Comparable from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.lang.CharSequence from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.lang.String from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] ... [Loaded java.security.Principal from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.security.cert.Certificate from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded Demo] **beginning execution** [Loaded Greeter] [Loaded Message] **created Greeter** Hello, World! [Loaded java.util.HashMap$KeySet from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.util.HashMap$KeyIterator from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]这只列出了输出中最重要的部分 — 完整的跟踪记录由 294 行组成，我删除了其中大部分，形成了这个清单。最初的一组类装入（本例中是 279 个）都是在尝试装入 Demo 类时触发的。这些类是每个 Java 程序（不管有多小）都要使用的核心类。即使删除 Demo main 方法的所有代码也不会影响这个初始的装入顺序。但是不同版本的类库所涉及的类数量和名称都不同。在上面这个清单中，装入 Demo 类之后的部分更有趣。这里的顺序显示了只有在准备创建 Greeter 类的实例时才会装入该类。不过，Greeter 类使用了 Message 类的静态实例，所以在可以创建 Greeter 类的实例之前，还必须先装入 Message 类。在装入并初始化类时，JVM 内部会完成许多操作，包括解码二进制类格式、检查与其它类的兼容性、验证字节码操作的顺序以及最终构造 java.lang.Class 实例来表示新类。这个 Class 对象成了 JVM 创建新类的所有实例的基础。它还是已装入类本身的标识 — 对于装入到 JVM 的同一个二进制类，可以有多个副本，每个副本都有其自己的 Class 实例。即使这些副本都共享同一个类名，但对 JVM 而言它们都是独立的类。非常规类路径装入到 JVM 的类是由 类装入器（ClassLoader）控制的。JVM 中构建了一个 引导程序 类装入器，它负责装入基本的 Java 类库类。这个特殊的类装入器有一些专门的特性。首先，它只装入在引导类路径上找到的类。因为这些是可信的系统类，所以引导程序装入器跳过了对常规（不可信）类所做的大量验证。引导程序不是唯一的类装入器。对于初学者而言，JVM 为装入标准 Java 扩展 API 中的类定义了一个 扩展 类装入器，并为装入一般类路径上的类（包括应用程序类）定义了一个 系统 类装入器。应用程序还可以定义它们自己的用于特殊用途（例如运行时类的重新装入）的类装入器。这样添加的类装入器派生自 java.lang.ClassLoader 类（可能是间接派生的），该类对从字节数组构建内部类表示（java.lang.Class 实例）提供了核心支持。每个构造好的类在某种意义上是由装入它的类装入器所“拥有”。类装入器通常保留它们所装入类的映射，从而当再次请求某个类时，能通过名称找到该类。每个类装入器还保留对父类装入器的引用，这样就定义了类装入器树，树根为引导程序装入器。在需要某个特定类的实例（由名称来标识）时，无论哪个类装入器最初处理该请求，在尝试直接装入该类之前，一般都会先检查其父类装入器。如果存在多层类装入器，那么会递归执行这一步，所以这意味着通常不仅在装入该类的类装入器中该类是 可见 的，而且对于所有后代类装入器也都是可见的。这还意味着如果一条链上有多个类装入器可以装入某个类，那么该树最上端的那个类装入器会是实际装入该类的类装入器。在许多环境中，Java 程序会使用多个应用程序类装入器。J2EE 框架就是一个示例。该框架装入的每个 J2EE 应用程序都需要拥有一个独立的类装入器以防止一个应用程序中的类干扰其它应用程序。该框架代码本身也将使用一个或多个其它类装入器，同样用来防止对应用程序产生的或来自应用程序的干扰。整个类装入器集合形成了树状结构的层次结构，在其每个层次上都可装入不同类型的类。装入器树作为类装入器层次结构的实际示例，图 1 显示了 Tomcat servlet 引擎定义的类装入器层次结构。这里 Common 类装入器从 Tomcat 安装的某个特定目录的 JAR 文件进行装入，旨在用于在服务器和所有 Web 应用程序之间共享代码。Catalina 装入器用于装入 Tomcat 自己的类，而 Shared 装入器用于装入 Web 应用程序之间共享的类。最后，每个 Web 应用程序有自己的装入器用于其私有类。图 1. Tomcat 类装入器在这种环境中，跟踪合适的装入器以用于请求新类会很混乱。为此，在 Java 2 平台中将 setContextClassLoader 方法和 getContextClassLoader 方法添加到了 java.lang.Thread 类中。这些方法允许该框架设置类装入器，使得在运行每个应用程序中的代码时可以将类装入器用于该应用程序。能装入独立的类集合这一灵活性是 Java 平台的一个重要特性。尽管这个特性很有用，但是它在某些情况中会产生混淆。一个令人混淆的方面是处理 JVM 类路径这样的老问题。例如，在图 1 显示的 Tomcat 类装入器层次结构中，由 Common 类装入器装入的类决不能（根据名称）直接访问由 Web 应用程序装入的类。使这些类联系在一起的唯一方法是通过使用这两个类集都可见的接口。在这个例子中，就是包含由 Java servlet 实现的 javax.servlet.Servlet。无论何种原因在类装入器之间移动代码时都会出现问题。例如，当 J2SE 1.4 将用于 XML 处理的 JAXP API 移到标准分发版中时，在许多环境中都产生了问题，因为这些环境中的应用程序以前是依赖于装入它们自己选择的 XML API 实现的。使用 J2SE 1.3，只要在用户类路径中包含合适的 JAR 文件就可以解决该问题。在 J2SE 1.4 中，这些 API 的标准版现在位于扩展的类路径中，所以它们通常将覆盖用户类路径中出现的任何实现。使用多个类装入器还可能引起其它类型的混淆。图 2 显示了 类身份危机（class identity crisis） 的示例，它是在两个独立类装入器都装入一个接口及其相关的实现时产生的危机。即使接口和类的名称和二进制实现都相同，但是来自一个装入器的类的实例不能被认为是实现了来自另一个装入器的接口。图 2 中通过将接口类 I 移至 System 类装入器的空间就可以解除这种混淆。类 A 仍然有两个独立的实例，但它们都实现了同一个接口 I。图 2. 类身份危机结束语Java 类定义和 JVM 规范一起为运行时组装代码定义了功能极其强大的框架。通过使用类装入器，Java 应用程序能使用多个版本的类，否则这些类就会引起冲突。类装入器的灵活性甚至允许动态地重新装入已修改的代码，同时应用程序继续执行。这里，Java 平台灵活性在某种程度上是以启动应用程序时较高的开销作为代价的。在 JVM 可以开始执行甚至最简单的应用程序代码之前，它都必须装入数百个独立的类。相对于频繁使用的小程序，这个启动成本通常使 Java 平台更适合于长时间运行的服务器类型的应用程序。服务器应用程序还最大程度地受益于代码在运行时进行组装这种灵活性，所以对于这种开发，Java 平台正日益受宠也就不足为奇了。在本系列文章的第 2 部分中，我将介绍使用 Java 平台动态基础的另一个方面：反射 API（Reflection API）。反射使执行代码能够访问内部类信息。这可能是构建灵活代码的极佳工具，可以不使用类之间任何源代码链接就能够在运行时将代码挂接在一起。但象使用大多数工具一样，您必须知道何时及如何使用它以获得最大利益。请阅读 Java 编程的动态性 第 2 部分以了解有效反射的诀窍和利弊。相关主题您可以参阅本文在 developerWorks 全球站点上的 英文原文.直接到 The Java Virtual Machine Specification 的出处，以了解二进制类格式、类的装入以及实际的 Java 字节码等细节。Martyn Honeyford 广受欢迎的"衡量 Java 本机编译"一文（developerWorks，2002 年 1 月）提供了有关 Java 语言本机代码编译问题及利弊的更多详细信息。二进制类格式包含大量重要的信息，通常这些信息甚至足够让您重新构造源代码（注释除外）。在 Greg Travis 的"How to lock down your Java code (or open up someone else's.html)"一文（developerWorks，2001 年 5 月）中，他向您显示了可以如何使用这些信息。获取有关 Jikes Research Virtual Machine (RVM.html) 的细节，它是用 Java 语言实现的，并是自我托管的（即，它的 Java 代码是依靠自身运行的，不需要另一个虚拟机）。通过 Java 规范请求 175（Java Specification Request 175，JSR 175）的 A Metadata Facility for the Java Programming Language，紧跟使属性可用于 Java 开发人员的发展。了解 Apache Software Foundation 的 Apache Tomcat Java 语言 Web 服务器项目的细节，包括 Tomcat 类装入器用法 的细节。说明本文基于较早期的 Java 版本（如 J2SE 1.4）及当时的技术环境（如 Tomcat 4.1）编写。文中提到的类装入器层次结构、JVM 内部细节及性能优化机制在现代 JVM（如 Java 8+）中可能有所演进，但核心概念依然适用。部分外部链接可能因年代久远而失效。
深度剖析Java内存管理：泄漏、溢出与优化
一、引言在 Java 编程领域，内存管理犹如大厦的基石，直接关系到应用程序的稳定性、性能和可扩展性。尽管 Java 拥有自动内存管理机制（垃圾回收器，Garbage Collector），但内存泄漏（Memory Leak）和内存溢出（OutOfMemoryError）问题仍然如影随形，困扰着许多开发者，给应用程序带来诸多隐患。内存泄漏就像一个隐藏在程序深处的“黑洞”，悄无声息地吞噬着内存资源。它指的是那些不再被应用程序使用的对象，却因为某些原因未能被垃圾回收器识别并回收，导致内存空间被白白占用。随着时间的推移，这些泄漏的内存逐渐积累，最终可能引发内存溢出，使程序陷入崩溃的边缘。内存溢出则是内存泄漏的“恶果”，当应用程序所需的内存超出了 JVM 所能提供的最大内存时，就会出现这种情况，导致程序无法正常运行，给用户带来极差的体验。二、Java 内存泄漏的深度解析（一）内存泄漏的概念内存泄漏的核心在于对象的生命周期管理失控。在 Java 中，对象通过引用被访问和操作。当一个对象不再被程序逻辑所需要，但仍然存在引用指向它时，垃圾回收器就无法将其回收，从而造成内存泄漏。这就好比一个房间里堆满了无用的杂物，但由于某些原因，这些杂物始终无法被清理出去，最终导致房间越来越拥挤，可用空间越来越小。（二）引发内存泄漏的常见场景1. 静态集合类的陷阱静态集合类（如 HashMap、ArrayList 等）在应用程序中广泛使用。如果将对象添加到静态集合中后，没有在合适的时机将其移除，那么即使该对象不再被使用，它也会一直存在于集合中，无法被回收。例如，在一个 Web 应用中，如果将用户会话信息存储在静态的 HashMap 中，而在用户注销时没有正确删除对应的会话对象，那么随着用户的不断登录和注销，HashMap 会不断积累无用的会话对象，最终导致内存泄漏。以下是一个简单的示例代码，展示了静态集合类可能导致的内存泄漏问题：import java.util.ArrayList; import java.util.List; public class StaticCollectionMemoryLeak { private static List<Object> staticList = new ArrayList<>(); public static void addObject(Object obj.html) { staticList.add(obj.html); } public static void main(String[] args.html) { for (int i = 0; i < 100000; i++.html) { addObject(new Object(.html)); } // 这里模拟程序运行一段时间后，忘记移除部分对象 staticList.remove(0.html); // 即使调用了 remove 方法，由于静态集合的生命周期与应用程序相同， // 之前添加的大量对象仍然可能无法被垃圾回收，导致内存泄漏 } }2. 监听器和回调的疏忽在 Java 的事件驱动编程中，监听器和回调机制十分常见。如果注册了监听器或回调，但在不再需要时没有及时注销，就会导致相关对象无法被回收。例如，在一个图形用户界面（GUI）应用中，为按钮添加了点击事件监听器。当界面关闭时，如果没有正确注销该监听器，那么即使界面已经不存在，监听器对象及其引用的相关资源仍然会留在内存中。以下是一个简单的 Swing 应用示例，展示了监听器未注销可能导致的内存泄漏问题：import javax.swing.JButton; import javax.swing.JFrame; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; public class ListenerMemoryLeak { public static void main(String[] args.html) { JFrame frame = new JFrame("Listener Memory Leak Example".html); JButton button = new JButton("Click Me".html); button.addActionListener(new ActionListener(.html) { @Override public void actionPerformed(ActionEvent e.html) { System.out.println("Button clicked!".html); } }); frame.add(button.html); frame.pack(); frame.setVisible(true.html); // 这里模拟界面关闭，但未注销监听器 frame.dispose(); // 由于监听器未被注销，它所引用的对象（包括匿名内部类实例）无法被垃圾回收，导致内存泄漏 } }3. 资源未关闭的隐患当程序打开文件、数据库连接、网络连接等外部资源时，如果在使用完毕后没有及时关闭，不仅会造成资源泄漏，还可能导致内存泄漏。因为这些资源对象通常会占用一定的内存空间，并且可能持有对其他对象的引用，使得相关对象无法被回收。例如，在一个数据库操作的程序中，如果忘记关闭数据库连接，那么连接对象及其相关的资源（如 Statement、ResultSet 等）将一直占用内存，直到程序结束。以下是一个简单的 JDBC 操作示例，展示了数据库连接未关闭可能导致的内存泄漏问题：import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.sql.Statement; public class DatabaseConnectionMemoryLeak { public static void main(String[] args.html) { Connection connection = null; try { connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "username", "password".html); Statement statement = connection.createStatement(); // 执行一些数据库操作 statement.executeUpdate("INSERT INTO users (name, age.html) VALUES ('John', 30.html)"); // 这里模拟忘记关闭连接和语句对象 } catch (SQLException e.html) { e.printStackTrace(); } // 由于连接未关闭，连接对象及其相关资源无法被垃圾回收，导致内存泄漏 } }4. 内部类的潜在风险内部类持有对外部类实例的隐式引用。如果内部类的生命周期比外部类长，并且内部类对象无法被回收，那么外部类对象也会被间接持有，无法被垃圾回收。例如，在一个多线程应用中，如果在主线程中创建了一个内部类对象，并将其提交到线程池中执行，而线程池中的线程一直运行，那么内部类对象及其引用的外部类对象将一直存在于内存中，即使主线程已经结束。以下是一个简单的多线程示例，展示了内部类可能导致的内存泄漏问题：import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class InnerClassMemoryLeak { public static void main(String[] args.html) { ExecutorService executorService = Executors.newFixedThreadPool(1.html); OuterClass outerClass = new OuterClass(); executorService.submit(new OuterClass.InnerClass(outerClass.html)); // 这里模拟主线程结束，但线程池中的线程仍在运行 outerClass = null; // 由于内部类对象在线程池中执行，它持有对外部类对象的引用，即使将外部类对象设置为 null， // 外部类对象也无法被垃圾回收，导致内存泄漏 } } class OuterClass { private int value = 10; class InnerClass implements Runnable { private OuterClass outer; InnerClass(OuterClass outer.html) { this.outer = outer; } @Override public void run() { while (true.html) { System.out.println(outer.value); try { Thread.sleep(1000.html); } catch (InterruptedException e.html) { e.printStackTrace(); } } } } }（三）内存泄漏的检测工具与方法1. JProfiler 的强大功能JProfiler 是一款功能强大的 Java 性能分析工具，它提供了丰富的功能来检测内存泄漏。通过实时监控内存使用情况，它可以帮助开发者快速定位内存泄漏的根源。它能够详细展示内存中对象的分布、引用关系以及对象的创建和销毁过程，让开发者清晰地了解内存的使用状况。例如，在一个复杂的企业级应用中，JProfiler 可以帮助开发者找出那些长时间存活且占用大量内存的对象，以及它们的引用路径，从而确定是否存在内存泄漏。以下是使用 JProfiler 检测内存泄漏的一般步骤：启动 JProfiler 并配置要分析的 Java 应用程序。在应用程序运行过程中，JProfiler 会实时收集内存使用数据。通过查看内存视图，如对象分配图、引用树等，分析内存使用情况，查找可能存在内存泄漏的对象。根据分析结果，定位到导致内存泄漏的代码位置，并进行修复。2. VisualVM 的便捷使用VisualVM 是 JDK 自带的一款性能分析工具，它集成了多种功能，方便开发者对 Java 应用程序进行监控和分析。在检测内存泄漏方面，VisualVM 可以提供实时的堆内存使用情况、类加载信息等。它还支持生成堆转储文件（Heap Dump），以便在应用程序出现问题后进行离线分析。例如，在开发一个 Web 应用时，VisualVM 可以帮助开发者监控应用的内存使用趋势，当发现内存持续增长且无法释放时，可以生成堆转储文件，进一步分析是否存在内存泄漏。以下是使用 VisualVM 检测内存泄漏的基本流程：启动 Java 应用程序后，打开 VisualVM。在 VisualVM 中找到要分析的应用程序进程，连接到该进程。选择“监视”选项卡，查看实时的内存使用情况，如堆内存使用量、已加载的类数量等。如果怀疑存在内存泄漏，可以点击“堆 Dump"按钮生成堆转储文件。使用 VisualVM 的分析功能，如“类”视图、“实例”视图等，分析堆转储文件，查找内存泄漏的线索。3. MAT 的深入分析Eclipse Memory Analyzer Tool（MAT）是一款专门用于分析 Java 堆转储文件的强大工具。它可以帮助开发者深入了解堆内存中的对象布局、引用关系以及对象的内存占用情况。MAT 能够自动检测内存泄漏的嫌疑点，并提供详细的报告和分析结果。例如，在分析一个大型的分布式系统的内存问题时，MAT 可以快速定位到那些占用大量内存且无法被回收的对象，通过分析对象的引用链，找出导致内存泄漏的原因。以下是使用 MAT 分析内存泄漏的典型步骤：获取 Java 应用程序的堆转储文件（可以使用 JProfiler、VisualVM 等工具生成）。打开 MAT，选择"File" -> "Open Heap Dump"，加载堆转储文件。MAT 会自动分析堆转储文件，并提供一系列的分析报告和视图。在"Leak Suspects"报告中，查看可能存在内存泄漏的嫌疑点，分析相关对象的引用关系和内存占用情况。根据分析结果，深入研究对象的引用链，找到导致内存泄漏的根源，并进行修复。（四）内存泄漏的修复策略1. 及时释放资源在使用完文件、数据库连接、网络连接等资源后，务必及时关闭。可以使用 try-finally 语句块来确保资源的正确关闭，无论程序是否发生异常。例如：import java.io.File; import java.io.FileInputStream; import java.io.IOException; public class ResourceRelease { public static void main(String[] args.html) { FileInputStream fis = null; try { File file = new File("test.txt".html); fis = new FileInputStream(file.html); // 读取文件内容 byte[] buffer = new byte[(int.html) file.length()]; fis.read(buffer.html); System.out.println(new String(buffer.html)); } catch (IOException e.html) { e.printStackTrace(); } finally { if (fis != null.html) { try { fis.close(); } catch (IOException e.html) { e.printStackTrace(); } } } } }2. 正确管理集合类避免在静态集合中存储不必要的对象，或者在对象不再使用时及时从集合中移除。同时，注意集合的容量管理，避免过度分配内存。例如，在使用 ArrayList 时，如果预知集合的大致大小，可以在创建时指定初始容量，以减少动态扩容带来的性能开销和内存浪费。import java.util.ArrayList; import java.util.List; public class CollectionManagement { public static void main(String[] args.html) { List<Object> list = new ArrayList<>(100.html); // 指定初始容量为 100 for (int i = 0; i < 50; i++.html) { list.add(new Object(.html)); } // 当对象不再需要时，及时从集合中移除 list.clear(); } }3. 注销监听器和回调在对象不再需要接收事件通知或回调时，务必注销相应的监听器或回调方法。例如，在一个 Android 应用中，当一个 Activity 被销毁时，应该注销所有在该 Activity 中注册的监听器，以防止内存泄漏。import android.app.Activity; import android.os.Bundle; import android.view.View; import android.widget.Button; public class ListenerUnregistration extends Activity { private Button button; @Override protected void onCreate(Bundle savedInstanceState.html) { super.onCreate(savedInstanceState.html); setContentView(R.layout.activity_main.html); button = findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener(.html) { @Override public void onClick(View v.html) { // 处理按钮点击事件 } }); } @Override protected void onDestroy() { super.onDestroy(); button.setOnClickListener(null.html); // 注销监听器 } }4. 优化内部类使用如果内部类的生命周期可能比外部类长，考虑将内部类改为静态内部类，并避免在内部类中持有外部类的非静态引用。如果需要访问外部类的成员，可以通过构造函数传递必要的参数。例如：import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class InnerClassOptimization { private int value = 10; public static void main(String[] args.html) { ExecutorService executorService = Executors.newFixedThreadPool(1.html); OuterClass outerClass = new OuterClass(); executorService.submit(new OuterClass.StaticInnerClass(outerClass.value)); outerClass = null; } } class OuterClass { private int value = 10; static class StaticInnerClass implements Runnable { private int outerValue; StaticInnerClass(int outerValue.html) { this.outerValue = outerValue; } @Override public void run() { while (true.html) { System.out.println(outerValue.html); try { Thread.sleep(1000.html); } catch (InterruptedException e.html) { e.printStackTrace(); } } } } }三、Java 内存溢出的全面解读（一）内存溢出的类型1. 堆溢出（OutOfMemoryError: Java heap space）堆是 Java 程序中用于存储对象实例的区域。当创建的对象数量过多，或者对象占用的内存过大，导致堆内存无法满足需求时，就会发生堆溢出。这种情况通常发生在程序中存在大量临时对象或者缓存数据没有及时清理的情况下。例如，在一个处理大数据集的程序中，如果一次性将所有数据加载到内存中，而不考虑内存的限制，就很容易引发堆溢出。2. 永久代溢出（OutOfMemoryError: PermGen space）永久代主要用于存储类的元数据信息，如类名、方法名、字段名等。在 Java 8 之前，如果应用程序加载了大量的类，或者动态生成了大量的代理类，可能会导致永久代溢出。例如，在一个使用了大量框架和库的 Web 应用中，由于框架会动态生成很多类，可能会超出永久代的内存限制。需要注意的是，Java 8 中已经将永久代移除，取而代之的是元空间（Metaspace），元空间使用本地内存，其内存管理方式与永久代有所不同。3. 栈溢出（StackOverflowError）栈用于存储方法调用的栈帧，每个方法调用都会在栈上创建一个栈帧，用于存储局部变量、方法参数、返回地址等信息。如果方法调用的层级过深，或者递归调用没有正确的终止条件，就会导致栈溢出。例如，以下递归方法会导致栈溢出：public class StackOverflowExample { public static void recursiveMethod() { recursiveMethod(); } public static void main(String[] args.html) { recursiveMethod(); } }（二）内存溢出的原因分析1. 内存需求超出预期在设计和开发程序时，如果对数据量和内存需求估计不足，可能会导致内存溢出。例如，在开发一个图像处理程序时，如果没有考虑到处理高清图像可能需要大量的内存，而直接将图像数据全部加载到内存中进行处理，就容易引发内存溢出。2. 内存泄漏的累积效应如前所述，内存泄漏会导致内存资源逐渐被占用，最终可能引发内存溢出。即使单个内存泄漏的对象占用的内存较小，但随着时间的推移，泄漏的对象越来越多，就会消耗大量的内存，使得可用内存越来越少，直到无法满足程序的正常需求。3. 不合理的内存配置JVM 的内存配置参数（如 -Xms、-Xmx、-XX:PermSize、-XX:MaxPermSize 等）设置不合理也可能导致内存溢出。如果初始堆内存设置过小，程序在运行过程中可能很快就会耗尽内存；如果最大堆内存设置过小，当程序需要更多内存时无法扩展，也会导致内存溢出。例如，在一个需要处理大量并发请求的 Web 应用中，如果将最大堆内存设置得过小，在高并发情况下就容易出现内存溢出。（三）内存溢出的解决方案1. 优化算法和数据结构选择更高效、更节省内存的算法和数据结构是应对内存溢出的关键一招。例如，处理海量数据时，原本使用数组存储数据可能因连续内存空间需求大而致溢出，此时若换成链表，它动态分配内存的特性，便能灵活存储数据，大幅削减内存压力；又或是在频繁查找场景里，摒弃普通数组改用哈希表，查找效率可从线性提至常数级，还能避免不必要内存占用。拿电商订单数据处理举例，若要统计各地区订单数量，起初用简单数组存订单，遍历查找对应地区订单极为耗时且占大量内存，代码如下：// 用数组存储订单信息，假设 Order 类包含地区信息等属性 Order[] orders = new Order[100000]; // 模拟填充订单数据 for (int i = 0; i < orders.length; i++.html) { orders[i] = new Order("地区" + i % 10.html); } // 统计各地区订单数量，低效且耗内存 int[] regionCount = new int[10]; for (Order order : orders.html) { int regionIndex = Integer.parseInt(order.getRegion(.html).substring(2.html)); regionCount[regionIndex]++; }优化为使用 HashMap，内存利用更合理，效率飞升：import java.util.HashMap; import java.util.Map; // 使用 HashMap 统计订单数量 Map<String, Integer> regionCountMap = new HashMap<>(); for (Order order : orders.html) { String region = order.getRegion(); regionCountMap.put(region, regionCountMap.getOrDefault(region, 0.html) + 1); }2. 调整 JVM 内存参数精准调整 JVM 内存参数是治内存溢出“硬伤”的良方。-Xms 设定初始堆大小，-Xmx 限制最大堆大小。开发测试环境里，遇堆溢出，适度上调 -Xmx，给程序充足“施展空间”；生产环境则需权衡硬件资源、并发量谨慎配置。以 Tomcat 部署的 Web 应用为例，启动脚本里设参数：java -Xms512m -Xmx2g -jar myapp.jar，初始给 512M 堆内存，上限 2G，防程序初始内存浪费，也撑住高峰负载。但参数调整非万能，还得结合代码优化，不然只是延缓而非根治问题。3. 采用缓存策略缓存是把“双刃剑”，用好能降内存开销、提程序性能。像频繁查询数据库场景，引入本地缓存（如 Guava Cache）存常用数据，减少数据库查询，节省内存。可缓存管理不当，数据过期不清理、无限制存储，反而成内存溢出“帮凶”。示例代码展示 Guava Cache 使用：import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import java.util.concurrent.TimeUnit; // 创建缓存，有效期 5 分钟，最大存 1000 条数据 Cache<String, Object> dataCache = CacheBuilder.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .maximumSize(1000.html) .build(); // 存入缓存 dataCache.put("key", "value".html); // 从缓存读取 Object cachedValue = dataCache.getIfPresent("key".html);缓存要依数据访问频率、时效性等特性精细规划，定期清理过期数据、限制容量，让其助力而非拖累系统。4. 数据分批处理数据“暴饮暴食”易撑爆内存，分批处理是“细嚼慢咽”的巧法。大数据集读写、网络数据批量传输场景，化整为零分批操作，降低内存峰值。像读取超大文件，别一股脑全读入内存，按行或固定字节数分批读：import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; public class FileBatchRead { public static void main(String[] args.html) { try (BufferedReader reader = new BufferedReader(new FileReader("largefile.txt".html))) { String line; while ((line = reader.readLine(.html)) != null) { // 对每行数据处理，如解析、存储等，不占大量内存 System.out.println(line.html); } } catch (IOException e.html) { e.printStackTrace(); } } }网络数据接收也同理，限定每次接收量，处理完再收下一批，稳守内存防线。四、Java 内存管理优化实战（一）优化案例剖析曾接手一企业级报表生成系统，高峰时内存溢出频发致服务瘫痪。剖析发现，代码从数据库捞海量数据存 ArrayList，没分页、筛选，还多处静态集合囤数据，垃圾回收器难清理。优化先改数据查询逻辑，按页取数，用完即清；再将静态集合按需清理、复用；关键函数内复杂对象生命周期精控，及时断引用，让垃圾回收。经此番“瘦身”，内存占用锐减，系统稳过高峰。（二）性能监控与持续优化优化非一劳永逸，得靠性能监控“盯梢”。工具如 New Relic、AppDynamics 实时绘内存、CPU 等指标曲线。据监控数据，定期复盘代码，深挖潜在问题，迭代优化。像发现某接口响应慢、内存攀升，深挖是缓存未命中频繁查库，调整缓存策略、优化查询语句后，性能、内存双优。持续优化融入开发流程，才能让 Java 程序内存管理“长治久安”。五、总结Java 内存管理是贯穿开发全程的核心课题，内存泄漏与溢出是悬在程序头顶的“达摩克利斯之剑”。从精准揪出内存泄漏“元凶”，到多维度化解内存溢出危机；从巧用工具监控诊断，到实战里打磨优化策略，步步为营，才能筑牢程序内存防线。开发者要代码层严谨把控，又善用工具赋能，还秉持持续优化理念，让 Java 程序在复杂业务场景稳健飞驰，以高效内存管理夯实高性能、高可靠根基，于数字化浪潮踏浪前行。未来，Java 内存管理技术仍会随硬件革新、业务升级不断演进。新垃圾回收算法、智能内存分配策略将破茧，助力开发者驯服内存这头“猛兽”，解锁更多复杂应用开发可能，为 Java 生态注入澎湃活力。愿各位开发者吃透本文精髓，在内存管理战场游刃有余，打造更多精品 Java 应用！说明：本文涉及的技术概念主要基于 Java 8 及以上版本，其中关于永久代（PermGen）的描述适用于 Java 7 及更早版本，Java 8 已改用元空间（Metaspace）。在实际生产中，请根据具体使用的 JDK 版本调整内存参数配置。
前言本文承接【Java 并发之 synchronized 关键字深度解析（一）】，着重介绍 synchronized 几种锁的特性及其底层实现原理。一、对象头结构及锁状态标识synchronized 关键字是如何实现给对象加锁的？首先我们需要了解 Java 中对象的组成。Java 对象在内存中主要由三部分组成：对象头（Object Header）、实例数据（Instance Data）和对齐填充（Padding）。对齐填充：JVM 规定对象的起始内存地址必须是 8 字节的整数倍，如果不够则用占位符填充，此部分即为对齐填充。实例数据：对象存储的真正有效信息，即对象的成员变量信息（包括继承自父类的）。对象头：由两部分组成。第一部分是对象的运行时数据（Mark Word），包括哈希码、锁偏向标识、锁类型、GC 分代年龄、偏向线程 ID 等；第二部分是对象的类型指针（Klass Word），用于在堆中定位对象的实例数据和方法区中的类型数据。Java 对象的公共特性都存放在对象头中。对象头存储内容如下所示（以 64 位操作系统为例）：|--------------------------------------------------------------------------------------------------------------| | Object Header (128 bits.html) | |--------------------------------------------------------------------------------------------------------------| | Mark Word (64 bits.html) | Klass Word (64 bits.html) | |--------------------------------------------------------------------------------------------------------------| | unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 无锁 |----------------------------------------------------------------------|--------|------------------------------| | thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 偏向锁 |----------------------------------------------------------------------|--------|------------------------------| | ptr_to_lock_record:62 | lock:2 | OOP to metadata object | 轻量锁 |----------------------------------------------------------------------|--------|------------------------------| | ptr_to_heavyweight_monitor:62 | lock:2 | OOP to metadata object | 重量锁 |----------------------------------------------------------------------|--------|------------------------------| | | lock:2 | OOP to metadata object | GC |--------------------------------------------------------------------------------------------------------------|其中 lock:2 表示有 2 bit 控制锁类型，biased_lock:1 表示 1 bit 控制偏向锁状态，对应关系如下所示：01：无锁（前面偏向锁状态为 0 时表示未锁定）01：可偏向（前面偏向锁状态为 1 时表示可偏向）00：轻量级锁10：重量级锁11：GC 标记看到前两种状态时读者可能会有些疑惑。JVM 的设计者想用 01 状态来表示两种情况（无锁和可偏向），但显然一个字符无法标识两种状态。因此，他们将前面一位暂时用不到的 bit 纳入进来，用前一位的值是 0 还是 1 来区分是无锁还是可偏向。二、锁的信息打印下面我们通过代码验证这几种锁的存在。JVM 默认开启偏向锁，默认的偏向锁启动时间为 4-5 秒后。因此，先让主线程睡眠 5 秒再加锁能保证对象处于偏向锁的状态。此外，也可以在 VM Options 中添加参数 -XX:BiasedLockingStartupDelay=0 来让 JVM 取消延迟启动偏向锁（本文的示例均未设置此参数），其效果跟不改变 VM Options 只在 main 方法中让主线程先睡眠 5 秒是一样的。此外，要打印对象存储空间需要引入 OpenJDK 的 jol-core 包依赖：<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>RELEASE</version> </dependency>User 对象代码：public class LockClient { static class User { public String name; public byte age; } }万事俱备，下面开始测试。1. 无锁状态先不睡眠五秒，此时偏向锁未开启，所以对象都是无锁状态（未加 synchronized 的情况下）。打印无锁状态的对象（锁标识 001）：@Test public void noLock() { User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }输出结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total下面我们来解读一下这个打印结果。通过 TYPE DESCRIPTION 可以知道，前三行打印的是对象头（object header），那么后面四行就是对象的实例数据和对其填充了。先看第一行，VALUE 中，标红的 001 表示当前对象是无锁状态，前面的 0 对应我们上面讲的可偏向锁状态为非偏向锁（如果是 1 表示偏向锁）。第三行存放的是对象指针。第四行和第六行存放的是对象的两个成员变量，第五行空间用于填充 age 变量；第七行就是我们所说的对齐填充，使对象内存空间凑齐 8 字节的整数倍。2. 偏向锁状态加上睡眠 5 秒：@Test public void biasedLocking() { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }看看打印结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 00 00 00 (00000101 00000000 00000000 00000000.html) (5.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到，锁状态为 101 可偏向锁状态了，只是由于未用 synchronized 加锁，所以线程 ID 是空的。其余数据跟上述无锁状态一样。偏向锁带线程 ID 情况，代码如下：@Test public void synchronizedLocking() { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } System.out.println(Thread.currentThread(.html).getId()); // System.out.println(Integer.toBinaryString(System.identityHashCode(Thread.currentThread(.html)))); User user = new User(); synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }输出结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 d8 00 fc (00000101 11011000 00000000 11111100.html) (-67053563.html) 4 4 (object header.html) 0e 7f 00 00 (00001110 01111111 00000000 00000000.html) (32526.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可见第一行中后面不再是 0 了，有了线程 ID 的值。3. 轻量级锁状态再看看轻量锁，不睡眠 5 秒，直接用 synchronized 给对象加锁，此时触发的就是轻量锁。代码如下：@Test public void lightWeightLock() { System.out.println(Integer.toBinaryString(System.identityHashCode(Thread.currentThread(.html)))); User user = new User(); synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }打印结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) e0 59 8b 34 (11100000 01011001 10001011 00110100.html) (881547744.html) 4 4 (object header.html) 77 7f 00 00 (01110111 01111111 00000000 00000000.html) (32631.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到锁的标识位为 000，轻量级锁。4. 重量级锁状态最后看一下重量级锁，只有在锁竞争的时候才会变为重量级锁，代码如下：@Test public void heavyWeightLock() { User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); Thread t1 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(5000.html);// 睡眠，创造竞争条件 } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t1.start(); Thread t2 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(1000.html); } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t2.start(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }输出结果为：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 6a 62 00 50 (01101010 01100010 00000000 01010000.html) (1342202474.html) 4 4 (object header.html) 74 7f 00 00 (01110100 01111111 00000000 00000000.html) (32628.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到锁状态为 010，重量级锁。5. 调用 hashCode 会取消偏向此外，如果通过 Object 对象的本地 hashCode 方法来获取对象的 hashCode 值，会使对象取消偏向锁状态。@Test public void cancelBiasedLocking() { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); System.out.println(user.hashCode(.html)); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }打印结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 00 00 00 (00000101 00000000 00000000 00000000.html) (5.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total 1644443712 com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 40 38 04 (00000001 01000000 00111000 00000100.html) (70795265.html) 4 4 (object header.html) 62 00 00 00 (01100010 00000000 00000000 00000000.html) (98.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) e0 29 99 e8 (11100000 00101001 10011001 11101000.html) (-392615456.html) 4 4 (object header.html) a8 7f 00 00 (10101000 01111111 00000000 00000000.html) (32680.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到，计算完对象的 hashCode 之后，该对象立即从偏向锁状态变为了无锁状态。即使后续给对象加锁，该对象也只会进入轻量级或者重量级锁状态，不会再进入偏向状态了。因为该对象一旦进行 Object 的 hashCode 计算，那么对象头中会保存这个 hashCode，此时再也无法存放偏向线程的 ID 了（因为对象头的长度无法同时存放 hashCode 和偏向线程 ID），所以此后该对象无法再进入偏向锁状态。三、锁膨胀过程到这里，我们一起看完了 synchronized 给对象加的各种锁状态以及触发场景，下面我们梳理一下它们之间的关系。JVM 启动后会默认开启偏向锁（默认 4-5 秒后开启），开启后，所有新建对象的对象头中都标识为 101 可偏向状态，且偏向线程 ID 为 0，表示处于初始化的偏向锁状态。此后一旦有线程对该对象使用了 synchronized 加锁，那么就会进入偏向锁状态，偏向线程 ID 记录当前线程 ID；如果走完同步块之后，有另一个线程对该对象加锁，那么膨胀为轻量级锁；如果未走完同步块就有另一个线程试图给该对象加锁，那么会直接膨胀为重量级锁（中间会有一个自旋锁的过程，此处略去）。1. 开启偏向锁开启偏向的锁膨胀草图下面演示一下对象从偏向锁膨胀为轻量级锁的过程：@Test public void biasedLockToLightWeightLock() throws InterruptedException { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } User user = new User(); Thread t1 = new Thread((.html) -> { synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }); t1.start(); t1.join(); // 确保 t1 执行完了再执行当前主线程 synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }打印结果如下，可以看到 user 对象先是偏向锁，然后变为轻量级锁，最后走完同步块释放锁变为无锁状态。com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 38 3a 1c (00000101 00111000 00111010 00011100.html) (473577477.html) 4 4 (object header.html) 7d 7f 00 00 (01111101 01111111 00000000 00000000.html) (32637.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) d8 a9 de 25 (11011000 10101001 11011110 00100101.html) (635349464.html) 4 4 (object header.html) 7d 7f 00 00 (01111101 01111111 00000000 00000000.html) (32637.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total2. 关闭偏向锁如果通过参数设置 JVM 不开启偏向锁，那么新创建的对象是 001 无锁状态，遇到 synchronized 同步块会变为轻量级锁，遇到锁竞争变为重量级锁。@Test public void lightWeightToheavyWeightLock() { //-XX:-UseBiasedLocking 关闭偏向锁 User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); Thread t1 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(5000.html);// 睡眠，创造竞争条件 } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t1.start(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); Thread t2 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(1000.html); } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t2.start(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 20 e9 ff 17 (00100000 11101001 11111111 00010111.html) (402647328.html) 4 4 (object header.html) 8e 7f 00 00 (10001110 01111111 00000000 00000000.html) (32654.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 1a 63 00 40 (00011010 01100011 00000000 01000000.html) (1073767194.html) 4 4 (object header.html) 8e 7f 00 00 (10001110 01111111 00000000 00000000.html) (32654.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total关闭偏向的锁膨胀草图四、重量级锁原理Java 中 synchronized 的重量级锁，是基于进入和退出 Monitor 对象实现的。在编译时会将同步块的开始位置插入 monitorenter 指令，在结束位置插入 monitorexit 指令。当线程执行到 monitorenter 指令时，会尝试获取对象所对应的 Monitor 所有权，如果获取到了，即获取到了锁，会在 Monitor 的 owner 中存放当前线程的 ID，这样它将处于锁定状态，除非退出同步块，否则其他线程无法获取到这个 Monitor。测试代码：https://github.com/daichangya/Dtutorials/tree/main/jvm/src/main/java/com/daicy/jvm说明：本文内容基于 HotSpot JVM 早期版本（Java 14 及之前），其中偏向锁（Biased Locking）在 Java 15 中被 deprecated，并在 Java 16 中正式移除。在新版本 JVM 中，锁升级过程可能不再包含偏向锁阶段。
使用 MAT 时的 Shallow Size 和 Retained Size 的区别编者注：本文为历史博文归档；涉及 JDK、框架与工具链版本请以当前官方文档为准。引用外链图片可能失效，阅读时请注意时效性。所有具备 Heap Profiling（堆分析）功能的工具（如 MAT、Yourkit、JProfiler、TPTP 等）都会涉及两个核心概念：Shallow Size 和 Retained Size。这两个概念在日常开发中并不常见，但它们对于理解内存占用至关重要。本文将对这两个名词进行详细解释。Shallow SizeShallow Size 指的是对象自身占用的内存大小，不包括它引用的其他对象。非数组类型对象：大小等于对象头与它所有成员变量大小的总和。这其中还包括一些 Java 语言特性的数据存储单元（如对象头信息）。数组类型对象：大小是数组元素对象的大小总和。Retained SizeRetained Size 的计算公式如下：Retained Size = 当前对象大小 + 当前对象可直接或间接引用到的对象的大小总和间接引用的含义：若存在引用链 A -> B -> C，则 C 是 A 的间接引用对象。实际意义：Retained Size 代表当前对象被 GC（垃圾回收）后，从 Heap（堆）上总共能释放掉的内存。注意：在计算释放内存时，需要排除被 GC Roots 直接或间接引用的对象，因为这些对象暂时不会被当做 Garbage（垃圾）回收。图解 Retained Size通过下图可以更直观地理解 Retained Size 的计算逻辑。在上图中，GC Roots 直接引用了 A 和 B 两个对象：A 对象的 Retained Size = A 对象的 Shallow SizeB 对象的 Retained Size = B 对象的 Shallow Size + C 对象的 Shallow Size这里不包括 D 对象，因为 D 对象被 GC Roots 直接引用，即使 B 被回收，D 依然存在。如果 GC Roots 不引用 D 对象呢？此时，引用关系发生变化：B 对象的 Retained Size = B 对象的 Shallow Size + C 对象的 Shallow Size + D 对象的 Shallow Size参考资料转载自：http://kenwublog.com/understand-shallow-and-retained-size-in-hprofling
背景知识在介绍双亲委派机制之前，首先需要了解 ClassLoader（类加载器） 的相关概念。Java 程序运行在 Java 虚拟机（JVM）之中。我们在 IDE 中编写的 Java 源代码被编译器编译成 .class 字节码文件，随后由 ClassLoader 负责将这些 class 文件加载到 JVM 中执行。JVM 中主要提供了三层 ClassLoader：Bootstrap ClassLoader：主要负责加载核心类库（如 java.lang.* 等），并负责构造 ExtClassLoader 和 AppClassLoader。ExtClassLoader：主要负责加载 jre/lib/ext 目录下的一些扩展 jar 包。AppClassLoader：主要负责加载应用程序的主函数类及classpath 下的类。那么，一个由 Hello.java 编译成的 Hello.class 文件，究竟是如何被加载到 JVM 中的呢？双亲委派机制详解查看 JDK 源码中 java.lang 包下的 ClassLoader 类，重点关注其 loadClass 方法，核心逻辑如下：public Class<?> loadClass(String name.html) throws ClassNotFoundException { return loadClass(name, false.html); } // ----- 核心逻辑 ----- protected Class<?> loadClass(String name, boolean resolve.html) throws ClassNotFoundException { // 首先，检查是否已经被类加载器加载过 Class<?> c = findLoadedClass(name.html); if (c == null.html) { try { // 存在父加载器，递归地交由父加载器处理 if (parent != null.html) { c = parent.loadClass(name, false.html); } else { // 直到最上面的 Bootstrap 类加载器 c = findBootstrapClassOrNull(name.html); } } catch (ClassNotFoundException e.html) { // 如果父加载器无法找到该类，则捕获异常 // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null.html) { // 如果仍未找到，则调用 findClass 方法尝试自己加载 c = findClass(name.html); } } return c; }这段代码清晰地解释了双亲委派机制。为了更直观地理解，我们可以通过下图来描述上述代码的执行流程：结合上图与源码，当一个类（如 Hello.class）需要被加载时，流程如下：检查是否已加载：首先会在当前的 ClassLoader（例如 AppClassLoader）中检查该类是否已经被加载过，如果有则无需再次加载。委托父加载器：如果没有加载过，则获取父加载器，调用父加载器的 loadClass 方法。递归向上：父加载器同理，先检查自己是否加载过，如果没有则继续向上委托。这是一个类似递归的过程，直到到达 Bootstrap ClassLoader。向下查找：在到达 Bootstrap ClassLoader 之前，各级加载器都只是检查是否加载过，并不会尝试自己去加载。直到 Bootstrap ClassLoader（它没有父加载器），如果它无法加载，则会下沉到子加载器去尝试加载（调用 findClass），一直到最底层。抛出异常：如果没有任何加载器能加载该类，最终将抛出 ClassNotFoundException。机制设计的意义双亲委派机制的设计主要有以下好处：防止核心类库被篡改。例如，如果有人试图替换系统级别的类（如 String.java）并篡改其实现，在这种机制下，由于系统类已经被 Bootstrap ClassLoader 加载过了（当一个类需要加载时，最先尝试加载的就是 Bootstrap ClassLoader），其他类加载器并没有机会再去加载这些类。这在一定程度上防止了危险代码的植入，保证了 Java 核心 API 的安全性。参考文章https://tushu.info/archives/java-de-classloader-yu-package-ji-zhi.html> 说明：本文基于 Java 8 及更早版本的 ClassLoader 架构（Bootstrap/Ext/App）进行描述。Java 9 引入模块系统后，类加载器层级有所调整（如引入 Platform ClassLoader 等），但双亲委派的核心思想依然适用。
JAVA虚拟机的生命周期 一个运行时的Java虚拟机实例的天职是：负责运行一个java程序。当启动一个Java程序时，一个虚拟机实例也就诞生了。当该程序关闭退出，这个虚拟机实例也就随之消亡。如果同一台计算机上同时运行三个Java程序，将得到三个Java虚拟机实例。每个Java程序都运行于它自己的Java虚拟机实例中。 Java虚拟机实例通过调用某个初始类的main()方法来运行一个Java程序。而这个main()方法必须是共有的(public.html)、静态的(static.html)、返回值为void，并且接受一个字符串数组作为参数。任何拥有这样一个main()方法的类都可以作为Java程序运行的起点。public class Test { public static void main(String[] args.html) { // TODO Auto-generated method stub System.out.println("Hello World".html); } } 在上面的例子中，Java程序初始类中的main()方法，将作为该程序初始线程的起点，任何其他的线程都是由这个初始线程启动的。 在Java虚拟机内部有两种线程：守护线程和非守护线程。守护线程通常是由虚拟机自己使用的，比如执行垃圾收集任务的线程。但是，Java程序也可以把它创建的任何线程标记为守护线程。而Java程序中的初始线程——就是开始于main()的那个，是非守护线程。 只要还有任何非守护线程在运行，那么这个Java程序也在继续运行。当该程序中所有的非守护线程都终止时，虚拟机实例将自动退出。假若安全管理器允许，程序本身也能够通过调用Runtime类或者System类的exit()方法来退出。JAVA虚拟机的体系结构 下图是JAVA虚拟机的结构图，每个Java虚拟机都有一个类装载子系统，它根据给定的全限定名来装入类型（类或接口）。同样，每个Java虚拟机都有一个执行引擎，它负责执行那些包含在被装载类的方法中的指令。 当JAVA虚拟机运行一个程序时，它需要内存来存储许多东西，例如：字节码、从已装载的class文件中得到的其他信息、程序创建的对象、传递给方法的参数，返回值、局部变量等等。Java虚拟机把这些东西都组织到几个“运行时数据区”中，以便于管理。 某些运行时数据区是由程序中所有线程共享的，还有一些则只能由一个线程拥有。每个Java虚拟机实例都有一个方法区以及一个堆，它们是由该虚拟机实例中所有的线程共享的。当虚拟机装载一个class文件时，它会从这个class文件包含的二进制数据中解析类型信息。然后把这些类型信息放到方法区中。当程序运行时，虚拟机会把所有该程序在运行时创建的对象都放到堆中。 当每一个新线程被创建时，它都将得到它自己的PC寄存器（程序计数器）以及一个Java栈，如果线程正在执行的是一个Java方法（非本地方法），那么PC寄存器的值将总是指向下一条将被执行的指令，而它的Java栈则总是存储该线程中Java方法调用的状态——包括它的局部变量，被调用时传进来的参数、返回值，以及运算的中间结果等等。而本地方法调用的状态，则是以某种依赖于具体实现的方法存储在本地方法栈中，也可能是在寄存器或者其他某些与特定实现相关的内存区中。 Java栈是由许多栈帧（stack frame）组成的，一个栈帧包含一个Java方法调用的状态。当线程调用一个Java方法时，虚拟机压入一个新的栈帧到该线程的Java栈中，当该方法返回时，这个栈帧被从Java栈中弹出并抛弃。 Java虚拟机没有寄存器，其指令集使用Java栈来存储中间数据。这样设计的原因是为了保持Java虚拟机的指令集尽量紧凑、同时也便于Java虚拟机在那些只有很少通用寄存器的平台上实现。另外，Java虚拟机这种基于栈的体系结构，也有助于运行时某些虚拟机实现的动态编译器和即时编译器的代码优化。 下图描绘了Java虚拟机为每一个线程创建的内存区，这些内存区域是私有的，任何线程都不能访问另一个线程的PC寄存器或者Java栈。 上图展示了一个虚拟机实例的快照，它有三个线程正在执行。线程1和线程2都正在执行Java方法，而线程3则正在执行一个本地方法。 Java栈都是向下生长的，而栈顶都显示在图的底部。当前正在执行的方法的栈帧则以浅色表示，对于一个正在运行Java方法的线程而言，它的PC寄存器总是指向下一条将被执行的指令。比如线程1和线程2都是以浅色显示的，由于线程3当前正在执行一个本地方法，因此，它的PC寄存器——以深色显示的那个，其值是不确定的。 数据类型 Java虚拟机是通过某些数据类型来执行计算的，数据类型可以分为两种：基本类型和引用类型，基本类型的变量持有原始值，而引用类型的变量持有引用值。 Java语言中的所有基本类型同样也都是Java虚拟机中的基本类型。但是boolean有点特别，虽然Java虚拟机也把boolean看做基本类型，但是指令集对boolean只有很有限的支持，当编译器把Java源代码编译为字节码时，它会用int或者byte来表示boolean。在Java虚拟机中，false是由整数零来表示的，所有非零整数都表示true，涉及boolean值的操作则会使用int。另外，boolean数组是当做byte数组来访问的，但是在“堆”区，它也可以被表示为位域。 Java虚拟机还有一个只在内部使用的基本类型：returnAddress，Java程序员不能使用这个类型，这个基本类型被用来实现Java程序中的finally子句。该类型是jsr, ret以及jsr_w指令需要使用到的，它的值是JVM指令的操作码的指针。returnAddress类型不是简单意义上的数值，不属于任何一种基本类型，并且它的值是不能被运行中的程序所修改的。 Java虚拟机的引用类型被统称为“引用（reference）”，有三种引用类型：类类型、接口类型、以及数组类型，它们的值都是对动态创建对象的引用。类类型的值是对类实例的引用；数组类型的值是对数组对象的引用，在Java虚拟机中，数组是个真正的对象；而接口类型的值，则是对实现了该接口的某个类实例的引用。还有一种特殊的引用值是null，它表示该引用变量没有引用任何对象。 JAVA中方法参数的引用传递 java中参数的传递有两种，分别是按值传递和按引用传递。按值传递不必多说，下面就说一下按引用传递。 “当一个对象被当作参数传递到一个方法”，这就是所谓的按引用传递。public class User { private String name; public String getName() { return name; } public void setName(String name.html) { this.name = name; } }public class Test { public void set(User user.html){ user.setName("hello world".html); } public static void main(String[] args.html) { Test test = new Test(); User user = new User(); test.set(user.html); System.out.println(user.getName(.html)); } } 上面代码的输出结果是“hello world”，这不必多说，那如果将set方法改为如下，结果会是多少呢？ public void set(User user.html){ user.setName("hello world".html); user = new User(); user.setName("change".html); } 答案依然是“hello world”，下面就让我们来分析一下如上代码。 首先User user = new User(); 是在堆中创建了一个对象，并在栈中创建了一个引用，此引用指向该对象，如下图：test.set(user.html); 是将引用user作为参数传递到set方法，注意：这里传递的并不是引用本身，而是一个引用的拷贝。也就是说这时有两个引用（引用和引用的拷贝）同时指向堆中的对象，如下图：user.setName("hello world".html); 在set()方法中，“user引用的拷贝”操作堆中的User对象，给name属性设置字符串"hello world"。如下图：user = new User(); 在set()方法中，又创建了一个User对象，并将“user引用的拷贝”指向这个在堆中新创建的对象，如下图：user.setName("change".html); 在set()方法中，“user引用的拷贝”操作的是堆中新创建的User对象。 set()方法执行完毕，目光再回到mian()方法System.out.println(user.getName(.html)); 因为之前，"user引用的拷贝"已经将堆中的User对象的name属性设置为了"hello world"，所以当main()方法中的user调用getName()时，打印的结果就是"hello world"。如下图： 类装载子系统 在JAVA虚拟机中，负责查找并装载类型的那部分被称为类装载子系统。 JAVA虚拟机有两种类装载器：启动类装载器和用户自定义类装载器。前者是JAVA虚拟机实现的一部分，后者则是Java程序的一部分。由不同的类装载器装载的类将被放在虚拟机内部的不同命名空间中。 类装载器子系统涉及Java虚拟机的其他几个组成部分，以及几个来自java.lang库的类。比如，用户自定义的类装载器是普通的Java对象，它的类必须派生自java.lang.ClassLoader类。ClassLoader中定义的方法为程序提供了访问类装载器机制的接口。此外，对于每一个被装载的类型，JAVA虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。和所有其他对象一样，用户自定义的类装载器以及Class类的实例都放在内存中的堆区，而装载的类型信息则都位于方法区。 类装载器子系统除了要定位和导入二进制class文件外，还必须负责验证被导入类的正确性，为类变量分配并初始化内存，以及帮助解析符号引用。这些动作必须严格按以下顺序进行： （1）装载——查找并装载类型的二进制数据。 （2）连接——指向验证、准备、以及解析（可选）。 ● 验证 确保被导入类型的正确性。 ● 准备 为类变量分配内存，并将其初始化为默认值。 ● 解析 把类型中的符号引用转换为直接引用。 （3）初始化——把类变量初始化为正确初始值。 每个JAVA虚拟机实现都必须有一个启动类装载器，它知道怎么装载受信任的类。 每个类装载器都有自己的命名空间，其中维护着由它装载的类型。所以一个Java程序可以多次装载具有同一个全限定名的多个类型。这样一个类型的全限定名就不足以确定在一个Java虚拟机中的唯一性。因此，当多个类装载器都装载了同名的类型时，为了惟一地标识该类型，还要在类型名称前加上装载该类型（指出它所位于的命名空间）的类装载器标识。 方法区 在Java虚拟机中，关于被装载类型的信息存储在一个逻辑上被称为方法区的内存中。当虚拟机装载某个类型时，它使用类装载器定位相应的class文件，然后读入这个class文件——1个线性二进制数据流，然后它传输到虚拟机中，紧接着虚拟机提取其中的类型信息，并将这些信息存储到方法区。该类型中的类（静态）变量同样也是存储在方法区中。 JAVA虚拟机在内部如何存储类型信息，这是由具体实现的设计者来决定的。 当虚拟机运行Java程序时，它会查找使用存储在方法区中的类型信息。由于所有线程都共享方法区，因此它们对方法区数据的访问必须被设计为是线程安全的。比如，假设同时有两个线程都企图访问一个名为Lava的类，而这个类还没有被装入虚拟机，那么，这时只应该有一个线程去装载它，而另一个线程则只能等待。 对于每个装载的类型，虚拟机都会在方法区中存储以下类型信息： ● 这个类型的全限定名 ● 这个类型的直接超类的全限定名（除非这个类型是java.lang.Object，它没有超类） ● 这个类型是类类型还是接口类型 ● 这个类型的访问修饰符（public、abstract或final的某个子集） ● 任何直接超接口的全限定名的有序列表 除了上面列出的基本类型信息外，虚拟机还得为每个被装载的类型存储以下信息： ● 该类型的常量池 ● 字段信息 ● 方法信息 ● 除了常量以外的所有类（静态）变量 ● 一个到类ClassLoader的引用 ● 一个到Class类的引用常量池 虚拟机必须为每个被装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合，包括直接常量和对其他类型、字段和方法的符号引用。池中的数据项就像数组一样是通过索引访问的。因为常量池存储了相应类型所用到的所有类型、字段和方法的符号引用，所以它在Java程序的动态连接中起着核心的作用。 字段信息 对于类型中声明的每一个字段。方法区中必须保存下面的信息。除此之外，这些字段在类或者接口中的声明顺序也必须保存。 ○ 字段名 ○ 字段的类型 ○ 字段的修饰符（public、private、protected、static、final、volatile、transient的某个子集） 方法信息 对于类型中声明的每一个方法，方法区中必须保存下面的信息。和字段一样，这些方法在类或者接口中的声明顺序也必须保存。 ○ 方法名 ○ 方法的返回类型（或void） ○ 方法参数的数量和类型（按声明顺序） ○ 方法的修饰符（public、private、protected、static、final、synchronized、native、abstract的某个子集） 除了上面清单中列出的条目之外，如果某个方法不是抽象的和本地的，它还必须保存下列信息： ○ 方法的字节码（bytecodes） ○ 操作数栈和该方法的栈帧中的局部变量区的大小 ○ 异常表 类（静态）变量 类变量是由所有类实例共享的，但是即使没有任何类实例，它也可以被访问。这些变量只与类有关——而非类的实例，因此它们总是作为类型信息的一部分而存储在方法区。除了在类中声明的编译时常量外，虚拟机在使用某个类之前，必须在方法区中为这些类变量分配空间。 而编译时常量（就是那些用final声明以及用编译时已知的值初始化的类变量）则和一般的类变量处理方式不同，每个使用编译时常量的类型都会复制它的所有常量到自己的常量池中，或嵌入到它的字节码流中。作为常量池或字节码流的一部分，编译时常量保存在方法区中——就和一般的类变量一样。但是当一般的类变量作为声明它们的类型的一部分数据面保存的时候，编译时常量作为使用它们的类型的一部分而保存。 指向ClassLoader类的引用 每个类型被装载的时候，虚拟机必须跟踪它是由启动类装载器还是由用户自定义类装载器装载的。如果是用户自定义类装载器装载的，那么虚拟机必须在类型信息中存储对该装载器的引用。这是作为方法表中的类型数据的一部分保存的。 虚拟机会在动态连接期间使用这个信息。当某个类型引用另一个类型的时候，虚拟机会请求装载发起引用类型的类装载器来装载被引用的类型。这个动态连接的过程，对于虚拟机分离命名空间的方式也是至关重要的。为了能够正确地执行动态连接以及维护多个命名空间，虚拟机需要在方法表中得知每个类都是由哪个类装载器装载的。 指向Class类的引用 对于每一个被装载的类型（不管是类还是接口），虚拟机都会相应地为它创建一个java.lang.Class类的实例，而且虚拟机还必须以某种方式把这个实例和存储在方法区中的类型数据关联起来。 在Java程序中，你可以得到并使用指向Class对象的引用。Class类中的一个静态方法可以让用户得到任何已装载的类的Class实例的引用。public static Class<?> forName(String className.html) 比如，如果调用forName("java.lang.Object".html)，那么将得到一个代表java.lang.Object的Class对象的引用。可以使用forName()来得到代表任何包中任何类型的Class对象的引用，只要这个类型可以被（或者已经被）装载到当前命名空间中。如果虚拟机无法把请求的类型装载到当前命名空间，那么会抛出ClassNotFoundException异常。 另一个得到Class对象引用的方法是，可以调用任何对象引用的getClass()方法。这个方法被来自Object类本身的所有对象继承：public final native Class<?> getClass(); 比如，如果你有一个到java.lang.Integer类的对象的引用，那么你只需简单地调用Integer对象引用的getClass()方法，就可以得到表示java.lang.Integer类的Class对象。 方法区使用实例 为了展示虚拟机如何使用方法区中的信息，下面来举例说明：class Lava { private int speed = 5; void flow(){ } }public class Volcano { public static void main(String[] args.html){ Lava lava = new Lava(); lava.flow(); } } 不同的虚拟机实现可能会用完全不同的方法来操作，下面描述的只是其中一种可能——但并不是仅有的一种。 要运行Volcano程序，首先得以某种“依赖于实现的”方式告诉虚拟机“Volcano”这个名字。之后，虚拟机将找到并读入相应的class文件“Volcano.class”，然后它会从导入的class文件里的二进制数据中提取类型信息并放到方法区中。通过执行保存在方法区中的字节码，虚拟机开始执行main()方法，在执行时，它会一直持有指向当前类（Volcano类）的常量池（方法区中的一个数据结构）的指针。 注意：虚拟机开始执行Volcano类中main()方法的字节码的时候，尽管Lava类还没被装载，但是和大多数（也许所有）虚拟机实现一样，它不会等到把程序中用到的所有类都装载后才开始运行。恰好相反，它只会需要时才装载相应的类。 main()的第一条指令告知虚拟机为列在常量池第一项的类分配足够的内存。所以虚拟机使用指向Volcano常量池的指针找到第一项，发现它是一个对Lava类的符号引用，然后它就检查方法区，看Lava类是否已经被加载了。 这个符号引用仅仅是一个给出了类Lava的全限定名“Lava”的字符串。为了能让虚拟机尽可能快地从一个名称找到类，虚拟机的设计者应当选择最佳的数据结构和算法。 当虚拟机发现还没有装载过名为“Lava”的类时，它就开始查找并装载文件“Lava.class”，并把从读入的二进制数据中提取的类型信息放在方法区中。 紧接着，虚拟机以一个直接指向方法区Lava类数据的指针来替换常量池第一项（就是那个字符串“Lava”），以后就可以用这个指针来快速地访问Lava类了。这个替换过程称为常量池解析，即把常量池中的符号引用替换为直接引用。 终于，虚拟机准备为一个新的Lava对象分配内存。此时它又需要方法区中的信息。还记得刚刚放到Volcano类常量池第一项的指针吗？现在虚拟机用它来访问Lava类型信息，找出其中记录的这样一条信息：一个Lava对象需要分配多少堆空间。 JAVA虚拟机总能够通过存储与方法区的类型信息来确定一个对象需要多少内存，当JAVA虚拟机确定了一个Lava对象的大小后，它就在堆上分配这么大的空间，并把这个对象实例的变量speed初始化为默认初始值0。 当把新生成的Lava对象的引用压到栈中，main()方法的第一条指令也完成了。接下来的指令通过这个引用调用Java代码（该代码把speed变量初始化为正确初始值5）。另一条指令将用这个引用调用Lava对象引用的flow()方法。 堆 Java程序在运行时创建的所有类实例或数组都放在同一个堆中。而一个JAVA虚拟机实例中只存在一个堆空间，因此所有线程都将共享这个堆。又由于一个Java程序独占一个JAVA虚拟机实例，因而每个Java程序都有它自己的堆空间——它们不会彼此干扰。但是同一个Java程序的多个线程却共享着同一个堆空间，在这种情况下，就得考虑多线程访问对象（堆数据）的同步问题了。 JAVA虚拟机有一条在堆中分配新对象的指令，却没有释放内存的指令，正如你无法用Java代码区明确释放一个对象一样。虚拟机自己负责决定如何以及何时释放不再被运行的程序引用的对象所占据的内存。通常，虚拟机把这个任务交给垃圾收集器。 数组的内部表示 在Java中，数组是真正的对象。和其他对象一样，数组总是存储在堆中。同样，数组也拥有一个与它们的类相关联的Class实例，所有具有相同维度和类型的数组都是同一个类的实例，而不管数组的长度（多维数组每一维的长度）是多少。例如一个包含3个int整数的数组和一个包含300个整数的数组拥有同一个类。数组的长度只与实例数据有关。 数组类的名称由两部分组成：每一维用一个方括号“[”表示，用字符或字符串表示元素类型。比如，元素类型为int整数的、一维数组的类名为“[I”，元素类型为byte的三维数组为“[[[B”，元素类型为Object的二维数组为“[[Ljava/lang/Object”。 多维数组被表示为数组的数组。比如，int类型的二维数组，将表示为一个一维数组，其中的每一个元素是一个一维int数组的引用，如下图： 在堆中的每个数组对象还必须保存的数据时数组的长度、数组数据，以及某些指向数组的类数据的引用。虚拟机必须能够通过一个数组对象的引用得到此数组的长度，通过索引访问其元素（期间要检查数组边界是否越界），调用所有数组的直接超类Object声明的方法等等。 程序计数器 对于一个运行中的Java程序而言，其中的每一个线程都有它自己的PC（程序计数器）寄存器，它是在该线程启动时创建的，PC寄存器的大小是一个字长，因此它既能够持有一个本地指针，也能够持有一个returnAddress。当线程执行某个Java方法时，PC寄存器的内容总是下一条将被执行指令的“地址”，这里的“地址”可以是一个本地指针，也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法，那么此时PC寄存器的值是“undefined”。 Java栈 每当启动一个新线程时，Java虚拟机都会为它分配一个Java栈。Java栈以帧为单位保存线程的运行状态。虚拟机只会直接对Java栈执行两种操作：以帧为单位的压栈和出栈。 某个线程正在执行的方法被称为该线程的当前方法，当前方法使用的栈帧称为当前帧，当前方法所属的类称为当前类，当前类的常量池称为当前常量池。在线程执行一个方法时，它会跟踪当前类和当前常量池。此外，当虚拟机遇到栈内操作指令时，它对当前帧内数据执行操作。 每当线程调用一个Java方法时，虚拟机都会在该线程的Java栈中压入一个新帧。而这个新帧自然就成为了当前帧。在执行这个方法时，它使用这个帧来存储参数、局部变量、中间运算结果等数据。 Java方法可以以两种方式完成。一种通过return返回的，称为正常返回；一种是通过抛出异常而异常终止的。不管以哪种方式返回，虚拟机都会将当前帧弹出Java栈然后释放掉，这样上一个方法的帧就成为当前帧了。 Java帧上的所有数据都是此线程私有的。任何线程都不能访问另一个线程的栈数据，因此我们不需要考虑多线程情况下栈数据的访问同步问题。当一个线程调用一个方法时，方法的的局部变量保存在调用线程Java栈的帧中。只有一个线程能总是访问那些局部变量，即调用方法的线程。 本地方法栈 前面提到的所有运行时数据区都是Java虚拟机规范中明确定义的，除此之外，对于一个运行中的Java程序而言，它还可能会用到一些跟本地方法相关的数据区。当某个线程调用一个本地方法时，它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区，但不止如此，它还可以做任何它想做的事情。 本地方法本质上时依赖于实现的，虚拟机实现的设计者们可以自由地决定使用怎样的机制来让Java程序调用本地方法。 任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时，虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时，虚拟机会保持Java栈不变，不再在线程的Java栈中压入新的帧，虚拟机只是简单地动态连接并直接调用指定的本地方法。 如果某个虚拟机实现的本地方法接口是使用C连接模型的话，那么它的本地方法栈就是C栈。当C程序调用一个C函数时，其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈，它的返回值也以确定的方式传回调用者。同样，这就是虚拟机实现中本地方法栈的行为。 很可能本地方法接口需要回调Java虚拟机中的Java方法，在这种情况下，该线程会保存本地方法栈的状态并进入到另一个Java栈。 该线程首先调用了两个Java方法，而第二个Java方法又调用了一个本地方法，这样导致虚拟机使用了一个本地方法栈。假设这是一个C语言栈，其间有两个C函数，第一个C函数被第二个Java方法当做本地方法调用，而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法（第三个Java方法），最终这个Java方法又调用了一个Java方法（它成为图中的当前方法）。
JVM调优实战：解决CMS concurrent-abortable-preclean LongGC的问题
1. 背景1.1 多个业务线的应用出现 Long GC 告警最近一段时间，频繁收到监控系统 CAT 上报的 Long GC 告警（配置阈值为大于 3 秒）。2. 分析前的一些 JVM 背景知识回顾2.1 JVM 堆内存划分新生代（Young Generation）新生代内被划分为三个区：Eden、From Survivor、To Survivor。大多数对象在新生代被创建，Minor GC 针对的是新生代的垃圾回收。老年代（Old Generation）在新生代中经历了几次 Minor GC 仍然存活的对象，会被晋升到老年代。Major GC 针对的是老年代的垃圾回收。本文重点分析的 CMS 就是一种针对老年代的垃圾回收算法。另外，Full GC 是针对整堆（包括新生代和老年代）做的垃圾回收。永久代（Perm）主要存放已被虚拟机加载的类信息、常量、静态变量等数据。该区域对垃圾回收的影响不大，本文不会过多涉及。2.2 CMS 垃圾回收的 6 个重要阶段initial-mark（初始标记）：CMS 的第一个 STW（Stop-The-World）阶段。标记 GC Root 直接引用的对象，由于 GC Root 直接引用的对象不多，所以速度很快。concurrent-mark（并发标记）：由第一阶段标记过的对象出发，所有可达的对象都在本阶段标记。concurrent-preclean（并发预清理）：也是一个并发执行的阶段。在本阶段，会查找前一阶段执行过程中从新生代晋升、新分配或被更新的对象。通过并发地重新扫描这些对象，预清理阶段可以减少下一个 STW 重新标记阶段的工作量。concurrent-abortable-preclean（并发可中止的预清理）：这个阶段其实跟上一个阶段做的事情一样，也是为了减少下一个 STW 重新标记阶段的工作量。增加这一阶段是为了让我们可以控制这个阶段的结束时机，比如扫描多长时间（默认 5 秒）或者 Eden 区使用占比达到期望比例（默认 50%）就结束本阶段。remark（重标记）：CMS 的第二个 STW 阶段。暂停所有用户线程，从 GC Root 开始重新扫描整堆，标记存活的对象。需要注意的是，虽然 CMS 只回收老年代的垃圾对象，但是这个阶段依然需要扫描新生代，因为很多 GC Root 都在新生代，而这些 GC Root 指向的对象又在老年代，这称为“跨代引用”。concurrent-sweep（并发清理）。3. 分析下面先看看出现 Long GC 时发生了什么。选取其中一个应用分析其 GC 日志，发现 Long GC 发生在 CMS 的收集阶段。箭头 1 显示 abortable-preclean 阶段耗时 4.04 秒。箭头 2 显示的是 remark 阶段，耗时 0.11 秒。虽然 abortable-preclean 阶段是 concurrent 的，不会暂停其他的用户线程，就算不优化可能影响也不大。但是频繁收到各个业务线的 GC 报警，长久来说也不是理想的状态。在调优之前先看下该应用的 GC 统计数据，包括 GC 次数、耗时：统计期间内（18 天）发生 CMS GC 69 次，其中 abortable preclean 阶段平均耗时 2.45 秒，final remark 阶段平均 112ms，最大耗时 170ms。4. 优化目标降低 abortable preclean 时间，而且不增加 final remark 的时间（因为 remark 是 STW 的）。5. JVM 参数调优5.1 第一次调优先尝试调低 abortable preclean 阶段的时间，看看效果。有两个参数可以控制这个阶段何时结束：-XX:CMSMaxAbortablePrecleanTime=5000：默认值 5s，代表该阶段最大的持续时间。-XX:CMSScheduleRemarkEdenPenetration=50：默认值 50%，代表 Eden 区使用比例超过 50% 就结束该阶段进入 remark。调整为最大持续时间为 1s，Eden 区使用占比 10%，如下：-XX:CMSMaxAbortablePrecleanTime=1000 -XX:CMSScheduleRemarkEdenPenetration=10调整思路：首先，每次 CMS 都发生在老年代使用占比达到 80% 时，这是由下面两个参数决定的：-XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly老年代的增长是由于部分对象在 Minor GC 后仍然存活，被晋升到老年代，导致老年代使用占比增长。也就是在每次 CMS GC 发生之前刚刚发生过一次 Minor GC，所以在那一刻新生代的使用占比是很低的。那么我们预计这个时候尽快结束 abortable preclean 阶段，在 remark 时就不需要扫描太多的 Eden 区对象，remark STW 的时间也就不会太长。调整的思路是这样了，那到底效果如何呢？第一次调整的结果在统计期间（17 小时左右）内，发生过 2 次 CMS GC。Abortable Preclean 平均耗时 835ms，这是预期内的。但是 Final Remark 平均耗时 495ms（调整前是 112ms），其中一次是 80ms，另一次是 910ms！将近 1 秒钟！Remark 是 STW 的！对于要求低延时的应用来说这是无法接受的！对比这两次 CMS GC 的详细 GC 日志，我们发现了一些对分析问题非常有用的东西。remark 耗时 80ms 的那次 GC 日志[YG occupancy: 181274 K (1887488 K.html)] - 年轻代当前占用情况和总容量耗时 80ms 的这次 remark 发生时（早上 9 点，非高峰时段），新生代（YG）占用 181.274M。remark 耗时 910ms 的那次 GC 日志[YG occupancy: 773427 K (1887488 K.html)]耗时 910ms 的这次 remark 发生时（晚上 10 点左右，高峰时段），新生代（YG）占用 773.427M。因为这个时候是高峰期，新生代的占用量上升得非常快。几乎同样的时间内，非高峰时段仅上升到 181M，但是高峰时段就上升到 773M。结论： 如果 abortable preclean 阶段时间太短，随后在 remark 时，新生代占用越大，则 remark 持续的时间（STW）越长。这就陷入了两难：不缩短 abortable preclean 耗时会报 Long GC；缩短的话，remark 阶段又会变长，而且是 STW，更不能接受。对于这种情况，CMS 提供了 -XX:+CMSScavengeBeforeRemark 参数，尝试在 remark 阶段之前进行一次 Minor GC，以降低新生代的占用。-XX:+CMSScavengeBeforeRemarkEnables scavenging attempts before the CMS remark step. By default, this option is disabled.5.2 第二次调优调优前的考虑：增加 -XX:+CMSScavengeBeforeRemark 不是没有代价的，因为这会增加一次 Minor GC 停顿。所以这个方案好或者不好的判断标准就是：增加 CMSScavengeBeforeRemark 参数之后的 Minor GC 停顿时间 + Remark 停顿时间，如果比增加之前的 Remark GC 停顿时间要小，这才是好的方案。第二次调整的结果在统计期间（20 小时左右）内，发生 3 次 CMS GC。Abortable preclean 平均耗时 693ms。Final remark 平均耗时 50ms，最大耗时 60ms。Final remark 的时间比调优前的平均时间（112ms）更低。那么 CMS GC 前的 Minor GC 停顿时间又如何呢？来看看详细的 GC 日志。3 次 CMS GC remark 前的 Minor GC 日志分析第 1 次是非高峰时段的表现，Minor GC 耗时 0.01s + remark 耗时 0.06s = 0.07s = 70ms，如下：第 2 次是高峰时段，Minor GC 耗时 0.01s + remark 耗时 0.05s = 0.06s = 60ms，如下：第 3 次是非高峰时段，Minor GC 耗时 0.00s + remark 耗时 0.04s = 0.04s = 40ms，如下：所以，3 次 Minor GC + remark 耗时的平均耗时 < 60ms，这比第一次调优时 remark 平均耗时 495ms 好得多了。6. 优化结果至此，我们最初的目标——降低 abortable preclean 时间，而且不增加 final remark 的时间——已经达到了。甚至 remark 的时间也缩短了。7. 小结解决 abortable preclean 时间过长的方案可以归结为两步：缩短 abortable preclean 时长，通过调整这两个参数：-XX:CMSMaxAbortablePrecleanTime=xxx -XX:CMSScheduleRemarkEdenPenetration=xxx调整为多少的一个判断标准是： abortable preclean 阶段结束时，新生代的空间占用不能大于某个参考值。在前面第一次调优后，新生代（YG）占用 181.274M，remark 耗时 80ms；新生代（YG）占用 773.427M 时，remark 耗时 910ms。所以这个参考值可以是 300M。而如果新生代增长过快，像这次调优应用 2 秒内就能用光 2G 新生代堆空间的，就只能通过 -XX:+CMSScavengeBeforeRemark 做一次 Minor GC 了。增加 -XX:+CMSScavengeBeforeRemark 参数，开启 remark 前进行 Minor GC 的尝试。虽然官方说明增加这个参数是尝试进行 Minor GC，不一定会进行，但实际使用起来，几乎每次 remark 前都会 Minor GC。8. 总结调优前明确目标。调优过程对 GC 指标进行数据统计分析（本文借助 gceasy.io 在线分析工具）来验证效果。需要能看懂 GC 日志。GC 调优不是一个一蹴而就的事情，它是微调、观察、再微调的过程。所以需要比较深入了解 GC 的一些基础，才能少走弯路。说明： CMS 垃圾收集器在 JDK 9 中被标记为废弃，并在 JDK 14 中被正式移除。本文内容主要适用于 JDK 8 及以下版本。
Java 内存区域编者注：本文为历史博文归档；涉及 JDK、框架与工具链版本请以当前官方文档为准。引用外链图片可能失效，阅读时请注意时效性。Java 虚拟机（JVM）管理的内存区域主要分为以下五部分：线程共享区域：方法区（Method Area）、堆（Heap）。线程私有区域：程序计数器（Program Counter Register）、本地方法栈（Native Method Stack）、虚拟机栈（VM Stack）。程序计数器（Program Counter Register）程序计数器是一块较小的内存空间，它可以看作是当前线程所执行的字节码的行号指示器。每一条 JVM 线程都有自己的程序计数器，各条线程之间互不影响、独立存储，这类内存区域被称为“线程私有”内存。在任意时刻，一条 JVM 线程只会执行一个方法的代码，该方法称为该线程的当前方法（Current Method）。如果该方法是 Java 方法，程序计数器保存的是 JVM 正在执行的字节码指令的地址。如果该方法是 native 方法，程序计数器的值则为 undefined（未定义）。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。说明：Java 虚拟机的多线程是通过线程轮流切换并分配处理器时间来实现的。Java 虚拟机栈（Java Virtual Machine Stack）与程序计数器一样，Java 虚拟机栈也是线程私有的。每一个 JVM 线程都有自己的虚拟机栈，栈与线程同时创建，其生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型：每个方法被执行的时候都会同时创建一个栈帧（Stack Frame），用于存储局部变量表、操作数栈（如表达式 a+b 的语法树计算、进栈出栈操作）、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程，就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。JVM 栈可以被实现成固定大小，也可以根据计算动态扩展。如果采用固定大小的 JVM 栈设计，那么每一条线程的 JVM 栈容量应该在线程创建时独立地选定。JVM 实现应该提供调节 JVM 栈初始容量的手段。如果采用动态扩展和收缩的 JVM 栈方式，应该提供调节最大、最小容量的手段。如果线程请求的栈深度大于虚拟机所允许的深度，将抛出 StackOverflowError。如果 JVM 栈可以动态扩展，但在尝试扩展时无法申请到足够的内存，则抛出 OutOfMemoryError。本地方法栈（Native Method Stack）本地方法栈与虚拟机栈作用相似，后者为虚拟机执行 Java 方法服务，而前者为虚拟机用到的 Native 方法服务。虚拟机规范对于本地方法栈中方法使用的语言、使用方式和数据结构没有强制规定。有的虚拟机（比如 HotSpot）直接把本地方法栈和虚拟机栈合二为一。该区域抛出的异常类型与虚拟机栈相同（StackOverflowError 或 OutOfMemoryError）。Java 堆（Java Heap）Java 堆是虚拟机管理的内存中最大的一块，同时也是被所有线程所共享的。它在虚拟机启动时创建，其主要作用是存放对象实例，几乎所有的对象实例以及数组都要在这里分配内存。堆中的对象被自动管理，也就是俗称的 GC（Garbage Collector，垃圾收集器） 所管理。开发者无需操心销毁回收的事儿。Java 堆的容量可以是固定大小，也可以随着需求动态扩展（通过 -Xms 和 -Xmx 参数），并在不需要过多空间时自动收缩。Java 堆所使用的内存不需要保证是物理连续的，只要逻辑上是连续的即可。JVM 实现应当提供给程序员调节 Java 堆初始容量的手段；对于可动态扩展和收缩的堆，则应当提供调节其最大和最小容量的手段。如果堆中没有内存完成实例分配并且堆也无法扩展，就会抛出 OutOfMemoryError。方法区（Method Area）方法区跟堆一样是被各个线程共享的内存区域，用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然这个区域被虚拟机规范描述为堆的一个逻辑部分，但是它的别名叫“非堆”，用来与堆做区别。方法区在虚拟机启动的时候创建。方法区的容量可以是固定大小的，也可以随着程序执行的需求动态扩展，并在不需要过多空间时自动收缩。方法区在实际内存空间中可以是不连续的。Java 虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段；对于可以动态扩展和收缩的方法区，则应当提供调节其最大、最小容量的手段。当方法区无法满足内存分配需求时，就会抛出 OutOfMemoryError。示例：堆、栈和方法区之间的关系以下代码示例说明了对象实例、类信息与引用变量在不同内存区域的分布：public class Test2 { public static void main(String[] args.html) { Test2 t2 = new Test2(); // JVM 将 Test2 类信息加载到方法区 // new Test2() 实例保存在堆区 // Test2 引用 t2 保存在栈区 } }运行时常量池（Runtime Constant Pool）运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外，还有一项信息是常量池（Constant Pool Table），用于存放编译期生成的各种字面量和符号引用，这部分内容将在类加载后存放到方法区的运行时常量池中。Java 虚拟机对 Class 文件的每一部分（自然也包括常量池）的格式都有严格的规定，每一个字节用于存储哪种数据都必须符合规范上的要求，这样才会被虚拟机认可、装载和执行。对于运行时常量池，Java 虚拟机规范没有做任何细节的要求，不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。一般来说，除了保存 Class 文件中描述的符号引用外，还会把翻译出来的直接引用也存储在运行时常量池中。运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性。Java 语言并不要求常量一定只能在编译期产生，也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池，运行期间也可能将新的常量放入池中。这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。既然运行时常量池是方法区的一部分，自然会受到方法区内存的限制，当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。直接内存（Direct Memory）直接内存并不是虚拟机运行时数据区的一部分，也不是 Java 虚拟机规范中定义的内存区域，但是这部分内存也被频繁地使用，而且也可能导致 OutOfMemoryError 异常出现。JDK 1.4 加入的 NIO 中，ByteBuffer 有个方法是 allocateDirect(int capacity.html)。这是一种基于通道（Channel）与缓冲区（Buffer）的 I/O 方式，它可以使用 Native 函数库直接分配堆外内存。然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能，因为避免了在 Java 堆和 Native 堆中来回复制数据。显然，本机直接内存的分配不会受到 Java 堆大小的限制，但是，既然是内存，则肯定还是会受到本机总内存（包括 RAM 及 SWAP 区或者分页文件）的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时，一般会根据实际内存设置 -Xmx 等参数信息，但经常会忽略掉直接内存，使得各个内存区域的总和大于物理内存限制（包括物理上的和操作系统级的限制），从而导致动态扩展时出现 OutOfMemoryError 异常。参考资料深入理解 Java 虚拟机JVM 内存模型详解Java 内存区域分析JVM 内存结构详解说明：本文内容主要基于 JDK 1.8 之前的经典 Java 内存模型（如永久代实现方法区）。在 JDK 1.8 及后续版本中，方法区的具体实现已变更为元空间（Metaspace），永久代已被移除，但内存区域的逻辑划分（如堆、栈、方法区概念）依然适用。具体参数与行为请以当前使用的 JDK 版本官方文档为准。
Java 虚拟机的生命周期一个运行时的 Java 虚拟机（Java Virtual Machine, JVM）实例的职责是运行一个 Java 程序。当启动一个 Java 程序时，一个虚拟机实例随之诞生；当该程序关闭退出时，这个虚拟机实例也随之消亡。如果在同一台计算机上同时运行三个 Java 程序，将得到三个 Java 虚拟机实例。每个 Java 程序都运行于它自己的 Java 虚拟机实例中。Java 虚拟机实例通过调用某个初始类的 main() 方法来运行一个 Java 程序。这个 main() 方法必须是公有的（public）、静态的（static）、返回值为 void，并且接受一个字符串数组作为参数。任何拥有这样一个 main() 方法的类都可以作为 Java 程序运行的起点。public class Test { public static void main(String[] args.html) { // TODO Auto-generated method stub System.out.println("Hello World".html); } }在上面的例子中，Java 程序初始类中的 main() 方法将作为该程序初始线程的起点，任何其他的线程都是由这个初始线程启动的。在 Java 虚拟机内部有两种线程：守护线程（Daemon Thread）和非守护线程（Non-Daemon Thread）。守护线程通常是由虚拟机自己使用的，比如执行垃圾收集任务的线程。但是，Java 程序也可以把它创建的任何线程标记为守护线程。而 Java 程序中的初始线程——即开始于 main() 的那个线程，是非守护线程。只要还有任何非守护线程在运行，那么这个 Java 程序也在继续运行。当该程序中所有的非守护线程都终止时，虚拟机实例将自动退出。假若安全管理器允许，程序本身也能够通过调用 Runtime 类或者 System 类的 exit() 方法来退出。Java 虚拟机的体系结构下图是 Java 虚拟机的结构图。每个 Java 虚拟机都有一个类装载子系统（Class Loading Subsystem），它根据给定的全限定名来装入类型（类或接口）。同样，每个 Java 虚拟机都有一个执行引擎（Execution Engine），它负责执行那些包含在被装载类的方法中的指令。![]([binary]