虚拟机运行快速复习
try-catch:catch-异常表+栈展开,finally-代码复制+异常表兜底
类的生命周期:加载,连接(验证,准备,解析),初始化,使用,卸载
类加载器:加载字节码.Class到JVM中生成一个Class对象
大部分类在具体用到的时候才会去加载(懒加载机制),已经加载的类会被放在 ClassLoader
中
对于一个类加载器来说,相同二进制名称的类只会被加载一次
类加载过程:
加载:通过类的全限定名获取该类的二进制字节流,存到类常量池,内存中生成Class对象
连接:
- 验证:验证Class二进制字节流合规
- 准备:为类对象分配内存
- 解析:符号引用转为直接引用
初始化:执行初始化方法
对象创建过程:类加载检查+分配内存+初始化零值+设置对象头+执行对象初始化方法
类加载器:
启动类加载器
|
拓展类加载器
|
应用程序类加载器
|
自定义类加载器
因为启动类加载器是C++做的,所以获取到ClassLoader为NULL的时候就是启动类加载器
自定义类加载器:
需要继承 ClassLoader
抽象类
不打破双亲委派机制:重写 ClassLoader
类中的 findClass()
打破双亲委派机制:重写 loadClass()
方法
类卸载:类无用的时候卸载
无用的类:所有实例被回收,类加载器被回收,Class对象没有任何引用
双亲委派机制:从下往上判断类是否被加载,从上往下尝试加载类
JVM 判定两个 Java 类是否相同的具体规则:全限定类名+类加载器
为什么要用双亲委派机制:可以避免类被重复加载,同时保证我们的核心API不被修改
如何实现热部署:自定义类加载器,并重写 ClassLoader 的 loadClass() ⽅法
热部署三步:
1)销毁原来的⾃定义 ClassLoader
2)更新 class 类⽂件
3)创建新的 ClassLoader 去加载更新后的 class 类⽂件
try-catch在jvm层面是怎么做的?
java中的try-catch通过异常表和栈展开来实现
异常表(exception-table)
每个方法的字节码中都有一个异常表,用于记录try-catch块的作用范围和对应的异常处理逻辑
异常表的每个条目包含以下信息:
起点,终点,处理代码的位置,捕获异常的类型
起点(start_pc):try块的起始指令偏移量
终点(end_pc):try块的结束指令偏移量(不包含该指令)
处理代码位置(handler_pc):catch块的第一条指令偏移量
捕获的异常类型(catch_type):要捕获的异常类(如java/lang/Exception),若为0表示捕获所有异常(finally块)
字节码结构
Exception table:
start end handler type
0 10 13 java/io/IOException
0 10 20 java/lang/Exception
异常处理流程
抛出异常,从异常表判断异常是否在处理逻辑内(也就是是否被try-catch{}包围),
代码中抛出异常时,JVM会执行以下步骤:
创建异常对象:实例化抛出的异常(如new IOException()
)
查找异常表:从当前方法的异常表中,按顺序匹配以下条件:
异常抛出的位置是否在某个条目的[start_pc, end_pc)
范围内。
抛出的异常是否是catch_type
的子类(或自身)
跳转到处理代码:
若找到匹配条目,跳转到handler_pc
执行catch
块
若未找到,触发栈展开:弹出当前栈帧,回到调用者方法重复上述过程。栈展开确保异常沿调用链向上传播,直到被处理或终止线程
未捕获异常:若所有栈帧均未处理异常,线程终止并打印堆栈跟踪
finally块的实现
finally 块的核心是:无论 try 或 catch 块中是否抛出异常或提前返回,finally 中的代码必须执行
为了实现这一点,JVM 的编译器(如 javac)在生成字节码时,会通过两种机制来确保 finally 的执行
finally块通过两种方式实现:
代码复制:编译器将finally
代码复制到try
和catch
块的所有退出路径(包括return
或异常抛出之后)。
异常表条目兜底:若finally
需要处理异常退出,会生成一个catch_type=0
的条目,捕获所有异常并执行finally
代码,之后重新抛出异常
代码复制
编译器会将 finally 块中的代码复制到所有可能的退出路径,包括:
try 块正常结束后的退出路径。
catch 块处理完异常后的退出路径。
try 或 catch 块中的 return、break、continue 语句之前
java代码
public void example() {
try {
System.out.println("try");
} catch (Exception e) {
System.out.println("catch");
} finally {
System.out.println("finally");
}
}
编译后的字节码逻辑
// try 块
L0:
System.out.println("try");
// 复制 finally 代码到 try 块末尾
System.out.println("finally");
return;
// catch 块
L1:
System.out.println("catch");
// 复制 finally 代码到 catch 块末尾
System.out.println("finally");
return;
// 异常表条目(自动处理异常后的 finally)
Exception table:
start=L0, end=L0, handler=L1, type=Exception
关键点:
finally 的代码会被复制到 try 和 catch 的末尾,确保正常流程下一定会执行
如果 try 或 catch 中有 return,编译器会先执行 finally 代码,再执行 return
异常表兜底(处理未捕获的异常)
如果 try 或 catch 块中抛出了未被捕获的异常,或者有 throw 语句,JVM 会通过异常表跳转到 finally 代码,执行后再重新抛出异常。
异常表条目
编译器会生成一个特殊的异常表条目,用于捕获所有类型的异常(catch_type=0)
确保任何未处理的异常都会先执行 finally,再继续传播异常
java代码
public void example() {
try {
throw new IOException();
} finally {
System.out.println("finally");
}
}
字节码的异常表会生成如下头目
Exception table:
start=L0, end=L1, handler=L2, type=0 // type=0 表示捕获所有异常
对应的执行流程:
try
块抛出IOException
。- JVM 查找异常表,发现
type=0
的条目(匹配所有异常)。 - 跳转到
handler=L2
(finally
代码的位置)执行System.out.println("finally")
。 - 重新抛出异常,继续栈展开
假设代码中有 try 和 finally,但没有 catch:
try {
throw new Exception();
} finally {
System.out.println("finally");
}
执行步骤:
try
块抛出异常,JVM 创建异常对象。- 直接查找当前方法的异常表,找到
catch_type=0
的条目,跳转到finally
代码。 - 执行
finally
块中的代码。 - 重新抛出异常,由外层调用者处理
简单总结
异常处理:异常表+栈展开
每个方法的字节码中都有一个异常表,用于记录try-catch块的作用范围和对应的异常处理逻辑
记录
try的起点
try的终点
catch的位置(处理代码的位置)
捕获的异常类型
当出现异常的时候查找异常表,查看异常出现的位置,如果有try-catch,就跳转到catch进行处理
没有的话就进行栈展开,栈展开确保异常沿调用链向上传播,直到被处理或终止线程
finally块通过代码复制和异常表兜底,保证finally块必须执行
代码复制:
编译器将finally
代码复制到try
和catch
块的所有退出路径(包括return
或异常抛出之后)
异常表兜底:
如果 try 或 catch 块中抛出了未被捕获的异常,或者有 throw 语句,JVM 会通过异常表跳转到 finally 代码,执行后再重新抛出异常
编译器会生成一个特殊的异常表条目,用于捕获所有类型的异常(catch_type=0)
确保任何未处理的异常都会先执行 finally,再继续传播异常
能说一下类的生命周期吗
⼀个类从被加载到虚拟机内存中开始,到从内存中卸载,整个⽣命周期需要经过七个阶段
加载 (Loading)
连接:{
验证(Verification)、
准备(Preparation)、
解析(Resolution)、
}
初始化 (Initialization)
使⽤(Using)
卸载(Unloading)
什么是类加载器
类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步
1.每个 Java 类都有一个引用指向加载它的 ClassLoader
2.数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成
简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)
字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来
其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类
类加载器是动态加载还是静态加载
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载
也就是说,大部分类在具体用到的时候才会去加载(懒加载机制),这样对内存更加友好
对于已经加载的类会被放在 ClassLoader
中
在类加载的时候,系统会首先判断当前类是否被加载过,已经被加载的类会直接返回,否则才会尝试加载
也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次
类加载的过程知道吗
加载是 JVM 加载的起点,具体什么时候开始加载,《Java 虚拟机规范》中并没有进⾏强制约束,可以交给虚拟机 的具体实现来⾃由把握。
类加载过程:加载,连接{验证,准备,解析},初始化
加载
加载过程JVM 要做三件事情:
1)通过⼀个类的全限定名来获取定义此类的⼆进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区(因为包含类常量池)的运行时数据结构
3)在内存中⽣成⼀个代表这个类的 java.lang.Class 对象,作为⽅法区这个类的各种数据的访问入口
加载阶段结束后,Java 虚拟机外部的⼆进制字节流就按照虚拟机所设定的格式存储在⽅法区之中了,⽅法区中的数据存储格式完全由虚拟机实现⾃⾏定义,《Java 虚拟机规范》未规定此区域的具体数据结构。
类型数据妥善安置在⽅法区之后,会在 Java 堆内存中实例化⼀个 java.lang.Class 类的对象, 这个对象将作为程序访问⽅法区中的类型数据的外部接口
连接
验证(验证合法)
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
准备(分配内存)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配
解析(符号引用转直接引用)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
初始化
初始化阶段是执行初始化方法 <clinit> ()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)
类加载总结
JVM 中内置了三个重要的 ClassLoader
:
BootstrapClassLoader
(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(%JAVA_HOME%/lib
目录下的rt.jar
、resources.jar
、charsets.jar
等 jar 包和类)以及被-Xbootclasspath
参数指定的路径下的所有类。ExtensionClassLoader
(扩展类加载器):主要负责加载%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。AppClassLoader
(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类
除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class
文件)进行加密,加载时再利用自定义的类加载器对其解密
除了 BootstrapClassLoader
是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader
抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类
为什么 获取到 ClassLoader
为null
就是 BootstrapClassLoader
加载的呢?
这是因为BootstrapClassLoader
由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null
类加载器有哪些
主要有四种类加载器:
- 启动类加载器(Bootstrap ClassLoader)⽤来加载 java 核⼼类库,⽆法被 java 程序直接引⽤。
- 扩展类加载器(extensions class loader):它⽤来加载 Java 的扩展库。Java 虚拟机的实现会提供⼀个扩展库⽬录。该类加载器在此⽬录⾥⾯查找并加载 Java 类。
- 系统类加载器(system class loader):它根据 Java 应⽤的类路径(CLASSPATH)来加载 Java 类。⼀般来说,Java 应⽤的类都是由它来完成加载的。可以通ClassLoader.getSystemClassLoader()来获取它。
- 用户自定义类加载器 (user class loader),⽤户通过继承 java.lang.ClassLoader 类的⽅式⾃⾏实现的类加载器
如何自定义类加载器
我们前面也说说了,除了 BootstrapClassLoader
其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader
。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader
抽象类
ClassLoader
类有两个关键的方法:
protected Class loadClass(String name, boolean resolve)
:加载指定二进制名称的类,实现了双亲委派机制 。name
为类的二进制名称,resolve
如果为 true,在加载时调用resolveClass(Class<?> c)
方法解析该类。protected Class findClass(String name)
:根据类的二进制名称来查找类,默认实现是空方法。
官方 API 文档中写到:
Subclasses of ClassLoader
are encouraged to override findClass(String name)
, rather than this method.
建议 ClassLoader
的子类重写 findClass(String name)
方法而不是loadClass(String name, boolean resolve)
方法
如果我们不想打破双亲委派模型:
需要重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载
如果我们想打破双亲委派模型:
需要重写 loadClass()
方法
说一下类卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的
但是由我们自定义的类加载器加载的类是可能被卸载的
只要想通一点就好了,JDK 自带的 BootstrapClassLoader
, ExtClassLoader
, AppClassLoader
负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的
什么是双亲委派机制?
双亲委派模型的工作过程
双亲委派模型的⼯作过程:如果⼀个类加载器收到了类加载的请求,它⾸先不会⾃⼰去尝试加载这个类,⽽是把这个请求委派给父类加载器去完成,每⼀个层次的类加载器都是如此
因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载
双亲委派模型介绍
类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了
ClassLoader
类使用委托模型来搜索类和资源。- 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
ClassLoader
实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器
下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”
从下往上判断类是否被加载
从上往下尝试加载类
说一下双亲委派模型的执行流程
简单总结一下双亲委派模型的执行流程:
1.在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
2.类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader
中。
3.只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass()
方法来加载类)。
4.如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException
异常
拓展一下:
JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class
文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。
为什么要用双亲委派机制
答案是为了保证应⽤程序的稳定有序。
例如类 java.lang.Object,它存放在 rt.jar 之中,通过双亲委派机制,保证最终都是委派给处于模型最顶端的启动类加载器进⾏加载,保证 Object 的⼀致。
反之,都由各个类加载器⾃⾏去加载的话,如果⽤户⾃⼰也编写了⼀个名为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中就会出现多个不同的 Object 类
可以避免类被重复加载,同时保证我们的核心API不被修改
说一下双亲委派模型的好处
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类)
保证了 Java 的核心 API 不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题:
比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现两个不同的 Object
类。双亲委派模型可以保证加载的是 JRE 里的那个 Object
类,而不是你写的 Object
类。
这是因为 AppClassLoader
在加载你的 Object
类时,会委托给 ExtClassLoader
去加载,而 ExtClassLoader
又会委托给 BootstrapClassLoader
,BootstrapClassLoader
发现自己已经加载过了 Object
类,会直接返回,不会去加载你写的 Object
类
可以避免类被重复加载,同时保证我们的核心API不被修改
如何破打破双亲委派机制
自定义加载器的话,需要继承 ClassLoader
。
如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。
但是,如果想打破双亲委派模型则需要重写 loadClass()
方法
我们有什么场景需要破坏我们的双亲委派机制
自定义类场景
破坏双亲委派机制的场景
1. 动态加载和热更新:
在某些开发环境中,可能需要动态加载新的类或更新现有类。为了实现热更新,可能需要创建一个自定义的类加载器,绕过双亲委派机制,以便直接加载新的类定义。
2. 插件架构:
在插件系统中,可能需要允许插件直接使用特定的类,而这些类可能与主应用程序中的类同名。通过自定义类加载器,可以避免加载主应用程序中的同名类,从而实现插件的独立性。
3. 隔离不同版本的库:
有时,应用程序可能需要同时使用同一库的不同版本。通过创建不同的类加载器,可以加载不同版本的库而不发生冲突,从而破坏双亲委派机制。
4. 安全性需求:
在某些安全敏感的应用场景中,可能需要自定义类加载器以实现更严格的安全控制。例如,可以限制某些类的加载,或加载特定来源的类。
5. 测试和调试:
在单元测试或调试过程中,可能需要加载特定版本的类或模拟某些类的行为。自定义类加载器可以帮助实现这种需求
你觉得应该怎么实现一个热部署功能
Java类的加载过程
我们已经知道了 Java 类的加载过程。⼀个 Java 类⽂件到虚拟机⾥的对象,要经过如下过程:
⾸先通过 Java 编译器,将 Java ⽂件编译成 class 字节码,
类加载器读取 class 字节码,再将类转化为实例,
对实例 newInstance 就可以⽣成对象。
类加载器 ClassLoader 的功能
也就是将 class 字节码转换到类的实例。在 Java 应⽤中,所有的实例都是由类加载器加载而来。
⼀般在系统中,类的加载都是由系统⾃带的类加载器完成,
⽽且对于同⼀个全限定名的 java 类(如 com.csiar.soc.HelloWorld)
只能被加载⼀次,而且无法被卸载
这个时候问题就来了,如果我们希望将 java 类卸载,并且替换更新版本的 java 类,该怎么做呢?
既然在类加载器中,Java 类只能被加载⼀次,并且⽆法卸载。
那么我们是不是可以直接把 Java 类加载器干掉呢?
答案是可以的
自定义类加载器
我们可以⾃定义类加载器,并重写 ClassLoader 的 findClass ⽅法。
想要实现热部署可以分以下三个步骤:
1)销毁原来的⾃定义 ClassLoader
2)更新 class 类⽂件
3)创建新的 ClassLoader 去加载更新后的 class 类⽂件。
到此,⼀个热部署的功能就这样实现了
Tomcat 的类加载机制了解吗
Tomcat 是主流的 Java Web 服务器之⼀,为了实现⼀些特殊的功能需求,⾃定义了⼀些类加载器。
Tomcat 类加载器如下:
Tomcat 实际上也是破坏了双亲委派模型的。
Tomact 是 web 容器,可能需要部署多个应⽤程序。不同的应⽤程序可能会依赖同⼀个第三⽅类库的不同版本,但是不同版本的类库中某⼀个类的全路径名可能是⼀样的。如多个应⽤都要依赖 hollis.jar,但是 A 应⽤需要依赖1.0.0 版本,但是 B 应⽤需要依赖 1.0.1 版本。这两个版本中都有⼀个类是 com.hollis.Test.class。如果采⽤默认的双亲委派类加载机制,那么⽆法加载多个相同的类。
所以,Tomcat 破坏了双亲委派原则,提供隔离的机制,为每个 web 容器单独提供⼀个 WebAppClassLoader 加载器。每⼀个 WebAppClassLoader 负责加载本身的⽬录下的 class ⽂件,加载不到时再交 CommonClassLoader加载,这和双亲委派刚好相反