我们知道的虚拟机有很多,运行Java的JVM虚拟机,运行Android程序的Davlik和Art虚拟机,运行C#的CLR虚拟机,那么什么是虚拟机呢,虚拟机的作用又是什么呢?运行JavaScript的v8引擎或者运行Python的引擎是否也是虚拟机呢?带着这几个问题,我们开始对虚拟机的学习。
虽然现在很多人都认为运行JavaScript的V8或运行Python的VirtualEnv,都不是虚拟机,而是解释器,主要原因是因为V8或者VirtualEnv不仅仅能执行字节码文件,还能将源文件编译成字节码文件,而传统上定义的虚拟机只是用来运行字节码文件的,如果将源文件编译成字节码,则需要编译器来帮忙,比如在JVM虚拟机上运行的文件都是已经编译成字节码的class文件,但是V8或者Python,都能一边编译源代码,一边执行编译后的字节码文件。但是现在这个规范已经越来越宽松了,也有不少大神认为V8或者VirtualEnv也是虚拟机。
那么一个虚拟机具备什么样的能力呢?我们下面就来具体看看吧。
- 将源码编译成字节码(编译器能力)
- 装载字节码文件(加载,链接,初始化)
-
内存管理
- 运行时内存区域
- 垃圾回收
- 指令解析和执行
接下来主要以JVM,Davlik和Art三款虚拟机为例,分别介绍上述的能力。
将源码编译成字节码
class字节码
java的字节码文件是通过java编译器来生成的,我们下载jdk后,通过javac命令,就可以将一个java源文件生成java字节码文件,这个字节码文件就可以直接在JVM上面运行了。
编译器通过对源代码进行词法,语法,语义分析,生成抽象语法树(AST),然后根据AST,生成目标文件。
词法,语法,语义这一流程不是java编译器独有的,是所有的编译器都共有的能力,不管是llvm编译c文件,或者是我们解析如html,xml等dsl文件,都是这样的步骤。解析完成后的字节码文件如下。
我简单介绍一下class字节码文件的内容结构
- Header:文件头包含了magic(魔数)——“验证是否是class格式文件”;minor_version,major_version——“该class文件支持的版本等数据信息”
- Constant Pool:常量池包含了类中所有的资源信息,如字面量常量——”字符串,被final修饰的常量等“;符号引用——”类和接口的全限定(绝对路径)名;字段的名称和描述符;方法的名称和描述符“
- Access Flag:类访问标志在常量池后面,标识类和接口的访问信息,如该Class文件是类还是接口,是否为public,是否为abstract等
- Class :类索引,包含当前类的索引(this_class)父类索引(super_class),接口索引(interfaces),通过这个索引,我们可以去常量池找这个类的全限定描述符
- fields:字段表集合,记录了类中每个变量的变量名, 变量类型, 访问标识, 属性等
- method:方法表集合,方法表和字段表的结构比较类似,包含了访问标识,名称索引,描述符索引,属性表索引等信息
- attributes:属性表,属性表非常庞大,包含方法的字节码指令,方法表里面的属性表索引就是指向该方法的字节码指令,常量值,方法抛出的异常等数据
Dex字节码
说完了class字节码,接下来对比说一下Dex字节码文件,我们知道class字节码文件只能在JVM上面运行,无法在Android虚拟机上运行,只有dex文件才能在Android虚拟机上运行,那么dex文件又是什么呢?它和class文件的区别是什么呢?
Android项目通过gradle构建生成apk文件,apk文件就是Android的安装包,安装包主要由dex文件,so文件,资源文件,manifest文件组成,如果有使用kotlin的话,apk包里面还会有kotlin的编译产物。
我这里只讲dex文件,Android的编译器会将java文件编译成dex,编译流程如下:
SourceCode(.java) — javac → Java Bytecode(.class) — Proguard → Optimized Java bytecode(.class) — Dex → Dalvik Optimized Bytecode(.dex)
从上面的流程看到,编译器第一步同样是将java文件转换成了class字节码文件,之后便是Android编译器所特有的部分:
- Proguard流程会对字节码文件进行压缩,优化和混淆,我们可以在gradle中开启配置proguradFiles的规则来开启我们的Proguard流程
- 当Proguard优化字节码文件后,dx编译器(AndroidStudio3.0之后开始采用D8编译器)会将优化后的字节码文件生成dex文件。
java8中引入了lambda等一些语法糖新特性,所以为了兼容这些语法糖,Android编译器在编译的途中会经历拖糖的操作,在Android Gradle Plugin3.1版本之前是用的第三方的插件进行脱糖操作,将所有的流程串起来,它的步骤如下图:
- Header:dex文件的头文件同样包含了magic魔数,用来标识是否是dex文件,还包含了checksum和signature等文件校验和签名信息码,file_size,header_size文件和头大小以及其他数据的大小等信息等等
- String_ids:字符串偏移数组,表示每个字符串在 data 区的偏移量,根据偏移量在Data区拿到数据
- Type_ids:数据类型索引,表示所有引用的数据类型在字符串中的索引
- Protos_ids:方法声明索引
- Fields:记录了所属类,类型以及方法名
- Methods:方法表
- Classes:类信息索引,记录了类信息,包括接口,超类,类数据偏移量
- Data:数据区,保存了dex文件中所有类的数据
dex的文件和class文件存放的数据是一样的,只是结构会有些不一致,而且dex文件是多个class文件的集合,所有会有数据去重,重排列等优化处理处理。
我们接着来看看虚拟机的第二个能力,如何装载上面的字节码文件
装载字节码文件
class字节码文件
java编译器将源文件编译成class字节码文件后,jvm就直接可以运行了,但想要运行,首先要将这个字节码文件加载进内存,jvm通过ClassLoader来加载指定路径的字节码文件,字节码的文件可以通过网络下载,也可以通过本地读取。我们看一下ClassLoader类加载class的实现。
//java.lang.ClassLoader
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//查找.class是否被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//通过父类或者根加载器加载,双亲委派模型的实现
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
//找到根加载器依然为空,只能子加载器自己加载了
long t1 = System.nanoTime();
c = findClass(name);
}
}
// 解析class文件
if (resolve) {
resolveClass(c);
}
return c;
}
}
通过上面的代码可以看到,jvm加载class是通过双亲委派模型加载,也就是会先采用父类或者根加载器来加载这个class文件,如果父类后者根类没法加载,才使用子类加载,加载方法为findClass()。
通过双亲委派来加载class字节码,这样可以避免类的重复加载以及安全性问题,如果我们要破坏双亲委派,则直接重写整个loadClass方法,如果遵循双亲委派模型,只需要实现findClass方法方法就行了。
jvm的主要几个类加载器,BootstrapClassloader;ExtentionClassLoader;ApplicationClassloader都是通过复写findClass去加载指定路径的class文件。Android虚拟机也是通过BaseDexClassLoader复写这个方法去DexList里面寻找Dex里面指定的class数据。
当JVM读取到字节码的二进制文件到内存后,会开始解析,读取头进行校验,在堆中创建运行时常量池和方法区,将字节码文件中的常量池,方法区和其他数据读取到运行时常量池和方法区的数据结构中。上面已经介绍了加载的过程,我们接着看看链接的过程。
加载和链接是同步进行的,链接主要是校验,准备,解析这三步。
- 校验就是验证魔数,版本号等信息,元数据,符号引用等是否正确
- 准备主要是为变量分配内存并设置初始值
- 解析是将常量池中的符号引用替换成直接引用,如指针,偏移量等。
链接完成后就是初始化,初始化主要是执行初始化静态语句块和变量赋值的操作。初始化完成后,就会返回一个可以使用的class对象了。
我们总结一下字节码文件加载,链接和初始化的过程。
-
加载(loadClass,findClass)
- 将字节码数据加载进内存,将字节码中的常量池方法区等静态数据转换成运行时常量池和方法区运行时数据结构,并在堆中生成Class对象
-
链接(ResoveClass)
- 验证:保证加载类的信息符合JVM规范,没有安全方面的问题
- 准备:为变量分配内存,并设置初始值
- 解析:虚拟机常量池的符号引用替换为直接引用(目标对象的指针,偏移,句柄等)
-
初始化
- static静态代码块
dex字节码文件
Android虚拟机怎么执行dex文件呢?他其实和java是一样的,读取字节码二进制字节流,然后进行加载,链接和初始化的过程,相同的部分就不说了,主要说说Android虚拟机不同的部分,也就是字节码文件加载的这部分,我们看一下Android虚拟机用来加载字节码的BaseDexClassLoader实现。
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
可以看到,Android的BaseDexClassLoader会在构造函数中会根据dex文件的路径,将dex文件读取到DexPathList中,并重写类的加载方法findClass,在pathList中去寻找想要加载的字节码文件,然后进行加载,链接,初始化操作后,返回一个Class对象。
这里我用QQ空间热修复方案常用的一张图,pathList就是一个Dex文件的数组,findClass会从前往后查找匹配的字节码文件,这也是热修复的原理之一,将要修复的字节码文件插入到pathList的dex数组前面,那么加载字节码的时候就会首先加载我们插入的字节码文件,达到热修复的目的。不过热修复的难点主要还要在于解决安全校验问题,这里就不说了。
虽然Android虚拟机运行的是dex文件,但是并不会直接执行apk里面的dex文件,apk文件只是一个安装包,当我们运行这个安装包时,Android系统会将dex文件进行优化生成odex文件,我们的启动程序加载的dex文件其实就是这个odex文件。Davlik虚拟机通过dexopt方法来优化dex文件,优化的过程包括校验,方法内联,指令优化等,最终生成的odex文件依然还是字节码文件。
但是Art虚拟机就不一样了,我们知道,java之所以比C运行慢,主要的原因是因为jvm执行的字节码文件,字节码文件是中间文件,而C直接运行就是机器码文件,c的编译器一开始就会将源代码编译成机器码文件,也就是AOT编译。为了让程序运行的更快,所以ART虚拟机也引入了AOT编译技术,通过dex2oat方法,直接会将dex文件编译成机器码,虽然编译后的文件还是以odex结尾,但是这个odex文件和dalvlk优化后生成的odex是不一样的,ART优化后的odex文件其实是一个ELF文件,ELF文件是linux的一种文件格式,里面包含了该dex的机器码数据,还有dex文件。但是dex2oat的耗时很久,也很占空间,导致安装耗时很久,对低性能手机很不友好,所以现在ART也不会在安装的时候就进行字节码编译成机器码的操作,而是运行时,对热代码在后台进行编译操作,或者通过运行时编译为机器码,也就是JIT技术,一般运行七八次,就能将该应用的所有热代码编译成机器码文件。
可以看到,JS引擎的WebAssembly其实也是AOT的技术,让引擎能够直接运行机器码,和ART在理论上是异曲同工的,同样也是为了优化字节码运行慢而引入的一种优化技术。
接下来就是虚拟机内存管理的部分了,我们接着往下看。
内存管理
运行时内存区域
在了解Java和Android虚拟机运行时内存区域之前,我们先了解一下操作系统进程的内存区域,虚拟机只是系统中的一个进程,所以我们可以从更大的视野看看一个进程在运行时的内存是怎样的,这里我以Linux系统为例。
Linxu进程的内存分为用户空间和内核空间两部分,内核空间就是系统的运行空间,用户空间是进程的运行空间,Linux进程的用户空间分布如下
- text段:用于存放程序代码的区域 - data段:用来存放编译阶段就能确定的数据
- bss段:用来存放未初始化的全局变量
- 堆
- 栈
当JVM运行在Linux系统时,作为Linux系统的一个进程,他同样具有上面同样的内存区域,但是在JVM运行字节码文件时,又将堆内存做了细分。
可以看到,JVM将堆分为了永久代,新生代和老年代,永久代其实就是运行时常量池和方法区,因为这部分的内存几乎不会被回收,所以称为永久代,新生代和老年代用来存放对象,当经过几次垃圾回收后依然存活的对象就从新生代进入了老年代。
我们再来看一下Android虚拟机的内存分布,Android虚拟机将堆内存同样分为三个区域:年轻代,年老代,永久代,针对年轻代和老年代,ART和Dalvik又做了细分,主要可以分为下面几种
我们具体看一下这几种堆的作用
- ZygoteSpace:Zygote进程启动过程中创建的所有对象。这些对象是所有进程共享
- ImageSpcace:存放预加载的类,Android Framework中通用的类都都是存储在这里
- Large Obj Space:存放大于12k的类对象的空间
- Main Allooc Space:存放小对象的空间
- Non Moving Space/Linear Alloc:只读的线性内存空间,主要用来存储虚拟机中在进程生命周期都不会结束清理的永久数据的类对象。
为什么Android的虚拟机要对堆划分这么多区域呢?主要都是为了性能的考虑,ZygoteSpace和ImageSpace存放共享的预加载的类,这样可以提高启动速度,还有根据对象的大小和特性划分LargeObjSpace,AllocSpace和Non Moving Space可以采用不同的垃圾回收策略,提高gc的效率和性能。
我们接着来看看虚拟机的垃圾回收机制
垃圾回收机制
垃圾回收机制分为对象存活判断和垃圾回收两部分,对象存活判断主要有下面两种方法
1. 引用计数:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1,计数器为0的对象就是不可能再被使用的。
2. 可达性分析:通过判断对象是否被GCROOT引入来判断对象是否还能被使用,GCROOT包括局部变量表里面的变量和引用,静态变量和全局变量,JNI方法。
垃圾清除算法主要是下面三种
1. 标记清除:标记清除通过扫描一次对象堆,标记出需要清除的对象,然后进行清除操作,整个过程需要将整个程序暂停,清除完成之后才恢复程序运行,而且这个算法会带来碎片化的问题。
2. 复制算法:复制算法会将存活的对象复制到一块内存,然后将遗留下来的对象进行清理,这种算法不会产生碎片问题,但是会占用更多的内存,因为要一块空间来复制存活的对象。
3. 标记整理:扫描一遍一次对象堆,标记处需要清除和存活的对象,然后将存活的对象全部在内存中向前移动,需要清除的对象自然就会在排到内存的后面,然后进行清楚。
不管是JVM虚拟机,还是Android的虚拟机,垃圾清除算法都是在上面三种中进行改进和优化。比如Dalvik的垃圾清除算法主要是标记清理,这样GC时会造成程序卡顿,ART改进了垃圾回收机制,除了根据对象大小和特性,开辟了更多的内存区域,同时在调用标记清楚算法时,只需要在回收时暂停一次程序,标记操作不需要暂停,而是让线程自己标记。在清楚时,也会更加高效。
接下来就是虚拟机最后一块能力了,也就是指令的执行
指令解析和执行
在前面说到过,每个方法里面的字节码指令会存放在Attributes属性表里,那么虚拟机如何执行方法的Code呢?我们先看看下面这个简单的函数被编译成字节码后的形式
public static int addAndDouble(int a, int b){ return (a + b) * 2; }
我来详细讲一下这段指令的过程,iload_0,iload_1表示加载局部变量表中偏移为0和1的变量,也就是a和b这两个变量,iadd表示相加,iconst_2表示2,imul表示有符号乘法。Ireturn表示返回int类型。虚拟机的执行器通过解释这些指令,就将我们的方法运行起来了。字节码指令非常多,jvm理论上最多支持256条指令,这里介绍一些主要的指令
- 加载和存储指令:iload,lload,fload……
- 运算指令:加法(iadd,ladd,fadd,dadd),减法(isub,lsub,fsub,dsub),乘法,除法,求余,取反……
- 类型转换:i2b,i2c,i2s,……
- 操作数栈管理指令:pop
- 方法调用和返回指令:invokevirtural,ireturn
- 对象创建和访问:new,newarray,getfield,bastore,baload……
- 异常处理:athrow
- 同步指令:monitorenter,moniterexit
- 控制转移指令:goto
那么jvm是如何执行这些指令的呢? jvm每调用一个方法,都会有一个栈帧来支持这个方法的调用,栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。栈帧结构如下图。
我们在回到上面的函数,它在栈帧中的操作如下。
可以看到,JVM执行指令是基于栈的,JVM的执行引擎,本质上就是一段switch函数,这段swtich函数执行到对应的指令时,便会基于栈来操作指令,其实这也是JVM运行慢的原因,他是基于栈的解释执行模型。
Android的指令是基于寄存器的,这种设计需要硬件支持,手机的Arm架构是支持这样的特性的。dex方法的字节码指令不会放在栈里面,而是放在寄存器里面,基于寄存器的指令数量更少,运行速度会更快,由于需要硬件支持,所以跨平台不友好。
我们可以看一段ART是如何基于解释器来执行字节码文件的代码实现
template<bool do_access_check, bool transaction_active>
JValue ExecuteSwitchImpl(Thread* self, const DexFile::CodeItem* code_item,
ShadowFrame& shadow_frame, JValue result_register,
bool interpret_one_instruction) {
constexpr bool do_assignability_check = do_access_check;
self->VerifyStack();
uint32_t dex_pc = shadow_frame.GetDexPC();
const auto* const instrumentation = Runtime::Current()->GetInstrumentation();
const uint16_t* const insns = code_item->insns_;
const Instruction* inst = Instruction::At(insns + dex_pc);
uint16_t inst_data;
ArtMethod* method = shadow_frame.GetMethod();
jit::Jit* jit = Runtime::Current()->GetJit();
// TODO: collapse capture-variable+create-lambda into one opcode, then we won't need
// to keep this live for the scope of the entire function call.
std::unique_ptr<lambda::ClosureBuilder> lambda_closure_builder;
size_t lambda_captured_variable_index = 0;
do {
dex_pc = inst->GetDexPc(insns);
shadow_frame.SetDexPC(dex_pc);
TraceExecution(shadow_frame, inst, dex_pc);
inst_data = inst->Fetch16(0);
switch (inst->Opcode(inst_data)) {
case Instruction::NOP:
PREAMBLE();
inst = inst->Next_1xx();
break;
case Instruction::MOVE:
PREAMBLE();
shadow_frame.SetVReg(inst->VRegA_12x(inst_data),
shadow_frame.GetVReg(inst->VRegB_12x(inst_data)));
inst = inst->Next_1xx();
break;
......
}
} while (!interpret_one_instruction);
// Record where we stopped.
shadow_frame.SetDexPC(inst->GetDexPc(insns));
return result_register;
}
可以看到上面的代码便是通过switch函数,来执行对应的字节码指令。
上面介绍的是虚拟机基于解析字节码指令来执行方法,其实虚拟机还有一种或方法能执行我们的代码,就是直接运行机器码文件。如ART虚拟机引入的AOT,就是提前将字节码编译成机器码,这样ART虚拟机就可以直接运行机器码,而不需要解释执行字节码文件,JIT也是在运行过程中,将热代码编译成机器码后运行。直接运行机器码的过程这里就不详说了。我们通过下图可以一览JVM运行字节码文件的全流程。
至此,虚拟机的知识已经讲完了,我们再来总结一下一个虚拟机所拥有的模块和功能。
基于字节码的编译模块:该模块主要是对源代码进行词法,语法,语义分析生成AST,并将AST生成中间文件,jvm的编译模块是javac,Android 虚拟机的编译模块是javac和dx或d8,v8的编译模块是Parser和Ignition
- 加载器模块:加载字节码的二进制流,并解析映射至堆内存中
- 解释器模块:解释执行函数字节码指令,虚拟机通过解释执行字节码的一种方式。
- 基于机器码的编译器模块:是虚拟机执行字节码的另一种方式,将字节码转化为本地机器代码后执行,如JIT和AOT,V8的TurboFan
真正想要深入了解虚拟机的方式就是自己动手写一个虚拟机,我们可以用Python手写一个虚拟机,因为Python已经有了内存回收的模块,我们只需要写一个类的加载模块和解释器模块就可以了。这里只是简单的介绍一下一个虚拟机具备的基本能力,有了这些基本能力,我们也有了深入了解虚拟机或者对虚拟机进行优化的理论知识。
比如华为的方舟编译,也是一个虚拟机,既然是虚拟机,那么就逃不开上面的模块,所以它之所以快,或许是用到了AOT,或者是对堆内存有了更多的细分,根据场景采用了更合适的垃圾回收算法。
基于上面讲的部分,我们同样可以迁移到对其他的虚拟机的学习中,比如我们可以去学习V8是怎么进行垃圾回收的,V8是怎么解释执行字节码的,V8是怎么加载类文件的。