Как работает распределение растровых изображений в Oreo и как исследовать их память?
Фон
В последние годы, чтобы проверить, сколько памяти у вас на Android и сколько вы используете, вы можете использовать что-то вроде:
@JvmStatic
fun getHeapMemStats(context: Context): String {
val runtime = Runtime.getRuntime()
val maxMemInBytes = runtime.maxMemory()
val availableMemInBytes = runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory())
val usedMemInBytes = maxMemInBytes - availableMemInBytes
val usedMemInPercentage = usedMemInBytes * 100 / maxMemInBytes
return "used: " + Formatter.formatShortFileSize(context, usedMemInBytes) + " / " +
Formatter.formatShortFileSize(context, maxMemInBytes) + " (" + usedMemInPercentage + "%)"
}
Это означает, что чем больше памяти вы используете, особенно путем хранения растровых изображений в памяти, тем ближе вы получаете максимальную память кучи, которую ваше приложение может использовать. Когда вы достигнете максимума, ваше приложение будет аварийно завершено с исключением OutOfMemory (OOM).
Эта проблема
Я заметил, что на Android O (в моем случае это 8.1, но, вероятно, и на 8.0), вышеприведенный код не подвержен распределению растровых изображений.
Продолжая копаться, я заметил в профилировщике Android, что чем больше памяти вы используете (сохраняя большие растровые изображения в моем POC), тем больше используемой памяти.
Чтобы проверить, как это работает, я создал простой цикл как таковой:
val list = ArrayList<Bitmap>()
Log.d("AppLog", "memStats:" + MemHelper.getHeapMemStats(this))
useMoreMemoryButton.setOnClickListener {
AsyncTask.execute {
for (i in 0..1000) {
// list.add(Bitmap.createBitmap(20000, 20000, Bitmap.Config.ARGB_8888))
list.add(BitmapFactory.decodeResource(resources, R.drawable.huge_image))
Log.d("AppLog", "heapMemStats:" + MemHelper.getHeapMemStats(this) + " nativeMemStats:" + MemHelper.getNativeMemStats(this))
}
}
}
В некоторых случаях я сделал это за одну итерацию, а в некоторых я только создал растровое изображение в списке, а не расшифровал его (код в комментарии). Подробнее об этом позже...
Это результат запуска выше:
Как видно из графика, приложение достигло огромного объема памяти, значительно превышающего допустимый максимальный объем кучи, о котором мне сообщили (который составляет 201 МБ).
Что я нашел
Я обнаружил много странного поведения. Из-за этого я решил сообщить о них здесь.
Сначала я попробовал альтернативу приведенному выше коду, чтобы получить статистику памяти во время выполнения:
@JvmStatic fun getNativeMemStats(context: Context): String { val nativeHeapSize = Debug.getNativeHeapSize() val nativeHeapFreeSize = Debug.getNativeHeapFreeSize() val usedMemInBytes = nativeHeapSize - nativeHeapFreeSize val usedMemInPercentage = usedMemInBytes * 100 / nativeHeapSize return "used: " + Formatter.formatShortFileSize(context, usedMemInBytes) + " / " + Formatter.formatShortFileSize(context, nativeHeapSize) + " (" + usedMemInPercentage + "%)" }
Но, в отличие от проверки динамической памяти, кажется, что максимальная собственная память со временем меняет свое значение, что означает, что я не могу знать, каково ее истинное максимальное значение, и поэтому в реальных приложениях я не могу решить, что размер кеша памяти должен быть. Вот результат кода выше:
heapMemStats:used: 2.0 MB / 201 MB (0%) nativeMemStats:used: 3.6 MB / 6.3 MB (57%)
heapMemStats:used: 1.8 MB / 201 MB (0%) nativeMemStats:used: 290 MB / 310 MB (93%)
heapMemStats:used: 1.8 MB / 201 MB (0%) nativeMemStats:used: 553 MB / 579 MB (95%)
heapMemStats:used: 1.8 MB / 201 MB (0%) nativeMemStats:used: 821 MB / 847 MB (96%)
Когда я дохожу до того, что устройство не может хранить больше растровых изображений (остановлено на 1,1 ГБ или ~850 МБ на моем Nexus 5x), вместо исключения OutOfMemory я получаю... ничего! Он просто закрывает приложение. Даже без диалога, говорящего, что это потерпело крах.
В случае, если я просто создаю новое растровое изображение, а не декодирую его (код доступен выше, только в комментарии), я получаю странный журнал, в котором говорится, что я использую тонны ГБ и имею в наличии тонны ГБ собственной памяти:
Кроме того, в отличие от того, когда я декодирую растровые изображения, я получаю сбой здесь (включая диалог), но это не OOM. Вместо этого это... NPE!
01-04 10: 12: 36.936 30598-31301 / com.example.user.myapplication E / AndroidRuntime: ОСНОВНОЕ ИСКЛЮЧЕНИЕ: AsyncTask #1 Процесс: com.example.user.myapplication, PID: 30598 java.lang.NullPointerException: попытка вызвать виртуальный метод 'void android.graphics.Bitmap.setHasAlpha(boolean)' для пустой ссылки на объект на android.graphics.Bitmap.createBitmap(Bitmap.java:1046) на android.graphics.Bitmap.createBitmap(Bitmap.java:980)) на android.graphics.Bitmap.createBitmap(Bitmap.java:930) на android.graphics.Bitmap.createBitmap(Bitmap.java:891) на com.example.user.myapplication.MainActivity$onCreate$1$1.run(MainActivity.kt:21) в android.os.AsyncTask$SerialExecutor$1.run(AsyncTask.java:245) в java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1162) в java.util.concurrent.ecread $. выполнить (ThreadPoolExecutor.java:636) в java.lang.Thread.run(Thread.java:764)
Глядя на график профилировщика, он становится еще более странным. Использование памяти, кажется, совсем не увеличивается, а в момент сбоя оно просто падает:
Если вы посмотрите на график, вы увидите много значков GC (мусорное ведро). Я думаю, что это может быть сжатие памяти.
- Если я делаю дамп памяти (используя профилировщик), в отличие от предыдущих версий Android, я больше не вижу предварительный просмотр растровых изображений.
Вопросы
Это новое поведение вызывает много вопросов. Это может уменьшить количество сбоев OOM, но также может затруднить их обнаружение, обнаружение утечек памяти и их устранение. Может быть, некоторые из того, что я видел, это просто ошибки, но все же...
Что именно изменилось в использовании памяти на Android O? И почему?
Как обрабатываются растровые изображения?
Можно ли по-прежнему просматривать растровые изображения в отчетах о дампе памяти?
Как правильно получить максимальную собственную память, которую разрешено использовать приложению, и распечатать ее в журналах, и использовать ее как средство для определения максимума?
Есть ли видео / статья на эту тему? Я не говорю об оптимизации памяти, которая была добавлена, но больше о том, как распределяются растровые изображения сейчас, как обрабатывать OOM сейчас и т.д....
Я думаю, что это новое поведение может повлиять на некоторые библиотеки кэширования, верно? Это потому, что они могут зависеть от размера кучи памяти.
Как могло быть так, что я мог создать так много растровых изображений, каждый размером 20 000x20 000 (что означает ~1,6 ГБ), но когда я мог создать только несколько из них из реального изображения размером 7 680x7 680 (то есть ~236 МБ)? Действительно ли это делает сжатие памяти, как я уже догадался?
Как собственные функции памяти могут вернуть мне такие огромные значения в случае создания растрового изображения, но более разумные, когда я декодировал растровые изображения? Что они имеют в виду?
Что за странный график профилировщика в случае создания растрового изображения? Она почти не увеличивается в использовании памяти, и все же достигла точки, когда она не сможет создать больше, со временем (после того, как будет вставлено много элементов).
Что со странным поведением исключений? Почему при растровом декодировании я не получил никаких исключений или даже журнал ошибок как часть приложения, и когда я их создал, я получил NPE?
Будет ли Play Store обнаруживать OOM и сообщать о них в случае сбоя приложения из-за этого? Это обнаружит это во всех случаях? Могут ли Crashlytics это обнаружить? Есть ли способ получить информацию о такой вещи, будь то пользователи или во время разработки в офисе?
2 ответа
Похоже, ваше приложение было убито убийцей Linux OOM. Разработчики игр и другие люди, которые активно используют родную память, видят, что это происходит постоянно.
Включение overcommit в ядре вместе с снятием ограничений на выделение растровых изображений на основе кучи может привести к изображению, которое вы видите. Вы можете прочитать немного о overcommit здесь.
Лично я хотел бы видеть OS API для изучения смерти приложений, но я не буду затаить дыхание.
- Как правильно получить максимальную собственную память, которую разрешено использовать приложению, и распечатать ее в журналах, и использовать ее как средство для определения максимума?
Выберите произвольное значение (скажем, четверть размера кучи) и придерживайтесь его. Если вам позвонят onTrimMemory
(который напрямую связан с OOM Killer и собственным давлением памяти), попробуйте уменьшить потребление.
- Я думаю, что это новое поведение может повлиять на некоторые библиотеки кэширования, верно? Это потому, что они могут зависеть от размера кучи памяти.
Не имеет значения - размер кучи Android всегда меньше, чем общая физическая память. Любая библиотека кэширования, которая использовала размер кучи в качестве ориентира, должна продолжать работать в любом случае.
- Как могло быть так, что я мог создать так много растровых изображений, каждый размером 20000x20000
Магия.
Я предполагаю, что текущая версия Android Oreo допускает чрезмерную загрузку памяти: нетронутая память фактически не запрашивается с аппаратного обеспечения, поэтому вы можете иметь ее столько, сколько позволяет ограничение адресуемой памяти ОС (чуть менее 2 гигабайт на x86, несколько терабайт на х64). Вся виртуальная память состоит из страниц (обычно 4 КБ каждая). Когда вы пытаетесь использовать страницу, она выгружается. Если ядру не хватает физической памяти, чтобы отобразить страницу для вашего процесса, приложение получит сигнал, убив его. На практике приложение будет убито Linux OOM убийцей до того, как это произойдет.
Как собственные функции памяти могут вернуть мне такие огромные значения в случае создания растрового изображения, но более разумные, когда я декодировал растровые изображения? Что они имеют в виду?
Что за странный график профилировщика в случае создания растрового изображения? Она почти не увеличивается в использовании памяти, и все же достигла точки, когда она не сможет создать больше, со временем (после того, как будет вставлено много элементов).
График профилировщика показывает использование памяти кучи. Если растровые изображения не учитываются в куче, этот график, естественно, их не показывает.
Нативные функции памяти, кажется, работают как (изначально) предназначенные - они правильно отслеживают виртуальные распределения, но не понимают, сколько физической памяти зарезервировано для каждого виртуального распределения ядром (которое непрозрачно для пространства пользователя).
Кроме того, в отличие от того, когда я декодирую растровые изображения, я получаю сбой здесь (включая диалог), но это не OOM. Вместо этого это... NPE!
Вы не использовали ни одну из этих страниц, поэтому они не отображаются в физической памяти, поэтому убийца OOM не убивает вас (пока). Выделение могло быть неудачным, потому что у вас закончилась виртуальная память, которая более безвредна по сравнению с нехваткой физической памяти, или из-за превышения какого-либо другого типа ограничения памяти (например, на основе cgroups), которое еще больше безвредны.
- ... Crashlytics может обнаружить это? Есть ли способ получить информацию о такой вещи, будь то пользователи или во время разработки в офисе?
OOM killer уничтожает ваше приложение с помощью SIGKILL (так же, как когда ваш процесс завершается после перехода в фоновый режим). Ваш процесс не может реагировать на это. Теоретически возможно наблюдать смерть процесса от дочернего процесса, но точную причину трудно понять. Посмотрите, кто "убил" мой процесс и почему?, Хорошо написанная библиотека может периодически проверять использование памяти и делать обоснованные предположения. Чрезвычайно хорошо написанная библиотека может обнаруживать выделения памяти, подключаясь к нативному malloc
функция (например, с помощью оперативного исправления таблицы импорта приложения или что-то в этом роде).
Чтобы лучше продемонстрировать, как работает управление виртуальной памятью, давайте представим, что каждый из них выделяет 1000 битмапов по 1 Гб, а затем изменяет один пиксель в каждом из них. Операционная система изначально не выделяет физическую память для этих битовых карт, поэтому они занимают около 0 байт физической памяти. После прикосновения к одному четырехбайтовому пикселю RGBA в Bitmap ядро выделит одну страницу для хранения этого пикселя.
ОС ничего не знает об объектах Java и растровых изображениях - она просто рассматривает всю память процесса как непрерывный список страниц.
Обычно используемый размер страницы памяти составляет 4 КБ. После касания 1000 пикселей - по одному в каждом растровом изображении размером 1 ГБ - вы все равно будете использовать менее 4 МБ реальной памяти.
Если кого-то заинтересует Java-версия этого блестящего кода Kotlin для получения памяти, используемой приложением, вот она:
private String getHeapMemStats(Context context) {
Runtime runtime = Runtime.getRuntime();
long maxMemInBytes = runtime.maxMemory();
long availableMemInBytes = runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory());
long usedMemInBytes = maxMemInBytes - availableMemInBytes;
long usedMemInPercentage = usedMemInBytes * 100 / maxMemInBytes;
return "used: " + Formatter.formatShortFileSize(context, usedMemInBytes) + " / " +
Formatter.formatShortFileSize(context, maxMemInBytes) + " (" + usedMemInPercentage + "%)";
}