目录
本文参考于《Java并发编程的艺术》有兴趣的同学可以购买学习一下这本书,内容比较基础,还是很好理解的,推荐一下~或者持续关注我的并发编程专栏,将书中的内容轻松搬进我们的脑子里。一起学习一起进步吧💪
进入正题❕开始学习吧⬇️
Lock锁
众所周知,锁是用来控制多个线程访问共享资源的方式。一般来说,一个锁能够防止多个线程同时访问共享资源。
在java SE 5之后,并发包中新增了Lock接口以及相关实现类用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用的时候需要显式的获取和释放锁。
Lock的使用方式也很简单,以下就是lock锁的使用方式。
需要注意的是:
1.在finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放。
2.不要将获取锁的过程写在try块中,因为如果在获取自定义锁发生异常时,异常抛出的同时,会导致锁无故释放。
Lock l = new ReentrantLock();
l.lock();
try {
// access the resource protected by this lock
} finally {
l.unlock();
}
Lock是一个接口,它定义了锁获取和释放的基本操作,Lock的API如表所示:
方法名 | 说明 |
void lock(); | 获取锁,调用该方法当前线程将会获取锁,当锁获取后,从该方法返回。如果锁不可用,则当前线程将出于线程调度目的而被禁用,并处于休眠状态,直到获得锁为止。 |
void lockInterruptibly() throws InterruptedException; | 可中断地获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程。 |
boolean tryLock(); | 尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。 |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; | 超时的获取锁,当前线程在以下3种情况下会返回: 1.当前线程在超时时间内获得了锁 2.当前线程在超时时间内被中断 3.超时时间结束,返回false |
void unlock(); | 释放锁 |
Condition newCondition(); | 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程将释放锁。 |
队列同步器
队列同步器AbstractQueuedSynchronizer是用来构建锁或者是其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
同步器的主要方法是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时候就需要使用同步器提供的3个方法,getState()、setState(int newState)和compareAndSetState(int expect, int update)来进行操作,因为它们都是final修饰的,能够保证状态的改变是安全的。
同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。
二者关系:
锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;
同步器面向锁的使用者,简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。
锁和同步器很好的隔离了使用者和实现者所需关注的领域。
有关于队列同步器的知识,我会专门整理出来一篇详细说明,这里就只做简单介绍。
重入锁
重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平和非公平性选择。
ReentrantLock虽然不像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
顺便捎带一下锁的公平性问题。
如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的。反之,是不公平的。公平的获取锁,也就是等待时间最长的线程优先获取锁,也可以是锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。
但我们需要知道的是,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都以tps作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
实现重进入
重进入的概念上面👆我们已经介绍过了,就是重复获取锁且不会被锁阻塞,这里我们详细说明一下,该特性的实现需要解决的两个问题。
1.线程再次获取锁
锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
2.锁的最终释放
线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0的时候表示锁已经成功释放。
👌 难吗?不难!好理解吗?好理解!👌
不要怕,上源码!
/**
* Performs non-fair tryLock. tryAcquire is implemented in
* subclasses, but both need nonfair try for trylock method.
*/
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;
}
ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,以上述非公平的实现为例。代码解析均以注释形式展示。
成功获取锁的线程再次获取锁,其实只是增加了同步状态值,这也就要求我们在释放同步状态的时候要减少同步状态值。
protected final boolean tryRelease(int releases) {
//计数减少
int c = getState() - releases;
//判断当前线程是否是占有线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
//当同步状态为0时,返回true
if (c == 0) {
free = true;
//将占有线程设置为null
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
如果该锁被获取了n次,那么前(n-1)次的tryRelease(int releases)方法必须返回false,只有同步状态完全释放了,才能返回true。
公平与非公平获取锁的区别
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO(先进先出)
上一节我们已经说了非公平的实现,那现在就看一下公平锁。
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
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;
}
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()方法,我把hasQueuedPredecessors的源码一起放出来,可以看到这个方法是加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有现成比当前线程更早的请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。
举个🌰🌰🌰
package lockdemo;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
public class LockDemo {
private static Lock fairLock = new CustomReentrantLock(true);
private static Lock nonfairLock = new CustomReentrantLock(false);
public static void main(String[] args) {
testLock(fairLock);
// testLock(nonfairLock);
}
private static void testLock(Lock lock) {
for (int i = 1; i < 5; i++) {
new Task(lock).start();
}
}
static class Task extends Thread {
private Lock lock;
public Task(Lock lock){
this.lock = lock;
}
public void run(){
for (int i = 0; i < 2; i++) {
lock.lock();
System.out.println("current task:"+ Thread.currentThread().getName() +
", waiting task: " + ((CustomReentrantLock)lock).getQueuedThreads().stream().map(Thread::getName).collect(Collectors.toList()));
lock.unlock();
}
}
}
private static class CustomReentrantLock extends ReentrantLock {
public CustomReentrantLock(boolean fair){
super(fair);
}
public Collection<Thread> getQueuedThreads(){
List<Thread> threadList = new ArrayList<>(super.getQueuedThreads());
Collections.reverse(threadList);
return threadList;
}
}
}
运行testLock(fairLock);和testLock(nonfairLock);结果如下:
经过观察公平锁和非公平锁的输出情况,我们可以看到公平锁每次都从同步队列中的第一个节点获取到锁,而非公平性锁出现了一个线程连续获取锁的情况。
❓那么问题来了❓
非公平锁可能使线程“饥饿”,为什么它又被设定成默认的实现呢?
根据上述的输出结果我们可以明显看到,公平锁的线程切换需要10次,而非公平的线程切换只需要5次,说明非公平锁的开销更小。
有兴趣的同学也可以自己进行测试,多次获取锁,来具体区别一下。
总的来说:公平性锁保证了锁的获取按照FIFO原则,但是会进行大量的线程切换。非公平性锁虽然可能会造成“饥饿”,但极少的线程切换,保证了更大的吞吐量。
读写锁
前面提到的锁基本都是排他锁,这些锁在同一时刻允许一个线程进行访问,但是读写锁同一时刻可以允许多个线程访问,但是在写线程访问的时候,所有的读线程和其他写线程均被阻塞。读写锁通过分离读锁和写锁,使并发性比一般排他锁有了很大提升。
接口
ReadWriteLock定义了获取读锁readLock()和写锁writeLock()的两个方法,实现为ReentrantReadWriteLock。除了接口方法外,也有一些便于监控内部工作状态的方法。
//返回当前读锁被获取的次数。
//注意:该次数不等于获取读锁的线程数。
final int getReadLockCount() {
return sharedCount(getState());
}
//返回当前线程获取读锁的系数
final boolean isWriteLocked() {
return exclusiveCount(getState()) != 0;
}
//判断读锁是否被获取
final int getWriteHoldCount() {
return isHeldExclusively() ? exclusiveCount(getState()) : 0;
}
//返回当前写锁被获取的次数
final int getReadHoldCount() {
if (getReadLockCount() == 0)
return 0;
Thread current = Thread.currentThread();
if (firstReader == current)
return firstReaderHoldCount;
HoldCounter rh = cachedHoldCounter;
if (rh != null && rh.tid == getThreadId(current))
return rh.count;
int count = readHolds.get().count;
if (count == 0) readHolds.remove();
return count;
}
我们用一个简单的例子来理解一下怎么使用ReentrantReadWriteLock
package lockdemo;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantReadWriteLockDemo {
static Map<String, Object> map = new HashMap<>();
static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
static Lock r = reentrantReadWriteLock.readLock();
static Lock w = reentrantReadWriteLock.writeLock();
public static final Object get(String key){
r.lock();
try{
return map.get(key);
}finally {
r.unlock();
}
}
public static final Object put(String key, Object obj){
w.lock();;
try{
return map.put(key,obj);
}finally {
w.unlock();
}
}
public static void main(String[] args) {
w.lock();
try{
map.clear();
}finally {
w.unlock();
}
}
}
通过上述例子,用非线程安全的HashMap组合使用读写锁来保证线程安全。
详细过程为:get操作即读操作的时候,获取读锁,并发访问该方法不会被堵塞。put操作即写操作的时候需要获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均阻塞,当写锁释放以后,其他读写操作才可以继续。
读写锁提升了读操作的并发性,保证了写操作对所有读操作的可见性。
ReentrantReadWriteLock的实现分析
读写状态的设计
读写锁的同样依赖于同步器来实现其同步功能,读写状态其实就是同步器的同步状态。ReentrantLock的同步状态表示一个线程的重复获取次数。读写锁也需要在同步状态维护读锁和写锁的状态。
这里我们就要知道什么是“按位切割使用”。
一个整型变量维护多种状态的时候,就需要按位切割使用这个变量,读写锁就是如此。将变量分为两部分,高16位表示读,低16位表示写。划分方式如下图
在ReentrantReadWriteLock源码中有关于读取与写入计数提取常量如下
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
写锁的获取与释放
写锁是一个支持重进入的排他锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取了,则当前线程进入等待状态。我们可以通过源码清晰的看出获取锁的逻辑处理。
首先通过源码注释我们就能够知道:
- 如果读取计数非零或写入计数非零且所有者是另一个线程,失败。
- 如果计数饱和,则失败。
- 如果该线程是可重入的获取或队列策略允许的,则该线程有资格锁定。如果是,请更新状态并设置所有者。
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
//获取当前线程
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表示写锁已经被释放,从而等待的读写线程能够继续访问读写锁,这里就不过多说明了。
读锁的获取与释放
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问的时候,读锁总会被成功获取,增加读状态。获取读锁的实现从Java 5到Java 6变得些许复杂,增加了一些新的功能,我们来看一下获取读锁的代码。
protected final int tryAcquireShared(int unused) {
/*
* Walkthrough:
* 1. If write lock held by another thread, fail.
* 2. Otherwise, this thread is eligible for
* lock wrt state, so ask if it should block
* because of queue policy. If not, try
* to grant by CASing state and updating count.
* Note that step does not check for reentrant
* acquires, which is postponed to full version
* to avoid having to check hold count in
* the more typical non-reentrant case.
* 3. If step 2 fails either because thread
* apparently not eligible or CAS fails or count
* saturated, chain to version with full retry loop.
*/
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);
}
/**
* Full version of acquire for reads, that handles CAS misses
* and reentrant reads not dealt with in tryAcquireShared.
*/
final int fullTryAcquireShared(Thread current) {
/*
* This code is in part redundant with that in
* tryAcquireShared but is simpler overall by not
* complicating tryAcquireShared with interactions between
* retries and lazily reading hold counts.
*/
HoldCounter rh = null;
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0) {
if (getExclusiveOwnerThread() != current)
return -1;
// else we hold the exclusive lock; blocking here
// would cause deadlock.
} else if (readerShouldBlock()) {
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
} else {
if (rh == null) {
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) {
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
if (compareAndSetState(c, c + SHARED_UNIT)) {
if (sharedCount(c) == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}
很清晰,我们来分析一下。
根据tryAcquireShared(int unused)注释可知
- 如果另一个线程持有写锁,则失败。
- 此线程符合lock-wrt状态,因此询问它是否因队列策略而阻塞。如果不是,请尝试通过CAS状态和更新计数授予。
- 否则,此线程符合lock-wrt状态,因此询问它是否因队列策略而阻塞。如果不是,请尝试通过CASing状态和更新计数授予。请注意,步骤不检查可重入获取,它被推迟到完整版本,以避免在更典型的不可重入情况下必须检查保留计数。
如果步骤2失败,或者是因为线程明显不符合条件,或者是CAS失败,或者计数饱和,则使用完整的重试循环链接到版本。
fullTryAcquireShared(Thread current)是完整版本的acquire for reads,用于处理tryAcquireShared中未处理的CAS未命中和可重入读取。这段代码与tryAcquireShared中的代码部分冗余,但总体上更简单,因为它不会使重试和延迟读取保留计数之间的交互变得复杂。
锁降级
锁降级是指写锁降级为读锁。如果是(写锁-释放-获取读锁)这样的分段完成的过程不是锁降级。锁降级为持有写锁后再获取读锁,随后释放写锁的过程。
感觉这部分内容比较少,回头专门搞一个锁降级看看,后续出吧~
累了。。。本文就先到这吧,本来还想写一个LockSupport工具和Condition接口的内容,但是我现在不想写了,分成下篇吧,这一刻只想摆烂hhhhhhh
拜拜👋