java内存模型与线程

2019-11-16  本文已影响0人  sizuoyi00

1. 缓存一致性

计算机并发执行若干任务,需要与内存交互。计算机存储设备处理速度与处理器处理速度有着量级的差距,所以现代计算机系统不得不加入一层读写速度尽可能接近处理器运算速度的“高速缓存”来处理内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算快速进行,运算结束后再从缓存同步到内存中,这样处理器无需等待缓慢的内存读写了。

但是这样引入了一个问题:缓存一致性。多处理器系统,每个系统有自己的高速缓存,而又共享同一主内存。当多个处理器运算任务都涉及同一主内存区域时,将导致各自的缓存数据不一致,如果真的发生这种情况,同步回主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时遵循一些协议,在读写时根据协议进行操作,如MESI。

MESI缓存一致性协议
如果变量X是一个如valotile修饰的变量,相当于在底层加了一个#Lock,此时会触发MESI缓存一致性协议。

1.T1线程读取了主内存变量X,将变量X标记为E状态,同时会一直监听/嗅探bus其他线程是否有对变量X的操作。
2.如果此时线程T2也读取了变量X,此时嗅探机制起作用,线程1与线程2会将变量X都编辑为S状态。
3.如果线程T1修改了变量X,先锁住该变量缓存行+将变量X标记为M+向bus发送消息,同时线程T2会嗅探到bus消息中其他线程要修改变量X,这时线程T2会将变量X标记为I。需要重新读取主存的变量X。
4.线程T1修改完变量X后,将状态标记为E,并写回主内存中X新值。

状态 描述 监听任务
M 修改 (Modified) 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive) 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared) 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该Cache line无效。

总线锁与缓存一致性协议:如果是一个缓存行(缓存最小单位),则使用缓存一致性协议,多行使用总线锁。

2. java内存模型(java memory model, JMM)

java虚拟机规范试图定义一种java内存模型来屏蔽掉各种硬件与操作系统的内存访问差异,以实现java程序在各种平台下都能达到一致的内存访问效果。

2.1 主内存与工作内存

java内存模型主要目标是定义程序中各个变量的访问规则,其是一个抽象,即咋虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。此处的变量包括****实例对象,静态字段和构成数组对象的元素,但不包括局部变量与方法参数。因为后者是线程私有的,不会被共享,自然不会存在竞争问题。

java内存模型规定了所有的变量存储在主内存中,对应硬件的主内存。每条线程还有自己的工作内存,对应硬件的CPU缓存、寄存器。线程对变量的所有操作(读取,赋值等)必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要主内存完成。

java内存模型与java内存区域并不是一个层次的内存划分,基本上没有联系。如果非要对应的话,主内存主要对应java堆中的对象实例数据部分,而工作内存则对应虚拟机栈的部分区域。

2.2 内存见交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存的实现细节,java内存模型定义了8种操作完成,以下每种操作都是原子的,不可再分的。

double,long特殊规则:虚允许虚拟机将没有被volatile修饰的64位数据的读写划分为两次32位的操作,即虚允许虚拟机实现选择可以不保证64位数据类型的load\store\read\write操作的原子性,这就是所谓的double和long的非原子性协定。但是目前各种平台的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作对待,因为编写代码时一般不需要把用到的long和double变量专门声明为volatile。

(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的 变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中, 以便随后的load动作使用
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作 内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要变量的值的字节码指令时将会执行这个操作
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中

主内存复制到工作内存:read+load
工作内存复制到主内存:store+write

注:java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。即可能出现read a、read b、load b、load a。

规则:

  1. 不允许read和load、store和write操作单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或相反的情况出现
  2. 不允许一个线程丢弃他的最近的assign操作,即变量在工作内存改变了之后必须把该变化同步回主内存
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
  7. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  8. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

2.3 对于volatile型变量的特殊规则

vaolatile是java虚拟机提供的最轻量级的同步机制。

特性1.保证此变量对所有线程的可见性。这里的可见性是指一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量的值在线程间传递均需要主内存来完成。

(1)运算结果并不依赖于变量的当前值,或者能够确保只有单一的线程修改变量的值
(2)变量不需要与其他的状态变量共同参与不变约束。

满足以上两点,volatile可保证原子性。

特性2.禁止指令重排序优化。普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致(As-If-Serial)。

指令重排
编译器以及CPU为了优化代码或者执行的效率而执行的优化操作
无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
指令重排一般发生在编译器和CPU运行时。
As-If-Serial:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变,多线程不遵守此原则。

内存屏障
1.保证特定操作的执行顺序
2.保证某些变量的内存可见性
通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化,禁止指令重排。

内存屏障插入规则

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

JMM保守策略内存屏障插入规则
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。

2.4原子性、可见性、有序性

原子性:由java内存模型直接保证的原子性变量操作包括read、load、assign、use、store、write,基本数据类型的访问读写是具备原子性的。大范围的原子性保证,java内存模型提供了lock、unlock来满足,虚拟机提供了更高层次的字节码指令monitorenter和monitorexit隐式对应,反映到java代码就是同步块-synchronized关键字。

可见性:一个线程修改了共享变量的值,其他线程能立即得知这个修改java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。对应上边MESI一致性协议分析

synchronized和final也能实现可见性。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。

有序性:如果在本线程内观察,所有操作都是有序的(串行As-If-Serial);如果在一个线程观察另一个线程,所有操作都是无序的(指令重排,工作内存与主内存同步延迟)。volatile有序性本身就包含了禁止指令重排的语义;synchronized有序性是由“一个变量在同一时刻只允许一条进程对其进行lock操作”保证的。

2.5先行发生原则

先行发生是java内存模型定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其实就是发生在操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改内存中共享变量的值,发送了消息,调用了方法等。

天然先行发生关系:

1.线程次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构
2.管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
3.volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
4.线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
5.线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段加测到线程已经终止执行。
6.线程中断规则:对线程interrupt()方法的调用先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
7.对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
8.传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

如果两个操作之间的关系不在天然发生关系中,就没有顺序保障,虚拟机可以对他们随意的进行重排序。

3 java与线程

3.1 线程的实现

使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现

1.内核线程实现

内核线程(KTL)就是直接由操作系统内核支持的线程,这种线程由内核完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口-轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的进程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一线程模型。

轻量级进程局限性:
(1)基于内核实现,各种线程操作,如创建,析构及同步,都需要进行系统调用。而系统调用代价相对较高,需要在用户态和内核态来回切换
(2)每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量时有限的。

2.使用用户线程实现

广义讲,一个线程只要不是内核线程,就可以认为是用户线程(UT)。狭义上用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。部分高性能数据库中的多线程是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型。

用户线程优势:不需要系统内核支持
用户线程劣势:没有内核的支持,所有线程操作需要用户程序自己处理。(1).要考虑线程创建、切换、调度 (2).由于系统操作只把处理器资源分配到进程,哪诸如“阻塞处理”,“多处理器系统如何将线程映射到其他处理器上”这些问题解决起来异常困难。

3.使用用户线程加轻量级进程混合实现
用户线程建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核进程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。这种混合模式中,用户线程与轻量级进程的数量比是不定的,即N:M,多对多的线程模型。

4.java线程的实现
jdk12以前,用户线程。
jdk12以后,线程模型替换为基于操作系统原生线程模型来实现。windows和linux使用一对一线程模型,一条java线程就映射到一条轻量级进程中(因为linux和windows系统提供的线程模型就是一对一的)。solaris同时支持一对一和多对多,可通过参数配置。

3.2 java线程调度

线程调度是指系统为线程分配处理器使用权的过程,分为协同式调度和抢占式调度。

协同式:现成的执行时间由线程本身控制,线程把自己的工作处理完了之后,主动通知系统切换到另一个线程上。好处:实现简单,切换操作自己可知,没有线程同步问题。坏处:线程执行时间不可控制,如果一个线程编写有问题,一直不告知系统进行线程抓换,程序会阻塞住。
抢占式:每个线程将由系统分配执行时间,线程的切换不由线程本身来决定(java Thread.yield可以让出执行时间,但是线程本身还是没办法获取执行时间)。好处:线程执行时间系统可控,不会出现一个线程阻塞程序的问题,java使用的就是这种。

虽然java线程调度是系统自动完成的,但是可以建议系统给某些线程多分配一点执行时间,另外的一些少点执行时间,即可通过设置线程优先级来完成。java一共有10个优先级,多个和线程同时处于Ready状态时,优先级越高越容易被系统选择执行。

不过优先级并不完全靠谱,因为基于的系统原生线程实现,所以取决于操作系统。1.操作系统的优先级与java的优先级级别并不一定可以一一对应。2.系统可能会自动改变,如windows既有优先级推进器,会识别特别勤奋的线程,越过优先级为它分配执行时间。

3.3 状态转换

在任意时间点,一个线程有且只能由一种状态。

  1. 新建(NEW):创建后尚未启动的线程
  2. 运行(RUNNABLE):Runable包括了操作系统线程状态的Running和Ready,也就是处于此状态的线程有可能正在执行,也可能在等待CPU为他分配执行时间。
  3. 无限期等待(WAITING):处于该状态的线程不会被分配CPU执行时间,他们要等待被其他线程显式的唤醒,以下方法会陷入无限期等待状态:

没有设置Timeout参数的Object.wait()方法
没有设置Timeout参数的Thread.join()方法
LockSupport.park()方法

  1. 限期等待(TIMED WAITING):处于该状态的线程也不会被分配CPU执行时间,不过无需等待被其他线程显式的唤醒,在一定时间后会由系统自动唤醒,以下方法会陷入限期等待状态:

Thread.sleep()方法 设置Timeout参数的Object.wait()方法
设置Timeout参数的Thread.join()方法
LockSupport.parkNanos()方法
LockSupport.parkUntil()方法

  1. 阻塞(BLOCKED):线程被阻塞了,阻塞状态与等待状态区别是:阻塞状态在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而等待状态则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程进入这种状态。
  2. 结束(TERMINATED):线程已经执行完毕。
上一篇下一篇

猜你喜欢

热点阅读