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
Compile any Swift file with swiftc -emit-ir and search the output for this string: "Objective-C Garbage Collection". You will find it. Swift has never supported garbage collection. That string is a legacy marker - proof that Apple once went down a different road.
In this article, we 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?
The compiler inserted two function calls into your code. You never wrote them. Here is where they went.
Automatic Reference Counting (ARC) is what puts them there — at compile time, not at runtime. The compiler analyzes ownership and inserts and optimizes calls to retain and release. Those operations execute at runtime. ARC is not a tracing garbage collector.
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 operations are placed and optimized at compile time; reference counts are updated at runtime.
1.1 Cross-References and weak
Let’s look at a classic cross-reference example, with a weak reference that prevents a retain cycle.
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 - Does not keep an object alive merely by storing it in
weak - Automatically inserts
releaseaccording to ownership and lifetime rules
Reclamation follows releases instead of waiting for a collection cycle. That does not guarantee deinitialization at a closing brace: exact object lifetimes can vary with permitted optimizations. See Apple’s ARC in Swift: Basics and beyond.
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.
In Objective-C, 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 Objective-C 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- Ends an ownership claim on
a; its placement follows lifetime rules, not an unconditional scope-end rule
- Ends an ownership claim on
These instructions make ARC operations explicit in this SIL output. Later optimization can still move or eliminate operations; this is not a frozen sequence for the final executable. Swift’s ARC optimization documentation also discusses LLVM-level ARC optimization.
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
- Does not keep an object alive merely by storing it in
weak - Inserts releases according to ownership and lifetime rules
Weak storage does not own its referent, but reading it is not free. The SIL instruction reference specifies that load_weak produces a strong result if the object is still alive. The getter above returns an owned optional even without a separate retain instruction.
3. What Is Garbage Collection?
Garbage Collection (GC) here means tracing collection: a runtime system that finds unreachable objects. A collection cycle:
- Starts from a set of roots
- Scans the object graph
- Identifies unreachable objects
- Frees them
Apple introduced GC for Objective-C in OS X 10.5 and deprecated it in OS X 10.8. Deprecation was not immediate removal.
How Apple’s GC Worked
Apple implemented a conservative, generational collector running on its own thread. Its architecture documentation explicitly says that a collection cycle never stopped all threads at once; individual threads could pause briefly:
- No guarantees when objects are freed
- Memory reclaimed in batches
- UI pauses were possible
Apple shipped this collector for Mac apps. It was never available in iOS.
3.1 Why Apple Abandoned Garbage Collection
Apple officially deprecated GC in OS X 10.8 and recommended migration to ARC. Its 2015 notice later ended GC support for new Mac App Store submissions and updates. That is a distribution policy, not a runtime-removal date.
Relevant Tradeoffs
- Reclamation timing
- A collector chooses when to reclaim unreachable objects
- Runtime work
- Tracing and collection add work; ARC also pays for reference-count operations
- Interoperability constraints
- Existing code must follow the selected memory-management model’s rules
- Compiler-assisted reference counting
- ARC automates retain/release without a tracing collection cycle
4. Garbage Collection vs ARC
This compares Apple’s former Objective-C collector with ARC, rather than ranking every GC implementation.
| Feature | Garbage Collection | ARC |
|---|---|---|
| Type | Runtime tracing collector | Compiler-managed runtime reference counting |
| Memory release | During a collection cycle | Reference-count driven; exact lifetime follows language rules |
| Performance | Collection work; individual thread pauses possible | Reference-count and deinitialization costs; no tracing cycle |
| Energy efficiency | Depends on collector and workload | Depends on reference-count traffic and workload |
| Mobile support | ❌ Apple’s collector was never available in iOS | ✅ Used in Swift and Objective-C on iOS |
| Developer control | Roots and strong/weak relationships | Strong/weak ownership relationships |
| Interop with C | Collector-aware allocation and roots may be needed | Core Foundation ownership needs explicit handling |
5. Objective-C Perspective
To understand why ARC was such an improvement, it helps to see what developers were doing before it existed.
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 automatic retain/release insertion on top of reference counting. It removed manual calls, not their runtime cost.
Why SIL Matters
SIL proves that:
- ARC operations are visible during compilation
- SIL exposes ownership operations before LLVM; later optimization can still change them
- ARC does not use runtime object-graph tracing to discover unreachable objects
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 tracing garbage collector. The compiler inserts and optimizes retain and release operations at compile time, but those operations, weak-reference bookkeeping, and deinitialization still run at runtime. ARC avoids tracing collection cycles; it does not make memory management free.
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
ARC replaced Apple’s tracing collector with compiler-managed reference counting. Reclamation follows ownership rather than collection cycles, but retain/release, weak-reference handling, and deinitialization still have runtime costs.
The fact that you may still see ”Objective-C Garbage Collection” in compiler output is just a marker of the road Apple chose not to take - not a sign that GC is still in use.
But ARC is not perfect. The compiler inserts retain and release. It does not solve retain cycles. Two objects holding each other with strong references will never be freed - ARC has no way to detect a cycle at compile time.
That is the next problem. Not a compiler limitation, but a design constraint that requires explicit decisions about ownership: weak, unowned, and knowing which object should not hold the other.
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
Discussion
Sign in with GitHub to comment.