深入理解Java虚拟机::前段编译与优化

前段编译与优化 前端编译器:把 *.java 文件转变成 *.class 文件 即时编译器:运行期把字节码转变成本地机器码 静态的提前编译器:直接把程序编译成与目标机器指令集相关的二进制代码 Javac 编译器 Java 语言实现 编译过程 准备过程:初始化插入式注解处理器。 提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程 我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止 解析与填充符号表 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树 词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素 词法分析过程由 com.sun.tools.javac.parser.Scanner 类来实现。 填充符号表。产生符号地址和符号信息 符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构 符号表中所登记的信息在编译的不同阶段都要被用到。 在 Javac 源代码中,填充符号表的过程由 com.sun.tools.javac.comp.Enter 类实现 该过程的产出物是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级节点, 分析与字节码生成过程 标注检查。对语法的静态信息进行检查。 标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配 顺便进行一个称为常量折叠的代码优化 数据流及控制流分析。对程序动态运行过程进行检查。 数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。 解语法糖。将简化代码编写的语法糖还原为原有的形式。 计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言 Java 中最常见的语法糖包括了前面提到过的泛型、变长参数、自动装箱拆箱,等等 字节码生成。将前面各个步骤所生成的信息转化成字节码。 在 Javac 源码里面由 com.sun.tools.javac.jvm.Gen 类来完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。 完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交到 com.sun.tools.javac.jvm.ClassWriter 类手上,由这个类的 writeClass()方法输出字节码,生成最终的 Class 文件,到此,整个编译过程宣告结束。 语法糖 泛型 泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口 和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。 类型擦除式泛型 Java 语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型了,并且在相应的地方插入了强制 转型代码,因此对于运行期的 Java 语言来说,ArrayList与 ArrayList其实是同一个类型 擦除式泛型的实现几乎只需要在 Javac 编译器上做出改进即可,不需要改动字节码、不需要改动 Java 虚拟机,也保证了以前没有使用泛型的库可以直接运行在 Java 5.0 之上。 擦除法实现泛型直接无法支持原生类型的泛型 运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦,需要传 class 类型才能确定 类型擦除导致无法重载 自动装箱、拆箱与遍历循环 自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法 遍历循环则是把代码还原成了迭代器的实现 条件编译 我不知道作者怎么理解条件编译的,但是他举的内容是编译优化里面的常见的死码消除

2021 Apr 09 · 1 min

深入理解Java虚拟机::类加载及执行子系统的案例与实战

类加载及执行子系统的案例与实战 Tomcat:正统的类加载器架构 必要的服务器功能 部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离 部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以互相共享。 服务器需要尽可能地保证自身的安全不受部署的 Web 应用程序影响 支持 JSP 应用的 Web 服务器,十有八九都需要支持 HotSwap 功能。 为了满足上述需求,在部署 Web 应用时,单独的一个 ClassPath 就不能满足需求了,所以各种 Web 服务器都不约而同地提供了好几个有着不同含义的 ClassPath 路径供用户存放第三方类库,这些路径一般 会以“lib”或“classes”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常每一个目录都会有一个相应的自定义类加载器去加载放置在里面的 Java 类库。 OSGi:灵活的类加载器架构 OSGi 中的每个模块(称为 Bundle)与普通的 Java 类库区别并不太大,两者一般都以 JAR 格式进行封装,并且内部存储的都是 Java 的 Package 和 Class。但是一个 Bundle 可以声明它所依赖的 Package(通过 Import-Package 描述),也可以声明它允许导出发布的 Package(通过 Export-Package 描述)。在 OSGi 里面,Bundle 之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库的可见性能得到非常精确的控制,一个模块里只有被 Export 过的 Package 才可能被外界访问,其他的 Package 和 Class 将会被隐藏起来。 加载规则 以 java.*开头的类,委派给父类加载器加载。 否则,委派列表名单内的类,委派给父类加载器加载。 否则,Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。 否则,查找当前 Bundle 的 Classpath,使用自己的类加载器加载。 否则,查找是否在自己的 Fragment Bundle 中,如果是则委派给 Fragment Bundle 的类加载器加载。 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。 否则,类查找失败。 字节码生成技术与动态代理的实现 省去了编写代理类那一点编码工作量, 实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。 Backport 工具:Java 的时光机器 ASM 框架直接对字节码进行处理,把高版本的字节码编译到更低版本的字节码

