uRick's PKM uRick's PKM
首页
导航
  • Java
  • 数据库
书单
  • 优质好文
  • 有趣的工具
  • 分类
  • 标签
  • 归档
关于
首页
导航
  • Java
  • 数据库
书单
  • 优质好文
  • 有趣的工具
  • 分类
  • 标签
  • 归档
关于
  • 一文搞懂JVM知识
    • 1. 前言
    • 2. 运行时数据区
    • 3. 堆内存分代
    • 4. 揭开对象神秘面纱
    • 5. 类装载机制
      • 5.1. 类加载器
      • 5.2. 类加载的流程
    • 6. GC算法与收集器
      • 6.1. GC算法
      • 6.2. GC收集器
    • 7. 常用工具
    • 8. 总述
    • 9. 参考
    • 10. 附录
  • 多线程基础
  • JUC☞Thread Pool
  • JUC☞Locker
  • JUC☞Queue
  • NIO浅谈
  • 有趣的二进制
  • 深入理解Lambda
  • Java8新特性
  • 单实例多种实现与解析
  • java
uRick
2020-10-05
目录

一文搞懂JVM知识

# 1. 前言

JVM是Java开发人员必知必会的知识,领会JVM基础知识,对开发能力、技术精进都会有很大的帮助;正因为有这么强大的JVM基建,才促使当今Java生态的蓬勃发展。

jvm-structure

# 2. 运行时数据区

