前言


我们之前也提到过线程不光有它的优势,同样的线程也会带来一定的风险

一般有以下三点问题:

  • 线程安全性问题
  • 线程活跃性问题
  • 线程性能问题

从第一点到第二点线程带来的风险严重性依次降低,第三个其实就是相当于优化。

线程安全性问题是一块大的专题,所以我们在解决线程安全性问题之前,我们先把第二点和第三点这两个问题先解决了。

一、线程活跃性问题


什么是活跃性问题呢?活跃性问题有多种形式,比如死锁、饥饿、活锁等等就是一种活跃性问题的体现。

死锁问题分析

死锁有一个非常经典的就是所谓的哲学家就餐的问题,有五个哲学家,每个哲学家在吃饭的时候,分给他们每人一只筷子,每个人只有一只筷子。

我们只要一般吃饭都需要用一双筷子,那么这些哲学家在谈论问题的时候,可能有些哲学家就饿了,于是就借用他旁边的人的筷子就组成了一双筷子,这样就可以吃饭了

image.png

但是假如每个人都不聊哲学问题了,都在那里吃饭,于是每个人都拿起了自己手中的那双筷子,再等待着另外一个人放下筷子,结果每个人都不放下自己手中的筷子,结果这五个哲学家最终就饿死了

image.png

就是说另外一个人手中有这个人所需要的资源,而另外一个人又有这个人手中所需要的资源,但是这两个资源他们两个人都不释放,所以他们两个人都拿不到自己需要的资源,这就是死锁问题

饥饿问题分析

就是餐厅排队吃饭,只有一个打饭窗口,有很多人来排队打饭,但是这些人非常没有素质非常没有礼貌来了就插队硬挤,而且买到了饭之后也不离开打饭窗口

image.png

可能就有那么一个弱小的同学,他死活就是挤不进去不能打饭,于是就被饿死了

在我们的线程中,线程有优先级这么一个概念,有的线程的优先级高,有的线程的优先级低,那么优先级低的线程,它就有可能一直得不到CPU的资源,也就是所谓的饥饿问题

活锁问题分析

相当于,两个人,这两个人非常的有礼貌,这两个人在独木桥相遇了,(假设一条河上面有两座桥)这两个人非常的有礼貌,握了一个手,说,“不好意思”,然后就退回去了

image.png

另一个人也不好意的退回去了,于是选择了另外的一条路,结果两个人又都在另一座桥上相遇,同理一直这样下去于是就一直反反复复。这就是活锁问题

二、线程性能的问题


比如说我们想看sping的资料,于是百度进入Spring的官网链接进去

image.png

但是我们来到这个网页上之后,我们发现,很多东西我都不认识

比如说我不认识Supports这个英文单词,我翻阅了一本英文词典查了一下这个单词

image.png

然后我回到那个网页,找到Supports这个地方,才可以继续往下读。

也就是说当我在查阅英文单词的时候:

  • 我需要记住我在刚刚读的网页中读到了哪一页的哪一个地方了
  • 我再来翻开这本词典查询这个英文单词
  • 再对到网页中翻到刚刚读到的位置,这就是所谓的上下文切换。

这样的话我们知道,显然不如直接在网页中往下读快。这就是性能问题

三、饥饿与公平问题


咱之前也已经说过一个餐厅里面排队的例子,如果一直打不到饭,那么就饿死了

我们知道当我们调用线程Start方法的时候,线程就会处于就绪状态

image.png

而就绪状态到运行状态之间是毫无规律的,谁能抢到CPU的时间片,那么谁就可以运行

而刚刚的的例子就是所谓的高优先级吞噬所有低优先级的CPU时间片,也就是在抢占CPU时间片的时候,高优先级它有可能会比低优先级的要快一些,但并不是绝对的

image.png

而低优先级的线程若一直没有抢占到CPU的时间片,那么也就导致了饥饿

除此之外还有就是所谓的线程被永久堵塞在一个等待进入同步块的状态

比如说,这里有一个方法两个线程同时的去执行这个方法

image.png

而这个方法加了锁了,当一个线程进来之后,在这里面干不完了,一直出不来了,那么,另外一个线程就一直等在这个方法外面

image.png

四、单线程与多线程的不同


一般在没有充足的同步的情况下,多个线程中的操作的执行顺序是不可预测的,那么可能就会我们说在单线程中正常执行的问题,那么在多线程中可能就会出现非常奇怪的问题

那么我们就来举一个例子来看一下线程安全性问题,我们写一个数值序列生成器

public class Sequence {

    
    int value;

    public int getNext(){
        return  value++;
    }
}

在这个类里面实现一个变量来保存当前的值,然后提供一个方法获取下一个值

那么当我们程序在调用的过程中,我们就可以发现不管怎么执行,它肯定是自增的,而且符合我们的预期

public static void main(String[] args) {

        Sequence sequence =new Sequence();

        while(true){
            System.out.println(sequence.getNext());
        }
}
//运行结果如下:
426358
426359
426360
426361
426362
426363

那么在我们多线程环境下,它会不会就有可能会出现不可预期的问题,执行的顺序是不是不可预期的呢?

public static void main(String[] args) {

    Sequence sequence =new Sequence();

    for (int i = 0 ;i<3; i++){
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    System.out.println(Thread.currentThread().getName() +" " +sequence.getNext());
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }
}
//运行结果如下:
Thread-1 0
Thread-0 1
Thread-2 1
Thread-0 2
Thread-1 2
Thread-2 3
Thread-1 3
Thread-0 3

发现出问题了,我们任务数值序列生成器不是产生一个唯一的产生重复的数值了,这样显然是不行的,那么我们发现这就是所谓的线程安全性问题

我们一起来分析看看是哪里出了问题,首先如图所对应三个线程

image.png

那么每一个线程是独立的,它都会执行这么一段代码getNext()方法

image.png

value++是个什么意思呢?value = value + 1

也就是说value的值先加1然后在赋给value,所以value++其实并不是一步,它相当于是两步操作

我们查看字节码来分析看看,是怎么回事,是怎么演示的?

image.png

首先我们定义这个变量是处于多个线程共享的一块区域,它属于一个公共区域里面,最初始的时候它的值为0

当第一个线程执行完iadd之后,value的值在我们的操作数栈中变成1了但是它还没有去设置value的值,因此此时value的值还是0

image.png

此时,如果第二个线程抢到了CPU的执行时间片,于是它也在执行iadd因为前一个线程并没有写入修改,所以此时它获取到的value的值也是0

而当我们开始执行putfield,执行往栈帧中的局部变量表中的value进行赋值的操作给value赋值为1

因为线程执行iadd时value值为0,而++后为1,所以两个线程应该是2的反而结果还是1,这就是造成了线程安全问题

那么如何解决线程安全性问题呢?其实非常简单,我们只需要在这里加一行

image.png

这样就不再会出现线程安全性问题了,我们再来执行代码看看吧

image.png

这里总结几点,具备以下这三个条件才会产生线程安全性问题。

  • 第一个是多线程环境下
  • 第二个是多个线程共享一个资源
  • 第三个是对共享资源进行非原子性操作

参考资料


龙果学院:并发编程原理与实战(叶子猿老师)


28640
116 声望25 粉丝

心有多大,舞台就有多大