(四)垃圾回收算法与垃圾收集器 —— 优化内存抖动

Java 发表评论


版权声明:本文为博主原创文章,未经博主允许不得转载。
本文纯个人学习笔记,由于水平有限,难免有所出错,有发现的可以交流一下。

一、垃圾回收算法

虚拟机在对对象进行回收前,需要对垃圾进行采集,不同的虚拟机实现可能使用不同的垃圾收集算法,不同的收集算法的实现也不尽相同。不同的算法各有各的优劣势。

1.标记-清除算法 Mark-Sweep

Mark-Sweep 算法分为标记和清除两个部分,会先标记出所有需要回收的对象,然后统一进行回收。

这里写图片描述

缺点:
Mark-Sweep 算法的标记和清除过程效率都不高。另外, Mark-Sweep 算法没有进行对象的移动,只是单纯的进行对象回收,这样很容易造成内存碎片。

如上图,如果这时候我们需要创建一个 10 格子大小的内存对象,虽然有足够的内存空间,但是没有连续的 10 个格子内存,这时候是无法申请下来的,OOM。

2.复制算法 Copying

Copying 算法是将内存分成相等的两块区域,然后使用其中的一块。当这块内存不足时候,将存货的对象复制到另一块上,然后清除已使用的这块上所有对象。

这里写图片描述

缺点:
这样虽然不会产生内存碎片这种问题,但是他的缺点也很明显:内存只有原来的一半。特别是当被使用的一半内存中,对象存活率较高的时候,需要复制的对象,以及复制的次数增加,导致效率低下。

如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况。

3.标记整理算法 Mark-Compact

考虑到 Copying 算法会浪费一半的内存,出现了 Mark-Compact 算法。用 Mark-Sweep 算法中的标记过程,然后让所有存活的对象移到一端,然后直接清理掉端边界以外的内存。

这里写图片描述

缺点:
Mark-Compact 算法虽然没有内存碎片问题,也不会浪费一半内存,但是它也引用了额外的开销,比如说额外的空间来保存迁移地址,需要遍历多次堆内存等。

二、分代收集算法

无论是一般的 JVM 还是 DVM,不会只使用一种垃圾收集算法。它会根据内存的划分实现不同的收集算法。基于不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

在 Java 程序运行的过程中,会产生大量的对象,因每个对象所能承担的职责不同所具有的功能不同所以也有着不一样的生命周期,有的对象生命周期较长,比如 Android中 的 Application、启动的 Service等;有的对象生命周期较短,比如一些函数内部 new 出来的 String 对象。

这里写图片描述

1.新生代 Young Generation

现在的商业虚拟机都采用 Copying 算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间。

1.新生代内存按照 8:1:1 的比例分为一个 Eden Space (亚丹)区和两个 Survivor(夏娃)区,From Space 和 To Space。

2.新建的对象都是在新生代分配内存。

  • 首先,每次使用 Eden 区和一块 Survivor 区 From Space。(即每次新生代可用内存为 90%)

  • 当 Eden 和 From Space 满时,触发新生代回收 Minor GC ,将 Eden 和 From Space 中还存活着的对象一次性地复制到 To Space 空间上,最后清理掉 Eden 和刚 From Space 空间。然后交换 From Space 和 To Space。

  • 每一次 Minor GC,把存活下来的对象年龄 +1,当某个对象的年龄达到老年的标准,就移到老年代中。

3.上面的 98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion),这些对象将直接通过分配担保机制进入老年代。

4.通过担保,如果这时候老年代内存也满了,会触发老年代的回收。新生代和老年代一起回收(Full GC)。

5.新生代采用 Copying 算法。

2.老年代 Old Generation

1.用于存放新生代中经过 N 次垃圾回收仍然存活的对象,以及 To From 不足时候,担保的对象。

2.内存比新生代也大很多(大概比例是1:2)。

3.当老年代内存满时触发回收 Major GC 。

4.老年代采用 Mark-Compact 算法。

3.永久代 Permanent Generation

1.主要存放所有已加载的类信息,方法信息,常量池等等。

2.并不等同于方法区,只不过是主流的 Sun 公司的 Hotspot JVM 用永久代来实现方法区而已,有些虚拟机没有永久带而用其他机制来实现方法区。

3.这个区域存放的内容与垃圾回收要回收的Java对象关系并不大。

三、垃圾收集器

垃圾收集器就是内存回收的具体实现。 Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、 不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。

这里写图片描述

注: 虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。如果两个收集器之间存在连线,就说明它们可以搭配使用。

1.Serial (串行)收集器

最基本,历史最悠久的收集器。曾经是 JVM 新生代的唯一选择。这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束 。

这里写图片描述

它的缺点也很明显,会 ‘Stop The World’。也就是会把我们的程序暂停。

但是单线程也意味着它非常简单高效,没有多余的线程交互,专心收垃圾就可以了。所以在 client 版本的 java 中是默认的新生代收集器。

2.ParNew 收集器

Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外。其他的行为和 Serial 一样。

这里写图片描述

ParNew 收集器是 server 版本的虚拟机中首选的新生代收集器。因为除了 Serial 就他可以和 CMS 配合。

3.Parallel Scavenge 收集器

同样是新生代的收集器,也同样是使用复制算法的,并行的多线程收集器。

这里写图片描述

它与 ParNew 等其他收集器差异化的地方在于,它的关注点在控制吞吐量,也就是 CPU 用于运行用户代码事件于 CPU 总消耗时间的比值。所以吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。虚拟机总共运行100分钟,其中垃圾回收花掉1分钟,则吞吐量为 99/99+1 = 99%。

而吞吐量越高表示垃圾回收时间占比越小,CPU 利用效率越高。所以这个收集器也被称为”吞吐量收集器”. 高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;

