JVM
2025-09-18 17:14:11 4 举报
AI智能生成
JVM
作者其他创作
大纲/内容
基础
虚拟机运行的是class文件
javac编译器:源码-->class文件
javap 反汇编工具
class文件
u4:cafe babe开头,magic 表示class文件标准格式
u2 + u2 :表示JDK版本
u2 :常量池中常量的数量
cp_info:具体有哪些常量,常量类型,空间
java如何实现平台无关的
是因为Java虚拟机充当了桥梁。他扮演了运行时Java程序与其下的硬件和操作系统之间的缓冲角色。我们可以理解为,Java的平台无关性,正是因为JVM的平台有关性<br>
java是编译型还是解释型
编译
通过编译器(compiler)把高级语言的源代码,直接编译成可以被机器执行的机器码,交由机器执行。如C语言。
解释
通过解释器(interpreter)直接解释执行,不需要编译成机器语言。如JavaScript
是结合了两种执行方式<br>1、javac把java代码编译成字节码,然后由Java虚拟机解释执行。<br>2、Java程序在通过解释器进行解释执行的过程中,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后会通过即时编译(JIT)会把部分“热点代码”直接翻译成机器码,然后由Java虚拟机直接运行。<br>3、除了JIT以外,现在Java中也支持AOT编译了,这就是纯纯的编译成机器码。
JIT & Interpreter 的区别<br>
JIT(JIT Compiler 即时编译器)<br>
当JVM发现某个方法或代码块运行时执行的特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。
触发JIT,热点代码识别方式<br>
基于采样的方式探测(Sample Based Hot Spot Detection)
周期性检测各个线程的栈顶,发现某个方法经常出现在栈顶,就认为是热点方法。好处就是简单,缺点就是无法精确确认一个方法的热度。容易受线程阻塞或别的原因干扰热点探测。
基于计数器的热点探测(Counter Based Hot Spot Detection)
采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。
优点
直接运行,效率更高
适用于热点代码的场景
缺点
增加启动时间:由于JIT编译器在程序运行时编译代码,它可能导致应用程序的启动时间较长。
可能会影响应用性能:JIT编译是需要进行热点代码检测、代码编译等动作的,这些都是要占用运行期的资源,所以,JIT编译过程中也可能会影响应用性能。
分类
C1 和 C2 是默认的编译器
程序运行中编译会抢占CPU资源
JDK9 aot ahead of time
把热点代码在程序运行前编译好
jkd10 graalvm
多语言支持
缩短服务启动时间
内存占用小
JIT优化<br>
逃逸分析
逃逸状态
全局逃逸(GlobalEscape):对象超出了方法或线程的范围,比如被存储在静态字段或作为方法的返回值。<br>
参数逃逸(ArgEscape):对象被作为参数传递或被参数引用,但在方法调用期间不会全局逃逸。<br>
无逃逸(NoEscape):对象在方法内,本身没有作为参数传递,也没有被当做方法返回值,并没有赋值给静态变量。适合标量替换<br>
优化手段
<br>
锁消除
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。<br>如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这个取消同步的过程就叫同步省略,也叫锁消除。<br>
标量替换
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。<br>
栈上分配
JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。
方法内联
方法内联意味着将一个方法的代码直接插入到调用它的地方,从而避免了方法调用的开销。
JIT优化问题<br>
JIT优化是在运行期进行的,是需要先执行一段时间的才能触发,所以,在JIT优化开始之前,我们的所有请求,都是要经过解释执行的,这个过程就会相对慢一些。而且,如果请求量比较大的的话,可能解释器对CPU资源占用比较大,会出现启动超时问题<br>
1、提升JIT优化的效率<br>
阿里研发的JDK——Dragonwell<br>通过记录Java应用上一次运行时候的编译信息到文件中,在下次应用启动时,读取该文件,从而在流量进来之前,提前完成类的加载、初始化和方法编译,从而跳过解释阶段,直接执行编译好的机器码。<br>
2、降低瞬时请求量
流量预热,小流量慢慢加到大流量
AOT<br>
AOT编译,翻译一下就是提前编译,它不像JIT一样在运行期才生成机器码,而是在编译期间就将字节码转换为机器码,这就直接省去了运行时对JVM的依赖。这是一种典型的静态编译技术。
优点
1、机器执行的时候执行的是经过编译优化的本地代码。执行本地代码可以非常的高效和快速,并不需要进行解释执行和JIT编译,就可以直接执行。<br>
2、静态编译后的可执行程序自包含了轻量级运行时支持,所以他在运行时不再需要依赖额外的JVM。<br>
3、解决应用程序的冷启动问题。有了静态编译后的本地代码,应用程序就可以快速地启动,不再解释执行,也不再需要JIT的预热。<br>
4、打破Java程序与本地代码之间的边界。因为编译后的代码也是本地代码,所以JNI调用的开销更低了。
缺点
需要满足封闭性假设
什么是封闭性假设,也就是说他要求所有运行时的内容必须在编译时可见,并且可以被编译到native image中。但是Java中有很多代码是没有办法在编译期就确定的,比如反射,他就是不满足封闭性假设的。<br><br>其他的类似的违反封闭性假设的特性还有动态代理、序列化、JNI、动态类加载等等。所以,遇到这些代码的时候,就需要额外的适配来解决。带来了很多的复杂性,也给静态编译带来了一定的局限性。
平台相关性
Java有一个很重要的特性就是平台无关性,但是随着静态编译的盛行,这个说法已经并不一定就成立了。<br>静态编译以后的代码程序,其实就是平台相关的了。
Interpreter
Interpreter 解释执行器
解释执行
JDK JRE关系
类加载
类加载机制<br>
加载
加载阶段的目的是将类的.class文件加载到JVM中
加载过程
1. 缓存思想,如果该类已经被加载过,则不加载<br>2. 使用双亲委派模型,对该类进行加载<br>3. 如果通过CLASSPATH找不到该类的定义,则会通过findClass让子类自定义的去获取类定义的二进制文件<br>4. 然后通过defineClass将二进制文件加载为类
类加载的过程线程安全吗<br>
是线程安全的,有synchronized锁<br>
双亲委派机制
如果一个类加载器收到了类加载的请求,先检查是否已经加载过这个类,如果有则直接返回,然后它也不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
优势
安全性,保证类库中类不会被自定义的类覆盖
打破双亲委派模型的方式
复写loadClass
SPI 机制
线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打坡了双亲委派模型(例如JDBC)<br>
Tomcat
Tomcat类加载机制
Tomcat的类加载机制,在默认情况下,是先把当前要加载的类委托给BootstrapClassLoader尝试加载,为了避免JRE中的核心类被我们应用自己给覆盖(如String等),Bootstrap如果无法加载,那么就由WebAppClassLoader尝试加载,如果无法加载,那么再委托通过双亲委派的方式向上委派给Common、System等类加载进行加载,即顺序为:Bootstrap->WebApp->System->Common<br><br>上面的是默认情况,tomcat中有一个配置delegate,他的默认值是false,如果设置成true了,那么他就会严格遵守双亲委派,按照Bootstrap->System->Common->WebApp的顺序进行加载。
为什么破坏双亲委派
一个Tomcat,是可以同时运行多个应用的,而不同的应用可能会同时依赖一些相同的类库,但是他们使用的版本可能是不一样的,但是这些类库中的Class的全路径名因为是一样的,如果都采用双亲委派的机制的话,是无法重复加载同一个类的,那么就会导致版本冲突。<br><br>而为了有更好的隔离性,所以在Tomcat中,每个应用都由一个独立的WebappClassLoader进行加载,这样就可以完全隔离开。而多个WebAppClassLoader之间是没有委派关系的,他们就是各自加载各自需要加载的Jar包。因此不同Web应用程序中的类可以使用相同的类名,而不会产生命名冲突。<br>
链接
1. 验证:校验类的正确性(文件格式,元数据,字节码,二进制兼容性),保证类的结构符合JVM规范。
2. 准备:为类变量分配内存并设置类变量的默认初始值,这些变量使用的内存都在方法区中分配。(这里初始化的是类变量,即static字段,实例变量会在对象实例化时随对象一起分配在Java堆中。)
3. 解析:把类的符号引用转为直接引用(类或接口、字段、类方法、接口方法、方法类型、方法句柄和访问控制修饰符7类符号引用 )
初始化
为类或接口的静态字段赋值,初始化阶段是执行类构造器 <clinit> ()方法的过程
符号引用和直接引用
符号引用
一串二进制数字,是一种用来表示引用目标的符号名称,比如类名、字段名、方法名等。符号引用与实际的内存地址无关,只是一个标识符,用于描述被引用的目标,类似于变量名。符号引用是在编译期间产生的,在编译后的class文件中存储。
直接引用
一段地址区间,物理内存。是实际指向目标的内存地址,比如类的实例、方法的字节码等。直接引用与具体的内存地址相关,是在程序运行期间动态生成的。
类什么时候会被加载
1. 当创建类的实例时,如果该类还没有被加载,则会触发类的加载。例如,通过关键字new创建一个类的对象时,JVM会检查该类是否已经加载,如果没有加载,则会调用类加载器进行加载。<br><br>2. 当使用类的静态变量或静态方法时,如果该类还没有被加载,则会触发类的加载。例如,当调用某个类的静态方法时,JVM会检查该类是否已经加载,如果没有加载,则会调用类加载器进行加载。<br><br>3. 当使用反射机制访问类时,如果该类还没有被加载,则会触发类的加载。例如,当使用Class.forName()方法加载某个类时,JVM会检查该类是否已经加载,如果没有加载,则会调用类加载器进行加载。<br><br>4. 当JVM启动时,会自动加载一些基础类,例如java.lang.Object类和java.lang.Class类等。<br><br>总之,Java中的类加载其实是延迟加载的,除了一些基础的类以外,其他的类都是在需要使用类时才会进行加载。同时,Java还支持动态加载类,即在运行时通过程序来加载类,这为Java程序带来了更大的灵活性。
类什么时候会被卸载
1. 该类所有的实例都已被GC回收。<br>2. 该类的ClassLoader已经被GC回收。<br>3. 该类对应的Class对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
如何判断JVM中类和其他类是不是同一个类
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
JVM结构
JVM内存结构
进程生命周期
数据被线程共享,会存在线程安全的问题
方法区
用于存储已被加载的类信息、常量、静态变量、即时编译后的代码等数据的内存区域,方法区的具体实现形式可以有多种,比如堆、永久代、元空间等。
可能会出现OOM
运行时常量池
是方法区的一部分。用于存储编译阶段生成的信息,主要有字面量和符号引用常量两类。其中符号引用常量包括了类的全限定名称、字段的名称和描述符、方法的名称和描述符。
字符串常量池
String常量池1.7之前存在方法区,之后移到堆内存
原因:方法区垃圾回收不频繁,在堆内存容易管理
元空间
元空间的特点是可以根据应用程序的需要动态调整其大小,因此更加灵活。它能够有效地避免了永久代的内存溢出问题,并且可以减少垃圾回收的压力。元空间的内存使用量受限于操作系统对本地内存的限制。
溢出可能的原因
元空间设置的过小
类加载过多(动态代理,Groovy脚本)<br>
类加载器泄漏
为什么废弃永久代,改用元空间
永久代是堆上的一部分,大小收堆大小的限制,容易发生OOM。元空间在本地内存上,不受堆大小的限制,不容易发生OOM。
堆
是存储对象实例的运行时内存区域
可能会出现OOM
堆内存中所有的对象都被所有线程共享么
否。ThreadLocal就不是共享的<br>JVM默认会为每个线程再Eden区分配一个Buffer区域用来加速对象的分配(TLAB Thread Local Allocation Buffer)<br>
分为Old ,Eden S0(From) S1(To)
线程生命周期
线程数据独立,不存在安全问题
虚拟机栈
一种线程私有的存储器,用于存储Java中的局部变量。每次方法调用都会创建一个栈帧,该栈帧用于存储局部变量,操作数栈,动态链接,方法出口等信息。当方法执行完毕之后,这个栈帧就会被弹出,变量作用域就会结束,数据就会从栈中消失。
可能会出现OOM
栈桢
局部变量表
操作数栈
进行算术运算临时的保存结果
动态链接
符号引用转变为直接引用
方法的返回
为了让后续的方法继续执行
程序计数器<br>
一个只读的存储器,用于记录Java虚拟机正在执行的字节码指令的地址。它是线程私有的,为每个线程维护一个独立的程序计数器,用于指示下一条将要被执行的字节码指令的位置。它保证线程执行一个字节码指令以后,才会去执行下一个字节码指令。
本地方法栈
和虚拟机栈作用一致,但存储的是系统方法,这些方法一般是C语言实现的
<br>
堆和栈的区别
1、存储位置不同,堆是在JVM堆内存中分配空间,而栈是在JVM的栈内存中分配空间。<br>2、存储的内容不同,堆中主要存储对象,栈中主要存储本地变量<br>3、堆是线程共享的,栈是线程独享的。<br>4、堆是垃圾回收的主要区域,不再引用这个对象,会被垃圾回收机制自动回收。栈的内存使用是一种先进后出的机制,栈中的变量会在程序执行完毕后自动释放<br>5、栈的大小比堆要小的多,一般是几百到几千字节<br>6、栈的存储速度比堆快,代码执行效率高<br>7、堆上会发生OutofMemoryError,栈上会发生StackOverflowError
Class常量池<br>
是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。
和运行时常量池关系
Class文件中的常量池部分的内容,会在运行期加载到常量池中去。
一个Java进程占用的内存都哪些部分
<br>
堆外内存
堆外内存则是在堆之外的一块持久化的内存空间。这种内存通常由操作系统管理,因此对于大规模数据存储和快速访问来说,使用堆外内存可以提供更好的性能和控制。堆外内存不受Java垃圾回收机制的管理。在不再需要堆外内存时,务必手动释放内存资源
● 元空间(Meta Space):从JDK 1.8开始,HotSpot虚拟机对方法区的实现进行了重大改变。永久代被移除,取而代之的是元空间(Metaspace)。元空间是用来来存储类的元数据信息的。<br>● 压缩类空间(Compressed Class Space):压缩类空间是元空间的一部分,专门用于存储类的元数据,而且在使用64位JVM时,通过使用较小的指针(通常是32位的指针)来引用类的元数据,从而减少了内存的使用量。<br>● 代码缓冲区(Code Cache):主要用于存储编译器编译后的本地机器代码。当Java方法被JVM的即时编译器(JIT编译器)编译成本地代码(Native Code)后,这些代码被存储在代码缓冲区中,以便后续直接执行,提高程序运行效率。<br>● 直接缓冲区(Direct Buffer):直接缓冲区(Direct Buffer)是Java NIO中的一个概念,用于在Java程序和操作系统之间高效地传递数据。与传统的Java IO相比,NIO引入了通道(Channel)和缓冲区(Buffer)的概念,使得数据的读写更加高效。直接缓冲区就是这些缓冲区中的一种,其特点是它在物理内存中分配存储空间,从而减少了数据在Java堆内存和操作系统之间来回复制的需要,提高了数据处理的效率<br>
非JVM内存
本地运行库指的是操作系统中用本地编程语言(如C或C++)编写的库,这些库直接运行在操作系统上,而不是在Java虚拟机(JVM)内部执行。这些库提供了一种方式,允许Java程序执行那些Java本身不直接支持的操作,比如系统级调用、访问特定硬件设备或使用特定于平台的特性和函数。由于这些库是用非Java语言编写的,它们能够提供更接近硬件层面的性能和功能。
JNI(Java Native Interface)是一个编程框架,允许Java代码与本地代码(如C和C++代码)进行交互。它是Java平台的一部分,为Java程序调用本地方法提供了一套标准的接口。通过JNI,Java程序能够使用本地方法来执行那些用Java语言难以或无法直接实现的任务,比如直接访问系统资源、调用操作系统API、使用特定硬件设备或实现性能关键型组件。
JAVA对象结构
对象头(Object Header):
1. 对象头是每个Java对象的固定部分,它包含了用于管理对象的元数据信息。对象头的结构在HotSpot中是根据对象的类型(即是否是数组对象、是否启用偏向锁等)而变化的,但一般情况下,对象头包含以下信息:<br> ○ Mark Word(标记字):用于存储对象的标记信息,包括对象的锁状态、GC标记等。<br> ○ Class Metadata Address(类元数据地址):指向对象所属类的元数据信息,包括类的类型、方法、字段等。<br>
<br>
实例数据(Instance Data):
实例数据是对象的成员变量(字段)的实际存储区域,它包含了对象的各个字段的值。实例数据的大小取决于对象所包含的字段数量和字段类型。
对齐填充(Padding):
对齐填充是为了使得对象的起始地址符合特定的对齐要求,以提高访问效率。由于虚拟机要求对象的起始地址必须是8字节的倍数(在某些平台上要求更大),因此可能需要在对象的实例数据末尾添加额外的字节来对齐。
内存分配
对象分配策略
指针碰撞(Bump the Pointer):如果堆内存是维护为一个连续的空闲内存列表,那么分配内存就简单地移动这个指针到足够大的空间去。这种方式在内存是完全连续的情况下效率很高。年轻代默认使用指针碰撞策略,因为年轻代通常配置为一个连续的内存区域,这样可以快速分配内存。这种方法通常与垃圾回收算法(如复制算法)结合使用,以有效地处理大量短生命周期的对象。<br>
空闲列表(Free List):如果堆内存包含了多个空闲区域,JVM会维护一个列表来记录这些空闲区域的大小和位置。当分配新对象时,JVM会查找这个列表,找到一个足够大的空间为新对象分配内存。老年代默认策略,由于老年代的对象的生命周期较长,内存分配不那么频繁,且更容易发生内存碎片化,它允许JVM更灵活地在不连续的内存块中分配对象空间,减少了内存的浪费。
分配流程
1、通过逃逸分析是否栈内分配
2、是否需要TLAB上分配<br>
3、判断是否大对象
4、放入Eden区<br>
线程安全问题
TLAB:堆的Eden区为每个线程预分配一块私有缓存区域(线程安全)<br>
栈上分配(线程安全)<br>
正常情况:CAS+失败重试<br>
什么是TLAB<br>
TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。<br><br>因为有了TLAB技术,堆内存并不是完完全全的线程共享,其eden区域中还是有一部分空间是分配给线程独享的。注意:只是在“分配”这个动作上是线程独占的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。<br>
缺点
因为TLAB内存区域并不是很大,所以,有可能会经常出现不够的情况<br>处理方式:根据refill_waste的值,这个值可以翻译为“最大浪费空间”。<br>当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。
对象创建过程
1. 类加载检查<br>在对象被创建时,Java虚拟机(JVM)首先检查这个对象所属的类是否已经被加载、链接和初始化。如果类还没有被加载到JVM中,那么系统会先进行类加载过程。这包括读取类的字节码,进行验证,准备以及解析引用到其他类的过程。<br>
2. 分配内存<br>内存在堆上为新对象分配。内存的分配方式可以是指针碰撞(在堆的空闲部分顺序分配)或空闲列表(通过列表查找足够大的未使用区块)。<br>为了处理并发情况,JVM可能采用分区(TLAB,Thread Local Allocation Buffer)技术来为每个线程预分配内存块,从而减少线程间的竞争。<br>
3. 初始化零值<br>分配给对象的内存空间在使用前需要被清零,确保对象的属性没有任何垃圾数据。这一步骤保证了对象的实例变量如果没有显式初始化,那么会被赋予Java语言规范定义的默认值(例如,int是0,boolean是false,对象引用是null)。<br>
4. 设置对象头<br>JVM会在对象的内存中写入数据,包括这个对象是哪个类的实例、对象的哈希码、对象的GC分代年龄等信息。对象头部分是执行instanceof等操作的基础。<br>
5. 执行<init>方法<br>执行构造函数。构造函数方法(在Java字节码中是<init>)会被调用,执行所有的初始化代码和初始化块,以及赋值给对象的实例变量初始值。<br>如果构造方法中有对父类构造方法的调用(隐式或显式调用super()),则父类构造方法也会被执行。这一步骤是递归的,直至达到继承层次中的最顶层的父类。
垃圾回收器
确定垃圾对象
引用计数(不推荐)
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。<br>
可达性分析
通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
GC Root 条件
Class - 由系统类加载器(system class loader)加载的类
Thread - 活着的线程
虚拟机栈中的栈桢的局部变量表中的变量
方法区中的常量或者静态变量
本地方法栈中的变量
被同步锁(synchronized)持有的对象
跨代引用的Remembered Set
缺点
STW时间长<br>
可达性分析算法需要对程序进行全局分析,因此时间复杂度较高,可能需要很长的时间才能完成分析,并且整个过程都是STW的,所以对应用的整体性能有很大影响。三色标记法可以优化
内存消耗
需要存储程序中所有的对象和它们之间的引用关系,这些信息需要占用内存空间
CMS三色标记
过程
将对象分为三种状态:白色、灰色和黑色
白色:该对象没有被标记过。<br>
灰色:该对象已经被标记过了,但该对象的引用对象还没标记完。<br>
黑色:该对象已经被标记过了,并且他的全部引用对象也都标记完了。
标记过程可以分为三个阶段。初始标记(Initial Marking)、并发标记(Concurrent Marking)和重新标记(Remark)。
初始标记:遍历所有的根对象,将根对象和直接引用的对象标记为灰色。在这个阶段中,垃圾回收器只会扫描被直接或者间接引用的对象,而不会扫描整个堆。因此,初始标记阶段的时间比较短。(Stop The World)
并发标记:在这个过程中,垃圾回收器会从灰色对象开始遍历整个对象图,将被引用的对象标记为灰色,并将已经遍历过的对象标记为黑色。并发标记过程中,应用程序线程可能会修改对象图,因此垃圾回收器需要使用写屏障(Write Barrier)技术来保证并发标记的正确性。(不需要STW)<br>
重新标记:重新标记的主要作用是标记在并发标记阶段中被修改的对象以及未被遍历到的对象。这个过程中,垃圾回收器会从灰色对象重新开始遍历对象图,将被引用的对象标记为灰色,并将已经遍历过的对象标记为黑色。(Stop The World)
在重新标记阶段结束之后,垃圾回收器会执行清除操作,将未被标记为可达对象的对象进行回收,从而释放内存空间。这个过程中,垃圾回收器会将所有未被标记的对象标记为白色(White)。
多标问题
这个对象原本应该被回收掉的白色对象,但是被错误的标记成了黑色的存活对象。从而导致这个对象没有被GC回收掉。多标的话,会产生浮动垃圾,这个问题一般都不太需要解决,因为这种垃圾一般都不会太多,另外在下一次GC的时候也都能被回收掉。
漏标问题
一个对象本来应该是黑色存活对象,但是没有被正确的标记上,导致被错误的垃圾回收掉了。
产生的原因
漏标的问题想要发生,需要同时满足两个充要条件:<br><br>1、至少有一个黑色对象在自己被标记之后指向了这个白色对象<br>2、所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用<br><br>那么,增量更新方案就是破坏了第一个条件,而原始快照方案就是破坏了第二个条件。
解决方式
CMS采用的是增量更新方案,G1则采用的是原始快照的方案
增量更新
如果有黑色对象在自己标记后,又重新指向了白色对象。那么我就把这个黑色对象的引用记录下来,在后续「重新标记」阶段再以这个黑色对象为根,对其引用进行重新扫描。通过这种方式,被黑色对象引用的白色对象就会变成灰色,从而变为存活状态。
原始快照
如果灰色对象在扫描完成前删除了对白色对象的引用,那么我们就在灰色对象取消引用之前,先将灰色对象引用的白色对象记录下来。<br>在后续「重新标记」阶段再以这些白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。通过这种方式,原本漏标的对象就会被重新扫描变成灰色,从而变为存活状态。
ZGC 三色标记<br>
过程同CMS<br>
漏标问题
染色指针+读屏障<br>
染色指针是指在对象的内存地址(指针)中,利用未被使用的高位比特(bit)存储 GC 所需的元数据(如标记状态、重定位信息等),而不影响指针对内存地址的正常指向。
染色位存储的核心信息:<br>标记位(Marked):表示对象是否被标记为 “存活”(对应三色标记法中的状态)。<br>重定位位(Relocated):表示对象是否已被移动到新内存地址(用于 GC 的重定位阶段)。<br>其他辅助位:如是否需要执行 finalize () 方法、是否为巨型对象等。
核心机制是通过读屏障监控应用线程对引用的 “读取操作”<br>结合染色指针的标记位判断对象状态,实时修正标记结果<br>
读屏障(Load Barrier):当应用线程读取一个对象引用时(如Object obj = parent.field),ZGC 会插入一段极短的代码(读屏障),执行以下检查:<br>1、解析引用的染色指针,查看其 “标记位”;<br>2、若指针指向的对象是 “白色未标记” 状态(标记位未设置),则立即将其标记为 “灰色”(设置标记位),并加入 GC 的标记队列,确保 GC 线程后续会遍历该对象的引用;<br>3、若对象已标记(灰色或黑色),则不做处理,应用线程正常访问。<br>
染色指针的作用:标记位直接存储在指针中,读屏障可在纳秒级时间内完成状态判断,无需访问对象本身或额外数据结构(如传统 GC 的对象头或记忆集),效率极高。
优点
彻底消除对象头修改,减少缓存颠簸
传统 GC(如 G1、CMS)在标记对象时需修改对象头的标记位,这会导致多核 CPU 的缓存一致性协议(如 MESI)频繁触发(缓存行失效),产生 “缓存颠簸”。而染色指针将标记信息存在指针中,对象内存本身从未被修改,完全避免了这一开销。
支持全并发标记与重定位
并发标记时,通过读屏障和染色指针的标记位,GC 线程与应用线程可无冲突并行,无需长时间停顿;<br>重定位阶段(对象移动)时,染色指针的 “重定位位” 可标记旧地址,应用线程访问旧地址时,读屏障会自动将指针更新为新地址(“自愈” 能力),实现无停顿的对象移动。
简化内存管理,降低延迟
染色指针让 ZGC 无需维护传统 GC 中的 “记忆集(Remembered Set)” 或 “卡表(Card Table)”,减少了数据结构维护成本,使 GC 停顿时间稳定在毫秒级以下。<br>
天然支持大内存场景
染色指针的有效地址位可支持 4TB~16TB 内存(通过调整染色位与地址位的分配),远超传统 GC 的内存上限,适合云计算、大数据等大内存应用。
缺点
依赖 64 位地址空间
32 位系统地址空间仅 4GB,无足够空闲高位比特用于染色,因此 ZGC 仅支持 64 位 JVM。<br>
内存上限受限于地址位数量
染色位占用了部分地址空间,早期 ZGC 支持 4TB 内存,后续扩展到 16TB,虽已满足绝大多数场景,但仍低于 64 位地址理论上限(2^64)。
依赖操作系统对 “地址掩码” 的支持
操作系统需忽略指针的高位染色位(仅解析有效地址位),现代系统(如 Linux 通过mmap的MAP_LOW48标志)均支持,但老旧系统可能存在兼容性问题。
对指针操作的额外开销
读屏障虽轻量(纳秒级),但每次引用读取都会触发,在极端场景下(如高频访问大量引用)可能带来微小的性能损耗(通常可忽略)。
为何无需依赖写屏障
传统 GC(如 CMS)需通过写屏障监控引用的 “修改操作”(如新增引用),但 ZGC 通过读屏障即可覆盖漏标场景:<br>任何被应用线程访问的 “白色对象”,都会在读取时被读屏障标记为灰色,确保其进入 GC 的标记流程;<br>即使引用关系被删除,只要该对象仍被其他引用路径可达(会被其他读操作触发标记),就不会被漏标。
跨代引用
JVM的跨代引用问题是指在Java堆内存的不同代之间存在引用关系,导致对象在不同代之间的引用被称为跨代引用。比如:新生代到老年代的引用,老年代到新生代的引用等。
问题
在进行一次MinorGC(YoungGC)的时候,会从GC Root出发,然后进行可达性分析,假如当前正在进行一次Young GC,如果他发现一个对象处于老年代,那么JVM就会中断这条路径。JVM会把原本有引用在老年代的对象回收掉<br>
解决
全局的数据结构——Remembered Set。主要作用是跟踪老年代对象与年轻代对象之间的引用关系,此后当发生Minor GC时,垃圾回收器不需要扫描整个老年代来确定哪些对象存活。它只需扫描Remembered Set中的条目。<br>
垃圾回收算法
通过可达性分析的算法标记处哪些对象是reachable,反推出可回收对象
标记-清除算法
优点:速度快,因为不需要移动和复制对象<br>缺点:会产生内存碎片,造成内存的浪费<br>
标记-整理算法
优点:1、不会产生内存碎片。2、不会浪费内存空间<br>缺点: 太耗时间(性能低)<br>
标记-复制算法
优点:内存空间是连续的,不会产生内存碎片<br>缺点:1、浪费了一半的内存空间。2、复制对象会造成性能和时间上的消耗
新生代:标记-复制算法<br>老年代:标记-整理算法/标记-清除算法<br>
新生代 老年代比例 2:1,eden s0 s1 比例 8:1:1
新生代只有两个区域可以么
不行,如果只有两个区域,1)两个区域都要存放对象,那就只能选择标记-清除或者标记-整理算法,这样会存在碎片和效率问题 2)如果使用标志-复制算法,那就会有1/2的空间空闲,利用率低<br>
survivor不够怎么办
这时候就需要把对象移动到老年代。但是,老年代也是可能空间不足的。所以,在这个过程中就需要做一次空间分配担保(CMS)
空间分配担保
在每一次执行YoungGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于,那么说明本次Young GC是安全的。如果小于,只要检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则认为担保成功。<br><br>所以,在YoungGC的复制阶段执行之后,会发生以下三种情况:<br>● 剩余的存活对象大小,小于Survivor区,那就直接进入Survivor区。<br>● 剩余的存活对象大小,大于Survivor区,小于老年代可用内存,那就直接去老年代。<br>● 剩余的存活对象大小,大于Survivor并且大于老年代,触发"FullGC"。
什么时候进入老年代
躲过15次GC
为什么是15次:对象头只有4个比特位存储分代年龄,最大值就是15<br>
大对象直接进入老年代
动态年龄判断:如果在Survivor空间中小于等于某个年龄的所有对象大小的总和大于Survivor空间的一半时,那么就把大于等于这个年龄的对象都晋升到老年代<br>
垃圾收集器
垃圾回收器对比
新生代收集器有Serial、ParNew、Parallel Scavenge;<br>老年代收集器有Serial Old、Parallel Old、CMS。<br>整堆收集器有G1、ZGC
垃圾回收器组合使用方式
吞吐量
业务代码线程时间/(业务代码线程时间+垃圾回收代码线程时间)
串行垃圾收集器
流程
Serial GC<br>
Serial是单线程的串行垃圾回收器,主要采用标记-复制算法进行垃圾回收。
Serial Old
Serial的老年代版本,也是个单线程收集器,适用于老年代,使用的是标记-整理算法。
特点
单线程
适用单核CPU,内存小的场景
适用新生代,老年代
GC过程中,必须暂定工作线程<br>STW 1000ms以上<br>
并行垃圾收集器
流程
ParNew
Serial的多线程版本,在参数、回收算法上,和Serial是完全一样的,所以他也是采用标记-复制算法进行垃圾回收的。
Parallel Scavenge
一个新生代的垃圾回收器,和ParNew一样,他也是多线程并行执行的,同样采用的也是标记-复制算法。与ParNew最大的不同是,Parallel Scavenge 关注的是垃圾回收的吞吐量(吞吐量=代码运行时间/(代码运行时间+垃圾收集时间),以吞吐量优先。<br><br>因为Parallel Scavenge收集器的高吞吐量可以最高效率的利用CPU时间,尽快的完成程序的运算任务等,所以他主要适合在后台运算,比如一些定时任务的执行。
Parallel Old
Parallel Scavenge的老年代版本,同样是一个关注吞吐量的并行垃圾收集器,他采用的是标记-整理算法算法进行垃圾回收的。
特点
多线程
适用新生代,老年代
STW 1000ms以上
CMS
过程
初始标记
并发标记
重新标记
并发清理
并发执行的垃圾收集器,他和Parallel最大的区别是他更加关注垃圾回收的停顿时间,通过他的名字Concurrent Mark Sweep就可以知道,他采用的是耗时更短的标记-清除算法。
特点
适用老年代
业务和垃圾回收并发
STW时间短 500ms
标记-清除算法
怎么解决的内存碎片问题
-XX:CMSInitiatingOccupancyFraction=70<br>-XX:+UseCMSInitiatingOccupancyOnly<br>当老年代使用率达到 70% 时,就提前触发 CMS 回收,而不是等到快满才回收。<br>这样可以减少内存紧张和碎片积累的风险。<br>
UseCMSCompactAtFullCollection<br>控制内存是否压缩
没有解决,是缓解内存碎片的影响,最终还是通过G1解决<br>
CMS和G1的区别<br>
<br>
G1
流程
CMS的改进版,解决了CMS内存碎片、更多的内存空间等问题。总之,G1是一个先进的垃圾收集器,业务和垃圾回收并发,它可以提高系统的吞吐量,降低停顿的频率,并且可以有效管理大型堆。
特点
并发回收,吞吐量高
空间整合,解决碎片的问题
整体上看是标记-整理算法
内存重新划分 region,局部上看是标记-复制算法
每一个region标识了是eden survior old
每一个region的角色可以互换
大小固定
有优先级,垃圾对象多的region会先回收
新生态 老年代 默认 2 :1
分代收集:分代概念在G1中依然得以保留
可预测停顿:STW时间短 200ms 并且可设置<br>
这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
支持热插拔:G1可以在运行时动态调整堆的大小,以适应不同的内存需求。
结构
适用场景
大型内存环境
对应用程序响应时间敏感的场景
对内存使用效率敏感的场景
动态内存需求的场景
要求回收时间具有可预测性的场景
ZGC
低停顿:STW 在亚毫秒级,且这个暂停时间不会随着堆、live-set 或 root-set 的大小而增加。<br>
标记-整理算法
高吞吐量:并发垃圾收集器,垃圾收集工作和 Java 线程同时完成,减少了垃圾收集对应用程序响应时间的影响。<br>
兼容性:ZGC与现有的Java应用程序完全兼容,并且无需更改代码即可使用。但是也有一定的限制,仅支持 Linux 64位系统,不支持 32位平台。不支持使用压缩指针,采用内存分区管理。
支持大堆:ZGC 能处理从 8MB 到 16TB 大小的堆,适用于大规模内存需求的应用程序
不分代回收:在G1上新增染色指针+读屏障,是对全量内存进行标记,但是回收时仅针对分内存回收,优先回收垃圾比较多的页面。<br>
垃圾回收时机
young gc触发时机<br>
eden,s区分配满的时候<br>
full gc 触发时机<br>
老年代空间不足
空间分配担保失败
永久代(metaspace)空间不足<br>
代码中执行system.gc(),不保证一定会立即执行<br>
内存泄漏和内存溢出的区别是什么
内存泄漏指的是程序中分配的内存在不再需要时没有被正确释放或回收的情况
内存溢出指的是程序试图分配超过其可用内存的内存空间的情况
一般来说,内存泄漏是会导致内存溢出的,因为内存泄漏会导致部分内存一直无法被回收,久而久之就会没有内存可以分配,就会导致内存溢出。
safe point<br>
安全点,代码执行过程中的一些特殊位置,当线程执行到这个位置的时候,可以被认为处于“安全状态”,如果有需要,可以在这里暂停,在这里暂停是安全的!
哪些操作需要等到安全点
垃圾回收
偏向锁撤销
获取Dump<br>
死锁监测
JIT编译优化<br>
STW(Stop-The-World)<br>
是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起。这是Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互。
为什么需要STW<br>
如果不暂停用户线程,就意味着期间会不断有垃圾产生,永远也清理不干净。
用户线程的运行必然会导致对象的引用关系发生改变,这就会导致两种情况:漏标和多标
多标:其实就是这个对象原本应该被回收掉的垃圾对象,但是被错误的标记成了存活对象。从而导致这个对象没有被GC回收掉。 这种情况还好一点,无非就是产生了一些浮动垃圾,下次GC再清理就好了
漏标:一个对象本来应该是存活对象,但是没有被正确的标记上,导致被错误的垃圾回收掉了。
其他STW的场景<br>
操作系统和硬件升级/维护
数据库维护
消息队列的重平衡
什么是内存溢出,什么是内存泄漏
内存溢出:内存不够用,新对象无法被分配<br>内存泄漏:对象没有被及时回收,一致占用内存<br>内存泄漏会导致内存溢出
CNCF?<br>
常用参数
常用命令
jinfo
jps
显示当前所有java进程pid的命令
jstat
监控JVM中的类加载、GC、线程等信息<br>包括实时内存大小信息
jstack
线程的堆栈信息
jmap
生成JVM中堆内存的Dump文件
jhat
使用jmap可以生成Java堆的Dump文件,生成dump文件之后就可以用jhat命令,将dump文件转成html的形式,然后通过http访问可以查看堆情况。
常用工具
jconsole
jvisualvm
arthas
MAT
heap hero
JVM性能优化
代码
对象=null
设计模式等
非代码
JVM参数配置
OOM
连续分配对象得不到及时的回收
并发场景下,线程访问量大,导致OOM
有些资源没有得到及时的释放
CPU
cpu
top<br>top -Hp PID<br>jstack pid | grep tid(1)<br>
内存
磁盘
网络
JVM调优<br>
发现问题
GC频繁<br>
死锁OOM<br>
线程池满了
CPU负载高<br>
排查问题
打印GC<br>
jstack查看线程堆栈信息<br>
dump堆文件,分析<br>
jdk命令<br>
jsconsole jvisualvm等实时查看jvm状态<br>
jps 列出jvm进程<br>
jinfo 查看jvm配置信息<br>
jstack查看线程堆栈信息<br>
linux命令<br>
top 查看CPU使用率<br>
pidstat 查看进程详细信息<br>
iostat查看磁盘IO<br>
解决方案
GC回收期选择<br>
参数调优
调整堆内存大小,和区域大小比例
设置停顿时间
调整老年代年龄,大对象标准
架构调优
集群,缓存等
0 条评论
下一页