在阿里一面中问到了这块知识,当时没准备类加载器相关的,还很尴尬地提到了插件化,结果也没能讲多少。那么,始めましょう!补全计划!
文章目录
概述
类加载 定义
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行效验,转换解析,连接初始化,都是在程序运行期间进行的,这种策略虽然会令类的加载提供一些性能开销,但是给java提供了很高的灵活性,java天生可以动态拓展的语言特性就是依赖运行时动态加载和动态链接的特点实现的,例如如果写一个面向接口的程序可以等到运行时再写具体的实现,用户可以通过java预定义的自定义类加载器让本地应用程序从网络或者磁盘读取一个二进制的流作为程序的一部分,这种组装应用程序目前广泛应用java程序中,最基础的Applet jsp 到复杂的OSGI,都是在java 运行时类加载完成的。
1.一个Class 有可能是一个类,有可能是一个接口
2. java 运行时类加载机制加载的是一个二进制流,无论它是以什么形态都可以
类加载器(java.lang.ClassLoader类) 定义
java.lang.ClassLoader类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个Java 类,即 java.lang.Class类的一个实例。
ClassLoader提供了一系列的方法,比较重要的方法如:
getParent() => 返回该类加载器的父类加载器
loadClass(String name) => 加载名称为name的类,返回的结果是java.lang.Class类的实例。
findClass(String name) => 查找名称为name的类,返回的结果是java.lang.Class类的实例。
findLoadedClass(String name) => 查找名称为name的已经被加载过的类,返回的结果是java.lang.Class类的实例。
defineClass(String name,byte[] b,int off,int len) => 把字节数组b中的内容转换成Java类,返回的结果是java.lang.Class类的实例。这个方法被声明为final的。
resolveClass(Class<?> c) => 链接制定的Java类
类加载器类型
当一个 JVM 启动的时候,Java 缺省开始使用如下三种类型类装入器:
Bootstrap ClassLoader|启动类加载器
主要负责jdk_home/lib目录下的核心 api 或 -Xbootclasspath 选项指定的jar包装入工作。
引导类装入器是用本地代码实现的类装入器,它负责将 /lib 下面的类库加载到内存中。用来加载 Java 的核心库(jre/lib/rt.jar),原生C++代码实现,并不继承自java.lang.ClassLoader。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
Extension ClassLoader|扩展类加载器
主要负责jdk_home/lib/ext目录下的jar包或 -Djava.ext.dirs 指定目录下的jar包装入工作。
扩展类加载器是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader) 实现的。它负责将< Java_Runtime_Home >/lib/ext 或者由系统变量 java.ext.dir 指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
System ClassLoader|系统类加载器
主要负责java -classpath/-Djava.class.path所指的目录下的类与jar包装入工作。
系统类加载器是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。
User Custom ClassLoader|用户自定义类加载器
java.lang.ClassLoader的子类
在程序运行期间, 通过java.lang.ClassLoader的子类动态加载class文件, 体现java动态实时类装入特性。除了系统提供的类加载器以外,开发人员可以通过继承 java.lang.ClassLoader类的方式实现自己的类加载器,以满足一些特殊的需求。
类加载过程
类从虚拟机中加载到内存中,到卸载出内存为止,他的整个生命周期包括
加载 => [ 验证 => 准备 => 解析 ] => 初始化 => 使用 => 卸载
其中[验证 准备 解析]又统称为连接(Linking)
加载(Loading)
加载 是类加载过程的一个阶段,在加载阶段,虚拟机需要完成三件事:
1)通过类的全限定名来获取次类的二进制字节流
2)将字节流所代表的静态存储结构转化为方法区的运行时数据结构
3)在内存中生成一个代表这个类的java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
加载完成之后,虚拟机的外部二级制字节流就按照虚拟机所需的格式存储在方法区之中,方法区的存储数据格式是由虚拟机实现自定义的,虚拟机规范未规定此区域的具体数据结构,然后在内存中实例化一个java.lang.Class类的对象,(Class 对象比较特殊,相对于HotStop而言,Class 对象虽然是一个对象,但是存放在方法区)这个对象作为程序访问方法区中的数据类型的外部接口
加载阶段和链接阶段的部分内容是交叉进行的,加载可能尚未结束,链接阶段可能已经开始了,但这些夹在加载阶段中进行的动作,仍然属于链接阶段的内容,两个阶段仍然保持着固定的顺序。
验证(Verification)
验证是链接的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。如果验证输入的字节流不符合Class 文件的约束和约束,就会抛出一个java.lang.VerifyErroryi异常或者他的子类。
验证分为四个动作:文件格式验证,元数据验证,字节码验证,符号引用验证
文件格式验证
检查验证码字节流是否符合Class 文件格式的规范,并且能被当前版本的jvm处理
元数据验证
对字节码的信息进行语义处理分析,保证描述的信息符合java 语言规范的要求。
主要对类的元数据进行检验,保证符合java语言规范的元数据信息
字节码验证
主要目的是通过数据流的控制流分析,确定程序语义的合法,符合逻辑,这个阶段主要对方法体进行检验,确保验证类的方法在运行的时候不会对jvm产生危害。
如果一个类的方法体没有通过字节码验证,那这个方法体肯定有问题,但是一个方法体通过了字节码验证也不一定是安全的。
符号应用验证
最后一个阶段的校验发生在jvm将符号引用转换为直接引用的时候,这个转化动作在解析中发生,符号引用验证可以看作对类自身以外(常量池中的各种引用)的信息发生校验。
如果这一步出错会产生 java.lang.IncompatibleClassChangeError 或者子类,对于虚拟机类加载机制来说验证阶段是一个非常重要,但不是必须的阶段。
准备(Preparation)
准备阶段是正式为类变量分配内存设置类变量初始化值的阶段,这些变量所使用的内存都将在方法区分配,(这里的变量指的是被static 修饰的类变量)不包括实例变量,实例变量将会在对象被实例化的时候进入堆内存中,这里的变量初始化值为0
解析(Resolution)
解析是虚拟机将常量池的符号引用替换为直接引用的过程。
1)类或者接口的解析
2)字段解析
3)类方法解析
4)接口方法解析
初始化(Initialization)
类的初始化是类的加载的最后一步,前面的类的加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器之外,其他代码全部由jvm主导控制,到了初始化阶段才真正开始字节码的执行。
或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程。
双亲委派机制
JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,父类加载器又将加载任务向上委托,直到最父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;如果不行就向下传递委托任务,由其子类加载器进行加载。如果父加载器为null,则会调用本地方法进行启动类加载尝试。
另外,虚拟机出于安全等因素考虑,不会加载< Java_Runtime_Home >/lib存在的陌生类,开发者通过将要加载的非JDK自身的类放置到此目录下期待启动类加载器加载是不可能的。
好处:保证java核心库的安全性(例如:如果用户自己写了一个java.lang.String类就会因为双亲委派机制不能被加载,不会破坏原生的String类的加载)
拓展:代理模式。与双亲委派机制相反,代理模式是先自己尝试加载,如果无法加载则向上传递。tomcat就是代理模式。
源码分析
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先检查这个classsh是否已经加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// c==null表示没有加载,如果有父类的加载器则让父类加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//如果父类的加载器为空 则说明递归到bootStrapClassloader了
//bootStrapClassloader比较特殊无法通过get获取
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {}
if (c == null) {
//如果bootstrapClassLoader 仍然没有加载过,则递归回来,尝试自己去加载class
long t1 = System.nanoTime();
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
插件化
定义:是一种遵循一定规范的应用程序接口编写出来的程序,只能运行在程序规定的系统平台下,而不能脱离指定的平台单独运行。[百度百科]
也就是说,插件可以提供一种动态扩展能力,使得应用程序在运行时加载原本不属于该应用的功能,并且做到动态更新和替换。
在Android中,就是把一些核心复杂依赖度高的业务模块封装成独立的插件,然后根据不同业务需求进行不同组合,动态进行替换,可对插件进行管理、更新,后期对插件也可进行版本管理等操作。
事实上,插件化属于动态加载技术的范畴,同时属于动态加载技术范畴的还有热修复。
原理
确立两个概念
- 宿主
所谓宿主,就是需要能提供运行环境,给资源调用提供上下文环境,一般也就是我们主 APK ,要运行的应用,它作为应用的主工程所在,实现了一套插件的加载和管理的框架,插件都是依托于宿主的APK而存在的。- 插件
插件可以想象成每个独立的功能模块封装为一个小的 APK ,可以通过在线配置和更新实现插件 APK 在宿主 APK 中的上线和下线,以及动态更新等功能。
核心原理
- Binder
我们都知道 Android 多进程通信核心就是 Binder ,如果没有它真的寸步难行。 Binder 涉及两层技术,你可以认为它是一个中介者模式,在客户端和服务器端之间, Binder 就起到中介的作用。如果要实现四大组件的插件化,就需要在 Binder 上做修改, Binder 服务端的内容没办法修改,只能改客户端的代码,而且四大组件的每个组件的客户端都不一样,这个就需要深入研究了。学习Binder的最好方式是 AIDL ,这方面在网上有很多资料,最简单的方式就是自己写个 aidl 文件自动生成一个 Java 类,然后去查看这个Java类的每个方法和变量,然后再去看四大组件,其实都是跟 AIDL 差不多的实现方式。- App打包流程
代码写完了,执行一次打包操作,中途经历了资源打包、 Dex 生成、签名等过程。其中最重要的就是资源的打包,即 AAPT 这一步,如果宿主和插件的资源id冲突,一种解决办法就是在这里做修改。- App安装流程
手机安装 App 的时候,经常会有下载异常,提示资源包不能解析,这时需要知道安装 App 的这段代码在什么地方,这只是第一步。第二步需要知道, App 下载到本地后,具体要做哪些事情。手机有些目录不能访问, App 下载到本地之后,放到哪个目录下,然后会生成哪些文件。插件化有个增量更新的概念,如何下载一个增量包,从本地具体哪个位置取出一个包,这个包的具体命名规则是什么,等等。这些细节都必须要清楚明白。- App启动流程
Activity 启动有几种方式?一种是写一个 startActivity ,第二种是点击手机 App ,通过手机系统里的 Launcher 机制,启动 App 里默认的 Activity 。通常, App 开发人员喜闻乐见的方式是第二种。那么第一种方式的启动原理是什么呢?另外,启动的时候,Main 函数在哪里?这个 Main 函数的位置很重要,我们可以对它所在的类做修改,从而实现插件化。- 资源加载机制
做 Android 插件化需要控制两个地方。首先是插件 Dex 的加载,如何把插件 Dex 中的类加载到内存?另外是资源加载的问题。插件可能是 Apk 也可能是 so 格式,不管哪一种,都不会生成 R.id ,从而没办法使用。这个问题有好几种解决方案。一种是是重写 Context 的 getAsset 、 getResource 之类的方法,偷换概念,让插件读取插件里的资源,但缺点就是宿主和插件的资源 id 会冲突,需要重写 AAPT 。另一种是重写 AMS中保存的插件列表,从而让宿主和插件分别去加载各自的资源而不会冲突。第三种方法,就是打包后,执行一个脚本,修改生成包中资源id。- Gradle打包原理
在实施插件化后,如何解决不同插件的开发人员的工作区问题。比如,插件1和插件2,需要分别下载哪些代码,如何独立运行?就像机票和火车票,如何只运行自己的插件,而不运行别人的插件?这是协同工作的问题。火车票和机票,这两个 Android 团队的各自工作区是不一样的,这时候就要用到 Gradle 脚本了,每个项目分别有各自的仓库,有各自不同的打包脚本,只需要把自己的插件跟宿主项目一起打包运行起来,而不用引入其他插件,还有更厉害的是,也可以把自己的插件当作一个 App 来打包并运行。
实现原理
在Android中应用插件化技术,其实也就是动态加载的过程,分为以下几步:
- 把可执行文件( .so/dex/jar/apk 等)拷贝到应用 APP 内部。
- 加载可执行文件,更换静态资源
- 调用具体的方法执行业务逻辑
Android 项目中,动态加载技术按照加载的可执行文件的不同大致可以分为两种:
- 动态加载 .so 库
- 动态加载 dex/jar/apk文件(现在动态加载普遍说的是这种)
第一点, Android 中 NDK 中其实就使用了动态加载,动态加载 .so 库并通过 JNI 调用其封装好的方法。后者一般是由 C/C++ 编译而成,运行在 Native 层,效率会比执行在虚拟机层的 Java 代码高很多,所以 Android 中经常通过动态加载 .so 库来完成一些对性能比较有需求的工作(比如 Bitmap 的解码、图片高斯模糊处理等)。此外,由于 .so 库是由 C/C++ 编译而来的,只能被反编译成汇编代码,相比中 dex 文件反编译得到的 Smali 代码更难被破解,因此 .so 库也可以被用于安全领域。
其二,“基于 ClassLoader 的动态加载 dex/jar/apk 文件”,就是我们指在 Android 中 动态加载由 Java 代码编译而来的 dex 包并执行其中的代码逻辑,这是常规 Android 开发比较少用到的一种技术,目前说的动态加载指的就是这种。
Android 项目中,所有 Java 代码都会被编译成 dex 文件,Android 应用运行时,就是通过执行 dex 文件里的业务代码逻辑来工作的。使用动态加载技术可以在 Android 应用运行时加载外部的 dex 文件,而通过网络下载新的 dex 文件并替换原有的 dex 文件就可以达到不安装新 APK 文件就升级应用(改变代码逻辑)的目的。
常规的JVM类似,在Android中类的加载也是通过ClassLoader来完成,具体来说就是PathClassLoader 和 DexClassLoader 这两个Android专用的类加载器,这两个类的区别如下:
- PathClassLoader:只能加载已经安装到Android系统中的apk文件(/data/app目录),是Android默认使用的类加载器。
- DexClassLoader:可以加载任意目录下的dex/jar/apk/zip文件,也就是我们一开始提到的补丁。
这里需要明白的一点是对于一个ClassLoader(类加载器)来说,将一个具体的类(class)加载到内存中其实是由虚拟机完成的,对于开发者来说,我们关注的重点应该是如何去找到这个需要加载的类。
参考博客
深入JVM系列(三)之类加载、类加载器、双亲委派机制与常见问题
《深入理解JVM》第七章 类加载器的时机 && 类加载器的过程
Android插件化和热修复知识梳理
有关Android插件化思考
Android插件化从入门到放弃-最强合集