类加载子系统


类加载子系统是用于加载编译后class文件的,但它只负责将符合格式要求的class字节码信息加载进内存,而只要符合格式规范的class文件都能被加载,至于加载进入的class文件到底是否能执行就并不是它负责的了,这是执行引擎子系统的范围之内的责任。
而类加载子系统中,核心知识点分为类加载器、双亲委派模型、类加载过程三大块。












同时被final和static修饰(即ConstantValue属性)的变量,在准备阶段就直接赋值了,没有赋初值这一环节。
被final修饰的变量会在编译期 直接确定,编译器会把它"内联"进字节码里。










OOM(内存溢出)

运行时数据区中,除开程序计数器之外,其他的区域都会存在内存溢出的风险。
java堆空间OOM:
如果内存不足发生GC时,堆中的对象都还存活,此时又没有足够的内存分配新的对象实例,最终堆空间就会出现OOM。



虚拟机栈和本地方法栈OOM:

注意:扩展无法申请到足够的内存空间才会抛OOM。
但是:这个条件在HotSpot中几乎很难达到,因为虚拟机栈所需的空间大小,在编译期就已经确定了,在运行期间机会很少存在会发生Java栈动态扩容的情况。


元数据空间和运行时常量池OOM:
元数据空间主要存储类元数据(类名、修饰符、父类信息、方法和字段信息)、静态变量、常量池、运行时常量池
字节码的类元信息存储在元空间,但字节码具体执行部分被JIT编译成机器码后被存在本地内存的代码缓冲区中。
元空间位于本地内存。
在运行时产生大量类字节码,从而使得元数据空间内存被耗尽,从而抛出OOM。
在JDK1.6时,因为字符串常量池位于运行时常量池中,所以还比较好测试,生成大量的字符串即可。
但1.7之后,字符串常量池被移入到了堆空间中,这样就很难使得运行时常量池再发生OOM的错误了。

直接内存OOM:

一直在循环申请直接内存使用,但是申请之后没有释放,当申请到第11次时,分配的直接内存空间被耗尽,从而抛出了OOM错误。

内存泄露

内存泄漏例子:

- ThreadLocal:
ThreadLocal为每个线程提供独立的变量副本,但如果使用不当(如未清理),会导致线程在结束后仍持有对对象的引用,从而造成内存泄漏。特别是在应用程序长时间运行时,未释放的ThreadLocal变量会占用越来越多的内存。- 大量的 static 成员:
static成员属于类本身,而不是某个实例。如果不慎将大量对象或集合声明为static,这些对象将不会被垃圾回收,直到类被卸载,从而可能导致内存泄漏。- 未正确关闭连接:
- 例如数据库连接、文件句柄、网络连接等,如果不正确关闭,这些资源将持续占用内存并可能导致内存泄漏。使用完后,确保及时关闭连接是非常重要的。
- 不正确的 equals() 和 hashCode():
- 如果
equals()方法与hashCode()方法不一致,可能导致集合(如HashMap或HashSet)中的对象无法被正确识别和回收,导致内存持续占用。 当两个对象在逻辑上相等(即通过equals()判断)但它们的hashCode()不相同时,存储在哈希表(如HashMap或HashSet)中的对象会被认为是不同的对象。这意味着,如果一个对象被存储在集合中,其他相等的对象不会被覆盖或找到。 - 引用了外部类的内部类:
- 内部类持有外部类的引用,如果外部类的实例在使用后未能及时释放,而内部类仍在使用,这将导致外部类无法被垃圾回收,造成内存泄漏。
- 非正确的重写 finalize() 方法:
- 如果
finalize()方法中引用了其他对象但未能正确释放资源,可能导致这些对象无法被回收,从而造成内存泄漏。由于finalize()方法不一定在对象死亡后立即调用,依赖它来管理资源是一种不安全的做法。 - 常量字符串:
- 虽然字符串常量池优化了字符串的存储,但大量创建不同字符串的常量仍可能导致内存占用增加,尤其是在使用
String连接操作时,可能导致生成大量临时对象,从而增加内存压力。

误区:会认为堆中有引用循环的情况出现就会引发内存泄漏问题。
但其实因为Java中GC判断算法采用的是可达性分析算法,对于根不可达的对象都会判定为垃圾对象,会被统一回收。因此,就算在堆中有引用循环的情况出现,也不会引发内存泄漏问题。

