常见的线程安全问题及解决

news2025/1/20 1:56:21

1. 什么是线程安全

线程安全指的是当多个线程同时访问一个共享的资源时,不会出现不确定的结果。这意味着无论并发线程的调度顺序如何,程序都能够按照设计的预期来运行,而不会产生竞态条件(race condition)或其他并发问题。

请看如下代码:

我们用两个线程分别让count++ 5w次,最后我们打印count,理论得到的结果是10w

public class ThreadDemo13 {
    public 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();
        
        t1.join();
        t2.join();

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

 运行代码:

我们发现结果与我们预期的并不相同。 

其实 count++  是由三个cpu指令来完成的

  • load 从内存中读取数据到cpu寄存器
  • add 把寄存器中的值 +1
  • save 把寄存器中的值写回到内存中

 由于线程之间是并发执行的,每个线程执行到任何一条指令后,都可能从cpu上调度走,而且去执行其他线程,于是当 t1 线程和 t2 线程并发执行时,会存在以下情况。

可以看到 这种情况两次  count++ 实际上只让count+1了,并且实际情况中  t1 的load和add之间又能有更多的指令,那样则会导致 多个count++ 只会令count+1 ,所以导致了结果与预期不符合 

这类问题就称为线程不安全问题。

2. 线程安全问题及解决 

从上面的示例我们可以发现,导致线程不安全的原因是,count++ 这三个指令不是整体执行的,于是我们要解决这个问题就可以想办法使这三个指令为一个整体

2.1 锁 

在Java中我们可以通过加锁的方式来保证线程安全,锁具有 “互斥” “排他” 的特性

在Java中,加锁的方式有很多种,最主要的方式,是通过 synchronized 关键字

语法:

synchronized(锁对象) {
    //要加锁的代码
}

加锁的时候,需要“锁对象”,如果一个线程用一个锁对象加上锁以后,其他线程也尝试用这个锁对象来加锁,就会产生阻塞(BLOCKED),直到前一个对象释放锁

锁对象是一个Object对象

我们给上述ThreadDemo13中 t1, t2 中的count++加上锁:

public class ThreadDemo13 {
    public static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        //随便创建一个对象,作为锁对象,因为所有类默认继承于Object类
        //所以任意一个类的对象都可以作为锁对象
        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);
    }
}

此时我们再次运行代码:

发现结果正确, 这是因为我们给  t1, t2 中的count都加上了同一个锁,运行代码时, t1 中的count++ 没有执行完时,t2中的 count++ 拿不到锁,就不会执行,同理,t2 中的count++ 没有执行完时,t1中的 count++ 也拿不到锁,也就不会执行。所以就保证了线程安全。

这里我们可以这样理解加锁操作:给代码加锁,就是规定这段代码必须拿到对应的锁才能执行,如果另一个线程中也有代码加了这把锁(即相同的锁对象),同样这段代码也必须拿到这个锁才能执行,但是这个锁只有一个,所以同一时间只能执行一段代码,另一段代码只能等上一段代码执行完,把锁释放了,才能拿到锁进而执行

注意:

  • 加了锁的代码,任然可能中途被调度出cpu,只不过调度出cpu后,任然是加锁状态
  • 只有以同一个锁对象加锁的线程间会产生锁竞争 

 synchronized 还有几种写法

1. 下面这个代码是否是线程安全的?

class Test {
    public static int count = 0;
    public void add() {
        synchronized (this) {
            count++;
        }
    }
}
public class ThreadDemo14 {
    public static void main(String[] args) throws InterruptedException {
        Test t = new Test();
        
        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 = " + t.count);
    }
}

注意this指的是当前对象,我们发现 调用add的都是t,所以, t1, t2 中 都是通过 t 来加锁,所以存在锁竞争,这段代码是线程安全的:

2.  锁可以加在方法上面

class Test1 {
    public static int count = 0;
    synchronized public void add() {
            count++;
    }
}
public class ThreadDemo15 {
    public static void main(String[] args) throws InterruptedException {
        Test1 t = new Test1();

        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 = " + t.count);
    }
}

这种写法与上面效果相同,都是通过当前对象加锁

2.2  synchronized 特性

下列代码能否正常打印Ting?

public class ThreadDemo16 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t = new Thread(() -> {
            synchronized (locker) { //1
                synchronized (locker) {  //2
                    System.out.println("Ting");
                }// 3
            }// 4
        });
        t.start();
    }
}

答案是可以的:

 解释:在1位置,第一次使用locker加锁,很明显这里是可以加上的,在2位置,这里也在尝试使用locker进行加锁,按照我们上面的理解,这里的锁是加不上的,但是最后却输出了Ting,这里是因为这两次加锁是同一个线程在进行,这种操作是允许的,这个特性称为可重入,这是Java开发者为了防止出现死锁而设计的,

注意:这种写法在1位置才会加锁,在2 位置时,不会真的加锁,在3位置也不会释放锁,在4位置才会释放锁 ,对于可重入锁,内部会有一个加锁次数的计数器,当加锁时计数器为0 才会加锁,每“加一次锁”计数器+1,而每出一个“}”计数器-1,为0时才释放锁

2.3 死锁的三种典型场景 

1. 一个线程,一把锁

如同上面所讲的,如果锁是不可重入锁,并且一个线程,用这把锁加锁两次就会出现死锁

2. 两个线程 两把锁

线程 1 获取到锁 A
线程 2 获取到锁 B
在这种情况下 ,1尝试获取 B, 2尝试获取 A

示例代码 :
 

