深入理解Java虚拟机学习笔记——四、Java内存模型与多线程

一、Java内存模型

Java内存模型的意义:屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各种平台上一致的内存访问效果。

1、主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中把变量存储到内存和从内存中取出变量。此处的变量是指实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数(局部变量和方法参数是线程私有的,所以不存在竞争问题)。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处内存不是物理内存,而是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,类似于处理器的高速缓存),线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝
。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量
。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都需要通过主内存来完成,线程、工作内存、主内存三者的交互关系如下图所示:
《深入理解Java虚拟机学习笔记——四、Java内存模型与多线程》

①虚拟机只会拷贝某个对象的引用、对象中某个在线程访问到的字段,而不会将整个对象拷贝到工作内存中。

② volatile依然有工作内存的拷贝,但是由于它特殊的操作顺序性,所以看起来像直接在主内存中读写访问一样。

2、内存间交互操作

对于一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回住内存之类的实现细节,Java内存模型定义了以下8中操作来完成,虚拟机实现必须保证下面提及的每一种操作都是原子的、不可再分的(对于long和double类型的变量来说,load、store、read和write操作在某些平台上允许有例外):

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

如果要把一个变量从主内存复制到工作区,就要顺序地执行read和load操作;如果要把变量从工作内存同步回主内存,就要顺序的执行store和write操作。

Java内存模型只要求上述两个操作必须按顺序执行,而不是连续执行。因此在read与load之间、store与write之间是可插入其他指令的。除此之外,Java内存模型还规定了在执行上述8中基本操作时必须满足如下规则:

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

3、volatile变量的特殊规则

volatile可以说是Java虚拟机提供的最轻量级的同步机制。Java内存模型对volatile专门定义了一些特殊的访问规则。 当一个变量被定义为volatile后,它将具备两种特性:

(1)保证此变量对所有线程的可见性。 可见性是指当一个线程修改了这个变量的值后,新值对于其他线程来说是可以立即得知的。 由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍然要通过加锁(synchronized和java.util.concurrent中的原子类)来保证原子性:

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

(2)禁止指令重排序优化。 普通变量仅仅能够保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与代码中的执行顺序一致。指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。

假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:

  • 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use操作;并且,只有当线程T对变量V执行的后一个动作是use时,线程T才能对变量V执行load操作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联,必须连续一起出现(该规则要求在工作内存中,每次使用变量V的值都必须先从主内存中刷新最新的值,用于保证能够看见其他线程修改变量后的值)。
  • 只有当线程T对变量V执行的前一个动作是assign时,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store时,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联,必须连续一起出现(该规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其它线程可以看到自己对变量V所做的修改)。
  • volatile修饰的变量不会被指令重排序优化,保证指令的执行顺序与程序的顺序相同。

4、long和double型变量的特殊规则

Java内存模型要求lock、unlock、read、load、assign、use、store、write操作都具有原子性,但是对于64位的long和double允许虚拟机将未被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机不保证64位数据类型的read、load、store、write这4个操作的原子性,这就是所谓的long和double的非原子协定。

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

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。

  • 原子性(Atomicity)

由Java内存模型来直接保证的原子性操作包括:read、load、assign、use、store、write,可以大致的认为基本数据类型的读写都是具备原子性的。Java虚拟机提供了更高层次的字节码指令monitorenter和monitorexit来隐式使用更大范围的原子性操作lock和unlock,这两个字节码指令反映到Java代码中就是synchronized,因此在synchronized代码块之间的操作也具备原子性。

  • 可见性(Visibility)

可见性是指当一个线程修改了变量的值,其他的线程能够立刻感知这个修改。Java内存模型是通过在修改过变量后将变量的值同步回主内存、在读取变量前从主内存刷新变量的值,这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是被volatile修饰的变量都是如此。普通变量与被volatile修饰的变量的区别是,被volatile修饰的变量能够在变量值改变后立即将新值同步回主内存,以及每次使用前立即从主内存中刷新。因此,volatile关键字保证了多线程环境下变量的可见性,而普通变量无法保证这一点。除了volatile外,Java中还有两个关键字可以保证可见性:synchronized和final。synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存”这条规则实现的。final的可见性是指被final修饰的的字段在构造器中一旦初始化完成,并且构造器没有将“this”的引用传递出去,那么在其他线程中就能够看到final字段的值。

  • 有序性(Ordering)

Java中天然的有序性可以总结为一句话:在本线程内观察,所有操作都是有序的;在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存之间同步延迟”现象。Java语言提供了volatile和synchronized关键字来保证线程间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一个线程对其lock操作”这条规则获得的。

6、先行发生(happens-before)原则

该原则是判断数据是否存在竞争、线程是否安全的主要依据。“先行发生”原则是指Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能够被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。 下面是Java内存模型中一些“天然的”先行发生关系,这些先行发生关系不需要任何同步器协助,可以在代码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以随意的对它们进行重排序。

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说,应该是控制流顺序而不是程序代码顺序。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,“后面”同样是指时间上的先后顺序。
  • 线程启动规则(Thread Start Rule):Thread的start方法先行发生于此线程的每一个操作。
  • 线程终止规则(Thread Termination Rule):线程的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测线程是否终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finallize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

