当前位置: 首页 > news >正文

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

1. 观察线程不安全

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关键字即可.

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

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每次加锁,也是针对某个特定的对象加锁!


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

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

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

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) 内存可见性,引起的线程安全问题.

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

相关文章:

  • langchain学习笔记之消息存储在内存中的实现方法
  • Day3 25/2/16 SUN
  • Linux:用 clang 编译带 sched_ext 功能内核
  • 与传统光伏相比 城电科技的光伏太阳花有什么优势?
  • 最新智能优化算法: 阿尔法进化(Alpha Evolution,AE)算法求解23个经典函数测试集,MATLAB代码
  • 利用亚马逊AI代码助手生成、构建和编译一个游戏应用(下)
  • auto关键字的作用
  • Deepseek高效使用指南
  • 每日一题——最长上升子序列与最长回文子串
  • 渗透测试方向的就业前景怎么样?
  • PHP基础部分
  • 人工智能学习(八)之注意力机制原理解析
  • 赖莎莎:创意总监的跨洋之旅
  • 【数据采集】基于Selenium爬取猫眼Top100电影信息
  • 如何搭建Wi-Fi CVE漏洞测试环境:详细步骤与设备配置
  • 第四章 Vue 中的 ajax
  • 基于图像处理的裂缝检测与特征提取
  • easyCode代码模板配置
  • 【ESP32】ESP-IDF开发 | WiFi开发 | HTTPS服务器 + 搭建例程
  • Java 运算符
  • 保险经纪公司元保在纳斯达克挂牌上市,去年净赚4.36亿元
  • 当农民跨进流动的世界|劳动者的书信①
  • 擦亮“世界美食之都”金字招牌,淮安的努力不止于餐桌
  • 金砖国家外长会晤落幕,外交部:发出了反对单边霸凌行径的“金砖声音”
  • 医学统计专家童新元逝世,终年61岁
  • 孙磊已任中国常驻联合国副代表、特命全权大使