Tracking down a memory leak

I've discovered that my app has a memory leak, where when I close a window, it disappears from the screen but it (along with all its views, view controllers, and associated objects) actually doesn't get released.

Instruments doesn't register it as a leak. It does show a few unidentified malloc leaks, but nowhere near enough to account for a window full of stuff.

Xcode's memory graph doesn't show any leaks either, and I've combed through it looking for retain cycles or unwanted captures in blocks. I've fixed a few, but it hasn't been enough to actually get the window released.

What other tactics are there for finding this kind of leak?

The project is here for anyone feeling adventurous: https://github.com/Uncommon/Xit

This sounds like a case of abandoned memory, not a memory leak. The Xcode memory debugger can automatically identify leaks, but not abandoned memory.

In ARC, an abandoned allocation is an allocation accidentally strongly referenced after its expected lifetime. You can typically address this by using weak or unowned references instead of strong references where appropriate.

For example, consider the following:

import Foundation

let notificationName = NSNotification.Name("SomeNotification")

class IsStronglyCaptured {
    var token: NSObjectProtocol? = nil

    init() {
        self.token = NotificationCenter.default.addObserver(forName: notificationName, object: nil, queue: nil) { notification in
            print(self)
        }
    }

    deinit {
        print("IsStronglyCaptured is being deinitialized")

        if let token {
            NotificationCenter.default.removeObserver(token, name: notificationName, object: nil)
        }
    }
}

class IsWeaklyCaptured {
    var token: NSObjectProtocol? = nil

    init() {
        self.token = NotificationCenter.default.addObserver(forName: notificationName, object: nil, queue: nil) { [weak self] notification in
            print(String(describing: self))
        }
    }

    deinit {
        print("IsWeaklyCaptured is being deinitialized")

        if let token {
            NotificationCenter.default.removeObserver(token, name: notificationName, object: nil)
        }
    }
}

_ = IsStronglyCaptured()
_ = IsWeaklyCaptured()

In this code IsStronglyCaptured and IsWeaklyCaptured instances register for notifications from NotificationCenter. In the callbacks IsStronglyCaptured is captured strongly, while IsWeaklyCaptured is captured weakly because it uses a capture list of [weak self]. The IsStronglyCaptured instance will persist indefinitely because of the strong capture, while the IsWeaklyCaptured instance will be deallocated almost immediately.

The program's output confirms this, showing that the IsWeaklyCaptured instance's deinitializer runs but the IsStronglyCaptured instance's doesn't:

IsWeaklyCaptured is being denitialized

The Xcode memory debugger won't automatically point out such references, but it can be used to manually search for them. If you can find an abandoned allocation in the memory debugger you can use the memory debugger to see what references (and potentially retains) that allocation.

When analyzing the above example after its final line executes, the Xcode memory debugger shows that the IsStronglyCaptured instance still exists and is referenced by NotificationCenter-related allocations, which indicates that NotificationCenter may be retaining the abandoned allocation.

For the record, the culprit ended up being WKUserContentController.add(_:name:) creating a retain cycle. Once I fixed that, the entire window contents got released properly.

Hi @Developer Tools Engineer, memory graph debugger can't capture leaks on my simple demo:

  • I have 2 view controllers. View controller 1 is the root view controller of the app
  • View controller 1 will use navigation controller to push to view controller 2 (which has retain cycle between itself and a closure)
  • However, after I tap "Back" button in the navigation controller to go back to view controller 1 -> Memory graph debugger not show "ViewController2" as leak.

Things to note here:

  • This is leaks, not abandoned memory (since we can't not access to view controller 2) from anywhere else.

So I wonder why Memory Graph Debugger doesn't work on this simple demo?

This is the code of the sample project:

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func didTapButton(_ sender: Any) {
        let vc2 = ViewController2()
        navigationController?.pushViewController(vc2, animated: true)
    }
}

final class ViewController2: UIViewController {
    private var callback: (() -> Void)?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        callback = {
            self.view.backgroundColor = .red // retain cycle here
        }
    }
}
Tracking down a memory leak
 
 
Q