【多线程】线程安全问题原因与解决方案

news2025/2/25 14:16:05

目录

线程安全的概念

线程不安全示例

线程不安全的原因 

    多个线程修改了同一个变量

    线程是抢占式执行的

    原子性

    内存可见性

    有序性

线程不安全解决办法

 synchronized 关键字-监视器锁monitor lock

    synchronized 的特性

        互斥

        刷新内存

        可重入

    synchronized 使用示例

    Java 标准库中的线程安全类

volatile 关键字

    volatile 能保证内存可见性

    volatile 不保证原子性

    synchronized 也能保证内存可见性


线程安全的概念

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。

线程不安全示例

public class Insecurity {

    // 定义自增操作的对象
    private static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        // 定义两个线程,分别自增5万次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increment();
            }
        });

        // 启动线程
        t1.start();
        t2.start();
        // 等待自增完成
        t1.join();
        t2.join();
        // 打印结果
        System.out.println("count = " + counter.count);

    }
}

class Counter {
    public int count = 0;
    // 自增方法
    public void increment () {
        count++;
    }
}

线程不安全的原因 

    多个线程修改了同一个变量

上面的线程不安全的代码中 , 涉及到多个线程针对 counter.count 变量进行修改。
counter.count 这个变量就是在堆上. 因此可以被多个线程共享访问.

    线程是抢占式执行的

多个线程在CPU上调度是随机的,顺序是不可预知的。

    原子性

要么都执行,要么都不执行。

  1. 从内存把数据读到 CPU
  2. 进行数据更新
  3. 把数据写回到 CPU

    内存可见性

 可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM) : Java 虚拟机规范中定义了 Java 内存模型 .
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果.

  • 线程之间的共享变量存在 主内存 (Main Memory).
  • 每一个线程都有自己的 "工作内存" (Working Memory) .
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
由于每个线程有自己的工作内存 , 这些工作内存中的内容相当于同一个共享变量的 " 副本 ". 此时修改线程1 的工作内存中的值 , 线程 2 的工作内存不一定会及时变化

    有序性

有序性是指编译过程中,JVM调用本地接口,CPU执行指令过程中,指令的有序性。

指令在特殊情况下会打乱顺序,并不是按程序员的预期去执行的。

编译器对于指令重排序的前提是 " 保持逻辑不发生变化 ". 这一点在单线程环境下比较容易判断 , 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高 , 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价 .

线程不安全解决办法

对于多线程修改同一个变量,在真实业务中都是修改同一个变量,无法避免。

对于线程是抢占式执行的,CPU调度是随机的,这里CPU是硬件层面,没办法处理。

剩下就是解决其他三个原因:

public class Main {

    // 定义自增操作的对象
    private static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        // 定义两个线程,分别自增5万次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increment();
            }
        });

        // 启动线程
        t1.start();
        t2.start();
        // 等待自增完成
        t1.join();
        t2.join();
        // 打印结果
        System.out.println("count = " + counter.count);

    }
}

class Counter {
    public volatile int count = 0;
    // 自增方法
    public synchronized void increment () {
        count++;
    }
}

 synchronized 关键字-监视器锁monitor lock

    synchronized 的特性

        互斥

synchronized 会起到互斥效果 , 某个线程执行到某个对象的 synchronized 中时 , 其他线程如果也执行到同一个对象 synchronized 就会 阻塞等待
  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
synchronized 用的锁是存在 Java 对象头里的。
可以粗略理解成 , 每个对象在内存中存储的时候 , 都存有一块内存表示当前的 " 锁定 " 状态 ( 类似于厕所的 " 有人 / 无人 ").
如果当前是 " 无人 " 状态 , 那么就可以使用 , 使用时需要设为 " 有人 " 状态 .
如果当前是 " 有人 " 状态 , 那么其他人无法使用 , 只能排队
理解 " 阻塞等待 ".
针对每一把锁 , 操作系统内部都维护了一个等待队列 . 当这个锁被某个线程占有的时候 , 其他线程尝试进行加锁, 就加不上了 , 就会阻塞等待 , 一直等到之前的线程解锁之后 , 由操作系统唤醒一个新的线程, 再来获取到这个锁 .
注意 :
  • 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也就是操作系统线程调度的一部分工作.
  • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