2021 Apr 08 · 1 min

深入理解Java虚拟机::虚拟机字节码执行引擎

虚拟机字节码执行引擎 概述 执行引擎是 Java 虚拟机核心的组成部分之一 虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。 运行时栈帧结构 Java 虚拟机以方法作为最基本的执行单元 栈帧是用于支持虚拟机进行方法调用和方法执行背后的数据结构 虚拟机运行时数据区中的虚拟机栈的栈元素 栈帧 局部变量表、操作数栈、动态连接和方法返回地址等信息 每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程 一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式 局部变量表 局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量 局部变量表的容量以变量槽为最小单位 64 位或者 32 位 每个变量槽都应该能存放一个 boolean、 byte、char、short、int、float、reference 或 returnAddress 类型的数据 局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。 当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递, 实例方法, 那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。 变量槽可以复用,但是会影响垃圾回收 把占用了大量内存但是已经用不到的变量设为 null 有助于 gc 的回收,因为在局部变量表中不再有引用 操作数栈 后入先出栈 动态连接 支持方法调用过程中的动态连接 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数 这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接 方法返回地址 当一个方法开始执行后,只有两种方式退出这个方法 遇到任意一个方法返回的字节码指令 代码中使用 athrow 字节码指令产生的异常 方法退出的过程实际上等同于把当前栈帧出栈 恢复上层方法的局部变量表和操作数栈 把返回值压入调用者栈帧的操作数栈中 调整 PC 计数器的值以指向方法调用指令后面的一条指令等 方法调用 方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还未涉及方法内部的具体运行过程 某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。 解析 方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用 方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的 在 Java 虚拟机支持以下 5 条方法调用字节码指令,分别是: invokestatic。用于调用静态方法。 invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。 invokevirtual。用于调用所有的虚方法。 invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。 invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面 4 条调用指令,分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的。 final 方法无法被覆盖 分派 静态分派 编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。 自动转型按照 char > int > long > float > double 的顺序转型进行匹配 动态分配 重写 invokevirtual 指令的运行时解析过程 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C。 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回 java.lang.IllegalAccessError 异常。 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。 单分派与多分派 如今的 Java 语言是一门静态多分派、动态单分派的语言 虚拟机动态分派的实现 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。 动态类型语言支持 invokedynamic 实现动态类型语言 动态类型语言 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的 静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。 动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。 Java 与动态类型 invokedynamic 指令以及 java.lang.invoke 包提供了 JVM 底层对于动态语言的实现的支持 java.lang.invoke 包 方法句柄 Java 语言也可以拥有类似于函数指针或者委托的方法别名这样的工具 Reflection 和 MethodHandle 机制本质上都是在模拟方法调用,但是 Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用 invokedynamic 指令 支持动态调用方法 基于栈的字节码解释执行引擎 解释执行 在 Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。 基于栈的指令集与基于寄存器的指令集 Javac 编译器输出的字节码指令流 一种基于栈的指令集架构 基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供 代码相对更加紧凑 编译器实现更加简单 栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些 完成相同功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈操作本身就产生了相当大量的指令

2021 Apr 07 · 1 min

深入理解Java虚拟机::虚拟机类加载机制

虚拟机类加载机制 概述 Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制 类加载的时机 一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unload ing)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking) 加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的 解析在某些情况下可以在初始化阶段之后再开始 为了支持 Java 语言的运行时绑定特性 JVM 规范严格规定了有且只有六种情况必须立即对类进行初始化 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段 使用 new 关键字实例化对象的时候 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候。 调用一个类型的静态方法的时候 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。 这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。 类加载的过程 加载 在加载阶段,Java 虚拟机需要完成以下三件事情: 通过一个类的全限定名来获取定义此类的二进制字节流。 从 ZIP 压缩包中读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础 从网络中获取,这种场景最典型的应用就是 Web Applet。 运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件。 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。 可以从加密文件中获取,这是典型的防 Class 文件被反编译的保护措施,通过加载时解密 Class 文件来保障程序运行逻辑不被窥探。 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。 非数组类型的加载既可以使用 Java 虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成 数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上 如果数组的组件类型不是引用类型, Java 虚拟机将会把数组 C 标记为与引导类加载器关联。 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到。 加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了 类型数据妥善安置在方法区之后,会在 Java 堆内存中实例化一个 java.lang.Class 类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。 验证 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。 文件格式验证 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理 元数据验证 对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求 字节码验证 通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的 符号引用验证 对类自身以外的各类信息进行匹配性校验 -Xverify:none 参数用来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。 准备 准备阶段是正式为类中定义的变量分配内存并设置类变量初始值的阶段 解析 解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程 符号引用 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可 内存布局无关 直接引用 直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄 内存布局直接相关 虚拟机实现可以对第一次解析的结果进行缓存, invokedynamic 除外,针对动态语言支持 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行 初始化 类的初始化阶段是类加载过程的最后一个步骤 根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源 与构造函数不同,编译期生成的私有方法 父类构造器优先子类构造器,父类静态块优先子类静态块 类加载器 类与类加载器 类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远超类加载阶段 每一个类加载器,都拥有一个独立的类名称空间 比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义 双亲委派模式 启动类加载器,C++语言实现,是虚拟机自身的一部分 其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。 启动类加载器 负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的类库加载到虚拟机的内存中 扩展类加载器 这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库 应用程序类加载器 由 sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是 ClassLoader 类中的 getSystem ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。 java.lang 开头的包因为安全原因无法被加载 破坏双亲委派模式 loadClass()加载类, 历史遗留问题 JNDI 服务, 线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。 追求动态性,热部署,热加载 在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索: 将以 java.*开头的类,委派给父类加载器加载。 否则,将委派列表名单内的类,委派给父类加载器加载。 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载。 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。 否则,类查找失败。 Java 模块化系统 实现模块化的关键目标可配置的封装隔离机制 模块就可以声明对其他模块的显式依赖,这样 Java 虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。 可配置的封装隔离机制还解决了原来类路径上跨 JAR 文件的 public 类型的可访问性问题。JDK 9 中的 public 类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些 public 的类型可以被其他哪一些模块访问 兼容性 保持向后兼容 模块带有版本号,需要手动选择版本号打包 类加载器 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代 <JAVA_HOME>\lib\ext 目录被舍弃 平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader 当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,

2021 Apr 06 · 2 min

深入理解Java虚拟机::类文件结构

