【从零开始学习Java重要知识 | 第三篇】暴打ReentrantLock底层源码

目录

前言:

前置知识: 

什么是公平锁与非公平锁?

尝试自己构造一把锁:

ReentrantLock源码:

加锁:

解锁:

总结:


 

前言:

在并发编程中,线程安全是一个重要的问题。为了保证多个线程之间的互斥访问和正确的同步操作,Java提供了一种强大的锁机制——ReentrantLock(可重入锁)。

与synchronized相比,ReentrantLock提供了更加灵活和强大的功能。它支持公平锁和非公平锁两种模式,可以通过lock()和unlock()方法手动控制锁的获取和释放,并且可以实现可重入特性,即同一个线程可以多次获得同一个锁而不会发生死锁。

在本文中,我们将详细讲解ReentrantLock的使用方法,包括基本的锁操作、可重入特性、公平性设置、条件变量和中断等。我们还将对比ReentrantLock和synchronized的性能差异,并给出一些适用场景的建议。

在正式介绍ReentrantLock之前,我们要先来学习一些前置知识,方便大家更好的理解ReentrantLock源码。

前置知识: 

什么是公平锁与非公平锁?

公平锁:

        公平锁是指多个线程按照申请锁的顺序依次获取锁,即先到先得的原则。当一个线程释放锁后,等待时间最长的那个线程将获得锁的访问权。公平锁可以避免线程饥饿,保证每个线程都有机会获得锁,但可能会降低系统的整体吞吐量。

非公平锁:

        非公平锁是指多个线程竞争锁时,不考虑线程的等待时间,直接尝试获取锁。如果当前锁没有被其他线程持有,则该线程立即获得锁的访问权,即先到先得。非公平锁的性能相对较好,因为它减少了线程切换和调度的开销,但可能会导致某些线程一直无法获取到锁,产生线程饥饿的问题。

简而言之:公平锁在线程获取锁的时候,等待时间最长的线程获取锁的访问权。非公平锁是线程自由竞争,不存在按照等待时间排序。

而ReentrantLock既可以是公平锁,也可以是非公平锁。无参构造情况下是非公平锁

尝试自己构造一把锁:

锁的基本原理就是:加锁就是放行或者阻塞当前线程。放锁就是唤醒被阻塞的线程。

基于这个思想,我们很快就可以设计出一个最简单的锁:

public class LiLocks {int statue =0;public void lock(){while ( statue!=0){System.out.println(Thread.currentThread().getName()+"等待锁");}statue=1;}public void unlock(){statue=0;}
}

但是这个锁存在一个最基本的问题:并不能处理全部情况

        理想状态下,A线程进入lock之后,先判断当前statue==0,之后做statue自增操作。这样当下一个线程进入的时候就会被阻塞到while循环中,直到A线程进入unlock方法对statue做递减操作。如果statue==0的话,就相当于释放锁。

而在有些特殊情况下,当A线程比较完之后,还没来得及给statue赋值,另外一个B线程就抢到了资源调度,由于此时statue还没变值,所以此时B线程仍然可以通过比较不进入while循环。就造成了加锁失败的情况

 也就是说:如果我们想要优化这个锁,那么就需要使得判断statue==0和赋值statue=1具有原子性。

这两步就是CAS(Compare And Swap),只不过我们的CAS是自己写的,所以不具备原子性。那么我们调用Java为我们提供的具有原子性的CAS操作就好了。

而Java为我们提供的具有原子性的cas操作来自Unsafe类,那么我们使用Unsafe类来改造代码:

public class LiLock {private static Unsafe unsafe;volatile  int statue =0;static {try {Field field = Unsafe.class.getDeclaredField("theUnsafe");field.setAccessible(true);unsafe = (Unsafe) field.get(null);} catch (Exception e) {e.printStackTrace();}}long objectOffset = unsafe.objectFieldOffset(LiLock.class.getDeclaredField("statue"));public LiLock() throws NoSuchFieldException {}public void lock() throws NoSuchFieldException {while (!unsafe.compareAndSwapInt(this, objectOffset, 0, 1)){System.out.println(Thread.currentThread().getName()+" is waiting");}}public void unlock() {statue=0;}
}

