详细剖析多线程2----线程安全问题(面试高频考点)

news2024/9/25 17:17:44

文章目录

  • 一、概念
  • 二、线程不安全的原因
  • 三、解决线程不安全问题--加锁(synchronized)
    • synchronized的特性
  • 四、死锁问题
  • 五、内存可见性导致的线程安全问题


一、概念

想给出⼀个线程安全的确切定义是复杂的,但我们可以这样认为:
在多线程环境下程序能够按照预期的方式运行,并且不会出现数据竞争或不一致性的情况。因此,如果一个程序在单线程环境下能够正常运行,在多线程环境下也能够保持一致性和正确性,那么可以认为这个程序是线程安全的。反之,如果一个程序在多线程环境下出现了竞态条件、死锁、数据竞争等问题,那么可以认为这个程序是线程不安全的。

二、线程不安全的原因

首先观察一下下面的这段代码,判断他能否按预期输出10w?

package Thread;
//线程安全问题
public class ThreadDemo16 {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        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();
        // 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了
        //很可能打印出来的 count是初始值0,或者计算中间的值,总之是不确定的结果
        t1.join();
        t2.join();
        // 预期结果应该是 10w
        System.out.println("count: " + count);

    }
}

运行之后我们发现打印的结果并不是10w,并且每一次运行的结果都是随机的,由此可见这个线程是不安全的,那么为什么会出现线程不安全这个问题呢?
在这里插入图片描述

由此总结线程不安全的原因-----

  1. 【根本原因】==操作系统上的线程是“抢占式执行”的,线程调度是随机的,==这是线程不安全的一个主要原因。随机调度会导致在多线程环境下,程序的执行顺序不确定,程序员必须确保无论哪种执行顺序,代码都能正常运行。
  2. 【代码结构】共享资源:多个线程同时访问并修改共享的数据或资源。当多个线程同时访问和修改共享资源时容易引发竞态条件和数据不一致的问题。
    ①一个线程修改一个变量是安全的
    ②多个线程修改一个变量是不安全的
    ③多个线程修改不同变量是安全的
  3. 【直接原因】多线程操作不是“原子的”。多线程操作中的原子性指的是一个操作是不可中断的,要么全部执行完成,要么都不执行,不能被其他线程干扰。这对于并发编程非常重要,因为如果一个操作在执行过程中被中断,可能导致数据不一致或者其他意外情况发生。(在上述多线程操作中,count++操作不是“原子的”,而是由多个CPU指令组成的,一个线程执行这些指令时,可能会在执行过程中被抢占,从而给其他线程“可乘之机”。要保证原子性操作,每个CPU指令都应该是“原子的”,即要么完全执行,要么完全不执行。)
  4. 内存可见性问题:在多线程环境下调用不可重入的函数(即不支持多线程调用的函数),可能导致数据混乱或程序崩溃。
  5. 指令重排序问题:在多线程环境下,由于编译器或处理器对指令进行重排序优化,可能导致预期之外的程序行为。

三、解决线程不安全问题–加锁(synchronized)

针对前述代码我们通过加锁解决线程安全问题

private 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++;
                }
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        // 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 cou
        t1.join();
        t2.join();
        // 预期结果应该是 10w
        System.out.println("count: " + count);
    }

synchronized的特性

1)互斥
synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执⾏到同⼀个对象 synchronized 就会阻塞等待.
• 进⼊ synchronized 修饰的代码块, 相当于加锁
• 退出 synchronized 修饰的代码块, 相当于解锁

理解 “阻塞等待”.
针对每⼀把锁, 操作系统内部都维护了⼀个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进⾏加锁, 就加不上了, 就会阻塞等待, ⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程,再来获取到这个锁.

注意:
• 上⼀个线程解锁之后, 下⼀个线程并不是⽴即就能获取到锁. ⽽是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的⼀部分⼯作.
• 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C
都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B ⽐ C 先来的, 但是 B 不⼀定就能获取到锁,⽽是和 C 重新竞争, 并不遵守先来后到的规则.

