1
本文主要回答以下问题:
1.锁是什么,有哪些类型的锁,什么时候需要锁,以及锁的实现原理;
2.如何正确地使用锁;有哪些潜在的问题;
3.如何提升并发的性能(例如减少锁竞争,JVM的锁优化,原子类,非阻塞算法,无锁算法);

1. 锁的类型

从不同的角度或功能来对锁进行分类,使锁的类型有很多。下面表格列出了一些常见的锁类型。

类型定义例子优点缺点
显示锁需要通过代码显示地加锁和释放锁,JDK1.5引入Lock,ReentrantLock加锁灵活,等待锁可设置超时时间并且可被中断需要写加锁和释放锁的代码
内置锁又叫隐式锁,使用关键字后由JVM自动地加锁和释放锁。每个对象都可以用作锁对象synchronized关键字代码简洁,不用关心锁的获取与释放;可重入1.等待锁时无法被中断;2.请求锁时会无限等待下去
乐观锁乐观锁和悲观锁是一种锁的设计思想。乐观锁总认为数据不会被其他线程修改,所以写操作前不会对资源加锁,当最坏情况发生时再采取其他措施CAS操作,版本号机制锁竞争少,性能高。适用于读多写少的场景只能保证单个变量的原子性。ABA问题。
悲观锁悲观锁总认为最坏的情况会出现,数据很可能被其他线程修改,所以它总会把资源锁住synchronized锁,ReentrantLock等加锁灵活。适用于写多读少的场景锁竞争激烈,性能较低
公平锁按线程请求锁的顺序获得锁FairSync,继承自AQS不会出现线程饿死性能较低
非公平锁不是按请求锁的顺序,而是允许”插队“获得锁。synchronized 和 ReentrantLock 默认都是非公平锁NonfairSync,继承自AQS性能高,避免了线程频繁的休眠和恢复可能出现线程饿死
独占锁独占锁(排它锁)和共享锁是从多个线程是否可以同时获取同一个锁的角度来分的。独占锁在同一时刻只能被一个线程所持有写锁,synchronized锁既可以读取数据,也可以修改数据锁冲突增多,并发度降低
共享锁共享锁能够被多个线程所拥有,某个线程对资源加上共享锁后,其他线程只能对其再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据读锁提高并发度不能修改数据
互斥锁同一时刻只能有一个线程拥有共享资源,加锁后其他线程无法获取该共享资源,保证资源修改的原子性,和独占锁类似synchronized锁可以读取和修改资源并发度不高
可重入锁又叫递归锁,可重复可递归调用的锁,在外层获取锁后在内层可以继续获取此锁,通过锁记录器累计锁的次数ReentrantLock,synchronized锁有助于避免死锁可能导致不变量的不一致,不能确定获取锁和释放锁时不变量保持不变
读锁是一种共享锁,加锁后只可读数据,读锁之间不互斥。ReentrantReadWriteLock提供了读锁和写锁ReentrantReadWriteLock.ReadLock并发读取非常高效不能写数据否则会有线程安全问题
写锁是一种独占锁,加锁后可写数据和读数据,读写、写读和写写都是互斥的ReentrantReadWriteLock.WriteLock可读可写相比读锁比较低效
自旋锁没有获取到锁的线程一直循环判断资源是否释放锁,而不会被挂起(阻塞)的循环加锁-等待机制TicketLock,CLHLock避免了线程切换的开销,锁时间较短时非常高效占用CPU时间
分布式锁对运行在集群上的分布式应用的共享资源进行加锁,是一种跨机器的互斥机制。要求高可用。Redis,Redlock,Zookeeper,数据库解决分布式场景的互斥问题和一致性问题与单机锁相比不够简洁可靠性较低。高并发下存在锁性能问题
可中断锁在请求锁时可以被中断,即收到中断信号会停止锁的请求,并抛出中断异常ReentrantLock.lockInterruptibly()有助于避免死锁
不可中断锁在请求锁时不可被中断,一直等待锁synchronized锁 异常情况时不能停止请求锁,易造成死锁
偏向锁当一段同步代码一直被同一个线程所访问时(即不存在多个线程的竞争时),那么该线程在后续访问时便会自动获得锁(无需任何同步操作),从而降低获得锁带来的开销JDK1.6开始synchronized的优化,默认开启偏向锁。JDK15改为默认关闭没有锁竞争时性能高有锁竞争时很快失效,JDK维护成本高
轻量级锁使用CAS申请锁,没有拿到锁的线程会自旋等待(自旋锁),默认最大自旋10次。不挂起线程,从而提高性能synchronized偏向锁升级到轻量级锁锁竞争较低时性能高锁竞争较高时很快失效
重量级锁当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。线程调度交给操作系统synchronized轻量级锁升级到重量级锁,可重入锁无锁撤销的问题性能较低

