JavaEE 初阶篇-深入了解多线程安全问题(出现线程不安全的原因与解决线程不安全的方法)

news2024/11/27 11:10:31

🔥博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞👍收藏⭐评论✍

文章目录

        1.0 多线程安全问题概述

        1.1 线程不安全的实际例子

        2.0 出现线程不安全的原因

        2.1 线程在系统中是随机调度且抢占式执行的模式

        2.2 多个线程同时修改同一个变量

        2.3 线程对变量的修改操作不是“原子”

        2.4 内存可见性

        2.5 指令重排序

        3.0 解决线程不安全问题(使用锁机制)

        3.1 synchronized 关键字可以作用的地方

        3.1.1 同步代码块

        3.1.2 同步实例方法

        3.1.3 同步静态方法

        3.2 join() 方法与 synchronized 关键字的区别

        4.0 加锁不合理所引发的问题

        4.1 对于一个加锁与一个没有加锁的两个线程随机调度执行同一个代码块或者方法的情况

        4.2 嵌套相同的锁 - 可重入锁

        4.3 两个线程两把锁 - 死锁

        4.4 死锁的四个必要条件

        4.4.1 互斥条件

        4.4.2 不可剥夺条件

        4.4.3 请求保持条件

        4.4.4 循环等待条件

        4.5 如何避免死锁?


        1.0 多线程安全问题概述

        多线程安全问题是指在多线程环境下,多个线程同时访问共享资源可能导致的数据不一致、数据竞争、死锁等问题。

        1.1 线程不安全的实际例子

        在多线程中,很容易就会出现多线程问题,从而引发多线程安全问题。

先看以下代码:

    public static long count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                    count++;
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                    count++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }

        一般来说,t1 和 t2 都对 count 这个变量进行 count++ 这个操作,那么 count 最后的输出结果按理来说应该为 10万。但是输出的结果是不一定为 10 万,而且等于 10 万的概率非常非常非常小。

运行结果:

        每次的运行结果都是不一样的,很大概率都是在 5 万到 10 万之间“徘徊”。也有可能会小于 5 万。

        出现了以上的结果,都是因为多线程抢占式执行随机调度,从而导致的结果。

具体分析:

        在 CPU 中执行 count++ 这一操作,大致需要执行三条指令:

        1)load:将内存中的数据读取加载到 CPU 的寄存器中。

        2)add:在寄存器中的值 +1 操作。

        3)save:把寄存器中的值写回到内存中。

        现在 t1 与 t2 两个线程并发的进行 count++ ,多线程的执行是随机调度,抢占式的执行模式。有可能会出现以下几种情况:

        出现可能 1 或者可能 2 这两次 count++ 的最终结果都是 2 ,因此是正确的。而对于可能 3 这种情况,虽然说,t1 与 t2 都完成了 count++ 的操作,但是,对于可能 3 这种情况,在 t2 完成 count++ 之后,count 由 0 改为 1 ,再把值写回到内存之后,但是 t1 来说,同样也是把 count 由 0 改为 1 ,写回到内存中。简单来说,t1 将 t2 线程中 count 进行了一次覆盖,重新赋值,所以 t2 这个线程的操作是无效操作。

        出现的情况有无数种,不可预计的。之所以说,最后出现的结果为 10 万的概率是非常非常小的,几乎没有可能吧。

对于出现小于 5 万的情况:

          按理来说,进行了三次 count++ 操作,最后的结果应该为: count == 3,但是这里最后的结果:count == 1。这就是有可能出现 count 小于 5万的可能,出现数越加越小了。

        以上就是属于多线程引发的线程安全问题。

        2.0 出现线程不安全的原因

        2.1 线程在系统中是随机调度且抢占式执行的模式

        这是导致线程不安全的“罪魁祸首,万恶之源”,不能去改变这个机制。

        2.2 多个线程同时修改同一个变量

        当多个线程同时修改同一个变量时,可能会导致数据竞争和结果不确定性。这种情况下,需要采取线程安全的措施来确保数据的一致性。

        2.3 线程对变量的修改操作不是“原子”

        count++ 这种,不是原子操作,在 cpu 执行 count++ 操作需要三条指令。

        2.4 内存可见性

        2.5 指令重排序

        3.0 解决线程不安全问题(使用锁机制)

        锁机制可以确保在任意时刻只有一个线程可以访问共享资源,从而避免数据竞争和保证数据的一致性。

        可以使用 synchronized 关键字等来实现锁机制。

代码如下:

    public static long count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                    synchronized (o){
                        count++;
                    }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                    synchronized (o){
                        count++;
                    }
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }

        通过 synchronized 关键字,把 count++ 这个操作进行了加锁,对于 o 对象可以理解为一个标志,一个锁的标识,当进入 {} 那一刻,就会加上锁,执行完代码块中的代码后,退出 {} 时,会自动解锁。 

        现在对于 t1 与 t2 线程来说,在每一次 for 循环之后,会抢占 “加锁” 随机调度,两个线程抢占的机会是一样的,比如说 t1 抢占到了“加锁”,那么 t2 想要再次对 count++ 加锁时,先会判断,判断当前加锁的线程是哪一个线程,如果不是自己线程,那么就会阻塞等待 t1 线程。等待 t1 执行完毕之后,t1 与 t2 会继续抢占对 count++ 这个操作进行“加锁”处理,一直循环往复。

        这样就保证了 CPU 在执行 3 条指令的时候,不会被其他线程“打扰到”。每一次都是如

此:

最后的运行结果:

        此时多线程问题是安全的。

补充:

        1)锁本质上也是操作系统提供的功能,内核提供的功能,通过 API 给应用程序 JVM 对于这样的系统 API 又进行封装。

        2)锁对象的用途,有且只有一个,就是用来区分,判断两个线程是否是针对同一个对象加锁,如果是,就会出现锁竞争/锁冲突/互斥,就会引起阻塞等待;如果不是,就不会出现锁竞争,也就不会阻塞等待。

        和对象的具体是什么类型,和它里面的属性、方法,对于接下来操作这个对象统统没有任何关系。所以可以将类似 o 对象简单理解为一个标识,一个工具。

        3)锁涉及的核心有两个:加锁、解锁

        主要的特性:互斥,一个线程获取到锁之后,另一个线程也尝试加这个锁,就会阻塞等待(锁竞争/锁冲突)。

        在代码中,可以创建出多个锁,只有多个线程竞争同一把锁,才会产生互斥,针对不同的锁,则不会。

        3.1 synchronized 关键字可以作用的地方

        3.1.1 同步代码块

        使用 synchronized 关键字修饰代码块,可以指定对象作为锁,确保在同一时刻只有一个线程可以访问该代码块。其他线程需要等待获取锁后才能执行代码块。

    public static long count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //作用于代码块中
                synchronized (o){
                    count++;
                }
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //作用于代码块中
                synchronized (o){
                    count++;
                }
            }
        });

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

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

        System.out.println(count);
    }

        3.1.2 同步实例方法

        使用 synchronized 关键字修饰实例方法,可以确保在同一时刻只有一个线程可以访问该实例方法。其他线程需要等待当前线程执行完毕后才能访问。

代码如下:

public class demo11 {
    public synchronized void add(){
        count++;
    };

    public long get(){
        return count;
    };

    public static long count = 0;
    public static void main(String[] args) throws InterruptedException {
        demo11 demo = new demo11();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                demo.add();
            }
        });

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

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

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

        System.out.println(demo.get());
    }
}

        3.1.3 同步静态方法

        使用 synchronized 关键字修饰静态方法,可以确保在同一时刻只有一个线程可以访问该静态方法。其他线程需要等待当前线程执行完毕后才能访问。

代码如下:

public class demo11 {
    public synchronized static void add(){
        count++;
    };

    public static long get(){
        return count;
    };

    public static long count = 0;
    public static void main(String[] args) throws InterruptedException {

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

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

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

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

        System.out.println(demo11.get());
    }
}

        3.2 join() 方法与 synchronized 关键字的区别

        1)join() 是 Thread 类的方法,用于等待调用该方法的线程执行完成。当一个线程调用另一个线程的 join() 方法时,它会被阻塞,直到被调用的线程执行完成。

        2)synchronized 关键字用于实现线程同步确保在同一时刻只有一个线程可以访问某个代码块或方法。

         总的来说,join 用于线程之间的协作和等待,而 synchronized 用于实现线程之间的同步和互斥访问共享资源。

        4.0 加锁不合理所引发的问题

        4.1 对于一个加锁与一个没有加锁的两个线程随机调度执行同一个代码块或者方法的情况

        对于这种情况来说,同样会导致多线程安全问题。因为对于一个加锁与另一个没有加锁的情况,这两个线程之间没有锁竞争或者产生互斥,所以还是会出现多线程安全问题。

代码如下:

public class demo9 {

    public static long count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                    synchronized (o){
                        count++;
                    }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                        count++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

运行结果:

        以上代码中即使有一个线程加上了锁,同样也是跟没有加锁的代码本质是一样的。因此,需要对两个线程中且操作同一个代码块或者方法进行同时加锁处理,才会解决多线程的安全问题。

        4.2 嵌套相同的锁 - 可重入锁

        在同一个线程中,嵌套同一个锁被称为可重入锁

代码如下:

        在外层加完锁之后,在内层继续加了相同的锁。再来了解加锁的详细过程:两个线程随机调度执行,假设 t1 对该代码块加锁,而 t2 就不能加锁了,此时产生了锁竞争,t2 需要阻塞等待 t1 执行后解锁后,才能继续去“抢夺”上锁;对于 t1 来说外层加完锁之后,此时内层加锁之前需要判断当前是那个线程对当前的代码块上锁,如果是当前线程加锁了,那么内层加锁这个操作就是为无,可以继续往下执行,注意这里没有产生锁竞争;如果不是当前线程加锁了,就会阻塞等待。

所以可重入锁是安全的,运行结果: 

        补充:解锁是执行到外层 } 花括号结束之后,才会自动解锁,而不是执行到内层的 } 花括号解锁。所以,内层加锁其实是没有用的,正常来说,有最外面加锁就足够了,之所以要搞上述操作,就是担心不小心把代码写错从而搞出“死锁”,目的就是避免程序员粗心大意。

        4.3 两个线程两把锁 - 死锁

        死锁是系统中的多个线程或进程相互等待对方释放资源,从而陷入僵局无法继续执行的状态。

代码如下:

public class demo12 {
    public static void main(String[] args) {

        Object o1 = new Object();
        Object o2 = new Object();

        Thread t1 = new Thread(()->{
           synchronized (o1){
               //这里用到 sleep 方法的原因是因为,
               //保证 t2 线程执行完:对 o2 进行加锁操作
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }

               synchronized (o2){
                   System.out.println("正在执行 t1 线程");
               }
           }
        });

        Thread t2 = new Thread(()->{
           synchronized (o2){
               //这里用到 sleep 方法的原因是因为,
               //保证 t1 线程执行完:对 o1 进行加锁操作
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }

               synchronized (o1){
                   System.out.println("正在执行 t2 线程");
               }
           }
        });

        t1.start();
        t2.start();
    }
}

        此时 t1 线程对 o1 加锁了,t2 线程对 o2 加锁了,对于 t1 来说,想要继续往下执行,需要 t2 对 o2 解锁,想要 t2 对 o2 解锁对话,需要 t1 对 o1 解锁。想要 t1 解锁的话,还是需要 t2 对 o2 解锁。。。此时成了很尴尬的情况,对方要需要对方的资源,双方都相互等待对方释放资源,从而僵持住了,无法执行下去。这样就造成了一个死锁。

通过 Jconsole 可执行程序观察:

已经检测到了死锁

