一 堆的核心概述

1.1 概述

image.png
1)一个jvm实例只存在一个堆内存,堆也是Java内存管理的核心区域。
2)Java堆区在 jvm启动的时候即被创建,其空间大小也就确定了,并且是jvm管理的最大的一块内存空间。堆内存是可以调节的。
3)《Java虚拟机规范》确定,堆是处于物理上不连续的内存空间,但是在逻辑上它应该被视为连续的。所有线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread local Alltocation Buffer,TLAB)。
4)《Java虚拟机规范》对Java堆的描述是:所有的对象实例和数组都应该在运行时分配到堆上。但是根据宋红康老师所说的:此处应该加上“几乎”。说明并不是那么绝对的,有例外的变化。
5)数组和对象可能永远都不会存储到栈上,因为栈帧中保存着引用,这个引用指向对象或数组在堆中的地址。在方法结束后,堆中的内存不会被马上移除,仅仅在垃圾回收的时候才会被移除。
6)在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
7)堆是GC(Garbage Colletor,垃圾收集器)执行垃圾回收的重点区域。
image.png

1.2 内存细分

1)现代的垃圾收集器大部分都基于分代收集理论来设计的
2)Java7及以前:堆内存逻辑上分为三部分:新生区+养老区+永久区
①Young Generation Space:新生区 Young/New
新生区又会被划分为 Eden区和Survivor区
②Tenure Generation Space:养老区 Old/Tenure
③Permanent Space:永久区 Perm
image.png
Java8及以后:堆内存逻辑上分为三部分:新生区+养老区+元空间
①Young Generation Space:新生区 Young/New
新生区又会被划分为 Eden区和Survivor区
②Tenure Generation Space:养老区 Old/Tenure
③Meta Space:元空间 Meta
3)约定:(JDK7<==>JDK8)新生区=新生代,养老区=老年区=老年代,永久区=永久代

二 设置堆内存大小和OOM

2.1 堆空间大小设置

1)概述:Java堆区用于存储Java对象实例。那么堆的大小在jvm启动的时候就已经设置好了,而我们在运行Java程序时候可以自己手动的设置Java堆的大小。

  1. -Xms:表示堆区的起始内存,等价于:-XX:InitialHeapSize
  2. -Xmx:表示堆区的最大内存,等价于:-XX:MaxHeapSize

3)如果不设置堆内存的话,初始的堆内存为:电脑内存 / 64,最大堆内存:电脑内存为 / 4
4)在自己手动设置堆内存大小时,一般是建议将这俩个参数设置为一样大小,目的是:为了能够在Java垃圾回收机制清理完堆区后不需要重新分割计算堆的大小,从而提高性能。

2.2 OOM举例

1)当Java程序所需要的内存超出了堆空间的最大内存,就会抛出OOM异常。
2)OMM的场景一共可以分为下面几种情况

  1. 堆内存超出
  2. 程序创建的线程数太多
  3. GC垃圾回收时间占用比例过大

    三 年轻代和老年代

    3.1 对象分类

    1)一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。
    2)另外一类是生命周期非常长的对象,在某些情况下甚至可以和jvm的生命周期保持一致。
    3)Java堆进一步划分可以分为新生区和养老区。

    3.2 新生区

    1)其中新生区又分为Eden区和Survivor0空间,Survivor1空间(有时也叫做from区,to区)
    2)在HotSpot中,Eden区和另外两个Survivor区默认占比是 8:1:1,当然,我们也可以通过参数来设置这个新生区中的比例大小:如 -XX:SurvivorRatio=8
    image.png

    3.3 养老区

    1)养老区的一般比新生区的内存大几倍。
    2)可以通过参数调节新生区和养老区的内存空间的比例大小。但是在开发一般都不会修改它。
    3)默认:-XX:NewRatio=2,表示新生区占堆内存的1份,养老区占堆内存的2份,新生区占堆的 1/3
    4)可以修改为:-XX:NewRatio=4,表示新生区占堆内存的1份,养老区占堆内存的4份,新生区占堆的 1/5
    5)几乎所有的Java对象都是在Eden区被new出来的,而绝大部分的对象也都是在新生区就被销毁了,IBM的一门研究表明:80%的对象都是在“朝生夕死”。
    6)可以通过显式的指定新生区的内存大小:-Xmn:100m,这样就是设置成100m了,但是这个参数似乎就会和前面设置的参数有矛盾了,因为之前已经设置了新生区和养老区的内存比例了,那么如果两个都设置了的话,就只有-Xmn这个显式指定新生区内存大小的参数生效。但是一般都不会使用这个参数来设置新生区大小。

    四 图解对象分配过程

    new出来的对象是怎么在堆中被分配内存的呢?

