程序员

掌握Java和Android虚拟机

2020-09-01  本文已影响0人  子者不语

我们知道的虚拟机有很多,运行Java的JVM虚拟机,运行Android程序的Davlik和Art虚拟机,运行C#的CLR虚拟机,那么什么是虚拟机呢,虚拟机的作用又是什么呢?运行JavaScript的v8引擎或者运行Python的引擎是否也是虚拟机呢?带着这几个问题,我们开始对虚拟机的学习。

虽然现在很多人都认为运行JavaScript的V8或运行Python的VirtualEnv,都不是虚拟机,而是解释器,主要原因是因为V8或者VirtualEnv不仅仅能执行字节码文件,还能将源文件编译成字节码文件,而传统上定义的虚拟机只是用来运行字节码文件的,如果将源文件编译成字节码,则需要编译器来帮忙,比如在JVM虚拟机上运行的文件都是已经编译成字节码的class文件,但是V8或者Python,都能一边编译源代码,一边执行编译后的字节码文件。但是现在这个规范已经越来越宽松了,也有不少大神认为V8或者VirtualEnv也是虚拟机。

那么一个虚拟机具备什么样的能力呢?我们下面就来具体看看吧。

  1. 将源码编译成字节码(编译器能力)
  2. 装载字节码文件(加载,链接,初始化)
  3. 内存管理
    • 运行时内存区域
    • 垃圾回收
  4. 指令解析和执行

接下来主要以JVM,Davlik和Art三款虚拟机为例,分别介绍上述的能力。

将源码编译成字节码

class字节码

java的字节码文件是通过java编译器来生成的,我们下载jdk后,通过javac命令,就可以将一个java源文件生成java字节码文件,这个字节码文件就可以直接在JVM上面运行了。

编译器通过对源代码进行词法,语法,语义分析,生成抽象语法树(AST),然后根据AST,生成目标文件。

词法,语法,语义这一流程不是java编译器独有的,是所有的编译器都共有的能力,不管是llvm编译c文件,或者是我们解析如html,xml等dsl文件,都是这样的步骤。解析完成后的字节码文件如下。


我简单介绍一下class字节码文件的内容结构

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编译器所特有的部分:

java8中引入了lambda等一些语法糖新特性,所以为了兼容这些语法糖,Android编译器在编译的途中会经历拖糖的操作,在Android Gradle Plugin3.1版本之前是用的第三方的插件进行脱糖操作,将所有的流程串起来,它的步骤如下图:

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对象了。

我们总结一下字节码文件加载,链接和初始化的过程。

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进程的用户空间分布如下

当JVM运行在Linux系统时,作为Linux系统的一个进程,他同样具有上面同样的内存区域,但是在JVM运行字节码文件时,又将堆内存做了细分。

可以看到,JVM将堆分为了永久代,新生代和老年代,永久代其实就是运行时常量池和方法区,因为这部分的内存几乎不会被回收,所以称为永久代,新生代和老年代用来存放对象,当经过几次垃圾回收后依然存活的对象就从新生代进入了老年代。

我们再来看一下Android虚拟机的内存分布,Android虚拟机将堆内存同样分为三个区域:年轻代,年老代,永久代,针对年轻代和老年代,ART和Dalvik又做了细分,主要可以分为下面几种

我们具体看一下这几种堆的作用

为什么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条指令,这里介绍一些主要的指令

那么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

真正想要深入了解虚拟机的方式就是自己动手写一个虚拟机,我们可以用Python手写一个虚拟机,因为Python已经有了内存回收的模块,我们只需要写一个类的加载模块和解释器模块就可以了。这里只是简单的介绍一下一个虚拟机具备的基本能力,有了这些基本能力,我们也有了深入了解虚拟机或者对虚拟机进行优化的理论知识。

比如华为的方舟编译,也是一个虚拟机,既然是虚拟机,那么就逃不开上面的模块,所以它之所以快,或许是用到了AOT,或者是对堆内存有了更多的细分,根据场景采用了更合适的垃圾回收算法。

基于上面讲的部分,我们同样可以迁移到对其他的虚拟机的学习中,比如我们可以去学习V8是怎么进行垃圾回收的,V8是怎么解释执行字节码的,V8是怎么加载类文件的。

上一篇 下一篇

猜你喜欢

热点阅读