Java 多线程(八)—— 锁策略,synchronized 的优化,JVM 与编译器的锁优化,ReentrantLock,CAS

前言

本文为 Java 面试小八股,一句话,理解性记忆,不能理解就死背吧。

锁策略

悲观锁与乐观锁

悲观锁和乐观锁是锁的特性,并不是特指某个具体的锁。

我们知道在多线程中,锁是会被竞争的,悲观锁就是指锁的竞争程度十分激烈,很多线程都想用这把锁,为了应对这个场景,我们会额外做一些工作。例如:一把锁,此时有几十个线程都想用,并且同一时刻它们都发出申请锁的请求,这时候锁的竞争程度很高,我们可以采取悲观锁的策略,额外做一些工作。

乐观锁则相反,锁的竞争程度很小,就不需要做额外的工作。例如:这一把锁只有两个线程在竞争,并且这两个线程用锁的概率也不是很高,这时候我们可以采用乐观锁策略。

重量级锁与轻量级锁

重量级锁和轻量级锁是遇到特定的场景而出现的解决方案。

上面我们就提到乐观和悲观的场景,重量级锁适用于悲观的场景,相应的也要付出更高的代价,效率相比轻量级锁要低效。

轻量级锁适用于乐观的场景,要付出的代价也要小很多,效率相比重量级锁要高效。

等待挂起锁与自旋锁

挂起等待锁就是指如果一把锁已经被一个线程占用的时候,发现有其他线程还想竞争这把锁,操作系统就会让它们阻塞等待,后续唤醒的时候需要由操作系统的内核来唤醒。

自旋锁就是指如果发现由锁竞争,这时候这些线程不会阻塞等待,而是以忙等的形式进行等待。

看到这里,其实等待挂起锁是适用于悲观的场景下,因为线程竞争激烈,没必要让它们占着 CPU 资源,直接让它们阻塞,释放出CPU 资源,减少资源的消耗。同时由于唤醒的时候是由操作系统的内核实现的,所以操作系统会在内核态和用户态频繁切换,效率也会比较低下。

而自旋锁则适用于乐观的场景,线程以忙等的形式,也就是占用着CPU,但是由于锁竞争不是很激烈,忙等的线程很快就可以获取到锁,所以没必要阻塞等待,因为操作系统的内核唤醒线程的效率要低效一些,所以自旋锁的效率会比等待挂起锁的效率要高。

互斥锁与读写锁

互斥锁就是加锁之后,拥有这把锁的线程才能进行操作,其他线程必须等待拿到锁之后才能进行自己的操作,这就是互斥。

读写锁分为读锁、写锁,因为我们知道线程安全问题是因为写操作而引起的,但是读操作是不会发生线程安全问题的,而读写锁就是针对读操作和写操作进行加锁读锁和读锁是不会互斥的,写锁与读锁是会互斥的,写锁与写锁是会互斥的(这里可以参考MySQL的幻读、脏读、不可重复读了)

公平锁与非公平锁

公平锁是指锁的分配是按线程的等待时长来分配的,举个例子:假设一把锁已经被一个线程占用,此时有三个线程都想竞争这把锁,那这时候我们会使用额外的数据结构来保存这些线程并且记录每个线程的等待时长,等到锁被释放的时候,操作系统会优先把这把锁分配给等待时长最长的线程,这也避免了线程饥饿。

非公平锁就是随机分配,不按 “先来先得” 的规矩

可重入锁与不可重入锁

可重入锁就是当一个线程拥有这把锁的时候,可以进行重复的加锁。

不可重入锁则相反,即使你这个线程拥有了这把锁,但是还是不能对其进行重复加锁。

synchronized 的优化

根据上面的锁策略,我们来总结一下 synchronized 的特性:

synchronized 具有自适应性
synchronzied 开始时是采取乐观锁策略,如果锁的冲突频繁,则转换为悲观锁
开始时是轻量级锁,如果锁冲突频繁,则转换为重量级锁
synchronized 实现轻量级锁的时候采用自旋锁策略
synchronized 是不公平锁,可重入锁,互斥锁,不是读写锁

锁升级

