一、JAVA内存模型与线程
1 CPU工作效率比IO工作效率大
1.1 为什么
计算机的存储设备与处理器的运算速度有几个数量级的差距
1.2 怎么处理CPU与IO之间效率的差距
加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)作为内存与处理器之间的缓冲
内存->缓存(计算)->内存
1.3 引发什么问题?
缓存一致性,也是因为这一点,所以有了线程安全问题
1.4 操作系统是如何解决缓存一致性的问题的?
通过协议进行处理
1.5 什么是指令重排序?
处理器可能会对输入代码进行乱序执行(Out-of-Order Execution)优化,以让处理器内部的运算单元能尽量被充分利用
2 JAVA内存模型
2.1 为什么需要使用JAVA自定义的内存模型
屏蔽各种硬件和操作系统内存访问差异
2.2 JAVA工作的主内存与工作内存是什么?
主内存
- 所有变量都存储在主内存
- 对应堆中的对象实例数据部分
- 直接对应于物理硬件的内存
工作内存
- 线程私有的内存区域,存储主内存中变量的副本
- 对变量的操作都是在工作内存中,不能对主内存进行操作
- 不同线程不能相互访问工作内存
- 对应虚拟机栈中的部分区域
- 对应物理硬件的寄存器和高速缓存
3 内存间交互操作指令
lock(锁定)
作用于主内存的变量,把一个变量标志为一条线程独占的状态
unlock(解锁)
作用于内存的变量,把一个处于锁定状态的变量释放出来
read(读取)
把变量的值从主内存传输到线程的工作内存
load(载入)
把read操作从主内存中得到的变量值放入工作内存的变量副本中
use(使用)
把工作内存中一个变量的值传递给执行引擎
assign(赋值)
从执行引擎接受到的值赋给工作内存的变量
store(存储)
把工作内存中的一个变量的值传送到主内存中
write(写入)
把store操作从工作内存中的到的变量对的值放入主内存变量中
4 指令操作之间的规则
不允许read和load、store和write操作之一单独出现。不允许一个变量从主内存读取了但工作内存不接受,或从工作内存发起会写但主内存不接受
不允许丢弃assign操作,在工作内存中改变之后必须把该变化同步回内存
不允许一个线程无原因的把数据从线程的工作内存同步回主内存
一个新的变量只能再主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量
一个变量在同一时刻只允许一条线程对其lock操作,lock几次,需要unlock几次
执行lock操作,会先清除工作内存中的值,然后重新获取主内存中的该值
如果一个变量事先没有被lock操作,不允许unlock,不允许unlock其他线程lock的变量
unlock的时候,需要把变量同步回主内存
5 对于volatile型变量的特殊规则
5.1 关键字volatile是最轻量级的同步机制
5.2 volatile的两种特性
5.2.1 保证变量对所有线程的可见性(更新某个变量值,新值对于其他线程来说是可以立即得知的),但不意味这样就是线程安全的,只有在以下两种场景,volatile才是线程安全的
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他状态变量共同参与不变约束
5.2.2 禁止指令重排序优化。保证变量赋值操作顺序与程序代码中的执行顺序一致,这一个也是volatile的特性,不仅仅是锁的特性
5.3 volatile程序性能
- 性能优于锁
- 读操作与普通变量几乎没有什么差别
- 写操作因为需要插入许多内存屏障指令保证不发生乱序,所以会慢一点
5.4 volatile内存中的特殊规则
- load、read的动作必须连续一起出现(每次使用变量的时候必须先从主内存中刷新最新的值)
- assign、store的动作必须连续一起出现(每次修改完V后都必须立刻同步回主内存,用于保证其他线程可以看到自己对变量的修改)
- 执行动作是有序的,保证代码的执行顺序与程序的顺序相同
因为这些特殊规则才促就了volatile的特性
5.5 对于long和double型变量的特殊规则
因为虚拟机把long和double变量都实现成原子操作,所以一般不需要把用到的long和double变量专门声明为volatile
5.6 并发中的三个特性
5.6.1 原子性
保证原子性变量操作,如果需要更大范围的原子性操作,那需要用锁或synchronized来保证
5.6.2 可见性
当一个线程修改了共享变量的值,其他线程能够立即得知这个修改
synchronized 和 final 能保证可见性
5.6.3 有序性
如果在本线程内观察,所有的操作都是有序的,如果一个线程中观察另一个线程,所有操作都是无序的
volatile synchronized能保证有序性
5.7 先行发生原则
判断数据是否存在竞争、是否安全的主要依据
5.7.1 是什么?
定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等
5.7.2 天然的先行发生关系(无须加锁)
程序次序规则。在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
volatile变量规则。对一个volatilc变量的写操作先行发生于后面对这个变量的读操作。
线程启动规则。Thread对象的start()方法先行发生于此线程的每一个动作。
线程终止规则。线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等手段检测到线程已经终止运行。
线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
对象终结规则。一个对象的初始化完成先行发生于它的finalize()方法的开始。
传递性。如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
5.7.3 时间先后顺序与先行发生原则之间基本没有太大的关系,衡量并发安全问题时不要受到时间顺序的干扰,一切必须以先行发生原则为准
6 Java与线程
6.1 线程的实现
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)
主流操作系统都提供了线程的实现,Java线程的关键方法都是声明Native,所以是直接使用了平台相关的方法去创建线程
6.2 实现线程的3种方式
6.2.1 使用内核线程
内核线程(Kernel-Level Thread KLT) 就是直接由操作系统内核支持的线程,这种线程由内核来完成线程的切换。
内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
每个内核线程都可以视为内核的一个分身。
支持多线程的内核叫做多线程内核。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能 有轻量级进程。轻量级进程与内核线程之间1:1的关系成为一对一线程模型。
局限性
- 基于内核线程实现,线程操作(创建,析构、同步)都需要系统调用,代价相对比较高,需在用户态(User Mode)和内核态(Kernel
Mode)中来回切换 - 需要消耗内核资源(如内核的栈空间)
6.2.2 使用用户线程
广义上面讲,一个线程只要不是内核线程,就可以认为是用户线程
从定义上讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制
狭义上面讲,完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现
用户线程的建立、同步、销户和调度完全在用户态中完成,不需要内核的帮助
如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的
这种进程与用户线程之间1:N的关系称为一对多的线程模型
优势:不需要内核支援
劣势:没有系统内核的支援,所有的线程操作都需要用户程序自己处理
- 线程的创建、切换和调度
- 阻塞如何处理
- 如何将线程映射到其他处理器上
因为用户线程实现程序比较复杂,所以使用用户线程的程序越来越少
6.2.3 使用用户线程加轻量级进程混合实现
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
轻量级进程作为桥梁,可以使用内核提供线程调度功能以及处理器映射功能,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。
这种关系为N:M关系,多对多的线程模型
6.3 JAVA线程的实现
- Windows版和Linux版本使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程中。
- Solaris通过支持一对一、多对多的线程模式。
6.4 JAVA线程调度
6.4.1 协同式线程调度
线程的执行时间由线程本身控制,线程把自己的工作执行完,主动通知系统切换到另外一个线程
好处
- 实现简单
- 切换操作堆线程自己是可知的,所以没有什么线程同步问题
坏处
- 线程执行时间不可控制,如果一个线程编写有问题,那程序一直会阻塞在那里
6.4.2 抢占式线程调度(JAVA线程实现方式)
线程的切换不由线程本身来做决定
好处
- 执行时间是可控的,不会有一个线程导致整个进程阻塞
使用优先级来建议系统对某个线程多分配执行时间
- 提供了10个级别的线程优先级
- 优先级是不太靠谱的,因为优先级不一定与系统线程优先级相对应,有可能有几个优先级相同的情况
- 优先级可能会被系统自行改变,如在window系统中
6.5 线程状态切换
新建(new)
创建后尚未启动的线程处于这种状态
运行(Runable)
Runnable包括了操作系统状态中的Running和Ready,也就是处于此线程由可能正在执行,也有可能正在等待CPU为他分配执行时间
无限期等待(Waiting)
处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显示的唤醒(释放锁)
- Object.wait()
- Thread.join()
- LockSupport.park()
限期等待(Timed Waiting)
处于这种状态的线程不会被分配CPU执行时间,无须等待被其他线程显示的唤醒,在一定时间之后会被系统自动唤醒。
阻塞(Blocked)
与等待状态的区别是,在等待获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生。
结束(Terminated)
已终止线程的线程状态。
感谢你看到这里,我是程序员麦冬,一个java开发从业者,深耕行业六年了,每天都会分享java相关技术文章或行业资讯
欢迎大家关注和转发文章,后期还有福利赠送!