Embercode
✕
Pointers and Memory AddressesLesson 9 of 16

45 min read

Shared ownership and weak references

Why this matters

The earlier smart pointers lesson gave every heap object one owner, using unique_ptr. That works when one part of the program clearly controls an object's life. Many programs do not work that way. A picture in an app may be shown in a list, in a preview and in an editor at the same time. Each of those three places needs the picture to stay alive. None of them should be the one that deletes it, because that would break the other two.

C++ solves this with shared_ptr. Many owners can share one heap object, and the object is deleted when the last owner lets go. The rule is simple, but it has one trap. If two objects own each other, nobody is ever the last owner, so neither is ever deleted. This is a memory leak, and it is easy to write by accident.

A second tool, weak_ptr, lets code look at an object without owning it. Java has the same idea under the name WeakReference, and it is the basis of most caches that do not grow until the computer runs out of memory. This lesson covers both languages. The C++ part comes first and is longer. The Java part shows how a garbage collector, which is the part of Java that frees unused objects, handles the same question.

Three terms are used throughout:

  • A reference count is a number that records how many owners an object has. Copying an owner adds one. Letting go of an owner subtracts one.
  • A cycle is a chain of owners that leads back to where it started, such as A owns B and B owns A.
  • A garbage collector is the part of the Java runtime that finds objects no program code can reach any more, and frees them.

The problem: one object, several users

Imagine two parts of a program that both need the same picture. The editor needs it while the user is working. The preview needs it while the preview is open. Neither part knows which will finish first.

With a plain pointer, the first part to finish would have to delete the picture, and the second part would then read freed memory. With unique_ptr, the program cannot even copy the owner, because the rule is that only one unique_ptr may own an object. The compiler is protecting you from the situation, but it gives you no way to share.

The answer is a counter. Every owner adds one to the count when it starts owning the object, and subtracts one when it stops. The last owner to stop sees the count drop to zero, and that owner deletes the object. No single part has to know about the others.

How shared_ptr counts owners

A shared_ptr holds a pointer to a heap object, and a pointer to a small block of memory, called the control block, that holds the counter. The counter records how many shared_ptr objects point at the same heap object.

Three things change the count:

  • Creating a shared_ptr from make_shared sets the count to 1.
  • Copying a shared_ptr, such as assigning it to another variable, adds 1.
  • A shared_ptr that goes out of scope, or is reset, subtracts 1.

When the count reaches 0, the object is deleted. The member function use_count() returns the current count, which makes the rule easy to see. Use make_shared to create the object. It is the safe way to do it, and common implementations store the object and its counter in one block of memory.

Worked examples 1 to 3: counting, a cycle, and a fix

The program below has three parts. Each part has a comment that names it. Read the output after the program, and check each line against the code.

C++ · editable

Output:

-- example 1: counting owners --
create gear
owners: 1
owners: 2
owners after block: 1
destroy gear
after reset, empty? 1
-- example 2: a cycle --
x owners: 2, y owners: 2
end of cycle block (nodes leaked)
-- example 3: weak_ptr breaks the cycle --
P owners: 1
child sees parent P
destroy parent P
destroy child C
end of weak block

The first part shows the count moving. The object is created with one owner. Inside the inner block, b is a second owner, so the count is 2. When the block ends, b goes away and the count drops back to 1. The object is still alive because a still owns it. Only when a.reset() runs does the count reach 0, and the destructor prints destroy gear. The word reset means "let go of the object now".

The second part shows the trap. x owns y, and y owns x. When the block ends, the variables x and y go away, so each object loses one owner. But each object still has one owner left, the other object. Both counts stay at 1 after the block, and neither count reaches 0. The destructors never run, so the words destroy node never appear. The memory is lost. No code can reach these two objects any more, and nothing will free them until the program ends.

The third part fixes the cycle. The parent owns the child with a shared_ptr, which is the normal direction of ownership. The child points back at the parent with a weak_ptr, which does not add to the count. When the block ends, the parent's count drops from 1 to 0, the parent is destroyed, and the parent's destructor releases its shared_ptr to the child. The child's count then drops to 0, and the child is destroyed too. The output shows the parent going first and the child second, which is the order of ownership.

