JVM之内存区域与内存溢出异常

概述

与C++程序员不同,Java程序员把内存的控制权交给了Java虚拟机,一旦出现内存泄露和溢出等方面的问题,了解Java虚拟机就显得特别重要,排查错误需要对Java虚拟机的内存管理机制有所了解。

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

运行时数据区分为:所有线程共享的数据区和线程隔离的数据区。

线程隔离的数据区有:程序计数器、Java虚拟机栈、本地方法栈。

所有线程共享的数据区有:方法区和堆。

下面进行一一介绍:

程序计数器

程序计数器是一块较小的内存空间,通俗的讲它就是当前线程所执行的字节码的行号指示器,概念模型中,字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。特点:各条线程都有一个特有的程序计数器,独立存储。

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

虚拟机规范中唯一没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

线程私有的,生命周期同线程一样。描述的是Java方法执行的内存模型:

每个方法在执行的时候会创建一个栈帧(Stack Frame)用于存储
局部变量表
操作数栈
动态链接
方法出口等信息。

每个方法从调用直到完成的过程,对应这一个栈帧在虚拟机栈中入栈到出栈的过程。

我的声音: 虚拟机栈中存放了一个挺重要的东西,叫局部变量表。局部变量表中包含了:

  • 编译器可知的各种基本数据类型(boolean、btye、char、short、int、float、long、double)
  • 对象引用(reference类型)(不是对象本身,可能是一个指向对象起始地址的引用指针,也可能是与此对象相关的位置)
  • returnAddress类型(指向一条字节码指令的地址)

注: 其中64位长度的long和double类型的数据会占2个局部变量空间,其余的占1个.并且局部变量表所需的内存空间在编译期间完成分配.进入一个方法时,这个方法需要在帧中分配的空间是完全确定的.方法运行过程中不会改变局部变量表的大小.

该区域存在两个异常状况:

  1. 如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverflowError异常.
  2. 如果虚拟机栈可以动态拓展,并且拓展时无法得到足够的内存,就会抛出OutOfMemoryError异常.

本地方法栈

本地方法栈(Native Method Stack) 与虚拟机栈所发挥的作用非常类似,它们的区别在于:虚拟机栈为虚拟机执行Java方法(字节码)服务;而本地方法栈则为虚拟机用到的Native方法服务.(虚拟机规范对其没有强制规定,例如Sun HotSpot虚拟机把虚拟机栈与本地方法栈合二为一).

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

Java堆

Java Heap特别重要,是虚拟机所管理的内存中最大的区域,并且被所有线程所共有,用于存放对象实例,几乎所有的对象实例都在这里分配内存.

Java堆是GC管理的主要区域.虚拟机规范规定,Java堆可以是物理上不连续的内存空间.

如果堆中没有内存用来完成实例的分配,并且无法再拓展,就会抛出OutOfMemoryError异常.

方法区

Method Area与Java堆一样,是线程共享的内存,用于存储已经被虚拟机加载的:

  • 类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码
  • 等等数据

我的声音: 方法区中的数据很难被GC回收,回收主要是常量池的回收和对类型的卸载,代码中直接写的"字符串",1,true等等就会在方法区中保存.

运行时常量池

Runtime Constant Pool是方法区的一部分.详情看书<<深入理解Java虚拟机>>.

直接内存

Direct Memory 详情看书<<深入理解Java虚拟机>>.

HotSpot虚拟机对象探秘

移HotSpot虚拟机为例,探讨其在Java堆中对象分配,布局和访问全过程.

对象的创建

下面说的对象是普通对象,并不包括:数组和Class对象等.

当虚拟机遇到了一个new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查该符号引用代表的类是否已经被加载,解析和初始化过.如果没有就会先执行相应类的类加载过程.

类加载并且检查通过后,虚拟机将会给新生的对象分配内存.内存大小在类加载完成后就完全确定,(分配内存就是从Java堆中划出一个已定大小的区域给此对象实例),分配主要有两种方式:

1.指针碰撞

如果Java堆中内存是规整的,就像一个整齐的仓库,已用的空间整齐的摆放着实例对象,中间放一个指针用于分界点的指示器,已用和未用的空间泾渭分明.有新的对象需要加入时,就把指示器移动一定大小德空间,然后再把对象放入进去.

2.空闲列表

如果Java堆中内存是不规整的,就像一个”杂乱的仓库”,可用空间和不可以空间交错,此时虚拟机就要维护一个列表,记录了哪些区域是可用的.分配空间的时候,从列表中找到一块足够大的区域划分出去,再更新列表的记录即可.

这两种方式主要由Java堆是否规整来决定,如果规整就用指针碰撞,否则用空闲列表;而是否规整又由采用的垃圾收集器是否带有压缩整理功能决定.

划分空间也是门学问,虚拟机中频繁有对象产生和销毁,A和B同时需要分配内存时,虚拟机需要解决个冲突的问题.解决这个问题有两个方案.

  1. 对分配内存空间的动作进行同步处理.
  2. 每个线程有一小块内存区域,称为本地线程分配缓存,不同线程的操作区域不同,当一小块满了后才需要同步锁定.

内存分配完成后,虚拟机需要将分配完成的空间初始化为零值,接下来虚拟机对对象进行设置.完成了后对于虚拟机来说,一个对象产生了.但是对于java程序还没结束,init方法执行后,初始化完成后才能算是对象被new出来了.

对象的内存布局

对象的访问定位

Java虚拟机栈中的本地变量表中,有reference类型的对象引用,根据它的指向来确定是代表那个实例对象,目前有两种主流的访问方式,分别为:

使用句柄

本地变量表中的reference类型先指向Java堆中的句柄池,句柄中包含了对象实例数据与类型数据各自的具体地址,这些地址指向了Java堆中的对象或者方法区中类型数据.

使用直接指针

本地变量表中的reference类型直接指向Java堆中的实例对象,如果实例对象包含了类型数据,则由对象实例中的类型数据指针指向方法区中的对象类型数据.

这两种方法各有优势,使用句柄访问好处是:reference中存储的是稳定的句柄地址,在对象被移动的时候(垃圾回收时需要整理内存,所以要移动),只要改版句柄中的实例数据指针,而reference本身不需要改变.

使用直接指针的好处在于,速度快,虚拟机中的定位需要耗时,它节省了指针定位的时间开销.注意:目前主要用的是直接指针,HotSpot也是,使用句柄比较罕见.

实战:OutOfMemoryError异常