类加载:如果你懂我的心,也许就不会犯这样的错了

前言

前几天线上偶现一个java.lang.ExceptionInInitializerError的异常,经排查是因为加载class的时候,spring的context或者里面的bean可能还没创好。接下来,我们一起通过这个问题,走进类加载的心,理解类加载机制,避免下次还犯类似的错。

异常模拟复现

代码如下:

public class SMSNotifyUtils {
     private  static SMSNotifyService service = ContextProviderUtils.getBean("smsNotifyService", SMSNotifyService.class);
 }
 
 public class ContextProviderUtils implements ApplicationContextAware {

    private static ApplicationContext appContext;

    @SuppressWarnings("unchecked")
    public static <T> T getBean(String beanName, Class<T> clazz) {
        if (appContext == null) {
            System.out.println("null appContext");
        }
        return (T) appContext.getBean(beanName, clazz);
    }

    public void setApplicationContext(ApplicationContext appContext) throws BeansException {
        ContextProviderUtils.appContext = appContext;
    }
}
复制代码

以上代码,从spring容器获取一个bean,初始化一个静态变量。你觉得有问题吗?想一下,什么时候会触发static变量初始化?类加载过程?什么时候可以获取到spring容器中的bean?容器启动完成?

运行如下:

分析如下:

  • 由日志可得,出现空指针异常,显然就是spring context或者里面的bean还没创好,就要赋值,肯定报异常啦。怎么解决这个问题呢?

  • spring bean跟spring容器启动有关嘛; new ClassPathXmlApplicationContext一执行完,它就嘟嘟起来了。

  • static变量呢,跟类加载有关嘛,static变量别名就叫类变量。触发类加载,才会给它初始化嘛。

  • 上面那个问题,往深一层,就可以理解为:SMSNotifyUtils类加载比spring的context/spring bean先一步了。OK,那我们直接来复习一波类加载机制吧,走进她的心。

类加载的心

一、类加载机制

虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化。最终形成可以被虚拟机最直接使用的java类型的过程就是虚拟机的类加载机制。

JVM的类加载机制主要有如下3种。

  • 全盘负责: 所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

  • 双亲委派: 所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。

  • 缓存机制: 缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为很么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因

二、类的生命周期

加载

加载是类加载过程的一个阶段。在加载阶段,虚拟机主要完成以下3件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口

加载数据来源

类加载器通常无须等到“首次使用”该类时才加载该类,Java虚拟机规范允许系统预先加载某些类。

加载时机

  • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动的时候,用户需要指定一个要执行的主类(就是包含main()方法的那个类),虚拟机会先初始化这个类;
  • 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

类加载时机表现为下图:

连接

连接包括验证,准备,解析阶段

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

对于:public static int value = 123;,那么变量value在准备阶段过后的初始值为0而不是123,这时候尚未开始执行任何java方法,把value赋值为123的动作将在初始化阶段才会被执行。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用: 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义定位到目标即可。

直接引用: 直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。

初始化

类初始化阶段是类加载的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码)。如:

private static int a = 10
复制代码

在类加载准备阶段赋值为0,初始化阶段才赋值为10。

三、类加载器

把class文件中的字节码数据读入JVM,转化成运行时对应的Class对象。而执行这个动作的代码模块,就叫类加载器。

类加载器种类

根类加载器(bootstrap class loader): 这个类加载器负责将一些核心的,被JVM识别的类加载进来,用C++实现,与JVM是一体的。

Extension ClassLoader(扩展类加载器): 它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null。

Applicaiton ClassLoader(应用程序类加载器): 用于加载我们自己定义编写的类

双亲委派模型

如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。。

双亲委派模型代码实现

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    //1 首先检查类是否被加载
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
             //2 没有则调用父类加载器的loadClass()方法;
                c = parent.loadClass(name, false);
            } else {
            //3 若父类加载器为空,则默认使用启动类加载器作为父加载器;
                c = findBootstrapClass0(name);
            }
        } catch (ClassNotFoundException e) {
           //4 若父类加载失败,抛出ClassNotFoundException 异常后
            c = findClass(name);
        }
    }
    if (resolve) {
        //5 再调用自己的findClass() 方法。
        resolveClass(c);
    }
    return c;

复制代码

异常复现回归

异常代码:

private  static SMSNotifyService service = ContextProviderUtils.getBean("smsNotifyService", SMSNotifyService.class);
复制代码

由上一小节,我们复习了类加载机制,加载时机,加载流程等。那么,你在去看上一段的有问题代码,是不是发现,class加载的时候,spring容器或者bean是不是可能还没初始化好,这时候肯定会空指针,那么,应该怎么解决这个问题呢?

把初始化下沉到方法里面,如下

  private static SMSNotifyService service=null;

  public static SMSNotifyService getSMSNotifyService() {
        if (service == null) {
            service = ContextProviderUtils.getBean("smsNotifyService", SMSNotifyService.class);
        }
        return service
 }
复制代码

参考与感谢

个人公众号

欢迎大家关注,大家一起学习,一起讨论。

猜你喜欢

转载自juejin.im/post/5d85c54a6fb9a06ad16facc0
今日推荐