总结并发编程中的锁策略、CAS及synchronized是如何进行优化的

什么是CAS?

CAS全称是Compare And Swap,它是CPU的一条指令,相当于这是一个原子的操作。

它做的工作正如它的名字一样 ,比较并且交换。假设内存中的原数据V,预期旧值A,和希望更新的值B

  • 比较V与A值是否相同
  • 如果相同就让B赋值给V

比如java类库中的 AtomicInteger类,底层就是通过CAS实现的。

用Atomicinteger类也可以保证多线程情况下修改同一个数据的线程安全。

代码实现:

public class Test14 {
    public static void main(String[] args) throws InterruptedException {
        // 传入初始值为0
        AtomicInteger atomicInteger = new AtomicInteger(0);
        // 开启三个线程对atomicInteger进行加操作,每个线程都加1000次
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    atomicInteger.getAndIncrement();
//                    System.out.println(atomicInteger);
                }
            }).start();
        }

        // 让主线程睡一秒,等待上面的线程执行完,再打印atomicInteger的值,也可以用join方法
        Thread.sleep(1000);
        // 输出atomicInteger的值,预期结果应该为3000
        System.out.println(atomicInteger);
    }
}

运行结果:

那么这种线程安全是怎么实现的呢?实际上atomicInteger的getAndIncrement()方法就是用CAS实现线程安全的。

为了更清楚了解getAndIncrement方法是如何利用CAS的,下面将用伪代码展示具体是如何实现的:

class AtomicInteger{
    public int value;
    public int getAndIncrement(){
        int oldValue = value;//读取内存中的值
        while(CAS(value, oldValue, oldValue + 1) != true){
            oldValue = value; // CAS失败说明主内存中的值已经被更新,需要重新读取
        }
        return oldValue;
    }
}

注意!!!因为CAS是cup上的一条指令,是原子性的,对主内存有修改操作,所以当多个线程同时执行CAS时,只有一个线程能执行成功。

1. CAS的应用

  1. 实现原子类,JAVA标准库中的java.util.concurrent.atomic包。
  2. 实现自旋锁。通过CAS来加锁。

2. ABA问题

假设存在3个线程,线程1、线程2和线程3,和一个共享变量num。num初始值为100,现在期望如果初始值是100,就修改为0。其中线程1和线程2的任务就是修改初始值,线程3的任务是对num进行加操作。

线程1优先执行了CAS,将num修改为了0。然后线程3对num的值又做了修改,直接让它加到100。这时候线程2来了,num的值又回到了100,如果线程2进行CAS操作,就会又将值改到0。但是我们只是希望对初始值为100的进行修改操作,这样就违背了我们的初衷。

由此我们也可以发现CAS的弊端,就是无法判断这个值是初始的,还是经过一系列变化最终又变回了初始值。这就是ABA问题。

如何解决ABA问题?

可以选择加入版本号来解决,在读取num值的时候也要读取版本号,只有值和版本号都符合预期才可以进行修改操作。num初始为100,版本号为0。

  • 线程1进行CAS操作,num值和版本号都与初始情况相同,num成功修改为0,同时将版本号加1,版本号为1。
  • 线程3对num进行加操作,num修改为100,同时版本号加1,版本号为2。
  • 线程2进行CAS操作,发现num为100,符合预期,但是版本号为2与初始的0不同,认为已经被修改过了,不能进行修改。

CAS在JAVA中是如何实现的

针对不同的操作系统,JVM用到了不同的CAS实现原理

  • JAVA的CAS利用的是unsafe类提供的CAS操作
  • unsafe类的CAS依赖的是JVM针对不同操作系统实现的Atomic::cmpxchg
  • Atomic::cmpxchg的实现使用了汇编的CAS操作,并使用cpu硬件提供的lock机制保证其原子 性

常见的锁策略

锁策略顾名思义就是设计锁的策略,我们可以了解常见的锁策略,设计出适合自己场景需求的锁。

1. 乐观锁 VS 悲观锁

1.1 乐观锁

乐观锁,可以简单理解为是一种比较乐观的锁设计策略,它的出发点就是会乐观的认为线程之间发生锁冲突的概率比较小,因此它并不会真的对资源进行锁定,而是在提交数据的时候再检查刚刚是否发生了线程冲突,如果冲突了就会提交失败,如果没有冲突就会提交成功。

