While optimizing the launch time of an old project last year, I ran into a weird issue: the same method call sometimes took a few nanoseconds, other times a few microseconds. After days of digging, I finally traced it back to a tiny 16-byte struct inside the Objective-C runtime — the method cache.
This article is my study notes from that journey. Hopefully, it helps you understand why objc_msgSend is so fast and how the caching mechanism actually works under the hood.
The 16-Byte Box Inside Every Class
Inside the objc_class struct, the third member is cache_t cache. Don’t underestimate these 16 bytes — they power the entire performance optimization of message sending:
struct cache_t {
struct bucket_t *_buckets; // 8 bytes, pointer to the hash table
mask_t _mask; // 4 bytes, hash table size minus 1
mask_t _occupied; // 4 bytes, number of cached methods
};
_buckets points to an array of bucket_t, and each bucket_t stores a key-value pair of a method’s SEL and IMP. _mask determines the hash table size, and _occupied tracks how many methods are currently cached.
When I first saw this struct, I had a doubt: only 16 bytes? How many methods can it possibly hold? Then I realized — it’s not designed to hold a lot. The whole point is to be small and fast. When it’s full, it just clears and starts over.
Here’s a subtle detail: the order of members in bucket_t varies across architectures. On arm64, IMP comes before SEL. On other architectures, it’s reversed. Apple’s source code comments explain it clearly:
“IMP-first is better for arm64e ptrauth and no worse for arm64. SEL-first is better for armv7* and i386 and x86_64.”
In short, it’s optimized for arm64e’s pointer authentication mechanism, squeezing every bit of performance out of the cache hit path. Apple cares so much about performance that they tweak member order per architecture.
Cache Lookup: Bitwise Ops, No Traversal
CacheLookup is the core macro in the message-sending assembly code. It computes a hash index directly using SEL & mask, locating the bucket_t without any array traversal:
mask_t begin = cache_hash(k, m);
// Internal implementation: return (mask_t)(key & mask);
On arm64, _mask is stored in the upper 16 bits, and _buckets and _mask are combined into _maskAndBuckets. This compact design saves memory and reduces cache line usage.
If the lookup misses, it probes forward according to cache_next rules until it finds an empty slot or loops back to the start. If it completely misses, CacheLookup jumps to __objc_msgSend_uncached, triggering the slow path.
The slow path centers around lookUpImpOrForward. The lookup order is: current class cache → current class method list → superclass chain → dynamic method resolution → message forwarding.
When I was profiling that launch optimization project with Instruments’ System Trace, I noticed a ton of __objc_msgSend_uncached calls during cold launch. The reason was simple: the cache starts empty, and every method’s first call hits the slow path. Understanding this helped me prioritize which methods to call earlier in the startup sequence.
Cache Write: Only the First Call Pays the Price
cache_fill_nolock is the entry point for cache writes. Before reading the source code, I thought caching was straightforward — call a method, stuff it in. Turns out there’s a lot more going on:
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
// The class must be initialized before caching is allowed
if (!cls->isInitialized()) return;
// Prevent duplicate writes across threads
if (cache_getImp(cls, sel)) return;
cache_t *cache = getCache(cls);
mask_t newOccupied = cache->occupied() + 1;
mask_t capacity = cache->capacity();
if (cache->isConstantEmptyCache()) {
cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
}
else if (newOccupied <= capacity / 4 * 3) {
// Capacity usage below 3/4 — proceed directly
}
else {
cache->expand(); // Capacity insufficient — trigger expansion
}
bucket_t *bucket = cache->find(key, receiver);
if (bucket->key() == 0) cache->incrementOccupied();
bucket->set(key, imp);
}
Three key points worth noting:
- The class must be initialized: Methods aren’t cached before
+initializefinishes. This means+initializeitself always goes through the slow path. - Expansion threshold is 3/4: When
_occupiedreaches 75% of capacity, expansion is triggered. - Expansion clears all old caches: No migration — it just throws everything away and rebuilds from scratch.
The third point confused me at first. Traditional hash tables migrate data on expansion to preserve hit rates. Why does Apple do the opposite? Let’s dig into that next.
Lock-Free Reads and Garbage Collection: The Concurrency Trade-Off
objc_msgSend does not hold any locks when reading the cache. Apple’s source code comments explain the reasoning:
“For speed, objc_msgSend does not acquire any locks when it reads method caches. Instead, all cache changes are performed so that any objc_msgSend running concurrently with the cache mutator will not crash or hang or get an incorrect result from the cache.”
The trade-off is clear: speed over simplicity. Reads are lock-free for maximum performance, but writes have to be extra careful to ensure concurrent readers don’t break.
Old caches aren’t freed immediately after expansion, because there might still be objc_msgSend calls running that are reading them. These old caches are placed on a garbage list. The collecting_in_critical function checks all thread program counters to ensure no thread is still inside objc_msgSend before safely releasing the memory.
All functions that modify cache data must acquire the cacheUpdateLock to prevent concurrent write conflicts. This “write-locked, read-lock-free + deferred release” design strikes a careful balance between concurrency safety and performance.
Why Not Migrate Old Cache on Expansion?
Traditional hash tables usually migrate old data to the new table during expansion. Method caching does the exact opposite — it clears everything.
Apple’s source code comments give the reason straight up:
“Cache’s old contents are not propagated. This is thought to save cache memory at the cost of extra cache fills.”
The way I see it: migration is too expensive. Caching is a performance optimization tool — if you spend significant time migrating data during expansion, you defeat the whole purpose. Clearing and rebuilding is more economical. The cost is just a few extra cache misses on subsequent calls, which is much cheaper than migrating all old data.
This also explains why the method cache stays small — it’s not supposed to store every method, just the recent hot ones. Full? Clear. Start over. Repeat.
Subclasses Cache Superclass Methods: An Easy-to-Miss Optimization
When the slow lookup finds a method somewhere up the superclass chain, lookUpImpOrForward calls cache_fill to store the IMP in the current class’s cache, not the superclass’s.
What does this mean in practice?
@interface Parent : NSObject
- (void)doSomething;
@end
@interface Child : Parent
@end
Child *c = [[Child alloc] init];
[c doSomething]; // First call: method found in Parent, cached in Child
[c doSomething]; // Second call: hits directly in Child's cache
The second call doesn’t need to traverse the inheritance chain at all. I had no idea about this until I read the source code. For hot paths that call inherited methods frequently, this optimization makes a real difference.
Can You See the Cache at Work in Development?
Yes. You can use LLDB to print a class’s cache_t and see which methods are currently cached:
(lldb) p (cache_t *)0x1000012f8
(cache_t *) $1 = 0x00000001000012f8
(lldb) p *$1
(cache_t) $2 = {
_buckets = 0x0000000101e23c20
_mask = 3
_occupied = 1
}
Under normal operation, _occupied stabilizes around 3/4 of capacity. When it exceeds 3/4, it expands, clears, and starts refilling. If you see _mask change (e.g., from 3 to 7), that means an expansion just happened.
I used this debugging technique to verify a hunch in my own project. I suspected certain high-frequency methods weren’t being cached. Printed the cache, saw _occupied was only 1 — and that led me to dig into the call order issue.
Summary
The method cache mechanism’s core design principles, in my own words:
- Reads are lock-free, writes are locked, cleanup is deferred: Performance comes at the cost of complexity, but the trade-off is worth it.
- Clear on expansion, don’t migrate: Sacrifice some hit rate for simplicity and speed.
- Subclasses cache superclass methods: Cuts down repeated lookups along the inheritance chain.
Understanding this mechanism gave me a much clearer picture of why objc_msgSend stays so fast despite dynamic dispatch — it reduces “method list traversal” (O(n)) to “one hash lookup” (O(1)).
More importantly, it changed how I write code. On hot paths, I pay attention to method call order so frequently-used methods get cached early. I also think more carefully about inheritance design, knowing how subclass caching behaves. These small details made a tangible difference when optimizing launch time and UI responsiveness.
A Reproducible Scenario: Measuring Route-Matcher Dispatch
I investigated method dispatch after a routing component appeared in a startup profile. The runnable project reduces that case to DemoRouteMatcher. It observes one call, then one million repeated calls, while accumulating a checksum so the compiler cannot discard the work and the business result remains verifiable.
The output is useful for learning and regression comparisons, but it is not a direct cache-hit counter. Objective-C does not expose a supported application API that reports each private method-cache hit and miss.
What the Benchmark Actually Measures
The first observed call can include class realization, initialization, page faults, timer granularity, scheduler activity, and CPU frequency changes. The repeated loop amortizes some fixed costs, yet it also includes loop overhead and the method body. Therefore, a smaller per-call average is consistent with efficient repeated dispatch, but it does not isolate the cache mechanism by itself.
NSUInteger checksum = 0;
double elapsed = MeasureCalls(matcher, @"/catalog/items",
1000000, &checksum);
NSLog(@"Elapsed: %.3f ms", elapsed);
How I Would Use This in a Real Investigation
Run the executable multiple times on the same machine, keep power and build settings consistent, and compare distributions rather than one attractive number. Then profile the real application with Instruments. If the method body performs allocation, I/O, locking, parsing, or layout, optimizing message lookup is unlikely to be the highest-value change.
Runtime cache layouts and assembly paths can change across OS releases and architectures. They are valuable implementation study material, not contracts that application code should read or modify. Use documented Runtime APIs and measure user-visible work.
Download and Run the Complete Project
The complete project is available in its dedicated GitHub repository: objc-method-cache-observation-demo. It requires macOS and the Xcode Command Line Tools.
git clone https://github.com/2252408699/objc-method-cache-observation-demo.git
cd objc-method-cache-observation-demo
make run
A successful run prints both timing observations, Checksum verification: PASS, and an explicit reminder that the result is observational. The repository includes complete source, build instructions, limitations, and cleanup commands.
