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

线程安全及死锁问题

系列文章目录

初步了解多线程-CSDN博客


目录

系列文章目录

前言

一、线程安全

1. 线程安全问题

2. 问题原因分析

3. 问题解决办法

4. synchronized 的优势

1. 自动解锁

2. 是可重入锁

二、死锁

1. 一个线程一把锁

2. 两个线程两把锁

3. N 个线程 M 把锁

4. 死锁的必要条件

5. 死锁的解决思路

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

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

1. 线程安全问题及原因

2. 解决方法

3. 官方述语


前言

本文摘要: 文章系统讲解了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. 线程安全问题及原因

    场景:使用一个线程读,一个线程写;

    读线程的涉及到 CPU 读内存,因为读内存的开销比较大,编译器可能将读内存优化成读寄存器,可能导致写线程即使对内存中的值做出修改,读线程也感知不到,如下:

    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;

    3. 官方述语

    在官方文档中通常把内存叫做主存(Main Memory),寄存器或者 CPU 的三级缓存称为工作内存(Working Memory);

    上述的 CPU 寄存器,也有可能是 CPU 将内存中的数据加载到 CPU 的三级缓存当中;


    http://www.xdnf.cn/news/1395289.html

    相关文章:

  • 【好题推荐】运算符的构造运用
  • 光伏发多少电才够用?匹配家庭用电需求
  • #医疗AI时代的生物医学Go编程:高性能计算与精准医疗的案例分析(五)
  • Linux内核进程管理子系统有什么第三十八回 —— 进程主结构详解(34)
  • JUC并发编程09 - 内存(01) - JMM/cache
  • 嵌入式Linux设备树驱动开发 - dtsof驱动
  • Unity DateTime 相关
  • 处理器(CPU/MPU)的双发射是什么?
  • 命令扩展与重定向
  • 可解释人工智能XAI
  • 【机器学习深度学习】Embedding 与 RAG:让 AI 更“聪明”的秘密
  • leetcode 191 位1的个数
  • 【0422】SMgrRelationData 中 md_num_open_segs 和 md_seg_fds 数组为什么是 4 个元素? 第四个元素表示什么?
  • Ubuntu磁盘分区重新挂载读写指南
  • 不一样的发票管理模式-发票识别+发票查验接口
  • ContextMenuManager for Win:优化右键菜单,解决用户痛点
  • lxml库如何使用
  • ElasticSearch对比Solr
  • C语言————操作符详解
  • TypeScript的Type
  • MySQL 中如果发生死锁应该如何解决?
  • 每日算法题【二叉树】:对称二叉树、二叉树的前中后序遍历
  • 回车换行、缓冲区刷新、倒计时小程序
  • MQTT高延迟通信优化指南
  • Python的Listd 数据格式 V0.1
  • 深入解析Nginx核心模块
  • DAY 17 常见聚类算法-2025.8.29
  • 将数据赋值到多个文档里,并将多个word放入压缩包并下载
  • 非标设计 机架模板 misumi 设计组合案例
  • 小康AI家庭医生,亮相2025WteamAI创客节!