专业的编程技术博客社区

网站首页 > 博客文章 正文

互联网大厂面试系列-面试被问到什么是JVM的逃逸分析?

baijin 2024-09-18 12:10:30 博客文章 5 ℃ 0 评论

我们知道在JVM中有堆内存和栈内存之分,其中堆内存是一种通用的内存区域,用来存放所有的Java对象,运行效率比栈内存要低。而栈内存通常是位于RAM区域中的存储空间,程序在运行过程中可以通过指针直接获取到存储在栈内存中的数据,其运行效率仅次于寄存器。

通过上面的描述我们可以知道对于Java对象的创建一般是在堆内存上进行分配的,那么它到底是如何进行分配的呢?下面我们就来详细介绍一下。

判断栈上是否有空间进行分配

这个概念与之前学过的概念有所冲突。因为之前我们学习的概念是通过new出来的对象一般都是在堆内存上进行内存分配的。但是并不是所有的对象都是在堆内存上进行分配,在满足一定的条件的时候,会先在栈内存上进行分配。这个与栈内存的存储特点有关。那么为什么会在栈内存上进行分配呢?满足什么样的条件的时候才会在栈上分配,并且我们知道堆内存有自己的GC机制,但是对栈中的内存如何进行回收呢?

为什么要分配在栈内存上?

首先来讲在JVM内存模型中,对于Java对象的内存分配都是在堆内存上进行的,当堆内存空间不足的时候会触发垃圾回收的GC机制,这个时候没有被其他对象引用的对象就会被回收,这个时候内存就会被清理出来用于新的对象的分配。

我们也知道,在堆内存中的对象内存分配以及GC操作都是比较消耗性能的,在我们创建的一些对象中其实都是临时性的存储。也就是说只使用一次,甚至是有些对象只是中间对象存在。如果能够减少这样的对象在堆内存中内存分配以及GC操作,那么将会对JVM执行效率有很大的提升。这个时候就可以考虑将这种类型的对象分配在栈上,而分配在栈上的对象就会随着入栈出栈操作进行销毁。

在什么情况下会在栈上分配?

根据上面所说的,我们要尝试减少临时对象在堆内存中的分配,JVM通过逃逸分析确定了该对象会不会被外部访问,如果不会逃逸则可以被分配到栈空间上,随着入栈出栈而销毁,这样可以减轻堆内存的压力,同时提升JVM执行效率。那么什么是逃逸分析呢?

什么是逃逸分析?

逃逸分析是目前来看JVM中比较前沿的优化方案,可以有效的减少Java程序中同步负载和堆内存分配压力。通过逃逸分析,JVM能够分析出一个新创建的对象的引用范围,从而确定这个对象到底是分配在堆内存中还是分配在栈内存中?

而逃逸分析的基本操作就是分析对象的动态作用域:当一个对象被定义了之后,它可能会被其他调用所使用,例如通过参数、通过方法调用等等操作被外部所使用。那么这个就被称为是逃逸。例如

public class Test {

    public User test1() {
        User user = new User();
        user.setId(1);
        user.setName("张三");
        return user;
    }

    public void test2() {
        User user = new User();
        user.setId(2);
        user.setName("李四");
    }
}

在上面代码中,创建了两个方法test1()和test2()。并且对于test1()来讲它有一个返回值。而这个返回的对象一定会被外部调用所使用,这种情况就可以称为是逃逸方法。

而对于test2()来讲,同样也创建了一个User对象,但是只在方法内部进行了调用,这个时候这个对象就被称为是非逃逸。

所以简单来讲,判断一个对象是否为逃逸对象,就是要看这个对象是否被其他外部调用所使用。

为什么要将非逃逸对象分配在栈上呢?

综上所述我们来分析一下为什么要将非逃逸的对象分配在栈内存中呢?

首先来讲,栈这种数据结构它有这个先进后出的特点,而在我们日常的开发中都会提到一个叫做方法栈的结构。方法栈中存储的就是那个方法先调用,那个方法后调用,并且按照顺序被存放在方法栈中。然后使用的时候从就可以从最内层方法开始逐级向外调用。