思考题:
1.乐观锁会加锁吗?
2.为什么wait和notify方法放在Object类里?

2. 加锁机制

锁的本质是什么?
锁是一种同步机制,控制并发程序对共享资源的访问,线程持有锁时才可以对共享资源进行访问。在锁的实现上,锁是一个数据标记。

2.1 内置锁(synchronized)的原理

每个Java对象都可以作为一个锁,这个锁被称为内置锁或监视器锁(Monitor Lock)。内置锁的优点是:进入synchronized修饰的同步代码块前会自动去获得锁,在退出(包括抛出异常退出)同步代码块时自动释放锁。

内置锁通过更改对象头里的锁状态位来加锁和释放锁(对象头的结构下面会展示)。同步的实现依赖于monitor对象(HotSpot虚拟机里的monitor实现是ObjectMonitor对象),每个Java对象都可以有一个对应的monitor对象与之关联。ObjectMonitor对象中有两个队列_WaitSet 和 _EntryList,用来保存ObjectWaiter对象(封装了等待的线程)列表,monitor对象的同步方式如下
lock-monitor

如上图所示,新的线程会进入EntryList,当一个持有锁的线程释放monitor时,在入口区(EntryList)和等待区(WaitSet)的线程都会去竞争监视器(图中Owner所在区域)。Monitor对象只能有一个owner,一个线程成为监视器的owner后如果需要等待某个条件而执行了wait()方法,那么这个线程会释放锁并进入WaitSet(第3步所示)。

32位JVM的对象结构
1.对象头:由 MarkWord(32bit) + ClassMeta地址(32bit) 组成。在无锁状态时MarkWord的存储结构为:对象hashcode-25bit,对象分代年龄-4bit,是否偏向锁-1bit,锁标志位-2bit。
32位JVM的MarkWord和ClassMeta地址分别占用32bit,64位JVM的MarkWord和ClassMeta地址分别占用64bit。
2.实例数据;
3.对齐填充的数据;JVM要求对象的起始地址必须是8字节的整数倍。
对象头的MarkWord存储结构:(锁状态这列为每行的含义)
lock-object

在JDK1.6时,虚拟机团队对synchronized进行了一系列的优化。在此之前,synchronized是一个重量级锁,效率比较低。优化后,synchronized一开始是无锁或偏向锁(MarkWord后三位是001或101),随着锁竞争程度的加剧,才开始锁升级:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。注意,锁升级是一个不可逆的过程。

synchronized的执行过程
1.检查MarkWord里是不是当前线程的ID,如果是,表示当前线程处于偏向锁;
2.如果不是,则用CAS操作将当前线程ID替换进MardWord;如果成功则表示当前线程获得偏向锁,置偏向标志位1;
3.如果失败,则说明发生锁竞争,撤销偏向锁,并升级为轻量级锁;
4.当前线程使用CAS将对象头的MarkWord替换为栈中锁记录指针;如果成功,当前线程获得锁;
5.如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁;
6.如果自旋成功则依然处于轻量级锁状态;
7.如果自旋失败,则升级为重量级锁。

如果线程争用激烈,那么应该禁用偏向锁(-XX:-UseBiasedLocking),JDK1.6之后偏向锁是默认开启的,JDK15偏向锁改为默认关闭(开启配置-XX:+UseBiasedLocking)。

2.2 可重入锁(ReentrantLock)的原理

2.2.1常规用法

Lock lock = new ReentrantLock(false); // 参数为空时,默认创建非公平锁

lock.lock(); 
try {
   operation-xx(); // 业务处理逻辑
} finally {
    lock.unlock(); // 放在finally中确保锁释放
}

2.2.2 ReentrantLock的实现

ReentrantLock的实现基于AbstractQueuedSynchronizer(简写为AQS),可重入功能基于AQS的同步状态字段(也是锁标志字段):state。state用来表示所有者线程已经重复获得该锁的次数。

可重入锁的原理:当某一线程获取锁后,将state值加1,并记录下当前持有锁的线程标识,以便检查是否是重复获取锁,以及检测线程释放锁时是否非法;再有线程来获取锁时,判断这个线程与持有锁的线程是否是同一个线程,如果是,将state值再加1;如果不是,则阻塞新来的线程。在线程释放锁时,将state值减1;当state值减为0时,表示当前线程彻底释放了锁。

ReentrantLock没有直接继承AQS类,而是在内部定义了一个私有内部类Sync来继承AQS类,然后把自己的同步方法的实现都委托给这个私有内部类。这种同步器实现方式也是AQS作者Doug Lea建议的方式。ReentrantLock的类间关系如下图所示:
lock-reentrantLock

2.2.3 AQS类介绍

j.u.c包中大部分的同步器(例如锁,屏障等)都是基于AQS类构建的。AQS为同步状态的原子性管理、线程的阻塞和解除阻塞以及排队提供了一种通用的机制。
同步器一般包含两种基本方法,一种是acquire,另一种是release。acquire操作用于阻塞调用的线程,直到或除非同步状态允许其继续执行。而release操作则是通过某种方式改变同步状态,使得一或多个被acquire阻塞的线程继续执行。

  • acquire操作包括:Lock.lock,Semaphore.acquire,CountDownLatch.await 等。
  • release操作包括:Lock.unlock,Semaphore.release,CountDownLatch.countDown 等。
    AQS封装了实现同步器时涉及的大量细节问题,极大地减少了同步器的实现工作,只用根据需要重写几个AQS的protected方法(tryAcquire/TryAcquireShard,tryRelease/tryReleaseShared,getState,setState等)即可。

整个AQS框架的关键是如何管理被阻塞线程的链表队列,该队列是严格的FIFO队列(但不一定是公平的)。AQS选择了CLH锁作为实现此队列的基础,因为CLH锁可以更容易地去实现“取消(cancellation)”和“超时”功能。

AQS框架提供了一个ConditionObject类,给维护独占同步的类以及实现Lock接口的类使用。此条件对象提供了await、signal和signalAll操作,AQS在一个单独的条件队列中维护这些条件对象节点,其中signal操作是通过将节点从条件队列转移到锁队列(即上述的CHL链表队列)中来实现的。

使用AQS框架构建同步器时,将AQS的子类作为同步器抽象类并不适合,因为AQS子类必须提供方法在内部控制acquire和release的规则,但这些方法都应该对用户隐藏。建议的做法是在定义的同步器类内部声明一个AQS子类作为私有内部类,把所有同步方法都委托给这个私有内部类,j.u.c包里的同步器类都是这种用法(使用私有内部类Sync)。
AQS框架的使用例子: ReentrantLock类代码截图
lock-aqs

3. 死锁

3.1 什么是死锁

经典的“哲学家进餐”问题很好的描述了死锁的状况,当下面这种情况出现时将产生死锁:每个哲学家都拥有其他人需要的资源(一根筷子),同时又等待其他人已经拥有的资源(一根筷子),并且每个人在获得所需资源(两根筷子)前都不会放弃已经拥有的资源。此时每个哲学家都在等待另一根筷子,在没有外力干涉的情况下,他们会一直等待下去,这就是死锁,所有哲学家都将“饿死”。

当死锁出现时,往往是在最糟糕的时候 - 系统高负载时,因为这时并发最高竞争也最激烈。火上浇油莫过如此。

下面介绍死锁的几种类型,包括:锁顺序死锁,动态的锁顺序死锁,协作对象之间的死锁,资源死锁。

锁顺序死锁
两个线程试图以不同的顺序来获得相同的锁,会发生锁顺序死锁。如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。代码示例如下,一个线程调用了leftRightOrder(),另一个线程同时调用了rightLeftOrder()方法,它们会发生死锁:

