Всем привет! Я Данила Горячкин — iOS-инженер в команде Performance в Авито. Занимаюсь оптимизацией производительности iOS‑приложений и менторингом разработчиков.

В этой статье последовательно разберем, как Swift управляет памятью: от базовых понятий вроде ARC,  Copy‑on‑Write, экзистенциальных контейнеров до нетипичных задач с «зомби»-объектами и non-frozen типами. Материал основан на документации Swift, докладах WWDC и практических примерах. Статья рассчитана на middle- iOS‑разработчиков, которые хотят лучше понимать, что происходит с их кодом на уровне памяти и почему одни решения оказываются дороже других.

Эта статья выйдет в двух частях: в первой разберем теорию, а во второй — практические примеры с кодом.

В этой статье

Тут еще больше контента

Стек, куча и глобальная память

Начнем с базовых понятий, с которыми не раз будем сталкиваться. Основой для обсуждения памяти в Swift являются: стек и куча.  

Стек — это область памяти с механизмом LIFO («последним пришел — первым ушел»), в которой память выделяется и освобождается за счёт простого сдвига указателя стека. Механизм работы стека доастаточный быстрый, а сам стек используется преимущественно для хранения временных данных, чье время жизни известно на этапе компиляции: локальных переменных и параметров функций.

Куча — это область памяти, где блоки выделяются и освобождаются по мере необходимости. Доступ к ней осуществляется произвольно. Механизм работы кучи требует дополнительных временных затрат на поиск подходящего свободного блока, при выделении памяти. Чаще всего куча используется для данных, время жизни которых может быть неопределённым или выходить за рамки выполнения отдельной функции.

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

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

Глобальная память выделяется при запуске программы, что обеспечивает быстрое время аллокации. Есть одно ограничение: она предназначена для сущностей, живущих на протяжений всей программы и имеющих фиксированный, известный на этапе компиляции, размер. В глобальной памяти могут храниться глобальные переменные или статические свойства (например, статичное свойство класса типа Int).

Стековая память также отличается высокой скоростью работы, поскольку выделение и освобождение памяти происходит за счёт простого смещения указателя стека. Однако, её можно использовать только для хранения временных данных: например, параметров функции или локальных переменных (let или var), время жизни которых ограничено одной функцией.

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

Подробнее:

  1. Understanding Swift Performance. WWDC16 (3:42)

  2. Explore Swift performance. WWDC24 (8:54)

Типы данных в Swift

После того, как мы разобрались c областями памяти, можно перейти непосредственно к началу работы с данными.

Типы данных можно разделить на две большие группы: Value и Reference. Это деление лежит в основе всей модели работы с памятью.

Value-типы передаются по значению. Это значит, что при присваивании или передаче в функцию копируется сама сущность. Изменение копии не влияет на оригинал. 

К Value-типам относятся: struct, enum (кроме indirect), tuples, Int, Bool...

Reference-типы передаются по ссылке. Копируется не сама сущность, а указатель на нее. Если несколько переменных указывают на один и тот же объект, изменение через одну переменную будет видно через другую. К Reference типам относятся: class, actor, indirect enum, closures…

Важно, что различие между Value и Reference типами влияет не только на семантику, но и на размещение в памяти. В большинстве случаев Value-типы размещаются на стеке, а Reference‑типы — в куче.

Интересный нюанс: коллекции Swift (например, Array или String) формально являются Value type, однако память для их содержимого обычно выделяется в куче. Чтобы сохранить семантику Value types (независимость копий) и избежать избыточного копирования, используется механизм Copy-on-Write (CoW)

Подробнее:

  1. Value And Reference Types In Swift

  2. Value Types and Reference Types in Swift. Vadim Bulavin

  3. Explore Swift performance. WWDC24 (10:53, 20:39)

Copy‑on‑Write

Говоря о типах данных, мы затронули CoW, но не разобрали что же это такое.