根据上面的代码来进行分析。在test1()中User对象因为是在返回值中,所以是逃逸的,这个时候我们需要将这个对象分配在堆内存上,并且通过一个指向堆内存的引用来进行调用。因为在其他的地方可能会调用到test1()方法返回的User对象,而这个时候需要将对象的引用指向新的对象。

而对于test2()中的对象来讲,由于它只在test2()方法中进行了调用,根据栈的结构特点,test1()的调用完成之后,接下来就要到test2()进行调用了,这个时候test2()中的User对象只会影响test2()方法中的内容,用法之后对应的对象引用就结束了,这个时候如果将这个对象创建到了对内存中,如果对象没有引用的时候,就会被垃圾回收机制处理。从而非常消耗JVM的性能。

所以如果这个对象可以像是test2()方法一样运行结束之后立马就随着出栈操作销毁,那么就不会引起GC,也就不会太消耗JVM性能。这也就出现了在栈上分配对象的需求。如下图所示,图片来源网络。

如何进行逃逸分析?

执行测试代码

public class TestDemo {
    public static void main(String[] args) {
        long a1 = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            User user = new User();
        }
        // 查看执行时间
        long a2 = System.currentTimeMillis();
        System.out.println("cost " + (a2 - a1) + " ms");
        // 为了方便查看堆内存中对象个数,线程sleep
        try {
            Thread.sleep(100000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }
}

我们可以使用如下的命令来进行逃逸分析。

-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

我们会看到在上面的命令中是-XX:-DoEscapeAnalysis表示关闭逃逸分析。结果如下,会看到创建了1000000个实例独享,并且占用的存储字节是24000000多。

(base) nihui:~ nihui$ jmap -histo 5462

 num     #instances         #bytes  class name
----------------------------------------------
   1:           530      102594440  [I
   2:       1000000       24000000  com.example.demo.test.User
   3:          2342        1222872  [B
   4:          4354         665456  [C
   5:           586          67096  java.lang.Class
   6:          2689          64536  java.lang.String
   7:           682          38432  [Ljava.lang.Object;

而笔者使用的是JDK8默认是开启逃逸分析的,会看到结果明显要比上面的结果要少的多。几乎不是同一个量级的。

(base) nihui:~ nihui$ jmap -histo 5476

 num     #instances         #bytes  class name
----------------------------------------------
   1:           533        7010776  [I
   2:        160271        3846504  com.example.demo.test.User
   3:          2342        1222872  [B
   4:          4354         665456  [C
   5:           586          67096  java.lang.Class
   6:          2689          64536  java.lang.String
   7:           682          38432  [Ljava.lang.Object;
   8:           418          13376  java.io.File
   9:           178          11392  java.net.URL

从这里我们可以知道,前面我们所说的在栈内存中分配对象的前提条件之一就是要开启JVM的逃逸分析。但是我们知道对象的大小是我们无法掌握的,有些对象大,有些对象小,但是在JVM中线程栈的大小是默认的1M。也就说如果当一个对象超过了1M的时候这个时候我们就无法在栈上进行分配了。这个时候我们应该如何去解决呢?或者说在我们分配对象的时候要将对象放在一段连续的空间上,如果正好这个时候没有这样一段连续的空间,我们又该如何解决呢?

这个时候JVM就做了一个优化,即便是在栈中没有对应的空间的情况下也可以通过其他方式来进行对象的分配。这个办法就是我们常说的标量替换。

什么是标量替换呢?

如上面所说的,如果在栈空间中没有一块完整的空间可以存放User对象了。这个时候我们就可以采用标量替换的操作。

标量替换操作并不是将整个的User对象都放入到栈中,而将对应的成员变量分别放在不同的空间中,也就将一个完整的对象拆分成小部分分别进行存放。而用来记录这些属性属于哪个对象的操作就被称为是标量替换。我们可以通过如下的参数来开启这个操作。

-XX:+EliminateAllocations

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配了。

在JDK1.7之后这个操作是默认开启的。

总结

上面的内容中我们介绍了逃逸分析相关的内容,并且介绍了如何在栈内存中对对象进行分配的操作。当然这都是对于一些小的对象来进行操作的,那么如果我们在操作过程中遇到了大对象呢?我们应该如何进行分配呢?在后续的分享中我们会详细介绍关于JVM内存中如何对大对象进行分配。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表