This project I found evolved into performance testing, and I couldn’t help testing anything I encountered. In this article, we’ll take a look at some simple uses of various lock data structures, as well as performance comparisons.

Various concurrent locks

First, let’s define an abstract base class with some common code for various lock tests:

  • We need to use locks to protect counter and hashMap
  • The write field indicates whether the thread is performing a write or read operation
  • Each thread performs a loopCount read or write operation
  • Start’s CountDownLatch is used to wait for all threads to execute together
  • Finish’s CountDownLatch is used to keep the main thread waiting for all threads to complete
@Slf4j
abstract class LockTask implements Runnable {
    protected volatile static long counter;
    protected boolean write;
    protected static HashMap<Long, String> hashMap = new HashMap<>();
    int loopCount;
    CountDownLatch start;
    CountDownLatch finish;

    public LockTask(Boolean write) {
        this.write = write;
    }

    @Override
    public void run(a) {
        try {
            start.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        for (int i = 0; i < loopCount; i++) {
            doTask();
        }
        finish.countDown();
    }

    abstract protected void doTask(a);
}
Copy the code

Synchronized lock (hashMap, counter) lock (hashMap, counter)

@Slf4j
class SyncTask extends LockTask {
    private static Object locker = new Object();

    public SyncTask(Boolean write) {
        super(write);
    }

