Java 虚拟机 | 内存分配模型

点赞关注,不再迷路,你的支持对我意义重大!

Hi,我是丑丑。本文 「Java 路线」导读 —— 他山之石,可以攻玉 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


目录


1. 运行时数据区域

根据《Java虚拟机规范》的规定,Java 虚拟机在执行程序时,会将内存划分为不同的数据区域:

内存区域 线程独占
程序计数寄存器 私有
Java 虚拟机栈 私有
本地方法栈 私有
Java 堆 共享
方法区 共享

—— 图片引用自网络

1.1 程序计数寄存器(Program Counter Register)

程序计数寄存器记录的是当前线程下一条准备执行执行的字节码行号。当虚拟机在进行顺序执行、分支、循环、函数调用或异常处理时,都会将「下一条字节码指令的行号」存储在程序计数器中。

为什么 Java 虚拟机需要这个程序计数器呢,这是为了保证正确地进行线程切换。操作系统的「时间片轮转机制」会为每个线程分配时间片,当一个线程的时间片用完,或者其他线程提前抢夺 CPU 时间片时,当前线程就会挂起,而将来挂起的线程获得时间片时,就需要通过程序计数器来恢复到正确的指令位置。

程序计数器只在执行 Java 方法时有意义,如果当前线程执行的是 native 方法,程序计数器的值是空(Undefined)。

1.2 虚拟机栈(Java Virtual MachineStack)

虚拟机栈描述的是 Java 方法执行的内存模型。虚拟机在执行方法时会创建一个栈帧(Stack Frame),每个方法从调用到结束的过程,都对应着一个栈帧入栈到出栈的过程。

  • 入栈:创建对应的栈帧,压入虚拟机栈;
  • 出栈:恢复上层方法中的局部变量表和操作数栈,如果有返回值,将返回值入栈,最后调整程序计数器指向方法调用指令的下一条执行。当所有栈帧都出栈后,线程结束。

提示: 栈的默认大小是 1M,可以用虚拟机参数-Xss调整大小。

1.2.1 栈帧

栈帧是支持虚拟机进行方法调用和方法执行的数据结构,每个栈帧都包含四个区域:

  • 1、局部变量表(Local Variable Table)

存放局部变量,当一个变量不再使用时,对应的空间会让出来给其他变量使用,这块区域的大小在编译时确定。

  • 2、操作数栈(Operand Stack)

用于存放字节码指令的操作数,这块区域的大小在编译时确定。在虚拟机的具体实现中,这这块区域可能是寄存器,也可能是栈。所谓 “基于栈的解释执行”,这里的栈指的是操作数栈。

  • 3、动态连接(Dynamic Linking)

  • 4、返回地址

返回地址存放函数调用位置的下一行指令,用于在方法正常返回时返回到上一层方法继续执行。如果是异常返回的话,则是通过异常处理器表来确定。

—— 引用自 https://paul.pub/android-dalvik-vm/ 强波(华为)著

1.2.2 栈 vs 寄存器

在《Java虚拟机规范》中,操作数栈是一个栈数据结构,但在虚拟机的具体实现里,也可能是寄存器结构。

  • 基于栈的解释执行 —— Java 虚拟机
  • 基于寄存器的解释执行 —— Android 虚拟机(Dalvik & ART)

易错: 这里的栈和寄存器都是虚拟机的虚拟实现,和 CPU 中的数据寄存器并不是同一个概念。

基于寄存器的虚拟机栈帧中没有操作数栈和局部变量表,取而代之的是虚拟寄存器。与 Java 虚拟机相比,基于寄存器的 Android 虚拟机的指令数明显较少,同时也避免了操作数栈和局部变量表之间的数据移动。

1.2.3 栈的优化技术

  • 编译优化:方法内联

方法内联的就是把目标方法的代码复制到调用位置,避免方法调用的出栈入栈行为。

  • 栈帧数据共享

一般两个栈帧的内存区域是独立的,而在大多数虚拟机实现中,会将两个栈帧中的下层栈帧的「操作数栈」和上层栈帧的「部分局部变量」重叠,这样在方法调用的时候就不用进行额外的参数复制了。

—— 图片引用自网络

1.3 本地方法栈(Native Method Stacks)

与虚拟机栈类似,区别在于虚拟机栈执行 Java 方法,而本地方法栈执行 native 方法。当虚拟机调用 native 方法时,不会在虚拟机栈中创建栈帧,而是直接动态链接调用 native 方法。

提示: 《Java虚拟机规范》没有强制规定本地方法栈中方法语言、使用方式与数据结构,有的虚拟机(如 HotSpot)直接合并了虚拟机栈和本地方法栈。

