多线程【锁策略与CAS的ABA问题】

简介: 多线程【锁策略与CAS的ABA问题】

🍒一.常见锁策略

91f88c91a4e647ec98b30537f0c4ef7b.png

🍎1.1乐观锁与悲观锁


乐观锁:


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


悲观锁:

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


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

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


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


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

就好比同学 C 开始认为 “老师比较闲的”, 问问题都会直接去找老师.但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不忙, 再决定是否来问问题


🍎1.2读写锁


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

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


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

1.两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可——读与读之间不用上锁

2.两个线程都要写一个数据, 有线程安全问题.——写与写之间需要上锁

3.一个线程读另外一个线程写, 也有线程安全问题.———读与写之间也需要上锁


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

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

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


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

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

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


synchronized不是读写锁


🍎1.3重量级锁与轻量级锁


重量级锁: 加锁机制重度依赖了 OS 提供了 mutex——需要要做的事情多,开销大


1.大量的内核态用户态切换

2.很容易引发线程的调度

轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex——需要做的事情少,开销少

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

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


理解用户态 vs 内核态

想象去银行办业务.

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

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

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


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


🍎1.4挂起等待锁与自旋锁


挂起等待锁:

往往通过内核的一些机制来实现的,往往较重,挂起等待锁是一i中典型的重量级锁的实现方式

自旋锁:

往往通过用户态的一些机制来实现的,往往较轻,自旋锁是一种典型的轻量级锁的实现方式

自旋锁伪代码:

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


如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.一旦锁被其他线程释放, 就能第一时间获取到锁.


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

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


🍎1.5公平锁与非公平锁


公平锁:

遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

非公平锁:

不遵守 “先来后到”. B 和 C 都有可能获取到锁.

synchronized 是非公平锁.


🍎1.6可重入锁与不可重入锁


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

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

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


理解 “把自己锁死”

一个线程没有释放锁, 然后又尝试再次加锁.按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();

synchronized关键字锁都是可重入的


🍒二.CAS(Compare and swap)


🍎2.1什么是CAS


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

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


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

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

返回操作是否成功。

CAS 伪代码

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

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


🍎2.2CAS的应用


1. 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.


伪代码实现:

class AtomicInteger {
       private int value;
       public int getAndIncrement() {
                     int oldValue = value;
                     while ( CAS(value, oldValue, oldValue+1) != true) {
                     oldValue = value;
                      }
                      return 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;
             } 
}


🍒三.CAS的ABA问题


🍎3.1什么是ABA问题


ABA 的问题:

假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要先读取 num 的值, 记录到 oldNum 变量中.使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A


🍎3.2ABA 问题引来的 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 问题搞的鬼!!


🍎3.3ABA解决方案


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

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

2.真正修改的时候

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

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

这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的

手机记为版本1, 以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意

这是翻新机, 就买. 如果买家在意, 就可以直接略过.


对比理解上面的转账例子:

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

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

2.为了解决 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, 版本小于当前版本, 认为操作失败.

相关文章
|
6月前
|
设计模式 消息中间件 安全
【JUC】(3)常见的设计模式概念分析与多把锁使用场景!!理解线程状态转换条件!带你深入JUC!!文章全程笔记干货!!
JUC专栏第三篇,带你继续深入JUC! 本篇文章涵盖内容:保护性暂停、生产者与消费者、Park&unPark、线程转换条件、多把锁情况分析、可重入锁、顺序控制 笔记共享!!文章全程干货!
420 1
|
并行计算 安全 Java
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
在Python开发中,GIL(全局解释器锁)一直备受关注。本文基于CPython解释器,探讨GIL的技术本质及其对程序性能的影响。GIL确保同一时刻只有一个线程执行代码,以保护内存管理的安全性,但也限制了多线程并行计算的效率。文章分析了GIL的必要性、局限性,并介绍了多进程、异步编程等替代方案。尽管Python 3.13计划移除GIL,但该特性至少要到2028年才会默认禁用,因此理解GIL仍至关重要。
1204 16
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
|
算法 安全 Java
Java线程调度揭秘:从算法到策略,让你面试稳赢!
在社招面试中,关于线程调度和同步的相关问题常常让人感到棘手。今天,我们将深入解析Java中的线程调度算法、调度策略,探讨线程调度器、时间分片的工作原理,并带你了解常见的线程同步方法。让我们一起破解这些面试难题,提升你的Java并发编程技能!
628 16
|
供应链 安全 NoSQL
PHP 互斥锁:如何确保代码的线程安全?
在多线程和高并发环境中,确保代码段互斥执行至关重要。本文介绍了 PHP 互斥锁库 `wise-locksmith`,它提供多种锁机制(如文件锁、分布式锁等),有效解决线程安全问题,特别适用于电商平台库存管理等场景。通过 Composer 安装后,开发者可以利用该库确保在高并发下数据的一致性和安全性。
263 6
|
Java
线程池内部机制:线程的保活与回收策略
【10月更文挑战第24天】 线程池是现代并发编程中管理线程资源的一种高效机制。它不仅能够复用线程,减少创建和销毁线程的开销,还能有效控制并发线程的数量,提高系统资源的利用率。本文将深入探讨线程池中线程的保活和回收机制,帮助你更好地理解和使用线程池。
711 2
|
Java 关系型数据库 MySQL
【JavaEE“多线程进阶”】——各种“锁”大总结
乐/悲观锁,轻/重量级锁,自旋锁,挂起等待锁,普通互斥锁,读写锁,公不公平锁,可不可重入锁,synchronized加锁三阶段过程,锁消除,锁粗化
|
9月前
|
Java API 微服务
为什么虚拟线程将改变Java并发编程?
为什么虚拟线程将改变Java并发编程?
427 83
|
6月前
|
Java
如何在Java中进行多线程编程
Java多线程编程常用方式包括:继承Thread类、实现Runnable接口、Callable接口(可返回结果)及使用线程池。推荐线程池以提升性能,避免频繁创建线程。结合同步与通信机制,可有效管理并发任务。
279 6
|
11月前
|
机器学习/深度学习 消息中间件 存储
【高薪程序员必看】万字长文拆解Java并发编程!(9-2):并发工具-线程池
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发编程中的强力并发工具-线程池,废话不多说让我们直接开始。
415 0
|
7月前
|
算法 Java
Java多线程编程:实现线程间数据共享机制
以上就是Java中几种主要处理多线程序列化资源以及协调各自独立运行但需相互配合以完成任务threads 的技术手段与策略。正确应用上述技术将大大增强你程序稳定性与效率同时也降低bug出现率因此深刻理解每项技术背后理论至关重要.
504 16

热门文章

最新文章