【JUC并发编程】使用多线程可能带来什么问题

news2024/11/16 16:30:25

【JUC并发编程】使用多线程可能带来什么问题?

文章目录

  • 【JUC并发编程】使用多线程可能带来什么问题?
    • 什么是多线程
    • 并发为什么会出现
    • 线程带来的安全性问题
      • 可见性问题
      • 原子性问题
      • 有序性问题
      • 活跃性问题
      • 性能问题
        • 引起线程切换的几种方式

什么是多线程

多线程意味着你能够在同一个应用程序中运行多个线程,我们知道,指令是在 CPU 中执行的,多线程应用程序就像是具有多个 CPU 在同时执行应用程序的代码。

image-20230119172831056

其实这是一种假象,线程数量并不等于 CPU 数量,单个 CPU 将在多个线程之间共享 CPU 的时间片,在给定的时间片内执行每个线程之间的切换,每个线程也可以由不同的 CPU 执行,如下图所示

image-20230119172846684

并发为什么会出现

计算机世界的快速发展离不开 CPU、内存和 I/O 设备的高速发展,但是这三者一直存在速度差异性问题,我们可以从存储器的层次结构可以看出

image-20230119173027682

CPU 内部是寄存器的构造,寄存器的访问速度要高于高速缓存,高速缓存的访问速度要高于内存,最慢的是磁盘访问。

程序是在内存中执行的,程序里大部分语句都要访问内存,有些还需要访问 I/O 设备,根据漏桶理论来说,程序整体的性能取决于最慢的操作也就是磁盘访问速度。

因为 CPU 速度太快了,所以为了发挥 CPU 的速度优势,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 使用缓存来中和和内存的访问速度差异
  • 操作系统提供进程和线程调度,让 CPU 在执行指令的同时分时复用线程,让内存和磁盘不断交互,不同的 CPU 时间片 能够执行不同的任务,从而均衡这三者的差异
  • 编译程序提供优化指令的执行顺序,让缓存能够合理的使用

线程带来的安全性问题

线程安全性是非常复杂的,在没有采用同步机制的情况下,多个线程中的执行操作往往是不可预测的,这也是多线程带来的挑战之一,下面我们给出一段代码,来看看安全性问题体现在哪

public class TSynchronized implements Runnable{

    static int i = 0;

    public void increase(){
        i++;
    }


