From 460271101982dc3c9078849866a39ed6cb6a60ee Mon Sep 17 00:00:00 2001 From: Todd White Date: Thu, 13 Aug 2026 08:39:15 -0400 Subject: [PATCH 1/3] Record the hidden class's .cxx_destruct when the class is made A hidden class is made with uninstalled_dtable and keeps it until the object is next messaged. A method is recorded in the class as it is installed into a dtable, in installMethodInDtable, and objc_update_dtable_for_class returns immediately for a class that has no dtable, so the class_addMethod that puts .cxx_destruct on a new hidden class does not reach the field. class->cxx_destruct therefore stays null, call_cxx_destruct finds nothing to call, and deallocHiddenClass never runs. The hidden class, its reference list and every association the object holds are never freed. An object that is messaged again after gaining an association gets a dtable, and with it the cleanup, which is why this is not seen more often. The field is recorded when the hidden class is made. Test/AssociatedObjectCleanup.m gives an object a retained association, hands the last reference to it, and destroys the object without messaging it again. The associated object's dealloc does not run before this change and does run after it. --- Test/AssociatedObjectCleanup.m | 38 ++++++++++++++++++++++++++++++++++ Test/CMakeLists.txt | 1 + associate.mm | 4 ++++ 3 files changed, 43 insertions(+) create mode 100644 Test/AssociatedObjectCleanup.m diff --git a/Test/AssociatedObjectCleanup.m b/Test/AssociatedObjectCleanup.m new file mode 100644 index 00000000..06831420 --- /dev/null +++ b/Test/AssociatedObjectCleanup.m @@ -0,0 +1,38 @@ +#include "Test.h" + +// A hidden class carries uninstalled_dtable until the object is next +// messaged, and a method is recorded in the class as it is installed into a +// dtable, so the .cxx_destruct that carries the cleanup has to be recorded +// when the hidden class is made. An object destroyed without being messaged +// again after gaining an association exercises that. + +static BOOL deallocCalled = NO; +static const char *key = "AssociatedObjectCleanupKey"; + +@interface Associated : Test +@end + +@implementation Associated +- (void)dealloc +{ + deallocCalled = YES; + [super dealloc]; +} +@end + +int main(void) +{ + Associated *object = [Associated new]; + Test *holder = [Test new]; + + objc_setAssociatedObject(holder, &key, object, OBJC_ASSOCIATION_RETAIN); + // The association holds the only reference. + [object release]; + assert(!deallocCalled); + + // Destroy the holder without sending it another message first. + object_dispose(holder); + + assert(deallocCalled); + return 0; +} diff --git a/Test/CMakeLists.txt b/Test/CMakeLists.txt index b88dda39..a6e7c1ab 100644 --- a/Test/CMakeLists.txt +++ b/Test/CMakeLists.txt @@ -17,6 +17,7 @@ set(TESTS AllocatePair.m AssociatedObject.m AssociatedObject2.m + AssociatedObjectCleanup.m BlockTest_arc.m ConstantString.m Category.m diff --git a/associate.mm b/associate.mm index 9cee7b8f..99479828 100644 --- a/associate.mm +++ b/associate.mm @@ -235,6 +235,10 @@ static inline Class initHiddenClassForObject(id obj) const char *types = sizeof(void*) == 4 ? "v8@0:4" : "v16@0:8"; class_addMethod(hiddenClass, cxx_destruct, (IMP)deallocHiddenClass, types); + // The class carries uninstalled_dtable until the object is next messaged, + // and a method is only recorded in the class as it is installed into a + // dtable, so record it here. Without this the class is never freed. + hiddenClass->cxx_destruct = (IMP)deallocHiddenClass; obj->isa = hiddenClass; return hiddenClass; } From 131b7d5e2dad838b8401465ea196c57341526537 Mon Sep 17 00:00:00 2001 From: Todd White Date: Thu, 13 Aug 2026 09:22:16 -0400 Subject: [PATCH 2/3] Let a hidden class share its superclass's dispatch table A hidden class holds the association list and adds no methods of its own, but it built a copy of its superclass's dispatch table the first time the object was messaged after gaining an association, and never changed it. The copy is most of what an association costs. It now answers sends from its superclass's table, and gets one of its own only when a method is added to it through object_addMethod_np or object_replaceMethod_np. deallocHiddenClass does not free a table the class does not own. The .cxx_destruct is recorded in the class rather than added as a method, which the previous commit already does. Adding it as a method here would install deallocHiddenClass in the table the superclass is using, and every instance of that class would run it. Resident bytes per object holding one association, over 100000 objects: 6723 to 516 where the object is messaged again after associating, and 579 to 516 where it is not. The two cases now cost the same. Suite passes, 202 of 202. --- associate.mm | 57 ++++++++++++++++++++++++++++++++++++++-------------- class.h | 5 +++++ dtable.h | 1 + 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/associate.mm b/associate.mm index 99479828..26807015 100644 --- a/associate.mm +++ b/associate.mm @@ -213,7 +213,23 @@ static Class allocateHiddenClass(Class superclass) newClass->info = objc_class_flag_resolved | objc_class_flag_user_created | objc_class_flag_hidden_class | objc_class_flag_assoc_class; newClass->super_class = superclass; - newClass->dtable = uninstalled_dtable; + // A hidden class adds no methods of its own until one is added through + // object_addMethod_np, so it can answer sends from its superclass's + // dispatch table rather than building a copy it would never change. + dtable_t superDtable = dtable_for_class((struct objc_class *)superclass); + if (superDtable != uninstalled_dtable) + { + newClass->dtable = superDtable; + newClass->info |= objc_class_flag_shared_dtable; + // The class answers the same methods as its superclass, so it has the + // same answers to the questions create_dtable_for_class would ask. + newClass->info |= (superclass->info & + (objc_class_flag_fast_arc | objc_class_flag_fast_alloc_init)); + } + else + { + newClass->dtable = uninstalled_dtable; + } newClass->instance_size = superclass->instance_size; LOCK_RUNTIME_FOR_SCOPE(); @@ -227,17 +243,9 @@ static inline Class initHiddenClassForObject(id obj) { Class hiddenClass = allocateHiddenClass(obj->isa); assert(!class_isMetaClass(obj->isa)); - static SEL cxx_destruct; - if (NULL == cxx_destruct) - { - cxx_destruct = sel_registerName(".cxx_destruct"); - } - const char *types = sizeof(void*) == 4 ? "v8@0:4" : "v16@0:8"; - class_addMethod(hiddenClass, cxx_destruct, - (IMP)deallocHiddenClass, types); - // The class carries uninstalled_dtable until the object is next messaged, - // and a method is only recorded in the class as it is installed into a - // dtable, so record it here. Without this the class is never freed. + // Recorded in the class rather than added as a method: the class may be + // answering sends from its superclass's dispatch table, which must not + // gain this. call_cxx_destruct reads the field. hiddenClass->cxx_destruct = (IMP)deallocHiddenClass; obj->isa = hiddenClass; return hiddenClass; @@ -254,7 +262,10 @@ static void deallocHiddenClass(id obj, SEL _cmd) cleanupReferenceList(list); freeReferenceList(list->next); //fprintf(stderr, "Deallocating dtable %p\n", hiddenClass->dtable); - free_dtable(hiddenClass->dtable); + if (!objc_test_class_flag(hiddenClass, objc_class_flag_shared_dtable)) + { + free_dtable(hiddenClass->dtable); + } // We shouldn't have any subclasses left at this point assert(hiddenClass->subclass_list == 0); // Remove the class from the subclass list of its superclass @@ -430,14 +441,30 @@ static Class hiddenClassForObject(id object) return hiddenClass; } +/** + * Gives a hidden class a dispatch table of its own, so that a method added to + * it does not reach the table its superclass is using. + */ +static Class hiddenClassForMethodAdd(id object) +{ + Class hiddenClass = hiddenClassForObject(object); + LOCK_RUNTIME_FOR_SCOPE(); + if (objc_test_class_flag(hiddenClass, objc_class_flag_shared_dtable)) + { + objc_clear_class_flag(hiddenClass, objc_class_flag_shared_dtable); + hiddenClass->dtable = create_dtable_for_class(hiddenClass, uninstalled_dtable); + } + return hiddenClass; +} + BOOL object_addMethod_np(id object, SEL name, IMP imp, const char *types) { - return class_addMethod(hiddenClassForObject(object), name, imp, types); + return class_addMethod(hiddenClassForMethodAdd(object), name, imp, types); } IMP object_replaceMethod_np(id object, SEL name, IMP imp, const char *types) { - return class_replaceMethod(hiddenClassForObject(object), name, imp, types); + return class_replaceMethod(hiddenClassForMethodAdd(object), name, imp, types); } static char prototypeKey; diff --git a/class.h b/class.h index 090d7132..6934f06b 100644 --- a/class.h +++ b/class.h @@ -343,6 +343,11 @@ enum objc_class_flags * On a class, guarantees that `+init` is trivial. */ objc_class_flag_fast_alloc_init = (1<<15), + /** + * This class's dtable belongs to its superclass and must not be modified + * or freed through this class. + */ + objc_class_flag_shared_dtable = (1<<19), /** * The class is a block class. Reference count management must be done by * the underlying blocks runtime. diff --git a/dtable.h b/dtable.h index db70e96d..d78c1a72 100644 --- a/dtable.h +++ b/dtable.h @@ -119,6 +119,7 @@ static inline int classHasDtable(struct objc_class *cls) * Updates the dtable for a class and its subclasses. Must be called after * modifying a class's method list. */ +dtable_t create_dtable_for_class(Class, dtable_t); void objc_update_dtable_for_class(Class); /** * Updates the dtable for a class and its subclasses. Must be called after From 87f5b15bed6ca26ee665047f4b937302e59a763e Mon Sep 17 00:00:00 2001 From: Todd White Date: Thu, 13 Aug 2026 09:33:32 -0400 Subject: [PATCH 3/3] Shorten the association reference list The reference list is allocated with the hidden class, zeroed there and walked in full when the object is destroyed, so its length is paid by every object that carries an association. Ten slots was a guess, and the Microsoft fork of this runtime still carries the same ten. WinObjC's key value observing, which is the heavy user of associations, puts one association on an observed instance: NSKVOSupport.mm has a single instance key holding the observation info, and the selector map in NSKVOSwizzling.mm is associated with the class rather than the instance. Four slots costs 293 bytes per object holding one association against 440, and an object with more than four gains a second block rather than a larger first one. ns per objc_getAssociatedObject, reading the key added last, and resident bytes per object over 50000 objects: size 2 size 4 size 6 size 10 bytes 246 293 346 440 get of 4 2.90 2.90 2.68 2.68 get of 8 3.34 3.12 3.12 3.12 get of 16 5.29 4.29 4.05 3.88 Reading is unchanged up to eight associations. Suite passes, 202 of 202 at every size measured. --- associate.mm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/associate.mm b/associate.mm index 26807015..e72dc101 100644 --- a/associate.mm +++ b/associate.mm @@ -35,7 +35,7 @@ uintptr_t policy; }; -#define REFERENCE_LIST_SIZE 10 +#define REFERENCE_LIST_SIZE 4 /** * Linked list of references associated with an object. We assume that there @@ -45,8 +45,8 @@ struct reference_list { /** - * Next group of references. This is only ever used if we have more than - * 10 references associated with an object, which seems highly unlikely. + * Next group of references. This is only ever used if we have more + * references associated with an object than a single block holds. */ struct reference_list *next; /**