JVM1-JVM组成1.1-JVM结构1.2-Java程序运行原理1.3-JVM内存分布⭐⭐1.4-前端编译流程1.5-对象定位方式⭐⭐1.6-引用类型⭐1.7-概念解析2-类加载2.1-类加载过程⭐⭐2.2-对象实例化过程⭐⭐2.3-类加载器⭐2.4-双亲委派模型⭐⭐2.5-破坏双亲委派机制⭐⭐3-垃圾收集3.1-垃圾内存判定⭐⭐3.2-垃圾收集算法⭐⭐3.3-垃圾收集流程⭐⭐3.4-垃圾收集分类⭐⭐3.5-垃圾收集器⭐⭐SerialParNewParallelCMSG1EpsilonZGCShenandoah3.6-内存泄露与溢出⭐⭐3.7-安全点和安全区4-JVM调优4.1-命令行工具4.2-GUI工具4.3-重要JVM参数⭐⭐4.4-对栈参数调优4.5-对GC调优⭐⭐5-字节码5.1-类文件结构⭐6-补充6.1-简述JVM6.2-逃逸分析技术6.3-简述字节码指令6.4-i++和++i的区别6.5-调优概述6.6-简述JMM
JVM由类加载子系统、运行时数据区、执行引擎和本地方法接口四个部分组成;
类加载器负责将字节码文件加载到JVM内存中在方法区生成Class实例对应的类模板数据,字节码文件是否可以运行由执行引擎决定;运行时数据区由虚拟机栈、堆、方法区、程序计数器、本地方法栈五部分组成,维护JVM的运行时数据;执行引擎由解释器和即时编译器组成,负责将字节码指令编译解释优化为操作系统指令集交给CPU执行;本地方法接口一般由C或者C++实现,Java程序通过调用本地方法能够与操作系统底层交互,直接调用操作系统底层提供的API访问硬件资源;
执行引擎:负责将字节码指令翻译成机器指令供操作系统和CPU执行,所有JVM执行引擎都是输入字节码指令输出执行结果
即时编译器将反复执行的热点代码编译成机器指令并缓存在方法区方便解释器解释运行的时候直接调用,Java是半编译型半解释型语言的根本原因是既可以使用解释器也可以使用即时编译器将字节码交给操作系统和CPU执行,Java最初没有即时编译器只是解释型语言,实际执行代码时解释器和即时编译器会协作工作
解释器会根据程序计数器逐条解释执行字节码指令,远古的解释器只会逐条翻译字节码指令并执行,效率低下;现在普遍使用模版解释器通过模板函数直接产生字节码指令对应的机器码提升解释器性能;Java早期因为只有解释器被C/C++程序员调侃运行效率低下,后续才通过即时编译器将热点代码、热点方法提前编译成机器指令缓存起来,解释器再次遇到直接调用机器指令缓存来大幅提升程序的执行效率
解释器的优势:能立即逐条解释执行字节码指令,启动快,单条指令执行慢;即时编译器需要先将一定范围内的字节码指令全部翻译成机器指令后才能开始执行,启动慢,单条指令执行快
即时编译器:代码大量复用的场景执行速度提升非常显著,相较于前端编译器javac、Eclipse的增量式编译器EJC被称为后端编译器,典型的即时编译器有HotSpot的C1、C2编译器,即时编译器的发展趋势是AOT静态提前编译器,常规的即时编译过程不直接涉及汇编、AOT编译器内部会涉及到汇编的生成和优化
热点代码探测技术:根据代码的调用执行频率来动态将多次被调用的一个方法或者一个方法内循环次数较多的循环体判定为热点代码进行即时编译,HotSpot会为每个方法建立方法调用计数器统计方法的被调用次数以及回边计数器统计循环体的循环次数
方法调用计数器在Client模式和Server模式下的阈值分别为1500和10000,超过该阈值方法就会成为热点代码向即时编译器提交编译请求,编译缓存到方法区以后直接调用机器指令缓存,该阈值可以通过参数-XX:CompileThreshold设置;方法调用计数器统计的是一段时间内方法的调用次数,超过指定时间方法调用计数器会衰减到原计数的一半,该时间通过参数-XX:CounterHalfLifeTime指定,该过程称为方法调用计数器的热度衰减,可以配置-XX:-UseCounterDecay关闭热度衰减
回边指遇到跳转到已经被执行过的字节码指令的指令,回边计数器在Client模式和Server模式下的阈值均为一万出头,是通过动态计算得到的阈值,回边计数器没有计数热度衰减,统计的是循环体执行的绝对次数,当回边计数器超过指定阈值会提交一个OSR栈上替换编译请求并且会降低回边计数器的值
JVM内嵌C1和C2两个即时编译器,JVM工作在Client模式下默认使用C1,工作在Server模式下默认使用C2,也可以通过命令参数显式指定即时编译器,64位操作系统只支持Server模式;C1编译器对字节码的编译优化简单,优化策略只采用方法内联、去虚拟化和冗余消除,编译速度快;C2编译器编译优化更激进,基于逃逸分析做标量替换、栈上分配和同步消除,优化后的代码执行效率更高但是编译耗时长,使用C2编译器需要开启性能监控,Server模式下的JVM默认是开启性能监控的;一般即时编译器编译出来的机器码性能都比解释器翻译出来的高
JDK10引入了Graal即时编译器,编译效果和C2差不多,带有试验标签,可以通过JVM参数配置使用
JDK9引入了实验标识的AOT静态提前编译器,是与即时编译对立的概念,用户可以通过jaotc工具在JVM启动前手动将字节码编译成以.so为后缀的机器码,优势是JVM可以直接加载执行预编译的机器码速度快无需预热,缺点是打破了Java的跨平台特性,同一份.so文件不能在不同硬件和操作系统平台上执行,JVM启动前就需要明确所有机器指令,失去Java的动态连接特性;且AOT静态提前编译器仅支持64位的Linux
JVM可以通过命令行-Xint、-Xcomp、-Xmixed显示指定只使用解释器、即时编译器或者二者混合使用,JVM默认工作在混合模式下,一个简单的几条语句循环体执行100w次,纯解释器约6000ms、纯即时编译器约950ms、混合模式约936ms
解释器和即时编译器并存的架构在热机状态下相较于冷机状态能承受更大的负载,以热机状态的可承载流量进行切流操作可能会导致冷机状态下的服务器因为无法承载流量而发生假死的情况
典型场景如生产环境中发布过程的分批发布,一般将正在运行的机器划分为N个批次,每次只对其中一个批次的机器进行发布更新,一般每个批次的机器数最多占总机器数的1/8;如果在发布平台填写总发布批数的时候认为热机状态下一半的机器就能承载当前流量负载分成两批发布,第一批发布更新因为另一半处于热机状态的机器刚刚好能承载当前流量所以能成功,但是第二批发布更新会因为已经更新成功的一半机器处于冷机状态无法承载当前的瞬时流量导致第一批发布成功的服务器全部宕机
基于这个问题一些限流框架比如Sentinel对资源的流控规则设计了预热模式,流量突增直接把系统拉升到高水位可能瞬间把处于冷机状态的系统压垮;预热模式可以在QPS超过指定阈值的情况下限定通过的流量,让流量在指定时间逐步增加到预设的QPS阈值上限,给冷机一个预热时间,避免原本热机状态下能承载的流量因为处于冷机状态被突增的流量压垮,使用这种流控规则也能防范上述分批发布冷热机切换导致服务器大批宕机的风险
本地方法接口:Java核心类库中一些没有方法体被native关键字修饰的方法就是本地方法,这些方法的方法体底层已经使用C/C++实现了
本地方法存在的主要作用是与操作系统和硬件交互必须使用C/C++,JVM提供本地方法接口供Java直接调用,JVM的运行依赖操作系统的支持
早期与由Java驱动打印机、生产管理设备等硬件交互还需要用户编写本地方法,随着Java的发展,这种现象已经很少见了
JVM的工作原理是通过类加载子系统将字节码加载到JVM内存中形成运行时数据区,由执行引擎将字节码指令翻译成对应操作系统的指令集交给CPU执行,在这个过程中调用本地方法接口为Java程序提供与操作系统交互和访问硬件资源的能力
字节码只是一个跨平台的通用契约,包含一些能被JVM识别的字节码指令、符号表以及其他辅助信息;操作系统只能识别机器指令或者汇编指令;需要将字节码指令翻译成机器指令操作系统才能识别执行
Java程序通过前端编译器如javac通过词法、语法、语义分析器和字节码生成器一系列环节生成Java字节码文件
Java字节码通过类加载器经过加载、链接、初始化三个环节加载到JVM内存中形成运行时数据区[其中链接环节又分为验证、准备和解析三个阶段]
每创建一个Java线程都会在运行时数据区生成一个虚拟机栈,每调用一个方法都会在虚拟机栈中压栈一个栈帧结构维护局部变量、字节码指令的操作数,每完成一个方法的调用当前栈帧就会被弹栈,由虚拟机栈的程序计数器记录下一条字节码指令的偏移量地址,由执行引擎中的解释器将字节码指令翻译为操作系统指令集被操作系统交给CPU进行执行,如果是热点代码,执行引擎中的即时编译器会将热点代码优化编译成操作系统指令集缓存起来,再次遇到不再通过解释器解释执行会直接调用编译后的指令集缓存

其中程序计数器、虚拟机栈、本地方法栈是线程私有的;堆、方法区和直接内存是线程共享的
运行时数据区对应Java中的Runtime类单例
运行时数据区
程序计数器:
概念:程序计数器是一个线程私有的寄存器,也叫PC寄存器或者程序钩子,每个线程都有一个程序计数器记录下一条将被执行的字节码指令偏移量地址,解释器读取和改变程序计数器的值;分支、循环、跳转等流程控制语句,字节码的顺序执行、异常处理、线程恢复等基础功能都依赖程序计数器来完成,是程序控制流字节码行号指示器
线程恢复是线程被切换回来时能根据程序计数器知道线程上次运行到哪儿了
特点:如果线程正在执行一个本地方法,相应程序计数器的值为Undefined
虚拟机栈
概念:虚拟机栈是一个线程私有的栈结构,是一个描述Java方法执行过程的线程内存模型,Java方法调用时通过压栈栈帧结构记录方法执行期间的局部变量表、操作数栈、动态链接、方法出口等信息,支持方法的调用返回、线程切换、异常的处理和传播、栈帧的分配与回收、即时编译器对字节码指令的优化;每当有方法被调用虚拟机栈会压栈一个栈帧;每当方法执行完毕遇到return语句或者抛出异常,虚拟机栈会弹栈当前栈帧,早期也被称为Java栈,默认大小一般为1M
栈顶栈帧称为当前栈帧;当前栈帧对应的方法称为当前方法;定义当前方法的类称为当前类;当前方法返回时返回值会被压栈到前一个栈帧的操作数栈;遇到return指令或者抛出未被处理的异常都会导致当前栈帧被弹出
特点:JVM规范规定,如果虚拟机栈设置了最大深度,线程请求的栈深度大于虚拟机栈允许的最大深度将抛出StackOverFlowError异常;如果虚拟机栈被设置为可以动态扩展,虚拟机栈扩容或者创建新的虚拟机栈时无法申请到足够内存将抛出OutOfMemeoryError异常
指令集架构模型
基于栈的指令集架构模型:每个方法的运行数据对应一个栈帧结构,调用方法和方法返回对应一个栈帧的压栈弹栈操作
基于栈的指令集架构一般以零地址指令为主,字节码指令占一个字节,相较于占两个字节的基于寄存器的指令集架构模型单个指令的字节数更少,但是因为频繁地压栈和弹栈操作,完成同一个操作,总的指令数量更多
特点:跨平台、以零地址指令为主[没有操作数或者操作数在操作码中隐式指定],单条指令占用空间小,相同的功能相较于基于寄存器的指令集架构总的指令数更多,内存开销更大
基于寄存器的指令集架构模型:X86等传统PC以及安卓的Davlik虚拟机都采用这种指令集架构
特点:基于CPU高速缓冲区,执行效率高;与硬件耦合度高可移植性差;指令集以一二三地址指令为主,单条指令内存占用多,但是完成相同功能相较基于栈的指令集架构指令数量更少
栈帧结构:由局部变量表、操作数栈、动态链接、方法返回地址、一些附加信息[帧数据区]五部分组成
局部变量表:是一个数字数组,容量在前端编译期间就被确定保存在字节码文件方法表项的Code属性中,存储实例方法和构造方法的this指针、形参、方法内部声明的局部变量值,存储基本数据类型和对象引用
局部变量表的基本单位是占四个字节的变量槽Slot,long和double占两个Slot,其余数据类型及地址引用都只占一个Slot,通过变量的起始位置索引对变量进行引用
静态方法的局部变量表中没有this指针,因此静态方法中不允许使用this
局部变量表中变量的作用域比方法的作用域小,在变量作用域后面声明的局部变量会复用已经失效的变量槽并覆盖掉旧值,这种占用关系在前端编译阶段就已经决定好了;方法调用时形参就是通过局部变量表来进行传递的
局部变量表直接引用的对象会被作为GC Roots根节点对象,这些对象以及被这些对象直接或者间接引用的对象不会被垃圾回收
局部变量虽然都创建在虚拟机栈中,但是不一定是线程安全的,比如引用数据类型的引用最终还是多线程访问的堆中同一个实例
操作数栈:
操作数栈是一个容量在前端编译期间就被确定保存在方法表项的Code属性表项的max_stack属性的基于数组的栈结构。操作数栈压栈弹栈字节码指令的操作数和执行结果;对局部变量的声明和初始化会先将字面量压栈到操作数栈再弹栈保存到局部变量表;字节码指令的操作数会先从局部变量表或者对象中压栈到操作数栈,再从操作数栈弹栈一个或者多个操作数参与字节码指令的执行,执行结果被压栈到操作数栈再弹栈保存到局部变量表或者对象中;此外还有专门操作操作数栈比如复制栈顶元素再压入操作数栈的字节码指令
操作数栈的基本单位也是一个四字节的变量槽Slot,long和double占两个栈单位深度即八个字节,其余基本数据类型和引用地址占一个栈单位深度
TOS栈顶缓存技术:频繁压栈弹栈会导致更多的指令和内存读写从而降低程序的执行速度,HotSpot的开发团队提出了栈顶缓存技术,将操作数栈的栈顶元素缓存在CPU寄存器中,执行字节码指令时让CPU直接操作寄存器中缓存的数据,减少压栈弹栈次数提高数据被访问的速度
动态链接:
静态链接:前端编译阶段就能明确具体方法的内容且在运行期间保持不变,这种在编译期间就能确定符号引用和直接引用的对应关系被称为静态链接,也叫早期绑定,符号引用能直接指向方法、字段或者类,典型场景如非多态方法调用、通过super调用父类方法、通过super方法调用父类构造器、单参构造器通过this方法调用无参构造器
面向过程的语言只支持早期绑定、面向对象的语言都支持封装、继承、多态,都同时支持早期绑定和晚期绑定
C++中虚函数的特征:就是多态,指可以使用父类型引用指向子类型实例来对子实例方法的调用;Java中的任意一个方法都可以具备这种虚函数的特征,如果希望Java中的方法不具备这种特征可以使用final关键字修饰父类中的对应方法,被final修饰的方法无法被子类重写,此时通过多态让子实例调用被final修饰的方法仍然调用的是父类中的方法
非虚方法:前端编译期间就能确定具体方法内容且运行时不会发生改变的方法,静态方法、私有方法、final修饰的方法、实例构造器、通过super调用父类方法都是非虚方法,除了这五类其他方法都是虚方法;静态方法通过字节码指令invokestatic调用;构造器、私有方法、通过super调用父类方法通过invokespecial调用;final修饰的非虚方法通过invokevirtual调用;所有的虚方法都通过invokevirtual和invokeinterface调用;JDK7为了让Java具备动态类型语言的特性同时在JVM上支持动态类型语言的运行引入了invokedynamic指令,静态类型语言指前端编译阶段就对变量类型进行检查的语言,动态类型语言指运行期间才对数据类型进行检查的语言,即变量没有类型,变量值才有类型,只能根据变量值才能确定一个变量的具体类型
动态链接指程序运行期间方法调用其他方法或者属性时将常量池中的符号引用转换成直接引用的过程,也叫晚期绑定,前端编译期间不能确定被调用的具体方法,只能在运行期间根据调用者的实际类型确定被调用的具体方法,典型场景就是多态,此时就会通过动态链接的方式来进行方法调用
方法重写的本质:将方法调用者的类型符号引用常量池表项索引去常量池中检索具体的方法并进行权限校验;没找到具体方法按照类型继承关系从下往上依次对各个父类进行检索和权限校验,如果遍历到顶级类或者接口还没有找到具体的方法就会抛出AbstractMethodError抽象方法异常,权限校验不通过抛出IllegalAccessError异常;为了避免频繁进行上述检索过程带来的性能开销,JVM在方法区为每个类建立了一个虚方法表,存储各个虚方法的具体方法入口,只要检索过一次,后续调用直接通过虚方法表找到对应方法的实际入口,虚方法表在类加载的连接环节的解析阶段被创建和初始化
方法返回地址:保存调用当前方法的方法调用当前方法时的程序计数器的值,弹栈当前栈帧时会将程序计数器的值设置为当前栈帧方法返回地址值,让当前线程继续执行上一个方法的后续代码
异常退出方法的执行会根据异常表跳转下一条将被执行的指令,无法被处理的异常将会抛给上层调用方法
long类型返回值对应指令为lreturn,float类型对应freturn,double类型对应dreturn,其余基本数据类型都对应ireturn,引用数据类型对应areturn,返回空值、构造器、<clinit>()方法对应的指令均为return
帧数据区:一般用于实现一些辅助功能比如为程序调试提供支持,不同的JVM实现允许携带的附加信息不同
本地方法栈
概念:本地方法栈的作用和虚拟机栈的作用是类似的,用于本地方法调用时记录方法调用的局部变量、操作数、动态链接、方法出口等信息
特点:JVM规范没有对本地方法使用的语言、本地方法栈的使用方式和数据结构做任何强制约束,JVM可以根据需要自由实现本地方法栈,像HotSpot虚拟机直接将本地方法栈和虚拟机栈合二为一,不区分本地方法栈和虚拟机栈,二者共享相同的内存区域和数据结构
Java堆
概念:Java堆是所有线程共享的一块内存区域,几乎所有的对象实例和数组对象都在堆上分配内存
默认情况下,Java堆的初始内存为物理内存的1/64,最大内存为物理内存的1/4;老年代与新生代的比值为2:1,单独指定新生代大小后该默认比值会失效;
实际生产中一般会将初始内存和最大内存设置成相同值避免系统刚启动时的连续扩容导致持续地垃圾回收以及每次垃圾回收后需要重新调整堆区大小带来的额外性能开销。该物理内存指可用内存,不包括操作系统占用的约几百兆空间
新生代中伊甸园区和幸存者区的内存大小比例为8:1:1
商用JVM新生代的垃圾收集一般都采用复制算法,1989年基于半区复制算法和分代收集理论提出了Appel式回收,将新生代分为一块较大的伊甸园区和两块较小幸存者区,HotSpot默认伊甸园区和幸存者区的大小比例是8:1:1,新生代中可用内存空间为整个新生代容量的90%,只有10%的新生代空间被浪费
实际情况下伊甸园区和幸存者区的比例不是严格的8:1:1,打印结果显示只有6:1:1,除了默认的配置比例开启JVM的自适应机制也可能影响伊甸园区和幸存者区的比例分配,此外还可能导致两个幸存者区不一样大;实际上即使配置关闭自适应机制也不会恢复默认的8:1:1;要想完全自定义伊甸园区和幸存者区的比例需要配置JVM参数-XX:SurvivorRatio=8来自定义伊甸园区是单个幸存者区容量的8倍
幸存者区有两个
设置两个幸存者区的目的是保证垃圾收集速度的前提下解决内存碎片的问题,标记清除算法会存在内存碎片问题,标记压缩算法效率太低不适合频繁GC的新生代,分区复制算法内存浪费太严重;设置两个幸存者区一次使用一个幸存者区,伊甸园区和一个幸存者区一轮Minor GC后存活对象拷贝到另一个幸存者区并循环往复,保证新生代频繁垃圾收集的效率、避免内存碎片、保证只有足够分代年龄的对象才能晋升老年代;
分代年龄阈值只能设置为0-15,否则会抛出错误MaxTenuringThreshold of 20 is invalid; must be between 0 and 15,这是因为对象头中记录对象分代年龄的区域只有4位,只能表示0-15
即使设置了分代年龄阈值JVM也会动态地调整对象晋升的动态年龄阈值,调整的规则是按年龄从小到大对存活对象占用大小进行累加,累加到某个年龄时内存占用大小超过幸存者区的标准值,默认是幸存者区域的一半;选择该年龄和分代年龄阈值中的更小值作为新的分代年龄阈值;可以通过启用-XX:+UseCMSInitiatingOccupancyOnly避免JVM自动调整分代年龄
方法区在逻辑上属于堆的一部分,但是实际指定堆容量并不包含方法区
特点:
由于即时编译和逃逸分析技术的发展,栈上分配、标量替换使得所有Java对象实例都分配在堆上变得不那么绝对,从JDK1.7开始默认开启逃逸分析,只要方法中引用数据类型的局部变量没有发生逃逸,这些对象都会通过标量替换的方式直接在栈上分配内存
JVM规范规定Java堆无需内存空间在物理上连续,只需要在逻辑上连续即可,但是大对象的存储多数虚拟机实现都会出于实现简单、存储高效要求连续的内存空间
主流的JVM的Java堆都被设计为容量可扩展,通过JVM参数-Xmx和-Xms设定最大容量和初始容量,当Java堆没有内存可以为实例分配内存且无法再扩展时将抛出OutOfMemoryError异常
JDK1.7及以后,类变量和字符串常量池从方法区转移到Java堆
几乎所有的对象都在伊甸园区被创建,熬过一次垃圾收集进入幸存者区,在幸存者区熬过指定次数的垃圾收集才会晋升老年代,对象分代年龄保存在对象的对象头中,大对象会直接在老年代创建
实际上JVM规范对运行时数据区的规定非常宽松,像堆可以是物理上连续内存空间也可以是不连续内存空间;大小可以固定也可以运行时按需扩展;可以使用任何垃圾收集算法管理堆也可以完全不进行垃圾收集
每个线程可以在伊甸园区开辟线程私有的TLAB保证对象分配时的线程安全和并发性能
在应用程序中可以通过Runtime中的totalMemory()方法和maxMemory()方法分别获取当前堆容量和最大堆容量
方法区
概念:方法区是所有线程共享的一块内存区域,也被称为非堆,存储已经被虚拟机加载的类元信息、字段描述信息、运行时常量池、静态变量、方法信息、即时编译代码缓存,JDK7以后串池和静态变量迁移到堆中
迁移串池的原因是应用中一般会创建大量字符串,JDK自带的类加载器几乎不可能被销毁,类无法达到卸载条件几乎不可能被卸载,只有大量使用反射、动态代理、CGLib等字节码操作框架、动态生成JSP、模块化热部署OSGI等自定义类加载器频繁动态加载卸载类的场景才会确实要求JVM具备类型卸载能力避免对方法区造成过大的内存压力;且判断废弃类的过程性能开销很高,方法区的垃圾收集效率很低,迁移到堆中能提高串池的回收效率
类元信息:类和直接父类的全限定名;类型的修饰符;实现的直接接口有序列表;字段信息包括字段名称、字段类型、修饰符和字段声明顺序;方法名称、返回值类型、形参列表、修饰符、字节码指令、操作数栈深度和局部变量表的长度;异常表记录try语句块对应字节码指令起始和结束偏移量以及出现异常跳转到catch语句块对应的字节码指令偏移量
运行时常量池:字节码文件中的常量池表存放前端编译器生成的各种字面量和符号引用,常量池表将在类加载后存放到方法区的运行时常量池,字面量包括整数、浮点数和字符串字面量,符号引用包含类、字段、方法和接口符号引用及对应描述符
符号引用:任何形式使用时能无歧义定位到目标的字面量,各种虚拟机实现只能接受满足相同规范要求的符号引用,这些符号引用的字面量格式被明确定义在JVM规范的字节码文件格式中
直接引用:指向目标的指针、相对偏移量或者直接定位到目标的句柄;有了直接引用,被引用的目标必定已经存在虚拟机的内存中了
JDK1.7以后,除了字符串常量池被单独转移到堆中,运行时常量池中的其他部分仍然在方法区中
字符串常量池:为了避免字符串的重复创建减少内存消耗提升性能为字符串专门开辟的一块内存区域,字符串常量池是C++实现的StringTable,可以理解为固定大小的哈希表,在JDK7及以后,字符串常量池和静态变量从永久代移动到Java堆中
方法区的GC效率太低,字符串通常会被大量创建,将字符串常量池放在堆中能提高串池内存的收集效率
从JDK7开始字符串常量池转移到堆中,同时为了节省内存,string.intern()方法调用时如果字符串常量池中没有相应的字符串对象,不会在字符串常量池中创建值相同的字符串对象,而是直接将相同字符串第一次调用intern()方法的调用者的地址引用存入字符串常量池并返回该地址引用而不会再额外创建字符串对象
但是使用new String("123")或者String str = "123"仍然会使用ldc指令在字符串常量池中创建值相同的全新独立的字符串对象,此时再调用intern()方法会返回字符串常量池中值相同独立的字符串对象的地址引用
字符串的toString()方法不会在字符串常量中创建相应的字符串,通常包括Object中继承来的toString()方法获得的字符串都是通过字符串拼接符+拼接出来的字符串,字符串拼接符本质上用StringBuilder拼接的字符串,拼接好字符串以后通过调用重写后的stringBuilder.toString()获取拼接后的字符串,该方法中的字符串构造器对应的字节码指令中没有ldc指令,不会在字符串常量池中创建对应的字符串,因此toString()方法也不会在字符串常量池中创建对应的字符串对象,一个对象调用了toString()方法后即使调用intern()方法也只是将堆中当前字符串对象的地址引用保存在字符串常量池中,而不会在字符串常量池中创建一个实打实的字符串对象
类变量:静态变量被所有类实例共享,即使没有任何类实例被创建依然可以直接访问,甚至对应类型的空指针也能直接访问指定类变量而不会抛出空指针异常。被final修饰的类变量被称为常量,常量在前端编译期间赋值,在类加载的连接环节的准备阶段初始化并赋实际值
JDK1.7及以前HotSpot采用永久代来实现方法区,使得垃圾收集器能像管理Java堆一样管理方法区;实践证明这不是一个好设计,永久代通过JVM参数-XX:MaxPermSize设置最大内存大小,不设置也有默认大小,像string.intern()等方法可能会导致字符串常量池中频繁创建对象,永久代很容易就会遇到内存溢出问题;JRockit和J9只要内存没有达到操作系统对进程可用内存的限制上限[如32位操作系统中的4GB限制]就不会出现内存溢出问题;从JDK1.6开始HotSpot团队就有使用本地内存实现方法区的计划,Oracle收购BEA获得JRockit就开始移植像JMC等优秀功能到HotSpot,HotSpot到JDK8废弃了永久代,改用和JRockit、J9一样基于本地内存的元空间作为方法区实现,将原来永久代移除类变量和字符串常量池的剩余类型信息等内容迁移到元空间
永久代的初始大小默认值约21M,最大大小32位操作系统默认为64M,64位操作系统默认为82M;元空间的默认初始大小约21M,最大大小为-1,表示没有限制,只受操作系统分配给每个进程的最大内存如32位操作系统中的4GB限制,元空间也会根据应用运行状态动态调整容量大小;实际上一般会指定元空间的大小,避免元空间耗尽可用的系统内存甚至影响到堆内存和本地内存的使用
使用元空间替换永久代能极大改善由于方法区内存不足频繁触发Full GC的情况
特点:
JVM规范在逻辑上将方法区看做Java堆的一部分,但是实际在JVM参数设置时方法区被排除在堆外,且方法区的别名为非堆内存
方法区无法满足新的内存分配需求时将抛出OutOfMemoryError异常
JDK1.7及以后,类变量和字符串常量池从方法区转移到Java堆,此时方法区的回收效率就很低了,JVM规范没有强制要求方法区必须进行垃圾回收,JDK11发布的ZGC就不支持类卸载
纯净的JDK默认加载的类多达1600余个
直接内存
概念:直接内存就是JVM所在物理机上的可用本地内存,JDK1.4引入的NIO或者Unsafe类可以直接为对象分配直接内存,可以避免用户态与内核态内存之间的相互拷贝在诸如向网卡写出数据或者从网卡写入数据的场景中提升IO性能;向磁盘或者网卡写出数据,正常情况下需要先将数据写入到用户态即JVM内存,再将数据复制到内核态即本地内存,再由操作系统将数据写入网卡或者本地磁盘;读取磁盘文件或者网卡数据,正常情况下需要先由操作系统从磁盘或者网卡将数据读取到本地内存,再将数据拷贝到用户态即JVM内存;直接内存可以同时被JVM和操作系统访问,相较于正常情况少了一次用户态和内核态数据的拷贝,提高IO读写性能;
特点:
直接内存虽然读写效率高,但是开辟回收成本也很高,不受JVM回收管理,堆dump文件也没有对直接内存进行记录
直接内存的默认大小与堆的最大大小一致,该容量不包含元空间占用的本地内存
这个问题和解答出自牛客Java面试宝典,讲的很不清楚,需要完善
Javac的编译过程分为1个准备过程和3个处理过程
1️⃣准备过程:初始化插入式注解处理器
2️⃣解析与填充符号表过程:
词法、语法分析;将源码的字符流转变成标记集合构造出抽象语法树
填充符号表产生符号地址和符号信息
3️⃣注解处理过程:
执行初始化注解处理器的processAnnotations()方法,执行过程中判断是否还有其他的注解处理器需要执行,如果有会生成一个新的JavaCompiler编译器对象对编译的后续步骤进行处理
执行插入式注解期间可能产生新的符号,如果有新符号产生需要转回解析与填充符号表过程处理这些新符号才能继续执行注解处理过程
4️⃣分析生成字节码过程:
标注检查:对语法的静态信息进行检查
数据流与控制流分析:对程序的动态运行过程进行检查
解语法糖:将语法糖还原为原有形式。
字节码生成:生成字节码
特点:
Javac是全量编译,每次编译都把整个Java源码重新编译一次;HotSpot没有要求必须使用Javac来编译生成字节码,Eclipse的前端编译器ECJ[Eclipse Compiler for Java]是一种增量式编译器,每次源码保存时进行编译,且只会编译Java源码中更新的内容,已经编译过的内容不会再进行编译
ECJ的编译速度比javac快,编译质量和Javac是差不多的,这也是Eclipse启动比IDEA快的原因;Tomcat使用的是ECJ来编译JSP文件,ECJ基于GPLv2开源协议开源,在Eclipse官网可以下载ECJ的源码
AspectJ作为面向切面的框架不仅可以将切面逻辑织入目标类生成增强后的字节码文件,还可以替代javac等前端编译器编译Java源码
前端编译器不会对代码进行优化,即使使用不同的前端编译器也不会对代码性能造成任何影响,代码优化主要由即时编译负责
javac要加-g参数才会在字节码文件中生成局部变量表,没有局部变量表的字节码文件被javap解析以后仍然会缺失局部变量表;IDEA和Eclipse编译时都会默认生成局部变量表以及指令源码行偏移量映射表等信息;
JVM可以使用句柄访问和直接指针两种方式通过局部变量表中保存的对象引用访问到堆中的对象实例,JVM规范没有明确要求必须使用哪一种方式,HotSpot采用直接指针的方式
句柄访问
概念:在堆空间开辟一块称为句柄池的空间保存句柄,句柄中保存指向堆中对象实例的指针和指向方法区中对象类型数据的指针,局部变量表中的地址引用指向句柄中指向对象实例的那个地址,先找到句柄再通过句柄指针找到对象实例
特点:
优点:虚拟机栈中的地址引用始终指向句柄,即使对象实例的位置因为GC等过程发生的变化,只需要更改句柄中对应的地址值,不需要更改所有虚拟机栈中对该对象的地址引用,栈空间中的引用地址会非常稳定
缺点:
每次访问都需要通过句柄两次指针跳转才能访问到对象,增加了程序执行开销,对程序执行性能影响比较显著
在堆区需要专门开辟一块空间保存对象的句柄信息,当对象数量非常多的时候会显著增加句柄内存占用
直接指针
概念:局部变量表中的地址引用直接指向堆中的对象实例,对象实例的对象头中类型指针指向方法区中对象类型实例
特点:
优点:对象访问速度快
缺点:对象位置一旦变化需要改变所有虚拟机栈中对应对象地址引用的值
JDK1.2以前引用的定义为存储另一块内存的起始地址的内存,JDK1.2对引用概念进行扩充,将引用分为了强引用、软引用、弱引用、虚引用和终结器引用
99%的场景都使用强引用,类库和框架源码中常常会用到软引用和弱引用,软引用和弱引用主要用于缓存场景,虚引用主要用于对象回收跟踪
强引用
概念:类似Object obj = new Object()这种使用构造器创建一个新对象并将对象地址赋值给一个变量,这个变量就称为指向该对象的强引用;将一个强引用复制给另一个变量,被赋值的变量也是强引用;
特点:
被强引用关联的对象称为可触及状态,对应软、弱、虚引用关联的对象都对应软、弱、虚可触及状态
软引用
概念:如果回收不可达对象后堆内存还是不够用,JVM就会回收只被软引用关联的对象,回收后内存还是不够就会抛出OOM
特点:
软引用常用于实现内存敏感的缓存,当内存充足时希望将这些缓存保存在内存中,内存紧张则希望抛弃这些缓存
通过softReference.get()来获取被软引用关联的对象,如果对象已经被回收则返回null
创建软引用后需要使用obj=null销毁强引用,如果不销毁强引用,软引用本身不会对GC行为造成影响
弱引用
概念:只要进行垃圾收集就会回收只被弱引用关联的对象
特点:
通过weakReference.get()来获取被弱引用关联的对象,如果对象已经被回收则返回null
集合WeakHashMap<K,V>中的内部类Entry<K,V>就继承自弱引用WeakReference,该集合只有其中的key使用的弱引用,值使用的强引用;当除了WeakHashMap本身对key对应对象的弱引用外该对象再没有其他引用,集合会自动丢弃该键值对,对应的对象也会自动被垃圾回收
虚引用
概念:虚引用不会影响对象的垃圾回收,被虚引用关联的对象在被垃圾回收的时候会给一个系统通知,用户通过虚引用来获取对应对象实例总是获取null,使用虚引用的目的只能是跟踪对象的垃圾回收过程,虚引用也被称为幽灵引用、幻影引用、幻想引用
特点:虚引用必须和引用队列一起使用,在创建虚引用的同时除了传参对应对象的强引用外还必须额外传参一个引用队列ReferenceQueue,垃圾收集器在回收对象前发现对象有虚引用会自动将虚引用加入到指定引用队列在对象回收之前执行一些像关闭文件句柄、释放数据库连接等用户自定义的资源清理工作以及监控对象的声明周期
终结器引用
概念:修饰词为缺省,只能在引用相关的包下才能使用,实际开发不可用;终结器引用用于实现对象的finalize()方法,被终结器引用关联的对象在GC开始时会将引用传入引用队列,由Finalizer线程通过终结器引用找到被引用的对象并调用该对象的finalize()方法,下次GC时对象会被直接回收
STW:
概念:GC过程中会偶尔暂停所有的用户线程,效果就像应用程序卡死了
可达性分析算法中枚举根节点需要暂停所有用户线程来保证分析前后数据的状态一致性,如果不暂停用户线程就可能导致分析过程中对象引用关系不断变化,无法保证分析结果的正确性;枚举对象图的过程也需要暂停用户线程,即使有并发标记阶段的垃圾收集器最后也需要暂停所有用户线程对变化的引用关系进行修正
现象:设置一个用户线程Thread.sleep()每隔1毫秒打印打印一次。当频繁显式调用parallel的GC时,打印间隔会有20-150ms的变化
指令重排序
概念:JVM允许在不影响代码最终执行结果的前提下可以乱序执行字节码指令
内存屏障
内存屏障可以阻挡编译器或者处理器对代码的优化
JMM规定只要不改变程序的执行结果,编译器和处理器可以随意优化当前程序。比如编译器认定一个锁只会被单个线程访问,该锁就可以被消除;一个被volatile修饰的变量如果只会被单线程访问,编译器会将该volatile变量当做一个普通变量来对待,这种优化可以在不改变程序执行结果的前提下提高程序的执行效率
happens-before原则
概念:happens-before原则,字面上是说一个操作发生在后一个操作的前面,但是深层次要表达的含义是前一个操作的结果对后一个操作发生时是可见的,这是Java内存模型为程序员提供的程序是顺序执行的视角;实际上JMM针对JVM实现时对编译器和处理器的优化约束是,在不改变程序执行结果的前提下,允许编译器和处理器随意优化程序;即程序的实际执行顺序不一定是按照程序员的预设顺序执行的,之所以这么做是为了在不改变程序执行结果的前提下尽可能地提高程序执行的并行度
happens-before规则为程序员提供一致的内存可见性,常用程序顺序规则和其他规则组合为并发程序提供可靠的内存可见性模型,理解happens-before规则就能编写并发安全的程序,这是JMM模型向程序员提供的理解JMM模型必要遵守的规则;另外JMM针对JVM的具体实现要求在不影响程序执行结果的前提下允许编译器和处理器做指令优化和指令重排
程序顺序规则:单线程内的执行顺序必须和代码的顺序保持一致;这里的含义是指禁止编译器和处理器对单线程内的操作进行重排序,多线程环境下为了提高性能,编译器和处理器可能会对代码进行重排序,多线程环境下还需要结合其他happens-before原则比如volatile变量规则、监视器锁规则确保线程之间的内存可见性和操作顺序
监视器锁规则:锁竞争时,对一个锁解锁必然发生在随后对这个锁加锁以前;含义是使用sychronized竞争本地锁时,一个线程只能获取上一个线程对共享数据的操作结果而无法拿到上一个线程操作前的共享数据
volatile变量规则:对于一个volatile变量的写操作相对于后续对该volatile变量的读操作可见
传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C,注意这里面的每个happens-before都要满足happens-before原则,这样的操作才具有传递性
start规则:主线程A启动子线程B,子线程B能看到主线程启动B前的操作
join规则:如果线程A中调用了threadB.join()并成功返回,那么线程B中的任意操作都happens-before于线程A从threadB.join()操作成功返回
线程终止规则:线程中的所有操作都happens-before于线程终止
对象终结规则:一个对象构造函数的执行结束happens-before于该对象的finalize方法开始执行
主内存:所有线程共享的内存空间
工作内存:每个线程特有的内存空间
写屏障
概念:对象引用发生变化时插入钩子或者检查点用于记录或者处理对象引用的变化,写屏障的实现方式包括卡表标记、SATB和引用计数
概述:
一个类的生命周期会依次经过加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备和解析统称为连接环节,加载、连接、初始化五个阶段组成了类加载过程
JVM启动时不会一次性加载所有用到的类、而是在需要使用的时候才会去动态加载
对类的使用指的是开发者使用类的静态字段、调用类的静态方法,创建类实例通过类实例使用成员变量以及调用实例方法
所有实例都会引用对应类的class实例,class实例又会引用加载该类的类加载器,当一个class实例的所有引用被销毁后需要再次创建对应类实例会先判断class实例是否还存在,不存在了才会尝试再次进行类加载;要卸载一个类的条件相当苛刻,仅加载该类的类加载器被卸载就很难实现,不管是引导类加载器还是所有的自定义类加载器几乎都不太可能被卸载,一般的自定义类加载器为了保证系统性能还会对类加载器添加缓存机制,只有OSGI和JSP的重加载等这类精心设计的可替换类加载器场景才会涉及到类的频繁加载和卸载问题
特点:
加载一个类会先加载其父类

