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

1.乐观锁vs悲观锁

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这 样别人想拿这个数据就会阻塞直到它拿到锁.(预测接下来冲突的概率很大,就要做另一类操作)

乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。(预测接下来锁冲突的概率不大,就要做另一类操作)

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

同学 A 认为 "老师是比较忙的, 我来问问题, 老师不一定有空解答". 因此同学 A 会先给老师发消息: "老师 你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 

如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁. 

同学 B 认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学 B 直接就来找老师.(没 加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 

也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁. 

这两种思路不能说谁优谁劣, 而是看当前的场景是否合适. 

如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 "白跑很多趟", 耗费额 外的资源. 

如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低. 


synchronized就既是一个悲观锁,也是一个乐观锁(自适应锁)

当前锁冲突概率不大,以乐观锁的方式运行,往往是纯用户态执行的.

一旦发现锁冲突概率大了,以悲观锁的方式运行,往往要进入内核,对当前线程挂起等待


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

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

设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 “提交版本必须大于记录 当前版本才能执行更新余额”(关于版本号下面会详细讲)

2.普通的互斥锁vs读写锁

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

synchronized就属于普通的互斥锁,两加锁操作之间会发生竞争

读写锁把锁操作细化了,加锁分成了“加读锁”“加写锁”

情况一:
    线程A尝试加写锁
    线程B尝试加写锁
此时AB产生竞争,和普通的锁没啥区别    
情况二:
    线程A尝试加读锁
    线程B尝试加读锁
此时AB不尝试竞争,锁相当于没加一样(多线程读,不设计修改,线程是安全的) (这种情况其实是相当普遍的)   
情况三:
    线程A尝试加读锁
    线程B尝试加写锁
此时AB产生竞争,和普通的锁没啥区别    

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

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

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

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

其中,

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

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

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

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

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

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

3.重量级锁vs轻量级锁

重量级锁通俗来说就是锁开销比较大,做的工作比较多

轻量级锁通俗来说就是锁开销比较小,做的工作比较少

悲观锁,经常会是重量级锁

乐观锁,经常会是轻量级锁

当然,这个规律不绝对。

重量级锁:主要依赖了操作系统提供的锁,使用这种锁,就容易产生阻塞等待

轻量级锁:主要尽量的避免使用操作系统提供的锁,而是尽量在用户态来完成功能,尽量避免用户态和内核态的切换,尽量避免挂起等待

synchronized是自适锁,既是轻量级锁,又是重量级锁,是根据锁冲突的情况来的,冲突的不高就是轻量级,冲突的很高就是重量级

上面说的好理解,下面说点有点官方的


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

  • CPU 提供了 “原子操作指令”.

  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.

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

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

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

  • 大量的内核态用户态切换

  • 很容易引发线程的调度

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

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

  • 少量的内核态用户态切换.

  • 不太容易引发线程调度.

理解用户态 vs 内核态 想象去银行办业务.

在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.

在窗口内, 工作人员做, 这是内核态. 内核态的时间成本是不太可控的.

如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.

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


4.自旋锁

自旋锁和挂起等待锁

自旋锁是轻量级锁的具体实现.(自旋锁是轻量级锁,也是乐观锁)

挂起等待锁是重量级锁的具体实现(挂起等待锁是重量级锁,也是悲观锁)

自旋锁:当发现锁冲突时,不会挂起等待,会迅速再来尝试看这个锁能不能获取到

自旋锁伪代码:

while (抢锁(lock) == 失败) {}
  1. 一旦锁被释放,就可以第一时间获取到
  2. 如果锁一直不释放,就会立即尝试获取锁,无限循环,直到获取到锁为止,就会消耗大量CPU

挂起等待锁:发现锁冲突,就挂起等待

  1. 一旦锁被释放,不能第一时间获取到
  2. 在锁被其他线程占用时,会放弃CPU资源

synchronized作为轻量级锁时,内部是自旋锁,作为重量级锁时,内部是挂起等待锁

理解自旋锁 vs 挂起等待锁

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

挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意, 

这个很长的时间间隔里, 女神可能已经换了好几个男票了). 

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

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

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.

  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的).


5.公平锁 vs 非公平锁

什么情况算公平?

认为符合“先来后到”这样的规则就是公平的,”机会均等“的竞争反而是不公平的

操作系统内部对于挂起等待锁就是非公平的,如果要想使用公平锁,就需要搞额外的数据结构来进行控制实现.公平锁和非公平锁没有好坏之分, 关键还是看适用场景.

