@IT·互联网Android开发经验谈Android

一篇文章带你了解 Java 自动内存管理机制及性能优化

2018-07-15  本文已影响420人  涤生_Woo

同样的,先来个思维导图预览一下本文结构。

一图带你看完本文

一、运行时数据区域

首先来看看Java虚拟机所管理的内存包括哪些区域,就像我们要了解一个房子,我们得先知道这个房子大体构造。根据《Java虚拟机规范(Java SE 7 版)》的规定,请看下图:

Java 虚拟机运行时数据区

1.1 程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

1.2 Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。请看下图:

Java 虚拟机栈
1.2.1 虚拟机栈溢出
  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
  2. 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。

1.3 本地方法栈

1.4 Java 堆

Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存(但是,随着技术发展,所有对象都分配在堆上也渐渐变得不是那么“绝对”了)。请看下图:

Generational Heap Memory 模型
1.4.1 Java 堆溢出

1.5 方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

1.5.1 运行时常量池

1.6 直接内存

二、内存分配策略

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过 JIT 编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

2.1 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC 。举个例子,看下面的代码:

private static final int _1MB = 1024 * 1024;

    /**
     * VM 参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     */
    private static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];//出现一次 Minor GC
    }

执行上面的testAllocation() 代码,当分配 allocation4 对象的语句时会发生一次 Minor GC ,这次 GC 的结果是新生代 6651KB 变为 148KB ,而总内存占用量则几乎没有减少(因为 allocation1、allocation2、allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次 GC 发生的原因是给 allocation4 分配内存时,发现 Eden 已经被占用了 6MB ,剩余空间已不足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC 。GC 期间虚拟机又发现已有的 3 个 2MB 大小的对象全部无法放入 Survivor 空间(从上图中可看出 Survivor 空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老年代去。

2.2 大对象直接进入老年代

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别到哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1 。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

2.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中的要求的年龄。

2.5 空间分配担保机制

三、内存回收策略

3.1 内存回收关注的区域

3.2 对象存活判断

3.2.1 引用计数算法
3.2.2 可达性分析算法
  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI 引用的对象

请看下图:

可达性分析算法

3.3 方法区的回收

  1. 该类的所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.4 垃圾收集算法

3.4.1 标记—清除算法
“标记—清除”算法示意图
3.4.2 复制算法
复制算法示意图

举个优化例子:新生代中的对象98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

再举个优化例子:将 Eden 和 Survivor 的大小比例设为 8:1 ,也就是每次新生代中可用内存空间为整个新生代容器的 90%,只有10% 的内存作为保留区域。当然 98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(空间分配担保机制在上面,了解一下)。

3.4.3 标记—整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。所以在老年代一般不能直接选用复制收集算法。

“标记—整理”算法示意图
3.4.4 分代收集算法

四、编程中的内存优化

相信大家在编程中都会注意到内存使用的问题,下面我就简单列一下在实际操作当中需要注意的地方。

4.1 减小对象的内存占用

我们可以考虑使用 ArrayMap / SparseArray 而不是 HashMap 等传统数据结构。(我在老项目中,根据 Lint 提示,将 HashMap 替换成 ArrayMap / SparseArray 之后,在 Android Profiler 中显示运行时内存比之前直接少了几M,还是挺可观的。)

  1. inSampleSize :缩放比例,在把图片载入内存之前,我们需要先计算出一个合适的缩放比例,避免不必要的大图载入。
  2. decode format:解码格式,选择 ARGB_8888 / RBG_565 / ARGB_4444 / ALPHA_8,存在很大差异。

4.2 内存对象的重复利用

4.3 避免对象的内存泄露

  1. 内部类引用导致 Activity 的泄漏
  2. Activity Context 被传递到其他实例中,这可能导致自身被引用而发生泄漏。

4.4 内存使用策略优化

五、内存检测工具

最后给推荐几个内存检测的工具,具体使用方法,可以自行搜索。当然除了下面这些工具,应该还有更多更好用的工具,只是我还没有发现,如有建议,可以在文章下面评论留言,大家一起学习分享一下。

后续

学习资料
上一篇 下一篇

猜你喜欢

热点阅读