[原]JVM内存区域以及内存分配策略

李猛 18/08/08 08:20:41

一、运行时的数据区域

Java虚拟机运行时的数据区

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。每个线程都有一个独立的程序计数器,彼此间计数器互不影响,独立存储,即是“线程私有”的内存。

在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、跳转、循坏、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

当线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是Native方法,这个计数器的值则为空,此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Native关键字说明其修饰的方法是一个原生态的方法,方法对应的实现不是在当前文件,而是在用其他语言(入C、C++等)实现的文件。Java语言本身不能对操作系统底层进行访问和操作,但可以通过JNI(Java Native Interface)调用其他语言实现对底层的访问。

Java 虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)是线程私有的,生命周期与线程相同。它描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本的数据类型、对象引用和returnAddress类型。

  1. 基本数据类型:boolean、byte、char、short、int、float、long、double。其中64位长度的long和double类型的数据会占用2个局部变量空间(slot),其余数据类型只占一个。局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中需要分配多大的局部变量空间是确定的,在方法运行间不会改变局部变量表的大小。
  2. 对象引用:reference类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置。
  3. returnAddress类型:指向了一条字节码指令的地址。

Java虚拟机规范对这个区域规定了两种异常状况:

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

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈的作用类似,区别只是虚拟机栈为虚拟机执行Java服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,具体虚拟机可以自由实现。

甚至有的虚拟机将本地方法栈和虚拟机栈合二为一,与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

Java 堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。所有的对象实例以及数组都要在堆上分配,随着技术的发展,这标准也不是那么“绝对”了。

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

  1. 从内存回收角度来看:现在的收集器基本都采用分代收集算法,Java堆还可以细分为新生代与老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
  2. 从内存分配的角度来看,线程共享的Java堆可能划分出多个线程私有的分配缓冲区(TLAB)。

不论如何划分,存储的仍然都是对象实例,进一步划分是为了更好的回收内存或更快的分配内存。

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

方法区

方法区(Method Area)是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它有个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池

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

运行时常量池除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。它还具有动态性,在运行期间也可能将新的常量放入池中(比如String类中的intern方法)。

  • 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要在使用时能够无歧义的定位到即可。
  • 直接引用:
    1. 直接指向目标的指针(指向类型、类变量、类方法的直接引用可能是指向方法区的指针)
    2. 相对偏移量(指向实例变量、实例方法的直接引用都是偏移量)
    3. 一个能间接定位到目标的句柄。

当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

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

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。避免了在Java堆和Native堆中来回复制数据,显著提高了性能。

本机直接内存不会受到Java堆大小的限制,但是会受到本机总内存大小和处理器寻址空间的限制。


二、HotSpot虚拟机对象探秘

对象的创建

  1. 类加载检查,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程。
  2. a. 为新生对象分配内存,常用的分配方式有“指针碰撞”(Bump the Pointer)和“空闲列表”(Free List),选用的分配方式由Java堆是否规整决定,而Java堆是否规整取决于垃圾收集器是否带有压缩整理的功能。在并发情况下不是线程安全的,要解决这个问题,一种是对分配内存空间的动作进行同步处理(实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性),另一种是把内存的分配的动作按照线程划分在不同不空间之中,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程需要分配空间就在哪个线程的TLAB上分配,当其用完需要扩展时,才需要同步。
    b. 内存分配完成,将分配到的内存空间都初始化为零(不包括对象头)。
  3. 设置对象头(对象哈希码、分代年龄等)
  4. 这时new指令已经执行完成,一般要执行init方法,把对象按照程序员的意愿进行初始化。

对象的内存布局

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

对象头

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,另一部分是类型指针。

存储对象自身的运行时数据:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark Word”。Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个实例的类。

实例数据

在代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。

这部分的存储顺序会受到虚拟机的分配参数策略和字段在Java源码中定义顺序的影响。在HotSpot中,相同宽度的字段总是被分配到一起,在满足这个前提条件下,在父类中定义的变量会出现在子类之前;如果CompactFields为true(默认为true),那么子类之中较窄的变量也可能会插到父类变量的空隙之中。

填充对齐

不是必然存在的,仅仅起着占位符的作用。HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整倍数,也就是对象的大小必须是8字节的整倍数,不够就要通过对齐填充来补全。

对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。

  1. 使用句柄访问,在Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
  2. 使用直接指针访问,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

两种访问方式各有优势:

使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中十分频繁,因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot就是采用直接指针来进行对象访问的。


三、内存分配策略

自动内存管理解决了两个问题:分配内存以及回收内存。

为对象分配内存,从整体来看,就是在堆上分配。下图是堆的示意图:
新生代与老年代

新生代中Eden区与一个Survivor区默认的比值为8:1,可以用-XX:SurvivorRatio参数来指定。JVM每次只会用Eden和其中一块Survivor区来为对象服务,所以无论什么时候,总是有一块Survivor区域是空闲的。因此,新生代实际可用的内存空间为9/10的新生代空间。

新生代GC(Minor GC):指发生在新生代的垃圾回收动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(非绝对),Major GC的速度一般会比Minor GC慢10倍以上。

内存分配的规则取决于当前使用的是哪种收集器组合和虚拟机中与内存相关的参数设置。

下面是几条普遍的内存分配原则:

  1. 对象优先在Eden分配

    大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

  2. 大对象直接进入老年代

    大对象指大量连续内存空间的Java对象。最典型的大对象就是那种很长的字符串以及数组,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

    虚拟机提供了一个-XX:PretenureSizeThreshold参数,大于这个设置值的对象直接在老年代分配,避免在Eden区及两个Survivor区之间发生大量的内存复制。

  3. 长期存活的对象直接进入老年代

    当对象在Eden(包括一个Survivor,假设为from区域)出生后,如果对象还存活,并且能够被另外一块Survivor区域(to区域)所容纳,则使用复制算法将这些存活的对象复制到另一块Survivor区域(to区域),然后清理使用过的Eden和Survivor区域(from区域),并且将对象的年龄+1。对象每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。可以通过-XX:MaxTenuringThreshold设置对象晋升老年代的阀值。

  4. 动态对象年龄判定

    为更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象的大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

  5. 空间分配担保

    如果老年代最大可用空间大于新生代所有对象总空间,Minor GC可以确保是安全的。如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,检查老年代的最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,若大于就尝试进行一次Minor GC(尽管有风险),若小于或HandlePromotionFailure设置值不允许冒风险就进行一次Full GC。

    当出现大量对象在Minor GC后仍然存活的情况,就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。老年代进行担保的前提是老年代本身还有容纳这些对象的剩余空间,每一次取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

    取平均值其实仍然是一种动态概率的手段,依然有可能导致担保失败重新发起Full GC。大部分情况下将HandlePromotionFailure开关打开,避免Full GC过于频繁。

    在JDK 6 Update 24之后,HandlePromotionFailure参数不会再影响虚拟机的空间担保策略,这时规则变为:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC。

关于内存回收涉及回收算法及收集器相关的知识,可以参考我的其他博文:

JVM中的垃圾对象以及相关清除算法
JVM各种垃圾收集器的简介


参考

《深入理解Java虚拟机》 第二版 周志明

Java中native的用法
java堆,新生代,老年代,Eden空间,From Survivor空间,To Survivor空间

作者:ldx19980108 发表于 2018/08/08 08:20:41 原文链接 https://blog.csdn.net/ldx19980108/article/details/81501493
阅读:18