胡琪

为今天工作,为明天投资,为未来孵化一些东西

从ClassNotFoundException看java的类加载机制和Instant Run类加载部分的设计

我们都知道java中的类加载机制采用的是双亲委派机制(其实更严格的说法应该是未经加载过的类采用双亲委派机制加载,已经加载过的不是)。利用这种特性我们可以做到自定义ClassLoader加载额外路径的jar进而做到代码热更新,考虑如下一种场景:在宿主jar中存在一个类X,在该类中存在一个函数someFun,在另外一个插件jar中包含一个和宿主jar同包名同类名的类X,在该类中也存在和宿主jar中X的相同签名的函数someFun,在宿主jar的main函数中调用了类X的someFun函数,那么如何做到让main函数中调用的函数someFun是来自插件jar中而不是宿主jar中的呢?根据java的双亲委派机制,我们很容易想到可以自定义一个ClassLoader用来加载插件jar,然后将这个自定义ClassLoader设置为当前ClassLoader的父加载器即可,因为按照双亲委派加载,在main函数中使用到类X时会委托当前ClassLoader的父加载器去加载类X,而此时其父加载器为我们自定义的类加载器,所以会优先从插件jar中加载类X,这样调用的方法也即为插件jar中的X类的同名方法。

比如:存在类com.test.caseclass.X,宿主jar和插件jar的这个类X中都包含一个print函数,宿主jar和插件jar中的print函数的实现不一样,为了简单起见,这里只是简单的打印一句输出作为不同的实现,宿主jar中的X类代码如下:

插件jar的X类代码如下:

在宿主的main方法中存在一处调用类X的print函数的代码,

那么很显然此时调用的是宿主jar中的类X的print函数,因此输出为:

那么我们如果想要让X.print调用的是插件jar中的print函数应该怎么做呢?我们只需要自定义一个ClassLoader,然后将其加载路径设置为插件jar,然后将该自定义ClassLoader设置为当前系统默认应用类AppClassLoader的父加载器即可(在java中用户的类的默认加载器是AppClassloader)。整个类加载器的委托加载关系转换如下图所示:

《从ClassNotFoundException看java的类加载机制和Instant Run类加载部分的设计》

此时代码如下:

运行程序,输出如下:

《从ClassNotFoundException看java的类加载机制和Instant Run类加载部分的设计》

可以看到确实是加载了插件jar中的类X而不是宿主jar中的类X,这个很好理解,因为双亲委派机制,查找类X时会优先从myClassloader中查找,因此查找到的是插件jar的类X,所以输出是插件jar中的X.print()。接着考虑复杂一点的情况,如果在类X中引用了类Y,而这个类Y只在宿主jar中存在,在插件jar中不存在,那么程序的输出会时什么呢?比如此时类X的print函数代码是这样的:

其中的Y.print仅仅只是打印一句:System.out.print(“this msg is from Y class”);注意此时我们的插件jar中不存在类Y,类似代码热修复场景仅仅类X的print函数代码出现bug,我们打算修复类X的print函数,类X引用到的类Y还是使用原本在宿主jar中存在的类Y。那么此时程序的输出是什么呢?请大家先思考几秒钟再往下看。

从ClassNotFoundException深入理解java类加载机制

正确答案是运行时抛出ClassNotFoundException。如下图所示:

《从ClassNotFoundException看java的类加载机制和Instant Run类加载部分的设计》

纳尼?为何会是类找不到异常呢?类Y不是在宿主jar中吗?不是父加载器(myClassLoader)找不到应该到子加载器即此时的系统类加载器(AppClassLoader)中查找吗?那应该能够找到类Y啊。为何会是ClassNotFoundException呢?

这就需要对java中的类加载机制和双亲委派模型细节有较为深入的了解了,因为java中的类加载器从加载过程来看实际上都可以概括为2类加载器,一类是初始加载器(initiating loader),一类是定义加载器(defining loader),前者是调用loadClass方法的加载器,后者是调用defineClass方法的加载器,而双亲委派意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器很大的可能不是同一个加载器,在 Java 虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。而双亲委派模型中的父加载器找不到该类会到子加载器中查找是针对loadClass而言的,也即是初始化加载器而言的,而上述示例中在Main.class中引用了类X,因此java虚拟机会去加载类X的字节码,此时java默认的加载器AppClassLoader就是类X的初始类加载器(调用loadClass),但是因为双亲委派机制实际加载类X的加载器是我们自定义的myClassLoader,因为myClassLoader是AppClassLoader的父加载器,同时myClassLoader的加载路径插件plug.jar中能够找到类X的字节码内容。此时在加载类X的字节码的时候发现类X引用了类Y,所以会调用loadClass去加载类Y,所以此时类Y的初始化加载器是myClassLoader,所以按照双亲委派模型,会委托myClassLoader的父加载器ExtClassLoader去加载类Y,很显然父加载器找不到,然后自己尝试加载,但是在myClassLoader的加载路径插件plug.jar中找不到类Y,所以抛出异常,注意事实上此时是不会到myClassLoader的子加载器AppClassLoader中查找类Y的,即使在其子加载器AppClassLoader加载路径中能够找到该类的字节码。因为类Y的初始化加载器是myClassLoader。

两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器,如类 com.example.Outer引用了类 com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。

方法 loadClass()抛出的是 java.lang.ClassNotFoundException异常;方法 defineClass()抛出的是 java.lang.NoClassDefFoundError异常。

关于java中ClassLoader的更多详细权威的讲解请参看:深入探讨 Java 类加载器

 

如何解决上面问题

相信大家知道了抛出异常的原因之后很容易想出一个非常简单的解决办法,即在插件jar中也保存一份类Y的代码,这样myClassLoader查找类Y时就能够找到该类,作为一个简单的demo这样做是可以的,但是如果是在实际使用中,当我们想动态替换一个类的功能的时候,如果这个类引用的类比较多,其引用的类也存在引用其他类的情况,因此我们需要将所有使用到的类都放到插件jar中,这种方式本质上相当于重新打包发布了一个宿主jar,只不过这个宿主jar中的类X修改了一部分代码,很显然这样做和我们的初衷动态的修复某一个类是相违背的,我们只希望去动态的修复那个出现bug的类,而其他的类都不用处理。如果大家对主流的热修复比较了解的话,大家可能就清楚这种在插件jar中保留宿主jar中的类的方案类似热修复中的全量替换,即我全部都不使用原来的宿主jar中的类,重新发布一个jar包作为插件jar去动态加载。那能否不把插件类X引用到的类都放到插件jar中同时不抛出异常呢?即插件jar中只保留要修复的类去动态加载,其引用的类仍然使用原来的宿主jar中的。答案是肯定的,谷歌的Instant Run中类加载部分的设计可以用来解决这类问题。

Instant Run类加载部分的设计

网上关于Instant Run的类加载部分的讲解都是简单的一句双亲委派机制带过,但是事实上,双亲委派机制不是Instant Run类加载部分设计的精妙之处,简单的双亲委派和前面提到的那个例子一样会抛出ClassNotFoundException,事实上Instant Run关于类加载部分设计的精妙之处在于双亲委派和代理加载构成闭环,如下图所示是谷歌Instant Run的设计图:

《从ClassNotFoundException看java的类加载机制和Instant Run类加载部分的设计》

(注:图片来源于网络)

即将安卓中的默认加载器PathClassLoader(类似java中的AppClassLoader)的父加载器设置为自定义的IncrementalClassLoader,但是IncrementalClassLoader只是个空壳,其对类的加载实际上是代理给了其内部定义的一个DelegateClassLoader去加载,同时将PathClassLoader也设置为DelegateClassLoader的父加载器,也就是说系统默认的加载器PathClassLoader的父加载器是自定义IncrementalClassLoader,其内部的DelegateClassLoader的父加载器是PathClassLoader,这样构成了一个闭环,用前面的示例来举例的话,当PathClassLoader加载类X的时候会委托IncrementalClassLoader加载,而IncrementalClassLoader会把该加载代理给DelegateClassLoader,这样DelegateClassLoader加载到类X,同时加载类X的时候发现类X引用了类Y,所以此时类Y的初始化加载器为DelegateClassLoader,根据前面的分析DelegateClassLoader是无法加载类Y的,但此时因为设置了PathClassLoader为DelegateClassLoader的父加载器,因此会委托PathClassLoader加载,而类Y在PathClassLoader的加载路径中,因此能够成功加载到类Y,从而解决前面示例中的问题。

