On This Page
On JDK 21, with the virtual-thread scheduler pinned to one carrier thread, four virtual threads that each do Thread.sleep(300) finish in 331 ms. Put the same sleep inside a synchronized block and the total is 1,219 ms. Swap the sleep for wait(300) inside the block and you get 1,227 ms.
Same 300 ms pause, three different answers. That's the sleep vs wait in Java question wearing a disguise, and it's why I stopped trusting the one-line answer ("sleep keeps the lock, wait releases it"). The line is true. It's also about a fifth of what you need.
Which one lets go of the lock?
Thread.sleep() pauses the current thread and keeps every monitor it holds. Object.wait() pauses the current thread, releases the monitor of the one object you called it on, and puts you in that object's wait set until someone notifies you, interrupts you, or the timeout runs out. That's the whole difference in mechanism. Everything else below falls out of it.
Which means the two calls aren't really rivals. sleep answers "I want to do nothing for a while." wait answers "I can't continue until some condition becomes true, and I'll step aside so whoever can make it true gets in." People mix them up because both show up as a thread that isn't running, and both throw InterruptedException.
I wrote this one after a review where someone replaced a polling sleep(50) loop with wait(50) "to be safe." It worked, so nobody questioned it. It also did nothing useful. Took me longer than I'd like to admit to explain why. The explanation, with numbers, is below; I ran everything on OpenJDK 21.0.12.1, 2 vCPU x86_64. JDK 25 is the latest GA according to openjdk.org/projects/jdk, and I did not run on it.
The table I wish I'd had
Thread.sleep |
Object.wait |
|
|---|---|---|
| Monitors held while paused | Keeps all of them | Releases the one it was called on; keeps any others |
| Needs to hold a monitor to call | No | Yes, on that object, or IllegalMonitorStateException |
| Who can end the pause early | Only an interrupt | notify, notifyAll, interrupt, timeout, or a spurious wakeup |
| Zero argument | sleep(0) returns almost at once |
wait(0) waits with no timeout, forever |
| State in a thread dump | TIMED_WAITING (sleeping) |
TIMED_WAITING (on object monitor) or WAITING (on object monitor) |
| Return value | Returns normally after the time | void: no hint whether it timed out or was notified |
| After an interrupt | Flag cleared, InterruptedException |
Flag cleared, monitor re-acquired first, then the exception |
| Virtual thread on JDK 21 | Unmounts, unless inside synchronized |
Pins the carrier inside synchronized, but the scheduler adds a carrier (see below) |
| Where it lives | Thread, static (sleep(Duration) since 19) |
Object, instance, on every object |
| Typical honest use | Backoff, pacing, tests that need time to pass | Guarded waiting inside a while loop (better: a Condition or a BlockingQueue) |
The Javadoc for wait says its timeout holds "more or less." sleep says it's "subject to the precision and accuracy of system timers and schedulers." Neither gives you a hard real-time number.
Six differences, each with the code that shows it
1. Who else can get in. This is the one that causes outages.
synchronized (cache) {
cache.wait(2000); // other threads can enter synchronized(cache) right now
}
synchronized (cache) {
Thread.sleep(2000); // every thread that wants this monitor queues behind you
}
Timeline (monitor = cache)
sleep: T1 |#### locked, paused 2000 ms ####|
T2 . . . BLOCKED . . . . . . . . . |enter|
wait: T1 |lock|--- waiting, monitor free ---|re-lock|
T2 |enter, work, exit|
2. Who can wake you. I sent notifyAll() at about 100 ms to a thread in sleep(1500) and to one in wait(1500). The sleeper slept 1,500 ms. The waiter woke after 100 ms. sleep has no wake-up channel except interruption, because nothing is waiting for anything.
3. wait needs the monitor, sleep doesn't.
wait outside synchronized -> java.lang.IllegalMonitorStateException: current thread is not owner
sleep outside synchronized -> fine
4. Zero means opposite things. Thread.sleep(0) returned in 1 ms. A thread in wait(0) was still WAITING 800 ms later and ended only because I interrupted it. If you compute a remaining timeout and it lands on 0, wait(remaining) can hang a thread forever. Guard it, or use System.nanoTime() deadlines like the example in the Object.wait Javadoc.
5. wait tells you nothing about why it returned. I ran wait(500) twice, once with nobody notifying and once with a notifyAll at 100 ms. Both returned normally; one after 500 ms, one after 100 ms. The method is void. The condition in your while loop is the only way to find out, which is the whole point of that loop. The rules for writing it properly live in wait(), notify() and notifyAll() in Java, so I'll leave it there.
6. Interrupt behaves almost the same, with one extra step. Both calls wake at once, both clear the interrupt flag, both throw. With wait, the thread first re-acquires the monitor, so inside the catch block Thread.holdsLock(lock) printed true. Same as sleep, but for a different reason: sleep never let go, wait took it back. If another thread is holding that monitor when you get interrupted, you sit BLOCKED before the exception arrives. What to do in that catch block is covered in the interruption article.
One lab, both calls, and the dumps
Here's the one I used for items 1 and 2. Pinned: OpenJDK 21.0.12.1 (Ubuntu build), 2 vCPU, x86_64. Save as Lab.java:
import java.util.concurrent.CountDownLatch;
public class Lab {
static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
String how = args[0]; // "sleep" or "wait"
CountDownLatch inside = new CountDownLatch(1);
Thread holder = new Thread(() -> {
synchronized (LOCK) {
inside.countDown();
try {
if (how.equals("sleep")) Thread.sleep(2000); // keeps LOCK
else LOCK.wait(2000); // releases LOCK
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}, "holder");
holder.start();
inside.await();
Thread.sleep(100); // make sure holder is parked
long t0 = System.nanoTime();
Thread second = new Thread(() -> {
synchronized (LOCK) {
System.out.println("second got monitor after "
+ (System.nanoTime() - t0) / 1_000_000 + " ms");
}
}, "second");
second.start();
second.join();
holder.join();
}
}
java Lab.java sleep
java Lab.java wait
second got monitor after 1900 ms # sleep: it waited out the holder's remaining 1,900 ms
second got monitor after 3 ms # wait: in at once
Both numbers repeated on every run I made. This isn't a race, so it's the one lab in this series that doesn't need a start gate or 10,000 iterations. The 100 ms head start is only there so the holder has actually reached its pause.
To see the dump, make the pause 10 seconds, wait a second, and run jcmd <pid> Thread.print. With sleep:
"holder" ... waiting on condition
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep0(Native Method)
at java.lang.Thread.sleep(Thread.java:509)
- locked <0x00000000a851d398> (a java.lang.Object)
"second" ... waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
- waiting to lock <0x00000000a851d398> (a java.lang.Object)
With wait, the holder looks like this and second is gone, because it already finished:
"holder" ... in Object.wait()
java.lang.Thread.State: TIMED_WAITING (on object monitor)
at java.lang.Object.wait0(Native Method)
- waiting on <0x00000000a851d398> (a java.lang.Object)
at java.lang.Object.wait(Object.java:366)
- locked <0x00000000a851d398> (a java.lang.Object)
Look at that last line. The dump says waiting on and locked with the same address, for a monitor the thread has just released. My guess is the locked line comes from the frame's lock record rather than from current ownership; I haven't confirmed that in the HotSpot source. If you're hunting a lock owner in a dump, BLOCKED threads with waiting to lock <addr> are the evidence. A locked <addr> line sitting under Object.wait is not. I read it wrong the first time.
My first version of this lab had second call notifyAll() when it got in. That woke the holder, so the holder had already finished by the time I looked and my dump showed neither thread. I spent a while convinced jcmd was broken. It wasn't. (The second time it was a grep buffering the PID line I was waiting for. Pipes and grep buffer; files don't.)
If your second thread shows up BLOCKED and you expected a wait, check who's inside the monitor with ThreadMXBean.getThreadInfo(id).getLockOwnerName(). In the sleep run that returned holder. That's contention, with a named culprit, not a deadlock.
Where the wrong pick shows up in production
Sleeping while holding a lock. A cache loader retries a failed fetch with Thread.sleep(500) inside synchronized (cache). When the backend is slow, every reader of that cache queues behind the sleeper. Dashboards show request latency rising by multiples of 500 ms, CPU near idle, and a pile of BLOCKED threads all pointing at one address. CPU at 2% and every thread paused is what a sleep-under-lock looks like.
Polling with sleep where you wanted a signal. I measured 40 rounds each of "consumer notices a flag flip", on the 2-vCPU box. Polling with sleep(50): mean 23,511 µs, worst 47,401 µs. wait/notify: mean 184 µs, worst 778 µs. Roughly 130 times slower on average, and the polling version wakes up 20 times a second to learn nothing. For the replacement I mentioned at the top: wait(50) in a loop with no notify anywhere is the same polling loop with a different name. It behaves identically, burns the same wakeups, and now throws IllegalMonitorStateException if somebody moves it outside the block.
sleep as a test fix. Adding Thread.sleep(100) until a flaky test goes green makes it pass on your laptop and fail on a 2-core CI runner. The race is still there. Use a latch or a Future from java.util.concurrent instead.
Virtual threads and the 1,227 ms. Back to the opening. With the scheduler pinned (-Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1):
sleep-outside: 4 virtual threads x 300 ms -> total 331 ms
sleep-inside: 4 virtual threads x 300 ms -> total 1219 ms
wait-inside: 4 virtual threads x 300 ms -> total 1227 ms
Inside synchronized, both calls pin the single carrier on JDK 21, so the four pauses run one after another. Now the default scheduler, 2 carriers on this machine, each thread with its own lock:
sleep-inside: total 621 ms
wait-inside: total 320 ms
Same pinning, different outcome. wait finished in one pause's time, so something added carriers; sleep ran two at a time. My reading is that Object.wait tells the scheduler it's about to block so it can compensate with another carrier, and sleep inside a monitor doesn't. That's inferred from these numbers, not from reading the source, so check it before you quote it. JEP 491 describes Object.wait() now unmounting a virtual thread, and takes pinning on synchronized away for both. The JEP page doesn't name a release; I'm taking JDK 24 from the release notes and haven't run 24 or 25. The longer story is in the virtual-thread pinning post.
A decision you can make in ten seconds
| You want to... | Use |
|---|---|
| Wait for a condition another thread will make true | wait in a while loop. Or a Condition, BlockingQueue, CountDownLatch; they exist so you don't hand-write this |
| Back off before a retry, holding no locks | sleep (or sleep(Duration) since JDK 19) |
| Pause for a fixed time and also want to be woken early | wait(timeout) in a loop, or Future.get(timeout) |
| Run something later or repeatedly | A ScheduledExecutorService, not a loop with sleep |
| Make a test pass | Neither. Use a latch |
| Pause while holding a lock | Don't. Release first, then sleep |
One question settles most of these: is anyone going to tell me when to stop? If yes, you want wait or one of its wrappers. If the answer is "the clock," you want sleep, and you should be holding nothing.
The answer I'd give in an interview, and what comes next
"sleep pauses the thread and keeps its monitors, so anyone needing that lock stays BLOCKED. wait must be called while holding the object's monitor, releases that monitor while it waits, and is woken by notify, notifyAll, interrupt or timeout. In a dump, sleep is TIMED_WAITING (sleeping) and wait is (on object monitor). Both clear the interrupt flag when they throw. And wait always sits in a while loop."
Expect this next: "What does wait(0) do?" (Forever. Not zero.) Or: "The waiter was notified. Is it running?" Not yet; it goes through BLOCKED until the notifier leaves its synchronized block. That one is in the thread states article with a dump.
The first lab gave me the answer in ten minutes. The dump reading, the virtual-thread numbers and the wait(0) edge ate the rest of a long day, which is why it's all written down here.
Questions that keep coming up
Does Thread.sleep() release a lock in Java? No. The Javadoc says the thread "does not lose ownership of any monitors." Anything else waiting for those monitors stays BLOCKED for the whole sleep.
Does wait() release all my locks? Only the monitor of the object you called it on. Any other synchronized object you hold stays locked while you wait, which is a classic way to build a deadlock.
Can I call wait() without synchronized? No. You'll get IllegalMonitorStateException. You must hold the object's monitor, from a synchronized block or method on that same object.
Which is better in virtual threads? On JDK 21, avoid both inside synchronized. On JDK 24 and later, JEP 491 changes this, but I measured only 21. Sleep outside any lock unmounts cleanly; I saw that on 21.