Java虚拟机—内存模型与线程

Java虚拟机—内存模型与线程

Lyon

Lyon

Keep balance,Be a better man!

​关注他

3 人赞了该文章

前言:

本文主要介绍Java的内存模型和Java线程。

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存以及从内存中取出的底层细节。主要涉及JVM中线程、主内存、工作内存的关系及之间的交互。

而Java线程主要介绍Java中线程的底层实现、线程调度和切换等过程。

主要内容包括:

1.Java内存模型

  • 主内存和工作内存
  • 内存间的交互
  • volatile型变量的规则
  • long和double变量的特殊规则
  • 原子性、可见性和有序性

2.Java线程

  • 线程的实现
  • Java线程调度
  • Java线程状态转换

0.概述

多任务处理在现代计算机操作系统中几乎已经是一项必备的功能了。在许多情况下让计算机同时去做几件事,不仅因为计算机的运算能力强大,还有一个重要的原因——计算机CPU的运算速度和它的存储以及通信子系统间的速度差距过大。大量的时间花费在了磁盘IO、网络通信和数据库访问上。

由于计算机的存储设备与处理器的运算速度间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的的高速缓存(Cache)来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓冲中,让运算能快速进行,在运算结束后再从缓存同步回内存之中,这样CPU就无须等待内存缓慢的读写速度了。

基于告诉缓存,处理器和内存间速度相差过高的矛盾被解决了,但是带来了新的问题:

缓存一致性(Cache Coherence)。在多处理器系统中,每个CPU核心都有自己的高速缓存,而他们又共享主内存,所以,当多个处理器之间的运算任务都设计到同一块主内存区域时,将可能导致各自的缓存数据不一致,如果出现这种情况,同步回主内存的数据以谁的缓存数据为准呢?

为了解决缓存一致性的问题,各个处理器访问缓存时都需要遵循一些协议,在读写时都需要根据协议进行操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly和Dragon Protocol等

Java虚拟机—内存模型与线程_第1张图片

除了增加高速缓存以外,为了使处理器内部的运算单元能尽量被利用,处理器可能会对输入代码进行乱序执行优化(Out-Of-Order Excution),处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的(但是不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致),这就称为处理器的乱序执行优化。与之类似,Java虚拟机的即时编译器JIT中也有类似的指令重排序(Instructijon Reorder)优化。


1.Java内存模型

由于计算机上的内存模型涉及到物理的主内存、高速缓存和寄存器等。这些不同的计算机不同的操场系统可能会存在差异,Java虚拟机规范中试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各个平台下都能达到一致的访问效果,Java的内存模型在JDK1.5时已经逐渐成熟和完善起来了。

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存以及从内存中取出的底层细节。

1.为了获得较高的执行性能,Java内存模型并没有限制JVM执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制JIT即时编译器进行代码执行顺序调整这类的优化措施。
2.Java内存模型中的变量和Java语言中的变量有所区别包括实例字段、静态字段和构成数组对象的元素,而不包括线程私有的局部变量和方法参数。

1.1主内存和工作内存

JVM中线程、主内存、工作内存关系如下:

Java虚拟机—内存模型与线程_第2张图片

Java内存模型规定了所有变量都存储在主内存内,此处主内存隶属于Java虚拟机内存的一部分,而虚拟机内存是操作系统分配的。每条Java线程还有自己的工作内存(类比上面的高速缓存),工作内存中保存了被该线程使用到的变量的主内存的副本,线程对变量的所有操作都在工作内存中进行,Java线程之间的变量值传递都通过主内存来完成。

虚拟机的内存主要是PC的物理内存,但是为了优化程序运行效率虚拟机也可能会让工作内存优先存储于寄存器和高速缓存中。

1.2内存间的交互

关于主内存和工作内存间的交互协议,即一个变量如何从工作内存拷贝到工作内存、又是如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了8种操作,这8种操作实现时必须保证每一种操作都是原子的、不可再分的。(long、double的特殊性除外),这8种操作如下,其中前4条是作用于主内存,后4条作用于工作内存:

  • lock 锁定,将一个变量标识为线程独占状态
  • unlock 解锁,将锁定状态的变量解除锁定,释放后的变量才可以被其他变量锁定
  • read 读取,将变量从主内存传输到线程的工作内存中,待之后的load加载
  • write 写入,把store操作从工作内存中得到的变量值写入主内存的变量中
  • load 加载,将read后从主内存得到的变量值加载到工作内存的变量副本中
  • use 使用,把工作内存中的一个变量值传递给字节码执行引擎,等待字节码指令使用
  • assign 赋值,把一个从执行引擎接收到的值赋值给工作内存的变量
  • store 存储,把工作内存中一个变量的值传送到主内存中,以便随后的write使用

