Your exam is about Java concurrency — threads sharing data safely. This guide builds up from "what is a thread" to "design a thread-safe print spooler." Every section uses only what you've already seen. Look for the purple Memory Anchor boxes — they'll give you jokes, mnemonics, and vivid images to pin these concepts in your brain.
In C, you write code and it runs line by line. With concurrency, multiple threads run the same code at the same time on shared data. And that's where everything breaks.
// Looks innocent. Is actually broken. int balance = 100; // Thread A: // Thread B (at the exact same time): balance = balance + 50; balance = balance - 30; // EXPECTED: 100 + 50 - 30 = 120 // ACTUAL: could be 150, 70, or 120 (random!)
Why? Because balance = balance + 50 is actually three steps: (1) read balance, (2) add 50, (3) write back. If Thread B reads between steps 1 and 3 of Thread A, it sees the OLD value. This is a race condition.
The entire Java concurrency toolkit exists to solve this one problem: how do you let multiple threads share data without corrupting it?
You already know how to code. Here's what's different in Java.
public class Main { private int count = 0; // instance variable private final int MAX = 10; // final = const public void increment() { count++; } public int getCount() { return count; } public static void main(String[] args) { // entry point Main obj = new Main(); obj.increment(); System.out.println(obj.getCount()); // prints 1 } }
| C++ | Java | Note |
|---|---|---|
int* ptr | No pointers | Everything is a reference (like shared_ptr) |
delete obj | Garbage collected | No manual free, ever |
#include <vector> | import java.util.*; | |
std::cout << | System.out.println() | |
cin >> x | Scanner sc = new Scanner(System.in); sc.nextInt(); | |
const int | final int | Also works on references (can't reassign, but can mutate object) |
virtual void f() = 0 | interface | Interface = pure virtual class |
Templates <T> | Generics <T> | Similar syntax but no specialization |
enum { A, B } | enum Status { A, B } | Java enums are full objects (can have methods!) |
// Interface: contract that classes must implement public interface Spooler { long submitBlocking(PrintJob job) throws InterruptedException; boolean cancel(long jobId); } // Enum: fixed set of constants public enum JobStatus { QUEUED, PRINTING, DONE, CANCELLED } // Record: immutable data class (like a struct with no setters) public record SpoolerMetrics(long totalSubmitted, long totalCompleted) {} // Regular class with validation public final class PrintJob { private final String owner; private final int pages; public PrintJob(String owner, int pages) { if (pages <= 0) throw new IllegalArgumentException("pages must be > 0"); this.owner = owner; this.pages = pages; } public String owner() { return owner; } public int pages() { return pages; } }
A thread is an independent line of execution. Your program starts with one thread (the main thread). You can create more.
// Create a thread using a lambda (Runnable) Thread t = new Thread(() -> { System.out.println("Hello from a different thread!"); }); t.start(); // launches a NEW thread that runs the lambda t.join(); // main thread WAITS for t to finish
t.start() = creates a new thread and runs the code there.t.run() = calls the method normally in the CURRENT thread. No new thread is created.start(). Using run() defeats the entire purpose.
start() = hiring a new employee and giving them the task.run() = doing it yourself while the employee stands there watching. Don't be that manager.
| Method | What it does |
|---|---|
t.start() | Launch the thread (can only be called ONCE per Thread object) |
t.join() | Block the current thread until t finishes |
t.interrupt() | Politely ask t to stop (sets a flag; doesn't force-kill) |
Thread.sleep(ms) | Current thread sleeps for ms milliseconds |
Thread.currentThread() | Returns the Thread object for whoever is running right now |
The fix for race conditions: make sure only one thread at a time can touch the shared data. That's what synchronized does.
class BankAccount { private int balance = 100; synchronized void deposit(int amount) { // grabs lock on "this" balance += amount; // only one thread in here at a time } // releases lock on "this" synchronized void withdraw(int amount) { // same lock as deposit! balance -= amount; } synchronized int getBalance() { // yes, reads need locking too return balance; } }
When you mark a method synchronized, Java uses this (the object itself) as the lock. All synchronized methods on the same object share the same lock. So if Thread A is inside deposit(), Thread B can't enter withdraw() on the same object until A finishes.
void doStuff() { // ... some thread-safe code ... synchronized (this) { // same as synchronized method balance += 50; // only this part is locked } // ... more thread-safe code ... }
Block-level is useful when only PART of your method needs protection. Less time holding the lock = less waiting for other threads.
synchronized(this), you're using YOUR object's lock. All synchronized methods on the same object = same lock = same bathroom = one at a time.
synchronized is reentrant: if Thread A holds the lock and calls another synchronized method on the same object, it doesn't deadlock (it already has the key)synchronized too! Without it, you might see a stale cached valuesynchronized prevents two threads from being in the critical section at the same time. But what if a thread gets in and finds the data isn't ready yet? (Queue is empty, buffer is full, etc.) It could spin-wait in a loop, but that wastes CPU. Enter: wait().
wait()). When someone refills the paper, they shout "PAPER'S BACK!" (notifyAll()). Everyone on the bench wakes up, and ONE person goes back in to check.class SharedResource { private int value = 0; synchronized void waitForValue(int target) throws InterruptedException { while (value != target) { // 1. Check condition wait(); // 2. Not ready? Release lock, sleep } // 3. Woke up - loop back, CHECK AGAIN // Condition is true AND we hold the lock. Safe to proceed. } synchronized void setValue(int v) { value = v; notifyAll(); // Wake ALL sleeping threads } }
while, NEVER ifWhy can't you use if (condition) wait() instead of while?
notifyAll). You wake up and rush to the kitchen. But three other people woke up too, and they ate all the pizza before you got there. With if, you'd start eating air. With while, you check the box again — empty? Back to sleep.
There are two reasons while is mandatory:
while handles it; if doesn't.notifyAll(), NEVER notify()notify() = texting ONE random friend "pizza's here." If that friend is already full (wrong thread, wrong condition), nobody comes. Pizza goes cold. Deadlock.notifyAll() = posting in the group chat. Everyone checks. The hungry one eats. The full ones go back to sleep. Always safe.
wait() and notifyAll() MUST be inside synchronizedYou can't step out of a bathroom you never entered. Calling wait() outside synchronized throws IllegalMonitorStateException.
waitForValue(42)wait().setValue(42)notifyAll() — wakes Thread Awhile: value (42) != 42? No! Exits loop.Here's a subtle problem: even reading shared data across threads can break without proper synchronization.
Each CPU core has its own cache — a private copy of recently-used memory. When Thread A writes closed = true, that value might sit in Core 1's cache and never make it to main memory. Thread B on Core 2 keeps reading closed = false from its own cache. Forever.
volatile, they only look at their notepad — one adds "milk" to the whiteboard, the other never sees it because they're reading their stale copy.volatile = "ALWAYS check the whiteboard. Never trust your notepad."
private volatile boolean closed = false; // written by Thread A // Thread B (dispatcher loop): while (!closed) { // without volatile, this might loop FOREVER // ... do work ... } // Thread A (shutdown): closed = true; // volatile guarantees B sees this immediately
volatile guarantees that every read goes to main memory (the whiteboard) and every write is flushed to main memory immediately. No caching.
| Use volatile | DON'T use volatile |
|---|---|
| Simple boolean flags (stop, closed, cancelled) | count++ (read-modify-write is NOT atomic) |
| One thread writes, others read | Multiple threads writing to the same variable |
| Status indicators | Anything needing check-then-act |
volatile makes reads/writes visible, but does NOT make count++ atomic! count++ is read-then-add-then-write (3 steps). Two threads doing count++ on a volatile int can still lose increments. Use AtomicLong for counters (next section).
final class JobRecord { volatile boolean cancelRequested = false; // set by cancel thread JobStatus status = JobStatus.QUEUED; // protected by synchronized (JobRegistry) } // In PrintWorker.run(): for (int p = 1; p <= job.pages(); p++) { if (r.cancelRequested) { // volatile! Sees cancel immediately registry.markCancelled(jobId); return; } Thread.sleep(job.pageMillis()); }
Why volatile here instead of synchronized? The PrintWorker checks this flag in a tight loop. Using synchronized every iteration would be expensive. volatile gives cross-thread visibility without the overhead of a lock.
You need a shared counter that multiple threads can safely increment. volatile isn't enough (count++ isn't atomic). synchronized works but is heavy. AtomicLong is the sweet spot: lock-free, thread-safe counters.
import java.util.concurrent.atomic.AtomicLong; private final AtomicLong idGen = new AtomicLong(1); private final AtomicLong submitted = new AtomicLong(0); private final AtomicLong completed = new AtomicLong(0); private final AtomicLong cancelled = new AtomicLong(0); // Thread-safe operations: long id = idGen.getAndIncrement(); // returns old value, then adds 1 submitted.incrementAndGet(); // adds 1, then returns new value long count = completed.get(); // just read the current value
| Scenario | Tool | Why |
|---|---|---|
| Boolean flag (stop/cancel) | volatile boolean | Simple read/write, no arithmetic |
| Counter (submitted/completed) | AtomicLong | Need atomic increment |
| Complex state (queue, map, state machine) | synchronized | Need multiple operations to happen as a unit |
This is the while-wait + notifyAll pattern applied to a real problem. A queue with a maximum capacity. Producers wait if it's full. Consumers wait if it's empty.
final class BoundedJobQueue { private final int capacity; private final Deque<Long> q = new ArrayDeque<>(); private int maxDepth = 0; BoundedJobQueue(int capacity) { this.capacity = capacity; } /* === PRODUCER: blocks until space available === */ synchronized void putBlocking(long jobId) throws InterruptedException { while (q.size() >= capacity) // full? wait. wait(); // (Iron Rule #1: while, not if) q.addLast(jobId); if (q.size() > maxDepth) maxDepth = q.size(); notifyAll(); // someone might be waiting to take } /* === CONSUMER: blocks until item available === */ synchronized long takeBlocking() throws InterruptedException { while (q.isEmpty()) // empty? wait. wait(); long id = q.pollFirst(); notifyAll(); // someone might be waiting to put return id; } /* === PRODUCER WITH TIMEOUT === */ synchronized boolean putWithTimeout(long jobId, long timeoutMs) throws InterruptedException { long deadline = System.currentTimeMillis() + timeoutMs; while (q.size() >= capacity) { long remaining = deadline - System.currentTimeMillis(); if (remaining <= 0) return false; // timed out! wait(remaining); // wait with timeout } q.addLast(jobId); if (q.size() > maxDepth) maxDepth = q.size(); notifyAll(); return true; } /* === CANCEL: remove a specific job if still queued === */ synchronized boolean removeIfPresent(long jobId) { boolean removed = q.remove(jobId); if (removed) notifyAll(); // freed a slot! return removed; } }
putBlocking(job3)wait(). Releases lock. Sleeps.takeBlocking()notifyAll(). Releases lock.while: q.size() (1) >= 2? No!notifyAll(). Done.
putWithTimeout is the same pattern but with a deadline. wait(remaining) wakes up either when notified OR when time runs out. After waking, check if the condition changed AND if you've run out of time.
A job in the print spooler has a lifecycle. It starts QUEUED, moves to PRINTING, then to DONE or CANCELLED. You can't go backwards. You can't skip steps. This is a state machine.
/* The valid transitions:
*
* QUEUED ──────> PRINTING ──────> DONE
* │ │
* └── CANCELLED <──┘
*
* DONE and CANCELLED are terminal. No transitions from them.
*/
if check on the current state.final class JobRegistry { private final Map<Long, JobRecord> jobs = new HashMap<>(); // Register a new job synchronized JobRecord create(long jobId, PrintJob job) { JobRecord r = new JobRecord(jobId, job); jobs.put(jobId, r); return r; } // QUEUED → PRINTING (but NOT if cancel was requested!) synchronized boolean markPrinting(long jobId) { JobRecord r = jobs.get(jobId); if (r == null) return false; if (r.status != JobStatus.QUEUED) return false; // guard if (r.cancelRequested) { // RACE CHECK! r.status = JobStatus.CANCELLED; return false; } r.status = JobStatus.PRINTING; return true; } // PRINTING → DONE synchronized void markDone(long jobId) { JobRecord r = jobs.get(jobId); if (r != null && r.status == JobStatus.PRINTING) // guard r.status = JobStatus.DONE; } // QUEUED|PRINTING → CANCELLED (but not from DONE) synchronized boolean markCancelled(long jobId) { JobRecord r = jobs.get(jobId); if (r == null) return false; if (r.status == JobStatus.DONE) return false; // can't cancel done r.status = JobStatus.CANCELLED; r.cancelRequested = true; // volatile flag for worker return true; } synchronized JobStatus status(long jobId) { JobRecord r = jobs.get(jobId); return r == null ? null : r.status; } }
This is the trickiest part. What if Thread A calls cancel() at the EXACT same time Thread B calls markPrinting()?
markCancelled, grabs lock first.cancelRequested = true (volatile, visible immediately)status = CANCELLEDmarkPrinting, grabs lock.r.status != QUEUED? It's CANCELLED now → return false.r.cancelRequested? It's already true (volatile!) → sets CANCELLED, return false.synchronizedcancelRequested (volatile) inside markPrintingCreating a new Thread for every task is expensive (OS has to allocate a stack, schedule it, etc.). A thread pool pre-creates a fixed number of worker threads that pick up tasks from a queue.
ExecutorService.import java.util.concurrent.*; // Create a pool of 4 worker threads ExecutorService pool = Executors.newFixedThreadPool(4); // Submit work (returns immediately; work runs in background) pool.submit(() -> { System.out.println("Running on: " + Thread.currentThread().getName()); }); // Submit more work pool.submit(() -> doHeavyComputation());
// Step 1: Stop accepting new tasks pool.shutdown(); // Step 2: Wait for current tasks to finish (with timeout) if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { // Step 3: Timed out? Force-kill remaining tasks pool.shutdownNow(); }
shutdown() = "Kitchen is closed. No new orders. But finish what's on the stove."awaitTermination(5s) = "I'll wait 5 minutes for you to finish cooking."shutdownNow() = "TIME'S UP. Turn off the stoves. Everyone out."
shutdown() is polite. shutdownNow() interrupts all running threads (sends them an InterruptedException). You almost always want to try shutdown() first, then fall back to shutdownNow().
Your assignment is a complete print spooler system. Here's how all the pieces fit together:
/*
* [User Thread] [Dispatcher Thread] [Worker Pool]
* | | |
* submitBlocking(job) | |
* | | |
* +--> registry.create(id, job) | |
* +--> queue.putBlocking(id) ----+ | |
* | | | |
* | BoundedJobQueue |
* | [id1, id2, ...] |
* | | | |
* | +-----> queue.takeBlocking() -----+ |
* | | | |
* | +--> pool.submit( | |
* | | PrintWorker(id)) | |
* | | | |
* | | [PrintWorker]
* | | markPrinting(id)
* | | for each page:
* | | if cancelRequested: bail
* | | sleep(pageMillis)
* | | markDone(id)
* | | |
* cancel(id) -----------------------> markCancelled(id) |
* | sets cancelRequested=true |
* | removes from queue if still |
* | queued |
*/
public final class SpoolerImpl implements Spooler { private final AtomicLong idGen = new AtomicLong(1); // unique job IDs private final AtomicLong submitted = new AtomicLong(0); // metrics counters private final AtomicLong completed = new AtomicLong(0); private final AtomicLong cancelled = new AtomicLong(0); private final JobRegistry registry = new JobRegistry(); private final BoundedJobQueue queue; private final ExecutorService pool; private final Thread dispatcher; private volatile boolean closed = false; // shutdown flag public SpoolerImpl(int capacity, int workers) { this.queue = new BoundedJobQueue(capacity); this.pool = Executors.newFixedThreadPool(workers); // Dispatcher: takes jobs from queue, submits to pool this.dispatcher = new Thread(() -> { while (!closed) { try { long jobId = queue.takeBlocking(); pool.submit(() -> { new PrintWorker(jobId, registry).run(); JobStatus st = registry.status(jobId); if (st == JobStatus.DONE) completed.incrementAndGet(); else if (st == JobStatus.CANCELLED) cancelled.incrementAndGet(); }); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }, "spooler-dispatcher"); dispatcher.start(); } // Submit a job (blocks if queue is full) public long submitBlocking(PrintJob job) throws InterruptedException { if (closed) throw new IllegalStateException("spooler closed"); long id = idGen.getAndIncrement(); registry.create(id, job); queue.putBlocking(id); submitted.incrementAndGet(); return id; } // Cancel a job public boolean cancel(long jobId) { boolean ok = registry.markCancelled(jobId); if (ok) { queue.removeIfPresent(jobId); // remove from queue if still there cancelled.incrementAndGet(); } return ok; } // Graceful shutdown public void close() throws Exception { closed = true; // volatile: dispatcher sees it dispatcher.interrupt(); // wake from takeBlocking dispatcher.join(5000); // wait up to 5s pool.shutdown(); if (!pool.awaitTermination(5, TimeUnit.SECONDS)) pool.shutdownNow(); } }
final class PrintWorker implements Runnable { private final long jobId; private final JobRegistry registry; public void run() { JobRecord r = registry.get(jobId); if (r == null) return; if (!registry.markPrinting(jobId)) return; // cancelled before start PrintJob job = r.job; for (int p = 1; p <= job.pages(); p++) { if (r.cancelRequested) { // volatile check each page registry.markCancelled(jobId); return; } try { Thread.sleep(job.pageMillis()); // simulate printing } catch (InterruptedException e) { Thread.currentThread().interrupt(); registry.markCancelled(jobId); return; } } registry.markDone(jobId); // all pages printed! } }
Thread.interrupt() does NOT kill a thread. It sets a flag and wakes the thread up from any blocking call (wait(), sleep(), join()). The thread then has to CHOOSE to stop.
interrupt() is the tap.
try { Thread.sleep(1000); // or wait(), or join(), or queue.take() } catch (InterruptedException e) { // IMPORTANT: sleep/wait CLEARS the interrupt flag when they throw! // Re-set it so callers above us know we were interrupted: Thread.currentThread().interrupt(); return; // or break, or handle gracefully }
sleep() or wait() throws InterruptedException, they clear the interrupt flag. If you catch the exception and don't restore the flag, code higher up the call stack won't know the thread was interrupted. Always call Thread.currentThread().interrupt() in the catch block.
Thread.currentThread().interrupt() = putting a new sticky note back so the message isn't lost.
| Strategy | When to use | Code |
|---|---|---|
| Propagate | Method can throw it | Just add throws InterruptedException to the signature |
| Catch + restore | Inside run() or can't throw |
catch (IE e) { Thread.currentThread().interrupt(); return; } |
// CRITICAL: NO package declaration! Class MUST be named Main. import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); // Read a single int int n = sc.nextInt(); // Read n ints into an array int[] arr = new int[n]; for (int i = 0; i < n; i++) arr[i] = sc.nextInt(); // Read a whole line (careful: nextLine after nextInt needs an extra nextLine) sc.nextLine(); // consume the leftover newline String line = sc.nextLine(); // Read until EOF while (sc.hasNextInt()) { int x = sc.nextInt(); } // Output System.out.println(result); // with newline System.out.print(result); // without newline } }
package line at the top. DOMjudge compiles your file directly.public class Main.nextInt() leaves a \n in the buffer. If you call nextLine() after, it reads that empty \n. Add an extra sc.nextLine() to clear it.
Implement a BoundedCounter with methods increment() (blocks at max) and decrement() (blocks at 0). (Uses: synchronized, while-wait, notifyAll — sections 4, 5)
class BoundedCounter { private int count = 0; private final int max; BoundedCounter(int max) { this.max = max; } synchronized void increment() throws InterruptedException { while (count >= max) wait(); // full? wait. count++; notifyAll(); // someone might be waiting to decrement } synchronized void decrement() throws InterruptedException { while (count <= 0) wait(); // empty? wait. count--; notifyAll(); // someone might be waiting to increment } synchronized int getCount() { return count; } }
Exact same structure as BoundedJobQueue. Replace "queue full/empty" with "counter at max/zero."
Implement StateTracker with states: IDLE → RUNNING → FINISHED, also IDLE|RUNNING → CANCELLED. FINISHED and CANCELLED are terminal. (Uses: synchronized, state machine guards — sections 4, 9)
enum State { IDLE, RUNNING, FINISHED, CANCELLED } class StateTracker { private State state = State.IDLE; synchronized boolean start() { if (state != State.IDLE) return false; state = State.RUNNING; return true; } synchronized boolean finish() { if (state != State.RUNNING) return false; state = State.FINISHED; return true; } synchronized boolean cancel() { if (state == State.FINISHED || state == State.CANCELLED) return false; state = State.CANCELLED; return true; } synchronized State getState() { return state; } }
Same pattern as JobRegistry. Guard check → transition → return success.
Implement a MessageQueue (unbounded) where the producer sends strings. The special message "QUIT" signals the consumer to stop. (Uses: synchronized, while-wait, notifyAll — section 5)
class MessageQueue { private final Queue<String> q = new LinkedList<>(); synchronized void put(String msg) { q.add(msg); notifyAll(); } synchronized String take() throws InterruptedException { while (q.isEmpty()) wait(); return q.poll(); } } // Consumer loop: // while (true) { // String msg = queue.take(); // if ("QUIT".equals(msg)) break; // process(msg); // }
The poison pill pattern: a special sentinel value that tells the consumer to shut down. Simpler than interrupting.
Given SpoolerImpl with a dispatcher thread, pool, and volatile boolean closed, write the close() method that gracefully shuts everything down. (Uses: volatile, interrupt, join, shutdown sequence — sections 6, 10, 12)
public void close() throws Exception { closed = true; // 1. Signal dispatcher to stop dispatcher.interrupt(); // 2. Wake from takeBlocking dispatcher.join(5000); // 3. Wait for dispatcher to exit pool.shutdown(); // 4. No new tasks to pool if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { pool.shutdownNow(); // 5. Force if still running } }
Order matters! Stop the dispatcher first (so it stops submitting), then shut down the pool. Setting closed = true (volatile) makes the dispatcher loop see it immediately. interrupt() wakes it from the blocking takeBlocking() call.