《一文就明白Java并发编程》
一文系列 之 并发编程。
行文3万余字,包含了多线程方方面面的知识点,相信对你会有所帮助!
黄老师
文章目录
1、进程&线程
虽然大家肯定知道进程、线程的概念,但在介绍整篇文章前还是需要再陈述下,保证行文信息量的连贯性。
一些基础知识:
- Linux操作系统分内核态和用户态。
- 内核态是操作系统自己管理和控制的,是“基石”。内核态也可以开发,利用linux内核模块化技术进行模块形式加载。
- 用户态是开放使用的,以进程为开放使用单位。进程是在用户态资源分配的单位(以进程为单位分配内存空间),进程内的线程是用户态执行单位(进程有默认线程,以线程去执行具体的代码,作为执行单位的特点是每个线程一个调用栈)。
- 其实,内核态是清楚并管理用户态的进程和线程的。用户态的每个进程在内核有对应的PCB作为管理对象。用户态的每个线程在内核有对应的TCB作为管理和调度对象。
- 在计算机中,CPU是稀缺资源,N多待调度的线程通过操作系统的Scheduler调度器去分配CPU时间片,让各个线程能“雨露均沾”。
- 用户态线程的调度功能有2种实现方式:
- 第一种:交由操作系统去管理调度,特点:用户态线程和内核线程是一一映射的(如图)。也是JDK 1.2之后版本的实现方式。
- 第二种:JDK1.2之前,由开发者(对于JDK来说,是JVM自己)在进程空间实现一套进程内线程的调度算法。这种实现方式对开发者来说复杂,又难以完全控制时间片,可能部分线程会饿死,因为是否分配CPU时间片的真正权力握在操作系统内核上.
2、JUC并发包
2.1、JUC包介绍
JUC是 java.util.concurrent的简称,是JDK包下原生的lib。是Java 5.0 提供的并发编程包,包含了并发编程中很常用的实用工具类。
下文以JDK8为例
其实,JUC包中的内容经过归类后并不繁多、复杂。
JUC提供的能力分类:
1、Atomic原子类
2、Lock相关 几类基础锁机制
3、并发安全的Collection类、Map类
4、多种队列工具类
5、线程池机制
6、多线程协同工具类
2.2、Atomic原子类
总所周知,Atomic原子类能保证在一个对象上多个操作步骤的原子性。这是Atomic机制在多线程并发环境中存在的意义。
2.2.1、Atomic原子类的细分
-
基本类型的原子性操作:AtomicBoolean、AtomicInteger、AtomicLong
- 这些类能保证在并发环境中对基础整型数字的get、set、incr、decr、add、minus等方面操作的原子性。
- JDK8开始有LongAdder类,功能与AtomicLong类似。在高并发,写多读少的场景性能比AtomicLong好。
-
数组类型的原子性操作:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
- 数组级别的原子操作。提供的方法方面,只是对数组上的指定单个元素做原子操作。不具备范围型批量操作方法。
-
引用类型的原子性操作:AtomicReference
- “AtomicReference” 是AtomicReference的Class声明,即AtomicReference是存放Java对象引用的,任何对象都可以。而AtomicReference能保证多线程环境下对这个对象引用变更的一致性。
- 代码案例见下文
-
带版本标识的引用类型的原子性操作:AtomicStampedReference、AtomicMarkableReference
-
AtomicReference无法避免ABA问题。
-
若业务场景对AtomicReference指向的对象的变化过程不关心,只关心当下时刻的值是多少,那么AtomicReference还是适合的。若业务场景关心对象值的变化过程,那么AtomicReference就不适合了。
-
AtomicStampedReference 在AtomicReference基础上,增加了一个
final int stamp
字段,大家可以理解为版本号+乐观锁的概念。每当对AtomicStampedReference的对象做更新时,需同时比对对象应用和stamp值,然后同时更新对象应用和stamp值 -
public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair<V> current = pair; return expectedReference == current.reference && expectedStamp == current.stamp && ((newReference == current.reference && newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); }
-
AtomicMarkableReference与AtomicStampReference的区别:相较于,AtomicStampReference新增的int stamp字段,AtomicMarkableReference增加的是boolean类型的字段。其他操作上都一样,但不能解决ABA问题。
-
-
反射方式原子操作:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
- 以反射方式,对一个对象内的整型数字字段进行原子操作
- 要求:
- 此字段必须是volatile的,保证变量变更线程间立即可见
- 此字段必须是变量,不能是final
- 只能是实例变量,不能是类变量,也就是说不能加static关键字
- 此字段只能是int、long基础类型(不能是包装类)
- 段的描述类型(修饰符public/protected/default/private)是与调用者与操作对象字段的关系一致。也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作
- 代码案例见下文
代码案例说明 - AtomicReference
以Person POJO类为背景,Person类里含有2个字段:name、age。
构造Person对象的初始值为 name=Tom, age = 18
。
计划
在 线程1 中将 name
修改为 Tom1
,age + 1
。
在 线程2 中将 name
修改为 Tom2
,age + 2
。
- 普通引用版本 《《《 会引发更新不一致性
// 普通引用
private static Person person; 《《《 全局Person对象
public static void main(String[] args) throws InterruptedException {
person = new Person("Tom", 18);
System.out.println("Person is " + person.toString());
Thread t1 = new Thread(new Task1());
Thread t2 = new Thread(new Task2());
t1.start(); 《《《 并发修改:name、age
t2.start(); 《《《 并发修改:name、age
t1.join();
t2.join();
System.out.println("Now Person is " + person.toString());
}
static class Task1 implements Runnable {
public void run() {
person.setAge(person.getAge() + 1); 《《《 并发场景下,对象多个字段的赋值非原子性
person.setName("Tom1"); 《《《
System.out.println("Thread1 Values "
+ person.toString());
}
}
static class Task2 implements Runnable {
public void run() {
person.setAge(person.getAge() + 2); 《《《 并发场景下,对象多个字段的赋值非原子性
person.setName("Tom2"); 《《《
System.out.println("Thread2 Values "
+ person.toString());
}
}
可能的输出:
Person is [name: Tom, age: 18]
Thread2 Values [name: Tom1, age: 21]
Thread1 Values [name: Tom1, age: 21]
Now Person is [name: Tom1, age: 21]
- 原子引用版本
// 普通引用
private static Person person;
// 原子性引用
private static AtomicReference<Person> aRperson;
public static void main(String[] args) throws InterruptedException {
person = new Person("Tom", 18);
aRperson = new AtomicReference<Person>(person); 《《《 对象的原子引用
System.out.println("Atomic Person is " + aRperson.get().toString());
Thread t1 = new Thread(new Task1());
Thread t2 = new Thread(new Task2());
t1.start(); 《《《 并发修改:name、age
t2.start(); 《《《 并发修改:name、age
t1.join();
t2.join();
System.out.println("Now Atomic Person is " + aRperson.get().toString());
}
static class Task1 implements Runnable {
public void run() {
aRperson.getAndSet(new Person("Tom1", aRperson.get().getAge() + 1)); 《《《 对象多字段操作原子性
System.out.println("Thread1 Atomic References "
+ aRperson.get().toString());
}
}
static class Task2 implements Runnable {
public void run() {
aRperson.getAndSet(new Person("Tom2", aRperson.get().getAge() + 2)); 《《《 对象多字段操作原子性
System.out.println("Thread2 Atomic References "
+ aRperson.get().toString());
}
}
输出之一:
Atomic Person is [name: Tom, age: 18]
Thread1 Atomic References [name: Tom1, age: 19]
Thread2 Atomic References [name: Tom2, age: 21]
Now Atomic Person is [name: Tom2, age: 21]
代码案例说明 - AtomicIntegerFieldUpdater
package automic;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
public class AtomicIntegerFieldUpdaterTest {
private static Class<Person> cls;
/**
* AtomicIntegerFieldUpdater class说明
* 基于反射的实用工具,可以对指定类的指定 volatile int 字段进行原子更新。此类用于原子数据结构,
* 该结构中同一节点的几个字段都独立受原子更新控制。
* 注意,此类中 compareAndSet 方法的保证弱于其他原子类中该方法的保证。
* 因为此类不能确保所有使用的字段都适合于原子访问目的,所以对于相同更新器上的 compareAndSet 和 set 的其他调用,
* 它仅可以保证原子性和可变语义。
* @param args
*/
public static void main(String[] args) {
// 新建AtomicLongFieldUpdater对象,传递参数是“class对象”和“long类型在类中对应的名称”
AtomicIntegerFieldUpdater<Person> personFieldUpdater = AtomicIntegerFieldUpdater.newUpdater(Person.class, "id");
Person person = new Person(12345);
personFieldUpdater.compareAndSet(person, 12345, 1000);
System.out.println("id=" + person.getId());
}
}
class Person {
volatile int id;
public Person(int id) {
this.id = id;
}
......
}
2.2.2、Atomic原子类的实现原理
Atomic的核心操作是CAS(Compare And Set)。该操作在操作系统层面对应C语言汇编的CMPXCHG指令,该指令通过三个操作数(变量V,预期旧值O,目标新值N),能原子的完成“变量V当前值与预期旧值E的等值判断,并完成新值N的赋值”。
注,常规的Atomic存在ABA的问题。
所有Atomic类内部都会调用Unsafe类完成CAS的操作
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
Unsafe内部就是C语言实现与操作系统的交互了,最终会涉及到CPU级的操作。
CMPXCHG指令介绍
Unsafe的CompareAndSwap方法最终在 hotspot 源码实现中都会调用统一的 cmpxchg 函数。
cmpxchg 函数源码:
源码地址:hotspot/src/share/vm/runtime/Atomic.cpp
jbyte Atomic::cmpxchg(jbyte exchange_value, volatile jbyte*dest, jbyte compare_value) {
assert (sizeof(jbyte) == 1,"assumption.");
uintptr_t dest_addr = (uintptr_t) dest;
uintptr_t offset = dest_addr % sizeof(jint);
volatile jint*dest_int = ( volatile jint*)(dest_addr - offset);
// 对象当前值
jint cur = *dest_int;
// 当前值cur的地址
jbyte * cur_as_bytes = (jbyte *) ( & cur);
// new_val地址
jint new_val = cur;
jbyte * new_val_as_bytes = (jbyte *) ( & new_val);
// new_val存exchange_value,后面修改则直接从new_val中取值
new_val_as_bytes[offset] = exchange_value;
// 比较当前值与期望值,如果相同则更新,不同则直接返回
while (cur_as_bytes[offset] == compare_value) {
// 调用汇编指令cmpxchg执行CAS操作,期望值为cur,更新值为new_val
jint res = cmpxchg(new_val, dest_int, cur);
if (res == cur) break;
cur = res;
new_val = cur;
new_val_as_bytes[offset] = exchange_value;
}
// 返回当前值
return cur_as_bytes[offset];
}
多CPU如何实现原子操作
在计算机硬件层面,CPU 处理器速度远远大于主内存,为了解决速度差异,在两者之间架设了CPU多级缓存,如 L1、L2、L3 级别的缓存,这些缓存离CPU越近就越快,将频繁操作的数据缓存到这里,加快访问速度。
现在都是多核 CPU 处理器,每个 CPU 处理器内维护各自关于内存数据的缓存,当多线程并发读写时,就会出现CPU缓存数据不一致的情况。
对于原子操作,CPU处理器提供2种方式:
- 总线锁定
当一个处理器要操作共享变量时,在 BUS 总线上发出一个 Lock 信号,其他处理就无法操作这个共享变量了。
缺点很明显,总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。
- 缓存锁定
后来的处理器都提供了缓存锁定机制,当某个处理器对缓存中的共享变量进行了操作,其他处理器会有个嗅探机制,将其他处理器的该共享变量的缓存失效,待其他线程读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现的。
现代的处理器基本都支持和使用的缓存锁定机制。
2.3、JUC包中的锁
Java中有很多种类的锁,在JUC包中也有比较多的锁类型。但不用怕难,因为归结到底层和操作系统级别,其核心原理和机制都是类似的,所以学Java的锁也只需要掌握锁背后的核心机制和扩展方向就行了,剩下的就交给举一反三吧。
通过上图,对JUC包中的锁有个宏观认识,相信对理解和掌握JUC包中的锁会有事半功倍的效果。
2.3.1、顶层抽象类:AbstractQueuedSynchronizer(AQS)
2.3.1.1、AbstractQueuedSynchronizer,简称AQS。【核心抽象类】
我们对于锁的认知,一般会有如下几个常规的认知:
1、多个线程尝试拿锁
2、拿不到锁的线程阻塞等待
3、用完锁的线程释放锁,等待线程得到锁
其实在JUC包中锁实现的本质原理也是这么回事。
有关于AQS:
1、AQS中的等待队列FIFO
- head:表示当前拿到锁的线程Node
- node:表示未拿到锁被阻塞的线程Node
2、AQS有“独占锁”和“共享锁”两种模式
- 独占模式(Node.EXCLUSIVE) 应用:ReentrantLock
- 共享模式(Node.SHARE) 应用:Semaphore
3、实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物
2.3.1.1.1、独占模式:
为帮助阅读,已精简剔除大量代码。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
...
/**重要变量*/
// 头结点,可理解为当前持有锁的线程
private transient volatile Node head;
// 阻塞的尾节点,每个新的节点进来,都插入到最后,也就形成了一个链表
private transient volatile Node tail;
// 代表当前锁的状态,0代表没有被占用,大于0 代表有线程持有当前锁, 大于 1 代表锁重入的次数
private volatile int state;
// 当前持有独占锁的线程。继承自AbstractOwnableSynchronizer
private transient Thread exclusiveOwnerThread;
/**重要接口*/
// 加锁接口
public final void acquire(int arg){
...}
boolean tryAcquire(int arg) {
...} // 尝试加锁
Node addWaiter(Node mode) {
...} // 拿不到锁就创建新等待节点,并添加到队列尾部
boolean acquireQueued(final Node node, int arg) {
...} // 使线程阻塞在等待队列中,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false
boolean parkAndCheckInterrupt() // 当前线程主动阻塞自己
//解锁接口
public final boolean release(int arg) {
...}
boolean tryRelease(int arg) {
...} // 尝试解锁,一般都会成功。若跨线程解锁会抛异常
void unparkSuccessor(Node node) // 唤醒等待队列里的下一个等待节点的线程
...
}
AbstractQueuedSynchronizer作为JUC包中锁的顶级抽象类,以模板方式和抽象方法方式,为JUC包中锁的实现定义了框架和提供了默认/基础实现。
AQS类锁能力基础认知:
1、如上代码注释所属。其实现原理借助于关键变量:head、tail、state、exclusiveOwnerThread,和关键方法:acquire和release,以及内部的子方法。
2、总体上实现:加锁、判断锁、阻塞等待队列、可重入锁、解锁等框架能力
AQS关键代码解读:
- acquireQueued(Node, int) 拿不到锁时,线程阻塞挂起
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;//标记是否成功拿到资源
try {
boolean interrupted = false;//标记等待过程中是否被中断过
//“自旋”!
for (;;) {
final Node p = node.predecessor();//拿到前驱
//如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
if (p == head && tryAcquire(arg)) {
setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
failed = false; // 成功获取资源
return interrupted;//返回等待过程中是否被中断过
}
//如果自己可以休息了,就通过park()进入waiting状态,直到被unpark()。如果不可中断的情况下被中断了,那么会从park()中醒过来,发现拿不到资源,从而继续进入park()等待。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
}
} finally {
if (failed) // 如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了),那么取消结点在队列中的等待。
cancelAcquire(node);
}
}
- unparkSuccessor(Node node) 解锁时唤醒下一个等待节点线程
private void unparkSuccessor(Node node) {
//这里,node一般为当前线程所在的结点。
int ws = node.waitStatus;
if (ws < 0)//置零当前线程所在的结点状态,允许失败。
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;//找到下一个需要唤醒的结点s
if (s == null || s.waitStatus > 0) {
//如果为空或已取消
s = null;
for (Node t = tail; t != null && t != node; t = t.prev) // 从后向前找。
if (t.waitStatus <= 0)//从这里可以看出,<=0的结点,都是还有效的结点。
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);//唤醒
}
2.3.1.1.2、共享模式:
查看带 xxxShared 字眼的方法
相比独占模式,共享模式的差异如下:
1、state 初始值大于1,代表可被共享的次数
2、共享模式加锁时,对state做减法操作,只要剩余state够就能加锁成功。加锁成功时不再记录加锁的Thread信息。
3、加锁失败时,与共享模式一样,添加等待节点到等待队列尾部。
4、解锁时,做state做加法操作。然后循环唤醒等待队列中的阻塞线程。
关键代码解读:
- tryAcquireShared
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
//获取AQS中资源个数
int available = getState();
int remaining = available - acquires;
//如果remaining小于0,说明没有可用的资源了,如果大于0,执行CAS操作获取资源,最后返回剩余的资源数
//如果返回的剩余资源数小于或者等于0,说明没有可用资源了,如果大于0,说明还有可用资源
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
- releaseShared
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
//Semaphore中的实现
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next)) // 释放资源占用
return true;
}
}
- doReleaseShared
private void doReleaseShared() {
for (;;) {
//获取首节点
Node h = head;
if (h != null && h != tail) {
//获取首节点状态
int ws = h.waitStatus;
//如果首节点状态是SIGNAL,说明首节点后面还有节点,唤醒他们
if (ws == Node.SIGNAL) {
//先把首节点状态改成0,0可以看成首节点的中间状态,只有在唤醒第二个节点的时候才会存在,当第二个节点唤醒之后,首节点
//就会被干掉
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
//这个方法就是唤醒首节点之后第一个处于非取消状态的节点
unparkSuccessor(h);
}
//判断ws == 0,这个是中间状态,就是说有一个线程正在唤醒第二个节点,这个时候,又有一个线程释放了资源,也要来唤醒第二个节点,但是他发现
//有别的线程在处理,他就把这个状态改成PROPAGATE = -3,而这个状态正是上一个方法需要判断的,上一个方法判断h.waitStatus < 0,会成立就是这里设置的
//当然,h.waitStatus < 0会成立,还有别的原因,这个只是其中一个,下面会分析
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
至此,你已经掌握了AQS抽象类的主要原理,相信在看具体锁实现时会事半功倍(AbstractQueuedSynchronizer中还有很多的具体实现,但不打紧,掌握这几个核心原理就可以了)。
2.3.2、顶层抽象类:AbstractOwnableSynchronizer
此类特别简单,可直接看代码。
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
// 锁独占模式下,当前独占锁的线程
private transient Thread exclusiveOwnerThread;
//set操作
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
//get操作
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
}
2.3.3、顶层抽象类:AbstractQueuedLongSynchronizer
一句话:64位版本的AQS
2.3.4、锁分类
Java中有哪些常见的锁?
- 见本文后面章节《Java中的锁》。进行了分类和详细的介绍。
2.3.5、ReentrantLock
假设你已经先读了:
- AQS
- 本文的锁分类介绍
那么,在此基础上我们差异性的陈述一些要点:
- ReentrantLock是独享锁
- ReentrantLock底层是基于AQS
- ReentrantLock支持公平锁模式和非公平锁模式,默认是非公平锁(即尝试加锁时是抢占式的)
老生常谈 之 ReentrantLock vs synchronized
既然与ReentrantLock相比,那么就从ReentrantLock特点的视角对比下:
锁类型 | 底层技术 | 是否公平锁 | 是否可重入 | |
---|---|---|---|---|
ReentrantLock | 独享锁 | CAS + AQS | 均支持。可指定 | 可重入 |
synchronized | 独享锁 | CAS + Monitor | 非公平锁 | 可重入 |
关于ReentrantLock的可重入,还记得上文的AbstractOwnableSynchronizer不,每次加锁时拿出来判断下即可,发现是同一个线程,重入。
另外,两者在性能层面没有明显区别。
所以,选择哪一种就要看具体代码场景了
- 适合ReentrantLock的场景
- 细粒度的锁范围控制
- 细粒度的阻塞线程唤醒。ReentrantLock提供了Condition类,用来实现分组唤醒需要唤醒的线程们。
- ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。
所以,一般情况下常规加锁需求用synchronized先,有更进一步的加锁要求时ReentrantLock更适合。
2.3.6、ReentrantReadWriteLock
上来先讲结论,然后再酌情看代码加深理解:
(参考下图)
- ReentrantReadWriteLock是读写锁
- ReentrantReadWriteLock内部持有两把锁来实现读写锁:ReadLock、WriteLock
- ReadLock&WriteLock共用一个Sync(即共享同一个AQS),正因为是共用一个Sync,才能在读写锁的时候分别感知对方的状态。
- ReentrantReadWriteLock的读写锁标记维护在同一个AQS的state上,为了记录ReadLock和WriteLock的次数, 通过EXCLUSIVE_MASK将32位的state拆成两段:高16位给ReadLock用,低16位给WriteLock用。
- 读锁状态:可多个线程同时拿锁,且均可各自重入。(见:Sync内部类HoldCounter和ThreadLocalHoldCounter)
- 写锁状态:独享锁状态。
- ReentrantReadWriteLock支持锁降级:写锁状态进入,读锁状态退出
ReentrantReadWriteLock内部的两把锁:
共用一个Sync(AQS):
Sync里有哪些重要信息呢?
-
将AQS state字段复用为读锁、写锁计数位的SHARED_SHIFT
-
ThreadLocalHoldCounter控制读锁重入
-
其他均继承自AQS
- state 记录锁的状态
- exclusiveOwnerThread 记录锁被哪个线程独占
- 等待队列
- 线程阻塞 & 等待线程唤醒方法
- 共享模式(读锁的场景)
ReentrantReadWriteLock分段使用AQS的锁计数state:
锁降级:
概念:
以写锁进入临界区,但以读锁结束。(加锁顺序:写锁-加锁 ==> 读锁加锁 ==> 写锁-解锁 ==> 读锁-解锁)
- 相关代码
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
...
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0) //若tryAcquireShared返回>=0。代表拿读锁成功
doAcquireShared(arg); //执行拿读锁的逻辑
}
...
}
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
...
abstract static class Sync extends AbstractQueuedSynchronizer {
...
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
//注解说明:当前时刻存在写锁,而且如果自己这个获取读锁的线程和当前持有写锁的线程是同一个线程的话,就不会返回-1。也就是说可以继续获取读锁。
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);
}
...
}
...
}
2.3.7、StampedLock
在 JDK 1.8 引入 StampedLock,可以理解为对 ReentrantReadWriteLock 在某些方面的增强,在原先读写锁的基础上新增了一种叫乐观读(Optimistic Reading)的模式。该模式并不会加锁,所以不会阻塞线程,允许多个度线程和1个写线程并发执行,故有更高的吞吐量和更高的性能。尤其适合读多写少的场景。
2.3.7.1、特性
它的设计初衷是作为一个内部工具类,用于开发其他线程安全的组件,提升系统性能,并且编程模型也比ReentrantReadWriteLock 复杂,所以用不好就很容易出现死锁或者线程安全等莫名其妙的问题。
三种访问数据模式:
- Writing(独占写锁):writeLock 方法会使线程阻塞等待独占访问,可类比ReentrantReadWriteLock 的写锁模式,同一时刻有且只有一个写线程获取锁资源;
- Reading(悲观读锁):readLock方法,允许多个线程同时获取悲观读锁,悲观读锁与独占写锁互斥,与乐观读共享。
- Optimistic Reading(乐观读):这里需要注意了,是乐观读,并没有加锁。也就是不会有 CAS 机制并且没有阻塞线程。仅当当前未处于 Writing 模式 tryOptimisticRead才会返回非 0 的邮戳(Stamp),如果在获取乐观读之后没有出现写模式线程获取锁,则在方法validate返回 true ,允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。《《《《《【重点】【重点】【重点】
支持读写锁相互转换
-
ReentrantReadWriteLock: 当线程获取写锁后可以降级成读锁,但是反过来则不行。
-
StampedLock:提供了读锁和写锁相互转换的功能,使得该类支持更多的应用场景。
2.3.7.2、详解乐观读带来的性能提升
StampedLock 性能比 ReentrantReadWriteLock 好,关键在于StampedLock 提供的乐观读。
我们知道ReentrantReadWriteLock 的读锁和写锁是互斥的,当有读锁的时候,写锁线程是阻塞等待的。
而,StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。
这里可能你就会有疑问,竟然同时允许多个乐观读和一个先线程同时进入临界资源操作,那读取的数据可能是错的怎么办?
是的,乐观读不能保证读取到的数据是最新的,所以将数据读取到局部变量的时候需要通过 lock.validate(stamp) 校验是否被写线程修改过,若是修改过则需要上悲观读锁,再重新读取数据到局部变量。
同时由于乐观读并不是锁,所以没有线程唤醒与阻塞导致的上下文切换,性能更好。
2.3.7.3、使用场景和注意事项
对于读多写少的高并发场景 StampedLock的性能很好!
通过乐观读模式很好的解决了写线程“饥饿”的问题,我们可以使用StampedLock 来代替ReentrantReadWriteLock ,但是需要注意的是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。
- StampedLock是不可重入锁,如果当前线程已经获取了写锁,再次重复获取的话就会死锁。使用过程中一定要注意;
- 悲观读、写锁都不支持条件变量 Conditon ,当需要这个特性的时候需要注意;
- 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规则一定要记清楚。
对比:
锁模式 | 是否可重入 | CAS后的ABA问题 | 锁转换 | |
---|---|---|---|---|
ReentrantReadWriteLock | 悲观读 | 可重入 | 无法避免ABA问题 | 锁降级 |
StampedLock | 悲观读/乐观读 | 不可重入 | 通过版本号解决ABA问题 | 锁降级/锁升级 |
2.3.7.4、代码例子
官方例子:
public class StampedLockDemo {
// 成员变量
private double x, y;
// 锁实例
private final StampedLock sl = new StampedLock();
// 排它锁-写锁(writeLock)
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
// 乐观读锁
double distanceFromOrigin() {
// 尝试获取乐观读锁(1)
long stamp = sl.tryOptimisticRead();
// 将全部变量拷贝到方法体栈内(2)
double currentX = x, currentY = y;
// 检查在(1)获取到读锁票据后,锁有没被其他写线程排它性抢占(3)
if (!sl.validate(stamp)) {
// 如果被抢占则获取一个共享读锁(悲观获取)(4)
stamp = sl.readLock();
try {
// 将全部变量拷贝到方法体栈内(5)
currentX = x;
currentY = y;
} finally {
// 释放共享读锁(6)
sl.unlockRead(stamp);
}
}
// 返回计算结果(7)
return Math.sqrt(currentX * currentX + currentY * currentY);
}
// 使用悲观锁获取读锁,并尝试转换为写锁
void moveIfAtOrigin(double newX, double newY) {
// 这里可以使用乐观读锁替换(1)
long stamp = sl.readLock();
try {
// 如果当前点在原点则移动(2)
while (x == 0.0 && y == 0.0) {
// 尝试将获取的读锁升级为写锁(3)
long ws = sl.tryConvertToWriteLock(stamp);
// 升级成功,则更新票据,并设置坐标值,然后退出循环(4)
if (ws != 0L) {
stamp = ws;
x = newX;
y = newY;
break;
} else {
// 读锁升级写锁失败则释放读锁,显示获取独占写锁,然后循环重试(5)
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
// 释放锁(6)
sl.unlock(stamp);
}
}
}
2.3.8、Semaphore
Semaphore是一种计数信号量,用于管理一组资源,内部是基于AQS的共享模式。它相当于控制使用公共资源的活动线程的数量。
使用场景的比喻:停车场车位滚动使用;卫生间坑位的滚动使用。
主要结构:
pu