Race Conditions and Thread Safety in Java
-
Last Updated: September 25, 2026
-
By: javahandson
-
Series

Thread safety in Java explained simply: what a race condition is, the read-modify-write and check-then-act patterns, immutability, and a map of every tool that fixes it.
Your code works fine on your laptop. Then it goes live, gets busy, and starts giving wrong answers. A total is off by one. A value appears twice. Welcome to the world of thread safety in Java, where two threads touching the same data can quietly break your program.
The root cause usually has a name: a race condition. Two threads race to read and change the same value. The winner depends on timing, and timing is never the same twice. So the bug shows up now and then, never on demand.
Here is the tricky part. Nothing crashes. There is no error to catch. The program just returns a wrong result and keeps going. That silence is what makes these bugs so hard to trust and so hard to find.
But do not worry. This problem is well understood, and Java gives you a full toolbox to fix it. First you need to see the problem clearly. Then you can pick the right tool for each case.
In this guide, we will name the core problem and map the tools that solve it. We keep the language simple and the examples short. Each fix gets a quick summary and a pointer to its own deep-dive article.
You only need to know what a thread is and what a shared field is. If you have written a class with a couple of fields, you are ready. Here is the road ahead.

Let us start with the core problem. A race condition is the reason most thread bugs exist. Once you see it clearly, everything else falls into place.
Imagine a shared notebook with a balance written in it. Two people want to add money at the same time. Both read the current number, both do the math on paper, and both write their new number back.
Say the balance is 100. Person A reads 100 and plans to write 150. Person B also reads 100 and plans to write 120. Whoever writes last wins, and the other change is simply erased.
That erased change is the heart of a race condition. Two actors worked on the same value at the same time. Their steps overlapped, so one result quietly vanished. Threads do exactly this with shared fields.
In Java, a race condition happens when two threads access the same data and at least one of them writes. The outcome then depends on the exact order of their steps. That order is decided by the thread scheduler, not by you.
The scheduler can pause a thread at almost any point. It might stop Thread A midway and let Thread B run. Later it swaps them back. Your code has no say in when these swaps happen.
So the same program can behave differently on each run. Sometimes the timing lines up badly and you get a wrong answer. Other times it works by pure luck. That is why race bugs feel random.
Let us trace the classic example. Two threads both run balance = balance + 1. The starting balance is 100, and we expect 102 at the end.
This is called a lost update. Both threads read the same starting value. Both added one to it. But one write stamped over the other, so an increment disappeared.
Not every race is a lost update. Some are about seeing an object too early. This one is called unsafe publication, and it surprises many developers.
Say one thread builds an object and stores it in a shared field. Another thread reads that field and starts using the object. The catch is timing again. The reader might see the reference before the object’s fields are fully set.
So the reader gets a half-built object, with some fields still empty. Nothing crashes, but the data is wrong. To publish an object safely across threads, you need proper coordination, which we explore in the volatile and synchronization articles.
| 💡 Interview Insight A common interview opener is “what is a race condition?” Give the lost-update story: two threads read the same value, both change it, and one write overwrites the other. Add that the result depends on thread scheduling, which you cannot control. A concrete example beats a textbook definition every time. |
Most race conditions come from just two code patterns. Learn to spot them, and you will catch bugs before they ship. Let us look at both.
This is the lost update we just saw. A thread reads a value, changes it, and writes it back. The three steps look like one, but they are not.
// Looks atomic, but it is really three steps. count++; // read count, add 1, write count // The same shape shows up here too. total = total + item.getPrice(); views = views + 1;
Between the read and the write, another thread can slip in. It reads the same old value and works from it. Now two threads compute from the same starting point, and one result is lost.
This pattern is just as common and just as sneaky. A thread checks a condition, then acts on it. But the condition can change between the check and the act.
// Check-then-act: the classic lazy init bug.
if (instance == null) { // check
instance = new Config(); // act
}
// Another example: check the map, then put.
if (!map.containsKey(key)) { // check
map.put(key, value); // act
}Two threads can both pass the check at the same time. Both see instance as null. So both create a new object, and you end up with two instances instead of one.
The map example fails the same way. Both threads see the key missing. Both then call put, and one value overwrites the other. The check told the truth, but only for a moment.
Both patterns share one flaw. There is a gap between the steps. In that gap, another thread can change the world under your feet.
We call these compound actions. A compound action is several small steps that must happen as one. If they can be split apart, a race can sneak in. The fix is to make the whole action atomic.
What “Atomic” Means Here
Atomic means all-or-nothing, with no visible middle. No other thread can catch the action half-done. It either has not started or is fully finished.
So making count++ atomic means no thread can read count between the read and the write. The whole read-modify-write becomes one indivisible step. That is exactly what our tools will provide.
| 💡 Interview Insight Interviewers love to ask you to spot the bug. If you see count++ or an if-check followed by an action on shared data, call it out. Name the pattern: read-modify-write or check-then-act. Then say it needs to be atomic. That vocabulary signals real concurrency experience. |
People throw the word thread-safe around a lot. But what does it really mean? Let us pin it down in plain words.
A class is thread-safe if it behaves correctly when many threads use it at once. It gives the right answer no matter how the threads are scheduled. You do not need extra locking on your side to make it work.
That last point matters a lot. A thread-safe class handles its own coordination inside. The caller just uses it and trusts it. The safety is built in, not bolted on later.
Thread safety only becomes an issue under three conditions. Remove any one of them, and the danger goes away. This is a powerful way to think about the problem.
So if data is never shared, you are safe. If data never changes, you are safe. And if every change is properly coordinated, you are safe. Every fix in this article attacks one of these three.
Thread safety is not just on or off. Classes fall along a range. Knowing the levels helps you read documentation correctly.
Most everyday classes are not thread-safe, and that is fine. Single-threaded code never needs it. The label only matters once threads share the object.
Making things thread-safe has a cost. Locks make threads wait, which can slow you down. Extra coordination adds complexity to your code.
So do not make everything thread-safe by reflex. Add safety only where threads truly share mutable data. Everywhere else, the plain and fast version is the better choice.
| 💡 Interview Insight A sharp answer to “what makes code thread-safe?” names the three ingredients: shared, mutable, and unsynchronized state. Then explain that you fix it by removing any one, often by not sharing, by making data immutable, or by synchronizing access. Framing it this way shows you understand the root cause, not just the keywords. |
Thread safety really has two separate problems inside it. Many developers know one but miss the other. Understanding both is what makes your fixes correct.
Atomicity is about steps not being split apart. A compound action like count++ must run as one unit. If another thread breaks in between the steps, you get a lost update.
This is the problem we saw with read-modify-write. The fix is mutual exclusion or an atomic operation. Only one thread should be inside the action at a time.
Visibility is a different issue. It is about one thread seeing another thread’s changes. A write by Thread A may sit in a CPU cache, unseen by Thread B for a while.
So even a single, simple write can cause trouble. Thread B keeps reading a stale value. The write happened, but it never became visible. That is a visibility bug, not an atomicity bug.
Here is the key insight that ties it together. A truly thread-safe fix must handle both problems at once. Solving one alone can leave the other open.
For example, volatile fixes visibility but not atomicity. A synchronized block fixes both, which is why it is so widely used. When you pick a tool, ask which of the two problems you actually have. We go deeper into visibility in The volatile Keyword in Java.
| 💡 Interview Insight A standout interview point is separating atomicity from visibility. Say atomicity keeps compound steps together, while visibility makes one thread’s write seen by others. Add that volatile gives visibility only, but synchronized gives both. Most candidates blur these two, so drawing the line clearly stands out. |
Here is the easiest path to thread safety in Java. Make your objects immutable. An immutable object never changes after you create it.
Remember the three ingredients of danger: shared, mutable, and unsynchronized. Immutability removes the mutable part completely. If data never changes, threads cannot corrupt it.
So many threads can read the same immutable object freely. There is nothing to coordinate, because there are no writes. No locks, no atomics, no worries. This is why immutability is so loved in concurrent code.
Making a class immutable follows a short recipe. Stick to these rules, and your object stays safe by design.
Let us make a simple Money class. Once created, its amount never changes. Any “change” returns a brand-new object instead.
public final class Money {
private final long amount; // final: set once, never changes
private final String currency;
public Money(long amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public long getAmount() { return amount; }
public String getCurrency() { return currency; }
// No setters. "Adding" returns a new Money object.
public Money add(long more) {
return new Money(this.amount + more, this.currency);
}
}Notice the add method. It does not touch the existing object. Instead, it builds a fresh Money with the new amount. The original stays exactly as it was, so every thread reading it stays safe.
The JDK is full of immutable types. You use them every day without thinking. This is a big reason they feel so natural.
For example, String never changes; every edit makes a new String. The wrapper types like Integer and Long are immutable too. So are the modern date types such as LocalDate and LocalDateTime.
Immutability has a small price. Every change creates a new object. For huge volumes of changes, that can add memory pressure.
For most code, though, this cost is tiny and worth it. The safety and simplicity you gain are huge. So reach for immutability first, and optimize only if profiling tells you to.
| 💡 Interview Insight When asked “how do you make a class thread-safe without locks?”, lead with immutability. List the recipe: final fields, no setters, a final class, and defensive copies for mutable parts. Add that String and the wrapper types work this way. This answer shows you prefer simple design over heavy locking. |
Sometimes you must share mutable state, and immutability is not enough. Then you reach into Java’s toolbox. Here is a map of the main tools and when to use each.
Each tool below gets a short summary and a link to its own deep-dive. Think of this as your decision guide. Pick the lightest tool that solves your exact problem.
The synchronized keyword lets only one thread run a block at a time. Others wait their turn. This makes a whole compound action atomic and also handles visibility.
Use it when several steps must run as one unit. It is the classic fix for check-then-act and multi-field updates. We cover it fully in Article 6 — Thread Synchronization in Java.
The volatile keyword makes sure every thread sees the latest value of a field. It fixes visibility, but it does not make compound actions atomic. So it is perfect for simple flags.
Use it for a stop flag or a “data is ready” marker with one writer. Do not use it for counters. We cover it fully in The volatile Keyword in Java.
Classes like AtomicInteger and AtomicLong make read-modify-write atomic without locks. A single call does the read, change, and write as one step. So a counter finally counts correctly.
Use them for counters, sequence numbers, and simple accumulators. They are fast because they avoid blocking. We cover them fully in Article 21 — Atomic Variables in Java.
Java ships thread-safe collections built for heavy sharing. ConcurrentHashMap, CopyOnWriteArrayList, and the blocking queues are the stars. They handle all the locking inside.
Use them instead of wrapping a plain HashMap or ArrayList by hand. They are faster and safer than doing it yourself. We cover these in the Collections Framework series.
The ReentrantLock class gives you more control than synchronized. You can try for a lock, set a timeout, or make locking fair. This extra power helps in tricky cases.
Use it when synchronized is too rigid for your needs. It is also the tool for avoiding deadlock with timeouts. We cover it in Article 16 — Locks in Java: ReentrantLock vs synchronized.
A Quick “Which Tool When” Guide

| 💡 Interview Insight A great interview answer maps tools to problems. Say volatile is for visibility, atomics are for lock-free counters, synchronized and locks are for compound actions, and concurrent collections are for shared data structures. Then add that immutability avoids the whole problem. This shows you know the whole landscape, not one hammer. |
Here is a fix people often forget. If data is never shared, it never needs locks. This idea is called thread confinement, and it is beautifully simple.
Thread confinement means keeping data inside a single thread. No other thread can touch it, so no race can happen. You remove the “shared” ingredient entirely.
This is often the cleanest option of all. There is no coordination to get wrong. If only one thread ever sees the data, that data is automatically safe.
Every local variable lives on its own thread’s stack. No other thread can reach it. So local variables are thread-safe by nature.
This is why plain methods with only local variables never have race conditions. The trouble starts only with shared fields. So prefer locals over fields whenever you can.
Sometimes you want a field that each thread owns privately. The ThreadLocal class gives you exactly that. Each thread sees its own separate copy.
A common use is a non-safe helper like SimpleDateFormat. You give each thread its own copy, so they never clash. It is a neat way to reuse an unsafe object safely.
| 💡 Interview Insight If asked “how can you avoid synchronization altogether?”, mention two paths: immutability and thread confinement. Explain that local variables are confined to one thread and are always safe. Add ThreadLocal for a private copy per thread. This shows you know that the best lock is often no lock at all. |
Race bugs are famous for slipping past tests. Understanding why helps you catch them earlier. It also explains those “works on my machine” moments.
A race needs an exact overlap between threads. A quick unit test rarely hits that window. So the test passes, and the bug hides in plain sight.
Production is different. Under heavy load, the overlap happens often. That is why a race can lurk for months and then strike on your busiest day.
Add a log line or attach a debugger, and the timing shifts. Often the bug then vanishes, only to return once you remove your changes. This makes race conditions true Heisenbugs, which hide when observed.
You cannot fully prove concurrency by testing alone. But a few habits raise your chances of catching bugs. Use them alongside careful design.
So testing helps, but design comes first. Prevent races by controlling shared state from the start. That is far more reliable than hoping a test catches them.
| 💡 Interview Insight If asked “how do you test for race conditions?”, be honest that testing cannot prove correctness here. Then mention stress testing with many threads, static analysis with SpotBugs, and the jcstress harness. Finish by stressing that good design beats testing for concurrency. That balanced answer reads as real experience. |
A few mistakes cause most thread-safety bugs. Watch for these in your own code. Each one has a simple fix.
Do not guess whether a class is thread-safe. Check its documentation instead. Many common classes, like ArrayList and HashMap, are not safe by default.
This one bites a lot of teams. A plain HashMap can corrupt itself under concurrent writes. In older Java it could even spin into an infinite loop. Use ConcurrentHashMap for shared maps.
Synchronization only works if all threads lock on the same object. If two threads lock different objects, the guard does nothing. So be consistent about which lock guards which data.
We saw this pattern earlier, and it is worth repeating. An if-check followed by an action on shared data is a classic bug. Wrap both steps together, or use an atomic method like putIfAbsent.
Let us pull it all together. A race condition happens when threads touch shared, mutable data without coordination. The result then depends on timing, so bugs appear at random and hide during testing.
Most races follow two patterns: read-modify-write and check-then-act. Both have a gap between steps where another thread can slip in. The fix is to make the whole action atomic.
Thread safety in Java means a class stays correct no matter how threads are scheduled. The easiest way to get there is immutability, which removes change entirely. Thread confinement helps too, by removing sharing.
When you must share mutable state, reach into the toolbox. Use volatile for flags, atomics for counters, synchronized or locks for compound actions, and concurrent collections for shared structures. Pick the lightest tool that fits, and your concurrent code stays both correct and fast.
Thread safety is a favourite in Java interviews. Short, precise answers work best. The most common questions are delivered below as an FAQ block.
A: A race condition happens when two threads access the same data at the same time and at least one writes. The result depends on the exact timing of their steps, which the scheduler controls. A common outcome is a lost update, where one thread’s write overwrites another’s.
A: A class is thread-safe if it behaves correctly when many threads use it at once, with no extra locking needed by the caller. Thread safety is only a concern when state is shared, mutable, and unsynchronized. Remove any one of those three, and the danger goes away.
A: Atomicity keeps a compound action’s steps together, so no thread breaks in mid-way. Visibility makes one thread’s write seen by other threads. volatile gives visibility only, while synchronized gives both. A correct fix usually needs to handle both problems.
A: Immutable objects never change after creation, so they remove the mutable ingredient of thread safety. Many threads can read them freely with no locks. You make a class immutable with final fields, no setters, a final class, and defensive copies for any mutable field.
A: Pick the lightest tool that fits. Use volatile for a simple flag, atomic classes for counters, synchronized or locks for compound actions, and concurrent collections for shared maps and lists. Better still, use immutability or thread confinement to avoid the problem entirely.
A: No. A plain HashMap is not thread-safe and can corrupt itself under concurrent writes. For shared access, use ConcurrentHashMap, which handles all the locking internally. It is both safer and faster than wrapping a HashMap by hand.