引发线程安全问题的原因及解决方法

本文详细探讨了多线程编程中线程安全问题的五个主要原因:线程抢占、并发修改同一变量、内存可见性、指令重排序和死锁。作者通过示例解释了这些问题,并提供了相应的解决策略,如使用锁机制(synchronized)和volatile关键字来确保数据一致性。
摘要由CSDN通过智能技术生成

在多线程编程中,很有可能我们得到的结果与我们预期得到的结果大相径庭,但是从代码上看我们又找不到什么错误.这其实就是线程安全问题引发的结果.在本文中,我们将详细地介绍引起线程安全问题的原因以及我们该如何解决对应原因引发的线程安全问题.

引起线程安全问题的原因一共有5个:

1. 线程之间抢占式执行,随机调度

2. 多个线程同时修改同一个变量/修改操作不是原子的

3. 内存可见性

4. 指令重排序

接下来,我们将一个个详细地进行分析.

1. 线程之间抢占式执行,随机调度

我们先来看一个例子:

public static void main(String[] args) {
        Thread t = new Thread(()->{
            while(true) {
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("hello thread");
            }
        });
        t.start();
        while(true) {
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("hello main");
        }
    }

针对上述代码,输出的结果是什么呢?也许许多读者认为,由于开启t线程需要时间,先打印"hello main",然后打印"hello thread",然后由于sleep时间一样,就不断交替打印下去.

但是,实际上的结果是是什么呢?请看下图:

我们发现一开始确实好像一直是先打印"hello main"再打印"hello thread";但是后面又出现了先打印''hello thread''再打印''hello main"的情况,这就证明了线程是随机调度的,这样得到的结果不确定引发了线程安全问题.

对应解决方法

这个原因其实是线程执行过程中的一个特点,我们无法直接去改变这样一个特性.对于上述的死循环内的打印,我们目前无法做到解决,但是我们可以通过对线程进行一些设计来避免出现这样的情况,上述的代码只是为了展示线程的调度是随机的,真实写代码肯定不会这么写.

2. 多个线程同时修改同一个变量(操作不是原子的)

我们仍然以一个例子作为开端:

public class test {
    private static int count =0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

我们在这里创建两个线程.每个线程里面都对count进行50000次自增操作,让着两个线程同时执行,都执行完后main线程打印出count.结果会是多少呢?100000?我们给出几张包含结果的照片:

不难发现,我们几乎每次得到的结果都是不一样的,而且相差很大,这是为什么呢?我们需要站在指令的角度来理解这个现象:

对于count++这个操作,一共涉及到三条指令:

1.load-->把内存中的count值读取到寄存器中.

2.add-->把寄存器中count的值加一.

3.save-->把寄存器中count的值放回到内存中count的地址中.

对于一个单线程来说,这个过程非常顺畅,load-add-save的过程一直按顺序进行,得到的结果肯定是100000.但是对于一个多线程来说,我们无法保证所有的save都在load之后,可能一个线程正在读,而另外一个线程之后马上存进去.产生了类似于数据库事务中的"脏读"问题.由于在同一时间点上,我们不知道这里两个线程各自在做什么操作,因此得到的结果可能是各种各样的,毫无规律可言,这也引发了所谓的线程安全问题.

对应解决方法

最简单的解决方法就是,设置两个变量各自记录,最后在把它们加起来,避免同时对一个变量的值进行修改.

但是,还有另外一种方法更加推荐,还记得我们在数据库事务中提及的如何解决"脏读"的问题的吗?

就是,加锁!在JAVA中我们也同样有加锁操作,把一些操作打包成一个"原子",保证执行的连贯性.

synchronized 关键字

在Java中,引入了synchronized关键字进行加锁操作,我们先来看一下它是怎么使用的,仍然是上述的例子:

public class test {
    private 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 < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

synchronized在使用时需要一个锁对象,这个对象可以是任何引用类型的,随后跟上一个大括号,里面放入打包好的操作语句,这样我们就将大括号里面的操作进行了"加锁"操作.

需要注意:在上述两个线程"加锁"时,需要使用同一个锁对象,否则不能达到预期效果.

sychronized还可以用来修饰方法:                                                                                                  sychronized修饰普通方法,相当于对this进行加锁;synchronized修饰静态方法,相当于对类对象加锁.

执行synchronized这个语句代码块时,经历了下述过程:                                                              synchronized捕获锁对象locker,进入代码块执行操作,出代码块进行解锁操作;然后这个锁对象locker就可以被下一个对应的synchronized语句捕获,进行加锁......直到所有需要捕获这个锁对象locker的synchronized语句全部执行完毕.

死锁问题

虽说"加锁"这个操作能够解决上述所提及的线程安全问题,非常好用;但是,锁也不是随便乱加的.不合适的"加锁"操作,会出现问题--->死锁问题.简单来说就是僵住了.

我们先来写一个"死锁"代码来观察一下:

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

        Thread t1 = new Thread(()->{
            synchronized (locker1) {
               System.out.println("t1获取到了锁1");
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t1获取到了锁2");
                }
           }


        });
        Thread t2 = new Thread(()->{
            synchronized (locker2) {
                System.out.println("t2获取到了锁2");
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1) {
                    System.out.println("t2获取到了锁1");
                }
            }
        });
        t1.start();
        t2.start();
    }

根据这个结果,我们发现t1线程只获取到了锁1,t2线程只获取到了锁2.这里面其实就产生了死锁,运行一下程序就会发现程序"卡死了".

死锁发生的必要条件

然后我们先来介绍一下死锁发生的四个必要条件:

1.互斥(锁特性)

2.不剥夺(锁特性)

3.请求并保持

4.循环等待

所谓必要条件,就是缺一不可,只要少了上面的任意一个条件就不会构成死锁.

对上面的程序我们可以来分析一下,死锁发生的条件都出现在哪?

首先,线程t1获取到锁1,等待1s,开始等待获取锁2;在线程1等待1s的过程中,线程2获取到锁2,开始等待锁1.由于前两个死锁发生的必要条件都是锁的特性,一般我们不做更改,因此这里死锁产生的原因是由于后面两点.

这里线程t1获取到锁1后,请求锁2,而线程t2获取到锁2后,请求锁1.形成了请求和保持,同时线程t1等待线程t2获得的锁,线程t2等待线程t1获得的锁,形成了循环等待.这样就产生了死锁.

死锁主要的三种场景
1. 锁是不可重入锁

比如说如下代码:

 public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(()->{
           synchronized(locker) {
                synchronized(locker) {
                    
                }
            }
        });
        t1.start();
    }

里面这个synchronized语句需要执行,则需要先获取到锁对象locker,但是这个锁对象又被外部的synchronized语句所占有,因此理论上来说,这个程序会出现死锁问题.但是由于synchronized的可重入特性,因此这样的代码在Java中是可以正常运行的.

2. 两个线程两把锁

这种情况就是我们刚才给出的例子:

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

        Thread t1 = new Thread(()->{
            synchronized (locker1) {
               System.out.println("t1获取到了锁1");
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t1获取到了锁2");
                }
           }


        });
        Thread t2 = new Thread(()->{
            synchronized (locker2) {
                System.out.println("t2获取到了锁2");
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1) {
                    System.out.println("t2获取到了锁1");
                }
            }
        });
        t1.start();
        t2.start();
    }
3. N个线程,M把锁

这种情况其实是第二种场景的推广,其实也是比较出名的哲学家就餐问题:

这里有一张圆桌,上面坐了五个哲学家要一起吃饭,但是每两位哲学家之间只有一根筷子,只有同时拥有一双筷子才可以吃饭,我们假定哲学家都是非常固执的人,他们只会先拿起靠近自己左手边的筷子,再拿起自己右手边的筷子.这样我们根据上图可以模拟一下过程:                                                        1号哲学家获取到1号筷子     2号哲学家获取到2号筷子      3号哲学家获取到3号筷子                        4号哲学家获取到4号筷子      5号哲学家获取到5号筷子

然后,按照道理5号哲学家要获取1号筷子,但是1号筷子在1号哲学家手中,没有办法被5号哲学家获取.这样就形成了"死锁".这里是对第二种死锁产生的场景的推广.

如何避免死锁

我们刚才讲过,只要不满足上述四个必要条件之一,就不会发生死锁.而前面两个条件是锁的特性,当然我们也可以自己实现一个可以剥夺的锁,但是在一般情况下我们不对它进行调整.但是我们可以通过破坏后面两个条件来避免死锁的产生.

破坏请求和保持--------避免使用嵌套锁.

破坏循环等待-----------约定好使用锁的顺序.

3.内存可见性

我们仍然以一个例子来引入:

public class Demo {
    public 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);
            count = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

对于这个程序来说,开启线程t1和线程t2后,t1会不断的执行while循环,t2会因为需要输入读取一个输入的整数而进入阻塞,当我们输入一个非0的整数之后,理论上t1会不再执行循环,打印t1结束.

但是实际上,t1并不会进行打印,而是继续不断执行while循环.

那么count的值到底有没有改变呢?

如改!!!这其实就是内存可见性产生的线程安全问题.

我们仍然需要站在指令的角度来理解:

对于 count == 0 这个看上去是一个简单的比较语句,实际上做了两件事:

1. load 把内存中count的值读取到寄存器中.

2. cmp 在寄存器中,把这个值和0进行比较.

但是,这个循环在短时间内执行了许多次,就会出现了大量的load和cmp操作.但是load操作(内存->寄存器)占据的时间相比于cmp操作(寄存器内部)长好几个数量级,JVM就会自动地进行优化----->只进行一次load操作,这就导致了即使我们后来修改count的值,实际运行时使用的count的值还是0.

虽然,优化可以大幅度节省运行的时间,但是在这种情况下就产生了线程安全问题.

对应解决方法

JVM的设计者在设计时,当然也考虑到了这一点:设计了volatile关键字.

volatile关键字的含义是容易变动的,使用volatile关键字修饰一个变量,相当于告诉JVM,这个变量是容易变动的,不应该对其进行优化.虽然在一定程度上增加了程序的运行时间,但是可以保证程序不会因为内存可见性的问题产生线程安全问题.

4. 指令重排序

这里我们先来看一小段代码:

{
    Object object = null;
    object  = new Object();

}

我们来分析一下,这两条语句到底做了什么事?

对于第一条语句,我们创建了一个指向Object类对象的object引用,目前它指向的对象为null

对于第二条语句,我们站在指令的角度简要地去理解,总体上做了三件事:

1. 向内存申请空间用来存放接下来将实例化出的对象.

2. 实例化对象并初始化

3. 将这个对象的引用赋值给object

在程序执行过程中,编译器可能会自动地对程序进行优化,这就有可能导致我们写的代码和实际运行的代码是有所不同的.比如上述代码,第二条语句中的三条指令,毋庸置疑是第一条先执行,但是第二条和第三条谁先执行就不好说了.

在单线程程序中,2和3谁先执行并不会影响到程序运行的结果;但是在多线程中,这就有可能导致非常严重的后果,比如在单例模式中就有可能不同线程创建出多个类实例,这在单例模式中式不允许的.(单例模式在后一篇博客马上会详细介绍).

对应解决方法

其实要解决指令重排序引发的线程安全问题,我们仍然需要使用到关键字volatile,因为指令重排序也是属于编译器对于我们代码的一种优化,加上volatile也就显示地声明了,对于这个变量我们不需要优化,编译器也就只好"转身离开".

  • 22
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值