笔记:JVM - 类加载

1. 静态内部类单例的原理:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化 INSTANCE,故而不占内存。
下面的代码中,当 Singleton 第一次被加载时,并不需要去加载 SingletonHolder ,只有当 getInstance() 方法第一次被调用时,才会去初始化 INSTANCE ,第一次调用 getInstance() 方法时会导致虚拟机加载 SingletonHolder 类,这种方法不仅能确保线程安全(由 JVM 保证线程安全),也能保证单例的唯一性,同时也延迟了单例的实例化。
public class Singleton{
  
    private Singleton(){}
  
    private static class SingletonHolder{
        private static Singleton INSTANCE = new Singleton();
    }
     
    public static Singleton getInstance(){
        return SingletonHolder.INSTANCE;
    }
}

2. String.class.getClassLoader() 返回 null ,说明 String.class 是由 Bootstrap 加载器加载的。Bootstrap 是用 C/C++ 编写的,因此它不能算作是一个 Java 对象,因此用 null 来表示 Bootstrap 。

3. 类的生命周期有 7 阶段:加载、验证、准备、解析、初始化、使用、卸载。验证、准备、解析统称连接阶段。

4. 加载:将类的字节码文件加载到内存中,并在方法区中构建出 Java 类的原型,即类模板对象,然后在堆中生成该类的 Class 对象。
  • 类模板对象:相当于 Java 类在 JVM 内存中的一个快照,JVM 可以通过它来获取到类中的任意信息;是反射机制的基础。
  • Class 对象:指向类模板对象。Class 类的构造器是私有的,只有 JVM 能创建 Class 对象。
  • 我们是通过 Class 实例来获取类信息的,而 JVM 是直接通过类模板对象来获取类信息的。
类加载到方法区后,JVM 内部会使用 C++ 编写的 instanceKlass 来描述 Java 类,其字段有:
  • _java_mirror:该 Java 类的镜像,即 Xxx.class ;而 Xxx 类对象的对象头又会有一个 klassPointer 指向它对应的 instanceKlass 。
  • _super/_fields/_methods/_constants/_class_loader/_vtable/_itable:表示该类的父类/字段/方法/常量池/类加载器/虚方法表/接口方法表。

5. 类的本质就是二进制流。类的二进制流的来源有(格式不正确会抛 ClassFormatException 异常):
  • 直接来自 .class 文件;
  • 在 jar/zip 等压缩包中提取 .class 文件;
  • 通过读取存储在数据库的二进制数据获得;
  • 通过 HTTP 等协议从网络上获取;
  • 运行时动态生成,如动态代理。

6. 数组本身也是对象,但数组类是由 JVM 在运行时根据需要而直接创建的(也就是说无需类加载器加载?);如果数组的元素类型是引用类型,则还需要类加载器去加载元素所属的那个类;元素类型相同的数组,如果维度不同,那么它们所属的类就是不同的。数组类默认是 public 的,但如果元素类型是引用类型,则其访问权限和元素所属类的访问权限一致。

7. 验证:检查字节码是否合法、合理、合规。该阶段拖慢了加载速度,但避免了加载后对字节码的各种检查。
格式检查:检查魔数、版本、元素长度等。格式验证和加载一起执行,其它验证阶段在方法区中执行;
语义检查:检查该类的父类是否真实存在,是否继承/重写了 final ,非抽象类是否重写了所有抽象方法,是否有不兼容的方法(比如有多个同名同参、返回值不同的方法,这在字节码中是允许的,但 JVM 不允许;又比如出现了 abstract final 的方法)等;
字节码验证:最复杂的验证工作。检查字节码指令是否可以正确执行,比如:是否会跳转到一条不存在的指令,函数调用是否传递了正确类型的参数,变量赋值时是否给了正确的类型等。栈映射帧(StackMapTable,也是方法的 Code 属性的属性)在该阶段创建,用于检测在特定字节码处,其局部变量表和操作数栈是否有正确的数据类型,注意只是尽可能地检查出可以预知的明显的问题;
符号引用验证:检查符号引用所对应的类和方法是否真实存在(相关异常:NoClassDefFoundError 和 NoSuchMethodError),并且该有权限访问它们。要到解析阶段才会进行,因为解析阶段要将符号引用转成直接引用。

