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

文章目录

  • 一、概念
  • 二、线程不安全的原因
  • 三、解决线程不安全问题--加锁(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();// 预期结果应该是 10wSystem.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, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 cout1.join();t2.join();// 预期结果应该是 10wSystem.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 也能拿到 Btry {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}// 尝试获取 B, 并没有释放 Asynchronized (B) {System.out.println("t1 拿到了两把锁!");}}});Thread t2 = new Thread(() -> {synchronized (B) {// sleep 一下, 是给 t1 时间, 让 t1 能拿到 Atry {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}// 尝试获取 A, 并没有释放 Bsynchronized (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.mzph.cn/news/768991.shtml

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

相关文章

立体统计图表绘制方法(凸显式环图)

立体统计图表绘制方法&#xff08;凸显式环图&#xff09; 记得我学统计学的时候&#xff0c;那些统计图表大都是平面的框框图&#xff0c;很呆板&#xff0c;就只是表现出统计的意义就好了。在网络科技发展进步的当下&#xff0c;原来一些传统的统计图表都有了进一步的创新。在…

RDGCN翻译

RDGCN翻译 Relation-Aware Entity Alignment for Heterogeneous Knowledge Graphs 面向异质知识图谱的关系感知实体对齐 阅读时间&#xff1a;2024.03.24 领域&#xff1a;知识图谱&#xff0c;知识对齐 作者&#xff1a;Yuting Wu等人 PKU 出处&#xff1a;IJCAI Abstract…

HarmonyOS NEXT应用开发之听歌识曲水波纹特效案例

介绍 在很多应用中&#xff0c;会出现点击按钮出现水波纹的特效。 效果图预览 使用说明 进入页面&#xff0c;点击按钮&#xff0c;触发水波纹动画。再次点击按钮&#xff0c;停止水波纹动画。 实现思路 本例涉及的关键特性和实现方案如下&#xff1a; 要实现存在两个连续…

C++ - 类和对象(上)

目录 一、类的定义 二、访问限定符 public&#xff08;公有&#xff09; protected&#xff08;保护&#xff09; private&#xff08;私有&#xff09; 三、类声明和定义分离 四、外部变量和成员变量的区别与注意 五、类的实例化 六、类对象的模型 七、类的this指针…

TCP详解

一、TCP报文段结构 1、源端口号和目的端口号都是16位&#xff0c;范围从&#xff08;1-65535&#xff0c;0不可用&#xff09; 2、序列号&#xff1a;在建立连接时由内核生成的随机数作为其初始值&#xff0c;通过 SYN 报文传给接收端主机&#xff0c;每发送一次数据&#xff0…

C语言数据结构易错知识点(5)(插入排序、选择排序)

插入排序&#xff1a;直接插入排序、希尔排序 选择排序&#xff1a;直接选择排序、堆排序 上述排序都是需要掌握的&#xff0c;但原理不会讲解&#xff0c;网上有很多详尽地解释&#xff0c;本文章主要分享一下代码实现上应当注意的事项 1.直接插入排序&#xff1a; 代码实…

拥抱C++的深度和复杂性,挖掘更多可能 !——《C++20高级编程(第5版)》

&#xff0c;C难以掌握&#xff0c;但其广泛的功能使其成为游戏和商业软件应用程序中最常用的语言。即使是有经验的用户通常也不熟悉许多高级特性&#xff0c;但C20的发布提供了探索该语言全部功能的绝佳机会。《C20高级编程(第5版)》为C的必要内容提供了一个代码密集型、面向解…

(AtCoder Beginner Contest 325) ---- D - Printing Machine -- 题解

目录 D - Printing Machine&#xff1a; 题目大意&#xff1a; 思路解析&#xff1a; 代码实现&#xff1a; D - Printing Machine&#xff1a; 题目大意&#xff1a; 思路解析&#xff1a; 打印一次后&#xff0c;需要充电一微秒后才能再次打印就可以看作每微妙只能打印一…

【文献阅读】AlphaFold touted as next big thing for drug discovery — but is it?

今天来精读2023年10月发在《Nature》上的一篇新闻&#xff1a;AlphaFold touted as next big thing for drug discovery — but is it? (nature.com)https://www.nature.com/articles/d41586-023-02984-w Questions remain about whether the AI tool for predicting protein …

蓝桥杯基础练习详细讲解二(具体代码、解题思路、Python)

试题 基础练习 回文数 提交此题 评测记录 资源限制 内存限制&#xff1a;512.0MB C/C时间限制&#xff1a;1.0s Java时间限制&#xff1a;3.0s Python时间限制&#xff1a;5.0s 问题描述 1221是一个非常特殊的数&#xff0c;它从左边读和从右边读是一样的&#x…

C语言从入门到实战----C语言中内存函数的使用和模拟实现

目录 前言 1.memcpy 使用和模拟实现 2. memmove 使用和模拟实现 3. memset 函数的使用 4. memcmp 函数的使用 前言 在编程领域&#xff0c;内存管理是至关重要的一环&#xff0c;它确保了程序能够高效、稳定地运行。 C语言作为一门底层的编程语言&#xff0c;提供了一系…

Redis 教程系列之Redis 集群配置(十三)

1.Redis集群方案比较 主从模式 在软件的架构中,主从模式(Master-Slave)是使用较多的一种架构。主(Master)和从(Slave)分别部署在不同的服务器上,当主节点服务器写入数据时,同时也会将数据同步至从节点服务器,通常情况下,主节点负责写入数据,而从节点负责读取数据。…

橘子疾病检测4种YOLOV8

橘子检测YOLOV8&#xff0c;检测4种疾病&#xff0c;采用YOLOV8-NANO&#xff0c;训练得到PT模型转换成ONNX&#xff0c;最后OPENCV调用&#xff0c;支持C/PYTHON/ANDROID 橘子检测YOLOV8&#xff0c;检测4种疾病

2025汤家凤考研数学视频,基础网课百度网盘课程+PDF讲义资料

2025汤家凤大神及数学全程 docs.qq.com/doc/DTmtOa0Fzc0V3WElI 复制粘贴到浏览器&#xff0c;可以见所有的Ke 第一轮 夯实基础 1.阅读大纲考查要求&#xff0c;明确每章的学习目标&#xff1b; 2.按节学习数学理论基础知识&#xff0c;吃透书中例题&#xff1b; 3.学习每章…

【C语言】数组(一维、二维数组的简单介绍)

数组&#xff08;Array&#xff09; 数组概念 数组是一组相同数据类型元素的集合&#xff0c;属于一种简单的数据结构&#xff0c;从中可以得到三个有效信息 数组元素是同一数据类型的变量数组存放一个或者多个数据&#xff0c;但是数组元素个数不能为0数组中各元素可独立作为…

【Web APIs】DOM节点

目录 1.节点操作 1.1DOM节点 1.2查找节点 1.2.1父节点查找 1.2.2子节点查找 1.2.3兄弟节点查找 1.3增加节点 1.4克隆节点 1.5删除节点 2.时间对象 2.1实例化 2.2时间对象方法 2.3时间戳 3.重绘和回流 1.节点操作 1.1DOM节点 DOM节点&#xff1a;DOM树中的每一个…

CHAT~(持续更新)

CHAT&#xff08;持续更新&#xff09; 实现一个ChatGPT创建API设计页面布局业务操作技术架构 编码其他 实现一个ChatGPT 创建API 最简单也最需要信息的一步 继续往下做的前提 此处省略&#xff0c;想要获取接口创建方式联系 设计 页面布局 按照官网布局 业务操作 注册登…

绝地求生:PUBG七周年庆典开启!参与周年话题投稿赢丰厚奖励

为庆祝七周年&#xff0c;闲游盒PUBG官方准备了众多活动与奖励&#xff0c;一起在庆典中创造难忘的回忆吧&#xff01;七周年庆典期间游玩PUBG&#xff0c;参与 #乐在7中鸡味无穷# 周年话题投稿&#xff0c;即有机会赢取魔力甜心萨莉套装 2奖励。 参与方式 在小黑盒PUBG社区中…

贪心算法相关题目

文章目录 1. 什么是贪心&#xff1f;2. 分发饼干3. 摆动序列4. 最大子数组和5. 买卖股票的最佳时机 II6. 跳跃游戏7. 跳跃游戏 II8.K 次取反后最大化的数组和9.加油站10.分发糖果11.柠檬水找零 1. 什么是贪心&#xff1f; 贪心的本质是选择每一阶段的局部最优&#xff0c;从而…

第1篇:Mysql数据库表结构导出字段到Excel(一个sheet中)

package com.xx.util;import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook;import java.sql.*; import java.io.*;public class DatabaseToExcel {public static void main(String[] args) throws Exception {// 数据库连接配置String u…