1. 可重入锁ReentrantLock
① 重进入
- 之前在学习同步器时,实现了独占锁Mutex。该锁存在一个缺陷:当一个线程调用Mutex的
lock()
方法获取锁之后,如果再次调用Mutex的lock()
方法获取该锁,该线程将会被阻塞。 - 即Mutex不支持重进入,在实现时没有考虑到占有锁的线程再次获取锁的场景。
- synchronized支持隐式的重进入,在递归调用
synchronized
方法时,不会发生阻塞。 - 重进入: 任意线程在获取之后仍能再次获取该锁而不会被锁所阻塞。
- 重进入需要解决的两个问题:
- 线程再次获取锁: 锁需要识别获取锁的线程是否为当前占据锁的线程,如果是,则再次获取成功。
- 锁的最终释放: 线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,用于表示当前线程重复获取锁的次数;释放锁时,计数自减,计数值为0表示锁已经成功释放。
- 重进入的计数方法,是通过同步状态实现的。
- 可重入锁
ReentrantLock
的两个特性:
- 支持重进入
- 支持线程获取锁时的公平性与非公平性选择
② ReentrantLock的非公平性访问
-
ReentrantLock
通过带fair参数的构造函数,创建支持公平或者非公平获取策略的锁实例。默认为false,即非公平性。public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
- 根据fair参数的值,为
ReentrantLock
创建公平或者非公平的的同步器。 - 公平的同步器,支持同步队列中的线程按照FIFO原则获取锁。
- 非公平的同步器,同步队列中的线程在锁可用时,都可以争抢获取锁的资格。可能使得先阻塞的线程最后才能获取到锁。
-
非公平获取锁,是由同步器的
tryAcquire()方法
调用nonfairTryAcquire()方法
实现的。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; }
- 先通过getState()方法获取同步状态值。
- 如果该值为0,说明没有线程占用该锁。直接通过CAS方法(
compareAndSetState()
)设置锁的同步状态,并设置当前线程为锁的持有者。返回true
,表示成功获取锁。 - 如果同步状态值不为0,判断当前线程是否为持有锁的线程(
current == getExclusiveOwnerThread()
)。如果是,说明占有锁的线程再次获取锁,直接增加同步状态值并返回true,表示获取锁成功, - 不满足上面的两种情况,返回
false
,表示获取锁失败。
-
占有锁的线程再次获取锁,只是将同步状态值增加。因此,该线程在释放锁时,需要将同步状态值减少。
-
锁的释放,不管是公平性还是非公平性,都通过
tryRelease()
方法实现锁的释放。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; }
- 锁被重复获取n次,线程实质上重复调用了n次
nonfairTryAcquire()
方法。释放时,需要调用n次tryRelease()
方法。 - 前n-1次,同步状态值均不为0,更新同步状态的值并返回
false
,表明锁未完全释放。 - 第n次,同步状态值变为0,设置占有锁的线程为null,并返回
true
,表明锁已经完全释放。
- 关于
nonfairTryAcquire(acquires)
方法和tryRelease(releases)
方法的参数:
- 调用时,
acquires = 1
,releases = 1
。表明线程重复获取锁时,同步状态值加1;线程释放锁时,同步状态的值减1。 - 当同步状态的值为0,表示锁被完全释放。
③ ReentrantLock的公平访问
-
公平访问,要求获取锁的线程必须是同步队列中头结点所包含的线程,这样能保证锁的获取按照FIFO规则。
-
ReentrantLock
的公平访问,调用公平同步器的tryAcquire()
方法实现。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()
方法相比,公平获取锁在CAS设置同步状态值时,要求线程所在的同步节点没有前驱节点。 - 即公平获取锁,条件判断时多了
!hasQueuedPredecessors()
方法。如果该方法返回true
,说明有线程比当前线程更早的发起了获取锁的请求,为了保证FIFO规则,当前线程无法获取锁。
④ 公平性锁和非公平性锁
- 可重入锁
ReentrantLock
,可以根据构造函数参数fair
的值,构造公平性的可重入锁或者非公平性的可重入锁。 - 两种锁的特点:
- 公平性锁,每次都是同步队列中的第一个节点的线程获取到锁;非公平性锁,一个线程可以连续获取锁。
- 公平性锁保证了锁的获取按照FIFO规则,而代价是进行大量的线程切换。非公平性锁可能造成线程饥饿,但极少进行线程切换,具有更大的吞吐量。
⑤ synchronized和ReentrantLock的比较与选择
synchronized
和ReentrantLock
的区别:
- 锁的实现:
synchronized
是 JVM 实现的,而ReentrantLock
是 JDK 实现的。 - 锁的性能: 新版本 Java 对
synchronized
进行了很多优化,例如自旋锁等,synchronized
与ReentrantLock
性能大致相同。 - 是否可中断:
ReentrantLock
可以响应中断,而synchronized
不行。 - 公平锁与非公平锁:
synchronized
中的锁是非公平的,ReentrantLock
支持公平锁和非公平锁两种,默认情况下是非公平的。 - 是否可以绑定多个condition对象: 一个
ReentrantLock
可以同时绑定多个 Condition 对象。比如ArrayBlockingQueue
中的ReentrantLock
就绑定了两种Condition对象,一个是notFull,一个是notEmpty。
- 使用时的选择:
- 除非需要使用
ReentrantLock
的高级功能,否则优先使用synchronized
。 - 因为
synchronized
是 JVM 提供的一种锁机制,JVM 原生地支持它;而ReentrantLock
并非所有的 JDK 版本都支持。 synchronized
锁的获取和释放由JVM隐式完成,不用担心没有释放锁而导致死锁问题。
2. 读写锁
① 读写锁概述
ReentrantLock
是可重入锁,也是公平/非公平锁,同时还是排他锁。- 之前提到的
Mutex
和ReentrantLock
都是排它锁,即同一时刻只能有一个线程可以获取到锁。 - 读写锁是一种特殊的锁,它包含一个读锁,一个写锁。同一时刻允许多个读线程访问;写线程访问时,所有的读线程和其他的写线程均被阻塞。
- 总结:
- 读写锁是一种特殊的锁,它包含一个读锁,一个写锁。读锁是支持重进入的共享锁,写锁是支持重进入的排它锁。
- 由于大多数场景都是多读少写,读写锁比排它锁具有更好并发性和吞吐量。
- JUC包中,
ReentrantReadWriteLock
是读写锁的实现。
ReentrantReadWriteLock
的特性:
- 公平性选择:
ReentrantReadWriteLock
支持公平和非公平获取锁的方式,默认情况为false,即非公平。非公平比公平的吞吐量更高。 - 重进入:
ReentrantReadWriteLock
支持重进入,读线程获取读锁后,能再次获取读锁;写线程获取写锁后,能再次获取写锁或读锁。 - 锁降级: 获取写锁、获取读锁再释放写锁,这时写锁能够降级为读锁。
② ReentrantReadWriteLock的接口与示例
-
ReentrantReadWriteLock
实现了ReadWriteLock接口
,ReadWriteLock接口只有两种方法:readLock()
方法和writeLock()
方法,分别用于返回读锁和写锁。 -
ReentrantReadWriteLock
的构造方法如下:public ReentrantReadWriteLock() {} public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); writerLock = new WriteLock(this); }
- 无参构造函数,默认创建非公平的读写锁。
- 参数fair用于指明构造公平还是非公平的读写锁,公平的读写锁对应公平同步器
FairSync
,非公平的读写锁对应非公平的同步器NonfairSync
。 - 读锁是
ReadLock
的实例,写锁是WriteLock
的实例,通过readLock()
和writeLock()
可以分别获取读锁或写锁。
ReentrantReadWriteLock
用于展示内部工作状态的方法:
- int getReadLockCount(): 返回读锁被获取的次数。读锁被获取
n次
,由于支持重进入,持有读锁的线程数小于等于n
。 - int getReadHoldCount(): 返回当前线程获取读锁的次数。
- boolean isWriteLocked(): 判断写锁是否被获取。
- int getWriteHoldCount(): 返回当前线程获取写锁的次数。如果当前线程占有写锁,返回实际获取次数;如果当前线程未占有写锁,返回0。
-
使用读写锁+HashMap实现缓存:
public class Cache { private HashMap<String, Integer> map; private ReentrantReadWriteLock lock; private Lock readLock; private Lock writeLock; public Cache() { map = new HashMap<>(); lock = new ReentrantReadWriteLock(); // 分别获取读锁、写锁 readLock = lock.readLock(); writeLock = lock.writeLock(); } public Integer get(String key) { readLock.lock(); try { return map.get(key); } finally { readLock.unlock(); } } public Integer put(String key, Integer value) { writeLock.lock(); try { return map.put(key, value); } finally { writeLock.unlock(); } } public void clear() { writeLock.lock(); try { map.clear(); } finally { writeLock.unlock(); } } }
get()
方法获取元素时,通过读锁保证多线程并发读缓存。put()
方法和clear()
方法,通过写锁保证同一时刻只有一个线程能写缓存。
③ 读写锁的读写状态设计
- 读写锁同样依赖自定义的同步器(公平或非公平)实现同步功能,读写步状态就是同步器的同步状态。
- 同步器的同步状态只有一个,而读写锁包含读锁和写锁。因此,需要将
32 bit
的同步状态进行分割(按位切割使用),高16 bit用于表示读状态,低16 bit表示写状态。
- 上图中,读状态和写状态都不为0,表明有线程获取了写锁,而且重进入了两次;该线程还获取了两次读锁。
- 获取写状态:
S & 0x0000FFFF
,高16 bit清零;获取读状态S >>> 16
,高16 bit无符号右移到低16 bit。 - 增加写状态:
S + 1
,增加读状态:S + (1 << 16)
,即S + 0x00010000
。
- 关于状态的判断:如果S不为0,写状态为0,则读状态大于0,读锁已被获取。
④ 写锁的获取与释放
- 写锁是支持重进入的排它锁
- 写锁获取的条件:
- 同步状态不为0: 若读锁已经被获取,或当前线程不是已经获取到读锁的线程,则当前线程不能获取写锁并进入阻塞状态;若当前线程为已经获取写锁的线程,直接调用
setState()
增加写状态。 - 同步状态为0: 通过
compareAndSetState()
方法设置写状态,并设置当前线程为持有写锁的线程。
-
调用
tryAcquire()
方法,可以实现写锁的获取:protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); int w = exclusiveCount(c); if (c != 0) { // 写状态为0,同步状态不为0,则读状态不为0,读锁已经被获取 if (w == 0 || current != getExclusiveOwnerThread()) return false; if (w + exclusiveCount(acquires) > MAX_COUNT) throw new Error("Maximum lock count exceeded"); // 持有写锁并再次获取写锁 setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) return false; setExclusiveOwnerThread(current); return true; }
-
与
ReentrantLock
中非公平获取锁的方法相比,当同步状态不为0时,除了判断当前线程是否为持有写锁的线程,还需要增加一个判断条件:读锁是否已经被获取。 -
读锁被获取后,写锁不能被获取的原因:读锁需要保证写锁的操作对读锁可见。即有读线程已经获取到读锁,如果写线程再获取到写锁,则读线程不会阻塞。因此,读线程无法感知到写线程的操作,不能保证读取到的数据是最新的。
-
写锁的释放:
- 获取了写锁n次,则需要释放写锁n次。
- 当写状态为0时,写锁被释放,需要设置持有写锁的线程为
null
。
⑤ 读锁的获取和释放
-
读锁是支持重进入的共享锁
-
调用
tryAcquireShared()
方法,可以获取读锁:// 只展示tryAcquireShared()方法的关键代码 Thread current = Thread.currentThread(); int c = getState(); if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return -1; int r = sharedCount(c); // 获取同步状态中的读状态
- 如果其他线程获取到了写锁,则当前线程无法获取到读锁并阻塞
- 如果当前线程获取到了写锁,或者写锁未被任何线程获取,则当前线程可以获取到读锁
- 当前线程获取到读锁,通过
compareAndSetState()
实现读状态的增加,每次增加1 << 16
。 - 由于同一时刻可能有多个线程获取到读锁,依靠CAS保证线程安全。
- 读锁的释放: 读状态用同步状态的高16 bit表示,释放读锁时,减少的同步状态值不是1,而是
1 << 16
。
⑥ 锁降级与锁升级
- 锁降级: 获取写锁,获取读锁,释放之前获取的写锁。这时,写锁降级为读锁。
- 锁升级: 获取读锁,获取写锁,释放之前获取的读锁。这时,读锁升级为写锁。
- 锁降级与锁升级都是为了保证数据的可见性。
ReentrantReadWriteLock
只支持锁降级,不支持锁升级。原因:
- 读写锁中的读锁是共享锁,多个线程获取到读锁,其中某个线程获取写锁,则其他线程不会发生阻塞。
- 这时,该线程写锁的操作对其他线程是不可见的,即其他线程无法获取到该线程对数据的更新。