Thread safety, atomic operations, and real-world Android caching

A plain HashMap is not thread-safe. When two threads read and write simultaneously, the internal array can corrupt silently — no exception is thrown, you just get wrong or missing data, and in pathological cases (a resize racing a read) an infinite loop. Even a simple read-modify-write is unsafe without external synchronisation.

The race condition a plain HashMap permits

1
2
3
4
5
6
7
val map = HashMap<String, Int>()   // NOT thread-safe

// Thread A and Thread B run this simultaneously:
// map["count"] = (map["count"] ?: 0) + 1
//
// Both read 0, both write 1 -> final value is 1, not 2 (lost update).
// Worse, a concurrent resize can corrupt the bucket array entirely.

Three Approaches — Why ConcurrentHashMap Wins

Approach Thread-safe Performance Use case
HashMap No Fast Single-threaded only
Collections.synchronizedMap() Yes Slow — one global lock Low concurrency
Hashtable Yes Slow — one global lock Legacy code only
ConcurrentHashMap Yes Fast — CAS + bucket locks Production multithreading

ConcurrentHashMap divides its data into independent buckets. Instead of locking the entire map on every operation, it uses CAS (Compare-And-Swap) for reads (which are mostly lock-free) and fine-grained per-bucket locks for writes. Threads operating on different buckets proceed in parallel; a synchronised map serialises everything behind a single lock.

Big O Under Concurrency

ConcurrentHashMap shares HashMap’s Big O — all operations are amortised O(1). The difference is not asymptotic but in constant factors: atomic operations carry a small overhead from CAS or a bucket lock. Under low contention that overhead is negligible; under high contention it remains far cheaper than a fully synchronised map.

Operation HashMap ConcurrentHashMap Note
get(key) O(1) O(1) Lock-free read via CAS
put(key, value) O(1) avg O(1) avg Bucket-level lock on write
putIfAbsent manual O(1) Atomic — no external sync
computeIfAbsent manual O(1)* Lambda runs only on a cache miss
merge manual O(1)* Atomic insert-or-combine
size() O(1) O(1) Approximate under concurrency
  • The map access is O(1); the lambda you pass may have its own complexity — that cost is yours, not the map’s.

Atomic Operations Reference

Atomic operations are the real reason to choose ConcurrentHashMap over ‘HashMap plus a lock’. Each executes as a single indivisible unit — no thread can interrupt it midway — which eliminates race conditions without any manual synchronized block.

putIfAbsent — insert only if the key is missing

Inserts the pair only if the key is absent. Returns the existing value if the key was present, or null if it was absent and the insert succeeded. Beware: the value expression is always evaluated, even when it will not be used — so never pass an expensive call here.

putIfAbsent

1
2
3
4
5
6
7
8
val map = ConcurrentHashMap<String, Int>()
map["key"] = 5

val a = map.putIfAbsent("key", 99)  // key exists -> a = 5, map unchanged
val b = map.putIfAbsent("new", 99)  // key absent -> b = null, inserts 99

// WARNING: 99 is always computed. Avoid expensive args:
// map.putIfAbsent("key", expensiveOperation())  // wasteful on hit

computeIfAbsent — lazy cache population

The lambda runs only on a cache miss, which makes this the primary pattern for thread-safe lazy initialisation — a clean replacement for double-checked locking.

computeIfAbsent

1
2
3
4
5
6
7
8
9
val cache = ConcurrentHashMap<String, UserProfile>()

// Lambda runs ONLY when the key is absent -- expensive call avoided on a hit
val profile = cache.computeIfAbsent("user123") {
    database.fetchProfile(it)
}

// Second call: key present -> lambda never runs, cached value returned
cache.computeIfAbsent("user123") { database.fetchProfile(it) }

computeIfPresent — update only if the key exists

computeIfPresent

1
2
3
4
5
6
7
8
9
10
11
12
13
val scores = ConcurrentHashMap<String, Int>()
scores["player1"] = 100

// Key exists -> runs the lambda, updates the value
scores.computeIfPresent("player1") { _, current -> current + 50 } // 150

