Java 1.5+ : Lock API : an alternative to the synchronized keyword

Plan


Synchronized keyword limitations

The java.util.concurrent.locks package from Java 1.5 introduces a Lock interface  defined as an alternative to the synchronized keyword : 
Lock implementations provide more extensive locking operations than can be obtained using synchronized methods and statements. They allow more flexible structuring, may have quite different properties, and may support multiple associated Condition objects.

The current limitation with synchronized methods and statements is that they don’t provide many options because that is a quite raw and low level API.  
For example :
– we cannot define read/write lock or segment a lock in several part . 
-notify() cannot target specific « type » of threads to  awake
– no fairness for acquiring the lock
– no way to only attempt to acquire the lock and to provide an alternative if it is not possible
And so for…

Locking on an Object field : the premises of the lock API

Here is the first paragraphs of the the Lock interface javadoc (emphasis is mine) : 

Lock implementations provide more extensive locking operations than can be obtained using synchronized methods and statements. They allow more flexible structuring, may have quite different properties, and may support multiple associated Condition objects.
A lock is a tool for controlling access to a shared resource by multiple threads. Commonly, a lock provides exclusive access to a shared resource: only one thread at a time can acquire the lock and all access to the shared resource requires that the lock be acquired first. However, some locks may allow concurrent access to a shared resource, such as the read lock of a ReadWriteLock.
The use of synchronized methods or statements provides access to the implicit monitor lock associated with every object, but forces all lock acquisition and release to occur in a block-structured way: when multiple locks are acquired they must be released in the opposite order, and all locks must be released in the same lexical scope in which they were acquired.
While the scoping mechanism for synchronized methods and statements makes it much easier to program with monitor locks, and helps avoid many common programming errors involving locks, there are occasions where you need to work with locks in a more flexible way. For example, some algorithms for traversing concurrently accessed data structures require the use of « hand-over-hand » or « chain locking »: you acquire the lock of node A, then node B, then release A and acquire C, then release B and acquire D and so on. Implementations of the Lock interface enable the use of such techniques by allowing a lock to be acquired and released in different scopes, and allowing multiple locks to be acquired and released in any order.
With this increased flexibility comes additional responsibility. The absence of block-structured locking removes the automatic release of locks that occurs with synchronized methods and statements. 

In bold, you can read the main features and constraints brought by the Lock API.
To sum up main of these: more flexible, more lock features (scope, order) , read and write lock flavors and as a consequence more responsibility for the developers.

We will go on by showing how to do the same thing with synchronized statements and ReentrantLock : the most basic lock implementation.

ReentrantLock : the basic lock

Suppose the following requirement : we want to concurrently write and read in a store some integer values.  For the need of the comparison, we will define an interface for that and two implementation : one using synchronized statements and another using a ReentrantLock.  

Here is the interface, Store :

public interface Store {
  void read() throws InterruptedException ;
  void write(int value) throws InterruptedException;
  int getTotalWait();
}

Here is the implementation using synchronized statements.

import java.util.ArrayList;
import java.util.List;
 
public class StoreWithSynchronizedStatement implements Store{
 
  private final int bufferSize;
  private int totalWait;
  private List<Integer> list = new ArrayList<>();
 
  private final Object lock = new Object();
 
  public StoreWithSynchronizedStatement(int bufferSize) {
    this.bufferSize = bufferSize;
  }
 
  @Override
  public void read() throws InterruptedException {
    synchronized (lock) {
      while (list.size() == 0) {
        totalWait++;
        lock.wait();
      }
      int value = list.remove(list.size() - 1);
      lock.notifyAll();
    }
  }
 
  @Override
  public void write(int value) throws InterruptedException {
    synchronized (lock) {
      while (list.size() == bufferSize) {
        totalWait++;
        lock.wait();
      }
      list.add(value);
      lock.notifyAll();
    }
  }
 
  @Override public int getTotalWait() {
    return totalWait;
  }
 
}

And now the implementation using a ReentrantLock.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
 
public class StoreWithReentrantLock implements Store {
 
  private final int bufferSize;
  private int totalWait;
  private List<Integer> list = new ArrayList<>();
 
  private final Lock lock = new ReentrantLock();
  private final Condition isFull = lock.newCondition();
  private final Condition isEmpty = lock.newCondition();
 
  public StoreWithReentrantLock(int bufferSize){
    this.bufferSize = bufferSize;
  }
 
  @Override
  public void read() throws InterruptedException {
    lock.lock();
    try {
      while (list.size() == 0) {
        totalWait++;
        isEmpty.await();
      }
      int value = list.remove(list.size() - 1);
      isFull.signalAll();
    } finally {
      lock.unlock();
    }
  }
 
