Как избежать проблем с памятью?
Память запущенной программы
Типичная структура памяти работающей программы может быть следующей:
- Статическая память: статический размер, статическое выделение (время компиляции), для глобальных переменных и статические локальные переменные.
- Память стека (стек вызовов): статический размер, динамическое выделение (время выполнения), для локальных переменных.
- Память кучи: динамический размер, динамическое выделение (время выполнения). Его программист контролирует некоторые языки программирования (объекты переменного размера).
Для всех данных должна быть выделена память (т. е. зарезервировано место в памяти).
Большой! Теперь мы знаем, как и когда наша программа использует память!
Теперь давайте посмотрим, как языки программирования управляют (выделяют и освобождают) памятью кучи!
Выделение и освобождение памяти в куче
Существует несколько типов управления памятью:
- Руководство: C, C++
- Ручное сохранение (MRR): Objective-C
- Автоматический подсчет ссылок (ARC): Objective-C, Swift
- Сборка мусора (полностью автоматическое управление): Java, JavaScript
- Право собственности: Руст
Давайте быстро заглянем внутрь волшебства каждого варианта!
Ручное управление памятью
В C такие функции, как malloc()
, используются для динамического выделения памяти из кучи. Когда память больше не нужна, указатель передается free
, который освобождает память, чтобы ее можно было использовать для других целей.
#include <stdlib.h> int *array = malloc(num_items * sizeof(int));
malloc()
попытается найти неиспользуемую память, достаточно большую для хранения указанного количества байтов, и зарезервировать ее. В противном случае программа завершается с сообщением об ошибке.
malloc()
не освобождается автоматически. Он также должен быть освобожден явным образом с помощью free
.
Если вы забудете освободить память, это приведет к утечкам памяти и нехватке памяти!
Мы не должны использовать освобожденный указатель, если он не переназначен или не перераспределен!
Вау, это не сложно?
Давайте заглянем внутрь C++!
В C++ управление памятью осуществляется с помощью операторов new
и delete
. new
используется для выделения памяти во время выполнения. delete
освобождает зарезервированную память.
Как и в случае с языком C, мы должны быть осторожны, чтобы не забыть освободить память и избежать ошибок доступа!
Несмотря на сложность ручного управления памятью, преимущество в том, что мы точно знаем потребности нашей программы и можем освободить память сразу после ее использования.
Мы также гарантируем, что объекты существуют столько, сколько должны, но не дольше.
Вы также можете создать свой автоматический способ управления памятью на основе низкоуровневых функций управления памятью!
От ручного высвобождения удержания (MRR) к автоматическому подсчету ссылок (ARC)
Приложения для iOS и OS X обеспечивают управление памятью посредством подсчета ссылок:
- Когда мы заявляем о праве собственности на объект, мы увеличиваем его счетчик ссылок.
- Когда мы закончим с объектом, мы уменьшим его счетчик ссылок.
- Когда счетчик достигает нуля, операционной системе разрешается его уничтожить.
Когда-то в Objective-C мы вручную контролировали количество ссылок на объект, вызывая специальные методы управления памятью:
alloc
: увеличить на единицу подсчет ссылок (владеть объектом).retain
: увеличить на единицу счетчик ссылок (получить право собственности на объект).release
,autorelease
: уменьшить на единицу количество ссылок (отказаться от владения объектом).
Это называется ручным высвобождением удержания (MRR)!
Наша работа состоит в том, чтобы требовать и отказываться от права собственности на каждый объект в программе:
- Если мы забудем освободить объект, его базовая память никогда не будет освобождена, что приведет к утечке памяти (позже мы увидим это более подробно).
- Когда мы пытаемся освободить объект слишком много раз, это приводит к висячему указателю (позже мы увидим это более подробно).
- В любом случае программа, скорее всего, выйдет из строя.
Трудно поддерживать баланс между каждым alloc
, retain
, copy
и release
или autorelease
!
К счастью, с новыми версиями Objective-C и Swift мы перешли на ARC!
Автоматический подсчет ссылок работает точно так же, как MRR, но автоматически вставляет для нас подходящие методы управления памятью. Это означает, что мы не будем снова вызывать вручную retain
, release
или autorelease
. Вау!
Автоматический подсчет ссылок позволяет нам полностью забыть об управлении памятью. Идея состоит в том, чтобы сосредоточиться на функциональности высокого уровня, а не на базовом управлении памятью.
Подробнее об ARC вы можете узнать здесь. Наслаждаться!
Ага, это большой шаг к автоматическому управлению памятью, но это не единственный способ! Продолжим наше открытие!
Вывоз мусора
Сборка мусора (GC) — это метод, используемый для автоматического управления памятью, как в Java и JavaScript.
В Java объекты выделяются с помощью оператора new
.
Чтобы упростить рабочий механизм GC, это похоже на запрограммированный поток в фоновом режиме, который будет запускаться каждый период для анализа использования памяти и попытки освободить неиспользуемые объекты.
В целом все ГК сосредоточены на двух направлениях:
- Узнайте все объекты, которые все еще живы или используются (Маркировка доступных объектов).
- Избавьтесь от всего остального — от якобы мертвых и неиспользуемых объектов (удаление неиспользуемых объектов или зачистка).
Этот алгоритм называется Mark and Sweep. Попался!
Ну и что мы можем заметить из всех этих определений:
- GC привязан к среде выполнения, а не к языку программирования.
- практически период выполнения не является регулярным и может происходить с неопределенными интервалами: либо по прошествии определенного времени, либо когда среда выполнения видит, что доступная память становится малой.
- это означает, что объекты не обязательно освобождаются именно в тот момент, когда они больше не используются.
- обычное выполнение программы приостанавливается, пока работает алгоритм сборки мусора, чтобы найти то, что используется, и очистить то, что не используется.
Вау! Это не очень волшебно!
Если честно, мне не нравится GC из-за этой индетерминированности. Я предпочитаю подход ARC и заблаговременный способ, а не задачу времени выполнения, которая может замедлить выполнение программы.
Сборка мусора против ARC
При использовании ARC компилятор вставляет код в исполняемый файл, который отслеживает количество ссылок на объекты и автоматически освобождает объекты по мере необходимости, вместо того, чтобы среда выполнения искала и удаляла неиспользуемые объекты в фоновом режиме.
Автоматический подсчет ссылок (ARC). Во время компиляции он вставляет в объектный код сообщения о сохранении и освобождении, которые увеличивают и уменьшают счетчик ссылок во время выполнения, помечая эти объекты для освобождения, когда количество ссылок на них достигает нуля. ARC отличается от трассировки сборки мусора отсутствием фонового процесса, который асинхронно освобождает объекты во время выполнения. В отличие от трассировки сборки мусора, ARC не обрабатывает циклы ссылок автоматически. Автоматический подсчет ссылок — Википедия
Удивительный! Это делается при компиляции. Детерминированное уничтожение, т. е. опережающее время и отсутствие необходимости в фоновой обработке!
Управление памятью и безопасность памяти
Я думаю, вы начинаете понимать, что автоматический не означает безопасный:
- Что, если сборщик мусора прибудет с опозданием для освобождения памяти?
- Что, если мы используем переменную в то же время, когда она освобождается GC (многопоточность)?
- Что, если ARC не справляется с циклами сохранения?
- А временные данные?
- А глобальные переменные?
- Что делать, если размер ввода превышает объем памяти?
К сожалению, эти случаи вызывают много проблем с памятью, которые приводят к сбоям программы, а иногда и к нарушениям безопасности!
В большинстве случаев автоматическое управление памятью гарантирует определенную степень безопасности. Однако этого недостаточно, и мы увидим, почему. Итак, давайте перейдем к тому, какие проблемы с памятью могут возникнуть и как их избежать!
Нарушения памяти и как их избежать
Жаргон памяти
Прежде чем мы начнем, вот несколько важных терминов памяти:
Буфер — это место для временного хранения информации, пока мы ожидаем, что что-то еще обработает данные, или пока мы их обрабатываем. Например, когда ввод поступает с клавиатуры, он сохраняется в буфере ввода до тех пор, пока он не будет прочитан и использован приложением.
Указатель — это переменная, в которой хранится адрес памяти объекта.
Переполнение буфера
Ага, я думаю, вы знаете эту известную уязвимость безопасности!
Переполнение буфера или переполнение буфера — это аномалия, при которой программа при записи данных в буфер выходит за пределы буфера и перезаписывает соседние области памяти. Переполнение буфера — Википедия
Другими словами, переполнение буфера происходит, когда программа пытается поместить в буфер больше данных, чем может вместить, или когда программа пытается поместить данные в область памяти за пределами буфера:
Ах, вы понимаете, почему я сказал, что автомат не означает безопасность!
Уязвимости переполнения буфера могут возникать в коде, который:
- полагается на внешние данные для управления своим поведением.
- зависит от свойств данных, которые применяются вне непосредственной области действия кода.
- имеет много функций управления памятью в C и C++, которые не выполняют проверку границ, и он может легко перезаписать выделенные границы буферов, с которыми они работают.
Запись за пределы блока выделенной памяти может привести к повреждению данных, сбою программы или запуску вредоносного кода.
Чтобы избежать этого типа нарушения, нам необходимо проверить весь код, который принимает ввод от пользователей через HTTP-запрос, и убедиться, что он обеспечивает надлежащую проверку размера и типа для всех таких входов.
Также рекомендуется проверять, что индексы массива находятся в правильных пределах, прежде чем использовать их в качестве индекса для массива.
Эта проблема касается всех языков программирования, независимо от того, управляется ли память вручную или автоматически, потому что она связана с тем, как она закодирована (BufferOverflowException
, ArrayIndexOutOfBoundsException
, IndexOutOfBoundsException
).
К вашему сведению, есть еще одна проблема с буфером: Buffer over-read, которая возникает при чтении буфера и заставляет программу превышать лимит буфера и читать соседнюю память.
Нулевое разыменование
Разыменование нулевого указателя происходит, когда нулевой указатель используется так, как если бы он указывал на допустимую область памяти.
Нулевой указатель не следует путать с неинициализированным указателем:
- неинициализированная переменная — это переменная, которая объявлена, но не установлена в определенное известное значение перед использованием.
- нулевой указатель — это указатель, который не указывает ни на какую ячейку памяти (указывающий ни на что). Он хранит базовый адрес сегмента.
Разыменование указателя NULL может произойти:
- когда программа не проверяет наличие ошибки после вызова функции, которая может вернуться с указателем NULL в случае сбоя функции.
- когда программа не может должным образом предвидеть или обрабатывать исключительные ситуации, которые редко возникают при нормальной работе программного обеспечения.
- из-за ряда недостатков, включая условия гонки и простые программные упущения.
В C разыменование нулевого указателя является неопределенным поведением. В Java доступ к нулевой ссылке вызывает NullPointerException.
Чтобы избежать такого рода нарушений:
- перед использованием указателя убедитесь, что он не равен NULL.
- при освобождении указателей убедитесь, что они не установлены в NULL. Обязательно установите для них значение NULL, как только они будут освобождены.
- при работе с многопоточной или другой асинхронной средой убедитесь, что для блокировки перед оператором if используются надлежащие API-интерфейсы блокировки; и разблокировать, когда он закончится.
- в Java NullPointerException может быть перехвачено кодом обработки ошибок, но предпочтительной практикой является обеспечение того, чтобы такие исключения никогда не возникали.
- использовать подход защитного программирования.
Для фанатов Разыменование нулевого указателя в Google Chrome и Google Chrome (cybersecurity-help.cz).
Висячие и дикие указатели
Слышали ли вы об Уязвимости висячих указателей с использованием массива подключаемых модулей DOM — Mozilla и Уязвимости висячих указателей в nsTreeSelection — Mozilla?
Исследователь безопасности Сергей Глазунов сообщил об уязвимости висячего указателя в реализации
navigator.plugins
, из-за которой объектnavigator
мог сохранять указатель на массив плагинов даже после его уничтожения.
Злоумышленник потенциально может использовать эту проблему для сбоя браузера и запуска произвольного кода на компьютере жертвы. 584512 — (CVE-2010–2767) nsPluginArray — повреждение памяти (mozilla.org)
Висячий указатель возникает, когда ссылающийся объект удаляется или освобождается, а указатель все еще указывает на ячейку памяти. Это создает проблему, поскольку указатель указывает на недоступную память. Ой!
Указатель, который не был инициализирован должным образом перед его первым использованием (даже не NULL), называется диким указателем. Поведение неинициализированного указателя совершенно не определено, потому что он может указывать на какое-то произвольное место, которое может быть причиной сбоя программы. Вот почему он называется диким указателем. МОЙ БОГ!
Эти проблемы возникают, когда движки JavaScript написаны с использованием C++ (актуальные версии Rust для Firefox).
Использование механизма автоматического управления памятью (GC или ARC) значительно снижает вероятность возникновения этих проблем с указателями, но это не предотвращает утечки памяти, как мы увидим ниже. Давайте двигаться дальше!
Переполнение стека
Я думаю, вы хотя бы раз сталкивались с этой ошибкой, если используете JavaScript в браузере:
Эта ошибка обычно возникает при рекурсивных вызовах и указывает на превышение максимального размера стека:
Здесь тоже GC не помогает, потому что каждая итерация ссылается на предыдущую!
Эта проблема характерна не только для JavaScript, а для всех языков программирования, не имеющих механизма Tail Call Optimization.
Хвостовой вызов — это когда последний оператор функции является вызовом другой функции. Оптимизация заключается в том, что функция хвостового вызова заменяет родительскую функцию в стеке. Таким образом, рекурсивные функции не будут увеличивать стек.
Вопрос теперь в том, что можно сделать, если язык не реализует оптимизацию хвостовых вызовов (TCO) по умолчанию?
Что ж, в этом случае нам может помочь волшебный паттерн, который можно применять на всех языках для точной имитации поведения TCO: паттерн батут.
Функция trampoline
заключает нашу рекурсивную функцию в цикл. Под капотом он вызывает рекурсивную функцию по частям, пока не перестанет производить рекурсивные вызовы:
// Trampoline const Trampoline = fn => (...args) => { let result = fn(...args) while (typeof result === 'function') { result = result() } return result }
С Trampoline мы почти не вносим изменений в обычный рекурсивный алгоритм (без TCO!), но полностью пропускаем создание стека вызовов. Это просто новая Утилита!
Чудесный узор!
Недостаточно памяти (OOM)
Эта ошибка возникает, когда недостаточно места для размещения объекта в куче, и куча не может быть расширена дальше. В этой ситуации GC не может помочь!
Ошибка OOM обычно означает, что программа делает что-то неправильно, например следующее:
- слишком долго держится за предметы
- пытаясь обработать слишком много данных за раз
- наличие большого количества глобальных переменных (проблема в функциях и переменных)
Например, в JavaScript ссылки, прямо указывающие на корень (глобальные или оконные), всегда активны (используются), и сборщик мусора не может их очистить!
Итак, что мы можем сделать, чтобы избежать OOM? Давайте посмотрим!
Для обработки больших данных,
- для серверных приложений, использующих Node JS, мы можем использовать потоки и пагинацию, геопространственные запросы.
- для интерфейсных приложений, использующих JavaScript и React, мы можем использовать некоторые приемы, такие как окно или разбиение на страницы.
- в общем, настоятельно рекомендуется принять эти принципы: обработка по запросу, отображение по запросу, в начале мы загружаем и обрабатываем только то, что необходимо (ленивая оценка).
Во избежание сильных ссылок попробуйте перед эксплуатацией этих API: WeakMap, WeakSet, WeakHashMap.
Для кэширования данных мы можем использовать такой механизм, как LRU (наименее недавно использовавшийся), установив максимальное количество самых последних использованных элементов, которые мы хотим сохранить.
Чтобы получить функции и переменные, мы можем применить подход Функционального программирования. Для JavaScript мы должны максимально избегать глобальных переменных, окон и глобальных слушателей.
Утечки памяти
Хотя сборщик мусора эффективно обрабатывает значительную часть памяти, он не гарантирует надежного решения проблемы утечки памяти. GC довольно умен, но не безупречен. Утечки памяти все еще могут подкрадываться. Утечки памяти — настоящая проблема в Java. Понимание утечек памяти в Java | Баелдунг
Мне нравится это объяснение. Это суммирует все, что я хочу объяснить в этой статье: GC важен, но недостаточен. Мы не должны полагаться на сборщик мусора, который все очистит за нас, но мы должны помочь ему хорошо выполнять свою работу!
Я думаю, вы удивлены тем, что мы можем столкнуться с утечками памяти, несмотря на наличие сборщика мусора. Смотрим вместе!
Утечка памяти — это ситуация, когда объекты в куче больше не используются, но сборщик мусора не может удалить их из памяти. Следовательно, они без необходимости поддерживаются. Когда возникает эта ситуация?
В Java утечки памяти могут происходить из-за:
- статические переменные
- незакрытые ресурсы (установить новое соединение или открыть поток)
- всякий раз, когда метод
finalize()
класса переопределяется, объекты этого класса не удаляются мгновенно сборщиком мусора. Вместо этого сборщик мусора ставит их в очередь для финализации, которая происходит позднее. - при использовании этого ThreadLocal каждый поток будет содержать неявную ссылку на свою копию переменной ThreadLocal. Он будет поддерживать свою собственную копию вместо того, чтобы делиться ресурсом между несколькими потоками, пока поток жив.
В JavaScript утечки памяти могут возникать из-за:
- Необъявленные или случайные глобальные переменные
- Забытые
setTimeout
иsetInterval
- Вне ссылки на DOM или отсоединенный узел: узлы, которые были удалены из DOM, но все еще доступны в памяти.
- Неочищенный прослушиватель событий DOM
- Подписка WebSocket и запрос к API
- Компоненты React, выполняющие обновления состояния и выполняющие асинхронные операции, могут вызвать проблемы с утечкой памяти, если состояние обновляется после размонтирования компонента.
Ух ты! Страшно знать, что управление памятью в JS и Java использует сборку мусора!
Многопоточность и состояние гонки
В различных моделях параллельного программирования процессы/потоки совместно используют общее адресное пространство, в которое они асинхронно читают и записывают данные. В этой модели все процессы имеют равный доступ к общей памяти.
Состояние гонки возникает в программе с общей памятью, когда два потока обращаются к одной и той же переменной, используя данные из общей памяти, и хотя бы один поток выполняет операцию записи. Доступ является одновременным, поэтому они могут происходить одновременно.
Для информации: несколько потоков могут безопасно пытаться прочитать общий ресурс, если они не пытаются его изменить.
Все системы, включающие в себя многопроцессорную среду, уязвимы для атаки состояния гонки!
В состоянии гонки общая память может быть повреждена потоками.
Для управления доступом к разделяемой памяти могут использоваться различные механизмы, такие как блокировки (синхронизированные) и семафор.
Java предлагает несколько структур данных, обеспечивающих параллельный доступ: DelayQueue
, BlockingQueue
, ConcurrentMap
, ConcurrentHashMap.
Заключение
Какое путешествие в память! Это было очень поучительно!
Мы увидели, как распределяется память программы и как она управляется: вручную или автоматически через ARC или GC.
Мы видели, что автоматическое управление памятью важно, но этого недостаточно. Некоторые проблемы с памятью могут возникать даже во время выполнения с GC.
Переполнение буфера, переполнение буфера, разыменование Null, висячие и дикие указатели, переполнение стека и нехватка памяти (OOM) являются исключениями для всех языков программирования. Они больше связаны с «небезопасным» дефектом кодирования, чем с внутренней проблемой языка программирования или среды выполнения.
Самый важный совет — придерживаться защитного подхода при написании кода. Программа должна иметь возможность работать правильно даже при непредвиденных процессах или при неожиданных входах.
Это все люди для этого путешествия. Приятного чтения!
Спасибо, что прочитали мою статью.
Want to Connect? You can find me at GitHub: https://github.com/helabenkhalfallah