4.1 概述

1)为新对象分配内存是一件非常严谨和复杂的任务,jvm的设计者们不仅需要考虑内存是如何分配的,在哪里分配等问题。
2)并且由于内存分配算法和垃圾回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间产生碎片的问题。

4.2 对象在堆区的活动走势

  1. new出来的对象先放在Eden区,此区是有大小限制的。
  2. 当Eden区的空间填满了时,程序又需要创建新的对象,jvm的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其他对象引用的对象进行销毁,再加载新的对象放到Eden区。
  3. 然后将Eden区的剩余对象移动到Survivor0区。
  4. 如果再次触发垃圾回收,此时幸存下来的对象放到Survivor0区,如果没有回收,就会放到Survivor1区。
  5. 如果再次经历垃圾回收,此时会重新放回到Survivor0区,接着再去Survivor1区。
  6. 啥时候可以去养老区,可以设置参数,默认是经历过15次垃圾回收仍然幸存的对象,就可以去养老区。可以通过参数:-XX:MaxTenuringThreshold=进行设置。
  7. 在养老区,相对悠闲,当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。
  8. 若养老区执行了MajorGC 之后发现依然无法进行对象的保存,就会产生OMM异常。java.lang.OutOfMemoryError: Java heap space

    4.3 结论

    1)针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
    2)关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。

    五 MinorGC、MajorGC、FullGC

    5.1 概述

    1)JVM在进行GC时,并非每次都对三个内存(新生区、养老区、方法区)区域一起垃圾回收的,大部分回收的时候都是指新生区。
    2)针对HopSpot VM的实现,它的GC按照回收区域又分为两大类型:一种是部分收集(Partial GC),另一种是整堆收集(Full GC)。
    3)部分收集:不是完整收集整个Java堆的垃圾收集

  9. 新生区的垃圾收集(Minor GC / Young GC):只是新生区的垃圾收集

  10. 养老区的垃圾收集(Major GC):只是养老区的垃圾收集
    1. 目前只有CMS GC 会有单独收集养老区的行为
    2. 注意:很多时候Major GC和Full GC混淆使用,需要具体分辨是老年区的垃圾收集还是整堆的垃圾收集
  11. 混合收集(Mixed GC):收集整个新生区以及部分的养老区垃圾收集。目前只有G1 GC才会有这种行为

4)整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

5.2 新生区的MinorGC触发机制

1)当新生区的空间不足时,就会触发MonirGC,指的是Eden区满的时候,而Survivor区满的时候不会触发Minor GC。
2)因为Java中的对象大多是朝生夕死的特性,所以Minor GC非常频繁,一般回收的速度也比较快。
3)Minor GC会引发STW,暂停其他用户线程,等待垃圾回收线程结束,其他用户线程才恢复。

5.3 养老区的MajorGC触发机制

1)指发生在老年代的GC,对象从养老区消失了,就说明MajorGC 或 Full GC 发生了。
2)出现了 Major GC ,经常会伴随着至少一次的 Minor GC(但是并非绝对)。也就是说在养老区空间不足时,会尝试先触发MinorGC,如果之后空间还是不足,那么才会触发Major GC。
3)Major GC 一般会比Minor GC慢 10倍以上,STW的时间更长。
4)如果Major GC 后,内存还是不足,那么就会报 OOM异常。

5.4 FullGC的触发机制

1)调用system.gc()时候,系统建议执行FullGC,但是不是必然执行。
2)养老区内存空间不足。
3)方法区空间不足。
4)通过Minor GC 进入养老区的平均大小大于养老区的可用内存。
5)由Eden区,survivor0区向survivor1区复制时,对象大小大于To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象的大小。
6)注意:full gc 时开发或者调优中尽量避免的,这样暂停的时间会短一些。

六 堆空间分代思想

为什么需要把Java堆分代?不分代就不能正常工作了吗?

1)不同的对象的生命周期不同。70%——99% 的对象是临时对象

  • 新生区:由Eden,两个Survivor区构成,to区总是为空
  • 老年代:存放新生代中经历多次GC仍然存活的对象

2)其实堆空间不分代也是可以的,分代的唯一理由就是:优化GC的性能。如果没有分代,那么所有的对象都在一块,就如同把一个学校的人都关在一个教室一样。GC的时候需要找到哪些对象是没用的,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把创建的对象放在某一个地方,当GC的时候,先把这个存放“朝生夕死”对象的区域进行回收,这样会腾出很大的空间出来。

七 内存分配策略

1)如果对象在Eden区出生并经历了一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到Survivor 空间中,并将对象的年龄设置为1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增长一岁,当它的年龄增长到一定的程度(默认15岁,每个JVM,每个GC都有所不同)时,就会被晋升到老年代。
2)对象晋升老年代的年龄阈值,可以通过 -XX:MaxTenuringThreshold 来设置。
3)针对不同年龄段的对象分配原则如:

  1. 优先分配到新生区的Eden区
  2. 大对象直接分配到养老区:尽量避免程序中出现过多的大对象
  3. 长期存活的对象分配到养老区
  4. 动态对象年龄判断:如果Survivor区中的相同年龄的所有对象总和大于等于Survivor空间的一半。大于或等于该年龄的对象直接进入养老区,无需达到MaxTenuringTheshold中要求的年龄。
  5. 空间分配担保:-XX:HandlePromotionFailure

    八 为对象分配内存TLAB

    8.1 为什么有TLAB

    1)由于堆区是共享的,任何线程都是可以访问到堆区中的共享数据。
    2)但是考虑到对象实例的对象在堆区是非常频繁的,因此在并发的环境下从堆区划分内存空间是线程不全的。
    3)为了避免多个线程操作同一个地址,需要使用到加锁机制,进而影响分配速度。

    8.2 TLAB是什么

    Thread Local Allocation Buffer

1)从内存模型而不是垃圾回收的角度来看,对Eden区进行继续划分,jvm为每个线程分配了一个私有的缓冲区域,它包含在Eden区中。
2)多线程同时分配内存的时候,使用TLAB可以避免一系列的非线程安全问题,同时还能快速的提升内存分配的吞吐量,因此可以将这种内存分配方式称之为快速分配策略。
3)据宋红康老师说的,所有的openJDK衍生出来的JDK都是提供了TLAB的设计的。
4)尽管不是所有的对象实例都是能够在TLAB中成功分配内存,但是JVM确实是将TLAB内存分配作为首选的。
5)在程序可以通过:-XX:UseTLAB 来设置是否开启TLAB空间。
6)默认情况下,TLAB占用Eden区内存非常小,仅占1%。可以通过:-XX:TLABWasteTargetPercent 设置TLAB占用的百分比。
7)一旦对象在TLAB内存空间分配失败,jvm会尝试通过加锁的机制保证数据的原子性,从而直接在Eden区直接分配内存。

九 小结堆空间的参数设置

1)-XX:+PrintlagsInitial:查看所有参数的默认值
2)-XX:+PrintlagsFinal:查看所有的参数的最终值
3)-Xms:初始堆空间内存(默认为物理内存的1/64)
4)-Xmx:最大堆空间内存(默认为物理内存的1/4)
5)-Xmn:设置新生代的大小(初始值及最大值)
6)-XX:NewRadio:配置新生代和老年代在堆结构的占比
7)-XX:SurvivorRadio:设置新生代中Eden和S0/S1空间的比例
8)-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
9)-XX:+PrintGCDetails:输出详细的GC处理日志

  • 打印gc的简要信息:(1)-XX:+PrintGC (2)-verbose:gc

10)-XX:HandlePromotionFailure:是否设置空间分配担保

十 堆不是分配对象的唯一选择

10.1 对象分配内存概述

