【线程】线程安全问题及解决措施

news2024/11/25 13:37:57

【线程】线程安全问题及解决措施

      • 前言
      • 一、由“随机调度”引起的线程安全问题
        • 1.1现象
        • 1.2 原因
        • 1.3 解决办法
        • 1.4 不当加锁造成的死锁问题
      • 二、由“系统优化”引起的线程安全问题
        • 2.1 内存可见性问题 / 指令重排序问题
        • 2.2 解决方案

前言

何为线程安全,即某段代码无论在单线程下执行、还是多线程下执行,都不会产生错误,此为线程安全;如果这个代码,在单线程下能够正确运行,在多线程下就不能正常运行,此为线程不安全存在线程安全问题

一、由“随机调度”引起的线程安全问题

1.1现象

首先来看一个实例:

public class ThreadDemo1  {
    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();

        t1.join();
        t2.join();

        System.out.println("count++十万次后:"+count);

    }
}

输出结果:
在这里插入图片描述
我们期望的结果是10万;
但是可以发现,不仅输出的结果不对,而且每次输出的还不一样。

1.2 原因

我们前面做过很多铺垫:

  1. 一个加法操作在系统中是由多个指令组成的:读取数据,加法运算,将结果写回内存;
  2. 多个线程在系统的执行是随机调度,抢占式执行的。

在这里插入图片描述
造成出现错误的直观原因呢就是一个线程进行加法操作后,还没有保存,就被另一个线程读走了。
结合两种铺垫,在系统中就可能出现各个指令的多种执行顺序,(列出四种,实际无数种),而只有前两种情况是对的。

但是这样的执行顺序又好像是在串行,跟我们要说的多线程似乎又无关了。其实不然,

Thread t1=new Thread(()->{
           for(int i=0;i<50000;i++){
               synchronized (locker) {
                   count++;
               }
           }
        });

这样一段代码中,我们只对count++加锁,而实际上for循环里的比较和自增我们并没有加锁,实际上实现的逻辑比较复杂的时候,我们也只需要对必要的步骤加锁,而不是对整个线程加锁,这样一来,整体的效率还是要比较高的。

那我们反过来思考一个问题,如果我这个操作在系统中只是由一个指令组成,那还会出现这样的问题吗?
显然是不会的,如果操作在系统中只有一个指令组成,就不涉及到这么多错误的执行顺序,什么顺序都是正确的。

  1. 所以,造成这个问题的直接原因是上述count++这个操作不是原子的,即这个操作是由多个指令组成的;
  2. 那造成这个问题的根本原因还是在于系统自身随机调度 / 线程抢占式执行这样的设定。
  3. 其次就是代码的写法问题,如果不针对同一个变量进行运算,也不会出现这样的问题。
1.3 解决办法

充分考虑出现上述问题的原因后,我们就可以很好的解决问题:

  1. 针对count++是非原子性的操作,我们可以对这个操作(三个指令)打包到一起,称为加锁操作;
  2. 针对随机调度这样的设定,我们没有办法更改;
  3. 针对代码结构这个问题,不一定能够修改。

综上,最好的解决办法就是对非原子的、可能造成问题的操作加锁。
Java中,使用synchronized(Object object){ }对操作加锁。
在这里插入图片描述

public class ThreadDemo1  {
    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);
    }
}

加锁之后,运行结果就正确了。
在这里插入图片描述
但是,此处的加锁也是很有讲究的,必须要产生锁竞争才可以。理解了锁竞争,以下问题就很容易解决了:

  1. 一个线程加锁,一个线程不加锁,可以吗?不可以,不能产生锁竞争;
  2. 两个线程,针对不同的对象加锁,可以吗?不可以,不能产生锁竞争。

这种明显的很容易区别出来,但也有一些不容易区分是否是对同一个对象加锁的操作,比如:

  1. 创建一个Test对象,在Test类内对this加锁:
class Test {
    public int count = 0;

	//对this加锁,也可以写作:对非静态方法加锁
    //synchronized public void add()
    public void add() {
        synchronized (this) {
            count++;
        }
    }
}
public class ThreadDemo1  {

    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都指向对象t,是同一个对象,存在锁竞争。

  1. 对类对象Test.class加锁:
class Test {
    public int count = 0;
    //对类对象加锁,也可以写作:对静态方法加锁
    synchronized public static void func()()
    public void add() {
        synchronized (Test.class) {
            count++;
        }
    }
}

一个类的类对象只有一个,对类对象加锁,也是同一个对象,存在锁竞争。

所以,加锁的核心一定是产生锁竞争

1.4 不当加锁造成的死锁问题

首先,理解一个概念:可重入锁。通过synchronized创建的锁就是可重入锁。

Object locker=new Object();
        Thread t=new Thread(()->{
           synchronized (locker){
               synchronized (locker){
                   System.out.println("hello");
               }
           }
        });

此代码,能正常打印出“hello”吗? 可以。
所谓可重入锁,就是在同一线程中,可以对同一对象多次加锁,不会产生阻塞。如果是不可重入锁,就会在第二次加锁时产生阻塞,卡死,出现死锁。

可重入锁的实现机制:

  1. 首先会记录持有锁的线程;相同的线程重复加锁就只是改动计数器,不同的线程则要阻塞等待。
  2. 其次在简单的不可重入锁的基础上加了一层计数器,每加一次锁,计数加一;每释放一次锁,计数减一,计数器变为0时,就将锁完全释放。

产生死锁的几种典型场景

  1. 一个线程,一把锁
    这种主要是针对不可重入锁,反复加锁出现的死锁;
  2. 两个线程,两把锁
    相当于A房间内锁了B的钥匙,B房间内锁了A的钥匙,产生的死锁;
public class ThreadDemo1  {
    public static void main(String[] args) throws InterruptedException {
        Object A=new Object();
        Object B=new Object();

        Thread t1=new Thread(()->{
            synchronized (A){
                try {
                    //在拿到锁A后,休眠,确保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 {
                    //在拿到锁B后,休眠,确保t1线程能拿到A
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    System.out.println("线程t2");
                }
            }
        });

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

    }
}

运行,程序卡死:
在这里插入图片描述
我们使用jconsole来观察线程状态,两个线程产生死锁:
在这里插入图片描述
简单的说就是t1拿到了A锁,t2拿到了B锁。t1此时想要在拿到B锁的条件是,t2能够释放B锁;t2想要拿到A锁的条件是,t1能够释放A锁,条件互斥,故产生死锁。

  1. N个线程M把锁。
    经典问题:哲学家就餐问题,本质上和两个线程互相加锁一致。

此处可以将哲学家理解为线程,将筷子理解为锁。
此处是5个哲学家5根筷子(注意不是5双),每个人要想吃到食物需要拿到就近的两根筷子。(两把锁)每个哲学家除了吃食物外,还要放下筷子,思考人生。但是每个哲学家什么时候吃饭,什么时候思考人生是不确定的。(随机调度)

大多数情况下,这个模型是可以正常运行的。但是有些时候,则不行,比如所有哲学家都同时拿起左手的筷子,那么每个人都手执一根筷子,那就所有人都吃不到饭,也不会放下筷子思考人生。此时就出现了死锁
在这里插入图片描述

解决这个问题,我们要先思考此处产生死锁的必要条件:
(1)互斥使用,锁的基本特征,同一把锁不能被多个线程同时持有;
(2)不可抢占,除非拿到锁的线程主动释放,别的线程不能得到这把锁;
(3)请求保持,一个线程拿到锁A之后,尝试获取锁B;
(4)循环等待,此处五个哲学家都在等下一个人放下筷子产生了死循环

所谓必要条件,就是只有四个条件都满足,才会产生死锁。前两个条件是锁的基本特征,不能够破坏;第三个条件依赖于代码结构和真实需求,不一定能破坏。那解决问题的核心就在于如何打破循环

其实解决问题的方案有很多种:
(1)引入额外的筷子(锁)/ 去掉一个哲学家(线程)
(2)引入计数器,限制同时吃饭的人数(线程)
(3)引入加锁顺序的规则。

前两种方案虽能解决问题,且实现也较容易,但是普适性较差。
第三种方案普适性较高,此处我们可以给筷子和锁都编号,并规定:5号哲学家要先拿起右手的筷子(5号筷子),别的哲学家先拿左手的筷子。此时循环被打破,问题得以解决。

哲学家思考问题参考:哲学家就餐问题

二、由“系统优化”引起的线程安全问题

2.1 内存可见性问题 / 指令重排序问题

先看示例:

public class ThreadDemo1  {
    private static int flag=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            while(flag==0){
                //此处不写任何逻辑
            }
        });

        Thread t2=new Thread(()->{
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            flag=1;
        });

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

此处在t2线程种,将flag设置为1后,按理说进程就应该结束了。
但实际上并没有:
在这里插入图片描述

此处就是JVM虚拟机/编译器对代码做出了优化,

while(flag==0){
    //此处不写任何逻辑
}

这个过程中,涉及的核心指令有两条:

  1. 将内存中的flag读取到cpu寄存器中(load)
  2. 拿着寄存器的值和0比较
    由于CPU的执行速度非常快,因为每次flag的值都不变、且load操作开销较大。此处JVM/编译器就将从内存中读取flag优化为从寄存器/缓存中读取flag。此时,即使在线程 t2 中修改了flag的值,也无法读取到,故称为内存可见性问题
    由于JVM虚拟机/编译器触发优化的条件我们并不可知,例如:
Thread t1=new Thread(()->{
            while(flag==0){
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

我们将 t1 线程的逻辑改为每轮循环等待1ms,此时优化就不会出现。
在这里插入图片描述

2.2 解决方案

我们可以利用volatile关键字强制设置某个变量必须从内存中读取,同时禁止指令重排序来解决这个问题:

private volatile static int flag = 0;

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

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

相关文章

[开源]3K+ star!微软Office的平替工具,跨平台,超赞!

大家好&#xff0c;我是JavaCodexPro&#xff01; 数字化的当下&#xff0c;高效的办公工具是提升工作效率的关键&#xff0c;然而大家想到的一定是 Microsoft Office 办公软件&#xff0c;然而价格也是相当具有贵的性价比。 今天JavaCodexPro给大家分享一款超棒的开源办公套…

【大数据分析机器学习】分布式机器学习

【作者主页】Francek Chen 【专栏介绍】 ⌈ ⌈ ⌈智能大数据分析 ⌋ ⌋ ⌋ 智能大数据分析是指利用先进的技术和算法对大规模数据进行深入分析和挖掘&#xff0c;以提取有价值的信息和洞察。它结合了大数据技术、人工智能&#xff08;AI&#xff09;、机器学习&#xff08;ML&a…

SOL链上的 Meme 生态发展:从文化到创新的融合#dapp开发#

一、引言 随着区块链技术的不断发展&#xff0c;Meme 文化在去中心化领域逐渐崭露头角。从 Dogecoin 到 Shiba Inu&#xff0c;再到更多细分的 Meme 项目&#xff0c;这类基于网络文化的加密货币因其幽默和社区驱动力吸引了广泛关注。作为近年来备受瞩目的区块链平台之一&…

一篇保姆式centos/ubuntu安装docker

前言&#xff1a; 本章节分别演示centos虚拟机&#xff0c;ubuntu虚拟机进行安装docker。 上一篇介绍&#xff1a;docker一键部署springboot项目 一&#xff1a;centos 1.卸载旧版本 yum remove docker docker-client docker-client-latest docker-common docker-latest doc…

Dubbo源码解析-Dubbo的线程模型(九)

一、Dubbo线程模型 首先明确一个基本概念&#xff1a;IO 线程和业务线程的区别 IO 线程&#xff1a;配置在netty 连接点的用于处理网络数据的线程&#xff0c;主要处理编解码等直接与网络数据 打交道的事件。 业务线程&#xff1a;用于处理具体业务逻辑的线程&#xff0c;可以…

前端全栈 === 快速入 门 Redis

目录 简介 通过 docker 的形式来跑&#xff1a; set、get 都挺简单&#xff1a; incr 是用于递增的&#xff1a; keys 来查询有哪些 key: redis insight GUI 工具。 list 类型 left push rpush lpop 和 rpop 自然是从左边和从右边删除数据。​编辑 如果想查看数据…

Python MySQL SQLServer操作

Python MySQL SQLServer操作 Python 可以通过 pymysql 连接 MySQL&#xff0c;通过 pymssql 连接 SQL Server。以下是基础操作和代码实战示例&#xff1a; 一、操作 MySQL&#xff1a;使用 pymysql python 操作数据库流程 1. 安装库 pip install pymysql2. 连接 MySQL 示例 …

编程语言之C++诞生记!

成长路上不孤单&#x1f60a;&#x1f60a;&#x1f60a;&#x1f60a;&#x1f60a;&#x1f60a; 【14后&#x1f60a;///C爱好者&#x1f60a;///持续分享所学&#x1f60a;///如有需要欢迎收藏转发///&#x1f60a;】 今日分享关于C诞生的相关内容&#xff01; 关于【C诞…

核心差异:知识VS文档管理(+工具软件安利)

在讨论知识管理和文档管理时&#xff0c;我们经常会听到这两种说法被混淆使用。然而&#xff0c;它们各自服务于不同的目的&#xff0c;这一点至关重要。 想象一下&#xff0c;你是一名项目经理&#xff0c;面临以下两项任务&#xff1a; 存储最新的项目计划捕捉团队讨论中获…

医院挂号就诊系统(源码+数据库+报告)

基于SpringBoot的医院挂号就诊系统&#xff0c;系统包含三种角色&#xff1a;管理员、医生、用户,系统分为前台和后台两大模块&#xff0c;主要功能如下。 前台&#xff1a; - 首页&#xff1a;展示医院相关信息、推荐医生等内容。 - 健康教育&#xff1a;提供健康知识、文章等…

【热门主题】000065 探索人工智能学习框架:开启智能未来的钥匙

前言&#xff1a;哈喽&#xff0c;大家好&#xff0c;今天给大家分享一篇文章&#xff01;并提供具体代码帮助大家深入理解&#xff0c;彻底掌握&#xff01;创作不易&#xff0c;如果能帮助到大家或者给大家一些灵感和启发&#xff0c;欢迎收藏关注哦 &#x1f495; 目录 【热…

《智慧教育实时数据分析推荐项目》详细分析

一、项目介绍 1、背景介绍 在互联网、移动互联网的带动下&#xff0c;教育逐渐从线下走向线上&#xff0c;在线教育近几年一直处于行业的风口浪尖&#xff0c;那随着基础设施的不断完善&#xff0c;用户需求也发生不少变化&#xff0c;因此传统教育机构、新兴互联网企业都在探…

使用LUKS对Linux磁盘进行加密

前言 本实验用于日常学习用&#xff0c;如需对存有重要数据的磁盘进行操作&#xff0c;请做好数据备份工作。 此实验只是使用LUKS工具的冰山一角&#xff0c;后续还会有更多功能等待探索。 LUKS&#xff08;Linux Unified Key Setup&#xff09;是Linux系统中用于磁盘加密的一…

在 cmd 输入 python.exe 后不报错也无反应的问题

在 cmd 输入 python.exe 后不报错&#xff1a;‘python.exe ’不是内部或外部命令&#xff0c;也不是可运行的程序或批处理文件&#xff0c;也无反应。只是显示这样一个弹窗&#xff1a; 查了下环境变量path&#xff0c;看看有什么地方有python.exe&#xff0c;发现原来在C:\Us…

10、PyTorch autograd使用教程

文章目录 1. 相关思考 1. 相关思考

如何在 Ubuntu 22 04 上安装和配置 Ansible 自动化平台

如何在 Ubuntu 22.04 上安装和配置 Ansible 自动化平台 简介 Ansible 是一个开源项目&#xff0c;并在 Github 上收获了 63k 的 star 。它是一个极其简单的 IT 自动化平台&#xff0c;使您的应用程序和系统更易于部署和维护。使用 SSH&#xff0c;以接近简单英语的语言实现从…

PowerMILL 客制化宏 - 用户菜单定义

用户右键菜单 在PowerMILL元素浏览器空白的地方右键弹出的菜单叫用户右键菜单。用户右键菜单可以调用宏或命令或用户二次开发的应用或批处理等等。 用户右键菜单定义 用户右键菜单需要建立一个没有扩展名的 “user_menu” 名称的文件&#xff0c;一般存放在 “C:\dcam\pmill2…

006 单片机嵌入式中的C语言与代码风格规范——常识

00 环境准备&#xff1a; 配置MDK支持C99 内置stdint.h介绍 stdint.h 是从 C99 中引进的一个标准 C 库的文件 路径&#xff1a;D:\MDK\ARM\ARMCC\include 01 C语言基础语法 一般的bug很有可能是C语言功底不扎实导致…… 1.结构体 由若干基本数据类型集合组成的一种自定义数…

《生成式 AI》课程 作业6 大语言模型(LLM)的训练微调 Fine Tuning -- part1

资料来自李宏毅老师《生成式 AI》课程&#xff0c;如有侵权请通知下线 Introduction to Generative AI 2024 Spring 该文档主要介绍了国立台湾大学&#xff08;NTU&#xff09;2024 年春季 “生成式人工智能&#xff08;GenAI&#xff09;” 课程的作业 5&#xff08;GenAI HW…

ZYNQ-7020嵌入式系统学习笔记(1)——使用ARM核配置UART发送Helloworld

本工程实现调用ZYNQ-7000的内部ARM处理器&#xff0c;通过UART给电脑发送字符串。 硬件&#xff1a;正点原子领航者-7020 开发平台&#xff1a;Vivado 2018、 SDK 1 Vivado部分操作 1.1 新建工程 设置工程名&#xff0c;选择芯片型号。 1.2 添加和配置PS IP 点击IP INTEGR…