记录 IoTDB 一次问题排查中学到的类加载知识

排查 [IOTDB-4899] [UDF] develop UDF class with Enum, return 500 when querying - ASF JIRA 时学习了一些 Java 类加载机制的知识,这里做个记录。

什么是类加载

类的加载指的是将类的 .class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供了访问方法区内的数据结构的接口。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM 规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了 .class 文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError 错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

简单来说,加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

1、通过一个类的全限定名来获取其定义的二进制字节流。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3、在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。

类加载具体机制可以参考:

Java类加载机制

JVM 基础 - Java 类加载机制

类加载机制

双亲委派机制

双亲委派机制是指如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器 在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。类加载器层次关系如下图:

记录 IoTDB 一次问题排查中学到的类加载知识_第1张图片
  • 启动类加载器(Bootstrap ClassLoader),负责加载存放在 $JAVA_HOME\jre\lib 下,或被 -Xbootclasspath 参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar,所有的 java.* 开头的类均被 Bootstrap ClassLoader 加载)。启动类加载器是无法被 Java 程序直接引用的。

  • 扩展类加载器(Extension ClassLoader),该加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 $JAVA_HOME\jre\lib\ext 目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如 javax.*开头的类),开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader),该类加载器由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  • 自定义类加载器(User ClassLoader),如果有必要,我们还可以加入自定义的类加载器。因为 JVM自带的 ClassLoader 只能从本地文件系统加载标准的 java class 文件。

全盘负责机制

当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显式使用另外一个类加载器来载入。

缓存机制

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

ClassLoader.loadClass 源码如下,可以看到第五行注释,首先会判断该类是否被加载过。

protectedClass loadClass(String name, booleanresolve)
    throwsClassNotFoundException
{
    synchronized(getClassLoadingLock(name)) {
        // First, check if the class has already been loadedClass c = findLoadedClass(name);
        if(c == null) {
            longt0 = 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.longt1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the statsPerfCounter.getParentDelegationTime().addTime(t1 - t0);
                PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                PerfCounter.getFindClasses().increment();
            }
        }
        if(resolve) {
            resolveClass(c);
        }
        returnc;
    }
}

需要注意的是,由不同 ClassLoader 加载的具有相同全限定名的 Class 属于不同的 Class.

即,使用 classLoaderA 加载 org.apache.iotdb.udf.example 后,再使用 classLoaderB 加载 org.apache.iotdb.udf.example,并不会走缓存的流程,因为这被认为是不同的 Class。这一点在排查问题初期没有打日志的时候也挺让人迷惑的。

一次问题排查

问题描述

Issue 链接:[IOTDB-4899] [UDF] develop UDF class with Enum, return 500 when querying - ASF JIRA

创建 UDF 成功,但是执行时报错

记录 IoTDB 一次问题排查中学到的类加载知识_第2张图片

找不到 org.apache.iotdb.udf.MySum$1 类

解决过程

org.apache.iotdb.udf.MySum$1 是什么

由于这个类的类名很像匿名类,首先查看创建 UDF 用到的 jar 包里是否有这个类,结果如下:

记录 IoTDB 一次问题排查中学到的类加载知识_第3张图片

可以看到打出的 jar 包里包括了 MySum$1 这个类,但是实际项目里只有 MySum 类,所以这个类应该是编译之后自动生成的。

结合日志,在 transform 的40行才报错,这一行刚好是 swtich 代码块开始的地方,经过查证,发现 JVM 会在 swtich enum 中 case 数量大于一定值时,将这个代码块编译出一个匿名类,代码如下。

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.apache.iotdb.udf;

import org.apache.iotdb.udf.api.type.Type;

// $FF: synthetic class
class MySum$1 {
    static {
        try {
            $SwitchMap$org$apache$iotdb$udf$api$type$Type[Type.INT32.ordinal()] = 1;
        } catch (NoSuchFieldError var4) {
        }

        try {
            $SwitchMap$org$apache$iotdb$udf$api$type$Type[Type.INT64.ordinal()] = 2;
        } catch (NoSuchFieldError var3) {
        }

        try {
            $SwitchMap$org$apache$iotdb$udf$api$type$Type[Type.FLOAT.ordinal()] = 3;
        } catch (NoSuchFieldError var2) {
        }

        try {
            $SwitchMap$org$apache$iotdb$udf$api$type$Type[Type.DOUBLE.ordinal()] = 4;
        } catch (NoSuchFieldError var1) {
        }

    }
}

为什么找不到 org.apache.iotdb.udf.MySum$1

为什么找不到这个匿名类?

既然是加载这个类时出现的问题,我们首先要知道是哪个类加载器在尝试加载这个类。结合类加载机制中的全盘负责机制,我们知道这个匿名类是由加载依赖它的 org.apache.iotdb.MySum 的类加载器来加载的,我们把这个类加载器记为 A

为什么 A 加载不到 org.apache.iotdb.udf.MySum$1 ?

由于 A 成功加载了 org.apache.iotdb.udf.MySum,我们知道 A 是能成功找到 jar 包下的文件的。那么可能是 A 被关闭了,所以就无法加载到匿名类了。