运行结果:

        程序一直在运行中

        4.4 死锁的四个必要条件

        4.4.1 互斥条件

        资源只能被一个线程或进程所持有,其他线程无法同时访问。

        4.4.2 不可剥夺条件

        线程已经获取的资源在未使用完之前不能被其他线程所抢占。

        4.4.3 请求保持条件

        线程可以持有一些资源并继续请求其他资源。

        4.4.4 循环等待条件

        每个线程都在等待其他线程所持有的资源,形成一个循环等待的情况。

        4.5 如何避免死锁?

        只需要破环任意一个满足死锁的必要条件即可。

        1)对于互斥条件来说,不能破坏,所以不用考虑这种情况。

        2)对于不可剥夺条件,破坏该条件的方法是:如果一个线程无法获取资源,可以释放已经持有的资源,避免长时间占用资源。

        3)对于请求保持条件,破坏该条件的方法是:一次性获取所有需要的资源。

        4)对于循环等待条件,破坏该条件的方法是:按照固定顺序来获取资源,避免形成循环等待。

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

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

相关文章

C++基础之虚函数(十七)

一.什么是多态 多态是在有继承关系的类中&#xff0c;调用同一个指令&#xff08;函数&#xff09;&#xff0c;不同对象会有不同行为。 二.什么是虚函数 概念&#xff1a;首先虚函数是存在于类的成员函数中&#xff0c;通过virtual关键字修饰的成员函数叫虚函数。 性质&am…

c语言:用do-while输出前40项的斐波那契数值

求Fibonacci数列的前40个元素。该数列的特点是第1、2两个数为1、1。从第3个数开始&#xff0c;每数是其前两个数之和。 分析&#xff1a;从题意可以用如下等式来表示斐波那契数列&#xff1a; 1&#xff0c; 1&#xff0c; 2&#xff0c; 3&#xff0c; 5&#xff0c; 8&#x…

如何确保实物档案的安全

确保实物档案的安全有以下几个关键点&#xff1a; 1. 建立完善的安全措施&#xff1a;为实物档案建立专门的存储区域&#xff0c;控制进出口&#xff0c;限制访问权限&#xff0c;并使用安全锁和监控设备等物理安保措施。 2. 规范档案管理制度&#xff1a;建立档案管理制度&…

深度学习入门简单实现一个神经网络

实现一个三层神经网络 引言测试数据 代码 引言 今天我们实现一个简单的神经网络 俩个输入神经元 隐藏层两个神经元 一个输出神经元 激活函数我们使用sigmoid 优化方法使用梯度下降 我们前期准备是需要把这些神经元的关系理清楚 x1&#xff1a;第一个输入 x2&#xff1a;第二个…

Android ImageView 的scaleType 属性图解

目录 前言测试素材测试布局xmlscaleType前言 一、ScaleType.FIT_CENTER 默认二、ScaleType.FIT_START三、ScaleType.FIT_END四、ScaleType.FIT_XY五、ScaleType.CENTER六、ScaleType.CENTER_CROP七、ScaleType.CENTER_INSIDE八、ScaleType.MATRIX 前言 原文链接&#xff1a; A…

接口自动化框架搭建(三):pytest库安装

1&#xff0c;使用命令行安装 前提条件&#xff1a;已安装python环境 pip install pytest 或者 pip3 install pytest2&#xff0c;从编译器pycharme中安装

STM32 | PWM脉冲宽度调制(第五天呼吸灯源码解析)

STM32 | PWM脉冲宽度调制(第五天)PWM 技术在以下其他机器学习领域和应用中也可以发挥作用: 自然语言处理 (NLP):调节文本生成模型(例如 GPT-3)的输出长度和多样性。 强化学习:控制代理在环境中采取行动的频率和持续时间。 时间序列预测:调节模型预测未来事件的时间间隔…

HTLM 之 vscode 插件推荐

文章目录 vscode 插件live Serverprettiersetting 保存这个文档的更改Material Theme / Material Theme icon vscode 插件 live Server prettier setting 搜索 format default 保存这个文档的更改 cmds // mac ctrls // win Material Theme / Material Theme icon 来更换…

命名空间【C++】(超详细)

文章目录 命名空间的概念命名空间的定义命名空间定义的位置作用域每一个命名空间都是一个独立的域作用域符&#xff1a;&#xff1a; 编译器找一个变量/函数等的定义&#xff0c;寻找域的顺序为什么要有命名空间&#xff1f;1.解决库与程序员定义的同名的重定义问题2.解决程序员…

腾讯 tengine 替代 nginx

