JVM备忘录

1. JVM内存区域划分

JVM(Java Virtual Machine)内存区域主要分为以下几个部分:

  1. 程序计数器(Program Counter Register):是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,用来记录正在执行的虚拟机字节码指令的地址。

  2. Java虚拟机栈(Java Virtual Machine Stacks):每个线程在执行Java程序时都会创建一个对应的Java虚拟机栈,用于存储局部变量、操作数栈、动态链接、方法出口等信息。Java虚拟机栈可以细分为Java栈(Java Stack)和本地方法栈(Native Method Stack)。

  3. 堆(Heap):是Java虚拟机管理的最大一块内存区域,被所有线程共享,用于存储对象实例和数组。堆是垃圾收集器的主要作用区域,可以细分为新生代和老年代等不同区域。

  4. 元空间(Metaspace):用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据。

  5. 直接内存(Direct Memory):在Java堆外直接分配内存,由Java NIO引入,通过一种名为“内存映射文件”的机制,可以在堆外分配内存,提高数据读写效率。

1.1 元空间

元空间存储了运行时常量池和类常量池。在Java 8及其之前的版本中,JVM的方法区(Permanent Generation)主要用于存储类的元数据信息,例如类名、访问修饰符、字段、方法、接口等信息,这些信息对应着Java语言规范中的描述。而在Java 8之后,方法区被彻底移除,而元数据信息则被移到了一个新的内存区域——元空间(Metaspace)中。

元空间主要存储如下内容:

  1. 类信息:类的全限定名、访问标志、直接超类、直接接口等信息。
  2. 字段信息:字段的名称、类型、修饰符等信息。
  3. 方法信息:方法的名称、返回值、参数、修饰符等信息。
  4. 常量池信息:常量池中的各种字面量和符号引用。
  5. 注解信息:注解的名称、值、目标等信息。
  6. 其他类型信息:如枚举、内部类等。

与方法区相比,元空间的内存管理方式有所不同。元空间中的类元数据信息是使用本地内存来存储的,而不是使用虚拟机的堆内存,因此可以更加灵活地分配和回收内存,而不受堆内存大小的限制。另外,元空间还提供了一些额外的功能,如支持动态地调整元数据空间大小,以及可以使用外部存储来保存元数据信息等。

1.1.1 运行时常量池

在Java中,每个类都有一个运行时常量池(Runtime Constant Pool),它是一种特殊的数据结构,用于存储常量,例如字符串常量、类和接口的符号引用、字段和方法的符号引用等。

具体来说,运行时常量池包含以下信息:

  • 字面量(Literal):常量池中的字符串常量和基本类型常量(例如 int、float、long、double 等)。
  • 符号引用(Symbolic References):运行时常量池中存储了对类、接口、字段和方法的符号引用,这些符号引用指向了类和方法的名称、描述符、类型等信息。
  • 类和接口的信息:运行时常量池中存储了每个类和接口的符号引用和其他元数据信息,例如类和接口的访问标志、父类、接口列表、字段信息、方法信息等。
  • 方法句柄(Method Handle):运行时常量池中存储了方法句柄,它是一种用于动态调用方法的引用类型。
  • 动态生成的代理类(Dynamic Proxy Classes):如果一个类被定义为动态代理类,它的类文件中将包含一个特殊的常量池项,用于存储代理类的相关信息。

虽然运行时常量池是每个类的一部分,但它并不是类加载器的一部分,而是在类加载后由虚拟机创建和维护的。在内存中,运行时常量池通常是以一个Java对象的形式存在,每个类都有一个对应的运行时常量池对象。

1.1.2 类常量池:

Java中的类常量池(Class Constant Pool)是每个类文件中的一个数据结构,用于存储常量、符号引用和其他元数据信息。与运行时常量池不同,类常量池是每个类文件的一部分,由编译器在编译期间生成并打包进类文件中。

