【多线程进阶】常见的锁策略和CAS

目录

一、乐观锁VS悲观锁

1.1 悲观锁

2.2 乐观锁

二、重量级锁VS轻量级锁

2.1 轻量级锁

2.2 重量级锁

三、自旋锁VS挂起等待锁

3.1 自旋锁

3.2 挂起等待锁

四、公平锁VS非公平锁

4.1 公平锁

4.2 非公平锁

五、可重入锁VS不可重入锁

5.1 可重入锁

5.2 不可重入锁

六、互斥锁VS读写锁

6.1 互斥锁

6.2 读写锁

七、CAS

7.1 CAS实现原子类

7.2 CAS实现自旋锁

八、CAS的ABA问题解决方案

8.1 什么是ABA问题

8.2 ABA问题引来的BUG

8.3 解决方案


一、乐观锁VS悲观锁

        Java中的乐观锁和悲观锁是两种并发控制的锁策略,用于解决多线程访问共享资源时可能出现的竞争和冲突问题。

1.1 悲观锁

        悲观锁的思想是,每次访问共享的资源时都认为其它线程可能会访问该资源,因此会对该资源进行加锁保护。就是总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

2.2 乐观锁

        乐观锁的思想是,每次访问共享的资源时都假定其它线程不会同时访问该资源,因子不会对该资源进行加锁保护,而是通过版本号和时间戳等方式来检测数据是否被其它线程修改过,如果检测数据已经被修改过,则会进行回滚或重试等操作。乐观锁的优点是避免了频繁地加锁和释放锁,从而提高了并发性能。

对比来看:这两种锁策略不能说谁优谁劣,而是看当前的场景是否合适,悲观锁适用于并发写入比较多的场景,可以有效的保证数据的一致性,但是在高并发的情况下可能会影响性能乐观锁适用于并发读取比较多的场景,可以提高并发性能,但是在并发修改多的情况下可能会导致数据一致性的问题

二、重量级锁VS轻量级锁

2.1 轻量级锁

        轻量级锁是一种基于对象头的锁实现方式,主要用于低竞争情况下的锁性能。当一个线程尝试获取对象的锁时,如果该对象没有被其它线程加锁,则该线程将对象头中的标志位改为轻量级标志位,并将对象头中存储的线程ID设置为当前线程的ID。如果该对象已经被其它线程加锁,则该线程会自旋等待锁的释放,在自旋的过程中,如果其它线程已经释放了锁,则当前线程可以直接获取锁,否则就会变成重量级锁。

2.2 重量级锁

        重量级锁是基于操作系统互斥量的锁实现方式,主要用于高竞争情况下的锁性能。当一个线程尝试获取对象的锁时,如果该对象已经被其他线程锁定,则该线程会进入阻塞状态,直到其他线程释放了锁,该线程才能继续执行。重量级锁的实现涉及到操作系统内核的系统调用,因此在高并发情况下会产生较大的系统开销和资源消耗。

对比来看:轻量级锁适用于低竞争情况下的并发访问,可以有效的提高锁的性能,但是在锁冲突比较严重的情况下就会变成重量级锁重量级锁适用于高竞争情况下的并发访问,可以保证数据的正确性,但是会带来比较大的系统开销和资源消耗

三、自旋锁VS挂起等待锁

3.1 自旋锁

        自旋锁是一种基于忙等待的锁实现方式,当一个线程尝试获取锁时,如果其它线程已经加锁,则该线程会不断地循环检测锁是否被释放,直到获取锁为止。自旋锁的优先可以避免线程的阻塞和切换,因此对于锁的竞争不是非常激烈的情况下,自旋锁可以提供良好的性能表现,但是在锁竞争比较激烈的时,自旋锁就会忙等造成资源的浪费,导致性能下降。

3.2 挂起等待锁

        挂起等待锁是一种基于线程挂起的锁实现方式,当一个线程尝试获取锁时,如果该锁已经被其他线程占用,则该线程会被挂起等待锁的释放。挂起等待锁的优点是可以避免线程的忙等待,节省CPU资源,同时也可以防止锁的竞争过于激烈,从而保证程序的稳定性。但是,挂起等待锁的缺点是在线程挂起和恢复的过程中,需要进行线程的切换和上下文切换,这些操作会带来一定的系统开销和性能下降。

