当前位置: 首页 > news >正文

Java并发编程:锁机制

1. 数据竞争和临界区

在讨论锁之前,我们需要先讨论一下数据竞争问题和临界区。

1.1 数据竞争

数据竞争是当有两个或多个同时执行的线程在并发访问同一个内存地址并且至少有一个线程尝试写入数据的时候而引起的数据不一致问题。避免数据竞争的方法有三种:

  1. 使用同步机制;
  2. 通过设计,将全局共享变量变为线程私有的数据;
  3. 改变变量的可视范围;

最典型的数据竞争场景就是多个线程并发的执行count++操作,因为count++ 实际上是一组 先读取,再计算,最后写入 的复合操作,在多线程环境下,这种复合操作会因为操作执行顺序的不确定性而发生数据不一致问题。

1.2 临界区

临界区就是线程需要互斥访问的代码区域,而线程进入临界区需要遵循如下原则:

  1. 多个线程同时请求进入临界区,但同一时刻只允许有一个线程进入;
  2. 当有线程在执行临界区代码的时候,其他线程需要等待,不允许进入临界区;
  3. 临界区的操作必须是有限的,不然会发生线程死锁;
  4. 一个线程执行完临界区后,需要选取下一个线程进入临界区,未被选中的继续等待;

根据临界区的作用,我们可以将线程访问共享数据的代码块构造成临界区,这样就可以有效的避免数据竞争问题。如何构造临界区呢?就是采用同步机制,当一个线程试图访问临界区的时候,同步机制会判断当前是否有其他线程正在使用临界区,如果没有,该线程进入临界区,否则该线程进入等待状态,直至临界区被释放,而最基本同步机制就是锁机制。

2. 锁机制

程序中的锁的作用其实和日常生活中的锁的作用是类似的。如果将临界区看作一个房间,那么锁就是房间的门锁,如果张三想要进入这个房间,首先看看门锁了没有,如果没有锁,那么张三就进入这个房间,同时把门锁锁上,这样后来的人就无法进入这个房间,如果想要进入,那么只能等待,直到张三打开锁从房间里面出来。我们可以利用锁机制来实现临界区,这就需要考虑如下几个问题:

  1. 如何在程序中来模拟锁;
  2. 当线程无法进入临界区时,如何等待;
  3. 当多个线程都在等待进入临界区时,如何决定他们进入临界区的顺序;

针对这三个问题,我们可以将锁机制分为3个组成部分:加锁机制,等待机制,排队机制;每种机制都是用来解决特定的问题。

1.1 加锁机制

锁是一个抽象的概念,在计算机系统中如何模拟锁呢?一般是使用一个变量来模拟锁,用不同的值来表示锁的不同状态,加锁和开锁操作就是将对应的状态值写入变量,例如:使用整数变量 mutex 表示锁,值0 表示锁打开,值1 表示锁关闭。在实际的运行过程中,当一个线程在尝试获取锁时,都是首先判断锁的状态,如果锁是unlock状态,那么该线程在获取锁的同时还需要将锁的状态改为lock状态来防止其他线程获取锁,这是典型的 先判断-后写入 操作,所以这就要求系统能够将锁变量的这种复合操作原子化,而底层的操作系统都会提供互斥锁,这些互斥锁为我们实现锁机制提供了操作系统级别的支持(通常我们将利用操作系统提供的互斥锁来实现的锁称为有锁算法,而不用系统互斥锁实现锁的方法叫做无锁算法,比如CAS算法)。所以当我们在应用程序中需要实现锁的时候,可以使用操作系统提供的这种锁变量。如果我们不使用操作系统的锁变量那么能不能在应用程序中实现自己的锁呢?这个答案是可以,因为如果不使用系统提供的锁变量,那么只能使用应用程序内部的私有变量来模拟锁,这就需要应用程序能够将私有变量的 先判断-后写入 的复合操作原子化,现在有一种算法就可以实现这种需求,就是 CAS 算法。所以目前的加锁方式有两种:一种是使用系统互斥量的加锁方式,一种是使用私有信号量(就是应用程序的普通变量)加 CAS 算法的加锁方式。

1.1.1 基于系统互斥量的加锁机制

