Java内存模型
2020-06-11 13:46:48 0 举报
AI智能生成
Java并发编程原理、Java并发包包含哪些内容、Java并发包到底是怎么来的、Java内存模型、并发的微观和宏观
作者其他创作
大纲/内容
按需禁用缓存
按需禁用编译优化
按需禁用编译优化
JVM
方法
volatile
synchronized
final
final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化
六项 Happens-Before 规则
前面一个操作的结果对后续操作是可见的
顺序性
前面一个操作的结果对后续操作是可见的
传递性
volatile 变量规则
valatile变量的写Happens-Before后续的读
锁的规则
对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
线程 start() 规则
主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在
启动子线程 B 前的操作
启动子线程 B 前的操作
线程 join() 规则
在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任
意操作 Happens-Before 于该 join() 操作的返回
意操作 Happens-Before 于该 join() 操作的返回
...
线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事
件的发生
件的发生
可以通过Thread.interrupted()方法检测到是否有中断发生
对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法
的开始
的开始
Happens-Before 规则
约束了编译器的优化行为
虽允许编译器优化
但是要求编译器优化后一定遵守 HappensBefore 规则
关键字
规则
在 Java 语言里面
Happens-Before 的语义本质上
是一种可见性
在现实世界里
如果 A 事件是导致 B 事件的起因
那么 A 事件一定是先于(Happens-Before)B 事件发生的
A Happens-Before B
意味着 A 事件对 B 事件来说是可见的
无论 A 事件和 B 事件是否发生在同一个线程里
A 事件发生在线程 1 上,B 事件发生在线程 2 上
Happens-Before 规则保证
证线程 2上 也能看到 A 事件的发生
这里所谓的发生/看见
指的都是:共享变量
Java内存模型 两部分
面向开发人员的
即上述Happens-Before 规则
面向JVM开发人员的
涉及到具体的实现了
volatile保证可见性功能的实现
synchronized——管程在Java中的实现:隐式锁
final 不变性的实现
...
了解(仅仅为了装逼)、忽略即可
隐式规则
只要我们按照这些 默许的规则 编写并发程序
就能够达到
按需禁用缓存
按需禁用编译优化
按需禁用编译优化
的目的
并发的博弈
可见性
按需禁用缓存
有序性
按需禁用编译优化
原子性
按需禁用CPU中断
原子性问题的源头是 线程切换
操作系统 做线程切换 是依赖 CPU中断的
单核时代
禁止CPU发生中断 就能够 禁止线程切换
多核时代
同一时刻,有可能有N个线程同时在执行
N个核(CPU)在工作 = 同时N个线程在工作
一个线程执行在 CPU-1 上
一个线程执行在 CPU-2 上
一个线程执行在 CPU-2 上
此时禁止 CPU中断
只能保证 CPU上的线程 连续执行
并不能保证 同一时刻只有一个线程执行
同一时刻只有一个线程执行
互斥
杀手级解决方案:锁
保证 对共享变量的修改是互斥的
无论是 单核CPU 还是多核CPU
都能保证 原子性
互斥锁
谨防:锁自家门来保护他家资产
性能:细粒度锁
“原子性”的本质
其实不是不可分割
不可分割只是外在表现
其本质是:多个资源间有一致性的要求
操作的中间状态对外不可见
在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位)
在银行转账的操作中也有中间状态(账户A 减少了100,账户B 还没来得及发生变化)
解决原子性问题
是要保证 中间状态对外不可见
预防 死锁
死锁发生的条件:四个条件 缺一不可
互斥
共享资源 X 和 Y 只能被一个线程占用
互斥这个条件我们没有办法破坏
因为我们用锁为的就是互斥
占有且等待
线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X
破坏:可以一次性申请所有资源
等待-通知
synchronized + 等待:wait() + 通知/唤醒:notify()、notifyAll()
不可抢占
其他线程不能强行抢占线程 T1 占有的资源
破坏: Lock 是可以轻松解决这个问题的
循环等待
线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源
破坏:对资源进行排序,然后按序申请资源
反向分析
只要我们破坏其中一个,就可以成功避免死锁的发生
性能
Java SDK 并发包里之所以有那么多东西
有很大一部分原因 就是要提升在某个特定领域的性能
并发问题
微观
原子性问题
可见性问题
有序性问题
宏观
安全性问题
安全性方面 要注意 数据竞争 和 竞态条件
活跃性问题
活跃性方面 需要注意 死锁、活锁、饥饿等问题
性能问题
最好的方案 自然就是使用 无锁的算法 和 数据结构 了
线程本地存储 (Thread Local Storage, TLS)
写入时复制 (Copyon-write)
乐观锁
Java 并发包里面的原子类也是一种 无锁的数据结构
Disruptor 则是一个 无锁的内存队列
减少锁持有的时间
使用 细粒度的锁:
ConcurrentHashMap
还可以使用读写锁
也就是读是无锁的,只有写的时候才会互斥
Java 语言里的线程
本质上就是操作系统的线程,它们是一一对应的
为什么要区分 堆和栈
局部变量 是和 方法(栈)同生共死的
一个变量如果想跨越方法的边界,就必须创建在堆里
每个线程都有自己独立的调用栈
每个线程都有自己的调用栈,局部变量保存在线程各自的调
用栈里面,不会共享
用栈里面,不会共享
没有共享,就没有伤害
线程封闭
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题
这个思路很好,已经成为解决并发问题的一个重要技术:线程封闭
当你看到代码里出现 if 语句的时候
就应该立刻意识到 可能存在 竞态条件
Lock 和 Semaphore 的区别
Lock --> 允许 1 个 线程进入临界区
Semaphore --> 允许 N个 线程进入临界区
Semaphore --> 允许 N个 线程进入临界区
管程和信号量 这两个 同步原语 在 Java 语言中的实现,理论上用这两个同步原语中
任何一个 都可以 解决所有的 并发问题 。
任何一个 都可以 解决所有的 并发问题 。
Java SDK 并发包里 为什么还有很多其他的工具类呢
分场景优化性能,提升易用性
ReadWriteLock
StampedLock
CountDownLatch
CountDownLatch 主要用来解决 一个线程等待多个线程 的场景
CountDownLatch 的计数器是 不能循环利用 的
CyclicBarrier
一组线程之间互相等待
但CyclicBarrier 的 计数器 是可以 循环利用 的,
而且具备自动重置的功能,一旦 计数器减到0 会 自动重置 到你设置的 初始值
而且具备自动重置的功能,一旦 计数器减到0 会 自动重置 到你设置的 初始值
所有的阻塞操作,都需要设置超时时间,这是个很好的习惯
线程安全容器
同步容器
synchronized
Collections
synchronized(this)
this --> 包装容器
并发容器
Java容器中的快速失败机制(fail-fast)
ConcurrentModificationException
它会探查容器上的任何除了你的进程所进行的操作以外的所有变化,
一旦它发现其它进程修改了容器,
就会立刻抛出ConcurrentModificationException异常
一旦它发现其它进程修改了容器,
就会立刻抛出ConcurrentModificationException异常
“快速报错”的意思
即:不是使用复杂的算法在事后来检查问题
原子类
基本 不会 出现 死锁
但可能出现 饥饿 和 活锁 问题,因为 自旋 会 反复重试
reference ABA 问题
version版本号
能够解决一些 简单的 原子性 问题
所有原子类的方法 都是针对 一个 共享变量 的
解决 多个变量 的 原子性 问题
还是使用 互斥锁 方案
concurrent包的实现
线程
是一个 重量级 的 对象,应该 避免 频繁 创建 和 销毁
线程池
是一种 生产者 - 消费者 模式
线程池 的 使用方 是生产者,线程池 本身 是消费者
强烈建议 你要根据 不同的业务类型 创建 不同的线程池 ,以 避免 互相干扰
FutureTask
实现了 Runnable 和 Future
如果任务之间有 依赖 关系,比如当前任务依赖前一个任务的执行结果,这种问题基本上都可以用 Future 来解决
等待-通知
synchronized + 等待:wait() + 通知/唤醒:notify()、notifyAll()
工具类
主要是 会用,知道 什么场景 用什么
重点关注: 细节问题 与 最佳实践
产生背景 、应用场景 以及 实现原理
异步编程
1.8
CompletableFuture
串行、并行、AND聚合、OR聚合
1.9
Flow API
并发工具
简单的并行任务
线程池 +Future
任务之间有聚合关系
CompletableFuture
批量的并行任务
CompletionService
ThreadPoolExecutor + Future
Executor
ThreadPoolExecutor
共享 一个队列
ForkJoinPool
有 多个任务队列
任务窃取 机制
最佳实践
我们写程序,不是做数学题,而是在 搞工程
工程中会有 很多 不稳定的因素,更有很多你预料不到的情况发生
所以不要让你的代码铤而走险,尽量使用 更稳妥 的 方案和设计
范例
while(true) 总不让人省心
死循环
break
活锁
随机数
signalAll() 总让人省心
用 signalAll() 会 更安全
写代码是 搞工程
尽量使用 更稳妥 的方案
Semaphore 需要锁中锁
Semaphore 允许多个线程访问一个临界区
这也是一把 双刃剑
当 多个线程 进入临界区 时
如果需要 访问共享变量 就会 存在并发 问题
所以 必须加锁
也就是说 Semaphore 需要 锁中锁
锁的申请和释放要成对出现
StampedLock
锁的升级
会生成新的 stamp ,而 finally 中释放锁用的是 锁升级前的 stamp
要对 stamp 重新赋值
回调总要关心执行线程是谁
当看到回调函数的时候,一定问一问执行回调函数的线程是谁
共享线程池:有福同享就要有难同当
所有的 CompletableFuture 默认共享一个 ForkJoinPool
当有阻塞式 I/O 时
可能导致所有的 ForkJoinPool 线程都阻塞
进而影响整个系统的性能
更倾向于使用 隔离 的方案
每个任务类型创建 单独的 线程池
线上问题定位的利器:线程栈 dump
定位线上并发问题
通过查看 线程栈 来 定位问题
重点是查看 线程状态
分析线程 进入该状态 的原因 是否合理
给线程赋予一个 有意义的 名字
......
并发设计模式
Immutability模式:利用不变性解决并发问题
1. 不可变类的特点:类、属性都是final的,方法是只读的1. 不可变类的特点:类、属性都是final的,方法是只读的
2. 为了解决有些不可变类 每次创建一个新对象 导致内存浪费的问题:享元模式/对象池
3. 注意事项:区别引用不可变和实际内容不可变
4. 更简单的不可变对象:无状态对象
Copy-on-Write模式:不是延时策略的COW
线程本地存储模式:没有共享,就没有伤害
Guarded Suspension模式:等待唤醒机制的规范实现
Balking模式:再谈线程安全的单例模式
Thread-Per-Message模式:最简单实用的分工方法
对应到现实世界,其实就是 委托代办
Worker Thread模式:如何避免重复创建线程?
类似于 车间里工人 的工作模式
提交到相同线程池中的任务一定是 相互独立 的,否则就一定要慎重
两阶段终止模式:如何优雅地终止线程?
第一个阶段主要是 线程T1 向 线程T2 发送终止指令
第二阶段则是 线程T2 响应终止指令
“毒丸”对象
“毒丸”对象是 生产者 生产的 一条 特殊任务
然后当 消费者线程 读到 “毒丸”对象 时
会 立即终止 自身的 执行
生产者-消费者模式:用流水线思想提高效率
避免共享的设计模式
Immutability 模式
需要注意对象属性的不可变性
Copy-on-Write 模式
需要注意性能问题
线程本地存储 模式
需要注意异步执行问题
多线程版本 IF 的设计模式
Guarded Suspension 模式
会等待 if 条件 变为 真
Balking 模式
则 不需要 等待
三种最简单的分工模式
Thread-Per-Message 模式
Worker Thread 模式
需要注意潜在的线程 死锁问题
任务之间 不要有 依赖关系
生产者 - 消费者模式
并发设计模式
并发设计模式是前人在做并发编程时已经归纳好的,
在不同场景下具有可行性的设计模式,
我们在设计并发程序时应当优先考虑这些设计模式(以及这些设计模式的组合)
在不同场景下具有可行性的设计模式,
我们在设计并发程序时应当优先考虑这些设计模式(以及这些设计模式的组合)
Immutability模式
充分利用了面向对象的封装特性,
将类的mutator的入口全部取消,自身的状态仅允许创建时设置。
状态的改变通常通过新建一个对象来达成,为了避免频繁创建新对象,
通常通过享元模式或对象池来解决该问题。
因此,其适用于对象状态较少改变或不变的场景,需要对一定的内存overhead可容忍。
将类的mutator的入口全部取消,自身的状态仅允许创建时设置。
状态的改变通常通过新建一个对象来达成,为了避免频繁创建新对象,
通常通过享元模式或对象池来解决该问题。
因此,其适用于对象状态较少改变或不变的场景,需要对一定的内存overhead可容忍。
COW模式
通过写时拷贝的方式,
保证读取时候的无阻塞及多线程读写时的无共享,
由于其写入时的拷贝机制和加锁机制(JAVA中),
因此仅适合于读多写非常少的场景。
保证读取时候的无阻塞及多线程读写时的无共享,
由于其写入时的拷贝机制和加锁机制(JAVA中),
因此仅适合于读多写非常少的场景。
相比于Immutability模式,
COW将引用指向新对象的操作封装在了内部(JAVA中)来实现一定的可变性。
COW将引用指向新对象的操作封装在了内部(JAVA中)来实现一定的可变性。
线程本地存储模式
利用线程本地存储空间(TLAB)来存储线程级别的对象,
以保证各线程操作对象的隔离性,
一定程度上可以等同于能够携带上下文信息的局部变量。
以保证各线程操作对象的隔离性,
一定程度上可以等同于能够携带上下文信息的局部变量。
JAVA中是在用户空间实现的ThreadLocal控制的,
目前的实现可以保证map的生命周期与各Thread绑定,
但Value需要我们手动remove来避免内存泄漏。
目前的实现可以保证map的生命周期与各Thread绑定,
但Value需要我们手动remove来避免内存泄漏。
对各类并发设计模式,
考量其 核心思想、核心技术、trade-off、适用场景、与其他设计模式的对比等。
考量其 核心思想、核心技术、trade-off、适用场景、与其他设计模式的对比等。
首先,应当考虑没有共享的模式,
这类方式用一些技术手段来避免并发编程中需要考虑的同步、互斥等问题,
有些模式的实现也被称为无锁机制,其简单且不易出错。
这类方式用一些技术手段来避免并发编程中需要考虑的同步、互斥等问题,
有些模式的实现也被称为无锁机制,其简单且不易出错。
其次,从分工、同步、互斥三个角度来看几个设计模式
从 分工 的角度看
以下三种模式在对线程 工作粒度 的划分上 逐渐变细
Thread-per-message 模式
Thread-per-message模式通过一消息/请求一线程的方式处理消息/请求,
这种模式要求线程创建/销毁overhead低且线程占用内存的overhead也低,
因此在overhead高时需要保证线程的数量不多,
或者采用更轻量级的线程(如协程)来保证。
这种模式要求线程创建/销毁overhead低且线程占用内存的overhead也低,
因此在overhead高时需要保证线程的数量不多,
或者采用更轻量级的线程(如协程)来保证。
Worker Thread 模式
Worker Thread模式相当于在Thread-per-message模式的基础上,
让消息/请求与threads的工厂打交道,
在JAVA中可以理解为线程池,
通过将同类消息/请求聚类到某类工厂(也有工厂模式的意思在)
来为这类消息/请求提供统一的服务(定量的线程数、统一的创建方法、统一的出错处理等),
当然,它依然有Thread-per-message中需要控制线程占用内存的问题。
让消息/请求与threads的工厂打交道,
在JAVA中可以理解为线程池,
通过将同类消息/请求聚类到某类工厂(也有工厂模式的意思在)
来为这类消息/请求提供统一的服务(定量的线程数、统一的创建方法、统一的出错处理等),
当然,它依然有Thread-per-message中需要控制线程占用内存的问题。
生产者-消费者 模式
生产者-消费者模式在Woker Thread模式的基础上,
加入了对消息/请求的控制(大部分使用队列来控制),
并划定了生产者线程和消费者线程,
其中它也包含了同步和互斥的设计,在JAVA中的线程池中也可见一斑。
这类设计常见于MQ中。
加入了对消息/请求的控制(大部分使用队列来控制),
并划定了生产者线程和消费者线程,
其中它也包含了同步和互斥的设计,在JAVA中的线程池中也可见一斑。
这类设计常见于MQ中。
从 同步 和 互斥 的角度看
多线程版本的if被划分为了两种模式(Guarded Suspension模式和Balking模式)
Guarded Suspension 模式
是 传统的 等待-通知 机制 的实现,
非常标准化,
JAVA中则依赖管程实现了各种工具类来保证多线程版本if的正确性
非常标准化,
JAVA中则依赖管程实现了各种工具类来保证多线程版本if的正确性
Balking 模式
依赖于 互斥 保证 多线程版本 if的正确性
两阶段终止模式
在线程粒度的管理中通过中断操作和置位标记来保证正常终止,
JAVA中在线程池粒度的管理中可以通过SHUNDOWN方法来对线程池进行操作,
源码中可以看到,
其实质也是通过第一种方式来达成目的的。
JAVA中在线程池粒度的管理中可以通过SHUNDOWN方法来对线程池进行操作,
源码中可以看到,
其实质也是通过第一种方式来达成目的的。
0 条评论
下一页