面试半年凭借这份JVM面试题我终于拿到了字节跳动的offer

72次阅读

共计 8501 个字符,预计需要花费 22 分钟才能阅读完成。

内存区域

虚拟机栈
生命周期与线程相同,描述的是 Java 方法执行的内存模型,每个方法在执行的时候都会创建一个栈帧,用于存取局部变量表、操作数栈、动态链接、方法出口等信息
本地方法栈
与虚拟机栈作用相似,只不过本地方法栈是为虚拟机使用到的 Native 方法服务
程序计数器
内存空间较小,可以看做是当前线程所执行的字节码的行号指示器。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值为空(Undefined)

内存区域最大的一块,此内存区域的唯一目的就是存放对象实例,基本上所有的对象实例分配都是由其分配内存。Java 堆是垃圾收集器管理的区主要区域,因此有时也成为 GC 堆
方法区
也称为非堆,主要用来存取已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

对象内存布局

对象头(Header)
用于存储对象自身的运行时数据,如 HashCode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳
类型指针
实例数据(Instance Data)
对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容
对齐补充(Padding)

仅仅起到占位符的作用

对象访问定位

句柄访问
Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
直接指针访问
Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址

虚拟机栈和本地方法栈异常

如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常
如果虚拟机在拓展时无法申请到足够的内存空间,则抛出 OutOfMemoryError(OOM)异常

对象已死

引用计数法
对象每引用一次就加 1,引用失效则减 1,当引用次数为 0 的时候将进行回收,会出现循环依赖问题,因此虚拟机没有使用此算法
可达性分析
使用 GC ROOTS 来判断一个对象是否可达,不可达将其判断为不可达的对象

回收算法

标记 - 清除算法
将要回收的对象进行标记,回收的时候直接将已标记的对象进行回收,但是很容易产生内存碎片
标记 - 整理算法
将要回收的对象进行标记并移动到内存区域的一端,减少内存碎片的产生,但是这很影响效率
复制算法
新生代的对象大部分都是朝夕生死的,使用复制算法将不需要回收的对象移动到 Survivor 区,为 Eden 区腾出空间,因为对象优先在 Eden 分配,年轻代中默认为 Eden:Survivor 为 8:1,其中 Survivor 有两个
分代收集算法
只是根据对象存活周期的不同将内存划分为几块,一般是将 java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法
在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,那就选中复制算法,只需要少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用标记–清理或标记–整理算法

HotSpot 算法实现

枚举根节点
安全点(Safepoint)
安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都是非常短暂,程序不太可能因为指令长度太长这个原因而过长时间运行,“长时间运行”的最明显的特征就是指令序列复用,如方法调用、循环跳转、异常跳转
产生安全点
方法调用
循环跳转
异常跳转
安全区域(Safe Region)
Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配 CPU 时间,典型的例子就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全的地方挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,需要安全区域来解决
在一段代码片段中,引用关系不会发生变化,在这个区域中的任意地方开始 GC 都是安全的,可以把 Safe Region 看成是被拓展了的 Safepoint

垃圾收集器

并行与并发的概念
并行(Parallel):指多条垃圾收集器线程并行工作,但此时用户线程仍然处于等待状态
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续执行,而垃圾回收器程序运行于另一个 CPU 上
新生代
Serial(JDK1.3.1 之前)
单线程收集器,进行垃圾回收时必须暂停其他所有的工作线程,知道它收集结束。优点是简单高效(与其他收集器的单线程相比),没有线程交互开销
ParNew(JDK1.3)
Serial 的多线程版本,除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处
Parallel Scavenge(JDK1.4)
使用复制算法的收集器,使用并行的多线程收集器,为了达到一个可控制的吞吐量,即 CPU 用于执行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 用户代码执行时间 /(用户代码执行时间 + 垃圾收集时间)),也称为吞吐量优先收集器
老年代
Serial Old
Serial 收集器的老年代版本,单线程收集器,使用标记——整理算法。主要意义也是在于给 Client 模式下的虚拟机使用,GC 时需要 STW
Parallel Old(JDK1.6)
Parallel Scavenge 收集器的老年代版本,使用多线程和标记——整理算法
CMS(JDK1.5,Concurrent Mark Sweep)
使用标记——清除算法,以获取最短回收停顿时间为目标的收集器。优点:并发收集,低停顿,Sun 公司也称之为并发低停顿收集器(Concurrent Low Pause Collector)
运行步骤
初始标记(需要 STW)
标记一下 GC Roots 能直接关联到的对象,速度很快
并发标记
进行 GC Roots Tracing 的过程
重新标记(需要 STW)
为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一点,但远比并发标记的事件短
并发清除
缺点
对 CPU 资源非常敏感
无法处理浮动垃圾
由于采用了标记——清除算法,所以这很容易导致产生大量空间碎片
G1(JDK1.7,Garbage First)
运行步骤
初始标记
并发标记
最终标记
筛选回收
面向服务端应用
特点
并行与并发
充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 STW(Stop The World,GC 进行时需停顿所有的 Java 执行进程)停顿的时间
分代收集
空间整合
可预测的停顿