JVM 会将 synchronized 的锁分为四个状态:无锁、偏向锁、自旋锁、重量级锁。

在这里插入图片描述

当还没进入synchronized 的时候,处于无锁状态,一旦进入 synchronized 代码块就会变成偏向锁,偏向锁并非真正加锁,而是通过标记的方式,以此来区分是否真正加锁了。偏向锁本质上相当于 “延迟加锁”,能不加锁就不加锁,避免了不必要的加锁开销,这也是一种懒汉模式的体现。

一旦产生锁竞争,偏向锁就会升级为自旋锁,也就是轻量级锁,如果竞争十分激烈,进一步升级为重量级锁。

synchronized 只能进行锁升级,但是不能进行锁降级!!!

JVM 与编译器的锁优化

锁消除

JVM 会自动检测出一些没有必要加锁的操作,避免这些无意义的加锁操作带来的不必要的开销,JVM 会把这些锁给消除,也就是说你代码加锁了,但是 JVM 给删除了。

大家不用担心这个优化会产生线程安全问题,因为 JVM 的锁消除是在100% 确定这个锁就是一个没必要加的锁,JVM 才会进行锁消除。

锁粗化

首先介绍一个概念,锁的粒度:加锁与解锁之间包含的代码指令越多,锁就越粗;相反,加锁与解锁之间包含的代码指令越少,锁就越细。

public class Test {
    public static int sum = 0;
    public static int count = 10000;
    public static int total = 1000;
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t = new Thread(() -> {
            for(int i = 0; i < 5000; i++) {
                synchronized (locker) {
                    sum++;
                }
                synchronized (locker) {
                    count--;
                }
                synchronized (locker) {
                    total--;
                }
            }
        });
    }
}

上面的代码就属于锁的粒度太细了,频繁加锁解锁。

        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 5000; i++) {
                synchronized (locker) {
                    sum++;
                    count--;
                    total--;
                }
            }
        });

这个代码就是锁的粒度粗,加锁和解锁的次数比较少。

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

ReentrantLock

ReentrantLock 和 synchronized 是并列关系,都是用来加锁的,并且都是可重入锁。

简单使用介绍,ReentrantLock 使用 lock() 加锁,unlock() 来解锁,为了避免我们因为加锁和解锁之间有return 或者 抛出异常等等情形没能进入解锁操作,所以这里使用 finally 来包含 unlock() 代码行,避免忘记解锁。

        ReentrantLock locker2 = new ReentrantLock();
        Thread t3 = new Thread(() -> {
            try {
                locker2.lock();
                count++;
            } finally {
                locker2.unlock();
            }   
        });

synchroinzed 和 ReentrantLock 的区别:
synchronized 是 Java提供的关键字,是 JVM 内部通过 C++ 实现的,ReentrantLock 是Java标准库提供的类,由Java代码实现
synchronized 是 通过代码块来实现加锁和解锁的,ReentrantLock 通过 lock() 加锁,unlock() 解锁,一定要注意 unlock() 可能存在未被调用的情况。
ReentrantLock 还有一个 tryLock() 这个方法的调用不会线程产生阻塞,如果加锁成功则返回 true,加锁失败则返回 false,接下来由调用者来根据返回值决定接下来怎么做。可以设置超时时间,当等待时间达到超时时间的时候再返回true / false
ReentrantLock 提供了公平锁的实现,ReentrantLock locker = new ReentrantLock(true);默认情况下是非公平锁。
在这里插入图片描述
ReentrantLock 搭配的通知等待机制是由Condition 类实现的,相比于 synchronized 的 wait / notify 的功能更强大一些。

synchronized 和 ReentrantLock 都是可重入的互斥锁。

CAS

CAS 全称是 Compare and swap,比较并交换

CAS 在 CPU 里是一条指令,具有原子性。
因此 CAS 操作是线程安全的

举个例子:假设内存原始数据为 V,把这个数据放入寄存器 1 和 寄存器 2 中,数据的加减等操作的结果由寄存器 2 保存。CAS 会先检测原始数据 V 和寄存器 1 的数值是否一致,如果一致的话,可以执行修改也就是把寄存器 2 的结果放入内存中。

下面给出 CAS 的伪代码进行进一步的理解:

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

第一个参数是内存的数值,第二个参数是寄存器 1 的数值,第三个参数是寄存器 2 的数值。

首先判断内存的数值是否和寄存器的数值一致,如果一致则进行寄存器 2 和内存数值的交换操作,注意 这本质上在 CPU 里是一条指令,具有原子性。

明确的指明:if-else 和 三目运算符在 CPU 里不是一条指令,和 CAS 还是由区别的。

原子类

CPU 有 CAS 指令,并且给操作系统提供了 CAS 的使用接口,操作系统对 CAS 进一步封装,给用户提供相应的接口,C++ 可以直接进行调用,而JVM 是由 C++ 实现的再次对 CAS 进行封装,给Java 程序员提供了 原子类。在这个 java.util.concurrent.atomic 包下就是我们的原子类了。

在这里插入图片描述

下面是原子类的伪代码:

class AtomicInteger {
 	private int value;
 	public int getAndIncrement() {
 		int oldValue = value;
 		while ( CAS(value, oldValue, oldValue+1) != true) {
 			oldValue = value;
 		}
 		return oldValue;
    }
}

oldValue 是寄存器,由于Java没有寄存器的使用,所以这里用 int 类型代替。

getAndIncrement() 其实就是 ++ 自增的操作,首先先把内存的数值(value)读到寄存器 1 中,CAS 指令 首先判断 value 是否和寄存器 1 中的数值 oldValue 相等,如果相等就把寄存器 2 的 oldValue + 1 的结果放到内存中,返回 true,否则返回 false 并且进入循环体再次读取内存的数值放入寄存器 1 中。


面对多线程下同时修改一个变量的时候,原子类是最佳的选择。

import java.util.concurrent.atomic.AtomicInteger;

public class Demo1 {
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("count = " + count.get());
    }
}

在这里插入图片描述

使用 CAS 实现自旋锁

下面是 使用 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;
 	}
}

核心代码:while(!CAS(this.owner, null, Thread.currentThread())){} 当 这个锁的拥有者 为 null 的时候,才能由线程Thread.currentThread() 获取这把锁的操作并返回true,否则该线程以忙等的形式等待这把锁。

ABA 问题

ABA 问题是什么?
我们知道 CAS 开始之前会先把内存的数值读到寄存器里,在进行 CAS 的操作之前,可能调度过来别的线程,这个线程对这个内存的数值进行了修改操作,然后又改回来了,看上去这个数值没有任何变化,实际上这个数据已经被动过了,接着把 CAS 调度过来执行,CAS 首先判定内存的数值是否和寄存器的数值一致,如果一致,进行交换操作,这时候数值肯定是一致的,所以交换操作正常被执行了。在进行内存数值和寄存器数值判定是否相等之前内存数值是否被改了又改过,这就是 ABA 问题。

ABA 问题会带来什么BUG?
假设一个人叫做白糖过来取500块钱,假设余额有 4k,这时候 ATM 机有点卡顿,这时候白糖进行了多次按下取款的操作,恰好这时候白糖的好朋友天王星发个信息说之前欠你的500块现在转账还你。
由于多次按下取款操作,就会产生多个取款的线程来执行取款操作,此时中间夹了一个还款操作的线程,大家来看一下下面的流程图:
在这里插入图片描述
取款线程 t1 把 account 修改为 3500, 还款线程将 account 修改为 4000, 接着又来了 取款线程 t3 由于内存4000 和寄存器的数值保留的 4000 是一致,所以又将余额修改为了 3500,你会发现白糖小伙就拿出了 500 块,但是余额却多扣了 500, 完了血亏 500,可怜的白糖又要辛苦打工了。

这种事件虽然发生概率极小,但是在庞大的请求数量面前还是不能忽视这个 bug 的。

如何解决 ABA 问题???
因为余额是可以加又可以减的变量,所以会出现上述极端的BUG,但是如果我们换一个指标来作为判断标准的话就可以避免上述的BUG,这里我们可以使用版本号来作为判断的指标,每次修改之后版本号就 + 1,每次进行修改操作的时候判断内存的版本号和寄存器的版本号是否相同

