并发编程
1 CAS算法
1.1 什么是CAS
CAS(Compare and Swap),即比较并替换,是用于实现多线程同步的原子操作。
所谓原子操作是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何context switch(切换到另一个线程)。
实现原子操作可以使用锁,锁机制对于满足基本的原子需求是没问题的,但synchronized是基于阻塞的锁机制,也就是当一个线程拥有锁时,访问同一资源的其他线程需要等待,直到该线程释放锁。
同时基于synchronized实现原子操作也会出现很多问题。
- 优先级低的线程抢到锁,被阻塞的线程优先级很高很重要怎么办?
- 获得锁的线程一直不释放锁怎么办?
- 有大量的线程来竞争资源,则CPU会花费大量时间和资源来处理这些竞争。
- 死锁问题处理。
其实锁机制是一种较为粗糙,粒度比较大的机制,对于一些简单的需求,如计数器显得有点过于笨重。
1.2 CAS实现原理
现代处理器基本都支持CAS指令,每一个CAS操作过程都包含三个运算符:内部地址V、期望值A、新值B。操作时如果这个内存地址V上存放的值等于期望值A,则将内存地址上的值修改为新值B,否则不做任何操作。常见的CAS循环其实就是在一个循环里不断的做CAS操作,直到成功为止。
CAS对于线程安全的实现,其是语言层面无任何处理,我们将其交CPU和内存完成,利用多核CPU的处理能力,实现硬件层面的阻塞,再加上volatile关键字的特性即可实现基于原子操作的线程安全。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lz8AJCMH-1687956058072)(assets/image-20200728161620715.png)]
1.3 悲观锁、乐观锁
说到CAS,不得不提到两个专业词语:悲观锁,乐观锁。我们先来看看什么是悲观锁,什么是乐观锁。
1.3.1 悲观锁
悲观锁总是假设会出现最坏的情况,每次去获取数据时,都会认为别人会修改,所以每次在获取数据时都会上锁。这样别人想拿到这个数据就会阻塞,直到它获取到锁。在关系型数据库中就大量应用了这种锁机制,如行锁、表锁、读锁、写锁。都是在操作前先上锁。Java中synchronized就是很直观的体现。
1.3.2 乐观锁
乐观锁总是假设一直都是最好的情况。每次获取时都认为别人不会修改,所以不会上锁,但是在更新时会判断在此期间别人有没有更新这个数据,可以使用版本号实现。乐观锁适用于读多写少的场景。这样可以提升系统吞吐量,而CAS就是一种乐观锁的实现。
1.4 CAS的典型问题
CAS看起来很好,但是其实现过程中会出现三个典型问题,分别为ABA、循环时间开销大、只能保证一个共享变量的原子操作。
1.4.1 ABA
根据之前的讲解CAS操作,其实现重要思路是需要取出内存地址中某个时刻的数据,而在下时刻比较并替换,那么在这个时间差就有可能导致数据的变化。
一个线程a将数值改成了b,接着又改成了a,此时CAS认为是没有变化,其实是已经变化过了,这种过程就叫ABA问题。
对于ABA问题的解决,常见的解决方式就是通过添加数据版本号实现,避免该问题的发生。后续讲到的原子类就是基于版本号避免ABA问题的出现。
1.4.2 循环时间长开销大
CAS会基于CPU进行自旋操作,如果CAS失效,就会一直进行尝试,如果自旋时间过长,会给CPU带来巨大性能开销。
public class CasTest {
private static AtomicInteger count = new AtomicInteger(0);
/**
* Cas 自旋操作
*/
public static void accumulation() {
//自旋
for (; ; ) {
//获取旧值
int oldValue = count.get();
//比较并且交换
boolean flag = count.compareAndSet(oldValue, oldValue + 1);
//如果成功退出自旋
if (flag) {
break;
}
//失败打印信息再来一次
System.out.println("数据已被修改自旋再来一次");
}
}
public static void main(String[] args) throws InterruptedException {
//五个线程再跑
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
//每个线程让count自增100000次
for (int n = 0; n < 100000; n++) {
accumulation();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
TimeUnit.SECONDS.sleep(2);
System.out.println(count);
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PVCn8ufi-1687956058074)(assets/image-20200728172201973.png)]
可以看到在高并发下,compareAndSet会很大概率失败,因此导致了CPU不断自旋,造成CPU性能浪费。
1.4.3 只能保证一个共享变量的原子操作
当对一个变量执行操作时,可以使用CAS循环方式保证原子操作,但对多个变量操作时,CAS则无法保证操作的原子性。因为对于一个内存地址来说,其内部只会存储一个变量。如果要对多个变量操作的话,则需要使用到锁或者进行合并(i=2,j=3 -> 合并ij为一个变量 -> 包装为一个引用类型 -> 进行原子操作)。
2 atomic原子操作类
在JDK1.5开始提供了java.util.concurrent.atomic
工具包,这个包下的所有类都基于CAS思想实现,提供了简单、高效、安全的更新一个变量的方式。
为了适配变量类型,在Atomic包中一共提供了12个类,分属于4种类型的原子更新方式,分别为原子更新基本类型、原子更新数据、原子更新引用和原子更新属性。其内部基本都是用Unsafe实现的包装类。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-paBliQzy-1687956058075)(assets/image-20200728175255463.png)]
2.1 原子更新基本类型
以原子方式更新基本类型,Atomic提供了三个类。分别为AtomicInteger、AtomicBoolean、AtomicLong。这三个类的方法基本相同,此处以AtomicInteger为例。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fsg398Kj-1687956058076)(assets/image-20200728181644489.png)]
1)int addAndGet():以原子的方式将输入的数字与AtomicInteger里的值相加,并返回结果。
public class AtomicIntegerTest {
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static Integer addAndGetDemo(int value){
return atomicInteger.addAndGet(value);
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Integer result = addAndGetDemo(i);
System.out.println(result);
}
}
}
2)boolean compareAndSet(int expect, int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值
public static boolean compareAndSetDemo(int expect,int update){
return atomicInteger.compareAndSet(expect, update);
}
/*System.out.println(compareAndSetDemo(1,1));*/
3)int incrementAndGet():对原值+1,并返回操作后的值。类似与redis中的increment命令。相反的还有decrementAndGet()
public static int incrementAndGetDemo(){
return atomicInteger.incrementAndGet();
}
4)int getAndAdd(int delta):原值加上指定值,并返回修改前的值。
public static int getAndAddDemo(int value){
return atomicInteger.getAndAdd(value);
}
/*System.out.println(incrementAndGetDemo());*/
5)int getAndSet(int newValue):将原值修改为新值,并返回修改前的值。
public static int getAndSetDemo(int newValue){
return atomicInteger.getAndSet(newValue);
}
/*System.out.println(getAndAddDemo(5));
System.out.println(atomicInteger.get());*/
6)int getAndIncrement():原值加1,返回修改前的值。对应的还有getAndDecrement()
public static int getAndIncrementDemo(){
return atomicInteger.getAndIncrement();
}
/* System.out.println(getAndIncrementDemo());
System.out.println(atomicInteger.get());*/
2.2 原子更新数组
通过原子方式更新数组里的某个元素,Atomic包中提供了三个类,分别为AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。
以上几个类中的方法几乎一样,只是操作的数据类型不同,以AtomicIntegerArray中的API为例。
//执行加法,第一个参数为数组的下标,第二个参数为增加的数量,返回增加后的结果
int addAndGet(int i, int delta)
//对比修改,参1数组下标,参2原始值,参3修改目标值,成功返回true否则false
boolean compareAndSet(int i, int expect, int update)
//参数为数组下标,将数组对应数字减少1,返回减少后的数据
int decrementAndGet(int i)
// 参数为数组下标,将数组对应数字增加1,返回增加后的数据
int incrementAndGet(int i)
//和addAndGet类似,区别是返回值是变化前的数据
int getAndAdd(int i, int delta)
//和decrementAndGet类似,区别是返回变化前的数据
int getAndDecrement(int i)
//和incrementAndGet类似,区别是返回变化前的数据
int getAndIncrement(int i)
// 将对应下标的数字设置为指定值,第一个参数数组下标,第二个参数为设置的值,返回是变化前的数据
getAndSet(int i, int newValue)
public class AtomicIntegerArrayDemo {
static int[] value = new int[]{
1,2,3};
static AtomicIntegerArray ai = new AtomicIntegerArray(value);
public static void main(String[] args) {
System.out.println(ai.getAndSet(2,6));
System.out.println(ai.get(2));
System.out.println(value[2]);
}
}
此时可以看到从AtomicIntegerArray获取的值与原传入数组的值不同。这是因为数组是通过构造方法传递,然后AtomicIntegerArray会将当前传入数组复制一份。因此当AtomicIntegerArray对内部数组元素进行修改时,不会影响原数组。
2.3 原子更新引用类型
之前提到的通过CAS只能保证一个共享变量的原子操作,当多个的话,就需要使用到锁。对于这个问题,在Atomic包中也进行了解决。如果要更新多个变量,就需要使用Atomic包中的三个类,分别为:AtomicReference(用于原子更新引用类型)、AtomicMarkableReference(用于原子更新带有标记位的引用类型)、AtomicStampedReference(用于原子更新带有版本号的引用类型)。
public class AtomicReferenceTest {
static class User{
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
public static AtomicReference<User> atomicReference = new AtomicReference<>();
public static void main(String[] args) {
User u1 = new User("张三",18);
User u2 = new User("李四",19);
atomicReference.set(u1);
atomicReference.compareAndSet(u1,u2);
System.out.println(atomicReference.get().getName());
System.out.println(atomicReference.get().getAge());
}
}
AtomicMarkableReference可以用于解决CAS中的ABA的问题。使用演示如下:
public class AtomicMarkableReferenceDemo {
static class User{
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
public static void main(String[] args) throws InterruptedException {
User u1 = new User("张三", 22);
User u2 = new User("李四", 33);
//只有true和false两种状态。相当于未修改和已修改
//构造函数出传入初始化引用和初始化修改标识
AtomicMarkableReference<User> amr = new AtomicMarkableReference<>(u1,false);
//在进行比对时,不仅比对对象,同时还会比对修改标识
//第一个参数为期望值
//第二个参数为新值
//第三个参数为期望的mark值
//第四个参数为新的mark值
System.out.println(amr.compareAndSet(u1,u2,false,true));
System.out.println(amr.getReference().getName());
}
}
AtomicStampedReference会基于版本号思想解决ABA问题,根据源码可知,其内部维护了一个Pair对象,Pair对象记录了对象引用和时间戳信息,实际使用的时候,要保证时间戳唯一,如果时间戳如果重复,还会出现ABA的问题。
AtomicStampedReference中的每一个引用变量都带上了pair.stamp这个时间戳,这样就可以解决CAS中的ABA的问题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e243kp1i-1687956058077)(assets/image-20200728233410970.png)]
使用实例如下:
public class AtomicStampedReferenceDemo {
private static final Integer INIT_NUM = 1000;
private static final Integer UPDATE_NUM = 100;
private static final Integer TEM_NUM = 200;
private static AtomicStampedReference atomicStampedReference = new AtomicStampedReference(INIT_NUM, 1);
public static void main(String[] args) {
new Thread(() -> {
int value = (int) atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + value + " 版本号为:" + stamp);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(atomicStampedReference.compareAndSet(value, UPDATE_NUM, stamp, stamp + 1)){
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + atomicStampedReference.getReference() + " 版本号为:" + atomicStampedReference.getStamp());
}else{
System.out.println("版本号不同,更新失败!");
}
}, "线程A").start();
new Thread(() -> {
// 确保线程A先执行
Thread.yield();
int value = (int) atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + value + " 版本号为:" + stamp);
System.out.println(Thread.currentThread().getName() +" : "+atomicStampedReference.compareAndSet(atomicStampedReference.getReference(), TEM_NUM, stamp, stamp + 1));
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + atomicStampedReference.getReference() + " 版本号为:" + atomicStampedReference.getStamp());
System.out.println(Thread.currentThread().getName() +" : "+atomicStampedReference.compareAndSet(atomicStampedReference.getReference(), INIT_NUM, stamp, stamp + 1));
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + atomicStampedReference.getReference() + " 版本号为:" + atomicStampedReference.getStamp());
}, "线程B").start();
}
}
线程A : 当前值为:1000 版本号为:1
线程B : 当前值为:1000 版本号为:1
线程B : true
线程B : 当前值为:200 版本号为:2
线程B : false
线程B : 当前值为:200 版本号为:2
版本号不同,更新失败!
2.4 原子更新字段类
当需要原子更新某个类里的某个字段时,就需要使用原子更新字段类。Atomic包下提供了3个类AtomicIntegerFieldUpdater(原子更新整型字段)、AtomicLongFieldUpdater(原子更新长整型字段)、AtomicReferenceFieldUpdater(原子更新引用类型字段)
原子更新字段类都是抽象类,每次使用都时候必须使用静态方法newUpdater创建一个更新器。原子更新类的字段的必须使用public volatile修饰符。
以AtomicIntegerFieldUpdater为例
public class AtomicIntegerFieldUpdaterTest {
static class User{
private String name;
public volatile int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
private static AtomicIntegerFieldUpdater<User> fieldUpdater = AtomicIntegerFieldUpdater.newUpdater(User.class,"age");
public static void main(String[] args) {
User user = new User("zhangsan",18);
System.out.println(fieldUpdater.getAndIncrement(user));
System.out.println(fieldUpdater.get(user));
}
}
2.5 JDK1.8新增原子类
LongAdder:长整型原子类
DoubleAdder:双浮点型原子类
LongAccumulator:类似LongAdder,但要更加灵活(要传入一个函数式接口)
DoubleAccumulator:类似DoubleAdder,但要更加灵活(要传入一个函数式接口)
以LongAdder为例,其内部提供的API基本上可以替换原先的AtomicLong。
LongAdder类似于AtomicLong是原子性递增或者递减类,AtomicLong已经通过CAS提供了非阻塞的原子性操作,相比使用阻塞算法的同步器来说性能已经很好了,但是JDK开发组并不满足,因为在非常高的并发请求下AtomicLong的性能不能让他们接受,虽然AtomicLong使用CAS但是CAS失败后还是通过无限循环的自旋锁不断尝试。
在高并发下N多线程同时去操作一个变量会造成大量线程CAS失败然后处于自旋状态,这大大浪费了cpu资源,降低了并发性。那么既然AtomicLong性能由于过多线程同时去竞争一个变量的更新而降低的,那么如果把一个变量分解为多个变量,让同样多的线程去竞争多个资源那么性能问题不就解决了?是的,JDK8提供的LongAdder就是这个思路。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tBEsSzla-1687956058078)(assets/image-20200729002844239.png)]
一段LongAdder和Atomic的对比测试代码:
public class Demo9Compare {
public static void main(String[] args) {
AtomicLong atomicLong = new AtomicLong(0L);
LongAdder longAdder = new LongAdder();
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
//atomicLong.incrementAndGet();
longAdder.increment();
}
}
}).start();
}
while (Thread.activeCount() > 2) {
}
System.out.println(atomicLong.get());
System.out.println(longAdder.longValue());
System.out.println("耗时:" + (System.currentTimeMillis() - start));
}
}
根据测试结论,使用longAdder相对比atomicLong可以进行大幅度的性能优化。当然不同计算机因为CPU、内存等硬件不一样,所以测试的数值也不一样,但是得到的结论都是一样的。
测试结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oCl9yw5H-1687956058079)(assets\20170903201405380)]
从上结果图可以看出,在并发比较低的时候,LongAdder和AtomicLong的效果非常接近。但是当并发较高时,两者的差距会越来越大。
3 显示锁(Lock)
3.1 基础介绍
在程序中可以通过synchronized实现锁功能,对于它可以称为内置锁,是由Java语言层面直接为我们提供使用的。可以在程序中隐式的获取锁。但是对于它的使用方式是固化的,只能先获取再释放。而且在使用的过程中,当一个线程获取到某个资源的锁后,其他线程再要获取该资源则必须要进行等待。synchronized并没有提供中断或超时获取的操作。
为了解决这些问题,所以才出现了显示锁。在显示锁中其提供了三个很常见方法:lock()、unLock()、tryLock()。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2cbWX80N-1687956058079)(assets/image-20200729114736511.png)]
Lock的标准用法
//加锁
lock.lock();
//业务逻辑
try{
i++;
}finally{
//解锁
lock.unLock();
}
不要将获取锁的过程写在try中,因为如果在获取锁时发生异常,异常抛出的同时会导致锁的释放。
在 finally 块中释放锁,目的是保证在获取到锁之后,最终能够被释放。
3.2 何时选择用synchronized还是Lock
如果在锁的使用过程中,不需要考虑尝试取锁或锁中断的这些特性的话。尽量使用synchronized。因为synchronized在现在的JDK中对于synchronized的优化是很多的。如锁优化升级。
同时synchronized要比显示锁的内存消耗要少。为什么呢? 因为synchronized是一个语言层面的内容,而lock是一个接口,在使用Lock时需要获取其对象实例后才能进行操作。特别在锁很多的情况下,如没特殊需求,建议使用synchronized。
3.3 ReentrantLock
3.3.1 标准使用方式
根据源码可知Lock本身是一个接口,那么对于其实现类来说,最常用的就是ReentrantLock。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XJ7fGDSm-1687956058080)(assets/image-20200729141310930.png)]
那么ReentrantLock应该如何来使用呢? 其实很简单,只要遵循使用规范即可。
//通过两个线程对value进行count次自增
public class LockTest extends Thread{
private static int count = 100000;
private static int value = 0;
private static Lock lock = new ReentrantLock();
@Override
public void run() {
for (int i = 0; i < count; i++) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+" : "+value);
value++;
}finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
LockTest l1 = new LockTest();
LockTest l2 = new LockTest();
l1.start();
l2.start();
TimeUnit.SECONDS.sleep(5);
System.out.println(value);
}
}
在加锁时务必要注意,对于解锁需要在finally中执行,因为在执行业务逻辑时,有可能出现异常,导致锁无法被释放。
而synchronized的使用要么作用在方法,要么作用在语句块。当出现异常后,代表脱离了执行的代码块,锁自然就会被释放。而显示锁本身是一个对象的实例,如果加锁后,没有进行释放的话,那么锁就会一直存在。
3.3.2 可重入
ReentrantLock一般会把它称之为可重入锁,其是一种递归无阻塞的同步机制。它可以等同于synchronized的使用,但是ReentrantLock提供了比synchronized更强大、灵活的锁机制,可以减少死锁发生的概率。
简单来说就是:同一个线程对于已经获取的锁,可以多次继续申请到该锁的使用权。而 synchronized 关键字隐式的支持重进入,比如一个 synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得
该锁。ReentrantLock 在调用 lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
其内部实现流程为:
- 每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,那么线程都会可能获得该锁而调用对应方法。
- 当某个线程请求成功后,JVM会记录锁的持有线程,并将计数器置为1,此时其他线程请求获取锁,则必须等待。
- 当持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器递增。
- 当持有锁的线程退出同步代码块时,计数器递减,如果计数器为0,则释放该锁。
synchronized可重入
public class SynDemo {
public static synchronized void lock1(){
System.out.println("lock1");
lock2();
}
public static synchronized void lock2(){
System.out.println("lock2");
}
public static void main(String[] args) {
new Thread(){
@Override
public void run() {
lock1();
}
}.start();
}
}
执行结果
lock1
lock2
根据执行结果可以看到,当同一个线程调用多个同步方法时,当其第一次获取锁成功时,接着调用其他同步方法时,仍然可以继续向下调用,不会发生阻塞。实现了锁的可重入。
ReentrantLock可重入
public class ReentrantTest {
private static Lock lock = new ReentrantLock();
private static int count = 0;
public static int getCount() {
return count;
}
public void test1(){
lock.lock();
try {
count++;
test2();
}finally {
lock.unlock();
}
}
public void test2(){
lock.lock();
try {
count++;
}finally {
lock.unlock();
}
}
static class MyThread implements Runnable{
private ReentrantTest reentrantTest;
public MyThread(ReentrantTest reentrantTest) {
this.reentrantTest = reentrantTest;
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
reentrantTest.test1();
}
}
}
public static void main(String[] args) throws InterruptedException {
ReentrantTest reentrantTest = new ReentrantTest();
new Thread(new MyThread(reentrantTest)).start();
TimeUnit.SECONDS.sleep(2);
System.out.println(count);
}
}
运行可以发现,虽然进行了多次加锁,但是并没有被阻塞。代表其也是支持可重入的。
3.3.3 公平锁与非公平锁
3.3.3.1 原理
在多线程并发执行中,当有多个线程同时来获取同一把锁,如果是按照谁等待时间最长,谁先获取锁,则代表这是一把公平锁。反之如果是随机获取的话,CPU时间片轮询到哪个线程,哪个线程就获取锁,则代表这是一把非公平锁。
那么公平锁和非公平锁哪个性能最好呢? 答案是非公平锁的性能更好,因为其充分利用了CPU,减少了线程唤醒的上下文切换的时间。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MAicvtUK-1687956058080)(assets/image-20200729154422765.png)]
公平锁
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B3T9tyl0-1687956058082)(assets/image-20200729154633299.png)]
非公平锁
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qswl2R25-1687956058082)(assets/image-20200729154821354.png)]
3.3.3.2 代码实现
在ReentrantLock和synchronized中,默认都为非公平锁。ReentrantLock可以通过参数将其开启使用公平锁。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qlMm4aZU-1687956058083)(assets/image-20200729160508082.png)]
1)ReentrantLock公平锁
public class FairLockTest {
//开启公平锁
private static Lock lock = new ReentrantLock(true);
public static void test(){
for (int i = 0; i < 2; i++) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"获取到锁");
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
new Thread("线程A"){
@Override
public void run() {
test();
}
}.start();
new Thread("线程B"){
@Override
public void run() {
test();
}
}.start();
}
}
根据结果可以看到,其获取锁的过程是按照公平策略来进行。
2)ReentrantLock非公平锁
只需要在实例化ReentrantLock时,不传入参数即为非公平锁。 根据执行结果可以看到,是按照非公平策略来进行锁的获取。
3.3.4 ReentrantLock与synchronized的比较
相似点:
都是以阻塞性方式进行加锁同步,也就是说如果当一个线程获得了对象锁,执行同步代码块,则其他线程要访问都要阻塞等待,直到获取锁的线程释放锁才能继续获取锁。
不同点:
- 对于Synchronized来说,它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成。
- Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
- ReenTrantLock的锁粒度和灵活度要优于Synchronized。
3.4 ReentrantReadWriteLock
对于之前学习的ReentrantLock或synchronized都可以称之为独占锁、排他锁,可以理解为是悲观锁,这些锁在同一时刻只允许一个线程进行访问。但是对于互联网应用来说,绝大多数的场景都是读多写少,比例大概在10:1。按照数据库的场景来说,对于读多写少的处理,就会进行读写分离。
在读多写少的场景下,对于业务代码的处理,此时也可以考虑进行读写分别加锁的操作,此时就可以使用ReentrantReadWriteLock。其对ReadWriteLock接口进行实现,内部会维护一对锁,分别为读锁、写锁。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yECvTndx-1687956058083)(assets/image-20200729161746584.png)]
3.4.1 读写锁特性
- 读操作不互斥,写操作互斥,读和写互斥。
- 公平性:支持公平性和非公平性。
- 重入性:支持锁重入。
- 锁降级:写锁能够降级成为读锁,遵循获取写锁、获取读锁在释放写锁的次序。读锁不能升级为写锁。
3.4.2 读写锁实现原理
ReentrantReadWriteLock实现接口ReadWriteLock,该接口维护了一对相关的锁,一个用于读操作,另一个用于写入操作。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tl9hxxu8-1687956058084)(assets/image-20200729165051019.png)]
ReadWriteLock定义了两个方法。readLock()返回用于读操作的锁,writeLock()返回用于写操作的锁。ReentrantReadWriteLock定义如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lvqNnb3J-1687956058085)(assets/image-20200729165201591.png)]
其内部的writeLock()用于获取写锁,readLock()用于获取读锁。
3.4.3 读写锁演示
读写锁的特点在于写互斥、读不互斥、读写互斥。下面就通过例子来演示具体效果:
public class ReentrantReadWriteLockDemo {
private static int count = 0;
private static class WriteDemo implements Runnable{
ReentrantReadWriteLock lock ;
public WriteDemo(ReentrantReadWriteLock lock) {
this.lock = lock;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.writeLock().lock();
count++;
System.out.println("写锁: "+count);
lock.writeLock().unlock();
}
}
}
private static class ReadDemo implements Runnable{
ReentrantReadWriteLock lock ;
public ReadDemo(ReentrantReadWriteLock lock) {
this.lock = lock;
}
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.readLock().lock();
count++;
System.out.println("读锁: "+count);
lock.readLock().unlock();
}
}
public static void main(String[] args) {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
WriteDemo writeDemo = new WriteDemo(lock);
ReadDemo readDemo = new ReadDemo(lock);
//运行多个写线程,不会重复,证明写互斥
//运行多个读线程,可能重复,证明读不互斥
//同时运行,读锁和写锁后面不会出现重复的数字,证明读写互斥
for (int i = 0; i < 3; i++) {
new Thread(writeDemo).start();
}
for (int i = 0; i < 3; i++) {
new Thread(readDemo).start();
}
}
}
3.4.4 锁降级
读写锁是支持锁降级的,但不支持锁升级。写锁可以被降级为读锁,但读锁不能被升级写锁。什么意思呢?简单来说就是获取到了写锁的线程能够再次获取到同一把锁的读锁,因为支持提到过ReentrantReadWriteLock这把锁内部是维护了两个锁的。 而获取到了读锁的线程不能再次获取同一把锁的写锁。
1)写锁降级读锁
public class LockDegradeDemo1 {
private static class Demo{
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void fun1(){
//获取写锁
lock.writeLock().lock();
System.out.println("fun1");
fun2();
lock.writeLock().unlock();
}
public void fun2(){
//获取读锁
lock.readLock().lock();
System.out.println("fun2");
lock.readLock().unlock();
}
}
public static void main(String[] args) {
new Demo().fun1();
}
}
根据执行结果可知,当一个线程获取到了写锁后,其可以继续向下来获取同一把锁的读锁。
2)读锁升级写锁
public class LockDegradeDemo2 {
private static class Demo{
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void fun1(){
//获取写锁
lock.writeLock().lock();
System.out.println("fun1");
//fun2();
lock.writeLock().unlock();
}
public void fun2(){
//获取读锁
lock.readLock().lock();
System.out.println("fun2");
fun1();
lock.readLock().unlock();
}
}
public static void main(String[] args) {
new Demo().fun2();
}
}
根据执行结果可知。当线程获取到读锁,不能继续获取写锁。
3.4.5 性能优化演示
在读多写少的情况下,通过读写锁可以优化原有的synchronized对于程序执行的性能。
public class Sku {
private String name;
private double totalMoney;//总销售额
private int storeNumber;//库存数
public Sku(String name, double totalMoney, int storeNumber) {
this.name = name;
this.totalMoney = totalMoney;
this.storeNumber = storeNumber;
}
public double getTotalMoney() {
return totalMoney;
}
public int getStoreNumber() {
return storeNumber;
}
public void changeNumber(int sellNumber){
this.totalMoney += sellNumber*25;
this.storeNumber -= sellNumber;
}
}
public interface SkuService {
//获得商品的信息
Sku getSkuInfo();
//设置商品的数量
void setNum(int number);
}
以synchronized形式运行
public class SkuServiceImplSync implements SkuService{
private Sku sku;
public SkuServiceImplSync(Sku sku) {
this.sku = sku;
}
@Override
public synchronized Sku getSkuInfo() {
try {
TimeUnit.MILLISECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
return this.sku;
}
@Override
public synchronized void setNum(int number) {
try {
TimeUnit.MILLISECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
sku.changeNumber(number);
}
}
public class SkuExec {
//读写线程的比例
static final int readWriteRatio = 10;
//最少线程数
static final int minthreadCount = 3;
//读操作
private static class ReadThread implements Runnable{
private SkuService skuService;
public ReadThread(SkuService skuService) {
this.skuService = skuService;
}
@Override
public void run() {
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
skuService.getSkuInfo();
}
System.out.println(Thread.currentThread().getName()+"读取商品数据耗时:"
+(System.currentTimeMillis()-start)+"ms");
}
}
//写操作
private static class WriteThread implements Runnable{
private SkuService skuService;
public WriteThread(SkuService skuService) {
this.skuService = skuService;
}
@Override
public void run() {
long start = System.currentTimeMillis();
Random r = new Random();
for(int i=0;i<10;i++){
//操作10次
skuService.setNum(r.nextInt(10));
}
System.out.println(Thread.currentThread().getName()
+"写商品数据耗时:"+(System.currentTimeMillis()-start)+"ms---------");
}
}
public static void main(String[] args) throws InterruptedException {
Sku sku = new Sku("computer",10000,10000);
SkuService skuService = new SkuServiceImplSync(sku);
for(int i = 0;i<minthreadCount;i++){
Thread setT = new Thread(new WriteThread(skuService));
for(int j=0;j<readWriteRatio;j++) {
Thread getT = new Thread(new ReadThread(skuService));
getT.start();
}
TimeUnit.MILLISECONDS.sleep(100);
setT.start();
}
}
}
执行结果
Thread-0写商品数据耗时:58ms---------
Thread-11写商品数据耗时:58ms---------
Thread-22写商品数据耗时:58ms---------
Thread-4读取商品数据耗时:2577ms
Thread-5读取商品数据耗时:3029ms
Thread-6读取商品数据耗时:3040ms
Thread-9读取商品数据耗时:4016ms
Thread-29读取商品数据耗时:4980ms
Thread-15读取商品数据耗时:8971ms
Thread-13读取商品数据耗时:9742ms
Thread-18读取商品数据耗时:10257ms
Thread-19读取商品数据耗时:10417ms
Thread-25读取商品数据耗时:10805ms
Thread-26读取商品数据耗时:11250ms
Thread-27读取商品数据耗时:11645ms
Thread-31读取商品数据耗时:12137ms
Thread-3读取商品数据耗时:13257ms
Thread-7读取商品数据耗时:13714ms
Thread-10读取商品数据耗时:13911ms
Thread-32读取商品数据耗时:13730ms
Thread-28读取商品数据耗时:14101ms
Thread-23读取商品数据耗时:14409ms
Thread-1读取商品数据耗时:14808ms
Thread-20读取商品数据耗时:14986ms
Thread-17读取商品数据耗时:15150ms
Thread-14读取商品数据耗时:15691ms
Thread-16读取商品数据耗时:16312ms
Thread-21读取商品数据耗时:16494ms
Thread-24读取商品数据耗时:16514ms
Thread-30读取商品数据耗时:16637ms
Thread-8读取商品数据耗时:16867ms
Thread-2读取商品数据耗时:16982ms
Thread-12读取商品数据耗时:16986ms
以ReentrantReadWriteLock形式执行
public class SkuServiceImplReen implements SkuService{
private Sku sku;
public SkuServiceImplReen(Sku sku) {
this.sku = sku;
}
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Lock readLock = lock.readLock();
private Lock writeLock = lock.writeLock();
@Override
public Sku getSkuInfo() {
readLock.lock();
try {
TimeUnit.MILLISECONDS.sleep(5);
return this.sku;
} catch (InterruptedException e) {
e.printStackTrace();
return null;
} finally {
readLock.unlock();
}
}
@Override
public void setNum(int number) {
writeLock.lock();
try {
TimeUnit.MILLISECONDS.sleep(5);
sku.changeNumber(number);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
}
}
修改启动类
SkuService skuService = new SkuServiceImplReen(sku);
执行结果
Thread-0写商品数据耗时:66ms---------
Thread-11写商品数据耗时:68ms---------
Thread-22写商品数据耗时:62ms---------
Thread-2读取商品数据耗时:765ms
Thread-8读取商品数据耗时:764ms
Thread-10读取商品数据耗时:764ms
Thread-5读取商品数据耗时:765ms
Thread-1读取商品数据耗时:765ms
Thread-4读取商品数据耗时:765ms
Thread-7读取商品数据耗时:764ms
Thread-6读取商品数据耗时:764ms
Thread-3读取商品数据耗时:765ms
Thread-9读取商品数据耗时:770ms
Thread-15读取商品数据耗时:760ms
Thread-17读取商品数据耗时:760ms
Thread-12读取商品数据耗时:760ms
Thread-13读取商品数据耗时:760ms
Thread-14读取商品数据耗时:760ms
Thread-18读取商品数据耗时:759ms
Thread-16读取商品数据耗时:760ms
Thread-20读取商品数据耗时:765ms
Thread-19读取商品数据耗时:765ms
Thread-21读取商品数据耗时:765ms
Thread-31读取商品数据耗时:704ms
Thread-28读取商品数据耗时:705ms
Thread-25读取商品数据耗时:705ms
Thread-30读取商品数据耗时:704ms
Thread-29读取商品数据耗时:705ms
Thread-27读取商品数据耗时:705ms
Thread-26读取商品数据耗时:705ms
Thread-23读取商品数据耗时:705ms
Thread-24读取商品数据耗时:705ms
Thread-32读取商品数据耗时:704ms
根据最终结果可以看出,其性能的提升是非常巨大的。
3.5 StamptedLock
StampedLock类是在JDK8引入的一把新锁,其是对原有ReentrantReadWriteLock读写锁的增强,增加了一个乐观读模式,内部提供了相关API不仅优化了读锁、写锁的访问,也可以让读锁与写锁间可以互相转换,从而更细粒度的控制并发。
3.5.1 ReentrantReadWriteLock存在的问题
在使用读写锁时,还容易出现写线程饥饿的问题。主要是因为读锁和写锁互斥。比方说:当线程 A 持有读锁读取数据时,线程 B 要获取写锁修改数据就只能到队列里排队。此时又来了线程 C 读取数据,那么线程 C 就可以获取到读锁,而要执行写操作线程 B 就要等线程 C 释放读锁。由于该场景下读操作远远大于写的操作,此时可能会有很多线程来读取数据而获取到读锁,那么要获取写锁的线程 B 就只能一直等待下去,最终导致饥饿。
对于写线程饥饿问题,可以通过公平锁进行一定程度的解决,但是它是以牺牲系统吞吐量为代价的。
3.5.2 StampedLock特点
1)获取锁的方法,会返回一个票据(stamp),当该值为0代表获取锁失败,其他值都代表成功。
2)释放锁的方法,都需要传递获取锁时返回的票据,从而控制是同一把锁。
3)StampedLock是不可重入的,如果一个线程已经持有了写锁,再去获取写锁就会造成死锁。
4)StampedLock提供了三种模式控制读写操作:写锁、悲观读锁、乐观读锁
写锁:
使用类似于ReentrantReadWriteLock,是一把独占锁,当一个线程获取该锁后,其他请求线程会阻塞等待。 对于一条数据没有线程持有写锁或悲观读锁时,才可以获取到写锁,获取成功后会返回一个票据,当释放写锁时,需要传递获取锁时得到的票据。
悲观读锁:
使用类似于ReentrantReadWriteLock,是一把共享锁,多个线程可以同时持有该锁。当一个数据没有线程获取写锁的情况下,多个线程可以同时获取到悲观读锁,当获取到后会返回一个票据,并且阻塞线程获取写锁。当释放锁时,需要传递获取锁时得到的票据。
乐观读锁:
这把锁是StampedLock新增加的。可以把它理解为是一个悲观锁的弱化版。当没有线程持有写锁时,可以获取乐观读锁,并且返回一个票据。值得注意的是,它认为在获取到乐观读锁后,数据不会发生修改,获取到乐观读锁后,其并不会阻塞写入的操作。
那这样的话,它是如何保证数据一致性的呢? 乐观读锁在获取票据时,会将需要的数据拷贝一份,在真正读取数据时,会调用StampedLock中的API,验证票据是否有效。如果在获取到票据到使用数据这期间,有线程获取到了写锁并修改数据的话,则票据就会失效。 如果验证票据有效性时,当返回true,代表票据仍有效,数据没有被修改过,则直接读取原有数据。当返回flase,代表票据失效,数据被修改过,则重新拷贝最新数据使用。
乐观读锁使用与一些很短的只读代码,它可以降低线程之间的锁竞争,从而提高系统吞吐量。但对于读锁获取数据结果必须要进行校验。
5)在StampedLock中读锁和写锁可以相互转换,而在ReentrantReadWriteLock中,写锁可以降级为读锁,而读锁不能升级为写锁。
3.5.3 使用示例
此处引用Oracle官方案例。https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/StampedLock.html
class Point {
//定义共享数据
private double x, y;
//实例化锁
private final StampedLock sl = new StampedLock();
//写锁案例
void move(double deltaX, double deltaY) {
//获取写锁
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
//释放写锁
sl.unlockWrite(stamp);
}
}
//使用乐观读锁案例
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); //获得一个乐观读锁
double currentX = x, currentY = y; //将两个字段读入本地局部变量
if (!sl.validate(stamp)) {
//检查发出乐观读锁后同时是否有其他写锁发生?
stamp = sl.readLock(); //如果有,我们再次获得一个读悲观锁
try {
currentX = x; // 将两个字段读入本地局部变量
currentY = y; // 将两个字段读入本地局部变量
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
//使用悲观读锁并锁升级案例
void moveIfAtOrigin(double newX, double newY) {
// 获取悲观读锁
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) {
//循环,检查当前状态是否符合
//锁升级,将读锁转为写锁
long ws = sl.tryConvertToWriteLock(stamp);
//确认转为写锁是否成功
if (ws != 0L) {
stamp = ws; //如果成功 替换票据
x = newX; //进行状态改变
y = newY; //进行状态改变
break;
}
else {
//如果不成功
sl.unlockRead(stamp); //显式释放读锁
stamp = sl.writeLock(); //显式直接进行写锁 然后再通过循环再试
}
}
} finally {
//释放读锁或写锁
sl.unlock(stamp);
}
}
}
4 AQS抽象队列同步器
AQS(AbstractQueuedSynchronizer),即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),JUC并发包的作者期望它能够成为实现大部分同步需求的基础。它是JUC并发包中的核心基础组件。
4.1 CLH队列锁
CLH队列锁即Craig, Landin, and Hagersten (CLH) locks。这是三个人的名字。 同时它也是现在PC机内部对于锁的实现机制。Java中的AQS就是基于CLH队列锁的一种变体实现。
CLH 队列锁也是一种基于单向链表的可扩展、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。
1)现在有一个队列,队列中的每一个QNode对应一个请求获取锁的线程,Qnode中包含两个属性,分别为myPred(前驱节点的引用)、locked(是否需要获取锁)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dl2VOn47-1687956058086)(assets/image-20200729190734068.png)]
2)当多个线程要请求获取锁时,则会按照请求顺序放入队列中。同时将myPred指向前驱节点的引用。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IIdMEXK7-1687956058093)(assets/image-20200729191742115.png)]
3)线程会对自己的myPred进行不断自旋查询,查看前驱节点是否释放锁。一旦发现前驱节点释放锁(locked属性变为false),则其会马上进行锁的获取。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ljGoaqAr-1687956058093)(assets/image-20200729191905034.png)]
4)当后续节点获取到锁后,则将原有的前驱节点从队列中移除。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h9WaXsz3-1687956058094)(assets/image-20200729191953432.png)]
4.2 AQS的设计模式
AQS本身是一个抽象类,其主要使用方式是继承,子类通过继承AQS并实现其内部定义的抽象方法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WqEUFBvI-1687956058094)(assets/image-20200729194810012.png)]
之前学习的ReentrantLock、ReentrantReadWriteLock其内部其实都是基于AQS实现的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-efIsXFSW-1687956058095)(assets/image-20200729194504217.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dQtBSNhc-1687956058095)(assets/image-20200729194532179.png)]
此时结合源码和之前的学习可知,他们两个并没有直接继承AQS,而是在其内部扩展了静态内部类来继承AQS。 这么做的原因,其思想就是通过区分使用者和实现者,来让使用者可以更加方便的完成对于锁的操作。
锁是面向使用者的,它定义了锁与使用者的交互实现方式,同时隐藏了实现细节。而AQS面向的是锁的实现者,其内部完成了锁的实现方式。从而通过区分锁和同步器让使用者和实现者能够更好的关注各自的领域。
4.3 AQS的实现思路
AQS的设计模式使用的是模版设计模式。通过源码可以看到,在AQS中其并没有对方法进行具体实现,这些方法都是需要开发者自行来实现的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JTWL6sdR-1687956058096)(assets/image-20200729214640021.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d4KwLl0P-1687956058096)(assets/image-20200729214528495.png)]
模版设计模式在开发中涉及的非常多,简单来说就是:在一个方法中定义一个流程的骨架,对于流程的具体实现让其在子类中完成。以Spring为例,其内部就大量应用到了模版设计模式,如JDBCTemplate、RedisTemplate、RabbitTemplate等等。
4.3.1 模版模式实现
//自定义模版抽象类
public abstract class AbstractCake {
protected abstract void shape();
protected abstract void apply();
protected abstract void brake();
/*模板方法*/
public final void run(){
this.shape();
this.apply();
this.brake();
}
protected boolean shouldApply(){
return true;
}
}
//自定义抽象实现类
public class CheeseCake extends AbstractCake {
@Override
protected void shape() {
System.out.println("芝士蛋糕造型");
}
@Override
protected void apply() {
System.out.println("芝士蛋糕涂抹");
}
@Override
protected void brake() {
System.out.println("芝士蛋糕烘焙");
}
}
public class CreamCake extends AbstractCake {
@Override
protected void shape() {
System.out.println("奶油蛋糕造型");
}
@Override
protected void apply() {
System.out.println("奶油蛋糕涂抹");
}
@Override
protected void brake() {
System.out.println("奶油蛋糕烘焙");
}
}
//执行类
public class MakeCake {
public static void main(String[] args) {
AbstractCake cake1 = new CheeseCake();
AbstractCake cake2 = new CreamCake();
cake1.run();
cake2.run();
}
}
根据上述实现方式可以发现,只需自定义一个抽象类,将执行流程的骨架定义好。接着可以通过实现类对其进行不同的实现。这种实现思想就是模版设计模式。
4.3.2 AQS中的模版模式
根据上述内容的讲解,其实在AQS中大量使用到了模版设计模式。查看其源码如:acquire(int arg)、release(int arg)、**acquireShared(int arg)**等等都是模版方法。
其内部的模版方法大致可以分为三类:
-
xxSharedxx:共享式获取与释放,如读锁。
-
acquire:独占式获取与释放,如写锁。
-
查询同步队列中等待线程情况。
4.3.3 AQS的同步状态
AQS对于锁的操作是通过同步状态切换来完成的,其有一个变量state,用于表示线程获取锁的状态。当state>0时表示当前已有线程获取到了资源,当state = 0时表示释放了资源。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9tI1jFqP-1687956058097)(assets/image-20200729222337298.png)]
在多线程下,一定会有多个线程来同时修改state变量,所以在AQS中也提供了一些方法能够安全的对state值进行修改。分别为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t6w8DwRq-1687956058097)(assets/image-20200729222606006.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IOtWqZRJ-1687956058097)(assets/image-20200729222623253.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8KlaafPQ-1687956058098)(assets/image-20200729222655772.png)]
4.4 AQS实现原理
4.4.1 Node节点
之前提到过AQS是基于CLH队列锁的思想来实现的,其内部不同于CLH单向链表,而是使用的是双向链表。那么对于一个队列来说,其内部一定会通过一个节点来保存线程信息,如:前驱节点、后继节点、当前线程节点、线程状态这些信息。
根据源码可知,AQS内部定义一个Node对象用于存储这些信息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-090ZYKNg-1687956058098)(assets/image-20200729231620835.png)]
两种线程等待模式:
-
SHARED:表示线程以共享模式等待锁,如读锁。
-
EXCLUSIVE:表示线程以独占模式等待锁,如写锁。
五种线程状态:
-
初始Node对象时,默认值为0。
-
CANCELLED:表现线程获取锁的请求已经取消,值为1。
-
SINNAL:表现线程已经准备就绪,等待锁空闲给我,值为-1。
-
CONDITION:表示线程等待某一个条件被满足,值为-2。
-
PROPAGETE:当线程处于SHARED模式时,该状态才会生效,用于表示线程可以被共享传播,值为-3。
五个成员变量:
-
waitStatus:表示线程在队列中的状态,值对应上述五种线程状态。
-
prev:表示当前线程的前驱节点。
-
next:表示当前线程的后继节点。
-
thread:表示当前线程。
-
nextWaiter:表示等待condition条件的节点。
同时在AQS中还存在两个成员变量,head和tail,分别代表队首节点和队尾节点
。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1fVpGCsZ-1687956058099)(assets/image-20200729233443780.png)]
4.4.2 节点在同步队列的操作
在多线程并发争抢同步状态(锁)时,按照队列的FIFO原则,AQS会将获取锁失败的线程包装为一个Node放入队列尾部中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2j4XGgNQ-1687956058099)(assets/image-20200730094716741.png)]
对于加入队列的过程需要保证线程安全,AQS提供了一个基于CAS设置尾节点的方法compareAndSetTail(Node expect,Node update)
。其需要传递当前期望的尾节点和当前节点,当返回true,当前节点才与队列中之前的尾节点建立连接。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oSJN2cHf-1687956058099)(assets/image-20200730102939636.png)]
此时可以发现头结点一定是获取锁成功的节点,头节点在释放锁时,会唤醒其后继节点。当后继节点获取锁成功后,则头节点的指针会指向该后继节点作为当前队列的头节点,接着将原先的头结点从队列中移除。
对于该流程来说,只有一个线程能够获取到同步状态,因此不需要CAS进行保证。只需要重新移动头部指针并断开原有引用连接即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3Wtu6PWG-1687956058100)(assets/image-20200730103436360.png)]