ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java类与对象:从JVM加载到new实例的底层全程解析

Java类与对象:从JVM加载到new实例的底层全程解析 我面试过不少Java候选人只要简历写了熟悉Java基础我几乎必问一个问题说说类和对象的区别。得到的答案出奇一致类是一个模板对象是模板创建出来的实例。这句话不能算错但也就值个及格分。因为只说到这一层说明你还是停留在教科书定义上没有真正理解Java运行时里类和对象分别是什么、做了什么、为什么这样设计。这篇文章我想换个讲法从三个层次拆开类从JVM视角看new一个对象发生了什么再把面向对象、抽象类接口、对象比较和拷贝这些高频面试点串进去。适合两类人刚学完Java语法的小白以及准备面试想系统过一遍类和对象知识点的同学。1. 类与对象的关系比模板和实例复杂一层1.1 三个层次的类源码、Class对象、实例对象先给结论类这个词在源代码层面、JVM运行时层面、以及你new出来的实例层面含义是不一样的。大部分人理解的类就是模板说的是第一层而面试官想听的往往是第二层和第三层的关系。第一层源码的类。你写下public class User { ... }这是一段静态的源代码文本描述了字段、方法、构造器。它不占运行内存只有信息没有状态。就像一张设计图。第二层运行时的Class对象。JVM第一次用到User类时会把User.class字节码加载进来经过校验、准备、解析最终在元空间里生成一个java.lang.Class的实例。这个实例代表的就是User类本身它记录了User有哪些字段、哪些方法、父类是谁、实现了什么接口。注意Class本身也是一个对象只是它的类是java.lang.Class。这一层是理解反射、类加载机制的关键但很多教材都讲得很少。第三层实例对象。new User()得到的对象是堆内存里真实的一块区域里面存着User各个字段的具体值。同一个User类你可以new一万个实例每个实例的字段值互相独立但这一万个实例对应的是同一个Class对象。这三层的关系可以这样记源码类是被编译进字节码的图纸Class对象是JVM加载图纸后生成的产品说明书而new出来的实例才是按照说明书批量生产的实物。你平时操作的大多是第三层但面试和调优时第二层往往才是关键。1.2 用代码看清Class对象和实例对象的区别说了这么多不如直接看段代码。public class User { private String name; public User(String name) { this.name name; } public void sayHello() { System.out.println(Hello, name); } }public class Main { public static void main(String[] args) throws Exception { User user1 new User(张三); User user2 new User(李四); // 两个实例对象的Class对象是同一个 System.out.println(user1.getClass() user2.getClass()); // true // 两个实例对象本身不相等 System.out.println(user1 user2); // false // 反射方式拿到的Class对象也是同一个 Class? clazz Class.forName(com.example.User); System.out.println(user1.getClass() clazz); // true } }getClass()返回的就是第二层的Class对象。所以类的实例是对象这句话没错但Class对象本身也是对象。很多人忽略这层导致后面学反射的时候一脸懵为什么Class.forName能拿到一个类因为它拿到的就是JVM里那个唯一的Class对象。同一类加载器下一个类只对应一个Class对象所以你用两种方式去取得到的结果是相等的。1.3 为什么对象是类的实例这句话不够准确如果你把类理解成源代码那对象是类的实例确实没什么问题。但严格来说源代码不会被执行执行的是Class对象。JVM在看到new User()指令时是拿着Class对象当蓝本在堆上分配实例内存、填充字段、调用构造器。所以更准确的说法是实例对象是运行时Class对象的具体表现。这句话不是抠字眼。它解释了几个常见现象为什么静态变量不属于任何实例却能所有实例共享因为静态变量就存放在类对象里所有实例都指向同一个Class对象。为什么反射能动态创建对象因为反射本质上就是拿着Class对象去叫醒构造器。为什么同一个类用不同类加载器加载实例之间用instanceof都不一定成立因为Class对象不是同一个。类加载机制相关的内容正好就和下一个问题衔接每次new对象时JVM到底在做什么。2. new一个对象时JVM在背后做的四件事new对象这句话我们天天说但new这个关键字背后不是一个简单的内存分配至少包含类加载、内存分配、字段初始化、构造器调用四步。我拆开讲。2.1 类加载从字节码到Class对象如果这个类之前没有被使用过new会先触发类加载。类加载分为三个阶段加载、连接、初始化。加载类加载器根据类的全限定名找到对应的.class文件读进来生成Class对象。连接又分成三步。验证检查字节码是否合法准备给类的静态变量分配内存并设置默认值注意这里只是默认值不是代码里写的初始值解析把类、方法、接口的符号引用替换成直接引用。初始化执行静态代码块和静态变量的赋值语句。关键点在于这个初始化只执行一次而且是懒触发的——第一次主动用到这个类时才会执行。很多人背过静态块只执行一次但不知道为什么。现在可以顺着链路看类初始化是类加载过程的最后一步类加载一次初始化自然也就一次。2.2 内存分配对象放在哪里长什么样类确认可用之后JVM在堆上给新对象分配内存。这里有个冷知识对象在堆里的内存布局分为三块——对象头、实例数据、对齐填充。对象头存GC标记、锁信息、哈希码以及指向Class对象的指针。实例数据各个字段的实际值包括父类字段。对齐填充为了满足内存对齐规则补的空白。在分配之前JVM其实已经把这块内存里的字段值全部设成了默认零值——引用类型是nullint是0boolean是falsedouble是0.0。也就是说字段的默认值初始化发生在构造器调用之前发生在你给字段赋初始值之前。后面有一道初始化顺序题考的就是这个机制。2.3 初始化顺序静态块、实例块、构造器的执行次序不看代码讲不清直接上一个经典例子。class Parent { static { System.out.println(Parent 静态块); } { System.out.println(Parent 实例块); } public Parent() { System.out.println(Parent 构造器); } } class Child extends Parent { static { System.out.println(Child 静态块); } { System.out.println(Child 实例块); } public Child() { System.out.println(Child 构造器); } }执行new Child()输出顺序是Parent 静态块 Child 静态块 Parent 实例块 Parent 构造器 Child 实例块 Child 构造器规律拆开看就三条静态部分只执行一次而且父类静态先于子类静态。这符合类加载顺序用到一个类必须先加载它的父类。实例块和字段赋值语句在每次new之前执行并且先于构造器。子类构造器第一行会隐式调用super()所以父类的实例块、构造器会先跑。这个顺序在笔试里经常变形考比如把实例块换成字段赋值问输出结果。只要记住父类静态 - 子类静态 - 父类实例块/字段赋值 - 父类构造器 - 子类实例块/字段赋值 - 子类构造器这条链路基本不会错。2.4 一道升级版初始化题的完整复盘再进一步我把静态变量赋值和实例变量赋值混进去写个稍微难一点的class Base { static int x initX(); static { System.out.println(Base static block); } int y initY(); Base() { System.out.println(Base constructor, y y); } static int initX() { System.out.println(Base initX); return 1; } int initY() { System.out.println(Base initY); return 2; } } class Derived extends Base { static int x initX2(); static { System.out.println(Derived static block); } int y initY2(); Derived() { System.out.println(Derived constructor, y y); } static int initX2() { System.out.println(Derived initX2); return 3; } int initY2() { System.out.println(Derived initY2); return 4; } }执行new Derived()的输出是Base initX Base static block Derived initX2 Derived static block Base initY Base constructor, y2 Derived initY2 Derived constructor, y4注意两个细节静态变量赋值语句和静态块的执行顺序取决于它们在源码里的先后顺序实例变量赋值和实例块同理。另外字段的显式赋值发生在构造器调用之前所以构造器里看到字段已经不是默认值了。这道题拿去考实习生能筛掉一大半只会写循环的简历。3. 类成员设计字段、构造器、方法的细节决定质量类和对象不只是概念更多体现在你怎么设计类的成员。这里说几个写代码时最容易出问题的地方。3.1 成员变量与局部变量不止作用域的区别字段成员变量和局部变量教科书会告诉你作用域不同字段属于对象局部变量属于方法。但还有一个隐藏区别字段有默认值局部变量没有。public class Demo { private int count; // 默认值0 public void test() { int x; // 没赋值不能用编译报错 System.out.println(x); } }JVM在对象分配时就把字段清零了所以字段即使不初始化也能读读到的是默认值。局部变量在栈帧里创建时不会主动清零所以编译器干脆强制你必须显式初始化否则直接编译失败。这种设计是为了逼你写明确意图而不是放任未初始化的变量乱跑。实际开发里我见过有人图省事不初始化字段后来排查了半天才发现读到的默认值。建议所有字段都显式给初始值要么在声明处要么在构造器里。3.2 构造器要不要显式声明该怎么重载如果你不写任何构造器编译器会给一个无参构造器。但一旦你写了带参构造器无参构造器就没了此时再new User()就会编译失败。这是新手最常踩的坑之一。构造器重载要注意一点多个构造器之间如果存在重复逻辑用this(...)复用不要复制粘贴。比如public User(String name) { this(name, 0); } public User(String name, int age) { this.name name; this.age age; }还有一个很多人不知道的坑不要在构造器里调用可重写的方法。如果构造器里调用了sayHello()而子类重写了sayHello()那么父类构造器执行期间子类的字段还没初始化全是默认值重写的方法一旦被调用可能读到一堆空值。这属于过早暴露对象的问题开发中很容易制造诡异bug。3.3 重载与重写方法的签名是分水岭重载overload是同一个类里方法名相同、参数列表不同重写override是父子类之间方法签名完全一致。判断标准只有一个参数列表。返回类型、访问修饰符都不参与方法签名。重载有一个经典面试陷阱public class OverloadTest { public void print(String s) { System.out.println(String); } public void print(Integer i) { System.out.println(Integer); } public void print(Object o) { System.out.println(Object); } }调用print(null)会发生什么答案是编译报错因为null既可以是String也可以是Integer编译器无法确定调用哪个。如果去掉Integer那个重载print(null)会调用String版本因为String比Object更具体。这个题看似无聊但确实能看出来一个人对方法重载规则的理解深度。实际编码里的教训是不要写参数类型模糊的重载尤其是不要混着用String和包装类型做重载极易引发歧义。重写这边建议重写时加Override注解。它不仅是注释编译器会帮你校验方法签名是否真的覆盖了父类方法。如果你把参数写错没加注解编译也能过——那就变成一个新的重载方法程序行为和你预期的完全不一样这种bug很难查。3.4 static成员真正属于类的东西static修饰的成员从类和对象的角度看最特殊它不属于任何一个实例而是存储在Class对象里。所以静态字段是所有实例共享的任何一个实例修改它其他实例都能看到。静态方法里不能直接访问实例字段和实例方法因为静态方法执行时可能压根没有实例存在。静态方法没有this因为this指向的是当前实例。如果你在静态方法里想调用本类的实例方法得先new一个对象或者接收一个实例参数。最常见的例子就是工具类——比如StringUtils、Collections它们不需要状态全部用静态方法实现。再说一个考点延伸静态内部类和内部类是两回事。静态内部类不持有外部类实例的引用所以可以独立创建非静态内部类必须依赖一个外部类实例而且会自动持有外部类的引用这也是为什么非静态内部类容易造成内存泄漏。写内部类或lambda时如果捕获了外部变量这个变量必须是final或effectively final——也就是说你不能再对捕获的变量重新赋值。刚学lambda的人在这个问题上卡住的特别多。4. 从类和对象的角度理解封装、继承与多态面向对象三大特性每个都能落到类和对象的具体设计上。这里不背定义只讲我在实际项目里怎么用以及为什么这么设计。4.1 封装私有字段不是形式主义封装的第一步就是字段私有化。为什么不能public因为public字段等于把对象内部的约束完全暴露给外部任何一段调用代码都能把字段改成非法值。举个经典例子。假设订单类有个状态字段public class Order { public int status; // 0-待支付 1-已支付 2-已发货 }如果status是public外部代码直接order.status 99订单就处于一个没有任何业务含义的状态。等到后面统计报表、做对账的时候这些脏数据会让你欲哭无泪。封装的做法是public class Order { private int status; public void pay() { // 业务校验再改状态 this.status 1; } public int getStatus() { return status; } }外部只能通过pay()这样的业务方法去修改状态校验逻辑收在类内部。这才是封装的意义类负责维护自己的状态一致性外界不需要知道内部实现。所以评判一个类封装得好不好不是看它有没有写getter/setter而是看外部操作它的方式是不是都被设计成了有意义的行为。4.2 继承什么时候该用什么时候该放弃继承表达的是is-a关系子类是父类的一种。判断标准很简单如果你说不清子类确实是父类的一种这句话就别用继承。比如圆形继承形状合理猫继承动物合理但数据库连接池继承配置文件就非常牵强那叫使用了配置应该用组合。继承最大的隐患是脆弱基类问题子类依赖父类的实现细节一旦父类修改了内部逻辑子类行为可能悄悄变化。比如我见过一个项目父类BaseService里加了个日志字段结果好几个子类的序列化行为全变了排查了很久。所以在实际的业务代码里组合优先于继承。组合的意思是持有对方的引用通过调用对方的方法来复用能力。比如public class OrderService { private final OrderRepository orderRepository; private final StockService stockService; public OrderService(OrderRepository orderRepository, StockService stockService) { this.orderRepository orderRepository; this.stockService stockService; } }OrderService有仓库和库存服务而不是继承它们。组合的优点是职责清晰、依赖明确、好测试——构造器里注入依赖测试时直接传mock对象。如果你看到一个新项目里到处是五层以上的继承链基本可以判断是设计失控的信号。4.3 多态为什么父类引用能调用子类方法多态的底层是方法重写加动态绑定。看这段代码public class Animal { public void speak() { System.out.println(Animal speak); } } public class Dog extends Animal { Override public void speak() { System.out.println(Dog speak); } } Animal animal new Dog(); animal.speak(); // 输出 Dog speakanimal的编译类型是Animal运行类型是Dog。JVM在运行时根据实际对象类型动态找到Dog类重写的speak()并调用。这个过程叫虚方法调用是Java实现多态的核心机制。但有几个方法不参与多态静态方法、私有方法、final方法。静态方法是跟着类走的animal.speak()如果speak是static它调用的是Animal里的版本私有方法是子类看不见的不存在重写final方法直接被禁止重写。这也是为什么我在前面强调判断一个方法是否属于某个对象先看它是不是static。多态最大的价值是面向抽象编程。写代码时依赖抽象类或接口运行时替换具体实现这样新增一种类型就不需要改调用方代码。比如支付场景顶层定义一个PayService接口支付宝、微信各实现一份业务代码只管调payService.pay(order)完全不知道底层是哪个渠道。5. 抽象类与接口类设计的岔路口抽象类和接口的选择几乎是Java面试必问的点也是很多人实际设计时纠结最多的地方。5.1 语法层面的六点差异先看一张对比表把硬性差异列清楚。维度抽象类接口实例化不能实例化不能实例化构造器可以有不能有字段可以有实例字段字段只能是public static final常量方法可以有抽象方法也可以有普通方法抽象方法Java 8以后有default/static方法继承限制一个类只能extends一个抽象类一个类可以实现多个接口访问修饰符方法可以是任意访问级别方法默认public这里顺带回答一个常被问到的点抽象类和普通类的区别。普通类可以实例化所有方法都有方法体抽象类不能实例化可以包含没有方法体的抽象方法子类必须实现这些抽象方法。如果一个类里没有任何抽象方法但你又不想让它被实例化可以声明成抽象类不过这种情况很少见一般说明设计有问题。5.2 设计层面的选择标准语法表背下来不难难的是设计时怎么选。我的原则很简单当你有公共状态和公共逻辑要复用并且这些类之间确实是is-a关系用抽象类。当你只是描述一个能力谁都可以实现用接口。当两者都合适时优先接口因为接口更灵活可以多实现也更容易写mock做单元测试。说得直白点抽象类适合当模板基类把一类事物的公共骨架搭好接口适合当能力约定只管说要干什么不管怎么干。5.3 一个订单系统的例子我举个例子。假设现在要开发一套订单处理流程不同渠道的订单处理步骤不一样但都有校验订单、计算金额、保存订单、发送通知四步只有计算金额和发送通知差别很大。这时可以写一个抽象基类public abstract class AbstractOrderHandler { public final void handle(Order order) { validate(order); BigDecimal amount calculateAmount(order); save(order); notify(order); } private void validate(Order order) { // 公共校验逻辑 } private void save(Order order) { // 公共保存逻辑 } protected abstract BigDecimal calculateAmount(Order order); protected abstract void notify(Order order); }handle方法已经定好整个流程骨架子类只需要实现两个抽象方法。这就是模板方法模式抽象类最典型的使用场景。而如果只是想抽象可支付这个能力让不同渠道的支付类都能接入那就用接口public interface Payable { boolean pay(Order order); }支付宝、微信、货到付款各自实现Payable业务系统只面向接口编程。注意代码里handle是final的这是刻意设计——流程骨架不允许子类重写否则子类乱改顺序公共逻辑就失控了。6. 对象比较与拷贝最容易被忽视的暗坑类写好了对象new出来了接下来最常遇到的就是对象怎么比较、怎么拷贝。这两块坑特别多而且直接影响线上bug。6.1 equals与hashCode的契约对象比较比较的是引用地址两个对象内容一样但地址不同就是false。要比较内容得用equals。但Object的equals默认也是所以如果你不重写它比的内容和一模一样。重写equals时必须遵守和hashCode的契约两个对象如果equals返回truehashCode必须相等。反过来不要求。这个契约的意义在于hash集合比如HashMap它定位key先算hashCode再通过equals确认是否同一个key。如果你只重写了equals而没重写hashCode两个内容相同的对象hashCode不同HashMap就会把它们当成两个key读到完全错误的数据。实际开发中重写equals/hashCode的类主要是值对象比如金额、坐标、身份证号这类。业务实体一般用id判断相等性没必要整对象比较。顺手提一句Objects.equals(a, b)可以处理null安全比a.equals(b)省心很多。6.2 浅拷贝与深拷贝clone方法的一个大坑Java的clone机制很容易让人误解。Object.clone()默认只做浅拷贝也就是只复制基本类型字段和引用本身引用指向的对象是共享的。看这段public class Address implements Cloneable { private String city; // 省略getter/setter } public class Person implements Cloneable { private String name; private Address address; // 省略getter/setter Override public Person clone() throws CloneNotSupportedException { return (Person) super.clone(); } }Person的clone只复制了name的引用和address的引用。结果就是克隆出来的person和原person的address指向同一个Address对象。你改克隆体的城市原对象的城市也跟着变这就是浅拷贝的经典事故。要深拷贝必须把引用的对象也复制一份Override public Person clone() throws CloneNotSupportedException { Person p (Person) super.clone(); p.address this.address.clone(); // 关键 return p; }而且Address也得实现Cloneable并重写clone()层数深了之后这种手写深拷贝会非常痛苦每一层都要处理漏一层就是线上bug。6.3 开发中实用的深拷贝方案对比除了手写clone还有几种常见方案各有取舍拷贝构造器比如new Person(existingPerson)在构造器里逐个字段赋值引用对象用new再建一遍。代码直观但同样要自己处理嵌套。序列化深拷贝把对象序列化成字节数组再反序列化回来。对复杂对象图很省事但要求所有相关类都实现Serializable性能差而且现在Java的序列化机制本身有安全风险不建议新项目用。JSON序列化用Jackson或Gson把对象转成JSON字符串再转回对象。最省心不用给每个类加标记接口但有一定性能开销而且对循环引用、某些类型如LocalDateTime的默认配置会有坑。我个人在新项目里基本不用Cloneable它本身是个没有任何方法的标记接口设计得就比较鸡肋。需要深拷贝时优先考虑JSON方案或者干脆把这种转换逻辑写清楚点的手动拷贝。性能敏感的场景再考虑别的方案但前提一定是先把正确性验证明白。最后再分享一个小经验写自定义类时toString一定要重写。别嫌麻烦日志里打印对象全靠它。没有toString的话你看到的是一串com.example.User1a2b3c4排查问题时毫无信息量。把关键字段拼进去调试效率能高出一截。这几个操作看着不起眼但都是我在实际项目里被坑过之后才养成的习惯写类和对象时顺手做掉后面能省很多事。
返回列表