首页

【阅读分享】AQS框架及锁性能测试对比

标签:并发aqs     发布时间:2024-01-19   

一、AQS框架简介

AQS诞生于Jdk1.5,在当时低效且功能单一的synchronized的年代,某种意义上讲,她拯救了Java@b@注:本系列文章所有测试用例均基于jdk1.8,操作系统为macOS

其实Doug Lea也意识到问题的复杂性,不可能出一个超级工具来解决所有问题,所以他把AQS设计为一个abstract类,并提供一系列子类去解决不同场景的问题,例如ReentrantLock、Semaphore等;当我们发现这些子类也不能满足我们加锁需求时,我们可以定义自己的子类,通过重写两三个方法,寥寥几行代码,实现强大的功能,这一切都得益于AQS作者敏锐的前瞻性

值得一提的是,虽然我们可以用某个子类去实现另一个子类所提供的功能(例如使用Semaphore替代CountDownLatch),但其易用、简洁、高效性等能否达到理想效果,都值得商榷;就好比在陆地上穿着雪橇走路,虽能前进,却低效易摔跤

1.2、并发框架

本小节仅带大家对AQS架构有个初步了解,在后文的独占锁、共享锁等中会详细阐述。下图为AQS框架的主体结构

1705675487125062240.png

从上图中我们看到了AQS中非常关键的一个概念:“阻塞队列”。即AQS的理念是当线程无法获取资源时,提供一个FIFO类型的有序队列,用来维护所有处于“等待中”的线程。看似无懈可击的框架设计,同时也牵出另外的一个问题:阻塞队列一定高效吗?

当“同步块逻辑”执行很快时,我们列出两种场景

场景1:直接使用AQS框架,例如试用其子类ReentrantLock,遇到资源争抢,放阻塞队列@b@场景2:因为锁占用时间短,无限重试

针对这2种场景,我们写测试用例比较一下

package org.xijiu.share.aqs.compare;@b@@b@import org.junit.Test;@b@@b@import java.util.concurrent.ExecutorService;@b@import java.util.concurrent.Executors;@b@import java.util.concurrent.TimeUnit;@b@import java.util.concurrent.locks.AbstractQueuedSynchronizer;@b@import java.util.concurrent.locks.ReentrantLock;@b@@b@ @b@public class CompareTest {@b@@b@  private class MyReentrantLock extends AbstractQueuedSynchronizer {@b@    protected final boolean tryAcquire(int acquires) {@b@      final Thread current = Thread.currentThread();@b@      while (true) {@b@        int c = getState();@b@        if (c == 0) {@b@          if (compareAndSetState(0, acquires)) {@b@            setExclusiveOwnerThread(current);@b@            return true;@b@          }@b@        }@b@      }@b@    }@b@@b@    protected final boolean tryRelease(int releases) {@b@      int c = getState() - releases;@b@      if (Thread.currentThread() != getExclusiveOwnerThread())@b@        throw new IllegalMonitorStateException();@b@      boolean free = false;@b@      if (c == 0) {@b@        free = true;@b@        setExclusiveOwnerThread(null);@b@      }@b@      setState(c);@b@      return free;@b@    }@b@  }@b@@b@  /**@b@   * 使用AQS框架@b@   */@b@  @Test@b@  public void test1() throws InterruptedException {@b@    ReentrantLock reentrantLock = new ReentrantLock();@b@    long begin = System.currentTimeMillis();@b@    ExecutorService executorService = Executors.newCachedThreadPool();@b@    for (int i = 0; i < 2; i++) {@b@      executorService.submit(() -> {@b@        for (int j = 0; j < 50000000; j++) {@b@          reentrantLock.lock();@b@          doBusiness();@b@          reentrantLock.unlock();@b@        }@b@      });@b@    }@b@    executorService.shutdown();@b@    executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);@b@    System.out.println("ReentrantLock cost : " + (System.currentTimeMillis() - begin));@b@  }@b@@b@  /**@b@   * 无限重试@b@   */@b@  @Test@b@  public void test2() throws InterruptedException {@b@    MyReentrantLock myReentrantLock = new MyReentrantLock();@b@    long begin = System.currentTimeMillis();@b@    ExecutorService executorService = Executors.newCachedThreadPool();@b@    for (int i = 0; i < 2; i++) {@b@      executorService.submit(() -> {@b@        for (int j = 0; j < 50000000; j++) {@b@          myReentrantLock.tryAcquire(1);@b@          doBusiness();@b@          myReentrantLock.tryRelease(1);@b@        }@b@      });@b@    }@b@    executorService.shutdown();@b@    executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);@b@    System.out.println("MyReentrantLock cost : " + (System.currentTimeMillis() - begin));@b@  }@b@@b@  private void doBusiness() {@b@    // 空实现,模拟程序快速运行@b@  }@b@}

上例,虽然MyReentrantLock继承了AbstractQueuedSynchronizer,但没有使用其阻塞队列。我们每种情况跑5次,看下两者在耗时层面的表现

耗时1

耗时2

耗时3

耗时4

耗时5

平均耗时(ms)

ReentrantLock

11425

12301

12289

10262

11461

11548

MyReentrantLock

8717

8957

10283

8445

8928

9066

上例只是拿独占锁举例,共享锁也同理。可以简单概括为:线程挂起、唤醒的时间占整个加锁周期比重较大,导致每次挂起、唤醒已经成为一种负担。当然此处并不是说AQS设计有什么缺陷,只是想表达并没有一种万能的框架能应对所有情况,一切都要靠使用者灵活理解、应用

1.3、类结构及如何使用

AQS类内部结构

因后文还会反复涉及,此处仅罗列2点

1)private volatile int state 重要属性,一般不论是实现独占锁还是共享锁,都要进行CAS操作的字段。独占锁时,如果通过cas将其从0改变为1的话,那么标记加锁成功;而在共享锁时,则表示支持并发数的最大值@b@@b@2)isHeldExclusively() 标记是否持有线程;AQS虽然为抽象类,但其继承了类AbstractOwnableSynchronizer,用来标记加锁线程,但AQS本身不依赖这个属性,也不会设置这个属性,实现类如果需要可以直接重新此方法。一般实现可重入特性需要重写该方法