synchronized的底层是使⽤操作系统的mutex lock实现的

2)可重入
我们来看一段代码

package Thread;
//下面这个代码能打印hello吗?
public class ThreadDemo17 {
    public static void main(String[] args) {
        Object lock=new Object();
        Thread t=new Thread(()->{
            synchronized (lock){//真正加锁,同时把计数器+1(初始为0,+1之后就说明当前这个对象被该线程加锁一次)同时记录线程是谁
                synchronized (lock){//先判定当前加锁线程是否持有锁的线程,如果不是同一个线程,阻塞
                                    //如果是同一个线程,就只是++计数器即可,没有其他操作
                    System.out.println("hello");
                }//1  把计数器-1,由于计数器不为0,不会真的解锁
            }//2---应该在2这里解锁,避免1和2之间的逻辑失去锁的保护,执行到这里,再次把计数器-1,此时计数器归零,真正解锁
            //总之就是最外层的{进行加锁,最外层的}进行解锁
        });
        t.start();
    }
}

能够打印hello
在这里插入图片描述
这段代码看起来有锁冲突,但是最终不会出现阻塞,关键在于,这两次加锁,其实是对同一个线程进行的;当前由于是同一个线程,此时锁对象就知道了第二次加锁的线程,就是持有锁的线程,第二次操作,就可以直接放行通过,不会线程阻塞,这个特性,称为“可重入”。可重入锁就是为了防止程序员在写代码时不小心写出来双重锁的效果而使代码出现问题,意思就是就算不小心写了那种双重锁代码,丰富的程序机制也不会让代码有问题。

对于可重入锁来说,内部会持有两个信息
1)当前这个锁是被哪个线程持有的
2)加锁次数的计数器

四、死锁问题

死锁是多线程中一类经典问题
加锁是能解决线程安全问题,但如果加锁方式不当,就可能产生死锁。

死锁的三种典型场景

  • 一个线程一把锁
    如果锁是不可重入锁,并且一个线程对这把锁加锁两次,就会出现死锁。(判定一个锁是可重入锁还是不可重入锁的关键在于是否允许同一个线程多次获取同一个锁。可重入锁允许同一个线程多次获取同一个锁,而不可重入锁则不允许。)
  • 两个线程,两把锁 线程1获取到锁A,线程2获取到锁B,接下来,1尝试获取B,2尝试获取A,就同样出现死锁了。 一旦出现死锁,线程就”卡住“无法继续工作。
    eg.
//这是一个死锁代码,严重bug 
public class ThreadDemo18 {
   public static void main(String[] args) {
       Object A = new Object();
       Object B = new Object();
       Thread t1 = new Thread(() -> {
           synchronized (A) {
               // sleep 一下, 是给 t2 时间, 让 t2 也能拿到 B
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               // 尝试获取 B, 并没有释放 A
               synchronized (B) {
                   System.out.println("t1 拿到了两把锁!");
               }
           }
       });

       Thread t2 = new Thread(() -> {
           synchronized (B) {
               // sleep 一下, 是给 t1 时间, 让 t1 能拿到 A
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               // 尝试获取 A, 并没有释放 B
               synchronized (A) {
                   System.out.println("t2 拿到了两把锁!");
               }
           }
       });
       t1.start();
       t2.start();
   } }

这个死锁问题也可以通过约定加锁顺序解决,先对A加锁,然后对B加锁
在这里插入图片描述

  • N个线程M把锁(哲学家就餐问题) 在这里插入图片描述
产生死锁的四个必要条件----
  • 互斥使用。获取锁的过程是互斥的,一个线程拿到这把锁,另一个线程也想获取,就需要阻塞等待。
  • 不可抢占。一个线程拿到锁之后,只能主动解锁,不能让其他线程强行抢走锁。
  • 请求保持。一个线程拿到锁A之后,在持有A的前提下,尝试获取B。
  • 循环等待/环路等待。