其他内存溢出问题
1.超出虚拟内存空间
虚拟内存=物理内存+交换空间。
当运行时程序请求的虚拟内存溢出时就会抛出该错误。
出现该问题的原因主要有两个,一个是地址空间不足,另一个是物理内存已被耗尽,一般只能提升硬件配置。
2.杀进程
属于Linux操作系统抛出的错误,当系统可用内存快耗尽时,内核的Out of Memory Killer组件会对所有进程进行打分,然后会尝试杀死一些评分低的进程,释放它们占用的内存空间来确保拥有足够的内存维护OS的运行。
一般来说,Java程序中是不必担心遇到这个问题的,因为"打分"这一操作,会基于活跃度进行,而Java程序部署之后,一般情况下都会处于持续运行的状态。
3.请求的数组大小超过JVM限制
数组这种数据结构,要求在分配时,物理内存必须连续,所以当分配一个巨型数组时,发现堆空间中已经没有一块这么大的连续空间,并且GC之后还是分配不下,那么就会抛出错误。
需要从业务上进行拆分,对于如此巨大的数组可以分为多次查询,将其分割为多个不同的小数组分配即可。

类加载器的任务是,根据一个类的全限定名读取它的二进制字节流数据后,将其加载到内存中并转换为一个与该类对应的Class对象。而虚拟机提供了三种类加载器,同时也可以自己实现。(四类类加载器)





若收到的字节码文件带密码、路径不在classpath或在运行时动态更改代码(如热部署),就需自定义类加载器。

Ext由boot加载,boot将ext的父加载器设为boot。
app由boot加载,boot将app的父加载器设为Ext。
自定义类加载器由app加载,app为父类。
系统默认的类加载器/系统类加载器:app

小结:

显式加载 or 隐式加载:

类的加载:



为了解决类加载器的隔离性问题:


完整过程:

当一个类加载器收到类加载任务时,会先交给自己的父加载器去完成,因此最终加载任务都会传递到最顶层的BootstrapClassLoader,只有当父加载器无法完成加载任务时,才会尝试自己来加载。
双亲委派的两个主要作用:
防止类的重复加载:
如果这个类已在父类加载器加载过了,就直接委派到父类使用就行,不用二次加载。
防止核心API被篡改:
假设你有一个应用程序,它使用了一个第三方库,这个库中可能包含了一个名为java.lang.String的类。由于双亲委派模型的作用,当你的应用程序尝试加载String类时,请求会首先委托给父类加载器,最终到达启动类加载器。启动类加载器会从Java核心库中加载String类,并返回给子加载器。因此,即使第三方库中的String类可能与Java核心库中的String类不同,应用程序使用的仍然是Java核心库中的标准String类。(启动类只会加载标准java库中的类)
如果没有双亲委派模型,应用程序可能会加载并使用第三方库中的String类,这可能导致不可预知的行为,因为第三方库可能对String类进行了修改或扩展,而这些修改可能与Java核心库的预期行为不一致。

双亲委派的实现原理:

// sun.misc.Launcher类
public class Launcher {
// sun.misc.Launcher类 → 构造器
public Launcher(){
Launcher.ExtClassLoader var1;
try {
// 会先初始化Ext类加载器并创建ExtClassLoader
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError(
"Could not create extension class loader", var10);
}
try {
// 再创建AppClassLoader并把Ext作为父加载器传递给App
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader");
}
// 将APP类加载器设置为线程上下文类加载器(稍后分析)
Thread.currentThread().setContextClassLoader(loader);
// 省略......
}
// sun.misc.Launcher类 → ExtClassLoader内部类
static class ExtClassLoader extends URLClassLoader {
// ExtClassLoader内部类 → 构造器
public ExtClassLoader(File[] var1) throws IOException {
// 在Ext初始化时,父类构造器会被设置为null
super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
SharedSecrets.getJavaNetAccess().getURLClassPath(this)
.initLookupCache(this);
}
}
// sun.misc.Launcher类 → AppClassLoader内部类
static class AppClassLoader extends URLClassLoader {}
}
// java.net.URLClassLoader类
public class URLClassLoader extends SecureClassLoader
implements Closeable {}
// java.security.SecureClassLoader类
public class SecureClassLoader extends ClassLoader {}
ext和app都继承自urlclassloader类,而urlclassloader类继承自Secureclassloader,而SecureclassLoader继承自Classloader,所以ext和app都间接继承自classloader类。