操作系统一般都会提供互斥量和信号量,这些系统级别的互斥量和信号量实现了基本的并发机制,应用程序可以通过系统互斥量来实现锁功能,这种方式由于利用了底层操作系统提供的资源因此会产生系统调用,发生用户态到系统态的转换。C语言中的 pthread_mutex_t 就代表系统互斥量,JVM中的重量级锁就是基于该互斥量实现的。

  • 优点:采用系统互斥量进行加锁的机制是一种悲观策略,这种策略是为了防止任何可能出现的线程安全问题,因此在每次执行临界区代码的时候,都要将其锁住,使得同一时刻只有一个线程执行,这样就可以保证线程的绝对安全,尤其是在竞争非常激烈的应用场景下;而且在应用开发的角度来看,因为系统互斥锁不但提供了互斥变量,而且同时提供了线程阻塞、排队、唤醒等机制的实现,所以我们在使用互斥锁的时候可以无需考虑线程调度等问题。
  • 缺点:每次加锁,解锁都会发生系统调用,产生用户态到内核态的转换,这种操作比较重,因此依赖操作系统的互斥量的锁也被称为重量级锁,尤其当临界区代码执行很快,并且竞争非常少的情况下,这种系统调用的开销就会过大,影响到系统性能;同时因为依赖底层操作系统的互斥量,在应用开发的角度来看这种方式缺乏灵活性;
  • 适用场景:适用于竞争比较激烈,并且临界区代码执行时间比较长的情况,这种情况下,由于竞争激烈,为了保证线程安全,因此加锁操作变得非常有必要,并且由于临界区代码执行时间长,加锁产生的开销相对与临界区代码的执行开销,比例很小,因此对性能的影响也很小。
1.1.2 基于私有信号量的加锁机制

前面我们说了,计算机系统中需要支持检查 - 写入原子操作的原子变量来模拟锁,操作系统提供的互斥量就是最底层的原子变量,上层应用可以利用系统互斥量来实现锁。当然应用程序也可以使用自己进程内部的私有变量配合 CAS 算法来实现原子变量,然后利用这种私有信号量来进行加锁操作。
CAS 全称是 Compare And Swap,就是先比较后交换,可以原子执行先检查,后写入的复合操作;

CAS 定义:CAS(V, E, N)
V:表示要更新的变量
E:表示预期值
N:表示新值
该算法执行一次的过程: 如果 V 的值等于 E ,那么更新 V 的值为 N 并返回成功,否则返回失败;

CAS算法

CAS 算法本身是 CPU 指令级的操作,需要底层硬件的支持,当然现在主流的 CPU 都支持 CAS 操作,而且只有一步原子操作,所以非常快。

CAS 算法的定义我们可以看到,任何变量都可以配合 CAS 算法实现检查写入操作的原子化,因此采用私有信号量加 CAS 就可以用来实现锁。

私有信号量实际就是进程内的一个变量,或者进程拥有的内存空间中某一段地址中的值,它对操作系统是透明的,操作系统无法感知到私有信号量的存在。

  • 优点:由于每次加锁或解锁操作都是通过CAS算法操作进程内的私有变量,不会产生系统调用和用户态内核态的转换,因此这种操作十分轻量级,这种锁也被成为轻量级锁,而且由于不依赖底层操作系统的互斥锁,又被称为无锁算法,同时这种算法给应用编程带来了很大的灵活性。
  • 缺点:CAS 算法会出现 ABA 问题,通过加入版本号(version)可以帮助解决 ABA 问题,但这又增加了编程复杂度;而且采用私有信号量加 CAS 算法来实现锁,必须要考虑线程调度问题,这也带来编程复杂度;
  • 适用场景:在锁竞争不激烈,或者临界区代码执行很快的场景。

ABA 问题:应用程序的内存空间一般分为堆空间和栈空间,栈空间由线程私有,堆空间是共享的,当线程需要对共享变量执行 CAS 操作的时候,需要先将共享变量的值读到栈空间,然后再执行 CAS 操作,这个过程带来了 ABA 问题;例如:线程T1T2共享一个变量X,都要对变量X 执行_CAS_ 操作:如果X的值等于A那么就更新为B并且返回true,否则返回false;在时刻t,发生了如下事件:T1将共享变量X的值读取到栈中,准备执行 CAS 操作,并且还未执行;同时T2也将X的值读取到栈中,并且成功执行了 CAS 操作,之后又将X的值改回AT1开始进行 CAS 操作的时间点恰好在T2执行完这一系列操作后,因为X的值等于A,因此T1执行 CAS 操作成功;虽然T1执行 CAS 成功了,但是从T1 读取到X的值到执行 CAS 的这段时间里,X实际已经被改变了两次;这个过程会带来安全隐患;