而我们常用的锁并发类,基本上都是AQS的子类或通过组合方式实现,可见AQS在Java并发体系的重要性

至于如何使用,是需要区分子类是想实现独占锁还是共享锁

独占锁tryAcquire()tryRelease()isHeldExclusively() -- 可不实现@b@@b@共享锁tryAcquireShared()tryReleaseShared()

AQS本身是一个abstract类,将主要并发逻辑进行了封装,我们定义自己的并发控制类,仅需要实现其中的两三个方法即可。而在对外(public方法)表现形式上,可依据自己的业务特性来定义;例如Semaphore定义为acquire、release,而ReentrantLock定义为lock、unlock

二、锁

相信大家经常会被各种各样锁的定义搞乱,叫法儿也五花八门,为了后续行文的方便,此章我们把一些锁概念阐述一下

独占锁,顾名思义,即在同一时刻,仅允许一个线程执行同步块代码。好比一伙儿人想要过河,但只有一根独木桥,且只能承受一人的重量

JDK支持的典型独占锁:ReentrantLock、ReentrantReadWriteLock

2.2、共享锁

共享锁其实是相对独占锁而言的,涉及到共享锁就要聊到并发度,即同一时刻最多允许同时执行线程的数量。上图所述的并发度为3,即在同一时刻,最多可有3个人在同时过河。

但共享锁的并发度也可以设置为1,此时它可以看作是独占锁

JDK支持的典型独占锁:Semaphore、CountDownLatch

2.3、公平锁

虽然叫做公平锁,但我们知道任何事情都是相对的,此处也不例外,我们也只能做到相对公平,后文会涉及,此处不再赘述

线程在进入时,首先要检查阻塞队列中是否为空,如果发现已有线程在排队,那么主动添加至队尾并等待被逐一唤起;如果发现阻塞队列为空,才会尝试去获取资源。公平锁相对非公平锁效率较低,通常来讲,加锁时间越短,表现越明显

2.4、非公平锁

任何一个刚进入的线程,都会尝试去获取资源,释放资源后,还会通知头节点去尝试获取资源,这样可能导致饥饿发生,即某一个阻塞队列中的线程一直得不到调度。

那为什么我们会说,非公平锁的效率要高于公平锁呢?假设一个独占锁,阻塞队列中已经有10个线程在排队,线程A抢到资源并执行完毕后,去唤醒头结点head,head线程唤醒需要时间,head唤醒后才尝试去获取资源,而在整个过程中,没有线程在执行加锁代码

因为线程唤起需要引发用户态及内核态的切换,故是一个相对比较耗时的操作。

我们再举一个不恰当的例子:行政部在操场上为同学们办理业务,因为天气炎热,故让排队的同学在场边一个凉亭等待,凉亭距离业务点约300米,且无法直接看到业务点,需要等待上一个办理完毕的同学来通知。假定平均办理一个业务耗时约30秒

公平锁:所有新来办理业务的同学都被告知去排队,上一个办理完业务的同学需要去300米外通知下一个同学,来回600米的路程(线程唤醒)预估耗时2分钟,在这2分钟里,因为没有同学过来办理业务,业务点处于等待状态@b@@b@非公平锁:新来办理业务的同学首先看一下业务点是否有人正在在办理,如果有人正在办理,那么主动进入排队,如果办理点空闲,那么直接开始办理业务。明显非公平锁更高效,队首的同学接到通知,过来办理的时间片内,业务点可能已经处理了2个同学的业务