具体来说,类常量池包含以下信息:

  • 字面量(Literal):类常量池中存储了所有字面量,包括字符串常量、数字常量、布尔常量等。
  • 符号引用(Symbolic References):类常量池中存储了对类、接口、字段和方法的符号引用,这些符号引用指向了类和方法的名称、描述符、类型等信息。
  • 类和接口的信息:类常量池中存储了每个类和接口的符号引用和其他元数据信息,例如类和接口的访问标志、父类、接口列表、字段信息、方法信息等。
  • 运行时注解信息:类常量池中存储了与类、字段、方法、参数等相关联的运行时注解信息。

类常量池与运行时常量池不同,它是每个类文件的一部分,由编译器在编译期间生成并打包进类文件中,而运行时常量池则是在类加载后由虚拟机创建和维护的。同时,类常量池中的符号引用需要在类加载时进行解析,并转换为实际的直接引用,而运行时常量池中的符号引用则可以在运行时动态解析。

1.2 JVM虚拟机栈

  • 操作数栈:
JVM虚拟机栈中的操作数栈是一种后进先出(LIFO)的数据结构,用于存储方法执行时的操作数(数值或对象引用)。当方法被调用时,虚拟机会为该方法创建一个新的栈帧,并将该栈帧压入虚拟机栈顶。栈帧包含了方法的局部变量表、操作数栈、方法返回地址等信息。
在执行方法时,所有的操作数都被存储在该方法的操作数栈中。当方法执行过程中需要进行一些计算操作时,比如加减乘除、取余等,虚拟机会将操作数从操作数栈中弹出,进行相应的计算操作,并将计算结果再次压入操作数栈中。
操作数栈的大小在编译时就已经确定,其最大容量也是固定的。当操作数栈的空间不足时,虚拟机会抛出StackOverflowError异常。因此,在编写Java程序时需要特别注意方法中操作数栈的大小,避免出现栈溢出的情况。
在方法执行完毕后,虚拟机会将该方法的栈帧弹出虚拟机栈,并将执行结果返回给调用方。如果方法是非void类型的,那么执行结果将被压入调用方的操作数栈中。
  • 动态链接:
JVM(Java Virtual Machine)虚拟机栈中的动态链接(Dynamic Linking)是指在方法调用过程中,虚拟机通过符号引用来动态确定方法的直接引用地址。动态链接在Java程序的执行过程中起到了非常重要的作用。
在Java程序中,方法调用是通过符号引用(Symbolic Reference)来实现的。符号引用是一种用来描述方法、变量等符号信息的数据类型,它包括符号名称和符号所在的类、方法等信息。在Java程序编译时,所有的符号引用都被保存在类的常量池中。但是,符号引用并不包含方法的直接引用地址,因此在方法调用时需要进行动态链接来确定方法的直接引用地址。
在执行方法调用时,虚拟机会从方法的符号引用中获取到方法所在的类,并通过该类的方法表找到对应的方法。如果该方法在类中是静态方法或者非私有实例方法,那么它的直接引用地址就可以直接确定。但是,如果该方法是私有实例方法或者虚方法,那么它的直接引用地址需要在方法调用时进行动态链接来确定。
动态链接的实现方式是通过虚方法表(Virtual Method Table)来实现的。虚方法表是一种数据结构,用于存储类中所有的虚方法信息。每个类都有一个虚方法表,其中存储了该类中所有的虚方法信息,包括方法名称、参数类型、返回类型等信息。在动态链接的过程中,虚拟机会根据对象的实际类型,在虚方法表中查找到对应的方法信息,并确定方法的直接引用地址。

2. 垃圾收集器

垃圾收集器主要的算法有:标记清除,标记复制,标记整理等。

2.1 G1收集器

G1(Garbage First)收集器是Java虚拟机提供的一种新型垃圾收集器,它的堆空间是由多个大小相等的Region(区域)组成的。与传统的垃圾收集器不同,G1收集器不是将堆空间划分为年轻代和老年代,而是将整个堆空间划分为多个大小相等的Region,每个Region可以是年轻代或老年代,也可以是空闲的。