ABA问题的解决方法:一个解决方法就是通过版本号,每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题,因为版本号只会增加不会减少。

1.2 同步机制

当有多个线程同时竞争一把锁,需要采用同步机制来协调竞争失败的线程。

1.2.1 阻塞式同步机制

将竞争锁失败的线程挂起,进入休眠状态,等待持有锁的线程释放锁之后,再将其唤醒,这就是阻塞式同步机制,这种机制会发生额外的线程切换;

线程切换:线程是CPU调度的最小单元,现在主流的操作系统(Windows和Linux)都是采用时间片轮转的抢占式调度方式来实现线程调度。CPU的每一个核心在同一时刻只能执行一个线程的任务,当CPU要切换执行另外一个线程的任务的时候,需要执行切换操作,就是将该线程挂起,然后将其执行快照从CPU寄存器拷贝到内存中,接下来将要调度的线程的执行快照从内存中拷贝到CPU寄存器中,然后开始继续执行。这种切换操作需要额外消耗计算资源。

  • 优点:由于等待锁的线程无法继续执行其任务,因此将其挂起可以将CPU让给其他线程,能够使CPU资源得到充分的利用;
  • 缺点:由于时间片用尽、任务执行完毕或者高优先级抢占而发生的线程切换是属于正常范围内的线程切换,而由于锁竞争而发生的线程上下文切换是属于额外的、临时的线程切换操作,大量的频繁的临时上下文切换操作会挤占本应该用于应用服务的计算资源,从而引发性能问题。
  • 适用场景:适合临界区代码执行时间较长,或者锁竞争比较激烈的场景;
1.2.2 非阻塞式同步机制

当线程竞争锁失败后,可以不将其挂起,而是继续运行;一般有两种模式实现非阻塞同步机制:

  1. 任务切换模式:竞争锁失败的线程可以先去执行其他计算任务,执行完毕后再重新尝试获取锁;
  2. 自旋等待模式:竞争锁失败的线程原地进行自旋等待,反复重试直到获取锁成功;

非阻塞式同步机制由于没有发生线程的挂起和唤醒操作,所以不会发生临时的线程上下文切换操作(由于 CPU 时间片用尽而发生的线程切换操作还是有的,这种切换是无法避免的,由底层操作系统的 CPU 调度机制决定,应用程序无法控制)

  • 优点:避免了临时的上下切换操作浪费计算资源;
  • 缺点:自旋等待模式以另外一种方式浪费了计算资源;任务切换模式会带来响应性问题;而且当锁竞争激烈的时候,这种机制容易发生线程饥饿问题;
  • 适用场景:适用于临界区代码执行速度很快,而且锁竞争不激烈的场景;

响应性问题:当线程执行 A 任务的时候,由于获取锁失败,线程先将任务 A 暂停,转而执行任务 B ,执行完任务 B 后,再切换到任务 A 重新尝试获取锁;当任务 B 的执行时间比较长,或者锁竞争激烈,该线程多次尝试获取锁失败(每次失败都会先去执行一下任务B)的时候,就会推迟 A 任务的完成时间(就是 A 任务的响应时间),从而导致 A 任务的响应速度变慢 。

线程饥饿问题:当锁竞争非常激烈的时候,一个线程在执行任务 A 的时候,如果获取锁失败,会不断尝试获取锁,但每次都获取锁失败,从而导致该线程的任务 A 一直无法继续执行,这样就会发生饥饿问题。

1.2.3 阻塞锁和自旋锁

我们将采用阻塞式同步机制的锁称为阻塞锁,将采用非阻塞式同步机制自旋等待模式的锁称为自旋锁;前一种锁会由于临时产生的上下文切换操作而浪费计算资源,后一种会因为自旋等待导致CPU空转浪费计算资源;这两种锁都会导致计算资源的浪费,那么具体采用哪一种锁必须根据场景来判断,哪种锁浪费的计算资源更少;一般当锁竞争激烈时,或临界区代码执行时间长的时候,自旋锁空转浪费的CPU时钟周期远远大于上下文切换,此时采用阻塞锁比较合适,而当锁竞争不激烈,或者临界区代码执行很快的情况下,上下文切换的浪费的计算资源就多过自旋锁,此时采用自旋锁比较合适。但是一般情况下,程序的运行状况是动态的,此时单纯采用阻塞锁或者自旋锁并不是最优的,最优的情况就是混合采用这两种锁,比如自适应自旋锁,设定一个阈值,先自旋等待一段时间,如果还是无法获取锁那么就将线程阻塞。

