Introduction
This article is originally from my medium blog post.
Memory management is a key part of any programming language. Numerous articles provide a fundamental explanation of how memory management operates, this particular article delves into a more in-depth exploration of the topic.
In this article we consider:
- What is the memory management?
- What is automatic reference counting?
- How
strong
,weak
, andunowned
references are implemented - What is the life cycle of an object?
- What are Side Tables?
Table of contents
Open Table of contents
Defining Memory Management
In simply words, memory is a collection of bytes, where each byte has own address. If we talk in terms of programming then, we have to deal with address space. An address space can consist of following components:
- text is a segment that contains an executable instructions.
- data is a portion of the virtual address space of a program, which contains the global and static variables that are initialised by the developer.
- stack is used for storing local variables.
- heap is used for storing dynamically allocated objects.
Memory management is the process of controlling and coordinating a computer’s main memory. It’s critical to understand how it works, otherwise it may lead to random crashes and increased memory consumption.
In some cases classes may be stored on the stack, and struct may be stored on the heap.
In Swift 5.6, the
any
keyword was introduced. If you use theany
keyword, it signifies that the object will be enclosed within an existential container. If the object can fit within three machine words, it will be stored inside the existential container; otherwise, it will be allocated on theHeap
, and the existential container will only store a reference to the object.
The Swift compiler may allocate reference types on the stack when their size is fixed, or their lifetime can be predicted. This is called “Stack Promotion.” This optimization occurs during the SIL generation phase.
If a value type is a part of a reference type, it saves on the
Heap
.
Defining ARC
Memory management in Swift is very tightly coupled with automatic reference cycle.
Automatic Reference Counting (ARC) is a memory management attribute used to monitor and manage an application’s memory usage.
Reference counting applies only to instances of classes. Structures and enumerations are value types, not reference types, and aren’t stored and passed by reference.
Every time when we create a new class instance, ARC allocates a chunk of memory, which contains an information about the created object along with a type of the instance and values that stored in this object.
To make sure that the instances don’t be deallocated while they are still needed, ARC tracks how many variables, constants, properties point out to this instance. The object won’t be deallocated as long as least there is one reference exists.
Understanding Strong, Weak, and Unowned
If we are working with structs or enums, ARC isn’t managing the memory of those types and we don’t need to worry about specifying weak
or unowned
for those constants or variables.
When we have to deal with classes, we can potentially run into a retain cycle. A retain cycle is a situation in which two objects keep strong references to each other indefinitely. To solve this kind of problem we can using weak
or unowned
keywords.
- strong keeps a firm hold on the instance and doesn’t allow it to be deallocated for as long as that strong reference exists.
- weak doesn’t keep a strong hold on the instance it refers to. A weak reference returns nil, when an object it points to is no longer alive.
- unowned as a weak one doesn’t keep a strong hold. Access to unowned references with a zero counter leads to a crash.
Swift conceptually have several counters for strong, weak, and unowned references. These counters are stored either along with an object right after isa pointer or in a side table, which be explained a lit bit later.
Defining Swift Runtime
Swift Runtime represents every dynamically allocated object with HeapObject
struct. It encompasses all the data components that constitute an object in Swift, including reference counts and type metadata.
Within its internal structure, each Swift object possesses three reference counts, corresponding to distinct types of references. During the SIL (Swift Intermediate Language) generation phase, the swiftc compiler strategically incorporates invocations to the swift_retain()
and swift_release()
methods. This integration occurs at points deemed suitable, achieved by capturing the initialization and destruction instances of HeapObjects
.
Introducing Side Tables
Side Tables are mechanism for implementing weak
references.
Side Tables was introduced in Swift 4. Let’s dive in a little bit history and take a closer look how it worked before Swift 4.
Before Swift 4
Let’s imagine that we have a class like this:
class Object {
let id: Int
let name: String
}
The next picture represents an object into memory:
The class properties and reference counters are stored in a single object. It helps to get a quicker access that storing it at the other chunk of memory.
After creating a weak reference to an object, the reference count for the weak reference is incremented. Now, consider a scenario where there are no remaining strong references to the object. In such a situation, the object is essentially labeled as a zombie
. It remains in memory until another object attempts to access it through the weak reference. This means that the zombie object could occupy memory for an extended period.
Another significant issue here: the process of loading an object through a weak link lacked thread safety.
class Object {}
class WeakHolder {
weak var weak: Object?
}
for i in 0..<1000000 {
let holder = WeakHolder()
holder.weak = Object()
dispatch_async(dispatch_get_global_queue(0, 0), {
let _ = holder.weak
})
dispatch_async(dispatch_get_global_queue(0, 0), {
let _ = holder.weak
})
}
This piece of code may potentially result in a crash. Two threads have the potential to simultaneously access an object through a weak
reference. Before acquiring the object, both threads verify whether the object is marked as a zombie. If both threads receive a positive response, they will both attempt to decrement the count and release memory. One of them will succeed in doing so, while the second thread will trigger a crash by attempting to free memory that has already been deallocated.
Understanding Swift Tables
The Side Table is a separate chunk of memory which store additional objects information. It’s optional, meaning that the object may have a side table, or it may not. Objects which have week pointers to itself can incur the extra cost, and objects which don’t need it don’t pay for it.
Side Table is created only one the following things happen:
weak
reference to the object is createdstrong
andunowned
counter overflows
Weak references now point directly to the Side Table, whereas strong and unowned references ones point directly to the object. In this case the object can be fully deallocated.
Utilizing unowned
carries a lower overhead compared to employing weak
. The reason behind this lies in how weak
variables reference the object via a side table, which introduces an additional pointer hop to access the object.
In contrast, unowned
references establish a direct link to the object, eliminating this overhead.
Swift Object Life Cycle
The object life cycle is well described in the source code here.
Swift object have their own life cycle. There are five states:
- Live
- Deallocating
- Deallocated
- Freed
- Dead
In live
state object is alive. Its reference counters are initialized to 1 strong
, 1 weak
, 1 unowned
. Despite on 1 weak
reference the Side Table is not created here. It’s needed that the state machine is working properly.
From live
state, object moves to deiniting
state once strong reference counter reaches zero. The deiniting
state means that deinit()
is in progress. At this point weak
references will return nil
, if there is an associated side table. unowned
reads trigger an assertion failure. New unowned
can be stored. From this point, object has two destinations:
- If there are no
weak
and unowned references, the will move to the dead state. - Otherwise, the object moves to deinited state.
In deinited
state the deinit()
has been completed and the object has outstanding unowned
references. The object can have two destinations from this point:
- If there is no
weak
references, the object immediately go to the dead state. - Otherwise, the object moves to freed state.
In freed
state the object is deallocated and only the side table is exist in the memory. During this phase the weak
reference count reaches to zero and the side table destroyed. The object transitions into the final state.
In dead
state there is nothing left from the object, expect for the pointer to it. The pointer to the HeapObject
is freed from Heap
, leaving no traces of the object in the memory.
Summary
Automatic reference counting isn’t a mystical process; the more we delve into its internal workings, the more we can reduce the likelihood of memory management errors in our code.
Key points to remember:
- Weak objects refer to the side table instead of referring to an object.
- ARC is implemented on compiler level. Swift compiler inserts calls of
retain
andrelease
wherever appropriate. - Swift object are not destroyed immediately. Instead, they have 5 phases in their life cycle.
Thanks for reading
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.