写点什么

JVM

用户头像
ltc
关注
发布于: 2021 年 08 月 04 日

运行时数据区域

程序计数器

程序计数器,可以看做是当前线程所执行字节码的行号指示器。其中,每个线程都有一个独立的程序计数器,这样线程切换后都能恢复到正确的执行位置。

如果一个线程正在执行是一个 Java 方法,这个计数器是记录的是正在执行的是虚拟机字节码指令地址,如果执行的是本地方法,这个计数器值则应为空。

Java 虚拟机栈

Java 虚拟机栈是线程私有的,他的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型,每个方法被创建的时候,Java 虚拟机都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。

局部变量表

局部变量表存放了编译期间可知的各种 Java 虚拟机的基本数据类型、对象引用和 returnAddress 类型。

这些数据类型在变量表中存储的空间以局部变量槽来标识,其中 long 和 double (64 位)会占用两个局部变量槽,其余数据类型均占一个。

如果线程所请求的栈大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。如果 Java 虚拟机容量可动态扩展。当栈扩展到无法申请足够内存是会抛出 OutOfMemoryError 异常。

本地方法栈

本地方法栈与 Java 虚拟机栈的功能类似,但是本地方法栈是为虚拟机使用到本地 (Native) 方法而服务的。

Java 堆

Java 堆是虚拟机管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域。Java 堆在虚拟机启动时创建,此内存区域的唯一目的就是存放实例对象。

方法区

方法区是各个线程共享的内存区域,用于存储已被虚拟机加载类型信息,常量和静态变量,即时编译器编译后的代码缓存等数据。也称 非堆( Non-Heap)

运行时常量池 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与引用符号,这部分内容将在类加载后存放到方法区的运行时常量池中。

直接内存

直接内存并不是 Java 虚拟机运行时的数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。JDK 1.4 中加入了 NIO(new Input/OutPut) 类,引用了一种基于 Channel(通道) 与 Buffer(缓冲区) 的 I/O 方式,他可以直接使用 Native 函数库直接分配堆外内存,然后在 Java 堆里面的 DirectByteBuffer 对象作为对这块内存的引用进行操作。避免了在 Java 堆和 Native 堆中来回进行复制数据。

Java 的对象

对象创建

通过 new 创建对象的过程

当 Java 虚拟机遇到一条字节码指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号的引用,并检查这个符号引用代表的类是否被加载解析和初始化过。如果没有则先执行相应的类加载过程。

在类加载检查通过后,虚拟机便会为新生的对象分配内存。在 Java 堆中,如果内存是规整的,即所有使用过的内存放在一边,未被使用的内存放在另一边,使用指针作为这两个区域的分界点,这时分配内存只需要将指针移动所分配内存大小的距离即可,这种内存分配方式称为“指针碰撞(Bump To Pointer)”。如果 Java 堆是不规整的,即已使用的内存和空闲的内存相互交错在一起,这时虚拟机就需要维护一个列表,记录那些内存是可用的。在分配时从列表中找一块足够大的空间划分给实例对象,并更新列表上的内容,这种分配方式被称为“空闲列表(Free List)”

由于对象的创建在虚拟机中是非常频繁的,为了避免并发环境下的非线程安全问题,虚拟机可以采用两种方案:一是采用 CAS 配上失败重试方式,保证更新操作的原子性;第二种便是将内存分配划分到不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲区(Thread Local Allocation Buffer, TLAB),即哪个线程要分配内存,就在哪个线程的分配缓冲区中进行分配。

对象的内存布局

对象在堆内存中的存储布局可划分为三个部分,对象头(Header)、实例数据(Instance Data) 和对齐填充(Padding)。

对象头

对象头又包括三个部分,MarkWord,元数据指针、数组长度

MarkWord

用于存储对象自身的运行时数据,如 哈希码(HashCode)、GC 分带年龄、锁状态标志、线程持有的锁、偏向锁 id、偏向时间戳(Epoch)等。MarkWord 长度在 32 位和 64 位虚拟机中分别为 32 个比特和 64 个比特。

为了最大成本的节约虚拟机的空间效率,MarkWord 是一个有着动态定义的数据结构,以便在有限空间下存储尽可能多的数据,根据对象的状态复用自己的存储空间。


类型指针

对象指向它的类型元数据的指针,Java 虚拟机需要通过这个指针来确定该对象是哪个类的实例。

32 位系统中,MarkWord 为 4 个字节 32 位,类型指针也占 4 个字节。而在 64 位系统中 MarkWord 占 8 个字节,类型指针在开启指针压缩的状态下只有 4 个字节,在未开启指针压缩的情况下有 8 个字节,且在 JDK 1.6 之后,指针压缩都是默认开启的。