1.3 排队机制

当有多个线程竞争锁失败,需要等待锁的释放的时候,如何对线程队列进行管理,就要采取相关的排队机制,排队机制分为公平和非公平两种,其中公平的排队机制就是先进先出,而非公平的排队机制就是未阻塞线程优先;

1.3.1 公平 - 先进先出(FIFO)

当持有锁的线程释放锁后,调度程序严格按照线程队列的顺序,使排在队列首部的线程能够下一个获取到锁;

  • 优点:
  • 缺点:
  • 适用场景:
1.3.2 非公平 - 未阻塞线程优先

当持有锁的线程释放锁后,此时,如果恰好有线程尝试获取锁,此时该线程如果还未进入阻塞状态,那么就会优先获取到锁;

  • 优点:
  • 缺点:
  • 适用场景:

1.4 共享机制

共享资源如何由多个线程共享访问,需要共享机制来控制,共享机制分为共享式和独占式两种:

1.4.1 共享式

  同一时刻,允许有多个线程同时访问该资源,就是共享模式,采用共享模式的锁也被称为共享锁。

  • 优点:
  • 缺点:
  • 适用场景:
1.4.2 独占式

同一时刻,只允许有一个线程访问该资源,其他线程想要访问资源,需要等待持有锁的线程释放锁,这就是独占模式,采用独占模式的锁被成为独占锁或排他锁。

  • 优点:
  • 缺点:
  • 适用场景:

2. Java 中用到的锁机制

前面的章节我们罗列了锁机制的4个组成部分;而 Java 中用到锁机制都是这4个部分的不同方式的排列组合;Java 中用到的锁机制如下表:

名称加锁机制同步机制排队机制共享机制
重量级锁基于系统互斥量阻塞式公平独占模式
偏向锁基于私有信号量--独占模式
轻量级锁基于私有信号量--独占模式
自旋锁基于私有信号量非阻塞式非公平独占模式
自适应自旋锁基于私有信号量非阻塞式非公平独占模式
共享锁---共享模式

3. Java 中的锁实现

我们在编写 Java 程序的时候,能够使用的锁有两种,一种是内部锁,又叫监视器锁,就是通过 synchronized 关键字来使用的锁,还有一种是外部锁,就是 Java 并发包中 Lock 接口的实现类,比如 ReentrantLockReentrantReadWriteLock等;当然我们也可以利用并发包的 Lock 接口和 AQSAbstractQueuedSynchronizer)框架来实现自定义的锁。这些锁实现基本上都采用了混合锁机制,就是在应用运行过程中会根据应用环境的变化来采用不同的锁机制。

相关文章:

  • VBA_NZ系列工具NZ10:VBA压缩与解压工具
  • 2025长三角杯数学建模B题思路模型代码:空气源热泵供暖的温度预测,赛题分析与思路
  • gitlab+portainer 实现Ruoyi Vue前端CI/CD
  • Memo of Omnipeek for 802.11 (Updating)
  • 产品更新丨谷云科技 iPaaS 集成平台 V7.5 版本发布
  • Secs/Gem第六讲(基于secs4net项目的ChatGpt介绍)
  • 【ROS2】编译Qt实现的库,然后链接该库时,报错:/usr/bin/ld: XXX undefined reference to `vtable for
  • 密码学实验:凯撒密码
  • mysql 字段类型解释
  • Linux基础 -- 在内存中使用chroot修复eMMC
  • Android Coli 3 ImageView load two suit Bitmap thumb and formal,Kotlin(七)
  • OpenCV CUDA模块中矩阵操作------矩阵元素求和
  • 每日算法刷题计划Day7 5.15:leetcode滑动窗口4道题,用时1h
  • STM32单片机内存分配详细讲解
  • 使用gitbook 工具编写接口文档或博客
  • 【C++】汇编角度分析栈攻击
  • 一文读懂--程序的编译汇编和链接
  • Datawhale 5月llm-universe 第2次笔记
  • Vue 3中ref
  • css画图形
  • 媒体:中国女排前队长朱婷妹妹被保送浙大受质疑,多方回应
  • 商务部回应稀土出口管制问题
  • 证监会:2024年依法从严查办证券期货违法案件739件,作出处罚决定592件、同比增10%
  • 一图看懂|印巴交火后,双方基地受损多少?
  • 中国—美国经贸合作对接交流会在华盛顿成功举行
  • 云南威信麟凤镇通报“有人穿‘警察’字样雨衣参与丧事”:已立案查处