[ARC] Unowned Reference Count and Deallocation Timing

Hi all,

I’m digging into the details of how Swift ARC manages object lifetime with respect to strong, weak, and unowned references.

Based on RefCount.h and various runtime comments, my current understanding is as follows:

  • An object has three reference counts: strong, unowned, and weak.
  • When the strong reference count drops to zero, deinit is called.
  • After deinit, as long as the unowned reference count is greater than zero , the object’s memory should not be deallocated yet .Actual deallocation (heap free) happens only when unowned RC also reaches zero.
  • (Additionally, the weak RC controls the lifetime of the side table.)

However, in practice, I’ve observed that even when unowned references remain (e.g. closures capturing [unowned obj]), the object’s memory seems to be deallocated immediately after deinit.


My questions:

  1. Is my understanding above correct according to Swift’s actual ARC implementation? (i.e., should memory only be deallocated after unowned RC drops to zero?)
  2. Or is it actually allowed/expected that memory is freed immediately after strong RC hits zero, even with outstanding unowned references?
  3. If my understanding is wrong, is there an official explanation or runtime nuance about how/when memory can be reclaimed in the presence of unowned references?

If anyone can clarify (or point to relevant runtime code/docs that describe this detail), I’d really appreciate it!

hi, and welcome to the Swift forums. your summary of the high-level lifecycle rules matches my current understanding. personally i think the description of the lifecycle state machine you referenced is some of the best documentation on this subject (you may also find this article on the matter of interest).

can you provide some sample code that demonstrates the behavior you describe (freeing memory while there are outstanding unowned references)? the only way i can think of that could lead to this offhand (other than a bug) is by using unowned(unsafe) references, which i think would be expected as they opt out of Swifts memory safety guarantees.

2 Likes

Semantically, it’s best to think of these as three types of references: strong, weak, and unowned. Each strong reference type will increment the reference count. As each strong reference is released, the reference count is decremented. When the reference count hits 0 (i.e., there are no more strong references), the object will be deallocated.

Once deallocated, weak references will be set to nil, but unowned references will not. [Technically, the unowned references in Swift are not just dangling pointers. They’re safer than that, resulting in more predictable runtime errors if you misuse them.]

As you’ve seen, the internal implementation is richer than this, but this is probably the best mental model from the perspective of a normal Swift programmer. For more information, see Automatic Reference Counting in The Swift Programming Language.

2 Likes

FWIW, SE-0458– Opt-in Strict Memory Safety Checking outlines the unsafe variant of unowned, namely unowned(unsafe).

Based on my understanding, as long as TestViewController is not deinitialized, the unowned references in unownedRefs should still exist, so the NSData object shouldn’t be deallocated.

However, what I observed is that right after calling self.data = nil, the memory usage (about 20MB for the NSData instance) increases and then immediately drops, indicating that the NSData is actually deallocated at that point.

Could you explain why this happens?

class TestViewController: UIViewController {
    var data: NSData?
    var unownedRefs: [() -> NSData?] = []

    @objc func createUnownedRefs() {
        self.data = NSData(data: Data(repeating: 4, count: 20 * 1024 * 1024))
        for _ in 0 ..< 10 {
            let unownedRef = { [unowned data = self.data] in
                return data
            }
            unownedRefs.append(unownedRef)
        }

        self.data = nil
    }
}

I expected that as long as TestViewController is alive, the unowned references would keep the NSData object around, but apparently that’s not the case. Why does the memory get released even though there are still unowned references?

An unowned reference will not keep the object from being deallocated. Only a strong reference will do that.

As The Swift Programming Language says:

And:

The unowned references do not factor into the lifespan of the object. A reference object is deallocated as soon as there are no more strong references to it. We use unowned (and weak) in those cases where we do not want a strong reference, typically only to avoid strong reference cycles. To keep an object from being deallocated, you need at least one strong reference to it.

So, we use unowned references is those cases where we:

  • explicitly want to avoid a strong reference (e.g., to avoid a strong reference cycle); but
  • do not need the modest overhead of a weak reference because we know that the object that has this unowned reference cannot outlive the object to which it references.

So, use weak/unowned references to break strong reference cycles (which is not apparently needed in your example) and choose unowned over weak only if your logic guarantees that the object with the reference cannot outlive the object it references.

2 Likes

NSData is also an Objective-C class, so it doesn't necessarily use Swift's reference-counting implementation. You need to be looking at e.g. swift_unknownObjectUnownedInit rather than the HeapObject headers directly. For objects that don't use Swift reference counting, Swift implements unowned references using the ObjC weak reference system, which is less efficient but does allow objects to be deallocated immediately.

