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

  Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人想出来。

1 概述

  对于从事C、C++程序开发的开发人员来说,在内存管理区域,他们既是拥有最高权力的“皇帝”,又是从事最基础工作的“劳动人民”——既拥有每一个对象的“所有权”,又担负着每一个对象生命开始到终结的维护责任。

  对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,由虚拟机管理内存这一切看起来都很美好。不过,也正是因为Java程序员把内存控制的权力交给了Java虚拟机,一旦出现内存泄漏和内存溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一项异常艰难的工作。

2 运行时数据区域

2.1 程序计数器

  程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

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

  如果线程正在执行的是一个Java方法,这个计数器的值是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,计数器值则为空(Undefined)。

  此内存是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2.2 Java虚拟机栈

  与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同

  每个方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等消息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。其中局部变量表存放了编译时期可知的各种基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)

  如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果虚拟机栈可以动态扩展并且扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

2.3 本地方法栈

  本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别只不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务

  与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常

2.4 Java堆

  对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动的时候创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

  根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的。

  如果堆内存中没有内存完成实例分配,并且也无法再扩展时,将会抛出OutOfMemoryError异常。

2.5 方法区

  方法区和Java堆一样,是各个线程共享的区域,它用于存储Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。

  Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。

  根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

2.6 运行时常量池

  运行时常量池是方法区的一部分,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

  既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法申请到内存时会抛出OutOfMemoryError异常。

3 HotSpot虚拟机对象探秘

3.1 对象的创建

  类加载:先检查类是否已被加载、解析和初始化,如果没有,执行相应的类加载过程;

  分配空间:为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来,分配方式主要有两种:指针碰撞,空闲列表,选择哪种方式取决于Java堆是否规整,而Java堆是否规整又取决于所采用的的垃圾收集器是否带有压缩整理的功能

  初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初值就直接使用,程序能访问到这些字段的数据类型对应的零值;

  设置对象头:虚拟机需要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息都存放在对象的对象头中。

在上面的工作都完成之后,从虚拟机的角度来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——<init>方法还没执行,所有的字段还为零值。

3.2 对象的内存布局

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

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

  实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。

  对齐填充并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用(对象的大小必须是8字节的整倍数)

3.3 对象的访问定位

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

1)句柄方式

  Java堆中将会划出一块内存来作为句柄池,reference对象存储的就是对象的句柄地址。句柄中包含了对象实例数据和类型数据的具体地址

  

2)直接指针方式

  

两种方式比较:由于reference中存储的是稳定的句柄地址,使用句柄方式时,在对象被移动时(如GC过程中的对象移动),只需改变句柄中实例数据指针,而reference本身不用动。直接指针方式速度快,节省了一次指针定位的时间开销,HotSpot采用此方式。

原文地址:https://www.cnblogs.com/tcxpz/p/10568004.html