阅读 824

一个一年没解决的ClassNotFoundException|类加载机制探索

背景

在一开始写Android的时候经常碰到一些ClassNotFoundException,大部分情况下是少导入了什么包导致的。我碰到一个困扰了一年之久的ClassNotFoundException,终于在这两天我解决了这个问题,下面让我给大家表演一下真正的技术。

我四年前写了个路由组件,一年前打算优化下注册逻辑,之前的注册逻辑是用ClassLoader去寻找特定包名下的所有class,然后去反射的方式实现的。我打算写了一个Plugin插件,通过transfrom的方式把所有的apt生成的class向一个注册类内插入,然后在初始化的时候调用这个注册类完成注册流程。

但是在插件写好之后,我只要一运行项目就会抛出一个ClassNotFoundException,报错内容如下。

    Process: com.kronos.router, PID: 4643
    java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{com.kronos.router/com.kronos.sample.MainActivity}: java.lang.ClassNotFoundException: Didn't find class "com.kronos.sample.MainActivity" on path: DexPathList[[zip file "/data/app/com.kronos.router-2/base.apk"],nativeLibraryDirectories=[/data/app/com.kronos.router-2/lib/x86, /system/lib, /vendor/lib]]
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2567)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2726)
        at android.app.ActivityThread.-wrap12(ActivityThread.java)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1477)
        at android.os.Handler.dispatchMessage(Handler.java:102)
        at android.os.Looper.loop(Looper.java:154)
        at android.app.ActivityThread.main(ActivityThread.java:6119)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776)
     Caused by: java.lang.ClassNotFoundException: Didn't find class "com.kronos.sample.MainActivity" on path: DexPathList[[zip file "/data/app/com.kronos.router-2/base.apk"],nativeLibraryDirectories=[/data/app/com.kronos.router-2/lib/x86, /system/lib, /vendor/lib]]
        at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:56)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:380)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:312)
        at android.app.Instrumentation.newActivity(Instrumentation.java:1078)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2557)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2726) 
        at android.app.ActivityThread.-wrap12(ActivityThread.java) 
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1477) 
        at android.os.Handler.dispatchMessage(Handler.java:102) 
        at android.os.Looper.loop(Looper.java:154) 
        at android.app.ActivityThread.main(ActivityThread.java:6119) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776) 
    	Suppressed: java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/appcompat/app/AppCompatActivity;
        at java.lang.VMClassLoader.findLoadedClass(Native Method)
        at java.lang.ClassLoader.findLoadedClass(ClassLoader.java:742)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:362)
复制代码

而我用jadx反编译了apk,在反编译后的项目内,是能找到所有的class的,然后因为工作原因我也就搁置了一段时间,然后断断续续,周末还是会去看看这个问题。

问题突破口

这两天正好在看《深入理解JVM虚拟机》的虚拟机类加载机制这章,其中的类加载的验证机制其实启发了我,先走下流程看下类的验证的释义。

类的验证

验证阶段是链接阶段的第一步,目的就是确保class文件的字节流中包含的信息符合虚拟机的要求,不能危害虚拟机自身安全。验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

  1. 文件格式验证

    验证class文件格式规范

  2. 元数据验证

    就是对字节码描述的信息进行语义分析,保证描述的信息符合java语言规范。验证点可能包括(这个类是否有父类(除Object)、这个类是否继承了不允许被继承的类(final修饰的)、如果这个类的父类是抽象类,是否实现了父类或接口中要求实现的方法)。

  3. 字节码验证

    进行数据流和控制流分析,这个阶段对类的方法体进行校验,保证被校验的方法在运行时不会做出危害虚拟机的行为。

  4. 符号引用验证

    符号引用中通过字符串描述的权限定名是否能找到对应的类、符号引用类中的类,字段和方法的访问性(private protected public default)是否能被当前类访问。

那么有没有可能在验证这个地方抛出的异常类似,然后导致这个类加载失败,导致了我上面的crash呢。

