Skip to content
Sebastian Fischer edited this page Feb 24, 2023 · 23 revisions

This repository demonstrates different ways of locking resources in Java.

Locking mechanisms

  • Intrinsic locks are easy to use because they are built into the language with the synchronized keyword.
  • Explicit locks are more flexible (and error prone) than intrinsic locks because they support unstructured locking and unlocking.

The mentioned features are documented here using examples that demonstrate how and when to use them. The test programs require Java version 17 or later and no other dependencies.

Code examples

As an example for a shared resource, we will implement bank accounts and synchronize access to them in different ways.

  interface Account {
    int balance();
    void deposit(int amount);
    void withdraw(int amount) throws InsufficientFundsException;
  }

The interface for bank accounts is wrapped in an interface for banks which provide a method createAccount to create new accounts. InsufficientFundsException is an empty subclass of Exception used to signal that the current balance does not allow to withdraw the specified amount.

Synchronization is required when transferring money from one account to another. Otherwise, multiple threads might read the current balance of the same account before they transfer money to another account and then update this balance. As a result, only the last update would take effect, a race condition.

The interface Bank<A extends Bank.Account> contains a default implementation of the transfer method that is not synchronized.

  default void transfer(A from, A to, int amount) throws InsufficientFundsException {
    from.withdraw(amount);
    to.deposit(amount);
  }

If the withdrawal fails due to insufficient funds in the source account, no money is deposited in the target account.

Apart from implementing locked versions of all Account methods, we will also override the transfer method of the Bank interface with a locked version.

Performance tests

To get an impression of performance differences between the different implementations, there is a test program simulating different scenarios of accessing bank accounts. More serious performance tests should be performed with the Java Microbenchmark Harness (JMH).

The simulation uses an ExecutorService to execute transfers and balance checks between different accounts with a pool of 16 threads. The read operations use a lock-free LongAdder to accumulate balances. Atomic classes like LongAdder are out of scope for this overview but an interesting alternative to locked resources for thread-safe programming.

The simulation is executed for all bank implementations using five different scenarios. In all scenarios a random selection of 10 created bank accounts is accessed concurrently. The total number of submitted tasks is 10 million. The read-only and write-only scenarios perform only balance checks and only transfers, respectively. There are two more scenarios performing either balance checks or transfers predominantly with a ratio of 10000 to 1. A fifth scenario performs the same number of balance checks and transfers.

The following figure shows median run times in milliseconds out of five runs for each combination of bank implementation and scenario.

Performance figures

We can see that in scenarios with predominant writes intrinsic locks perform better than all explicit lock implementations in our experiment. Moreover, the advantage of stamped and read-write locks in predominant read scenarios does not appear to be significant. The performance of intrinsic locks appears to be roughly the same as explicit locks when there are many reads and better when there are predominantly writes.

Tasks

These tasks are designed to explore the interface of explicit locks and check their performance on different systems.

Performance test

Run the Simulation on your own machine and compare the results. Adjust the NUMBER_OF_THREADS or use different implementaitons of ExecutorService and observe performance differences. Experiment with different ratios of read and write tasks and note your findings.

Timed lock aquisition

Refactor the transfer methods of the bank implementations with explicit locks to use timed lock aquisition. Use tryLock with a small random timeout to avoid spinning when locks are held by other threads.

The light switch problem

Simulate a variant of the Light Switch Problem where all people flip light switches concurrently. Implement it using a suitable flavor of explicit locks and then compare it with my implementation using intrinsic locks.

Discussion

Alternative bank interface

Instead of on banks, we could also implement a transfer method (with a single argument) on accounts. How would such a change affect the implementation with respect to deadlock prevention?

Extra flexibility of explicit locks

Discuss scenarios from your own experience where the extra features of explicit locks for lock aquisition and thread coordination might be useful.