基础知识
JVM整体架构
模块 | 中文名 | 说明 |
---|---|---|
Class Loader | 类加载器 | 依据特定格式加载class文件到内存 |
Runtime Data Area | 运行时数据区 | JVM内存空间 |
Execution Engine | 执行引擎 | 对字节码进行解释,提供垃圾回收功能 |
Native Interface | 本地方法接口 | 融合不同开发语言的原生库 |
开发者需重点关注Runtime Data Area的内存结构模型,以及Execution Engine的垃圾回收机制。
Class Loader分类
加载器 | 中文名 | 说明 |
---|---|---|
Bootstrap Class Loader | 启动类加载器 | C++编写,用于加载核心类库(绝大多数来自lib) |
Extension Class Loader | 扩展类加载器 | Java编写,用于加载扩展类库(绝大多数来自lib/ext) |
System Application Class Loader | 系统类加载器 | Java编写,用于加载剩余类库 |
Customer Class Loader | 自定义类加载器 | Java编写,用于实现自定义功能 |
双亲委派机制
Class Loader工作流程
- Loading(加载):加载class字节码到内存中,将静态数据转换为Runtime Data Area中的运行时数据,并生成该类所对应的
java.lang.Class
对象(作为方法区该类数据的访问入口) - Linking(链接):链接分为验证、准备和解析三个阶段。验证阶段将检查class字节码的正确性,准备阶段为类变量分配空间并设置初值,解析阶段会将符号引用转换为直接引用
- Init(初始化):执行类变量赋值和静态代码块
类的加载方式
- 隐式加载:使用
new
关键字生成对象时,JVM会自动加载该类 - 显式加载:手动调用
ClassLoader.loadClass
或Class.forName
方法加载类(loadClass加载的类未被链接和初始化,而forName加载的类已被初始化)
运行时数据区线程占用
所谓的线程独占,指对于每一个线程将创建单独的模块,不共享资源
程序计数器
- 程序计数器是当前线程所执行的字节码的逻辑行号指示器
- 执行引擎工作时通过改变计数器的值来选取下一条指令
- 每个线程都有自己的专属计数器,这保证了在多线程切换时能顺利恢复线程的执行状态
- 只对Java方法计数,对于Native方法将记录Undefined
- 因为只记录行号,故不会发生内存泄漏
栈
运行时数据区包含两个栈,分别是虚拟机栈和本地方法栈,两者基本类似,区别在于一个针对Java方法,一个针对Native方法。
- 栈是线程私有的,随线程而生,随线程而灭
- 如果线程请求的栈深度超过虚拟机所允许的深度,将抛出
java.lang.StackOverflowError
异常 - 当虚拟机栈可以动态扩展时,如果无法申请到足够的内存,将抛出
java.lang.OutOfMemoryError
异常 - 每个方法的执行都会在栈中创建一个栈帧
栈帧
栈帧是用于支持方法执行的数据结构,它位于栈中,栈帧记录了方法的局部变量表等信息。
组成元素 | 作用 | 说明 |
---|---|---|
局部变量表 | 存放方法参数和局部变量 | 空间占用大,最小单位为Slot;JVM是使用局部变量表完成参数值到参数变量列表的传递过程;实例方法对应的局部变量表存有该实例的引用(this) |
操作数栈 | 方法执行时寄存数据 | 在方法执行的过程中,各种字节码指令会从局部变量表或者对象实例字段中复制常量或者变量值到操作数栈中 |
动态链接 | 包含该栈帧所属方法属性的引用 | 符号引用在运行期间动态的转化成的直接引用 |
返回地址 | 方法执行结束时提供被调用的位置 | 方法正常退出时,调用者程序计数器的值可以作为返回地址;方法退出的过程等同于把当前栈帧出栈、把返回值(如果有的话)压入调用者栈帧的操作数栈中、最后让程序计数器加一 |
方法区
方法区会存储类的字节码、class/method/field等元数据对象、static变量和static-final常量等信息。由于虚拟机规范未明确规定方法区的实现,所以不同JDK版本有着不同的特性。
- 在JDK6及之前版本,方法区使用永久代(Permanent Generation)作为实现。因为永久代使用的是JVM内存,所以容易引发
java.lang.OutOfMemoryError
- 在JDK7中,字符串常量池从永久代移动到了Java堆中
- 在JDK8中,永久代被元空间(Meta Space)替代,元空间使用系统本地内存,理论上只要不触碰系统内存上限就不会发生溢出(但要避免元空间无限膨胀)
堆
堆内存用于存储对象实例,它在物理空间上可以不连续,JVM的垃圾回收主要针对的就是该片内存。
由于现代虚拟机主要采用分代收集思想,所以堆可以细分为新生代和老年代,具体可跳转至分代收集理论。
内存分配策略
- 静态存储:编译时就确定每个数据在运行时所需的存储空间
- 栈式存储:编译时未知,但运行时能够确定所需存储空间
- 堆式存储:编译和运行阶段均无法确定所需存储空间,应动态分配
GC之标记算法
标记算法用于判别垃圾,常见的标记算法有两种:
- 引用计数算法:通过计算对象的引用数量来判断该对象是否可被回收,引用计数为0的对象会被视作垃圾。该算法执行效率高,不会打断程序执行,但无法检测出循环引用的情况,易导致内存泄漏,实际环境中基本不会使用。
- 可达性分析算法:通过判断对象的引用链是否可达来判断该对象是否可被回收,引用链以GC ROOT作为起点:
GC之回收算法
回收算法用于回收被判定为垃圾的对象,常见的回收算法有三种:(分代回收常被误认为是第四种算法,但它实际是综合三种算法优点的一套理论)
-
标记-清除算法:利用可达性分析算法标记存活的对象,再从堆内存中进行线性遍历,回收被标记的不可达对象,并清除可达对象的标记(以便下次GC)。由于该种算法不会改变对象的位置,所以在GC后会产生大量内存碎片,过多的碎片会导致后续创建较大对象时没有连续的可用空间,进而频繁触发GC。
-
复制算法:将内存分为两个区域,一个区域为对象面,另一个区域为空闲面,对象只能在对象面创建,当对象面的内存耗尽时,将触发一次GC,存活的对象会被移动到空闲面上,最后将对象面上的内容一次性清除,交换两个区域的名称。该算法解决了标记-清除算法产生过多碎片的问题,但会浪费50%的内存空间,仅适合对象存活率较低的场景。(商用虚拟机采用该算法回收新生代,因为新生代中存活的对象很少)
-
标记-整理算法:利用可达性分析算法标记存活的对象,然后让所有可达对象向一端聚拢,最后清除掉边界以外的所有内容。该算法充分利用了内存空间,避免了内存碎片,适用于对象存活率高的场景。
GC之分代收集理论
- JDK7及之前版本,堆内存划分为新生代、老年代和永久代。
- JDK8及之后版本,堆内存仅分为新生代和老年代,其中新生代中的对象存活率低,采用复制算法进行GC(Minor GC);而老年代中的对象存活率高,则采用标记-清除或标记-整理算法进行GC(Full GC)。
分代收集流程:
- 新创建的对象会生成在Eden区(Eden有伊甸园的含义)
- 当Eden区满后将触发一次Minor GC,存活的对象会被转移至from space,其年龄加一
- 当from space满后将再次触发Minor GC,存活的对象会被转移至to space,其年龄加一,两个space分区会交换名称(这样保证总有一个Survivor分区是空闲的)
- 如此重复下去,当Survior中幸存对象的年龄达到设定的阈值(默认为15岁,可通过
-XX: MaxTenuringThreshold
调整),将被转移至老年代中 - 当老年代空间不足或执行了
System.gc()
时,将触发Full GC(JDK7及以下版本中永久代空间不足时也会触发)
注意事项:
- 如果一个对象在从Eden区转移至Survivor区时,Survivor区无法供给充足的空间,那么该对象会直接进入老年代
- 如果一个对象在生成时所需的空间超过设定的阈值(
-XX: +PretenuerSizeThreshold
),也将直接进入老年代 - Full GC会阻塞线程运行,对于响应要求高的程序应降低Full GC的发生频率
其它调优参数:
-XX: SurvivorRatio
:设置Eden区和单个Survivor分区的内存分配比例(默认8:1)-XX: NewRatio
:设置老年代和新生代内存分配比例(默认2:1)
新生代垃圾收集器
- Serial收集器(
-XX: +UseSerialGC
):采用复制算法的单线程收集器,进行垃圾收集时必须暂停其它线程的工作(Client模式下的默认使用该收集器) - ParNew收集器(
-XX: +UseParNewGC
):采用复制算法的多线程收集器,相当于Serial的多线程版,适合需要快速响应的场景(Server模式下的首选收集器) - Parallel Scavenge收集器(
-XX: +UseParallelGC
):采用复制算法的多线程收集器,除此之外还可以控制吞吐量(吞吐量=运行耗时/(运行耗时+GC耗时)
),适合关注运算速度的场景(Server模式下的默认使用该收集器,可以使用-XX: UseAdaptiveSizePolicy
开启自动调优)
老年代垃圾收集器
- Serial Old收集器(
-XX: +UseSerialOldGC
):采用标记-整理算法的单线程收集器,与新生代Serial类似,进行垃圾收集时必须暂停其它线程的工作(Client模式下的默认使用该收集器) - Parallel Old收集器(
-XX: +UseParallelOldGC
):采用标记-整理算法的多线程收集器,与新生代Parallel类似,更关注吞吐量。 - CMS收集器(
-XX: +UseConcMarkSweepGC
):采用标记-清除算法,能够最短化停顿时间,尽可能做到与用户线程同时工作。(首先标记GC ROOT能直接关联到的对象,接着并发的进行可达性分析,然后用户线程在此期间发生过的变动重新标记,最后并发进行回收操作)
注:Parallel Old收集器只能与Parallel Scavenge收集器组合使用,CMS收集器只能与Serial收集器或ParNew收集器组合使用,而Serial Old收集器可以跟任意新生代收集器组合使用。
G1收集器
使用-XX +UseG1GC
启用,它既能用于新生代也能用于老年代,未来可能替代CMS收集器。
- 支持并发操作,能够与用户线程同时进行
- 将整个堆划分为多个大小相等的区域独立管理,新生代和老年代在物理上可以是不连续的
- 基于标记-整理算法,无内存碎片
- 建立可预测的停顿时间模型
典型面试题
JVM、JRE和JDK之间的联系?
- JDK是面向开发者的Java开发工具包,它包含JRE和基础工具(javac等)
- JRE是Java运行环境,它包含JVM和基础类库(lib)
- JVM是虚构出来的计算机,将类加载并翻译成特定平台的机器指令
Java的平台无关性是如何实现的?
Java源码首先被编译成中间字节码,再由不同平台的JVM进行解析,不同平台的JVM会将字节码转换为具体平台中的机器指令,如此就可以实现一份Java代码能够在多个平台运行。
JVM为何不直接解析源码,而要引入中间字节码?
- 如果直接解析源码,每次执行时还需要重新检查语法,浪费时间和性能,引入中间字节码则只需在编译阶段做一次检查。
- 其它能够转换为字节码的语言也能够被JVM执行,这增强了平台的扩展能力。
JVM是如何加载class文件的?
- 首先由类加载器对class文件进行加载和验证,类静态数据会被投放到方法区中,并且会在堆中生成用于描述该类的
java.lang.Class
实例 - 紧接着会在方法区中给类的静态变量分配内存并设置默认值,将符号引用转化为直接引用
- 当发生对象的实例化操作时,执行引擎会开始执行Java代码中用户制定的赋值动作,并与运行时数据区进行交互
什么是反射?
Java反射指的是在程序运行的过程中,能够动态获取某个类的相关信息,以及动态调用某个对象的相关方法。
能够实现反射的一个大前提是——Java中每一个类都是java.lang.Class
的实例对象。
以下是反射常用方法:
方法 | 作用 |
---|---|
Class.forName(String className) | 根据全限定名获取相应类的Class对象 |
classObj.getDeclaredConstructor().newInstance() | 同上(Java9后推荐写法) |
classObj.getDeclaredMethod(String name, Class… parameterTypes) | 获取该Class对象所对应类中的方法对象(extends和implements的方法除外) |
methodObj.setAccessible(boolean flag) | 设置该方法对象是否进行访问检查(true关闭访问检查) |
classObj.getMethod(String name, Class… parameterTypes) | 获取该Class对象所对应类中的public方法对象(包括extends和implements的方法) |
methodObj.invoke(Object classInstance, Object… args) | 执行方法 |
为什么要使用双亲委派机制加载类?
①避免重复加载相同的类:向上委托将询问加载过该字节码的Class Loader,并直接使用已加载class实例,这样可以避免重复加载,减小内存开销。
②避免核心类库被篡改:双亲委派机制缔造了Class Loader间的层级关系,父加载器的优先级比子加载器高,当父类能够自行加载该类的话,子类便没有机会进行加载,这样可以避免核心API被随意篡改。
JVM性能调优参数-Xms -Xmx -Xss的含义?
-Xss
规定每个线程所对应的虚拟机栈大小,通常设置为256K-Xms
规定初始堆的大小,当容量不够时会自动扩容(不超过-Xmx)-Xmx
规定堆能扩容的最大值(通常将-Xms和-Xmx设置为同样的值,因为扩容会引起内存抖动,影响程序的稳定性)
请解释堆和栈的区别与联系
区别:
层面 | 栈 | 堆 |
---|---|---|
存储内容 | 编译时未知、但运行时能够确定所需存储空间的元素 | 编译和运行阶段均无法确定所需空间,需要动态分配的元素(例如数组和对象) |
管理方式 | 栈空间能够自动释放 | 堆空间需要垃圾回收器执行GC |
空间大小 | 占用内存小 | 占用内存大 |
碎片相关 | 产生碎片少 | 产生碎片多 |
分配方式 | 支持静态和动态分配 | 仅支持动态分配 |
执行效率 | 高(只涉及入栈和出栈) | 低(双向链表) |
联系:
引用数组或对象时,栈里定义的变量保存该数组/对象在堆中的首地址。当该程序运行到该变量作用域之外后,该变量的栈空间便会被释放,而数组/对象依旧存在于堆空间中,直至GC对其进行回收。
请列举Java内存泄漏的场景
- 滥用static关键字:因为静态变量会存储在方法区中,GC几乎不会对方法区中的内容进行回收,使用过多的静态内容可能会造成内存泄漏
- 连接忘记关闭:数据库连接、网络连接或IO连接等连接若没有手动关闭,那么会一直占据内存
- 不合理的作用域:如果一个变量的定义范围大于它的使用范围,也可能造成内存负担
请解释JDK6和JDK6+中intern()方法的区别与联系
- 共同点:
当调用intern()
方法时,如果字符串常量池中已存在该字符串对象,则返回该对象的引用。 - 区别:
若常量池中不存在该字符串对象,JDK6的intern()
方法会直接在常量池中创建该对象并返回引用,而JDK6+的intern()
方法会先检查堆空间中是否有该字符串对象,若有就把该对象的引用添加进常量池并返回,若没有才会在常量池中创建对象。
请简述Java中的强引用、软引用、弱引用及虚引用
- 强引用:形如
Object obj = new Object()
,该类对象在任何情况下都不会被回收,使用obj = null
可以消除强引用 - 软引用:描述一个对象可能会被使用、但不是必须存在的情况(例如图片缓存),当内存充足时不会回收,若内存不足就会回收,使用
new SoftReference(Object obj)
来包装软引用对象 - 弱引用:比软引用更弱的情况,无论内存是否紧缺都会在最近的一次GC时被回收,使用
new WeakReference(Object obj)
来包装软引用对象 - 虚引用:相当于无引用,随时会被回收,主要和引用队列联合使用来跟踪回收过程
new PhantomReference<String>(Object obj, ReferenceQueue queue)