【多线程】线程安全问题

这篇具有很好参考价值的文章主要介绍了【多线程】线程安全问题。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

【多线程】线程安全问题

1. 一段线程不安全的代码

我们先来看一段代码:

public class ThreadDemo {
    public static int count = 0;
    public static void main(String[] args) {
        for (int i = 0; i < 10_0000; i++) {
            count++;
        }
        System.out.println("count = " + count);
    }
}
// 打印结果:count = 10000

这段代码是对 count 自增 10w 次,随之的打印结果 count = 100000,相信也没有任何的歧义,那么上述代码是否能优化呢?能否让速度更快呢?

相信学习到这里大家都会想到用多线程,可以搞两个线程,每个线程执行 5w 次自增就行了,甚至还可以搞五个线程,每个线程执行 2w 次就行了。

此处为了代码简洁,我们弄两个线程,每个线程对 count 自增 5w 次,最终也相当于对 count 自增了 10w 次了:

public class ThreadDemo {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        // 下面的等待是为了让两个线程都自增完成
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}
// 第一次执行打印结果:count = 64121
// 第二次执行打印结果:count = 54275
// 第三次执行打印结果:count = 63608

小伙伴们下去执行这个代码,可能跟我的打印结果不同,但是会发现,好像怎么样都到不了 10w,明明我们预期结果是 10w,但是达不到预期,此时就可以认为程序出现了 BUG!

凡是实际结果与预期结果不同,都认为是出现了 BUG!

上述代码就是典型的线程安全问题,也可以称为线程不安全!

由以上两段代码可知:

  • 如果没有多线程,那么代码的执行顺序是固定的,代码执行顺序固定,那么程序的结果也就是固定的!

  • 如果使用多线程,那么代码的执行顺序会出现更多的变数,执行顺序的可能性由于 CPU 的随机调度,可能出现了无数情况!

所以我们就要解决这类问题,保证在无数种执行顺序下,代码执行结果仍然正确!

要解决上述的问题,首先要理解 count++ 这个操作到底做了一件什么事。

这个 ++ 操作本质上要分成三步执行!

  1. 先把内存中的值,读取到 CPU 寄存器中 (load)

  1. 把 CPU 寄存器的数值进行 +1 运算 (add)

  1. 把得到的结果写回到内存中 (save)

由于 CPU 是随机调度的,所以就可能出现以下的情况:

【多线程】线程安全问题

情况1 相当于就是执行了 t1 线程的 ++ 操作后,再去执行 t2 线程的 ++ 操作。

情况2 相当于 t1 在执行 ++ 操作第一步 load 的时候,t1 线程被切走了,CPU 去调用了 t2 线程执行 ++ 操作的第一步 load 操作,执行完 t2 的load 操作后,又被切走了,CPU 去执行 t1 线程的 add 和 save 操作了。

像上述 情况1 和 情况2 所得到的结果可能就是截然不同的!

【多线程】线程安全问题

上图可以发现,由于是两个线程同时针对 count 变量进行修改,因为 ++ 操作是分三次执行的,此时不同的线程调度顺序,就可能产生不同的结果。

  • 情况1,线程1和2 同时对 count++,结果自增了两次,没有任何问题!

  • 情况2,线程1 和 2 同时对 count++,但由于执行顺序不同,导致 count 只自增了一次!

所以这就是为什么上述代码每次打印的结果都不同了,那有没有可能刚好打印 10w 呢?也是有可能的!

为什么会出现上述情况呢?请往下看:


2. 线程不安全的原因

2.1 随机调度

罪魁祸首,就是 CPU 随机调度,线程抢占式执行带来的风险,就是由于随机调度和抢占式执行,让代码的顺序不可预估,不同的顺序带来的结果也可能截然不同。

就好比结婚:

需要先认识妹子,建立感情,谈恋爱,见家长,给彩礼,领结婚证,结婚

那如果顺序乱了,那麻烦可就大了!

2.2 修改共享数据

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

  • 多个线程读同一个变量,没问题

  • 多个线程修改不同的变量,没问题

  • 多个线程修改相同的变量,有问题

  • 一个线程读,一个线程改,有问题

  • ......

2.3 原子性

上述的 count++ 这个操作就不是原子性的,也就是可以分成 load,add,save 三个指令操作,如果我们能将 count++ 这个操作改成原子性的,也就是三个指令操作必须一次完成(合并成一个指令),此时也就解决了线程安全问题。

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

