本文参考于《Java并发编程的艺术》
1、Lock接口
1.1、锁说明
锁是用来控制多个线程访问共享资源的方式
,一般来说,一个锁能够防止多个线程同时访问共享资源
(但是有些锁可以允许多个线程并发的访问共享资源
,比如读写锁)。
1.2、Lock接口和synchronized关键字的对比
- Lock接口:它提供了与synchronized关键字类似的同步功能,
只是在使用时需要显式地获取和释放锁
。虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁
等多种synchronized关键字所不具备的同步特性。 - synchronized关键字:使用synchronized关键字将会隐式地获取锁,但是
它将锁的获取和释放固化了,也就是先获取再释放
。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好。
1.3、Lock接口的使用
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class locks {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
new Thread(()->{
lock.lock();
try{
System.out.println("t1.....");
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
},"t1").start();
new Thread(()->{
lock.lock();
try{
System.out.println("t2.....");
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
},"t2").start();
}
}
输出结果
使用说明
- 在finally块中释放锁,目的是
保证在获取到锁之后,最终能够被释放
。 - 不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,
异常抛出的同时,也会导致锁无故释放
。
1.4、Lock的API
方法名称 | 描述 |
---|---|
void lock() | 获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回 |
void lockInterruptibly() throws InterruptedException | 可中断地获取锁,和 lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程 |
boolean tryLock() | 尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false |
boolean tryLock(1ong time,TimeUnit unit) throws InterruptedException | 超时的获取锁,当前线程在以下3种情况下会返回:①当前线程在超时时间内获得了锁 ②当前线程在超时时间内被中断 ③超时时间结束,返回false |
void unlock() | 释放锁 |
Condition newCondition() | 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的 wait()方法,而调用后,当前线程将释放锁 |
2、队列同步器
2.1、什么是队列同步器?
- 队列同步器(以下简称同步器),是
用来构建锁或者其他同步组件的基础框架
,它使用了一个int成员变量表示同步状态
,通过内置的FIFO队列来完成资源获取线程的排队工作。 - 同步器的主要使用方式是
继承
,子类通过继承同步器并实现它的抽象方法来管理同步状态
2.2、队列同步器来访问或修改同步状态的方法
getState()
:获取当前同步状态。setState(int newState)
:设置当前同步状态。compareAndSetState(int expect,int update)
:使用CAS设置当前状态,该方法能够保证状态设置的原子性
。
2.3、同步器可重写的方法
2.4、同步器提供的模板方法
2.5、独占锁说明
独占锁就是在同一时刻只能有一个线程获取到锁
,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁
。
2.6、队列同步器的实现分析
2.6.1、同步队列
1. 简要说明
- 同步器
依赖内部的同步队列(一个FIFO双向队列)
来完成同步状态的管理。 当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列
,同时会阻塞当前线程。 当同步状态释放时,会把首节点中的线程唤醒
,使其再次尝试获取同步状态。 - 同步队列中的节点(Node)用来保存
获取同步状态失败的线程引用、等待状态以及前驱和后继节点
- 节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部
2. 同步器的基本结构
3. 节点加入到同步队列的过程
过程说明
同步器包含了 两个节点类型的引用,一个指向头节点
,而另一个指向尾节点
。当一个线程成功地获取了同步状态(或者锁), 其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中
,而这个加入队列的过程必须要 保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail
。
4. 首节点
- 首节点是
获取同步状态成功的节点
,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点
。 - 设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,
它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可
。
2.6.2、独占式同步状态获取与释放
1. 独占式同步状态获取与释放的总过程
- 首先
调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态
- 如果
同步状态获取失败,则构造同步节点
(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部 - 最后
调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态
。 - 如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
2. 头节点的说明
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态
。通过调用同步器的release(int arg)
方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。
3. 节点自旋获取同步状态的过程
过程说明
节点进入同步队列之后,
就进入了一个自旋的过程
,
每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)。
4. 只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?
- 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,
后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点
。 维护同步队列的FIFO原则
。
5. 总结
- 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;
- 移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。
- 在释放同步状态时,同步器调用
tryRelease(int arg)方法
释放同步状态,然后唤醒头节点的后继节点。
2.6.3、共享式同步状态获取与释放(读写锁)
1. 共享式锁与独占锁的区别
- 共享式获取与独占式获取最主要的区别在于
同一时刻能否有多个线程同时获取到同步状态
。 - 共享式和独占式主要区别在于
tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的
,因为释放同步状态的操作会同时来自多个线程。
图示说明
左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞。
2. 成功获取到同步状态并退出自旋的条件
如果当前节点的前驱为头节点
时,尝试获取同步状态,如果tryAcquireShared(int arg)方法返回值返回值大于等于0
,表示该次获取同步状态成功并从自旋过程中退出。
2.6.4、自定义同步器实现自定义独占锁案例
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
public class test {
public static void main(String[] args) {
MyLock lock = new MyLock();
new Thread(()->{
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + " locking....");
TimeUnit.SECONDS.sleep(1L);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + " unlocking....");
lock.unlock();
}
},"t1").start();
new Thread(()->{
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + " locking....");
}finally {
System.out.println(Thread.currentThread().getName() + " unlocking....");
lock.unlock();
}
},"t2").start();
}
}
// 独占锁
class MyLock implements Lock{
class MySync extends AbstractQueuedSynchronizer{
@Override
protected boolean tryAcquire(int arg) {
if(compareAndSetState(0,1)){
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
setExclusiveOwnerThread(null);
setState(0);
return true;
}
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
protected Condition newCondition(){
return new ConditionObject();
}
}
private MySync sync = new MySync();
@Override
public void lock() {
sync.acquire(1);
}
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override
public boolean tryLock() {
return sync.tryAcquire(1);
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1,unit.toNanos(time));
}
@Override
public void unlock() {
sync.release(1);
}
@Override
public Condition newCondition() {
return sync.newCondition();
}
}
同步器重写的方法
tryAcquire()
tryRelease()
isHeldExclusively()
newCondition()
3、重入锁
3.1、什么是重入锁?
重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁
。
3.2、重入锁例子
- 而synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。
- ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是
在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞
。
3.3、实现重入锁需要解决的问题
- 线程再次获取锁:锁需要去
识别获取锁的线程是否为当前占据锁的线程
,如果是,则再次成功获取。 - 锁的最终释放:线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。(
就是获得了几次就要释放几次
)
3.4、实现重入锁的解决办法
- 线程再次获取锁:
通过判断当前线程是否为获取锁的线程来决定获取操作是否成功
,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。 - 锁的最终释放:
成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放同步状态时减少同步状态值
。该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。
3.5、公平与非公平获取锁
1. 公平锁说明
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序
,也就是FIFO。公平的获取锁,也就是等待时间最长的线程最优先获取锁
,也可以说锁获取是顺序的。
2. 公平锁的好处
公平锁能够减少“饥饿”发生的概率
,等待越久的请求越是能够得到优先满足。
3. 非公平锁的说明
对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁
,而公平锁则不同。
4. 非公平锁的坏处
非公平性锁可能使线程“饥饿”
5. 非公平锁的好处
但极少的线程切换,保证了其更大的吞吐量
4、读写锁
4.1、什么是读写锁?
读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升
。
4.2、读写锁的特点
- 保证
写操作对读操作的可见性
以及并发性的提升 - 读写锁能够
简化读写交互场景的编程方式
。 读写锁的性能都会比排它锁好
,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。
4.3、读写锁的工作流程
当写操作开始时,所有晚于写操作的读操作均会进入等待状态
,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读,保证写操作对读操作的可见性。
4.4、读写锁的实现分析
4.4.1、 读写状态的设计
读写状态就是其同步器的同步状态。在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写
。
- S不等于0时,当写状态(S&0x0000FFFF)等于0时,
则读状态(S>>>16)大于0,即读锁已被获取
。
4.4.2、写锁的获取与释放
1. 写锁简介
写锁是一个支持重进入的排它锁
。
2. 写锁获取过程
- 如果当前线程已经获取了写锁,则
增加写状态
。 - 如果当前线程在获取写锁时,
读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态
。
3. 写锁不能获取的情况
如果存在读锁,则写锁不能被获取
原因
读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作
。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞
。
4. 写锁的释放过程
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放
,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。
4.4.3、读锁的获取与释放
1. 读锁简介
读锁是一个支持重进入的共享锁
2. 获取过程
它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态
。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。
3. 释放过程
读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)。
4.4.4、锁降级
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程
。
5、LockSupport工具
LockSupport定义了一组以park
开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法
来唤醒一个被阻塞的线程。
6、Condition接口
6.1、Condition接口简介
- 任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括
wait()、wait(long timeout)、notify()以及notifyAll()
方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。 - Condition接口也提供了
类似Object的监视器方法
,与Lock配合可以实现等待/通知模式
6.2、Object的监视器方法与Condition接口的对比
6.3、Condition接口的方法
- 当调用
await()
方法后,当前线程会释放锁并在此等待。 - 其他线程调用
Condition对象的signal()方法
,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。
6.4、Condition的实现分析
6.4.1、等待队列
1. 什么是等待队列?
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用
,该线程就是在Condition对象上等待的线程。
2. 等待队列的基本结构
一个Condition包含一个等待队列
- Condition拥有
首节点
(firstWaiter)和尾节点
(lastWaiter)。 - 当前线程调用
Condition.await()
方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。该节点更新过程是由锁来保证线程安全
的。 - Condition拥有首尾节点的引用
3. 并发包中的Lock的同步队列和等待队列
而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列
6.4.2、等待
- 调用
Condition的await()
方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态
。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。 - 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,
相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中
。
调用await()方法后相关线程的工作流程
- 调用await()方法的线程成功获取了锁的线程,也就是同步队列中的首节点,
该方法会将当前线程构造成节点并加入等待队列中
,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态
。 - 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。
- 同步队列的首节点并不会直接加入等待队列,而是
通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中
。
6.4.3、通知
1. 当前线程加入等待队列
调用该方法的前置条件是当前线程必须获取了锁
2. 节点从等待队列移动到同步队列的过程
- 通过调用同步器的
enq(Node node)
方法,等待队列中的头节点线程安全地移动到同步队列。 - 当节点移动到同步队列后,
当前线程再使用LockSupport唤醒该节点的线程
。
3. Condition的signalAll()
Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程
。