JVM面试题
JVM内存区域
JDK1.7
JDK1.8
简述JVM内存模型
线程私有的运行时数据区:程序计数器、Java虚拟机栈、本地方法栈。
线程共享的运行时数据区:Java堆、方法区
程序计数器
内存空间小,线程私有。字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成。
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
虚拟机栈
Java虚拟机栈用来描述Java方法执行的内存模型。线程创建时就会分配一个栈空间,线程结束后栈空间被收回。
栈中元素用于支持虚拟机方法调用,每个方法在执行时都会创建一个栈帧存储方法的局部变量表、操作栈、动态链接和返回地址等信息。
动态链接:主要服务一个方法需要调用其他方法的场景。在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。
局部变量表:存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)
虚拟机栈会产生两类异常:
StackOverflowError:线程请求的栈深度大于虚拟机允许的深度抛出。
OutOfMemoryError:如果JVM栈内容可以动态扩展,虚拟机栈占用内存超出抛出。
本地方法栈
本地方法栈与虚拟机栈作用相似,不同的是虚拟机栈为虚拟机执行Java方法服务,本地方法栈为本地方法服务。可以将虚拟机栈看作普通的java函数对应的内存模型,本地方法栈作由native关键词修饰的函数对应的内存模型。
本地方法栈会产生两类异常:
StackOverflowError:线程请求的栈深度大于虚拟机允许的深度抛出。
OutOfMemoryError:如果JVM栈内容可以动态扩展,虚拟机栈占用内存超出抛出。
JVM中的堆
堆的主要作用是存放对象实例,Java里几乎所有对象实例都分配在堆分配内存,堆也是内存管理中最大的一块。此内存区域的唯一目的就是存放对象实例。Java内存回收主要就是针对堆这一区域进行。可以通过-Xms和-Xmx设置堆的最小和最大容量。线程共享,主要是存放对象实例和数组。内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。可以位于物理上不连续的空间,但是逻辑上要连续。
OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。
方法区
方法区会存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。
JDK6之前使用永久代实现方法区,容易内存溢出。JDK7把放在永久代的字符串常量池、静态变量等移出,JDK8抛弃永久代,改用在本地内存中实现的元空间来实现方法区,把JDK7中永久代内容移到元空间。
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize
控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
方法区会抛出OutOfMemoryError异常。
运行时常量池
运行时常量池存放常量池表,用于存放编译器生成的各种字面量与符号引用。一般除了保存Class文件中描述的符号引用外,还会把符号引用翻译的直接引用也存储在运行时常量池。除此之外,也会存放字符串基本类型。
JDK8之前,放在方法区,大小受限于方法区。JDK8将运行时常量池存放堆中。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
JDK 1.7 为什么要将字符串常量池移动到堆中?
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能
直接内存
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。
直接内存也称为堆外内存,就是把内存对象分配在JVM堆外的内存区域。这部分内存不是虚拟机管理,而是由操作系统来管理。Java通过DirectByteBuffer对其进行操作,避免了在Java堆和Native堆来回赋值数据。
简述Java创建对象的过程
1.检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查引用代表的类是否已被加载、解析和初始化,如果没有就先执行类加载。
2.通过检查通过后虚拟机将为新生对象分配内存。
3.完成内存分配后虚拟机将成员变量设为零值。
4.设置对象头,包括哈希码、GC信息、锁信息、对象所属类的类元信息等。
5.执行init方法,初始化成员变量,执行实例化代码,调用类的构造方法,并把堆内信息对象的首地址赋值给引用变量。
对象访问定位
Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址。
reference 中直接存储对象地址
String s = new String;
s在Java虚拟机栈,new后面的String为实例对象在Java堆,类型前面的String存储在方法区。
使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference 自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁 GC 那么句柄方法好,如果是对象频繁访问则直接指针访问好。
简述JVM给对象分配内存的策略
1.指针碰撞:这种方式在内存中放一个指针作为分界指示器将使用过的内存放在一边,空闲的放在另一边,通过指针挪动完成分配。
2.空闲列表:对于Java堆内存不规整的情况,虚拟机必须维护一个列表记录哪些内存可用,在分配时从列表中找到一块足够大的空间划分给对象并更新列表记录。
Java对象内存分配是如何保证线程安全的
1.对分配内存空间采用CSA机制,配合失败重试的方式保证更新操作的原子性。该方式效率低。
2.每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这个”私有“内存中分配。一般采用这种策略。
JVM垃圾回收机制
简述对象的内存布局
对象在堆内存的存储布局可以分为对象头、实例数据和对齐填充。
对象头主要包含两部分数据:MarkWord、类型指针。MarkDown用于存储哈希码(HashCode),GC分代年龄、锁状态标志位、线程持有的锁、偏向线程ID等信息。类型指针即对象指向它的类元数据指针,如果对象是一个Java数组,会有一块用于记录数组长度的数据。
实例数据存储代码中定义各种类型的字段信息。
对齐填充起占位作用。HotSpot虚拟机要求对象的起始地址必须是8的整数倍,因此需要对齐填充。
如何判断对象是否是垃圾
引用计数法:设置计数器,对象被引用计数器加一,引用失效时计数器减一,如果计数器为0则被标记为垃圾。会存在对象间循环引用问题,一般不使用这种方法。
可达性分析:通过GC Roots 的根对象作为起始点,从这些节点开始,根据引用关系向下搜索,如果某个对象没有被搜到,则会被标记为垃圾。可作为 GC Roots 对象包括虚拟机栈和本地方法栈中引用的对象、类静态属性引用的对象、常量引用的对象。
垃圾收集算法
标记清除算法
- 效率问题:标记和清除两个过程效率都不高。
- 空间问题:标记清除后会产生大量不连续的内存碎片。
标记复制算法
为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
- 可用内存变小:可用内存缩小为原来的一半。
- 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。
标记整理算法
标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
常见内存分配策略
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。
长期存活的对象进入老年代既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
简述分代收集算法
根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。一般将堆分为新生代和老年代,对这两块采用不同的算法。新生代使用:标记复制算法。
老年代使用:标记清除算法或者标记整理算法。
简述Java的引用类型
强引用:被强引用关联的对象不会被回收。一般采用new方法创建强引用。
软引用:被软引用关联的对象只有在内存不够的情况下才会被回收。一般采用SoftReference类来创建软引用。
弱引用:垃圾回收期碰到即回收,也就是说它只能存活到下一次垃圾回收之前。一般采用WeakReference类来创建弱引用。
虚引用:无法通过该引用获取对象。唯一目的就hi为了等在对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用。虚引用主要用来跟踪对象被垃圾回收的活动。
类加载
简述JVM类加载过程
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
以下五种情况必须对类进行初始化(而加载、验证、准备自然需要在此之前完成):
(1)遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时没初始化触发初始化。使用场景:使用 new 关键字实例化对象、读取一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。
(2)使用 java.lang.reflect 包的方法对类进行反射调用的时候。
(3)当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化。
(4)当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
(5)当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。
加载:
1.通过全类名获取的二进制字节流
2.将类的静态存储结构转化为方法区的运行时数据结构
3.在内存中生成类的Class对象,作为方法区数据的入口
验证:对文件格式,元数据。字节码,符号引用等验证安全性。是连接的第一步,确保 Class 文件的字节流中包含的信息符合当前虚拟机要求。
准备:在方法区为类变量分配内存并设置初始值。
解析:这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化:执行类构造器clinit方法,真正初始化。
加载
类加载过程的第一步,主要完成下面 3 件事情:
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
Class
对象,作为方法区这些数据的访问入口。
简述JVM中的类加载器
BootsrapClassLoader启动类加载器:加载/lib下的jar包和类。C++编写。
ExtensionClassLoader扩展类加载器:/lib/ext目录下的jar包和类。Java编写
AppClassLoader应用类加载器:加载当前当前ClassPath下的jar包和类。Java编写。
简述双亲委派机制
总结一下双亲委派模型的执行流程:
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。 - 如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundException
异常。
加载类顺序:BootstrapClassLoader -> ExtensionClassLoader -> AppClassLoader -> CustomClassLoader
检查类是否加载顺序: CustomClassLoader -> AppClassLoader -> ExtensionClassLoader -> BootstrapClassLoader
双亲委派机制优点:
1.避免类的重复加载。相同的类被不同的类加载器会产生不同的类,双亲委派保证了Java程序的稳定运行。
2.保证核心API不被修改。
优点:双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的 Object
类。
验证
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段主要由四个检验阶段组成:
- 文件格式验证(Class 文件格式检查)
- 元数据验证(字节码语义检查)
- 字节码验证(程序语义检查)
- 符号引用验证(类的正确性检查)
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
初始化
初始化阶段是执行初始化方法 <clinit> ()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
类卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
调用system.gc()一定会发生垃圾收集吗?
调用system.gc()的时候,其实并不会马上进行垃圾回收,只会把这次gc请求记录下来。需要配合System.runFinalzation()才会进行真正回收。
类加载器
类加载器的主要作用就是加载 Java 类的字节码( .class
文件)到 JVM 中(在内存中生成一个代表该类的 Class
对象)。 字节码可以是 Java 源程序(.java
文件)经过 javac
编译得来,也可以是通过工具动态生成或者通过网络下载得来。
其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类。
类加载器加载规则
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
对于已经加载的类会被放在 ClassLoader
中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
类加载器总结
JVM 中内置了三个重要的 ClassLoader
:
BootstrapClassLoader
(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(%JAVA_HOME%/lib
目录下的rt.jar
、resources.jar
、charsets.jar
等 jar 包和类)以及被-Xbootclasspath
参数指定的路径下的所有类。ExtensionClassLoader
(扩展类加载器):主要负责加载%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。AppClassLoader
(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
内存溢出和内存泄漏
内存溢出:程序在申请内存时,此时已用内存过多,没有足够的剩余空间供其使用。
内存泄漏:程序在申请内存后,不能完全释放已申请的内存空间。
内存溢出就是你要求分配的内存超出了系统能给你的,系统不能满足需求,于是产生溢出。
内存泄漏是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· winform 绘制太阳,地球,月球 运作规律
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人