8. 准备:为类的的静态变量分配内存空间,并初始化为默认值(零值或空值)。
  • 该阶段不会为实例变量分配空间并初始化(加载类的时候对象还没有创建呢);静态变量在方法区中,而实例变量在堆中。
  • 该阶段不会有代码被执行(比如,不会执行 new 操作)。
注意,static final 的基本数据类型变量在编译成字节码后会有一个 ConstantValue 属性,也就是说,它的值在编译时已经确定了;因此,在准备阶段会直接将常量值赋给该变量。static final String s = "x"; 也是同理的。但是,static final String s = new String("x"); 则需要延后到初始化阶段才能完成最终赋值;因为准备阶段无法进行 new 操作,所以只能在 <clinit>() 方法内 new String("x"); 后才把该对象地址赋给变量 s 。

9. 解析:将各种符号引用转换成直接引用。以方法为例,JVM 为每一个类都准备了一张方法表,方法表记录了该类的所有方法,只要给出偏移量,就可以直接调用相应的方法。通过解析,符号引用就变成了目标方法在方法表中的对应位置,从而可以调用目标方法。
如果直接引用存在,那么可以确定它对应的类/方法/字段在系统中存在。
JVM 规范没有规定解析阶段的执行时机。在 Hotspot VM 中,解析阶段在初始化阶段完成之后才会执行。

10. 初始化:为静态变量赋予用户指定的初始值。到了这个阶段,JVM 会执行类中定义的 Java 代码,而该阶段主要是执行类的 <clinit>() 方法(类初始化方法,由静态代码块和静态成员的赋值语句构成)。在加载一个类之前,JVM 总是会先尝试加载其父类,因此父类的 <clinit>() 方法总是先于子类的 <clinit>() 方法执行(原因:子类的 <clinit>() 方法可能访问父类的静态成员,因此在子类的 <clinit>() 方法执行前必须先加载父类)。
假设静态代码块在前,静态变量 num 的声明语句在后,那么可以在静态代码块中给 num 赋值,但不可以调用这个静态变量(非法前向引用)。

11. 关于静态成员的赋值:
class Demo{
 
    public static int i1 = 1;                            //初始化阶段的<clinit>()方法中赋值
    public static final int i2 = 2;                      //链接的准备阶段中赋值
    public static final int i3 = new Random().nextInt(3);//初始化阶段的<clinit>()方法中赋值
 
    public static Integer i4 = Integer.valueOf(4);       //初始化阶段的<clinit>()方法中赋值
    public static final Integer i5 = Integer.valueOf(5); //初始化阶段的<clinit>()方法中赋值
 
    public static final String s1 = "static";            //链接的准备阶段中赋值
    public static final String s2 = new String("static");//初始化阶段的<clinit>()方法中赋值
 
}
 
/**
 * 总结1:对于static变量:
 * 1. 不是final的静态变量肯定是在初始化阶段显式赋值。
 * 2. 如果是final的,且是直接用字面量赋值,则在准备阶段显示赋值。
 * 3. 如果是final的,且不是直接用字面量赋值,则在初始化阶段显示赋值。
 *
 * 总结2:只有static final的、直接用字面量赋值(即不涉及方法或构造器调用)的才是在准备阶段显式赋值。
 */

12. 类只能初始化一次,因此 JVM 会保证 <clinit>() 方法的线程安全性;后到的线程想要执行 <clint>() 方法时会被阻塞,当 <clint>() 方法执行完成后,JVM 会直接给等待队列中的线程返回已经准备好的信息。
如果 <clinit>() 方法耗时较长,则可能会导致多个线程阻塞,引发死锁;这种死锁是很难发现的,因为这些线程没有相应的锁信息。
比如:线程 1 加载类 A ,在 A 的初始化方法中先休息一段时间,再去加载类 B ;而线程 2 在线程 1 休息期间加载了类 B ,然后在 B 的初始化方法中又要去加载类 A ,就会导致死锁。

