Java Thread Safety - Synchronized
Table of Contents
- What is
synchronized
? - Scope of the
synchronized
Keyword - Why Use the
synchronized
Keyword? - Characteristics of the
synchronized
Keyword - Optimizing
synchronized
Lock Usage
What is synchronized
?
When introducing this keyword, I want to start with a real-life scenario. Imagine there’s a restroom that only one person can use at a time. If two people try to use it simultaneously, it could cause issues (not ideal). Some code behaves similarly. In a multi-threaded environment, if multiple threads call a piece of code simultaneously, the result of that code could be inconsistent across threads. Therefore, we use synchronized
, which is a synchronization lock that ensures that code execution remains synchronized at any given time.
In other words, synchronized
works like a lock on the restroom stall. Once someone enters and locks the door, only they can use the restroom. Others without the key can only wait outside until the first person finishes and releases the lock, passing it to the next person in line.
So, what exactly is a lock? (It feels a bit abstract) Let’s read a snippet of code:
public class SynchronizedUse {
private int count = 10;
// Lock object
private Object o = new Object();
public void m() {
synchronized (o) { // To execute the following code, the lock on object o must first be obtained
count--;
System.out.println(Thread.currentThread().getName() + " count= " + count);
}
}
}
In this code, we create a new Object
and use synchronized
on this object o
. Many people misunderstand locks, thinking that synchronized
“locks” the code block it’s used on. In reality, synchronized
locks the object itself. To execute the code block modified by synchronized
, you must first obtain the lock for the object o
.
Since there is only one object, this code ensures that only one thread can obtain the lock at a time, so only one thread can execute the locked code.
Scope of the synchronized
Keyword
Does this mean I have to create a new
Object
every time I want to synchronize some code?
No. By reading the following code, you’ll see that you can directly use the this
object to lock code, which is a simplified approach.
public class SynchronizedUse02 {
private int count = 10;
private void m() {
synchronized (this) {
count--;
System.out.println(Thread.currentThread().getName() + " count= " + count);
}
}
}
In addition to enclosing a code block with braces {}
(synchronized block), the synchronized
keyword can also be used to modify a method. A modified method is called a synchronized method, which effectively locks the this
object. As shown in the code below, this usage is equivalent to SynchronizedUse02
(the previous example).
public class SynchronizedUse03 {
private int count = 10;
public synchronized void m() { // Equivalent to synchronized(this) { ... }
count--;
System.out.println(Thread.currentThread().getName() + " count= " + count);
}
}
Additionally, the synchronized
keyword can also modify a static method, and its scope extends to the entire static method, affecting all instances of the class. Those familiar with Java’s static
keyword know that synchronized
on a static
method doesn’t apply to any instantiated object but rather to the class itself. The following code demonstrates this:
public class SynchronizedUse04 {
private static int count = 10;
// When synchronized is used on a static method, it essentially locks the class
public synchronized static void m() {
count--;
System.out.println(Thread.currentThread().getName() + " count= " + count);
}
// Equivalent method
public static void mm() {
// In essence, it’s a reflection
synchronized (SynchronizedUse04.class) {
count--;
}
}
}
Thus, even if I instantiate multiple SynchronizedUse04
objects and call the static method m()
in different threads, synchronization is maintained because static methods belong to the class, not an instance, and they synchronize across all objects of that class.
Why Use the synchronized
Keyword?
We’ve been emphasizing “synchronization,” so why is synchronization so important in high-concurrency programs? Let’s use a small demo to illustrate:
public class SynchronizedUse05 implements Runnable {
private int count = 10;
// Without a lock, duplicate numbers are likely to appear, and numbers won’t be printed in order.
// Each synchronized code block represents an atomic operation, which is indivisible.
public synchronized void run() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
/*
* The main method actually launches only one method from one object.
* However, in the for loop, multiple threads access the same object, t.
*/
public static void main(String[] args) {
SynchronizedUse05 t = new SynchronizedUse05();
for (int i = 0; i < 8; i++) {
// Eight new threads all accessing the run() method in t.
new Thread(t, "THREAD" + i).start();
}
}
}
In this example, we start eight different threads, each calling the run()
method in t
. Each thread decrements the variable count
. The run()
method has a synchronization lock, so if the lock is removed, it’s easy to see inconsistent results or duplicate numbers and out-of-order printing, as shown in the example below. We can understand it as follows:
Without the lock, running results (not necessarily the same each time):
THREAD5 count = 4
THREAD6 count = 3
THREAD7 count = 2
THREAD3 count = 6
THREAD2 count = 7
THREAD0 count = 9
THREAD1 count = 8
THREAD4 count = 5
With the lock, results are consistent each time:
THREAD0 count = 9
THREAD7 count = 8
THREAD6 count = 7
THREAD5 count = 6
THREAD4 count = 5
THREAD3 count = 4
THREAD2 count = 3
THREAD1 count = 2
Synchronizing read-write code in multithreaded environments can also avoid common Dirty Read issues. A dirty read occurs when a write method is locked but a read method is not. At the same time, only one write thread can proceed, but reads are unlimited, leading to possible data errors. In this case, if another thread reads data while writing is incomplete, it may read incorrect data, leading to potential NullPointerExceptions or inexplicable errors. The following demo illustrates this:
public class SynchronizedUse07 {
String name;
double balance;
private synchronized void set(String name, double balance) {
this.name = name;
// Adding a lock while writing; if other programs run during write time,
// and the read program in another thread accesses the information,
// the write operation may not complete, causing incorrect data.
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance = balance;
}
private /* synchronized */ double getBalance(String name) {
return this.balance;
}
public static void main(String[] args) {
SynchronizedUse07 a = new SynchronizedUse07();
new Thread(() -> a.set("zhangsan", 100.0)).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));
}
}
In the code above, we commented out the synchronization lock synchronized
on the getBalance(String name)
method but kept it for the write (initialization) method. This results in a dirty read problem. To highlight the issue, we added a delay in set()
:
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
In the business logic, writing data can be time-consuming (we added a 2-second delay). If the read operation isn’t locked, it can start before the write completes and read incorrect data (we read after a 1-second delay, while the write isn’t yet complete). Thus, the first read retrieves 0.0 (uninitialized value), while the second read (after writing completes) correctly retrieves 100.0.
Program output:
0.0 // Read after 1 second, write incomplete
100.0 // Read after 3 seconds, write (approx. 2 seconds) completed
From these examples, we now have a basic understanding of the synchronized
keyword’s purpose and usage.
Characteristics of the synchronized
Keyword
Can a synchronized method be called concurrently with a non-synchronized method?
We can easily answer this based on the dirty read example: Yes. For clarity, here’s a demo; feel free to run it and explore:
public class SynchronizedUse06 {
// m1
is a synchronized method; can m2 be called during m1 execution? Answer: Yes, it can.
private synchronized void m1() {
System.out.println(Thread.currentThread().getName() + " m1 start...");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " m1 end.");
}
private void m2() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " m2.");
}
public static void main(String[] args) {
SynchronizedUse06 t = new SynchronizedUse06();
new Thread(t::m1, "t1:").start();
new Thread(t::m2, "t2:").start();
}
}
Here, m1()
is a synchronized method with a 10-second delay, while m2()
is non-synchronized. We create two threads, t1
runs m1()
and t2
runs m2()
. We can see that m1()
and m2()
can run simultaneously:
t1: m1 start...
t2: m2.
t1: m1 end.
We used Java 8’s lambda expressions to simplify the code, known as “syntax sugar.” Feel free to explore this feature further.
Can synchronized methods call each other?
Let’s look at a simple program:
/**
* Synchronized keyword usage example 08
* One synchronized method can call another synchronized method
* A thread that already owns the lock can re-acquire it.
* In other words, synchronized locks are re-entrant.
*
* @author huangyz0918
*/
public class SynchronizedUse08 {
private synchronized void m1() {
System.out.println("m1 start...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();
}
private synchronized void m2() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2.");
}
public static void main(String[] args) {
SynchronizedUse08 t = new SynchronizedUse08();
t.m1();
}
}
Output:
m1 start...
m2.
In the code, we call the synchronized method m1()
, which calls m2()
, demonstrating that synchronized methods can indeed call each other. Both methods require the same lock, so when m2()
is called within m1()
with the lock already held, it can still obtain the lock to execute. This shows that synchronized
locks are re-entrant.
Are synchronized methods inherited by subclasses?
No, the synchronized
keyword is not inherited.
Although you can define methods with synchronized
, the keyword is not part of the method definition, so it cannot be inherited. If a method in the superclass is synchronized and overridden in the subclass, the overridden method in the subclass is not synchronized by default. The synchronized
keyword must be explicitly added in the subclass. Alternatively, the subclass method can call the superclass’s method, making the subclass method synchronized implicitly. Here’s a demo:
public class SynchronizedUse09 {
public static void main(String[] args) {
T t = new T();
t.m();
}
synchronized void m() {
System.out.println("m start...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m end.");
}
}
class T extends SynchronizedUse09 {
@Override
synchronized void m() {
System.out.println("child m start...");
super.m();
System.out.println("child m end.");
}
}
In this example, m()
is explicitly synchronized, making it a synchronized method. If the synchronized
keyword is omitted, m()
won’t be synchronized. However, calling super.m()
still requires a lock, so the method waits until it’s available.
What happens when a program, while holding a lock, throws an exception and exits the synchronized block—does the lock get released?
By default, if an exception occurs during execution, the lock is released.
We can create a demo to verify this:
public class SynchronizedUse10 {
int count = 0;
synchronized void m() {
System.out.println(Thread.currentThread().getName() + " start...");
while (true) {
count++;
System.out.println(Thread.currentThread().getName() + " count = " + count);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (count == 5) {
int i = 1 / 0; // An exception is thrown here, releasing the lock. To prevent lock release, catch the exception and continue.
}
}
}
public static void main(String[] args) {
SynchronizedUse10 t = new SynchronizedUse10();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(t::m, "t2").start();
}
}
Output:
t1 start...
t1 count = 1
t1 count = 2
t1 count = 3
t1 count = 4
t1 count = 5
t2 start...
Exception in thread "t1" java.lang.ArithmeticException: / by zero
t2 count = 6
at learn.multithreading.synchronizedlearn.SynchronizedUse10.m(SynchronizedUse10.java:32)
at java.base/java.lang.Thread.run(Thread.java:844)
t2 count = 7
t2 count = 8
t2 count = 9
t2 count = 10
t2 count = 11
t2 count = 12
We use an infinite loop, and when count == 5
, we manually throw an exception, causing thread t1
to exit the synchronized method m()
and release the lock. As seen, after t1
throws an exception, t2
quickly acquires the lock and starts execution. This shows that during execution, if an exception occurs, the lock is released by default. Therefore, when handling concurrency, special care should be taken with exception handling to avoid inconsistency. For example, in a web app where multiple servlet threads access a resource, an exception in one thread might cause others to enter the synchronized code block and access inconsistent data.
Optimizing synchronized
Lock Usage
Although Java has significantly optimized synchronized
locks, their performance is still lower compared to other synchronization mechanisms. When using this keyword, consider the lock granularity to avoid unnecessary computational resource waste.
Example:
public class SynchronizedUse11 {
private int count = 0;
synchronized void m1() {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++; // Only this part of the business logic needs synchronization, so locking the entire method is unnecessary.
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
void m2() {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Only this block of code needs synchronization, so don’t lock the entire method.
// Using fine-grained locks reduces the time threads spend competing, improving efficiency.
synchronized (this) {
count++;
}
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Here, only count++
lacks atomicity, so it’s prone to synchronization issues. There’s no need to lock the entire method. The use of the synchronized
keyword is flexible and should always be evaluated for efficiency during development.
Also, remember that synchronized
locks the object in heap memory. When the locked object’s properties change or its reference points to a new object in the heap, the lock changes. This should be avoided in practice:
public class SynchronizedUse12 {
public static void main(String[] args) {
SynchronizedUse12 t = new SynchronizedUse12();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t2 = new Thread(t::m, "t2");
t.o = new Object(); // The lock object changes, so t2 can execute, otherwise t2 would never run.
t2.start();
}
Object o = new Object();
void m() {
synchronized (o) { // This demonstrates: the lock is on the object in heap memory, not the reference in stack memory.
while (true) {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStack
Trace();
}
System.out.println(Thread.currentThread().getName());
}
}
}
}
Output:
t1
t1
t2 // t2 thread starts here as the lock object has changed
t1
t2
t1
t2
t1
....
In this program, we use t.o = new Object();
to change the lock object to a new one in heap memory. At this point, threads t1
and t2
are not using the same lock, allowing t2
to execute. Otherwise, in the infinite loop of method m()
, t2
would never run until t1
finished.
Lastly, avoid using string objects as locks in practice. Why? If you use a library that locks a string object A
, and you also lock string object A
in your code, two unrelated pieces of code might use the same lock, causing mysterious deadlock and blocking issues. Such problems are hard to troubleshoot. Additionally, identical strings point to the same memory address, so it seems like different locks are used, but they aren’t:
public class SynchronizedUse13 {
private String s1 = "Hello";
private String s2 = "Hello";
// The strings s1 and s2 point to the same object in heap memory.
// The stack memory stores primitive variables and object reference handles.
// The heap memory holds object instances.
void m1() {
synchronized (s1) {
}
}
void m2() {
synchronized (s2) {
}
}
}
In conclusion, using Java’s synchronized
keyword is flexible and requires continuous practice and careful attention to ensure efficient and correct code.