写点什么

读书笔记之《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》(下)

作者:菜农曰
  • 2022 年 7 月 17 日
  • 本文字数:11112 字

    阅读完需:约 36 分钟

读书笔记之《深入理解Java虚拟机:JVM高级特性与最佳实践》(下)

💡 学而不思则罔,思而不学则殆。 —— 孔子

👉 微信公众号已开启,菜农曰,没关注的同学们记得关注哦!


本篇带来的是周志明老师编写的《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》,十分硬核!



全书共分为 5 部分,围绕内存管理执行子系统程序编译与优化高效并发等核心主题对 JVM 进行了全面而深入的分析,深刻揭示了 JVM 工作原理。


全书整体 5 个部分,十三章,共 358929 字。整体结构相当清晰,以至于写读书笔记的时候无从摘抄(甚至想把全书复述一遍),以下是全书第三部分的内容,望读者细细品尝!

一、第三部分 虚拟机执行子系统

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步


第六章 类文件结构

计算机只认识 0 和 1,所以我们写的程序需要经编译器翻译成由 0 和 1 构成的二进制格式才能由计算机执行。

1)无关性的基石

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式 — 字节码(ByteCode)是构成平台无关性的即时。


2)Class 类文件的结构

任何一个 Class 文件都对应着唯一一个类或接口的定义信息,但反过来,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)


Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数据。


Class 文件格式采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:


  • 无符号数 :基本的数据类型,可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值

  • :由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以 _info 结尾。

1. 魔数与 Class 文件的版本

每个 Class 文件的头 4 个字节称为 魔数(0xCAFEBABE),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。


紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本好,第 7 和第 8 个字节是主版本号。



Java 的版本号是从 45 开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1

2. 常量池

主次版本号之后便是常量池的入口


常量池中常量的数量是不固定的,所以在常量池的入口会放置一项 u2 类型的数据,代表常量池容量计数值。



常量池容量(偏移地址:0x00000008)为十六进制数 0x0016,即十进制的 22,这就代表常量池中有 21 项常量,索引值范围为 1~21。


常量池主要存放两大类常量:字面量符号引用


符号引用包括了三类常量:


  • 类和接口的全限定名

  • 字段的名称和描述符

  • 方法的名称和描述符

3. 访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这两个标志用于识别一些类或接口层次的访问信息


  • 这个 Class 是类还是接口

  • 是否定义为 public 类型

  • 是否定义为 abstract 类型

  • 如果是类的话是否声明为 final


4. 类索引、父类索引和接口索引集合

类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据集合,Class 文件中由这三项数据来确定这个类的继承关系。



从偏移地址0x000000F1开始的 3 个 u2 类型的值分别为 0x00010x00030x0000,也就是类索引为 1,父类索引为 3,接口索引集合大小为 0,然后通过 javap 命令计算出来的常量池,找出对应的类和父类的常量

5. 字段表集合

字段表用于描述接口或类中声明的变量。



相对于全限定名和简单名称来说,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示,而对象类型则用字符 L 加对象的全限定名来表。



对于数组类型,每一维度将使用一个前置的“[”字符来描述

6. 方法表集合

方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。


而方法里面的代码,经过编译器编译器编译成字节码指令后,存放在方法属性表集合中一个名为 Code 的属性里,属性表作为 Class 文件格式中最具扩展性的一种数据项目。



如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器 <clinit> 方法和实例构造器 <init> 方法。

7. 属性表集合

属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格顺序,只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息。


3)字节码指令简介

Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多


个代表此操作所需参数(称为操作数,Operands)而构成。

第七章 虚拟机类加载机制

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

1)类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading) 7 个阶段。



加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)


初始化的时机


  1. 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。通俗来说也就是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候

  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先初始化

  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类

  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化

2)类加载的过程

1. 加载

在加载阶段,虚拟机需要完成以下 3 件事情:


  • 通过一个类的全限定名来获取定义此类的二进制字节流

  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

2. 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。


Class 文件并不一定要求用 Java 源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生 Class 文件。


