深入理解JVM(三)----Java线程内存模型和线程

引子

在物理机中,并发执行多个任务,充分利用计算机处理器的性能,看起来顺理成章,但实际上并不像看起来那么简单。其中一个重要的原因就是绝大多数的任务不能只靠处理器“计算”就能完成,处理器至少要与内存交互,比如读取、存储数据,这个I/O操作是很难消除的。而储存设备和处理器的运算速度差了好几个量级,所以引入了高速缓存(cache)来作为缓冲。即将运算要使用的数据复制到缓存,让运算快速进行,运算结束后从缓存同步回主存,这样处理器就无须等待缓慢的内存读写了。

虽然解决了处理器和内存的速度矛盾,但是引入了新的问题:缓存一致性。为了解决一致性问题,需要处理器访问缓存时遵循一些协议来进行读写操作,比如MSI,MESI等。不同的物理机拥有的内存模型可以不同。
硬件的内存模型:
在这里插入图片描述

Java内存模型

因为java是一次编写到处运行的,所以java内存模型就是来屏蔽不同硬件和操作系统内存访问的差异。
在这里插入图片描述
可以看出java内存模型和物理机内存模型是很相似的,事实上java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存交互,也没有限制即使编译器可以调整代码执行顺序这类优化措施。
java内存模型的重要目标是定义程序中各个变量的访问规则,即变量存储这样的底层细节。这里的工作内存可以与高速缓存做类比,保存了线程使用变量的内存副本拷贝。

内存交互

前面说到工作内存与主内存会进行数据读写交互,这个读写交互具体实现细节则是由Java内存模型来控制的,Java内存模型为主内存和工作内存间的变量拷贝及同步写回定义了具体的实现协议,该协议主要由8种操作来完成,不同虚拟机在实现时必须保证每一个基本数据类型的操作都是原子性不可再分的(long,double类型的变量在某些平台可以例外,虽然在JVM规范中没有强制要求long,double类型具有原子性,但是规范建议各JVM实现成具有原子性的,实际上市面上的JVM也基本都实现了原子性),具体8种操作如下:

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

线程、工作内存、主内存对应这8种操作的交互关系图如下:
在这里插入图片描述
按照上面的8种内存交互操作,如果要把一个变量从主内存复制到工作内存,就需要顺序的执行read和load操作,而如果要把一个变量从工作内存同步回主内存,则需要顺序执行store和write操作,这里说的是顺序执行,而不是连续执行,这也就意味着两个操作之间可以插入其他操作,例如对主内存中的变量1和变量2访问时,一种可能的顺序是read 1, read 2, load 2, load 1。

除此之外,Java内存模型对这8中操作还存在着其他的约束:

  • 只允许read和load、store和write这两对操作成对出现。
  • 不允许线程丢弃它的最近的assign操作,即变量在工作内存中改变之后,必须同步回写到主内存。
  • 不允许线程把没有经过assign操作的变量,同步回写到主内存。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中使用未经初始化的变量,即对一个变量进行use、store操作之前,必须先执行过load、assign操作。
  • 一个变量在同一时刻只能被一条线程执行lock操作,一旦lock成功,可以被同一线程重复lock多次,多次执行lock之后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 对一个变量执行lock操作,将会清空工作内存中该变量的值,所以在执行引擎使用这个变量前,需要重新执行load或assign操作对其进行初始化。
  • 对一个变量执行unlock操作之前,必须先把该变量同步回主内存(执行store、write操作)。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许unlock一个被其他线程lock的变量。

原子性、可见性、有序性

原子性:一个操作不可被中断,是不可分割的。
可见性:一个线程修改了共享变量的值,其他线程可以立即感知到这个修改
有序性:JVM在一些情况下会进行指令重排序,多线程情况下可能会发生问题,有序即按序执行。

对于Volatile的特殊规则

volatile可以说是jvm提供的最轻量级的同步机制,它可以提供可见性和防止指令重排。
可见性指的是:当一个线程修改这个变量的值以后,其他线程可以立刻感知到。
所以volatile只符合以下两种场景的运算,否则仍然要通过加锁保证原子性。
1、运算结果并不依赖变量的当前值,或者能保证只有单一线程修改这个值
2、变量不需要和其他状态变量共同参与不变性约束

防止指令重排,volatile在本地代码中插入许多内存屏障来保证处理器不发生乱序执行。

对于long和double的特殊规则

java内存模型规定允许虚拟机将没有volatile修饰64位数据的读写分为两次32位操作来进行,这就是long和double的非原子协定。这种情况下多线程共享一个没有volatile修饰的long或double类型变量,同时进行读写修改,那么可能读到”半个百变量“。
但是虽然java内存模型允许long,double的读写可以不是原子的,但是目前商用的虚拟机几乎都把64位数据的读写操作作为原子操作来看待了。所以这种情况几乎不会发生。

happens - before原则

定义:
1、如果一个操作Happens-before另一操作,那么第一个操作结果对第二个操作可见.而且第一个执行操作顺序在第二个之前执行.
2、两个操作之间存在Happens-before关系,并不意味着一定要按照Happens-before制定的顺序执行操作,如果重排序后与Happens-before顺序执行的操作结果一致,这样的重排序并不非法

1.程序次序规则:一个线程内,按照代码书写顺序,书写在前面的操作先发生于书写在后面的操作.
2.锁定规则:一个UNLOCK操作先行发生于后面对同一个锁的UNlock操作.
3.volatile变量规则:对volatile 修饰的变量的写操作 先行发生于后面对这个变量的读操作;
4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

Java与线程

线程的实现

实现线程的方式主要有三种,内核进程实现,用户进程实现,用户进程+轻量级进程混合实现。

内核线程实现

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这种操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口:轻量级进程(Light Weight Process ,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系成为一对一的线程模型。

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:

(1)首先,由于是基于内核线程实现的,所以各种线程操作,如创建,析构和同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换。

(2)其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。

用户线程实现

从广义上讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。

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

使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如果将线程映射到其他处理器上”这类问题解决起来将会异常的困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,除了以前在不支持多线程的操作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java,Ruby等语言都曾经使用过用户线程,最终又都放弃使用它。

用户线程+轻量级进程混合实现

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

Java线程的实现

在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了java虚拟机的线程是怎样映射的,这点不同的平台上没有办法达成一致,虚拟机规范中也并未限定java线程需要使用哪种线程模型来实现。线程模式只对线程的并发规模和操作成本产生影响,对java程序的编码和运行过程来说,这些差异都是透明的。

对于Sun JDK来说,他的Windows版和Linux版都是使用一对一的线程模型来实现的,一条java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是就是一对一的。

所以java线程是映射到系统原生系统来实现的。

Java线程调度

线程的调度方式主要有两种,协同式调度和抢占式调度。

协同式调度中,线程执行时间由线程本身控制,线程将自己工作执行完以后切换到另一个线程上。优点是实现简单,没有线程同步问题;缺点就是线程执行时间不可控制,如果一个线程出现问题不进行切换,那么整个程序就会阻塞。

java采用抢占式调度,每个线程由系统分配时间,切换不由线程本身决定。(java中的Thread.yield()方式可以让出时间,但从获取时间的话,线程本身是没办法的)。这样线程的执行时间是可控的,不会出现一个线程出问题,整个程序阻塞的情况。
java中可以通过设置优先级的方式,”建议“系统给该线程多分配一些时间。java有10个优先级,优先级越高越容易被系统执行。但是优先级是不靠谱的,原因是java线程是映射到原声系统来实现的,所以线程调度取决于操作系统,但是这些操作系统的线程优先级不能和java线程优先级一一对应。在不同平台上会有不同的优先级。所以不能完全依赖优先级来判断哪一个线程会先执行。

Java线程状态转化

java语言存在5种线程状态

  • 新建(New):创建后尚未启动
  • 运行(Runable):包括了操作系统进程的Running和Ready,也就是说处于此状态的线程可能正在执行,也可能等待cpu分配时间。
  • 无限期等待(Waiting):这个状态不会被分配cpu时间,等待被显式唤醒。有以下几种方法可以达到这个状态。
    没有设置Timeout参数的Object.wait();
    没有设置Timeout参数的Thread.join()
    LockSupport.park(Thread thread)//让当前线程阻塞,unpart()唤醒
  • 限期等待(Timed Waiting):在一段时间后由操作系统自动唤醒。以下方法可以让线程进入限期等待。
    Thread.sleep()
    设置了Timeout参数的Object.wait();
    设置了Timeout参数的Thread.join();
    LockSupport.parkNanos()的方法
    LockSupport.parkUntil()的方法
  • 阻塞(Blocked):线程被阻塞,等待获取一个排他锁。在程序将进入同步区域时,线程进入这种状态。
  • 结束(Terminated):已终止线程的状态,结束运行。
    下附状态转换图一张:
    在这里插入图片描述

有关线程和进程的区别我觉得放在这里不太恰当,所以重新写了一篇文章
请看这里:线程和进程的区别

参考文章:
java内存模型
线程实现方式
线程状态转化
LockSupport源码解析
happens-before原则

:本文的很多内容都是《深入理解java虚拟机》(周志明 著)一书中的,这是一本非常经典的jvm读物,我目前的只是浅读,所以很多地方自己思考不足以描述问题,便用原书中的内容叙述了。有兴趣的小伙伴可以买这本书读一读,希望大家有所收获。

猜你喜欢

转载自blog.csdn.net/machine_Heaven/article/details/104550595