Java-JVM面试题

Java 虚拟机面试题 (⭐⭐⭐)

JVM 内存区域。

JVM 基本构成Java/JVM基本构成

从上图可知,JVM主要包括四个部分:

  1. 类加载器(ClassLoader):在JVM启动时或者在类运行时将需要的class加载到JVM中。(下图表示了从java源文件到JVM的整个过程,可配合理解。)

    Java/类加载器加载过程

  2. 执行引擎:负责执行class文件中包含的字节码指令;

  3. 内存区(也叫运行时数据区):是在JVM运行的时候操作所分配的内存区。

    运行时内存区主要可以划分为5个区域,如图:

    Java/运行时内存区

    方法区(MethodArea):用于存储类结构信息的地方,包括常量池、静态变量、构造函数等。虽然JVM规范把方法区描述为堆的一个逻辑部分,但它却有个别名non-heap(非堆),所以大家不要搞混淆了。方法区还包含一个运行时常量池。

    java堆(Heap):存储java实例或者对象的地方。这块是GC的主要区域。从存储的内容我们可以很容易知道,方法和堆是被所有java线程共享的。

    java栈(Stack):java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的java栈,在这个java栈中,其中又会包含多个栈帧,每运行一个方法就建一个栈帧,用于存储局部变量表、操作栈、方法返回等。每一个方法从调用到直至执行完成的过程,就对应一栈帧在java栈中入栈到出栈的过程。所以java栈是现成有的。

    程序计数器(PCRegister):用于保存当前线程执行的内存地址。由于JVM程序是多线程执行的(线程轮流切换),所以为了保证线程切换回来后,还能恢复到原先状态,就需要一个独立计数器,记录之前中断的地方,可见程序计数器也是线程私有的。

    本地方法栈(Native MethodStack):和java栈的作用差不多,只不过是为JVM使用到native方法服务的。

  4. 本地方法接口:主要是调用C或者C++实现的本地方法及回调结果。

开线程影响哪块内存?

每当有线程被创建的时候,JVM 就需要为其在内存中分配虚拟机栈和本地方法栈来记录调用方法的内容,分配程序计数器记录指令执行的位置,这样的内存消耗就是创建线程的内存代价。

JVM 的内存模型的理解?

Java内存模型即Java Memory Model,简称JMMJMM定义了Java 虚拟机(JVM) 在计算机内存(RAM)中的工作方式。JVM 是整个计算机虚拟模型,所以 JMM 是隶属于 JVM 的。

Java 线程之间的通信总是隐式进行,并且采用的是共享内存模型。这里提到的共享内存模型指的就是 Java 内存模型(简称 JMM),JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。总之,JMM 就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before 原则)及其外部可使用的同步手段(synchronized/volatile 等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

需要更全面理解建议阅读以下文章:

全面理解 Java 内存模型(JMM)及 volatile 关键字

全面理解 Java 内存模型

描述一下 GC 的原理和回收策略?

提到垃圾回收,我们可以先思考一下,如果我们去做垃圾回收需要解决哪些问题?

一般说来,我们要解决三个问题:

  1. 回收哪些内存?
  2. 什么时候回收?
  3. 如何回收?

这些问题分别对应着引用管理和回收策略等方案。提到引用,我们都知道 Java 中有四种引用类型:

  • 强引用:代码中普遍存在的,只要强引用还存在,垃圾收集器就不会回收掉被引用的对象。
  • 软引用:SoftReference,用来描述还有用但是非必须的对象,当内存不足的时候会回收这类对象。
  • 弱引用:WeakReference,用来描述非必须对象,弱引用的对象只能生存到下一次 GC 发生时,当 GC 发生时,无论内存是否足够,都会回收该对象。
  • 虚引用:PhantomReference,一个对象是否有虚引用的存在,完全不会对其生存时间产生影响,也无法通过虚引用取得一个对象的引用,它存在的唯一目的是在这个对象被回收时可以收到一个系统通知。

不同的引用类型,在做 GC 时会区别对待,我们平时生成的 Java 对象,默认都是强引用,也就是说只要强引用还在,GC 就不会回收,那么如何判断强引用是否存在呢?

一个简单的思路就是:引用计数法,有对这个对象的引用就+1,不再引用就-1,但是这种方式看起来简单美好,但它却不能解决循环引用计数的问题。

因此可达性分析算法登上历史舞台,用它来判断对象的引用是否存在。

可达性分析算法通过一系列称为 GCRoots 的对象作为起始点,从这些节点从上向下搜索,所走过的路径称为引用链,当一个对象没有任何引用链与 GCRoots 连接时就说明此对象不可用,也就是对象不可达。

GC Roots 对象通常包括:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表)
  • 方法中类的静态属性引用的对象
  • 方法区中常量引用的对象
  • Native 方法引用的对象

可达性分析算法整个流程如下所示:

第一次标记:对象在经过可达性分析后发现没有与 GC Roots 有引用链,则进行第一次标记并进行一次筛选,筛选条件是:该对象是否有必要执行 finalize()方法。没有覆盖 finalize()方法或者 finalize()方法已经被执行过都会被认为没有必要执行。 如果有必要执行:则该对象会被放在一个 F-Queue 队列,并稍后在由虚拟机建立的低优先级 Finalizer 线程中触发该对象的 finalize()方法,但不保证一定等待它执行结束,因为如果这个对象的 finalize()方法发生了死循环或者执行时间较长的情况,会阻塞 F-Queue 队列里的其他对象,影响 GC。

第二次标记:GC 对 F-Queue 队列里的对象进行第二次标记,如果在第二次标记时该对象又成功被引用,则会被移除即将回收的集合,否则会被回收。总之,JVM 在做垃圾回收的时候,会检查堆中的所有对象否会被这些根集对象引用,不能够被引用的对象就会被圾收集器回收。一般回收算法也有如下几种:

  1. 标记-清除(Mark-sweep)

    标记-清除算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

  2. 标记-整理(Mark-Compact)

    标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。该垃圾回收算法适用于对象存活率高的场景(老年代)。

  3. 复制(Copying)

    复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。

  4. 分代收集算法

    不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。Java 堆内存一般可以分为新生代、老年代和永久代三个模块:

新生代:

  1. 所有新生成的对象首先都是放在新生代的。新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。

  2. 新生代内存按照 8:1:1 的比例分为一个 eden 区和两个survivor(survivor0,survivor1)区。大部分对象在 Eden 区中生成。回收时先将 eden 区存活对象复制到一个 survivor0 区,然后清空 eden 区,当这个 survivor0 区也存放满了时,则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 eden 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。

  3. 当 survivor1 区不足以存放 eden 和 survivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。

  4. 新生代发生的 GC 也叫做 Minor GC,MinorGC 发生频率比较高(不一定等 Eden 区满了才触发)。

老年代:

  1. 在老年代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。

  2. 内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC,即 Full GC。Full GC 发生频率比较低,老年代对象存活时间比较长。

永久代:

永久代主要存放静态文件,如 Java 类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class,例如使用反射、动态代理、CGLib 等 bytecode 框架时,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。

垃圾收集器

垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现:

  • Serial 收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
  • Serial Old 收集器 (标记-整理算法): 老年代单线程收集器,Serial 收集器的老年代版本;
  • ParNew 收集器 (复制算法): 新生代收并行集器,实际上是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现;
  • CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短 GC 回收停顿时间。Parallel Old 收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge 收集器的老年代版本;
  • Parallel Scavenge 收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC 线程时间),高吞吐量可以高效率的利用 CPU 时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
  • G1(Garbage First)收集器 (标记-整理算法): Java 堆并行收集器,G1 收集器是 JDK1.7 提供的一个新收集器,G1 收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1 收集器不同于之前的收集器的一个重要特点是:G1 回收的范围是整个 Java 堆(包括新生代,老年代), 而前六种收集器回收的范围仅限于新生代或老年代。

内存分配和回收策略

JAVA 自动内存管理:给对象分配内存 以及 回收分配给对象的内存。

  1. 对象优先在 Eden 分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 MinorGC。

  2. 大对象直接进入老年代。如很长的字符串以及数组。很长的字符串以及数组。

  3. 长期存活的对象将进入老年代。当对象在新生代中经历过一定次数(默认为15)的 Minor GC 后,就会被晋升到老年代中。

  4. 动态对象年龄判定。为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

需要更全面的理解请点击这里

类的加载器,双亲机制,Android 的类加载器。

类的加载器

大家都知道,一个 Java 程序都是由若干个.class 文件组织而成的一个完整的 Java 应用程序,当程序在运行时,即会调用该程序的一个入口函数来调用系统的相关功能,而这些功能都被封装在不同的 class 文件当中,所以经常要从这个 class 文件中要调用另外一个 class 文件中的方法,如果另外一个文件不存在的话,则会引发系统异常。

而程序在启动的时候,并不会一次性加载程序所要用到的 class 文件,而是根据程序的需要,通过 Java 的类加载制(ClassLoader)来动态加载某个 class 文件到内存当的,从而只有 class 文件被载入到了内存之后,才能被其它 class 文件所引用。所以 ClassLoader 就是用来动态加载 class 件到内存当中用的。

双亲机制

类的加载就是虚拟机通过一个类的全限定名来获取描述此类的二进制字节流,而完成这个加载动作的就是类加载器。

