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

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

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

一图带你看完本文

一、运行时数据区域

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

Java 虚拟机运行时数据区

1.1 程序计数器

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

  • 由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
  • 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
  • 此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

1.2 Java 虚拟机栈

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

Java 虚拟机栈

  • 有人把 Java 内存区分为堆内存和栈内存,而所指的“栈”就是这里的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
  • 局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和 returnAddress 类型(指向了一条字节码指令的地址),其中64位长度的 long 和 double 类型的数据占用2个局部变量空间,其余数据类型只占用1个。
  • 操作数栈也常被称为操作栈,它是一个后入先出栈。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。
  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。
  • 当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。
  • 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。
  • 在 Java 虚拟机规范中,规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
1.2.1 虚拟机栈溢出
  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
  2. 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
  • 当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。
  • 系统分配给每个进程的内存是有限制的,除去 Java 堆、方法区、程序计数器,如果虚拟机进程本身耗费的内存不计算在内,剩下内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。
  • 出现 StackOverflowError 异常时有错误栈可以阅读,栈深度在大多数情况下达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。
  • 但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

1.3 本地方法栈

  • 本地方法栈与虚拟机栈所发挥的作用非常相似,它们之间的区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机栈使用到的 Native 方法服务。
  • 与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

1.4 Java 堆

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

Generational Heap Memory 模型

  • 对于大多数应用来说,Java 堆是 Java 虚拟机所管理的内存中最大的一块。
  • Java 堆是垃圾收集器管理的主要区域,也被称为“GC堆”。
  • Java 堆可以细分为新生代、老年代、永久代;再细致一点可以分为 Eden、From Survivor、To Survivor、Tenured、Permanent 。
  • Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像磁盘空间一样。
  • 从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(TLAB)。
  • 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
1.4.1 Java 堆溢出
  • Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
  • Java 堆内存的 OOM 异常是实际应用中常见的内存溢出异常情况。当出现 Java 堆内存溢出时,异常堆栈信息 “java.lang.OutOfMemoryError” 会跟着进一步提示 “Java heap space” 。
  • 通常是先通过内存映像分析工具对 Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏还是内存溢出。
  • 如果是内存泄漏,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。
  • 如果不存在泄露,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

1.5 方法区

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

  • Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
  • 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
  • 当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
1.5.1 运行时常量池
  • 运行时常量池是方法区的一部分。
  • 常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
  • 运行时常量池相对于 Class 文件常量池的一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。
  • 当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。在 OutOfMemoryError 后面跟随的提示信息时 “PermGen space” 。

1.6 直接内存

  • 直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
  • NIO 类,一种基于通道与缓冲区的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
  • 本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。
  • 由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果我们发现 OOM 之后 Dump 文件很小,而程序中有直接或间接使用了 NIO ,那就可以考虑检查一下是不是这方面的原因。

二、内存分配策略

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过 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 大对象直接进入老年代

  • 所谓的对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
  • 虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(新生代采用复制算法收集内存)。

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

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

2.4 动态对象年龄判定

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

2.5 空间分配担保机制

  • 在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC ,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这次也要改为进行一次 Full GC。
  • 上面提到的“冒险”指的是,由于新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况,把 Survivor 无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。
  • 取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败。
  • 如果出现了HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁。
  • 但在 JDK 6 Update 24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的控件分配担保策略,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC ,否则将进行 Full GC。

三、内存回收策略

  • 新生代 GC(Minor GC) :指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC / Full GC):值发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

3.1 内存回收关注的区域

  • 上面已经介绍 Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭。
  • 栈中的栈帧随着方法的进入和退出而有条不紊地执行者出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。
  • 因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
  • 而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

3.2 对象存活判断

3.2.1 引用计数算法
  • 给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为 0 的对象就是不可能再被使用的。
  • 这种算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,但它很难解决对象之间相互循环引用的问题。
  • 举个例子,对象 objA 和 objB 都有字段 instance,赋值令 objA.instance = objB 及 objB.instance = objA ,除此之外,这两个对象再无任何引用,实际上,这两个对象已经不可能再被访问,但是它们因为相互引用着对方,导致它们的引用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。
3.2.2 可达性分析算法
  • 这个算法的基本思路就是通过一系列额称为“GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连或者说这个对象不可达时,则证明此对象是不可用的。
  • 在 Java 语言中,可作为 GC Roots 的对象包括以下:
  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI 引用的对象

请看下图:

可达性分析算法

3.3 方法区的回收

  • 方法区(HotSpot 虚拟机中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收 Java 堆的对象非常类似。
  • 判定一个类是否是“无用的类”需要同时满足下面3个条件:
  1. 该类的所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  • 虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然回收。

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 分代收集算法
  • 根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记—清除” 或者 “标记—整理” 算法来进行回收。
  • 当前商业虚拟机的垃圾收集都采用 “分代收集” 算法。

四、编程中的内存优化

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

4.1 减小对象的内存占用

  • 使用更加轻量的数据结构

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

  • 避免使用 Enum
  • 减小 Bitmap 对象的内存占用
  1. inSampleSize :缩放比例,在把图片载入内存之前,我们需要先计算出一个合适的缩放比例,避免不必要的大图载入。
  2. decode format:解码格式,选择 ARGB_8888 / RBG_565 / ARGB_4444 / ALPHA_8,存在很大差异。
  • 使用更小的图片:尽量使用更小的图片不仅仅可以减少内存的使用,还可以避免出现大量的 InflationException。

4.2 内存对象的重复利用

  • 复用系统自带的资源:Android系统本身内置了很多的资源,例如字符串/颜色/图片/动画/样式以及简单布局等等,这些资源都可以在应用程序中直接引用。
  • 注意在 ListView / GridView 等出现大量重复子组件的视图里面对 ConvertView 的复用
  • Bitmap 对象的复用
  • 避免在 onDraw 方法里面执行对象的创建:类似 onDraw() 等频繁调用的方法,一定需要注意避免在这里做创建对象的操作,因为他会迅速增加内存的使用,而且很容易引起频繁的 GC,甚至是内存抖动。
  • StringBuilder:在有些时候,代码中会需要使用到大量的字符串拼接的操作,这种时候有必要考虑使用 StringBuilder 来替代频繁的 “+” 。

4.3 避免对象的内存泄露

  • 注意 Activity 的泄漏
  1. 内部类引用导致 Activity 的泄漏
  2. Activity Context 被传递到其他实例中,这可能导致自身被引用而发生泄漏。
  • 考虑使用 Application Context 而不是 Activity Context :对于大部分非必须使用 Activity Context 的情况(Dialog 的 Context 就必须是 Activity Context),我们都可以考虑使用 Application Context 而不是 Activity 的 Context,这样可以避免不经意的 Activity 泄露。
  • 注意临时 Bitmap 对象的及时回收:例如临时创建的某个相对比较大的 bitmap 对象,在经过变换得到新的 bitmap 对象之后,应该尽快回收原始的 bitmap,这样能够更快释放原始 bitmap 所占用的空间。
  • 注意监听器的注销:在 Android 程序里面存在很多需要 register 与 unregister 的监听器,我们需要确保在合适的时候及时 unregister 那些监听器。自己手动 add 的 listener,需要记得及时 remove 这个 listener。
  • 注意缓存容器中的对象泄漏:我们为了提高对象的复用性把某些对象放到缓存容器中,可是如果这些对象没有及时从容器中清除,也是有可能导致内存泄漏的。
  • 注意 WebView 的泄漏:通常根治这个问题的办法是为 WebView 开启另外一个进程,通过 AIDL 与主进程进行通信,WebView 所在的进程可以根据业务的需要选择合适的时机进行销毁,从而达到内存的完整释放。
  • 注意 Cursor 对象是否及时关闭

4.4 内存使用策略优化

  • 资源文件需要选择合适的文件夹进行存放
  • Try catch 某些大内存分配的操作:在某些情况下,我们需要事先评估那些可能发生 OOM 的代码,对于这些可能发生 OOM 的代码,加入 catch 机制,可以考虑在 catch 里面尝试一次降级的内存分配操作。例如 decode bitmap 的时候,catch 到 OOM,可以尝试把采样比例再增加一倍之后,再次尝试 decode。
  • 谨慎使用 static 对象:因为static的生命周期过长,和应用的进程保持一致,使用不当很可能导致对象泄漏。
  • 特别留意单例对象中不合理的持有:因为单例的生命周期和应用保持一致,使用不合理很容易出现持有对象的泄漏。
  • 珍惜Services资源:建议使用 IntentService
  • 优化布局层次,减少内存消耗:越扁平化的视图布局,占用的内存就越少,效率越高。我们需要尽量保证布局足够扁平化,当使用系统提供的 View 无法实现足够扁平的时候考虑使用自定义 View 来达到目的。
  • 谨慎使用 “抽象” 编程
  • 使用 nano protobufs 序列化数据
  • 谨慎使用依赖注入框架
  • 谨慎使用多进程
  • 使用 ProGuard 来剔除不需要的代码
  • 谨慎使用第三方 libraries
  • 考虑不同的实现方式来优化内存占用

五、内存检测工具

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

  • Systrace
  • Traceview
  • Android Studio 3.0 的 Android Profiler 分析器
  • LeakCanary

后续

学习资料
  • 《深入理解Java虚拟机:JVM高级特性与最佳实践》
  • Android性能优化典范
分类:
Android
分类:
Android
收藏成功!
已添加到「」, 点击更改