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

前言

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

运行时数据区域

运行时数据区域就是指虚拟机在运行java程序时,把虚拟机自己管理的内存划分为若干个不同的数据区,如下图:

程序计数器

程序计数器是线程私有;

程序计数器是较小的一块内存,用于记录当前线程执行到哪一步;

如果执行的是java方法,则程序计数器记录的是正在执行的虚拟机字节码指令的地址;

如果执行的是native方法,则程序计数器为空;

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

java虚拟机栈

线程私有;

描述java方法执行的内存模型,每个方法在执行时,都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链表、方法出口等信息;

每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈出栈的过程;

此内存区域会出现两种异常状况:

如果线程请求的栈深度大于虚拟机栈允许的深度,则抛出StackOverflowError异常;

如果虚拟机栈可动态扩展,且在扩展时无法申请到足够的内存时,则抛出OutOfMemoryError;

本地方法栈

线程私有;

与虚拟机栈基本一致,唯一不同的地方在于:虚拟机栈中执行的是java方法,而本地方法栈中执行的是native方法

java堆

线程共享;

java堆是虚拟机内存管理中最大的一块区域,虚拟机启动时创建;

几乎所有的对象实例都在堆内存中分配;

java堆可以处于物理上不连续的空间中,只要逻辑上是连续的即可;

如果堆中没有内存分配去完成实例化,且堆无法扩展时,将会抛出OutOfMemoryError;

方法区

线程共享;

用于存储虚拟机加载的类信息、常量、静态变量、字节码文件等;

当方法区无法分配内存时,则抛出OutOfMemoryError;

运行时常量池

运行时常量池是方法区的一部分;

用于存储编译时期产生的字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放;

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

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。但是这部分内存被频繁使用,而且也可能导致OutOfMemoryError;

JDK1.4中新加入的NIO(New Input/Output)类,引入一种基于通道(Channel)与缓冲区(buffer)的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在java堆中DirectByteBuffer对象作为这块内存的引用进行操作;

直接内存不会受java堆大小的限制,但是,受本机总内存大小以及处理器寻址空间的限制;

服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各区域内存总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError;

HotSpot虚拟机中对象

对象的创建

检查类加载是否完成:虚拟机遇到new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引号,并且检查这个符号引用代表的类是否已被虚拟机加载、解析、初始化过,如果没有,那必须先执行相应的类加载过程;

为新生对象分配内存

  1. 为对象分配内存,相当于把一块确定大小的内存从java堆中划分出来。假设java堆内存时规整的,所有已用内存一边,未用内存一边,中间放置一个指针作为分界点指示器,那分配内存就是指针想空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”;如果java堆不规整,已使用和未使用内存相互交错,这时就不能指针碰撞来分配内存,而是需要维护一张列表,记录哪些内存可用,在分配时从列表中找出一块足够大的空间划分给对象实例,并更新列表的记录,这种分配方式叫做“空闲列表”,至于选择哪一种分配方式,取决于java堆内存是否规整,而这又取决于所采用的垃圾收集器是否带有压缩整理功能决定的。
  2. 解决分配内存时的并发问题:一种方法是对分配内存的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓存(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来决定

初始化对象分配到的内存空间

虚拟机设置对象:例如对象是哪个类的实例、如何找到类的元数据信息、对象的额哈希码、对象的GC分代年龄等信息;

对象的内存形态

对象在内存中的布局可以分为三个部分:对象头、实例数据、对齐填充

对象头:对象头包含两部分数据——

  1. 第一部分用于存储对象运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别位32bit和64bit,官方称为“Mark Word”。
  2. 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据:对象真正存储的有效信息,也是程序中定义的各种类型的字段内容;

对齐填充:不是必然存在的,也没有特别的含义,他仅仅起着占位符的作用。由于HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象大小必须是8字节的倍数。而对象头部分正好是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全;

对象的访问定位

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

使用句柄

使用句柄访问的话,java堆将会划分出一块内存来作为句柄池,refrence中存放的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,这样做的好处是,无论对象怎么被移动(垃圾收集时经常发生移动),refrence本身不会变化,只需要修改句柄中的实例数据地址即可,如下图

 

直接指针访问

这种情况需要考虑的是,在java堆中对象布局如何放置对象的类型数据信息,而refrence中存储的直接就是对象的实例数据地址信息,这样做的好处是,少了一次指针定位的开销,如下图:

 

 

posted @ 2019-02-26 20:25  ^^ITBOY^^  阅读(393)  评论(1编辑  收藏  举报