堆内存区

Java虚拟机中,堆内存区用于分配所有对象实例以及数组的内存空间,Java虚拟机规范中规定,堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的。一个JVM实例只存在一个堆内存,它的大小在创建时就会确定,不过具体占用的空间可以在指定的上下限之间自动调整。

对于堆内存中分配的空间,在一个方法结束后(栈帧弹出),堆中的对象不会马上被移除,而是在垃圾收集(GC)的时候才会被检测判断是否应移除。

堆内存的划分

如图中所示,在HotSpot虚拟机中,堆内存被进行了分代的划分,这是针对垃圾回收性能的一种优化。

在HotSpot中,堆内存被划分为新生代(Young)老年代(Old),新生代又分为Eden(伊甸园)Survivor0(幸存者0区,一般简称S0)Survivor1(幸存者1区,一般简称S1)。新生代的垃圾回收更加频繁,老年代进行垃圾回收的次数则较少。S0和S1区也叫做fromto区(两者位置可能互换),主要用于在新生代和老年代之间转移对象,后面会详细介绍对象在这几个堆内存区的分配流程。

固然我们可以不对堆内存进行任何划分,每次垃圾回收都将整个堆检测一遍,但这样显然效率不够高。实际上,Java代码中90%以上的对象创建后就很快会被销毁,而有一部分例如单例对象等则生命周期和JVM一样长,重复对其进行垃圾检测没有意义,分代垃圾回收实现了对生命周期长的对象执行更少次数的垃圾检测,以节省CPU资源减少GC停顿时间。

注:

  1. 方法区(Java7及之前叫永久代,Java8及之后叫元数据空间)理论上也属于堆内存,但相关的配置参数,以及垃圾回收的相关规则主要是针对新生代和永久代的,因此这里就不把它们放在一块了,有关方法区内存将在后面章节详细介绍。
  2. 至于为什么新生代划分为Eden、S0、S1,这是由于新生代的垃圾收集算法一般采用标记复制算法(后面章节会介绍),这种收集策略需要划分内存交换区域;而老年代一般采用标记压缩算法,因此就没有这样的划分了;新生代和老年代选择两种不同的垃圾回收算法和其中对象的生命周期长度有关。

堆内存相关JVM参数

设置堆内存的总大小

JVM启动时堆空间的大小就已经指定,堆空间的实际大小会在指定值之间自动调整,但如果需要分配的对象空间超出了堆内存的可用大小,就会报错OutOfMemoryError。下面两个JVM参数设置了堆内存的整体大小(即新生代+老年代)。

-Xms:也可以写作-XX:InitialHeapSize,堆空间的起始值,默认为计算机物理内存的1/64

-Xmx:也可以写作-XX:MaxHeapSize,堆空间的最大值,默认为计算机物理内存的1/4

具体参数值可以用带kmg的数字指定,如-Xms512m表示堆空间大小起始值为512兆字节。

注:

  1. 在实际开发中,如果我们使用容器化部署Java程序,要注意尽量选择8u121版本后的JDK,或是手动设置堆内存大小,因为这个版本之前的JDK无法识别容器的内存限制参数,而是会基于物理机的内存自动设置堆大小,这可能造成JVM超过容器内存限制而被重启。
  2. 实际开发中,一个技巧是将-Xms-Xmx设置为同样大小的值,毕竟服务器内存不值钱,这样能够避免堆空间频繁调整带来的性能开销。

分代大小的调整

对于几个分代的大小划分,JVM也提供了一些参数用于手动调整。

-XX:NewRatio:新生代所占堆内存比例,默认为-XX:NewRatio=2即新生代占1,老年代占2,新生代占堆内存的1/3。

-Xmn:手动设置新生代的内存大小,其功能和上面的-XX:NewRatio是重复的,一般不设置此参数而是采用比例的方式进行设置。

-XX:SurvivorRatio:S0和S1所占内存比例,默认为-XX:SurvivorRatio=8即Eden:S0:S1=8:1:1。

对象实例分配流程

在JVM中为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。前面我们介绍过了堆内存的分代思想,这里我们再对对象在堆内存中的分配流程进行详细描述。

  1. new生成的对象不断放入Eden区。
  2. Eden区填满时,程序又需要创建对象,此时JVM的垃圾回收器将对Eden区进行GC,将其中不再被其它地方引用的对象销毁。
  3. 将剩余的对象移动到S0区,新对象放入Eden区。
  4. 如果再次触发Eden区的垃圾回收,将Eden区剩余对象和S0内对象放入S1区。
  5. 如果再次触发Eden区的垃圾回收,将Eden区剩余对象和S1内对象放入S0区,S0和S1交替存储对象,我们也可以将其称为一对fromto区。
  6. S0或S1中对象达到指定生存次数后(默认为15次),将被移动到老年代,老年代的GC频率相比新生代就低很多了。

当然上面描述的是理想情况,如果我们创建了一个很大的对象,甚至其大小超出了Eden区,这个对象也有可能直接进入老年代。我们在实际开发中应该尽量避免频繁创建短生命周期的大对象,因为老年代的GC相比新生代造成的停顿时间更长,使得我们程序的整体性能变差。

GC模式

前面我们介绍过,JVM的GC并非一定要在整个堆上进行,大部分GC都发生在新生代。对于HotSpot,根据选用的垃圾收集器不同可以分为不同的垃圾收集模式:新生代的Young GC(也叫Minor GC),老年代的Major GC,全堆(包括方法区)的Full GC。

Young GC会在Eden区空间不足时触发,由于Java中大部分对象都是短生命周期的,因此Young GC也是十分频繁的,速度也较快。Young GC会引起STW(Stop The World)暂停其他用户的线程等待垃圾回收结束。

Full GC出现时经常伴随至少一次Young GC,老年代空间不足时才会触发Full GC,它比Young GC慢10倍以上,STW的时间更长。如果Full GC之后堆内存仍然不足,就会抛出OOM错误。Full GC在开发中应该尽量避免,以减少STW时间对用户的影响。

至于Major GC的概念,在垃圾收集器的具体实现中,Major GC都是Young GC(Minor GC)触发的,所以实际上没有什么单独的Major GC,因此我们一般只关注Young GC和Full GC即可。

注:观察GC情况时,我们可以加入JVM参数-XX:+PrintGCDetails,此时会输出详细的GC日志。

对象实例存储结构

前面介绍栈时我们知道局部变量存在于栈帧上的局部变量表中,对于自定义类型的变量局部变量表中只保存对象的引用地址,而这个引用就会指向堆内存中的具体对象。下图展示了一个对象实例在堆内存中的数据结构。

一个对象在堆内存中主要存在2部分数据:对象头和实例数据(至于对齐填充是用于内存对齐而填充的空数据,这一部分也可能是不存在的,这里就不多解释了)。对象头中包含对象的哈希值,GC分代年龄,锁状态标志等运行时信息,除此之外还包含了指向类型信息的指针指向元数据区。实例数据就是具体的类字段等,这里还会包括父类的实例数据,如果实例数据中包含引用类型,那么它还会指向堆内存中其它对象实例。

堆内存的优化机制

实际上,HotSpot对堆内存的分配还有一些优化机制,这里我们简单介绍一下。

TLAB区

我们知道堆是线程共享的内存区域,那么多线程在堆中分配内存时就会产生线程安全问题,然而堆内存分配在Java中是极为频繁的,每次堆内存分配都加锁十分影响性能,此时提出了TLAB(Thread Local Allocation Buffer)区的概念。

TLAB区其实十分简单,就是在Eden区专门划分一片内存,针对每个线程都分配一个互不干扰的区域,线程分配堆内存时优先在TLAB区分配,此时也无需加锁;只有当TLAB区满后,再进行常规的堆内存加锁分配,这样就达到了提升内存分配吞吐量的目的。

默认情况下,HotSpot开启了TLAB区功能。

对象栈上分配

如果熟悉C++,我们肯定了解栈上其实也可以分配对象,而且这个操作也十分常用。C++中如果对象仅作为局部变量在栈内使用,将其分配在堆上无疑是个费力不讨好的操作。虽然Java是个相对“傻瓜式”的编程语言,没有能够手动分配栈上对象的语法,但JVM中存在一个“逃逸分析技术”,能够自动帮我们将合适的对象分配到栈上,甚至将对象的内容打散分配,我们知道JVM的操作数栈还做了栈顶缓存优化,因此实际上我们没必要太担心JVM在堆上分配所有对象是否开销过高的问题。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap