Java 内存模型与线程

35

Java 内存模型与线程

概述

在许多场景下,需要让计算机同时处理几项任务,充分发挥计算机性能。除了充分发挥计算机处理器的能力外,一个服务器要同时为多个客户端提供服务,则是另一个更具体的并发应用场景。衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second,TPS)是重要的指标之一,它代表着一秒内服务平均能响应的请求总数,而TPS与程序的并发能力密切相关。

硬件效率与一致性

物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。
由于计算机的存储设备与处理器的运算速度存在几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。
基于高速缓存的存储交互很好地解决了处理器和内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议。

Java内存模型

Java内存模型主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

工作内存和主内存

工作内存:每条线程都有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、写入)都必须在工作内存中进行,而不能直接读写主内存中的数据。工作内存在物理上对应寄存器和高速缓存。

内存间交互操作

Java内存模型定义了8种原子性操作,如下:

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

Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  • read和load、store和write必须成对出现,不允许单独出现
  • 不允许线程丢弃它最近的assign操作
  • 不允许一个线程无原因的把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assgin)的变量,换句话说就是对一个变量实施use、store之前,必须先执行assign和load操作。
  • lock操作是独占的,任何时刻只允许一个线程对其进行lock操作,但lock操作可以被同一线程执行多次。只有执行了相同次数的unlock操作后,对象才能被完全解锁。
  • 如果对一个变量执行lock操作,那么将清空(所有)工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 如果一个变量没有被lock锁定,那么不允许对它使用unlock,也不允许去unlock一个被其他线程锁定的对象。
  • 对一个变量执行unlock操作之前,必须先把变量同步回主内存中(执行store、write操作)。

对于 volatile 型变量的特殊规则

关键词volatile是Java虚拟机提供的最轻量级的同步机制。
当一个变量被定义为volatile型后,该变量具备下面两种特性:

  1. 保证此变量对所有线程的可见性
  2. 禁止指令重排序优化

“保证此变量对所有线程的可见性”的原理:对于volatile型变量,每次读取之前都会先进行刷新,将变量的值从主内存中放到工作内存中;每次写入都会被刷新到主内存中,这样保证volatile在所有线程的可见性。
“禁止指令重排序优化”的原理:在对volatile型变量赋值后,会多执行一个lock add1 $0x0, (%esp)操作,这个操作相当于一个内存屏障,指令排序时,不能把后面的指令排序到内存屏障之前的位置。

lock add1 $0x0, (%esp)的作用

  1. 相当于一个内存屏障,可以禁止指令重排序
  2. 查阅IA32手册可知,它的作用将本处理器的缓存写入内存,该写入动作也会引起别的处理器或者别的内核无效化其缓存。所以通过这样一个空操作,可让前面volatile变量的修改对其他处理器立即可见。

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

long和double的非原子性协定:JVM规范并没有对long和double类型变量的读写原子性做出规范,由虚拟机自己决定是否实现long和double类型变量的原子性。

原子性、可见性、有序性

原子性
  1. 由Java内存模型直接保证的原子性操作有:lock、unlock、load、read、use、assign、store、write。
  2. 由1可知基本类型的访问、读写都是具备原子性的。
  3. 由synchronized关键字修饰的代码块具备原子性,它字节码层面通过monitorenter和monitorexit来完成,这两个字节码底层使用了lock和unlock操作。
可见性
  1. volatile保证了多线程操作时变量的可见性。
  2. synchronized可实现可见性,因为该关键字底层使用lock、unlock操作实现,而unlock操作规定在执行unlock之前,必须先把此变量同步回主内存中。
  3. final修饰的变量也具有可见性,final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去,那么在其他线程中就能看见该变量的值。
有序性

在一个线程内部看,所有的操作都是有序的,如果在一个线程观察另一个线程,所有的操作都是无序的。这句话前半段说的是“线程内表现为串行的语义”,后半段说的是“指令重排序”、“工作内存和主内存同步延迟”现象。

volatile和synchronized保证了线程之间操作的有序性,volatile关键字本身包含了禁止指令重排序,synchronized则是由同一时刻只能有一个线程对其进行lock操作所获得的。

先行发生(Happen-Before)原则

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么很多操作都会变得很麻烦。先行发生是Java内存模型中定义的两项操作之间的偏序关系。
下面是Java内存模型下一些“天然的”先行发生关系。如果两个操作之间的关系不在此列,并且无法从下列规则中推导出来,则它们就没有顺序性保障,虚拟机就可以对它们随意地进行重排序。

  1. 程序次序规则:在一个线程内,按照控制流顺序,写在前面的操作Happen-Before写在后面的操作。
  2. 管程锁定规则:一个unlock操作Happen-Before后面对同一个锁的lock操作。这里的“后面”指时间上的次序。
  3. volatile变量规则:对一个volatile变量的写操作Happen-Before后面对这个变量的读操作。这里的“后面”指时间上的次序。
  4. 传递性:如果操作A Happen-Before 操作B,操作B Happen-Before 操作C,那么操作A Happen-Before 操作C。

上面四个是先行发生原则中最重要的四个,其余不在赘述。

Java与线程

线程的实现

  • 进程:是资源分配的最小单位,是程序的一次执行过程。
  • 线程:是程序执行或者说占用CPU时间片的最小单位
  • 用户线程:用户线程运行在用户空间中,由线程库进行调度
  • 内核线程:就是直接由操作系统内核支持的线程,由内核进行调度
  • 轻量级进程:是操作系统提供的内核线程的一种高级接口,一个轻量级进程都有一个内核线程与之对应

需要注意的是用户线程和内核线程都是在操作系统层面进行划分的,CPU对此是无感知的。

在操作系统之上,实现线程一般有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。

关于进程、线程、内核线程、用户线程的内容可以参考这篇文章:https://www.cnblogs.com/heiyan/p/16147720.html

内核线程实现

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
1比1的线程模型.png

从上图可以看出,程序通过LWP来实现线程,而每个LWP对应一个KLT内核线程,KLT通过内核提供的线程调度器获得CPU执行时间,因此线程的每一次调度(如阻塞、等待、执行等)都将导致从用户态切换到内核态。这是该方案最严重的缺点。优点是无需程序自己实现复杂的线程调度策略,直接由操作系统进行调度。

局限性:

  1. 各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换
  2. 每个轻量级进程都需要有一个内核线程的支持,因此轻量级进行需要消耗一定的内核资源
用户线程实现

用户线程运行在用户空间中,由线程库进行调度。
N比1的线程模型 用户线程实现.png

上面的图和《深入理解JVM虚拟机》上的图有所不同,是本人经过查阅操作系统相关资料后,根据个人理解画出的图,如有错误,可评论指正,不胜感激。

要明确的一点是用户线程对于操作系统来说是无感知的,操作系统只能对内核线程进行调度,然后我们在某个操作系统上去调度用户线程,肯定是无法完全绕过操作系统的。多个用户线程对应一个KLT(这里忽略了LWP,按理说应该还有一层LWP,它才是操作系统提供给我们实现线程的高级接口),由线程库来对用户线程进行调度,这整个过程是运行在KLT上的,KLT是通过内核线程调度器来执行的。

这种方式有一个明显的缺点:如果某个用户线程进行了系统调用,那么就会阻塞对应的内核线程,从而导致整个程序处于阻塞状态。早起Java就使用的这种线程模型。

混合实现

混合实现同时包含了上面两种实现方式,被称为N:M实现,包含了它们的优点,克服了它们的缺点。但同时也存在实现起来特别复杂的缺点。最近特别火的Golang、Erlang就使用了这种混合实现。
N比M的混合线程模型.png

操作系统支持的轻量级进程作为用户线程和内核线程之间的桥梁,并且一个用户线程可以被映射到多个轻量级进程上来执行,一个用户线程进行系统调用并不会完全阻塞整个程序。

Java 线程的实现

Java线程采用了1:1的线程模型,即通过内核线程来实现的。

Java线程调度

线程调度方式主要有两种:

  1. 抢占式线程调度:线程的执行时间是系统可控的
  2. 协同式线程调度:线程执行时间不可控
状态转换

java线程的状态转换过程.png