线程安全问题及其解决

news2024/9/20 22:33:37

文章目录

  • 一. 线程安全问题
    • 1.1 线程不安全的例子
    • 1.2 线程不安全的原因
      • 1.2.1 随即调度, 抢占式执行
      • 1.2.2 修改共享数据
      • 1.2.3 修改操作非原子性
      • 1.2.4 内存可见性
      • 1.2.5 指令重排序
      • 1.2.6 总结
  • 二. 线程安全问题的解决
    • 2.1 synchronized(解决前三个问题)
      • 2.1.1 synchronized 的锁是什么
      • 2.1.2 synchronized 的特性
    • 2.2 volatile关键字(解决第四,五个问题)
      • 2.2.1 volatile 能保证内存可见性
      • 2.2.2 volatile不保证原子性

一. 线程安全问题

当我们使用多个线程访问同一资源(可以是同一变量, 同一个文件, 同一条记录等) 的时候, 若多个过程只有读操作, 那么不会发生线程安全问题. 但是如果多个线程中对资源有读和写的操作, 就容易出现线程安全问题.

1.1 线程不安全的例子

class Counter {
    public int count = 0;
    public void increase() {
        count+=1;
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        
        thread1.start();
        thread2.start();
        
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}

我们认为count应该是100000, 但是实际输出的却不是, 这就说明上述代码有线程安全问题.

1.2 线程不安全的原因

1.2.1 随即调度, 抢占式执行

线程的调度不是按顺序的, 而是抢占式的, 这是系统规定, 我们无法修改.

1.2.2 修改共享数据

上面线程不安全的代码中, 涉及多个线程对counter.count变量进行修改, 此时这个counter.count是一个多线程都能访问到的"共享数据".

在这里插入图片描述

counter.count这个变量就在堆上, 因此可以被多个线程共享访问.

1.2.3 修改操作非原子性

什么是原子性?

我们把一段代码想象成一个房间, 每个线程就是要进入这个房间的人. 如果没有任何机制保证, A进入房间之后, 还没有出来; B 是不是也可以进入房间, 打断 A 在房间里的隐私. 这个就是不具备原子性的.

那我们应该如何解决这个问题呢? 是不是只要给房间加一把锁, A 进去就把门锁上, 其他人是不是就进不来了. 这样就保证了这段代码的原子性了.

有时也把这个现象叫做同步互斥, 表示操作是互相排斥的.

一条Java语句不一定是原子的, 也不一定只是一条指令.

比如上述代码中的count+=1其实是三个操作组成的:

  • 从内存把数据读到CPU
  • 进行数据更新
  • 把数据写回到CPU
时间t1t2
T1load
T2load
T3add
T4save
T5add
T6save

在这里插入图片描述

不保证原子性会带来的问题: 如果一个线程正在对一个变量操作, 中途其他线程插入进来了, 如果这个操作被打断了, 结果就可能是错误的.

这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.

1.2.4 内存可见性

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

我们先来看下面的代码

public class Demo {
    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                ;
            }
            System.out.println("t1 end");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("其输入isQuit的值");
            isQuit = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们先输入1, 结果线程t1还未停止.

在这里插入图片描述

在这里插入图片描述

我们想让线程t1在isQuit非零时停止, 但事实并非所愿, 这就是内存可见性引发的线程安全问题.

程序在编译运行的时候, Java编译器和JVM可能会对代码作出一些"优化", 在保持原有逻辑不变的情况下, 提高代码的执行效率, 这就称为 编译器优化.

编译器优化本质是靠代码智能地对代码进行分析判断, 进行调整. 这个调整过程大部分情况下都能保持逻辑不变, 但是如果遇到多线程, 就可能会发生差错, 逻辑改变.

while (isQuit == 0)

本质上是两个指令: 一是读内存; 二是比较并跳转

比较操作是在寄存器上进行的, 速度十分快, 相较之下, 读内存操作就会显得很慢.

此时, JVM就会反应到, 这个代码要反复读取同一个内存值, 读出的结果还都是一样的, 于是编译器就直接把读内存这个指令给优化掉了, 只读一次内存, 后续直接拿寄存器中的数据比较, 大大加快了执行速度.

但是, JVM没有预料到我们会在其他线程修改isQuit的值, 编译器没法准确判定出t2线程会不会执行, 什么时候执行, 因此就出现了误判.

虽然其他线程把值修改了, 但是另一个线程中没有重复读取isQuit的值, 这就引发了内存可见性的问题.

1.2.5 指令重排序

指令重排序也是编译器优化的一种手段, 在保证原有逻辑不发生变化的情况下, 对代码执行的顺序进行调整, 使调整后的执行效率变高.

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

1.2.6 总结

线程安全问题的原因:

  • 根本原因: 多个线程之间的调度顺序是"随机的", 操作系统使用"抢占式"执行的策略来调度线程.
    • 当前主流的操作系统都是抢占式执行的.
  • 多个线程同时修改同一个变量.
  • 此处进行的修改不是"原子的".
  • 内存可见性引起的线程安全问题.
  • 指令重排序引起的线程安全问题.

二. 线程安全问题的解决

2.1 synchronized(解决前三个问题)

为了保证每个线程都能正常执行原子操作, Java引入了线程同步机制. 注意: 在任何时候, 最多允许一个线程拥有同步锁, 谁拿到锁就进入代码块, 其他的线程只能在外等着( BLOCKED) .

同步机制的原理, 其实就相当于给某段代码加“锁”, 任何线程想要执行这段代码, 都要先获得“锁”, 我们称它为同步锁.

2.1.1 synchronized 的锁是什么

同步代码块: synchronized 关键字可以用于某个区块前面, 表示只对这个区块的资源实行互斥访问.

synchronized(加锁的对象){
     //需要同步操作的代码
}

public void increase() {
    synchronized(this) {
        count+=1;
    }
}

同步方法: synchronized 关键字直接修饰方法, 表示同一时刻只有一个线程能进入这个方法, 其他线程在外面等着.

public synchronized void method(){
    //可能会产生线程安全问题的代码
}

public synchronized void increase() {
    count+=1;
}

synchronized 进行加锁解锁, 是以对象为维度进行的. 使用synchronized 的时候, 其实是指定了某个具体对象进行加锁.

对于同步代码块来说, 同步锁对象是由程序员手动指定的 ; 但是对于同步方法来说, 同步锁对象只能是默认的:

  • 静态方法: 默认加锁对象是当前类的Class对象(类名.class)

    public synchronized static void method(){
        //可能会产生线程安全问题的代码
    }
    //相当于
    public static void method(){
        synchronized(类名.class) {
            //可能会产生线程安全问题的代码
        }
    }
    
  • 非静态方法: 默认加锁对象是this

如果多个线程对同一个对象进行加锁, 就会出现锁竞争; 如果是多个对象针对不同的对象进行加锁, 不会产生锁竞争.

class Counter {
    public int count = 0;
    public void increase() {
        synchronized(this) {//对this加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        // 两个线程对同一个对象(this -> counter)加锁, 那么结果就是100000
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}
class Counter {
    public int count = 0;
    private Object locker = new Object();
    public void increase() {
        synchronized(this) {//对this加锁
            count+=1;
        }
    }
    public void increase2() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
            }
        });
        // 两个线程对不同对象(this -> counter / locker)加锁, 那么结果就不是100000.
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}
class Counter {
    public int count = 0;
    private Object locker = new Object();
    public void increase() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
    public void increase2() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
            }
        });
        // 两个线程对同一对象(locker)加锁, 那么结果就是100000.
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}

总结:

同步锁对象可以是任意类型, 但是必须保证 竞争"同一个共享资源"的多个线程必须针对同一个对象进行加锁。

2.1.2 synchronized 的特性

  1. 互斥

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

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

    可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).

    如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.

    如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队

    理解"阻塞等待"

    针对每一把锁, 操作系统内部都维护了一个等待队列, 当这个索贝某个线程栈有的时候, 其他线程尝试进行加锁, 就加不上了, 会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.

    注意:

    • 上一个线程解锁之后, 下一个线程并不会立即就能获得到锁, 而是要靠操作系统来"唤醒", 这也是操作系统调度的一部分工作.
    • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
  2. 刷新内存

    synchronized 的工作过程:

    • 获得互斥锁
    • 从主内存拷贝变量的最新副本到工作的内存
    • 执行代码
    • 将更改后的共享变量的值刷新到主内存
    • 释放互斥锁

2.2 volatile关键字(解决第四,五个问题)

2.2.1 volatile 能保证内存可见性

volatile 修饰的变量, 编译器就不会把读操作优化到都寄存器中, 于是就能保证在循环过程中, 始终能读取内存中的数据, 保证 “内存可见性”.