举个例子:当你想去问老师题的时候,你不会事先问老师现在有没有其他同学在问题,你会乐观的认为现在肯定没有其他人在问老师问他,你去了就能直接问老师。然后你就直接去找老师问题(相当于尝试提交数据),结果到了办公室发现已经有人在问老师题了(资源已经被其他线程占用,发生线程冲突),于是你只能排队等待问题(数据提交失败)。

优缺点:乐观锁的优点在于,如果没有发生线程冲突的情况,执行效率就会比较高,因为你少去了资源锁定的过程,只是在最后判断一下是不是发生冲突了。但是它的缺点也很明显,就是如果冲突比较多的情况,就会耗费额外的资源,因为那些数据做了修改的动作,但是因为发生冲突不能提交成功,所以相当于白修改了,反而会影响执行性能。

乐观锁通常用CAS对数据进行冲突检查。正如在CAS中getAndIncrement方法伪代码的例子一样,如果发生冲突会进入循环,不断重试,直到CAS操作成功为止。

1.2 悲观锁

与乐观锁相对应,它是一种悲观的设计锁的策略。它会悲观的认为多线程之间发生锁冲突的概率比较大,所以它会避免这种情况发生。所以它会对资源进行锁定,只有拿到锁的线程才能对数据进行操作。

举个例子:当你想去问老师问题的时候,你会事先跟老师发消息问这个时候老师是否有空(这个过程相当于尝试获取锁),老师如果回复你,现在没有其他人你可以过来问我,这就相当于成功获取锁,把老师这个资源锁定了。如果老师回复你,现在有同学正在问我,等我叫你的时候再来,相当线程于获取锁失败,进入阻塞等待,等待重新被CPU调度尝试获取锁。

优缺点:悲观锁的优点是当线程冲突确实比较多的时候,避免了像乐观锁这种处理方式导致“白跑一趟”的情况,可以有效节省cpu资源。缺点就是,对资源进行锁定,开锁和释放锁都会开销很大。其他竞争锁失败进入阻塞等待的线程,也不能及时的在锁被释放的时候去竞争锁,因为这涉及到了CPU的调度,和上下文切换。

2. 读写锁

为什么会出现读写锁?

多线程之间,读数据和读数据之间是不会出现线程安全问题的,只有一个线程读,一个线程写,或者两个线程都在写数据,才会发生线程不安全问题。因此我们延伸出了读写锁,来提高锁的效率。

  • 读锁和读锁:不互斥
  • 写锁和读锁:互斥
  • 写锁和写锁:互斥

JAVA标准库中提供了ReentrantReadWriteLock类,来实现读写锁。

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

注:Synchronized不是读写锁!读写锁最主要用在“频繁读,不频繁写”的场景中

3. 重量级锁 VS 轻量级锁

锁对资源的锁定,对线程的互斥。追根溯源是CPU这样的硬件提供的。

  • CPU实现了原子操作指令
  • 操作系统在CPU指令的基础上,实现了mutex互斥量
  • JVM基于操作系统提供的互斥锁,实现了synchronized关键字和ReentrantReadWrite类。

3.1 重量级锁

重量级锁就是,大量了使用了mutex互斥量去实现锁,这样的设计是很重量的,因为mutex是操作系统层面的,如果频繁使用mutex去设计锁,必然会出现很多从用户态到内核态的切换,而且很容易引起线程的调度。一旦有线程的调度就意味着“沧海桑田”。因为CPU对线程的调度是随机不确定的。

概括来讲,重量级锁的实现加重了对OS提供的mutex的依赖。

  • 大量的用户态到内核态的切换
  • 容易引发线程的调度

3.2  轻量级锁

轻量级锁就是少量依赖mutex去实现锁,能在用户态搞定的,尽量不用内核态的mutex解决。

  • 少量的用户态到内核态的切换
  • 不容易引发线程调度

注:synchronized一开始是轻量级锁,当锁冲突比较严重的时候会升级为重量级锁。

一般认为乐观锁就是轻量级锁,悲观锁就是重量级锁。

4.  自旋锁

