【深入理解Java虚拟机】笔记

走近Java

JDK版本号

只有程序员内部使用的开发版本号才继续沿用1.5、1.6、1.7的版本号,而公开版本号则改为JDK5、JDK6、JDK7的命名方式

JDK(Java Development Kit):Java程序设计语言、Java虚拟机、Java API类库这三部分统称为JDK

  • JDK是用于支持Java程序开发的最小环境

  • 可以把Java API类库中的Java SE API子集和Java虚拟机这两部分统称为JRE(Java Runtime Environment),JRE是支持Java程序运行的标准环境

Java技术体系分为4个平台

  • Java Card:java小程序、小内存设备上的平台

  • Java ME:移动端

  • Java SE:支持面向桌面级应用

  • Java EE:支持使用多层架构的企业应用

Java发展史

1996 JDK1 —— 1998 JDK1.2 —— 2000 JDK1.3 —— 2002 JDK1.4 —— 2004 JDK1.5 —— 2006 JDK1.6 2011 JDK1.7 —— 2013 JDK1.8

Java虚拟机

HotSpot VM

是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机,后被Oracle收购

开源JDK:OpenJDK

OpenJDK 7 和Oracle JDK 7在程序上是非常接近的,两者共用了大量相同的代码。注意:在构建OpenJDK时,对系统的最后一点要求就是所有的文件,包括源码和依赖项目,都不要放在包含中文的目录里面,可能会出错

调试

在调试Java代码执行时,如果要跟踪具体Java代码在虚拟机中是如何执行的,而虚拟机都采用模板解释器来执行字节码,最终执行的汇编代码都是运行期间产生的,无法直接设置断点,因此需要通过设置-XX:StopInterpreterAt=,当遇到的字节码指令时,会中断程序执行,进入断点调式

Java内存区域与内存溢出异常

知识点

  • Java虚拟机拥有自动内存管理的机制

Java运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间。

image

程序计数器:是一块较小的内存空间,是当前线程所执行的字节码的行号指示器

  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、线程恢复等基础功能都需要依赖这个计数器来完成

  • 由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,未来线程切换后都恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,属于线程私有

疑问:既然多线程是通过线程切换来实现的,那多线程是如何提高速度的?

一个cpu可以多线程,但是一个单核的cpu任何时间点,都只能在做一个任务。而程序时间大多花在读取数据(IO)上,真正的计算工作花时间还是相对少的,因此cpu很大时间表现都很闲。现实中的CPU在大部分时候的闲置状态的。因此,开多线程能提高效率不如说成是充分利用了CPU执行时间

Java虚拟机栈:是Java方法执行的内存模型

  • 每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中的出栈入栈过程

  • 局部变量表存放了编译期可知的各种基本数据类型、对象引用类型

本地方法栈:与虚拟机栈类似

  • 区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务

Java堆:被所有线程共享的一块内存区域,主要用于存放对象实例

  • 内存回收,java堆是垃圾收集器管理的主要区域,收集器基本都采用分代收集算法:新生代、老年代

  • java堆可以处于物理上不连续的内存空间中

方法区:(永久代)是java堆的一部分,用于存储已被虚拟机加载的类信息、常量、静态变量等

运行时常量池:是方法区的一部分,用于存储Class文件中包含的常量池信息,存放编译期生成的各种字面量和符号引用

  • 具有动态性,可以存放运行期间新生成的常量

直接内存:堆外内存,某些场景可以调用使用提高性能

Java堆与内存

对象在内存中存储的布局可以分为3块区域:对象头(包括Mark Word和指针)、实例数据、对齐填充

  • 对象的创建,先去检查常量池中能否找到这个类的符号引用,找不到则加载类,找到则引用,并在java堆中根据分配方式分配对象的内存空间,并把对象信息存入对象头中

  • 对象的访问(句柄和直接指针),句柄是java堆中有个句柄池存放指向实例数据地址的句柄,直接指针则直接指向实例数据地址,各有好处

  • 内存泄漏:泄漏对象通过路径与GC Roots的引用链关联导致垃圾收集器无法回收它们

  • 内存溢出:内存对象确实存活,达到堆最大空间而溢出

垃圾收集器与内存分配策略

垃圾收集器(Garbage Collection,GC)

程序计数器、虚拟机栈、本地方法栈随线程生而灭,不需要考虑回收,而堆中的内存,是需要垃圾收集器回收的

如何判断对象实例已经死亡

  • 引用计数算法:使用+1,失效-1,存在对象之间相互循环引用的问题

  • 可达性分析算法:通过一系列称为GC Roots的对象作为起始点,向下搜索对象现成引用链,当一个对象没有到GC Roots的引用链时就是失效的

  • 引用:强引用、软引用、弱引用、虚引用,根据强度不同,垃圾收集器对它们的回收策略有所差异

  • 两次标记才死亡:第一次标记后筛选是否有必要执行finalize()方法,若有必要则进入F-Queue队列中,等待第二次标记,通过执行finalize()方法逃脱死亡,而任何一个对象的finalize()方法只能被调用一次。

  • 回收方法区(永久代):效率低,很难回收。特别是回收无用的类,1、所有实例被回收,2、加载该类的ClassLoader被回收,3、Class对象没有被引用

垃圾收集算法

  • 标记-清除算法:先标记后清除,效率低,易产生大量不连续的内存碎片

  • 复制算法:根据8:1:1分成Eden空间和两个Survivor空间,每次使用Eden和一个Survivor,清理后将还存活的复制到另一个Survivor中,再清理Eden和Survivor中的数据,提高利用率

  • 标记-整理算法:复制算法对于对象存活率较高的情况下就会效率很低,老年代一般采用先标记再整理的算法,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

  • 分代收集算法:主流都采用这种,先把内存划分几块,新生代、老年代,根据各个年代的特点采用最适当的收集算法

HotSpot虚拟机的算法实现

  • 枚举根节点:可达性分析对执行时间的敏感体现在GC停顿,为了分析工作而确保一致性的快照,为了减少停顿时长引入了OopMap的数据结构来快速找出对象引用的相关信息

  • 在OopMap的协助下可以快速准确的完成GC Roots枚举,而OopMap的产生也需要额外的空间成本,因此只在特定的位置记录这些信息,称为安全点,程序执行到安全点才停下来GC,标准是否具有让程序长时间执行的特征:循环、调用等。这里有抢先式中断:先把所有线程都中断,再判断是否到安全点,没到就回复线程直到安全点,主动式中断:GC时,设置一个中断标记,各个线程轮询访问,直到线程到安全点,现在都采用这个

  • 安全区域:引用关系不发生变化的代码片段之中,任意地方开始GC都是安全的

垃圾收集器:算法的具体实现

没有万能的收集器,只有对具体应用场景最合适的收集器

  • Serial收集器、ParNew收集器、Parallel Scavenge收集器、Serial Old收集器、Paralled Old收集器

  • CMS收集器:并发标记-重新标记,采用这种方式可以获取最短回收停顿时间

  • G1收集器:并发、分代、空间整合、可预测的停顿(优势),并将整个java堆分成多个大小相等的独立区域,并分析优先级

Full代表这次GC发生了Stop-The-World,可以调用System.gc()方法触发Full GC
GC日志中的3324K->152K(3712K)代表GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)

内存分配策略

  • 新生代GC(Minor GC):是指发生在新生代的垃圾收集动作,频繁,快速

  • 老年代GC(Major GC/Full GC):是指发生在老年代的GC动作,慢

  • 大对象直接进入老年代,可以设置临界值

  • 长期存活的对象进入老年代,通过Minor GC若存活则Age计数器增加,直到进入老年代

  • 空间分配担保:Minor GC之前虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,若小于则判断是否允许老年代担保,允许则判断是否大于老年代对象的平均水平,大于则Minor GC,小于则Full GC

虚拟机性能监控与故障处理工具

这里涉及到的数据包括:运行日志、异常堆栈、GC日志、线程快照、堆转储快照

JDK命令行工具

JDK的bin目录下的java.exe和javac.exe,提供各种各样的监控分析工具

  • jps:虚拟机进程状况工具

  • jstat:虚拟机统计信息监视工具

  • jinfo:java配置信息工具

  • jmap:java内存映像工具,生成堆转储快照

  • jhat:虚拟机堆转储快照分析工具

  • jstack:java堆栈跟踪工具,生成线程快照

还有HSDIS,JIT生成代码反汇编的插件

JDK可视化工具

  • JConsole:Java监视与管理控制台

  • VisualVM:多合一故障处理工具,生成快照,分析性能等

调优案例分析

案例分析

高性能硬件上的程序部署策略

针对java堆内存有12G的情况下,GC一次停顿时间太长,因此采用若干个32位虚拟机建立每个内存2G的负载均衡集群,再使用适合的CMS收集器进行垃圾回收

堆外内存导致的溢出错误

除了Java堆和永久代之外,还有这些区域会占用到较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制:Direct Memory、线程堆栈、Socket缓存区、JNI代码、虚拟机和GC

不恰当数据结构导致内存占用过大

根本原因是用HashMap<Long,Long>结构来存储数据文件空间效率太低

由Windows虚拟内存导致的长时间停顿

GUI程序在最小化后,资源管理中显示的占有内存大幅度减小,但虚拟内存则没有变化,原因是最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发送GC时就有可能因为恢复页面文件的操作而导致不正常GC停顿,通过设置参数-Dsun.awt.keepWorkingSetOnMinimize=true来解决

Eclipse运行速度调优

  • 升级JDK版本,可能会有隐藏的配置导致内存溢出的问题

  • Eclipse启动时,Full GC大多数是由于老年代容量扩展而导致的,由永久代扩展导致的也有,为了避免扩展所带来的性能浪费,把老年代和永久代的容量固定下来

  • 使用CMS并行的收集器达到老年代最快的GC

类文件结构

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式【字节码】实现语言无关性的基础仍然是虚拟机和字节码存储格式。java虚拟机不和包括java在内的任何语言绑定,它只与Class文件这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。基于安全方面的考虑,java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束,但任何一门功能性语言都可以表示为一个能被java虚拟机所接受的有效的Class文件。

java程序(.java)-> javac编译器 -> 字节码(.class)-> java虚拟机

Class类文件的结构

任何一个Class文件都对应着唯一一个类或接口的定义信息,Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在Class文件之中,中间没有添加任何分隔符

Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是java技术体系实现平台无关、语言无关两项特性的重要支柱

伪结构:无符号数和表

  • 无符号数:属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值

  • 表是由多个无符号数或其他表构成的复合数据类型,一般以“_info”结尾,整个Class文件本质上就是一张表

可以使用一个前置的容量计数器来描述同一类型但数量不定的多个数据

魔数与Class文件的版本

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

  • 紧接着的4个字节存储的是Class文件的版本号,5、6次版本号,7、8主版本号

常量池

紧接着版本的常量池入口,入口放置一项u2类型的数据,代表常量池容量计数值,计数值从1开始

  • 主要存放两大类常量:字面量、符号引用`(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)

运行期转换,当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址中。

  • 常量池中的每一项常量都是一个表,现有14种结构不同的表数据结构(Utf-8、Integer、Long、String、Methodref等)

访问标志

紧接着常量池的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否public类型;是否定义为abstract类型等

类索引、父类索引与接口索引集合

Class文件中由着三项数据来确定这个类的继承关系

字段表集合

字段表用于描述接口或者类中声明的变量。字段包括类级变量以及实例级变量

  • 字段的信息包括:作用域、可变性final等等,各个修饰符都可以用布尔值表示,适合用标志位来表示。而字段名叫什么名字、被定义为什么类型则无法固定,只能引用常量池中的常量来描述

  • 对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组,将被记录为“[[Ljava/lang/String;”,

方法表集合

方法表的结构如同字段表一样,包括了访问标志、名称索引、描述符索引、属性表集合

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

  • 重载方法,要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数再常量池中的字段符号引用的集合,注意:返回值不会包含在特征签名中

属性表集合

在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息

  • 对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示

  • Code属性:java程序方法体中的代码经过javac编译器处理后,最终变为字节码指令存储在Code属性内

  • Slot是虚拟机为局部变量分配内存所使用的最小单位,长度不超过32位的数据类型,每个局部变量占用1个Slot

  • 在字节码指令之后的是这个方法的显式异常处理表集合,是java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现java异常及finally处理机制

  • ConstantValue属性:如果同时使用final和static修饰的变量用ConstantValue来进行初始化,如果只使用static则在方法中进行初始化,非static变量时再实例构造器方法中进行初始化

注意:final、static、static final修饰的字段赋值的区别

1、static修饰的字段在加载过程中准备阶段被初始化,但是这个阶段只会赋值一个默认的值(0或者null而并非定义变量设置的值)初始化阶段在类构造器中才会赋值为变量定义的值。
2、final修饰的字段在运行时被初始化,可以直接赋值,也可以在实例构造器中赋值,赋值后不可修改。
3、static final修饰的字段在javac编译时生成comstantValue属性,在类加载的准备阶段直接把constantValue的值赋给该字段。可以理解为在编译期即把结果放入了常量池中

字节码指令

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

  • 一个字节长度,意味着指令集的操作码总数不可能超过256条,因此并非每一种数据类型和每一种操作都有对应指令

  • 在java虚拟机的指令集中,大多数指令都包含了其操作所对应的数据类型信息,i代表int,d代表double,a代表reference

  • 大部分指令都没有支持整数类型byte、char、short和boolean,但是编译器会在编译期或运行期将byte和short类型的数据带符号扩展为相应的int类型数据

加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输

  • 将一个局部变量加载到操作栈:iload 等

  • 将一个数值从操作数栈存储到局部变量表:istore 等

  • 将一个常量加载到操作数栈:bipush 等

运算指令

运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作数栈。可分为对整型数据进行的运算指令、对浮点型数据进行的运算指令

加法 iadd、减法 isub、乘法 imul、除法 idiv、求余 irem、取反 ineg 等

  • 当一个操作产生溢出时,将会使用有符号的无穷大来表示,即NaN

类型转换指令

  • 小范围类型转大范围类型:直接转(无需命令)

  • 大范围类型转小范围类型:使转换符 i2b、i2c 等

对象创建与访问指令

  • 创建对象:new ,创建数组:newarray

  • 访问类字段:getfield

  • 检查类实例类型的指令:instanceof

操作数栈管理指令

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2

  • 将栈顶的两个数值交互:swap

控制转移指令

  • 条件分支:ifeq、ifnull 等

  • 复合条件分支:tableswitch、lookupswitch 等

  • 无条件分支:goto

方法调用

  • invokevirtual、invokeinterface、invokestatic 等

异常处理指令

  • java中的throw语句:athrow

同步指令

同步一段指令集序列通常是由java语言中的synchronized语句块来表示的,java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义

虚拟机类加载机制

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

类加载的过程

类加载的生命周期包括:加载、连接【验证、准备、解析】、初始化、使用和卸载

加载交由虚拟机实现,而需要执行初始化阶段,严格规定了以下5种主动引用的情况

  • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时

  • 使用java.lang.reflect包的方法对类进行反射调用

  • 当初始化一个类的时候,若父类未进行过初始化,则先初始化父类

  • 当虚拟机启动时,用户需要指定一个要执行的主类(main)

  • MethodHandle实例具有的方法句柄所对应的类没有初始化,需先初始化

有且只有这5种情况是主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用
接口与类的区别是第3种情况下,接口的初始化并不要求其父接口全部都完成初始化,只有在真正使用到父接口的时候才会初始化

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

  • 通过类名获取定义此类的二进制字节流

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

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

验证:为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求

文件格式验证、元数据验证、字节码验证、符号引用验证

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

这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。这里说的初始化指的是数据类型的零值,例如

1
publci static int value = 123;

这里初始值为0,而不是123,赋值123的动作将在初始化阶段才会执行,特殊情况(ConstantValue),即设置了final,则在准备阶段就赋值为123

解析:将常量池内的符号引用替换为直接引用的过程

  • 符号引用:以一组符号来描述所引用的目标

  • 直接引用:是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄

  • 在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直是成功的。invokedynamic指令不具有这样的规则,因为它是用于动态语言支持

  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行

初始化:类初始化阶段是类加载过程的最后一步,执行类中定义的初始化值

  • 此时执行的是\()方法,它是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量,在前面的静态语句块可以赋值,但不能访问

  • 虚拟机会保证在子类的\()方法执行之前,父类的\()方法已经执行完毕,因此在虚拟机中第一个被执行的\()方法的类肯定是java.lang.Object

注意:类的构造函数<init>()方法是在new实例的时候初始化的方法

类加载器

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

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否“相等”,只有在这两个类是由同一类加载器加载的前提下才有意义,所谓“相等”包括equals、instanceof、isInstance、isAssignableFrom

双亲委派模型

从虚拟机的角度,类加载器可以分为两类:启动类加载器(虚拟机的一部分)、其他类加载器(虚拟机外,都基础java.lang.ClassLoader)
从开发人员的角度,类加载器可以分为4类:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)、自定义类加载器

  • 层级关系:启动类加载器<—扩展类加载器<—应用程序类加载器<—自定义类加载器

  • 双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层都是如此,因此所有的加载请求最终都会传到顶层的启动类加载器,只有当父加载器无法加载时,子加载器才会尝试去加载

通过这种双亲委派模型,才会避免同一个类肯定是有同一个父加载器加载,从而是同一个类

虚拟机字节码执行引擎

执行引擎是java虚拟机最核心的组成部分之一,执行引擎在执行java代码的时候可能会有解释执行(通过解释器执行)编译执行(通过即时编译器产生本地代码执行),输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果

栈帧:运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。每个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。每个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,执行引擎运行的所有字节码指令都是只针对当前栈帧进行操作

局部变量表:用于存放方法参数和方法内部定义的局部变量

  • 局部变量表的容量以变量槽Slot为最小单位,Slot可以被复用

  • 在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数

  • 局部变量没有在初始化阶段被赋值,因此没有默认值,需要java中赋予初始值

操作数栈:在方法的执行过程中,会有各种字节码指令往操作数栈中出栈和入栈,是后入先出栈

java虚拟机的解释执行引擎称为“基于栈的执行引擎”,这里的栈就是操作数栈

动态连接

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

方法返回地址

一个方法执行完成后,会退出到调用方法的地址(包括:正常完成退出、异常完成出口)

方法调用

方法调用不等于方法执行,在Class文件中存储的都只是符号引用,使得Java方法的调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。方法调用分为解析分派

解析:调用目标在程序代码写好、编译器进行编译时就必须确定下来

编译期可知,运行期不可变,符合这个条件的有:静态方法、私有方法、实例构造器、父类方法

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用

分派

java面向对象的3个特性:继承、封装和多态,分派调用即是多态性特征的体现

  • 静态分派:所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载
1
2
3
4
5
6
7
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
StaticDispatch sr = new StaticDispatch();
sr.sayHello((Man)man);
sr.sayHello((Woman)man)

“Human”称为变量的静态类型,Man为变量的实际类型。变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么,在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本

  • 动态分派:运行期根据实际类型确定方法执行版本,动态分派的典型应用是方法重写
1
2
3
4
Human man = new Man();
Human woman = new Woman();
man.sayHello(); //man say hello
woman.sayHello(); //woman say hello

指令在运行时解析过程入下:1、先找到对象的实际类型,记作C,2、在类型C中找到相符的方法,找到则直接返回,找不到去父类找。

方法重载:是指同一个类中的多个方法具有相同的名字,但这些方法具有不同的参数列表,即参数的数量或参数类型不能完全相同

方法重写:是存在子父类之间的,子类定义的方法与父类中的方法具有相同的方法名字,相同的参数表和相同的返回类型

  • 动态分派是非常频繁的动作,因此使用虚方法表索引来代替元数据查找以提高性能

动态类型语言支持

  • 动态类型语言:它的类型检查的主体过程是在运行期而不是编译期,变量无类型而变量值才有类型

  • java.lang.invoke包:这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle

Reflection MethodHandle
是在模拟Java代码层次的方法调用 是在模拟字节码层次的方法调用
重量级 轻量级
只为Java语言服务 可服务于所有Java虚拟机之上的语言
  • invokedynamic指令:与MethodHandle类似,把如何查找目标方法的决定权从虚拟机转嫁到用户代码中

基于栈的字节码解释执行引擎

解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)

编译执行

解释:程序源码 —> 语法分析 —> 抽象语法树 —> 指令流 —> 解释器 —> 解释执行

编译:程序源码 —> 语法分析 —> 抽象语法树 —> 优化器 —> 生成器 —> 目标代码

基于栈的指令集与基于寄存器的指令集

基于栈的指令集主要优点就是可移植
基于寄存器的指令集主要优点是性能好,速度快

类加载及执行子系统的案例与实战

Tomcat:正统的类加载器架构

主流的Java Web服务器都实现了自己定义的类加载器,并根据目录去区分加载的目标,同样按照双亲委派模型来实现

OSGi:灵活的类加载器架构

是基于Java语言的动态模块化规范,在OSGi里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库的可见性能得到非常精准的控制,一个模块里只有被Export过的Package才可能被外界访问,其他的Package和Class将会隐藏起来。OSGi的模块类加载器之间只有规则,没有固定的委派关系,已经发展成了一种更为复杂的、运行时才能确定的网状结构

字节码生成技术与动态代理的实现

动态指的是针对使用Java代码实际编写了代理类的“静态”而言的,实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用与不同的应用场景中

早期(编译期)优化

Java语言的“编译期”可能指的是前端编译器、JIT编译器、AOT编译器

  • 前端编译器:把*.java文件转变成*.class文件的过程,例如Sun的javac编译器

  • JIT编译器(虚拟机的后端运行期编译器):把字节码转变成机器码的过程

  • AOT编译器(静态提前编译器):直接把*.java文件编译成本地机器码的过程

javac编译器

编译过程分为如下3个过程

  • 解析与填充符号表过程:词法分析(生成标记)、语法分析(生成语法树),把分析出来的符号与值存入表格中

  • 插入式注解处理器的注解处理过程:可以在编译期对注解进行处理,修改语法树等

  • 分析与字节码生成过程:分析、审查语法树的结构

生成过程:标注检查(常量折叠)、数据及控制流分析、解语法糖(虚拟机运行时不支持这些语法,在编译阶段还原为基础语法结构)、字节码生成

Java语法糖的味道

为了便于程序员开发,带来的便捷或隐藏的功能

泛型与类型擦除

  • 泛型:本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别被称为泛型类、泛型接口和泛型方法

  • Java语言中的泛型只在程序的源码中存在,在编译后的字节码文件中,就被擦除为原生类型

因此两个static方法名返回值相同,参数为List\<A> a和List\<B> a被编译后擦除后相同了就无法编译成功了
引入了Signature属性,这个属性保存的参数类型并不是原生类型,因此可以通过反射手段取得参数化的类型

自动装箱、拆箱与遍历循环

条件编译

只有条件为常量的if语句才会被编译器将不成立的代码块消除掉

晚期(运行期)优化

即时编译器(JIT编译器):在运行时,虚拟机会把这些热点代码编译成与本地平台相关的机器码

解释器与编译器

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。可以使用解释执行节约内存,使用编译执行来提升效率

为了在程序启动响应速度与运行效率之间达到最佳平衡,虚拟机会逐渐启用分层编译的策略

编译对象与触发条件

在运行过程中会被即时编译器编译的“热点代码”有两类:被多次调用的方法、被多次执行的循环体。一般采用基于计数器的热点探测方法:方法调用计数器(会有阈值,周期性衰减)、回边计数器

编译过程

虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。后台执行编译的过程,Server Compiler和Client Compiler的编译过程不一样

  • Server Compiler:三段式编译器,主要关注局部性优化,第一阶段生成一种高级中间代码表示,第二阶段生成低级中间代码表示,第三阶段扫描生成机器代码,一二阶段会进行代码优化

  • Client Compiler:是充分优化过的高级编译器,采用寄存器、分配器

编译优化技术

虚拟机团队几乎把对代码的所有优化措施都集中在了即时编译器之中,以下几项最有代表性的优化技术:

语言无关的经典优化技术之一:公共子表达式消除

语言相关的经典优化技术之一:数组范围检查消除

最重要的优化技术之一:方法内联

方法内联的重要性要高于其他优化措施,它的主要目的有两个:去除方法调用成本、为其他优化建立基础

由于在编译期解析的方法只有:静态方法、私有方法、实例构造器、父类方法,其他实例方法是虚方法对于一个虚方法,编译期做内联的时候根本无法确定应该使用哪个方法版本。编译器在进行内联时,如果是非虚方法,那么可以直接进行内联,如果是虚方法,则会通过CHA(类型继承关系分析)找出方法是否有多个目标版本可供选择,如果只有一个版本,那也可以进行内联。如果有多个版本,采用内联缓存,调用之前缓存为空,第一次调用则缓存当前接收版本,后面调用若一样则沿用,否则重新缓存。

最前沿的优化技术之一:逃逸分析

当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸,还可能被外部线程访问到,称为线程逃逸,能证明一个对象不会逃逸到方法或线程之外,则可以进行如下一些高效优化:栈上分配、同步消除、标量替换

Java与C/C++的编译器对比

Java与C/C++的编译器的对比实际上代表了最经典的即时编译器与静态编译器的对比,java即时编译器的劣势:

  • 占用用户程序的运行时间

  • java语言是动态扩展语言,运行时加载新类

  • 使用虚方法的频率高

  • 对象的内存分配在堆上,只有方法的局部变量是在栈上的

Java语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些“拖后腿”的特性都是为了Java语言的开发效率做出了很大贡献

Java内存模型与线程

衡量一个服务性能的高低好坏,每秒事务处理数(TPS)是最重要的指标之一,TPS值与程序的并发能力又有非常密切的关系

现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲,因此引入新的问题:缓存一致性,则需要各个处理器访问缓存时都遵循一些协议

处理器、高速缓存和主内存的关系如下:

image

内存模型

Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存中,每条线程有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,线程之间的变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下:

image

从更低层次上说,主内存就直接应对于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存

内存间交互操作

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

  • lock(锁定)

  • unlock(解锁)

  • read(读取)

  • load(载入)

  • use(使用)

  • assign(赋值)

  • store(存储)

  • write(写入)

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

对volatile型变量的特殊规则:最轻量级的同步机制

当一个变量定义为volatile后,它将具备【可见性】和【禁止指令重排序优化】,我们从volatile和锁之间的选择的唯一凭据仅仅是volatile的语义能否民主使用场景的需求

  • 可见性

保证此变量对所有线程的可见性,是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的

volatile变量在各个线程的工作内存中不存在一致性问题,但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的(例如i++操作,并非原子操作,所以使用volatile任然得不到正确的结果)

因此不符合以下规则的运算场景任然需要通过加锁保证原子性:运算结果不依赖变量的值,或者能够确保只有单一的线程修改变量的值

特别适合当一个线程停止时,其他也停止的操作

  • 禁止指令重排序优化

并发操作中,指令在运行时可能会因为优化提前执行,而导致程序运行错误,而volatile关键字则可以避免此类情况的发生。volatile修饰的变量通过在指令后增加了一个内存屏障,来防止指令排序错乱`

总结:内存模型对volatile变量定义的特殊规则是:

1、每次执行这个变量前要先从主内存刷新得到最新值
2、每次修改这个变量后都必须立刻同步回主内存中
3、被volatile修饰的变量不会被指令重排序优化

原子性、可见性与有序性

  • 原子性:我们大致可以认为基本数据类型的访问读写是具备原子性的。大范围的原子性:synchronized

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

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

先行发生原则

Java语言中有一个“先行发生”的原则,这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,若操作A先行发生于操作B,则操作A会影响操作B的结果

下列是Java内存模型中的“天然的”先行发生关系:

  • 程序次序规则

  • 管程锁定规则

  • volatile变量规则

  • 线程启动规则

  • 线程终止规则

  • 线程中断规则

  • 对象终结规则

  • 传递性:A先B,B先C,则A先C

注意:时间先后顺序与先行发生原则之间基本没有太大的关系(典型的例子:指令重排序)

Java与线程

线程的实现

  • 使用内核线程实现:直接由操作系统内核支持的线程,通过操纵调度器对线程进行调度,一般调用的是内核的高级接口轻量级进程,就是我们所谓的线程,但是调度需要在用户态和内核态之间切换

  • 使用用户线程实现:完全建立在用户空间的线程库上,用户线程的建立、同步、销毁和调度完全在用户态中完成,所有操作都是用户程序自己处理,因此程序会比较复杂,现在很少人用

  • 使用用户线程加轻量级进程混合实现:用户线程的系统调用要通过轻量级进程来完成

  • Java线程的实现:操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致

Java线程调度:线程调度是指系统为线程分配处理器使用权的过程

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

  • 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定,Java使用的就是抢占式,还可以给系统建议,通过给线程设置优先级,

线程状态

  • 新建(New)

  • 运行(Runable):包括了操作系统线程状态中的Running和Ready

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

  • 限期等待(Timed Waiting):在一定时间之后它们会由系统自动唤醒

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

  • 结束(Terminated)

线程安全与优化锁

线程安全

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

java语言中的线程安全

  • 不可变(final)

  • 绝对线程安全(方法内部用了同步,调用时可能还需要同步才能保证绝对线程安全)

  • 相对线程安全(独立调用时安全的,相关线程安全类)

  • 线程兼容(本身不安全,可以通过同步调用手段保证安全)

  • 线程对立(无法并发)

线程安全的实现方法

  • 互斥同步

synchronized:通过monitorexit获取对象的锁,这里唤醒其他线程需要操作系统来帮忙完成,所以synchronized是Java语言中一个重量级的操作,有经验的程序员都会在确实必要的情况下才使用这种操作

ReentrantLock:和synchronized很相似,不过是通过lock()和unlock()方法配合try/finally语句块来完成的,它有三个高级特性:等待可中断、公平锁(按照时间顺序来获取锁)、锁绑定多个条件(绑定多个Condition对象)

提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步

  • 非阻塞同步

先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果产生了冲突就采取其他补偿措施(例如不断重试),为保证原子操作,compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。例子中使用AtomicInteger替代int

  • 无同步方案:可重入代码、线程本地存储

如果一个变量要被某个线程独享,可以使用java.lang.ThreadLocal类来实现线程本地存储的功能,每一个线程的Thread对象中都有一个ThreadLocalMap对象

锁优化

  • 自旋锁与自适应自旋:因为挂起线程和恢复线程的操作都需要转入内核态中完成,会有消耗,如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,可以让后面请求锁的线程忙循环(自旋),这就是所谓的自旋锁,自旋的次数有限制,而自适应带来的就是自旋的时间不再固定,由前一次自旋时间来决定

  • 锁消除:虚拟机即时编译时,检测到不可能存在共享数据竞争的锁进行消除

  • 锁粗化:对于没有必要的反复加锁解锁,虚拟机会把加锁同步的范围扩展到整个操作序列的外部

  • 轻量级锁:虚拟机的对象头中第一部分用于存储对象自身的运行时数据(Mark Word),虚拟机将使用CAS操作尝试将对象的MarkWord更新为指向Lock Record的指针,如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,若更新失败了,判断是否拥有当前锁,拥有则进入,否则这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那将膨胀为重量级锁

  • 偏向锁:如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。这个锁会偏向一个获得它的线程

参考文献

  • 【深入理解Java虚拟机】书籍