类和对象这四个字,几乎出现在每一本Java入门教材的前几章,也劝退过不少刚动手的新手。尤其是第一次看到“万物皆对象”这句话,很多人会愣一下:对象是什么?我为什么要new一个对象?new出来的东西到底放在哪?其实用不着把概念想得那么玄。类就是图纸,对象就是按照图纸造出来的实物。房子不是户型图,对象也不是类,这个区别一旦建立起来,后面学继承、多态、集合都会顺很多。这篇内容适合两类人看:一是完全零基础、刚装好JDK还没跑通第一个Demo的新手;二是学过一遍但总觉得“类和对象”很虚、不知道自己写的代码每一步在干什么的初学者。我尽量不用教材腔,直接照着写代码的角度把这件事讲透。
1. 类和对象到底是什么——先从生活经验建立画面感
1.1 图纸与实物:最朴素也最准确的类比
你去售楼处看房,销售递过来的户型图是一张纸,上面画着几室几厅、标注了尺寸和朝向。你不能住进户型图里,你能住的是按这张图盖出来的房子。户型图规定的是一类房子该有的属性——面积、房间数、朝向,也规定了这类房子能干什么——住人、做饭、晾衣服,但它本身不是房子。
把这张图平移过来:类就是户型图,对象就是按图盖出来的那一套具体的房子。同一个户型可以复制到不同楼栋、不同楼层,盖出无数套房子;同一个类也可以被new出来好多个对象,每个对象的成员变量可以不一样,但它们都拥有类里定义的结构和行为。
写Java的时候这种感觉特别明显。你定义一个Student类,它只存在于代码里,是静态的模板;运行起来之后,new Student()才会在内存里真正创建出一个具体的学生对象,这个对象有自己的姓名、年龄、学号。类负责“定义有什么、能干什么”,对象负责“具体是谁、具体值是多少”。
1.2 为什么Java把类和对象当成地基来设计
有人会问:我不学这些概念,直接用变量和数组写程序不行吗?对于几十行的练习Demo,确实能跑。但一旦程序变大,问题就来了。
举个例子。没有类的时候,你想描述一个学生,得维护三个独立的数组:一个存名字,一个存年龄,一个存学号。你想打印某个学生的信息,得同时按下标去取三个数组里的值。如果学生数量变成几百个,还得再维护成绩、班级、联系方式……每个新属性都要新增一个数组,每一次操作都要在所有数组之间来回跳。这样的代码写出来,自己三天后看都会头晕。
类解决的就是这个问题:把“一个学生相关的数据”和“这个学生能提供的操作”绑在一起。外部程序只需要面向Student这个类型操作,不需要关心内部到底有哪几个数组、怎么组装信息。这就是面向对象最朴素的工程价值——内聚、复用、分工明确。
所以Java把类作为组织代码的基本单位不是偶然的,它的一切语法设计——成员变量、方法、构造器、继承、接口、多态,全都是围绕“把类型设计和对象使用分开”展开的。类和对象这一课如果基础不牢,后面越学越吃力。
1.3 类与对象的关系速查
| 对比项 | 类 | 对象 |
|---|---|---|
| 角色定位 | 模板、蓝图、抽象概念 | 具体实例、真实存在的个体 |
| 定义次数 | 在代码里定义一次 | 可以被new出任意多个 |
| 内存位置 | 类模板信息加载到方法区(JDK8之后对应元空间) | 对象实例存放在堆内存 |
| 用代码表示 | class Student {} | new Student() |
| 是否可独立操作数据 | 不能,类本身不是数据载体 | 能,每个对象持有自己的数据 |
这张表不用死记,写代码的时候脑子里过一遍就行。重要的是形成习惯:看到类名,想的是“这是一个模板”;看到new,想的是“现在按照模板造实物了”。
2. 动手写一个类——从空类到完整的学生类
2.1 类的三大组成:成员变量、构造器与方法
一个最普通的类,由三部分组成。
成员变量描述“这个类型的对象有什么”。学生有姓名、年龄、学号,那就定义name、age、studentId三个变量。这些变量定义在类体内部、方法外部,叫成员变量,也叫属性、字段。
构造器描述“对象被创建时如何初始化”。它决定了你new Student()的时候,哪些值需要你传进去,哪些值可以做默认处理。
方法描述“这个类型的对象能干什么”。学生能自我介绍,那就写一个introduce()方法:打印姓名和年龄。方法内部可以读取成员变量,也可以修改成员变量。
先看一个最朴素的类骨架:
public class Student { // 1. 成员变量:定义这个类的对象有哪些数据 private String name; private int age; private String studentId; // 2. 构造器:创建对象时的初始化入口 public Student(String name, int age, String studentId) { this.name = name; this.age = age; this.studentId = studentId; } // 3. 方法:定义对象能做什么 public void introduce() { System.out.println("我是" + name + ",今年" + age + "岁"); } }这个类定义完之后,你就可以在别的代码里这样使用它:
Student s = new Student("张三", 20, "2024001"); s.introduce();第一行是“按图纸盖房子”,第二行是“让这栋房子开口说话”。类写一次,你能盖出无数个不同的学生对象。
2.2 访问修饰符怎么选:不要把一切都public
上面例子里的name、age、studentId我都写了private,构造器和introduce()方法写了public。新手第一次看到这些修饰符,最常见的反应是:反正我自己写着玩,全部public不就行了?
短期的练习Demo这么写确实不会出问题,但我还是建议从第一行代码开始就养成private成员变量 + public方法的习惯。原因不光是“这是规范”,而是它真的能帮你少踩坑。
假设你把age写成public,那么任何代码都能直接写stu.age = -30,一个学生年龄变成负三十,程序不会报错,但数据明显不合法。如果改成private,外部就不能直接赋值,必须走setAge(int age)这样的方法。你可以在方法里加一道校验,顺便把这个对象的年龄控制在合理范围内。这不只是语法问题,它是你作为设计者,对数据完整性的一种掌控。
同样的道理也适用于类本身。一个Java文件里可以有多个类,但public类只能有一个,而且文件名必须和这个public类的类名一致。这不是故意刁难,而是让代码文件与对外暴露的类型一一对应,别人拿到项目时能快速找到入口。
2.3 完整Demo:写一个可以运行的Student类
把前面的骨架补全,顺便加一个toString()方法,这样直接打印对象时能看到内容,而不是一串看不懂的内存地址。
public class Student { private String name; private int age; private String studentId; public Student(String name, int age, String studentId) { this.name = name; this.age = age; this.studentId = studentId; } public void introduce() { System.out.println("我叫" + name + ",今年" + age + "岁,学号是" + studentId); } @Override public String toString() { return "Student{name='" + name + "', age=" + age + ", studentId='" + studentId + "'}"; } public static void main(String[] args) { Student stu = new Student("李四", 21, "2024002"); stu.introduce(); System.out.println(stu); } }运行结果:
我叫李四,今年21岁,学号是2024002 Student{name='李四', age=21, studentId='2024002'}注意看main方法里的第一行代码:new Student(...)就是在创建一个对象,stu是一个引用变量,它存的不是对象本身,而是对象在堆内存里的地址。你可以暂时把stu理解成“指向那套房子的门牌号”,拿着门牌号,你才能找到房子、操作房子。
3. 对象是怎么诞生的——new、构造器与内存直觉
3.1 new一个对象到底发生了什么
很多新手以为new Student("张三", 20, "2024001")就是“调用了一个函数,拿到一个结果”。这样说不是全错,但忽略了内存层面的关键动作。一行new背后,至少做了四件事:
- 在堆内存里给新对象分配一块空间。堆是Java运行时存放对象实例的区域,所有
new出来的对象都住在这里。 - 对成员变量做默认初始化。整数默认0,小数默认0.0,布尔默认false,引用类型默认null。
- 执行成员变量定义处的显式赋值。如果你写
private int age = 18;,这一步会把age先设成18。 - 调用构造器,执行构造器里的代码。构造器内可以通过
this.xxx = xxx覆盖刚才的默认值或显示值,完成真正的定制初始化。
顺序很重要。这也是为什么构造器里给成员变量赋值能生效——因为构造器是最后执行的,它有权覆盖之前的所有默认状态。
如果用生活类比:new就是“按图纸动工”。第一步是打地基(分配内存),第二步是按建筑规范上水电(默认初始化),第三步是贴瓷砖刷墙(显式赋值),第四步是业主自己布置家具(构造器定制)。
3.2 构造器的三个关键细节:名字、无返回值、默认构造器
构造器写法和普通方法高度相似,但有三条硬规则,新手最容易栽在第二条上。
第一条:构造器名字必须和类名完全一致。类叫Student,构造器就必须叫Student,大小写都不能错。
第二条:构造器不能有返回值类型,连void都不能写。一旦你写了public void Student(...),Java就不会把它当成构造器,而是当成一个普通的、名字比较奇怪的方法。结果就是你new Student(...)的时候,编译器会告诉你找不到合适的构造器。
第三条:如果你一个构造器都没写,Java会自动送一个无参构造器public Student() {},它什么都不做,只是让你能new出来一个“全部使用默认值”的对象。但只要你手写了任何一个构造器,那个默认的无参构造器就消失了。比如你定义了public Student(String name),那么new Student()就会编译报错。
这也是我经常让初学者做的一道填空练习:定义一个类,只写一个有参构造器,再写main方法尝试new一个无参对象,亲自看一下报错信息长什么样。看一次报错比背十遍规则都管用。
构造器还支持重载。你可以同时定义无参、两参、三参的构造器,调用的时候Java会根据你传入的参数个数和类型,自动匹配对应版本。重载不是构造器特有的能力,普通方法也能重载,但构造器重载是实际工作中用得最频繁的,因为它能让你灵活控制“创建对象时最低限度需要提供哪些信息”。
3.3 this关键字的真实身份
this是Java新手最早接触、也最容易理解偏的一个关键字。它的含义其实特别朴素:this就是“当前正在调用这个方法的那个对象”。
比如stu.introduce()在执行时,方法里的this就指向stu。如果是s2.introduce(),方法里的this就指向s2。同一个方法定义,被不同对象调用时,this指向不同对象,因此this.name读到的也是不同对象的数据。
this最常用的场景是解决“参数名和成员变量名冲突”。构造器里写this.name = name,左边的this.name指成员变量,右边的name指参数,两边就被清楚地区分开。如果你图省事不写this,直接写name = name,Java会采取就近原则,把这两个name都当成参数自己给自己赋值,成员变量纹丝不动,最后你会看到一个诡异的现象:对象创建成功了,但里面的name还是null。这个坑我见新人踩过太多次,排查半天,结果就是少了一个this。
this还有一个进阶用法:在构造器里调用另一个构造器,比如this(name, 18)就能在一个构造器中调用两参构造器。注意这个调用必须写在构造器第一行,否则编译不过。这个知识点现在可以先记着,后面写复杂类的时候会很顺手。
3.4 对象的生命周期与GC
对象从new的那一行开始出生,之后什么时候“死亡”?答案是:当没有任何引用变量指向它的时候,它就变成了垃圾。
举个例子:
Student s = new Student("张三", 20, "2024001"); s = null;第二行把s置为null,之前new出来的对象就再也没有人能找到了。Java的垃圾回收器(GC)会在合适的时机把它从堆内存里清理掉。你不需要手动释放内存,这是Java比C++省心很多的地方。
但要注意,GC不是立刻执行。所以如果程序在短时间内疯狂创建对象、而且一直持有引用不释放,堆内存就会迅速上涨。这就是内存泄漏风险。类和对象这一课不需要深入垃圾回收算法,但你要建立一条基本意识:对象占堆内存,引用变量占栈内存;引用还在,对象就活着;引用没了,对象迟早被回收。
4. 变量、方法与封装——类和对象绕不开的三个细节
4.1 成员变量与局部变量:作用域、默认值和生命周期
在一个类里面,有些变量定义在方法外面,有些变量定义在方法里面,它们看起来都是变量,实际差别很大。
| 对比项 | 成员变量 | 局部变量 |
|---|---|---|
| 定义位置 | 类体内部、方法外部 | 方法内部或代码块内部 |
| 作用域 | 整个类内可用 | 只在定义它的方法内部可用 |
| 默认值 | 有默认值,int为0,引用类型为null | 没有默认值,不赋值就用会编译报错 |
| 生命周期 | 随对象创建而创建,随对象被回收而销毁 | 方法执行时创建,方法执行完就消失 |
| 存放位置 | 堆内存中(实例成员) | 栈帧中的局部变量表 |
新手最容易忽略的是默认值这条。成员变量即使你不在代码里赋值,Java也会给一个默认值,所以一个没有赋name的Student对象打印出来,name是null而不是报错。但局部变量不一样,你写一个方法:
public void test() { int x; System.out.println(x); // 编译报错:可能尚未初始化变量x }这里编译器会直接拒绝。这是Java的一种友好设计:宁可编译期报错,也不要让你带着不确定的值往下跑。
4.2 static修饰的成员属于类,不属于任何对象
在类和对象的知识体系里,static是个容易混淆的点。一句话概括:static成员属于类本身,在类加载时就存在,被所有对象共享;非static成员属于每一个具体对象,每个对象各有一份。
用代码体会一下:
public class Counter { public static int totalCount = 0; // 类级别计数器 public String name; // 对象级别属性 public Counter(String name) { this.name = name; totalCount++; } }每new一次Counter,totalCount都会加一。不管创建多少个对象,整个程序里只有一份totalCount;而name是每个对象自己持有一份。访问它们的方式也不一样:totalCount用Counter.totalCount直接访问,name必须先有对象,用对象名.name访问。
现在很多初学者会碰到一个标准报错:在static的main方法里直接访问非static成员变量,编译会提示“无法从静态上下文引用非静态成员”。原因是main是类级别的入口,还没创建任何对象,自然访问不到某个具体对象的name。解决办法就是:先在main里创建一个对象,再通过对象去访问。把这个错误想明白,“static”这关就算基本过了。
4.3 private与getter/setter:封装到底封装的是什么
封装是面向对象的三大特性之一,听起来很玄,落到代码上就是一句话:把成员变量用private藏起来,对外提供受控的读写方法。
有很多人认为封装就是“加private,然后IDE一键生成getter和setter”,这是形式,不是目的。封装真正的价值在“读写控制”。
举一个具体场景。学生类的age,如果直接开放public int age,那么程序中任何位置都能随意修改,而且改完没有任何检查。负年龄、1000岁都可以被写进去。但如果走setAge(int age),你就能在方法里做拦截:
public void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("年龄不合法:" + age); } this.age = age; }非法值直接被挡在门外。这就像家里的大门:不是不让外人进,而是进门前要过一道检查登记。开关都握在你手里,想加规则的时候,只需要改一个方法。
同样的道理也适用于读取。你可以在getName()里拼接前缀,可以在getScore()里做数据脱敏,调用方看到的是一个稳定的方法,内部怎么演化,外部无感。这就是封装对工程维护最大的贡献:把变化控制在局部。
5. 实战演示——手写一个学生信息管理Demo
5.1 需求描述与设计思路
理论说了不少,现在用一个小例子把前面的内容串起来。需求很简单:定义一个学生类,包含姓名、年龄、学号和语文、数学、英语三科成绩;提供一个计算平均分的方法,提供一个展示信息的方法;然后在主方法中创建三个学生对象,放进一个数组,依次输出每个人的信息和平均分。
为什么选这个需求?因为它足够小,能看清楚类和对象如何配合,又能覆盖成员变量、构造器、方法调用、对象数组四个核心点。我建议你自己打开IDE先照着敲一遍,然后再看下面的解释。
设计时考虑两点。第一,成绩不应该让外部随意修改,应该用private加getter;但计算平均分的方法可以直接放在Student内部,因为成绩本来就是学生对象自己的一部分,逻辑放这里最自然。第二,三科成绩用三个double变量就够,不需要引入数组,降低初学负担。
5.2 完整可运行的代码
public class Student { private String name; private int age; private String studentId; private double chineseScore; private double mathScore; private double englishScore; public Student(String name, int age, String studentId, double chineseScore, double mathScore, double englishScore) { this.name = name; this.age = age; this.studentId = studentId; this.chineseScore = chineseScore; this.mathScore = mathScore; this.englishScore = englishScore; } public double getAverage() { return (chineseScore + mathScore + englishScore) / 3; } public void showInfo() { System.out.println("姓名:" + name + ",学号:" + studentId + ",平均分:" + String.format("%.2f", getAverage())); } }public class Main { public static void main(String[] args) { Student[] students = new Student[3]; students[0] = new Student("张三", 20, "2024001", 88.0, 90.5, 76.0); students[1] = new Student("李四", 21, "2024002", 66.5, 80.0, 91.0); students[2] = new Student("王五", 19, "2024003", 98.0, 92.0, 85.5); for (int i = 0; i < students.length; i++) { students[i].showInfo(); } } }这段代码有两个文件,Student.java和Main.java。Main类里只有main方法作为程序入口,Student类专心定义学生对象的结构和行为,各司其职。
5.3 运行结果与关键点复盘
运行结果:
姓名:张三,学号:2024001,平均分:84.83 姓名:李四,学号:2024002,平均分:79.17 姓名:王五,学号:2024003,平均分:91.83几个值得留意的细节。
Student[] students = new Student[3];创建了一个长度为3的数组,但数组里此刻还没有任何对象,每个元素都是null。必须逐个new Student(...)之后,数组元素才真正指向有效对象。如果不new就调用students[0].showInfo(),马上就会抛出空指针异常。这是初学者最常见的数组使用误区。
getAverage()是实例方法,它不需要参数,因为三科成绩就存放在当前对象的成员变量里。方法内部直接读chineseScore、mathScore、englishScore即可,这里的“当前对象”就是调用showInfo()时那个具体的学生。
String.format("%.2f", getAverage())只是格式化输出,把平均分保留两位小数,不影响核心逻辑。你可以去掉它直接打印原始值,结果会是一个长长的小数。
把这段代码亲手敲一遍,你会发现类和对象不再是两个抽象名词,而是你每天都在用的写代码方式。
6. 常见问题与排查技巧——新人踩坑实录
6.1 危险的NullPointerException
空指针是Java新手遇到的第一个拦路虎,报错长这样:Exception in thread "main" java.lang.NullPointerException。
它的出现只有一个原因:你在一个null的引用上调用了方法或访问了属性。常见来源有三个:
- 声明了对象变量但忘了
new,直接调用方法。 - 对象数组只创建了数组本身,没有给每个元素new对象。
- 某个方法返回null,调用方没有判空就直接使用。
排查技巧很简单:看异常堆栈里提示的代码行号,定位到那一行,然后检查那一行涉及的引用变量到底是不是null。可以在调用前加一条输出语句打印引用变量,System.out.println(student),如果显示null,说明对象没创建成功。空指针不怕,可怕的是不知道它指向哪一行。
6.2 构造器被误写成方法的编译事故
症状是:你明明定义了一个带参构造器,但new Student("张三", 20)就是报错,说找不到这个构造器。
打开类文件一看,十有八九是下面这种写法:
public void Student(String name, int age) { // 看起来像构造器,其实是普通方法 }多了void,一切都变了。Java认为这是一个返回值类型为void的普通方法,名字叫Student。既然没有真正的带参构造器,new的时候自然匹配不到。解决方法是把构造器原样改回没有返回类型的版本:
public Student(String name, int age) { }判断一个方法到底是构造器还是普通方法,就看两条:名字和类名完全一致,且没有任何返回类型声明(void也不行)。
6.3 this遗漏导致的属性赋值失败
很多新手写构造器时图省事:
public Student(String name, int age) { name = name; age = age; }这段代码编译完全没问题,但运行之后,对象的name和age还是默认值,null和0。
原因前面讲过:构造器参数和成员变量同名时,Java就近匹配,name = name两个都指参数,自己给自己赋值,白干一场。解决方式有两种:一是写全this.name = name,二是把参数换个名字,比如stuName。我建议直接养成写this的习惯,因为你永远无法保证参数名和成员变量名哪天会撞上。
6.4 初始化顺序与默认值理解不到位
有人会问:我明明在成员变量定义处写了private int age = 18;,构造器里也传了别的值,为什么age不是18?
因为它执行了两次赋值。先执行默认初始化(age=0),再执行显式赋值(age=18),最后执行构造器里的赋值(age=传入值)。最终结果以构造器最后一次赋值为准。如果构造器里没给age赋值,age就是18,显式赋值成为最终分子;如果构造器赋了值,构造器的值覆盖显式值。
这个顺序不用背,但要有意识。遇到“为什么对象初始化出来不是我想要的值”的问题时,优先检查构造器里是不是还保留了代码,把显式值覆盖掉了。
6.5 问题速查表
| 常见问题 | 可能原因 | 解决思路 |
|---|---|---|
| 空指针异常 | 引用为null,或对象数组元素未new | 定位报错行,检查引用是否new过 |
| new时找不到构造器 | 构造器多了void,或写了其他名字 | 检查构造器名字、返回类型 |
| 属性赋值后仍为null/0 | 构造器内漏写this,参数覆盖 | 用this.成员变量 = 参数 |
| static上下文中无法访问成员变量 | main方法中直接访问对象成员 | 先创建对象,再通过对象访问 |
| 打印对象输出看不懂的地址 | 使用println直接打印对象 | 重写toString方法 |
| 两个对象数据互相干扰 | 把实例变量误写成static | 去掉static,使用实例成员 |
最后说点个人带新人的体会。我带人入门的时候,并不让他们上来就背“类是什么、对象是什么”的定义,而是让他们对着Student这个例子手抄十遍,改不同的属性,加不同的方法,跑到自己觉得没意思为止。类和对象是个手艺活,看十篇概念解析,不如亲手new一次、故意把this漏掉一次、再把void加到构造器上编译报错一次。错误看得多了,概念自然就熟了。如果今天你只能记住一句话,那就记:类是图纸,new是开工,对象是盖出来的房子。把这句话刻在脑子里,然后开个Java文件,把上面的Demo敲一遍,会比刷一小时教程更有用。