LowMemoryKiller 〜AndroidのActivityが破棄される仕組み〜


この記事は、LIFULLその2 Advent Calendar 2020の23日目の記事です。

今回は、Androidの低レイヤーな話を取り上げてみようと思います。
具体的には、OOM Killerでプロセスがkillされるのを未然に防ぐLowMemoryKillerの仕組みについてです。

ネイティブアプリの開発は、よりメモリの事を意識した開発が必要だなと日々感じていたので、もっと低いレイヤーで何が行われているかをちゃんと理解したいと思ったのがきっかけで、勉強してきた内容になります。

読んでいただけたらこの辺の内容を理解できる内容になっていると思います。

  • LowMemoryKillerの仕組み
  • Activityが破棄される基準
  • onSaveInstanceState()Bundleに保存するデータは実際どこに保存されているのか

Androidエンジニアじゃなくても、Androidの世界を少し覗いた気になれるような記事になればいいなと思います。

はじめに

Androidには、OOM Killerによってプロセスがkillされるのを未然に防ぐLowMemoryKillerという仕組みがあります。
AndroidでActivityが突如破棄されてしまうことがありますが、その仕組みに深く関係しているのがLowMemoryKillerです。

LowMemoryKillerの必要性

LowMemoryKillerの仕組みに入る前に、LowMemoryKillerがどんなときに必要になるか話をします。

まず前提知識になりますが、Androidアプリ開発でまず画面を用意するときは、Activityというコンポーネントを使用します。1つのActivityが1つの画面というイメージでここでは大丈夫です。(Activity上で複数の画面をFragmentで管理する設計もありますが、今回はFragmentは話に必要ないので省きます)
一般的に、1つのアプリには複数のActivtityが含まれ、1アプリは1プロセスで実行されます。

デフォルトでは、同じアプリのすべてのコンポーネントは同じプロセスで実行され、ほとんどのアプリでこの動作を変更する必要はありません。

アプリを閉じてもユーザーがアプリをkillしない限りバックグラウンドに残り続けます。Activityのインスタンスもプロセス内に残り続けます。

そのため、あるアプリがバックグラウンド時に他のアプリがメモリを要求した場合、アプリ側からみると以下のような段階を踏んで空きメモリを確保しようとします。

  1. メモリの開放
    • Activity#onLowMemory()Activity#onTrimMemory(int)が呼び出される
    • (Fragmentの破棄などのタイミングがここにあたる)
  2. Activityの破棄
    • Activity#onDestroy()が呼び出される
    • ただし、Activity#onSaveInstanceState(Bundle)が事前に呼び出され、必要な状態は保存される
  3. プロセスの終了
    • プロセスが終了される
    • この場合、実行中のActivityは終了するがActivity#onDestrory()は呼び出されない
    • アプリがActivity#onStop()で停止している状態であれば、onSaveInstanceState(Bundle)は呼び出された後となるため、Activityの状態復元は可能

このようにメモリが足りなくなると最終的にはアプリのプロセス終了に到ります。実際にプロセスの終了を行うカーネル側からみると、これがOOM Killerによってプロセスがkillされるタイミングですと、今ユーザーにとって重要なアプリでも問答無用でkillされることになってしまいます。

こんな事態にならないよう未然に防ぐのがLowMemoryKillerです。

OOM Killerよりもっと前の段階で、そのActivityが重要か重要じゃないかを判断して、重要度の低いActivityを持つプロセスからkillしていき、メモリを空けるように動きます。

  • OOM Killer
    • どのプロセスがkillされるかは運任せに近く、重要なプロセスでも問答無用でkillする
  • LowMemoryKiller
    • 重要度が低いActivityを持つプロセス(ex. バックグラウンドにいるActivity)から段階的にkillすることを試みる

このようにLowMemoryKillerが裏で不要なプロセスをよしなにkillしてくれるおかげで私達は、普段快適にAndroidのスマートフォンを利用できているわけです。

LowMemoryKillerの全体像

