DEV Community

Cover image for Memory Management With Swift: ARC, Strong, Weak, and Unowned Explained
Nick Vasilev
Nick Vasilev

Posted on

Memory Management With Swift: ARC, Strong, Weak, and Unowned Explained

Introduction

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, and unowned references are implemented?
  • What is the life cycle of an object?
  • What are Side Tables?

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 the any 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 the Heap, 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
}
Enter fullscreen mode Exit fullscreen mode

The next picture represents an object into memory:

Image description

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
   })
}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

Side Table is created only one the following things happen:

  • Weak reference to the object is created
  • strong and unowned 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.

Image description

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

Image description

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 and release wherever appropriate.
  • Swift object are not destroyed immediately. Instead, they have 5 phases in their life cycle.

Links

[1] Automatic Reference Counting Documentation
[2] Discover Side Tables
[3] Object Life Cycle. Swift Repo

Top comments (0)