插件化揭秘(一.类是如何一步一步被加载到虚拟机中的)

1,152 阅读14分钟

🔥 Hi,我是小余。

本文已收录到 GitHub · Androider-Planet 中。这里有 Android 进阶成长知识体系,关注公众号 [小余的自习室] ,在成功的路上不迷路!

前言

前面一篇文章我们讲解了关于Class文件类文件结构,而Class文件最终需要加载到虚拟机内存中才能被使用, 本章就来讲解下,Class文件被加载到虚拟机中的过程

java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特性实现的。 我们应用程序使用的Class文件不一定是要安装运行的apk里面,也可以使用存放在本地或者远端的二进制Class文件。

类加载生命周期

一个类型从被加载到虚拟机内存中到从内存中卸载需要经历以下步骤。

类加载过程.png 其中加载验证准备初始化卸载这五个步骤是按顺序执行的,而解析步骤是不确定的,可能会在初始化后执行

下面分别来介绍下这几个阶段

1.加载

这里要说明下这个加载和我们的“class类加载”要区分开,这里的加载只是类加载的一个阶段

加载过程

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

java虚拟机规范对这三点要求并不是特别具体,第一条值要求使用权限定名去获取字节流,并没有指明字节流的存储位置, 可能是在本地或者远端,这里就给开发者扩展提供了一定空间,众多插件化相关框架就是建立在这个基础上的。

具体就是重写一个类加载器,重写findClass或者loadClass方法去实现自己的Class二进制流获取方式。

看下RePlugin框架中的方式:

public class RePluginClassLoader extends PathClassLoader {
	@Override
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        //
        Class<?> c = null;
        c = PMF.loadClass(className, resolve);
        if (c != null) {
            return c;
        }
        //
        try {
            c = mOrig.loadClass(className);
            // 只有开启“详细日志”才会输出,防止“刷屏”现象
            if (LogDebug.LOG && RePlugin.getConfig().isPrintDetailLog()) {
                LogDebug.d(TAG, "loadClass: load other class, cn=" + className);
            }
            return c;
        } catch (Throwable e) {
            //
        }
        //
        return super.loadClass(className, resolve);
    }

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        // INFO Never reach here since override loadClass , unless not found class
        if (LOGR) {
            LogRelease.w(PLUGIN_TAG, "NRH lcl.fc: c=" + className);
        }
        return super.findClass(className);
    }
 }

2.连接:包括验证,准备,解析

2.1:验证阶段

这个阶段主要是为了检测Class文件的安全性和合法性 主要包括几下几种验证方式:

  • 1.文件格式验证
  • 2.元数据验证
  • 3.符号引用验证
  • 4.字节码验证

这个阶段对Android开发来说,只需要了解下有这个步骤即可,无需深入。

2.2:准备

准备阶段是正式为类中定义的静态变量分配内存,并设置类变量的初始值

这里要提两点:

  • 1.这时候的内存分配只包括类的静态变量,不包括实例的变量,实例变量会在对象实例化时一起分配到java堆中
  • 2.这里的初始值,表示的数据的零值,而不是数据的真实值, 如下:
public static int value = 1234

在准备阶段过后,value在内存中的数据是0,而不是1234. 这是为什么呢?

因为value的真实赋值操作是在putStatic方法后,而putStatic方法是在类构造器cinit方法中,所以会在类的初始化阶段给value赋值为1234

各数据类型零值如下:

数据类型零值
int0
long0L
short(short)0
char'\u0000'
byte(byte)0
booleanfalse
float0.0f
double0.0d
referencenull

但是也有特殊情况: 如使用final修饰的静态变量,则在准备阶段就会赋值为真实的final值如上的1234

2.3.解析

