多线程——解决线程安全问题

news2024/11/25 1:08:49

目录

·前言

一、 synchronized 关键字

1. synchronized 的作用

1. synchronized 的特性

(1)互斥性

(2)可重入

2. synchronized 使用示例

(1)修饰代码块

(2)直接修饰普通方法

(3)修饰静态方法

3. Java 标准库中线程安全类

二、 volatile 关键字

1.内存可见性问题

2.指令重排序问题

·结尾


·前言

        在开始本篇文章分享之前,先简单回顾一下上一篇文章的内容,上一篇文章介绍了线程安全这一话题,列举了一个代码示例来演示线程不安全的样子,然后通过分析代码示例来引出了产生线程安全问题的原因,最后使用了 synchronized 关键字来解决了示例代码的问题,本篇文章就来针对在 Java 中解决线程安全问题两个重要的关键字 synchronized 关键字与 volatile 关键字进行详细的介绍。 

一、 synchronized 关键字

1. synchronized 的作用

        使用 synchronized 关键字主要是为了保证线程安全,至于他如何保证线程安全,这就与他的特性有关了,使用 synchronized 关键字属于加锁操作,在 Java 中还有很多加锁方式,但是使用 synchronized 关键字加锁是最主要的方式。

1. synchronized 的特性

(1)互斥性

        synchronized 会起到互斥效果,如果一个线程针对一个对象加上锁之后,其他线程再尝试对这个对象加锁就会产生阻塞,这也就是锁竞争,此时这个被阻塞的线程状态就会变成 BLOCKED 一直阻塞到前一个线程释放锁为止。如果两个线程是分别对不同的对象加锁,此时也就不会产生锁竞争,就不会有阻塞。

  • 进入 synchronized 修饰的代码块,相当于加锁;
  • 退出 synchronized 修饰的代码块,相当于解锁;

(2)可重入

        使用 synchronized 关键字所加的锁是可重入锁,可重入锁就避免了自己把自己锁死这样的问题,可以这样理解,在一个线程中,对同一个对象加锁两次如下图所示:

        此时就会出现“死锁问题”,但是由于使用 synchronized 加锁属于可重入锁,也就避免了上面的情况,那么他是如何避免的呢? 对于可重入锁来说,它的内部会持有两个信息:

  1. 当前这个锁是哪个线程持有的
  2. 记录加锁次数的计数器

        还是上面的代码,可重入锁的执行如下图所示: 

        在上述对同一把锁进行第二次加锁时,会先判断当前加锁的线程是否是持有锁的线程,如果不是同一个线程,那么就会进行阻塞,如果是同一个线程就只会进行计数器+1的操作,没有其他的操作了。

2. synchronized 使用示例

        在使用 synchronized 关键字进行加锁操作时,首先需要准备好“锁对象”,加锁解锁的操作都是依托于这里的“锁对象”来进行展开的,在 Java 中,任何一个对象都可以作为“锁对象”,可以大致认为 Java 中每个对象在内存中存储时,都有一块内存,表示当前“锁定”状态,这样,作为锁对象的对象,在当一个线程中代码执行到 synchronized 修饰的代码块时,这个对象的”锁定“状态就变为”加锁“状态,此时其他线程在想对这个对象加锁就会产生阻塞(为方便理解做出的假设)。

(1)修饰代码块

        修饰代码块就如下代码使用的加锁方式:

public class SynchronizedDemo {
    // 创建锁对象
    private static Object locker = new Object();
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            // 修饰代码块,进行加锁
            synchronized (locker) {
                System.out.println("hello thread!");
            }
        });
    }
}

        上述这种,属于锁定任意对象,因为这里 synchronized 所使用的锁对象是任意的,下面再演示一下锁定当前对象的用法,这里用一个示例代码,使用 synchronized 关键字来保证两个线程对变量 count 进行自增操作的线程安全,代码及运行结果如下:

class Test{
    public int count = 0;
    public void add() {
        // 使用 this 作为锁对象,是指锁定当前对象
        synchronized (this) {
            count++;
        }
    }
}

public class ThreadDemo13 {
    public static void main(String[] args) throws InterruptedException {
       Test t = new Test();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                t.add();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                t.add();
            }
        });
        // 启动线程 t1 和 t2
        t1.start();
        t2.start();
        // 等待线程 t1 和 t2 工作完成
        t1.join();
        t2.join();
        // 打印 count 的值
        System.out.println("count = " + t.count);
    }
}

 

        如上面示例代码可以发现,锁对象是 this,这就相当于锁定了当前 this 所引用的对象,代码中两个线程都使用创建出的对象 t 来调用 add 方法,所以对于这两个线程来说,this 指向的是同一个对象,就会产生锁竞争,就会产生阻塞,进而就可以保证线程安全。 

(2)直接修饰普通方法

        使用 synchronized 直接修饰普通方法,相当于锁的是当前类实例化出来的对象,如下图所示:

        这两个代码本质上都是对 this 进行加锁,也就是当前对象,两个线程在调用 add 方法时,是使用同一个对象来调用才会发生锁竞争,如果不使用同一个对象调用 add 方法就不会产生锁竞争,也就无法保证线程安全。 

(3)修饰静态方法

        使用 synchronized 修饰静态方法,相当于锁的是类对象,如下图所示:

        Test.class 这是获取类对象,一个类,类对象只有一个,所以当多个线程同时调用 func 方法时,就会产生锁竞争,直接修饰静态方法效果也是一样,静态方法可以直接使用类名来调用,所以本质也是对类对象进行加锁。 

3. Java 标准库中线程安全类

        在 Java 标准库中有很多都是线程不安全的,这是因为这些类可能会涉及到多线程修改共享数据(这是产生线程安全问题的原因之一),又没有添加任何锁措施,比如:

ArrayList、HashMap、TreeMap、TreeMap、HashSet、TreSet、StringBuider……

        虽然说上面这些类是线程不安全的,但也不是说在写多线程代码涉及多个线程尝试修改上述类实例化的同一个对象就 100% 出现问题,只是说容易出现线程安全问题,至于会不会出现也要具体代码具体分析。 

        当然,在 Java 标准库中也有一些是线程安全的类,这些类中都使用了一些锁机制来控制预防产生线程安全问题,比如:

ConcurrentHashMap、StringBuffer…… 

        这里以 StringBuffer 为例,如下图所示:        在 StringBuffer 中的一些关键方法上都使用 synchronized 关键字进行修饰了,所以在一定程度上保证了使用 StringBuffer 的线程安全。同样,使用这些类也不能保证 100% 不出现线程安全问题,也是需要具体代码具体分析。

        在这里,还有一个类虽然没有加锁,但是也是线程安全的,就是 String ,由于 String 实例化出来的对象不能被修改,所以可以保证线程安全。

二、 volatile 关键字

1.内存可见性问题

        如果我们写一个代码,使用一个线程对变量进行修改,一个线程去读取这个变量,此时会不会产生线程安全问题呢?其实也是可能会产生线程安全问题的,以下面代码为例,代码及运行结果如下所示:

import java.util.Scanner;

