ARTICLE DETAIL

资讯详情

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

类与对象到底差在哪?从JVM内存到运行机制彻底拆解

类与对象到底差在哪?从JVM内存到运行机制彻底拆解 一个面试问题暴露的认知盲区类与对象到底差在哪前阵子面试一位自称Java基础扎实的候选人我问他说说类与对象的本质区别是什么。他张口就来——类是模板对象是实例。我又追问了一句那你觉得类本身在内存里有没有位置如果没有那Person.class这个文件加载到哪里去了他愣住了支支吾吾半天说不清楚。这一幕其实很典型。很多写了两三年 Java 的人能背出模板与实例这个定义但一旦被问到类加载静态成员的存储位置new 到底做了什么这些底层问题时就露馅了。说到底类与对象这四个字不是背一个定义就能过关的它是整个 Java 面向对象体系的地基。地基松了后面学多线程、学 JVM、学框架源码都会飘。这篇文章我不会只给你一个定义。我会从设计层面、内存层面、运行层面这三个角度把类与对象这件事彻底拆开。顺便把那些网上高频搜但是没人讲透的边角问题——表达式必须包含类类型、Optional 对象操作、对象为空判断、动态代理里的类与对象——也一起揉进来说清楚。不管你是准备面试、刚入门、还是写了几年代码想补基础这篇都值得看完。1. 类的本质编译期的类型蓝图与运行期的 Class 对象1.1 类是类型系统的骨架不是一句模板就能概括的说类是一张设计图纸这个类比没有错但它只描述了类和对象关系的一个侧面。如果你想把本质两个字讲透首先要意识到类是 Java 类型系统的载体它承担了三种完全不同的角色。第一种角色类是对现实世界概念的建模。比如定义了Person类本质上是在说所有具有姓名、年龄这些属性能吃饭、能走路这类行为的个体都属于 Person 这个概念。这是从需求分析角度的理解。第二种角色类是编译器进行类型检查的依据。你在代码里写Person p new Student();编译器靠什么判断这行能不能过靠的就是Person和Student这两个类在继承体系中的位置。没有类编译器看到的只是一堆无差别的字节类型安全无从谈起。这也是为什么 Java 是强类型语言因为一切类型约束都落地在类这个结构上。第三种角色类本身也是一个对象——准确地说每个类在 JVM 里都会对应一个java.lang.Class类型的实例。这部分很少有人提但它是理解反射、动态代理、Spring 容器这些高级玩意的关键锚点。后文我会专门展开。1.2 类加载机制你的 .class 文件是怎么从磁盘变成 JVM 里的 Class 对象的回到开头那个面试问题类在内存里到底有没有位置答案是有但它不是以你写的那个人类的形式存在的而是以一种叫做Class 对象的特殊对象存在的。过程是这样的你写了一个Person.javajavac把它编译成Person.class字节码文件。当程序第一次使用Person类时JVM 的类加载器ClassLoader会把这个字节码文件读进来经过加载、验证、准备、解析、初始化这五个阶段最终在 JVM 的堆内存或者更准确地说是方法区/元空间不同 JDK 版本存放位置有差异中生成一个java.lang.Class对象这个对象就代表了Person类本身。这里有几个关键点值得嚼一嚼类不是从一开始就存在于内存里的它是懒加载的。只有当你new Person()、访问Person的静态成员、或者用反射Class.forName(Person)时JVM 才会去加载它。同一个类在同一个类加载器下只会被加载一次也就是说 JVM 里只有一个代表Person的 Class 对象。无论你 new 多少个 Person 实例它们指向的是同一个 Class 对象。正因为类本身也是对象所以你可以拿到它、操作它——这就是反射的根基。Person.class这个语法拿到的不就是那个 Class 对象吗理解了这一层再看类是模板这句话就知道它说的太浅了。模板只是类在设计阶段的样子。到了运行期类以 Class 对象的形式真实存在于内存中拥有自己的属性、方法描述信息还能被读取和修改。1.3 静态成员属于类实例成员属于对象这句话的底层含义Java 基础里有一个高频考点静态变量static 修饰的成员属于类实例变量属于对象。很多教程只是让你记住这个结论但结合上面的内容这句话就有了实际意义。static修饰的成员在类加载的准备阶段就会被分配到内存中它依附的对象是那个 Class 对象——也就是说只要类被加载了静态成员就存在不需要 new 任何一个实例就能访问。你可以用Person.count直接访问静态变量正是因为count是挂在 Class 对象上的。而实例变量就不同了。每个new Person()出来的对象都有自己独立的一份实例变量内存空间。p1.name和p2.name是两块完全不同的内存互不影响。我见过不少人因为没想明白这一点写出很诡异的代码。比如在静态方法里直接访问实例变量编译直接报错或者用对象.静态变量这种写法——Java 虽然允许但 IDEA 会给你标黄警告因为它容易让人误以为这个变量属于对象。实际运行的时候JVM 看的根本不是你用哪个对象来访问的它看的是变量的声明类型——这就叫静态成员与对象无关。2. 对象的本质new 关键字背后的内存分配与生命周期2.1 new 不是魔法它干了几件具体的事很多人把new Person()当成一个造东西的咒语念出来就有对象了。实际上new这个关键字背后JVM 至少做了四件事第一步检查类是否已经被加载。如果Person的 Class 对象还不存在先触发类加载流程。这就是为什么第一次 new 一个类的时候程序会卡那么一下。第二步在堆内存中为对象分配一块连续的内存空间。分配完之后这块内存的所有位都被置为零——也就是说对象的实例变量会先有一个零值数字是 0boolean 是 false引用是 null。注意这时候对象还是一个零状态的空壳子。第三步为对象头设置必要信息。每个对象在内存里不只有实例变量还有一段隐藏的对象头Object Header里面记录了这个对象属于哪个类通过指向 Class 对象的引用、对象的哈希码、GC 分代年龄、锁状态标志等。这就是为什么同一个类 new 出来的对象能调用到自己的类方法——因为每个对象都持有一个指向自己 Class 对象的引用。第四步调用构造方法init执行你在构造函数里写的初始化逻辑。这一步做完对象才真正变成你期望的样子——name 被赋上张三age 变成 18。所以你看new的本质不是凭空创造而是按着 Class 对象里的元数据信息在内存里复制出一份数据的载体。2.2 栈上的引用与堆上的对象一对纠缠不清的孪生兄弟新手最容易混乱的点在于Person p new Person();这行代码里p到底是什么p不是对象本身它是一个引用Reference是一个存放在栈内存里的变量它的值是一个指向堆内存中那个 Person 对象的地址。你可以把p理解成遥控器堆里的对象才是电视机。你拿着遥控器按按钮——也就是用p.name、p.eat()这种语法——实际上是通过遥控器的信号去操作电视机。这个区分极其重要因为它能解释很多诡异现象为什么p1 p2;之后改动p1.namep2.name也变了因为p1和p2这两个遥控器指向的是同一台电视机。很多对象赋值页面不变、集合里存的都是同一个对象这类 bug追溯到底都是这种引用传递造成的。为什么方法参数传对象方法内部改了属性外面的对象也变了因为传进去的不是对象的副本而是引用——你复制了一个遥控器但遥控器控制的还是原来那台电视机。为什么判断两个对象相等不能用因为比较的是两个遥控器的编号引用地址而不是电视机本身的内容。对象为空这个判断也一样。if (p null)检查的其实是这个遥控器有没有绑定任何电视机。一个新建的对象永远不为 null因为 new 成功就一定在堆里有位置而如果一个引用变量没有被赋值或者被赋了 null检查就通过了。2.3 对象的生命周期从可达到不可达再到被 GC 回收对象什么时候被销毁很多刚学 Java 的人以为出了作用域就被销毁。准确地说对象本身没有作用域概念作用域是引用变量的概念。真正决定对象生死的是——它是否还被可达。什么是可达就是从 GC Roots垃圾回收根节点出发沿着引用链能够遍历到的对象就是活着的。GC Roots 包括栈里的局部变量引用、静态变量引用、JNI 引用等。当一个对象不再被任何 GC Roots 引用时它就变成了垃圾等待 GC 回收。举个具体例子public void test() { Person p new Person(); // 栈上 p 引用堆中对象 p null; // p 不再指向那个对象 // 此时堆里的 Person 对象没有被任何引用指着了成为可回收对象 }注意把p置为 null 只是切断了这条引用链对象并不会立刻被回收。GC 的具体回收时机取决于 JVM 的垃圾回收策略可能是下一次 Minor GC也可能更晚。这也是个面试爱问的点。这个可达性分析的机制恰恰是理解类与对象关系的延伸——对象是活跃的、有生命周期的而类只要被加载一次就会一直待在元空间里直到 JVM 退出。一个可能被回收一个几乎永生这就是本质区别的一部分。3. 类与对象的核心差异从定义、内存到数量的多维对照3.1 五个维度拆开对比为了让你看得更清晰我把类与对象的差异整理成一张多维对照表对比维度类Class对象Object性质抽象的概念、模板、数据类型具体的实体、实例、数据载体存在位置编译期是 .class 文件运行期是元空间的 Class 对象运行期对象实例存在于堆内存创建方式通过编写代码定义由 JVM 类加载器装载通过 new、反射、反序列化等创建数量关系一个类在内存中只有一个对应的 Class 对象一个类可以创建无数个对象实例是否有状态类的描述信息几乎不变静态成员可变更但依附类对象每个对象都有独立的实例变量状态生命周期从类加载到 JVM 退出全局唯一、几乎长生从创建到不再被引用可能随时被 GC能否直接操作可以通过反射操作 Class 对象可以通过引用直接调用方法、访问属性这张表背下来容易但你要能对着每一行讲出背后为什么才算真懂。拿数量关系这一行举例。为什么一个类只有一个 Class 对象因为类加载器的缓存机制——每次加载类之前都会先去查缓存缓存里有了就返回同一个。这保证了Person.class Class.forName(Person)这种比较在同一个类加载器环境下永远为 true。而对象就不一样了new Person()每次都会在堆里划一片新内存所以new Person() new Person()永远是 false除非 Person 重写了 hashCode 和 equals但那是另一个话题。再拿是否有状态这一行举例。类的静态变量确实可以变但记住它是全局唯一的一份所有对象共享。而对象的实例变量是每个对象各一份。这直接决定了静态成员适合放什么——全局配置、常量、工具方法但绝对不适合放和某个具体对象绑定的数据。3.2 类型Type与实例Instance的关系在生活中并不难找如果想找一个生活化的类比我自己的体会是类是一枚印章对象是盖出来的一个个印痕。印章本身刻着认证通过四个字这是它的类定义——规定了印出来的效果是什么样。但你拿这枚印章在十份文件上盖了十个印每一个印痕都独立存在于一张纸上有自己的位置内存地址、有自己的纸张生命周期可回收。你盖坏了其中一张纸上的印其他纸上的印不受影响——这就对应了每个对象实例变量独立。而印章本身不会因为盖了十次就消失它还好好躺在那里——这就对应了 Class 对象一直存在。还有一个类比也常用类就像菜谱对象就像按菜谱炒出来的每一盘菜。菜谱是文字是抽象的概念它可以被十万家餐馆参考但每一盘菜端上桌都是独立的一盘色香味各自有差异吃完了GC 回收菜谱还在。这类比的妙处在于它天然解释了为什么类是抽象、对象是具体——抽象意味着不占用实际的桌面空间具体意味着真实存在于某个位置。理解了这两个词的本质比记住定义有用得多。3.3 表达式必须包含类类型一个经典的报错背后就是类型混淆搜热词的时候看到不少人查表达式必须包含类类型这个报错这个问题非常典型它正是类与对象混淆的直接体现。这个报错通常出现在你写xxx.class这种语法时xxx不是一个类而是一个对象。比如Person p new Person(); String className p.class.getName(); // 编译报错表达式必须包含类类型正确写法应该是String className Person.class.getName(); // 或者 String className p.getClass().getName();为什么p.class不行因为.class这个语法是一个字面量只在类名上有效用于获取这个类的 Class 对象。而对象的类型信息要通过getClass()这个实例方法来拿由 Object 类继承而来。一个是编译期就确定的类字面量一个是运行期动态返回的运行时类型两者虽然最终都指向同一个 Class 对象但使用语法完全不同。很多报这个错的开发者实际上就是没搞清楚类名和对象引用在语法层面的分工。类名是类型的静态入口对象引用是实例的动态入口二者不能混用。把这个错误弄懂了你对类与对象的边界感会提升一大截。4. 实战中那些和类与对象纠缠最深的坑4.1 判断对象为空的几种方式以及 Optional 的正确打开方式判断对象为空这个需求几乎是天天写。最朴素的方式是if (obj ! null)新手都会。但后端开发里真正普遍的场景是你从一个接口拿到一个对象这个对象可能不为 null但它内部的某个关联字段可能是 null或者你调用的方法返回的可能是一个 Optional。这里有个值得展开的细节Optional本身就是一个容器对象你用Optional.of(obj)来包装时如果 obj 是 null会直接抛NullPointerException只有用Optional.ofNullable(obj)才是安全的。很多新手在这里栽跟头本质上是没理解 Optional 是一个对象它内部持有的引用可以为 null 这层关系。再举一个真实业务场景。假设你有一个Order对象需要判断它的getAddress()是否为空并且不为空时取出城市名。初级写法是这样if (order ! null) { Address addr order.getAddress(); if (addr ! null) { String city addr.getCity(); // ... } }这种层层判空的代码用 Optional 改造后会清爽很多但也隐藏着对对象模型的理解要求Optional.ofNullable(order) .map(Order::getAddress) .map(Address::getCity) .ifPresent(city - { /* ... */ });注意map的每一步输入是一个对象输出是另一个对象或者 null这个链式调用能成立的前提是每个方法调用都发生在对象层面而 Optional 就是一个包装对象的对象。如果你不清楚对象之间的关系会非常容易写出链到一半 NPE的代码。4.2 对象赋值页面不变引用共享与不可变对象的纠缠看热词里有vue对象赋值页面不变虽然这是个前端问题但它的根子和 Java 的对象赋值如出一辙。前端是这样你给一个 Vue 响应式对象新增属性页面不刷新因为新增的属性没走 Vue 的响应式劫持。后端的对应场景是你修改了对象但缓存、日志、下游系统统统感知不到变化。在 Java 里这类问题的核心往往在于——你赋值的时候是把引用赋过去了还是把内容复制过去了。看这个经典误区ListString list1 new ArrayList(); list1.add(A); ListString list2 list1; // 这不是复制是让 list2 指向同一个对象 list2.add(B); System.out.println(list1.size()); // 输出 2很多人下意识以为list2 list1是把 list1 的数据拷了一份给 list2。不是。list2 和 list1 是两个引用变量但绑定的是同一个 ArrayList 对象。你要真的复制内容得new ArrayList(list1)或者调用clone()。这段逻辑放到类与对象的语境下就是那句老话的复现对象是堆上的独立实体引用是栈上的遥控器。你在业务代码里做的赋值绝大多数情况下只是复制了遥控器并没有复制电视机。还有一个相关的坑是 String 的不可变性。String 对象一旦创建内容不可变所有看似修改字符串的操作比如replace、concat实际都是生成新的 String 对象。这也是对象生命周期特性的体现——有些对象设计成不可变就是为了安全地在多处引用共享中传递不担心被意外改动。4.3 动态代理类、Class 对象、代理对象三者如何协作动态代理是 Java 面试八股文里的高频考点也是理解类与对象关系的一个极佳实战切入点。Java 动态代理的核心类是java.lang.reflect.Proxy。当你调用Proxy.newProxyInstance(ClassLoader, interfaces, InvocationHandler)时JVM 会在运行期动态生成一个代理类一个全新的 Class 对象然后用这个新的类创建出代理对象。这个过程里类和对象的关系被体现得淋漓尽致Proxy.newProxyInstance的第一个参数 ClassLoader决定了新的代理类由哪个类加载器来加载第二个参数 interfaces是代理类要实现的接口列表——代理类是根据这些接口的元数据现场生成的。生成代理类的时候JVM 实际上是动态构造了一套字节码然后把这个字节码加载成 Class 对象。这个 Class 对象和你手写的那些类没有什么本质区别同样可以 new 出多个代理对象实例。当你调用代理对象的方法时方法调用不会执行代理类自身写好的逻辑而是被转发到 InvocationHandler 的invoke方法。为什么能做到这一点因为代理类在生成时每个方法的实现体里都写了一段调用 handler.invoke(this, method, args)的字节码。从类与对象的视角看动态代理的本质就是在运行期动态创建一个新类再基于这个类创建对象并让所有方法调用都走统一的拦截器。如果你只懂类是模板、对象是实例这个层面你是无法理解动态代理的——因为你意识不到类这个东西在运行期是可以被动态创造出来的。4.4 抽象类和普通类的区别对类本身的进一步细分热词里还有抽象类和普通类的区别这也是个老生常谈但常问常新的点。从类与对象的本质区别延伸下去你会发现类本身也不是铁板一块。抽象类和普通类最大的区别在于能否被实例化——抽象类不能 new普通类可以。但这句话背后隐藏的问题是为什么 abstract 修饰的类就不能 new根本原因是抽象类里可能含有抽象方法这些方法只有声明没有实现体。如果一个抽象类可以被 new那么 new 出来的对象调用一个没有实现体方法的时候JVM 根本不知道该执行什么字节码。所以 Java 语法层面直接禁止了抽象类的实例化——不是没必要而是逻辑上不允许。但注意抽象类不能 new不代表它没有构造方法。抽象类可以有构造方法而且它的构造方法是给子类用的——子类实例化的过程中会通过super()隐式调用父类的构造方法。这是继承体系下对象创建链的体现一个子类对象的完整创建过程会从最顶层的父类一路初始化下来。这种机制也解释了为什么抽象类参与继承体系这件事比能否实例化更值得关注。在实际业务中抽象类最常见的用法是模板方法模式——父类定义好流程骨架普通方法把某些具体步骤留给子类去实现抽象方法。这时候类不仅仅是对象的模板它还是家族祖辈的规矩制定者。这个层级递进也是普通类和抽象类在面向对象设计里的不同站位。4.5 类加载与类初始化什么时候类会被激活热词里有类加载这是我前文提过但没有完全展开的部分。它和类与对象的关系在于你常听到的静态代码块哪个先执行子类和父类静态块顺序这类问题考察的就是类加载时机的理解。类加载时机我记得很清楚下面这几个场景会触发类的初始化new 一个类的实例对象访问或者赋值类的静态变量注意用 final 修饰的编译期常量除外调用类的静态方法使用反射比如Class.forName(Person)初始化一个类时如果它的父类还没初始化会先触发父类初始化这个顺序是面试官最喜欢的连环问。比如问有一个父类 A 和一个子类 B它们都有静态代码块和构造代码块第一次new B()的时候执行的顺序是什么答案应该是A 的静态代码块 - B 的静态代码块 - A 的构造代码块 - A 的构造方法 - B 的构造代码块 - B 的构造方法。这个顺序背后的逻辑是先类加载静态成员属于类后对象创建实例变量属于对象。类只有一份所以静态块只执行一次对象每次 new 都会经历构造流程。把类和对象是两套生命周期这条主线理清了这类顺序题就变得非常朴素。5. 面试场上怎么把这个问题答出层次感5.1 从背定义到讲原理一个递进式的回答思路如果你去面试被问到类与对象的区别我建议你按照下面的层次递进回答而不是只甩一句类是模板对象是实例。第一层先说定义类是对一组具有相同属性和行为的对象的抽象描述是数据类型对象是类的一个具体实例是真实存在于内存中的数据载体。第二层说创建方式类是写代码定义的由编译器编译成 .class 文件再由类加载器加载进 JVM在元空间生成对应的 Class 对象对象通过 new 关键字在堆内存中分配空间并初始化返回给调用者的是一个引用。第三层说内存与生命周期类的 Class 对象全局唯一生命周期伴随 JVM 运行对象可以创建多个生命周期受可达性分析控制不再被引用后会被 GC 回收。第四层说使用方式静态成员直接依附于类通过类名即可访问实例成员必须通过对象引用来访问。如果你能一口气说到第三层面试官基本能确认你不是背题的。如果还能顺手举一个动态代理或者反射的例子说明类在运行期也能被动态生成那这道题的回答质量会相当高。因为这些例子证明了你不只是懂类与对象的静态区别而是理解它们在 JVM 运行时的真实协作关系。这类经验我建议在面试前自己口头多练几遍。很多人心里明白但一紧张就漏到第二层就断了。按照这个递进结构背熟答题的时候会稳得多。5.2 高频追问清单面试官挖坑的四个方向面试官问完类与对象的区别之后通常还有一系列追问。我把常见的追问方向列一下每一个都指向一个具体的知识盲区追问一一个类可以有几个对象多个对象之间共享哪些数据独立哪些数据这个问题的本质是考察静态成员与实例成员的存储位置差异以及对象之间数据隔离的理解。追问二对象相等和引用相等的区别这里要用到和equals的对比。引用相等比较的是栈上的地址值对象相等比较的是堆上对象的内容默认的 equals 等价于重写之后才能比较内容。这背后恰恰是引用与对象分离这个模型在起作用。追问三构造方法能不能被继承很多人会脱口而出能。准确说是构造方法不会被子类继承但子类构造方法会隐式调用父类无参构造方法super()如果父类只有有参构造子类构造方法必须显式调用。这个追问的考点是对象创建链的完整性。追问四静态方法能不能被重写答案是不能。如果子类定义了一个和父类静态方法签名完全相同的方法那是隐藏而不是重写调用哪个取决于引用变量的编译期类型而不是运行期类型。这个考点实际上还是在区分属于类和属于对象这两种绑定机制。每个追问你都可以把话题绕回到类与对象的关系上因为这些问题本来就是对这一核心关系的多角度考察。5.3 从八股文到实战回答这类问题时的一个额外加分项面试里光把知识点讲对只能算及格想拿高分最好结合业务场景。比如你可以这样说我之前做权限系统的时候用策略模式定义了一个抽象类AbstractAuthStrategy里面用模板方法定义了校验流程具体的 token 解析逻辑交给不同子类去实现。这里抽象类是骨架、子类对象是具体策略。后来需要对某些接口做动态权限增强又用 JDK 动态代理生成了代理对象在 invoke 里统一做了日志记录和异常兜底。这个过程中我明显感觉到类描述规则、对象承载状态、代理类动态生成这三种角色在运行时各司其职。这段话的本质是把类与对象的知识点平移到真实工程里展示你对它的理解不只是停留在语法层面。面试官听到这种回答会认为你是真正用面向对象思想在写代码的。别小看这个加分项在八股文满天飞的年代能把基础概念讲出工程味道的人太少了。6. 结尾一个小技巧与你最后分享一个我自己的体会。教初学者理解类与对象最有效的类比不是图纸与房子而是印章与印痕。图纸和房子的关系容易让人误以为类需要被照搬一次才变成对象但准确地说对象是每一次使用类时产生的独立实体。印章盖下的每一个印痕都是独立的印章本身永远是那个印章印痕却各有归属、早晚会模糊消散。这个类比把类唯一、对象多态、对象有生命周期三个特性一把全说清楚了。还有一个小技巧如果你正在学或者正在复习 Java 基础我建议你养成一个习惯在 IDEA 里用断点调试去观察对象。new 一个对象之后展开调试面板里的对象引用你能看到对象头和实例变量的值用上 JOLJava Object Layout插件还能直接打印出一个对象在内存里占了多少字节对象头里有什么。这种亲眼所见带来的理解比看十篇博客都深刻。类与对象这个话题乍一看是最简单的基础题细究下去却连接着类加载、内存模型、反射、代理、GC 这一整棵 Java 知识树。把这道地基打牢了后面每一层楼都盖得安心。
返回列表