JAVA中的锁
CAS算法
我们知道在使用锁的时候对性能会有影响,CAS(Compare And Swap 比较并交换)是一种有名的无锁算法,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
实现思想是这样的,CAS(V, A, B),V为需要读写内存地址的值、A为预期原值,B为新值。如果内存地址的值与预期原值相匹配,那么通过原子方式将该位置值更新为新值。否则,说明已经被其他线程更新,处理器不做任何操作。JVM的CAS操作是通过处理器提供的指令实现的。
ABA问题
CAS操作有个经典的ABA问题。假如线程A获取变量X的值(为A),线程B也获取变量X的值(为A),然后线程B使用CAS修改了变量X的值为B,然后又使用CAS修改变量X的值为A,这时候线程A进行CAS操作发现X变量值为A,然后执行成功。但其实这时候的A已经不是A线程获取到的那个A了,这就是ABA问题。解决的方法可以再增加一个标记来标识对象是否有过变更。
锁的种类
乐观锁和悲观锁
乐观锁和悲观锁是在数据库中引入的名词,是一种思想,JAVA并发包中也引入了类似的思想。
悲观锁是指对数据被外界修改持悲观态度,认为每次拿数据时都会被别人修改,所以在每次对数据处理前都要先进行加锁,使数据处于锁定状态。悲观锁在Java中的使用,就是利用synchronized、Lock的实现等锁来实现。
乐观锁认为数据一般不会造成冲突,所以访问数据时不会上锁,在数据更新提交时才会判断数据是否冲突,一般会使用“数据版本机制”或“CAS操作”来实现。
公平锁和非公平锁
根据多个线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。
公平锁表示线程获取锁的顺序是按照线程请求锁的时间来决定的。
非公平锁则相反,先申请的有可能最后才获取到锁。
在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
独占锁和共享锁
根据锁只能被单个线程持有还是能被多个线程持有,可以分为独占锁和共享锁。
独占锁指任何时候只有一个线程能够获取锁,独占锁就是一种悲观锁。
共享锁可以同时由多个线程持有,共享锁是一种乐观锁。
可重入锁
当一个线程获取一个被其他线程持有的独占锁时,该线程会被阻塞。那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞呢?如果不被阻塞,该锁就是可重入的,也就是说,只要该线程获取了锁,那么可以无限次进入被该锁锁住的代码。
我们看个例子理解一下:
public class LockDemo {
public synchronized void setA(){
System.out.println("hello");
setB();
}
public synchronized void setB(){
System.out.println("world");
}
}
如上代码中,调用setA方法会先获取锁,然后打印输出,调用setB,如果synchronized锁不是可重入的,该线程就会一直阻塞。
实际上,synchronized内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标识,用来标识当前锁被哪个线程占用,然后关联一个计数器,一开始计数器为0,说明该锁未被任何线程占用,当一个线程获取到锁时,计数器的值会变为1,这时其他线程来获取锁会发现锁的持有者不是自己而挂起。但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己时,就会把计数器值+1,释放锁后-1。当计数器值为0时,锁里面的线程标识会被重置为null,这时候被阻塞的线程会被唤醒重新竞争该锁。
自旋锁
自旋锁是指当前线程在获取锁时,如果发现锁已经被其他线程占有,则不会立马阻塞自己,而是会多次尝试获取(默认次数是10),很有可能在后面几次尝试中其他线程已经释放了锁,如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞。这样做能够减少线程上下文切换的开销,坏处是可能会浪费CPU的时间。
偏向锁/轻量级锁/重量级锁
这三种锁指的都是状态,都是针对synchronized的。
JDK1.6之前,synchronized被称为重量级锁,性能很差。为了换取性能,JDK1.6之后,JVM在内置锁上做了非常多的优化,引入了"偏向锁"和"轻量级锁"。JDK1.6之后,锁一共有四个状态,级别从低到高是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。锁可以升级但是不能降级。
Lock接口
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源。在Lock接口出现之前,JAVA是靠synchronized关键字实现锁功能的,JDK5之后,并发包中新增了Lock接口及相关实现类用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式的获取和释放锁,它拥有了锁获取和释放的人为可操作性,可中断的获取锁和超时获取锁等synchronized关键字不具备的功能。
使用synchronized关键字会隐式地获取和释放锁,这种方法简化了同步地管理,但是扩展性没有显式的锁好。例如一个场景,先获取锁A,再获取锁B,锁B获得后,释放锁A并获取锁C…,在这种情况下,synchronized实现就非常麻烦了,而使用Lock接口则可以很好的解决。
Lock接口提供的synchronized关键字不具备的特性如下:
能被中断地获取锁是指,以前我们在使用synchronized关键字时,如果一个线程获取到了锁,由于sleep方法或者其他原因使当前线程阻塞了,但是又没有释放锁,那么其他的线程便只能一直等待,如果其他线程想要去做其他事情,调用Thread.interrupt中断也无济于事,而Lock则可以响应中断,释放锁。
Lock是一个接口,它定义了锁获取和释放的基本操作,API如下:
Lock接口最常用的实现就是ReentrantLock类,Lock接口的实现基本都是通过聚合了一个同步器的子类来完成线程访问控制的,接下来我们来看下什么是同步器。
队列同步器AQS
AbstractQueuedSynchronizer抽象队列同步器,是用来构建锁或者其他同步组件的基础组件,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。大部分并发包中的锁的底层就是使用AQS实现的。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行修改,同步器为我们提供了三个方法进行操作,它们能保证状态的改变是安全的。
- int getState() //获取同步状态
- void setState(int newState) //设置当前同步状态
- boolean compareAndSetState(int expect, int update) //使用CAS设置当前状态,该方法能够保证状态设置的原子性
同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供锁或者其他同步组件来使用。同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。
可以这样理解两者的关系:锁是面向使用者的,定义了锁与使用者交互的接口,隐藏了实现细节。同步器面向的是锁的实现者,简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。
同步器的设计
同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义的锁的实现类中,并调用同步器提供的模板方法,而这些模板方法内部就会调用我们重写的方法。
同步器可重写的方法与描述见下图:
实现自定义同步组件时,将会调用同步器提供的模板方法,这些模板方法和描述见下图:
同步器提供的模板方法基本上分为三类:独占式获取与释放同步状态、共享式获取与释放同步状态以及查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。
只有掌握了同步器的工作原理才能更加深入的理解并发包中的其他并发组件。
接下来我们来手动实现一个独占锁来学习一下同步器的使用
public class Mutex implements Lock {
//继承同步器,重写方法
private static class Sync extends AbstractQueuedSynchronizer{
//判断是否处于占用状态
protected boolean isHeldExclusively(){
return getState()==1;
}
//当状态为0时获取锁,设置状态为1
@Override
protected boolean tryAcquire(int arg) {
if(compareAndSetState(0,1)){
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
//释放锁,将状态设置为0
@Override
protected boolean tryRelease(int arg) {
if(getState()==0){
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
//返回一个Condition,每个condition包含了一个condition队列
Condition newCondition(){
return new ConditionObject();
}
}
private final Sync sync=new Sync();
//调用模板方法实现锁的功能
@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();
}
}
在上面的例子中,Mutex是我们实现的独占锁。它的内部定义了一个静态内部类,该内部类继承了AQS并重写了独占式获取和释放同步状态。然后在实现锁的功能时只需要调用同步器提供的模板方法即可,模板方法内部会调用我们重写的方法。
队列同步器的实现分析
同步队列
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,入队的线程将会通过自旋的方式获取同步状态,若在有限次的尝试后,仍未获取成功,会阻塞当前线程,同步队列中头节点是获取到同步状态的节点,当头节点同步状态释放时,会把头节点后继节点的线程唤醒,使其再次尝试获取同步状态。后继节点线程恢复运行并获取同步状态后,会将旧的头结点从队列中移除,并将自己设为头结点。
同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,Node类的属性和描述如下所示:
节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该同步队列的尾部,同步队列的基本结构如下:
同步器包含了两个节点类型的引用,一个指向头节点,一个指向尾节点。当一个线程成功获取到了同步状态(获取锁),其他线程将无法获取到同步状态,转而被构造成节点并加入到同步队列的尾部,这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程认为的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
同步器将节点加入到同步队列的过程如下图:
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,过程如下图
设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功地获取到同步状态,因此设置头节点的方法不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。
独占式同步状态获取与释放
通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移除,该方法的源码如下:
public final void acquire(int arg) {
//该方法将会调用子类复写的 tryAcquire 方法获取同步状态
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这段代码的逻辑为:首先调用子类重写的tryAcquire方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter方法将该节点加入到同步队列的尾部,最后调用acquireQueued方法,使该节点自旋获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
首先看一下addWaiter方法,该方法用来向尾部添加节点
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// 尝试以快速方式将节点添加到队列尾部
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//快速插入节点失败,调用 enq 方法,不停的尝试插入节点
enq(node);
return node;
}
//通过 CAS + 自旋的方式插入节点到队尾
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
// 设置头结点,初始情况下,头结点是一个空节点
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
enq方法中:如果当前同步队列为null时,则head、tail其实是都为null的,那么在第一次循环中,它会自己构造一个节点我们称之为哨兵节点,当尾节点为空时,则通过CAS将构造的哨兵节点添加到队列中充当头节点,然后再将tail也指向头节点。
接下来进行下一次循环,发现tail不为null,然后通过CAS算法将我们的节点设置到尾部,这时就完成了尾节点的插入。
上面代码中通过compareAndSetTail方法来确保节点能够被线程安全添加。在enq方法中,同步器通过死循环来保证节点的正确添加,在循环中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回。否则,当前线程不断地尝试设置,可以看出,enq将并发添加节点的请求通过CAS变得串行化了。
节点进入同步队列之后,就进入了一个自旋的过程尝试获取同步状态,每个节点(或者说每个线程)都在自旋,当获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程),如下面的代码所示
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
// 循环获取同步状态
for (;;) {
final Node p = node.predecessor();
/*
* 前驱节点如果是头结点,表明前驱节点已经获取了同步状态。前驱节点释放同步状态后,
* 在不出异常的情况下, tryAcquire(arg) 应返回 true。此时节点就成功获取了同
* 步状态,并将自己设为头节点,原头节点出队。
*/
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
/*
* 如果获取同步状态失败,则根据条件判断是否应该阻塞自己
* /
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
/*
* 如果在获取同步状态中出现异常,failed = true,cancelAcquire 方法会被执行。
* 取消获取同步状态
*/
if (failed)
cancelAcquire(node);
}
}
在acquireQueued方法中,当前线程在死循环中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个
- 第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否为头节点。
- 第二,维护队列的FIFO原则。
该方法中,节点自旋获取同步状态的行为如下图所示:
在上图中,由于非首节点线程前驱节点出队或者被中断而从等待返回,随后检查自己的前驱节点是否为头节点,如果是则尝试获取同步状态。可以看到节点之间在循环检查的过程中基本不相互通信,而是简单地判断自己的前驱是否是头节点,这样就使得节点的释放规则符合FIFO,并且也便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程由于中断而被唤醒)。
独占式同步状态获取流程,也就是acquire(int arg)方法调用流程,如下图所示:
当同步状态获取成功后,当前线程从acquire方法返回,对于锁这种并发组件而言,代表当前线程获取到了锁。
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点。代码如下:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
该方法执行时,会唤醒头节点的后继节点线程,unparkSuccessor方法使用LockSupport工具类(后面会学到)来唤醒处于等待状态的线程。
独占式同步状态获取和释放过程总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用release方法释放同步状态,然后唤醒头节点的后继节点。
共享式同步状态获取与释放
共享式获取与独占式获取最重要的区别在于同一时刻能否有多个线程同时获取到同步状态。
通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态,代码如下:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
// 通过自旋的方式获取同步状态
for (;;) {
//获取前驱节点,判断是否是头节点
final Node p = node.predecessor();
if (p == head) {
// 尝试获取共享同步状态,前驱是头结点,其类型可能是 EXCLUSIVE,也可能是 SHARED.
//如果是 EXCLUSIVE,线程无法获取共享同步状态。
//如果是 SHARED,线程则可获取共享同步状态。
int r = tryAcquireShared(arg);
if (r >= 0) {
// 设置头结点,如果后继节点是共享类型,唤醒后继节点
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
/**
* 这个方法做了两件事情:
* 1. 设置自身为头结点
* 2. 根据条件判断是否要唤醒后继节点
*/
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
// 设置头结点
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
/*
* 节点 s 如果是共享类型节点,则应该唤醒该节点
*/
if (s == null || s.isShared())
doReleaseShared();
}
}
在acquireShared方法中,同步器调用tryAcquireShared方法尝试获取同步状态,tryAcquireShared方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared方法返回值大于等于0。可以看到在doAcquireShared方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功并从自旋过程中退出。
与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared方法可以释放同步状态,代码如下:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点,对于能够支持多个线程同时访问的并发组件,它和独占式主要区别在于tryReleaseShared方法必须确保同步状态线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。
独占式超时获取同步状态
通过调用同步器的doAcquireNanos(int arg, long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则返回false。
我们知道当一个线程获取不到锁而阻塞在synchronized处时,对该线程执行中断操作,此时虽然该线程的中断标志位被改,但该线程依然会阻塞在synchronized处,等待着获取锁。JAVA1.5后,同步器中提供了acquireInterruptibly(int arg)方法,该方法在等待获取同步状态时,如果当前线程被中断,将会立刻返回并抛出异常。
而超时获取同步状态则可以视为响应中断获取同步状态的增强版,doAcquireNanos方法支持在响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知,nanosTimeout=now-lastTime,其中now为当前唤醒时间,lastTime为上次唤醒时间,如果nanosTimeout大于0则说明时间未到,需要继续睡眠。否则则表示已经超时。
该方法代码如下:
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (nanosTimeout <= 0L)
return false;
final long deadline = System.nanoTime() + nanosTimeout;
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return true;
}
nanosTimeout = deadline - System.nanoTime();
if (nanosTimeout <= 0L)
return false;
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if (Thread.interrupted())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
该方法在自旋过程中,当节点的前驱节点为头节点时尝试获取同步状态,如果获取成功则从该方法返回,这个过程和独占式同步获取的过程类似,但是在同步状态获取失败的处理上有所不同。如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout<=0表示已经超时),如果没有超时,重新计算超时间隔nanosTimeout,然后使当前线程等待nanosTimeout纳秒。
如果nanosTimeout小于等于spinForTimeoutThreshold(1000纳秒)时,将不会使该线程进行超时等待,而是进入快速的自旋过程。原因在于:非常短的超时等待无法做到十分准确,如果这时再进行超时等待,会让nanosTimeout的超时表现得不准确。因此,在超时非常短的情况下,同步器会进入无条件的快速自旋
独占式超时获取同步状态的流程如下:
手动实现自己的锁
在前面我们对AQS的源码进行了分析,接下来我们来实现一个锁来加深对同步器的理解。
需求:该锁在同一时刻,允许最多两个线程同时访问,超过两个线程的访问将被阻塞。
首先,该锁支持两个线程同时访问,显然是共享式访问。因此,需要用到同步器提供的acquireShared等共享式相关方法。,所以我们就需要重写同步器的tryAcquireShared等方法。
public class TwinsLock implements Lock {
private static final class Sync extends AbstractQueuedSynchronizer{
Sync(){
setState(2);
}
@Override
protected int tryAcquireShared(int arg) {
for (;;){
int current=getState();
int newCount=current-arg;
if(newCount<0||compareAndSetState(current,newCount)){
return newCount;
}
}
}
@Override
protected boolean tryReleaseShared(int arg) {
for (;;){
int current=getState();
int newCount=current+arg;
if(compareAndSetState(current,newCount)){
return true;
}
}
}
}
private final Sync sync=new Sync();
@Override
public void lock() {
sync.acquireShared(1);
}
@Override
public void unlock() {
sync.releaseShared(1);
}
//其他方法省略
}
因为同时最多允许两个线程访问,所以我们可以设置初始status为2,每当一个线程进行获取则-1,线程释放后则+1,当status=0时则表示已经有两个线程获取到了锁,此时如果再有其他线程来进行获取,则被阻塞。
接下来我们来测试一下我们的锁。
public class TwinsLockTest {
public static void main(String[] args) {
final TwinsLock lock=new TwinsLock();
class Worker extends Thread
{
@Override
public void run() {
while (true){
lock.lock();
try {
System.out.println(Thread.currentThread().getName());
Thread.sleep(5000);
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
}
for (int i=0;i<10;i++){
Worker w=new Worker();
w.setDaemon(true);
w.start();
}
for (int i=0;i<10;i++){
try {
Thread.sleep(1000);
System.out.println();
}catch (Exception e){
e.printStackTrace();
}
}
}
}
首先我们循环创建了十个Worker线程,该线程在执行的过程中获取锁,获取锁之后打印线程名字,然后让线程睡眠5s,并不释放锁。然后我们可以看到线程名字是成对输出,也就是在同一时刻只有两个线程能够获取到锁。
重入锁ReentrantLock
ReentrantLock是可重入的独占锁,同时只能有一个线程可以获取该锁,并且该锁还支持是否成为公平锁\非公平锁。
首先通过构造方法可知,ReentrantLock默认是非公平锁,可以通过参数决定创建公平锁还是非公平锁。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
可重入的实现分析
可重入是指线程在获取到锁之后能够再次获取该锁而不会被阻塞,想要实现可重入需要解决以下问题:
- 锁需要识别当前获取锁的线程是否为当前占据锁的线程,如果是则再次成功获取。
- 锁如何最终释放。
我们以默认非公平性的实现为例。
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
首先,该方法获取当前的线程,如果state为0则说明当前锁为空闲的,则通过CAS设置锁状态,将锁的占有者设置为当前线程。如果state不为0则说明当前锁已经被占有,判断当前锁的占有者和当前线程是否一致,如果一样则将当前状态+1,返回true表示获取锁成功。这里我们知道,获取锁的线程再次获取锁,只是增加了状态值,所以在释放锁的时候一定要减少状态值,我们来看下源码。
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
当前线程调用释放锁方法,首先将当前状态-1,如果锁被同一线程获取了n次,那么(n-1)次调用释放锁方法返回的都是false,只有state=0,锁完全被释放了,才将free设置为true,将锁的拥有者设置为null返回,表示释放成功。
公平锁和非公平锁的区别
我们来看一下公平锁获取锁和我们上面非公平锁获取锁有什么区别。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
我们看到这个方法与非公平锁的nonfairTryAcquire相比,只是多了一个hasQueuedPredecessors方法。
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
如上代码是判断当前线程节点是否有前驱节点,如果有返回true,则表示有线程比当前线程更早的请求获取锁,因此需要等待前面的线程获取并释放锁以后才能继续获取锁。否则返回false,则表示当前AQS队列为空或者当前线程节点是AQS的第一个节点。如果h== t,则说明等待同步队列为空,直接返回false。如果h!=t并且s==null,则说明有一个元素将要作为AQS的第一个节点入队列(可以看前面入队列时的enq函数),那么返回true。如果h!=t并且s!=null和s.thread!=Thread.currentThread()则说明队列里面的第一个元素不是当前线程,返回true。
读写锁ReentrantReadWriteLock
前面介绍的ReentrantLock是独占锁,在同一时刻只允许一个线程进行访问,而读写锁在同一时刻允许多个线程访问,但是在写线程访问时,所有的读线程和和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。一般情况下,读写锁的性能都会比排他锁好,因为大多数场景读是多于写的。ReentrantReadWriteLock是JAVA并发包中读写锁的实现,提供了如下特性。
ReentrantReadWriteLock定义了两个方法来获取读锁和写锁,分别是readLock()和writeLock()。除此之外,还提供了一些其他方法。
读写锁的示例
我们知道HashMap是线程不安全的,我们使用读写锁来保证Cache是线程安全的。
public class Cache {
static Map<String,Object> map=new HashMap<>();
static ReentrantReadWriteLock readWriteLock=new ReentrantReadWriteLock();
static Lock readLock=readWriteLock.readLock();
static Lock writeLock=readWriteLock.writeLock();
public static final Object get(String key){
readLock.lock();
try {
return map.get(key);
}finally {
readLock.unlock();
}
}
public static final Object put(String key,Object value){
writeLock.lock();
try {
return map.put(key,value);
}finally {
writeLock.unlock();
}
}
}
读写锁的实现分析
读写锁内部同样依赖自定义同步器来实现同步功能。我们知道ReentrantLock中同步状态state表示锁被一个线程重复获取的次数,而读写锁中的自定义同步器中的同步状态上则需要维护多个读线程和一个写线程的状态。那么读写锁是如何实现的呢?
读写锁将同步状态分为了两个部分,state为整型为32位,高16位表示读,低16位则表示写。
上面图表示当前线程已经获取了写锁,并且重入了两次,同时也连续获得了两次读锁。
写锁的获取与释放
写锁是一个可重入的排他锁,如果当前线程获取到了写锁,则写状态+1,如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
首先该方法获取整体的同步状态,然后获取写状态,如果存在读锁或者当前线程不是已经获取写锁的线程,返回false。如果存在读锁,则写锁不能被获取的原因是:读写锁要确保写锁的操作对读锁可见。
写锁的释放基本类似,每次释放减少写状态,当写状态为0时表示写锁被释放,等待的读写线程就可以继续访问读写锁。
读锁的获取与释放
读锁是一个可重入的共享锁,能够被多个线程同时获取,在没有其他写线程访问(写状态为0)时,该锁总会被成功获取。获取读锁的实现从JAVA5到JAVA6变得复杂了很多,主要原因是一些新功能的加入,例如getReadHoldCount,作用是返回当前线程获取读锁的次数。我们知道读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护,这就使功能变得复杂。
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
return fullTryAcquireShared(current);
}
StampedLock
StampedLock是JDK8中新增的一个锁,该锁提供了三种模式的读写控制,当调用获取锁的系列函数时,会返回一个long类型的变量,称之为戳记(stamp),这个戳记代表了锁的状态。其中try系列获取锁的函数,当获取锁失败后会返回0的stamp值。当调用释放锁和转换锁的方法时需要传入获取锁时返回的stamp值。
StampedLock提供的三种读写模式的锁如下:
- 写锁writeLock:是一个独占锁,同一时刻只有一个线程可以获取该锁,当一个线程获取该锁后,其他请求读锁和写锁的线程必须等待,类似于ReentrantReadWriteLock的写锁(不同的是这里的写锁是不可重入锁),当目前没有线程拥有读锁或者写锁时才可以获取到该锁。请求该锁成功后会返回一个stamp变量用来表示该锁的版本,当释放该锁时需要调用unlockWrite方法并传递获取锁时的stamp值。并且它提供了非阻塞的tryWriteLock方法。
- 悲观读锁readLock:是一个共享锁,在没有线程获取独占写锁的情况下,多个线程可以同时获取该锁。如果已经有线程持有写锁,则其他线程请求获取该读锁会被阻塞,类似于ReentrantReadWriteLock的读锁(不同的是这里的读锁是不可重入锁),这里说的悲观是指在具体操作数据前其会悲观的认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据加锁,这是在读少写多的情况下的一种考虑。请求该锁成功后会返回一个stamp变量用来表示该锁的版本,当释放该锁时需要调用unlockRead方法并传递获取锁时的stamp值。并且它提供了非阻塞的tryWriteRead方法。
- 乐观读锁tryOptimisticRead:它相对于悲观读锁来说,在操作数据前并没有通过CAS设置锁的状态,仅仅通过位运算测试。如果当前没有线程持有写锁,则简单地返回一个非0的stamp版本信息。获取该stamp后在具体操作数据前还需要调用validate方法验证该stamp是否已经不可用,也就是看当调用tryOptimisticRead返回stamp后到当前时间期间是否有其他线程持有了写锁,如果是validate返回0,否则就可以使用该stamp版本的锁对数据进行操作。由于tryOptimisticRead并没有使用CAS设置锁状态,所以不需要显式地释放该锁。该锁地一个特点是适用于读多写少地情况,因为获取读锁只是使用位操作进行检验,不涉及CAS操作,所以效率会高很多,但是同时由于没有使用真正的锁,在保证数据一致性上需要复制一份要操作的变量到方法栈,并且在操作数据时可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,所以最多返回的不是最新的数据,但是一致性还是得到保障的。
StampedLock的读写锁都是不可重入锁。当多个线程同时尝试获取读锁和写锁时,谁先获取锁都是随机的。该锁并没有实现Lock接口,自己在内部维护了一个双向阻塞队列。
LockSupport工具
LockSupport是个工具类,主要作用是挂起和唤醒线程,LockSupport也成为构建同步组件的基础工具。
java6中,LockSupport增加了park(Object blocker)、parkNanos(Object blocker, long nanos)、parkUntil(Object blocker, long deadline)等三个方法,用于实现阻塞当前线程的功能,其中参数blocker是用来标识当前线程在等待的对象,该对象主要用于问题排查和系统监控。
public class LockSupportDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
System.out.println("子线程开始挂起");
LockSupport.park();
System.out.println("子线程结束挂起");
}
});
thread.start();
Thread.sleep(10000);
System.out.println("主线程开始结束子线程挂起");
LockSupport.unpark(thread);
}
}
Condition接口
我们知道任意一个JAVA对象,都拥有wait()、notify()等方法,这些方法和synchronized关键字配合,可以实现等待\通知模式,来完成线程间通信。而Condition接口也提供了类似的方法,与Lock配合可以实现等待\通知模式。不同在于,synchronized同时只能与一个共享变量的notify或wait方法实现同步,而Lock可以 创建多个条件变量。
举例来说:synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题,而我们一个Lock对象可以创建多个Condition实例,可以选择不同的线程注册到不同的Condition实例上,这样调用Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。
如下是Condition接口中的方法:
Condition定义了等待\通知两种类型的方法,和wait/notify一样,当前线程调用Condition的方法时,也需要提前获取到Condition对象关联的锁。Condition对象是由Lock对象创建出来的,我们来看下Condition如何使用。
public class ConditionDemo {
Lock lock=new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
lock.lock();
try {
System.out.println("释放锁,开始等待");
condition.await();
System.out.println("已唤醒");
}finally {
lock.unlock();
}
}
public void conditionSignal() throws InterruptedException {
lock.lock();
try {
condition.signal();
}finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ConditionDemo conditionDemo=new ConditionDemo();
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
try {
conditionDemo.conditionWait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
Thread.sleep(10000);
System.out.println("获取线程,准备唤醒");
conditionDemo.conditionSignal();
}
}
可以看到调用await方法后,当前线程会释放锁并在此等待,当主线程调用Condition对象的signal方法后,通知当前线程后,当前线程才从await方法返回。
多个Condition实例例子
public class MoreCondition {
private Lock lock = new ReentrantLock();
public Condition conditionA = lock.newCondition();
public Condition conditionB = lock.newCondition();
public void conditionAWait() throws InterruptedException {
lock.lock();
try {
conditionA.await();
}finally {
lock.unlock();
}
}
public void conditionBWait() throws InterruptedException {
lock.lock();
try {
conditionB.await();
}finally {
lock.unlock();
}
}
public void conditionASignal() throws InterruptedException {
lock.lock();
try {
conditionA.signalAll();
}finally {
lock.unlock();
}
}
public void conditionBSignal() throws InterruptedException {
lock.lock();
try {
conditionB.signalAll();
}finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
MoreCondition moreCondition=new MoreCondition();
Thread threadA=new Thread(new Runnable() {
@Override
public void run() {
try {
System.out.println("线程A执行");
moreCondition.conditionAWait();
System.out.println("线程A被唤醒");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread threadB=new Thread(new Runnable() {
@Override
public void run() {
try {
System.out.println("线程B执行");
moreCondition.conditionBWait();
System.out.println("线程B被唤醒");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
threadA.start();
threadB.start();
Thread.sleep(10000);
System.out.println("准备唤醒,调用conditionA的signalAll方法");
moreCondition.conditionASignal();
}
}
Condition的实现分析
ConditionObject是AQS的内部实现Condition接口的类。我们调用ReentrantLock的newCondition方法,其实就是创建了一个ConditionObject对象。每个ConditionObject对象中都包含着一个队列(等待队列),该队列是Condition实现等待\通知的关键。
等待队列
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并加入等待状态。事实上,节点的定义复用了同步器中节点的定义,也就是说,同步队列和等待队列中的节点类型都是同一个Node类。
一个Condition包含一个等待队列,Condition拥有首节点和尾节点。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。等待队列的基本结构如下:
如上图所示,新增节点只需要将原有的尾节点指向新增节点然后更新尾节点即可。上面这个过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。
等待
调用Condition的await方法,会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await方法返回时,当前线程一定获取了Condition相关联的锁。
如果从队列(同步队列和等待队列)的角度看await方法,当调用await方法时,相当于同步队列的首节点(获取了锁的节点)移动到等待队列中。
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。
当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出异常。
过程如下图所示:
通知
调用Condition的signal方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中。
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignal(first);
}
调用该条件的前置条件是当前线程必须获取了锁,可以看到调用了isHeldExclusively方法检查当前线程是否是获取锁的线程,接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程。过程如下所示:
Condition的signalAll方法,相当于对等待队列中的每个节点均执行一次signal方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。