目录
一.Lock接口
二.队列同步器
2.1队列同步器接口与示例
2.2 队列同步器的实现分析
2.2.1 同步队列
2.2.2 独占式同步状态获取与释放
2.2.3 共享式同步状态与释放
2.2.4 独占式超时获取同步状态
2.2.5 自定义同步组件——TwinsLock
三.重入锁
四.读写锁
4.1读写锁的接口与示例
4.2读写锁的实现分析
5.LockSupport工具
6.Condition接口
6.1Condition接口与示例
6.2Condition的实现分析
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(当然有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。在LOCK接口出现之前,Java程序使用synchronized实现锁功能。使用synchronized关键字将会隐式的获取锁,但是它将锁的获取和释放固化了,扩展性比较差。而Lock接口需要显式地获取与释放锁,它拥有锁获取与释放的可操作性、可中断的获取锁与超时获取锁等多种synchronized关键字所不具备的同步特性。
Lock接口提供的synchronized关键字不具备的主要特性:
Lock是一个接口,它定义了获取和释放的基本操作,LOCK的API如表所示:
Lock接口的实现是ReentrantLock,Lock接口的实现基本都是通过聚合了一个同步器的子类来完成线程的访问控制。
队列同步器AbstractQueuedSynchronizer是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中,免不了要对同步状态进行更改,此时需要使用同步器提供的3个方法:getState(),setState(int new State)和compareAndSetState(int expect,int update)来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何的同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件:ReentrantLock、ReentrantReadWriteLock和CountDownLatch等。
同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。这样可以理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现的细节;同步器是面向锁的实现着,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者所需关注的领域。
同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问和修改同步状态。
同步器可重写的方法如下表所示:
实现自定义的同步组件时,将会调用同步器提供的模板方法,这些模板方法与描述如下表所示:
同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只能获取锁的线程释放了锁,后继的线程才能够获取锁。自定义同步组件的示例代码如下:
//自定义锁(同步组件)
public class MutexComponent implements Lock {
//只需要将操作代理到Sync上即可
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();
}
public boolean isLocked() {
return sync.isHeldExclusively();
}
public boolean hasQueuedThreads() {
return sync.hasQueuedThreads();
}
//静态内部类,自定义同步器
private static class Sync extends AbstractQueuedSynchronizer {
//当状态为0时,获取锁
@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;
}
//是否处于占有状态
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
Condition newCondition() {
return new ConditionObject();
}
}
}
用户使用MutexComponent时并不会直接和内部同步器的实现打交道,而是调用MutexComponent提供的方法,以获取锁的lock为例,只需要在方法实现中调用同步器的模板方法acquire(int args)即可,当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样就大大降低了实现一个自定义同步组件的门槛。
实现分析主要包括:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构与模板方法。
同步器依赖于内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成一个节点并将其加入同步队列,同时阻塞当前线程。当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的节点用来保存获取同步状态失败的线程的引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述如下表所示:
节点是构成同步队列的基础,同步器拥有首节点和尾节点,没有成功获取同步状态的线程将会成为节点加入该队列的尾部,同步队列的基本结构如图所示:
加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update)。同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置成首节点。设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证。
节点进入同步队列之后,就进入了一个自旋的过程,每个节点或者说每个线程都在自省的观察,当条件满足,获取到了同步状态就可以从这个自旋过程中退出了,否则依旧留在这个自旋过程中。虽然所有的节点都在尝试获取同步状态,但是只有前驱节点是头节点才能够获取到同步状态。主要原因如下:
独占式同步状态获取流程,也就是acquire(int arg)方法调用流程如下:
总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程会被加入到队列中并在队列中进行自旋;移出队列或停止自旋的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。
共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。
重入锁ReentrantLock就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁,除此之外该锁还支持获取锁时的公平和非公平性选择。在前面的示例程序MutexComponent类中,当一个线程调用lock()方法获取锁之后,如果再次调用lock()方法,则该线程将会被自己所阻塞,原因是MutexComponent在实现tryAcquire(int acquires)方法时没有考虑占有锁的线程再次获取锁的情况,而再调用tryAcquire(int acquires)方法时返回了false,导致该线程被阻塞。简单的说MutexComponent是一个不支持重入的锁。而synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍然能够多次的获取该锁,而不像MutexComponent由于获取了锁,在下一次获取锁时候出现阻塞自己的情况。
ReentrantLock虽然没有像synchronized关键字一样支持隐式的重进入,但是在调用lock方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
锁获取的公平性问题:如果在绝对的时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。事实上,公平的锁机制往往没有非公平锁的效率高,吞吐量大,但是公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
重入锁是指任意线程在获取到锁之后能够再次获取该锁而不会被锁锁阻塞,该特性的实现需要解决下面的两个问题:
线程再次获取锁
锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
锁的最终释放
线程重复n次获取了锁,随后在第n次释放锁之后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获得次数,而锁被释放时,计数自减,当计数等于0时表示锁成功释放。
排他锁在同一时刻只会允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。在读多于写的情况下,读写锁能够提供比排他锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock,它提供的特性如下所示:
ReadWriteLock仅定义了获取读锁和写锁的两个方法,即readLock()方法和writeLock()方法,而其实现ReentrantReadWriteLock除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,这些方法以及描述如下表所示:
接下来,通过一个缓存示例说明读写锁的使用方式:
public class Cache {
static Map map = new HashMap();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
/**
* 获取一个key对应的value
*
* @param key
* @return
*/
public static final Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
/**
* 设置key对应的value,并返回旧的value
*
* @param key
* @param value
* @return
*/
public static final Object put(String key, Object value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
/**
* 清空所有的内容
*/
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
}
Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。
当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应的工作。LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具。这些方法以及描述如表所示: