Java之虚拟机内存模型

本文阅读 54 分钟
首页 代码,Java 正文

多任务处理在现在计算机操作系统中几乎是一项必备的功能。这不单是因为计算机计算能力强大,更重要的原因是计算机的计算速度远高于它的的存储和通信子系统速度。所以我们就通过让计算机同时处理多个任务来讲处理器的运算能力得到充分运用。

除了充分运用计算机的处理能力外,一个服务端同时对多个客户端提供服务则是另一个更具体的并发应用的场景。衡量一个服务性能的高低好坏,每秒事务处理数(TPS)是一个重要指标,它代表着一秒内服务端平均能响应的请求总数,而TPS值和程序的并发能力又有非常密切的关系。对于计算量相同的任务,程勋线程的并发协调性越有条不紊,效率自然就会越高;反之,线程之间频繁的阻塞甚至死锁,将会大大降低程序的并发能力。

在了解Java并发问题之前我们先了解一下,物理计算机的并发问题,物理机遇到的并发问题和虚拟机中的情况有很多相似的地方,物理机对并发的处理方案对于虚拟机有很大的参考意义。

前面说过,为了充分的利用处理器的性能,我们让计算机并发执行多个运算任务,这种因果关系看起来顺理成章。但是他们的其实并没有这么简单,因为绝大多数的运算都不可能只靠处理器,处理器至少要和内存进行交互,如读取运算数据,存储运算结果等,这个I/O操作是很难消除的(无法仅靠寄存器来完成所有的运算任务)。由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现在计算机都会加入一层或多层读写速度尽可能接近处理器运算速度的“高速缓存”来作为处理器和内存之间的缓冲:将运算需要的数据复制到缓存中,让运算能快速的进行,当运算结束后从缓存同步回内存之中,这样处理器就不用等待缓慢的内存读写了。

高速缓存很好的解决了处理器和内存的速度矛盾,但是这也为计算机系统带来了更高的复杂度,因为它引起了一个新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享一个主内存。当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生了缓存不一致的问题,那同步回到主内存时以谁的缓存数据为准呢?为了解决缓存一致性问题,需要各个处理器在访问缓存时都遵守一些协议,在读写时根据这些协议来进行操作。而在本文中要讨论的内存模型可以理解为在特定的操作协议下对特定的内存和高速缓存进行的读写访问的过程抽象。不同架构的物理机可以拥有不一样的内存模型,Java虚拟机也有自己的内存模型

除了增加高速缓存,为了使处理器内部的运算单元能尽量的被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果和顺序执行的结果是一致的。因此如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理的乱序执行优化类似,Java虚拟机中指令重排也是类似的优化。

java虚拟机规范中试图定义一种java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都可能达到一致的内存访问效果。在此之前,C语言/C++直接使用物理硬件和操作系统的内存模型,所以就会出现在一套平台上并发访问正常,但是在另一套平台上却有问题,平台兼容性相对较差。

Java内存模型的目的及实现方式

JMM的主要目标是定义程序中的各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。此处的变量与java变成中变量有所区别,它包括了实例字段,静态字段和构成数据对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。为了更好地性能,java内存模型并没有限制执行引擎使用处理器的特定寄存器和缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化。

JMM规定所有的变量都存贮在主内存(虚拟机内存的一部分,可以物理硬件的主内存类比)中,每条线程还有自己的工作内存(可与处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的主内存中的变量的副本(注意:一个对像如果10M,是不是会把这个10M的内存复制一份到工作内存呢?显然是不会的,但是这个对象的引用,对象中的某个在线程中访问到的字段是有可能会复制到工作能存中的,但是不会把整个对象复制一份),线程对变量的所有操作(读取,赋值等)都需要在工作内存中进行,而不能直接读写主内存中的变量(即便voliate变量也不例外)。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

另外要注意,这里所说的主内存、工作内存和Java内存区域中的java堆,栈,方法区等并不是一个层次的内存划分,这两者没有任何关系,如果非要勉强对应的话,主内存主要对应于java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就是直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存

主内存和工作内存之间的交互

Java内存模型定义了8种操作来完成关于主内存和工作内存之间具体的交互,这些操作都是原子的,不可分割(long double类型除外)。这8种操作如下所示:

  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和load之间,store和write之间是可以插入其它指令的,如对内存中的变量a,b进行访问时,一种可能出现的顺序是read a, read b, load b, load a。

除此之外,java内存模型还规定了在执行上述8中基本操作时必须满足以下规则

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

通过这8中内存访问操作及其相关的规定,再加上volatile的一些特殊规定,就完全可以确定哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但又十分的繁琐,实践起来很是麻烦,所以java虚拟机提供了一个等效判断原则–先行发现原则。

volatile的含义和用法

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机。Java内存模型为volatile专门定义了一些特殊的规则。 volatile的两种语义 第一:保证了此变量对所有的线程是可见的,这里的可见性是指当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通的变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。

第二:禁止指令的重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖复制结果的地方都能获取到正确的结果,而不能保证变量复制操作的顺序与程序代码中的执行顺序一致。

由此我们可以看到volatile变量在各个线程的工作内存中不存在一致性的问题(从物理存储的角度看,在各个线程的工作内存中也可以存在不一致的情况,但是由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题,至于如何解决不一致的情况请看下面的“如何解决缓存一致性问题”),但是java里面的运算并非原子的操作,导致volatile变量的运算在并发情况下一样是不安全的。

要使用volatile关键字的应用场景,必须满足以下规则,否则仍然会出现并发问题

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

volatile变量从主内存到工作内存,又到主内存的过程:线程A在操作一个被volatile变量修饰的变量x时,会将其值复制到线程A的工作内存副本中,然后修改它的值,在修改完之后会将值强制写回主内存,这时如果查看汇编指令会发现比没有volatile指令修饰的变量多了一个lock#语句,正是因为这个lock语句执行时保证了volatile变量的内存可见性。早期的处理器lock锁的是总线,会阻塞其他cpu的读写操作,导致性能比较低;所以在最近的处理器中,如果访问的内存有高速缓存,那么就是用“高速缓存锁”,确认对高速缓存中的数据进行原子操作,并不会对总线和总线上的相关内存加锁,但是如果访问的内存在高速缓存中不存在,那么就会锁总线。而在某个cpu要把修改的缓存行数据前需要向总线申请独占式访问权,同时通知其他cpu他们相同的缓存行置为无效,只有申请到了独占式访问权,才可以修改缓存行中的数据,在修改完缓存行数据后,其他cpu要想访问想要读取这个缓存行的数据,这个缓存行的数据必须为“共享”状态,而已被修改的数据会立马回写到内存中,这时由于其他的cpu一直在嗅探总线,所以会立马感知到这个数据变化。这里需要说明一下,CPU缓存不仅仅在做内存传输的时候才与总线打交道,每个cpu也会不停的嗅探总线上的数据变化以及其他缓存在干什么,一直在不停的嗅探总线的其他的cpu就会立马知道有cpu对自己缓存中的变量的值进行了修改,前提是如果有这个变量的话。当这些cpu需要对这个变量进行操作时就需要重新去内存中读取。

volatile使用场景

场景一 使用 volatile 变量作为状态标志。在该场景中,应用程序的某个状态由一个线程设置,其他线程会读取该状态并以该状态作为其计算的依据( 或者仅仅读取并输出这个状态值。此时使用 volatile变量作为同步机制的好处是一个线程能够 “通知” 另外一个线程某种事件( 例如,网络连接断连之后重新连上)的发生,而这些线程又无须因此而使用锁,从而避免了锁的开销以及相关问题。

场景二 使用 volatile 保障可见性。在该场景中,多个线程共享一个可变状态变量 ,其中一个线程更新了该变量之后。其他线程在元须加锁的情况下也能够看到该更新。

场景三使用 volatile变量替代锁。volatile 关键字并非锁的替代品,但是在一定的条件下它比锁更合适 ( 性能开销小 、代码简单 )。多个线程共享一组可变状态变量的时候,通常我们需要使用锁来保障对这些变量的更新操作的原子性,以避免产生数据不一致问题。利用 volatile 变量写操作具有的原子性 ,我们可以把这一组可变状态变量封装成一个对象,那么对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象引用赋值给相应的引用型变量来实现。在这个过程中, volatile 保障了原子性和可见性。从而避免了锁的使用。

切记volatile并不能保证排他性操作,当一个变量参与计算时仍然需要使用锁,来实现更广范围的原子性操作,所以votile一般适用于直接赋值,而不适用于计算例如:i++。

解决缓存一致性问题,有两种方式:

  • 过在总线加LOCK#锁的方式

在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

  • 通过缓存一致性协议

上面说了,LOCK#会锁总线,实际上这不现实,因为锁总线效率太低了。因此最好能做到:使用多组缓存,但是它们的行为看起来只有一组缓存一样。缓存一致性协议就是为了做到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于"嗅探(snooping)"协议,基本思想是:所有内存的传输都发生在一条共享的总线上,而所有的处理器内核都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。

原子性

原子性即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定,我们只要知道这个事情就可以了,不用太过在意这些几乎不会发生的场景)。

如果应用场景需要一个更大范围的原子性操作,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开发给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块----synchronize关键字,因此在synchronize块之间的操作也具备原子性

一个很经典的例子就是银行账户转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

同样地反映到并发编程中会出现什么结果呢?

举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?·

i = 9;

假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即得知这个修改

Java内存模型是**通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量和volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除volatile之外,Java还有两个关键字能实现可见性:synchronizefinal。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。而final关键字的可见性是:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值

举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

有序性

有序性即程序执行的顺序按照代码的先后顺序执行

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个现场观察另一个线程,所有的操作都是无序的。前半句的意思是“线程内部表现为串行的语义”,后半句指“指令重排序”现象和“工作内存和主内存同步延迟”现象。

举个简单的例子,看下面这段代码:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;      //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

这段代码有4个语句,那么可能的一个执行顺序是: img

那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3? 答案是不可能。因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

先行发生原则

如果Java内存模型中所有的有序性都仅靠volatile和synchronize来完成,那么有很多操作都将变的非常啰嗦,但是我们编写java代码时缺没有察觉到这一点,这是因为Java语言中有一个“先行发生原则”。此原则是判断数据是否存在竞争,线程是否安全的非常有用的手段。 什么是“先行发生原则”呢?先行发生原则是Java内存模型中定义的两项操作之间的偏序关系。比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到。

需要注意的是,一个操作“时间上的先发生”不代表这个操作会是“先行发生”;一个操作“先行发生”,同样不能得出这个操作是“时间上的先发生”。

并发并不一定依赖多线程(PHP中很常见的多进程并发),但是在Java里面谈论并发,基本上都与线程脱不了关系。

线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。目前线程是Java里面进行处理器资源调度的最基本的单元,不过如果日后Loom项目能成功为Java引入纤程的话,这点可能就改变了。

主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。

不同操作系统实现线程的方式大致有三种:

  • 内核线程实现 使用内核线程实现的方式也被称为1:1实现。内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核。 程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口----轻量级进程,轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。

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

  • 用户线程实现 使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会收到限制,并不具备通常意义上的用户线程的优点。

而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。

Java、Ruby等语言都曾经使用过用户线程,最终放弃了。但是近年来许多新的、以高并发为卖点的 编程语言又普遍支持了用户线程,例如:GoLang、Erlang。

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

Java 线程的实现

Java线程如何实现并不受Java虚拟机规范的约束,而与具体虚拟机有关。Java线程在早期的Classic虚拟机上(JDK1.2之前)上,是基于一种被称为“绿色线程”的用户线程实现的,但从JDK1.3器,主流虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现。

以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度的建议),全权交由底层的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统决定和完成的。