解析阶段就是将class文件常量池中的符号引用转换为虚拟机内存中的直接引用

  • 符号引用: 这个在前面讲解Class文件结构的时候有说明,符号引用是用一组符号来描述所引用的目标, 符合可以是任何形式的字面量。符号引用与虚拟机的内存布局无关

  • 直接引用: 直接引用是可以直接指向目标的指针,相对偏移量或者能简介定位到目标的句柄, 直接引用是直接和虚拟机内存布局关联的。同一个符号引用再不同虚拟机上翻译出来的直接引用也不尽相同。

主要解析下面几种元素:

  • 1.类或者接口解析
  • 2.字段解析
  • 3.方法解析
  • 4.接口方法解析

验证,准备,解析三个阶段一起组成了类加载的连接阶段

通过上面的分析可知: 连接阶段主要任务就是完成将class文件中的类信息,加载到虚拟机的内存中, 并给类的静态变量赋上零值。

3.初始化

初始化阶段就是执行类构造器<clinit>方法的过程<clinit>方法是在编译器自动收集类中所有类变量的赋值动作和静态语句块static{}中的语句合并产生的

注意事项

  • 1.静态语句块只能访问定义在其之前的变量,对在其后的变量只能赋值不能访问: 如下:
public class SuperClass {
    public static int before = 1;
    static {
        //这里正常
        before=11;
        //这里正常
        System.out.println("before is "+before);
        //这里报The value 22 assigned to 'after' is never used ,但是不会报红,编译未报错
        after = 22;
        //编译期报非法向前引用,报红
        System.out.println("before is "+after);
        System.out.println("this is SuperClass!!");
    }
    public static int after = 2;
    public static  String className = "SuperClass";
}

  • 2.<clinit>方法执行前会优先加载父类的<clinit>,所以最先执行的肯定是Object的<clinit>方法 看下面例子:
public class SuperClass {
    public static int value = 123;
    static {
        value = 1234;
    }
}
public class SubClass extends SuperClass{
    public static int sunValue = value;
}
@Test
public void classLoaderTest() {
	System.out.println("sub:"+SubClass.sunValue);
}

结果:sun:1234

由于父类的static{}方法优先执行,所以结果sunValue是执行完父类执行完方法后的值

  • 3.<clinit>方法在多线程情况下会加锁同步,多个线程同时初始化一个类,那么只会有其中一个线程执行这个类的
