jvm内存的分配与回收你不得不懂的

256 阅读11分钟

1、前言

也许你会说jvm的知识对于Android开发者来说日常开发中根本不会涉及到。其实不然,熟悉jvm的运行原理,能指导我们写出更高性能的代码,同时对于Android高级开发工程师来说这在面试中也是必考的内容。因此我们对它必须要有一个基本的概念。

2、运行时数据区

jvm在执行java程序的过程中会把它所管理的内存区域划分为5个数据区。每个区都有各自的能力和职责:

jvm.png

2.1 程序计数器

程序计数器是一块比较小的内存空间。java的多线程执行是通过CPU给每个线程分配时间片来实现的,一个时间片大概也就几十毫秒,线程A使用完时间片之后就会切换到其他拿到CPU时间片的线程去,当线程A再次获得时间片是怎么继续之前的执行进度的呢,这就是程序计数器的作用了,为了确保线程切换后能恢复到正确的执行位置,每条线程都有个线程专属的程序计数器用于记录当前线程执行的指令码地址。

如果执行的是java方法,程序计数器记录的是虚拟机字节码指令的地址,如果执行的是Native方法,记录的为空。

同时程序计数器是是唯一一个不会发生OOM异常的区域。

2.2 java虚拟机栈

java虚拟机栈就是我们日常口头常说的“堆栈”中的栈,他描述的是java方法执行的内存模型。方法执行的会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法调用到结束,对应的就是一个栈帧在虚拟机栈中进栈和出栈的过程。java虚拟机栈也是线程私有的。

如果栈的深度大于jvm所允许的最大深度,将会抛出StackOverflowError异常。当栈申请不到足够的内存,将会抛出OutOfMemoryError异常。

2.3 本地方法栈

本地方法栈是为本地方法(Native)服务的,其特性和java虚拟机栈几乎一样。

2.4 堆

堆就是我们常说的“堆栈”中的堆,也叫GC堆,是垃圾回收发生的主要场所。它是所有线程都共享的一块内存区域,几乎所有的对象实例都在这里分配内存。不过随着jvm优化技术的发展,现在基于逃逸分析的技术已经可以实现栈上分配的的优化方案了。下一节会对对象分配做个详细的介绍。

同样当无法申请到足够内存时,会抛出OOM异常。

2.5 方法区

方法区也是所有线程共享的区域。它用于存储被jvm加载的累的信息,常量、静态变量、即时编译器编译后的代码缓存等数据。

3、java对象分配

上面在讲述堆的时候说到“几乎”所有的对象都在堆里分配内存。注意这里用的是“几乎”而不是“所有”。由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换等优化手段导致堆上分配并不是那么绝对的一件事情了。

3.1 栈上分配

所谓栈上分配就是将我们的对象分配到栈上,栈上分配的前提是通过逃逸分析,对象的作用域不会逃逸出当前方法,jvm就会通过标量替换的手段将对象分配到栈上。通过栈上分配就不需要GC的介入去回收这个对象,对象会随着方法的出栈而释放。

3.2TLAB

TLAB即Thread Local Allocation Buffer,线程本地缓存区,是线程私有的一块内存区域。 对于不能栈上分配的对象会在堆中进行分配,而堆是所有线程共享的一块区域,因此可能出现多个线程同时申请同一块内存的情况。故而申请内存的时候要进行同步操作,jvm一般采用CAS来保证操作的安全性,CAS通过自旋是需要消耗CPU的性能的,在竞争激烈的场景下效率会比较低。因此jvm引入了TLAB来避免多线程的冲突,在分配对象的时候每个线程使用自己线程私有的TLAB,提升了内存分配的效率。

TLAB本身也是从堆中划出来的一块空间,这块空间一般不会很大,因此大对象是无法在TLAB上进行分配的。假如我们的TLAB的大小是100kb,当前已经使用了80kb,这是时候来了一个对象需要申请30kb的内存,由于TLAB只剩下20kb的余量,因此是无法完成内存分配的。这个时候jvm就会有两种选择:

  1. 放弃TLAB,直接在堆内存中进行内存分配;
  2. 放弃当前的TLAB,重新申请TLAB空间再次进行内存分配; 两种方案都存在问题,方案一浪费了TLAB剩下的内存空间。方案二可能会导致频繁的申请TLAB,但是TLAB从堆内存划出来也会存在冲突,这就和使用TLAB的初衷相违背了。

为了解决两个方案存在的问题,虚拟机定义了一个refill_waste的值,可以理解为“最大浪费空间”。

当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。

前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。

3.3 内存分配方法

java中主要有两种内存分配方式:

  • 指针碰撞:假设内存是绝对规整的,已分配的内存在一侧,空闲可用的内存在另外一侧,中间使用一个指针来作为两边界的指示器。当新对象需要申请内存的时候,只需要把指针往空闲的一侧移动对象需要申请大小相等的距离。
  • 空闲列表:假设内存不是规整的,碎片化严重,已使用和空闲的交错在一起,那就没办法使用指针碰撞的分配方式了,虚拟机必须维护一个列表,记录那些内存时可以使用的,在分配对象的时候从列表中找到一块足够大可以分配当前对象的内存,然后更新列表记录。

