JVM运行时数据区篇(堆空间扩展知识)

Posted ProChick

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM运行时数据区篇(堆空间扩展知识)相关的知识,希望对你有一定的参考价值。

1.堆空间常见参数设置

参数名参数含义
-XX:PrintFlagsInitial查看所有可设置参数的默认初始值
-XX:PrintFlagsFinal查看所有可设置参数的最终值( 显示修改后的 )
-Xms设置堆空间内存的初始值
-Xmx设置堆空间内存的最大值
-Xmn设置新生代内存的初始值及最大值
-XX:NewRatio设置新生代与老年代在堆结构中的占比
-XX:SurvivorRatio设置新生代中Eden空间和S0、S1空间的占比
-XX:MaxTenuringThreshold设置存放在S0或S1空间中对象的最大年龄计数器值
-XX:+PrintGC输出简要的GC垃圾回收处理日志
-verbose:gc输出简要的GC垃圾回收处理日志
-XX:+PrintGCDetails输出详细的GC垃圾回收处理日志
-XX:HandlePromotionFailure设置空间分配担保

什么是空间分配担保?

  • 在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则认为此次 Minor GC 是安全的。因为如果年轻代存储不下某个可用对象,则可以直接存储在老年代

  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败

    在JDK7以后的版本中,此参数就失效了,或者我们可以认为这个参数的值就是 true

    • 如果值为 true ,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次 Minor GC ,但依然是有风险的
      • 如果小于,则改为进行一次 Fu11 GC
    • 如果值为 false,那么会直接进行一次 Fu11 GC

2.堆空间私有缓存区域TLAB

  • 为什么使用TLAB?

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

    • TLAB,全称 Thread Local Allocation Buffer

    • 从内存模型而不是垃圾收集的角度来看,JVM对 Eden 区域其实继续进行了划分,JVM为每个线程分配了一个私有缓存区域,它就包含在Eden空间内

    • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

    • 所有由 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计

    • 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但JVM明确是将 TLAB 作为内存分配的首选

    • 在程序中,我们可以通过参数 -XX:UseTLAB 设置是否开启 TLAB 空间,默认是开启的

    • 默认情况下,TLAB空间的内存非常小,仅占有整个 Eden 空间的1%,我们可以通过参数 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的占比大小

    • 一旦对象在 TLAB 空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配对象内存

  • TLAB空间分配过程

3.堆空间是分配对象的唯一选择吗?

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

4.什么是逃逸分析?

  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段进行判断, 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法
  • 通过逃逸分析,Java HotSpot 编译器能够分析出一个新对象的引用所被使用的范围,从而决定是否要将这个对象分配到堆上
  • 实质上逃逸分析的基本行为就是分析对象的动态作用域
  • 如果一个对象在方法中被定义后,该对象只在方法内部使用( 包括new出来的对象实体 ),则认为没有发生逃逸。此时该对象就可以分配到栈上,随着方法执行的结束,栈空间就被移除,进而对象被移除
  • 如果一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。此时该对象就要分配到堆上

什么情况下可以称为发生了逃逸?

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 useEscapeAnalysis1(){
        EscapeAnalysis e = getInstance();
    }
    
    // 对象的作用域仅在当前方法中有效,没有发生逃逸
    public void useEscapeAnalysis(){
        EscapeAnalysis e = new EscapeAnalysis();
    }
}

相关的参数设置

  • 在JDK7之前版本中,我们可以使用参数 -XX:DoEscapeAnalysis 显式开启逃逸分析
  • 在JDK7以及之后版本中,HotSpot VM中默认就已经开启了逃逸分析,我们可以使用参数 -XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果

5.基于逃逸分析进行的优化策略

1.栈上分配

  • 将堆分配转化为栈分配。如果一个对象在子线程中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成之后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。
  • 常见的栈上分配场景:给成员变量赋值、方法返回值、实例引用传递

程序示例

public static void main(String[] args) {
    long start = System.currentTimeMillis();

    for (int i = 0; i < 10000000; i++) {
        alloc();
    }

    long end = System.currentTimeMillis();
    
    // 查看执行时间
    System.out.println("花费的时间为: " + (end - start) + " ms");
    try {
        Thread.sleep(1000000);
    } catch (InterruptedException e1) {
        e1.printStackTrace();
    }
}

private static void alloc() {
    // 未发生逃逸的对象
    User user = new User();
}

static class User {

}
  • 未开启逃逸分析(分配到了堆中,进行了GC,并且花费时间较长)

  • 开启逃逸分析(分配到了栈中,没有进行GC,并且花费时间较短)

2.同步省略

  • 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  • 我们知道线程同步的代价是相当高的,同步的后果是降低并发性和性能
  • 在动态编译同步代码块的时候,JIT编译器可以借助逃逸分析来判断同步代码块所使用的锁对象是否只能够被一个线程访问而没有被其他线程所使用。如果没有,那么JIT编译器在编译这个同步代码块的时候就会取消对这部分代码的同步,这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

程序示例

public void test() {
    Object obj = new Object();
    synchronized(obj) {
        ...
    }
}

// 由于方法中定义的obj对象的生命周期只在当前方法中,线程之间不会共享,所以无需进行加锁机制
// 所以根据逃逸分析,在JIT编译阶段就会将同步代码块消除,转化为下方代码
public void test() {
    Object obj = new Object();
    ...
}

3.标量替换

  • 有的对象可能不需要作为一个连续的内存结构存在也可以北方问道,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中
  • 标量 Scalar 是指一个无法在分解成更小的数据的数据,Java中的原始数据类型就是标量
  • 相对的,那些还可以分解的数据叫做聚合量( Aggregate ),Java中对象就是聚合量,因为它可以分解成其他聚合量和标量
  • 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来替代,这个过程就是标量替换

程序示例

class Point{
    private int x;
    private int y;
    public Point(int x,int y){
        this.x = x;
        this.y = y;
    }
}

public static void alloc(){
    Point point = new Point(1,2);
}

// 经过标量替换,在JIT期间会将上方代码转化为下方代码
public static void alloc(){
    int x = 1;
    int y = 2;
}

6.逃逸分析总结

  • 关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的
  • 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段
  • 不成熟的根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。 一个极端的例子,就是经过逃逸分析之后,发现所有的对象都逃逸了,那这个逃逸分析的过程就白白浪费掉了
  • 我们前面说到,通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是具体实现还是取决于JVM 设计者的选择。业界表名,Oracle Hotspot JVM 中并未这么做,所以可以明确所有的对象实例其实还都是创建在堆上。只不过标量替换的过程确实存在,所以在上面代码演示中,对于未经过逃逸的对象,其实是进行了标量替换,所以耗时比较短
  • 在JDK之前的版本中,intern字符串缓存和静态变量曾经都被分配在永久代上。 而在现在的JDK版本中,永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是转移到元数据区,而是直接在堆上分配,这也同样证实了上面的观点:所有的对象实例其实都是创建在堆上

以上是关于JVM运行时数据区篇(堆空间扩展知识)的主要内容,如果未能解决你的问题,请参考以下文章

JVM运行时数据区篇(堆空间基本概述)

JVM运行时数据区篇(有关对象的扩展知识)

JVM运行时数据区篇(虚拟机栈)

JVM运行时数据区篇(本地方法栈)

JVM运行时数据区篇(方法区基本概述)

JVM运行时数据区篇(基础认知)