【面试】深入解析JVM内存结构

520 阅读7分钟

一、Java跨平台运行

我们都知道Java语言一次编译到处运行,可以在windows上运行也可以在Liunx上运行,属于跨平台语言,Java其实就是依赖JVM实现的跨平台性,但是我们的JVM本身是不存在跨平台的。

通过Javac.exe编译.java原代码文件生成.class文件,然后再通过类加载器,将class文件加载到JVM中,交由JVM运行,最后输出结果。

我们来记一张简洁的运行图:

image.png

二、JVM的组成

JVM由4大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区域),Execution Engine(执行引擎),Native Interface(本地接口)。

image.png

  • ClassLoader(类加载器): 负责加载字节码文件,即是java编译后的.class文件。
  • Runtime Date Area(运行时数据区域): 存放.class文件和分配内存。
  • Native Interface(本地库接口): 负责调用本地接口,即是调用不同的语言接口给java使用。
  • Execution Engine(执行引擎): 当.class字节码文件被加载后,会把指令和数据信息存放在内存中,此时执行引擎负责把这些命令解释给操作系统。

2.1 类加载器

2.1.1 类加载器的过程

image.png

  • 加载:将字节码文件加载到内存
  • 校验:检验字节码文件的正确性
  • 准备:给类的静态变量分配内存,并赋予默认值
  • 解析:类装载器装入类所引用的其他对象
  • 初始化:对类的静态变量初始化为指定值,执行静态代码块

2.1.2 类加载的种类

  1. 启动类加载器:负责加载JRE的核心类库

  2. 扩展类加载器:负责加载JRE扩展的ext中的JAR类包

  3. 系统类加载器:负责加载ClassPath路径下的类包

  4. 用户自定义加载器:负责加载用户自定义路径下的类包

2.1.3 类加载机制

1. 全盘负责委托机制: 当类加载器加载一个类时,除非显示的是另一个加载器,该类所依赖的和应用的类也由这个类加载器载入

2. 双亲委派机制:

执行流程:

1)当类加载器接收到类加载请求的时候,会先判断当前类是否被加载过,如果已经加载过直接返回,否则才尝试加载。

2)类加载的时候,不会直接去加载目标类,先委派父类加载器去寻找目标类。

3)只有父类无法加载到这个类,子加载器才会尝试自己去加载。

4)如果子类加载器也无法加载到目标类,那么它会抛出一个 ClassNotFoundException 异常。

Java虚拟机采用的是双亲委派模式,双亲委派机制的优势:避免类的重复加载,保护程序安全,防止核心API被随意篡改

2.2 运行时数据区域

image.png

运行时数据区域总共分为五部分:分别是Java虚拟机栈、本地方法栈、程序计数器、堆、方法区。

方法区: 负责存储.class文件,并且这块有一个运行常量池,就是存储一些变量或者常量信息的。

堆: 分配内存给对象,比如我们new的对象,就存在堆里面。

java虚拟机栈: 也可称为线程栈,每个线程独享的内存空间。

本地方法栈: 本地native方法独享的内存空间。

程序计数器: 记录线程执行的位置,方便线程切换后再次执行。

2.3.1 栈和堆的区别

image.png

image.png

每一个线程都会维护自己的线程栈,独享自己的内存空间,栈里面的空间是自动释放的,方法一结束,就自动腾出内存空间,堆是在执行过程中自己手动new出来对象,此时产生的内存空间,要手工管理,进行回收。

2.3.1 Java虚拟机栈

比如我们的main方法,调用sum函数,执行一个和的运算,此时我们的Java虚拟机栈就会为期分配栈帧内存区域。

public class MainDemo {

    // 一个方法对应一块栈帧内存区域
    public static Integer sum() {
        int a = 1;
        int b = 2;
        return a + b;
    }

    // main方法也对应一块栈帧内存区域
    public static void main(String[] args) {
        Integer sum = sum();
        System.out.println(sum);
    }
}

首先我们来看一下他的执行顺序是怎样的?首先是先调用main函数,随后再去调用sum函数,sum运算结束之后,再销毁栈内存,其次再返回到main函数,等到main结束之后,再销毁main的栈内存空间,这个过程main先执行了,却是最后退出,即栈帧内部的数据结构即是先进后出(FILO)。

image.png

我们将上面的demo进行反汇编,翻译成JVM虚拟机的汇编代码:

javap -c MainDemo.class > MainDemo.txt

image.png

然后我们打开MainDemo.txt文件,里面就是一堆的JVM运行的汇编代码。

Compiled from "MainDemo.java"
public class com.dt.thread.java.MainDemo {
  public com.dt.thread.java.MainDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static java.lang.Integer sum();
    Code:
       0: iconst_1
       1: istore_0
       2: iconst_2
       3: istore_1
       4: iload_0
       5: iload_1
       6: iadd
       7: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      10: areturn

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #3                  // Method sum:()Ljava/lang/Integer;
       3: astore_1
       4: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: aload_1
       8: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
      11: return
}

这一堆的代码,怎么来解读呢?其实Oracle官方有专门的指令码文档来解读。这里我们就来简单来解读一下

iconst_1 将int类型常量1压入栈

istore_0 将int类型值存入局部变量0

iconst_2 将int类型常量2压入栈

istore_1 将int类型值存入局部变量1

iload_0 从局部变量0中装载int类型值

iload_1 从局部变量1中装载int类型值

iadd 执行int类型的加法

invokestatic 调用类(静态)方法

areturn 从方法中返回引用类型的数据

我们栈帧内部存放的是一些局部变量,操作数栈,动态链表,方法出口。

image.png

这里当我们的栈中的局部变量是对象的时候,那么此时我们存储的是堆内存空间中对象的地址。

image.png

2.3.1 堆

Java虚拟机启动时创建,用于存放对象实例,几乎所有的对象包括常量池都在堆上分配内存,当对象无法在内存申请内存时,就会抛出OOM(OutOfMemoryError)异常。

image.png

所有的类都是在Eden Space(伊甸区)new出来的,当伊甸区空间用完了,程序又需要创建对象,JVM的垃圾回收器将对伊甸区进行垃圾回收(Minor GC),将伊甸区中不再被其它对象所引用的对象销毁,然后被引用的剩余对象移到幸存者0区,当0区空间不够用,再次进行GC,然后移动到1区,如果1区也满了,将会转移到0区,幸存者0区和1区中反复存在,经过多次GC,超过15次的存活对象,最后将会进入到老年区,如果老年区内存空间也满了,将会产生MajorGC,进行老年区的内存清理,如果老年代执行了MajorGC之后,任然无法进行对象的保存,也会产生OOM(OutOfMemoryError)异常。

总结

GC是垃圾回收机制,java中申请的内存可以被垃圾回收装置进行回收,GC可以一定程度的避免内存泄漏,但是会引入一些额外的开销。 GC中主要回收的是堆和方法区中的内存,栈中内存的释放要等到线程结束或者是栈帧被销毁,而程序计数器中存储的是地址不需要进行释放。