重入锁ReentrantLock,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。
例子:之前AQS的一个自己实现的锁
package com.example.demo.thread;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* @author : pengweiwei
* @date : 2020/2/9 6:51 下午
*/
public class Mutex implements Lock {
// 静态内部类,自定义同步器
private static class Sync extends AbstractQueuedSynchronizer {
// 是否处于占用状态
protected boolean isHeldExclusively() {
return getState() == 1;
}
// 当状态为0的时候获取锁
public boolean tryAcquire(int acquires) {
//如果状态从 0 变成1 成功 则表示获取到了锁
if (compareAndSetState(0, 1)) {
//设置当前线程拥有独占访问权
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
// 释放锁,将状态设置为0
protected boolean tryRelease(int releases) {
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 的lock()方法获取锁之后,如果再次调用lock()方法,则该线程将会被自己所阻塞,原因是 Mutex在实现tryAcquire(int acquires)方法时没有考虑占有锁的线程再次获取锁的场景,而在调用tryAcquire(int acquires)方法时返回了false,导致该线程被阻塞。简单地说,Mutex 是一个不支持重进入的锁。而synchronized关键字隐式的支持重进入,比如一个 synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地 获得该锁,而不像Mutex由于获取了锁,而在下一次获取锁时出现阻塞自己的情况。
ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用 lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
如果在绝对时间上,先对锁进行获取的请求一定 先被满足,那么这个锁是公平的,反之,是不公平的。
公平的获取锁,也就是等待时间最 长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数, 能够控制锁是否是公平的。
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
* 创建一个给定公平策略的读写锁实例。
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
公平的锁机制往往没有非公平的效率高。
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题。
ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,以非公平性(默认 的)实现为例。源码如下。
/**
* 不公平的尝试获取锁方法。
*/
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); //拿到当前的获取锁的次数
if (c == 0) {
if (compareAndSetState(0, acquires)) { //修改为1,acquires传入1
setExclusiveOwnerThread(current); //设置当前独占线程为当前线程
return true;
}
}
//c != 0时 说明有对象获得锁
else if (current == getExclusiveOwnerThread()) { //如果获得锁的线程时当前线程
int nextc = c + acquires; //将获取次数加1
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc); //然后设置获取次数
return true;
}
return false;
}
该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程 来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返 回true,表示获取同步状态成功。
成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在 释放同步状态时减少同步状态值。源码如下:
protected final boolean tryRelease(int releases) {
int c = getState() - releases; //获取锁次数减去需要释放的次数(一般是1)
if (Thread.currentThread() != getExclusiveOwnerThread()) //保证是当前独占锁的线程来进行释放锁
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) { //如果c == 0 表示完全释放了锁
free = true;
setExclusiveOwnerThread(null); //当前独占锁的线程设置为null
}
setState(c); //设置获取锁次数
return free;
}
如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只 有同步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终 释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
公平获取锁的源码:
/**
* 公平版的获取同步状态。不要授予访问权限,除非递归调用或没有等待者或第一个。
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//与非公平的区别 多了一个hasQueuedPredecessors方法的判断
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;
}
}
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());
}
该方法与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了 hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程 获取并释放锁之后才能继续获取锁。
公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非 公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
之前提到锁(如Mutex和ReentrantLock)基本都是排他锁,这些锁在同一时刻只允许 一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问 时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁, 通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读 服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对 后续的读服务可见。
在没有读写锁支持的(Java 5之前)时候,如果需要完成上述工作就要使用Java的等 待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写 操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠 synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现 脏读。改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。 当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后, 所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。
一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。
Java并发包提供读写锁 的实现是ReentrantReadWriteLock。
package com.example.demo.Lock;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* @author : pengweiwei
* @date : 2020/2/11 5:52 下午
*/
public class ReentrantReadWriteLockDemo {
static Map<String, Object> map = new HashMap<String, Object>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
// 获取一个key对应的value
public static Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 设置key对应的value,并返回旧的value
public static Object put(String key, Object value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// 清空所有的内容
public static void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
}
在读操作get(String key)方法中,需要获取读 锁,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和 clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和 写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。
ReentrantReadWriteLock的实现,主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放以及锁降级。
读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个 写线程的状态,使得该状态的设计成为读写锁实现的关键。
如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁 将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如下图所示。
当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两 次读锁。读写锁是如何迅速确定读和写各自的状态呢?答案是通过位运算。假设当前同步 状态值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无 符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16), 也就是S+0x00010000。
根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0 时,则读状态(S>>>16)大于0,即读锁已被获取。
写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
源码:
protected final boolean tryAcquire(int acquires) {
/*
* 注释:
* 1. 如果读计数非零或写计数非零并且同步状态所有者不是当前线程,失败
* 2. 如果计数饱和,则失败。(只有当count已经非零时才会发生这种情况)
* 3. 否则,如果是可重入获取或队列策略允许,则此线程有资格获得锁。如果是,则更新状态并设置所有者。
*/
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;
}
该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存 在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就 无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当 前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态 为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。
源码:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively()) //如果当前线程不是独占线程
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访 问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加 读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写 锁已被其他线程获取,则进入等待状态。获取读锁的实现从Java 5到Java 6变得复杂许 多,主要原因是新增了一些功能,例如getReadHoldCount()方法,作用是返回当前线程获 取读锁的次数。读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数 只能选择保存在ThreadLocal中,由线程自身维护,这使获取读锁的实现变得复杂。
源码:
protected final int tryAcquireShared(int unused) {
/*
* Walkthrough:
* 1. 如果另一个线程持有写锁,则失败
* 2. 否则,这个线程有资格进入lock wrt状态,所以询问它是否应该因为队列策略而阻塞。如果不是,尝试授予
* 通过CAS状态和更新计数。注意,步骤不检查重入获取,它被推迟到完整版本,以避免在更典型的不可重入情况下
* 检查持有计数。
* 3. 如果第2步失败,要么是因为线程显然不符合条件,要么是CAS失败,要么是count饱和,
* 那么用完整的重试循环链接到版本
*/
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);
}
在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获 取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程 (线程安全,依靠CAS保证)增加读状态,成功获取读锁。
读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减 少的值是(1<<16)。
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获 取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如 果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写
锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即 遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程 T才能获取写锁进行数据更新。
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过 程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了 写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。