类加载子系统

类加载子系统

类加载子系统是用于加载编译后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() 方法不一致,可能导致集合(如 HashMapHashSet)中的对象无法被正确识别和回收,导致内存持续占用。 当两个对象在逻辑上相等(即通过 equals() 判断)但它们的 hashCode() 不相同时,存储在哈希表(如 HashMapHashSet)中的对象会被认为是不同的对象。这意味着,如果一个对象被存储在集合中,其他相等的对象不会被覆盖或找到。
  • 引用了外部类的内部类
  • 内部类持有外部类的引用,如果外部类的实例在使用后未能及时释放,而内部类仍在使用,这将导致外部类无法被垃圾回收,造成内存泄漏。
  • 非正确的重写 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 方法即可。自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。

自定义类加载器需要实现三点:

  1. 继承ClassLoader

  2. 重写findClass()方法

  3. 在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(上下文),打破了双亲委派模型。

相关推荐
baivfhpwxf20232 小时前
ACS X轴回零程序 项目实战版
网络·数据库·算法
xiaoye37082 小时前
某大厂java面试题一面20260313
java
一叶落4382 小时前
LeetCode 219. 存在重复元素 II(C语言详解)
算法·哈希算法·散列表
像污秽一样2 小时前
算法设计与分析-习题2.4
数据结构·算法·排序算法
啦啦啦_99992 小时前
13. AI面试题之 Dify
java
不想看见4042 小时前
Reverse Bits位运算基础问题--力扣101算法题解笔记
笔记·算法·leetcode
春日见2 小时前
端到端大模型自动驾驶
java·开发语言·驱动开发·docker·自动驾驶·计算机外设
rell3362 小时前
机顶盒播放udp/rtp马赛克
java·网络·网络协议·udp
Arya_aa2 小时前
多个对象通过集合实现io流的读写
java