Android 内存泄漏问题多多,怎么优化?

tech2022-08-30  101

作者 | 无名之辈FTER

来源 | 博客

责编 | 夕颜

出品 | (ID:news)

众所周知,Java因其拥有独特的虚拟机(JVM)设计,使其成为一门跨平台、内存自动管理的高级开发语言。所谓跨平台,即"一次编译,多次运行",从而解决了不同平台由于编译器不同导致无法运行问题;所谓内存自动管理,即Java不像C/C++那样需要开发者来分配、释放内存,它拥有一套垃圾回收机制来管理内存,这套机制减轻了很多潜在的内存回收不当问题。然而,虽然Java的垃圾回收机制非常优秀,但当我们在写程序过程中有一些不好的习惯可能会导致部分内存无法被垃圾回收器回收,而我们自己又无法进行回收,从而导致这部分内存长期被占用无法释放,并且随着这部分内存的增大,极大的影响了程序的性能,这种情况被称之为“内存泄漏”。

Java虚拟机(JVM)

虚拟机是一种虚构出来的抽象化计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的,它拥有自己完善的虚拟硬件架构,如处理器、堆栈、寄存器等,而且还具有相应的指令系统。Java虚拟机就是这么一种虚拟机。Java虚拟机,即Java Virtual Machine(JVM),是运行所有Java程序的抽象计算机,是Java语言的运行环境,它屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码)。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行,即"一次编译,多次运行",正是因为如此,从而使得Java语言具有跨平台移植的特性。

Java虚拟机本质上就是一个程序,Java程序的运行依靠具体的Java虚拟机实例,每个Java应用程序都对应着一个Java虚拟机实例,且Java程序与其对应Java虚拟机实例生命周期一致。在Java虚拟机规范中,JVM主要包括五大模块,即类装载器子系统、运行时数据区、执行引擎、本地方法接口和垃圾收集模块。其中,类加载器子系统,用于加载字节码文件到内存,就是JVM中的runtime data area(运行时数据区)的method area方法区,整个过程中装载器只负责文件结构格式能够被装入,并不负责能不能运行;运行时存储区,即JVM内存区域,JVM运行程序的时候使用;执行引擎,在不同的虚拟机实现里面,执行执行引擎可能会有解释器解释执行字节码文件或即时编译器编译产生本地代码执行字节码文件,可能两种都有;本地方法接口,即Java Native Interface(JNI),用于与本地库(native library)交互,是Java与其他编程语言(C/C++)交互的"桥梁";垃圾收集,用于对已分配的内存资源进行回收,主要是Java堆和方法区的内存。JVM架构如下图所示:

1.1 JVM内存管理

Java虚拟机在执行Java程序时会把它所管理的内存划分若干个不同的数据区域,这些区域的用途各不相同,创建、销毁的时间也各有区别,比如有的随着Java虚拟机进程的启动而存在、有的区域则依赖于用户线程的启动和结束而创建、销毁,但它们有一个共同的“名字”,即运行时数据区域。Java虚拟机管理的内存主要有:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区以及直接内存等,其中,程序计数器、虚拟机栈和本地方法栈为线程私有,方法区和Java堆为线程间共享。

程序计数器

程序计数器(Program Counter Register)是内存中的一块较小的区域,它可以看作成是当前线程所执行的字节码行号指示器,依赖于用户线程的启动和结束而创建、销毁,是线程私有内存数据区域。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一确定的时刻,一个处理器都只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响、独立存储。需要注意的是,如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果线程正在执行的是Native方法,那么这个计数器的值为空。

在Java虚拟机规范中,程序计数器是唯一一个没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

类似于程序计数器,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有,生命周期与用户线程周期相同,它描述的是Java方法执行的内存模型,即每个Java方法在执行时JVM会为其在这部分内存中创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接以及方法出口信息等,每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈和出栈过程。局部变量表是我们在开发过程中接触较多的部分,它存放了编译器可知的各种基本数据类型(byte/boolean/char/int/short/long/float/double)、对象引用(reference类型)和returnAddress类型,其中,64位长度的long和double类型的数据占用2个局部变量空间,其他的类型占1个(4个字节)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完成确定的,在方法运行期间不会改变局部变量表的大小。下图是虚拟机栈存储示意图:

在Java虚拟机规范中,虚拟机栈可能会出现两种异常情况,即StackOverflowError和OufOfMemoryError,其中,StackOverflowError出现在如果线程请求的栈深度大于虚拟机所允许的深度;OufOfMemoryError出现在如果虚拟机栈可以动态扩展,但是扩展后仍然无法申请到足够的内存。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用非常相似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(即字节码),而本地方法栈则为虚拟机使用到的Native方法服务。下图演示了一个线程调用Java方法和本地方法时的栈,以及虚拟机栈和本地方法栈之间毫无障碍的跳转。示意图如下:

在Java虚拟机规范中,本地方法栈也会出现StackOverflowError和OufOfMemoryError异常情况。

Java堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,它在JVM启动时被创建,生命周期与JVM相同,是被所有线程所共享的一块区域,此区域唯一的目的是存放对象实例和数组,几乎所有的对象实例都在这里分配内存。Java堆是垃圾收集器管理的主要区域,也是"内存泄漏"集中出现的地方。由于JVM中的垃圾收集器大部分采用分代收集算法,因此,Java堆又被细分为:新生代和老年代,其中,新生代区域存放创建不久的对象,老年代存放经历过多次GC后仍然存活的对象。实际上,根据JVM规范,Java堆还可被继续细分为Eden空间、From Survivor空间以及To Survivor空间等,这个我们在垃圾回收机制模块详细阐述。下图是Java堆内存划分示意图:

在JVM规范中,如果堆可以动态扩展,但是扩展后仍然无法申请到足够的内存,就会抛出OutOfMemoryError异常。当然,我们可以通过-Xmx和-Xms来控制堆内存的大小,其中,-Xmx用于设置Java堆起始的大小,-Xms用于设置Java堆可扩展到最大值。

方法区

像Java堆一样,方法区(Method Area)是各个线程共享的内存区域,它的生命周期与虚拟机相同,即随着虚拟机的启动和结束而创建、销毁。方法主要用于存放已被虚拟机加载的类信息、常量、静态变量以及即时编译器(JIT)编译后的代码等数据,它的大小决定了系统能够加载多少个类,如果定义的类太多,导致方法区抛出OutOfMemoryError异常。需要注意的是,对于JDK1.7来说,在HotSpot虚拟机中方法区可被理解为"永久区",但是JDK1.8以后,方法区已被取消,替代的是元数据区。元数据区是一块堆外的直接内存,与永久区不同,如果不指定大小,默认情况下在类加载时虚拟机会尽可能加载更多的类,直至系统内存被消耗殆尽。当然,我们可以使用参数-XX:MaxMetaspaceSzie来指定元数据区的大小。

运行时常量池用于存放编译期生成的各种字面量和符号引用。

直接内存

直接内存(Direct Memory)不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,它是在JDK1.4中新加入的NIO(New Input/Output)类,通过引入了一种基于通道与缓冲区的I/O方式,使用Native函数库直接分配得到的堆外内存。对于这部分内存区域,主要通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,直接内存的存在避免了在Java堆和Native堆中来回复制数据,从而在某些场景能够显著地提高性能。

本机直接内存的分配不会受到Java堆大小的限制,但是仍然会受到本机总内存(包括RAM以及SWAP或者分页文件)大小以及处理器寻址空间的限制。如果申请分配的内存总和(包括直接内存)超过了物理内存的限制,就会导致动态扩展时出现OutOfMemoryError异常。

1.2 垃圾回收器与内存分配策略

在上一节中我们详细分析了JVM的运行时内存区域,了解到程序计数器、虚拟机栈、本地方法栈是线程的私有区域,当线程结束时这部分所占内存资源将会自动释放,而线程的共享区域Java堆是存放所有对象实体的地方,因此是垃圾回收器回收(GC,Garbage Collection)的主要区域。(方法区也会有GC,但是一般我们讨论的是Java堆)

1.2.1 如何判断对象"已死"?

垃圾收集器在回收一个对象,第一件事就是确定这些对象之中有哪些是“存活”的,哪些已经“死亡”,而垃圾回收器回收的就是那些已经“死亡”的对象。如何确定对象是否已经死亡呢?通常,我们可能会说当一个对象没被任何地方引用时,就认为该对象已死。但是,这种表述貌似不够准确,因此,JVM规范中给出了两种判断对象是否死亡的方法,即引用计数法和可达性分析。

引用计数法

引用计数法实现比较简单,它的实现原理:给对象一个引用计数器,每当一个地方引用它时,计数器就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就会被认为已经死亡。客观地说,引用计数器法效率确实比较高,也容易实现,但是它也有不足之处,就是无用对象之间相互引用的问题,这种情况的出现会导致相互引用的对象均无法被垃圾回收器回收。

