Lock实现原理
Lock完全用Java写成,在java这个层面是无关JVM实现的。
在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReentrantLock、ReadWriteLock(实现类ReentrantReadWriteLock),其实现都依赖java.util.concurrent.AbstractQueuedSynchronizer类,实现思路都大同小异,因此我们以ReentrantLock作为讲解切入点。
实现Lock接口的类有很多,以下为几个常见的锁实现
- ReentrantLock:表示重入锁,它是唯一一个实现了Lock接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数
- ReentrantReadWriteLock:重入读写锁,它实现了ReadWriteLock接口,在这个类中维护了两个锁,一个是ReadLock,一个是WriteLock,他们都分别实现了Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是:
读和读不互斥、读和写互斥、写和写互斥
。也就是说涉及到影响数据变化的操作都会存在互斥。 - StampedLock: stampedLock是JDK8引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock是一种乐观的读策略,使得乐观锁完全不会阻塞写线程
JUC的atomic包下运用了CAS的AtomicBoolean、AtomicInteger、AtomicReference等原子变量类
JUC的locks包下的AbstractQueuedSynchronizer(AQS)以及使用AQS的ReentantLock(显式锁)、ReentrantReadWriteLock
附:运用了AQS的类还有:Semaphore、CountDownLatch、ReentantLock(显式锁)、ReentrantReadWriteLock
JUC下的一些同步工具类:CountDownLatch(闭锁)、Semaphore(信号量)、CyclicBarrier(栅栏)、FutureTask
JUC下的一些并发容器类:ConcurrentHashMap、CopyOnWriteArrayList
JUC下的一些Executor框架的相关类: 线程池的工厂类->Executors 线程池的实现类->ThreadPoolExecutor/ForkJoinPool
JUC下的一些阻塞队列实现类:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue
lock实现过程
计数值、双向链表、CAS+自旋
我们以ReentrantLock为例做分析,其他原理类似。
可以实现公平锁和非公平锁( 当有线程竞争锁时,当前线程会首先尝试获得锁而不是在队列中进行排队等候,这对于那些已经在队列中排队的线程来说显得不公平,这也是非公平锁的由来),默认情况下为非公平锁。
实现原理
ReentrantLock() 干了啥
public ReentrantLock() {
sync = new NonfairSync();
}
在lock的构造函数中,定义了一个NonFairSync,
static final class NonfairSync extends Sync
NonfairSync 又是继承于Sync
abstract static class Sync extends AbstractQueuedSynchronizer
一步一步往上找,找到了
这个鬼AbstractQueuedSynchronizer(简称AQS),最后这个鬼,又是继承于AbstractOwnableSynchronizer(AOS),AOS主要是保存获取当前锁的线程对象,代码不多不再展开。最后我们可以看到几个主要类的继承关系:
FairSync 与 NonfairSync的区别在于,是不是保证获取锁的公平性,因为默认是NonfairSync(非公平性),我们以这个为例了解其背后的原理。
其他几个类代码不多,最后的主要代码都是在AQS中,我们先看看这个类的主体结构。
看看AbstractQueuedSynchronizer是个什么
再看看Node是什么?
看到这里的同学,是不是有种热泪盈眶的感觉,这尼玛,不就是双向链表么?我还记得第一次写这个数据结构的时候,发现居然还有这么神奇的一个东西。
最后我们可以发现锁的存储结构就两个东西:"双向链表" + "int类型状态"。
最后我们可以发现锁的存储结构就两个东西:"双向链表" + "int类型状态"。
简单来说,ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
需要注意的是,他们的变量都被"transient和volatile修饰。
一个int值,一个双向链表是如何烹饪处理锁这道菜的呢,Doug Lea大神就是大神,
我们接下来看看,如何获取锁?
lock.lock()怎么获取锁?
public void lock() {
sync.lock();
}
可以看到调用的是,NonfairSync.lock()
看到这里,我们基本有了一个大概的了解,还记得之前AQS中的int类型的state值,
这里就是通过CAS(乐观锁)去修改state的值(锁状态值)。lock的基本操作还是通过乐观锁来实现的。
获取锁通过CAS,那么没有获取到锁,等待获取锁是如何实现的?我们可以看一下else分支的逻辑,acquire方法:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这里干了三件事情:
- tryAcquire:会尝试再次通过CAS获取一次锁。
- addWaiter:将当前线程加入上面锁的双向链表(等待队列)中
- acquireQueued:通过自旋,判断当前队列节点是否可以获取锁。
addWaiter() 添加当前线程到等待链表中
可以看到,通过CAS确保能够在线程安全的情况下,将当前线程加入到链表的尾部。
enq是个自旋+上述逻辑,有兴趣的可以翻翻源码。
acquireQueued() 自旋+CAS尝试获取锁
可以看到,通过CAS确保能够在线程安全的情况下,将当前线程加入到链表的尾部。
enq是个自旋+上述逻辑,
acquireQueued() 自旋+CAS尝试获取锁
可以看到,当当前线程到头部的时候,尝试CAS更新锁状态,如果更新成功表示该等待线程获取成功。从头部移除。
lock.unlock() 释放锁
public void unlock() {
sync.release(1);
}
可以看到调用的是,NonfairSync.release()
最后又调用了NonfairSync.tryRelease()
基本可以确认,释放锁就是对AQS中的状态值State进行修改。同时更新下一个链表中的线程等待节点。
总结
lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)
lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。
lock释放锁的过程:修改状态值,调整等待链表。
可以看到在整个实现过程中,lock大量使用CAS+自旋。因此根据CAS特性,lock建议使用在低锁冲突的情况下。目前java1.6以后,官方对synchronized做了大量的锁优化(偏向锁、自旋、轻量级锁)。因此在非必要的情况下,建议使用synchronized做同步操作。
锁实现
简单说来,AbstractQueuedSynchronizer会把所有的请求线程构成一个CLH队列,当一个线程执行完毕(lock.unlock())时会激活自己的后继节点,但正在执行的线程并不在队列中,而那些等待执行的线程全 部处于阻塞状态,经过调查线程的显式阻塞是通过调用LockSupport.park()完成,而LockSupport.park()则调用 sun.misc.Unsafe.park()本地方法,再进一步,HotSpot在Linux中中通过调用pthread_mutex_lock函数把 线程交给系统内核进行阻塞。
与synchronized相同的是,这也是一个虚拟队列,不存在队列实例,仅存在节点之间的前后关系。令人疑惑的是为什么采用CLH队列呢?原生的CLH队列是用于自旋锁,但Doug Lea把其改造为阻塞锁。
当有线程竞争锁时,该线程会首先尝试获得锁,这对于那些已经在队列中排队的线程来说显得不公平,这也是非公平锁的由来,与synchronized实现类似,这样会极大提高吞吐量。 如果已经存在Running线程,则新的竞争线程会被追加到队尾,具体是采用基于CAS的Lock-Free算法,因为线程并发对Tail调用CAS可能会 导致其他线程CAS失败,解决办法是循环CAS直至成功。AQS的实现非常精巧,令人叹为观止,不入细节难以完全领会其精髓,下面详细说明实现过程:
AbstractQueuedSynchronizer通过构造一个基于阻塞的CLH队列容纳所有的阻塞线程,而对该队列的操作均通过Lock-Free(CAS)操作,但对已经获得锁的线程而言,ReentrantLock实现了偏向锁的功能。
synchronized 的底层也是一个基于CAS操作的等待队列,但JVM实现的更精细,把等待队列分为ContentionList和EntryList,目的是为了降低线程的出列速度;当然也实现了偏向锁,从数据结构来说二者设计没有本质区别。但synchronized还实现了自旋锁,并针对不同的系统和硬件体系进行了优 化,而Lock则完全依靠系统阻塞挂起等待线程。
当然Lock比synchronized更适合在应用层扩展,可以继承 AbstractQueuedSynchronizer定义各种实现,比如实现读写锁(ReadWriteLock),公平或不公平锁;同时,Lock对 应的Condition也比wait/notify要方便的多、灵活的多。
总体来讲线程获取锁要经历以下过程(非公平):
1、调用lock方法,会先进行cas操作看下可否设置同步状态1成功,如果成功执行临界区代码
2、如果不成功获取同步状态,如果状态是0那么cas设置为1.
3、如果同步状态既不是0也不是自身线程持有会把当前线程构造成一个节点。
4、把当前线程节点CAS的方式放入队列中,行为上线程阻塞,内部自旋获取状态。
(acquireQueued的主要作用是把已经追加到队列的线程节点进行阻塞,但阻塞前又通过tryAccquire重试是否能获得锁,如果重试成功能则无需阻塞,直接返回。)
5、线程释放锁,唤醒队列第一个节点,参与竞争。重复上述。