Blocks in Objective-C come in three distinct flavors — Global, Stack, and Heap — each with its own lifecycle rules and memory behavior that most developers never consciously think about. It wasn’t until I encountered a wild pointer crash under MRC that I realized Blocks have three different forms in memory: Global Blocks, Stack Blocks, and Heap Blocks. Their lifecycles are completely different, and understanding the distinction is the key to writing correct Block code.
The Underlying Identity of the Three Block Types
A Block is essentially an Objective-C object, and every Block instance has an isa pointer. Depending on which class the isa points to, Blocks are divided into three types:
_NSConcreteGlobalBlock: Global Block_NSConcreteStackBlock: Stack Block_NSConcreteMallocBlock: Heap Block
These three types correspond to different memory locations. Global Blocks live in the data segment, Stack Blocks live on the stack, and Heap Blocks live on the heap.
Global Block: Lives as Long as the App
Global Blocks have the longest lifecycle — from creation until the application terminates. They don’t require manual memory management and are never released.
When is a Block global? When the Block doesn’t capture any automatic variables — only using global or static variables — it becomes a Global Block.
// Global Block — captures no external variables
void (^globalBlock)(void) = ^{
NSLog(@"This is a global block");
};
// Global Block — only uses global or static variables
static int staticNum = 10;
void (^globalBlock2)(void) = ^{
NSLog(@"staticNum = %d", staticNum);
};
A Global Block’s isa points to _NSConcreteGlobalBlock. It’s unaffected by reference counting — operations like copy, retain, and release have no effect. It behaves like a function, existing for the entire program’s runtime.
Stack Block: Lifecycle Ends with the Function
Stack Blocks live on the stack, their lifecycle is controlled by the system and they’re destroyed immediately after the function returns.
When is a Block a Stack Block? When a Block captures automatic variables (local variables) and isn’t referenced by a strong pointer, it becomes a Stack Block.
// Stack Block — captures a local variable, no strong reference
- (void)testMethod {
int localNum = 10;
// Under MRC, this block is a Stack Block
void (^stackBlock)(void) = ^{
NSLog(@"localNum = %d", localNum);
};
// After the function returns, stackBlock becomes invalid
}
The biggest risk with Stack Blocks is: if you store a Stack Block for later use under MRC, by the time you access it after the function returns, the memory has already been reclaimed — causing a wild pointer crash.
// This crashes under MRC
- (NSArray *)blocks {
int i = 1;
return @[^{ return i; }]; // Stack Block placed in array, invalid after return
}
- (void)callBlock {
int (^block)(void) = [self blocks][0];
block(); // 💥 Crash: accessing freed stack memory
}
A crucial point about Stack Block memory management: calling retain on a Stack Block does NOT prevent it from being destroyed. Only copy can move it from the stack to the heap.
Heap Block: Manually Managed Lifecycle
Heap Blocks live on the heap, their lifecycle is managed by the developer (or ARC). Stack Blocks are copied to the heap when referenced by a strong pointer or by the copy modifier.
// Heap Block — under ARC, referenced by a strong pointer
int localNum = 10;
void (^mallocBlock)(void) = ^{
NSLog(@"localNum = %d", localNum);
};
// Under ARC, this block is on the heap
Using the Block_copy() function or [block copy] method copies a Stack Block to the heap. Heap Blocks require reference counting management — they’re destroyed when no strong pointers reference them. Under MRC, you need to manually call Block_release().
Behavior Under ARC
Under ARC, you rarely encounter Stack Blocks directly. ARC automatically copies Blocks from the stack to the heap in several situations:
- When a Block is assigned to a strong pointer (
__strongorid) - When a Block is returned from a function
- When
copyis manually called on a Block - When a Block is passed to system APIs with
usingBlockparameters (like GCD, array enumeration, etc.)
That’s why under ARC, you’ll typically only see Global Blocks and Heap Blocks — Stack Blocks are rarely visible. However, in certain scenarios — like passing Blocks as function parameters — you still need to be aware of Block lifecycle.
Differences in Copy Behavior
The three Block types respond differently to copy, retain, and release:
- Global Block:
retain,copy, andreleaseall have no effect — it’s in static data segment. - Stack Block:
retainandreleasehave no effect. Onlycopycan move it to the heap, turning it into a Heap Block. - Heap Block: Supports
retainandrelease.copyonly increments the reference count, it doesn’t create a new object.
Under MRC, calling retain on a Stack Block doesn’t prevent its destruction — only copy moves it to the heap and lets it survive beyond the function return.
Summary
The core difference between the three Block types lies in their storage location and lifecycle:
- Global Block: In the data segment, lifecycle tied to the app. Captures no local variables.
retain,copy,releasehave no effect. - Stack Block: On the stack, destroyed when the function returns.
retainhas no effect — onlycopycan save it. - Heap Block: On the heap, lifecycle managed by reference counting. The type most commonly encountered under ARC. Supports normal
retain/release.
Understanding these three types helps you avoid wild pointer crashes and memory leaks, and write correct Block code. Especially under MRC, remember: Stack Blocks must be copied to survive.
The most common real-world pitfall I’ve encountered is Blocks that appear to work under ARC but behave unexpectedly when passed across module boundaries. I once had a Block that worked perfectly when called directly, but crashed when passed to a C function in a third-party library. The issue was that the C function didn’t trigger ARC’s automatic heap copy — it received a Stack Block pointer that became invalid after the calling function returned. The fix was wrapping the Block in [^{
// ...
} copy] before passing it to the C function.
Another subtle issue: when debugging Block lifecycle problems, the most reliable technique is adding NSLog(@"%p", block) before and after the function returns. If the address changes or becomes 0x0, the Block was on the stack and got destroyed. This simple check has saved me hours of “mystery crash” debugging sessions where the crash log alone wasn’t enough to identify the root cause.
A Real Project Scenario: The Callback Outlived Its Stack Frame
I reproduced this failure while maintaining an older image-processing component that still used manual reference counting. The component accepted a completion Block, waited for background work to finish, and then returned a thumbnail name to a product-detail screen. Direct calls always worked, which made the eventual EXC_BAD_ACCESS look random.
The failure only appeared after I added a short asynchronous delay. By that point, the method that created the capturing Block had already returned. The callback had been stored like an ordinary pointer, so it still pointed at a Stack Block whose stack frame no longer existed. Logging the runtime class before and after copy made the problem obvious: the capturing Block changed from __NSStackBlock__ to __NSMallocBlock__.
The Ownership Fix Used in the Demo
The durable fix has two parts. The object uses a copy property, and the asynchronous operation takes its own copied reference until the callback has finished. Under MRC, that ownership must be balanced explicitly.
@property (nonatomic, copy) ImageCompletion completion;
- (void)startWithImageName:(NSString *)imageName {
ImageCompletion callback = [_completion copy];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
150 * NSEC_PER_MSEC),
dispatch_get_main_queue(), ^{
NSString *output =
[imageName stringByAppendingString:@"-thumbnail.jpg"];
if (callback) callback(output);
[callback release];
});
}
- (void)dealloc {
[_completion release];
[super dealloc];
}
This is deliberately an MRC example. In a modern ARC target, the compiler handles much of this ownership automatically, but understanding the transition still matters when working with legacy modules, C APIs, or callbacks crossing module boundaries.
What the Runnable Project Demonstrates
- A non-capturing Global Block and its runtime class.
- A capturing Stack Block before it is copied.
- The same capturing Block after it moves to heap-managed storage.
- A delayed image callback that completes safely after its original method returns.
The program prints each Block’s runtime class and finishes with Completed without a dangling Block. That makes the lifecycle difference observable instead of leaving it as a diagram or abstract rule.
Download and Run the Complete Code
The complete, tested project is available in the dedicated GitHub repository: Objective-C Block Lifecycle Demo on GitHub.
git clone https://github.com/2252408699/objc-block-lifecycle-demo.git
cd objc-block-lifecycle-demo
make run
Requirements: macOS plus Xcode or the Xcode Command Line Tools. The included Makefile compiles the source with ARC disabled so the Stack-to-Heap transition and the matching releases remain visible.
