1. Java 内存区域与内存溢出异常

74 阅读9分钟

Java 内存区域

1. 程序计数器

  • 【程序计数器】(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的【字节码的行号指示器】。
  • 【字节码解释】 工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执 行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需 要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内 存区域为“线程私有”的内存

2.Java虚拟机栈

  • 与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的, 它的生命周期与线程相同。
  • “栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机 栈中【局部变量表】部分。
  • 【局部变量表】存放了编译期可知的各种 Java 虚拟机基本数据类型
  • 【局部变量表】所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈 帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大 小。

3. Java 堆

  • 对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一 块。
  • Java 堆是被所有线程【共享的一块内存区域】,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
  • Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC 堆”
  • Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
  • Java 堆既可以被实现成固定大小的,也可以是可扩展的(通过参数-Xmx 和-Xms 设定)

4. 方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了

5. 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。

Class 文件中除了有类 的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放 到方法区的运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语 言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中

6. 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,但是这部分内存也被频繁地使用

它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行 操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复 制数据。

对象探秘

1. 对象的创建

  1. 类加载检查 当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、 解析和初始化过。如果没有,那必须先执行相应的类加载过程
  2. 虚拟机将为新生对象分配内存 对象所需内存的大小 在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来。
  3. 将分配到的内存空间(但不包括对象头)都初始化为零值 这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
  4. 对对象进行必要的设置 例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用 Object::hashCode()方法时才计算)、对象的 GC 分代年龄等信息。这些信息存放在对象的 对象头(Object Header)之中。

【思考】:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。

解决这个问题有两种可选方案:

一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用 CAS 配上失败重试的方式保证更新操作的原子性;

另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。

2. 对象的内存布局

对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

**【对象头部分】**包括两类信息。

  1. 存储对象自身的运行时数据:如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位,官方称它为“Mark Word” 对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的最大限度,但对象头里的信息是额外的,例如Mark Word 的 32 个比特存储空间中的 25 个比特用于存储对象 哈希码,4 个比特用于存储对象分代年龄,2 个比特用于存储锁标志位,1 个比特固定为 0
  2. 类型指针: 即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。

【实例数据部分】

对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

HotSpot 虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

【对齐填充】

这并不是必然存在的,也没有特别的含义,它仅仅起 着占位符的作用。

HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

3. 对象的访问定位

Java 程序会通过栈上的 【reference 数据】 来操作堆上的具体对象

对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有 【使用句柄】和【直接指针】 两种

【使用句柄访问】 Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息

【好处】

  • reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。

【直接指针访问】 Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销

【好处】

  • 速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本