验证阶段会完成 4 个阶段的检验动作:


  • 文件格式验证


  1. 是否以魔数 OxCAFEBABE 开头

  2. 主、次版本是否在当前虚拟机处理范围之内

  3. 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)

  4. ...


该验证阶段的主要目的是保证输入的字节流能正确地解析并存储在方法区之中,是基于二进制字节流进行的。


  • 元数据验证


  1. 该类是否有父类

  2. 这个类的父类是否继承了不允许被继承的类(final 修饰的类)

  3. 如果类不是抽象的,是否实现了其父类或接口之中要求实现的所有方法

  4. ...


该阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。主要目的是对类的元数据进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。


  • 字节码验证


  1. 保证任意时刻操作数栈的数据类型与指令代码序列都能来配合工作

  2. 保证跳转指令不会跳转到方法体以外的字节码

  3. ...


该阶段的主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。


  • 符号引用验证


  1. 符号引用中通过字符串描述的全限定名是否能找到对应的类

  2. 在指定类中是否存在符合方法的字段描述以及简单名称所描述的方法和字段

  3. ...


该阶段的主要目的是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,确保解析动作能够正常执行。

3. 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

4. 解析

解析阶段是虚拟机将常量池的符号引用替换成直接引用的过程。


  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。

  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同、如果有了直接引用,那引用的目标必定已经在内存中存在。

5. 初始化

类初始化阶段是类加载过程的最后一步。初始化阶段是执行类构造器 <clinit> 方法的过程。

3)类加载器

类加载阶段中 通过一个类的全限定名来获取描述此类的二进制字节流 这个动作放到了 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为 类加载器

1. 类与类加载器

每一个类加载都拥有一个独立的类名称空间。比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

2. 双亲委派模型

类加载器可以划分为 3 类:


  • 启动类加载器(Bootstrap ClassLoader)


这个类加载器负责将存放在**<JAVA_HOME>\lib 目录中的,或者被-Xbootclasspath**参数所指定的路径中的


  • 扩展类加载器(Extension ClassLoader)


这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载**<JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs**系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。


  • 应用程序类加载器(Application ClassLoader)


这个类加载器由 sun.misc.Launcher$App-ClassLoader 实现。由于这个类加载器是 ClassLoader 中的**getSystemClassLoader()**方法的返回值,所以一般也称它为系统类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器



这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。


双亲委派模型的工作过程


如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

3. 破坏双亲委派模型
  1. JDK 1.2 之后已不提倡用户再去覆盖**loadClass()方法,而应当把自己的类加载逻辑写到 findClass()方法中,在 loadClass()方法的逻辑里如果父类加载失败,则会调用自己的 findClass()**方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。

  2. 线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的**setContextClassLoaser()**方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

第八章 虚拟机字节码执行引擎

1)运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。


栈帧存储了方法的局部变量表操作数栈动态连接和方法返回地址等信息。


一个线程方法中的调用链可能会很长,对于执行引擎来说,只有位于栈顶的栈帧才是有效的,称为 当前栈帧

1. 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。


局部变量表的容量以变量槽 为最小单位。

2. 操作数栈

操作数栈也常称为操作栈,是一个后入先出栈。操作数栈的最大深度会在编译的时候写入到 Code 属性的 max_stacks 数据项中。


在概念模型中,两个栈帧作为虚拟机栈的元素,是完全独立的。但在大多数虚拟机的实现中会有一部分优化重叠。这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。


3. 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

4. 方法返回地址

停止方法的运行有两种方式:


  • 执行引擎遇到任意一个方法返回的字节码指令。这种退出方式称为正常完成出口

  • 在方法执行过程中遇到了异常,并且这个异常没有在方法体内遇到处理。这种退出方法的方式称为异常完成出口

2)方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。


在编译时期,一切方法调用在 Class 文件里面存储的都只是符号引用,只有在类加载期间,甚至到运行期间才能确定目标方法的直接引用


解析


在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,而这种解析成立的条件为:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

二、第四部分 程序编译与代码优化

从计算机程序出现的第一天起,对效率的追求就是程序天生的坚定信仰,这个过程犹如一场没有终点、永不停歇的 F1 方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车