数组长度 如果对象是一个数组对象便拥有该区域,若不是数组便没有该区域,该区域长度为 4 个字节,用于存储 Java 对象的大小

实例数据

该区域存储了对象的有效信息,即对象内部的各个类型的字段内容,无论是父类中继承下来的还是子类中定义的字段都必须记录起来。

但是该部分的内存分配策略会受到虚拟机分配策略参数和字段在 Java 源码中定义顺序的影响。HotSpot 虚拟机默认的分配顺序为 longs/doubles、ints、shorts/charts、bytes/booleans、oops。

对齐填充

第三部分对齐填充并不是必然存在的,并无特殊意义,知识因为在 虚拟机中内存管理要求所有对象的其实地址必须是 8 字节的整数倍,因此对象的大小也为 8 字节的整数倍。

定位访问对象

Java 程序会根据栈上的 reference 数据来操作堆上的具体对象。而 reference 访问到 Java 堆上的对象主要有使用句柄直接指针两种方式。

句柄访问

Java 堆中会划分出一块内存作为句柄池,reference 中存储的对象就是句柄池地址,句柄池中包含了对象的实例数据与类型数据各自具体的地址信息。


该方法的优点是句柄池中的句柄地址是稳定的,在对象被移动(垃圾回收时)时,只会改变句柄池中对象实例指针的地址,而 reference 本身不需要被修改。

直接指针

reference 中存储的直接就是实例对象的地址,有关于对象类型的地址则被放入到实例对象中。


直接指针的优点在于速度快,它节省了一次指针定位的开销,在只需访问对象中的实例内容时,不需要多一次的间接访问开销。

垃圾回收

如何判断对象已死

在 Java 对对象进行回收之前,需要判断哪些对象已死,哪些对象存活,常用的判断方法有两种: 引用计数法可达性分析法

引用计数法

引用计数法就是在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一,当引用失效时就减一。当计数器为 0 时,对象便不再被引用。

引用计数法虽然消耗了一些空间,但是原理简单,判定高效,在绝大多数情况下是一个不错的算法。但是引用计数法需要考虑到很多例外的情况,必须要配合大量的额外工作才能正确的工作,比如单纯引用计数法就很难解决对象之间相互循环引用的问题。

可达性分析法

通过一系列 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,向下搜索的过程中所走过的路径称为 引用链(Reference Chain)。如果对象没有任何一条引用链路连接,即从任何一个 GC Roots 出发都不能到达该对象。


在 Java 中,GC Roots 包含:

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

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

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

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

  • Java 虚拟机内部引用的对象,比如基本数据类型对应的 class 对象以及一些常驻对象。

  • 被同步锁持有的对象

  • 反应 Java 虚拟机内部情况的 JMX Bean,与 JVMTI 中注册的回调和本地代码缓存

Java 中的引用

在 JDK 1.2 后,扩充了引用的概念,将引用依强度依次分为 强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)

强引用 强引用是指在程序代码中普遍存在的引用类型的赋值。一般只要强引用关系还在,垃圾收集器就永远不会被回收掉。

软引用 被软引用关联的对象,会在系统即将发生内存溢出异常前进行回收。

弱引用 被弱引用关联的对象只能生存到下一次垃圾收集为止。

虚引用 被虚引用关联的对象,既不会影响对象的生存时间,也无法通过虚引用获取对象实例,只是在该对象被回收时收到一个系统的通知。

垃圾回收过程

在进行垃圾回收是,首先会对对象进行可达性分析,若果发现对象没有与 GC Roots 相连接的链路,将会对对象进行第一次标记。然后看标记的对象是否覆盖了 finalize 方法或者 finalize 方法已经被虚拟机调用过了,如果没有覆盖或者已经被调用过了,则直接回收; 如果没有则将对象放入到 F-Queue 中主备通过 Finalizer 线程去执行这些方法,在执行完之后,垃圾收集器会对对象进行二次标记,如果对象依旧被 GC Roots 引用,则对象会被回收。


finalize 方法只能被 jvm 调用一次,且对象被标记后只有通过 finalize 方法来逃脱,且只有一次逃离机会。

方法区的垃圾回收

在方法区 (又称元数据或永久代) 中,垃圾的回收对象为两种,废弃的常量不再使用的类型

废弃常量的判定: 判定一个常量是否是废弃常量只需要判断虚拟机中有没有其他地方引用这个常量。

不再使用类型的判定:

  • 该类的所有实例被回收

  • 加载该类的加载器已经被回收

  • 该对象的 java.lang.class 对象没有在任何地方被引用

