【JVM面试篇】高频八股汇总——Java内存区域
目录
1. 介绍下 Java 内存区域(内存模型)?
2. Java 对象的创建过程?
3. 对象的访问定位的两种方式?
4. 方法区和永久代以及元空间是什么关系呢?
5. 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
6. JDK 1.7 为什么要将字符串常量池移动到堆中?
7. JVM 常量池中存储的是对象还是引用呢?
8. Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的?
9. JVM内存模型里的堆和栈有什么区别?
10. 栈中存的到底是指针还是对象?
11. 堆分为哪几部分呢?
12. 如果有个大对象一般是在哪个区域?
13. 程序计数器的作用,为什么是私有的?
14. 方法区中的方法的执行过程?
15. 方法区中还有哪些东西?
16. String保存在哪里呢?
17. String s = new String(“abc”)执行过程中分别对应哪些内存区域?
18. 引用类型有哪些?有什么区别?
19. 内存泄漏和内存溢出的理解?
20. jvm 内存结构有哪几种内存溢出的情况?
21. 有具体的内存泄漏和内存溢出的例子么请举例及解决方案?
1. 介绍下 Java 内存区域(内存模型)?
根据 JDK8规范,JVM 运行时内存共分为方法区(元空间)、堆、虚拟机栈、程序计数器、本地方法栈五个部分。
还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。
-
程序计数器 (Program Counter Register)
-
这是一块线程私有的较小内存空间,可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
-
它是程序控制流的指示器,用于实现分支、循环、跳转、异常处理、线程恢复等基础功能。在多线程环境下,每个线程都需要一个独立的程序计数器,以便线程切换后能恢复到正确的执行位置。
-
此区域是 Java 虚拟机规范中唯一没有规定任何
OutOfMemoryError
情况的区域。
-
-
Java 虚拟机栈 (Java Virtual Machine Stacks)
-
这也是线程私有的内存区域,其生命周期与线程相同。
-
它描述的是 Java 方法执行的内存模型:每个方法被执行时,Java 虚拟机都会同步创建一个栈帧 (Stack Frame) 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
局部变量表存放了编译期可知的各种基本数据类型、对象引用 (
reference
类型) 和returnAddress
类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,方法运行期间不会改变其大小。 -
该区域可能抛出两种错误:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
;如果虚拟机栈可以动态扩展,但在扩展时无法申请到足够内存,则抛出OutOfMemoryError
。
-
-
本地方法栈 (Native Method Stack)
-
与虚拟机栈的作用非常相似,主要区别在于:虚拟机栈为执行 Java 方法(字节码)服务,而本地方法栈则为执行虚拟机使用到的本地(Native)方法服务。
-
虚拟机规范对本地方法栈使用的语言、使用方式与数据结构没有强制规定,具体的虚拟机可以自由实现它。
-
与虚拟机栈一样,本地方法栈区域也会抛出
StackOverflowError
和OutOfMemoryError
异常。
-
-
Java 堆 (Java Heap)
-
这是所有线程共享的一块内存区域,在虚拟机启动时创建。
-
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都应当在堆上分配内存(随着技术发展,如逃逸分析,也存在栈上分配的可能性,但堆仍是主体)。
-
堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”。从垃圾回收的角度,现代收集器基本都采用分代收集算法,所以堆可以细分为:新生代 (Young Generation) 和 老年代 (Old Generation);新生代又可细分为 Eden 空间、From Survivor 空间、To Survivor 空间等。
-
堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
-
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出
OutOfMemoryError
。
-
-
方法区 (Method Area)
-
这也是线程共享的内存区域。
-
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
-
在逻辑上,它是堆的一部分,但为了与堆区分,有时被称为“非堆”。
-
运行时常量池 (Runtime Constant Pool) 是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
-
当方法区无法满足新的内存分配需求时,将抛出
OutOfMemoryError
。在 JDK 8 及之后,方法区的具体实现由永久代 (PermGen) 转变为元空间 (Metaspace),元空间使用本地内存,大大降低了 OOM 风险。
-
-
直接内存 (Direct Memory)
-
这部分内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
-
但是,它被频繁地使用(例如通过
NIO
的DirectByteBuffer
),可能导致OutOfMemoryError
。 -
直接内存是在 Java 堆外的、直接向系统申请的内存空间。其大小不受 Java 堆大小的限制,但受限于机器总内存。
-
2. Java 对象的创建过程?
流程总结
1. 类加载检查
当遇到 new
指令时,JVM 首先检查目标类是否已被加载。若未加载,则触发类加载过程(加载→验证→准备→解析→初始化)。这一步确保对象所属的类信息已存在于方法区。
2. 内存分配
JVM 在堆内存中为对象分配空间。分配方式取决于垃圾收集器算法:
-
指针碰撞(Bump the Pointer):适用于内存规整的场景(如 Serial、ParNew 收集器)。
-
空闲列表(Free List):适用于内存不规整的场景(如 CMS 收集器)。
补充:TLAB(Thread Local Allocation Buffer)是优化多线程分配空间的机制,避免竞争。
3. 内存空间初始化零值
分配内存后,JVM 将对象所有成员变量置为零值(如 int
为 0、boolean
为 false
、引用类型为 null
)。这一步保证了对象的字段不包含随机值。
4. 设置对象头(Object Header)
对象头包含两类信息:
-
Mark Word:存储哈希码、GC 分代年龄、锁状态等运行时元数据。
-
类型指针:指向方法区中该对象的类元数据(Klass Pointer)。
*补充:若启用指针压缩(-XX:+UseCompressedOops),类型指针占 4 字节而非 8 字节。*
5. 执行 <init>
方法(构造函数)
调用对象的构造方法(即 <init>
方法),按代码逻辑初始化成员变量。此时才真正赋予程序员定义的初始值(如 int a = 1;
)。
关键点:此步骤前对象已存在,但字段值可能被构造方法覆盖。
6. 栈中建立引用关联
最后一步,在虚拟机栈的局部变量表中建立引用指向堆内存对象。例如 Object obj = new Object();
中的 obj
变量指向堆中分配的对象地址。
3. 对象的访问定位的两种方式?
1. 句柄访问(间接访问)
核心原理:在堆内存中划分独立的 句柄池,存储对象实例数据的指针(指向堆中实例对象)和对象类型数据的指针(指向方法区中的类元信息)。
访问路径:
-
栈帧中的引用(reference)先定位到句柄池中的句柄;
-
再通过句柄中的 实例数据指针 访问堆中的对象实例。
优点:引用本身稳定(对象被GC移动时,只需更新句柄中的指针,无需修改栈中的引用)。
缺点:访问对象需 额外多一次指针定位(引用→句柄→实例),性能略低。
2. 直接指针访问(HotSpot虚拟机默认方式)
核心原理:栈帧中的引用 直接存储对象在堆中的内存地址,无需句柄池中转。对象内存布局中额外存储指向方法区类元信息的指针(即对象头中的类型指针)。
访问路径:
-
引用直接定位到堆中的对象实例;
-
通过对象头中的 类型指针 访问方法区的类元信息。
优点:访问速度更快(减少一次指针定位),节省时间开销。
缺点:对象移动时(如GC整理内存),需修改所有引用该对象的地址(通过复制算法或指针更新实现)。
关键对比
维度 | 句柄访问 | 直接指针访问 |
---|---|---|
访问速度 | 较慢(两次寻址) | 更快(一次寻址) |
内存占用 | 更高(需句柄池) | 更低 |
引用稳定性 | 高(GC移动对象不修改引用) | 低(需更新引用) |
主流选择 | 较少使用 | HotSpot默认方式 |
4. 方法区和永久代以及元空间是什么关系呢?
方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。
概念 | 性质 | 与方法区的关系 | 关键特点 |
---|---|---|---|
方法区 | JVM 规范定义 | 逻辑标准 | 存储类元数据、运行时常量池等 |
永久代 | HotSpot 实现 | JDK 7 及之前的方法区实现 | 位于堆中,固定大小,易内存溢出 |
元空间 | HotSpot 实现 | JDK 8 及之后的方法区实现 | 位于本地内存,动态扩展,更稳定 |
5. 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
当元空间溢出时会得到如下错误:
java.lang.OutOfMemoryError: MetaSpace
你可以使用 -XX:MaxMetaspaceSize
标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize
调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize
控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
3、在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
4、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
特性 | 永久代 (PermGen) | 元空间 (Metaspace) |
---|---|---|
内存位置 | JVM 堆内存内部 | 本地内存 (Native Memory) |
大小管理 | 固定上限 (-XX:MaxPermSize ) | 可设置上限 (-XX:MaxMetaspaceSize ),默认几乎无上限 |
主要OOM | java.lang.OutOfMemoryError: PermGen space | java.lang.OutOfMemoryError: Metaspace |
GC触发 | 依赖Full GC | 独立于Old Gen GC,有专门的、更轻量的回收机制 |
设计目标 | 静态类加载模型 | 适应动态类加载、卸载、反射等现代需求 |
调优 | 需精确设置MaxPermSize | 通常只需监控,必要时设置MaxMetaspaceSize 防止失控 |
6. JDK 1.7 为什么要将字符串常量池移动到堆中?
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
7. JVM 常量池中存储的是对象还是引用呢?
1. 常量池的本质是数据结构,存储多种类型的数据项
JVM常量池(包括Class文件常量池和运行时常量池)本质上是一个表结构。它存储的是多种类型的数据项,而不是单一类型的对象或引用。这些数据项包括符号引用(Symbolic References,如类和接口的全限定名、字段和方法的名称和描述符)、字面量(Literals,如文本字符串、final
基本类型常量、Class
对象对应的类名)以及其他与类结构相关的常量值。其核心作用是提供链接阶段和运行时所需的关键元数据。
2. 存储符号引用:非直接对象或引用
常量池中存储的符号引用(如 java/lang/Object
、main:([Ljava/lang/String;)V
)是编译时生成的字符串形式的名称和描述符。在类加载过程的解析阶段之前,JVM并不知道这些符号引用对应的实际内存地址(如类在方法区的地址、方法在方法表的索引、字段在内存中的偏移量)。此时,常量池中存储的仅仅是这些符号信息本身,既不是对象实例,也不是指向对象或方法的直接内存地址(直接引用)。
3. 存储字面量:基本类型直接存储值,字符串特殊处理
-
对于基本类型的字面量(如
final int MAX = 100;
中的100
),常量池中直接存储的是该常量值的二进制表示(如整数值100
)。这不是对象也不是引用,就是一个具体的数值。 -
对于文本字符串字面量(如代码中的
"Hello World"
),情况较为特殊:-
在Class文件常量池中,存储的是该字符串的 UTF-8编码字节序列(可以理解为字符数据本身)。
-
当类被加载到JVM后,字符串字面量会被解析(“驻留”)到 字符串常量池中。运行时常量池中对于该字符串字面量的条目,最终会存储一个指向“字符串常量池”中对应
String
对象的引用。 -
关键点: 字符串常量池本身位于Java堆内存中(至少在JDK 7及之后版本)。因此,运行时常量池中存储的字符串字面量条目,最终是一个指向堆内存中
String
对象的引用。
-
4. 解析后:符号引用转换为直接引用
在类加载的解析阶段,JVM会将常量池中的符号引用解析为直接引用。解析完成后:
-
指向类、接口、字段、方法的符号引用会被替换为对应的直接引用(如指向方法区类数据的指针、方法表的索引、字段的内存偏移量等)。
-
指向字符串字面量的条目,如前所述,已经存储着指向堆中
String
对象的引用(这本身就是一种直接引用)。 -
基本类型字面量保持不变,存储具体值。
此时,运行时常量池中的相应条目存储的就是解析后的直接引用或具体值。
5. 总结核心要点
-
常量池存储多种类型数据:符号引用、基本类型字面量值、字符串字面量信息等。
-
符号引用在解析前是名称字符串,不是对象或引用。
-
基本类型字面量直接存储具体值,不是对象或引用。
-
字符串字面量在类加载解析后,运行时常量池中存储的是指向堆内存中
String
对象的引用(直接引用)。 -
解析完成后,所有符号引用都会被替换为对应的直接引用或具体值。
示例验证:字符串字面量
String s1 = "abc"; // 字面量方式
String s2 = new String("abc"); // new 方式
-
"abc"
这个字面量会存储在Class文件常量池。 -
类加载时,
"abc"
被解析并驻留(intern)到堆中的字符串常量池,创建一个String
对象表示"abc"
。 -
运行时常量池中对应
"abc"
的条目存储的就是指向堆中这个String
对象的引用。 -
s1
直接获得运行时常量池中的这个引用。 -
s2
通过new
在堆中创建一个新的String
对象(其内部char[]
可能指向常量池中那个String
对象的char[]
),s2
指向这个新对象。常量池中的引用与s2
指向的对象无关。
JVM 常量池数据存储和解析过程:
符号引用 → 直接引用(非对象)
基本类型 → 原始值(非对象)
字符串 → 指向堆对象的引用(唯一存储引用的场景)
核心结论:常量池主要存储字面量和符号引用,仅在解析后的字符串字面量条目中存储引用(指向堆中的String对象),其他情况均不存储对象或引用。
8. Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的?
字符串字面量进入常量池的时机
字符串字面量(如 "字面量"
)在类加载过程中被解析并添加到常量池。具体来说,当包含该字面量的类被 JVM 加载时,在“解析阶段”(Resolution Phase),JVM 会检查常量池:如果字面量尚未存在,则创建并存储;如果已存在,则直接引用现有对象。这个过程发生在 Java 程序的初始化阶段,而非运行时实例化对象时。关键点是:字面量的添加是类加载时的静态行为,与后续代码执行无关。
new String("字面量")
的具体行为分析
在表达式 new String("字面量")
中,"字面量"
字面量在类加载时已进入常量池。然后,在运行时执行 new String()
时:
-
"字面量"
会从常量池中获取引用,作为参数传递给String
构造函数。 -
new String()
会在堆上创建一个新的字符串对象,该对象内容与常量池中的字面量相同,但指向独立的内存地址。这意味着new String("字面量")
可能产生两个对象:一个在常量池(字面量本身),一个在堆(新创建的实例)。相关但无需深入的概念包括:intern()
方法可用于手动将字符串放入常量池。
字符串字面量,在类加载阶段进入常量池。
9. JVM内存模型里的堆和栈有什么区别?
1. 存储内容不同
-
栈:存储局部变量、方法参数及方法调用帧(如返回地址)。基本数据类型(如
int
、boolean
)的值和对象引用(内存地址)直接存放在栈帧中。 -
堆:存储所有对象实例(如
new Object()
)和数组。对象的成员变量(包括其他对象的引用)实际数据保存在堆中。
2. 内存分配与回收机制
-
栈:
-
内存分配/释放由编译器自动管理(入栈/出栈)。
-
每个方法调用创建一个栈帧,方法结束时帧自动弹出。
-
-
堆:
-
内存由垃圾回收器(GC)管理。
-
对象通过
new
手动申请空间,GC 自动回收无引用的对象(分代收集算法)。
-
3. 线程共享性
-
栈:线程私有。每个线程有独立的栈空间,栈内数据对其他线程不可见。
-
堆:线程共享。所有线程共享堆内存,需通过同步机制(如
synchronized
)保证数据安全。
4. 存取速度
- 栈的存取速度通常比堆快,因为栈遵循先进后出(LIFO,Last In First Out)的原则,操作简单快速。
- 堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
5. 存储空间
- 栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。
- 堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
10. 栈中存的到底是指针还是对象?
在JVM内存模型中,栈(Stack)主要用于管理线程的局部变量和方法调用的上下文,而堆(Heap)则是用于存储所有类的实例和数组,
当我们在栈中讨论"存储”时,实际上指的是存储基本类型的数据(如int,double等)和对象的引用,而不是对象本身。
这里的关键点是,栈中存储的不是对象,而是对象的引用。也就是说,当你在方法中声明一个对象,比如Myobject obj = new Myobject();,这里的 obj 实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据(例如在64位系统上是8字节),它指向堆中分配给对象的内存区域。
11. 堆分为哪几部分呢?
- 新生代(Young Generation):新生代分为Eden Space和Survivor Space。在Eden Space中,大多数新创建的对象首先存放在这里。Eden区相对较小,当Eden区满时,会触发一次Minor GC(新生代垃圾回收)。在Survivor Spaces中,通常分为两个相等大小的区域,称为S0(Survivor 0)和S1(Survivor 1)。在每次Minor GC后,存活下来的对象会被移动到其中一个Survivor空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
- 老年代(Old Generation/Tenured Generation):存放过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Major GC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
- 元空间(Metaspace):从Java 8开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。元空间并不在Java堆中,而是使用本地内存,这解决了永久代容易出现的内存溢出问题。
- 大对象区(Large Object Space/Humongous Objects):在某些JVM实现中(如G1垃圾收集器),为大对象分配了专门的区域,称为大对象区或Humongous Objects区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题。
12. 如果有个大对象一般是在哪个区域?
大对象通常会直接分配到老年代。
新生代主要用于存放生命周期较短的对象,并且其内存空间相对较小。如果将大对象分配到新生代,可能会很快导致新生代空间不足,从而频繁触发 Minor GC。而每次 Minor GC 都需要进行对象的复制和移动操作,这会带来一定的性能开销。将大对象直接分配到老年代,可以减少新生代的内存压力,降低 Minor GC 的频率。
大对象通常需要连续的内存空间,如果在新生代中频繁分配和回收大对象,容易产生内存碎片,导致后续分配大对象时可能因为内存不连续而失败。老年代的空间相对较大,更适合存储大对象,有助于减少内存碎片的产生。
13. 程序计数器的作用,为什么是私有的?
Java 程序是支持多线程一起运行的,多个线程一起运行的时候 CPU 会有一个调动器组件给它们分配时间片,比如说会给线程 1 分给一个时间片,它在时间片内如果它的代码没有执行完,它就会把线程 1 的状态执行一个暂存,切换到线程 2 去,执行线程 2 的代码,等线程 2 的代码执行到了一定程度,线程 2 的时间片用完了,再切换回来,再继续执行线程 1 剩余部分的代码。
我们考虑一下,如果在线程切换的过程中,下一条指令执行到哪里了,是不是还是会用到我们的程序计数器啊。每个线程都有自己的程序计数器,因为它们各自执行的代码的指令地址是不一样的呀,所以每个线程都应该有自己的程序计数器。
14. 方法区中的方法的执行过程?
当程序中通过对象或类直接调用某个方法时,主要包括以下几个步骤:
- 解析方法调用:JVM会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
- 栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等。
- 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。
15. 方法区中还有哪些东西?
《深入理解 Java 虚拟机》书中对⽅法区 (Method Area)存储内容描述如下:它⽤于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
- 类信息:包括类的结构信息、类的访问修饰符、⽗类与接⼝等信息。
- 常量池:存储类和接⼝中的常量,包括字⾯值常量、符号引⽤,以及运⾏时常量池。
- 静态变量:存储类的静态变量,这些变量在类初始化的时候被赋值。
- ⽅法字节码:存储类的⽅法字节码,即编译后的代码。
- 符号引⽤:存储类和⽅法的符号引⽤,是⼀种直接引⽤不同于直接引⽤的引⽤类型。
- 运⾏时常量池:存储着在类⽂件中的常量池数据,在类加载后在⽅法区⽣成该运⾏时常量池。
- 常量池缓存:⽤于提升类加载的效率,将常⽤的常量缓存起来⽅便使⽤。
16. String保存在哪里呢?
String 保存在字符串常量池中,不同于其他对象,它的值是不可变的,且可以被多个引用共享。
17. String s = new String(“abc”)执行过程中分别对应哪些内存区域?
首先,我们看到这个代码中有一个 new 关键字,我们知道 new 指令是创建一个类的实例对象并完成加载初始化的,因此这个字符串对象是在运行期才能确定的,创建的字符串对象是在堆内存上。
其次,在 String 的构造方法中传递了一个字符串 abc ,由于这里的 abc 是被 final 修饰的属性,所以它是一个字符串常量。在首次构建这个对象时,JVM 拿字面量 “abc” 去字符串常量池试图获取其对应 String 对象的引用。于是在堆中创建了一个 “abc” 的 String 对象,并将其引用保存到字符串常量池中,然后返回;
所以,如果 abc 这个字符串常量不存在,则创建两个对象,分别是 abc 这个字符串常量,以及 new String 这个实例对象。如果 abc 这字符串常量存在,则只会创建一个对象。
18. 引用类型有哪些?有什么区别?
引用类型主要分为强软弱虚四种:
- 强引用指的就是代码中普遍存在的赋值方式,比如 “A a = new A()” 这种。强引用关联的对象,永远不会被 GC 回收。
- 软引用可以用 SoftReference 来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
- 弱引用可以用 WeakReference 来描述,他的强度比软引用更低一点,弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够。
- 虚引用也被称作幻影引用,是最弱的引用关系,可以用 PhantomReference 来描述,他必须和 ReferenceQueue 一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
回收条件与时机:
强引用:只要可达,永不回收。
软引用:内存不足时回收 (
GC
决定时机)。弱引用:下次
GC
发生时即回收(只要无强/软引用)。虚引用:随时可能回收(只要无强/软/弱引用),回收后入队通知。
19. 内存泄漏和内存溢出的理解?
内存泄露:
本质是对象无法被垃圾回收(GC)释放,导致无用对象持续占用内存空间。
-
核心机制:当对象不再被程序使用,但因意外的强引用(如静态集合、未关闭的资源) 被持有,GC会判定其“存活”而无法回收。
内存泄露常见原因:
- 静态集合:使用静态数据结构(如 HashMap 或 ArrayList)存储对象,且未清理。
- 事件监听:未取消对事件源的监听,导致对象持续被引用。
- 线程:未停止的线程可能持有对象引用,无法被回收。
内存溢出:
内存溢出是指 Java 虚拟机(JVM)在申请内存时,无法找到足够的内存,最终引发 OutOfMemoryError。这通常发生在堆内存不足以存放新创建的对象时。
内存溢出常见原因:
- 大量对象创建:程序中不断创建大量对象,超出 JVM 堆的限制。
- 持久引用:大型数据结构(如缓存、集合等)长时间持有对象引用,导致内存累积。
- 递归调用:深度递归导致栈溢出。
20. jvm 内存结构有哪几种内存溢出的情况?
- 堆内存溢出:当出现
Java.lang.OutOfMemoryError: Java heap space
异常时,即堆内存溢出。原因是代码中可能存在大对象分配或发生内存泄露,致使多次GC
后仍无法找到足够大内存容纳当前对象。 - 栈溢出:若编写一段不断递归调用且无退出条件的程序,会导致不断压栈。类似情况,
JVM
实际会抛出StackOverflowError
;当然,若JVM
试图扩展栈空间失败,则会抛出OutOfMemoryError
。 - 元空间溢出:元空间溢出时,系统会抛出
Java.lang.OutOfMemoryError: Metaspace
。出现此异常原因是系统代码非常多、引用第三方包非常多或通过动态代码生成类加载等方法,导致元空间内存占用很大。 - 直接内存内存溢出:使用
ByteBuffer
中的allocateDirect()
时会用到(在很多JavaNIO
(如netty
)框架中被封装为其他方法),出现问题会抛出Java.lang.OutOfMemoryError: Direct buffer memory
异常。
21. 有具体的内存泄漏和内存溢出的例子么请举例及解决方案?
内存泄露:
示例1:静态集合长期持有对象引用
public class StaticLeak {static List<Object> list = new ArrayList<>(); // 静态集合void addData() {for (int i = 0; i < 1000; i++) {list.add(new byte[1024 * 1024]); // 添加大对象}}
}
-
核心问题:静态集合
list
的生命周期与类相同,即使对象不再使用,也无法被 GC 回收。 -
解决方案:
-
使用
WeakHashMap
(基于弱引用)替代强引用集合。 -
对象不再需要时,显式移除引用(如
list.remove(object)
)。 -
尽量减少静态变量。
-
如果使用单例,尽量采用懒加载。
-
示例2:未关闭资源(如文件流、数据库连接)
public void readFile() {try {FileInputStream fis = new FileInputStream("largefile.txt"); // 打开文件流// 处理文件但未关闭 fis} catch (IOException e) { /* ... */ }
}
无论什么时候当我们创建一个连接或打开一个流,JVM 都会分配内存给这些资源。比如,数据库链接、输入流和 session 对象。
忘记关闭这些资源,会阻塞内存,从而导致 GC 无法进行清理。特别是当程序发生异常时,没有在 finally 中进行资源关闭的情况。这些未正常关闭的连接,如果不进行处理,轻则影响程序性能,重则导致 OutOfMemoryError 异常发生。
如果进行处理呢?第一,始终记得在 finally 中进行资源的关闭;第二,关闭连接的自身代码不能发生异常;第三,Java7 以上版本可使用 try-with-resources 代码方式进行资源关闭。
内存溢出:
示例1:堆内存溢出(Heap OOM)
public class HeapOOM {public static void main(String[] args) {List<byte[]> list = new ArrayList<>();while (true) {list.add(new byte[1024 * 1024]); // 持续分配大数组}}
}
示例2:方法区溢出(Metaspace OOM)
public class MetaSpaceOOM {static class DynamicClass { /* 动态生成的类 */ }public static void main(String[] args) {while (true) {// 动态生成类并加载Enhancer enhancer = new Enhancer();enhancer.setSuperclass(DynamicClass.class);enhancer.create(); // 如使用 CGLib}}
}
示例3:栈溢出(StackOverflowError)
public class StackOverflow {void recursiveCall() {recursiveCall(); // 无限递归}public static void main(String[] args) {new StackOverflow().recursiveCall();}
}