作为Android开发人员,一直以来都是在把JVM的特点拿来学习,把JVM、Dalvik、ART割裂的单独来看,现在把他们放到一起,去比较他们的区别,但是了解的还很浅显,这里面的学习内容深究下去还是有很多很多的。
JVM
JVM(Java Virtual Machine)是一种虚拟机,用来将由java文件编译成的class字节码文件再编译成机器语言,供机器识别。有了JVM中间人的存在就不需要直接与操作系统打交道,且不同的操作系统有不同的JVM,于是就屏蔽了操作系统间的差异,从而使java成为跨平台语言。
Java的使用流程
- 编写.java文件
- 编译成.class
- 打包成.jar(Java Archive) .war(Web Archive)使用
- 命令行则直接使用.class
其实.jar和.war是.class文件的压缩包,其中还包含了不同的配置文件,使用时通过类加载器取其内部的.class字节码文件加载到JVM。
Class文件结构
JVM接收的最初数据是class字节码文件,由.java文件编译产生。并不是只有java语言可以编译成class文件,其他语言也是可以(Scala、Groovy)。
class文件是由8位为一组的字节为基本单位,构成的二进制文件,为什么是二进制文件呢?
- 机器语言为二进制,所以使用便捷;
- 占用空间小,3.1415927用文本文件存储需要将各个位转成ASCII码再存储需占用9字节,二进制文件存储只需四字节;
- 存储数据精确不会丢失。
结构如上图,最上方为起始位,内容包含了.java文件的信息。
类型
class文件中只有两种类型:无符号数和表
无符号数为基本类型,有:u1、u2、u4、u8,数字代表字节数。无符号数可以代表数字、索引引用、数量值,或者按照UTF-8编码构成字符串值
表则是由基本类型和表构成的类型,属于组合类型
magic
文件最初的4个字节,称为魔数,是计算机识别文件类型的依据,不同于感官,.word .png .avi这种通过扩展名识别文件类型的方式,计算机识别多数文件是通过文件头部的魔数。
这种做法的优点在于安全性,文件的扩展名可以人为随意的修改,也许并不会造成文件的不可用(早年间的“图种”一词不知多少人有经历),也可能造成文件不可用,但文件的类型在文件创建之初就被赋予魔数的话,就可以大限度的保证文件的安全性。
version
表示此.class的版本信息,有minor_version和major_version两种类型共占了4字节。
不同版本Java有不同的特性,产生的class结构也会不同,JVM通过识别版本从而确定是否可识别此文件。JVM是向下兼容的,如果.class版本过高则不能运行(Unsupported major.minor version **)。
constant
constant_pool为常量池,用来存放常量,constant_pool_count为池中的计数。
constant_pool的索引从1开始,当指针不想引用此constant_pool时则将指针指向0,此操作简单(赋值永远比删除简单)。
常量池中有两大类常量类型:字面量和符号引用
字面量为java中的基本数据类型
符号引用:以一组符号来描述所引用的目标,引用的目标并不一定已经加载到内存中,在类加载过程的解析阶段JVM将常量池内的符号引用替换为直接引用。类型有:
- CONSTANT_Class_info 类和接口的全限定名(该名称在所有类型中唯一标识该类型)
- CONSTANT_Fieldref_info 字段的名称和描述符
- CONSTANT_Methodref_info 方法的名称和描述符
常量池中每一项常量都是一个表
举例:
class A{
int i=9;
int b=new B();
}
class B{
}
编译时会产生A.class和B.class,此时A.class有两个常量9和B。JVM加载A.class时,将由于常量9属于字面量即基本数据类型,直接放入常量池。
到常量B时,由于常量B不属于字面量即基本数据类型,所以此时产生一个符号引用来代表常量B。
等到A.class加载到了解析阶段,需要将符号引用改为直接引用,但找不到符号引用B的直接引用,
在使用阶段,由于A对象主动引用了B类,所以JVM通过类加载器开始加载B.class(同样的加载步骤),并创建了B对象,并将符号引用B改为B对象的直接引用。
access
access_flags即java中的类修饰符
类的身份信息
一个类要有类名,关系要有extends和implement。java中类是单继承,所以除Object外所有类都有一个父类,而接口则可以有多实现。
this_class是这个类的全限定名
super_class是这个类父类的全限定名
interfaces是这个类实现接口的集合
interfaces_count是这个类实现接口的数量
fields
fields_count表示fields表中的数量
fields是表结构用来存放字段,字段即为类中声明的变量。字段包括了类级变量或实例级变量,static修饰符为判断依据。
public static final transient String str = "Hello World";
一个字段包含的信息有:
- 作用域(public、private、protected修饰符)
- 类级变量还是实例级变量(static修饰符)
- 可变性(final)
- 并发可见性(volatile修饰符,是否强制从主内存读写)
- 可否序列化(transient修饰符)
- 字段数据类型(基本类型、对象、数组)
- 字段名称(str)
一个字段有多种修饰符,每种修饰符只有两种状态:有、没有,所以采用标志位来表示最为合理。字段的其他信息,叫什么名字、被定义为什么数据类型,这些都是无法固定的,所以引用常量池中的常量来描述。
access_flags:修饰符
name_index:简单名称
指变量名,存放在常量池。例如字段str的简单名称“str”。
descriptor_index:描述符
描述字段的类型
举例:
java.lang.String[][] —— [[Ljava/lang/String
int[] —— [I
String s —— Ljava/lang/String
attributes:属性集合,以用于描述某些场景专有的信息。
上面的类型只定义了变量信息,那变量的初始赋值操作呢?
赋值操作是将常量赋值给变量,常量有字面量和符号引用,字面量会在常量池中,符号引用依据情况会在解析或使用阶段改为直接引用。
字段赋值的时机:
a:对于非静态的field字段的赋值将会出现在实例构造方法<init>()中
b:对于静态的field字段,有两个选择:1、在类构造方法<cinit>()中进行;
2、使用ConstantValue属性进行赋值
编译器对于静态field字段的初始化赋值策略:如果final和static同时修饰一个字段,并且这个字段是基本类型或者String类型的,
那么编译器在编译这个字段的时候,会在对应的field_info结构体中增加一个ConstantValue类型的结构体,在赋值的时候使用这个ConstantValue进行赋值;如果该field字段并没有被final修饰,或者不是基本类型或者String类型,那么将在类构造方法中赋值。
对于全局变量的值是被编译在构造器中赋值的
https://www.cnblogs.com/straybirds/p/8331687.html
methods
methods_count表示methods表中的数量
methods是表结构用来存放方法,表结构和字段的表结构一致
由于部分关键字相对于变量和方法是有区别的
举例:
int indexOf(char[] source,int sourceOffset,int sourceCount,char[] targetOffset,int targetCount,int fromIndex) —— ([CII[CII)I
方法内部的代码存到什么地方了?
attributes:属性表
在字段表和方法表中都有属性表
属性表所能识别的属性有
方法中到具体代码就存放在方法表中属性表的Code属性中
参考 https://blog.csdn.net/sinat_37138973/article/details/54378263
JVM内存模型
PC
与CPU中的PC不同,CPU中的PC是记录即将执行的下条指令的地址,而JVM中记录的是正在执行的虚拟机字节码指令的地址,且执行native方法时PC为空虚拟机栈
每个方法被执行的时候都会创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常;如果虚拟机栈可以动态扩展当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常。本地方法栈
为虚拟机使用到的Native方法服务
本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。方法区
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。堆
存放对象实例,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
参考 https://www.cnblogs.com/dingyingsi/p/3760447.html
在知道了class字节码文件结构和JVM内存模型后,需要一个过程将字节码文件加载到内存。
类加载器
负责将字节码文件载入到内存,
BootstrapClassLoader – JRE/lib/rt.jar
ExtensionClassLoader – JRE/lib/ext或者java.ext.dirs指向的目录
ApplicationClassLoader – CLASSPATH环境变量, 由-classpath或-cp选项定义,或者是JAR中的Manifest的classpath属性定义
从上至下依次为父子关系,并不是继承关系。
类加载机制
委托机制
当加载B.class时,请求首先发到ApplicationClassLoader,ApplicationClassLoader看都不看就交给父亲ExtensionClassLoader,ExtensionClassLoader也是看都不看就交给父亲BootstrapClassLoader,BootstrapClassLoader为始祖了,于是在自己的管辖区内查找看有没有B.class,有就加载没有就告诉儿子ExtensionClassLoader,你自己处理,ExtensionClassLoader收到父亲的信息后在自己的管辖区内查找B.class,有就加载没有就告诉儿子ApplicationClassLoader,你自己处理,ApplicationClassLoader收到父亲的信息后在自己的管辖区内查找B.class,有就加载没有就ClassNotFoundException。可见性机制
子类加载器可以看到父类加载器加载的类单一性机制
因委托机制的关系,一个类(唯一的全限定名)只能被一个类加载器加载一次
类加载方式
显式加载
通过class.forname()等方法,显式加载需要的类隐式加载
程序在运行过程中当碰到通过new等方式生成对象时,隐式调用类装载器加载对应的类到jvm中
自定义类加载器
以上三种类加载器,在某些场景下就不适用。由于以上三种类加载器都是加载指定位置的class,当加载异地加密的class时就无法使用,此时需要自定义类加载器,加载指定位置的class到内存并在执行解密后使用。
有了class字节码文件结构、JVM内存模型和类加载器这三个部分的初识,接着就是三个独立部分合作的场景:类加载过程
类加载过程
类加载器将字节码文件载入JVM内存的过程
加载
主要是获取定义此类的二进制字节流,并将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,最后在Java堆中生成一个代表这个类的java.lang.Class对象作为方法区这些数据的访问入口。
类加载器参与的阶段。验证
确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。主要验证过程包括:文件格式验证(魔数、版本号),元数据验证(类关系),字节码验证(数据流、控制流)以及符号引用验证(引用可达)。准备
正式为类变量(static变量)分配内存并设置类变量初始值的阶段。关于准备阶段为类变量设置零值的唯一例外就是当这个类变量同时也被final修饰,那么在编译时,就会直接为这个常量赋上目标值。解析
解析时虚拟机将常量池中的符号引用替换为直接引用。初始化
初始化阶段是执行类构造器<clinit>()方法的过程。类构造器<clinit>()方法是由编译器自动收藏类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生。
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步。
参考 https://www.cnblogs.com/dooor/p/5289994.html
一个项目、jar包、war包中有数百成千上万的字节码文件,一个字节码文件只能被加载一次(类加载器的委托机制),JVM是一次性加载全部文件的吗?肯定不是,具体的实现由不同的JVM自由发挥,但对于初始化阶段JVM有明确要求(被引用),自然初始化之前的阶段也必须完成。
类的引用方式
主动引用
- 当使用new关键字实例化对象时,当读取或者设置一个类的静态字段(被final修饰的除外)时,以及当调用一个类的静态方法时(比如构造方法就是静态方法),如果类未初始化,则需先初始化。
- 通过反射机制对类进行调用时,如果类未初始化,则需先初始化。
- 当初始化一个类时,如果其父类未初始化,先初始化父类。
- 用户指定的执行主类(含main方法的那个类)在虚拟机启动时会先被初始化。
被动引用
除了上面这4种方式,所有引用类的方式都不会触发初始化,称为被动引用。如:
- 通过子类引用父类的静态字段,不会导致子类初始化;
- 通过数组定义来引用类,不会触发此类的初始化;
- 引用类的静态常量不会触发定义常量的类的初始化,因为常量在编译阶段已经被放到常量池中了。
参考 https://blog.csdn.net/zcxwww/article/details/51330327
初始化的对象会被放在什么地方呢?
内存分配策略
两个存储位置:本地线程缓存TLAB和堆
新对象产生时首先检查本地线程是否开启了缓存,是则存储在TLAB,否则去堆中寻找位置。
堆又分了:Eden、两个Survivor、Tenured共4个区,Eden与Survivor大小比是8:1,Eden和Survivor称为新生代,Tenured称为老年代(JDK8已经没有持久代了)
当新对象产生时,存放在Eden,当Eden放不下时触发Minor GC,将Eden中存活的对象复制到一Survivor中。继续存放对象到Eden,当Eden放不下时触发Minor GC,将Eden和非空闲Survivor中存活的对象复制到空闲Survivor中,往复操作。每经过一次Minor GC,对象的年龄加1,当对象年龄达到阀值(默认15)进入Tenured。如果在Minor GC期间发现存活对象无法放入空闲的Survivor区,则会通过空间分配担保机制使对象提前进入Tenured。如果在Survivor空间中的相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于和等于该年的对象就可以直接进入老年代,无需等到指定的阀值。
空间分配担保机制:
在执行Minor GC前, VM会首先检查Tenured是否有足够的空间存放新生代尚存活对象,由于新生代使用复制收集算法,为了提升内存利用率,只使用了其中一个Survivor作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代,但前提是老年代需要有足够的空间容纳这些存活对象。但存活对象的大小在实际完成GC前是无法明确知道的,因此Minor GC前,VM会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小,如果条件成立, 则进行Minor GC,否则进行Full GC(让老年代腾出更多空间)。然而取历次晋升的对象的平均大小也是有一定风险的,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然可能导致担保失败(Handle Promotion Failure,老年代也无法存放这些对象了),此时就只好在失败后重新发起一次Full GC(让老年代腾出更多空间)。
分代的唯一理由就是优化GC性能,让GC在固定区域工作。
GC
- Minor GC
在年轻代(Eden和Survivor)中执行的GC - Major GC
在老年代(Tenured)中执行的GC - Full GC
清理整个堆空间包括年轻代和老年代
垃圾回收最重要的一点是如何判断对象为垃圾?
可达性分析算法
通过一系列称为GC Roots的对象作为起点,然后向下搜索,搜索所走过的路径称为引用链/Reference Chain,当一个对象到GC Roots没有任何引用链相连时,即该对象不可达,也就说明此对象是不可用的,如图:Object5、6、7虽然互有关联,但它们到GC Roots是不可达的,因此也会被判定为可回收的对象。
回收时的回收算法:
分代
根据对象存活周期的不同将内存划分为几块,如JVM中的新生代、老年代,这样就可以根据各年代特点分别采用最适当的GC算法:
在新生代:每次垃圾收集都能发现大批对象已死,只有少量存活。因此选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
在老年代:因为对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记—清理”或“标记—整理”算法来进行回收,不必进行内存复制,且直接腾出空闲内存。
新生代-复制算法
该算法的核心是将可用内存按容量划分为大小相等的两块,每次只用其中一块,当这一块的内存用完,就将还存活的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉。
这使得每次只对其中一块内存进行回收,分配也就不用考虑内存碎片等复杂情况,实现简单且运行高效。
但由于新生代中的98%的对象都是生存周期极短的,因此并不需完全按照1:1的比例划分新生代空间,所以新生代划分为一块较大的Eden区和两块较小的Survivor区。
老年代-标记清除算法
该算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象(可达性分析), 在标记完成后统一清理掉所有被标记的对象。
该算法会有以下两个问题:
- 效率问题:标记和清除过程的效率都不高;
- 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集。
老年代-标记整理算法
标记清除算法会产生内存碎片问题,而复制算法需要有额外的内存担保空间,于是针对老年代的特点,又有了标记整理算法。标记整理算法的标记过程与标记清除算法相同,但后续步骤不再对可回收对象直接清理,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。
分区
将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收。这样做的好处是可以控制一次回收多少个小区间。
在相同条件下,堆空间越大,一次GC耗时就越长,从而产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割为多个小块,根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减少一次GC所产生的停顿。
参考 http://www.importnew.com/23035.html
对象逃逸(点到为止)
本该销毁的对象,逃到了它处。
public class A {
public static Object obj;
public void globalVariableEscape() { // 给全局变量赋值,发生逃逸
obj = new Object();//new的对象本该在栈帧出栈时销毁,但被外部static引用导致进入方法区常量池
}
public Object methodEscape() { // 方法返回值,发生逃逸
return new Object();//new的对象本该在栈帧出栈时销毁,但被外部方法或线程引用,导致对象只能在外部方法栈帧出栈或线程销毁时被清理
}
public void instanceEscape() { // 实例引用发生逃逸
b = new B(this); //(示意而已,并不准确)新建B对象时引用了A对象,除B使用外,其余无引用A,此时本可以回收A,但B却引用导致无法回收。循环引用就是A在引用B,导致互相引用都不能被回收。
}
}
public class B {
public static Object obj;
public void instance(A a) { // 引用传入的实例
obj = a;
}
}
对象的引用类型(强软弱虚)
JVM中真正将一个对象判死刑至少需要经历两次标记过程:
第一个过程,可达性分析算法
第二个过程,判断这个对象是否需要执行finalize()方法。
第一次GC时,对象在经历了可达性分析算法被标记后,若对象重写了finalize()方法且没被执行过则会被放入F-Queue队列中,否则回收。
第二次GC时,JVM会有一个优先级比较低的线程去执行队列中对象的finalize()方法,执行只是触发finalize()方法并不会确保方法一定完成,防止死循环或异常等情况导致对象不可被回收,这时第二次标记完成对象被回收。
只有当对象存在引用链连接GC Roots时才确保不会被回收,即对象为强引用。那么有些对象,我们希望在内存充足的情况下不要回收,在内存不足的时候再将其回收掉。如果只有强引用,那这个对象永远都不会被回收。于是有了软引用、弱引用、虚引用的概念。
- 强引用
即使OOM也不会被回收 - 软引用
内存不足时才会被回收 - 弱引用
只要GC就会被回收 - 虚引用
唯一的作用就是监听被回收
参考 https://blog.csdn.net/huachao1001/article/details/51547290
类卸载
卸载需要满足三个条件:
1、该类所有的实例已经被回收
2、加载该类的ClassLoder已经被回收
3、该类对应的java.lang.Class对象没有被引用
JVM自带的根类加载器、扩展类加载器和系统类加载器,JVM本身会始终引用这些类加载器,因此条件2不会形成。
而这些类加载器则会始终引用它们所加载的类对象,因此条件3也不会形成。
唯一会被卸载的类只有自定义的类加载器加载的类。
Dalvik【DVM】
Google自己设计的,用于Android平台的虚拟机;
支持已转化为
dex
格式的java应用程序运行;而dex
是专为Dalvik设计的一种压缩格式允许在有限的内存中同时运行多个虚拟机实例,并未每一个Dalvik应用作为一和独立的Linux进程运行;
Android 5.0以后,Google直接删除
Dalvik
,取而代之的是ART
。
Dalvik与JVM的区别
1. 基于的架构不同
1.1 JVM基于的栈结构
- 栈是线程独有的,保存其运行状态和局部自动变量的(所以多线程中局部变量都是相互独立的,不同于类变量)。
- 栈在线程开始的时候初始化(线程的Start方法,初始化分配栈),每个线程的栈互相独立。
- 每个函数都有自己的栈,栈被用来在函数之间传递参数。
- 操作系统在切换线程的时候会自动的切换栈,就是切换SS/ESP寄存器。
- 栈空间不需要在高级语言里面显式的分配和释放。
1、POP 20
2、POP 7
3、ADD 20, 7, result
4、PUSH result
JVM基于栈则意味着需要去栈中读写数据,所需的指令会更多,这样会导致速度慢,对于性能有限的移动设备,显然不是很适合。
1.2 DVM基于的寄存器结构
- 寄存器是中央处理器内的组成部分。
- 寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。
- 在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器(PC),在中央处理器的算术及逻辑部件中,包含的寄存器有累加器(ACC)
基于寄存器的虚拟机,它们的操作数是存放在CPU的寄存器的。没有入栈和出栈的操作和概念。但是执行的指令就需要包含操作数的地址了,也就是说,指令必须明确的包含操作数的地址
//需要明确的制定操作数R1、R2、R3(这些都是寄存器)的地址
ADD R1, R2, R3 ;就一条指令搞定了。
DVM是基于寄存器的,它没有基于栈的虚拟机在拷贝数据而使用的大量的出入栈指令,同时指令更紧凑更简洁。但是由于显示指定了操作数,所以基于寄存器的指令会比基于栈的指令要大,但是由于指令数量的减少,总的代码数不会增加多少。
总结
基于寄存器的虚拟机对于更大的程序来说,在它们编译的时候,花费的时间更短。 JVM字节码中,局部变量会被放入局部变量表中,继而被压入堆栈供操作码进行运算,当然JVM也可以只使用堆栈而不显式地将局部变量存入变量表中。Dalvik字节码中,局部变量会被赋给65536个可用的寄存器中的任何一个,Dalvik指令直接操作这些寄存器,而不是访问堆栈中的元素。
2. 可执行程序的字节码不同
在Java SE程序中,Java类会被编译成一个或多个.class文件,打包成jar文件,而后JVM会通过相应的.class文件和jar文件获取相应的字节码。
执行顺序为: .java文件 -> .class文件 -> .jar文件
而DVM会用dx工具将所有的.class文件转换为一个.dex文件,然后DVM会从该.dex文件读取指令和数据。
执行顺序为:.java文件 –>.class文件-> .dex文件
如上图所示,.jar文件里面包含多个.class文件,每个.class文件里面包含了该类的常量池、类信息、属性等等。当JVM加载该.jar文件的时候,会加载里面的所有的.class文件,JVM的这种加载方式很慢,对于内存有限的移动设备并不合适。
而在.apk文件中只包含了一个.dex文件,这个.dex文件里面将所有的.class里面所包含的信息全部整合在一起了,这样再加载就提高了速度。.class文件存在很多的冗余信息,dex工具会去除冗余信息,并把所有的.class文件整合到.dex文件中,减少了I/O操作,提高了类的查找速度。
dex文件是虚拟机直接执行的文件。但是系统做了一些优化,不直接从apk中提取dex运行启动apk,效率太慢,所以就有了后面的odex、oat文件,我们后续再说
3. DVM允许在有限的内存中同时运行多个进程
DVM经过优化,允许在有限的内存中同时运行多个进程。在Android中的每一个应用都运行在一个DVM实例中,每一个DVM实例都运行在一个独立的进程空间。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。
4. DVM由Zygote创建和初始化
Zygote是虚拟机实例的孵化器,它是一个DVM进程,同时它也用来创建和初始化DVM实例,完成库的加载,预制类库和初始化等操作。每当系统需要创建一个应用程序时,Zygote就会fork自身,快速的创建和初始化一个DVM实例,用于应用程序的运行。对于一些只读的系统库,所有虚拟机实例都和Zygote共享一块内存区域。
5. Dalvik虚拟机有自己的 bytecode, 并非使用 Java bytecode.
DVM架构
DVM的源码位于dalvik/目录下,其中dalvik/vm目录下的内容是DVM的具体实现部分,它会被编译成libdvm.so;dalvik/libdex会被编译成libdex.a静态库,作为dex工具使用;dalvik/dexdump是.dex文件的反编译工具;DVM的可执行程序位于dalvik/dalvikvm中,将会被编译成dalvikvm可执行程序。DVM架构如下图所示。
从上图可以看出,首先Java编译器编译的.class文件经过DX工具转换为.dex文件,.dex文件由类加载器处理,接着解释器根据指令集对Dalvik字节码进行解释、执行,最后交与Linux处理。
Optimized DEX,即优化过的DEX
- odex
- 5.0之前,在使用DVM虚拟机的时候,系统设计的文件,odex是由classess.dex生成的,即优化的过dex
- odex怎么生成的
- (5.0之前)apk在安装的时候,就会验证和优化,验证代码合法性和代码执行速度。验证完毕后会产生odex
- odex的特点什么作用?
- 运行apk的时候,直接加载odex,避免重复验证和优化,加快了apk的响应时间,加快软件加载速度
- 防盗版,因为在安装apk的时候,系统会把apk压缩包里面的dex文件删除,并把apk文件存放到权限比较高的目录,一般用户拿不到,即使拿到也用不了,因为里面的dex文件已经被删除了
DVM的运行时堆
DVM的运行时堆主要由两个Space以及多个辅助数据结构组成,两个Space分别是Zygote Space(Zygote Heap)和Allocation Space(Active Heap)。
- Zygote Space用来管理Zygote进程在启动过程中预加载和创建的各种对象,Zygote Space中不会触发GC,所有进程都共享该区域,比如系统资源。
- Allocation Space是在Zygote进程fork第一个子进程之前创建的,它是一种私有进程,Zygote进程和fock的子进程在Allocation Space上进行对象分配和释放。
除了这两个Space,还包含以下数据结构:
- Card Table:用于DVM Concurrent GC,当第一次进行垃圾标记后,记录垃圾信息。
- Heap Bitmap:有两个Heap Bitmap,一个用来记录上次GC存活的对象,另一个用来记录这次GC存活的对象。
- Mark Stack:DVM的运行时堆使用标记-清除(Mark-Sweep)算法进行GC,不了解标记-清除算法的同学查看Java虚拟机(四)垃圾收集算法这篇文章。Mark Stack就是在GC的标记阶段使用的,它用来遍历存活的对象。
Dalvik虚拟机你需要知道的15个问题
大部分jvm是基于栈的,而Dalvik是基于寄存器的。
基于栈的机器必须使用指令来载入栈上数据,或是用指令来操纵数据,因此指令集更为庞大。但是对于寄存器指令而言,又必须指定源地址和目的地址,因此,基于寄存器的jvm单个指令更大。Dalvik一些特点:
a)常量池32位索引
b)默认栈12kb,3个页,每页4kb
c)默认启动堆2MB,最大值16MB,最小1MB
d)堆最大支持1024MB
e)堆和栈的参数可以通过-Xms和-Xmx更改所有的android线程都对应一个linux线程。每个Android Dalvik应用程序都运行在自己的沙盒里,不同的应用在不同的进程空间里运行。
Dalvik相当于java的JVM,.NET的CLI,Python、Perl、Ruby的Interpreter。Dalvik定义自己的字节码为VM的指令。
目前Dalvik支持的功能:
a).dex文件
b)Dalvik指令集
c)J2ME CLDC API
d)多线程Dalvik支持的平台有:
a)基于Unix的系统
b)Linux
c)BSD
d)Mac OSXDalvik 虚拟机实现位于 dalvik/目录下,dalvik/vm是虚拟机的实现部分,被编译为libdvm.so,而dalvik/libdex被编译成libdex.a静态库作为dex工具库;dalvik/dexdump是.dex文件的反编译工具。虚拟机的可执行程序位于dalvik/dalvikvm中,将被编译为dalvikvm可执行程序。
Dalvik需要的其他库:
a)OpenSSl 加密技术
b)Zlib 免费的一般目的数据压缩库
c)ICU 字符编码技术
d)java包 包括java.nio,java.lang,java.util
e)Apache Harmony classlibApache HttpClientDalvik虚拟机的运行库大部分是用可移植的C写的,除了JNI call bridge。
Dalvik不遵循java SE和java ME的API规范,所以不支持AWT或者Swing。
dalvik/vm/Dvm.mk 中会根据dvm_arch来选择编译的目标集体系结构。
dx工具:位于dalvik/dx目录,用于将字节码转换成.dex。
例:dx --dex --output=helloworld.dex helloworld.classdexdump工具:位于dalvik/dexdump目录,用于反编译dex文件。
dex数据类型:
byte 8bit
ubyte 8bit
short 16bitlittle-endian
ushort 16bit little-endian
int 32bitlittle-endian
uint 32bitlittle-endian
long 64bitlittle-endian
ulong 64bitlittle-endian
sleb128 LEB128 variable-lengtha
uleb128 LEB128 variable-lengtha
uleb128p1 LEB128 variable-lengtha
LEB128类型:1~5个字节组成。所有字节组合在一起代表一个32位值。除最后一个字节最高标志位为0外,其他都为1,剩下的7位为有效负荷。有符号的LEB128的符号由最后一个字节的有效负荷最高位决定。具体算法在:dalvik/libdex/LEB128.h。dex文件被映射到DexMapList,结构体定义在dalvik/libdex/DexFile.h? 中。
ART虚拟机
- Android 2.2 使用DVM虚拟机
- Android 4.4 推出ART虚拟机
- Android 5.0 全面替换ART
- Android7.0 仍是ART环境,但是编译策咯改为AOT 和 JIT 混合编译
4.1 首次安装 不再统一执行dex2oat
4.2 改由程序运行时根据实际情况来决定哪些部分被编译成本地代码,会采用JIT混合编译
4.3 然后在满足两个条件的时候在执行AOT优化。
4.4 集合了AOP和JIT的特点,使得安装速度,运行速度,储存空间和耗电等指标都得到了优化
ART(Android Runtime)是Android 4.4发布的,用来替换Dalvik虚拟,Android 4.4默认采用的还是DVM,系统会提供一个选项来开启ART。在Android 5.0时,默认采用ART,DVM从此退出历史舞台。
ART与DVM的区别
DVM
- 安装快,体积小,但是每次运行APP都要编译, 每次编译特别耗电
- DVM中的应用每次运行时,字节码都需要通过即时编译器(JIT,just in time)转换为机器码,这会使得应用的运行效率降低。
- 基于Dex,但是安装时会转为ODex加速使用,Odex会放置在系统目录下,放置篡改
ART
- 安装体积大慢,但是每次运行APP直接读取本地机器码效率快,不用每次编译比较省电
- 在ART中,系统在安装应用时会进行一次预编译(AOT,ahead of time),将字节码预先编译成机器码并存储在本地,这样应用每次运行时就不需要执行编译了,运行效率也大大提升
- 基于Dex,但是会转为OA
ART的运行时堆
与DVM的GC不同的是,ART的GC类型有多种,主要分为Mark-Sweep GC和Compacting GC。ART的运行时堆的空间根据不同的GC类型也有着不同的划分,如果采用的是Mark-Sweep GC,运行时堆主要是由四个Space和多个辅助数据结构组成,四个Space分别是Zygote Space、Allocation Space、Image Space和Large Object Space。Zygote Space、Allocation Space和DVM中的作用是一样的。Image Space用来存放一些预加载类,Large Object Space用来分配一些大对象(默认大小为12k)。其中Zygote Space和Image Space是进程间共享的。
采用Mark-Sweep GC的运行时堆空间划分如下图所示。
除了这四个Space,ART的Java堆中还包括两个Mod Union Table,一个Card Table,两个Heap Bitmap,两个Object Map,以及三个Object Stack。如果想要跟多的了解它们,请参考ART运行时Java堆创建过程分析 – 罗升阳这篇文章。
OAT & ART
5.0全面替换ART虚拟机,使用AOT编译模式,dex文件经过dex2oat编译,会生成.art、.oat两个文件
-
oat
- 一个android定制的elf文件,原始dex也保存在其中。8.0后,dex单独保存到.vdex文件中
- 比dex文件大10%-20%(因为它包括dex和本地代码机器指令)
- ART安装慢,体积大,多余10%-20%, 但是每次运行APP直接读取本地机器码,效率快,不用每次编译 比较省电
-
art文件
- 类似于一个内存映像,缓存常用的ArtField、ArtMethod、DexCache等内容,加载后可直接使用,避免解析耗时
- odex进行优化生成的可执行二进制码文件
- 用于加快应用启动速度
- 从odex中拆分出来的,art文件主要为了加快应用的对“热代码”的加载与缓存
vdex
google在android8.0新增加了vdex文件,通过package直接转化的可执行二进制文件。
- 加快验证速度的元数据,在手机安装APK的时候,系统都会验证代码的合法性。(vdex会较快验证速度)
- 有助于提升软件更新的性能和体验。(vdex会保存验证过的dex文件,以便在ART系统更新期间,无需再次解压验证dex)
- 默认是启动状态,停用该功能 请将 ART_ENABLE_VDEX 环境变量设为 false。
smali文件
虚拟机有自己的一套指令集,汇编语言,反编译dex文件就可以得到smali文件, smali文件就是寄存器语言
- smali文件就是寄存器语言
- DVM ==> java ==>.class ==>.dex ==> odex == >smali
- ART == > java ==>.class ==>.dex ==> oat == >smali
- 无论的dex,odex,vdex,oat他们只不过是对dex的优化,最终加载在虚拟机后,会转换成smali寄存器语言,去运行
ART的运行原理:
- 在Android系统启动过程中创建的Zygote进程利用ART运行时导出的Java虚拟机接口创建ART虚拟机。
- APK在安装的时候,打包在里面的classes.dex文件会被工具dex2oat翻译成本地机器指令,最终得到一个ELF格式的oat文件。
- APK运行时,上述生成的oat文件会被加载到内存中,并且ART虚拟机可以通过里面的oatdata和oatexec段找到任意一个类的方法对应的本地机器指令来执行。
3.1 oat文件中的oatdata包含用来生成本地机器指令的dex文件内容
3.2 oat文件中的oatexec包含有生成的本地机器指令。
注意:
这里将DEX文件中的类和方法称之为DEX类和DEX方法,将OTA中的类和方法称之为OTA类和OTA方法,ART运行时将类和方法称之为Class和ArtMethod。
ART中一个已经加载的Class对象包含了一系列的ArtField对象和ArtMethod对象,其中,ArtField对象用来描述成员变量信息,而ArtMethod用来描述成员函数信息。对于每一个ArtMethod对象,它都有一个解释器入口点和一个本地机器指令入口点。
ART找到一个类和方法的流程:
- 在DEX文件中找到目标DEX类的编号,并且以这个编号为索引,在OAT文件中找到对应的OAT类。
- 在DEX文件中找到目标DEX方法的编号,并且以这个编号为索引,在上一步找到的OAT类中找到对应的OAT方法。
- 使用上一步找到的OAT方法的成员变量begin_和code_offset_,计算出该方法对应的本地机器指令。
可以分配内存的Space有三个:Zygote Space、Allocation Space和Large Object Space。不过,Zygote Space在还没有划分出Allocation Space之前,就在Zygote Space上分配,而当Zygote Space划分出Allocation Space之后,就只能在Allocation Space上分配。因此实际上应用运行的时候能够分配内存也就Allocation 和 Large Object Space两个。
而分配的对象究竟是存入上面的哪个Space呢?满足如下三个条件的内存,存入Large Object Space:1)Zygote Space已经划分除了Allocation Space,2)分配对象是原子类型数组,如int[] byte[] boolean[], 3)分配的内存大小大于一定的门限值。
对于分配对象时内存不足的问题,是通过垃圾回收和在允许范围内增长堆大小解决的。由于垃圾回收会影响程序,因此ART运行时采用力度从小到大的进垃圾回收策略。一旦力度小的垃圾回收执行过后能满足分配要求,那就不需要进行力度大的垃圾回收了。这跟dalvik虚拟机的对象分配策略也是类似的。
Mod Union Table对象
一个用来记录在GC并行阶段在Image Space上分配的对象对在Zygote Space和Allocation Space上分配的对象的引用。
另一个用来记录在GC并行阶段在Zygote Space上分配的对象对在Allocation Space上分配的对象的引用。
Allocation Stack:用来记录上一次GC后分配的对象,用来实现类型为Sticky的Mark Sweep Collector。
Live Stack:配合allocation_stack_一起使用,用来实现类型为Sticky的Mark Sweep Collector。
Mark Stack:用来在GC过程中实现递归对象标记
部分摘自
https://www.jianshu.com/p/5e5cb9c6ed37
https://blog.csdn.net/fei20121106/article/details/44494009
https://blog.csdn.net/evan_man/article/details/52414390