(三)内存泄漏与排查 —— 优化内存泄漏

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


这篇笔记比较大面积内容来自 深入理解Java虚拟机 JVM高级特性与最佳实践(第二版),确实写的很好,强烈推荐一下。
这边纯粹做笔记,方便自己查看。

一、java 堆栈

SUN 公司的 java 规范中,运行时数据区域分为两大块,一个是共享数据区,一个是线程私有。其中共享数据区包括方法区和堆,线程私有包括程序计数器 PC,虚拟机栈和本地方法栈。

这里写图片描述

作用:
这里写图片描述

1.程序计数器 PC

很小,存放下一条指令(即下一条该执行的代码),属于线程私有的。

CPU 运行是 抢占式 的,在同一时间点只会执行一个代码语句。多线程时候,各个线程进行抢占 CPU,当 A 线程运行到第 10 行的时候,B 线程抢占了 CPU,一段时间后,A 线程又抢占回来,这时候 A 线程需要继续第 11 行代码,这个就要靠程序计数器 PC 进行保存。

当程序在执行一行 java 代码的时候,程序计数器 PC 记录的是虚拟机字节码的地址;当执行的是一个 native 方法, 程序计数器 PC 为 null。

注: *程序计数器 PC 是 java 虚拟机规范中唯一没有 oom 的区域。

2.虚拟机栈和本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

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

这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

在笔者的实验中,将实验范围限制于单线程中的操作,尝试了下面两种方法均无法让虚拟机产生 OutOfMemoryError 异常,尝试的结果都是获得 StackOverflowError 异常,测试代码如代码清单 2-4 所示。

1.使用-Xss参数减少栈内存容量。 结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
2.定义了大量的本地变量,增大此方法帧中本地变量表的长度。 结果:抛出  StackOverflowError 异常时输出的堆栈深度相应缩小。

代码清单 2-4 虚拟机栈和本地方法栈 OOM 测试(仅作为第 1 点测试程序)

/**
 * VM Args:-Xss128k
 * 
 * @author zzm
 */
public class JavaVMStackSOF {
	private int stackLength = 1;

	public void stackLeak() {
		stackLength++;
		stackLeak();
	}

	public static void main(String[] args) throws Throwable {
		JavaVMStackSOF oom = new JavaVMStackSOF();
		try {
			oom.stackLeak();
		} catch (Throwable e) {
			System.out.println("stack length:" + oom.stackLength);
			throw e;
		}
	}
}

运行结果:

stack length:2402
Exception in thread"main"java.lang.StackOverflowError
at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:20)
at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:21)
at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:21)

……后续异常堆栈信息省略

实验结果表明: 在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。

如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常,如代码清单 2-5 所示。 但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

其实原因不难理解,操作系统分配给每个进程的内存是有限制的,譬如 32 位的 Windows 限制为 2GB。 虚拟机提供了参数来控制 Java 堆和方法区的这两部分内存的最大值。 剩余的内存为 2GB(操作系统限制)减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。 如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。 每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

这一点读者需要在开发多线程的应用时特别注意,出现 StackOverflowError 异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在。 而且,如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。 但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。 如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。

代码清单2-5 创建线程导致内存溢出异常。

/**
 * VM Args:-Xss2M(这时候不妨设置大些)
 * 
 * @author zzm
 */
public class JavaVMStackOOM {
	private void dontStop() {
		while (true) {
		}
	}

	public void stackLeakByThread() {
		while (true) {
			Thread thread = new Thread(new Runnable() {
				@Override
				public void run() {
					dontStop();
				}
			});
			thread.start();
		}
	}

	public static void main(String[] args) throws Throwable {
		JavaVMStackOOM oom = new JavaVMStackOOM();
		oom.stackLeakByThread();
	}
}

注意: 特别提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作。由于在 Windows 平台的虚拟机中,Java 的线程是映射到操作系统的内核线程上的 [1],因此上述代码执行时有较大的风险,可能会导致操作系统假死。

3.方法区

用于存储已被虚拟机加载的类信息、 常量、 静态变量、 即时编译器编译后的代码等数据。

根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

