В этой статье мы рассмотрим принципы работы памяти в Swift и разберемся, как Swift располагает байты в памяти, как управляет памятью, и что из себя представляет жизненный цикл объектов.
Содержание
Что такое память?
Основной единицей информации является бит, который равен 1 или 0. Традиционно мы организовываем биты в группы по восемь, называемые байтами. Память — это просто длинная последовательность байтов, один за другим уходящих в даль. Но они расположены в определенном порядке. Каждый байт получает число, называемое его адресом.
Мы рассматриваем память, организованную по словам (word), а не по байтам. Слово — это расплывчатый термин в информатике, но обычно он означает единицу размером с указатель. На современных устройствах для 64-битного процессора (который и стоит на iPhone) одно слово (word) равняется 8 байтам (64 бита = 8 байтам). Именно столько мы можем получить байт за одно обращение к памяти.
Но как наши данные хранятся в памяти? Как они располагаются и почему? Давайте заглянем под капот и, быть может, там найдем для себя что-то интересное.
Memory Layout
Size
Итак, первое, что нам интересно узнать — сколько потребуется выделить памяти для хранения структуры FullResume
.
struct FullResume {
let id: String
let age: Int
let hasVehicle: Bool
}
Проверить ответ нам поможет MemoryLayout, который позволяет узнать информацию о размере структуры. Через статические свойства мы можем получить информацию о размере, выравнивании и шаге.
MemoryLayout<FullResume>.size // 25
Размер вычисляется достаточно просто — это сумма всех его полей. Как мы можем увидеть, String
занимает 16 байт, Int
— 8 байт, а Bool
— 1 байт.
MemoryLayout<String>.size // 16
+ MemoryLayout<Int>.size // 8
+ MemoryLayout<Bool>.size // 1
Ради интереса попробуем переставить наше Bool
свойство на первое место, остальные поля просто сместим ниже. Сколько теперь? Кажется, 25. Или нет? По логике размер не должен измениться, ведь он считается по сумме всех полей. Проверяем!
struct FullResume {
let hasVehicle: Bool
let id: String
let age: Int
}
MemoryLayout<FullResume>.size // 32 ???
MemoryLayout<Bool>.size // 1
+ MemoryLayout<String>.size // 16
+ MemoryLayout<Int>.size // 8
Немного неожиданный результат: перестановкой мы заняли только больше памяти. Что ж, давайте разбираться дальше.
Посмотрим, как наша структура разместилась в памяти, в этом нам поможет метод withUnsafeBytes(_:)
, который возвращает нам UnsafeRawBufferPointer
, позволяющий итерироваться по каждому байту. Получаем следующую картину — Bool
занял все 8 байтов, String
как и ожидалось свои 16 байтов и Int
занял 8 байтов.
Чтобы разобраться с этим, нам понадобится понять еще два термина — stride (шаг) и alignment (выравнивание). Для начала познакомимся со stride.
Stride
Я выделю простую структуру ShortResume
. Простую в том плане, что она имеет меньший размер (Int32 — 4 байта, Bool — 1 байт) и будет проще восприниматься на изображениях.
struct ShortResume {
let age: Int32
let hasVehicle: Bool
}
Итак, как видно на изображении выше, шаг определяет промежуток между элементами, который всегда будет больше или равен размеру объекта. Благодаря шагу мы знаем, на сколько байтов нужно двигать указатель, чтобы добраться до следующего объекта. Можно заметить, что между первым и вторым резюме остается свободные 3 байта. Но зачем и для чего нужны эти 3 пустые байта? Вопросов становится больше, чем ответов, но мы уже близки к разгадке.
Alignment
Первое, что нам хочется понять — для чего нужно выравнивание? В начале статьи выделялся такой термин как word, который на примере ShortReume
выглядит так:
Суть выравнивания в том, чтобы сделать как можно меньше обращений к памяти для получения данных — это позволит работать программе максимально быстро.
Для ясности рассмотрим пример не выровненных (смещенных) данных. Это грозит тем, что для получения значения Int32
нужно сделать два обращения к памяти — сначала прочитать слово 0, затем слово 1, соединить два прочитанных массива байтов, и только затем получить окончательное значение. На всё это накладывается непонятки: откуда читать данные у слова 0 и до куда у слова 1.
Дабы избежать таких ситуаций, у нас и есть такое значение — alignment (выравнивание).
У всех простых типов в Swift есть свое выравнивание. Простой Int
или String
должен выравниваться по 8 байт, Int32
и Int16
требуют меньше выравнивания — 4 и 2 байта соответственно, а для Bool
достаточно одного. Как можно заметить, для простых типов выравнивание равно размеру. Но давайте рассмотрим, как эти числа влияют на memory layout структуры.
MemoryLayout<Int>.alignment // 8
MemoryLayout<Int32>.alignment // 4
MemoryLayout<Int16>.alignment // 2
MemoryLayout<Bool>.alignment // 1
Возвращаясь к нашему FullResume
(из которого был убран только String
), можно заметить следующее: размер — 9, выравнивание — 8, шаг — 16. Каждое свойство выровнено, мы можем получить любое значение свойства из резюме за один цикл чтения памяти.
И немного иная картина получится если переставить Bool
на первое место. Из-за того что Int
имеет выравнивание равное 8, он должен начинаться с байта, кратный 8, поэтому и образовывается пустое место между Bool
и Int
, что за собой влечет увеличения размера структуры, которая становится 16 вместо 9.
Выравнивание всей структуры рассчитывается достаточно просто — это наибольшее выравнивание из всех свойств. Если мы заменим Int
на Int16
, у которого выравнивание равно 2, то и вся структура будет иметь выравнивание 2.
Шаг считается также просто, но немного хитрее — это размер округленный в большую сторону, кратный выравниванию. Именно поэтому при размере структуры равному 9 байт следующим числом, кратным 8, будет 16.
Проверь себя
struct Test {
let firstBool: Bool
let array: [Bool]
let secondBool: Bool
let smallInt: Int32
}
Нужно определить размер, выравнивание и шаг. В уме это будет немного сложновато, поэтому можно просто набросать на бумажке, какое свойство сколько памяти займет с учетом выравнивания.
Ответ
MemoryLayout<Test>.size // 24
MemoryLayout<Test>.alignment // 8
MemoryLayout<Test>.stride // 24
Если распечатать байты, то получим следующую схему:
Class
MemoryLayout работает и для классов. Попробуем вывести для них всё то же самое.
class PaidService {
let id: String
let name: String
let isActive: Bool
let expiresAt: Date?
}
MemoryLayout<PaidService>.size // 8
MemoryLayout<PaidService>.alignment // 8
MemoryLayout<PaidService>.stride // 8
Что ж, везде будет 8, потому что классы — ссылочный тип, а все ссылки равны 8 байтам (на 64-битной машине).
Чтобы узнать реальный размер, занимаемый в куче, нужно воспользоваться Objective-C runtime функцией — class_getInstanceSize(_:)
. В этом случае получится:
16 * 2 String + 8 Bool (1 + 7 alignment) + 8 Date + 8 Optional (1 + 7 alignment) + 16 metadata (isa ptr + ref count)
Deep dive
Хочется погрузиться немного глубже и найти какой-нибудь способ, чтобы обследовать содержимое памяти напрямую. Не просто распечатывать байты, как мы делали до этого, а посмотреть на указатели: кто где живет и как размещается в памяти. Для всего этого нам нужно:
Дампить память
Найти указатели
Визуализировать
Поначалу были попытки написать такую программу самостоятельно, пока не нашелся интересный доклад от Mike Ash.
Суть в том, что Mike Ash написал программу на Swift, которая может прыгать по указателям и уходить в глубину, учитывая то, что в какой-то момент указатель может стать конечным. Для того, чтобы не словить краш при обращении к указателю, которого нет, он использует вспомогательные функции из языка C. Исходный код открыт, и с ним можно ознакомиться.
Вспомним наш FullResume
и попробуем прогнать его через dumper.
Самая прелесть заключается в том, что этот dumper строит граф памяти, и имеется возможность оценить всё визуально.
Получилось довольно изящно. Здесь отображается как адрес памяти самого объекта, так и адреса его внутренностей.
Попробуем изменить структуру на класс и снова прогоним через наш Memory Dumper.
class FullResume {
let id: String
let age: Int
let hasVehicle: Bool
}
Немного сложней, чем структура, да.
Это и логично, классы в Swift сами по себе сложнее, так как связаны с Objective-C, хранятся в куче, имеют свои метаданные для указателей, счетчики ссылок и так далее.
Теперь нам удалось очень наглядно разглядеть эту разницу.
Управление памятью
Мы немного покопались в памяти, посмотрели как располагаются наши данные, узнали, что порядок свойств объявленных в классе или структуре напрямую влияет на выделяемый размер.
Но есть еще интересные моменты, связанные с тем, как и сколько живут классы в памяти. Давайте посмотрим, как счетчики ссылок влияют на управление памятью.
Reference Counters
Всего в Swift три счетчика ссылок:
Strong
Weak
Unowned
Попробуем разобраться зачем так много, как они все работают вместе и где хранятся.
До Swift 4
Прежде как перейти к текущей реализации счетчиков ссылок, хочется упомянуть старую, чтобы прояснить, для чего сделали новые механизмы и какие проблемы решили.
До Swift 4, счетчики ссылок располагались до свойств класса прямо в объекте. Класс имел только два счетчика — weak и strong.
На объект начинает ссылаться два внешних объекта — один сильно, другой слабо, счетчики прибавляются по одному.
В один момент времени объект с сильной ссылкой удаляется из памяти, и теперь у нас осталась только одна слабая ссылка. Что происходит в этот момент?
Данные объекта уничтожаются, но память не освобождается, так как счетчик еще требуется хранить. В памяти остается так называемый «зомби объект», на который ссылается слабая ссылка. Только при обращении по слабой ссылке в runtime будет выполнена проверка: «зомби» (NSZombie) этот объект или нет. Если да, счетчик ссылок уменьшается.
Xcode умеет находить такие объекты и сообщать о них, плюс имеет инструмент для этого.
Данный подход достаточно прозрачный, но главный минус в том, что так объекты могут долго оставаться в памяти, занимая лишнее место, хотя не несут никакой пользы.
Встречался еще один достаточно критичный баг: получение (загрузка) объекта по слабой ссылке было не потокобезопасным!
import Foundation
class Target {}
class WeakHolder {
weak var weak: Target?
}
for i in 0..<1000000 {
print(i)
let holder = WeakHolder()
holder.weak = Target()
dispatch_async(dispatch_get_global_queue(0, 0), {
let _ = holder.weak
})
dispatch_async(dispatch_get_global_queue(0, 0), {
let _ = holder.weak
})
}
Данный кусок кода может получить ошибку в Runtime. Суть именно в том механизме, который был рассмотрен ранее. Два потока могут одновременно обратиться к объекту по слабой ссылке. Перед тем, как получить объект, они проверяют, является ли проверяемый объект «зомби». И если оба потока получат ответ true
, они отнимут счётчик и постараются освободить память. Один из них сделает это, а второй просто вызовет краш, так как попытается освободить уже освобожденный участок памяти.
Такая реализация не очень хороша и с этим нужно что-то делать.
Side Table
В новой реализации появляется такое понятие как Side Table или, если на русском — «Боковая Таблица».
Боковая таблица — это область в памяти, содержащая некоторую дополнительную информацию об объекте, которую не нужно хранить в нем самом. В текущей реализации в боковой таблице хранятся счетчики ссылок, но в некоторых статьях мелькала мысль, что там можно было бы хранить associated objects. Сейчас они хранятся в глобальной таблице, доступ к которой замедлен из-за потокобезопасности.
Стоит разобраться как сегодня Swift работает с боковой таблицей. Потому что в новой реализации объект должен как-то ссылаться на боковую таблицу и работать со счетчиками ссылок.
Чтобы избежать дополнительных затрат в виде 8 байт на указатель боковой таблицы, Swift прибегает к изящной оптимизации.
Первоначально объект содержит pointer и имеет только два счетчика ссылок. Боковой таблицы нет, ибо объект в ней никак не нуждается. При увеличении счетчика сильных ссылок всё работает как обычно, и ничего особенного не происходит.
Во втором поле резервируется один бит, по которому определяется, используется ли сейчас боковая таблица в этом поле или здесь хранится счетчик ссылок.
Как только мы начинаем ссылаться на объект слабо (weak reference), то создается боковая таблица, и теперь объект вместо сильного счетчика ссылок хранит ссылку на боковую таблицу. Сама боковая таблица также имеет ссылку на объект.
Еще боковая таблица может создаваться, когда происходит переполнение счетчика, и он уже не помещается в поле (счетчики ссылок будут маленькими на 32-битных машинах).
С таким механизмом слабые ссылки ссылаются не напрямую на объект, а на боковую таблицу, которая указывает на объект. Это решает две предыдущие проблемы:
Экономие памяти. Объект удаляется из памяти, если на него больше нет сильных ссылок.
Это позволяет безопасно обнулять слабые ссылки, поскольку слабая ссылка теперь не указывает напрямую на объект и не является предметом race condition.
Object lifecycle
Такой механизм немного усложняет понимание жизненного цикла объекта, но в самом исходном коде Swift в комментариях он расписан хорошо и представляет из себя конечную машину состояний.
Итак, машина заводится с самого первого состояния сразу, как только мы создали объект. Объект жив, его счетчики инициализируются со значениями strong — 1, unowned — 1, weak — 1 (weak появляется только с боковой таблицей). На данный момент нет боковой таблицы. Операции с unowned переменными работают нормально.
Когда strong RC достигает нуля, вызывается deinit()
, и объект переходит в следующее состояние.
Это состояние Deiniting. На данном этапе операции со strong ссылками не действуют. При чтении через unowned ссылку будет срабатывать assertion failure. Но новые unowned ссылки еще могут добавляться. Если есть боковая таблица, то weak операции будут возвращать nil. Далее из этого состояния уже можно перейти в два других.
Первое: если нет боковой таблицы (то есть нет weak ссылок) и нет unowned ссылок, то объект переходит в Dead состояние и сразу удаляется из памяти.
Второе: если у нас есть unowned или weak ссылки, объект переходит в состояние Deinited. В этом состоянии функция deinit()
завершена. Сохранение и чтение сильных или слабых ссылок невозможно. Как и сохранение новых unowned ссылок. При попытке чтения unowned ссылки вызывается assertion failure. Из этого состояния также возможно два исхода.
В том случае, если нет слабых ссылок, объект переходит непосредственно в состояние Dead, которое было описано выше.
В случае наличия weak ссылок, а значит и боковой таблицы, осуществляется переход в состояние Freed (Освобожден). В Freed состоянии объект уже полностью освобожден и не занимает места в памяти, но его боковая таблица остается жива.
После того как счетчик слабых ссылок достигает нуля, боковая таблица также удаляется и освобождает память, и осуществляется переход в финальное состояние — Dead.
В мертвом состоянии от объекта ничего не осталось, кроме указателя на него. Указатель на HeapObject освобождается из кучи, не оставляя следов объекта в памяти.
Инварианты счетчиков ссылок
Весь жизненный цикл сопровождается инвариантами счетчиков ссылок. Инвариантность — это выражение, определяющее непротиворечивое внутреннее состояние объекта.
Если счетчик strong ссылок становится равен нулю, то объект всегда переходит в состояние deiniting. Unowned ссылки выкидывают ошибку в runtime, а чтение weak ссылок возвращает nil.
Счетчик unowned ссылок получает +1 от счетчика strong ссылок, который впоследствие уменьшается после завершения функции
deinit()
объекта.Счетчик weak ссылок получает +1 от счетчика unowned ссылок. Он уменьшается после освобождения (freed) объекта из памяти.
FreeNickname
Большое спасибо за статью! А каков размер SideTable? Указатель + 3 числа. 32 байта (на 64-битной системе)?
Правильно ли я понимаю, что слабые ссылки зануляются только при обращении к ним (они обращаются к SideTable, видят, что объект удалён, и зануляются, счётчик слабых ссылок уменьшается)? Если да, то я правильно понимаю, что, если в цикле создать 10^6 объектов + по 1 слабой ссылке на объект, а потом удалить все объекты, то у нас, если мы не будем трогать слабые ссылки, останется висеть в памяти 32 мегабайта SideTables?
Timbaev Автор
Спасибо за хороший вопрос! Насчет размера — я затрудняюсь сразу ответить, но могу предположить, что меньше, использую
int32
для счетчиков. Сама SideTable представляет из себя такое хранилище, то есть может иметь еще дополнительные флаги:Саму реализацю SideTable можно посмотреть тут
Я попробую покопать и узнать примерные размеры и вернусь к вам с ответом)
Второй вопрос, про то, когда удаляются боковые таблицы — да, проверка осуществляется при обращении по ссылке, поэтому важно, чтобы SideTable была максимально маленькой.