本題のLowMemoryKillerの仕組みについてです。

まずAndroidは、LinuxカーネルをベースとするOSであり、LowMemoryKillerは、Linuxカーネルに既にある機能をうまく活用して実現されている仕組みになります。ざっくり書くと、OOM Killerによってプロセスがkillされる前に重要度が低いActivityを持つプロセスをkillしてメモリを空ける仕組みです。

LowMemoryKillerの登場人物は主に3つです。

  • ActivityManagerService
    • 現在どのActivityが重要かを判断するActivityの管理人のような役割をする
    • ActivityStackというスタックでActivityを管理
    • Activityの重要度を示すスコアの更新依頼をカーネル側に行う
  • lmkdデーモン
    • oom_score_adj というスコアを更新する
  • lowmemorykillerドライバ
    • メモリの空きが少なくなってきたタイミングで表に出ていない重要度の低いアプリのプロセスのkillを試みる
    • 実際にここでプロセスをkillする仕組みには、Linuxのディスクキャッシュの開放を行うshrink_slab()を活用している

例としてA画面 → B画面へのActivity遷移が起きたときの全体像をざっくり図に表すと以下のようになります。

  1. まず、ActivityManagerServiceがActivity BをActivityStackに積む
  2. ActivityManagerServiceは、各Activityの重要度を示すスコアをカーネル側に伝達するため、スコアの更新依頼を行う
  3. lmkdデーモンがスコア(oom_score_adj)を更新する
  4. lowmemorykillerドライバは、端末のメモリに空きが少なくなってきたときに、lmkdで更新されるスコアを参照して、スコアの高いActivityを持つプロセスからkillしていき、メモリを解放することを試みる

※ただし、カーネル4.12の時点で、lowmemorykillerドライバはカーネルから削除されており、lmkdがメモリのモニタリングとプロセス強制終了タスクを実行するように変わっています。基本的に機能としてはlowmemorykillerドライバと同じ機能をサポートしていますが、Android10以降では、メモリプレッシャーの検出にカーネルプレッシャーストール情報(PSI)モニターを使用する、新しいlmkdモードがサポートされていたりします。詳しくは、ローメモリ キラー デーモン(Imkd)を参照ください。

Activityの重要度を示すスコア(oom_score_adj)

lmkdが更新するスコアに従って、メモリの空きが少なくなってきたときにActivityの破棄やプロセスのkillを段階的に行うので、このスコアをActivityが破棄される基準といえそうです。

このスコアは、oom_score_adjと呼ばれ、OOMKillerがプロセスをkillするときにも使用されます。

oom_score_adjは、プロセスとActivityのライフサイクルの状態によって決められるkillされる閾値(メモリ空き容量)を表します。

実際に自分の端末でこのスコアを確認してみました。
(闘値は固定ではなく端末の状態で上下します。)


$adb shell dumpsys activity oom

OOM levels:
    -900: SYSTEM_ADJ (   73,728K)
    -800: PERSISTENT_PROC_ADJ (   73,728K)
    -700: PERSISTENT_SERVICE_ADJ (   73,728K)
      0: FOREGROUND_APP_ADJ (   73,728K)
     100: VISIBLE_APP_ADJ (   92,160K)
     200: PERCEPTIBLE_APP_ADJ (  110,592K)
     250: PERCEPTIBLE_LOW_APP_ADJ (  129,024K)
     300: BACKUP_APP_ADJ (  221,184K)
     400: HEAVY_WEIGHT_APP_ADJ (  221,184K)
     500: SERVICE_ADJ (  221,184K)
     600: HOME_APP_ADJ (  221,184K)
     700: PREVIOUS_APP_ADJ (  221,184K)
     800: SERVICE_B_ADJ (  221,184K)
     900: CACHED_APP_MIN_ADJ (  221,184K)
     999: CACHED_APP_MAX_ADJ (  322,560K)
  • 先頭についている3桁の数字:システム内で生かすべき優先順位
  • [XXXX_XXX_ADJ]の語尾の()の中の数字:プロセスを終了する具体的なメモリの空き容量の闘値

