本文全景脑图
学习王宝令老师的《Java并发编程实战》,感觉受益匪浅,把自己的一些困惑找到了答案。课程第一部分主要涉及并发编程的问题、并发编程的理论知识,这是我结合自身情况的一篇总结。 第一部分主要涉及并发编程的问题、并发编程的理论知识
一. 并发编程的问题
1. 微观剖析并发编程的问题
- 可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到。
CPU缓存会导致可见性问题。
- 有序性: 程序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序。这句会带来有序性能问题。
- 原子性: 一个或者多个操作在CPU执行过程中不被中断。
CPU能保证的原子操作是CPU指令级别,而不是高级语言的操作符。所以并发编程的线程切换会带来原子性问题。
2. Java解决并发问题的方法
- Java内存模型规范
可见性与有序性问题原因是缓存和编译优化带来,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。
- JVM提供的按需禁用缓存和编译优化的方法:
- volatile(解决可见性问题):对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 实现原理:
- Lock前缀指令会引起CPU缓存回写到内存。
- 一个CPU的缓存回写到内存会导致其他CPU的缓存无效。
- 实现原理:
- Happens-Before规则(解决有序性问题):前一个操作的结果对后续操作是可见的。
- 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说,是控制流顺序而不是程序代码顺序,因为有分支、循环等结构。
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作,这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止监测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等手段监测到线程已经终止执行。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码监测到中断事件的发生,可以通过Thread.interrupt()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- final:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
- volatile(解决可见性问题):对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 互斥锁
- synchronized
- 互斥:同一个时刻只有一个线程执行。
- synchronize三个作用范围:
class X { // 1.修饰非静态方法 synchronized void foo() { // 临界区 } // 2.修饰静态方法 synchronized static void bar() { // 临界区 } // 3.修饰代码块 Object obj = new Object(); void baz() { synchronized(obj) { // 临界区 } } }- 理解锁膨胀全景图
- 死锁问题
- 死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
- 什么情况下会出现死锁:
- Coffman总结:
- 互斥:共享资源X和Y只能被一个线程占用;
- 占有且等待:线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
- 不可抢占:其他线程不能强行抢占线程T1占有的资源。
- 循环等待:线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源。 除第一条互斥外,其他三条破坏其中一条就可以避免死锁。
- Coffman总结:
- 解决方式:
- 一次性申请所有资源解决占有且等待。
- 如果申请不到下一个资源,释放原本占有的资源解决不可抢占。
- 根据资源id顺序申请资源解决循环等待。 ---> 同理mysql中悲观锁的顺序加锁。
- 线程间的协作
- 等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
- 等待队列:等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
- 注意点1:被通知的线程要想重新执行,仍然需要获取到互斥锁,因为曾经获取的锁在调用wait()时已经释放了。
- 注意点2:wait()、notify()、notifyAll()方法操作的等待队列是互斥锁的等待队列,sychronized锁定的是obj -> obj.wait()、obj.notify()、obj.notifyAll()。
- 情况分析:
synchronized(obj) { // 临界区 if(condition) { wait(); } } synchronized(obj) { // 临界区 if(condition) { // notify(); notifyAll(); } }- 当条件不满足时,线程进入等待队列的同时,会释放持有的互斥锁;
- 当条件满足时,调用notify()/notifyAll()会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
- 关于notify()与notifyAll()
- notify()是等待队列中的随机一个线程
- notify()是通知所有等待队列中的线程
- wait()方法调用 -> 当前线程暂停执行,进入【等待队列】中,并释放锁标志。
- notify()方法调用 -> 【等待队列】中移除任意一个线程,并转移到【锁标志等待队列】中。
- notifyAll()方法调用 -> 【等待队列】中移除所有线程,并全部转移到【锁标志等待队列】中。
- wait()与sleep()
- wait会释放所有锁而sleep不会释放锁资源。
- wait只能在同步方法和同步块中使用,而sleep任何地方都可以。
- wait无需捕捉异常,而sleep需要。
- sleep是Thread的静态方法,而wait是Object类的方法。
- synchronized
二. 并发编程理论知识
1. 宏观角度重新审视并发编程的问题
- 并发编程是一个复杂的技术领域:
- 微观上涉及:原子性问题、可见性问题、有序性问题。
- 宏观上涉及:安全性问题、活跃性问题、性能问题。
- 安全性问题:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一共享数据。
- 竞态条件:指的是程序的执行结果依赖线程执行的顺序。
if (状态变量 满足 执行条件) { // 执行【共享数据】操作 } - 活跃性问题:
- 死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
- 活锁:同时放弃资源,然后又重试竞争资源。
- 饥饿:线程因无法访问所需资源而无法执行下去的情况。
- cpu繁忙,优先级低的线程一直不被执行到。
- 持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
- 性能问题:
- 阿姆达尔定律(Amdahl):处理器并行运算之后效率提升的能力。
S = 1/(1-p)+p/n p: 并行百分比 (1-p): 串行百分比 n: CPU的核数 - 串行百分比:用单线程执行临界区的时间/用单线程执行(临界区+非临界区)
- 使用锁的时候,一定要关注对性能的影响。优化策略:
- 第一无锁设计:如 TLS、Copy-on-write、乐观锁。
- 第二减少持有锁的时间,增加并行度。如,细粒度的锁。
- 性能方面三个重要指标:
- 吞吐量:系统在单位时间内能处理请求的数量。
- 延迟:指的是从发出请求到收到响应的时间。
- 并发量:指的是能同时处理的请求数量。
2. 管程,解决并发问题的钥匙
- 管程指的是管理共享变量以及对共享变量的的操作过程,让他们支持并发。
- 解决并发编程的万能钥匙:
- 信号量
- 管程: 在Java采用的是管程技术中,synchronized关键字及 wait()、notify()、notifyAll()都是管程的组成部分。
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。
- 管程模型介绍
- Hasen管程模型:Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
- Hoare管程模型:Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
- MESA管程模型:MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
- Java中synchronized的MESA管程模型精简版
生命周期
- 线程生命周期: 在操作系统层面,线程也有“生老病死”,专业的说法就是生命周期,学习生命周期就是搞懂 【生命周期中各个节点的状态转换机制】。
- 通用的线程生命周期
- 初始状态:语言层面的线程创建,在操作系统层面线程还没有被创建,所以不会被分配cpu执行权。
- 可运行状态:线程可以分配cpu执行权了,因为在操作系统层面线程已经被创建了。
- 运行状态:得到cpu执行权。
- 休眠状态:运行状态的线程,调用了一个阻塞api或者等待某个事件(例如管程模型中提到的条件变量),就会进入休眠状态。
- 终止状态:线程执行完,或者出现异常就会进入终止状态。
- Java中的线程生命周期
- RUNNABLE -> BLOCKED : 线程等待synchronize的隐式锁。 注: 调用阻塞API时候,并不会切换到BLOCKED。
- RUNNABLE -> WAITING :
- 获得synchronize隐式锁的线程,调用无参数的Object.wait()。
- 调用无参的Thread.join方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
- 调用 LockSupport.park() 方法。其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
- 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)方法。 TIMED_WAITING与WAITING状态的区别,仅仅是触发条件多了超时条件。
- NEW -> RUNNABLE状态:t.start();
- 从RUNNABLE到TERMINATED状态
- interrupt():当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。
- 例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。 stop()禁用原因:stop() 方法会真的杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁。