1. 什么是JVM?
JVM本质上就是一个软件,是计算机硬件的一层软件抽象,在这之上才能够运行Java程序,JAVA在编译后会生成类似于汇编语言的JVM字节码,与C语言编译后产生的汇编语言不同的是,C编译成的汇编语言会直接在硬件上跑,但JAVA编译后生成的字节码是在JVM上跑,需要由JVM把字节码翻译成机器指令,才能使JAVA程序跑起来。
JVM运行在操作系统上,屏蔽了底层实现的差异,从而有了JAVA吹嘘的平台独立性和Write Once Run Anywhere。根据JVM规范实现的具体虚拟机有几十种,主流的JVM包括Hotspot、Jikes RVM等,都是用C/C++和汇编编写的,每个JRE编译的时候针对每个平台编译,因此下载JRE(JVM、Java核心类库和支持文件)的时候是分平台的,JVM的作用是把平台无关的.class里面的字节码翻译成平台相关的机器码,来实现跨平台。
2.什么是DVM,和JVM有什么不同?
JVM是Java Virtual Machine,而DVM就是Dalvik Virtual Machine,是安卓中使用的虚拟机,所有安卓程序都运行在安卓系统进程里,每个进程对应着一个Dalvik虚拟机实例。他们都提供了对象生命周期管理、堆栈管理、线程管理、安全和异常管理以及垃圾回收等重要功能,各自拥有一套完整的指令系统,以下简要对比两种虚拟机的不同。
①JAVA虚拟机运行的是JAVA字节码,Dalvik虚拟机运行的是Dalvik字节码
JAVA程序经过编译,生成JAVA字节码保存在class文件中,JVM通过解码class文件中的内容来运行程序。而DVM运行的是Dalvik字节码,所有的Dalvik字节码由JAVA字节码转换而来,并被打包到一个DEX(Dalvik Executable)可执行文件中,DVM通过解释DEX文件来执行这些字节码。
②Dalvik可执行文件体积更小
以下是JVM规范中以C的数据结构表达的class文件结构,class文件被虚拟机加载到内存中后便是这样
class文件中包含多个不同的方法签名,如果A类文件引用B类文件中的方法,方法签名也会被复制到A类文件中(在虚拟机加载类的连接阶段将会使用该签名链接到B类的对应方法),也就是说,多个不同的类会同时包含相同的方法签名,同样地,大量的字符串常量在多个类文件中也被重复使用,这些冗余信息会直接增加文件的体积,而JVM在把描述类的数据从class文件加载到内存时,需要对数据进行校验、转换解析和初始化,最终才形成可以被虚拟机直接使用的JAVA类型,因为大量的冗余信息,会严重影响虚拟机解析文件的效率。
为了减小执行文件的体积,安卓使用Dalvik虚拟机,SDK中有个dx工具负责将JAVA字节码转换为Dalvik字节码,dx工具对JAVA类文件重新排列,将所有JAVA类文件中的常量池分解,消除其中的冗余信息,重新组合形成一个常量池,所有的类文件共享同一个常量池,使得相同的字符串、常量在DEX文件中只出现一次,从而减小了文件的体积。
dx工具的转换过程和DEX文件的结构如下图所示。
③JVM基于栈,DVM基于寄存器
关于栈式虚拟机:
1.代码必须使用这些指令来移动变量(即push和pop)
2.代码尺寸小和解码效率会更高些
3.堆栈虚拟机指令有隐含的操作数。
关于寄存器式虚拟机:
1.使用堆栈来分配激活记录器
2.基于寄存器代码免去了使用push和pop命令的麻烦,减少了每个函数的指令总数。
3.代码尺寸和解码效率不如基于栈虚拟机,因为它包含操作数,所以指令大于基于堆栈的指令。但是基于寄存器产生更少的代码,所以总的代码数不会增加。
4.寄存器虚拟机必须从操作指令中解码操作数,需要额外的解码操作。
基于栈与基于寄存器的指令集,用在解释器里,笼统说有以下对比:
- 从源码生成代码的难度:基于栈 < 基于寄存器,不过差别不是特别大
- 表示同样程序逻辑的代码大小(code size):基于栈 < 基于寄存器
- 表示同样程序逻辑的指令条数(instruction count):基于栈 > 基于寄存器
- 简易实现中数据移动次数(data movement count):基于栈 > 基于寄存器;不过值得一提的是实现时通过栈顶缓存(top-of-stack caching)可以大幅降低基于栈的解释器的数据移动开销,可以让这部分开销跟基于寄存器的在同等水平。请参考另一个回答:寄存器分配问题? - RednaxelaFX 的回答
- 采用同等优化程度的解释器速度:基于栈 < 基于寄存器
- 交由同等优化程度的JIT编译器编译后生成的代码速度:基于栈 === 基于寄存器
因而,笼统说可以有以下结论:
- 要追求尽量实现简单:选择基于栈
- 传输代码的大小尽量小:选择基于栈
- 纯解释执行的解释器的速度:选择基于寄存器
- 带有JIT编译器的执行引擎的速度:随便,两者一样;对简易JIT编译器而言基于栈的指令集可能反而更便于生成更快的代码,而对比较优化的JIT编译器而言输入是基于栈还是基于寄存器都无所谓,经过parse之后就变得完全一样了。
要是拿两个分别实现了基于栈与基于寄存器架构、但没有直接联系的VM来对比,效果或许不会太好。现在恰巧有两者有紧密联系的例子——JVM与Dalvik VM。JVM的字节码主要是零地址形式的,概念上说JVM是基于栈的架构。Google Android平台上的应用程序的主要开发语言是Java,通过其中的Dalvik VM来运行Java程序。为了能正确实现语义,Dalvik VM的许多设计都考虑到与JVM的兼容性;但它却采用了基于寄存器的架构,其字节码主要是二地址/三地址混合形式的,乍一看可能让人纳闷。考虑到Android有明确的目标:面向移动设备,特别是最初要对ARM提供良好的支持。ARM9有16个32位通用寄存器,Dalvik VM的架构也常用16个虚拟寄存器(一样多……没办法把虚拟寄存器全部直接映射到硬件寄存器上了);这样Dalvik VM就不用太顾虑可移植性的问题,优先考虑在ARM9上以高效的方式实现,发挥基于寄存器架构的优势。 Dalvik VM的主要设计者Dan Bornstein在Google I/O 2008上做过一个关于Dalvik内部实现的演讲;同一演讲也在Google Developer Day 2008 China和Japan等会议上重复过。这个演讲中Dan特别提到了Dalvik VM与JVM在字节码设计上的区别,指出Dalvik VM的字节码可以用更少指令条数、更少内存访问次数来完成操作。
眼见为实。要自己动手感受一下该例子。
创建Demo.java文件,内容为:
public class Demo {
public static void foo() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
}
}
通过javac编译,得到Demo.class。通过javap可以看到foo()方法的字节码是:
0: iconst_1
1: istore_0
2: iconst_2
3: istore_1
4: iload_0
5: iload_1
6: iadd
7: iconst_5
8: imul
9: istore_2
10: return
接着用Android SDK里platforms\android-1.6\tools目录中的dx工具将Demo.class转换为dex格式。转换时可以直接以文本形式dump出dex文件的内容。使用下面的命令:
** Command prompt 代码 **
dx --dex --verbose --dump-to=Demo.dex.txt --dump-method=Demo.foo --verbose-dump Demo.class
可以看到foo()方法的字节码是:
** Dalvik bytecode代码 **
0000: const/4 v0, #int 1 // #1
0001: const/4 v1, #int 2 // #2
0002: add-int/2addr v0, v1
0003: mul-int/lit8 v0, v0, #int 5 // #05
0005: return-void
让我们看看两个版本在概念上是如何工作的。
** JVM: **
(图中数字均以十六进制表示。其中字节码的一列表示的是字节码指令的实际数值,后面跟着的助记符则是其对应的文字形式。标记为红色的值是相对上一条指令的执行状态有所更新的值。下同)
说明:
Java字节码以1字节为单元。上面代码中有11条指令,每条都只占1单元,共11单元==11字节。 程序计数器是用于记录程序当前执行的位置用的。对Java程序来说,每个线程都有自己的PC。PC以字节为单位记录当前运行位置里方法开头的偏移量。
每个线程都有一个Java栈,用于记录Java方法调用的“活动记录”(activation record)。Java栈以帧(frame)为单位线程的运行状态,每调用一个方法就会分配一个新的栈帧压入Java栈上,每从一个方法返回则弹出并撤销相应的栈帧。
每个栈帧包括局部变量区、求值栈(JVM规范中将其称为“操作数栈”)和其它一些信息。局部变量区用于存储方法的参数与局部变量,其中参数按源码中从左到右顺序保存在局部变量区开头的几个slot。求值栈用于保存求值的中间结果和调用别的方法的参数等。两者都以字长(32位的字)为单位,每个slot可以保存byte、short、char、int、float、reference和returnAddress等长度小于或等于32位的类型的数据;相邻两项可用于保存long和double类型的数据。每个方法所需要的局部变量区与求值栈大小都能够在编译时确定,并且记录在.class文件里。
在上面的例子中,Demo.foo()方法所需要的局部变量区大小为3个slot,需要的求值栈大小为2个slot。Java源码的a、b、c分别被分配到局部变量区的slot 0、slot 1和slot 2。可以观察到Java字节码是如何指示JVM将数据压入或弹出栈,以及数据是如何在栈与局部变量区之前流动的;可以看到数据移动的次数特别多。动画里可能不太明显,iadd和imul指令都是要从求值栈弹出两个值运算,再把结果压回到栈上的;光这样一条指令就有3次概念上的数据移动了。
Java的局部变量区并不需要把某个局部变量固定分配在某个slot里;不仅如此,在一个方法内某个slot甚至可能保存不同类型的数据。如何分配slot是编译器的自由。从类型安全的角度看,只要对某个slot的一次load的类型与最近一次对它的store的类型匹配,JVM的字节码校验器就不会抱怨。以后再找时间写写这方面。
** Dalvik VM: **
说明:
Dalvik字节码以16位为单元(或许叫“双字节码”更准确 =_=|||)。上面代码中有5条指令,其中mul-int/lit8指令占2单元,其余每条都只占1单元,共6单元==12字节。
与JVM相似,在Dalvik VM中每个线程都有自己的PC和调用栈,方法调用的活动记录以帧为单位保存在调用栈上。PC记录的是以16位为单位的偏移量而不是以字节为单位的。
与JVM不同的是,Dalvik VM的栈帧中没有局部变量区与求值栈,取而代之的是一组虚拟寄存器。每个方法被调用时都会得到自己的一组虚拟寄存器。常用v0-v15这16个,也有少数指令可以访问v0-v255范围内的256个虚拟寄存器。与JVM相同的是,每个方法所需要的虚拟寄存器个数都能够在编译时确定,并且记录在.dex文件里;每个寄存器都是字长(32位),相邻的一对寄存器可用于保存64位数据。方法的参数按源码中从左到右的顺序保存在末尾的几个虚拟寄存器里。
与JVM版相比,可以发现Dalvik版程序的指令数明显减少了,数据移动次数也明显减少了,用于保存临时结果的存储单元也减少了。
你可能会抱怨:上面两个版本的代码明明不对应:JVM版到return前完好持有a、b、c三个变量的值;而Dalvik版到return-void前只持有b与c的值(分别位于v0与v1),a的值被刷掉了。
但注意到a与b的特征:它们都只在声明时接受过一次赋值,赋值的源是常量。这样就可以对它们应用常量传播,将
int c = (a + b) * 5;
替换为
int c = (1 + 2) * 5;
然后可以再对c的初始化表达式应用常量折叠,进一步替换为:
int c = 15;
把变量的每次状态更新(包括初始赋值在内)称为变量的一次“定义”(definition),把每次访问变量(从变量读取值)称为变量的一次“使用”(use),则可以把代码整理为“使用-定义链”(简称UD链,use-define chain)。显然,一个变量的某次定义要被使用过才有意义。上面的例子经过常量传播与折叠后,我们可以分析得知变量a、b、c都只被定义而没有被使用。于是它们的定义就成为了无用代码(dead code),可以安全的被消除。 上面一段的分析用一句话描述就是:由于foo()里没有产生外部可见的副作用,所以foo()的整个方法体都可以被优化为空。经过dx工具处理后,Dalvik版程序相对JVM版确实是稍微优化了一些,不过没有影响程序的语义,程序的正确性是没问题的。这是其一。
其二是Dalvik版代码只要多分配一个虚拟寄存器就能在return-void前同时持有a、b、c三个变量的值,指令几乎没有变化:
0000: const/4 v0, #int 1 // #1
0001: const/4 v1, #int 2 // #2
0002: add-int v2, v0, v1
0004: mul-int/lit8 v2, v2, #int 5 // #05
0006: return-void
这样比原先的版本多使用了一个虚拟寄存器,指令方面也多用了一个单元(add-int指令占2单元);但指令的条数没变,仍然是5条,数据移动的次数也没变。
题外话1:Dalvik VM是基于寄存器的,x86也是基于寄存器的,但两者的“寄存器”却相当不同:前者的寄存器是每个方法被调用时都有自己一组私有的,后者的寄存器则是全局的。也就是说,概念上Dalvik VM字节码中不用担心保护寄存器的问题,某个方法在调用了别的方法返回过来后自己的寄存器的值肯定跟调用前一样。而x86程序在调用函数时要考虑清楚calling convention,调用方在调用前要不要保护某些寄存器的当前状态,还是说被调用方会处理好这些问题,麻烦事不少。Dalvik VM这种虚拟寄存器让人想起一些实际处理器的“寄存器窗口”,例如SPARC的Register Windows也是保证每个函数都觉得自己有“私有的一组寄存器”,减轻了在代码里处理寄存器保护的麻烦——扔给硬件和操作系统解决了。IA-64也有寄存器窗口的概念。
题外话2:Dalvik的.dex文件在未压缩状态下的体积通常比同等内容的.jar文件在deflate压缩后还要小。但光从字节码看,Java字节码几乎总是比Dalvik的小,那.dex文件的体积是从哪里来减出来的呢?这主要得益与.dex文件对常量池的压缩,一个.dex文件中所有类都共享常量池,使得相同的字符串、相同的数字常量等都只出现一次,自然能大大减小体积。相比之下,.jar文件中每个类都持有自己的常量池,诸如"Ljava/lang/Object;"这种常见的字符串会被重复多次。Sun自己也有进一步压缩JAR的工具,Pack200,对应的标准是JSR 200。它的主要应用场景是作为JAR的网络传输格式,以更高的压缩比来减少文件传输时间。在官方文档提到了Pack200所用到的压缩技巧。
3.什么是ART虚拟机,和JVM/DVM有什么不同?
首先了解JIT(Just In Time,即时编译技术)和AOT(Ahead Of Time,预编译技术)两种编译模式。
JIT以JVM为例,javac把程序源码编译成JAVA字节码,JVM通过逐条解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译,执行速度必然比C/C++编译后的可执行二进制字节码程序慢,为了提高执行速度,就引入了JIT技术,JIT会在运行时分析应用程序的代码,识别哪些方法可以归类为热方法,这些方法会被JIT编译器编译成对应的汇编代码,然后存储到代码缓存中,以后调用这些方法时就不用解释执行了,可以直接使用代码缓存中已编译好的汇编代码。这能显著提升应用程序的执行效率。(安卓Dalvik虚拟机在2.2中增加了JIT)
相对的AOT就是指C/C++这类语言,编译器在编译时直接将程序源码编译成目标机器码,运行时直接运行机器码。
Dalvik虚拟机执行的是dex字节码,ART虚拟机执行的是本地机器码
Dalvik执行的是dex字节码,依靠JIT编译器去解释执行,运行时动态地将执行频率很高的dex字节码翻译成本地机器码,然后在执行,但是将dex字节码翻译成本地机器码是发生在应用程序的运行过程中,并且应用程序每一次重新运行的时候,都要重新做这个翻译工作,因此,及时采用了JIT,Dalvik虚拟机的总体性能还是不能与直接执行本地机器码的ART虚拟机相比。
安卓运行时从Dalvik虚拟机替换成ART虚拟机,并不要求开发者重新将自己的应用直接编译成目标机器码,也就是说,应用程序仍然是一个包含dex字节码的apk文件。所以在安装应用的时候,dex中的字节码将被编译成本地机器码,之后每次打开应用,执行的都是本地机器码。移除了运行时的解释执行,效率更高,启动更快。(安卓在4.4中发布了ART运行时)
ART优点:
①系统性能显著提升
②应用启动更快、运行更快、体验更流畅、触感反馈更及时
③续航能力提升
④支持更低的硬件
ART缺点
①更大的存储空间占用,可能增加10%-20%
②更长的应用安装时间
总的来说ART就是“空间换时间”
参考资料:
1.Android 代码优化
网友评论