了解java的类加载器有助于你在编写java代码时的合理性、整洁度以及对反射的理解等。
闲音少叙,话接正文:
首先看一段代码让我们提提兴致:
public class Main {
public static final Main m = new Main();
private static int a;
private static int b = 0;
Main(){
a++;
b++;
}
public static void main(String[] args) {
System.out.println(m.a);
System.out.println(m.b);
}
}
你觉得它打印的结果是多少?1和1?来看结果:
程序的运行过程可能跟你想的一样,只不过你漏了一个环节,属于类加载器的加载过程,接下来我们深入类加载器看看对这个类做的什么?首先我们来剖析java类加载器:
类的加载、连接与初始化
加载:查找并加载类的二进制数据
连接:
- 验证:确保被加载的类的正确性 //比如不是javac编译的字节码文件不能给予运行
- 准备:为类的静态变量分配内存,并将其初始化为默认值 //默认值,比如int默认为0
- 解析:把类中的符号引用转换为直接引用 //这个不好解释,往后看
初始化:为类的静态变量赋予正确的初始值 //正确的初始值就是我们赋的值
继续剖析这3个阶段:
类加载器 - 加载
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构
加载.class文件的方式:
- 本地系统中直接加载 //经常使用的,就是编译后执行
- 通过网络下载.class文件 //通过java.net.URLClassLoader对象可以从网上回去下载.class文件
- 从zip,jar等归档文件中加载.class文件 //这种方式也是常用的,别人写的文件已经打好包了,java虚拟机是可以解析这些文件的
- 从专有数据库中提取.class文件 //不常用
- 将java源文件动态编译为,class文件 //不常用,这是将java源文件放到托管的主机,它会将这个java源文件编译成.class文件再去执行
java中有两种类型的类加载器
1、java虚拟机自带的加载器
1- 根类加载器(Bootstrap)
-- 由C++编写,无法通过java代码获得,
-- 如果一个类由根类加载器加载,那么调用getClassLoader返回null
-- 对于jdk自带的类会被根类加载器加载
2- 扩展类加载器(Extension)
-- java编写
-- 用于加载jar文件
3- 系统类加载器(System)
-- java编写,应用加载器
-- 对于自定义的类都会被应用加载器加载(打印出来的类加载器中含有$AppClassLoader字样)
2、用户自定义的类加载器
- java.lang.ClassLoader的子类
- 用户可以自定义类的加载方式
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器再预料某个类将要被使用时就预先加载它,如果再预先加载的过程中遇到了.class文件确实或存在错误,类加载器将会在程序首次主动使用到该类时才报告错误(LinkageError),如果这个类一直没有被主动使用,类加载器不会报告错误
类加载器-连接-类的验证
类的验证:
类被加载后就进入连接阶段。连接就是将已经读入到内存的类的二进制数据合并到虚拟机的运行时环境中去。
类验证的过程:
- 类文件的结构检查
确保类文件遵从java类文件的固定格式
- 语义检查
确保类本身符合java语言的语法规定
比如验证final修饰的类没有子类,被final修饰的方法没有被覆盖
- 字节码验证
确保字节码流可以被java虚拟机安全地执行
字节码流代表java方法(包括静态方法和实例方法),它是由称为操作码的单字节指令组成的序列,每一个操作码后都跟着一个或多个操作数字。字节码验证步骤会检查每个操作码是否合法,即是否有着合法的操作数。
(下图为某个class文件,里面的行头的 0 3 4 7 就是操作数)
- 二进制兼容性的验证
确保项目引用的类之间协调一致。
例如,在A类中的a方法调用了B类中的b方法,java虚拟机在验证A类时会检查方法区内a方法调用的B类b方法是否存在,版本不兼容时会出现这种情况,抛出异常NoSuchMethodError
类加载器-连接-准备
在准备阶段,java虚拟机为类的静态变量分配内存,并设置默认值的初始值。
比如以下test类在准备阶段,将int类型的静态变量a分配4个字节的内存空间,并赋予默认值0,static修饰的变量打印出来会是默认值,没被static修饰的变量不能直接使用。
public class test{
private static int a;
}
在解析阶段,java虚拟机会把类的二进制数据中的符号引用替换为直接引用。
例如,A类中的a方法调用B类中的b方法
public void a(){
B.b();//这种形式就称为符号引用
}
在A类的二进制数据中,包含了一个对B类的b方法应用,在解析过程中java虚拟机会把这个符号引用替换为一个指针,该指针指向B类的b方法在方法区内的内存位置,这个指针就是直接引用
类加载器-初始化
在初始化阶段,java虚拟机执行类的初始化语句,为类的静态变量赋予初始值。在程序中,静态变量的初始化由两种途径:
1、在静态变量的声明处进行初始化
2、在静态代码块中进行初始化
java程序对类的使用方式可分为两种:
1、主动使用:
所有的java虚拟机的实现必须在每个类或接口被java程序“首次主动使用”时才初始化他们
主动使用的6中情况(类初始化时机):
- 创建类的实例 //new对象
- 访问某个类或接口的静态变量,或者对该静态变量赋值 //对象.属性
- 调用类的静态方法 //对象.方法
- 反射 //反射的构建方式,比如:对象.class;Class.forName();对象.getClass()
- 初始化一个类的子类 //初始化子类对象时父类对象也一并被加载,常见的有通过反射获取一个类的父类对象
- java虚拟机启动时被标明为启动类的类
2、被动使用:除主动使用就是被动使用,没有被使用类不会被初始化,只有主动使用才会被初始化。
至此我们可以来解释开始那个有趣的代码的原理了:
/*
* 原因从两点出发,代码执行顺序和类加载原理:
* 程序走到public static Main m = new Main() 时,因为主动使用导致Main类被初始化,
* 按照类加载原理:先执行构造方法,加载a和b变量被赋予int类型的默认值 0,然后自增+1,此时的a和b的值都为1,这个时候new操作执行完毕。
* 接着程序继续往下执行,由于static的变量只分配一次内存,a变量已存在不会被赋予默认值,而b变量已存在又被赋值为0。
* 所以输出结果为1和0
* */
再看几个例子:
public class Main {
public static void main(String[] args) {
System.out.println(FinalTest.a);
System.out.println("---------------------");
System.out.println(FinalTest.b);
}
}
class FinalTest {
public static final int a = 1;
public static final int b = new Integer(1);
//根据类加载器原理,类被初始化或没被初始化可以通过静态代码块来判断
static {
System.out.println("静态代码块执行了!");
}
}
上述的打印结果给出两个结论:
1、当一个静态常量的取值能在编译期确定,那么调用此静态常量不会导致此类被初始化
2、当一个静态常量的取值只能在运行期确定,那么调用此静态常量会导致此类被初始化
public class Main {
public static void main(String[] args) {
Child c = new Child();
System.out.println("--------------------");
Parent2 p = new Parent2();
}
}
class Parent1 {
static {
System.out.println("执行了parent1类的静态代码块");
}
}
class Parent2 extends Parent1 {
static {
System.out.println("执行了parent2类的静态代码块");
}
}
class Child extends Parent2 {
static {
System.out.println("执行了child类的静态代码块");
}
}
结论:如果初始化一个类时它有直接的父类,那么系统将由子类往上先初始化父类,再向下加载子类。
但是这条规则不适用于接口,父接口并不会在子实现类或者子接口的初始化而被初始化,只有当程序首次使用特定接口的静态变量时,才会使该接口初始化。自己可以参照着试试
结论:使用ClassLoader抽象类的loadClass方法加载一个类,它不属于主动使用,并不会导致该类被初始化。
类加载器-父委托机制
我们知道类加载器用于将类加载到java虚拟机中。从JDK1.2版本开始,类的加载过程采用父委托机制,这种机制能更好的保证java平台的安全。此委托机制中,除了java虚拟机自带的根类加载器以外,其余的类加载器都有且只有一个父加载器,其中父子关系并不是所谓的继承关系。当一个程序请求加载器加载一个类时,这个加载器会先委托自己的父加载器去加载,如果父加载器无法完成加载任务才会使用请求的这个加载器去加载。
我们知道java自带的类加载器有3种:
- 根类加载器,用于加载jdk的lang包种的类
- 扩展类加载器,用于加载jar包种的类
- 系统加载器,用于加载用户自定义的类
父子加载器之间的关系
在父委托机制中,除了根类加载器以外,其余的类加载器都只有一个符加载器。
实际上类加载器之间的父子关系指的是加载器对象之间的包装关系,而不是类之间的继承关系。一对父子加载器可能是同一个加载器类的两个实例,也可能不是。在子加载器对象中包装了一个父加载器对象。
为什么需要父委托机制?
原因在于:父委托机制的有点是能够提高软件系统的安全性。因为在此机制下,用户定义的类加载器不可能加载因该由父加载器加载的可靠类,从而防止不可靠甚至恶意代码的代替由父加载器加载的可靠代码。例如:java.lang.Object类总是由根类加载器加载,其它任何用户自定义的类加载器都不可能加载含有恶意代码的java.lang.Object类。
命名空间
每个类加载器都有自己的命名空间,命名空间由该加载器以及所有父加载器所加载的类组成。在同一个命名空间中,不会出现类的完整名字(全类名)相同的两个类;在不同的命名空间中,有可能会出现类的完整名字(全类名)相同的两个类。
运行时包
由同一个类加载器加载的属于相同包的类组成了运行时包。决定两个类是不是属于同一个运行时包,不仅要看他们的包名是否相同,还要看定义类加载器(父委托机制中子委托父加载器,如果父加载器能加载,那么这个子加载器和父加载器同属于该加载器的定义类加载器;如果只能由当前类加载器完成加载,那么当前的类加载器就是定义类加载器)是否相同。只有属于同一运行时包的类才能相互访问包可见的类和类成员。这样的限制能避免用户自定义的冒充核心库的类去访问核心类库的包课件成员。例如用户自定义一个java.lang.xxx类,并由用户自定义的类加载器加载,由于java.lang.xxx和核心类库java.lang.*由不同的加载器加载,它们属于不同的运行时包,所以java.lang.xxx不能访问核心类库java.lang包中的课件成员。