Why is this unreferenced object refusing to deallocate?

I'm playing around with a NotificationCenter clone that automatically unregisters its "notification blocks" whenever the parent object is deallocated. This was done by having an Obj-C NSMapTable with weak keys that maps observers to the blocks they registered, while a second Swift dictionary with strong keys/values map notification keys to a NSHashTable with weak references to the same blocks that are being retained by the first table.

final class Box<T>: NSObject {
    let obj: T
    init(obj: T) {
        self.obj = obj
        super.init()
    }
}

final class NotificationCenter {

    typealias ClosureBox = Box<() -> Void>

    var observers = NSMapTable<AnyObject, NSHashTable<ClosureBox>>.weakToStrongObjects()
    var notifications = [String: NSHashTable<ClosureBox>]()

    func register(_ closure: @escaping () -> Void, notification string: String, forObserver observer: AnyObject) {
        if observers.object(forKey: observer) == nil {
            observers.setObject(NSHashTable(), forKey: observer)
        }
        if notifications[string] == nil {
            notifications[string] = NSHashTable.weakObjects()
        }
        let box = ClosureBox(obj: closure)
        observers.object(forKey: observer)?.add(box)
        notifications[string]?.add(box)
    }

    func post(notification string: String) {
        for box in notifications[string]?.allObjects ?? [] {
            box.obj()
        }
    }
}

I have the following unit test to confirm this works, which uses an autoreleasepool to force the observer under test to deallocate:

func test_observerIsDeallocated() {
    let center = NotificationCenter()
    let notification = "myNotification"
    var timesExecuted = 0

    autoreleasepool {
        let observer = UIView()

        center.register({
            timesExecuted += 1
        }, notification: notification, forObserver: observer)

        center.post(notification: notification)
        XCTAssertEqual(timesExecuted, 1)
        center.post(notification: notification)
        XCTAssertEqual(timesExecuted, 2)
        center.post(notification: "otherNotification")
        XCTAssertEqual(timesExecuted, 2)
    }

    center.post(notification: notification)
    XCTAssertEqual(timesExecuted, 2)
}

However, the last assertion fails. If I print the contents of the dictionaries at that point, it reveals this:

NSMapTable {

}

["myNotification": NSHashTable {
[9] <_TtGC16AppAttestTestsds3BoxFT_T__: 0x60000283ad20>
}
]

This shows that the observer was in fact deallocated, with its related "notification boxes" being evicted from the NSMapTable. But how come the box object is still being referenced by notifications's NSHashTable if it only holds weak references? I've tried multiple ways to write this, but the box never dies even though it isn't being retained by anything. Where are those 9 references coming from?

Nevermind, I think I just found out. NSMapTable doesn't really gets rid of its values. So this is not a Swift issue, just a Foundation API detail.

Discussion

Use of weak-to-strong map tables is not recommended. The strong values for weak keys which get zeroed out continue to be maintained until the map table resizes itself.

Working solution: Make the map lazy so that the autoreleasepool will get rid of it as well and rebuild the dictionary to force it to get rid of the references. Not what I expected to take out of this unfortunately...

final class NotificationCenter {

    typealias ClosureBox = Box<() -> Void>

    lazy var observers: NSMapTable? = NSMapTable<AnyObject, NSHashTable<ClosureBox>>.weakToStrongObjects()
    lazy var notifications = [String: NSHashTable<ClosureBox>]()

    func register(_ closure: @escaping () -> Void, notification string: String, forObserver observer: AnyObject) {
        if observers?.object(forKey: observer) == nil {
            observers?.setObject(NSHashTable(), forKey: observer)
        }
        if notifications[string] == nil {
            notifications[string] = NSHashTable.weakObjects()
        }
        let box = ClosureBox(obj: closure)
        observers?.object(forKey: observer)?.add(box)
        notifications[string]?.add(box)
    }

    func post(notification string: String) {
        rebuildObserverTable()
        for box in notifications[string]?.allObjects ?? [] {
            box.obj()
        }
    }

    func rebuildObserverTable() {
        // This is a workaround for an issue with NSMapTable.
        let oldTable = observers
        let values = oldTable?.objectEnumerator()?.compactMap { $0 as? NSHashTable<ClosureBox> } ?? []
        let keys = oldTable?.keyEnumerator().compactMap { $0 } ?? []
        observers = NSMapTable.weakToStrongObjects()
        for (k, v) in zip(keys, values) {
            observers?.setObject(v, forKey: k as AnyObject)
        }
    }
}

This makes the unit test pass.

1 Like