public class VolatileDemo {
    private static int flag = 0;
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        Thread t1 = new Thread(()->{
            while (flag == 0){
                // 循环中什么都不写
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(()->{
            System.out.println("请输入 flag 的值:");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

         上述代码主要是想用线程 t2 来控制线程 t1 的结束,可是当我们输入非 0 的值的时候,发现线程 t1 并没有结束,这显然是出现了问题,至于为什么出现这样的问题,这里我可以先给出一个原因,就是因为线程 t2 修改了内存,但是线程 t1 并没有看到这个内存的变化,这也就是“内存可见性问题”。

        那么为什么会出现这样的问题呢?在上述代码执行过程中,由于线程 t2 要等待用户的输入,所以无论 t1 先启动还是 t2 先启动,等待用户输入的过程中,线程 t1 必然是已经循环了很多次了,线程 t1 这里涉及到代码的核心指令有以下两条:

  1. load 读取内存中 flag 的值到 CPU 的寄存器中;
  2. 拿着 CPU 寄存器中的值和 0 进行比较。

        由于 t1 循环执行的速度非常快,所以就会产生反复的执行上述指令 1 和 2 ,在这个执行过程中又有两个关键要点:

  1. load 操作每次执行的结果都是一样的,这是因为在我修改 flag 是过了几秒后才修改的,在此期间,load 已经执行了上亿次;
  2. load 操作的开销远远大于比较操作(访问寄存器的操作速度远远超过访问内存)。

        频繁执行 load 和比较操作,其中执行 load 开销大,并且 load 的结果又没有变化(出现变化要等几秒后), 此时,JVM 就会认为我们这里的 load 操作没有存在的必要,就会做出代码优化,把上述 load 操作优化掉(只有前几次进行 load 操作,后面发现 load 获取的值是一样的,所以就直接把 load 操作给优化掉了),优化掉 load 操作后,就不会再重复读内存了,而是直接使用之前 load 操作读到 CPU 寄存器中“缓存”的值了,这样就会提高循环的执行速度。

        “内存可见性问题”是高度依赖编译器的优化来实现的,当然,编译器什么时候触发优化,什么时候不触发优化,并不容易观察,对于上述代码来说,只需要改动一点,结果就会截然不同,所以解决上述问题方法也很简单,我们可以直接在 t1 线程的循环中加上 sleep 方法,具体代码及运行结果如下所示:

import java.util.Scanner;

public class VolatileDemo {
    private static int flag = 0;
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        Thread t1 = new Thread(()->{
            while (flag == 0){
                // 循环中什么都不写
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(()->{
            System.out.println("请输入 flag 的值:");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

        上述代码可以正确运行结束,主要是因为加了 sleep ,这使原本一秒可以进行上百亿次的循环一下降到一秒循环 1000 次了,这是 load 操作的整体开销没有那么大了,所以优化的迫切程度就降低了,就不会进行优化了。

        修改后的代码虽然可以正常工作了,但是我们更希望让我们的代码确保无论当前代码怎么写都不要出现“内存可见性问题”,所以 Java 就提供了 volatile 关键字,volatile 关键字可以使上述的优化强制关闭,可以确保每次循环都重新从内存中读取数据。使用 volatile 关键字进行解决上述代码的“内存可见性问题”代码及运行结果如下:

import java.util.Scanner;

public class VolatileDemo {
    // 使用 volatile 关键字进行修饰变量 flag 迫使每次读 flag 变量都要从内存中读
    private static volatile int flag = 0;
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        Thread t1 = new Thread(()->{
            while (flag == 0){
                // 循环中什么都不写
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(()->{
            System.out.println("请输入 flag 的值:");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

 

        此时,代码也可以正确的运行。

2.指令重排序问题

        出现指令重排序问题也与 JVM 的优化有关,这个问题不容易用代码进行演示,所以可以举一个生活中的例子来进行说明,如下:

        假设目前我们执行的一段代码顺序是这样的:

  1. 去宿舍楼下取外卖;
  2. 回宿舍写作业;
  3. 去宿舍楼下卖水。

        上述逻辑在我们的 JVM 上会对流程进行一个优化,比如按 1->3->2 的顺序执行也是没有问题的,并且可以少下一次楼,这就叫做指令重排序,我们编译器在对于指令重排序的前提是“保持原有的逻辑不发生变化”,这一点在单线程环境下比较容易判断,但是在我们多线程环境下就没那么容易判断了,所以多线程中 JVM 对我们的代码进行指令重排序时就可能出现优化后的逻辑与之前不等价的情况,此时我们仍然可以使用 volatile 关键字来阻止 JVM 对代码进行指令重排序。

·结尾

        文章到此也就要结束了,通过使用 synchronized 关键字和 volatile 关键字可以解决线程安全的问题,当然解决线程安全问题也未必一定要使用上述两个关键字,希望通过对这两个关键字的介绍,能让大家更好的理解这两个关键字,这两个关键字在后续文章中会经常出现用来保证多线程代码的线程安全,如果对本篇文章的知识有不明白的地方欢迎在评论区进行留言,同样,如果感觉本篇文章还不错的话,留下您宝贵的三连吧,我们下一篇文章再见~~~

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

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

相关文章

Linux的GDB学习与入门

GDB GDB&#xff08;GNU Debugger&#xff09;是一个功能强大的调试工具&#xff0c;广泛用于调试 C、C 和其他编程语言编写的程序。它是 GNU 项目的一部分&#xff0c;专为帮助开发者在程序执行时检测和修复错误设计。GDB 能够控制程序的执行&#xff0c;查看程序内部的状态&…

2024诺奖引发思考,AI究竟是泡沫还是未来?

你好&#xff0c;我是三桥君 现在的AI技术发展得非常快&#xff0c;特别是深度学习和大模型这些技术&#xff0c;感觉和以前那些最后没搞成的技术泡沫不一样。 现在AI有超级强大的计算能力&#xff0c;还有大量的数据可以用来训练&#xff0c;算法也越来越厉害&#xff0c;能搞…

【单机游戏】【烛火地牢2:猫咪的诅咒】烛火地牢2:猫咪的诅咒介绍

《烛火地牢2&#xff1a;猫咪的诅咒》是一款将Roguelike与2D横版动作融为一体的独立游戏&#xff0c;由新西兰制作人Chris McFarland耗费3年时间精心制作。玩家将闯入不同的关卡接受挑战&#xff0c;通关要求是寻找每个关卡中的钥匙。在闯关时玩家能获得武器&#xff0c;防具&a…

关于int*的*号归属权问题

再根据函数指针定义&#xff1a;int (*int) (int a)。我们发现*和后面的标识符才是一体的 所以int *a,b;的写法更好&#xff0c;说明a是指针类型&#xff0c;b是int类型

让Kimi像人类思考的“Kimi探索版“已开启灰度内测!GPT-o1贡献者之一宣布离职|AI日报

文章推荐 “AI教父”辛顿与物理学家霍普菲尔德荣获诺贝尔物理学奖&#xff01;“AI教母”李飞飞选择谷歌云作为主要计算提供商&#xff5c;AI日报 今日热点 o1推理模型贡献者Luke Metz官宣从OpenAI离职 就在昨日&#xff0c;o1推理模型贡献者之一Luke Metz发文称自己经过两…

Ofcms-(java代码审计学习)

1、背景 根据《java代码审计实战》学习进行记录&#xff0c;java代码审计CMS实战。 2、Ofcms下载 可搜索Ofcms1.1.2版本进行下载。下载连接&#xff1a;ofcms: java 版CMS系统、基于java技术研发的内容管理系统、功能&#xff1a;栏目模板自定义、内容模型自定义、多个站点管…

五、UI弹窗提示

一、制作弹窗UI 二、创建脚本 1、继承WindowRoot&#xff08;UI基类&#xff09; 获取UI上面的组件 2、初始化 将这个文本失活 3、写一个提示出现的方法 这个派生类中&#xff0c;继承了基类的两个方法&#xff0c;设置显示和设置文本 对应基类的这两个方法 将动画赋值给动…

DDPM - Denoising Diffusion Probabilistic Models 扩散模型

DDPM - Denoising Diffusion Probabilistic Models 扩散模型 扩散模型概述 扩散模型是在模拟图像加噪的逆向过程。也就是在实现一个去噪的过程。简单的来说就包括两个过程组成 前向的扩散过程 Forward Diffusion Process反向扩散过程 Reverse Diffusion Process 下面我们对整个…

流域生态系统服务评价、水文水生态分析、碳收支、气候变化影响、制图等领域中的应用

流域生态系统服务评价、水文水生态分析、碳收支、气候变化影响、制图等领域中的应用 专题一、生态系统服务评价技术框架 1.1 生态系统服务概述 1.2 流域生态系统服务的分类与作用 1.3 生态系统服务评估方法 专题二、AI大模型与生态系统服务评价 2.1 目前常用大模型介绍 2…

Java并发 - AQS之ReentrantLock

文章目录 ReentrantLockAQS 队列AbstractOwnableSynchronizerAbstractQueuedSynchronizerNodewaitStatusSHARED/EXCLUSIVE 模式 加锁流程尝试加锁 tryAcquire加锁失败入队addWaiterenq 阻塞等待 acquireQueuedparkAndCheckInterrupt 放弃加锁 cancelAcquire唤醒阻塞线程 unpark…

大语言模型训练

大语言模型训练 1.两大问题2.并行训练2.1数据并行2.2模型并行2.3张量并行2.4混合并行 3.权重计算3.1浮点数3.2混合精度训练3.3deepspeed&#xff08;微软&#xff09;3.3.1 ZeRO3.3.2ZeRO-offload 3.3总结 4.PEFT4.1Prompt TuningPrefix-tuning4.2P-tuning & P-tuning v2 5…

arcpy总结

arcpy 一、是什么二、为什么三、怎么用1、在哪里打开2、基础术语3、代码组织4、案例&#xff08;1&#xff09;裁剪&#xff08;2&#xff09;土地变化特征分析&#xff08;4&#xff09;文件访问与检测&#xff08;5&#xff09;空间数据的查询、插入与更新&#xff08;6&…

伯努利分布(Bernoulli distribution)的两次成功之间间隔次数的分布

伯努利分布&#xff08;Bernoulli distribution&#xff09;是一种特殊的二项式分布&#xff0c;即0-1分布。百科上已经说明了这种分布&#xff0c;即&#xff0c;其中。其数学期望为&#xff0c;方差为。详细说明见0—1分布_百度百科 本文进一步说明对于这类分布的事件&#…

BUUCTF-greatescape1

发现有ftp包和tcp包居多 下载解压是个流量包&#xff0c;使用wiresharh打开&#xff0c;CTRLF&#xff0c;按下图搜索ftp tcp18流发现ssc.key 传送&#xff0c;在19流发现key内容 复制保存为ssc.key, 加载key解密tls&#xff0c;再追踪tls流可得flag INS{OkThatWasWay2Easy} …

微知-Bluefield DPU使用flint烧录固件报错MFE_NO_FLASH_DETECTED是什么?MFE是什么?

文章目录 背景一些报错场景MFE是什么&#xff1f;有哪些MFE 背景 在DPU的fw操作flint的时候&#xff0c;很多命令都会报这个错误&#xff1a;MFE_NO_FLASH_DETECTED&#xff0c;早期很疑惑并且猜测MFE是Mellanox Firmware Engine。实际并不是&#xff0c;具体还得走到mellanox…

linux 中mysql my.cnf 配置模版

前置准备 sudo systemctl stop mysqld 注意&#xff1a; 原本配置重命名做备份 备份数据 删文件 直接新建 my.cnf 把配置 11要粘进去的内容 直接粘进去 注意&#xff1a;尽管log-bin 和 log_bin 都可以启用二进制日志&#xff0c;但为了保持与现代MySQL版本的兼容性和一…

【算法系列-哈希表】两数之和(Map)

【算法系列-哈希表】两数之和 (Map) 文章目录 【算法系列-哈希表】两数之和 (Map)1. 两数之和(LeetCode 1, 梦开始的地方)1.1 思路分析&#x1f3af;1.2 解题过程&#x1f3ac;1.3 代码示例&#x1f330; 2. 四数相加II(LeetCode 454)2.1 思路分析&#x1f3af;2.2 解题过程&am…

MySQL-08.DDL-表结构操作-创建-案例

一.MySQL创建表的方式 1.首先根据需求文档定义出原型字段&#xff0c;即从需求文档中可以直接设计出来的字段 2.再在原型字段的基础上加上一些基础字段&#xff0c;构成整个表结构的设计 我们采用基于图形化界面的方式来创建表结构 二.案例 原型字段 各字段设计如下&…

rsync 数据镜像同步服务笔记

1. rsync概述 定义&#xff1a;rsync是一款数据镜像备份工具&#xff0c;支持快速完全备份和增量备份&#xff0c;支持本地复制与远程同步。镜像指两份完全相同的数据备份.特点&#xff1a; 支持整个目录树和文件系统的更新&#xff1b;可选择性地保留符号链接、文件属性、权限…

【Orange Pi 5嵌入式应用编程】-BMP280传感器驱动

BMP280传感器驱动 文章目录 BMP280传感器驱动1、BMP280传感器介绍2、BMP280的测量流程2.1 气压测量2.2 温度测量2.3 IIR滤波2.4 滤波器选择2.5 噪声3、BMP280的功耗模式3.1 休眠模式3.2 强制模式3.3 正常模式3.4 模式转换4、数据读取及计算4.1 寄存器数据覆盖4.2 输出补偿4.3 补…