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

【多线程】线程安全问题

线程安全问题是多线程编程中非常重要,它涉及到可执行文件在多线程环境下对共享资源的正确访问和操作

一. 线程安全的概念

线程安全是指在多线程环境下,程序能够正确地处理共享资源,确保数据的完整性和一致性

意味着:

一个代码,不管在单线程下执行,还是多线程下执行,都不会产生bug,那么就是线程安全

一个代码,在单线程下运行正确,但是多线程下运行产生bug,那么就是“线程不安全”

典型的线程不安全示例 —— 多线程同时修改共享变量导致结果错误 

public class Demo_1 {
    public static int count = 0;

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

        Thread thread1 =new Thread(()->{
            for (int i = 0; i < 5_0000; i++) {
                count++;
            }
        });

        thread.start();
        thread1.start();

        thread.join();
        thread1.join();

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

按道理说,应该输出10w ,但是结果不是,没有达到预期的结果,则说明该线程存在线程安全问题

 为什么没有输出预期结果?

cpu在运行可执行文件的时候,本质上是在执行指令,count++这个操作由3个代码组成

  1. 从内存中读取数据到cpu的寄存器中
  2. 将cpu寄存器中的数值+1
  3. 将寄存器中被修改后的值写回内存中

如果是一个线程,执行三条指令(执行顺序不发生改变),那么结果肯定是正确的

如果是两个线程(并发执行),可能会出现在原来线程1三条有序的指令中间插入线程2的指令(如图)会导致执行的结果不可预测

出现这种情况的根本原因:线程的随机调度和抢占式执行

注意:这里的并发执行,是并发+并行,具体那种,看cpu调度 

二. 线程不安全的原因

(1)线程间的执行方式

  • 抢占式执行和随机调度的机制导致了线程之间的执行顺序不可预测

(2)多个线程修改同一个变量

  • 一个线程修改同一个变量,不会造成线程不安全
  • 如果只是读取变量内容,不会造成线程不安全(没有修改)
  • 如果两个不同的变量,也不会造成线程不安全(没有相互覆盖)

(3)多线程修改变量的操作,本质上不是原子性

  • 每个cpu指令都是原子性的,要么不执行,要么执行完
  • 如果一个操作是由多个cpu指令组成,线程在执行一半的时候,容易会被调度走,导致其他的指令插入进来

注意:+=,-= 操作也是非原子,= 是原子

(4)内存可见性问题

  • 常发生在一个线程读数据,一个线程写数据,由于代码优化的功能,导致数据被修改,执行读数据操作的线程没有发现

(5)指令重排序问题

  • 如果一个操作是由多个cpu指令组成,在操作中指令的顺序发生了改变,导致出现bug

 知道了造成线程不安全原因,就可以对症下药,我们经常针对原因3做出操作,我们可以使用让多个指令打包在一起,成为“整体”


        Thread thread = new Thread(()->{
            for (int i = 0; i < 5_0000; i++) {
                synchronized (object){
                    count++;
                }
            }
        });

加锁的目的,是为了把三个操作,打包成一个原子的操作。 

三. 线程不安全的解决

1. 锁

(1)锁的作用 

锁用于 控制多个线程对共享资源的访问,确保同一时间只有一个线程能操作临界区资源

(2)锁的特点

锁互斥 / 锁竞争

  • 如果一个线程,针对一个对象上锁后,其他的线程,如果尝试向这个对象上锁,就会发生阻塞
  • 如果这个对象被释放(解开锁),才不会被阻塞
  • 如果是两个不同的对象上锁,那么就不会发生锁竞争
  • 如果一个线程加锁,一个线程不加锁,也不会发生锁竞争

加锁的核心一定是产生锁竞争,没有发生锁互斥,那么加锁没有意义

(3)synchronized关键字

在java中使用synchronized关键字,来表示锁

锁对象:在使用的时候,必须要有一个锁对象,

  1. 在java中,任何一个对象都可以做为一个锁对象
  2. 锁竞争不取决于操作的对象是什么,而是取决于是否在操作同一个对象。
  3. 如果两个线程操作的是同一个对象,那么就会产生竞争。如果不是同一个对象,则不会产生竞争。
                synchronized (object){
                    count++;
                }
  1. 在 synchronized关键字中,进入{ }表示加锁,出了{ }表示解锁
  2. 加锁的目的,是为了把三个操作,打包成一个原子的操作。 并不是加锁之后,执行三个操作过程中,线程就不调度了,只是保证了其他线程无法“插队”
  3. 本质上synchronized关键字是通过调用系统的 API来进行加锁的。

可重入性 

