From Garbage Collection to ARC: How Objective-C Memory Management Evolved and Why ARC Is Not a Garbage Collector

From Garbage Collection to ARC: How Objective-C Memory Management Evolved and Why ARC Is Not a Garbage Collector

Did You Know Apple Used Garbage Collection? Why It Was Abandoned in Favor of ARC

For many iOS and macOS developers today, ARC feels like it has always been there. But that’s not entirely true. Before ARC became the default memory management model, Apple experimented with Garbage Collection (GC) in Objective-C—mainly on macOS.

In this article, we’ll walk through:

  1. What ARC is and how it actually works under the hood
  2. How the Swift compiler inserts retain/release calls (with real examples)
  3. Why you may still see references to Objective-C Garbage Collection in compiler output
  4. What Garbage Collection is, how Apple implemented it, and why it was abandoned
  5. A clear comparison: Garbage Collection vs ARC
  6. Final thoughts

All examples are intentionally simple, and we’ll use Swift where possible, with Objective-C references when it helps clarify history.


1. So, What Is ARC?

Automatic Reference Counting (ARC) is a compile-time memory management system. It is not a runtime garbage collector. Instead, the compiler analyzes your code and automatically inserts calls to retain and release objects when needed.

From Apple’s documentation:

“ARC automatically manages the memory of your apps by keeping track of how many references there are to each object.”

A Simple ARC Example in Swift

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("Person \(name) initialized")
    }
    deinit {
        print("Person \(name) deinitialized")
    }
}

func testARC() {
    let person = Person(name: "Alice")
}

testARC()

Output:

Person Alice initialized
Person Alice deinitialized

What Actually Happens?

Even though you never wrote retain or release, the compiler did. Conceptually, ARC transforms your code into something like this (simplified pseudo-code):

allocate Person
retain Person
release Person

The key point: ARC decisions are made at compile time, not at runtime.


1.1 Cross-References and weak

Let’s look at a classic retain cycle example.

class Owner {
    let name: String
    var pet: Pet?
    init(name: String) { self.name = name }
    deinit { print("Owner deinit") }
}

class Pet {
    let name: String
    weak var owner: Owner?
    init(name: String) { self.name = name }
    deinit { print("Pet deinit") }
}

func testCycle() {
    let owner = Owner(name: "Bob")
    let pet = Pet(name: "Dog")
    owner.pet = pet
    pet.owner = owner
}

testCycle()

Output:

Owner deinit
Pet deinit

Here ARC:

  • Inserts retain for strong references
  • Skips retain for weak
  • Automatically inserts release when scopes end

This deterministic behavior is one of ARC’s biggest strengths.


2. Inspecting ARC with swiftc -emit-ir

Let’s compile a Swift file and inspect its intermediate representation (IR).

swiftc -emit-ir ARCExample.swift

Inside the generated IR output, you may find strings like:

...
!"Objective-C Garbage Collection", ...
...

Important Clarification ⚠️

This does not mean Swift or ARC is using Garbage Collection.

This string exists for Objective-C runtime compatibility. The Objective-C runtime historically supported multiple memory management models:

  • Manual retain/release (MRC)
  • Garbage Collection (macOS only, deprecated)
  • ARC

The runtime still exposes flags and symbols to describe these modes.


2.1 Manual Reference Counting (MRC): ARC’s Foundation

Before ARC, Objective-C developers used Manual Reference Counting (MRC).

Person *p = [[Person alloc] init]; // retain count = +1
[p retain];                        // +1
[p release];                       // -1
[p release];                       // dealloc

Under MRC, developers were fully responsible for balancing retain, release, and autorelease calls.

Does ARC Rely on Manual Reference Counting?

Yes — conceptually and technically.

ARC is built on top of the same retain/release runtime used by MRC. The difference is who writes those calls:

  • MRC: the developer writes them manually
  • ARC: the compiler inserts them automatically

ARC does not introduce a new memory model. It automates the existing one.

This is why ARC-generated code still calls:

  • objc_retain
  • objc_release
  • objc_storeStrong

The underlying reference counting mechanism remains unchanged.


2.2 ARC Under the Hood: Inspecting SIL (Swift Intermediate Language)

To really understand ARC, we need to look at SIL (Swift Intermediate Language).

📘 SIL documentation

SIL sits between:

Swift source code
   
SIL (after ARC analysis)
   
LLVM IR
   
Machine code

A Test Case for ARC Analysis

class A {
    let id: Int
    init(id: Int) { self.id = id }
    deinit { print("A deinit") }
}

class B {
    var a: A?
    deinit { print("B deinit") }
}

func createObjects() {
    let a = A(id: 1)
    let b = B()
    b.a = a
}

createObjects()

Emitting SIL

swiftc -emit-sil ARCExample.swift

You will see SIL output similar to the following:

...

// A.__allocating_init(id:)
// Isolation: unspecified
sil hidden [exact_self_class] @$s10ARCExample1AC2idACSi_tcfC : $@convention(method) (Int, @thick A.Type) -> @owned A {
// %0 "id"                                        // user: %4
// %1 "$metatype"
bb0(%0 : $Int, %1 : $@thick A.Type):
  %2 = alloc_ref $A                               // user: %4
  // function_ref A.init(id:)
  %3 = function_ref @$s10ARCExample1AC2idACSi_tcfc : $@convention(method) (Int, @owned A) -> @owned A // user: %4
  %4 = apply %3(%0, %2) : $@convention(method) (Int, @owned A) -> @owned A // user: %5
  return %4                                       // id: %5
} // end sil function '$s10ARCExample1AC2idACSi_tcfC'

// B.a.getter
// Isolation: unspecified
sil hidden [transparent] @$s10ARCExample1BC1aAA1ACSgvg : $@convention(method) (@guaranteed B) -> @owned Optional<A> {
// %0 "self"                                      // users: %2, %1
bb0(%0 : $B):
  debug_value %0, let, name "self", argno 1       // id: %1
  %2 = ref_element_addr %0, #B.a                  // user: %3
  %3 = begin_access [read] [dynamic] %2           // users: %4, %6
  %4 = load %3                                    // users: %7, %5
  retain_value %4                                 // id: %5
  end_access %3                                   // id: %6
  return %4                                       // id: %7
} // end sil function '$s10ARCExample1BC1aAA1ACSgvg'
...

// createObjects()
// Isolation: unspecified
sil hidden @$s10ARCExample13createObjectsyyF : $@convention(thin) () -> () {
bb0:
  %0 = metatype $@thick A.Type                    // user: %4
  %1 = integer_literal $Builtin.Int64, 1          // user: %2
  %2 = struct $Int (%1)                           // user: %4
  // function_ref A.__allocating_init(id:)
  %3 = function_ref @$s10ARCExample1AC2idACSi_tcfC : $@convention(method) (Int, @thick A.Type) -> @owned A // user: %4
  %4 = apply %3(%2, %0) : $@convention(method) (Int, @thick A.Type) -> @owned A // users: %15, %11, %10, %5
  debug_value %4, let, name "a"                   // id: %5
  %6 = metatype $@thick B.Type                    // user: %8
  // function_ref B.__allocating_init()
  %7 = function_ref @$s10ARCExample1BCACycfC : $@convention(method) (@thick B.Type) -> @owned B // user: %8
  %8 = apply %7(%6) : $@convention(method) (@thick B.Type) -> @owned B // users: %14, %9, %13, %12
  debug_value %8, let, name "b"                   // id: %9
  strong_retain %4                                // id: %10
  %11 = enum $Optional<A>, #Optional.some!enumelt, %4 // user: %13
  %12 = class_method %8, #B.a!setter : (B) -> (A?) -> (), $@convention(method) (@owned Optional<A>, @guaranteed B) -> () // user: %13
  %13 = apply %12(%11, %8) : $@convention(method) (@owned Optional<A>, @guaranteed B) -> ()
  strong_release %8                               // id: %14
  strong_release %4                               // id: %15
  %16 = tuple ()                                  // user: %17
  return %16                                      // id: %17
} // end sil function '$s10ARCExample13createObjectsyyF'

Simplified for clarity:

%0 = alloc_ref $A
..
> %4 = load %0
> retain_value %4 
> return %4 
..
strong_retain %0
...
strong_release %0

What’s Happening Here?

  1. alloc_ref $A
    • Allocates an instance of A
  2. strong_retain
    • ARC inserts a retain because a is strongly referenced
  3. strong_release
    • Inserted when a goes out of scope

These instructions are explicit ARC decisions, already finalized at this stage.


Strong vs Weak in SIL

If we change B to use a weak reference:

class B {
    weak var a: A?
}

The SIL changes:

// B.a.getter
// Isolation: unspecified
sil hidden [transparent] @$s10ARCExample1BC1aAA1ACSgvg : $@convention(method) (@guaranteed B) -> @owned Optional<A> {
// %0 "self"                                      // users: %2, %1
bb0(%0 : $B):
  debug_value %0, let, name "self", argno 1       // id: %1
  %2 = ref_element_addr %0, #B.a                  // user: %3
  %3 = begin_access [read] [dynamic] %2           // users: %5, %4
  %4 = load_weak %3                               // user: %6
  end_access %3                                   // id: %5
  return %4                                       // id: %6
} // end sil function '$s10ARCExample1BC1aAA1ACSgvg'

Simplified for clarity:

%0 = alloc_ref $A
..
> %4 load_weak %0
> return %4 
// no retain for weak assignment
..

strong_release %0

ARC:

  • Retains for strong references
  • Skips retain for weak
  • Inserts releases deterministically

3. What Is Garbage Collection?

Garbage Collection (GC) is a runtime memory management system. Instead of deterministic retains/releases, the system periodically:

  1. Pauses execution
  2. Scans the object graph
  3. Identifies unreachable objects
  4. Frees them

Apple’s GC was available in Objective-C on macOS (10.5–10.8).

📘 Official GC documentation (archived)

How Apple’s GC Worked

Apple implemented a conservative, stop-the-world garbage collector:

  • No guarantees when objects are freed
  • Memory reclaimed in batches
  • UI pauses were possible

This model worked reasonably well for desktop apps—but not for mobile.

📘 Cocoa memory management overview


3.1 Why Apple Abandoned Garbage Collection

Apple officially deprecated GC in OS X 10.8 and removed it entirely later.

Key Reasons

  1. Performance unpredictability
    • GC pauses hurt UI responsiveness
  2. Energy inefficiency
    • Scanning memory is expensive (critical for mobile devices)
  3. Complex interoperability
    • ARC + GC mixed code was painful
  4. ARC solved most problems
    • Deterministic, fast, compiler-driven

4. Garbage Collection vs ARC

Feature Garbage Collection ARC
Type Runtime Compile-time
Memory release Non-deterministic Deterministic
Performance Unpredictable pauses Predictable
Energy efficiency Low High
Mobile support ❌ Never supported ✅ Default
Developer control Minimal Explicit via strong/weak
Interop with C Difficult Excellent

5. Objective-C Perspective

Before ARC, Objective-C developers wrote:

Person *p = [[Person alloc] init];
[p release];

With GC:

Person *p = [[Person alloc] init];
// no release

With ARC (Looks the same as GC, but is completely different in essence):

Person *p = [[Person alloc] init];
// compiler inserts retain/release

ARC offered the simplicity of GC with the performance of manual memory management.


Why SIL Matters

SIL proves that:

  • ARC is static, not dynamic
  • Memory management decisions are finalized before LLVM
  • There is no runtime object graph scanning

This is fundamentally different from Garbage Collection.


5.1 Common Myths about ARC & Garbage Collection

Over the years, several persistent myths have formed around ARC and Garbage Collection. Let’s clear up the most common ones.

Myth 1: “ARC is just Garbage Collection with a different name”

False.

ARC is not a garbage collector. It does not run at runtime, does not scan memory, and does not pause your app. All retain and release decisions are made at compile time.

Garbage Collection, on the other hand, is a runtime system that periodically analyzes object reachability.

Myth 2: “ARC automatically prevents all memory leaks”

False.

ARC prevents many leaks, but not retain cycles. Developers must still design object graphs carefully and use weak or unowned where appropriate.

Myth 3: “Garbage Collection frees memory immediately”

False.

Garbage-collected objects are freed when the collector runs, not when they become unreachable. This makes memory usage and performance less predictable.

Myth 4: “Swift used to rely on Objective-C Garbage Collection”

False.

Swift has never supported Garbage Collection. From day one, Swift was designed around ARC.

6. Conclusion

Garbage Collection was an important experiment in Apple’s platform history. It helped shape better APIs and highlighted the need for safer memory management.

However, ARC won because it:

  • Is deterministic
  • Is fast
  • Scales to mobile devices
  • Gives developers precise control

The fact that you may still see “Objective-C Garbage Collection” in compiler output is just a reminder of that history—not a sign that GC is still in use.

ARC remains one of Apple’s most successful engineering decisions.


Further Reading

Last updated on