Эффективная Java в Kotlin, пункт 7: Устранение устаревших ссылок на объекты

Программисты, выросшие на языках с автоматическим управлением памятью (таких как Java, в которой всю работу выполняет сборщик мусора [GC]), редко думают об освобождении объектов. Это приводит к утечкам памяти и в некоторых случаях OutOfMemoryError. Единственное наиболее важное правило - освободить неиспользуемый объект. Давайте посмотрим на пример из книги (перенесен на Kotlin), который представляет собой реализацию стека:

class Stack {
    private var elements: Array<Any?> = 
        arrayOfNulls(DEFAULT_INITIAL_CAPACITY)
    private var size = 0

    fun push(e: Any) {
        ensureCapacity()
        elements[size++] = e
    }

    fun pop(): Any? {
        if (size == 0)
            throw EmptyStackException()
        return elements[--size]
    }

    /**
     * Ensure space for at least one more element, roughly
     * doubling the capacity each time the array needs to grow.
     */
    private fun ensureCapacity() {
        if (elements.size == size)
            elements = Arrays.copyOf(elements, 2 * size + 1)
    }

    companion object {
        private const val DEFAULT_INITIAL_CAPACITY = 16
    }
}

Вы можете заметить здесь проблему? Выделите минутку, чтобы подумать об этом.

Проблема в том, что когда мы открываем, мы просто уменьшаем размер, но не освобождаем элемент в массиве. Допустим, у нас было 1000 элементов в стеке, и мы поместили почти все из них один за другим, и теперь наш размер равен 1. У нас должен быть только один элемент, и мы можем получить доступ только к одному элементу, но наш стек все еще удерживается 1000 элементов и не позволяет GC уничтожить их. Побольше бы таких проблем и могло быть OutOfMemoryError. Как это исправить? Решение простое:

fun pop(): Any? {
    if (size == 0)
        throw EmptyStackException()
    val elem = elements[--size]
    elements[size] = null
    return elem
}

Это был очень редкий пример и огромная ошибка, но есть предметы повседневного обихода, которые мы используем, чтобы извлечь выгоду или можем извлечь выгоду из этого правила. Допустим, нам нужен mutableLazy делегат свойства. Он должен работать как ленивый, но также должен допускать изменение состояния свойств. Я могу сделать следующую реализацию:

fun <T> mutableLazy(initializer: () -> T): ReadWriteProperty<Any?, T> = MutableLazy(initializer)

private class MutableLazy<T>(
    val initializer: () -> T
) : ReadWriteProperty<Any?, T> {

    private var value: T? = null
    private var initialized = false

    override fun getValue(
        thisRef: Any?, 
        property: KProperty<*>
    ): T {
        synchronized(this) {
            if (!initialized) {
                value = initializer()
                initialized = true
            }
            return value as T
        }
    }

    override fun setValue(
        thisRef: Any?, 
        property: KProperty<*>, 
        value: T
    ) {
        synchronized(this) {
            this.value = value
            initialized = true
        }
    }
}

Использование:

var game: Game? by mutableLazy { readGameFromSave() }

fun setUpActions() {
    startNewGameButton.setOnClickListener {
        game = makeNewGame()
        startGame()
    }
    resumeGameButton.setOnClickListener {
        startGame()
    }
}

Вышеупомянутая реализация mutableLazy верна, но имеет один недостаток: initializer не удаляется после использования. Это означает, что он сохраняется до тех пор, пока существует ссылка на экземпляр MutableLazy, даже если он больше не используется. Вот как можно улучшить MutableLazy реализацию:

fun <T> mutableLazy(initializer: () -> T): ReadWriteProperty<Any?, T> = MutableLazy(initializer)

private class MutableLazy<T>(
    var initializer: (() -> T)?
) : ReadWriteProperty<Any?, T> {

    private var value: T? = null

    override fun getValue(
        thisRef: Any?, 
        property: KProperty<*>
    ): T {
        synchronized(this) {
            val initializer = initializer
            if (initializer != null) {
                value = initializer()
                this.initializer = null
            }
            return value as T
        }
    }

    override fun setValue(
        thisRef: Any?, 
        property: KProperty<*>, 
        value: T
    ) {
        synchronized(this) {
            this.value = value
            this.initializer = null
        }
    }
}