AQS框架是支持公平、非公平两种模式的,使用者可以根据自身的情况做选择,而Java中的内置锁synchronized是非公平锁

2.5、可重入锁

即某个线程获取到锁后、在释放锁之前,再次尝试获取锁,能成功获取到,不会出现死锁,便是可重入锁;需要注意的是,加锁次数需要跟释放次数一样

synchronized、ReentrantLock均为可重入锁

2.6、偏向锁 / 轻量级锁 / 重量级锁

之所以将这三个锁放在一起论述,是因为它们都是synchronized引入的概念,为了描述流畅,我们把它们放在一起

偏向锁:JVM设计者发现,在大多数场景中,在同一时刻争抢synchronized锁只有一个线程,而且总是被这一个线程反复加锁、解锁;故引入偏向锁,且向对象头的MarkWord部分中, 标记上线程id,值得一提的是,在线程加锁结束后,并没有解锁的动作,这样带来的好处首先是少了一次CAS操作,其次当这个线程再次尝试加锁时,仅仅比较MarkWord部分中的线程id与当前线程的id是否一致,如果一致则加锁成功。偏向锁因此而得名,它偏向于占有它的线程,对其非常友好。当上一个线程释放锁后,如果有另一个线程尝试加锁,偏向锁会重新偏向新的线程。而当一个线程正占有锁,又有一个新的线程试图加锁时,便进入了轻量级锁@b@@b@轻量级锁:所谓轻量级锁,是针对重量级锁而言的,这个阶段也有人叫自旋锁。其本质就是不会马上挂起线程,而是反复重试10(可使用参数-XX:PreBlockSpin来修改)次。因为线程挂起、唤醒也是相当耗时的,在锁并发不高、加锁时间短时,采用自旋可以得到更好的效果,具体可以参考1.2章的测试用例@b@@b@重量级锁:线程挂起并进入阻塞队列,等待被唤醒

这3层锁是逐级膨胀的,且过程不可回逆,即某个锁一旦进入重量级锁,便不可回退至轻量级锁或偏向锁。虽然synchronized不是本文的重点,但既然提起来了,我们可以把其特性简单罗列一下

synchronized 独占锁、非公平锁、可重入;内部做了很多优化

那synchronized锁的性能究竟如何呢?我们跟AQS框架中的ReentrantLock做个简单对比

public class SynchronizedAndReentrant {@b@@b@  private static int THREAD_NUM = 5;@b@@b@  private static int EXECUTE_COUNT = 30000000;@b@@b@  /**@b@   * 模拟ReentrantLock处理业务@b@   */@b@  @Test@b@  public void test() throws InterruptedException {@b@    ReentrantLock reentrantLock = new ReentrantLock();@b@    long begin = System.currentTimeMillis();@b@    ExecutorService executorService = Executors.newCachedThreadPool();@b@    for (int i = 0; i < THREAD_NUM; i++) {@b@      executorService.submit(() -> {@b@        for (int j = 0; j < EXECUTE_COUNT; j++) {@b@          reentrantLock.lock();@b@          doBusiness();@b@          reentrantLock.unlock();@b@        }@b@      });@b@    }@b@    executorService.shutdown();@b@    executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);@b@    System.out.println("ReentrantLock cost : " + (System.currentTimeMillis() - begin));@b@  }@b@@b@  private void doBusiness() {@b@  }@b@@b@  /**@b@   * 模拟synchronized处理业务@b@   */@b@  @Test@b@  public void test2() throws InterruptedException {@b@    long begin = System.currentTimeMillis();@b@    ExecutorService executorService = Executors.newCachedThreadPool();@b@    for (int i = 0; i < THREAD_NUM; i++) {@b@      executorService.submit(() -> {@b@        for (int j = 0; j < EXECUTE_COUNT; j++) {@b@          synchronized (SynchronizedAndReentrant.class) {@b@            doBusiness();@b@          }@b@        }@b@      });@b@    }@b@    executorService.shutdown();@b@    executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);@b@    System.out.println("synchronized cost : " + (System.currentTimeMillis() - begin));@b@  }@b@@b@}

耗时1

耗时2

耗时3

耗时4

耗时5

平均耗时(ms)

ReentrantLock

5876

5879

5601

5939

5925

5844

synchronized

5551

5611

5794

5397

5445

5559

在JDK1.8的ConcurrentHashMap中,作者已经将分段锁摒弃,进而采用synchronized为分桶加锁。synchronized已日趋成熟,我们应该摒弃对它低性能的偏见,放心大胆地去使用它

  • ◆ 相关内容