虚拟机、内存、垃圾回收

计算机执行流程

硬盘:存储exe、class、dex文件,这些文件存储的就是指令码、类信息;

内存:把硬盘中的指令码复制到内存中,类加载进来,生成对象、调用栈等;

程序计数器:记录当前执行指令的地址,当前指令执行完成后,计算单元通知程序计数器,程序计数器指向下一条指令的地址;

寄存器:把当前执行栈的指令、参数地址和返回值地址复制到寄存器,根据程序计数器的指示,交给计算单元处理;

计算单元:接收寄存器的值和操作,执行计算,计算的中间值存在寄存器中。计算完成后,把最终值传给寄存器,寄存器传给内存,同时,通知程序计数器;

高速缓存/三级缓存:把一些常用的指令、常量存储在高速缓存中,就不用每次都去内存取,节省了大量时间;

32/64位:上面的每个过程,都涉及到数据从一个地方传输到另一个地方,每次传输4个字节(32位)的处理器就是32位处理器,每次传输8个字节的就是64位处理器。指令集设计、文件存储时会根据传输的最小单位来决定最小单位;

class文件相关

class文件:java虚拟机能识别的可执行文件,包含类信息、常量、jvm字节码等信息。java、jRuby、Groovy语言编写的程序,经过对应的编译器编译,都能得到class文件;

calss文件的信息协议:class文件由16进制数表示,最小单位是1字节(8位),协议定义了第m-n字节代表x属性,第m-n字节代表y属性。。。,如果某个属性需要很多个不确定的字节表示,开头就会有2个字节表示这个属性有x个字节,那么接下来x个字节就是这个属性的具体值。其中,有个code属性,存储的是class字节码,就是虚拟机能识别的机器码,如果这个类是个抽象类、接口,可能就没有code属性。class文件文件中记录的类、方法、变量都是使用唯一标识符,称为符号引用,运行时才会通过虚拟机分配内存,才会把符号引用转换为对应的地址引用;

class字节码:java虚拟机能读懂的指令,由于jvm是基于栈的架构,所以字节码指令只有操作,没有操作数(参数)。比如,对一个int型数据进行add操作,add是操作,int是操作数,如果没有操作数,就要用addInt、addFloat等等多个操作来表示。但是字节码只占1个字节,最多只能表示255种不同指令,所以有些类型的操作是不支持的,同时,float、boolean等类型数据都会转为对应的int值,使用addInt操作

解释执行和编译执行:编译执行就是编译器生成的就是本地机器码,编译期间就确定了内存分配、哪个指令放在哪个寄存器中执行、参数地址、目标地址等,执行的时候就指令直接从内存复制到寄存器。解释执行就是编译器生成的是虚拟机指令码,程序安装到硬盘、加载到内存中的指令也是虚拟机指令码(ART在安装的时候会把虚拟机指令码转为本地机器码),执行的时候由虚拟机解释指令码,然后调用本地机器码指令去完成具体的逻辑

基于栈的指令集和基于寄存器的指令集:基于寄存器的指令集,编译出来的是本地机器码,一条指令中包含操作、参数、返回值等信息,并且已经确定了哪些指令放在哪些寄存器中执行。基于栈的指令集只有操作指令,没有参数、返回值、地址等,也不指定在哪些寄存器中执行。

优缺点比较:
1.基于栈的指令,不指定寄存器,可以跨平台。基于寄存器的不行;
2.基于栈的指令,编译器不需要考虑空间分配,实现更简单。基于寄存器的编译器实现比较复杂;
3.基于栈的指令,实现在内存中,是每条指令都要去内存中读取,频繁的内存访问效率很低。基于寄存器的一次性复制多条指令到寄存器中,不需要频繁访问内存;
4.基于栈的指令,占用空间更少,但指令条数更多,意味着更多的cpu计算次数;基于寄存器的cpu效率更高;
5.基于栈的指令,可以在运行时优化(即JIT技术),根据运行时的一些信息进行更有效率的优化,比如某个同步锁在执行过程中发现根本用不上,就不用释放锁。基于寄存器的,在编译的时候就要指定哪条指令用哪个寄存器,需要在编译的时候优化;
6…虽然看上去基于寄存器的指令集效率更高,但是也有一些专业的测试表明基于栈的java虚拟机比dalvik效率更高,所以实际情况还得实际分析;

java虚拟机公有规范和私有实现:java虚拟机规范规定了jvm必须正确地识别class文件、正确地执行class字节码,但是并没有限制如何实现。虚拟机开发者可以自由地实现,使效率更高、内存占用更小,其中,有两个方向可以挖掘:①将class字节码在加载或执行时,翻译成另一种虚拟机指令集,使用另一种更好的虚拟机实现。比如2.2之前的dalvik;②将class字节码在加载时,翻译成宿主机CPU指令集(即JIT技术)。比如ART

java类加载

虚拟机类加载流程:加载、验证、准备、解析、初始化

加载:通过全限定名获取对应的class文件(二进制流),将class文件中的静态数据转换为运行时的数据结构,存在内存中的方法区,同时生成Class对象(注意,是类的代表,不是类的各个实例,目的是访问类中的静态方法、静态成员用),作为这个类的静态数据访问入口

验证:因为虚拟机可以接收任何来源的class文件(不一定是编译出来的),有些“错误”虽然不会通过编译,但有可能是人为生成的class文件,验证的目的是保证class文件符合虚拟机规范,不会对虚拟机造成影响。验证需要做很多工作,比较重要的4点是:检查class文件格式、检查数据规范、检查指令、检查符号引用。检查class文件格式就是按照class文件结构对其进行检查,这一步通过后,就会把class数据转换为运行时数据存在方法区,检查其中的数据有没有问题(有没有父类、final变量有没有随意改变),数据检查完成后检查指令有没有问题,最后检查符号引用是不是唯一的、是否能够准确找到对应的引用

准备:正式为类的静态变量(static类型)分配内存和初始值,这个初始值是变量类型的默认值(int是0,boolean是false),真正跟代码相关的值,是在类初始化(即初始化阶段)的时候分配;

解析:主要是将之前的符号引用转换为实际引用

初始化:这个阶段是真正开始执行java代码(即calss文件中的指令码)了,该赋值的赋值,该执行的执行

类加载时机:虚拟机规范并没有规定什么时候开始执行加载,只是规定了“需要使用的时候,类需要初始化完成,如果没有,就立即开始加载——初始化的过程”,需要使用就是指new对象、读/写变量、反射等

热更新/动态加载:上面的5个步骤,只有“加载”是可以自定义实现的,其它步骤都是虚拟机实现的。类加载的本质就是要读取一段calss文件二进制流,可以采用各种方法去完成。

java虚拟机中的内存分为几个区域:程序计数器、虚拟机栈、本地方法栈、方法区和堆。程序计数器是记录指令地址和跳转的,每个线程都由一个独立的PC计数器。虚拟机栈是保存局部变量(基本类型就是本身值,对象类型就是保存引用)、方法出口等信息,也是每个线程都独立的,如果请求的栈深度太大会抛异常。本地方法栈和虚拟机栈的作用一样,对应的是本地方法。

方法区:方法区是线程共享的,保存类相关的信息、类和code的描述、常量池等,如果类太多也会抛异常。这个区域的内存回收依赖于虚拟机的实现,有的虚拟机放在堆内存的永久代中,有的的单独管理的。

堆:所有的对象都是分配在这个区域,当new一个对象时,这个对象需要的内存大小是编译时就已知的,会在堆中找出一块长度足够的区域分配给该对象,初始化值是0,等到init方法时才会赋值。对于不连续的内存,会有一个表,类似目录那样去记录。

内存回收:PC计数器、虚拟机栈和本地方法栈的内存在编译时就确定了的,随线程创建而生,随线程而灭,基本不存在内存回收的问题。方法区和堆的内存则不一样,一个类的不同实现需要的内存不一样,一个方法的不同分支占用内存也不一样,需要等到运行时才能确定,这部分内存的创建和回收都是动态的

哪些内存需要回收(标记垃圾):有一种比较广泛的说法是“引用计数”来确定是否是垃圾,但是如果相互引用就永远无法回收了,事实证明,目前主流的商用虚拟机都不是这种方法,而是采用可达性标记。可达性分析的基本思路是,通过一些列GC Roots对象作为起点,这些Roots引用到达不了的就是垃圾,一般可以作为Roots的对象有几种:方法区中常量引用的对象、方法区static类型成员、虚拟机栈和本地方法栈中引用的对象

如何回收(清除垃圾):1.直接清除,就是把标记的内存置为默认值,为了避免内存碎片,把剩下的内存移到一起。2.复制算法,把存活的对象拎出来,复制到另一块空间中,将此区域全部清空,这种算法效率高,但空间利用率不高。现代商用虚拟机普遍采用分代收集算法,把内存分为新生代和老年代,根据不同代的特点,选择不同的收集算法。新生代会产生大量对象,GC时80%以上的新生对象都会被回收,剩下的少数对象就移到青年代,经过多次GC都没有回收的就会移到老年代,而方法区中的类信息、常量,由于很少回收,所以直接在老年代。对于新生代这种,因为每次需要复制的对象(存活的)很少,所以采用复制算法,对于老年代,对象比较稳定,采用整理-清除比较省空间;

