complete theoretical part
This commit is contained in:
+182
@@ -0,0 +1,182 @@
|
||||
### ⚛️ 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.
|
||||
Reference in New Issue
Block a user