《深入理解Java虚拟机》之Java内存模型与线程

阅读《深入理解Java虚拟机》第2版,结合JDK8的读书笔记。当前文章为书本的第12章节。

12.1.概述

本章节将介绍虚拟机如何实现多线程,多线程之间由于线程共享和竞争数据而导致的一系列问题以及解决方案。

TPS(Transactions Per Second):它代表着一秒内服务端平均能相应的请求总数。

12.2.硬件的效率与一致性

计算机的存储设备和处理器的运算速度存在比较大的差异,为了弥补这个差异引入了缓存来作为内存与处理器之间的缓冲。

将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

每个处理器都有自己的高速缓存,但是它们共享同一主内存,因此各个处理器访问缓存时都遵循一些协议。例如MSI,MESI,MOSI,Synapse,Firefly及DragonProtocol。

下图为处理器、高速缓存、主内存之间的交互关系图,拍摄于周志明老师的《深入理解Java虚拟机 第2版》处理器、高速缓存、主内存之间的交互关系

12.3.Java内存模型

12.3.1.主内存与工作内存

Java内存模型规定了所有的变量(这里的变量是指实例字段,静态字段和构成数组对象的元素,并非局部变量和方法参数)都存储在主内存中,每条线程都有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝(虚拟机实现不会将整个对象都拷贝一次,可能是拷贝使用到的字段),线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

下图为线程、主内存、工作内存三者的交互关系图,拍摄于周志明老师的《深入理解Java虚拟机 第2版》
线程、主内存、工作内存三者的交互关系图

12.3.2.内存间交互操作

Java内存模型中定义了以下8种操作来完成。

  • lock

锁定,作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock

解锁,作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。

  • read

读取,作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便随后的load动作使用。

  • load

载入,作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use

使用,作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

  • assign

赋值,作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store

存储,作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  • write

写入,作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

把一个变量从主内存赋值到工作内存,需要顺序(不要求连续执行,中间可以接其它操作)地执行read和load操作。如果要把变量从工作内存同步回主内存,需要顺序地执行store和write操作。

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

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

12.3.3.对于volatile型变量的特殊规则

关键字volatile可以说是java虚拟机提供的最轻量级的同步机制。当一个变量定义为volatile之后,它将具备两种特性:

  1. 保证此变量对所有线程的可见性。这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
  2. 禁止指令重排序优化。
对所有线程的可见性

我们来看一段代码,编译成字节码之后的效果。

volatile变量编译成字节码

只有一行代码的increase()方法在Class文件中由4条字节码指令构成(return指令不是由race++产生的)。

  • getstatic

获取类的静态域,并将其值压入栈顶

  • iconst_1

将int型1推送到栈顶

  • iadd

将栈顶两int型数值相加并将结果压入栈顶

  • putstatic

为类的静态域赋值

这就是对所有线程可见性的原因。

禁止指令重排序优化

从硬件架构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保证程序能得出正确的执行结果。

例如:指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排,因为(A+10)x2不等于Ax2+10,但是指令3可以重排到指令1、2之前或中间,只要保证CPU执行后面依赖到A、B值的操作时能获取正确的A和B值即可。

volatile变量在写的时候会插入许多内存屏障指令来保证处理器不会发生乱序执行。

内存屏障是指重排序时不能把后面的指令重排序到内存屏障之前的位置。

非原子性

volatile变量并不能保证原子性,因此在不符合以下两条规则的运算场景时,仍然需要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。

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

12.3.4.对于long和double型变量的特殊规则

对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。

12.3.5.原子性、可见性与有序性

  • 原子性(Atomicity)

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write。基本数据类型的访问读写时具备原子性的。

如果应用场景需要更大范围的原子性保证,可以通过同步块-使用synchronized关键字,隐式地使用monitorenter和monitorexit两个字节码指令。

  • 可见性(Visibility)

当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。除了volatile,还有synchronized和final也能实现可见性。

synchronized可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得

final可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且没有发生this引用逃逸,那在其他线程中就能看见final字段的值。

  • 有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语音,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的。

12.3.6.先行发生原则

Java内存模型存在以下“天然的”先行发生关系。

  • 程序次序规则:在一个线程内,按照控制流的代码顺序执行
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
  • 传递性:操作A先发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C

12.4 Java与线程

12.4.1.线程的实现

线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件IO等),又可以独立调度(线程是CPU调度的基本单位)。

在JAVA API中,一个Native方法往往意味着这个方法没有使用或无法使用平台无关的手段来实现。也有可能是为了执行效率而使用Native方法,不过,通常高效率的手段就是平台相关的手段。

实现线程主要有三种方式:

  1. 使用内核线程实现
  2. 使用用户线程实现
  3. 使用用户线程加轻量级进程混合实现
Java线程的实现

虚拟机规范并未限定Java线程需要使用哪种线程模型来实现。

12.4.2.Java线程调度

线程调度是指系统为线程分配处理器使用权的过程。主要分两种调度方式:协同式线程调度和抢占式线程调度。

  • 协同式线程调度

线程的执行时间由线程自己控制,线程完成自己的工作后,主动通知系统切换到另一个线程。

实现简单,但是线程执行时间不可控。

  • 抢占式线程调度

线程的执行时间由系统来分配,线程的切换不由线程本身来决定。

Java线程调度方式

Java线程使用抢占式调度方式。

  1. 在Java中,Thread.yield()可以让线程出让执行时间
  2. 通过设置优先级-Thread.setPriority(),来建议系统多给指定线程分配一点执行时间

Java线程优先级为1-10,10为最高级,1为最低级。因为Java线程是通过映射到系统的原生线程上实现的,所以线程调度最终还是取决于操作系统。而且操作系统提供的线程优先级可能不一定能够Java的线程优先级对应。

12.4.3.状态转换

Java语言定义了6种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这6种状态分别为:

  • 新建(new)

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

  • 运行(runable)

该状态包括了操作系统线程状态中的running和ready两种状态,处于该状态的线程有可能正在执行,也有可能等待CPU给它分配执行时间。

  • 无限期等待(waiting)

处于该状态的线程不会被分配执行时间,需要等待其他线程显式的唤醒。以下方法会让线程进入无限期等待状态:

  1. 没有设置timeout参数的Object.wait()
  2. 没有设置timeout参数的Thread.join()
  3. LockSupport.park()
  • 限期等待(timed waiting)

处于该状态的线程不会被分配执行时间,不需要其他线程唤醒,在一定时间后会由系统自动唤醒。以下方法会让线程进入限期等待状态:

  1. Thread.sleep()
  2. 设置了timeout参数的Object.wait()
  3. 设置了timeout参数的Thread.join()
  4. LockSupport.parkNanos()
  5. LockSupport.parkUntil()
  • 阻塞(blocked)

阻塞状态是指线程在等待着获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生。

  • 结束(terminated)

已终止线程的线程状态。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

瑾析编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值