  @Override
  public void write(int value) throws InterruptedException {
    lock.lock();
    try {
      while (list.size() == bufferSize) {
        totalWait++;
        isFull.await();
      }
      list.add(value);
      isEmpty.signalAll();
    } finally {
      lock.unlock();
    }
  }
 
  @Override public int getTotalWait() {
    return totalWait;
  }
 
}

Conclusion : for basic requirement, that is synchronization on reading and writing without additional requirement, the Lock API looks expensive : it is more verbose, it creates some abstractions not required : 3 variables for locking without bringing code robustness advantage here and it is error prone since forgetting the finally statement that releases the unlock is bound to create a deadlock.
At last, as we will see that, in terms of fastness, in spite of the usage of a condition to read and another to write, ReentrantLock has equivalent or worse performance according to the JVM version used. 
So definitively, ReentrantLock makes sense only when we really need specific features such as lock trying (with or without timeout)  or fairness scheduling and that we don’t want to implement it ourselves.

ReentrantLock versus synchronized statement benchmark

The benchmark class that relies on the two Store implementations :

import davidxxx.lock.Store;
import davidxxx.lock.StoreWithReentrantLock;
import davidxxx.lock.StoreWithSynchronizedStatement;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
 
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
 
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Threads(1)
@State(Scope.Benchmark)
public class LockObjectBenchmark {
 
  private StoreWithReentrantLock storeWithReentrantLock;
  private StoreWithSynchronizedStatement storeWithSynchronizedStatement;
  private static final int NB_ITERATION = 100_000;
  private static final int BUFFER_SIZE = 10;
 
  public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder().include(LockObjectBenchmark.class.getSimpleName())
                                      .warmupIterations(5)
                                      .measurementIterations(5)
                                      .forks(1)
                                      .build();
 
    new Runner(opt).run();
  }
 
  @Setup(Level.Iteration)
  public void doSetup() {
    storeWithReentrantLock = new StoreWithReentrantLock(BUFFER_SIZE);
    storeWithSynchronizedStatement = new StoreWithSynchronizedStatement(BUFFER_SIZE);
  }
 
  @Benchmark
  public void _1_store_with_synchronized_statement() {
    execute(storeWithSynchronizedStatement);
  }
 
  @Benchmark
  public void _2_store_with_reentrant_lock() {
    execute(storeWithReentrantLock);
  }
 
  public void execute(Store store) {
    List<Thread> threads = Arrays.asList(createThreadWriter(store),
                                         createThreadWriter(store),
                                         createThreadReader(store),
                                         createThreadReader(store));
 
    for (Thread t : threads) {
      t.start();
    }
 
    for (Thread t : threads) {
      try {
        t.join();
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
    }
 
  }
 
  private static Thread createThreadReader(Store store) {
    return new Thread(
        () -> {
          for (int i = 0; i < NB_ITERATION; i++) {
            try {
              store.read();
            } catch (InterruptedException e) {
              e.printStackTrace();
            }
          }
        }
    );
  }
 
  private static Thread createThreadWriter(Store store) {
    return new Thread(
        () -> {
          for (int i = 0; i < NB_ITERATION; i++) {
            try {
              store.write(1);
            } catch (InterruptedException e) {
              e.printStackTrace();
            }
          }
        }
    );
  }
 
}

Here is the way of running the benchmark :

java -jar target/lock-api.jar LockObjectBenchmark -wi 1 -i 10 -f 1 -t 1 -si true -bm Throughput -tu s -r 30s

And here the result with a JRE 11.0.1 on Windows 7 :

Benchmark                                                  Mode  Cnt  Score   Error  Units
LockObjectBenchmark._1_store_with_synchronized_statement  thrpt   10  6,392 ± 0,254  ops/s
LockObjectBenchmark._2_store_with_reentrant_lock          thrpt   10  4,746 ± 0,149  ops/s

Here the result with a JRE 11.0.4 (the last version at that time) on Windows 7 :

Benchmark                                                  Mode  Cnt  Score   Error  Units
LockObjectBenchmark._1_store_with_synchronized_statement  thrpt   10  6,386 ± 0,160  ops/s
LockObjectBenchmark._2_store_with_reentrant_lock          thrpt   10  4,585 ± 0,107  ops/s

Here the result with a JRE 1.8.0_171 on Windows 7 :

Benchmark                                                  Mode  Cnt  Score   Error  Units
LockObjectBenchmark._1_store_with_synchronized_statement  thrpt   10  4,687 ± 0,118  ops/s
LockObjectBenchmark._2_store_with_reentrant_lock          thrpt   10  4,213 ± 0,156  ops/s

Here we used a Throughput benchmark (ops/s): so higher score means faster.
Conclusion : the synchronized statement appears either much faster than ReentrantLock in Java 11-Windows 7 and as fast as in Java 8-Windows 7.

Ce contenu a été publié dans Non classé. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *