Memory Management & Garbage Collection Questions
Memory as a performance resource in garbage-collected and managed runtimes (JVM, .NET, Go, V8, CPython). Covers allocation patterns and allocation pressure (boxing, value versus reference types, stack versus heap), GC algorithms and behaviour (generational, mark-and-sweep, reference counting, concurrent and compacting collectors, GC roots), pauses and tuning, heap sizing, fragmentation, object pooling and buffer reuse, data-oriented layouts and per-object overhead, on-heap versus off-heap trade-offs, and detecting and fixing memory leaks with heap dumps and profilers, including leaks from caches, closures, goroutines, asyncio tasks and native extensions. Emphasizes how memory pressure affects throughput, latency and stability, including low-latency and frame-rate-bound applications. Pointer, ownership and manual-allocation semantics, embedded memory budgets, virtual memory and paging, generic data-processing memory tricks, and GPU framework memory are covered elsewhere.
Can a memory leak happen in a garbage-collected language? Explain how, and how you would tell a real leak from normal heap growth.
Sample Answer
Short answer. Yes. A garbage collector frees objects the program can no longer reach; it cannot free objects the program still references but will never use again. A leak in a managed language is therefore unintended reachability: something with a long life (a static field, a global cache, a listener list, a thread) keeps a reference to something that should have been dropped.
How it happens
- Unbounded cache or map. Entries are added per request and never evicted.
- Registrations never removed. A long-lived publisher holds a listener, which holds a whole screen or request context (on mobile, a leaked screen object can pin its entire view tree: see the mobile section below).
- Pooled threads with thread-local data. The thread is a root and lives as long as the pool.
- Native resources. Memory behind a handle (direct buffers, which are Java byte buffers whose memory lives outside the garbage-collected heap, plus files and sockets) the GC does not see or only frees late.
CPython (the standard Python interpreter) frees mostly by reference counting: each object keeps a count of the references to it and is freed the moment the count reaches zero. The same rule applies: a reference held from a module-level object keeps the object alive, and reference cycles (objects that point at each other, so neither count reaches zero) wait for the cyclic collector, a separate periodic pass that finds and frees such groups.
Real leak versus normal growth
Normal growth: heap used climbs between collections and falls when one runs. Collectors take their time, so a process can look large and be healthy. The signal is the low point after collection:
- Real leak: the low points keep rising, with the same load, until collections thrash.
- Normal growth: low points settle on a plateau (a warm cache, a loaded dataset).
Then confirm by comparing two snapshots and reading which allocation site or class grows.
A Python demonstration of the compare-two-snapshots method with tracemalloc (standard library):
import tracemalloc
_cache = {} # module-level: reachable for the life of the process
def handle_request(i):
_cache[i] = bytearray(10_000) # added per request, never evicted
tracemalloc.start()
for i in range(1_000):
handle_request(i)
snap1 = tracemalloc.take_snapshot()
for i in range(1_000, 3_000):
handle_request(i)
snap2 = tracemalloc.take_snapshot()
top = snap2.compare_to(snap1, "lineno")[0]
print("biggest growth between snapshots:")
print(" ", top.traceback[0].filename.split("/")[-1], "line", top.traceback[0].lineno,
"size_diff_MB=%.1f" % (top.size_diff / 1e6))
Run with python -I leak_vs_growth.py on python:3.12-slim (Python 3.12.15). Output:
biggest growth between snapshots:
leak_vs_growth.py line 7 size_diff_MB=20.2
Two thousand more requests added 20.2 MB at line 7 of the listing (counting import tracemalloc as line 1), the _cache[i] = ... store. That line is the leak because nothing removes entries; the same code with an eviction limit would show a size_diff that stops growing. The size matches the arithmetic: 2,000 requests x 10,000 bytes is 20.0 MB, plus a little container overhead.
On the JVM the equivalent tools are jcmd <pid> GC.class_histogram and heap dumps (with -XX:+HeapDumpOnOutOfMemoryError), per the JDK troubleshooting guide.
The C++ contrast
C and C++ leak differently: an allocation with no matching free. new int[1000] with no delete[] loses the only pointer, and nothing ever reclaims it, even though the program can no longer reach it. A garbage collector would reclaim that case; the managed-language leak is the opposite case, where a pointer is still held.
#include <cstdio>
int main() {
int* buf = new int[1000]; // never deleted
buf[0] = 1;
std::printf("%d\n", buf[0]);
return 0;
}
Built with g++ -g -fsanitize=address in gcc:14 and run, AddressSanitizer's leak checker (LeakSanitizer) reports at exit:
==15==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4000 byte(s) in 1 object(s) allocated from:
#0 0xffffb0c94b9c in operator new[](unsigned long) (/usr/local/lib64/libasan.so.8+0xd4b9c)
#1 0x400910 in main /w/cpp_missing_delete.cpp:4
(frames #2 to #4, in libc and the program entry point, omitted)
SUMMARY: AddressSanitizer: 4000 byte(s) leaked in 1 allocation(s).
The addresses and process id differ between runs and machines; the 4000 bytes (1000 ints of 4 bytes) and the file and line do not.
Valgrind's memcheck (a tool that runs the program on a simulated CPU and reports leaks) serves the same purpose. These are optional tools you can use instead of or alongside the compiler flag; both find unreachable-but-allocated memory, which is a different test from the heap-snapshot method for reachable growth. In modern C++ the usual fix is to stop calling new and delete by hand and use smart pointers (objects such as std::unique_ptr that call delete automatically when they go out of scope) and containers.
The same leak on a phone
Mobile apps follow the same rule: a long-lived object holds a reference to a short-lived screen.
- Android (Kotlin or Java). A destroyed
Activity(one screen) stays in memory if a static field, a singleton, a registered listener or a running background task still references it, and with it its entire view tree and bitmaps. LeakCanary, a library added as adebugImplementationdependency so it only exists in debug builds, watches destroyed Activities and Fragments and reports the reference chain that keeps one alive. Android Studio's Memory Profiler can capture a heap dump and its class filter "Show activity/fragment leaks" lists instances that should be gone. - iOS (Swift). Swift uses automatic reference counting, the same count-the-references idea as CPython. Two objects that hold strong references to each other, or a closure stored on an object that captures
selfstrongly, form a retain cycle and are never freed; the usual fix is a weak capture such as[weak self]. Xcode's Memory Graph Debugger shows live objects and the references between them and flags suspected leaks, so you can look for leaked instances in the object list and follow their reference arrows. - Without a production heap dump, reproduce the leak in a debug build: open and close the suspect screen about ten times, force a collection or take a heap snapshot, and count how many instances of that screen class are still alive. Zero or one is healthy; one per visit is the leak, and the reference chain from the tool names the holder.
Takeaway
GC removes the unreachable-leak class and keeps the reachable-leak class. Decide with post-collection heap over time, then name the retaining reference with a snapshot diff or heap dump.
An object you expected to be collected is still alive. Explain what GC roots are, and describe how long-lived caches, static or module-level variables, and captured closures can keep objects reachable. How would you confirm and fix it?
Sample Answer
Short answer. GC roots are the references the running program holds directly, the starting points of every reachability trace: local variables and parameters on each live thread's stack, static fields, references from native code, and the threads themselves. An object stays alive if any chain of references leads from a root to it. Caches, statics and captured closures keep objects alive because each one hangs the object off a long-lived root.
How each one pins objects
- Long-lived cache. A
static Mapor a singleton cache is a root. Everything put in it stays until someone removes it. Without an eviction rule it only grows. - Static or module-level variable. Reachable for the life of the class loader (the JVM component that loads classes; a class and its static fields live as long as its loader, usually the whole process) or process. A static list of "recent requests" holds every request object, and everything those objects reference, forever.
- Captured closure. A lambda or inner function keeps a reference to everything it captures, including the enclosing object (
this). Hand a lambda to a long-lived registry and you hand over the whole enclosing object, even if the lambda body uses one field. The thread-local case is similar: a pooled thread is itself a root, so values in its thread-local map stay until removed.
Demonstration
A weak reference is a reference that does not keep its target alive: if only weak references point to an object, the collector may reclaim it, and the weak reference then reads as empty (get() returns null). That makes it a probe for "is this object still held by something else?". A screen object owns a 4 MB buffer and registers a listener lambda in a static list:
import java.lang.ref.WeakReference;
import java.util.*;
import java.util.function.Consumer;
public class ListenerRootsDemo {
static final List<Consumer<String>> LISTENERS = new ArrayList<>(); // static: a GC root
static class Screen {
final byte[] bigBuffer = new byte[4 * 1024 * 1024];
void register() {
LISTENERS.add(msg -> use(bigBuffer, msg)); // lambda captures this (the Screen)
}
static void use(byte[] b, String m) { }
}
static boolean collected(WeakReference<Screen> ref) {
for (int i = 0; i < 5 && ref.get() != null; i++) { System.gc(); }
return ref.get() == null;
}
public static void main(String[] args) {
Screen a = new Screen();
a.register();
WeakReference<Screen> ref = new WeakReference<>(a);
a = null; // no local variable points at it any more
System.out.println("after dropping local, collected? " + collected(ref));
LISTENERS.clear(); // remove the root path
System.out.println("after removing listener, collected? " + collected(ref));
}
}
Run with java -Xmx256m ListenerRootsDemo.java on eclipse-temurin:21-jdk (OpenJDK 21.0.12):
after dropping local, collected? false
after removing listener, collected? true
Setting the local variable to null did nothing: the static LISTENERS list still reached the lambda, and the lambda reached the screen and its buffer. Removing the listener cut the last root path, and the weak reference then cleared. (System.gc() is only a request that HotSpot honours by default; the demo uses it to make the outcome visible, and a service should not depend on it.)
The module-level case works the same way in Python. A list at module scope is a root for the life of the process, and CPython (the standard interpreter) frees an object when nothing references it:
import gc
import weakref
RECENT = [] # module-level list: lives as long as the process
class Session:
def __init__(self):
self.data = bytearray(1_000_000)
def handle():
s = Session()
RECENT.append(s) # "remember the last requests" and never trim
return weakref.ref(s) # a weak reference does not keep s alive
ref = handle()
gc.collect()
print("after the function returned, still alive?", ref() is not None)
RECENT.clear()
gc.collect()
print("after clearing the list, still alive?", ref() is not None)
Run with python -I module_pin.py on python:3.12-slim (Python 3.12.15):
after the function returned, still alive? True
after clearing the list, still alive? False
handle() returned and its local s is gone, yet the Session survives because RECENT still lists it; weakref.ref is Python's weak reference, so ref() returns the object while it lives and None once it is freed.
Confirm it
- Name the suspect class with a class histogram (
jcmd <pid> GC.class_histogram) or from a heap dump, filtered to the object you expected to be gone. - Get the reference chain. Open the heap dump in an analyzer such as Eclipse MAT and run "path to GC roots" on one suspect instance, excluding weak and soft references from the path (those references do not keep an object alive, so the paths that remain are the ones that actually pin it). JFR can also capture the chain: the JDK troubleshooting guide uses
jcmd <pid> JFR.dump ... path-to-gc-roots=truewith theOldObjectSampleevent (a JFR event that samples long-lived objects and, with that option, records the path from a GC root to each). - Read the chain bottom-up. It will end at a static field, a thread, or a registry, with a lambda or another captured reference in the middle. That chain, not the leaked object, is where the bug is.
- Test the hypothesis cheaply. In a test or a debug endpoint, remove the suspected registration or clear the suspected cache, force a collection, and check whether the weak reference (as in the demo) or instance count drops.
Fix
Match the fix to the root: bound or expire the cache; unregister listeners in the lifecycle's teardown method; hold a long-lived registry's entries through weak references when the registrant should control lifetime (WeakReference, WeakHashMap, or a weak-value cache); avoid capturing the enclosing object by extracting what the closure needs into a local; clear thread-locals in pooled threads in a finally. Verify with the same load: post-collection heap floor stays flat, instance count for the suspect class stops growing.
That is every published Memory Management & Garbage Collection question for Mobile Developer so far. Browse the other topics in this category, or practice this one interactively.