1.3volatile型变量的规则

volatile关键字的含义

这个关键字定义的变量具有以下两种特性:

  • 保证此变量对所有变量的可见性
  • 禁止指令重排序优化

1.保证此变量对所有线程的可见性

这里的「可见性」是指,一旦这个变量的值被修改,则其修改后的值对于其他线程来说是立刻感知到的,不会存在缓存和延迟的问题。volatile修饰后的变量会使得当前CPU的Cache立即写入内存,且会使其他的CPU(线程)上同样的变量Cache立即无效化。于是所有线程上的变量都从当前主内存修正过后的变量值来读取,保证了此变量的修改对所有线程的可见性。

但是volatile只能保证可见性,并不能保证原子性,即很多场景下,我们仍然需要通过加锁(sychronized或java.util.concurent包下的原子类)来保证原子性。

2.禁止指令重排序优化

volatile修饰后的变量,赋值后多执行了一个lock addl指令的操作,作用相当于一个内存屏障,使得指令重排序时不能把后面的指令重排序到内存屏障之前,从而阻止了指令重排序。

1.4long和double变量的特殊规则

对于64位的数据类型long和double,虚拟机允许其读写操作划分为两次32位的操作,即JVM实现可以选择不保证64位数据类型的load、store、write和read这4个操作的原子性,这就是所谓的long和double的非原子协定。

但是JVM实现中几乎都用了手段来保证long和double类型读写操作的原子性,所以几乎是没有任何影响的。

1.5原子性、可见性和有序性

原子性(Atomicity):

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可认为基本数据类型的读写访问是原子性的。如果应用场景需要更大范围地保证整体操作的“原子性”,内存模型上还可以通过lock和unlock操作来满足需求,尽管JVM没有将lock和unlock操作开放给用户,但是提供了更高层次的字节码指令—monitorenter和monitorexit指令来隐式地使用这两个操作,这两个字节码反应到Java关键字就是:sychronized。所以在sychronized修饰过后的方法、操作等也具备原子性。

可见性(Visibility)

除了上面说的volatile关键字,Java还有两个关键字来实现可见性:sychronized和final

有序性(Odering)

如果是在单个线程的角度来看,那么所有的操作都是有序的,但是在多线程的角度,由于指令重排序和工作内存与主内存间同步延迟现象的发生,经常会导致「无序」的情况。

Java提供volatile和sychronized两个关键字来保证线程之间操作的有序性。其中volatile是天然自带立即同步工作内存和主内存和禁止了指令重排序来实现有序性的;sychronized是通过“一个时刻只允许一条线程对其进行lock操作”这条规则来实现的。


2.Java与线程

线程是比进程更轻量级的调度单位,线程的引入可以把一个进程的资源分配和执行调度分开,各个线程即可共享进程资源如内存地址、文件IO等,又可以独立调度。线程是CPU调度的最小单位。那么Java中线程是如何实现,线程间的调度又是怎样完成的呢?我们先介绍下操作系统中线程的实现,再来讨论。

2.1线程的实现

主流的操作系统都提供了线程的实现,在Java中无论通过继承Thread类还是实现Runnable接口,一个类要想创建线程,最终都会通过调用Thread类的start方法实现。而Thread类的大部分关键方法都是Native方法,即不是通过Java而是调用本地方法(通过C/C++)实现的。其实现过程依赖于Native方法和操作系统之间的交互,不同操作系统有着不一样的实现。

广义上线程(而不是Java线程)的实现主要有三种方式:

  • 使用内核线程实现
  • 使用用户线程实现
  • 使用用户线程+轻量级进程混合实现

1.使用内核线程实现

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

程序一般不会直接去使用内核线程,而是使用内核线程的另一种高级接口——轻量级进程(Light Weight Process ,LWP)轻量级进程就是我们通常意义上将的「线程」。每个轻量级进程都由一个内核线程直接支持,这种1:1的关系称为一对一的线程模型:

Java虚拟机—内存模型与线程_第3张图片

优势:由于内核线程的支持,使得每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞,也不会影响整个进程的继续工作。

劣势:由于基于内核线程实现,所以各种线程操作如创建、析构、同步都需要进行系统调用,而系统调用涉及到在用户态(User Mode)和内核态(Kernel)中来回切换,主要的系统调用代价较高,且消耗一定的内核资源,因此一个操作系统能支持的轻量级进程数是有限的。

