Android开发几乎每个项目都会用到Okio,OkHttp,Glide等,而这些开源库几乎都大量使用的内存池来提高性能。我们都知道,内存池主要的作用就是防止频繁的申请和回收内存引发的内存抖动问题,但内存抖动在那些方面降低了应用的性能呢?接下老我经就这个问题谈一下自己的理解。水平一般,能力有限,如有错误,麻烦不吝赐教,先谢过了。
这篇文章主要围绕一下两个问题展开
- 为什么频繁的内存申请与回收比较消耗性能
- 内存池除了降低了内存分配和回收的频率,还有那些好处
通过这篇文章,我希望能达到两个目的:
- 加深对JVM内存模型的理解
- 能更容易记住JVM的内存模型
JVM Heap的内存模型
上图就是JVM Heap的内存模型图。
JVM将Heap的内存划分为一个Eden区,两个大小相同的 Survivor 区 S1 和 S2,这三块区域统称为新生代内存区域。最后还有一个比较大的Old(老年代)区。
对象创建过程中的资源消耗
当我们创建对象的时候,单纯从 Java 代码层次来看,这是一个很简单的过程,无非就是在 Heap 内存中申请一块指定大小的内存,然后调用指定的 init 方法,对这块内存进行初始化。然而这个看似简单的过程背后需要 JVM 做各种各样的处理,有些步骤相当消耗性能。接下来,我们来仔细分析下这个过程。
- 检测是否是LargeObject,如果是,则在 LargeObjectSpace 申请内存,如果不是,则在 mainspece 申请内存
- 确定好内存的分配区域后,会查找对应区域,找到一块和申请的内存一样大的连续的内存空间,若找到,则直接分配,否则需要触发一次gc
- gc 完成后,重复执行第二步
- 另外对于多线程的场景下,经常会出现多个线程同时创建对象的情况,这时他们可能会同时在同一块内存区域申请内存,为了防止他们申请到重叠的内存区域,JVM 必须保证整个对象创建过程是线程安全的。
JVM的内存分配与回收策略
-
对象优先在Eden分配,大多数情况下,对象在新生代Eden区中分配。
-
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组(例如byte数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个环消息,而比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”。经常出现大对象容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
- 长期存活的对象直接进入老年代
如果对象在Eden出生并经过第一次 Minor GC 后仍然存活,并且能被 Survior 容纳的话,将被移动到 Survior 空间中,并且对象年龄设定为1。对象在 Survior 区中每“熬过”一次 Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。
- 当 Eden 区没有足够空间进行进行分配时,虚拟机将发起一次 Minor GC(新生代GC)。Minor GC 采用的是“复制算法”。
复制算法 复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 当 Old 区没有足够空间进行分配时(应用为大对象申请内存或者接收年轻代中年龄增加到一定程度的对象),虚拟机将会发起一次 Major GC (老年代GC)。因为 Old 区比较大,并且采用的是标记回收的算法,所以速度比较慢,一般会比 Minor GC 慢10倍以上。
当然还有,动态对象年龄判断,空间分配担保等策略,不是我们这期讨论的重点,有兴趣的朋友可以看《深入理解JVM虚拟机》这本书。
-
当多个线程同时申请内存的时候,为了保证多个线程不会分配到相同或有交叉的内存,JVM 必须对申请内存的操作做同步处理,如果一个多线程的应用频繁申请内存,会降低应用的性能。
-
内存回收是 “Stop the world” 的,在内存回收的过程中,必须停顿所有 Java 执行线程。所以频繁的内存回收肯定会降低应用的性能。
总结
我们以 Okio 为例。Okio 提供了 SegmentPool 来避免频繁创建和回收对象带来的性能消耗。
- SegmentPoll 复用了 Segment, 减少了 Segment 对象的频繁创建。
- Segment 属于大对象,分配的时候会直接进入老年代区域,即使分配在了年轻代,经过几次 Minor GC,也会晋升到老年代,而老年代 Major GC 的频率比 Minor GC 的频率低的多。
所以内存池可以在内存申请和回收两个方面极大的提升程序的效率。