13. 类的使用分为主动使用和被动使用,主动使用会初始化类,而被动使用则不会初始化类(当然加载和连接阶段还是必不可少的)。

14. 主动使用有且仅有 8 种情况:
  1. 通过 new 、反射、反序列化、克隆等手段创建该类的实例。
  2. 调用在该类中声明的(不能是从父类继承的)静态方法,也就是当字节码使用了 invokestatic 指令的时候。
  3. 使用了在该类/接口中声明的静态字段(必须是非 final 的,或是 final 但不是直接用字面量赋值的)。
  4. 使用 java.lang.reflect 包中的方法反射类的方法时,如 Class.forName("com.xxx.MyObject"); 。
  5. 初始化子类时,如果父类还没有被初始化,则先初始化父类。注意,初始化某个类时,不会初始化该类实现的接口;初始化某个接口时,也不会初始化它的父接口。即:一个接口不会因为它的实现类或子接口的初始化而初始化。
  6. 一般来说,一个接口不会因为它的实现类或子接口被初始化而初始化,但是,从 Java 8 开始,接口可以有 default 方法,因此如果接口有默认方法,那么如果它的直接或间接实现类的初始化会导致该接口先被初始化。
  7. JVM 启动时,会初始化主类,即 main() 方法所在的那个类(具体来说,JVM 启动时会通过引导类加载器加载一个初始类,这个类在加载、连接、初始化后,会调用 main() 方法,从而使得主类被初始化)。
  8. 初次调用 MethodHandle 实例时,会初始化该 MethodHandle 实例指向的方法所在的类。
接口没有静态代码块,那如何验证使用接口的静态字段时会初始化该接口呢?可以在接口中再定义一个如下变量:
public static final Object obj = new Object(){ { System.out.println("init!");} }; 
这里使用了匿名内部类,显然 obj 对象只有在接口被初始化时才会创建,而创建 obj 对象时会执行构造代码块,就会有输出了。

15. 被动使用的一些场景:
  1. 访问静态字段时,只有真正声明这个字段的类会被初始化,即:通过子类类名引用父类的静态变量时,子类不会被初始化;
  2. 定义某个类的数组时,该类不会被初始化,如 Object[] objs = new Object[10]; ;
  3. 引用 static final 的、直接通过字面量赋值的字段时不会初始化所在类;
  4. 调用 ClassLoader 类的 loadClass() 方法加载类时,不会初始化该类;
  5. 代码中使用 Xxx.class 时不会初始化 Xxx 类;
  6. Class.forName() 的某个重载方法的第二个参数为 false 时可以指定不初始化该类。

16. 类(Class 对象)、类加载器、类实例之间的关系:类加载器和类是双向的一对多的关系;类实例会引用它所属于的那个类;类则引用了它在方法区中的模板结构。关系如下:

17. 类的卸载(Unloading):当某个 Class 对象不再被引用,那么该类在方法区中的数据就可以被卸载了(卸载主要指的是删除该类在方法区中的模板结构),从而结束该类的生命周期。允许类被卸载的条件之前有提到:堆中不存在该类及其子类的对象、加载该类的类加载器被回收(很难)、该类的 Class 对象不再被引用。当类被卸载后,如果又有需要,则会重新加载该类,并生成一个新的对应的 Class 对象。

18. 为什么说类的卸载很难?
  • Bootstrap 加载器加载的类是核心 API ,不可能被卸载;
  • 引导类加载器和系统类加载器总能被直接或间接地访问到,因此它们加载的类在运行期间也不太可能被卸载;
只有自定义的类加载器加载的类在简单的上下文环境中才可能被卸载,而且一般还需要借助于强制调用 JVM 的垃圾收集才能做到。因此,类被卸载的可能性是很小的,至少卸载的时刻是不确定的,不要建立在某个类被卸载的前提下去实现某些功能。

19. 类的显式加载指的是在代码中通过 Class.forName() 或 ClassLoader.loadClass() 方法加载类;隐式加载由 JVM 自动完成。

