[笔记]Java虚拟机中类的加载

.class/字节码

计算机的硬件底层,至今还是0/1电路,人们用汇编语言实现了在硬件电路上运行程序,汇编的每一条指令,都有对应的机器码。
但是汇编语言与硬件架构和操作系统有关,在一台设备上正常的机器码,换台设备可能就无法运行,我们又不可能为每种设备都去重新开发一遍软件。
所以,当年Java提出了“一次编写,到处运行”的口号,其核心就是在开发者和硬件设备之间插入一层Java虚拟机,开发者面向的Java虚拟机是一致的,Java虚拟机再去和各种底层OS和硬件交互。
Java虚拟机里运转的就不再是机器码,而是字节码,也叫中间语言(在开发语言和汇编语言中间),其实就是我们编译出来的.class类文件,字节码直接在Java虚拟机中运行,虚拟机再根据当前所在的操作系统和硬件环境,转换成机器码。

字节码和类的关系

.class文件里存放的是对类的定义和描述,Java虚拟机需要把.class文件(从硬盘/网络/数据库等)加载到内存,并完成加载、连接和初始化,才能生成Java虚拟机能使用的java类。
java类在使用上最大的特定是运行时加载,就是说不必在编译时初始化和连接,而是在程序运行过程中,动态地加载和连接,比如用接口做动态实例化、用自定义的ClassLoader做热修复等,都是这种动态扩展的体现。

类的加载

类直到被使用时才会加载到虚拟机内存,用完以后再从内存中卸载,整个生命周期可以分为7个阶段:
加载-->(连接:验证-->准备-->解析)-->初始化-->使用-->卸载
这7个阶段,开始的顺序基本可以保证,但是并不是串行,一个阶段还在执行中,就会激活下一个阶段。
加载的是二进制字节流,而且可以选用自定义的ClassLoader。
解析有时候会在初始化之后才执行,这种行为叫做动态绑定。
类的初始化不是对象的初始化,对象的初始化是创建实例。
类的卸载不是在使用之后立即卸载,而是在GC中,经过严格的过滤(没有实例、没有ClassLoader、没有引用/反射),符合条件才能从内存中卸载(而且方法区一般很少做GC,默认ClassLoader的生命周期甚至与进程一致)。

java的类是运行时加载,但是引用一个类不一定会触发类的加载,因为引用分为主动引用和被动引用,只有主动引用能触发类的加载。

主动引用
以下5种情况,是必须知道类的各种信息的,只有这5种情况会触发类的加载。
1.new类的实例、读/写静态变量、调用静态方法时。
如果在读静态的final字段,其所在的类并不会被加载,因为这种字段在编译时会统一放到一个NoInitialization类的常量池中,与所在的类就没关系了。
2.反射调用时(java.lang.reflect)
3.初始化子类时,必须初始化父类(如果是接口,就不需要初始化父接口),所以java.lang.Object总是最先被初始化的类。
4.虚拟机启动时执行main()函数的那个主类
5.JDK1.7动态语言支持中,需要访问静态变量/静态方法时。

被动引用
被动引用的情况下,类不会被加载,比如:
1.通过子类使用父类的成员变量(只有直接定义字段的类才会被初始化)
2.数组定义中引用的类,不会触发初始化(虚拟机只做了一个一维数组)
3.静态常量(被编译到NoInitialization类的常量池中)

关于类的初始化和对象的初始化
类的初始化不是对象的初始化,类的加载有7个阶段,类的初始化是其中之一,类的初始化并不调用类的构造器;
类加载完成后,才可以实现对象的初始化,为对象去分配内存,用类的构造器初始化对象,为对象初始化实例变量,赋值实例变量。
子类的初始化列表不能初始化父类或者祖先类的成员,因为在执行初始化时,只会初始化一次父类中的对象,子类中的初始化没有机会得到执行。
使用静态字段时,只有直接定义字段的类才会被初始化,哪怕代码上是通过派生类使用的静态字段(即只触发父类的初始化,不触发子类的初始化)。

一个类从加载到初始化,分5个阶段:

  • 加载
    类加载的第一阶段是加载,可以用系统提供的加载器,也可以用自定义的加载器,加载会做三件事:
    1.根据类的全名,获取定义这个类的二进制字节流(字节流可以来自于JAR等压缩包/Applet等网络数据/反射代理/JSP文件生成/数据库等)
    2.把字节流的静态存储结构转化为方法区的runtime数据结构(做成虚拟机需要的数据存储格式,这个格式,各JVM厂商会自己定义)
    3.生成这个类的java.lang.Class对象,放在内存里(一般是放在方法区,而不是堆)
    数组不是加载器加载的,而是JVM直接创建的。
    只有加载阶段可以用户自定义加载器,其他阶段都是虚拟机操作
  • 验证
    因为JVM只要求输入二进制字节流,但不限定输入方式,所以不能保证输入的字节流是否有害,这样的话,JVM就必须先对字节流做验证(很明显,验证是夹在加载过程中的动作,必须验证通过,才能把字节流转化到方法区的虚拟机数据结构)。
    验证主要包括四个阶段:
    1.文件格式,首先验证
    只要文件格式正确,就能进方法区,后面的3个验证阶段,是在方法区做的验证。
    检查字节流是否符合Class文件格式(就是编译出来的.class字节码),当前版本的JVM能否识别。
    2.元数据,在文件格式之后验证
    根据方法区中类的存储结构,检查类的关系是否合法。(应该有父类、不能继承final父类、应该实行父类和接口要求的方法、没有和父类冲突的方法和字段)
    3.字节码,在元数据之后验证
    根据方法区中类的存储结构,检查类的方法有没有问题(数据类型错误、对象映射错误等)。当然,字节码验证不可能找到全部错误。
    字节码检查有类型推导和类型检查两种方式,类型检查更快一些。
    4.符号引用,在解析阶段验证(在解析阶段才会把符号引用转为直接引用,在转换时才需要验证),符号引用验证通过后,才能做解析操作。
    根据方法区中类的存储结构,检查符号引用是否正常(能不能根据全名找到对应的类、指定类有没有对应的方法和字段、这些类/方法/字段是否允许访问)。
  • 准备
    准备阶段会给类的静态变量分配内存(方法区),因为类还没初始化,所以这些变量只能先赋零值(比如String的'\u0000'零值、reference的null值),等类的初始化时,再赋代码中的值。
    不过,静态常量(static final),在准备阶段就可以得到代码中要求赋的值(在NoInitialization类里)
    非静态变量与准备阶段无关,需要在类实例化时,在堆中分配内存。
  • 解析
    解析阶段处理的其实是符号引用。
    直接引用会有指针/句柄直接指向内存,但是Java的动态加载机制决定了,Java编译出来的.class字节码里不可能有直接引用,因为在编译期,对象还没加载到内存,无法引用。
    Java只能先用符号引用,用一组字面量符号来描述所引用的目标,在类的加载时,通过解析,把符号引用再替换为直接引用。(因为类对象一般在方法区,所以符号引用指向的也是方法区)
    一般来说,符号引用是在ClassLoader加载类的时候就被虚拟机解析,但是,Java是动态加载的,所以一个符号引用也可以直到被使用时,才会被虚拟机解析(所以解析可能发生在类的初始化阶段,也就是在使用这个类之前)。
    解析主要针对7类符号引用(类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符),在Java静态语言中,主要是前4种。
    1.类或接口
    首先,虚拟机根据类的全名去加载类
    然后,对于数组类型,由虚拟机生成数据对象
    最后,检查是否可以访问所引用的类(private/public/protected等)。
    2.字段
    解析字段主要是解析字段所属的类
    首先,从这个类里找到字段
    然后,如果没有找到,从接口及其父接口里递归地找
    然后,如果没有找到,从父类里递归地找(如果都找不到,就报错NoSuchFieldError)
    然后,如果在父类和多个接口中同时定义了这个字段,编译会报错(因为无法确定你要用的是哪个)
    最后,检查是否可以访问字段及其所在的类
    3.类方法
    解析类方法要先解析所在的类,并确认是类,而不是接口。
    解析过程和字段类似
    首先,在所属类里找
    然后,去接口递归地找
    然后,去父类递归地找(如果最终在抽象类里找到了,抽象类是不能直接用的,所以会报AbstractMethodError错误)
    最后,检查访问权限
    4.接口方法
    解析类方法要先解析所在的接口,并确认是接口,而不是类。
    解析过程和方法类似
    首先,在所属接口里找
    然后,在父接口里递归地找
    最后,检查访问权限
    -初始化
    初始化过程就是执行类的构造器方法(())的过程,注意是构造器方法,不是实例化一个类的构造函数。
    ()是编译器生成的,就是用于处理静态代码块和为静态变量赋值的(静态变量在准备阶段只赋了零值),所以如果一个类里没有静态代码块和静态变量,就没有()构造器方法。
    ()构造器方法有这样几个规律:
    1.()是合并产生的,编译器会按代码顺序收集静态代码块和静态变量,注意是有顺序的,静态代码块中不能引用后面的静态变量(但是可以赋值,因为准备阶段已经在方法区做好了静态变量,所以能赋值,但是因为代码顺序,还没初始化,所以不能引用),不注意的话容易产生非法向前引用变量。
    2.所在的类也是父类先执行,接口也是。
    3.所以父类的()会先执行,不需要显示调用;但是,接口不需要先调父类的()。
    4.虚拟机会为()做加锁同步。
    其中,规律1比较容易迷惑,我们看这个例子:
class SingleTon {
    private static SingleTon singleTon = new SingleTon();
    public static int count1;
    public static int count2 = 0;
 
    private SingleTon() {
        count1++;
        count2++;
    }
 
    public static SingleTon getInstance() {
        return singleTon;
    }
}
 
public class Test {
    public static void main(String[] args) {
        SingleTon singleTon = SingleTon.getInstance();
        System.out.println("count1=" + singleTon.count1);
        System.out.println("count2=" + singleTon.count2);
    }
}

最终执行结果是:
count1=1;
count2=0;
因为static 静态代码段会依次执行,第一行中count1和count2被调用后,都被赋值为1,但是第三行static代码中,count2又被赋值为0了。

类加载器和双亲委派

类加载器是放在JVM外部实现的,这样应用程序可以按照自己的方式去获取二进制字节流。当然,虚拟机自己也有一个类加载器,就是启动类加载器(C++实现的Bootstrap ClassLoader),Bootstrap ClassLoader并不是Java类,所以无法获得它的引用。
虚拟机同时拥有很多个加载器。

类的和类的加载器
类的和类的加载器是深层绑定的,虚拟机加载同一个Class文件时,如果使用了不同的类加载器,这两个类就必定不相等(每个ClassLoader都有独立的类名称空间)。
在卸载类时(卸载方法区中的类对象),除了要判断没有类的实例,没有类的引用和反射,还要判断加载这个类的ClassLoader是否已经被回收(因为方法区的类信息里有指向ClassLoader的引用)。

双亲委派
因为虚拟机中同时存在多个类加载器,就会存在类的重复加载,甚至可能影响核心的Java API(如加载了一个自定义的String类),为了协调这些类加载器的关系,Java推荐了双亲委派机制。
双亲委派的核心其实是一条链式结构,把虚拟机自带的Bootstrap ClassLoader作为链的开始,后面扩展了Extention ClassLoader,再后面扩展了Application ClassLoader,再后面可以扩展各种自定义的ClassLoader。
在双亲委派机制下,类的加载就实现了父类加载器优先,所有可以加载这个类的ClassLoader中,会向上递归到最顶层那个ClassLoader,这样越基础的类就是由越上层的加载器进行加载,这样可以避免重复加载,并保护核心的Java API。
双亲委派机制可能被破坏,因为它不是强制性的要求。比如为了兼容较老的JDK版本使用findClass而不是loadClass;比如为了在基础的类中调用下层的代码,可以通过线程上下文加载器去实现让父类加载器请求子类加载器完成加载;再比如就是现在比较火的热部署/热更新,通过把新的dexElement插入到系统的dexElement之前,实现热部署/热更新。

引用

《深入理解Java虚拟机》
类加载和对象的初始化过程
Java 类加载机制 ClassLoader Class.forName 内存管理 垃圾回收GC
JVM源码分析之JDK8下的僵尸(无法回收)类加载器
关于Java类加载双亲委派机制的思考(附一道面试题)

你可能感兴趣的:([笔记]Java虚拟机中类的加载)