饭还是要一口一口吃,我们先从抛出这个异常的地方开始跟进吧。

Android ClassLoader

这几天查了下资料,同时翻看了下ClassLoader的源代码,安卓的类加载机制基本上来说和Java的是一样的。而ClassNotFoundException这个异常是在ClassLoader在loadClass方法触发的时候抛出的异常。

sample项目比较简单,所以默认使用的是PathDexClassLoader,而findClass方法还是调用的BaseDexClassLoader

    BaseDexClassLoader
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = pathList.findClass(name);
        if (clazz == null) {
            throw new ClassNotFoundException(name);
        }
        return clazz;
    }
复制代码

上面是BaseDexClassLoaderfindClass方法,简单的说当在ptahList内能找到你的类的情况下,返回class类,如果class没有找到就会抛出ClassNotFoundException

那么问题来了,我反编译包中这些class都是存在的,那么问题在哪呢??????从findClass分析的话,那么罪魁祸首只有可能是DexPathList

DexPathList

  private final DexPathList pathList;
    /**
     * Constructs an instance.
     *
     * @param dexPath the list of jar/apk files containing classes and
     * resources, delimited by {@code File.pathSeparator}, which
     * defaults to {@code ":"} on Android
     * @param optimizedDirectory directory where optimized dex files
     * should be written; may be {@code null}
     * @param libraryPath the list of directories containing native
     * libraries, delimited by {@code File.pathSeparator}; may be
     * {@code null}
     * @param parent the parent class loader
     */
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.originalPath = dexPath;
        this.pathList =
            new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

复制代码

首先在BaseDexClassLoader构造方法中初始化了DexPathList对象,然后在findClass使用的就是这个DexPathList对象。


    /**
     * Makes an array of dex/resource path elements, one per element of
     * the given array.
     */
    private static Element[] makeDexElements(ArrayList<File> files,
            File optimizedDirectory) {
        ArrayList<Element> elements = new ArrayList<Element>();
        /*
         * Open all files and load the (direct or contained) dex files
         * up front.
         */
        for (File file : files) {
            ZipFile zip = null;
            DexFile dex = null;
            String name = file.getName();
            if (name.endsWith(DEX_SUFFIX)) {
                // Raw dex file (not inside a zip/jar).
                try {
                    dex = loadDexFile(file, optimizedDirectory);
                } catch (IOException ex) {
                    System.logE("Unable to load dex file: " + file, ex);
                }
            } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
                    || name.endsWith(ZIP_SUFFIX)) {
                try {
                    zip = new ZipFile(file);
                } catch (IOException ex) {
                    /*
                     * Note: ZipException (a subclass of IOException)
                     * might get thrown by the ZipFile constructor
                     * (e.g. if the file isn't actually a zip/jar
                     * file).
                     */
                    System.logE("Unable to open zip file: " + file, ex);
                }
                try {
                    dex = loadDexFile(file, optimizedDirectory);
                } catch (IOException ignored) {
                    /*
                     * IOException might get thrown "legitimately" by
                     * the DexFile constructor if the zip file turns
                     * out to be resource-only (that is, no
                     * classes.dex file in it). Safe to just ignore
                     * the exception here, and let dex == null.
                     */
                }
            } else {
                System.logW("Unknown file type for: " + file);
            }
            if ((zip != null) || (dex != null)) {
                elements.add(new Element(file, zip, dex));
            }
        }
        return elements.toArray(new Element[elements.size()]);
    }
复制代码

DexPathList 封装了dex路径,这是一个final类,而且访问权限是包权限,也就是说外界不可继承,也不可访问这个类。在DexPathList构造的时候会根据路径。去生成了一个dex数组,相信看过热修复机制的朋友看到这些应该已经比较熟悉了。

    DexPathList
        /**
     * Finds the named class in one of the dex files pointed at by
     * this instance. This will find the one in the earliest listed
     * path element. If the class is found but has not yet been
     * defined, then this method will define it in the defining
     * context that this instance was constructed with.
     *
     * @return the named class or {@code null} if the class is not
     * found in any of the dex files
     */
    public Class findClass(String name) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;
            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        return null;
    }
