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:
- What ARC is and how it actually works under the hood
- How the Swift compiler inserts retain/release calls (with real examples)
- Why you may still see references to
Objective-CGarbage Collectionin compiler output - What
Garbage Collectionis, how Apple implemented it, and why it was abandoned - A clear comparison: Garbage Collection vs ARC
- 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 deinitializedWhat 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 PersonThe 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 deinitHere ARC:
- Inserts
retainfor strong references - Skips retain for
weak - Automatically inserts
releasewhen 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.swiftInside 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]; // deallocUnder 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_retainobjc_releaseobjc_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 sits between:
Swift source code
↓
SIL (after ARC analysis)
↓
LLVM IR
↓
Machine codeA 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.swiftYou 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 %0What’s Happening Here?
alloc_ref $A- Allocates an instance of
A
- Allocates an instance of
strong_retain- ARC inserts a retain because
ais strongly referenced
- ARC inserts a retain because
strong_release- Inserted when
agoes out of scope
- Inserted when
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 %0ARC:
- 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:
- Pauses execution
- Scans the object graph
- Identifies unreachable objects
- Frees them
Apple’s GC was available in Objective-C on macOS (10.5–10.8).
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.
3.1 Why Apple Abandoned Garbage Collection
Apple officially deprecated GC in OS X 10.8 and removed it entirely later.
Key Reasons
- Performance unpredictability
- GC pauses hurt UI responsiveness
- Energy inefficiency
- Scanning memory is expensive (critical for mobile devices)
- Complex interoperability
- ARC + GC mixed code was painful
- 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 releaseWith ARC (Looks the same as GC, but is completely different in essence):
Person *p = [[Person alloc] init];
// compiler inserts retain/releaseARC 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
- 📘 SIL documentation: https://github.com/swiftlang/swift/blob/main/docs/SIL/SIL.md
- 📘 Objective-C runtime docs: https://developer.apple.com/documentation/objectivec
- 📘 Swift + Objective-C interoperability: https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis
- 📘 Apple ARC: https://clang.llvm.org/docs/AutomaticReferenceCounting.html
- 📘 Objective-C GC: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/GarbageCollection/Introduction.html
- 📘 ARC Transition Guide: https://developer.apple.com/library/archive/releasenotes/ObjectiveC/RN-TransitioningToARC/
- 📘 Official GC documentation (archived): https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/GarbageCollection/Introduction.html
- 📘 Cocoa memory management overview: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/MemoryMgmt.html