下面给一个伪代码:

        int oldVersion = version;

        if(CAS(version, oldVersion, oldVersion + 1)) {
            account += 500;
        }

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【Spring IOC】实现一个简单的 Spring 容器

1. 理解知识 Spring 容器的作用 Spring 容器是Spring的核心&#xff0c;用来管理Bean对象的。容器将创建对象&#xff0c;把它们连接在一起&#xff0c;配置它们&#xff0c;并管理他们的整个生命周期从创建到销毁。 Bean 对象的管理 当一个 Bean 对象交给 Spring 容器管理…

非线性数据结构之图

一、有向图&#xff08;Directed Graph&#xff09; 1. 定义 有向图是一个由顶点&#xff08;节点&#xff09;和有方向的边&#xff08;弧&#xff09;组成的图。在有向图中&#xff0c;每条边都有一个起点和一个终点&#xff0c;表示从一个顶点到另一个顶点的关系。 2. 特…

【算法】——滑动窗口专题

阿华代码&#xff0c;不是逆风&#xff0c;就是我疯 你们的点赞收藏是我前进最大的动力&#xff01;&#xff01; 希望本文内容能够帮助到你&#xff01;&#xff01; 目录 一&#xff1a;长度最小的子数组 二&#xff1a;无重复字符的最长子串 三&#xff1a;最大连续1的个…

目前主流的人工智能学习框架有哪些?

随着人工智能&#xff08;AI&#xff09;技术的蓬勃发展&#xff0c;越来越多的AI学习框架相继推出&#xff0c;成为开发者、研究人员和企业构建机器学习&#xff08;ML&#xff09;和深度学习&#xff08;DL&#xff09;模型的首选工具。AI学习框架不仅提供了丰富的工具库和函…

揭开自然语言处理(NLP)的神秘面纱

时间&#xff1a;2024年 11月 05日 作者&#xff1a;小蒋聊技术 邮箱&#xff1a;wei_wei10163.com 微信&#xff1a;wei_wei10 音频&#xff1a;喜马拉雅 大家好&#xff0c;欢迎来到“小蒋聊技术” &#xff0c;我是小蒋&#xff01;。小蒋最近在学习清华大模型课程&…

C#:强大而优雅的编程语言

在当今的软件开发领域&#xff0c;C#作为一种广泛应用的编程语言&#xff0c;以其强大的功能、优雅的语法和丰富的生态系统&#xff0c;受到了众多开发者的喜爱。本文将深入探讨 C#的各个方面&#xff0c;展示它的魅力和优势。 一、C#的历史与发展 C#是由微软公司开发的一种面…

SQL CASE表达式与窗口函数

CASE 表达式是一种通用的条件表达式&#xff0c;类似于其他编程语言中的if/else语句。 窗口函数类似于group by&#xff0c;但是不会改变记录行数&#xff0c;能扫描所有行&#xff0c;能对每一行执行聚合计算或其他复杂计算&#xff0c;并把结果填到每一行中。 1 CASE 表达式…

C++之位算法

位算法 常见位运算总结 位1的个数 给定一个正整数 n&#xff0c;编写一个函数&#xff0c;获取一个正整数的二进制形式并返回其二进制表达式中 设置位 的个数&#xff08;也被称为汉明重量&#xff09;。 示例 1&#xff1a; 输入&#xff1a;n 11 输出&#xff1a;3 解释…

【OJ题解】C++实现字符串大数相乘:无BigInteger库的字符串乘积解决方案

&#x1f984;个人主页: 起名字真南 &#x1f984;个人专栏:【数据结构初阶】 【C语言】 【C】 【OJ题解】 目录 1. 引言2. 题目分析示例&#xff1a; 3. 解题思路4. C代码实现5. 代码详解6. 时间和空间复杂度分析7. 边界情况分析8. 总结 1. 引言 在开发中&#xff0c;有时我们…

用Python将PDF表格提取到文本、CSV和Excel文件中

从PDF文档中提取表格并将其转换为更易于处理的格式&#xff08;如文本、CSV和Excel文件&#xff09;&#xff0c;是数据分析和信息管理中的常见需求。此过程可显著简化表格数据的处理&#xff0c;使数据的操作、分析和与其他数据集的集成更加便捷。无论是财务报表、研究论文&am…

