当前位置: 首页 > article >正文

线程安全问题(面试重难点)

这里只是简单介绍以下线程安全,具体情况要结合代码进行判断

线程 是随机调度,及 抢占式执行 ,具有随机性,就可能会让我们的结果出现不同

当我们得到的结果并不是我们想要的时候(不符合需求),就会被认定为BUG,此时就是出现了线程安全问题

那么存在线程不安全的代码就被认为是 "线程不安全"

概念

我们先来看一个非常典型的线程不安全的例子

因为线程之间是并发执行的,所以我们为线程添加上等待,也就是说,理论上我们的代码运行的结果应该为 10000

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

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

    t.start();
    t2.start();
    t.join();//如果没有join,三个线程同时执行,则最后输出并不是t t2 计算完的效果(并发执行)
    t2.join();

    System.out.println("count = "+count);//预期是10000,但是实际值小于预测值,且每次不一样--->BUG-->少加
}

实际结果: 与我们预期不相符,那么此时就代表出现了 线程安全问题

那么为什么会出现这样的结果呢?原因就在于多线程的并发

CPU 执行指令,实际上就是执行一个线程的过程,加上多线程就会变得更加复杂

例如,代码中的 count++,虽然表面上是一行代码,但实际上是三个 CPU 指令

  1. 读取内存中的count数值到 CPU 寄存器中

  2. 寄存器中的值+1,但是此时修改过的值仍然在寄存器中,内存中的count没有发生变化

  3. 将寄存器中计算后的值,写回内存中的count

就像你完成作业,虽然听起来只是一个动作,但是也要你将书包里的作业本拿出来,将作业写好,再将作业放回书包里面,这三步操作

所以当两个线程同时都要count++时,这个顺序可能就会发生改变,就会出现以下情况

比如:

线程1将内存中的count拿出到寄存器,还没有完成放回操作

此时线程2也去内存中拿出count,那么此时,因为线程1还没有放回,就代表内存中的count没有被修改

也就是说,在这种条件下,线程2拿到的count和线程1拿到的是数值一样的,那么放回的也就会是一样的,此时线程1和线程2的语句也都执行了,count也分别被线程1和线程2改变了

只不过线程1和线程2的改动是一样的

只要我们将线程运行的顺序稍作修改:

t.start();
t.join();
t2.start();
t2.join();

此时得到的结果就是:

也就是说

如果多线程环境下代码运行的结果不符合预期(单线程下的结果),那么这个线程就是不安全的,反之,线程安全

原因

我们要先明确,多线程的运行逻辑: 线程是抢占式运行

修改共享数据

就是上面代码的那一种情况,涉及到多个线程对同一变量进行更改

这个变量就是多个线程都能访问到的共享数据

所以对于修改数据只有三种安全的情况:

  1. 单线程修改不同变量

  2. 多线程读取同一变量(这里并不涉及修改,只能获取)

  3. 多线程修改不同变量

原子性

什么叫做原子性呢?

指在多线程环境下,某一个操作要么完全执行,要么完全不执行,不会出现执行到一半被打断的情况,这个操作是不可分割的

举个例子:

将一段代码比作一个公共文档,在没有任何设置的情况下,任何人都可以对公共文档进行修改,而多线程中的线程就是要修改文档的人.当A正在进行修改的时候,B也要进行修改的话,可能就会因为多端同时修改而导致文档无法保存

更通俗一点讲就是:

如果我们没有考虑原子性:

购买火车票时,可能就会因为同时点击同一张票,并且同时进行付款,那么这张票很可能就会被卖两次

那么此时我们就要对文档进行设置,即一次只能允许一个人进行修改,当线程A进行修改时,其他线程就不能进行修改了,这样就保证了代码的原子性了

不过要注意的是, 一条 Java 语句不一定是原子的,也不一定只是一条指令 

比如 count++ ,就是一个典型的例子,他是一行语句,但是在CPU角度是三条指令

如果一个线程正在对一个变量进行操作,中途有其他线程插入进来了,如果操作被打断了,可能就会造成结果错误

也就是说,只要操作是原子的,那么多线程同时进行操作也就不会出现安全问题

比如: 直接对 int/double 直接赋值 --> 多个线程同时进行赋值也是不会出现问题的

可见性

指一个线程对共享变量值的修改,能够及时的被其他线程看到

在这之前,我们要先了解JMM(Java内存)

用来屏蔽掉各种硬件和操作系统的内存访问差异,来实现让Java程序在各种平台都能达到一致的并发效果

每一个线程都是有自己的工作内存的,相当于同一个共享变量的 "副本",修改线程A的工作内存的值,线程B的工作内存的值不一定会变化

就像上面的例子,count++

线程A已经修改了,但是线程B获取的count还是修改之前的主内存的count

代码顺序性

代码在编写时,也是需要对顺序进行考虑的

比如,有一段代码是这样的:

  1. 对变量A+5

  2. 对变量B-3

  3. 对变量A+3

  4. 最后输出变量A和B

在单线程的情况下,JVM、CPU指令集会代码进行优化,比如按照 1->3->2->4 的顺序进行执行,也就是直接对A+8,减少一次加法,这就是指令重排序

但是并不是所有的代码都可以进行重排序的!! 只有逻辑不发生变化的情况下,才能进行重排序

问题解决

这里暂时只给出代码来解决上述的问题

这里使用的方法是 --> 加锁 ,也就是将非原子代码转换为原子代码

public static int count = 0;
public static void main(String[] args) throws InterruptedException {
    //先创建一个对象,使用这个对象来作为锁
    Object locker = new Object();
    Thread t1 = new Thread(()->{
        for (int i = 0; i < 50000; i++) {
            synchronized (locker){//同步,这里指的是互斥
                count++;
            }
        }
    });
    //锁先被t1占领,t2先进入阻塞等待,只有当t1进行完毕之后,t2才能占领锁
    //这样t2的load就在t1的save之后  但是两个线程都是并发执行的
    Thread t2 = new Thread(()->{
        for (int i = 0; i <50000 ; i++) {
            synchronized (locker){
                count++;
            }
        }
    });

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

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


http://www.kler.cn/a/579108.html

相关文章:

  • 复现 MODEST 机器人抓取透明物体 单目 ICRA 2025
  • 游戏引擎学习第147天
  • openharmony适配HDF编译进Linux内核
  • 40岁开始学Java:控制反转IoC
  • 蓝桥备赛(13)- 链表和 list(上)
  • vue3组合式API怎么获取全局变量globalProperties
  • 统信UOS上AI辅助绘图:用DeepSeek+draw.io生成流程图
  • 可狱可囚的爬虫系列课程 18:成都在售新房数据爬虫(lxml 模块)实战
  • 在PyCharm开发环境中,如何建立hello.py文件?
  • Manus全球首个通用Agent,Manus AI:Agent应用的ChatGPT时刻
  • 计算机网络笔记(二)——1.2互联网概述
  • Dify使用日常:我是如何按标题级别将word中的内容转存到excel中的
  • 八点八数字科技:开启数字人应用的无限可能
  • 什么是时序数据库?有哪些时序数据库?常见的运用场景有哪些?
  • nlp培训重点-5
  • DeepSeek【部署 03】客户端应用ChatBox、AnythingLLM及OpenWebUI部署使用详细步骤
  • MySQL数据库安装篇
  • DR和BDR的选举规则
  • NLTK和jieba
  • DeepSeek 助力 Vue3 开发:打造丝滑的表格(Table)之功能优化,加载结合分页 ,Table11加载结合分页