排查的过程涉及 IoTDB UDF management 部分的具体代码,这里直接放出定位到的代码,由于 try-with-resource 在代码块结束后会自动调用资源的 close 方法,所以这里会自动关掉加载 org.apache.iotdb.udf.MySum 的类加载器 A,之后 org.apache.iotdb.udf.MySum$1 无法用 A 加载,于是出现了上面的报错。

try (UDFClassLoader currentActiveClassLoader =
        UDFClassLoaderManager.getInstance().updateAndGetActiveClassLoader()) {
      updateAllRegisteredClasses(currentActiveClassLoader);

      Class functionClass = Class.forName(className, true, currentActiveClassLoader);
      functionClass.getDeclaredConstructor().newInstance();
      udfTable.addUDFInformation(functionName, udfInformation);
      udfTable.addFunctionAndClass(functionName, functionClass);
    } catch (IOException
        | InstantiationException
        | InvocationTargetException
        | NoSuchMethodException
        | IllegalAccessException
        | ClassNotFoundException e) {
      String errorMessage =
          String.format(
              "Failed to register UDF %s(%s), because its instance can not be constructed successfully. Exception: %s",
              functionName.toUpperCase(), className, e);
      LOGGER.warn(errorMessage, e);
      throw new UDFManagementException(errorMessage);
    }

ClassLoader.close()

在排查问题的时候,对 某 ClassLoader 加载的类在该 ClassLoader 被 close 后是否还可以被访问到存在疑问,查看源码之后发现是可以的。URLClassLoader 在被 close 后无法被用于加载新的类或资源,但是已经被 load 的类和资源仍然可以访问。

/**
 * Closes this URLClassLoader, so that it can no longer be used to load
 * new classes or resources that are defined by this loader.
 * Classes and resources defined by any of this loader's parents in the
 * delegation hierarchy are still accessible. Also, any classes or resources
 * that are already loaded, are still accessible.
 * 

* In the case of jar: and file: URLs, it also closes any files * that were opened by it. If another thread is loading a * class when the {@code close} method is invoked, then the result of * that load is undefined. *

* The method makes a best effort attempt to close all opened files, * by catching {@link IOException}s internally. Unchecked exceptions * and errors are not caught. Calling close on an already closed * loader has no effect. * * @exception IOException if closing any file opened by this class loader * resulted in an IOException. Any such exceptions are caught internally. * If only one is caught, then it is re-thrown. If more than one exception * is caught, then the second and following exceptions are added * as suppressed exceptions of the first one caught, which is then re-thrown. * * @exception SecurityException if a security manager is set, and it denies * {@link RuntimePermission}{@code ("closeClassLoader")} * * @since 1.7 */ public void close() throws IOException { SecurityManager security = System.getSecurityManager(); if (security != null) { security.checkPermission(new RuntimePermission("closeClassLoader")); } List errors = ucp.closeLoaders(); // now close any remaining streams. synchronized (closeables) { Set keys = closeables.keySet(); for (Closeable c : keys) { try { c.close(); } catch (IOException ioex) { errors.add(ioex); } } closeables.clear(); } if (errors.isEmpty()) { return; } IOException firstex = errors.remove(0); // Suppress any remaining exceptions for (IOException error: errors) { firstex.addSuppressed(error); } throw firstex; }

setContextClassLoader

setContextClassLoader 是用来打破双亲委派机制的一种手段,Java SPI 机制里就用到了它,这里不深入解释。

由于一开始遗忘了类加载的全盘负责机制,加上在 0.13 分支上看到了下面的代码块,产生了这样的误解:在加载匿名类时,会首先尝试使用线程的 ContextClassLoader(默认是 SystemClassLoader,即 AppClassLoader)来加载。

    if (!information.isBuiltin()) {
      Thread.currentThread()
        .setContextClassLoader(
            UDFClassLoaderManager.getInstance().getActiveClassLoader());
    }

由于 IoTDB 1.0 中,查询的规划线程和执行线程不一致,这段代码块是规划线程中的,所以一开始认为是没有正确的 setContextClassLoader 导致的问题,应该在执行线程中 setContextClassLoader。不过经过实验加上查阅资料,发现应该是按照全盘负责机制理解,setContextClassLoader 并不起作用,所以这段代码块应该删掉。

是否可以不关闭类加载器

由于我们用的类加载器继承自 URLClassLoader,会占用文件描述符,如果存在过多不关闭的 ClassLoader 显然是有过度占用资源的问题的。但是可能少量的 ClassLoader 并不会有大问题:

Leaving Classloader open after first use

类卸载时机

满足下述条件时一个类可能会被卸载掉:

1、该类所有的实例都已经被回收,也就是 java 堆中不存在该类的任何实例。2、加载该类的 ClassLoader 已经被回收。3、该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

ClassLoader 什么时候被回收还没有找到合适的资料,欢迎补充~

请注意,ClassLoader 并不提供接口让我们显示 unload 一个类,一般来说我们只能等 JVM 自己帮我们卸载掉一个类,所以不能对卸载类有预期。

References

Java类加载机制
JVM 基础 - Java 类加载机制
JAVA系列之类加载机制详解
Leaving Classloader open after first use

你可能感兴趣的:(java,iotdb)