public static
class LockOrderingDeadlocks {
    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRightOrder() {
        synchronized (left) {
            synchronized (right) {
                // doSomething()
            }
        }
    }

    public void rightLeftOrder() {
        synchronized (right) {
            synchronized (left) {
                // doSomethingElse()
            }
        }
    }
}

动态的锁顺序死锁
这种情况比较隐蔽,因为在方法内的锁顺序是固定的,看似无害,但锁的顺序取决于传递给方法的参数的顺序,此时调用方法的双方可能传的参数是颠倒的,这时就会造成锁顺序死锁,即在动态调用方法时发生了锁顺序死锁。代码示例如下:

public void
transferMoney(Account fromAccount, Account toAccount, double amount) {
    synchronized (fromAccount) {
        synchronized (toAccount) {
            // fromAccount减少amount金额
            // toAccount增加amount金额
        }
    }
}

上述定义了银行转账的方法transferMoney,系统如果同时调用了 transferMoney(A, B, 1) 和 transferMoney(B,
A, 2) 则会发生死锁。
要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来加锁。

协作对象之间的死锁
这种情况比前面两种情况更加隐蔽,如果在持有锁时调用某个外部方法,那么将可能出现活跃性问题,因为这个协作对象提供的外部方法中可能会获取其他锁。

资源死锁
与等待锁一样,线程在相同的资源集合上等待资源时,也会发生死锁。资源池越大,出现这种情况的概率越小。

死锁的产生必须是以下4个情况同时发生

  • 互斥条件:每个资源都被分配给了一个进程,且资源不能被共享;
  • 保持和等待条件:已经获取资源的进程被认为能够再次获取新的资源,所以会一直持有获得的资源并等待;
  • 不可抢占条件:分配给进程的资源不能被抢夺;
  • 循环等待:每个进程都在等待另一个进程释放资源,形成一个环路的等待。

如果其中任意一个条件不成立,死锁就不会发生。可以通过破坏其中任意一个条件来破坏资源死锁。

3.2 死锁的避免和诊断

在需要获取多个锁时,在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,把获取锁时需要遵循的协议写入正式文档,并始终遵循这些协议。

使用定时的锁,对超过等待时限的锁请求返回一个失败信息,而不是一直等待下去。Lock接口的tryLock(long, TimeUnit)方法,提供了这个功能。定时的锁可以避免死锁的发生,或者说使线程可以从死锁中恢复。

死锁的诊断分析,可以通过jstack dump线程信息,查看每个线程持有了哪些锁,以及被阻塞的线程在等待哪个锁。命令为:jstack ${pid} > file_jstack.txt。 把线程信息文件file_jstack.txt上传到
https://fastthread.io/ 可以帮助你更快速分析。

3.3 其他活跃性危险

死锁是最常见的活跃性危险,除此之外,还有饥饿、丢失信号和活锁等。

饥饿
定义:当线程由于无法访问他需要的资源而不能继续执行时,就发生了“饥饿”。

引发线程饥饿的最常见资源就是CPU,比如一些线程的优先级高或者大量循环计算一直占用着CPU,导致其他线程无法竞争到CPU。因此,要避免使用线程优先级,否则可能会增加发生饥饿的风险。

丢失的信号
定义:线程在wait一个条件为真后继续执行,但在线程开始wait之前这个条件已经为真,但线程wait前并没有检查这个条件,导致这个已经为真的条件成为了一个丢失的信号,从而使线程一直等待下去。

解决方法是在wait操作前都检查一次条件谓词,就不会发生信号丢失的问题,如下所示:

synchronized(lock)
{
    while(!conditionPredicate()) { // 检查条件谓词
        lock.wait(); // 在while循环中调用wait
    }
}

活锁
定义:当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁(Livelock)。

发生活锁时,尽管未发生线程阻塞,但线程在不断重复执行相同的操作,而且总会失败,所以没法继续执行下去。这种活锁通常是由于过度的错误恢复代码造成的,需要在重试机制中引入随机性来解决,比如随机化重试的等待时长,避免各线程又在同一时刻重试。

