Skip to content
Commitwise
Menu
Programming

Memory: ARC vs garbage collection

Understand how Swift frees memory with automatic reference counting and how Kotlin, Java and TypeScript do it with a garbage collector, why retain cycles and long-lived references leak, and how to find and fix leaks on each platform.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You open a screen, close it, open it again. Ten times. The app’s memory climbs with every visit and never comes back down, and after a while the system kills it. Nobody forgot to “free” anything; none of these languages makes you. The screens are still in memory because something still points to them. Knowing who frees memory, and when, is how you find that something.

New to some of these? Open what you need, or skip ahead.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often skips classes for data and uses plain objects described by an interface.
Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What is a reference?

A variable that holds an object doesn’t contain the object itself; it holds a reference, like an address, to where the object lives in memory. Two variables can hold references to the same object. The Value types vs reference types tutorial explains the difference in detail.

Part Not started

Who frees memory, and when

Think of a library book. While anyone has it checked out, it stays off the shelf. Reference counting is a librarian who keeps a tally: every new borrower adds one, every return subtracts one, and when the tally hits zero, the book goes back on the shelf immediately.

A garbage collector is a different librarian. Nobody keeps a tally. Every so often, the librarian walks through the whole building, notes every book someone is actually holding, and puts all the others back on the shelf in one go.

Both get books back eventually. They differ in when, and in what can trick them.

ARCAutomatic Reference CountingSwift's memory management: each object counts how many strong references point to it and is freed at zero.Open in glossary → (Swift) inserts retain/release operations at compile time; each class instance carries a strong reference count and is deallocated synchronously, on the releasing thread, when it reaches zero. Value types aren’t reference counted (though their heap-backed storage is). Tracing garbage collectionGarbage collectionThe runtime periodically finds objects nothing can reach any more and frees them for you.Open in glossary → (JVM, Android ART, V8) periodically marks everything reachable from roots (thread stacks, static fields, globals, JNI handles) and reclaims the rest; modern collectors are generational and mostly concurrent. ARC trades cycles and per-operation overhead for deterministic, low-latency reclamation; GC trades pauses, CPU and memory headroom for cycle-proof, allocation-cheap memory management.

final class Photo {
    let name: String
    init(name: String) { self.name = name }
    deinit { print("freed \(name)") }         // runs the moment it's freed
}

var a: Photo? = Photo(name: "beach")
var b = a                                     // two references: count is 2
a = nil                                       // count 1: still alive
print("a is nil")
b = nil                                       // count 0: freed right now
print("b is nil")
// a is nil
// freed beach
// b is nil
class Photo(val name: String)

var a: Photo? = Photo("beach")
var b = a                                     // two references to one object
a = null
b = null                                      // now unreachable…
println("both null")
// …but it's freed whenever the garbage collector next runs,
// which might be in a millisecond or in a minute. There is no reliable "freed" callback.
class Photo {
    final String name;
    Photo(String name) { this.name = name; }
}

Photo a = new Photo("beach");
Photo b = a;                                  // two references to one object
a = null;
b = null;                                     // now unreachable…
System.out.println("both null");
// …but it's freed whenever the garbage collector next runs.
// finalize() is deprecated; java.lang.ref.Cleaner exists but runs at an unknown time.
class Photo {
  constructor(readonly name: string) {}
}

let a: Photo | null = new Photo("beach");
let b = a;                                    // two references to one object
a = null;
b = null;                                     // now unreachable…
console.log("both null");
// …but the engine's garbage collector frees it later, at a time it chooses.
// FinalizationRegistry callbacks may run late, or never.
  • Swift (ARC): each object counts how many strong references point to it. When the count drops to zero, it’s freed immediately, and its deinit runs. You can see exactly when.
  • Kotlin, Java, TypeScript (garbage collection): nobody counts. The garbage collector runs from time to time, finds every object the program can still reach (from local variables, static fields, the running code), and frees everything else. “Unreachable” doesn’t mean “freed yet”.

Either way, you never call free yourself. Your job is only to stop pointing to things you no longer need.

Predict the output

When is the Photo freed: right after b is set to nil/null, or later?

The stack and the heap

Memory a program uses comes in two main areas:

  • The stack holds a function’s local values while it runs. It’s cleaned up automatically when the function returns, instantly and for free.
  • The heap holds objects that must live longer than the function that created them: class instances, arrays, closures that are stored. This is the memory ARC and garbage collectors manage.