20. 如果两个 Class 对象来自同一个类文件,但由不同类加载器加载,那么这两个 Class 对象就是不同的,而且它们指向的模板结构也不是同一个。每个类加载器都有自己的命名空间,命名空间由该类加载器及其父加载器加载的所有类组成。多个完整名字(包名加类名)相同的类不可能出现在同一个命名空间中,只可能存在于不同的命名空间中,可以借助这个特性来运行同一个类的不同版本。

21. 类加载机制的 3 个基本特征:
  1. 双亲委派机制:Java 2 开始引入的机制,但不是所有类加载器都遵循这个机制。比如,启动类加载器有时候需要加载用户自定义的类(比如 JDBC ),这时候就不能使用双亲委派机制,而是使用上下文加载器;我们自定义的类加载器也可以打破这个机制。
  2. 可见性:子加载器可以访问父加载器加载的类,反过来不行,不然会因为缺少必要的隔离而无法利用类加载器来实现容器的逻辑。
  3. 单一性:子加载器不会重复加载父加载器已经加载的类。但是兄弟类加载器可以加载同一个类,因为它们互相之间并不可见。

22. 一般而言,类加载器可以分为引导类加载器、扩展类加载器、系统类加载器、用户自定义类加载器。但 JVM 直接将类加载器分为引导类加载器(即 Bootstrap ,它是用 C/C++ 实现的)和自定义类加载器(即继承自抽象类 ClassLoader 的类加载器),后面三种加载器都属于自定义类加载器。子加载器和父加载器之间不是继承关系,而是包含关系。

23. 启动类加载器是用 C/C++ 实现,嵌套在 JVM 内部;负责加载 Java 的核心类库,并且只加载包名以 java/javax/sun 开头的类;启动类加载器不继承自 ClassLoader ,没有父加载器,而且会加载扩展类加载器和系统类加载器,并指定自己为它们的父加载器。
可以通过 -Xbootclasspath/a:path 参数让 Bootstrap 再去 path 路径下加载类;相当于指定 path 目录也是核心类库的目录。

24. 扩展类加载器是 Java 编写的,父加载器是启动类加载器。是 Launcher 的静态内部类(sun.misc.Launcher$ExtClassLoader),间接继承自 ClassLoader 类,会到 java.ext.dirs 系统属性指定的目录和 JDK 安装目录的 jre/lib/ext 目录中加载类库(包括我们放到该目录下的 Jar 包)。

25. 系统类加载器是 Java 编写的,父加载器是扩展类加载器。是 Launcher 的静态内部类(sun.misc.Launcher$AppClassLoader),间接继承自 ClassLoader 类,会到 java.class.path 系统属性指定的目录和 classpath 目录中加载类库。是应用程序中的默认类加载器,也是我们自定义的类加载器的默认父加载器,可通过 ClassLoader.getSystemClassLoader() 方法获得。

26. 自定义类加载器时通常要继承 ClassLoader 类。自定义类加载器的好处:
  1. 实现类库的动态加载,加载源可以是本地 Jar 包,也可以是网络上的远程资源;
  2. 通过类加载器可以实现非常绝妙的插件机制,为应用程序提供了一种动态增加新功能的机制;
  3. 实现应用隔离。

27. 获取类加载器的途径:

//获取加载某个类的类加载器,如果该类是扩展包的,则获取到扩展类加载器
Xxx.class.getClassloader();
 
//获取当前线程上下文的类加载器,默认是系统类加载器
Thread.currentThread().getContextClassLoader();
 
//获取系统类加载器,再调用其getParent()方法即可获得扩展类加载器
ClassLoader.getSystemClassLoader();

28. 
尝试获取引导类加载器时会得到 null ,那是因为引导类加载器是用 C/C++ 编写的,不能看作是一个对象。数组类的 Class 对象不是类加载器创建的,而是在 JVM 运行时根据需要自动创建的,通过数组类的 Class 对象获取其 ClassLoader ,会发现该加载器和加载数组元素所属类的加载器是一样的;如果是基本数据类型的数组,则其 Class 对象的类加载器是 null ,这个 null 代表没有/不需要类加载器,而不是指引导类加载器。