システム内で生かすべき優先順位の数字がより小さいほどプロセスがkillされづらくなり、高いほどkillされやすくなります。システムや永続化サービスを除いた時、一番この中でkillされる可能性が低いのは、level:0のフォアグラウンドいるActivityということになります。

ちなみに、XXXX_XXX_ADJ の意味は、ProcessList.javaのソースコードを読めばわかります。

ActivityがフォアグラウンドにあればそのActivityのscoreはFOREGROUND_APP_ADJで0、1つ前に使っていたならPREVIOUS_APP_ADJで700、ホームのアプリのActivityならHOME_APP_ADJで600といった形で、Activityのライフサイクルの状態などによってスコアが変化します。
例えばActivityの画面遷移が起きた時のスコアの変遷は以下のようになります。

このようにActivityのライフサイクルが変わるたびに、ActivityManagerServiceが各Activityの重要度を判断して、スコアを更新するということです。実際にスコアを更新するのはlmkdです。

Androidを触っていると、”いつの間にかアプリがkillされている”ってことがありますが、内部的にはこのようにActivityのスコア更新を行って、メモリの空きが少なくなったときにはこのスコアの基準に従ってActivity破棄やプロセスのkillが行われているようです。

Bundleは実際どこに保存されているのか

アプリのプロセスがLowMemoryKillerによってkillされる話をここまでしてきましたが、Androidでは、破棄されたActivityを復元させることもできます。

LowMemoryKillerの必要性でも軽く触れましたが、その際は、onSaveInstanceState()でデータをBundleに保存しておき、Activityを復元するときには保存しておいたBundleのデータを取り出すことで、Activityが突然破棄された場合に対処します。これに関してはAndroidエンジニアなら常識になっていると思いますが、

このときBundleは実際どこに保存されているのでしょうか?

その答えは、
SystemServerのプロセス上にあるActivityStackです。

先ほどのoom_score_adjの話を振り返ると、SystemServerのプロセスのoom_score_adjは-900になります。
アプリをバックグラウンドに移動すると、まず前回使っていたActivityとしてスコアは0から700になり、さらに時間が経つにつれてスコアが増えていくことを踏まえると、スコア-900のSystemServerのプロセスにデータを保存しておけばシステムから非常にkillされにくいということがいえます。

ただし、もちろんですがSystemServerのプロセスのメモリを使用するので、Bundleにあまり大きなデータを保存してはいけません。SystemServerのメモリが大きくなりすぎると、メモリを空けようとして、裏のActivityをkillする頻度が上がるため、タスクの切り替えがすごく遅くなったように感じたり、メモリ不足によるアプリの異常終了などが多くなってしまうようです。

またBundleにあまり大きなデータを保存しようとすると、TransactionTooLargeExceptionという例外も発生します。

The Binder transaction buffer has a limited fixed size, currently 1Mb, which is shared by all transactions in progress for the process.

Activity間の画面遷移を行う際に、Intentに載せられるExtraのペイロードの上限が1MBであることはこのようにドキュメントに書かれていますが、手元で何度かこのエラーに遭遇したときは実際は1MBいってなくても発生していました。

個人的な解釈としては、onSaveInstanceState()で保存するBundleにはidやBooleanなどだけで、大きなデータはViewModelか永続化領域に保存か、ネットワークから再取得するか、という使い方が適切なんだろうなと思います。
便利ですがBundleには、なんでもかんでもツッコまないようにしましょう。

おわりに

Linuxカーネルのより詳細な話も書こうとしたのですが、長くなりすぎるので本記事では省略しました。でも今回説明した仕組みをより詳細に知るためにはLinuxの知識も必要となります。気になる方はぜひ参考文献に載せている書籍などをご覧ください。

普段何気なく書いてるコードもこうして裏では動いてくれているんだと思いをはせながら、来年は今年よりメモリに優しい開発をしていきたいなと思います。

メリークリスマス。よいお年を。

参考文献