4.Serial Old收集器

老年代版本的串行收集器,使用标记整理算法。

这里写图片描述

5.Parallel Old 收集器

老年代版本的 Parallel Scavenge 收集器,使用多线程采集,标记整理算法。

这里写图片描述

6.CMS 收集器

Concurrent Mark Sweep 收集器是一种以获得最短回收停顿事件为目标的收集器,也称为并发低停顿收集器或低延迟垃圾收集器;。从名字也能看出使用的是标记清除算法。

这里写图片描述

CMS收集器的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)
    仅标记一下GC Roots能直接关联到的对象,速度很快;但需要”Stop The World”;

  • 并发标记(CMS concurrent mark)
    进行 GC Roots 追踪的过程;刚才产生的集合中标记出存活对象;应用程序也在运行;并不能保证可以标记出所有的存活对象;

  • 重新标记(CMS remark)
    为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;需要 “Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短;采用多线程并行执行来提升效率;

  • 并发清除(CMS concurrent sweep)
    回收所有的垃圾对象;

优点:
CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。

缺点:

  • 造成 CPU 资源紧张:
    从图中可以看到会比其他收集器多开线程

  • 无法处理浮动垃圾
    由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。

    因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

    要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

  • 大量内存碎片
    采用 标记—清除”算法,产生大量碎片。

7.G1收集器

Garbage-First 收集器是当今收集器技术发展最前沿的成果之一,是一款面向服务端应用的垃圾收集器。

这里写图片描述

图中可以看到和 CMS 差不多,但是 G1 的采集范围是整个堆(新生代老生代)。他把内存堆分成多个大小相等的独立区域,在最后的筛选回收的时候根据这些区域的回收价值和成本决定是否回收掉内存。

四、内存抖动

内存抖动是指内存频繁地分配和回收,而频繁的 GC 会导致卡顿,严重时和内存泄漏一样会导致 OOM。

1.抖动导致 OOM

Dalvik 虚拟机主要使用标记清除算法,也可以选择使用拷贝算法。这取决于编译时期:

这里写图片描述

ART 是在 Android 4.4 中引入的一个开发者选项,也是 Android 5.0 及更高版本的默认 Android 运行时。google 已不再继续维护和提供 Dalvik 运行时,现在 ART 采用了其字节码格式。

ART 有多个不同的 GC 方案,这些方案包括运行不同垃圾回收器。默认方案是 CMS。

结论:不论是 Dalvik 虚拟机默认使用标记清除算法,还是 ART 虚拟机默认使用有 CMS 收集器,CMS 收集器使用的也是标记清除算法,所以都容易在内存抖动时候导致 OOM。

2.检测内存抖动

内存抖动在 Android Profile 中表现为:

这里写图片描述

在 Android Studio 中点击 memory profiler 中的红点录制一段时间的内存申请情况,再点击结束。

这里写图片描述

可以在下方看到,在这段时间内,各个类型对象创建的个数和占用的内存。

点击各个类型,可以显示其具体对象,在点击具体对象,可以显示其具体创建的地方。我们可以对占较多内存、或者个数较多的对象进行查看,分析其原因,对其进行优化,这样就可以避免内存抖动。

3.优化

对于基于内存抖动,我们主要需要注意是:

1.尽量避免在循环体或者频繁调用的函数内创建对象,应该把对象创建移到循环体外。

2.另外还有一个经典的 String 拼接创建大量小的对象造成的内存抖动。

String string = "a";
string += "b";
string += "c";

这里会产生 3个 字符串对象, “a”、”ab”、 “abc”,,实际上我们需要的只有最后一个 “abc” 这个对象,但是由于中间采用 + 号进行拼接字符串,导致产生了较多无用的字符串常量,可以使用 StringBuilder 进行优化。

注: String string = “a” + “b” + “c” ; 如果一次性拼接完,这样是只会产生一个字符串常量。

五、查看可用内存

OOM 就是申请的内存超过了Heap的最大值。

但是,OOM 的产生不一定是一次申请的内存就超过了最大值,导致 OOM 的原因基本上都是我们的不良代码平时”积累”下来的。

我们知道Android应用的进程都是从一个叫做Zygote的进程fork出来的。并且每个应用android会对其进行内存限制。我们可以 查看 /system/build.prop 中的对应字段来查看我们 app 的最大允许申请内存。

代码查看:

ActivityManager mActivityManager = (ActivityManager)getSystemService(Context.ACTIVITY_SERVICE);
int memoryClass = mActivityManager.getMemoryClass();
int largeMemoryClass = mActivityManager.getLargeMemoryClass();
Log.e(TAG, "111 zx onCreate: " + memoryClass + " " + largeMemoryClass);

这里写图片描述

getMemoryClass():是系统为应用分配的内存,并没有额外的扩充。

getLargeMemoryClass() :单个虚拟机可分配最大内存。而 Android 上的应用是带有独立虚拟机的,也就是每开一个应用就会打开一个独立的虚拟机。也就是说这是单个应用的最大内存

命令行查看:

adb shell getprop dalvik.vm.heapsize

adb shell getprop dalvik.vm.heapgrowthlimit

这里写图片描述

vm.heapsize:对应 getMemoryClass,

vm.heapgrowthlimit:对应 getLargeMemoryClass,

注:正常情况下 dvm heap 的大小是不会超过 dalvik.vm.heapgrowthlimit 的值。在 Android 开发中,如果要使用大堆,需要在 manifest 中指定 android:largeHeap 为 true,这样 dvm heap 最大可达 heapsize。

但是作为开发者,建议不要配置 manifest 获取最大内存,这使得应用在扩展性方面有很大限制,建议优化内存已达到节省内存的目的。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

昵称 *