多线程☞锁策略,CAS,Sychronized原理

文章详细介绍了Java中的各种锁策略,包括乐观锁与悲观锁、轻量级锁与重量级锁、自旋锁与挂起等待锁、读写锁以及互斥锁的概念和区别。此外,还讨论了Synchronized的实现机制,从轻量级锁到重量级锁的升级,并提到了锁消除和锁粗化的优化策略。同时,文章提到了CAS(CompareAndSwap)操作及其在实现原子类和自旋锁中的应用,以及ABA问题及其解决方案。
摘要由CSDN通过智能技术生成

1.常见锁策略

1.乐观锁与悲观锁

乐观锁:在获取锁的时候预期这个锁竞争不太激烈,就可以先不加锁或者少加锁,等待后续出现锁竞争,在重新加锁。
悲观锁:总是设想最坏的结果,拿到一个数据,不管有没有锁的竞争,都会加锁之后再执行任务。

注意:Synchronized初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。

2.轻量级锁与重量级锁

轻量级锁:加锁的过程比较简单,用到的资源比较少,典型的就是在用户态的加锁操作(在java层面就可以完成)。
重量级锁:加锁的过程比较复杂,用到的资源也比较多,典型的就是在内核态的加锁操作。

乐观锁是能不加锁就不加锁,从而导致他干的活少,那么他消耗的资源就少,从而可以说乐观锁就是一种轻量级锁。

悲观锁是任何时候都是先要加锁,导致要完成的工作量大,进而消耗的资源就多,可以说悲观锁锁也还是一个重量级锁。

注意:Sychronized开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。

3.自旋锁与挂起等待锁

自旋锁:不停的检查锁是否被释放,如果一旦锁被释放立马获所资源。

自旋锁的优点:

  • 他是一个纯用户态的操作,比较轻量

  • 及时知道锁释放的消息,及时获取资源

缺点:

  • 不停的循环会浪费大量的资源

注意:我们可以通过控制自旋次数来提高效率,并且避免系统资源的过度浪费。例如:我们规定自旋次数不能超过三次,三次之后就不再询问,开始等待状态。这个等待状态就是挂起等待锁。

挂起等待锁:不主动询问资源,而是要让系统调度去竞争资源。

特点:

  • 通过阻塞与就绪状态的切换来获取锁资源

  • 锁一旦释放,没有办法立马知道

  • 是通过系统内核来处理的

注意:自旋锁是一个典型的轻量级锁的具体实现,挂起等待锁是一种典型的重量级锁的具体实现。

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

4.读写锁与普通互斥锁

读写锁会在锁中标识读或者写,在竞争是是根据这个标识来判断是否需要参与竞争。

读锁:读的时候加锁(共享锁),多个锁可以共存,同时加多个读锁不会互相影响。
写锁:写的时候加锁(排他锁),只有一个锁在工作,和其他的锁是冲突的。

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

锁.

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

加锁解锁.

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

行加锁解锁.

其中,

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

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

  • 读加锁和写加锁之间, 互斥.

为什么要使用读写锁

在我们们程序中可能会出现激烈的锁竞争,但是获取锁之后可能是大量的读操作,读操作又不涉及修改操作,所有的读操作可以并发执行,只要读的时候不要让别人的修改就好。针对高并发的业务,锁竞争就会大大降低,进而提高程序的工作效率。

互斥锁:有竞争关系,等一个线程锁释放之后,别的线程再来竞争锁。

5.公平锁与非公平锁

公平锁:对于锁竞争,遵循先来后到的原则,先排队的先获取锁,后到的后获取锁。
非公平锁:同时去抢一个锁,谁抢到就是谁的锁。

现实世界,对于绝对的公平很难实现,对于实现一个公平锁,也需要投入大量的精力,这也是一种资源的消耗,但是非公平锁就是没有这个问题,java与系统锁用到的锁,默认都是非公平锁。

6.可重入锁与不可重入锁

可重入锁:对一把锁可以连续加锁多次,不会造成死锁(加锁多次,解锁也需要多次)。
不可重入锁:对一把锁连续加锁多次,造成死锁。

2.CAS

1.什么是CAS

CAS全称:Compare And Swap, 比较并交换,一个CAS包括以下步骤:我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较A 和V 是否相等(比较操作)

  1. 如果相等,将B写入内存,覆盖V

  1. 返回操作是否成功

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

这个代码简单实现CAS原理。这些伪代码,在CPU中只对应一条指令cmpxchg。

2.CAS是怎么实现的

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

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

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

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

简而言之,是有硬件层面的支持,软件层面才得以实现!

3.CAS的应用

1.实现原子类

之前在线程安全问题部分,我们实现了多线程自增操作问题,由于原子性,所以导致线程出现不安全,但是在CAS部分,由标准库中提供了 java.util.concurrent.atomic 包,同样可以实现自增操作,是线程安全的。

典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作。

 public static void main(String[] args) throws InterruptedException {
        //基于CAS的源子类
        AtomicInteger atomicInteger = new AtomicInteger();
        
        //创建两个线程,进行自增
        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 5000; i++) {
                //通过调用方法进行自增操作
                atomicInteger.getAndIncrement();
            }
        });

        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 5000; i++) {
                atomicInteger.getAndIncrement();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("执行结果:" + atomicInteger.get());
    }

没有通过手动加锁,但是结果正确,这就是实现了原子性进而保证结果在正确。

2.实现自旋锁

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

4.CAS的ABA问题

1.什么是ABA问题

ABA 的问题:

假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要

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

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

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

到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.

2.解决方案

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

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

  • 真正修改的时候,

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

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

3.Sychronized原理

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.

2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.

3. 实现轻量级锁的时候大概率用到的自旋锁策略

4. 是一种不公平锁

5. 是一种可重入锁

6. 不是读写锁

1.锁升级

无锁:没有锁竞争的时候。理论上会出现的一个状态
偏向锁:偏向锁实际上没有加锁,只是给锁对象加了一个标签。记录这个锁属于哪个线程。如果后续没
有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)。如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态。
轻量级锁:通过 自旋锁(自适应)来实现用户态的操作。此处的轻量级锁就是通过CAS来实现的。自选是让CPU空转,这样比较浪费资源。因此此处也不会一直自选下去, 达到一定的时间或者一定的次数,就不再自旋了,这就是所谓的自适应。
重量级锁:内核态的加锁操作,调用的是CPU的加锁指令

此处的重量级锁就是指用到内核提供的 mutex :

  • 执行加锁操作, 先进入内核态.

  • 在内核态判定当前锁是否已经被占用

  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.

  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.

  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.

2.锁消除

锁消除是Sychronized的一种优化策略。

sychronized是程序员手动加锁的处理逻辑,在什么时候加锁,在哪个地方加锁,jvm管不了,但是在编译和运行的时候,jvm就可以知道是读操作还是写操作。如果程序员对所有操作都加锁,但是有没有写操作,那么此时jvm就会认为加锁是多余的操作,那么此时的sychronized就不会真正的加锁, 这个现象就叫做锁消除

注意:sychronized只有100%确定不需要锁的时候才会出现锁消除优化。

3.锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化。

实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值