何时回收:进行垃圾标记的时候,需要保证此时不会有对象的分配变化、引用变化,要不然标记的就不准确了。有两种方法:抢先式中断和主动式中断。抢先式就是GC线程去把其它线程中断,如果有线程在“不安全”点上,就恢复它,等它跑到安全点再执行。主动式中断就是当GC线程想要执行GC时设置一个标识,其它线程在某些点会去轮询GC线程的标识,如果发现GC想要执行,就主动挂起,等GC执行完了恢复。

强引用:显示赋值的引用,只要引用还在就永远不会回收;
软引用:SoftReference,系统将要发生内存溢出时,会把弱引用对象标记为垃圾进行一次GC,如果内存还是不足就会OOM;
弱引用:WeakReference,GC的时候,无论内存是否充足,都回回收弱引用对象;
虚引用:PhantomReference,GC的时候回回收,虚引用的对象也无法获得实例,唯一的作用是这个对象被回收时会收到一个系统通知;

jvm、dalvik和art比较

1.文件对比
jvm:识别class文件,具体情况见上文。

dalvik:识别dex文件,dex文件是把多个class文件集中到一起,常量、公共类库信息只需要保持一份,空间效率更高。apk安装过程中会把dex文件优化成odex文件,主要是对指令做一些优化。o

art:识别dex文件,在apk安装过程中,会把20%左右指令集翻译成本地机器码,文件格式转为oat,但是文件名仍然是odex文件,路径不变。oat文件中包含原dex文件和dex文件对应的本地机器指令。程序运行时,先解析dex文件找到对应的类/方法,然后通过索引找到对应的本地机器指令。因为一次性把全部代码优化为本地机器码非常耗时,所以安装时只优化常用的20%,后面在运行过程中动态优化。

2.字节码执行对比:
jvm:执行class字节码,class字节码是基于栈的指令集,程序运行过程中,jvm从内存中读取字节码执行;

dalvik:执行dex字节码,dex字节码是基于寄存器的指令集,运行过程中,dalvik从内存中读取字节码执行。虽然dalvik是基于寄存器的指令集,只是指令带有源地址和目标地址,并不会把当前栈整个复制到寄存器中去;

art:执行本地机器码,基于寄存器的指令集,程序安装时把dex字节码中常用的指令翻译成本地机器码,程序运行时,从内存中读取本地机器码执行。对于翻译成本地机器码的指令,运行时会把当前执行栈复制到寄存器中,执行时计算单元直接从寄存器中取指令;

dalvik垃圾回收
1.dalvik虚拟机的内存空间,老罗的分析中只分析了堆区,推测和java虚拟机一样,分为程序计数器、虚拟机栈、本地方法栈、方法区和堆区,但是dalvik虚拟机把堆区分为了java堆、native堆和bitmap堆。对于dalvik虚拟机内存回收的讨论都是基于堆区的讨论;

2.zygote进程的虚拟机分为zygote堆和active堆,zygote堆中存放android系统核心类库、java核心类库等公共资源,active堆为空。fork一个进程时,复制一个虚拟机,只会把空active堆复制过去,zygote堆是公共可读堆,其它进程也可以访问。当其它进程需要堆zygote堆进行写操作时,会使用写时拷贝技术把zygote堆复制过去,这样就不会影响zygote进程的公共堆了;

3.root对象:java全局变量、当前运行栈的局部变量、JNI全局变量、常量池中的String对象;

4.垃圾标记:标记过程中为了保证标记的准确性,又要兼顾程序终止的时间,所以分为两个阶段,第一个阶段需要终止其它线程,第二个阶段不需要。阶段1:标记出成员变量、当前运行栈局部变量和寄存器引用的对象,作为root对象;阶段2:以root对象为根节点遍历其它被引用的对象。阶段2允许其它线程执行,这样可能会带来对象引用关系的变化,如果有变化,在阶段2结束后会终止其它线程,堆这些变化的对象进行重新标记,由于阶段2的时间很短,此期间变化的对象比较少,所以重新标记也很快,对程序造成的影响非常小;

