前言
在开发过程中,编码习惯或者某些情况没有考虑到都会有产生内存泄露的可能,这时候内存泄露的分析流程就显得尤为重要。
今天的SOP会以一个算法集成的检测内存泄露作为例子进行编写。接下来我会以流程图的形式将整个流程大体过一遍。
内存泄露流程图 |
编写相关脚本
首先我们要知道为什么要编写脚本,编写脚本有什么用。首先对于内存泄露来说,如果软件存在这个内存泄漏,单独一次的泄露其实人眼很难分析出来,可能就泄露那么几k或者几十k的。而内存泄露是什么,堆内存有个内存块一直不能被GC给回收,如果我们反复进行泄露的路径,就会有越来越多的内存块没有被释放,这样泄露的就会越来越多,我们写脚本就是为了让这个泄露越来越明显,好让我们开发人员更加轻易的发现是否有泄露,哪里有泄露,怎么解决泄露。
接下来我们就来说道说道这个脚本到底怎么上手。其实脚本选什么语言都可以,只要能实现目标就行,phython、shell语言、dos命令符这些都可以实现目标。我个人觉得其实对于安卓开发来说,dos命令符的形式是最适合的,因为我们在走脚本的时候还会用到adb的相关指令,dos命令符感觉就是为其量身定制的。
编写内存泄露脚本的总体流程就是:以广角畸变算法为例,相机应用在切换广角镜头以及广角镜头拍照的时候会走广角畸变算法,所以我们整体的自动化脚本就围绕这两个操作去进行,可以设置一些进退广角模式的操作,广角镜头拍照,关闭打开软件等操作,然后再给予脚本一个循环去让其自动的跑起来。
但是值得注意的是光让他跑起来还是远远不够的,我们还要拿到每次循环的时候,Java Heap的占用大小,后续判断是否内存泄露,这个占用大小的走势决定了判断结果,而如果具体到脚本中,我们需要拿到Java Heap的文件,我们便需要拿到hprof文件、meminfo文件、swap文件。接下来我就会以一个具体的脚本做到保姆级讲解。在保姆级讲解前,我们还需要知道一些基础的adb操作手机的指令。
常用adb命令 | |
打开相机 | adb shell am start -n com.sec.android.app.camera/com.android.camera.CameraActivity |
关闭相机 | adb shell am force-stop com.sec.android.app.camera |
点击事件 | adb shell input tap 500 500 |
Recent App | adb shell input keyevent 187 |
home键 | adb shell input keyevent 3 |
返回键 | adb shell input keyevent 4 |
截图(保存到SDCard) | adb shell /system/bin/screencap -p /sdcard/screenshot.png |
从SD卡导出到电脑 | adb pull /sdcard/screenshot.png /Users/dhht/Desktop |
录屏 | adb shell screenrecord /sdcard/test.mp4 adb pull /sdcard/test.mp4 /Users/dhht/Desktop/test.mp4 |
亮屏 | adb shell input keyevent 26 |
上下滑动 | adb shell input swipe 700 2000 700 1000 |
左右滑动 | adb shell input swipe 100 1000 1000 1000 |
解锁 | adb shell input keyevent 82 |
锁定 | adb shell input keyevent 26 |
输入密码,并回车: | adb shell input text 123456 && adb shell input keyevent 66 |
屏幕长亮 | adb shell svc power stayon true[true | false l usb l acwireless] |
wifi设置界面 | adb shell am start-a android.intent.action.MAIN -n com.android.settings/.wifi. WifiSettings |
打开wifi | adb shell svc wifi enable |
关闭wifi | adb shell svc wifi disable |
打开蓝牙 | adb shell service call bluetooth_manager 6 |
关闭蓝牙 | adb shell service call bluetooth_manager 9 |
打开网页 | adb shell am start -a android.intent.action.VIEW -d http://google.com |
卸载应用 | adb uninstall com.example.appname |
屏幕相关 | wm density wm size wm density 240 |
定位当前页面位置 | adb shell dumpsys activity top |
获取屏幕分辨率 | adb shell wm size |
删除屏幕锁 | adb shell rm /data/system/access_control.key adb shell rm /data/system/password.key adb shell rm /data/sysem/gesture.key |
基本知道常用的adb命令之后,我们就可以写出一个简单的自动化脚本啦,下面我将会通过一个示例代码进行一个相机的讲解。大家可以仔细看一遍这个脚本内容,我觉得还是非常好懂的,一些不太熟悉的语句都通过注释有进行解释,这个脚本主要是dump heap之后,进行自动化脚本,每次循环都会dumpheap一次,不断得到内存信息。
数据可视化
写好脚本之后,一般可以执行一晚上,这样才能保证数据的数量,最后将其可视化的时候可以更加明显的发现是否存在泄露。
跑完一晚上之后,我们先来看看整个跑完之后的一个文件结构
我们先来看看如何分析内存是否泄漏,首先我们要知道一个前提条件就是,内存没有泄漏的情况,应用整体的内存占用是趋于一个稳定的数值进行上下波动。当出现泄漏的时候,整体的内存占用就会越来越大,所以我们在判断的时候可以着重分析一下Total、Activity。如果观察到Total随着时间的流动慢慢变多,这时候就要注意了。
根据上面的脚本编写可以知道,在执行完一个循环之后,adb就会将当前手机的各个部分的占用信息dump下来。这里面就包括了手机当前的总占用空间。
那问题就来了,我们怎么快速将每个文件中的占用空间快速找出来同时进行归纳呢。这时候我们选择使用Python语言去进行爬取其中的关键数据,提高排查的效率。
首先我们来观察一下这些文件里面的内容组成
Applications Memory Usage (in Kilobytes): Uptime: 2869016 Realtime: 2869016 ** MEMINFO in pid 31551 [com.wingos.wingcamera] ** Pss Private Private Swap Rss Heap Heap Heap Total Dirty Clean Dirty Total Size Alloc Free ------ ------ ------ ------ ------ ------ ------ ------ Native Heap 30023 30000 0 0 33364 41224 33756 2045 Dalvik Heap 29144 29044 0 0 38408 21332 10666 10666 Dalvik Other 2568 2460 0 0 5776 Stack 1120 1120 0 0 1136 Ashmem 717 600 0 0 1688 Other dev 25 0 24 0 488 .so mmap 9569 676 2376 0 72820 .jar mmap 1109 0 64 0 32516 .apk mmap 14498 0 14116 0 16888 .ttf mmap 33 0 0 0 160 .dex mmap 23 8 0 0 872 .oat mmap 416 0 0 0 14616 .art mmap 1053 844 0 0 21504 Other mmap 995 40 108 0 3916 EGL mtrack 7420 7420 0 0 7420 GL mtrack 8268 8268 0 0 8268 Unknown 1309 1308 0 0 2108 TOTAL 108290 81788 16688 0 261948 62556 44422 12711 App Summary Pss(KB) Rss(KB) ------ ------ Java Heap: 29888 59912 Native Heap: 30000 33364 Code: 17244 138120 Stack: 1120 1136 Graphics: 15688 15688 Private Other: 4536 System: 9814 Unknown: 13728 TOTAL PSS: 108290 TOTAL RSS: 261948 TOTAL SWAP (KB): 0 Objects Views: 95 ViewRootImpl: 1 AppContexts: 7 Activities: 1 Assets: 35 AssetManagers: 0 Local Binders: 34 Proxy Binders: 44 Parcel memory: 13 Parcel count: 54 Death Recipients: 4 OpenSSL Sockets: 0 WebViews: 0 SQL MEMORY_USED: 0 PAGECACHE_OVERFLOW: 0 MALLOC_SIZE: 0 |
其实按正常情况来说,对于内存泄露,我们只需要关注里面我标红的数值变化,所以我们需要将关键数值批处理出来到一张表中,然后再画出统计图。
接下来我会结合详细的Python代码进行讲解,需要注意的是,该代码需要一定的Python基础,但不多,主要用到os跟csv库。为了让大家都能看懂,我这边有仔细得进行注释。大家可以简单学学python语法就能自己写一个出来。
import os
import sys
import re
import codecs
import csv
# 定义函数
def start_find_str():
# 打开每个memnifo文件,并将其定义为infile变量
with open(filePathr, 'r', encoding='utf-8', errors='ignore') as infile:
# 创建一个列表作为表格每一行的数据,通过append方法进行添加数据
memlist = []
# 我们需要的三列数据变量
strt, strs, stra = "", "", ""
while True:
content = infile.readline()
if not content:
strt, strs, stra = "", "", ""
infile.close()
return
# re.match里面用正则表达式,一行一行的遍历文件内容,如果这行包含TOTAL则match,进行抓取操作
elif re.match(r'(.*TOTAL .*)', content):
# 这个是获得一个列表,获得这行所有的数字
strlist = re.findall(r"\d+\.?\d*", content)
# 获得列表的第一个数字则是Total的值
strt = "".join(strlist[0])
memlist.append(strt)
# 下面类似,就不一一赘述
elif re.match(r'(.*TOTAL SWAP PSS:.*)', content):
strlist = re.findall(r'\d+', content)
strs = ''.join(strlist[1])
memlist.append(strs)
elif re.match(r'(.*Activities:.*)', content):
strlist = re.findall(r"\d+\.?\d*", content)
stra = ''.join(strlist[1])
print(len(strs))
if len(strs) == 0:
memlist.append("")
memlist.append(stra)
elif re.match(r'(.*PAGECACHE_OVERFLOW: .*)', content):
csvwriter.writerow(memlist)
memlist.clear()
# start to find
#入口函数
if __name__ == '__main__':
# 获取meminfo文件路径,sys.argv[1] 是用户传进来的,这个取决于用户怎么执行这个文件
filePath = str(sys.argv[1]) + "\\meminfo"
# os.listdir()列出这个文件夹路径的所有文件
file_counts = len(os.listdir(filePath))
# 获取proc_meminfo_res文件路径
filePathw = str(sys.argv[1]) + "\\proc_meminfo_res"
# 设定表头
headers = ['TOTAL', 'TOTAL SWAP PSS', 'Activities']
i = 1
# 打开文件 with open() as
# outfile这里可以理解成变量名,要保存的csv文件就是filePathw
with open(filePathw, 'w', encoding='utf-8', errors='ignore') as outfile:
# csv的writer方法,在创建csv文件之前都要执行这个方法
csvwriter = csv.writer(outfile)
# 开始将表头进行写入
csvwriter.writerow(headers)
# 开始循环每个文件去抓取数据
while i <= file_counts:
filePathr = filePath + "\\meminfo_" + str(i) + ".txt"
start_find_str()
i += 1
print('handle data size : %d , yeah!' % (i - 1))
print("meminfo already done")
# 循环结束,关闭OS流
# 得到csv文件
outfile.close()
最后得到的结果就是下面图片显示的文件
按照正常的分析内存泄漏有这个文件就可以了,只需要后续将其装换成excel表格我们便可以直观的查看他的数值走势。
但是我们也可以看一下swap文件,因为两个原理都差不多,这边就不多加赘述。
接下来我们来看看怎么将已有csv文件装换成excel文件。
Python代码也很简单,我这边就做个简单的解释。
# coding=utf-8
import pandas as pd
import os
import sys
# Python 3
# 将csv文件转为xlsx格式,以逗号分隔
def csv_to_xlsx_pd():
#filePath = input("please input dir:")
# 进入到filePath这个目录中
os.chdir(filePath)
#filer = input("please input read file name:")
# 调用pd库,进行直接装换
csv = pd.read_csv(filer, encoding='utf-8')
csv.to_excel(filer+'.xlsx', sheet_name='data')
if __name__ == '__main__':
filePath = str(sys.argv[1])
filer = str(sys.argv[2])
csv_to_xlsx_pd()
自此excel表格就做转换好了,我们接下来就可以依据表格进行画图表,看斜率,看方差的形式来判断是否存在内存泄漏。一般考虑商业行为数字不超过一个数值都是能接受的。
当然如果Python使用的比较熟练的话可以,直接写个自动将excel的内容转化成图表。也是使用Python提供的库,使用非常便捷,可以自己来试试。
Hrof文件分析
当应用发生泄漏的时候,我们这时候就需要找到到底什么地方存在泄漏了,然后将其进行解决,这时候hrof文件就显得尤为重要了。Hrof记录了应用实时的内存堆栈情况,所以学会怎么去看尤为重要。
首先是选择合适的分析工具。我这边给大家举例几个,比较常见的软件工具:
MAT(Eclipse Memory Analyzer)、Android Studio、LeakCanary。
考虑到大家平时最常用的IDE就是AS,今天的讲解也会从AS出发,其实本质上都差不多,主要还是要知道几个简单的概念的问题就没问题了。
使用hrof文件也很简单,只需要将hrof文件拖进到AS中,AS就会自动加载profile功能,上面两幅图就是出现profile界面。我们可以先来认识一下红框里面的各种名词是什么概念。
Class Name:对象的类型,比如byte[]就是说这里的对象都是byte数组类型。
Allocations:生成对象的数量,这里还是拿byte[]来说,这里的360就是说生成了360个byte[]对象。
Shallow Size:这些对象所占用的内存(不包括引用对象),这里还是拿byte[]来说,这里的25218442就是360个byte[]对象所占的内存,单位是B,也就是25218442/1024/1024M。
Retained Size:这些对象所占用内(包括应用对象),内存释放时,实际释放的大小就是这里的大小。
举个例子可能就更明白了,有两个对象,对象A的大小是20kb,对象B的大小是10kb,对象B是对象A的成员变量,那么:
Class Name:A Allocations:2 Shallow Size:2*20*1024 Retained Size:2*20*1024+2*10*1024
Class Name:B Allocations:1 Shallow Size:10*1024 Retained Size:10*1024
我们在分析内存泄漏的时候重点需要关注的数值就是Retained Size,如果内存一直在增长,我们首先就要观察不同时间hrof文件中,什么classname的Retained Size一直在变大,这时候很可能就是他一直无法释放导致内存发生了泄漏。
例如很多时候你会发现byte[]的Retained Size一直在增长,内存泄漏很可能就是他导致的,但是byte[]又很抽象,根本看不出来是代码的哪里发生了泄漏,这时候我们需要点击item,他便会跳出相对应的preference,AS提供的profile有一个超级好用的引用链的功能,可以仔细的查看相关的调用情况,如下图所示。如果公司里面的代码并没有进行内网管理,直接就是在外网进行开发以及调试,更是可以直接右键会有一个jump to source,go to instance,可以直接跳转到相关的逻辑或资源。
其实在这里就能看到是bitmap导致的内存泄漏,我们只需要接着顺腾摸瓜,局面就会越来越清晰。在基本明确泄漏点之后,可以根据这个点涉及的操作,重新进行脚本的dump内存,进一步明确是否是这个点发生的泄漏,提高整体问题解决的效率。
在找到问题之后,这时候新的问题又来了,我们往往看着代码无能为力,暗自发出感叹,“诶 不可能啊 这么写没问题啊”这就涉及到下一个章节我们需要描述的问题了。
常见问题的分析和解决
非静态内部类
非静态内部类使用很容易会引起内存泄漏问题。这我们就需要看看非静态内部类和静态内部类之前的区别了。
下面这个表格是搬运的公司前辈总结的内容,我们需要着重注意一下最后生命周期那一行。非静态内部类的生命周期是依赖于外部类,甚至最后会比外部类的时间域更长。
class 对比 | static inner class | non static inner class |
与外部 class 引用关系 | 如果没有传入参数,就没有引用关系 | 自动获得强引用 |
被调用时需要外部实例 | 不需要 | 需要 |
能否调用外部 class 中的变量和方法 | 不能访问非静态变量、方法,但是可以访问静态变量、方法 | 能访问非静态变量、方法,但不能访问静态变量、方法 |
生命周期 | 自主的生命周期 | 依赖于外部类,甚至比外部类更长 |
而至于为什么会比外部类长,主要还是因为非静态内部类被调用的时候是需要外部进行一个实例化的操作,在实例化之后,非静态内部类实例会获得外部类的一个强引用。举个例子来说:
1.public class OuterClass {
2. private String name;
3.
4. public OuterClass(String name) {
5. this.name = name;
6. }
7.
8. public void doSomething() {
9. InnerClass inner = new InnerClass();
10. inner.printName();
11. }
12.
13. public class InnerClass {
14. public void printName() {
15. System.out.println(name);
16. }
17. }
18.}
在这个例子中,OuterClass 是外部类,InnerClass 是非静态内部类。OuterClass 有一个字段 name 和一个方法 doSomething(),而 InnerClass 有一个方法 printName(),用于打印外部类的 name 字段。
假设我们创建一个 OuterClass 实例并调用它的 doSomething() 方法:
1.OuterClass outer = new OuterClass("John");
2.outer.doSomething();
此时会创建一个 OuterClass 实例,并在 doSomething() 方法中创建一个 InnerClass 的实例 inner。在 InnerClass 的 printName() 方法中打印了外部类的 name 字段。
假设在 doSomething() 方法执行结束后,外部类实例 outer 不再被引用,但 inner 仍然存在,并且持有对外部类实例的隐式引用。即使外部类实例 outer 可以被垃圾回收,但由于 inner 持有对外部类实例的引用,垃圾回收器无法回收 outer 和 inner。
因此,InnerClass 的生命周期可能比 OuterClass 更长,直到 inner 也不再被引用或被显式地销毁。要避免这种情况下的内存泄漏,您需要及时释放对外部类实例的引用。在上述示例中,可以在 doSomething() 方法的末尾将 inner 设置为 null,以便让垃圾回收器回收它们:
1.public void doSomething() {
2. InnerClass inner = new InnerClass();
3. inner.printName();
4. inner = null; // 显式释放对外部类的引用
5.}
通过上面的举例,我们基本可以总结出两点,来避免非静态内部类导致的。
- 使用非静态内部类,在使用外部类使用完内部类的时候,及时释放对外部类实例的引用。
- 使用静态内部类可以避免非静态内部类可能带来的内存泄漏问题。在需要定义内部类的情况下,如果内部类不需要访问外部类的实例字段或方法,建议将内部类声明为静态内部类,以避免潜在的内存泄漏风险。
静态View
在 Android 开发中,静态 View 变量可能会导致内存泄漏问题。这是因为静态变量的生命周期与应用程序的生命周期相同,如果一个静态 View 变量持有了一个对于 Activity 或 Fragment 的引用,那么即使它们已经被销毁,仍然无法被垃圾回收,从而导致内存泄漏。
1.public class MyActivity extends AppCompatActivity {
2. private static TextView staticTextView;
3.
4. @Override
5. protected void onCreate(Bundle savedInstanceState) {
6. super.onCreate(savedInstanceState);
7. setContentView(R.layout.activity_my);
8.
9. staticTextView = findViewById(R.id.static_text_view);
10. staticTextView.setText("Hello");
11. }
12.}
在这个示例中,MyActivity 类中有一个静态的 staticTextView 变量,它引用了布局中的一个文本视图。当 MyActivity 创建时,将通过 findViewById 方法获取该文本视图的引用,并将其赋值给静态变量。如果 MyActivity 被销毁,但是静态变量仍然持有对文本视图的引用,即使 MyActivity 已经被垃圾回收,文本视图也无法释放,导致内存泄漏。
为了避免这种情况的内存泄漏,应该尽量避免在静态变量中持有对 View 的引用。如果需要使用静态变量存储数据,应该确保及时释放对 View 的引用,可以在适当的时机将静态变量设置为 null,例如在 Activity 的 onDestroy 方法中。
1.@Override
2.protected void onDestroy() {
3. super.onDestroy();
4. staticTextView = null;
5.}
通过这样的方式,可以释放静态变量对 View 的引用,使得 View 能够在不再需要时被正常垃圾回收,避免内存泄漏问题。
各种线程任务
无论是Thread、Asynctask、Handle,都很容易造成内存的泄漏。因为他们的声明很容易就会对外部的类有一个隐式的引用,最后如果活动已经结束了,但是线程还没结束的话,因为线程持有活动的引用,导致外部的类无法被垃圾回收机制给回收。
1.public class MyActivity extends AppCompatActivity {
2. private Thread mThread;
3.
4. @Override
5. protected void onCreate(Bundle savedInstanceState) {
6. super.onCreate(savedInstanceState);
7. setContentView(R.layout.activity_my);
8.
9. mThread = new Thread(new Runnable() {
10. @Override
11. public void run() {
12. // 执行一些耗时操作
13. // 例如,访问长生命周期对象或持有对其的引用
14. }
15. });
16. mThread.start();
17. }
18.
19. @Override
20. protected void onDestroy() {
21. super.onDestroy();
22. // 注意:在 Activity 销毁时停止线程的执行
23. mThread.interrupt();
24. }
25.}
在这个示例中,我们在 onCreate 方法中创建了一个新的 Thread,并在其中执行了一些耗时操作。这个 Thread 对象持有对外部类 MyActivity 的引用,因为它需要访问外部类的成员变量和方法。
一般的解决方式也很简单,在安卓开发的时候,在最后的摧毁生命周期的时候,将线程相关的逻辑进行停止,并将线程置空。或者声明的时候进行静态内部类的声明,这都可以有效防止内存泄漏的发生。
集合中的对象未清理造成内存泄露
这个比较好理解,如果一个对象放入到ArrayList、HashMap等集合中,这个集合就会持有该对象的引用。当我们不再需要这个对象时,也并没有将它从集合中移除,这样只要集合还在使用(而此对象已经无用了),这个对象就造成了内存泄露。并且如果集合被静态引用的话,集合里面那些没有用的对象更会造成内存泄露了。所以在使用集合时要及时将不用的对象从集合remove,或者clear集合,以避免内存泄漏。
资源未关闭或释放
这个估计是最常见的内存泄漏的形式了,image、Bitmap等一些资源相关的小玩意,你要是忘记释放的话,他们可是会一直堆在你那为数不多的内存里面发烂发臭。在使用IO、File流或者Sqlite、Cursor等资源时要及时关闭。这些资源在进行读写操作时通常都使用了缓冲,如果及时不关闭,这些缓冲对象就会一直被占用而得不到释放,以致发生内存泄露。因此我们在不需要使用它们的时候就及时关闭,以便缓冲能及时得到释放,从而避免内存泄露。
总结
以上就是内存泄漏总的一个流程和注意点,只要不是特别要求内存的项目,一般的内存泄漏按照上面的的步骤一步一步来,都可以做到初步的释放泄漏的内存。
噢!对了,在最后我们来区分一下内存溢出、内存泄漏、内存抖动之间的关系和区别吧。
首先内存溢出就是我们俗称的OOM,程序申请内存的时候,申请的大小已经比能提供的最大内存,这时候程序一般表现为程序崩溃或者异常停止。
内存泄漏主要就是程序中有不正确的内存使用,一些需要清除的内存,没有及时清除,造成堆积内存。如果一直在泄漏的话,最后就会慢慢演变成内存溢出了。
内存抖动指的是系统频繁地在物理内存和虚拟内存之间进行页面交换,而无法有效利用内存的情况。当系统频繁地加载和卸载页面,导致大量的页面调度开销和延迟,从而影响系统的性能。内存抖动通常发生在系统内存不足、运行多个内存密集型应用程序或任务以及内存分配不合理等情况下。为了避免内存抖动,可以通过增加物理内存、优化内存管理和减少不必要的内存分配等方式来改善系统性能。