JVM-Runtime-Area

  • ClassLoader:类加载器是JVM加载Class二进制文件的入口,它的职责就是负责查找和导入class文件。其实就是根据完全限定名获取类的二进制流,并将二进制流表示的静态结构转换为 Runtime Data Areas 中的数据结构,在方法区中会生成一个相关类的Class(java.lang.Class)对象,作为访问方法区中动态数据的一个入口,关于JVM类加机制后续介绍。

  • Method Area 方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。 方法区中还有一个块区域运行时常量池,它主要用于存放编译期生成的各种字面量与符号引用(每个Class文件都有字面量和符号应用),会在类加载后存放到该区域。 在Java8以后,该区域已被元空间(Metadata Space)代替,它不在基于虚拟机划分内存空间,而是直接使用物理机内存,它的大小受限于物理机。

  • Heap 堆是JVM内存管理中最大的一块区域,也是很重的一块区域,它存在的意义就是存储创建的对象,堆也是是一块线程共享的一块区域,当JVM启动时它就被创建,大部分对象创建分配内存都在该区域实现。它的大小是可以(通过参数-Xmx、-Xms)调整的,通常为了避免JVM自动调整大小,导致一定的内存开销,把俩个参数值设为相同大小。 由于Heap内存回收处理基于分代收集算法理论实现,它又可以为Eden、From Survivor、To Surivivor和Old 区,后续介绍在这几个区域中的相关回收算法。

  • VM Stack 虚拟机栈是线程私有的,每个方法被执行的时候,Java虚拟机都会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等相关信息。每一个方法被调用到执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 其中栈帧(Stack Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接、方法返回值和异常分派的。栈帧随着方法调用而创建,随着方法结束而销毁;无论方法是正常完成还是异常完成都算作方法结束。

  • Program Counter Register 程序计数器占用空间较小,它是线程执行字节码行号指示器;我们都知道多线程执行是通过获取时间片来方式实现的,一个处理器只能执行一个线程的指令,那么多个线程执行肯定需要来回切换恢复线程原来执行的位置,其实计数器就能够解决这个问题,保证线程准确无误的恢复执行。每一个线程都有一个计数器,线程间是相互独立的。

  • Native Method Stack 本地方法栈的功能与虚拟机栈的是一样的,只不过本地方法栈是为本地(Native)方法执行服务而已。

  • Execution Engine 执行引擎是一种用于测试硬件、软件或整个系统的软件的测试执行引擎。

  • Native Method Interface 本地方法接口是一个编程框架,它允许运行在JVM中的Java代码被库和本机应用程序调用。

  • Native Method Lib 本地方法库是执行引擎所需要的相关(C, C++)类库。

# 3. 堆内存分代

堆从GC的角度还可以细分为:新生代(Eden区、From Survivor 区和 To Survivor区)和老年代,下面介绍堆内各个分区的特点以及存在的意义。

Heap

  • Eden Area: 新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。当 Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收;
  • From Survivor: 上一次GC的幸存者,作为这一次GC的被扫描者;
  • To Survivor: 保留了一次 MinorGC 过程中的幸存者;
  • Old Area: 存放应用程序中生命周期长的内存对象,在老年代上的GC操作称为Major GC。
  • Metaspace: Java8之后,永久代已经被移除,被一个称为“元数据区”(Metaspace)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入Native Memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。

在年轻代由Eden和两个Survivor组成。很奇妙的是JVM为什么这里要这样划分,而不像老年代一样使用一个区域呢?

其实很容易看出,年轻代通过Survivor和Eden来减轻老年代的负担,因为老年代存储的都是大对象,GC回收耗时较长,对应用的性能影响较大。Survivor的目的就是减少被送到老年代的对象,进而减少Full GC(老年代在触发Major GC 一般也会伴随Minor GC)的发生,经过Survivor缓冲预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象(并不是所有对象都是经历16次Monir GC才会进入老年代,当对象一些大对象分配内存时也会进入老年代),才会被送到老年代。

还有一个原因,年轻代的对象多数都是“朝生夕灭”的短命对象,GC比较频繁,通过Survivor来保证内存的延续性,避免大量的内存碎片产生,便于后续内存分配;在年轻代大多使用“标记-复制”算法收集器回收内存,使用Survivor缓冲,当对象达到一定年龄后进入老年代,从而降低老年代GC成本。默认Eden:From Survivor:To Survivor比例为8:1:1,Young:Old的比例为1:2。

# 4. 揭开对象神秘面纱

在虚拟机中,当遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。若类已加载完成,接下来开始为对象分配内存,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

Java对象在堆内存中的主要由3部分组成:Header(对象头)、Instance Data(实例数据)、Padding(对填充)。

对象内存布局

  • 对象头:对象头中主要包括两部分数据(Mark Word),一部分存储对象自身运行时的数据,另一部分类型指针;其中MarkWord占用8字节,主要包括哈希码、GC分代年龄、线程持有的锁等等;类型指针主要用于标识对象所属哪一个类的实例,占8字节;当然还有一块记录数据长度的区域(当对象是数组时),占8字节;
  • 实例数据:存储对象所有的成员变量信息,在程序代码里面所定义的各种类型的字段内容;
  • 对齐填充:保证对象大小必须是8字节的倍数,当对象大小不足8字节的整数倍是,通过它来对齐填充。

一个对象如何创建?

  1. new 一个类;
  2. 根据new的一个参数在常量池中定位一个类的符号引用;
  3. 若没有找到这个符号引用,说明类还没有被加载,则进行类的加载、解析和初始化;
  4. 虚拟机为对象分配内存(堆);
  5. 将分配的内存初始化为零值(不包括对象头);
  6. 调用对象的init方法(构造函数、代码块等)。

# 5. 类装载机制

# 5.1. 类加载器

一个类需要在JVM中生效使用,它需要放入JVM,那怎么把一个类放入虚拟机中呢?肯定需要一个入口,这个入口就是“类加载器”,类加载在JVM中起着举足轻重的作用;任意一个类在虚拟机中的唯一性,需要累加器和类自身才能保证,每一个类加载器,都拥自己的一个独立的类名称空间;常见类对象之间的比较都是建立在一个加载器的前提下进行比较的,若脱离来加载器,那么比较便没有了意义。

在Java9以前类加载器分为:启动类加载器、拓展类加载器、应用类加载器,Java9之后拓展加载器被替换为平台类加载器,并且在Java9支持模块下,类加载方式发生了显著变化。

Class Loader

  • Bootstrap Class Loader: 负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar、tools.jar)的类;

  • Extension Class Loader: 它是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的,负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库;

  • Application Class Loader: 它是由sun.misc.Launcher$AppClassLoader来实现,负责加载用户路径(Classpath)上的类库,JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承java.lang.ClassLoader实现自定义的类加载器;

  • Platform Class Loader: Java9后替代了Extension Class Loader,把原来拓展加载器加载的类库,拆解为很多JMOD文件,基于JMOD方式实现加载。

