浅入虚拟机(二)

java虚拟机&&android虚拟机

Posted by Cc1over on November 30, 2018

java虚拟机&&android虚拟机

JVM基本结构

Java栈

  • 栈占用的是操作系统内存,每个线程对应一个虚拟机栈,它是线程私有的,生命周期和线程一样,每个方法被执行时产生一个栈帧,栈帧用于存储局部变量表、动态链接、操作数和方法出口等信息,当方法被调用时,栈帧入栈,当方法调用结束时,栈帧出栈。
  • 局部变量表中存储着方法相关的局部变量,包括各种基本数据类型及对象的引用地址等,因此他有个特点:内存空间可以在编译期间就确定,运行时不再改变。
  • 栈定义了两种异常类型:StackOverFlowError(栈溢出)和OutOfMemoryError。如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StackOverFlowError;不过大多数虚拟机都允许动态扩展虚拟机栈的大小,所以线程可以一直申请栈,直到内存不足时,抛出OutOfMemoryError。

本地方法栈

  • 本地方法栈用于支持native方法的执行,存储了每个native方法的执行状态。本地方法栈和虚拟机栈它们的运行机制一致,唯一的区别是,虚拟机栈执行Java方法,本地方法栈执行native方法。在很多虚拟机中,会将虚拟机栈和本地方法栈一起使用。

方法区

  • 方法区存放了要加载类的信息(如类名,修饰符等)、静态变量,构造函数、final定义的常量,类中的字段和方法等信息。方法区是全局共享的,当方法区超过它允许的大小时,就会抛出OutOfMemory:PermGen Space异常。

  • 在Hotspot虚拟机中,这块区域对应持久代,一般来说,方法区上执行GC的情况很少,因此方法区被称为持久代的原因之一,但这并不代表方法区上完全没有GC,其上的GC主要针对常量池的回收和已加载类的卸载。但是GC的条件十分苛刻,需要满足三个条件:

    • 该类所所有的对象实例已经被回收,也就是Java堆中不存在该类的任何实例

    • 加载该类的ClassLoader已经被回收

    • 该类对应的java.lang.Classd对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

    在大量使用反射、动态代理、CGLib等ByteCode框架都需要虚拟机具备类卸载的功能,来保证持久代不会溢出

  • 运行时常量池是方法区的一部分,用于存储编译器生成的常量和引用。一般来说,常量的分配在编译时就能确定,但也不全是,也可以存储在运行时期产生的常量。比如String类的intern方法,作用是String类维护了一个常量池,如果调用的字符”hello”已经在常量池中,则直接返回常量池中的地址,否则新建一个常量加入池中,并返回地址。

Java堆

堆区又被分为两大区域

- Young/New Generation 新生代

新生对象放置在新生代中,新生代由Eden 与Survivor Space 组成。

- Old/Tenured Generation 老年代

老年代用于存放程序中经过几次垃圾回收后还存活的对象

Young/New Generation 新生代

序中新建的对象都将分配到新生代中,新生代又由Eden与两块Survivor Space 构成。Eden 与Survivor Space 的空间大小比例默认为8:1,即当Young/New Generation 区域的空间大小总数为10M 时,Eden 的空间大小为8M,两块Survivor Space 则各分配1M,这个比例可以通过-XX:SurvivorRatio 参数来修改。Young/New Generation的大小则可以通过-Xmn参数来指定。

Eden:刚刚新建的对象将会被放置到Eden 中

Survivor Space:幸存者区域是新生代与老年代的缓冲区域,两块幸存者区域分别为s0 与s1,当触发Minor GC 后将仍然存活的对象移动到S0中去(From Eden To s0)。这样Eden 就被清空可以分配给新的对象。

当再一次触发Minor GC后,S0和Eden 中存活的对象被移动到S1中(From s0To s1),S0即被清空。在同一时刻, 只有Eden和一个Survivor Space同时被操作。所以s0与s1两块Survivor 区同时会至少有一个为空闲的,这点从下面的图中可以看出。