加载阶段
功能:
通过类的全限定名获取类的二进制字节流
字节流可以本地字节码文件,zip和jar格式压缩包、war包、网络、动态代理技术、JSP文件、数据库以及加密文件等各种方式获取
从加密文件中解密获取,是一种防止字节码文件被反编译的保护措施[比如将安卓的.apk格式替换成.zip格式解压就能获取字节码文件,对字节码文件进行反编译就能盗版一个软件或者寻找软件漏洞,因此一般都会对字节码文件进行加密防止我们这种人反编译字节码,真正运行的时候可以通过比如类加载器对加密后的字节码文件进行一个解密操作]
将字节流代表的静态存储结构转化为方法区的类模板结构
在Java堆中生成一个当前类的Class实例作为方法区各种类型数据的访问入口
Class实例是instanceKlass实例的一个镜像,是访问类型元数据也是实现反射的入口,通过class实例能访问类模板结构中的各种数据
Class类的构造器是私有的,只有JVM能够创建class实例
通过Class对象可以通过反射获取调用当前类声明的所有方法和字段
特点:
类加载在初始化阶段以前除了加载阶段用户可以通过自定义类加载器来控制字节流的获取方式,其余阶段都由JVM主导控制
加载阶段和连接环节的部分动作是交叉进行的,加载阶段尚未结束,连接环节就可能已经开始了
类加载器只负责加载阶段,其他阶段由执行引擎负责
验证阶段
功能:验证阶段会对文件格式、元数据、字节码和符号引用进行验证,确保字节码二进制字节流数据符合JVM规范,保证字节码字节流信息不会危害虚拟机自身安全,验证不通过会报verify error
文件格式验证:保证字节流符合字节码文件规范,比如模数检查、版本检查、指令长度检查等,格式验证和加载阶段同时进行,验证通过后才会加载类信息到方法区生成类模板结构,生成类模板结构后才会执行后续三项验证
元数据验证:检查字节码信息在语义上是否符合JVM规范,例如除Object外所有的类的属性表中都有指定父类、检查被final修饰的方法或者类没有被重写或者继承过、非抽象类是否实现了所有抽象方法或者接口方法、检查是否存在不兼容的方法比如方法不能同时被abstract和final修饰
字节码验证:对字节码流进行分析,判断字节码是否可以被正确执行;比如检查字节码执行过程中是否会跳转到一条不存在的指令、方法的调用和变量的赋值已经指令的调用是否传递了正确类型的参数;方法的参数类型、对象类型转换是否正确
栈映射帧[StackMapTable]:用于检测在特定字节码处。局部变量表和操作数栈是否有正确的数据类型,该过程只能尽可能检查可以明显预知的问题,不能完全确定字节码可以被安全执行,没有通过该检查的类不会被虚拟机装载,通过了也不能证明这个类没有问题;StackMapTable在方法表的Code属性中的属性表的StackMapTable中
符合引用验证:在解析阶段才会执行,对常量池中的各种符号引用信息进行匹配校验,判断当前类是否具备访问其依赖的某些外部类、方法和字段等资源的权限以及这些资源是否存在
特点:
验证阶段不是必须执行,用户可以使用-Xverify:none参数关闭大部分类验证操作缩短类加载时间,但是相关参数在JDK13被标记为过时
准备阶段
功能:为类变量分配内存并赋默认零值,如果类变量是通过基本数据类型字面量声明的常量则直接赋实际值,因为常量不能被二次赋值;
常量的显式赋值语句中不涉及到方法或者构造器调用的基本数据类型或String类型字面量的显式赋值在准备阶段进行,涉及到方法或者构造器调用的常量仍然在初始化阶段的clinit<>()方法执行时显式赋值
解析阶段
功能:JVM将运行时常量池中类、接口、方法、字段的符号引用替换为直接引用
符号引用:能无歧义定位到所引用目标的一组符号,被引用目标不一定已经加载到JVM内存中,符号引用由常量池表项CONSTANT_String组合而成,CONSTANT_String会引用常量池表项中的CONSTANT_utf8_info字符串字面量,这些字符串会被创建在串池中
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或者能间接定位到目标的句柄,有了直接引用就说明被引用的目标必定已经存在于JVM内存中
特点:解析阶段一般在初始化阶段完成之后才执行
初始化
功能:执行前端编译器自动生成的类构造器方法<clinit>()对类变量进行显式初始化并执行类中的静态代码块,clinit<>()方法由常量的方法显式赋值语句、静态变量的显式赋值语句以及静态代码块顺序合并产生
注意接口中是不能有静态代码块的
特点:
除了加载阶段用户可以通过自定义类加载器的方式局部参与,在初始化阶段前的其他类加载阶段都由JVM主导控制,直到初始化阶段,JVM才将主导权移交给应用程序开始执行类中编写的Java代码
初始化环节在遇到new、getstatic、putstatic或invokestatic字节码指令时才会被触发执行,相应的静态代码块在对应时机才会有且被执行一次;<clinit>()方法的执行为了保证线程安全是加了锁的,但是clinit<>()方法的访问标志只有一个static,没有synchronized,上的锁是一个隐式的锁;多线程竞争进行类加载如果<clinit>()方法存在死循环或者无法获得资源发生死锁可能会导致等待锁的线程比如同时实例化某个或者某几个对象的线程全部阻塞无限时等待,而且这种阻塞没有任何提示信息很难被发现,也无法通过jvisualVM检测到
典型场景是在A的静态代码块中去加载B,在B的静态代码块中又去加载A,两个线程同时去加载这两个类就可能导致两个类的加载全部死锁,最终需要加载这两个类的线程全部阻塞,因此对待一个类的静态代码块中加载另外一个类的情况一定要特别小心
如果一个类没有类变量或者静态代码块,编译生成的字节码文件中不会有<clinit>()方法
<clinit>方法中的代码不管是类变量声明时的赋值语句还是静态代码块中的语句都是自上而下按顺序进行显式赋值,这些类变量在链接环节已经分配了内存,因此即使在源文件中静态代码块中的变量赋值语句在变量定义前面也能正常执行,只是静态代码块会先赋值,然后被变量定义处的赋值语句再次覆盖;加载一个类前总是先加载其父类,因此父类的<clinit>()方法总是先于子类的<clinit>()之前被调用,因为初始化一个类的子类会加载该父类且是主动使用类会在父类加载过程中就执行初始化环节
非法前向引用:在静态代码块后面定义的类变量只能在静态代码块中赋值,但是不能对在静态代码块中对该变量进行调用比如System.out.println(变量),如果在静态代码块前面定义的变量则不存在该问题
只有主动使用类才会在类加载过程中执行初始化环节,除了以下八种主动使用类的情况其他都是被动使用,其实对应的就是上述new、getstatic、putstatic或invokestatic四条字节码指令
使用new关键字、或者通过反射、克隆、反序列化创建一个类的实例
使用字节码指令getstatic、putstatic访问或者赋值类或者接口的静态字段或者非字面量显示赋值的常量,注意使用字面量显示赋值的常量被访问不会触发类加载的初始化阶段
使用字节码指令invokestatic调用类的静态方法
使用java.lang.reflect包中的反射类的方法时,比如Class.forName(str)主动加载指定类
初始化一个类的子类[加载一个类前会首先保证加载该类的所有父类]
注意初始化一个类并不会先初始化其实现的接口,初始化一个接口也不会先初始化他的父接口,接口只有在程序首次使用静态字段或者访问非字面量显式赋值的常量时才会触发初始化环节
JVM启动时加载主类,主类的执行将依次导致所需的类的类加载
JDK7开始提供的动态语言支持,首次调用反射包的MethodHandle实例时需要初始化该MethodHandle指向的方法所在的类[即涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类需要先进行初始化]
如果接口中定义了默认方法,接口的实现类发生初始化前需要先初始化该接口
被动使用的场景
通过子类访问父类中声明的静态变量,只有父类会进行初始化,子类会被类加载但是不会触发子类的初始化
声明引用类型数组不会触发该类的初始化,在后续主动使用该类时才会触发初始化
访问通过字面量显式赋值声明的常量不会触发所在类或者接口的初始化
通过classLoader的loadClass方法使用指定类加载器主动加载类不会触发类的初始化
概述:对象实例化过程是执行构造器对应的<init>()方法的过程,<init>()方法由非静态变量声明语句、非静态代码块以外的其他代码代码块和对应构造器组成
特点:
<init>()方法支持重载,当前类有几个构造器就有几个<init>()方法
<init>()方法中的代码执行顺序为:父类成员变量初始化、父类代码块、父类构造器、子类成员变量初始化、子类代码块、子类构造器
静态变量声明、静态代码块、成员变量声明、普通代码块、构造器的执行顺序
具有父类的子类实例化代码执行顺序
加载当前类会先加载当前类的父类,类加载过程的初始化阶段会执行<clinit>()方法,<clinit>()方法会先执行当前类的静态变量声明语句再执行静态代码块;
子类加载完成后进行对象实例化调用<init>()方法会先调用执行父类的<init>()方法,<init>()方法依次执行当前类的成员变量声明语句、普通代码块和构造器代码;
但是特别注意
执行<init>方法前准确的说是new指令执行期间成员变量会被赋零值,执行<init>方法时如果父类和子类都声明了一个同名成员变量并且都进行了显式赋值[经过测试没有进行显式赋值也会表现为好像对父和子两个实例各自的成员变量的隔离操作],通过多态的方式创建子实例[非多态的方式创建子实例效果也是一样的],这个成员变量的行为会表现为好像父类和子类各有一个实例,这两个实例的同名成员变量是相互隔离的,父类的<init>方法执行期间在成员变量声明处对同名变量的显式赋值不会对子类的同名变量生效,子类的同名变量会一直保持默认零值直到子类<init>方法开始执行;在父类的构造方法中对该变量进行访问更新就好像在访问父类实例自身的成员变量一样,父类的<init>()方法执行完毕子类实例的同名成员变量仍然为默认零值;子类的<init>方法执行时对子实例该成员变量的显式赋值才会首次生效;
如果子类没有声明过父类中声明的同名成员变量,就会完美表现为下图的情况,就好像只有一个子实例,父类的<init>方法都在对子实例继承的该成员变量先进行赋值和更改,在父类<init>方法期间对该成员变量的访问更改似乎都在对子实例的成员变量进行访问更改,子类<init>方法开始执行时子实例的该成员变量会表现为经过父类<init>方法对该成员变量的所有访问更改操作,就像是父类的<init>方法只是对子实例的成员变量进行操作而没有额外的父实例
如果父类和子类都声明了返回值类型和形参列表都相同的非抽象同名方法,在父类<init>方法执行期间对该方法的调用都会直接调用子类的同类方法实现而非父类的同名方法实现,且父类<init>方法执行期间对该同名方法调用时,方法中对同时在父类和子类都声明过的同名成员变量进行访问会直接访问子实例的成员变量而非父实例的同名成员变量,此时子实例同名成员变量会始终为默认零值
大多数面向对象的编程语言中,属性的访问都是静态绑定而非动态绑定,编译阶段编译器只会根据引用类型而非对象的实际类型来决定访问引用类型对应的属性值,这意味着如果子类和父类同时声明了同名成员变量name,通过多态的方式声明了子类型实例如Animal dog = new Dog();,即使在子类的<init>方法中对name进行了显式赋值以及更新操作,通过dog.name访问到的仍然是父类<init>方法对父实例同名成员变量的显式赋值及更新结果,就好像完全没有经过子类<init>方法显式赋值或者更新过一样;通过((Dog)dog).name向下强制转型后再对同名成员变量进行访问就像单独在访问子实例的成员变量一样

