Skip to content

类与对象 ​

Java 初级 · 面向对象

类是「模板」,对象是「实例」。面向对象把数据与操作数据的行为打包在一起。本节不止讲语法,更从 JVM 内存布局讲清:对象 new 出来后在堆里长什么样、this 是谁、static 为什么属于「类」、构造器与代码块按什么顺序执行。这些底层认知,是理解继承、多态、集合的基石。

一、类、对象与 new 的内存真相 ​

java
public class Student {
    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
// 使用
Student s = new Student("张三", 18);

new Student(...) 发生时,JVM 在堆里分配一块内存存放对象实例。一个对象在堆里的结构(以 HotSpot 为例)大致是:

  • 对象头(Object Header):包含 Mark Word(哈希码、GC 分代年龄、锁状态等运行时元数据)和类型指针(klass pointer),指向方法区/元空间里的 Student 类元数据(JVM 由此知道「这是个 Student,有哪些方法」)。
  • 实例数据:你的字段 name(一个引用)、age(一个 int 值)按声明顺序紧凑排列。
  • 对齐填充:HotSpot 要求对象大小为 8 字节整数倍,不足则补齐。

而栈里的局部变量 s 只保存这个对象的地址,并不装着对象本身。这也解释了上一节「引用类型传地址副本」——方法拿到 s,拿到的就是这串地址的副本。

为什么 new 一定要堆?

对象可能很大、生命周期不确定、要被多个引用共享,堆正好提供「按需分配、GC 自动回收」的能力。而局部基本类型放栈,随方法结束自动销毁,高效但不适合长生命周期的复杂数据。这种「栈管短命小数据、堆管对象」的分工,是 JVM 内存模型的核心。

二、this:当前对象的引用 ​

this 不是关键字魔法,它就是一个隐式参数:每个实例方法被调用时,JVM 自动把「当前对象」作为 this 传进去(字节码里是 aload_0,把第 0 个局部变量槽——也就是 this——压栈)。

java
public Student(String name, int age) {
    this.name = name;   // this.name 指「字段」,name 指「参数」
    this.age = age;
}

为什么需要 this?当参数名与字段名冲突时,this.字段 明确指向对象自己的字段。字节码上,this.sound() 就是一次普通的 invokevirtual,只是目标对象是 this。

static 方法里没有 this

static 属于类、不依赖任何对象,调用时根本没传 this。所以在 static 方法里访问 this 或非 static 字段/方法,编译器直接报错。判定一条语句能不能用 this,先问「此刻有没有对象」。

三、static:类级存储,只有一份 ​

static 字段/方法属于类本身,不属于某个对象。全类只有一份、所有实例共享——这一点在所有 JDK 版本都成立,也是 static 最本质、最该记住的特征。

但「这一份数据到底存在哪块内存」,不同 JDK 版本的实现并不一样。很多老教程说的"static 存在方法区/永久代"已经过时,下面以 JDK 8+ 为准讲清楚,并单独标注版本差异(面试常考)。

java
public class Counter {
    private static int total = 0;   // 类级,所有 Counter 实例共享
    private int id;                 // 实例级,每个对象各一份

    public Counter() {
        id = ++total;               // 每 new 一个,total 自增
    }
    public int getId() { return id; }
}

static 数据到底存在哪?(JDK 8+ 为准) ​

一句话:JDK 8 及之后,static 字段的值随 java.lang.Class 对象一起存放在堆(Heap)里;元空间(Metaspace)只放类的元数据,不放静态变量的值。

  • 堆里的 Class 对象:每个被加载的类在 JVM 中都会生成一个 java.lang.Class 实例,它和其它普通对象一样分配在堆中。static 字段的最终"值"(上图的 total = 2)就作为这个 Class 对象的一部分被存放。所以访问 Counter.total 时,JVM 找到 Counter 的 Class 对象、读出它内部的那个 total 字段即可——根本不需要任何 Counter 实例。
  • 元空间(Metaspace):JDK 8 起取代永久代,使用本地内存(Native Memory)。它存放的是类的元数据:Klass 结构体、方法字节码、字段的布局与偏移量、运行时常量池等——也就是"这个类长什么样"的描述信息,不包含静态变量的运行时取值。

JDK 7 及以前的版本差异(面试常考)

  • JDK 7 及以前:方法区由**永久代(PermGen,非堆内存)**实现,static 字段的值就存在永久代里,和 Class 对象"绑"在一起。永久代大小由 -XX:MaxPermSize 限制,容易触发 java.lang.OutOfMemoryError: PermGen space。
  • JDK 8 起:永久代被彻底移除,改为元空间(Metaspace);同时 java.lang.Class 对象(连带 static 字段的值)也从方法区搬到了堆。因此今天说"static 存在方法区/永久代"是过时说法;准确说法是:「静态变量的值在堆里的 Class 对象中,类的元数据在元空间」。
  • 一句话记忆:永久代 = 类的定义 + 静态值(老);元空间 = 只放类的定义(新),静态值跟着 Class 对象进了堆(新)。
  • 注意「方法区」是 JVM 规范里的逻辑概念,并不等于某个具体内存区域;它的 HotSpot 实现从「永久代」变成了「元空间」,但规范本身始终存在。
  • static 字段在类加载的「准备+初始化」阶段分配并赋值,早于任何对象创建。
  • 访问方式:类名.static成员(推荐)、对象.static成员(不推荐,易误导)。
  • static 方法不能访问非 static 成员——因为它没有 this 去定位「哪个对象」的字段。

什么该用 static?

工具方法(Math.max)、常量(public static final)、真正「全类共享且独立于对象」的状态(如全局计数器、配置)才用 static。不要把本该属于对象的业务状态标成 static,否则所有对象会互相污染数据。

四、构造方法:初始化的入口 ​

构造方法与类同名、无返回类型,new 时由 JVM 自动调用,用于把对象初始化成合法状态。

