Highlight
Heap memory is typically the largest portion of app memory usage, and Apple provides a complete toolchain from Xcode Memory Report to Instruments Allocations to diagnose transient spikes, sustained growth, and memory leaks.
Core Content
Heap memory is the dynamically allocated memory region used at app runtime, accessed via malloc, calloc, Swift/ObjC object instantiation, and similar mechanisms. Heap memory is typically the largest portion of app memory usage because dirty pages count toward the app’s memory limit (04:22). This session systematically covers five heap memory problem types: transient growth (memory spikes), sustained growth (only increasing), memory leaks (objects that can’t be released), runtime performance issues (stuttering from frequent allocation/deallocation), and how to troubleshoot each with Apple’s toolchain.
The toolchain spans four levels: Xcode Memory Report (overall trends), Instruments Allocations (allocation history and call stacks), Memory Graph Debugger (object reference relationships), and command-line tools (heap, malloc_history, vmmap, Leaks) (04:10).
The session uses the Destination Video sample project. The first problem: repeatedly opening the image picker causes memory to spike near 1GB, eventually OOM crashing (05:46). Tracking with the Allocations Instrument reveals each open creates many unreleased autorelease pool content pages—the cause is autoreleased objects from calling Objective-C framework APIs in a loop all accumulating in the same pool (10:54). The fix is wrapping the loop body in nested autoreleasepool blocks to ensure immediate release after each iteration (12:10).
The second problem is sustained memory growth. After fixing transient growth, memory rises in steps—each image picker open grows memory without falling back (13:09). Using Mark Generation to separate persistent allocations from each period reveals many Data storage allocations from ThumbnailLoader (15:23). Memory Graph Debugger traces the reference chain— these objects are held by global cache globalImageCache, and the cache key uses current time instead of file creation time, generating new cache entries on every call (18:58).
The third problem is memory leaks. Memory Graph Debugger finds ThumbnailRenderer, ThumbnailLoader, and a closure context forming a reference cycle (21:43). The closure strongly captures renderer by default, causing mutual strong references that prevent release. The fix is using a [weak renderer] capture list (23:40).
Detailed Content
Heap Memory Basics
Heap memory is part of app virtual memory, storing dynamically allocated and long-lived objects. Each heap allocation comes from 16KB memory pages with three states (02:05):
- Clean: Not written or read-only mapped; can be discarded anytime
- Dirty: Written; counts toward memory usage; compressed or swapped under pressure
- Swapped: Compressed or written to disk
Only Dirty and Swapped count toward the app’s memory footprint. Heap memory typically dominates (02:51).
MallocStackLogging is an important debugging feature recording each allocation’s call stack and timestamp (03:47), enabled in the Xcode Scheme’s Diagnostics tab.
Transient Growth: Autorelease Pool
Transient growth appears as memory spikes—rapid rise then fall. This triggers memory pressure, causing the system to compress/swap memory or even terminate the app (07:56).
In the Allocations Instrument, two approaches locate the cause (08:10):
- Created & Still Living: Select the period from trough to peak, view allocations still alive after growth
- Created & Destroyed: Select a larger period, view allocations created and destroyed in that timeframe
Key fix code for transient growth (12:16):
func loadThumbnails(with renderer: ThumbnailRenderer) {
for photoURL in urls {
autoreleasepool {
// Objects created inside the loop body are released at the end of each iteration.
renderer.faultThumbnail(from: photoURL)
}
}
}
Key points:
autoreleasepoolcreates a local scope where autoreleased objects are released when the scope ends- In the original code, autoreleased objects from the loop accumulated in the thread’s top-level pool until the loop finished
- Nested pools ensure immediate release after each iteration, avoiding memory spikes
Sustained Growth: Wrong Cache Key
Sustained growth appears as step-wise memory rise—growing after each operation without falling back. Mark Generation separates persistent allocations from different time periods (14:08).
Key fix code for wrong cache key (19:28):
func faultThumbnail(from photoURL: URL) {
// Wrong: use the current time as the cache key.
// let timestamp = UInt64(Date.now.timeIntervalSince1970)
// Correct: use the file creation time as the cache key.
let timestamp = cacheKeyTimestamp(for: photoURL)
let cacheKey = CacheKey(url: photoURL, timestamp: timestamp)
let thumbnail = cacheProvider.thumbnail(for: cacheKey) {
return makeThumbnail(from: photoURL)
}
images.append(thumbnail.image)
}
Key points:
- Original code used
Date.nowto generate cache keys, creating a different key on every call - The same file created a new
PhotoThumbnailobject and joined the global cache on every access - After the fix, file creation timestamp ensures the same file uses the same cache key
Memory Leaks: Reference Cycles
Memory leaks are reachable objects that will never be used again. Closures strongly capture references by default, easily forming reference cycles (22:10).
Key fix code for reference cycles (23:40):
// Wrong: the closure strongly captures renderer.
loader.completionHandler = {
self.thumbnails = renderer.images // Implicit strong capture.
}
// Correct: use a weak capture.
loader.completionHandler = { [weak renderer] in
guard let renderer else { return }
self.thumbnails = renderer.images
}
Key points:
- Closures strongly capture all referenced variables by default
[weak renderer]creates a weak reference, breaking the reference cycleguard let rendererensures the object still exists when the closure executes
Memory Graph Debugger marks leaking objects with yellow triangles (20:03), filterable by type to locate leak sources.
Reference Types: weak vs unowned
Both weak and unowned can break strong reference cycles but behave differently (26:51):
- weak: Always optional; automatically becomes
nilwhen object is released; requires extra weak reference storage allocation - unowned: Directly holds object address; no extra memory; crashes if accessing deallocated object
Performance difference example (30:11):
// weak requires extra weak reference storage for each object.
weak var holder: Swallow?
// unowned directly holds the address with no extra overhead.
unowned let holder: Swallow
Key points:
- weak references require extra storage to track weak references
- unowned references have no extra overhead but must guarantee the referenced object outlives the reference
- When using weak, check call frequency of runtime functions like
swift_weakLoadStrong()
Implicit Traps in Reference Cycles
Assigning methods to closure properties implicitly captures self (29:07):
class ByteProducer {
let data: Data
private var generator: ((Data) -> UInt8)? = nil
init(data: Data) {
self.data = data
// Wrong: defaultAction implicitly uses self, creating a reference cycle.
generator = defaultAction
}
func defaultAction(_ data: Data) -> UInt8 {
// ...
}
}
// Correct: explicit closure plus weak capture.
generator = { [weak self] data in
return self?.defaultAction(data)
}
// Or use unowned when generator does not outlive ByteProducer.
generator = { [unowned self] data in
return self.defaultAction(data)
}
Key points:
- Using methods as closures implicitly captures
self - Use explicit closures and capture lists to control reference type
- unowned applies when closure lifetime clearly doesn’t exceed the captured object
Tool Usage Recommendations
- MallocStackLogging: Enable during development to record allocation call stacks (03:47)
- Memory Graph Debugger: Capture memory snapshots when paused to view reference relationships (04:32)
- Instruments Allocations: Analyze memory trends and allocation history (05:15)
- Command-line tools:
heap,malloc_history,vmmap,Leaksfor offline analysis (04:50)
Core Takeaways
1. Use nested autoreleasepool when calling framework APIs in loops
When loops call APIs that produce autoreleased objects (especially Objective-C frameworks), wrap the loop body in nested autoreleasepool. This ensures temporary objects are released immediately after each iteration, avoiding memory spikes.
Why it’s worth it: The original implementation accumulates all autoreleased objects in the thread’s top-level pool until the pool is drained—in a loop this can take a long time.
How to start: Add autoreleasepool { ... } in the loop; verify memory spikes disappear with Xcode Memory Report or Instruments Allocations.
2. Use stable keys for cached data
When caching, ensure keys uniquely identify data and that repeated access to the same data hits the cache. When using file URLs as keys, combine file modification or creation time—not current time.
Why it’s worth it: Wrong cache keys cause perpetual cache misses—each access creates new objects and adds them to cache, causing sustained memory growth.
How to start: Audit all caching code for key stability. For file resources, use file attributes (like creationDate or contentModificationDate) rather than runtime time.
3. Default to weak for closure captures; use unowned only when lifecycle is guaranteed
Closures strongly capture by default, easily forming reference cycles. Using [weak self] to break cycles is the safe approach. When closure lifetime clearly doesn’t exceed the captured object (internal closures, synchronous callbacks), [unowned self] saves weak reference storage overhead.
Why it’s worth it: weak references require extra storage per object—significant overhead with many objects. unowned has no extra overhead but crashes on deallocated access.
How to start: Use capture lists in all closures. Default to weak; consider unowned only after confirming lifecycle relationships.
4. Use Mark Generation to track sustained growth sources
When memory rises in steps, use Instruments Allocations’ Mark Generation to mark at different time points, separating persistent allocations from each period.
Why it’s worth it: Sustained growth may come from multiple sources—Mark Generation segments growth by time, helping locate which code caused the increase.
How to start: Reproduce the growth pattern in Instruments, click Mark Generation before and after growth, then sort by growth size to view allocation types per generation.
5. Check for cascading leaks after fixing
After fixing one memory leak, other leaks may disappear too—because objects held by leaked objects also leak (24:08).
Why it’s worth it: Leaked objects may hold strong references to other objects; fixing the root leak allows held objects to release normally.
How to start: After fixing a leak, rerun Memory Graph Debugger to confirm leak count decreased or disappeared, and check for new root-cause leaks.
Related Sessions
- Consume noncopyable types in Swift — Introduction to noncopyable types in Swift, understanding copying and avoiding unnecessary overhead
- Explore Swift performance — How Swift balances abstraction and performance, and Swift language features affecting performance
- Explore the Swift on Server ecosystem — Swift for server applications, covering key frameworks and tools
- Go further with Swift Testing — Deep dive into Swift Testing framework, writing test suites and using advanced features
Comments
GitHub Issues · utterances