对象实例化方式
通过构造器、类的静态方法如XxxBuilder/XxxFactory调用私有构造器创建对象
class.newInstance():通过反射的方式调用被public修饰的空参构造器实例化对象
该方法在JDK1.9被弃用
constructor.newInstance(Xxx):通过反射的方式调用无参或者有参构造器实例化对象,对构造器的权限修饰符没有限制更加灵活,因此把Class.newInstance()废弃了
clone():当前类实现Cloneable接口,实现其中的clone()方法,实际上Cloneable接口是一个标识接口,不带任何方法,重写的是Object中的clone()方法,通过该方法可以复制一个已有对象,是对对象的浅拷贝
对象的浅拷贝:即创建一个相同类型的新对象,如果属性值是基本数据类型新对象直接赋值老对象属性的值,如果属性值是引用数据类型新对象直接赋值老对象属性的引用地址
反序列化:通过反序列化可以从文件或者网络中获取一个对象的二进制流,使用二进制流还原成一个对象
第三方库如Objenesis:使用相关字节码技术跳过构造函数创建实例
Java中new关键字的原理[不包含数组和Class对象]
1️⃣:JVM遇到字节码new指令时,通过操作数去常量池定位一个类的符号引用,检查符号引用代表的类是否已经被类加载,如果没有会首先加载相应类
2️⃣:类已经被加载,JVM在Java堆中根据对象结构和属性为实例计算对象大小和分配内存;如果Java堆内存是规整的,JVM会使用指针碰撞的方式为新对象分配内存;如果Java堆不是规整的,JVM会使用空闲列表的方式为新对象分配内存;
对象结构:
对象头Header:包含运行时元数据MarkWord和类型指针Klasspoint两部分,如果是数组对象还会额外包含数组长度部分;默认情况下非数组对象32位操作系统对象头占8个字节,64位操作系统下占12个字节;
MarkWord:存储对象哈希值、GC分代年龄、锁状态标志、偏向锁标记、持有偏向锁的线程ID、偏向锁的时间戳、轻量级锁状态下指向栈中锁记录的指针、重量级锁状态下指向同步监视器的指针;32位操作系统固定占4个字节,64位操作系统固定占8个字节
KlassPointer:指向方法区的类元数据即InstanceKlass结构,能够由此确定对象所属类、类的完整继承结构等;在32位操作系统占4个字节,在64位操作系统占8个字节,从JDK7u4开始指针压缩就是默认开启的,KlassPointer也受指针压缩的影响,因此默认情况下KlassPointer始终占4个字节;
如果是数组对象,对象头还会记录数组的长度;数组长度无论是32位还是64位操作系统时钟占4个字节,但是数组对象未开启指针压缩的情况下对象头会自动进行8位对齐
实例数据Instance Data:
存储当前类中声明以及从父类继承下来的实例字段,先存放父类中定义的变量,再存放子类中定义的变量;相同宽度的字段总是被分配在一起;如果参数CompactFields=true,子类的窄变量可能插入到父类变量的空隙,默认该参数就为true
基本数据类型不管是32位还是64位操作系统都是占相同的固定字节;引用64位操作系统未开启指针压缩占8个字节,开启指针压缩占4个字节;32位操作系统引用占4个字节
数组对象要预留数组长度为对应单个元素长度乘以数组总长度
对齐填充Padding:
整个对象进行八位对齐,增加CPU的读取效率
内存分配方式
指针碰撞:以一个指针作为内存分界点指示器,指针一侧为已使用内存,指针另一侧为未使用内存,分配内存仅仅只是将指针向空闲空间方向挪动与新创建对象大小相等的距离
空闲列表:JVM维护一个记录可用内存块的列表,为对象分配内存时从空闲列表选出一块足够大的空间分配给对象实例并更新列表上的记录
Java堆是否规整由采用的垃圾收集器的垃圾收集算法决定,像基于复制算法的Serial和ParNew新生代垃圾收集器带有内存压缩整理能力;像基于标记清除算法的CMS老年代并发垃圾收集器就不具备内存规整的能力
此外为对象分配内存要考虑线程安全问题,比如JVM正在使用指针碰撞的方式给对象A分配内存,还没有来得及修改top指针的值,又使用了原来的指针为对象B分配内存。JVM采用CAS配上失败重试的方式保证更新操作的原子性以及使用TLAB本地线程分配缓冲两种方式来解决对象内存分配的线程安全问题
TLAB:每个线程预先在Java堆中预先分配一小块内存称为TLAB[Thread Local Allocation Buffer],线程在对应TLAB中为对象分配内存,TLAB中的对象实例可以被所有线程共享,TLAB用完时还会创建新的TLAB来分配内存,只有无法创建TLAB时才会使用同步锁定的方式直接在堆中直接为对象分配内存,可以使用JVM参数-XX:[+|-]UseTLAB决定JVM是否启用TLAB;通过TLAB可以让各个线程在各自的TLAB同时为对象分配内存不会相互干扰,避免所有线程都去同步竞争伊甸园区的同一块内存;JVM为线程分配TLAB的过程也会加CAS锁
默认情况下TLAB只占伊甸园区容量的1%,实际单个TLAB默认大小还需要除以单个GC轮次内单个县城能从伊甸园区申请多少次TLAB以及预期的当前GC周期内活跃的线程数通过计算得出
TLAB剩余空间无法为新对象分配内存时会判断当前TLAB的剩余空间是否大于通过JVM参数-XX:TLABRefillWastePraction指定的TLAB最大可浪费空间,默认为单个TLAB大小的1/64,如果大于会使用CAS锁将新对象直接分配在伊甸园区并继续使用当前TLAB;如果小于会重新在伊甸园区为线程再分配一块新的TLAB并为该对象分配内存,如果新TLAB还是存不下该对象也会使用CAS锁直接分配在伊甸园区;如果实在无法为线程分配新的TLAB,当前线程所有的剩余对象都会在伊甸园区分配;TLAB内存浪费现象比较严重,因此JVM设置TLAB最大可浪费空间来控制该内存浪费现象
3️⃣:内存分配完成后,JVM将对象实例的字段全部初始化为默认零值,保证对象实例字段在java代码中不显式赋值或者显式赋值前也能直接访问到字段对应的零值
如果JVM在TLAB中为对象分配内存,在TLAB中为对象分配内存时就会为字段赋值默认值
注意只有对象的字段不进行显式赋值才会在对象分配内存后赋默认零值并且可以直接在方法中使用未经显式赋值的字段,在方法中声明的局部变量必须进行显式初始化,否则没显式赋值就直接使用会编译报错
初始化分为默认初始化[零值初始化/隐式初始化]、显式初始化/代码块初始化、构造器初始化
默认初始化[零值初始化]:为指定类型变量赋默认零值,变量在没有显示初始化的情况下Java会为变量分配默认值
显示初始化/代码块初始化:显式初始化是变量在声明时就通过赋值语句指定初始值,代码块初始化像静态代码块一样但是不需要加static关键字,可以给实例变量赋值
构造器中初始化:对象创建时通过构造器初始化对象的成员变量
4️⃣:JVM为对象设置对象头信息,对象头信息包括对象所属类的class实例引用、对象哈希码、GC分代年龄、锁状态标识等
对象哈希码在第一次真正调用当前对象的hashCode()方法时才会计算和赋值
从JVM的角度到此所有字段都为默认的零值的新对象已经产生,但是Java程序层面执行构造器创建对象还没开始
5️⃣:执行字节码invokespeacial指令调用类的<init>()方法按照开发者的意愿初始化对象实例,此时一个对象实例被完全构造出来
前端编译器在遇到new关键字会依次生成new、dup和invokeSpecial三条字节码指令,new指令负责检查指定类是否已经加载、计算新对象大小并为新对象分配内存,dup指令会复制新对象引用压栈操作数栈作为第三条指令invokespecial的操作数,invokespecial调用目标类型的构造器初始化对象实例
成员变量的显式初始化、代码块初始化和构造器初始化代码对应字节码指令都会按顺序组合到<init>方法中,调用子类的<init>方法前会先调用父类的<init>方法
对象的分配策略
对象会被首先分配在伊甸园区,熬过第一次YGC进入幸存者区;
在幸存者区熬过若干次YGC达到指定分代年龄阈值会晋升到老年代;
大对象会直接被分配在老年代;
幸存者区放不下新生代的存活对象超出部分的对象也会直接晋升到老年代;
没有启用配置-XX:+UseCMSInitiatingOccupancyOnly的情况下只有第一次会按照预设的分代年龄阈值控制对象晋升老年代,此后会取幸存者区中小于某个年龄的所有对象内存占用超过幸存者区的指定容量时[默认为幸存者区的50%]的年龄和预设分代年龄阈值中的较小值作为新的分代年龄阈值
这个问题和解答出自牛客Java面试宝典,讲的很不清楚,需要完善
概念:
类加载器是用于实现通过一个类的全限定名获取该类的字节码二进制字节流过程的代码,除了引导类加载器内嵌于JVM由C++实现,其他类加载器都放到JVM外部由Java实现并都继承自抽象类ClassLoader,方便让应用程序决定如何去获取所需的字节码二进制字节流,也赋予了Java类可以被动态加载到JVM中并被执行的能力
用于实现类加载过程的加载环节
类加载器除了加载类以外还可以加载Java应用需要的文本、图像、配置文件、视频等文件资源
JVM支持引导类加载器和自定义类加载器,JVM规范将所有直接或者间接继承自抽象类ClassLoader的类加载器都划分为自定义类加载器,包括JDK提供的扩展类加载器和系统类加载器
特点:
类加载器在JDK1.0只是单纯为了满足Java小程序Java Applet被设计出来,但是在设计之初没有将类加载器绑定在JVM内部,JDK提供现成的类加载器实例并允许用户在应用中自定义类加载器,如今类加载器在热部署、代码热替换、字节码加密解密领域大放异彩
两个类要相等要求全类名必须相等,加载这两个类的类加载器实例必须是同一个,否则即使同一台JVM中一个字节码文件被两个不同的类加载器实例加载,这两个类就必定不相等,这些不同的class实例指向方法区的类模板数据也是不同的
每个类加载器实例都有一个独立的类名称命名空间,会保存已经由当前类加载器以及其父类加载器加载过的所有类的class实例的引用地址,进行类加载时会判断当前类是否已经被加载,如果已经被加载会直接返回,否则才会尝试进行加载;一旦类加载器加载的类都被卸载,类加载器也会自动被销毁;同一个命名空间中不会出现全类名相同的两个类;不同命名空间中完全可能出现全类名相同的两个类,在大型应用中也会借助该特性来运行同一个类的不同版本,比如在Tomcat中让同一个工程通过不同类加载器的加载实现工程的隔离,在实际生产中应用比较广泛
每个class实例都有一个引用指向加载该类的类加载器
数组类不是通过类加载器加载而是由JVM在需要时自动创建,数组类通过getClassLoader()方法获取类加载器的时候获取的是加载数组元素类型的对应类加载器,基本数据类型是JVM预设的也不需要进行类加载,如果数组元素是基本数据类型尝试获取数组的类加载器会获取到null
ClassLoader中有loadClass和findClass两个关键方法,loadClass方法就是双亲委派机制的具体实现,findClass方法是根据类的全类名获取类文件的二进制字节流,默认是空方法,需要子类进行重写;可以通过重写loadClass打破双亲委派机制,不想打破重写findClass方法即可;系统类加载器和应用类加载器的findClass方法和defineClass方法都在URLClassLoader中重写的,但是defineClass最终还是调用的重载的本地defineClass解析字节码二进制数据返回class实例,如果用户没有太复杂的类加载需求可以直接继承URLClassLoader连字节码文件加载逻辑都不需要写就能自定义类加载器;URLClassLoader继承自SecureClassLoader,主要涉及对代码源位置和证书以及字节码的访问权限验证的方法
JVM通过显式加载和隐式加载两种方式加载字节码文件;显式加载指在Java代码中通过显式调用类加载方法如Class.forName(name);、class.getClassLoader().loadClass(name);或者ClassLoader.getSystemClassLoader().loadClass(name);加载字节码文件;不通过显式加载方式加载的字节码文件都是隐式加载,通过JVM自动控制加载过程,比如一个类引用了另一个类的对象,额外引用的类如果还没有被加载就会通过JVM自动加载到内存中
基础类库也可能需要加载用户代码,JDK提供的API除了提供默认的标准实现,仍然可能需要用户来提供自己的实现,比如Java中的JNDI、JDBC、文件系统、Cipher加密解密架构等,都是利用JDK内部的ServiceProvider/ServiceLoader机制来实现的,这些情况不会使用双亲委派模型来加载而是使用线程上下文类加载器加载
在双亲委派模型下,子类加载器能将类加载请求委托给父类,但是父类加载器不能将类加载请求委托给子类;同一个类加载器的多个实例无法看到相互之间的加载结果,一个类可以被同一种类加载器的不同实例加载多次
如果运行程序时,程序中使用指定类加载器加载某个类,但是字节码文件又不在该类加载器的加载目录下会抛ClassNotFoundException异常
只有被同一个类加载器加载的两个类才能进行类型转换,否则类型转换时会抛出异常
Class.forName()和ClassLoader.loadClass()都是显式加载指定类返回class实例,Class.forName()将类加载请求委托给当前类的类加载器,类加载期间会执行类的初始化阶段;ClassLoader.loadClass是一个实例方法,通过指定类加载器进行调用并将类加载请求委托给当前类加载器,类加载期间不会指定类的初始化阶段即不会调用clinit<>()方法,只会在类的第一次主动使用时才会触发初始化阶段,而且不会执行类加载期间的解析环节
JDK9系统类加载器变成了ClassLoaders的内部类,系统类加载器的父类加载器变成了取代扩展类加载器的平台类加载器,平台类加载器的父类加载器是引导类加载器,因为JDK9新特性模块化系统导致类加载器也有了一些新变化
JDK9是基于模块化构建的,将原来的tr.jar、tools.jar拆分成数十个JMOD文件,Java类库天然满足了可扩展的需求,无需再保留JAVA_HOME\lib\ext目录,此前使用JAVA_HOME\lib\ext目录或者java.ext.dirs系统变量来扩展JDK的功能已经没有继续存在的价值,扩展类加载器被重新命名为平台类加载器
JDK9及以后启动类加载器BootClassLoader、平台类加载器、系统类加载器全部继承自BuiltinClassLoader,BuiltinClassLoader继承自SecureClassLoader;引导类加载器也变成了JVM和Java类库共同协作实现的类加载器,但是为了向下兼容,尝试获取启动类加载器仍然会返回null
类加载器新增名称字段,主要用于与类加载器相关的调试场景下
双亲委派机制也发生了一些变化,Java代码被划分成了若干模块,不同的模块由不同的类加载器进行加载,因此在进行类加载在把当前类委派给父类加载器加载前先判断当前类是否能归属到某一个系统模块中,如果是就找负责加载该模块的类加载器处理类加载请求
类加载器分类
引导类加载器:也叫启动类加载器,使用C和C++实现嵌套在JVM的内部,负责加载<JAVA_HOME>\lib目录下-X:bootclasspath参数指定目录下像rt.jar、tools.jar这种名字能被JVM识别的字节码文件或者字节码压缩包[名字无法被JVM识别的类库即使放在对应目录下也不会被加载],Java的核心类库都使用引导类加载器加载,引导类加载器也只加载包名为java、javax、sun打头的类
特点:用户尝试获取引导类加载器的行为最后都只能获取到null值
rt.jar:rt表示RunTime,是Java基础类库,常用的以java.打头的类库都在该压缩包内
JDK9开始引入了模块系统、扩展类加载器被改名为平台类加载器,JavaSE除了少数几个关键模块还由启动类加载外,其他模块均由平台类加载器加载
扩展类加载器:sun.misc.Launcher类中的内部类,负责加载<JAVA_HOME>\lib\ext目录、系统变量java.ext.dirs指定目录下的所有类库
特点:JDK允许用户将具有通用性的类库放在ext目录下来扩展JavaSE的功能,在JDK9以后这种扩展机制被模块化天然的扩展能力取代
系统类加载器:也叫应用类加载器,sun.misc.Launcher类中的内部类,负责加载用户类路径下[即用户通过环境变量CLASSPATH指定的路径]的所有类库,系统类加载器是默认的线程上下文类加载器
特点:如果应用程序没有自定义过类加载器,一般情况下系统类加载器就是默认的类加载器,用户的自定义类都通过系统类加载器进行加载
自定义类加载器:自定义类直接或者间接继承抽象类ClassLoader并声明类加载目录对应用功能进行扩展,JDK1.2以前重写loadClass方法自定义类加载过程,JDK1.2及以后引入了双亲委派机制并实现在loadClass方法中,不再建议用户覆盖loadClass方法通过新引入的findClass方法自定义类加载逻辑,当父类加载不了findClass方法会被loadClass方法调用;
应用场景:
隔离加载类:不同框架的类、框架与用户自定义类的全类名可能相同,大型主流框架一般都会使用自定义类加载器将框架和用户代码加载到不同环境中避免全类名相同的类发生冲突;像Tomcat这种Web应用服务器内部也会自定义好几种类加载器用户隔离一个Web服务器上的同一套源码的多个应用程序
修改类的加载方式:扩展系统从各种形式的数据源获取字节码二进制流的能力
防止源码泄漏:获取到字节码二进制流以后对数据进行解密再加载到JVM内存防止源码泄漏
特点:
自定义类加载器的父类加载器是系统类加载器
没有太复杂的需求可以直接继承URLClassLoader甚至连findClass方法都不需要重写
通过自定义类加载器实现类库从本地或者网络动态加载是Java语言繁荣的关键因素之一,像OSGI和Eclipse的插件机制都是通过类加载器实现的插件机制,能为程序提供动态增加新功能且无需重启应用的能力;像大型框架在框架内部就通过自定义类加载器实现了不同组件模块、框架和框架之间组件的隔离;引入框架无需任何修改就能为应用添加新功能,想要在C/C++中不修改程序就能为应用添加新功能几乎是不可能的事情
自定义类加载器可以通过重写loadClass()方法抹去双亲委派机制,但是仍旧不能使用自定义类加载器加载核心类库,因为JDK还为核心类库提供了一层保护机制,因为类加载器最后都会在findClass方法中调用JDK提供的本地方法defineClass()传入字节码二进制数组获取class对象,该方法会调用preDefineClass()提供对JDK核心类库的保护
概念:从JDK1.2开始Java就一直保持双亲委派的类加载架构,引导类加载器、扩展类加载器、系统类加载器和自定义类加载器依次存在逻辑上的父子关系,通过类加载器中的parent字段的组合方式指定某个类加载器的父类加载器,双亲委派模型要求:一个类加载器收到类加载请求,首先将这个请求委派给其父类加载器,类加载请求依次首先被委派到引导类加载器,只有父类加载器向子类加载器反馈自己无法完成该类加载请求即父类加载器的搜索范围内没有找到所需的类时,子类加载器才会尝试自己去完成类加载请求
双亲委派机制的实现逻辑在ClassLoader的loadClass方法中,首先通过当前类加载器的命名空间判断指定类是否被当前类加载器加载过,如果没有调用父类加载器的loadClass方法尝试加载当前类直到类加载器的父类加载器为null,单独让引导类加载器尝试加载一次当前类,如果父类加载器能加载并返回了对应的class对象,子类加载器直接返回该class实例,如果不能加载子类加载器调用findClass方法尝试自行加载当前类并调用本地方法defineClass解析字节码二进制数据返回对应的class实例;如果所有类加载器都遍历了仍无法加载当前类则抛出ClassNotFoundException
优点:
类加载器的层次关系导致类也有优先级层次关系,加载与rt.jar包中的核心类库全类名相同的类不论被哪一种类加载器加载,最终都会委派给引导类加载器进行加载。防止用户通过命名与核心包类库全限定名相同的类使用非引导类加载器加载来产生如多个不同的Object类篡改Java类库的基础行为;
用户尝试加载与核心类库全类名相同的类,由于双亲委派机制类加载请求最终会被委派给引导类加载器,引导类加载器根据全类名只会去加载核心包下的同名类而不会去加载用户自定义的同名类
此外就算攻击者绕过了双亲委派模型,ClassLoader的preDefineClass方法仍然会在定义类之前进行类名校验,任何以java.开头的类名都会触发抛出SecurityException防止恶意代码定义或者加载伪造的核心类
确保一个类只能被唯一确定的类加载器加载,避免类的重复加载
不允许用户使用核心包名防止用户随意使用引导类加载器
缺点:
双亲委派机制会导致顶层的类加载器无法将类加载请求委派给底层的类加载器,这会导致系统核心类无法访问用户自定义的类,比如在核心类库提供一个接口,该接口被用户类实现,该接口提供了一个工厂方法用于创建接口的子实现实例,但是JVM默认将类加载请求首先委派给加载当前类的类加载器,引导类加载器无法将类加载请求委派给底层类加载器,就会导致该工厂方法无法创建由应用类加载器加载的对象实例的问题
双亲委派模型不是JVM规范的强制约束,只是一种推荐类加载方式,一些核心API只提供了接口需要用户来提供子实现,此时会选择使用线程上下文类加载器来加载这些子实现,默认是系统类加载器,此时就会变成引导类加载器将类加载请求委派给子类加载器,这种行为就破坏了双亲委派机制
JVM判定两个类相同的规则是:类的全类名相同,加载这两个类的类加载器相同
双亲委派机制的实现
双亲委派机制的代码实现在java.lang.ClassLoader的loadClass()方法中,实现逻辑是反复调用父类加载器的loadClass()方法进行类加载直到调用到引导类加载器,如果父类加载器能加载直接返回父类加载器加载返回的class对象,如果父类加载器无法加载则返回null,子类加载器才调用自己的findClass()方法尝试进行加载;如果最终没有子类加载器能加载该类,则抛出ClassNotFoundException异常[findClass()方法被loadClass()方法调用]
双亲委派模型不是JVM规范的强制性约束,只是Java设计者推荐的类加载器实现方式,java领域的大部分类加载器都遵循该模型,历史上出现过三次被大规模破坏的情况
1️⃣:JDK1.2以后双亲委派模型才被引入
抽象类ClassLoader在JDK1.1就存在了,为了兼容用户已经自定义的类加载器代码,引入双亲委派机制时已经无法通过技术手段避免loadClass()方法被子类覆盖,只能在JDK1.2及以后添加一个findClass()方法,父类加载器调用findClass()尝试加载失败时子类加载器才会调用findClass()方法尝试类加载,尽可能引导用户在findClass()方法中编写类加载逻辑而不要重写loadClass()方法。将双亲委派模型的代码实现放在loadClass()方法中,既保证类加载器的双亲委派机制也能让用户自定义类加载逻辑;
自定义类加载器如果不想打破双亲委派机制可以重写findClass()方法,如果想打破双亲委派机制可以重写loadClass()方法
2️⃣:JDK引入线程上下文类加载器破坏了双亲委派机制
核心类库总是被用户代码继承和调用,双亲委派模型能保证核心类库的一致性问题,但是双亲委派模型本身存在缺陷,如果有核心类库要调用用户代码,需要对用户代码进行类加载;但是在不指定类加载器的前提下,JVM会将类加载请求委托给当前类的类加载器[这个论点出处是AI啊,官方文档只明确指出过Class.forName()会使用当前类的类加载器加载指定类],即在核心类库调用用户实现类会将用户实现类的类加载请求委托给引导类加载器,引导类加载器无法加载用户实现类且没有父类加载器可以委托就会直接抛出ClassNotFoundException异常。
为了解决这个问题,Java引入了线程上下文类加载器,核心类库可以通过获取线程上下文类加载器去加载用户实现类,这是一种父类加载器请求子类加载器完成类加载的行为,打破了双亲委派模型的层次结构,逆向调用子类加载器,因此线程上下文类加载器是一种不优雅但是无可奈何的设计。
Java中涉及服务提供接口SPI的类加载过程基本都通过线程上下文类加载器完成的,比如对用户资源进行查找和集中管理的标准接口JNDI、数据库驱动JDBC、加密架框架JCE、对象XML绑定框架JAXB和服务间通信框架JBI等SPI接口需要调用加载由应用层自行实现提供的接口子实现;为了消除这种极不优雅的实现方式,JDK6开始提供了java.util.ServiceLoader类,通过META-INF/services中的配置信息和责任链模式才给SPI的类加载提供了相对合理的解决方案
核心类库调用用户实现类的典型例子就是JNDI服务,JNDI服务用于对资源的查找和集中管理,需要调用由其他厂商实现并部署在类路径下的JNDI服务提供接口SPI的代码;
线程上下文类加载器:线程上下文类加载器是线程的一个属性,用户可以通过thread.setContextClassLoader()方法对线程上下文类加载器进行设置,如果创建线程时没有指定线程上下文类加载器会直接从父线程[父线程即创建启动当前线程的线程]继承,如果整个应用程序都没有设置过线程上下文类加载器,那么线程上下文类加载器就是系统类加载器
Tomcat为了能优先加载Web应用目录下的类再加载其他目录下的类,自定义了一个Web应用类加载器WebAppClassLoader,Web应用类加载器的父类加载器为共享类加载器,共享类加载器和Catalina类加载器的父类加载器为通用自定义类加载器CommonClassLoader,通用自定义类加载器的父类加载器为系统类加载器;通用自定义类加载器负责加载tomcat的common目录、共享类加载器负责加载shared目录即加载像Spring、Mybatis这种能在Web应用类之间共享的类库、Catalina类加载器负责加载server目录即Tomcat自身的类、web应用类加载器负责加载WEB-INF目录;通用自定义类加载器加载的类可以被其他三个类加载器加载的类使用,因此负责加载公共类库实现公共类库的共享和隔离;Catalina类加载器和共享类加载器是平行关系都以通用自定义类加载器作为父类加载器,这两个类加载器加载的类相互隔离无法互相调用;共享类加载器作为Web应用类加载器的父类可以隔离Tomcat自身的类和Web应用的类,每个Web应用都会创建一个单独的Web应用类加载器实例实现各个Web应用之间的相互隔离并将该web应用类加载器作为启动Web应用的线程的线程上下文类加载器,这样Web应用创建的线程默认的线程上下文类加载器就是对应应用的web应用类加载器;这些类加载器是符合双亲委派机制的,但是这还无法满足tomcat的业务需求,比如
Spring中的jar包被共享类加载器加载,但是Spring中的类常常会用到应用提供的使用了Spring相关注解的业务类,业务类被共享类加载器的子类加载器加载,而Spring的类加载器无法加载这些业务类,Tomcat就是通过线程上下文类加载器解决的该问题,Spring的类需要加载业务类时将业务类的类加载请求委托给线程的上下文类加载器即默认的web应用类加载器,通过线程上下文类加载器的方式让高层的类加载器委托低层级的类加载器来加载业务类破坏双亲委派机制逆向使用类加载器
3️⃣:模块化规范破坏双亲委派机制
代码热替换、模块化部署指开发者希望Java程序能像电脑外设一样,不用重启电脑直接拔插鼠标、键盘、U盘就能立即升级外设,对于线上系统重启就可能会被列为生产事故,因此程序动态性对大型系统或者企业级软件开发者具有很大吸引力
2008年,SUN公司提出的JSR-294、JSR-277规范提案败给IBM主导的JSR-291规范提案,IBM推出了对应的OSGI R4.2项目,SUN公司不甘心失去模块化规范的主导权,后续在JDK9推出了Jigsaw项目,但此时OSGI已经成为业界事实上的Java模块化标准。时至今日OSGI在运行期动态热部署上仍然压Jigsaw一头,而jigsaw只能局限在静态地解决模块间封装隔离和访问控制的问题
OSGI实现模块化热部署的关键是其自定义类加载器机制,每个模块都有一个自己的类加载器,需要更换模块时,将模块连同类加载器一起换掉来实现代码的热替换。OSGI的类加载器不再是双亲委派模型推荐的树状结构而是发展为更复杂的网状结构,OSGI中的类加载器只有的类查找顺序只有部分类符合双亲委派模型,其余的类查找都是在平级的类加载器中进行;这种通过多个自定义类加载器实例加载同一个字节码文件生成不同class实例的方式也破坏了双亲委派机制
概念:类加载与卸载、常量的创建与废弃、对象的分配与回收过程是动态的,导致堆和方法区有显著的不确定性,垃圾收集器关注的就是堆和方法区的内存管理,需要对垃圾对象、废弃常量和不再使用的类型进行判定
一个对象的死亡需要经历两次标记过程和一次筛选过程,对象先经过可达性分析算法进行第一次标记,然后筛选被标记的对象是否有必要执行finalize()方法,finalize方法已经被调用过或者对象没有覆盖finalize方法都没有必要执行finalize方法,在finalize方法执行期间仍然可以重新引用被标记为不可触及的对象让对象活过来,但是一旦finalize方法执行完毕对象仍然没有与引用链上的任何一个对象建立关联,第二次标记时必定被回收
finalize方法因为影响Java的安全性和GC性能一直被认为是一个糟糕的设计,JDK9就开始逐步废弃各个类的finalize方法,在JDK18中被彻底移除
垃圾对象判定算法
引用计数算法:
概念:每个对象都指定一个引用计数器,每当该对象被一个引用指向时引用计数器加1,每当一个引用失效时引用计数器减1,任意时刻只要对象的引用计数器为0该对象就不可能再被使用就被认为是垃圾对象[因为不可能再通过某个引用找到该对象的实际地址]
特点:引用计数法致命的缺点是需要考虑非常多的例外情况;因此主流的JVM都没有选用引用计数算法来管理内存,但是Phython就是使用的引用计数算法
比如单纯的引用计数法很难解决对象间的循环引用问题,比如两个对象除了在字段中相互引用对方外再无任何引用,实际上这两个对象都不可能再被访问到,但是因为引用计数器都不为0无法被回收;再比如环形链表通过某个节点被外部引用,一旦该节点的引用失效,该环形链表再也无法被访问,但是环形链表的每个节点都因为引用计数器不为0无法被回收;
可达性分析算法:
概念:通过一系列被称为GC Roots的根对象作为起始节点集,从根节点开始根据引用关系向下搜索存活对象图,搜索过程走过的路径被称为引用链,如果某个对象与GC Roots间没有任何引用链相连,该对象再也不可能被使用,称从GC Roots到该对象不可达,该对象也被称为不可达对象
非静态代码块的方法中定义的局部变量出了代码块已经失效了但是仍然存在于局部变量表中,此时GC不会将对应的变量进行回收,需要后续变量复用局部变量表中失效变量的slot空间时将对应变量值覆盖掉该引用才会被销毁,此时此前已经结束的代码块中定义的对象才会被垃圾回收器回收

