1. What is a shared resource
You’ve already dealt with shared resources. A home where a family lives — a shared resource of that family. An office fridge — a shared resource for all colleagues. You get the idea.
In programming, a shared resource is a variable, object, or data structure that multiple threads can access simultaneously. It can be:
- A counter variable for the number of processed orders.
- A request list filled by some threads and processed by others.
- An open file being written to by multiple threads.
- A database connection used by different parts of the program.
In Java, any object or variable that multiple threads can reach becomes a potential “shared resource.”
Example of a shared resource: a global counter
public class Counter {
public int value = 0;
}
If several threads increase this counter, they will access the same value variable — that’s your shared resource.
2. Problems of concurrent access
In a single-threaded program, everything is simple: one thread — one executor — it moves through the code like a train on rails. But as soon as multiple threads come into play, a real “saber dance” begins: threads can interfere with each other’s work in the most unexpected places.
Race condition
A race condition is a situation where the result of the program depends on how exactly the actions of threads interleaved. That is, if you run the same program several times, the result may differ — and that’s not a bug, it’s a “feature” of multithreading.
Classic example: two threads increment a counter
Let’s model a simple situation: we have a shared counter, and two threads increment its value a thousand times each.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // DANGER ZONE!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Expected value: 2000");
System.out.println("Actual value: " + counter.value);
}
}
What will we see on the screen?
Sometimes 2000, sometimes 1985, sometimes 1937... Why? Because the operation counter.value++ is not atomic! It consists of three steps:
- Read the current value of counter.value.
- Increase it by 1.
- Write it back.
If two threads read the same value at the same time, both increase it, and then both write the result back — one increment will be “lost.” This is a lost update.
Inconsistent object state
If you have a complex object consisting of several fields, and threads simultaneously change different fields, the object may end up in a “weird” or inconsistent state. For example, the account balance decreased, but the transaction history did not update — the client panics, the accountant is shocked.
3. Why synchronization is needed
Synchronization is a way to tell the program: “Stop, this piece of code must be executed by only one thread at a time! The others will wait!” It’s like a sign on a restroom door: “Cleaning! Do not enter!” While one person is inside, everyone else waits outside (and silently curses whoever is taking too long).
Guaranteeing data integrity
If we want our counter to always increment correctly, we must forbid simultaneous modification of its value by multiple threads.
Example: synchronizing counter increment
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
Now, if two threads call increment(), only one of them can execute this method at that moment. The second will wait until the first finishes.
Diagram: what happens during synchronization
+-------------------+
| Thread 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Thread 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Thread 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Thread N |/
+-------------------+
All threads line up to execute the protected section of code. Only one thread can be inside the “critical section” (the synchronized block) at a time.
4. A quick introduction to synchronization techniques
Synchronization in Java is not a single technique but a whole arsenal of tools that protect a shared resource from concurrent access.
The synchronized keyword
This is the primary synchronization tool in Java. You can use it in two ways:
Synchronized method
public synchronized void increment() {
value++;
}
Synchronized block
public void increment() {
synchronized (this) {
value++;
}
}
Here, this is the object being locked. While one thread executes this block, other threads wanting to enter the same block with the same object will wait.
Specialized classes from java.util.concurrent
- Lock, ReentrantLock — a more flexible alternative to synchronized.
- ReadWriteLock — to split locks into read and write.
- Semaphore — limit the number of threads executing code simultaneously.
- CountDownLatch, CyclicBarrier, and others — to coordinate thread work.
Important: Today we’re just getting acquainted with the basics — we’ll talk about these classes a bit later.
5. Practical example: an application with a multithreaded counter
Suppose we’re collecting user request statistics for some service. Each thread is a separate user who increments the shared counter.
Without synchronization
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Expected value: 30000");
System.out.println("Actual value: " + counter.value);
}
}
Result: Almost always less than 30000. Sometimes — much less! Why? Because threads interfere with one another.
Synchronization: fixing the bug
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Expected value: 30000");
System.out.println("Actual value: " + counter.value);
}
}
Result: Always 30000. Hooray, synchronization works!
6. Useful nuances
Visualization: what a race condition looks like
Let’s draw a small table to show how two threads can “lose” an increment:
| Step | Thread 1 | Thread 2 | Value |
|---|---|---|---|
| 1 | Reads value=0 | 0 | |
| 2 | Reads value=0 | 0 | |
| 3 | Increments to 1 | 0 | |
| 4 | Increments to 1 | 0 | |
| 5 | Writes 1 | 1 | |
| 6 | Writes 1 | 1 |
When synchronization is needed
Synchronization isn’t always necessary. When a variable lives in its own little world and only one thread works with it, you can relax. But as soon as you share it with other threads, you can’t go without synchronization. Even if it seems everything will be fine — don’t believe it. A race bug is insidious: it can hide for a long time and then suddenly surface at the worst possible moment.
Looking ahead: other synchronization tools
Today we met only the basic tool — synchronized. In the next lectures, we will cover:
- How an object monitor works and what types of locks there are.
- What static synchronized methods are (static + synchronized).
- How the volatile keyword works and why it’s needed.
- What modern synchronization classes exist (Lock, Semaphore, etc.).
7. Common mistakes when working with shared resources
Mistake No. 1: Ignoring multithreading.
One of the most common mistakes is not thinking about a variable being accessible from multiple threads. Even if the program is single-threaded now, later someone will add threads — and bugs will appear “out of nowhere.”
Mistake No. 2: Insufficient or excessive synchronization.
If you don’t synchronize access to a shared resource, you’ll get race conditions and inconsistent data. If you synchronize everything indiscriminately, the program will “choke” on locks and become slow. Always try to synchronize only what truly needs it.
Mistake No. 3: Synchronizing on the wrong object.
If you synchronize on different objects (for example, local variables or string literals), that won’t protect the shared resource. All threads must synchronize on the same object.
Mistake No. 4: Expecting atomicity from non-atomic operations.
The operation i++ is not atomic! Even if a variable is declared volatile, that does not make the increment atomic. Such operations require synchronization.
Mistake No. 5: “I got lucky, everything works for me.”
A race condition may not show up on your computer, but it will definitely show up on the server or for a user. Never rely on “maybe it’ll be fine” in multithreaded programs!
GO TO FULL VERSION