目录
0.前言
个人学习、整理和记录JVM相关知识点用。其中大部分内容来自以下地址,表示感谢。
CS-Notes
java知识点解析
jvm入门之程序计数器
Dalvik虚拟机原理
Java虚拟机(JVM)面试题
参考书籍:《深入理解Java虚拟机》
1.运行时数据区域
1.1 程序计数器
记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法,则为空),属于线程私有。
1.2 Java虚拟机栈
每个Java方法在执行的同时,会创建一个栈用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在Java虚拟机栈中入栈和出栈的过程。
这个Java虚拟机栈内存大小,在JDK1.4默认256K,在JDK1.5+默认1M。
该区域可能抛出以下异常:
当线程请求的栈深度超过最大值,会抛出StackOverflowError异常;
栈进行动态扩展时,如果无法申请到足够内存,会抛出OutOfMemoryError异常。
1.3 本地方法栈
本地方法栈与Java虚拟机栈类似,他们之间的区别是本地方法栈为本地方法服务。
本地方法一般是用其他语言(C,C++或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。
1.4 堆
所有对象在这里分配内存,是垃圾收集的主要区域(“GC堆”)。
现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块,新生代(Young Generation)和老年代(Old Generation)。
堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出OutOfMemoryError异常。
1.5 方法区
用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出OutOfMemoryError异常。对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
HotSpot虚拟机把他当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次Full GC之后永久代的大小都会改变,所以经常会抛出OutOfMemoryError异常。为了更容易管理方法区,从JDK1.8开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
方法区是一个JVM规范,永久代与元空间都是其一种实现方式。在JDK1.8之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,堆存储静态变量和常量池等。
1.6 运行时常量池
运行时常量池是方法区的一部分。Class文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。除了在编译期生成的常量,还允许动态生成,例如String类的intern()。
1.7 直接内存
在JDK1.4中引入了NIO类,它可以使用Native函数库直接分配堆外内存,然后通过Java堆里的DirecrByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。
2.GC回收机制和策略
GC回收主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈者三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行GC回收。
2.1 判断一个对象是否可被回收
2.1.1 引用计数算法
当有循环引用时,计数器永远不为0,导致无法对他们进行回收。
2.1.2 可达性分析算法
以GC Roots为起点进行搜索,可达的对象都是存活的,不可达对象可被回收。
Java虚拟机使用该算法来判断对象是否可回收,GC Roots一般包含以下内容:
1.虚拟机栈中局部变量表引用的对象;
2.本地方法栈中JNI中引用的对象;
3.方法区中类静态属性引用的对象;
4.方法区中的常量引用的对象。
2.1.3 方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率很低,所以在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备卸载功能。
类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
1.该类所有的实例都已经被回收,此时堆中不存在该类的任何实例;
2.加载该类的ClassLoader已经被回收。
3.该类对应的Class对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
2.1.4 finalize()
类似C++的析构函数,用于关闭外部资源。但是try-finally等方式可以做得更好,并且finalize()方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好别用。
当一个对象可被回收时,如果需要执行该对象的finalize()方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象已经调用了finalize()方法自救,那后面回收时就不会再调用该方法。
2.2 引用类型
上述的引用计数和可达性算法,在判断对象是否能回收时,都与引用有关。Java有4中强度不同的引用类型。
2.2.1 强引用
被强引用的对象不会被回收。
使用new一个新对象的方式来创建强引用。
Object obj = new Object();
2.2.2 软引用
被软引用关联的对象,当发生GC时,如果内存不够的情况,才会回收。
使用SoftReference类来创建软引用。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
2.2.3 弱引用
被弱引用关联的对象,当发生GC时,一定会被回收。
使用WeakReference类来创建弱引用。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
2.2.4 虚引用
又称为幽灵引用或者幻影引用,一个对象是否有虚引用,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
为一个对象设置虚引用的唯一目的,是能在这个对象被回收时收到一个系统通知。
使用PhantoReference来创建虚引用。
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;
2.3 垃圾收集算法
2.3.1 标记-清除
在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。
在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为“空闲链表”的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
在分配时,程序会搜索空闲链表寻找空间大小等于新对象大小size的块block。如果找到的块等于size,会直接返回这个分块;如果找到的块大于size,会将块分割成两部分,返回size大小的分块,并把另外一块返回给空闲链表。
算法的不足:
1.标记和清除过程效率都不高;
2.会产生大量不连续的内存碎片,导致无法给大对象分配内存。
2.3.2 标记-整理
让所有存活的对象向一端移动,然后直接清理掉端边界外的内存。
优点:不会产生内存碎片
不足:需要移动大量对象,处理效率比较低。
2.3.3 复制
将内存划分为大小相等的两块,每次只使用其中一块,当一块内存用完了,就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要的不足是只使用了内存的一半。
2.3.4 分代收集
现在的商业虚拟机采用分代收集算法,它根据对象存活周期,将内存划分为几块,不同块采用适当的收集算法。一般将堆分为新生代和老年代,新生代使用复制算法,老年代使用标记-清除或者标记-整理。
2.4 垃圾收集器
以上是HotSpot虚拟机中的7个垃圾收集器,连线表示垃圾收集器可以配合使用。
术语说明。单线程与多线程,单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程。串行与并行,串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集器的时候,需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了CMS和G1是并行外,其他垃圾收集器都是串行。
2.4.1 Serial收集器
Serial翻译为串行,也就是说它是以串行的方式执行。
同时它是单线程,只会在一个线程进行垃圾收集工作。
它的优点是简单高校,在单个CPU环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
它是Client场景下的默认新生代收集器,因为在该场景下内存一般不会很大。它收集100-200MB垃圾的停顿时间,可以控制在100ms内。
2.4.2 ParNew收集器
是Serial收集器的多线程版本。
是Server场景下默认的新生代收集器,除了性能原因外,主要是因为只有Serial收集器和ParNew收集器能和CMS收集器配合使用。
2.4.3 Parallel Scavenge收集器
多线程收集器。其他收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而他的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指CPU用于运行用户程序的时间占总时间的比值。
停顿时间越短就越适合需要用户交互的程序,良好的相应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
缩短停顿时间是以牺牲吞吐量和新生代空间换取的,新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
可以通过一个开关参数打开GC自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例、晋升老年代对象年龄等细节参数。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
2.4.4 Serial Old收集器
是Serial收集器的老年代版本,也是给Client场景下的虚拟机使用。如果用在Server场景下,它有两大用途:
1.在JKD1.5以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
2.作为CMS收集器的后备预案,在并发收集发生ConcurrentModeFailure时使用。
2.4.5 Parallel Old收集器
是Parallel Scavenge收集器的老年代版本。
在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑ParallelScavenge加Parallel Old收集器。
2.4.6 CMS收集器
CMS(Concurrent Mark Sweep),Mark Sweep指的是标记-清除算法。
分为以下四个流程:
1.初始标记:仅仅只是标记以下GC Roots能直接关联到的对象,速度很快,需要停顿;
2.并发标记:进行GC Roots Tracing的过程,它在整个回收过程中耗时最长,不需要停顿;
3.重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分的标记记录,需要停顿;
4.并发清除:不需要停顿。
缺点:
吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致CPU利用率不高;
无法处理浮动垃圾,可能出现ConcurrentModeFailure。浮动垃圾是指并发清除阶段,由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次GC时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着CMS收集不能像其他收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现ConcurrentModeFailure,这时虚拟机将临时启用SerialOld来替代CMS。
标记-清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次Full GC。
2.4.7 G1收集器
G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多CPU和大内存的场景下有很好的性能。HotSpot开发团队赋予他的使命是未来替换掉CMS收集器。
堆被分为新生代和老年代,其他收集器进行收集的范围都是整个新生代或者老年代,而G1可以直接对新生代和老年代一起回收。
G1把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
通过引入Region的概念,从而将原来的一整块内存空间划分成多个小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个Region垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
每个Region都有一个Remembered Set,用来记录该Region对象的引用对象所在的Region。通过使用Remembered Set,在做可达性分析的时候就可以吗、避免全堆扫描。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
1.初始标记;
2.并发标记;
3.最终标记:为了修正在并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中。这阶段需要停顿线程,但是可并行执行。
4.筛选回收:首先对各个Region中的回收价值和成本进行排序,根据用户期望的GC停顿时间在制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
G1收集器的特点:
1.空间整合:整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
2.可预测的停顿:能让使用者明确指定在一个长度为M毫秒的时间片段内,消在GC上的时间不得超过N毫秒。
2.5 内存分配和回收
将堆区内存划分为新生代和老年代,默认比例是1:2,可以通过参数-XX:NewRatio来指定。
在新生代又被划分为一个较大的Eden空间和两个较小的Survivor空间,默认比例8:1:1。
Minor GC:回收新生代,因为新生代对象存活时间较短,因此Minor GC会频繁执行,执行的速度一般也会比较快。
Major GC:也叫Full GC,回收新生代和老年代,老年代对象存活时间长,因此Major GC很少执行,执行速度也会比Minor GC慢很多。
2.5.1 分配和回收策略
大多数情况下,对象在新生代的Eden上分配,当空间不足时,发起Minor GC,把Eden区和Survivor from区的存活对象复制到Survivor to区,如果to区空间不足,剩余对象分配在老年代(空间分配担保),然后from和to角色转换。
大对象直接进入到老年代,大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。这样做是为了避免大对象在Eden和Survivor之间大量内存复制。通过-XX:PretenureSizeThreshold来指定,大于此值的对象直接进入老年代分配。
长期存活的对象进入老年代,为对象定义年龄计数器,没经过一次Minor GC依然存活的对象,年龄加1,增加到一定的年龄则移动到老年代中。通过-XX:MaxTenuringThreshold来指定年龄阈值。
动态对象年龄判定,虚拟机并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,则年龄大于等于该年龄的对象可以直接进入老年代。
空间分配担保,在发生Minor GC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那么Minor GC可以确定是安全的。如果不成立,虚拟机会查看HandlePromotionFailure的值是否允许担保失败,如果允许,那我就会继续检查老年代最大可用的连续空间是否大于历次晋升老年代对象的平均大小,如果大于,将尝试进行一次Minor GC;如果小于,或者HandlePromotionFailure的值不允许冒险,那么就要进行一次Major GC。
2.5.2 Major GC的触发条件
对于Minor GC,触发条件非常简单,当Eden空间满了就触发。而Major GC触发相对复杂,有以下条件:
1.调用System.gc();只是建议虚拟机执行Major GC,但是虚拟机不一定会真正执行。不建议使用这种方式,而是让虚拟机管理内存;
2.老年代空间不足,当大对象和长期存活的对象进入老年代时,老年代内存空间不足。
3.空间分配担保失败,使用复制算法的Minor GC需要老年代的内存空间做担保,如果担保失败会执行一次Major GC。
4.JDK1.7以及以前版本,永久代空间不足。在JDK1.7及以前,HotSpot虚拟机中的方法区是用永久代实现的,永久代中存放的是一些Class的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用CMS GC的情况下也会执行Major GC。如果Major GC后仍然回收不了,那么虚拟机就会OOM。为了避免以上原因引起的Major GC,可增大永久代空间,或转为使用CMS GC。
5.Concurrent Mode Failure,执行CMS GC的过程中,同时有对象要放入老年代,而此时老年代空间不足(可能是GC过程中浮动垃圾过多导致暂时性的空间不足),便会报ConcurrentModeFailure错误,并触发Major GC。
3.类加载机制
类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类,因为那么做会占用很多内存。
3.1 加载过程
类的生命周期有7个阶段:加载,验证,准备,解析,初始化, 使用,卸载。其中前五个阶段属于类加载过程。
加载。完成以下三件事:
通过类的完全限定名称获取定义该类的二进制子节流;
将该子节流表示的静态存储结构转换为方法区的运行时存储结构;
在内存中生成一个代表该类的Class对象,作为方法区中该类各种数据的访问入口。
验证。确保Class文件的子节流中包含的信息符合当前虚拟机调度要求,并且不会危害虚拟机自身安全。
准备。被static修饰的变量叫类变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。
解析。将常量池的符号引用替换为直接引用的过程。
初始化。在初始化阶段才真正开始执行类中定义的Java程序代码。初始化阶段是虚拟机执行构造器<clinit>()方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其他资源。
3.2 类与类加载器
两个类相等,需要类本身相等,并且使用同一个类加载器加载。这是因为每一个类加载器都拥有一个独立的类名称空间。这里的相等,包括类的Class对象的equals方法,isAssignableFrom方法,isInstance方法的返回结果为true,也包括使用instanceof关键字做对象所属关系判定结果为true。
从Java虚拟机的角度来讲,分为两种类加载器:1.启动类加载器,使用C++实现,是虚拟机自身的一部分;2.所有其他类的加载器,使用Java实现,独立于虚拟机,继承自抽象类java.lang.ClassLoader。
从Java开发人员的角度看,可以分为:
1.启动类加载器,此类加载器负责将存放在<JRE_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机了、内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把请求委派给启动类加载器,直接使用null代替即可。
2.扩展类加载器,这个类加载器是由ExtClassLoader(sun.misc.LauncherAppClassLoader)实现的,由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序默认的类加载器。
3.3 双亲委派模型
应用程序是由三种加载器互相配合而实现类加载,除此之外还可以加入自己定义的类加载器。
下图展示了类加载器之间的层次关系,称为双亲委派模型。该模型要求除了顶层的启动类加载器外,其他的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系来实现,而不是继承。
工作过程:一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时,才尝试自己加载。
好处:这么做使得Java类随着他的类加载器一起具有一种优先级的层次关系,从而使得基础类得到统一。例如java.lang.Object存放在rt.jar中,如果编写另外一个java.lang.Object并放到ClassPath中,程序可以编译通过。由于双亲委派模型的存在,所以在rt.jar中的Object比ClassPath中的Object优先级更高,这是因为rt.jar中的Object使用的是启动类加载器,而ClassPath中的Object使用的是应用程序类加载器。rt.jar中的Object优先级更高,那么程序中所有的Object都是这个Object。
实现:以下是抽象类java.lang.ClassLoader的代码片段,其中的loadClass()方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出ClassNotFoundException,此时尝试自己去加载。
public abstract class ClassLoader {
// The parent class loader for delegation
private final ClassLoader parent;
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
}
自定义类加载器实现
以下代码中的FileSystemClassLoader是自定义类加载器,继承自java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节码文件(.class文件),然后读取该文件内容,最后通过defineClass方法来把这些字节代码转换成java.lang.Class类的实例。
java.lang.ClassLoader的loadClass方法实现了双亲委派的逻辑,自定义类加载器一般不去重写它,但是需要重写findClass方法。
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}
4.Android虚拟机Dalvik和ART
4.1 Dalvik虚拟机
Android应用程序是运行在Dalvik虚拟机里面的,并且每一个应用程序对应有一个单独的Dalvik虚拟机实例。Android应用程序中的Dalvik虚拟机实例实际上是从Zygote进程的地址空间拷贝而来的,这样就可以加快Android应用程序的启动速度。
4.1.1 什么是Dalvik虚拟机
Dalvik是Google公司自己设计用于Android平台的Java虚拟机,它是Android平台的重要组成部分,支持dex格式(Dalvik Executable)的Java应用程序的运行。dex格式是专门为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统。Google对其进行了特定的优化,使得Dalvik具有高效、简洁、节省资源的特点。从Android系统架构图知,Dalvik虚拟机运行在Android的运行时库层。
4.1.2 Dalvik虚拟机的功能
Dalvik作为面向Linux、为嵌入式操作系统设计的虚拟机,主要负责完成对象生命周期管理、堆栈管理、线程管理、安全和异常管理,以及垃圾回收等。Dalvik充分利用Linux进程管理的特点,对其进行了面向对象的设计,使得可以同时运行多个进程,而传统的Java程序通常只能运行一个进程,者、这也是为什么Android不采用JVM的原因。Dalvik为了达到优化的目的,底层的操作大多和系统内核相关,或者直接调用内核接口。另外,Dalvik早期并没有JIT编译器,直到Android2.2才加入了对JIT的技术支持。
4.1.3 Dalvik虚拟机和JVM的区别
1.结构不同(栈和寄存器)
本质上,Dalvik也是一个Java虚拟机,但是特别之处在于没有使用JVM规范。大多数JVM都是基于栈的结构,而Dalvik虚拟机是基于寄存器。基于栈的指令很紧凑,一个指令占一个字节,故而称为字节码。基于寄存器的指令由于需要指定源地址和目标地址,因此需要占用更多的指令空间。两者各有优劣,一般而言,执行同样的功能,前者需要更多的指令(主要是load和store指令),而后者需要更多的指令空间。需要更多的指令意味着要更多的CPU时间,而需要更多的指令空间意味着数据缓冲更易失效。
2.运行文件不同
Java虚拟机运行的是Java字节码,而Dalvik虚拟机运行的是专有文件格式dex。在java程序中,Java类会编译成一个或多个class文件,然后打包到jar文件中,接着Java虚拟机会从相应的class文件和jar文件中获取对应的字节码。Android应用虽然也使用Java语言,但是在编译成class文件后,还会通过DEX工具将所有的class文件转换成一个dex文件,Dalvik虚拟机再从中读取指令和数据。dex文件减少了整体的文件尺寸和I/O操作次数,也提高了类的查速度。
由下图可以看到,jar和apk文件的组成结构,以及class文件和dex文件的差异。dex格式文件使用共享的、特定类型的常量池机制来节省内存。常量池存储类中的所有字面常量。它包括字符串常量、字段常量等值。
总的来说,Dalvik虚拟机有以下特点:
1.使用dex格式的字节码,不兼容Java字节码格式;
2.代码密度小,运行效率高,节省资源;
3.常量池只使用32位索引;
4.有内存限制;
5.默认栈大小是12kb(3个页,每页4kb);
6.堆默认启动大小为2MB,默认最大值为16MB;
7.堆支持的最小启动大小为1MB,支持的最大值为1024MB;
8.堆和栈参数,可以通过-Xms和-Xmx修改。
4.1.4 Dalvik系统结构
一个Android应用程序,需要经过以下过程才可以在Dalvik虚拟机运行
1.把Java源文件或者Kotlin源文件,通过编译器编译成class文件;
2.使用DX工具把class文件装换成dex文件;
3.使用aapt工具把dex文件,资源文件以及AndroidManifest.xml文件(二进制格式)组合成APK;
4.将APK安装到Android设备运行。
下图展示的是一个apk的签名过程
Dalvik类加载器
一个dex文件需要类加载器加载原生类和Java类,然后通过解释器根据指令集对Dalvik字节码进行解释和执行。Dalvik类加载器使用mmap函数,将dex文件映射到内存中,通过普通的内存读取操作即可访问dex文件,然后解析dex文件内容并加载其中的类到哈西表中。
Dalvik解释器
对于任何虚拟机来说,解释器无疑是核心的部分,所有的Java字节码都经过解释器解释执行。由于Dalvik解释器的效率很重要,Android分别实现了C语言版本和各种汇编语言版本的解释器。解释器通常是循环执行,需要一个入口函数调用处理程序执行第一条指令,而后每条指令执行时引出下一条指令,通过函数指针调用处理程序。
垃圾收集
Dalvik虚拟机使用常用的Mark-Sweep算法(标记-清除),而不是JVM常用的分代回收算法。
GC的第一步是标记出活动对象,因为没有办法识别那些不可访问的对象,这样所有未被标记的对象就是可以回收的垃圾对象。当进行GC时,需要停止虚拟机的运行,又称为STW(Stop-the-world)。Dalvik在运行过程中要维护一些状态信息,这些信息包括:每个线程所保存的寄存器、Java类中的静态字段、局部和全局的JNI引用。。JVM中的所有函数调用会对应一个相应C的栈帧,每一个栈帧里可能包含对对象的引用,比如包含对象引用的局部变量和参数。所有这些引用信息被加到一个根集合中,然后从根集合开始,递归查找可以从根集合出发访问的对象。因此Mark过程又叫追踪,追踪所有可被访问的对象。
GC的第二步是回收内存。在Mark阶段通过markBits位图可以得到所有可访问的对象集合,而liveBits位图表示所有已经分配的对象集合,通过比较两个位图的差异就是可回收的对象集合。Sweep阶段调用free来释放这些内存给堆。
Dalvik启动流程
Dalvik进程管理是依赖于Linux的进程体系结构的,如要为应用程序创建一个进程,它会使用Linux的fork机制来复制一个进程。Zygote是一个虚拟机进程,同时也是一个虚拟机实例的孵化器,它通过init进程启动。
Android的启动
启动电源,加载引导程序到RAM;
BootLoader引导;
Linux Kerne引导;
Init进程创建;
Init fork出Zygote进程,Zygote进程创建虚拟机,创建系统服务;
Android Home Launcher启动。
4.2 ART虚拟机
Android4.4及以前,使用的虚拟机是Dalvik。随着硬件水平的不断发展以及用户对更高性能的需求,Dalvik虚拟机的不足日益突出,所以在Android5.0及以上的机型,使用了ART(Android RunTime)虚拟机以解决性能问题。
Dalvik使用的JIT(Just-In-Time)技术来进行代码转译,每次执行应用的时候,Dalvik将程序的代码编译为机器语言执行,而ART采用了AOT(Ahead-Of-Time)技术,会在应用程序安装时就装换成机器语言,不再执行解释,从而优化了应用运行的速度。在内存管理方面也有较大的改进,对内存分配和回收都做了算法优化,降低了内存碎片化程度,回收时间也得以缩短。
内存管理
内存管理是ART的一大改进。ART虚拟机首先会从系统空间取得足够的空间,这些空间在没有使用的时候并不占用物理内存,在使用的时候才分配物理内存,在不需要的时候及时归还给系统。ART将分配到的空间根据需要托管给不同的算法进行管理,主要提供了如下几种分配算法:
1.RosAlloc(Rows of slots Allocation)的分配策略:在Ros Alloc Space分配对象,是一种线性分配方式,将一个大的连续空间划分为多个片,每个片中只能分配固定大小的内存。这种分配方式有一个更加细粒度的结构,可以锁定独立的对象;
2.BumpPointer:在Bump Pointer Space中分配对象。每一次申请时,分配需要的size,返回end地址的值。然后将end后移size,作为下一次申请的地址。这种分配采用不计数申请的方式,直到发生oom。采用Moving GC的方式进行回收。
3.TLAB(Thread Local Allocation Block):在由Bump Pointer Space提供的线程局部缓冲区中分配对象,按线程进行管理。每一个线程,从Bump Pointer Space中申请一个block,在线程内使用Bump Pointer的分配策略。由于每一个线程独立在自己的block中分配内存,避免了同步,可以提高效率。
4.DLMalloc:这是原Dalvik使用的算法。在DI Malloc Space分配对象,将memory划分成很多小的数据块,每一个块的前8个或者16个字节作为Header,使用链表来管理空闲数据块。
使用这些不同算法来分配内存,与Dalvik相比可以有效的减少碎片化,由于碎片化减少,相应也就减少了GC的次数。此外,像TLAB这样的算法引入,也减少了申请内存时线程之间的竞争。
代码执行
下图是Android对apk的执行流程
Java文件在编译成class文件,然后经过Android平台的dx工具转换成dex文件后,同Native code(JNI)和资源一起打包成apk,apk安装到手机后解压出dex文件。Dalvik会通过dexopt工具将dex优化,成为Odex文件,Odex文件的效率比dex高,但其中大部分代码任然需要每次执行时编译;而ART则会将dex通过dex2oat工具得到一个ELF文件,它是一个可执行的文件。
以下的java代码为例:
int a = 1;
int b = 2;
public int test(){
int x = a;//1
int y = b;//2
int z = a + b;
return z;
}
在执行这段Java代码时,Dalvik虚拟机先要把test()方法的每句代码转成dex代码,对其中的1 2两句赋值语句,执行时需要在虚拟机中进行“指令读取-识别指令-跳转-实例操作”的解析过程,而ART中Java代码都被以方法为单位编译成汇编指令,执行上面这个方法的时候,1 2 两句代码只需要直接拷贝寄存器的值,各需要一条汇编指令就可以完成,省去了跳转、指令读取的过程,大大提高了执行效率。
和Dalvik相比,ART使得Android 系统的性能得到了一定程度的提升,不过缺点也比较明显,apk经过dex2oat预编译后,占用的空间增加,因此Android Rom占用的空间更大。手机在安装apk时,安装时间也明显变长。
5.常见面试题
1.说一下JVM的运行时数据区;
2.浅拷贝和深拷贝
浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址;
深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存。
3.说一下堆和栈的区别
物理地址:堆的物理地址分配对象是不连续的,性能慢。栈使用的是数据结构中的栈结构,先进先出,物理地址是连续的,性能快。
内存分配:堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定,一般远大于栈。栈是连续的,所以在编译器就确认,大小固定。
存放的内容:堆存放的是对象的实例和数组,因此更关注数据的存储。栈存放局部变量、操作数栈、返回结果,更关注的是程序方法的执行。PS:静态变量放在方法区,静态对象放在堆。
程序的可见度:堆对于整个应用程序都是共享的,可见的。栈只对于线程可见,是线程私有的,生命周期也和线程相同。
4.为对象分配内存
类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:
指针碰撞:如果Java堆的内存是规整的,即所有用过的内存放在一边,空闲的放在另一边。分配内存时,将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成了分配。
空闲列表:如果Java堆的内存不是规整的,则需要有虚拟机维护一个列表来记录哪些内存是可用的,这样在分配的时候可以从列表中查到足够大的内存给对象,并在分配后更新列表记录。
5.怎么判断对象是否可以被回收?
6.说一下GC过程;
7.哪些对象可以作为GC Roots?
8.内存分配策略师怎样的?
9.简述Java类加载机制;
10.什么事双亲委派模型?