ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CS-Notes 深入理解 Java 虚拟机:运行时数据区域、垃圾收集、内存分配策略与类加载机制全景

CS-Notes 深入理解 Java 虚拟机:运行时数据区域、垃圾收集、内存分配策略与类加载机制全景 CS-Notes 深入理解 Java 虚拟机运行时数据区域、垃圾收集、内存分配策略与类加载机制全景【免费下载链接】CS-Notes:books: 技术面试必备基础知识、Leetcode、计算机操作系统、计算机网络、系统设计项目地址: https://gitcode.com/GitHub_Trending/cs/CS-Notes本篇技术指南基于 CS-Notes 笔记仓库中的 Java 虚拟机 文档展开系统梳理 HotSpot 虚拟机的运行时数据区域程序计数器、虚拟机栈、堆、方法区、直接内存、垃圾收集的判定依据与七大收集器、内存分配与 Full GC 触发策略以及类加载机制与双亲委派模型的实现源码。读完后你将能够解释StackOverflowError/OutOfMemoryError的内存成因、合理配置-Xss、-Xms、-Xmx等虚拟机参数、根据吞吐量和停顿时间需求选择 GC 收集器并亲手实现一个自定义类加载器。一、运行时数据区域JVM 内存模型的地基Java 程序运行时虚拟机把内存划分为线程私有与线程共享两大类区域。整体结构如下图所示图中标注为 JDK 1.6 时期的布局其中方法区在 JDK 8 之后被元空间取代后文会展开程序计数器PC Register程序计数器记录当前线程正在执行的虚拟机字节码指令的地址用于线程切换后恢复到正确的执行位置如果线程正在执行的是本地方法Native Method则计数器的值为空Undefined。它是唯一一个不会发生OutOfMemoryError的内存区域因为它的值随指令指针实时更新、不涉及动态内存申请。Java 虚拟机栈JVM Stack与栈帧每个 Java 方法在执行的同时会创建一个栈帧Stack Frame用于存储局部变量表、操作数栈、常量池引用、方法返回地址等信息。从方法调用直至执行完成的过程恰好对应一个栈帧在 Java 虚拟机栈中入栈和出栈的过程——这也是线程私有和生命周期与线程相同的由来栈帧不跨线程共享线程死亡后栈随之销毁。可以通过-Xss虚拟机参数指定每个线程的 Java 虚拟机栈内存大小JDK 1.4 中默认为 256KJDK 1.5 默认为 1Mjava -Xss2M HackTheJava该区域可能抛出两类异常二者分别对应两种内存边界被打破的场景当线程请求的栈深度超过最大值递归过深、调用链过长会抛出StackOverflowError栈进行动态扩展时如果无法申请到足够内存会抛出OutOfMemoryError。本地方法栈Native Method Stack本地方法栈与 Java 虚拟机栈在功能上几乎一致区别在于它专为本地方法服务。本地方法一般用其它语言C、C 或汇编编写并被编译为基于本机硬件和操作系统的原生程序运行在本地方法栈中通常以 JNIJava Native Interface方式被 Java 代码调用。HotSpot 中本地方法栈与虚拟机栈是合二为一的但虚拟机规范仍将二者区分开来。堆HeapGC 的主要区域堆是所有对象实例和数组分配内存的地方是被所有线程共享的区域也是垃圾收集GC的主要区域因此常被称为GC 堆。从内存回收的角度看现代垃圾收集器基本都采用分代收集思想把堆进一步划分为新生代Young Generation新创建的对象主要在这里分配对象存活时间短回收频繁老年代Old Generation长期存活的对象最终进入这里。堆不需要是物理上连续的内存并且可以动态增加内存大小当堆中所有空间都用于对象分配、且无法申请到更多空间时抛出OutOfMemoryError。堆大小可通过以下两个参数控制java -Xms1M -Xmx2M HackTheJava-Xms堆内存初始大小-Xmx堆内存最大值。实践中常见做法是将两者设为相同值避免 JVM 在运行期反复调整堆大小带来的抖动。方法区从永久代到元空间方法区用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据同样不需要连续内存且可动态扩展扩展失败抛出OutOfMemoryError。对该区域做垃圾回收的目标主要是回收常量池中的废弃常量和卸载不再被使用的类难度远高于堆的回收。方法区、永久代与元空间三者的关系是面试与调优中的高频考点务必区分规范与实现两个层面概念性质说明方法区JVM 规范定义的逻辑区域是一个抽象概念只规定必须存在这样一块区域永久代PermGenHotSpot 对方法区的一种实现每次 Full GC 后大小都会改变难以预估-XX:PermSize/-XX:MaxPermSize常调不好元空间MetaspaceHotSpot 对方法区的另一种实现JDK 1.8 起默认实现位于本地内存而非虚拟机堆内存中可通过-XX:MaxMetaspaceSize限制HotSpot 虚拟机早期把方法区当作永久代来进行垃圾回收但由于很难确定永久代的大小受类数量、字符串常量、反射缓存等诸多因素影响且每次 Full GC 后其大小都会变化经常出现java.lang.OutOfMemoryError: PermGen space。为了更容易管理方法区从 JDK 1.8 开始移除永久代把方法区移至元空间它位于本地内存Native Memory中。JDK 1.8 之后原来永久代的数据被重新划分类的元信息元数据进入元空间而静态变量和字符串常量池等被移入堆中。运行时常量池Runtime Constant Pool运行时常量池是方法区的一部分是类文件Class File中常量池表的运行时内存表示Class 文件中的常量池表包含编译器生成的字面量如字符串、数值常量和符号引用如类的全限定名、字段名与方法签名类加载后这些内容会被放入运行时常量池与 Class 文件常量池不同运行时常量池具备动态性运行时也能生成新的常量放入其中最典型的例子是String类的intern()方法。直接内存Direct MemoryJDK 1.4 引入 NIO 类库后可以直接使用 Native 函数库分配堆外内存并通过 Java 堆中的DirectByteBuffer对象作为这块内存的引用进行操作无需在堆内存与堆外内存之间来回拷贝数据。由于省去了一次内存复制它在网络传输、大文件读写等场景中能显著提高性能。需要注意两点边界一是直接内存不属于运行时数据区域中的任何一块它的内存使用直接受系统内存和进程虚拟内存的限制二是DirectByteBuffer对象本身仍分配在堆中其回收依赖 GC——堆内对象被回收时才会通过 Cleaner 机制释放堆外内存这也是堆外内存泄漏问题的来源。仓库中 Java IO 笔记的 NIO 章节有面向块、非阻塞的 I/O 模型说明及文件、套接字 NIO 实例可作为本节内容的配套延伸阅读。二、垃圾收集Garbage Collection垃圾收集主要对象是堆和方法区。程序计数器、Java 虚拟机栈、本地方法栈三个区域属于线程私有生命周期与线程相同线程结束区域即消失因此天然不需要 GC。判断一个对象是否可被回收1. 引用计数算法为对象添加一个引用计数器对象每被引用一次计数器加 1引用失效时减 1计数为 0 的对象可被回收。该算法判定效率简单但存在致命缺陷——循环引用两个对象互相持有引用时即使外界已不再使用它们计数器也永远不为 0public class Test { public Object instance null; public static void main(String[] args) { Test a new Test(); Test b new Test(); a.instance b; b.instance a; a null; b null; doSomething(); } }在上述代码中a 与 b 引用的对象实例互相持有了引用因此当我们把对 a、b 的引用去除之后由于两个对象还存在互相之间的引用导致两个 Test 对象无法被回收。正是因为循环引用的存在Java 虚拟机不使用引用计数算法Python、Objective-C 等语言则使用它。2. 可达性分析算法Java 虚拟机采用可达性分析Reachability Analysis以一组称为GC Roots的根对象作为起始点集从它们开始向下搜索搜索路径称为引用链能被引用链触及到的对象视为存活不可达的对象可被回收。GC Roots 一般包含虚拟机栈栈帧中的局部变量表中引用的对象本地方法栈中 JNINative 方法引用的对象方法区中类静态属性引用的对象如静态变量指向的对象方法区中常量引用的对象如字符串常量池中的引用此外还包括被 synchronized 持有的对象、JVM 内部引用基本类型 Class 对象、常驻异常对象、系统类加载器等。3. 方法区的回收常量池回收与类卸载方法区主要存放类元数据其回收率比新生代低很多回收性价比不高。方法区中的 GC 主要目标是对常量池的回收和对类的卸载。为了避免内存溢出在大量使用反射和动态代理的场景中都需要虚拟机具备类卸载功能。一个类要被卸载必须同时满足以下三个条件且满足条件也不一定会被卸载该类所有的实例都已经被回收堆中不存在该类的任何实例加载该类的 ClassLoader 已经被回收该类对应的java.lang.Class对象没有在任何地方被引用无法在任何地方通过反射访问该类的方法。4. finalize() 方法finalize()类似 C 的析构函数可用于关闭外部资源。但try-finally等机制能完成同样的事情且更可靠finalize()运行代价高、执行时机不确定、无法保证各对象的调用顺序官方也不建议使用。它有一个特殊的自救特性当对象被判定为可回收、且重写了finalize()时可以在方法中让对象重新被引用从而逃脱回收。自救只能成功一次——如果同一个对象再次面临回收虚拟机不会再调用它的finalize()。四种引用类型无论引用计数还是可达性分析判定对象是否可被回收都与引用有关。Java 在java.lang.ref包中提供了四种强度递减的引用类型为可回收划定了更精细的边界1. 强引用被强引用关联的对象永远不会被回收只要引用还在。使用new创建一个对象引用即典型的强引用Object obj new Object();2. 软引用SoftReference被软引用关联的对象只有在内存不足即将发生OutOfMemoryError的情况下才会被回收。适合实现缓存等场景内存充裕时保留对象内存紧张时牺牲它们Object obj new Object(); SoftReferenceObject sf new SoftReferenceObject(obj); obj null; // 使对象只被软引用关联3. 弱引用WeakReference被弱引用关联的对象只能存活到下一次垃圾回收发生之前无论内存是否充足只要 GC 运行就会回收它。常用于避免内存泄漏的辅助映射如ThreadLocal的 Entry 对 ThreadLocal 的弱引用Object obj new Object(); WeakReferenceObject wf new WeakReferenceObject(obj); obj null;4. 虚引用PhantomReference又称幽灵引用或幻影引用。对象是否有虚引用存在完全不影响其生存时间也无法通过虚引用获取对象实例get()永远返回 null。为对象设置虚引用的唯一目的是在该对象被回收时收到一个系统通知——需配合ReferenceQueue使用常在堆外内存管理如ByteBuffer的 Cleaner等场景用于回收通知Object obj new Object(); PhantomReferenceObject pf new PhantomReferenceObject(obj, null); obj null;垃圾收集算法1. 标记 - 清除Mark-Sweep分两个阶段标记阶段遍历所有对象检查每个对象是否为活动对象是则在其对象头打上标记清除阶段回收被标记/未标记为存活的对象并复位标志。清除阶段的实现中还会判断回收后的空闲分块与前一个空闲分块是否连续若连续则合并分块并将空闲分块连接到空闲链表上分配时程序搜索空闲链表寻找空间大于等于新对象大小 size 的块——若恰好等于 size 直接返回若大于 size 则把块分割为 size 与 (block - size) 两部分返回前者、把后者归还空闲链表。不足标记和清除过程效率都不高会产生大量不连续的内存碎片导致后续无法为大对象分配连续内存。2. 标记 - 整理Mark-Compact让所有存活对象都向内存一端移动然后直接清理端边界以外的内存。优点不产生内存碎片不足需要移动大量对象对象地址变化后需更新所有引用处理效率比较低。3. 复制Copy将可用内存划分为大小相等的两块每次只使用其中一块这一块用完后将存活对象全部复制到另一块再把使用过的内存空间一次性清理。主要不足是空间利用率只有一半。现代商业虚拟机用变形的复制算法回收新生代不是均分两块而是一块较大的Eden 空间和两块较小的Survivor 空间From/To每次使用 Eden 和其中一块 Survivor。回收时把 Eden 与当前 Survivor 中的存活对象全部复制到另一块 Survivor再一次性清理 Eden 和使用过的那块 Survivor。HotSpot 虚拟机的 Eden 与 Survivor 比例默认是8:1两块 Survivor 各占 1/10保证 90% 的可用内存。如果每次回收后存活对象超过 10%一块 Survivor 放不下则需要老年代进行空间分配担保——借用老年代空间存放这些多余对象。4. 分代收集Generational Collection基于绝大多数对象朝生夕灭的弱分代假说把堆按对象存活周期划分为新生代与老年代各区域采用最适合其对象特征的算法新生代复制算法对象死亡率高、无需额外对象协助即可回收老年代标记 - 清除 或 标记 - 整理算法对象存活率高、没有额外空间协助。HotSpot 的 7 个垃圾收集器下图中橙色Young generation为新生代收集器蓝色Tenured generation为老年代收集器连线表示可以搭配使用G1 则横跨新生代与老年代先厘清两个维度单线程与多线程单线程收集器只使用一个线程做 GC多线程收集器使用多个线程并行回收串行与并行串行指 GC 与用户程序交替执行GC 时必须停顿用户程序即 STW并行指 GC 与用户程序同时执行。除了 CMS 和 G1 外其它收集器均以串行方式执行。1. Serial 收集器以串行方式执行的单线程收集器。优点是简单高效在单个 CPU 环境下由于没有线程交互的开销它拥有最高的单线程收集效率。它是Client 场景下的默认新生代收集器因为该场景下内存通常不会很大——Serial 收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内只要不频繁这种停顿是可以接受的。2. ParNew 收集器Serial 的多线程版本。它是Server 场景下搭配 CMS 时的默认新生代收集器——除性能原因外关键原因是除了 Serial 之外它是当时唯一能与 CMS 收集器配合使用的新生代收集器。它本身并未引入新东西与 Serial 的区别仅在于多线程执行。3. Parallel Scavenge 收集器同为多线程新生代收集器。其它收集器的目标是尽可能缩短 GC 时用户线程的停顿时间低延迟而它的目标是达到一个可控制的吞吐量——即 CPU 用于运行用户代码的时间占总时间的比值因此被称为吞吐量优先收集器。停顿时间越短越适合需要与用户交互的程序提升响应体验高吞吐量则能高效利用 CPU尽快完成运算任务适合后台运算且无需太多交互的任务。注意二者的权衡缩短停顿时间是以牺牲吞吐量和新生代空间为代价的——新生代空间变小后GC 更频繁吞吐量下降。Parallel Scavenge 提供了一个开关参数可打开GC 自适应调节策略GC Ergonomics开启后无需手工指定新生代大小-Xmn、Eden 与 Survivor 区比例、晋升老年代的对象年龄等细节参数虚拟机会根据系统运行状况动态调整这些参数提供最合适的停顿时间或最大的吞吐量。4. Serial Old 收集器Serial 的老年代版本同样为 Client 场景设计。若在 Server 场景下使用它有两个用途在 JDK 1.5 及之前版本Parallel Old 诞生以前中与 Parallel Scavenge 搭配使用作为 CMS 收集器的后备预案当 CMS 发生 Concurrent Mode Failure并发模式失败时临时启用 Serial Old 来替代 CMS 完成收集。5. Parallel Old 收集器Parallel Scavenge 的老年代版本同样是多线程、以吞吐量为目标。在注重吞吐量以及 CPU 资源敏感的场合都可以优先考虑Parallel Scavenge Parallel Old的组合。6. CMS 收集器Concurrent Mark SweepCMS 是一款以获取最短回收停顿时间为目标的老年代收集器基于标记 - 清除算法实现Mark Sweep 即其含义。它把回收过程细分为四个阶段其中两个阶段可与用户线程并发执行阶段是否需 STW说明初始标记Initial Mark需要仅标记 GC Roots 能直接关联到的对象速度很快并发标记Concurrent Mark不需要对 GC Roots Tracing 的漫长过程是整个过程耗时最长的一步重新标记Remark需要修正并发标记期间因用户程序继续运作而产生的标记变动并发清除Concurrent Sweep不需要清理未标记对象耗时不短但无需停顿由于耗时最长的并发标记和并发清除都不停顿用户线程CMS 的总停顿时间明显低于 Serial Old。但它有三个固有缺点吞吐量低低停顿以牺牲吞吐量为代价多线程的 CMS 需要更多 CPU 资源CPU 利用率不够高无法处理浮动垃圾可能出现 Concurrent Mode Failure并发清除阶段用户线程仍在运行会产生新的垃圾浮动垃圾这部分只能等下一次 GC 回收。因此必须预留内存不能像其他收集器那样等老年代快满才回收若预留空间不足就会发生 Concurrent Mode Failure虚拟机将临时启用 Serial Old 收集器重新进行老年代收集空间碎片标记 - 清除算法会产生不连续碎片可能出现老年代剩余空间足够但找不到足够大的连续空间来分配对象不得不提前触发一次 Full GC。7. G1 收集器Garbage-FirstG1 是一款面向服务端应用、可并行与并发的收集器在多 CPU 和大内存场景下有很好的性能HotSpot 开发团队赋予它的使命是未来替换掉 CMS。与其它收集器只回收整个新生代或老年代不同G1 可以同时对新生代和老年代进行回收。G1 的关键设计Region 化布局把堆划分为多个大小相等的独立区域Region每个 Region 可以根据需要扮演 Eden、Survivor 或 Old 的角色新生代与老年代不再是物理隔离的可预测的停顿时间模型每个 Region 都可单独回收。G1 通过记录每次 Region 回收的耗时与回收空间基于历史经验维护一个优先列表每次按用户允许的收集时间优先回收价值最大的 RegionGarbage-First 名称的由来Remembered Set 避免全堆扫描每个 Region 都有一个 Remembered Set记录该 Region 中对象的引用来自哪些其它 Region做可达性分析时凭它即可避免对整个堆扫描。G1 的运作大致划分为四个步骤不计维护 Remembered Set 的开销初始标记与 CMS 相同仅标记 GC Roots 直接关联对象需要 STW并发标记从 GC Roots 开始遍历整个堆与用户线程并发最终标记Final Mark处理并发标记期间的对象变动——虚拟机把这段时间的变化记录在线程的Remembered Set Logs中本阶段将这些日志数据合并到 Remembered Set 中。该阶段需要停顿线程但可以并行执行筛选回收Select Relocatable Collections对各 Region 的回收价值和成本排序根据用户期望的 GC 停顿时间制定回收计划。此阶段可并发执行但为控制整体停顿、提高收集效率实际会停顿用户线程且只回收一部分 Region。G1 的两个显著特点空间整合整体基于标记 - 整理算法实现局部两个 Region 之间基于复制算法实现运行期间不产生内存碎片可预测的停顿可明确指定在一个长度为 M 毫秒的时间片段内消耗在 GC 上的时间不得超过 N 毫秒。三、内存分配与回收策略Minor GC 与 Full GCMinor GCYoung GC只回收新生代。新生代对象存活时间短、死亡率高因此 Minor GC 执行频繁、速度相对较快Full GCMajor GC回收老年代与新生代通常还包含方法区/元空间相关处理。老年代对象存活时间长Full GC 很少发生但每次执行速度都比 Minor GC 慢很多。内存分配策略对象进入老年代的五条路径1. 对象优先在 Eden 分配新建对象优先在新生代 Eden 区分配当 Eden 空间不够时触发一次 Minor GC。2. 大对象直接进入老年代大对象指需要较大连续内存空间的对象最典型的是很长的字符串和数组。为避免为大对象反复在 Eden 与 Survivor 之间复制可通过参数让超过阈值的对象直接在老年代分配-XX:PretenureSizeThreshold # 大于此值字节的对象直接在老年代分配仅对 Serial/ParNew 等使用分代复制算法的收集器生效G1 中该参数无效经常出现大对象也会提前触发垃圾收集以腾出足够的连续空间。3. 长期存活的对象进入老年代对象在 Eden 出生经过一次 Minor GC 仍存活则被移到 Survivor且年龄加 1年龄达到阈值后晋升老年代。阈值由下参数控制-XX:MaxTenuringThreshold # 对象晋升老年代所需的最小年龄HotSpot 默认值通常为 154. 动态对象年龄判定虚拟机并不死板地要求年龄必须达到MaxTenuringThreshold才晋升如果在 Survivor 中相同年龄的所有对象大小的总和超过 Survivor 空间的一半则年龄大于等于该年龄的对象可以直接进入老年代无需等到阈值年龄。5. 空间分配担保Allocation Failure / Promotion Failure使用复制算法的 Minor GC 需要老年代提供内存担保若老年代最大可用连续空间 新生代所有对象总空间则 Minor GC 可确认安全否则查看HandlePromotionFailure是否允许担保失败允许时再检查老年代最大可用连续空间是否大于历次晋升到老年代的对象平均大小若是则尝试一次 Minor GC若否则先执行一次 Full GC若HandlePromotionFailure不允许冒险则直接 Full GC。Full GC 的触发条件Minor GC 的触发条件很简单Eden 区满。而 Full GC 的触发场景相对复杂常见的有以下五种代码中主动调用System.gc()只是建议虚拟机执行 Full GC虚拟机可以但不一定真正执行。官方不建议使用这种方式而应交给虚拟机自行管理内存老年代空间不足常见于大对象直接进入老年代、长期存活对象晋升等场景。规避手段包括尽量不创建过大的对象与数组用-Xmn调大新生代让对象在新生代就被回收掉用-XX:MaxTenuringThreshold调大晋升年龄让对象在新生代多存活一段时间空间分配担保失败使用复制算法的 Minor GC 需要老年代空间担保担保失败则 Full GC见上文第 5 条分配策略JDK 1.7 及以前的永久代空间不足JDK 1.7 及以前 HotSpot 的方法区由永久代实现其中存放类信息、常量、静态变量等。当要加载的类、反射类及调用的方法较多时永久代可能被占满在未配置 CMS GC 的情况下会执行 Full GC若 Full GC 后仍无法回收则抛出java.lang.OutOfMemoryError。规避方法增大永久代空间-XX:MaxPermSize或改用 CMS GCCMS 收集器并发模式失败Concurrent Mode FailureCMS GC 过程中同时有对象要放入老年代而此时老年代空间不足可能是浮动垃圾过多导致的暂时性不足便会触发 Full GC此时由 Serial Old 以串行方式重新收集老年代。四、类加载机制Java 类是在运行期间第一次使用时动态加载的而不是一次性加载所有类——如果一次性加载会占用大量内存。类的生命周期7 个阶段类从被加载到内存开始直到卸载出内存为止整个生命周期包括 7 个阶段加载Loading→ 验证Verification→ 准备Preparation→ 解析Resolution→ 初始化Initialization→ 使用Using→ 卸载Unloading。其中解析与初始化的先后次序并非固定——类加载过程包含加载、验证、准备、解析、初始化 5 个阶段解析可以在初始化之后才开始这是为了支持 Java 语言的动态绑定。1. 加载完成三件事通过类的完全限定名称获取定义该类的二进制字节流将该字节流表示的静态存储结构转换为方法区的运行时存储结构在内存中生成一个代表该类的java.lang.Class对象作为方法区中该类各种数据的访问入口。二进制字节流可以来自多种渠道从 ZIP 包中读取——这是 JAR、EAR、WAR 格式的基础从网络中获取——最典型的应用是 Applet运行时计算生成——例如动态代理技术java.lang.reflect.Proxy使用ProxyGenerator.generateProxyClass生成代理类的二进制字节流由其它文件生成——例如读取 JSP 文件运行时生成对应的 Class 类。2. 验证确保 Class 文件字节流中的信息符合当前虚拟机的要求并且不会危害虚拟机自身安全如是否带有魔数、版本号是否匹配、常量池中是否存在不存在的符号引用等。3. 准备类变量是被static修饰的变量。准备阶段为类变量分配内存并设置零值初始值如 int 为 0、引用为 null使用的方法区内存。注意两点实例变量不会在这阶段分配内存它随对象实例化一起分配在堆中。实例化不是类加载的过程类加载只发生一次实例化可发生多次若类变量是常量static final且编译期可确定则初始化为表达式定义的值而非零值// 准备阶段被初始化为 0零值123 是初始化阶段赋的值 public static int value 123; // 常量在准备阶段即被初始化为 123 public static final int value 123;4. 解析把常量池中的符号引用替换为直接引用的过程。符号引用以字符串形式描述目标类全名、方法名等直接引用则是直接指向目标的指针、相对偏移量或句柄。解析过程在某些情况下可以在初始化之后才开始以支持动态绑定。5. 初始化初始化阶段才真正开始执行类中定义的 Java 程序代码。它是虚拟机执行类构造器clinit()方法的过程clinit()由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生收集顺序由语句在源文件中出现的顺序决定与语法分析器采用的任何一种深度优先策略无关静态语句块只能访问定义在它之前的类变量定义在它之后的类变量只能赋值不能访问否则编译器报非法向前引用public class Test { static { i 0; // 给变量赋值可以正常编译通过 System.out.print(i); // 这句编译器会提示非法向前引用 } static int i 1; }父类的clinit()优先于子类执行即父类的静态语句块先于子类static class Parent { public static int A 1; static { A 2; } } static class Sub extends Parent { public static int B A; } public static void main(String[] args) { System.out.println(Sub.B); // 输出 2 }接口接口中不能使用静态语句块但仍会有类变量赋值的初始化操作因此接口与类一样都会生成clinit()。但接口与类不同执行接口的clinit()方法不需要先执行父接口的clinit()——只有父接口中定义的变量被使用时父接口才会初始化接口的实现类在初始化时也一样不会执行接口的clinit()线程安全虚拟机会保证一个类的clinit()方法在多线程环境下被正确地加锁与同步——多个线程同时去初始化一个类时只会有一个线程执行这个类的clinit()其它线程阻塞等待。若clinit()中有耗时操作可能造成多块线程阻塞且这种阻塞很隐蔽。类初始化时机主动引用与被动引用虚拟机规范并未强制约束何时加载、验证、准备一个类但严格规定了有且只有以下 5 种情况必须对类进行初始化加载、验证、准备随之发生即主动引用遇到new、getstatic、putstatic、invokestatic四条字节码指令时若类没有初始化过必须先触发其初始化。最常见的生成场景使用new实例化对象读取或设置一个类的静态字段被final修饰、编译期已存入常量池的静态字段除外调用一个类的静态方法使用java.lang.reflect包的方法对类进行反射调用时当初始化一个类时若发现其父类还未初始化需要先触发父类初始化虚拟机启动时需要指定主类包含main()方法的类虚拟机先初始化这个主类使用 JDK 1.7 动态语言支持时若一个java.lang.invoke.MethodHandle实例最终解析为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄且对应类未初始化过则先触发其初始化。除以上 5 种场景外的所有引用方式都称为被动引用不会触发初始化。常见例子有三个通过子类引用父类的静态字段不会导致子类初始化System.out.println(SubClass.value); // value 字段在 SuperClass 中定义通过数组定义来引用类不会触发该类初始化SuperClass[] sca new SuperClass[10];该过程初始化的是数组类——一个由虚拟机自动生成的、直接继承自Object的子类其中包含数组的属性和方法——而不是SuperClass本身通过常量引用定义常量的类不会触发其初始化常量在编译期就存入调用方的常量池本质上没有直接引用到定义常量的类System.out.println(ConstClass.HELLOWORLD); // 不会触发 ConstClass 的初始化类与类加载器独立的命名空间判断两个类是否相等除了类本身全限定名要一样加载它们的类加载器也必须相同——因为每一个类加载器都拥有一个独立的类名称空间。这里的相等涵盖Class对象equals()、isAssignableFrom()、isInstance()的返回结果以及instanceof判定的结果。这也意味着即使两份com.example.Foo字节码完全一致只要由两个不同的类加载器加载它们就是两个不同的类。类加载器分类从 Java 虚拟机规范角度看只存在两种类加载器启动类加载器Bootstrap ClassLoader用 C 实现是虚拟机自身的一部分所有其它的类加载器用 Java 实现独立于虚拟机存在且都继承自抽象类java.lang.ClassLoader。从 Java 开发者角度通常划分为三层类加载器实现职责启动类加载器BootstrapC虚拟机自身的一部分加载JRE_HOME\lib目录中、或被-Xbootclasspath参数指定路径中、且虚拟机识别的仅按文件名识别如rt.jar名字不符的类库即使放在 lib 下也不会被加载类库。无法被 Java 程序直接引用需要委派时直接用null代替扩展类加载器Extensionsun.misc.Launcher$ExtClassLoader加载JAVA_HOME/lib/ext或被java.ext.dirs系统属性指定路径中的类库开发者可直接使用应用程序类加载器Applicationsun.misc.Launcher$AppClassLoaderClassLoader.getSystemClassLoader()的返回值故又称系统类加载器。负责加载用户类路径ClassPath上的类库若应用中没有自定义类加载器它就是默认的类加载器双亲委派模型Parents Delegation Model应用程序由上述三种类加载器互相配合实现类加载此外还可以加入自定义类加载器。类加载器之间的层次关系如下图所示即双亲委派模型除了顶层的启动类加载器外其它类加载器都必须有自己的父类加载器。注意这里的父子关系一般通过**组合关系Composition**而非继承关系Inheritance实现工作过程一个类加载器收到加载请求时先把请求转发到父加载器逐级向上传递直到启动类加载器只有父加载器无法完成即在自己的搜索范围内找不到该类时子加载器才尝试自己加载。好处Java 类随其类加载器一起形成带有优先级的层次关系从而使基础类得到统一。以java.lang.Object为例它存放在rt.jar中若开发者再写一个java.lang.Object并放到 ClassPath 中程序照样能编译通过但由于双亲委派模型的存在rt.jar中的Object由启动类加载器加载优先级更高ClassPath 中的Object由应用程序类加载器加载永远不会被加载——程序中所有的Object都是rt.jar里那一个。这保证了核心 API 类不会被恶意篡改。源码实现java.lang.ClassLoader的loadClass()方法正是上述逻辑的体现先检查类是否已加载未加载则委派父加载器加载父加载器抛出ClassNotFoundException后再调用自己的findClass()尝试加载public abstract class ClassLoader { // The parent class loader for delegation private final ClassLoader parent; public Class? loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); } protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class? c findLoadedClass(name); if (c null) { try { if (parent ! null) { c parent.loadClass(name, false); } else { c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c null) { // If still not found, then invoke findClass in order // to find the class. c findClass(name); } } if (resolve) { resolveClass(c); } return c; } } protected Class? findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); } }自定义类加载器实现自定义类加载器一般不重写loadClass()它承载了双亲委派逻辑而是重写findClass()根据类的全名定位到.class文件读取字节流后通过defineClass()转换成Class实例。以下FileSystemClassLoader用于从文件系统加载类public class FileSystemClassLoader extends ClassLoader { private String rootDir; public FileSystemClassLoader(String rootDir) { this.rootDir rootDir; } protected Class? findClass(String name) throws ClassNotFoundException { byte[] classData getClassData(name); if (classData null) { throw new ClassNotFoundException(); } else { return defineClass(name, classData, 0, classData.length); } } private byte[] getClassData(String className) { String path classNameToPath(className); try { InputStream ins new FileInputStream(path); ByteArrayOutputStream baos new ByteArrayOutputStream(); int bufferSize 4096; byte[] buffer new byte[bufferSize]; int bytesNumRead; while ((bytesNumRead ins.read(buffer)) ! -1) { baos.write(buffer, 0, bytesNumRead); } return baos.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return null; } private String classNameToPath(String className) { return rootDir File.separatorChar className.replace(., File.separatorChar) .class; } }由于loadClass()内置了双亲委派FileSystemClassLoader加载的请求会先逐级委派给应用、扩展、启动类加载器只有当父加载器都找不到该类时才会真正执行上面重写的findClass()从rootDir指定的文件系统路径读取字节码并定义新类。这也解释了为什么在普通场景下不能简单绕过双亲委派来重新加载java.lang包下的类。五、仓库内相关文档本篇内容以 notes/Java 虚拟机 为主体原文大部分内容参考周志明《深入理解 Java 虚拟机》一书结合仓库内以下文档可形成完整的 Java 知识体系Java 基础Java 语言基础与本文的引用、初始化等内容互补Java 容器JDK 集合实现弱引用在WeakHashMap类结构中的典型应用Java 并发线程与共享内存模型理解线程私有/共享区域划分的背景Java IONIO 章节面向块、非阻塞的 I/O 模型与文件、套接字实例是直接内存一节的应用延伸计算机操作系统 - 内存管理虚拟内存与分页机制理解元空间使用本地内存、堆外内存与操作系统交互的基础。参考资料周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社本文主体内容的参考来源。【免费下载链接】CS-Notes:books: 技术面试必备基础知识、Leetcode、计算机操作系统、计算机网络、系统设计项目地址: https://gitcode.com/GitHub_Trending/cs/CS-Notes创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表