1. 为什么说类是图纸,对象是实物
1.1 从“设计蓝图"到"落地成楼”的核心差异
先抛一个我经常在面试里问的问题:你new一个对象的时候,到底发生了什么?很多候选人能流利背出"分配内存、初始化、返回引用",但问到"内存是怎么分的?对象头里存了什么?"就卡壳了。问题就出在大多数人把类和对象当成一个概念在学,从来没把它们放回JVM的内存现场去看。
类(Class)和对象(Object)最朴素的关系,就是图纸和大楼的关系。类定义的是"大楼长什么样"——有哪些属性(墙、窗、门)、有哪些行为(电梯运行、消防疏散),但它本身不占工地现场的一平米土地。对象才是这栋楼,是实实在在立在堆内存里的那一块地皮、那几面墙。你写一个Person类,里面定义了name和age,这一行代码只是把图纸挂在了图纸库(方法区/元空间)里;只有当你写下new Person(),JVM才会在堆上圈出一块地,把图纸上描述的字段、方法入口按规则填进去。
这个比喻的价值在于,它能解释很多令人困惑的现象:为什么同一个类能new出无数个对象?因为图纸可以复印无数份,每份盖出来的楼都是独立的,改一栋楼的电梯井不会影响另一栋。为什么静态变量不归对象管?因为静态成员是"图纸上的公共备注",所有楼共用,不画在某一栋楼的施工图里。为什么判断两个对象相等不能用==?因为==比的是"是不是同一栋楼",而不是"户型是否相同"。把这些想通了,后面看内存模型就不会觉得抽象。
1.2 类、对象、引用:三者千万别搅成一锅粥
很多人把"对象"和"引用"混着说,一写代码就是"这个对象是null",严格讲应该是"这个引用指向null"。类、对象、引用是三个完全不同的层级:
- 类:静态的、编译期就确定的结构描述,存储在元空间(方法区)。
- 对象:运行时在堆内存中创建的具体实例,拥有独立的实例字段值。
- 引用:栈帧里的一个变量,它不装对象本身,只装"指向堆中这块内存的地址"。
画个简单的心智模型:栈上有一个变量叫p,它里面存了一个数值——这个数值是堆中某个内存区域的首地址。堆里那片区域,按照Person类的布局排布,有对象头、有name字段、有age字段。p通过这个地址找到那片区域,这就是"引用指向对象"。你传参的时候传的是地址的副本,所以方法内部重新赋值并不会改变外部引用的指向,但通过引用修改对象内部字段却会影响原对象——因为地址指向的还是同一块地皮。
我见过不少新手在这里栽跟头。比如写了一个交换两个对象的方法,以为传引用就能换,结果方法内外引用各换各的;再比如用HashMap时把对象当key,却修改了对象的hashCode相关字段,导致再也查不到这个键。这些都是没分清"引用本身"和"引用指向的对象"各自的生命周期和可变性导致的。
1.3 为什么Java要“拒绝”自由创建对象
C语言程序员刚接触Java时往往会问:为什么我不能像malloc一样,随意在任意地址建对象?Java的设计哲学是"安全优先,效率靠JIT和GC兜底"。让开发者手动管理内存,C已经证明了两个极端结果:高手写出极致性能,大多数人制造出内存泄漏和悬垂指针。
Java的解决方案是:你只负责new,我不但替你分配内存,还替你回收。代价是你不再拥有对象的内存地址,只能通过引用间接操作;你也不能手动free,GC什么时候回收、回收哪块,由垃圾回收算法决定,你可以通过调整GC参数"建议"它,但没法命令它。
这种设计带来的实际好处是:内存安全(没有悬垂指针)、开发效率(不用手工释放)、移动性好(对象可以在GC时搬移地址,引用会自动更新)。代价则是:对象创建和回收存在额外开销,GC停顿会影响延迟敏感的应用。理解了这个前提,再看后面JVM堆内存的分代、GC Roots、逃逸分析这些概念,就知道每一处设计都是在"安全"和"性能"之间找平衡。
2. JVM内存模型:每一块地皮到底干什么用
2.1 运行时数据区的整体作战地图
JVM的内存布局规范(JVM Specification里的Runtime Data Area)把内存划分成几块区域,它们各司其职,谁都不能越界。了解这块内容有个很功利的原因:它是面试必问,工作必备。你排查OOM时至少得知道是哪个区域满了,才能对症下药。
整个运行时数据区可以分成两大阵营:线程共享区和线程私有区。
- 线程共享区:堆(Heap)、方法区(Method Area)/元空间(Metaspace)。所有线程都能访问,需要做并发控制。
- 线程私有区:虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、程序计数器(Program Counter Register)。每个线程启动时都会分配一套私有的栈空间,不需要加锁。
堆是"工地现场",所有对象实例和数组都在这里分配;方法区是"图纸库",存放类元信息、常量、静态变量、JIT编译产物;虚拟机栈是"施工人员的手账",记录了每个线程的方法调用链和局部变量;程序计数器是"施工进度条",记录当前线程执行到哪一条字节码指令;本地方法栈是给调用native方法(比如C语言写的底层库)用的另一套手账。
这里有个易混淆点:Java内存模型(JMM)和运行时数据区不是一回事。JMM是一套抽象的内存一致性规范,定义的是多线程下共享变量的可见性、有序性、原子性规则(happens-before、volatile、synchronized的内存语义);运行时数据区才是JVM在物理内存上实实在在划分的区域。面试官问"JVM内存模型"时,通常期望你先答运行时数据区,再展开JMM并发语义。两个都要会,别被网上的叫法带偏。
2.2 栈、堆、方法区之间的配合关系
看一段简单的代码,把三个区域的协作关系串起来。假设有这样一个类:
public class Person { // 实例字段:属于每个对象(堆中),不在类里直接存值 private String name; private int age; // 静态字段:属于类(元空间) static String species = "Homo sapiens"; // 构造方法:字节码储存在元空间的方法表中 public Person(String name, int age) { this.name = name; this.age = age; } // 实例方法:方法体字节码在元空间,调用时压栈执行 public void introduce() { String msg = "I am " + this.name + ", age " + this.age; System.out.println(msg); } }执行Person p = new Person("Alice", 30); p.introduce();时发生了什么:
元空间里已经加载了Person类的元信息:字段列表、方法字节码、常量池、静态变量。
main线程的虚拟机栈里压入一个main帧,帧里有个局部变量p,此时还是null。
执行
new:在堆里分配一块内存,按Person的布局准备好对象头、实例字段(name、age),初始值为零值,然后调用构造方法把"Alice"和30写进字段。栈帧里的p被赋值为堆中对象地址。
调用
p.introduce()时,栈帧里新建一个introduce帧,局部变量msg指向字符串常量(JDK7以后字符串常量在堆中,字符串对象本身是普通对象),方法执行完后introduce帧出栈,msg和this引用随之失效。等到什么时候栈帧里的p不再引用这个Person对象,且堆里没有任何其他引用指向它,这个对象就变成"不可达",成为GC的候选目标。
这个流程里最值得品味的是:方法区的类元信息是所有对象共享的"施工总图",每个对象只存储自己那份实例字段数据,并通过对象头里的类型指针找到自己属于哪个类。这也解释了为什么同一个类new的一万个对象之间互相独立——它们各自的字段值不共享,但方法代码是共用的。
2.3 堆内存的分代模型:为什么要分新生代和老年代
堆不是一个大平层,而是分代的。绝大多数对象的生命周期都很短,比如方法内部临时创建的字符串、循环里的中间对象;只有少数对象活得久,比如Spring容器里的单例Bean。分代收集利用这个"弱分代假设":新生代用复制算法快速清理短命对象,老年代用标记-整理(或标记-清除,取决于收集器)算法处理长时间存活的大对象。
默认的堆划分大致是这样:
| 区域 | 作用 | 默认占比(常见JDK8/11) | 回收方式 |
|---|---|---|---|
| Eden | 大多数新对象首先在这里分配 | 新生代约8:1:1里的Eden占8 | Minor GC,复制存活对象 |
| Survivor S0 / S1 | 存放Minor GC后存活下来的对象,两区轮流交换 | 各占新生代的1 | 复制算法来回倒腾 |
| Old | 经历多次Minor GC(默认15次)仍存活的对象晋升到这里 | 老年代约占堆的2/3 | Major GC / Full GC,标记整理为主 |
为什么使用8:1:1而不是一块大区域挨个标记清除?因为新生代死得快,复制算法的成本远低于标记-清除。每次Minor GC时,把Eden和正在使用的Survivor中存活的对象,一次性复制到另一个空的Survivor区,然后整体清空Eden和旧Survivor。这样避免了内存碎片,代价是有一块Survivor总是空闲的浪费——但研究表明,这个"浪费"换来的是极高的回收效率。
老年代的对象有大有小,用复制算法成本太高(大对象复制太贵),所以大多数收集器在老年代用标记-清除(CMS)或标记-整理(G1的Humongous区域和Mixed GC另说)。这也是为什么你调优时一般先调新生代大小:新生代太小,对象还没活到够就频繁Minor GC;太大,老年代没空间还得Full GC。实践上,RocketMQ、Spring Cloud这类服务,我一般把NewRatio调到1:2到1:4之间再压测。
2.4 逃逸分析:栈上分配真的存在吗
需要泼一盆冷水:教科书上说"对象一定在堆中分配",这句话在HotSpot的逃逸分析面前已经不完全准确了。开启逃逸分析后(JDK8默认开启),如果一个对象只在方法内部使用,没有被方法外部的任何地方"逃逸"出去,JIT编译器可能把它标量替换成多个局部变量,直接在栈帧里分配字段,不真正创建堆对象。
比如这个代码:
public static int sum(int x, int y) { Point p = new Point(x, y); // p可能不会真的出现在堆上 return p.x + p.y; }在C2编译器的眼里,这个Point对象完全可以用x和y两个局部变量替代,于是new Point被"优化掉"了,构造方法内联,字段访问变成直接读局部变量。这就是为什么很多微基准测试里,短命小对象的创建开销几乎可以忽略——因为JIT已经帮你"取消"了对象创建。
但逃逸分析不是银弹:对象被返回、被存入集合、被static引用、被传给别人线程,都会发生逃逸,逃逸了就老老实实去堆上待着。所以写代码时,能用局部变量就别硬塞进成员变量里;能返回基本类型就不必包一层对象。这既是给GC减负,也是给逃逸分析创造机会。
3. new一个对象,JVM内部究竟做了什么
3.1 从字节码到内存分配的完整流程
拆开new Person("Alice", 30)这条语句,JVM执行的操作远比字面复杂。HotSpot里大概有七个关键步骤:
类加载校验:JVM先检查Person类是否已经加载、链接、初始化。如果没有,触发类加载(加载→验证→准备→解析→初始化)。
分配内存:在堆中为对象分配一块连续的内存。具体方法取决于堆是否规整:使用Serial、ParNew这类带压缩整理的收集器时用指针碰撞(内存规整,只需移动指针);使用CMS时用空闲列表(内存不规整,需要找一块足够大的空闲块)。
零值初始化:把这块内存区域置零(基本类型字段为0、boolean为false、引用为null)。这也是为什么你不在构造方法里给字段赋初值,对象也绝不会含有垃圾数据。
设置对象头:对象头里有Mark Word(存储哈希码、GC分代年龄、锁状态标志)、类型指针(指向方法区中的类元数据)。如果是数组,还要记录数组长度。
执行构造方法:调用
<init>方法,按构造函数里定义的字段赋值逻辑,把"Alice"和30写入对象字段。此时一个可以被安全使用的对象才算诞生。返回引用:栈帧中变量p获得对象地址。
注意步骤3和步骤5的区分:零值初始化是"内存清零",构造方法是"业务赋值"。如果构造方法中抛了异常,对象会被视为未完成初始化,引用也不会被返回,堆中这片内存会变成"准垃圾"等待GC。
还有一个并发细节:内存分配不是无竞争的。堆是所有线程共享的,两个线程同时new对象就可能撞车。HotSpot用CAS(比较并交换)加失败重试来保证分配的原子性,或者给每个线程本地分配缓冲区(TLAB,Thread Local Allocation Buffer)。TLAB本质上是Eden区里给每个线程画的一块小领地,线程在自己的TLAB里分配内存不需要锁,TLAB用完或者线程创建/销毁时再向Eden申请新块。肉眼可见的效果:多线程大量new小对象时,开启了TLAB后吞吐量能明显提升。
3.2 指针碰撞与空闲列表:两种分配方式的取舍
这个知识点看着小,却是面试官很喜欢挂在嘴边的"你觉得分配内存要考虑什么"的答案之一。GC后的堆如果空间是规整的(一端是已用对象,另一端是空闲区,中间有个分界指针),那么新对象的内存分配就是把分界指针向空闲方向挪动需要的字节数,这就是指针碰撞(Bump the Pointer)。如果堆内存不规整(已用对象和空闲区交错),JVM就得维护一个列表记录哪些空闲块可用,分配时在列表里找一块足够大的块更新记录,这叫空闲列表(Free List)。
具体用哪种,取决于GC收集器是否带有"压缩整理"功能。Serial、Parallel、ParNew(以及G1的region内部局部是规整的)倾向于指针碰撞;CMS因为采用标记-清除算法会产生碎片,倾向于空闲列表。这也是CMS有个调优痛点:老年代碎片多到一定程度,即使还有空间也可能触发Full GC的Serial Old做压缩。你在生产上用CMS时,往往要配合-XX:CMSFullGCsBeforeCompaction(或新版本的UseCMSCompactAtFullCollection相关参数)定期压缩。
3.3 对象布局:用jol亲眼看一下对象头里有什么
与其纸上谈兵,不如直接上工具看。OpenJDK的JOL(Java Object Layout)可以把对象在内存中的布局打出来。在Maven工程里加依赖:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.16</version> </dependency>然后写一段代码:
import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.vm.VM; public class JOLDemo { public static void main(String[] args) { System.out.println(VM.current().details()); Object obj = new Object(); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }输出大概长这样(具体数值因机器位数和指针压缩状态而异):
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) 4 4 (object header) 00 00 00 00 8 4 (object header) e5 01 00 20这里前8个字节是Mark Word,后4个字节是类型指针(开启了指针压缩-XX:+UseCompressedOops时)。Mark Word里塞了多种信息:最低1位给偏向锁,2位给锁状态标志,还可以存24位的分代年龄、31位的哈希码、指向Monitor的指针等。你调用hashCode()后,Mark Word里就会写入identity hash code;你执行synchronized(obj)后,锁标志位会变化。这也是为什么有人问你"为什么对象头能承载锁状态"——因为Mark Word本身就是个可变的多状态标签,按不同锁级别复用里面的位。
再看一个有字段的类:
public class User { private int id; // 4字节 private String name; // 引用,指针压缩时4字节 private boolean flag; // 1字节 }用JOL打印会发现对象总大小往往不是各字段的简单累加,因为有对齐填充。HotSpot默认要求对象大小是8字节的整数倍,如果你的实例数据凑下来是13字节或17字节,就补几个字节凑整。对于读取有好处:对齐后的对象地址总是8的倍数,CPU和JVM都可以更快定位。但要注意,字段重排可能会打乱你源码里的声明顺序——JVM会按照"相同类型字段放一起、从大到小排列"的规则重新排布实例字段,目的是更好的内存对齐和缓存利用。所以不要依赖字段声明的物理顺序。
3.4 四种创建对象的方式对比
除了new,还有反射、clone、反序列化三条路。它们的内存分配过程和new不太一样:
| 创建方式 | 是否调用构造方法 | 典型场景 | 注意点 |
|---|---|---|---|
| new关键字 | 调用构造方法 | 日常99%的场景 | 编译器在字节码中生成new、dup、invokespecial |
| 反射(Class.newInstance / Constructor.newInstance) | 调用构造方法 | Spring、Jackson等框架 | 新代码优先用Constructor,不用已废弃的newInstance |
| clone() | 不调用构造方法 | 原型模式、对象拷贝 | 需要在类中实现Cloneable,字段浅拷贝,克隆对象的内存布局按源对象复制 |
| 反序列化(ObjectInputStream / 序列化框架) | 不调用构造方法 | 跨进程传输、缓存持久化 | 会创建对象但走的是Unsafe或ObjectStreamClass分配,不执行构造逻辑 |
很多人一听到"反序列化不调用构造方法"会愣一下,但仔细想想就明白:反序列化时对象的字段值是从字节流里还原出来的,如果还调构造方法,构造方法里的重赋值逻辑会破坏还原结果。所以严谨地说,反序列化生成的是一种"绕过构造方法"的特殊对象。这也是为什么有些不可变类(比如许多单例或工具类)要把默认构造方法私有化,防止反序列化破坏约束。
顺嘴提醒一句:如果你用序列化做深拷贝,千万注意static字段不会被序列化、transient字段会被跳过、父类无参构造方法的隐含调用——这些坑我后面在常见问题里再展开。
4. 生命周期与垃圾回收:对象从生到死的必经之路
4.1 GC Roots与可达性分析:谁决定对象活不活
垃圾回收判定对象死亡的经典算法是可达性分析:从一组"根对象"(GC Roots)出发,沿着引用链遍历,能够到达的对象就是活着的,到达不了的就是可回收的。这组根对象包括:
- 虚拟机栈中局部变量表里的引用(正在执行的方法用着的对象)。
- 方法区中静态变量引用的对象(类级别的引用,比如static字段)。
- 方法区中常量引用的对象(字符串常量池里的引用)。
- 本地方法栈中JNI引用的对象。
- Java线程活跃的Thread对象、ThreadLocal里被当前线程持有的Map等。
这个机制解释了一个常见困惑:一个对象没有被任何变量引用,但在FULL GC前它可能还没被回收。因为从GC Roots出发遍历需要时间,JVM不会每new一个对象立刻去扫描全堆。对象死亡是"延迟判定"的,它先变成"被标记为不可达"的候选对象,然后真正被回收可能还要等下一次GC或指定GC触发条件。
需要注意:可达性分析的遍历必须发生在一个一致性的快照里,所以GC时会触发Stop-The-World(STW),暂停所有业务线程。这也是为什么垃圾收集器一直在和STW时间较劲——CMS尝试并发标记、G1把堆分成region逐步回收、ZGC用染色指针做到超低停顿,本质上都是在"缩短暂停时间"和"降低回收成本"之间做取舍。
4.2 引用类型强软弱虚:四个级别的"遗愿"
除了强引用,Java还有SoftReference(软引用)、WeakReference(弱引用)、PhantomReference(虚引用)三个级别的引用类。它们决定了对象在内存压力下的存活优先级:
- 强引用:
new出来的引用。只要强引用还在,GC就不会回收。 - 软引用:
SoftReference包裹的引用,在内存充足时不会回收,快要OOM时才会被回收。适合做内存敏感的缓存(但实际工程里更多用LRU本地缓存,因为软引用的回收时机不可精确控制)。 - 弱引用:
WeakReference包裹的引用,下一次GC时无论内存足不足都会回收。ThreadLocal的ThreadLocalMap就用弱引用存key,就是为了防止Entry泄露。 - 虚引用:
PhantomReference包裹的引用,它唯一的作用是通知"对象被回收了",用于堆外内存的清理跟踪。因为虚引用.get()永远返回null,你没法通过它拿到对象本身。
这四种引用在面试里经常和"内存泄漏"一起考察。比如问"ThreadLocal为什么会内存泄漏",标准答案链条是:ThreadLocalMap里的key是WeakReference,但value是强引用。如果线程长期存活且map里的entry不清理,value会一直被强引用链保持,形成泄漏。正确姿势是每次用完ThreadLocal都调用remove(),别指望GC兜底。
4.3 内存泄漏典型现场:哪些代码在“谋杀”对象
内存泄漏是Java开发者的老朋友,我给你整理几类我踩过的坑:
第一类:static集合无限增长
public static List<Task> taskCache = new ArrayList<>(); public static void addTask(Task task) { taskCache.add(task); // 任务处理完但忘了remove,list一直在涨 }静态集合的生命周期和类一样长,里面所有对象都被GC Roots强引用,永远不会被回收。排查时直接jmap dump堆,看哪个大对象列表占了几百MB,基本一眼锁定。
第二类:ThreadLocal用了不remove
业务线程池里的线程是复用的,ThreadLocalMap又挂在Thread上。如果每次都往ThreadLocal里set一个对象但不remove,下次线程复用时会带着旧值,而且entry堆积无法清理。我见过一个报表服务,一次大查询把几万条用户DTO塞进ThreadLocal,线程池复用几天后直接OOM。
第三类:监听器、回调、观察者注册后未注销
很多人写事件监听时只管addListener,忘了removeListener。注册进去的对象被监听器持有,监听器又被全局管理器持有,整个对象都别想被回收。特别常见于注册中心、消息订阅、GUI组件。补救办法是:能改成弱引用注册就改成弱引用,或者在对象的生命周期结束方法里显式注销。
第四类:内部类(匿名内部类/非静态内部类)持有外部类引用
非静态内部类构造时会隐式持有外部类实例的引用,即使外部类的强引用已经没了,只要内部类对象还活着,外部类对象就不会死。最典型的就是Android开发里Handler持有Activity导致的泄漏。Java服务端虽然不太谈这个,但如果你在一个长生命周期组件里持有短生命周期对象的内部类,同样会复现。所以能设计成静态内部类就别用非静态内部类,静态内部类不自动持有外部类引用。
4.4 用jstat和jmap亲眼观察对象回收
光讲理论不够,最后来点实操。本地起一个Spring Boot应用或一个简单Java进程,用这几个命令观察对象状态:
# 查看进程 jps -l # 每隔1秒打印一次GC信息(young、old等分代回收次数和耗时) jstat -gcutil <pid> 1000 # 输出堆内存快照,留着以后分析 jmap -dump:format=b,file=heap.hprof <pid> # 排查大对象占用,先看直方图 jmap -histo <pid> | head -50jstat -gcutil的输出里有E(Eden)、S0/S1(Survivor)、O(Old)、M(Metaspace)这几个区域的百分比,以及YGC(年轻代GC次数)、YGCT(年轻代GC消耗秒数)、FGC(Full GC次数)、FGCT(Full GC耗时)。如果你看到YGC几万次、FGC十几秒,基本可以先怀疑新生代太小或对象晋升太快。
拿到heap.hprof后,用MAT(Memory Analyzer)或者JProfiler打开。第一步看Leak Suspects,它会把最大的对象、最可疑的引用链列出来。我排查时习惯先看Dominator Tree,按"保留堆大小"排序,那个占了几百MB的实体基本就是泄漏元凶;再看Path to GC Roots,就能发现是谁还攥着它的引用不放。
5. 高频面试题与避坑实践
5.1 面试必考的十个类与对象问题
把面试里围绕这部分最高频的十个问题整理成一个速查表,每个问题附一句我推荐的回答思路:
| 问题 | 回答要点 |
|---|---|
| 类加载过程有哪些阶段? | 加载→验证→准备→解析→初始化,其中准备阶段为static字段分配内存并置零值,初始化阶段执行静态代码块和变量赋值。 |
| 什么是双亲委派模型? | 加载类时先委派给父类加载器,父类找不到才自己加载,目的是避免核心类(如java.lang.Object)被覆写,保证类型一致性。 |
| new一个对象的过程概览? | 类加载校验→分配内存(指针碰撞/空闲列表)→零值初始化→设置对象头→执行构造方法→返回引用。 |
| 对象头里有什么? | Mark Word存hashCode、分代年龄、锁状态标志;类型指针指向元空间的类元数据;数组对象额外存数组长度。 |
| synchronized锁存在哪? | 存储在对象头的Mark Word中,锁升级路径:无锁→偏向锁→轻量级锁→重量级锁。 |
| 为什么要有分代收集? | 不同对象的生命周期差异大,分代让短命对象在新生代用复制算法快速回收,长命对象进入老年代用标记整理,提高效率。 |
| 可达性分析里的GC Roots有哪些? | 栈帧局部变量、静态变量、常量引用、JNI引用、活跃线程等。 |
| 强/软/弱/虚引用怎么用? | 强引用不回收;软引用内存紧张才回收;弱引用下次GC即回收;虚引用只用于跟踪回收,拿不到对象。 |
| 逃逸分析做了什么? | 分析对象是否逃逸出方法/线程,不逃逸则标量替换为局部变量,尝试栈上分配,减少堆分配和GC压力。 |
| 什么情况会导致OOM? | 堆溢出(大量对象无法回收)、栈溢出(无限递归)、元空间溢出(类加载过多)、直接内存溢出(NIO的ByteBuffer未释放)。 |
这十个如果都能讲出"是什么+为什么+实际案例",面试基本能覆盖这个领域的80%追问。
5.2 内存优化实战:我调过的一个慢查询服务
给你讲一个我实际调过的案例,照着这个思路复现一遍就能把上面的知识串起来。
现象:一个报表生成服务,每天凌晨批量跑任务时CPU飙高,老年代GC频繁,单次Full GC耗时3~5秒,任务经常超时。
排查步骤:
先用jstat -gcutil观察,发现FGC次数很高,每次FGCT秒级,老年代被撑到90%以上。说明有大对象或者对象晋升异常。
jmap -histo看直方图,发现一个byte[]组成的ArrayList占了堆内存近40%。进一步看业务代码,原来是报表查询把几万行结果全部塞进内存再逐行格式化,中间产生了大量StringBuilder和临时DTO。
从dump文件看引用链,发现这些大对象都挂在某个静态的"任务上下文"集合里,任务处理完后没有清空。这一条是根因:不释放+静态持有=永久泄漏倾向。
改动方案:
- 查询改造为流式分批处理,每批500条立即格式化输出,不再全量堆积。
- 任务的上下文对象从static集合里显式清理,改成try-finally里remove。
- 调大新生代比例,避免太多小对象快速晋升到老年代。
调完以后FGC次数降了一个量级,单次任务耗时从原来的30分钟压到8分钟。核心思路没别的:少创建无用对象、及时释放长生命周期持有的引用、让短命对象留在新生代完成它短暂的一生。
5.3 常见异常与避坑技巧速查
最后列几个开发中一定会遇到的异常和对应的处理建议:
NullPointerException
原因不外乎:引用为null就调方法;方法返回null没判断;拆箱时包装类为null;数组或List里含有null元素。我最常用的防御是:能不返回null就不返回null(用Optional或空集合),数据边界处做一次显式校验。
ClassCastException
转型异常最经典发生在泛型擦除之后,比如把List
OutOfMemoryError: Java heap space
堆内存不足。先jmap dump看哪个对象占得多,再回头看引用链。如果对象数量和业务量成正比并且量级合理,那就是堆给小了;如果对象数量异常增长且没有释放,那就是泄漏。
StackOverflowError
无限递归、轮询嵌套过深、Json循环引用序列化都可能导致。Json序列化的循环引用一般是对象A里有B、B里有A、序列化时A->B->A->B死循环,解决方式是@JsonIgnore或者@JsonManagedReference/@JsonBackReference。
ConcurrentModificationException
遍历集合时修改集合结构。比如for-each循环里list.remove()。正确做法是使用迭代器Iterator的remove方法,或者收集到待移除列表统一删除。
UnsupportedOperationException
Arrays.asList返回的List是固定长度的,对它调用add/remove方法就会抛这个异常。正确姿势是想可变集合就new ArrayList<>(Arrays.asList(...))。
5.4 一点实战心得
很多教程讲到对象创建就停了,讲到内存模型就画图解释区域划分,但不讲"为什么这么分""出了事怎么查"。我个人觉得,类与对象这块知识,真正拉开差距的是你能不能把这几个层次的知识串成一条线:写代码时——知道new出来的东西在堆里占多大、会不会被GC回收;排查问题时——知道该往栈里找还是往堆里找,看到OOM日志能判断哪个区域满了;架构设计时——知道用什么样的创建方式代价更小、怎么利用不可变对象减少并发风险。
说个我自己的习惯供参考:每次写完一段核心业务逻辑,我都会在心里快速过一遍——这段代码创建了哪些对象、它们的生命周期是长是短、有没有被不合理的强引用长期持有。这个习惯帮我提前干掉过不少内存隐患,比出了问题再拿着MAT分析舒服太多了。
如果你现在正准备面试,建议把今天讲的对象创建流程、GC Roots、四种引用、分代收集这几块画在一张图上,自己对着图复述一遍,能讲顺了,这部分就算真正过关了。