一.什么是类的加载
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。
类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,
类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
二.类的生命周期
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、装换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型。
在 Java 语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的。
类的生命周期( 7 个阶段):
其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。解析阶段可以在初始化之后再开始(运行时绑定或动态绑定或晚期绑定)。
以下五种情况必须对类进行初始化(而加载、验证、准备自然需要在此之前完成):
(1)遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时没初始化触发初始化。
使用场景:使用 new 关键字实例化对象、读取一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。
(2)使用 java.lang.reflect 包的方法对类进行反射调用的时候。
(3)当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化。
(4)当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
(5)当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,
并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。
前面的五种方式是对一个类的主动引用,除此之外,所有引用类的方法都不会触发初始化,佳作被动引用。
举几个例子:
public class SuperClass {
static { System.out.println("SuperClass init!"); } public static int value = 1127; } public class SubClass extends SuperClass { static { System.out.println("SubClass init!"); } } public class ConstClass { static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world!" } public class NotInitialization { public static void main(String[] args) { System.out.println(SubClass.value); /** * output : SuperClass init! * * 通过子类引用父类的静态对象不会导致子类的初始化 * 只有直接定义这个字段的类才会被初始化 */ SuperClass[] sca = new SuperClass[10]; /** * output : * * 通过数组定义来引用类不会触发此类的初始化 * 虚拟机在运行时动态创建了一个数组类 */ System.out.println(ConstClass.HELLOWORLD); /** * output : * * 常量在编译阶段会存入调用类的常量池当中,本质上并没有直接引用到定义类常量的类, * 因此不会触发定义常量的类的初始化。 * “hello world” 在编译期常量传播优化时已经存储到 NotInitialization 常量池中了。 */ } }
三.类的加载过程
1、加载
(1)通过一个类的全限定名来获取定义次类的二进制流(ZIP 包、网络、运算生成、JSP 生成、数据库读取)。
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
(3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法去这个类的各种数据的访问入口。
数组类的特殊性:数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的。
但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建的,数组创建过程如下:
(1)如果数组的组件类型是引用类型,那就递归采用类加载加载。
(2)如果数组的组件类型不是引用类型,Java 虚拟机会把数组标记为引导类加载器关联。
(3)数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public。
内存中实例的 java.lang.Class 对象存在方法区中。作为程序访问方法区中这些类型数据的外部接口。
加载阶段与连接阶段的部分内容是交叉进行的,但是开始时间保持先后顺序。
2、验证
是连接的第一步,确保 Class 文件的字节流中包含的信息符合当前虚拟机要求。
文件格式验证:
(1)是否以魔数 0xCAFEBABE 开头
(2)主、次版本号是否在当前虚拟机处理范围之内
(3)常量池的常量是否有不被支持常量的类型(检查常量 tag 标志)
(4)指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
(5)CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
(6)Class 文件中各个部分集文件本身是否有被删除的附加的其他信息
(7)……
只有通过这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面 3 个验证阶段全部是基于方法区的存储结构进行的,不再直接操作字节流。
元数据验证:
(1)这个类是否有父类(除 java.lang.Object 之外)
(2)这个类的父类是否继承了不允许被继承的类(final 修饰的类)
(3)如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
(4)类中的字段、方法是否与父类产生矛盾(覆盖父类 final 字段、出现不符合规范的重载)
这一阶段主要是对类的元数据信息进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。
字节码验证:
(1)保证任意时刻操作数栈的数据类型与指令代码序列都鞥配合工作(不会出现按照 long 类型读一个 int 型数据)
(2)保证跳转指令不会跳转到方法体以外的字节码指令上
(3)保证方法体中的类型转换是有效的(子类对象赋值给父类数据类型是安全的,反过来不合法的)
(4)……
这是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
这个阶段对类的方法体进行校验分析,保证校验类的方法在运行时不会做出危害虚拟机安全的事件。
符号引用验证:
(1)符号引用中通过字符创描述的全限定名是否能找到对应的类
(2)在指定类中是否存在符方法的字段描述符以及简单名称所描述的方法和字段
(3)符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问
(4)……
最后一个阶段的校验发生在迅疾将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。
符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,还有以上提及的内容。
符号引用的目的是确保解析动作能正常执行,如果无法通过符号引用验证将抛出一个 java.lang.IncompatibleClass.ChangeError 异常的子类。
如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。
3、准备
这个阶段正式为类分配内存并设置类变量初始值,内存在方法去中分配(含 static 修饰的变量不含实例变量)。
public static int value = 1127;
这句代码在初始值设置之后为 0,因为这时候尚未开始执行任何 Java 方法。
而把 value 赋值为 1127 的 putstatic 指令是程序被编译后,存放于 clinit() 方法中,所以初始化阶段才会对 value 进行赋值。
基本数据类型的零值:
特殊情况:如果类字段的字段属性表中存在 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 1127。
4、解析
这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
(1)符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。
(2)直接引用:直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和迅疾的内存布局实现有关。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型。
5、初始化
前面过程都是以虚拟机主导,而初始化阶段开始执行类中的 Java 代码。
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由JVM主导。
到了初始阶段,才开始真正执行类中定义的Java程序代码。
在 Java 代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。
如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。
除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。
类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。
只有当初始化完成之后,类才正式成为可执行的状态。
JVM初始化步骤:
(1)假如这个类还没有被加载和连接,则程序先加载并连接该类;
(2)假如该类的直接父类还没有被初始化,则先初始化其直接父类;
(3)假如类中有初始化语句,则系统依次执行这些初始化语句;
类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
(1)创建类的实例,也就是new的方式;
(2)访问某个类或接口的静态变量,或者对该静态变量赋值;
(3)调用类的静态方法;
(4)反射(如 Class.forName(“com.shengsiyuan.Test”));
(5)初始化某个类的子类,则其父类也会被初始化;
(6)Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用 java.exe命令来运行某个主类;
好了,到目前为止就是类加载机制的整个过程,但是还有一个重要的概念,那就是下面介绍的类加载器。
四.类加载器
通过一个类的全限定名来获取描述此类的二进制字节流。虚拟机设计团队把加载动作放到JVM外部实现,以便让应用程序决定如何获取所需的类。
1、双亲委派模型
从 Java 虚拟机角度讲,只存在两种类加载器:一种是启动类加载器(C++ 实现,是虚拟机的一部分);另一种是其他所有类的加载器(Java 实现,独立于虚拟机外部且全继承自 java.lang.ClassLoader)
(1)启动类加载器:加载 lib 下或被 -Xbootclasspath 路径下的类。
(2)扩展类加载器:加载 lib/ext 或者被 java.ext.dirs 系统变量所指定的路径下的类。
(3)引用程序类加载器:ClassLoader负责,加载用户路径上所指定的类库。
除顶层启动类加载器之外,其他都有自己的父类加载器。
工作过程:如果一个类加载器收到一个类加载的请求,它首先不会自己加载,而是把这个请求委派给父类加载器。只有父类无法完成时子类才会尝试加载。
2、破坏双亲委派模型
keyword:线程上下文加载器(Thread Context ClassLoader)。
五.类的加载
1、类加载有三种方式:
(1)命令行启动应用时候由JVM初始化加载;
(2)通过Class.forName()方法动态加载;
(3)通过ClassLoader.loadClass()方法动态加载;
三种方式区别比较大,看个例子就明白了:
public class HelloWorld { public static void main(String[] args) throws ClassNotFoundException { ClassLoader loader = HelloWorld.class.getClassLoader(); System.out.println(loader); //使用ClassLoader.loadClass()来加载类,不会执行初始化块 loader.loadClass("Test2"); //使用Class.forName()来加载类,默认会执行初始化块 // Class.forName("Test2"); //使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块 // Class.forName("Test2", false, loader); } }
public class Test2 { static { System.out.println("静态初始化块执行了!"); } }
Class.forName()和ClassLoader.loadClass()区别:
Class.forName():将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
注:Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
2、使用classLoader加载
System.out.println("before loadClass... "); Class c =Test.class.getClassLoader().loadClass("com.hundsun.test.ClassInfo"); System.out.println("after loadClass... "); System.out.println("before newInstance... "); ClassInfo info1 =(ClassInfo) c.newInstance(); System.out.println("after newInstance... "); 输出结果: before loadClass... after loadClass... before newInstance... static invoked... contruct invoked... after newInstance...
3、使用class.forName进行加载
System.out.println("before class.forName"); Class cc =Class.forName("com.hundsun.test.ClassInfo"); System.out.println("after class.forName"); ClassInfo info2 =(ClassInfo) cc.newInstance(); 输出结果: before class.forName static invoked... after class.forName before newInstance... contruct invoked... after newInstance...
下面说一下两者具体的执行过程 :
LoadClass()方法加载类及初始化过程:
类加载(loadclass())(加载)——》newInstance()(链接+初始化) newInstance():
(开始连接)静态代码块——》普通变量分配准备(a=0;b=0;c=null)——》(开始初始化)普通变量赋值(a=1;b=2;c=”haha”)——》构造方法——》初始化成功。
Class.forName(Stirng className)一个参数方法加载类及初始化过程:
类加载(Class.forName())(加载)——》静态代码块——》newInstance()(链接+初始化)newInstance():
(开始连接)普通变量分配准备(a=0;b=0;c=null)——》(开始初始化)普通变量赋值(a=1;b=2;c=”haha”)——》构造方法——》初始化成功。
Class.forName()三个参数的加载类及初始化过程同classLoader一样。
从上边的断点调试可以看出,静态代码块不是在初始化阶段完成的,它陷于类初始化,先于普通变量默认分配(整型分配为0,字符串分配为null),
这也就是为什么我们不能在静态代码块中引用普通变量的原因之一,这与上面所谓的“分配”、“初始化”相违背。
六.例子分析
jvm经过了编译成字节码文件,加载,链接(验证,准备,解析),初始化....等过程。
下面这个图一起看看:
代码:
public class Animal{ private int age=18; public void run() {} } publci class Test{ public static void main(String[] args){ Animal animal = new Animal(); animal.run(); } }
运行这个main方法的步骤:
(1)首先是编译器会将这两个类都编译成字节码文件并放在你的项目存放路径;
(2)Test这个类会以某种方式告诉JVM自己的类名“Test”,虚拟机就会以某种牛逼的方法可以找到你这个Test.class放在那个目录下面;
(3)调用类加载器,采用双亲委托机制去加载这个类,最后不出意外应该是应用类加载器去加载这个Test.class,以二进制流的形式加载进JVM方法区;
(4)在加载之后会去验证这个Test.class是否符合规范,没问题的话就会解析这个加载进来的Test.class,将其中很多信息都保存下来,常量和符号引用保存在常量池中,
其他的比如访问修饰符,全类名,直接父类的全类名,方法和字段信息,除了常量以外的所有静态变量,以及指向类加载器和Class对象的指针等都存在常量池外面;
(5)通过保存在方法区中的字节码,JVM可以执行main()方法,在执行这个方法的时候,会一直持有有一个指向Test的常量池的指针;
(6)在执行main方法的第一条指令的时候,就是告诉JVM为Test常量池的第一个类型分配足够内存;
由于main方法一直持有执行Test常量池指针于是很迅速的找到了常量池第一项,发现它是一个对Animal类的符号引用,
然后就会先检查方法区看有没有Animal类有没有被加载,假如没有的话就要去找到这个Animal类;
这里就有了一个算法的小知识,怎么才能够让虚拟机最快速度找到Animal类所在位置呢?可以用散列表,搜索树等算法。
(7)加载Animal.class到方法区并提取其中有用的信息保存在方法区,然后替换Test常量池第一个类型的符号引用,变为直接引用;
注意,这个时候还没有创建对象,直接引用指向的是方法区中Animal所在的地址;
(8)JVM在堆中为创建Animal对象分配足够内存,怎么确定这个内存多大合适呢?其实JVM比较牛,已经设好了可以根据方法区中存放的信息确定一个类创建对象要用到多少堆空间;
(9)对象创建好了会设置Animal实例变量的默认初始值:age = 0
(10)创建一个栈帧(里面有一个指向Animal对象的引用),压入java栈中,到此main方法第一条指令就执行完毕;还记得一个方法一个栈帧么;
(11)然后根据这个栈帧调用java代码,将age的值初始化为正确的值:18
(12)通过这个栈帧执行run()方法,又会开辟一个栈帧存放run()方法内部的所有信息;
(13)run()方法执行完毕,释放这个栈帧;然后main()执行完毕,释放栈帧;然后就是程序执行完毕,清理回收堆中所有对象以及方法区;
总结:
大概就是这么一个流程,其中最后的那个清理回收过程其实很重要,由于java栈和方法区的清理内存效率非常好,
可以不用在意,重点是在堆中清理内存,而且由于有的程序是会运行很久的,不可能每次都等程序执行完毕之后再一起清理,肯定是要一边运行程序一边清理堆内存中没用的对象,
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
2019-05-20 Mybatis异常invalid comparison: java.util.Date and java.lang.String