知识结构

JVM内存结构

JVM内存空间:

  • 程序计数器
  • Java 虚拟机栈
  • 本地方法栈
  • 方法区

JVM内存结构

记忆方法:java程序由两部分组成:对象方法

  • 对象存在
  • 方法存在
    • 一般的方法存在jvm栈
    • 本地方法存在本地方法栈中。
  • 另外还有静态变量常量,存在方法区中。
  • 程序运行起来怎么知道运行到哪了呢,用程序计数器

JDK 1.8 同 JDK 1.7 比,最大的差别就是:元数据区(Metaspace)取代了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。

程序计数器

记录当前线程正在执行的那条字节码指令的地址

作用

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。
  • 在多线程情况下,程序计数器记录的是当前线程执行的位置,从而当线程切换回来时,就知道上次线程执行到哪了。

特点

  • 是一块较小的内存空间。
  • 线程私有,每条线程都有自己的程序计数器。
  • 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
  • 是唯一一个不会出现OutOfMemoryError的内存区域。

Java 虚拟机栈

描述 Java 方法运行过程的内存模型

Jvm栈为每个运行的java方法创建栈帧,存放信息:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息
  • ……

stack

压栈出栈过程

当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。

栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧。

方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化。

特点

  • 局部变量表大小编译时确定,不会改变
  • 栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
    • StackOverFlowError 若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常。(递归容易导致stackoverflow问题)
    • OutOfMemoryError 若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。
  • 线程私有

本地方法栈

本地方法是指java以外的语言写的方法,本地方法保存在动态链接库中。

描述本地方法运行过程的内存模型 ,几乎所有的对象都存储在堆中。

存放对象实例和数组

特点

  • 线程共享,需要注意同步问题
  • 垃圾回收的主要场所

方法区

堆的一个逻辑部分,存放:

  • 已经被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码

特点

  • 线程共享
  • JDK8使用元空间(Metaspace)代替永久代来实现方法区

运行时常量池

运行时常量池是方法区的一部分, 常量就存放在运行时常量池中

常量池是.class文件的一部分代码,记录了字面量和符号引用

当类被 Java 虚拟机加载后, .class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如 String 类的 intern() 方法就能在运行期间向常量池中添加字符串常量。

直接内存

除 Java 虚拟机之外的内存

在 NIO 中引入了一种基于通道和缓冲的 IO 方式。它可以通过调用本地方法直接分配 Java 虚拟机之外的内存,然后通过一个存储在堆中的DirectByteBuffer对象直接操作该内存,而无须先将外部内存中的数据复制到堆中再进行操作,从而提高了数据操作的效率。

HotSpot虚拟机对象探秘

对象的内存布局

HotSpot 虚拟机中分为三块区域:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

内存布局

对象头

记录了对象在运行过程中所需要使用的一些数据:

  • 哈希码
  • GC 分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程 ID
  • 偏向时间戳

对象头可能包含类型指针,通过该指针能确定对象属于哪个类。

如果对象是一个数组,那么对象头还会包括数组长度。

实例数据

成员变量的值,其中包括父类成员变量和本类成员变量

对齐填充

确保对象的总长度为 8 字节的整数倍

对齐填充并不是必然存在,也没有特别的含义,它仅仅起着占位符的作用。

对象创建过程

类加载检查

jvm遇到new指令-> 检查常量池中是否有这个类的符号引用 、 符号引用所代表的类是否已被加载、解析和初始化过

为新生对象分配内存

栈上分配

如果对象不会超出方法的使用范围(逃逸分析),则可以分配栈上内存,随着栈帧的销毁而回收内存。这样可以减少gc负担。

TLAB

当多线程在堆上分配内存时,需要同步,会导致效率差

给每个线程划分私有的堆空间,分配内存时先分配到自己那块空间,减少同步,提升效率。

