15 What's the Difference Between Synchronized and Reentrant Lock

15 What’s the difference between synchronized and ReentrantLock #

Starting from today, we will enter the stage of learning Java concurrency. Software concurrency has become a fundamental skill in modern software development, and the efficient concurrency mechanism carefully designed in Java is one of the foundations for building large-scale applications. Therefore, assessing the basic skills of concurrency has become a must-have requirement for companies to interview Java engineers.

Today I want to ask you a question, what is the difference between synchronized and ReentrantLock? Some people say that synchronized is the slowest, is this statement reliable?

Typical Answer #

synchronized is a built-in synchronization mechanism in Java, so it is also known as Intrinsic Locking. It provides mutual exclusion semantics and visibility. When a thread has acquired the lock, other threads trying to acquire it can only wait or block there.

Before Java 5, synchronized was the only means of synchronization. In code, synchronized can be used to modify methods or specific code blocks. Essentially, a synchronized method is equivalent to enclosing all method statements in a synchronized block.

ReentrantLock, usually translated as reentrant lock, is a lock implementation provided by Java 5. Its semantics are similar to synchronized. The reentrant lock is acquired by directly calling the lock() method in the code, and the code is more flexible. At the same time, ReentrantLock provides many useful methods that can achieve detailed control that synchronized cannot achieve, such as fairness control or using defined conditions. However, when coding, it is also necessary to make sure to call the unlock() method to release the lock, otherwise, it will hold the lock indefinitely.

The performance of synchronized and ReentrantLock cannot be generalised. In earlier versions, synchronized had a significant performance difference in many scenarios. Many improvements were made in subsequent versions, and it may perform better than ReentrantLock in low-competition scenarios.

Analysis of Test Points #

Today’s question is a common basic question that tests concurrent programming. The typical answer I provided can be considered a relatively comprehensive summary.

For concurrent programming, different companies or interviewers may have different interview styles. Some few large companies like to continuously ask you about the extension or underlying mechanisms of related mechanisms, while some prefer to approach it from a practical perspective. Therefore, you need to be patient when preparing for concurrent programming.

In my opinion, as one of the basic tools of concurrency, you should at least master:

  • Understanding what thread safety is.
  • Basic usage and examples of mechanisms like synchronized, ReentrantLock.

Furthermore, you also need to:

  • Master the underlying implementations of synchronized, ReentrantLock; understand lock inflation, deflation; understand concepts like biased lock, spin lock, lightweight lock, heavyweight lock.
  • Master various different implementations and case analysis of java.util.concurrent.lock in the concurrent package.

Knowledge Expansion #

In the previous articles, we have introduced some concepts related to concurrency. Some students have mentioned that they found it difficult to understand, especially for those who are unfamiliar with these concepts. Therefore, in this lesson, I will provide some additional explanations on basic concepts.

First, we need to understand what thread safety is.

I recommend reading the book “Java Concurrency in Practice” written by experts like Brain Goetz. Although it may seem academic, it is undeniable that this is a very systematic and comprehensive book on Java concurrency programming. According to its definition, thread safety is a concept related to the correctness of a multi-threaded environment, specifically the correctness of shared and modifiable states in a multi-threaded environment. In the context of a program, these states can be seen as data.

From a different perspective, if the state is not shared or not modifiable, there is no thread safety issue. Based on this, we can infer two methods to ensure thread safety:

  • Encapsulation: By encapsulating the state, we can hide and protect the internal state of an object.
  • Immutability: Do you remember the emphasis on final and immutable in Lesson 3? It’s the same reasoning. Currently, Java does not have true native immutability, but it may be introduced in the future.

Thread safety needs to ensure several fundamental characteristics:

  • Atomicity: Simply put, related operations should not be interfered by other threads, and this is generally achieved through synchronization mechanisms.
  • Visibility: When a thread modifies a shared variable, its new state should be immediately known by other threads. This is usually interpreted as reflecting the thread’s local state to the main memory. volatile is responsible for ensuring visibility.
  • Ordering: It ensures the serial semantics within a thread, and avoids instruction reordering, etc.

Perhaps this is a bit difficult to understand, so let’s take a look at the following code snippet to analyze where the requirement for atomicity is reflected. This example simulates two operations on a shared state by comparing the values obtained from two separate readings.

You can compile and execute it, and you will see that even with a low degree of concurrency between the two threads, it is very easy to encounter a situation where former is not equal to latter. This is because during the process of reading values twice, other threads may have already modified the sharedState.

public class ThreadSafeSample {
  public int sharedState;
  public void nonSafeAction() {
      while (sharedState < 100000) {
          int former = sharedState++;
          int latter = sharedState;
          if (former != latter - 1) {
              System.out.printf("Observed data race, former is " +
                      former + ", " + "latter is " + latter);
          }
      }
  }

  public static void main(String[] args) throws InterruptedException {
      ThreadSafeSample sample = new ThreadSafeSample();
      Thread threadA = new Thread(){
          public void run(){
              sample.nonSafeAction();
          }
      };
      Thread threadB = new Thread(){
          public void run(){
              sample.nonSafeAction();
          }
      };
      threadA.start();
      threadB.start();
      threadA.join();
      threadB.join();
  }
}

Here is the result on my computer:

Observed data race, former is 13097, latter is 13099

By adding synchronization to protect the two assignments, using this as the mutex, we can prevent other threads from concurrently modifying the sharedState.

synchronized (this) {
  int former = sharedState ++;
  int latter = sharedState;
  // …
}

If we decompile the code using javap, we can see a similar fragment that implements the semantics of synchronization using monitorenter/monitorexit:

11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield    #2                // Field sharedState:I
18: dup_x1
…
56: monitorexit

In the next lesson, I will provide a more in-depth analysis of synchronized and other lock implementations.

Using synchronized in the code is very convenient. If it is used to modify a static method, it is equivalent to enclosing the method body with the following code:

synchronized (ClassName.class) {}

Let’s take a look at ReentrantLock. You may wonder what reentrancy is. It refers to the automatic success of acquiring a lock when a thread tries to acquire a lock it already holds. This concept represents the granularity of lock acquisition, where holding the lock is based on threads rather than the number of calls. The emphasis on reentrancy in Java lock implementation is to distinguish it from the behavior of pthread.

The reentrant lock can be set as fair. We can choose whether it is fair when creating a reentrant lock.

ReentrantLock fairLock = new ReentrantLock(true);

Here, fairness means that in a competitive scenario, when fairness is true, the lock is more likely to be given to the thread that has been waiting for the longest time. Fairness is a way to reduce the occurrence of “starvation” (a situation where individual threads wait for a lock for a long time but are unable to acquire it).

If we use synchronized, we cannot choose fairness at all. It is always unfair, which is also the choice of mainstream operating system thread scheduling. In general scenarios, fairness may not be as important as imagined, and Java’s default scheduling strategy rarely leads to “starvation”. At the same time, ensuring fairness will introduce additional overhead, which naturally leads to a certain decrease in throughput. Therefore, I suggest that fairness should only be specified when your program really needs it.

Let’s learn about reentrant locks from a practical coding perspective. To ensure lock release, I recommend that for every lock() action, you should immediately follow it with a try-catch-finally block. The typical code structure is as follows, which is a good habit.

ReentrantLock fairLock = new ReentrantLock(true); // This is an example of creating a fair lock, which is generally not necessary.
fairLock.lock();
try {
  // do something
} finally {
   fairLock.unlock();
}

Compared to synchronized, ReentrantLock can be used like a regular object, so you can take advantage of its various convenient methods for fine-grained synchronization operations, and even implement use cases that are difficult to express with synchronized, such as:

  • Lock acquisition attempt with timeout.
  • Checking whether there are any threads, or a specific thread, waiting to acquire the lock.
  • Responding to interrupt requests.

Here, I want to emphasize the use of condition variables (java.util.concurrent.Condition). If ReentrantLock is an alternative choice to synchronized, then Condition transforms operations such as wait, notify, and notifyAll into corresponding object-oriented behaviors, making complex and obscure synchronization operations intuitive and controllable.

The most typical application scenario of condition variables is in standard library classes such as ArrayBlockingQueue.

Let’s refer to the source code below. First, a condition variable is obtained through a reentrant lock:

/** Condition for waiting takes */
private final Condition notEmpty;

/** Condition for waiting puts */
private final Condition notFull;

public ArrayBlockingQueue(int capacity, boolean fair) {
  if (capacity <= 0)
      throw new IllegalArgumentException();
  this.items = new Object[capacity];
  lock = new ReentrantLock(fair);
  notEmpty = lock.newCondition();
  notFull =  lock.newCondition();
}

The two condition variables are created from the same reentrant lock and are then used in specific operations, such as the take method below, to check and wait for the conditions to be met:

public E take() throws InterruptedException {
  final ReentrantLock lock = this.lock;
  lock.lockInterruptibly();
  try {
      while (count == 0)
          notEmpty.await();
      return dequeue();
  } finally {
      lock.unlock();
  }
}

When the queue is empty, the correct behavior of a thread attempting to take is to wait for enqueuing to occur, rather than returning directly. This is the semantics of BlockingQueue, and this logic can be elegantly implemented using the notEmpty condition.

So, how do we ensure that the subsequent take operation is triggered after an enqueueing operation? Let’s look at the implementation of enqueue:

private void enqueue(E e) {
  // assert lock.isHeldByCurrentThread();
  // assert lock.getHoldCount() == 1;
  // assert items[putIndex] == null;
  final Object[] items = this.items;
  items[putIndex] = e;
  if (++putIndex == items.length) putIndex = 0;
  count++;
  notEmpty.signal(); // Notifies waiting threads that the notEmpty condition has been satisfied
}

By using the combination of signal and await, the condition checking and notifying waiting threads are completed smoothly. Note that it is very important to call signal and await in pairs. If, for example, only await is called, the thread will wait indefinitely until it is interrupted.

In terms of performance, early implementations of synchronized were relatively inefficient compared to ReentrantLock, and in most scenarios, there was a significant performance difference. However, in Java 6, many improvements were made to synchronized. You can refer to the performance comparison here. In high contention situations, ReentrantLock still has certain advantages. In most cases, there is no need to focus too much on performance. It is more important to consider the convenience and maintainability of code structure.

Today, in the first lesson of the concurrency phase of this column, I introduced what thread safety is, compared and analyzed synchronized and ReentrantLock, and introduced practical examples of condition variables, etc. In the next lesson, I will analyze the advanced content of locks with source code and case studies.

Practice Exercise #

Have you got a clear understanding of synchronized and ReentrantLock, which we discussed today? Think about it, which methods have you used in ReentrantLock? And what problems do they solve?

Please write your thoughts on this question in the comment section. I will select the comments that have been carefully considered and reward them with a learning incentive. Feel free to discuss with me.

Are your friends also preparing for interviews? You can “invite friends to read” and share today’s topic with them. Perhaps you can help them out.