Java多线程之线程安全问题

文章目录

  • 一. 线程安全概述
    • 1. 什么是线程安全问题
    • 2. 一个存在线程安全问题的程序
  • 二. 线程不安全的原因和线程加锁
    • 1. 案例分析
    • 2. 线程加锁
      • 2.1 理解加锁
      • 2.2 synchronized的使用
      • 2.3 再次分析案例
    • 3. 线程不安全的原因
  • 三. 线程安全的标准类

一. 线程安全概述

1. 什么是线程安全问题

我们知道操作系统中线程程的调度是抢占式执行的, 宏观上上的感知是随机的, 这就导致了多线程在进行线程调度时线程的执行顺序是不确定的, 因此多线程情况下的代码的执行顺序可能就会有无数种, 我们需要保证这无数种线程调度顺序的情况下, 代码的执行结果都是正确的, 只要有一种情况下, 代码的结果没有达到预期, 就认为线程是不安全的, 对于多线程并发时会使程序出现BUG的代码称作线程不安全的代码, 这就是线程安全问题.

2. 一个存在线程安全问题的程序

定义一个变量count, 初始值为0, 我们想要利用两个线程将变量count自增10万次, 每个线程各自负责5万次的自增任务.

于是写出了如下代码:

class Counter {public int count = 0;public void add() {count++;}
}public class TestDemo12 {public static void main(String[] args) {Counter counter = new Counter();// 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});// 启动线程t1.start();t2.start();// 等待两个线程结束try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}// 打印最终的 count 值System.out.println("count = " + counter.count);}
}

执行结果:

img

我们预期的结果应该时10万, 但得到得结果明显要比10万小很多, 你可以尝试将程序多运行几次你会发现程序每次运行的结果都不一样, 但绝大部分结果, 都会比预期值要小, 下面就来分析这种结出现的原因.

二. 线程不安全的原因和线程加锁

1. 案例分析

在上面, 我们使用多线程所写的程序将将一个初始值为0的变量自增10万次, 但得到的实际得到的结果要比预期的10万小, 万恶之源还是线程的抢占式执行, 线程调度的顺序是随机的, 就造成线程间自增的指令集交叉, 导致运行时出现两次或者多次自增但值只会自增一次的情况, 导致得到的结果会偏小.

一次的自增操作本质上可以分成三步:

  1. 把内存中变量的值读取到CPU的寄存器中(load).
  2. 在寄存器中执行自增操作(add)
  3. 将寄存器的值保存至内存中(save)

如果是两个线程并发的执行count++, 此时就相当于两组 load, add, save进行执行, 此时不同的线程调度顺序就可能会产生一些结果上的差异.

下面的时间轴总结了一个变量由两个线程并发进行两次自增时, 常见几种常见的情况:

  • 情况1

线程间指令集无交叉, 实际结果和预期结果一致.

img

  • 情况2

线程间指令集存在交叉, 实际结果小于预期结果.

img

  • 情况3

线程间指令集完全交叉, 实际结果小于预期结果.

img

上面列举的三种情况并不是所有可能状况, 其他状况也类似, 可以自己尝试推导一下, 观察上面列出的情况情况, 我们不难发现出当多线程的指令集没有交叉情况出现的时侯, 程序就可以得到正确的结果; 而一旦指令集间有了交叉, 结果就可能会比预期的要小, 也就是说造成这里线程安全问题的原因在于这里的自增操作不是原子性的.

那么再观察上面有问题的结果, 思考结果一定是大于5万吗, 其实不一定, 只是这种可能性比较小, 当线程当t2自增两次或多次,t1只自增一次, 最后的效果是加1.

img

当然也有可能最后计算出来的结果是正确的, 不过再这种有问题的情况下可能就更小了, 但并不能说完全没有可能.

那么如何解决上面的线程安全问题呢, 我们只需要想办法让自增操作变成原子性的即可, 也就是让load, add, save三步编程一个整体, 也就是下面介绍的对对象加锁.

2. 线程加锁

2.1 理解加锁

为了解决由于 “抢占式执行” 所导致的线程安全问题, 我们可以针对当前所操作的对象进行加锁, 当一个线程拿到该对象的锁后, 就会将该对象锁起来, 其他线程如果需要执行该对象所限制任务时, 需要等待该线程执行完该对象这里的任务后才可以.

用现实生活中的例子来理解, 假设小明要去银行的ATM机子上办理业务, 我们知道为了安全, 每台ATM一般都在一个单独的小房间里面, 这个小房间由一扇门和一把锁, 当小明进入房间使用ATM时, 门就会自动锁上, 此时如果其他人想要使用这台ATM就得等小明使用完从房间里面出来才行, 那么这里的 “小明” 就相当于一个线程, ATM就相当于一个对象, 房间就相当于一把锁, 其他想使用这台ATM机子的人就相当于其他的线程.

img

imgimg

在Java中最常用的加锁操作就是使用synchronized关键字进行加锁.

2.2 synchronized的使用

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.

线程进入 synchronized 修饰的代码块, 相当于加锁, 退出 synchronized 修饰的代码块, 相当于解锁.

  • 使用方式1

使用synchronized关键字修饰普通方法, 这样会给方法所对在的对象加上一把锁.

以上面的自增代码为例, 对add()方法和加锁, 实质上是个一个对象加锁, 在这里这个锁对象就是this.

class Counter {public int count = 0;synchronized public void add() {count++;}
}

对代码做出如上修改后, 执行结果如下:

img

  • 使用方式2

使用synchronized关键字对代码段进行加锁, 需要显式指定加锁的对象.

还是基于最开始的代码进行修改, 如下:

class Counter {public int count = 0;public void add() {synchronized (this) {count++;}}
}

执行结果:

img

  • 使用方式3

使用synchronized关键字修饰静态方法, 相当于对当前类的类对象进行加锁.

class Counter {public static int count = 0;synchronized public static void add() {count++;}
}

执行结果:

img

2.3 再次分析案例

我们这里再来分析一下, 为什么上锁之后, 线程就安全了, 代码如下:

class Counter {public int count = 0;public void add() {count++;}
}public class TestDemo12 {public static void main(String[] args) {Counter counter = new Counter();// 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});// 启动线程t1.start();t2.start();// 等待两个线程结束try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}// 打印最终的 count 值System.out.println("count = " + counter.count);}
}

加锁, 其实就是想要保证这里自增操作 load, add, save的原子性, 但这里上锁后并不是说让这三步一次完成, 也不是在执行这三步过程中其他线程不进行调度, 加锁后其实是让其他想操作的线程阻塞等待了.

比如我们考虑两个线程指令集交叉的情况下, 加锁操作是如何保证线程安全的, 不妨记加锁为lock,解锁为unlock, t1和t2两个线程的运行过程如下:

t1线程首先获取到目标对象的锁, 对对象进行了加锁, 处于lock状态, t1线程load操作之后, 此时t2线程来执行自增操作时会发生阻塞, 直到t1线程的自增操作执行完成后, 释放锁变为unlock状态, 线程才能成功获取到锁开始执行load操作… , 如果有两个以上的线程以此类推…

img

加锁本质上就是把并发变成了串行执行, 这样的话这里的自增操作其实和单线程是差不多的, 甚至上由于add方法, 要做的事情多了加锁和解锁的开销, 多线程完成自增可能比单线程的开销还要大, 那么多线程是不是就没用了呢? 其实不然, 对方法加锁后, 线程运行该方法才会加锁, 执行完该方法的操作后就会解锁, 此方法外的代码并没有受到限制, 这部分程序还是可以多线程并发执行的, 这样整体上多线程的执行效率还是要比单线程要高许多的.

注意:

  • 加锁, 一定要明确是对哪个对象加的锁, 如果两个线程针对同一个对象加锁, 会产生阻塞等待(锁竞争/锁冲突); 而如果两个线程针对不同对象加锁, 不会产生锁冲突.

3. 线程不安全的原因

  1. 最根本的原因: 抢占式执行, 随机调度, 这个原因我们无法解决.
  2. 代码结构.

我们最初给出的代码之所以有线程安全的原因, 是因为我们设计的代码是让两个线程同时去修改一个相同的变量.

如果我们将代码设计成一个线程修改一个变量, 多个线程读取同一个变量, 多个线程修改多个不同的变量等, 这些情况下, 都是线程安全的; 所以我们可以通过调整代码结构来规避这个问题, 但代码结构是来源于需求的, 这种调整有时候不是一个普适性特别高的方案.

  1. 原子性.

如果我们的多线程操作中修改操作是原子的, 那出问题的概率还比较小, 如果是非原子的, 出现问题的概率就非常高了, 就比如我们最开头写的程序以及上面的分析.

  1. 指令重排序和内存可见性问题

主要是由于编译器优化造成的指令重排序和内存可见性无法保证, 就是当线程频繁地对同一个变量进行读取操作时, 一开始会读内存中的值, 到了后面可能就不会读取内存中的值了, 而是会直接从寄存器上读值, 这样如果内存中的值做出修改时, 线程就感知不到这个变量已经被修改, 就会导致线程安全问题, 归根结底这是编译器优化的结果, 编译器/jvm在多线程环境下产生了误判, 结合下面的代码进行理解:

import java.util.Scanner;class MyCounter {volatile public int flag = 0;
}public class TestDemo13 {public static void main(String[] args) {MyCounter myCounter = new MyCounter();Thread t1 = new Thread(() -> {while (myCounter.flag == 0) {// 这个循环体咱们就空着}System.out.println("t1 循环结束");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入一个整数: ");myCounter.flag = scanner.nextInt();});t1.start();t2.start();}
}

执行结果:

img

上面的代码中, t2线程修改flag的值让t1线程结束, 但当我们修改了flag的值后线程t1线程并没有终止, 这就是编译优化导致线程感知不到内存的变化, 从而导致线程不安全.

while (myCounter.flag == 0) {
// 这个循环体咱们就空着
}

t1线程中的这段代码用汇编来理解, 大概是下面两步操作:

  1. load, 把内存中flag的值读取到寄存器中.
  2. cmp, 把寄存器的值和0进行比较, 根据比较结果, 决定下一步往哪个地方执行(条件跳转指令).

要知道, 计算机中上面这个循环的执行速度是极快的, 一秒钟执行百万次以上, 在这许多次循环中, 在t2真正修改之前, load得到的结果都是一样的, 另一方面, CPU 针对寄存器的操作, 要比内存操作快很多, 也就是说load操作和cmp操作相比, 速度要慢的多, 此时jvm就针对这些操作做出了优化, jvm判定好像是没人修改flag的值的, 于是在之后就不再真正的重复load, 而是直接读取寄存器当中的值.

所以总结这里的内存可见性问题就是, 一个线程针对一个变量进行读取操作, 同时另一个线程针对这个变量进行修改, 此时读到的值, 不一定是修改之后的值, 这个读线程没有感知到变量的变化.

但实际上flag的值是有人修改的, 为了解决这个问题, 我们可以使用volatile关键字保证内存可见性, 我们可以给flag这个变量加上volatile关键字, 意思就是告诉编译器,这个变量是 “易变” 的, 一定要每次都重新读取这个变量的内存内容, 不可以进行优化了.

class MyCounter {volatile public int flag = 0;
}

修改后的执行结果:

img

编译器优化除了导致的内存可见性问题会有线程安全问题, 还有指令重排序也会导致线程安全问题, 指令重排序通俗点来讲就是编译器觉得你写的代码太垃圾了, 就把你的代码自作主张进行了调整, 也就是编译器会智能的在保持原有逻辑不变的情况下, 调整代码的执行顺序, 从而加快程序的执行效率.

上面所说的原因并不是造成线程安全的全部原因, 一个代码究竟是线程安全还是不安全, 都得具体问题具体分析, 难以一概而论, 如果一个代码踩中了上面的原因,也可能是线程安全, 而如果一个代码没踩中上面的原因,也可能是线程不安全的, 我们写出的多线程代码, 只要不出bug, 就是线程安全的.
JMM模型 :
在看内存可见性问题时, 还可能碰到JMM(Java Memory Model)模型, 这里简单介绍一下, JMM其实就是把操作系统中的寄存器, 缓存(cache)和内存重新封装了一下, 在JMM中寄存器和缓存称为工作内存, 内存称为主内存; 其中缓存和寄存器一样是在CPU上的, 分为一级缓存L1, 二级缓存L2和三级缓存L3, 从L1到L3空间越来越大, 最大也比内存空间小, 最小也比寄存器空间大,访问速度越来越慢, 最慢也比内存的访问速度快, 最快也没有寄存器访问快.

synchronized与volatile关键字的区别:
synchronized关键字能保证原子性, 但是是否能够保证内存可见性是不一定的, 而volatile关键字只能保证内存可见性不能保证原子性.

三. 线程安全的标准类

Java 标准库中很多都是线程不安全的, 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施, 这些类在多线代码中使用要格外注意,下面列出的就是一些线程不安全的集合:

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的, 使用了一些锁机制来控制, 如下:

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

比如我们可以看一下StringBuffer中的方法, 绝大多数都是加锁了的.

img

还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的:

  • String

我们需要的知道的是加速操作是有副作用的, 在加锁的同时, 会带来额外的时间开销, 那些线程安全的类已经强制加锁了, 但有些情况下, 不使用多线程是没有线程安全问题的, 这个时候使用那些线程不安全感的类更好一些, 而且使用这些线程不安全的类更灵活, 就算面临线程安全问题, 我们可以自行手动加锁, 有更多的选择空间.

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

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

相关文章

基于C#实现赫夫曼树

赫夫曼树又称最优二叉树&#xff0c;也就是带权路径最短的树&#xff0c;对于赫夫曼树&#xff0c;我想大家对它是非常的熟悉&#xff0c;也知道它的应用场景&#xff0c;但是有没有自己亲手写过&#xff0c;这个我就不清楚了&#xff0c;不管以前写没写&#xff0c;这一篇我们…

【LeetCode刷题笔记】DFSBFS(二)

994. 腐烂的橘子(树/图的BFS问题) 解题思路: 多源BFS ,首选找到 所有的腐烂的橘子 ,放入队列中,然后进行 BFS 广搜,广搜的 层数 - 1 就是所需要花费的分钟数。 在最开始先扫描一遍二维数组,将所有的 腐烂的橘子 加入 队列 ,同时统计新鲜橘子的数量 <

Blender烘焙AO操作及对应的python代码

&#xff08;一&#xff09;Blender软件操作 1. 导入模型&#xff08;这里省略&#xff09; 2. 材质设置 模型使用的所有材质都需要删除Surface Shader&#xff0c;没有其他多余的计算&#xff0c;可以大量缩短烘焙时间。删除之后的只留下一个材质输出节点&#xff0c;如图所…

CentOS Stream 9系统Cgroup问题处理

安装docker容器启动失败 之前适配过Ubuntu系统的容器&#xff0c;由于版本比较高&#xff0c;没有挂载Cgroup的路径。这次使用Centos Stream 9系统安装docker容器时也遇到了这个情况。由于处理方式有些不一样&#xff0c;所以记录一下。 这是docker容器启动过报错的输出日志。…

Haclon简介及数据类型

Haclon简介 HALCON是由德国MVtec公司开发的机器视觉算法包&#xff0c;它由一千多个各自独立的函数&#xff08;算子&#xff09;构成&#xff0c;其中除了包含各类滤波、色彩以及几何、数学转换、形态学计算分析、图像校正&#xff0c;目标分类辨识、形状搜寻等基本的图像处理…

C/C++文件操作————写文件与读文件以及通讯录的改进 (保姆级教学)

个人主页&#xff1a;点我进入主页 专栏分类&#xff1a;C语言初阶 C语言程序设计————KTV C语言小游戏 C语言进阶 C语言刷题 欢迎大家点赞&#xff0c;评论&#xff0c;收藏。 一起努力&#xff0c;一起奔赴大厂。 目录 1.前言 2.写文件函数与读文件函数 …

多个JDK版本可以吗:JDK17、JDK19、JDK1.8轻松切换(无坑版)小白也可以看懂

多个版本JDK切换 多个JDK&#xff1a;JDK17、JDK19、JDK1.8轻松切换&#xff08;无坑版&#xff09;小白也可以看懂 提示&#xff1a;看了网上很多教程&#xff0c;5w观看、32w观看、几千观看的&#xff0c;多多少少带点坑&#xff0c;这里我就把踩过的坑都给抹了 文章目录 多…

「Verilog学习笔记」不重叠序列检测

专栏前言 本专栏的内容主要是记录本人学习Verilog过程中的一些知识点&#xff0c;刷题网站用的是牛客网 题目要求检测a的序列&#xff0c;a为单bit输入&#xff0c;每个时刻可能具有不同的值&#xff0c; 当连续的六个输入值符合目标序列表示序列匹配&#xff0c;当六个输入值的…

Redisson分布式锁源码解析、集群环境存在的问题

一、使用Redisson步骤 Redisson各个锁基本所用Redisson各个锁基本所用Redisson各个锁基本所用 二、源码解析 lock锁 1&#xff09; 基本思想&#xff1a; lock有两种方法 一种是空参 另一种是带参 * 空参方法&#xff1a;会默认调用看门狗的过期时间30*1000&…

内网穿透的应用-如何在本地安装Flask,以及将其web界面发布到公网上并进行远程访问

轻量级web开发框架&#xff1a;Flask本地部署及实现公网访问界面 文章目录 轻量级web开发框架&#xff1a;Flask本地部署及实现公网访问界面前言1. 安装部署Flask2. 安装Cpolar内网穿透3. 配置Flask的web界面公网访问地址4. 公网远程访问Flask的web界面 前言 本篇文章讲解如何…

『亚马逊云科技产品测评』活动征文|通过Lightsail搭建个人笔记

提示&#xff1a;授权声明&#xff1a;本篇文章授权活动官方亚马逊云科技文章转发、改写权&#xff0c;包括不限于在 Developer Centre, 知乎&#xff0c;自媒体平台&#xff0c;第三方开发者媒体等亚马逊云科技官方渠道 文章目录 前言实践知识储备Lightsail介绍Leanote介绍实践…

VSCode插件koroFileHeader的使用。

文章目录 前言一、koroFileHeader是什么&#xff1f;二、使用步骤1.安装1.配置2.食用 前言 今天的天气还不错&#xff0c;真是金风玉露一相逢&#xff0c;便胜却人间无数&#xff0c;写篇博客玩玩&#xff0c;主题&#xff1a;注释。注释的本质就是对代码的解释和说明&#xf…

STM32_6(TIM)

TIM定时器&#xff08;第一部分&#xff09; TIM&#xff08;Timer&#xff09;定时器定时器可以对输入的时钟进行计数&#xff0c;并在计数值达到设定值时触发中断16位计数器、预分频器、自动重装寄存器的时基单元&#xff0c;在72MHz计数时钟下可以实现最大59.65s的定时不仅…

在游戏开发中,实时渲染和离线渲染对于游戏平衡的影响有哪些?

实时渲染和离线渲染对游戏平衡有那些影响呢&#xff1f;在游戏开发中&#xff0c;渲染方式的选择对游戏的整体表现和玩家体验有着至关重要的作用。那么&#xff0c;实时渲染和离线渲染究竟有哪些利弊呢&#xff1f; 一、实时渲染 实时渲染&#xff0c;顾名思义&#xff0c;是…

Ubuntu 1.84.2Visual Studio Code 下载配置与vscode查看内存Hex Editor插件,简单易懂

目录 前言 一 首先我为啥要重装Vs Code呢&#xff1f; 二 下载1.84.2Visual Studio Code 三 配置Vscode终端字体 四 安装插件 前言 这是一篇将老版本的VsCode下载至最新版的博文&#xff0c;从下载到调试全篇 一 首先我为啥要重装Vs Code呢&#xff1f; 因为我想安装这个…

1、postman的安装及使用

一、安装、登录 1.安装 下载地址 2.注册登录&#xff08;保存云服务进度&#xff09; 二、界面介绍 三、执行接口测试页面 请求页签&#xff1a; 1、params&#xff1a;当是get请求时&#xff0c;通过params传参 2、authorization&#xff1a;鉴权 3、headers&#xff1…

idea 26 个天花板技巧

1、 查看代码历史版本&#xff1b;2、 调整idea的虚拟内存&#xff1a;&#xff1b;3、 idea设置成eclipse的快捷键&#xff1b;4、 设置提示词忽略大小写&#xff1b;5、 关闭代码检查&#xff1b;6、 设置文档注释模板&#xff1b;7、 显示方法分隔符&#xff1b;8、 设置多行…

DAOS低时延与高性能RDMA网络

什么是RDMA RDMA&#xff08;Remote Direct Memory Access&#xff09;远程直接内存访问是一种技术&#xff0c;它使两台联网的计算机能够在主内存中交换数据&#xff0c;而无需依赖任何一台计算机的处理器、缓存或操作系统。与基于本地的直接内存访问 ( DMA ) 一样&#xff0c…

03-详细介绍Stream及其常用API

Stream API Stream API(java.util.stream)把真正的函数式编程风格引入到Java中,可以极大地提高程序员生产力&#xff0c;让程序员写出高效、简洁的代码 实际开发中项目中多数数据源都是来自MySQL、Oracle等关系型数据库,还有部分来自MongDB、Redis等非关系型数据库 从关系型…