【多线程】线程不安全问题

news2025/3/19 13:49:58

文章目录

  • 多线程不安全的原因
    • 大的层面->
      • 多线程是随机调度的
      • 容易产生死锁
    • 小的层面->
      • 内存不可见性
        • 引入volatile关键字
      • 指令重排序
        • 不是原子性带来的隐患
  • synchronized
    • 锁的互斥性及作用
    • 可重入性——解决死锁
  • wait()和notify()
    • 两个突然迸发出的疑问

多线程不安全的原因

大的层面->

多线程是随机调度的

操作系统根据CPU时间片轮转、优先级调度等调度策略,让各个线程轮流上台执行,而不是一次性做完一个线程的任务,而这个分配调度的过程是我们无法预测的,多线程任务产生与预期不符的结果—>线程不安全问题。多个线程共享数据并且可修改————线程A修改共享变量S,线程B修改共享变量S,线程C读取,由此产生:

  1. C在A和B修改前读取了,结果为原始值
  2. C在A修改成功后,B修改前读取了,结果为A修改后的(为何标红,因为修改到读取这里还大有文章,还存在内存不可见和指令重排序两个隐性问题)
  3. B的修改时间早于A…

容易产生死锁

锁使得多个线程在执行相同的方法体/代码块时,避免线程间同步执行任务而产生资源冲突、操作重复的问题,但是锁的存在也可能会让线程产生循环阻塞的效果,也就是死锁:

public class DemoThread {
    public static void main(String[] args) throws InterruptedException {
//        构造死锁代码
        Object locker1=new Object();
        Object locker2=new Object();

        Thread t1=new Thread(()->{
            synchronized(locker1){
                System.out.println("t1尝试获取locker1");
	                try {
	                    Thread.sleep(1000);
	                } catch (InterruptedException e) {
	                    throw new RuntimeException(e);
	                }
                synchronized (locker2){
                    System.out.println("t1尝试获取locker2");
                }
            }
        });

        Thread t2=new Thread(()->{
            synchronized(locker2){
                System.out.println("t2尝试获取locker2");
	                try {
	                   Thread.sleep(1000);
	                } catch (InterruptedException e) {
	                    throw new RuntimeException(e);
	                }
                synchronized (locker1){
                    System.out.println("t2尝试获取locker1");
                }

            }
        });

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

结果是打印两句后,程序不在进行下一步。原因是t1先一步获取到了locker1,t2先一步获取到了locker2,要让t1释放locker2就得让t1的代码块执行完也就是获取locker2,而要获取locker2就得让t2释放,t2又同时需要获取locker1。这样两个线程都在等对方
释放锁,就双双进入阻塞(BLOCKED)状态了。

t1尝试获取locker1
t2尝试获取locker2

使用jdk自带的jconsole.exe观察线程t1、t2状态:在这里插入图片描述
在这里插入图片描述

需要注意的是Java解决了可重入锁的问题(死锁的一种情况),也就是一个线程不能用锁把自己“锁住”,

public class DemoThread {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();

        Thread t1=new Thread(()->{
            synchronized(locker1){
                synchronized (locker1){
                    System.out.println("t1请求在锁里竞争自己这个锁");
                }
            }
        });
        t1.start();
    }
}

结果正常输出并且正常结束。(可重入的原理在synchronized段落细说)

t1请求在锁里竞争自己这个锁

小的层面->

内存不可见性

多线程的共享变量和对象存储在主内存【Main Memory】中,而每个线程有自己的工作内存【Working Memory】,实际上主内存就是物理意义上的CPU里的“内存”,工作内存就是CPU里的寄存器和缓存。共享变量实际存储在主内存里,而工作内存里的只是线程拷贝的副本,程序进行读取操作读取的是自己工作内存的副本值,线程修改变量时需要先修改工作内存里的副本再刷新到主内存里才算成功。而一个线程修改这个共享变量,还没刷新到主内存上,其他线程的副本还没与主内存的值同步就读取了自己工作内存的副本,产生了变量似乎更新失败的结果,这就是内存不可见(叫内存不可及时读到更好些)。

public class Main {
    public static boolean state=true;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while(state){
			//state一更新,t1就结束
            }
        });
        Thread t2=new Thread(()->{
            Scanner scanner=new Scanner(System.in);
            System.out.print("修改state:");
            state=scanner.nextBoolean();
        });
        t1.start();
        t2.start();
    }
}

