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

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) 指令重排序, 引起的线程安全问题.

相关推荐
西元.2 分钟前
多线程循环打印
java·开发语言·jvm
高林雨露2 分钟前
Kotlin 基础语法解析
android·开发语言·kotlin
ml130185288749 分钟前
DeepSeek 助力心理医生小程序赋能!心理咨询小程序 线上咨询平台搭建
java·开发语言·小程序
不辉放弃9 分钟前
零基础讲解pandas
开发语言·python
用键盘当武器的秋刀鱼10 分钟前
springBoot统一响应类型3.5版本
java·spring boot·spring
A227411 分钟前
Netty——心跳监测机制
java·netty
huangyingying202529 分钟前
03-分支结构
后端
Heliotrope_Sun31 分钟前
测试用例篇
java·测试用例
00后程序员31 分钟前
【Flutter -- 基础组件】Flutter 导航栏
后端
bobz96534 分钟前
ovs internal port 对比 veth-pair 性能
后端