文章目录
一、JVM 内存结构
1. 请详细描述 JVM 的内存结构,各个区域的作用是什么?
- 堆(Heap):这是 JVM 中最大的一块内存区域,用于存储对象实例。几乎所有的对象实例和数组都在堆上分配内存。堆是垃圾回收(GC)的主要区域,又可以细分为新生代(Young Generation)和老年代(Old Generation),新生代还包括一个 Eden 区和两个 Survivor 区(一般是 S0 和 S1)。
- 方法区(Method Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在 JDK 1.8 及之后,方法区被元空间(Metaspace)取代,元空间并不在虚拟机内存中,而是使用本地内存。
- 虚拟机栈(Java Virtual Machine Stack):每个线程在创建时都会创建一个虚拟机栈,它用于存储栈帧。栈帧中保存了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每调用一个方法就会在栈顶压入一个栈帧,方法执行完成后,栈帧出栈。
- 本地方法栈(Native Method Stack):与虚拟机栈类似,不过它是为虚拟机使用到的本地(Native)方法服务的。例如,Java 通过 JNI(Java Native Interface)调用 C 或 C++ 代码时,就会使用本地方法栈。
- 程序计数器(Program Counter Register):每个线程都有一个独立的程序计数器,它记录了当前线程正在执行的字节码指令的地址。如果线程正在执行一个 Java 方法,计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行本地方法,则计数器值为空(Undefined)。
2. 堆内存是如何划分的?新生代和老年代的比例是多少?
- 堆内存分为新生代和老年代。新生代主要存放新创建的对象,老年代存放经过多次垃圾回收仍然存活的对象。
- 在 HotSpot 虚拟机中,新生代和老年代默认比例是 1:2,即新生代占整个堆内存的 1/3,老年代占 2/3。但这个比例可以通过参数 - XX:NewRatio 来调整。例如,-XX:NewRatio=4 表示新生代与老年代的比例是 1:4,新生代占堆内存的 1/5。
3. Eden 区和 Survivor 区的作用是什么?它们之间是如何协作的?
- Eden 区是对象最初创建时分配内存的地方。当 Eden 区内存满了,就会触发 Minor GC(新生代垃圾回收)。
- Survivor 区有两个,一般称为 S0 和 S1。在 Minor GC 时,Eden 区中存活的对象会被复制到其中一个 Survivor 区(假设是 S0),同时 S1 中存活的对象也会被复制到 S0 中,并且对象的年龄(对象在 Survivor 区经历一次垃圾回收年龄就加 1)会增加。经过一次 Minor GC 后,Eden 区被清空,原来的 S1 也被清空,S0 变成新的 S1,原来的 S0 成为下一次 Minor GC 时存放对象的 Survivor 区。当对象的年龄达到一定阈值(默认是 15,可以通过 - XX:MaxTenuringThreshold 参数调整),就会被晋升到老年代。
4. 方法区中主要存储哪些内容?JDK 1.8 之后方法区有什么变化?
- 在 JDK 1.7 及之前,方法区主要存储已被虚拟机加载的类信息(类的全限定名、类的访问修饰符、类的父类、接口等)、常量(字符串常量池、基本类型常量)、静态变量、即时编译器编译后的代码缓存等。
- JDK 1.8 之后,方法区被元空间取代。元空间与之前的方法区不同,它不再使用虚拟机内存,而是使用本地内存。这样做的好处是可以避免由于方法区内存不足导致的 OutOfMemoryError 错误,因为本地内存相对来说资源更充足。字符串常量池也从方法区移到了堆中。
二、垃圾回收机制
1. 请介绍一下垃圾回收的算法有哪些?它们各自的特点是什么?
- 标记 - 清除算法(Mark - Sweep):
过程:首先从根对象(如栈中的局部变量、静态变量等)开始遍历,标记所有可达的对象,然后清除所有未被标记的对象(即不可达对象)。
特点:实现简单,但是会产生内存碎片,因为清除的对象在内存中可能是不连续的,导致后续大对象无法分配到连续的内存空间。 - 复制算法(Copying):
过程:将内存分为两块大小相等的区域,每次只使用其中一块。当这一块内存满了,就将存活的对象复制到另一块内存中,然后将原来的内存块清空。例如在新生代中,Eden 区和 Survivor 区就采用了类似的复制算法思想,Eden 区和一个 Survivor 区用于分配对象,另一个 Survivor 区用于存放复制过来的存活对象。
特点:不会产生内存碎片,并且复制过程相对简单高效。但是内存利用率低,因为总有一半的内存空间处于空闲状态。 - 标记 - 整理算法(Mark - Compact):
过程:首先标记所有可达对象,然后将所有存活的对象向内存的一端移动,最后清除边界以外的内存空间。
特点:避免了内存碎片问题,同时也不像复制算法那样浪费一半内存。但是移动对象的过程比较复杂,会增加一定的时间开销。 - 分代收集算法(Generational Collection):
过程:根据对象存活周期的不同将内存划分为不同的区域,如新生代和老年代。在新生代中,由于对象存活时间短,采用复制算法可以高效地回收垃圾;在老年代中,对象存活时间长,采用标记 - 清除或标记 - 整理算法,因为老年代对象移动成本较高。
特点:综合利用了不同算法的优势,根据对象的特点选择合适的算法,提高了垃圾回收的效率。
2. 什么是 Minor GC、Major GC 和 Full GC?它们之间有什么区别?
- Minor GC:发生在新生代的垃圾回收,主要是回收新生代中的对象。由于新生代对象大多存活时间较短,所以 Minor GC 频率较高,但回收速度相对较快。在 Minor GC 时,Eden 区和 Survivor 区中不可达的对象会被回收。
- Major GC:通常指老年代的垃圾回收。Major GC 的频率相对较低,因为老年代对象存活时间较长。Major GC 的速度一般比 Minor GC 慢,因为老年代对象较多且可能涉及复杂的对象引用关系。在 Major GC 时,老年代中不可达的对象会被回收。
- Full GC:是对整个堆(包括新生代、老年代和方法区)进行的垃圾回收。Full GC 会导致应用程序停顿较长时间,因为它需要处理整个堆内存。触发 Full GC 的原因有多种,例如老年代空间不足、方法区空间不足、调用 System.gc () 方法(不过该方法只是建议虚拟机进行 Full GC,虚拟机不一定会立即执行)等。
3.如何判断一个对象是否可以被回收?
- 引用计数法:给每个对象添加一个引用计数器,每当有一个地方引用该对象时,计数器值加 1;当引用失效时,计数器值减 1。当计数器值为 0 时,就认为该对象可以被回收。这种方法实现简单,但是无法解决对象之间相互循环引用的问题,例如对象 A 引用对象 B,对象 B 又引用对象 A,即使它们在外部没有其他引用,引用计数器也不会为 0,导致无法回收。
- 可达性分析算法:通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,就说明此对象是不可达的,可以被回收。在 Java 中,可作为 GC Roots 的对象包括虚拟机栈中的局部变量表中的对象、方法区中的静态变量引用的对象、本地方法栈中 JNI 引用的对象等。
4. 垃圾回收器有哪些?它们各自的特点和适用场景是什么?
- Serial 收集器:
特点:单线程收集器,在进行垃圾回收时,必须暂停其他所有工作线程,直到垃圾回收完成。它的优点是简单高效,没有线程交互开销。
适用场景:适用于客户端模式下的小型应用,因为简单且内存占用少。 - ParNew 收集器:
特点:Serial 收集器的多线程版本,使用多个线程进行垃圾回收。它在新生代使用复制算法,与 Serial 收集器相比,在多核心处理器环境下可以充分利用多线程优势,提高垃圾回收效率。
适用场景:可以作为老年代使用 CMS 收集器时的新生代收集器,因为 CMS 收集器无法与 Serial 收集器配合工作。 - Parallel Scavenge 收集器:
特点:也是一个多线程收集器,在新生代使用复制算法。它的目标是达到一个可控制的吞吐量(运行用户代码时间与垃圾收集时间之和的比值)。可以通过设置参数 - XX:MaxGCPauseMillis 来控制最大垃圾回收停顿时间,或者通过 - XX:GCTimeRatio 来设置吞吐量大小。
适用场景:适用于注重吞吐量的应用,例如后台计算任务等。 - Serial Old 收集器:
特点:Serial 收集器的老年代版本,单线程收集器,使用标记 - 整理算法。主要用于客户端模式,在服务器模式下,主要作为 CMS 收集器发生 Concurrent Mode Failure(并发模式失败,当 CMS 收集器在并发清理阶段,老年代内存增长过快,导致 CMS 无法在垃圾堆积满之前完成清理,就会发生这种情况)后的后备收集器。
适用场景:与 Serial 收集器类似,适用于客户端模式下的小型应用,或者作为 CMS 收集器的后备方案。 - Parallel Old 收集器:
特点:Parallel Scavenge 收集器的老年代版本,多线程收集器,使用标记 - 整理算法。与 Parallel Scavenge 收集器配合使用,可以更好地实现高吞吐量的应用场景。
适用场景:适用于注重吞吐量的应用,特别是在多核处理器环境下,与 Parallel Scavenge 收集器搭配能充分发挥优势。 - CMS(Concurrent Mark Sweep)收集器:
特点:一种以获取最短回收停顿时间为目标的收集器。它是基于标记 - 清除算法实现的,整个过程分为四个步骤:初始标记(Initial Mark)、并发标记(Concurrent Mark)、重新标记(Remark)和并发清除(Concurrent Sweep)。初始标记和重新标记阶段需要暂停其他线程,而并发标记和并发清除阶段可以与用户线程并发执行。优点是停顿时间短,对响应时间要求高的应用比较友好。
适用场景:适用于互联网应用、B/S 系统等对响应时间要求较高的场景,但是它会产生内存碎片,并且在并发清除阶段可能会因为用户线程继续产生垃圾对象而导致 Concurrent Mode Failure。 - G1(Garbage - First)收集器:
特点:一种面向服务端应用的垃圾收集器。它将堆内存划分为多个大小相等的 Region,这些 Region 可以是新生代也可以是老年代。G1 收集器可以同时兼顾吞吐量和低停顿时间。它会跟踪每个 Region 中垃圾的价值(回收后获得的空间大小和回收所需时间的比值),优先回收价值最大的 Region。G1 收集器的停顿时间是可预测的,并且在回收过程中可以与用户线程并发执行。
适用场景:适用于大内存、多核心的服务器环境,特别是对停顿时间有严格要求的应用,如电商交易系统等。
三、类加载机制
1. 请描述 Java 类加载的过程,包括加载、验证、准备、解析和初始化这几个阶段。
- 加载(Loading):
这是类加载的第一个阶段,通过类的全限定名获取该类的二进制字节流。这个字节流可以从多种来源获取,例如本地文件系统、网络、jar 包等。
将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。 - 验证(Verification):
目的是确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
文件格式验证:验证字节流是否符合 Class 文件格式的规范,例如是否以魔数 0xCAFEBABE 开头,主次版本号是否在当前虚拟机处理范围内等。
元数据验证:对字节码描述的信息进行语义分析,确保其符合 Java 语言规范,例如类是否有父类(除了 Object 类),类中的方法是否重写了父类的方法且符合重写规则等。
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如检查操作数栈的数据类型与指令代码序列是否匹配等。
符号引用验证:确保解析动作能正确执行,例如符号引用中通过字符串描述的全限定名是否能找到对应的类等。 - 准备(Preparation):
为类的静态变量分配内存,并设置默认初始值。例如,对于 int 类型的静态变量,默认初始值为 0;对于对象引用类型的静态变量,默认初始值为 null。这里分配的内存是在方法区中。
注意,对于被 final 修饰的静态常量,如果在编译期就能够确定其值(如 final int a = 10;),则在准备阶段就会直接将其初始化为指定的值,而不是默认值。 - 解析(Resolution):
将常量池中的符号引用替换为直接引用的过程。符号引用是一组符号来描述所引用的目标,例如类的全限定名、方法名等。直接引用则是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
类或接口的解析:将符号引用中的类或接口的全限定名解析为对其在方法区中对应数据结构的直接引用。
字段解析:解析字段的符号引用,确定字段在类或接口中的具体位置。
方法解析:解析方法的符号引用,确定方法在类或接口中的具体实现。 - 初始化(Initialization):
执行类构造器() 方法。() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。虚拟机会保证在子类的() 方法执行之前,父类的() 方法已经执行完毕。
当初始化一个类时,如果其父类还没有被初始化,则先初始化其父类。
当一个类中存在多个静态代码块和静态变量赋值语句时,它们会按照在代码中出现的顺序依次执行。
2. 类加载器有哪些?它们之间的层次关系是怎样的?
- 启动类加载器(Bootstrap ClassLoader):
它是 Java 虚拟机实现的一部分,用 C++ 语言实现(在 HotSpot 虚拟机中)。负责加载 Java 核心类库,如 rt.jar 中的类,这些类存放在 JDK 的 jre/lib 目录下。它是所有类加载器的父加载器,但是在 Java 代码中无法直接获取到它。 - 扩展类加载器(Extension ClassLoader):
由 Java 语言实现,继承自 ClassLoader 类。负责加载 JDK 安装目录下 jre/lib/ext 目录中的类库,或者由系统变量 java.ext.dirs 指定的路径中的类库。 - 应用程序类加载器(Application ClassLoader):
也称为系统类加载器,同样由 Java 语言实现,继承自 ClassLoader 类。它负责加载用户类路径(ClassPath)上所指定的类库,是程序中默认的类加载器。在 Java 代码中可以通过 ClassLoader.getSystemClassLoader () 方法获取到它。 - 自定义类加载器:
用户可以继承 ClassLoader 类,重写 findClass () 等方法来自定义类加载器,用于加载特定路径或特定格式的类文件。例如,在实现热部署、加密类文件加载等场景中会用到自定义类加载器。 - 层次关系:类加载器之间采用双亲委派模型。当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,每一层的类加载器都是如此,直到最顶层的启动类加载器。如果父类加载器无法完成加载请求(它的搜索范围中没有找到对应的类),子类加载器才会尝试自己去加载。这种模型保证了 Java 核心类库的安全性,避免了用户自定义的同名类覆盖核心类库中的类。
3. 什么是双亲委派模型
- 双亲委派模型:如上述类加载器层次关系中所述,当一个类加载器收到类加载请求时,它首先把请求委派给父类加载器去完成,只有当父类加载器无法完成加载请求时,子类加载器才会尝试自己去加载。
- 优点:
安全性:保证了 Java 核心类库的安全性。例如,对于 java.lang.Object 类,无论哪个类加载器去加载它,最终都是由启动类加载器加载,避免了用户自定义的同名类覆盖核心类库中的类,防止了恶意代码对核心类库的破坏。