I'm not an expert in the Foundation bridging implementation, but I believe the NSData objects created by Data should use Swift reference counting on recent Apple OSes. It is OS-dependent, however.

2 Likes

Robert has provided a good summary of the 'high level' view of the types of references in Swift, and John's point regarding non-native objects is well taken, so let's set those aside. since i find this topic rather fascinating, i want to highlight what unowned references do keep alive if they outlive their referent – namely, the memory allocation for the object instance itself.

with an altered version of your sample code that uses Swift's native objects, then using Xcode's memory debugger tool reveals that outstanding unowned references to an object do not prevent it from deinitializing, but they do keep the memory allocation for the instance itself around until they are all gone:

import Foundation
import Testing

final class DataRef {
  let data: Data

  init(_ data: Data) {
    self.data = data
    print("DataRef init: \(ObjectIdentifier(self))")
  }

  deinit {
    print("DataRef deinit: \(ObjectIdentifier(self))")
  }
}

final class TestObject {
  var data: DataRef?
  var closure: (() -> Void)?

  func createUnownedRefs() {
    let data = DataRef(Data(repeating: 4, count: 20 * 1024 * 1024))
    self.data = data

    self.closure = { [unowned data] in
      withExtendedLifetime(data) {}
    }
    print("formed unowned ref")

    self.data = nil
    print("cleared strong ref")
  }
}

@Test
func huskTest() async {
  do {
    let obj = TestObject()
    withExtendedLifetime(obj) {
      obj.createUnownedRefs()
      // <---
      // Setting a breakpoint here, and using Xcode's 'Debug Memory Graph' tool
      // will show that the pointer printed during DataRef's init/deinit still
      // exists in memory. That is the allocation for the object instance itself.
      // This is typically far smaller than the storage of its actual contents (e.g.
      // in this case the underlying bytes of Data), as it is roughly just the size of
      // whatever space is needed for the instance's stored properties.
      //
      // Additionally, sometimes the `leaks` tool will even report such things
      // as leaks since they look like live objects which have no remaining strong
      // references. When the last unowned reference goes away, the object allocation
      // itself is reclaimed.
      print("unowned refs created")
    }
  }
  // <---
  // Setting a breakpoint here and looking for the same object in the memory graph
  // debugger will yield no results, as the unowned references are gone (or it may
  // show some new object if the memory has been reused).
  print("obj destroyed")
}

First of all, thank you so much for taking the time to answer my question in such detail. Thanks to your explanations, all my doubts have been resolved.

I’ve confirmed that, as you described, deinit is indeed called, but the memory for the instance is not actually deallocated as long as outstanding unowned references remain.

1 Like

Consider:

  1. When you create the large NSData instance, memory is allocated for it.

  2. When you create an unowned reference to that instance, a tiny structure is created that refers to that large NSData instance.

  3. When you remove your last strong reference to the original NSData instance, its memory is deallocated. At this point, all that remains is the tiny structure associated with the unowned reference to an instance that no longer exists.

  4. When you remove the unowned reference, the small amount of memory for that unowned reference structure is removed too.

Here is an Instruments recording of those four steps (with ⓢ signposts at each step), showing the memory usage during this process:

Note that at the third ⓢ signpost, where the last strong reference is removed (but while the unowned reference still persists), the memory associated with this large object is deallocated.

As an aside, if you use unowned(unsafe) instead of unowned, it is effectively just a dangling pointer to memory that may or may no longer exist. Swift’s unowned(unsafe) behaves like Objective-C’s unsafe_unretained, without the guard rails that Swift’s unowned offers.


Not that it’s terribly relevant, but here is the code associated with the above Instruments recording:

//  ViewController.swift

import UIKit
import os.log

private var log = OSSignposter(subsystem: Bundle.main.bundleIdentifier!, category: .pointsOfInterest)

class ViewController: UIViewController {
    var data: NSData?
    unowned var unownedData: NSData?

    @IBAction func createStrongReference(_ sender: Any) {
        log.emitEvent(#function)
        data = NSData(data: Data(repeating: 0, count: 20_000_000))
    }

    @IBAction func createUnownedReference(_ sender: Any) {
        log.emitEvent(#function)
        unownedData = data
    }
    
    @IBAction func removeStrongReference(_ sender: Any) {
        log.emitEvent(#function)
        data = nil
    }

    @IBAction func removedUnownedReference(_ sender: Any) {
        log.emitEvent(#function)
        unownedData = nil
    }
}
2 Likes