A Swift struct or a Kotlin Int local variable can live on the stack; anything you create with new, a class initializer, or {} in TypeScript lives on the heap. The details are up to the compiler, but the rule of thumb helps: heap objects are the ones that can leak.

Costs of each model

ARC (Swift)Tracing GC (JVM, ART, V8)
When memory is reclaimedImmediately at count zeroAt the next collection of that generation
CyclesLeak unless broken with weak/unownedCollected
Runtime costAtomic increment/decrement on retain/release (often optimized away)Allocation is a pointer bump; collection costs CPU and may pause threads
Memory headroomLow: little garbage waitingHigher: heap sized larger than live data to amortize collections
Latency riskLarge deinit cascades on the main thread (freeing a big object graph at once)GC pauses; jank on Android when many short-lived objects are allocated per frame
Deterministic cleanupdeinit is reliable for owned resourcesNo; use close()/use/try-with-resources/using

Modern JVM collectors (G1 by default; ZGC and Shenandoah for sub-millisecond pauses) and ART’s concurrent copying collector make pauses rare, so on Android the practical GC concern is allocation churn in hot paths (per-frame allocations in onDraw or during scrolling), which shows up as frequent young-generation collections.

  • ARC: compile-time inserted retain/release, deterministic deallocation at count zero, deinit reliable; cycles leak.
  • Tracing GC (JVM G1/ZGC, ART, V8): reachability from roots, generational, mostly concurrent; cycles are fine, timing isn’t deterministic. Never rely on finalization (finalize deprecated, Cleaner and FinalizationRegistry are best-effort).
  • Costs: ARC → atomic refcounting, deinit cascades; GC → headroom, pauses, allocation churn in hot paths.
SwiftKotlinJavaTypeScript
MechanismARCGC (JVM or ART)GC (JVM)GC (V8, JavaScriptCore…)
Cleanup hookdeinit (deterministic)use {} / close()try-with-resourcestry/finally, using (TS 5.2)
Weak referenceweak / unownedWeakReferenceWeakReferenceWeakRef, WeakMap

Check this part

  1. In Swift, when is an object freed?In Kotlin (on the JVM or Android), when is an unreachable object freed?In Java, when is an unreachable object freed?In TypeScript (JavaScript), when is an unreachable object freed?

  2. Do you have to free objects yourself in these four languages?

  3. Where should cleanup like closing a file live on the JVM or in TypeScript?

Objects live in memory until nothing refers to them. Swift's ARC counts references and frees an object the moment the count reaches zero. Kotlin, Java and TypeScript use a garbage collector that periodically finds unreachable objects and frees them later.

Part Not started

Cycles and long-lived references

Back to the reference-counting librarian. Two friends each keep the other’s library card, “just in case”. Neither card’s tally ever drops to zero, even after both friends have moved away. The cards are stuck forever. That’s a retain cycleRetain cycleTwo or more objects holding strong references to each other, so none of them is ever freed.Open in glossary →: two objects holding strong references to each other.

The walking librarian (the garbage collector) isn’t fooled by that: if nobody in the building holds either card, both go back. But it is fooled by a card pinned to the notice board at the entrance, which the librarian always counts as “in use”. In code, that notice board is anything that lives as long as the app: a singleton, a static field, a global list.

Two failure modes, one per memory model. Under ARC, any strong reference cycle (object ↔ object, object → closure → object) is unreclaimable; break it with a weak reference (optional, zeroed on deallocation) or unowned (non-optional, traps if accessed after deallocation). Under GC, cycles are free, but reachability leaks are the norm: an object reachable from a GC root through a forgotten path (a listener list in a singleton, a static cache, a pending callback or coroutine, a long-lived thread) lives as long as that root. Both are the same bug in spirit: an owner that outlives its usefulness.

final class Person {
    let name: String
    var apartment: Apartment?                 // a person owns their apartment (strong)
    init(name: String) { self.name = name }
    deinit { print("\(name) freed") }
}

final class Apartment {
    let unit: String
    weak var tenant: Person?                  // weak: doesn't keep the person alive
    init(unit: String) { self.unit = unit }
    deinit { print("\(unit) freed") }
}

