Skip to content

JVM面试题

Updated: at 01:12 PM

1. JVM

1.1. JVM内存结构

1.1.1. 简单介绍java内存结构/JVM,虚拟内存,物理内存的区别?

图示(JDK1.7):

Java 运行时数据区域(JDK1.7)

图示(JDK1.8):

Java 运行时数据区域(JDK1.8 )

共享的内存:方法区(1.8之后改为元空间,从虚拟机内存转移到本地内存,里面有运行时常量池),堆(位于虚拟机内存中,内含字符串常量池)

私有的内存:每个线程有单独的虚拟机栈,本地方法栈,程序计数器

简单介绍java各个内存区域
  1. 堆内存:JVM在创建时向Linux申请一大块内存用于自动内存管理,根据GC的方式不同,堆内存的分区也不同,例如JDK8中默认的Parallel Scavenge会把堆内存分为新生代和老年代,两者的比例为2:1,新生代中又分了一个Eden和两个Suvivor,其比例默认为8:1:1,而G1GC则会被堆内存分为1,2,4,8M不等。
  2. 栈:虚拟机存放了java方法执行中的局部变量表,操作数栈,方法出口等信息
  3. 方法区:JDK7之前由永久代(PermGen)实现,JDK8之后由元数据区(metaspace)实现。存放了<1>类信息,包括类的接口信息,父类信息。<2>JIT之后的CodeCache。<3>常量池。<4>静态变量:包括类级别变量和接口中的变量,全部方法
方法区的PermGen|metaspace的区别?JDK8为什么要把方法区移到metaspace?

方法区是jvm规范,PermGen和metaSpace是方法区的实现

原因:

  1. 永久代存在虚拟机内存中,会为GC带来不必要的复杂性,而metaspace存放在本地内存中
  2. 类及方法的信息比较难确定大小,因此对于永久代的大小指定比较困难,太小容易永久代溢出,太大则容易导致老年代溢出
静态变量存储在哪?

静态方法(实际上所有方法)以及静态变量都存储在方法区,因为它们是反射数据的一部分(类相关数据,而不是与实例相关的)。

程序计数器是什么?程序计数器什么时候为空?
  1. PC是程序流的指示器,分支,循环,跳转等基础功能都需要依赖这个程序计数器,是线程私有的
  2. 如果是执行java方法,则存储JVM指令地址。否则如果执行native方法,则是undefined

1.1.2. java对象一定分配在堆上吗?讲讲内存逃逸?

逃逸分析是指JVM会收集对象的信息来判断对象的作用域有没有逃出当前方法,例如在方法中声明了一个StringBuffer sb且调用了sb.toString(),那么sb就没有发生逃逸

DEMO:

public static StringBuffer escape(String s1, String s2){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(s1);
        stringBuffer.append(s2);
        return stringBuffer;
    }
 
    public  void noEscape(String s1,String s2){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(s1);
        stringBuffer.append(s2);
    }

对于未发生逃逸的变量,JVM会进行三种优化:

  1. 同步消除:将synchronized关键字消除
  2. 栈上分配:将对象由堆上分配转为栈上分配
  3. 分离对象:如果原来new了一个对象,那么并不会创建该对象,而是创建其中的基础变量

1.1.3. 什么是符号引用?对象访问的两种方式?

符号引用和直接引用

符号引用是解析阶段使用的,可以任意字面量的形式,被引用的类、方法或者变量还没有被加载到内存。

直接引用则是具体内存的指针,被引用的类、方法或者变量已经被加载到内存中了。

类加载的解析阶段就是完成常量池中的符号引用转化为直接引用。

JVM是如何区分是引用类型还是基本类型?他们的存放位置有什么不同?

当声明的是局部变量的时候,JVM 会将基本类型变量名和值放在栈帧中,将引用类型的内存地址放在栈帧中,数据在堆中

对象的访问定位有哪两种方式?
  1. 句柄

​ 在堆中开辟的句柄池,到对象实例的指针和对象元数据的指针

​ 优点:gc回收,当有多个reference指向同一个句柄池,如果需要对象移动,只需要改变到对象实例数据的指针

img

  1. 直接指针

​ 直接指针可以直接访问实例对象,对象头中存放Class MetaData,减少了一次内存开销,但是增大了gc回收器的实现难度

​ 优点:减少一次内存开销

​ 缺点:增大了gc的难度,还是考虑多个reference指向同一个对象实例数据,所有reference都要改

img

1.1.4. 解析和分派

解析