        Thread t = new Thread(()->{
//            可重锁
//            同一个线程内,连续使用两次锁,不会发生死锁
            synchronized (object) {
                synchronized (object) {
                    System.out.println("111");
                }
            }
        });

 在java中,锁具有可重入性,所有这种情况是正确的(在c++/c,python等中出现这种情况会发生死锁)

在可重⼊锁的内部, 包含了两个信息:"线程持有者" 和 "计数器"
如果遇到 “ {  ” 则计数器+1 ,“  }  ”则计数器 - 1 

 

 一个线程进行加锁的时候,发现锁已经被使用了,那么正常情况下就会进入阻塞状态,但是如果被使用的对象是自己,那么就可以继续加锁

 (4)常见的写法

1. 修饰实例方法
    public synchronized void method() {
        // 同步代码块
    }    

 每次只有一个线程可以执行这个方法。

2. 修饰静态方法
    public static synchronized void method() {
        // 同步代码块
    }

因为静态方法属于类而不是实例,所以锁定的是类的对象,影响的是所有实例。

3. 修饰代码块

    static Object o =new Object();

    synchronized(o) {
        // 同步代码块
    }

 最常用的写法

(5)死锁

1. 死锁经典案例
1. 一个线程两把锁

在java中,锁具有可重入性,所有出现反复加锁的情况不会发生死锁

2. 两个线程两把锁

类似于房间钥匙在车里,车钥匙在房间里

public class Demo_4 {
    static Object A = new Object();
    static Object B = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
           synchronized (A){
               //休眠保证thread1线程可以拿到o2锁
               try {
                   Thread.sleep(500);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               System.out.println("thread线程状态(尝试获取B锁):"+Thread.currentThread().getState());
               synchronized (B){
                   System.out.println("拿到了两把锁");
               }
           }
        });

        Thread t2 = new Thread(()->{
            synchronized (B){
                //休眠保证thread线程可以拿到o1锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                System.out.println("thread1线程状态(尝试获取o1锁):"+Thread.currentThread().getState());
                synchronized (A){
                    System.out.println("拿到了两把锁");
                }
            }
        });

