【JavaEE】锁策略、CAS和synchronized的优化

目录

1、常见的锁策略

1.1、乐观锁 vs 悲观锁

1.2、轻量级锁 vs 重量级锁 

1.3、自旋锁 vs 挂起等待锁

 1.4、互斥锁 vs 读写锁

1.4.1、读写锁的使用场景(适用于"频繁 读,不频繁写"的场景) 

1.5、可重入锁 vs 不可重入锁 

1.5.1、死锁的多种情况

 1.5.2、死锁的破解方法

1.6、公平锁 vs 非公平锁

 2、CAS(Compare and swap)

2.1、CAS的应用 

2.1.1、实现原子类

2.1.2、实现自旋锁

2.2、 CAS的ABA问题

2.2.1、什么是ABA问题

 2.2.2、解决CAS中的ABA问题

3、synchroinzed原理

3.1、synchronized的基本特点

 3.2、synchronized加锁工作过程(锁膨胀/锁升级)

3.2.1、偏向锁

3.3、锁消除

3.4、 锁粗化


1、常见的锁策略

1.1、乐观锁 vs 悲观锁

锁的实现者,预测接下来所冲突的概率是比较大,还是比较小,根据这个冲突的概率,来决定接下来该咋做。

  • 乐观锁:预测锁冲突比较小假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
  • 悲观锁:预测锁冲突比较大总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到他拿到锁。

【举个🌰】:2022年国家疫情放开,有的人听到这个消息,是悲观的态度,想着社会上有大面积的人感染,为了和这些人不接触,所以这些比较悲观的人会屯大量的物资。所以悲观锁一般要做的工作更多一些,效率会更低一些。乐观的人,提到这个消息,无所谓,既然国家都放开了,这就说明病毒的已经没有太大的威胁了,也就没有屯物资。所以乐观锁做的工作就会更少一点,效率更高一点。但这并不绝对。


1.2、轻量级锁 vs 重量级锁 

  • 重量级锁加锁解锁过程更慢,更低效加锁机制重度依赖了OS提供了mutex,大量的内核态和用户态的切换,很容易引发线程的调度。
  • 轻量级锁加锁解锁工程更快,更高效加锁机制尽可能不适用mutex,而是尽量在用户态代码完成。是在搞不定了,在使用mutex。少量的内核态和用户态的切换,不太容易引发线程调度。

也可以认为一个乐观锁很可能是一个轻量级锁,一个悲观锁很可能也是一个重量级锁(但是这个结论不绝对)


1.3、自旋锁 vs 挂起等待锁

自旋锁轻量级锁的一种典型实现挂起等待锁重量级锁的一种典型实现

  • 自旋锁:如果获取锁失败了,立即再次尝试获取锁,无限次循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时间内到来一旦锁被其他线程释放,就能第一时间获取到锁。(通常自旋锁是纯用户态的不需要经过内核态,获取锁的时间相对更短)
  • 挂起等待锁当某个线程在获取到锁的时候,其他那些没有获取到锁的线程只能挂起等待,此时这些线程会被CPU调度走,等到锁被释放,这些被CPU调度走的线程,在被CPU调度回来后,就会重新进行锁竞争。(通过内核的机制来实现挂起等待,获取锁的时间更长了)

【举个🌰】:比如我们在追求自己的女神的时候,被发好人卡,这个时候我们没有气馁,和之前一样每天给女神发,早安,晚安。有一天女神和自己的男朋友分手了,这个时候,我们就能第一时间抓住时机上位。这就相当于自旋锁,他会一直占用CPU的资源,进行忙等;而另一种情况,就是我们在被拒绝之后,潜心敲代码,将女神抛掷脑后,突然有一天女神说要不咱俩处对象试试。这种情况就相当于挂起等待锁,CPU将没有获得锁的线程调度去干别的事情,当“女神”这个锁被释放了,在将这些线程调度回来,进行锁竞争,然后某个线程获取锁。

✨ 自旋锁的优缺点:

  • 优点:没有放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
  • 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗CPU资源。(而挂起等待的时候是不消耗CPU的)。

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


 1.4、互斥锁 vs 读写锁

  • 互斥锁:是一种独占锁,之前的博客中使用synchronized加锁之后,线程A获得这个锁了,线程A在没有释放这个锁之前,那么线程B就会加锁失败,失败的线程B就会释放CPU让给其他线程,既然线程B释放掉了CPU,自然线程B加锁的代码就会被阻塞。对于互斥锁只有两个操作:加锁和解锁。只有两个线程针对同一个锁对象加锁时,才会产生锁竞争(互斥)。
  • 读写锁一个线程对于数据的访问,主要存在两种操作:读数据和写数据读写锁就是把读操作和写操作区分对待。对于读写锁来说,分为三个操作:读加锁,写加锁,解锁