The rule for picking the weak side

Most cycles have a link that can be changed to weak_ptr without breaking the program. Choose the link that points back toward the owner, not the link that points down. In the tree example, the child points back to the parent, so the child's link is the weak one. A common case is a tree where each node knows its parent. The parent-to-child link is shared_ptr, and the child-to-parent link is weak_ptr.

If you are not sure which way to go, ask this question: which object keeps the other one alive? That object holds a shared_ptr to the other one. The other object holds a weak_ptr back.

weak_ptr: a look without ownership

A weak_ptr points at a heap object without adding to its count. It is a note that says where the object is, and it does not stop the object from being deleted. Because the object might be gone, you cannot use a weak_ptr directly. You first call lock(), which returns a shared_ptr.

  • If the object is still alive, lock() returns a shared_ptr to it. That shared_ptr counts as an owner while it exists, so the object stays alive for the time you use it.
  • If the object has been deleted, lock() returns an empty shared_ptr. An empty shared_ptr compares equal to nullptr.

The member function expired() returns true when the object is gone. It is useful for a quick check, but its answer can be out of date by the time you act on it, if another thread deletes the object in between. Calling lock() once and testing the result is safer.

In the third worked example, the if statement calls lock() and checks the result in the same line. The object cannot disappear between the check and the use, because the returned shared_ptr keeps it alive inside the if block.

Worked example 4: a cache that does not keep objects alive

A cache stores results that are expensive to make, so the program can reuse them. A simple cache that uses shared_ptr has a problem. The cache itself is an owner, so the cached objects stay alive forever, even when nothing else uses them. A cache that uses weak_ptr avoids that. It remembers where each object is, but it does not keep the object alive. When every other user lets go, the object is deleted, and the next request loads it again.

The program below loads a "picture" by name. It returns the existing picture if one is still alive, and loads a new one if not.

C++ · editable

Output:

load logo
same object: 1, owners: 2
load logo
got a picture after release: 1

The first call to get finds nothing in the map, so it loads the picture and stores a weak_ptr to it. The second call finds the entry, calls lock(), and gets a shared_ptr to the same object. Both a and b point at it, so the count is 2, and load logo is printed only once. After both reset calls, the count is 0, and the picture is deleted. The third call finds an expired weak_ptr, so lock() returns an empty pointer, and the picture is loaded again. That is the second load logo line.

This cache has a weakness that a real version would fix. The map keeps one entry for every name it has seen, even after the picture is gone. The entries are small, but the map grows without limit if the program asks for many different names. A production cache would also remove the expired entries, for example when it runs out of room. The lesson here is the pattern: store weak_ptr, and call lock() before each use.

Java: no counts, a garbage collector instead

Java does not have shared_ptr, delete or reference counts. The program creates objects with new and never frees them by hand. The garbage collector runs in the background and frees an object when no chain of ordinary variables leads to it. These ordinary variables are called strong references. Every variable, field or list element that holds an object is a strong reference, unless the program says otherwise.

This makes cycles safe in Java. Two objects that point at each other are both freed when no other path reaches them, because the collector looks for paths from the program's live variables, not for counts that drop to zero. The C++ cycle from worked example 2 would not leak in Java. The price is that you cannot choose the exact moment an object is freed.

A Java WeakReference is a wrapper around an object that does not count as a strong reference. You create one with new WeakReference<>(object), and you read the object with get(). As long as a strong reference exists, get() returns the object. Once the collector has freed the object, get() returns null.

Java example: a weak reference and a forced collection

The program below creates a large object, holds it through a weak reference, drops its only strong reference, and asks the JVM to collect garbage. The object takes about 20 MB, so the effect is visible in the memory numbers.

import java.lang.ref.WeakReference;
 
public class WeakDemo {
    static class Picture {
        final String name;
        final byte[] data = new byte[20_000_000];
 
        Picture(String name) {
            this.name = name;
        }
    }
 
    public static void main(String[] args) {
        Picture strong = new Picture("logo");
        WeakReference<Picture> weak = new WeakReference<>(strong);
        System.out.println("before: " + weak.get().name);
 
        strong = null;
        System.gc();
 
        Picture after = weak.get();
        System.out.println("after gc: " + (after == null ? "cleared" : after.name));
    }
}