4. 提升并发性能

下面介绍的几种提升并发性能的方式是从减少同步开销的方面出发的,除此之外提升性能还包含其他手段,比如使用缓存,查询优化等。
除了锁优化是JVM自动完成的,下面的其他3种方式都是在编码设计时需要注意的。

4.1 减少锁的竞争

有3种方式可以降低锁的竞争程度:1.减少锁的持有时间;2.降低锁的请求频率;3.使用带有协调机制的独占锁,这些机制允许更高的并发性。
下面的几种具体措施即是基于上面的3种指导思想。

缩小锁的范围
缩小锁的作用范围,比如把一些无关代码移出被锁住的同步代码块,尤其是那些开销较大的操作,能有效减少锁持有的时间。或者说,只把真正需要同步的操作才加锁。

在分解同步代码块时,分解后的同步代码块也不能过小,比如一次原子操作需要同时更新多个变量的情况就必须包含在一个同步代码块中。如果分解为了多个同步代码块,由于过多的同步操作会产生更多的开销,在JVM执行锁粗化操作时,可能会将分解的同步块又重新合并起来。

减小锁的粒度
当一个锁需要保护多个相互独立的状态变量时,可以将这个锁分解为多个更小粒度的锁,每个锁只保护一个变量,从而提高可伸缩性,最终降低每个锁被请求的频率。

对锁粒度的分解,实际上是减少原锁的竞争,使多个独立的变量之间没有锁竞争。代码示例如下,ServerStatus类维护两个变量:当前的登录用户users和正在执行的查询queries。两个同步方法addUser和addQuery都需要获得ServerStatus对象的锁才能执行。减小锁的粒度后,在ServerStatusDecompose类中这两个方法各自用了users和queries对象的锁,所以两个方法不存在锁的竞争。

public static
class ServerStatus {
    public final Set<String> users = new HashSet<>();
    public final Set<String> queries = new HashSet<>();

    public synchronized void addUser(String u) { users.add(u); }
    public synchronized void addQuery(String q) { queries.add(q); }
}
// 减小锁的粒度,两个独立变量users和queries分别用两个锁来保护
public static class ServerStatusDecompose {
    public final Set<String> users = new HashSet<>();
    public final Set<String> queries = new HashSet<>();

    public void addUser(String u) {
        synchronized (users) {
            users.add(u);
        }
    }
    public void addQuery(String q) {
        synchronized (queries) {
            queries.add(q);
        }
    }
}

锁分段
锁分段技术很知名的例子就是ConcurrentHashMap的实现,它使用了一个锁数组,这个数组存储了16个锁,每个锁保护所有hash buckets的1/16,其中第N个bucket由第 N mod 16 个锁来保护。这样分段保护后,把对锁的请求减少到原来的1/16,同时能够支持多达16个的并发写入。

某些情况下,可以将锁分解技术扩展至对一个变长的独立对象集合进行分区锁定,这称为锁分段。

锁分段有其劣势,当要独占访问整个集合时需要获取多个锁,相比单个锁,这比较困难,开销也更大。比如当ConcurrentHashMap需要扩容并重新哈希键值时,就需要获取所有的分段锁。

替代独占锁
降低锁竞争的另一个方法是放弃使用独占锁。比如使用并发容器,读写锁,不可变对象和原子变量。

4.2 JVM的锁优化

自旋锁
等待锁的线程执行一个忙循环(自旋)来等待锁,而不是挂起线程等待,这称为自旋锁。

因为很多锁状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。JDK1.6已经默认开启自旋锁(-XX:+UseSpinning),当锁占用时间很短时,自旋锁的效果非常好;但如果锁占用时间很长,那自旋的线程只会白白浪费CPU资源。因此自旋等待的时间有一个限度,超过限度就挂起线程。这个固定的限度在JDK1.6进行了优化,引入了自适应自旋,使自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定。如果对于某个锁自旋很少成功获得过锁,那么以后获取这个锁时可能直接省略掉自旋。

锁消除
锁消除是指JVM在即时编译阶段对一些同步代码块的锁进行清除,它的判断依据来自于逃逸分析,如果同步代码块里的数据都不会逃逸出去被其他线程所访问,那么就可以认为它们是线程私有的,对它们加的锁就可以消除掉。