然而在输入false后,没有出现程序结束的情况,原因是JIT编译器的自动优化,重复的读取操作让它将t1读取内存改为读取寄存器,而这时t2修改了值也就无法被t1察觉到,这也算内存不可见。


所以Java引入了volatile来规避这个问题。使用voltile修饰state后,上面的程序就可以正常结束了。

public static volatile boolean state=true;
引入volatile关键字

具体逻辑是被volatile修饰的变量只要涉及到修改,那么就会强制刷新主内存,使其他线程立即可见,即在值写入主内存前,其他线程无法插一脚进去来个读操作,其他线程拷贝的副本会失效,要进行读取,必须到主内存中读取,这样也顺便刷新了自己的工作内存。
使用voltile修饰state后,上面的程序就可以正常结束了。
volatile的这些特性都是基于内存屏障实现的,关于内存屏障,我想这篇文章讲解的比我会更好:《点此链接》


但volatile不具有原子性,也可以使用synchronized和原子类来解决内存不可见问题。

指令重排序

不是原子性带来的隐患
  • Java指令不一定是原子性的;原子性指对某个变量的操作或访问是不可分割的,要么完全执行,要么不完全执行,且这个过程不会被其他线程中断,比如变量自增就不是原子性的,底层分为读取>自增>加载到内存上三个指令操作。这样的任务细分可以提高程序的效率但也会因为指令重排序而产生问题。
  • 指令重排序是指编译器对代码的自动优化,即执行的指令不一定按代码顺序来,还有CPU对指令的动态调整,对于单线程很适用,但是在多线程中就会产生线程不安全问题。

所以在多线程场景下,对共享变量的操作会发生这样的问题:

```java
public class Main {
   public static int a=0;
   public static boolean flag=false;
   public static void main(String[] args) {
       Thread t1=new Thread(()->{
   			a = 1;          // 普通写操作
   			flag = true;    // 普通写操作
       });
       Thread t2=new Thread(()->{
   			if (flag) {     // 普通读操作
   				System.out.println(a); // 可能输出0
   			}
       });
       t1.start();
       t2.start();
   }
}

结果可能是

0

假定未发生内存不可见问题,可能的情况是t2对a的读指令排在t1的写指令前…
使用volatile、锁、原子类可以规避指令重排序。

synchronized

锁的互斥性及作用

synchronize [ˈsɪŋkrəˌnaɪz];锁可以解决内存不可见问题和指令重排序问题,它的操作是原子性的,可以让一段操作(代码块/方法)完全执行而中途不受其他干扰(锁的互斥性)。例如多个线程对一个共享变量同时修改,对这个修改操作加锁就可以解决存在的内存不可见和指令重排序问题,让这个修改操作真真正正的修改刷新到主内存上了且每个线程也都看到了,读取指令也不再是插在修改指令前,只能等持有锁的线程修改变量后才能读取。

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

        //两个线程让同一个变量自增两万次
        //count++操作必须由持有锁的线程执行完结束后另一个线程可以获取最新count值并自增,再刷新回主内存
        Object lock=new Object();
        Thread t1=new Thread(()->{
            for(int i=0; i<10000; i++){
                synchronized(lock){
                    count++;
                }
            }
        });

        Thread t2=new Thread(()->{
            for(int i=0; i<10000; i++){
                synchronized(lock){
                    count++;
                }
            }
        });

        t1.start();
        t2.start();
		//让main线程等待俩线程结束再观察count值
        t1.join();
        t2.join();
        System.out.println("count="+count);

结果:

count=20000

修饰代码块时,锁对象可以是任意对象,不过一般都是创建一个Object对象。
其他用法时的锁对象:

  • 修饰普通方法,锁对象是当前对象,相当于synchronized(this){...}
public synchronized void add(){}
  • 修饰静态方法,锁对象是类对象,也就是.class对象
public synchronized static void add(){}

可重入性——解决死锁

Java解决了可重入锁的问题,允许一个线程重复持有自己的锁,并且可以正常释放。

· ReentrantLock是java.util.concurrent.locks包的一个类,它的实例也叫锁对象,在它内部维护了两个字段,一个state变量(volatile int state)和一个持有线程(ExclusiveOwnerThread)用来记录当前持有锁的线程,每次加锁state自增一次,state=0表示0个线程持有该锁,state>0表示锁已被持有,数值代表重入的次数,而持有线程则记录是哪个线程持有了锁(将持有锁的线程的引用赋值给ExclusiveOwnerThread)。它是显式锁,需要你自己去手动创建才能实现可重入。
·那么我没用过ReentrantLock,咋也可以可重入的;别急,synchronized就是基于可重入锁机制实现的,在 JVM 中,锁对象(你自己定义的)会维护一个持有者的线程 ID 和锁的计数器。当线程第一次获取锁时,JVM 会记录该线程的锁状态。如果该线程再次请求同一个锁,JVM 会检查它是否已经持有该锁。如果是,它不会阻塞,而是允许线程继续执行,并且记录该线程持有锁的次数增加一次。

public class DemoThread {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
		//t1似乎在自己锁自己??!
        Thread t1=new Thread(()->{
            synchronized(locker1){
                synchronized (locker1){
                    System.out.println("t1请求在锁里竞争自己这个锁");
                }
            }
        });
        t1.start();
    }
}

程序正常打印并结束。

wait()和notify()

线程调度是随机的,而调度的过程由操作系统控制,为了让多个线程有逻辑性、可控的执行,那么就需要用到wait()和notify()/notifyAll()。

  • 它们在Object类里定义的,所以任意类的对象都可用这三种方法
  • Java定义都必须搭配synchronized使用
  • 使用wait的前提是持有锁,否则抛IllegalMonitorStateException异常
  • 没有线程使用锁对象调用wait(),该锁对象调用notify()/notifyAll()不会报错,但多此一举
方法说明
wait()使用锁对象调用,使用了该对象用作锁对象的线程释放锁进入阻塞等待状态
wait(long timeout)使用锁对象调用,使用了该对象用作锁对象的线程释放锁进入阻塞等待状态,有等待时间上限。参数单位:ms
notify()使用锁对象调用,唤醒一个使用了该对象用作锁对象的线程,使之持有锁
notifyAll()使用锁对象调用,唤醒所有使用了该对象用作锁对象的线程,调度器决定它们持有锁的先后,直到所有线程结束

两个突然迸发出的疑问

那么问题来了:

  1. 如果某个线程的锁对象调用了notify(),而且有多个线程使用该锁对象调用了wait(),是不是会像notifyAll()一样唤醒所有线程??
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();//定义锁对象
        Thread t1=new Thread(()->{
            synchronized (locker1){
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1结束");
            }
        });
        Thread t2=new Thread(()->{
            synchronized (locker1){
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t2结束");
            }
        });
        t1.start();
        t2.start();
        Thread.sleep(10);//sleep的作用是让wait执行在notify前,否则任何线程都唤醒不了
        synchronized (locker1){
            locker1.notify();
        }
}
结果是打印"t2结束""t1结束"后,另一个线程就杳无音讯了,程序也结束不了

显而易见这个notify()不会唤醒两个线程。在这种场景下,notify()会在等待的线程中挑一个线程唤醒(操作系统线程调度的范畴),让其加入等待行列,等以前持有锁的线程释放后,它立即获得锁,其他线程依旧阻塞等待。而notifyAll(),会唤醒所有线程,线程调度让它们依次获取锁,直到所有线程结束。

  1. 如果一个线程的任务执行到一半,该线程或者其他线程蹦出个wait强行让他释放锁进入阻塞状态,那么后面我再获得锁,任务咋进行?
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();//定义锁对象
        Thread t1=new Thread(()->{
            synchronized (locker1){
                try {
                    System.out.println("调用wait");
                    locker1.wait();//执行到这里锁被释放
                    System.out.println("结束wait");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1结束");
            }
        });
        Thread t2=new Thread(()->{
            synchronized (locker1){//相比于上面的例子删去了同步代码块
                System.out.println("t2结束");
            }
        });
        t1.start();
        t2.start();
        Thread.sleep(10);//sleep的作用是让wait执行在notify前,否则任何线程都唤醒不了
        synchronized (locker1){
            locker1.notify();
        }
}

结果是:

调用wait
t2结束
结束wait
t1结束

显而易见,运行过程应该是:t1先获取到了锁,执行到了wait()方法立即释放锁,同时t2执行完毕,main线程获取到了锁,执行notify(),t1线程又获取到了锁,从之前的执行进度继续执行。
所以线程半路被wait()打断强行阻塞,后面再获取锁会继续从被打断的进度开始继续执行。

七千字长文,点个关注再走呗😉

完。

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

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

相关文章

【C++】树和二叉树的实现(下)

本篇博客给大家带来的是用C语言来实现数据结构树和二叉树的实现&#xff01; &#x1f41f;&#x1f41f;文章专栏&#xff1a;数据结构 &#x1f680;&#x1f680;若有问题评论区下讨论&#xff0c;我会及时回答 ❤❤欢迎大家点赞、收藏、分享&#xff01; 今日思想&#xff…

kafka指北

为自己总结一下kafka指北&#xff0c;会持续更新。创作不易&#xff0c;转载请注明出处。 目录 集群controller选举过程broker启动流程 主题创建副本分布ISRleader副本选举机制LEO 生产数据流程同步发送和异步发送 分区策略ack应答生产者发送消息的幂等性跨分区幂等性问题&…

7、vue3做了什么

大佬认为有何优点&#xff1a; 组合式api----逻辑集中、对ts有更好的支持RFC–开放了一个讨论机制&#xff0c;可以看到每一个api的提案&#xff0c;方便源码维护&#xff0c;功能扩展&#xff0c;大家一起讨论 官方rfc响应式独立&#xff0c;new Proxy&#xff0c;天生自带来…

基于大语言模型与知识图谱的智能论文生成工具开发构想

基于大语言模型与知识图谱的智能论文生成工具开发构想 一、研究背景与意义 1.1 学术写作现状分析 #mermaid-svg-FNVHG5EiEgVSCpHK {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-FNVHG5EiEgVSCpHK .error-icon{fil…

JUC大揭秘:从ConcurrentHashMap到线程池,玩转Java并发编程!

目录 JUC实现类 ConcurrentHashMap 回顾HashMap ConcurrentHashMap CopyOnWriteArrayList 回顾ArrayList CopyOnWriteArrayList: CopyOnWriteArraySet 辅助类 CountDownLatch 线程池 线程池 线程池优点 ThreadPoolExecutor 构造器各个参数含义&#xff1a; 线程…

4.3--入门知识扫盲,IPv4的头部报文解析,数据报分片,地址分类(包你看一遍全部记住)

IPv4协议&#xff1a;网络世界的快递包裹指南&#xff08;附拆箱说明书&#xff09; “IPv4就像一张明信片&#xff0c;既要写清楚地址&#xff0c;又要控制大小别超重” —— 某网络工程师的桌面铭牌 一、IPv4报头&#xff1a;快递面单的终极艺术 1.1 报头结构图&#xff08;…

苍穹外卖-阿里云OSS使用

第一步&#xff1a; package com.sky.properties;import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;Component ConfigurationProperties(prefix "sky.alioss") …

Vue生命周期_Vue生命周期钩子

一、生命周期介绍 每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤&#xff0c;比如设置好数据侦听&#xff0c;编译模板&#xff0c;挂载实例到 DOM&#xff0c;以及在数据改变时更新 DOM。 在此过程中&#xff0c;它也会运行被称为生命周期钩子的函数&#xff0c;让…

数据库设计实验(4)—— 数据更新实验

一、目的与要求 掌握用SQL语句实现数据的插入、修改和删除。 二、实验准备 1. 建立一个商店的数据库store&#xff0c;记录顾客及其购物情况&#xff0c;由下面三个表组成&#xff1a; 商品&#xff08;商品号&#xff0c;商品名&#xff0c;单价&#xff0c;商品类别&#x…

Apache DolphinScheduler:一个可视化大数据工作流调度平台

Apache DolphinScheduler&#xff08;海豚调度&#xff09;是一个分布式易扩展的可视化工作流任务调度开源系统&#xff0c;适用于企业级场景&#xff0c;提供了一个可视化操作任务、工作流和全生命周期数据处理过程的解决方案。 Apache DolphinScheduler 旨在解决复杂的大数据…

再学:call与delegatecall、call转账 Bank合约

目录 1.call与delegatecall 2.transfer && call 3.若想内部传递abi编码 4.Bank合约 1.call与delegatecall call&#xff1a;切换上下文 delegatecall&#xff1a;不切换上下文 delegatecall可以理解为 A在调用B这个集成在A的方法 可升级合约&#xff0c;常用del…

关于解决新版本spring项目请求测试接口返回406的问题

目录 一、问题产生 二、问题排查 &#xff08;1&#xff09;首先是打断点debug进行排查 &#xff08;2&#xff09;网上查找相关资料排查 &#xff08;3&#xff09;老项目测试 三、问题解决 一、问题产生 使用Apifox对后端发送请求进行接口测试时返回状态码406&#xff0…

linux入侵排查_应急响应

1.实验目标 掌握linux系统中信息收集的方法 掌握linux系统中持久化操作方法及排查方式 掌握linux系统入侵排查思路 2.实验步骤 1.统计攻击者爆破次数 2.排查攻击者第一次使用恶意用户登录的时间 3.检查sudoer文件 4.排查计划任务 5.排查计划任务 6.排查恶意服务 7.排查…

AI视频生成产品体验分享(第2趴):Vidu、Hailuo、Runway、Pika谁更胜一筹?

hi&#xff0c;大家&#xff0c;继上次体验完可灵、即梦和pixverse&#xff0c;今天打算从产品经理的角度再研究下Vidu、Hailuo、Runway、Pika这几款产品&#xff01;欢迎加入讨论&#xff01; 一、产品简介 1. Vidu&#xff1a;国产自研的「一致性标杆」 &#x1f4cc;官网…

R语言高效数据处理-自定义格式EXCEL数据输出

注&#xff1a;以下代码均为实际数据处理中的笔记摘录&#xff0c;所以很零散&#xff0c; 将就看吧&#xff0c;这一篇只是代表着我还在&#xff0c;所以可能用处不大&#xff0c;这一段时间都很煎熬&#xff01; 在实际数据处理中为了提升效率&#xff0c;将Excel报表交付给…

基于srpingboot高校智慧校园教学管理服务平台的设计与实现(源码+文档+部署讲解)

技术范围&#xff1a;SpringBoot、Vue、SSM、HLMT、Jsp、PHP、Nodejs、Python、爬虫、数据可视化、小程序、安卓app、大数据、物联网、机器学习等设计与开发。 主要内容&#xff1a;免费功能设计、开题报告、任务书、中期检查PPT、系统功能实现、代码编写、论文编写和辅导、论…

【小白向】Word|Word怎么给公式标号、调整公式字体和花括号对齐

【小白向】Word&#xff5c;Word怎么给公式标号、调整公式字体和花括号对齐 我的版本&#xff1a;Word 2021 如需快速查看关键步骤&#xff0c;请直接阅读标红部分。 如果遇到无法调整的情况&#xff0c;可以直接下载我的示例文档进行参考&#xff1a;花括号和其他的示例公式.…

js逆向-下载某音乐

首先点击播放音乐&#xff0c;会拿到这样一个数据包 ​ 查看参数两个参数都是加密的 ​ 返回包里面有一个url&#xff0c;url拿到访问发现就是音频链接 ​ 访问直接下载下来 ​ 要逆向这两个参数采用xhr断点 ​ 这里加上路径的一部分 ​ 发现这些参数都是加密的 ​ 往下跟栈&am…

百度OCR调用记录

根据说明&#xff0c;调用测试 设置注册的API Key和Secret Key 调用类&#xff08;官方文档中有&#xff09; 这里改传入路径&#xff1b; 测试问题 1.{"error_code":110,"error_msg":"Access token invalid or no longer valid"} 查到说是 …

项目实战:基于瑞萨RA6M5构建多节点OTA升级-创建系统最小框架<三>

MCUBoot项目创建完成后,接下来我们需要搭建多节点OTA系统最小框架,再将系统分模块搭建逐层完善,直到实现最终完整系统。开始动手干吧! 目录 一、创建项目 ​二、配置FSP ​2.1 配置RS485属性 ​2.2 配置定时器0 2.3 创建初始化进程并配置属性 ​2.4 创建RS485进程并…