29. ClassLoader 是抽象类,但没有抽象方法,比较重要的方法有:
//加载名称为name的类,返回该类的Class对象,找不到则抛出异常
//该方法实现了双亲委派机制,所以如果自定义的类加载器要遵循双亲委派机制,就不要重写该方法
public Class<?> loadClass(Stirng name) throws ClassNoFoundException;
 
//查找名称为name的类,返回该类的Class对象,找不到则抛出异常
//是protected的,Java鼓励我们重写该方法;如果自定义的类加载器要遵循双亲委派机制,就重写该方法
//一般配合defineClass()方法使用,由此方法将二进制流封装成Class对象
protected Class<?> findClass(String name) throws ClassNoFoundException;
 
//负责将byte数组中从off开始、长度为len的部分封装成Class对象
//这个二进制流可以来自本地文件,也可以来自网络
protected final Class<?> defineClass(String name, byte[] b, int off, int len);
 
//解析(连接阶段的解析)指定的类
protected final void resolveClass(Class<?> c);
 
//查找名称为name的已经被加载的类,找不到则返回空
protected final Class<?> findLoadedClass(String name);

30. SecurityClassLoader 直接继承了 ClassLoader ,主要增加了一些验证相关的方法,了解即可。

31. URLClassLoader 直接继承了 SecurityClassLoader ,为 ClassLoader 中的很多空方法提供了具体的实现,并新增了 URLClassPath 类协助获取 Class 字节码流等功能。在自定义类加载器时,如果没有复杂的需求,则可以直接继承该类(扩展类加载器和系统类加载器就是继承了它)。

32. Class.forName(String) 和 ClassLoader#loadClass(String) 的区别:
  • 前者是静态方法,加载的类属于主动使用(它的某个重载方法的第二个参数可以指定是否初始化类)。
  • 后者是实例方法,是被动使用。

33. 双亲委派机制是 Java 2 开始才有的,并且是建议我们遵循而不是强制遵循:
优势:避免类的重复加载,确保类的全局唯一性(唯一性一方面指一个类不会被同一个类加载器加载多次,另一方面也指一个类不会同时被父加载器和子加载器加载);保护程序安全,防止核心 API 被篡改。
弊端:委托过程是单向的,虽然使得结构清晰、分工明确,但导致顶层的类加载器不能访问底层类加载器加载的类,比如有些系统接口需要用户实现,实现类可以访问到其接口,而接口却访问不到它的实现类。
自定义的 ClassLoader 重写 loadClass() 方法时会破坏双亲委派机制,但这样依旧无法篡改核心的 API(比如让这个类加载器去加载自定义的 java.lang.Object 类,最终加载的还是 Java 的 Object 类),因为任何类加载器最终都必须调用 final 的 defineClass() 方法,而该方法一定会执行 preDefineClass() 接口,该接口提供了对核心 API 的保护。
自己定义一个 java.lang.String 类时加载的还是核心类库中的 String 类,在运行自定义的 java.lang.String 类中的 main() 方法时会报错说系统的 String 类没有 main() 方法;自己定义一个 java.lang.MyClass 类时会抛出 SecurityException ,因为 java.lang 包不能随便使用。
Tomcat 的默认类加载器会先自己加载类,加载失败才让父加载器加载,这也是 Servlet 规范推荐的一种做法。

34. Java 发展过程中发生的破坏双亲委派机制的行为,注意这里的破坏并不一定是贬义的:
  1. Java 2 前没有双亲委派机制,自定义类加载器时只能重写 loadClass() 方法,因此 Java 2 前自定义的类加载器都破坏了双亲委派机制。
  2. 为了解决双亲委派机制的弊端而不得不引入线程上下文类加载器(不太优雅的设计),使得父加载器可以请求子加载器完成类加载行为。
  3. 第三次被破坏是由用户对程序动态性的追求而导致的,如代码热替换(Hot Swap,即在不停止程序的情况下,通过替换程序文件来修改程序行为。脚本语言本身就是支持热替换的)、模块热部署等。参考 JSR-291(即 OSGi R4.2)。
