I once blamed NSObject for a memory problem that it did not cause. An import feature in one of my Objective-C tools created thousands of short-lived job objects. Instruments showed a larger memory increase than my rough calculation predicted, so I assumed that every object carried a mysterious fixed amount of hidden NSObject data. That assumption sent me in the wrong direction for an afternoon.
The useful lesson was not a magic byte count. It was learning to separate three things: the language-level shape of an object, the allocation strategy used by the current runtime and allocator, and the behavior promised by Apple’s public APIs. This article rebuilds that investigation with a small, runnable command-line project. It shows what we can measure safely and where we should stop making universal claims.
The Import Job That Started the Investigation
My importer represented each input file with an ImportJob object. The class stored a processed-record count and a source name. I multiplied the number of jobs by the size of those two fields and expected the result to match the process footprint. It did not. The first mistake was treating an object’s logical instance size as the complete cost of keeping it alive.
An Objective-C object needs runtime identity in addition to the ivars declared by my subclass. Allocators may also round requests into size classes, and an object can retain other objects whose storage lives elsewhere. Temporary autoreleased values, collection capacity, framework caches, and debugging instrumentation can all affect the number shown by a memory tool. Consequently, “two ivars” is not a complete memory budget.
I also saw an EXC_BAD_ACCESS during the same investigation and briefly described it as “a damaged isa.” That was too confident. An invalid receiver, a use-after-free, a bad pointer, or unrelated memory corruption can lead to the same visible symptom. The crash site is evidence, not a diagnosis. Zombies, Address Sanitizer, the backtrace, and a reproducible test are much better starting points.
What NSObject Actually Gives a Subclass
NSObject is the root class used by most Objective-C class hierarchies. It supplies the basic behavior expected by Cocoa code: object creation conventions, runtime identity, class queries, equality and hashing hooks, textual descriptions, and protocol conformance. A subclass inherits this behavior and can override the parts that need domain-specific meaning.
That does not mean every detail of the runtime is physically stored inside every NSObject instance. Methods belong to class metadata, not to a separate copy embedded in each object. The runtime uses an object’s class identity to find behavior. This distinction matters when estimating memory: adding ten methods changes executable or metadata costs, but it does not add ten function bodies to every instance.
The safest mental model is intentionally modest. An instance has enough runtime-visible state to identify its class plus the storage required by its instance variables and inherited layout. The precise representation is an implementation detail. It may vary by architecture, operating-system release, runtime build, and optimization. Code should use the supported Objective-C Runtime APIs when it needs introspection instead of copying a private structure definition from one version of objc4.
Object Storage and Method Metadata Are Different
In the sample, ImportJob declares an integer ivar and an object-pointer ivar. Those fields contribute to the instance layout. Its summaryLine implementation, however, is discoverable through the class’s method list. The demo calls class_copyMethodList on ImportJob and confirms that the selector is present directly on that class.
Directly is an important word. class_copyMethodList does not return an already-flattened catalog of all inherited methods. If the runtime cannot find a selector on the current class, normal lookup can continue through the superclass chain. That is why an ImportJob instance can respond to behavior originating in NSObject even though the copied list for ImportJob contains only methods implemented there.
In production I avoid scanning method lists on a hot path. It is useful for diagnostics, adapters, carefully designed serialization systems, and developer tooling, but ordinary dispatch is clearer for ordinary application logic. Runtime introspection is powerful precisely because it reaches below normal abstractions; it deserves a narrow purpose and tests around its assumptions.
Logical Instance Size Is Not Total Memory Cost
The public function class_getInstanceSize reports the size in bytes of instances of a class. In my current arm64 test environment, the sample prints 8 bytes for NSObject and 24 bytes for ImportJob. Those numbers are observations from that build, not promises that every Apple device and runtime will always return them.
This is where claims such as “every Objective-C object occupies 16 bytes” become misleading. A logical instance size can differ from the allocator’s usable block size. Alignment and allocator buckets may add slack, while referenced objects such as the source string have allocations of their own. A process also contains runtime metadata and caches shared by many instances. No single multiplication captures all of that.
For a real memory regression, I use Instruments Allocations to compare object counts and responsible call stacks between two repeatable runs. I add a heap tool only when the question calls for allocator-level information. The runtime size function is still valuable: it lets a unit test confirm that a subclass layout is at least as large as its superclass and helps explain which bytes conceptually belong to the instance.
Allocation Starts at Zero, Initialization Adds Meaning
Fresh Objective-C object storage is zero-initialized. The demo creates an ImportJob and verifies that its integer begins at zero and its object pointer begins as nil. That behavior is convenient, but it should not be confused with a finished domain object.
Zero may be invalid for a timeout, index, state enum, or identifier. A required collaborator being nil may make the object unusable. An initializer establishes those invariants. In the original importer, I eventually replaced casual property assignment with a designated initializer that required a source URL and rejected invalid configuration early. Zero-filled storage prevented random garbage; the initializer made the object correct.
I also avoid presenting the runtime’s internal allocation call chain as a stable public contract. Private source is excellent for learning and debugging a particular OS release, but application code should not depend on a specific helper name, bit layout, or allocation sequence remaining unchanged.
Classes, Meta-Classes, and the Superclass Chain
Objective-C allows classes themselves to receive messages. The runtime supports this using meta-classes. For an ImportJob instance, object_getClass(job) returns the ImportJob class. Calling object_getClass on that class returns a meta-class, which the demo verifies with class_isMetaClass.
The ordinary superclass relationship is easier to apply day to day: class_getSuperclass([ImportJob class]) is NSObject. Instance-method lookup can move through that ordinary class chain. Class-method lookup uses the corresponding meta-class structures. The graph eventually converges around the root of the hierarchy, but drawing raw pointer loops from memory is less useful than testing the relationship you actually depend on.
For example, a debugging assertion that a dynamically supplied class descends from a required base class is reasonable. Depending on a hard-coded meta-class address or a private cache structure is not. Public APIs express intent and give the runtime room to evolve.
How to Run the Complete Project
The repository contains a Foundation command-line program, a Makefile, a README, and a small .gitignore. It requires macOS and the Xcode Command Line Tools. Clone it and run:
git clone https://github.com/2252408699/objc-nsobject-runtime-inspector-demo.git
cd objc-nsobject-runtime-inspector-demo
make run
The program performs seven checks and exits with a nonzero status if any check fails. A successful run ends with All NSObject runtime checks passed. The exact size lines may differ on another architecture or future macOS version; that variation is part of the lesson, not a failed test.
I deliberately used public functions from <objc/runtime.h>. The project does not guess at a private isa bitfield, inspect undocumented cache buckets, or require a specific objc4 snapshot. That makes the example useful for understanding the model without turning today’s implementation into tomorrow’s dependency.
A Practical Debugging Checklist
- Measure object counts and allocation call stacks before estimating from ivars.
- Treat
class_getInstanceSizeas logical instance information, not total retained memory. - Remember that methods live in class metadata and inherited lookup crosses the superclass chain.
- Use a designated initializer to establish invariants even though raw storage starts at zero.
- Investigate
EXC_BAD_ACCESSwith evidence; do not label every crash asisacorruption. - Keep private runtime-source observations version-specific and out of application contracts.
After applying this checklist to the importer, the dominant cost turned out to be retained source data and temporary parsing objects, not a hidden payload inside NSObject. Reducing the batch size and releasing intermediate results fixed the peak. More importantly, the diagnosis survived an OS update because it was based on measured ownership and supported APIs.
Complete runnable example: objc-nsobject-runtime-inspector-demo on GitHub