在Java9以前类的加载遵守双亲委派加载模型:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

在Java9后,在遵守双亲委派模型的基础上发生了变化,每一种类加载器都有自己对应的加载模块:当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。

# 5.2. 类加载的流程

一个类在JVM需要经历一下几个过程,其实就是类在JVM中生命周期阶段,每一个阶段处理层层递进,前呼后应为下一个阶段处理做准备。

类加载生命周期

  • Loading: 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个 Class 文件获取,这里既可以从 ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),还可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类);

  • Verification: 确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全;包括文件格式验证、元数据验证、字节码验证、符号引用验证;

  • Perparation: 为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间;这里的初始化并非源代码中赋值的值,而是类型原始类型对应的初始值,而我们设置的值编译时会以字面常量形式放入二进制文件中,等到类初始化是才会赋值;

  • Resolution: 就是将常量池中的符号引用替换为直接引用的过程,包括类和接口解析、字段解析、方法解析、接口方法解析;

  • Initalization: JVM类加载的最后一个阶段,根据编码制定的主观计划去初始化类变量和其他资源;其实就是执行javac编译生成构造器指令<clinit>(),至于类的初始化,在《Java虚拟机规范》中有明确严格的规定。

关于初始化

  • 遇到new、getstatic、putstatic或invokestatic 这4条指令时,如果类没有进行初始化,则需要先进行初始化。生成这4条指令的常见场景是:
    • 使用new关键字实例化对象的时候
    • 读取或设置一个类的静态字段(被final修饰、已在编译期把结果放到常量池的静态字段除外)的时候;
    • 调用一个类的静态方法的时候。
  • 当使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化则需要先初始化。
  • 当初始化一个类的时候,发现父类没有进行初始化,则需要对父类进行初始化。
  • 当虚拟机启动的时候,用户需要制定一个要执行的主类(这里注意:是包含main方法的那个类),虚拟机会先初始化这个主类;
  • 当然,遇到以下几种情况不被初始化:
    • 通过子类引用父类的静态字段,子类不会被初始化;
    • 通过数组来引用类;
    • 调用类的常量。
// 一个简单的示例
public class Parent {
    public static int parentInt;
    public static int parentInt2 = 3;
    public static final int parentInt3 = 5;
    {  System.out.println("Parent 成员代码块调用了…"); }
    static { ystem.out.println("Parent 静态代码块调用了…"); }
    public Parent() {  System.out.println("Parent 构造函数初始化了…"); }
    public static void tt() { System.out.println("Parent 静态方法…"); }
}

public class Child extends Parent{
    public static int childnt;
    public static int childnt2 = 7;
    public static final int childnt3 = 85;
    static {System.out.println("Child 静态代码块调用了…"); }
    { System.out.println("Child 成员代码块调用了…"); }
    public Child() { System.out.println("Chile 构造方法调用了…"); }
}

