一个iOSer对Java GC垃圾回收的理解

iOS和android系统流畅性的关键-内存管理

Posted by renchao on July 31, 2017

一个iOSer对Java GC垃圾回收的理解

前言

对于用OC写的iOS程序来说,内存管理机制是非常重要的内容。OC中采用引用计数的机制,对象的引用计数器会因被引用而加1,被释放而减1。当引用计数为0时,便将该内存收回。这就要求程序员在合适的地方加上计数语句,非常麻烦。还好后来出现了ARC自动引用计数,编译器自动加语句而让程序员的工作大大减少,但是对于C对象还是要手动管理。这种机制的优点是简单,后期的使用也会因内存的合理分配而流畅。缺点是会造成循环引用,内存泄露。所以要注意内存泄漏问题。

那么基于Java的安卓内存管理是怎样的呢?

判断对象是否死亡

1.引用计数算法

同iOS内存管理机制,不再重复。会造成内存泄漏,无法回收。

2.可达性分析算法

是通过一些可以称为GCRoots的对象作为起始点,从这些节点开始向搜索,搜索所走的路径称为引用链,当一个对象叨叨GCRoots没有任何引用链相连时,就证明对象是不可用的。可以作为GCRoots的对象有四种:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象。

即使在可达性分析算法中不可达的对象,也并非是真正的非死不可的,它是处于一个缓刑阶段,要真正判断一个对象死亡,至少要经历两次标记的过程:可达性分析不可达的对象将被第一次标记,并触发finalize方法,在这个方法里会再次查找是否该对象与gcroot链上的对象有关系,若有关系,则不会回收该对象,否则,被第二次标记,判定为可回收对象。

如果对象在进行可达性分析后没有发现与GCRoots相连接的引用链,那么它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否覆盖finalize方法,当对象没有覆盖finalize方法或者finalize方法被虚拟机调用过,就认为没有必要执行。如果对象没有必要执行finalize方法,那这个对象就会被放置在F-Queue的队列中,并且稍后由一个虚拟机自动建立,低优先级的Finalizer线程去执行。finalize方法是对象逃脱死亡的最后一次机会,稍后GC会对F-Queue中的对象进行二次小规模的标记,只要对象在finalize中重新与引用链上的任何对象建立连接就行,在第二次标记的时候就会被移除队列,如果对象还没有逃脱那么就会真的被回收了。

finalize方法:

finalize()是Object的protected方法,子类可以覆盖该方法以实现资源清理工作,GC在回收对象之前调用该方法。 finalize()与C++中的析构函数不是对应的。C++中的析构函数调用的时机是确定的(对象离开作用域或delete掉),但Java中的finalize的调用具有不确定性。

finalize的工作原理应该是这样的:一旦垃圾收集器准备好释放对象占用的存储空间,它首先调用finalize(),而且只有在下一次垃圾收集过程中,才会真正回收对象的内存。所以如果使用finalize(),就可以在垃圾收集期间进行一些重要的清除或清扫工作.

finalize()在什么时候被调用?有三种情况: 1.所有对象被Garbage Collection时自动调用,比如运行System.gc()的时候. 2.程序退出时为每个对象调用一次finalize方法。 3.显式的调用finalize方法

除此以外,正常情况下,当某个对象被系统收集为无用信息的时候,finalize()将被自动调用,但是jvm不保证finalize()一定被调用,也就是说,finalize()的调用是不确定的,这也就是为什么sun不提倡使用finalize()的原因。

总之,finalize相当于析构函数,他是垃圾回收器回收一个对象的时候第一个要调用的方法。不过由于Java的垃圾回收机制能自动为我们做这些事情,所以我们在一般情况下是不需要自己来手工释放的。

回收算法

回收算法可大致分为四个:标记清除、标记整理、复制算法和分代算法。目前流行的算法是这样的:将内存区域分代,分成新生代和老年代,在新生代中执行复制算法,在老年代中执行标记清除和标记整理算法。

1.标记-清除算法

该算法主要用于老年代。原因是老年代的对象大多是不会被清除的。标记清除算法分为标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它主要有两个缺点:

  • 效率问题,标记和清除连个过程的效率都不高。
  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到连续内存而不得不提前触发另一次GC。

