【多线程】锁策略、CAS、Synchronized

简介: 锁策略, cas 和 synchronized 优化过程

 目录

常见的锁策略

乐观锁 vs 悲观锁

悲观锁:

乐观锁:

读写锁

重量级锁 vs 轻量级锁

自旋锁(Spin Lock)

公平锁 vs 非公平锁

可重入锁 vs 不可重入锁

CAS

什么是 CAS

CAS 是怎么实现的

CAS 有哪些应用

1) 实现原子类

2) 实现自旋锁

CAS 的 ABA 问题

什么是 ABA 问题

ABA 问题引来的 BUG

解决方案

Synchronized 原理

基本特点

加锁工作过程

其他的优化操作

锁消除

锁粗化


常见的锁策略

乐观锁 vs 悲观锁

悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观锁:

假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

举个栗子: 同学 A 和 同学 B 想请教老师一个问题.

同学 A 认为 "老师是比较忙的, 我来问问题, 老师不一定有空解答". 因此同学 A 会先给老师发消息: "老师你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题.如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.

同学 B 认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 "版本号" 来解决.

假设我们需要多线程修改 "用户账户余额".

设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录当前版本才能执行更新余额"

1) 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1,balance=100 ).

2) 线程 A 操作的过程中并从其帐户余额中扣除 50 100-50 ),线程 B 从其帐户余额中扣除 20( 100-20 );

3) 线程 A 完成修改工作,将数据版本号加1 version=2 ),连同帐户扣除后余额( balance=50),写回到内存中;

4) 线程 B 完成了操作,也将版本号加1 version=2 )试图向内存中提交数据( balance=80),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新的乐观锁策略。就认为这次操作失败.

读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需 要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复 数读者之间并不互斥,而写者则要求与任何人互斥。