public class ThreadDemo17 {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                try {
                    //等 t2 拿到B
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println("t1拿到了两把锁");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (B) {
                try {
                    //等 t1 拿到A
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    System.out.println("t2拿到了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

3. N个线程M把锁

类似于上面的两个线程两把锁的问题,

下面简单画个图演示这种情况:

这种情况下,每个线程都在等待左边的锁被释放,形成一个死锁 

解决:对每把锁都进行编号,规定每个线程都必须先获取编号小的锁,再获取编号大的锁,于是,线程1在获取到锁A前是不会获取锁F的,所以就避免了上述情况。

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

在Java中,多线程共享内存可能会导致内存可见性问题,从而引起线程安全问题。简单来说,内存可见性是指:当一个线程修改了共享变量的值时,这个新值可能不会立即被其他线程所看到

示例代码:

public class ThreadDemo18 {
    public static int flag = 0;
    public static void main(String[] args) {

        int a = 0;
        Thread t1 = new Thread(() -> {
            while(flag == 0) {

            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("把flag置为1");
            flag = 1;
        });

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

当我们运行代码发现:

flag被置为1后 t1线程并没有结束 

 这里我们主要查看这段代码:

这段代码的核心指令只有两条
1. 读取内存中flag的值到cpu寄存器里
2. 拿寄存器里的值和 0 比较

在上述循环中的循环速度是非常快的, 一秒钟可能就运行了几亿次,在这个执行过程中,1操作每次读取的结果都是一样的,并且我们知道,内存的读写速度,相对于2操作的比较速度,是慢得多的,在这个循环中,九成九的时间都在执行1操作,并且运行了很多次(几亿甚至上百亿),读取的值都没有变化,此时JVM 就可能做出优化:不再执行 1 操作,直接用之前寄存器中的值和0做比较。当后面 flag的值变为1后,t1 线程中寄存器中存的值还是0,所以循环不会结束。

我们可以在while中加一个sleep,让循环变慢:

public class ThreadDemo18 {
    public static int flag = 0;
    public static void main(String[] args) {

        int a = 0;
        Thread t1 = new Thread(() -> {
            while(flag == 0) {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("把flag置为1");
            flag = 1;
        });

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

运行代码:

我们发现循环可以正常结束。 

这是因为不加sleep时,一秒钟循环几亿次,操作 1 的整体开销占比是非常大的,优化的迫切程度就更高;加了sleep后一秒循环1000次,操作 1 的整体开销占比就小很多了,优化的迫切程度也就每那么高。

Java中提供了 volatile 关键字,可以使上述优化被关闭 

public class ThreadDemo18 {
    volatile public static int flag = 0;
    public static void main(String[] args) {

        int a = 0;
        Thread t1 = new Thread(() -> {
            while(flag == 0) {
                
            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("把flag置为1");
            flag = 1;
        });

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

运行结果:

volatile 有两个功能 

1. 保证内存可见性
2. 防止指令重排序

2.5 线程饿死 

 Java中的线程饥饿(Thread Starvation),是指某个或某些线程无法获取到所需的CPU时间或其它系统资源,从而陷入长时间的等待状态,无法继续正常执行。这种情况可能会导致程序性能下降、响应时间延长,甚至出现死锁等严重问题。

线程饥饿通常是由于以下几个原因引起的:

  1. CPU资源被占用:当有一个或多个线程占用了大量的CPU资源时,其他线程可能无法获得足够的CPU时间,从而无法正常执行。

  2. 长时间等待资源:当某个线程需要等待某个资源(如锁、I/O操作等)时,如果该资源一直被其他线程占用,那么该线程可能会长时间等待,从而导致线程饥饿。
    示例代码:

    public class ThreadDemo19 {
        public static void main(String[] args) throws InterruptedException {
            Object locker = new Object();
    
            Thread t1 = new Thread(() -> {
                synchronized (locker) {
                    while(true) {
                        //模拟长时间占用锁
                    }
                }
            });
    
            Thread t2 = new Thread(() -> {
                synchronized (locker) {
                    System.out.println("执行了t2");
                }
            });
    
            t1.start();
            //确保t1先拿到locker
            Thread.sleep(100);
            t2.start();
        }
    }

  3. 线程优先级不足:当有多个线程同时竞争某个资源时,如果优先级较低的线程一直无法获得该资源,那么它们可能会陷入长时间的等待状态。
    例如:现有 1,2,3 三个线程,线程1 的优先级更高,现在线程1 拿到了锁,但是现在某个条件不满足,线程1无法执行,所以线程1 由把锁释放了,但是线程 1 释放锁之后 任然会参与到锁竞争中,又由于 线程1的优先级高于线程2和线程3,所以任然是线程1拿到锁,于是导致了死锁。这种情况下我们可以使用 wait/notify 解决 让线程1 在条件满足时 再尝试获取锁

2.6 wait / notify 

  1. wait()方法

    • wait()方法是Object类中定义的方法,可以在任何对象上被调用。它使当前线程释放对象的锁,并让线程进入等待状态,直到其他线程调用相同对象上的notify()或notifyAll()方法将其唤醒。
    • 调用wait()方法会导致当前线程进入等待队列,并释放对象的监视器锁(即释放synchronized块或方法中的锁),允许其他线程获得该锁并执行相应操作。
    • wait()方法可以指定等待的超时时间,如果在指定的时间内没有被唤醒,则线程会自动苏醒。
  2. notify()方法

    • notify()方法也是Object类中定义的方法,用于唤醒等待在相同对象上的某个线程。它会选择性地通知等待队列中的一个线程,表示该线程可以尝试重新获得对象的锁。
    • 如果有多个线程在等待相同对象上的锁,那么只有其中一个线程会被唤醒,具体唤醒哪个线程是不确定的。
    • notifyAll()方法则会唤醒等待队列中的所有线程。

 示例:

public class ThreadDemo20 {
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker) {
                try {
                    System.out.println("wait之前");
                    //wait必须在synchronized内部,因为要释放锁的前提是得加上锁
                    locker.wait();
                    System.out.println("wait之后");
                } catch (InterruptedException e) {
                    //wait 和 sleep join都是一类的可能会被提前唤醒,需要捕获异常
                    e.printStackTrace();
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("notify之前");
                //Java特别规定notify也必须在synchronized内部
                locker.notify();
                System.out.println("notify之后");
            }
        });
        t1.start();
        Thread.sleep(1000);
        t2.start();
    }
}

 执行过程:

t1 执行后会立刻拿到锁,并且打印 “wait之前” 然后进入wait方法 (释放锁,阻塞等待),然后等待1秒,t2开始执行,拿到锁 打印 “notify”之前 ,然后执行 notify,让 t1 停止阻塞,重新参与锁竞争

注意:wait()可以设置最大等待时间,具体规则和join相同

 

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

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

相关文章

【数据库原理】函数依赖、三范式、视图、事务、数据库设计(概念、逻辑、物理结构设计,实施)、数据流图、数据字典、存储过程、触发器、备份与还原【我统统拿下!】

函数依赖 函数依赖是关系数据库中的重要概念&#xff0c;用于描述关系中属性之间的依赖关系。 在关系数据库中&#xff0c;如果关系 R 中的某个属性或属性组的值能够唯一确定关系中其他属性的值&#xff0c;那么我们就说这个属性或属性组对其他属性具有函数依赖关系。 举个例…

pytest系列——allure之在测试用例添加标题(@allure.title())

前言 通过使用装饰器allure.title可以为测试用例自定义一个更具有阅读性的易读的标题。 allure.title的三种使用方式&#xff1a; 直接使用allure.title为测试用例自定义标题&#xff1b;allure.title支持通过占位符的方式传递参数&#xff0c;可以实现测试用例标题参数化&a…

SpringBootCache缓存——j2cache

文章目录 缓存供应商变更&#xff1a;j2cache 缓存供应商变更&#xff1a;j2cache <!-- https://mvnrepository.com/artifact/net.oschina.j2cache/j2cache-core --><dependency><groupId>net.oschina.j2cache</groupId><artifactId>j2cache-cor…

【数组和函数实战: 斗地主游戏】

目录 1. 玩法说明 2. 分析和设计 3. 代码实现 4. 游戏演示1. 玩法说明 一副54张牌,3最小,两个王最大,其实是2,和上面一样从大到小排列 2. 分析和设计 2.1 分析和设计 常量和变量设计 一副牌有54张,有牌的数值和花色,可以分别用两个数组来存储,card为卡牌表示的数值,color为…

WebGL笔记:矩阵平移的数学原理和实现

矩阵平移的数学原理 让向量OA位移 x方向&#xff0c;txy方向&#xff0c;tyz方向&#xff0c;tz 最终得到向量OB 矩阵平移的应用 再比如我要让顶点的x移动0.1&#xff0c;y移动0.2&#xff0c;z移动0.3 1 &#xff09;顶点着色器核心代码 <script id"vertexShader&…

echarts实现全国及各省市地图

echarts实现全国及各省市地图&#xff08;内附地图json文件&#xff09; 去阿里云就可以获取&#xff1a;[阿里云地理]&#xff1a;http://datav.aliyun.com/portal/school/atlas/area_selector#&lat31.769817845138945&lng104.29901249999999&zoom4(http://datav…

6.7 Windows驱动开发:内核枚举LoadImage映像回调

在笔者之前的文章《内核特征码搜索函数封装》中我们封装实现了特征码定位功能&#xff0c;本章将继续使用该功能&#xff0c;本次我们需要枚举内核LoadImage映像回调&#xff0c;在Win64环境下我们可以设置一个LoadImage映像加载通告回调&#xff0c;当有新驱动或者DLL被加载时…

HTML块元素和行内元素

HTML块元素和行内元素 1.分类2.块元素3.行内元素 1.分类 在HTML中&#xff0c;根据元素的表现形式&#xff0c;一般可以分为两类&#xff1a; 块元素&#xff08;block&#xff09;行内元素&#xff08;inline&#xff09; 2.块元素 在HTML中&#xff0c;块元素在浏览器显示…

制作一个RISC-V的操作系统二-RISC-V ISA介绍

文章目录 ISA的基本介绍啥是ISA为什么要设计ISACISCvsRISCISA的宽度知名ISA介绍 RISC-V历史和特点RISC-V发展RISC-V ISA 命名规范模块化的ISA通用寄存器Hart特权级别Control and Status Register&#xff08;CSR&#xff09;内存管理与保护异常和中断 ISA的基本介绍 啥是ISA …

单显卡插槽安装英伟达Tesla P4 AI加速卡

Tesla P4是专业AI显卡&#xff0c;只有70瓦功耗&#xff0c;可以作为AI入门使用。 安装时碰到的几个问题&#xff1a; 首先因为单显卡插槽&#xff0c;就需要先安装好机器&#xff0c;然后ssh登录进行相关配置。安装的时候来回插拔了好多次&#xff01; 其次就是安装驱动时&a…

【java毕业设计源码】基于SSM框架的在线智能题库管理系统设计与实现

该项目含有源码、文档、PPT、配套开发软件、软件安装教程、项目发布教程等学习内容。 目录 一、项目介绍&#xff1a; 二、文档学习资料&#xff1a; 三、模块截图&#xff1a; 四、开发技术与运行环境&#xff1a; 五、代码展示&#xff1a; 六、数据库表截图&#xff1a…

麒麟系统添加环境变量

环境变量添加方法 方法一&#xff1a;用户主目录下的.profile或.bashrc文件&#xff08;推荐&#xff09; 登录到你的用户&#xff08;非root&#xff09;&#xff0c;在终端输入&#xff1a; sudo vim ~/.profile 或者 sudo vim ~/.bashrc 翻到该文件最后&#xff0c…

位图布隆过滤器(附面试题)

文章目录 目录 文章目录 前言 一 . 位图 1.1 面试题 1.2 位图概念 1.3 位图的实现 1.4 位图的应用 二 . 布隆过滤器 2.1 布隆过滤器提出 2.2布隆过滤器概念 2.3 布隆过滤器的查找 2.4 实现 2.5 布隆过滤器删除 2.6 布隆过滤器优点 2.7 布隆过滤器缺陷 2.8 布隆过滤器使用场景 三…

解决ZED SDK安装后不可用,出现“核心已转储”的闪退问题

在陈述问题简单回顾下ZED SDK安装的步骤 ZED的运行需要显卡支持&#xff0c;cuda加速&#xff0c;因此需要提前安装好显卡驱动以及对应的cuda和cudnn&#xff0c;基础工作在此不再赘述&#xff0c;以下步骤默认已经完成上述准备工作。 建议新建一个虚拟环境以限定ZED使用的py…

SpringIOC第二课,@Bean用法,DI详解,常见面试题Autowired VS Resource

一、回顾 但是我们之前MVC时候&#xff0c;在页面上&#xff0c;为什只用Controller,不用其他的呢&#xff1f; 用其他的好使吗&#xff1f;(我们可以在这里看到&#xff0c;出现404的字样&#xff09; Service ResponseBody public class TestController {RequestMapping(&quo…

前端对浏览器的理解

浏览器的主要构成 用户界面 &#xff0d; 包括地址栏、后退/前进按钮、书签目录等&#xff0c;也就是你所看到的除了用来显示你所请求页面的主窗口之外的其他部分。 浏览器引擎 &#xff0d; 用来查询及操作渲染引擎的接口。 渲染引擎 &#xff0d; 用来显示请求的内容&#…

基于ResNet18网络完成图像分类任务

目录 1 数据处理 1.1 数据集介绍 1.2 数据读取 1.3 构造Dataset类 2 模型构建 3 模型训练 4 模型评价 5 模型预测 6 什么是预训练模型和迁移学习 7 比较“使用预训练模型”和“不使用预训练模型”的效果。 总结 在本实践中&#xff0c;我们实践一个更通用的图像分类任务…

陶博士月线反转6.4 python 化代码

陶博士月线反转6.4 python 化代码 量化系统有好几个月没有进度了&#xff0c;之前一直纠结策略问题&#xff0c;无从下手。最近和量化的同学聊了下&#xff0c;还得先把自动交易流程先跑起来后面再慢慢优化策略。 所以先拿陶博士的月线反转6.4 python 化&#xff0c;作为试水的…

最常用的Linux命令#程序员

最常用的Linux命令 #程序员 #linux 1. 文件和目录管理 icon2. 文件查看和编辑 3.流程管理 4. 系统信息 5. 用户和组管理 6. 网络配置与监控 7. 包管理

产品学习之路(一)

在做好开发的同时&#xff0c;还需要熟悉产品业务逻辑&#xff0c;不能为了功能而做功能&#xff0c;要从产品经理的角度去看待每个需求和客户痛点所在&#xff0c;这样针对产品设计出来的东西自己也有发言权&#xff1b; 目前作为一名前端开发人员&#xff0c;也在自学产品知识…