Java体系根对象类型:根对象集合是一组必须活跃的引用,判断技巧是如果一个指针向堆中的对象但是指针本身又不在堆中,该指针指向的对象就是一个根对象,这些指针包含:
虚拟机栈中局部变量表中的引用数据类型引用
本地方法栈中引用数据类型引用
类静态属性引用
串池中的引用
同步监视器[同步对象]即同步锁持有的对象
JVM内部的引用比如基本数据类型对应的class对象,常驻的像空指针、内存溢出等异常对象,系统类加载器
反映虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
GCRoots中还有可能加入一些临时的对象,比如只对堆的新生代进行局部垃圾回收,发生跨代引用的老年代对象也会临时加入到GC Roots中来保证可达性分析的准确性同时避免回收新生代对整个堆进行全局扫描
OopMap
GC Roots枚举根节点期间需要暂停用户线程,对虚拟机栈进行扫描,对整个栈进行扫描很消耗性能,HotSpot采用了空间换时间的方法,使用OopMap存储栈上的对象引用信息,OopMap记录的是栈中某个寄存器或者栈帧存储的对象引用的偏移量,每个栈帧可能有多个OopMap,存储在栈帧的附加信息区域,GCRoots枚举根节点时直接通过遍历每个栈帧的OopMap找到栈上的根节点
废弃常量的判定条件[废弃常量与Java堆中垃圾对象的判定非常相似,常量池中的类、接口、方法、字段的符号引用就是字符串字面量]
堆中没有任何对象引用常量池中的常量
JVM在堆外也没有其他任何地方引用该常量的字面量
不再使用的类型判定[也是类的最后一个声明周期中类卸载的三个条件]
Java堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收
该条件除非是像OSGI、JSP的重加载这类经过精心设计的可替换类加载器的场景,否则通常是很难达成的;JVM自带的类加载器加载的类是不会被卸载的,用户自定义类加载器加载的类是有可能被卸载的,但是一般为了提升系统性能,自定义类加载器都会采用缓存的策略,一般是不会被卸载的,即使会被卸载,卸载的时机也是无法被确定的
类型对应的java.lang.Class实例没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
三种垃圾收集算法的对比[没有最好的算法,都有优缺点,需要根据应用场景选择使用]
速度:复制算法最快、标记清除算法中等、标记压缩算法最慢
内存开销:复制算法需要活对象两倍的开销、标记清除算法和标记压缩算法都是正常开销,标记清除算法有内存碎片,标记压缩算法和复制算法没有内存碎片
移动对象:复制算法和标记压缩算法都会移动对象,标记清除算法不会移动对象[移动对象需要更改所有旧的引用地址值]
标记清除算法[Mark-Sweep]
标记清除算法是最早的垃圾收集算法,分为标记和清除两个阶段,1960年提出并被应用到Lisp语言中,当堆中有效内存空间被耗尽时,STW暂停所有用户线程,标记阶段通过可达性分析在对象头标记出所有需要回收的对象或者存活对象,清除阶段统一回收掉所有垃圾对象,所谓的回收垃圾实际上是将空闲列表中对应地址标记为空闲,新数据写入时直接覆盖掉旧数据
空闲列表:将垃圾对象的地址记录形成列表,为新对象分配内存时判断某块连续内存大小是否足够,足够就直接覆盖掉垃圾数据并更新空闲列表状态
老年代一般使用标记清除算法和标记压缩算法的混合实现
优点:
简单易实现
缺点:
效率低下,标记和清除都需要遍历所有对象,且GC时会暂停整个应用程序,用户体验差
清理后的内存会产生内存碎片,需要维护一个空闲列表来为新对象分配内存,且内存碎片太多可能导致大对象分配时找不到足够的连续内存而提前触发新的垃圾收集动作
半区复制算法
为了解决标记清除算法执行效率低的问题,1963提出复制算法并由论文作者本人引入到Lisp语言发行版本中,1969年提出半区复制算法,复制算法和半区复制算法是同一种算法,半区复制算法只是复制算法的一种具体实现形式。半区复制算法将可用内存划分为大小相等的两块,每次只使用其中一块,当这块内存用完时,将可达性分析中存活的对象规整复制到另一块内存,交换两个内存块的角色完成垃圾收集
优点:
实现简单,且当一块内存中只有少量存活对象时,效率很高
复制存活对象时自动规整内存,不存在内存碎片,分配内存只需要移动堆顶指针即可
缺点:
可用内存缩小为原来的一半,内存空间太浪费
对象移动后引用地址会发生变化,垃圾回收还需要重新改变引用对应对象的地址值,内存和修改引用上的开销也不小
当一块内存中大多数都是存活对象时,会产生大量内存复制和更新引用地址的开销,无效开销非常严重;因此复制算法只适用于内存充足且存活对象非常少的场景,不适合老年代这种保留的对象都是难以消亡,内存占用本身就很大的分代区域
Appel式回收:1989年针对分代收集理论提出了更优化的半区复制算法名为Appel式回收,将新生代分为一块较大的伊甸园区和两块较小的幸存者区,每次分配内存只使用伊甸园区和其中一块幸存者区,发生垃圾收集时将伊甸园区和幸存者区的存活对象一次性复制到另一块幸存者区,然后直接清理掉伊甸园区和已用过的那块幸存者区
HotSpot的伊甸园区和一块幸存者的默认大小比例为8:1,每次新生代可用内存占整个新生代容量的90%,只有10%的新生代内存被浪费;普通场景下新生代可被回收的对象比例统计高达98%,当然没有人能保证新生代每次只有10%的存活对象,因此Appel式回收还有一个逃生门安全设计,当幸存者空间不足以容量一次YGC后的存活对象,需要依赖其他内存区域[大多都用老年代]进行分配担保
HotSpot中的Minor GC都使用的是半区复制算法
标记压缩算法[Mark-Compact、标记整理算法]
针对强分代假说老年代对象的存亡特征,1970年提出了标记压缩算法,标记阶段标记所有存活对象,整理阶段让所有存活对象规整到内存空间的一侧,然后直接清理掉存活对象边界外的内存空间
优点:
内存规整,使用指针碰撞来为对象分配内存,消除了复制算法内存减半的高额代价
缺点:
效率甚至比标记清除算法还低一些,比复制算法多了一个标记环节,比标记清除算法多了一个垃圾整理环节
需要更改移动后的对象的引用地址
移动过程也需要暂停用户线程,且暂停用户线程的时间也会长一些
分代收集理论
商用虚拟机的垃圾收集器的设计大多数都遵循分代收集理论,分代收集理论实质是一套符合大多数程序运行实况的经验法则,建立在强弱两个分代假说的基础上,常用JVM的一致的设计原则是:垃圾收集器应该将Java堆划分出不同区域,将对象按照分代年龄分配存储到不同区域;
弱分代假说:绝大多数对象都是朝生夕死[程序运行期间产生的临时变量、不可变类实例],IBM的研究结果表明80%的对象都在新生代被销毁
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡[业务相关的对象]
跨代引用假说:跨代引用相对于同代引用仅占极少数
分代年龄:对象熬过垃圾收集过程的次数
如果一个区域中大多数对象都是朝生夕死,回收时只需要关注如何保留少量存活的对象不需要标记大量将被回收的对象,能以较低代价频繁回收大量空间,适合使用复制算法;如果一个区域中的大多数对象都是难以消亡的对象,可以使用较低的频率来回收该区域,因为对象存活率高,一般使用标记清除算法和标记压缩算法混合实现[像CMS是针对老年代基于标记清除算法实现的垃圾回收器,当内存碎片导致大对象无法分配时,补偿使用基于标记压缩算法的Serial Old垃圾回收器执行Full GC清理规整老年代内存]
新生代都是复制算法、老年代除了CMS是标记清除算法其他都是标记压缩算法
记忆集:
分代收集理论存在对象跨代引用的问题,假如此刻只进行一次局限于新生代的垃圾收集,但是新生代对象可能被老年代对象引用,为了找出新生代的存活对象,需要进行完整的可达性分析,除了遍历GC Roots中的引用[判断新生代对象是否被堆外引用指向]还要遍历堆中包括老年代在内的所有对象来保证垃圾收集的准确性,为新生代的垃圾收集带来很大的性能负担。
为了解决该问题,分代收集理论增加了第三条跨代引用分代假说;根据跨带引用假说针对新生代建立称为记忆集RSet的全局数据结构,记忆集将老年代划分成若干小块,标识出老年代哪一块内存存在跨代引用。发生YGC时,老年代中只有包含跨代引用的小块内存中的对象会被加入到根对象集合中参与可达性分析,从而避免了扫描整个老年代,也无需浪费空间专门记录每个对象是否存在以及存在哪些跨代引用;虽然在对象引用关系发生变化时需要更新记忆集保证跨代引用数据的准确性,增加运行时开销,但是比起新生代垃圾收集扫描整个堆来说仍然很划算
增量收集算法
为了解决分代收集算法STW的时间较长的问题,提出了增量收集算法,增量收集算法每次垃圾收集只收集一小块内存空间,接着继续执行用户线程,依次反复直到垃圾收集完成,即把集中在一块的暂停时间分散到一段时间区间内,增量收集算法侧重于协调垃圾收集线程和用户线程的执行
缺点:
频繁在垃圾回收线程和用户线程间进行上下文切换,造成垃圾回收器吞吐量的下降
分区算法
分区算法将整个堆划分成称为region的小区间,每个小区间独立使用,独立垃圾回收,一些小区间作为伊甸园区、一些小区间作为幸存者区,一些小区间作为老年代,一些小区间专门存放大对象;根据指定暂停时间可以控制单次GC回收多少个小区间,降低GC导致的停顿间隔
前沿GC都是复合算法,并且并行和并发兼备[并行指多个垃圾回收线程同时工作、并发指垃圾回收线程和用户线程同时运行]
触发Minor GC:
条件:伊甸园区没有足够空间为新对象分配内存
注意:
此时只是确定Minor GC会执行,但是执行前还需要检查老年代的情况
对象晋升老年代机制:JVM为每个对象都设置了一个分代年龄计数器存储在对象头中,新对象总是尝试在伊甸园区分配内存,经历一次Minor GC仍然存活会移动到幸存者区并将分代年龄设置为1,此后对象每在幸存者区熬过一次Minor GC分代年龄就增加1,当分代年龄增加到指定阈值[默认为15,通过JVM参数-XX:MaxTenuringThreshold设置],对象就会晋升移动到老年代,没有配置启用-XX:+UseCMSInitiatingOccupancyOnly的情况下,除了第一次垃圾收集采用预设的分代年龄阈值后续都会动态计算取让幸存者区内存占用达到指定值的分代年龄或者指定分代年龄阈值的最小值作为新的分代年龄阈值
检查老年代空间是否充足
在正式执行Minor GC前JVM会检查老年代的最大连续可用空间是否大于新生代所有对象的总空间
如果大于本次Minor GC是安全的,可以直接进行Minor GC
如果老年代最大连续可用空间小于新生代所有对象总空间,说明本次Minor GC存在风险,JVM会继续检查系统参数-XX:HandlePromotionFailure是否为true允许空间分配担保决定是否进行本次Minor GC
JDK6Update24及以后版本参数HandlePromotionFailure失效不会再影响JVM的空间分配担保策略,JVM在源码中直接采用允许担保失败的策略,只要触发YGC时老年代的最大连续可用空间大于新生代对象总大小或者大于历次晋升对象的平均大小就会进行YGC,否则将YGC改为Full GC
如果不允许担保失败,JVM会直接将此次Minor GC改为进行一次Full GC
如果允许担保失败,JVM会检查老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于JVM会尝试直接进行这次存在风险的Minor GC,如果小于JVM会将此次Minor GC改为进行一次Full GC
允许空间分配担保的条件下Minor GC仍然可能失败,此时会补充执行一次Full GC
Major GC和Full GC的暂停时间在Minor GC的十倍以上,大部分时候的垃圾收集都是Minor GC
部分收集
Minor GC:也叫YGC,只对新生代进行垃圾回收,伊甸园区没有可用空间为新对象分配内存就会触发Minor GC
特点:因为绝大多数Java对象都是朝生夕死而且对象会首先分配在伊甸园区,因此Minor GC执行非常频繁,存活对象少使用复制算法回收速度快,Serial和Parrallel会全程STW,其他垃圾收集器会部分时间STW,Major GC和Full GC的暂停时间都在Minor GC的十倍以上
Major GC:也叫Old GC,只对老年代进行垃圾回收
特点:在触发Major GC以后执行Major GC以前会先执行一次Minor GC,如果内存依然不足才会真正执行Major GC
只有CMS会有Major GC即单独回收老年代的行为
Mixed GC:混合收集,收集整个新生代和部分老年代,只有G1才有混合收集
整堆收集
Full GC:收集整个堆和方法区,会在GC期间全程暂停用户线程的执行,在JDK10以前都只有串行Full GC,这是Full GC低效的原因;JDK10以后G1引入了并行Full GC
Full GC的触发时机
调用System.gc()建议系统执行的就是Full GC,系统会根据运行情况自行判断是否执行Full GC
老年代空间不足
YGC前老年代的可用连续空间小于历次年轻代晋升老年代的平均大小会将YGC改为进行一次Full GC
大对象直接在老年代分配内存但是老年代没有足够的连续可用空间
方法区空间不足
JDK8以后方法区使用本地内存,字符串常量池和类变量都移出方法区,因为方法区不足触发Full GC的概率已经很低了
G1混合回收阶段完成前老年代的空闲内存已经被耗尽,此时会使用Full GC暂停所有用户线程来进行兜底垃圾收集
G1的暂停时间设置的太短导致回收频率变高,如果垃圾收集的速度跟不上垃圾产生的速度最终还是会导致老年代内存被耗尽并触发Full GC
使用G1时找不到逻辑上连续的H区存放大对象就会直接启动Full GC
导出堆dump文件前也会默认触发Full GC
降低Full GC频率的措施
增加方法区和堆区的内存大小,增大老年代占堆内存比例
禁止调用System.gc()
使用基于标记压缩算法的垃圾收集器,避免产生内存碎片导致提前执行Full GC
尽量避免在程序中频繁创建大对象
重点是CMS和G1的原理、工作流程和优缺点
使用命令java -XX:+PrintCommandLineFlags -version能打印当前JDK版本默认使用的垃圾收集器
整体概述
垃圾收集器分类
按照垃圾收集线程的数量分为串行和并行垃圾收集器[线程的并行指的是多核同时执行,并发指单核交替执行]
单核硬件条件不优越场合下串行性能表现更好,串行垃圾收集器是JVM工作在客户端模式下的默认垃圾收集器
串行回收器:Serial、Serial Old
并行回收器:ParNew、Parallel Scavenge、Parallel Old
并发回收器:CMS、G1
按照工作模式分为并发式和独占式垃圾收集器
并发指用户线程和垃圾收集线程部分时间可以同时工作,独占指整个垃圾收集期间用户线程全部暂停
按照内存碎片的处理方式分为压缩式和非压缩式垃圾收集器
压缩式指对存活对象进行内存规整,使用指针碰撞的方式为新对象分配内存
非压缩式指不对存活对象进行内存规整,使用空闲列表的方式为新对象分配内存
按照收集的内存区域分为年轻代垃圾收集器和老年代垃圾收集器
新生代垃圾收集器:Serial、Parallel Scavenge、ParNew
老年代垃圾收集器:Serial Old、Parallel Old、CMS
整堆收集:G1
垃圾收集器的组合关系
JDK8及以前可选择的组合关系:Serial+CMS/Serial Old;ParNew+CMS/Serial Old;Parallel Scavenge+Serial Old/Parallel Old
CMS是并发垃圾回收器,垃圾回收同时用户线程还会继续执行,因此CMS不能等到老年代空间满了再进行回收,如果确实回收晚了或者垃圾制造速度比回收速度快导致CMS垃圾回收失败,此时JVM会自动使用Serial Old暂停所有用户线程执行Full GC作为兜底方案来容错,因此使用CMS需要使用Serial Old作为兜底垃圾收集器
JDK8中Serial+CMS和ParNew+Serial Old的组合过时但是还可用,JDK9中这两个组合完全被禁止使用,此时Serial只能搭配Serial Old,ParNew只能搭配CMS
JDK14中Parallel+Serial Old过时但是还可以使用,CMS被移除,因为JDK9引入的G1替代了CMS
在JDK14及以后能使用的组合只有Serial+Serial Old;Parallel+Parallel Old;G1
JDK8中的默认组合是Parallel+Parallel Old
Parallel底层使用的框架和CMS不同,导致两个不能组合使用
存在多种不同组合的原因是Java在移动端和服务器端都有很多应用场景、针对不同场景的要求对垃圾回收器的要求不同,没有万能的垃圾回收器,只能选择针对具体场景最合适的垃圾回收器
[截止到JDK14的垃圾收集器组合关系]

