类与对象
Java 初级 · 面向对象
类是「模板」,对象是「实例」。面向对象把数据与操作数据的行为打包在一起。本节不止讲语法,更从 JVM 内存布局讲清:对象 new 出来后在堆里长什么样、this 是谁、static 为什么属于「类」、构造器与代码块按什么顺序执行。这些底层认知,是理解继承、多态、集合的基石。
一、类、对象与 new 的内存真相
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——压栈)。
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+ 为准讲清楚,并单独标注版本差异(面试常考)。
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()会编译报错,需要就自己补。
public class Book {
private String title;
// 写了有参构造后,无参构造不再自动生成
public Book(String title) { this.title = title; }
// 若还想 new Book() 不报错,需手动补:
public Book() { this.title = "未命名"; }
}构造器链:子类如何用 super 调父类构造
this(...) 可在构造方法里调用同类其他构造;super(...) 调用父类构造。子类构造第一行隐式或显式调用 super(),确保父类先初始化好——因为子类对象里本身就包含一整块「父类部分」的字段。
五、代码块的执行顺序(面试高频)
一个对象「诞生」时,各类初始化按固定顺序发生:
规则总结:
- 类级别:
static字段赋值 +static {}块,类第一次被使用时执行一次(且先于任何对象)。 - 实例级别:
实例字段赋值+实例 {}块,按代码书写顺序,在构造方法体之前执行。 - 有继承时,先父后子(父类完全就绪,子类才建)。
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 决定。
八、最佳实践
- 字段私有化,通过
getter/setter控制读写,必要时做校验(防御非法状态)。 - 一个类只描述一类事物,保持高内聚。
- 构造方法参数过多时,考虑链式
setter或构建器(Builder)模式,避免「 telescoping constructors(叠罗汉式重载构造)」。 - 工具方法、常量用
static;实例相关状态用非static。 - 理解初始化顺序,避免在字段未就绪时就被使用(尤其继承场景)。
📚 官方文档与参考资料
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
- Java 教程 · 类与对象:https://docs.oracle.com/javase/tutorial/java/javaOO/index.html
- OpenJDK 项目:https://openjdk.org/
✏️ 练习与自测
- 定义
Book类,含私有字段title、price及有参构造、getter;new两个对象打印信息。用javap -c看构造方法里aload_0(this)如何给字段赋值。 - 在
Book中加static字段count记录创建总数,每次构造自增;对比「实例字段」和「static 字段」在多个对象间的值差异(画图说明共享性)。 - 故意定义有参构造后不写无参构造,观察
new Book()是否报错,理解默认构造规则。 - 写一个包含
static{}、实例{}、构造方法体的类,依次new两次,记录输出顺序,验证「静态块只一次、实例块每次、构造体最后」。
本节小结
new 在堆上分配对象:对象头(Mark Word + 类型指针)+ 实例字段(基本类型存值、引用存地址),栈里只保存引用地址。字段大多 private + getter/setter 实现封装。this 是 JVM 隐式传入的当前对象引用(字节码 aload_0),static 属于类、存于元空间、全类共享且无 this。构造器初始化对象,没写时编译器给无参构造、写了就不再给;初始化顺序为「static 块(一次)→ 实例块/字段赋值 → 构造体」,有继承则先父后子。访问修饰符划定可见性边界,封装从私有化字段开始。