diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..812c3f9 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..105dbd3 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..4f9985e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Answers.md b/Answers.md new file mode 100644 index 0000000..b34f760 --- /dev/null +++ b/Answers.md @@ -0,0 +1,229 @@ +### Question 1 — What are Atomic Variables? + +**Atomic variables** are special variables that support indivisible (atomic) read-modify-write operations. "Atomic" means the operation completes as a single, uninterruptible unit — no thread can observe the variable in a partially-updated intermediate state. + +#### Difference from Ordinary Variables + +With a regular `int`, an operation like `counter++` is actually **three steps**: +1. Read the current value +2. Increment it +3. Write it back + +Between any two of those steps, another thread can jump in and read or write the same variable — causing a **race condition** and a lost update. + +With an `AtomicInteger`, the entire increment is a single hardware-level operation (typically a CPU **CAS — Compare-And-Swap** instruction), making it thread-safe without needing a `synchronized` block or a lock. + +--- + +### Question 2 — Four Classes from `java.util.concurrent.atomic` + +| Class | What it wraps | +|---|---| +| `AtomicInteger` | `int` | +| `AtomicLong` | `long` | +| `AtomicBoolean` | `boolean` | +| `AtomicReference` | Any object reference | + +#### Typical Use Case — `AtomicLong` as a Transaction ID Generator + +In a banking system, every new transaction needs a unique, incrementing ID, and multiple threads generate transactions concurrently. + +```java +AtomicLong transactionId = new AtomicLong(0); + +// Each thread calls: +long myId = transactionId.getAndIncrement(); +``` + +`getAndIncrement()` atomically returns the current value and increments it. No two threads ever receive the same ID, and no `synchronized` block is needed. + +--- + +### Question 3 — Locks vs. Atomic Variables + +| | Atomic Variables | Locks | +|---|---|---| +| **Mechanism** | Hardware CAS (non-blocking) | OS-level mutex (blocking) | +| **Overhead** | Very low | Higher (thread suspension/waking) | +| **Scope** | Single variable | Arbitrary code region | +| **Composability** | Hard to compose multiple atomics safely | Easy — lock covers multiple statements | +| **Blocking** | Never blocks (spin/retry on failure) | Blocks waiting threads | + +#### Use Atomic Variables when: +- You need to update a **single variable** (a counter, a flag, a reference). +- Contention is **low to moderate** — CAS retries are cheap. +- You want **non-blocking, lock-free** progress guarantees. + +#### Use a Lock when: +- You need to atomically update **multiple variables together** (e.g., deducting from one account and adding to another — exactly the `transfer()` case in this assignment). +- The critical section involves **complex logic** spanning multiple steps. +- You need `Condition` variables for **conditional waiting** (e.g., wait-for-sufficient-funds). +- You need **fairness guarantees** (`ReentrantLock` supports fair ordering; CAS-based atomics do not). + +--- + +### Bonus Task — Race Condition Demonstration + +```java +import java.util.concurrent.atomic.AtomicInteger; + +public class RaceConditionDemo { + static int normalCounter = 0; + static AtomicInteger atomicCounter = new AtomicInteger(0); + + public static void main(String[] args) throws InterruptedException { + int numThreads = 100; + int increments = 1000; + Thread[] threads = new Thread[numThreads]; + + for (int i = 0; i < numThreads; i++) { + threads[i] = new Thread(() -> { + for (int j = 0; j < increments; j++) { + normalCounter++; // NOT thread-safe: read-modify-write race + atomicCounter.incrementAndGet(); // Thread-safe: single atomic CAS + } + }); + threads[i].start(); + } + + for (Thread t : threads) t.join(); // Wait for all threads to finish + + System.out.println("Expected: " + (numThreads * increments)); // 100,000 + System.out.println("Normal counter: " + normalCounter); // likely < 100,000 + System.out.println("Atomic counter: " + atomicCounter.get()); // always 100,000 + } +} +``` + +#### Sample Output +``` +Expected: 100000 +Normal counter: 94371 ← lost updates due to race condition +Atomic counter: 100000 ← always correct +``` + +The normal counter prints a value **less than 100,000** because concurrent threads overwrite each other's increments. The `AtomicInteger` always produces the correct result because `incrementAndGet()` is a single indivisible CPU-level operation. + +--- + + + +### Question 4 — Correct but Slow Under High Contention + +A program can be completely correct (no race conditions, no lost updates) yet still scale poorly. Three concurrency-related factors that limit scalability even when correctness is guaranteed: + +#### 1. Lock Contention +If many threads compete for the same lock, only one proceeds at a time while the rest are **blocked and descheduled** by the OS. The more threads you add, the longer the queue in front of that lock. Throughput plateaus or even degrades. A single `synchronized` method on a shared object becomes a **serialization bottleneck** — the program effectively runs single-threaded through that section regardless of how many cores are available. + +#### 2. Amdahl's Law / Sequential Sections +Even a small fraction of code that *must* run serially severely caps maximum speedup. If 10% of your code is sequential (e.g., a global lock, a single-threaded flush, or a synchronized queue), you can **never exceed 10× speedup** no matter how many CPU cores you add. The formula is: + +``` +Max Speedup = 1 / (sequential_fraction + parallel_fraction / N) +``` + +So even 5% sequential code limits you to 20× speedup with infinite threads. + +#### 3. False Sharing +Modern CPUs cache memory in **cache lines** (typically 64 bytes). If two threads write to different variables that happen to reside in the same cache line, the CPU coherence protocol forces those cache lines to be **invalidated and transferred between cores** on every write — even though the threads are touching logically independent data. This causes massive invisible overhead that manifests as contention without any lock being held. + +#### 4. Lock Granularity (bonus factor) +Using one coarse-grained lock (e.g., one lock for the entire bank) serializes all operations. Fine-grained locking (one lock per account) allows truly concurrent independent operations — but requires more careful design to avoid deadlocks. Poor granularity choices can make a correct solution perform no better than a single-threaded one. + +--- + +### Question 5 — Why More Threads ≠ More Performance + +Adding more threads beyond a certain point hurts rather than helps. The key reasons: + +#### Context Switching +Every time the OS switches the CPU from one thread to another, it must **save and restore the entire CPU state** (registers, program counter, stack pointer, cache state). With many threads, this overhead grows significantly. If you have more threads than CPU cores, threads spend more time being swapped in and out than doing actual work — a phenomenon called **thrashing**. + +#### Contention +When multiple threads compete for the same lock or resource, most sit **blocked doing nothing productive**. Adding more threads just lengthens the queue — it doesn't increase throughput. At high thread counts, the time threads spend *waiting* can dwarf the time spent *working*. + +#### Cache Coherence +Modern CPUs maintain per-core caches. When multiple threads on different cores read and write the same memory, the hardware must keep all caches consistent (via protocols like **MESI**). Each write to a shared variable forces other cores to **invalidate their cached copy** and fetch the new value from main memory or a neighboring core's cache. This traffic grows with thread count and becomes a bottleneck independent of any locking. + +#### Synchronization Overhead +Every `synchronized` block, `lock.lock()` call, or atomic CAS has its own cost — even when there's no contention. With many threads all paying this overhead per operation, the accumulated cost adds up. Operations like `volatile` writes force **memory fences** that prevent compiler and CPU reordering, adding latency to every access. + +#### The Combined Effect +Performance follows a curve, not a line: +- Improves as you add threads up to roughly the number of CPU cores (or slightly above for I/O-bound work) +- Then levels off +- Eventually **decreases** as overhead and contention dominate + +This is why thread pool sizing is a tuning exercise, not "more is always better." + +--- + +### Question 6 — Why Deadlocks Hide Until Production + +#### Why They Don't Appear During Testing + +Deadlocks require a very **specific interleaving of threads** — for example: +1. Thread 1 acquires lock A +2. Thread 2 acquires lock B +3. Thread 1 tries to acquire lock B → **blocks** +4. Thread 2 tries to acquire lock A → **blocks** +5. Both wait forever → deadlock + +The OS thread scheduler determines these orderings **non-deterministically** based on CPU load, OS timeslicing, hardware interrupts, JIT compilation state, and dozens of other factors. + +In a typical test environment: +- Fewer threads are running +- The machine is lightly loaded +- Tests complete quickly, reducing the window of opportunity +- The JVM may use different thread scheduling from production hardware + +The exact scheduling window that causes deadlock may statistically occur once every millions of operations — trivially missed in a short test run, but inevitable under sustained production load with hundreds of concurrent threads running for hours. + +#### Two Strategies to Expose Deadlocks During Testing + +**Strategy 1 — Stress Testing with Injected Delays** + +Deliberately insert `Thread.sleep()` or `Thread.yield()` calls inside critical sections, particularly *between* the first and second lock acquisitions: + +```java +void transfer(BankAccount target, long amount) { + lock.lock(); + try { + Thread.sleep(1); // <-- inject here during testing to widen the race window + target.lock.lock(); + try { + // ... transfer logic + } finally { + target.lock.unlock(); + } + } finally { + lock.unlock(); + } +} +``` + +This artificially widens the window where a context switch can occur, making the "right" (wrong) interleaving far more likely. Tools like **jcstress** (the Java Concurrency Stress test harness) are designed specifically for this — they systematically vary thread scheduling to expose rare race conditions and deadlocks. + +**Strategy 2 — High-Concurrency Watchdog Tests** + +Write a test that launches many threads performing **cross-transfers simultaneously** (A→B, B→A, A→C, C→A, etc.) under high concurrency and runs them for an extended period. Include a **watchdog thread** that detects if any thread is stuck for longer than a timeout threshold: + +```java +@Test(timeout = 10000) +void testNoDeadlockUnderStress() throws InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(50); + List> futures = new ArrayList<>(); + + for (int i = 0; i < 10000; i++) { + BankAccount from = accounts.get(random.nextInt(accounts.size())); + BankAccount to = accounts.get(random.nextInt(accounts.size())); + futures.add(executor.submit(() -> from.transfer(to, 10))); + } + + // If deadlock occurs, test times out and fails + for (Future f : futures) f.get(5, TimeUnit.SECONDS); + executor.shutdown(); +} +``` + +If a deadlock exists, the test will time out and fail deterministically — turning a production mystery into a reproducible test failure. diff --git a/pom.xml b/pom.xml index 33ea6f3..0f704db 100644 --- a/pom.xml +++ b/pom.xml @@ -9,8 +9,8 @@ 1.0-SNAPSHOT - 23 - 23 + 21 + 21 UTF-8 diff --git a/src/main/java/dev/banking/model/BankAccount.java b/src/main/java/dev/banking/model/BankAccount.java index 745ede2..265ec49 100644 --- a/src/main/java/dev/banking/model/BankAccount.java +++ b/src/main/java/dev/banking/model/BankAccount.java @@ -1,18 +1,15 @@ package dev.banking.model; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + public class BankAccount { private final int accountId; private long balance; + private final ReadWriteLock lock = new ReentrantReadWriteLock(); - /* - * Students may introduce additional fields - * such as: - * - Lock / ReentrantLock - * - ReadWriteLock - * - Object monitor - * - etc. - */ public BankAccount(int accountId, long initialBalance) { this.accountId = accountId; @@ -23,56 +20,60 @@ public class BankAccount { return accountId; } - /* - * TODO: - * Return the current balance in a thread-safe way. - * - * Requirements: - * - Must be safe under concurrent reads/writes - * - Should not block unnecessarily if using read/write locks - */ + public long getBalance() { - throw new UnsupportedOperationException("TODO: implement thread-safe balance read"); + lock.readLock().lock(); + try { + return balance; + } + finally { + lock.readLock().unlock(); + } } - /* - * TODO: - * Increase balance atomically. - * - * Requirements: - * - Must not lose updates under concurrency - */ + public void deposit(long amount) { - throw new UnsupportedOperationException("TODO: implement thread-safe deposit"); + lock.writeLock().lock(); + try { + if (amount < 0) throw new IllegalArgumentException("Amount cannot be negative"); + balance += amount; + } + finally { + lock.writeLock().unlock(); + } } - /* - * TODO: - * Decrease balance atomically. - * - * Requirements: - * - Must not cause race conditions - * - Negative balance handling is NOT required unless you decide - * to extend the system (optional) - */ public void withdraw(long amount) { - throw new UnsupportedOperationException("TODO: implement thread-safe withdraw"); + lock.writeLock().lock(); + try { + if (amount < 0) throw new IllegalArgumentException("Amount cannot be negative"); + if (balance < amount) throw new IllegalArgumentException("insufficient balance"); + balance -= amount; + } + finally { + lock.writeLock().unlock(); + } } - /* - * TODO: - * Transfer money between two accounts atomically. - * - * IMPORTANT REQUIREMENTS: - * - Must be atomic (no partial transfer) - * - Must be deadlock-free - * - Must protect both source and target accounts - * - * HINT: - * - Consider global lock ordering using accountId - * - Or tryLock with retry strategy - */ + public void transfer(BankAccount target, long amount) { - throw new UnsupportedOperationException("TODO: implement atomic deadlock-free transfer"); + BankAccount first = this.accountId < target.accountId ? this : target; + BankAccount second = this.accountId > target.accountId ? this : target; + + first.lock.writeLock().lock(); + try { + second.lock.writeLock().lock(); + try { + if (amount < 0) throw new IllegalArgumentException("Amount cannot be negative"); + if (amount > balance) throw new IllegalArgumentException("insufficient balance"); + this.balance -= amount; + target.balance += amount; + } finally { + second.lock.writeLock().unlock(); + } + } + finally{ + first.lock.writeLock().unlock(); + } } } \ No newline at end of file