目录
一、乐观锁&悲观锁
二、公平锁&非公平锁
三、可重入锁&非可重入锁
四、读写锁&互斥锁
互斥锁
读写锁
读写锁涉及的类:ReentrantReadWriteLock
读写锁的优势:
五、轻量级锁&重量级锁
六、CAS
①基于CAS实现原子类
下面,来一段CAS的伪代码,来解释一下,为什么上述的过程没有出现线程安全问题:
②基于CAS实现自旋锁(实现一个类:SpainLock/伪代码)。
属性:需要一个线程的引用,来确保是哪一个线程加锁的
lock方法:
Java当中的自旋锁
unlock方法
ABA问题
解决ABA问题
一、乐观锁&悲观锁
判定一个锁是悲观锁还是乐观锁,主要还是站在锁冲突(锁竞争)发生的概率预测上面来进行判断的。
对于乐观锁:
乐观锁默认锁竞争的情况不那么激烈
我们熟悉的ConcurrentHashmap,它使用的就是乐观锁的策略。只有当存在线程安全问题的时候,才会采用加锁的方式。
而与它相反的,Hashtable,采用的就是悲观锁的处理方式,无论是否发生线程安全问题,都需要加锁。
二、公平锁&非公平锁
给定一个场景:
此时,三个线程t1,t2,t3同时竞争一把锁
t1如果最先获取到锁,那么接下来在t1释放锁之前,t2,t3必须要阻塞等待t1释放锁。
如图:
可以看到,虽然t2,t3线程都没有获取到锁,但是t2比t3先开始阻塞等待。
那么,当t1释放锁之后,t2和t3线程哪个优先可以获取到锁呢?这个就涉及到公平锁和非公平锁的差别。
对于公平锁:会优先让t2线程获取到锁,也就是让最开始进行阻塞等待的线程优先获取到锁。
对于非公平锁: 在t1释放锁之后,其余阻塞等待的线程都会继续重新竞争锁,不存在谁最先获取到锁的情况。
其中,synchronized是非公平锁,ReentranctLock可以实现公平&非公平两种策略。
对于公平锁来说,因为它需要确保其他线程都按照顺序再次获取锁。
因此,公平锁需要一个队列来记录阻塞等待线程的等待顺序。
三、可重入锁&非可重入锁
也在这篇文章当中介绍了。
synchronized是可重入的
(1条消息) Java对于synchronized的初步认识_革凡成圣211的博客-CSDN博客https://blog.csdn.net/weixin_56738054/article/details/128062475?spm=1001.2014.3001.5502
四、读写锁&互斥锁
互斥锁
典型代表就是synchronized。
提供了加锁、解锁两个操作。
当一个线程获取到锁之后,其他的线程如果想加锁,那就会造成"阻塞等待"。无论其他线程对于加锁代码块的操作是否产生线程安全问题,都会产生阻塞等待。
synchronized是典型的互斥锁
关于synchronized的介绍,也已经在这一篇文章当中提及了:
(1条消息) Java对于synchronized的初步认识_革凡成圣211的博客-CSDN博客https://blog.csdn.net/weixin_56738054/article/details/128062475?spm=1001.2014.3001.5502
读写锁
有关线程安全问题的分析,我们在上一篇文章当中也提到了。
再简单回顾一下:如果多个线程同时针对某一变量进行修改操作,那么就会发生线程安全问题。此处的修改可以理解为"写"操作。
但是,如果在多个线程仅仅只是读取某一个变量的值,不对这个变量进行修改,那么不存在线程安全问题。
读写锁涉及的类:ReentrantReadWriteLock
这个类当中,同时又提供了两个静态内部类:
第一个是:ReentrantReadWriteLock.ReadLock
这表示一个读锁,提供了lock,unlock两个方法,一个代表加锁,另外一个代表解锁。
第二个是:ReentrantReadWriteLock.WriteLock writeLock
这表示一个写锁,也提供了lock,unlock两个方法。
读写锁的优势:
而读写锁,恰好就是针对"写"这个操作来作文章。给"写",也就是修改的操作加锁,不给"读"的操作加锁。
也就是:
读加锁和读加锁之间,不会产生锁冲突;
读加锁和写加锁之间,会产生锁冲突;
写加锁和写加锁之间,会产生锁冲突;
所以:
如果代码当中的操作仅仅是读操作,那么只加读锁即可。
如果代码当中的操作涉及写,那么加写锁即可。
以看到,由于减小了读、读的锁冲突,相比于互斥锁,有效减少了锁的粒度 。
五、轻量级锁&重量级锁
对于synchronized,它的轻量级锁,是基于自旋锁的方式实现的。
对于synchronized,它的重量级锁,是基于挂起等待锁的方式实现的。
下一篇文章,将详细介绍。
六、CAS
含义:cas的全称为:compare and swap
是并发编程当中一种常用的算法,它包含了下面三个参数:V,A,B。
V表示要读写的内存位置,A表示旧的预期值(可以理解为原来的值),B表示一个新值。
给定一个场景:
如图,CPU寄存器当中有两个变量A=10,B=20,内存当中有一个地址V,里面保存了一个10。
当内存当中的值(&V)和旧的值(A)相同的时候,把新的值(B)写入到内存当中。
伪代码: 其中address代表V的地址,A代表预期值(expectVal),也就是改动之前的值。
B代表新的(swapvalue)值。
boolean CAS(address, expectVal,swapvalue){
if(&address==expectedvalue){
&address=swapvalue;
return true;
}
return false;
}
以上看似简单的几行代码,也有几个比较特殊的地方:
第一个特殊的地方:CAS这一系列代码是一条CPU指令。
那么,也就意味着:CAS操作是原子的。也就意味着:比较和交换的整个过程是原子的。
也就意味着,一个线程在CAS的时候,另外的线程无法进入CAS的代码块。
①基于CAS实现原子类
还是之前的文章当中提到的场景,让两个线程,分别对一个变量count各自自增50W次的场景。
根据之前的知识,可以判定,上述代码是存在线程安全问题的,因为count++这个操作不是原子性的。
但是,如果把上述代码改成这样,引入了原子类(AtomicInteger)呢?
运行结果:可以看到这个时候,没有发生线程安全问题。
下面,来一段CAS的伪代码,来解释一下,为什么上述的过程没有出现线程安全问题:
class AtomicInteger{
/**
* val可以理解为V,也就是原来的内存当中的值
*/
private int val;
public int getAndIncrement(){
//读取旧的值,也就是原来内存当中的值
int oldVal=val;
//oldValue为一个预期的值(A)
//oldValue+1为目标值(B)
//比较,如果oldValue的值和原来内存当中的值相同,那么就需要更改内存当中
//val的值,然后返回true
while (CAS(val,oldVal,oldVal+1)!=true){
oldVal=val;
}
return oldVal;
}
}
第一步:使用一个oldValue变量,来保存原来的内存当中的值value。此处的oldValue可以理解为寄存器当中的值,线程工作内存当中的值,也就是预期值。
第二步:已经进入到CAS方法当中了:
判断内存当中的值val是否和寄存器当中的值相同,也就是val和oldValue的值是否相同。
这里有可能有个疑问:oldValue不是刚刚由val赋值过去的吗?但是为什么二者还会出现不相同的情况呢?
正常情况下面,oldValue的值应该是和内存当中的val相同的。如果相同的话,那么就把oldValue的值+1,然后赋值给val。(下划线部分的内容,都是在CAS方法内部完成的)
然后,CAS方法返回true,不进入while循环。
但是,完全有可能在一个线程(thread1)进入了getAndIncrement()方法当中之后,被切出了CPU内核
此时,另外一个线程(thread2)修改了val的值,也就是原来内存当中的值。
然后,当thread1重新回到cpu内核上面的时候,发现,val已经不再是自己的工作内存当中的oldValue了。
这个时候,由于val和oldValue的值不相同,因此CAS方法返回false,进入while循环当中,也就是CAS不成功,继续进行load。
下面,模拟一下两个线程被cpu进行调度的过程。
时间轴 | thread1 | thread2 |
t1 | load:oldValue=val | |
t2 | load:oldValue=val | |
t3 | CAS(val,oldValue,oldVal+1) 返回true,实现increment | |
t4 | 返回自增过后的value | |
t5 | CAS(val,oldValue,oldValue+1) 因为val和oldValue不相同,因此返回false,进入while循环,重新比较,直到CAS的返回值为true。 | |
t6 | CAS自增成功之后,返回true。 |
可以看到,上述的过程当中,没有涉及到任何阻塞等待的情况。
但是,出现了反复比较的情况。但是,只要不CAS成功,那么线程就会一直CAS,因此这种实现线程安全的方式也存在效率的问题。
②基于CAS实现自旋锁(实现一个类:SpainLock/伪代码)。
自旋锁不涉及任何的阻塞等待的操作。
属性:需要一个线程的引用,来确保是哪一个线程加锁的
/**
* 记录哪一个线程尝试获取这个自旋锁
*/
private Thread threadOwner=null;
lock方法:
/**
* 加锁
*/
public void lock(){
while (CAS(this.threadOwner,null,Thread.currentThread())!=true){
}
}
以上CAS的工作过程,是这样的:
首先,判断threadOwner是否为null。如果为null,那么说明此时自旋锁还没有被占用。
可以把当前线程,也就是Thread.currentThread()赋值给threadOwner。这个时候,threadOwner就已经被赋值了。
当其他线程再次CAS的时候,就会因为threadOwner!=null,因此就不会产生赋值,因此返回false,重新进入while循环进行CAS判断
时间轴 | 线程 |
t1 | 进入lock方法 |
t2 | 进行CAS操作:threadOwner,null,Thread.currentThread() 说明: 如果threadOwner为null,说明这把锁没有被占有,当前线程(t1)可以占用这把锁,因此把当前线程的引用赋值给threadOwner 此时,如果其他线程(t2)过来,那么就会因为threadOwner不为null而进入循环,反复比较,直到二者相同。 |
Java当中的自旋锁
ReentrantLock就是一个典型的自旋锁。从上述的代码也可以看出来,线程没有发生阻塞等待的情况,也就是自旋锁不会造成阻塞等待的情况。
unlock方法
让threadOwner重新变为null。
这样,其他线程再次CAS的时候,threadOwner就不再是null了,不用进入CAS。
public void unlock(){
threadOwner=null;
}
ABA问题
之前提到了,对于CAS指令:
可以看到,在if语句当中,对于&address和expectedvalue两个值,也就是原来内存当中的值和期待的值,当这两个值相同的时候,才会把内存当中的值切换为swapvalue,也就是目标值。
但是,对于判断相等这个操作,很有可能是这样的一种场景:
当线程thread把value加载到自己的工作内存之后,被CPU调度离开了操作系统内核。
调度离开之后,其他线程(thread1),有可能对于原来内存当中的值(val),也进行了一次修改(变为val1),然后又改回了(val)。这个时候,线程thread再次读取ovalue的值,读取到的值虽然是和线程工作内存当中的值:value一样。但是,已经有其他线程对于原来的value更改过了。
总结一下:ABA:
A:原来线程内存当中的值
B:其他线程更改过后的值
A:另外的线程又把B改变回来的值--A
解决ABA问题
对于原来内存当中的val值,同时添加一个版本号来修饰。
初始的时候,版本号为1,当有线程对于这个值进行修改的时候,让版本号+1。
后续判断&address的expectValue是否相等的操作改变为判断版本号是否相同
这样,就让A和B的版本号不一样,B和该回去的A的版本号又不一样,也就避免了ABA问题。