Когда мы устанавливаем initializer на null, предыдущее значение может быть переработано GC.

Насколько важна эта оптимизация? Не так уж и важно возиться с редко используемыми предметами. Говорят, что преждевременная оптимизация - источник чистого зла. Хотя лучше установить null для неиспользуемых объектов, если это не требует больших затрат. Особенно, когда это тип функции (который часто является анонимным классом в Kotlin / JVM) или когда это неизвестный класс (Any или общий тип. Пример выше Stack, который мог использоваться для хранения тяжелых объектов.). Нам следует заботиться о более глубокой оптимизации, когда мы делаем общие инструменты, и особенно библиотеки. Например, во всех трех реализациях ленивого делегата из Kotlin stdlib мы видим, что инициализаторам после использования присваивается значение null:

private class SynchronizedLazyImpl<out T>(
    initializer: () -> T, lock: Any? = null
) : Lazy<T>, Serializable {
    private var initializer: (() -> T)? = initializer
    private var _value: Any? = UNINITIALIZED_VALUE
    private val lock = lock ?: this

    override val value: T
        get() {
            val _v1 = _value
            if (_v1 !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return _v1 as T
            }

            return synchronized(lock) {
                val _v2 = _value
                if (_v2 !== UNINITIALIZED_VALUE) {
                    @Suppress("UNCHECKED_CAST") (_v2 as T)
                } else {
                    val typedValue = initializer!!()
                    _value = typedValue
                    initializer = null
                    typedValue
                }
            }
        }

    override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

    override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

    private fun writeReplace(): Any = InitializedLazyImpl(value)
}

Общее правило таково:

Когда мы удерживаем состояние, мы всегда должны помнить об управлении памятью. Однако, прежде чем менять реализацию, мы должны найти лучший компромисс для нашего проекта, имея в виду не только память и производительность, но также читаемость и масштабируемость нашего решения. Когда мы делаем приложение, в большинстве случаев важнее удобочитаемость. Когда мы разрабатываем библиотеку, обычно важнее производительность и память.

Нам необходимо обсудить несколько распространенных источников утечек памяти. Во-первых, в кэше хранятся объекты, которые, возможно, никогда не будут использоваться. Это идея кешей, но она не поможет нам, когда мы страдаем от ошибки нехватки памяти. Решение - использовать слабые ссылки. Сборщик объектов по-прежнему может собирать сборщик мусора, если требуется память, но часто они существуют и будут использоваться.

Другая распространенная проблема - это проблема обратных вызовов и слушателей. Разработчики часто регистрируют их и не отменяют их регистрацию, когда они больше не нужны. Здесь снова решение состоит в том, чтобы зарегистрировать обратные вызовы с использованием слабой ссылки.

Большая проблема в том, что утечки памяти иногда трудно предсказать, и они не проявляются до тех пор, пока приложение не выйдет из строя. Поэтому искать их следует с помощью специальных инструментов. Самый простой инструмент - это профилировщик кучи. У нас также есть несколько библиотек, которые помогают в поиске утечек данных. Например, популярной библиотекой для Android является LeakCanary, которая показывает уведомление при обнаружении утечки памяти.

Об авторе

Марчин Москала (@marcinmoskala) - тренер и консультант, в настоящее время специализирующийся на проведении мастер-классов по Kotlin для Android и продвинутым Kotlin (заполните форму, чтобы мы могли обсудить ваши потребности) . Также он спикер, автор статей и книги о разработке Android на Kotlin.

Вам нужна мастерская Kotlin? Посетите наш сайт, чтобы узнать, что мы можем для вас сделать.

Чтобы быть в курсе отличных новостей о Kt. Academy, подписывайтесь на рассылку новостей, следите за Твиттером и следите за нами в среде.