        t1.start();
        t2.start();
    }
}
public class Demo_4 {
    static Object A = new Object();
    static Object B = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
           synchronized (A){
               //休眠保证thread1线程可以拿到o2锁
               try {
                   Thread.sleep(500);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               System.out.println("thread线程状态(尝试获取B锁):"+Thread.currentThread().getState());
               synchronized (B){
                   System.out.println("拿到了两把锁");
               }
           }
        });

        Thread t2 = new Thread(()->{
            synchronized (B){
                //休眠保证thread线程可以拿到o1锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                System.out.println("thread1线程状态(尝试获取o1锁):"+Thread.currentThread().getState());
                synchronized (A){
                    System.out.println("拿到了两把锁");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

 

两个进程发生死锁现象,t1拿到了A锁,t2拿到了B锁。t1此时想要在拿到B锁的条件是,t2能够释放B锁;t2想要拿到A锁的条件是,t1能够释放A锁,两个线程互不相让,所以产生了死锁。

 产生了死锁,两个线程就卡住了,导致后面的代码无法正常执行

如何解决这种死锁? 

规定好加锁的顺序就解决这种死锁问题,比如都规定只有先拿到了A锁,才能拿B锁

3. N个线程M把锁

哲学家就餐问题:五个哲学家在一个圆桌上吃饭,桌子上只有5个筷子,怎么安排

其实在大都数情况下,筷子的数量是够的,可以正常完成就餐情况

但是如果在同一时刻,所有的哲学家同时拿起左边的筷子,那么就会发生死锁,不能正常就餐

解决死锁的方法:

  1. 增加一个筷子/去掉一个哲学家
  2. 增加一个计数器,限制最多可以多少人同时就餐
  3. 引入加锁顺序的规则
  4. 银行家算法

可以对这五个筷子(锁)进行编号,规定每个哲学家(线程) ,获取筷子的时候,必须先获得编号小的,才能获得编号大的

2. 死锁产生的必要条件
(1)互斥使用

一个线程获得这个锁,那么另外一个线程想获得这个锁,就只能阻塞等待

(2)不可抢占

一个线程获得这个锁,只能主动解锁,别的线程不能强行把锁抢走

(3)占有且等待

一个线程至少占有一个锁,并且等待获取其他锁

(4)循环等待/环路等待

存在一个线程等待环路,环路中的每个线程都在等待下一个线程所占有的资源。

 解除死锁

上面的四种产生的必要条件,只要破坏其中的一个,那么就会解除死锁

通常解除循环等待这个必要条件,通过指定一定的规则,就可以避免循环等待

2. volatile关键字

如果一个线程进行读操作,一个线程进行写操作,这时候会出现“ 内存可见性 ”引起的线程安全问题

import java.util.Scanner;

//线程不安全问题:内存可见性
public class Demo_5 {
//这里即使修改flg的值也不会影响thread1线程的执行---flg变量的读写存在可见性问题
 volatile static int flg = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (flg == 0) {

            }
            System.out.println("执行完毕");
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("修改flg的值");
            Scanner scanner = new Scanner(System.in);
            flg = scanner.nextInt();
            scanner.close();

        });

        thread2.start();
        thread1.start();
    }
}

 

我们会发现,即使输入一个非0的数,线程1还是没有结束

因为这里JVM对代码进行了优化,比较flg是否等于0,flg中的值是缓存中寄存器中的值

这里的核心步骤:从内存中读取flg的值 和  拿着寄存器中的值和0比较 

从内存中读取flg的值开销比较大,由于内存的执行速度非常快(1秒几亿次),在执行几百次,发现flg的值没有发生任何改变,JVM就会以为flg是一直不变的,那么就会省略从内存中读取的操作,一直在使用之前缓存的值,提高了执行效率(代码优化功能)

所以在多线程中,线程2修改了其中的值,线程1会因为代码优化功能没有看见内存中这个值的变化 

 解决方案

在java中,提供了关键字volatile可以使JVM提供的代码优化功能强制关闭,每次都必须从内存中读取数据

 volatile static int flg = 0;

开销会变大,效率变低,但是数据的准确性提高了 

volatile关键字的核心功能:保证内存的可见性和禁止指令重排序

四. 线程饥饿问题

线程饥饿:在并发环境中,某些线程因资源分配或调度机制不合理,长期无法获取所需资源,无法执行其任务。饥饿的线程仍处于活跃状态(如等待队列中),但资源被其他线程持续抢占。

 举例说明:

模拟情况:假如一群人排队上厕所,这时候出现一个人,他去上厕所(上锁),发现厕所没有纸,于是他从厕所出来了(释放锁),但是他又跑进厕所(上锁)看现在有没有纸,发现没有又跑出来(解锁),就一直这样反复横跳,导致后面的人不能上,工作人员也不能往里面存放纸。

对于线程来说:有一个线程上锁又解锁,解锁又上锁,一直循环,导致后面线程不能使用这个资源

饥饿的线程处于活跃状态(如等待队列中),但资源被其他线程持续抢占。

解决方案 

在java中提供关键字wait( )notify( ),这两个关键字要搭配一起使用 

(1)wait

wait在使用的时候,使用的对象是锁

使用wait方法执行的操作

  • 让当前线程释放
  • 让线程进入WAITING状态或者TIMED_WAITING(是否带有时间参数)
  • 检测是否有其他线程使用notify方法,如果有则被唤醒,重新获取锁

这些操作是原子的,是一气呵成的,不能被其他指令插入

 结束等待条件

  • 其他线程调用该对象的 notify 方法.
  • 如果wait方法带有时间参数,等待时间超时 
  • 使用 interrupted 方法提前唤醒

public class Demo_6 {
    static Object A = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            synchronized (A){
                try {
                    System.out.println("准备进入wait状态,等待notify唤醒");
                    A.wait();
                    System.out.println("被唤醒");
                } catch (InterruptedException e) {
                    System.out.println("被interrupt唤醒");
                }
            }
        });

        Thread t2 = new Thread(()->{
            synchronized (A){
                try {
                    //确保t1进入wait状态
                    Thread.sleep(1000);
                    System.out.println("准备唤醒A锁");
                    A.notify();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });



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


    }
}

 

执行过程:

  1. 在这里t1线程会先执行,拿到锁,打印"准备进入wait状态,等待notify唤醒",执行wait方法(释放锁,进入阻塞状态)
  2. t2线程执行,拿到锁,先执行sleep方法(保证t1先拿到锁),打印"准备唤醒A锁",然后执行notify操作,唤醒t1线程
  3. 这时候t1线程会尝试申请锁,但是由于t2线程还没有释放锁,会发生锁互斥(t1进入阻塞状态)
  4. t2执行完并释放锁,t1会得到锁资源,执行wait后面的内容,打印"被唤醒"

(2)notify

notify在使用的时候,使用的对象是锁notify方法可以把wait阻塞的线程唤醒,这两个方法经常一起搭配使用 

1)notify和wait之间是依靠锁对象联系在一起,如果是两个不同的对象,那么就无法唤醒