  • 若你没写任何构造方法,编译器自动给一个无参构造(默认把字段置零值)。
  • 一旦你定义了任意构造方法,编译器不再生成无参构造——此时 new Student() 会编译报错,需要就自己补。
java
public class Book {
    private String title;
    // 写了有参构造后,无参构造不再自动生成
    public Book(String title) { this.title = title; }
    // 若还想 new Book() 不报错,需手动补:
    public Book() { this.title = "未命名"; }
}

构造器链:子类如何用 super 调父类构造 ​

this(...) 可在构造方法里调用同类其他构造;super(...) 调用父类构造。子类构造第一行隐式或显式调用 super(),确保父类先初始化好——因为子类对象里本身就包含一整块「父类部分」的字段。

五、代码块的执行顺序(面试高频) ​

一个对象「诞生」时,各类初始化按固定顺序发生:

规则总结:

  1. 类级别:static 字段赋值 + static {} 块,类第一次被使用时执行一次(且先于任何对象)。
  2. 实例级别:实例字段赋值 + 实例 {} 块,按代码书写顺序,在构造方法体之前执行。
  3. 有继承时,先父后子(父类完全就绪,子类才建)。
java
public class Demo {
    static { System.out.println("静态块(类加载一次)"); }
    { System.out.println("实例块(每次 new)"); }
    public Demo() { System.out.println("构造方法体"); }
}
// new Demo() 输出顺序: 静态块 → 实例块 → 构造方法体

构造器里的「重写陷阱」

在父类构造方法里调用一个被子类重写的方法,非常危险:此时子类对象还没完全构造好,字段可能还是零值,子类方法看到的却是「半成品对象」。所以构造器里尽量只调用 private/final 方法,避免调用可重写的实例方法。

六、访问修饰符:可见性边界 ​

修饰符同类同包子类(不同包)任意
private✅❌❌❌
(默认/包私有)✅✅❌❌
protected✅✅✅❌
public✅✅✅✅

封装的第一步就是字段尽量 private,通过 getter/setter 受控暴露。这样你能在 setter 里加校验(如 age 不能为负),外界不能随意破坏对象内部一致性。

七、常见误区与注意点 ​

WARNING

  • 忘写无参构造:一旦定义了有参构造,无参构造不再自动生成,new X() 报错,需要就手动补。
  • static 里用 this:static 无当前对象,不能访问 this 或非 static 成员。
  • 构造器调用可重写方法:子类初始化时可能触发意料之外的行为,谨慎。
  • 过度 public:所有字段设 public 破坏封装,应优先 private + getter/setter。
  • 误以为对象「在栈里」:对象在堆,栈里只存引用地址。s = null 只是断开引用,对象何时回收由 GC 决定。

八、最佳实践 ​

  1. 字段私有化,通过 getter/setter 控制读写,必要时做校验(防御非法状态)。
  2. 一个类只描述一类事物,保持高内聚。
  3. 构造方法参数过多时,考虑链式 setter 或构建器(Builder)模式,避免「 telescoping constructors(叠罗汉式重载构造)」。
  4. 工具方法、常量用 static;实例相关状态用非 static。
  5. 理解初始化顺序,避免在字段未就绪时就被使用(尤其继承场景)。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 定义 Book 类,含私有字段 title、price 及有参构造、getter;new 两个对象打印信息。用 javap -c 看构造方法里 aload_0(this)如何给字段赋值。
  2. 在 Book 中加 static 字段 count 记录创建总数,每次构造自增;对比「实例字段」和「static 字段」在多个对象间的值差异(画图说明共享性)。
  3. 故意定义有参构造后不写无参构造,观察 new Book() 是否报错,理解默认构造规则。
  4. 写一个包含 static{}、实例{}、构造方法体的类,依次 new 两次,记录输出顺序,验证「静态块只一次、实例块每次、构造体最后」。

本节小结

new 在堆上分配对象:对象头(Mark Word + 类型指针)+ 实例字段(基本类型存值、引用存地址),栈里只保存引用地址。字段大多 private + getter/setter 实现封装。this 是 JVM 隐式传入的当前对象引用(字节码 aload_0),static 属于类、存于元空间、全类共享且无 this。构造器初始化对象,没写时编译器给无参构造、写了就不再给;初始化顺序为「static 块(一次)→ 实例块/字段赋值 → 构造体」,有继承则先父后子。访问修饰符划定可见性边界,封装从私有化字段开始。