线程安全问题

本文详细阐述了线程安全问题的定义,产生原因(包括线程调度随机、多线程共享变量、操作非原子性和内存可见性),并探讨了解决策略,如加锁和volatile关键字的使用,以及如何避免死锁问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

1. 什么是线程安全问题?

2. 产生线程安全问题的原因

3. 如何解决线程安全问题

  1. 线程调度随机 

  2. 多线程对一变量修改

  3. 操作不原子

  4. 内存不可见性

在多线程中,最最复杂的地方就是线程安全,当然它也是最重要的地方。

1. 什么是线程安全问题?

     什么是线程安全问题呢?

     举个简单的例子来说:

     某些代码,在单个线程中运行,执行结果完全正确。但是,如果将一个操作,在多个线程中运行,就可能会出现 bug 。这就是 “ 线程安全问题 ” ,也就是 “ 线程不安全 ” 。                                 

     就举个简单点的例子,我们在两个线程中各自自增 1w 次,那当我们在 main 方法中打印就应该是 2w ,事实果真如此吗,请看VCR:

     那为什么会出现上述问题呢?

     其实,count++ 这步操作 站在 cpu 的角度上本质是分成三步进行的,先将数据从内存读取到 cpu 寄存器中,在将寄存器中的数据 ++ ,最后在保存到内存中去。

     由于 线程之间的调度是随机的,所以,cpu 完全有可能出现上图情况

     按照上图,当我们两个线程进行如图的操作时,虽然线程 t1 和线程 t2 个自增了一次,可是存入内存中的结果只自增了一次,这就导致出现了 bug ,也使得 “ 线程不安全 ” 。

2. 产生线程安全问题的原因

  1. 在操作系统中,由于线程的调度顺序是随机的(抢占式执行)

     这个是 线程的属性。

  2. 多个线程,针对同一个变量进行修改。 就如同上面的两线程对 count 进行值的修改。

     这个是我们的需求。

  3. 由于你在线程中执行的操作,不是原子的。

     什么叫做不是原子的呢?

     就拿上面 count 来说吧,当 count 进行 ++ 的时候, cpu 会先取值,加加,存值,它将 count++ 分成了三个步骤,所以就不是原子的。

  4. 内存可见性问题

     这是由于编译器优化处理 而出现的问题。

  5. 指令重排序问题(后面单例模式中会说明)

3. 如何解决线程安全问题

    要想解决线程安全问题,就得从线程安全问题产生的源头出发:

  1. 线程调度随机 

     这个是属于线程的基本属性,我们无法进行更改。

  2. 多线程对一变量修改

     这个属于我们的需求,我们不希望修改这个。

  3. 操作不原子

     针对这个原子性,我们可以对其加锁,将不原子的操作锁起来,变成原子的,这样就不会影响线程安全了,那是通过什么实现的呢,请看下图:

     当我们通过 synchronized 对 Object类型的 locker 进行加锁之后,多线程对一个变量修改,也就不会出现 bug 了。这里的原理是什么呢:当我们在 t1 线程中对 locker 进行加锁之后,当 t2 在想对locker 进行加锁的时候,就会产生 “ 锁冲突 / 锁竞争 ”,t2 在获取到 锁 locker 之前就会阻塞等待。

     这里加锁的对象不重要,主要是用来判断 是否竞争的同一个锁 会不会出现“ 锁冲突 / 锁竞争 ”

     如果我们对不同的 对象进行加锁,这两个线程就 不会出现 “ 锁冲突 / 锁竞争 ” ,就导致这个锁加了就跟没加一样,两个线程不会因为 锁 而等待,就可能会出现 bug。

代码:

public class Main {
    static int count=0;
    static Object locker=new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<10000;i++){
                synchronized (locker) {
                count++;
            }
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<10000;i++){
                synchronized (locker) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        // 等待两线程执行完成
        t1.join();
        t2.join();
        System.out.println(count);
    }

     在加锁解决 “ 线程不安全问题 ” 之后,还得注意锁的使用,当我们在使用锁的时候,难免会出现 “ 锁嵌套 ” ,而 “ 锁嵌套 ” 就可能会导致 “ 死锁 ” 的情况:

     那当我们写出上述代码( 锁嵌套 )的时候,为什么最终还是会输出所有的结果呢?

     这是由于 cpu 处理速度特别快,就导致,t1 线程创建完调用 start( ) 方法之后,很快就对 locker 上锁了,在 t2 还没来得及对 locker2 进行加锁的时候,t1 已经对 locker2 进行加锁了,并且执行完打印操作之后,快速释放 两个锁,这就导致 虽然 锁嵌套 了,但是还是能够输出正确的 结果。

     所以,我们可以在 获取第一把锁之后,sleep( ) 一秒,再来看结果就会发现 线程都没有结束,并且,都没有打印出东西:

那针对上述 “ 死锁 ” 的情况,我们该如何去进行代码的修改来避免这个问题呢?

我们可以针对 “ 死锁 ” 的形成条件入手:

   1. 互斥使用 : 当我们对某个对象进行上锁之后,解锁之前,其他线程不能对这个锁进行上锁操作,如果要使用,就只能阻塞等待。

     这是属于 锁 的基本属性,我们无法干预。

   2. 不可抢占 :当锁已经被某个线程拿到之后,除非这个线程主动释放,其他线程才能使用,不能够强行抢过来。

     这还是属于 锁 的基本特性。

   3. 请求保持 : 当一个线程尝试获取多把锁,先拿到 锁1 之后,再去尝试获取 锁2 ,这个过程中 锁1 不会释放。

     针对这个,我们可以调整代码结构,避免编写 “ 锁嵌套 ” 的逻辑。但是,在编写大量代码的时候,我们难免会不经意的编写到 嵌套结构:

     

   4. 等待循环 / 环路等待 : 等待的关系构成 环了。

     为了避免成环,我们可以约定对锁进行编号,然后,每次优先对编号小的锁进行加锁操作,此时,循环就破解了。

  

  拓展:

    “ 哲学家就餐问题 ” :

    怎么解决呢:

  4. 内存不可见性

     计算机运行的程序以及代码,要经常访问数据,而这些依赖的数据,往往会存在内存中,当 cpu 想要使用这个数据的时候,它就会线先内存中读,再放到 cpu 寄存器中,最后参与运算。

     由于 cpu 读取内存中的数据这个操作 其实非常慢,为解决这个问题,提高编译的效率,此时编译器就会对代码做一些优化,把一些本来要从内存中读取的操作,改成从 cpu 寄存器中读 ,这样减少了读取内存的次数,就可以提高整体的程序效率了。

   

     上述图中,我们在 线程t1 中执行死循环,判断条件为 isQuite == 0 ,由于 cpu 执行速率非常快,此时,编译器就发现,经历这么多次读内存,isQuite 还是没有变,此时 isQuite 就会被 cpu 认为并不改变,此时编译器就会优化代码,直接从 cpu 寄存器中读取,导致后面我们修改 isQuite 的值,线程t1 并没有从内存中读取 修改过的 isQuite ,这样 线程 t1 就陷入死循环了。 

      那如何解决这个问题呢?

      我们只需要对需要操作的 isQuite 用 volatile 修饰,就相当于告诉 编译器,你不要优化读取 isQuite 的操作。

下面这个代码,没有加 volatile 进行修饰,为啥还是能让 t1线程 结束呢?

      这是由于当我们加了 sleep 休眠之后,cpu 读取内存的操作就慢下来了,这就导致读内存的开销就大幅度减小了,就不会触发编译器对读内存操作的优化了,此时修改 isQuite 就会让t1停下来。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值