Java 并发
2025-01-03 15:23:43 0 举报
AI智能生成
Java 并发
作者其他创作
大纲/内容
并发设计模式
不可变类的防御性复制
微信收单数据推送 优化
消息推送服务商
海量消息存在推送丢失的
动账户消息 消息量太大
收单
多方消息推送服务商
更新路由信息应该
展哥说的要把那些消息记录持久化下来
保护性暂停
避免死锁
破坏请求和保持条件
核心转账
Java 并发基础
Java 线程的实现
使用了 一对一 的线程模型实现的,一条 Java 线程 就映射到一条轻量级进程,也就是一条内核线程
调度主要方式
抢占式线程调度 (Java)
线程执行时间由系统分配,线程的切换不由线程本身来决定
创建线程启动线程的 4 种方式
直接使用 Thread 类创建线程
实现 Runnable 接口,并用这个实例作为 Thread 的 target 来创建 Thread 对象
将线程与任务分离,更加灵活
实现 Callable 接口,使用 FutureTask 类来包装 Callable 对象 ,使用 FutureTask 对象作为 Thread 对象的 target 创建并启动线程
通过线程池创建线程
Runable Callable 对比
Callable call 方法有返回值,还可以抛出异常 ; Runable run 方法没有返回值,也没有抛出异常,只能捕获
CompletableFuture
Java8 新增了 CompletableFuture 提供对异步计算的支持,可以通过回调的方式处理计算结果
supplyAsync (ForkJoinPool)
thenApply
whenComplete
它们的返回值作为参数,传递到下一个方法中
main 线程调用 thenApply 方法时,如果 supply 执行完毕,main 线程来执行 then,否则 ForkJoinPool 执行 then,when 执行逻辑也是相同的
Java 线程之间如何通信
volatile 和 synchronized
volatile 写 - 读 内存语义
锁的内存语义
等待通知机制
管道输入 / 输出流
构造线程
由父线程进行分配空间,继承父线程的优先级、是否为 Daemon 属性、类加载器、可继承的 ThreadLocal,并为他分配线程 ID,此时线程在堆中等待运行
启动线程
调用 start() 方法,父线程同步告知 JVM,只要线程规划器空闲,立刻启动调用 start() 方法启动线程
Daemon 线程
当没有非 Daemo 线程时,立刻退出,不再执行任何剩余的代码
垃圾回收器线程就是一种守护线程
如果要设置线程为 Daemo 线程必须在线程启动前设置
如何避免虚假唤醒
(先获取锁) notify 只能随机唤醒一个 WaitSet 中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线
程 可以使用 notifyAll while + wait,当条件不成立,再次 wait
程 可以使用 notifyAll while + wait,当条件不成立,再次 wait
线程中常用的方法
为什么 Thread 类的 sleep 和 yield 方法是静态的
正在执行的线程才能调用
sleep yield 的作用
用于防止占满 CPU,但不会释放锁,适用于无锁场景
isInterrupted() interrupted() 区别
interrupted() 是静态方法,用于返回打断标记并且清除标记
isInterrupted() 是实例方法,只用来返回打断标记
JAVA 线程的中断
调用 interrupt() 方法
运行的线程被打断只会设置打断标志,会继续运行
Thread.sleep (long millis) 被打断抛出异常后又会清除掉打断标志
LockSupport park
park 线程,被打断可以继续运行,中断标志为 true ,不会抛出异常
线程中断标志位为 true park 不会生效
park unpark 原理
每个线程都有一个自己的 Parker 对象 由 _counter , _cond 和 _mutex 组成
park方法会检查 _counter , 如果为 0 , 这时,获得 _mutex 互斥锁,线程进入 _cond 条件变量阻塞 设置 _counter = 0,调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1 唤醒 _cond 条件变量中的 线程恢复运行设置 _counter 为 0
调用 Unsafe.unpark(Thread_0) 方法 , 设置 _counter 为 1,当前线程调用 Unsafe.park() 方法, 检查 _counter,本情况为 1,继续运行, 无需阻塞, 设置 _counter 为 0
错误的中断方法
suspend() resume() 线程被挂起,不会释放锁资源,容易造成死锁
stop() 方法会真正杀死线程,如果这时线程锁住了共享资源,被杀死后就再也没有机会释放锁
System.exit (int) 终止当前正在运行的Java虚拟机,会让整个程序都停止
优雅的退出线程
两阶段终止模式
打断后,在捕获异常时重设打断标记,通过检测打断标记退出
线程的生命周期与状态转换 6 种
NEW
RUNABLE
JAVA中的运行、可运行 (未获得CPU) IO 阻塞操作
BLOCKED
synchronized
WAITING
wait
park(AQS await 底层实现)
TIME_WAITING
Thread.sleep(long)
TERMINATED
执行完成
为什么要使用多线程
更好的利用多核 CPU
异步调用 避免阻塞主线程,耗时间操作可以开一个新线程执行,避免阻塞主线程
多线程存在什么问题
导致线程不安全,没有正确同步的多线程会存在数据竞争,产生违反直觉的结果
多线程由于有线程的创建和上下文切换开销,不一定比单线程快
多线程的活跃性问题
并发编程会受到硬件资源和软件资源的限制,当然可以考虑横向纵向拓展,资源复用
什么是线程安全
当多个线程同时访问一个对象时,不需要进行额外的同步,调用方也不需要进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果
线程安全的等级
不可变、 绝对线程安全、 相对线程安全、 线程兼容 和 线程对立
实现线程安全的方法
互斥同步 悲观锁:Java 中 synchronized 、 ReentrantLock、 数据库中的行锁 适用于写比较多
非阻塞同步 乐观锁:CAS 原子操作 版本号 适用写比较少
无同步方案
线程本地存储
具体说说什么是乐观锁与悲观锁
乐观锁总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用 版本号机制 和 CAS 算法实现
MySQL 实现乐观锁
基于 version ,每次更新不管成功失败,结束当前事务,如果失败 (affected_rows 是不是等于预期值) 则重新起一个事务进行查询更新
悲观锁认为数据很容易被其他线程修改,所以在处理数据前先加锁,并且在整个数据处理过程中,数据都处于锁定状态,可以使用 Java 中 synchronized ReentrantLock、 数据库中的行锁
CAS
如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值
CAS 底层 cmpxchg 指令,如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀,该前缀会
(1) 使用缓存锁定,来保证指令执行的原子性
(2) 禁止该指令与之前和之后的读和写指令重排序
(3) 把写缓冲区中的所有数据刷新到内存中
2 3 是 lock 指令提供的内存屏障的效果
CAS 实现原子操作的三大问题
1. ABA 2. 竞争大时候,开销大 3. 只能保证一个共享变量的原子操作(对多个共享变量操作时,循环CAS就无法保证操作的原子性)
AtomicStampedReference
循环时间长开销大
如果 JVM 能支持处理器提供的 pause 指令,那么效率会有一定的提升
第一延迟流水线执行指令,使CPU不会消耗过多的执行资源
第二避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空
AtomicReference
原子操作的实现原理
处理器实现原子操作
锁总线、锁缓存
JAVA 如何实现原子操作
使用锁
循环 CAS
上下文切换
任务从保存到再加载的过程就是一次上下文切换
什么时候会发生上下文切换
主动
主动让出 CPU sleep() 、wait()(需要持有锁)、yield()
请求 IO
运行结束
被动
线程时间片用完
GC 时
更高优先级线程需要运行
线程被终止
如何减少上下文切换
无锁并发
将数据 ID 按照 Hash 算法取模分段,不同的线程处理不同的数据
CAS 算法
使用最少的线程数
使用协程
为什么内核线程调度切换起来成本高
内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要
来自于响应中断、保护和恢复执行现场的成本
来自于响应中断、保护和恢复执行现场的成本
协程切换相比线程切换做的事情更少,协程切换只涉及基本的 CPU 上下文切换,完全在用户态进行
多线程的活跃性问题
死锁
活锁
两个线程互相改变结束条件,导致两个线程都得不到结束,通过增加随机睡眠时间来避免
饥饿
一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束 ,通过 ReentrantLock 公平模式
Java 并发进阶
volatile
什么是 volatile
Java 当中的一个关键字,当一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义
一个线程对此 volatile 变量的修改对于其他线程来讲是立即可见
禁止指令重排序
可见性问题
为什么会出现可见性问题
为了解决CPU运算速率与内存读写速率不匹配的矛盾,在 CPU 与内存之间加入了多级缓存,CPU 直接操作的是缓存中的数据,但在多处理器下,将可能导致各自的缓存数据不一致
volatile 可见性原理
对 volatile 变量写操作之后,JVM 会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,如果过期则将当前处理器的缓存行设置成无效状态,在下次访问相同内存地址时,强制执行缓存行填充
基于总线嗅探机制的 MESI 协议
volatile 使用的优化
追加字节 修改操作会锁缓存行,通过追加字节,避免要同时修改的 volatile 变量处于同一个缓存行
synchronized
什么是 synchronized
synchronized 关键字解决的是多个线程之间访问资源的同步性,它可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。JDK 1.6 之前, synchronized 属于重量级锁,效率低下,因为 Java 的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,则需要进行系统调用, 这就不可避免地陷入用户态到核心态的转换中,时间成本相对较高。JDK 1.6 对锁的实现引入了大量的优化如 偏向锁、轻量级锁,自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
JDK 1.6 synchronized 的优化
偏向锁
由于轻量级锁每次加锁要进行 CAS 会影响效率,引入偏向锁,只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS,以后只要不发生竞争,这个对象就归该线程所有
偏向锁撤销
-XX: -UseBiasedLocking 关闭偏向锁
notify/wait()
升级为重量级锁
hashCode()方法
锁会膨胀为重量级锁
其它线程使用对象
批量重偏向
当撤销偏向锁阈值超过 20 次后,JVM 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
批量撤销
当撤销偏向锁阈值超过 40 次后,JVM 会这样觉得,自己确实偏向错了,根本就不该偏向
自旋锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞和唤醒
适应性自旋锁
如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次
锁消除
根据逃逸技术分析,如果加锁的对象完全不会逃逸,就没必要加锁,JIT 即时编译器就会把 synchronized 优化掉
锁粗化
如果在一个操作序列中对同一个对象反复加锁对同一个对象反复加锁和解锁,JVM 会将会把加锁同步的范围扩展到整个操作序列的外部
synchronized 关键字的底层原理
每个 Java 对象都可以关联一个 Monitor 对象
Monitor 是依赖于底层的操作系统的 Mutex Lock 来实现的
wait / notify 原理
waitSet 、EntryList
obj.wait() 让进入 object 监视器的线程到 waitSet 等待
obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒
修饰同步代码块
线程执行到 monitorenter 指令时,将会尝试获取对象对应 Moniter 的所有权;当一个对象获取到 Moniter 的所有权,对象便处于锁定状态,会将对象头 MarkWord 中的 ptr_to_heavyweight_monitor 指针指向 Monitor;执行 monitorexit 时通过MarkWord 找到 Monitor 将 owner 重置为 null , 唤醒 EntryList
异常表,跳转到 monitorexit
修饰方法
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了, 执行线程就要求先成功持有 Moniter ,然后才能执行方法;当方法完成时释放 Moniter。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常, 那这个同步方法所持有的 Moniter 将在异常抛到同步方法边界之外时自动释放
synchronized 如何实现可重入
偏向锁
Lock Record
轻量级锁
Lock Record
重量级锁
synchronized 经过编译,会在同步块的前后分别形成 monitorenter 和 monitorexit 这个两个字节码指令。 在执行 monitorenter 指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行 monitorexit 指令时会将锁计算器就减1,当计算器为 0 时,锁就被释放了
synchronized 可见性保证
如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,使用这个变量前需要重新加载
对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中
对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中
为什么 synchronized 无法禁止指令重排,却能保证有序性
"一个变量在同一个时刻只允许一条线程对其进行 lock 操作" ,这个规则决定了持有同一个锁的两个同步块只能串行地进入,相当于单线程
as-if-serial 语义
instance = new Singleton() 可以分解为
第一步 分配对象的内存空间
第二部 初始化对象
第三步 设置 instance 指向内存空间
二、三 可能重排
基于 volatile 双重检查锁定 (DCL) 实现延迟初始化
双重检查锁定是一种延迟初始化技术,可以用来实现既支持延迟加载又支持高并发的单例模式
DCL 为什么要加 volatile —— 创建对象的过程可能被重排序
volatile 不允许重排 二、三重排
基于类初始化的延迟初始化
基于类初始化的延迟允许二、三重排,但不允许非构造线程 "看到" 这个重排序
初始化执行流程
通过获取Class对象的初始化锁LC,来进行同步。当线程A获取LC锁,将初始化状态设置为初始化中,释放锁。另外一个线程B获取 LC,发现初始化状态为初始化中,释放锁,到LC对应的条件队列中进行等待。线程A又获取LC锁,进行对象初始化,分配存储空间、初始化对象、赋值给引用变量(这个过程可以重排,但是其他线程看不到),设置初始化状态为初始完毕,唤醒条件队列中等待的线程,释放锁。其他所有线程依次获取LC锁发现已经初始化完毕,释放锁,也初始化完毕 ,整个过程通过初始化锁LC的获取释放,由happen-before的锁规可知这个过程可以保证可见性
锁升级流程
锁的 4 种状态:无锁状态 、偏向锁状态 、轻量级锁状态 、重量级锁状态
最开始对象是无锁状态
进入同步代码块
偏向锁加锁
把对象是否偏向设置为 1,并把 MarkWord 的线程 ID 改为当前线程 ID,此时对象处于偏向锁状态,一个线程继续对该对象加锁,发现是偏向锁状态,判断偏向锁线程是否是这个线程,如果是则是直接进入,如果偏向线程不是当前线程,也就是存在锁竞争,那么就撤销偏向锁
偏向锁的撤销
偏向锁的撤销,需要等待全局安全点,首先,它会暂停持有偏向锁的线程,判断持有偏向锁的线程是否退出同步代码块,是则将对象头设置为无锁状态,否则升级为轻量级锁
轻量级锁加锁
进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个锁记录,用于存储锁对象目前的 Mark Word 的拷贝,使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针,成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位将转变为 "00"
CAS 失败则说明存在竞争,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧
如果是则执行锁重入,再添加一条 Lock Record 作为重入的计数
如果不是,则说明已经有其它线程持有了该对象的轻量级锁,膨胀为重量级锁,为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,并且对象头所标志位改为 10 然后自己进入 Monitor 的 EntryList 阻塞
轻量级锁解锁
当退出同步块时如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
当退出同步块时,锁记录的值不为 null,CAS 操作把对象当前的 Mark Word 和 线程中复制的 Displaced Mark Word 替换回来,成功则解锁成功,失败,说明轻量级锁已经膨胀为了重量级锁,进入重量级锁解锁流程
重量级锁解锁流程
按照 Monitor 地址找到 Monitor 对象,设置 owner 为 null,唤醒 EntryList 中 BLOCKED 的线程
锁适应场景
1、偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁
2、轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争
3、重量级锁:有实际竞争,且锁竞争时间长
synchronized 关键字 和 volatile 关键字的区别
volatile 不能保证数据的原子性,synchronized 能保证,它们都能保证有序性与可见性
volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性
JMM
并发编程核心 分工、协作、互斥
并发编程三大特性
原子性
指一个操作或者一系列操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
可见性
当一个变量对共享变量进行了修改,另外的线程都能立即看到修改后的最新值
有序性
程序的代码执行顺序和语句的顺序是一致的
JMM 三大特性实现方式
原子性
Java 内存模型通过 read、write、load、store、use、assign 来保证原子性操作,此外还有
lock 和 unlock,直接对应着synchronized关键字的 monitorenter 和 monitorexit 字节码指令
lock 和 unlock,直接对应着synchronized关键字的 monitorenter 和 monitorexit 字节码指令
后简化为 read、 write、 lock 和 unlock 4种
可见性
Java 保证可见性可以认为通过 volatile、synchronized 、final 来实现
有序性
Java通过 volatile、synchronized 来保证
什么是 JMM
JMM 是 Java 内存模型,它控制了 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见;除此之外它还定义了线程和主内存之间的抽象关系,线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以 读/写 共享变量的副本
为什么需要 JMM
通过 JMM 屏蔽了不同硬件和操作系统内存的访问差异,保证了 Java 程序在不同的平台下达到一致的内存访问效果
多级缓存
CPU 的高度运算需要高速的数据,为了解决 I/O 速度和 CPU 运算速度之间的不匹配问题,在 CPU 中内置了少量的高速缓存,由于CPU的运算速度超越了 一级缓存的数据 I/O 能力,CPU厂商又引入了多级的缓存结构
多核 CPU 的情况下有多个一级缓存,通过一致性的协议 MESI,保证缓存内部数据的一致
由于 MESI 的性能问题引入了,存储缓存 Store Bufferes (写之前应用)和 失效队列(读之前应用),这又导致了重排序,就有了读写屏障来解决
每个核心都有自己的一、二级缓存,但三级缓存却是一颗 CPU 上所有核心共享的
容量大小依次增大,访问速度依次减慢
程序局部性原理
happens - before
happens - before 定义
如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
两个操作之间存在 happens-before 关系,如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么允许重排序
as-if-serial 和 happens-before 的区别
happens-before 关系本质上和 as-if-serial 语义是一回事 一个是正确同步的多线程一个是单线程
happens - before 规则
程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任一后续操作
as-if-serial 语义保证的
监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁
锁的 释放-获取 建立的
volatile 变量规则:对一个 volatile域的写,happens-before 于任意后续对这个 volatile 域的读
由 volatile 写-读 建立的 happen-before 规则
start() 规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
join() 规则:如果线程A执行操作ThreadB.join() 并成功返回,那么线程B中的任意操作happens-before于线程 A 从 ThreadB.join() 操作成功返回
传递性:如果A happens-before B,且B happens-before C,那么 A happens-before C
重排序
什么是重排序
重排序是指 编译器 和 处理器 为了优化程序性能而对指令序列进行重新排序的一种手段
重排序的种类
重排序分3种,编译器优化的重排序、(处理器采用了)指令级并行的重排序、内存系统的重排序
编译器 处理器不会对什么进行重排序
编译器处理器不会对存在数据依赖关系操作重排序(单线程下)
写后读/写后写/读后写
重排序对单线程的影响
不管怎么重排 (存在数据依赖性不会重排),(单线程)程序的执行结果不能被改变。编译器,处理器,runtime 都遵守
as-if-serial 语义
重排序对多线程影响
如果多线程没有正确同步,则重排序可能导致破坏多线程语义,比如存在控制依赖性时,为了避免对并发度的影响,编译器和处理器会采取猜测执行,提前计算将结果存入重排序缓冲,也可能导致破坏多线程语义
除了 Java 内存模型 你还知道什么模型
顺序一致性模型
一个提供了极强的内存可见性保证的理论模型
一个线程中的所有操作必须按照程序的顺序来执行
(不管程序是否同步) 所有线程都只能看到一个单一的操作执行顺序
每个操作都必须原子执行且立刻对所有线程可见
JMM 与 顺序一致性差异
不保证单线程内的操作按顺序执行
不保证所有线程能看到一致的操作执行顺序
不保证对 64 位变量写操作具有原子性,新内存模型只保证了读操作的原子性
JMM 对于正确同步多线程 提供程序的执行结果将与该程序在顺序一致性模型中的执行结果相同,但是允许重排序
JMM 的基本方针为 在不改变程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门;例如 JMM 中,临界区内的代码可以重排序,但是对其他线程不可见
JMM 对于未同步或未正确同步的多线程程序 只提供最小安全性并不保证读取的共享变量是正确的,只保存他是零值,或者一个线程写入的(可能只写了一半)
volatile 内存语义
volatile 写读内存语义
线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息
volatile 内存语义的实现 (两个层面)
volatile 对编译器定制的 volatile 重排序规则
当第二个操作是volatile写,不管第一个操作是什么都不能重排序,到 volatile 写后面
当第一个操作是volatile读,不管第二个操作是什么,都不允许重排序到 volatile 读前
volatile 写读之间不能重排序 (老模型已经有了)
volatile 内存语义的实现 l1 LoadLoad l2 l1 的装载先于 l2 及其后续装载指令
在每个volatile写操作的前面插入一个StoreStore屏障
在每个volatile写操作的后面插入一个StoreLoad屏障
x86
在每个volatile读操作的后面插入一个LoadLoad屏障
在每个volatile读操作的后面插入一个LoadStore屏障
x86 只会写读重排序,故只需要在写后加StoreLoad屏障
JSR-133 为什么要增强 volatile 的内存语义
为了提供一种比锁更轻量的线程之间的通信的机制
锁的内存语义
线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息
锁的释放 - 获取内存语义的实现
利用 volatile 变量的 写-读 所具有的内存语义
利用 CAS 所附带的 volatile读 和 volatile 写的内存语义
final 域的内存语义
写 final 域的重排序规则的实现
JMM 禁止编译器把 final域的写重排序到构造函数之外
编译器会在构造函数 retrun 之前插入一个 StoreStore 屏障,以禁止处理器把 final域 的写重排序到构造函数之外,普通变量则没有这个保证
读 final 域的重排序规则的实现
一个线程中初次读对象引用与初次读该对象包含的 final 域,不能重排序
编译器在读 final 域前 LoadLoad 屏障
final 语义在处理器中的实现
x86 不会对间接依赖(初次读取对象的引用与初次读该对象包含的final域)重排序 LoadLoad(x),不会写写重排序 StoreStore(x)
为什么含有 final域 对象的 引用(this) 不能在构造函数中逃逸
因为可能final域还没有初始化(指令重排),对象逸出,别的线程就可能读取到未初始化的 final域
JSR-133 为什么要增强 final 的内存语义
只要对象是正确构造的(被构造对象的引用在构造函数中没有 "逸出" ),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值
JUC
原子类
AtomicInteger 类主要利用 CAS + volatile 和 native 方法来保证原子操作
LongAdder
JDK 8 引入了的一个原子累加器 LongAdder 专门用作累加
LongAdder 原理
没有竞争在 base 上累加,有竞争创建累加单元,累加单元最多和CPU核心数一样,个数达到了核心数,就通过不断循环,每次循环就换累加单元,尝试累加
使用了字节填充解决伪共享
Java8 中新增了一个注解:@sun.misc.Contended 用来解决累加单元 Cell 的伪共享,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的 padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效
阻塞队列
LinkedBlockingQueue
底层数据结构
底层是一个单向链表 , 默认是无界队列,也可以指定大小
核心成员变量
两个锁,两个条件变量,如果没有传入队列大小,notFull 一般也用不上
入队
last = last.next = newNode
除了自己 put 以外,队列还有空位,由自己叫醒 一个 put 线程,唤醒一个能避免竞争,因为就算唤醒多个,也只有一个能获得锁
出队
将虚拟头节点节点设置为下一个节点
如果队列还有元素,由自己叫醒另一个 take 线程
LinkedBlockingQueue 如何加锁
当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是
head 节点的线程安全。两把锁保证了入队和出队没有竞争
head 节点的线程安全。两把锁保证了入队和出队没有竞争
当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
当节点总数等于 1 时(就一个 dummy 节点) 会产生竞争, take 线程会被 notEmpty 条件阻塞
ArrayBlockingQueue
底层数据结构
基于循环数组,大小由传入的初始大小确定
核心成员变量
一个锁,两个条件变量
LinkedBlockingQueue 与 ArrayBlockingQueue 的比较
LinkedBlockingQueue 支持有界,ArrayBlockingQueue 强制有界
LinkedBlockingQueue 两把锁,ArrayBlockingQueue 一把锁,Linked 性能更好
LinkedBlockingQueue 实现是链表,ArrayBlockingQueue 实现是数组
LinkedBlockingQueue 是懒惰的,而 ArrayBlockingQueue 需要提前初始化 Node 数组
PriorityBlockingQueue
底层数据结构
由数组实现的默认大小11 的 小根堆, 是 PriorityQueue 的线程安全版本
核心成员变量
一个锁一个条件变量,因为他是无界队列,没有 notFull 条件
PriorityBlockingQueue 扩容
添加元素之前会获取锁,添加元素前发现队列已满,会进行扩容 —— 先释放锁,通过 CAS 设置 allocationSpinLock(扩容完毕恢复),只允许一个线程扩容,扩容完毕之前,其他线程自旋并放弃 CPU,扩容完再获取锁,由获取锁的线程用新数组替换原来数组,并添加元素,添加完毕释放锁
初始 11,如果队列容量低于(<) 64,则扩容后为 2n+2
否则扩容为 1.5 倍原容量
DelayQueue
底层数据结构
按延迟时间从小到大出队优先级队列 ;相比 PriorityBlockingQueue ,其添加元素和扩容时都一直持有锁
核心成员变量
核心成员 1 锁 1 条件队列
用一个 Thread 类型 leader 变量记录了等待堆顶元素的第 1 个线程通过 getDelay(..)可以知道堆顶元素何时到期,在条件变量 available上等待有限时间,当发现还有其他线程也在等待堆顶元素 (leader!=NULL) 时无限等待,直到被唤醒
SynchronousQueue
两个内部类
单向链表实现的栈或队列
TransferQueue FIFO 队列对应着公平模式
TransferStack 栈 非公平模式
如果栈中没有元素,或者栈顶元素跟将要入栈的元素模式一样,就入栈
入栈后自旋等待一会看有没有其它线程匹配到它,自旋完了还没匹配到元素就阻塞等待
阻塞等待被唤醒了说明其它线程匹配到了当前的元素,就返回匹配到的元素
或节点被取消执行节点清理
如果两者模式不一样,且头节点没有在匹配中,就拿当前节点跟它匹配,匹配成功了就返回匹配到的元素
如果两者模式不一样,且头节点正在匹配中,当前线程就协助去匹配,匹配完成了再让当前节点重新入栈重新匹配
节点被取消
对接点 s 进行清除, 若 s 不是链表的尾节点,则直接 CAS 进行节点的删除,若s是链表的最后一个节点 , 则暂存,当结点 s 不再是 tail 结点时,延后删除
p.casNext(n, n.next)
并发容器
ConcurrentLinkedQueue
底层是一个单向链表,使用 CAS 来实现出队入队
入队
定位到链表尾部,尝试把新节点放到后面
如果尾部变化了,则重新获取尾部,再重试
优化
tail 代表尾节点,但并不是每次都更新tail,当 tail 与真正尾节点距离大于等于 1 才会CAS更新 tail
出队
定位到头节点,尝试更新其值为 null
如果成功了,就成功出队
如果失败或者头节点变化了,就重新寻找头节点,并重试
没找到元素就返回 null
优化
head 代表头节点,但并不是每次都更新head,当head与真头节点距离大于等于 1 才会 CAS更新 head
避免每次都需要使用循环 CAS 更新 tail 或 head 节点,提高效率
CopyOnWriteArrayList
读不会加锁,写入也不会阻塞读取操作,但是写写互斥 JDK8 ReentrantLock JDK 11 synchronized
写 add
拷贝新的数组,在新数组添加,后替换原数组,不会阻塞读操作
读 get 存在弱一致性
可能读取到被删的值
同步器 AQS
对 AQS 的理解
同步器是用来构建锁和其他同步组件的基础,内部维护了一个FIFO (双向链表) 的自旋队列 (CHL) 来实现线程的排队,它屏蔽了同步状态管理、线程的排队、等待与唤醒的细节,简化了我们实现同步器组件的难度
如何实现一个锁
实现一个静态内部类 Sync, 该类 继承 AQS 并重写 AQS
自定义同步组件实现 Lock 接口,重写的方法中调用 (组合委派的方式调用) 内部类 Sync 的方法
结点状态 waitStatus
CANCELLED = 1 (可打断获取锁时被打断、超时获取锁时超时或被打断 导致节点取消) SIGNAL = -1 CONDITION = -2 PROPAGATE = -3 刚加入默认是 0
为什么要有 PROPAGATE -3
只有doReleaseShared 用到了,可以有效避免在高并发场景下可能出现的、线程没有被成功唤醒的情况出现
AQS 定义两种资源共享方式
Exclusive(独占)
只有一个线程能获取锁,分为独占公平和独占非公平锁
公平锁能防止 "饥饿" ,但是会进行大量的线程切换
非公平锁,可能出现饥饿,但是线程切换次数少,吞吐量高
公平锁和非公平锁的区别
公平锁比非公平锁多了两次抢锁,同时公平锁必须是队头元素才能获得锁
ReentrantLock
公平锁加锁
必须是队头元素才能获得锁
非公平锁加锁
上来就 CAS 抢锁,CAS 成功,设 owner 为当前线程,失败再次 CAS 抢锁 仍然失败则 addWaiter() 方法进入 CHL 队列
解锁
释放同步状态 state--,state 为 0 时,唤醒后继线程
可重入原理
加锁时,查看同步状态,如果不等于 0,且同步状态拥有者是自己,则 state++; 释放锁时 state-- 直到减为 0,tryRelease 返回 true,随后唤醒后继线程
默认不可打断
被打断后不会退出,只会设置当前线程打断标志
可打断原理
以可打断方式获取锁或超时获取锁,在被打断后会抛出异常退出
Share(共享)
多个线程可同时执行获取锁,同步器 Semaphore,CountDownLatch ,CyclicBarrier
独占式获取同步状态流程
获取同步状态,获取成功,退出返回
获取同步状态,获取失败封装成节点进入CHL(自旋锁队列)队列,自旋两次挂起,被打断或者前驱节点被释放的时候唤醒,当前驱节点是头节点,则获取同步状态,获取成功,设置当前节点为头节点
独占式超时获取同步状态流程
与独占式获取同步状态类似,只是多了一个超时等待,每次自旋获取同步状态失败会判断是否超时,超时则退出,否则进入等待状态,等待状态可能被前驱节点唤醒或者被打断,若是被打断则抛出异常后退出
AQS 底层数据结构
双向链表实现的 FIFO 自旋 (CHL) 队列
AQS 使用了什么设计模式
模板方法
lock (Lock接口) - > acquire (AQS) -> tryAcquire (自己重写的AQS (具体同步器自己实现))
acquire 方法就相当于一个模板方法
内部类 ConditionObject
await
获取锁,调用 await()方法 Node node = addConditionWaiter(); 将线程加入条件变量的双向链表中。 节点 Node 的 waitStatus 为 -2,调用 fullyRelease 释放锁上所有重入次数,唤醒锁等待队列中下一个节点,挂起线程直到其它线程将该线程从等待队列移动外部的锁等待队列或者被打断
signal
锁的持有者调用 signal() ,doSignal (first) 将头节点从条件变量链表中断开,将下一个节点设置为 first 。transferForSignal 将节点加入锁等待队列(将waitStatus 由 -2 改为 0 ,enq(node)入队,enq(node) 成功则返回其前一个结点,将其waitStatus设置为-1,以便后续唤醒当前结点)
锁
StampedLock (不属于AQS)
支持乐观读,不用加锁
乐观读之前,验证戳,如果没有发生过写,戳还有效,如果发生过获取写锁,那就人工锁升级 —— 获取读锁再读
ReentrantReadWriteLock
与 ReentranLock几乎一摸一样,不一样体现在 state,写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位 ;写锁是独占模式,读锁是共享模式,它们都是可重入的
锁降级
当前持有写锁,再获取读锁,释放写锁
原理
同步状态为 0 直接加写锁或者读锁
加读锁
获取同步状态与写锁数量,同步状态不为 0,写锁不为 0,且加写锁的线程是自己则加读锁成功,否则进入CHL队列
加写锁
获取同步状态与写锁数量,同步状态不为 0,写锁为 0 则说明加了读锁,如果加读锁的线程是自己则可以加写锁,否则进入CHL队列
释放写锁
写锁全部释放完毕唤醒后继线程
释放读锁
读锁全部释放完毕唤醒后继线程
同步器 CountDownLatch
构造方法
设置同步状态的初始值,也就是子任务数
latch.await()
主线程调用 await,通过同步器状态判断有无子任务在执行,如果同步器状态 state = 0 则说明没有子任务,直接返回,继续执行主线程,否则进入 CHL 队列
latch.countDown()
子任务执行完毕,会调用 countDown() 释放同步状态,当最后一个子任务完成时,同步状态 state = 0,唤醒后继线程,也就是阻塞的主线程
Semaphore
构造方法
设置同步状态的初始值,也就是许可数
acquire
acquire 获取许可数,成功则返回;没有剩余许可则获取失败,进入CHL队列
release
release 释放许可数,释放成功唤醒后继线程
CyclicBarrier
构造函数
初始化计数器的值 与 任务
await
计数器减 1,判断 count 是否为 0,即所有线程都到达,没有则在条件队列上等待
当计数器值为 0,由当前线程执行任务,然后唤醒所有等待的线程
CyclicBarrier 和 CountDownLatch 的区别
CountDownLatch:一个或者多个线程,等待其他多个线程完成某件事情之后才能执行,可以用于服务启动之前等待各个组件加载完毕 或 实现多个线程开始执行任务的最大并行性
CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行,可以用于多线程计算数据,最后合并结果的应用场景
CyclicBarrier 可以多次使用,CountDownLatch 只能使用一次
线程池
使用线程池的好处
提高线程的可管理性
降低资源消耗
提高响应速度
Executor 框架
什么是 Executor 框架
Executor 框架是 Java5 之后引进的,其内部使用了线程池机制,通过该框架来控制线程的 启动、执行 和 关闭,可以简化并发编程的操作。通过 Executor 来启动线程比使用 Thread 的 start 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题 —— 构造函数返回之前其他线程就持有该对象的引用
Executor 框架包含什么
Executor 框架包括了 线程池的管理,线程工厂、阻塞队列 以及 拒绝策略等
三大部分组成
任务
任务的执行 Executor
异步计算的结果 (Future)
Executor 框架的使用流程
主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象,交给 ExecutorService 执行,submit() 方法会返回一个实现 Future 接口的对象,主线程执行 FutureTask.get()获取执行结果
队列的泛型是 Runable,没法执行 Callable 吗
AbstractExecutorService 可以将 Callable 任务转化成 Runable子类 FutureTask
Executor 框架继承关系
创建线程池
通过构造方法实现
Executor 框架的工具类 Executors 来实现
FixedThreadPool
没有救急线程,也不需要超时时间
LinkedBlockingQueue 阻塞无界队列,可以放任意数量的任务
适用于任务量已知,相对耗时的任务
Integer.MAX_VALUE,容量过大,可能会堆积大量的任务,从而造成OOM
SingleThread Executor
只有一个核心线程
LinkedBlockingQueue 阻塞无界队列
适合希望多个任务排队执行
那为什么不自己创建一个单线程串行执行任务
自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一
个线程,保证线程池的正常工作
个线程,保证线程池的正常工作
与 FixedThreadPool 区别
FixedThreadPool 可以强转为 ThreadPoolExecutor 对象调用 setCorePoolSize 等方法进行修改
newSingleThreadExecutor 使用了装饰器模式包装了一下,使其无法通过转换类型,调用修改线程的方法
Integer.MAX_VALUE,容量过大,可能会堆积大量的任务,从而造成OOM
CachedThreadPool
全部都是救急线程,60s 空闲生存时间
SynchronousQueue 无边界的同步队列,必须是一个线程接收任务一个线程提交任务,这个队列专门 和 CachedThreadPool 搭配
默认非公平Stack模式,链表实现
适合任务数比较密集,但每个任务执行时间较短的情况
ScheduledThreadPool
只有核心线程
DelayedWorkQueue 作为任务队列
DelayedWorkQueue 是基于优先级队列的,与 DelayQueue 逻辑基本一样,是一个按延迟时间从小到大出队的 PriorityQueue , 并没使用现成的 PriorityQueue,自己用数组实现了一个
初始16 1.5 倍扩容
有什么方法呢
延迟执行
schedule
周期性地执行
scheduleAtFixedRate 执行时间大于间隔,下一个任务会在这个任务执行完毕后立刻执行,单还是会串行
scheduleWithFixedDelay 从上一次任务执行结束之后开始算间隔时间
和 Timer 相比有什么优势
Timer 缺点:一个线程串行,前一个任务的延迟会影响到之后的任务,抛出异常会导致其他任务终止
SingleThread ScheduledExecutor
ForkJoinPool
什么是 Fork/Join 框架
Fork / Join 框架是 Java7 提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最汇总每个小任务结后得到大任务结果的框架,体现了分而治之的思想
什么是工作窃取算法
把大任务拆分成小任务,放到不同队列执行,执行的快的线程窃取执行的慢的线程的任务来执行,同时为了减少锁竞争,采用了双端队列,工作线程从队列头部取任务,窃取线程会随机从其他工作线程的队列的尾部获取任务
线程池核心参数
corePoolSize 核心线程数目 (最多保留的线程数)
maximumPoolSize 最大线程数目 corePoolSize + 救急线程数量
keepAliveTime 生存时间 - 针对救急线程
unit 时间单位 - 针对救急线程
workQueue 阻塞队列
threadFactory 线程工厂 - 可以为线程创建时起个好名字
handler 拒绝策略
AbortPolicy 让调用者抛出 RejectedExecutionException 异常
这是默认策略
CallerRunsPolicy 让调用者运行任务
DiscardPolicy 放弃本次任务
DiscardOldestPolicy 放弃队列中最早的任务,添加该任务
线程池状态
线程池状态与线程个数合二为一,使用 int 类型原子变量的高 3 位来表示线程池状态,低 29 位表示线程数量,好处就是可以用一次 CAS 原子操作
进行赋值
进行赋值
有哪些状态
TERMINATED > TIDYING > STOP(shutdownnow) > SHUTDOWN(shutdown) > RUNNING
线程池原理
线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务
当线程数达到 corePoolSize 并没有线程空闲,这时再加入任务,新加的任务会被加入 workQueue 队列排队,直到有空闲的线程
如果队列选择了有界队列,那么任务超过了队列大小时,会创建 maximumPoolSize - corePoolSize 数目的线程来救急
如果线程到达 maximumPoolSize 仍然有新任务这时会执行拒绝策略
ThreadPoolExecutor 源码分析
execute 任务提交流程
获取当前线程池状态
当前线程数小于 corePoolSize , 则 (向 Workers) 添加一个核心线程(带任务)
成功则返回
添加失败
重新获取线程池状态
是RUNNING并且加入队列成功
第二次检查线程池状态,若加入阻塞队列后不是RUNNING了就从队列中移除
移除成功 执行拒绝策略
移除失败 就给这个任务一个被执行的机会,若当前线程池工作线程为空,尝试新增 Worker 去消费阻塞队列的任务
是RUNNING但加入队列失败
说明阻塞队列已满,则尝试添加新线程执行 (救急线程), 添加失败则执行拒绝策略
不是RUNNING
则尝试添加新线程执行 (救急线程), 添加失败则执行拒绝策略
当前线程数大于等于 corePoolSize
addWorker
addWorker 这个方法主要用来创建并启动新的工作线程,创建和启动工作线程成功则返回 true
检测当前能够添加 Worker
不能则返回 false
线程池状态处于 STOP,TIDYING,TERMINATED 时,添加工作线程失败
线程池状态处于 SHUTDOWN 状态且 worker 的首个任务不为空时,添加工作线程失败
线程池状态处于 SHUTDOWN 且 worker 的首个任务为空且阻塞队列为空时,添加工作线程失败
能则自旋 + CAS 增加线程的个数
如果线程超过个数限制则返回 false
CAS 成功进入 worker 创建流程
创建 worker,存放 worker 集合的 HashSet 不是线程安全的,为了防止 add 时候其他线程 remove ,需要加独占锁,并会再次检查线程池状态,如果线程池状态处于 RUNNING 或 SHUTDOWN 且当前 Worker 是为了用于消费阻塞队列缓存的任务时候向 workers 新增 worker ,成功后释放独占锁,启动工作线程(start)
添加失败从workers 中移除工作线程并通过CAS减少线程个数 (finally方法)
Worker 构造函数为什么会把 state 设置为 -1
只有已经启动的线程才能对其中断 —— 在 runWorker 时才会将其 state 置 0
任务执行流程分析
runWorker
Worker 继承了AQS,实现了 Runable,任务执行由 Worker 来实现,它自己可能有任务,如果为空,就从队列中取任务; 启动工作线程时会执行 run 方法, 在 run 方法中执行 runWorker 方法
getTask 不断从队列中获取任务执行
获取到任务,执行前加锁 task.run() 执行完毕释放锁
没有任务了就阻塞等待在阻塞队列直到获取到队列的任务,如果判断当前Worker需要被回收就返回null
Worker 什么时候需要被回收
getTask 返回 null 时
线程池状态大于等于 STOP 或 状态为SHUTDOWN且任务队列为空,就会被回收,返回 null
阻塞被打断后继续执行发现条件满足
超过了最大线程数 或者 线程数超过了核心线程数并且获取任务超时 (keepAliveTime) ,返回 null
wc > maximumPoolSize的情况是因为可能在此方法执行阶段同时执行了
setMaximumPoolSize
setMaximumPoolSize
正确关闭线程池步骤
调用 shutDown() 或者 shutDownNow() ,后循环调用 awaitTermination 直到返回 true
shutdown
shutdown 方法中断线程之前会调 w.tryLock,运行线程会获取锁,tryLock会失败,所以不会对正在执行任务的线程进行中断,而会对空闲的线程进行中断
对于空闲的线程,是阻塞在 getTask() 的 workQueue.take() 方法中的,响应中断退出阻塞,线程池状态为SHUTDOWN并且队列为空,减少个数,getTask返回 false,在 runWorker移除Worker
shutdownNow
它是对每个线程调用 t.interrupt() 方法来实现的,试图停止所有正在执行的线程,不过只是尽力尝试终止正在执行的线程,不保证一定能终止,因为不响应中断的线程无法终止。之后就会把那些未执行的任务进行移除并且返回。
如何合理配置线程池参数
CPU 密集型
CPU 核数 +1 个线程
比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响
IO 密集型
即该任务需要大量的 IO,即大量的阻塞 CPU 核数 * 2
线程数 = N(CPU核数)*(1+ WT(线程等待时间)/ ST(线程时间运行时间))
0 条评论
下一页