synchronized 的底层是使用操作系统的 mutex lock 实现的 .

        刷新内存

synchronized 的工作过程 :
  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁
所以 synchronized 也能保证内存可见性 . 具体代码参见后面 volatile 部分 .

        可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
理解 " 把自己锁死 "
一个线程没有释放锁 , 然后又尝试再次加锁 .
代码示例
在下面的代码中 ,
  • increase increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前对象加锁的.
  • 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)
这个代码是完全没问题的 . 因为 synchronized 是可重入锁 .
static class Counter {
    public int count = 0;
    synchronized void increase() {
        count++;
   }
    synchronized void increase2() {
        increase();
   }
}
在可重入锁的内部 , 包含了 " 线程持有者 " " 计数器 " 两个信息 .
  • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

    synchronized 使用示例

synchronized 本质上要修改指定对象的 " 对象头 ". 从使用角度来看 , synchronized 也势必要搭配一个具体的对象来使用.
1) 直接修饰普通方法 : 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
    public synchronized void methond() {
   }
}
2) 修饰静态方法 : 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
    public synchronized static void method() {
   }
}
3) 修饰代码块 : 明确指定锁哪个对象
锁当前对象
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }
}
锁类对象
public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
       }
   }
}

    Java 标准库中的线程安全类

Java 标准库中很多都是线程不安全的 . 这些类可能会涉及到多线程修改共享数据 , 又没有任何措施 .
  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder
但是还有一些是线程安全的 . 使用了一些锁机制来控制 .
  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer
StringBuffer 的核心方法都带有 synchronized
还有的虽然没有加锁 , 但是不涉及 " 修改 ", 仍然是线程安全的
  • String

volatile 关键字

    volatile 能保证内存可见性

volatile 修饰的变量 , 能够保证 " 内存可见性 ".
代码在写入 volatile 修饰的变量的时候 ,
  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候 ,
  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本
前面我们讨论内存可见性时说了 , 直接访问工作内存 ( 实际是 CPU 的寄存器或者 CPU 的缓存 ), 速度非常快, 但是可能出现数据不一致的情况 .
加上 volatile , 强制读写内存 . 速度是慢了 , 但是数据变的更准确了 .
代码示例
在这个代码中
  • 创建两个线程 t1 t2
  • t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.
  • t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.
  • 预期当用户输入非 0 的值的时候, t1 线程结束.
