ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java对象创建与JVM垃圾回收机制详解:从new到GC全链路

Java对象创建与JVM垃圾回收机制详解:从new到GC全链路 面试里问“Java对象怎么创建”的十个有九个第一反应是new但你要是接着问他“new一个对象JVM内部到底干了哪些事”“这个对象什么时候会被回收”不少写了两三年Java的人也得愣一下。对象创建方式和内存回收机制就是Java基础里最“八股”又最绕不开的两块内容它不只是面试题更是你排查内存泄漏、优化GC停顿、理解Spring这类框架底层行为的底层知识。这篇文章我打算把这俩话题放在一起讲透从对象创建的六种姿势到JVM分配内存的完整链路再到GC判活、分代回收和主流收集器的选择最后把面试里那些高频追问和实际开发中的坑串一遍。不管你是准备校招、社招还是单纯想把Java基础补扎实这篇都值得读完。1. 创建Java对象的几种姿势从new到反序列化1.1 new、反射与构造器三种主流的创建入口先聊最常规的三种。new关键字是绝大多数对象进入JVM的方式它做的事情很纯粹在堆上分配一块内存然后调用对应的构造器完成初始化。语法简单性能也是所有创建方式里最好的因为JVM对new指令做了充分优化逃逸分析、锁消除、标量替换这些优化手段主要就是为它服务的。第二种是反射。JDK里提供两条反射创建路径Class.newInstance()和Constructor.newInstance()。前者只能调用无参构造器而且要求构造器可见Java 9之后就被标记为废弃了原因是它会把 checked exception 和 unchecked exception 混在一起抛错误处理很难受。后者的Constructor.newInstance()才是更正确的打开方式它可以拿到任意构造器包括私有的、带参的都能setAccessible(true)之后强行调用。Spring的Bean创建、MyBatis的Mapper代理、各种ORM框架里的对象实例化底层全靠它。反射比new慢是事实慢在哪里主要是每次调用都要做访问权限检查、参数类型匹配还要处理装箱拆箱如果能缓存Constructor对象性能损耗能压下去不少。所以实际工程里Spring容器启动时才用反射创建Bean运行期业务代码里就老老实实new或者交给池化对象管理不要为了“炫技”在热路径上频繁反射。第三种严格说也不算第三种是Constructor的变体但值得单独说通过构造器创建对象本质上是“先分配内存再调用构造方法”如果你在构造方法里做了大量耗时的初始化逻辑那对象创建的开销大头就不在JVM而在你自己的代码。这一点后面讲内存分配链路时还会再提。1.2 clone、反序列化与其他“不走寻常路”的创建方式除了new和反射还有三种容易被忽略的创建途径。clone()比较特殊它不调用任何构造器直接从已有对象复制出一份新对象。调用的前提是实现Cloneable接口否则会抛CloneNotSupportedException。这里有个经典误区很多人以为clone()会走构造器其实完全不会它是在JVM层面直接复制内存块效率挺高但默认行为是浅拷贝——对象内部的引用字段新旧两个对象还是指向同一个东西。想深拷贝就得自己重写clone()逐层把引用对象也复制一遍。反序列化也算一种创建方式。ObjectInputStream.readObject()从字节流里恢复对象时同样不会调用构造器。这意味着什么一个没有无参构造器的类、甚至构造器里有大量校验逻辑的类反序列化照样能拿到对象而且字段值完全来自序列化流。所以反序列化本质上是“绕过构造器防线”的一种创建手段这也是为什么很多安全攻击都盯着反序列化——它对对象的产生过程几乎没有约束。还有一个更底层的Unsafe.allocateInstance()。它连类都不检查直接分配内存、创建对象构造器不调用、实例字段不初始化字段值是默认零值这个东西日常开发基本用不到但像Objenesis这类库就是靠它绕过构造器来创建对象的很多框架在做代理和反序列化时会在背后偷偷用它。至于工厂方法和建造者模式它们不算新的底层创建方式本质是对new的封装目的是把创建逻辑收敛到一处方便统一控制参数校验、缓存复用、对象池化面试时别把它们和上面几种并列去答就行。1.3 创建方式对比表面试官问“有什么区别”时怎么答把上面几种方式整理成一张表面试或者自测的时候直接对照着说创建方式是否调用构造器主要使用场景特别提醒new关键字调用日常编码最常用走完整创建链路性能最好Class.newInstance()调用仅无参老代码、早期反射JDK 9起废弃处理异常麻烦Constructor.newInstance()调用任意构造器Spring等框架创建Bean可访问私有构造器需缓存优化clone()不调用对象复制需实现Cloneable默认浅拷贝反序列化不调用RPC、缓存持久化、对象传输字段取自序列化流安全性需注意Unsafe.allocateInstance()不调用框架底层、代理对象创建绕过一切检查慎用面试官问到“对象创建有哪几种方式”按这个表回答基本是满分结构。如果他继续追问“为什么反序列化不调用构造器”你就说对象状态是从字节流直接恢复的不需要经过构造逻辑这也解释了为什么反序列化对象可能绕过类里写的约束条件。再往下深挖就进入JVM内部了。2. 对象创建背后的内存分配链路JVM到底做了什么2.1 类加载检查与内存分配从常量池查证到指针碰撞new指令并不是简单“申请一块内存”就完事。JVM字节码里执行new的时候第一步是到常量池里找到这个类的符号引用检查这个类有没有被加载、解析、初始化过。如果没有就触发类加载流程——加载、验证、准备、解析、初始化一条链路走完类信息进入方法区JDK 8之后是元空间然后才能继续分配对象。类加载完成之后JVM就清楚这个对象到底需要多大内存了。这个大小在类元信息里是确定的就像你知道了对方的穿衣尺码才能去买衣服。接着就是从堆上划分一块对应大小的内存出来。具体怎么划分取决于堆内存是否规整如果使用复制算法整理过内存比如Serial、ParNew收集器管理的区域空闲内存是连续的只需要移动一个指针就能完成分配这叫“指针碰撞”如果堆内存不太规整有碎片比如CMS收集器管理的区域JVM就得维护一张空闲列表找一块足够大的空间分出去这叫“空闲列表”。分配内存还有个并发问题。JVM堆是线程共享的两个线程同时new对象不能都往同一个位置分配否则就串了。解决思路两种一是对分配动作做CAS加失败重试保证原子性二是Java线程自己先在Eden区圈一块私有缓冲区也就是TLAB线程内部对象分配优先在TLAB里做缓冲区用完了再走CAS去公共区域分。绝大多数对象都是通过TLAB分配出去的这也是对象创建性能能撑住高并发的原因。2.2 初始化零值、对象头与构造方法对象“出生”的完整流程内存分配完成后JVM把这块内存区域初始化为零值。这一步很关键它保证了对象的实例字段即使没有显式赋值也能拿到默认值int是0、boolean是false、引用类型是null。Java语法层面“字段默认初始化”的规则底层就是这一步实现的。然后设置对象头。对象头里存的东西比很多人想的多Mark Word里记录哈希码、GC分代年龄、锁状态标识类型指针指向类的元数据JVM靠它判断这个对象到底是哪个类的实例如果对象是数组还要记录数组长度。这些信息是对象“身份”的一部分GC判断存活、锁膨胀升级都离不开它们。最后才是执行构造方法。这里注意一个顺序实例字段在构造器里如何初始化是按照字节码指令顺序来的。常见写法是在字段声明处直接赋值比如private int age 18;这个赋值动作会编译进构造器在构造器代码之前先执行。如果字段没有显式赋值上一步的零值就保持到访问为止。给个生活类比分配内存是拿到一个新房子的钥匙初始化零值是毛坯房里先刷好白墙设置对象头是给房子贴上门牌号执行构造方法才是你按自己的风格做装修。这几步一个都不能乱漏掉谁都住不踏实。2.3 TLAB与逃逸分析JVM为对象创建埋的“加速器”我们常说“new出来的对象在堆上”但这句话严格说并不绝对。JIT编译时如果发现某个方法的局部对象没有“逃逸”出方法——比如没有被返回、没有被存入成员变量、没有被传给外部方法——JVM就可以做栈上分配或者标量替换。对象占用的空间从堆挪到栈帧上方法结束栈帧弹出对象直接被销毁根本不需要GC介入。这就是逃逸分析的威力减少堆分配次数也就减少了GC压力。TLAB则是分配阶段的优化每个线程在Eden区预分配一小块私有空间线程内分配对象不用和其他线程竞争只在TLAB空间不足时才走公共分配路径。这个机制对吞吐量的提升非常明显实践里大部分小对象都是直接在TLAB里分配出去的。理解这两点之后你就明白为什么网上说“开JVM调优参数不要迷信-XX:UseTLAB”这种话了——TLAB本来就是默认开启的。但逃逸分析是有代价的JIT要花费额外的编译成本去分析对象引用关系所以不是所有对象都能优化掉。实际开发中与其指望JIT帮你兜底不如从代码层面减少无用对象的创建比如循环里避免无意义的new、尽量复用可变对象这些习惯比任何参数都管用。3. 对象回收判断什么样的对象会被GC盯上3.1 引用计数法的困境与可达性分析判活的标准姿势Java堆里对象多如牛毛GC不可能一个个问“你还有没有人引用”。怎么判断一个对象该死早期语言用过引用计数法每个对象维护一个计数器被引用一次加1引用失效减1减到0就直接回收。思路很直观但有个致命漏洞——循环引用。两个对象互相引用外部谁也不引用它们计数永远不为0垃圾就永远清不掉。所以现代JVM基本不这么干。HotSpot用的是一套更聪明的方案可达性分析。它从一组固定的根节点出发沿着对象引用关系往下遍历所有能走到的对象都标记为“存活”走不到的对象就是可回收的。这组根节点叫GC Roots包括虚拟机栈里局部变量表引用的对象、方法区里静态属性引用的对象、常量引用的对象、JNI引用的对象、正在被synchronized锁住的对象以及JVM内部的Class对象、常驻异常对象等。理解这点对排查问题特别重要。最常见的“内存泄漏”场景其实就是本该被回收的对象因为被某个GC Root间接引用住了导致可达性分析一直能走到它它就一直赖在堆里。很多Java服务的OOM不是堆不够而是对象被不该持有的引用链拴住了。3.2 四种引用类型从强引用到虚引用各自的应用场景Java里引用分四种强度面试必问这里直接上对比引用类型回收时机典型使用场景代表类强引用只要可达就永不回收内存不足直接OOM日常new出来的引用Object对象默认引用软引用内存充足不回收即将OOM前回收缓存系统、图片内存缓存SoftReference弱引用下一次GC就回收ThreadLocal的ThreadLocalMap的keyWeakReference虚引用随时可能回收且不能通过它取到对象配合引用队列监控对象回收NIO堆外内存回收PhantomReference这里展开说几个高频考点。软引用于做缓存是合理的内存快不够了软引用对象先被清理掉避免OOM。但这带来一个陷阱软引用对象的回收时机取决于GC算法和堆大小回收并不及时如果缓存对实时性要求高还是得配一个容量淘汰策略不能全指望软引用兜底。弱引用最经典的例子就是WeakHashMap和ThreadLocal的内部实现。ThreadLocalMap的key是弱引用所以当外部不再持有ThreadLocal对象时key在下一次GC后就会被清空但value还在形成了“key被回收、value还在”的残留。这正是ThreadLocal内存泄漏的根源所以必须用完后手动remove()。虚引用是最另类的你通过PhantomReference.get()永远拿不到实际对象它唯一的作用就是当对象被回收时JVM把这个虚引用放入关联的引用队列ReferenceQueue应用层通过监控队列得知“某对象已回收”。NIO里的堆外内存回收就是这套玩法DirectByteBuffer利用虚引用配合Cleaner在对象回收后释放堆外内存。面试如果问你“如何监控一个对象是否被GC回收了”答案就是虚引用加引用队列。网上有些项目就是靠这个思路做GC监听器的。3.3 finalize机制一个被时代淘汰的“遗物”聊判死机制绕不开finalize()这个历史包袱。可达性分析之后一个对象如果和GC Roots没有任何引用链理论上就“判死”了。但如果这个类覆写了finalize()且方法没有被调用过JVM会把它放进一个F-Queue队列由一个低优先级的Finalizer线程去执行。执行过程中如果对象在finalize()里把自己重新和某个GC Root建立了关联那它就能“死里逃生”下一轮可达性分析就活着了。听起来很奇妙对吧但实际开发里千万别依赖它。第一finalize()的执行时机完全不确定GC触发时间不固定Finalizer线程本身也受调度影响你没法预知对象什么时候被“善后”。第二它只能执行一次第二次回收时不会再给机会。第三它性能开销大且容易拖慢GC因为带了finalize的对象会有额外的队列管理。Java 9把它标记为废弃Java 17直接把finalize()移除了。现在想在对象销毁前做资源清理正确姿势是用try-with-resources明确管理生命周期或者用Cleaner内部基于虚引用实现在对象被GC回收后做回调清理。记住一条原则资源清理这种事能显式就别依赖隐式能确定就别靠“可能发生的GC”。4. 垃圾回收的实际执行分代收集与主流收集器4.1 堆内存为什么分代新生代、老年代与元空间大部分Java对象生命周期极短创建、使用、不再被引用一个业务请求的功夫就没了。如果所有对象混在一起用同一种回收策略效率一定很差。所以HotSpot把堆分成新生代和老年代思路很朴素——按对象“年龄”分桶管理。新生代里继续细化成Eden区和两块Survivor区S0、S1比例默认8:1:1。新对象统一进入Eden区Eden满了触发Minor GC用复制算法把存活对象复制到Survivor区两块Survivor之间交替使用每次Minor GC都会给存活对象的年龄加1。熬过一定次数默认15后对象晋升到老年代。还有人会问为什么非得要两块Survivor——为了用复制算法时不产生内存碎片S0和S1互相倒腾始终保留一块干净空间。老年代里存放的是两类对象一是熬过多次Minor GC的“老油条”二是大对象直接进老年代通过-XX:PretenureSizeThreshold控制。大对象为什么直接进老年代因为大对象在新生代里反复复制代价太高而且大对象进入Eden会挤压其他小对象的空间容易提前触发Minor GC。但这带来一个隐患如果程序频繁创建大对象老年代很快就满翻过头来频繁触发Full GCGC停顿时间会很难看。还有一个常被误解的点方法区不属于堆JDK 8之后叫元空间使用本地内存存储类的元数据、方法字节码、常量池等信息。元空间也会触发GC类元数据卸载但它不归堆内存管。很多人面试把此区概念搞混注意一下。4.2 从标记-清除到G1GC算法演进背后的权衡GC算法三家马车每个都有取舍。标记-清除算法最简单先标记所有可回收对象然后统一收回。问题是产生大量内存碎片后面分配大对象时明明总空间够却找不到连续内存被迫提前触发新一轮GC。标记-复制算法没有碎片问题但浪费空间需要留一块区域做“活水区”所以只适合存活率很低的年轻代。标记-整理算法在标记之后把所有存活对象向内存一端移动解决了碎片问题但移动对象要STWStop The World停顿时间长。真正让算法“活”起来的是分代思想年轻代存活率低用复制算法年老的存活率高用标记清除或标记整理。后来的G1把这种思路迭代成Region模式整个堆划分成大小相等的Region每个Region独立扮演Eden、Survivor或老年代角色G1每次都回收一部分Region包括年轻代Region和部分老年代Region不需要扫描整个堆。G1用RSetRemembered Set记录跨Region引用通过SATBSnapshot At The Beginning算法解决并发标记时的漏标问题再通过停顿预测模型尽量把GC停顿控制在你设定的目标范围内。拿G1和CMS对比CMS是真正意义上的“并发收集器”标记阶段和应用程序线程并发执行停顿短但它在老年代用的是标记-清除碎片问题无法根治而且并发失败时会退化成Serial Old停顿直接爆炸。G1则把堆划分成小Region用可预测的停顿模型和更精细的回收粒度把延迟压下来。ZGC更激进用染色指针和读屏障把几乎所有的GC阶段都并发化STW时间压到毫秒级以下。没有“最好”的收集器只有“最适合”的收集器。这是理解GC演进的关键。4.3 常见收集器选型与触发时机CMS、G1与ZGC日常接触到的主要是Serial、Parallel、CMS、G1和ZGC。Serial是单线程收集器适合客户端模式、小堆、低并发场景Parallel是JDK 8默认的年轻代收集器追求吞吐量优先适合多核场景CMS在JDK 9之后被废弃、JDK 14移除但老系统里还能见到它的通用处理方式参考遇到老年代碎片严重、promotion failed时往往就得切到G1了G1从JDK 9开始成为默认适合堆内存较大4GB以上且对延迟有明确要求的服务ZGC面向超大堆、极低停顿的场景JDK 15之后逐渐成熟如果对延迟极其敏感值得评估。收集器的选型可以先给一个通用判断用命令行跑个实验服务配合GC日志看实际停顿和吞吐再决定是按默认的Parallel还是切G1还是上ZGC。不要盲目追新也不要死守旧版本。触发时机这块很多做运维的同学容易混淆。Minor GC一般发生在新生代Eden区空间不够时Full GC常见触发原因包括老年代空间不足、元空间空间不足、调用System.gc()、大对象晋升导致空间分配担保失败以及CMS的promotion failed/concurrent mode failure。遇到Full GC先别急着调堆大小打开GC日志看Cause再定位到具体是哪个区域满了、什么对象占的空间大排查方向才正确。5. 面试高频追问与实战避坑把原理用到代码里5.1 高频追问对象创建后什么时候回收、对象会漏回收吗把前面所有内容串起来其实可以回答一个综合性问题new一个对象到它被回收经历了什么第一步类加载检查类没加载就先加载第二步在Eden区分配内存通常在TLAB里第三步初始化零值、设置对象头、执行构造方法对象“诞生”第四步业务使用对象被引用、被修改、被传递第五步某个时刻Eden区满了触发Minor GC如果这个对象没有GC Roots引用链了它就被标记为可回收还活着就复制到Survivor区年龄加1第六步年龄够了之后晋升到老年代第七步老年代满了触发Full GC对象在这一轮才可能被彻底清理。这个回答把创建和回收的系统性理解都展示出来了是面试加分点。那么对象会漏回收吗理论上可达性分析是精确的但JVM为了保证分析期间引用关系不变化必须让业务线程短暂停顿这就是STW。JVM通过安全点SafePoint机制通知线程在哪里停下来检查配合解释器和JIT生成的OopMap来准确获取局部变量和堆的引用关系GC Roots的信息就是从这里枚举出来的。开发中更常见的反而是“表面上该回收实际一直被引用”。String常量池里的字符串、静态集合里存的实例、监听器未移除、ThreadLocal未清理、IO流未关闭、内部类隐式持有外部类引用这些都是隐形引用源排查内存泄漏时优先级最高。5.2 深浅拷贝在对象复制里的坑clone背后还连着内存回收对象深拷贝虽然不是创建对象的新方式但它和引用、回收的关系非常密切。默认的clone()是浅拷贝基本类型字段直接复制值引用类型字段只复制引用。这意味着两个对象“共享”了内部的可变对象改一个另一个跟着变。这在业务上往往不是你要的。三种常见深拷贝方案第一种重写clone()逐一拷贝引用字段。代码量可控但类内部引用层次深了判断漏拷一个就很容易出bug。第二种用序列化方式拷贝把对象写到ByteArrayOutputStream再从流里读出来。思路巧妙且能自动处理深层的引用复制但是要求类实现Serializable字段上的transient修饰会丢值而且性能很差线上高频调用不推荐。第三种就是手动构造新对象字段一个个赋值最朴素也最可靠适合字段少、结构稳定的对象。我建议在小规模、简单对象的深拷贝场景里首选重写clone()字段多就用第三方库如Apache Commons Lang的SerializationUtils或json序列化反序列化但要确认对象结构简单、没有循环引用。特别提醒循环引用的两个对象用JSON序列化做深拷贝几乎必然出问题这是实际开发绕不过的坎。深拷贝和内存回收有什么关系深拷贝之后新对象和原对象不再持有相同的内部引用原对象如果失去所有外部强引用就可以被GC回收。如果浅拷贝后多个对象共享同一个内部对象这个内部对象只要被其中任何一个引用着就不会被回收——这既是共享带来的存活也是缓存泄漏的常见源头。5.3 对象判空与空指针从语法层面到内存层面的理解对象判空是Java面试的入门题但它其实和内存回收在底层是同一件事。空指针的本质很简单一个引用变量是null意味着它没有指向任何堆内存对象这时候调用其方法或访问其字段JVM就会抛NullPointerException。从内存视角看null引用和已经回收的对象是两回事对象被回收后你持有的引用还在但指向的内存已经失效再操作它同样会出问题。实际开发里减少空指针有几个习惯可以坚持。能用基本类型不用包装类型避免自动拆箱导致NPE方法返回集合时返回空集合而不是null入参用Objects.requireNonNull做前置校验链式调用频繁的对象访问考虑用Optional包装注意Optional不能序列化别在领域对象里到处用。还有一个很容易忽视的场景Map.get(key)返回null的时候要分清楚是key不存在还是value本身为null这是排查业务bug时特别容易被坑的点。如果你在做代码评审看到有人写了类似if (obj ! null obj.getXxx() ! null)的长链判空提示他用Optional或者拆方法既提高可读性又能避免遗漏。记住判空的目的不是让代码看着“严谨”而是尽早暴露引用失效的问题避免错误在后续悄悄扩散。我面试的时候最常问的一句话是你new了一个对象然后忘了处理引用和这个对象应该被回收却没被回收哪个更可怕答案是后者。对象创建是入口回收是出口只懂入口不懂出口写出的代码迟早会在内存问题上翻车。这些坑知道对象创建和回收的原理后再看代码会通透很多。
返回列表