第十章 早期(编译器)优化

1)Javac 编译器

Javac 编译器本身就是一个由 Java 语言编写的程序


编译过程大致可以分为 3 个过程,分别是:


  • 解析与填充符号表过程

  • 插入式注解处理器的注解处理过程

  • 分析与字节码生成过程



Javac 编译动作的入口是 com.sun.tools.javac.main.JavaCompiler

1. 解析与填充符号表

解析:


解析步骤包括了经典程序编译原理中的 词法分析语法分析 两个过程


  • 词法分析过程由 com.sun.tools.javac.parser.Scanner 类来实现,根据 Token 序列构造抽象语法树的过程。

  • 语法分析过程由 com.sun.tools.javac.parser.Parser 类来实现,这个阶段产出的抽象语法树由 com.sun.tools.javac.tree.JCTree 类表示。


经过这个步骤之后,编译器基本就不会再对源代码进行操作了,后续的操作都建立在抽象语法树上。


填充符号表


填充符号表的动作由 enterTrees() 方法实现。


符号表就是由一组符号地址和符号信息构成的表格,其中所登记的信息在编译的不同阶段都要用到。


填充符号表的过程由 com.sun.tools.javac.comp.Enter 类实现,此过程的出口是一个待处理列表(To Do List),包含了


每一个编译单元的抽象语法树的顶级节点,以及 package-info.java(如果存在的话)的顶级节点

2. 注解处理器

Java 语言提供了对注解的支持,这些注解与普通的 Java 代码一样,是在运行期间发挥作用的。

3. 语义分析与字节码生成

在上述步骤结束后,可以得到一个抽象语法树,但是无法保证源程序是符合逻辑的,语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查。


  • 标注检查

  • 数据及控制流分析

  • 解语法糖

  • 字节码生成

第十一章 晚期(运行期)优化

1)解释器与编译器

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐开始发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存限制较大(如嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。



HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Compiler 和 Server Compiler,简称为 C1 编译器 和 C2 编译器。用户可以使用 -client 或 -server 来指定运行在 Client 模式还是 Server 模式


为了在程序启动响应速度与运行速度之间达到最佳平衡,引入了 分层编译


  • 第 0 层:程序解释执行,解释器不开启性能监控功能,可触发第 1 层编译

  • 第 1 层:也称为 C1 编译,将字节码编译为本地代码,进行简单,可靠的优化,如有必要将加入性能监控的逻辑

  • 第 2 层:也称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化

2)编译对象与触发条件

在运行过程中会被即时编译器的 热点代码 有两种:


  • 被多次调用的方法

  • 被多次执行的循环体


判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为 热点探测


  • 基于采样的热点探测:虚拟机会周期性地检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。

  • 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法。(方法调用计数器 和 回边计数器)


回边计数器:作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边。建立回边计数器统计的目的就是为了触发 OSR 编译。



3)编译优化技术

1. 公共子表达式消除

如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。

2. 数组边界检查消除

为了安全,数组边界检查是必须要做的,但不是在每一次运行期间都会进行检查。

3. 方法内联

方法内联的行为是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。为了解决 Java 中虚方法内联的问题,引入了一种名为 "类型继承关系分析(CHA)" 的 技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否为抽象类等信息。

4. 逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,称为线程逃逸。


要证明一个对象不会逃逸到方法或线程之外,需要对这个变量作一些优化:


  • 栈上分配

  • 同步消除

  • 标量替换

三、第五部分 高效并发

并发处理的广泛应用是使得 Amdahl 定律代替摩尔定律称为计算机性能发展原动力的根本原因,也是人类压榨计算机运算能力的最有力的武器


第十二章 Java 内存模型与线程

1)硬件的效率与一致性

当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,因此在读写时要根据协议来操作,如 MSI、MESI、MOSI 等


2)主内存与工作内存

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


3)内存间交互操作

Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分


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

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

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用

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

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

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

  • store(存储):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量

  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中


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


