Java虚拟机-堆

特点

  • 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
    注意:如果使用 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 区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。

 

                                            

                                                                  虚拟机对象分配流程



作者:晓_魂淡
链接:https://www.jianshu.com/p/702eddcac053
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值