分代收集理论

当前虚拟机的垃圾收集器,大多都遵循了“分代收集理论”,该理论依据实际情况和法则建立,也是建立在两个分代假说之上

分代假说

  1. 弱分代假说:绝大多数对象都是朝生夕灭的

  2. 强分代假说:越是熬过多次垃圾收集的对象,越难以消亡。

Java 堆中垃圾回收分区

在 Java 堆中,会将新生代分成一块较大的 Eden 区,和两块较小的 Survivor 区。在 HotPots 虚拟机中,这一比例默认为为 8:1:1.


垃圾回收的方式

  • 部分收集 (Partical GC):指不完整收集整个 Java 堆新生代收集 (Minor GC/Young GC):指新生代中的垃圾收集器老年代收集 (Major GC/Old GC):指老年代的垃圾收集器混合收集 (Mixed GC):指整个新生代和部分老年代的垃圾收集器(G1 收集器)

  • 整堆收集 (Full GC):整个 Java 堆的垃圾收集器

回收算法

标记-清除算法

标记清除算法会首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象

缺点:

  • 执行效率不稳定,标记和清除这两个动作的执行效率会随着对象数量的增长而降低

  • 该方法会留下大量的碎片空间


标记-复制算法

标记复制算法会将内存按照容量大小划分为相等的两块,每次只使用其中的一块,当这一块用完了,就将还活着的对象复制到另一块上,然后再把已使用过的空间清理掉。

缺点:

  • 如果内存中多数对象都是活的,这种算法会产生大量的内存间复制开销

  • 空间浪费


该算法常用于 Java 堆中新生代的垃圾回收方法。每次分配内存只使用 Eden 和 一块 Survivor 区,当进行垃圾收集时,会将 Eden 和 Survivor 区上存活的对象复制到另一块 Survivor 区上。

标记-整理算法

在对象存活较多时,就需要进行较多的复制操作,会降低回收的效率。标记整理算法是对存活的对象进行标记,然后对对象进行整理,这样既减少了复制的次数,也避免了空间的浪费。

垃圾收集器

经典垃圾收集器

Serial/Serial Old 收集器

Serial 收集器是一个新生代收集器,采用复制算法。Serial Old 收集器是 Serial 收集器的老年代版本,采用标记-整理算法。

Serial 收集器与 Serial Old 收集器都是一个单线程收集器,在垃圾回收时,他们会暂停所有用户线程,直到垃圾收集的结束。一旦所需要回收的垃圾较多时,则用户线程等待的时间便会相当漫长, 从而影响用户体验。

Serial 收集器是 HotSpot 虚拟机运行在客户端下默认的新生代收集器,与其他收集器相比简单高效,并且内存消耗较少, 在单核或多核环境下,几乎没有线程切换的开销。

Serial Old 收集器主要也是供客户端模式下的 HotSpot 虚拟机使用。Serial Old 还可以在服务端作为 CMS 收集器发生 Concurrent Mode Failure 失败后的备份预案。


ParNew 收集器

ParNew 收集器(标记-复制算法)可以视为是 Serial 收集器的多线程版本。因为其所有控制参数、收集算法、Stop The World、对象分配规则、回收策略都与 Serial 收集器完全一致。


ParNew 收集器在单核环境中的性能始终不如 Serial 收集器。

Parallel Scavenge/ Parallel Old 收集器

Parallel Scavenge 收集器也是一个新生代收集器,也是基于标记-复制算法实现的垃圾收集器,也是能够并行收集的多线程收集器。如果 CMS 收集器关注点在与尽量缩短垃圾收集时的时间,Parallel Scavenge 收集器则的目标则是达到一个可控的吞吐量。


Parallel Old 收集器(标记-整理算法)可以视为 Parallel Scavenge 收集器的老年代版本。


CMS 收集器

CMS (Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器,通常用于 B/S 系统服务端上,对服务的响应速度较为关注。

CMS 收集器工作分为以下四个步骤

  1. 初始标记 (CMS inital Mark) : 标记一下 GC Roots 能直接关联到的对象,该步骤需要 Stop The Wrold

  2. 并发标记 (CMS concurrent mark) : 对 GC Roots 进行 Tracing

  3. 重新标记 (CMS remark) : 修正因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要 Stop The World

  4. 并发清楚 (CMS concurrent sweep) : 回收垃圾


concurrent mode failure 由于 CMS 是和用户线程并行的,如果在并行清理的过程中,老年代产生的垃圾不足以容纳应用产生的垃圾则会产生 concurrent mode failure

CMS 有三个明显的缺点

  • 对 CPU 资源非常敏感。在并发标记过程中,虽然不会对用户进程产生影响,但是由于占用了 CPU 资源,导致总吞吐量下降,用户进程便会变慢,CMS 默认启动线程数量是 (CPU 数量 + 3),这就意味着当 CPU 数量不足 4 个时,CMS 便会对用户线程产生很大的影响。

  • CMS 无法处理浮动式垃圾。由于 CMS 在并发标记和并发清理阶段还是会有用户线程产生垃圾,但是这部分垃圾产生在标记结束之后,因此 CMS 只好在下一次垃圾回收时清理掉这些垃圾。

  • CMS 是一款基于 标记-清除算法实现的收集器,这便意味着有大量的空间碎片产生。

CMS 在解决“对象消失”问题时采用的是增量更新的方法

Garbage First (G1) 收集器

G1 是一款主要面向服务端的垃圾收集器,G1 相较于其他几个收集器,可以面向堆内任何部分来组成回收集,而不只是单单面对新生代(Minor GC),老年代(Major GC)或者整堆(Full GC)这三者中的一个。

G1 回收器的回收标准并不是判断对象是哪个代,而是那块内存中存放的垃圾数量最多,垃圾回收的收益就最大。

G1 开创了基于 Region 的堆内存布局。G1 不再坚持以固定的大小以及固定的数量来分代划分区域,而是把连续的 Java 堆划分成为多个大小不等的独立区域。每个区域可以根据需要来扮演 Eden、Survivor、或者老年代空间,这样无论是新创建的对象还是已存活了一段时间的对象还是 熬过多次收集的对象,都能取到很好的收集效果。

在 Region 中还有一个名为 Humongous 区域,专门用来存储大对象,只要是大小超过 Region 一半的对象就会被认定为大对象。

G1 收集器虽然仍然保留了新生代和老年代的概念,但是 新生代和老年代都不是固定的了,他们之间的内存区域并不是相连的,因此避免了在整个 Java 堆中进行全区域的垃圾收集。

G1 收集器也需要耗费相当于 Java 堆中 10% ~ 20%的额外内存来维持收集器的工作。

G1 收集器在解决 “对象消失”问题时采用的是原始快照 (SATB) 算法。

G1 垃圾收集器为了解决在垃圾回收过程中新对象内存的分配问题时,G1 为每一个 Region 都设置了两个 TAMS (Top at Mark Start) 的指针。

G1 回收器回收的四个工作阶段:

  • 初始标记 (Intial Marking): 标记 GC Roots 能直接关联到的对象,并且修改 TAMS 的值,让下一阶段用户线程并发运行时能正确的在 Region 中正确分配新对象。

  • 并发标记 (Concurrent Marking): 从 GC Roots 开始进行可达性分析

  • 最终标记 (Final Marking):对用户线程做一个短暂的停留,用于处理并发结束后少量的 STAB 记录

  • 筛选回收 (Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户的期望停顿时间,来制定回收计划。

类加载

类加载的过程

类的个生命周期如下图:



为支持运行时绑定,解析过程在某些情况下可在初始化之后再开始,除解析过程外的其他加载过程必须按照如图顺序开始。

加载
  1. 通过全限定类名来获取定义此类的二进制字节流。

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  1. 文件格式验证:如是否以魔数 0xCAFEBABE 开头、主、次版本号是否在当前虚拟机处理范围之内、常量合理性验证等。此阶段保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。

  2. 元数据验证:是否存在父类,父类的继承链是否正确,抽象类是否实现了其父类或接口之中要求实现的所有方法,字段、方法是否与父类产生矛盾等。该阶段保证不存在不符合 Java 语言规范的元数据信息。

  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如保证跳转指令不会跳转到方法体以外的字节码指令上。

  4. 符号引用验证:在解析阶段中发生,保证可以将符号引用转化为直接引用。

可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。

解析

虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

初始化

到初始化阶段,才真正开始执行类中定义的 Java 程序代码,此阶段是执行 <clinit>() 方法的过程。

<clinit>() 方法是由编译器按语句在源文件中出现的顺序,依次自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。(不包括构造器中的语句。构造器是初始化对象的,类加载完成后,创建对象时候将调用的 <init>() 方法来初始化对象)。


<clinit>() 不需要显式调用父类(接口除外,接口不需要调用父接口的初始化方法,只有使用到父接口中的静态变量时才需要调用)的初始化方法 <clinit>(),虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。


<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。


虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。

类加载的时机

对于初始化阶段,虚拟机规范规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到 new、getstatic 和 putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。对应场景是:使用 new 实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法。

  2. 对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

  3. 当初始化类的父类还没有进行过初始化,则需要先触发其父类的初始化。(而一个接口在初始化时,并不要求其父接口全部都完成了初始化)

  4. 虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。

  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

以上这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用,例如:

  1. 通过子类引用父类的静态字段,不会导致子类初始化。

  2. 通过数组定义来引用类,不会触发此类的初始化。MyClass[] cs = new MyClass[10];

  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

类加载器


把实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。


将 class 文件二进制数据放入方法区内,然后在堆内(heap)创建一个 java.lang.Class 对象,Class 对象封装了类在方法区内的数据结构,并且向开发者提供了访问方法区内的数据结构的接口。


目前类加载器却在类层次划分、OSGi、热部署、代码加密等领域非常重要,我们运行任何一个 Java 程序都会涉及到类加载器。


类的唯一性和类加载器


对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性。


即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也不相等。这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、 isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。


双亲委派模型


如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。


这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。


Bootstrap 类加载器是用 C++ 实现的,是虚拟机自身的一部分,如果获取它的对象,将会返回 null;扩展类加载器和应用类加载器是独立于虚拟机外部,为 Java 语言实现的,均继承自抽象类 java.lang.ClassLoader ,开发者可直接使用这两个类加载器。


Application 类加载器对象可以由 ClassLoader.getSystemClassLoader() 方法的返回,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。


双亲委派模型对于保证 Java 程序的稳定运作很重要,例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。


破坏双亲委派模型


双亲委派模型主要出现过 3 较大规模的“被破坏”情况。


双亲委派模型在引入之前已经存在破坏它的代码存在了。

双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK 1.0 时代就已经存在,JDK 1.2 之后,其添加了一个新的 protected 方法 findClass(),在此之前,用户去继承 ClassLoader 类的唯一目的就是为了重写 loadClass() 方法,而双亲委派的具体逻辑就实现在这个方法之中,JDK 1.2 之后已不提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载逻辑写到 findClass() 方法中,这样就可以保证新写出来的类加载器是符合双亲委派规则的。


基础类无法调用类加载器加载用户提供的代码。

双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),但如果基础类又要调用用户的代码,例如 JNDI 服务。JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器去加载(在 JDK 1.3 时放进去的 rt.jar ),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface,例如 JDBC 驱动就是由 MySQL 等接口提供者提供的)的代码,但启动类加载器只能加载基础类,无法加载用户类。为此 Java 引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread.setContextClassLoaser() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。如此,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。