类和类加载器息息相关,判定两个类是否相等,只有在这两个类被同一个类加载器加载的情况下才有意义,否则即便是两个类来自同一个 Class 文件,被不同类加载器加载,它们也是不相等的。

注:这里的相等性保函 Class 对象的 equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果以及 Instance 关键字对对象所属关系的判定结果等。

类加载器可以分为三类:

  • 启动类加载器(Bootstrap ClassLoader):负责加载\lib 目录下或者被-Xbootclasspath 参数所指定的路径的,并且是被虚拟机所 识别的库到内存中。
  • 扩展类加载器(Extension ClassLoader):负责加载\lib\ext 目录下或者被 java.ext.dirs 系统变量所指定的路径的所有类库到内存中。
  • 应用类加载器(Application ClassLoader):负责加载用户类路径上的指定类库,如果应用程序中没有实现自己的类加载器,一般就是这个类加载 器去加载应用程序中的类库。
  1. 原理介绍

    ClassLoader 使用的是双亲委托模型来搜索类的,每个 ClassLoader 实例都有一个父类加载器的引用(不是继承的关系,是一个包含的关系),虚拟机内置的类加载器(Bootstrap ClassLoader)本身没有父类加载器,但可以用作其它 ClassLoader 实例的的父类加载器。

    当一个 ClassLoader 实例需要加载某个类时,它会在试图搜索某个类之前,先把这个任务委托给它的父类加载器,这个过程是由上至下依次检查的,首先由最顶层的类加载器 Bootstrap ClassLoader 试图加载,如果没加载到,则把任务转交给 Extension ClassLoader 试图加载,如果也没加载到,则转交给 App ClassLoader 进行加载,如果它也没有加载得到的话,则返回给委托的发起者,由它到指定的文件系统或网络等待 URL 中加载该类。

    如果它们都没有加载到这个类时,则抛出 ClassNotFoundException 异常。否则将这个找到的类生成一个类的定义,将它加载到内存当中,最后返回这个类在内存中的 Class 实例对象。

    类加载机制:类的加载指的是将类的.class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法去内,然后在堆区创建一个 java.lang.Class 对象,用来封装在方法区内的数据结构。类的加载最终是在堆区内的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供了访问方法区内的数据结构的接口。

    类加载有三种方式:

    1. 命令行启动应用时候由 JVM 初始化加载
    2. 通过 Class.forName()方法动态加载
    3. 通过 ClassLoader.loadClass()方法动态加载

    这么多类加载器,那么当类在加载的时候会使用哪个加载器呢?

    这个时候就要提到类加载器的双亲委派模型,流程图如下所示:

    双亲委派模型的整个工作流程非常的简单,如下所示:

    如果一个类加载器收到了加载类的请求,它不会自己立去加载类,它会先去请求父类加载器,每个层次的类加器都是如此。层层传递,直到传递到最高层的类加载器只有当 父类加载器反馈自己无法加载这个类,才会有当子类加载器去加载该类。

  2. 为什么要使用双亲委托这种模型呢?

    因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要让子 ClassLoader 再加载一次。考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的 String 来动态替代 java 核心 api 中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为 String 已经在启动时就被引导类加载器(BootstrcpClassLoader)加载,所以用户自定义的ClassLoader 永远也无法加载一个自己写的 String,除非你改变 JDK 中 ClassLoader 搜索类的默认算法。

  3. 但是 JVM 在搜索类的时候,又是如何判定两个 class 是相同的呢?

    JVM 在判定两个 class 是否相同时,不仅要判断两个类名否相同,而且要判断是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM 才认为这两个 class 是相同的。就算两个 class 是同一份 class 字节码,如果被两个不同的 ClassLoader 实例所加载,JVM 也会认为它们是两个不同 class。

    比如网络上的一个 Java 类 org.classloader.simple.NetClassLoaderSimple,javac 编译之后生成字节码文件 NetClasLoaderSimple.class,ClassLoaderA 和 ClassLoaderB 这个类加载器并读取了 NetClassLoaderSimple.class 文件并分别定义出了 java.lang.Class 实例来表示这个类,对 JVM 来说,它们是两个不同的实例对象,但它们确实是一份字节码文件,如果试图将这个 Class 实例生成具体的对象进行转换时,就会抛运行时异常 java.lang.ClassCastException,提示这是两个不同的类型。

Android 类加载器