//wait和notify不是同一个对象
public class Demo_7 {
    static Object A = new Object();
    static Object B = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                try {
                    System.out.println("准备进入wait状态,等待notify唤醒");
                    A.wait();
                    System.out.println("被唤醒");
                } catch (InterruptedException e) {
                    System.out.println("被interrupt唤醒");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (A) {
                try {
                    //确保t1进入wait状态
                    Thread.sleep(1000);
                    System.out.println("准备唤醒A锁");
                    B.notify();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        t1.start();
        t2.start();
    }
}

 

2)如果有多个wait 但是只有一个notify(前提锁对象都相同),那么就会随机唤醒一个

public class Demo_8 {
    static Object A = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{

            synchronized (A){
                try {
                    A.wait();
                    System.out.println("t1 被唤醒");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }

        });

        Thread t2 = new Thread(()->{
            synchronized (A){
                try {
                    A.wait();
                    System.out.println("t2 被唤醒");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        Thread t3 = new Thread(()->{
            synchronized (A){
                try {
                    A.wait();
                    System.out.println("t3 被唤醒");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        Thread t4 = new Thread(()->{
            synchronized (A){
                try {
                    Thread.sleep(1000);
                    A.notify();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }
        });

        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

这里会随机唤醒一个

也可以使用notifyAll唤醒这个对象的所有等待线程

        Thread t4 = new Thread(()->{
            synchronized (A){
                try {
                    Thread.sleep(1000);
                    A.notifyAll();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }
        });

notify方法在使用的时候,即使没有wait,也不会产生副作用

五. 常见的阻塞状态 

1) 线程进入了 WAITING 状态(死等),必须要被唤醒

  1. 如果是wait( ),需要使用 notify()唤醒
  2. 当线程调用wait( ) 或 join()进入等待,可以调用 interrupt() 触发异常,导致提前唤醒。
  3. join 的自然结束

2) 线程进入了 BLOCKED 状态,必须持有锁的线程释放锁,操作系统来负责唤醒

3) 线程进入了 TIMED_WAITING 状态(sleep,wait,join),由操作系统会计时,时间到了之后进行唤醒

  1. 时间到了操作系统进行唤醒
  2. 如果是wait( ),需要使用 notify()唤醒
  3. 当线程调用wait( ) 或 join() sleep()进入等待,可以使用interrupt()触发异常,导致提前唤醒
  4. join的自然结束 

 六. sleep(),wait() 区别

(1) 所属类不同

  • sleep(): Thread类的静态方法,可以在任何地方使用
  • wait() : Object类的实例方法,必须在锁内使用(由锁对象调用)

(2) 锁的释放行为

  • sleep(): 不释放锁
  • wait() : 释放锁

(3) 唤醒条件 

  • sleep(): 时间到了,被操作系统唤醒,或者被异常唤醒
  • wait() : 其他线程调用同一对象的notify,时间到了,被操作系统唤醒(有时间参数),也可以被异常唤醒(特殊手段)

(4) 线程状态变化

  • sleep(): 线程只能进入TIMED_WAITING状态
  • wait() :线程进入WAITING状态或者TIMED_WAITING状态

 点赞的宝子今晚自动触发「躺赢锦鲤」buff!

相关文章:

  • 【服务器环境安装指南-指定 cuda 版本】在 Ubuntu 22.04 上完成 cuda-toolkit 12.0 和 cudnn 12.x 的安装教程
  • 智慧路灯的发展史
  • springboot中logback日志配置
  • 20402/20404系列电子校准件
  • 基于SpringBoot+Vue的在线考试系统+LW示例
  • 回溯算法:组合I
  • 蓝桥杯 跑步计划
  • 深入剖析C# List<T>的底层实现与性能奥秘
  • QtConcurrent::run并发
  • 如何选择免费中文 Postman 替代工具?
  • 高度电路中时序设计之二
  • CentOS 7部署主域名服务器 DNS
  • 动态规划之完全背包
  • 《TypeScript 面试八股:高频考点与核心知识点详解》
  • 若依框架二次开发——若依集成 JSEncrypt 实现密码加密传输方式
  • 【重装系统】全流程记录,在 MacOS 的电脑上烧录 Ubuntu 启动盘
  • 2025年渗透测试面试题总结-某shopee -红队-Singapore(题目+回答)
  • 练习题:103
  • 【LeetCode 热题100】 4. 寻找两个正序数组的中位数的算法思路及python代码
  • 数据库的视图有什么用?
  • 网站制作步骤是什么/东莞今日头条新闻
  • 聊城做网站哪里好/百度开发平台
  • 网站发布与推广计划/今日腾讯新闻最新消息
  • 做企业网站织梦和wordpress哪个好/站长之家怎么用
  • 企业线上培训平台有哪些/广州seo和网络推广
  • 怎么做同学录的网站/seo工资服务