    @Override
    public void run() {
        for(int i = 0;i < 1000;i++) {
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        TSynchronized tSynchronized = new TSynchronized();
        Thread aThread = new Thread(tSynchronized);
        Thread bThread = new Thread(tSynchronized);
        aThread.start();
        bThread.start();
        System.out.println("i = " + i);
    }
}

这段程序输出后会发现,i 的值每次都不一样,这不符合我们的预测,那么为什么会出现这种情况呢?我们先来分析一下程序的运行过程。

TSynchronized 实现了 Runnable 接口,并定义了一个静态变量 i,然后在 increase 方法中每次都增加 i 的值,在其实现的 run 方法中进行循环调用,共执行 1000 次。

可见性问题

在单核 CPU 时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决,CPU 和 内存之间

image-20230119173309851

在多核时代,因为有多核的存在,每个核都能够独立的运行一个线程,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存

image-20230119173331235

因为 i 是静态变量,没有经过任何线程安全措施的保护,多个线程会并发修改 i 的值,所以我们认为 i 不是线程安全的,导致这种结果的出现是由于 aThread 和 bThread 中读取的 i 值彼此不可见,所以这是由于 可见性 导致的线程安全问题。

原子性问题

看起来很普通的一段程序却因为两个线程 aThreadbThread 交替执行产生了不同的结果。但是根源不是因为创建了两个线程导致的,多线程只是产生线程安全性的必要条件,最终的根源出现在 i++ 这个操作上。

这个操作怎么了?这不就是一个给 i 递增的操作吗?也就是 「i++ => i = i + 1」,这怎么就会产生问题了?

因为 i++ 不是一个 原子性 操作,仔细想一下,i++ 其实有三个步骤,读取 i 的值,执行 i + 1 操作,然后把 i + 1 得出的值重新赋给 i(将结果写入内存)。

当两个线程开始运行后,每个线程都会把 i 的值读入到 CPU 缓存中,然后执行 + 1 操作,再把 + 1 之后的值写入内存。因为线程间都有各自的虚拟机栈和程序计数器,他们彼此之间没有数据交换,所以当 aThread 执行 + 1 操作后,会把数据写入到内存,同时 bThread 执行 + 1 操作后,也会把数据写入到内存,因为 CPU 时间片的执行周期是不确定的,所以会出现当 aThread 还没有把数据写入内存时,bThread 就会读取内存中的数据,然后执行 + 1操作,再写回内存,从而覆盖 i 的值,导致 aThread 所做的努力白费。

image-20230119173424485

为什么上面的线程切换会出现问题呢?

我们先来考虑一下正常情况下(即不会出现线程安全性问题的情况下)两条线程的执行顺序

image-20230119173441821

可以看到,当 aThread 在执行完整个 i++ 的操作后,操作系统对线程进行切换,由 aThread -> bThread,这是最理想的操作,一旦操作系统在任意 读取/增加/写入 阶段产生线程切换,都会产生线程安全问题。例如如下图所示

image-20230119173500804

最开始的时候,内存中 i = 0,aThread 读取内存中的值并把它读取到自己的寄存器中,执行 +1 操作,此时发生线程切换,bThread 开始执行,读取内存中的值并把它读取到自己的寄存器中,此时发生线程切换,线程切换至 aThread 开始运行,aThread 把自己寄存器的值写回到内存中,此时又发生线程切换,由 aThread -> bThread,线程 bThread 把自己寄存器的值 +1 然后写回内存,写完后内存中的值不是 2 ,而是 1, 内存中的 i 值被覆盖了。

我们上面提到 原子性 这个概念,那么什么是原子性呢?

并发编程的原子性操作是完全独立于任何其他进程运行的操作,原子操作多用于现代操作系统和并行处理系统中。

原子操作通常在内核中使用,因为内核是操作系统的主要组件。但是,大多数计算机硬件,编译器和库也提供原子性操作。

在加载和存储中,计算机硬件对存储器字进行读取和写入。为了对值进行匹配、增加或者减小操作,一般通过原子操作进行。在原子操作期间,处理器可以在同一数据传输期间完成读取和写入。这样,其他输入/输出机制或处理器无法执行存储器读取或写入任务,直到原子操作完成为止。

简单来讲,就是「原子操作要么全部执行,要么全部不执行」。数据库事务的原子性也是基于这个概念演进的。

有序性问题

在并发编程中还有带来让人非常头疼的 有序性 问题,有序性顾名思义就是顺序性,在计算机中指的就是指令的先后执行顺序。一个非常显而易见的例子就是 JVM 中的类加载

image-20230119173549649

这是一个 JVM 加载类的过程图,也称为类的生命周期,类从加载到 JVM 到卸载一共会经历五个阶段 「加载、连接、初始化、使用、卸载」。这五个过程的执行顺序是一定的,但是在连接阶段,也会分为三个过程,即 「验证、准备、解析」 阶段,这三个阶段的执行顺序不是确定的,通常交叉进行,在一个阶段的执行过程中会激活另一个阶段。

有序性问题一般是编译器带来的,编译器有的时候确实是 「好心办坏事」,它为了优化系统性能,往往更换指令的执行顺序。

活跃性问题

多线程还会带来活跃性问题,如何定义活跃性问题呢?活跃性问题关注的是 「某件事情是否会发生」

「如果一组线程中的每个线程都在等待一个事件的发生,而这个事件只能由该组中正在等待的线程触发,这种情况会导致死锁」

简单一点来表述一下,就是每个线程都在等待其他线程释放资源,而其他资源也在等待每个线程释放资源,这样没有线程抢先释放自己的资源,这种情况会产生死锁,所有线程都会无限的等待下去。

「死锁的必要条件」

造成死锁的原因有四个,破坏其中一个即可破坏死锁

  • 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程释放。
  • 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持占有。
  • 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 循环等待:指在发生死锁时,必然存在一个进程对应的环形链。

换句话说,死锁线程集合中的每个线程都在等待另一个死锁线程占有的资源。但是由于所有线程都不能运行,它们之中任何一个资源都无法释放资源,所以没有一个线程可以被唤醒。

如果说死锁很痴情的话,那么活锁用一则成语来表示就是 弄巧成拙

某些情况下,当线程意识到它不能获取所需要的下一个锁时,就会尝试礼貌的释放已经获得的锁,然后等待非常短的时间再次尝试获取。可以想像一下这个场景:当两个人在狭路相逢的时候,都想给对方让路,相同的步调会导致双方都无法前进。

现在假想有一对并行的线程用到了两个资源。它们分别尝试获取另一个锁失败后,两个线程都会释放自己持有的锁,再次进行尝试,这个过程会一直进行重复。很明显,这个过程中没有线程阻塞,但是线程仍然不会向下执行,这种状况我们称之为 活锁(livelock)

如果我们期望的事情一直不会发生,就会产生活跃性问题,比如单线程中的无限循环

while(true){...}

for(;;){}

在多线程中,比如 aThread 和 bThread 都需要某种资源,aThread 一直占用资源不释放,bThread 一直得不到执行,就会造成活跃性问题,bThread 线程会产生饥饿

性能问题

与活跃性问题密切相关的是 性能 问题,如果说活跃性问题关注的是最终的结果,那么性能问题关注的就是造成结果的过程,性能问题有很多方面:比如**「服务时间过长,吞吐率过低,资源消耗过高」**,在多线程中这样的问题同样存在。

在多线程中,有一个非常重要的性能因素那就是我们上面提到的 线程切换,也称为 上下文切换(Context Switch),这种操作开销很大。

在计算机世界中,老外都喜欢用 context 上下文这个词,这个词涵盖的内容很多,包括上下文切换的资源,寄存器的状态、程序计数器等。context switch 一般指的就是这些上下文切换的资源、寄存器状态、程序计数器的变化等。

在上下文切换中,会保存和恢复上下文,丢失局部性,把大量的时间消耗在线程切换上而不是线程运行上。

为什么线程切换会开销如此之大呢?线程间的切换会涉及到以下几个步骤

image-20230119173658485

将 CPU 从一个线程切换到另一线程涉及挂起当前线程,保存其状态,例如寄存器,然后恢复到要切换的线程的状态,加载新的程序计数器,此时线程切换实际上就已经完成了;此时,CPU 不在执行线程切换代码,进而执行新的和线程关联的代码。

引起线程切换的几种方式

线程间的切换一般是操作系统层面需要考虑的问题,那么引起线程上下文切换有哪几种方式呢?或者说线程切换有哪几种诱因呢?主要有下面几种引起上下文切换的方式

