核心概述

      一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。Java堆在JVM启动的时候即被创建,其空间大小也就确定了,是JVM管理的最大一块内存空间。
      《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但是在逻辑上它应该是连续的。

所有的线程共享Java堆,注意堆中还存在线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)

内存细分

      现代垃圾收集器大部分都基于分代收集理论设计,所以将堆空间细分为:

  • Java 7及之前堆逻辑上分为三部分:新生代+老年代+永久代

    • Young Generation Space 新生代 Young/New
    • Tenure Generation Spacce 老年代 Old/Tenure
    • Permanent Space 永久代 Perm
  • Java 8及之后堆内存逻辑上分为三部分:新生代+老年代+元空间

    • Young Generation Space 新生代 Young/New
    • Tenure Generation Spacce 老年代 Old/Tenure
    • Meta Space 元空间 Meta

设置堆内存大小与OOM

      Java堆用于存储Java对象实例,堆的大小在JVM启动时就已经设定好了,可以通过选项-Xmx-Xms来进行设置

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

      一旦堆区的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常。

通常情况下会加-Xmx-Xms两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理问堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认内存大小:

  • 初始内存大小:物理电脑内存大小 / 64
  • 最大内存大小:物理电脑内存大小 / 4

年轻代与老年代

      存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另外一类对象的声明周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。

请输入图片描述

      默认情况下新生代和老年代的比例为1:2,可以使用-XX:NewRatio=value进行设置。在HotSpot中,Eden空间和另外两个Survivor空间缺省所占比例是8:1:1,可以使用-XX:SurvivorRatio调整Eden和Servivor的比例。
请输入图片描述

对象分配过程

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

  1. new的对象先放Eden区,此区有大小限制
  2. 当Eden区的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中的垃圾对象进行销毁,再加载新的对象到Eden区。
  3. 将Eden区中Survivor From移动到Survivor To区
  4. 如果再次触发垃圾回收,将上次幸存下来的对象放到另一个Survivor区(一直在From和To区之间移动)
  5. 当年龄达到15时对象将放到老年代。可以通过-XX:MaxTenuringThreshold=<N>进行年龄的设置

请输入图片描述

分代思想

为什么需要把Java堆分代?

      它建立在两个分代假说之上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多此垃圾收集过程的对象就越难以消亡

      这两个分代假说共同奠定了多款常用的垃圾回收器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
      在Java堆划分出了不同的区域后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了"Minor GC"、"Major GC"、"Full GC"这样的回收类型划分。

防止混淆:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意"Major GC"这个说法现在有点混淆,在不同资料上常有不同所指,需按上下文区分到底是老年代收集还是整堆收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

TLAB

      TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。

什么是TLAB?

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略

为什么有TLAB?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中十分频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

TLAB在说明

      尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。在程序中,我们可以通过选项-XX:UseTLAB设置是否开启TLAB空间。
      默认情况下TLAB空间的内存非常小,仅占有整个Eden空间的1%,我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间占用Eden空间的百分比大小。
      一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

堆是对象分配的唯一选择吗?

      在《Java虚拟机规范》中对堆的描述是:"所有的对象实例以及数组 都应当在堆上分配"。注意在上面写的"几乎"是从具体实现角度来看的。由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
      在Java虚拟机中,对象是在Java堆中分配内存的,这似乎是一个普遍的常识,但是,有一种特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外内存存储技术。

逃逸分析

      如果需要将堆上的对象分配到栈,需要使用逃逸分析技术,这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java HotSpot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

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

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

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

public static StringBuffer createStringBuffer(String s1,String s2){
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.qppend(s2);
    return sb;//sb发生了逃逸
}

public static StringBuffer createStringBuffer(String s1,String s2){
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.qppend(s2);
    return sb.toString();//sb没有逃逸
}
public class EscapeAnalysis{

    public EscapeAnalysis obj;
    //方法返回EscapeAnalysis对象,发生逃逸
    public EscapeAnalysis getInstance(){
        return obj == null ? new EscapeAnalysis():obj;
    }
    //为成员属性赋值,发生逃逸
    public void setObj(){
        this.obj = new EscapeAnalysis();
    }
    //对象的作用域仅在当前方法中有效,没有发生逃逸
    public void useEscapeAnalysis(){
        EscapeAnalysise = new EscapeAnalysis();
    }
    //引用成员变量的值,发生逃逸
    public void useEscapeAnalysis2(){
        EscapeAnalysis e = getInstance();
    }
}

我们可以设置-XX:+DoEscapeAnalysis开启逃逸分析。
通过选项-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果。

      使用逃逸分析,编译器可以对代码做如下优化:

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

代码优化之标量替换

      标量是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

class Point{
    int x;
    int y;
}

private static void alloc(){
    Point point = new Point(1,2);
    System.out.println("x = "+point.x +",y = "+point.y);
}

//标量替换后
private static void alloc(){
    int x = 1;
    int y = 2;
    System.out.println("x = "+x +",y = "+y);
}

我们可以设置-XX:+EliminateAllocations开启标量替换,允许将对象打散分配到栈上

Last modification:September 8th, 2020 at 10:49 pm
如果觉得我的文章对你有用,请随意赞赏