多线池与高并发篇章二
前言:
上天:
高并发,缓存,大流量,大数据量
入地:
JVM,OS,算法,线程,IO
1. volatile关键字
/**
* @description: volatile关键字 是一个变量在多个线程中可见
* 我就简单解释吧 A B线程 java默认A线程在自己的工作内存中保存一个变量的值,如果B线程修改了主内存中变量的值
* 但是 我们A线程就不知道变量的值已经发生改变,一直在用着自己工作内存的变量的值
* 而使用volatile关键字 可以让我们所有的线程都可以读到变量的修改值
* @author: 大佬味的小男孩
* @date: 2020-08-08 18:17
**/
public class Demo01 {
/*volatile*/ boolean running = true; //对比一下有无volatile的情况下,整个程序的执行效果
public void m() {
System.out.println("m start...");
while (running) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("m end...");
}
public static void main(String[] args) {
Demo01 demo01 = new Demo01();
Runnable runnable = new Runnable() {
@Override
public void run() {
demo01.m();
}
};
new Thread(runnable).start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
demo01.running = false;
System.out.println("主线程嗝屁...");
}
}
1.1 保证线程可见性
图解:
Volatile实现内存可见性的过程
线程写Volatile变量的过程:
1. 改变线程本地内存中Volatile变量副本的值;
2. 将改变后的副本的值从本地内存刷新到主内存
线程读Volatile变量的过程:
1. 从主内存中读取Volatile变量的最新值到线程的本地内存中
2. 从本地内存中读取Volatile变量的副本
Volatile实现内存可见性原理:
写操作时,通过在写操作指令后加入一条store屏障指令,让本地内存中变量的值能够刷新到主内存中读操作时,
通过在读操作前加入一条load屏障指令,及时读取到变量在主内存的值。
PS: 内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。
Java编译器也会根据内存屏障的规则禁止重排序。
volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,
所以,JMM采用了保守策略。如下:
StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
1.2 禁止指令重排序(CPU)
1.2.1 DCL单例
/**
* @description: 饿汉式:private修饰,自定内部定义,别人只能拿来用
* 类加载到内存后,就会实例化一个单例对象, jvm保证线程安全
* 简单实用
* 缺点:不管你用不用得到,类加载是就完成了实例化
* Class.forName("")
* 话说你都不用,那你加载干嘛?
* @author: 大佬味的小男孩
* @date: 2020-08-08 21:50
**/
public class Demo02 {
private static final Demo02 demo02 = new Demo02();
//空参
private Demo02() {
}
public static Demo02 getDemo02() {
return demo02;
}
// public void m() {
// System.out.println("m");
// }
public static void main(String[] args) {
Demo02 d1 = Demo02.getDemo02();
Demo02 d2 = Demo02.getDemo02();
System.out.println(d1 == d2);
}
}
/**
* @description: 懒汉式:虽然达到初始化的目的,但是给现场带来了不安全的问题
* @author: 大佬味的小男孩
* @date: 2020-08-08 22:07
**/
public class Demo03 {
private static Demo03 demo03;
private Demo03() {
}
public static /*synchronized*/ Demo03 getDemo03() {
//当调用getDemo03时候,先判断一下是否为空
if (demo03 == null) {
// synchronized (Demo03.class){
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
demo03 = new Demo03();
// }
}
//但是问题来了:在多线程的环境下,当前代码在线程A和线程B都判断为空的时候就出幺蛾子
//加synchronized就可以解决问题
return demo03;
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() ->
System.out.println(Demo03.getDemo03().hashCode())
).start();
}
}
}
在上述代码中存在一个问题,厉害的同学应该已经发现了,我尝试在两个不同的地方加锁,但是第二个地方的锁还是会造成线程不安全
这关乎我下面要讲的内容:
public class Demo04 {
private static /*volatile*/ Demo04 demo03;
private Demo04() {
}
public static /*synchronized*/ Demo04 getDemo03() {
//省略业务逻辑
//当调用getDemo03时候,先判断一下是否为空
if (demo03 == null) {
synchronized (Demo04.class) {
//双重检查
if (demo03 == null) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
demo03 = new Demo04();
}
}
}
return demo03;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() ->
System.out.println(Demo04.getDemo03().hashCode())
).start();
}
}
}
1.2.2 在单例模式下双重检查下需不需要加volatile?
volatile和双重检查的面试题:你知道单例模式?在单例模式在听说吗双重检查吗?那请问已经有双重检查了,那么需不需要加volatile?
答案:需要
指令重排序
Double Check Lock
loadfence原语指令
storefence原语指令
1.3 原子性的问题
虽然Volatile 关键字可以让变量在多个线程之间可见,但是Volatile不具备原子性。
解决方案:
1. 使用synchronized
2. 使用ReentrantLock(可重入锁)
3. 使用AtomicInteger(原子操作)
1.4 synchronized和volatile比较
a)volatile不需要加锁,比synchronized更轻便,不会阻塞线程。
b)synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性。
与锁相比,Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。
如果严格遵循 volatile 的使用条件(变量真正独立于其他变量和自己以前的值 )
在某些情况下可以使用 volatile 代替 synchronized 来优化代码提升效率。
2. CAS(无锁优化 自旋)
2.1 开胃小菜
/**
* @description: 在用自旋锁解决同样问题的情况下。使用AtomXXX类更加合适
* AtomXXX类本身方法都是能保证原子性,但是不能保证多个方法直接连续调用是原子性
* @author: 大佬味的小男孩
* @date: 2020-08-09 10:17
**/
public class Demo01 {
/*volatile*/ //int count = 0;
AtomicInteger count = new AtomicInteger(0);
/*synchronized*/ void m(){
for (int i = 0; i < 1000; i++) {
count.incrementAndGet();//相当于count++
}
}
public static void main(String[] args) {
Demo01 demo01 = new Demo01();
ArrayList<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100; i++) {
threads.add(new Thread(()->demo01.m()));
}
threads.forEach((o)->o.start());
threads.forEach((o)->{
try {
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(demo01.count);
}
}
2.2 CAS
字面意思是:比较 并且 设置 (原来我有一个值是0,我想改成1。我想保证线程安全,这个时候就加锁,加synchronized。但是现在有一个方法可以替代这把锁)
同步组件中大量使用CAS技术实现了Java多线程的并发操作。
整个AQS同步组件、Atomic原子类操作等等都是以CAS实现的,甚至ConcurrentHashMap在1.8的版本中也调整为了CAS+Synchronized。
可以说CAS是整个JUC的基石。
2.3 CAS原理
CAS的思想 (V,Expected,NewValue)
V:要改的值。Expected:期望当前值是什么(期望当前值是0)。NewValue:期望改了之后的值是什么(期望改成1)
解读:cas期望当前值是0,所以会先判断当前值是否为0,如果当前值为0,就把值修改成1,。如果当前值不为0,
那么就有其他线程修改这个值,cas可能会再试一次或者跑异常。
这个时候就有人可能会问:假设当前线程A判断当前值为0,在还没讲当前值修改成1的情况下,另外一个线程B跑进来将该值修改成其他值。
这怎么保证线程的安全呢? 这时候就要拜托我们CPU原语支持。
cas的操作是CPU指令级别上的支持,在将值修改成1的情况下,中间不能被打断。
2.4 synchronized和CAS原理的优缺点
2.4.1 MarkWord
synchronized使用monitorenter和monitorexit这两个jvm指令实现的,这两个jvm指令实现锁的使用,主要是基于 Mark Word和monitor。
Mark Word:包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,
Mark Word用于存储对象自身的运行时数据,它是synchronized实现轻量级锁和偏向锁的关键。
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、
偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),
但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,
但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,
Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,
它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化
2.4.2 monitor
我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,
所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,
每一个Java对象都带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。
每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),
同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
2.5 JUC下的atomic类
JUC下的atomic类都是通过CAS来实现的,下面就是一个AtomicInteger原子操作类的例子,在其中使用
了Unsafe unsafe = Unsafe.getUnsafe()。Unsafe 是CAS的核心类,它提供了硬件级别的原子操作。
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
//操作的值也进行了volatile修饰,保证内存可见性
private volatile int value;
查看AtomicInteger的addAndGet()方法:
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
public final native boolean compareAndSwapInt(Object var1, long var2, int var4,int var5);
Unsafe 是一个比较危险的类,主要是用于执行低级别、不安全的方法集合。尽管这个类和所有的方法都是公开的(public),
但是这个类的使用仍然受限,你无法在自己的java程序中直接使用该类,因为只有授信的代码才能获得该类的实例。
可是为什么Unsafe的native方法就可以保证是原子操作呢?
2.6 native关键词
前面提到了sun.misc.Unsafe这个类,里面的方法使用native关键词声明本地方法,为什么要用native?
Java无法直接访问底层操作系统,但有能力调用其他语言编写的函数or方法,是通过JNI(Java NativeInterfface)实现。
使用时,通过native关键字告诉JVM这个方法是在外部定义的。但JVM也不知道去哪找这个原生方法,此时需要通过javah命令生成.h文件。
示例步骤(c语言为例):
1. javac生成.class文件,比如javac NativePeer.java
2. javah生成.h文件,比如javah NativePeer
3. 编写c语言文件,在其中include进上一步生成的.h文件,然后实现其中声明而未实现的函数
4. 生成dll共享库,然后Java程序load库,调用即可native可以和任何除abstract外的关键字连用,
这也说明了这些方法是有实体的,并且能够和其他Java方法一样,拥有各种Java的特性。
native方法有效地扩充了jvm,实际上我们所用的很多代码已经涉及到这种方法了,通过非常简洁的接口帮我们实现Java以外的工作。
native优势:
很多层次上用Java去实现是很麻烦的,而且Java解释执行的效率也差了c语言啥的很多,纯Java实现可能会导致效率不达标,或者可读性奇差。
Java毕竟不是一个完整的系统,它经常需要一些底层的支持,通过JNI和native method我们就可以实现jre与底层的交互,
得到强大的底层操作系统的支持,使用一些Java本身没有封装的操作系统的特性
2.7 多CPU的CAS处理
CAS可以保证一次的读-改-写操作是原子操作,在单处理器上该操作容易实现,但是在多处理器上实现就有点儿复杂了。CPU提供了两种方法来实现多处理器的原子操作:总线加锁或者缓存加锁。
2.7.1 总线加锁:
总线加锁就是就是使用处理器提供的一个LOCK 信号,当一个处理器在总线上输出此信号时,
其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式显得有点儿霸道,不厚道,
他把CPU和内存之间的通信锁住了,在锁定期间,其他处理器都不能其他内存地址的数据,其开销有点儿大。
2.7.2 缓存加锁
缓存加锁:其实针对于上面那种情况我们只需要保证在同一时刻对某个内存地址的操作是原子性的即可。缓存加锁就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时,处理器不在输出LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当CPU1修改缓存行中的i时使用缓存锁定,那么CPU2就不能同时缓存了i的缓存行。
2.8 CAS缺陷
CAS虽然高效地解决了原子操作,但是还是存在一些缺陷的,主要表现在三个方法:循环时间太长、只能保证一个共享变量原子操作、ABA问题。
2.8.1 循环时间太长
如果CAS一直不成功呢?这种情况绝对有可能发生,如果自旋CAS长时间地不成功,则会给CPU带来非常大的开销。在JUC中有些地方就限制了CAS自旋的次数,例如BlockingQueue的SynchronousQueue。
2.8.2 只能保证一个共享变量原子操作
看了CAS的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了。
2.8.3 ABA问题
CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的ABA问题。对于ABA问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加1,即A —> B —> A,变成1A —> 2B —> 3A。
CAS的ABA隐患问题,Java提供了AtomicStampedReference来解决。
AtomicStampedReference通过包装[E,Integer]的元组来对对象标记版本戳stamp,从而避免ABA问题。对于上面的案例应该线程1会失败。
2.8.4 AtomicStampedReference和AtomicInteger的区别
/**
* @description:
* @author: 大佬味的小男孩
* @date: 2020-08-17 18:48
**/
public class Test1 {
private static AtomicInteger ai = new AtomicInteger(100);
private static AtomicStampedReference asr = new AtomicStampedReference(100, 1);
//ABA问题演示:
//1. 线程1先对数据进行修改 A-B-A过程
//2. 线程2也对数据进行修改 A-C的过程
public static void main(String[] args) {
// AtomicInteger可以看到不会有任何限制随便改
// 线程2修改的时候也不可能知道要A-C 的时候,A是原来的A还是修改之后的A
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
ai.compareAndSet(100, 110);
ai.compareAndSet(110, 100);
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
//为了让线程1先执行完,等一会
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("AtomicInteger:" + ai.compareAndSet(100, 120));
System.out.println("执行结果:" + ai.get());
}
});
thread1.start();
thread2.start();
//顺序执行,AtomicInteger案例先执行
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
//AtomicStampedReference可以看到每次修改都需要设置标识Stamp,相当于进行了1A-2B-
// 线程2进行操作的时候,虽然数值都一样,但是可以根据标识很容易的知道A是以前的1A,还是现在的3A
Thread tsf1 = new Thread(new Runnable() {
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 预期引用:100,更新后的引用:110,预期标识getStamp() 更新后的标识getStamp() + 1
asr.compareAndSet(100, 110, asr.getStamp(), asr.getStamp() + 1);
asr.compareAndSet(110, 100, asr.getStamp(), asr.getStamp() + 1);
}
});
Thread tsf2 = new Thread(new Runnable() {
public void run() {
//tsf2先获取stamp,导致预期时间戳不一致
int stamp = asr.getStamp();
try {
TimeUnit.MILLISECONDS.sleep(100); //线程tsf1执行完
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("AtomicStampedReference:" + asr.compareAndSet(100, 120, stamp, stamp + 1));
int[] stampArr = {stamp + 1};
System.out.println("执行结果:" + asr.get(stampArr));
}
});
tsf1.start();
tsf2.start();
}
}
AtomicInteger:true
执行结果:120
AtomicStampedReference:true
执行结果:120
运行结果充分展示了AtomicInteger的ABA问题和AtomicStampedReference解决ABA问题。
3 . J.U.C之atomic包
AtomicInteger的工作原理,它们的内部都维护者一个对应的基本类型的成员变量value,
这个变量是被volatile关键字修饰的,保证多线程环境下看见的是同一个(可见性)。
AtomicInteger在进行一些原子操作的时候,依赖Unsafe类里面的CAS方法,原子操作就是通过自旋方式,
不断地使用CAS函数进行尝试直到达到自己的目的。
除了AtomicInteger类以外还有很多其他的类也有类似的功能,
在JUC中有一个包java.util.concurrent.atomic存放原子操作的类。
3.1 基本类型
3.1.1 AtomicInteger:整形原子类
主要API如下:
get() //直接返回值
getAndAdd(int) //增加指定的数据,返回变化前的数据
getAndDecrement() //减少1,返回减少前的数据
getAndIncrement() //增加1,返回增加前的数据
getAndSet(int) //设置指定的数据,返回设置前的数据
addAndGet(int) //增加指定的数据后返回增加后的数据
decrementAndGet() //减少1,返回减少后的值
incrementAndGet() //增加1,返回增加后的值
lazySet(int) //仅仅当get时才会set
compareAndSet(int, int)//尝试新增后对比,若增加成功则返回true否则返回false
3.1.2 AtomicLong:长整型原子类
AtomicLong主要API和AtomicInteger,只是类型不是int,而是long
3.1.3 AtomicBoolean :布尔型原子类
compareAndSet(boolean, boolean) //参数1为原始值,参数2为修改的新值,若修改成功返回true,否则返回false
getAndSet(boolean)// 尝试设置新的boolean值,直到成功为止,返回设置前的数据
3.2 引用类型
3.2.1 AtomicReference:引用类型原子类
3.2.2 AtomicStampedReference:原子更新引用类型里的字段原子类 (版本号)
3.2.3 AtomicMarkableReference :原子更新带有标记位的引用类型 (true false)
3.3 数组类型 使用原子的方式更新数组里的某个元素
3.3.1 AtomicIntegerArray:整形数组原子类
addAndGet(int, int) //执行加法,第一个参数为数组的下标,第二个参数为增加的数量,返回增加后的结果
compareAndSet(int, int, int) // 对比修改,参1数组下标,参2原始值,参3修改目标值,成功返回true否则false
decrementAndGet(int) // 参数为数组下标,将数组对应数字减少1,返回减少后的数据
incrementAndGet(int) // 参数为数组下标,将数组对应数字增加1,返回增加后的数据
getAndAdd(int, int) // 和addAndGet类似,区别是返回值是变化前的数据
getAndDecrement(int) // 和decrementAndGet类似,区别是返回变化前的数据
getAndIncrement(int) // 和incrementAndGet类似,区别是返回变化前的数据
getAndSet(int, int) // 将对应下标的数字设置为指定值,第二个参数为设置的值,返回是变化前的数据
3.3.2 AtomicLongArray:长整形数组原子类
AtomicIntegerArray主要API和AtomicLongArray,只是类型不是int,而是long
3.3.2 AtomicReferenceArray :引用类型数组原子类
//参数1:数组下标;
//参数2:修改原始值对比;
//参数3:修改目标值
//修改成功返回true,否则返回false
compareAndSet(int, Object, Object)
//参数1:数组下标
//参数2:修改的目标
//修改成功为止,返回修改前的数据
getAndSet(int, Object)
3.4 对象的属性修改类型
AtomicIntegerFieldUpdater:原子更新整形字段的更新器
AtomicLongFieldUpdater:原子更新长整形字段的更新器
AtomicReferenceFieldUpdater :原子更新引用类形字段的更新器
如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。
AtomicIntegerFieldUpdater:原子更新整形字段的更新器
AtomicLongFieldUpdater:原子更新长整形字段的更新器
AtomicReferenceFieldUpdater :原子更新引用类形字段的更新器
但是他们的使用通常有以下几个限制:
限制1:操作的目标不能是static类型,前面说到的unsafe提取的是非static类型的属性偏移量,
如果是static类型在获取时如果没有使用对应的方法是会报错的,而这个Updater并没有使用对应的方法。
限制2:操作的目标不能是final类型的,因为final根本没法修改。
限制3:必须是volatile类型的数据,也就是数据本身是读一致的。
限制4:属性必须对当前的Updater所在的区域是可见的,也就是private如果不是当前类肯定是不可见的,
protected如果不存在父子关系也是不可见的,default如果不是在同一个package下也是不可见的。
实现方式:通过反射找到属性,对属性进行操作。
4. AQS
AQS(AbstractQueuedSynchronizer),即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),
JUC并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。它是JUC并发包中的核心基础组件。
在这里我们只是对AQS进行了解,它只是一个抽象类,但是JUC中的很多组件都是基于这个抽象类,也可以说这个AQS是多数JUC组件的基础。
4.1 AQS的作用
Java的内置锁一直都是备受争议的,在JDK 1.6之前,synchronized这个重量级锁其性能一直都是较为低下,
虽然在1.6后,进行大量的锁优化策略,但是与Lock相比synchronized还是存在一些缺陷的:
它缺少了获取锁与释放锁的可操作性,可中断、超时获取锁,而且独占式在高并发场景下性能大打折扣。
AQS解决了实现同步器时涉及到的大量细节问题,例如获取同步状态、FIFO同步队列。基于AQS来构建同步器可以带来很多好处。
它不仅能够极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题。
4.2 state状态
AQS维护了一个volatile int类型的变量state表示当前同步状态。
当state>0时表示已经获取了锁,当state = 0时表示释放了锁。
它提供了三个方法来对同步状态state进行操作:
getState():返回同步状态的当前值 setState():设置当前同步状态
compareAndSetState():使用CAS设置当前状态,该方法能够保证状态设置的原子性
这三种操作均是CAS原子操作,其中compareAndSetState的实现依赖于Unsafe的compareAndSwapInt()方法
4.3 资源共享方式
AQS定义两种资源共享方式:
Exclusive(独占,只有一个线程能执行,如ReentrantLock)
Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,
至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():当前同步器是否在独占式模式下被线程占用,一般该方法表示是否被当前线程所独占。
只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取同步状态,成功则返回true,失败则返回false。
其他线程需要等待该线程释放同步状态才能获取同步状态。
tryRelease(int):独占方式。尝试释放同步状态,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取同步状态。负数表示失败;0表示成功,但没有剩余可用资源;
正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放同步状态,如果释放后允许唤醒后续等待结点,返回true,否则返回false。
4.4 CLH同步队列
AQS内部维护着一个FIFO队列,该队列就是CLH同步队列,遵循FIFO原则( First Input First Output先进先出)。CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理。
当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。
4.5 入列
CLH队列入列非常简单,就是tail指向新节点、新节点的prev指向当前最后的节点,当前最后一个节点的next指向当前节点。
private Node addWaiter(Node mode) {
//新建Node
Node node = new Node(Thread.currentThread(), mode);
//快速尝试添加尾节点
Node pred = tail;
if (pred != null) {
node.prev = pred;
//CAS设置尾节点
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//多次尝试
enq(node);
return node;
}
在上面代码中,两个方法都是通过一个CAS方法compareAndSetTail(Node expect, Node update)来设
置尾节点,该方法可以确保节点是线程安全添加的。在enq(Node node)方法中,AQS通过“死循环”的方
式来保证节点可以正确添加,只有成功添加后,当前线程才会从该方法返回,否则会一直执行下去。
4.6 出列
CLH同步队列遵循FIFO,首节点的线程释放同步状态后,将会唤醒它的后继节点(next),而后继节点将会在获取同步状态成功时将自己设置为首节点。head执行该节点并断开原首节点的next和当前节点的prev即可,注意在这个过程是不需要使用CAS来保证的,因为只有一个线程能够成功获取到同步状态。过程图如下: