美文网首页
JVM 系列 - 内存区域 - Java 堆(五)

JVM 系列 - 内存区域 - Java 堆(五)

作者: _晓__ | 来源:发表于2018-12-06 16:58 被阅读0次

    特点

    • Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块,也被称为 “GC堆”,是被所有线程共享的一块内存区域,在虚拟机启动时被创建
    • 唯一目的就是储存对象实例和数组(JDK7 已把字符串常量池和类静态变量移动到 Java 堆),几乎所有的对象实例都会存储在堆中分配。随着 JIT 编译器发展,逃逸分析、栈上分配、标量替换等优化技术导致并不是所有对象都会在堆上分配。
    • Java 堆是垃圾收集器管理的主要区域。堆内存分为新生代 (Young) 和老年代 (Old) ,新生代 (Young) 又被划分为三个区域:Eden、From Survivor、To Survivor。
      堆默认内存划分
    • 从内存分配的角度看,线程共享的 Java 堆中可能划分出多个线程私有的线程本地分配缓存区(Thread Local Allocation Buffer,TLAB)
    • 根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx-Xms 控制)。

    Java 堆会出现的异常

    • 如果 Java 堆可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,那 Java 虚拟机将抛出一个 OutOfMemoryError 异常。

    运行时数据区

    运行时数据区

    JIT 编译器

    即时编译器(Just-in-time Compilation,JIT)

    • Java 程序最初是通过解释器来解释执行的,当虚拟器发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译为机器码,并进行各种层次的优化,完成这个任务的编译器成为即使编译器(JIT)。
    • 在 HotSpot 实现中有多种选择:C1、C2 和 C1 + C2,分别对应 client、server 和分层编译。
      1、C1 编译速度快,优化方式比较保守;
      2、C2 编译速度慢,优化方式比较激进;
      3、C1 + C2 在开始阶段采用 C1 编译,当代码运行到一定热度之后采用 G2 重新编译;
      在 JDK8 之前,分层编译默认是关闭的,可以添加 -server -XX:+TieredCompilation 参数进行开启。
      JIT 工作原理图

    什么是热点代码

    • 被多次调用的方法:方法调用的多了,代码执行次数也多,成为热点代码很正常。
    • 被多次执行的循环体:假如一个方法被调用的次数少,只有一次或两次,但方法内有个循环,一旦涉及到循环,部分代码执行的次数肯定多,这些多次执行的循环体内代码也被认为“热点代码”。

    如何检测热点代码

    • 基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。
      缺点:不够精确,容易受到线程阻塞或外界因素的影响
      优点:实现简单、高效,很容易获取方法调用关系
    • 基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。
      缺点:实现麻烦,不能直接获取方法的调用关系
      优点:统计结果精确

    HotSpot 虚拟器为每个方法准备了两类计数器:方法调用计数器和回边计数器,两个计数器都有一定的阈值,超过阈值就会触发JIT 编译。
    -XX:CompileThreshold 可以设置阈值大小,Client 编译器模式下,阈值默认的值1500,而 Server 编译器模式下,阈值默认的值则是10000。

    方法调用计数器
    回边计数器

    逃逸分析

    • 逃逸分析是 Java 虚拟机中的一种优化技术,但它并不是直接优化代码,而是为其他优化手段提供优化依据的分析技术。
    • 逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸。
    • 可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

    对象的三种逃逸状态

    • GlobalEscape(全局逃逸) 一个对象的引用逃出了方法或者线程。例如,一个对象的引用是复制给了一个类变量,或者存储在在一个已经逃逸的对象当中,或者这个对象的引用作为方法的返回值返回给了调用方法。
    • ArgEscape(参数逃逸) 在方法调用过程中传递对象的引用给调用方法,这种状态可以通过分析被调方法的二进制代码确定。
    • NoEscape(没有逃逸) 一个可以进行标量替换的对象,可以不将这种对象分配在堆上。
    private Object o;
    
    /**
     * 给全局变量赋值,发生逃逸(GlobalEscape)
     */
    public void globalVariablePointerEscape() {
        o = new Object();
    }
    
    /**
     * 方法返回值,发生逃逸(GlobalEscape)
     */
    public Object methodPointerEscape() {
        return new Object();
    }
    
    /**
     * 实例引用传递,发生逃逸(ArgEscape)
     */
    public void instancePassPointerEscape() {
        Object o = methodPointerEscape();
    }
    
    /**
     * 没有发生逃逸(NoEscape)
     */
    public void noEscape() {
        Object o = new Object();
    }
    

    配置逃逸分析

    • 开启逃逸分析,对象没有分配在堆上,没有进行GC,而是把对象分配在栈上。 (-XX:+DoEscapeAnalysis 开启逃逸分析(JDK8 默认开启,其它版本未测试) )
    • 关闭逃逸分析,对象全部分配在堆上,当堆中对象存满后,进行多次GC,导致执行时间大大延长。堆上分配比栈上分配慢上百倍。(-XX:-DoEscapeAnalysis 关闭逃逸分析)
    • 可以通过 -XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果

    标量替换

    • 标量
      一个数据无法再分解为更小的数据来表示了,Java 虚拟机中的基本数据类型 byte、short、int、long、boolean、char、float、double 以及 reference 类型等,都不能再进一步分解了,这些就可以称为标量。
    • 聚合量
      一个数据可以继续分解,就称为聚合量。对象就是最典型的聚合量。
      如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复到基本数据类型来访问,就叫标量替换。
    • 替换过程
      如果逃逸分析可以证明一个对象不会被外部访问,并且这个对象可以拆散的话,那程序真正执行时将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外(栈上存储的数据,有很大概率会被虚拟机分配至物理机器的高速寄存器中存储),还可以为后续的进一步优化手段创造条件。

    -XX:+EliminateAllocations 可以开启标量替换
    -XX:+PrintEliminateAllocations 查看标量替换情况(Server VM 非Product 版本支持)

    栈上分配

    栈上分配的技术基础是逃逸分析和标量替换。使用逃逸分析确认方法内局部变量对象(未发生逃逸,线程私有的对象,指的是不可能被其他线程访问的对象)不会被外部访问,通过标量替换将该对象分解在栈上分配内存,不用在堆中分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。方法执行完后自动销毁,而不需要垃圾回收的介入,减轻 GC 压力,从而提高系统性能。

    使用场景:对于大量的零散小对象,栈上分配提供了一种很好的对象分配策略,栈上分配的速度快,并且可以有效地避免垃圾回收带来的负面的影响,但由于和堆空间相比,栈空间比较小,因此对于大对象无法也不适合在栈上进行分配。

    测试栈上分配:

    public static void alloc() {
        byte[] b = new byte[2];
        b[0] = 1;
    }
    
    public static void main(String[] args) {
        long timeMillis = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
             alloc();
        }
        // 开启使用栈上分配执行时间 6 ms左右
        // 关闭使用栈上分配执行时间 900 ms左右
        System.out.println(System.currentTimeMillis() - timeMillis);
    }
    
    • 开启使用栈上分配(JDK8 默认开启,其它版本未测试),-XX:+DoEscapeAnalysis 表示启用逃逸分析,栈上分配依赖于 JVM 逃逸分析结果。
    • 禁止使用栈上分配,-XX:-DoEscapeAnalysis 表示禁用逃逸分析。
      注意:如果使用 idea 等工具测试,需使用 Run 执行

    同步消除

    线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。(锁和锁块内的对象不会逃逸出线程就可以把这个同步块取消)

    测试同步消除:

    public static void alloc() {
        byte[] b = new byte[2];
        synchronized (b) {
             b[0] = 1;
        }
    }
    
    public static void main(String[] args) {
        long timeMillis = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
             alloc();
        }
        // 开启使用同步消除执行时间 10 ms左右
        // 关闭使用同步消除执行时间 3870 ms左右
        System.out.println(System.currentTimeMillis() - timeMillis);
    }
    
    • 开启同步消除 (-XX:+EliminateLocks (JDK8 默认开启,其它版本未测试) )
    • 关闭同步消除(-XX:-EliminateLocks
    • 可以通过配置 -XX:+PrintEscapeAnalysis 开启打印逃逸分析筛选结果
      注意:如果使用 idea 等工具测试,需使用 Run 执行

    TLAB

    • TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区。这是线程私有的,在新生代 Eden 区分配内存区域,默认是开启的,也可以通过 -XX:+UseTLAB 开启。TLAB 的内存非常小,默认设定为占用新生代的1%,可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 占用 Eden Space 空间大小。
    • 由于对象一般会分配在堆上,而堆是全局共享的。同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步,而在竞争激烈的场合内存分配的效率又会进一步下降。JVM 使用 TLAB 来避免多线程冲突,每个线程使用自己的 TLAB,这样就保证了不使用同步,提高了对象分配的效率。
    • 由于 TLAB 空间一般不会很大,因此大对象无法在 TLAB 上进行分配,总是会直接分配在堆上。TLAB 空间由于比较小,因此很容易装满。比如,一个100K的空间,已经使用了80KB,当需要再分配一个30KB的对象时,肯定就无能为力了。这时虚拟机会有两种选择,第一,废弃当前 TLAB,这样就会浪费20KB空间;第二,将这30KB的对象直接分配在堆上,保留当前的 TLAB,这样可以希望将来有小于20KB的对象分配请求可以直接使用这块空间。实际上虚拟机内部会维护一个叫作 refill_waste 的值,当请求对象大于 refill_waste 时,会选择在堆中分配,若小于该值,则会废弃当前 TLAB,新建 TLAB 来分配对象。这个阈值可以使用 -XX:TLABRefillWasteFraction 来调整,它表示 TLAB 中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的 TLAB 空间作为 refill_waste。默认情况下,TLAB 和 refill_waste 都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整 TLAB 的大小,可以使用 -XX:-ResizeTLAB 禁用,并使用 -XX:TLABSize 手工指定一个 TLAB 的大小。-XX:+PrintTLAB 可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。

    扩展

    虚拟机对象分配流程:首先如果开启栈上分配,JVM 会先进行栈上分配,如果没有开启栈上分配或则不符合条件的则会进行 TLAB 分配,如果 TLAB 分配不成功,再尝试在 Eden 区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。


    虚拟机对象分配流程

    相关文章

      网友评论

          本文标题:JVM 系列 - 内存区域 - Java 堆(五)

          本文链接:https://www.haomeiwen.com/subject/rznicqtx.html