1.4 Java 堆(Java Heap)

堆是虚拟机上最大的一块内存,绝大多数对象都是存储在堆上的(Class 对象存储在方法区,满足逃逸分析的对象在栈上分配)。垃圾回收机制操作的主要区域也是堆。

堆和方法区都是线程共享的,为什么 Java 区分出两块区域呢?

这体现的是 动静分离 的思想,堆中存放的是生命周期比较短,经常需要进行垃圾回收的数据;而方法区中存放的是生命周期比较长的数据。将两种数据分开存储,有利于更高效地进行内存管理。

1.5 方法区(Method Area)

1.5.1 方法区的数据

方法区主要存放虚拟机加载的类相关数据,包括:

  • 类信息
  • 静态变量
  • 静态常量
  • 即时编译器生成的代码
  • 运行时常量池

「Class 文件常量池」和「运行时常量池」是比较容易混淆的概念。其实它们一个是静态的,一个是动态的。

「Class 文件常量池(Constant Pool Table)」是静态的,指的是编译后存放在 Class 文件常量池中的字面量 & 符号引用,而这些常量会在类加载之后进入运行时常量池。

「运行时常量池」是动态的,Java 不要求常量只能在编译时声明,在运行时同样可以将新的常量加入到常量池中。例如 String#intern()

提示: 所谓字符串常量池属于运行时常量池的一部分。

1.5.2 方法区 ? 永生代 ? 元空间 ?

这三个概念也是比较容易混淆的,简单来说:方法区是虚拟机规定的运行时数据区域,永久代 & 元空间是方法区在不同虚拟机上的具体实现。

以 HotSpot 虚拟机为例:

在 JDK 1.7 之前,HotSpot 虚拟机使用永久代(Permanent Generation)来实现方法区,永久代中存储的都是生命周期较长的数据。永久代可以跟堆一起执行垃圾回收。不过在永久代执行垃圾回收的 “性价比” 并没有新生代高,一般新生代垃圾回收可以回收 70% ~ 95% 的空间,而永久代的垃圾回收率就远低于此。

从 JDK 1.8 开始,HotSpot 虚拟机使用元空间(Metadata)来实现方法区,并且使用了本地内存存储,扩展了方法区的内存上限。

为什么 HotSpot 要使用元空间来代替永久代呢?

因为永久代空间有限,经常出现不够用或者内存溢出异常。而使用本地内存就可以方便扩展方法区的大小。当然,元空间也不是完美的,因为机器总内存是有限的,使用大量的本地内存的话就会挤压堆内存的上限。


2. 直接内存 / 堆外内存 / 本地内存

这三个概念其实是相同的,直接内存(Direct Memory)不属于 Java 虚拟机规定的运行时数据区域。不受制于 Java 堆大小限制,但是受制于机器总内存。

在 JDK 1.4 NIO 中引入了基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,可以直接通过 native 方法分配一块直接内存,并且创建一个 Java 层 DirectByteBuffer 对象供应用层访问。


3. 内存溢出

3.1 程序计数器

在《Java虚拟机规范》中,程序计数器是 JVM 中唯一不会发生 OOM 的区域。

3.2 栈溢出

虚拟机栈和本地方法栈类似,都可能抛出的两种异常:

  • StackOverflowError 异常: 线程的栈帧深度大于虚拟机允许的最大深度;
  • OutOfMemoryError 异常: 无法申请到足够内存时;

3.3 堆溢出

申请内存空间超出最大堆内存空间时发生堆溢出。应检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少冗余 / 不必要的内存消耗。

3.4 方法区溢出

有两种情况会导致方法区内存溢出:

  • 1、运行时常量池溢出
  • 2、加载的类信息溢出

3.5 直接内存溢出

与堆一样,申请的直接内存超过直接内存容量时,也会发生内存溢出。

ByteBuffer.allocateDirect(128*1024*1024)
java.lang.OutofMemoryError : Direct buffer memory

4. 总结

  • 1、程序计数器是线程私有,描述的是当前线程下一条需要执行的字节码指令行号;

  • 2、虚拟机栈描述的是 Java 方法执行的内存模型;

  • 3、本地方法栈与虚拟机栈类似,区别在于虚拟机栈执行 Java 方法,而本地方法栈执行 native 方法;

  • 4、堆是虚拟机上最大的一块内存,绝大多数对象都是存储在堆上的,垃圾回收机制操作的主要区域也是堆;

  • 5、方法区主要存放虚拟机加载的类相关数据。


创作不易,你的「三连」是丑丑最大的动力,我们下次见!

你可能感兴趣的:(Java 虚拟机 | 内存分配模型)