《深入理解Java虚拟机(第2版)》读书笔记 - Java内存区域与内存溢出异常

96
hugo54
2019.06.29 15:31* 字数 2308

程序计数器(Program Counter Register)

PC是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

每个线程都有自己独立的PC,PC是线程私有的。

如果线程执行的是Java方法,该线程的PC中记录的是正在执行的虚拟机字节码指令的地址

如果线程执行的是Native方法,该线程的PC的值为空(Undefined)

虚拟机栈(VM Stack)

VM Stack描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个Stack Frame,它用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

  • 局部变量表:存放编译期可知的基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(指向对象起始地址的引用指针 / 指向一个代表对象的句柄)和returnAddress类型(指向一条字节码指令的地址)

  • 操作数栈:

每个方法从调用到执行的过程 = 一个Stack Frame在VM Stack中入栈到出栈的过程

每个线程都有自己的VM Stack,VM Stack是线程私有的。

本地方法栈(Native Method Stack)

与VM Stack作用相似,区别在于:VM Stack为虚拟机执行Java Method(字节码)服务;Native Method Stack为执行native方法服务。

堆(Heap)

Heap唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。根据Java虚拟机规范:

The heap is the runtime data area from which memory for all class instances and arrays is allocated.

另外,Heap是线程共享的。

方法区(Method Area)

方法区是线程共享的,用于存储:

  • 被虚拟机加载的类信息

  • 常量

  • 静态变量

  • 即时编译器编译后的代码

  • ……

Java虚拟机规范把Method Area描述为Heap的一个逻辑部分,但与Heap有区分。

运行时常量池(Runtime Constant Pool)

运行时常量池是Method Area的一部分,用于存放编译期生成的字面量和符号引用。

直接内存(Direct Memory)

直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

JVM常见的异常

  • StackOverflowError:线程请求的栈(VM Stack / Native Method Stack)深度大于虚拟机允许的深度,抛出此异常。

  • OutOfMemoryError:如果栈(VM Stack / Native Method Stack)可以动态扩展,当扩展时无法申请到足够的内存,抛出此异常。

对象的创建过程

(此处讨论的对象限于普通Java对象,不包括数组和Class对象等)

当JVM遇到一条new指令时,JVM会根据以下流程创建一个新的对象:

  1. 类加载检查:检查这个指令的参数是否能在常量池中定位到类的符号引用,检查符号引用代表的类是否被加载、解析和初始化过。如果没有,执行类加载。

  2. 为新对象分配内存:类加载完成后,对象所需内存大小便已确定。为对象分配内存等同于把一块确定大小的内存从Heap中划分出来。(随着JIT编译器的发展和逃逸分析技术逐渐成熟,产生了栈上分配、标量替换等优化技术,因而并不是所有对象都在Heap上分配)

  3. 内存初始化:将分配到的内存空间都初始化为零值(不包括对象头)。

  4. 设置对象头信息:例如该对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等。

  5. 执行<init>方法: At the level of the Java Virtual Machine, every constructor written in the Java programming language (JLS §8.8) appears as an instance initialization method that has the special name <init>.

为新对象分配内存的方式由Heap是否规整决定:

  • 假设Heap中的内存是绝对规整的,用过的内存在一边,空闲的内存在另一边,它们之间有一个指针作为分界点指示器,只需将指针向空闲一边移动与对象大小相同的长度,即可完成分配。这种分配方式被称为“指针碰撞”

  • 假设Heap中内存不是规整的,而是已用内存和空闲内存在物理上交错分布,就无法使用“指针碰撞”的方法。而需要JVM维护一个逻辑上连续的可用内存列表,在列表中找到一块足够大的内存空间分配给对象,并更新列表记录。这种方式被称为“空闲列表”

而Heap是否规整又由所采用的垃圾收集器(Garbage Collector)决定。下面是几种常用GC的内存分配方式:

  • 指针碰撞:Serial、ParNew等带Compact过程的GC

  • 空闲列表:CMS这种基于Mark-Sweep算法的GC

另外,JVM为对象分配内存时,在并发情况下要考虑线程安全。这个问题有两种解决方案:

  • 对内存分配的动作做同步处理:CAS + 失败重试,以确保更新操作的原子性

  • 把内存分配的动作按线程划分在不同的空间中执行:每个线程在Heap中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer)。每个线程在各自的TLAB上分配内存,只有TLAB用完并分配新TLAB时,才需要同步锁定。

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可分为三块区域:对象头、实例数据和对齐填充。

对象头(Header)

对象头包括两部分信息:

一部分用于存储对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定对象是哪个类的实例。

实例数据(Instance Data)

实例数据部分是对象真正存储的有效信息,也是程序代码中所定义的各种字段内容,其中包括从父类继承的字段和子类中自行定义的字段。

对齐填充(Padding)

由于HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍(对象大小必须是8字节的整数倍),而且Header部分正好是8字节的整数倍。所以当Instance Data长度没有对齐时,需要做对齐填充。

对象的访问定位

建立对象是为了使用对象,Java程序通过Stack上的reference数据来操作Heap上的具体对象。对象访问的方式取决于虚拟机实现,主流的访问方式有句柄直接指针

如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄的好处是:reference中存储的稳定的句柄地址,对象被移动时只会改变对象实例指针,reference本身不需要修改。

如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,reference 中存储的直接就是对象的地址。使用直接指针的好处是:速度更快,节省了一次指针定位的时间开销。

日记本