类文件结构 实现语言无关性的基础是虚拟机和字节码存储格式 Class 类文件的结构 Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型无符号数和表 无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上也可以视作是一张表, ClassFile { u4 magic; //0xCAFEBABE u2 minor_version; //class file minor version u2 major_version; //class file major version u2 constant_pool_count; //count of entries in next item cp_info constant_pool[constant_pool_count-1]; //constants u2 access_flags; //class assess flags u2 this_class; //index of this class to const pool u2 super_class; //index of super class to const pool u2 interfaces_count; //number of interfaces implemented u2 interfaces[interfaces_count];//indices of interfaces u2 fields_count; //number of fields in the class field_info fields[fields_count];//fields descriptions u2 methods_count; //number of methods in the class method_info methods[methods_count]; //methods descriptions u2 attributes_count; //number of attributes of the class attribute_info attributes[attributes_count]; //attributes } 魔数与 Class 文件的版本 魔数 每个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。 Class 文件的版本号 第 5 和第 6 个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。 高版本需要向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件。 常量池 常量池的入口放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count) Class 文件结构中只有常量池的容量计数是从 1 开始, 0 用于表示无常量池引用 常量池中主要存放两大类常量: 字面量(Literal)和符号引用(Symbolic References) 字面量 文本字符串 声明为 final 的常量值 符号引用 被模块导出或者开放的包 类和接口的全限定名 字段的名称和描述符 方法的名称和描述符 方法句柄和方法类型 动态调用点和动态常量 常量池中的每一项常量都是一个表,互相之间的结构都不相同 JVM 规范中关于 Class 文件格式的章节 访问标志 识别一些类或者接口层次的访问信息 有 16 个标志位可以使用,当前只定义了其中 9 个 类索引、父类索引与接口索引集合 类索引用于确定这个类的全限定名 父类索引用于确定这个类的父类的全限定名 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 关键字后的接口顺序从左到右排列在接口索引集合中。 字段表集合 字段表用于描述接口或者类中声明的变量 类级变量以及实例级变量,但不包括在方法内部声明的局部变量 字段表集合中不会列出从父类或者父接口中继承而来的字段 方法表集合 volatile 关键字和 transient 关键字不能修饰方法 Java 语言中,重载方法需要签名不同 Class 文件格式中,允许方法签名相同,返回值不同的方法存在 属性表集合 Code 属性 Java 程序方法体里面的代码经过 Javac 编译器处理之后,最终变为字节码指令存储在 Code 属性内。 接口和抽象方法没有 Code 属性 《Java 虚拟机规范》中明确限制了一个方法不允许超过 65535 条字节码指令 Exceptions 属性 Exceptions 属性的作用是列举出方法中可能抛出的受查异常 LineNumberTable 属性 LineNumberTable 属性用于描述 Java 源码行号与字节码行号(字节码的偏移量)之间的对应关系 非必需,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号 LocalVariableTable 及 LocalVariableTypeTable 属性 LocalVariableTable 属性用于描述栈帧中局部变量表的变量与 Java 源码中定义的变量之间的关系 非必需,影响是当其他人引用这个方法时,所有的参数名称都将会丢失 SourceFile 及 SourceDebugExtension 属性 SourceFile 属性用于记录生成这个 Class 文件的源码文件名称 非必需,当抛出异常时,堆栈中将不会显示出错代码所属的文件名 ConstantValue 属性 ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值。只有被 static 关键字修饰的变量(类变量)才可以使用这项属性。 InnerClasses 属性 InnerClasses 属性用于记录内部类与宿主类之间的关联。 如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成 InnerClasses 属性。 Deprecated 及 Synthetic 属性 Deprecated 和 Synthetic 两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。 Deprecated 属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过代码中使用@deprecated注解进行设置。 StackMapTable 属性 目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。 Signature 属性 它是一个可选的定长属性,可以出现于类、字段表和方法表结构的属性表中 Signature 属性会为记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为 Java 语言的泛型采用的是擦除法实现的伪泛型,字节码(Code 属性)中所有的泛型信息编译(类型变量、参数化类型)在编译之后都通通被擦除掉 BootstrapMethods 属性 用于保存 invokedynamic 指令引用的引导方法限定符。 MethodParameters 属性 MethodParameters 的作用是记录方法的各个形参名称和信息。 模块化相关属性 Module 属性是一个非常复杂的变长属性,除了表示该模块的名称、版本、标志信息以外,还存储了这个模块 requires、exports、opens、uses 和 provides 定义的全部内容 运行时注解相关属性 RuntimeVisibleAnnotations 是一个变长属性,它记录了类、字段或方法的声明上记录运行时可见注解,当我们使用反射 API 来获取类、字段或方法上的注解时,返回值就是通过这个属性来取到的 字节码指令简介 Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字以及跟随其后的零至多个代表此操作所需的参数构成 加载和存储指令 加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输 运算指令 算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶 大体上运算指令可以分为两种: 对整型数据进行运算的指令与对浮点型数据进行运算的指令。 类型转换指令 类型转换指令可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作 对象创建与访问指令 虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令(在下一章会讲到数组和普通类的类型创建过程是不同的)。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素 操作数栈管理指令 用于直接操作操作数栈的指令 控制转移指令 控制转移指令可以让 Java 虚拟机有条件或无条件地从指定位置指令的下 一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改 PC 寄存器的值 方法调用和返回指令 用于方法调用 异常处理指令 显式抛出异常的操作都由 athrow 指令来实现 同步指令 Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程来实现 公有设计,私有实现 《Java 虚拟机规范》描绘了 Java 虚拟机应有的共同程序存储格式:Class 文件格式以及字节码指令集。这些内容与硬件、操作系统和具体的 Java 虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看作程序在各种 Java 平台实现之间互相安全地交互的手段。 一个优秀的虚拟机实现,在满足《Java 虚拟机规范》的约束下对具体实现做出修改和优化也是完全可行的,并且《Java 虚拟机规范》中明确鼓励实现者这样去做。只要优化以后 Class 文件依然可以被正确读取,并且包含在其中的语义能得到完整保持,那实现者就可以选择以任何方式去实现这些语义,虚拟机在后台如何处理 Class 文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可 Class 文件结构的发展 Class 文件结构一直处于一个相对比较稳定的状态,Class 文件的主体结构、字节码指令的语义和数量几乎没有出现过变动,所有对 Class 文件格式的改进,都集中在访问标志、属性表这些设计上原本就是可扩展的数据结构中添加新内容。 二十余年间,字节码的数量和语义只发生过屈指可数的几次变动,例如 JDK 1.0.2 时改动过 invokespecial 指令的语义,JDK 7 增加了 invokedynamic 指令,禁止了 ret 和 jsr 指令。

2021 Apr 05 · 2 min

深入理解Java虚拟机::调优案例分析与实战

调优案例分析与实战 大内存硬件上的程序部署策略 目前单体应用在较大内存的硬件上主要的部署方式有两种: 通过一个单独的 Java 虚拟机实例来管理大量的 Java 堆内存。 同时使用若干个 Java 虚拟机,建立逻辑集群来利用硬件资源。 面临的问题 回收大块堆内存而导致的长时间停顿,自从 G1 收集器的出现,增量回收得到比较好的应用 大内存必须有 64 位 Java 虚拟机的支持,但由于压缩指针、处理器缓存行容量(Cache Line)等因素,64 位虚拟机的性能测试结果普遍略低于相同版本的 32 位虚拟机。 必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生堆转 储快照,哪怕成功生成了快照也难以进行分析;如果确实出了问题要进行诊断,可能就必须应用 JM C 这种能够在生产环境中进行的运维工具。 相同的程序在 64 位虚拟机中消耗的内存一般比 32 位虚拟机要大,这是由于指针膨胀,以及数据类 型对齐补白等因素导致的,可以开启压缩指针功能来缓解。 集群间同步导致的内存溢出 集群同步软件的问题导致了内存泄漏 -XX:+HeapDumpOnOutOfMemoryError 堆外内存导致的溢出错误 直接内存:可通过-XX:MaxDirectMemorySize 调整大小,内存不足时抛出 OutOfMemoryError 或者 OutOfMemoryError:Direct buffer memory。 线程堆栈:可通过-Xss 调整大小,内存不足时抛出 StackOverflowError(如果线程请求的栈深度大 于虚拟机所允许的深度)或者 OutOfMemoryError(如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时 无法申请到足够的内存)。 Socket 缓存区:每个 Socket 连接都 Receive 和 Send 两个缓存区,分别占大约 37KB 和 25KB 内存,连接 多的话这块内存占用也比较可观。如果无法分配,可能会抛出 IOException:Too many open files 异常。 JNI 代码:如果代码中使用了 JNI 调用本地库,那本地库使用的内存也不在堆中,而是占用 Java 虚 拟机的本地方法栈和本地内存的。 虚拟机和垃圾收集器:虚拟机、垃圾收集器的工作也是要消耗一定数量的内存的。 外部命令导致系统缓慢 执行这个 Shell 脚本是通过 Java 的 Runtime.getRuntime().exc()方法来调用的。这种调用方式可以达到执行 Shell 脚本的目的,但是它在 Java 虚拟机中是非常消耗资源的操作,即使外 部命令本身能很快执行完毕,频繁调用时创建进程的开销也会非常可观。 服务器虚拟机进程崩溃 由于 MIS 系统的用户多,待办事项变化很快,为了不被 OA 系统速度拖累,使用了异步的方式调用 Web 服务,但由于两边服务速度的完全不对等,时间越长就累积了越多 Web 服务没有调用完成,导致在等待的线程和 Socket 连接越来越多,最终超过虚拟机的承受能力后导致虚拟机进程崩溃。通知 OA 门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。 不恰当数据结构导致内存占用过大 业务上需要每 10 分钟加载一个约 80M B 的数据文件到内存进行数据分析,这些数据会在内存中形成超过 100 万个 HashMap<Long,Long>Entry,在这段时间里面 Minor GC 就会造成超过 500 毫秒的停顿,对于这种长度的停顿时间就接受不了了 如果不修改程序,仅从 GC 调优的角度去解决这个问题,可以考虑直接将 Survivor 空间去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0 或者-XX:+Always-Tenure),让新生代中存活的对象在第一次 Minor GC 后立即进入老年代,等到 Major GC 的时候再去清理它们。 由 Windows 虚拟内存导致的长时间停顿 程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生垃圾收集时就有可能因为恢复页面文件的操作导致不正常的垃圾收集停顿。 在 Java 的 GUI 程序中要避免这种现象,可以加入参数“- Dsun.awt.keepWorkingSetOnMinimize=true”来解决 由安全点导致长时间停顿 解决问题的第一步是把这两个特别慢的线程给找出来,这个倒不困难,添加-XX: +SafepointTimeout 和-XX:SafepointTimeoutDelay=2000 两个参数,让虚拟机在等到线程进入安全点的时间超过 2000 毫秒时就认定为超时,这样就会输出导致问题的线程名称 方法调用、循环跳转、异常跳转这些位置都可能会设置有安全点,但是 HotSpot 虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用 int 类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop ),相对应地,使用 long 或者范围更大的数据类型作为索引值的循环就被称为不可数循环 (Uncounted Loop),将会被放置安全点。通常情况下这个优化措施是可行的,但循环执行的时间不单单是由其次数决定,如果循环体单次执行就特别慢,那即使是可数循环也可能会耗费很多的时间。 清理连接的索引值就是 int 类型,所以这是一个可数循环,HotSpot 不会在循环中插入安全点。当垃圾收集发生时,如果线程刚好执行到该函数里的可数循环时,则必须等待循环全部跑完才能进入安全点,此时其他线程也必须一起等着,所以从现象上看就是长时间的停顿。找到了问题,解决起来就非常简单了,把循环索引的数据类型从 int 改为 long 即可,但如果不具备安全点和垃圾收集的知识,这 种问题是很难处理的。