对比来看:自旋锁适用于锁竞争不是很激烈的情况下,这样可以提高性能挂起等待锁适用于锁竞争比较激烈的情况,可以保证程序的稳定性

四、公平锁VS非公平锁

4.1 公平锁

        公平锁是指多个线程按照申请锁的顺序来获取锁。在实际编程中,如果一个锁是公平的,那么线程的调度顺序就会按照线程获取锁的顺序来执行(先来后到的原则),这样避免了“饥饿现象”,也就是在优先级较高的线程也不能优先执行,必须按照获取锁的顺序来执行。

4.2 非公平锁

        非公平锁是在锁被释放时,不一定按照申请锁的顺序来获取锁,而是随机,这样就会产生“饥饿现象”,可能极端情况下某个线程永远都获取不到锁。

对比来看:公平锁看起来更加公平,所有线程都有平等的机会获取到锁,但是公平锁的实现往往会随着系统性能的显著下降而下降;非公平锁虽然会造成线程饥饿,但是在高并发、吞吐量大的系统中,会优先选择非公平锁。

五、可重入锁VS不可重入锁

5.1 可重入锁

        可重入锁是指同一线程可以多次持有同一把锁而不会死锁。可重复锁通常是通过一个计数器来记录锁的持有次数,每次加锁计数器都会加1,解锁计数器减1,当计数器为0时,锁就会完全释放。

5.2 不可重入锁

        不可重入锁是指同一个线程不能重复获取已经持有的锁,否则会死锁。

六、互斥锁VS读写锁

6.1 互斥锁

        当一个线程获取到互斥锁时,其它线程就无法再获取到该锁,直到该线程释放锁。互斥锁的优点是能够保证数据的一致性,但缺点会带来较大的性能开销,因此在每次获取锁时,需要进行线程的阻塞和上下文切换。

6.2 读写锁

        读写锁是一种针对读写操作加锁的实现方式,相比于互斥锁,读写锁可以实现更细粒度的并发控制。读写锁允许多个线程同时进行读操作,但只允许一个线程进行写操作。

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

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

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

这两个是操作系统内核提供的API在Java里进行封装的,系统API再底层的实现就是CPU指令级别了

其中,

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

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

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

七、CAS

        CAS(Compare and Swap,比较并交换)是一种基于原子操作内存并发控制方式,是实现乐观锁的一种方式。

        CAS机制包括三个参数:内存中的原数据V,旧的期望值A,需要修改的新值B。当且仅当V的值等于A时,CAS操作才会通过原子方式将V的值修改为B;如果V的值不等于A,那么CAS操作将不会执行任何操作,并且会返回V的当前值。

7.1 CAS实现原子类

         在Java中,CAS操作主要由java.util.concurrent.atomic包提供的原子类来实现这些原子类提供了一系列基于CAS机制的线程安全的原子操作,包括原子加、原子减、原子更新等操作。这些原子类通过使用CAS机制,可以避免锁机制的使用,从而提高并发性能。

CAS的优点:无锁化的实现方式,避免了锁机制的使用,可以避免由于锁竞争导致线程阻塞等待,从而提高系统的并发性能,而且CAS操作不会阻塞其它线程的访问,可以提高线程的响应速度。

CAS的缺点:CAS机制需要在循环中不断地进行CAS操作,知道成功为止,但是这可能引起ABA问题,其次,CAS机制只能针对一个变量进行原子操作,如果需要对多个变量进行原子操作,就需要使用锁机制来保证操作的原子性。最后,CAS机制的实现依赖于CPU硬件支持,不同CPU对于CAS操作的支持程度不同,可能会影响CAS机制的效率。

典型的就是 AtomicInteger 类.,下面代码演示AtomicInteger的使用:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerDemo {
    private static int value = 0;
    private static AtomicInteger atomicInteger = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                value++;
                //相当于后置++操作
                atomicInteger.getAndIncrement();
            }
        });
        thread1.start();
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                value++;
                atomicInteger.getAndIncrement();
            }
        });
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("number: " + value);
        System.out.println("atomicInteger: " + atomicInteger);
    }
}

运行结果:

7.2 CAS实现自旋锁

        自旋锁是一种高效的锁机制,基于忙等策略,避免了线程的上下文切换所带来的性能损失,从而优化了系统的并发性能。其核心思想是,当一个线程试图获取锁,但是锁已经被其它线程占用时,该线程会持续检查锁状态,直到锁被释放为止。

1、自旋锁通常使用一个标志位来表示锁的状态,这个标志位被设定为volatile的变量,以确保所有线程都能看到这个变量的最新状态,当锁的状态为0(未被占用)时,线程尝试通过CAS机制将标志位设置成1(已被占用),以此来获取锁。

2、CAS操作会比较当前锁状态(oldValue)和预期状态(value)。只有在两者相等时,CAS操作才会将锁状态更新为新的状态(通常为1,表示锁已被占用)。如果CAS操作成功,那么线程就获取到锁,并可以执行临界区代码。如果CAS操作失败,这意味着锁已经被其它线程占用,此时线程并不会立即放弃锁,而是继续执行CAS操作尝试获取锁,也就是自旋等待。

3、当线程完成临界区代码执行并准备释放锁时,它将通过CAS操作将锁状态重新设置成0(未被占用)。这个过程也是原子的,保证了锁状态的安全性。

八、CAS的ABA问题解决方案

8.1 什么是ABA问题

        ABA问题是指在使用ABA操作进行比较-交换时,如果变量在此期间被修改了两次及以上,那么CAS操作可能会出现误判。

举一个栗子:假设存在两个线程t1和t2,有一个共享变量num,其初始值为A,step1:接下来线程t1想使用CAS把num值改为Z,那么需要的操作是先读取num的值,记录到oldNum变量中,step2:然后再使用CAS判断当前num的值是否为A,如果为A,就修改为Z,但是,在t1执行这两个操作之间,t2线程可能会把num的值从A改成了B,又从B改成了A,以至于t1线程无法区分当前这个变量A中途是否修改过。

8.2 ABA问题引来的BUG

        大部分的情况下,t2线程这样反复横跳改动,对于t1是否修改num是没有影响的,但是有特殊的情况,举一个例子:

        假设我的银行卡上有100元,我想取走50元,手机银行创建了两个线程,并发的执行-50操作,我们期望一个线程执行-50成功,另一个线程-50失败,但是如果使用CAS的方式来完成这个扣款过程就可能出现问题。

异常的过程:

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

2、线程1扣款成功,存款被改成50;

3、线程2执行之前,我一个朋友给我转账50,余额就变成了100;

4、到线程2执行了,发现存款为100,就会进行扣款操作

这样就导致了进行了两次扣款,就是ABA问题搞的鬼!!!

8.3 解决方案

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

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

2、真正修改的时候:·如果当前版本号和读取的版本号相同,则修改数据,并将版本号+1。

                                  ·如果当前版本号高于读取的版本号,就操作失败,认为数据已经被修  改。

示例代码:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;

public class CASDemo {
    private static final AtomicStampedReference<Integer> count =
            new AtomicStampedReference<>(1,1);
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            //获取初始版本号
            int stamp = count.getStamp();
            //获取初始值
            int oldValue = count.getReference();
            try {
                //等待1s是为了让线程1拿到版本号和初始值
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程1的初始值:" + oldValue + "初始版本号:" + stamp);
            //模拟线程执行过程中被其它线程干扰,使得oldValue被修改
            count.compareAndSet(oldValue, 2, stamp, stamp + 1);
            System.out.println("线程1:新值是 " + count.getReference() + ",版本号是:" + count.getStamp());
            //模拟操作完成之后,变量值又修改为1
            count.compareAndSet(2,1, count.getStamp(), count.getStamp() + 1);
            System.out.println("线程1最新值:" + count.getReference() + ",最新版本号:" + count.getStamp());
        });

        Thread thread2 = new Thread(() -> {
            int stamp = count.getStamp(); // 获取初始版本号
            int oldValue = count.getReference(); // 获取初始值
            System.out.println("线程2: 初始值是 " + oldValue + ", 初始版本号是 " + stamp);
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 模拟操作过程中,变量的值被其他线程改为了2
            boolean flag = count.compareAndSet(oldValue, 3, stamp, stamp + 1);// 将变量的值由1改为3,此时线程1已经将变量的值由1改为2
            System.out.println("线程2是否修改成功: " + flag);
            System.out.println("线程2: 新值是 " + count.getReference() + ", 新版本号是 " + count.getStamp() );
        });
        thread1.start();
        thread2.start();
    }
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值