Skip to content
Courses

The Heap and Allocation Lifetimes

The HeapLesson 1 of 625 minutesLessonNo lab next

Say a helper function creates some data and hands it back to the caller. The helper is finished, but the caller isn’t done with that data. Where can it live?

An ordinary local variable won’t work. In the stack frames we’ve been examining, returning from a function makes its local space available for later calls. Returning the address of a local array doesn’t somehow keep the array alive, so the caller would be left with an address it cannot safely use.

We need memory whose lifetime we can control separately from the function call. That is what dynamic allocation gives us. The heap is the pool of memory used for these allocations: a program asks for space while running, uses it for as long as the data is needed, and then releases it.

The program can also decide how much space to ask for based on what it encounters, such as the length of a file or the number of records it has to hold. The request can still fail, because dynamic memory is flexible rather than unlimited.

The name makes the heap sound like one tidy block of memory. On Linux, glibc can place dynamic allocations in the mapping labeled [heap] or in other mappings, so they do not have to occupy one continuous region. They still have ordinary virtual addresses, and the CPU reads and writes them through pointers just as it does anywhere else. The Linux allocation manual describes where glibc gets that storage.

We’ll use the same small C programs and Ubuntu VM as the earlier sections. If you want a longer refresher on storage categories, see The Heap and Data Lifetime.

Somebody has to keep track of which memory is available, how much was handed out, and what has since been returned. That job belongs to the allocator. In our Ubuntu C programs, the allocator comes from glibc, the GNU C library.

We only need two allocator functions for now:

  • malloc(bytes) requests a block of the specified size and returns a pointer to its start. It does not initialize those bytes. For the nonzero requests on this page, NULL means the allocation failed, so check before using the pointer.
  • free(pointer) releases a live allocation obtained from malloc or a related function. After free returns, the program must stop accessing that allocation.

These are library calls. The allocator can answer a request from memory it already manages and ask the operating system for more when it runs short. It does not need a new system call for every little object the program creates.

The program chooses when to release each allocation. If it allocates A and then B, it can release A while B is still in use. Heap allocations do not have to end in the nested order that stack frames do.

Let’s make the helper return one allocated integer. Allocating one integer is overkill in a real program, but it leaves nowhere for the lifetime change to hide.

Create the working directory in the Ubuntu terminal:

Terminal window
mkdir -p ~/underdunn-course/heap ~/underdunn-course/notes
cd ~/underdunn-course/heap

Save this complete program as ticket.c:

#include <stdio.h>
#include <stdlib.h>
static int *make_ticket(int number)
{
int *ticket = malloc(sizeof *ticket);
if (ticket == NULL) {
return NULL;
}
*ticket = number;
printf("helper: %p holds %d\n", (void *)ticket, *ticket);
return ticket;
}
int main(void)
{
int *saved = make_ticket(42);
if (saved == NULL) {
fputs("allocation failed\n", stderr);
return 1;
}
printf("caller: %p holds %d\n", (void *)saved, *saved);
free(saved);
saved = NULL;
return 0;
}

sizeof *ticket requests enough bytes for the integer that ticket will point to. It does not read that integer. After the allocation succeeds, *ticket = number initializes the new storage, and return ticket passes its address back to main.

Compile and run:

Terminal window
gcc -std=c17 -Wall -Wextra -O0 -g ticket.c -o ticket && ./ticket

Both lines should print the same address and the value 42. Your address will differ, but the output will have this shape:

helper: 0x5555555592a0 holds 42
caller: 0x5555555592a0 holds 42

The matching addresses are the useful evidence. By the time the caller prints that second line, make_ticket has returned. Its local pointer is gone, but the allocation is still live. The return did not copy the integer into some new allocation; main received a copy of the existing allocation’s address.

There are two different things to keep track of here:

  • The pointer variable stores an address. ticket belongs to the helper’s call, and saved belongs to main. In the unoptimized stack model we’ve been using, these local variables have space in their respective stack frames.
  • The allocated integer lives in the storage requested by malloc. Its lifetime continues across the helper’s return and ends at free(saved).

Calling something a “heap pointer” only describes where the pointer leads. The pointer variable itself can be local, stored in a global variable, or part of another allocation.

Follow the successful path through the program:

Point in the program Local pointer Allocated integer
After initialization in the helper ticket points to it Live, holds 42
After the helper returns saved points to it; ticket no longer exists Still live, holds 42
After free(saved) saved still exists; its old value is unusable Lifetime ended
After saved = NULL saved holds a null pointer Already released

The assignment to NULL gives saved a known value after cleanup. It doesn’t free anything by itself. If we’d assigned NULL before releasing the allocation and had no other pointer to it, we’d have lost our way to free that storage: a memory leak.

One more catch: free does not find and clear every pointer the program copied. Every pointer to the released object must stop being used to access it. That is why we keep track of ownership. Here, main owns the allocation after the helper returns, so main is responsible for releasing it.

free does not promise to erase the bytes or immediately return the underlying memory to the operating system. The allocator may keep that storage for a later request. The release contract still ends our use of the allocation.

Change main so it keeps two allocations at once. Keep the helper unchanged:

  1. Create the first ticket with value 42, then a second with value 99. Check each call for failure. If the second fails, release the first before returning.
  2. After both calls succeed, free the first ticket and set its pointer to NULL.
  3. Print the second ticket’s value, then free it and set its pointer to NULL.

Compile again with the same command. The final print should show 99. Trace where each allocation begins and ends. Explain why returning from either helper call doesn’t release its ticket, and why freeing the first doesn’t release the second.

Check your lifetime trace

Each successful helper call creates a separate allocation. Both remain live after their helpers return. Freeing the first ends only that allocation’s lifetime, so the second still holds 99 and can be read before its own cleanup.

A successful version has two malloc calls and two matching free calls. If the second pointer is only a copy of the first, there is still just one allocation. Go back and make a separate make_ticket(99) call.

Knowledge check

Question 1 of 2

Why can main read the ticket after make_ticket returns?

If compilation fails, fix the reported line before running. The && in the command prevents an old executable from running after a failed build. To recover the baseline, restore the complete listing above and compile again.

Addresses will change across runs, so compare the helper and caller within one run. If the program prints allocation failed, it exits without trying to use the failed allocation. Restore the one-integer request and check that the VM has available memory before trying again.

We now know what the program asks for and when it gives that storage back. The allocator still needs somewhere to check the sizes and availability of the blocks it manages. In Anatomy of a Heap Chunk, we’ll look at how glibc represents those blocks and where it hides the bookkeeping.