Java 内存模型规定了在执行上述 8 种基本操作时需满足的以下规则:


  1. 不允许 read 和 load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现

  2. 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存

  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中

  4. 一个新的变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化(load 或 assign) 的变量

  5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁

  6. 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值

  7. 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量

  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中。

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

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


1. 原子性


Java 内存模型的 read、load、assign、use、store 和 write 可以直接保证原子性变量操作。


2. 可见性


可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。除了 volatile 之外,在 Java 中还可以通过 synchronize 和 final 来保证可见性。


3. 有序性


如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的。


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

4)Java 与线程

1. 线程的实现

各个线程之间既可以共享进程资源(内存地址、文件 I/O 等),又可以独立调度(线程是 CPU 调度的基本单位)


实现线程的主要有 3 种方式:


  1. 内核线程实现


直接由操作系统内核支持的线程,这种线程由内核来完成线程切换。程序一般不会直接取使用内核线程,而是去使用内核线程的一种高级接口— 轻量级进程 LWP = 线程


局限性:


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


  1. 用户线程实现


一个线程只要不是内核线程,就可以认为是用户线程。轻量级进程也属于用户线程。用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态上完成,不需要内核的帮助。


局限性:


没有系统内核的支援,线程的所有操作都需要用户程序自己处理,在 阻塞、调度之类的问题处理起来会异常困难


  1. 用户线程加轻量级进程混合实现


在这种混合实现下,既存在用户线程、也存在轻量级进程。用户进程完全是建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。

2. Java 线程调度

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


  • 协同式线程调度


线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。


特点:实现简单,但线程执行的时间不可控制,如果一个线程编写有问题,就会导致一直阻塞


  • 抢占式线程调度


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


特点:线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题。

3. 线程状态切换

Java 语言中定义了 5 种线程状态:


  • 新建(New):创键后尚未启动的线程处于这种状态

  • 运行(Runable):Runable 包括了操作系统状态中 Running 和 Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着 CPU 为它分配执行时间

  • 无限期等待(Waiting):处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被其他线程显式地唤醒

  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配 CPU 执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒

  • 阻塞(Blocked):线程被阻塞了,等待着获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行


第十三章 线程安全与锁优化

1)线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。


我们可以将 Java 语言中各种操作共享的数据分为 5 类:


  1. 不可变


不可变的对象一定是线程安全的。保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为 final,这样在构造函数结束之后,它就是不可变的。


  1. 绝对线程安全


在 Java API 中标注自己是线程安全的类,大多数都不是绝对线程安全的。


  1. 相对线程安全


相对的线程安全就是我们通常意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。


  1. 线程兼容


线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。


  1. 线程对立


线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。

2)线程安全的实现方法

1. 互斥同步

互斥是方法,同步是目的。最基本的手段就是使用 synchronized 关键字,经过编译之后,会在同步块的前后分别形成 monitorentermonitorexit 这两个字节码指令。


除了使用 synchronized 关键字还可以使用 J.U.C 包下的 ReentrantLock 来实现同步。相比 synchronizedReentrantLock 增加了一些高级功能,主要有以下 3 项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。

2. 非阻塞同步

非阻塞同步是一种基于冲突检测的乐观并发策略。通常可以使用 CAS 来完成操作。


大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

3. 无同步方案

如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

3)锁优化

HotSpot 虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如 适应性自旋锁消除锁粗化轻量级锁偏向锁




这篇咱们主要是针对 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》 下半部分做了相关的读书笔记。请读者慢慢阅读,转化成自己的知识~!👨💻


不要空谈,不要贪懒,和小菜一起做个 吹着牛 X 做架构 的程序猿吧~点个关注做个伴,让小菜不再孤单。咱们下文见!


👀 今天的你多努力一点,明天的你就能少说一句求人的话!

👉🏻 微信公众号:菜农曰,没关注的同学们记得关注哦!

发布于: 刚刚阅读数: 4
用户头像

菜农曰

关注

还未添加个人签名 2021.11.24 加入

还未添加个人简介

评论

发布
暂无评论
读书笔记之《深入理解Java虚拟机:JVM高级特性与最佳实践》(下)_Java_菜农曰_InfoQ写作社区