01 | 并发编程Bug的源头:可见性、原子性、有序性

可见性

一个线程对共享变量的修改,另外一个线程能够立刻看到,称之为可见性。这里的缓存是指CPU缓存,可见是指不同线程对内存中的共享变量可见。
image.png

原子性

image.png
CPU在线程间不断的切换来达到多个线程并发执行的效果,但在编程过程中,我们编写的代码往往不具备原子性,如count+=1,至少需要三条CPU指令

  • 指令1:把变量count加载的CPU的寄存器
  • 指令2:在寄存器中执行+1操作
  • 指令3:将结果写入内存

这就将导致多个线程并发对count进行读写操作时引发线程安全问题(线程切换)。

image.png

有序性

编译器为了优化性能,有时候会改变程序中语句的先后顺序(指令重排)。问:为啥需要指令重排?

指令重排的好处

  • 编译时:提升销量减少内存读取,如下代码重排之后可以直接从寄存器读取值无需再去内存读取

    1. // 重排前
    2. int h = "hello";
    3. int ag = 1024;
    4. h += "world";
    5. ag = ag*1024;
    6. // 重排后
    7. int h = "hello";
    8. h += "world";
    9. int ag = 1024;
    10. ag = ag*1024;
  • 处理器运行时:一个汇编指令可能涉及多个步骤,每个步骤可能使用不同的寄存器,现在的CPU一般采用流水线来执行指令,也就是说,CPU有多个功能单元(如获取、解码、运算和结果),一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段,流水线是并行的, 第一条指令执行还没完毕,就可以执行第二条指令,前提是这两条指令功能单元相同或类似,所以一般可以通过指令重排使得具有相似功能单元的指令接连执行来减少流水线中断的情况。


02 | Java解决可见性、有序性、原子性

解决可见性:volatile关键字

意义:禁用CPU缓存解决可见性问题

解决有序性:Happens-Before规则

定义:前面一个操作的结果对后续操作是可见的,解决编译优化带来的有序性问题
规则:

  • 顺序性:按照程序顺序,前面的操作Happens-Before于后续的任意操作
  • volatile变量规则:对一个volatile变量的写操作,Happens-Before于后续对这个变量的的读操作
  • 传递性: A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C
  • 锁规则: 对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
  • 线程start()规则:主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
  • 线程join规则:主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作

    互斥锁:解决原子性问题

    保证同一时刻只有一个线程执行。
    锁模型:
    image.png

    Java 锁技术
  • synchronized:可用来修饰方法和修饰代码块

    1. public class LockTest{
    2. // 修饰静态方法锁定的是当前Class类对象,等同于run2方法
    3. synchronized static void run(){ ... }
    4. synchronized(LockTest.class) static void run(){...}
    5. // 修饰非静态方式时锁定的是当前实例对象this,等用语paly2方法
    6. synchronized void play(){ ... }
    7. synchronized(this) static void play2(){...}
    8. }

    加锁本质就是在锁对象的对象头中写入当前线程id

    03 | 死锁

    细粒度锁:用不同的锁对受保护资源进行精细化管理能够显著提升性能。但提升性能的同时可能会导致死锁。

    定义

    一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

    死锁产生条件

    只有以下这四个条件都发生时才会出现死锁:

  • 1. 互斥:互相资源X和Y只能被一个线程占用;

  • 2. 占用且等待:线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
  • 3. 不可抢占:其他线程不能强行抢占线程T1占用的资源;
  • 4. 循环等待:线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源;

破坏其中一个即可避免死锁的发生,解决方案如下:

  • 1. 互斥:锁本身且互斥,此条件无法破坏!
  • 2. 占用且等待 :一次性申请所有资源
  • 3. 不可抢占:占用部分资源的线程进一步申请其他资源时,如果申请不到则主动释放它占用的资源
  • 4. 循环等待:对资源锁进行排序,按“序”申请资源

04 | wait()与sleep()区别

  • wait()会释放所有锁而sleep不会释放锁资源
  • wait()只能在同步方法和同步块中使用
  • wait()无需捕获异常,而sleep()需要

05 | 安全性问题、活跃性问题

安全性

出现安全性问题的场景:存在共享数据并且该数据会发送变化(存在数据竞争),通俗地讲就是有多个线程会同时读写同一个数据。

活跃性

指某个操作无法执行下去,如一下情况

  • 死锁:死锁后线程会互相一直等待导致程序无法向下执行
    • 解决方案:见 04
  • 活锁:锁持有者一直互相“谦让”导致程序无法继续向下执行
    • 解决方案:随机等待(Raft)
  • 饥饿:指的是线程因无法访问所需资源而无法执行下去的情况,在CPU繁忙情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
    • 解决方案:保证资源充足;公平地分配资源;避免持有锁的线程长时间执行

06 | 管程

管程:指的是管理共享变量以及对共享变量的操作过程,让他们支持并发,Java就是采用管程技术实现并发编程,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。目的保证同一时刻只有一个线程在执行。

管程模型

  • Hasen模型:要求notify放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行
  • Hoare模型:T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2
  • MESA模:T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面

    07 | Java线程

    通用线程的生命周期

  • 初始状态:线程已经被创建,但是还未分配CPU

  • 可运行状态:线程已分配CPU
  • 运行状态:分配到空闲CPU的执行时间
  • 休眠状态:运行状态的线程调用阻塞的API或者等待某个事件
  • 终止状态:线程执行完毕或出现异常退出

image.png

Java中线程的生命周期

  • NEW(初始化状态)
  • RUNNABLE(可运行/运行状态)
  • BLOCKED(阻塞状态)
  • WAITING(无时限等待)
  • TIMED_WATING(有时限等待)
  • TERMINATED(终止状态)

image.png

状态转换

  • RUNNABLE > BLOCKED 转换条件如下:
    • 线程等待 synchronized 的隐式锁
  • RUNNABLE > WAITING 转换条件如下:
    • 获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法
    • 调用无参数的 Thread.join() 方法
    • 调用 LockSupport.park() 方法
  • RUNNABLE > TIMED_WAITING 转换条件如下:
    • 调用带超时参数的 Thread.sleep(long millis) 方法
    • 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法
    • 调用带超时参数的 Thread.join(long millis) 方法
    • 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法
    • 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法
  • NEW > RUNNABLE 转换条件如下:
    • Thread.start()
  • RUNNABLE > TERMINATED:转换条件如下:
    • run()方法执行结束
    • run()执行异常
    • interrupt()方法