Concurrency bugs feel like gremlins that only show up at 2 a.m. A few years back I shipped a tiny background service that intermittently dropped items because two threads were adding to the same HashSet without coordination. The fix ended up being a one-liner, but the hours lost made me promise myself: never let shared collections go unsynchronized. In this post I’m sharing how I approach converting a plain Java HashSet into a thread-safe set, what’s really happening under the hood, and how that fits into modern JVM development in 2026. You’ll see how I decide between the classic synchronized wrapper and newer concurrent collections, how to reason about memory visibility, and how to test that the code actually behaves when many threads hammer it at once.
Why a HashSet Isn’t Safe on Its Own
A HashSet trades safety for speed; it assumes a single thread mutates it at a time. If two threads add simultaneously, internal buckets can be corrupted, leading to lost elements or even infinite loops during iteration. The JVM memory model compounds this: writes in one thread aren’t guaranteed to become visible in another without synchronization. If you’re sharing a set beyond a single thread, you need a guardrail. I like to remind teammates that “not blowing up” is not the same as “correct” — silent data loss is the real danger.
Hidden failure modes
- Torn state during resize: concurrent adds can resize the internal table and leave next pointers inconsistent, leading to elements that vanish from iteration.
- Visibility lag: a thread inserts an element, but another thread keeps reading an outdated bucket array and never sees it.
- Concurrent modification during iteration: a thread iterates while another mutates, triggering ConcurrentModificationException or, worse, endless loops.
The Synchronized Wrapper: What Collections.synchronizedSet Does
The static method Collections.synchronizedSet wraps any Set with an object whose methods are synchronized on a private mutex (the wrapper itself). Every mutating and read operation acquires the lock, enforcing mutual exclusion and establishing happens-before relationships so writes become visible to readers. Iteration requires an extra synchronized block around the iterator to avoid ConcurrentModificationException. The original HashSet remains the backing store; the wrapper just serializes access.
Key properties:
- Mutex location: the wrapper instance, not the backing HashSet.
- Memory visibility: entering and exiting synchronized blocks flushes and reads the thread-local caches per the JVM memory model, so updates become visible to other threads.
- Blocking behavior: only one thread can interact with the set at a time; contention can become a bottleneck under high write pressure.
Minimal Example (Classic Wrapper)
import java.util.*;
public class SynchronizedSetExample {
public static void main(String[] args) {
Set ids = new HashSet();
ids.add(42);
ids.add(99);
// Wrap with a synchronized view
Set threadSafeIds = Collections.synchronizedSet(ids);
// Safe mutation across threads
Runnable r = () -> threadSafeIds.add((int)(Math.random() * 1000));
new Thread(r).start();
new Thread(r).start();
// Safe iteration must lock on the wrapper
synchronized (threadSafeIds) {
for (Integer id : threadSafeIds) {
System.out.println(id);
}
}
}
}
I always synchronize on the wrapper when iterating; that’s the subtle rule people forget.
When the Wrapper Shines and When It Hurts
I reach for Collections.synchronizedSet when:
- I already have a HashSet and just need basic thread safety with minimal refactor.
- Write frequency is moderate and contention is low (e.g., configuration caches warmed during startup, occasional refresh later).
- The codebase already uses intrinsic locks and consistent lock ordering is documented.
I avoid it when:
- The set receives heavy concurrent writes or long-running operations inside the lock. A single mutex becomes a choke point.
- I need lock-free reads. In analytics pipelines I prefer concurrent structures to keep read throughput high.
- I care about fairness; the intrinsic monitor is not fair and may starve threads under load.
Modern Alternatives in 2026
Even though the wrapper still works, I now consider other options first:
- CopyOnWriteArraySet: great for read-heavy, write-rare scenarios (feature flags, handler registries). Iteration sees a stable snapshot, but writes copy the whole array, so high write rates are costly.
- ConcurrentSkipListSet: sorted, scalable under concurrent access using lock-free algorithms; good when you need ordering.
- Sets.newConcurrentHashSet() from Guava or the JDK’s ConcurrentHashMap.newKeySet(): hash-based, high-concurrency, lock-striped design. My default choice for most concurrent sets.
A quick decision table I keep in my notes:
Pick
—
Collections.synchronizedSet
CopyOnWriteArraySet
ConcurrentHashMap.newKeySet()
ConcurrentSkipListSet## Practical Rules for Using Collections.synchronizedSet
- Keep the wrapper reference: store the synchronized view and avoid leaking the raw HashSet.
- Guard iteration: surround
for-eachwithsynchronized(wrapper) { ... }. - Be consistent with lock ordering: if other locks exist, document the order to avoid deadlocks.
- Avoid long-running work inside the synchronized block; compute outside, then perform the minimal mutation.
- Treat the wrapper as the only gateway. Direct access to the backing set breaks the contract.
Thread-Safety Walkthrough With a Real Scenario
Imagine a background scheduler that collects task IDs that should not be retried once processed. Multiple worker threads may mark tasks as completed while another thread periodically persists the set to durable storage.
import java.util.*;
import java.util.concurrent.*;
public class TaskRegistry {
private final Set completed = Collections.synchronizedSet(new HashSet());
public void markDone(String taskId) {
// Light work inside synchronized methods of the wrapper
completed.add(taskId);
}
public boolean isDone(String taskId) {
return completed.contains(taskId);
}
public List snapshot() {
// Iteration must be guarded
synchronized (completed) {
return new ArrayList(completed);
}
}
}
Because writes are short and reads are quick lookups, the single monitor stays inexpensive. If persisting takes time, I grab a snapshot inside the lock, then perform I/O outside:
public void flushToDisk(Path file) throws IOException {
List copy;
synchronized (completed) {
copy = new ArrayList(completed);
}
Files.write(file, copy); // slow operation outside lock
}
This keeps the critical section narrow.
Testing Concurrency in 2026 Style
Relying on hope is not a strategy. Here’s how I validate thread safety today:
- JUnit 5 +
@RepeatedTestcombined withExecutorServiceto hammer the set. - Java Flight Recorder (JFR) event streaming to spot monitor contention.
- jcstress (OpenJDK’s concurrency test harness) for litmus tests when correctness is critical.
- If your pipeline includes AI agents, wire a small fuzzing script that generates random interleavings and checks invariants.
Sample stress test using JUnit 5:
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.RepeatedTest;
import java.util.*;
import java.util.concurrent.*;
class SynchronizedSetTest {
@RepeatedTest(10)
void concurrentAddsAreVisible() throws Exception {
Set backing = new HashSet();
Set safe = Collections.synchronizedSet(backing);
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 0; i < 1000; i++) {
int val = i;
pool.submit(() -> safe.add(val));
}
pool.shutdown();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
synchronized (safe) {
assertEquals(1000, safe.size());
}
}
}
If this test flakes, you likely forgot to guard iteration or allowed a thread to touch the raw HashSet.
Memory Model Considerations
The synchronized wrapper not only serializes access but also establishes happens-before relationships. When a thread exits a synchronized method (like add on the wrapper), all writes inside become visible to any thread that subsequently enters a synchronized method on the same monitor. That’s why visibility works without explicit volatile. However, if you mutate the backing set directly, you skip the monitor, and visibility is no longer guaranteed. Stick to the wrapper.
Performance Notes
Typical overhead for synchronized wrappers is a monitor enter/exit per call. On modern JVMs, biased and lightweight locking make uncontended paths cheap (single-digit nanoseconds). Under contention, the cost rises quickly because only one thread can access the set at a time. If you see more than, say, 10–15 ms p99 latency in critical paths, consider migrating to ConcurrentHashMap.newKeySet or sharding your data across multiple sets with separate locks.
Micro-optimizations I use sparingly:
- Reduce lock hold time: prepare values before entering the block.
- Separate read and write sets if reads dominate and staleness is acceptable; keep a synchronized writer set and periodically swap a volatile reference to a read-only snapshot.
- Batch updates: accumulate changes per thread and merge under the lock in larger chunks.
Migrating from Synchronized Wrapper to ConcurrentHashMap.newKeySet
Sometimes a team starts with the synchronized wrapper and later needs higher throughput. The migration path is gentle:
// old
Set names = Collections.synchronizedSet(new HashSet());
// new
Set names = ConcurrentHashMap.newKeySet();
Most call sites remain identical. You no longer need to synchronize iteration because the iterator is weakly consistent and tolerates concurrent modification. Be aware that ordering is not guaranteed; if you relied on HashSet’s encounter order during a single-threaded iteration, rethink that assumption.
Common Mistakes I Still See
- Forgetting to wrap iteration in a synchronized block on the wrapper.
- Holding the lock while performing I/O or long computations, causing contention spikes.
- Keeping a reference to the raw HashSet and mutating it in tests “for convenience.” That breaks thread safety in production too.
- Returning the backing set from a getter instead of the wrapper, allowing callers to bypass the lock.
- Mixing intrinsic locks (synchronized) with explicit
ReentrantLockwithout a documented ordering, leading to deadlocks.
Tooling I Use in 2026
- IDE inspections (IntelliJ, VS Code with Java extensions) now flag unsynchronized iterations on known synchronized wrappers.
- Java Flight Recorder live streaming to Grafana for lock profiling.
- AI code assistants to propose refactors toward concurrent collections when contention metrics cross thresholds.
- Code review bots that check for synchronized block scope and backing set leakage.
Full Example: Building a Thread-Safe Tag Registry With Metrics
Here’s a realistic sample where a web service tracks active user tags and exposes metrics. I keep the synchronized wrapper but measure contention so I know when to upgrade.
import java.util.*;
import java.util.concurrent.atomic.LongAdder;
public class TagRegistry {
private final Set tags = Collections.synchronizedSet(new HashSet());
private final LongAdder lockHits = new LongAdder();
public void addTag(String tag) {
lockHits.increment();
tags.add(tag);
}
public boolean contains(String tag) {
lockHits.increment();
return tags.contains(tag);
}
public List snapshot() {
lockHits.increment();
synchronized (tags) {
return new ArrayList(tags);
}
}
public long getLockHits() {
return lockHits.sum();
}
}
If lockHits climbs too fast relative to QPS, I swap the backing implementation to ConcurrentHashMap.newKeySet() and delete the explicit synchronized block in snapshot, accepting the weakly consistent iterator.
How I Decide Today
1) Start with ConcurrentHashMap.newKeySet for any new concurrent set unless I have a specific reason not to.
2) Keep Collections.synchronizedSet when retrofitting thread safety into legacy code with minimal surface change and low contention.
3) Use CopyOnWriteArraySet for plugin registries and flag stores with rare writes.
4) Reach for ConcurrentSkipListSet when sorted iteration matters.
Deep Dive: What Happens Inside Collections.synchronizedSet
The wrapper is a thin implementation of java.util.SynchronizedSet, an inner class returned by Collections. Each method is public synchronized and delegates to the backing set. The iterator() method returns the backing iterator, but the Javadoc warns callers to manually synchronize while iterating. No extra state is kept beyond the mutex reference and the delegate. Because the wrapper doesn’t change equals/hashCode semantics, you can safely store it in maps or pass it to APIs that expect a Set.
Lock identity and sharing
If multiple wrappers are created over the same backing set, each wrapper has its own monitor. That means synchronizing on wrapper A does not protect code that calls through wrapper B even though both touch the same backing HashSet. Always keep a single wrapper instance and share it. When I see code like Collections.synchronizedSet(set) in two different methods, I extract it into a field and pass that around.
Serialization edge case
Synchronized wrappers are serializable if the backing set is serializable. The monitor is transient (recreated on deserialization). If you rely on a custom external lock object, prefer Collections.synchronizedSet(set, lock) by first wrapping the set in a synchronized map alternative, or simply create your own delegating wrapper that synchronizes on an explicit lock you control.
CopyOnWriteArraySet: When Reads Dominate
CopyOnWriteArraySet uses an internal CopyOnWriteArrayList. On every write it copies the entire array, so O(n) writes but O(1) snapshots for iteration because readers see an immutable snapshot. I reach for it when:
- There are far more reads than writes (feature flag lookups, listeners list).
- Iteration must be snapshot-stable without external locking.
- Element count is modest (tens to low thousands). Beyond that, copy cost bites.
Pitfalls:
- Mutations inside a tight loop cause thrash; prefer batching writes.
- Memory churn shows up in GC metrics. If you see promotion spikes, switch to a concurrent set.
ConcurrentHashMap.newKeySet: My Default Workhorse
This method returns a Set backed by a ConcurrentHashMap with dummy Boolean values. Properties:
- Lock striping and CAS-driven operations enable high concurrency.
- Iterators are weakly consistent: they don’t throw ConcurrentModificationException and may miss or include recent changes.
- Null keys are disallowed (same as ConcurrentHashMap). If you relied on null in a HashSet, clean that up first.
I usually pick it for request-scoped caches, deduplication sets, and any data structure that both reads and writes under load. A mental performance rule of thumb: expect it to scale linearly with core count until your workload becomes memory-bandwidth bound.
Migrating code that expects fail-fast iterators
If callers rely on ConcurrentModificationException as a signal, migrating to ConcurrentHashMap.newKeySet removes that signal. Add explicit versioning or counters instead, or keep a small synchronized wrapper where fail-fast semantics matter.
ConcurrentSkipListSet: Ordered and Concurrent
When I need natural ordering or a custom comparator plus concurrency, ConcurrentSkipListSet is the tool. It’s essentially a concurrent skip list map over dummy values. Lookup and updates are O(log n) but scale better than synchronized wrappers under contention. Iterators are weakly consistent and ordered. I use it for leaderboards, time-ordered queues, and rate-limiter buckets where ordering matters.
Blending Approaches: Layered Sets
Sometimes a single data structure can’t satisfy all needs. Two patterns I use:
- Hot+Cold split: Hot set is a synchronized wrapper for tiny, frequently updated items; cold set is a ConcurrentHashMap.newKeySet for bulk data. Queries check hot first, then cold. This keeps hot lock contention minimal while preserving high throughput for the rest.
- Read snapshot with periodic swap: Writers update a synchronized set; every N milliseconds a scheduler copies it to an immutable Set exposed via an AtomicReference for lock-free reads. Readers tolerate staleness; writers stay simple.
Operational Playbook
Metrics to watch
- Monitor contention:
jfreventsjava.monitor.blockedandjava.monitor.enterto track p95/p99 wait times. - Allocation rate: CopyOnWriteArraySet shows up as spikes in
TLABallocation when writes occur. - GC pauses: rising pauses could indicate excessive copy churn or large HashSet resizes inside a synchronized block.
Production toggles
- Feature flag the implementation: allow swapping between synchronized wrapper and concurrent set at startup. I keep a simple factory controlled by an environment variable so I can flip without redeploying.
- Sampling logs: log once per minute if lock acquisition time exceeds a threshold. Avoid per-call logging; it skews performance.
Deployment considerations
- JVM flags: default biased locking is already removed in recent JVMs; rely on lightweight locking optimizations. No special flags needed.
- Container limits: if CPU is constrained, contention costs magnify. Benchmark under the same core limits as production.
Testing Patterns Beyond Unit Tests
- jcstress scenario: Verify that two concurrent adds and one contains never miss an element. jcstress lets you write a simple Actor test to prove visibility.
- Chaos clock skews: If your code mixes time-based eviction with synchronized sets, simulate clock jumps to ensure the lock isn’t held while sleeping.
- Fuzzing with virtual threads: Using Project Loom virtual threads, spin thousands of lightweight tasks to shake out ordering bugs. Even though virtual threads reduce blocking cost, the synchronized wrapper still serializes access; you’ll see if throughput is acceptable.
Edge Cases and How I Handle Them
- null elements: HashSet allows null; ConcurrentHashMap.newKeySet does not. If null may appear, sanitize inputs or wrap with Optional.
- Equals/hashCode changes: If elements are mutable and their hashCode changes after insertion, even a synchronized wrapper cannot save you. Make elements immutable or avoid using them as keys.
- Iteration while mutating inside the same lock: It’s valid to iterate while adding/removing if you hold the wrapper lock, but be aware of iterator semantics: it will see changes made after iterator creation when using HashSet; not so with CopyOnWriteArraySet.
- Serialization across classloaders: If you serialize a synchronized wrapper and deserialize in another module, ensure the backing class is compatible. Otherwise recreate from raw elements and wrap anew.
- Custom locks: If you need to share a lock with other data structures, write a small delegating set that synchronizes on a provided
Object lockinstead of the wrapper itself.
Performance Benchmarks (Rules of Thumb, Not Exact Numbers)
In my tests on a 16-core laptop (2026 hardware), rough observations:
- Collections.synchronizedSet(new HashSet): uncontended ~10 ns/op for add/contains; under 16-thread write-heavy load, tail latency climbs and throughput flattens quickly.
- ConcurrentHashMap.newKeySet(): scales to cores for mixed read/write until memory bandwidth becomes limiting; adds stay sub-200 ns under contention.
- CopyOnWriteArraySet: reads ~50 ns; writes jump to microseconds proportional to size.
- ConcurrentSkipListSet: log-scale operations; slightly higher base cost but predictable under contention.
These numbers shift per workload and JVM, but the shape of the curves is consistent and guides my choice.
Refactoring Checklist for Legacy Code
When I’m asked to “make it thread-safe” in an older codebase:
1) Search for all references to the HashSet. Replace the field with a synchronized wrapper stored in the same variable.
2) Update iteration sites to wrap with synchronized(set) { ... }.
3) Delete or hide any getter that returns the raw set. Return an unmodifiable view of the wrapper if necessary.
4) Add tests that run concurrent mutations and validate size/contents.
5) Instrument contention: simplest is a Micrometer timer around critical blocks.
6) Document lock ordering and ownership in Javadoc or class-level comments.
7) If contention is measurable after this change, plan a migration to ConcurrentHashMap.newKeySet and relax fail-fast expectations.
Example: Retrofitting a Legacy Cache
A legacy cache stores recently processed IDs in a HashSet and periodically clears it. The minimal retrofit:
public class LegacyCache {
private final Set recent = Collections.synchronizedSet(new HashSet());
public void mark(String id) {
recent.add(id);
}
public boolean seen(String id) {
return recent.contains(id);
}
public void clearAll() {
synchronized (recent) {
recent.clear();
}
}
}
If clearAll is expensive (e.g., coupled with disk cleanup), split it:
public void clearAll() {
Set copy;
synchronized (recent) {
copy = new HashSet(recent);
recent.clear();
}
// cleanup using copy outside the lock
}
Debugging Checklist When Things Still Go Wrong
- Confirm that every iteration is wrapped in a synchronized block on the wrapper, not on some other lock.
- Search for
instanceof HashSetcasts; they often reveal leakage of the backing set. - Look for helper methods that accept
Setbut are passed the raw backing set; wrap the argument before passing. - Inspect thread dumps: if you see many threads blocked on the same monitor, consider a concurrent set alternative.
- Turn on
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssemblyonly if you’re deep into performance investigation; otherwise rely on JFR and async-profiler.
Working with Virtual Threads
Project Loom is mainstream now. Virtual threads make blocking cheaper, but synchronized still blocks the carrier thread while owning the monitor. That means a hot monitor can still throttle throughput. If you adopt virtual threads for massive concurrency (e.g., millions of short tasks), favor lock-free or striped-lock structures like ConcurrentHashMap.newKeySet. A synchronized wrapper remains fine for small pools or low-duty-cycle tasks.
API Design Tips
- Expose methods, not the set. Provide
add,contains,snapshot,sizerather thangetSet. - If you must expose the set, return
Collections.unmodifiableSet(wrapper)so callers can’t bypass the monitor. - Document iterator expectations: “Callers must synchronize on the returned set when iterating” or “Iterator is weakly consistent; may miss updates.”
- Prefer constructor injection of the set for testability. You can inject a stub or a concurrent variant without changing callers.
Security and Correctness Considerations
- Denial of service via large elements: A synchronized wrapper does not prevent a malicious caller from adding huge objects. Consider size caps and defensive copies.
- Hash collision attacks: If keys are attacker-controlled, consider using
LinkedHashSetorConcurrentHashMapwith a good hash spread; synchronized wrapper doesn’t change collision handling. - Fail-fast expectations: Some security-sensitive code relies on ConcurrentModificationException to detect tampering. Switching to weakly consistent iterators removes that signal; add explicit modification counters when needed.
Mini Playbook: Which Set Do I Choose?
- Prototype or quick fix, low traffic: Collections.synchronizedSet(new HashSet())
- High read, low write, want snapshot iteration: new CopyOnWriteArraySet()
- High concurrency, unordered: ConcurrentHashMap.newKeySet()
- High concurrency, ordered: new ConcurrentSkipListSet()
- Need to share a lock with other structures: custom wrapper with shared mutex.
Measurement-Driven Iteration
I treat synchronization choices as hypotheses. I instrument, measure, and then decide whether to keep or swap. Typical signals that push me off the synchronized wrapper:
- p99 latency spikes on endpoints that touch the set.
- CPU utilization low but request latency high (classic monitor contention shape).
- Thread dumps show BLOCKED on the wrapper monitor.
- Lock profiling shows long hold times due to work inside the synchronized region.
Appendix: DIY Synchronized Wrapper (When You Need a Custom Lock)
public final class LockedSet implements Set {
private final Set delegate;
private final Object lock;
public LockedSet(Set delegate, Object lock) {
this.delegate = Objects.requireNonNull(delegate);
this.lock = Objects.requireNonNull(lock);
}
@Override public int size() { synchronized (lock) { return delegate.size(); } }
@Override public boolean isEmpty() { synchronized (lock) { return delegate.isEmpty(); } }
@Override public boolean contains(Object o) { synchronized (lock) { return delegate.contains(o); } }
@Override public Iterator iterator() {
// caller must still synchronize when iterating
return delegate.iterator();
}
@Override public boolean add(E e) { synchronized (lock) { return delegate.add(e); } }
@Override public boolean remove(Object o) { synchronized (lock) { return delegate.remove(o); } }
@Override public void clear() { synchronized (lock) { delegate.clear(); } }
// implement remaining methods similarly
}
This pattern lets you coordinate multiple data structures on one explicit lock, helpful in composite objects.
Closing Thoughts
Thread-safe collections aren’t glamorous, but they save nights of production firefighting. Wrapping a HashSet with a synchronized view is still a valid move in 2026, especially when you need a quick, low-risk fix. The important habits: always interact through the wrapper, guard iteration with the same lock, and keep critical sections short. When load grows, migrate to concurrent collections that scale without a single monitor. Instrumentation is cheap—measure contention early and often. If you remember one thing from my painful night years ago, let it be this: a one-line wrapper is easy, but the discipline around how you use it is what keeps your data correct.