1)在《深入理解Java虚拟机》中关于Java堆内存中有这样一段描述:随着JIT编译器的发展和逃逸分析技术逐渐成熟。栈上分配,标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也逐渐地变得不是那么绝对了。
2)在Java虚拟机中,对象是在Java堆上分配内存地,这是一个普遍地常识。但是有一种特殊情况:那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收了。这也是最常见的堆外存储技术。
3)除此之外,前面提到的基于OpenJDK深度定制化的TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现的off-heap,将生命周期较长的Java对象从heap中转移至heap外, 并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

10.2 逃逸分析

10.2.1 实例分析

1)没有发生逃逸的对象,则可以分配在栈上,随着方法执行的结束,栈空间就被移除。

  1. public void methodA(){
  2. V v = new V();
  3. v = null;
  4. }

2)下面的StringBuffer对象逃出了方法.

  1. public static StringBuffer methodA(String s1, String s2){
  2. StringBuffer sb = new StringBuffer();
  3. sb.append(s1);
  4. sb.append(s2);
  5. return sb;
  6. }
  • 如果想StringBuffer对象不逃逸出方法,则可以生成一个对象返回

    1. public static String methodA(String s1, String s2){
    2. StringBuffer sb = new StringBuffer();
    3. sb.append(s1);
    4. sb.append(s2);
    5. return sb.toString();
    6. }

    3)分析当前类中的对象逃逸情况

    1. public class EscapeAnalysis{
    2. public EscapeAnalysis obj;
    3. // 方法返回EscapeAnalysis对象,发生逃逸
    4. public EscapeAnalysis getInstance(){
    5. return obj = null? new EscapeAnalysis():obj;
    6. }
    7. // 为成员属性赋值,发生逃逸
    8. public void setObj(){
    9. this.obj = new EscapeAnalysis();
    10. }
    11. // 对象的作用域仅在当前方法中有效,没有发生逃逸
    12. public void useEscapseAnalysis(){
    13. EscapseAnalysis e = new EscapseAnalysis();
    14. }
    15. // 引用成员变量的值, 发生逃逸
    16. public void method(){
    17. EscapseAnalysis e = this.getInstance();
    18. }
    19. }

    10.2.2 参数设置

    1)在JDK 6u23 版本中,HotSpot中就默认开启逃逸分析了。
    2)如果选择使用老版本的JDK,可以设置参数

  1. -XX:+DoEscapeAnalysis:显式地开启逃逸分析
  2. -XX:+PrintEscapeAnalysis:查看逃逸分析地筛选结果

3)开发中能使用局部变量,就不要使用在方法外定义。

10.2.3 代码优化

10.2.3.1 概述

1)栈上优化:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
2)同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
3)分离对象或者标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问得到,那么对象的部分(全部)可以不存储在内存中,而是存储在CPU寄存器中。

10.2.3.2 栈上分配

1)JIT编译器在编译期间根据逃逸分析的结果,发现一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
2)常见的栈上分配的场景

  1. 给成员变量赋值
  2. 方法返回值
  3. 实例引用传递

    10.2.3.3 同步省略

    1)线程同步的代价是相当高的,同步的后果是降低并发性和性能。
    2)在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略。也叫锁消除。
    1. public void fun(){
    2. Object obj = new Object();
    3. synchronized(obj){
    4. System.out.println(obj);
    5. }
    6. }
    7. // 代码对obj这个对象进行加锁,但是obj对象的生命周期只在fun()方法中,并不会被其他线程访问
    8. // 所以在JIT编译阶段就会被优化掉,优化如下
    9. public void fun(){
    10. Object obj = new Object();
    11. System.out.println(obj);
    12. }

    10.2.3.4 标量替换

    1)标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
    2)相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为它可以分解成其他聚合量和标量。
    3)在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆分成若干个它包含的成员变量来替换,这个过程就叫做标量替换。
    1. public class A{
    2. public static void main(String[] args){
    3. fun();
    4. }
    5. public static void fun(){
    6. B b = new B(1, 2);
    7. System.out.println("x=" + b.x + ",y=" + b.y);
    8. }
    9. }
    10. class B{
    11. public int x;
    12. public int y;
    13. }
  • 经过标量替换
    1. public static void fun(){
    2. int x = 1;
    3. int y = 2;
    4. System.out.println("x=" + x + ",y=" + y);
    5. }