读写锁中约定:

  1. 读锁和读锁之间,不会锁竞争,不会产生阻塞等待。(不会影响程序的速度,代码还是可以跑的很快)
  2. 写锁和写锁之间,有锁竞争。
  3. 读锁和写锁之间,也有锁竞争(2,3这两种,速度虽然减慢,但是保证了准确性)。

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

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

1.4.1、读写锁的使用场景(适用于"频繁 读,不频繁写"的场景) 

比如学习通,每节课老师都要使用学习通点名,点名就需要查看班级的同学列表(读操作),这个操作可能要每周执行好几次,而什么时候修改同学列表呢(写操作)?就是有同学加入这个班级的时候,可能一年都不必改一次。

再比如,同学使用学习通查看作业时(读操作),一个班级的同学很多,多操作一天就要进行几十次,但是这一节课的作业,老师只是布置了一次(写操作)。

❗❗❗总结

  • 读写锁在多个线程进行读一个数据的时候,此时并没有线程安全问题,直接并发的读取即可
  • 多个线程都要写一个数据的时候,有线程安全问题。这个时候就需要对这个数据进行加锁。
  • 多个线程,一些在读数据,一些在修改这些数据,也存在线程安全问题,这个时候就要正对这个数据进行读加锁和写加锁。(在写的时候,不允许读;再读的时候,不允许写)
  • synchronized不是读写锁

1.5、可重入锁 vs 不可重入锁 

一个线程,针对一把锁,连续加锁两次。

  • 出现了死锁,就是不可重入锁;
  • 不出现死锁,就是可重入锁

Java里面只要是以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有线程的Lock实现类,包括synchronized关键字锁都是可重入的。

我们通过一个伪代码来了解一下可重入锁和不可重入锁。

class BlockingQueue{
    synchronized void put(){
        this.size();
    }
    
    synchronized int size(){
    }
}