当每次对象从Eden 复制到Survivor Space 或者从Survivor Space 之间复制,计数器会自动增加其值。 默认情况下如果复制发生超过16次,JVM 就会停止复制并把他们移到老年代中去。如果一个对象不能在Eden中被创建,它会直接被创建在老年代中。

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,通常很多的对象都活不过一次GC,所以Minor GC 非常频繁,一般回收速度也比较快。

清理之后:

Old/Tenured Generation 老年代

老年代用于存放程序中经过几次垃圾回收后还存活的对象,例如缓存的对象等,老年代所占用的内存大小即为-Xmx 与-Xmn 两个参数之差。

堆是JVM 中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new 对象的开销是比较大的,鉴于这样的原因,Hotspot JVM 为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间,这块空间又称为TLAB(Thread Local Allocation Buffer),其大小由JVM 根据运行的情况计算而得,在TLAB 上分配对象时不需要加锁,因此JVM 在给线程的对象分配内存时会尽量的在TLAB 上分配,在这种情况下JVM 中分配对象内存的性能和C 基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配,TLAB 仅作用于新生代的Eden,因此在编写Java 程序时,通常多个小的对象比大的对象分配起来更加高效,但这种方法同时也带来了两个问题,一是空间的浪费,二是对象内存的回收上仍然没法做到像Stack 那么高效,同时也会增加回收时的资源的消耗,可通过在启动参数上增加 -XX:+PrintTLAB来查看TLAB 这块的使用情况。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,通常会伴随至少一次Minor GC(但也并非绝对,在ParallelScavenge 收集器的收集策略里则可选择直接进行Major GC)。MajorGC 的速度一般会比Minor GC 慢10倍以上。
虚拟机给每个对象定义了一个对象年龄(age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象创建的内存申请过程

  • JVM会试图为相关的Java对象在Eden中初始化一块内存区域

  • 当Eden空间足够时,内存申请结束。否则进入第三步

  • JVM试图释放在Eden中所有不活跃的对象,释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中部分活跃的对象放入Survivor区

  • Suvivor区被用来作为新生代和老年代的缓冲区域,当老年代空间足够时,Survivor区的对象会被移动到老年代,否则会被保留在Survivor区

  • 当老年代空间不够时,JVM会在老年代进行垃圾收集(Major GC/Full GC)

  • Major GC/Full G后,若Survivor 及老年代仍然无法存放从Eden 复制过来的部分对象,导致JVM 无法在Eden 区为新对象创建内存区域,JVM 此时就会抛出内存不足的异常。

Dalvik虚拟机

Dalvik虚拟机与Java虚拟机的最显著区别是它们分别具有不同的类文件格式以及指令集。Dalvik虚拟机使用的是.dex文件,而Java虚拟机使用的是.class文件,而关于指令,Dalvik虚拟机使用的指令是基于寄存器的,而Java虚拟机使用的指令集是基于堆栈的。

如何理解Dalvik虚拟机基于寄存器?

作为虚拟机最基本的要实现哪些功能应该有:

  • 将源码编译成VM指定的字节码。

  • 包含指令和操作数的数据结构(指令用于处理操作数作何种运算)。

  • 一个为所有函数操作的调用栈。

  • 一个“指令指针”:用于指向下一条将要执行的指令。

  • 一个虚拟的“CPU”–指令的派发者:

    • 取指:获取下一条指令(通过IP获取)

    • 译码:对指令进行翻译,将要作何种操作

    • 执行:执行指令

而下面通过一个加法的小例子说明一下基于堆栈的虚拟机和基于寄存器的虚拟机的区别:

基于堆栈

比如现在要执行一个a+b在栈中的计算过程,涉及的操作应该如下:

  • POP a
  • POP b
  • ADD a,b,result
  • PUSH result

整个运算流程就是a出栈,b出栈,然后计算a+b的结果result然后result入栈

基于寄存器

基于寄存器的虚拟机,它们的操作数是存放在CPU的寄存器的。没有入栈和出栈的操作和概念。但是执行的指令就需要包含操作数的地址了,也就是说,指令必须明确的包含操作数的地址,这不像栈可以用栈指针去操作,基于寄存器的VM没有入栈和出栈的操作,我们需要明确的制定操作数的地址,这种设计的有点就是去掉了入栈和出栈的操作,并且指令在寄存器虚拟机执行得更快。比如上面的加法运算,只需要一条指令ADD R1, R2, R3即可,其中操作数为R1、R2、R3(这些都是寄存器)的地址

基于寄存器得虚拟机还有一个优点就是一些在基于Stack的虚拟机中无法实现的优化,比如,在代码中有一些相同的减法表达式,那么寄存器只需要计算一次,然后将结果保存,如果之后还有这种表达式需要计算就直接返回结果。这样就减少了重复计算所造成的开销

寄存器虚拟机也有一些问题,比如虚拟机的指令比Stack vm指令要长(因为Register指令包含了操作数地址)

ART虚拟机

ART虚拟机和Dalvik虚拟机都是android中的虚拟机,而Dalvik虚拟机采用的JIT编译器解释dex文件为机器码,当App运行时,每当遇到一个新类,JIT编译器就会对这个类进行编译,经过编译后的代码,会被优化成相当精简的原生型指令码(即native code),这样在下次执行到相同逻辑的时候,速度就会更快。

当然使用JIT也不一定加快执行速度,如果大部分代码的执行次数很少,那么编译花费的时间不一定少于执行dex的时间。所以JIT不对所有dex代码进行编译,而是只编译执行次数较多的dex为本地机器码。

有一点需要注意,dex字节码翻译成本地机器码是发生在应用程序的运行过程中的,并且应用程序每一次重新运行的时候,都要做重做这个翻译工作,所以这个工作并不是一劳永逸,每次重新打开App,都需要进行JIT编译。

对执行次数频繁的dex代码进行编译和优化,减少以后使用时的翻译时间,虽然可以加快Dalvik运行速度,但是还是有弊病,那就是将dex翻译为本地机器码也要占用时间。

ART虚拟机的优化

与Dalvik不同,在ART 环境中,应用在第一次安装的时候,字节码就会预先编译成机器码,使其成为真正的本地应用。之后打开App的时候,直接使用本地机器码运行,因此运行速度提高。

ART需要应用程序在安装时,就把程序代码转换成机器语言,所以这会消耗掉更多的存储空间,但消耗掉空间的增幅通常不会超过应用代码包大小的20%。由于有了一个转码的过程,所以应用安装时间难免会延长。

Android 7.0之后的ART混合编译模式

AOT编译模式遇到的困境:

由于系统更新时,所有的应用都需要重新安装,这会导致所有的应用都需要在重新编译一遍,如果你的应用贼多的话……。

编译之后的native code会比较大,消耗了储存空间,如果你的应用非常大的话,一些小容量的手机可能无法安装。

这中编译行为对动态apk的支持不是很好。

而混合编译却解决了这些问题:

用户安装App的时候,不再进行预编译了,这个和KitKat的时候一样。当用户安装之后立即使用该App,仍然使用JIT编译模式来执行App,但是同时会生成一个离线的 profile 文件,这个 profile 文件会记录JIT运行时的所有 hot code(热点代码)信息。然后在未来的某个时间点,Android Framework 会基于这个 profile 文件来启动一个预编译行为,它只便于记录的热点代码。

在 JIT 阶段,它带来的好处:

  • 快速安装
  • 系统快速更新

在 AOT 阶段,它带来的好处:

  • 快速启动,更好的运行性能
  • 低消耗:CPU,储存空间,电量…

在ART混合编译模式下:

  • 一些用户只使用App中的一部分功能,只有这些被频繁使用的部分(这个功能涉及到的代码)才值得被编译成 native code
  • 在 JIT 阶段,我们可以很容易的找到经常被使用的代码
  • 使用 AOT 来加快这些经常使用的用例
  • 避免在一些基本不适用的代码上花费开销

参考资料:

  • https://blog.csdn.net/anjoyandroid/article/details/78609971
  • http://286.iteye.com/blog/1931174
  • https://blog.csdn.net/Luoshengyang/article/details/8852432
  • https://blog.csdn.net/u012481172/article/details/50904574