-
Notifications
You must be signed in to change notification settings - Fork 0
Home
This repository demonstrates different ways of locking resources in Java.
-
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.
- Reentrant locks support fairness as well as polled, timed, and interruptible lock aquisition.
- Read-write locks support multiple readers at the same time.
- Stamped locks support optimistic reads (without blocking).
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.
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.
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.
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.
These tasks are designed to explore the interface of explicit locks and check their performance on different systems.
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.
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.
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.
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?
Discuss scenarios from your own experience where the extra features of explicit locks for lock aquisition and thread coordination might be useful.
© Sebastian Fischer CC BY 4.0