2.使用用户线程实现

从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),因此轻量级进程也属于用户线程。

从狭义上来讲,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在。用户线程的建立、同步、销毁和调用完全在用户态即可完成,无需内核的帮助。这种进程与用户线程之间1:N的关系称为一对多的线程模型:

Java虚拟机—内存模型与线程_第4张图片

优势:不需要系统内核的支援,少了内核态用户态之间相互切换的开销,可以支持规模更大的线程数量。大部分高性能数据库中的多线程就是由用户线程实现的。

劣势:所有线程创建、调度和切换都需要用户程序自己处理,而且由于操作系统只把CPU资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”等这类问题解决起来将会比较麻烦,因而使用用户线程实现的程序一般都比较复杂。

Java、Ruby等语言都曾经使用过用户线程,最终由放弃了使用它。

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

在这混合模式下,即存在用户线程也存在轻量级进程,用户线程还是完全建立在用户空间中。在这种混合模式下,用户线程和轻量级进程的数量比是不确定的即N:M的关系,这种就是多对多的线程模型。

Java虚拟机—内存模型与线程_第5张图片

优点:这种模式下用户线程的创建、切换、析构依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度机制以及处理器映射,并且轻量级进程的实现大大降低整个系统进程被完全阻塞的风险。

Java线程的实现

Java线程在JDK1.2以前是基于“绿色线程”的方式2——用户线程实现的。在JDK1.2中,线程模型替换为了基于操作系统的原生线程模型来实现,而不同操作系统具体线程模型实现也不同,所以Java线程的实现是分操作系统的。

对于常用的Sun JDK来说,Windows版本和Linux版本的操作系统提供的线程模型是一对一的模型,即一条用户线程对应一条轻量级进程,所以Java线程也是使用方式1——内核线程来实现的。(一条Java线程映射到一条轻量级进程中)

而在Solaris平台中(一种UNIX系统的衍生版),由于操作系统的特性可以同时支持一对一以及多对多(方式1和方式3),因此Solaris版的JDK可以用虚拟机参数来指定Java使用的具体线程模型。

2.2Java线程调度

线程调度是指系统为线程分配CPU使用权的过程,主要调度方式有两种:

  • 协同式线程调度(Cooperative Threads-Scheduling)
  • 抢占式线程调度(Preemptive Threads-Scheduling)

使用协同式线程调度的多线程系统,线程执行的时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。使用协同式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系统进行线程切换,所以没有线程同步的问题,但是坏处也很明显,如果一个线程出了问题,则程序就会一直阻塞。

使用抢占式线程调度的多线程系统,每个线程执行的时间以及是否切换都由系统决定。在这种情况下,线程的执行时间不可控,所以不会有「一个线程导致整个进程阻塞」的问题出现。

Java使用的就是抢占式线程调度。在Java中,Thread.yield()可以让出CPU执行时间,但是对于获取,线程本身是没有办法的。对于获取CPU执行时间,线程唯一可以使用的手段是设置线程优先级,Java设置了10个级别的程序优先级,当两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。

Java中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和Java中的一一对应,所以Java优先级并不是特别靠谱。

2.3Java线程状态转换

Java语言中有6种线程状态,在任意时刻里,一个线程有且只有其中的一种状态。

  • New新建
  • Runable可运行
  • Waiting等待
  • Timed Waiting限时等待
  • Blocked阻塞
  • Terminated结束

New:

创建后尚未启动的线程处于此状态。

Runable:

Java线程中的可运行状态,对应操作系统中线程状态中的Ready和Running。表示线程有可能正在执行,还有可能处于就绪状态,等待CPU为其分配时间片来执行。

Waiting:

处于此状态是,线程不会被CPU分配执行时间,且需要等待被其他线程显示地唤醒,以下方法会让线程处于无限等待状态:

Thread.join()方法,未设置Timeout参数
Object.wait()方法,未设置Timeout参数
LockSupport.park()方法

Timed Waiting:

处于这种状态的线程同样不会被分配CPU执行时间,不过在等待一定时间后会被系统自动唤醒。以下方法会让线程处于限时等待状态:

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

Blocked:

表示线程被阻塞,阻塞和等待状态下都不会获得CPU时间片。

Terminated:

结束,已终止的线程状态,此状态下,线程已终止运行。

Java虚拟机—内存模型与线程_第6张图片

 

 

编辑于 2018-10-02

你可能感兴趣的:(JAVA)