①loadClass() 方法用于加载指定类,实现了双亲委派模型,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。
public class LoadClassRole {
/**
* loadClass() 主要负责:
* 1. 实现双亲委派机制
* 2. 调用findClass()查找类
* 3. 调用defineClass()定义类
* 4. 解析类(如果需要)
*/
// ClassLoader.loadClass()的典型实现:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 第一步:检查类是否已加载
Class<?> c = findLoadedClass(name);
if (c != null) {
return c;
}
// 第二步:双亲委派 - 先让父加载器尝试
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器找不到,继续往下执行
}
// 第三步:父加载器找不到,调用findClass()
if (c == null) {
c = findClass(name); // ⬅️ 这里调用findClass()
}
// 第四步:解析类
if (resolve) {
resolveClass(c);
}
return c;
}
}
②ClassLoader 的 findClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。即找到字节码文件中的指定类。
public class FindClassRole {
/**
* findClass() 主要负责:
* 1. 根据类名查找类字节码
* 2. 读取.class文件内容
* 3. 返回字节数组
* 4. 通常需要子类重写这个方法
*/
// 需要子类重写的findClass()方法:
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 根据类名找到.class文件
byte[] classData = loadClassData(name);
// 2. 调用defineClass()将字节码转换为Class对象
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClassData(String className) {
// 从文件系统、网络、数据库等读取.class文件
// 返回字节数组
return null;
}
}
③拿到这个字节码之后再调用 defineClass() 方法将字节码.class文件转换成 Class 对象。ClassLoader#defineClass 是一个native方法,逻辑在JVM的C语言代码中。
public class DefineClassRole {
/**
* defineClass() 主要负责:
* 1. 将字节数组转换为Class对象
* 2. 进行字节码验证
* 3. 在JVM中注册这个类
* 4. 这是final方法,子类不能重写
*/
// defineClass()是final方法,由JVM实现:
// protected final Class<?> defineClass(String name, byte[] b, int off, int len)
public void demonstrateDefineClass() {
// defineClass()完成的核心工作:
// 1. 字节码验证 - 确保.class文件格式正确
// 2. 符号引用解析准备
// 3. 在方法区创建类元数据
// 4. 创建Class对象并返回
// 这是真正"创建"类的步骤!
}
}

// sun.misc.Launcher类 → AppClassLoader内部类 → loadClass()方法
public Class loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
int i = name.lastIndexOf('.');
if (i != -1) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPackageAccess(name.substring(0, i));
}
}
// 依旧调用的是父类loadClass()方法
return (super.loadClass(name, resolve));
}

自定义加载器:

自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。
自定义类加载器需要实现三点:
-
继承ClassLoader
-
重写findClass()方法
-
在findClass()中调用defineClass()


// 运维终端类加载器
public class OpsClassLoader extends ClassLoader {
// 接收到的class文件本地的存储位置
private String rootDirPath;
// 构造器
public OpsClassLoader(String rootDirPath) {
this.rootDirPath = rootDirPath;
}
// 读取Class字节流并解密的方法
private byte[] getClassDePass(String className) throws IOException {
String classpath = rootDirPath + className;
// 模拟文件读取过程.....
FileInputStream fis = new FileInputStream(classpath);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
int n = 0;
byte[] buffer = new byte[bufferSize];
while ((n = fis.read(buffer)) != -1)
// 模拟数据解密过程.....
baos.write(buffer, 0, n);
byte[] data = baos.toByteArray();
// 模拟保存解密后的数据....
return data;
}
// 重写了父类的findClass方法
@SneakyThrows
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 读取指定的class文件
byte[] classData = getClassDePass(name);
// 如果没读取到数据,抛出类不存在的异常
if (classData == null)
throw new ClassNotFoundException();
// 通过调用defineClass方法生成Class对象
return defineClass(name,classData,0,classData.length);
}
}

如果你想代码更简洁,你也可以通过继承URLClassLoader类实现。


双亲委派破坏者 - 线程上下文类加载器


上下文类加载器 :就是绑定到当前线程的类加载器。

所以父加载器是通过线程的方法得到下层加载器appclassloader,从而加载其中的类。

可以把上下文加载器设置成任何你想用的下层的加载器,如ext,app,只是默认是app
可以手动改成别的:



线程上下文类加载器就是双亲委派模型的破坏者,可以在执行线程中打破双亲委派机制的加载链关系,从而使得程序可以逆向使用类加载器。
从JDBC角度分析线程上下文类加载器:

// rt.jar包 → DriverManager类
public class DriverManager {
// .......
// 静态代码块
static {
// 加载并初始化驱动
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
// DriverManager类 → loadInitialDrivers()方法
private static void loadInitialDrivers() {
// 先读取系统属性 jdbc.drivers
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
//通过ServiceLoader类查找驱动类的文件位置并加载
ServiceLoader<Driver> loadedDrivers =
ServiceLoader.load(Driver.class);
//省略......
}
});
//省略......
}

在MySQL的jar包中存在一个META-INF/services/目录,而在该目录下,存在一个java.sql.Driver文件,该文件中指定了MySQL驱动Driver类的路径

该类是实现了Java定义的SPI接口java.sql.Driver的,所以在启动时,SPI的动态服务发现机制可以发现指定的位置下的驱动类。




boot想去加载第三方实现类,不在自己范围,所以交给app(上下文),打破了双亲委派模型。