5.GC有四种类型:内存不足触发的GC、抛OOM前的GC、内存达到阈值的GC、显示调用的GC。当一个对象分配失败时,会启动GC,根据参数决定是内存不足GC还是OOM的GC;当一个对象分配成功后,会检查当前内存数值,如果超过阈值则会唤醒GC线程,GC线程5000毫秒轮询一次,是否需要执行GC;如果虚拟机允许显示调用GC,调用System.gc和VMRuntime.gc就会触发GC;

6.上面四种类型的GC会有几个参数来区分,是否回收zygote堆、是否回收软引用对象、是否是并行GC;

art垃圾回收
1.art虚拟机内存空间分为image空间、zygote堆、active堆和largeObject空间,在dalvik的基础上新增了image空间和largeObject空间。art虚拟机在安装过程中,dex翻译成oat文件,除了翻译出本地机器码,还会创建需要预加载的系统类对象,zygote进程虚拟机启动时,会将这些对象映射到内存中。需要创建新的进程时,active堆会复制过去,zygote根据需要会通过写时拷贝技术复制,而image空间不会复制。image空间中保存的是一些预先创建的对象,这些对象之间可能会互相引用,所以地址是固定的,这个空间在运行时是不能改变的(不会回收,也不会分配新对象);

2.art的垃圾收集器分为3种:收集active堆的收集器、收集active和zygote堆的收集器、收集(自上次收集以来)新分配的对象。收集器需要实现5个接口,也分别对应着垃圾收集的5个步骤:初始化、标记(并行或非并行)、处理标记过程种变化的对象(如果是并行的才有这步)、回收、结束。

3.上面所说的这些内存分配和回收,都是通过C库提供的内存管理接口来实现的,而largeObject空间是虚拟机自己维护一个FreeList,创建大对象时,为这个对象找一个快合适的空闲内存来分配,释放后将释放的内存添加到FreeList中去;

4.art在dalvik基础上的优化:1.区分了更多的区,可以只回收固定的区,将影响降到最低;2.功能更加细分的收集器,比如只收集新生对象,影响降到最低;3.二次标记过程中使用的记录表缓存起来重复使用;4.专门的LargeObject区和分配回收策略,避免分配大内存时的频繁GC

5.内存碎片会导致分配一个稍微大一点的对象时分配失败,引起OOM,android从5.0开始支持Compacting GC,即在垃圾标记、回收时,增加整理/压缩步骤。压缩Compacting GC需要进行整理和压缩,效率会比标记清除GC效率低,但是可以通过结合使用,提升体验,比如应用在前台的时候使用标记清除,在后台的时候使用压缩;

6.采用Compacting GC,在分配对象失败导致OOM前,会对堆空间进行压缩整理,然后再尝试分配;

android虚拟机相关知识点
1.android系统启动的时候,会创建zygote进程,创建一个dalvik虚拟机实例,这个虚拟机会将java核心库加载进来。需要创建其它进程时,会复制zygote进程中的虚拟机,并共享zygote进程中的java核心库。这种模式,新的应用进程创建快,并且能共享java核心库,节省内存空间;

2.java虚拟机定义了3个接口:获取虚拟机参数、创建虚拟机、获取虚拟机,dalvik、art和jvm都实现了这三个接口,可以无缝切换。有一个系统属性persist.sys.dalvik.vm.lib表示当前系统使用哪个虚拟机;

3.Zygote进程在启动过程中,获取系统属性persist.sys.dalvik.vm.lib的值来决定到底加载libdvm.so还是libart.so,然后创建不同的虚拟机。程序安装时,如果系统属性persist.sys.dalvik.vm.lib的值为libdvm.so,就将dex指令码优化后存在odex文件中,如果未libart.so,就将dex指令码翻译成本地机器码,还是存放在odex文件中;

4.java创建的对象放在java堆中,手机厂商可以配置java堆区的大小,代码中可以获取java堆区的最大值;

5.低版本的dalvik虚拟机,bitmap堆是单独的,但是会把这部分大小和java堆一起计算,不能超出配置的java堆区大小。高版本的虚拟机中,bitmap堆已经合入java堆了;

6.native代码分配的内存放在native堆区,没有文档表明这部分大小如何限制,但是4.4的系统,2G内存,当一个应用分配到480M左右的时候就崩溃了;

7.Manifest中可以配置该进程为largeHeap,在4.4的系统,2G内存机器中,分配到120M就崩溃了;

8.高版本的dalvik虚拟机,垃圾回收线程并行,一次只回收一部分,每次造成的中止时间小于5ms。每次回收都会打印日志,回收了多少、当前已使用/总共、造成的中止时间;

9.我们调用的大部分java运行时库,都是通过调用目标机器的操作系统(即linux)接口实现的,比如线程调度;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章