2.标记-整理算法

主要用于老年代。先对对象进行标记,然后将所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这是对标记-清除算法的优化措施,防止碎片化。新生代对象较多,若使用这种算法,效率不高。

3.复制算法

主要用于新生代。为了解决效率问题,我们引入了一种复制算法,它将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了就将还存活的对象复制到另一块中,然后再把已经使用过的内存一次清理掉。这样使得每次只对整个半区内存回收,也就不用考虑标记清除带来的碎片问题。它的优点是实现简单,运行高效。但是缺点内存代价太高,每次要花费一般的内存空间,当收集存活率比较高的对象时,就要进行较多次的复制,效率就会变得低下。

4.分代收集算法

分代收集算法就是根据各个年代的特点采用最适当的算法来收集。在新生代中,每次回收都有大量的对象死去,只有少量的对象存活,所以选择用复制算法,只需要付出少量存活对象的空间就可以完成收集。而在老年代中对象的存活率非常的高,没有额外空间对它进行分配,所以使用标记清除或者标记整理算法来完成回收。

接下来主要说说分代的回收机制:

对象分代

JVM使用分代回收,是因为:不同的对象,生命周期是不一样的。如果不进行存活时间的区分,每次都对整个堆空间进行回收,花费时间相对会长,这对于生命周期长的对象来说是没有效果的,因此不同生命周期的对象采用不同的收集方式可以提高垃圾回收效率。

img

如上图所示,将整个堆空间共划分为三个代:年轻代,老年代和持久代。其中持久代主要存放Java类的类信息,与垃圾回收要收集的java对象关系不大。年轻代和年老代的划分对垃圾收集影响比较大。

年轻代

所有新生成的对象都在年轻代。新生代存放大量生命周期很短的对象,年轻代的目标就是尽快收集那些生命周期短的,朝生夕死的对象。年轻代分为三个区:Eden区、Survivor1区和Survivor2区(内存大小大概是8:1:1)。对象会在Eden区和一个Sur区,另一个区不会被使用。当这两个区满时,还存活的对象将被复制到一个Sur区,并将那两个区进行垃圾回收。当这个区满时,此区的存活对象将被复制到另一个Sur区,但这个区也满了时,将从第一个区复制过来并且此时还存活的对象复制到老年区。需要注意,Sur的两个区是对称的,没有先后关系,所以同一个区中可能同时存在从Eden区复制过来的对象,和从 前一个Sur复制过来的对象,而复制到老年区的只有从第一个Sur区过来的对象,而且Sur区总有一个是空的。如果这些对象的年龄标记到达15后还不满,自动放入老年区,或者同样年龄的对象超过一个sur区的一半,也会自动进入老年区。

老年代

在年轻代N次垃圾回收都幸存下来的对象将会进入老年代,此时的对象都是生命周期较长的对象。

持久代

用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或 者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新 增的类。持久代大小通过-XX:MaxPermSize=进行设置。

1.对象优先在Eden区分配

大多数情况下对象在新生代Eden中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次GC。

2.大对象直接进入老年代

所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组,让大对象直接在老年代分配,这样的目的是避免Eden和两个Survivor区之间发生大量的内存复制。

3.长期存活的对象将进入老年代

虚拟机给每一个对象定义了一个年龄计数器,如果对象在Eden出生并经过了第一次GC仍然存活,就被Survivor容纳。进入Survivor空间中,并且对象年龄设为1,对象在Survivor中熬过一次GC年龄就增加为1岁,当它的年龄增加到一定程度时(默认为15岁)就会晋级为老年代。

4.动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到那个标志才晋级老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到默认的年龄15再进入。

什么时候触发垃圾回收

1.JVM空闲的时候 2.显式调用system.gc() 3.空间满时

Scavenge GC

一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC, 清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对 年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很 大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲 出来。

Full GC

对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个对进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调 节。有如下原因可能导致Full GC:

  • 年老代(Tenured)被写满
  • 持久代(Perm)被写满
  • System.gc()被显示调用