想要解决死锁问题,核心思想是破坏上述的必要条件,只要破坏一个就好,前面2个条件是锁的基本特性,不好破坏,条件3需要看代码实际需求,也不太好破坏,只有循环等待最容易破坏。
只要指定一定的规则,就可以有效避免循环等待~
对上述哲学家就餐问题,指定加锁顺序,针对五把锁都进行编号,约定每个线程获取锁的时候,一定要先获取编号小的锁,后获取编号大的锁。

在这里插入图片描述

在这里插入图片描述

面试问题:谈谈你对死锁的理解?
前面四大点都是要点,需要逻辑严密,条理清楚的和面试官谈论

五、内存可见性导致的线程安全问题

如果一个线程写,一个线程读,是否会引起线程安全问题呢?我们来看下面这段代码

private static int flag=0;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while(flag==0){
                //循环里不做处理
            }
            System.out.println("让t线程结束");
        });
        Thread t2=new Thread(()->{
            System.out.println("请输入flag的值:");
            Scanner scanner=new Scanner(System.in);
            flag=scanner.nextInt();
        });
        t1.start();
        t2.start();//t2要等待用户输入,无论t1先启动还是t2先启动,等待用户输入的过程中,t1必然都是已经循环很多次了
    }

当我们输入一个非0的值的时候会发现t1并没有真的结束,当下这个情况,也是bug
相当于t2修改了内存,但是t1没有看到这个内存的变化,就称为“内存可见性”问题
在这里插入图片描述

总的来说,一个线程循环,一个线程修改,在多线程的情况下,编译器对代码的优化做出了错误判断,本来期待编译器把读内存的操作优化掉,变成读寄存器中缓存的值,这样的优化,有助于提高我们循环的执行效率,并且编译器发现没有修改flag,编译器做了错误判断,那么在这样的情况下,t2通过用户输入进行修改flag会导致并未在t1中生效,就出现了不能让t1顺利结束的bug。

1)如果我们对循环里面做处理,让代码休眠10ms,会出现什么结果呢?
在这里插入图片描述
我们发现,输入一个非0的值的时候会发现t1线程结束
在这里插入图片描述
为什么会这样呢?

内存可见性高度依赖编译器的优化的具体实现,编译器啥时候触发优化是不确定的(尽量不要出现这种问题)意味着上述代码如果稍微改动可能结果截然不同,就如上述代码,不加sleep,一秒钟循环上百亿次,load操作的整体开销非常大,优化的迫切程度更多;加了sleep,load整体开销没那么大,优化的迫切程度就降低了。

2)给 flag 加上 volatile

private volatile static int flag=0;
// 执⾏效果
// 当⽤⼾输⼊⾮0值时, t1 线程循环能够⽴即结束.

java中提供了volatile就可以使上诉优化被强制关闭,可以确保每次循环条件都会重新从内存中读取数据;就是强制读取内存,开销是大了,效率是低了,数据正确性/逻辑正确性也提高了。
volatile关键字其中一个核心功能就是保证内存可见性,另一个功能,禁止指令重排序。

总结一下:volatile 和 synchronized 有着本质的区别. synchronized 能够保证原⼦性, volatile 保证的是内存可⻅性.


最后,码字不易,如果觉得对你有帮助的话请点个赞吧,关注我,一起学习,一起进步!

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

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

相关文章

STL标准模板库(C++

在C里面有已经写好的标准模板库〈Standard Template Library)&#xff0c;就是我们常说的STL库&#xff0c;实现了集合、映射表、栈、队列等数据结构和排序、查找等算法。我们可以很方便地调用标准库来减少我们的代码量。 size/empty 所有的STL容器都支持这两个方法&#xff0c…

腾讯云GPU云服务器_GPU云计算_异构计算_弹性计算