复制代码

当我们调用DexPathListfindClass的时候,就是去遍历所有的dexElements实例(顺手讲下热修复原理,不就是把dex加载到Elements的最前面,当最前面的dex有值的情况下就不会调用后面的dex去生成实例),然后从dex实例中去获取到我们的类,如果没找到那么就会返回一个null。

有没有可能是别的原因导致的呢,dex数组一开始在加载的时候就出现问题了呢????

DexFile和类加载验证

其实我在解决异常的时候,在ClassNotFoundException上面发现了另外一个Log日志的。W/m.kronos.route: Failure to verify dex file '/data/app/com.kronos.router-vMP1wsKFwirBk84bH8e11Q==/base.apk': Invalid type descriptor: 'V;'其实我看到这个日志的时候大概已经知道问题所在了。其实这个报错就是我插入的字节码不合法,然后这个dex加载失败了。

但是本着需要探索下宇宙的边界在哪里的精神,我决定还是深挖一下。以下大部分基于我的猜测,并没有实际证据支撑,如果有误导或者问题请各位大佬指正啊。

 
    /**
     * Opens a DEX file from a given filename. This will usually be a ZIP/JAR
     * file with a "classes.dex" inside.
     *
     * The VM will generate the name of the corresponding file in
     * /data/dalvik-cache and open it, possibly creating or updating
     * it first if system permissions allow.  Don't pass in the name of
     * a file in /data/dalvik-cache, as the named file is expected to be
     * in its original (pre-dexopt) state.
     *
     * @param fileName
     *            the filename of the DEX file
     *
     * @throws IOException
     *             if an I/O error occurs, such as the file not being found or
     *             access rights missing for opening it
     */
    public DexFile(String fileName) throws IOException {
        mCookie = openDexFile(fileName, null, 0);
        mFileName = fileName;
        guard.open("close");
        //System.out.println("DEX FILE cookie is " + mCookie);
    }
    
     /*
     * Open a DEX file.  The value returned is a magic VM cookie.  On
     * failure, an IOException is thrown.
     */
    native private static int openDexFile(String sourceName, String outputName,
        int flags) throws IOException;

复制代码

我们先看下类加载机制的流程图

  1. 加载

这个阶段我个人看法,就是在ClassLoader的构造函数执行的过程。从安卓出发应该就是BaseClassLoader初始化过程中把所有.dex文件读入到ClassLoader内存中。

  1. 验证

这个阶段我个人看法,就是DexFile类的openDexFile方法被执行完之后,这个native代码应该会去验证.dex文件内容是否合法。如果非法则不会加载这个dex文件。

  1. 后续流程

后续流程我认为则是和class的构造什么的相关的,并不在文章的讨论范围之内。

结论

首先要多尊重下字节码,因为在插桩过程中并没有代码的有效性检查的情况下,我们没法保证我们插入的字节码是一个没有错误的代码,特别是在安卓中,因为多个.class文件会被打成一个.dex,如果其中有一个.class文件的格式有问题的情况下,就会导致这个dex挂载失败,然后吧就会抛出一些奇奇怪怪的类找不到的问题。就像这个异常,其实和我插入的类并没有任何关系一样。

其次在源码的追溯过程中,更深入的感受了下java的类加载机制,虽然我也不能确定我的理解是不是有偏差,毕竟和这方面相关的资料实在有限,我甚至都没找到是如何验证代码格式的这段逻辑。

最后吧,问题才会让人成长,让人记忆更深刻。吃一堑长一智,之前在准备面试的过程中我还是看过一部分类加载机制的,但是和天书一样,今天看明天忘,偶尔吃个亏还是不错的。