Java内存区域和内存溢出异常
Peng's Blog 只记录和技术相关的东西

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

2017-08-20
JVM
 

概述

Java把内存控制的权利交给了JVM,所以程序员不需要资源回收的情况。

但是,正是因为这种舒适区,一旦出现内存泄露和溢出方面的问题,如果不了解JVM是怎样使用内存的,那么排查错误将会成为一件非常艰难的工作。

运行时的数据区域

程序计数器(PC)

是一块较小的内存空间,可以看做是当前线程锁执行字节码的 行号指示器

字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、判断、跳转、异常处理等都需要依赖这个计数器来完成。

特性:

  • 当前线程所执行的字节码 行号指示器。
  • 每个线程都有一个
  • 线程私有,生命周期与线程相同,随JVM启动而生,JVM关闭而死
  • 如果执行的是Native方法,那么这个计数器的值则为空(Undefined)
  • 这是Java中唯一一个没有规定OutOfMemoryError情况的区域

Java虚拟机

主要存放的是:局部变量表、操作数栈、动态链接、方法出口等信息。

局部变量表主要包括:基本数据类型对象引用

(64位长度的long和double会占两个局部变量空间(Slot),其它都是占一个)

局部变量表所需的内存空间在编译期间完成分配,空间大小是确定的,在方法运行期间不会改变局部变量表的大小。

在Java虚拟机规范中,对这个区域规定了两种异常情况:1、如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;2、如果虚拟机栈可以动态扩展,如果扩展时无法申请足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈

作用与Java虚拟机栈相似,区别在于:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。(Native方法是指通过其他语言实现的方法,但也能在JVM上编译实现)

Heap,是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

主要是为了存放对象实例,几乎所有对象实例都在这里分配内存。(随着技术的发展,现在也不是所有对象实例都需要在堆上分配内存了。)

Java堆是垃圾回收器管理的主要区域,因此很多时候又被称作“GC堆”.. Garbage Collected Heap.. 垃圾收集堆(垃圾堆,哈哈哈)

Java堆可以在物理上不连续的内存空间中,只要逻辑上是连续的即可。

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

方法区

Method Area,与Java堆一样,是各个线程共享的内存区域。

存储已被虚拟机加载的类信息、常量、静态变量等。

它有个别名叫 Non - Heap(非堆),目的是和堆区分开来。

关于异常:当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池

Runtime Constant Pool 是方法区的一部分。

主要跟Class文件有关。Class文件中除了有类的版本、字段、方法、接口等描述信息之外,还有一项重要的信息叫做常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

具有动态性,也就是说并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量放入池中。这种特性的话,主要就是String类的intern()方法。

关于异常,既然它属于方法区,所以当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

Direct Memory 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但用的非常多,并且也可能产生OutOfMemoryError异常。

书上主要讲了Java1.4里面的NIO(New I/O),里面引入了一种基于通道和穿冲去的I/O方式:可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能明显的提高性能,因为它避免了在Java堆和Native堆中来回复制数据。

本机分配的直接内存不受Java堆大小的限制,但受机器总内存的影响。在动态扩展时还是有可能会出现OutOfMemoryError异常。

HotSpot虚拟机对象揭秘

对象的创建

从语言层面,对象的创建只需要使用 new 这个关键字就可以了,但是在JVM层面,可没有那么简单。

大概过程:

  • 当JVM遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程。

  • 类加载检查通过之后,JVM需要为新生对象分配内存,内存的大小在类加载完成后就已经可以确定了。

    关于内存分配有两个非常重要的知识点:1、指针碰撞,2、空间列表。如果Java堆中的内存是规整的,一遍空间一边已用,中间放个指针.. 指针移动一下就分配好了。这个就叫指针碰撞。但是很多时候空闲和非空闲内存相互交错,这时候指针碰撞就不可用了,这时候JVM必须维护一个列表 用来记录哪些内存是可用的。这种方式就是空闲列表。

    这里面还有可能会发生线程安全的问题,解决方案有两种:1、对分配内存空间的动作进行同步处理,2、把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一块小内存,称为本地线程分配缓冲(TLAB)。

  • 内存分配完成之后,JVM需要将分配到的内存空间都初始化为零值。

  • 对对象进行必要的设置,例如这个对象是哪个类的实例、对象的hashCode、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。

  • 至此,一个新的对象已经产生了,最后一步就是执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算安全的生产出来了。

对象的内存布局

对象在内存中存储的布局可以分成3块区域:对象头(Header)、实例数据(Instance Data)和 对齐填充(Padding)。

对象头主要包括两部分信息:

  • 用于存储对象自身的运行时的数据,比如hashCode、线程持有的锁等
  • 类型指针,即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

实例数据

对象真正存储的有效信息,也是在程序代码中所定义的各类型的字段内容。

对齐填充

这个倒不是必须要的,至少因为JVM的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,也就是说对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的整数倍,因此,当对象实例诗句部分没有对齐时,就需要对齐填充来补全了。

对象的访问定位

建立对象的目的是为了使用对象,我们的Java程序是通过栈上的reference数据来操作堆上的具体对象。换句话说就是:栈上放着对象的引用,这引用指向哪呢?指向堆。

那么如何根据引用来找到堆中具体的对象位置呢?目前主要有两种方式:

1、使用句柄。2、直接指针

句柄:

屏幕快照 2017-08-20 下午3.08.18

最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾回收时移动对象是非常普遍的行为)时只会改变句柄中的数据指针,而reference本身不需要修改。

直接指针

屏幕快照 2017-08-20 下午3.08.31

最大的好处就是快,它节省了一次指针定位的时间开销。


Comments

评论功能暂停使用,如需跟作者讨论请联系底部的GitHub