对象所需内存的大小在类加载完成后便可完全确定 , 从堆中划分一块对应大小的内存空间给新的对象 。

  • 指针碰撞
    堆中内存规整,移动指针

    说明采用的是“标记-复制算法”或“标记-整理算法”,空闲内存和已使用内存中间放着一个指针作为分界点指示器,那么分配内存时只需要把指针向空闲内存挪动一段与对象大小一样的距离。

  • 空闲列表
    堆中内存不规整,从空闲列表中找内存分配

    已使用的内存和空闲内存交错(说明采用的是标记-清除算法,有碎片), VM 维护一个列表,记录其中哪些内存块空闲可用。分配之时从空闲列表中找到一块足够大的内存空间划分给对象实例。

对象创建时的线程安全问题:

  • CAS配上失败重试
  • 本地线程分配缓冲:每个线程预留一块内存

初始化

为对象中的成员变量赋上初始值,设置对象头信息,调用对象的构造函数方法进行初始化。

对象的访问方式

堆中放对象,栈里放对象引用,引用访问对象的方式有两种:

句柄访问方式

对象引用->句柄池->实例数据和类型数据

更安全,对象移动,引用不变

句柄方式

直接指针访问方式

对象引用->实例数据(含类型数据指针)->类型数据

更快

直接指针访问

垃圾收集策略与算法

程序计数器、虚拟机栈、本地方法栈:线程私有,内存分配和回收有确定性,无需过多考虑垃圾回收;

堆、方法区:动态分配内存和回收,垃圾收集器重点关注这块。

判断对象是否存活

一个对象不被任何对象或变量引用,则为无效对象,需要被回收。

JAVA主要用的可达性分析算法

引用计数法

在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。

简单,但很难解决对象间相互循环引用问题。

可达性分析

所有和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象(图论中的不可到达)就是无效对象。

GC Roots 是指:

  • Java 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中常量引用的对象
  • 方法区中类静态属性引用的对象

GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。

三色标记

(1) 黑色子节点已被访问

(2) 灰色当前节点访问了但是子节点没被访问

(3) 白色没被访问。

finalize()

可达性分析认为不可达后:

  • 没有finalize或者已经调用过一次,对象被回收
  • 执行finalize,对象放入一个队列中,虚拟机以较低的优先级执行这些 finalize()方法。对象在队列中被执行前如果被附加引用,则重生;否则被回收。

引用的种类

强软弱虚

强引用

引用赋值,类似“ Object obj = new Object()”

有强引用,GC就不会回收被引用对象

如果错误地保持了强引用,比如:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏

软引用

当JVM认为内存不足时,才会回收软引用的对象。

软引用对象都被清理了,内存仍不足,才会抛出OOM。

常用来实现内存敏感的缓存,有内存空闲就不清理,内存不足才清理。

弱引用

生存周期到下一次垃圾回收之前。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。

虚引用

完全不影响对象生存时间,仅仅是用来在对象被回收时,通知你它被回收了。

垃圾收集算法

清除、复制(新生代)、整理(老年代)

标记-清除算法

过程:

  • 标记:GC Roots遍历,标记存活(可达)对象
  • 清除:遍历堆,清除未被标记的对象
  • 或者反过来,标记非存活对象

缺点:

  • 效率问题:对象越多越耗时
  • 空间问题:内存碎片化,清除后产生大量不连续内存碎片

标记-复制算法

内存等分为两块,每次使用一块,GC时将存活者复制到另一块内存上,清空原来内存。

  • 优点:没有内存碎片化问题
  • 缺点:浪费空间

优化

  • 内存分为三块: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。
    • 为什么要Survivor区:避免一次Minor GC后对象直接从Eden进入老年代,从而避免过度的Full GC,使用Survivor后只有存活16次的才移到老年代
    • 为什么要两个:如果只有一个,每次次Minor GC后,Survivor区也会回收一部分,被回收的地方就变成了内存碎片,使用率大幅度降低
  • 分配担保:Survivor没有足够空间存放上一次新生代收集下来的存活对象,这些对象直接进入老年代

标记-整理算法

过程:

  • 标记:遍历GC Roots,标记存活
  • 整理:移动存活对象到一起,清除末端内存地址以后的内存

缺点:需要暂停应用程序,大量对象移动导致长时间停顿

对于老年代,关注延迟(内存回收)用清除算法,关注吞吐量(内存分配)用整理算法

HotSpot 垃圾收集器

七种,前三种新生代,后三种老生代,G1通用:

  • Serial(复制)
  • PalNew(复制)
  • Parallel Scavenge(复制)
  • G1(整理、复制)
  • CMS(清除)
  • Serial Old(整理)
  • Parallel Old(整理)

垃圾收集器

Serial

只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程

适合客户端(内存小,创建对象少,回收时间短)

ParNew

Serial 的多线程版本,只能和CMS配合使用

追求降低停顿时间,适合交互式应用

Parallel Scavenge

多线程

追求提升吞吐量,适合没有交互的后台

吞吐量=用户代码时间/(用户代码时间+GC时间)

G1

将堆划分为一块块独立的 Region。当要进行垃圾收集时,首先估计每个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,因此可以获得最大的回收效率。

  • 初始标记:Stop The World
    • 仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记
    • 修改TAMS 指针的值,让下一阶段用户线程并发运行时新分配的对象,都在指针地址之上(即默认为存活不回收)
  • 并发标记:并发执行,进行可达性分析,速度很慢。
  • 最终标记:Stop The World,处理并发标记阶段更新的对象引用关系。
  • 筛选回收:Stop The World,回收无效对象,选择价值最高的Region,复制移动Region。

对于Region之间的对象引用问题,使用记忆集来解决。每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针

缺点:

  • 内存占用更多(如每个Region都有记忆集)
  • 额外负载

G1和CMS的区别

  • G1清理过程可以设置停顿时间,时间长就多回收一些Region,时间短就少些;CMS时间不可控。
  • G1只有并发标记是并发的,其他3个阶段都要停顿;CMS并发标记和并发清除并发,另2个阶段停顿。
  • CMS只用于老年代,G1全都可以
  • G1基于标记整理和标记复制;CMS基于标记清除

CMS

使用标记清除,追求最短停顿时间,用户线程和GC线程并发。

  • 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记,速度快。
  • 并发标记:使用多条标记线程,与用户线程并发执行。此过程进行可达性分析,遍历标记出所有废弃对象。速度很慢。
  • 重新标记:Stop The World,修正上一步,并发标记过程中,本来不可达的对象由于新的引用变得可达了,这种对象标记出来不回收,速度较快。
  • 并发清除:只使用一条 GC 线程,与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。

CMS

缺点:

  • 吞吐量低(并发占用线程,应用程序变慢)
  • 无法处理浮动垃圾(并发标记过程中新出现的无效对象,包括本来可达变不可达)
  • 标记-清除产生内存碎片化问题

Serial Old

Serial 的老年代版本 ,使用标记-整理算法

Parallel Old

Parallel Scavenge 的老年代版本 ,追求吞吐量

ZGC

特点:

  • 停顿时间短,不超过10ms
  • 停顿时间不随着堆的增大而增大

原理:

  • 染色指针:m0,m1,remapped

    ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

  • 读屏障

    当应用线程从堆中读取对象引用时,如果发现对象引用不是最新的,读屏障会去获取最新的地址

  • page更灵活

    和G1一样分区,但是不固定大小,更灵活

一开始都是remapped视图

标记过程中,被访问过或被标记过,则转换到m0视图,即表示该对象存活。

并发转移时,如果被转移或被访问,则转换到remapped视图,即表示该对象

其他

并发与并行的区别:

  • 并发是两个队列交替使用一台咖啡机
  • 并行是两个队列同时使用两台咖啡机

并发与并行

内存分配与回收策略

对象内存主要分配在新生代的Eden区,少数情况直接分配在老年代,具体规则取决于垃圾收集器。

对象优先在 Eden 分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

  • Minor GC:回收新生代
  • Major GC:回收老年代
  • Full GC:回收整个堆

大对象直接进入老年代

大对象是指需要大量连续内存空间的 Java 对象,如很长的字符串或元素数量庞大的数组。

一个大对象能够存入 Eden 区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及大量的复制,就会造成效率低下。

虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。(新生代采用复制算法回收垃圾)

长期存活的对象将进入老年代

JVM 给每个对象定义了一个对象年龄计数器。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。

动态对象年龄判定

如果在Survivor空间中相同年龄所有对象的大小总和>Survivor空间的一半,年龄>=该年龄的对象就可以直接进入老年代。

空间分配担保

为防止Survivor空间不够Minor GC时的复制操作,老年代预留空间做担保。

  • JDK6之前:
    • 检查老年代最大可用连续空间是否大于新生代所有对象总空间:大于则Minor GC,否则下一步
    • 检查 HandlePromotionFailure 值是否允许担保失败
      • 允许: 检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小 :大于则Minor GC(有风险),否则Full GC
      • 不允许:Full GC
  • JDK6之后:
    • 抛弃HandlePromotionFailure 值, 只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

JVM触发Full GC的情况

  • System.gc() 方法的调用 :建议但不一定会 Full GC
  • 老年代空间不足
  • 永久代空间不足
  • CMS GC时有新的对象放入老年代,老年代空间不足
  • 统计得到的 Minor GC 晋升到旧生代的平均大小大于老年代的剩余空间

基础故障处理工具

jps

查看正在运行的虚拟机进程,显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一 ID(LVMID,Local Virtual Machine Identifier)

参数:

  • -l 输出主类全名

jstat

监视虚拟机各种运行状态信息,主要三类信息:类加载、垃圾收集、运行期编译状况

jstat -option VMID 间隔时间(ms) 次数

参数:

  • -gc 监视java堆状况,Eden区、Survivor区(S0,S1)、老年代等的容量,已用空间,垃圾收集时间等信息

  • -class 监视类加载相关信息

  • -compiler 编译统计

jinfo

实时查看和调整虚拟机各项参数

jmap

生成堆转储快照、查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等

先运行这句

1
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

新版的Linux系统加入了 ptrace-scope 机制. 这种机制为了防止用户访问当前正在运行的进程的内存和状态, 而一些调试软件本身就是利用 ptrace 来进行获取某进程的内存状态的(包括GDB),所以在新版本的Linux系统, 默认情况下不允许

jmap [ option ] vmid

参数:

  • -heap 查看堆的详细信息
  • -dump 生成java堆转储快照,格式:jmap -dump:[live,]format=b,file=<filename> vmid
  • -finalizerinfo 查看F-Queue中等待finalizer方法执行的对象
  • -histo 显示堆中对象统计信息,包括类、实例数量、合计容量
1
2
jmap -histo $pid > /tmp/jmapout/jmap.dmup.histo.`date+%H%M`
#内存dump

jstack

生成虚拟机当前时刻的线程快照,通常用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等。

jstack [ option ] vmid

参数:

  • -l 显示关于锁的附加信息
1
2
jstack -l $pid > /tmp/jmapout/jmap.jstack.`date+%H%M`
#线程dump

jcmd

1
2
jcmd -l $pid VM.native_memory detail > /tmp/jmapout/native_memory.`date+%H%M`
#堆外内存dump

OOM&Full GC

  • 打印日志
  • 获取dump文件
  • MAT分析
  • 修改验证
  1. JVM参数:

    1
    -Xms20m -Xmx20m -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC -XX:+HeapDumpOnOutOfMemoryError -XX:+HeapDumpBeforeFullGC -XX:+HeapDumpAfterFullGC -XX:HeapDumpPath=C:\Users\SJTU_116\Desktop\gc
  2. 查看日志

  3. 导入hprof文件到MAT中分析

参考这个视频

类文件结构

Class 文件是二进制文件 ,有两种数据类型:

  • 无符号数: u1、u2、u4、u8 分别代表 1/2/4/8 字节的无符号数
  • 表: 多个无符号数或者其他表作为数据项构成的复合数据类型

在vim下输入:%!xxd可查看十六进制的Class文件内容。

十六进制下class文件内容

魔数

Class 文件的头 4 个字节, 用来表示这个 Class 文件的类型, 相当于文件后缀名。(cafe babe)

版本信息

