【线程安全】 三类线程安全问题

邓敏 2021年12月14日 96次浏览

什么是线程安全

《Java Concurrency In Practice》的作者 Brian Goetz 对线程安全是这样理解的,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。

通俗来讲,就是在多线程并发的情况下,每个线程的执行结果始终都是预期的结果。那么这个线程就是线程安全的。

出现线程安全的三种情况

在理解了上面的概念后,如果平时开发的时候就会发现,我们会经常遇到线程不安全的情况,大概的罗列了以下三种

  • 运行结果错误
  • 发布和初始化导致线程安全问题
  • 活跃性问题

运行结果错误

首先我们先看下面一段代码

public class WrongResult {
    private static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    count++;
                }
            }
        };
        Thread t0 = new Thread(runnable);
        Thread t1 = new Thread(runnable);

        t0.start();
        t1.start();

        t0.join();
        t1.join();
        System.out.println(count);
    }
}

上面这段代码的逻辑,就是两个线程对count进行自加一,每个线程都会循环自加1000次。那么预期的结果应该是2000。单其实执行的结果却没有2000,会始终比2000少,这究竟是什么原因导致这个种情况的发生呢。
这里就需要理解我们计算机CUP调度的分配了。在CUP的分配规则里面,是以时间为单位给每个线程去分配的,如果当前线程的所分配的时间执行完,就会切给另外一个线程去执行,这样不好的地方就是,他没有办法保证i + +的原子性,我们可以先看下面这张图。
image.png
通过上面的图我们可以了解到,i + +在cup执行的步骤其实分为三步

  • 第一步是读取i的值
  • 第二步是执行加一操作
  • 第三步就是保存结果的值

这样我们就可以仔细的思考一下,如果CUP给线程一分配的时间只足够他执行到第二步操作,之后就被切到了线程二,那么这时就会导致线程二没有获取到最新的值,因为线程一还没有执行到第三步去保存结果就被CPU给切掉了。那么这个时候线程二再去自增计算出来的值,就会和线程一获得的结果一样的。自然我们拿到的结果就会比预期的结果少了很多。像这种情况也就是最典型的线程安全问题。

发布和初始化导致线程安全问题

这种就比较好理解,比如在项目启动的时候,去获取一段初始数据,但是这个数据需要通过线程异步的初始化才会有。但是线程初始化数据需要时间,如果程序在线程还没有初始化完成数据后就去获取数据,这个时候就会导致线程安全问题。可以看下面这段代码来理解。

public class WrongInit {

    private List<String> info;

    public WrongInit() {
        info = new ArrayList<>();
        Thread thread = new Thread(()->{
           info.add("数据开始初始化");
            try {
                info.add("数据初始化中....");
                //模拟数据初始化需要的时间
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
           info.add("数据初始化完成!");
        });
        thread.start();
    }

    public static void main(String[] args) throws InterruptedException {
        WrongInit init = new WrongInit();
        init.info.forEach(System.out::println);
    }
}

上面的代码中,为了能模拟出数据没有完全初始化的情况,在线程中休眠了1秒中,其实数据应该是要打印出【数据初始化完成!】然而结果去只打印到【数据初始化中....】,其实在这里还会有另外一种情况,如果创建线程的时间大于程序调用的时间,可能会直接报空指针异常。这种也是线程不安全的情况。

活跃性问题

线程的活跃性问题其实可以分为三种,分别为死锁,活锁和饥饿。

这三种都有个一个共同的特性,就是他们会让线程卡住,死活也得不到运行结果,这种情况其实是最线程安全中最复杂的也是最严重的,如果线程卡住的太多,不仅会占用服务的资源,甚至还会导致服务假死或者宕机。

死锁

死锁比较常见,就是两个线程互相等单对方的资源,单同时又互不相让,都想自己先执行。可以看下面这段代码

public class ThreadDeadMain {

    private static Object o1 = new Object();

    private static Object o2 = new Object();

    public static void main(String[] args) {

        Thread thread01 = new Thread(()->{
           synchronized (o1){
               System.out.println(Thread.currentThread().getName()+"获取到o1的锁了");
               try {
                   TimeUnit.SECONDS.sleep(1);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               synchronized (o2){
                   System.out.println(Thread.currentThread().getName()+"获取到o2的锁了");
               }
           }
        });

        Thread thread02 = new Thread(()->{
            synchronized (o2){
                System.out.println(Thread.currentThread().getName()+"获取到o2的锁了");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o1){
                    System.out.println(Thread.currentThread().getName()+"获取到o1的锁了");
                }
            }
        });
        thread01.start();
        thread02.start();
    }
}

上面这段代码中启动了两个线程,每个线程中有两个锁,线程1启动时会先获取o1的锁,然后再去获取o2的锁,之后才会执行完毕最后释放o2和o1的锁,线程2相对于线程1的逻辑则相反,显示获取o2的锁,然后再去获取o1的锁,最后执行完毕释放o2的锁再去释放o1的锁。为了让两个线程能发生死锁的情况,我在两个线程都获取第一个锁的时候让线程休眠的一秒种,这样等到两个线程同时去获取第二个锁的时候,就会发现,线程1的第二个锁的o2在线程2的第一个锁总没有释放,然而线程2的第二个锁o1又被线程1占着,这样就会发生两者互不相让,又同时占领着锁。导致程序一直卡着。

活锁

活锁相对于死锁有种相反的意思,死锁是卡着资源,然后活锁不一样,他不占用锁的资源,但是他会一直运行着,不过他会一直循环运行,但是一直没有遇到正确的结果,导致线程一直在运行。但是他不会像死锁一样卡着不运行。

可以看到下面这段代码

public class ThreadLiveMain implements Runnable {

    private int num;

    public ThreadLiveMain(int num) {
        this.num = num;
    }

    @Override
    public void run() {
        System.out.println("中奖数字:"+num);
        int i = 0;
        do {
            //随机获取1-10的数字
            Random random = new Random();
            i = random.nextInt(10)+1;
            System.out.println("随机数:"+i);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }while (num!=i);
        System.out.println("抽中啦!");
    }
    public static void main(String[] args) {
        ThreadLiveMain main = new ThreadLiveMain(11);
        Thread thread = new Thread(main);
        thread.start();
    }
}

这是一个类似于抽奖的小程序,线程里面会随机出1-10的数字来判断自己是否中奖,如果我们给中奖数字在1-10内,这个时候程序就会得到正常的结果,因为他在随机数范围内。但是如果我们给了一个11,这个时候就超出随机数的范围了。线程会一直的去循环判断,但是又遇不到正确的随机数字,这样线程就不会停止下来,这种情况就称之为活锁。

饥饿

饥饿就比较有趣了,他就真的是因为饥饿所以才导致线程拿不到结果,Java的线程有优先级的概念,有1-10的优先级划分,如果一个线程的等级被设置到最低的1,那么这个线程可能永远也拿不到线程的资源,线程吃不到饭,自然也没力气干活,没力气干活那也就拿不到结果。还有一种情况就是某个线程持有某个文件的锁,如果其他线程想有修改文件就需要先获得这个文件的锁,那这个修改文件的线程就会陷入饥饿,没法在继续运行。