一个操作“时间上的先发生”不代表这个操作是“先行发生”,“先行发生”同样不是“时间上的先发生”。

二、Java与线程

1、线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配与执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。 每个已经执行了start()方法且还未结束的java.lang.Thread类的实例就代表了一个线程。Thread类的所有关键方法都是声明为Native的。在Java API中,一个Native方法往往意味着这个方法没有使用或无法使用与平台无关的手段实现。 实现线程主要有3中方式:

  • 使用内核线程实现

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。 程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是通常意义上所讲的线程。每个轻量级进程都是一个独立的调度单元,所以即使有一个轻量级进程阻塞了,也不会影响到整个进程的工作。但是轻量级也有局限性:由于轻量级进程是基于内核线程实现的,所以各种线程操作(创建、析构、同步)都需要进行系统调用,而系统调用的代价是很高昂的,需要在用户态(User Mode)和内核态(Kernel Mode)来回切换。其次,每个轻量级进程都需要内核线程的支持,因此轻量级进程需要消耗掉一定的内核资源(如内核线程的栈空间),因此一个系统支持的轻量级进程数是有限的。 这种轻量级进程和内核线程一对一的关系被称为一对一的线程模型。
《深入理解Java虚拟机学习笔记——四、Java内存模型与多线程》

  • 使用用户线程实现

从广义上讲,一个线程只要不属于内核线程,那么就可以认为是用户线程(User Thread,UT)。因此,从这个定义上看,轻量级进程也属于用户线程,只不过由于轻量级进程需要内核线程的支持,许多操作都需要进行系统调度,效率会受到限制。 而狭义上的用户线程是指完全建立在用户空间的线程库上,系统内核无法感知线程存在的实现。用户线程的建立、同步、销毁都是在用户态下完成的,不需要内核的帮助。这种进程与用户线程一对多的关系被称为一对多的线程模型。
《深入理解Java虚拟机学习笔记——四、Java内存模型与多线程》

  • 使用用户线程加轻量级进程混合实现

这种实现,既存在用户线程,也存在轻量级进程。用户线程还是建立在用户空间上,因此线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用通过轻量级进程完成,大大降低了整个进程被阻塞的风险。这种方式中,用户线程与轻量级进程的数量比例是不确定的,即多对多的线程模型。
《深入理解Java虚拟机学习笔记——四、Java内存模型与多线程》

2、Java线程的实现

在JDK 1.2之前,是基于“绿色线程”的用户线程实现的;在JDK 1.2中,线程模型替换为基于操作系统原生线程模型来实现。Sun JDK在Windows和Linux系统中,都是使用一对一的线程模型,一条Java线程就映射到一个轻量级进程中。在Solaris中,由于操作系统的可以同时支持一对一(通过Bound Threads或Alternate Libthread实现)以及多对多(通过LWP/Thread Based Synchronization实现)的线程模型,所以Solaris版本的JDK提供了-XX:+UseLWPSynchronization(默认)和-XX:+UseBoundThreads来指定虚拟机所使用的线程模型。

3、Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要有两种调度方式:协同式线程调度和抢占式线程调度。在协同式线程调用方式中,线程的执行时间由线程本身控制,线程在执行完成后,主动通知系统切换线程。抢占式线程调度中,每个线程由系统来分配执行时间,线程的切换不由线程本身决定。

4、状态转换

Java语言定义了5中线程状态,在任一时刻,一个线程有且只有一种状态:

  • 新建(New):创建后尚未启动的线程处于该状态。
  • 运行(Runnable):Runnable包括了操作系统线程状态的Running和Ready,处于此状态的线程有可能正在执行,也有可能在等待CPU分配时间。
  • 无限期等待(Waiting):处于该状态的线程不会分配处理器执行时间,它们要等待被其他线程显示的唤醒。以下方法会让线程陷入无限期等待状态:

没有设置Timeout参数的Object.wait()方法。 没有设置Timeout参数的Thread.join()方法。 LockSupport.park()方法。

  • 期限等待(Timed Waiting):处于该状态的线程也不会被分配处理器时间,不过无需等待被其他线程显示的唤醒,在一定时间后会被系统自动唤醒。以下方法会让线程进入期限等待:

Thread.sleep()方法 设置了Timeout参数的Object.wait()方法。

设置了Timeout参数的Thread.join()方法。

LockSupport.parkNanos()方法。 LockSupport.parkUntil()方法。

  • 阻塞(Blocked):线程被阻塞了,“等待状态”与“阻塞状态”的区别:“阻塞状态”在等待着获得一个排他锁,这个事件将会在另外一个线程放弃这个锁是发生;而“等待状态”则是在等待一段时间或者唤醒动作的发生
  • 结束(Terminated):已终止的线程的线程状态,线程已经执行结束。

这5中状态的转换关系如下图所示:
《深入理解Java虚拟机学习笔记——四、Java内存模型与多线程》

    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/haoyuyang/article/details/70176939
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