关于谷歌Instant Run类加载部分的代码因为不是本节重点,因此不做详细描述,而且谷歌Instant Run类加载部分的代码非常简单,大家可以从官网进行了解:https://android.googlesource.com/platform/tools/base/+/gradle_2.0.0/instant-run/instant-run-server/src/main/java/com/android/tools/fd/runtime/IncrementalClassLoader.java

重点是弄清楚前面提到的示例中为何当插件jar中的类X引用的类Y不在插件jar中时会抛出异常和如何解决该异常,以及Instant Run类加载部分设计的思想。

Instant Run类加载部分设计思想的借鉴意义

前面说过Instant Run类加载部分的设计可以用来解决前面示例提到的插件jar中类X引用的类Y不在插件jar中时导致的ClassNotFoundException,推而广之,该设计可以用来解决广义上的类似热修复的补丁类/动态加载的插件类引用了原始jar/dex中的某个类导致的ClassNotFoundException,能够很好的做到补丁类代码和业务代码互不影响的效果,即能够被代理ClassLoader加载的类会通过DelegateClassLoader加载,不能被代理ClassLoader加载的类会被系统默认的ClassLoader加载(java中的AppClassLoader和安卓中的PathClassLoader),就像没使用代理ClassLoadr一样透明,从而很好的做到了动态加载的类代码和原业务代码相隔离的效果。即动态加载时只需要关心补丁类而不用关心业务代码类(宿主jar/dex中的类),即只用修复bug类,而无需全量合成替换。

java中findClass和安卓findClass差异导致在java中直接应用Instan run类加载思想产生的异常原因及解决方案

虽然Instan Run的设计思想很好的解决了动态加载的插件jar的类代码和原宿主jar业务代码相隔离的效果,但是并不意味着这种思想可以直接移植到java代码运行环境中而不加任何修改,原因是安卓中的ClassLoader的findClass方法和java中的ClassLoader的findClass的原理是不一样的,安卓中的findClass是从dex文件中查找类,和java中的loadClass方法一样安卓查找类时如果一个类以及加载会从缓存中取该类的Class对象。而java中的这个逻辑是在loadClass方法中的。如果直接采用Instan Run的这个设计思想,将自定义的MyClassLoader的类查找委托给代理的DelegateClassLoader的findClass方法,那么此时我们重写的肯定是ClassLoader的findClass方法,举个简单例子如下:

考虑这样一种情况,如果插件jar中的某个类X引用了插件jar中的类Y,那么当加载类X时,会使用代理ClassLoader的findClass方法去加载类Y,然后当宿主jar中也用到类Y的时候此时按照这个设计思想很显然代理ClassLoader还是会通过findClass方法加载类Y,那么此时就会抛出一个attempted duplicate class definition的异常,也就是某个类被同一个ClassLoader加载了2次,也即对同一个类使用同一个ClassLoader实例调用了2次findClass方法,这在java中是不允许的。

之所以会调用2次findClass方法原因是我们重写的是findClass方法,而java中一个类已经加载后下次使用会从缓存中加载的逻辑是在loadClass方法中的。而Instan Run的关于ClassLoader整个设计思想是自定义的ClassLoader直接调用代理DelegateClassLoader的findClass方法而不是loadClass,所以直接引用到java平台的话第二次还是会调用代理DelegateClassLoader的findClass方法。为何安卓上没这个问题,前面说过安卓中的findClass方法和java中是不一样的,安卓中的findClass方法实际上是委托DexPathList的findClass方法来完成类的查找的,不清楚的小伙伴可以参看也谈安卓dex的动态加载与MultiDex和热修复

那么如何解决呢?知道原因是第二次还会调用findClass导致的就很容易解决了,我们可以在DelegateClassLoader中设置一个缓存Map,用来保存已经加载的类名称与其对应的Class对象,然后在findClass方法中加载类的时候先判断在缓存Map中是否存在该类的对象,如果存在直接返回,否则在调用父类的findClass方法即可,换而言之就是把java中ClassLoader的loadClass方法中缓存逻辑加到代理DelegateClassLoader中即可。

打赏

点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注