在安卓中,方法区内存溢出的情况基本不会出现,如果是做 web 的,或者想了解的,可以自己查看 深入理解Java虚拟机 JVM高级特性与最佳实践。

注: 常量池属于方法区的一部分。

4.java 堆

对于大多数应用来说,.java 堆是 java 虚拟机管理最大的一块内存。此内存区域存在的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做 “GC堆”。 从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、 From Survivor 空间、 To Survivor 空间等。 从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。 不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。

java 堆内存不足时:

如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。 于是就能找到泄露对象是通过怎样的路径与GC Roots 相关联并导致垃圾收集器无法自动回收它们的。 掌握了泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。

如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、 持有状态时间过长>的情况,尝试减少程序运行期的内存消耗。

安卓中较长出现的内存泄露就是泄露对象与 GC Roots 相关联导致对象无法被回收。

二、GC 回收

对于 java GC 回收来说,怎么确认一个对象是否可被回收是最首要的事情。

1.引用计数

很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。

引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是它很难解决对象之间相互循环引用的问题。

举个简单的例子:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引
用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。

所以, java 虚拟机并不是通过引用计数算法来判断对象是否存活的。

2.可达性分析算法

这个算法的基本思路就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。 如图所示,对象 Obj D、 Obj E 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为是可回收的对象。

这里写图片描述

在 Java 语言中,可作为 GC Roots 的对象包括下面几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。

3.finalize

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。 当对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

所以,如果在 finalize 中对对象进行重新链接到 GC Roots ,是可以进行 “自救”。但是不推荐,因为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的一个妥协。 它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。 有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对这个方法用途的一种自我安慰。可以忽略这个方法的存在。

三、内存泄漏

1.内存泄漏

内存泄漏是指在 java 堆中的泄漏,根本原因是: 长生命周期的对象拥有短生命周期对象的引用,导致短生命周期对象与 GC Roots 相关联,不会被进行回收。

2.引用

java 有四种引用,强软弱虚:

强:Object obj = new Object()
软: 有用,但是又不是必须的对象。在内存不足时候,会将软引用回收,还不够的话,OOM
弱: 非必须的对象,gc 直接进行回收
虚: 幽灵、幻影引用,不对生存造成任何影响(在对象被回收时候 能够通知)

使用软引用和弱引用有助于程序内存的优化。

使用软引用,在内存不足时,可进行回收,有助于避免 OOM
使用弱引用,内存得到及时回收,节省内存,避免 OOM

四、Android Profiler

1.位置

Android Profiler 是 Android Studio 提供的一个可以监控 CPU、内存和网络的工具,这边我们使用 Android Profiler 来进行内存分析。

这里写图片描述

Android Profiler 窗口不一定会有,点击工具栏上的 Android Profiler 工具图标,在 Android Studio 下面会添加一个 Android Profiler 的窗口。
这里写图片描述

我们点击 Memory 这个模块进行分析。

2.辅助功能

这里写图片描述

① 强制执行垃圾收集事件的按钮。
② 捕获堆转储的按钮。
③ 记录内存分配的按钮。
④ 放大时间线的按钮。
⑤ 跳转到实时内存数据的按钮。
⑥ 事件时间线显示活动状态、用户输入事件和屏幕旋转事件。
⑦ 内存使用时间表,其中包括以下内容:

  • 每个内存类别使用多少内存的堆栈图,如左边的y轴和顶部的颜色键所示。
  • 虚线表示已分配对象的数量,如右侧y轴所示。
  • 每个垃圾收集事件的图标。

但是,默认情况下并不是所有的分析数据都可见。如果您看到一条消息,说“高级分析不可用于所选进程”,则需要启用高级分析以查看以下内容:

  • 活动时间表
  • 分配对象的数量
  • 垃圾收集事件

3.内存快照

点击按钮 2 ,获取堆转储的内存快照,在这里可以看到应用分配了那些对象,使用了多少内存。这里有三种排列方式可以选择:

Arrange by class: 根据类名分配。
Arrange by package:根据包名分配。
Arrange by callstack: 根据调用堆栈排序

这里写图片描述

右边有四个显示数据:

Alloc Cout:对象数
Native Size:native 占用的内存大小
Shallow Size:对象占用内存大小
Retained Set:对象引用组占用的内存

