1.Java 内存区域与内存溢出异常
对于 Java 程序员来说,在虚拟机自动内存管理机制下,不需要为 new 操作去写配对的 delete/free 代码,不容易出现内存泄漏。但是如果出现内存泄漏问题,如果不了解虚拟机的机制,便难以定位。
##思维导图
运行时数据区域
Java 虚拟机所管理的内存包括以下:
程序计数器
一块较小的内存,可以看作是当前线程所执行的字节码的行号指示器。
在虚拟机概念模型(各种虚拟机实现可能不一样)中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
程序计数器是属于线程私有的内存。如果执行的是Java方法,该计数器记录的是正在执行的虚拟机字节码指令的地址。如果是 Native 方法则为空(Undefined)。
此区域是唯一一个在 Java 虚拟机中没有规定 OOM 情况的区域。
###Java 虚拟机栈
Java虚拟机栈也是线程私有的。
描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。
局部变量表存放了编译器可知的各种基本数据类型、对象引用和 returnAddress 类型。其所需的内存空间在编辑期完成分配,不会再运行期改变。
可能存在两种异常:StackOverflowError 和 OutOfMemoryError。
本地方法栈
与虚拟机栈非常相似,只不过是为虚拟机使用到的 Native 方法服务。可能存在两种异常:StackOverflowError 和 OutOfMemoryError。
Java 堆
Java堆是被所有线程共享的,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这分配。是垃圾收集器管理的主要区域,可以分为新生代和老年代。可以物理不连续,只要逻辑上是连续的即可。如果堆中没有内存完成实例分配也无法再扩展时,会抛出 OutOfMemoryError 异常。
方法区
是线程共享的区域。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。该区域对于垃圾收集来说条件比较苛刻,但是还是非常有必要要进行回收处理。当无法满足内存分配需求时,将抛出OutOfMemoryError异常。
在 Hotspot 的实现为永久代(PermGen),从 jdk7-jdk8 去除永久代,变为元空间(MetaSpace),官方说明:http://openjdk.java.net/jeps/122。
将原来永久代的数据:
- 类的信息(class metaspace)转移到了本地内存
- 类的静态变量(class statics)转移到了堆
- 字符串字面量(interned strings)转移到堆
运行时常量池
是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。Java虚拟机规范要求较少,通常还会把翻译出来的直接引用也存储在此。另外一个重要特征是具备动态性,可以在运行期间将新的常量放入池中,如 String 的 intern()
方法。可能存在的异常:OutOfMemoryError。
直接内存
并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。JDK 1.4 的 NIO 引入了基于通道(Channel)和缓冲区(Buffer)的 IO 方法,可以使用 Native 函数库直接分配对外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作以提升性能。
虚拟机对象
进一步了解虚拟机内存中数据的其他细节,比如它们是如何创建、如何布局以及如何访问的。下面以虚拟机HotSpot和常用的内存区域 Java 堆为例,深入探讨 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
对象的创建
虚拟机遇到一条 new 指令时,先检查指令的参数是否能在常量池中定位到一个类的符号,并且检查这个符号引用代码的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便完全确定,为对象分配空间等同于把一块确定大小的内存从 Java 堆中划分出来:
- 在使用 Serial、ParNew 等带 Compac t过程的收集器时,系统采用的分配算法是指针碰撞(内存绝对规整,只要通过指针作为分界点标识)
- 而使用 CMS 这种基于 Mark-Sweep 算法收集器时,通常使用空闲列表(内存不规整,通过维护一个列表记录那块内存是可用的)
另外一个需要考虑的并发下的线程安全问题,有两种方案:
- 分配内存空间的动作进行同步处理(实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性)
- 为每个线程分配一小块内存(称为本地线程分配缓冲,TLAB),各个线程独立分配,只有 TLAB 用完需要分配新的才需要同步锁定,虚拟机通过
-XX:+/-UseTLAB
参数来设定。
内存分配完后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头),这保证了对象的实例字段在Java代码中可以不赋值就直接使用,程序能访问到这些字段数据类型对应的零值。
接下来设置对象的对象头(Object Header)信息,包括对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象GC分代年龄等。
接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
对象在内存中存储的布局可以分为3块区域:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding):
- 对象头包括两部分信息:第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例(并不是所有虚拟机都必须在对象数据上保留类型指针)。另外如果对象是一个Java数组,对象头中还必须有一块用于记录数组长度的数据。
- 实例数据部分是真正存储的有效信息,也是在代码中所定义的各种类型字段内容。无论是父类继承的还是子类中定义的都需要记录下来。这部分存储的顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
- 对齐填充不是必然存在的,主要是由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍。
对象的访问定位
句柄访问
Java 堆中划出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。其最大好处就是 reference 存储的是稳定的句柄地址,在对象被移到(垃圾收集时移到)只改变实例数据指针,而 reference 不需要修改。
直接指针访问
Java 堆对象的布局中必须考虑如果放置访问类型数据的相关信息,而 reference 中存在的直接就是对象地址。其最大好处在于速度更快,节省了一次指针定位的时机开销。HotSpot 采用该方式进行对象访问,但其他语言和框架采用句柄的也非常常见
参考资料
- 周志明. 深入理解Java虚拟机 : JVM高级特性与最佳实践 : Understanding the JVM : advanced features and best practices[M]. 机械工业出版社, 2013.