var ana: Person? = Person(name: "Ana")
var flat: Apartment? = Apartment(unit: "4A")
ana?.apartment = flat
flat?.tenant = ana
ana = nil                                     // "Ana freed": nothing else holds her strongly
flat = nil                                    // "4A freed"
class Person(val name: String) {
    var apartment: Apartment? = null
}

class Apartment(val unit: String) {
    var tenant: Person? = null                // a cycle is fine for the GC
}

var ana: Person? = Person("Ana")
var flat: Apartment? = Apartment("4A")
ana!!.apartment = flat
flat!!.tenant = ana
ana = null
flat = null                                   // both unreachable: both will be collected

object Registry {                             // lives as long as the app
    val people = mutableListOf<Person>()      // anything added here never becomes unreachable
}
class Person {
    final String name;
    Apartment apartment;                      // a cycle is fine for the GC
    Person(String name) { this.name = name; }
}

class Apartment {
    final String unit;
    Person tenant;
    Apartment(String unit) { this.unit = unit; }
}

final class Registry {                        // lives as long as the app
    static final List<Person> PEOPLE = new ArrayList<>();   // anything added here stays reachable
}
class Person {
  apartment?: Apartment;                      // a cycle is fine for the GC
  constructor(readonly name: string) {}
}

class Apartment {
  tenant?: Person;
  constructor(readonly unit: string) {}
}

let ana: Person | undefined = new Person("Ana");
let flat: Apartment | undefined = new Apartment("4A");
ana.apartment = flat;
flat.tenant = ana;
ana = undefined;
flat = undefined;                             // both unreachable: both will be collected

const registry: Person[] = [];                // module-level: anything pushed here stays reachable
  • In Swift, decide which side owns the other. The person owns the apartment (strong); the apartment only refers back to its tenant (weak). A weak reference doesn’t add to the count, and becomes nil automatically when the object is freed. The most common cycle is a closure that uses self while self stores the closure; [weak self] fixes it.
  • In Kotlin, Java and TypeScript, cycles aren’t a problem, but anything that something long-lived still points to stays in memory. A screen added to a global list, a listener never removed from a singleton, a timer still running: all keep their objects alive.

Swift note

Delegates are almost always weak var delegate: SomeDelegate?, with the protocol marked AnyObject so that weak is allowed. The object that creates a helper owns it; the helper only points back.

Spot the bugScreens that are never freed

Tap the line with the bug.

protocol DownloadDelegate: AnyObject {
    func downloadFinished()
}

final class Downloader {
    var delegate: DownloadDelegate?
}

final class GalleryScreen: DownloadDelegate {
    let downloader = Downloader()
    init() { downloader.delegate = self }
    func downloadFinished() { print("reload") }
}
object DownloadManager {
    private val listeners = mutableListOf<(String) -> Unit>()
    fun addListener(listener: (String) -> Unit) { listeners += listener }
}

class GalleryScreen {
    private val photos = mutableListOf<String>()

    fun open() {
        DownloadManager.addListener { file -> photos += file }
    }
}
final class DownloadManager {
    private static final List<Consumer<String>> LISTENERS = new ArrayList<>();
    static void addListener(Consumer<String> listener) { LISTENERS.add(listener); }
}

class GalleryScreen {
    private final List<String> photos = new ArrayList<>();

    void open() {
        DownloadManager.addListener(file -> photos.add(file));
    }
}
const downloadListeners: Array<(file: string) => void> = [];

class GalleryScreen {
  private photos: string[] = [];

  open(): void {
    downloadListeners.push((file) => this.photos.push(file));
  }
}

Exerciseeasy

A delegate that doesn't leak

A Timer-like Ticker calls back every second. A ClockScreen creates one and shows the time. Design the connection so that closing the screen frees it and stops the ticking, in your language.

›

The screen owns the ticker. The ticker must refer back to the screen without keeping it alive (Swift: weak), or the screen must explicitly stop and unregister when it closes (all languages).

SolutionTry it yourself first
protocol TickerDelegate: AnyObject { func tick() }

final class Ticker {
    weak var delegate: TickerDelegate?          // doesn't keep the screen alive
    private var task: Task<Void, Never>?

    func start() {
        task = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                await MainActor.run { self?.delegate?.tick() }
            }
        }
    }

    func stop() { task?.cancel() }
    deinit { task?.cancel() }
}