可以看到有些 Activity 存在多个,这就需要确认是否存在内存泄漏。可以点击某一个对象进行查看。
这里写图片描述

然后单击一个类名,Instance View 窗格就会显示在右侧,显示该类的每个实例。
在 Instance View 窗格中,单击一个实例。References 选项卡显示在下面,显示了哪个实例被分配在哪个线程中。
在 References 选项卡中,右击任意行可以在编辑器中跳转到该代码。

4.hprof 文件

点击内存快照的左上角 Export capture to file 按钮,保存成 hprof 文件。

这里写图片描述

把 hprof 文件拉到 Android Studio 中进行文件分析。目前 Android Studio 提供了两个内存泄漏检测的工具:

Activity 泄漏的检测。
相同字符串的检测。

点击绿色开始图标进行检测,下方 Analysis Results 可以显示分析结果,展示可能泄漏的对象。

这里写图片描述

五、MemoryAnalyzer

官方下载链接
CSDN下载链接

官方下载链接需要翻墙。

1.转换 hpro

MAT 是用来分析 java 程序的 hpro 文件的,与 Android 导出的 hprof 有一定的格式区别,因此我们需要把上面导出的 hprof 文件转换一下,sdk 中有提供转换的工具 hprof-conv.exe,在 SDK2.3\platform-tools 下。

在 cmd 窗口中打开,执行命令 hprof-conv -z 原文件 输出文件 进行转换。
这里写图片描述

注: 命令中 -z 表示去除非应用消耗的内存。

2.MAT 打开 hpro

直接运行 MemoryAnalyzer,把转换后的 hpro 打开。
这里写图片描述

打开柱形图,输入在 Android Studio 中检测到可能存在泄漏的类名进行搜索。
这里写图片描述

右击,选择链接到 GC Root 的最短距离,排除软弱虚引用。
这里写图片描述

可以看到,SessionActicity 是被 SystemMessageHandler 引用,导致不能回收,内存泄漏。很明显这是在 Activity 销毁的时候没有进行清空 Handle 中未发送的消息,导致了泄漏。
这里写图片描述

针对这种情况,只需要在 Activity 销毁的时候,同时清空 Handle 中未发送的消息即可。

六、LeakCanary

LeakCanary github 连接

也可以在项目中直接整合 LeakCanary 进行内存泄漏检测,LeakCanary 的整合十分简单,按 github 上步骤即可。LeakCanary 虽然不是很准确,但是也可以用一下。

七、内存泄露常见原因

1.静态成员、单例

static 修饰的对象生命周期是在程序进程死亡时才释放,如果在 static 变量中误引用了其他对象,则会导致这个对象无法被释放,造成内存泄漏。

优化: 严格控制 static 的使用,确认是否必须。

2.未关闭、释放的资源

如果文件流,数据库操作对象等资源,没有进行释放,导致泄漏。

优化: 及时关闭相应资源,正常这些资源用完就关闭、释放,或者在 Activity 销毁的时候进行关闭、释放。

3.Bitmap

Bitmap 的解析需要占用内存,但是安卓系统只提供 8M 的内存给所有的 Bitmap,如果图片过多,那么就会造成内存溢出。

优化: 注意对 Bitmap 的 recycle,或者使用第三方库进行辅助管理,比如 Glide 和 Picasso。

4、Thread、Handler

线程执行时间较长,Activity 已经调用销毁的方法,但是线程或者 Runnable 是 Acticvity 内部类,持有 Activity 的引用(非静态内部类持有外部内的引用),导致 Acticity 无法释放,造成泄漏。

Handler 在使用中,发送 Message 对象到 MessageQueue中,然后 Looper 会轮询 MessageQueue,然后取出 Message 执行,但是如果一个 Message 长时间没被取出执行,而 Message 中有 Handler的引用,而 Handler 又会引用 Activity,导致 Activity 无法释放,造成泄漏。

优化: 在 Activity 销毁的时候结束线程,已经清空 Handle 中未发送的消息(类似这些的东西)。也可以将内部类改为静态内部类以及使用软引用来引用 Activity。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值