锁粗化
如果一系列连续的操作都对同一个对象反复加锁和解锁,甚至在循环中进行反复加锁和解锁,那么即使没有线程竞争,这种频繁的互斥同步操作也会导致性能损耗。虚拟机探测到这种情况时,会把锁的范围扩大(粗化)到所有连续操作的外面(或循环体的外部),这样只需加一次锁即可。

偏向锁 和 轻量级锁
偏向锁和轻量级锁是JDK1.6中引入的对内置锁synchronized的优化措施。偏向锁的目的是在无竞争的情况下把整个同步都消除掉,CAS操作也省去。轻量级锁的加锁和解锁都是通过CAS操作来进行的,在没有竞争的情况下,避免了同步的开销。这两种锁的升级过程见“加锁机制 - 内置锁的原理”。

4.3 原子变量类

原子变量比锁的粒度更细,量级更轻,它既有原子性,也有内存可见性,是一种更好的volatile变量。最常用的原子变量是标量类:AtomicInteger,AtomicLong,AtomicBoolean,AtomicReference。所有这些类都支持CAS操作(比较并交换)。

锁与原子变量的性能对比:

  1. 在中低程度的竞争下,原子变量的性能远超锁的性能,原子变量能提供更高的可伸缩性;
  2. 在高度竞争的情况下,锁的性能将超过原子变量的性能。这是因为锁在发生竞争时会挂起线程,从而降低了CPU的使用率和共享内存总线上的同步通信量。

4.4 非阻塞算法

非阻塞算法是指,一个线程的失败或挂起不会导致其他线程也失败或挂起的算法。
无锁算法是指,算法中的每个步骤中都有某些线程能够执行下去。
如果在算法中仅使用CAS操作协调各线程,并且能正确地实现功能,那么这种算法既是非阻塞的,又是无锁的。

非阻塞算法之所以能提升性能,是因为线程阻塞的代价较大。线程阻塞的代价,即线程上下文切换的代价。要阻塞或唤醒一个线程需要操作系统介入,要在户态与核心态之间切换。这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间和寄存器等,用户态切换至内核态需要传递许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。这会消耗cpu时间。

非阻塞的栈代码示例:

public static
class NonblockingStack<E> {
    AtomicReference<Node<E>> top = new AtomicReference<>(); // 栈顶元素

    public void push(E item) {
        Node<E> newHead = new Node<>(item);
        Node<E> oldHead;
        do {
            oldHead = top.get();
            newHead.next = oldHead;
        } while (!top.compareAndSet(oldHead, newHead));
    }

    public E pop() {
        Node<E> oldHead;
        Node<E> newHead;
        do {
            oldHead = top.get();
            if (oldHead == null) { return null; }
            newHead = oldHead.next;
        } while (!top.compareAndSet(oldHead, newHead));
        return oldHead.item;
    }
}

ABA问题
在CAS操作中可能会遇到ABA问题,当值由A -> B,再由B -> A,是否认为值没有发生变化?在某些算法中,这被认为是发生了变化,会影响算法的执行步骤。解决ABA问题可以通过引入版本号,在比较两个值的同时也一起比较版本号。

非阻塞算法在设计和实现时非常困难,但能提供更高的性能和可伸缩性。在JVM的版本升级过程中,并发性能的提升都来自于类库中对非阻塞算法的使用。

参考文献

[1] Brian Goetz,Tim Peierls,Joseph Bowbeer等.《Java并发编程实战》 机械工业出版社,2012
[2] Brian Goetz,Tim Peierls,Joseph Bowbeer,et al. 《Java Concurrency in Practice》Addison-Wesley,2006
[3] 周志明.《深入理解Java虚拟机-第3版》 机械工业出版社,2019
[4] Doug Lea. 欧振聪 译. AQS论文翻译: https://www.cnblogs.com/dennyzhangdd/p/7218510.html,2017
    Doug Lea. AQS论文原文:https://gee.cs.oswego.edu/dl/papers/aqs.pdf,2004

 

 

 

 


Cong
8 声望1 粉丝

Create value