腾讯云GPU服务器是提供GPU算力的弹性计算服务&#xff0c;腾讯云GPU服务器具有超强的并行计算能力&#xff0c;可用于深度学习训练、科学计算、图形图像处理、视频编解码等场景&#xff0c;腾讯云百科txybk.com整理腾讯云GPU服务器租用价格表、GPU实例优势、GPU解决方案、GPU软…

Python 全栈系列236 rabbit_agent搭建

说明 通过rabbit_agent, 以接口方式实现对队列的标准操作&#xff0c;将pika包在微服务内&#xff0c;而不必在太多地方重复的去写。至少在服务端发布消息时&#xff0c;不必再去考虑这些问题。 在分布式任务的情况下&#xff0c;客户端本身会启动一个持续监听队列的客户端服…

vscode使用Runner插件将.exe文件统一放到一个目录下

找到右下角管理&#xff0c;点击扩展。 找到Code Runner插件&#xff0c;打开扩展设置。 向下翻&#xff0c;找到Executor Map&#xff0c;点击在settings.json中编辑。 在c和c的配置命令栏中增加\\\output\\即可。&#xff08;增加的目录不能自动创建&#xff0c;需要手动创建…

超高并发下Redis热点数据风险破解

1 介绍 作者是互联网一线研发负责人,所在业务也是业内核心流量来源,经常参与 业务预定、积分竞拍、商品秒杀等工作。 近期参与多场新员工的面试工作,经常就 『超高并发场景下热点数据』 可用性保障与候选人进行讨论。 本文聚焦一些关键点技术进行讨论,并总结一些热点场景…

pytorch 实现线性回归 softmax(Pytorch 04)

一 softmax 定义 softmax 是多分类问题&#xff0c;对决策结果不是多少&#xff0c;而是分类&#xff0c;哪一个。 为了估计所有可能类别的条件概率&#xff0c;我们需要一个有 多个输出的模型&#xff0c;每个类别对应一个输出。为了解决线 性模型的分类问题&#xff0c;我们…

Vscode按键占用问题解决

Vscode按键占用 在使用vscode的过程中&#xff0c;官方按键 Ctrl . 按键可以提示修复代码中的问题&#xff0c;但是发现按了没有反应。 解决问题 首先确认vscode中是否设置了这个按键&#xff0c;默认设置了的系统输入法中是否有按键冲突了&#xff0c;打开输入法设置检查 …

STM32 | Systick定时器(第四天源码解析)

STM32 | Systick定时器(第四天)STM32 | STM32F407ZE中断、按键、灯(续第三天)1、参考delay_us代码,完成delay_ms的程序 定时器频率换算单位:1GHZ=1000MHZ=1000 000KHZ = 1000 000 000HZ 定时器定时时间:计数个数/f(频率) 或者 (1/f(频率))*计数的个数 500/1MHZ = 500/1…

力扣3. 无重复字符的最长子串

Problem: 3. 无重复字符的最长子串 文章目录 题目描述思路及解法复杂度Code 题目描述 思路及解法 1.川建一个set集合存储最长的无重复的字符&#xff1b; 2.创建双指针p、q&#xff0c;每次当q指针指向的字符不在set集合中时将其添加到set集合中让q指针后移&#xff0c;并且更新…

IDEA, Pycharm, Goland控制台乱码

IDEA, Pycharm, Goland控制台乱码 问题描述: 控制台出现&#xfffd;&#xfffd;&#xfffd;&#xfffd;等乱码 复现频率: 总是 解决方案: 以IDEA为例 添加 -Dfile.encodingUTF-8位置 idea64.exe.vmoptions 在安装idea的bin目录idea.vmoptions idea客户端 示意图

SpringBoot3+Vue3项目的阿里云部署--将后端以及前端项目打包

一、后端&#xff1a;在服务器上制作成镜像 1.准备Dockerfile文件 # 基础镜像 FROM openjdk:17-jdk-alpine # 作者 MAINTAINER lixuan # 工作目录 WORKDIR /usr/local/lixuan # 同步docker内部的时间 RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ…

为什么 Hashtable 不允许插入 null 键 和 null 值?

1、典型回答 浅层次的来回答这个问题的答案是&#xff0c;JDK 源码不支持 Hashtable 插入 value 值为 null&#xff0c;如以下JDK 源码所示&#xff1a; 也就是JDK 源码规定了&#xff0c;如果你给 Hashtable 插入 value 值为 null 就会抛出空指针异常 并目看上面的JDK 源码可…

SpringAOP+自定义注解实现限制接口访问频率,利用滑动窗口思想Redis的ZSet(附带整个Demo)

目录 1.创建切面 2.创建自定义注解 3.自定义异常类 4.全局异常捕获 5.Controller层 demo的地址&#xff0c;自行获取《《—————————————————————————— Spring Boot整合Aop面向切面编程实现权限校验&#xff0c;SpringAop自定义注解自定义异常全局…

Godot.NET C# 工程化开发(1):通用Nuget 导入+ 模板文件导出,包含随机数生成,日志管理,数据库连接等功能

文章目录 前言Github项目地址&#xff0c;包含模板文件后期思考补充项目设置编写失误环境visual studio 配置详细的配置看我这篇文章 Nuget 推荐NewtonSoft 成功Bogus 成功Github文档地址随机生成构造器生成构造器接口(推荐) 文件夹设置Nlog 成功&#xff01;Nlog.configNlogHe…

AIPaperPass功能介绍

点击下方▼▼▼▼链接直达AIPaperPass &#xff01; AIPaperPass - AI论文写作指导平台 目录 1.AIPaperPass 插入代码功能上线&#xff01; 体验方式 2.AIPaperPass介绍 1.高质量 2.免费大纲 3.参考文献 4.致谢模板 3.书籍介绍 AIPaperPass智能论文写作平台 1.AIPap…

Mac电脑高清媒体播放器:Movist Pro for mac下载

Movist Pro for mac是一款专为Mac操作系统设计的高清媒体播放器&#xff0c;支持多种常见的媒体格式&#xff0c;包括MKV、AVI、MP4等&#xff0c;能够流畅播放高清视频和音频文件。Movist Pro具有强大的解码能力和优化的渲染引擎&#xff0c;让您享受到更清晰、更流畅的观影体…

吴恩达2022机器学习专项课程(一) 3.6 可视化样例

问题预览 1.本节课主要讲的是什么&#xff1f; 2.不同的w和b&#xff0c;如何影响线性回归和等高线图&#xff1f; 3.一般用哪种方式&#xff0c;可以找到最佳的w和b&#xff1f; 解读 1.课程内容 设置不同的w和b&#xff0c;观察模型拟合数据&#xff0c;成本函数J的等高线…

Dynamo与Revit API之间的转换

今天来聊聊 Dynamo 与 Revit 之间图元转换的基础知识&#xff0c;这些需要你牢牢记住哦&#xff0c;不然在 Python script 中写代码&#xff0c;经常会报错的~ 通常来讲&#xff0c;所有来自 Dynamo 节点的几何图形都不是 Revit 的几何对象&#xff0c;所以它们需要与 Revit AP…

c#绘制图形

窗体工具控件 如果选纹理 ,需要在ImageList中选择图像(点击添加选择图片路径) using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Drawing.Drawing2D; using System.Linq; using System.…

【JavaEE初阶系列】——阻塞队列

目录 &#x1f6a9;阻塞队列的定义 &#x1f6a9;生产者消费者模型 &#x1f388;解耦性 &#x1f388;削峰填谷 &#x1f6a9;阻塞队列的实现 &#x1f4dd;基础的环形队列 &#x1f4dd;阻塞队列的形成 &#x1f4dd; 内存可见性 &#x1f4dd;阻塞队列代码 &#…