    @Override
    protected void doTask(a) {
        synchronized (locker) {
            if (write) {
                counter++;
                hashMap.put(counter, "Data" + counter);
            } else {
                hashMap.get(counter);
                //log.debug("{}, {}", this.getClass().getSimpleName(), value);}}}}Copy the code

Then there is ReentrantLock, which is also very simple to use, releasing the lock in finally:

@Slf4j
class ReentrantLockTask extends LockTask {
    private static ReentrantLock locker = new ReentrantLock();

    public ReentrantLockTask(Boolean write) {
        super(write);
    }

    @Override
    protected void doTask(a) {
        locker.lock();
        try {
            if (write) {
                counter++;
                hashMap.put(counter, "Data" + counter);
            } else{ hashMap.get(counter); }}finally{ locker.unlock(); }}}Copy the code

Then there is ReentrantReadWriteLock, a reentranceable read-write lock, where we need to distinguish between read and write operations to obtain different types of locks:

@Slf4j
class ReentrantReadWriteLockTask extends LockTask {
    private static ReentrantReadWriteLock locker = new ReentrantReadWriteLock();

    public ReentrantReadWriteLockTask(Boolean write) {
        super(write);
    }

    @Override
    protected void doTask(a) {
        if (write) {
            locker.writeLock().lock();
            try {
                counter++;
                hashMap.put(counter, "Data" + counter);
            } finally{ locker.writeLock().unlock(); }}else {
            locker.readLock().lock();
            try {
                hashMap.get(counter);
            } finally{ locker.readLock().unlock(); }}}}Copy the code

Then there are the fair versions of reentrant locks and reentrant read-write locks:

@Slf4j
class FairReentrantLockTask extends LockTask {
    private static ReentrantLock locker = new ReentrantLock(true);

    public FairReentrantLockTask(Boolean write) {
        super(write);
    }

    @Override
    protected void doTask(a) {
        locker.lock();
        try {
            if (write) {
                counter++;
                hashMap.put(counter, "Data" + counter);
            } else{ hashMap.get(counter); }}finally{ locker.unlock(); }}}@Slf4j
class FairReentrantReadWriteLockTask extends LockTask {
    private static ReentrantReadWriteLock locker = new ReentrantReadWriteLock(true);

    public FairReentrantReadWriteLockTask(Boolean write) {
        super(write);
    }

    @Override
    protected void doTask(a) {
        if (write) {
            locker.writeLock().lock();
            try {
                counter++;
                hashMap.put(counter, "Data" + counter);
            } finally{ locker.writeLock().unlock(); }}else {
            locker.readLock().lock();
            try {
                hashMap.get(counter);
            } finally{ locker.readLock().unlock(); }}}}Copy the code

Finally, StampedLock in 1.8:

@Slf4j
class StampedLockTask extends LockTask {
    private static StampedLock locker = new StampedLock();

    public StampedLockTask(Boolean write) {
        super(write);
    }

    @Override
    protected void doTask(a) {
        if (write) {
            long stamp = locker.writeLock();
            try {
                counter++;
                hashMap.put(counter, "Data" + counter);
            } finally{ locker.unlockWrite(stamp); }}else {
            long stamp = locker.tryOptimisticRead();
            long value = counter;

            if(! locker.validate(stamp)) { stamp = locker.readLock();try {
                    value = counter;
                } finally{ locker.unlockRead(stamp); } } hashMap.get(value); }}}Copy the code

There is also a distinction between read and write locks, but for read locks we first try optimistic read, get a stamp and read the data we need to protect, then check the stamp if it is ok, the data has not changed, optimistic lock is in effect, if there is a problem upgrade to pessimistic lock and read again. Because StampedLock is complex and easy to use incorrectly, be sure to read up on the various lock upgrade examples (optimistic read to read, optimistic read to write, and read to write) on the website if you really want to use it.

Performance testing and analysis

Also we define the types of performance tests:

@ToString
@RequiredArgsConstructor
class TestCase {
    final Class lockTaskClass;
    final int writerThreadCount;
    final int readerThreadCount;
    long duration;
}
Copy the code

Each test can be flexibly selected:

  • The lock type to test
  • Number of writer threads
  • Number of reader threads
  • The final test result is written back to Duration

Here is the performance test scenario definition:

 @Test
public void test(a) throws Exception {
    List<TestCase> testCases = new ArrayList<>();

    Arrays.asList(SyncTask.class,
            ReentrantLockTask.class,
            FairReentrantLockTask.class,
            ReentrantReadWriteLockTask.class,
            FairReentrantReadWriteLockTask.class,
            StampedLockTask.class
    ).forEach(syncTaskClass -> {
        testCases.add(new TestCase(syncTaskClass, 1.0));
        testCases.add(new TestCase(syncTaskClass, 10.0));
        testCases.add(new TestCase(syncTaskClass, 0.1));
        testCases.add(new TestCase(syncTaskClass, 0.10));

        testCases.add(new TestCase(syncTaskClass, 1.1));
        testCases.add(new TestCase(syncTaskClass, 10.10));
        testCases.add(new TestCase(syncTaskClass, 50.50));
        testCases.add(new TestCase(syncTaskClass, 100.100));
        testCases.add(new TestCase(syncTaskClass, 500.500));
        testCases.add(new TestCase(syncTaskClass, 1000.1000));

        testCases.add(new TestCase(syncTaskClass, 1.10));
        testCases.add(new TestCase(syncTaskClass, 10.100));
        testCases.add(new TestCase(syncTaskClass, 10.200));
        testCases.add(new TestCase(syncTaskClass, 10.500));
        testCases.add(new TestCase(syncTaskClass, 10.1000));

        testCases.add(new TestCase(syncTaskClass, 10.1));
        testCases.add(new TestCase(syncTaskClass, 100.10));
        testCases.add(new TestCase(syncTaskClass, 200.10));
        testCases.add(new TestCase(syncTaskClass, 500.10));
        testCases.add(new TestCase(syncTaskClass, 1000.10));

    });

    testCases.forEach(testCase -> {
        System.gc();
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {
            benchmark(testCase);
        } catch(Exception e) { e.printStackTrace(); }}); StringBuilder stringBuilder =new StringBuilder();
    int index = 0;
    for (TestCase testCase : testCases) {
        if (index % 20= =0)
            stringBuilder.append("\r\n");
        stringBuilder.append(testCase.duration);
        stringBuilder.append(",");
        index++;
    }
    System.out.println(stringBuilder.toString());
}
Copy the code

As you can see here, we defined 20 test scenarios for these six locks, covering several broad categories:

  • Only read
  • It’s just writing
  • In the case of concurrent reads and writes, the number of concurrent reads and writes increases gradually
  • Reading more than writing (the most common)
  • Writing more than reading

The gc sleep is forced for 1 second between each test, and the result is output every 20 line feeds. The test classes are as follows:

private void benchmark(TestCase testCase) throws Exception {
    LockTask.counter = 0;
    log.info("Start benchmark:{}", testCase);
    CountDownLatch start = new CountDownLatch(1);
    CountDownLatch finish = new CountDownLatch(testCase.readerThreadCount + testCase.writerThreadCount);
    if (testCase.readerThreadCount > 0) {
        LockTask readerTask = (LockTask) testCase.lockTaskClass.getDeclaredConstructor(Boolean.class).newInstance(false);
        readerTask.start = start;
        readerTask.finish = finish;
        readerTask.loopCount = LOOP_COUNT / testCase.readerThreadCount;
        if (testCase.lockTaskClass.getSimpleName().startsWith("Fair")) readerTask.loopCount /= 100;
        IntStream.rangeClosed(1, testCase.readerThreadCount)
                .mapToObj(__ -> new Thread(readerTask))
                .forEach(Thread::start);
    }
    if (testCase.writerThreadCount > 0) {
        LockTask writerTask = (LockTask) testCase.lockTaskClass.getDeclaredConstructor(Boolean.class).newInstance(true);
        writerTask.start = start;
        writerTask.finish = finish;
        writerTask.loopCount = LOOP_COUNT / testCase.writerThreadCount;
        if (testCase.lockTaskClass.getSimpleName().startsWith("Fair")) writerTask.loopCount /= 100;
        IntStream.rangeClosed(1, testCase.writerThreadCount)
                .mapToObj(__ -> new Thread(writerTask))
                .forEach(Thread::start);
    }

    start.countDown();
    long begin = System.currentTimeMillis();
    finish.await();
    if (testCase.writerThreadCount > 0) {
        if (testCase.lockTaskClass.getSimpleName().startsWith("Fair")) {
            Assert.assertEquals(LOOP_COUNT / 100, LockTask.counter);
        } else {
            Assert.assertEquals(LOOP_COUNT, LockTask.counter);
        }
    }
    testCase.duration = System.currentTimeMillis() - begin;
    log.info("Finish benchmark:{}", testCase);
}
Copy the code

The code does a few things:

  • According to the number of read-write threads in the test case, a certain number of threads are started to dynamically create types based on the class name and read-write type
  • The number of loops executed by each thread is evenly distributed proportionally, fair type two tests /100, because it’s too slow to wait a few hours
  • Use two countdownlatches to hold all threads open, wait for all threads to complete, and finally check the total number of counters

Here, we set the cycle times to 10 million times, and the results obtained under the environment of Aliyun 12-core 12G machine JDK8 are as follows:

Here, we run two tests. In fact, at the beginning of my test code, there were no HashMap reads and writes, only counter reads and writes (at this point, the loop number was 100 million). All the first tests were only counter reads and writes, and the second test was the version of the code posted here.

So the data in this table can’t be compared directly because it’s mixed up with three cycles, the top one is 100 million cycles, the bottom one is 10 million cycles, and the yellow two are 1 million and 100,000 cycles respectively.

This test is very informative, so here are a few of the results I saw, or what else you can taste from this test:

  • The performance of the synchronized keyword is already quite good with various optimizations for simple locking. Without the advanced ReentrantLock feature, there will be no performance problems with using synchronized
  • Reentrant is a bit faster than synchronized when the task is very light. In general, it is not possible to just ++, which is similar to synchronized
  • After the concurrency, the execution time of various locks increases slightly, but not too much, and the performance is not good when the concurrency is insufficient
  • StampedLock performs extremely well when the task is light, and performs extremely well when only reads are performed because it is an optimistic lock
  • Read/write locks almost always perform better than normal locks when the task is not that light, as shown in the table below, read/write locks when the task is too light because of the complexity of locking implementation overhead compared to normal reentrant locks
  • The fair version of the lock is very, very slow, more than 100 times slower than the unfair version, and the CPU is fully loaded while the other version of the lock executes at around 20% of the 12 cores, which is also true, regardless of how many threads are blocked most of the time

So the choice of locks is clear:

  • If you don’t need any of ReentrantLock’s advanced features, synchronized does
  • ReentrantLock is generally an excellent alternative to synchronized, if you don’t bother
  • ReentrantReadWriteLock Specifies the read and write concurrency of complex tasks
  • StampedLock is used for relatively lightweight tasks with high concurrency and complexity to achieve extreme performance
  • Enable ReentrantLock or ReentrantReadWriteLock fairness only if you have special requirements

Now look at ReentrantLock

As mentioned earlier, reentrant locking has some advanced features compared to synchronized. Let’s write some test code:

  • We first lock 10 times in the main thread
  • Print some information about the lock
  • Loop 10 times to open 10 threads trying to acquire the lock. The wait time is between 1 and 10 seconds. Obviously, the main thread cannot acquire the lock until it releases the lock
  • Outputs some information about the lock once a second
  • The main thread releases the lock after 5 seconds
  • Hibernate to see if the child thread gets the lock
@Test
public void test(a) throws InterruptedException {

    ReentrantLock reentrantLock = new ReentrantLock(true);
    IntStream.rangeClosed(1.10).forEach(i -> reentrantLock.lock());
    log.info("getHoldCount:{},isHeldByCurrentThread:{},isLocked:{}",
            reentrantLock.getHoldCount(),
            reentrantLock.isHeldByCurrentThread(),
            reentrantLock.isLocked());

    List<Thread> threads = IntStream.rangeClosed(1.10).mapToObj(i -> new Thread(() -> {
        try {
            if (reentrantLock.tryLock(i, TimeUnit.SECONDS)) {
                try {
                    log.debug("Got lock");
                } finally{ reentrantLock.unlock(); }}else {
                log.debug("Cannot get lock"); }}catch (InterruptedException e) {
            log.debug("InterruptedException Cannot get lock");
            e.printStackTrace();
        }
    })).collect(Collectors.toList());

    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> log.info("getHoldCount:{}, getQueueLength:{}, hasQueuedThreads:{}, waitThreads:{}",
            reentrantLock.getHoldCount(),
            reentrantLock.getQueueLength(),
            reentrantLock.hasQueuedThreads(),
            threads.stream().filter(reentrantLock::hasQueuedThread).count()), 0.1, TimeUnit.SECONDS);

    threads.forEach(Thread::start);

    TimeUnit.SECONDS.sleep(5);
    IntStream.rangeClosed(1.10).forEach(i -> reentrantLock.unlock());
    TimeUnit.SECONDS.sleep(1);
}
Copy the code

The output is as follows:

08:14:50. 834 [main] INFO me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - getHoldCount: 10, isHeldByCurrentThread:true,isLocked:true08:14:50. [849] - thread pool - 1-1 the INFO me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - getHoldCount: 0, getQueueLength:10, hasQueuedThreads:true.waitThreads: 10 08:14:51. [Thread - 849 0] the DEBUG me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - always get a lock 08:14:51. [848] - thread pool - 1-1 the INFO me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - getHoldCount: 0, getQueueLength:9, hasQueuedThreads:true.waitThreads: 9 08:14:52. [849] Thread - 1 the DEBUG me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - always get a lock 08:14:52. [849] - thread pool - 1-1 the INFO me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - getHoldCount: 0, getQueueLength:8, hasQueuedThreads:true.waitThreads: 8 08:14:53. [846] - thread pool - 1-1 the INFO me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - getHoldCount: 0, getQueueLength:8, hasQueuedThreads:true.waitThreads: 8 08:14:53. 847 the DEBUG me. [Thread - 2] josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - always get a lock 08:14:54. [847] - thread pool - 1-1 the INFO me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - getHoldCount: 0, getQueueLength:7, hasQueuedThreads:true.waitThreads: 7 08:14:54. 849 / Thread - 3 the DEBUG me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - always get a lock 08:14:55. [847] - thread pool - 1-1 the INFO me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - getHoldCount: 0, getQueueLength:6, hasQueuedThreads:true.waitThreads: 6 08:14:55. 850 / Thread - 4 DEBUG me. Josephzhu. Javaconcurrenttest. Lock. ReentrantLockTest - always get a lock 08:14:55. 850 / Thread - 5 DEBUG me. Josephzhu. Javaconcurrenttest. Lock. The lock 08:14:55 ReentrantLockTest - Got. 851 [Thread - 6] The DEBUG me. Josephzhu. Javaconcurrenttest. Lock. The lock 08:14:55 ReentrantLockTest - Got the 852 [Thread - 7] the DEBUG Me. Josephzhu. Javaconcurrenttest. Lock. The lock 08:14:55 ReentrantLockTest - Got the 852 [Thread - 8] the DEBUG Me. Josephzhu. Javaconcurrenttest. Lock. The lock 08:14:55 ReentrantLockTest - Got the 852 [Thread - 9] the DEBUG Me. Josephzhu. Javaconcurrenttest. Lock. The lock 08:14:56 ReentrantLockTest - Got the 849 [] - thread pool - 1-1 of the INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:0, getQueueLength:0, hasQueuedThreads:false.waitThreads:0
Copy the code

From this output you can see:

  • It initially shows that the lock was locked 10 times by the main thread
  • The number of threads waiting for locks increases over time
  • Five threads were unable to acquire the lock because of a timeout
  • Five seconds later, five more threads took the lock

Reentrant locking is more powerful than synchronized:

  • You can wait out of time to acquire the lock
  • You can view some information about the lock
  • Locks can be broken (not demonstrated here)
  • Fairness mentioned earlier
  • The reentrant feature is not unique to synchronized; it can also be reentrant

Speaking of reentrant, let’s do a boring experiment to see how many times we can reentrant:

@Test
public void test2(a) {
    ReentrantLock reentrantLock = new ReentrantLock(true);
    int i = 0;
    try {
        while (true) { reentrantLock.lock(); i++; }}catch (Error error) {
        log.error("count:{}", i, error); }}Copy the code

The results are as follows:

Examples of lock misuse

Finally, the simplest example of lock misuse is not as impressive, but it is common in business code because the scope of the lock is inconsistent with the scope of the protected object. For example:

@Slf4j
public class LockMisuse {

    @Test
    public void test1(a) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        IntStream.rangeClosed(1.100000).forEach(i -> executorService.submit(new Container()::test));
        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.HOURS);
        log.info("{}", Container.counter); }}class Container {
    static int counter = 0;
    Object locker = new Object();

    void test(a) {
        synchronized(locker) { counter++; }}}Copy the code

In the code, the resource we want to protect is static, but the lock is object level, different instances hold different locks, no protection at all:

summary

In this article, we briefly tested the performance of various locks, and I feel that this test may not be able to simulate 100% of the real situation, which is not only inconsistent in the number of read-write threads, but also inconsistent in the operation frequency, but this test basically saw the results of our guess. In the daily code development process, you can choose the appropriate lock type according to the actual function and scenario needs.

Sometimes expensive locks can cause misuse, misuse, deadlocks, live locks, etc. Instead, I recommend starting with simple “pessimistic” locks when there are no obvious problems. In addition, as in the last example, authentication check code must be used when using locks, and the relationship between locks and protected objects should be considered to avoid hidden bugs caused by lock failure.

Similarly, the code can be found on Github, and you are welcome to clone it and like it.

Welcome to follow my wechat public number: The garden of the owner