public class SuperClass {
    public static int value = 123;
    static {
        value = 1234;
        System.out.println(value);
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
public class SubClass extends SuperClass{
    public static int sunValue = value;
}
    @Test
public void classLoaderTest() {

	Runnable runnable = new Runnable() {
		@Override
		public void run() {
			System.out.println(Thread.currentThread()+".start");
			SuperClass superClass = new SuperClass();
			System.out.println(Thread.currentThread()+".end");
		}
	};
	Thread thread1 = new Thread(runnable);
	Thread thread2 = new Thread(runnable);
	thread1.start();
	thread2.start();
}
结果:
Thread[Thread-1,5,main].start
1234
Thread[Thread-0,5,main].start
Thread[Thread-0,5,main].end
Thread[Thread-1,5,main].end

看到这里只执行了一次方法,也验证前面的结论

4.类初始化时机:

  • 1.遇到new,getStatic,putStatic,或invokestatic这四条字节码指令时 分别对应:
    • new->使用new创建实例对象
    • getStatic,putStatic->读取或者设置一个static类型的字段,final修饰除外
    • invokestatic:调用一个类的静态方法
  • 2.对类进行反射调用
  • 3.当前类初始化时,优先初始化父类
  • 4.虚拟机启动时会优先初始化包含main方法的那个类,在Android中就是ActivityThread
  • 5.当一个MethodHandle实例最后的解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种方法句柄,优先初始化这个方法句柄对应的类
  • 6.当一个接口中定义了jdk8中新加入的默认方法,被default修饰的的接口方法,如果接口的实现类需要初始化,则其会优先进行初始化

有且仅有上面6种情况类会进行初始化,被称为主动引用

有主动引用,当然会有被动引用除了前面主动引用的场景,所有引用类型的方式都不会触发初始化,称为被动引用

用个例子来说明:

演示1

public class SuperClass {
    static {
            System.out.println("this is SuperClass!!");
    }
    public static String className = "SuperClass";
}	

public class SubClass extends SuperClass{
    static {
            System.out.println("this is SubClass !!");
    }
}
单元测试:
@Test
public void classLoaderTest() {
String className = SubClass.className;
}    

打印结果:this is SuperClass!!, 只初始化了父类SuperClass而没有初始化SubClass,说明我们子类读取父类的static字段的时候,只会初始化父类,而不会初始化子类。

演示2:

我们将演示1中的单元测试改成下面这句话:

@Test  
public void classLoaderTest() {
    SuperClass[] classes = new SuperClass[20];
}   

发现并没有打印出this is SuperClass!!表明调用数组方法,并没有去初始化我们的SuperClass类,我们知道数组的创建,在字节码指令中对应newarray指令。而这个指令会自动触发数组对象的初始化,在这个例子中的数组对象对应的类为:[Lcom.anna.music.SuperClass,它是由虚拟机自动生成的继承为Object的子类

演示3

我们将演示1中的SuperClassclassName字段改为final的静态字段,在再子类中去引用这个类:

public class SuperClass {
    static {
            System.out.println("this is SuperClass!!");
    }
    public static final String className = "SuperClass";
}	

@Test
public void classLoaderTest() {
String className = SubClass.className;
}    

结果:什么都没有

这是因为final修饰的字段在编译期会被被存入class的常量池中,并没有引用常量的类,所以不会触发类的初始化

上面通过3个例子讲述了常见的几种被动引用

类加载器

类加载器用于加载Class文件到内存中

比较两个类是否相等,只有这两个类使用同一个类加载器加载的情况下才有意义,因为使用不同类加载器加载的类必定不同

如下:

  @Test
public void classLoaderTest() {

    ClassLoader loader = new ClassLoader() {
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
            InputStream is = getClass().getResourceAsStream(fileName);
            if(is == null){
                return super.loadClass(name);
            }

            try {
                byte[] b = new byte[is.available()];
                is.read(b);
                return defineClass(name,b,0,b.length);
            } catch (IOException e) {
                e.printStackTrace();
            }
            return null;
        }
    };
    try {
        Object super1 = loader.loadClass("com.anna.music.SuperClass").newInstance();
        System.out.println(super1.getClass());
        System.out.println(super1 instanceof com.anna.music.SuperClass);
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

}  

结果: class com.anna.music.SuperClass false

虽然两个类都是同一个路径下的同一个类,但是使用不同的类加载器ClassLoader加载,获取的Class类肯定不是同一个

类加载器在Android插件化框架中起着关键作用,基本上市面上使用的几种框架都需要自定义DexClassLoader或者PathClassLoader进行处理。

双亲委派模型

类加载器ClassLoader主要分以下几种

  • 1.启动类加载器(Bootstrap Class Loader): 负责加载存放在<JAVA_HOME>\lib目录下的class文件
  • 2.扩展类加载器(Extension Class Loader): 负责加载存放在<JAVA_HOME>\lib\ext目录下的Class文件,使用ExtClassLoader类实现
  • 3.应用程序类加载器(Application Class Loader): 一般是系统默认加载器,默认实现类AppClassLoader,用于加载用户路径上的所有类库
  • 4.自定义类加载(User Class Loader): 自己定义的类加载,加载自己的特点class,一般用于插件化过程:

他们的关系如下:

类加载双亲委派.png 双亲委派模型工作过程如下:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去加载,没一个层次的加载器都是如此, 因此所有的加载请求最终都会传送到最底层的启动类加载器,只有父加载器无法完成加载的时候才会将加载任务向下传递个子类进行。

这个过程就好比: 古代皇帝打了胜战,要把战利品先一层一层提交上去,直到皇帝那,皇帝说,我要不了这么多,然后把剩余战利品一级一级向下分发直到士兵那,下一级到将军那也是同样处理

为什么这么做?

  • 避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
  • 安全性考虑,防止核心API库被随意篡改。

Android中的类加载器:

上面所讲的都是java虚拟机类加载器加载Class文件的过程,在Android中因为apk中使用的是dex文件,这是一种特殊压缩方式得到的文件,内部不仅仅只有class文件,而是完全对Class文件内部的各种函数表、变量表等进行优化,并产生一个新的文件,所以java中和Android中的ClassLoader也不一样

下面来看下Android中的类加载构成

android classloader.png

  • ClassLoader是一个抽象类,所有类加载器的基类
  • BootClassLoader:是ClassLoader的子类(注意不是内部类),用于加载一些系统Framework层级需要的类
  • SecureClassLoader扩展了ClassLoader类,加入了权限方面的功能,加强了安全性
  • BaseDexClassLoader是实现了Android ClassLoader的大部分功能, PathClassLoaderDexClassLoade的基类
  • PathClassLoader加载应用程序的类,会加载/data/app目录下的dex文件以及包含dex的apk文件或者java文件
  • DexClassLoader可以加载自定义dex文件以及包含dex的apk文件或jar文件,支持从SD卡进行加载。我们使用插件化技术的时候会用到
  • InMemoryDexClassLoader:用于加载内存中的dex文件

这里我们主要来看PathClassLoaderDexClassLoader这两个类加载器是我们Android中最重要的类加载器 查看源码:

public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        super((String)null, (File)null, (String)null, (ClassLoader)null);
        throw new RuntimeException("Stub!");
    }
}

DexClassLoader类中就只有一个构造方法,构造方法中直接调用了父类的构造,DexClassLoader继承了BaseDexClassLoader,构造方法中的参数的含义是:

  • dexPath:dex文件路径
  • optimizedDirectory:dex文件首次加载时会进行优化操作,这个参数即为优化后的odex文件的存放目录,官方推荐使用应用私有目录来缓存优化后的dex文件,dexOutputDir = context.getDir(“dex”, 0);
  • librarySearchPath:动态库路径
  • parent:当前类加载器的父类加载器

继续看PathClassLoader

public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super((String)null, (File)null, (String)null, (ClassLoader)null);
        throw new RuntimeException("Stub!");
    }

    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super((String)null, (File)null, (String)null, (ClassLoader)null);
        throw new RuntimeException("Stub!");
    }
}

PathClassLoader有两个构造方法,同样也是直接调用了父类的构造方法,从构造方法上来看,DexClassLoader和PathClassLoader的区别只有第二个参数optimizedDirectory,在PathClassLoader中optimizedDirectory默认传入的是null。

从源码中看这两个类的作用也是因为optimizedDirectory参数的不同而不同在源码中看使用PathClassLoader由于没有传入optimizedDirectory,系统会自动生成以后缓存目录,即/data/dalvik-cache/,在这个目录存放优化以后的dex文件

所以PathClassLoader只能加载已安装的apk的dex,即加载系统的类和已经安装的应用程序(安装的apk的dex文件会存储在/data/dalvik-cache中),而DexClassLoader可以加载指定路径的apk、dex,也可以从sd卡中进行加载

Android中插件化框架原理

将apk放到应用文件夹,如assert目录下,然后重写DexClassLoader,实现自己loadClass等方法,并使用反射替换系统中的类加载器,在类进行加载的时候,就会自动使用重写后的类加载器进行加载,这个时候就可以加载未安装的apk中的文件,看到其实插件化也没什么深不可测的。

总结

本篇文章介绍了类加载过程: 加载,验证,准备,解析和初始化,这5个过程中虚拟机做了哪些操作,还介绍了类加载的工作原理,双亲委派模型。 还讲解了一些关于Android中类加载的分析以及简单描述了下插件化框架的原理 相信大家学完JVM的几篇文章对Class文件如何加载到虚拟机内存中有了一个比较系统的了解。

码文不易,看完别忘记点赞关注,谢谢