浅谈线程安全问题的原因和解决方案

1. 观察线程不安全

java 复制代码
class Counter {
    public int count = 0;
    
    public void increase() {
        count++;
    }
    
}

// 线程安全问题演示.
public class Demo12 {
    public static void main(String[] args) throws InterruptedException {
        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);
    }
}

上述的代码中两个线程, 针对同一个变量, 进行循环自增. 各自自增5w次,预期最终应该是10w, 但实际上,并不是这样的结果. 每次运行的结果都不一样, 并且还都是错的.

在多线程下,发现由于多线程执行,导致的bug, 统称为"线程安全问题". 如果某个代码, 在单线程下执行没有问题, 多个线程下执行也没问题, 则称为"线程安全",反之就可以称为"线程不安全".

那么啥是bug呢? bug是一个非常广义的概念. bug 的中文名,可以翻译成"幺蛾子". 只要是实际运行效果和预期效果(需求中的效果)不一致,就可以称为是一个bug.

线程安全和线程不安全的区别也就是多线程代码是否有bug.

那么上述的代码为啥会出现bug呢?

如果上述操作, 在两个线程或者多个线程并发执行的情况下, 就可能会出现问题.

如果上述两个线程是这样串行执行的, 那么结果就会是对的. 但是真的能这样吗? 上述图片中虽然是只是自增两次,但是由于两个线程并发执行, 就可能在一定的执行顺序下,导致运算的中间结果就被覆盖了. 在这5w次的循环过程中, 有多少次这俩线程执行++是"串行的"?,有多少次会出现覆盖结果的? 这些都不确定. 因为线程的调度是随机的, 是抢占式执行的过程.

上述的过程就是结果被覆盖的例子. 此处这两个线程的调度是不确定的, 这两组对应的操作也会有差异. 而且上述代码得到的结果一定是小于100000的, 因为有结果被覆盖掉了.

2. 线程安全问题的原因

1) [根本原因]多个线程之间的调度顺序是"随机的", 操作系统使用"抢占式"执行的策略来调度线程.这就是罪魁祸首,万恶之源.

和单线程不同的是, 在多线程下, 代码的执行顺序,产生了更多的变化.

以往只需要考虑代码在一个固定的顺序下执行,执行正确即可. 现在则要考虑多线程下, N种执行顺序下,代码执行结果都得正确.

这件事情,木已成舟,咱们无力改变.当前主流的操作系统,都是这样的抢占式执行的.

2) 多个线程同时修改同一个变量就容易产生线程安全问题.

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

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

多个线程修改多个变量, 没事.

3) 进行的修改, 不是"原子的".

如果修改操作,能够按照原子的方式来完成, 此时也不会有线程安全问题.

count++ 不是原子的~

= 直接赋值, 可以视为原子.

if = 先判定, 再赋值, 也不是原子的~~

所以解决线程安全, 最主要的切入手段就是"加锁".

"加锁"相当于是把一组操作, 给打包成一个"原子"的操作.

事务的那个原子操作, 主要是靠回滚. 此处这里的原子, 则是通过锁进行"互斥", 也就是这个线程进行工作的时候, 其他线程无法进行工作.

那根据上面的例子和代码, 我们就可以知道要给count++加锁, 使用synchronized关键字即可.

于是乎代码变动成了这样.

java 复制代码
class Counter {
    public int count = 0;

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

}

// 线程安全问题演示.
public class Demo12 {
    public static void main(String[] args) throws InterruptedException {
        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++这一件事, for循环并没有加锁, for循环中操作的变量i是栈上的一个局部变量. 两个线程, 是有两个独立的栈空间, 也就是完全不同的变量, 就不涉及到线程安全问题. 因此,这两个线程,有一部分代码是串行执行的, 有一部分是并发执行的, 就仍然要比纯粹的串行执行效率要高.

synchronized进行加锁解锁, 其实是以"对象"为维度进行展开的.

加锁目的是为了互斥使用资源.(互斥的修改变量)

synchronized每次加锁,也是针对某个特定的对象加锁!


如果两个线程针对同一个对象进行加锁
就会出现锁竞争/锁冲突(一个线程能加锁成功,另一个线程阻塞等待), 那么就可以解决线程安全问题.

具体是针对哪个对象加锁,不重要.
重要的是, 两个线程, 是不是针对同一个对象加锁.

就比如更改一下代码, 也一样可有算出正确答案.

java 复制代码
class Counter {
    public int count = 0;

    private Object locker = new Object();

     public void increase() {
        synchronized (locker) {
            count++;
        }
    }

    public void increase2() {
        synchronized (locker) {
            count++;
        }
    }

}

// 线程安全问题演示.
public class Demo12 {
    public static void main(String[] args) throws InterruptedException {
        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.increase2();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(counter.count);
    }
}

4) 内存可见性,引起的线程安全问题.

java 复制代码
public class Demo13 {

    private static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                ;
            }
            System.out.println("t1 执行结束. ");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入 isQuit 的值: ");
            isQuit = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

这就是内存可见性的问题.

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

程序在编译运行的时候, java编译器和jvm可能会对代码做出一些"优化". 一般的程序猿是负责写代码的.当写好一个代码之后,人家开发java编译器,开发jvm的大佬,可能会认为你这个代码写的不够好. 当你的代码实际执行的时候, 编译器/jvm就可能把你的代码给改了,保持原有逻辑不变的情况下,提高代码的执行效率.

编译器优化, 本质上是靠代码,智能地对你写的代码进行分析判断进而进行调整. 这个调整过程大部分情况下都是ok的, 都能保证逻辑不变. 但是, 如果遇到多线程了,此时的优化可能就会出现差错, 也就是使原有的代码逻辑改变了.

volatile关键字的出现, 就弥补了上述的问题.

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.

⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的并发效果.

5) 指令重排序, 引起的线程安全问题.

相关推荐
shuair26 分钟前
idea 2023.3.7常用插件
java·ide·intellij-idea
paterWang1 小时前
基于 Python 和 OpenCV 的酒店客房入侵检测系统设计与实现
开发语言·python·opencv
小安同学iter1 小时前
使用Maven将Web应用打包并部署到Tomcat服务器运行
java·tomcat·maven
Yvonne9781 小时前
创建三个节点
java·大数据
东方佑1 小时前
使用Python和OpenCV实现图像像素压缩与解压
开发语言·python·opencv
我真不会起名字啊2 小时前
“深入浅出”系列之杂谈篇:(3)Qt5和Qt6该学哪个?
开发语言·qt
laimaxgg2 小时前
Qt常用控件之单选按钮QRadioButton
开发语言·c++·qt·ui·qt5
水瓶丫头站住2 小时前
Qt的QStackedWidget样式设置
开发语言·qt
不会飞的小龙人2 小时前
Kafka消息服务之Java工具类
java·kafka·消息队列·mq
是小崔啊2 小时前
java网络编程02 - HTTP、HTTPS详解
java·网络·http