## ⚛️ Atomic Variables & Synchronization

1. In the world of multithreaded programming, when we say an operation is atomic, it means that the operation is either completely done or not done at all. There is no middle ground, and no one else can take over.
   <br>The main purpose of these variables is to enable thread-safe operations on a single variable, without using heavy locking mechanisms (such as synchronized).
   <br>When we work with ordinary variables (like a simple int), an operation like count++ appears to be one line, but at the processor level it turns into three steps: Read, Modify and Write.
   <br>If two threads do this at the same time, a race condition may occur and data may be corrupted. However, atomic variables rely on the processor's hardware capabilities called CAS (Compare-And-Swap), performing all three steps as a single, inseparable operation at the hardware level.

---

2. Java has designed special atomic classes for different data types. Four of the most popular ones are: <br>I. AtomicInteger <br>II. AtomicLong <br>III. AtomicBoolean <br>VI. AtomicReference.
   <br><br>Typical Use Case for AtomicInteger:
   <br>One of the most common uses of AtomicInteger is implementing **Global Counters** or **ID Generators** in web applications.
   <br>Suppose we have a web server that is being requested by hundreds of threads at the same time, and we want to count the total number of visits:

```JAVA
public class WebCounter {
    //Using atomic variables for lock-free counting
    private final AtomicInteger visitorCount = new AtomicInteger(0);

    public void incrementVisitors() {
        visitorCount.incrementAndGet(); 
    }

    public int getCount() {
        return visitorCount.get();
    }
}
```

---

3. Both are tools for creating security in multi-trading environments, but their working mechanism is completely different:
<br> **Locks**: Assume that interference will definitely occur. So the thread locks the door before entering the sensitive part of the code, the other threads are blocked behind the door and go to sleep until the lock is unlocked.
<br> **Atomic variables**: They assume that there will be no interference. The thread does its work and checks at the last moment using the CAS algorithm; if no other thread has changed the data, it records the new value, and if there was interference, it simply repeats its work.
<br><br> When is it better to use Lock?
<br> I. When we have complex, multi-line operations: If we need to update several different variables at once or perform complex conditional logic, atomic variables are not suitable. Atomic variables are only for a single variable. For complex operations, we should definitely use Lock or synchronized.
<br> II. When contention between threads is extremely high: If thousands of threads are simultaneously hitting the same point, atomic variables will constantly enter recursion loops due to consecutive CAS failures, causing CPU usage to reach 100%. In these situations, it is more economical to lock and put the threads to sleep.
<br><br> When is it better to use Atomic Variables?
<br> I. For independent variables and counters: If we just want to change a flag or update a simple counter or state, atomics are incredibly fast.
<br> II. Reduce context switch overhead: Locks put threads to sleep and wake them up, which is a heavy burden on the operating system. Atomic variables are non-blocking, they do not suspend a thread, and therefore have extremely high performance for simple operations.

---

### Bonus Task:

```Java
public class AtomicComparisonDemo { 
    private static int normalCount = 0;
    private static final AtomicInteger atomicCount = new AtomicInteger(0);

    public static void main(String[] args) {
        System.out.println("Starting Race Condition Simulation");
        
        int numberOfThreads = 10;
        int incrementsPerThread = 1000;
        int expectedTotal = numberOfThreads * incrementsPerThread;

        System.out.println("Expected final value for both: " + expectedTotal);
        System.out.println("Activating " + numberOfThreads + " threads...");

        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < numberOfThreads; i++) {
            Thread t = new Thread(() -> {
                for (int j = 0; j < incrementsPerThread; j++) {
                    normalCount++; 
                    
                    atomicCount.incrementAndGet();
                }
            });
            threads.add(t);
        }

        for (Thread t : threads) {
            t.start();
        }

        try {
            for (Thread t : threads) {
                t.join();
            }
        } catch (InterruptedException e) {
            System.out.println("Main thread interrupted.");
        }

        System.out.println("\nSimulation Results:");
        System.out.println("Normal int (Ordinary) Result : " + normalCount);
        System.out.println("AtomicInteger Result         : " + atomicCount.get());

        if (normalCount < expectedTotal) {
            System.out.println("Notice: The normal int lost some increments due to a Race Condition!");
        }
        if (atomicCount.get() == expectedTotal) {
            System.out.println("Success: AtomicInteger produced the exact correct result.");
        }
    }
}
```