synchronized是非公平锁


6.可重入锁 vs 不可重入锁

public class Demo26 {
    private static void func() {
        // .......进行一些多线程操作
        // 第一次加锁
        synchronized (Demo26.class) {
            // 第二次加锁
            synchronized (Demo26.class) {

            }
        }
    }


    public static void main(String[] args) {
        func();
    }
}

在这里插入图片描述

为了避免上述问题,就引入了“可重入锁”,一个线程,可以对同一个锁,反复加锁多次也没事

可重入锁,在内部记录这个锁是哪个线程获取到的,如果发现当前加锁的线程和持有锁的线程是同一个,则不挂起等待,而是直接获取到锁,同时还会给内部加上个计数器,记录当前是第几次加锁了,通过计数器来控制啥时候释放锁。

synchronized就属于可重入锁

7.CAS

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

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

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

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

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

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

  3. 返回操作是否成功。
    在这里插入图片描述
    上面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.

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

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

7.2 CAS的ABA问题

在CAS里无法区分数据始终就是A,还是从A->B->A,在这种情况下CAS就可能有bug

假设 滑稽老哥 有 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 相同, 再次执行扣款操作

这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼!!

**解决方案 **

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

真正修改的时候,

  • 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.

  • 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

对比理解上面的转账例子

假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50

操作.

我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.

为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.

  1. 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,

版本号为 1, 期望更新为 50.

2)线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.

3)在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.

4)轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读 到的版本号为 1, 版本小于当前版本, 认为操作失败.

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

8.synchronized底层工作

synchronized使用锁策略:

  1. 既是悲观锁,也是乐观锁,开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 既是轻量级锁,也是重量级锁(自适应),开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁来实现
  4. 不是读写锁
  5. 是非公平锁
  6. 是可重入锁

8.2 加锁工作过程

synchronized在加锁的时候要经历几个阶段:

  1. 无锁(没加锁)
  2. 偏向锁(刚开始加锁,未产生竞争的时候)
  3. 轻量级锁(产生锁竞争了)
  4. 重量级锁(锁竞争的更激烈)

会根据情况,依次升级

1) 偏向锁

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

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

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

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

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

但是该做的标记还是得做的, 否则无法区分何时需要真正加锁. 这个过程类似于单例模式中的“懒汉模式”,必要时再加锁,节省开销

举个栗子理解偏向锁

假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证
结婚(避免了高成本操作), 也可以一直幸福的生活下去. 

但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作
完成了, 让女配死心. 

2) 轻量级锁

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

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

  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)

  • 如果更新成功, 则认为加锁成功

  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

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

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

也就是所谓的 “自适应”

3) 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁 此处的重量级锁就是指用到内核提供的 mutex .

  • 执行加锁操作, 先进入内核态.

  • 在内核态判定当前锁是否已经被占用 如果该锁没有占用, 则加锁成功, 并切换回用户态.

  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.

  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.

2.3 其他优化操作

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 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂,

也不至于写出特别慢的程序.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
JVM优化是为了提高多线程程序的性能和并发度。其中膨胀是指当一个线程获取失败时,JVM会将其自旋一定次数,如果还没有获得,就会将膨胀升级。 膨胀的过程一般分为以下三个阶段: 1. 自旋(Spin Locking):当一个线程获取失败时,JVM会将其自旋一定次数,尝试获取。自旋的目的是为了减少线程切换的开销,因为线程进入自旋状态时不会释放CPU资源。如果自旋次数超过了阈值,那么就会进入下一个阶段。 2. 轻量级(Lightweight Locking):在这个阶段,JVM会为争用的线程在对象头上分配一些空间,用于存储记录。这个记录包含了的指针、持有的线程ID以及一些标志位等信息。如果记录的CAS操作成功,那么当前线程就获得了。如果CAS操作失败,那么就会进入下一个阶段。 3. 重量级(Heavyweight Locking):在这个阶段,JVM会将升级为重量级,也就是使用操作系统提供的互斥(Mutex)来保证线程的安全性。重量级的代价很高,因为它会涉及到用户态和内核态之间的切换,所以尽量避免的膨胀。 的膨胀过程可以通过JVM参数来调节,例如可以设置自旋次数、启用偏向等。在实际应用中,应该尽量避免的竞争,采用分离、读写、无编程等技术来提高程序的并发度和性能。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值