What is a retain cycle, and how do you find and fix one?

3.3.1 · L1 · Interview

Reported at: PhonePe, Walmart

Predict first

What does this print?

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

var a: A? = A()
a!.b = B()
a!.b!.a = a
a = nil
print("after")
  • deinit A, deinit B, after
  • after
  • deinit A, after

Predict first

What does this print once the back-reference is weak?

final class Parent {
    var child: Child?
    deinit { print("deinit Parent") }
}
final class Child {
    weak var parent: Parent?
    deinit { print("deinit Child") }
}

var p: Parent? = Parent()
p!.child = Child()
p!.child!.parent = p
p = nil
print("after")
  • after
  • deinit Parent, deinit Child, after
  • deinit Child, deinit Parent, after

Mental model. Two people each holding the other’s hand over a ledge. Neither can let go first, so neither falls, and neither is ever pulled back up.

A retain cycle is a loop of strong references: A holds B and B holds A (or a longer ring, A to B to C to A). Every object in the loop has at least one strong reference, from the next one, so no count ever reaches zero. None of them is freed and none runs deinit. Nothing crashes: the app just uses more memory over time.

To find one, add a print or breakpoint in deinit and exercise the screen: if it never fires, something holds it. The Xcode Memory Graph debugger draws the loop, and Instruments’ Leaks tool flags unreachable cycles. To fix it, break one link with weak or unowned, choosing the link that points against ownership (child to parent). The usual hiding places are delegates, stored closures, timers, notification and Combine subscriptions, and long-running tasks.

Each object is still held by the other, so releasing the outside variable frees neither. Only after prints.

With the child pointing back weakly there is no loop. The parent dies first, which then releases the child.

Common traps

  • Expecting a crash or a warning. A cycle is silent. Only deinit logs, graphs and tests reveal it.
  • Fixing it by deleting the object from a list. The cycle still holds it. Break the loop itself.

They might follow up with

  • Do value types form cycles? Only through references they hold. A struct cannot contain itself directly.
  • Are cycles always a bug? Short-lived ones that you deliberately break later (for example in a stop() method) are fine. Unbroken ones leak.

Related questions

  • 3.2.3 How do you decide between weak, unowned and strong for a back-reference?
  • 3.7.1 What is the difference between a leak, abandoned memory, fragmentation and a memory spike?
  • 3.7.2 How do you find a retain cycle with the Memory Graph debugger?

Why should a delegate property be weak, and why must the protocol be… →