1、Java Concurrent API
中的 Lock
接口(Lock interface
)是什么?对比同步它有什么优势?
答:Lock
接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。
优势:
- 可以使锁更公平;
- 可以使线程在等待锁的时候响应中断;
- 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间;
- 可以在不同的范围,以不同的顺序获取和释放锁;
整体上来说
Lock
是synchronized
的扩展版,Lock
提供了无条件的、可轮询的(tryLock()
方法)、定时的(tryLock()
带参方法)、可中断的(lockInterruptibly()
)、可多条件队列的(newCondition()
方法)锁操作。另外Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized
只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。
2、什么是CAS
?
CAS
是compare and swap
的缩写,即我们所说的比较交换。CAS
是一种基于锁的操作,而且是乐观锁。在 Java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version
来获取数据,性能较悲观锁有很大的提高。CAS
操作包含三个操作数 ——内存位置(V
)、预期原值(A
)和新值(B
)。如果内存地址里面的值和A
的值是一样的,那么就将内存里面的值更新成B
。CAS
是通过无限循环来获取数据的,若果在第一轮循环中,a
线程获取地址里面的值被b
线程修改了,那么a
线程需要自旋,到下次循环才有可能机会执行。java.util.concurrent.atomic
包下的类大多是使用CAS
操作来实现的(AtomicInteger,AtomicBoolean,AtomicLong)
。
3、乐观锁和悲观锁的理解及如何实现,有哪些实现方式?
- 悲观锁: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步原语
synchronized
关键字的实现也是悲观锁。 - 乐观锁: 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于
write_condition
机制,其实都是提供。乐观锁。在Java
中java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS
实现的。
3.1、乐观锁的实现方式:
- 使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
Java
中的Compare and Swap
即CAS
,当多个线程尝试使用CAS
同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
3.2、CAS
操作中包含三个操作数
- 需要读写的内存位置(
V
) - 进行比较的预期原值(
A
) - 拟写入的新值(
B
)
如果内存位置
V
的值与预期原值A
相匹配,那么处理器会自动将该位置值更新为新值B
。否则处理器不做任何操作。
4、CAS
的会产生什么问题?
ABA
问题: 比如说一个线程one
从内存位置V
中取出A
,这时候另一个线程two
也从内存中取出A
,并且two
进行了一些操作变成了B
,然后two
又将V
位置的数据变成A
,这时候线程one
进行CAS
操作发现内存中仍然是A
,然后one
操作成功。尽管线程one
的CAS
操作成功,但可能存在潜藏的问题。从Java1.5
开始JDK
的atomic
包里提供了一个类AtomicStampedReference
来解决ABA
问题。- 循环时间长开销大: 对于资源竞争严重(线程冲突严重)的情况,
CAS
自旋的概率会比较大,从而浪费更多的CPU
资源,效率低于synchronized
。 - 只能保证一个共享变量的原子操作: 当对一个共享变量执行操作时,我们可以使用循环
CAS
的方式来保证原子操作,但是对多个共享变量操作时,循环CAS
就无法保证操作的原子性,这个时候就可以用锁。
5、AQS
?
答:AQS
队列同步器是用来构建锁或其他同步组件的基础框架,它使用一个 volatile int state
变量作为共享资源,如果线程获取资源失败,则进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。比如我们提到的ReentrantLock
,Semaphore
,其他的诸如ReentrantReadWriteLock
,SynchronousQueue
,FutureTask
等等皆是基于AQS
的。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,对同步状态进行更改需要使用同步器提供的 3个方法 getState()
、setState()
和 compareAndSetState()
,它们保证状态改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅定义若干同步状态获取和释放的方法,同步器既支持独占式也支持共享式。同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。锁面向使用者,定义了使用者与锁交互的接口,隐藏实现细节;同步器面向锁的实现者,简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与唤醒等底层操作。
每当有新线程请求资源时都会进入一个等待队列,只有当持有锁的线程释放锁资源后该线程才能持有资源。等待队列通过双向链表实现,线程被封装在链表的 Node
节点中,Node
的等待状态包括:
CANCELLED
(线程已取消)SIGNAL
(线程需要唤醒)CONDITION
(线程正在等待)PROPAGATE
(后继节点会传播唤醒操作,只在共享模式下起作用)。
6、AQS
的原理?
- 获取同步状态: 调用
acquire()
方法,维护一个同步队列,使用tryAcquire()
方法安全地获取线程同步状态,获取失败的线程会被构造同步节点并通过addWaiter()
方法加入到同步队列的尾部,在队列中自旋。之后调用acquireQueued()
方法使得该节点以死循环的方式获取同步状态,如果获取不到则阻塞,被阻塞线程的唤醒主要依靠前驱节点的出队或被中断实现,移出队列或停止自旋的条件是前驱节点是头结点且成功获取了同步状态。 - 释放同步状态: 同步器调用
tryRelease()
方法释放同步状态,然后调用unparkSuccessor()
方法唤醒头节点的后继节点,使后继节点重新尝试获取同步状态。
6.1、CLH
队列:
答: CLH(Craig,Landin,and Hagersten)
队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS
是将每条请求共享资源的线程封装成一个CLH
锁队列的一个结点(Node)
来实现锁的分配。
6.2、AQS
原理图
答: 获取同步状态时,调用 acquire()
方法,维护一个同步队列,使用 tryAcquire()
方法安全地获取线程同步状态,获取失败的线程会被构造同步节点并通过 addWaiter()
方法加入到同步队列的尾部,在队列中自旋。之后调用 acquireQueued()
方法使得该节点以死循环的方式获取同步状态,如果获取不到则阻塞,被阻塞线程的唤醒主要依靠前驱节点的出队或被中断实现,移出队列或停止自旋的条件是前驱节点
是头结点且成功获取了同步状态。
释放同步状态时,同步器调用 tryRelease()
方法释放同步状态,然后调用 unparkSuccessor()
方法唤醒头节点的后继节点,使后继节点重新尝试获取同步状态。
AQS
使用一个int
成员变量来表示同步状态,通过内置的FIFO
队列来完成获取资源线程的排队工作。AQS
使用CAS
对该同步状态进行原子操作实现对其值的修改。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
状态信息通过protected
类型的getState()
,setState()
,compareAndSetState()
进行操作:
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
6.3、AQS
对资源的共享方式
答:AQS
定义两种资源共享方式,独占模式通过 acquire
和 release
方法获取和释放锁,共享模式通过acquireShared
和 releaseShared
方法获取和释放锁。
Exclusive
(独占):只有一个线程能执行,如ReentrantLock
。又可分为公平锁和非公平锁:- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁;
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的。
Share
(共享):多个线程可同时执行,如Semaphore/CountDownLatch
。Semaphore
、CountDownLatch
、CyclicBarrier
、ReadWriteLock
我们都会在后面讲到。
ReentrantReadWriteLock
可以看成是组合式,因为ReentrantReadWriteLock
也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源
state
的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS
已经在顶层实现好了。
6.4、AQS
底层使用了模板方法模式
答: 同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承
AbstractQueuedSynchronizer
并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state
的获取和释放) - 将
AQS
组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
6.5、AQS
使用了模板方法模式,自定义同步器时需要重写下面几个AQS
提供的模板方法。
//该线程是否正在独占资源。只有用到condition才需要去实现它。
isHeldExclusively()
//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryAcquire(int)
//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryRelease(int)
//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryAcquireShared(int)
//共享方式。尝试释放资源,成功则返回true,失败则返回false。
tryReleaseShared(int)
答: 默认情况下,每个方法都抛出 UnsupportedOperationException
。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS
类中的其他方法都是final
,所以无法被其他类使用,只有这几个方法可以被其他类使用:
- 以
ReentrantLock
为例,state
初始化为0,表示未锁定状态。A
线程lock()
时,会调用tryAcquire()
独占该锁并将state
+1。此后,其他线程再tryAcquire()
时就会失败,直到A
线程unlock()
到state=0
(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A
线程自己是可以重复获取此锁的(state
会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state
是能回到零态的。 - 再以
CountDownLatch
以例,任务分为N
个子线程去执行,state
也初始化为N
(注意N
要与线程个数一致)。这N
个子线程是并行执行的,每个子线程执行完后countDown()
一次,state
会CAS(Compare and Swap)
减1。等到所有子线程都执行完后(即state=0
),会unpark()
调用主线程,然后主调用线程就会从await()
函数返回,继续后面动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现
tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一种即可。但AQS
也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock
。
7、为什么只有前驱节点是头节点时才能尝试获取同步状态?
答: 头节点是成功获取到同步状态的节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节
点。
目的:维护同步队列的 FIFO
原则,节点和节点在循环检查的过程中基本不通信,而是简单判断自己的前驱是否为头节点,这样就使节点的释放规则符合 FIFO
,并且也便于对过早通知的处理,过早通知指前驱节点不是头节点的线程由于中断被唤醒。
8、AQS
共享式式获取/释放锁的原理?
答:
- 同步状态: 调用
acquireShared()
方法,该方法调用tryAcquireShared()
方法尝试获取同步状态,返回值为int
类型,返回值不小于0 表示能获取同步状态。因此在共享式获取锁的自旋过程中,成功获取同步状态并退出自旋的条件就是该方法的返回值不小于0。 - 释放同步状态: 调用
releaseShared()
方法,释放后会唤醒后续处于等待状态的节点。它和独占式的区别在于tryReleaseShared()
方法必须确保同步状态安全释放,通过循环 CAS 保证,因为释放同步状态的操作会同时来自多个线程。
9、什么是可重入锁(ReentrantLock
)?
答:ReentrantLock
重入锁,是实现Lock
接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。
在Java关键字
synchronized
隐式支持重入性,synchronized
通过获取自增,释放自减的方式实现重入。与此同时,ReentrantLock
还支持公平锁和非公平锁两种方式。
重入性的实现原理
答: 要想支持重入性,就要解决两个问题:
-
在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;
-
由于锁会被获取
n
次,那么只有锁在被释放同样的n
次之后,该锁才算是完全释放成功。
ReentrantLock
支持两种锁:公平锁和非公平锁。何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO
。
10、ReadWriteLock
是什么?
答: 如果使用 ReentrantLock
,可能本身是为了防止线程 A
在写数据、线程 B
在读数据造成的数据不一致,但这样,如果线程 C
在读数据、线程 D
也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。因为这个,才诞生了读写锁ReadWriteLock。
ReadWriteLock
是一个读写锁接口,读写锁是用来提升并发程序性能的锁分离技术,ReentrantReadWriteLock
是 ReadWriteLock
接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。
而读写锁有以下三个重要的特性:
- 公平选择性: 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
- 重进入: 读锁和写锁都支持线程重进入。
- 锁降级: 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。