【多线程】锁策略与SCA(Compare and swap)

1. 乐观锁与悲观锁

乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,所以加锁的时候就不会做太多的工作,加锁的速度更快,但是可能会引入一些其他的问题。
悲观锁:在加锁之前,预估当前出现锁冲突的概率比较大,所以加锁的时候就会做很多工作,加锁的速度就变慢,但是不容易出现一些问题。

就像是疫情期间,可能会动不动封小区,但是我和我妈都有不同的判断。
而我认为没那么邪乎,,接着每天吃喝玩乐,认为没必要那么担心。
我妈认为事态比较紧急,认为完全会封小区,所以会提前准备很多物资,做很多很多准备。

2. 轻量级锁和重量级锁

轻量级锁:加锁的速度快,开销比较小 ----> 一般都是乐观锁。
重量级锁:枷锁的速度慢,开销比较大 ----> 一般就是悲观锁。

轻量级、重量级锁和乐观、悲观锁有什么区别
其实是一样的,但是是站在两种不同的角度
乐观和悲观是未加锁之前,对未发生的事情进行评估
轻量重量是对加锁之后,对结果进行的评价

3. 自旋锁和挂起等待锁

自旋锁就是轻量级锁的一种典型实现。加锁的时候,搭配上循环,如果成功拿到锁,循环结束,如果拿不到锁,不会阻塞放弃cpu,而是再次循环尝试加锁。
挂起等待锁就是重量级锁的一种实现。在加锁不成功的时候先主动放弃,进入阻塞等待,让出cpu去做一些别的事情。等到有机会再去参与锁竞争,但是阻塞等待的时间可能是未知数。好处就是可以在阻塞的过程中把cpu资源让出来做别的事情。

有一天我和女神表白,但是被发了张好人卡,我应该怎么做?
1.继续当舔狗,如此循环往复。她如果分手了我就能第一时间掌握情报,发动恋爱攻势,每天暧昧不清,但是这样我就没有心思做别的事情,一门心思的扑在女神上面,耗时耗力。
2.先暂时放弃追女神,专注学习多线程,争取拿个好offer。过了一段时间我听说女神又分手了,这时候我心里渴望爱情的火苗又熊熊燃烧,再次尝试对女神发动恋爱攻势。
第一种就是自旋锁,刚一释放锁,逮住机会进行加锁,很大概率可以成功,但是会过多消耗cpu。第二种就是挂起等待锁,先放弃锁竞争,进入阻塞,但是一旦进入阻塞,什么时候去cpu上调度就是个未知数了,可能中间已经有好多线程进行了加锁。但是好处就是可以让出cpu去做一些其他的事情。

4. 读写锁

读写锁就是把加锁分成两种情况:读加锁和写加锁。
如果一个线程读,另一个线程也只是读,就不会存在线程安全问题
如果一个线程写,另一个线程无论是读还是写都会可能存在线程安全问题

如果两个或者多个线程都是加读锁,此时不会产生锁冲突;
两个或者多个线程加写锁,此时会产生锁冲突;
如果两个线程一个线程是写锁,一个线程是读锁,此时会产生锁冲突。
读写锁也是操作系统内置的锁,在Java中对此进行了封装,类名叫ReentrantReadWriteLock,这个类里面包含两个内部类

5. 公平锁和非公平锁

示例:
当女神和现任男友分手之后,谁来上位?有两种方案。
1:按照先来后到的顺序,追求女神时间更久的老铁先上位
2:所有老铁上位的概率都一样,各凭本事竞争
对于计算机来说,约定好方案1是公平的

按照先来后到的方式进行加锁,就是公平锁
而对于机会均等的方式进行加锁,就是非公平锁

synchronized属于非公平锁,n个线程竞争同一个锁,其中一个线程拿到锁,等到这个线程释放锁之后,剩下的n-1个线程需要重新竞争各凭本事拿到锁,另外操作系统的内核针对锁的处理也是如此。如果需要使用非公平锁,直接使用系统原生的锁即可(synchronized),如果需要使用公平锁,可以利用优先级队列,记录等待时间,让等待久的线程先拿到锁。