内存分配与回收策略

新生代 GC 与老年代 GC
新生代 GC(Minor GC)
指发生在新生代的垃圾回收动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快
老年代(Major GC / Full GC)
指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行进行 Major GC 的策略选择过程)。Major GG 的速度一般会比 Minor GC 慢 10 倍以上
对象优先在 Eden 分配
对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB(Thread Local Allocation Buffer 本地线程分配缓冲区)上分配
大对象直接放入老年代
大对象指的是需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串和数组,尽量避免出现朝生夕灭的大对象
长期存活的对象将进入老年代
虚拟机为每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当其年龄增加到一定程度(默认为 15 岁),就会晋升到老年代。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置
空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlerPromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;如果大于,将尝试进行一次 Minor GC,尽管这个 Minor GC 是有风险的;如果小于,或者 HandlerPromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC

JVM 常用命令

jps
类似于 Linux 中的 ps 命令,列出正在执行的虚拟机进程,并显示虚拟机执行主类 (Main Class,main() 函数所在的类)名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID),是使用频率最高的 JDK 命令行工具,因为其他的 JDK 工具大多需要输入它查询到的 LVMID 来确定要监控的是哪一个虚拟机进程
jps [options] [hostid]
options
-q
只输出 LVMID,省略主类的名称
-m
输出虚拟机进程启动时传递给主类 main()函数的参数
-l
输入主类的全名,如果进程执行的是 Jar 包,输出 Jar 包路径
-v
输出虚拟机进程启动时的 JVM 参数
hostid
RMI 注册表中注册的主机名
jstat
虚拟机统计信息监视工具,可以显示本地或远程虚拟机中的类加载、内存、垃圾收集、JIT 编译等运行数据
jstat [option vmid [interval[s | ms] [count]] ]
options 列举 2 个
-class
监视类装载、卸载数量、总空间以及类装载所耗费的时间
-gc
监视 Java 状况,包括 Eden 区、两个 Survivor 区、老年代、永久代等的容量、已用空间、GC 时间合计等信息
interval 和 count 代表查询间隔和次数,如果省略这两个参数,说明只查询一次;eg:
jstat -gc 2764 250 20
需要每 250ms 查询一次进程 2764 垃圾收集情况,一次查询 20 次
jinfo
Java 配置信息工具,实时查看和调整虚拟机各项参数
jinfo [options] pid
options
jinfo 对于 Windows 平台功能仍然有较大限制,只提供了最基本的 -flag 选项
eg:查询 CMSInitiatingOccupancyFraction 参数值
jinfo -flag CMSInitiatingOccupancyFraction 1444
jmap
Java 内存影像工具(Memory Map for Java),jmap 命令用于生成堆转储快照(一般称为 headdump 或 dump 文件)。和 jinfo 命令一样,jmap 有不少功能在 Windows 平台都是受限的
jmap [option] vmid
options 列举 4 个
-dump
用于生成 Java 堆转储快照
-finalizerinfo
显示在 F -Queue 中等待 Finalizer 线程执行 finalize 方法的对象。只在 Linux/Solaris 平台才有效
-head
显示 Java 堆详细信息,如使用哪种回收器、参数配置、分代状况等。只在 Linux/Solaris 平台下有效
-histo
显示堆中对象统计信息,包括类、实例数量、合计容量
jhat
虚拟机堆转储快照分析工具,Sun JDK 提供 jhat(JVM Heap Analysis Tool)命令与 jmap 搭配使用,来分析 jmap 生成的堆转储快照,jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 文件的分析结果后,可以在浏览器中查看。
jstack
Java 堆栈跟踪工具,jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为 threaddump 或者 javacore 文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因。如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿的时候通过 jstack 来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者等待些什么资源
jstack [option] vmid
-F
当正常输出的请求不被响应时,强制输出线程堆栈
-l
除堆栈外,显示关于锁的附加信息
-m

如果调用到本地方法的话,可以显示 C /C++ 的堆栈

类加载