使用哪种分配方式其实是和当前虚拟机采用的何种垃圾回收息息相关的。像使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

3.4内存分配总结

obj_mem.png 注:图片来源于网络

java对象整体的分配流程:

  1. 首先会进行逃逸分析判断对象是否符合栈上分配,如果符合的话就会通过标量替换等手段将对象分配在栈上;
  2. 判断对象是不是大对象,大对象则直接分配到老年代;
  3. 不是大对象尝试使用TLAB进行分配;
  4. TLAB分配不成功则会在堆内存的Eden区进行分配;

4、垃圾回收机制

我们的内存空间是有限的,因此我们必须对“无用”的对象进行回收从而释放它所占用的内存,也就是GC。要进行垃圾回收必须要先判断出哪些是垃圾,那么到底是怎么做的呢?找到垃圾之后又是怎么回收的呢?

4.1 垃圾定位算法

4.1.1引用计数法

给对象添加一个引用计数器,每当有一个地方引用它时,引用计数器数值加1,当取消对它的引用时,引用计数器数值减1。如果引用计数器为0了,就说明这个对象没有被任何地方引用,那它就是一个无效的对象,也就是我们找到的“垃圾”。

引用计数法原理简单,效率很高,是一个很不错的算法。但是java虚拟机并没有采用它来管理内存,主要原因是它解决不了循环引用的问题,比如两个对象objA和objB,objA持有了objB的引用,objB持有了objA的引用,因此两个对象的引用计数器就都不会是0,objA和objB除了被对方引用外再无其他的对于引用,但是它们就永远不会被回收掉。

4.1.2、可达性分析算法

当前主流的对象存活判断算法。可达性分析算法的思想就是通过一些被称为“GC Roots”的根对象作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象并不在引用链上,或者说从GC Roots到这个对象不可达时,则证明这个对象是一个“垃圾”对象。

可以当做GC Roots的对象主要是:

  • java虚拟机栈(本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象,如java类中静态变量。
  • 方法区中常量引用的对象,譬如字符串常量池里的引用。
  • 本地方法JNI所引用的对象。
  • java虚拟机内部的引用,如基本数据类型对于的Class对象,一些常驻的异常对象(比如NullPointException)等。
  • 所有被同步锁持有的对象。

4.2 垃圾回收算法

4.2.1 分代收集思想

虚拟机作者会把java堆至少分成新生代(Young Generation)和老年代(Old Generation)。其中新生代又分为Eden、From Survivor和To Survivor三块空间。Eden和两个Survivor区的大小比例是8:1:1。

大部分新建对象会在Eden中进行内存分配。当Eden区无法申请到足够的内存会触发Minor GC,将Eden和From Survivor中存活下来的对象复制到To Survivor,对象的age+1,同时将From Survivor和To Survivor位置对换,也就是说原来的From Survivor变成了To Survivor,To Survivor变成了From Survivor。一直持续这个过程,当age大于设定的某个数值(一般是15左右),如果对象还存活,就将对象转到老年代中去。

4.2.2 复制算法

复制算法。将内存分为两块区域,每次只使用其中一块区域,当发生垃圾回收的时候,把存活的对象复制到另外的区域。因此复制算法的缺点也就比较明显,每次只能使用部分内存,会浪费掉另外一半内存。IBM公司曾经做过研究发现新生代中的对象98%熬不过第一轮收集,因此HotSpot虚拟机将新生代按8:1:1的比例分成一个Eden和两个Survivor区,发生回收时,会将Eden和From Survivor中存活的对象复制到To Survivor,这样每次可以利用的空间就可以达到90%,只有10%的空间会被“浪费”。

image.png 图片来源于网络

4.2.3标记-清除算法

标记-清除是最早出现的算法也是最基础的算法,根据它的名字,算法分为“标记”,“清除”两个部分:首先标记出所有需要被回收的对象,再标记完成之后统一回收被标记的对象。当然也可以反过来,标记存活的对象,回收未被标记的对象。这个方法比较明显的缺点就是回收之后会导致内存的碎片化比较严重,从而导致之后大对象的分配很难获得连续的空间进行频繁的触发GC。

image.png图片来源网络

4.2.4 标记-整理算法

复制算法在对象存活率比较高的场景下性能就比较低了,因为会涉及到大量对象的复制,而复制这个操作本来就是比较消耗性能的。老年代的特点就是对象的声明周期比较长很难被回收,因此复制算法肯定不合适,就出现标记整理算法,首先标记出所有需要被回收的对象,然后将存活的对象往内存的一侧移动,最后直接清理掉边界之外的对象。

image.png图片来源于网络