final class ClockScreen: TickerDelegate {
    private let ticker = Ticker()
    init() { ticker.delegate = self; ticker.start() }
    func tick() { print(Date.now) }
    deinit { print("ClockScreen freed") }       // the ticker goes with it and cancels its task
}
class Ticker(private val scope: CoroutineScope) {
    fun start(onTick: () -> Unit): Job = scope.launch {
        while (isActive) {
            delay(1_000)
            onTick()
        }
    }
}

class ClockViewModel : ViewModel() {
    private val _time = MutableStateFlow(System.currentTimeMillis())
    val time: StateFlow<Long> = _time

    init {
        Ticker(viewModelScope).start { _time.value = System.currentTimeMillis() }
        // viewModelScope is cancelled when the ViewModel is cleared: the loop stops,
        // and nothing long-lived holds the screen.
    }
}
final class Ticker implements AutoCloseable {
    private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();

    void start(Runnable onTick) {
        executor.scheduleAtFixedRate(onTick, 1, 1, TimeUnit.SECONDS);
    }

    @Override public void close() { executor.shutdownNow(); }   // stops the thread and drops onTick
}

final class ClockScreen implements AutoCloseable {
    private final Ticker ticker = new Ticker();
    void open() { ticker.start(() -> System.out.println(Instant.now())); }
    @Override public void close() { ticker.close(); }          // call when the screen closes
}
class Ticker {
  private id: ReturnType<typeof setInterval> | undefined;

  start(onTick: () => void): void {
    this.id = setInterval(onTick, 1000);
  }

  stop(): void {
    if (this.id !== undefined) clearInterval(this.id);   // the timer stops holding onTick
  }
}

class ClockScreen {
  private readonly ticker = new Ticker();
  open(): void { this.ticker.start(() => this.render()); }
  close(): void { this.ticker.stop(); }                 // call when the screen closes
  private render(): void { console.log(new Date().toISOString()); }
}
  • Swift: the screen owns the ticker; the ticker’s delegate is weak, and its task captures self weakly. Closing the screen frees it, which frees the ticker, whose deinit cancels the task.
  • Kotlin (Android): tie the work to a lifecycle-scoped coroutine scope (viewModelScope, lifecycleScope), and it’s cancelled automatically.
  • Java and TypeScript: a running timer (a thread, setInterval) is a GC root that keeps its callback, and everything the callback uses, alive. Stopping it is mandatory; AutoCloseable (or a dispose/close method) makes the obligation explicit.

weak vs unowned, and their cousins

Predict the output

The cart is freed, then the badge reads its count. What happens?

final class Cart { var count = 3 }

final class Badge {
    unowned let cart: Cart
    init(cart: Cart) { self.cart = cart }
    func label() -> String { "\(cart.count)" }
}

var cart: Cart? = Cart()
let badge = Badge(cart: cart!)
cart = nil
print(badge.label())
class Cart { var count = 3 }

class Badge(cart: Cart) {
    private val cart = WeakReference(cart)
    fun label() = "${cart.get()?.count ?: 0}"
}

var cart: Cart? = Cart()
val badge = Badge(cart!!)
cart = null
println(badge.label())
class Cart { int count = 3; }

class Badge {
    private final WeakReference<Cart> cart;
    Badge(Cart cart) { this.cart = new WeakReference<>(cart); }
    String label() { Cart c = cart.get(); return String.valueOf(c == null ? 0 : c.count); }
}
class Cart { count = 3; }

class Badge {
  private readonly cart: WeakRef<Cart>;
  constructor(cart: Cart) { this.cart = new WeakRef(cart); }
  label(): string { return `${this.cart.deref()?.count ?? 0}`; }
}

let cart: Cart | undefined = new Cart();
const badge = new Badge(cart);
cart = undefined;
console.log(badge.label());

On the JVM and in JavaScript, weak references are a niche tool (caches keyed by object, canonicalizing maps via WeakHashMap / WeakMap), not the usual fix for leaks: unregistering and lifecycle-scoping are. SoftReference caches are discouraged on Android (the runtime clears them aggressively and unpredictably); use LruCache with an explicit size.

