JVM学习--(八)Java内存模型和线程

JVM学习–(八)Java内存模型和线程

计算机往往需要并发处理多个线程,一个服务端要同时对多个客户端进行服务。

一.硬件的效率与一致性

让计算机并发的执行若干的任务和充分利用计算机CPU的性能是因果关系。由于内存的存取与处理器的速度不是一个量级的,所以不得不在这两者间加入一个高速缓存来提高运算速度。但是也带来了一个新的问题:缓存一致性。在多路处理器系统中,每个处理器都有自己的高速的缓存,但是共享一块主内存,这种系统被称为共享内存多核系统。为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议。
“内存模型”可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象。
为了使处理器内部的运算单元能尽量被充分利用,除了增加高速缓存还可以对输入代码进行乱序执行优化,在保证结果与顺序执行的结果是一致的情况下,并不保证计算的顺序与输入代码的顺序一致,类似的java虚拟机中也有指令重排序优化。

二.Java内存模型

模型需要做到可以并发操作时不会发生歧义,也需要保证使虚拟机有足够的空间去使用硬件的各种特性。JDK5之后java内存模型开始成熟和完善了起来。

主内存与工作内存

java内存模型的主要目的是定义程序中各种变量的访问规则,关注的是把变量值储存到内存和从内存中取出变量值这样的底层细节。这里的变量包括了实例字段、静态字段和构成数组对象的元素。
java内存模型规定了所有的变量都储存在主内存中,每条线程有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对于变量的所有操作都必须在工作内存上进行。
在这里插入图片描述
为了获得更好的运行性能,虚拟机可能会让工作内存优先储存在寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

内存间交互操作

关于主内存和工作内存之间的交互协议,java内存模型定义了以下8种操作来完成。java虚拟机保证了下面8中操作都是原子的、不可再分的:①lock:把主内存的一条变量转为一条线程独占的模式。
②unlock:把一条锁定的变量释放出来,使其可以被其他的线程所使用。
③read:把一个变量从主内存中传输到线程的工作内存中。
④load:把read的变量放入工作内存的副本当中。
⑤use:将工作内存中的变量传递给执行引擎。
⑥assign:把一个从执行引擎接受的值赋给工作内存的变量。
⑦store:把工作内存中的一个变量传入主内存中。
⑧write:把store得到的变量放入主内存的变量中。
read和load以及store和write操作只要求是按照顺序执行,但是不要求连续执行,因此操作中间可以插入其他指令。
执行操作时也必须遵循以下的规则:
①不允许read和load以及store和write操作单独出现。
②不允许一个线程丢弃最近的assign操作,即变量在工作主存中改变了之后必须同步回主内存。
③没有assign操作时,也不允许随意的将工作内存同步回主内存。
④对于一个变量进行use、store前必须进行assign和load,这是因为一个变量必须是在主内存中诞生。
⑤一个变量在同一时刻只允许被执行一个lock,但是lock可以被执行很多次,同时在解锁的时候需要执行相同次数的unlock。
⑥对一个变量执行lock,会清空工作内存中此变量的值,执行引擎使用前需要重新load和assign。
⑦变量没有被lock就不能unlock,同时不能lock被其他线程锁定的变量。
⑧对变量执行unlock之前,必须将变量同步回主内存。
后来java设计团队将上述的8中命令简化为read、write、lock、unlock。但是只是语言上的改变,基础设计并没有改变。

对于volatile型变量的特殊规则

volatile是java虚拟机提供的最轻量级的同步机制。
volatile修饰的变量具有两个特性:1️⃣保证此变量对所有线程的可见性。(需要注意的是java的运算操作并非是原子操作,这导致volatile在并发状态下也并非是安全的,因此在除了以下两个环境下之外的环境必须加锁来保证原子性:1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;2.变量不需要与其他的状态变量共同参与不变约束)2️⃣禁止指令重排序优化(机器级的优化操作)。(volatile的变量会形成“内存屏障”的形式,使得指令重排序无法越过内存屏障)
在某些情况下,volatile的同步机制确实要优于锁,但是由于虚拟机对于锁会有很多优化,并不能确定volatile的性能确实好于synchronized,在使用volatile与锁中的唯一判断依据是volatile的语义是否满足场景的需求。
java内存模型中volatile变量定义的特殊规则,假设T是一个线程,V和W分别表示两个volatile型变量,则在进行read、load、use、assign、store以及write操作的时候需要满足:
1.只要T对于V的前一个动作是load,之后才可以执行use,反之也亦然。可以认为use的动作必须和read和load搭配出现。(保证了看到的永远都是最新的值)
2.同样的assign的后一个动作肯定是store,可以认为assign和store以及write是关联出现的。(保证了每次修改都会同步回主内存)
3.如果线程对于变量V实施的use和assign动作先于对于变量W执行的动作,那么read、write动作V也会优先于W。(保证变量不会被指令重排序优化)

针对long和double型变量的特殊规则

对于64位的数据结构在模型中定义了一个宽松的规则:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。即long和double的非原子协定。但是需要注意的是在主流的虚拟机中并不需要特地的将long和double设置成volatile。

原子性、可见性与有序性

1.原子性
大致可以认为对于基本类型的访问、读写基本都是具有原子性的。如果需要在更大的范围中保证原子性,java内存模型提供了lock和unlock,尽管这两个代码并不直接开放给用户,但是用户可以直接用synchronize操作也具有原子性。
2.可见性
可见性指的就是一个线程修改共享变量时,其他线程可以立刻得知这个修改。volatile可以保证每次修改新值可以立刻同步回主内存,但是普通变量并不能保证这一点。除了volatile可以实现以外,synchronize和final也可以实现。同步块的可见性由“对一个变量执行unlock操作之前,必须将此变量同步回主内存中”来实现的。而final关键词的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去,那么在其他的线程中就可以看到final字段的值。
3.有序性
java中天然的有序性可以表达为:如果在本线程观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。java中通过volatile和synchronize来保证了线程之间的有序性关系。

先行发生原则

java中有个天然的“先行发生”的原则。先行发生是java内存模型中定义的两项操作之间的偏序关系,比如操作A先于操作B,其实也就是说在发生操作B之前,操作A产生的影响已经可以被操作B观察到。以下是一些java内存模型中的“天然的”先行发生关系:
1.程序次序规则。按照控制流顺序,书写在前面的操作先于书写在后面的操作。
2.管程锁定规则。线程unlock先行于另一个线程的lock操作。
3.volatile变量规则。写操作必须先于另一个变量的读操作。
4.线程启动规则。start()方法先行于此线程的每个动作。
5.线程终止规则。所有操作必须先行于终止检测。
6.线程中断规则。对线程的interrupt方法必须先行于被中断线程代码检测到中断事件的发生。
7.对象终止规则。一个对象的初始化完成先行于它的finalize方法的开始。
8.传递性。如果A先于B,B先于C,则A先于C。
一个操作时间上的先发生并不代表着这个操作是先行发生的。同样反过来先行发生也不代表着时间上的先发生。

三.Java与线程

线程的实现

