### ⚛️ Atomic Variables & Synchronization


1. Atomic variables in Java are part of the `java.util.concurrent.atomic` package.
They provide lock‑free, thread‑safe operations on a single variable. 
Their purpose is to allow read‑modify‑write operations to be performed 
as a single, indivisible unit without using synchronized blocks or explicit locks.
2. 
    * `AtomicInteger` – for `int` values 
    * `AtomicLong` – for `long` values 
    * `AtomicBoolean` – for `boolean` values 
    * `AtomicReference<V>` – for object references (any type).
   </br>
   Typical use case for `AtomicInteger`: A request counter in a web server. 
   Each incoming request increments the counter to generate a unique ID or to count total requests. 
   Multiple threads (handling requests concurrently) can safely call `incrementAndGet()` without any locks.
3. Locks and atomic variables are both tools for managing thread safety, 
but they differ significantly in how they work and when you should use them.
</br>
    * Locks can protect arbitrarily large sections of code and can coordinate access to multiple 
   variables simultaneously. For example, if you need to update two bank accounts in a single atomic 
   operation, you must use a lock. Atomic variables, on the other hand, only cover a single variable.
   You cannot atomically update two independent `AtomicInteger` objects together without additional synchronization.
    * Locks are blocking – when a thread cannot acquire a lock, it is suspended by the operating system and later
   woken up, which causes context switches and overhead. Atomic variables are non‑blocking; they use hardware‑level
   Compare‑And‑Swap (CAS) instructions. If an atomic operation fails because another thread modified the variable,
   it simply retries immediately (spins) without leaving the CPU.
    * Because locks can be held while waiting for other locks, they can cause deadlocks if not used carefully.
   Atomic variables never cause deadlocks because there is no waiting for locks – each operation either 
   succeeds immediately or retries.
    * For simple operations like incrementing a counter, atomic variables are usually much faster than locks,
   especially when contention is low to moderate. However, under extremely high contention (many threads pounding
   the same variable), atomic variables may suffer from excessive retry spinning, and a well‑tuned lock might perform
   better. For long critical sections (e.g., many lines of code, I/O, or complex updates), locks are more efficient
   because spinning would waste CPU cycles.
* **When is a lock a better choice?**

  -When you need to atomically update multiple variables that belong together (e.g., transferring money between accounts).

  -When the critical section is long or contains blocking operations (like network calls or file I/O).

  -When you need explicit waiting and notification.

* **When is an atomic variable a better choice?**

  -For simple, single‑variable operations such as counters, sequence generators, or status flags.

  -When you want lock‑free code that cannot deadlock.

  -For high‑frequency updates where lock overhead would become a bottleneck (e.g., statistics collection, request counters).



#### 🎯Bonus Task:
```java
public class raceCondition
{
    private static int plainCounter = 0;
    private static AtomicInteger atomicCounter = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException
    {
        final int THREAD_COUNT = 10;
        final int INCREMENTS_PER_THREAD = 1000;

        Thread[] threads = new Thread[THREAD_COUNT];

        for (int i = 0; i < THREAD_COUNT; i++)
        {
            threads[i] = new Thread(()-> {
                for (int j = 0; j < INCREMENTS_PER_THREAD; j++)
                {
                    plainCounter++;
                    atomicCounter.incrementAndGet();
                }
            });
        }

        for (int i = 0;i < THREAD_COUNT; i++)
        {
            threads[i].start();
        }
        for (int i = 0;i < THREAD_COUNT; i++)
        {
            threads[i].join();
        }

        int expected = THREAD_COUNT*INCREMENTS_PER_THREAD;
        System.out.println("expected: "+expected);
        System.out.println("int: "+plainCounter);
        System.out.println("atomic: "+atomicCounter);

    }
}
```
sample output:
```
expected: 10000
int: 8880
atomic: 10000
```

---
### 🔒 Locks & Concurrent Design

4. **Explanation:**
   Even if a program has no race conditions, it can still suffer from poor performance when many threads compete
