信頼性(Reliability)テストとは、「障害の発生のしにくさ」をテストすること。
システムやサービスが使えなくなる頻度やその間隔を示す指標である平均故障間隔(Mean Time Between Failures)を用いて品質状況を判断する。
このテストの中ではリソースリークも不具合報告対象になる。
不具合報告に対してエンジニアが不具合箇所を特定できない場合、次のような要求がくることがある。
特定するためにログ追加したAPKを作成したので、こちらで再現をお願いします。
なめてるの?
テスターに再現させて不具合治し続けてるからプロジェクト計画が遅延するんだよ!
不具合発生する毎にログ追加した再現依止めろや!
こんな解析しているから、僕の台湾出張が伸びたんだろ!
僕はエンジニアでもテスターでもないけれど「品質戦略立てたのお前だから責任とれ」とかいわれて、この数ヶ月間テストして解析して修正箇所を特定してきた。
リーク解析手順はプロセス化しやすいので記載しておく。
メモリやファイルディスクリプタなど、リソース枯渇に関する問題は、使用可能なリソースが時間と共に減少する問題である。
利用可能なリソースが減少すると、システムの性能が低下したり不安定になる。
これは、システムを再起動しなければ回復しない。
リソースリークの問題は、一定期間・一定間隔でリソース使用量を採取し、統計を解析する事が重要となる。
主なリソースリークと確認方法は次のとおり。
測定目的 | 取得コマンド | 確認点 | ||
---|---|---|---|---|
システム上のメモリ使用数チェック | cat /proc/meminfo | Buffers、Cashed | ||
slabを増加チェック | cat /proc/meminfo | Slab | ||
kernel slab memory leak検出 | cat /proc/slabinfo | |||
threadの増加チェック | cat /proc/meminfo | KernelSlack | ||
kernel slack thread leak検出 | ps -t | |||
userlandプロセスのmemory leakチェック | dumpsys meminfo | PSS | ||
global Reference leakチェック | call->Dump.dumpReferenceTable() | logcat | ||
FD(file descriptor)増加チェック | lsof |
今回はメモリリークについて記載する。
メモリリークしているプロセスの特定方法
メモリリークに限っていえば色々なツールや方法でリークを発見可能。
例えば「ps」コマンドでも「dumpsys」コマンドでもよい。
Android10からは Android デバイス上からパフォーマンス トレースを取得するためのツール「perfetto」なども存在する……らしい。
「dumpsys」コマンドを使った方法を紹介する。
1 |
$ dumpsys meminfo -t 50 |
「t」オプションはタイムアウト時間を秒単位で指定(デフォルト値は 10 秒)。
1 |
$ dumpsys meminfo -c |
「c」オプションは処理しやすいコンパクトな表示となる。
これを例えば10分毎に出力し、出力されたデータをグラフ化する。
グラフ作成はgnuplotでもExcelを使っても良い。
一定期間中にほぼ単調増加しているのであれば、そのモジュールはメモリリークの疑いがある。
この時点で不具合報告対象ではあるが、リークしている量に応じて不具合報告の重篤度は変わるため
2時間で1MBのメモリリークは修正必須
などとエンジニアチームと議論して予め決めておいた方がよい。
dumpsysで出力させた結果内容の説明
メモリリークしているプロセス名が特定出来ているなら次のように「dumpsys」を使うことができる。
1 |
$ adb shell dumpsys meminfo com.android.systemui |
出力結果は次の通り。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
Applications Memory Usage (in Kilobytes): Uptime: 797818 Realtime: 797818 ** MEMINFO in pid 1159 [com.android.systemui] ** Pss Private Private Swap Rss Heap Heap Heap Total Dirty Clean Dirty Total Size Alloc Free ------ ------ ------ ------ ------ ------ ------ ------ Native Heap 9477 9464 0 0 11720 12104 10854 1249 Dalvik Heap 7458 7352 0 0 15440 12074 6037 6037 Dalvik Other 3458 3236 0 0 5080 Stack 1116 1116 0 0 1124 Ashmem 891 840 0 0 1916 Other dev 323 0 320 0 648 .so mmap 2680 248 60 0 41680 .jar mmap 2034 0 0 0 32352 .apk mmap 18323 0 15488 0 27472 .ttf mmap 208 0 76 0 384 .dex mmap 1564 1272 264 0 2072 .oat mmap 412 0 0 0 12556 .art mmap 1543 1352 0 0 14780 Other mmap 378 4 124 0 2504 GL mtrack 700 700 0 0 700 Unknown 522 520 0 0 976 TOTAL 51087 26104 16332 0 171404 24178 16891 7286 |
それぞれの内容を説明してる日本語サイトは少ない。
用語 | 説明 | 補足 |
---|---|---|
Uptime | スリープ時間を除く、起動から現在までの時間 | ミリ秒 (ms) 単位で示す |
Realtime | スリープ時間を含む、起動から現在までの時間 | ミリ秒 (ms) 単位で示す |
Native Heap | cのmallocからヒープ領域を参照 | C++ によって要求されたメモリはネイティブ プロセス、Java によって要求されたメモリはJava プロセス |
Dalvik Heap | Java の新しい Java ヒープ領域を指す | 仮想メモリが占めるスペースだけ |
Pss Total | 実際の物理メモリを占有するスペース | |
Private Dirty | プライベート常駐メモリ | プロセスのメモリ空間は物理メモリとは異なる仮想メモリであり、プロセスは物理メモリ RAM を直接操作できない。必要に応じて、OSがプロセスを物理メモリに適用できるようにマップする |
Heap Size | 占有されているメモリの合計 (ヒープ heap) | |
Heap Alloc | 仮想アドレス内の割り当てスペース | |
Heap Free | 空きメモリ |
この中で大事なのはPrivate Dirty。
Dalvik Heap (= Java Heap) と Native Heap の「Private Dirty」列を見ると、Java ヒープ上の SystemUI のメモリ使用量が 9M、Native ヒープ上で 7M であることがわかる。
このデータを
- テスト前
- テスト後
で取得して(もしくは定期的に取得して)Dalvik HeapとNative Heap のどちらが単調増加傾向にあるのかをグラフ化するとよい。
[おまけ] PSコマンド、TOPコマンド
各プロセスの仮想メモリ使用量は、psコマンドでは「VSZ」、topコマンドでは「VIRT」として確認できる。
また各ユーザープロセスの物理メモリ使用量を確認するには、psコマンドでは「RSS」、topコマンドでは「RES」がある。
ユーザー空間 | カーネル空間 | |
---|---|---|
仮想メモリ | カーネルへの影響:なし 他プログラムへの影響:なし 発生事象:メモリを消費したプログラムでENOMEMエラーが発生する |
カーネルへの影響:限定的 他プログラムへの影響:なし OSハングアップという観点では考慮不要 |
物理メモリ | カーネルへの影響:なし 他プログラムへの影響:あり 発生事象: ・スワップアウト発生 ・ページキャッシュ減少 ・OOM-Killer発生 |
カーネルへの影響:あり 他プログラムへの影響:あり 発生事象: ・スワップアウト発生 ・ページキャッシュ減少 ・OOM-Killer発生 |
この中で、カーネルへの影響があり、OS停止に至り、リブートする可能性があるのは、「カーネル空間」の「物理メモリ」を消費しているとき。
メモリリーク解析のドリルダウン
メモリリークの解析には「ドリルダウン(掘り下げて詳細な情報を得る)」が必要となる。
ヒープ ダンプ(hprof ファイル)を出力
Javaレイヤリークの疑いがあるのであればJavaVMのダンプ出力ファイル(特定の時点でのJavaプロセスメモリのスナップショット)であるHPROFファイルを取得する。
ヒープ ダンプ(hprof ファイル)を出力するだけならコマンドラインから可能(プロセス番号が「2099」の場合)
1 2 3 4 |
% ./adb shell am dumpheap 2099 /data/local/tmp/myleakcanaryheapdump.hprof Waiting for dump to finish... |
【Linux】
1 2 3 |
% adb shell ps -ef | grep extservice u0_a188 2099 416 0 10:46:36 ? 00:00:00 com.hoge.extservice |
【Windows】
1 2 |
> adb shell ps -ef | findstr extservice u0_a188 2099 416 0 10:46:36 ? 00:00:00 com.hoge.extservice |
もしくは直接プロセス名を指定してもよいし、出力名を指定しなくてもよい。
1 2 3 4 |
% ./adb shell am dumpheap com.hoge.test File: /data/local/tmp/heapdump-20230529-164441.prof Waiting for dump to finish... |
ファイル名が指定されていない場合、デフォルトのパスとファイル名は次のようになる。
/data/local/tmp/heapdump-日付-時間.prof
GC(ガーベージコレクション)で回収するメモリか確認する
GC(ガーベージコレクション)で回収するメモリは、dalvik と art のメモリ。
プログラムが使用しなくなったと判断されたメモリをプログラマが関与することなくヒープに戻すメカニズム。
「am dumpheap -g」コマンドを使用すると、強制的にGCを実施することができる。
このため、
【GC前】
- adb shell am dumpheap プロセス名 /data/local/tmp/before_heapdump.hprof
- adb shell dumpsys meminfo プロセス名 > /data/local/tmp/dumpsys_before_gc.txt
【GC後】
- adb shell am dumpheap -g プロセス名 /data/local/tmp/after_gc_heapdump.hprof
- adb shell dumpsys meminfo プロセス名 > /data/local/tmp/dumpsys_after_gc.txt
を比較して、Dalvik heapが信頼性(Reliability)テスト実施前の使用量まで減っているのであれば、java管理のメモリは問題なく回収されている。
GCを実施すると「natvice」と「jar」のメモリ使用量が増えることがある。
これは、おそらくheapファイルを保存するのに使用されるnatviceなメモリなんじゃないかな……。
なお、am dumpheapコマンドのオプションは次のとおり。
- –user: ユーザー ID、デフォルトの UserHandle.USER_CURRENT
- -n: natice のヒープダンプを取得します
- -g: 取得する前に 1 回 gc します。
- -m: パラメータを非表示にする
【初期準備】
- adb shell am dumpheap プロセス名 /data/local/tmp/start_heapdump.hprof
【テスト中】
定期的に実行する
- adb shell dumpsys meminfo プロセス名
【テストが終了】
- adb shell am dumpheap プロセス名 /data/local/tmp/before_heapdump.hprof
- adb shell dumpsys meminfo プロセス名 > /data/local/tmp/dumpsys_before_gc.txt
- adb shell am dumpheap -g プロセス名 /data/local/tmp/after_gc_heapdump.hprof
- adb shell dumpsys meminfo プロセス名 > /data/local/tmp/dumpsys_after_gc.txt
【hprofとdumpsys結果の回収】
- adb pull /data/local/tmp/start_heapdump.hprof .
- adb pull /data/local/tmp/before_gc_heapdump.hprof .
- adb pull /data/local/tmp/after_gc_heapdump.hprof .
- adb pull /data/local/tmp/dumpsys_before_gc.txt .
- adb pull /data/local/tmp/dumpsys_after_gc.txt .
GCで回収されないJava メモリリークの解析方法
メモリ使用量などのアプリのパフォーマンスを監視するのには以前は「Dalvik Debug Monitor Server (DDMS)」というAndroid SDK に含まれるツールを使うのが定番だった。
ただし、DDMS は Google によってサポートされなくなり、今ではAndroid Memory Profiler が定番。
Android Studioを起動し、File→Openでhprofファイルを読みこむ。
表示されている意味はそれぞれ次の通り。
- Allocations: ヒープ内の割り当ての数
- Native Size: Java で割り当てられたオブジェクト用のメモリが表示される
- Shallow Size: このオブジェクト タイプによって使用されている Java メモリの合計量(バイト単位)
- Retained Size: このクラスのすべてのインスタンスのために保持されているメモリの合計サイズ(バイト単位)
残念ながら、今回のメモリリークにGCで回収されないJavaレイヤのメモリは見つからなかったので解析方法は省略。
GCで回収されず、Native heapがメモリリークしている場合
adb shell showmap PID(Android の場合) を使用するか /proc/PID/smaps を見ることで、リークの疑いのあるオブジェクトを見つける。
1 2 |
$ adb root $ adb shell showmap $PID| sort -nr -k 2 | head -10 |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
virtual shared shared private private Anon Shmem File Shared Private size RSS PSS clean dirty clean dirty swap swapPSS HugePages PmdMapped PmdMapped Hugetlb Hugetlb # object -------- -------- -------- -------- -------- -------- -------- -------- -------- --------- --------- --------- -------- -------- ---- ------------------------------ 1356528 202040 69333 112716 31928 16556 40840 0 0 0 0 0 0 0 2880 TOTAL 28124 24768 22500 0 2280 0 22488 0 0 0 0 0 0 0 419 [anon:libc_malloc] 34600 24104 17747 8584 0 15520 0 0 0 0 0 0 0 0 4 /system_ext/priv-app/SystemUI/SystemUI.apk 31284 23848 1693 23848 0 0 0 0 0 0 0 0 0 0 5 /system/framework/framework.jar 26952 17440 2544 16228 1188 0 24 0 0 0 0 0 0 0 4 /vendor/lib/egl/libGLES_mali.mt5897.so 9180 9180 1182 0 8116 0 1064 0 0 0 0 0 0 0 1 [anon:dalvik-/system/framework/boot-framework.art] 7704 7508 317 7496 12 0 0 0 0 0 0 0 0 0 5 /system/framework/arm/boot-framework.oat 393216 7368 7368 0 0 0 7368 0 0 0 0 0 0 0 1 [anon:dalvik-main space (region space)] 6728 6728 493 0 6328 0 400 0 0 0 0 0 0 0 1 [anon:dalvik-zygote space] 5552 4712 840 4620 80 0 12 0 0 0 0 0 0 0 4 /system/lib/libhwui.so |
上の例で[anon:libc_malloc]がリークしてそうだと分かったとする。
ネイティブ領域のメモリリーク解析方法
Androidアプリ上では C/C++ などのNativeコードがない場合でも、Native メモリが使用されることがよくある。
例えばNative ライブラリを内包してたり、一部のフレームワーク API (Regex など) の実装がNativeコードを通じて内部実装されてる事もある。
例えば、Nativeのpthreadのリークだと分かったが、Java・Kotlinレイヤでquit()、nullを忘れていた……など。
1 2 |
thread?.quit() thread = null |
今から記載するNativeレイヤのリーク解析は、端末のroot権限がないと実施できないしパッケージのシンボル情報も必要なので端末メーカの方法。
- 「libc.debug.malloc.program」「libc.debug.malloc.options」にリークの疑いのあるプロセスを指定
- 「kill -47 <プロセスID>」でバックトレースをdump
- 「native_heapdump_viewer.py」を使ってシンボル情報を与えることでdump情報からコード情報に変換しHTMLファイルを作成する
- 「テスト開始前」「テスト開始後」のリーク箇所を特定する
ここはAndroidのデバイスのビルド環境に依存するために、現場によって異なる。
可能であれば、テスト前・後のHTMLファイルを比較すればメモリリークしているソースコードの行数まで一発で分かる。
おわりに
「デバッグを考慮した設計」ができない人は開発者に向いてない。
まぁ僕は開発者にはなれなかったけどね……
エンジニアスキルによらずに不具合解析できるようにAI(人工知能)使う活動も何年も進めてる……日の目を見ることはあるのかな。