线程是比进程更加轻量级的调度执行单元。目前线程是java里面进行处理器资源调度的最基本单位。Thread就代表着线程,但是Thread类是用Native实现的。实现线程主要有三种方法:1.使用内核线程实现(1:1)。2.使用用户线程实现(1:N实现)。3.使用用户线程加轻量级进程混合实现(N:N实现)。
1.内核线程实现
内核线程就是直接通过操作系统内核支持的线程,由内核来实现线程的切换,内核通过操纵调度器对线程进行调度,并且会把线程任务映射到各个处理器上。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口–轻量级进程(就是我们常说的通常意义上的线程)。每个轻量级的线程都是一个独立的调度单元,即使其中一个进程在系统的调用中被阻塞了,也不会影响整个进程继续工作。缺点是每次调用都需要进行系统调用,系统调用的代价相对较高,需要在内核态和用户态之间相互切换,其次是每个进程都需要一个内核线程支持,因此数量有限。
2.用户线程实现
用户线程被称为1:N实现。广义上来说一个线程不是内核线程那么都可以认为是用户线程。狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核无法感知用户线程的存在以及如何实现的。优点是不需要切换内核态,速度快。劣势是所有的操作都需要用户程序完成,例如解决阻塞异常困难,所以java以前使用过,但后来放弃了。
3.混合实现
一种将内核线程和用户线程一起使用的实现方式,被称为N:M实现。操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,因此可以使用内核提供的线程调度功能及处理器映射。大大降低了整个进程被完全阻塞的风险。
4.Java线程的实现
jdk1.3起,java都替换成1:1的模型。由于java虚拟机的运行环境不同,很大程度上取决于操作系统支持怎样的线程模型,线程模型只对线程的并发规模和操作成本产生影响,对于java编写的代码和运行过程而言,这些差异都是透明的。

Java线程调度

线程调度指的是系统为线程分配处理器使用权的过程,调度的主要方式有两种,分别是协同式线程调度和抢占式线程调度。
协同式线程指的是线程的执行时间由线程本身来控制,一个线程完成后主动通知另一个线程工作。坏处就是一个线程出问题,那么系统就会崩溃。
抢占式调度指的是每个线程将由系统来分配执行时间。在Java中Thread:yield()方法可以主动让出执行时间,但是如果想要主动获得时间却没有什么方法。但是我们可以“建议”操作系统给某个线程多一些的执行时间–通过设置线程优先级来实现。java语言中设置了10个级别的线程优先级,当两个线程处在ready时,优先级越高的线程会被越先执行。
java的优先级并不是跟操作系统的优先级是一一对应的,例如windows中只有7种优先级,因此java优先级中有几个优先级它们之间的效果是完全相同的。
我们也不可以过度依赖线程优先级,因为优先级可能被系统自行改变,例如windows系统中存在一个叫做“优先级推进器”的功能,指的是系统检测到某个进程运行次数多时,会自动给它提升优先级。

状态转换

java语言中定义了6种线程状态,一个线程在同一时间只有其中的一种状态,这六种状态是:
①新建②运行:可能是正在被执行,也可能等待操作系统给它分配执行时间。③无限期等待:没有其他线程对这个线程执行唤醒操作,那么这个线程会一直等待。④限期等待:无需其他线程换成,过一定的时间会自动唤醒。⑤阻塞:等待获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生。⑥结束

四.Java和协程

java语言早期隐藏了各种操作系统线程差异性的统一线程接口。这种便捷的并发编程方式和同步的机制依然在有效的运作着,但是在某些环境中已经显示出了疲态。

内核线程的局限

例如现在的B/S架构模式要求每一个服务必须在极短的时间内完成计算,但是1:1内核线程模型的天然劣势就是切换速度慢,耗时大,以前的服务方式多是一个请求会允许花费时间很长的单体应用,而现在更多的是切换及其频繁的线程。

线程的复苏

内核线程的调度成本主要来自于用户态和核心态之间的状态切换,这两种状态的开销主要来自于响应中断、保护和恢复执行现场的成本。同样就算切换到用户线程,这一部分的开销同样不能省略。但是程序员可以将这个过程玩出各种花样。
古老时代(DOS):大致采用在内存中划分出一片额外区域来模拟调用栈,只要线程压栈和退栈不破坏这个,这样多段代码执行的时候会互相缠绕在一起,因此被称为“栈缠绕”。
操作系统提供多线程时代:最初的用户线程被设计成协同式调度–“协程”,在今天也称为“有栈协程”,这种方式最大的优点是轻量。

Java解决方案

对于有栈协程,有一种特例叫做纤程,但是Loom项目并没有上线,还在测试中。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值