ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java对象创建与内存回收全解析:从new到GC Roots的完整链路

Java对象创建与内存回收全解析:从new到GC Roots的完整链路 做Java开发这些年有个话题几乎每次面试都会被问到日常写代码更是天天绕不开——对象的创建方式和内存回收机制。很多朋友能把“new一个对象”挂在嘴边但真被问到“除了new还有哪些方式能创建对象”“JVM到底怎么判断一个对象该被回收”“为什么老年代老是爆掉”的时候反而越讲越含糊。这篇文章把我自己整理的一套思路完整写出来既适合准备java面试题的朋友当深度解读看也适合刚入门、想从底层理解Java对象生命周期的开发者慢慢读。内容不绕弯子直接按创建、存储、回收、调优、排查这条主线走看完你能形成一条完整的知识链而不是零散背几个八股文考点。1. 对象创建你以为的new只是冰山一角1.1 五种常见创建方式你答全了几个我面试别人的时候第一问通常就是“一个Java对象有哪些创建方式”。大部分人能说出来new、反射少数人能提到clone和反序列化。实际上日常开发中常见的创建方式至少有五种我列个表给你对比着看顺便把关键特征写清楚创建方式是否调用构造方法是否需要类信息典型使用场景new关键字调用构造方法编译期已知类型日常编码90%的场景Class.newInstance()调用无参构造方法运行期类名JDK9之后已废弃框架老代码里常见Constructor.newInstance()调用任意构造方法运行期类名参数Spring反射创建Bean的核心逻辑clone()不调用构造方法已存在对象原型模式、对象拷贝反序列化不调用构造方法Serializable/ExternalizableRPC传输、缓存持久化恢复这几种方式里最容易被忽略的坑是构造方法有没有被调用。很多人觉得“创建对象”和“调用构造方法”是一回事实际操作中clone和反序列化都是绕过构造方法创建对象的。这意味着什么意味着这个对象创建出来之后它的实例变量是没有经过构造函数初始化逻辑的完全依赖JVM直接分配内存然后把字段值填进去。如果你在一个类里用构造方法给某些字段赋了默认值走clone和反序列化这条路的时候这些默认值是不会生效的这个特性在面试聊到“深浅拷贝”和“安全编码”时非常加分。1.2 new创建从字节码到对象落地new是最直接的创建方式但它在JVM里干了三件事第一在堆内存中分配一块连续空间第二给实例字段赋予零值不是默认值而是int0、引用null这种零值第三执行构造方法。很多人会忽略第二步和第三步之间的顺序实际上JVM先完成零值初始化再执行构造方法这样即使构造方法处理到一半抛异常也不会因为字段是随机内存垃圾而导致后续问题。从字节码角度去看new关键字对应的是new指令、dup指令和invokespecial init这三条。第一步分配内存、压入对象引用第二步复制引用给后续调用使用第三步执行构造函数。这里有个细节值得注意invokespecial是在编译期就确定好的符号引用所以new创建对象的方式在性能上比反射要快很多因为在JIT热点优化时new往往可以被做成无锁的指针碰撞分配。我在实际开发中有一条经验能用new完成的对象创建不要为了所谓“灵活性”去换成反射。反射带来的动态性是有代价的后面会详细说但至少先建立这个意识new不是“不高级”它是JVM优化最充分、最可控的创建路径。1.3 反射创建Class.forName和newInstance的坑反射创建对象的常见写法是Class.forName(com.example.User)拿到Class对象再调用newInstance()。这个写法在JDK9以后其实已经被标记废弃了原因是它只调用无参构造方法并且把构造方法抛出的受检异常统统包装成InvocationTargetException异常信息变得很难追。官方推荐用clazz.getDeclaredConstructor().newInstance()替代。用反射创建对象有几个细节必须注意forName默认会执行静态代码块和静态变量初始化因为它的本质是“加载类并完成类初始化”。有些框架利用这个特性做插件注册但对普通业务代码来说这个特性容易引发意外的静态逻辑执行。getDeclaredConstructor拿到的是任意访问权限的构造方法但构造方法可能是private的需要先调用setAccessible(true)才能创建实例。反射创建的实例不会参与JIT的某些内联优化所以在高频创建对象的循环里反射性能可以比new慢几十倍。Spring容器创建Bean时大量使用反射但这些Bean通常是单例的创建一次之后都在管理容器里复用所以性能影响不大。我在给团队做代码排查时见过一个真实案例某个定时任务在for循环里用反射创建了上万个小对象接口RT从50ms一路涨到1.2秒。后来把反射创建改成缓存构造器或用LambdaMetafactory做代理性能才拉回来。所以反射不是不能用而是要看使用频率和使用场景。1.4 clone的本质浅拷贝就是“复制引用”clone方法是一个让无数人踩坑的东西。首先一个类不实现Cloneable接口就调用clone()会抛CloneNotSupportedException。这个接口本身没有方法它更像一个标记告诉JVM“这个类允许我按字段复制”。clone方法在Object里是protected的子类如果要对外提供克隆能力得重写并扩大访问权限。关键点在于Object.clone()实现的复制是浅拷贝对于基本类型字段会复制值对于引用类型字段只会复制引用地址也就是说克隆出来的对象和原对象共享同一个引用字段实例。实际开发中很多面试题问“java对象深度拷贝怎么做”就是从这里引出来的。如果你要对一个包含List、Map这种复杂结构的对象做真正的深拷贝光靠clone是不够的。我常用的三种深拷贝思路后面在问题排查章节会展开讲这里先记住一个结论clone默认浅拷贝深拷贝要么自己写递归复制逻辑要么用序列化/JSON序列化的方案但序列化会有性能损耗。1.5 反序列化创建不经过构造函数的“入侵者”反序列化创建对象也是一个高频考点。ObjectInputStream.readObject()在底层会通过反射或者JVM内部机制直接分配内存创建对象整个过程完全不调用任何构造函数。这个特性有两面性。好的一面是它让RPC框架可以跨网络恢复对象状态坏的一面是如果不做任何校验恶意构造的字节流可以在反序列化时触发危险对象的构造链这就聊到了Java反序列化漏洞。所以现在主流做法是在ObjectInputStream上重写resolveClass做反序列化白名单校验或者直接用fastjson2、Jackson这类带类型校验的序列化框架。另外补充一个冷门知识使用Unsafe.allocateInstance也可以绕过构造函数创建对象连构造方法都不需要。这在一些框架里被用来做对象预分配但普通业务代码别碰Unsafe不是给应用层用的工具高版本JDK直接限制反射访问。1.6 创建方式对比面试回答的正确姿势把这几种方式放在一起对比后面试问答就会轻松很多。面试官如果再问“Java有哪些创建对象的方式”你不仅要说答案还要补上层次感先说最普通的new强调它调用构造方法是编译期可确定类型的最优解。再说反射创建重点说Class.newInstance的局限性和Constructor.newInstance的区别顺带说反射在Spring等框架中的应用。然后说clone区分浅拷贝和深拷贝指出这个坑。最后说反序列化强调构造函数不会执行以及它带来的安全风险。如果面试官是技术大牛还可以抛出Unsafe.allocateInstance这个彩蛋。这样回答的深度会明显高于背答案的候选人因为你展示的是一条完整的技术脉络而不是几个孤立的知识点。2. 对象到底住在哪儿JVM内存结构与对象布局2.1 堆、栈、方法区的分工不能搞混对象创建之后放在哪里是理解内存回收机制的前提。JVM运行时数据区里堆内存是对象的“主营地”几乎所有对象实例和数组都在这里分配。虚拟机栈是方法执行的“工作台”栈帧里存了局部变量表、操作数栈、动态链接和方法出口局部变量表里存的是引用类型变量指向堆里的对象。方法区在JDK8之后被元空间替代存放类元信息、常量、静态变量等。我经常用一个生活化类比来解释堆就像一个大仓库对象是仓库里的货物栈是工位工位上贴着便签写的是“某件货物在某排某列”。便签本身不装货物但通过便签能找到货物。方法区则是仓库的管理台账记录着这个仓库有哪些货架规格类信息。GC清理的是仓库里的货物而不是工位上的便签。这里有个容易混淆的点new出来的对象本身一定在堆上吗不一定。在开启逃逸分析时JIT有可能把不会逃逸出方法作用域的小对象分配到栈上方法结束就随栈帧销毁省去GC压力。这是JVM的激进优化手段代码层面看不出区别。2.2 对象在堆内存的三段式布局一个Java对象在堆内存中并不是只有实例数据那么单纯它由三部分组成对象头、实例数据、对齐填充。对象头存储Mark Word哈希码、GC分代年龄、锁状态标记等还有类型指针指向类的元数据告诉JVM这个对象是什么类型。数组对象还会多一个数组长度字段。实例数据真正的业务字段值根据不同字段类型按对齐规则排列。对齐填充HotSpot要求对象大小按8字节对齐不够就填充占位符。对象头里最值得玩味的是Mark Word。它是一块很小的内存但会根据对象状态复用存储无锁状态存哈希码和分代年龄轻量级锁状态存指向栈中锁记录的指针重量级锁状态存指向监视器锁的指针。这也是为什么我们常说的“synchronized锁升级优化”在内存层面是有根据的锁信息本身就是存在对象头里的。2.3 对象分配从TLAB到老年代的路径对象在堆里不是随便乱放的。绝大多数对象优先在Eden区分配Eden是年轻代的一部分。之所以叫“优先”是因为JVM为了减少并发竞争每个线程在Eden区都划分了一块私有缓冲区叫做TLAB。线程创建对象时先在自己这块TLAB里分配满了才去Eden区公共区域竞争这样大部分情况下连锁都不用加。-XX:UseTLAB默认是开启的如果你想观察TLAB对分配性能的影响可以用-XX:PrintTLAB打印相关日志。当Eden区空间不足时触发Minor GC存活对象移到Survivor区经过多次GC仍然存活的对象会依据年龄晋升到老年代。大对象不按年龄晋升而是直接进老年代可以通过-XX:PretenureSizeThreshold设置阈值。理解这条路径有助于你看GC日志。每次Minor GC日志里的from、to、eden这些空间变化本质就是对象沿着“Eden - Survivor - Old”这条流水线移动。这条流水线就是分代回收策略的物理基础。2.4 判断对象为空的NPE陷阱这里顺带说一个和对象创建强相关的日常高频问题判断对象为空。很多空指针其实就出在对象创建之后没有判空就使用。Java原生的判空写法是if (obj null)但从JDK8开始有Objects.isNull和Objects.nonNullJDK9又加入了Objects.requireNonNullElse。开发中真正容易出问题的不是单对象判空而是集合元素判空。比如从Map里取值然后直接使用或者从数组中取对象后不判空。现在很多同学也喜欢用Optional链式操作但Optional不要在字段类型上滥用它更适合作为方法返回值表达“可能没有值”的结果。对象创建和判空其实是同一件事的两个阶段创建可能成功返回null比如反序列化失败也可能被缓存机制命中返回特殊值。养成“对象使用前问一句是否可能为null”的习惯能避免大量线上告警。对这个话题感兴趣的朋友可以重点嚼一嚼Optional 对象操作的高频面试场景很多八股文里其实讲得不够深。3. 内存回收JVM怎么判定一个对象是“垃圾”3.1 引用计数法为什么会被主流JVM抛弃判定垃圾的标准方法有两个引用计数法和可达性分析。引用计数法思路很直观——给每个对象挂一个引用计数器被引用一次就加一失效就减一归零了就可以回收。实现简单执行效率高。但有一个致命缺陷无法解决循环引用。我给你举个实际场景对象A持有对象B的引用对象B持有对象A的引用除此之外这两个对象已经没有外部引用了。此时A和B的引用计数都是1永远不会归零但它们其实已经是不可达的垃圾了。这个场景在双向链表、父子节点等数据结构中非常常见。所以HotSpot等主流JVM都没有用引用计数法来管理堆内存。顺带说一句Python用的是引用计数为主、标记清除为辅的策略与Java不同这是不同语言对内存管理策略的选择差异。如果你带着Java的知识去看Python的GC会有很多有趣的对照。3.2 可达性分析从GC Roots出发的“指路法”HotSpot采用可达性分析算法。思路是选一组“根对象”作为起点沿着引用链往下走能走到的对象就是“活着的”走不到的对象标记为可回收。这些根对象称为GC Roots主要包括虚拟机栈中引用的对象正在执行的局部变量方法区中静态属性引用的对象static变量指向的对象方法区中常量引用的对象本地方法栈中JNI引用的对象被synchronized持有的锁对象Java虚拟机内部的系统类、Class对象等我用一个比喻来理解整个对象关系网就像一张蜘蛛网GC Roots是蜘蛛的脚JVM从每个“脚”出发沿着网线爬。爬到的地方就是还连着蜘蛛的活节点爬不到但曾经存在过的丝线就是可以剪掉的废网。这种分析的执行地点通常叫“安全点”只有程序到达安全点GC才能开始枚举根节点这也是为什么GC日志里经常出现趋向于安全点等待的时间开销。3.3 四种引用类型不只是强引用能引对象可达性分析的强弱还取决于引用类型。Java提供了四种引用引用类型回收时机典型使用场景强引用永不回收可达就存活普通new出来的对象软引用内存溢出之前回收内存敏感的缓存图片缓存弱引用下次GC时回收ThreadLocal的Key引用、WeakHashMap虚引用随时可能回收无法通过它获取对象对象回收跟踪、DirectByteBuffer清理强引用就是最常见的Object obj new Object()这种只要引用链还在GC就不会碰它。软引用适合做“能留则留、留不下就删”的缓存但有个坑在内存充裕的时候软引用对象也可能会长期占用内存需要配合引用队列二次清理。弱引用是被GC“见一次收一次”的态度WeakHashMap在缓存key时非常好用但要注意value可能通过强引用链反拽key造成意想不到的问题。虚引用最特殊它get()永远返回null存在的意义就是让你在对象被回收时收到一个通知。NIO的DirectByteBuffer就是通过虚引用和Cleaner机制来回收堆外内存的这部分设计得比较深面试能被问到“堆外内存怎么回收”时再用这个知识点救场效果很好。3.4 finalize方法一个不建议依赖的“临终回调”finalize是Object里的受保护方法设计意图是在对象被回收前执行一些清理工作。但这套机制在现代JVM里早就不推荐用了而且已经被Java 9标记为废弃。原因有三个第一finalize的执行时机不确定不能保证在对象“去世”前一定执行第二它会让对象逃脱一次垃圾回收——如果finalize代码里重新让this被引用这个对象就“复活”了这种现象叫“自我拯救”第三finalize的执行性能极差会拖累整个GC流程。我在实际项目里几乎从不用finalize资源清理统一走try-with-resources或工具类的close方法。如果你在旧代码里看到finalize早点改成显式清理就好不要指望JVM帮你兜底。4. 从新生到年老分代回收的完整流程4.1 为什么JVM要搞分代回收很多人面对GC算法时会被标记-清除、标记-复制、标记-整理绕晕但理解了分代思想之后就清楚多了。分代回收的核心依据是“弱分代假说”绝大多数对象都是朝生夕死的存活得越久越可能继续活更久。JVM把堆按代划分后就可以对不同代采用不同的算法。年轻代因为对象存活率低用标记-复制算法复制没有标记到的对象到另一块Survivor区同时把存活对象整理到连续空间代价小效率高。老年代对象存活率高复制成本太大就采用标记-清除或标记-整理算法前者有空间碎片问题后者还要移动对象。一个常考的面试细节是为什么Eden和两个Survivor的比例默认是8:1:1。因为复制算法需要留一块空Survivor作为复制目的地如果Eden太大Minor GC时有大量对象需要复制如果Survivor太小存活对象放不下就会触发分配担保进入老年代。所以HotSpot默认的8:1:1是权衡后的经验值。4.2 Minor GC年轻代的自救大会Minor GC只回收年轻代部分收集器也回收Eden和一个Survivor触发条件是Eden空间不足。新对象创建得越快Eden消耗得越快Minor GC频率就越高。Minor GC过程大体是这样检查Eden和From Survivor中存活的对象把存活对象按年龄复制到To Survivor。每经历一次Minor GC存活对象年龄加一。当对象年龄超过-XX:MaxTenuringThreshold默认15或者To Survivor装不下了就直接晋升到老年代。清空Eden和From Survivor然后交换From和To。这里有个动态年龄判断的细节容易被忽略JVM不会傻等对象熬到15岁才晋升它会在某个Survivor区中同龄对象总和超过Survivor空间一半时取这些对象年龄的最小值作为晋升阈值批量晋升。这个机制对响应型应用影响很大如果老年代频繁被对象提前挤爆可能就是要调整Survivor空间大小或检查是否有大对象逃逸。4.3 Major GC和Full GC老年代的“终极清理”Major GC回收老年代Full GC回收整个堆和方法区元空间。Full GC通常是昂贵且耗时的会导致长时间 Stop The World。触发Full GC的常见原因包括老年代空间不足、元空间不足、调用System.gc()以及CMS的并发模式失败。我在线上遇到的Full GC问题绝大多数都跟老年代快速被塞满有关。比如高并发场景下每个请求都new大数组数组直接进入老年代又比如一个线程池里线程数配置过高每个线程都会保留大量局部变量。还有一种隐蔽情况是线程池里的ThreadLocal没有及时removeThreadLocalMap里累积了大量Entry虽然key是弱引用但value被强引用链拽着直到GC才发现老年代空间疯狂膨胀。如果你的应用经常触发Full GC先用jstat -gcutil pid去观察老年代使用率走势再判断是短生命周期对象异常晋升还是确实有长生命周期对象累积。这个分析思路比一上来就加内存靠谱得多。4.4 垃圾收集器选型从Serial到G1、ZGC垃圾收集器是分代回收策略的“执行者”不同收集器适合不同场景。梳理一下主流的Serial / Serial Old单线程垃圾回收适合单核小堆客户端模式偶尔还能看到。Parallel Scavenge / Parallel OldJDK8默认面向吞吐量适合后台任务、批处理系统。CMS面向响应时间老年代并发回收但会产生内存碎片JDK9后被标注废弃。G1JDK9默认把堆划分为Region同时兼顾吞吐量和响应时间是当前的主流标配。ZGC / Shenandoah超低停顿适合超大堆几十GB甚至上百GB。ZGC的着色指针和读屏障让GC停顿时间控制在10毫秒以内面向大内存低延迟场景。收集器选型的核心是“场景决定论”。比如一个批处理系统单次任务跑很久更关心吞吐量那Parallel就很匹配。在线交易系统RT敏感用G1或者ZGC更合适。不要盲目追新JVM团队对不同收集器的默认参数调优了很多年你换一个收集器配套的调优参数也要重新摸一遍。5. 高频排查实录从内存泄漏到对象拷贝5.1 内存泄漏对象明明没用了为什么还活着内存泄漏的本质是“对象不再被业务使用但GC Roots仍然可以触达它”。我踩过的最常见三类场景静态集合未清理static List里不断add对象撑到内存爆掉。排查时先看静态字段和单例对象的集合容量。监听器/回调未注销组件销毁时没把注册到全局事件总线上的回调引用移除导致对象一直被事件源引用。ThreadLocal线程复用线程池场景下ThreadLocal在线程执行完没删线程没销毁就永远带着value。排查Java内存泄漏的通用步骤我用JDK自带工具就能搞定先用jmap -dump:formatb,fileheap.hprof pid导出堆快照再用MAT或JProfiler打开用Dominator Tree看大对象持有链重点看哪些对象占用了大量内存但业务上早已不需要。我一般会从两个方向入手查对象数量和查持有链前者看是否有对象无限增长后者定位是谁拽住了它。5.2 OOM到底有哪几类分别怎么解决OOM不是只有一种来解决方式差异很大异常类型触发原因常用解决思路Java heap space堆内存不足或对象泄漏加大堆、排查泄漏、优化对象数量GC overhead limit exceededGC时间占比过高但回收效果差降低堆内存或清理大对象必要时dump堆分析Metaspace元空间不足调整-XX:MaxMetaspaceSize排查类加载器泄漏Direct buffer memory堆外内存不足检查DirectByteBuffer使用可能要用虚引用清理unable to create new native thread线程数超出系统上限降低线程池线程数、调整系统线程限制有一次线上服务频繁OOM从监控看是Java heap space但堆只配了4GB应用本身不可能用那么多。后来dump发现某个网关请求类对象数量爆炸原因是异步回调里把请求上下文对象缓存到了静态MapMap的key是用户ID但用户ID会重复导致value被覆盖后旧的上下文对象没有被清理同时新的还在往里塞。真正的原因不是堆不够大而是代码逻辑里没有限制集合容量。所以OOM排查优先考虑泄漏再考虑加内存顺序不能反。5.3 对象深度拷贝的三种实现方式既然热词里有“java对象深度拷贝”这里展开说一下。深拷贝的落地方案我用一个包含List和Map的User类举例方案一重写clone方法手动对引用字段创建新对象Override public User clone() { User u (User) super.clone(); u.setTags(new ArrayList(this.tags)); u.setAttr(new HashMap(this.attr)); return u; }优点是对性能影响小缺点是要手写所有引用字段的复制逻辑字段多了容易漏。方案二用序列化实现ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos); oos.writeObject(original); ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); User copy (User) ois.readObject();需要类实现Serializable且所有嵌套对象都可序列化。这种方式代码最少但性能慢如果有transient字段会被跳过。方案三用JSON序列化比如Jackson或GsonUser copy objectMapper.readValue(objectMapper.writeValueAsBytes(original), User.class);这个更方便不需要实现Serializable但对某些字段类型比如泛型擦除或者特殊集合可能有反序列化后类型丢失的问题大数据量时有额外开销。实际项目里我用得最多的是方案一把复杂的克隆逻辑收敛到对象自己的clone方法里保持性能可控只有对象嵌套很深且改动频率高才考虑反过来用JSON方案。5.4 面试八股文对象与内存回收的高频答题框架最后分享一套我总结的答题框架适合应对大多数java面试题里关于对象和GC的追问创建部分说全五种创建方式重点讲反射绕过权限、clone浅拷贝、反序列化绕过构造方法这三个“对比点”。存储部分用“栈存引用、堆存对象、元空间存类信息”一句话概括再展开对象头三部分。回收部分先说可达性分析从GC Roots出发再说四种引用对应不同回收时机最后落到分代回收和收集器选型。实践部分用线上案例说一个OOM排查过程展示你既懂理论也会有工具。这个框架的好处是能把零散知识串成故事面试官追问任何一个点你都能往上下环节延展。背答案是背不出这种延伸能力的真得靠理解加实操。6. 我的几点实操心得做了这么多年Java开发我和对象创建、内存回收打过太多交道最后分享几个可能只有实战场才碰得到的体会。第一个体会是不要为了“显得高级”去用复杂创建方式。能new就new没必要在业务代码里绕一圈反射除非你是在写框架。代码的可读性、可维护性远比那一点点动态性重要。团队代码里如果有这种花活review时都会被重点盯防。第二个体会是排查GC问题前先看代码再看参数。我见过太多人一遇到GC频繁就加堆内存结果把问题盖住了隔一个月又来一次。正确的顺序应该是先看有没有内存泄漏其次看对象分配是否合理最后才调参数。加内存是最容易想到但往往最无效的兜底因为根因在代码里。第三个体会是做面试准备也好做项目复盘也好对象创建和GC回收这两个知识点一定要放在一起学。它们就像一扇门的两面创建方式决定了对象从哪里来回收机制决定了对象到哪里去中间是你在代码里对引用关系的每一次操作。理解了这条完整的生命周期你写代码时对内存的感知力会自动上一个台阶排查线上问题的效率也会明显不一样。以后再有人问你“Java对象是怎么创建和回收的”你就可以顺着这条主线把new、反射、clone、反序列化、GC Roots、可达性分析、分代回收、G1和ZGC整个链路讲得清清楚楚。这套东西不光是面试用日常开发写高性能、高可用的服务同样避不开。
返回列表