【Java EE】-多线程编程(九) 锁策略&&CAS&&锁优化

作者:学Java的冬瓜
博客主页:☀冬瓜的主页🌙
专栏【JavaEE】
分享
主要内容:乐观锁VS悲观锁、轻量级锁VS重量级锁、自旋锁VS挂起等待锁、互斥锁VS读写锁、公平锁VS非公平锁、可重入锁VS不可重入锁。CAS实现原子类,CAS实现自旋锁、CAS的ABA问题、Synchronized的锁优化:锁升级、锁消除、锁粗化。

一、常见的锁策略

1、乐观锁 VS 悲观锁

  • 乐观锁:是一种预期乐观的策略,认为锁竞争很少。在操作数据时先不加锁,而是在提交数据时再检查其它线程是否对这个数据进行了修改,如果没有,则提交成功;则需要回滚操作,并重新尝试。
  • 悲观锁:是一种预期悲观的策略,认为并发访问共享资源时,必然会产生冲突。在访问数据时,先加锁,然后再进行操作,确保其它线程无法同时操作该数据。这种锁策略常常会导致性能问题,因为加锁会导致其它线程阻塞等待。
  • 在实际开发中,乐观锁常常被用于高并发的场景,因为它能够提高并发性能。而悲观锁则用于对数据一致性要求较高的场景中,因为它能够保证数据的一致性。

2、轻量级锁 VS 重量级锁

  • 轻量级锁:轻量级锁是一种优化的锁机制,通过CAS(Compare And Swap,下面会讲)操作,来避免线程阻塞和唤醒的开销
    当一个线程尝试获取锁时,如果该锁没有被其它线程占用,它会将锁记录在自己的线程栈帧中,并将锁对象头中的标志位设置成"轻量级锁"状态。如果另一个线程也尝试获取这个锁,它会发现锁对象头中标志位已经被设置成"轻量级锁",就会进入自旋,占用CPU资源,直到该锁被前一个线程释放或者自旋次数达到最大值升级为重量级锁。注意:轻量级锁的自旋次数是有限的,达到一定次数后会变成重量级锁,因次占用资源少,效率高
  • 重量级锁:重量级锁是一种传统的锁机制,它使用操作系统提供的底层线程同步机制来实现(如互斥量、信号量等)
    当一个线程尝试获取锁时,如果该锁已经被其他线程占用,这个线程就会阻塞等待,让出CPU资源,直到该锁被前一个线程释放。重量级锁的缺点是线程的阻塞和唤醒开销比较大。
  • 在实际开发中,轻量级锁适用于短时间内只有一个线程访问共享资源的场景(即锁竞争较少的场景),可以提高效率和性能;重量级锁适用于多个线程竞争同一个锁的场景,可以保证多线程的安全性和准确性。

3、自旋锁 VS 挂起等待锁

  • 自旋锁:自旋锁是一种基于忙等的锁,当一个线程尝试加锁时,如果锁已经被占用,那这个线程就会一直自旋等待锁的释放,直到获取到锁为止。如果锁的持有时间长,则会导致CPU的浪费。
  • 挂起等待锁:挂起等待锁是重量级锁的一种实现方式,和重量级锁的情况类似,当一个线程获取锁,这个锁已经被其他线程占用时,当前线程就被操作系统"挂起",不参与操作系统调度,不占用CPU,直到锁被释放后才能唤醒这个线程并再次尝试获取锁。
  • 总的来说,自旋等待锁适用于锁的持有时间短的情况,挂起等待锁适用于锁的持有时间长的情况。

4、互斥锁 VS 读写锁

  • 互斥锁:一个线程加锁时,如果锁被占用了,那这个线程就进入阻塞等待。
  • 读写锁:对读和写单独加锁,其中读和读之间不互斥,而读和写以及写和写之间存在互斥。
    原因是,读和读之间不存在线程安全问题,而读和写以及写和写都可能引发线程安全问题。并且因为读操作比写操作更频繁,因此同时允许多个线程读取,提高并发性,从而提高效率。
    只有读操作的时候加共享锁,有写操作的时候加排它锁。

5、公平锁 VS 非公平锁

  • 公平锁:当多个线程对被占用的刚被释放的锁,加锁时,阻塞等待时间长的先获取到锁。遵循"先来先服务"的原则,避免了线程饥饿现象,但是可能会导致线程频繁切换上下文,降低了效率和性能。
  • 非公平锁:当多个线程对被占用的刚被释放的锁,加锁时,每个线程都有机会,即随机一个线程获取到锁。减少频繁切换上下文,提高效率。但是可能会导致一些线程一直获取不到锁资源,造成线程饥饿。
  • 总的来说,公平锁适用于对线程执行顺序有严格要求的场景,非公平锁适用于对性能要求较高的场景。

6、可重入锁 VS 不可重入锁

  • 可重入锁:同一个线程对一个对象加两次锁,不会产生死锁。
  • 不可重入锁:同一个线程对一个对象加两次锁,第二次加锁时,锁对象认为这个线程不是给它加了锁的线程,就让这个线程阻塞等待,但是其实都是一个线程,因此产生死锁。一般不建议适用。

以synchronized作为示例:

  • synchronized可以是乐观锁,也可以是悲观锁。synchronized默认是乐观锁,但是如果发现当前锁竞争比较激烈就会变成悲观锁。
  • synchronized可以是轻量级锁,也可以是重量级锁。synchronized默认是轻量级锁,但是如果发现当前锁竞争比较激烈就会变成重量级锁。
  • synchronized的轻量级锁基于自旋锁的方式实现;synchronized的重量级锁基于挂起等待锁的方式实现
  • synchronized是互斥锁,不是读写锁。
  • synchronized是非公平锁,不是公平锁。
  • synchronized是可重入锁。

总结:适用场景

  • 乐观锁和悲观锁:是对锁竞争的一种预测。乐观锁适用于高并发场景;悲观锁适用于数据一致性要求高的场景。
  • 轻量级锁和重量级锁:轻量级锁适用于短时间内锁竞争少的情况;重量级锁适用于锁竞争大的情况,可以保证线程安全。
  • 自旋锁和挂起等待锁:自旋锁适用于锁持有时间短的情况,挂起等待锁适用于锁持有时间长的情况。
  • 轻量级锁和自旋锁的自旋:轻量级锁和自旋锁都是使用自旋的方式等待获取锁,但具体情况有所不同:它们都持续占用CPU资源,但在自旋时如果被占用的锁一直获取不到:
    轻量级锁有一个最大自旋次数,达到这个次数后会变成重量级锁,这个线程再尝试获取锁时,就会阻塞等待,不占用CPU资源;
    而自旋锁会死等,一直占用CPU资源自旋,尝试获取锁,直到原来的线程释放这个锁。
  • 这六种锁策略相当于用来描述一把锁是怎么样的一把锁。

二、CAS

1、CAS概念

CAS:全称Compare And Swap,字面上的意思是:“比较并交换”,这里的交换其实也可以理解为赋值。

比如在下列代码中:在内存中有个V,原来的预设值为A(操作CAS前把V加载(load)到寄存器给A),需要修改成的新值为B(也在寄存器上),那么CAS操作就是先比较此时内存中的V和寄存器上的A是否相等,如果相等则令V=B,返回true,否则就返回false。

伪代码:

boolean CAS(V, A, B) {
    if (&V == A) {
   		&V = B;
        return true;
    }
    return false;
}

需要注意的是:CAS操作是原子的,上面三步操作在CPU上仅仅是一个指令。所以为线程安全的方式又提供了一种思路。

2、CAS应用场景

1> CAS实现原子类

标准库中提供了包:java.util.concurrent.atomic
原子类:这个包中的类的操作是原子的,是基于CAS实现,这个包下的类是线程安全的。

// 有如下类
AtomicBoolean、AtomicInteger、AtomicIntegerArray、
AtomicLong、AtomicReference、AtomicStampedReference。
@ AtomicInteger的几个方法

AtomicInteger类的几个方法如下:

AtomicInteger atomicInteger = new AtomicInteger(10);  // initalValue
atomicInteger.getAndIncrement();  // i++ 获取后增加
atomicInteger.getAndDecrement();  // i-- 获取后减少

atomicInteger.incrementAndGet();  // ++i 增加后获取
atomicInteger.decrementAndGet();  // --i 减少后获取

atomicInteger.addAndGet(10); // i+=delta  增加delta后获取
@ CAS实现原子类的方法

以AtomicInteger类的getAndIncrement()方法使用CSA分析为例

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

getAndIncrement方法伪代码:

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

分析上面的伪代码:
       一般情况下,value一定是在内存(为了保证原子性),oldvalue和oldvalue+1是在寄存器上(这两个也可以在内存,只是在寄存器上会更快)。第4行中因为是把内存上的值value读到寄存器上得到oldvalue,因此应当是相等的,那么CAS返回值是true,循环结束,此时是i++操作,因此先使用再++,所以返回oldvalue;
       但是,如果把内存中的value的值从内存读取到寄存器后(第4行执行完后,当前线程被切出CPU),当前线程被切出CPU,另外有一个线程进行了CAS操作修改value的值,那么当原来的线程调度回来后,value和oldvalue就不相等了,返回false,因此进入循环,此时就重新把内存中的value的值拷贝到寄存器上得到新的oldvalue,再去进行CAS判定,这样就可以保证原子性。

总的来说:原子类的实现:每次修改之前,看看value是否已经被修改,不修改则直接把oldvalue+1给value;若修改了,则先把value重新读到oldvalue中,再重新CAS

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

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

定义一个锁的变量owner,用来标记当前锁被哪个线程拥有。当有一个线程执行lock操作时:

       如果当前锁未被其他线程获取,进入CAS时,owner == null,那么就把当前的线程标记为这个锁的拥有者,因为循环条件的前面还有个非,此时CAS返回true,所以退出循环,这个线程就已经获取到锁。
       如果当前锁已经被占用,那么返回false,取非后就是true,再进入循环,再CAS操作,直到锁被原来的线程释放,这个线程才能获取到锁,这就是CAS实现自旋锁。

形如上面的代码,就可以利用CAS实现一个自旋锁。
然而,虽然CAS也能解决线程安全问题,且高效,但是呢,CAS只能某些特定的情况,因此,大部分情况下还是使用加锁操作。

3、CAS的ABA问题

ABA问题:CAS的原理是:比较value和oldvalue是否相等,如果相等,就视为value没有中途被修改过,所以进行下一步的修改没问题。但是呢有没有可能value被修改过,然后又还原回来了呢?是有可能的!!!

在这里插入图片描述

  • 正常情况下:本来应该是t1线程CAS操作后,t2线程操作时,余额=1000
    那么value!=oldvalue,返回false。那就只完成t1线程的1000元扣款,是合理的

  • 若刚好ATM机卡的时候我点了两下,且这时我妈往我卡里充钱和我取钱一样的金额:
    也就是说:t3线程在t2load后,CAS前给我的账户充值了1000元,那么本来变回1000的value,现在又回到了2000此时,在t2看来,余额没变,满足CAS的条件,所以又扣款,最终value变成1000。
    我妈给我充了1000,我本来还有2000,最终却只剩下了1000,所以就出现了在t1和t2线程中重复扣款的现象

怎么解决?上面的情况出现的原因就是我的value虽然修改了,但又恢复回来了,因此解决的办法就是不让它恢复回去。
方法:使用版本号,以版本号为基准(value),这个版本号从0开始,不管充值还是取钱,每次操作加1,这样,如果版本号没改变,那就确保是还没修改的,就解决的CAS的ABA问题。

三、synchronized的锁优化

1、锁升级

锁升级又叫锁膨胀。
无锁 => 偏向锁 => 轻量级锁 => 重量级锁
当代码执行synchronized(this){ ... }刚进入代码块中时,就会从无锁变成偏向锁。

偏向锁的原则:非必要不加锁,即当前线程获取到锁时,先加上偏向锁,具体是先加一个标记,如果整个过程中没有锁竞争,在synchronized执行完后,取消偏向锁即可;
但是,如果在使用的过程中,如果有其它线程尝试获取这个锁,那么在这些线程获取到锁之前,迅速把偏向锁升级为真正的加锁状态,轻量级锁。

轻量级锁:此时,synchronized通过自旋的方式加锁,直到其它线程释放当前锁或者自旋次数达到最大值变成重量级锁。

重量级锁:基于操作系统原生的API进行加锁,如果此时有一个线程要加锁,但是锁对象被占用了,这个线程就进入阻塞等待。在操作系统中,这个线程对应的PCB就会被放入到阻塞队列中,暂时不占用CPU资源了。

2、锁消除

编译器察觉到我们的代码中加锁的部分其实不需要加锁,它就给我们做了一个优化,就把这个锁消除了。

3、锁粗化

锁的粒度:synchronized包含的代码越多,粒度就越粗,反之则越细。一般来说,锁的粒度细一点会更好,因为可以更好的并发执行。

但是有的情况下,粒度粗一点还反而更好。比如:频繁加锁解锁,并且前一次的解锁和后一次的加锁之间,间隙非常小,那反而粒度粗一点,一次的加锁和解锁搞定更好,因为多次加锁解锁的操作也是有资源消耗的!(比如你打电话时要讲三件事,你应该打一次电话告诉对方三件事情,而不是打三个电话,每次告诉对方一件事)
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学Java的冬瓜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值