面试官您好,您问的“JVM内存模型”,这是一个非常核心的问题。在Java技术体系中,这个术语通常可能指代两个不同的概念:一个是JVM的运行时数据区,另一个是Java内存模型(JMM)。前者是JVM的内存布局规范,描述了内存被划分成哪些区域;后者是并发编程的抽象模型,定义了线程间如何通过内存进行通信。
我先来介绍一下JVM的运行时数据区,这通常是大家更常提到的“内存模型”。
根据Java虚拟机规范,JVM在执行Java程序时,会把它所管理的内存划分为若干个不同的数据区域。这些区域可以分为两大类:线程共享的和线程私有的。
【线程共享区域】
这些区域的数据会随着JVM的启动而创建,随JVM的关闭而销毁,并且被所有线程共享。
堆 (Heap)
new
关键字创建的所有对象,都在这里分配内存。方法区 (Method Area)
运行时常量池 (Runtime Constant Pool)
【线程私有区域】
这些区域的生命周期与线程相同,随线程的创建而创建,随线程的销毁而销毁。
Java虚拟机栈 (Java Virtual Machine Stack)
StackOverflowError
。本地方法栈 (Native Method Stack)
native
方法(即由非Java语言实现的方法)服务。程序计数器 (Program Counter Register)
OutOfMemoryError
情况的区域。如果说运行时数据区是物理层面的内存划分,那么Java内存模型(JMM)就是并发编程领域的抽象规范。它不是真实存在的内存结构,而是一套规则。
volatile
、synchronized
、final
的内存语义,以及著名的Happens-Before原则。总结一下:
面试官您好,堆和栈是JVM运行时数据区中两个最核心、但功能和特性截然不同的内存区域。它们的区别,我通常从以下几个维度来理解。
我们可以把一次程序运行想象成在一家快餐店点餐:
栈 (点餐流程单):
堆 (中央厨房):
new
关键字创建的所有对象,其实体都存放在堆中。栈上的那个对象引用,仅仅是一个指向堆中对象实体的“门牌号”或“地址”。栈 (自动化的流程单):
堆 (需要专人管理的厨房):
new
开始,直到没有任何引用指向它时才结束。栈:
-Xss
参数设置)。堆:
-Xms
和-Xmx
设置)。栈:线程私有。每个线程都有自己独立的虚拟机栈。一个线程不能访问另一个线程的栈空间,因此栈上的数据天然是线程安全的。
堆:所有线程共享。整个JVM进程只有一个堆。这意味着任何线程都可以通过引用访问堆上的同一个对象。这也正是多线程并发问题的根源所在,我们需要通过各种锁机制来保证对堆上共享对象访问的安全性。
这两种内存区域如果使用不当,会分别导致两种最经典的JVM异常:
StackOverflowError
(栈溢出):通常是由于方法递归调用过深(流程单写得太长,超出了纸的范围),或者栈帧过大导致的。OutOfMemoryError: Java heap space
(堆溢出):通常是由于创建了大量的对象实例,并且这些对象由于被持续引用而无法被GC回收(后厨的东西太多,放不下了),最终耗尽了堆内存。通过这个全方位的对比,我们就能清晰地理解堆和栈在JVM中所扮演的不同角色和承担的不同职责了。
面试官您好,您这个问题问到了JVM内存管理的一个核心细节。最精确的回答是:栈中既不存指针,也不直接存对象,它存的是“基本类型的值”和“对象的引用”。
我们可以通过一个具体的代码例子和生活中的比喻来理解它。
假设我们有下面这样一个方法:
public void myMethod() {
// 1. 基本数据类型
int age = 30;
// 2. 对象引用类型
String name = "Alice";
// 3. 数组引用类型
int[] scores = new int[3];
}
当myMethod()
被调用时,JVM会为它在当前线程的虚拟机栈上创建一个栈帧。这个栈帧的“局部变量表”里会存放以下内容:
对于 int age = 30;
:
age
是一个基本数据类型。JVM会直接在栈帧里为age
分配一块空间,并将值 30
本身存放在这块空间里。对于 String name = "Alice";
:
name
是一个对象引用。JVM的处理分为两步:
String
对象,其内容是 “Alice”。name
变量分配一块空间,这块空间里存放的不是"Alice"这个字符串本身,而是一个指向堆中那个String
对象的内存地址。这个地址,我们就称之为 “引用”(Reference)。对于 int[] scores = new int[3];
:
scores
也是一个对象引用(在Java中,数组是对象)。String
类似:
scores
变量分配空间,存放一个指向堆中那个数组对象的引用。我们可以把这个过程比喻成入住一家酒店:
那么:
new String("Alice")
:相当于酒店为你分配了一间房间(在堆上创建对象)。String name = ...
:酒店前台给了你一张房卡(在栈上创建引用),这张房卡上有房间号,可以让你找到并打开那间房。所以,严格来说,栈中存的既不是C++意义上的“指针”(虽然功能类似,但Java的引用是类型安全的,且由JVM管理),更不是对象本身。它存的是一个受JVM管理的、类型安全的、指向堆内存的“门牌号”——我们称之为“引用”。
面试官您好,JVM的堆内存是垃圾回收器(GC)进行管理的主要区域,为了优化GC的效率,特别是为了实现分代回收(Generational GC) 的思想,HotSpot虚拟机通常会将堆划分为以下几个主要部分:
新生代是绝大多数新创建对象的“第一站”。它的主要特点是对象“朝生夕死”,存活率低。因此,新生代通常采用复制算法(Copying Algorithm) 进行垃圾回收,这种算法在对象存活率低的场景下效率非常高。
新生代内部又被细分为三个区域:
a. Eden区 (Eden Space)
new
一个对象时,它首先会被分配在Eden区。b. 两个Survivor区 (Survivor Space)
老年代用于存放那些生命周期较长的对象,或者是一些大对象。
对象来源:
-XX:PretenureSizeThreshold
参数设置),为了避免它在新生代的Eden区和Survivor区之间频繁复制,JVM会选择将其直接分配在老年代。GC算法:老年代的对象特点是存活率高,不适合用复制算法(因为需要复制的对象太多,空间浪费也大)。因此,老年代的垃圾回收(通常被称为Major GC或Full GC)通常采用标记-清除(Mark-Sweep)或标记-整理(Mark-Compact) 算法。
我们可以用一个故事来描绘一个普通对象的生命周期:
这种分代的设计,使得JVM可以针对不同生命周期的对象,采用最高效的回收策略,从而大大提升了GC的整体性能。
面试官您好,程序计数器(Program Counter Register)是JVM运行时数据区中一块非常小但至关重要的内存区域。要理解它,我们可以从 “它是什么” 和 “为什么必须是线程私有” 这两个角度来看。
native
方法(本地方法),那么这个计数器的值是空(Undefined)。因为native
方法是由底层操作系统或其他语言实现的,不受JVM字节码解释器的控制。其根本原因就在于Java的多线程是通过CPU时间片轮转来实现的。
场景分析:
结论:
值得一提的是,程序计数器是JVM运行时数据区中唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError
情况的区域。因为它所占用的内存空间非常小且固定,几乎可以忽略不计。
总结一下,程序计数器就像是每个线程专属的 “书签”,它忠实地记录着每个线程的阅读进度,确保了在并发执行和频繁切换的复杂环境下,每个线程都能准确无误地继续自己的执行流程。因此,它的“线程私有”特性,是实现多线程正确性的根本保障。
面试官您好,虽然方法本身的代码是存放在方法区的,但一个方法的执行过程,其主战场却是在Java虚拟机栈(JVM Stack) 中。
整个过程,可以看作是一个栈帧(Stack Frame)在虚拟机栈中“入栈”和“出栈” 的旅程。
我通过一个简单的例子来描述这个动态过程:
public class MethodExecution {
public static void main(String[] args) {
int result = add(3, 5); // 1. 调用add方法
System.out.println(result);
}
public static int add(int a, int b) { // 2. add方法
int sum = a + b;
return sum; // 3. 返回
}
}
main
线程执行到add(3, 5)
这行代码时,JVM首先需要找到add
方法在方法区的具体位置(如果之前没解析过的话)。add
方法之前,JVM会在main
线程的虚拟机栈中,为add
方法创建一个新的栈帧(我们称之为add-Frame
),并将其压入栈顶。
main
方法对应的栈帧(main-Frame
)就在add-Frame
的下方。add-Frame
就像一个专属的工作空间,它里面包含了:
add
方法的参数a
和b
(值分别为3和5),以及局部变量sum
。add
方法执行完毕后,应该回到main
方法中的哪一行代码继续执行。main-Frame
中的操作数3和5,会被传递到add-Frame
的局部变量表中,赋值给a
和b
。add
方法的字节码指令。
a
和b
的值加载到add-Frame
的操作数栈上。sum
中。return
指令,将局部变量sum
的值再次加载到操作数栈顶,准备作为返回值。方法执行完毕,需要返回。返回分为两种情况:
正常返回 (Normal Return):
return sum;
。add
方法的栈帧会将返回值(8)传递给调用者(main
方法)的栈帧,通常是压入main-Frame
的操作数栈中。add
方法的栈帧会从虚拟机栈中被销毁(出栈)。main
方法中调用add
的那一行,继续向后执行(比如将main-Frame
操作数栈顶的8赋值给result
变量)。异常返回 (Abrupt Return):
add
方法中发生了未被捕获的异常。add
方法的栈帧同样会被销毁(出栈),但它不会有任何返回值给调用者。main
方法去处理。如果main
方法也处理不了,这个异常会继续向上传播,直到最终导致线程终止。总结一下,方法的执行过程,本质上是线程的虚拟机栈中,栈帧不断入栈和出栈的过程。当前正在执行的方法,其对应的栈帧永远位于栈顶。这个清晰、高效的栈式结构,是Java方法能够实现有序调用和递归的基础。
面试官您好,方法区是JVM运行时数据区中一个非常重要的线程共享区域。正如《深入理解Java虚拟机》中所述,它主要用于存储已被虚拟机加载的元数据信息。
我们可以把方法区想象成一个JVM的 “类型信息档案馆”,当一个.class
文件被加载进内存后,它的大部分“档案信息”都存放在这里。
这些信息主要可以分为以下几大类:
这是方法区的核心。对于每一个被加载的类(或接口),JVM都会在方法区中存储其完整的元信息,包括:
java.lang.String
)。java.lang.Object
)。class
还是接口interface
)。public
, abstract
, final
等)。来源:每个.class
文件内部都有一个“常量池表(Constant Pool Table)”,用于存放编译期生成的各种字面量和符号引用。当这个类被加载到JVM后,这个静态的常量池表就会被转换成方法区中的运行时常量池。
内容:
"Hello, World!"
)、final
常量的值等。动态性:运行时常量池的一个重要特性是它是动态的。比如String.intern()
方法,就可以在运行时将新的常量放入池中。
static
关键字修饰的字段,会存放在方法区中。值得一提的是,方法区是一个逻辑上的概念,它的具体物理实现在不同JDK版本中是不同的:
OutOfMemoryError: PermGen space
。总结一下,方法区就像是JVM的“图书馆”,里面存放着所有加载类的“户口本”(类型信息)、“字典”(运行时常量池)、“公共财产”(静态变量)以及“最优操作手册”(JIT编译后的代码)。它是Java程序能够运行起来的基础。
String s = "abc";
)存储位置:当您像这样直接用双引号创建一个字符串时,这个字符串"abc"
会被存放在一个特殊的内存区域,叫做字符串常量池(String Constant Pool)。
工作机制:
"abc"
的字符串。s
。String
对象,内容是"abc"
,然后将它的引用返回。特性:这种方式创建的字符串,是共享的。例如:
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); // 输出: true
这里的s1
和s2
指向的是常量池中同一个对象。
new
关键字创建 (String s = new String("abc");
)存储位置:这种方式的行为就和普通的Java对象一样了,它会涉及到两个内存区域。
工作机制:
new String("abc")
这行代码,首先,JVM还是会去检查字符串常量池,确保池中有一个"abc"
的对象(如果没有就创建一个)。new
关键字会在Java堆(Heap) 上,创建一个全新的String
对象。这个新的String
对象内部的字符数组,会复制常量池中那个"abc"
对象的数据。s
。特性:这种方式总是在堆上创建一个新对象,即使字符串的内容已经存在于常量池中。
String s1 = "abc"; // 在常量池
String s2 = new String("abc"); // 在堆上
System.out.println(s1 == s2); // 输出: false
这里的s1
和s2
指向的是两个完全不同的对象,一个在常量池,一个在堆。
值得一提的是,字符串常量池的物理位置在JDK版本中是有变迁的:
将常量池移到堆中,一个主要的好处是方便GC对常量池中的字符串进行回收。
intern()
方法的作用String
类还提供了一个intern()
方法,它是一个与常量池交互的桥梁:
String
对象(比如通过new
创建的)调用intern()
方法时,JVM会去字符串常量池里查找是否存在内容相同的字符串。
总结一下:
String
,对象在字符串常量池中。new
关键字创建的String
,对象主体在Java堆中。理解这个区别,对于我们优化内存使用和正确判断字符串相等性(特别是用==
时)至关重要。
面试官您好,Java中的引用类型,除了我们最常用的强引用,还提供了软、弱、虚三种不同强度的引用,它们的设计主要是为了让我们可以更灵活地与垃圾回收器(GC) 进行交互,从而实现更精细的内存管理。
我们可以把这四种引用的强度,比作一段关系的“牢固程度”:
Object obj = new Object();
。只要一个对象还存在强引用指向它,那么垃圾回收器永远不会回收这个对象,即使系统内存已经非常紧张,即将发生OutOfMemoryError
。null
(比如obj = null;
),或者超出了其作用域,它与对象之间的“强关联”才会断开。SoftReference
类来包装对象。软引用关联的对象,是那些有用但并非必需的对象。WeakReference
类来包装对象。弱引用的强度比软引用更弱。ThreadLocal
的Key:ThreadLocalMap
中的Key就是对ThreadLocal
对象的弱引用,这有助于在ThreadLocal
对象本身被回收后,防止一部分内存泄漏。WeakHashMap
。PhantomReference
类实现,并且必须和引用队列(ReferenceQueue
)联合使用。phantomRef.get()
方法永远返回null
。ReferenceQueue
中。DirectByteBuffer
,它在Java堆上只是一个很小的对象,但它在堆外分配了大量的本地内存。我们可以为这个DirectByteBuffer
对象创建一个虚引用。当GC回收这个对象时,虚引用会入队。我们的后台清理线程可以监视这个队列,一旦发现有虚引用入队,就知道对应的堆外内存已经不再被使用,就可以安全地调用free()
方法来释放这块本地内存了。通过这四种不同强度的引用,Java赋予了开发者与GC协作的能力,让我们能够根据对象的生命周期和重要性,设计出更健壮、内存使用更高效的程序。
面试官您好,我了解弱引用。它是一种比软引用“更弱”的引用类型,其核心特点是:一个只被弱引用指向的对象,只要垃圾回收器开始工作,无论当前内存是否充足,它都一定会被回收。
弱引用提供了一种让我们能够“监视”一个对象生命周期,但又“不干涉”其被回收的方式。
在Java的API和各种框架中,弱引用有很多巧妙的应用。我举两个最著名的例子来说明它在哪里用,以及如何用:
案例一:ThreadLocal
中的内存泄漏“防线”
这是弱引用最广为人知的一个应用。
ThreadLocal
的内部,每个线程都持有一个ThreadLocalMap
。这个Map的Entry(键值对)被设计为:
ThreadLocal
对象的弱引用 (WeakReference
)。ThreadLocal
变量置为null
了 (myThreadLocal = null;
),这意味着我们不再需要它了。myThreadLocal
被置为null
,只要这个线程还存活,ThreadLocalMap
中的Entry就会一直强引用着这个ThreadLocal
对象,导致它永远无法被回收。myThreadLocal
在外部的强引用消失,下一次GC发生时,ThreadLocalMap
中那个作为Key的ThreadLocal
对象就会被自动回收,Entry的Key就变成了null
。ThreadLocal
在调用get()
, set()
时,会顺便检查并清理掉这些Key为null
的Entry。弱引用的使用,是ThreadLocal
能够进行部分自我清理、防止内存泄漏的第一道防线。案例二:WeakHashMap
—— 构建“会自动清理的缓存”
这是一个更直接体现弱引用价值的例子。
WeakHashMap
是什么?
HashMap
。WeakHashMap
中put(key, value)
时,这个key
对象被弱引用所包裹。key
对象时,在下一次GC后,这个key
对象就会被回收。WeakHashMap
内部有一个机制(通过ReferenceQueue
),当它发现某个key
被回收后,它会自动地将整个Entry(包括key和value)从Map中移除。key
,缓存的内容作为value
。WeakHashMap
中对应的这条缓存记录也会自动地、安全地被清理掉,我们完全不需要手动去维护缓存的过期和清理,从而完美地避免了因缓存引发的内存泄漏。弱引用的核心用途,就是构建一种非侵入式的、依赖于GC的关联关系。它允许我们“依附”于一个对象,但又不会强行延长它的生命周期。这在实现缓存、元数据存储、监听器管理等需要避免内存泄漏的场景中,是非常有价值的工具。
面试官您好,内存泄漏和内存溢出是Java开发者必须面对的两个核心内存问题。它们是两个不同但又紧密相关的概念。
我可以用一个 “水池注水” 的比喻来解释它们:
HashMap
,如果不手动remove
,它里面存放的对象的生命周期就和整个应用程序一样长,即使这些对象早就不需要了。finally
块中正确关闭,它们持有的底层资源和缓冲区内存就无法被释放。ThreadLocal
使用不当:没有在finally
中调用remove()
方法,导致在线程池场景下,Value对象无法被回收。new
一个新对象),而JVM发现堆内存已经耗尽,并且经过GC后也无法腾出足够的空间,最终只能抛出OutOfMemoryError
,导致应用程序崩溃。-Xmx
参数设置的堆最大值,对于应用的实际需求来说太小了。StackOverflowError
:虽然这也是OOM的一种,但它特指栈内存溢出,通常是由于无限递归或方法调用链过深导致的。在实践中,排查这类问题,我会使用专业的内存分析工具:
-XX:+HeapDumpOnOutOfMemoryError
)让JVM在发生OOM时,自动生成一个堆转储快照(Heap Dump)文件。面试官您好,JVM的内存结构在不同区域都可能发生内存溢出,这通常意味着程序申请内存超出了JVM所能管理的上限。我主要熟悉以下四种最常见的内存溢出情况:
异常信息:java.lang.OutOfMemoryError: Java heap space
原因分析:这是最常见的一种OOM。正如您所说,根本原因是在堆中无法为新创建的对象分配足够的空间。这通常由两种情况导致:
代码示例:
// 模拟内存确实不够用
List<byte[]> list = new ArrayList<>();
while (true) {
// 不断创建大对象,直到耗尽堆内存
list.add(new byte[1024 * 1024]); // 1MB
}
解决方案:
-Xmx
参数来调高堆的最大值。异常信息:通常是 java.lang.StackOverflowError
,在极少数无法扩展栈的情况下可能是OutOfMemoryError
。
原因分析:每个线程都有自己的虚拟机栈,用于存放方法调用的栈帧。栈溢出通常不是因为内存“不够大”,而是因为栈的深度超过了限制。
代码示例:
public class StackOverflowTest {
public static void recursiveCall() {
recursiveCall(); // 无限递归
}
public static void main(String[] args) {
recursiveCall();
}
}
解决方案:
-Xss
参数来增大每个线程的栈空间大小,但这治标不治本。异常信息:java.lang.OutOfMemoryError: Metaspace
原因分析:元空间(在JDK 8之前是永久代)主要存储类的元数据信息。元空间溢出意味着加载的类太多了。
代码示例:
// 使用CGLIB等字节码技术不断生成新类
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MyClass.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) ->
proxy.invokeSuper(obj, args));
enhancer.create();
}
解决方案:
-XX:MaxMetaspaceSize
参数来调高元空间的最大值。异常信息:java.lang.OutOfMemoryError: Direct buffer memory
原因分析:这是由于使用了NIO(New I/O) 中的ByteBuffer.allocateDirect()
方法,在堆外(本地内存) 分配了大量内存,而这部分内存又没能被及时回收。
DirectByteBuffer
对象被GC回收时,触发一个清理机制(通过虚引用和Cleaner
)。如果堆内存迟迟没有触发GC,那么堆外的直接内存就可能一直得不到释放,最终耗尽。代码示例:
// 不断分配直接内存,但不触发GC
List<ByteBuffer> buffers = new ArrayList<>();
while (true) {
buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); // 1MB
}
解决方案:
System.gc()
来“建议”JVM进行一次Full GC,但这通常不被推荐。-XX:MaxDirectMemorySize
参数来明确指定直接内存的最大容量。通过对这几种OOM的理解和分析,我们可以在遇到问题时,根据不同的异常信息,快速地定位到可能的原因,并采取相应的解决措施。
这是最常见、也最容易被忽视的一种内存泄漏。
假设我们有一个需求,需要临时缓存一些用户信息,但开发人员错误地使用了一个静态的HashMap
来存储。
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
// 模拟一个用户服务
class UserService {
// 【问题根源】使用了一个静态的Map来缓存用户对象
private static Map<String, User> userCache = new HashMap<>();
public void cacheUser(User user) {
if (!userCache.containsKey(user.getId())) {
userCache.put(user.getId(), user);
System.out.println("缓存用户: " + user.getId() + ", 当前缓存大小: " + userCache.size());
}
}
// 缺少一个移除缓存的方法!
}
// 用户对象
class User {
private String id;
private String name;
// ... 构造函数, getter/setter ...
public User(String id, String name) { this.id = id; this.name = name; }
public String getId() { return id; }
}
// 模拟Web请求不断调用
public class StaticLeakExample {
public static void main(String[] args) {
UserService userService = new UserService();
while (true) {
// 模拟每次请求都创建一个新的User对象并缓存
String userId = UUID.randomUUID().toString();
User newUser = new User(userId, "User-" + userId);
userService.cacheUser(newUser);
// 为了不让程序瞬间OOM,稍微 sleep 一下
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
userCache
是一个static
变量,它的生命周期和整个UserService
类的生命周期一样长,通常也就是整个应用程序的运行时间。while (true)
循环不断地创建新的User
对象并调用cacheUser
方法。每调用一次,这个新的User
对象就被put
进了静态的userCache
中。userCache
这个Map一直强引用着所有被放进去的User
对象。即使这些User
对象在业务逻辑上早就不需要了,但只要userCache
还引用着它们,垃圾回收器就永远不会回收这些User
对象。userCache
越来越大,占用的堆内存越来越多,最终耗尽所有堆内存,抛出 java.lang.OutOfMemoryError: Java heap space
。明确移除(治标):最直接的办法是,在确定不再需要某个缓存对象时,手动从userCache
中调用remove()
方法将其移除,切断强引用。但这依赖于开发者必须记得去调用,容易遗漏。
使用弱引用(治本):这是一个更优雅、更自动化的解决方案。我们可以使用WeakHashMap
来替代HashMap
。
// 解决方案:使用WeakHashMap
private static Map<String, User> userCache = new WeakHashMap<>();
WeakHashMap
的特性:它的键(Key)是弱引用。当一个User
对象在程序的其他地方不再有任何强引用指向它时(比如,处理完一个Web请求,相关的User
对象都变成了垃圾),即使它还存在于WeakHashMap
中,GC也会将它回收。WeakHashMap
在检测到Key被回收后,会自动地将整个键值对从Map中移除。使用专业的缓存框架(最佳实践):在生产环境中,我们不应该手写缓存。应该使用专业的缓存框架,如Guava Cache, Caffeine, 或 Ehcache。这些框架不仅内置了基于弱引用、软引用的自动清理机制,还提供了更丰富的功能,如基于大小的淘汰、基于时间的过期、统计等。
ThreadLocal
使用不当导致的内存泄漏这个案例在线程池环境下尤其常见。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalLeakExample {
// 创建一个ThreadLocal来存储大对象
static ThreadLocal<byte[]> localVariable = new ThreadLocal<>();
public static void main(String[] args) {
// 使用固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(1);
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
// 【问题根源】为ThreadLocal设置了一个大对象
localVariable.set(new byte[1024 * 1024 * 5]); // 5MB
System.out.println("线程 " + Thread.currentThread().getName() + " 设置了值");
// 【关键问题】任务执行完毕后,没有调用remove()方法!
// localVariable.remove(); // 正确的做法应该是加上这一行
});
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// ...
}
}
newFixedThreadPool(1)
创建了一个只有一个线程的线程池。这意味着,所有100个任务,都是由同一个线程来轮流执行的。ThreadLocal
的存储原理:ThreadLocal
的值实际上是存储在Thread
对象自身的ThreadLocalMap
中的。ThreadLocalMap
中放入了一个5MB的字节数组。localVariable
这个ThreadLocal
对象可能会因为方法结束而被回收(它的Key是弱引用)。线程池 -> 线程T1 -> T1.threadLocals(ThreadLocalMap) -> Entry -> Value(5MB的byte[])
。localVariable.set()
时,它会覆盖掉旧的值,但如果后续任务不再使用这个ThreadLocal
,那么最后一次设置的那个5MB的数组就会永久地留在这个线程里,直到线程池被关闭。如果线程池很大,或者ThreadLocal
存储的对象更多,就会慢慢地耗尽内存,导致OOM。解决方案非常简单,但必须强制遵守:
养成在finally
块中调用remove()
的习惯。
executor.submit(() -> {
localVariable.set(new byte[1024 * 1024 * 5]);
try {
// ... 执行业务逻辑 ...
System.out.println("线程 " + Thread.currentThread().getName() + " 设置了值");
} finally {
// 确保在任务结束时,无论正常还是异常,都清理ThreadLocal的值
localVariable.remove();
System.out.println("线程 " + Thread.currentThread().getName() + " 清理了值");
}
});
调用remove()
方法会彻底地将ThreadLocalMap
中对应的Entry
移除,从而切断整个引用链,让Value对象可以被正常地垃圾回收。这是使用ThreadLocal
时必须遵守的铁律。