一.操作系统相关基础知识
1.物理内存、虚拟内存、逻辑地址与交换空间
物理内存(RAM):加载到内存地址寄存器中的内存又叫“硬件内存”,是内存单元真正的地址(也叫物理地址)。RAM作为进程运行不可或缺的资源,对系统和稳定性有着决定性的影响。另外,RAM的一部分被操作系统留作他用,比如显存等。
逻辑地址:由CPU控制生成的地址,是一个程序级别的概念。这里引用一个浅显的例子——我们在C语言指针编程中,可以读取指针变量本身的值(&操作),这里取得的值就是逻辑地址——也就是说,这个(&操作)取得的值是CPU控制生成的一个逻辑地址,并不是这个指针变量在RAM中的真正地址。
那么,我们为什么要这么一个并不是真正地址的逻辑地址呢?深层次的原因这里不予以探究,但是一个比较浅显的原因就是,逻辑地址的分配非常灵活——在一个数组中,我们通过逻辑地址可以保证数组中元素地址的连续性。当然这个逻辑地址最终还是要通过一定的方式映射到RAM中的物理地址上,这个物理地址才是元素存储的真正地址,而这个物理地址,不一定是连续的。
虚拟内存:是操作系统级别的概念,指计算机呈现出要比实际拥有的内存大得多的内存量。它使得每个应用程序都认为自己拥有独立且连续的可用的内存空间(一段连续完整的地址空间),这个内存大小跟操作系统的位数有关。比如32位系统,逻辑内存的最大为2^23。而实际上,它通常是被映射到多个物理内存段(在真正的物理地址上不一定是连续的),还有部分暂时存储在外部磁盘存储器上,在需要时再加载到内存中来。
上一段我们我们说了半天的逻辑地址,笔者的理解就是虚拟内存中的地址。OK,现在我们知道了虚拟内存有两个特点——一个是在虚拟内存中虚拟地址/逻辑地址是连续的,便于灵活分配;二是虚拟内存可以是计算机呈现出比实际内存大的多的内存。那么为什么虚拟内存会呈现出这么大的内存的神奇功能呢?或者说这多出来的额内存是哪来的?这就要用到我们接下来讲的交换(Swap)空间。
交换(Swap)空间:在系统中运行的每个进程都需要使用到内存,但不是每个进程都需要每时每刻使用系统分配的内存空间。当系统运行所需内存超过实际的物理内存,内核会释放某些进程所占用但未使用的部分或所有物理内存,将这部分释放的数据存储在磁盘上直到进程下一次调用,并将释放出的内存提供给有需要的进程使用。
引用一个容易理解但不是很恰当的比喻:你不需要很长的轨道就可以让一列火车从上海开到北京。你只需要足够长的铁轨(比如说3公里)就可以完成这个任务。采取的方法是把后面的铁轨立刻铺到火车的前面,只要你的操作足够快并能满足要求,列车就能象在一条完整的轨道上运行。
swap和虚拟内存结伴而来的。如果系统是64位,最大虚拟内存可以是2的64次方,没有计算机会有这么大的内存。当内存不够用的时候只能映射到磁盘。linux专门开辟了一个swap磁盘分区,当物理内存不够用的时候(程序并不知道),将内存中很久不使用的内存区域交换到swap区。也即是说:用作虚拟内存的磁盘空间称为交换空间(swap空间)。
2.进程的地址空间
在32位操作系统中,进程的地址空间是0到4GB。这里我们需要强调一点:进程所拥有的内存空间指的是“虚拟内存”,虚拟地址/逻辑地址与进程息息相关,不同进程里同一个虚拟地址指向的物理地址不一定是相同的,所以离开进程谈虚拟内存没有任何意义。下图展示了Linux进程地址空间(虚拟内存)的组成:
Stack(栈)与Heap(堆)
Stack空间(进栈和出栈)由操作系统控制,其主要储存函数地址、函数参数、局部变量等等,所以Stack空间不需要很大,一般几MB大小。
Heap空间由程序员控制,主要包括实例域、静态域、数组元素等,储存空间比较大,一般为几百NB到几GB。正是由于Heap空间由程序员管理,所以容易出现使用不当的问题(如内存泄漏),当然,Heap内存也是系统GC发生的区域。
3.Android中的进程
Android中的进程主要分为native进程和Java进程——native进程指的是采用C/C++实现的,不包括Dalvik实例的Linux进程;java进程:实例化了Dalvik虚拟机的Linux进程,我们开发的APP就是出于java进程中的。
我们上面说过,Heap(堆)内存是由程序员控制的。我们使用malloc、C++ new和java new所申请的空间都是heap空间,只不过C/C++申请的内存空间在native heap中,而java申请的内存空间则在dalvik heap中。在平时的开发中,我们打交道的最多的就是dalvik heap,我们的实例域、静态域、数组元素等都是在dalvik的heap中,虚拟机的GC也发生在其中。
二. DVM(Dalvik虚拟机)
1.DVM与JVM
(1)什么JVM?
JVM是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。它有自己完善的(虚拟)硬件架构(如处理器、堆栈、寄存器等),还具有相应的指令系统。使用“Java虚拟机”程序就是为了支持与操作系统无关、在任何系统中都可以运行的程序。
Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码,就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java能够“一次编译,到处运行”的原因。
(2)什么是DVM?
Dalvik是Google公司自己设计用于Android平台的Java虚拟机,也就是说,本质上,Dalvik也是一个Java虚拟机。是Android中Java程序的运行基础。
其指令集基于寄存器架构,执行其特有的文件格式——dex字节码来完成对象生命周期管理、堆栈管理、线程管理、安全异常管理、垃圾回收等重要功能。它的核心内容是实现库(libdvm.so),大体由C语言实现。
(3)JVM 与 Dalvik VM的关系
上面我们说JVM是一个虚构出来的计算机,这种说法并不是很准确——严格来说,JVM是一种规范,或者说实现这种规范的实例,从这个角度来说,Dalvik VM也是一个 特殊的JVM,这点在上面也有提到。说的更通俗一点,能符合规范正确执行Java的.class文件的就是JVM;那么Android开发包中的dx与Dalvik VM结合起来,就可以看成是一个JVM了(要把一个东西称为“JVM”必须要通过JCK(Java Compliance Kit)的测试并获得授权后才能行,所以严格来说dx + Dalvik VM不能叫做JVM,因为没授权)。
2.JVM和DVM的区别与联系
(1).Dalvik VM是基于寄存器的架构(reg based),而JVM是堆栈结构(stack based)。
这里的寄存器架构和堆栈结构指的是计算机指令系统,计算机指令系统分为四种:堆栈型,累加器型,寄存器-储存器型和寄存器-寄存器型。四种分类的依据是操作数的来源。堆栈型默认的操作数都在栈顶,累加器型默认一个操作数是累加器,寄存器-存储器型的操作数可以是寄存器或者内存。寄存器-寄存器型除了访存指令,操作数都是寄存器。
x86一开始并没有使用太多的通用寄存器,原因之一(注意,只是之一)是当时的编译器无力进行寄存器分配,让编译器自动决定程序中众多变量哪些应该装入寄存器、哪些应该换出、哪些变量应该映射到同一个寄存器上,并不是一件易事,JVM采用堆栈结构的原因之一就是不信任编译器的寄存器分配能力,转而使用堆栈结构,躲开寄存器分配的难题。
如今的CPU早就有足够的晶体管来支持复杂设计,为了性能着想,大量使用寄存器型的指令,原因在于寄存器离CPU最近,所以延时最短,取指最快,有利于主频提高。
那么基于栈与基于寄存器的架构,谁更快呢?intel的X86还保留有累加器指令和堆栈型指令,这是为了历史兼容。很多现今的处理器,除了load和store指令访存外,只支持对寄存器操作,不支持对堆栈以及内存的直接操作——这也从侧面反映出基于寄存器比基于栈的架构更与实际的处理器接近。
①.dvm速度快!寄存器存取速度比栈快的多,dvm可以根据硬件实现最大的优化,比较适合移动设备。JAVA虚拟机基于栈结构,程序在运行时虚拟机需要频繁的从栈上读取写入数据,这个过程需要更多的指令分派与内存访问次数,会耗费很多CPU时间。
②.指令数小!dvm基于寄存器,所以它的指令是二地址和三地址混合,指令中指明了操作数的地址;jvm基于栈,它的指令是零地址,指令的操作数对象默认是操作数栈中的几个位置。这样带来的结果就是dvm的指令数相对于jvm的指令数会小很多,jvm需要多条指令而dvm可能只需要一条指令。
(2).Dalvik 执行速度比 JVM 快,但移植性稍差.
Dalvik 执行速度比 JVM 快的原因,上面已经做了一些说明,这里在综合移植性说一下。在一个解释器上执行VM指令,包含三个步骤:指令分派、访问操作数和执行计算。
①.指令分派
指令分派负责从内存中读取 VM 指令,然后跳转到相应的解释器代码中。上面提到过,完成同样的事情,基于栈的虚拟机需要更多的指令,意味着更多的指令分派和 内存访问次数,这是 JVM 的执行性能不如 Dalvik VM 的原因之一。
②.访问操作数
访问操作数是指读取和写回源操作数和目的操作数。Dalvik VM 通过虚拟寄存器来访问操作数, 由于具有相近的血缘, Dalvik 的虚拟寄存器在映射到物理寄存器方面具有更充分的优势, 这也是 Dalvik VM 性能较佳的一个原因。
JVM 的操作数通过操作数栈来访问, 而因为指令中没有使用任何通用寄存器,在虚拟机的实现中可以比较自由的分配实际机器的寄存器,因而可移植性高。
作为一个优化,操作数栈也可以由编译器映射到物理寄存器上,减少数据移动的开销。
③.指令执行
(3).Dalvik执行的是特有的DEX文件格式,而JVM运行的是.class文件格式.*
在Java程序中,Java类会被编译成一个或多个class文件,然后打包到jar文件中,接着Java虚拟机会从相应的class文件和jar文件中获取对应的字节码;Android应用虽然也使用Java语言,但是在编译成class文件后,还会通过DEX工具将所有的class文件转换成一个dex文件,Dalvik虚拟机再从中读取指令和数据。
优势:
class文件去冗余:class文件存在很多的冗余信息,dex工具会去除冗余信息(多个class中的字符串常量合并为一个,比如对于Ljava/lang/Oject字符常量,每个class文件基本都有该字符常量,存在很大的冗余),并把所有的.class文件整合到.dex文件中。减少了I/O操作,提高了类的查找速度。
缺点:
方法数受限:多个class文件变成一个dex文件所带来的问题就是方法数超过65535时报错,由此引出MultiDex技术。
可以看到,这里最终生成了一个.odex文件,odex是为了在运行过程中进一步提高性能,对dex文件的进一步优化,优化后的文件大小会有所增加,应该是原DEX文件的1-4倍。
更多关于.class文件与.dex文件的知识可以参照这篇文章:深入理解Android(二):Java虚拟机Dalvik,这篇文章中对这两个文件的结构做了非常详细的分析,这里就不多说了。
(4).Dalvik可以允许多个instance 运行,也就是说每一个Android 的App是独立跑在一个VM中.
一个应用,一个进程,一个Dalvik!
Zygote是一个虚拟机进程,同时也是一个虚拟机实例的孵化器,它通过init进程启动。首先会孵化出System_Server,他是android绝大多系统服务的守护进程,它会监听socket等待请求命令,当有一个应用程序启动时,就会向它发出请求,zygote就会FORK出一个新的应用程序进程。
这样做的好处是:Zygote进程是在系统启动时产生的,它会完成虚拟机的初始化,库的加载,预置类库的加载和初始化等等操作,而在系统需要一个新的虚拟机实例时,Zygote通过复制自身,最快速的提供个进程;另外,对于一些只读的系统库,所有虚拟机实例都和Zygote共享一块内存区域,大大节省了内存开销。
每一个app启动的时候,就会有自己的进程与Dalvik虚拟机实例。而这样做的好处是一个App crash只会影响到自身的VM,不会影响到其他。 Dalvik的设计是每一个Dalvik的VM都是Linux下面的一个进程。那么这就需要高效的IPC。另外每一个VM是单独运行的好处还有可以动态active/deactive自己的VM而不会影响到其他VM。
3.Android为什么会出现OOM?
通过上面的介绍,我们对Dalvik虚拟机已经有了一些初步的了解,现在我门回到Android的内存管理中来。前面我们说过Heap(堆)内存是由程序员控制的,用C/C++申请的内存空间在native heap中,而java申请的内存空间则在dalvik heap中
那么为什么会出现OOM的情况呢?这个是因为Android系统对dalvik虚拟机的heap大小作了硬性限制,当java进程申请的空间超过这个阈值时,就会抛出OOM异常(这个阈值可以是48M、24M、16M等,视机型而定)。
也就是说,程序发生OMM并不表示RAM不足,而是因为程序申请的java heap对象超过了dalvik vm heapgrowthlimit。也就是说,在RAM充足的情况下,也可能发生OOM。
这样设计的目的是为了让Android系统能同时让比较多的进程常驻内存(RAM),这样程序启动时就不用每次都重新加载到内存,能够给用户更快的响应。迫使每个应用程序使用较小的内存,移动设备非常有限的RAM就能使比较多的app常驻其中。
java程序发生OMM并不是表示RAM不足,如果RAM真的不足,Android的memory killer会起作用,当RAM所剩不多时,memory killer会杀死一些优先级比较低的进程来释放物理内存,让高优先级程序得到更多的内存。
三.JVM / Dalvik VM垃圾回收机制
这里为什么要讲JVM的垃圾回收机制?——前面我们说过,Davlik VM本质上也是一种JVM,因此我们从大处着手,并在其中我们会穿插讲解一下Dalvik VM在具体实现上的一些不同。
1.JVM的基本架构
如图,Java VM规则将JVM所管理的内存分为以下几个部分:
(1).方法区
各个线程所共享的,用于存储已被虚拟机加载类信息、常量、静态变量、即时编译器编译后的代码等数据。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
常量池
运行时常量池是方法区的一部分。用于存放编译器生成的各种字面量和符号引用。运行期间也可以将新的常量放入常量池中,用得比较多的就是String类的intern()方法,当一个String实例调用intern时,Java查找常量池中是否有相同的Unicode的字符串常量,若有,则返回其引用;若没有,则在常量池中增加一个Unicode等于该实例字符串并返回它的引用。
(2).Java堆
① Java堆
虚拟机管理内存中最大的一块,被所有线程共享,该区域用于存放对象实例,几乎所有的对象(实例变量,数组)都在该区域分配,是内存回收的主要区域。每个对象都包含一个与之对应的class信息(我们常说的类类型,Clazz.getClass()等方式获取)。【这里的“对象”,不包括基本数据类型】
从内存回收角度看,由于现在的收集器大都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代,再细分一点的话可以分为Eden空间、From Survivor空间、To Survivor空间等(这个后面会讲)。根据Java虚拟机规范规定,Java堆可以处于物理上不连续的空间,只要逻辑上是连续的就行。如果在堆中没有内存可分配时,并且堆也无法扩展时,将会抛出OutOfMemoryError异常。
② Dalvik堆
Dalvik VM的堆结构相对于JVM的堆结构有所区别,只而主要体现在Dalvik将堆分成了Active堆和Zygote堆。之前我们有说过,这个Zygote是一个虚拟机进程,同时也是一个虚拟机实例的孵化器——那么同样的,zygote堆是Zygote进程在启动时的预加载的类、资源和对象;除此之外所有的对象,包括我们在代码中创建的实例、静态域和数组,都是储存在Active堆里边的。
为什么要把Dalvik堆分成Zygote堆和Active堆?这主要是因为Android通过fork方法创建一个新的zygote进程,为了尽可能的避免父进程和子进程之间的数据拷贝,fork方法使用写时拷贝技术,简单讲就是fork的时候不立即拷贝父进程的数据到子进程中,而是在子进程或者父进程对内存进行写操作时才对内容进行复制。
Dalvik的Zygote堆存放的预加载类都是Android核心类和Java运行时库,这部分很少被修改,大多数情况下父进程和子进程共享这块区域,因此没有必要对这部分类进行垃圾回收之类的修改,直接复制即可。而Active堆作为我们程序代码中创建实例对象的存放堆,是垃圾回收的重点区域,因此将两个堆分开。
(3).Java 栈 / Java虚拟机栈(Java Virtual Machine Stacks)
线程私有,它的生命周期与线程相同。 Java虚拟机栈描述的是Java方法(区别于native的本地方法)执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动作链接、方法出口等信息。每个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
我们所用的大多数 JVM 都是基于 Java 栈的运行机制,而有一个例外的实现,Google 移动设备操作系统 Android 的虚拟机 Dalvik 则是基于寄存器的机制(Dalvik 虽然支持 Java 语言开发,但从虚拟机的角度看,并不符合 Java VM 标准),关于虚拟机实现时,栈和寄存器机制的比较,请参考论文“Virtual Machine Showdown: Stack Versus Registers”;
Java栈,划分为操作数栈、栈帧数据和局部变量区,方法中分配的局部变量在栈中,同时每一次方法的调用都会在栈中分配栈帧。对于基于栈的 Java 虚拟机,方法的调用和执行伴随着压栈和出栈操作。每个线程有各自独立的栈,由虚拟机来管理栈的大小,但我们应该对它的大小有个概念。栈的大小是把双刃剑,如果太小,可能会导致栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果过大,就会影响到可创建栈的数量,如果是多线程的应用,就会导致内存溢出。
来看一段字节码在 Java 栈中的执行示例,100 与 98 相加:
iload_0 // 载入局部变量 0,整型,压入栈中
iload_1 // 载入局部变量 1,整型,压入栈中
iadd // 弹出两个整型数,相加,将结果压入栈
istore_2 // 弹出整型数,存入局部变量 2
(4).本地方法栈(Native Method Stacks)
本地方法栈与Java虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机所使用到的Native方法服务。说白了,这是 Java 调用操作系统本地库的地方,用来实现 JNI(Java Native Interface,Java 本地接口)
(5).程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所执行字节码的行号指示器,字节码解释器工作时通过改变该计数器的值来选择下一条需要执行的字节码指令。是线程私有,生命周期与线程相同。
2.垃圾回收算法相关
(1).可回收对象的判定
①.引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它的时候,计数器的值就加1;当引用失效的时候,计数器的值就减1;任何时刻计数器为0的对象是不可能再被引用的。
这种方法实现简单,判断效率也很高;但是该算法有一个致命的缺点就是难以解决对象相互引用的问题:试想有两个对象,相互持有对方的引用,而没有别的对象引用到这两者,那么这两个对象就是无用的对象,理应被回收,但是由于他们互相持有对方的引用,因此他们的引用计数器不为0,因此他们不能被回收。
②.可达性分析算法
为了解决上面循环引用的问题,Java采用了一种全新的算法——可达性分析算法。这个算法的核心思想是,通过一系列称为“GC Roots”的对象作为起始点,从这些结点开始向下搜索,搜索所走过的路径成为“引用链”,当一个对象到GC Roots没有一个对象相连时,则证明此对象是不可用的(不可达)。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 上面说的JVM栈(栈帧数据中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- Native 方法栈中JNI引用的对象。
需要注意一点,即使在可达性分析算法中不可达对象,也并非是“非死不可”的,要真正宣告一个对象的死亡,至少需要经历两次标记的过程:
如果一个对象在进行可达性分析之后发现没有与GC Roots相连的引用链,那么他将会第一次标记并且****。当对象没有复写finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机讲着两种情况都视为“没有必要执行finalize()方法”。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被加入一个“F-Queue”队列中,并在稍后由一个虚拟机建立的、优先级低的Finalize线程,去触发这个方法,但并不承诺会等待他运行结束。
finalize()方法是对象逃脱死亡厄运的最后一次机会,稍后的GC会对在“F-Queue”队列中的对象进行第二次小规模的标记;
如果对象要在finalize()中拯救自己,只需要重新与引用链上的对象就行关联即可,那么在第二次标记时它将被移出“即将回收”的集合;
如果对象这个时候还是没有逃脱,那基本上他就真的被回收了。
③.引用
无论是引用计数法还是可达性分析算法,判断对象的存活与否都与“引用”有关。在JDK1.2之前,“引用”的解释为:如果reference类型的数据中储存的数值代表的是另外一块内存的起始地址,就称这个数据代表着一个引用。在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用。
- 强引用:就是指在程序代码之中普遍存在的,类似于“Object obj = new Object();”这样的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
- 软引用:用来描述一些还有用但并非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收的范围,进行第二次回收——如果这次回收还没有腾出足够的内存,才会内存溢出抛出异常。在JDK1.2之后,提供了SoftReference来实现软引用。
- 弱引用:也是用来描述非必须对象的,但是他的强度比软引用更弱一些。被弱引用引用的对象,只能生存到下一次GC之前,当GC发生时,无论无论当前内存是否足够,都会回收掉被弱引用关联的对象。JDK1.2之后,提供了WeakRefernce类来实现弱引用。
- 虚引用:是最弱的一种引用,一个对象有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置一个虚引用关联的唯一目的就是能够在这个对象呗收集器回收的的时候收到一个系统的通知。
(2).Stop The World
有了上面的垃圾对象的判定,我们还要考虑一个问题,那就是Stop The World。垃圾回收的时候,需要保持整个引用状态不变:假设一个对象没有被标记到,或者没有与GC Roots产生关联,那么他被判定为垃圾,需要回收;但是等我一会执行回收的时候,他又被别的对象引用了——这样的话整个GC过程就无法执行了。
因此,在GC的过程中,其他所有程序进程处于暂停状态,也就是俗称的卡住了。所有的GC卡顿问题均由此而来,这个暂时无法解决。幸运的是,这个卡顿是非常短暂的,尤其是在Java堆的新生代(待会会讲),对程序的影响微乎其微。
(3).几种辣鸡回收算法
①.标记清除算法 (Mark-Sweep)
标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。
这种算法的缺点是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。
②.复制算法 (Copying)
复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的另一半内存空间中的对象一次性全部清理掉,这样一来就不容易出现内存碎片的问题。
这种算法的优点就是,实现简单,运行高效且不容易产生内存碎片;缺点也显而易见:将可用内存缩小为了原来的一半,代价非常高昂。
从算法原理我们可以看出Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低(要复制的对象比较多)。
③.标记整理算法 (Mark-Compact)
该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
这种算法特别适用于存活对象多,回收对象少的情况,因为回收的对象少,标记完了之后需要移动的对象就相对较少。
④.分代回收算法
当前的商业虚拟机的垃圾收集器都采用“分代收集”算法,这种算法并没有什么新的思想,只是根据对象的存活的周期不同将内存划分为几块。
前面我们说过,复制算法:适用于存活对象很少,回收对象多;标记整理算法:适用于存活对象多,回收对象很少的情况。这两种算法情况正好互补!
一般情况下我们把Java的对分为新生代和老年代,在新生代,每次垃圾收集时,都会有大批的对象死去,只有少量存活,因此适用复制算法;而在老年代,因为对象存活率高、没有额外的空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来进行回收。
3.Java堆内存模型
上面我们已经简略的说过Java堆和Dalvik堆的区别,这里我们复习一下:Java堆用于存放对象实例,几乎所有的对象(实例变量,数组)都在该区域分配,是内存回收的主要区域;Dalvik将堆分成了Active堆和Zygote堆,zygote堆是Zygote进程在启动时的预加载的类、资源和对象;除此之外所有的对象,包括我们在代码中创建的实例、静态域和数组,都是储存在Active堆里边。
- Java堆按照对象存活的时间可分为新生代和老年代
- 新生代又分为三个部分:一个内存较大的Eden区,和两个内存较小且大小相同的Survivor区,比例为8:1:1.
- Eden区存放新生的对象
- Survivor存放每次垃圾回收后存活的对象
(1).新生代又分为三个部分:一个内存较大的Eden区,和两个内存较小且大小相同的Survivor区,比例为8:1:1.
对象的内存分配,主要分配在新生代的Eden(伊甸园)区上,当Eden区没有足够的空间进行分配时,虚拟机将发起一次“复制算法”的GC,在这个过程中,存活下来的对象被放到Survivor 0区;当第二次GC来临的时候,Survivor 0空间的存活对象也需要再次用复制算法,放到Survivor 1空间,二把刚刚分配对象的Survivor 0空间和Eden空间清除;第三次GC时,又把Survivor 1空间的存活对象复制到Survivor 0的空间,就这样来回倒腾。
通过上面的分析我们不难理解新生代为什么这么分配了:Eden区是对象分配的主要区域,这是很频繁的,尤其是大量的局部变量产生的临时对象,因此他占的比例为8/10,至于为什么是8,这个我也不是很清楚,我们只需要知道这个区域确实占了很大比例就行;这个区域分配的对象大多数都是“朝生夕灭”,因此存活下来的对象较少,故采用“复制算法”; 至于两个Survivor的比例为什么是1:1,这个应该很好理解。
(2).什么样的对象会被移入老生带?
①.新生代中经历过15次GC的对象
虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次GC后仍然存活,将被移动到Survivor空间中,并且对象的年龄设为1;对象在Survivor区中每“熬过”一个GC,年龄就增加1岁,当它年龄增加到一定程度(默认为15岁),就会晋升到老年带中。
②.大对象直接进入老年代
所谓大对象是指,需要连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组,虚拟机提供了一个PretenureSizeThreshold参数,令大于这个这个值的对象直接在老生代中分配。这样做主要是为了避免在Eden区和两个Survivor区之间复制算法执行的时候产生大量的内存复制。
4.触发GC的类型
了解这些是为了解决实际问题,Java虚拟机会把每次触发GC的信息打印出来来帮助我们分析问题,所以掌握触发GC的类型是分析日志的基础。
GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC。
GC_CONCURRENT: 当我们应用程序的堆内存达到一定量,或者可以理解为快要满的时候,系统会自动触发GC操作来释放内存。
GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。
GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC。
5.安卓分配与回收
Android系统并不会对Heap中空闲内存区域做碎片整理。系统仅仅会在新的内存分配之前判断Heap的尾端剩余空间是否足够,如果空间不够会触发gc操作,从而腾出更多空闲的内存空间。
在Android的高级系统版本里面针对Heap空间有一个Generational Heap Memory的模型,这个思想和JVM的逐代回收法很类似,就是最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后累积一定时间再移动到Permanent Generation区域。系统会根据内存中不同的内存数据类型分别执行不同的gc操作。
站在巨人的肩膀上摘苹果:
理解Java垃圾回收机制
从虚拟机视角谈 Java 应用性能优化
Dalvik 虚拟机和 Sun JVM 在架构和执行方面有什么本质区别?
JVM、DVM(Dalvik VM)和ART虚拟机对比
《深入理解Java虚拟机 第2版》