操作系统支持怎样的线程模型,在很大程度上会影响上面的Java虚拟机的线程是怎样映射的,这一点在不同的平台上很难达成一致,因此《java虚拟机规范》中才不去限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是完全透明的。

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要有两种方式:协同式线程调度抢占式线程调度协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。优点是实现简单,而且由于线程要自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。Lua语言中的“协同例程”就是这类实现。缺点也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

抢占式线程调度:每个线程将有系统来分配执行时间,线程的切换不由线程本身来决定。如果线程想要主动获取执行时间,线程本身是没有什么办法的。在这种线程调度方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。Java使用的线程调度方式就是抢占式调度

前面说过虚拟机可以通过设置线程的优先级给操作系统提供调度建议,但是Java中一共设置了10个优先级,而不同的操作系统的线程优先级是不同的,这就导致Java线程优先级和操作系统线程优先级无法一一对应,如果一个操作系统的线程优先级大于Java线程优先级数,这还好说,中间留出一点空位就行了,但对于比Java线程优先级数少的系统,就不得不出现几个Java线程优先级对应一个系统优先级了。例如,windows系统就只有七个优先级,而windows系统的虚拟机只使用了其中六种线程优先级,因此在windows下设置线程优先级为1和2、3和4、6和7、8和9的效果是完全相同的。