在这里插入图片描述

代码在 写入 volatile修饰的变量的时候:

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存
    • 工作内存(work memory) : 是指存储区, 包括cpu寄存器和cpu缓存.

代码在 读取 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 线程循环能够立即结束.

2.2.2 volatile不保证原子性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, 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.

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

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

相关文章

flutter开发web应用支持浏览器跨域设置

开发web应用难免会遇到跨域问题&#xff0c;所以flutter设置允许web跨域的设置是要在你的flutter安装路径下面 flutter\bin\cache 找到flutter_tools.stamp文件&#xff0c;然后删除掉&#xff1a;这个文件是临时缓存文件 然后找到 flutter\packages\flutter_tools\lib\src\web…

上位机模块之圆形测量,基于halcon的二次开发

夸克网盘可自取。链接&#xff1a;https://pan.quark.cn/s/ac192950e051 //在此之前可以先浏览2篇博客&#xff0c;分别是序列化与反序列化和ROI绘制获取 https://blog.csdn.net/m0_51559565/article/details/134422834 //序列化与反序列化 https://blog.csdn.net/m0_51559565…

【文件读取/包含】任意文件读取漏洞 afr_2

1.1漏洞描述 漏洞名称任意文件读取漏洞 afr_2漏洞类型文件读取漏洞等级⭐⭐漏洞环境dockers攻击方式 1.2漏洞等级 高危 1.3影响版本 暂无 1.4漏洞复现 1.4.1.基础环境 靶场dockers工具BurpSuite 1.4.2.环境搭建 1.kali创建docker-compose.yml文件 touch docker-compose.ym…

msvcp140_CODECVT_IDS.dll丢失怎么办,分享两个有效的方法

在计算机使用的过程中&#xff0c;我们经常会遇到一些错误提示&#xff0c;其中最常见的就是“缺少xxx.dll文件”。这些文件是动态链接库&#xff08;DLL&#xff09;文件&#xff0c;它们包含了程序运行所需的函数和资源。而msvcp140_CODECVT_IDS.dll就是其中之一。那么&#…

PY32F002B从压缩包到实现串口printf输出

最近学习使用芯领的PY32F002B开发板&#xff0c;记录学习历程供有同样需求的人参考。 本文主要讲述利用开发板实现printf语句串口输出。 开发环境的初步搭建 官方提供了一个压缩文件&#xff0c;文件名py32f002B_231026.zip&#xff0c; 链接&#xff1a;https://pan.baidu.c…

名城银河湾220㎡5室2厅2厨3卫,精致美学演绎的格调感。福州中宅装饰,福州装修

以手作维度构境, 跳脱约定成俗的风格, 转化内外地域分际, 于静谧中凝聚丰厚的美学能量, 谦虚且沉默以对 项目信息 项目名称 | 名城银河湾 设计地址 | 福建福州 项目面积 | 220㎡ 项目户型 | 5室2厅2厨3卫 设计风格 | 现代轻奢 全案设计师通过对业主的自身的情况和生活…

博流BL602芯片 - 烧录配置

硬件介绍 淘宝上买的核心板&#xff0c;大概结构如上。 直接插入电脑usb&#xff0c;即可实现供电、下载&#xff08;控制BOOT/EN&#xff09;、串口通讯 固件包 1、环境配置 1.1串口 开发板使用了 CH340G 的 USB 转串口芯片&#xff0c;自行安装CH340串口驱动。 1.2编译环境…

APISpace 验证码短信API接口案例代码

1.验证码短信API产品介绍 APISpace 的 验证码短信API&#xff0c;支持三大运营商&#xff0c;虚拟运营商短信发送&#xff0c;电信级运维保障&#xff0c;独享专用通道&#xff0c;3秒可达&#xff0c;99.99&#xff05;到达率&#xff0c;支持大容量高并发。可批量发送多个号码…

前端 vue 面试题 (一)

文章目录 v-if,v-show差别v-for和v-if虚拟dom解决什么问题vue的data为什么返回函数不返回对象比较vue&#xff0c;reactvue双向绑定原理vue虚拟dom 的diff算法vue 虚拟dom的diff算法的时间复杂度vue2与vue3的区别vue数据缓存&#xff0c;避免重复计算单页应用怎么跨页面传参vue…

Java追加式将内容写入yml文件

