总结锁策略, cas 和 synchronized 优化过程

1.锁策略

1.1. 乐观锁与悲观锁

乐观锁假定数据通常不会出现并发冲突,仅在数据提交更新时,才对是否产生并发冲突进行检测。若发现冲突,会向用户返回错误信息,由用户决定后续操作。

例如,在一个在线文档编辑系统中,如果多个用户同时编辑同一个文档的不同部分,乐观锁可以先让用户进行编辑,在保存时检查是否有冲突。

悲观锁则总是预设最坏情况,每次获取数据时都认为他人会修改,因此每次拿数据时都会上锁,导致其他想获取该数据的线程阻塞,直至获取到锁。

比如在银行系统中,处理账户资金操作时,通常会使用悲观锁来确保数据的绝对安全。

需要注意的是:Synchronized 初始使用乐观锁策略,当发现锁竞争频繁时,会自动切换为悲观锁策略。

1.2. 重量级锁与轻量级锁

锁的核心特性“原子性”源于 CPU 等硬件设备提供的支持。

(1)CPU 提供了“原子操作指令”。

(2)操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁。

(3)JVM 基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。

同时要知道,synchronized 并不仅仅是对 mutex 进行封装,其内部还进行了诸多其他工作。

重量级锁的加锁机制重度依赖 OS 提供的 mutex,会产生大量的内核态与用户态切换,容易引发线程调度。这两种操作成本较高,一旦涉及切换,情况就会有很大变化。

比如在一个高并发的数据库系统中,如果频繁使用重量级锁,可能会导致系统性能严重下降。

轻量级锁的加锁机制尽可能避免使用 mutex,尽量在用户态代码中完成,实在无法解决时才使用 mutex,因此只有少量的内核态与用户态切换,不太容易引发线程调度。

而且,synchronized 开始是一个轻量级锁,若锁冲突严重,则会转变为重量级锁。

1.3. 自旋锁(Spin Lock)

以往,线程抢锁失败后会进入阻塞状态,放弃 CPU,需等待很久才能再次被调度。但实际上,在多数情况下,尽管当前抢锁失败,不久后锁就会被释放,没必要放弃 CPU。此时便可使用自旋锁来解决这一问题。

若获取锁失败,立即再次尝试获取锁,不断循环,直至获取到锁为止。第一次获取锁失败,第二次尝试会很快进行。一旦锁被其他线程释放,就能第一时间获取到锁。

自旋锁是一种典型的轻量级锁实现方式,其优点在于没有放弃 CPU,不涉及线程阻塞和调度,锁一旦释放就能立刻获取。缺点是若锁被其他线程持有时间较长,会持续消耗 CPU 资源(而线程挂起等待时不消耗 CPU)。

例如,在一个短时间内频繁获取锁的场景中,自旋锁可以提高性能。

另外,synchronized 中的轻量级锁策略大概率是通过自旋锁的方式实现的。

1.4. 公平锁与非公平锁

公平锁遵循“先来后到”原则。例如 B 比 C 先来,当 A 释放锁后,B 能先于 C 获取到锁。

非公平锁则不遵循这一原则,B 和 C 都有获取到锁的可能。

需要注意的是,操作系统内部的线程调度可视为随机的。若不做额外限制,锁就是非公平锁。若要实现公平锁,需依赖额外的数据结构来记录线程的先后顺序。

公平锁和非公平锁并无绝对的好坏之分,关键取决于适用场景。

比如在一个对响应时间要求较高的系统中,可能更倾向于使用非公平锁。

同时,synchronized 是非公平锁。

1.5. 可重入锁和不可重入锁

可重入锁指的是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如在一个有锁操作的递归函数中,若递归过程中该锁不会阻塞自身,那么这个锁就是可重入锁(因此也叫递归锁)。

在 Java 中,只要是以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。但 Linux 系统提供的 mutex 是不可重入锁。

此外,synchronized 是可重入锁。

例如,在一个递归计算的函数中,如果使用的锁是不可重入的,就会导致死锁。

1.6. 读写锁

在多线程环境中,数据的读取方之间不会产生线程安全问题,但数据的写入方相互之间以及和读取方之间需要进行互斥。若在两种场景下都使用同一把锁,会造成极大的性能损耗,读写锁因此应运而生。

读写锁(readers-writer lock),从英文名可看出,执行加锁操作时需额外表明读写意图,多个读取者之间不互斥,而写者则要求与任何人互斥。

一个线程对于数据的访问,主要存在读数据和写数据两种操作:两个线程都只是读数据时,不存在线程安全问题,可直接并发读取;两个线程都要写数据,存在线程安全问题;一个线程读,另一个线程写,也存在线程安全问题。

读写锁将读操作和写操作区别对待。Java 标准库提供了 ReentrantReadWriteLock 类来实现读写锁。ReentrantReadWriteLock.ReadLock 类表示读锁,提供了 lock/unlock 方法进行加锁解锁;ReentrantReadWriteLock.WriteLock 类表示写锁,同样提供了 lock/unlock 方法进行加锁解锁。

其中,读加锁和读加锁之间不互斥,写加锁和写加锁之间互斥,读加锁和写加锁之间互斥。