public static void main(String[] ){
    BlockingQueue queue = new BlockingQueue();
    Thread t = new Thread(()->{
        queue.put();
    });

这个时候,两个方法的锁对象都是queue,t线程调用put方法,针对锁对象queue,put方法进行了加锁,这个时候t线程被占用了,但是在执行put的方法体的时候,size方法也针对queue对象加锁,这个时候第二个锁尝试加锁,需要等待第一个锁被释放。第一个锁要释放,就需要第二个锁加锁成功。这在逻辑上就矛盾了。也就形成了死锁。这样的锁称为不可重入锁。上述使用synchronized对不可重入锁进行了逻辑上的讲解,但是synchronized是可重入锁。

❓❓❓上述这种情况在我们的日常开发中很容易遇到,当遇到这种情况的时候,就真的的死锁了吗?


❗❗❗当然是不会,因为我们的synchronized是个"可重入锁"。在上述的场景中不会死锁,一个线程在第二次对同一个锁对象加锁的时候会判定一下,看当前尝试申请锁的线程是不是已经就是锁的拥有者了,如果是,则直接放行。


1.5.1、死锁的多种情况

1️⃣上述说到的,一个线程,一把锁,加锁两次,可重入锁没事,不可重入锁死锁了

2️⃣两个线程两把锁,即使是可重入锁,也会死锁。

3️⃣ N个线程,M把锁。

这里通过哲学家,就餐问题来了解这种死锁情况。

✨死锁的四个必要条件(只要发生死锁,这四个条件都有体现。)

  1. 互斥使用:一个线程拿到一把锁之后,另一个线程不能使用。(锁的基本特点)
  2. 不可抢占:一个线程拿到所,只能自己主动释放,不能被其他线程强行占有【挖墙脚行为是不行的】(锁的基本特点)
  3. 请求和保持:就像上面的例子,哲学家拿到一个筷子之后,去哪另一支筷子,拿到的绝不放手。【吃着碗里的,惦记锅里的】(代码的特点,看我们自己怎样设计代码)
  4. 循环等待:上面的哲学家例子中,同时5个哲学家同时拿起左手边的筷子,想要拿起另一支筷子吃面条。这个时候就形成了循环等待。(代码的特点)

 1.5.2、死锁的破解方法

 ❓❓❓死锁是一个比较严重的bug,实践中如何避免出现死锁呢?


❗❗❗这个时候,很多老铁想到了银行家算法,但是这里我们并不推荐这个写法,因为银行家算法实现起来比较复杂,再开发中,追求的是简单可靠。

  1. 所以这里我们推荐一个简单有效的做法,可以通过破解死锁的必要条件中的一个,就可以避免死锁的发生。这里最好破解的就是循环等待这个条件。
  2. 我们针对锁进行编号,如果需要同时获取多把锁,约定加锁顺序,务必是先对小的编号加锁,后对大的编号加锁。这里还是通过哲学家就餐的例子来理解破解的方法

 再代码的设计时,让多个线程按照顺序加锁就可以了,多个线程多把锁,让其中两个线程同时获取最小的一把锁,这个时候就会形成一个线程一个锁都没有拿到。这个时候就会将死锁破解。


1.6、公平锁 vs 非公平锁

  • 公平锁:多个线程等待同一个锁的时候,谁先来,谁就先获取到这把锁(遵守先来后到)
  • 非公平锁:多个线程在等待同一个锁的时候,不遵守先来后到(每个等待的线程获取到锁的概率时均等的)。

❗❗❗注意:

  • 操作系统内部的线程调度就可以视为时随机的,如果不做任何额外限制,锁就是非公平锁,如果要想实现公平锁,就需要依赖额外的数据结构(队列),来记录线程门的先后顺序。
  • 公平锁和非公平锁没有好坏之分,关键还是看使用场景。
  • synchronized是非公平锁。

 2、CAS(Compare and swap)

CAS的全称就是Compare and swap(比较和交换)

这里是将寄存器A的值和内存M的值进行对比,如果值相同,就把寄存器B内存M的值进行交换

我们通过下面的这个不是原子的伪代码来了解CAS的硬件指令,真实的CAS是原子的硬件指令来完成的,这个伪代码只是辅助理解CAS的工作流程。

此处所谓的CAS指的是CPU提供的一个单独的CAS指令,通过这一条指令,就完成上述伪代码描述的过程,CPU指令已经是不可分割的最小单位。当多个线程同时对某个资源进行CAS操作,只能由一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

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

CAS最大的意义可以让我们在写多线程代码的时候不加锁,就能够保证线程安全。

 当多线程编程的时候,我们既要保证线程安全,又不想加锁,就可以使用CAS进行"无锁编程",下面的原子类和自旋锁都是无锁编程中的一些具体实现。


2.1、CAS的应用 

2.1.1、实现原子类

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

我们创建AtomicInteger类对象,在线程t1和t2线程中对这个对象进行自增50000次。查看最终结果。


import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo26 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //num++  后置++
                num.getAndIncrement();
//                //++num  前置++
//                num.incrementAndGet();
//                //--num  前置--
//                num.decrementAndGet();
//                //num--  后置--
//                num.getAndDecrement();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        //get 获取到数据
        System.out.println(num.get());
    }
}

 通过这个伪代码来,了解AtomicInteger类。

❓❓❓上述伪代码中在执行CAS之前,已经将value的值赋给了oldValue,这个时候在使用CAS进行比较value和oldValue的值相等,是不是没有意义?


❗❗❗肯定是有意义的,因为在多线程的环境下,线程的调度是随机的,可能线程1在执行完oldvalue = value,这个时候线程2将,线程2寄存器中的oldvalue值修改了并且传给了内存(value改变了),这个时候再执行线程t1,这个时候内存中的值(value)就和t1线程中寄存器中的值(oldvalue)不相同了。

2.1.2、实现自旋锁

我们还是通过下面的伪代码来了解CAS实现自旋锁。

public class SpinLock {
//记录当前的锁被那个线程持有,为null就是没有线程持有。
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

2.2、 CAS的ABA问题

2.2.1、什么是ABA问题

假设存在两个线程t1和t2,有一个共享变量num在内存中,初始值为A。

接下来,线程t1想使用CAS把内存中的值(num)改成Z,那么就需要

  • 先从内存中读取num的值,记录到oldNum变量(寄存器)中。
  • 使用CAS判定当前内存中的值num和寄存器中的值oldNum是否相等为A,为A,就将内存中的值修改成B。

但是内存中的值(num)和寄存器中的值(oldNum)相等中间存在两种情况。

  1. t1线程在执行上述两个操作的时候,中间没有其他线程修改内存中num的值,一直就是A
  2. t1线程在执行这两个操作时,可能将第一个操作执行完,t2线程被系统调度给CPU,内存中的值(num)被t2线程改成了B,又将B改成了A。

第二种情况,t1线程就无法区分但钱这个变量始终是A,还是经历了一个变化过程。(就好比我们买手机,买了一个翻新机,但是我们看不出来。)CAS只能对比值是否相同,不能确定这个值是否中间发生过改变

 2.2.2、解决CAS中的ABA问题

大部分情况下,t2线程这样的一个反复横跳改动,对于t1是否修改num是没有影响的,但是不排除一些特殊情况。