如何保证原子性呢?可以使用 synchronized 关键字,后续就会讲解到。

2.4 内存可见性

一个线程对共享变量值的修改,能够及时地被其他线程看到,如果没有被及时发现,那么可能读的线程,读到了一个修改之前的值,也会造成线程不安全!有点类似于数据库事务那块的脏读概念!

这个具体后续讲解 volatile 关键字会详细介绍。

2.5 指令重排序

这个可以理解为是编译器想给我们代码做优化,但是好心办坏事了。

就比如说张三女朋友让张三去买菜,给张三列个清单:需要买土豆,黄瓜,青椒,茄子,白菜,豆芽。

张三来到菜市场,如果按照清单的顺序买菜是这种情况:

【多线程】线程安全问题

这样显然很麻烦啊,能不能优化买菜的顺序呢?反正只需要把清单上的菜都买到就行了嘛!

于是张三调整顺序后是这样买菜的:

【多线程】线程安全问题

按照上述顺序买菜的话,仍然能买到清单上的菜,但是张三可谓轻松了很多,整体买完菜的速度也提升了不少

指令重排序,就像上述一样,可以少跑很多趟,优化了效率。

编译器对于指令重排序的前提是 "保持逻辑不发生变化",对于单线程环境来讲,比较容易判断,但是在多线程的环境下就没那么容易了,多线程代码执行复杂程序更高,编译器很难在编译阶段对代码的执行结果进行预判,因此编译器激进的指令重排序很容易导致重排后的逻辑和之前不等价,好比现在两个人去买菜,很容易买重,或者少买了。

3. synchronized 加锁操作

3.1 针对指定对象加锁

上述将的五个造成线程不安全的原因,首先要明确CPU 的随机调度,线程抢占式执行,这个是内核规定的,我们无法做出修改,那我们能限制线程不允许访问同一个变量吗?是可以的!但是这样做就进一步削弱了多线程的优势了!

所以我们可以从原子性入手,来解决因为指令不是原子性造成的线程安全问题!

那么针对上述线程不安全的代码,就可以利用 synchronized 关键字进行加锁,来保证加锁的代码块是原子性的:

public class ThreadDemo {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                synchronized (object) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                synchronized (object) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        // 下面的等待是为了让两个线程都自增完成
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}
// 第一次执行打印结果:count = 100000
// 第二次执行打印结果:count = 100000
// 第三次执行打印结果:count = 100000

上述代码就是针对 object 这个对象加锁了,一个对象只有一把锁,所以当 t1 线程执行到 count++ 时就会尝试获取 object 的锁,如果获取到了,就进行加锁操作,并执行 count++,只有执行完 synchronized 代码块的内容后,才会自动释放锁,如果 t1 在执行 count++ 的过程中,t2也执行到 count++ 了,此时 t2 就会尝试获取 object 对象锁,但是 object 已经被 t1 加锁了,那么 t2 就只能阻塞等待了,等 t1 释放锁,t2 才能获取锁,并加锁!那么 t1 只有执行完 synchronized 代码块后,自动释放锁!

这就好比上公厕,一个公厕只能供一个人使用,如果张三进入了公厕,就会把门锁上,此时外面的人想上厕所,就只能等张三上完厕所出来,其他人才能进去上厕所!

【多线程】线程安全问题

synchronized 用的锁是对象头里的,可以粗略理解成每个对象在存储的时候,都有一块内存表示当前 "锁定" 状态,类似于上述图中厕所有人还是没人,如果厕所有人(对象已经被加锁),此时其他人就无法使用,就得排队(阻塞等待)。如果厕所没人,就能赶紧去厕所,把锁加上,其他人就不能在上厕所了

理解 "阻塞等待",针对每一把锁,操作系统内部都维护了一个等待队列,当这个锁被某个线程占有时,其他线程尝试加锁,就加不上了,就会阻塞等待,而这个等待队列,就存储想要获取到这把锁的线程。

那么上一个线程释放锁了,下一个线程会立即获取到锁吗?并不是,而是需要靠操作系统来进行 "唤醒",这也是操作系统线程调度的一部分工作,假设 A 获取到锁了,B 尝试获取锁,C 后来的也尝试获取锁,当 A 释放锁了,B 一定能获取到锁吗?虽然 B 比 C 先来,但是B 不一定能获取到锁,而是和 C 重新竞争,并不遵循先来后到的原则,具体在介绍锁特性的时候会再次介绍。

