线程:并发中可能存在的问题

1. 并发编程三大特性

并发编程的三大特性实际上是并发编程中所有问题的根源,

  1. 原子性
    所有操作要么全部成功,要么全不成功
  2. 可见性
    一个线程对变量的修改,其余的线程能够立刻读取到变量的最新值
  3. 有序性
    代码在执行阶段的执行顺序与程序中编写顺序可能不一致

2. 原子性

原子性是三大特性中最好理解的,此处需要引入竞态条件的概念。

竞态条件

竞态条件是指,在多线程的情况下,由于多个线程执行的时序不同,而出现不正确的结果。

以抄写单词为例,多个人抄写100遍,

  1. 查询剩余抄写次数
  2. 如果剩余次数大于1,则把次数减1
  3. 抄写单词

这三个操作是原子的,在执行区间不能有其他线程读取剩余次数。

上例也是最常见的竞态条件类型。上面例子的问题出现在第 2、3 步操作依赖于第1步的检查,而第一步的检查结果并不能保证在执行 2、3 步的时候依旧有效。这是因为其它线程可能在在执行完第一步时已经改变了剩余次数。此时 2,3 步依旧会按照已经失效的检查结果继续执行,那么线程安全问题就出现了。

单例模式在并发问题中就很容易出现这种错误,

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if(singleton==null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

这段代码在非并发的情况下没有任何问题。但是在并发的情况下,因为竞态条件有可能引发错误。如果线程 A 判断 singleton 为空后准备创建 singleton 对象。此时线程切换到线程B,B也开始执行这段代码,它同样会判断 singleton 为空去创建 singleton,这样本来的单例却变成了双例,和期望的正确结果不一致。

3. 可见性

缓存不一致性

不可见性是由于缓存不一致性导致的,缓存一致性一直是编程领域的难题之一。变量被修改后,当前线程中是能够立刻被看到的,但是并不保证别的线程会立刻看到。

public class visibility {
    private static class ShowVisibility implements Runnable{
        public static Object o = new Object();
        private Boolean flag = false; 
        @Override
        public void run() {
            while (true) {
                if (flag) {
                    System.out.println(Thread.currentThread().getName()+":"+flag);
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ShowVisibility showVisibility = new ShowVisibility();
        Thread blindThread = new Thread(showVisibility);
         blindThread.start();
        //给线程启动的时间
        Thread.sleep(500);
        //更新flag
        showVisibility.flag=true;
        System.out.println("flag is true, thread should print");
        Thread.sleep(1000);
        System.out.println("I have slept 1 seconds. I guess there was nothing printed ");
    }
}

理论上主线程将子线程的flag改为 true后,blindThread开始打印。但是实际执行情况如下,

flag is true, thread should print
I have slept 1 seconds. I guess there was nothing printed

实际上,blindThread会一直保持不打印的状态,因为进入 while循环后,循环过程使用的flag变量不是内存中的flag变量,而是子线程缓存中的变量。while 循环不断获取该变量值,不断从缓存中读取。即使内存更新的情况下,子线程依旧无法知道该变量的实际值。

CPU缓存模型

摩尔定律表明CPU的运算速度每 18 个月速度将会翻一番。CPU 的计算速度提升了,但是内存的访问速度却没有什么大幅度的提升。这就好比一个脑瓜很聪明程序员,很快就想好程序怎么写,但是电脑性能很差,每敲一行代码都要反应好久,导致完成编码的时间依旧很长。CPU 计算的瓶颈出现在对内存的访问上,所以CPU使用了 L1、L2、L3,一共三级缓存。其中 L1 缓存根据用途不同,还分为 L1i 和 L1d 两种缓存。如下图,
image

缓存的访问速度是主存的几分之一,甚至几十分之一。通过缓存极大的提高了 CPU 计算速度。CPU 会先从主存中复制数据到缓存,CPU 在计算的时候就可以从缓存读取数据了,在计算完成后再把数据从缓存更新回主存。这样在计算期间,就无须访问主存了,速度大大提升。加上缓存后,CPU 的数据访问如下,
image
这个模型也就解释了 blindThread运行过程中没有读取内存中的flag变量的原因。

Volatile关键字

对上面的 blindThread代码进行一处修改就能够解决子线程不输出的问题,

private Boolean flag = false;

改为,

private volatile Boolean flag = false;

4.有序性

CPU 为了提高运行效率,可能会对编译后代码的指令做一些优化,这些优化不能保证代码顺序执行。但是一定能保证代码执行的结果和按照编写顺序执行的结果是一致的。

指令重排序的优化仅对单线程程序确保安全。如果在并发的情况下,程序没能保证有序性,程序的执行结果往往会出乎意料。另外注意,指令重排序,并不是代码重排序。代码被编译后,一行代码可能会对应多条指令,所以指令重排序更为细粒度。

指令重排实例

public class Singleton {
    private static Singleton instance; 
    private Singleton (){}
 
    public static Singleton getSingleton() {
        if (instance == null) {                         
            synchronized (Singleton.class) {
                if (instance == null) {       
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

上面通过双重判断的方式希望能够避免单例模式构建失败,理想的执行过程中即使在并发的环境中不会出现单例模式失效的情况,理论上这样的双重判断是没毛病的。

但是实际执行过程中依旧会出现问题,与原子性中的例子不同,此时出现的不是过多对象构建的现象。而是出现对象获取不正确的现象。

原因就出现在instance = new Singleton();创建对象的过程中,这一行代码会被编译为3条指令。正常指令顺序和实际执行顺序的对比如下,
image
可以看出在优化后第 2 和第 3 步调换了位置。

  1. 假如线程 A 正在初始化 instance,此时执行完第 2 步,正在执行第三步
  2. 线程 B 执行到 if (instance == null) 的判断,那么线程 B 就会直接得到未初始化好的 instance,而此时线程 B 使用此 instance 显然是有问题的

要解决本例的有序性问题很简单,只需要为 instance 声明时增加 volatile 关键字,volatile 修饰的变量是会保证读操作一定能读到写完的值

5. 问题根源—JMM模型

JMM概述

JMM是Java内存模型,是一种规范,描述了Java程序的运行行为,包括多线程操作对共享内存读取时所能读取的遵守的规则。

CPU的性能越来越强大,受迫于频率提升的困难,现代CPU架构开始向多核发展。为充分使用CPU的性能,越来越多的开发者会选择多线程程序开发。CPU在计算时会做一些优化,这些优化对於单线程程序来说是没有问题的,但对多线程程序则不是那么的友好。

计算机在运行时,绝大多数时间都会把对象信息保存在内存中。但是在此期间,编译器、处理器或者缓存都可能会把变量从分配的内存中取出处理再放回。比如在while循环中判断flag是否为true来执行一段特定的逻辑,那么编译器为了优化可能会选择把flag值取到缓存中。此时主存中的flag值可能会被其它线程所改变,但是此线程是无法感知的。直到某个特定的时机触发此线程从主存中刷新flag值。所有这些优化都是为了程序有更好的性能。在单线程的程序中,这种优化对于用户来讲是毫无感知的,不过多线程的程序中,这种优化有些时候会造成难以预料的结果。

JMM允许编译器和缓存保持对数据操作顺序优化的自由度。除非程序使用Synchronized或者volatile显式的告诉处理器需要确保可见性。这意味着如果没有进行同步,那么多线程程序对于数据的操作,将会呈现不同的顺序。也就是有序性一节中,基于代码顺序对数据赋值顺序的推论,在多线程程序中可能会不成立。

Happens-before规则

JMM对程序中所有操作都定义了Happens-before规则。无论两个操作是否在同一个线程,如果要想保证操作A能看到操作B的结果,那么A、B之间一定要满足Happens-Before关系。如果两者间不满足Hapen-Before关系,JVM可以对其任意重排序。

Happens-Before在多线程领域具有重大意义,如果想对共享变量的操作符合设想的顺序,那么需要依照Happens-Before原则来开发。happens-before并不是指操作A先于操作B发生,而是指操作A的结果在什么情况下可以被后面操作B所获取。

Happens-before原则如下,

  1. 程序顺序规则,如果程序中A操作在B操作之前,则线程中A操作也同样在B操作之前执行
  2. 上锁原则,不同线程对同一个锁的lock操作一定发生在unlock操作之前
  3. volatile变量原则,对于volatile变量的写操作一定早于对其的读操作
  4. 传递规则,如果A早于B执行,B早于C执行,则A一定早于C执行
  5. 线程中断原则,线程interruput方法一定早于检测到线程的中断信号
  6. 线程终结规则,如果线程A终结了,并导致另外一个线程B中ThreadA.join()方法取得返回,则线程A中所有的操作都早于线程B在ThreadA.join()之后执行的操作

6. 死锁

前面几节讲解了并发的三大特性 - 原子性、可见性、有序性。解决这些问题的关键就是同步,而一种重要的同步方式就是加锁,所谓的加锁就是某个线程声明某个资源暂时由当前独享,等用完后,此线程解锁,也就是放弃对该资源的占有。

如果有其它线程等待使用该资源,那么会再次对此资源加锁。所谓的死锁,其实就是因为某种原因,达不到解锁的条件,导致某线程对资源的占有无法释放,其他线程会一直等待其解锁,而被一直 block 住。

死锁产生原因

  1. 交叉死锁
    线程A持有资源R1的锁,线程B持有资源R2的锁。此时线程A想获取R2的锁,B也想要获取R1的锁。两个线程都在等待对方释放资源,就一直僵持
  2. 内存不足
    假设系统内存20M,两个执行的线程鸽子使用了10M,但是10M对于任务的执行是不够的,两个线程都在等待对方执行完毕释放内存
  3. 交互过程死锁
    客户端发送请求服务端响应为例,如果在交互过程中出现了数据丢失,客户端以为服务端没有返回数据,服务端以为客户端还没收到数据。两个端都在等待对方的回信,如果没有设置超时,会造成持续的等待
  4. 数据库锁
    数据库中对表或者行记录加锁,如果没能正确释放锁,会导致其他线程陷入等待
  5. 文件锁
    与数据库锁形式相近,无法正确释放文件锁会导致其他线程无法读取文件内容,直到系统释放文件资源
  6. 死循环
    某个线程对资源加锁后,陷入死循环,一直无法释放锁

死锁举例

交叉死锁是最常见的死锁,

public class DeadLock {
    private final String write_lock = new String();
    private final String read_lock = new String();

    public void read() {
        synchronized (read_lock) {
            System.out.println(Thread.currentThread().getName() + " got read lock and then i want to write");
            synchronized (write_lock) {
                System.out.println(Thread.currentThread().getName() + " got read lock and write lock");
            }
        }
    }

    public void write() {
        synchronized (write_lock) {
            System.out.println(Thread.currentThread().getName() + " got write lock and then i want to read");
            synchronized (read_lock) {
                System.out.println(Thread.currentThread().getName() + " got write lock and read lock");
            }
        }
    }

    public static void main(String[] args) {
        DeadLock deadLock = new DeadLock();
        new Thread(() -> {
            while (true) {
                deadLock.read();
            }
        },"read-first-thread").start();

        new Thread(() -> {
            while (true) {
                deadLock.write();
            }
        },"write-first-thread").start();
    }
}

main方法的执行结果,(本次运行子线程read-first-thread先执行,可能其他测试过程中write-first-thread会先执行,不过都会产生交叉死锁)

read-first-thread got read lock and then i want to write
read-first-thread got read lock and write lock
read-first-thread got read lock and then i want to write
read-first-thread got read lock and write lock
read-first-thread got read lock and then i want to write
read-first-thread got read lock and write lock
read-first-thread got read lock and then i want to write
write-first-thread got write lock and then i want to read

本次运行过程中,在 write 线程启动前,一切正常。read-first-thread 线程能够先后获得 read 锁和 write 锁。但是当 write 线程启动后,立刻出现了问题,日志不再打印,而是停留在 write 线程等待 read 锁这一步。这是因为已经死锁了。 read 线程在等 write 线程释放写锁,而 write 线程在等 read 线程释放读锁。两个线程就会如此一直等下去了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章