接下来4个字节是版本信息,java只向下兼容。
34对应jdk8

常量池

常量池存放两种类型常量:

  • 字面量:java语言中定义的常量,如字符串、final修饰的值
  • 符号引用:定义的各种名字,如类和接口的全限定名、字段的名称和描述符、方法的名字和描述符

特点:

  • 常量池数量不固定,开头放置一个u2类型的无符号数,记录常量池容量
  • 每个常量都是一个表。第一位是u1标志位,标志常量类型

访问标志

常量池后的2个字节,用于识别一些类或接口的访问信息,如这个Class是类还是接口、是否是public等

类索引、父类索引与接口索引集合

  • 类索引:u2数据,表示类名
  • 父类索引:u2数据,表示父类名
  • 接口索引集合:一组 u2 数据 ,表示实现的接口名

字段表集合

存储本类涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量
一个成员变量一个字段表
不会出现从父类(或接口)中继承而来的字段

方法表集合

描述方法的一些信息
方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译后的字节码指令。

属性表集合

每个属性对应一张属性表

类加载时机和过程

类加载:把类加载到内存中的过程

类的生命周期七个:加载、验证、准备、解析、初始化、使用、卸载。

类的生命周期

除了解析以外,其他都要按顺序开始。

加载

  • 通过类的全限定名来获取类的二进制字节流

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

  • 创建一个表示该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

  • 格式验证:字节流是否满足Class文件格式规范

  • 元数据验证:字节码语义分析,符合java语言规范

  • 字节码验证:对方法体验证

  • 符号引用验证:在解析阶段发生

准备

为类变量(或称“静态成员变量”)分配内存并设置初始值。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。

1
public static int value = 123;

初始化为0

1
public static final int value = 123;

初始化为123

解析

将常量池内的符号引用替换为直接引用

初始化

执行类构造器 <clinit>() 方法 , <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static {} 块)中的语句合并产生的,先执行父类的。

类加载器

类与类加载器

实现“通过一个类的全限定名来获取描述该类的二进制字节流”功能

判断类是否“相等”

任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。

“相等”:包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

加载器种类

  • 启动类加载器(Bootstrap ClassLoader): 负责将存放在 <JAVA_HOME>\lib 目录中的,并且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。

  • 扩展类加载器(Extension ClassLoader): 负责加载 <JAVA_HOME>\lib\ext 目录中的所有类库,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader): 由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为“系统类加载器”。它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

类加载器双亲委派模型

双亲委派模型

双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)

工作过程

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

在 java.lang.ClassLoader 中的 loadClass() 方法中实现该过程

ExtClassLoader为什么没有设置parent?

ClassLoader类的getParent()函数可以输出父类加载器。

1
2
3
4
5
6
7
8
9
10
11
 public static void main(String[] args) throws ClassNotFoundException {
ClassLoader classLoader = Test.class.getClassLoader();
System.out.println(classLoader);
System.out.println(classLoader.getParent());
System.out.println(classLoader.getParent().getParent());
}

//结果输出
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@5a61f5df
null

因为BootstrapClassLoader是由c++实现的,所以并不存在一个Java的类,因此会打印出null

为什么要双亲委派模型

保证基础类仅加载一次,不会让jvm中存在重名的类。

比如String.class,每次加载都委托给父加载器,最终都是BootstrapClassLoader,都保证java核心类都是BootstrapClassLoader加载的,保证了java的安全与稳定性。

打破双亲委派

针对java.*开头的类,jvm的实现中已经保证了必须由bootstrp来加载

但是可以自定义一个类,包名不是java.*,可以放在用户目录下加载。

自己实现ClassLoader

继承ClassLoader类,重写findClass(String name)方法。

首先看下ClassLoader类的loadClass()的代码:加载类的流程

  • 查看是否加载过,加载过就直接返回类
  • 没加载过得话递归往上,让父类类加载器加载,父类为null说明父类就是启动类加载器
  • 如果启动类也无法加载,调用findClass()来加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 查看是否已经加载过该类,加载过的类会有缓存,是使用native方法实现的
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//父类不为空则先让父类加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//父类是null就是BootstrapClassLoader,使用启动类类加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类类加载器不能加载该类
}