6. 可重入锁和不可重入锁

一个线程针对这一把锁,连续加锁两次,不会出现死锁,那么就是可重入锁。如果出现了死锁,那就是不可重入锁。

可重入锁的特点:
1)记录当前是哪个线程持有这把锁
2)加锁的时候会判断申请加锁的线程是不是记录好的持有这把锁的线程
3)会有一个计数器,记录加锁的次数,从而确定何时释放锁

7. synchronized锁

synchronized锁具有的策略:

乐观锁/悲观锁自适应
轻量级锁/重量级锁自适应
自旋锁/挂起等待锁自适应
不是读写锁
非公平锁
可重入锁

Linux 提供的mutex锁(系统原生的锁)具有的策略:

悲观锁
重量级锁
挂起等待锁
不是读写锁
非公平锁
不可重入锁

7.1 锁升级

synchronized加锁过程:

开始使用synchronized加锁的时候会处于**"偏向锁"状态,在遇到锁竞争的时候,“偏向锁"会升级为轻量级锁**,此处的轻量级锁就是通过自旋锁实现的。
如果线程一旦增多大量的线程都在自旋,CPU的消耗就会很大了,于是就会升级为重量级锁,此处的重量级锁就是通过挂起等待锁实现的。此时拿不到锁的线程就不会自旋了,而是进入"阻塞等待”,就会让出CPU了。

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
理解:就相当于男女之间搞暧昧,如果没有男生来竞争,那么就一直保持着朋友之上,恋人未满的关系。如果有人来竞争,就立即官宣。如果没人竞争,你又喜欢上别人,那么也不会向情侣分手那么复杂,毕竟我们只是朋友。
在这里插入图片描述

7.1.1 偏向锁阶段

核心思想:能不加锁,就不加锁,能晚加锁,就晚加锁。
所谓“偏向锁”,并非真的加锁了,而只是做了一个非常轻量的标记。

一旦有其他线程来和我竞争这个锁,那我就在另一个线程之前,先把锁获取到,此时就从偏向锁升级到轻量级锁了(真正的加锁了,就有互斥了)
此时就是非必要不加锁。在遇到竞争的情况下,偏向锁是没有提高效率的。但是如果是在没有遇到其他线程竞争锁的情况下,偏向锁就大幅度地提高了效率,所以这个偏向锁还是很有意义的。
偏向锁标记是对象头里面的一个属性,每个锁对象都有自己的这个标记。当这个锁对象首次被加锁的时候,是先进入偏向锁。如果这个过程中,没有涉及到锁竞争,下次加锁还是先进入偏向锁。一旦这个过程中升级成了轻量级锁了,后续再针对这个对象加锁,都是轻量级锁(跳过了偏向锁,不可逆)。

7.1.2 轻量级锁阶段

假设有锁竞争,但是竞争的锁不多,此处就是通过自旋锁的方式来实现,反复快速地去循环。

优势:另外的线程把锁释放了,就会第一时间拿到锁
劣势: 比较消耗CPU

与此同时: synchronized内部也会统计,当前的这个锁对象上,有多少个线程在参与竞争。
如果发现参与竞争的线程比较多了,就会进一步升级到重量级锁。
对于自旋锁来说:如果同一个锁竞争者很多,大量的线程都在自旋,整体CPU的消耗太大,此时就会去升级为重量级锁。

7.1.3 重量级锁阶段

重量级锁就是挂起等待锁。
此时拿不到锁的线程就不会继续自旋了,而是进入 “阻塞等待”,就会让出CPU(不会使得CPU的占用率太高)。当前线程释放锁了之后的时候,就由系统随机唤醒一个线程来获取锁了。

到底多少个线程才算多:这个要去看JVM的源码,在源码中搞一个配置项,作为 “阈值”。发现超过阈值,就会升级为重量级锁
大家要关注的是策略,而不是参数:参数是可以随时调整的,策略是通用的
此处的锁只能升级,不能降级,自适应这个词,严格来说不算很严谨

7.2 锁消除

锁消除是synchronized中内置的优化策略,编译器编译代码的时候,如果发现这个代码不需要加锁,就会自动把锁给干掉。

优化策略很保守,比如说加锁代码中,没有涉及到“成员变量的修改”,就只是一些局部变量,都是不需要加锁的,其他很多模棱两可的代码,编译器也不知道这里是加锁还是不加锁,所以编译器都不会去消除。

所以这个锁消除是针对那种一眼看上去完全不涉及到线程安全问题的代码,能够把锁消除掉。
偏向锁是需要运行起来才知道有没有冲突。

7.2 锁粗化

锁粗化会把多个细粒度的锁合并为一个粗粒度的锁,优化合并成一个粗粒度的锁,这样可以缩短等待时间。

synchronized{}中的这个大括号里面的代码越少,就认为锁的粒度越细
这个大括号里面的代码越多,就认为这个锁的粒度越粗

一般来说,还是让这个锁的粒度更细一点最好,更有利于多个线程并发执行。但是有的时候,是希望这个锁的粒度粗点也好。
锁粗化提高了效率,因为每次加锁都可能会涉及到阻塞。
在这里插入图片描述
synchronized 这个锁的背后涉及到了很多的“优化手段”

锁升级:偏向锁 -> 轻量级锁 -> 重量级锁
锁消除:自动干掉不必要的锁
锁粗化:把多个细粒度的锁合并为一个粗粒度的锁,减少锁的竞争的开销

上面的这些机制是在内部,在看不到的地方默默发挥作用

7. CSA

7.1 定义

CAS: 全称Compare and swap(比较并交换),比较交换的是内存和寄存器。
CAS的操作:

假设内存中的原数据V,旧的预期值A,需要修改的新值B。
比较 A 与 V 是否相等。(比较)
如果比较相等,将 B 写入 V。(交换)
返回操作是否成功

7.2 伪代码

伪代码:

boolean CAS(address, expectValue, swapValue) {
	if (&address == expectedValue) {
		&address = swapValue;
	return true;
	}
	return false;
}

什么是CAS?
如果有一个内存M,还有两个寄存器A和B,那么CAS(M,A,B),就表示为:

如果M和A的值相同的话,就把M和B的值进行交换,整个操作返回一个true
如果M和A的值不同的话,无事发生,同是返回一个false

7.3 作用

CAS 的作用是很大,CAS不是一个方法,而是一个CPU指令,一个CPU指令就可以将比较和交换全部搞定,即单个CPU指令是原子的,而且这个CAS可以代替加锁操作去解决线程安全问题。

解决线程安全问题一般都需要进行加锁操作,一旦加锁,就涉及到线程之间的阻塞等待,此时代码的效率就被降低了。但是使用CAS不仅可以解决线程安全问题就可,而且还可以不用加锁,避免阻塞等待,提高效率。

使用这个CAS这个CPU指令来解决线程安全问题,不用进行加锁,此时就叫做无锁编程

弊端:

使得代码变得更加复杂,难理解
CAS只适用于特定的场景,没有加锁操作的普适性

CAS的本质就是一个CPU指令,经过操作系统封装之后,提供了一个API ,然后再经过JVM进行封装之后,提供了一个API,此时我们才可以去使用这个CAS。

7.4 应用场景

使用CAS指令来实现原子类
使用CAS指令来实现自旋锁

7.4.1 CAS指令实现原子类

原子类的定义:

使用CAS指令实现的类,可以取代原有的类型,完成自增自减的操作。比如AtomicInteger这个类就可以代替int类型完成 int 类型的自增操作。

原子类的目的:

原本int类型的count++操作不是原子的,是分为三个步骤的(load,add,save),但是我们的原子类就可以将这个操作封装为原子的,从而解决线程安全问题。

示例线程安全问题代码:

public class Demo {  
    public static int count = 0;  
    public static void main(String[] args) throws InterruptedException {  
         Thread  t1 = new Thread(() ->{  
            for(int i = 0;i < 5000; i++){  
                count++;  
            }  
        });  
        Thread t2 = new Thread(() ->{  
            for(int i = 0; i < 5000; i++){  
                count++;  
            }  
        });  
        t1.start();  
        t2.start();  
        t1.join();  
        t2.join();  
        System.out.println(count);  
    }  
}

理想情况下count的值应该是10000,但是实际上并不是的。
不加锁,通过CAS实现的原子类来解决这个线程安全问题,使用CAS指令实现的原子类AtomicTnteger来解决这个线程安全问题,使用AtomicInteger这个原子类将自增操作封装为原子类。

public class Demo {  
	//将count使用原子类来初始化,count初始化的值为0
    public static AtomicInteger count = new AtomicInteger(0);  
    public static void main(String[] args) throws InterruptedException {  
         Thread  t1 = new Thread(() ->{  
            for(int i = 0;i < 5000; i++){  
                count.getAndIncrement();//相当于count++  
            }  
        });  
        Thread t2 = new Thread(() ->{  
            for(int i = 0; i < 5000; i++){  
                count.getAndIncrement();//相当于count++ 
            }  
        });  
        t1.start();  
        t2.start();  
        t1.join();  
        t2.join();  
        System.out.println(count.get());//相当于打印count结果  
    }  
}

通过CAS指令实现的原子类之后,我们这里的自增操作就保证了在自增的过程中不会有其他的线程穿插进来执行,就也就保证了线程安全。
CAS指令是通过重试的方式让自增操作不会被其他线程穿插进来,而加锁操作是通过让线程等待的方式让自增操作不会被其他线程穿插进来,两者使用了不同的方式都保证了线程安全。
CAS指令是通过判定值是否有发生变化来作为是否有其他线程穿插进来的依据。

7.4.2 CAS指令实现自旋锁

public class SpinLock{
	//用来记录锁被哪一个线程获取
	//null -> 没有获取到锁 ,  非 null -> 已经获取到锁了 
	private Thread owner = null;
	public void lock(){
		//通过CAS看当前锁是否被某个线程持有
		//若锁已被别的线程持有,就自旋等待
		//若锁没有被别的线程持有,就把owner设置为当前尝试加锁
		while(!CAS(this.owner,null,Thread.currentThread())){
		
		}
	}
	public void unlock(){
		this.owner = null;
	}
}

基于CAS指令的多线程编程技巧是很重要的,而且我们在开发中会经常使用被CAS封装好的操作。

7.5 CAS的问题:ABA问题

CAS指令操作的关键就是通过值是否发生变化来判断是否有其他的线程穿插执行。

这个判定方式是不严谨的,在很极端的情况之下:

其他的线程已经穿插执行了,把值从A 修改为B 之后又重新修改为A了,值已经发生了变化,只是在发生了变化之后,有变回了A而已 ,值 :A -> B -> A

虽然已经被其他的线程穿插了,但是最后的结果的值是没有发生变化的,所以一般不会出现bug。
ABA问题一般不会出现bug,但是也有极端的情况出现:

当我打算去银行取钱的时候,我的账户上有1000元,我打算取出500元
当我点击取款500的按钮之后,还没有反应,此时我有点击了一次取款500元的按钮
此时就会有两个线程同时去并发执行取款500元的操作

使用CAS指令之后,虽然在最后也可以保证只取款了500元的,但是如果在最后的时候又来了一个线程,操作是给我的账户上充值了500元,此时由于是M和A是相同的,所以我最后的那两个取款线程都执行成功了,导致我最后取款出来了500元,但是我的账户上损失了1000元,此时就出现了bug。

如何解决ABA问题?

约定好数据的变化是单向的(只能够增加/减少),数据的变化不可以是双向的(既能够增加又能够减少)
对于本身就必须双向变化的数据,可以引入一个版本号,版本号这个数字只能增加,不能减少

以上的解决思路不仅仅限于ABA问题,还可以在分布式系统中出现,CAS本质上是在JVM中的封装。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值