  1. 我们要解决这个问题,可以通过约定数据只能单方向变化(只能增加,或者只能减小),问题就迎刃而解了。
  2. 但是如果我们的需求是该数值,既能增加也能减小,这个时候我们可以引入另外一个版本号变量,约定版本号只能增加(每次修改,都会增加一个版本号),这样每次CAS对比的时候,就不是对比数值本身,而是对比版本号。

下面的图不是完全正确,但是在大体范围内描述了 使用版本号解决CAS的ABA问题。

 只要约定版本号,只能递增或者递减,就能保证此时不会出现ABA反复横跳的问题,以版本号为基准,而不是以变量数值为基准了。


3、synchroinzed原理

上面说到的CAS,我们用来解释了自旋锁的实现。这里来了解synchronizedla工作过程,来看synchronized里面具体都干了啥。

3.1、synchronized的基本特点

根据前面所说的所策略,我们就可以总结出,synchronized具有一下特性(只考虑JDK1.8)

  1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
  2. 开始时轻量级锁实现,如果锁被持有时间较长,就会转化为重量级锁。
  3. 实现轻量级锁的时候大概率用到自旋锁策略。
  4. synchronized是一种不公平锁
  5. synchronized是一种可重入锁
  6. synchronized不是读写锁

 3.2、synchronized加锁工作过程(锁膨胀/锁升级)

JVM将synchronized锁分为无锁,偏向锁、轻量级锁、重量级锁状态。会根据锁的竞争激烈程度,对锁状态进行升级。

  • 锁的级别按照下面的先后顺序升级,我们把这个升级过程称为"锁膨胀"。
  • 锁的升级是单向的,也就是说只能从低到高升级,不会出现降级的情况。

当锁升级为轻量级锁的时候,如果当前锁竞争非常的激烈,比如10个线程,竞争1个锁,1个竞争上 了,另外9个等待,也就是说这10个线程都在轻量级锁策略的情况下,那么9个线程进行自旋等待(忙等),CPU的消耗就非常大,既然如此就要将锁升级为重量级锁,在内核里进行阻塞等待,这个时候就意味着等待的线程暂时放弃CPU,有内核进行后续调度。 

 上述锁状态中只有偏向锁,没有介绍,我们在这里来了解一下偏向锁。

3.2.1、偏向锁

  • 偏向锁不是真的"加锁"只是给对象头中做一个"偏向锁标记"记录这个锁属于那个线程。
  • 如果后续没有其他线程来竞争该锁,那么此时就不用真的加锁了(避免了加锁和解锁的开销)
  • 但是一旦有别的线程尝试来竞争这个锁,于是偏向锁就会立即升级为真的锁(轻量级锁),此时别的线程只能等待。(偏向锁的策略,既保证了效率,又保证了线程安全

3.3、锁消除

  • 上面的锁升级是在代码运行阶段进行的优化手段,这里的锁消除是在编译阶段进行的优化手段。
  • 锁消除:编译器+JVM会检测当前代码是否是多线程执行,是否有必要加锁,如果没有必要,在编写的时候又把锁给写上了,就会在编译过程中自动把锁去掉。

例如我们之前说到的StringBuffer,他是一个线程安全的字符串类 ,它的关键方法都加了synchronized关键字。

如果是单线程情况下使用StringBuffer,不会涉及线程安全问题,不需要使用synchronized关键字,但是StringBuffer类的关键方法中都加了synchronized关键字,这个时候每次调用StringBuffer类中的方法,就会经行加锁和解锁,加锁和解锁这个操作,会浪费一些资源。所以使用消除锁的策略,就能在编译阶段消除这个问题。


3.4、 锁粗化

  • 锁的粒度:synchronized代码块,包含代码的多少(代码越多,粒度越大 ;代码越少,粒度越细)。
  • 一般写代码的时候,多数情况下,是希望锁的粒度更小一点(串行执行的代码少,并发执行的代码就多)。串行代码越少越少,程序执行就越快。

 但是事无绝对,有的时候并不是锁的粒度越小越好,如果频繁加锁和解锁,此时编译器就可能把这个操作优化成一个粒度更粗的锁。因为每次加锁解锁,都会有开销的,尤其是释放锁之后,想要重新加锁,还需要重新竞争。

 

我们举个例子来看:

 滑稽老哥当了领导, 给下属交代工作任务:

方式一:

  • 打电话, 交代任务1, 挂电话.
  • 打电话, 交代任务2, 挂电话.
  • 打电话, 交代任务3, 挂电话.

方式二:

  • 打电话, 交代任务1, 任务2, 任务3, 挂电话.

显然, 方式二是更高效的方案

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值