刚入行那会儿,我也把“类是图纸,对象是实物”这句话挂在嘴边。直到有一次面试官反问:“那图纸什么时候被加载进内存?实物是在哪一块区域浇铸出来的?GC又是靠什么判断这件实物该扔了?”我才发现,光有比喻远远不够。这篇就把从“类”到“对象”再到“JVM内存模型”的完整链路拆开讲一遍,覆盖类定义、类加载、对象创建、内存区域划分、GC回收,以及面试里围绕这些概念衍生出来的高频追问。想弄清这些问题的人,不管是准备Java面试、刚学完基础仍对内存心存疑惑,还是想系统梳理对象模型的老手,都能从这里找到能直接用的理解方式。
1. 图纸不是一张,类其实是一个“套餐”
1.1 类里面装着什么:字段、方法、构造器与静态成员
很多人把类理解成“字段 + 方法的集合”,方向没错,但不够完整。一个普通类里其实打包了四类东西:描述状态的实例字段、描述行为的实例方法、负责初始化对象的构造器,还有独立于任何对象的静态成员。
看一个最常见的例子:
public class User { private Long id; private String name; private Address address; public User(Long id, String name) { this.id = id; this.name = name; } public String getDisplayName() { return name == null ? "匿名用户" : name; } public static User createGuest() { return new User(null, "游客"); } }这里id、name、address是实例字段,每个User对象都有自己的副本;getDisplayName()是实例方法,它操作的是具体某个对象的数据;构造器User(Long, String)决定了对象创建时字段怎么初始化;而createGuest()是静态方法,不依赖实例,可以直接通过User.createGuest()调用。
理解这四类成员的区别,是理解内存模型的第一块地基。实例字段要跟着对象走,所以存在堆里;静态成员属于类的,在类初始化阶段就要准备好,留在方法区或元空间;构造器不是普通方法,它在对象分配之后被调用,用来把一块“刚切出来的内存”变成一个有意义的对象。
我在带新人的时候经常问一个问题:静态方法里能不能直接访问实例字段?答案显而易见不能,但很多人只是背结论。真正原因是静态方法没有this引用,它不知道该去操作堆里的哪一个对象。反过来,实例方法里可以访问静态成员,因为实例方法能通过类信息找到静态成员所在的位置。
1.2 抽象类、接口、普通类:三种“图纸规格”的差别
“抽象类和普通类的区别”几乎是Java面试的送分题,但真正能讲明白的人不多。普通类可以实例化,抽象类不能直接new;抽象类可以声明抽象方法,也可以有已经实现的方法和构造器;普通类则没有抽象方法。设计抽象类,本质上是为了把“不变的部分实现掉,把变化的部分留给子类”。
接口在Java 8之后也变复杂了。默认方法让接口有了方法体,静态方法也让接口可以承载工具方法,但接口依然没有实例字段(除了public static final常量),依然不能有构造器。我个人的选择标准很简单:如果只是定义行为契约,用接口;如果需要抽取公共状态和通用逻辑,用抽象类;如果多个实现之间存在大量重复代码,抽象类更合适,否则接口更灵活。
内部类也是高频考点。Java的内部类分四种:静态内部类、成员内部类、局部内部类、匿名内部类。最容易踩坑的是成员内部类会隐式持有外部类实例的引用,这在Android开发或长生命周期组件里是内存泄漏的重灾区。静态内部类不持有外部类引用,所以Builder模式、Entry这类对象都用静态内部类。局部内部类和匿名内部类在方法栈帧里存在,但对象本身还是分配在堆上,方法结束只是引用消失,对象要等GC回收。
画类图的时候,不同关系用不同箭头。用StarUML画透视图时要注意:继承是空心三角加实线,实现是空心三角加虚线,依赖是普通虚线箭头,关联是实线箭头,聚合是空心菱形加箭头,组合是实心菱形加箭头。这几类关系对应到Java代码里,依赖是方法参数、关联是成员变量、聚合和组合是成员变量但生命周期强弱不同,继承和实现则是类声明层面的关系,别把箭头画错。
2. new 一个对象,到底发生了什么
2.1 类加载:把图纸读进内存
很多人以为new User()只是分配一块内存然后跑构造器,实际上在此之前还有一个隐藏环节:类加载。如果User类还没有被加载过,JVM要先把User.class这个字节码文件读进来,经过加载、验证、准备、解析、初始化五个阶段,最终在方法区(HotSpot里是元空间)生成对应的Class对象。
加载阶段通过类全限定名找到字节码文件并读入;验证阶段检查字节码是否符合规范;准备阶段为静态变量分配内存并赋默认值,比如static int count在这时已经是0;解析阶段把常量池里的符号引用替换成直接引用。初始化阶段才执行静态代码块和静态变量赋值,也就是<clinit>方法。
这里有个常见的错误认知:“Java是静态链接的”。Java其实是典型的动态链接语言,符号引用在运行时解析,类按需加载,甚至可以延迟到真正使用的时候。所以“全部类在启动时就链接完”的说法对Java不成立,这也是Java容器能实现热部署、动态替换类的基础。
面试里讲到类加载,能脱口而出“加载-验证-准备-解析-初始化”是基本分,能说出准备阶段和初始化阶段分别做了什么事,才是加分项。
2.2 字节码视角:new、dup、invokespecial 的三步舞
写一行代码User user = new User(1L, "张三");,表面上是一句话,字节码层面其实是四步:
// 伪字节码示意 new User // 1. 在堆上分配空间,对象处于半初始化状态 dup // 2. 复制一个引用,供后续构造器使用 invokespecial User.<init> // 3. 调用构造器完成初始化 astore_1 // 4. 把引用存入局部变量表关键在第一步和第三步之间。new之后、invokespecial之前,对象已经被分配好,但字段还是默认值,构造器还没跑完。如果此时有别的线程拿到了这个半初始化对象的引用,就会看到一个“不完整”的对象。这也是双重检查锁单例为什么需要volatile修饰实例字段的原因:防止构造器中的指令重排序导致另一个线程读到半初始化对象。
再往下挖一层,构造器里调用可以被重写的方法是个隐蔽陷阱。因为Java是动态分派的,如果父类构造器里调用了子类重写的方法,而子类自己的字段还没初始化,就会出现拿到null或者默认值的情况。我当年在一个报表系统里就踩过:基类构造器里调用了buildTitle(),子类重写后依赖自己的titleMap字段,结果titleMap恒为null。这种问题不查半天根本反应不过来。
2.3 对象在堆里的长相
一个Java对象在堆内存里不是光秃秃的数据,它的布局由三部分组成:对象头、实例数据、对齐填充。
对象头最重要,HotSpot虚拟机里通常包含两部分:Mark Word和Klass Pointer。Mark Word存储对象的哈希码、GC分代年龄、锁状态等信息,高并发下加锁优化的状态就记录在这里。Klass Pointer指向类元数据,告诉JVM这个对象到底是什么类型。如果对象是数组,对象头里还会多一个数组长度字段。
实例数据就是你在类里声明的字段。字段排列顺序受虚拟机的“分配策略”影响,HotSpot会尽量按从大到小的顺序排列,把引用类型排在一起。为了减少内存碎片,对象的整体大小要是8字节的整数倍,不够就补齐,这就是对齐填充。64位虚拟机开了压缩指针之后,类指针从8字节压缩到4字节,能省不少内存。
还有个反直觉的点:对象不一定总在堆上。现代JVM在做逃逸分析之后,如果对象只在当前方法内部使用、没有逃逸出线程,可能做栈上分配甚至标量替换,也就是直接把对象的字段拆成局部变量。这就是为什么某些性能测试下,创建大量小对象的代码也没有想象中那么慢。
3. JVM内存模型:对象住在哪、怎么搬家
3.1 运行时数据区:一张内存地图
聊JVM内存模型的时候,首先要区分两个概念:一个是JVM运行时数据区,解决“对象分配在哪”;另一个是Java内存模型(Java Memory Model,JMM),解决“多线程下变量修改何时可见”。很多面试者把这两个混在一起答,一开口就崩了。这里讲的是前者。
运行时数据区可以画成一张表:
| 区域 | 线程私有还是共享 | 存什么 | 异常表现 | 常见参数 |
|---|---|---|---|---|
| 程序计数器 | 私有 | 当前线程执行的字节码行号 | 无 | 无 |
| 虚拟机栈 | 私有 | 栈帧,存局部变量表、操作数栈、方法返回地址 | StackOverflowError | -Xss |
| 本地方法栈 | 私有 | 本地方法调用 | StackOverflowError | -Xss |
| Java堆 | 共享 | 对象实例、数组 | OutOfMemoryError: Java heap space | -Xms、-Xmx |
| 方法区/元空间 | 共享 | 类元信息、静态变量、常量池 | OutOfMemoryError: Metaspace | -XX:MaxMetaspaceSize |
程序计数器是唯一不会OOM的区域,因为每条线程只需要一个很小的行号记录。虚拟机栈和本地方法栈溢出通常表现为无限递归,栈帧一层层压下去,最后StackOverflowError。堆是对象的主要居住地,new User()出来的对象就落在这里。元空间在JDK 8之后取代了永久代,存的是类元信息,字符串常量池被挪到了堆里。
3.2 堆内分代:对象的一辈子
堆不能是一锅粥,否则每次GC都要扫描全堆。HotSpot把堆分成新生代和老年代,新生代内部又拆成Eden区和两个Survivor区(S0、S1),默认比例大致是8:1:1。
新对象通常出生在Eden区。Minor GC发生时,Eden区存活的、以及Survivor区里还活着的对象,会被拷贝到一个空的Survivor区,然后清空Eden和另一个Survivor。每经历一次Minor GC还活着,对象的年龄就加一,达到阈值(默认15)就晋升到老年代。大对象(比如特别长的数组、大量字符串)会直接进老年代,避免在新生代来回拷贝。
判断对象能不能回收,靠的是可达性分析,不是引用计数。引用计数解决不了循环引用问题:A引用B、B引用A,谁都不再被别人引用时,引用计数还是1,永远清不掉。可达性分析从GC Roots出发往下遍历,能到达的对象就是存活的,到达不了的就标记为可回收。
GC Roots包括:虚拟机栈里的局部变量引用、静态变量引用、JNI引用、常量池里的引用等。这也是为什么我们总说,栈帧里的局部变量不再使用后,即使还有引用在位,只要把它置为null,对象就可能提前被回收。
引用类型也分强弱:强引用只要还在,GC就不可能回收;软引用在内存不足时才回收,适合做缓存;弱引用遇到GC就回收,适合做WeakHashMap这类短生命周期关联;虚引用主要用来跟踪对象被回收的状态。面试时被问“如何判断对象为空、如何避免内存泄漏”,本质就是在考SoftReference、WeakReference的设计动机。
3.3 “判断对象为空”到底在判断什么
热搜词里“判断对象为空”出现频率很高,但很多人没想清楚:判断空分两种,一种是引用为null,一种是对象存在但内部没有内容。
Java里判断引用为null,直接obj == null就行;有人为了优雅用Objects.isNull(obj),本质一样。判断字符串空,用str == null || str.isEmpty(),JDK 11之后用isBlank()还能把全空白的也识别出来。判断集合空,用CollectionUtils.isEmpty(list),它会先判引用是否为null再判size()==0。判断Map空同理。
真正高级的做法是引入“空对象模式”,用Collections.emptyList()或自定义一个NullUser对象,避免到处返回null,把分支判断转移到类型系统里。这个思路在很多框架的默认实现里很常见。前端同学常说的“判断是不是空对象 JS”,和Java的哲学不一样:JS用Object.keys(obj).length === 0,它判断的是对象本身没有可枚举属性。对比着看,Java的判空更强调“引用是否存在”和“容器是否有内容”两个维度的分离。
4. 面试高频题:从类到对象的实战拷问
4.1 “new 一个对象的过程”怎么答才完整
这道题在Java面试题里出现频率极高,答案有层次。最基本的一条线是类加载:如果类还没被加载,先加载并初始化类,执行<clinit>。第二条线是内存分配:在堆的Eden区划出一块空间,大小由对象布局决定,对象头、字段、对齐填充都算进去。第三条线是构造器:调用<init>方法,执行字段默认初始化、显式初始化、构造代码块和构造器里的代码。
能再说一层就更好了。比如提到TLAB(Thread Local Allocation Buffer),每个线程在Eden区有自己的一块私有分配区域,避免多线程分配时的锁竞争;提到指针压缩,64位下如何省内存;提到逃逸分析,对象不一定在堆上。最后一层收在“半初始化对象”上,用synchronized双重检查锁和volatile为什么配合使用来证明你真的理解了这个过程。
4.2 工具类与基础API中容易翻车的细节
StringTokenizer老牌工具类,很多人已经不用了,但它还常出现在笔试题里。它和split的差别是:StringTokenizer按字符逐个分割,性能上更原始,但它不是正则,不会处理转义;split接收的是正则表达式,功能强得多。用于简单固定分隔符场景,比如CSV按逗号拆分,StringTokenizer依然有存在空间。
Math类设计成不能被实例化,因为所有方法都是静态的。Math.abs、Math.max、Math.sqrt这类纯函数不依赖状态,设计成静态方法合理。同样道理的还有Collections、Objects这些工具类,构造函数都是private,这是“工具类应该不能实例化”的设计约束。
很多新手看到编译报错“表达式必须包含类类型”直接懵。其实这个错误多半是把类名当实例用:比如你写String.length(),编译器会告诉你“表达式必须包含类类型”,因为length()是实例方法,应该用某个String对象去调。反过来String.valueOf(1)没问题,因为valueOf是静态方法。区分“类名点号调用”和“对象名点号调用”,是做Java基础题必须过的一关。
对象数组去重也是一道经典题。[user1, user2, user3]去重,如果只是去重复引用,用LinkedHashSet就够了;如果是按业务字段去重,比如按userId去重,就必须重写equals()和hashCode(),否则两个字段值相同的对象在Set眼里还是不同的。这里隐藏一个硬约束:重写equals就一定要重写hashCode,否则HashMap、HashSet这些散列容器会出现逻辑错误。排序同理,用Comparator.comparing(User::getAge),不要自己手写一堆if-else比较。冒泡排序作为算法题怎么写都能过,但工程里对象排序永远优先用工具方法。
4.3 其他语言里的“对象”,能反衬Java的设计
光看Java理解不透对象,多看点别的语言有奇效。热搜词里“js中函数是对象吗”这个问题特别说明问题。在JavaScript里,函数是Function类型的实例,可以赋值给变量、当参数传递,也可以携带属性,所以才有高阶函数这种玩法。Java不把方法当对象,但用函数式接口Function、Predicate配合Lambda来模拟类似能力,本质上是在用“接口对象”包装行为。
Python走得更远,一切皆对象,连类本身都是type类创建的实例,所以才会有元类、装饰器这些东西。PHP里“接口数组对象”的说法很形象,数组和对象经常相互转换,灵活性高但类型安全弱。Django里的Model.objects.get(id=1).delete()也很有意思,它在ORM层把“查询对象”和“删除操作”封装在一起,底层还是SQL,但上层让你操作的是对象关系映射后的Python对象。前端框架uni-app的Vue 3里,ref号称万能对象,把基本类型包装成响应式对象,底层是Proxy代理;jQuery里$("input[name='xxx']")拿到的也是jQuery对象,是对原生DOM的封装。看完这些再回头看Java的“引用即句柄”,回过头来就懂为什么a == b比的是引用而不是数据本身了。
5. 从对象模型到真实项目:建模、映射与权限
5.1 UML类图:把对象关系画明白
对象之间的关系如果画不清楚,写的代码大概率也是糊涂的。我实践下来的判断顺序是:先看方法里有没有用到别的类,有就是依赖;再看类里有没有持有别的类作为成员变量,有就是关联;如果成员变量的生命周期和整体强绑定,比如Order包含OrderItem且Order删除后OrderItem没有单独存在意义,就是组合,实心菱形;如果整体消失后部分还能独立存在,比如Team和Member,就是聚合,空心菱形;最后再看继承和实现,那是抽象层次的约束。
用StarUML画类图时,先画类框,再从上往下加字段和方法,然后拖箭头连接关系。画聚合和组合时经常搞反,核心判断就是一句话:“部分能不能脱离整体存活”。能存活是聚合,不能存活是组合。这个点在工作里能帮忙选出正确的对象模型,比如一个商城系统里,商品分类删掉了,分类下的商品不能跟着没,所以分类和商品之间是聚合;订单删了,订单明细就没有意义,所以订单和明细之间是组合。
5.2 多商户跨境商城里的对象设计
热搜词里出现“spring boot + mybatis 的 java 开源多商户跨境商城源码下载”,说明很多人想通过现成项目学对象建模。我的建议是源码可以下,但重点看两件事:实体关系模型和行级权限。
多商户系统里,租户隔离是靠对象模型里的“租户字段”完成的。商户、商品、订单全部带tenant_id,查询时MyBatis拦截器自动在SQL后拼上AND tenant_id = ?,而不是每个业务代码手动写,这样才不容易漏。这个设计在百度、阿里内部都是通用做法,面试时能把这个讲出来,含金量直接拉满。
对象映射到数据库表是另一个大话题。Java对象里有BigDecimal,MySQL里就要对应DECIMAL;Java的LocalDateTime对应DATETIME;枚举字段可以用EnumType.STRING还是ORDINAL都要显式决定。别用浮点型存金额,这是所有电商项目的铁律。热搜词里还有“阿里json.parsearray转换对象有两万行扛得住吗”,这其实是对象批量解析的性能问题。JSON.parseArray把两万行JSON转成两万个Java对象,每个对象又带着字符串和嵌套对象,内存占用可能轻松到几十MB甚至上百MB。处理这种事我一般先算估算内存,大列表拆成分批处理,能只解析必要字段就只映射必要字段,别一口气全塞进内存。
5.3 警惕对象设计里的内存黑洞
对象设计得好不好,直接反映在内存占用上。最经典的问题是内部类隐式持外引用。一个匿名内部类被放入静态集合,等于把外部Activity或Service实例也一并存活,这是Android内存泄漏的经典套路。换个思路,能用静态内部类就尽量用静态内部类,这样它不会对外部实例“拉帮结派”。
大字符串、大数组最容易吃爆堆。比如日志系统里把一个超大请求体转成String再转成JSON对象,瞬间冒出一个几十MB的大对象,还会直接进老年代,加剧Full GC。我处理过的一个生产事故,就是对象里嵌套了一个List<String>,某个用户的数据把列表撑到几十万条,接口直接OOM。后来改成从库里分页拿出来,在内存里只保留摘要对象,问题才消失。
6. 常见问题与排查技巧实录
6.1 三个溢出异常怎么看
先说StackOverflowError。多半是无限递归,调用栈一层层弹上去,栈帧压爆了。排查时先看报错栈顶的重复类名,基本就是那几行代码。单位时间内的递归、正则回溯、循环方法调用都可能是诱因。用-Xss调大栈空间只适合临时缓解,准确做法是找递归边界。
再说OutOfMemoryError: Java heap space。堆满了,对象只进不出。第一步用jmap -dump:format=b,file=heap.hprof <pid>导堆转储,然后分析对象分布,找出谁的实例数异常,再看它被什么强引用连在GC Roots上。排查过一次Gateway服务OOM,最后发现是内存缓存做成了强引用Map,数据越积越多,换成带过期淘汰的缓存容器就消停了。
元空间溢出在动态生成类的场景里很常见,比如大量用字节码框架动态代理、JSP编译、反射频繁生成新类。此时看响应报错提示和类加载器数量,必要时限制-XX:MaxMetaspaceSize,但根治办法是少生成无意义的类。
6.2 对象操作翻车现场
用==比较对象是最常见的低错。两个字符串内容一样,但引用不同,==为false。Integer也有缓存陷阱:-128到127之间缓存比较相等,超出范围就是两个不同对象。网上对“为什么阿里巴巴编程规约强制用equals比较”的讨论够多了,我不重复,只说教训:我见过真有人拿BigDecimal的==比金额,线上少算一分钱都算不完账。BigDecimal必须用compareTo,因为它还区分0.00和0的保留精度。
HashMap里对象字段改了也要注意。HashMap用hashCode()定位桶,如果对象放进Map后,参与hashCode的字段变了,它的哈希值也跟着变,再get就找不到原来的值了。解决方法是放进集合的对象尽量设计成不可变,业务上要改就走“重新创建对象再放进去”的路线。
翻车率较高的还有JSON反序列化。我遇到过JSON.parseObject把客户传的"0.10"按Float解析,结果精度损失,后面计算全部飘掉。DTO字段的类型必须和上游数据约定好,涉及金额、百分比的一律用BigDecimal或String接收再转换,不能图省事。
6.3 高频面试题速查表
| 面试题 | 核心回答要点 | 加分项 |
|---|---|---|
| 类与对象的关系 | 类是抽象定义,对象是具体实例 | 用内存模型说明:类元信息在元空间,对象在堆 |
| new一个对象的过程 | 类加载、分配内存、执行构造器 | 提到TLAB、半初始化对象、逃逸分析 |
| 抽象类和接口的区别 | 抽象类有构造器、可有状态、单继承;接口多实现、行为契约 | Java 8默认方法与静态方法 |
| 静态方法能否调实例方法 | 不能,因为没有this引用 | 从字节码角度解释局部变量表 |
| JVM内存模型 | 运行时数据区五大块,堆栈方法区职责 | 区分JVM内存模型与Java内存模型 |
| 对象什么时候可回收 | 可达性分析,不可达才回收 | GC Roots是什么、引用类型分类 |
| 如何判断对象为空 | 引用为null与对象内容为空分开判断 | 空对象模式、Optional的使用边界 |
| hashCode和equals为什么必须一起重写 | 散列容器先比较hashCode再equals | 举例HashMap存取失效 |
6.4 内存模型优化中性价比最高的几件事
和“gc+java内存模型优化”相关的文章很多,但我见过的多数建议空泛。按我个人的经验,性价比排序大致是这样:第一,避免大对象进老年代,比如超长字符串、大数组、超大List;第二,谨慎持有静态集合,静态Map里只放弱引用或设置容量上限;第三,批量数据处理分页,不要一次把数据库结果全部映射成对象;第四,合理设置堆大小,-Xms和-Xmx设一样避免动态扩容抖动,但不要盲目给大;第五,减少不必要的对象创建,字符串拼接用StringBuilder,能复用对象就复用。这些动作不用改多少代码,效果却比调一个什么参数都明显。
排查内存问题时,我的日常起手式是jps找到进程,jstat -gcutil <pid> 1000看GC曲线,jstack看线程状态,再决定要不要上jmap导出堆分析。现在有Arthas这类工具更方便,可以线上直接dashboard、thread、heapdump,省去很多重启的麻烦。
我个人在这些年踩过最深的一次坑,是核心结算服务里对象建模阶段没有区分“值对象”和“引用对象”,把可变对象当Map的Key用,改了一个字段后整个缓存命中率掉到谷底。从那以后我养成了画内存分布图的习惯,每写一个复杂的类结构,先在草稿纸上把“谁引用谁”标出来。类与对象、内存模型这些知识,看起来是面试八股,但项目一旦动起来,它们就是排查问题的直觉来源。你真正看懂了一个对象从图纸变成实物的全部路径,很多线上诡异问题,都不再是运气题。