WWDC Quick Look đź’“ By SwiftGGTeam
Analyze heap memory

Analyze heap memory

Watch original video

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):

  1. Created & Still Living: Select the period from trough to peak, view allocations still alive after growth
  2. 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:

  • autoreleasepool creates 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.now to generate cache keys, creating a different key on every call
  • The same file created a new PhotoThumbnail object 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 cycle
  • guard let renderer ensures 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 nil when 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, Leaks for 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.


Comments

GitHub Issues · utterances