2021 Apr 04 · 1 min

深入理解Java虚拟机::虚拟机性能监控、故障处理工具

虚拟机性能监控、故障处理工具 jps 虚拟机进程状况工具 可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一 ID(LVMID,Local Virtual Machine Identifier) jstat 虚拟机统计信息监视工具 显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据 jinfo 是实时查看和调整虚拟机各项参数 System.getProperties() jmap 生成堆转储快照 查询 finalize 执行队列 Java 堆和方法区的详细信息 如空间使用率 当前用的是哪种收集器 jhat 与 jmap 搭配使用,来分析 jmap 生成的堆转储快照 jstack 用于生成虚拟机当前时刻的线程快照

2021 Apr 03 · 1 min

深入理解Java虚拟机::垃圾收集器与内存分配策略

垃圾收集器与内存分配策略 垃圾回收需要完成的三件事 那些内存需要回收 什么时候回收 如何回收 当垃圾回收成为性能瓶颈时,对垃圾回收进行必要的监控和调整 需要回收的区域 方法区 内存堆 对象已死 引用计数法 在对象中添加一个引用计数器 每当有一个地方引用它时,计数器值就加一 当引用失效时,计数器值就减一 任何时刻计数器为零的对象就是不可能再被使用的 优点 原理简单,判定效率也很高 缺点 有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作 如单纯的引用计数就很难解决对象之间相互循环引用的问题 可达性分析 通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的 GC Roots 在虚拟机栈(栈帧中的本地变量表)中引用的对象 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量 方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象 Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器 所有被同步锁(synchronized 关键字)持有的对象 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。 其他临时性加入的对象 引用 强引用是程序代码之中普遍存在的引用赋值 软引用是用来描述一些还有用,但非必须的对象 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。 回收方法区 废弃的常量和不再使用的类型 判断一个类不再被使用 该类所有的实例都已经被回收 加载该类的类加载器已经被回收 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法 垃圾回收算法 分代收集理论 绝大多数对象都是朝生夕灭的 熬过越多次垃圾收集过程的对象就越难以消亡 跨代引用相对于同代引用来说仅占极少数 标记清除算法 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象 主要缺点 执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低 标记、清除之后会产生大量不连续的内存碎片 标记复制算法 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉 空间浪费 优化 新生代中的对象有 98%熬不过第一轮收集 把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1 当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保 标记整理算法 用于老年代回收 标记过程仍然与标记清除算法一样 整理时让所有存活的对象都向内存空间一端移动 STW 的影响 是否移动对象需要考虑延迟和吞吐 优化 平时采用标记清除,当内存碎片化到影响对象分配的时候采用标记整理 HotSpot 的算法实现细节 根节点枚举 根节点枚举始终必须在一个能保障一致性的快照中进行 枚举根节点时是必须要停顿的, STW 准确式垃圾收集 用 OopMap 的数据结构来直接得到哪些地方存放着对象引用的 一旦类加载动作完成,HotSpot 会把对象偏移量上的数据类型计算出来 安全点 只是在“特定的位置”记录了这些信息 强制要求必须执行到达安全点后才能够暂停 安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的 抢先式中断 在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上 主动式中断 当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起 安全区域 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。 记忆集与卡表 解决对象跨代引用所带来的问题 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构 卡表 字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page) 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。 写屏障 解决卡表元素如何维护的问题 写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令 伪共享问题 并发的可达性问题 从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了 三色标记 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过 对象消失的问题 赋值器插入了一条或多条从黑色对象到白色对象的新引用 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用 增量更新 当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。 原始快照 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。 经典垃圾回收器 Serial 收集器 使用一个处理器或一条收集线程去完成垃圾收集工作 额外内存消耗最小 简单而高效 Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择 ParNew 收集器 Serial 收集器的多线程并行版本 Parallel Scavenge 收集器及 G1 收集器等都没有使用 HotSpot 中原本设计的垃圾收集器的分代框架,而选择另外独立实现,CMS 只能和 ParNew 搭配使用 Parallel Scavenge 收集器 基于标记-复制算法实现的收集器 达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值 最高效率地利用处理器资源 Serial Old 收集器 Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器 使用标记-整理算法 供客户端模式下的 HotSpot 虚拟机使用 Parallel Old 收集器 Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集 基于标记-整理算法实现 注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合 CMS 收集器 获取最短回收停顿时间为目标的收集器 标记-清除算法 初始标记(CMS initial mark) 仅仅只是标记一下 GC Roots 能直接关联到的对象 并发标记(CMS concurrent mark) 从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程 重新标记(CMS remark) 修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录 并发清除(CMS concurrent sweep) 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的 其中初始标记、重新标记这两个步骤仍然需要“Stop The World” 缺点 对处理器资源非常敏感 CMS 默认启动的回收线程数是(处理器核心数量 +3)/4 CMS 收集器无法处理“浮动垃圾” CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉 空间碎片 G1 收集器 开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式 提供并发的类卸载的支持 主要面向服务端应用的垃圾收集器 JDK9 的默认收集器 面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式 基于 Region 的堆内存布局 G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果 Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象 对于那些超过了整个 Region 容量的超级大对象, 将会被存放在 N 个连续的 Humongous Region 之中 使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率 Region 里面存在的跨 Region 引用对象如何解决 使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多,它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内 根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%至 20%的额外内存来维持收集器工作 并发标记阶段如何保证收集线程与用户线程互不干扰地运行 原始快照算法 如果内存回收的速度赶不上内存分配的速度, G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间“Stop The World” 怎样建立起可靠的停顿预测模型 G1 收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息 收集过程 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。 G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的 最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率 (Allocation Rate),而不追求一次把整个 Java 堆全部清理干净 G1 垃圾收集产生的内存占用(Footprint)和程序运行时的额外执行负载 (Overload)都要比 CMS 要高 G1 的记忆集(和其他内存消耗)可能会占整个堆容量的 20%乃至更多的内存空间 G1 对写屏障的复杂操作要比 CMS 消耗更多的运算资源 目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间 低延迟垃圾收集器 ZGC 收集器 在尽可能对吞吐量影响不太大的前提下,把垃圾收集的停顿时间限制在十毫秒以内的低延迟 ZGC 收集器是一款基于 Region 内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器 ZGC 的 Region 具有动态性——动态创建和销毁,以及动态的区域容量大小 小型 Region(Small Region):容量固定为 2MB,用于放置小于 256KB 的小对象。 中型 Region(Medium Region):容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象。 大型 Region(Large Region):容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象 ZGC 收集器有一个标志性的设计是它采用的染色指针技术 染色指针是一种直接将少量额外的信息存储在指针上的技术 ZGC 用地址的高 4 位提取出来存储四个标志信息 其引用对象的三色标记状态 是否进入了重分配集(即被移动过) 是否只能通过 finalize()方法才能被访问到 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作 ZGC 只使用了读屏障 ZGC 使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上 收集过程 并发标记:标记阶段会更新染色指针中的 Marked0、Marked1 标志位 并发预备重分配: 根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set) 并发重分配(Concurrent Relocate):重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用 选择合适的垃圾收集器 如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点 如果是 SLA 应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点 如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的

2021 Apr 02 · 3 min

深入理解Java虚拟机::自动内存管理

自动内存管理 程序计数器 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器 每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址 如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined) Java 虚拟机栈 线程私有的 生命周期与线程相同 每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息 局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型、对象引用和 returnAddress 类型 StackOverflowError OutOfMemoryError 本地方法栈 虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务 StackOverflowError OutOfMemoryError Java 堆 Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建 此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存 逃逸分析,栈上分配,标量替换 垃圾收集器管理的内存区域 分代收集理论 所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区 TLAB 以提升对象分配时的效率 Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的 OutOfMemoryError 方法区 线程共享 存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据 可以选择不实现垃圾收集 内存回收目标主要是针对常量池的回收和对类型的卸载 运行时常量池 方法区的一部分 存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中 具备动态性 OutOfMemoryError 直接内存 不是虚拟机运行时数据区的一部分 在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作 不会受到 Java 堆大小的限制 OutOfMemoryError 对象的创建 当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程 对象所需内存的大小在类加载完成后便可完全确定 对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来 假设 Java 堆中内存是绝对规整的–指针碰撞 如果 Java 堆中的内存并不是规整的–空闲列表 选择哪种分配方式由 Java 堆是否规整决定 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定 指针碰撞 Serial、ParNew 等带压缩整理过程的收集器 空闲列表 使用 CMS 这种基于清除(Sweep)算法的收集器 在虚拟机中对象创建的线程安全 对分配内存空间的动作进行同步处理 内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定 内存分配完成之后虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值 对对象进行必要的设置 new 指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来 对象的内存布局 对象头(Header) 对象自身的运行时数据 哈希码(HashCode) GC 分代年龄 锁状态标志 线程持有的锁 偏向线程 ID 偏向时间戳 类型指针 指向它的类型元数据的指针 记录数组长度的数据 实例数据(Instance Data) 程序代码里面所定义的各种类型的字段内容 父类继承下来的 子类中定义的字段 对齐填充(Padding) 不是必然存在的 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍 对象的访问定位 使用句柄访问 Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息 使用直接指针访问 考虑如何放置访问类型数据的相关信息 reference 中存储的直接就是对象地址 速度更快,它节省了一次指针定位的时间开销