用户对程序动态性的追求。

代码热替换(HotSwap)、模块热部署(Hot Deployment)等,OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。


自定义类加载器


Java 默认 ClassLoader,只加载指定目录下的 class,如果需要动态加载类到内存,例如要从远程网络下来类的二进制,然后调用这个类中的方法实现我的业务逻辑,如此,就需要自定义 ClassLoader。


自定义类加载器分为两步:


  1. 继承 java.lang.ClassLoader

  2. 重写父类的 findClass() 方法


针对第 1 步,为什么要继承 ClassLoader 这个抽象类,而不继承 AppClassLoader 呢?因为它和 ExtClassLoader 都是 Launcher 的静态内部类,其访问权限是缺省的包访问权限。static class AppClassLoader extends URLClassLoader{...}


第 2 步,JDK 的 loadCalss() 方法在所有父类加载器无法加载的时候,会调用本身的 findClass() 方法来进行类加载,因此我们只需重写 findClass() 方法找到类的二进制数据即可。


new 一个对象的过程


  1. 确认类元信息是否存在。当 JVM 接收到 new 指令时,首先在 metaspace 内检查需要创建的类元信息是否存在。 若不存在,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名+类名为 Key 进行查找对应的 class 文件。 如果没有找到文件,则抛出 ClassNotFoundException 异常 , 如果找到,则进行类加载(加载 - 验证 - 准备 - 解析 - 初始化),并生成对应的 Class 类对象。

  2. 分配对象内存。 首先计算对象占用空间大小,如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小,接着在堆中划分—块内存给新对象。 在分配内存空间时,需要进行同步操作,比如采用 CAS (Compare And Swap) 失败重试、 区域加锁等方式保证分配操作的原子性。

  3. 设定默认值。 成员变量值都需要设定为默认值, 即各种不同形式的零值。

  4. 设置对象头。设置新对象的哈希码、 GC 信息、锁信息、对象所属的类元信息等。这个过程的具体设置方式取决于 JVM 实现。

  5. 执行 init 方法。 初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。


用户头像

ltc

关注

ltc 2019.07.04 加入

还未添加个人简介

评论

发布
暂无评论
JVM