Java 虚拟机面试题

1、JVM内存区域。

  1. 类加载器(ClassLoader):在JVM启动时或者在类运行将需要的class加载到JVM中。
  2. 执行引擎:负责执行class文件中包含的字节码指令;
  3. 内存区(也叫运行时数据区):是在JVM运行的时候操作所分配的内存区。运行时内存区主要可以划分为5个区域。
  4. 方法区(MethodArea):用于存储类结构信息的地方,包括常量池、静态常量、构造函数等。虽然JVM规范把方法区描述为堆的一个辑部分, 但它却有个别名non-heap(非堆),所以大家不要搞混淆了。方法区还包含一个运行时常量池。
  5. Java堆(Heap):存储Java实例或者对象的地方。这块是GC的主要区域。从存储的内容我们可以很容易知道,方法和堆是被所有Java线程共享的。
  6. Java栈(Stack):Java栈总是和线程关联在一起,每当创一个线程时,JVM就会为这个线程创建一个对应的Java栈在这个Java栈中,其中又会包含多个栈帧,每运行一个方法就建一个栈帧,用于存储局部变量表、操作栈、方法返回等。每一个方法从调用直至执行完成的过程,就对应一栈帧在Java栈中入栈到出栈的过程。所以Java栈是现成有的。
  7. 程序计数器(PCRegister):用于保存当前线程执行的内存地址。由于JVM程序是多线程执行的(线程轮流切换),所以为了保证程切换回来后,还能恢复到原先状态,就需要一个独立计数器,记录之前中断的地方,可见程序计数器也是线程私有的。
  8. 本地方法栈(Native MethodStack):和Java栈的作用差不多,只不过是为JVM使用到native方法服务的。
  9. 本地方法接口:主要是调用C或C++实现的本地方法及回调结果。
开线程影响哪块内存?

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

2、JVM的内存模型的理解?

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

Java线程之间的通信总是隐式进行,并且采用的是共享内存模型。这里提到的共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

总之,JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

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

3、描述一下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中要求的年龄。
  5. 需要更全面的理解请点击这里

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

类的加载器

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

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

双亲机制

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

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

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

类加载器可以分为三类:

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

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

当一个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

  • ClassLoader 隔离问题:JVM识别一个类是由 ClassLoaderid + PackageName + ClassName。
  • 加载不同Jar包中的公共类:
    • 让父ClassLoader加载公共的Jar,子ClassLoade加载包含公共Jar的Jar,此时子ClassLoader在加载Jar的时候会先去父ClassLoader中找。(只适用Java)
    • 重写加载包含公共Jar的Jar的ClassLoader,在loClass中找到已经加载过公共Jar的ClassLoader,是把父ClassLoader替换掉。(只适用Java)
    • 在生成包含公共Jar的Jar时候把公共Jar去掉。

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

  1. 简介:Garbage-First(G1,垃圾优先)收集器是服务类型的收集器,目标是多处理器机器、大内存机器。它高度符合垃圾收集暂停时间的目标,同时实现高吞吐量。Oracle JDK 7 update 4 以及更新发布版完全支持G1垃圾收集器
  2. G1的内存划分方式:它是将堆内存被划分为多个大小相等的 heap 区,每个heap区都是逻辑上连续的一段内存(virtual memory). 其中一部分区域被当成老一代收集器相同的角色(eden, survivor, old), 但每个角色的区域个数都不是固定的。这在内存使用上提供了更多的灵活性

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

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

  1. 栈内存:在函数中定义的一些基本类型的变量和对象的引用变量都在函数的栈内存中分配。 当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。
  2. 堆内存:堆内存用来存放由new创建的对象和数组。在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。

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

(1)JVM调优的常见命令工具包括:
  1. jps命令用于查询正在运行的JVM进程,
  2. jstat可以实时显示本地或远程JVM进程中类装载、内存、垃圾收集、JIT编译等数据
  3. jinfo用于查询当前运行这的JVM属性和参数的值。
  4. jmap用于显示当前Java堆和永久代的详细信息
  5. jhat用于分析使用jmap生成的dump文件,是JDK自带的工具
  6. jstack用于生成当前JVM的所有线程快照,线程快照是虚拟机每一条线程正在执行的方法,目的是定位线程出现长时间停顿的原因。
(2)JVM常见的调优参数包括:
  1. -Xmx:指定Java程序的最大堆内存, 使用Java -Xmx5000M -version判断当前系统能分配的最大堆内存
  2. -Xms:指定最小堆内存, 通常设置成跟最大堆内存一样,减少GC
  3. -Xmn:设置年轻代大小。整个堆大小=年轻代大小 + 年老代大小。所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
  4. -Xss:指定线程的最大栈空间, 此参数决定了Java函数调用的深度, 值越大调用深度越深, 若值太小则容易出栈溢出错误(StackOverflowError)
  5. -XX:PermSize:指定方法区(永久区)的初始值,默认是物理内存的1/64, 在Java8永久区移除, 代之的是元数据区, 由-XX:MetaspaceSize指定
  6. -XX:MaxPermSize:指定方法区的最大值, 默认是物理内存的1/4, 在java8中由-XX:MaxMetaspaceSize指定元数据区的大小
  7. -XX:NewRatio=n:年老代与年轻代的比值,-XX:NewRatio=2, 表示年老代与年轻代的比值为2:1
  8. -XX:SurvivorRatio=n:Eden区与Survivor区的大小比值,-XX:SurvivorRatio=8表示Eden区与Survivor区的大小比值是8:1:1,因为Survivor区有两个(from, to)

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

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

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

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

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

14、如何理解Java的虚函数表?

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

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