  • 当前正在执行的任务完成,系统的 CPU 正常调度下一个需要运行的线程
  • 当前正在执行的任务遇到 I/O 等阻塞操作,线程调度器挂起此任务,继续调度下一个任务。
  • 多个任务并发抢占锁资源,当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务。
  • 用户的代码挂起当前任务,比如线程执行 sleep 方法,让出CPU。
  • 使用硬件中断的方式引起上下文切换

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

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

相关文章

Linux的ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM及分配页释放页函数的简单介绍

Linux的ZONE_DMA&#xff0c;ZONE_NORMAL,ZONE_HIGHMEM及分配页释放页函数的简单介绍简单介绍一下页&#xff1a;Linux 区&#xff1a;分配页系统调用释放页系统调用简单介绍一下页&#xff1a; 内核把物理页作为内存管理的基本单位。 尽管处理器的最小可寻址单位通常为字(甚至…

ZooKeeper-分布式锁实现

4.11)Zookeeper分布式锁-概念 •在我们进行单机应用开发&#xff0c;涉及并发同步的时候&#xff0c;我们往往采用synchronized或者Lock的方式来解决多线程间的代码同步问题&#xff0c;这时多线程的运行都是在同一个JVM之下&#xff0c;没有任何问题。 •但当我们的应用是分…

【JavaScript】实现简易购物车

&#x1f4bb;【JavaScript】实现简易购物车 &#x1f3e0;专栏&#xff1a;有趣实用案例 &#x1f440;个人主页&#xff1a;繁星学编程&#x1f341; &#x1f9d1;个人简介&#xff1a;一个不断提高自我的平凡人&#x1f680; &#x1f50a;分享方向&#xff1a;目前主攻前端…

客快物流大数据项目(一百零四):为什么选择Elastic Search作为存储服务

文章目录 为什么选择Elastic Search作为存储服务 一、​​​​​​​​​​​​​​ElasticSearch简介

【GD32F427开发板试用】懒人新手试用

本篇文章来自极术社区与兆易创新组织的GD32F427开发板评测活动&#xff0c;更多开发板试用活动请关注极术社区网站。作者&#xff1a;东东_dxGGN2 我收到的开发板是GD32F427R-START&#xff0c;MCU是GD32F427RKT6&#xff0c;如下图&#xff08;座机拍的见谅&#xff09; 测试流…

【C++】从0到1入门C++编程学习笔记 - 核心编程篇:内存分区模型

文章目录一、程序运行前二、程序运行后三、new 操作符C程序在执行时&#xff0c;将内存大方向划分为4个区域 代码区&#xff1a;存放函数体的二进制代码&#xff0c;由操作系统进行管理的全局区&#xff1a;存放全局变量和静态变量以及常量栈区&#xff1a;由编译器自动分配释…

2022年回顾 | 被磨砺,被厚待

岁末年首&#xff0c; 最宜盘点过往的时光。 回顾2022团结一心&#xff0c;攻坚克难&#xff0c; 祝福2023大展宏图&#xff0c;鹏程万里。 2022我们遇到了"卷土重来"、 “挥之不去”&#xff0c; 也等到了"再也不见"和 “永远下线”。 2022是一个&…

HTML中的table标签与a标签

这里写自定义目录标题一、table标签1、什么是table标签2、table标签中长见到的标签3、例子代码及其结果二、a标签1、什么是a标签2、a标签中常见的属性3、例子代码及其结果一、table标签 1、什么是table标签 table标签表示整体的一个表格 2、table标签中长见到的标签 <tr…