// 执行Main 方法需要先初始化main 方法的类,在执行main 方法中的逻辑
public class ExcuteMain {
    static { System.out.println("调用方法,静态代码块执行了…"); }
    public static void main(String[] args) {
        // new 一个对象,先调用静态代码块,然后是成员代码块,最后构造方法
        //new Parent();

        // 调用类的静态方法,类的静态代码块也被执行
        //Parent.tt();

        // 调用的类的静态变量,静态代码块也会调用
        //System.out.println(Parent.parentInt);

        // 调用类的finall 修饰的常量,不会初始化,不会调用类的构造方法,也不会调用静态代码块,因为它是放到常量池中的。
        //System.out.println(Parent.parentInt3);

        //通过子类引用父类的静态字段,子类不会被初始化
        //System.out.println(Child.parentInt);

        // 调用子类的静态变量,父类的静态、子类的静态代码块都会执行
        //System.out.println(Child.childnt2);

        // 调用子类静态常量,父子都不会初始化
        System.out.println(Child.childnt3);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

# 6. GC算法与收集器

目前市场上商业虚拟机的垃圾回收器实现都是基于“分代收集”理论参照实战经验进行设计,JVM中GC算法也是依据“分代理论”基础设计的;在JVM中诸多GC收集器都是基于标记清除、标记复制、标记整理三大算法理论实现的,根据不同的分区的特性使用不同的垃圾回收算法。

# 6.1. GC算法

  1. 标记清除算法 首先查找内存(堆)中需要回收的对象并把它标记处理,在查找的过程中把内存(堆)中的对象都会扫描一遍,才能找到需要回收的对象,这个扫描的过程比较耗时;然后将被标记的对象清理掉。在标记和清理的过程都是比较耗时的,而且容易产生大量的内存碎片,这些内存碎片易导致后续对象内存分配空间不连续。

标记

清除

  1. 标记复制算法 其标记过程与标记过程同标记清除算法都是一样的,只是它将可用的容量分为两块大小相等区域,每次只使用其中的一块,当其中一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。该算法不会存在空间碎片问题,但是有一块空间是空的,比较浪费,而且当标记复制的过程中,若存活的对象比较话,对象复制到另一块空间的开销会很大。

标记

复制

  1. 标记整理算法 标记过程仍然与"标记-清除"算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

标记

整理

根据算法特性以及内存空间特性,标记复制算法适用Young区,而标记清除和标记整理算法使用与Old区。因为Young区中的对象正常情况下是“朝生夕死”的,而Old区中存活的对象都是生命周期较长的,复制算法就太不适用了。

上述介绍的GC算法理论都是基于分代收集理论算法实现,分代理论算法会根据对象存活周期的不同将内存划分为几块, 如JVM中的 新生代、老年代、永久代,这样就可以根据各年代特点分别采用最适当的GC算法。还有一种分区理论算法,分区算法则将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收。这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减少一次 GC 所产生的停顿;在下文中介绍的G1收集器便于该理论的实践。

# 6.2. GC收集器

收集器 特性 算法 备注
Serial 简单高效的单线程收集器
收集时需要暂停所有用户线程,适用于内存比较小的嵌入式设备
标记-复制 新生代收集器
Serial Old Serial的老年代版本 标记-整理 老年代收集器
Parallel Scavenge 侧重于吞吐量的并行的多线程收集器,适合计算型、关注点不在交互体验的场景 标记-复制 新生代收集器
Parallel Old Parallel Scavenge的老年代版本 标记-整理 老年代收集器
CMS 以获取最短回收停顿时间为目标的并发收集器,适合对交互场景有较高要求的场景
流程:①初始标记、②并发标记、③重新标记、④并发清除
标记-清除 老年代收集器
G1 将内存划分为大小独立的区域Region,新生代、老年代不在是物理隔离,而都是Region组成的集合,
但依然遵循分代设计,只是分代区域不再是固定的;
适合对交互场景有较高要求的场景,流程:①初始标记、②并发标记、③最终标记、④筛选回收
标记-整理+标记-复制 新/老年代
ParNew Serial收集器的多线程版本,在多核处理器下性能比Serial要好,但是单核处理器下比Serial差 标记-复制 新生代收集器
ZGC 可伸缩低延迟的收集器,基于“朝生夕死”染色指针技术实现,不管是物理上还是逻辑上,
不存在新老年代的概念了,目前使用很少,还在试验阶段
标记-整理 低延迟收集器
Shenandoah 可以理解为G1的升级版本,也是基于Region方式实现,改进提升很多性能,支持并发收集;
程序响应低延迟,算法实现并不是简单的“标记复制”
标记-复制 低延迟收集器

其实无论哪一款垃圾收集器,都有自身的优点与缺点,都会产生“Stop The Wold”停顿,对应用都会产生一定影响,至于选择哪一款收集器,需要根据应用实际运用场景和需求做出一个合理的调整。

# 7. 常用工具

  1. jps:java process status

输出java处理程序进程状态信息

-q:只输出进程 ID
-m:输出传入 main 方法的参数
-l:输出完全的包名,应用主类名,jar的完全路径名
-v:输出jvm参数
-V:输出通过flag文件传递到JVM中的参数
1
2
3
4
5
  1. jstat:Java Virtual Machine statistics monitoring tool https://docs.oracle.com/javase/8/docs/technotes/tools/unix/jstat.html 监控JVM 类加载、内存、垃圾收集、jit编译信息 参数信息:https://www.cnblogs.com/lizhonghua34/p/7307139.html

  2. jinfo

实时查看和调整虚拟机的各项参数的工具,通常会先使用jps查看java进程的id,然后使用jinfo查看指定pid的jvm信息

虚拟机常用参数:

-Xms:初始堆大小,默认为物理内存的1/64(<1GB);默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制
-Xmx:最大堆大小,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制
-Xmn:新生代的内存空间大小,注意:此处的大小是(eden+ 2 survivor space)。与jmap -heap中显示的New gen是不同的。整个堆大小=新生代大小 + 老生代大小 + 永久代大小。在保证堆大小不变的情况下,增大新生代后,将会减小老生代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
-XX:SurvivorRatio:新生代中Eden区域与Survivor区域的容量比值,默认值为8。两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10。
-Xss:每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。应根据应用的线程所需内存大小进行适当调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。一般小的应用, 如果栈不是很深, 应该是128k够用的,大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。和threadstacksize选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:"-Xss is translated in a VM flag named ThreadStackSize”一般设置这个值就可以了。
-XX:PermSize:设置永久代(perm gen)初始值。默认值为物理内存的1/64。
-XX:MaxPermSize:设置持久代最大值。物理内存的1/4。
1
2
3
4
5
6
7
  1. jmap:JVM Memory Map

用于生成heap dump文件,如果不使用这个命令,还可以使用-XX:+HeapDumpOnOutOfMemoryError参数来让虚拟机出现OOM的时候自动生成dump文件。

  1. jhat:JVM Heap Analysis Tool

与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。在此要注意,一般不会直接在服务器上进行分析,因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析

  1. jstack

jstack是java虚拟机自带的一种堆栈跟踪工具。jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息,如果是在64位机器上,需要指定选项"-J-d64",Windows的jstack使用方式只支持以下的这种方式

  1. JConsole:Java Monitoring and Management Console

它用于连接正在运行的本地或者远程的JVM,对运行在java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。而且本身占用的服务器内存很小,甚至可以说几乎不消耗。命令行输入jconsole 打开工具

  1. VisualVM

命令行运行jvisualvm,VisualVM 是一个工具,它提供了一个可视界面,用于查看 Java 虚拟机 (Java Virtual Machine, JVM) 上运行的基于 Java 技术的应用程序(Java 应用程序)的详细信息。VisualVM 对 Java Development Kit (JDK) 工具所检索的 JVM 软件相关数据进行组织,并通过一种使您可以快速查看有关多个 Java 应用程序的数据的方式提供该信息。您可以查看本地应用程序以及远程主机上运行的应用程序的相关数据。此外,还可以捕获有关 JVM 软件实例的数据,并将该数据保存到本地系统,以供后期查看或与其他用户共享

# 8. 总述

本文主要从JVM内存结构、加载类机制以及JVM内存自动管理GC收集器三个方面归纳整理,对于JVM来说仅仅是九牛一毛,有很多知识点还需要在实践过程中深入研究探讨。

# 9. 参考

  1. 美团—新一代垃圾回收器ZGC的探索与实践 (opens new window)
  2. 周志明.《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》

# 10. 附录

  1. 从实际案例聊聊Java应用的GC优化 (opens new window)
  2. 深入理解堆外内存 Metaspace (opens new window)
  3. JVM 常用调优参数 (opens new window)
  4. Java最前沿技术——ZGC (opens new window)
#JVM
上次更新: 2024/03/02, 14:21:03
多线程基础

多线程基础→

最近更新
01
从0到1:开启商业与未来的秘密
11-26
02
如何阅读一本书: 读懂一本书,精于一件事
10-25
03
深入理解Lambda
06-27
更多文章>
Theme by Vdoing | Copyright © 2019-2024 uRick | CC BY 4.0
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式