G1收集器采用的是分代收集的思想,但是它的实现与传统的分代收集器有所不同。G1将堆空间划分为多个Region,每个Region可以是年轻代或老年代,这些Region之间是没有固定的边界的,而是通过指针相连。在G1收集器中,为了避免Full GC的出现,G1会根据堆中各个Region的垃圾情况,动态地决定哪些Region需要进行垃圾回收,将这些Region标记为收集集合(Collection Set),然后对它们进行垃圾回收。这个过程称为Mixed GC,因为它同时包括了年轻代和老年代的垃圾回收。

G1收集器的堆空间划分如下:

  • Eden区:新生对象的分配区域,大小一般为整个堆空间的1/4到1/8。当Eden区无法容纳新生对象时,会触发一次Minor GC。
  • Survivor区:每个Survivor区一般占整个堆空间的1/32到1/16。当Eden区发生Minor GC时,存活的对象会被移动到其中一个Survivor区,并在两个Survivor区之间进行复制。在多次Minor GC后,仍然存活的对象会被移动到老年代。
  • Old区:用于存放长生命周期的对象。老年代的大小通常是整个堆空间的一半到三分之二。当老年代空间不足时,会触发一次Major GC(Full GC)。
  • Humongous区:用于存放大对象(大于等于一个Region的一半)。如果一个对象无法在Eden区或Survivor区中分配,就会被直接分配到Humongous区。

在G1收集器中,每次垃圾回收都会选择一些Region作为收集集合,这些Region会被标记为可回收的,然后通过多线程并发地进行垃圾回收。G1收集器的目标是在可接受的停顿时间内,尽可能地回收垃圾,并保证应用程序的吞吐量。

2.2 可达性分析