加载
通过一个类的全限定名来获取定义此类的二进制字节流
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
(Class 对象比较特殊,存放在方法区里)
二进制流获取路径
从 ZIP 包中获取,这很常见,最终成为 JAR、EAR、WAR 格式的基础
从网络中获取,如从 Applet 应用中获取
运行时计算生成,如动态代理技术(JDK 动态代理或 cglib),在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为”*$Proxy“的代理类的二进制流
由其他文件生成,如 JSP 应用,由 JSP 文件生成对应的 Class 类
从数据库中读取,比较少见,有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发
验证
文件格式验证
验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处
是否以魔数 0xCAFEBABE 开头
主、次版本号是否在当前虚拟机处理范围之内
常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求
这个类是否有父类(除了 java.lang.Object 之外,所有的类应当有父类)
这个类的父类是否继承了不允许继承的类,如被 final 修饰的类
如果这个类是抽象类,是否实现了其父类或接口之中要求实现的所有方法
类中的字段、方法是否与父类产生矛盾(如覆盖了父类的 final 字段、或者出现不符合规则的方法重载、重写)
字节码验证
验证过程最为复杂,主要目的是通过数据流和控制流分析,以确定程序语义是合法的、符合逻辑的
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作数栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中
保证跳转指令不会调转到方法体以外的字节码指令上
保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的
符号引用验证
符号引用中通过字符串描述的全限定名是否能找到对应的类
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
符号引用中的类、字段、方法的访问性
(private、protected、public、default)是否可以被当前类访问
准备
为类变量(被 static 修饰的变量,不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配
初始值通常情况下是数据类型的零值
如果类字段的字段属性表中存在 ConstantValue 属性,那么在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值(被 final 修饰的类变量在编译时会生成 ConstantValue 属性)
eg:public static final int value = 123;
在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值给 123
解析
解析阶段是虚拟机将常量池内的符号引用(在 Class 文件中以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量)替换成直接引用的过程
符号引用(Symbolic References)
符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中
直接引用(Direct References)
直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在
类或接口的解析
字段解析
类方法解析
接口方法解析
初始化
必须立即对类进行初始化的 5 种情况
遇到 new(实例化一个对象)、getstatic(读取一个类的静态字段【被 final 修饰、已在编译期把结果放入常量池的静态字段除外】)、putstatic(设置一个类的静态字段【被 final 修饰、已在编译期把结果放入常量池的静态字段除外】)或 invokestatic(调用一个类的静态方法)字节码指令时
使用 java.lang.reflect 包的方法对类进行反射调用的时候
当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
包含 main 方法的类(执行的主类)
当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的的类没有进行过初始化,则需先触发其初始化
主动引用
上述五种情况均为主动引用
被动引用
所有引用类的方法都不会触发初始化
通过子类引用父类的静态字段,不会导致子类初始化
通过数组定义来引用类,不会触发此类的初始化
常量在译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
类构造器()

可由类中的 static{}语句块产生
也可由接口中的定义的常量产生,接口中不能定义 static{}语句块,需要注意的是,执行接口的 () 方法不需要先执行父接口的 () 方法,只有当父接口的常量使用时,父接口才会初始化
它不要显示调用地父类构造器,虚拟机会保证在子类的 () 方法执行之前,父类的 () 方法已经执行完毕。可以得出 java.lang.Object 是第一个先执行 () 方法的类
虚拟机会保证一个类的 () 方法在多线程环境中被正确加锁、同步,如若多个线程同时去初始化一个类,那么只有一个线程执行这个类的 () 方法,其他线程阻塞等待,直至这个线程执行完 () 方法。其他线程唤醒之后不会再次进入 () 方法
() 方法不是必须的,如果类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 () 方法
实例构造器 ()
使用
卸载

类加载器

类加载器分类
从 JVM 的角度上看
启动类加载器 Bootstrap ClassLoader
由 C ++ 实现,是虚拟机的一部分
所有其他的类加载器
由 Java 实现,独立于虚拟机外部,并且全部继承自抽象类 ClassLoader
从 Java 开发人员角度上看
启动类加载器 Boostrap ClassLoader
拓展类加载器 Extension ClassLoader
应用程序类加载器 Application ClassLoader
自定义类加载器 User ClassLoader
双亲委派模型
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都是应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载

下面附上我自己整理的路线图:


最后:

上面的路线图只是一部分,欢迎大家关注我的公众号:前程有光,路线图都放在我的公众号里面了,另外整理了 1000 多道将近 500 多页 pdf 文档的 Java 面试题资料关注后回复领取资料即可领取到,文章都会在里面更新,整理的资料也会放在里面。

正文完
 0