GC的性能指标:吞吐量、暂停时间和内存占用三者是相互矛盾的关系,随着硬件技术的发展,内存占用越来越大,硬件性能的提升也促进吞吐量的提升,但是内存的扩大也导致STW的时间更长,暂停时间越来越重要,现代GC优化的重点就是降低STW的时间;此外不同场景对垃圾收集器的具体要求也不同,像Parallel更关注吞吐量、CMS、G1、ZGC更关注暂停时间;
吞吐量:总运行时间等于程序运行时间加垃圾回收时间,吞吐量为程序运行时间占总运行时间的比例
通过减少GC线程活跃的频率降低线程上下文切换来提升吞吐量会导致单次垃圾收集暂停时间的增加导致高延迟
暂停时间:即执行单次垃圾收集时用户线程被暂停的时间
降低单次GC处理的内存大小,增大GC的频率可以降低暂停时间,但是会增加线程上下文切换拉低总的吞吐量
内存占用:堆区大小
垃圾收集器的选择策略
垃圾收集器最基础的调优策略是调整堆内存大小让JVM自动调整其他参数,效果不一定特别好但一定不会差
客户端或者嵌入式这种单核、内存、CPU资源贫瘠的平台没有暂停时间要求选择Serial
要求高吞吐量、多核平台且允许暂停时间超过1s选择Parallel
多核平台、要求低延迟、允许响应时间不超过1s的互联网应用等需要快速响应的场景选择CMS、G1这种并发垃圾收集器
垃圾回收器的发展历史
1999年随JDK1.3发布的串行垃圾回收器Serial和ParNew
在硬件性能比较低的场合下才会考虑Serial和Serial Old的组合
ParNew是Serial的并行版本
2002年随JDK1.4发布Parallel和CMS[Concurrent Mark Sweep],Parallel在JDK6发布配套的老年代收集器parallel Old并成为新生代的默认垃圾收集器
关注低延迟的场景下更多地选择CMS;关注吞吐量的场景优先选择parallel
2012年随JDK1.7发布了G1
G1使用的是分区算法,兼具回收新生代和老年代
2017年发布的JDK9中G1成为HotSpot的默认垃圾回收器,CMS被提示过时且后续将会废弃
2018年随着JDK11发布引入Epsilon和实验性的可伸缩的低延迟垃圾回收器ZGC
2019年JDK12发布在Open JDK中引入了红帽公司研发的专注低延迟的Shenandoah
2020年随着JDK14的发布删除了CMS垃圾回收器,扩展了ZGC可以在mac和windows上使用
介绍
Serial基于复制算法Client模式下默认的串行新生代垃圾收集器,对应Serial Old是基于标记压缩算法Client模式下默认的串行老年代垃圾收集器,Serial Old在Server模式下主要与JDK1.5及以前的Parallel组合使用以及作为CMS的兜底垃圾收集方案
多核场景下选择Serial效率不高,一般只有嵌入式设备等单核场景下才会考虑
注意没有JVM参数-XX:+UseSerialOldGC,使用该参数JVM启动会报错,新生代使用Serial老年代默认会使用Serial Old
1999年随JDK1.3发布
特点
优点:
Serial在垃圾收集的内存和线程上的开销都非常小,单核场景下效率比其他垃圾收集器高,在Serverless等云计算新应用场景下也被广泛使用
缺点:
暂停时间太长,开发Web应用不会考虑使用Serial
介绍
ParNew基于复制算法的新生代并行垃圾收集器,是Serial的并行版本,底层共享了Serial很多代码
是JVM曾经很多版本下Server模式下的默认垃圾收集器,随着JDK9与Serial Old的组合被彻底移除以及JDK14中CMS被移除几乎落幕,从JDK9开始配置使用ParNew就会警告不建议使用,将来会移除
因为新生代回收频繁,多核场景使用并行方式更高效,暂停时间相较于Serial也会短一些,默认垃圾收回线程数是CPU核心数
不像Serial指定新生代使用ParNew不会同时指定老年代的垃圾收集器
设计目标上保证指定暂停时间的前提下尽可能提升吞吐量
1999年随JDK1.3发布
介绍
基于复制算法的新生代并行垃圾收集器,性能和ParNew差不多,设计目标上与ParNew相反优先保证吞吐量的前提下尽可能提升暂停时间,相较于ParNew多了一个自适应调节策略,能够动态调整年轻代的大小、伊甸园区和幸存者区的比例,分代年龄阈值等参数来尽量满足预设的吞吐量和停顿时间
由于底层框架和CMS不同,不能和CMS组合使用,只能和Parallel Old或者Serial Old组合使用,发布parallel Old替换掉原来只适合单核场景的Serial Old且替代CMS作为关注高吞吐量场景下的首选垃圾收集器[CMS侧重于暂停时间,而且parNew与CMS的组合表现比Parallel和Serial Old组合表现更好]
2002年随JDK1.4发布,在JDK1.6发布基于标记压缩算法的并行parallel Old,在JDK1.8与Parallel Old的组合成为Server模式下的默认垃圾收集器
新生代指定使用parallel老年代会自动启用parallel Old,老年代使用parallel Old新生代也是自动启用parallel
相关JVM参数
默认情况下,当CPU核数小于8,垃圾收集线程数等于CPU核数,CPU核数大于8,垃圾收集线程数等于八分之五的CPU核数向下取整后加3,比如12个核心对应10个垃圾收集线程,也可以通过JVM参数ParallelGCThreads设置
配置JVM参数MaxGCPauseMillis可以设置parallel的最大暂停时间,parallel为了将暂停时间控制在指定值内会自动调整堆的大小和比例参数,如果该参数设置的太小,会导致JVM自动调小堆内存大小导致GC频率升高增加线程上下文切换成本导致整体吞吐量下降,因此使用parallel该参数需要谨慎配置
配置JVM参数GCTimeRatio设置垃圾收集时间占总进程运行时间的比例,默认值为垃圾收集时间不超过程序运行时间的1%,即默认吞吐量为99%
JVM参数UseAdaptiveSizePolicy就是parallel的自适应调节策略的开关,默认就是开启状态,使用parallel一般会手动指定堆大小,然后让JVM自动调节堆的其他参数
特点
吞吐量高,适合不需要太多交互如执行批量任务、订单处理、工资支付和科学计算等的后台运算场景
介绍
CMS是Concurrent-Mark-Sweep的缩写,如同名字一样,是HotSpot第一款基于标记清除算法的老年代并发垃圾收集器,在GC过程中的部分时间段垃圾收集线程可以和用户线程同时工作
CMS的设计理念是在优先保证暂停时间的前提下尽可能提升吞吐量,非常适合互联网或者B/S系统这种特别注重响应速度的场景,CMS只能搭配Serial或者ParNew使用,在G1发布前CMS的使用非常广泛,至今仍然有很多系统在使用CMS
三色标记法:完全没有被GC线程访问过的对象被标记为白色、被GC线程访问过但是该对象直接引用的对象没有被全部访问过标记为灰色、对象和该对象直接引用的对象全部被GC线程访问过标记为黑色,标记阶段结束只有被标记为白色的对象才会被回收
工作流程
初始标记阶段
功能:标记出GC Roots集合中的根节点对象
特点:单个垃圾回收线程,会STW,只是标记根节点集合中的对象,执行速度非常快
并发标记阶段
功能:从根节点对象开始遍历整个存活对象图
特点:
单个垃圾回收线程,不会STW,因为要遍历所有存活对象因此耗时长
并发标记期间因为引用关系变化可能导致多标和漏标
多标:多标指已经被标黑的对象在并发标记阶段变成实际上的不可达对象被称为浮动垃圾,浮动垃圾在下一轮GC才会被清除,浮动垃圾不会影响垃圾收集的正确性,多标的情况包括
已经被标黑或者标灰的对象在并发标记阶段所有引用被断开变成不可达对象成为浮动垃圾
并发标记开始后创建的新对象会被直接标记为黑色,这部分对象在并发标记期间变成不可达对象也会直接变成浮动垃圾;浮动垃圾会在下一轮垃圾收集才会被清除,浮动垃圾不会影响最终垃圾收集的正确性
漏标:漏标要同时满足两个条件,已经标黑的对象重新直接或者间接引用白色对象,已经标灰的对象在并发标记阶段直接或者间接断开这些白色对象的引用;
因为GC线程不会再去遍历已经被标黑的对象,这些被标灰对象断开又被标黑对象重新引用的白色对象,本轮GC无法再被GC线程访问被标黑或者标灰,本轮GC这些实际可达的白色对象会被当做垃圾回收
这种漏标行为会影响用户程序的正确性,是不可接受的;解决办法是并发标记期间,如果一个对象的引用被断开又被其他对象重新引用,可以将这些对象放入特定的集合,在并发标记结束后停止所有用户线程遍历重新标记集合中的对象
CMS通过写屏障加增量更新的方式,即黑色对象重新引用白色对象时记录白色对象等待对被记录的白色对象重新遍历标记,破坏对象被漏标的第一个条件
G1通过写屏障加原始快照的方式,即灰色对象断开白色对象的引用时记录白色对象到特定集合中等待被重新标记破坏对象被漏标的第二个条件,本质还是让垃圾回收按照并发标记前的快照来执行垃圾回收,应该被清理的垃圾当做浮动垃圾处理;
ZGC是通过读屏障的方式在读取对象的成员变量还没到断开引用就记录下该成员变量等待遍历重新标记破坏对象被漏标的第二个条件
重新标记阶段
功能:对并发标记阶段可能漏标的对象重新标记,多标的对象一律当浮动垃圾等待下一轮GC再处理
特点:多个垃圾收集线程,会STW,暂停时间比初始标记阶段长,但远比并发标记阶段短
并发清除阶段
功能:清除掉标记阶段被判定消亡的对象
特点:单个垃圾收集线程,因为不需要移动存活对象不会STW
重置阶段
功能:恢复在GC过程中发生变化的各种状态和相关数据结构,为下一个GC做准备
特点:单个垃圾收集线程,不会STW
特点:
只有初始标记和重新标记两个阶段STW,暂停时间非常短,目前没有任何一款GC能完全做到不需要STW,只有重新标记阶段使用多个垃圾收集线程,其他阶段都是单个垃圾收集线程
因为是首款并发垃圾收集器,CMS与以往垃圾收集器堆内存几乎满了才收集不同,只要堆内存使用率达到某个阈值CMS就会开始垃圾回收,一旦CMS回收期间预留的内存无法满足用户线程的需要会出现Concurrent Mode Failure失败,此时JVM会使用Serial Old替代CMS进行整堆收集,远远增大暂停时间
CMS使用标记清除算法,维护空闲列表为新对象分配内存,除了CMS老年代几乎都使用标记压缩算法,这种设计是为了降低暂停时间,因为规整内存需要暂停用户线程会大大增加暂停时间,实际上CMS的突出优点就是其低延迟
CMS因为算法设计缺陷存在大量内存碎片大对象无法存入老年代导致频繁提前Full GC、并发标记和清除阶段大量占用CPU资源导致高并发或者硬件低配环境下应用在垃圾收集期间的性能和吞吐量表现很差以及始终有一部分内存被浮动垃圾占用的问题;虽然用户群体多,但是在JDK9被标记为过时,在JDK14中被移除;引入了基于分区算法的G1替换CMS
相关JVM参数
手动指定老年代使用CMS,会自动配置新生代使用ParNew以及使用Serial Old作为兜底垃圾收集器
触发CMS开始垃圾回收的老年代内存阈值在JDK5及以前为68%,JDK6及以后默认值为92%,根据实际情况可以以降低Full GC频率为目标自行配置
Serial Old、Parallel Old、G1都能执行Full GC,默认使用Serial Old执行Full GC,使用其他两种垃圾收集器时由对应垃圾收集器执行Full GC,注意Full GC可以选择压缩或者不压缩内存,可以通过JVM参数UseCMSCompactAtFullCollection开启Full GC后压缩内存功能,也可以通过JVM参数-XX:CMSFullGCsBeforeCompaction设置每隔多少次的Full GC后对内存空间进行一次压缩规整
配置JVM参数-XX:ParallelCMSThreads设置CMS垃圾收集线程的数量,默认新生代并行垃圾收集器线程数量ParallelGCThreads加3除以4
介绍
G1是基于分区算法能同时回收新生代和老年代的并发垃圾收集器,开创了局部收集和分区内存布局的设计思路;设计目标是在保证暂停时间的前提下尽可能提高吞吐量,主要针对多核CPU和大容量内存的服务端应用,能极高概率满足指定暂停时间还同时兼顾高吞吐量
在JDK7移除了实验性标识,在JDK9及以后被设置为Server模式下的默认垃圾收集器
G1将堆区分割成物理上不连续的若干大小一致且固定的Region区域,G1增加了专门存放大对象的Humongous,使用若干物理和逻辑上都不连续Region的动态集分别作为伊甸园区、幸存者区、Humongous和老年代组成整个堆,单个Region只能表示其中一种分代区域,H区在逻辑上属于老年代
超过0.5个Region的对象会存在Humongous中,如果一个Humongous存不下会组合多个逻辑上连续的H区来存放[其他GC存放大对象一般都要求物理内存连续],如果找不到逻辑上连续的H区存放大对象就会直接启动Full GC
JVM维护一个空闲列表记录空闲的Region,每个Region都可以根据需要被分配为任意一种分代区域,Region之间使用复制算法进行垃圾回收,Region内部通过指针碰撞的方式为对象分配内存,TLAB也会被分配在Region中
G1在后台维护一个优先级列表跟踪每个Region的可回收空间大小和回收所需时间的经验值,通过软实时的停顿时间模型根据指定吞吐量和停顿时间优先选取部分回收价值最大的Region进行回收,保证有限的垃圾收集时间内获取尽可能高的收集效率,避免进行整个分代区域的垃圾收集;因为设计理念是每次都侧重于回收垃圾量最大的部分空间,因此被命名为Garbage First
G1的停顿时间不一定比CMS的最好表现好,但是最差情况要比CMS好的多;
软实时指的是不要求垃圾回收时间必须小于指定时间,而是指有一定的把握在指定时间内完成
G1在低于6G的普通大小堆中表现平平无奇,适合在要求低延迟的多核心大堆应用中使用,适合在超过50%的Java堆都是活跃数据、新对象分配频率或者对象分代年龄提升频率很高以及GC暂停时间长于500ms到1s的场景下替换CMS
特点
兼具分代收集和并行并发,不要求每个分代区域在逻辑上连续,也不再要求每个分代区域固定大小,Region之间使用复制算法,整体上可以看做由复制算法和标记清除算法实现的标记压缩算法,避免内存碎片;当Java堆非常大的时候G1的优势非常明显
软实时停顿时间模型保证每次都在有限垃圾收集时间和尽可能在指定停顿时间内收集回收价值最大的部分Region
G1提供YoungGC、Mixed GC和Full GC三种不同触发条件的垃圾回收模式
G1可以在垃圾收集线程处理速度较慢的情况下自动调用用户线程来加速GC工作
在JDK10将串行的Full GC改成并行Full GC,在很多场景下都优于Parallel的Full GC
缺点:G1相较于其他垃圾收集器会造成额外10-20%的垃圾收集内存开销,执行负载开销也比CMS大,不能全方位碾压CMS,运行经验表明在低于6-8G的小内存应用上CMS的性能表现大概率会比G1更好,超过6-8G的大内存应用,内存越大G1相较于CMS优势越大
相关JVM参数
在JDK7或者JDK8使用G1需要通过UseG1GC参数显式开启
参数G1HeapRegionSize可以设置每个Region的大小,值只能指定为小于等于32的2次幂,单位默认为MB,默认每个Region是堆的1/2000。如果默认Region小于1M会自动取1M
参数MaxGCPauseMillis指定最大暂停时间,默认值为200ms,JVM会尽力实现但是不能每次都保证;一般设置几十到300ms之间
设置的太低比如低于50ms会降低每次收集的Region数量增大回收的频率导致吞吐量降低最终可能导致垃圾收集的速度跟不上用户线程的消耗速度频繁触发Full GC,G1设计的吞吐量目标是90%的用户程序执行时间和10%的垃圾回收时间
避免使用-Xmn或者-XX:NewRatio等JVM参数显式设置年轻代的大小,固定年轻代的大小会导致期待的最大GC暂停时间参数-XX:MaxGCPauseMillis失效,可以设置新生代的最小占比和最大占比,实际大小交给JVM自己控制就好
参数ParallelGCThread可以设置垃圾收集的线程数,通过参数ConcGCThreads可以设置并发标记的线程数,默认值为并发垃圾收集线程数的四分之一
参数InitiatingHeapOccupancyPercent可以设置触发GC周期的堆占用率阈值,默认堆占用率达到45%就会触发新一轮GC
对G1的调优一般只设置堆的最大内存、设置最大暂停时间,剩下的交给JVM自动控制
参数G1MixedGCLiveThresholdPercent配置老年代Region进行混合回收的垃圾占用空间比例阈值。默认值为65%,低于该阈值的老年代Region被认为存活对象占比太高,回收复制开销大且耗时长。
参数G1MixedGCCountTarget设置一轮GC中混合回收的次数,超过垃圾内存占用比例的老年代region默认情况下会被分8次被回收,优先回收回收价值最高的region
参数-XX:G1HeapWastePercent设置最大可浪费堆内存比例,默认值为5%,剩余可回收内存低于该值会直接停止混合回收阶段
工作流程
G1的一次GC包含YGC、老年代并发标记和混合回收三个环节,如果GC评估执行失败会进行第四个串行独占的Full GC,在JDK10改成了并行Full GC,作用和CMS搭配Serial Old类似作为兜底手段,正常情况下一般不会出现Full GC,全程单线程独占暂停时间不可控,出现Full GC说明需要进行系统调优
YGC环节
功能:只要伊甸园区内存满了就会触发对年轻代进行并行独占式的垃圾收集,伊甸园区的存活对象和幸存者区未达到分代年龄阈值的存活对象复制到空闲的新幸存者区,幸存者区达到分代年龄阈值的存活对象晋升移动到老年代或者空闲的Region中成为新的老年代,原来的伊甸园区和幸存者区更新空闲列表成为空闲Region
特点:YGC进行时会暂停所有用户线程
流程:
扫描根对象:遍历根对象即静态变量、局部变量表中的引用数据类型引用,RSet中记录的外部引用
更新RSet:处理脏卡队列并更新记忆集RSet
记忆集[Remembered Set、RSet]
无论是在G1还是其他分代垃圾收集器,JVM都是通过记忆集RSet来避免YGC时进行完整的可达性分析解决跨代引用问题,G1相较于其他垃圾回收器额外使用10%-20%的垃圾回收内存开销维护记忆集,如果YGC只回收新生代还要进行完整的可达性分析是非常高昂且多余的开销;在G1主要的大堆应用场景这个问题特别突出,会严重降低频繁触发的Minor GC效率
G1给每个region都配置一个RSet;每次引用数据类型写操作时都会产生一个写屏障中断写操作,检查被引用的对象和当前对象是否在同一个region中[其他垃圾收集器是检查两个对象是否一个处于年轻代,一个处于老年代],如果不在同一个region中会将当前对象记录到被引用对象所在region对应的RSet记忆集的具体实现卡表CardTable中
YGC时,将RSet中的引用临时加入GC Roots的枚举范围,避免新生代存活对象不进行完整可达性分析被漏标的情况,回收老年代本身就要回收新生代,就相当于全堆扫描,无需特别关心记忆集的问题
脏卡队列:当引用赋值语句执行时通过写屏障将属性所属对象封装成卡入队列,这个队列就被称为脏卡队列
老年代对象跨代引用年轻代对象时会通过写屏障将老年代对象引用记录在记忆集中,但这种记录不是实时的,因为多线程同步更新RSet开销太大;利用脏卡队列记录在赋值语句执行时属性的所属对象封装成卡入队列,等到YGC前才对脏卡队列中的所有卡进行处理并更新记忆集,这种方式更高效。更新后的记忆集才能真实反映当前的跨代引用情况
处理RSet:被老年代对象跨代引用的新生代对象被处理为存活对象
复制对象:伊甸园区存活对象、幸存者区未达到分代年龄阈值的对象被复制到新的幸存者区,分代年龄自增。达到分代年龄阈值的对象和幸存者放不下的伊甸园区存活对象会被复制到老年代
处理引用:处理软引用、弱引用、虚引用、终结器引用、JNI弱引用,最终让原来的region区域置空,数据被复制到的region成为新一轮的幸存者区
并发标记环节
功能:当堆内存使用率达到默认值45%时触发老年代并发标记过程
特点:并发标记期间仍然会正常触发执行YGC阶段
流程:
初始标记:标记GCRoots根对象集合中的所有根节点对象,同时触发一次YGC
特点:该阶段会STW
根分区扫描:扫描幸存者区对象指向老年代对象的引用并标记被引用的老年代对象
特点:该阶段不会STW,目的是解决新生代对老年代的跨代引用问题,这里姑且认为伊甸园区现在是空的,原来伊甸园区的对象都进入了幸存者区
并发标记:使用可达性分析算法对整堆并发标记,期间会计算每个Region中存活对象比例即对象活性,如果发现某个Region中全是垃圾会立即回收该Region
特点:不会STW,但是可能被YGC中断
重新标记:对并发标记阶段可能发生漏标的对象进行修正,通过写屏障加初始快照SATB破坏漏标的第一个条件白色对象被灰色对象断开无法被察觉的条件
特点:会STW
独占清理:该阶段不会做垃圾清理,只是对各Region的回收价值进行计算排序,根据用户设置的吞吐量和停顿时间选取部分将被回收的高收益老年代Region为混合回收做准备
特点:会STW
混合回收环节
功能:将满足指定暂停时间和吞吐量,最具回收价值的Region中老年代的存活对象复制到新的老年代Region中
特点:
并发标记完成后马上开始混合回收
默认情况下将垃圾内存占用超过65%的Region分8次回收,每次回收满足条件且回收价值最高老年代Region的1/8和全部新生代Region
混合回收不一定必须要执行完全部八次回收,回收过程中如果JVM发现剩余可回收内存低于堆内存可浪费空间阈值就会停止混合回收,默认值为5%,允许堆内存有5%的空间被浪费,避免花费很多时间进行混合回收但是收益却很有限
介绍
JDK11引入,无操作GC,只做内存分配,不做垃圾回收,只适用于一次性运行完一小段程序就结束进程的场景
介绍
JDK11引入基于分区算法、不设置分代、使用读屏障、染色指针和内存多重映射技术实现的可并发标记压缩算法的可伸缩低延迟垃圾收集器,设计目标是不严重影响吞吐量的前提下实现任意堆大小场景下,将暂停时间限制在10ms内
ZGC除了初始标记阶段会STW,在并发标记、并发预备重分配、并发分配、并发重映射四个其他阶段都是并发的
JDK14中被拓展到可以在Mac和Windows上使用,使用ZGC需要配置JVM参数-XX:+unlockExperimentalVMOptions -XX:+UseZGC,参数+unlockExperimentalVMOptions表示要解锁实验性的JVM参数+UseZGC
JDK21中引入了分代ZGC,暂停时间可以缩短到1ms内,可以通过配置JVM参数-XX:+UseZGC -XX:+ZGenerational启用分代ZGC
特点
在保证ZGC暂停时间不超过10ms的前提下,ZGC吞吐量略低于Parallel,略高于G1,均只有1-2%的差距;如果不要求暂停时间,ZGC的吞吐量相较于Parallel和G1会有将近50%的提升
暂停时间方面ZGC吊打parallel和G1,平均和99%的暂停时间都只有1-2ms,最大暂停时间也能控制在10ms内;parallel和G1平均暂停时间在200-300ms之间
ZGC的测试数据相当亮眼,未来会作为大内存低延迟服务端应用的首选垃圾收集器
介绍
OpenJDK12引入由RedHat开发侧重低延迟的垃圾收集器,商业版oracleJDK未引入导致开源版JDK竟然比商用版还多功能的情况,Shenandoah在2014年被RedHat捐赠给OpenJdk
Shenandoah团队号称无论堆大小设置成多少都能把暂停时间限制在10ms内,但是实际效果还是取决于堆大小和工作负载,2016年红帽使用ElasticSearch对200GB的维基百科数据进行索引并发表测试结果,暂停时间确实相较于经典垃圾收集器有质的飞跃,在其他垃圾收集器总停顿时间都达到十秒的情况下只有320ms,最大停顿时间达到1-4秒的情况下只有89ms,平均停顿时间达到450-850ms的情况下只有53ms,但是在吞吐量方面相较于经典垃圾收集器断崖式下滑,以下为测试数据
概念:
内存泄露:Java中定义的内存泄漏就是用户已经不会再使用某个对象,但是垃圾回收时还是发现可达性分析时对象还是可达的,该对象及其可支配子树都无法被回收
宽泛性的内存泄漏指由于开发者不好的实践或者疏忽导致对象生命周期不必要地变得很长,最终导致内存紧张甚至内存溢出
根本原因是长生命周期对象持有短生命周期对象的引用,导致短生命周期对象无法被即使回收,内存泄漏按照发生方式可以分为常发性、偶发性、一次性和隐式内存泄漏四类,内存泄漏举例如下
短生命周期对象被长生命周期对象持有导致的内存泄漏:单例对象和静态集合容器的生命周期和应用的生命周期一样长,被单例对象持有的外部对象无法被回收会导致内存泄漏,没有手动移除静态集合容器中已经不会再使用的对象也会导致内存泄漏
一些像将局部变量声明成成员变量或者类变量,在方法中创建局部变量并赋值给成员变量或者类变量且没有及时置为null,将没有必要设置会话级别的数据设置成会话级别等不好的实践
网络连接、IO通道、数据库连接等提供close方法的资源如果没有手动调用对资源进行关闭或者没有在fianlly语句块正确释放发生了异常,资源无法得到释放资源对象也无法被回收
TLAB存不下新对象会重新为线程分配一块新的TLAB,旧的TLAB的剩余空间不会再继续使用,而且单个TLAB本身就只占伊甸园区的1%还要除以线程总数,很容易出现剩余空间不足的情况,TLAB的内存浪费现象也确实比较严重,会导致始终有部分内存无法被使用
为此JVM使用最大浪费空间对TLAB进行约束,当TLAB剩余空间存不下新对象且剩余空间小于最大浪费空间,TLAB所属线程会向JVM申请一块新的TLAB区域存储新对象,如果新TLAB仍然存不下,对象会被直接分配到伊甸园区;如果当前TLAB的剩余空间大于最大浪费空间,对象会被直接分配到伊甸园区,TLAB会继续使用;默认最大浪费空间的JVM参数TLABRefillWastePraction为64,表示值为TLAB大小的1/64
当一个外部类实例的方法返回一个内部类实例,该内部类实例被一个长生命周期对象持有,即使外部类实例不再被程序使用,外部类实例也无法被垃圾回收造成内存泄漏
从Object继承的默认hashCode方法通过对象引用地址计算对象哈希值,如果重写了hashCode方法,修改对象的属性值会改变对象的哈希值,如果这些对象被集合管理,会导致从集合中获取的对象因为更新操作导致哈希值发生变化无法再计算出旧的哈希值找到原对象在集合中存储的位置导致对象无法被获取和删除,添加新对象时可能导致原对象直接被覆盖;即使是使用contains方法使用当前对象的引用作为参数去hashSet集合中检索对象也无法找到该对象;
默认的hashCode方法确实存在局限,比如两个对象的属性值完全相同就会被认为这两个对象相同这种场景很常见,而且集合也依赖hashCode为对象分配位置和删除集合中的对象,判定集合中是否存在相同对象会同时使用对象的hashCode和equals方法,因此JVM规范要求两个对象通过equals方法判定相等,两个对象的hashCode方法计算得到的哈希值也必须相等,两个对象通过equals方法判定不相等,两个对象的hashCode方法可以相等也可以不相等,因此重写equals方法必须重写hashCode方法;
使用集合管理对象经常会考虑两个对象属性值完全相同就相同,并且同时重写equals和hashCode方法,默认重写的hashCode为了和重写的equals保持一致就是会根据属性值计算哈希值;此时就要特别注意,一旦重写了hashCode且被集合管理的对象,一旦修改了对象的属性,这个对象因为无法计算出旧的哈希值再也无法从集合中找到,也无法从集合中删除,而且该对象因为新的哈希值对应桶上不一定有相同对象还可以再被放入集合一次,且与旧对象属性值相同的对象添加时发现哈希值对应位置上已经有对象,调用equals方法比较两个对象时发现对象不相等会直接覆盖掉修改了属性的原对象,如果没有额外重新保存修改了属性的原对象,原对象就会直接丢失[lombok的@Data注解会重写getter、setter、toString、equals、hashCode、无参构造器和全参构造器,因此使用了该注解的对象在使用集合管理时千万不要修改集合中对象的属性,改了集合中的对应对象就找不到也删不了,如果没有额外保存一份再添加旧属性完全相同的对象时会直接将修改后的对象直接覆盖掉]
像String这种不可变类型不存在修改属性值会导致hashCode变化的问题,使用集合管理时可以放心修改
缓存没有设置有效的移除策略导致缓存的对象无法被移除,生产环境的缓存数据量不好控制
可以通过WeakHashMap使用弱引用管理缓存数据,key使用弱引用,value使用强引用,当key除了集合本身没有其他引用的情况下,集合会自动丢弃相应键值对让value对应对象能自动被垃圾回收
也可以使用软引用当堆内存不足时自动清理只有软引用的缓存
客户端在系统监听器API中注册回调对象但是没有显式取消导致回调对象无法被回收,可以将回调对象保存为WeakHashMap的键,让回调对象没有得到使用时自动进行回收
过期引用:栈帧弹栈只是将指针指向下一个栈帧,被弹出的栈帧内容并未被置空,栈中仍然保存着已弹栈栈帧中的引用,这些引用指向的对象不会再被使用但是不会立即被回收,这是Effecitve Java指出的
内存泄漏分类
常发性:发生内存泄露的代码被频繁执行,每次执行都会导致内存泄漏
偶发性:代码执行只有特定环境下才会发生内存泄露,比如数据库连接、IO资源没有被正确关闭,关闭代码不在finally块中抛出了异常无法被正确关闭;偶发性内存泄漏可能在特定环境下变成常发性内存泄漏
一次性:发生内存泄漏的代码只会被执行一次
隐式:进程结束时才会释放对象内存
内存溢出:应用申请内存但是没有空闲内存可用,且尝试一次垃圾收集后也无法提供更多的内存;
抛OOM以前一定会触发一次Full GC,尝试清理死亡对象、软引用指向的对象以及直接内存;极端情况比如超大对象超出了堆的最大容量则不会尝试Full GC直接就会抛出OOM
内存溢出的常见原因包括
内存中加载的数据量过于庞大,比如一次从数据库获取过多的数据
集合管理的对象引用在使用完后没有清空
代码中存在死循环或者循环体产生过多的重复对象实例
第三方库中存在BUG
堆或者方法区内存设置过小
比如并发量太高,tomcat标准管理器ConcurrentHashMap类型的sessions属性保存着用户的session信息,开发者可以导出堆dump文件,使用OQL语句查询session数量、堆空间占用比例、统计session的创建时间判断是否因为短时间创建大量session导致内存溢出
线程请求的栈深度超出固定大小的虚拟机栈允许的最大深度时抛出StackOverflowError异常,可扩缩容的虚拟机栈扩容时无法申请到足够内存时将抛出OutOfMemoryError异常
应用中创建大量大对象且没有及时被回收,像JDK6及以前永久代的字符串常量池回收不积极,此时加载类,运行时创建大量动态类,intern方法调用太随意,都很容易发生永久代的内存溢出;永久代替换成元空间、串池和类变量迁移到堆以及intern方法只在串池保留已有字符串对象的引用等以后这个问题得到改善
直接内存存在内存泄漏点无法被回收
避免内存泄漏的方法
及时销毁无用对象的引用
避免在循环中创建对象
处理字符串时尽量使用StringBuilder替代String
少用静态变量,静态变量生命周期与类的生命周期一致
通过参数-Xss调整虚拟机栈的大小
while循环体中没有发生逃逸的局部变量每执行一次循环这些局部变量就能变成垃圾被回收
避免内存溢出的方法
配置增加堆和方法区内存
分析错误日志,检查内存溢出前是否存在其它异常或者错误
分析GC日志、堆转储快照、监控堆内存的使用情况、对每次GC后的堆内存情况统计排查内存泄漏点、调整堆、方法区直接内存的容量以及长生命周期对象
可能发生OOM的区域和OOM的触发条件
运行时数据区除了程序计数器外的堆、方法区和虚拟机栈、本地方法栈都可能发生内存溢出
堆:即使在Full GC后老年代仍然没有可用空间无法为新对象分配内存
虚拟机栈:HotSpot不区分虚拟机栈和本地方法栈,允许动态扩展的虚拟机栈扩展栈容量时无法申请到足够内存会抛出OOM、固定栈容量没有足够空间继续压栈栈帧时会抛出StackOverFlowError
没有控制好边界条件的死循环或者递归调用一定会导致栈溢出
方法区:使用CGLib、JSP或者动态产生JSP文件的场景,基于OSGI的应用会使用不同类加载器加载同一个字节码文件产生不同的类的场景都会在运行时动态生成大量类可能导致方法区内存溢出,常量池中对象的频繁创建也可能导致方法区内存溢出;从JDK8开始用元空间替代永久代使用本地内存存储方法区数据使得方法区的内促溢出情况得到改善
直接内存:直接内存默认容量与堆内存一致,可以通过参数MaxDirectMemorySize配置,通过反射获取Unsafe实例为对象进行内存分配以及使用NIO中的部分API导致直接内存的使用量超出指定上限也会抛出内存溢出异常,Java内存分析工具无法直接监控直接内存的使用情况,一般结合JVM内存和系统进程的内存监控一起排查直接内存存在的问题
安全点:
概念:安全点指线程指令中执行时间比较长的某些指令位置,比如方法调用指令执行时的压栈操作比较耗时、循环跳转和异常跳转指令都可以作为安全点
特点
GC过程中用户线程只能在安全点停顿下来
选择执行时间比较长的指令位置作为安全点的原因是在执行很快的指令位置处暂停会比较影响程序执行性能
类比高速不让停车,只能在收费站停车
线程可以通过抢占式中断和主动式中断停顿在安全点处
抢占式中断:先统一中断所有线程,不在安全点的线程恢复运行继续执行到下一个安全点,目前没有虚拟机采用抢占式中断方式
主动式中断:JVM中设置一个中断标志,每个线程运行到安全点都去轮询该中断标志,如果中断标志为true就在该安全点将线程停顿
安全区域:
概念:安全区域是一段指令片段,在该指令片段中对象引用关系不会发生变化,那么在这段代码片段执行期间的任何时刻进行垃圾收集都是安全的
特点:
程序正常执行情况下,每隔很短一段时间就能遇到一个安全点,但是如果线程因为调用sleep方法处于阻塞状态,此时程序无法响应JVM的暂停请求去安全点进行中断,JVM也不能一直等线程被唤醒以后才发出中断请求
运行到安全区域的线程会被标识为Safe Region,发生GC时,JVM会忽略掉标识为Safe Region的线程;当线程即将离开安全区域时会检查JVM是否已经完成了GC,如果已经完成了GC会继续运行,否则线程会暂停等待直到收到可以离开安全区域的信号
JDK命令行工具
JDK在bin目录下提供了一系列监控JVM运行状态的工具,源码在lib/tools目录下,都是打成jar包的字节码文件,只有需要修改命令行工具功能时才需要看源码,一般只需要掌握如何使用
javap:javap是JDK自带的字节码文件解析工具,作用是解析二进制字节码文件将字节码文件对应表结构信息输出为纯文本格式,通过指定特定命令参数也可以筛选仅显示指定修饰符的字段、构造器和方法,方法对应字节码指令,常量等类信息以及字节码文件的元信息
静态代码块会被编译成<clinit>()方法,javap解析<clinit>()和<init>()方法会被反编译为静态代码块和构造器
jps :用于查看指定操作系统的所有HotSpot虚拟机进程,可以显示虚拟机执行主类名称、进程ID、执行jar包的绝对路径、主方法入参、被用户或者虚拟机自动调整过的系统参数
搭配jstatd可以查看远程主机上的Java进程,这种方式不安全,建议只在本地使用JPS命令
jstat:用于监控获取JVM各种运行状态信息,可以显示本地或者远程JVM进程中的类加载信息、即时编译信息、GC信息、GC相关的堆内存信息、虚拟机进程id、没有GUI纯文本控制台的服务器环境下监控虚拟机性能、排除内存泄漏和内存溢出问题的首选工具;指定时间间隔对堆运行状态数据进行取样记录
生产环境可以通过jstat拉取GC数据计算GC占总运行时间的比例,如果该比例超过20%说明堆的压力比较大,如果超过90%说明堆几乎没有什么可用空间,随时都可能发生OOM
此外还可以对拉取的一段时间内的运行状态信息进行统计分析来评估系统性能,比如获取一段时间内老年代内存变化的谷值,如果这个值一直在增长,说明老年代垃圾收集不彻底,随着时间推移有极大概率会出现OOM
jinfo:主要用于查看任意JVM参数的默认值和实际值,能在JVM运行期间实时修改被标记了manageable的JVM参数,数量只有16个,JVM参数总共有七百多个,能通过这种方式实时修改的参数相当有限;可以查看在Java程序中可以通过System.getProperties()获取的JVM进程的系统属性,被用户或者JVM自动调整过的系统参数,任意指定参数名的参数值,此外java命令本身还可以打印所有JVM参数的默认值、打印所有JVM参数的实际值、打印所有被标记了manageable的JVM参数
jmap:用于导出本地或者远程JVM进程的堆转储内存映像快照dump文件,堆转储快照文件会保存距离指令执行时最近的一个安全点时刻所有的对象、类、根节点对象、虚拟机栈信息、堆信息、对象的聚合信息、类加载信息等;线上的堆转储文件可能高达几百兆,可以设置只保留活对象的快照信息来压缩堆转储文件的大小以及提升堆转储文件的生成速度,一般内存溢出都是由于GC无法回收的对象即活对象导致的;此外Jmap命令还可以生成某个时间点的堆内存信息、堆配置信息、堆中对象的统计信息、类加载信息、等待被执行finalize方法的对象信息等;此外用户还可以配置JVM进程在发生OOM之前自动生成一份dump文件或者每次发生Full GC以前自动生成一份dump文件
dump文件是一个二进制文件不能直接通过文本软件打开,一般都使用命令行工具jhat或者jconsole、jvisualVM,JProfile、Eclipse的MAT这种GUI监控分析工具来解析和分析内存溢出和内存泄漏的问题的发生源头
因为dump文件只会在安全点生成,如果只导出活对象的dump快照,在指令执行时刻到安全点时刻中间被销毁的对象无法被记录下来
jmap有很多功能只能在linux操作系统上使用
jhat:用于分析jmap生成的dump文件
jhat内置了一个微型服务器可以通过HTTP通信协议访问对堆dump文件的分析结果,默认端口号是7000,jhat的分析结果很简陋,而且一次只支持分析一个dump文件,一般堆dump分析工具都支持使用OQL查询语句,jhat在JDK9以后已经移除,官方建议使用JvisualVM替代
jstack:打印当前时刻JVM进程中所有线程的虚拟机栈快照,展示线程的执行状态,展示线程是否处于死锁、等待获取监视器、是否调用sleep或者wait方法进入阻塞状态;锁信息、本地方法栈信息,线程运行期间可能存在的问题;生成线程快照的主要目的是为了定位线程长时间出现停顿的原因,了解没有响应的线程在后台做什么或者到底在等待什么资源
在Java程序中也能通过Thread.getAllStackTraces()获取所有线程的运行状态,但是信息没有jstack归纳的那么全面
比如jstack会显示两个线程都在等待哪个同步监视器,甚至能推断两个线程是否发生了死锁
jcmd:是一个整合了除jstat外其他命令行工具大部分功能的多功能命令行工具,除了打印线程快照、堆内存信息、GC信息、查看JVM进程、方法形参、类加载信息、打印系统属性、所有JVM参数值、JVM的运行数据外还拥有jmap的大部分功能,官方推荐使用jcmd命令替代jmap
jstated:jstatd工具可以配合jps、jstat等命令行工具实现对远程JVM进程的监控,jstatd可以在服务器上作为代理服务器建立本地计算机与远程监控工具之间的通信,将当前机器的JVM进程数据传递到远程计算机上供监控工具进行统计分析
命令行工具的局限性
无法获取如方法间的调用关系、方法的调用次数、方法的执行时间等方法级别等对定位系统性能瓶颈至关重要的运行数据
需要用户登录JVM进程所在宿主机,数据的结果展示和分析结果通过终端输出,不够直观
命令行工具无法获取方法间的调用关系、调用时间、调用次数等方法级别的统计分析数据,这些数据对定位系统性能瓶颈至关重要,为了安全需要用户登录JVM进程所在宿主机,数据直接在命令行终端通过文本输出,很不直观;大多数GUI工具都能满足这些需求
JDK的bin目录下自带jconsole、jvisualvm、jmc三个图形化综合诊断工具;此外比较知名的还有MAT、JProfiler、Arthas等第三方GUI工具
必须掌握visualVM、在此基础上掌握Arthas、然后可以掌握JProfiler;在堆dump分析方面必须掌握MAT
jconsole
介绍:JDK5引入的基于JMX的JVM进程监控管理工具,可以实时监控本地或者远程JVM进程的堆内存使用情况、线程数、类个数、CPU占用率、非堆内存占用、信息并以折线图的形式展现,此外还可以强制执行GC、生成堆dump文件、检测JVM进程是否发生死锁,显示JVM的运行情况、配置参数情况
功能简陋,属于入门级别的GUI系统监控工具
Visual VM
介绍:JDK1.6u7引入了Visual VM几乎整合了所有的命令行工具,可以监控本地或者远程宿主机的全部JVM进程的系统参数配置、CPU占用率、GC、堆、方法区、线程的信息,JDK8后期版本及更高的版本需要从官网下载安装;此外还可以用于生成和解析堆转储快照文件、生成和解析线程快照;此外Visual VM还提供对CPU和内存的抽样功能,对CPU抽样会将占用CPU时间比较长的方法以列表的形式展现,同时还会统计每个线程占用CPU时间并排序;对内存抽样会以柱状图的形式展示每个类的实例数和内存占用量,点进每个类还会展示当前类下的所有实例的详细信息;此外还能添加另一个堆dump文件比较两个堆dump文件类统计信息的变化情况
visualVM是独立的软件,被JDK整合到bin目录下作为标准工具组件,因为这些组件都以j开头,因此JDK整合visual VM就被起名为jvisualVM,一般独立安装的visual VM就叫visual VM
IDEA可以安装插件VisualVM Launcher插件,启动应用程序的同时会自动启动Visual VM
visualVM支持插件扩展,可以在线也可以离线安装插件,用于内存监控的柱状图插件VisualGC是visualVM的必装插件;
VisualVM可以同时监控多个JVM进程,通过JMX代理连接远程JVM进程
功能比JConsole强大,是必须掌握的GUI监控工具
Eclipse MAT
介绍:MAT的招牌功能就是分析堆dump文件用于排查内存泄漏问题和优化内存开销,MAT是Eclipse的一个插件,该插件可以独立下载使用,使用MAT解析堆dump文件可以查看所有对象实例、对象的属性、对象的支配树、还能根据指定引用类型展示当前对象引用和被引用的对象;可以查看线程调用了哪些方法、方法对应栈帧局部变量表中的引用指向的对象,每个对象被哪些对象引用和引用了哪些对象;查看所有类、父类和对应类加载器、类的静态变量、类的不可达对象数量以及每个类的深堆浅堆大小等类信息;GCRoots到所有可达对象的引用链,GCRoots的个数;MAT还可以给出怀疑的内存泄漏点,只要生命周期太长的对象都会被MAT怀疑为内存泄漏点,比如一个局部变量出了作用范围还被长生命周期对象引用就会被MAT怀疑是一个内存泄漏点;MAT也能导出和解析堆dump文件;MAT打开dump文件时可以选择生成内存泄露报告或者组件报告,报告哪些对象是可疑内存泄漏点,分析是否存在重复字符串、空集合等可能发生内存问题的被怀疑对象;检测是否存在因为不同类加载器加载同一个字节码文件生成不同的类;还能比较两个dump文件类数据直方图,观察一段时间内实例数量增长最快的类和类实例;jhat、visualVM、MAT可以使用OQL查询语句过滤掉无用信息检索出目标数据
MAT只能处理主流厂商如SUN、SAP、HP的HPROF格式dump文件;也能解析IBM的PHD格式的dump文件
对象的支配树:对象引用图中,如果指向对象B的完整路径都经过对象A,称对象A支配对象B,如果对象A是距离对象B最近的支配者,称对象A是对象B的直接支配者;当前对象和被当前对象直接支配的对象组成支配树
保留集:支配树中当前对象和其子树称为当前对象的保留集,对象A的保留集指对象A被回收可以连带被回收的所有对象的集合加上对象A本身
浅堆:指单个对象实例本身内存占用大小
对象头:普通对象的对象头包含MarkWord和Klasspoint两部分,如果是数组还会额外增加数组长度部分;MarkWord包含一系列比如轻量级锁、偏向锁、分代年龄等标记位,在32位操作系统固定占4个字节,64位操作系统固定占8个字节;KlassPointer指向实例的class对象,在32位操作系统占4个字节,在64位操作系统占8个字节,从JDK7u4开始指针压缩就是默认开启的,KlassPointer也受指针压缩的影响,因此默认情况下KlassPointer占4个字节;因此默认情况下普通对象的对象头在32位操作系统下占8个字节,64位操作系统下占12个字节;如果是数组对象,数组长度无论是32位操作系统还是64位操作系统都是4个字节,但是数组对象未开启指针压缩的情况下对象头会自动8位对齐
实例数据:基本数据类型不论是32位操作系统还是64位操作系统内存占用都是固定的,引用数据类型引用64位操作系统占用8个字节,开启指针压缩的情况下占用4个字节;32位操作系统引用数据类型引用占用4个字节;数组元素为基本数据类型内存占用或者引用内存占用乘以数组总长度
在计算完对象头和实例数据大小之和后还要对整个对象进行八字节对齐
深堆:一个对象的深堆大小等于对象保留集中的所有对象的浅堆大小之和,对象的深堆大小指当前对象被回收可以真正被释放的内存空间大小
对象的实际大小为对象可触及的所有对象的浅堆之和,大于等于对象的深堆大小
JProfiler
介绍:JProfiler由ej公司开发,功能比MAT强大的多,但是收费;支持离线在线dump文件分析,本地远程监控;还提供功能强大的常用监控配置模版,对CPU占用、线程、内存的监控功能更细腻,展示形式更直观,内存直方图也会直接显示每个对象或者类下所有对象的深堆浅堆大小;支持对jdbc、noSql、jsp、servlet、socket的性能监控分析,提供很多操作系统平台的安装版本以及主流IDE插件;一般用来提升方法执行性能、分析堆中对象引用链排查内存泄漏问题;排查线程问题;分析存在问题的JDBC、慢SQL;
JProfiler提供全功能和抽样两种数据采集方式
全功能模式会在字节码加载前就将性能监控分析代码写入到被监控的字节码中,功能强大,采集的调用堆栈信息非常准确,缺点是对系统性能影响比较大,一般需要配合过滤器过滤掉JRE和框架中的类避免JProfiler对这些类进行分析
抽样模式指每隔一段时间对JVM堆和虚拟机栈中的信息进行取样,内存泄漏和内存溢出分析一般使用抽样模式就够了,JProfiler本身也推荐抽样模式,无需配置过滤器,对JVM进程影响非常小,缺点是无法使用JProfiler查看某个方法的调用次数和对方法性能分析
JProfiler还可以开启实时抽样功能,开启会严重降低系统性能,一般只有排查内存泄漏才会使用该功能,可以设置每创建多少个对象就取样一次,对取样对象可以按类或者包分类,可以从某个时间点开始实时观察到新创建了多少对象、有多少对象被回收、剩余多少对象,有多少大对象并以直方图和不同颜色标识的形式展示
JProfiler的Heap Walker功能也能生成堆dump文件,解析堆dump文件的类实例个数和深堆浅堆大小以及对象实例到GC Roots的引用链以及对象的整个引用关系图
CPU Viewer能监控所有线程的状态,展示一段时间内线程的状态变化情况以及生成线程快照;也能监控指定线程中的方法调用树以及每个被调用方法的执行时间、平均执行时间、最小时间、最大时间、占整个方法调用时间的百分比以及方法被调用的次数
Arthas
介绍:visualVM和JProfiler一般用于上线前的性能压力测试和代码优化;线上网络一般是隔离的,本地的GUI工具要连上线上环境很不方便,上线以后一般使用阿里开源的Arthas在服务端通过命令行进行调优和性能监控;Arthas提供了命令行客户端,通过JavaAgent的permain方法实现在main方法执行前先执行Arthas的监控代理代码,通过ASM实现类字节码的热更新和方法追踪操作,并且在8563端口提供了一个和命令行客户端一样的Web客户端;提供一系列命令工具可以用于间隔打印线程、堆内存数据、JVM参数配置、类的静态变量、导出堆dump文件、类加载信息、方法信息、反编译已加载类和方法的源码;提供内存编译器能够在JVM运行期间实时编译Java源码文件替换掉JVM中同名的类;监控统计方法调用次数、执行耗时、方法入参、返回值、抛出异常、方法的调用路径、每个方法调用节点上的耗时、调用线程的信息;生成火焰图等若干其他功能,火焰图可以从浏览器访问3658端口查看
JMC
介绍:JDK7u40在bin目录下引入了JMC,是Oracle收购BEA后从JRocket中移植到HotSpot中的,JFR飞行记录仪是JMC的一部分,在JDK11开源,此前属于商业版特性,JFR能以极低性能开销搜集JVM的性能数据,默认配置下性能开销平均低于1%;JMC包含一个GUI客户端和众多性能数据收集插件,采用取样而非代码植入的方式采集运行数据,对应用性能影响非常小,开着JMC做压测对JVM造成的唯一影响是Full GC变多了;JMC自定义监控堆内存、CPU占用率、线程等各类数据,还可以设置触发器在CPU占用过低或者过高、发生死锁、线程数量太多时触发报警
飞行记录仪会监控JVM进程的系统属性、内存、线程、GC、热点包、热点类、热点方法、调用树等信息;记录JVM进程运行期间发生的出现异常、线程启动等瞬时事件、垃圾回收等持续事件、时长超出指定阈值的计时事件和周期性对方法、栈取样的取样事件,要使用飞行记录仪需要手动启动
JVM参数分类
标准参数:可以通过java -h打印出来的就是标准参数,形式上以-打头,比如java -version,基本上不会随着JDK版本迭代发生变化,以-X和-XX打头的参数都是非标准参数
-X参数:形式上以-X打头,相对稳定,可以通过java -X打印具体的参数列表,可以用于设置混合模式,禁用即时编译器,禁用解释器,设置堆大小、虚拟机机栈大小等
-XX参数:形式上以-XX打头,参数数量多达六百余个,常常会随着JDK版本迭代发生修改或者被移除,按参数格式分为布尔类型参数和非布尔的键值类型参数;
布尔类型参数指通过+/-启用和禁用某个功能的参数,比如启用或者禁用指定垃圾收集器或者启用或者禁用自适应堆大小策略;
键值类型根据参数值又分为数值类型和非数值类型,数值类型比如设置堆的大小,垃圾收集的最大暂停时间等,非数值类型比如指定导出的堆转储文件存储路径
JVM参数的配置方式
可以在Eclipse和IDEA的运行参数配置面板的VM arguments或者VM Options设置JVM参数
配置为运行jar包的命令行中的命令参数
通过tomcat运行war包时,linux可以在tomcat的catalina.sh文件中的JAVA_OPTS参数中设置需要的JVM参数,windows可以运行tomcat的启动脚本catalina.bat所在目录下通过命令set "JAVA_OPTS=-Xms512M -Xmx1024M"设置所需的JVM参数
只有十几个被标记为manageable的JVM参数可以在程序运行期间使用jinfo进行修改,其他参数只要JVM一启动就无法再运行期间进行修改
Java代码获取JVM参数
通过Runtime类或者java.lang.management包下的ManagementFactory等本地或者远程监视管理JVM的组件能实现在程序运行期间动态地获取JVM的实际参数配置
常用JVM参数
堆内存参数
-Xms:指定堆内存初始大小,等价于参数-XX:InitialHeapSize
-Xmx:指定堆内存最大大小,等价于参数-XX:MaxHeapSize
-XX:NewSize:指定新生代初始内存大小
-XX:MaxNewSize:指定新生代最大内存大小
-Xmn:同时指定新生代的初始大小和最大大小都为同一个指定值
GC调优一条重要经验是尽可能将对象留在新生代,实际开发中可以根据GC日志调整新生代空间大小最大限度降低新对象直接进入老年代的情况
-XX:SurvivorRatio:指定伊甸园区相较于一个幸存者区内存大小的倍数
默认情况下开启自适应策略会自动调整伊甸园区和幸存者区的比例来满足垃圾收集指定暂停时间和吞吐量的要求,伊甸园区和单个幸存者区的默认比例为8:1,但是实际上一般不是这个值,经过测试验证为6:1,即使关闭了自适应调节策略依然为6:1,该参数的默认值确实也是8:1,只有显示指定该参数值为8伊甸园区和单个幸存者区的比例才能满足8:1
-XX:MaxTenuringThreshold:设置对象晋升老年代的分代年龄阈值
-XX:NewRatio:指定老年代相较于新生代内存大小的倍数
-XX:PermSize:指定永久代初始内存大小
-XX:MaxPermSize:指定永久代最大内存大小
-XX:MetaspaceSize:设置元空间初始内存大小
该参数不会生效,不论设置何值,64位JVM元空间的初始容量都大约为20m,-XX:MetaspaceSize实际作用是元空间不断扩容到指定阈值后每次扩容之前都会触发Full GC
-XX:MaxMetaspaceSize:设置元空间最大内存大小
-XX:HandlePromotionFailure:是否允许空间分配担保,JDK6 Update24版本以后OpenJDK的源码中已经不使用该参数而是直接使用该参数为true的规则,只要YGC前老年代的连续可用空间大于新生代对象总大小或者大于历次晋升对象的平均大小就会进行YGC,否则将YGC改为Full GC
-XX:PretenureSizeThreadshold=1024:默认单位为字节,设置让大于此阈值的新对象直接分配在老年代,该参数只对Serial、ParNew有效
-XX:TargetSurvivorRatio:设置minorGC结束后幸存者区中占用空间的期望比例,会涉及到幸存者区分代年龄阈值的计算
-XX:+UseCompressedOops:启用压缩指针
虚拟机栈参数
-Xss:设置单个虚拟机栈的内存最大大小,等价于-XX:ThreadStackSize
直接内存参数
-XX:MaxDirectMemorySize:设置直接内存大小,这里的直接内存大小不包括元空间的本地内存,JVM可以通过NIO和Unsafe实例访问这些内存,默认直接内存大小和堆内存大小相同
处理OOM参数
-XX:+HeapDumpOnOutOfMemoryError:发生OOM时自动生成dump文件
-XX:+HeapDumpBeforeFullGC:每次FullGC前自动生成dump文件
-XX:HeapDumpPath:指定dump文件的存储路径和文件名
默认是JVM的工作目录,工作目录指java启动命令所在目录,或者代码System.getProperty("user.dir")的返回值也是工作目录,如果生成的hprof文件的名字相同会在文件后缀后加.1、.2...
-XX:OnOutOfMemoryError:指定一个可执行程序或者脚本的路径,发生OOM时自动执行该脚本,比如发生OOM时自动执行shell脚本让服务器重启
垃圾收集器参数
-XX:+PrintCommandLineFlags:打印命令行参数,最后会打印使用了哪种垃圾收集器
-XX:+UseSerialGC:指定新生代和老年代都使用串行垃圾收集器,Serial和Serial Old是Client模式下的默认垃圾收集器
-XX:+UseParNewGC:指定新生代使用ParNew,不影响老年代
-XX:ParallelGCThreads:指定新生代并行垃圾收集器的线程数量,当CPU核数小于等于8默认开启和CPU核心相同数量的垃圾收集线程;当CPU核数大于8个,默认值为5/8的CPU核数向下取整后加3
-XX:+UseParallelGC:指定新生代使用Parallel,会自动配置-XX:+UseParallelOldGC老年代使用Parallel Old,老年代启用Parallel Old也会默认新生代使用Parallel,也使用-XX:ParallelGCThreads设置Parallel的垃圾收集线程数量
-XX:MaxGCPauseMillis:设置单位为毫秒的垃圾收集器最大暂停时间
JVM默认开启了自适应堆大小调节策略,为了尽可能将暂停时间和吞吐量控制在指定范围内,垃圾收集器会自动调整年轻代大小、伊甸园区和幸存者区的比例,对象分代年龄阈值,CMS触发垃圾收集老年代内存占用阈值等参数;一般开启自适应调节策略下仅指定堆的最大大小、垃圾收集器的目标吞吐量以及最大停顿时间,其他参数让JVM自动调整完成调优工作
Parallel主打吞吐量,不建议对Parallel的暂停时间要求太苛刻
-XX:GCTimeRatio:设置垃圾收集时间占总运行时间的比重,即设置吞吐量,默认值为99,即吞吐量为99%
-XX:+UseConcMarkSweepGC:指定老年代使用CMS,使用CMS新生代会自动使用ParNew并启用Serial Old作为兜底
从JDK9开始配置该参数会收到CMS将在未来被移除的警告,在JDK14中CMS被移除,再配置该参数JVM会警告并自动使用默认垃圾收集器,但是CMS在服务器与客户端强交互比如互联网场景下仍然非常常见
-XX:CMSInitiatingOccupanyFraction:设置触发CMS开始工作时的老年代内存占用阈值
JDK5及以前默认值为68%,JDK6及以后为92%,内存增长缓慢可以增大该阈值降低老年代垃圾收集频率改善应用性能,内存使用率增长很快应该降低该阈值避免垃圾收集速度跟不上用户的内存消耗速度频繁触发全程独占的串行Full GC
自适应堆大小调节策略会自动调整该参数,但是可以通过参数-XX:CMSInitiatingOccupanyFractionOnly配置只按照该参数的值触发CMS老年代垃圾收集
-XX:+UseCMSCompactAtFullCollection:指定在使用CMS期间每次执行完Full GC后都对内存空间进行压缩规整,会增加Full GC的时间,但是能降低因为内存碎片导致Full GV的频率
-XX:CMSFullGCBeforeCompaction:指定在使用CMS期间执行多少次Full GC后对内存空间进行压缩规整
-XX:parallelCMSThreads:指定CMS垃圾收集线程数量,默认线程数是新生代垃圾收集线程数量加3除以4,当CPU资源紧张时,应用程序性能受CMS收集线程的影响在垃圾收集阶段可能非常糟糕
-XX:+CMSClassUnloadingEnable:允许CMS在垃圾收集时卸载未被引用的类,启用该参数可以在类加载器频繁加载和卸载类的场景中优化内存的使用
CMS初始标记和重新标记阶段默认都是并行标记可触及对象以提高标记速度,可以通过参数-XX:-CMSParallelInitialEnabled和-XX:-CMSParallelRemarkEnabled关闭并行标记功能
-XX:+ExplicitGCInvokesConcurrent、-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses:指定JVM在执行System.gc()时使用CMS而非默认的Full GC,在使用基于NIO的Netty时回收堆外内存需要使用Full GC,配置该参数可以使用CMS回收堆外内存显著降低Full GC导致的长时间停顿;此外启用该参数还会在垃圾收集期间卸载未被引用的类,在类频繁加载和卸载的场景中优化内存的使用
-XX:+UseG1GC:指定使用G1进行整个堆的垃圾收集
-XX:G1HeapRegionSize:指定每个Region的内存大小,值可以是1-32MB之间的二次幂,默认为堆内存的1/2000
-XX:ParallelGCThreads:指定执行并发标记的线程数,一般将该参数设置为新生代并行垃圾收集线程数的1/4左右
-XX:InitiatingHeapOccupancyPercent:设置触发G1垃圾收集周期的Java堆占用率阈值,默认值为45
-XX:G1NewSizePercent、-XX:G1MaxNewSizePercent:指定新生代占整个堆的最小和最大百分比,默认值分别是5%和60%
-XX:G1ReservePercent:老年代预留一部分假空间作为假天花板,预留空间用于减少新生代对象晋升老年代因为空间不足导致的Full GC
-XX:InitiatingHeapOccupancyPercent:指定触发G1并发标记时的堆占用阈值,默认为45%
-XX:G1MixedGCLiveThresholdPercent:设置region允许被回收时的region内存占用阈值
-XX:G1HeapWastePercent:指定允许被浪费的堆内存阈值,G1单个周期默认经过八次混合回收手机整个老年代,当回收期间发现剩余可回收空间低于该参数指定的值,混合回收会被终止,避免浪费很多的时间进行垃圾收集但是回收的空间却很有限,默认允许被浪费的空间为堆内存大小的10%
-XX:G1MixedGCCountTarget:一次全局并发标记后混合回收执行的最大次数,默认为8次
信息打印参数
-XX:+PrintCommandLineFlags:在程序启动时打印用户手动设置或者JVM自动设置的-XX参数,主要打印性能调优相关的参数,-XX:+PrintVMOptions的作用差不多,主要打印适合调试和分析的配置相关参数
-XX:+PrintFlagsInitial:在程序启动时打印所有-XX参数的默认值
-XX:+PrintFlagsFinal:在程序启动时打印所有-XX参数的实际值,:=符号表示实际值不等于默认初始值
-XX:+PrintTLAB:打印线程本地分配缓冲区的相关信息
-XX:+PrintEscapeAnalysis:打印逃逸分析的筛选结果
此外可以使用jinfo命令查看具体某个JVM参数的默认值和实际值
-XX:+TraceClassLoading:打印所有加载过的类的日志,没啥代码的情况下也会打印一千行左右
GC日志相关参数
-verbose:gc:标准参数类型,等价于-XX:+PrintGC,GC时打印简化的GC日志信息,不会打印堆空间的详细情况
-XX:+PrintGCDetails:GC时打印详细的GC日志信息,JVM进程退出时打印当前内存区域的详细信息
-XX:+PrintGCTimeStamps:需要搭配-XX:+PrintGCDetails一起使用在GC日志头部打印JVM启动到GC时刻的时间
-XX:+PrintGCDateStamps:需要搭配-XX:+PrintGCDetails一起使用在GC日志头部打印GC时刻日期时间形式的时间戳
-XX:+PrintHeapAtGC:每次GC前后都打印堆内存信息,该参数可以独立使用,和-XX:+PrintGCDetails一起使用时会将堆内存信息和GC信息混合在一起打印并在JVM进程结束以前打印一次堆内存信息
-Xloggc:<file>:将GC日志写入到指定文件中,日志目录必须提前创建,否则JVM启动会报错;
-XX:+PrintGCApplicationStoppedTime:打印每次GC的暂停时间
-XX:+PrintGCApplicationConcurrentTime:打印每次GC前应用的连续运行时间
-XX:+PrintReferenceGC:打印GC期间回收的软引用、弱引用和虚引用的数量
-XX:+PrintTenuringDistribution:打印每次minorGC后幸存者区中对象的分代年龄分布
-XX:GCLogFileSize=1M:指定单个GC日志文件的最大大小
-XX:+UseGCLogFileRotation:启用GC日志文件的滚动功能,当GC日志文件达到设定的最大大小时自动将日志内容输出到下一个日志文件
-XX:NumberOfGClogFiles:指定GC滚动日志文件的数量,默认为0,表示不滚动,超出该数量会覆盖最旧的日志文件
其他参数
-XX:ReservedCodeCacheSize=<n>[g|m|k]、-XX:InitialCodeCacheSize=<n>[g|m|k]:分别表示设置即时编译器生成的本地机器代码缓存区域的预留空间大小以及初始空间大小,在较新的JVM中代码缓存区域空间大小的默认值通常为240MB,初始大小为160KB
-XX:+UseCodeCacheFlushing:默认情况下即时编译代码缓存区域满了以后,JVM会关闭即时编译器切换到纯解释执行模式;启用该参数后,缓存区域会自动清除部分缓存为新的即时编译任务腾出空间
-XX:+UseStringDeduplication:启用字符串去重功能,默认情况下创建字符串对象会在堆创建字符串对象指向字符串常量池中的常量,启用字符串去重功能,在创建新字符串对象时会检查一个char[]形式的字符串表中是否已经存在相同的字符串,如果存在就直接引用已经存在的字符串对象;在存在大量重复字符串的场景下这种方式可以显著减少堆中重复字符串对象的数量
该问题出处是JavaGuide,写的很烂,待完善
栈可能出现的问题
如果栈容量是固定的,线程请求的栈深度超过虚拟机允许的最大深度,将抛出StackOverflowError异常
如果栈容量可以动态扩展,栈扩展或者创建时无法申请到足够内存将抛出OutOfMemoryError异常
排查思路
查找错误日志,检查是StackOverflowError还是OutOfMemoryError
如果是StackOverflowError,检查代码是否有递归调用方法的情况以及栈容量太小的情况
如果是OutOfMemoryError,检查是否有死循环创建线程的情况
GC调优目的
GC调优的目的是尽可能降低单次GC的持续时间,降低GC频率
年轻代的GC调优尽可能维持单次GC耗时小于50ms,GC间隔在十秒以上
老年代的GC调优尽可能维持单次GC耗时小于1s,GC间隔在十分钟以上
评价GC性能的核心指标
延迟:最大停顿时间,越短越好,为了更短的暂停时间甚至能接受适度增加GC频次
吞吐量:吞吐量是用户线程运行时间占整个系统总运行时间的百分比
互联网公司的系统基本追求低延时,避免一次GC停顿时间过长对用户体验造成损失,一般互联网要求一次停顿时间不超过应用服务的TP9999[TP9999即Top Percent 0.9999的请求的响应时间],GC的吞吐量不低于99.99%
GC日志分析
-XX:+PrintGCDetails详细的GC日志包括GC发生时刻时间戳、GC类型、GC原因即GC Cause,使用的垃圾收集器、垃圾收集前后堆中各个区域内存占用的变化、整个堆内存占用在GC前后的变化,Full GC还会额外打印方法区GC前后的内存占用变化、垃圾收集线程的工作时间、垃圾收集从开始到结束的时间等信息
使用GCEasy分析GC日志能使用图表展示GC前后JVM各内存区域的内存占用变化、计算GC吞吐量以及平均暂停时间和最大暂停时间,GC的持续时间、堆内存各个区域以及元空间的总容量,对GC数据的统计分析
GCViewer是开源GC日志分析工具,下载运行jar包就能启动,能根据GC日志对GC的吞吐量、暂停时间、内存占用情况进行简单统计
GChisto也是开源GC日志分析工具,但是不咋维护而且Bug比较多,可以分析GC日志文件通过图表、报表、列表等形式展示GC次数、频率、持续时间等信息
HPjmeter只能打开由JVM参数-verbose:gc和-Xloggc:gc.log生成的日志文件,只要添加了其他参数生成的GC日志文件就无法被HPjmeter打开,功能比较强大一般用于分析HP机器上产生的GC日志文件
此外比较出名的GC日志分析工具还有GCLogViewer、garbagecat
分析GC Cause
使用gceasy等工具分析GC日志文件可以直观地看到GC Cause的分布情况,了解导致GC的不同原因出现的次数、每种原因下GC的平均时间、最大时间和总时间;具体的GC Cause分类可以从HotSpot的源码src/share/vm/gc/shared/gcCause.hpp和src/share/vm/gc/shared/gcCause.cpp中找到,重点关注以下几个GC Cause
System.gc():通过System.gc()手动触发的GC操作
CMS:CMS的初始标记和重新标记两个STW阶段执行的操作
Promotion Failure:老年代没有足够的内存空间分配给新生代晋升的对象
Concurrent Mode Failure:CMS运行期间老年代预留的空间无法满足用户线程运行的需要,此时收集器会退化成Serial Old,变成全程独占串行的Full GC,严重影响GC的性能
GCLocker Initiated GC:线程在执行JNI临界区时正好需要进行GC,此时GC Locker将会阻止GC的发生,同时阻止其他线程进入JNI临界区,直到最后一个线程退出临界区时再触发一次GC
GC问题判断
如何判断一次GC问题是GC导致的故障还是系统本身引发的GC问题,引起服务Response Time突然增加的原因包括GC耗时增大、线程阻塞增多、慢查询增多以及CPU负载高四个诱因,可以通过以下判断方法来判断引入RT上涨的根本诱因
时序分析:最先发生的事件是根因的概率更大,比如先观察到CPU负载飚高那么影响链就很可能是CPU负载高-->慢查询增多-->GC耗时增大-->线程阻塞增多-->RT上涨
概率分析:综合历史问题的经验按近到远分析,比如近期慢查询比较多,影响链就可能是慢查询增多-->GC耗时增大-->CPU负载飚高-->线程阻塞增多-->RT上涨
实验分析:通过模拟故障的方式对现场情况进行模拟,触发其中一个或者多个诱因观察是否会发生相同问题,影响链可能是线程Block增多-->CPU负载飚高-->慢查询增多-->GC耗时增加-->RT上涨
反证分析:判断事故发生时诱因是否发生,如果从集群角度观察到慢查询、CPU都正常,但是仍然出现问题,影响链可能是GC耗时增加-->线程阻塞增加-->RT上涨
此外CPU飚高可以用火焰图看热点、慢查询可以检查DB情况、线程阻塞可以检查线程状态和锁竞争情况,各个诱因都没有问题就怀疑GC存在问题可以继续分析GC
常见GC问题场景
动态扩容引起的空间震荡
现象:服务刚启动时GC次数较多,内存剩余空间很大但是仍然发生GC,可以通过观察GC日志或者通过堆内存监控工具发现,GC Cause一般是Allocation Failure,可以观察在GC前后堆内存容量是否为固定值
原因:-Xms和-Xmx设置的不一样大,初始空间不够用可能会连续扩容,每次扩容前都会进行一次GC,此外如果剩余空间很多也会进行缩容操作
解决方案:将初始值和最大值这种成对出现的内存大小配置参数如-Xms和-Xmx、-XX:MaxNewSize和-XX:NewSize、-XX:MetaSpaceSize和-XX:MaxMetaSpaceSize设置成同一个值来获得一个稳定的堆和方法区;当然在类似富客户端Java应用这种需要动态伸缩节省空且不追求停顿时间的场景下还是推荐允许堆进行扩缩容
显式GC的去留
关于显式GC
显式GC的调用是否触发GC是随机的,但是在调用System.gc()以后再调用System.runFinalization()会强制确保调用失去引用的对象的finalize()方法
显式GC一般用在像性能基准测试这种特殊场景下,比如在做测试前先做一个GC,防止测试过程中因为内存占用原因对测试结果造成影响导致结果不准确
现象:手动调用System.gc()可能会导致JVM没有达到需要扩缩容、Old区达到回收阈值、MetaSpace空间不足、新生代晋升失败、大对象担保失败等触发条件的情况下仍然触发Full GC,这种GC可以通过GC日志的GC Cause确认,设置参数-XX:+DisableExplicitGC会让手动调用System.gc()时执行空方法,但是建议保留手动调用System.gc()
CMS分为Background和Foreground两种模式,Background模式就是CMS中常规的并发收集,System.gc()调用的是和Serial Old GC一样基于Lisp2的压缩式GC,会收集整个堆和元空间,因为压缩性能开销大且独占,因此Foreground模式会导致很长的STW,在应用中频繁调用System.gc()会非常危险
HotSpot中的GC算法都带自适应功能,会搜集先前垃圾收集的效率等数据决定后续GC使用的参数,但是System.gc()默认不更新GC统计数据避免用户强行GC对自适应功能产生干扰,但是可以通过-XX:+UseAdaptiveSizePolicyWithSystemGC开启对System.gc()性能数据的统计
原因:禁用掉System.gc()可能导致内存泄漏问题,DirectByteBuffer因为零拷贝的特点被Netty等各种NIO框架使用。堆外内存不像堆内存由JVM管理,必须手动释放,DirectByteBuffer的清理工作通过sun.misc.Cleaner自动完成,在为DirectByteBuffer分配空间的过程中会显式调用System.gc()希望通过Full GC来强迫释放掉无用的DirectByteBuffer对象对应的本地内存,HotSpot会在YGC时触发对新生代中DirectByteBuffer对象的引用处理进而触发Cleaner对死亡DirectByteBuffer的清理工作,在Major GC时清理对应的老年代DirectByteBuffer,如果开启了-XX:+DisableExplicitGC,System.gc()会失效,会发生直接内存的OOM
互联网的RPC通信大量使用NIO,建议保留手动调用System.gc(),同时JVM提供了参数-XX:+ExplicitGCInvokesConcurrent和-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses将System.gc()的触发类型从Foreground改为Background,Background模式也能触发Cleaner对DirectByteBuffer关联的直接内存的清理,还能大幅降低STW开销,此外在G1和ZGC中开启-XX:+ExplicitGCInvokesConcurrent也能大幅提升手动调用System.gc()时的性能,建议在代码规范中做好约束规范好System.gc()的调用
方法区的OOM
原因:在JDK7以前,字符串常量池,类变量都存放在方法区,String.intern()方法是不受控的,因此永久代的大小不好设置,经常出现永久代的OOM,在JDK7字符串常量池、类变量都移动到堆中,JDK8直接用基于本地内存的元空间取代永久代,这种情况大大被改善;但是一般为了避免元空间耗尽JVM内存,都会设置一个MaxMetaSpaceSize,运行时JVM会自动扩缩容方法区的大小;即使是为了避免弹性扩缩容带来的额外GC消耗将-XX:MetaSpaceSize和-XX:MaxMetaSpaceSize两个值都设置为固定大小最终也会因为类加载器只要存活,类就无法被卸载,导致类占用空间一直在累增最终导致元空间无法扩容频繁触发GC最终导致OOM,对于元空间这种问题一般都集中在反射、Javasisit字节码增强、CGLIB动态代理、OSGI自定义类加载器等动态类加载场景下
问题分析:使用JProfiler或者MAT分析堆dump文件按类聚合的直方图,或者通过jcmd打印几次直方图观察一下具体是哪个包下的类数量增加比较多就能定位;必要的时候还可以添加-XX:+TraceClassLoading和-XX:+TraceClassUnLoading参数观察详细的类加载和卸载信息;还可以给元空间的使用率加一个监控便于提前发现和解决问题
过早晋升
原因:
新生代或者伊甸园区设置的过小,GC更频繁,导致本应该不能被晋升的对象达到了晋升条件,同时增加GC的整体开销
内存分配速率过大,通过观察问题发生前后内存的分配速率。出现明显提升可以尝试检查网卡流量、存储类中间件的慢查询日志等信息检查是否有大量数据被加载到内存中
分代年龄阈值设置的过大或者过小。如果分代年龄阈值设置的过大,早应该晋升的对象会一直停留在幸存者区直到幸存者区溢出,一旦溢出,对象老化机制将失效,整个新生代的对象将全部提升到老年代;如果分代年龄阈值设置的过小也会引起对象的过早晋升,导致老年代空间迅速增长导致分代收集失去意义
同一个应用在不同时刻执行任务或者流量的成分变化都是导致对象的生命周期发生波动,固定的分代年龄阈值无法适应动态的变化,可能会导致上述的影响,HotSpot会自动使用动态计算的方式调整对象晋升的分代年龄阈值,会累计分代年龄小于n的对象内存占用,一旦内存占用大于幸存者区的条件值就会拿n与分代年龄阈值进行比较,二者谁更小就会选取谁作为分代年龄阈值
判断方法:
对象分配速率接近晋升速率且对象的晋升年龄较小,GC日志中只要出现了Desired survivor size 107347968 bytes, new threshold 1(max 6)等信息就说明对象只经历一次GC就存放到老年代
Full GC比较频繁,且一次GC后老年代的内存释放比例非常大
危害:
过早晋升问题不会立即导致垃圾收集问题,因此不会体现地特别明显,但是长期积累可能会导致更频繁地收集器退化等问题,同时还会导致更频繁的YGC和FGC,导致吞吐量增大的同时还会导致更频繁的长时间停顿
解决方案:
如果是伊甸园区或者新生代设置的过小,可以在堆大小不变的情况下适当增大新生代,一般老年代大小考虑到浮动垃圾最好设置在活跃对象的三倍空间左右,剩下的空间都可以分配给新生代;运行期间如果活跃对象的数量增长也可以适当再调节老年代的大小
即使是美团,也没有按部就班地设置新生代和老年代保持默认的1:2比例,以下是美团一次典型优化过早晋升的例子,CMS GC情况下存活对象内存占用300-400M,老年代调整为1.5G,新生代调整为2.5G,JVM的YGC频次从26次/分降低至11次/分,一分钟内总的YGC时间从1100ms降低至500ms,老年代GC从40分钟一次降低至7小时30分钟一次
如果是分配速率过大,偶发性的分配速率过大通过内存分析工具找到问题代码,从业务逻辑上做优化;如果是持续性的分配速率过大,调整GC类型或者增大内存空间
内存碎片和收集器退化
现象:CMS的并发垃圾收集算法退化为Foreground模式单线程独占的GC,STW时间可能长达十几秒;退化的串行垃圾收集可以选择进行带压缩动作的算法,这种情况下是真正意义上的Full GC,会对整堆进行收集,暂停时间也会长于普通的CMS并发收集;退化的垃圾收集还可以选择不带压缩动作的算法只收集老年代,暂停时间相对于MSC算法短一些
原因:
对象分配内存失败,尝试发起一次YGC前检查老年代剩余内存小于新生代对象总内存占用,触发动态担保机制但是判断老年代可用空间仍然小于历次平均晋升空间直接触发Full GC替代YGC
一次YGC后,发现幸存者区放不下一次YGC后新生代的存活对象,所有新生代对象都只能放入老年代,但此时老年代也放不下
内存碎片导致晋升的大对象找不到连续可用内存存放触发Full GC,内存碎片会导致JVM需要逐个遍历空闲列表直到找到一个合适大小的内存空间,拉低空间分配效率;即使老年代可用空间足够,但是没有连续可用内存可以分配给晋升或者创建的对象也会提前触发Full GC
显式调用GC也会导致收集器退化
老年代正在并发收集,但是用户线程同时也在运行,新生代垃圾收集触发对象晋升,但是老年代没有足够空间保存新晋升的对象,导致该问题的重要原因还有一个浮动垃圾,这些对象无法在本轮垃圾收集期间回收,因此CMS的垃圾收集阈值不能设置的太高,否则预留的内存空间很可能不够
这种退化可以通过GC日志中出现Concurrent Mode Failure来识别
解决方案:
内存碎片:配置JVM参数-XX:UseCMSCompactAtFullCollection=true开启FUll GC期间自动对内存空间进行规整,默认情况下就是开启的;还可以设置JVM参数-XX:CMSFullGCsBeforeCompaction=n控制多少次Full GC后对内存进行一次压缩规整
增量收集:调整-XX:CMSInitiatingOccupancyFraction降低CMS垃圾收集的触发阈值,同时还需要设置-XX:+UseCMSInitiatingOccupancyOnly,否则JVM只会在第一次使用设定值,后续则会自动调整
浮动垃圾:控制每次晋升对象的大小、缩短每次CMS GC的时间,必要时调整NewRatio的值,启用-XX:+CMSScavengeBeforeRemark在重新标记阶段前强制执行一次YGC,减少老年代对新生代的跨代引用,提升重新标记阶段的效率降低该阶段的停顿时间
堆外内存溢出
现象:内存使用率不断上升甚至开始使用SWAP内存[SWAP内存是当系统物理RAM内存不足无法满足运行程序需要时操作系统会自动将一部分磁盘空间用作虚拟内存,这部分磁盘空间就被称为SWAP内存],同时出现GC时间飙升,线程被阻塞等现象,通过top命令发现JVM进程的RES即实际占用物理内存已经超出-Xmx设置的最大堆内存,此时可以确定发生了堆外内存泄漏
原因:
使用NIO和Netty相关组件时通过Unsafe#allocateMemory和ByteBuffer#allocateDirect申请堆外内存但是没有主动释放
JNI调用本地代码申请的本地内存没有释放
解决方案:
可以配置JVM参数-XX:NativeMemoryTracking=detail使用NMT[NativeMemoryTracking]工具分析是哪种原因导致的堆外内存泄漏,需要重启项目,开启该工具会导致5-10%的性能开销。通过jcmd命令可以查看堆内内存、Code区域、通过Unsafe.allocateMemory和DirectByteBuffer申请的堆外内存,但是不包括本地方法申请的堆外内存;如果total中的committed项和top命令结构中的RES实际内存相差不大说明是用户主动申请的堆外内存未释放造成、如果相差较大基本可以确定是JNI本地方法调用造成
主动申请的本地内存未释放:NIO和Netty中都有一个计数器字段用于计算当前已经申请的堆外内存大小,NIO中为java.nio.Bits#totalCapacity、Netty中为io.netty.util.internal.PlatformDependent#DIRECT_MEMORY_COUNTER,NIO和Netty在申请堆外内存前都会比较计数器字段和堆外内存阈值-XX:MaxDirectMemorySize比较大小,如果计数器的值超出最大值的限制则抛出OOM异常,NIO抛出OutOfMemoryError: Direct buffer memory;Netty抛出OutOfDirectMemoryError: failed to allocate capacity byte(s) of direct memory (used: usedMemory , max: DIRECT_MEMORY_LIMIT )
我们可以对NIO或者Netty的计数器字段数值打点准确监控主动使用堆外内存的情况,通过Debug调试检查使用堆外内存的地方是否正确执行了释放内存的代码,判断是否配置了参数-XX:+DisableExplictGC,该参数会使System.gc()失效
JNI本地方法申请的本地内存未释放:可以通过Google perftools+Btrace等工具分析出问题代码位置,gpergtools能追踪统计内存分配情况,Btrace是SUN推出的java追踪监控工具,能定位具体的调用栈,使用Netty和SpringBoot引入外部依赖也可能导致堆外内存泄漏
JNI引发的GC问题
现象:在GC日志中出现GC Cause为GCLocker Initiated GC的情况
在这种本地方法调用期间如果新生代内存不足需要进行YGC,会因为无法进行YGC导致对象直接分配在老年代
如果老年代也没有足够空间会导致线程阻塞等待GC Locker锁释放
JDK还有一个有一定几率触发两次连续GC的BUG,这个Bug在JDK14被修复
原因:JNI需要获取堆中的数据,可以拷贝对象本身,也可以直接共享引用指针;如果本地方法直接使用引用指针,如果此时发生GC就可能导致数据错误,因此发生共享引用指针的JNI调用时会禁止GC的发生并阻止其他线程进入JNI临界区直到最后一个线程退出临界区时会触发一次GC
解决方案:
配置-XX:+PrintJNIGCStalls参数能打印JNI调用时的线程,JNI调用需要谨慎,不一定能提升性能反而可能导致GC问题
具体措施
-Xms和-Xmx即初始堆内存和最大堆内存设置成相同值能避免每次垃圾收集后JVM都重新扩缩容堆内存
使用-Xmn设置年轻代堆内存大小时一般默认设置为整个堆的1/3-1/4
使用-XX:SurvivorRatio设置年轻代中伊甸园区和幸存者区的比例、避免创建大对象,尽可能避免对象直接创建在老年代
启用-XX:+HeapDumpOnOutOfMemoryError当JVM发生OOM时自动生成dump文件
配置-XX:PretenureSizeThreshold指定当新创建的对象超过指定大小时直接将对象分配在老年代
配置-XX:MaxTenuringThreshold指定对象的分代年龄阈值
配置-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log开启GC日志方便分析和定位问题
制定代码规范严格限制显式调用GC,但是也不能一刀切直接禁用显式调用GC,可能导致直接内存的泄露和溢出
字节码文件概述
JVM不和包括Java在内的任何语言绑定,只和满足指定规范格式的字节码文件关联,任何语言只要能编译成正确格式的字节码二进制流就能在JVM上被执行,正因如此JVM也是一款事实上的跨语言平台
字节码文件不包含任何分隔符,字节码文件的结构顺序、字节数量、含义都有严格限制,以此来压缩字节码文件的大小
字节码文件由表或者无符号数构成,无符号数表示特定含义特定长度的二进制码;表的长度不固定,表前面会使用无符号数指定表的长度,表中存放常量池、当前类实现的所有接口、字段、方法、属性及其描述信息;整个字节码文件相当于一个表,其中每个无符号数或者表相当于字节码文件这张大表中的每个元素,每个元素的含义、次序、长度都是限定或者通过无符号数显式指明的,很像通信协议
方法表中的字节码指令由一个字节长度的操作码和其后此操作需要的零个或多个操作数构成,基于栈的指令集以零地址指令为主,很多操作码都不需要操作数,操作码和操作数之间使用空格分隔
可以通过jclasslib插件查看字节码文件的结构和内容;也可以通过NotePad++安装HEX-Editor十六进制编辑插件或者软件Binary Viewer去一个字节一个字节解析字节码文件二进制数据;还可以通过JDK自带的javap解析字节码文件翻译成带结构的文本数据供开发者进行分析
类文件结构
魔数
四个字节cafebabe作为字节码文件的标识,魔数对不上报错ClassFormatError
除了常量池表、字段表、方法表、属性表字节数无法固定,魔数为四个字节,其他结构都是两个字节
类文件版本
两个部分:两个字节副版本,两个字节主版本
主版本.副版本共同构成了字节码文件的版本,JDK每升级一个大版本,主版本从45开始加1
高版本JVM只能解释执行低版本字节码文件,否则报错UnsupportedClassVersionError
常量池计数器
两个字节表示常量池表项的个数
常量池表
常量池存储着字节码中用到的常量、字段名、方法名、方法返回值类型、方法形参等各种字面量和符号引用;加载以后存放在方法区的运行时常量池中,类文件中空间占用最大的数据项
字面量就是字符串或者基本数据类型的常量值,符号引用包含类和接口的全限定名、字段名称和描述字段类型的描述符、方法名和描述方法形参返回值类型的描述符;符号引用通过常量池索引引用其他符号引用或者字面值,最终指向的还是字符串字面值
常量池表由14种常量池表项组成,每个常量池表项由一个字节的标识位和字面值或符号引用两部分组成;每种常量池表项的数量和顺序也不是固定的
字符串字面值的长度不是固定的,标志项后面跟两个字节记录字符串长度
访问标志
两个字节的访问标志由多个标志值相加得到,这些标志值表明类文件是类、接口、枚举类还是注解,类的权限修饰符
Java中一个普通类只能被权限修饰符public或者缺省修饰,但是内部类可以被全部四种权限修饰符修饰
类索引
两个字节指向常量池中对应索引的常量池项表示当前类的全限定类名,实际指向的是符号引用,符号引用最终指向常量池中的多个字符串字面量
父类索引
两个字节指向常量池中对应索引的常量池项表示当前类的父类全限定类名
接口计数器
两个字节表示当前类实现的接口数量
接口索引集合
接口表,每个表项都对应一个接口的符号引用的常数表项索引
字段表计数器
两个字节表示类文件中类变量和实例变量的字段个数
字段表
字段表项包含表示字段修饰关键字的访问标志,字段名、字段描述符共四个字节的常量池表项的索引,两个字节表示属性个数的属性计数器和一个属性表
Java中一个类中的字段不能重名,但是类文件中字段名相同但是描述符不同,字段表仍然会认为这两个字段合法
不同类型的字段比如常量字段都有对应独特的属性表项,每个属性的属性名和属性值都为两个字节的常量池表项索引,属性长度为四个字节的值
方法计数器
两个字节表示类本身定义的方法加<init>和<clinit>方法的数量,不包含从父类或者父接口中继承来的方法
方法表集合
每个方法表项都包含均为两个字节的访问标志、方法名索引、描述符索引、属性计数器和一个若干字节的属性表,访问标志表示方法的修饰符由多个可能的标志值求和得到,描述符索引包含方法的访问权限修饰符、返回值类型和形参数量、顺序、类型信息;在方法表的属性表项中有一个属性名为Code的属性表项保存着方法对应操作数栈最大深度、局部变量表的最大长度、字节码指令长度、非本地方法的字节码指令[每个字节码指令一个字节的操作码和两个字节指向常量池表项的操作数组成,有些字节码只有操作码没有操作数]、异常表以及属性表,属性表中又保存着字节码指令与Java源码行号映射表和局部变量表
属性表计数器
两个字节的属性表计数器表示类的属性表项个数
属性表
属性表存放的是字节码文件携带的辅助信息,比如类文件对应源文件的名称、类上是否带有表明类生命周期的注解等用于JVM验证、运行和程序调试的信息;JDK8一共有23种属性,JVM通过属性名识别,对于不认识的属性会自动忽略
字段表和方法表也有自己的属性表,存储常量字段的属性信息、方法的字节码指令、异常信息、局部变量表等信息
虚拟机:
概念:
模拟计算机执行虚拟计算机指令的软件,分为系统虚拟机[Virtual Box、VMware提供操作系统,对物理计算机仿真]和程序虚拟机[JVM执行Java字节码指令]
物理机的执行引擎直接建立在处理器、缓存、指令集和操作系统层面上,物理机的指令集和物理硬件深度绑定,比如X86架构和ARM架构的指令集千差万别;虚拟机的执行引擎是由软件自行实现,虚拟机可以不受物理条件制约限制指令集与执行引擎的结构体系;虚拟机能在不同硬件平台上执行同一套指令集;但是执行效率相较于物理机略差一些
JVM是对JVM规范的实现,Oracle发布JVM规范,提供HotSpot作为openJDK和oracleJDK的默认虚拟机,不同厂商针对JVM规范有各自的虚拟机实现
JVM特点:
一次编译,处处运行;自动内存管理;自动垃圾回收
JVM是一种跨语言平台,JVM的执行基础是字节码文件,JDK7以后JVM通过JSR292规范实现字节码文件可以由任意语言通过前端编译器编译获得,只要编译后的字节码文件遵循JVM规范就可以被JVM识别、装载和运行;该特点让JVM具备在大型平台上实现多语言混合编程解决各种领域问题的能力
采用基于栈的指令集架构
采用解释器和即时编译器混合工作模式
JVM发展历史
96年随JDK1发布第一款商用JVM-Classic VM,只有解释器没有即时编译器,可以外挂即时编译器但是外挂时无法使用解释器;不能识别内存数据类型,比如判断内存数据是对象本身还是对象引用,在移动对象位置后修改对象引用地址等场景会相对比较麻烦
在JDK1.2发布的Exact VM中已经实现了准确式内存管理即运行时可以准确判定内存数据的数据类型、实现了解释器和即时编译器的混合工作,但是还没有投产就被HotSpot替换了
HotSpot是SUN公司于97年从一家名为Longview Technologies的小公司收购,从JDK1.3开始就一直是JDK的默认虚拟机,在08年Java开源后同时作为openJDK和oracleJDK的默认虚拟机。HotSpot名字指的就是其热点代码探测技术
JRockit VM由BEA公司发布,于08年被Oracle收购,其中的一些优秀特性比如监控内存泄漏的JMC、基于本地内存的元空间都被整合到HotSpot中;JRockit专注服务端应用,专注应用程序的响应时间,内部只有即时编译器没有解释器,大量行业基准测试都显示JRockit是世界上最快的JVM,在延迟敏感型场景下应用广泛
J9 VM由IBM发布,市场定位与HotSpot差不多,都作为服务端、桌面、嵌入式等多用途VM,被广泛用于IBM自家的各种Java产品,在自家产品上测试响应速度世界最快,但是通用性和在其他厂商产品上的性能比不上JRockit,而且在Windows平台上Bug很多;19年IBM将其开源命名为OpenJ9交给Eclipse基金会打理
Oracle在Java ME方向发布过CDC和CLDC两款JVM,诺基亚时代的游戏和应用程序都是使用Java ME产品线开发的,现在移动市场被安卓和IOS二分天下,Java ME几乎失去了移动端市场,只有CLDC因为简单轻量和高度可移植性在智能控制器、传感器和老年机上还在使用
AliJVM团队基于HotSpot深度定制开发了TaobaoJVM,提供基于大数据场景的ZenGC,开发GCIH技术将长生命周期对象移到堆外降低GC压力,实现了通过GCIH移至堆外的对象在多个JVM进程中的共享,淘宝、天猫等阿里产品都使用Taobao JVM,在阿里自家产品上的性能表现很好,但是硬件上严重依赖Intel的CPU,损失了JVM的兼容性
Oracle在18年发布了Graal VM,设计目标是通过将各种语言的源代码通过前端编译器编译成类似字节码的中间语言格式在Graal VM上运行,作为任何任何语言的运行平台,甚至支持不同语言类库的相互调用
逃逸分析技术
概念
逃逸分析是一种分析算法,通过逃逸分析JVM能判断一个新对象的引用使用范围并决定是否将该对象分配在Java堆
栈上分配和标量替换是基于即时编译和逃逸分析技术的编译优化手段,没有发生逃逸的对象可以在虚拟机栈上分配存储。JVM没有实现严格意义上将对象分配在虚拟机栈上,只是通过标量替换将聚合量拆分成标量存储在虚拟机栈中模拟在虚拟机栈分配对象的过程,对象在栈上分配无需占用堆内存也无需进行垃圾回收
对象是否发生逃逸:对象在方法中创建后仅在当前方法内部使用则该对象没有发生逃逸,一旦该对象存在被外部方法引用的可能就认为对象发生了逃逸
一个对象只要作为方法返回值被返回,不管是否被其他方法接收使用都算发生了逃逸
对象逃逸的情况
方法中创建的对象作为方法返回值返回[严格到方法2通过调用方法1获取的对象a即使对象a没有逃出方法2的范围仍然认为对象a发生了逃逸]、赋值给实例变量或者类变量存在被外部方法使用的可能
特点:
JDK6u23后默认开启逃逸分析,此前需要手动配置JVM参数开启,通过启用配置-XX:+PrintEscapeAnalysis可以打印逃逸分析的筛选结果
只有Server模式下才能启用逃逸分析,客户端模式下没有逃逸分析,相应的基于逃逸分析的代码编译优化是C2编译器的功能,Client模式下的默认C1编译器只具备更简单的优化功能,Server模式是64位操作系统下JVM的默认工作模式
逃逸分析本身是一个相对复杂耗时的过程,很难保证逃逸分析的性能开销低于直接在堆上分配对象,而且逃逸分析发现对象确实发生了逃逸,逃逸分析过程本身就成了无效开销,因此逃逸分析技术本身至今也不是特别成熟,只是作为即时编译的重要优化手段;淘宝的GCIH直接将对象分配在本地内存不考虑垃圾回收是当前堆外分配比较成熟的方案,通过加内存就能提升系统性能
因为栈上分配速度快[不用考虑堆分配内存的线程竞争]、不用考虑GC,开发中能使用局部变量就尽量不要使用实例变量或者类变量
编译器基于逃逸分析对代码编译的优化手段
栈上分配:没有发生逃逸的对象会优先分配在栈上,随着栈帧弹栈被回收;不开启逃逸分析在堆上创建一千万个对象耗时77ms,开启逃逸分析在栈上创建一千万个对象只需要4ms而且不会占用堆内存
同步省略:编译同步代码块时,通过逃逸分析发现锁对象只能被单个线程访问,及时编译器会取消同步代码块的同步避免上锁带来的性能损耗,这个过程也叫锁消除,自动去掉没必要加的锁
标量替换:Java中一个对象实例因为可以被分解为标量而被视为聚合量,标量指基本数据类型的实例变量,一个聚合量可以被拆分为若干标量和子聚合量,子聚合量可以继续被拆分为多个孙标量;没有发生逃逸的聚合量可以替换成只在栈空间开辟存储空间的若干标量;通过这种方式实现对象在栈上的内存分配,标量替换也是默认启用配置-XX:+EliminateAllocations开启的,没有属性的实例不能进行标量替换只能存储在堆上
概述
一条字节码指令由一个字节的操作码和其后的零至多个操作数构成,简单的操作数可能隐含在操作码中;因为操作码长度为一个字节,因此操作码的总数不会超过256条,实际上字节码指令总共约200多条,很多字节码都是针对不同类型数据执行相同的操作,一般通过基础数据类型的首字母作为这些指令的区分标志,引用数据类型的操作指令使用字母a作为区分标志
byte、char、short、boolean不被指令支持,在编译期或者运行期将byte和short转换成带符号扩展的int类型即带正负的int类型,将boolean和char转换成零位扩展的int类型数据即正的int类型;对应着局部变量表中的一个Slot槽对应四个字节;处理byte、char、short、boolean数组也会转成int数组来进行处理
字节码指令可以从局部变量表、运行时常量池、堆中的对象、方法调用或者系统调用中获取值数据或者对象引用压栈操作数栈,也可以从操作数栈弹栈一个到多个值完成赋值、运算、方法传参和系统调用等操作
字节码指令分类概述
数据加载与存储指令
load:根据索引将局部变量表压栈到操作数栈
const、push、ldc:将常量从运行时常量池压栈到操作数栈
store:将数据从操作数栈弹栈存储到局部变量表
算术指令
add、sub、mul、div、rem、neg、iinc:均为加、减、乘、除、取余、取反、自增单词的前三个字母
自增指令是直接将局部变量表中的数据自增,不会通过操作数栈辅助;自增指令只会在int类型的局部变量通过i++和i--自增自减才会使用该指令,double类型或者short等类型以至于int类型的成员变量即使写成d++、i++、s++也会先将变量压栈到操作数栈,再压入常量1,使用add指令相加后再存入局部变量表
JVM没有对运算过程中的溢出问题做任何处理,只规定了除0异常
算术指令无法得到明确的结果会返回NaN
位运算指令
包括位移[左移、右移、无符号右移]、位或、位与、位异或指令
比较运算指令
比较栈顶两个数据大小的cmpg/cmpl,根据比较结果将-1、0、1压入操作数栈,比较运算指令一般结合控制转移指令一起使用
类型转换指令
宽化类型转换指令:数据类型从小范围类型转换为大范围类型,这种转换一般自动编译对应源码到字节码指令不需要用户进行强制类型转换操作,整数转成浮点数可能发生精度损失丢失最低有效位上的值,因为相同的字节长度,浮点数一部分表示底数、一部分表示指数,可以表示更大的范围但是可能损失最大有效位数;宽化类型转换指令的格式为两个转换前后基本数据类型标识字母中间加一个2意为谐音to,表示从前者类型转换为后者
窄化类型转换指令:数据类型从大范围类型向小范围类型转换,也叫强制类型转换,强制类型转换一般需要用户在代码中显式调用,很容易出现精度损失问题但是不会抛出运行时异常
对象创建与访问指令
new、newarray、anewarray、multianewarray:创建类实例、创建基本类型数组实例、创建引用类型数组实例、创建多维数组实例
字段访问指令getstatic、putstatic、getfield、putfield:将类变量压入操作数栈、从操作数栈弹出数据赋值给类变量、将实例变量压入操作数栈、将数据从操作数栈弹出赋值给实例变量
数组操作指令astore、aload、arraylength:将数组元素从操作数栈弹栈存储到指定数组的指定索引处、将数组元素从指定数组的指定索引处、根据数组引用获取数组长度并压栈到操作数栈
类型检查指令instanceof判断引用是否是指定类型,checkcast指令将当前类型引用强制转换为指定类型
方法的调用与返回指令
invokevirtual、invokeinterface、invokespecial、invokestatic:分别为调用对象的实例方法,通过接口类型引用调用子实现实例的实例方法,调用实例构造器、私有方法、super调用父类方法等不可以被重写的特殊方法,调用类和接口的静态方法
invokedynamic的用法暂不清楚
return:弹栈操作数栈栈顶元素压入当前方法调用者的操作数栈,丢弃当前栈帧恢复调用者栈帧的执行
操作数栈管理指令
pop、pop2:弹出并丢弃操作数栈栈顶的一个或者两个变量槽
dup、dup2:复制操作数栈数栈栈顶的一个或者两个变量槽并压栈操作数栈
dup_x1、dup_x2:复制操作数栈栈顶的一个变量槽并插入到栈顶两个或者三个变量槽下面
dup2_x1、dup2_x2:复制操作数栈栈顶的两个变量槽并插入到栈顶三个或者四个变量槽下面
swap:交换栈顶两个变量槽
nop:什么都不做,一般用户调试占位
控制转移指令
条件跳转指令ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull:弹出操作数栈栈顶int类型数值与0比较满足某个条件跳转到指定字节码偏移量,否则顺序执行
如果是两个其他数据类型比较大小会先实用比较运算指令将int类型的比较结果-1、0、1压栈操作数栈,再对比较结果使用条件跳转指令跳转到指定的字节码偏移量位置继续执行
如果是两个int类型比较大小会使用比较跳转指令
比较跳转指令if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne:两个int类型数据满足相等、不等、小于、大于、小于等于、大于等于或者引用相等、不等时跳转指定字节码偏移量位置
多条件分支跳转指令tableswitch、lookupswitch:多条件分支跳转指令专为switch-case语句设计,tableswitch用于类似1234这种连续的case值,可以直接根据操作数匹配跳转到相应指令地址;lookupswitch用于离散case值的跳转,将离散的case值与字节码偏移量组队存入case-offset表,前端编译器会将case-offset表根据case值进行排序,每次执行switch-case语句都要搜索case-offset表匹配case值找到并跳转对应字节码指令位置
switch-case语句中的break关键字对应goto指令指无条件跳转到指定字节码,一个case分支没有break关键字会继续执行下一个case分支,不匹配任何case分支会跳转default分支
JDK5引入case值可以是枚举类型,JDK7引入case值可以是字符串类型,case值为字符串时会以字符串的哈希值作为case-offset表的case值,先搜索匹配哈希值相同的case值,然后调用String的equals方法验证两个字符串确实相等
无条件跳转指令goto、goto_w:无条件向前或者向后跳转到指定偏移量处的字节码指令继续向下执行
while和for循环都是通过无条件跳转指令和其他比较运算指令或者条件跳转指令组合实现的
异常处理指令
athrow指令手动抛出异常,对应throw关键字
JVM使用异常表处理可能抛出的运行时异常,只要方法定义了try-catch或者try-finally结构或者通过throw关键字抛出了异常,就会创建异常表保存每个异常捕获范围的起始结束偏移量位置,异常处理catch块对应的字节码偏移量地址,对应异常类在常量池表中的索引,异常表在前端编译期间生成并保存在方法表项的Code属性中
异常表也存在多态,只要异常是异常表中对应异常的子类就可以跳转对应异常处理的偏移量字节码
执行完catch语句块没有finally块直接通过goto指令跳转到return指令结束方法执行并返回,有finally块先跳转执行finally块
异常表中找不到匹配的异常处理,当前方法会强制结束并将异常重新抛给上层调用方法的栈帧,如果所有栈帧弹出前都找不到匹配的异常处理,当前线程将被终止,如果异常在最后一个非守护线程抛出并找不到匹配的异常处理会导致JVM终止
try-catch-finally中的return返回值,如果在执行finally之前遇到了return关键字,比如return在catch或者try语句块中,那么在finally中对之前return返回的变量的修改将不会生效;这是因为这种情况下确实会在返回前先执行finally语句块,但是执行前会先将局部变量表中的返回值复制一份并保存在局部变量表中,finally语句块确实修改了局部变量表中最初的返回值,但是finally块执行结束会直接返回finally块执行前被拷贝的返回值,如果return关键字在fianlly关键字后面,finally块中对返回值的修改仍然会生效
同步控制指令
同步方法编译出来的方法和普通方法是一样的,不会显式使用monitorenter和monitorexit指令进行同步控制,方法调用时JVM通过方法表项的访问标志知道该方法被声明为同步方法,JVM会自动控制在方法调用前获取锁,在方法完成时释放锁
monitorenter指令:进入同步代码块时在局部变量表中保存同步监视器引用并压栈操作数栈,使用monitorenter指令去检查同步监视器的对象头中的锁状态标识,如果符合条件就将增加锁状态计数并在同步监视器中的owner中记录握有锁的线程,不满足条件就进行等待直到符合条件;一旦当前线程握有同步监视器,线程就会进入同步代码块执行临界区代码
monitorexit指令:退出同步代码块时将同步监视器引用压入操作数栈,monitorexit指令将同步监视器的锁状态标识减1释放锁退出同步代码块
同步代码块会自动在异常表添加对任何类型异常的处理,处理方法是调用monitorexit释放锁,然后向操作数栈压入异常对象,通过athrow指令抛出异常并执行return指令结束方法的执行,释放锁出现异常又会通过异常表再次跳转到同一块异常捕获处理字节码指令起始位置再次释放锁并抛出异常
i++和++i的区别区别
单独的i++和++i对应字节码指令是相同的都是iinc 1 by 1将局部变量表指定索引处的变量值加1
a=i++和a=++i则不同,通过字节码指令可以发现,a=i++是先将变量i压入操作数栈,然后局部变量表中的i自加1,再将操作数栈中的变量i的值赋值给局部变量表的a,即a为i自增以前的值;a=++i则是先将局部变量表中的i自加1,然后将变量i的值压入操作数栈,再将操作数栈的变量i的值赋值给局部变量表的a,即a为i自增以后的值
调优目的
避免Full GC,避免发生OOM,发生OOM能及时排查解决OOM问题
上线前的调优目的侧重发现CPU飚高、请求延迟高、TPS偏低、内存泄漏和内存溢出等问题
上线后侧重于对生产环境的监控、记录分析GC日志、堆栈线程快照、堆转储文件快照等
调优指标
响应时间:用户提交请求到用户接收到响应期间的间隔时间,响应时间一般为数据在系统中的流转时间与垃圾收集暂停时间之和
一般关注平均响应时间,前50%、95%、99%请求响应时间
一般打开一个站点的平均响应时间为几秒、查询一条有索引的数据库记录响应时间为十几毫秒、从机械硬盘读取数据几毫秒、从固态硬盘读取数据几百微秒,从本地内存读取1M数据十几微秒,从分布式缓存读取数据几毫秒,局域网延迟在几到十几毫秒
吞吐量:单位时间内响应请求的数量,吞吐量主要受并发数和响应时间的影响
并发数低响应速度即使很快吞吐量也不会高,随着并发数的增加响应速度变慢了但是吞吐量会增加,并发数太高导致响应速度太慢吞吐量反而会下降,当并发数超过系统瓶颈吞吐量变为0
并发数:单位时间内对服务器产生实际交互的请求数,一般为在线人数的5-15%
内存占用:Java堆的实际内存占用
调优的一般方式
性能监控:通过监控GC、CPU占用、内存变化、线程状态、请求响应时间收集系统性能数据及时排查系统可能存在的问题
性能分析:开发或者测试阶段使用GCviewer、GCeasy等日志分析工具分析GC日志、使用JDK自带的命令行工具、GUI工具如Arthas、jconsole、jvisualVM实时监控系统性能,分析堆dump快照、堆栈快照、热点方法、火焰图、方法运行数据、引用链排查问题发生的原因
性能调优:针对具体问题针对性地更改配置、优化业务代码;提升系统吞吐量、缩短响应时间;降低GC频率,避免Full GC;比如合理设置线程池线程数量、使用缓存中间件、消息队列优化具体场景下的业务表现、系统的扩缩容和设置流控策略等
概念
引入JMM的原因:磁盘读取速度太慢,引入内存提高CPU的数据读取速度,相比于CPU的高速缓存,内存数据读取速度还是不够快,CPU运行时先将数据从内存读取到高速缓存,运行时直接从高速缓存读取数据,运算完成再将高速缓存中的数据回写到内存中,CPU三级缓存其中一级和二级缓存是CPU核心私有,三级缓存是所有CPU核心共享的;如果CPU运行时只修改了高速缓存中的数据但是没有来得及修改内存中的数据,其他CPU核心运行其他线程从内存中读取的数据就是脏数据;一个线程修改了共享数据,另一个线程无法获取最新的共享数据就产生了可见性问题;为了提高代码运行速度,CPU和编译器可能会对指令进行重排序导致代码实际的执行顺序和代码的原顺序不一致,指令重排序只能保证单线程串行执行的语义一致性,无法保证多线程并发执行的语义一致性,多线程并发访问共享变量可能会因为指令重排序导致一系列有序性问题;对于不可分割的整体操作,比如读取库存和扣减库存,如果无法保证操作整体的原子性,执行期间可能因为线程上下文切换导致部分对共享数据的更新操作丢失引起超卖现象等问题;操作系统通过内存模型定义了一系列系统规范来解决可见性、有序性和原子性问题,但是不同操作系统的内存模型不同,为了达到跨平台的目的,Java自己提供了一套内存模型JMM去屏蔽不同操作系统之间的差异
JMM定义了一套并发编程相关规范,抽象了缓存、线程、主存之间的关系,JMM将寄存器、高速缓存等线程私有的计算区域抽象为线程的工作内存,线程间共享的计算区域抽象为主内存,每个线程的工作内存都相互独立,线程将主内存的数据读取到工作内存计算完成后再刷新到主内存;当某个线程修改了工作内存中的数据需要和其他线程进行数据共享时需要将工作内存的数据刷新回主内存再让其他线程读取;JMM规定了八种主内存和工作内存交互的原子操作以及做这些操作时必须满足一系列规则
不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回马了但主内存不接受的情況出现
不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
不允许一个线程无原因地[没有发生过任何assign操作]把数据从线程的工作内存同步回主内存中
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化 [load或assign]的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作
一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量
对一个变量执行unlock操作之前,必须先把此变量同步回主内存中[执行store、write操作]
| 操作 | 功能 |
|---|---|
lock | 对主内存变量加锁 |
read | 从主存读取数据到工作内存传输通道 |
load | 将数据赋值给工作内存变量副本 |
use | 工作内存变量传递给执行引擎 |
assign | 执行引擎运算结果赋值给工作内存变量 |
store | 工作内存数据传输到主内存写缓冲区 |
write | 将运算后的数据写入主内存变量 |
unlock | 解锁主内存变量 |
JMM通过内存屏障保证内存交互原子操作的顺序性和可见性,内存屏障是一种CPU指令,JMM定义了四种内存屏障
volatile关键字能保证有序性、可见性、无法保证原子性,有序性就是通过插入内存屏障禁止指令重排序,可见性指一个线程修改了共享数据对另一个线程可见,线程每次获取volatile修饰的变量都能获取到最新值,volatile关键字告诉JVM当前变量在工作内存中的值是不确定的,需要到主内存中去读取,本质上是禁用CPU高速缓存;
| 屏障类型 | 功能 |
|---|---|
LoadLoad | 禁止该屏障后的读操作与该屏障前的读操作重排序 |
StoreStore | 禁止该屏障后的写操作与该屏障前的写操作重排序 |
LoadStore | 禁止该屏障后的写操作与该屏障前的读操作重排序 |
StoreLoad | 禁止该屏障后的读操作与该屏障前的写操作重排序 |
八种内存交互操作在底层操作内存数据,内存屏障规定内存操作的顺序和可见性,但是程序员写代码时无法知道处理器和编译器会怎样重排序优化指令,不知道代码的实际执行顺序,在并发编程中无法明确一个线程执行的代码是否能被另一个线程看见;JMM向程序员提供了八条happens before规则给程序员一个内存可见性保证,程序员不需要关注底层实现通过这些规则就能明确操作Ahappens before操作B,操作A的结果就对操作B可见
程序顺序规则:单线程内的执行顺序必须和代码的书写顺序保持一致;这里的含义是指禁止编译器和处理器对单线程内的操作进行重排序,多线程环境下为了提高性能,编译器和处理器可能会对代码进行重排序,多线程环境下还需要结合其他happens-before原则比如volatile变量规则、监视器锁规则确保线程之间的内存可见性和操作顺序
监视器锁规则:锁竞争时,对一个锁解锁必然发生在随后对这个锁加锁以前;含义是使用sychronized竞争本地锁时,一个线程只能获取上一个线程对共享数据的操作结果而无法拿到上一个线程操作前的共享数据
volatile变量规则:对于一个volatile变量的写操作相对于后续对该volatile变量的读操作可见
传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C,注意这里面的每个happens-before都要满足happens-before原则,这样的操作才具有传递性
线程启动规则:主线程A通过start方法启动子线程B,子线程B能看到主线程启动B前的操作
join规则:如果线程A中调用了threadB.join()并成功返回,那么线程B中的任意操作都happens-before于线程A从threadB.join()操作成功返回
线程终止规则:线程中的所有操作都happens-before于线程终止
对象终结规则:一个对象构造函数的执行结束happens-before于该对象的finalize方法开始执行
线程中断规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted方法检测是否有中断发生
Java在JMM的基础上提供了一套并发工具让开发者能直接使用解决可见性、有序性和原子性三大问题避免并发安全问题,核心的并发工具类有
synchronized直接保证可见性、有序性和原子性
volatile保证可见性和有序性
cas保证原子性,cas衍生出一系列无锁并发工具如Atomic原子类、ReentrantLock、CountdownLatch等等