Java中的多线程与锁(三)(队列同步器)

0. 队列同步器(java.util.concurrent.locks.AbstractQueuedSynchronizer)
  1. 队列同步器提供了更改锁状态的最基础的 ‘原子操作’(上一篇文章 Java中的多线程与锁(二) 中有提及) ,所以可以通过使用 队列同步器 来实现自定义的锁组件,这也是设计队列同步器的初衷( java.util.concurrent.locks.AbstractQueuedSynchronizer 被设计为抽象类,类的设计者明确要求通过继承该类来实现自定义的锁组件)。
  2. 独占锁与共享锁。首先说独占锁,可能会有多个线程同时试图获取独占锁,但是仅仅只允许一个线程能获取成功,在成功获取锁的线程释放锁之前,其它任何试图获取锁的操作都会失败(获取失败的线程可能在会锁上等待),所以获取独占锁的操作必须保证多线程安全,因为任何时刻仅有一个线程成功获取独占锁,且只有持有锁的线程才能释放锁,所以持有锁的线程执行释放独占锁的操作是安全的,因为在它释放它持有的独占锁之前绝不会有第二个线程同时执行释放锁的操作,所以释放独占锁的操作不需要被保护,独占式的获取及持有锁保证了锁释放操作的多线程安全再来看共享锁,共享 即 可以被多个线程同时拥有,但是可能会对持有锁的线程的数目进行约束,或者对试图获取锁的线程有额外条件,如果条件不满足则获取失败,所以获取共享锁的操作必须保证多线程安全,而且释放共享锁的操作也必须保证多线程安全,因为可能会有多个线程同时执行释放共享锁的操作。
  3. 独占式获取与共享式获取。如果一个锁同时支持独占式获取和共享式获取,则当该锁被独占式成功获取时,其它试图独占式获取或共享式获取该锁的操作都将失败,而当该锁被共享式成功获取时,其它试图共享式获取该锁的操作可以被允许成功,而试图独占式获取该锁的操作则会告以失败。独占式获取与其它任何方式的获取相对立,独占式获取独占整个锁资源,而多个共享式获取则能够共存。
  4. 如何处理获取锁失败的线程。当一个线程获取锁失败时,该获取操作可以立刻返回,线程继续执行(这取决于具体实现),当然,线程也可以在锁上等待(这取决于具体实现),此时,锁必须管理获取失败的线程,锁通常维护一个内部队列,用来管理等待锁的线程,这个队列可称为 ‘同步队列’,因为队列中的线程都是为了获取锁,从而实现对某些操作进行同步的目的。
  5. 同步队列的实现。可以使用一个先进先出(FIFO)的链表来管理等待锁的线程,其中每个线程都是链表中的一个节点,越排在前面的节点其等待时间也越长,先进先出(FIFO)的操作策略也相对公平。
1. 下面来分析 AbstractQueuedSynchronizer 抽象类的两个方法:‘void acquire(int arg)’ 和 ‘boolean release(int arg)’
  • void acquire(int arg) 方法,该方法以独占的方式获取锁,并且在操作返回之前忽略线程中断。acquire 方法没有返回值,其返回值类型为 void。
    1. 该方法源代码如下:
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

if 语句块中,先尝试执行获取操作 tryAcquire(int arg) (可以发现该方法仅仅是抛出异常,因为需要在子类中覆盖该方法,以实现我们自定义的需求/逻辑,暂时不管),如果获取操作失败则 addWaiter 方法将当前线程构造成一个独占模式的 Node 节点,并将该节点加入到同步队列的尾部,此时节点的状态 waitStatus 为 0(初始状态),重点在于 acquireQueued(Node node, int arg) 方法,该方法使得当前线程尝试以独占且不可中断的模式获取锁。
2. acquireQueued(Node node, int arg) 方法,该方法返回一个 boolean 值,如果返回值为 true,则当前线程执行自我中断,并从 acquire(int arg) 方法返回。acquireQueued 方法源代码如下:

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

首先看 for 循环,如果当前节点(即 当前线程所在的节点)的前任节点为头节点 head 的话,当前线程会再次尝试执行获取操作,如果成功获取,则将自己设置为头节点,并返回自己的中断状态。如果获取失败则进入 shouldParkAfterFailedAcquire 方法判断是否 park(通过调用 LockSupport.park() 方法使得调用线程让出 CPU,退出线程调度) 自己,进入 shouldParkAfterFailedAcquire 方法,我们发现,使得当前线程 park 自己的唯一条件(即 使得 shouldParkAfterFailedAcquire 返回 true)是当前节点的前任节点的状态 waitStatus 值为 Node.SIGNAL(该状态表示后继节点的线程需要被 unpark),注意,这里是一个节点(当前线程所在的节点)设置另一个节点(当前节点的前任节点)的状态 waitStatus 值。当前节点将它的前任节点的状态设置为 Node.SIGNAL 后会在紧接着的下一次 for 循环中 park 自己,设置前任节点的状态为 Node.SIGNAL 目的是告诉前任节点在它释放锁时通知自己,完成设置操作,当前线程才能安心的 park 自己,因为它知道自己会被通知(signal)的。
for 循环可能发生异常,导致执行失败(即 failed = true)(我认为应该是为了处理 tryAcquire 方法可能的异常,因为该方法需要子类覆盖以实现自定义的获取操作逻辑,其行为是未知的,考虑其异常处理是必要的),如果失败(即 failed = true),则执行取消获取 cancelAcquire,从同步队列中移除自己,注意,只有 try 语句执行异常的线程才会进而执行取消操作。

  • boolean release(int arg) 方法,以独占的方式释放锁,如果成功释放,则返回 true,否则返回 flase
    1. 该方法源代码如下
public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

当成功释放锁(即 tryRelease(int arg) 方法返回 true,可以发现该方法仅仅是抛出异常,因为需要在子类中覆盖该方法,以实现我们自定义的需求/逻辑,暂时不管)时,会执行唤醒同步队列中头节点的后继节点,重点在于 unparkSuccessor 方法,注意,在该方法中并没有更新头节点的操作,因为当某个线程获取到该独占锁时,会将自己设置为头节点。

  • 综上,可以发现,我们讨论的两个方法 ‘void acquire(int arg)’ 和 ‘boolean release(int arg)’ 中,主要说了线程怎样加入同步队列和 主动 park 自己以及 被动 unpark ,主要说的是同步队列中等待线程的管理。
  • 我要说明,到目前为止,我仍然没有完全领悟类的设计者设计这个框架类的根本思想,因为涉及到多线程,代码中的每一个操作都必须考虑并发操作的影响,当改变来自多个地方且以不同的时间点,情况就变得异常复杂,处处都是陷阱。
  • 很庆幸,当需要实现自定义锁组件时,我们实际要做的并没有很复杂,并发包的作者已经为我们完成了大部分复杂且易出错的工作(例如,等待线程的管理,同步状态管理)
2. 自定义的独占锁组件,实现 tryAcquire(int arg) 和 tryRelease(int arg) 方法
  • 可以查看源码中方法的注释,tryRelease 方法以独占的模式修改锁的状态来反映一个释放操作,tryAcquire 方法则尝试以独占的模式获取锁,这里并没有说要做什么,也没有要求怎么做,当然这可能是废话,这两个方法都是空的啊!类的设计者甚至没有说明什么是锁,是的,到底什么是锁呢?这是锁的语义,我们可以自定义,比如:某个对象 A ,它有一个 int 类型的状态,其初始状态值为 0,我们规定 A 的状态为 0 时表示 ‘空闲’ 状态,对象 A 处于‘空闲’状态时,任何线程都可以尝试 ‘持有‘ 该对象,但是任何时刻,只允许一个线程持有,且状态值为 1 时,表示该对象已被某个线程持有,持有该对象的线程可以重置该对象的状态值为 0,表示释放该对象,这时对象 A 又回到 ‘空闲’ 状态,这里的对象 A 就是锁(或者排它锁)。在 tryAcquire 和 tryRelease 方法中,我们要做的就是更改对象的状态来表达/表示‘持有锁’以及‘释放锁’操作。
    查看 AbstractQueuedSynchronizer 抽象类源码,我们可以根据需要在子类中覆盖下面的可选方法以实现自定义的逻辑,很明显,这些方法都没有被定义为抽象方法,所以并没有强制要求重写全部方法,不同的方法对应不同的锁获取模式,而且方法要配对使用。可选的覆盖方法如下:
	protected boolean tryAcquire(int arg) {	// 独占式获取
        throw new UnsupportedOperationException();
    }
    protected boolean tryRelease(int arg) { // 独占式释放
        throw new UnsupportedOperationException();
    }
    protected int tryAcquireShared(int arg) { // 共享式获取
        throw new UnsupportedOperationException();
    }
    protected boolean tryReleaseShared(int arg) { // 共享式释放
        throw new UnsupportedOperationException();
    }
    protected boolean isHeldExclusively() {
        throw new UnsupportedOperationException();
    }

该类也提供了修改同步器内部状态的方法,我们覆盖上述获取及释放锁的方法时,通过修改同步器内部状态值来定义锁的获取及释放语义。修改同步器内部状态值的方法如下:

	/* 获取同步器内部状态 int 值 */
	protected final int getState() {
        return state;
    }
	/* 设置同步器内部状态,该方法本身非线程安全, 
	 * 必须在确保线程安全的场景中才能使用该方法 */
    protected final void setState(int newState) {
        state = newState;
    }
	/* CAS 操作设置同步器内部状态值,线程安全 */
    protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
  • 综上,通过继承 AbstractQueuedSynchronizer 抽象类,实现一个简单的排它锁,代码如下:
/* 自定义独占式锁/排它锁 */
class MMLock{
	private final Sync sync;
	/* 记录当前持有锁的线程对象,用来防止未持有锁的线程试图释放锁  */
	private volatile Thread threadOwnedLock;
	/* 将获取锁与释放锁的操作委托给内部实现类 Sync */
	public MMLock() {
		sync = new Sync();
	}
	/* 获取锁 */
	public void lock() {
		sync.acquire(1);
	}
	/* 释放锁 */
	public void unlock() {
		if (threadOwnedLock == Thread.currentThread()) {			
			sync.release(1);
		}
	}
	
	private class Sync extends AbstractQueuedSynchronizer{
		private static final long serialVersionUID = 1L;

		@Override
		protected boolean tryAcquire(int arg) {
			boolean res = super.compareAndSetState(0, arg);
			if (res) { // 记录成功持有锁的线程对象,用来防止未持有锁的线程释放锁
				threadOwnedLock = Thread.currentThread();
			}
			return res;
		}

		@Override
		protected boolean tryRelease(int arg) {
			if (super.getState() == arg) {
				threadOwnedLock = null;
				setState(0); // 释放锁
			}
			return true;
		}
	}
}

可以看到这个自定义排它锁的实现非常简单(内部实现中,状态 0 表示锁 ’空闲‘,锁可以被获取,状态 1 表示 ‘已加锁’,而对于锁的使用者,只需执行获取及释放锁操作即可),仅仅选择性覆盖/实现了 tryAcquire 和 tryRelease 这两个方法,其中 tryAcquire 方法通过 CAS 操作(即 compareAndSwap )提供了原子操作保证,因为同一时间可能会有多个线程试图获取锁,而 tryRelease 方法却没有使用 CAS 操作,因为这是排它锁,且通过变量 threadOwnedLock 记录当前持有锁的线程对象,使得仅仅持有锁的线程才能执行释放锁操作,所以释放操作不会有多线程同时执行的情况,注意,变量 threadOwnedLock 使用 volatile 关键字修饰,确保内存可见性。
将第一篇文章Java中的多线程与锁(一)(关于同步)中累加器程序使用的利用 ‘等待/通知’机制 实现的锁 MyLock 更换为上述的自定义锁组件 MMLock 类,可见,程序可以得到正确结果。

  • 在下面程序中使用上述自定义锁组件 MMLock,来观察锁对象的内部状态,程序代码如下(主线程(main)一直持有锁不释放,其它 3 个线程(线程名称分别为:t-0,t-1,t-2)则进入同步队列等待,使用 Java VisualVM 工具查看程序运行情况):
/* 观察自定义锁组件 MMLock 对象的内部状态 */
import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class Temp_2 {
	public static void main(String[] args) throws InterruptedException {
		MMLock lock = new MMLock();
		int thread_num = 3; // 线程数量为 3
		Thread[] threads = new Thread[thread_num];
		for(int i = 0;i < thread_num;i++) {
			threads[i] = new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						Thread.sleep(1 * 1000);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
					// 尝试获取锁,因为主线程(main-thread)先获取锁,且一直不释放锁,导致该线程进入同步队列等待
					lock.lock(); 
				}
			}, "t-" + i); // 线程名称分别为:t-0,t-1,t-2,共 3 个线程
		}
		for(int i = 0;i < thread_num;i++) {
			threads[i].start();
		}
		lock.lock(); // 主线程获取锁
		System.out.println("main-thread acquired lock, then into sleep");
		Thread.sleep(0); // 主线程持有锁,且一直不释放。只管睡觉。
	}
}

/* 自定义独占式锁/排它锁 */
class MMLock{
	private final Sync sync;
	/* 记录当前持有锁的线程对象,用来防止未持有锁的线程试图释放锁  */
	private volatile Thread threadOwnedLock;
	/* 将获取锁与释放锁的操作委托给内部实现类 Sync */
	public MMLock() {
		sync = new Sync();
	}
	
	public void lock() {
		sync.acquire(1);
	}
	
	public void unlock() {
		if (threadOwnedLock == Thread.currentThread()) {			
			sync.release(1);
		}
	}
	
	private class Sync extends AbstractQueuedSynchronizer{
		private static final long serialVersionUID = 1L;

		@Override
		protected boolean tryAcquire(int arg) {
			boolean res = super.compareAndSetState(0, arg);
			if (res) { // 记录成功持有锁的线程对象,用来防止未持有锁的线程释放锁
				threadOwnedLock = Thread.currentThread();
			}
			return res;
		}

		@Override
		protected boolean tryRelease(int arg) {
			if (super.getState() == arg) {
				threadOwnedLock = null;
				setState(0); // 释放锁
			}
			return true;
		}
	}
}

先运行程序,然后使用 JDK 自带的工具 Java VisualVM 查看运行中的程序,选中运行中的程序,在 ‘Monitor’ 面板,点击 ‘Heap Dump’ 按钮执行堆快照操作,然后在生成的快照面板,在 ‘Classes’ 界面使用类名称 ‘mmlock’ 筛选类,可以看到结果显示有一个该类的实例,显示如下:
查看MMLock对象双击上图中的第二行,进入查看该类型实例对象的界面,如下图:
MMLock类型实例对象可以发现,当前持有锁的线程为主线程(main),而其它 3 个线程(t-0,t-1,t-2)都在同步队列中等待,通过查看同步队列中节点的状态 waitStatus 值,可以发现除了最后一个节点即 尾节点的状态值为 0(初始状态值),其它节点包括头节点的状态值都为 -1 即 Node.SIGNAL 的值,这是因为每个节点的线程在 park 自己之前,会设置它的前任节点的状态值为 Node.SIGNAL,以表示当前节点需要被通知(即 signal),而尾节点并没有后继节点,所以它还是初始值。可见,同步队列中,从头节点开始,每一个节点对紧跟它的后继节点负责,负责 signal 它的后继节点。通过查看 ‘Threads’ 面板可以看到,这个 3 个线程都在 park 方法上 waiting,如下图:
线程状态- 还没有说共享锁的自定义实现,在下一篇文章Java中的多线程与锁(四)(队列同步器)中继续讨论。


参考书籍
  • 《并发编程的艺术》(该书的介绍锁的部分,大多是讲解代码,缺乏对类设计者的设计思想的解读,而这一点却是最关键的。通过代码只能去猜测作者的意图,但如果能够知晓作者的意图/设计思想,就能很清晰代码的行为/目的。仍然感谢作者的努力。)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值