一、堆的概述
一个进程对应一个JVM实例,也就是一个进程对应一个运行时数据区。一个进程又包含多个线程,这些线程共享了方法区和堆,但是每个线程都有各自的程序计数器、本地方法栈和虚拟机栈。
- 一个jvm实例只存在一个堆内存,堆也是java内存管理的核心区域
- Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,是JVM管理的最大一块内存空间
- 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
- 所有的线程共享java堆,在这里还可以划分线程私有的缓冲区(TLAB),所以堆空间不一定是所有线程共享的,因为在堆中还可能会有线程独有的缓冲区。
- 所有的对象实例以及数组都应当在运行时分配在堆上
- 数组或对象永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
- 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域
二、堆的内存结构
JDK 7 之前堆空间的内存结构划分为:
- 新生代
(1)Eden
区
(2)Survivor
区 - 老年代
- 永久代
JDK 8 之前堆空间的内存结构划分为:
- 新生代
(1)Eden
区
(2)Survivor
区 - 老年代
- 元空间
三、新生代与老年代
存储在JVM中的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 一类是对象的生命周期非常长,在某些极端的情况下甚至能够和JVM生命周期保持一致
堆区进一步细分,可以划分为:
- 年轻代:包括
Eden
区、Survivor 0
区、Survivor 1
区、 - 老年代
新生代与老年代在堆结构的占比,默认-XX:NewRatio=2
,表示新生代占1,老年代占2,新生代占整个堆的1/3
在hotSpot中,Eden
空间和另外两个Survivor
空间缺省所占的比例是8:1:1
,但是由于自适应的内存分配策略,导致默认的比例可能不是8: 1: 1
,所以需要手动显式指定参数 -XX:SurvivorRatio=8
来调整Eden
空间和另外两个Survivor
空间比例
几乎所有的Java对象都是在Eden
区被new
出来的,绝大部分的Java对象都销毁在新生代
可以使用选项-Xmn
设置新生代最大内存大小
四、对象在堆空间的分配过程
对象首先会在Eden
区被创建出来,当Eden
区空间放满了以后,如果再创建新的对象,就会触发垃圾回收操作。还存活的对象会被放到幸存者S0
区,同时为每一个对象分配一个年龄计数器。垃圾回收之后Eden
区会被清空
如果继续不断创建对象,Eden
区会再次被放满对象,然后触发垃圾回收操作。还存活的对象会被放到S1
区,同时还需要判断S0
区是否还有存活的对象,如果有就会把S0
区的对象也放到S1
区,年龄计数器自增长。垃圾回收操作结束之后,Eden
区和S0
清空,此时S0
区变成了to
区(Survivor
区谁空间是空的,谁就是to
区)
当Survivor
区中的对象的年龄计数器达到16
时,该对象就会晋升到老年代,阈值默认设置为-XX:MaxTenuringThreshold=15
垃圾回收,频繁在新生代收集,很少在老年代收集,几乎不在永久代/元空间收集。对于一些超大对象,如果新生代放不下就会直接放到老年代,如果老年代在进行垃圾回收之后还放不下这个超大对象,就会报错OOM
五、Minor GC、Major GC、Full GC
JVM在进行GC时,并非每次都对新生代、老年代、方法区这三块区域一起进行垃圾回收,大部分时候回收指的是新生代
HotSpot VM的实现,它里面的GC按照回收区域又分为两大类型。一种是部分收集,一种是整堆收集
部分收集:不是完整收集整个Java堆的垃圾收集
- 新生代收集(
Minor GC / Young GC
):只是新生代Eden
、S0
、S1
的垃圾收集 - 老年代收集(
Major GC / Old GC
):只是老年代的垃圾收集。目前只有CMS GC
会有单独收集老年代的行为。很多时候Major GC
会和Full GC
混淆使用,需要具体分辨是老年代回收还是整堆回收。 - 混合收集(
Mixed GC
):整个新生代以及部分老年代的垃圾收集。目前只有G1 GC
会有这种行为
整堆收集(Full GC
):收集整个Java堆和方法区的垃圾
新生代Minor GC
触发机制
- 当新生代空间不足时,就会触发
Minor GC
,这里的新生代满指的是Eden
区被填满,Survivor
区被填满不会引发GC,每次Minor GC
会清理新生代空间 - 大部分Java生命周期都非常短,所以
Minor GC
非常频繁,一般回收速度也比较快 Minor GC
会引发STW
,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
老年代Major GC/Full GC
触发机制
- 出现了
Major GC
通常会伴随至少一次的Minor GC
,也就是说当老年代空间不足时,会先尝试触发Minor GC
,如果之后空间还不足就会触发Major GC
。但非绝对的,在Parallel Scavenge
收集器的收集策略里就有直接进行Major GC
的策略选择过程。 Major GC
的速度一般会比Minor GC
慢10倍以上,STW
的时间更长- 如果
Major GC
之后,内存还不足,就报out of memory
Full GC
触发机制
触发Full GC
执行的情况有以下五种
- 调用
System.gc()
时,系统建议执行Full GC
,但是不必然执行 - 老年代空间不足
- 方法区空间不足
- 通过
Minor GC
后进入老年代的平均大小小于老年代的可用内存 - 由
Eden
区,SurvivorS0(from)
区向S1(to)
区复制时,对象大小大于S1(to)
可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小 Full GC
是开发或调优中尽量要避免的,这样暂停时间会短一些
六、内存分配策略
如果对象在Eden
出生并经过第一次Minor GC
后依然存活,并且能被Survivor
容纳的话,将被移动到Survivor
空间中,使用年龄计数器把这个对象的年龄设为1
。对象在Survivor
区中每经过一次Minor GC
仍然存活的话,年龄就增加一,当它的年龄增加到一定程度(默认15)时,就会被晋升到老年代中
针对不同年龄段的对象分配原则如下:
- 优先分配到Eden
- 大对象直接分配到老年代
- 长期存活的对象分配到老年代
- 动态对象年龄判断。如果
Survivor
区中相同年龄的所有对象大小的总和大于Survivor
空间的一半,年龄大于或等于该年龄的对象可以直接进入到老年代,无需等到MaxTenuringThreshold
阈值要求的年龄 - 空间分配担保,设置
-XX: HandlePromotionFailure
为true
七、TLAB(线程私有缓存区域)
为什么有TLAB(Thread Local Allocation Buffer)
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
什么是TLAB
- 从内存模型而不是垃圾收集的角度,对
Eden
区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden
空间内 - 多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
说明
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但是JVM确实是将TLAB作为内存分配的首选
- 通过选项
-XX:UseTLAB
设置是否开启TLAB空间 - 默认情况下,TLAB空间的内存非常小,仅占有整个
Eden
空间的1%
,通过-XX:TLABWasteTargetPercent
可以设置TLAB空间所占用Eden
空间的百分比大小 - 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在
Eden
空间中分配了内存
八、堆空间的参数设置
-
-XX:PrintFlagsInitial
: 查看所有参数的默认初始值 -
-XX:PrintFlagsFinal
:查看所有的参数的最终值(可能会存在修改,不再是初始值)具体查看某个参数的指令:
jps
:查看当前运行中的进程jinfo -flag SurvivorRatio pid
: 查看新生代中Eden
和S0/S1
空间的比例
-
-Xms
: 初始堆空间内存(默认为物理内存的1/64) -
-Xmx
: 最大堆空间内存(默认为物理内存的1/4) -
-
Xmn
: 设置新生代大小(初始值及最大值) -
-XX:NewRatio
: 配置新生代与老年代在堆结构的占比 -
-XX:SurvivorRatio
:设置新生代中Eden
和S0/S1
空间的比例 -
-XX:MaxTenuringThreshold
:设置新生代垃圾的最大年龄(默认15) -
-XX:+PrintGCDetails
:输出详细的GC处理日志打印gc简要信息:
-XX:+PrintGC
-verbose:gc
-
-XX:HandlePromotionFailure
:是否设置空间分配担保
在发生Minor GC
之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
- 如果大于,则此次
Minor GC
是安全的 - 如果小于,则虚拟机会查看
-XX:HandlePromotionFailure
设置值是否允许担保失败。JDK 7以后可以认为就是true
- 如果
HandlePromotionFailure=true
,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。如果大于,则尝试进行一次Minor GC
,但这次Minor GC
依然是有风险的。如果小于,则改为进行一次Full GC
- 如果
HandlePromotionFailure=false
,则改为进行一次Full GC
- 如果
九、逃逸分析
随着JIT即时编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会可以使得不在堆空间进行对象内存的分配
如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,就可能被优化成栈上分配,这样就无需再堆上分配内存,也无需进行垃圾回收了,这就是最常见的堆外存储技术。
如果将堆上的对象分配到栈,就需要使用逃逸分析手段。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,虚拟机编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸,例如作为调用参数传递到其它地方。
栈上分配
将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。经过逃逸分析如果发现一个对象并没有逃逸出方法的话,就可能被优化成栈上分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间,局部变量对象也被回收,这样就无需进行垃圾回收操作了。
成员变量赋值、方法返回值、实例引用传递都会导致对象发生逃逸
同步策略
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
在动态编译同步块的时候,JIT即时编译器可以借助逃逸分析来判断同步块所使用的所对象是否只能被一个线程访问而没有被发布到其它线程。如果没有发布到其它线程,那么JIT即时编译器在编译这个同步块的时候就会取消对这部分代码的同步,这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
分离对象或标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或者全部,可以不存储在内存,而是存储在CPU寄存器中,也就是说这个对象可以全部或者部分存储在栈中,而不是存储在堆中。
标量是指一个无法在分解成更小的数据的数据,Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量,Java中对象就是聚合量,因为它可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来替代。这个过程就是标量替换。
上述代码经过分离Point
对象,进行标量替换后,可以优化成
Point
这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。相当于把对象打散分配到栈上,这样就可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。标量替换为栈上分配提供了很好的基础。
逃逸分析技术进行的代码优化,实际上是标量替换技术起到了关键作用。栈上分配理论上是可行的,但是它的实现基础是标量替换,将对象打散成标量,而标量存储在栈上的,所以也就实现了栈上分配。