如果当其它线程获取锁失败,进入阻塞队列等待,这个时候就相当于放弃了CPU资源,下一次等CPU想起它们再调度的时候,已经是“沧海桑田”,因为这个过程开销是很大的,还涉及线程上下文切换。所以很可能出现调度不及时的情况,持有锁的线程已经释放锁了,那些等待的线程还没有被CPU调度进入竞争状态,尤其是当锁持有的时间很少的时候,这时候很可能等待线程被调度时间比执行锁代码的时间还要多,很大程度上影响了代码的性能。

而自旋锁就是针对这一情况的策略,如果锁持有的时间比较少,那么我们可以不用让其他获取锁失败的线程放弃CPU资源,而是不断的通过循环尝试竞争锁,一旦锁被释放,那么其他线程就能立马去竞争到锁。

自旋锁伪代码:

while(抢锁() == 失败){

}

所有没获取到锁的线程都会在循环里面不断尝试获取锁,直到成功。

自旋锁优点:不涉及线程的阻塞和调度,当锁被释放可以第一时间获取到锁。

自旋锁缺点:如果锁持有的时间很长,或者参与自旋的线程很多,就会耗费比较多的CPU资源,而阻塞等待是不消耗CPU的。

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

5. 公平锁 VS 非公平锁

公平锁:获取锁失败的线程,遵循先来后到原则,越先等待的,当锁被释放会优先拿到锁。

非公平锁:不遵循先来后到原则,随机从等待的线程里面选取一个拿到锁。

注:synchronized是非公平锁

6. 可重入锁 VS 不可重入锁

可重入锁:允许同一个线程多次获取同一把锁。也就是说当线程拿到锁之后,在锁代码里面如果还遇到被这把锁锁起来的代码块,可以直接进入。

不可以重入锁:不允许同一个线程多次获取同一把锁。

注:JAVA里只要以Reentrant开头命名的锁都是可重入锁,synchronized也是可重入锁。Linux操作系统提供的mutex是不可重入锁。

Synchronized的优化过程

jdk1.6及之前,synchronized锁的实现都是直接对资源进行了锁定,大量依赖了互斥量mutex。在jdk1.8后,对synchronized做了很多优化,让它自适应面对不同的多线程情况。不再一开始就选择用重量级锁,而是有一个锁升级的过程,提升了性能。

锁的状态有四种,分别是无锁,偏向锁,轻量级锁和重量级锁。锁可以根据需求自动升级(膨胀),升级按:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。锁只能升级,不能降级。但是可以重新恢复到无锁状态(在长时间没有执行有锁的代码情况下)。

synchronized锁同步信息保存在锁对象的对象头中的Mark Word里面,锁升级成功主要依赖是否偏向锁和锁标志位的信息实现。下图是Mark Word存放的信息图。

无锁

对象的初始状态就是无锁状态。

public class Test15 {
    public static void main(String[] args) throws InterruptedException {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

 打印对象头的信息,锁的标志位是01,表示无锁状态。

偏向锁

在竞争比较小的时候,获取到锁的线程大概率都是同一个线程,这种情况就是锁偏向这个线程的情况,所以我们在此基础上延伸出了偏向锁。

偏向锁的原理就是在第一个线程获取锁的时候,就会记录该线程的线程id,之后其他线程再想获取锁就只需要比对线程id即可,并且偏向锁在加锁之后并不会主动撤销锁,而是会保持锁的状态,因为偏向锁针对的情况本身就是同一个线程反复拿到锁的情况,所以在锁代码执行完毕之后不用撤销锁,这样下一次还是这个线程拿到锁的时候,只用比对线程id,如果一样,直接进入执行代码,这样的操作比轻量级锁省去了加锁和释放锁的开销,只有在第一次拿到锁的时候才会加锁,然后就一直保持锁状态。

那么什么时候偏向锁会撤销呢?偏向锁不会主动撤销,但是当有不是偏向锁记录的线程去拿到锁的时候,这个时候偏向锁就会撤销,并且升级为轻量级锁。

代码演示

对Object对象o进行加锁,并在加锁代码中打印对象的信息。 

 public static void main(String[] args) {
        Object o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }

 打印结果发现锁的标志位是00,轻量级锁。为什么不是偏向锁呢?明明这里只出现了一个线程调用锁。是因为偏向锁的启用有延迟,默认会延迟4秒才能激活偏向锁。所以我们这里可以先让程序睡眠五秒。

注意这里在休眠之后新建对象的对象才会启用偏向锁。

 public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }

 睡眠五秒后,在打印加锁后的对象信息发现锁的标志位为101,是偏向锁的标志。

升级为轻量级锁代码

在进入synchronized之前,已经激活了偏向锁,所以打印对象信息显示锁标志位是101,但是没有线程持有偏向锁,所以没有id。进入synchronized之后,线程持有偏向锁,这时候带线程id。接着开启了一个新的线程去尝试获取锁,由于在主线程的synchronized的代码块中我们睡眠了3秒,这时候主线程的锁还没有释放,新的线程就已经开始尝试获取锁了,这时候会发生锁竞争,而我们偏向锁进行id检查发现新线程的线程id与绑定的id不符,这时候发生锁升级,由偏向锁升级为轻量级锁。

 public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object lock = new Object();
        System.out.println("偏向锁(101)不带线程id" + ClassLayout.parseInstance(lock).toPrintable());
        synchronized (lock){
            System.out.println("偏向锁(101)带线程id" + ClassLayout.parseInstance(lock).toPrintable());
            Thread.sleep(3000);
        }

        Thread t1 = new Thread(() -> {
            synchronized (lock){
                System.out.println("轻量级锁(000)" + ClassLayout.parseInstance(lock).toPrintable());
            }
        });
        t1.start();
    }

 升级为重量级锁代码

升级为轻量级锁后,又开了两个新线程

当锁已经为轻量级锁时,两个新的线程对锁发生了竞争,这时候锁就会膨胀为重量级锁。

 public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object lock = new Object();
        System.out.println("偏向锁(101)不带线程id" + ClassLayout.parseInstance(lock).toPrintable());
        synchronized (lock){
            System.out.println("偏向锁(101)带线程id" + ClassLayout.parseInstance(lock).toPrintable());
            Thread.sleep(3000);
        }

        Thread t1 = new Thread(() -> {
            synchronized (lock){
                System.out.println("轻量级锁(000)" + ClassLayout.parseInstance(lock).toPrintable());
            }
        });
        t1.start();

        Thread.sleep(1000);
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                synchronized (lock) {
                    System.out.println("重量级锁(010)" + ClassLayout.parseInstance(lock).toPrintable());
                }
            }).start();
        }
    }

当所有带锁的代码块执行完毕后,在一定时间内没有发生锁竞争,对象中的锁标志位又会变为001,无锁标志。

 public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object lock = new Object();
        System.out.println("偏向锁(101)不带线程id" + ClassLayout.parseInstance(lock).toPrintable());
        synchronized (lock){
            System.out.println("偏向锁(101)带线程id" + ClassLayout.parseInstance(lock).toPrintable());
            Thread.sleep(3000);
        }

        Thread t1 = new Thread(() -> {
            synchronized (lock){
                System.out.println("轻量级锁(000)" + ClassLayout.parseInstance(lock).toPrintable());
            }
        });
        t1.start();

        Thread.sleep(1000);
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                synchronized (lock) {
                    System.out.println("重量级锁(010)" + ClassLayout.parseInstance(lock).toPrintable());
                }
            }).start();
        }
        Thread.sleep(5000);
        System.out.println("锁代码块执行完毕后:无锁状态001" + ClassLayout.parseInstance(lock).toPrintable());
    }

 总结

  1. 默认开启偏向锁,但有延迟性,延迟4秒激活偏向锁
  2. 开启偏向锁后,有新线程竞争时,偏向锁被撤销,升级为轻量级锁
  3. 当锁竞争更激烈时,轻量级锁升级为重量级锁

其他优化操作

1. 锁消除

编译器+JVM判断锁是否可以消除,如果可以就直接消除。比如一些时候只有单个线程在使用加锁方法,那么就可以进行锁消除,进行性能优化。

2. 锁粗化

一般来说我们希望锁的粒度越细(即加锁的代码越少),是希望可以让其他线能快速获得锁,以此提高多线程跑代码的性能。但是在一些场景下,可能并没有其他线程来抢占锁,这种情况下频繁的开锁释放锁,反而会消耗资源,拉低性能,这时候JVM就会自动把锁粗化,避免性能降低。

  • 31
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值