💡 学而不思则罔,思而不学则殆。 —— 孔子 👉 微信公众号已开启,菜农曰,没关注的同学们记得关注哦!
本篇带来的是周志明老师编写的《深入理解Java虚拟机:JVM高级特性与最佳实践》,十分硬核!
全书共分为 5 部分,围绕内存管理、执行子系统、程序编译与优化、高效并发等核心主题对JVM进行了全面而深入的分析,深刻揭示了JVM工作原理。
全书整体5个部分,十三章,共 358929 字。整体结构相当清晰,以至于写读书笔记的时候无从摘抄(甚至想把全书复述一遍),以下是全书第三部分的内容,望读者细细品尝!
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步
计算机只认识 0 和 1,所以我们写的程序需要经编译器翻译成由 0 和 1 构成的二进制格式才能由计算机执行。
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式 — 字节码(ByteCode)是构成平台无关性的即时。
任何一个Class 文件都对应着唯一一个类或接口的定义信息,但反过来,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数据。
Class 文件格式采用一种类似C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:
_info
结尾。每个 Class 文件的头4个字节称为 魔数(0xCAFEBABE),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。
紧接着魔数的4个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本好,第 7 和第 8 个字节是主版本号。
Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加 1
主次版本号之后便是常量池的入口。
常量池中常量的数量是不固定的,所以在常量池的入口会放置一项 u2 类型的数据,代表常量池容量计数值。
常量池容量(偏移地址:0x00000008)为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。
常量池主要存放两大类常量:字面量 和 符号引用
符号引用包括了三类常量:
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这两个标志用于识别一些类或接口层次的访问信息
访问标志
类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据集合,Class 文件中由这三项数据来确定这个类的继承关系。
类索引、父类索引、接口索引集合
从偏移地址0x000000F1
开始的3个u2类型的值分别为0x0001、0x0003、0x0000,也就是类索引为1,父类索引为3,接口索引集合大小为0,然后通过javap命令计算出来的常量池,找出对应的类和父类的常量
字段表用于描述接口或类中声明的变量。
字段访问标志
相对于全限定名和简单名称来说,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表。
描述符表示字符含义
对于数组类型,每一维度将使用一个前置的“[”字符来描述
方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。
而方法里面的代码,经过编译器编译器编译成字节码指令后,存放在方法属性表集合中一个名为 Code 的属性里,属性表作为 Class 文件格式中最具扩展性的一种数据项目。
如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器 <clinit> 方法和实例构造器 <init> 方法。
属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格顺序,只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息。
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多
个代表此操作所需参数(称为操作数,Operands)而构成。
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading) 7个阶段。
类的生命周期
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)
初始化的时机:
在加载阶段,虚拟机需要完成以下 3 件事情:
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Class 文件并不一定要求用 Java 源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生 Class 文件。
验证阶段会完成 4 个阶段的检验动作:
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储在方法区之中,是基于二进制字节流进行的。
该阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。主要目的是对类的元数据进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。
该阶段的主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。
该阶段的主要目的是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,确保解析动作能够正常执行。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
解析阶段是虚拟机将常量池的符号引用替换成直接引用的过程。
类初始化阶段是类加载过程的最后一步。初始化阶段是执行类构造器 <clinit> 方法的过程。
类加载阶段中 通过一个类的全限定名来获取描述此类的二进制字节流 这个动作放到了 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为 类加载器
每一个类加载都拥有一个独立的类名称空间。比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
类加载器可以划分为 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)关系来复用父加载器的代码。
双亲委派模型的工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
一个线程方法中的调用链可能会很长,对于执行引擎来说,只有位于栈顶的栈帧才是有效的,称为 当前栈帧
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽 为最小单位。
操作数栈也常称为操作栈,是一个后入先出栈。操作数栈的最大深度会在编译的时候写入到 Code 属性的 max_stacks 数据项中。
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全独立的。但在大多数虚拟机的实现中会有一部分优化重叠。这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
栈帧之间的数据共享
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
停止方法的运行有两种方式:
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。
在编译时期,一切方法调用在 Class 文件里面存储的都只是符号引用,只有在类加载期间,甚至到运行期间才能确定目标方法的直接引用
解析
在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,而这种解析成立的条件为:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。
从计算机程序出现的第一天起,对效率的追求就是程序天生的坚定信仰,这个过程犹如一场没有终点、永不停歇的F1方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车
Javac 编译器本身就是一个由Java 语言编写的程序
编译过程大致可以分为 3 个过程,分别是:
Javac 编译过程
Javac 编译动作的入口是 com.sun.tools.javac.main.JavaCompiler 类
解析:
解析步骤包括了经典程序编译原理中的 词法分析 和 语法分析 两个过程
经过这个步骤之后,编译器基本就不会再对源代码进行操作了,后续的操作都建立在抽象语法树上。
填充符号表:
填充符号表的动作由 enterTrees() 方法实现。
符号表就是由一组符号地址和符号信息构成的表格,其中所登记的信息在编译的不同阶段都要用到。
填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,此过程的出口是一个待处理列表(To Do List),包含了
每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点
Java 语言提供了对注解的支持,这些注解与普通的 Java 代码一样,是在运行期间发挥作用的。
在上述步骤结束后,可以得到一个抽象语法树,但是无法保证源程序是符合逻辑的,语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查。
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐开始发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存限制较大(如嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。
HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Compiler 和 Server Compiler,简称为 C1编译器 和 C2 编译器。用户可以使用 -client 或 -server 来指定运行在 Client 模式还是 Server 模式
为了在程序启动响应速度与运行速度之间达到最佳平衡,引入了 分层编译
在运行过程中会被即时编译器的 热点代码 有两种:
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为 热点探测
回边计数器:作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边。建立回边计数器统计的目的就是为了触发 OSR 编译。
方法调用计数器
回边计数器
优化技术总览
如果一个表达式E已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。
为了安全,数组边界检查是必须要做的,但不是在每一次运行期间都会进行检查。
方法内联的行为是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。为了解决 Java 中虚方法内联的问题,引入了一种名为 "类型继承关系分析(CHA)" 的 技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否为抽象类等信息。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,称为线程逃逸。
要证明一个对象不会逃逸到方法或线程之外,需要对这个变量作一些优化:
并发处理的广泛应用是使得 Amdahl 定律代替摩尔定律称为计算机性能发展原动力的根本原因,也是人类压榨计算机运算能力的最有力的武器
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,因此在读写时要根据协议来操作,如 MSI、MESI、MOSI等
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。
Java 内存模型规定了在执行上述 8 种基本操作时需满足的以下规则:
Java 内存模型是围绕着在并发过程中如何处理 原子性、可见性和有序性 这三个特征建立的
1. 原子性
Java 内存模型的 read、load、assign、use、store 和 write 可以直接保证原子性变量操作。
2. 可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。除了 volatile 之外,在 Java 中还可以通过 synchronize 和 final 来保证可见性。
3. 有序性
如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的。
前半句是指:线程内表现为串行的语义,后半句是指:指令重排序现象和工作内存与主内存同步延迟现象
各个线程之间既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是 CPU 调度的基本单位)
实现线程的主要有 3 种方式:
直接由操作系统内核支持的线程,这种线程由内核来完成线程切换。程序一般不会直接取使用内核线程,而是去使用内核线程的一种高级接口— 轻量级进程 LWP = 线程
局限性:
由于基于内核线程实现,各种线程间的操作(创建、析构及同步)都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换。
一个线程只要不是内核线程,就可以认为是用户线程。轻量级进程也属于用户线程。用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态上完成,不需要内核的帮助。
局限性:
没有系统内核的支援,线程的所有操作都需要用户程序自己处理,在 阻塞、调度之类的问题处理起来会异常困难
在这种混合实现下,既存在用户线程、也存在轻量级进程。用户进程完全是建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度 和 抢占式线程调度
线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。
特点:实现简单,但线程执行的时间不可控制,如果一个线程编写有问题,就会导致一直阻塞
每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。
特点:线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题。
Java 语言中定义了 5 种线程状态:
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
我们可以将 Java 语言中各种操作共享的数据分为 5 类:
不可变的对象一定是线程安全的。保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为 final,这样在构造函数结束之后,它就是不可变的。
在 Java API 中标注自己是线程安全的类,大多数都不是绝对线程安全的。
相对的线程安全就是我们通常意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。
互斥是方法,同步是目的。最基本的手段就是使用 synchronized 关键字,经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。
除了使用 synchronized 关键字还可以使用 J.U.C 包下的 ReentrantLock 来实现同步。相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。
非阻塞同步是一种基于冲突检测的乐观并发策略。通常可以使用 CAS 来完成操作。
大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
HotSpot虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如 适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等
这篇咱们主要是针对 《深入理解Java虚拟机:JVM高级特性与最佳实践》 下半部分做了相关的读书笔记。请读者慢慢阅读,转化成自己的知识~!👨💻
👀 今天的你多努力一点,明天的你就能少说一句求人的话! 👉🏻 微信公众号:菜农曰,没关注的同学们记得关注哦!