锁策略以及CAS和Synchronized的优化过程

目录

锁策略(locking strategy)

乐观锁/悲观锁

悲观锁:

乐观锁:

读写锁(readers-write lock)

重量级锁 VS 轻量级锁

自旋锁(Spin Lock)

可重入锁VS不可重入锁

synchronized 特点:

CAS

什么是CAS

CAS是怎么实现的

CAS有哪些应用

实现原子类

注意:

实现自旋锁

CAS的ABA问题

什么是ABA问题

解决方案

Synchronized

每日一言


锁策略(locking strategy)

乐观锁/悲观锁

锁的实现,预测接下来锁冲突概率是大,还是不大,根据这个冲突的概率,来决定接下来该怎么做

锁冲突:就是锁竞争,两个线程针对一个对象加锁,产生了阻塞等待

悲观锁:

预测接下来冲突概率比较大

所以每次在拿数据的时候就会上锁,这样别人想拿这个数据的时候就会阻塞直到它释放锁,适用于写操作比较频繁的场景

乐观锁:

预测接下来冲突概率比较小

发生冲突之前什么都不管就是干,如果发生了并发冲突了,则让返回用户的错误信息,让用户决定如何去做

读写锁(readers-write lock)

多线程之间,数据的读取方之间不会产生 线程安全,但数据写入方互相之间以及和读者之间都需要进行互斥,如果两种场景夏都用同一个锁,就会产生极大地性能损耗,所以读写锁因此而产生.

。读写锁可以提高程序的并发度,减少锁的竞争,从而提高程序的性能,但是比较容易引起死锁,要注意避免死锁的发生

读者之间并不互斥,而写者则要求与任何人互斥.

一个线程对于数据的访问,主要存在两种操作:读数据 和 写数据

  • 两个线程都只是读一个数据,此时并没有线程安全问题,之间并发的读取即可

  • 两个线程都要写一个数据,有线程安全问题

  • 一个线程读另一个线程写,也有线程安全问题

读写锁就是把读操作和写操作区分对待,java标准库提供了ReentrantReadWriteLock类,实现了读写锁

  • ReentrantReadwriteLock.ReadLock类表示一个读锁,这个对象提供了lock/unlock方法进行加锁解锁

  • ReentrantReadWriteLock.WriteLock 类表示一个写锁,这个对象也提供了lock/unlock方法进行加锁解锁

其中

  • 读加锁和读解锁之间,不互斥

  • 写加锁和写解锁之间,互斥

  • 读加锁和读解锁之间,互斥

注意只要是涉及到了"互斥",就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不知道隔了多久了

因此尽可能的减"互斥"的机会,就是提高效率的重要途径

读写锁特别适合于"频繁读,不频繁读"的场景(这样的场景也是非常广泛存在的)

比如教务系统

每节课老师都要用教务系统点名,点名就需要查看班级的同学列表(读操作),这个操作可能要每周执行好几次

而修改的情况不多,每次来了新同学或者有同学退学的时才修改一下

Synchronized不是读写锁

重量级锁 VS 轻量级锁

锁的核心特性"原子性",这样的机制追更溯源是CPU这样的硬件设备提供的

  • CPU提供了"原子操作指令"

  • 操作系统基于CPU的原子指令,实现了mutex互斥锁

  • JVM基于操作系统提供的互斥锁,实现了syschronized 和 ReentrantLock 等关键字和类

锁策略以及CAS和Synchronized的优化过程_第1张图片

注意,synchronized并不仅仅是对mutex进行封装,在synchonized内部还做了其他的工作

重量级锁:加锁机制重度的依赖了OS提供的mutex

  • 大量的内核态用户态切换

  • 很容易引发线程的调度

这两个操作,成本比较高,一旦涉及到用户态代码完成,就意味着"沧海桑田"

轻量级锁:加锁机制尽量不适用mutex,而是尽量在用户态代码上完成,实在搞不定了,再使用mutex.

  • 少量的内核态用户切换

  • 不太容易引发线程调度

理解用户态 VS 内核态

想象去银行办业务

在窗口外,自己做,这是用户态,用户态的时间和成本是比较可控的

在窗口内,工作人员做,这是内核态,内核态的时间和成本是不太可控的

如果办业务时反复和工作人员沟通,还需要重新排队,这时效率是很低的

synchronized开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁

自旋锁(Spin Lock)

按照以前的方式,线程在强锁失败时会进入阻塞状态,放弃CPU,需要过很久才能被再次调度

但实际上,大部分情况下,虽然当前抢锁失败,但是过不了很久,没必要就放弃CPU,这个时候就可以使用自旋锁来处理这个问题

自旋锁的伪代码:

while(抢锁(lock) == 失败) {}

如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时内到来

一旦锁被其他线程释放,就能第一时间获取到锁

理解自旋锁 VS挂起等待锁

想象一下,大三毕业 要实习了,你投了五家公司都没有得到回应

挂起等待锁:心态爆炸,彻夜买醉,直到那五份中有一份来找你了,他可能是真的找不上人了,才找你自旋锁:继续投简历,每天投100份简历,好几百家公司只要有一家公司找你 你立马就会去,有很大机会找到公司实习

自旋锁是一种典型的 轻量级的实现方式

  • 优点:没有放弃CPU,不涉及现成的阻塞和调度,一旦锁被释放,就能第一时间获取到锁

  • 缺点:如果锁被其他的线程持有的时间比较长,那么就会持续的消耗CPU资源(而等待的时候是不消耗CPU的)

synchronized中的轻量级锁策略大概率就是通过自旋锁的方式实现的

公平锁 VS非公平锁

现在有个公共厕所,有三个人在排队,第一个人先去上,第一个人上完之后该轮到谁呢?

公平锁:遵守"先来后到"

非公平锁:后两个人都有可能抢上厕所

  • 操作系统内部的线程调度就是视为是随机的,如果不做任何的额外限制,锁就是非公平锁,如果要想实现公平锁,就要依赖额外的数据结构,来记录线程们的先后顺序

  • 公平锁和非公平锁没有好坏之分,关键还是看适用场景

synchronized是非公平锁

可重入锁VS不可重入锁

可重入锁的字面意思是"可以重新进入的锁",即允许同一个线程多次获取同一把锁

比如递归里有一个加锁操作.,整个过程总整个锁不会阻塞自己,这个锁就是可重入锁(因为这个原因可重入锁也叫递归锁)

java中只要Reentrant开头命名的锁都是可重入锁,而且jdk提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的

而Linux系统提供的mutex是不可重入锁

不可重入锁: 把自己锁死

//第一次,加锁成功
lock();
//第二次加锁,锁已经被占用,阻塞等待
lock();

按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待,直到第一次的锁被释放,才能获取到第二个锁,但是释放第一个锁也是应该这个线程来完成的,结果这个线程自己把自己锁住了,又要加锁,还没解锁,这样就形成了死锁_这样的锁就是不可重入锁

synchronized 特点:

  1. 既是乐观锁,也是悲观锁

  2. 既是轻量级锁,又是重量级锁

  3. 轻量级锁基于自旋锁实现,重量级锁基于挂起等待实现

  4. 不是读写锁

  5. 是可重入锁

  6. 是非公平锁

CAS

什么是CAS

全称Compare and swap ,字面意思:比较并交换",一个CAS涉及到以下操作:

假设内存中的原数据V,旧的预期值A,需要修改的新值B

  1. 比较A与V是否相等.(比较)

  2. 如果相等,把B写入V (交换)

  3. 返回操作是否成功

两种典型的不是原子性的代码

  1. check and set (if 判定然后设定值)

  2. read and update(i++)

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号

CAS可以视为是一种乐观锁,(或者可以理解为CAS是乐观锁的一种实现方式)

CAS是怎么实现的

针对不同的操作系统,JVM用到了不同的的CAS实现原理,简单的来讲:

  • java的CAS利用的是unsafe这个类提供的CAS操作

  • unsafe的CAS依赖了的是jvm针对不同的操作系统实现的Atomic::cmpxchg;

  • Atomic::cmpxchg的实现使用了汇编的CAS操作,并使用CPU硬件提供的lock机制保证其原子性

简而言之,是因为硬件赋予了支持,软件层面上才能做到

CAS有哪些应用

实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();  

伪代码实现

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
    int oldValue = value;
    while ( CAS(value, oldValue,    oldValue+1) != true) {
        oldValue = value;
    }
    return oldValue;
    }
}

注意:

  • CAS是直接读写内存的,而不是操作寄存器

  • CAS是读内存,比较,写内存操作是一条硬件指令,是原子的

锁策略以及CAS和Synchronized的优化过程_第2张图片

1.线程1进行赋值

线程1的oldvalue == 主内存的value,直对内存接进行++操作

锁策略以及CAS和Synchronized的优化过程_第3张图片

2.线程2进行赋值

线程2的oldvalue != 主内存的value,然后进入循环,线程2的oldvalue 赋值为主内存的value,然后再进行判断,线程2的oldvalue == 主内存的value,然后直接对内存的value进行++操作

锁策略以及CAS和Synchronized的优化过程_第4张图片

然后线程1和线程2返回各自的oldvalue的值

通过以上代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作

本来check and set操作在代码角度不是原子的,但是在硬件层面上可以让一条指令完成这个操作,也就变成原子的了

实现自旋锁

基于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;
    }
}

CAS的ABA问题

什么是ABA问题

ABA问题:

假设二存在两个线程t1 and t2 ,有偶一个共享变量num,初始值为A

接下来,线程t1想使用,CAS把num的值改为Z,那么就需要

  • 先读取num的值,记录到oldNum变量中,

  • 使用CAS判定当前num的值是否为A,如果为A就修改成z

但是在t1执行这两个操作之间,t2线程可能会把num的值从A改为B,又从B改为了A

线程t1的CAS是期望num不变就修改,但是num的值已经被t2给改了,只不过又改成了A,这个时候线程就无法确定是始终是A还是经历了一段变化成为的A

ABA问题引来的新的bug

大部分情况下,其他线程反复的横调对修改他的值是没有影响的,但是还有一些特殊的情况

法外狂徒张三 有 100 存款. 张三想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作. 我们期望一个线程执行 -50 成功, 另一个线程 -50 失败. 如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.

正常的过程

1.  存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期 望更新为 50.

2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.

3. 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.

异常的过程

1.  存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期 望更新为 50.

2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中

3. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!

4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作 这个时候, 扣款操作被执行了两次!!! 这就是ABA的异常

解决方案

给要修改的值,引入版本号,再CAS比较的同时,也要比较版本号是否符合预期

  • CAS在读取旧值的同时,也要读取版本号

  • 真正修改的时候

    • 如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1

    • 如果当前的把那本号高于读取到的版本号,就操作失败(认为数据已经被修改过了)

    这就可以引出一个例子,当一件产品刚开始的版本号为1,然后每次在某鱼上卖出去的时候把他的版本号加一这就就可以判断经过了几手,我们就可以从这方面判断买还是不买

    在 Java 标准库中提供了 AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提 供了上面描述的版本管理功能.

Synchronized

见以下链接

Synchronized的基本概念和原理_Lzm_0.0的博客-CSDN博客

每日一言

明日复明日,明日何其多

你可能感兴趣的:(多线程,java技术,锁,java,jvm)