目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(Gc Root Set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

2.3 GC Root

在Java语言中,被视为垃圾的对象必须满足两个条件中的至少一个,即对象不可达或对象无法继续使用。而GC Root(垃圾回收根节点)就是一组被定义为不可回收的对象,它们直接或间接引用了所有的活动对象,并形成了整个对象图的根。以下是一些常见的GC Root类型:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 静态变量中引用的对象
  • 常量引用(如字符串常量池)中的对象
  • JNI 引用中的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

对于以上这些对象,它们都被认为是不可回收的,并且所有活动的对象都可以通过它们直接或间接引用到。因此,在垃圾回收时,垃圾收集器会首先从这些根节点开始遍历对象图,标记所有与之相关的对象,并将其保留下来,未标记的对象则被判定为垃圾对象,并被回收。

GC Root只是垃圾回收的一个概念,不会实际存在于内存中,而是由垃圾收集器来维护。垃圾收集器会根据不同的GC算法和具体的实现方式来选择和维护GC Root,并在垃圾回收过程中对其进行遍历和处理。

3. 类加载

  1. 加载过程

Java类加载是指将Java类的字节码文件加载到内存中,并经过验证、准备、解析、初始化等过程,最终将该类的Class对象作为Java程序访问和使用该类的入口。

Java类的加载过程可以分为以下三个步骤:

  • 加载(Loading):将.class文件中的字节码读入内存,并创建出对应的Class对象,作为该类的唯一入口。类加载器(ClassLoader)负责将字节码文件加载到内存中,它是Java虚拟机的重要组成部分。
  • 链接(Linking):将类的二进制数据合并到JVM运行时状态中。主要包括验证、准备和解析三个阶段。
    1. 验证(Verification):确保被加载的类符合Java语言规范和虚拟机规范,防止恶意代码的攻击。主要包括文件格式验证、元数据验证、字节码验证和符号引用验证等过程。
    1. 准备(Preparation):为类的静态变量分配内存,并初始化默认值。这个阶段会为类的所有静态变量分配内存,并设置默认值,即0或null。
    1. 解析(Resolution):将符号引用解析为直接引用。这个阶段会将类中的符号引用,如方法、字段等,替换为直接引用,即指向内存地址的指针。
  • 初始化(Initialization):执行类的初始化代码,包括静态变量的赋值和静态代码块的执行。在Java虚拟机中,类的初始化是线程安全的,并且只会执行一次。
  1. 符号引用

在Java程序运行时,所有的类、接口、方法、字段等信息都被存储在Class文件中,这些信息被称为符号(Symbol)。符号引用(Symbolic Reference)就是指向这些符号的引用,它包含了被引用的符号名称以及对符号的描述。

符号引用是一种用来描述引用其他类、接口、字段、方法等的方式,它并不直接指向具体的内存地址,而是通过类似字符串的方式,记录了被引用符号的名称和描述信息。在Java程序执行过程中,虚拟机需要将符号引用转换为实际的内存地址,这个过程称为符号解析。

例如,在Java程序中使用一个类的某个方法,会生成对该方法的符号引用,包括方法名称、参数类型等信息。在程序运行时,虚拟机需要根据这个符号引用,找到该方法在内存中的实际地址,才能够执行这个方法。

符号引用的作用是将程序中的引用与实际的内存地址分离开来,使得Java程序可以具有跨平台的特性,同时也方便了Java程序的动态加载和卸载。

  1. Class文件

Java Class文件的结构可以分为三个主要部分:文件头、常量池和类信息。

  • 文件头

Java Class文件的头部固定为16个字节,包含了一些基本的信息,如文件的魔数(cafebabe)、版本号等。

  • 常量池

Java Class文件的常量池(Constant Pool)是Java语言中的常量池表现形式,是Class文件中最大的部分。常量池是一个表格,里面存放着编译时生成的各种字面量和符号引用,如类名、方法名、字段名、字符串常量、接口方法句柄等等。

常量池的主要作用是为Java代码提供了一种基础数据类型的表达方式,同时也为类的加载和解析提供了必要的信息。在运行时,JVM会将常量池中的符号引用转换为实际的内存地址,从而实现程序的执行。

  • 类信息

类信息部分包含了类的访问标志、类名、父类名、接口列表、字段表、方法表、属性表等信息,它们描述了类的各种特征和行为。其中,字段表和方法表分别描述了类中的字段和方法,包含了访问标志、名称、描述符等信息。

在Java Class文件中,各个部分的顺序是固定的,同时也是紧密相连的。当JVM加载一个Class文件时,它会按照文件结构解析文件,并将解析出的信息保存在内存中,供程序执行时使用。

  1. 类加载器

主要有四种类加载器:

  • 启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用。
  • 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  • 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。
  • 用户自定义类加载器 (user class loader),用户通过继承 java.lang.ClassLoader类的方式自行实现的类加载器。
  1. 双亲委派机制

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

4. 性能监控工具

  1. jstat

jstat是一个强大的工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。

  1. jmap

jmap命令用于生成堆转储快照(一般称为heapdump或dump文件)。

jmap的作用并不仅仅是为了获取堆转储快照,它还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。

  1. jhat

JDK提供jhat命令与jmap搭配使用,来分析jmap生成的堆转储快照。 jhat内置了一个微型的HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看。

  1. jstack

jstack命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者 javacore文件)。

  1. JConsole

JConsole( Java Monitoring and Management Console),是一款基于 JMX( Java Manage-ment Extensions) 的可视化监视管理工具。
它的功能主要是对系统进行收集和参数调整,不仅可以用在虚拟机本身的管理上,还可以用于运行于虚拟机之上的软件中。

使用JConsole连接了一个本地程序,在概述可以看到Java程序运行的概览信息,包括堆内存使用情况、线程、类、CPU使用情况四项信息的曲线图。

内存的作用相当于可视化的jstat命令,用于监视被收集器管理的虚拟机内存。
它不仅包含堆内存的整体信息,更细化到eden区、suvivior区、老年代的使用情况。

JConcole还可以监控线程,相当于可视化的jstack命令,JConcole显示了系统内的线程数量,并在屏幕下方显示了程序中所有的线程。单击线程名称,就可以查看线程的栈信息。

其他信息还包含如类加载情况,虚拟机信息等。

5. JVM调优

一般情况下,JVM调优可通过以下步骤进行:

  • 分析系统系统运行情况:分析GC日志及dump文件,判断是否需要优化,确定瓶颈问题点;
  • 确定JVM调优量化目标;
  • 确定JVM调优参数(根据历史JVM参数来调整);
  • 依次确定调优内存、延迟、吞吐量等指标;
  • 对比观察调优前后的差异;
  • 不断的分析和调整,直到找到合适的JVM参数配置;
  • 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪。
  1. 调整内存大小

