您的位置:首页 > 编程语言 > Java开发

[JVM] 1.1 自动内存管理机制:java内存区域与内存溢出异常

2019-04-14 21:34 543 查看

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

  • 2. HotSpot虚拟机对象

  • 本篇博客内容基本出自《深入理解java虚拟机》
      Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。
      本章从概念上介绍Java虚拟机内存的各个区域,讲解这些区域的作用、服务对象以及其中可能产生的问题,这是翻越虚拟机内存管理这堵围墙的第一步。

    1.运行时数据区域

    Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域

    1.1 程序计数器

          程序计数器(Program:Counter Register、)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条执行字节码指令。
      在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令,因此,分为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器。各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“”线程私有“的内存。
      如果执行的是java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址。如果是native方法,计数器为空。此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

    1.2 Java虚拟机栈

          Java虚拟机栈(Java Virtuai Machine Stack)同样是线程私有的,虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。一个方法对应一个栈帧。
      局部变量表存放了各种基本类型、对象引用(reference类型)和returnAddress类型(指向了一条字节码指令地址)。其中64位长度long 和 double占两个局部变量空间(Slot),其他只占一个。当进入一个方法时, 这令方法需要在帧中分配多大的局部变量空间是完全确定的(编译期间完成分配),在方法运行期间不会改变局部变量表的大小。
      规定的异常情况有两种:1.线程请求的栈的深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;2.如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,就抛出OutOfMemoryError异常。

    1.3 本地方法栈

          本地方法栈(Native Method Stack)与虚拟机栈非常相似,但是是为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方 式与数据结构并没有强制规定,,甚至有的虚拟机(譬如 Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

    1.4 Java堆

          Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。由所有线程共享,在虚拟机启动时创建。堆区唯一目的就是存放对象实例(以及数组)。
      Java堆是垃圾收集器管理的主要区域,也被称做“GC堆’’。从内存回收的角度来看,由于现在收集器基本采用分代收集算法,堆中可细分为新生代和老年代,再细分可分为Eden空间、From Survivor空间、To Survivor空间。内存分配的角度来看,可能划分出多个线程私有的分配缓冲区。
      堆中没有内存完成实例分配,也无法扩展时,抛出OutOfMemoryError异常。

    1.5 方法区

    所有线程共享,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
      当方法区无法满足内存分配需求时,抛出OutOfMemoryError。

    1.6 运行时常量池

    运行时常量池(Runtime Constant Pool’) 是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池(Const Pool Table),用于存放编译期生成的各种字面量和符号引用。并非预置入Class文件中常量池的内容才进入方法运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

    1.7 直接内存

    并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。
      JDK1.4加入了NIO,引入一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。因为避免了在Java堆和Native堆中来回复制数据,提高了性能。
      当各个内存区域总和大于物理内存限制,抛出OutOfMemoryError异常。

    2. HotSpot虚拟机对象

    以常用的虚拟机HotSpot 和常用的内存区域Java 堆为例,深入探讨HotSpot 虚拟机在Java 堆中对象分配、布局和访问的全过程。

    2.1 对象的创建

    过程:虚拟机遇到一条new指令时,首先将先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。接着虚拟机将为新生对象分配内存。对象所需的内存大小在类加载的完成后即可完全确定,为对象分配空间的任务就是把一块确定大小的内存从Java中划分出来。

    1. 两种内存分配方式

      A、指针碰撞

      定义:假设Java堆中的内存时绝对规整的,所有用过的内存都放在一遍,空闲的内存放在一遍,中间放着一个指针作为分界线,当你需要为一个对象分配内存空间的时候,只需要把这个指针往空闲的区域挪一点点即可。

      要求:Java堆中的内存时绝对规整的——这个条件是很苛刻的,一开始运行程序的时候我们可以规整(按顺序)为数据分配内存,但是在程序的运行过程中,有一些数据被删除,释放出原来的空间,这样子内存空间就变得不规整了。

      B、空闲列表

      定义:如果Java堆中的内存是不规整的,即已使用过的内存和空闲的内存相互交错,那么我们就需要一个表来记录哪些区域是存有数据的,哪些区域是空闲可用的。当需要给新数据分配空间的时,就查询这个空闲列表,找到一块足够大的空间划分给对象实例,并更新列表上的记录。

      具体选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由相应的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等待Compact过程的收集器时,系统采用的分配算法时指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

    2. 问题二——线程安全问题

      对象创建在虚拟机中时非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发的情况下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

      解决方案:

      A、对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
      B、把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓存。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UserTLAB参数来设定。
      .
        内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。作用:这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
        接着,虚拟机会对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码等信息。对象的GC 分代年龄等信息。这些信息存放在对象的对象头之中。
        在这些工作完成后,从虚拟机的角度看,一个新的对象那个已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——方法还没执行,所有的字段都还是零值。一般来说,执行new指令之后会接着执行方法,把对象按照程序的意愿进行初始化,这样一个真正可用的对象才算完全生产出来了。流程图(转载,侵删)如下:
        

    2.2 对象的内存布局

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

    1. 对象头包含的信息:
      A、第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等,官方称它为"Mark Word"。考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间中存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
      B、另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针。换句话说,查找对象的元数据信息并不一定要经过对象本身。(如果对象是一个Java数组,那在对象头信息中还必须要有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中却无法确定数组的大小)。

    2. 实例数据——对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。存储顺序会受两个因素影响
      A、虚拟机分配策略参数HotSpot虚拟机的分配策略
      B、和字段在代码中定义顺序的影响
      HotSpot虚拟机默认的分配策略为(longs/doubles,ints,shorts/chars,bytes/booleans,opps); 从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足前一个条件的情况下,在父类中定义的变量会出现在子类之前,如果CompactFields的参数值为true(默认为true),那么子类中较窄的变量也可能会插入到父类变量的空隙中。

    3. 对齐填充——并不是必然存在的,也没有特别的含义,仅仅是起着占位符的作用(对象起始地址必须是8字节的整数倍)。方便读取。

    2.3 对象的访问定位

    建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有两种:使用句柄和直接访问。

    1. 句柄访问
      java堆中会划分出一块内存来作为句柄池。reference中存储就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。示意图如下:

      对象类型数据就是被虚拟机加载的类信息(即Class信息)。定义了一个类的元数据、它包含的成员等。
      对象实例数据是指基于某个类new出来的一个或多个具体实例。实例要访问它对应的类信息时,通过一个指针指向另一个保存类型信息的内存区。

      好处:reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不用改变。

    2. 直接指针访问
      java堆对象的布局中必须考虑如何设置访问类型数据的相关信息,而reference中存储的直接就是对象地址。HotSpot使用的就是这种访问方式。示例图如下

      好处:速度更快,它节省了一次指针定位的时间开销,由于对象的访问在java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: 
    相关文章推荐