系列文章目录

初步了解多线程-CSDN博客


目录

系列文章目录

前言

一、线程安全

1. 线程安全问题

2. 问题原因分析

3. 问题解决办法

4. synchronized 的优势

1. 自动解锁

2. 是可重入锁

二、死锁

1. 一个线程一把锁

2. 两个线程两把锁

3. N 个线程 M 把锁

4. 死锁的必要条件

5. 死锁的解决思路

三、Java 标准库中的线程安全类

四、内存可见性引起的线程安全问题

1. 线程安全问题及原因

2. 解决方法


前言

本文摘要: 文章系统讲解了Java多线程中的线程安全问题及解决方案。主要内容包括:1)线程安全问题的产生原因,如多线程修改共享变量、操作非原子性等;2)使用synchronized关键字的加锁机制解决线程安全问题,分析其优势(自动解锁、可重入锁);3)死锁问题及其四种必要条件,提出通过破坏循环等待条件来避免死锁;4)Java标准库中线程安全与不安全类的对比;5)内存可见性问题及volatile关键字的解决方法。文章通过代码示例详细阐述了线程安全相关概念及实践方案。


一、线程安全

1. 线程安全问题

以下面代码为例:

public class ThreadDemo14 {private static int count = 0;public static void main(String[] args) {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();try {t1.join();t2.join();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("count = " + count);}
}

上述代码的运行结果并不是 10w,而是一个小于 10w 的数字;

    自增操作在 CPU 上分为 3 步:

    • load:将内存中的数字加载到寄存器;
    • add():寄存器中的数值实现自增;
    • save():将寄存器中的值保存到内存中;

    假设 t1 线程在 t2 线程 save 之前,就执行了 load 操作,那么 t1 线程在 save 时,就会覆盖掉 t2 线程之前 save 的结果,导致 t2 之前的自增失效;同理 t2 也会覆盖掉 t1 save 的结果;两个线程出现互相覆盖的情况,就会让最终结果小于 10w;

    2. 问题原因分析

    出现线程安全问题的原因有以下几点:

    1. 线程的调度是随机的,这是问题的根本原因;

    2. 多个线程同时修改同一个变量;

    3. 自增操作本质上是三个 CPU 指令构成的,指令穿插容易发生结果覆盖;

    3. 问题解决办法

    针对原因 1,线程的调度是随机的,这是操作系统内部实现的,不能进行干预;

    针对原因 2,需要根据实际情况分析,但是不一定都能避免;

    针对原因 3,可以通过加锁的方式,将这几个 CPU 指令打包成一个整体;

    虽然在随机调度的过程中,仍然有可能执行一部分指令后将线程调度下 CPU,但是加锁之后,其它线程就会处于阻塞状态,即使线程被调度走,其它线程也不能进行插队,直到这个线程释放锁之后,其余线程才能尝试获取锁;、

    注意:

    加锁需要针对某个具体的锁对象进行加锁,加锁操作是需要基于锁对象的;

    在 Java 中,任何一个对象都可以作为锁对象;

    多个线程必须针对同一个锁对象加锁,才能产生锁竞争/锁冲突,才能解决线程安全问题;

    如果针对的是不同的锁对象加锁,不会产生锁竞争/锁冲突,线程安全问题仍然存在;

    Java 中加锁推荐使用 synchronized 关键字实现;

    如下:

    public class ThreadDemo15 {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();t1.join();t2.join();System.out.println("count = " + count);}
    }

    也可以在方法中,针对 this 进行加锁:

    public class ThreadDemo16 {private static int count = 0;public static void main(String[] args) throws InterruptedException {ThreadDemo16 t = new ThreadDemo16();Thread t1 = new Thread(() -> {for(int i = 0; i < 50000; i++){t.add();}});Thread t2 = new Thread(() -> {for(int i = 0; i < 50000; i++){t.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}public void add(){synchronized(this){count++;}}
    }

    针对 this 进行加锁,就等同于针对方法加锁:

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

    注意:针对 this 加锁时,要判断不同线程中 this 表示的对象是否为同一个对象,同一个对象才能产生锁竞争/锁冲突,不同的对象不会产生;

    也可以针对类对象进行加锁:

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

    可以在静态方法进行加锁:

    public class ThreadDemo17 {private static int count = 0;public static void main(String[] args) throws InterruptedException {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for(int i = 0; i < 50000; i++){add();}});Thread t2 = new Thread(() -> {for(int i = 0; i < 50000; i++){add();}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}synchronized public static void add(){count++;}
    }

    针对静态方法进行加锁,就相当于给类对象加锁:

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

    4. synchronized 的优势

    1. 自动解锁

    synchronized 加锁,可以不考虑释放锁的问题,方法体中的代码执行完毕后,自动解锁;

    如果是通过 lock() 加锁,unlock() 解锁,类似这种方式,就需要代码中考虑解锁的时机;

    如果程序中间 break 了,或者抛出异常了,都需要把解锁考虑好,出现异常之前要把锁解了;

    2. 是可重入锁

    public class ThreadDemo18 {public static void main(String[] args) {Thread t = new Thread(() -> {Object locker = new Object();synchronized (locker){synchronized (locker){System.out.println("hello thread");}}});t.start();}
    }

    上述代码,仍然可以打印 “hello thread”,原因是 synchronized 是可重入锁;

    t 线程已经获取了锁 locker,第二次再获取锁 locker 仍然可以获取到,而不会出现阻塞等待的问题,这样的热性就称为“可重入”;

    实现可重入锁的原理:

    实现可重入锁,需要在锁对象中加两个字段,一个记录持有锁的线程,另一个记录加锁的次数;

    第一次加锁时,记录持有锁的线程,并将将加锁的次数置为 1;

    后续再次或者多次加锁时,检测持有锁的线程是否为原来的线程,如果不是,尝试获取锁的线程就要阻塞等待;如果是原来的线程,就将计数器加 1;

    释放锁时,要注意如果计数器不为 1,就将计数器减 1,并且不真的释放锁;

    当计数器为 1,表示已经是最后一层锁,将计数器减 1,并释放锁,此时锁才真正被释放;

    二、死锁

    1. 一个线程一把锁

    如果锁不是可重入锁,同一个线程先后对同一个对象两次加锁,就会产生死锁问题;

    2. 两个线程两把锁

    如果线程 t1 持有锁 A,线程 t2 持有锁 B,t1 线程尝试获取锁 B,同时 t2 线程尝试获取锁 A,此时两个线程都会进入阻塞等待,都在等待对方释放锁,就会出现死锁问题;

    public class ThreadDemo19 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(() -> {synchronized(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){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker1){System.out.println("线程 t2 获取到 locker1");}}});t1.start();t2.start();}
    }

    3. N 个线程 M 把锁

    假设有 5 个线程 t1, t2, t3, t4, t5,以及 5 把锁 locker1, locker2, locker3, locker4, locker5;

    按照 t1, locker1, t2, locker2, t3, locker3, ..., t5, locker5 呈环形排列;

    每个线程都必须获取到相邻的两把锁之后才能完成工作;

    正常情况下,只要有一个线程先获取到相邻的两把锁,就能完成工作,之后释放锁,其余线程也都能完成工作;

    特殊情况下,如果 t1, t2, t3, t4, t5 分别同时获取到了 locker5, locker1, loecker2, locker3, locker4,那么每个线程都无法完成工作,就会出现死锁的问题;

    4. 死锁的必要条件

    死锁有 4 个必要条件:

    1. 获取锁的过程是互斥的,同一把锁只能被一个线程获取,其它线程想要尝试获取锁,会进入阻塞等待;

    2. 锁无法抢占,一个线程拿到锁之后,必须要主动释放锁之后,其它线程才能获取;

    3. 请求保持,线程拿到锁 A 之后,在持有锁 A 的前提下,再尝试获取锁 B;

    4. 循环等待/环路等待,多个线程获取到不同的锁后,还需要再获取其余线程持有的锁,才能完成工作,多个线程获取锁的逻辑上形成一个环路;

    5. 死锁的解决思路

    死锁的解决思路要从死锁的必要条件入手,只要可以破坏死锁的必要条件,就能避免死锁;

    条件 1 和条件 2 都是锁的基本特性,是不能破坏的;

    条件 3 有时候可以在代码层面避免,有时候必须要同时持有多把锁才能完成工作,是否可以破坏取决于具体的业务逻辑;

    条件 4 是最容易破坏的,只要给获取锁的顺序制定规则,就能有效避免循环等待;比如,每个线程都要优先获取编号小的锁,那么 t1 就不会先获取 locker5,而是会和 t2 竞争 locker1,不管是谁先获取到了 locker1,另外一个线程都会进入阻塞等待,而不会去获取其它的锁,这样就避免了环路,也就解决了死锁问题;

    三、Java 标准库中的线程安全类

    线程不安全的类:

    • ArrayList
    • LinkedList
    • HashMap
    • TreeMap
    • HashSet
    • TreeSet
    • StringBuilder

    当有多个线程,同时修改上述对象,就容易出现线程安全问题;

    线程安全的类:

    • Vecter(不推荐使用)
    • HashTable(不推荐使用)
    • ConcurrentHashMap
    • StringBuffer
    • String

    这几个类都自带了锁,当多个线程同时修改,出现线程安全问题的可能性较小;

    这里需要注意 String,String 没有带锁,但是仍然是安全的;因为 String 中的字符数组或者 byte 数组是被 private 修饰的,是无法被获取,无法被修改;

    四、内存可见性引起的线程安全问题

    1. 线程安全问题及原因

    import java.util.Scanner;public class ThreadDemo20 {private static int isQuit = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while(isQuit == 0){}System.out.println("hello t1");});Thread t2 = new Thread(() -> {Scanner in = new Scanner(System.in);System.out.println("请输入 isQuit 的值:");isQuit = in.nextInt();});t1.start();t2.start();}
    }

    上述代码,即使输入了一个不为 0  的数,仍然不会打印 “hello t1”;

    原因:

    当用户输入完毕后,t1 线程中循环已经循环很多次了;

    在 CPU 中,判断 isQuit 是否为 0 分为两个步骤,一是需要将内存中 isQuit 的值,加载到 CPU 寄存器中,另外一个是判断寄存器中的值是否为 0;

    判断寄存器中的值是否为 0 是非常快的,但是将内存中 isQuit 的值加载到 CPU 寄存器中是很慢的;

    编译器经过多次循环,认为 isQuit 的值不会发生改变,并且将内存中的值加载到寄存器中开销很大,因此编译器会进行优化,经过多次循环后,不再读内存中的值,而是使用寄存器中的值进行比较;

    因此即使 t2 改变了 isQuit 的值,t1 也不会读取,因此会死循环;

    上述问题就称为内存可见性问题,因为内存不可见,导致发生线程安全问题;

    2. 解决方法

    解决问题的思路是使 CPU 持续加载 isQuit 的内存,确保 isQuit 的值发生改变时,可以即时读到;

    保持内存可见需要用到关键字 volatile,使用 volatile 关键字修饰 isQuit 即可;

    private volatile static int isQuit = 0;

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

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

    相关文章

    2025年8月无人驾驶技术现有技术报告

    第1章 引言 无人驾驶技术作为21世纪交通运输领域最具革命性的技术创新之一&#xff0c;正在深刻地改变着人类的出行方式和生活模式。进入2025年&#xff0c;随着人工智能、5G通信、高精度传感器等关键技术的快速发展与成熟&#xff0c;无人驾驶技术已从实验室的概念验证阶段逐…

    CETOL 6σ 助力康美医疗(CONMED Corporation)显著提升一次性穿刺器产品合格率

    概述 康美医疗 (CONMED Corporation)将 Sigmetrix 的 CETOL 6σ 公差分析软件应用于一次性穿刺器的结构优化。该装置是微创外科技术的一次早期突破。在设计阶段&#xff0c;团队发现“测量临界间隙”存在尺寸偏差、超出预期范围&#xff0c;可能在手术中造成患者皮肤损伤&…

    LaunchScreen是啥?AppDelegate是啥?SceneDelegate是啥?ContentView又是啥?Main.storyboard是啥?

    虽然我很想挑战一下swiftui,但是精力真的是有限&#xff0c;把精力分散开不是一个很好的选择&#xff0c;so swiftui浅尝则止了&#xff0c;目前的精力在html上面。 AppDelegate todo SceneDelegate todo ContentView 最明显的就是这个&#xff0c;当编辑的时候&#xff0c;页面…

    垃圾回收机制(GC)

    目录 垃圾回收机制 引用计数法 可达性分析算法 垃圾回收算法 标记清除算法 复制算法 标记压缩算法 JVM中一次完整的GC&#xff08;分代收集算法&#xff09; 在新生代中 在老年代中 空间分配担保原则 对象从新生代进入老年代的几种情况‌ Young GC 和 Full GC 垃…

    DNS域名系统

    DNS域名系统一、什么是DNS?二、DNS的域名层级1. 根域2. 顶级域3. 二级域4. 三级域&#xff08;子域&#xff09;5. 主机名三、DNS服务器的分类四、DNS的解析过程五、DNS的记录类型六、FQDN&#xff08;完全限定域名&#xff09;一、什么是DNS? DNS&#xff08;Domain Name S…

    虚拟内存和虚拟页面

    虚拟内存虚拟内存是现代操作系统提供的一种内存管理机制&#xff0c;它允许程序访问比实际物理内存更大的地址空间。虚拟内存通过将程序的地址空间划分为多个固定大小的块&#xff08;称为页面&#xff09;&#xff0c;并将这些页面映射到物理内存或磁盘上的页面文件中&#xf…

    【2025年电赛E题】基于k230的矩形框识别锁定1

    文章目录 概要 整体架构流程 技术名词解释 技术细节 1. 多阈值适配与目标识别逻辑 2. 动态ROI与状态管理机制 3. 数据平滑与偏差计算 4. 硬件适配与UART通信 小结 静态矩形框识别 动态矩形框追踪 概要 本文分析的代码是基于立创庐山派K230CanMV开发板的目标追踪系统实现,主要…

    c语言中的数组可以用int a[3]来创建。写一次int就可以了,而java中要声明两次int类型像这样:int[] arr = new int[3];

    C 语言数组只需写一次int&#xff0c;而 Java 需两次int相关声明&#xff0c;核心原因是两种语言的数组本质定义、类型系统设计和内存管理逻辑完全不同&#xff0c;具体可拆解为两点核心差异&#xff1a;一、C 语言&#xff1a;数组是 “内存块的类型绑定”&#xff0c;一次声明…

    深度学习——详细教学:神经元、神经网络、感知机、激活函数、损失函数、优化算法(梯度下降)

    神经网络实战&#xff1a; 深度学习——神经网络简单实践&#xff08;在乳腺癌数据集上的小型二分类示例&#xff09;-CSDN博客https://blog.csdn.net/2302_78022640/article/details/150779819?spm1001.2014.3001.5502 深度学习——神经网络&#xff08;PyTorch 实现 MNIST…

    Ubuntu 软件安装的五种方法

    1、App Store 安装 Ubuntu 里面有 一个App叫 “Ubuntu软件” 2、Sudo apt-get install 安装法 注意 使用apt工具安装软件&#xff0c;需要sudo&#xff0c;也就是root权限 例子 apt -get install git 会提示查看是否以root用户运行&#xff0c;install-安装sudo a…

    Day15 (前端:JavaScript基础阶段)

    接续上文&#xff1a;Day14——JavaScript 核心知识全解析&#xff1a;变量、类型与操作符深度探秘-CSDN博客 点关注不迷路哟。你的点赞、收藏&#xff0c;一键三连&#xff0c;是我持续更新的动力哟&#xff01;&#xff01;&#xff01; 主页:一位搞嵌入式的 genius-CSDN博…

    在线旅游及旅行管理系统项目SQL注入

    1.前言 之前在网上随便逛逛的时候&#xff0c;发现一个有各种各样的PHP项目的管理系统&#xff0c;随便点进一个查看&#xff0c;发现还把mysql版本都写出来了&#xff0c;而且还是PHP语言。 https://itsourcecode.com/free-projects/php-project/online-tours-and-travels-m…

    Java网络编程(UDP, TCP, HTTP)

    1. OSI 七层网络模型层级名称核心功能协议示例数据单元7应用层提供用户接口和网络服务HTTP, FTP, SMTP, DNS报文6表示层数据格式转换、加密/解密、压缩/解压SSL, JPEG, MPEG数据流5会话层建立、管理和终止会话连接NetBIOS, RPC会话数据4传输层端到端可靠传输、流量控制、差错校…

    【P2P】P2P主要技术及RELAY服务1:python实现

    P2P 技术 P2P(点对点)网络的核心是去中心化的网络拓扑和通信协议。DP的应用相对较少,但可能出现在: 路由优化:在一些复杂的P2P网络中,一个节点需要向另一个节点发送消息。为了找到一条延迟最低或跳数最少的路径,可能会用到类似最短路径的算法,而这类算法(如Bellman-F…

    docker 安装 redis 并设置 volumes 并修改 修改密码(一)

    在 Docker 中安装 Redis 并设置volumes持久化数据,同时修改 Redis 密码的完整步骤如下: 安装 Docker 如果还没有安装 Docker,可以参考以下步骤安装: 在 Alibaba Cloud Linux 上安装 Docker # 更新系统 sudo yum update -y# 安装 Docker 依赖 sudo yum install -y yum-util…

    如何找出所有连接到本机指定端口的客户端 IP

    在日常运维或排查网络问题时&#xff0c;我们常常需要知道&#xff1a;有哪些客户端正在连接我的服务&#xff1f;连接数是否异常&#xff1f;是否存在恶意扫描或 DDoS 行为&#xff1f;本文将教你使用一条简洁高效的 Linux 命令组合&#xff0c;统计连接到本机某个端口&#x…

    java IDE安装idea社区版步骤

    IntelliJ IDEA 社区版&#xff08;Community Edition&#xff09;是一款功能强大且完全免费的集成开发环境&#xff0c;非常适合 Java 和 Kotlin 初学者或进行基础开发2。我会为你提供详细的安装步骤。 &#x1f6e0; IntelliJ IDEA 社区版安装指南 &#x1f4cb; 系统要求与…

    Agent智能体

    什么是 Agent&#xff1f; Agent 是一个智能体&#xff0c;可以接收用户请求&#xff0c;利用大模型&#xff08;LLM&#xff09;的推理能力&#xff0c;自动决定&#xff1a; 自己回答还是调用外部工具&#xff08;数据库、API、脚本等&#xff09; 最终把结果返回给用户。 能…

    【VSCode】使用VSCode打开md文件以及转化为PDF

    【VSCode】使用VSCode打开md文件以及转化为PDF在 Visual Studio Code (VS Code) 中渲染 Markdown 并保存为 PDF&#xff0c;可以通过以下步骤实现。 首先安装好 VSCode&#xff0c;可以参考下述链接 https://blog.csdn.net/weixin_43848614/article/details/148042035 安装m…

    苹果ImageIO零日漏洞分析:攻击背景与iOS零点击漏洞历史对比

    苹果公司已紧急发布全生态系统安全更新&#xff0c;修复编号为CVE-2025-43300的ImageIO框架高危零日漏洞&#xff08;zero-day&#xff09;&#xff0c;该漏洞已被用于复杂的定向攻击。这是苹果在2025年修复的第七个零日漏洞&#xff0c;凸显iOS和macOS设备面临的威胁持续升级。…