3.2 针对 this 加锁

上面的案例我们是针对 object 对象加锁,同时也可以针对 this 加锁:

public class ThreadDemo {
    public int count = 0;
    public void increment() {
        synchronized (this) {
            count++;
        }
    }
}

这里加锁是对 this 对象加锁,在 JavaSE 语法阶段,学习到 this 关键字了解到 this 就是当前对象的引用。

后续要调用上述的 increment 方法时,必须要有一个 ThreadDemo 类型的对象才能调用 increment 方法,那么哪个对象调用了 increment 方法了,就是针对哪个对象加锁!

比如:

public static void main(String[] args) throws InterruptedException {
    ThreadDemo demo = new ThreadDemo();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5_0000; i++) {
            demo.increment();
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5_0000; i++) {
            demo.increment();
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(demo.count);
}
// 打印结果:count = 100000

此时通过 demo 这个对象调用了 increment 方法,那就就是针对 demo 这个对象加锁,所以针对 this 加锁,也就是针对当前对象加锁,一定要弄清楚当前是哪个对象!

上述代码如果 t1 和 t2 线程都执行到了 demo.increment() 里的 synchronized 代码块,此时就会发送锁竞争。当 t1 竞争到锁了,此时 t2 就需要阻塞等待,等到 t1 执行完 synchronized 代码块后释放锁了,t2 才能尝试获取锁。

同时针对 this 加锁也可以写成如下的样子:

public class ThreadDemo {
    public int count = 0;
    synchronized public void increment() {
        count++;
    }
}

此时也是针对 this(当前对象) 加锁,只不过这里进入 increment 方法就会加锁,结束 increment 方法自动释放锁。

3.3 针对类对象加锁

类对象是个啥?不知道大家伙还记不记得学习反射的时候,需要获取类的class这个对象,才能进行反射。

当我们针对静态方法加锁时,就是针对类对象加锁:

public class ThreadDemo {
    synchronized public static void func() {
        System.out.println("hello world");
    }
}

此时也就是相当于针对 ThreadDemo.class 这个对象加锁!

3.4 synchronized 疑难解答

注意!一定要弄清楚针对哪个对象加锁!不存在针对方法加锁!

有一个 Demo 类,并且实例化了一个 d 对象,下面的例子都基于 Demo 类来举例:

例1:synchronized 修饰了 func1 和 func2 方法,如果 t1 线程正在执行 d.func1() 方法,此时就针对 d 这个对象加锁了,如果此时 t2 想执行 d.func2() 就不行了!因为 d 已经被 t1 加锁了!

例2:synchronized 修饰了 fun1 方法但是没有修饰 func2 方法,如果 t1 线程正在执行 d.func1(),此时 t2 仍然能执行 d.func2(),因为 func2 没有被 synchronized 修饰。

例3:synchronized 修饰了静态的func1 方法,也修饰了普通的 func2 方法,如果 t1 线程正在执行 Demo.func1(),此时 t2 能执行 d.func2() 方法,因为是针对不同的对象加锁,t1 是针对 d 对象加锁,而 t2 是针对 Demo.class 类对象加锁。

3.5 synchronized 是可重入锁

这里设想这样的一个场景,t 线程执行 run 方法,进入方法后,针对 object 对象加锁,执行了一会代码后,还没释放锁,又再次对 object 对象加锁了!

按照前面的学习,t 只有等线程释放了锁,才能再次加锁,那上述情况不就是 t 在等待 t 释放锁,但是 t 目前释放不了,所以就僵住了,也就是死锁了!但是 synchronized 不会出现这样的问题:

public static void main(String[] args) {
    Object object = new Object();
    Thread t = new Thread(() -> {
        synchronized (object) {
            System.out.println("第一次加锁成功!");
            synchronized (object) {
                System.out.println("第二次加锁成功!");
            }
        }
    });
    t.start();
}
// 打印结果:
// 第一次加锁成功!
// 第二次加锁成功!

这个代码就是对 object 对象重复加锁了两次,按我们理解来说,这里 t 就僵住了!但是运行程序发现居然能正常打印 "第二次加锁成功!",那么就说明没有出现死锁的情况!

可重入锁,对同一个对象加锁两次,如果没有问题,那么就是可重入锁,synchronized 就是可重入锁!


下期预告:死锁的解决方案文章来源地址https://www.toymoban.com/news/detail-430165.html

到了这里,关于【多线程】线程安全问题的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • java基础之线程安全问题以及线程安全集合类

    当多个线程同时访问同一个临界资源时,原子操作可能被破坏,会导致数据丢失, 就会触发线程安全问题 临界资源: 被多个线程同时访问的对象 原子操作: 线程访问临界资源的过程中不可更改和缺失的操作 互斥锁 每个对象都默认拥有互斥锁, 该锁默认不开启. 当开启互斥锁之后

    2024年01月18日
    浏览(52)
  • Java多线程之线程安全问题

    我们知道操作系统中线程程的调度是抢占式执行的, 宏观上上的感知是随机的, 这就导致了多线程在进行线程调度时线程的执行顺序是不确定的, 因此多线程情况下的代码的执行顺序可能就会有无数种, 我们需要保证这无数种线程调度顺序的情况下, 代码的执行结果都是正确的

    2023年04月17日
    浏览(45)
  • Java中的多线程——线程安全问题

    作者:~小明学编程   文章专栏:JavaEE 格言:热爱编程的,终将被编程所厚爱。 目录 多线程所带来的不安全问题 什么是线程安全 线程不安全的原因 修改共享数据 修改操作不是原子的 内存可见性对线程的影响 指令重排序 解决线程不安全的问题 synchronized 互斥 刷新内

    2024年02月03日
    浏览(79)
  • 【多线程】线程安全问题原因与解决方案

    目录 线程安全的概念 线程不安全示例 线程不安全的原因      多个线程修改了同一个变量     线程是抢占式执行的     原子性     内存可见性     有序性 线程不安全解决办法  synchronized -监视器锁monitor lock     synchronized 的特性         互斥         刷新内

    2024年02月06日
    浏览(37)
  • java 线程安全问题 三种线程同步方案 线程通信(了解)

    线程安全问题指的是,多个线程同时操作同一个共享资源的时候,可能会出现业务安全问题。 下面代码演示上述问题,先定义一个共享的账户类: 在定义一个取钱的线程类 最后,再写一个测试类,在测试类中创建两个线程对象 某个执行结果: 为了解决前面的线程安全问题,

    2024年02月09日
    浏览(43)
  • 【Java】线程安全问题

    在之前的文章中,已经介绍了关于线程的基础知识。 我的主页: 🍆🍆🍆爱吃南瓜的北瓜 欢迎各位大佬来到我的主页进行指点 一同进步!!! 我们创建两个线程t1和t2,对静态变量count执行++操作各50000次。 我们的预期结果是100000。但是当两个线程分别执行++操作时最后的结果

    2024年04月10日
    浏览(43)
  • 线程安全问题及解决方法

    线程在执行的过程中出现错误的主要原因有以下几种: 1、根本原因 导致线程不安全的所有原因中,最根本的原因是——抢占式执行。因为CPU字在进行线程调度的时候,是随机调度的,而且这是无法避免的一种原因。 2、代码结构 当多个线程同时修改同一个变量的时候,很容

    2024年02月06日
    浏览(34)
  • 线程安全问题

    目录 🐇今日良言:一路惊喜 马声蹄蹄 🐼一、线程安全问题 🐳1.概念 🐳2.代码 🐳3.原因 🐳4.解决方案 🐳 1.概念 如果多线程环境下代码运行的结果是符合我们预期的,即该代码在单线程中运行得到的结果,那么就说说这个程序是线程安全的,否则就是线程不安全的. 线程安全问

    2024年02月15日
    浏览(32)
  • 【JavaEE】多线程之线程安全(synchronized篇),死锁问题

    线程安全问题 观察线程不安全 线程安全问题的原因  从原子性入手解决线程安全问题 ——synchronized synchronized的使用方法  synchronized的互斥性和可重入性 死锁 死锁的三个典型情况  死锁的四个必要条件  破除死锁 在前面的章节中,我们也了解到多线程为我们的程序带来了

    2024年02月01日
    浏览(55)
  • SimpleDateFormat 线程安全问题修复方案

    在日常的开发过程中,我们不可避免地会使用到 JDK8 之前的 Date 类,在格式化日期或解析日期时就需要用到 SimpleDateFormat 类,但由于该类并不是线程安全的,所以我们常发现对该类的不恰当使用会导致日期解析异常,从而影响线上服务可用率。 以下是对 SimpleDateFormat 类不恰当

    2024年02月12日
    浏览(39)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包