前言 最近需要使用java的jackson-dataformat-yaml写yml文件&#xff0c;但多数情况是在现有的文件内容中追加地写一部分新的内容。网上查了一下没有查到有直接追加的api&#xff0c;看源码偶然间找到了一个实现思路&#xff0c;记录一下。 追加写入到yml文件 使用的工具是jac…

SAPRouter Certificate即将过期更新证书

今日收到SAP发的一封邮件提示SAPRouter Certificate即将过期&#xff0c;顺便记录下更新证书的方法步骤。 1、登录SAProuter服务器&#xff0c;用户使用安装SAProuter的用户&#xff0c;我的是saprter用户 进入到/saprouter目录&#xff0c;备份certreq cred_V2 local.pse src…

深度学习_13_YOLO_图片切片及维度复原

需求&#xff1a; 在对获取的图片进行识别的时候&#xff0c;如果想减少不必要因素的干扰&#xff0c;将图片切割只对有更多特征信息的部分带入模型识别&#xff0c;而剩余有较多干扰因素的部分舍弃&#xff0c;这就是图片切割的目的&#xff0c;但是又由于模型对图片的维度有较…

DNS服务器典型配置

文章目录 安装主程序bind和安全插件bind-root修改主配置文件/etc/named.conf正向解析 安装主程序bind和安全插件bind-root yum install bind-chroot修改主配置文件/etc/named.conf vim /etc/named.conf将listen-on和allow-query的ip或域名换成any 表示为服务器所有的IP地址启…

【数据处理】Python:实现求条件分布函数 | 求平均值方差和协方差 | 求函数函数期望值的函数 | 概率论

猛戳订阅&#xff01; &#x1f449; 《一起玩蛇》&#x1f40d; &#x1f4ad; 写在前面&#xff1a;本章我们将通过 Python 手动实现条件分布函数的计算&#xff0c;实现求平均值&#xff0c;方差和协方差函数&#xff0c;实现求函数期望值的函数。部署的测试代码放到文后了&…

C语言--五子棋项目【图文详解 经典】

今天小编带领大家学一学C语言入门必写的五子棋项目&#xff0c;题目非常经典&#xff0c;值得一学。 目录 一.目标效果 二.五子棋的元素 1.棋子 2.棋盘 三,需要准备的工具 四.具体内容 1.加载背景图片 2.画横线与竖线 3. 画小黑点 4.获取鼠标消息 5.画棋子 6.如何判断比…

【java学习—十五】创建多线程的两种方式(2)

文章目录 1. 多线程的创建和启动2. Thread 类3. 创建多线程的两种方式3.1.继承Thread类实现多线程3.1.1. 举例3.1.2. 举例2 3.2. 实现Runnable接口3.2.1. 举例13.2.2. 举例2 4. 继承方式和实现方式的联系与区别5. 使用多线程的优点 1. 多线程的创建和启动 Java 语言的 JVM 允许…

【差分演化算法相关文献总结】

差分演化算法相关文献总结 前言概述文献综述总结 前言 本人作为一名从事了三年演化算法研究的菜鸡研究生&#xff0c;其中大部分时间都在专注于差分演化算法&#xff08;Differential Evolution, DE&#xff09;的相关研究。现如今已经毕业&#xff0c;回顾往昔&#xff0c;经过…

跌破1940后金价直指1900 对黄金代理是好是坏?

受以鲍威尔为首的美联储官员近期讲话的影响&#xff0c;加上巴以冲突暂时出现降温&#xff0c;导致避险需求下降&#xff0c;在两大因素的影响之下&#xff0c;现货黄金行情在近期的大涨之后出现大跌。金价不光跌破1950关口&#xff0c;在跌穿1940后势头更是直指1900。金价在一…

虹科干货丨Lambda数据架构和Kappa数据架构——构建现代数据架构

文章来源&#xff1a;虹科云科技 虹科干货丨Lambda数据架构和Kappa数据架构——构建现代数据架构 如何更好地构建我们的数据处理架构&#xff0c;如何对IT系统中的遗留问题进行现代化改造并将其转变为现代数据架构&#xff1f;该怎么为你的需求匹配最适合的架构设计呢&#xf…

异常--Java

cry…catch使用 /*需求&#xff1a;测试除法器&#xff08;try...catch&#xff09;* 测试人&#xff1a;小王* 测试日期&#xff1a;2023/11/15* */ package yichang_test1;import java.util.InputMismatchException; import java.util.Scanner;public class TestException2 …