//如果父类未加载该类
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
//让当前类加载器加载
c = findClass(name);
}
}
return c;
}
}

自定义加载器并重写findClass()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class MyClassLoader extends ClassLoader {
//用于读取.Class文件的路径
private String swapPath;
//用于标记这些name的类是先由自身加载的
private Set<String> useMyClassLoaderLoad;

public MyClassLoader(String swapPath, Set<String> useMyClassLoaderLoad) {
this.swapPath = swapPath;
this.useMyClassLoaderLoad = useMyClassLoaderLoad;
}

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
Class<?> c = findLoadedClass(name);
if (c == null && useMyClassLoaderLoad.contains(name)){
//特殊的类让我自己加载
c = findClass(name);
if (c != null){
return c;
}
}
return super.loadClass(name);
}

@Override
protected Class<?> findClass(String name) {
//根据文件系统路径加载class文件,并返回byte数组
byte[] classBytes = getClassByte(name);
//调用ClassLoader提供的方法,将二进制数组转换成Class类的实例
return defineClass(name, classBytes, 0, classBytes.length);
}

private byte[] getClassByte(String name) {
String className = name.substring(name.lastIndexOf('.') + 1, name.length()) + ".class";
try {
FileInputStream fileInputStream = new FileInputStream(swapPath + className);
byte[] buffer = new byte[1024];
int length = 0;
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
while ((length = fileInputStream.read(buffer)) > 0){
byteArrayOutputStream.write(buffer, 0, length);
}
return byteArrayOutputStream.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return new byte[]{};
}
}

可以看到就是启动类无法加载的类或者自己选定的类由findClass函数加载。重写之后就是到.Class所在路径下找到对应的类文件,读取字节流。即“通过一个类的全限定名来获取描述该类的二进制字节流”。

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//自定义一个示例类,用于被我们自己的类加载器加载
public class Test {
public void printVersion(){
System.out.println("当前版本是1哦");
}
}

//写个定时任务,一直调用printVersion方法,观察输出,看我们是否替换成功。
public static void main(String[] args) {
//创建一个2s执行一次的定时任务
new Timer().schedule(new TimerTask() {
@Override
public void run() {
String swapPath = MyClassLoader.class.getResource("").getPath() + "swap/";
String className = "com.example.Test";

//每次都实例化一个ClassLoader,这里传入swap路径,和需要特殊加载的类名
MyClassLoader myClassLoader = new MyClassLoader(swapPath, Sets.newHashSet(className));
try {
//使用自定义的ClassLoader加载类,并调用printVersion方法。
Object o = myClassLoader.loadClass(className).newInstance();
o.getClass().getMethod("printVersion").invoke(o);
} catch (InstantiationException |
IllegalAccessException |
ClassNotFoundException |
NoSuchMethodException |
InvocationTargetException ignored) {
}
}
}, 0,2000);
}

操作步骤:

  1. 先编译下工程,将Test.class拷贝到swap文件夹下。
  2. 运行main方法,可观察到控制台一直输出“当前版本是1哦”。
  3. 修改Test#pringtVersion方法的源代码,将输出的内容改为”当前版本是2哦”,然后编译工程,将新的Test.class拷贝到swap文件件下,并替换之前的Test.class。

输出结果显示没更换前是输出“当前版本是1哦”,更换了以后是输出“当前版本是2哦”

我们在没有重启的情况下,完成了类的热替换。

为什么需要o.getClass().getMethod(“printVersion”).invoke(o);这样通过反射获取method调用,不能先强转成Test,然后test.printVersion()吗

我:因为如果你这么写

1
2
Test test = (Test)o;
o.printVersion();

Test.class会隐性的被加载当前类的ClassLoader加载,当前Main方法默认的ClassLoader为AppClassLoader,而不是我们自定义的MyClassLoader。然后会抛出ClassCastException,因为一个类,就算包路径完全一致,但是加载他们的ClassLoader不一样,那么这两个类也会被认为是两个不同的类。

更多