一篇文章带你弄懂乐观锁与悲观锁,CAS原子性,synchronized底层原理

1 篇文章 0 订阅

文中加入了个人理解,如有不准确的地方欢迎提出,笔者会及时的进行改正。

乐观锁与悲观锁

乐观锁: 假设数据不会发生冲突,只有在进行数据更新的才会对数据进行检查,如果冲突则更新失败并返回错误信息

悲观锁: 悲观锁与乐观锁恰恰相反,它是假设资源每次都会被修改,所以在访问资源之前都会进行上锁,这样其他人想要访问资源的时候就会被阻塞,直到锁被释放。

CAS

CAS(Compare And Swap)是乐观锁的一种实现方式。

image-20211124120508130

通过 CAS 实现的轻量级锁会在想要更新变量时判断线程内存中的变量与公共内存中的变量值是否相等,如果相同则进行修改,如果不同则重新读取变量值并重复上述操作。

CAS的优缺点

CAS 的优点很明显:CAS 减少了线程之间的上下文切换带来的消耗,避免了线程一条线程占有公共资源其他线程全部都被阻塞。
CAS的缺点从上图也可以看出:如果不断有其他线程修改公共变量,本线程就会不断的自旋,浪费 CPU 资源。

ABA问题

说到 CAS 一定要谈到 ABA 问题,先通过下图了解一下什么是 ABA 问题

image-20211125135403127

线程1获取的时候变量为 A,线程2将变量更新为B,线程3将变量又设置为A,此时线程1判断变量还是原来的值并将其更新为C。

这个操作看上去没有什么问题,在变量时基本数据类型时也基本不会发生问题,但是如果变量是一个引用类型,其中的字段可能发生了改变会带来严重的后果。

那么我们如何解决 ABA 问题呢?

通常我们会添加一个版本号来判断这个公共变量是否被改变,以 sql 代码为例:

update table set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} 

在 juc 中也提供了相应的类: AtomicStampReference

public class AtomicTest {
    public final static AtomicStampedReference<String> ATOMIC_REFERENCE = new AtomicStampedReference<String>("abc" , 0);

    public static void main(String []args) {
        //创建一个线程池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(150, 150, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(1), Executors.defaultThreadFactory(), new ThreadPoolExecutor.DiscardPolicy());

        for(int i = 0 ; i < 100 ; i++) {
            final int num = i;
            //先获取原来的version
            final int stamp = ATOMIC_REFERENCE.getStamp();
            threadPoolExecutor.execute(()->{
                try {
                    Thread.sleep(Math.abs((int)(Math.random() * 100)));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //比较value和version,并设置为新的值
                if(ATOMIC_REFERENCE.compareAndSet("abc" , "abc2" , stamp , stamp + 1)) {
                    System.out.println("我是线程:" + num + ",我获得了锁进行了对象修改!");
                }
            });
        }
        //下面这个线程是为了将数据改回原始值,以便之后的操作
        threadPoolExecutor.execute(()->{
            int stamp = ATOMIC_REFERENCE.getStamp();
            while(!ATOMIC_REFERENCE.compareAndSet("abc2", "abc" , stamp , stamp + 1));
            System.out.println("已经改回为原始值!");
        });

    }
}
CAS的原子性

同时 CAS 的判断与写入操作必须本身保证是原子的,否则在判断和修改变量时其他线程对公共变量进行了修改又会导致数据不安全。

JUC 的 atomic 包下大量使用了 CAS ,我们通过 AtomicInteger 的底层源码来看看 CAS 是如何实现原子性的。

new AtomicInteger().getAndIncrement();

我们点进 getAndIncrement方法

private static final Unsafe U = Unsafe.getUnsafe();

public final int getAndIncrement() {
    return U.getAndAddInt(this, VALUE, 1);
}

我们可以看到,这里实际上是调用了 Unsafe 类的方法,Unsafe 类是 Java 的一个后门,由于 java 不能直接操作内存,java 中有许多native 方法用来直接调用 c、c++的方法库从而直接操作内存,Unsafe 中的方法基本都是 native 的。

我们不断再向里点

@HotSpotIntrinsicCandidate
    public final native boolean compareAndSetInt(Object o, long offset,int expected,int x);

最终发现了一个本地的方法,这个方法调用了调用了 C、C++ 库中的方法。

实际上 C、C++ 库中的方法最终调用的是汇编语言中的 cmpxchg 方法,也就是说 cpu 本身就提供了 CAS 的相关指令。

但是 cmpxchg 指令本身也不能保证原子性,比如两个 cpu 同时进行上述的 cas 操作有可能也会在进行修改的时候被另一个 cpu 打断。但是 C、C++ 库中的方法在 cmpxchg 前加了一个 LOCK_IF_MP 使有多个CPU的时候在加上一把 lock 锁,lock锁会在一条CPU进行CAS 操作的时候锁死总线,这样其他 CPU 就无法操作。

总之 CAS 的原子性底层实现就是通过总线锁实现的。

image-20211124125506404

synchronized

java 中 synchronized 就是悲观锁的一个具体的实现。

我们从 synchronized 锁的对象底层原理锁升级来解释。

锁对象分类
  • 对于普通方法,锁是当前对象
  • 对于静态方法,锁是当前对象的类的Class对象
  • 对于同步代码块,锁是括号中的对象
重量级锁底层原理

首先我们了解一下一个对象在堆内存中存储的结构:对象头实例数据对齐填充

对象头中存储了:对象Hash值、GC年龄计数器、锁的信息、指向对象类型的索引、(数组还会有数组长度)

任何一个对象都有一个 monitor 对象与之关联,而当一个线程拿到锁时,monitor对象的 _Owner 就会指向该线程,其他线程想要拿到锁就会进入 _EntryList 并被阻塞 ,当前线程释放锁后 _EntryList 中的线程会对锁进行争抢(synchronized是一种非公平锁,也就是说不管这个线程是否是先来的,在争抢锁时机会时相同的,而公平锁则是按照先来后到的方式来获取锁)。

image-20211125125436810

对于同步代码块来说,代码最终会编译成 monitorenter 和 monitorexit 指令,monitorenter 对应的就是线程尝试获取锁的过程,而 monitorexit 则是释放锁的过程。

对于方法而言,它的实现方法在 JVM 中没有详细说明,但是也可用通过 monitorenter 与 monitorexit 来实现。

锁的升级

尽管 synchronized 被我们称为是重量级锁,但是自从 jdk1.6 对 synchronized 进行优化后,synchronized 就变得没有那么“重”了。

我们先得出一个结论:能使用 synchronized 就尽量使用 synchronized

具体的原因是 synchronized 在被优化过后不会一上来就使用重量级锁,而是按照:

偏向锁 -> 轻量级锁 -> 重量级锁

的顺序不断升级。

偏向锁

在有些情况下,锁并不会被不同的线程不断竞争,而是不断被一个线程获取,这样锁的获取和释放就会带来不必要的开销,所以偏向锁就是为了解决这种场景而出现的。

我们在底层原理说到过,对象头中存储了锁的相关信息,其中有 1bit 就是用于存储当前是否是偏向锁(偏向锁表示),2bit用于存储锁标志位(偏向锁对应为 01),在偏向锁的情况下还存储了当前偏向的线程ID,当一个线程想要获取锁首先会判断一下锁对象的偏向线程是否是自己,如果是则代表当前线程已经获取了锁,如果不是则会通过偏向锁标识先判断一下当前是否是偏向锁模式,如果设置了,则尝试使用 CAS 操作将对象头中的线程ID设置为自己的。

偏向锁采用了一种直到产生竞争才会释放锁的机制,也就是说,在尝试使用 CAS 操作将对象头指向自己时其他线程也进行了同样的操作,即产生了冲突,此时偏向锁就会撤销。

撤销的过程:CAS 发生冲突后,拥有偏向锁的线程会在安全点被暂停,此时会检查该线程的状态,如果不在运行则将对象头中的线程置为无锁状态;如果在运行要么对象头重新偏向其他线程,要么恢复到无锁状态,要么标记对象不适合作为偏向锁并升级,最后唤醒暂停的线程。

轻量级锁

轻量级锁的加锁和解锁都运用到了上文提到的 CAS 操作

加锁: 线程在执行同步代码前会先在虚拟机栈的栈帧中创建存储锁记录的空间,并尝试将对象头中的 Mark Work 复制到锁记录中。然后线程会尝试 CAS 操作来将对象头中的指针替换为指向当前锁记录的指针,成功则获取锁,失败则尝试自旋获取锁。

解锁: 尝试将线程中的锁记录替换回对象头,成功则表示没有竞争发生,失败则锁或膨胀为重量级锁(自旋达到一定次数)。

总结

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,执行速度和非同步代码几乎没有差别如果线程间存在竞争,则带来了撤销锁的消耗只有一个线程访问同步代码
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度可能会造成竞争锁带来的自旋,消耗CPU资源追求响应时间,同步代码执行速度快
重量级锁线程竞争不会被自旋,不会消耗CPU线程阻塞,相应时间慢追求吞吐量,同步代码执行速度慢

可重入锁与不可重入锁

公平锁与非公平锁

自旋锁 互斥锁 读写锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值