Run it from the folder that holds the compiled class. The -Xlog:gc:stderr option prints a line each time the collector runs, so you can see that the collection happened.

javac WeakDemo.java
java -Xlog:gc:stderr WeakDemo

Output from one run on Java 25:

[0.003s][info][gc] Using G1
before: logo
[0.043s][info][gc] GC(0) Pause Full (System.gc()) 23M->1M(28M) 5.429ms
after gc: cleared

The Pause Full line shows the heap going from 23 MB in use to 1 MB, so the picture was freed. The program printed cleared because weak.get() returned null. The number and timing in your run may differ, and the Java language does not promise that System.gc() will clear a weak reference at any particular moment. It is a request. The JVM usually honours it, and this run is typical, but a program must never depend on the exact timing.

Why caches use WeakReference

A Java cache often maps a key, such as a file name, to an object that is costly to build. A cache that holds strong references keeps every object forever, so memory grows until the program fails. A cache that holds weak references has a better rule. An object stays in memory as long as some other part of the program still uses it. When memory runs short, the collector may free the objects that only the cache remembers, and the cache will rebuild them later.

The next program is the Java version of the picture cache. It loads each picture once while a strong reference exists, and loads it again after the collector has cleared it.

import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;
 
public class WeakCache {
    static class Picture {
        final String name;
        final byte[] data = new byte[5_000_000];
 
        Picture(String name) {
            this.name = name;
        }
    }
 
    static final Map<String, WeakReference<Picture>> cache = new HashMap<>();
    static int loads = 0;
 
    static Picture load(String name) {
        WeakReference<Picture> ref = cache.get(name);
        Picture p = (ref == null) ? null : ref.get();
        if (p != null) {
            return p;
        }
        loads++;
        p = new Picture(name);
        cache.put(name, new WeakReference<>(p));
        return p;
    }
 
    public static void main(String[] args) {
        Picture a = load("logo");
        Picture b = load("logo");
        System.out.println("same object: " + (a == b) + ", loads: " + loads);
 
        a = null;
        b = null;
        System.gc();
 
        Picture c = load("logo");
        System.out.println("loads after gc: " + loads + ", got a picture: " + (c != null));
    }
}

Output:

same object: true, loads: 1
loads after gc: 2, got a picture: true

The first two calls return the same object, so the picture was loaded only once. After a and b are cleared and the collector runs, the weak reference returns null, and the third call loads the picture again. The count loads goes from 1 to 2.

There are two cautions for a Java cache. First, a HashMap keeps its keys forever, so the entries with cleared values stay in the map until you remove them. Java's WeakHashMap solves part of this by removing an entry when its key is freed, but it only helps when the key is the object that goes away. Second, a weak reference is not a fix for data that exists nowhere else. If the only copy of something is in a weak cache, the collector can clear it at any time, and the program must be able to rebuild it.

Common mistakes

Mistake 1: two shared_ptr objects that point at each other

This is the cycle from worked example 2. The symptom is a destructor that never runs, or memory that keeps growing in a loop that builds linked structures. The fix is to make one of the links a weak_ptr, using the rule from earlier in this lesson. A tool that finds memory never freed will report this kind of leak. The next lesson shows how to use one.

Mistake 2: keeping the raw pointer from lock()

The lock() call returns a shared_ptr, and that shared_ptr is a temporary. A temporary is destroyed at the end of the statement that created it. If you take the raw address with get() and keep it, you keep an address without an owner. The program below does exactly that. The owner is reset before the raw pointer is used.

C++ · editable

Compile it with the address sanitizer, which is a debugging option that checks each memory access. The -g option adds line numbers to the report.

g++ -std=c++20 -Wall -g -fsanitize=address -o mistake mistake.cpp
./mistake

The sanitizer stops the program and prints an error. The first lines of the output are:

=================================================================
==754814==ERROR: AddressSanitizer: heap-use-after-free on address 0x6fae271e0010 at pc 0x5f1287ee05c4 bp 0x7ffd605561d0 sp 0x7ffd605561c0
READ of size 4 at 0x6fae271e0010 thread T0
    #0 0x5f1287ee05c3 in main /home/chiranjeet/dev/anisha/scratch-offtmp/claude-ptr/mistake.cpp:12

The words heap-use-after-free mean the program read memory after it was freed. The line mistake.cpp:12 points at *raw. The addresses differ from run to run. The fix is to keep the shared_ptr alive for as long as you use the object, for example by storing the result of lock() in a named variable, not by copying get() into a raw pointer.

Mistake 3: a cache that holds shared_ptr forever

A cache that stores shared_ptr values keeps every object alive, because the cache is one of the owners. The program does not crash. It just uses more and more memory. The cause is the same as a leak: an owner that never lets go. Store weak_ptr in a cache of objects that can be rebuilt, and remove the entries that have expired.

Mistake 4: relying on the exact time of garbage collection

In Java, a weak reference may still return the object after System.gc(). The JVM is free to skip a collection, and a program that works only because a collection happened to run is fragile. Write the code so that get() returning null is a normal case, and rebuild the object when it happens.

Practice

Exercise 1: predict the count

Write the count after each line of this code, and say which lines are copies.

auto a = make_shared<int>(5);
auto b = a;
auto c = b;
b.reset();

Show the answer

After a is created, the count is 1. auto b = a; copies, so the count is 2. auto c = b; copies again, so the count is 3. b.reset() lets go of one owner, so the count is 2. The two owners left are a and c. The copies are the two assignments, b = a and c = b.

Exercise 2: find the cycle

Two structs hold shared pointers to each other, as shown below. Will the destructor of either struct ever run when the variables go out of scope? Explain why.

struct Person {
    shared_ptr<Person> friend_of;
    ~Person() { cout << "bye\n"; }
};
 
{
    auto p = make_shared<Person>();
    auto q = make_shared<Person>();
    p->friend_of = q;
    q->friend_of = p;
}

Show the answer

No, neither destructor runs. When the block ends, p and q each lose one owner, but q still owns the object that p points to, and p still owns the object that q points to. Each count stays at 1, so neither count reaches 0. The fix is to make one of the two friend_of members a weak_ptr.

Exercise 3: change the link that is weak

A tree node has a shared_ptr to each child and a link back to its parent. Which of the two links should be a weak_ptr, and why?

Show the answer

The link from the child back to the parent should be the weak_ptr. The parent owns its children, so the parent-to-child link is the shared_ptr. If the parent were deleted, its children would be deleted with it. The child-to-parent link is a look, not an owner, so it is a weak_ptr. Using a shared_ptr there would make a cycle.

Exercise 4: a weak check in a function

Write a function void show(const weak_ptr<Picture>& w) that prints the picture's name if it still exists, and prints gone if it does not. Use lock() once.

Show the answer

void show(const weak_ptr<Picture>& w) {
    if (auto p = w.lock()) {
        cout << p->name << "\n";
    } else {
        cout << "gone\n";
    }
}

The if statement calls lock() once and tests the result. The object cannot be freed between the test and the use, because p keeps it alive inside the block.

Exercise 5: predict the Java output

In WeakCache, what does the second line print if the line a = null; is removed, so a still holds the picture when System.gc() runs?

Show the answer

The second line prints loads after gc: 1, got a picture: true. The picture is still reachable through the strong reference in a, so the collector cannot clear the weak reference. The cache returns the same object, and no new load happens.

Summary

  • A shared_ptr keeps a count of its owners. Copying adds one, and letting go subtracts one. The object is deleted when the count reaches 0.
  • Two objects that own each other keep each other's count above 0, so neither is ever deleted. This is a leak.
  • A weak_ptr points at an object without owning it. Call lock() to get a shared_ptr before you use the object, and check that the result is not empty.
  • Put the strong link on the side that owns the other object, and the weak link on the side that only looks at it.
  • In Java, the garbage collector frees objects that no strong reference reaches, so cycles are safe. A WeakReference lets a cache hold an object without keeping it alive, and the program must handle a null result from get().
Practice this topic: coding problems for Pointers and Memory Addresses →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Drawing pointer diagrams