Copy-on-Write (CoW) — оптимизация, основанная на копировании данных только при их изменении в одной из копий. До внесения изменений и копия, и оригинал ссылаются на одну область памяти. После — у каждого своя копия данных. Данный подход позволяет снизить затраты на копирование.

Наиболее частым местом неявного использования CoW в Swift являются коллекции, например String, Array, Dictionaries, Set.

Ещё одним примером неявного использования CoW в Swift является копирование больших структур (размером больше 3 машинных слов), обёрнутых в экзистенциальный контейнер. В этом случае копируется только сам контейнер, а не данные структуры. При изменении значения структуры через копию контейнера срабатывает механизм CoW для сохранения Value-семантики. В результате данные дублируются.

Подробнее:

  1. Механизм Copy-on-Write. Митасов Эдуард

  2. Understanding Swift Performance. WWDC16 (36:42)

Экзистенциальные контейнеры

Экзистенциальный контейнер — это сущность, которая хранит значение определенного протокола, скрывая конкретный тип сущности, реализующей протокол. Контейнер позволяет хранить и работать с сущностями разных типов, соответствующих одному протоколу. Например, при создании массива, содержащего элементы определённого протокола требуется, чтобы все элементы были одного размера. Это необходимо из-за ограничения массива как структуры данных. Обеспечить это условие можно, если хранить в массиве не сами сущности, а их экзистенциальные контейнеры.

Сам контейнер обычно состоит из 5 машинных слов: 3 слова под хранение значения (или ссылки на него в куче, если значение не помещается), одного слова под из value witness table и одного слова под protocol witness table.

Также возможна ситуация увеличения размера контейнера. Это происходит, когда контейнер должен хранить информацию о нескольких протоколах. Например, если переменная объявлена как ProtocolA & ProtocolB, её размер будет 6 машинных слов, поскольку создаётся один контейнер с двумя protocol witness tables.

Кроме увеличения размера контейнера, мы можем добиться и его снижения. Для этого стоит добавить AnyObject при объявлении протокола. В этом случае размер контейнера сократиться с 5 до 2 машинных слова. Одно машинное слово будет использоваться в качестве ссылки на объект, второе — protocol witness tabel.

Подробнее:

  1. Understanding Swift Performance. WWDC16 (27:07)

  2. Type Layout. Swift Documentation

  3. Writing High-Performance Swift Code. Swift Documentation

Типы ссылок в Swift

Также, для полного понимания работы памяти, важно разобраться в различных типах ссылок. В Swift основными типами, используемыми при разработке, являются strong, weak и unowned (safe). Кроме них, существуют также unowned (unsafe) ссылки, различные unmanaged и raw ссылочные типы (например, UnsafeRawPointer). При анализе потребления памяти программы через инструменты иногда можно встретить упоминание conservative «ссылок» — они возникают, когда инструмент предполагает, что данное значение может являться ссылкой.

Разные типы ссылок позволяют найти наилучшее решение для соблюдения компромисса между безопасностью и performance-ом приложения.

Weak — позволяют безопасно и без крашей работать со ссылками, не создавая strong reference cycle (про них поговорим ниже). При обращении к weak-ссылке на удалённый объект, будет получен nil. Этот тип ссылки требует наибольших накладных расходов, так как для него используется отдельная структура (side table, про неё поговорим ниже), в которой ведётся подсчёт ссылок.

Unowned (safe) — также позволяют безопасно предотвратить strong reference cycle, но не возвращают nil. При обращении к unowned (safe)-ссылке на удалённый объект поведение программы строго определенно — она завершится с ошибкой. Такой тип не требует отдельной side table, но всё ещё несёт дополнительные расходы, связанные с подсчётом ссылок. А также способен приводить к появлению «зомби-объектов» (об этом поговорим в разделе про жизненный цикл объекта)

Unowned (unsafe) — небезопасный способ работы со ссылками, который не создаёт strong reference cycle (про них тоже поговорим ниже). При обращении к ссылке на удалённый объект возникает неопределённое поведение: программа может упасть или продолжить работу с некорректными данными. Его преимущество — полное отсутствие накладных расходов на подсчёт ссылок.

Unmanaged и raw ссылки — в основном используются для совместимости с другими языками программирования, где требуется ручной контроль над памятью.

Что такое side table?

Side table — это дополнительная мета-структура, в которой ведётся подсчёт ссылок на объект. Она была введена для решения проблемы «зомби»-объектов при использовании weak-ссылок. Это такие ситуации, когда у объекта был выполнен deinit, но память не могла быть освобождена из-за наличия weak ссылок.

Чаще всего side table создаётся при появлении первой weak-ссылки, но может возникнуть и по другим причинам — например, из-за переполнения счётчиков strong или unowned (safe) ссылок в метаданных самого объекта.

Side table хранит в себе: 

  • ссылку на объект, для которого она создана;

  • три счётчика ссылок (strong, unowned safe и weak);

  • дополнительные служебные флаги.

Важно понимать, что по умолчанию (при наличии только сильной ссылки) для одного объекта создаются 2 счётчика ссылок: для strong и unowned (safe) ссылок. Они хранятся в метаполях самого объекта. Однако, если на объект создаётся weak ссылка или один из счётчиков превышает допустимый лимит, подсчёт ссылок переносится в отдельную структуру — side table. В ней присутствуют уже 3 счётчика: для strong, unowned safe и weak ссылок.

Подробнее:

  1. Automatic Reference Counting. Swift Documentation

  2. Analyze heap memory. WWDC24 (16:21)

  3. Swift Pointers Overview: Unsafe, Buffer, Raw and Managed Pointers. Vadim Bulavin

  4. Analyze heap memory. WWDC24 (26:51)

  5. Unsafe Swift. WWDC20 (9:27)

  6. Safely manage pointers in Swift. WWDC20

  7. RefCount.h

  8. Память в Swift от 0 до 1. Тимур Шафигуллин

  9. Unsafe Swift. WWDC20 (3:36)

Управление памятью на куче и ARC

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

Управление происходит при помощи Automatic Reference Counting (ARC). ARC автоматически отслеживает количество активных ссылок на каждый объект. Когда количество сильных ссылок на объект достигает нуля, в большинстве случаев, система немедленно освобождает занимаемую им память.

Проблема, которая чаще всего может возникать при работе с ARC — утечка памяти, вызванная strong reference cycle. Это ситуация, когда несколько объектов удерживают друг друга с помощью сильных ссылок. В результате их счётчики ссылок никогда не достигают нуля, а память не освобождается, даже если объекты больше не нужны.

Чтобы избежать утечки памяти, вызванной strong reference cycle, необходимо сделать одну из ссылок не strong. Например, воспользовавшись weak или unowned ссылками.

Также нужно учитывать, что strong reference cycle могут возникать при работе с замыканиями. Это происходит, например, когда замыкание, хранящееся в качестве свойства класса, захватывает (явно или неявно) сильную ссылку self на этот же самый класс.

Избежать strong reference cycle можно при работе с замыканиями следующими путями:

  1. Захватить weak или unowned ссылку на объект с которым образуется retain cycle через capture list

  2. Передать этот объект в качестве аргумента замыкания при его вызове, не захватывая его напрямую

Подробнее:

  1. Automatic Reference Counting. Swift Documentation (ARC)

  2. Automatic Reference Counting. Swift Documentation (Class Instances)

  3. Detect and diagnose memory issues. WWDC21 (10:27)

  4. Analyze heap memory. WWDC24 (26:51)

  5. Analyze heap memory. WWDC24 (23:27)

  6. Automatic Reference Counting. Swift Documentation

  7. Analyze heap memory. WWDC24 (23:27)

  8. Expressions. Swift Documentation

Жизненный цикл объекта

Чтобы понять как происходят инициализации, как выглядит освобождение и т. д., важно понимать жизненный цикл объекта. Изначально объект находится в состоянии Live. Когда счётчик сильных ссылок достигает нуля, объект переходит в состояние Deiniting. На этом этапе выполняется метод deinit.

Если на объект не было weak- и unowned-ссылок, он сразу переходит в состояние Dead. В этом состоянии все данные об объекте удалены.

Если на объект была unowned-ссылка, то после выполнения deinit объект переходит в состояние Deinited и остаётся в нём, пока счётчик unowned-ссылок не станет равным нулю. Затем:

  1. Если у объекта нет side table, он переходит в состояние Dead

  2. Если side table есть, объект переходит в состояние Freed. В этом состоянии память объекта освобождается, но side table продолжает существовать. Как только счётчик weak-ссылок в side table становится равным нулю, объект окончательно переходит из состояния Freed в состояние Dead

Из статьи  Vadim Bulavin «Advanced iOS Memory Management with Swift: ARC, Strong, Weak and Unowned Explained»

Кстати, и в Swift 5+ можно получить «зомби»-объект, несмотря на наличие side table. Это произойдет, если «заморозить» объект в состоянии Deinited. Для этого нужно создать strong- и unowned-ссылки на объект, а затем удалить strong-ссылку. В таком состоянии у объекта уже будет выполнен метод deinit, но его память не будет освобождена, пока существует unowned-ссылка.

Подробнее:

  1. RefCount.h

  2. Swift 4 Weak References. Mike Ash

  3. Память в Swift от 0 до 1. Тимур Шафигуллин

  4. Properties. Swift Documentation

  5. Analyze heap memory. WWDC24 (27:43)

  6. Analyze heap memory. WWDC24 (21:43)

  7. Automatic Reference Counting. Swift Documentation

  8. Advanced iOS Memory Management with Swift: ARC, Strong Weak and Unowned Explained. Vadim Bulavin

Класс или структура: как выбирать

Выбор зависит от конкретной ситуации. Apple рекомендует оценивать его по следующим критериям:

  1. Стоимость аллокации. Наиболее дорогая аллокация — та, что в куче (требует поиска места и записи метаданных). Классы обычно размещаются в куче, а структуры — на стеке, но есть исключения:

    1. Класс может быть размещён на стеке, если его размер и время жизни известны на этапе компиляции.

    2. Структура может оказаться в куче, если:

      1. она является полем класса, находящегося в куче;

      2. используется в экзистенциальном контейнере, и её размер превышает 3 машинных слова;

      3. захвачена по ссылке в escaping-замыкании;

      4. передаётся через inout параметр;

      5. и ещё в некоторых случаях.

    3. и ещё в некоторых случаях

  2. Затраты на подсчёт ссылок (ARC). Структуры сами по себе не имеют счётчиков ссылок, но могут быть как эффективнее, так и менее эффективны, чем классы:

    1. Пример эффективности структуры над классом: структура и класс содержат два простых поля value-типа (например, Int). При копировании структуры счётчики ссылок не меняются, а при копировании класса увеличивается один счётчик — счётчик класса.

struct StructA {

let a: Int = 1

let b: Int = 1

}

class ClassA {

let a: Int = 1

let b: Int = 1

}

2.2 Пример эффективности класса над структурой: структура и класс содержат два поля reference-типа. При копировании структуры увеличиваются два счётчика (для каждого поля), а при копировании класса — только один (для самого объекта).

class MyClass {}

struct StructA {

let a = MyClass()

let b = MyClass()

}

class ClassA {

let a = MyClass()

let b = MyClass()

}

Диспетчеризация методов. Классы обычно используют динамическую диспетчеризацию (через таблицу методов), а структуры — более быструю статическую. Однако, класс может получить статическую диспетчеризацию с модификатором final или благодаря оптимизациям компилятора.

Также, стоит отметить, что классы имеют свои уникальные возможности: наследование или проверка на идентичность (===). Но с ними легче допустить утечку памяти из-за ARC.

Итог:

  1. Используем класс, если это требуется API (требуется AnyObject или NSObject) или необходимы особые возможности классов

  2. Следует избегать использования структур, если они содержат в себе большое число полей reference типа или используются через протокол и имеют размер более чем в 3 машинных слова

    Во всех остальных случаях стоит опираться на критерии, упомянутые выше.

Подробнее:

1. Choosing Between Structures and Classes. Swift Documentation

2. Value Types and Reference Types in Swift. Vadim Bulavin

3. Understanding Swift Performance. WWDC16 (2:53)

Жми сюда!

Особенности памяти non-frozen типов

При работе с фреймворками встречаются понятия frozen и non-frozen. Что же это такое? 

Frozen и non-frozen — это атрибуты, указывающие на стабильность памяти структур и перечислений в Swift.

Non-frozen структуры и перечисления могут изменить свой размер или состав в будущих версиях фреймворка — даже без перекомпиляции вашего кода. Например, при обновлении бинарного фреймворка.

Frozen-структуры и перечисления гарантированно не изменятся: их размер и память остаются фиксированными. Эта стабильность позволяет оптимизировать работу с ними — например, хранить их в глобальной памяти или быстрее выделять память на стеке под них.

Иногда во время компиляции компилятор не может вычислить на стеке нужный размер памяти для выполнения функции. Такое бывает при использовании non-frozen value типа, такого как URL. При выделении памяти под функцию сначала выделяется память для всего остального, необходимого для работы функции. Затем отдельно выделяется память согласно фактическому размеру non-frozen value типа.

Ещё одной особенностью Non-frozen value type, является то, что они  не сохраняются в глобальную память. Вместо этого, в глобальной памяти хранится ссылка на них, а сами сущности размещаются на куче.

Подробнее:

  1. Understanding Swift Performance. WWDC16 (27:26)

  2. Explore Swift performance. WWDC24 (23:21)

  3. Value Types and Reference Types in Swift. Vadim Bulavin

  4. Attributes. Frozen. Swift Documentation

  5. Explore Swift performance. WWDC24 (21:09)

  6. Explore Swift performance. WWDC24 (23:43)

Выравнивание памяти

Структуры и классы не всегда занимают столько места, сколько занимают их поля. Это связано с необходимостью хранения метаданных (например, таких как счётчик ссылок), а также выравниванием памяти.

Выравнивание памяти — это оптимизация, ускоряющая работу программ за счёт минимизации количества обращений к памяти при чтении и записи переменных.

Без выравнивания может возникнуть ситуация, когда переменная (например, 4-байтовая) располагается на границе двух машинных слов (скажем, по 8 байт каждое). В этом случае для чтения её значения процессору придётся загрузить два слова, извлечь из них нужные части и объединить результат. Для записи также потребуется два отдельных обращения. Это увеличивает накладные расходы.

Подробнее:

1. Type Layout. Swift Documentation

2. Выравнивание данных. Wikipedia

3. Память в Swift от 0 до 1. Тимур Шафигуллин

Инструменты анализа памяти

Для runtime наблюдений нам могут помочь Allocations или Malloc stack logging. Для анализа конкретного момента, «снепшота»: Leaks, Memory Graph Debugger, Virtual Memory Tracker.
Также, для анализа *.memgraph файла можно воспользоваться CLI инструментами footprint, vmmap, leaks, heap, malloc_history.

Подробнее:

1. iOS Memory Deep Dive. WWDC18 (16:48)

2. Analyze heap memory. WWDC24 (32:07)

Итоги 

В первой части этой статьи мы разобрались с теорией того, как устроена память в Swift, и рассмотрели некоторые корнер-кейсы. В качестве 101 или справочника, чтобы вспомнить некоторые особенности памяти — можно прочитать только первую часть. Однако, в скором времени выйдет продолжение, где мы разберем прикладные задачи и рассмотрим типовые ошибки.

Кликни здесь и узнаешь