---

4. A race-free schedule is one in which access to shared resources is fully protected. But these same protection mechanisms create bottlenecks under high contention. This phenomenon is called lock contention, where threads spend most of their time waiting for access to a shared resource instead of doing useful work.
<br><br> Three concurrency-related factors that limit scalability:
<br> I. **Amdahl's Law**: According to this rule, the speedup of a program with parallelization is limited by its non-parallelizable (sequential) portion. If 95% of our code is parallelized but 5% has to be executed sequentially, even with 1000 processor cores, our program will never get more than 20x faster. The sequential portions lock the scalability ceiling.
<br> II. **Bus Traffic / CAS Contention**: If we are using AtomicInteger variables to resolve the Race Condition, in high contention, the Compare-And-Swap (CAS) method will fail repeatedly. When 100 threads simultaneously try to update an atomic variable, 1 thread will succeed and 99 threads will fail and have to try again in a loop. These repeated retries will consume a lot of memory bus bandwidth and CPU cycles without doing any useful work.
<br> III. **Coarse-Grained Locking**: If we synchronize an entire method or large object for convenience, data safety is guaranteed, but in effect we have turned the program into a single-threaded program. Threads enter the method one by one, and the rest are left hanging behind, which wastes our array of processor cores.

---

5. The following four key concepts explain this phenomenon:
<br> I. **Context Switching**: A processor core can only execute one thread at a time. To create the illusion of concurrent execution, the operating system constantly pauses the current thread, saves its state, and loads the next thread. This process is called Context Switch. When the number of threads becomes too large, the processor spends most of its time on administrative operations of "saving and loading threads" and there is no time left to actually execute our code.
<br> II. **Contention**: When the number of threads requesting a resource increases, contention occurs. This contention leads to blocking of threads. Threads are forced to sleep and wake up, which in turn places a heavy processing load on the kernel.
<br> III. **Cache Coherence Overhead**: Each CPU core has a very fast cache that holds a copy of the RAM data. When a thread on a core changes the value of a shared variable, the CPU hardware has to send a signal to all other cores (Cache Invalidation). 
<br> VI. **Synchronization Overhead**: Entering and exiting lock structures, managing the queue of waiting threads behind synchronized methods, and enforcing memory barriers to ensure that changes to volatile variables are visible to other threads are all additional code that runs at the hardware level and consumes processor bandwidth.

---

6. A deadlock occurs when two or more threads wait in a loop for resources that are locked by each other to be released. In this case, the threads remain in a waiting state (BLOCKED) forever, no work progresses, the CPU usage for those threads becomes zero, and the program completely locks up.
<br> For the deadlock to occur, the second thread must wake up at exactly that tiny fraction of a second (millisecond or microsecond) when the first thread has acquired the first lock but has not yet acquired the second lock. This tiny time frame is called the **Timing window**.
<br> Two strategies for detecting and catching deadlocks in the test environment:
<br> I. **Injecting Artificial Delays**: The best way is to intentionally slow down the threads in our test code after they have acquired the first lock, so that other threads have time to wake up and acquire the second lock. To do this, we use `Thread.sleep()` or `Thread.yield()` for giving turn to other threads.
<br> II. **High-Contention Stress Testing**: In this strategy, we write a program that slams hundreds of threads on a particular method at exactly the same time, in a single second. This amount of intense artificial contention closely simulates production conditions and forces weak code to lock up.

---

Done :)