像静态方法、私有方法、实例构造、父类方法(final方法)这些,指令调用是invokespecial或者invokestatic,在类加载后其方法就被解析为直接引用了,所调用的方法就已经确定了。

分派

分为静态分派和动态分派

  1. 静态分派比如方法重载,参数根据静态类型确定

  2. 动态分派比如方法重写,调用方根据动态运行时确定,即先完成符号引用到直接引用的解析得到调用方的实际类型,然后通过虚函数表vtable得到要调用的方法

vtable

vtable虚函数表主要是加快查找具体调用类型的方法签名,主要作用是如果子类没有重写父类方法,则方法入口地址是一样的。即使重写了,索引号也是一样的。这样可以快速定位。

1.2. 类加载机制

1.2.1. new一个对象底层都发生了什么?

<1>类加载检查:查找该类有没有被加载过,如果没有,则执行类加载过程

<2>分配内存:两种方式,指针碰撞空闲列表

<3>初始化零值

<4>设置对象头:例如设置对象头的类数据类型引用

<5>执行init方法:调用init方法执行构造方法

类加载的过程
  1. 加载:获取此类的源信息并转化为方法区的运行时数据结构,即在方法区内存中生成一个java.lang.Class对象,作为这个类各种数据的访问入口。加载与双亲委派机制有关,优先调用父类的加载器去加载。
  2. 链接:包括三个子阶段:
    1. 验证:<1>确保class的信息格式符合虚拟机的需求且安全
    2. 准备:<1>为类中的静态字段分配内存,并设置初始值为默认值0 <2>这里不包含final修饰的变量,因为final变量在编译的时候已经确定了 <3>不会为实例变量分配内存初始化,因为实例变量是在堆中分配内存的
    3. 解析:<1>将常量池中的符号引用转化为直接引用
  3. 初始化:<1>执行类构造器clinit()方法,JVM会自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来, 需要区分init()方法
类加载的顺序?

父类的静态成员变量,父类的静态代码块->子类的静态成员变量,子类的静态代码块,父类的成员变量,父类非静态代码块,父类构造器->子类的成员变量,子类的非静态代码块,子类的构造器

why?

子类先加载父类,Clinit()是class加载时候调用的,是先于new创建实例的

new过程不知道为什么先是成员变量,再是非静态代码块,再是构造函数

类加载的时机
  1. new、getstatic、putstatic、invokestatic指令
  2. 反射
  3. 父类为初始化
  4. 虚拟机启动,main()
分配内存的两种方式?如何保证线程安全的?
两种方式

指针碰撞:前提是内存分为空闲的和非空闲的,只需要挪动划分区域的指针,适合空间压缩整理的垃圾回收器。

空闲列表:列表记录哪些空间是空闲的,适合基于清除算法的垃圾回收器。

如何保证并发安全的?
  1. TLAB:为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述CAS进行内存分配

  2. 在堆上分配内存时,会使用CAS + 重试来申请内存

1.2.2. 类文件结构?(会考吗)

 ClassFile { 
     u4 magic; //Class ⽂件的标志 
     u2 minor_version;//Class 的副版本号 
     u2 major_version;//Class 的主版本号 
     
     u2 constant_pool_count;//常量池的数量 
     cp_info constant_pool[constant_pool_count-1];//常量池 
     u2 access_flags;//Class 的访问标记 
     
     u2 this_class;//当前类 
     u2 super_class;//⽗类 
     
     u2 interfaces_count;//接⼝数量
     u2 interfaces[interfaces_count];//⼀个类可以实现多个接⼝ 
     u2 fields_count;//Class ⽂件的字段属性 
     field_info fields[fields_count];//⼀个类会可以有个字段 
     u2 methods_count;//Class ⽂件的⽅法数量 
     method_info methods[methods_count];//⼀个类可以有个多个⽅法 
     u2 attributes_count;//此类的属性表中的属性数 
     attribute_info attributes[attributes_count];//属性表集合 
}
  1. 魔数cafebabe
  2. 副版本号和主版本号
  3. 当前类和父类的信息 this_class,super_class
  4. 常量池数量和常量池 constant_pool
  5. class访问标记access_flags
  6. 字段数量和字段(字段是开发者定义的,例如private int a),fields
  7. 属性数量和属性(属性属于JVM添加的,例如exceptions表示该方法可能抛出的异常),attributes
  8. 接口数量和接口 interfaces
  9. 方法数量和方法 methods

1.2.3. 类加载器有哪些?双亲委派?

类加载器有哪些?
  1. 启动类加载器:C++实现,嵌入JVM内部,用来加载java核心类库,用于提供JVM自身需要的类,并不继承自java.lang.ClassLoader,唯一没有父加载器
  2. 扩展类加载器:由java语言编写,继承自ClassLoader,父类是启动类加载器,主要负责加载JRE/lib/ext目录下的jar包和类。例如JMX类提供了对java应用程序的监控。
  3. 应用程序类加载器:继承自ClassLoader,负责加载当前应用classpath下的jar包和类
用户自定义类加载器的实现步骤?

继承自抽象类ClassLoader

几个关键方法的伪代码:

loadClass()
protected loadClass() {
    // 先从缓存中查找
    Class<?> c = findLoadedClass(name);
    if(c == null) {
        c = parent.loadClass(...);
        // 委派给父类去加载。如果没有父类,则交给启动类加载器去加载
    }
    if(c == null) {
        c = findClass(...);
        // 如果父类加载器也没加载成功,则用自定义的findClass()去加载
    }
}
findClass()

默认ClassLoader接口没有实现findClass(),会抛出异常。

一种实现方式:

protected Class<?> findClass() {
	// 获取到Class文件的字节流
	byte[] classData = getClassData(...);
	// 将byte字节流转化为Class对象
	return defineClass(...);
}
defineClass()

将byte字节流转化为JVM能识别的Class对象

resolveClass()

解析Class对象

自定义类加载器的步骤

如果要打破双亲委派原则,继承ClassLoader重写loadClass()方法。如果不打破双亲委派原则,继承自ClassLoader并重写findClass()方法。

为什么需要用户自定义类加载器?
  1. 需要隔离加载类:应用引入中间件,中间件和应用模块是分开的,要隔离的话就要把这个类加载到不同的环境,确保应⽤中使⽤的Jar包和中间件使⽤的Jar包是不冲突的。再者一些中间件如应用服务器本身就有不同应用环境隔离的需求。方法就是每个应用自定义类加载器并打破双亲委派原则,以实现隔离。
  2. 扩展加载源:DriverManager是启动类加载器加载的,不能加载各种数据源的驱动,反委派
双亲委派的好处?

可以避免类的重复加载,确保一个类的全局唯一性

1.3. GC原理

1.3.1. 什么是强、软、弱、虚引用?

强引用

强引用是在程序代码中普遍存在的,例如Object obj = new Object();这类引用就是强引用。只要强引用还存在,就永远不会被回收。

软引用

有用但非必需的对象,系统要OOM之前,会把软引用回收掉,SoftReference softRef = new SoftReference<>(obj);

弱引用

指关联的对象只能生存到下一次GC之前,当GC时无论如何都会回收掉弱引用。WeakReference weakRef = new WeakReference<>(obj);

虚引用

虚引用不会对生存时间产生影响,必须配合引用队列使用,只是在GC了之后通过队列能有通知。

DEMO:强引用,软引用,弱引用,虚引用_强软弱引用-CSDN博客

1.3.2. 死亡对象判断算法有哪些?

引用计数法:一个对象被引用一次,计数器+1

缺点:无法解决循环引用问题

可达性分析:GC Roots开始,能遍历到的对象记为存活

三色标记算法:

  1. 白色:未标记的对象
  2. 黑色:已经确认存活的对象
  3. 灰色:正在遍历的对象
方法区的回收

废弃常量和不再使用的类型

废弃常量指常量池引用计数为0的常量

无用的类指<1>该类所有实例被回收<2>加载该类的ClassLoader被回收<3>该类的java.lang.Class没有任何地方被引用(没有被反射访问)

1.3.3. 有哪些可以作为GC Root?

所有不在堆上的对象:

  1. 虚拟机栈中的对象
  2. 本地方法栈中的对象
  3. 方法区中常量引用的对象,比如字符串常量
  4. 方法区中类静态属性引用的对象

1.3.4. GC的触发时机

  1. JVM的模板解释器在解析new运算符的时候进行GC判断。如果Eden放不下则Minor GC,如果Eden仍然放不下则尝试allocate到Old,如果Old放不下就Full GC
  2. 调用system.gc()

1.3.5. 垃圾收集算法

标记-清除算法

最简单的算法,先标记,后清除。

缺点:<1>会产生大量碎片。<2>需要维护一个空闲链表

标记-复制算法

半区复制,标记以后将存活的复制到另一个半区。新生代适合,因为新生代存活率低,复制的少,且速度快。

缺点:<1>空间减半<2>比如复制的很多,复制耗时

标记-整理(压缩)算法

标记,将存活的往一端移动,清除边界的。适合老年代。

