简介
Java源代码(.java文件)在经过编译器编译之后被转换成字节代码(.class 文件),类加载器将.class文件中的二进制数据读入到内存中,将其放在方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
类加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
一、类的生命周期
类的生命周期分为5个阶段:加载、链接(验证、准备、解析)、初始化、使用、卸载。
其中加载、验证、准备、初始化和卸载这几个的顺序是确定的,类的加载过程必定按上面的流程顺序进行。但解析过程顺序不一定,它在某些情况下可能在初始化阶段后再开始,因为Java支持运行时绑定的(也称为动态绑定或晚期绑定,其实就是多态),例如子类重写父类方法。
注意:加载阶段,Java虚拟机规范中没有进行约束。但是初始化阶段,Java虚拟机规范中有严格的约束。(注:加载、验证、准备要在初始化之前开始)
下面5种情况必须立即进行初始化:
1、创建类的实例,也就是new一个对象;获取或者赋值类的静态变量、静态非字面值常量(静态字面值常量除外)。
2、调用类的静态方法。
3、通过反射调用(Class.forName(“xxx”))。
4、初始化一个类的子类(会首先初始化子类的父类)。
5、启动程序所使用的main方法所在类。
上面的5中情况称为主动引用,除此主动引用之外还有被动引用。
被动引用有如下3种常见情况:
1、通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
2、定义对象数组和集合,不会触发该类的初始化。
3、类A引用类B的静态常量不会导致类B初始化(注意静态常量必须是字面值常量,否则还是会触发B的初始化)。
其他不会触发初始化的情况:
1、通过类名获取Class对象,不会触发类的初始化。如System.out.println(User.class)。
2、通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化。
3、通过ClassLoader默认的loadClass方法,也不会触发初始化动作。
1、加载阶段
加载主要是将.class文件通过二进制字节流读入到JVM中
加载阶段,虚拟机需要完成以下三件事情:
1、通过classloader在classpath中获取XXX.class文件,将其以二进制流的形式读入内存。
2、将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3、在内存中生成一个该类的java.lang.Class对象,这样便可以通过该对象访问方法区中的这些数据。
class文件的加载方式:
1、从本地系统中直接加载
2、从zip,jar等归档文件中加载.class文件
3、从数据库中提取.class文件
4、将Java源文件动态编译为.class文件
相对于类生命周期的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
2. 链接阶段
当类被加载后,系统为之生成一个对应的Class对象,接着会进入链接阶段,链接阶段将会负责把类的二进制文件合并到JRE中。类的链接阶段分为如下三个阶段:
1、验证:验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致;
2、准备:准备阶段则负责为类的静态属性分配内存,并设置默认初始值;
3、解析:将类的二进制数据中的符号引用替换成直接引用(符号引用是用一组符号描述所引用的目标;直接引用是指向目标的指针)
验证
确保被加载的类的正确性。
验证阶段主要是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
包括一下几个方面的验证:
文件格式验证:验证字节流是否符合Class文件格式的规范,如:是否以模数0xCAFEBABE开头、主次版本号是否在当前虚拟机处理范围内等等。
元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求;如:这个类是否有父类,是否实现了父类的抽象方法,是否重写了父类的final方法,是否继承了被final修饰的类等等。
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,如:操作数栈的数据类型与指令代码序列能配合工作,保证方法中的类型转换有效等等。
符号引用验证:确保解析动作能正确执行;如:通过符合引用能找到对应的类和方法,符号引用中类、属性、方法的访问性是否能被当前类访问等等。
验证阶段是非常重要的,但不是必须的。可以采用-Xverify:none参数来关闭大部分的类验证措施。
准备
为类的静态变量分配内存,并将其赋默认值。
为类变量分配内存并设置类变量初始值,这些内存都将在方法区中分配。
对于该阶段有以下几点需要注意:
1、对static修饰的静态变量进行内存分配、并赋默认值(如0、0L、null、false等),而不是代码中被显示的赋予的值。
2、对final static修饰的静态字面值常量进行内存分配、直接赋初值。
3、对final static修饰的静态非字面值常量进行内存分配、赋默认值。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
符号引用就是一组符号来描述目标,可以是任何字面量。属于编译原理方面的概念如:包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。如指向方法区某个类的一个指针。
假设:一个类有一个静态变量,该静态变量是一个自定义的类型,那么经过解析后,该静态变量将是一个指针,指向该类在方法区的内存地址。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符这7类符号引用进行。
这7类符号引用进行分别对应于常量池的7种常量类型:CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_IntrfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info。
3. 初始化阶段
为类的静态变量赋初值。
特别注意:类在初始化前,必须先经过加载、验证、准备阶段。
静态变量赋初值的两种方式:
1、定义静态变量时指定初始值。
private static String x="123";
2、静态代码块里为静态变量赋值。
private static String x;
static{
x="123";
}
在编译生成class文件时,编译器会产生两个方法加于class文件中:
clinit:类的初始化方法
init:实例的初始化方法
类初始化的注意事项:
1、类只初始化一次;
2、初始化的执行顺序(静态变量、静态代码块)只跟代码中出现的顺序有关(按照声明的顺序执行)。
1. clinit(类的初始化方法)
clinit是类构的造器,主要作用是在类加载过程中的初始化阶段进行执行,执行内容包括:静态变量初始化(赋初值)和静态代码块的执行。
注意事项:
1、如果类中没有静态变量或静态代码块,那么clinit方法将不会被生成。
2、在执行clinit方法时,必须先执行父类的clinit方法。
3、clinit方法只执行一次。
4、静态变量的赋初值和静态代码块和合并顺序由源文件中出现的顺序决定。
2. init(实例的初始化方法)
init是实例的构造器,主要作用是在类实例化过程中执行,执行内容包括:成员变量初始化和构造代码块的执行。
注意事项:
1、如果类中没有成员变量和构造代码块,那么clinit方法将不会被生成。
2、在执行init方法时,必须先执行父类的init方法。
3、init方法每实例化一次就会执行一次。
4、init方法先为实例变量分配内存空间,再执行赋默认值,然后进行赋初值和构造代码块的执行(实例变量的赋初值和构造代码块和合并顺序由源文件中出现的顺序决定)。
类初始化的触发条件
- 创建类的实例,也就是new一个对象;
- 获取或者赋值类的静态变量、静态非字面值常量(静态字面值常量除外);
- 调用类的静态方法;
- 反射调用(Class.forName(“xxx”))
- 初始化一个类的子类(会首先初始化子类的父类)
- 启动程序所使用的main方法所在类。
类初始化的步骤
- 如果这个类还没有被加载和链接,那先进行加载和链接;
- 如果这个类存在直接父类,并且这个直接父类还没有被初始化,先初始化直接父类(不适用于接口) 。
注意:在一个类加载器中,类只能初始化一次。- 如果类中存在初始化语句(如static变量和static代码块),依次执行这些初始化语句。
4. 使用阶段
- 使用
5. 卸载阶段
如下几种情况下,Java虚拟机将结束生命周期:
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
二、类加载器和双亲委派机制
虚拟机设计团队把类加载阶段中的”通过一个类的全限定名来获取描述此类的二进制字节流“这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为”类加载器“。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。通俗地说:比较两个类是否”相等“,只有在这两个类是同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所说的”相等“,包括代表类的Class对象的equals()方法、isAsignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
从Java开发人员的角度看,类加载器可以划分为:启动类加载器(BootstrapClassLoader)、扩展类加载器(ExtensionClassLoader)、应用程序类加载器(AppClassLoader)、自定义加载器(CustomClassLoader)。
(1)启动类加载器
负责加载$JAVA_HOME jre/lib/rt.jar里所有的class或者-Xbootclasspath参数所指定的路径中,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)。由C++识别,不是ClassLoader子类
(2)扩展类加载器
负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar 或者 -Djava.ext.dirs指定目录下的jar包
(3)应用程序类加载器
负责加载classpath中指定的jar包或者 -Djava.class.path所指定目录下的类和jar包。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
(4)自定义加载器
通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader。如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。
双亲委派模型
双亲委派模型(Parents Delegation Model)要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程:是指如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
双亲委派模型的好处:
- 避免类的重复加载:在JVM中,每个类都由一个唯一的全限定名和一个对应的类加载器确定,类加载器根据全限定名和类路径来确定类的位置。因此一个JVM实例中,如果有两个类加载器分别加载了同一个类,JVM会认为这两个类是不同的,从而导致类型转换异常等问题。通过双亲委派机制,类加载器在加载类之前会先委托给自己的父类加载器去加载,从而保证一个类在JVM中只会有一份,并且由其父类加载器所加载。
- 保护程序安全,防止核心API被随意篡改:Java核心类库(java.lang 包下的类)都是由启动类加载器加载的,其他的类都是由其他类加载器加载的。这样,我们可以保证Java核心类库的安全性,因为不同的应用程序无法改变这些类的实现。
Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基本的行为也就无法保证,应用程序也将会变得一片混乱。
双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中。逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
jdk1.8源码
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
上面代码注释写的很清楚,首先调用findLoadedClass方法检查是否已加载过这个类,如果没有就调用parent的loadClass方法,从底层一级级往上。如果所有ClassLoader都没有加载过这个类,就调用findClass方法查找这个类,然后又从顶层逐级向下调用findClass方法,最终都没找到就抛出ClassNotFoundException。这样设计的目的是保证安全性,防止系统类被伪造。
自定义类加载器的应用
自定义类加载器通常有以下四种应用场景:
• 源代码加密,防止源码泄露
• 隔离加载类,采用隔离加载,防止依赖冲突。
• 修改类加载的方式。
• 扩展加载源。
1、源代码加密
源代码加密的本质是对字节码文件进行操作。我们可以在打包的时候对class进行加密操作,然后在加载class文件之前通过自定义classloader先进行解密操作,然后再按照标准的class文件标准进行加载,这样就完成了class文件正常的加载。因此这个加密的jar包只有能够实现解密方法的classloader才能正常加载。
2、隔离加载类
我们常常遇到头疼的事情就是jar包版本的依赖冲突,写代码五分钟,排包一整天。
举个栗子:
工程里面同时引入了 A、B 两个 jar 包,以及 C 的 v0.1、v0.2 版本,v2 版本的 Log 类比 v1 版本新增了 error 方法,,打包的时候 maven 只能选择 C 的一个版本,假设选择了 v1 版本。到了运行的时候,默认情况下一个项目的所有类都是用同一个类加载器加载的,所以不管你依赖了多少个版本的 C,最终只会有一个版本的 C 被加载到 JVM 中。当 B 要去访问 Log.error,就会发现 Log 压根就没有 error 方法,然后就抛异常 java.lang.NoSuchMethodError。这就是类冲突的一个典型案例。
类隔离技术就是用来解决这个问题。让不同模块的 jar 包用不同的类加载器加载。
JVM 提供了一种非常简单有效的方式,我把它称为类加载传导规则:JVM 会选择当前类的类加载器来加载所有该类的引用的类。例如我们定义了 TestA 和 TestB 两个类,TestA 会引用 TestB,只要我们使用自定义的类加载器加载 TestA,那么在运行时,当 TestA 调用到 TestB 的时候,TestB 也会被 JVM 使用 TestA 的类加载器加载。依此类推,只要是 TestA 及其引用类关联的所有 jar 包的类都会被自定义类加载器加载。通过这种方式,我们只要让模块的 main 方法类使用不同的类加载器加载,那么每个模块的都会使用 main 方法类的类加载器加载的,这样就能让多个模块分别使用不同类加载器。这也是 OSGi 和 SofaArk 能够实现类隔离的核心原理。
3、热加载/热部署
在应用运行的时升级软件,无需重新启动的方式有两种,热部署和热加载。
对于Java应用程序来说,热部署就是在服务器运行时重新部署项目,热加载即在运行时重新加载class,从而升级应用。
热加载可以概括为在容器启动的时候起一条后台线程,定时的检测类文件的时间戳变化,如果类的时间戳变掉了,则将类重新载入。对比反射机制,反射是在运行时获取类信息,通过动态的调用来改变程序行为。而热加载则是在运行时通过重新加载改变类信息,直接改变程序行为。
热部署原理类似,但它是直接重新加载整个应用,这种方式会释放内存,比热加载更加干净彻底,但同时也更费时间。
4、扩展加载源
字节码文件可以从数据库、网络、移动设备、甚至是电视机机顶盒进行加载,可以与源代码加密方式搭配使用。比如部分关键代码可以通过移动U盘读取再加载到JVM。