我们需要知道,线程优先级并不是一项稳定的调节手段,这不仅仅提现在某些操作系统上不同的优先级实际会变得相同这一点上,还有其他情况让我们不能过于依赖线程优先级:优先级可能会被系统自行改变。

Java线程状态

Java语言定义了6种线程状态:

  • 新建:创建后尚未启动的线程处于这种状态。
  • 运行:包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
  • 无限期等待:处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让先测会给你陷入无限期的等待状态:
    - 没有设置Timeout参数的Object.wait()方法; - 没有设置Timeout参数的Thread.join()方法; - LockSupport.park()方法。
  • 限期等待处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。
    - Thread.sleep()方法; - 设置了Timeout参数的Object.wait()方法; - 设置了Timeout参数的Thread.join()方法; - LockSupport.parkNanos()方法; - LockSupport.parkUntil()方法;
  • 阻塞:线程被阻塞了,“线程阻塞”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  • 结束:已终止线程的线程状态,线程已经结束执行。

随着现在web应用程序越来越复杂、请求量越来越高,内核线程的局限性就越来越明显了。因为如今Java虚拟机线程实现的仍是1:1的内核线程模型,而这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。在以前的单体应用中,处理一个请求可以允许花费很长时间,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成极大的浪费。

传统的Java Web服务器的线程池的容量通常在几十个到两百个之间,当程序员把以百万计的请求往线程池灌时,系统即便能处理得过来,但其中的切换损耗也是相当可观的。现实需求在迫使Java去研究新的解决方案,此时很多人又想起来了Java初期使用的“绿色线程”。

先考虑个问题,为什么内核线程调度切换成本就要更高呢? 答案是成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自响应中断、保护和恢复执行现场的成本。这种保护和恢复现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。

如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改为采用用户线程,这部分开销是否能省掉呢?答案是“不能”。但是,一旦吧保护、恢复现场及调度的工作从操作系统交到程序员手上,那就可以有很多手段来缩减这些开销。

协程其实也是用户线程,只是由于最初多数的用户线程被设计成协同式调度的,所以给它起了个别名----“协程”。又由于这时候的协程会完整地做调用栈的保护、恢复工作,所以也被称为“有栈协程”,这也是为了和后来的“无栈协程”做区分。无栈协程本质上是一种有限状态机,状态保存在闭包里,自然比有栈协程恢复调用栈要轻量的多,但功能也相对更有限。

协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。举例来说,Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构还会额外消耗16kb内存。与之相对的,一个协程的栈通常在几百个字节到几kb之间,所以Java虚拟机里线程池容量达到两百就已经不算小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计。

协程当然也有它的局限性,需要在应用层面实现的内容(调用栈、调度器这些)特别多。

对于有栈协程,有一种特例实现名为纤程。这里不多赘述。

本文为互联网自动采集或经作者授权后发布,本文观点不代表立场,若侵权下架请联系我们删帖处理!文章出自:https://blog.csdn.net/qq_38571892/article/details/123136129
-- 展开阅读全文 --
Web安全—逻辑越权漏洞(BAC)
« 上一篇 03-13
Redis底层数据结构--简单动态字符串
下一篇 » 04-10

发表评论

成为第一个评论的人

热门文章

标签TAG

最近回复