JVM面经汇总

java虚拟机在执行java程序的过程中,会把它管理的内存划分成若干个不同的数据区域。

JVM的主要组成部分及作用

img

JVM包含两个子系统和两个组件,两个子系统为ClassLoader类装载、Execution Engine执行引擎

两个组件为Runtime Data Area运行时数据区、Native Interface本地接口

介绍下Java内存区域(运行时数据区)

JVM在执行java程序时会将它所管理的内存划分为若干个不同的数据区域。

img

程序计数器

程序计数器是一块较小的内存空间,可以看作当前线程所执行的字节码的行号指示器。字节码解释器工作时,通过改变程序计数器的值选择下一跳需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等功能都需要依据程序计数器来完成。

为了线程切换后能恢复到正确的执行位置,每个线程都需要有独立的程序计数器。由于每个线程的程序计数器是独立存储的,因此各线程之间的程序计数器互不影响,这类内存区域被称为线程私有的内存区域。

注:程序计数器是唯一不会出现 OutOfMemoryError 的内存区域。

虚拟机栈

和程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的内存模型,每个方法被执行的时候会创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息。一个方法被调用直至执行完成的过程对应一个栈帧在虚拟机中从入栈到出栈的过程。

局部变量表存放编译器可知的各种基本数据类型、对象引用类型和返回地址类型。

Java虚拟机栈会出现的两种异常:

  • 若JVM不能动态扩展,当线程请求的栈深度大于JVM所允许的深度时,将抛出 StackOverflowError 异常
  • 若JVM可以动态扩展,当无法申请到足够的内存时,将抛出 OutOfMemoryError 异常

本地方法栈

本地方法栈和虚拟机栈的作用相似。区别在于,虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈为虚拟机使用到的本地方法服务。有的虚拟机(如 HotSpot 虚拟机)把本地方法栈和虚拟机栈合二为一。

和虚拟机栈一样,本地方法栈也会出现 StackOverflowError 和 OutOfMemoryError 两种异常。

堆是JVM管理的内存中最大的一块。堆是被所有线程共享的内存区域,其目的是存放对象实例,几乎所有的对象实例都在堆中分配内存。

Java 堆是垃圾回收器管理的主要内存,因此也称为 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现代编译器基本都采用分代垃圾回收算法,所以 Java 堆还可以分成新生代和老年代,新生代又可以细分成 Eden 区、From Survivor 区、To Survivor 区等。细分成多个空间的目的是为了更好地回收内存或者更快地分配内存

字符串常量池

字符串常量池存放在堆中,包括String对象执行intern方法后存的地方,双引号直接引用的字符串。

方法区

和 Java 堆一样,方法区也是被所有线程共享的内存区域。方法区用于存储已经被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常

注:JDK 1.8 将方法区彻底移除,用元空间取而代之,元空间使用的是直接内存。

运行时常量池

运行时常量池是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息,用于存放编译器生成的字面量和符号引用,这些信息将在类加载后存放到方法区的运行时常量池中。

运行时常量池也受到方法区内存的限制,当常量池无法再申请到内存时将抛出 OutOfMemoryError 异常

直接内存

直接内存不是JVM运行时数据区域的一部分,也不是JVM规范中定义的内存区域,但是这部分也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。

本机直接内存的分配不受到 Java 堆大小的限制,但是直接内存仍然受到本机总内存地大小及处理器寻址空间的限制。若各个内存区域的总和大于物理内存限制,就会导致动态扩展时出现 OutOfMemoryError 异常。

Java内存模型

当多个线程访问同一个对象时,调用这个对象的行为都可以获取正确的结果,那么这个对象是线程安全的。出现线程安全问题一般都是因为 主存与工作内存数据不一致和重排序 造成的,而解决线程安全的问题最重要就是理解这两个问题是怎么产生的,这时就需要理解Java内存模型(JMM)。

Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信
Java内存模型的主要目标就是 定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的细节

JMM抽象结构模型

共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。JMM就从抽象层次定义了这种方式,并且 JMM决定了一个线程对共享变量的写入何时对其他线程是可见的

img

内存模型三大特性

原子性
可见性

可见性:当一个线程修改一个共享变量的值,其它线程能够立即得知这个修改。

JMM是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

实现可见性的方式:

  1. volatile:通过在指令中添加 lock 指令,实现内存可见性。
  2. synchronized:当线程获取锁时,会从内存中获取共享变量的最新值;释放锁时,会将共享变量同步回主内存中。
  3. final:被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
有序性

有序性:在线程中,所有的操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是指发生了指令重排。

JMM允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。volatile通过添加内存屏障来禁止指令重排。

注:

  • synchronized:具有原子性,有序性和可见性
  • volatile:具有有序性和可见性
  • final:具有可见性

内存屏障

JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。

JMM内存屏障分类

JVM面经汇总_第1张图片

happens-before原则

JVM 还规定了先行发生(happens-before)原则,让一个操作无需控制就能先于另一个操作完成。

单一线程原则
管程锁定规则

堆和栈的区别

  • 堆的物理地址分配对象是不连续的;栈遵循先进后出原则,物理地址分配是连续的。
  • 堆分配的内存是在运行时确认的,大小不固定;栈分配的内存是在编译时确认的,大小是固定的。
  • 堆存放对象的实例和数组等;栈存放局部变量、操作数栈、返回结果等;
  • 堆是被所有线程共享的内存区域;栈是线程私有的内存区域;

注:静态变量存在方法区;静态的对象存放在堆。

类加载机制

⭐类加载的执行过程

类加载过程:加载->连接(验证、准备、解析)->初始化

JVM面经汇总_第2张图片

加载

类加载器通过类的全限定名查找到加载的class文件,并通过class文件创建类对象。

连接
验证

验证加载的class文件的正确性。(文件格式验证、元数据验证、字节码验证、符号引用验证)

准备

**为类变量分配内存并设置默认初始值。**不包含final修饰的static,因为final在编译时就已经分配了。也不会为实例变量分配初始化。(类变量分配在方法区中,实例变量会随着对象分配到堆中)

解析

JVM把常量池的符号引用(一个标识)替换成直接引用(指向内存中的地址)。

初始化

初始化类变量和成员变量。

⭐对象的创建过程

img

JVM遇到一条 new 指令时,先检查常量池是否已经加载过相应的类,若没有则会先执行相应的类加载。类加载过后,分配内存。若java堆内存是规整的,使用指针碰撞方式分配内存;若不是规整的,就从空闲列表中分配内存;通过CAS同步处理并发问题。然后对内存空间进行初始化,最后执行方法。

  • 指针碰撞:若堆内存是规整的,即所有用过的内存放一边,空闲的放另一边。分配内存时将位于中间的指针指示器向空闲的内存移动与对象大小相等的距离,完成内存分配。
  • 空闲列表:若堆内存不是规整的,则需要JVM维护一个列表来记录哪些内存是可用的,在分配时就能从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录

对象的内存布局

对象的内存布局一般分为三个部分:对象头,实例数据,对齐填充

对象头可以分为运行时数据和类型指针

  • Markword(运行时数据):用来存储对象自身的运行时数据,如哈希码,GC分带年龄,锁状态标志,偏向线程ID,线程持有的锁等。
  • Klass(类型指针):用来指向对象对应的Class对象的内存地址。
  • Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;

类加载器

通过类的全限定名来获取类的二进制字节流的代码块叫做类加载器。类加载器分别有启动类加载器、扩展类加载器、系统类加载器、用户自定义类加载器。

启动类加载器

Bootstrap ClassLoader**加载java的核心类库,无法被java程序直接引用。**是虚拟机自身的一部分,用来加载JAVA_HOME/lib/目录中的

扩展类加载器

Extensions ClassLoader加载java的扩展类库,JVM的实现会提供一个扩展库目录,该类加载器会在此目录中查找并加载java类。

系统类加载器

System ClassLoader根据类路径来加载java类,java应用的类都是由系统类加载器加载的。

用户自定义类加载器

通过继续 java.lang.ClassLoader类来实现

双亲委派模型

当一个类加载器收到类加载的请求时,不会自己去加载这个类,而是将其委派给父类加载器去完成,只有当父加载器无法完成请求时,子加载器才会尝试去完成

img

内存泄露和内存溢出的场景

内存泄露无用对象一直占用内存或不能及时释放,从而造成内存空间浪费。

内存泄露的原因:长生命周期对象持有短生命周期对象的引用就可能发生内存泄露。

内存泄露场景

  • 静态集合类引起内存泄露
  • 各种连接对象使用后未关闭

内存溢出程序在运行过程中无法申请到足够的内存导致错误。

注:除了程序计数据器之外,其它的运行时区域都有可能抛出OOM异常。

内存溢出场景

堆溢出:堆用来存储对象,因此只要不断创建对象,并保证对象到GC Roots之间存在引用链,那么对象数据达到最大堆容量时就会产生OOM。

/**
 * java堆内存溢出测试
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {

    static class OOMObject{}

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

本地方法栈和虚拟机栈溢出:栈容量由 -Xss 参数 设定。

  • 若线程请求的栈深度大于虚拟机所允许的最大深度,抛出StackOverflowError。
  • 若虚拟机在扩展栈时无法申请到足够的内存,抛出OOM
/**
 * 虚拟机栈和本地方法栈内存溢出测试,抛出stackoverflow exception
 * VM ARGS: -Xss128k 减少栈内存容量
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

    public void stackLeak () {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length = " + oom.stackLength);
            throw e;
        }
    }
}

垃圾回收

垃圾回收,顾名思义就是释放垃圾占用的空间,从而提升程序性能,防止内存泄露。当一个对象不再被需要时,该对象就需要被回收并释放空间。

Java 内存运行时数据区域包括程序计数器、虚拟机栈、本地方法栈、堆等区域。其中,程序计数器、虚拟机栈和本地方法栈都是线程私有的,当线程结束时,这些区域的生命周期也结束了,因此不需要过多考虑回收的问题。而堆是虚拟机管理的内存中最大的一块,堆中的内存的分配和回收是动态的,垃圾回收主要关注的是堆空间。

调用垃圾回收器的方法

调用垃圾回收器的方法是 gc,该方法在 System 类和 Runtime 类中都存在。

在 Runtime 类中,方法 gc 是实例方法,方法 System.gc 是调用该方法的一种传统而便捷的方法。

在 System 类中,方法 gc 是静态方法,该方法会调用 Runtime 类中的 gc 方法。

其实,java.lang.System.gc 等价于 java.lang.Runtime.getRuntime.gc 的简写,都是调用垃圾回收器。

方法 gc 的作用是提示 JVM进行垃圾回收,该方法由系统自动调用,不需要人为调用。该方法被调用之后,由 JVM决定是立即回收还是延迟回收。

finalize方法

与垃圾回收有关的另一个方法是 finalize 方法。该方法在 Object 类中被定义,在释放对象占用的内存之前会被调用。该方法的默认实现不做任何事,如果必要,子类应该重写该方法,一般建议在该方法中释放对象持有的资源。
一个对象是否应该在垃圾回收器在GC时回收,至少要经历两次标记过程。

第一次标记:如果对象在进行可达性分析后被判定为不可达对象,那么它将被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize() 方法。对象没有覆盖 finalize() 方法或者该对象的 finalize() 方法曾经被虚拟机调用过,则判定为没必要执行。

finalize()第二次标记:如果被判定为有必要执行 finalize() 方法,那么这个对象会被放置到一个 F-Queue 队列中,并在稍后由虚拟机自动创建的、低优先级的 Finalizer 线程去执行该对象的 finalize() 方法。但是虚拟机并不承诺会等待该方法结束,这样做是因为,如果一个对象的 finalize() 方法比较耗时或者发生了死循环,就可能导致 F-Queue 队列中的其他对象永远处于等待状态,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,如果对象要在 finalize() 中挽救自己,只要重新与 GC Roots 引用链关联上就可以了。这样在第二次标记时它将被移除「即将回收」的集合,如果对象在这个时候还没有逃脱,那么它基本上就真的被回收了。

引用类型分类

在 JDK 1.2 之后,Java 将引用分成四种,按照引用强度从高到低的顺序依次是:强引用、软引用、弱引用、虚引用

  • 强引用:指在程序中普遍存在的引用。GC永远不会回收被强引用关联的对象;
  • 软引用:指在程序中还有用但并非必需的对象。发生内存溢出前被回收
  • 弱引用:指在程序中非必需的对象。被弱引用关联的对象只能存活到下一次垃圾回收发生前,当GC工作时,被弱引用关联的对象一定会被回收。
  • 虚引用是最弱的引用关系。无法通过虚引用取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。

被引用的对象是否一定能存活

不一定,看引用类型,弱引用在下一次GC时会被回收,软件引用在内存不足时会被回收,但是没有在引用链中的对象就一定会被回收。

判断对象是否可回收

垃圾回收器在对堆进行回收之前,首先需要确定哪些对象是可回收的。常用的算法有两种,引用计数算法和根搜索算法。

引用计数算法

思想:引用计数算法就是给每个对象添加一个引用计数器,用于记录对象被引用的次数,引用计数为0的对象即为可回收的对象无法解决对象之间循环引用的情况

可达性分析(根搜索)算法

根搜索算法的思路是,从 GC Roots 的对象作为起始点开始搜索,当一个对象到 GC Roots 没有任何引用链(搜索所走过的路径称为引用链)相连时,说明对象可以被回收。

在 Java 中,GC Roots 一般包含下面几种对象:

  • 虚拟机栈中引用的对象;
  • 本地方法栈中的本地方法引用的对象;
  • 方法区中的类静态属性引用的对象;
  • 方法区中的常量引用的对象;
OopMap

OopMap 记录了栈上本地变量到堆上对象的引用关系。其作用是:垃圾回收时,收集线程会对栈上的内存进行扫描,查看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。

垃圾回收算法

标记—清除算法

标记—清除算法是最基础的垃圾回收算法,后续的垃圾收集算法都是基于标记—清除算法进行改进而得到的。标记—清除算法分为“标记”和“清除”两个阶段,首先标记所有需要回收的对象,然后统一回收所有被标记的对象

优点:实现简单、不需要对象进行移动。

缺点:效率不高,会产生不连续的内存碎片。

复制算法

复制算法是将可用内存分成大小相等的两块,每次只使用其中的一块,当用完一块内存时,将还存活的对象复制到另外一块内存,然后把已使用过的内存空间一次性清理掉

优点:实现简单,运行高效,不用考虑内存碎片。

缺点:内存使用率不高,只有原来的一半。对象存活率高时会频繁进行复制。

标记—整理算法

标记—整理算法是根据老年代的特点提出的。标记需要回收的对象,然后让所有存活的对象都向一端移动,然后清除边界以外的内存

优点:解决了标记—清理算法存在的内存碎片问题。

缺点;需要对象进行移动,一定程序上降低了效率。

为什么要分为新生代和老年代(分代收集算法)

分代收集算法根据对象的存活周期不同将内存划分为多个区域,一般把 Java 堆分为新生代和老年代。在新生代中,大多数对象的生命周期都很短,因此选用复制算法。在老年代中,对象存活率高,因此选用标记—整理算法

其中新生代又分为1个Eden区和2个Survivor区,通常称为From SurvivorTo Survivor区。

JVM面经汇总_第3张图片

⭐垃圾回收器

新生代收集器

Serial:是一个采用复制算法的新生代单线程收集器。

ParNew:是一个采用复制算法的新生代并行收集器。

Parallel Scavenge:是一个采用复制算法的新生代并行收集器,追求高吞吐量,高效利用CPU。目标是达到一个可控的吞吐量,和ParNew的最大区别是GC自动调节策略;虚拟机会根据系统的运行状态收集性能监控信息,动态设置这些参数,以提供最优停顿时间和最高的吞吐量;

老年代收集器

Serial Old:是一个采用标记整理算法的老年代单线程收集器。

Parallel Old:采用标记整理算法的老年代并行收集器。Parallel Scavenge老年代版本。

CMS:是一个采用标记清除算法的老年代并行收集器。它是以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。

⭐CMS垃圾回收过程
  • 初始标记(会STW):标记GC Roots内直接关联的对象。
  • 并发标记:从GC Roots开始对堆进行可达性分析,找出存活对象。
  • 重新标记:再标记一次。为啥还要再标记一次?因为并发标记并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。
  • 并发清除清理掉标记已死亡的对象

CMS的问题

  • 并发回收导致CPU紧张:在并发阶段,虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。
  • 无法清理浮动垃圾:在并发标记和并发清理阶段,用户线程还在继续运行,就有可能产生新的垃圾对象,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。
  • 内存碎片问题
整堆收集器

G1:**采用标记整理算法。**java堆并行收集器,G1回收的范围是整个Java堆(包括新生代,老年代)

G1是JDK1.7中用于取代CMS的压缩回收器。G1首先会将堆分为大小相等的Region,避免全区域GC。然后追踪每个垃圾堆积的价值大小,通过维护一个优先列表,根据允许的回收时间回收价值最大的Region。G1会采用RememberedSet来存放Region之间的对象引用,其他回收器的新生代与老年代的对象引用,从而避免全堆扫描。

G1垃圾回收过程
  • 初始标记(会STW):标记GC Roots内直接关联的对象。
  • 并发标记:从GC Roots开始对堆进行可达性分析,找出存活对象。
  • 最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
  • 筛选回收(会STW):首先对各个Region的回收价值和成本进行排序,根据用户所期望的 GC停顿时间来制定回收计划。这个阶段可以与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高回收效率。

分代垃圾回收器的工作机制

分代回收器有两个分区:老年代和新生代。新生代主要采用复制算法,新生代里有3个分区:Eden区、To Survivor区、From Survivor区,默认占比是8:1:1。

新生代的执行流程:

  • 当 Eden 区没有足够空间进行分配时,JVM将发起一次 Minor GC。Eden区执行第一次GC后,存活的对象会被移动到其中一个Survivor区。
  • Eden区再次执行GC,会将 Eden区和From Survivor区存活的对象复制到To Survivor区,清空 Eden区和From Survivor区,最后From Survivor和To Survivor区交换(From Survivor变To Survivor,To Survivor变From Survivor)。
  • 每次在From Survivor和To Survivor区移动时都存活的对象年龄就会加1,当年龄到达15时,进入老年代。

分配内存与回收策略

Java 堆可以分成新生代和老年代,新生代又可以细分成 Eden 区、From Survivor 区、To Survivor 区等。对于不同的对象,有相应的内存分配规则。

GC分类
Young/Minor GC

Young/Minor GC指发生在新生代的GC。因为大多数对象的生命同期很短,因此Minor GC会频繁执行,一般回收速度也比较快

Full GC

Full GC又称Major GC,指发生在老年代的GC。出现了Full GC,经常会伴随至少一次Minor GC老年代对象存活时间长,因此Full GC很少执行,而且执行速度会比Minor GC很多。

GC的触发条件
Minor GC触发条件
  • 当Eden没有足够内存分配时,触发Minor GC
Full GC触发条件
  • System.gc()默认触发Full GC
  • CMS GC时出现Concurrent Mode Failure会导致一次Full GC
  • 堆dump带GC默认也是触发Full GC
  • 当准备要触发Minor GC时,若发现统计数据说之前Minor GC的平均晋升大小比目前老年代剩余的空间大,则不会触发Minor GC,而是转为触发Full GC
对象优先在 Eden 区分配

大多数情况下,对象在新生代Eden区分配,当Eden区空间不够时,触发Minor GC。

什么情况下新生代对象会晋升为老年代
大对象直接进入老年代

大对象是指连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。大对象对于虚拟机的内存分配而言是坏消息,经常出现大对象会导致内存还有不少空间时就提前触发垃圾回收以获取足够的连续空间分配给大对象。

注:将大对象直接在老年代中分配的目的是避免在 Eden 区和 Survivor 区之间出现大量的内存复制。

长期存活的对象进入老年代

虚拟机采用分代收集的思想管理内存,因此需要识别每个对象应该放在新生代还是老年代。JVM给每个对象定义了年龄计数器,对象在 Eden 区出生之后,若经过第一次Minor GC之后仍然存活,将进入 Survivor 区,同时对象年龄变为1,对象在 Survivor区每经过一次Minor GC且存活,年龄就增加1,增加到一定阈值(默认为15)时则进入老年代

动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到阈值才能进入老年代。若Survivor区中相同年龄的所有对象的空间总和大于 Survivor 区空间的一半,则年龄大于或等于该年龄的对象直接进入老年代

空间分配担保机制

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间总和,如果这个条件成立,那么 Minor GC 可以确保是安全的。只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。

注:Minor GC后,若对象太大无法进入Survivor区,则会通过分配担保机制进入老年代。

参考文章:

  • Java虚拟机(JVM)面试题(2020最新版)
  • 《面试小抄》之JVM篇21问与答
  • Java虚拟机面试题精选(一)
  • Java虚拟机:垃圾收集原理和垃圾收集器

你可能感兴趣的:(面经,面试,java,jvm,经验分享,后端)