读写锁特别适用于“频繁读,不频繁写”的场景,此类场景十分常见。并且,synchronized 不是读写锁。

例如,在一个高并发的缓存系统中,如果读操作远远多于写操作,使用读写锁可以显著提高性能。

2.CSA

CAS是操作系统/硬件,给JVM提供的另外一种更轻量的原子操作的机制

CAS是CPU提供的一个特殊的指令,compare and swap 是一条指令(原子的)

这个指令先比较内存和寄存器的值,如果相等,则把寄存器和另一个值进行交换;如果不相等,则不进行操作.

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

比较 A 与 V 是否相等。(比较)

如果比较相等,将 B 写入 V。(交换)

返回操作是否成功。

当多个线程同时对某个资源进⾏CAS操作,只能有⼀个线程操作成功,但是并不会阻塞其他线程,其他
线程只会收到操作失败的信号。
CAS可以视为是⼀种乐观锁.(或者可以理解成CAS是乐观锁的⼀种实现⽅式

CAS的ABA问题

数值本身为0,执行CAS之前,另一个线程把值从0->100,100->0

不是没线程穿插,而是其他线程穿插过程中把值 改了又改了回去

ABA问题引来的BUG
⼤部分的情况下,t2线程这样的⼀个反复横跳改动,对于t1是否修改num是没有影响的但是不排除⼀
些特殊情况.
假设滑稽⽼哥有100存款.滑稽想从ATM取50块钱.取款机创建了两个线程,并发的来执⾏-50操作.
我们期望⼀个线程执⾏-50成功,另⼀个线程-50失败.
如果使⽤CAS的⽅式来完成这个扣款过程就可能出现问题.

正常的过程
        1. 存款100.线程1获取到当前存款值为100,期望更新为50;线程2获取到当前存款值为100,期望更新为50.
        2. 线程1执⾏扣款成功,存款被改成50.线程2阻塞等待中.
        3. 轮到线程2执⾏了,发现当前存款为50,和之前读到的100不相同,执⾏失败.

异常的过程
        1. 存款100.线程1获取到当前存款值为100,期望更新为50;线程2获取到当前存款值为100,期望更新为50.
        2. 线程1执⾏扣款成功,存款被改成50.线程2阻塞等待中.
        3. 在线程2执⾏之前,滑稽的朋友正好给滑稽转账50,账⼾余额变成100!!
        4. 轮到线程2执⾏了,发现当前存款为100,和之前读到的100相同,再次执⾏扣款操作

解决方案

给要修改的值,引⼊版本号.在CAS⽐较数据当前值和旧值的同时,也要⽐较版本号是否符合预期.
• CAS操作在读取旧值的同时,也要读取版本号.
• 真正修改的时候,
• 如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1.
• 如果当前版本号⾼于读到的版本号.就操作失败(认为数据已经被修改过了).

3.Synchronized的优化过程

为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

3.1 偏向锁(Biased Locking)


第一个尝试加锁的线程优先进入偏向锁状态。偏向锁是Java虚拟机(JVM)中用于提高线程同步性能的一种优化技术。在多线程环境中,对共享资源进行同步操作,需要使用锁(synchronized)来保证线程的互斥访问。传统的锁机制存在竞争和上下文切换的开销,对性能会有一定的影响。而偏向锁则是为了减少无竞争情况下的锁操作开销而引入的。

偏向锁不是真的“加锁”,只是先让线程针对锁对象有个标记,记录某个锁属于哪个线程。

它的基本思想是,当一个线程获取锁并访问同步代码块时,如果没有竞争,那么下次该线程再次进入同步块时,无需再次获取锁。这是因为在无竞争的情况下,假设一个线程反复访问同步代码块,无需每次都去竞争锁,只需判断锁是否处于偏向状态;如果是,那么直接进入同步代码块即可。

通俗来说就是,如果后续没有其他线程再来竞争该锁,那么就不用真的加锁了,从而避免了加锁解锁的开销。 但一旦还有其他线程来尝试竞争这个锁,偏向锁就立即升级成真的锁(轻量级锁),此时别的线程就只能等待了。这样做既保证了效率,也保证了线程安全。

如何判定有没有别的线程来竞争该锁?
偏向锁是synchronized内部做的工作。synchronized会针对某个对象进行加锁,这个所谓的“偏向锁”正是在这个对象里头做一个标记。

由于一开始已经在锁对象中记录了当前锁属于哪个线程,因此很容易识别当前申请锁的线程是否是一开始就记录了的线程。

如果另一个线程正在尝试对同一个对象进行加锁,也会先尝试做标记,但结果却发现已经有标记了。于是JVM就会通知先来的线程,让它赶快把锁升级一下。

偏向锁本质上是“延迟加锁”,即能不加锁就不加锁,尽量避免不必要的加锁开销;但是该做的标记还是得做的,否则就无法区分何时需要真正加锁。

举个栗子理解偏向锁
假设男主是一个锁,女主是一个线程。如果只有女主和男主暧昧(即只有这一个线程来使用这个锁),那么即使男主和女主不领证结婚(避免了高成本操作),也可以一直生活下去。

但是如果此时有女配出现,也尝试竞争男主,想和男主搞暧昧,那么此时女主就必须当机立断,不管领证结婚这个操作成本多高,也势必要把这个动作完成(即真正加锁),让女配死心。

所以说,偏向锁 = 搞暧昧~~

3.2 自旋锁

自旋锁是一种典型的轻量级锁的实现方式,它通常是纯用户态的,不需要经过内核态。按之前的方式,线程在抢锁失败后即进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,在大部分情况下虽然当前抢锁失败,但过不了很久锁就会被释放,没必要就放弃 CPU。这个时候就可以使用自旋锁来处理这样的问题。

自旋锁是一种忙等待锁的机制。当一个线程需要获取自旋锁时,它会反复地检查锁是否可用,而不是立即被阻塞。如果获取锁失败(锁已经被其他线程占用),当前线程会立即再尝试获取锁,不断自旋(空转)等待锁的释放,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。这样能保证一旦锁被其他线程释放,当前线程能第一时间获取到锁。

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

自旋锁适用于保护临界区较小、锁占用时间短的情况,因为自旋会消耗CPU资源。自旋锁通常使用原子操作或特殊的硬件指令来实现。

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

此处的轻量级锁是通过 CAS 来实现。通过 CAS 检查并更新一块内存 (比如比较 null 与该线程引用是否相等),如果更新成功,则认为加锁成功;如果更新失败,则认为锁被占用,继续自旋式的等待,期间并不放弃 CPU 资源。

由于自旋操作是一直让 CPU 空转,比较浪费 CPU 资源,因此此处的自旋不会一直持续进行,而是达到一定的时间或重试次数就不再自旋了。这也就是所谓的 “自适应”。

3.3重量级锁

如果竞争进一步激烈 , 自旋不能快速获取到锁状态 , 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .(也就是上面的如果排队的人打印的太多了,此时我们就需要找柜员进行操作也就是从用户态转换成内核态)

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


转换成内核态的时候,我们就要看内核态是否有没有被人占用,如果被人占用,依旧阻塞等待,如果没有,那么就加锁成功,并切换成用户态进行操作一些事情。被人占用后,阻塞等待,等到其他线程释放了后,就唤醒该锁,并重新获取当前锁。

3.4 其他的优化操作

锁消除
也是一种编译器优化的手段,编译器会自动针对你当前写的加锁代码,做出判定,如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized给优化。(编译器只会在自己非常有把握的时候,才会进行锁消除)

我们再javase中学到,StringBuilder和StringBuffer,我们当时说StringBuffer是安全的,StringBuilder是不安全的,因为StringBuffer是带有synchronized的,而StringBuilder是不带有synchronized的。

//举个例子,有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();

sb.append("a");

sb.append("b");

sb.append("c");

sb.append("d");

append等方法,都是带有synchronized,如果上述代码都只是在同一个线程中执行,此时就没必要加锁了,JVM就把锁去掉了(目的:是为了节省开销)

锁粗化

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

锁的粒度:表示synchronized包含的代码范围是大还是小,范围越大,粒度越粗;范围越小,粒度越细

锁的粒度细了,能够更好的提高线程的并发,但是也会增加“加锁解锁”的次数

实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.

但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释 放锁.

举个栗子理解锁粗化

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

方式一: 

打电话, 交代任务1, 挂电话. 

打电话, 交代任务2, 挂电话. 

打电话, 交代任务3, 挂电话. 

方式二:

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

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

可以看到 , synchronized 的策略比较复杂的 , 在背后做了很多事情 , 目的为了让程序猿哪怕啥都不懂 ,也不至于写出特别慢的程序.

相关推荐

  1. Synchronized升级过程

    2024-07-17 18:14:01       45 阅读
  2. 多线程(策略, synchronized 对应的策略)

    2024-07-17 18:14:01       41 阅读
  3. synchronized底层加释放的原理

    2024-07-17 18:14:01       30 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-17 18:14:01       66 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-17 18:14:01       70 阅读
  3. 在Django里面运行非项目文件

    2024-07-17 18:14:01       57 阅读
  4. Python语言-面向对象

    2024-07-17 18:14:01       68 阅读

热门阅读

  1. springboot+js实现SSE消息推送

    2024-07-17 18:14:01       16 阅读
  2. 鼠标的形状

    2024-07-17 18:14:01       19 阅读
  3. 视频网站适用于什么服务器类型呢?

    2024-07-17 18:14:01       22 阅读
  4. 重要的单元测试

    2024-07-17 18:14:01       21 阅读
  5. 软件测试bug周期

    2024-07-17 18:14:01       23 阅读
  6. #if defined(WEBRTC_USE) webrtc.a的宏机制

    2024-07-17 18:14:01       17 阅读
  7. bug【创作模板】

    2024-07-17 18:14:01       19 阅读
  8. 计算机视觉6 计算机视觉---风格迁移

    2024-07-17 18:14:01       21 阅读
  9. Python 可变参数 *args 和 **kwargs 的用法

    2024-07-17 18:14:01       17 阅读