Java 内存模型

2021-03-16  本文已影响0人  Vic_wkx

一段简单的 Java 程序,Test.java:

class Test {
    public static void main(String[] args) {
        System.out.println("Hello, world");
    }
}

首先使用 javac 命令编译成 Test.class:

javac Test.java

然后通过 java 命令执行:

java Test 

输出如下:

Hello, world

Java 程序在运行过程中,首先会将 .java 文件编译成 .class 字节码文件,Java 程序访问 Java 类时,会使用 ClassLoader 将 .class 文件加载到 JVM (Java visual machine)内存中。

JVM 中的内存可以划分为两大块:线程间共享区域和线程私有区域。线程间共享区域又分为堆、方法区,线程私有区域分为虚拟机栈、本地方法栈、程序计数器。

Java 内存模型

一、堆

堆(Heap)是 JVM 所管理的内存中最大的一块,它的唯一目的就是存放对象实例。几乎所有的对象实例都是在堆上分配的,因此它也是 GC(Garbage Collector)管理的主要区域。因为堆是线程间共享的,所以分配在堆上的对象如果被多个线程同时访问,需要考虑线程安全问题。

堆中的内存可以划分为新生代和老年代,新生代又分为 Eden 区和 Survivor 区,在 GC 回收时,不同区域会采用不同的回收策略。

1.1 OutOfMemoryError

理论上,虚拟机栈、堆、方法区都有可能发生 OOM(OutOfMemoryError),但大多数情况下是发生在堆中。如以下代码:

import java.util.ArrayList;
import java.util.List;

class Test {
    public static void main(String[] args) {
        List<Test> tests = new ArrayList<>();
        while (true) {
            tests.add(new Test());
        }
    }
}

我们在一个死循环中不断 new 出对象,这会不断占用堆中的内存,当堆内存不够时,必然产生 OOM:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at java.base/java.util.Arrays.copyOf(Arrays.java:3721)
        at java.base/java.util.Arrays.copyOf(Arrays.java:3690)
        at java.base/java.util.ArrayList.grow(ArrayList.java:237)
        at java.base/java.util.ArrayList.grow(ArrayList.java:242)
        at java.base/java.util.ArrayList.add(ArrayList.java:485)
        at java.base/java.util.ArrayList.add(ArrayList.java:498)
        at Test.main(Test.java:8)

二、方法区

方法区(Method Area)主要用来存储 JVM 中已经加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。

方法区只是 JVM 规范中规定的一块区域,并不是实际实现。HotSpot 在 JDK 1.7 之前使用 “永久区”(Perm 区)来实现方法区,在 JDK 1.8 之后 “永久区”就被移除了,取而代之的是一种叫做“元空间”(metaspace)的实现方式。

三、虚拟机栈

虚拟机栈用来描述 Java 方法执行的内存模型,每个方法被执行的时候,JVM 都会在线程中为这个方法创建一个栈帧。所有的栈帧都被放在虚拟机栈中。

3.1 栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的一种数据结构。

一个线程包含多个栈帧,每个栈帧内部主要包含局部变量表、操作数栈、动态链接、返回地址。

3.1.1 局部变量表

顾名思义,局部变量表用于存储方法内部创建的局部变量,包括调用方法时传递过来的实参。在 .java 文件被编译成 .class 文件时,就会在方法的 Code 属性表中的 max_locals 数据项中,确定该方法需要分配的最大局部变量表的容量。

比如,我们创建一个 Test.java 文件:

class Test {
    public static int add(int k) {
        int i = 1;
        int j = 2;
        return i + j + k;
    }
}

先使用 javac Test.java 编译出 .class 文件,然后使用 javap -v Test.class 查看字节码:

class Test
  minor version: 0
  major version: 56
  flags: (0x0020) ACC_SUPER
  this_class: #2                          // Test
  super_class: #3                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #3.#12         // java/lang/Object."<init>":()V
   #2 = Class              #13            // Test
   #3 = Class              #14            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               add
   #9 = Utf8               (I)I
  #10 = Utf8               SourceFile
  #11 = Utf8               Test.java
  #12 = NameAndType        #4:#5          // "<init>":()V
  #13 = Utf8               Test
  #14 = Utf8               java/lang/Object
{
  Test();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static int add(int);
    descriptor: (I)I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: iload_0
         8: iadd
         9: ireturn
      LineNumberTable:
        line 3: 0
        line 4: 2
        line 5: 4
}
SourceFile: "Test.java"

其中的 locals=3 就表示局部变量表的长度是 3,也就是说经过编译后,局部变量表的长度已经确定是 3,分别保存参数 i、j、k。

3.1.2 操作数栈

操作数栈(Operand Stack)也常被称为操作栈,与局部变量表一样,操作数栈的最大深度在编译后就确定了。对应上例中的 stack=2,栈中的元素可以是任意 Java 数据类型。

当一个方法刚刚开始执行时,操作数栈是空的,在方法执行过程中,会有各种字节码指令被压入和弹出操作数栈。

在上例中,各指令的含义如下:

3.1.3 动态链接

在一个 .class 文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。

Java 虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态链接(Dynamic Linking)。

3.1.4 返回地址

当一个方法执行后,有两种方式退出:

无论哪种方式退出,方法退出后都需要返回到方法被调用的位置,程序才能继续执行(比如发生异常时,可以在上层调用处再 catch 此异常)。“返回地址”就是用来记录当前方法返回地址的。

3.1.5 StackOverflowError

当方法无限递归调用时,就会出现 StackOverflowError。如以下代码:

class Test {
    public static void main(String[] args) {
        main(null);
    }
}

运行时就会出现以下错误:

Exception in thread "main" java.lang.StackOverflowError
        at Test.main(Test.java:3)
        at Test.main(Test.java:3)
        at Test.main(Test.java:3)
        ...

前文说到,每运行一个方法,线程中都会创建一个栈帧。在这个递归调用中,每个方法都没有运行结束,所以每个方法都不会退出,所以每个创建出的栈帧都不会被销毁,最终必然导致 StackOverflowError。

四、本地方法栈

本地方法栈(Native Method Stack)和虚拟机栈基本相同,只不过是针对 native 方法,在 HotSpot 中已经将本地方法栈和虚拟机栈合二为一了。

五、程序计数器

Java 程序是多线程的,CPU 可以在多个线程中分配执行时间片段。当某一个线程被挂起时,需要记录当前线程正在执行的位置,以便 CPU 重新执行此线程时,知道从何处开始。程序计数器就是用来记录当前线程正在执行的位置的。

程序计数器中没有规定 OOM 异常,当一个线程正在执行 Java 方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行 native 方法,这个计数器的值为空(Undefined)。

Java 内存模型详情

参考文章

Android 工程师进阶 34 讲
The Java® Virtual Machine Specification

上一篇下一篇

猜你喜欢

热点阅读