如何在 IntelliJ IDEA 中调整 `Ctrl+/` 快捷键生成注释的位置

前言 在使用 IntelliJ IDEA 编写代码时&#xff0c;注释是代码可读性和维护性的重要组成部分。IDEA 提供了快捷键 Ctrl/ 用于快速生成单行注释。然而&#xff0c;默认情况下&#xff0c;使用此快捷键生成的注释会出现在行首&#xff0c;导致注释与代码之间存在较大的空格&…

深入理解对象池 sync.Pool

文章目录 前言应用使用源码走读数据结构Get获取对象Put归还对象poolDeque分析GC时 总结 前言 当多个 goroutine 都需要创建同⼀种对象的时候&#xff0c;如果 goroutine 数量过多&#xff0c;导致对象的创建剧增&#xff0c;进⽽导致 GC 压⼒增大。形成下面的恶性循环&#xf…

项目管理(软设软考高频)

一、进度管理 1.Gantt图 2.PERT图 二、风险管理 三、沟通管理 四、成本管理

在Java中,实现数据库连接通常使用JDBC

学习总结 1、掌握 JAVA入门到进阶知识(持续写作中……&#xff09; 2、学会Oracle数据库入门到入土用法(创作中……&#xff09; 3、手把手教你开发炫酷的vbs脚本制作(完善中……&#xff09; 4、牛逼哄哄的 IDEA编程利器技巧(编写中……&#xff09; 5、面经吐血整理的 面试技…

gradle下载的jar包,源码出现Decompiled .class file, bytecode version

如下是问题截图 问题产生原因&#xff1a; gradle依赖下载只下载了jar包&#xff0c;这导致idea在读取jar包时&#xff0c;需要通过Fernflower技术对jar包进行反编译&#xff0c;而反编译过程中只会保留源码信息&#xff0c;因此注释等额外信息全部丢失 解决方案&#xff1a…

[357]基于springboot的中小型制造企业质量管理系统

摘 要 信息数据从传统到当代&#xff0c;是一直在变革当中&#xff0c;突如其来的互联网让传统的信息管理看到了革命性的曙光&#xff0c;因为传统信息管理从时效性&#xff0c;还是安全性&#xff0c;还是可操作性等各个方面来讲&#xff0c;遇到了互联网时代才发现能补上自古…

SAP(PP生产制造)拆解工单业务处理

1、BOM维护 要拆解的成品或半成品要和原成品、半成品BOM一致 2、创建拆解工单 CO01选择拆解工单的类型&#xff0c;以及填写拆解的物料和拆解工厂 维护工单组件 注意&#xff1a; 1、拆解入库组件的数量需要维护为负数 2、拆解工单投料组件数量维护为正数 3、拆解工单收发…

NavVis LX系列产品典型应用—现有住宅装修改造-沪敖3D

现有住宅装修改造项目的 数据捕捉和测量技术 当Jay Ure着手翻新和美化自己的新家时&#xff0c;他敏锐地发现这是现场测试NavVis VLX的绝佳机会。 为了全面评估&#xff0c;他聘请了一位工程师&#xff0c;采用传统的全站仪技术进行地形测绘。之后&#xff0c;他用移动扫描设…

【初阶数据结构篇】链式结构二叉树(续)

文章目录 须知 &#x1f4ac; 欢迎讨论&#xff1a;如果你在学习过程中有任何问题或想法&#xff0c;欢迎在评论区留言&#xff0c;我们一起交流学习。你的支持是我继续创作的动力&#xff01; &#x1f44d; 点赞、收藏与分享&#xff1a;觉得这篇文章对你有帮助吗&#xff1…

qt QTabWidget详解

1、概述 QTabWidget是Qt框架中的一个控件&#xff0c;它提供了一个标签页式的界面&#xff0c;允许用户在不同的页面&#xff08;或称为标签&#xff09;之间切换。每个页面都可以包含不同的内容&#xff0c;如文本、图像、按钮或其他小部件。QTabWidget非常适合用于创建具有多…