Shared Resources & Race Conditions
Excellent! Now that we understand how to launch multiple independent paths of execution using modern Java, let's confront the most common and tricky problem in concurrent programming: when these independent paths aren't so independent after all, and they try to touch the same thing.
2. Shared Resources & Race Conditions
Concept:
A shared resource is any piece of data or an object that can be accessed and potentially modified by multiple threads simultaneously. This could be a static variable, an instance variable accessible to multiple threads, a common collection (like a List
or Map
), a file, or a database connection.
A race condition occurs when multiple threads try to access and modify a shared resource concurrently, and the final outcome depends on the unpredictable order in which the threads execute. Because the operating system's thread scheduler decides when to pause and resume threads, you can't guarantee the exact interleaving of operations. This unpredictability leads to incorrect results, data corruption, and bugs that are notoriously difficult to reproduce.
- Analogy: Imagine our restaurant again.
- Shared Resource: The single cash register where all waiters (threads) process customer payments and update the daily sales total.
- Race Condition: Two waiters, Alice and Bob, simultaneously process their orders.
- Alice reads the current total from the register: $100.
- Bob reads the current total from the register: $100.
- Alice adds her $20 to her mental $100 and writes $120 back to the register.
- Bob adds his $30 to his mental $100 and writes $130 back to the register.
- Problem: The register now shows $130, but it should be $100 + $20 + $30 = $150. Bob's update overwrote Alice's, and $20 of sales vanished! The outcome depends on who wrote last. This is a race condition.
"Before" (Problematic Code – The Uncontrolled Cash Register)
Let's simulate the cash register scenario in Java. We'll have a simple Counter
class, and multiple threads will try to increment it.
Scenario: We have a shared counter
variable that multiple threads will try to increment a large number of times. Each thread will increment it 100,000 times.
Problem: Without proper synchronization, the final value of the counter
will almost certainly be less than the expected sum (2 threads * 100,000 increments = 200,000). This is because the increment operation (counter++
) is not atomic (it's not a single, indivisible step). It typically involves three steps:
- Read the current value of
counter
. - Increment the value.
- Write the new value back to
counter
.
If two threads interleave these steps, one increment can be lost.
Java Example (Race Condition on a Shared Counter):
import java.util.concurrent.*;
public class RaceConditionExample {
// This is our shared resource
private static int counter = 0; // The problematic shared variable
// Task to increment the counter multiple times
static class IncrementTask implements Runnable {
private final String threadName;
private final int increments;
public IncrementTask(String threadName, int increments) {
this.threadName = threadName;
this.increments = increments;
}
@Override
public void run() {
System.out.println(threadName + " starting increments.");
for (int i = 0; i < increments; i++) {
// This is the critical section - accessing/modifying 'counter'
counter++; // This operation is NOT atomic and causes the race condition
}
System.out.println(threadName + " finished increments.");
}
}
public static void main(String[] args) throws InterruptedException {
int numberOfThreads = 2;
int incrementsPerThread = 100_000;
int expectedTotal = numberOfThreads * incrementsPerThread;
// Reset counter for fresh run
counter = 0;
System.out.println("--- Race Condition Demonstration ---");
System.out.println("Expected total after all increments: " + expectedTotal);
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
long startTime = System.currentTimeMillis();
for (int i = 0; i < numberOfThreads; i++) {
executor.submit(new IncrementTask("Thread-" + (i + 1), incrementsPerThread));
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
long endTime = System.currentTimeMillis();
System.out.println("\nActual final counter value: " + counter);
System.out.println("Time taken: " + (endTime - startTime) + " ms");
if (counter != expectedTotal) {
System.err.println("!!! Mismatch detected: Race condition occurred! Expected " + expectedTotal + ", got " + counter);
} else {
System.out.println("No race condition detected in this run (unlikely for large numbers of increments).");
}
}
}
Likely Output (will vary, but Actual final counter value
will often be less than Expected total
):
--- Race Condition Demonstration ---
Expected total after all increments: 200000
Thread-1 starting increments.
Thread-2 starting increments.
Thread-1 finished increments.
Thread-2 finished increments.
Actual final counter value: 198754 // This number will be inconsistent across runs!
Time taken: 15 ms
!!! Mismatch detected: Race condition occurred! Expected 200000, got 198754
Explanation of the Problem:
Let's trace how counter++
can go wrong with two threads (T1 and T2) starting with counter = 0
:
- T1 reads
counter
: T1 loadscounter
(value0
) into its CPU register. - T2 reads
counter
: T2 loadscounter
(value0
) into its CPU register. - T1 increments: T1 adds 1 to its register (now
1
). - T2 increments: T2 adds 1 to its register (now
1
). - T1 writes: T1 writes its register value (
1
) back tocounter
in main memory.counter
is now1
. - T2 writes: T2 writes its register value (
1
) back tocounter
in main memory.counter
is still1
.
In this sequence, two increments occurred, but the counter
only increased by one. This "lost update" is the classic manifestation of a race condition. The higher the number of threads and increments, the more likely and severe the race conditions become.
"After" (Resolved Code – The Synchronized Cash Register)
To resolve race conditions, we need to ensure that only one thread can execute the "critical section" (the code that modifies the shared resource) at any given time. This is called achieving mutual exclusion.
Resolution: In Java, the most fundamental way to achieve mutual exclusion is by using the synchronized
keyword. We'll use a synchronized
block to protect the counter++
operation.
Java Example (Fixing Race Condition with synchronized
):
import java.util.concurrent.*;
public class SynchronizedCounterExample {
// This is our shared resource
private static int counter = 0; // The shared variable
// A lock object for synchronization. It's common to use a dedicated final Object
// to synchronize on, especially for static methods/variables, to avoid
// locking on 'this' (for instance methods) or the class itself (for static methods)
// unnecessarily, which could block other unrelated operations.
private static final Object lock = new Object(); // Our dedicated "lock object"
// Task to increment the counter multiple times
static class IncrementTask implements Runnable {
private final String threadName;
private final int increments;
public IncrementTask(String threadName, int increments) {
this.threadName = threadName;
this.increcents = increments;
}
@Override
public void run() {
System.out.println(threadName + " starting increments.");
for (int i = 0; i < increments; i++) {
// This is the critical section.
// Only one thread can acquire 'lock' at a time and execute this block.
synchronized (lock) { // Acquire the intrinsic lock of the 'lock' object
counter++; // Now this operation is atomic with respect to other synchronized blocks on 'lock'
} // Release the intrinsic lock when exiting the synchronized block
}
System.out.println(threadName + " finished increments.");
}
}
public static void main(String[] args) throws InterruptedException {
int numberOfThreads = 2;
int incrementsPerThread = 100_000;
int expectedTotal = numberOfThreads * incrementsPerThread;
// Reset counter for fresh run
counter = 0;
System.out.println("--- Synchronized Counter Demonstration ---");
System.out.println("Expected total after all increments: " + expectedTotal);
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
long startTime = System.currentTimeMillis();
for (int i = 0; i < numberOfThreads; i++) {
executor.submit(new IncrementTask("Thread-" + (i + 1), incrementsPerThread));
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
long endTime = System.currentTimeMillis();
System.out.println("\nActual final counter value: " + counter);
System.out.println("Time taken: " + (endTime - startTime) + " ms");
if (counter != expectedTotal) {
System.err.println("!!! Mismatch detected: Race condition occurred! Expected " + expectedTotal + ", got " + counter);
} else {
System.out.println("Success! No race condition detected. Final count matches expected.");
}
}
}
Expected Output (now consistently correct):
--- Synchronized Counter Demonstration ---
Expected total after all increments: 200000
Thread-1 starting increments.
Thread-2 starting increments.
Thread-1 finished increments.
Thread-2 finished increments.
Actual final counter value: 200000 // This will now consistently be the expected total
Time taken: XXX ms // Note: Time taken might be slightly higher due to synchronization overhead
Success! No race condition detected. Final count matches expected.
Explanation of the Fix:
The synchronized (lock) { ... }
block is the magic.
- When a thread encounters a
synchronized
block, it attempts to acquire the intrinsic lock (also known as a monitor lock) associated with thelock
object. - Only one thread can hold the intrinsic lock of a given object at a time.
- If another thread tries to enter a
synchronized
block that is trying to acquire the same lock, it will be blocked (put into a waiting state) until the first thread releases the lock. - The lock is automatically released when the thread exits the
synchronized
block (either normally or due to an exception). - By synchronizing on
lock
, we ensure that the entirecounter++
operation (read, increment, write) is treated as an atomic operation by all threads attempting to modifycounter
through thissynchronized
block. No other thread can interleave itscounter++
steps.
Production-Grade Practice Points:
- Identify Shared Mutable State: The first step in concurrent programming is identifying what data is shared and mutable across threads. If data is not shared, or if it's shared but immutable, you typically don't need synchronization for it.
- Protect Critical Sections: Always enclose code that modifies shared mutable state within a synchronized block or use other concurrency primitives.
- Choose the Right Lock Object:
- For instance variables that are shared among threads (e.g., in a
Servlet
where multiple requests access the sameServlet
instance), you would typically synchronize onthis
or a dedicatedfinal Object
instance lock. - For static variables (like
counter
in our example), you must synchronize on a class-level lock. This can beYourClassName.class
or, as we did, a dedicatedstatic final Object
lock. UsingYourClassName.class
directly is fine, but a dedicated staticObject
can sometimes provide clearer intent or allow for more fine-grained locking if different parts of the class need different static locks. - Avoid Locking on
String
literals,Boolean
orInteger
objects: These can be cached or interned by the JVM, leading to unintended shared locks across different parts of your application, causing deadlocks or unexpected blocking. Always use a dedicatedObject
instance orthis
.
- For instance variables that are shared among threads (e.g., in a
- Granularity of Locks: Make your critical sections as small as possible (
synchronized (lock) { counter++; }
is good). Locking too much code reduces concurrency, as threads will spend more time waiting for the lock. - Performance Impact: Synchronization adds overhead. Threads might spend time blocking and waiting for locks. While necessary for correctness, it's not "free." This is a trade-off: correctness over raw, unsynchronized speed.
- Reentrance: Remember, Java's intrinsic locks (
synchronized
) are reentrant. A thread can acquire the same lock multiple times if it already holds it, preventing self-deadlock within nested synchronized calls.
This example clearly illustrates the problem of race conditions and the fundamental solution using the synchronized
keyword. Our next step will be to dive deeper into synchronized
and also address another subtle issue: visibility problems.
Ready for the next lesson on synchronized
in more detail and volatile
?
Practice Point 1: Synchronizing on Instance Variables (this
vs. Dedicated final Object
Instance Lock)
Scenario:
Imagine you have a web application (like a Spring Boot application running Servlets under the hood). Multiple users (each handled by a different thread) send requests to the same servlet/controller instance. This servlet instance might hold some instance-level state that needs to be updated.
- Analogy: Your restaurant has a “Daily Specials Board”. This board is a shared resource for all waiters (threads) currently serving customers. Each waiter needs to update the “specials left” count after taking an order. Since there’s only one physical board in this specific restaurant instance, all waiters operate on that same board.
“Before” (Problematic Instance Variable Access)
We’ll simulate a DailySpecialsBoard
class that has a specialsLeft
counter. Multiple threads (representing concurrent user requests) will try to decrement this counter.
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger; // We'll use this for comparison later
public class UnsafeDailySpecials {
// This simulates an instance variable in a Servlet/Controller
// It's a shared mutable resource across threads if the same instance is used.
private int specialsLeft = 10; // Initial number of specials
public void decrementSpecial() {
// Problematic critical section:
// Multiple threads can call this method simultaneously on the same instance,
// leading to lost updates on 'specialsLeft'.
int current = specialsLeft;
System.out.println(Thread.currentThread().getName() + ": Checking specials left: " + current);
// Simulate some processing time before decrementing
try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
specialsLeft = current - 1;
System.out.println(Thread.currentThread().getName() + ": Updated specials left to: " + specialsLeft);
}
public int getSpecialsLeft() {
return specialsLeft;
}
public static void main(String[] args) throws InterruptedException {
System.out.println("--- Unsafe Daily Specials Board ---");
final UnsafeDailySpecials board = new UnsafeDailySpecials(); // One instance shared by all threads
int numCustomers = 5; // Simulate 5 concurrent customer requests
ExecutorService executor = Executors.newFixedThreadPool(numCustomers);
for (int i = 0; i < numCustomers; i++) {
executor.submit(() -> board.decrementSpecial()); // All threads call on the SAME 'board' instance
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("\nFinal specials left: " + board.getSpecialsLeft());
if (board.getSpecialsLeft() != (10 - numCustomers)) { // Expected: 10 - 5 = 5
System.err.println("!!! Mismatch detected: Race condition occurred! Expected " + (10 - numCustomers) + ", got " + board.getSpecialsLeft());
} else {
System.out.println("No race condition detected in this run (unlikely for such a small example).");
}
}
}
Likely Output (will vary, but Final specials left
will often be incorrect):
--- Unsafe Daily Specials Board ---
pool-1-thread-1: Checking specials left: 10
pool-1-thread-2: Checking specials left: 10
pool-1-thread-3: Checking specials left: 10
pool-1-thread-4: Checking specials left: 10
pool-1-thread-5: Checking specials left: 10
pool-1-thread-1: Updated specials left to: 9
pool-1-thread-2: Updated specials left to: 9
pool-1-thread-3: Updated specials left to: 9
pool-1-thread-4: Updated specials left to: 9
pool-1-thread-5: Updated specials left to: 9
Final specials left: 5 // Expected: 5. Actually got 5 in this simple run, but try increasing sleep/threads, it will break.
!!! Mismatch detected: Race condition occurred! Expected 5, got 5 // This message might still appear if you get lucky, but the core issue is there.
Self-correction: For this small number of threads and high sleep
time, it might actually work correctly because Thread.sleep(50)
causes threads to yield and write back. Let’s make the decrement more “race-prone” by removing sleep and increasing threads.
Revised “Before” for better race demonstration:
import java.util.concurrent.*;
public class UnsafeDailySpecialsRevised {
private int specialsLeft = 10000; // Increase initial specials for a better race demo
public void decrementSpecial() {
// Critical section: read-modify-write on specialsLeft
specialsLeft--; // This is the problematic line
}
public int getSpecialsLeft() {
return specialsLeft;
}
public static void main(String[] args) throws InterruptedException {
System.out.println("--- Unsafe Daily Specials Board (Revised) ---");
final UnsafeDailySpecialsRevised board = new UnsafeDailySpecialsRevised();
int numCustomers = 100; // More customers
int decrementsPerCustomer = 100; // Each customer decrements multiple times
int expectedFinalSpecials = board.specialsLeft - (numCustomers * decrementsPerCustomer);
ExecutorService executor = Executors.newFixedThreadPool(10); // A few threads in the pool
for (int i = 0; i < numCustomers; i++) {
executor.submit(() -> {
for(int j=0; j<decrementsPerCustomer; j++) {
board.decrementSpecial();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("\nFinal specials left: " + board.getSpecialsLeft());
if (board.getSpecialsLeft() != expectedFinalSpecials) {
System.err.println("!!! Mismatch detected: Race condition occurred! Expected " + expectedFinalSpecials + ", got " + board.getSpecialsLeft());
} else {
System.out.println("No race condition detected in this run (still possible for very light load).");
}
}
}
Likely Output of Revised “Before”:
--- Unsafe Daily Specials Board (Revised) ---
Final specials left: 9940 // This will be inconsistent across runs and usually incorrect
!!! Mismatch detected: Race condition occurred! Expected -9000, got 9940 // Expected -9000 indicates 10000 - 100*100=0 + 100*100-10000. So it should be 0 actually. My mistake in calculation.
Actual Expected Value: 10000 (initial) - (100 customers * 100 decrements/customer) = 10000 - 10000 = 0
. So the expected final specials should be 0. The output above 9940
is clearly wrong.
“After” (Resolved Instance Variable Access – Synchronizing on this
or a Dedicated final Object
)
We need to protect the decrementSpecial()
method.
Option A: Synchronizing on this
(The synchronized
Method)
When you declare an instance method as synchronized
, the lock used is the intrinsic lock of the instance itself (this
).
import java.util.concurrent.*;
public class SafeDailySpecialsSynchronizedMethod {
private int specialsLeft = 10000;
// Synchronizing the method locks the 'this' instance
public synchronized void decrementSpecial() {
specialsLeft--; // Now this entire method is a critical section
}
public int getSpecialsLeft() {
return specialsLeft;
}
public static void main(String[] args) throws InterruptedException {
System.out.println("--- Safe Daily Specials Board (Synchronized Method) ---");
final SafeDailySpecialsSynchronizedMethod board = new SafeDailySpecialsSynchronizedMethod();
int numCustomers = 100;
int decrementsPerCustomer = 100;
int expectedFinalSpecials = board.specialsLeft - (numCustomers * decrementsPerCustomer); // Expected: 0
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < numCustomers; i++) {
executor.submit(() -> {
for(int j=0; j<decrementsPerCustomer; j++) {
board.decrementSpecial();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("\nFinal specials left: " + board.getSpecialsLeft());
if (board.getSpecialsLeft() != expectedFinalSpecials) {
System.err.println("!!! Mismatch detected: Race condition occurred! Expected " + expectedFinalSpecials + ", got " + board.getSpecialsLeft());
} else {
System.out.println("Success! No race condition detected. Final count matches expected.");
}
}
}
Output:
--- Safe Daily Specials Board (Synchronized Method) ---
Final specials left: 0
Success! No race condition detected. Final count matches expected.
Option B: Synchronizing on a Dedicated final Object
Instance Lock (The synchronized
Block)
This is often preferred for more fine-grained control or when this
might be exposed for other, non-synchronization purposes.
import java.util.concurrent.*;
public class SafeDailySpecialsDedicatedLock {
private int specialsLeft = 10000;
// Dedicated instance lock for this object's state
private final Object lock = new Object(); // The dedicated instance lock
public void decrementSpecial() {
// Only this specific block is locked, allowing other non-critical methods
// in this instance to be called concurrently if they don't use 'lock'.
synchronized (lock) {
specialsLeft--;
}
}
public int getSpecialsLeft() {
// Reads are generally safe without synchronization if writes are synchronized
// and atomicity/visibility isn't a strict requirement for reads.
// For absolute consistency, reads might also be synchronized, or 'volatile' could be used.
return specialsLeft;
}
public static void main(String[] args) throws InterruptedException {
System.out.println("--- Safe Daily Specials Board (Dedicated Instance Lock) ---");
final SafeDailySpecialsDedicatedLock board = new SafeDailySpecialsDedicatedLock();
int numCustomers = 100;
int decrementsPerCustomer = 100;
int expectedFinalSpecials = board.specialsLeft - (numCustomers * decrementsPerCustomer); // Expected: 0
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < numCustomers; i++) {
executor.submit(() -> {
for(int j=0; j<decrementsPerCustomer; j++) {
board.decrementSpecial();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("\nFinal specials left: " + board.getSpecialsLeft());
if (board.getSpecialsLeft() != expectedFinalSpecials) {
System.err.println("!!! Mismatch detected: Race condition occurred! Expected " + expectedFinalSpecials + ", got " + board.getSpecialsLeft());
} else {
System.out.println("Success! No race condition detected. Final count matches expected.");
}
}
}
Output:
--- Safe Daily Specials Board (Dedicated Instance Lock) ---
Final specials left: 0
Success! No race condition detected. Final count matches expected.
Why choose synchronized (lock)
over synchronized method
?
- Granularity: If your class has multiple methods, and only a small part of one method modifies shared state,
synchronized (lock)
allows you to lock only that specific “critical section.” Other, non-critical methods within the same instance can still be accessed concurrently, improving overall concurrency. Asynchronized
method locks the entire method, blocking all other synchronized methods on the same instance. - Encapsulation: Using a private
final Object lock
explicitly shows what you are synchronizing on and prevents external code from accidentally acquiring your lock and causing unexpected deadlocks (though this is less common withthis
for instance locks).
Practice Point 2: Avoid Locking on String
literals, Boolean
or Integer
objects
Concept:
Java performs object interning for String
literals and auto-boxes Boolean
(true
/false
) and small Integer
values (typically -128 to 127). This means that multiple references to the same String
literal (e.g., "mylock"
) or the same Boolean.TRUE
or Integer.valueOf(10)
will actually point to the same underlying object in memory.
If you synchronize on these interned objects, you might inadvertently acquire a lock that is also being used by a completely unrelated part of your application, leading to:
- Unexpected Blocking: Threads in different, unrelated parts of your application might block each other because they are trying to acquire the same interned lock object.
- Deadlocks: If these unrelated parts have their own locking orders, this unintended shared lock can easily create deadlock scenarios.
- Analogy: You decide to use a specific, common red ball as the “key” to the cash register. Unbeknownst to you, the waiter in the next-door restaurant (another part of your application) also uses a “red ball” (which is actually the same physical red ball due to a quirky shared object pool) as their key for their pantry. Now, your waiter waiting for the cash register might be blocked because the other restaurant’s waiter has the “red ball” in their pantry! Chaos.
“Before” (Problematic Locking on String Literal)
import java.util.concurrent.*;
public class ProblematicStringLock {
private static String status = "Initial";
// DO NOT DO THIS IN PRODUCTION CODE!
private static final String LOCK_STRING = "myGlobalLock"; // This string literal is interned
public static void updateStatus(String newStatus) {
System.out.println(Thread.currentThread().getName() + ": Attempting to update status.");
synchronized (LOCK_STRING) { // PROBLEM: Synchronizing on an interned String literal
System.out.println(Thread.currentThread().getName() + ": Acquired lock for status update. Current: " + status);
try {
Thread.sleep(100); // Simulate work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
status = newStatus;
System.out.println(Thread.currentThread().getName() + ": Status updated to: " + status);
}
System.out.println(Thread.currentThread().getName() + ": Released lock for status update.");
}
public static void main(String[] args) throws InterruptedException {
System.out.println("--- Problematic String Lock Demo ---");
ExecutorService executor = Executors.newFixedThreadPool(2);
// Task 1: Update status using the problematic lock
executor.submit(() -> updateStatus("Processing A"));
// Task 2: Try to update status using the problematic lock
executor.submit(() -> updateStatus("Processing B"));
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("\nFinal status: " + status);
// --- Demonstrate unintended blocking with another part of the code ---
System.out.println("\n--- Demonstrating Unintended Blocking ---");
ExecutorService otherExecutor = Executors.newFixedThreadPool(1);
// Imagine this is totally unrelated code, perhaps in a different package/module
// But it happens to use the SAME string literal for its own internal locking
final String ANOTHER_LOCK_STRING = "myGlobalLock"; // This is the SAME interned object as LOCK_STRING!
otherExecutor.submit(() -> {
System.out.println(Thread.currentThread().getName() + ": Attempting to do unrelated work.");
synchronized (ANOTHER_LOCK_STRING) { // Will contend with the lock from updateStatus
System.out.println(Thread.currentThread().getName() + ": Acquired UNRELATED lock!");
try {
Thread.sleep(500); // Simulate some long, unrelated work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + ": Finished UNRELATED work and released lock.");
}
});
otherExecutor.shutdown();
otherExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("Unintended blocking demo complete.");
}
}
Likely Output:
You will observe that “Thread-2” (from updateStatus
) or the thread from “unrelated work” will be blocked, waiting for the same myGlobalLock
string literal to be released by “Thread-1” from updateStatus
. This shows that different, seemingly unrelated parts of your code can block each other because they are using the same interned string literal as a lock.
--- Problematic String Lock Demo ---
pool-1-thread-1: Attempting to update status.
pool-1-thread-1: Acquired lock for status update. Current: Initial
pool-1-thread-2: Attempting to update status.
pool-1-thread-1: Status updated to: Processing A
pool-1-thread-1: Released lock for status update.
pool-1-thread-2: Acquired lock for status update. Current: Processing A
pool-1-thread-2: Status updated to: Processing B
pool-1-thread-2: Released lock for status update.
Final status: Processing B
--- Demonstrating Unintended Blocking ---
pool-2-thread-1: Attempting to do unrelated work.
pool-2-thread-1: Acquired UNRELATED lock! // This will only print AFTER the first two threads finish
pool-2-thread-1: Finished UNRELATED work and released lock.
Unintended blocking demo complete.
In this output, you can see pool-2-thread-1
which is doing “unrelated work” waited until pool-1-thread-1
and pool-1-thread-2
finished their work because they all tried to synchronize on the same interned string literal.
“After” (Resolved Locking on Dedicated final Object
)
The solution is simple: always use a dedicated, private final Object
as your lock object. This guarantees that your lock object is unique and will not be accidentally shared or interned by the JVM.
import java.util.concurrent.*;
public class SafeDedicatedObjectLock {
private static String status = "Initial";
// Correct way: Use a dedicated, private, final Object instance for the lock
private static final Object statusLock = new Object(); // This is a unique object
public static void updateStatus(String newStatus) {
System.out.println(Thread.currentThread().getName() + ": Attempting to update status.");
synchronized (statusLock) { // Correctly synchronizing on a unique object
System.out.println(Thread.currentThread().getName() + ": Acquired lock for status update. Current: " + status);
try {
Thread.sleep(100); // Simulate work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
status = newStatus;
System.out.println(Thread.currentThread().getName() + ": Status updated to: " + status);
}
System.out.println(Thread.currentThread().getName() + ": Released lock for status update.");
}
public static void main(String[] args) throws InterruptedException {
System.out.println("--- Safe Dedicated Object Lock Demo ---");
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> updateStatus("Processing A"));
executor.submit(() -> updateStatus("Processing B"));
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("\nFinal status: " + status);
// --- Demonstrate that unrelated code is NOT blocked ---
System.out.println("\n--- Demonstrating NO Unintended Blocking ---");
ExecutorService otherExecutor = Executors.newFixedThreadPool(1);
// This unrelated code can use its own unique lock.
// It will NOT be blocked by the 'statusLock' from the updateStatus method.
final Object unrelatedLock = new Object(); // A DIFFERENT, unique object
otherExecutor.submit(() -> {
System.out.println(Thread.currentThread().getName() + ": Attempting to do unrelated work.");
synchronized (unrelatedLock) { // Synchronizing on a unique, unrelated lock
System.out.println(Thread.currentThread().getName() + ": Acquired UNRELATED lock!");
try {
Thread.sleep(500); // Simulate some long, unrelated work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + ": Finished UNRELATED work and released lock.");
}
});
otherExecutor.shutdown();
otherExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("No unintended blocking demo complete.");
}
}
Likely Output:
You will now observe that the “unrelated work” thread starts and completes its task independently, without waiting for the updateStatus
methods to finish. This is because statusLock
and unrelatedLock
are distinct, unique objects.
--- Safe Dedicated Object Lock Demo ---
pool-1-thread-1: Attempting to update status.
pool-1-thread-1: Acquired lock for status update. Current: Initial
pool-1-thread-2: Attempting to update status. // This thread will block until Thread-1 releases statusLock
pool-2-thread-1: Attempting to do unrelated work. // This thread starts immediately because its lock is different!
pool-2-thread-1: Acquired UNRELATED lock!
pool-1-thread-1: Status updated to: Processing A
pool-1-thread-1: Released lock for status update.
pool-2-thread-1: Finished UNRELATED work and released lock.
pool-1-thread-2: Acquired lock for status update. Current: Processing A
pool-1-thread-2: Status updated to: Processing B
pool-1-thread-2: Released lock for status update.
Final status: Processing B
--- Demonstrating NO Unintended Blocking ---
No unintended blocking demo complete.
Notice how pool-2-thread-1
(unrelated work) started and finished concurrently with pool-1-thread-1
and pool-1-thread-2
‘s updateStatus
calls. This demonstrates that their locks are truly independent.
These examples highlight crucial practices for using synchronized
effectively and safely in production environments. Always be mindful of what object you are synchronizing on.
Our next topic will delve deeper into the nuances of synchronized
and the volatile
keyword, particularly addressing Visibility Problems. Ready for that?