1、多线程基础
-
线程的生命周期(状态)
- NEW:新建状态
- Java线程刚刚被创建,线程就是新建状态,此时它已经有了相应的内存空间和其它资源,但是还没有开始执行
- RUNNABLE:就绪状态
- 新建线程对象后,调用该线程的start()方法就可以启动线程。当线程启动时,线程就进入就绪状态(runnable)
- 由于还没有分配CPU,线程将进入线程队列排队,等待CPU服务,这表明它已经具备了运行条件。当系统挑选一个等待执行的Thread对象后,它就会从等待状态进入执行状态。系统挑选的动作称之为“CPU调度”。一旦获得CPU,线程就进入运行状态并自动调用自己的run方法
- RUNNABLE:运行状态
- 该线程抢到CPU时间片之后才会进入运行状态
- BLOCKED:阻塞状态
- 没有抢占到锁的线程会被阻塞,进入阻塞状态
- WAITING:等待状态
- 线程进入等待状态表示需要等待其他线程通知或者中断才会执行
- TIMED_WAITING:超时等待状态
- 可以给线程设定一个等待时间,达到指定时间时会执行
- TERMINATED:终止状态
- 表示线程已经执行结束
- NEW:新建状态
-
三大特性
- 原子性
- 一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
- 有序性
- 程序执行的顺序遵循代码的顺序
- Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性
- 可见性
- 多个线程访问同一个变量的时候,一个线程修改了这个变量的值,其他线程也可以立刻看到这个修改后的值
- 原子性
-
死锁
死锁是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象。比如两个线程在拥有锁的情况下,又尝试获取对方的锁,就会导致程序进入阻塞状态
-
死锁必须具备的四个条件
- 互斥条件
- 该资源在任意时刻只能被一个线程占用
- 请求与保持
- 一个线程因为请求资源而被阻塞,已获得该资源的线程保持资源的拥有不释放
- 不剥夺
- 线程已经获得的资源在线程没有执行完之前不能被其他线程强行剥夺,只能等自己执行之后再释放资源
- 循环等待
- 若干线程之间形成一种头尾相接的循环等待资源的关系
- 互斥条件
-
如何避免死锁
死锁的四个必备条件,只要破坏其中一个就可以避免死锁
- 破坏互斥条件
- 这个条件不能被破坏,因为使用锁就是为了让资源互斥
- 破坏请求与保持条件
- 线程在执行的时候一次性申请完所有要用的资源
- 破坏不剥夺条件
- 占用资源的线程如果在申请其他资源的时候,申请不到,那么就释放自己已经占用的资源
- 破坏循坏等待条件
- 按照申请资源的顺序来访问资源,释放资源的顺序正好与其相反
- 破坏互斥条件
-
-
as-if-serial语义
- 并不是所有的程序都是出现重排序的问题,不管是编译器的重排序还是处理器的重排序,都会遵循数据依赖原则,编译器和处理器不会改变两个存在数据依赖关系的操作
-
happens-before原则
- 程序顺序规则
- 一个线程中的每个操作,happens-before 于该线程中的任意后续操作; 可以简单认为是 as-if-serial。单个线程中的代码顺序不管怎么变,对于结果来说是不变的
- volatile 变量规则
- 对于 volatile 修饰的变量的写的操作,一定 happens-before 后续对于 volatile 变量的读操作
- 传递性规则
- 如果 1 happens-before 2; 2 happens-before 3
- 那么传递性规则表示: 1 happens-before 3
- start 规则
- 如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作
- join 规则
- 如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程A 从 ThreadB.join()操作成功返回
- 监视器锁的规则
- 对一个锁的解锁,happens-before 于随后对这个锁的加锁
- 程序顺序规则
2、线程池
线程池就是采用池化思想(类似连接池、常量池、对象池等)管理线程的工具,JUC 给我们提供了 ThreadPoolExecutor 体系类来帮助我们更方便的管理线程、并行执行任务
- ThreadPoolExecutor 都有哪些核心参数
- 核心线程数(corePoolSize)
- 最大线程数(maximumPoolSize)
- 空闲线程超时时间(keepAliveTime)
- 时间单位(unit)
- 阻塞队列(workQueue)
- 拒绝策略(handler)
- 线程工厂(ThreadFactory)
- 执行流程
- 判断线程池的状态,如果不是RUNNING状态,直接执行拒绝策略
- 如果当前线程数 < 核心线程数,则新建一个线程来处理提交的任务
- 如果当前线程数 > 核心线程数且任务队列没满,则将任务放入阻塞队列等待执行
- 如果 核心线程数 < 当前线程池数 < 最大线程数,且任务队列已满,则创建新的线程执行提交的任务
- 如果当前线程数 > 最大线程数,且队列已满,则执行拒绝策略拒绝该任务
3、synchronized
synchronized是Java中的一个关键字,主要用来解决多线程之间访问共享资源出现的同步安全性问题的。它可以保证被它修饰的对象、方法、代码块在同一时间点只能有一个线程访问
-
特性
- 原子性
- 通过MarkWord锁标识判断是否被其他线程占用
- 可见性
- 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存
中重新读取最新的值 - 线程解锁前,必须把共享变量的最新值刷新到主内存中
- 靠操作系统内核的Mutex Lock(互斥锁)实现,相当于 JMM 中的 lock、unlock。退出代码块时刷新变量到主内存
- 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存
- 有序性
- as-if-serial特性保证
- 可重入性
- Monitor对象有个计数器,他会记录下线程获取锁的次数,线程获取锁后 +1,线程执行完毕后 -1,直到清零释放锁
- 原子性
-
锁类型
synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁,锁的状态根据竞争激烈的程度从低到高不断升级
- 无锁
- 无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功
- 偏向锁
- 大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁
- 当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果存在表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁
- 轻量级锁
- 当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能
- 重量级锁
- 其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程
- 无锁
-
锁升级
-
偏向锁的获取
首先获取锁 对象的 MarkWord,判断是否处于可偏向状态(biased_lock=1、且 ThreadId 为空)
-
如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord
- 如果 cas 成功,就把Markword中的线程ID指向当前线程并且加上偏向锁标记,表示已经获得了锁对象的偏向锁,接着执行同步代码块
- 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
-
如果是已偏向状态,需要检查 MarkWord中存储的ThreadID 是否等于当前线程的 ThreadID
- 如果相等,不需要再次获得锁,可直接执行同步代码块
- 如果不相等,说明当前锁偏向于其他线程,需要撤销拥有偏向锁线程的偏向锁并升级到轻量级锁
-
偏向锁的撤销
- 偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象的锁状态升级到轻量级锁的状态
- 对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况
- 如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程
- 如果同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块
-
轻量级锁的加锁
-
锁升级为轻量级锁之后,对象的 MarkWord 也会进行相应的的变化
- 线程在自己的栈桢中创建锁记录 Lock Record
- 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中
- 将锁记录中的 Owner 指针指向锁对象
- 将锁对象中对象头的 MarkWord替换为指向锁记录的指针
-
自旋锁
- 轻量级锁在加锁过程中,用到了自旋锁,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是将线程阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的
- 注意:锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景
- 线程不断的循环会消耗 CPU 资源。默认情况下自旋的次数是 10 次
- 轻量级锁在加锁过程中,用到了自旋锁,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是将线程阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的
-
自适应自旋锁
- JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源
-
-
轻量级锁的解锁
- 轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑
- 通过CAS 操作把线程栈帧中的 Lock Record 替换回到锁对象的MarkWord 中(简单来说就是将锁状态置为无锁,偏向锁位为"0",锁标识位为"01")
- 如果成功表示没有竞争,如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁
-
重量级锁的基本原理
因为自旋会消耗CPU,为了避免过多的自旋,一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态
当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的竞争
重量级锁的状态下,对象的MarkWord会指向一个Monitor对象的指针
-
JVM会在字节码中增加 1个 monitorenter 和 2个 monitorexit 指令
- 执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1
- 执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放
- 正常情况下只会执行第一个monitorexit释放锁,然后返回
- 如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁
-
每一个 JAVA 对象都会与一个Monitor 监视器关联
- 可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 Monitor
- monitorenter 表示去获得一个对象监视器
- monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器
- monitor 依赖操作系统的 MutexLock(互斥锁)来实现的,线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能
-
synchronized原理图
4、volatile
volatile是Java里的一个关键字。是保证各个线程之间的可见性和代码执行的有序性的
-
如何解决可见性的
- 当变量被
volatile
修饰时,这个变量被修改后会立刻刷新到主内存,并失效其他线程的缓存,当其它线程需要读取该变量时,会去主内存中读取新值 - 为什么会有可见性问题
- 因为JMM内存模型规定线程对所有变量的操作都必须在工作内存中进行,而线程间变量值的传递需要通过主内存进行传递,所以当多线程同时读写一个变量时就可能会出现缓存不一致
- 当变量被
-
如何解决有序性的
- 被
volatile
修饰的变量会加上内存屏障禁止重排序- 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障
- 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
- 被
-
什么是指令重排序
- 重排序主要分为处理器的重排序和编译器的重排序。编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能。
- 这些重排序可能会导致可见性问题,JMM会要求在编译器生成指令的时候插入内存屏障来禁止处理器重排序
-
什么是内存屏障
内存屏障是一种屏障指令,它使CPU或编译器在屏障之前和之后的内存指令进行排序约束,这就表明在屏障之前的指令一定会在屏障之后的指令先执行
-
内存屏障的四种类型
LoadLoad(LL)屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
StoreStore(SS)屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
LoadStore(LS)屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
StoreLoad(SL)屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
-
2.1、可见性问题是怎么出现的?
-
硬件层面的优化(可见性问题出现的根本原因)
因为CPU的运算速度要远远大于内存的读写速度,所以CPU大部分时间都在等待数据从内存中读取,运算完再写入内存。内存速度慢,所以CPU不管效率再高都没用(*“木桶效应”,木桶装多少水取决于最短的木板*)
-
CPU层面的高速缓存(为了解决CPU饥饿问题)
- 为了解决上面所出现的问题,现代计算机在CPU和内存之间又加入了一个读写速度接近于CPU运算速度的高速缓存,缓存从内存中获取数据,这样CPU就不用直接跟主存打交道,而是直接从缓存中读取数据,如此便很好的解决了上面CPU“饥饿”的问题
-
-
缓存一致性问题(可见性问题出现的实质原因)
在单线程情况下是没有任何问题的,但是在多线程情况下就有可能出现问题。比如现在有A和B两个线程在不同的CPU上面运行,不同的CPU又有各自的高速缓存,如果两个线程同时操作一个共享变量,而互相又不知道对方操作了该变量,最后两个线程再将各自高速缓存中的数据按照先后顺序写入主存中,从而产生与预期不一致的结果,这就是缓存不一致问题
-
总线锁(为了解决缓存一致性问题)
- CPU都是通过总线来读取主存中的数据的,总线锁就是当一个CPU需要对主存进行操作的时候,会向总线发送一个LOCK#信号,这样就会使得其他CPU无法通过总线来获取主存中的数据,总线锁将CPU和主存之间的通信锁住了,在锁定期间,其他CPU都是无法对主存进行操作的
- 这样虽然是解决了缓存一致性问题,但是每次对总线上锁性能开销很大,而且多核CPU就是为了并行,提高系统效率,这样搞那多核CPU就没有意义了
-
缓存锁(为了解决总线锁带来的效率问题)
- 所谓缓存锁,就是指主存中的数据如果被缓存到了CPU的缓存行中,并且在LOCK锁定期间,那么它再执行写操作写入主存的时候,不需要对总线上锁,而是直接修改内部的内存地址,基于缓存一致性协议来保证了原子性。说白了缓存锁就是基于缓存一致性协议实现的一个比总线锁效率更高的锁
- 总线锁和缓存锁怎么选择,取决于很多因素,比如CPU是否支持、以及存在无法缓存的数据时(比较大或者多个缓存行的数据),必然还是会使用总线锁
- 所谓缓存锁,就是指主存中的数据如果被缓存到了CPU的缓存行中,并且在LOCK锁定期间,那么它再执行写操作写入主存的时候,不需要对总线上锁,而是直接修改内部的内存地址,基于缓存一致性协议来保证了原子性。说白了缓存锁就是基于缓存一致性协议实现的一个比总线锁效率更高的锁
-
缓存一致性协议MESI(实现缓存锁的本质)
为了达到数据的一致性,需要各个处理器在读写缓存的时候遵循一定的协议,比如MSI、MESI、MOSI等,最常见的就是MESI协议了
- MESI表示缓存行的四种状态
- M(Modify):表示共享数据只缓存在当前CPU的高速缓存中,并且是被修改状态,也就是缓存中的数据与主存中的数据不一致
- E(Exclusive):表示缓存的独占状态,数据只缓存在当前CPU的缓存中,并且没有被修改
- S(Shared):表示数据被多个CPU缓存,并且各个缓存中的数据与主缓存数据一致
- I(Invalid):表示缓存已经失效。
- 当CPU修改缓存中的数据的时候,如果发现操作的数据是共享数据,也就是该数据在其他CPU缓存中也存在副本,那么就会通知其他CPU将该数据副本的缓存行置位无效状态,当其他CPU再去读取这个数据的时候,发现自己的缓存行已经失效了,就会去主存中重新读取
- MESI表示缓存行的四种状态
-
MESI有什么问题?
当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。简单来说,A线程修改缓存中的共享变量需要通知B线程,让B线程中共享变量的缓存行失效,而这个过程是同步的,是需要时间的,A线程需要等待B线程处理的响应,这是阻塞的
MESI缓存一致性协议需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值(总线风暴),所以不要大量使用volatile
-
MESI的一个优化 — Store Bufferes(解决缓存锁带来的问题)
当修改本地缓存中的变量时,需要将 I (无效)状态通知给其他拥有该共享变量的CPU缓存中,并且等待确认,这个等待确认的过程是会阻塞CPU的,降低了CPU性能。所以引入了Store Bufferes。
Store Bufferes是一个写缓冲,CPU0可以先把写入缓存的操作存储在Store Bufferes中,Store Bufferes中的指令再根据缓存一致性协议(MESI)来通知其他CPU缓存行失效,这样CPU0就不用等待其他CPU的确认响应继续执行其他指令,直到CPU0收到其他CPU响应的结果,再更新自己缓存中的值,然后从缓存中同步到主存中
-
Store Bufferes带来的风险(引入新的重排序问题)
- 由于把数据写会主存的过程是异步的,数据什么时候保存到主存中是无法确定的,在没保存到主存之前,当前CPU可以从Store Bufferes中读取最新的值,但是其他CPU读取到的值就不是最新的了,可能还是之前的无效数据
-
失效队列
- CPU去执行失效是一个并不简单的操作,而且Store Bufferes也不是无穷大的,所以有时候CPU也需要等待失效结果的确认,这样会导致CPU处理性能大幅度下降,所以又引入了失效队列
- 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送
- Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行
- 通过内存屏障来解决
- 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate
- CPU去执行失效是一个并不简单的操作,而且Store Bufferes也不是无穷大的,所以有时候CPU也需要等待失效结果的确认,这样会导致CPU处理性能大幅度下降,所以又引入了失效队列
5、AQS
AQS它是J.U.C这个包里面非常核心的一个抽象类,它为多线程访问共享资源提供了一个队列同步器,内部由两个核心部分组成
一个volatile修饰的state变量,作为一个竞态条件
-
用双向链表结构维护的FIFO线程等待队列
多个线程通过对这个state共享变量进行修改来实现竞态条件,竞争失败的线程加入到FIFO队列并且阻塞,抢占到竞态资源的线程释放之后,后续的线程按照FIFO顺序实现有序唤醒
-
AQS里面提供了两种资源共享方式
一种是独占资源,同一个时刻只能有一个线程获得竞态资源。比如ReentrantLock就是使用这种方式实现排他锁
另一种是共享资源,同一个时刻,多个线程可以同时获得竞态资源。CountDownLatch或者Semaphore就是使用共享资源的方式,实现同时唤醒多个线程