对于 Android 而言,最终的 apk 文件包含的是 dex 类型的文件,dex 文件是将 class 文件重新打包,打包的规则又不是简单地压缩,而是完全对 class 文件内部的各种函数表进行优化,产生一个新的文件,即 dex 文件。因此加载某种特殊的 Class 文件就需要特殊的类加载器 DexClassLoader。可以动态加载 Jar 通过 URLClassLoader

  1. ClassLoader 隔离问题:JVM 识别一个类是由 ClassLoaderid + PackageName + ClassName。

  2. 加载不同 Jar 包中的公共类:

    • 让父 ClassLoader 加载公共的 Jar,子 ClassLoade 加载包含公共 Jar 的 Jar,此时子 ClassLoader 在加载 Jar 的时候会先去父 ClassLoader 中找。(只适用 Java)
    • 重写加载包含公共 Jar 的 Jar 的 ClassLoader,在 loClass 中找到已经加载过公共 Jar 的 ClassLoader,是把父 ClassLoader 替换掉。(只适用 Java)
    • 在生成包含公共 Jar 的 Jar 时候把公共 Jar 去掉。

JVM 跟 Art、Dalvik 对比?

GC 收集器简介?以及它的内存划分怎么样的?

  1. 简介:

    Garbage-First(G1,垃圾优先)收集器是服务类型的收集器,目标是多处理器机器、大内存机器。它高度符合垃圾收集暂停时间的目标,同时实现高吞吐量。Oracle JDK 7 update 4 以及更新发布版完全支持 G1 垃圾收集器

  2. G1 的内存划分方式:

    它是将堆内存被划分为多个大小相等的 heap 区,每个 heap 区都是逻辑上连续的一段内存(virtual memory). 其中一部分区域被当成老一代收集器相同的角色 (eden, survivor, old), 但每个角色的区域个数都不是固定的。这在内存使用上提供了更多的灵活性

Java 的虚拟机 JVM 的两个内存:栈内存和堆内存的区别是什么?

Java 把内存划分成两种:一种是栈内存,一种是堆内存。两者的区别是:

  1. 栈内存:在函数中定义的一些基本类型的变量和对象的引用变量都在函数的栈内存中分配。 当在一段代码块定义一个变量时,Java 就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java 会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

  2. 堆内存:堆内存用来存放由 new 创建的对象和数组。在堆中分配的内存,由 Java 虚拟机的自动垃圾回收器来管理。

JVM 调优的常见命令行工具有哪些?JVM 常见的调优参数有哪些?

JVM 调优的常见命令工具包括:

  1. jps 命令用于查询正在运行的 JVM 进程,
  2. jstat 可以实时显示本地或远程 JVM 进程中类装载、内存、垃圾收集、JIT 编译等数据
  3. jinfo 用于查询当前运行这的 JVM 属性和参数的值。
  4. jmap 用于显示当前 Java 堆和永久代的详细信息
  5. jhat 用于分析使用 jmap 生成的 dump 文件,是 JDK 自带的工具
  6. jstack 用于生成当前 JVM 的所有线程快照,线程快照是虚拟机每一条线程正在执行的方法,目的是定位线程出现长时间停顿的原因。

JVM 常见的调优参数包括:

  • -Xmx

    指定 java 程序的最大堆内存, 使用 java -Xmx5000M -version 判断当前系统能分配的最大堆内存

  • -Xms

    指定最小堆内存, 通常设置成跟最大堆内存一样,减少 GC

  • -Xmn

    设置年轻代大小。整个堆大小=年轻代大小 + 年老代大小。所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8。

  • -Xss

    指定线程的最大栈空间, 此参数决定了 java 函数调用的深度, 值越大调用深度越深, 若值太小则容易出栈溢出错误(StackOverflowError) -XX:PermSize指定方法区(永久区)的初始值,默认是物理内存的 1/64, 在 Java8 永久区移除, 代之的是元数据区, 由-XX:MetaspaceSize 指定

  • -XX:MaxPermSize

    指定方法区的最大值, 默认是物理内存的 1/4, 在 java8 中由 -XX:MaxMetaspaceSize 指定元数据区的大小

  • -XX:NewRatio=n

    年老代与年轻代的比值,-XX:NewRatio=2, 表示年老代与年轻代的比值为 2:1

  • -XX:SurvivorRatio=n

    Eden 区与 Survivor 区的大小比值,-XX:SurvivorRatio=8 表示 Eden 区与 Survivor 区的大小比值是 8:1:1,因为 Survivor 区有两个(from, to)

jstack,jmap,jutil 分别的意义?如何线上排查 JVM 的相关问题?

JVM方法区存储内容 是否会动态扩展 是否会出现内存溢出 出现的原因有哪些。

如何解决同时存在的对象创建和对象回收问题?

JVM 中最大堆大小有没有限制?

JVM 方法区存储内容 是否会动态扩展 是否会出现内存溢出 出现的原因有哪些。

如何理解 Java 的虚函数表?

Java 运行时数据区域,导致内存溢出的原因。

对象创建、内存布局,访问定位等。