2021 Apr 01 · 1 min

深入理解Java虚拟机::走进 Java

走进 Java 发展史 1996.01.23 JDK 1.0 发布 JVM Applet AWT 1996.02.19 JDK 1.1 发布 JAR 文件格式 JDBC JavaBeans RMI 内部类 反射 1998.12.04 JDK 1.2 发布 J2SE J2EE J2ME EJB Java Plug-in Java IDL Swing JIT VM Classic VM HotSpot VM Exact VM strictfp 关键字 2000.05.08 JDK 1.3 发布 JNDI Java 2D API JavaSound 2002.02.13 JDK 1.4 发布 正则表达式 异常链 NIO 日志类 XML 解析器和 XSLT 转换器 2004.09.30 JDK 5 发布 自动装箱 范型 动态注解 枚举 可变长参数 遍历循环 JMM JUC 2006.12.11 JDK 6 发布 动态语言支持 编译期注解处理器 微型 HTTP 服务器 API 锁和同步,垃圾回收,类加载改进 2009.02.19 JDK 7 发布 支持 Mac OS G1 收集器 加强对非 Java 语言的调用支持 可并行的类加载架构 2014.03.18 JDK 8 发布 Lambda 支持 Nashorn JavaScript 引擎 新的时间日期 API 彻底移除 HotSpot 的永久代 2017.09.21 JDK 9 发布 Jigsaw 模块化 JShell JLink HTTP 2 2018.03.20 JDK 10 发布 本地类型推断增强 整合 JDK 代码仓库 统一的垃圾回收接口 应用程序类数据共享 2018.09.25 JDK 11 发布 ZGC 授权许可调整 Epsilon:低开销垃圾回收器 标准 HTTP Client 升级 基于嵌套的访问控制 简化启动单个源代码文件的方法 用于 Lambda 参数的局部变量语法 低开销的 Heap Profiling 支持 TLS 1.3 协议 飞行记录器 2019.03.20 JDK 12 发布 Switch 表达式 Shenandoah 垃圾回收器 2019.08.17 JDK 13 发布 ZGC 增强 更新 Socket 实现 Switch 表达式更新 文本块 2020.03.17 JDK 14 发布 instanceof 模式匹配 G1 的 NUMA 可识别内存分配 改进 NullPointerExceptions 提示信息 Record 类型 Switch 表达式 删除 CMS 垃圾回收器 2020.09.15 JDK 15 发布 Edwards-Curve 数字签名算法 封闭类 禁用、弃用偏向锁 Java 虚拟机家族 Sun Classic/Exact VM HotSpot VM Mobile/Embedded VM BEA JRockit/IBM J9 VM BEA Liquid VM/Azul VM Apache Harmony/Google Android Dalvik VM Microsoft JVM 及其他

2021 Mar 31 · 2 min