在这里,我们简单实现了一个自旋锁。而我们如果想要把这个锁改为公平锁,要怎么做呢?

也就是说:当此时有1,2,3 总共三个线程的时候,如果1线程获取到了锁,那么我们如何让2,3线程按照顺序获取锁呢?

假设线程2此时已经被while循环阻塞了,如果此时线程3来的话,那么我们是不能让线程3进入while阻塞的。如果3也被阻塞的话,那么不就是说2和3又在同一起跑线了。无法保证2先来就先获取到锁。

所以此时的问题就在于:如何不使用相同的while来阻塞3线程,让2线程优先获得到锁?

我们回顾一下我们之前学过的阻塞一个线程的方式:

1.wait():使用不了,他需要和synchronized 关键字一起使用,而我们是在自己写锁,当然不能使用synchronized

2.sleep():使用不了,我们不知道线程1需要多长时间才会释放锁,因此无法确定让线程2,3睡眠多长时间。

3.while(ture):使用不了,使用这个还是将线程2,3放到一个起跑线了,无法确保先来的线程2一定能过比线程3获取到锁

基于这三种方法都使用不了,我们只能采用第四种:LockSupport类中的park()方法,它可以阻塞一个线程,使其不消耗cpu资源,直至被其他线程唤醒或者中断

那么我们直接对线程3采用park方法就可以了。通过这种机制,我们就实现了公平锁的机制。

通过上述知识,我们引入了公平锁的基本实现方式,理解如何构造一个公平锁有利于我们阅读ReentrantLock源码。

ReentrantLock源码:

加锁:

        最外层:

这段代码的基本逻辑为:如果当前线程不能获取锁,则会进入等待队列中等待,并在等待过程中不断尝试获取锁。如果当前线程被中断,则会重新设置中断标志。

我们在这里主要聊的是加锁,因此我们就先看tryAcquire这个方法:

这个方法分为两个实现:公平锁非公平锁,因此我们逐个来看:

公平锁:

我们来先看一下这个方法中调用的一些比较重要的方法:

1.setExclusiveOwnerThread():将锁的拥有线程修改为当前线程。

2.compareAndSetState():具有原子性的CAS操作。

3.getExclusiveOwnerThread():获取当前持有锁的线程对象。

4.setState:设置当前锁的状态。 

5.hasQueuedPredecessors():判断当前线程是否需要排队

在了解完这些方法之后,其实了解公平锁的代码逻辑就很简单了。

公平锁的基本代码逻辑为:

1.获取当前线程和锁状态。

2.如果当前锁没有被线程获取(c==0),进一步判断:当前线程是否需要排队(!hasQueuedPredecessors()),如果不需要就进行CAS操作,将状态从0变为acquirs。并且设置锁的持有线程为当前线程。返回结果为true。

3.如果当前锁已经被线程获取,则再次判断获取锁的线程是不是当前线程(current == getExclusiveOwnerThread())如果是的话,更新状态值nextc为c+acquires(setStates(sextc)),返回ture。

4.如果当前锁已经被获取,且获取线程不是当前线程,则返回false

这里的整体逻辑其实和我们自己构造的公平锁逻辑很相似了。主要的区别在于:我们为什么在这里把获取锁的状态值设置为了acquire,而不是简单的1?

其实道理很简单,设置为acquire之后,我们就可以根据状态值判断锁的重入次数。这样在释放的时候才不会释放错误。        


非公平锁:

非公平锁的代码逻辑几乎和公平锁一样,只不过在判断当前锁没有线程获取的时候,我们不再调用 hasQueuedPredecessors()方法来检测当前线程是否需要排队。也就是自由竞争。

让我们回到加锁的最外层: 

如何把一个线程加入到等待队列中也是热门面试题,因此我们在这里也顺带讲一下:

在ReentrantLock的加锁外层中,为了把一个线程放到等待队列中,我们使用了addWaiter方法:

addWaiter是AQS提供的代码:

整体的代码逻辑也很简单:

先接收传递过来的节点,再用一个for构成的死循环抱住核心逻辑代码。这样是确保进入该方法的线程都可以被添加到队列当中。因为for死循环了。

让我们解读一下源代码:

1.先获取到同步队列的尾节点,之后对尾节点进行非空判断,如果为空,就先初始化队列。

2.如果尾节点不为空,先设置待加入节点的前驱节点为尾节点。

3.使用cas操作,使得tail更新为node。(tail为始终指向尾节点的一个变量

4.将实际尾节点的next指针指向node。

5.返回node。

这样我们就完成了一次添加尾节点。其实就是一个很简单的尾插法。而我们要讲一下:为什么在更新Tail变量的时候,要使用cas操作?

这是因为在多线程竞争的条件下,我们要保证始终只有一个节点获取到tail,成为真正的尾节点,如果不使用cas算法来对tail进行维护的话,那么多个线程同时获取到tail之后,就会引发线程竞争

目前我们已经知道了addwaiter的作用就是把线程加入到阻塞队列中,那么下一步很明显就应该是阻塞在队列中的线程了,但是我们想一想逻辑:阻塞队列是先进先出的,那么在阻塞和唤醒的时候,就应该是:当前线程自己阻塞后,由前一个线程唤醒。由图可看:

而我们的新加节点也不是直接阻塞的,他需要告知前一个节点,而node节点 中就为我们维护了这样一个变量:

某一个node对象的waitStatus的值为0的时候,意味着他在释放锁之后不需要unpark其他对象,反之如果它的值为-1的话,就需要在释放锁之后unpark其他对象。 

那么很显然,这段代码逻辑应该就是在acquireQueued这个方法中执行的;

 让我们来看一看这个方法:

这段方法的逻辑为:先获取到这个节点的前一个节点:如果前一个节点为头节点的话,说明当前线程是一个进行排队的,那么就调用tryAcquire方法进行加锁。

如果加锁成功,就调用setHead方法,把线程变为头节点。

这种方式使得加锁成功后,线程不会再留到这个队列当中。

如果不是头节点,就调用shouldParkAfterFailedAcquire()方法:

这个其实就是我们之前讲到的设置waitStatus的逻辑。 

我们分别来看这几个代码的逻辑:

1.如果前驱节点的waitStatus等于-1。就返回ture。

2.如果前驱节点的waitStatus>0.则说明此前驱节点不可用,我们就一直往前找,直到找到前驱节点小于等于0的节点。 

3.如果前驱节点的waitStatus=0,就使用CAS操作,将其值更新为-1。

我们来看看外围方法:

 也就是说:当shouldParkAfterFailedAcquire方法返回ture(前驱节点的waitstatus=-1)的时候,就调用parkandCheckInterrupt方法

解锁:

这是最外层的方法:基本逻辑为:

先调用tryRelease(arg)来尝试释放锁,如果锁释放成功,则肇东当前头节点,如果头节点不为空且waitstatus的值不为0,则证明有节点需要头节点unpark,那么就调用unparkSuccessor

否则tryRelease(arg)返回false,标识锁释放失败。

因此我们来看一看tryRelease方法:

整体的逻辑为:先用getstate-release(锁支持重入)。之后判断当前线程是否是持有锁的线程。如果不是就抛出异常。之后创建锁的标志量free,false标识释放失败,true标识释放成功。那么当c==0的时候,意味着锁释放完全了,就更新标识量free为true。并且同步设置锁的拥有者为null。再最外层更新锁的状态量state

这就是解锁的全部过程。

总结:

ReentrantLock 是 Java 并发包中的一个重要类,它提供了一种可重入的互斥锁实现。在多线程环境下,ReentrantLock 提供了更灵活和强大的同步控制机制,相比于 synchronized 关键字,它具有更多的特性和扩展能力。

首先,ReentrantLock 采用了独占模式,即同一时刻只允许一个线程持有锁。这保证了被锁保护的临界区只能被一个线程访问,从而避免了多个线程同时修改共享资源导致的数据竞争和不一致性。

其次,ReentrantLock 支持可重入,也就是说同一个线程可以多次获取同一个锁。这种特性使得线程可以在持有锁的情况下重复进入同一个临界区,并且在退出时释放锁。这样的设计简化了编程模型,并且避免了死锁的可能。

此外,ReentrantLock 还提供了公平性选择的机制。通过构造函数传入的参数可以选择公平模式或非公平模式,默认是非公平模式。在公平模式下,锁会按照请求的顺序分配给等待线程,避免了线程饥饿的情况。在非公平模式下,锁的获取是无序的,可能会导致某些线程一直获取不到锁。

如果我的内容对你有帮助,请点赞,评论,收藏。创作不易,大家的支持就是我坚持下去的动力!

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/634444.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

大数据工作岗位需求分析

前言:随着大数据需求的增多,许多中小公司和团队也新增或扩展了大数据工作岗位;但是却对大数据要做什么和能做什么,没有深入的认识;往往是招了大数据岗位,搭建起基础能力后,就一直处于重复开发和…

鉴源实验室|自动驾驶仿真测试技术分析

01 引言 随着科技的不断发展,自动驾驶技术逐渐成为汽车行业的热门话题。然而,要将自动驾驶车辆投放到真实道路上之前,必须进行广泛的测试,以确保其在各种情况下都能安全可靠地运行。自动驾驶车辆的测试是一个复杂而昂贵的过程。…

细说CubeIDE——之建立一个工程

博主今天给大家带来新的一个软件,由于公司要上市,盗版软件不让装,IAR,KEIL律师函争相投递,博主只能老老实实把软件卸掉,苦逼工作还得干,怎么办? 你说怎么办?怎么办&#…

常用中间件漏洞

IIS6 IIS7 安装 控制面板-----打开关闭windows功能 添加角色-----添加IIS 启动之后访问localhost 复现 服务器换成IIS7 访问报错 大概就是缺少CGI模块 问题解决 添加php-cgi的路径 添加脚本映射 修改php.ini文件 将 cgi.fix_pathinfo1 然后设置一个图片 访问 在后缀加上/.…

如何使用JS逆向爬取网站数据

引言: JS逆向是指利用编程技术对网站上的JavaScript代码进行逆向分析,从而实现对网站数据的抓取和分析。这种技术在网络数据采集和分析中具有重要的应用价值,能够帮助程序员获取网站上的有用信息,并进行进一步的处理和分析。 基…

Linux第29步_安装“Notepad++”软件

STM32CubeProgrammer脚本文件的后缀为“.tsv”,ST公司官方也叫做FlashLayout。在烧写“TF-A固件”之前,我们需要用“Notepad”软件打开“后缀为.tsv”的脚本文件,根据需求决定哪些文件需要更新,设置好这个脚本文件。 .tsv是文本格…

C++ 之LeetCode刷题记录(十三)

😄😊😆😃😄😊😆😃 开始cpp刷题之旅。 依旧是追求耗时0s的一天。 70. 爬楼梯 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可…

【MySQL自身的性能优化】InnoDB 的 Buffer Pool

这里写目录标题 一、引入缓存的重要性二、InnoDB 的 Buffer Pool1. Buffer Pool 内部组成2. free 链表管理空闲页3. flush 链表管理脏页4. LRU 链表提高缓存命中那咱需要咋地解决预读问题呢?那咱需要咋地解决 Buffer Pool 污染问题呢? 5. 脏页什么时候被…

94.乐理基础-记号篇-装饰音记号(二)助音的四种类型

内容参考于:三分钟音乐社 上一个内容:93.乐理基础-记号篇-装饰音记号(一)级进、跳进、经过音、辅助音-CSDN博客 当前写的助音是扩展的辅助音的内容,所以要先看上一个内容 助音的四种类型:下方的三个辅助…

自己是如何使用单元测试

前言 自己是如何使用单元测试 进行单元测试能够让我们在编写方法的具体实现代码后,能清晰地看到其是否能实现预期的功能,有助于我们及时修正自己方法中存在的bug,以免在后续使用到某方法时出现意想不到的错误。 一、引入单元测试所使用的依赖…

【ARMv8M Cortex-M33 系列 7.2 -- HardFault 问题定位 1】

文章目录 问题背景堆栈对齐要求Cortex-M33 的 FPU 功能 问题背景 rt-thread 在PendSV_Handler退出的时候发生了HardFault_Handler是什么原因?且 LR 的值为0xfffffffd 堆栈对齐要求 在 ARM Cortex-M 架构中,堆栈指针 (SP) 必须始终保持 8 字节对齐。这…

米贸搜|Facebook新手请查收!如何在FB上定位到B类受众?

一、确定目标受众和营销目标 在利用Facebook进行获客之前,B2B企业需要首先明确目标受众和营销目标。目标受众是指潜在的客户或合作伙伴,而营销目标可能是增加销量、提高品牌知名度、获取客户线索等。 Facebook的受众定位可以分成三大类:人口…

对象存储, 开源MinIO docker-compose.yml 文件

文章目录 python SDK 文档地址:docker-compose.yml 文件控制台使用:应用服务中使用样例: python SDK 文档地址: https://min.io/docs/minio/linux/developers/python/API.html docker-compose.yml 文件 version: 3services:min…

C语言中的浮点数存储

首先明确一个概念:C语言中整形是按照二进制存储在内存中,浮点型是按科学计数法存储在内存中(本质上存储的还是二进制数据0和1)。 如果没看懂这句话,没关系!看完以下正文,你就会豁然开朗&#x…

Qt简单使用与初识

🌇个人主页:平凡的小苏 📚学习格言:命运给你一个低的起点,是想看你精彩的翻盘,而不是让你自甘堕落,脚下的路虽然难走,但我还能走,比起向阳而生,我更想尝试逆风…

【汇编】pushf popf

两个指令的作用分别是将16位标志寄存器压入栈、从栈中取出,但是貌似没人做实验验证。 这里做个实验加深理解,顺便总结下几个标志寄存器的作用。 一、结构 8086CPU的flag寄存器的结构如下: OF:判断运算结果是否溢出 CF&#xff1…

VUE--组件的生命周期及其基本应用

VUE的生命周期 上图是实例生命周期的图表,需要注意以下几个重要时期: 创建期:beforeCreated、created 挂载期:beforeMount、mounted 更新期:beforeUpdate、updated 销毁期:beforeUnmount、unmounted 生命周…

蓝桥杯真题(Python)每日练Day2

题目 题目分析 对于本题首先确定其数据结构为优先队列,即邮费最小的衣服优先寄,算法符合贪心算法。可以直接使用queue库的PriorityQueue方法实现优先队列。关于PriorityQueue的使用方法主要有: import queue q queue.Queue()# 队列 pq qu…

JAVA实现向Word模板中插入Base64图片和数据信息

目录 需求一、准备模板文件二、引入Poi-tl、Apache POI依赖三、创建实体类(用于保存向Word中写入的数据)四、实现Service接口五、Controller层实现 需求 在服务端提前准备好Word模板文件,并在用户请求接口时服务端动态获取图片。数据等信息插…

代理IP是什么,代理IP的工作原理是怎么样的?

数字时代,随着数据采集的应用,代理IP也受到越来越多人的关注。但是,很多人对代理IP的具体概念及其工作原理并不是很了解。今天我们就来针对代理IP的相关知识做一个简单的探讨,说说代理IP是什么,它的工作原理又是什么&a…