线程安全问题

本文详细解释了线程安全的概念,展示了线程不安全的示例并分析其原因,包括指令重排序和内存可见性问题。讨论了解决线程不安全的方法,如使用synchronized和volatile关键字,以及避免死锁的策略。
摘要由CSDN通过智能技术生成

目录

1.什么是线程安全

2.线程不安全的原因

3.解决线程不安全的方法

关于死锁问题的探讨:


1.什么是线程安全

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
 

2.线程不安全的原因

大家可以先猜测这个代码的结果,可能有的人会觉得是20000,但结果真的是这样吗? 

public class Demo15 {
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
           for (int i=0;i<10000;i++){
                count++;
           }
        });

        Thread t2=new Thread(()->{
            for (int i=0;i<10000;i++){
                count++;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count="+count);

    }
}

实际上这个代码的结果是不确定的, 这里运行了三次,但每次结果都不一样。其实再多运行多少次这个代码的结果每次也都还是不一样的,这就是典型的线程不安全的情况。

             

为什么会出现这样线程不安全的情况呢?

count++ 这个操作,站在 cpu 指令的角度上看,其实是3个指令:

  • load: 把内存中的数据加载到寄存器中
  • add: 把寄存器中的值 +1
  • save: 把寄存器中的值写回到内存

由于线程是随机调度,抢占式执行,那么在某个线程执行指令过程中,当它执行到任何一个指令的时候,其他线程都可能会抢占它的 cpu 。

                                       

上面这两种情况t1和t2都是可以做到互不干扰的让count+1。

不难看出上图中 其中一个线程的load 总在 另一个线程的save 之后,这样的就是线程安全的

 一旦 其中一个线程的load 不在 另一个线程的save 之后 都是会导致线程不安全的问题出现。

比如下面这样的情况,到最后只有t1这个线程成功让count+1了,t2的被t1覆盖掉了

 像这样的情况其实有无数种,这里就不一一列举了

          ……

3.解决线程不安全的方法

其实线程不安全最主要的原因就是以下这几点 :

1.线程在系统中是随机调度,抢占式执行的(根本原因)

2.多个线程同时修改同一个变量

3.线程针对变量的修改操作不是"原子"的

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

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

解决方案:

1.线程在系统中是随机调度,抢占式执行的(根本原因)

对于这个原因不是我们能改变的

2. 多个线程同时修改同一个变量

那可能有人会说,不如让多个线程改多个变量,各改各的,不会争起来。但是,对于前面的代码,t1和t2就是要修改同一个变量,那么这种情况下就不行了。 

3.线程针对变量的修改操作不是"原子"的

可以通过一些操作,把上述一系列"非原子"的操作,打包成一个"原子"操作,也就是把load,add,save这三个动作变成一个整体,要么一起做,要么都不做。为此,Java引入了一个关键字 synchronized,这个关键字的作用是给线程加锁。

 通过加锁可以起到互斥的效果,通俗一点的意思就是我办事的时候你打扰不了。

public class Demo15 {
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        // 先创建出一个对象, 使用这个对象作为锁
        Object locker=new Object();

        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="+count);

    }
}

对于上面这个代码,t1和t2仍然是随机调度抢占式执行,但是现在无论是谁争到了那把锁,它都可以做到在count++的时候不被另一个线程打扰(能够完整的执行一套load,add,save操作),t1争到了锁,那么t2就得阻塞等待,反之亦然,这时就能得到我们想要的结果了。

对于上述代码,t1和t2都加锁,线程是安全的,那么一个加锁一个不加锁线程还安全吗?

答案是否定的,t1和t2一个加锁一个不加锁,线程是不安全的,每次的执行结果又开始变得不可预料。

因为此时对于t1来说它加了锁就得守规矩,但是t2没加锁,它没有锁限制,那么这时候,t1拿到锁正干活呢,以为不会有人打扰,但t2却不讲武德,不阻塞等待了,不管三七二十一,撬了你的锁就一顿操作猛如虎,欺负守规矩的t1,那么这样就可能会把t1的结果覆盖掉。

关于死锁问题的探讨:

场景一:锁是不可重入锁,并且一个线程针对一个锁对象连续加了两次锁

class Counter {
    private int count = 0;

    public void add() {
        synchronized (this) {
            count++;
        }
    }

    public int get() {
        return count;
    }
}

public class Demo21 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (counter) {
                    counter.add();
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + counter.get());
    }
}

上述代码,t2被加了两把锁,假设 t2 先启动(t1 先不考虑)。
t2 第一次加锁,肯定能加成功,当 t2 尝试第二次加锁的时候,此时counter2 变量,属于已经被锁定的状态了。按照之前的理解,尝试针对一个已经被锁定的对象加锁就会出现阻塞等待,阻塞到对象被解锁为止。要想获取到第二把锁,就需要执行完第一层大括号,要想执行完第一层大括号,就需要先获取到第二层的锁,那么这就矛盾了,就会出现死锁问题。

解决方法:通过引入可重入锁来解决问题。

也就是java的锁不是普通的锁,经过处理,在这样的情况下即使嵌套使用加锁也不会出现死锁问题。 

场景二:两个线程两把锁

有线程1和线程2,以及有锁A和锁B

现在,线程1 和 2 都需要获取到 锁A 和 锁B

现在让两个线程分别拿到一把锁,然后再去尝试获取对方的锁

这样的代码结果会怎样呢?

public class Demo21 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    // 为了更好的控制线程的执行顺序, 引入 sleep, 否则死锁可能重现不出来.
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2) {
                    System.out.println("t1 获取了两把锁");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker1) {
                    System.out.println("t2 获取到两把锁");
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

在这个代码中, 

t1 尝试针对 locker2 加锁就会阻塞等待,等待 t2 释放 locker2 

t2 尝试针对 locker1 加锁也会阻塞等待,等待 t1 释放 locker1

两个线程互不相让,僵持不下,导致程序既没有结束,也没有打印线程中的内容。

这样的结果等于是告诉我们程序出bug了,我们回到代码中把代码的加锁顺序改一改,就不会出现上述结果了。


场景三: N个线程, M 把锁

让每个哲学家都坐在两个筷子之间.
每个哲学家要做两件事:1.思考人生.(放下筷子)2.吃面条.(拿起左右两根筷子)
每个哲学家什么时候吃面条,什么时候思考人生都是不确定的(抢占式执行)

如果出现极端情况,就会出现问题:同一时刻,所有的哲学家都拿起左边的筷子,那么所有的哲学家都无法拿起右手的筷子。而且,每个哲学家都是比较固执的人,每个哲学家只要吃不到面条,就绝对不会放下手里的筷子 。

这种情况也是比较典型的死锁情况。 

那么如何解决呢?

约定每个哲学家必须先获取编号小的筷子,后获取编号大的筷子。

放到代码的情况下就是:如果当代码中确实需要用到多个线程获取多把锁,那就约定好加锁的顺序,就可以有效避免死锁了 。

总结:产生死锁的必要条件(缺一不可)
1.互斥.(锁基本特性)
2.不可抢占/不可剥夺. (锁的基本特性)
3.请求和保持.(解决方法:避免锁嵌套使用)
4.循环等待.(解决方法:如果一定要嵌套使用锁,一定要约定好加锁的顺序 )

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

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

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            count = scanner.nextInt();
        });

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

上述代码我们预期的结果是:如果输入一个非零的结果那么线程t1就会退出循环,线程结束。

但是结果却并非如此 

可以看到无论我们输入什么都不结束,那么为什么会出现这样的问题呢?

上述问题产生的原因,就是"内存可见性"引起的问题。

如何解决内存可见性问题?

通过使用 volatile关键字 来修饰变量,给变量修饰上这个关键字之后,编译器就知道了这个变量是"反复无常的",那么编译器就不会按照上述策略进行优化了。

volatile关键字的作用主要有如下两个:

  1. 保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
  2. 保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。

现在代码结果就符合我们的预期了。 

注意:volatile 不能保证原子性 

 

 这个代码没加锁,但用volatile来修饰count,可结果依然不稳定

  • 49
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值