Leaks that ship

  • Swift: escaping closures stored on self (self.onComplete = { self.reload() }), Timer.scheduledTimer(target: self, …) (the run loop holds the timer, the timer holds self), Combine sink stored in self.cancellables that captures self strongly, NotificationCenter block observers.
  • Android: a static or singleton reference to an Activity, View or Context (use applicationContext), non-static inner classes and anonymous listeners outliving their Activity, coroutines launched in GlobalScope, Flow collected without repeatOnLifecycle.
  • Backend Java: unbounded static caches and ThreadLocals on pooled threads, listeners added per request, classloader leaks on redeploy.
  • TypeScript: event listeners on window/document from components that unmount, setInterval never cleared, closures captured in module-level maps, detached DOM nodes referenced from JavaScript.
  • ARC: break cycles with weak (optional, zeroed) or unowned (traps if outlived). Delegates weak with AnyObject protocols; closures stored on self use [weak self]; method references capture strongly.
  • GC: cycles are fine; reachability leaks aren’t: singletons, statics, listener lists, running timers/threads/coroutines, ThreadLocals. Fix by unregistering and lifecycle-scoping, not by weak references.
  • WeakReference/WeakRef/WeakMap for caches and canonical maps; avoid SoftReference caches on Android (LruCache instead).
weak var delegate: DownloadDelegate?
cancellable = model.$items.sink { [weak self] in self?.render($0) }
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) { model.items.collect(::render) }
}
try (Ticker ticker = new Ticker()) {           // AutoCloseable: always stopped
    ticker.start(this::render);
}
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
// on unmount: controller.abort();

Check this part

  1. Under ARC, two objects hold strong references to each other. What happens when nothing else refers to them?

  2. How do you write a delegate property that doesn't keep the delegate alive?A screen adds a listener to an app-wide object. What must it do when it closes?A screen adds a listener to a static list. What must it do when it closes?A component adds a listener to a module-level array. What must it do when it closes?

  3. What happens when an unowned reference is read after its object was freed?What does WeakReference.get() return after the referent is collected?What does WeakRef.deref() return after the target is collected?

  4. On Android, what is the usual fix for a Flow collected from an Activity that leaks?

Under ARC, two objects that point to each other strongly are never freed: a retain cycle, broken with weak or unowned. A garbage collector frees cycles, but anything still reachable from a long-lived object (a singleton, a static list of listeners) leaks just the same.

Part Not started

Finding leaks

Finding a leak is detective work with one simple method: do something that should free memory, several times, then look at what’s still there. Open and close the same screen five times. If there are now five copies of it in memory, something is holding them, and the tools will show you exactly what.

Leak hunting is the same workflow everywhere: establish a baseline, repeat an action that should be memory-neutral, force or wait for reclamation, diff object counts, and inspect the retention path (the chain of references from a root to the leaked object). The tools differ; the shortest path to a root is what you’re looking for in all of them.

  1. Pick an action that should leave no trace: open and close a screen, run one request, show and dismiss a dialog.
  2. Repeat it several times, so a leak stands out as a count (5 screens) rather than noise.
  3. Look at what’s in memory with your platform’s tool, and search for the type that should be gone.
  4. Follow the references back from the leaked object to whatever holds it: a closure, a singleton’s list, a timer.
  5. Fix the owner, then repeat steps 1–3 to confirm the count is back to zero.
PlatformToolWhat to look for
iOSXcode Debug Memory Graph; Instruments Leaks and AllocationsPurple ”!” leak markers, instance counts of your view controllers, the reference path
AndroidLeakCanary (debug builds); Android Studio Memory Profiler heap dumpThe leak trace from a GC root to the Activity or Fragment
JVM backendHeap dump (jcmd <pid> GC.heap_dump) opened in Eclipse MAT or IntelliJDominator tree, biggest retained sizes, path to GC roots
Web / NodeChrome DevTools Memory tab: heap snapshots, compare view“Detached” DOM nodes, growing counts of your classes, retainers

Practice: the deinit / log trick

Before reaching for a tool, add a message when a screen is created and when it’s freed or closed. If “freed” never appears after you leave the screen, you’ve found a leak:

final class GalleryScreen {
    init() { print("Gallery created") }
    deinit { print("Gallery freed") }        // never printed? something still holds it
}
class GalleryActivity : AppCompatActivity() {
    override fun onDestroy() {
        super.onDestroy()
        Log.d("Leak", "Gallery destroyed")
        // destroyed isn't freed: LeakCanary checks that it's actually collected afterwards
    }
}
public class GalleryActivity extends AppCompatActivity {
    @Override protected void onDestroy() {
        super.onDestroy();
        Log.d("Leak", "Gallery destroyed");
        // destroyed isn't freed: LeakCanary checks that it's actually collected afterwards
    }
}
const registry = new FinalizationRegistry((name: string) => console.log(`${name} collected`));

class GalleryScreen {
  constructor() { registry.register(this, "Gallery"); }
}
// In DevTools, click "Collect garbage" (the trash-can icon) and watch for the message.
// It's a debugging aid only: collection timing isn't guaranteed.

In Swift, deinit tells you the exact moment. On the JVM and in JavaScript, “destroyed” or “closed” only means your code stopped using it; whether the memory is actually reclaimed is what LeakCanary and heap snapshots check.

Production puzzles

Spot the bugA refresh timer that keeps screens alive

Tap the line with the bug.

final class PricesScreen {
    private var timer: Timer?

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
            self.refresh()
        }
    }

    func refresh() { /* reload prices */ }
    deinit { timer?.invalidate() }
}
class PricesActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())

    private val refresh = object : Runnable {
        override fun run() {
            reloadPrices()
            handler.postDelayed(this, 5_000)
        }
    }

    override fun onStart() { super.onStart(); handler.post(refresh) }
    private fun reloadPrices() { /* reload prices */ }
}
public class PricesActivity extends AppCompatActivity {
    private final Handler handler = new Handler(Looper.getMainLooper());

    private final Runnable refresh = new Runnable() {
        @Override public void run() {
            reloadPrices();
            handler.postDelayed(this, 5_000);
        }
    };

    @Override protected void onStart() { super.onStart(); handler.post(refresh); }
    private void reloadPrices() { /* reload prices */ }
}
class PricesPage {
  private timer?: ReturnType<typeof setInterval>;

  mount(): void {
    this.timer = setInterval(() => this.refresh(), 5000);
  }

  refresh(): void { /* reload prices */ }
  unmount(): void { /* remove DOM */ }
}

Exercisemedium

An image cache under memory pressure

Your app shows a feed of images. A teammate added a cache, a plain dictionary from URL to decoded image, and memory crashes on older phones went up. Redesign the cache: what it stores, how it’s bounded, and how it reacts when the system is low on memory.

›

A decoded 1080 × 1080 image is about 4.5 MB in memory (width × height × 4 bytes), whatever its file size. A few hundred of them is gigabytes. The cache must be bounded by bytes, not by count.

›

Every platform has a cache type that evicts by cost and listens to memory warnings, and a hook to drop caches when the system asks.

SolutionTry it yourself first
final class ImageCache {
    private let cache = NSCache<NSURL, UIImage>()

    init() {
        cache.totalCostLimit = 100 * 1024 * 1024            // 100 MB of decoded pixels
        // NSCache also evicts automatically under memory pressure.
    }

    func image(for url: URL) -> UIImage? { cache.object(forKey: url as NSURL) }

    func insert(_ image: UIImage, for url: URL) {
        let cost = Int(image.size.width * image.scale * image.size.height * image.scale * 4)
        cache.setObject(image, forKey: url as NSURL, cost: cost)
    }
}
class ImageCache(context: Context) : ComponentCallbacks2 {
    private val maxBytes = (Runtime.getRuntime().maxMemory() / 8).toInt()   // 1/8 of the app's heap

    private val cache = object : LruCache<String, Bitmap>(maxBytes) {
        override fun sizeOf(key: String, value: Bitmap) = value.allocationByteCount
    }

    init { context.registerComponentCallbacks(this) }

    fun get(url: String): Bitmap? = cache.get(url)
    fun put(url: String, bitmap: Bitmap) { cache.put(url, bitmap) }

    override fun onTrimMemory(level: Int) {
        if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) cache.evictAll()
        else cache.trimToSize(cache.maxSize() / 2)
    }
    override fun onConfigurationChanged(newConfig: Configuration) {}
    override fun onLowMemory() { cache.evictAll() }
}
final class ImageCache {
    private final LruCache<String, Bitmap> cache;

    ImageCache() {
        int maxBytes = (int) (Runtime.getRuntime().maxMemory() / 8);   // 1/8 of the app's heap
        cache = new LruCache<>(maxBytes) {
            @Override protected int sizeOf(String key, Bitmap value) { return value.getAllocationByteCount(); }
        };
    }

    Bitmap get(String url) { return cache.get(url); }
    void put(String url, Bitmap bitmap) { cache.put(url, bitmap); }
    void trim() { cache.trimToSize(cache.maxSize() / 2); }        // call from onTrimMemory
}
class ImageCache {
  private readonly entries = new Map<string, { bitmap: ImageBitmap; bytes: number }>();
  private total = 0;

  constructor(private readonly maxBytes = 100 * 1024 * 1024) {}

  get(url: string): ImageBitmap | undefined {
    const entry = this.entries.get(url);
    if (!entry) return undefined;
    this.entries.delete(url);                       // move to the end: most recently used
    this.entries.set(url, entry);
    return entry.bitmap;
  }

  put(url: string, bitmap: ImageBitmap): void {
    const bytes = bitmap.width * bitmap.height * 4;
    this.entries.set(url, { bitmap, bytes });
    this.total += bytes;
    for (const [key, entry] of this.entries) {      // Map iterates oldest first
      if (this.total <= this.maxBytes) break;
      this.entries.delete(key);
      entry.bitmap.close();                         // release GPU/decoded memory now
      this.total -= entry.bytes;
    }
  }
}
  • Bound by bytes, not entries: the cost of an image is its decoded size; a count-based limit of 100 can be 50 MB or 1.5 GB.
  • React to pressure: NSCache evicts on memory warnings by itself; on Android, onTrimMemory levels tell you how aggressively to trim; in the browser, keep the budget conservative and release ImageBitmaps with close().
  • Downsample before caching: decode at the size you display (a 300-point thumbnail doesn’t need a 4,000-pixel bitmap). Image libraries (Kingfisher/Nuke, Coil/Glide) do this, plus a disk tier, which is why they’re usually the right answer in production.
  • Not SoftReference or WeakRef as the eviction policy: the GC’s timing isn’t a caching strategy, and Android clears soft references aggressively.
  • Workflow: baseline → repeat a memory-neutral action → reclaim → diff counts → inspect the retention path → fix the owner → re-measure.
  • Tools: Xcode Memory Graph and Instruments (Leaks, Allocations); LeakCanary and Android Studio heap dumps; jcmd GC.heap_dump + MAT; Chrome heap snapshots (detached nodes, retainers).
  • Schedulers outlive screens: Timer + strong block, Handler reposting an inner Runnable, setInterval without clearInterval. Stop scheduled work at the end of the visible lifetime, not in deinit.
  • Caches: bound by bytes (decoded size), react to memory pressure (NSCache, LruCache + onTrimMemory), downsample before caching.

Check this part

  1. How do you reproduce a memory leak of a screen?

  2. Which Xcode tool shows what still holds an object?Which library automatically reports leaked Activities in debug builds?Which library automatically reports leaked Activities in Android debug builds?Where do you look for leaked objects in a web app?

  3. A repeating timer's callback captures the screen strongly, and the timer is invalidated in deinit / onDestroy cleanup only. Why does it still leak (on iOS) or keep running (on Android/web)?

  4. How should an in-memory image cache be bounded?

Every platform has a tool that shows what is still in memory and why: Xcode's memory graph, LeakCanary and the Android Studio profiler, JVM heap dumps, Chrome heap snapshots. Reproduce by repeating an action, then follow the reference path to whatever holds the leaked object.

Key takeaways

  • Swift (ARC) frees an object the moment nothing strongly refers to it; Kotlin, Java and TypeScript use a garbage collector that frees unreachable objects later.
  • Under ARC, two objects pointing at each other strongly never get freed: make one reference weak, and use [weak self] in stored closures.
  • With a garbage collector, anything still reachable from something long-lived (a singleton, a static list, a running timer) stays in memory: unregister and stop things when a screen closes.
  • Find leaks by repeating an action and checking what’s still in memory with your platform’s tool.
  • ARC is deterministic and leaks cycles; tracing GC handles cycles but leaks by reachability and has no reliable finalization.
  • weak vs unowned: optional and zeroed vs trapping when outlived. On the JVM and web, fix leaks with lifecycles and unregistering, not weak references.
  • Schedulers, listener registries and global caches are the usual roots of leaks; stop work at the end of the visible lifetime.
  • Bound caches by bytes and respond to memory pressure; measure with heap tools, don’t guess.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.