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

java与c++之间有一堵由内存动态分配和垃圾收集技术所围城的‘高墙’,墙外的人想进去。墙里面的人想出来

运行时的数据区域

Java虚拟机运行时数据区.jpg

程序计数器

程序计数器是一块较小的内存空间,它可以看做是当前线程执行的字节码行号的指示器。由于java虚拟机的多线程是通过线程轮流切换并分配处理器时间的方式来执行的,在任何一个确定的时刻,一个处理器都只会执行一个线程。因此,线程切换后为了能够恢复到正确的位置,每个线程都要有一个独立的程序计数器,各个线程的程序计数器独立存储,相互不影响,所以程序计数器这块内存是线程私有

Java虚拟机栈

与程序计数器一样,虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧,这个栈帧用于存储「局部表量表」,「操作数栈」,「动态连接」,「方法出口」等信息。每一个方法从调用到执行完的过程,对应着一个栈帧在虚拟机栈中进栈和出栈的过程。 > 局部变量表:存放了编译器可知的各种基本数据类型(boolean,byte,char等)、对象引用和returnAddress类型

这个区域规定了两种异常情况:

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

本地方法栈

本地方法栈与虚拟机栈发挥的作用十分类似,但本地方法是为虚拟机执行「Native方法」服务的,有的虚拟机(如HotSpot)就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,这个区域也有StackOverflowError和OutOfMemoryError异常。

Java堆

与程序计数器和虚拟进栈不同的是,堆是所有线程所共享的一块区域。这个区域的唯一目的就是存放实例对象 > Java虚拟机规范:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸技术的成熟,又变得不那么绝对了

从内存分配的角度来看,由于现在的收集器基本都采用分代收集算法,所以Java堆又可以划分成:

  • 新生代
    • Eden区域
    • From Survivor区域
    • To Survivor区域
  • 老年代

划分的目的是为了更好的垃圾回收,但是无论哪个区域,存储的都是对象实例。

Java堆可以处在不连续的内存空间中,只要逻辑上是连续的即可,可以是固定大小的,也可以是可扩展的。当对象实例没有完成实例分配,并且堆无法扩展时,就会抛出OutOfMemoryError异常。

方法区

首先方法区和Java堆一样,是所有线程共享的区域。它用于存储虚拟机加载的「类信息」,「常量」,「静态变量」等数据。当方法区的内存无法满足内存分配需求时,会抛出OutOfMemoryError异常。

很多人叫方法区为“永久代”,本质上并不等价,应该说是Hotspot虚拟机使用永久代的技术实现了方法区而已,现在看来不是个好主意,会导致内存更容易溢出,之后又用native memory实现方法区的规划了

运行时常量池

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

  • Java虚拟机对class文件每一部分都有严格规定,但对于运行时常量池,则没有任何细节要求
  • 一般来说,除了保存class文件中描述的「符合引用」外,还会把翻译出来的「直接引用」也存储在运行时常量池中
  • 运行时常量池相对于的另外一个重要特征是具备「动态性」

直接内存

直接内存既不是虚拟机运行区的一部分,也不是内存部分,但是这部分被频繁调用的话,也会导致OutOfMemoryError异常

HotSpot对象

对象的创建

  1. 当虚拟机遇到一条new指令是,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化过。如果没有,那么限制性这个类的相应的加载过程。
  2. 在类加载完成后,将为新生对象分配空间。对象所需内存大小在类加载完成后即可完全确定,为对象分配空间的任务等同于把一块与对象大小相等的内存从堆中划分出来。
    • 假设Java堆中的内存是绝对工整的,就是说所有用过的内存都放在一边,没有用过的都放在另一边,中间放着一个指示器作为分界点。那么分配内存的过程其实就是将指示器向空闲内存的一边移动对象大小的距离即可,这种分配方式称为「指针碰撞」。
    • 若堆内存不工整,那么虚拟机必须维护一个列表,记录哪些内存是可用的,哪些内存已经使用过了,在分配时候从空闲内存中找出一块足够大的给对象,这种分配方式称为「空闲列表」。

除了如何「划分内存空间」划分内存空间之外,另一个需要考虑的问题就是「对象创建在虚拟机中是一个非常频繁的行为」,包括两个 ,即使是一个简单的指针移动,在并发情况下也是不安全的。这种问题有两种解决方案:

  1. 将移动指针的操作进行同步处理;
  2. 把内存分配的动作按照每个线程划分在不同的区域中,即每个线程在Java堆预先划分出一块内存区域,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配。

「内存分配」完成后,虚拟机需要将分配到的内存空间都初始化为零值,这一步操作保证了对象的实例字段可以不赋初值就直接使用。虚拟机接下去对对象进行必要的设置,至于这个对象是哪个类的实例,如何找到元数据信息,对象的哈希码等信息都存放在对象的对象头之中。

上述工作完成后,一个新的对象实际上就已经产生了,但是需要执行init把对象按照程序员的意向初始化,这样才算完全产生一个对象。

对象的内存布局

在HotSpot虚拟机中,对象在内存中的存储布局分为三个部分:

  • 对象头
    • 第一部分用于存储对象运行时数据(如哈希码,GC年代,锁状态标识等)
    • 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例。
  • 实例数据
    • 实例部分存储的是对象真正有效的信息,也就是在程序代码中所定义的各种类型字段的内容。
  • 对齐填充
    • 对齐填充并不是必然的存在,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,当对象实例数据部分没有对齐时,就需要通过对齐填充来不全。

对象的访问定位

Java程序需要通过上的reference数据来操作上的具体对象,由于reference在Java虚拟机规范中值规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位,访问堆中的具体位置,所以对象访问方式也是取决于虚拟机具体实现的。目前主流的方式是句柄直接指针

  1. 句柄:Java堆中将会划分出来一块内存作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址。

1348659242_7055.jpg 2. 直接指针访问:那么reference中存放的就是对象地址。

1348658605_5211.jpg

两种访问对象的方式,使用各有优势

  • 句柄来访问最大的好处就是reference中存储的句柄地址是稳定的,不会随着对象的移动而移动
  • 直接指针访问的最大好处就是访问速度快 > 就HotSpot而言,它是使用直接指针方式访问数据的