static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (counter.flag == 0) {
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
t1 读的是自己工作内存中的内容 .
t2 flag 变量进行修改 , 此时 t1 感知不到 flag 的变化 .
如果给 flag 加上 volatile
static class Counter {
    public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.

    volatile 不保证原子性

volatile synchronized 有着本质的区别 . synchronized 能够保证原子性 , volatile 保证的是内存可见性.

代码示例
这个是最初的演示线程安全的代码 .
  • increase 方法去掉 synchronized
  • count 加上 volatile 关键字.
static class Counter {
    volatile public int count = 0;
    void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(counter.count);
}
此时可以看到 , 最终 count 的值仍然无法保证是 100000.

    synchronized 也能保证内存可见性

synchronized 既能保证原子性 , 也能保证内存可见性 .
对上面的代码进行调整 :
  • 去掉 flag volatile
  • t1 的循环内部加上 synchronized, 并借助 counter 对象加锁.
static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (true) {
            synchronized (counter) {
                if (counter.flag != 0) {
                    break;
               }
           }
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}

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

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

相关文章

【第三章:存储系统】

目录 知识框架No.0 引言No.1 存储器概述No.2 主存储器一、SRAM芯片和DRAM芯片二、只读存储器三、主存储器的基本组成1、基本的半导体元件和原理 知识框架 No.0 引言 这一章节主要是&#xff1a;这些二进制的数据在计算机内部如何存储 在学习这个章节之前&#xff0c;首先把下面…

Mybatis - 基础

文章目录 一、 Mybatis基本介绍二、 Mybatis 快速入门程序2.1 引入Mybatis依赖2.2 准备工作2.3 配置SQL信息2.3.1 IDEA连接数据库2.3.2 打开日志信息 2.4 JDBC 了解2.5 数据库连接池2.5.1 Druid数据库连接池 三、 Mybatis 基础3.1 环境准备3.1.1 数据库表3.1.2 实体类 3.2 基础…

EasyRecovery16中文最新版电脑数据恢复软件下载使用教程

EasyRecovery如果需要使用它来恢复数据&#xff0c;请注意&#xff0c;尤其是当需要恢复的数据文件非常重要时&#xff0c;建议使用软件EasyRecovery以保障数据安全。共有三个版本&#xff0c;分别是个人版、专业版、企业版&#xff0c;这三种都可以免费下载并使用&#xff0c;…

[MySQL]关于MySQL索引的一点点东西

最是人间留不住,朱颜辞镜花辞树. 目录 一.为什么需要索引 1.什么是索引 2.索引的创建原则 二.理解索引 1. MySQL与磁盘交互基本单位 2.索引的结构 a.关于hash索引,B树索引,B树索引的特点 B树 B树 HASH b.为什么使用InnoDB 存储引…

15-721 chapter 13 查询执行

优化的目标 CPU层面 cpu是多级流水线操作&#xff0c;所以我们的目标是让每个处理器的每个部分都处于busy。多条流水线&#xff0c;我们没有依赖的指令可以放到不同的流水线里面。但是流水线如果遇到branch判断错误的话&#xff0c;就要flush掉 可以用值传递来代替跳转 查询执…

Linux开发板安装Python环境

1. 环境介绍 硬件&#xff1a;STM32MP157&#xff0c;使用的是野火出的开发板。 软件&#xff1a;Debian ARM 架构制作的 Linux 发行版&#xff0c;版本信息如下&#xff1a; Linux发行版本&#xff1a;Debian GNU/Linux 10 内核版本&#xff1a;4.19.94 2. Python 简介…

基于常用设计模式的业务框架

前言 做开发也有好几年时间了&#xff0c;最近总结和梳理自己在工作中遇到的一些问题&#xff0c;工作中最容易写出BUG的需求就是改造需求了。一个成熟的业务系统是需要经过无数次迭代而成的&#xff0c;也意味着经过很多开发人员之手&#xff0c;最后到你这里&#xff0c;大部…

每日学术速递5.11

CV - 计算机视觉 | ML - 机器学习 | RL - 强化学习 | NLP 自然语言处理 Subjects: cs.CV 1.Diffusion Explainer: Visual Explanation for Text-to-image Stable Diffusion 标题&#xff1a;扩散解释器&#xff1a;文本到图像稳定扩散的视觉解释 作者&#xff1a;Seongmin…

【数据结构】【算法】二叉树、二叉排序树、树的相关操作

树结构是以分支关系定义的一种层次结构&#xff0c;应用树结构组织起来的数据&#xff0c;逻辑上都具有明显的层次关系。 操作系统中的文件管理系统、网络系统中的域名管理、数据库系统中的索引管理等都使用了树结构来组织和管理数据。 树的基本概念 树Tree是由n个节点组成的有…

数据结构-查找-散列结构(散列表)

目录 *一、散列表 二、散列函数 *除留余数法 *直接定址法 数字分析法 平方取中法 三、冲突处理方法--开发定地法 *3.1线性探测法 *查找效率&#xff1a; *3.2平方探测法 3.3伪随机序列法 3.4再散列法 *一、散列表 又称哈希表&#xff0c;数据元素的关键字与其存储…

bgp团体属性配置案例一

RouterA的配置 sysname RouterA interface GigabitEthernet1/0/0 ip address 192.168.0.1 255.255.255.0 interface LoopBack0 ip address 1.1.1.1 255.255.255.255 bgp 10 router-id 1.1.1.1 //Router ID&#xff0c;建议配置为LoopBack0的IP地址 peer 192.168.0.2 as-number …

【Linux】信号的处理

信号篇终章 文章目录 前言一、信号的处理 1.可重入函数 2.volatile关键字 3.SIGCHLD信号总结 前言 在前两篇linux文章中我们详细的讲解了信号的产生和信号的保存&#xff0c;今天来到最后一个重点信号的处理&#xff0c;对于信号的处理我们会重新引入进程…

如何实现一个高效的H264信源编码器?了解核心算法和实现流程

H264 H264是一种常用的视频编码标准&#xff0c;它以网络传输和存储为设计目的&#xff0c;能够将视频信号进行高效压缩&#xff0c;并保持较高的视频质量。 H264视频编码标准采用的是基于帧的编码方式。每一帧视频都被分为不同的块&#xff0c;每个块中都包含了可压缩的信息…

11.PC端网页特效

PC端网页特效 1. 元素偏移量 offset 系列 1.1 offset 概述 offset 翻译过来就是偏移量&#xff0c; 使用 offset 系列相关属性可以动态的得到该元素的位置&#xff08;偏移&#xff09;、大小等 获得元素距离带有定位父元素的位置获得元素自身的大小&#xff08;宽度高度&a…

03:MYSQL----DQL,聚合函数

目录 1:介绍 2:语法 3:聚合函数 4:DOL 语句练习 5:SQL执行顺序 1:介绍 数据查询语言&#xff0c;用来查询数据库中表的记录 2:语法 select 字段列表 from 表名列表 where 条件列表 group by 分组字段列表 having 分组后字段列表 order by 排序字段列表 limit 分页参…

Vben Admin 自学记录 —— 使用 mock 模拟数据以及模拟api联调接口(持续更新中...)

Vben Admin —— 使用 mock 模拟数据以及模拟api联调接口 数据 mock&联调相关概念及使用 练习 —— 在之前table基础上&#xff0c;使用mock模拟数据&#xff0c;替换原来的死数据&#xff0c;添加新增、查看、修改和删除api并添加逻辑&#xff0c;实现一个简单的、完整的…

《编程思维与实践》1067.小型组合数

《编程思维与实践》1067.小型组合数 题目 思路 法一: 注意到题目数据最大为 C 40 20 137846528820 C_{40}^{20}137846528820 C4020​137846528820在long long的范围内,所以其实可以不用大整数的处理方法去计算: 由于 C m n m ! n ! ( m − n ) ! m ( m − 1 ) . . . ( m −…

mysql数据库的库操作 --2

目录 库操作 2.1&#xff1a;数据库的查看与创建与使用 2.2&#xff1a;字符集和效验规则 2.3&#xff1a;修改和删除数据库 2.4&#xff1a;数据库的备份和恢复 2.5&#xff1a;查看连接情况 库操作 2.1&#xff1a;数据库的查看与创建与使用 2.1.1&#xff1a;数据库…

AcWing算法提高课-1.3.9庆功会

宣传一下算法提高课整理 <— CSDN个人主页&#xff1a;更好的阅读体验 <— 本题链接&#xff08;AcWing&#xff09; 点这里 题目描述 为了庆贺班级在校运动会上取得全校第一名成绩&#xff0c;班主任决定开一场庆功会&#xff0c;为此拨款购买奖品犒劳运动员。 期望…

[golang gin框架] 32.Gin 商城项目- 支付宝支付操作相关功能讲解

一.支付宝支付之前的准备工作 创建应用、配置签名、提交审核 支付宝支付之前的准备工作 支付宝开放平台支持使用 普通公钥、公钥证书 两种签名方式 公钥证书模式下完成支付需要获取的内容&#xff1a; appId 应用私钥 应用公钥证书 支付宝根证书 支付宝公钥证书 普通公钥模式下…