下载地址 变更列表 - The Tengine Web Server 解压 tar -xvf 安装包.gz 进入到解压目录 cd 解压目录 使用 ./configure 命令来指定安装目录,这边指定安装到 /opt/tengine/install路径下 新建install目录 ./configure --prefix/opt/tengine/install 检查是否有缺失的依…

Windows中忘记MySQL ROOT密码的解决方法

在需要ROOT身份登录MySQL但又忘记密码时&#xff0c;可以先已管理员身份运行cmd命令窗口,输入以下命令停止MySQL服务 net stop mysql 随后cd到MySQL安装目录下的bin目录下 D: //我的安装在D盘 cd D:\MySQL\bin 使用跳过权限验证的方式起启动MySQL mysqld --console --skip-g…

双非计算机考研目标211,选11408还是22408更稳?

求稳得话&#xff0c;11408比22408要稳&#xff01; 很多同学只知道&#xff0c;11408和22408在考察的科目上有区别&#xff0c;比如&#xff1a; 11408考的是考研数学一和英语一&#xff0c;22408考察的是考研数学二和英语二&#xff1a; 考研数学一和考研数学二的区别大吗…

PCL点云处理之 基于垂直度检测与距离聚类 的路面点云提取方案 (二百三十九)

PCL点云处理之 基于垂直度检测与距离聚类 的路面点云提取方案 (二百三十九) 一、算法流程二、具体步骤1.垂直度检测与渲染1.代码2.效果2.水平分布点云提取1.代码2.效果3.路面连通点云提取1.代码2.效果三、完整代码四、参考文献一、算法流程

Java:链表

一、链表简介 1、链表与顺序表的区别 上一篇博客我介绍了顺序表&#xff0c;这次我们来认识认识链表&#xff01;先来看看二者的区别&#xff1a; 顺序表&#xff1a;由于顺序表实际上是一个数组&#xff0c;因此它在物理上是连续的&#xff0c;逻辑上也是连续的&#xff01; …

文件上传漏洞-黑名单检测

黑名单检测 一般情况下&#xff0c;代码文件里会有一个数组或者列表&#xff0c;该数组或者列表里会包含一些非法的字符或者字符串&#xff0c;当数据包中含有符合该列表的字符串时&#xff0c;即认定该数据包是非法的。 如下图&#xff0c;定义了一个数组$deny_ext array(.a…

电脑文件msvcp120.dll丢失的解决方法详细分析,找多种靠谱方法修复

遇到msvcp120.dll文件丢失的问题实际上不算罕见&#xff0c;这往往是由于我们频繁使用电脑而导致的意外删除&#xff0c;或者是电脑受到病毒感染。当这类情况发生时&#xff0c;msvcp120.dll文件可能会被错误地移除或损坏&#xff0c;这便需要我们去进行修复。接下来&#xff0…

3D汽车模型线上三维互动展示提供视觉盛宴

VR全景虚拟看车软件正在引领汽车展览行业迈向一个全新的时代&#xff0c;它不仅颠覆了传统展览的局限&#xff0c;还为参展者提供了前所未有的高效、便捷和互动体验。借助于尖端的vr虚拟现实技术、逼真的web3d开发、先进的云计算能力以及强大的大数据处理&#xff0c;这一在线展…

【正点原子Linux连载】第二十三章 Linux PWM驱动实验 摘自【正点原子】ATK-DLRK3568嵌入式Linux驱动开发指南

1&#xff09;实验平台&#xff1a;正点原子ATK-DLRK3568开发板 2&#xff09;平台购买地址&#xff1a;https://detail.tmall.com/item.htm?id731866264428 3&#xff09;全套实验源码手册视频下载地址&#xff1a; http://www.openedv.com/docs/boards/xiaoxitongban 第二十…

Rust 02.控制、引用、切片Slice、智能指针

1.控制流 //rust通过所有权机制来管理内存&#xff0c;编译器在编译就会根据所有权规则对内存的使用进行 //堆和栈 //编译的时候数据的类型大小是固定的&#xff0c;就是分配在栈上的 //编译的时候数据类型大小不固定&#xff0c;就是分配堆上的 fn main() {let x: i32 1;{le…