一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

    • 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
    • 两个线程都要写一个数据, 有线程安全问题.
    • 一个线程读另外一个线程写, 也有线程安全问题.

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

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

      其中

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

        只要是涉及到 "互斥", 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.

        因此尽可能减少 "互斥" 的机会, 就是提高效率的重要途径.

        读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的).

        Synchronized 不是读写锁.

        重量级锁 vs 轻量级锁

        锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

          • CPU 提供了 "原子操作指令".
          • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
          • JVM 基于操作系统提供的互斥锁, 实现了 synchronized ReentrantLock 等关键字和类.

          image.gif编辑

          synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作.

          重量级锁: 加锁机制重度依赖了 OS 提供了 mutex

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

            这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田".

            轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使mutex.

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

              synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.

              自旋锁(Spin Lock

              按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.

              但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题.

              自旋锁伪代码:

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

              image.gif

              如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.

              一旦锁被其他线程释放, 就能第一时间获取到锁.

              理解自旋锁 vs 挂起等待锁

              想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~

              挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).

              自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.

              自旋锁是一种典型的 轻量级锁 的实现方式.

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

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

                公平锁 vs 非公平锁

                假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后

                C 也尝试获取锁, C 也获取失败, 也阻塞等待.

                当线程 A 释放锁的时候

                公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

                非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁.

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

                  synchronized 是非公平锁.

                  可重入锁 vs 不可重入锁

                  可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁

                  比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。

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

                  而 Linux 系统提供的 mutex 是不可重入锁.

                  CAS

                  什么是 CAS

                  CAS: 全称Compare and swap,字面意思:”比较并交换,一个 CAS 涉及到以下操作:

                  我们假设内存中的原数据V,旧的预期值A,需要修改的新值B

                    1.  比较 A V 是否相等。(比较)
                    2.  如果比较相等,将 B 写入 V。(交换)
                    3.  返回操作是否成功。

                    CAS 伪代码

                    这里写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解

                    CAS 的工作流程.

                    boolean CAS(address, expectValue, swapValue) {
                     if (&address == expectedValue) {
                       &address = swapValue;
                            return true;
                       }
                        return false;
                    }

                    image.gif

                    当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

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

                    CAS 是怎么实现的

                    针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

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

                      简而言之,是因为硬件予以了支持,软件层面才能做到。

                      CAS 有哪些应用

                      1) 实现原子类

                      标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.

                      典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

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

                      image.gif

                      伪代码实现:

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

                      image.gif

                      假设两个线程同时调用 getAndIncrement

                      1) 两个线程都读取 value 的值到 oldValue . (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)

                      2) 线程1 先执行 CAS 操作. 由于 oldValue value 的值相同, 直接进行对 value 赋值.

                        • CAS 是直接读写内存的, 而不是操作寄存器.
                        • CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.

                        3) 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue value 不相等, 不能进行赋值. 因此需要进入循环.

                        4) 线程2 接下来第二次执行 CAS, 此时 oldValue value 相同, 于是直接执行赋值操作.

                        5) 线程1 和 线程2 返回各自的 oldValue 的值即可.

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

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

                        image.gif

                        CAS ABA 问题

                        什么是 ABA 问题

                        假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
                        接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要

                          • 先读取 num 的值, 记录到 oldNum 变量中.
                          • 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.

                          但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A

                          这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手

                          机.

                          ABA 问题引来的 BUG

                          image.gif编辑

                          解决方案

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

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

                          · 真正修改的时候,

                            • 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
                            • 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

                            这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的手机记为版本1, 以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意这是翻新机, 就买. 如果买家在意, 就可以直接略过.

                            在 Java 标准库中提供了 AtomicStampedReference<E> 类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能.

                            Synchronized 原理

                            基本特点

                            结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

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

                              加锁工作过程

                              JVM synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。

                              image.gif编辑

                              1) 偏向锁

                              第一个尝试加锁的线程, 优先进入偏向锁状态.

                              偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.

                              如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)

                              如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

                              偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.

                              但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.

                              2) 轻量级锁

                              随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).

                              此处的轻量级锁就是通过 CAS 来实现.

                                • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
                                • 如果更新成功, 则认为加锁成功
                                • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

                                自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.

                                因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.

                                也就是所谓的 "自适应"

                                3) 重量级锁

                                如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁

                                此处的重量级锁就是指用到内核提供的 mutex .

                                  • 执行加锁操作, 先进入内核态.
                                  • 在内核态判定当前锁是否已经被占用
                                  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
                                  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
                                  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.

                                  其他的优化操作

                                  锁消除

                                  编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.

                                  什么是 "锁消除"

                                  有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

                                  StringBuffer sb = new StringBuffer();
                                  sb.append("a");
                                  sb.append("b");
                                  sb.append("c");
                                  sb.append("d");
                                  image.gif

                                  此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.

                                  锁粗化

                                  一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

                                  image.gif编辑

                                  举个栗子理解锁粗化

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

                                  方式一:

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

                                    方式二:

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

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

                                      相关文章
                                      |
                                      26天前
                                      |
                                      Java 开发者
                                      在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
                                      在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
                                      44 4
                                      |
                                      1月前
                                      |
                                      Java
                                      线程池内部机制:线程的保活与回收策略
                                      【10月更文挑战第24天】 线程池是现代并发编程中管理线程资源的一种高效机制。它不仅能够复用线程,减少创建和销毁线程的开销,还能有效控制并发线程的数量,提高系统资源的利用率。本文将深入探讨线程池中线程的保活和回收机制,帮助你更好地理解和使用线程池。
                                      61 2
                                      |
                                      2月前
                                      |
                                      调度 Android开发 开发者
                                      构建高效Android应用:探究Kotlin多线程优化策略
                                      【10月更文挑战第11天】本文探讨了如何在Kotlin中实现高效的多线程方案,特别是在Android应用开发中。通过介绍Kotlin协程的基础知识、异步数据加载的实际案例,以及合理使用不同调度器的方法,帮助开发者提升应用性能和用户体验。
                                      55 4
                                      |
                                      2月前
                                      |
                                      Java 开发者
                                      在 Java 多线程编程中,Lock 接口正逐渐取代传统的 `synchronized` 关键字,成为高手们的首选
                                      【10月更文挑战第6天】在 Java 多线程编程中,Lock 接口正逐渐取代传统的 `synchronized` 关键字,成为高手们的首选。相比 `synchronized`,Lock 提供了更灵活强大的线程同步机制,包括可中断等待、超时等待、重入锁及读写锁等高级特性,极大提升了多线程应用的性能和可靠性。通过示例对比,可以看出 Lock 接口通过 `lock()` 和 `unlock()` 明确管理锁的获取和释放,避免死锁风险,并支持公平锁选择和条件变量,使其在高并发场景下更具优势。掌握 Lock 接口将助力开发者构建更高效、可靠的多线程应用。
                                      25 2
                                      |
                                      2月前
                                      |
                                      Java 应用服务中间件 测试技术
                                      Java21虚拟线程:我的锁去哪儿了?
                                      【10月更文挑战第8天】
                                      41 0
                                      |
                                      2月前
                                      |
                                      安全 调度 数据安全/隐私保护
                                      iOS线程锁
                                      iOS线程锁
                                      29 0
                                      |
                                      2月前
                                      |
                                      安全
                                      【多线程】CAS、ABA问题详解
                                      【多线程】CAS、ABA问题详解
                                      21 0
                                      |
                                      2月前
                                      |
                                      Java 编译器 程序员
                                      【多线程】synchronized原理
                                      【多线程】synchronized原理
                                      61 0
                                      |
                                      2月前
                                      |
                                      存储 消息中间件 资源调度
                                      C++ 多线程之初识多线程
                                      这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
                                      48 1
                                      C++ 多线程之初识多线程
                                      |
                                      2月前
                                      |
                                      Java 开发者
                                      在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
                                      【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
                                      20 3