缺点:效率要低于复制算法

总结

  1. 标记复制算法最快,但是需要两倍空间
  2. 标记清除算法速度中等,但是会产生内存碎片
  3. 标记整理算法最慢,但是不会堆积碎片
说明一下JVM分代收集的含义与原理
  • 弱分代假说: 绝大多数对象都是朝生夕灭的
  • 强分代假说: 熬过越多次垃圾收集过程的对象就越难以消亡
  • 跨代引用假说: 跨代引用相对于同代引用仅占极少数

因此新生代:老年代 = 2 : 1

1.3.6. CMS/G1/ZGC的区别?

CMS只是针对老年代的GC,采用的是标记-清除算法(cms即并发的mark-sweep),G1 GC采用的对整个堆的GC(Mixed GC),采用的是标记整理法。

两者的标记阶段类似,都是初始标记,并发标记,重新标记三个阶段。

重新标记的方法不同,CMS使用的是增量更新算法,即之前标记出来新增加的黑色->白色的边,以黑色为GC Root重新标记。G1使用的SATB原始快照方法,即当作没切断一样重新做一遍GC。

清除阶段由于内存模型的不同有较大的差别

CMS的工作原理
  1. 初始标记(STW):标记出所有的GC Roots直接关联的对象,需要STW但速度非常快
  2. 并发标记:遍历整个对象图,耗时比较长但是可以与用户线程并行执行
  3. 重新标记(STW):修改并发标记期间,因用户进程继续运作导致标记错误的记录,需要STW但时间比较短
  4. 并发清除:清除死亡的对象,由于不需要移动存活对象,所以可以并行执行。

在这里插入图片描述

G1的工作原理
  1. 初始标记(STW):标记GC Root能直接关联到的对象
  2. 并发标记:从GC Root开始对堆中的对象进行可达性分析,遍历整个对象图
  3. 重新标记(STW):处理遗留的SATB记录
  4. 回收:对选出的region按照价值进行排序,决定要把回收的region存活对象复制到空的region中,再清理掉旧的region对象(类似于清理-标记算法)
G1回收器的堆内存
  1. Region:把堆分为默认2048个大小相同的独立Region块,整体被控制在1MB到32MB之间,且为2的N次幂。
  2. Humongous:对于大小超过0.5 region的对象,JVM会认为其是大对象,直接放到Humongous区域。该区域在Minor GC和Major GC都可以被回收。该区域永远不能被移动。
  3. 虽然还保留着新生代和老年代的逻辑概念,但是物理上已经不再隔离,一个Region有可能属于Eden,Survivor或者Old内存区域。
G1回收器如何实现可预测的停顿时间模型?

什么是”停顿时间模型”:指在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  1. G1每次只选择一定比例的Region进行回收,而不是回收全部的Region(整个堆)
  2. G1跟踪各个Region的价值大小(获得的空闲空间大小与所付出的代价,即性价比),每次优先回收性价比高的region
CMS和G1的优缺点
CMS的目标

<1>使得回收的停顿时间最短<2>第一次实现了让垃圾收集线程与用户线程同时工作

CMS缺点总结
  1. 垃圾-清除算法,会产生大量碎片->分配大对象->提前Full GC
  2. 因为在并发标记和并发清理阶段用户线程还是继续执行,导致产生浮动垃圾,从而产生大量Full GC。
G1收集器适用的场景
  1. 面向服务器应用,针对具有大内存、多处理器的机器
  2. 堆内存大,G1只清理部分region
  3. 低延迟,停顿时间可控
G1收集器的缺点
  1. G1垃圾收集器会有更高的内存占用,占用堆内存10%-20%
  2. 在小内存的应用上表示不如CMS垃圾收集器

1.4. JVM调优

1.4.1. JVM有哪些常用的参数?

  1. -Xmn:设置新生代大小
  2. -XX:SurvivorRatio:设置Survivor区和Eden区大小
  3. -Xmx -Xms:设置堆内存最大值、最小值
  4. -XX:NewRatio:设置新生代老年代的比例

1.4.2. Minor GC运行很频繁的原因?

新生代空间太小,产生了太多朝生夕灭的对象

1.4.3. Minor GC运行时间太慢的原因?

<1>新生代空间太大 <2>对象引用链太长 <3>Survivor区太小,被迫提前去老年区(复制开销)

1.4.4. Full GC运行很频繁的原因?

<1>产生了大量的大对象 <2>老年代空间太小

1.4.5. gc一定会停顿吗?

-XX:+UseEpsilonGC开启不回收垃圾的gc