基于Spring Boot和Spring Cloud实现微服务架构

首先&#xff0c;最想说的是&#xff0c;当你要学习一套最新的技术时&#xff0c;官网的英文文档是学习的最佳渠道。因为网上流传的多数资料是官网翻译而来&#xff0c;很多描述的重点也都偏向于作者自身碰到的问题&#xff0c;这样就很容易让你理解和操作出现偏差&#xff0c;…

采用特殊硬件指令对密码学算法加速

1. 引言 Armando Faz-Hermandez等人2018年论文《SoK: A Performance Evaluation of Cryptographic Instruction Sets on Modern Architectures》&#xff0c;开源代码见&#xff1a; https://github.com/armfazh/flo-shani-aesni&#xff08;C语言&#xff09; slide见&…

Java高手速成 | 多态性实战

多态性&#xff08;polymorphism&#xff09;是OOP最强大、最有用的特性。截至目前&#xff0c;多态性用到了所讲的所有其他OOP概念和特性。在通向精通Java语言编程的征程上&#xff0c;多态性是最高级别概念站点。 一个对象具有跟另一不同类的对象一样的行为&#xff0c;或者具…

QT5.14.2使用回顾

前面已有博客介绍了QT的安装和配置VS2019配置Qt5.14.2以及在线配置Qt5.15.2&#xff0c;这里再接着该版本说明下QT的使用&#xff0c;主要是汇总下之前博客中的内容&#xff1a;Ubuntu下的基本知识点&#xff08;二&#xff09;QT4.8.6工程到QT5.12.1的迁移注意前面安装时候&am…

小程序开发超好用的UI组件——Vant Weapp

Vant Weapp 是有赞前端团队开源的一套小程序 UI 组件库&#xff0c;助力开发者快速搭建小程序应用。它所使用的是 MIT 开源许可协议&#xff0c;对商业使用比较友好,官网地址&#xff1a;https://vant-contrib.gitee.io/vant-weapp/#/home 安装 Vant 组件库 在小程序项目中&a…

设计模式学习(十):lterator迭代器模式

一、什么是Iterator模式使用Java语言显示数组arr中的元素时&#xff0c;我们可以使用下面这样的for循环语句遍历数组。for (int i 0; i < arr.length; i){system.out.println(arr[i]); }请注意这段代码中的循环变量i。该变量的初始值是o&#xff0c;然后会递增为1,2&#x…

halo 1.4.17 使用Mysql 安装与配置

1 下载代码 https://github.com/halo-dev/halo/archive/refs/tags/v1.4.17.zip 2 查看1.4版本文档 https://docs.halo.run/1.4/ 1.3 使用idea打开并设置jdk 11 1.4 将h2配置成为mysql 修改前 修改后 1.5 打包成jar halo使用的是Gradle&#xff0c;打包时&#xff0c…

微软官宣裁员 10000 人。分享一些我的建议给大家

大家好&#xff01;我是韩老师。昨天&#xff0c;西雅图双雄经历着不眠之夜。早些时间&#xff0c;就有传言说 1 月 18 日&#xff0c;亚马逊会裁员 18000 人。微软要裁员的各种消息也是满天飞。北京时间昨天晚上&#xff0c;微软官方博客发了一篇标题为 Focusing on our short…

10. 元组tuple类型详解

python3 tuple类型的使用 1. 基本知识 a. 元组&#xff08;tuple&#xff09;与列表类似, 不同之处在于元组的元素(项)不能修改。 b. 元组写在小括号 () 里&#xff0c;元素之间用逗号隔开。 c. 元组中的元素类型也可以不相同。 d. 构造包含0个或1个元素的元组比较特殊, 所以…

自增主键为什么不是连续的?

在前面文章中,我们提到过自增主键,由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑。 之前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递…

【深度学习数学基础之线性代数】研究使用链式法则进行反向传播的求导算法

链式法则 简单的说链式法则就是原本y对x求偏导&#xff0c;但是由于过程较为复杂&#xff0c;我们需要将函数进行拆分&#xff0c;通过链式进行分别求导&#xff0c;这样会使整个计算更为简单。 假设f k ( a b c ) f k(a bc)fk(abc) 通俗来说&#xff0c;链式法则表明&a…

宝贝代码部署笔记

记录前后端分离项目部署到云服务器 文章目录1. 启动数据库2. 创建数据库3. 阿里云开放后端项目端口4. 运行SQL文件5. 打包前端文件6. 服务端创建文件夹7. 打包后端jar包8. 安装配置Nginx服务器9. 启动Tomcat10. 项目文件上传部署1. 启动数据库 使用命令cd /opt/mysql/support-…