Merge pull request 'Develop' (#1) from develop into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-31 23:56:09 +00:00
9 changed files with 321 additions and 50 deletions
+10
View File
@@ -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/
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="HW-09-Advanced-Multithreading" />
</profile>
</annotationProcessing>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://maven.myket.ir/" />
</remote-repository>
</component>
</project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="25" project-jdk-type="JavaSDK" />
</project>
Generated
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+182
View File
@@ -0,0 +1,182 @@
### ⚛️ Atomic Variables & Synchronization
1. Atomic variables in Java are part of the `java.util.concurrent.atomic` package.
They provide lockfree, threadsafe operations on a single variable.
Their purpose is to allow readmodifywrite 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 nonblocking; they use hardwarelevel
CompareAndSwap (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 welltuned 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, singlevariable operations such as counters, sequence generators, or status flags.
-When you want lockfree code that cannot deadlock.
-For highfrequency 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 concurrencyrelated factors that limit scalability:**
* **Lock contention**
If a program uses a single coarsegrained 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 multicore 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
HyperThreading). 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 lockhandling 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 lockfree 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? (ThreadScheduling 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.
+8
View File
@@ -21,6 +21,14 @@
<version>5.12.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<version>1.12.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -1,18 +1,15 @@
package dev.banking.model;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class BankAccount {
private final int accountId;
private long balance;
/*
* Students may introduce additional fields
* such as:
* - Lock / ReentrantLock
* - ReadWriteLock
* - Object monitor
* - etc.
*/
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public BankAccount(int accountId, long initialBalance) {
this.accountId = accountId;
@@ -23,56 +20,71 @@ 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");
readLock.lock();
try
{
return balance;
} finally
{
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");
writeLock.lock();
try
{
balance += amount;
} finally
{
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");
writeLock.lock();
try
{
balance -= amount;
} finally
{
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");
if (target == this)
{
return;
}
BankAccount first = this;
BankAccount second = target;
if (this.getAccountId() > target.getAccountId())
{
first = target;
second = this;
}
first.writeLock.lock();
try
{
second.writeLock.lock();
try
{
//"first" and "second":only for the order of locking to prevent deadlock (have nothing to do with the direction of the transfer)
this.balance -= amount;
target.balance += amount;
} finally
{
second.writeLock.unlock();
}
} finally
{
first.writeLock.unlock();
}
}
}