这个专题我发现怎么慢慢演化为性能测试了, 遇到任何东西我就忍不住去测一把. 本文我们会大概看一下各种锁数据结构的简单用法, 顺便也会来比拼一下性能.
各种并发锁
首先, 我们定一个抽象基类, 用于各种锁测试的一些公共代码:
我们需要使用锁来保护 counter 和 hashMap 这 2 个资源
write 字段表示这个线程是执行写操作还是读操作
每一个线程都会执行 loopCount 次读或写操作
start 的 CountDownLatch 用于等待所有线程一起执行
finish 的 CountDownLatch 用于让主线程等待所有线程都完成
- @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() {
- try {
- start.await();
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- for (int i = 0; i <loopCount; i++) {
- doTask();
- }
- finish.countDown();
- }
- abstract protected void doTask();
- }
下面我们实现最简单的使用 synchronized 来实现的锁, 拿到锁后我们针对 hashMap 和 counter 做一下最简单的操作:
- @Slf4j
- class SyncTask extends LockTask {
- private static Object locker = new Object();
- public SyncTask(Boolean write) {
- super(write);
- }
- @Override
- protected void doTask() {
- synchronized (locker) {
- if (write) {
- counter++;
- hashMap.put(counter, "Data" + counter);
- } else {
- hashMap.get(counter);
- //log.debug("{}, {}", this.getClass().getSimpleName(), value);
- }
- }
- }
- }
然后是 ReentrantLock, 使用也是很简单, 需要在 finally 中释放锁:
- @Slf4j
- class ReentrantLockTask extends LockTask {
- private static ReentrantLock locker = new ReentrantLock();
- public ReentrantLockTask(Boolean write) {
- super(write);
- }
- @Override
- protected void doTask() {
- locker.lock();
- try {
- if (write) {
- counter++;
- hashMap.put(counter, "Data" + counter);
- } else {
- hashMap.get(counter);
- }
- } finally {
- locker.unlock();
- }
- }
- }
然后是 ReentrantReadWriteLock, 可重入的读写锁, 这屋里我们需要区分读操作还是写操作来获得不同类型的锁:
- @Slf4j
- class ReentrantReadWriteLockTask extends LockTask {
- private static ReentrantReadWriteLock locker = new ReentrantReadWriteLock();
- public ReentrantReadWriteLockTask(Boolean write) {
- super(write);
- }
- @Override
- protected void doTask() {
- 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();
- }
- }
- }
- }
然后是可重入锁和可重入读写锁的公平版本:
- @Slf4j
- class FairReentrantLockTask extends LockTask {
- private static ReentrantLock locker = new ReentrantLock(true);
- public FairReentrantLockTask(Boolean write) {
- super(write);
- }
- @Override
- protected void doTask() {
- 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() {
- 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();
- }
- }
- }
- }
最后是 1.8 推出的 StampedLock:
- @Slf4j
- class StampedLockTask extends LockTask {
- private static StampedLock locker = new StampedLock();
- public StampedLockTask(Boolean write) {
- super(write);
- }
- @Override
- protected void doTask() {
- 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);
- }
- }
- }
这里同样区分读写锁, 只是读锁我们先尝试进行乐观读, 拿到一个戳后读取我们需要保护的数据, 随后校验一下这个戳如果没问题的话说明数据没有改变, 乐观锁生效, 如果有问题升级为悲观锁再读取一次. 因为 StampedLock 很复杂很容易用错, 真的打算用的话务必研读官网的各种锁升级的例子 (乐观读到读, 乐观读到写, 读到写).
性能测试和分析
同样我们定义性能测试的类型:
- @ToString
- @RequiredArgsConstructor
- class TestCase {
- final Class lockTaskClass;
- final int writerThreadCount;
- final int readerThreadCount;
- long duration;
- }
每一种测试可以灵活选择:
测试的锁类型
写线程数量
读线程数量
最后测试结果回写到 duration
下面是性能测试的场景定义:
- @Test
- public void test() 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());
- }
在这里可以看到, 我们为这 6 个锁定义了 20 种测试场景, 覆盖几大类:
只有读的情况
只有写的情况
读写并发的情况, 并发数渐渐增多
读比写多的情况 (这个最常见吧)
写比读多的情况
每一次测试之间强制触发 gc 后休眠 1 秒, 每 20 次结果换行一次输出.
测试类如下:
- 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);
- }
代码主要干了几件事情:
根据测试用例的读写线程数, 开启一定量的线程, 根据类名和读写类型动态创建类型
每一个线程执行的循环次数是按比例均匀分配的, 公平类型的两次测试数 / 100, 因为实在是太慢了, 等不了几小时
使用两个 CountDownLatch 来控制所有线程开启, 等待所有线程完成, 最后校验一下 counter 的总数
在这里, 我们把循环次数设置为 1000 万次, 在阿里云 12 核 12G 机器 JDK8 环境下运行得到的结果如下:
这里, 我们进行两次测试, 其实一开始我的测试代码里没有 HashMap 的读写操作, 只有 counter 的读写操作 (这个时候循环次数是 1 亿次), 所有第一次测试是仅仅只有 counter 的读写操作的, 后一次测试是这里贴的代码的版本.
所以这个表格中的数据不能直接来对比因为混杂了三种循环次数, 上面那个表是 1 亿从循环的时间, 下面那个是 1000 万次, 黄色的两条分别是 100 万次和 10 万次循环.
这个测试信息量很大, 这里说一下我看到的几个结论, 或者你还可以从这个测试中品味出其它结论:
synchronized 关键字经过各种优化进行简单锁的操作性能已经相当好了, 如果用不到 ReentrantLock 高级功能的话, 使用 synchronized 不会有什么太多性能问题
在任务非常轻的时候可重入锁比 synchronized 还是快那么一点, 一般场景下不可能只是 ++ 操作, 这个时候两者差不多
并发上来之后各种锁的执行耗时稍微增多点, 没有增多太厉害, 并发不足的时候反而性能还不好
在任务很轻的时候 StampedLock 性能碾压群雄, 在只有读操作的时候因为只是乐观锁, 所以性能好的夸张
在任务没有那么轻的时候读写锁的性能几乎都比普通锁好, 看下面那个表格, 在任务实在是太轻的时候读写锁因为复杂的锁实现开销的问题不如普通的可重入锁
公平版本的锁非常非常慢, 可以说比非公平版本的慢 100 倍还不止, 而且执行的时候 CPU 打满, 其它版本的锁执行的时候 CPU 利用在 12 核的 20% 左右, 其实想想也对, 不管是多少线程, 大部分时候都阻塞了
所以说对于这些锁的选择也很明确:
如果用不到 ReentrantLock 的什么高级特性, synchronized 就可以
一般而言 ReentrantLock 完全可以替代 synchronized, 如果你不嫌麻烦的话
ReentrantReadWriteLock 用于相对比较复杂的任务的读写并发的情况
StampedLock 用于相对比较轻量级任务的高并发的情况, 用起来也比较复杂, 能够实现极致的性能
只有有特殊需求的话才去开启 ReentrantLock 或 ReentrantReadWriteLock 的公平特性
再来看看 ReentrantLock
之前也提到了可重入锁相对 synchronized 有一些高级特性, 我们写一些测试代码:
我们先在主线程锁 10 次
输出一下锁的一些信息
循环 10 次开启 10 个线程尝试获取锁, 等待时间是 1 秒到 10 秒, 显然主线程释放锁之前是获取不到锁的
1 秒一次定时输出锁的一些信息
5 秒后主线程释放锁
休眠一下观察子线程是否拿到锁了
- @Test
- public void test() 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);
- }
输出如下:
- 08:14:50.834 [main] INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:10,isHeldByCurrentThread:true,isLocked:true
- 08:14:50.849 [pool-1-thread-1] INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:0, getQueueLength:10, hasQueuedThreads:true, waitThreads:10
- 08:14:51.849 [Thread-0] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Cannot get lock
- 08:14:51.848 [pool-1-thread-1] INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:0, getQueueLength:9, hasQueuedThreads:true, waitThreads:9
- 08:14:52.849 [Thread-1] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Cannot get lock
- 08:14:52.849 [pool-1-thread-1] INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:0, getQueueLength:8, hasQueuedThreads:true, waitThreads:8
- 08:14:53.846 [pool-1-thread-1] INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:0, getQueueLength:8, hasQueuedThreads:true, waitThreads:8
- 08:14:53.847 [Thread-2] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Cannot get lock
- 08:14:54.847 [pool-1-thread-1] INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:0, getQueueLength:7, hasQueuedThreads:true, waitThreads:7
- 08:14:54.849 [Thread-3] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Cannot get lock
- 08:14:55.847 [pool-1-thread-1] 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 - Cannot get lock
- 08:14:55.850 [Thread-5] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Got lock
- 08:14:55.851 [Thread-6] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Got lock
- 08:14:55.852 [Thread-7] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Got lock
- 08:14:55.852 [Thread-8] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Got lock
- 08:14:55.852 [Thread-9] DEBUG me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - Got lock
- 08:14:56.849 [pool-1-thread-1] INFO me.josephzhu.javaconcurrenttest.lock.ReentrantLockTest - getHoldCount:0, getQueueLength:0, hasQueuedThreads:false, waitThreads:0
从这个输出可以看到:
一开始显示锁被主线程锁了 10 次
随着时间的推移等待锁的线程数量在增加
5 个线程因为超时无法获取到锁
5 秒后还有 5 个线程拿到了锁
这也可以看到可重入锁相比 synchronized 功能更强大点:
可以超时等待获取锁
可以查看到锁的一些信息
可以中断锁 (这里没有演示)
之前提到的公平性
可重入特性并不是它特有的功能, synchronized 也能重入
提到了可重入, 我们进行一个无聊的实验看看可以重入多少次:
- @Test
- public void test2() {
- ReentrantLock reentrantLock = new ReentrantLock(true);
- int i = 0;
- try {
- while (true) {
- reentrantLock.lock();
- i++;
- }
- } catch (Error error) {
- log.error("count:{}", i, error);
- }
- }
结果如下:
锁误用的例子
最后再提下最简单的锁误用的例子, 虽然没有那么高大上, 但是这种因为锁范围和锁保护对象的范围不一致导致误用的问题在业务代码中到处都是, 比如:
- @Slf4j
- public class LockMisuse {
- @Test
- public void test1() 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() {
- synchronized (locker) {
- counter++;
- }
- }
- }
在代码里我们要保护的资源是静态的, 但是锁却是对象级别的, 不同的实例持有不同的锁, 完全起不到保护作用:
小结
本文我们简单测试了一下各种锁的性能, 我感觉这个测试可能还无法 100% 模拟真实的场景, 真实情况下不仅仅是读写线程数量的不一致, 更多是操作频次的不一致, 不过这个测试基本看到了我们猜测的结果. 在日常代码开发过程中, 大家可以根据实际功能和场景需要来选择合适的锁类型.
有的时候高大上的一些锁因为使用复杂容易导致误用, 错用, 死锁, 活锁等问题, 我反而建议在没有明显问题的情况下先从简单的『悲观』锁开始使用. 还有就是像最后的例子, 使用锁的话务必需要认证检查代码, 思考锁和保护对象的关系, 避免锁不产产生效果导致隐藏的 Bug.
同样, 代码见我的 GitHub, 欢迎 clone 后自己把玩, 欢迎点赞.
来源: https://www.cnblogs.com/lovecindywang/p/11216570.html