可达性分析

为了解决引用计数法的无用对象循环引用导致无法被回收情况,JVM中又引入了可达性分析算法来判断对象是否存活,这种算法也是普遍被应用的方式。可达性分析基本思想:通过一系列被称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连接时,则证明此对象不可用,即被判断可回收对象。可达性分析算法示意图如下图所示:

 那么,哪些对象可以作为"GC Roots"呢?

虚拟机栈中(局部变量表)引用的对象;

方法区中类静态属性引用的对象;

方法区中常量引用的对象;

本地方法栈中Native方法引用的对象;

1.2.2 垃圾收集算法

前面我们通过引用计数法或可达性分析找到了哪些对象是可以被回收的,本节将重点阐述JVM中的垃圾回收器是如何将这些不可用对象进行回收,即垃圾收集算法,主要包括标记-清除算法、复制算法、标记-整理以及分代收集等。相关介绍如下:

标记-清除算法

标记-清理算法是最基础的垃圾收集算法,它的实现分为两个阶段,即“标记”和“清除”,其中,标记的作用为通过引用计数法或可达性分析算法标记出所有需要回收的对象;清除的作用为在标记完成后统一回收所有被标记的对象。这种算法比较简单,但是缺陷也比较明显,主要表现为两个方面:一是标记和清理的效率比较低;二是标记清理之后会产生大量不连续的内存碎片,空间碎片太大可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次GC。标记-清除算法执行过程如下图所示:

复制算法

为了解决标记-清理算法效率不高问题,人们提出了一种复制算法,它的基本原理:将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上,然后再把已使用的内存空间一次性清理掉。这种方式实现简单,运行高效,且缓解了内存碎片的问题,但是由于其只对整个半区进行内存分配、回收,从而导致可使用的内存缩小为整个内存的一半。复制算法执行过程如下图所示:

在HotSpot虚拟机中,整个内存空间被分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间。当回收时,将Eden和Survivor中还存活着的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Surivor的大小比例为8:1:1,也就是每次新生代中可用内存空间为整个内存空间的90%,这就意味着有剩余的10%不可用。当Survivor空间不够用时,就需要依赖其他内存(老年代)进行分担担保。

注:新生代是指刚创建不久的对象;老年代指被多次GC仍然存活的对象。

标记-整理算法

虽说复制算法有效地提高了标记-清除算法效率不高问题,但是在对象存活率较高的情况下,就需要进行较多的复制操作(复制对象),尤其是所有对象都100%存活的极端情况,这种复r制算法效率将会大大降低,因此,老年代区域通常不会直接选用这种算法。根据老年代的特点,有人提出了标记-整理算法,该算法基于标记-清除算法发展而来,其中,标记同标记-清除算法一致,整理为将所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记-整理算法执行过程如下图所示:

分代收集算法

分代收集算法是目前大部分虚拟机的垃圾收集器采用的算法,这种算法的思想是根据对象的存活周期的不同将Java堆内存划分为几块,即新生代区域和老年代区域,然后对不同的区域采用合适的算法。由于新生代每次GC时都会有大批对象死去,只有少量的对象存活,因此通常选用复制算法;而老年代中因为存活率高、没有额外空间对它进行分配担保,就必须使用"标记-清理“或”标记-整理"算法进行回收。分代收集算法模型如下图所示:

哪些对象能够进入老年代?

大对象;

每次Eden进行GC后对象年龄加1进入Survivor,对象年龄达到15时进入老年代;

如果Survivor空间中相同年龄所有对象大小的总和大于survivor空间的一半,年龄大于等于该年龄的对象就直接进入老年代。

如果survivor空间不能容纳Eden中存活的对象,由于担保机制会进入老年代。如果survivor中的对象存活很多,担保失败,那么会进行一次Full GC。

什么是Minor GC、Major GC和Full GC?

Minor GC从新生代空间(Eden和Survivor区域)回收内存;

Major GC是清理永久代;

Full GC是清理整个堆内存空间,包括新生代和永久代。

1.2.3 内存分配与回收策略

Java的自动内存管理归结于两方面,即为对象分配内存和回收分配给对象的内存,其中,在上一小节中我们详细阐述了回收内存的具体细节,这里不再讨论。对于对象的内存分配,实际上就是在Java堆中为对象分配内存,准确来说是在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配,并且,少数情况下也可能会直接分配在老年代中。总之,JVM中对对象内存的分配不是固定的模式,其细节取决于使用哪种垃圾收集器,和虚拟机中与内存相关的参数设置。常见的内存分配策略:

对象优先在Eden分配

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

大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。对于内存分配来说,大对象也是一个很棘手的东西,尤其是“短命大对象”,经常出现在内存空间还较多的情况下,大对象直接导致提前出发垃圾收集器以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,使得大于这个设置值得对象直接在老年代内存区域分配,这样做的目的在于避免在Eden区及两个Survivor区之间发生大量的内存复制。

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

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1.对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会被晋升到老年代中。虚拟机提供了一个-XX:MaxTenuringThreshold参数设置老年代年龄阈值。

虚拟机并不是永远地要求对象的年龄必须达到了-XX:MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄所有对象的大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的;如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那么这时就需要进行一次Full GC。

1.3 JVM的类加载机制

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段,其中,验证、准备、解析3个部分统称为连接(Linking)。类的生命周期如下图所示:

在虚拟机中,我们常说的类的加载过程是指加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)这五个阶段,它们的具体作用为:

加载

加载过程是将二进制字节流(Class字节码文件)通过类加载器加载到内存并实例化Class对象的过程(加载到方法区内)。这个过程独立于虚拟机之外,并且二进制流可以从不同的环境内获取或者由其他文件生成。

验证

验证Class文件的字节流是否符合虚拟机的要求,以免造成虚拟机出现异常。包括:文件格式验证、元数据验证、字节码验证、符号引用验证。

准备

为静态变量(被final关键字修饰)分配内存空间、赋值和设置类变量初始化(自动初始化)。

解析

将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄以及调用点限定符7类符号引用进行。

初始化

执行类构造器<clinit>方法的过程,变量的声明初始化就在这个阶段进行。

虚拟机类加载的时机?

1)遇到new、getstatic、putstatic或者invokestatic 这四条字节码指令的时候,且该类没有进行初始化则进行该类的初始化;2)使用反射机制的时候;3)初始化类的父类;4)初始化虚拟机要执行主类;5)使用动态语言特性的时候;

总之,当对一个类进行主动引用的时候就会进行初始化操作,而进行被动引用的时候便不会触发类初始化操作,比如通过子类引用父类静态字段时子类不会被初始化。

常见内存泄漏与优化

2.1 内存泄漏

当一个对象已经不需要再使用本该被回收时,另外一个正在使用的对象持有它的引用从而导致它不能被垃圾收集器回收,结果它们就一直存在于内存中(通常指Java堆内存),占用有效空间,永远无法被删除。随着内存不断泄漏,堆中的可用空间就不断变小,这意味着为了执行常用的程序,垃圾清理需要启动的次数越来越多,非常严重的话会直接造成应用程序报OOM异常。

优化/避免内存泄漏原则:

涉及到使用Context时,尽量使用Application的Context;

对于非静态内部类、匿名内部类,需将其独立出来或者改为静态类;

在静态内部类中持有外部类(非静态)的对象引用,使用弱引用来处理;

不再使用的资源(对象),需显示释放资源(对象置为null),如果是集合需要清空;

保持对对象生命周期的敏感,尤其注意单例、静态对象、全局性集合等的生命周期;

2.2 常见内存泄漏与优化

(1) 单例造成的内存泄漏

案例

/** 工具类,单例模式 * @Auther: Jiangdg * @Date: 2019/10/8 17:23 * @Description: */ public class CommonUtils { private static CommonUtils instance; private Context mCtx; private CommonUtils(Context context){ this.mCtx = context; } public static CommonUtils getInstance(Context context) { if(instance == null) { instance = new CommonUtils(context); } return instance; } } /**使用单例模式时造成内存泄漏 * * @Auther: Jiangdg * @Date: 2019/10/8 17:24 * @Description: */ public class SingleActivity extends AppCompatActivity { private CommonUtils mUtils; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); mUtils = CommonUtils.getInstance(this); } }

分析与优化

在上述示例中,当SingleActivity实例化Commontils对象完毕后,Commontils将持有SingleActivity对象的引用,而由于单例模式的静态特性,Commontils对象的生命周期将于应用进程的一致,这就会导致在应用未退出的情况下,如果SingleActivity对象已经不再需要了,而Commontils对象该持有该对象的引用就会使得GC无法对其进行正常回收,从而导致了内存泄漏。优化:对于需要传入Context参数的情况,尽量使用Application的Context,因为它会伴随着应用进程的存在而存在。

public class SingleActivity extends AppCompatActivity { private CommonUtils mUtils; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 造成内存泄漏 //mUtils = CommonUtils.getInstance(this); mUtils = CommonUtils.getInstance(this.getApplicationContext()); } }

(2) Handler造成的内存泄漏

案例

/** 使用Handler造成内存泄漏 * @Auther: Jiangdg * @Date: 2019/10/8 17:55 * @Description: */ public class HandlerActivity extends AppCompatActivity { // 匿名内部类 private Handler mUIHandler = new Handler() { @Override public void handleMessage(Message msg) { super.handleMessage(msg); } }; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); new Thread(new Runnable() { @Override public void run() { // 处理耗时任务 // ... mUIHandler.sendEmptyMessage(0x00); } }); } }

分析与优化

在剖析Handler消息机制原理一文中我们知道,在Android应用启动时,应用程序的主线程会为其自动创建一个Looper对象和与之关联的MessageQueue,当主线程实例化一个Handler对象后,它就自动与主线程的MessageQueue关联起来,所有发送到MessageQueue的Message(消息)都会持有Handler的引用。由于主线程的Looper对象会随着应用进程一直存在的且Java类中的非静态内部类和匿名内部类默认持有外部类的引用,假如HandlerActivity提前出栈不使用了,但MessageQueue中仍然还有未处理的Message,Looper就会不断地从MessageQueue取出消息交给Handler来处理,就会导致Handler对象一直持有HandlerActivity对象的引用,从而出现HandlerActivity对象无法被GC正常回收,进而造成内存泄漏。优化:将Handler类独立出来,或者使用静态内部类,因为静态内部类不持有外部类的引用。

public class HandlerActivity extends AppCompatActivity { // 匿名内部类默认持有HandlerActivity的引用 // 造成内存泄漏 // private Handler mUIHandler = new Handler() { // @Override // public void handleMessage(Message msg) { // super.handleMessage(msg); // } // }; // 优化,使用静态内部类 // 假如要持有HandlerActivity,以便在UIHandler中访问其成员变量或成员方法 // 需要使用弱引用处理 private UIHandler mUIHandler; static class UIHandler extends Handler { private WeakReference<HandlerActivity> mWfActivity; public UIHandler(HandlerActivity activity) { mWfActivity = new WeakReference<>(activity); } @Override public void handleMessage(Message msg) { super.handleMessage(msg); } } @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); mUIHandler = new UIHandler(this); } }

Java中四种引用关系:

强引用

用来描述永远不会被垃圾收集器回收掉的对象,类似"Object obj = new Object"

软引用

用来描述一些还有用但并非必须的对象,由SoftReference类实现。被软引用关联着的对象会在系统将要发生OOM之前,垃圾收集器才会回收掉这些对象。

弱引用

用来描述非必须的对象,比软引用更弱一些,由WeakReference类实现。被弱引用的对象只能生产到下一次垃圾收集发生之前,无论当前内存是否足够。

虚引用

最弱的引种引用关系,由PhantomReference类实现。一个对象是否有虚引用的存在,完全不会对其生存时间产生影响,也无法通过虚引用来获取该对象实例。为一个对象设置虚引用关联的唯一目的是能在这个对象被垃圾收集器回收时收到一个系统通知。

(3) 线程(非静态内部类或匿名内部类)造成的内存泄漏

案例

/** 使用线程造成的内存泄漏 * @Auther: Jiangdg * @Date: 2019/10/9 10:04 * @Description: */ public class ThreadActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 开启一个子线程 new Thread(new MyRunnable()).start(); // 开启一个异步任务 new MyAsyncTask(this).execute(); } class MyRunnable implements Runnable { @Override public void run() { } } class MyAsyncTask extends AsyncTask { private Context mCtx; public MyAsyncTask(Context context) { this.mCtx = context; } @Override protected Object doInBackground(Object[] objects) { return null; } } }

分析与优化

 在之前的分析中可知,Java类中的非静态内部类和匿名内部类默认持有外部类的引用。对于上述示例中的MyRunnable和MyAsyncTask来说,它们是一个非静态内部类,将默认持有ThreadActivity对象的引用。假如子线程的任务在ThreadActivity销毁之前还未完成,就会导致ThreadActivity无法被GC正常回收,造成内存泄漏。优化:将MyRunnable和MyAsyncTask独立出来,或使用静态内部类,因为静态内部类不持有外部类的引用。

public class ThreadActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 开启一个子线程 new Thread(new MyRunnable()).start(); // 开启一个异步任务 // 优化:使用Application的Context new MyAsyncTask(this.getApplicationContext()).execute(); } // 优化:使用静态内部类 static class MyRunnable implements Runnable { @Override public void run() { } } // 优化:使用静态内部类 // 如果需要传入Context,使用Application的Context static class MyAsyncTask extends AsyncTask { private Context mCtx; public MyAsyncTask(Context context) { this.mCtx = context; } @Override protected Object doInBackground(Object[] objects) { return null; } } }

(4) 静态实例造成的内存泄漏

案例

/**非静态内部类创建静态实例造成的内存泄漏 * @Auther: Jiangdg * @Date: 2019/10/9 10:43 * @Description: */ public class StaticInstanceActivity extends AppCompatActivity { private static SomeResources mSomeResources; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); if(mSomeResources == null) { mSomeResources = new SomeResources(this); } } class SomeResources { private Context mCtx; public SomeResources(Context context) { this.mCtx = context; } } }

分析与优化

在上述案例中,演示了防止StaticInstanceActivity重建,比如横竖屏切换,导致反复创建SomeResources实例的问题,这里使用了static修饰关键字将SomeResources实例声明了静态实例,以确保该实例始终存在的是同一个,且它的生命周期与应用相同。然而,由于SomeResources是一个非静态内部类,其对象默认持有外部类StaticInstanceActivity的引用,就会导SomeResources的对象一直持有该引用,造成内存泄漏。优化:使用单例模式实现SomeResources,或者将其改成静态内部类。如果需要传入Context参数,必须使用Application的Context。

public class StaticInstanceActivity extends AppCompatActivity { private static SomeResources mSomeResources; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); if(mSomeResources == null) { // 优化,使用Application的Context mSomeResources = new SomeResources(this.getApplicationContext()); } } // 优化:使用静态内部类 static class SomeResources { private Context mCtx; public SomeResources(Context context) { this.mCtx = context; } } }

(5) 资源未关闭或监听器未移除(注销)引起的内存泄露情况

在开发中,如果使用了BraodcastReceiver,ContentObserver,File,Cursor,Stream,Bitmap、自定义属性attributeattr、传感器等资源,应该在Activity销毁时及时关闭或者注销,否则这些资源将不会被回收,从而造成内存泄漏。比如:

// 使用传感器等资源,需要注销 SensorManager sensorManager = getSystemService(SENSOR_SERVICE); Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ALL); sensorManager.registerListener(this,sensor,SensorManager.SENSOR_DELAY_FASTEST); sensorManager.unregisterListener(listener); // 使用BraodcastReceiver,需要注销 Myreceiver recevier = new Myreceiver(); intentFilter = new IntentFilter(); intentFilter.addAction("android.net.conn.CONNECTIVITY_CHANGE"); registerReceiver(recevier,intentFilter); unRegisterReceiver(recevier); // 自定义属性,需要recycle TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AttrDeclareView); int color = a.getColor(R.styleable.AttrDeclareView_background_color, 0); a.recycle();

除了上述常见的5种内存泄漏外,还有包括无限循环动画、使用ListView、使用集合容器以及使用WebView也会造成内存泄漏,其中,无限循环动画造成泄漏的原因是没有再Activity的onDestory中停止动画;使用ListView造成泄漏的原因是构造Adapter时没有使用缓存的convertView;使用集合容器造成泄漏的原因是在不使用相关对象时,没有清理掉集合中存储的对象引用。在优化时,在退出程序之前将集合中的元素(引用)全部清理掉,再置为null;使用WebView造成泄漏的原因是在不使用WebView时没有调用其destory方法来销毁它,导致其长期占用内存且不能被回收。在优化时,可以为WebView开启另外一个进程,通过AIDL与主线程进行通信,便于WebVIew所在的进程可以根据业务需要选择合适的时机进行销毁。

更多精彩推荐 ☞为效能而生,企业级敏捷研发管理工具PingCode正式发布! ☞GitHub 标星 20000+,国产 AI 开源从算法开始突破 | 专访商汤联合创始人林达华☞微软三杰☞性能超越GPU、FPGA,华人学者提出软件算法架构加速AI实时化 ☞进入编译器后,一个函数经历了什么? ☞直播平台防盗链探究 点分享点点赞点在看
最新回复(0)