183 lines
10 KiB
Markdown
183 lines
10 KiB
Markdown
### ⚛️ 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.
|