• JavaEE:线程安全问题的原因和解决方案


    目录

    线程不安全问题出现的原因及其解决方案

    1.抢占式执行

    2.多个线程修改同一个变量

    3、操作指令不是原子的

    例子:

    4、内存可见性问题

    例子:

     原因:

    解决方案:

    5.指令重排序

    例子:

    原因:

    解决方法:


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

    这个程序是线程安全的

    即如果在多线程的随机调度下,代码出现bug,此时就认为线程不安全.

    线程不安全问题出现的原因及其解决方案

    1.抢占式执行

    多个线程的调度执行过程,可以视为是"全随机"的(也不能视为成纯的随机,但是确实在应用层程序这里是没有规律的),即无法确定先执行哪一个线程,后执行哪一个线程(通俗的来讲就是哪一个线程先抢到机会,哪个线程就先执行),

    抢占式执行被视为线程不安全的万恶之源,罪魁祸首

    (内核实现的,咱们无能为力)

    2.多个线程修改同一个变量

    相反:

    一个线程修改一个变量,没事

    多个线程读同一个变量,没事

    多个线程修改不同便是,没事

    但当这三个同时满足时,就可能出现线程不安全问题:多个线程&&同时修改&&一个变量。

    (有的时候可以调整代码,来从这里入手,规避线程安全问题,但是普适性不高!)

    3、操作指令不是原子的

    什么是原子性?

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

    由于线程是在CPU上调度执行的,而CPU在执行指令时,都是以“一个指令”为单位进行执行,但是有些简单的操作本质上是多个CPU指令:

    例如count++这个操作,本质上是三条CPU指令:load、add、save

    (先把temp的值从内存中读取到CPU寄存器上,然后进行add操作,最后再把寄存器上的值写会到内存上),但是在多线程环境下线程操作是并发的,此时就可能出现线程不安全的问题。

    例子:

    创建两个线程,对count进行自加10w次后看结果,如果正常来说是10w

    1. public class Demo {
    2. public static int count = 0;
    3. public static void main(String[] args) throws InterruptedException {
    4. Thread t1 = new Thread(() -> {
    5. for (int i = 0; i < 50000; i++) {
    6. count++;
    7. }
    8. });
    9. Thread t2 = new Thread(() -> {
    10. for (int i = 0; i < 50000; i++) {
    11. count++;
    12. }
    13. });
    14. t1.start();
    15. t2.start();
    16. t1.join();
    17. t2.join();
    18. System.out.println("count = " + count);
    19. }
    20. }

    可以看到,我一共运行了三次程序,得到是数值均没有达到10w,且每次都不相等

     这个是正常的指令运行模式:

    诸如此类都是错误的:

    除去第一张图片的两种排列方法之外的都是错误的!!

    在形如第二张图片中的排列方法的排序下,此时的多线程自增就会存在"线程安全问题"!!

    整个线程调度过程中,执行的顺序都是随机的,由于在调度过程中,出现"串行执行"两种情况的次数,和其他情况的次数不确定,

    因此得到的结果就是不确定的值.

    虽然结果是不确定的值,但是结果的范围是可以预测的:

    考虑极端情况下:

    如果两个线程之间的调度全是串行执行,结果就是:10w

    如果两个线程之间的调度全是七日情况,一次串行执行都没有,结果就是5w

    但是:

    正常分析就是如此,但是我们在实际操作中运行代码是可以得到<5w的值的

    当t1加了一次的时候,t2加了两次就会出现这种情况

    解决方案:

    把多个CPU指令打包成一个原子操作,使用synchronized关键字对可能出现线程不安全问题的代码进行加锁操作。了解synchronized关键字点这哦

    1. class Count{
    2. public int count = 0;
    3. public synchronized void increase(){
    4. for (int i = 0; i < 50000; i++) {
    5. count++;
    6. }
    7. }
    8. }
    9. public class Demo {
    10. public static void main(String[] args) throws InterruptedException {
    11. Count c = new Count();
    12. Thread t1 = new Thread(() -> {
    13. c.increase();
    14. });
    15. Thread t2 = new Thread(() -> {
    16. c.increase();
    17. });
    18. t1.start();
    19. t2.start();
    20. t1.join();
    21. t2.join();
    22. System.out.println("count = " + c.count);
    23. }
    24. }

    4、内存可见性问题

            程序员写代码,写好的代码编译之后,在机器上运行,但是由于程序员的水平参差不齐,  大佬写的代码非常高效,菜鸡(博主)写的代码比较低效,跑得慢;  于是写编译器的大佬们就想办法让编译器具有一定的"优化能力"!!我代码里写了一些逻辑,然后编译器把我写的代码等价转换成另外一种执行逻辑,等价转换之后,代码的逻辑不变(逻辑等价),但是效率变高了!!

            在多线程环境下,编译器优化后的代码可能就会和原来的代码逻辑有所不同,运行时出现了我们预期之外的结果,这就是内存可见性问题

    例子:

    t1线程中循环判断count的值是否为0,t2线程中修改count的值:

    1. import java.util.Scanner;
    2. public class Test {
    3. static class Counter{
    4. public int count;
    5. }
    6. public static void main(String[] args) {
    7. Counter counter = new Counter();
    8. Thread t1 = new Thread(() -> {
    9. while (counter.count == 0){
    10. }
    11. System.out.println("t1线程结束");
    12. });
    13. t1.start();
    14. Thread t2 = new Thread(() -> {
    15. System.out.println("修改count的值");
    16. Scanner scanner = new Scanner(System.in);
    17. counter.count = scanner.nextInt();
    18. System.out.println("count = " + counter.count);
    19. });
    20. t2.start();
    21. }
    22. }

    无论我们输入什么数值,t1线程中的while循环都不会结束

     原因:

            因为t1线程中的while循环里没有什么任何操作,所以编译器这个就认为count的值是不会发生改变的,既然count不会改变,那么只需要在内存中读取一次就行了,不必在每次执行count == 0时都从去compare一次,这样太浪费时间了;

            于是编译器在优化之后,count只有在第一次执行count == 0比较的时候是从内存中读取的,之后的每次都是从CPU寄存器的缓存中读取,这样一来就节省了许多的时间。(从寄存器中读取数据的速度比从内存中读取数据的速度快了成千上万倍)

            但是编译器并没有想到我们会通过其他线程来修改count的值,所以当我们在t2线程中修改count的值后,t1线程并没有感知到,因此代码便陷入了死循环。

    解决方案:

    既然编译器自己的判定不准了,将不应该优化的给优化了,就可以让程序员显示的提醒编译器,这个地方不要优化,所以可以使用volatile关键字来修饰count,此时编译器就不会对count进行“只读一次内存”的优化了,所以volatile可以保证“内存可见性”问题。

    1. import java.util.Scanner;
    2. public class Test {
    3. static class Counter{
    4. public volatile int count;
    5. }
    6. public static void main(String[] args) {
    7. Counter counter = new Counter();
    8. Thread t1 = new Thread(() -> {
    9. while (counter.count == 0){
    10. }
    11. System.out.println("t1线程结束");
    12. });
    13. t1.start();
    14. Thread t2 = new Thread(() -> {
    15. System.out.println("修改count的值");
    16. Scanner scanner = new Scanner(System.in);
    17. counter.count = scanner.nextInt();
    18. System.out.println("count = " + counter.count);
    19. });
    20. t2.start();
    21. }
    22. }

    5.指令重排序

    指令重排序也是编译器优化所带来的问题,有些单个的操作可以分为多个CPU指令(例如count++,就分为三个CPU指令:load,add,save),经过编译器优化后,这些指令的顺序可能会发生改变,在多线程环境下,就可能出现bug,即带来线程不安全问题。

    例子:

    单例模式的"懒汉模式中"存在指令重排序的问题:

    1. class Singleton{
    2. private static Singleton instance = null;
    3. //封装构造方法
    4. private Singleton(){
    5. }
    6. public static Singleton getInstance(){
    7. if(instance == null){
    8. synchronized (Singleton.class){
    9. if(instance == null){
    10. instance = new Singleton();
    11. }
    12. }
    13. }
    14. return instance;
    15. }
    16. }

    原因:

    在new一个对象的时候,我们大致分为三个步骤:

    1)申请内存,得到内存首地址

    2)调用构造方法,来初始化实例

    3)把内存的首地址赋值非instance(即new的对象)使用

    此时,编译器可能会进行指令重排序的优化,因为在单线程角度下,第二步和第三步的执行顺序是可以调换的,先执行哪一步后执行哪一步,最终结果是一样的。

    然而,在多线程角度下,就可能会出现问题:

    假设代码在经过编译器优化后出现了指令重排序的问题,并且按照1、3、2的顺序来执行new操作。如果t1线程执行完第一步和第三步后,此时的instance对象是一个不完全的对象,只是有内存,但是内存上的数据无效;当t1在执行第二步之前,t2线程调用了getInstance()方法,那么它就会认为(instance == null)的条件为假,直接返回当前这个不完全的instance对象,那么bug就出现了

    就相当于老板叫你写一段代码后天要用,然后你玩着玩着忘记了,到了后天老板问你要,你想着老板应该不会那么急,你就说你已经写好了,但是老板很开心说到:"好,马上传给我"

    解决方法:

    使用volatile关键字,就可以禁止编译器进行指令重排序的优化。

    所以一般我们在编写多线程代码时,volatile能写就写,可以最大程度的避免内存可见性问题/指令重排序问题!!

  • 相关阅读:
    类与对象(十七)----继承extend
    斐波拉契数列
    终于搞懂了!原来 Vue 3 的 generate 是这样生成 render 函数的
    我深刻反思了一下自己。
    Go语言errors的使用
    全局大喇叭--广播机制
    Vue前端导出Excel文件
    【逆向】在程序空白区添加Shellcode
    设计模式复习题
    轻量级开源ROS 的机器人设备(2)--设计
  • 原文地址:https://blog.csdn.net/m0_62976995/article/details/127022179