简易实现热替换:假设 Hot 类的 hot() 方法可能在程序运行时需要修改;那么写程序时,在每次需要调用 Hot#hot() 方法时,先创建一个自定义类加载器,让这个全新的类加载器去加载该类,从而产生一个全新的 Hot 类的 Class 对象,然后利用这个 Class 对象,通过反射创建实例、获取 hot() 方法的 Method 对象,然后通过 method.invoke(hot); 来调用 hot() 方法。
线程启动后,JVM 默认会把系统类加载器赋给它;从而可以通过调用当前线程的 getContextClassLoader() 方法来获取线程上下文类加载器。
JDBC 的 DriverManager 是核心类库中的,由启动类加载器加载;但是 DriverManager 的静态代码块中需要加载驱动类,而驱动类肯定不是核心类库中的;因此,此时是通过 ServiceLoader 以及系统类加载器来加载驱动类的(ServiceLoader 底层是通过线程上下文类加载器,即系统类加载器来加载的),这就违反了双亲委派机制。
ServiceLoader 是 Service Provider Interface(SPI)的一个实现,用于解耦;它约定,在 Jar 包的 META-INF/services 包下,接口全限定名称作为文件名称,该接口的实现类的全限定名称作为文件的内容(可以有多个实现类,每个实现类的全限定名称占一行);这样,它就可以通过 META-INF/services 包下的文件,去加载指定接口的实现类。
使用 JDBC 时,即使没有加载驱动类,JDBC 也可以正常使用,就是因为 ServiceLoader 会自动去加载驱动类。

35. 沙箱安全机制:Java 沙箱(Sandbox)是 Java 安全模型的核心;沙箱机制就是将 Java 代码限定在 JVM 特定的运行范围之内,并严格限制代码对本地系统资源的访问,从而隔离代码,避免它对本地系统造成破坏。因此,它可以保护程序安全,保护原生的 JDK 代码。所有 Java 程序运行时都可以指定沙箱,定制安全策略。

36. 自定义类加载器作用:
  1. 隔离加载类,从而实现容器、模块化的效果:隔离的其中一个好处是可以使得应用依赖的 Jar 包与中间件依赖的 Jar 包不会相互影响;
  2. 修改类的加载方式:除了 Bootstrap 加载的类,其它类都不是必需的,我们可以按需进行动态加载;
  3. 扩展加载源:可以从数据库、网络中加载类;
  4. 防止源码泄漏:对 Java 代码进行编译加密后,需要自定义类加载器来对其进行解密。

进行类型转换时,两个类必须是被同一个类加载器加载的,否则会有异常;如果有另一个类加载器加载了这其中一个类,这时候就要特别注意。


37. JDK 9 基于模块化进行构建,导致类的加载体系有很大的变化:
  1. 扩展机制被移除,扩展类加载器保留(为了兼容)但改名为平台类加载器,且可通过 ClassLoader 类的新方法 getPlatformClassLoader() 获取到。为什么这么做?因为基于模块化构建的类库天然地满足了可扩展的需求,这样一来 java.ext.dirs 属性以及 <JAVA_HOME>/lib/ext 目录就没有存在的必要了,因此移除扩展机制。
  2. 平台类加载器和应用程序类加载器不再继承自 URLClassLoader ,也不再是 Launcher 类的静态内部类;BootClassLoader(启动类加载器)和这两个加载器都继承自 BuildinClassLoader ,都是 ClassLoaders 类的静态内部类。自定义类加载器时不应继承 URLClassLoader 。
  3. 类加载器有了名称(在构造方法中指定),通过 getName() 方法获得。
  4. 启动类加载器不再是 C/C++ 编写,而由 JVM 和 Java 类库实现,但为了兼容,获取启动类加载器的方法依旧返回空,而非其实例。
  5. 双亲委派机制有些变化:3 个类加载器负责加载的模块是确定的,因此在委派给父加载器之前,加载器会先判断该类是否属于某个模块,如果是,则直接让负责该模块的那个加载器去加载。

全部评论

相关推荐

评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务