目录
本节要点
- 了解常见锁策略
- 了解
synchronized
使用的锁策略 - 理解
CAS
实现逻辑 - 了解
CAS
出现的ABA
问题,并解决 synchronized
锁的原理
常见锁策略
我们已经知道锁在我们的并发编程十分重要.那我们就需要了解,这些锁实现的策略!都有那些策略,便于我们更加深刻的理解锁!
下面介绍的几组锁策略,每一组里面都是相异的,每组策略之间又有相互关联的!
- 乐观锁 vs 悲观锁
这是程序员处理锁冲突的态度(原因),通过自己的预期而实现咋样的锁
就好比疫情:
乐观的人觉得过段时间就好了,就不会囤太多物资
悲观的人觉得紧张,就屯好多物资,做了好多工作
乐观锁
程序员在设计锁的时候,预期锁冲突概率很低
做的工作更少,付出的成本低,更高效
悲观锁
预期锁冲突概率很高
做的工作多,付出的成本高,更低效
- 互斥锁vs读写锁
互斥锁
只有多个线程对同一个对象加锁才会导致互斥
互斥锁就是普通的锁,只有加锁和解锁
读写锁
可以对读操作加锁(不存在互斥关系,可以多线程读)
对写操作加锁(只能进行写操作)
读写操作加锁(读时不能写,写时不能读)
- 轻量级锁vs重量级锁
轻量级锁
做的事情更少,开销比较小
重量级锁
做的事情更多,开销比较大
这里的轻量级锁和重量级锁和上面的悲观锁和乐观锁有所重叠
一个是设计锁的态度(原因),一个是处理锁冲突的结果!
通常情况下一般可以认为乐观锁一般都是轻量级锁,悲观锁都是重量级锁!但是不绝对!!!
是如何实现轻量和重量呢?
其实我们基于纯用户态实现的锁就是认为是轻量级锁,开销小,程序员可控!
如果是基于内核的一些功能实现(比如调用了操作系统内核的mutex接口)的锁就认为是重量级锁(操作系统的锁会在内核做好多事情,比如让线程等待…)
- 自旋锁vs挂起等待锁
这是上述轻量级锁和重量级锁的典型实现
自旋锁
往往通过纯用户态代码实现,较轻
挂起等待锁
通过内核的一些机制实现,往往较重
- 公平锁vs非公平锁
公平锁
多个线程等待一把锁时,遵循先来后到原则
非公平锁
多个线程等待一把锁时,每个线程拿到锁的机会均等!
这里就有人有疑惑了,咋的,机会均等还不公平了?
但是你换个场景想想,如果你在等待办理业务,先来不就应该先办业务嘛,就好比排队,你先来排在前面!所以这才公平嘛!!!
- 可重入锁vs不可重入锁
可重入锁
可重入锁就是对一个对象多次加锁时不会造成死锁
不可重入锁
一个对象多次加锁时会造成死锁!
synchronized使用的锁策略
我们了解了上述的多组锁策略,我们来分析一下,synchronized
用了那些锁策略!
- 自适应锁,即是乐观锁又是悲观锁
- 不是读写锁只是普通的互斥锁
- 既是一个轻量级锁又是重量级锁(根据锁竞争程度自适应)
- 轻量级锁的部分基于自旋锁实现,重量级锁基于挂起等待宿实现
- 非公平锁(锁拿到的机会均等)
- 可重入锁(加锁多次,不会导致死锁)
CAS
什么是cas?
CAS 全称是
compare and swap
,是一种用于在多线程环境下实现同步功能的机制。CAS 操作包含三个操作数 – 内存位置、预期数值和新值。CAS 的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作。
可能有点抽象,我们看下面案例!
我们假设内存中的原数据V
,旧的预期值A
,需要修改的新值B
。
- 比较
A
与V
是否相等。(比较) - 如果比较相等,将
B
写入V
。(交换) - 返回操作是否成功。
可能看到这里你还是很懵!
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
我们看这个伪代码!
通俗点讲,就是CAS
解决了多线程中多条指令进行的赋值问题!
我们之前已经了解过当我们需要对一个值进行++
在cpu
中其实要执行3条指令!
先拿到值放在寄存器,将寄存器中的值更改,然后在放回内存!
而我们知道在多线程执行写操作时,就会导致线程不安全问题!
因为++
操作并不是原子性的!
而这里的CAS
做的就是将多条指令封装成一条指令,达到原子性的效果!避免线程不安全问题!
我们的cpu
提供了一个单独的指令cas
来执行上诉代码!!!
CAS使用
CAS
可以做什么呢?
- 基于
CAS
能够实现"原子类"
java
标准库中给我们提供了一组原子类,就是将常用类(int long array …)进行了封装,可以基于CAS
进行修改,并且线程安全!
//基于CAS多线程对一个数实现自加
import java.util.concurrent.atomic.AtomicInteger;
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
//原子类
AtomicInteger atomicInteger = new AtomicInteger(0);
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000 ; i++) {
//这个方法相当于 ++num
atomicInteger.incrementAndGet();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000 ; i++) {
//这个方法相当于 ++num
atomicInteger.incrementAndGet();
}
});
t1.start();//启动线程
t2.start();
t1.join();
t2.join();//等待2个线程执行结束
System.out.println("多线程自加结果:"+atomicInteger.get());
}
}
可以看到我们基于CAS
多线程进行一个数的更改并不用加锁也能保证线程安全!!!
我们来学习一下java
原子类中的一些方法!
原子类在java.util.concurrent.atomic
包下!
构造方法,可以给初值!
实现
+=
操作
自加自减!
我们来看一下这里自加的实现逻辑!
//伪代码
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
我们分析上述伪代码!
就是说,如果内存值和寄存器中的值不相等就会循环,相等就完成CAS
交换!
假如两个线程对1进行自加!!!
看到这里应该明白了基于
CAS
实现原子类的原理!
- 基于
CAS
能够实现"自旋锁"
//伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
可以看到自旋锁的实现和原子类的实现类似!
我们来分析一下!
如果已经有线程持有了该锁对象,那么while
循环就会一直自旋,直到该锁被释放,该线程才可以拿到该锁!
if(this.owner==null)
为真,Thread.currentThread
当前线程就可以拿到该锁!否者自旋等待锁!
CAS的ABA面试问题
我们知道CAS
的实现是通过对比当前CPU
中的值和内存中的值是否相等,如果相等就采取计然后进行交换!
我们是否想过另外一种情况,就是该内存中的值改变了多次,又改回了原来那个值,显然这时内存中的值虽然和cpu
中值相等,但是该值已经进行了多次改变,并没有保证此次CAS
的原子性!
举个例子:
当有两个线程t1
和t2
这两个对象对同一块内存空间采取CAS
修改操作!
我们已经知道CAS
的原子性,t1
和t2
都能执行完成!
而如果这时有第3个线程在t1
的load
和t2
的CAS
之间将该值又更改回去,那么就出现bug
了
我们假设一种现实场景:
某一天你去ATM
取款
你的余额为1000元
然后你取款500元
你不小心多点了一次取款,但是你没有察觉到!
然后第一次你取款成功了,正常情况你第二次肯定无法取款成功!
因为我们知道CAS
会比较寄存器和内存中的值!而此时的余额已经不是1000了,该CAS
指令就无法成功执行!
但是如果在你执行第二个取款操作之前,你的朋友刚好给你转账500元!这样CAS
在比较时发现相等就会再次执行取款操作,你取500居然取出了1000,余额还有500,这就是一个BUG
!
我们用图来描述一下上述情况!
我们如何解决这个问题呢?
- 我们可以引入一个版本号
记录每次更改内存的次数,如果更改一次,版本号就加1,且版本号只能递增!!!
进行比较时只需要比较版本号即可,如果版本号相等就可以进行交换!
我们再进行上述的CAS
就不会产生bug
了!
当我们引入版本号时,每次只要比较版本号的值是否相等就可以判断内存中的值是否已经修改过,很好的解决了CAS
中的ABA
问题!
- 我们也可以用时间戳代替版本号,达到的效果一样,也可解决
ABA
问题!
synchronized原理
我们总结上面的锁策略,就可以总结出synchronized
的一些特性(JDK1.8版本)
- 自适应锁,根据锁竞争激烈程度,开始是乐观锁竞争加剧就变成悲观锁
- 开始是轻量级锁,如果锁冲突加剧,那就变成重量级锁
- 实现轻量级锁是采用自旋锁策略,重量级锁采用挂起等待锁策略
- 是普通的互斥锁
- 可重入锁
加锁过程
synchronized是如何做到自适应过程的呢?
JVM
将synchronized
锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
我们用生活中的例子便于理解锁的变化!
好比一个男生喜欢上了一个女生(漂亮又对男生超好)
但是这个男生比较渣,不想和她纠缠,如果确认了关系,他就要要放弃一片森林了,但是他又想谈恋爱! 所以他选择和那个女生搞暧昧不确立关系(偏向锁)就是说避免了确立关系和分手的纠缠(避免了加锁解锁的开销),过了一段时间有一个其他的男生追这个女生,此时如果这个男生再不确立关系,就有可能失去女生,所以他马上和女生确立关系(自旋锁)…
synchronized锁的优化操作
- 锁膨胀/锁升级
体现了synchronized
锁的自适应能力,根据锁的竞争激励程度自动升级锁
- 锁粗化/锁细化
这里的粗细指的是加锁的粒度,换句话说就是加锁代码的范围,范围越大,加锁的粒度越大,锁粗化!
编译器会根据你写的代码进行优化策略,在不改变代码逻辑的情况下,使代码效率更高!
Thread t1 = new Thread(()->{
Object locker = new Object();
int num = 0;
synchronized (locker){
//针对一整个循环加锁,粒度大
for (int i = 0; i < 10; i++) {
num++;
}
}
});
Thread t2 = new Thread(()->{
Object locker = new Object();
int num = 0;
for (int i = 0; i < 10; i++) {
synchronized (locker) {
//每次循环加锁,粒度小
num++;
}
}
});
- 锁消除
顾名思义,锁消除就是将锁给去掉!
有时候加锁操作并没有起到作用,编译器就会将该锁去掉,提供代码效率!
比如我们知道Vector
和Stringbuffer
类的关键方法都进行了加锁操作,如果在单线程代码使用这两个类,编译器就会对代码进行优化,进行锁消除!
java中的JUC
啥是JUC?
java.util.concurrent
这个包简化为JUC
这个包下有很多java多线程并发编程的接口和类!
我们来了解一下其他的一些重要的类和接口!
Callable
Callable
是一个接口,创建线程的一中方法
我们就疑惑了,不是已经有Runnable
了嘛,Callable
实现的对象可以返回结果,而Runnable
取不方便!
例如我们要实现1到100的相加,Runnable
就会比较麻烦,而我们通过Callable
就比较方便!
//Runnable方式实现
public class Demo2 {
static class Result {
//辅助类保存结果
public int sum = 0;
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while (result.sum == 0) {
result.lock.wait();
}
System.out.println(result.sum);
}
}
}
显然这个代码有点麻烦,还需要借助一个辅助类才能实现!
//Callable方式实现
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo3 {
public static void main(String[] args) {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 1; i <= 100; i++) {
result+=i;
}
return result;
}
}; //callable描述了这个任务!(你去点餐)
//辅助的类将callable任务标记,便于执行线程!(给了小票,区分谁的食物)
FutureTask<Integer> task = new FutureTask<>(callable);
Thread t1 = new Thread(task);//执行线程任务!(给你做好了)
try {
int result = task.get(); //获取到结果(凭小票取餐)
System.out.println("计算结果:"+result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
我们通过Callable
接口实现时需要注意一些细节,我们要通过FutureTask
对象将Callable
传入标记,便于后面拿值(task.get()
)
ReentrantLock
可重入锁
我们知道synchronized
也是可重入锁!这个有什么过人之处呢?
public class Demo4 {
public static void main(String[] args) {
//一个参数的构造方法 true 为公平锁 false 非公平锁 默认非公平锁
ReentrantLock lock = new ReentrantLock(true);
//加锁
lock.lock();
//解锁
lock.unlock();
}
}
我们可以看到ReentrantLock
将加锁和解锁分开操作!
其实分开的做法并不好,有时候可能会忘记解锁(lock,unlock()
)就会使线程造成阻塞!
- 和
synchronized
锁的区别
synchronized
是一个关键字(背后逻辑是JVM,由C++实现),Callable
是一个接口(背后逻辑由java代码编写)synchronized
不需要手动释放锁操作,出了代码块,锁自动释放,而ReentrantLock
必须手动释放锁!synchronized
是一个非公平锁,ReentrantLock
提供了公平锁和非公平两个版本,供选择!synchronized
锁竞争失败就会进行阻塞等待,而ReentrantLock
除了阻塞等待外还提供了trylock
失败直接返回- 基于
synchronized
的等待机制是wait
和notify
功能相对有限,而ReentrantLock
等待机制提供了Condition
类功能强大
其实在日常开发synchronized
功能就够用了!
semaphore
信号量
一个更广义的锁!
锁是信号量的一种为"二元信号量"
举个生活中的例子:
你去停车场停车:
当你到门口你可以看到有个牌子写了当前还剩多少车位!
进去一辆车 车位就减一
出来一辆车 车位就加一
如果当前车位为0 就阻塞等待!
这个标识多少车位牌子(描述可用资源的个数)就是信号量
每次申请一个资源 计数器就-1
(称为p操作)
每次释放一个资源 计数器就+1
(称为v操作)
资源为0阻塞等待!
锁是特殊的"二元信号量" 只有0或1标识资源个数!
信号量就是把锁推广到一般情况,可用资源更多的时候,如何处理
一般很少用到!
import java.util.concurrent.Semaphore;
public class Demo5 {
public static void main(String[] args) throws InterruptedException {
//创建一个可用资源个数为3的信号量
Semaphore semaphore = new Semaphore(3);
//p操作 申请信号量
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
//v操作 释放信号量
semaphore.release();
System.out.println("释放成功");
}
}
我们只有3个资源,而却申请了4次,那么第4次就会进行阻塞等待!
CountDownLatch
重点线,这里怎么解释呢!
就好比一场跑步比赛,裁判要等到所有人越过终点线,比赛才算结束!
这样的场景在开放中也很常见!
例如多线程下载!
比如迅雷等 都是将一个文件分给多个线程同时下载,当所有线程下载完毕,才算下载完成!
import java.util.concurrent.CountDownLatch;
public class Demo6 {
public static void main(String[] args) throws InterruptedException {
//5个线程!
CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i <5; i++) {
Thread t1 = new Thread(()->{
try {
Thread.sleep(300);
//获取该线程名
System.out.println(Thread.currentThread().getName());
latch.countDown();//该任务执行
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
}
latch.await(); //等待所有线程执行结束
System.out.println("比赛结束");
}
}
CopyOnWriteArrayList
写时拷贝
当我们在多线程环境下使用ArrayList
时
读操作并不会导致线程不安全!
但是写操作就可能出现线程不安全问题!
当多个线程进行多写操作时,显然就线程不安全,会有脏读问题!
我们可以自己加锁操作!
我们java
提供了多线程环境下使用的ArrayList
CopyOnWriteArrayList
-
当多线程去写操作时,我们的
ArrayList
会创建一个副本进行写操作!当写操作完成后再更新数据! 就不会出现数据修改一般的情况! -
当
ArrayList
扩容时,也会慢慢搬运,不会一致性将ArrayList
直接拷贝,导致操作卡顿!
多线程下使用hash表(常考)
HashMap
线程不安全!
HashTable
[不推荐]
因为HashTable
就是将关键方法进行synchronized
加锁!
也就相当于直接给HashTable
加锁,效率很低!
无论进行什么操作都会导致锁竞争!
ConcurrentHashMap
[推荐]
HashTable
对象加锁
就好比一个公司里的员工需要请假,都要向老板请假才有用!
而老板就相当于锁,如果有很多员工请假就会导致锁竞争激烈,线程阻塞!
解决方案:
老板权利下放!
让每个部门的人向部门管理人员请假!
我们知道哈希表的结构是由数组,数组元素是链表!
并且链表长度相对短! 所以锁冲突就很小!!!
ConcurrentHashMap
优点
- 针对读操作不进行加锁,只对写操作加锁
- 减少锁冲突,在每个表头加锁
- 广泛使用
CAS
操作,进一步提高效率(比如维护size操作) - 进行扩容巧妙的化整为零,进行了优化