// Key absent -> does nothing, no entry created
scores.computeIfPresent("ghost") { _, current -> current + 50 }   // null

// Returning null removes the entry
scores.computeIfPresent("player1") { _, current ->
    if (current <= 0) null else current
}

merge — insert or combine

The cleanest pattern for counters and accumulators. If the key is absent it inserts the value directly; if present it runs the lambda to combine the old and new values. Returning null removes the entry.

merge — atomic counter

1
2
3
4
5
6
7
8
9
10
11
val wordCount = ConcurrentHashMap<String, Int>()

wordCount.merge("kotlin", 1) { old, new -> old + new }  // absent -> inserts 1
wordCount.merge("kotlin", 1) { old, new -> old + new }  // present -> 1+1 = 2
wordCount.merge("kotlin", 1) { old, new -> old + new }  // 2+1 = 3
println(wordCount["kotlin"])  // 3

// Real analytics use:
fun trackEvent(userId: String) {
    eventCount.merge(userId, 1) { current, inc -> current + inc }
}

compute — full control, always runs

compute — session lifecycle in one atomic call

1
2
3
4
5
6
7
sessions.compute(sessionId) { _, current ->
    when {
        current == null     -> Session.new()      // create
        current.isExpired() -> null               // remove (null deletes)
        else                -> current.refresh()  // extend
    }
}

Choosing the right operation

Operation Key absent Key exists Lambda lazy?
put Inserts Overwrites
putIfAbsent Inserts Ignores No — always evaluated
replace Ignores Overwrites
replace(k,old,new) Ignores Updates if value matches — (CAS)
computeIfAbsent Runs lambda, inserts Ignores Only on a miss
computeIfPresent Ignores Runs lambda, updates Only on a hit
merge Inserts value Runs lambda, combines Only on update
compute Runs lambda Runs lambda Always runs

Real-World Android Patterns

In-memory image cache

The canonical use case. computeIfAbsent guarantees the download runs exactly once per URL, even under concurrent access from many background threads.

Thread-safe image cache

1
2
3
4
5
6
7
8
9
10
11
12
class ImageCache {
    private val cache = ConcurrentHashMap<String, Bitmap>()

    fun get(url: String): Bitmap =
        cache.computeIfAbsent(url) { downloadImage(it) }  // once per URL

    fun invalidate(url: String) = cache.remove(url)
    fun clear() = cache.clear()
}

// GC-friendly variant for Bitmaps (prevents OOM):
val weakCache = ConcurrentHashMap<String, WeakReference<Bitmap>>()

Analytics event counter

AnalyticsTracker with merge

1
2
3
4
5
6
7
8
9
class AnalyticsTracker {
    private val counts = ConcurrentHashMap<String, Int>()

    fun track(event: String) =
        counts.merge(event, 1) { current, inc -> current + inc }

    fun getCount(event: String) = counts.getOrDefault(event, 0)
    fun snapshot(): Map<String, Int> = HashMap(counts)  // safe copy
}

ConcurrentHashMap vs Coroutine State

In modern Android, StateFlow and Mutex handle observable UI state better than a concurrent collection. ConcurrentHashMap remains the right tool for pure caches and lookup tables accessed from multiple threads outside a coroutine context.

Tool Best for
ConcurrentHashMap Caches and lookup tables across threads
StateFlow Observable UI state in ViewModels
Mutex Complex state mutations inside coroutines (suspends, doesn’t block)

Other Concurrent Collections

Collection Read Write Best for
ConcurrentHashMap Fast (CAS) Fast (bucket lock) Cache, lookup table
CopyOnWriteArrayList Fast (lock-free) Slow O(N) — full copy Listener lists, rare writes
CopyOnWriteArraySet Fast (lock-free) Slow O(N) Small read-heavy sets
CHM.newKeySet() Fast Fast High-write sets
ConcurrentLinkedQueue Fast (CAS) Fast (CAS) Producer/consumer, no backpressure
LinkedBlockingQueue Medium (lock) Medium (lock) Bounded work queue with backpressure

↑ Chapter Index


This site uses Just the Docs, a documentation theme for Jekyll.