深入理解JVM内存结构—堆(Heap)

1、堆(Heap)概述

Java堆(Java Heap)是JVM所管理的最大的一块内存空间。Java堆是被所有线程共享的一块内存区域,在JVM启动的时候创建堆的唯一目的就是存放对象实例的,Java中几乎所有的对象实例和数组都在堆上分配内存,但是随着JIT编译器的发展与逃逸分析技术的成熟,桟上分配、标量替换等优化技术使得对象可以在桟帧中直接分配。Java堆的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。Java堆所使用的内存亦不需要保证是连续的。

Heap 用来存储数对象实例的,例如:

//new Student()正常情况会在堆内存中开辟空间,然后把对象的地址地址返回,stu引用了它
Student stu = new Student();  

首先JVM 会在堆中分配出Student 对象存储的内存区域,并将地址返回,然后再在JVM Stack 中的局部表量表中创建Student 对象的引用用来存放Heap 分配的Student 地址。之后就可以在程序中使用栈中的引用对象来访问堆中的数组或对象。当使用完对象后,我们不必显式的管理堆内存释放工作,堆内存的释放会由GC(垃圾收集器)自动完成。


图1 对象的分配和访问

Java堆是垃圾回收器工作的主要区域,因此堆也被称为“GC堆”(Garbage Collected Heap)。从垃圾回收的角度来看,由于早期的收集器基本采用分代收集算法,所以Java堆可以细分为:新生代(Young/New Generation)和老年代(Old/Tenured Generation 老年代)。新生对象放置在新生代中,新生代由Eden空间Survivor From空间Survivor To空间 组成;老年代用于存放程序中经过指定次数垃圾回收后还存活的对象。


图2 堆内存分代模型
(1)Young/New Generation 新生代

程序中新建的对象都将分配到新生代中,新生代又由Eden(伊甸园)与两块Survivor(幸存者) Space 构成。Eden 与Survivor Space 的空间大小比例默认为8:1,即当Young/New Generation 区域的空间大小总数为10M 时,Eden 的空间大小为8M,两块Survivor Space 则各分配1M,这个比例可以通过-XX:SurvivorRatio 参数来修改。Young/New Generation的大小则可以通过-Xmn参数来指定。

Eden:刚刚新建的对象将会被放置到Eden 中,这个名称寓意着对象们可以在其中快乐自由的生活。
Survivor 空间:幸存者区域是新生代与老年代的缓冲区域,两块幸存者区域在逻辑上分别为s0 与s1。

当触发Minor GC 后将Eden区中仍然存活的对象移动到S0中去(From Eden To s0)。这样Eden 就会被清空可以分配给新的对象。当再一次触发Minor GC后,S0和Eden 中存活的对象被移动到S1中(From s0 To s1),S0即被清空。在同一时刻, 只有Eden和一个Survivor Space同时被操作。所以s0与s1两块Survivor 区同时会至少有一个为空闲的,这点从下面的图中可以看出。
       当每次对象从Eden 复制到Survivor Space 或者从Survivor Space 之间复制,计数器会自动增加其值。 默认情况下如果复制发生超过15次,JVM 就会停止复制并把他们移到老年代中去。如果一个对象不能在Eden中被创建,它会直接被创建在老年代中。

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

● Minor GC 清理过程(图中红色区域为垃圾)


图3 Minor GC发生之前

图4 Minor GC发生之后

注意
1. 图中的"From" 与"To" 只是逻辑关系而不是Survivor 空间的名称,也就是说谁装着对象谁就是"From"。
2. 一个对象在幸存者区被移动/复制的次数决定了它是否会被移动到堆中。

(2)old/Tenured Generation 老年代

老年代用于存放程序中经过几次垃圾回收后还存活的对象,例如缓存的对象等,老年代所占用的内存大小即为-Xmx 与-Xmn 两个参数之差。

堆是JVM 中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new 对象的开销是比较大的,鉴于这样的原因,Hotspot JVM 为了提升对象内存分配的效率,对于所创建的线程都会在Eden区分配一块独立的空间,这块空间称为TLAB(Thread Local Allocation Buffer),其大小由JVM 根据运行的情况计算而得,也可以使用jvm参数调整。在TLAB 上分配对象时不需要加锁,因此JVM 在给线程的对象分配内存时会尽量的在TLAB 上分配,在这种情况下JVM 中分配对象内存的性能和C语言基本是一样高效的但如果对象过大的话则仍然是直接争抢Eden区进行分配,TLAB是在Eden去划分出来的一块区域,一般不会太大,因此在编写Java 程序时,通常多个小的对象比大的对象分配起来更加高效,但这种方法同时也带来了两个问题,一是空间的浪费,二是对象内存的回收上仍然没法做到像Stack 那么高效,同时也会增加回收时的资源的消耗,可通过在启动参数上增加 -XX:+PrintTLAB来查看TLAB 这块的使用情况。

● 老年代GC(Major GC/Full GC):指发生在老年代的GC,但它并不只是在老年代回收空间,通常会伴随至少一次Minor GC(但也并非绝对,在Parallel Scavenge 收集器的收集策略里则可选择直接进行Major GC)。Major GC 的速度一般会比Minor GC 慢10倍以上。

虚拟机给每个对象的对象头可以记录对象的年龄(4bit)。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

(3)逃逸分析

逃逸分析的基本行为就是分析对象的动态作用域:

  • 当一个对象在方法内定义时候并且只在方法中使用了,就认为没有发生逃逸;
  • 当一个对象在方法中定义之后,它被外部方法所调用,就认为发生了逃逸。例如作为调用参数传递到其他参数中。

发生了逃逸:

public StringBuilder(){
   StringBuilder sb=new StringBuilder();
   //.....处理逻辑
   return sb;
}

StringBuilder对象会作参数传递给其他方法,因此这里发生了逃逸,不会在桟上分配空间。

没发生逃逸:

public String(){
   StringBuilder sb=new StringBuilder();
   //.....处理逻辑
   return sb.toString();
}

在方法中分配的内存没有传递给其他方法,自在本方法中使用了,没有发生逃逸,因此可以在桟上直接给StringBuilder分配内存。

逃逸分析的优化

1)桟上分配

一个方法的中的引用如果没有发生逃逸,则会直接在桟帧上分配内存,这样可以提高对象的分配效率并且可以减少GC的次数。

桟上分配的好处

不需要GC介入去回收这些对象,出栈即释放资源,可以提高性能。原理:由于我们GC每次回收对象的时候,都会触发Stop The World(STW),这时候所有用户线程都停止了,然后我们的GC线程工作去进行垃圾回收,如果对象频繁创建在我们的堆中,也就意味这我们也要频繁的暂停所有线程,这对于用户无非是非常影响体验的,栈上分配就是为了减少垃圾回收的次数

2)标量替换

JVM中的原始数据类型(int,long等)都不能在进一步分解,他们就可以成为标量。相对的,如果一个数据可以继续分解,那么他成为聚合量,java中最典型的聚合量就是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个这个对象是可以分解的,那么程序真正执行的时候可能不创建这个对象,而改为直接创建它的若干个被这个方法能够使用到的成员变量来代替。拆散后的变量便可以被单独的分析与优化,可以分别分配在栈帧或者寄存器上,原来的对象就不需要整体被分配在堆中。

标量替换的 JVM 参数如下:

  • 开启标量替换:-XX:+EliminateAllocations
  • 关闭标量替换:-XX:-EliminateAllocations
  • 显示标量替换详情:-XX:+PrintEliminateAllocations
3)锁消除

线程同步的代价是相当高的,同步带来的后果是降低了并发性和程序性能。逃逸分析以判断某个对象是否始终只被一个线程访问,如果只被一个线程访问,那么该对象的同步操作就可以转化为没有同步的操作,这样可以大大提高并发性能
锁消除的 JVM 参数如下:

  • 开启锁消除:-XX:+EliminateLocks
  • 关闭锁消除:-XX:-EliminateLocks

锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上。

2、JVM中给对象分配内存的过程

通过上的学习我们可以大致总结出一个对象被创建过程中内存申请过程:

对象分配过程

1)依据逃逸分析,判断是否可以在桟上分配内存
如果没有发生逃逸,那么就会在桟帧上直接分配内存。线程销毁或方法调用结束之后,直接出栈,没有GC介于回收垃圾。

  • 优点:可以在函数调用结束后自行销毁对象,不需要垃圾回收器的介入,有效避免垃圾回收带来的负面影响栈上分配速度快,提高系统性能

  • 局限性:栈空间小,对于大对象无法实现栈上分配

2)判断是否是大对象
大对象这个概念比较模糊,那么多大的对象是大对象呢?其实这个值可以使用jvm参数指定的:

大对象到底多大:-XX:PreTenureSizeThreshold=n(仅适用于 DefNew / ParNew新生代垃圾回收器 )。参考链接

G1回收器的大对象判断,则依据Region的大小(-XX:G1HeapRegionSize)来判断,如果对象大于Region 50%以上,就判断为大对象Humongous Object。

3)判断是否可以在TLAB中分配内存
由于对象一般分配在堆上,而堆是线程共用的,因此可能会有多个线程在堆上申请空间,而每一次的对象分配都必须线程同步,会使分配的效率下降。
考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。

  • 局限性: TLAB空间一般不会太大(占用eden区),所以大对象无法进行TLAB分配,只能直接分配到堆 Heap上。

4)在Eden区分配内存

  • (1)JVM会试图为新对象在Eden区中初始化一块内存区域。

  • (2)当Eden空间足够时,内存申请结束。否则进入第三步。

  • (3)JVM试图释放在Eden中所有不活跃的对象(执行一次Minor GC), 释放后若Eden 空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor 区。

  • (4)Survivor区被用来作为新生代与老年代的缓冲区域,当老年代空间足够时,Survivor区的对象会被移到老年代,否则会被保留在Survivor 区。

  • (5)当老年代空间不够时,JVM会在老年代进行0级的完全垃圾收集(Major GC/Full GC)。

  • (6)Major GC/Full GC后,若Survivor 及老年代仍然无法存放从Eden 复制过来的部分对象,导致JVM 无法在Eden 区为新对象创建内存区域,JVM 此时就会抛出内存不足的异常。

3、Java堆相关调优参数总结

JVM的调优参数,我这里首推官方文档。另外我这里总结了几个常用的Java堆有关的调优参数,大家可以参考学习。

(1)-Xms size:设置初始堆大小,默认为物理内存的1/64(<1GB);默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制

(2)-Xmx size:设置最大堆大小,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制 。等价的参数:-XX:MaxHeapSize=size

(3)-Xmn size:设置新生代的内存空间大小,即Eden+ 2个survivor space。 在保证堆大小不变的情况下,增大新生代后,将会减小老生代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。 等价的参数:-XX:NewSize=size

(4)-XX:SurvivorRatio:设置新生代中Eden区域与Survivor区域的容量比值,默认值为8。两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10。

(5)-XX:NewRatio=ratio:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5

(6)-XX:MaxTenuringThreshold=threshold:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代即被回收的概论。

(7)-Xss size:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小默认为1M,以前每个线程堆栈大小默认为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

(8)-XX:TLABSize=size:设置TLAB的初始大小,将它设置为0表示让JVM自动选择初始大小。

(9)-XX:+PrintGCDetails:打印 GC 信息
(10)-XX:+PrintFlagsInitial:打印所有参数的默认值
(11)-XX:+PrintFlagsFinal:打印所有参数当前值
(12)-XX:+HeapDumpOnOutOfMemoryError: 让虚拟机在发生内存溢出时 Dump 出当前的内存堆转储快照,以便分析用。默认情况下,堆内存快照会保存在JVM的启动目录下名为java_pid.hprof 的文件里(在这里就是JVM进程的进程号)。也可以通过设置-XX:HeapDumpPath=来改变默认的堆内存快照生成路径,可以是相对或者绝对路径。

(13)-XX:OnOutOfMemoryError: 当内存溢发生时,我们甚至可以可以执行一些指令,比如发个E-mail通知管理员或者执行一些清理工作。通过-XX:OnOutOfMemoryError 这个参数我们可以做到这一点,这个参数可以接受一串指令和它们的参数。

在下面的例子中,当内存溢出错误发生的时候,我们会将堆内存快照写到/tmp/app/heapdump.hprof 文件并且在JVM的运行目录执行脚本发送邮件:

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/app/heapdump.hprof -XX:OnOutOfMemoryEror="OOM_alert.sh" MyApp

(14)-XX:+HandlePromotionFailure :jvm默认启用空间分配担保。年轻代每次Minor GC之前都会计算一下老年代剩余的可用空间,如果大于,那么此次Monior GC是安全的;如果剩余空间小于年轻代所有对象的大小总和(包括垃圾对象),那么就会看这个参数有没有开启(jdk1.8以后这个参数默认开启),如果有这个参数,就会看看老年代的可用空间是否大于之前Monior GC时候老年代的平均大小,如果大于,那就尝试进行一次Monior GC(有风险);否者进行Full GC,如果FGC之后还放不下新生代的对象,那就抛出OOM

(15)-XX:+DoEscapeAnalysis :启用逃逸分析(默认打开)

(16)-XX:+EliminateAllocations :标量替换(默认打开)

留言区

还能输入500个字符