• 吃透Java线程安全问题


    目录

    一、什么是线程安全

    二、造成线程不安全的原因 

     🍑对原子性在多线程并发执行中出现问题的分析

     🍑优化过程中所造成的线程不安全

    1、内存可见性引起的安全问题

    2、指令重排序引起的安全问题

    三、总结 


    一、什么是线程安全

    想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
    如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
     

    🌰栗子

    1. package Thread;
    2. public class demo77 {
    3. private static int count;
    4. public static void main(String[] args) throws InterruptedException {
    5. Thread t1 = new Thread(() -> {
    6. for (int i = 0; i < 5000; i++) {
    7. count++;
    8. }
    9. });
    10. Thread t2 = new Thread(() -> {
    11. for (int i = 0; i < 5000; i++) {
    12. count++;
    13. }
    14. });
    15. t1.start(); // 两个线程在创建好了后,线程所对应的PCB加入到系统链表,参与系统调度
    16. t2.start();
    17. // 让主线程main等t1、t2执行完了再接着往下走
    18. t1.join();
    19. t2.join();
    20. System.out.println(count);
    21. }
    22. }

     想这样,在多线程情况下,程序的运行结果不符合我们的预期,这被称为线程不安全


    二、造成线程不安全的原因 

    根本原因:操作系统的随机调度执行,抢占式执行 

    还有:我们可以看到我们的count是一个全局变量,我们的线程t1、线程t2对count变量同时都进行了修改——++操作(为什么说是同时呢,因为我们的t1线程、t2线程在创建完了后就参与到系统调度,由系统随机分配线程的执行,可能是t2线程先执行10个指令然后t1再执行10个指令,相当于是同时)

    那么我们就改一下代码让t1、t2分批次对count修改不就行了吗?

    1. package Thread;
    2. public class demo77 {
    3. private static int count;
    4. public static void main(String[] args) throws InterruptedException {
    5. Thread t1 = new Thread(() -> {
    6. for (int i = 0; i < 5000; i++) {
    7. count++;
    8. }
    9. });
    10. Thread t2 = new Thread(() -> {
    11. try {
    12. t1.join();// 等t1线程执行完了,t2线程再执行
    13. } catch (InterruptedException e) {
    14. e.printStackTrace();
    15. }
    16. for (int i = 0; i < 5000; i++) {
    17. count++;
    18. }
    19. });
    20. t1.start(); // 两个线程在创建好了后,线程所对应的PCB加入到系统链表,参与系统调度
    21. t2.start();
    22. t2.join(); // 等t2线程执行完了,主线程main再接着执行,执行顺序:t1->t2->打印count
    23. System.out.println(count);
    24. }
    25. }

     

    大家有没有想过为什么多个线程同时执行count++的时候就会出现BUG呢?

    这是因为我们多个线程同时对同一变量修改的所造成的BUG往往和我们操作的原子性有关!!!这时候的操作往往不是一个整体,多个线程并发执行这些操作就可能出现一些问题

    如果我们在变量修改过程中,操作是原子的——只是对应一个机器指令,那么即使是多个线程同时对同一个变量修改也不一定会造成BUG,但也可能造成BUG——要看具体的业务场景

    总之我们要避免多个线程同时对同一个变量来操作

    🍑 对原子性在多线程并发执行中出现问题的分析

    🔔🔔注意:

    当我们执行t1.start()、t2.start()后,t1线程和t2线程就在操作系统内核中创建出来了t1、t2线程就参与到了系统调度当中

    而调度是随机的——他可能先让t1执行几个指令,然后t2再执行几个指令、最后再把CPU的控制权交给t1。

    于是因为系统的调度是随机的(这是罪魁祸首,但我们无法改变),当我们多个线程同时执行一些不是整体的操作的时候(++或--)由于并发就会产生一些问题:

     🌰栗子一

    🌰栗子二 

    为什么会产生上面的BUG呢?

    就是因为我们的++操作不是一个整体,是一个由多个指令所组成的操作 

    解决方案:也是加锁:“synchronized”,意味着把这三条指令打包成了一组指令,然后把这一组指令看出成一条指令了,类似于数学里的“整体代换”思想。

     

     

     首先我们要明白加锁操作都是针对某一个对象来进行的(加锁本质就是给对象头里设置个标记),加锁有以下几种形式

    形式一、 

     形式二、

    1. package Thread;
    2. class Counter {
    3. public static int count;
    4. // public synchronized void increase() {
    5. // ++count; 这两种写法视为是等价的
    6. // }
    7. public void increase() {
    8. synchronized (this) { // 这里this可以是任意对象,this可以有多个Counter counter1 = new Counter(), Counter counter2 = new Counter();
    9. ++count;
    10. }
    11. }
    12. }
    13. public class demo777 {
    14. public static void main(String[] args) throws InterruptedException {
    15. Counter counter1 = new Counter();
    16. Counter counter2 = new Counter();
    17. Thread t1 = new Thread(() -> {
    18. for (int i = 0; i < 5000; i++) {
    19. counter1.increase();
    20. }
    21. });
    22. Thread t2 = new Thread(() -> {
    23. for (int i = 0; i < 5000; i++) {
    24. counter1.increase();
    25. }
    26. // 多个线程去调用这个increase方法,其实就是针对这个Counter对象counter1来进行加锁
    27. // 如果一个线程t1获取到了该对象counter1的锁,那么另一个线程t2就要等到counter1对应的锁开了后(t1线程执行完该锁里的内容——++操作)t2才能执行++操作
    28. // 此时++操作相当于是成为了一个整体(相当于一个指令,当一个线程再执行这个加锁的整体的指令的时候,另一个线程只能阻塞等待)
    29. });
    30. t1.start();
    31. t2.start();
    32. t1.join();
    33. t2.join(); // 确保线程t1和线程t2都执行完了,main主线程再接着执行——输出count
    34. System.out.println(Counter.count); // 输出10000
    35. }
    36. }

     

    形式三、 

     

     

     

    当我们给不同的对象上锁后,如果用住房来比喻

    不同的房间相当于是不同的对象,不同的线程相当于是不同的客人

    如果房间1住了客人A,那么房间1就上了锁,客人B就需要等客人A不再住房间1(开了锁)然后客人B才能住房间1;或者客人B住其他的房间(其他的对象,没上锁的)

     

    1. package Thread;
    2. // 测试线程竞争,对锁的竞争
    3. public class demo7777 {
    4. public static Object object1 = new Object();
    5. public static Object object2 = new Object();
    6. public static void main(String[] args) {
    7. Thread t1 = new Thread(() -> {
    8. // 针对object1对象进行加锁,加锁操作是针对某一个对象来进行的
    9. synchronized (object1) {
    10. System.out.println("t1线程start");
    11. try {
    12. Thread.sleep(1000);
    13. } catch (InterruptedException e) {
    14. e.printStackTrace();
    15. }
    16. System.out.println("t1线程finish");
    17. }
    18. });
    19. t1.start();
    20. Thread t2 = new Thread(() -> {
    21. synchronized (object1) { // 针对object1对象来进行加锁操作
    22. System.out.println("t2线程start");
    23. try {
    24. Thread.sleep(1000);
    25. } catch (InterruptedException e) {
    26. e.printStackTrace();
    27. }
    28. System.out.println("t2线程finish");
    29. }
    30. });
    31. t2.start();
    32. }
    33. }

     

     

    我们上面就是两个线程t1和t2同时对object1这个对象进行了加锁,然后t1与t2直接就产生了竞争。从上述代码的实现过程中我们也可以看到,等到t1线程执行完了后,t2线程才开始执行。

    但如果是两个线程对不同的对象进行加锁,则没有竞争(就像两个客人(两个线程)住不同的房间(不同的对象)当然不会发生竞争。

    1. package Thread;
    2. // 测试线程竞争,对锁的竞争
    3. public class demo7777 {
    4. public static Object object1 = new Object();
    5. public static Object object2 = new Object();
    6. public static void main(String[] args) {
    7. Thread t1 = new Thread(() -> {
    8. synchronized (object1) { // 针对object1对象来进行加锁,加锁操作是针对一个对象来进行的
    9. System.out.println("t1线程start");
    10. try {
    11. Thread.sleep(1000);
    12. } catch (InterruptedException e) {
    13. e.printStackTrace();
    14. }
    15. System.out.println("t1线程finish");
    16. }
    17. });
    18. t1.start();
    19. Thread t2 = new Thread(() -> {
    20. synchronized (object2) { // 针对object2对象进行加锁
    21. System.out.println("t2线程start");
    22. try {
    23. Thread.sleep(1000);
    24. } catch (InterruptedException e) {
    25. e.printStackTrace();
    26. }
    27. System.out.println("t2线程finish");
    28. }
    29. });
    30. t2.start();
    31. }
    32. }

     

     

     

    对上面补充一下:

     


     🍑优化过程中所造成的线程不安全

    上述三个是造成线程安全问题的的主要原因,除此之外。在编译器/JVM/操作系统对程序优化的过程中也会好心办坏事,造成线程不安全

    1、内存可见性引起的安全问题

    分析

    🌰栗子 

     

    为什么会这种情况呢?

    但我们的t1、t2创建好了后,操作系统内存对我们这两个线程进行随机调度执行。那么我们的t1线程在执行过程中就不断地从内存中读取flag的值,然后进行判断。那么就有可能t1线程在执行了好长一会后,t2才执行、输入了一个整数&&改变了flag值。

     

    是操于作系统就发现,怎么回事,这个flag值一直也没有改变,那么t1线程一直不断地从内存中读取flag的值,这样的效率是很低的。于是操作系统就对这种情况进行了优化,t1在第一次从内存中读取flag的值后就不再反复的从内存中读取了,接下来只是不断地进行判断——也就是说:在t2线程把flag的值更改了后,t1线程并没有成功读取到更改后的flag值。

     解决方案

     

     

    再补充一个栗子

     


     

    2、指令重排序引起的安全问题

     

    比如说接下来我要干三件事

    1、去超市闲逛

    2、 回家

    3、去超市买菜

    如果我安装1->2->3的顺序来执行,是不是很傻、很浪费时间。在程序执行的过程中也是如此,通过顺序的改变和调整就可以达到优化的效果。

    但有时候顺序是不能随便改变的

    编译器 / JVM / 操作系统 优化优化着,还优化出BUG了,那为啥还要有优化呢?

    因为不同的程序员的水平差异是非常大的,通过优化,对我们的程序执行效率可能会有翻倍的提升!!!服务器启动的时候,如果加上编译器优化,启动时间10min!但如果关闭优化,启动时间 > 30 min!!!

    所以说,不管怎么优化,我们的大前提是要保证程序的逻辑是不变的!我们是希望在逻辑不变的前提下,通过一些优化的操作来提升效率、提高速度!!!

    补充一下在单线程下,保证逻辑不变很容易做到。但在多线程环境下,想有在不改变逻辑的前提下优化就变得很困难了——所以才会出现内存可见性问题、指令重排序问题

    三、总结 

  • 相关阅读:
    npm create vue@latest 原理
    数据包伪造替换、会话劫持、https劫持之探索和测试
    Web网页前端教程免费:引领您踏入编程的奇幻世界
    接入HMS Core应用内支付服务过程中一些常见问题总结
    机器学习之增强学习DQN(Deep Q Network)
    使用JMeter软件压测接口配置说明
    每日一道算法题
    Ubuntu安装和配置ssh
    css知识学习系列(5)-每天10个知识点
    大数据Doris(二十二):数据查看导入
  • 原文地址:https://blog.csdn.net/weixin_61061381/article/details/125986729