for the same resources. High contention means many threads try to access the same shared data or locks at the same time.
While correctness is preserved, throughput can drop dramatically because threads spend more time waiting, retrying,
or invalidating caches than doing useful work.

    **Three concurrency‑related factors that limit scalability:**

   * **Lock contention**

        If a program uses a single coarse‑grained lock (e.g., synchronizing the whole method), only one thread can
   execute the critical section at a time. All other threads queue up and block. As more threads are added, the queue
   grows, but the throughput cannot exceed the rate at which the lock is released and reacquired. This turns a
   concurrent program into essentially a sequential one for that resource, creating a scalability bottleneck.

   * **Cache coherence traffic**

        On modern multi‑core CPUs, each core has its own cache. When multiple threads repeatedly read and write to the
   same memory location (even with atomic operations), the caches must stay consistent. The hardware uses a cache
   coherence protocol. Every write to a shared variable invalidates the cache line in all other cores, forcing them to
   reload from main memory or a shared cache. Under high contention, this causes a storm of invalidations and cache
   misses, increasing memory latency and reducing performance even without explicit locks.

   * **False sharing**

        False sharing occurs when two or more threads modify different variables that happen to reside on the same 
   cache line (typically 64 bytes). Although the threads do not share the same logical variable, the cache coherence
   protocol treats the entire line as shared. When one thread updates its variable, the cache line is invalidated on
   other cores, causing unnecessary reloads. This can slow down seemingly independent threads, and the problem worsens
   with more threads because the probability of cache line overlaps increases.


5. * **Context switching overhead**

        The operating system can run only as many threads as there are hardware cores (or hardware threads like 
Hyper‑Threading). When the number of active threads exceeds the number of cores, the OS must constantly pause one thread
and switch to another. A context switch involves saving and restoring register states, updating memory management 
structures, and flushing parts of the pipeline and caches. Each switch costs microseconds – small per switch, but when 
thousands of switches happen per second, total overhead becomes significant, reducing useful work throughput.
    * **Contention for shared resources**

        As more threads compete for the same locks, memory, or I/O channels, the fraction of time spent waiting 
   (blocking) increases. Throughput does not increase linearly and eventually saturates. Contention on a popular lock
   can cause the system to spend most of its time in the operating system scheduler and in lock‑handling code, leading 
   to severe performance collapse.
    * **Cache coherence**

        More threads mean more cores reading and writing to shared data. Every write to a shared variable triggers cache
   coherency traffic (invalidations, bus transactions). This traffic increases with the square of the number of 
   contending cores in some cases. Moreover, all cores share the same memory bus. When many threads access memory 
   heavily, the bus becomes a bottleneck, and memory latency increases due to queuing delays.
    * **Synchronization overhead**

      Every locking operation (`synchronized`, `ReentrantLock`, `Semaphore`) involves overhead: acquiring the lock, 
   possibly parking the thread, and later unparking it. Even lock‑free atomic operations under high contention cause
   repeated retries, which burn CPU cycles without progressing. The overhead per operation grows, and total throughput can drop.

---
### ⚠️ Deadlocks
6. * **Why Deadlocks Often Appear Only in Production? (Thread‑Scheduling Perspective)**

      Deadlocks are notoriously hard to reproduce during testing because they depend on specific interleavings of thread
execution – the exact order in which threads acquire locks. In a testing environment (e.g., with low load, few cores, or
deterministic scheduling), the probability of hitting the exact timing window where two threads hold locks in opposite
order is extremely low.
   * **Two Strategies to Expose Deadlocks During Testing:**
     * Stress Testing with Thread Interleaving Controllers (e.g., jcstress, Lincheck):
        Use tools that systematically explore thread interleavings. For example, Java Concurrency Stress (jcstress)
     generates many schedules, including rare ones. Alternatively, ConcurrentLinkedDeque test harnesses or Lincheck
     (from Kotlin) can be used. A simpler approach: in a test, repeatedly run a scenario with many threads and use 
     Thread.yield() or Thread.sleep(1) at strategic points to increase the chance of switching contexts in the middle of
     lock acquisition.
     * Inject Artificial Delays and Random Preemption Points:
        Within the critical sections, insert small random sleeps (Thread.sleep(1)) or Thread.yield() right after 
     acquiring the first lock but before acquiring the second lock. This greatly increases the chance of interleaving.
     Use a randomised test runner that loops the same test thousands of times with different random seeds. Also, run
     tests on machines with more CPU cores and under load to make scheduling less predictable.