现象:垃圾收集频率非常频繁。

原因:如果内存太小,就会导致频繁的需要进行垃圾收集才能释放出足够的空间来创建新的对象,所以增加堆内存大小的效果是非常显而易见的。

注意:如果垃圾收集次数非常频繁,但是每次能回收的对象非常少,那么这个时候并非内存太小,而可能是内存泄露导致对象无法回收,从而造成频繁GC。

参数配置:

 //设置堆初始值
 指令1:-Xms2g
 指令2:-XX:InitialHeapSize=2048m
 ​
 //设置堆区最大值
 指令1:`-Xmx2g` 
 指令2: -XX:MaxHeapSize=2048m
 ​
 //新生代内存配置
 指令1:-Xmn512m
 指令2:-XX:MaxNewSize=512m
  1. 设置符合预期的停顿时间

现象:程序间接性的卡顿

原因:如果没有确切的停顿时间设定,垃圾收集器以吞吐量为主,那么垃圾收集时间就会不稳定。

注意:不要设置不切实际的停顿时间,单次时间越短也意味着需要更多的GC次数才能回收完原有数量的垃圾.

参数配置:

 //GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间
 -XX:MaxGCPauseMillis 
  1. 调整内存区域大小比率

现象:某一个区域的GC频繁,其他都正常。

原因:如果对应区域空间不足,导致需要频繁GC来释放空间,在JVM堆内存无法增加的情况下,可以调整对应区域的大小比率。

注意:也许并非空间不足,而是因为内存泄造成内存无法回收。从而导致GC频繁。

参数配置:

 //survivor区和Eden区大小比率
 指令:-XX:SurvivorRatio=6  //S区和Eden区占新生代比率为1:6,两个S区2:6
 ​
 //新生代和老年代的占比
 -XX:NewRatio=4  //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2
  1. 调整对象升老年代的年龄

现象:老年代频繁GC,每次回收的对象很多。

原因:如果升代年龄小,新生代的对象很快就进入老年代了,导致老年代对象变多,而这些对象其实在随后的很短时间内就可以回收,这时候可以调整对象的升级代年龄,让对象不那么容易进入老年代解决老年代空间不足频繁GC问题。

注意:增加了年龄之后,这些对象在新生代的时间会变长可能导致新生代的GC频率增加,并且频繁复制这些对象新生的GC时间也可能变长。

配置参数:

//进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,默认值7
 -XX:InitialTenuringThreshol=7 
  1. 调整大对象的标准

现象:老年代频繁GC,每次回收的对象很多,而且单个对象的体积都比较大。

原因:如果大量的大对象直接分配到老年代,导致老年代容易被填满而造成频繁GC,可设置对象直接进入老年代的标准。

注意:这些大对象进入新生代后可能会使新生代的GC频率和时间增加。

配置参数:

 //新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。
  -XX:PretenureSizeThreshold=1000000 
  1. 调整GC的触发时机

现象:CMS,G1 经常 Full GC,程序卡顿严重。

原因:G1和CMS 部分GC阶段是并发进行的,业务线程和垃圾收集线程一起工作,也就说明垃圾收集的过程中业务线程会生成新的对象,所以在GC的时候需要预留一部分内存空间来容纳新产生的对象,如果这个时候内存空间不足以容纳新产生的对象,那么JVM就会停止并发收集暂停所有业务线程(STW)来保证垃圾收集的正常运行。这个时候可以调整GC触发的时机(比如在老年代占用60%就触发GC),这样就可以预留足够的空间来让业务线程创建的对象有足够的空间分配。

注意:提早触发GC会增加老年代GC的频率。

配置参数:

 //使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小
 -XX:CMSInitiatingOccupancyFraction
 ​
 //G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%
 -XX:G1MixedGCLiveThresholdPercent=65 

参考:

  • JVM进阶-JVM调优总结

你可能感兴趣的:(jvm,java)