如果你也在自学 Java,大概率已经进入这种状态:前十天围着变量、运算符、if/else、for 循环打转,第十一天刚把数组理出点头绪,第十二天一睁眼,突然撞上四个大字——面向对象。我当时对着“对象”两个字懵了好一阵,脑子里全是“对象?哪来的对象?new 是谁?”
后来才明白,第 12 天恰恰是 Java 学习路上真正的分水岭。前面学的是“用工具”,后面学的是“造工具”;前面是语法,后面是思维。这一天的内容,决定了你以后是能读懂框架源码,还是永远卡在“能跑但看不懂”的阶段。这篇就记录一下我在第 12 天是怎么扛过面向对象入门这一关的,包括类与对象的理解方式、当天的实操代码、踩过的坑,以及这些内容在面试八股里的真实分量。
1. 第12天,Java学习走到哪一步了
1.1 前11天踩过的路
先快速对一下进度表,看看第 12 天的坐标到底在哪。如果你是按常见的 Java 自学路线图走,前 11 天大概覆盖了这些东西:
- JDK 安装与环境变量配置,搞懂了
JAVA_HOME、PATH、CLASSPATH的基本用法; - 注释规范、标识符、关键字;
- 8 种基本数据类型(byte / short / int / long / float / double / char / boolean)与包装类型的初步认识;
- 变量的作用域、常量的使用;
- 运算符系列:算术、赋值、关系、逻辑、三元、位运算;
- 流程控制:if/else、switch、for、while、do-while;
- 数组的声明、创建、遍历、常见异常(数组越界);
- 简单方法定义与调用、参数传递、return 的使用。
这个阶段最典型的学习成果,就是能写一个“考试管理”或者“学生成绩统计”的控制台程序。比如用数组存 10 个人的成绩,用 for 循环算平均分,用 if 判断及格率。写得多烂不重要,重要的是你已经开始用编程语言表达逻辑了。
但坦白讲,前 11 天写出来的代码,本质上是“C 语言风格的 Java”。全是 top-down 的顺序执行,一个 main 方法从头写到尾,数据散落在各个变量里。程序长度一旦超过 200 行,改一处逻辑就要翻半天,非常痛苦。第 12 天的意义,就是把这套“平面”的编程方式,升级成“立体”的思维方式。
1.2 为什么第12天必须啃面向对象
面向对象编程(OOP)不是 Java 发明的,Java 只是把这件事贯彻得特别彻底。从语法层面看,Java 里任何一个有意义的程序代码,基本都要定义类,然后创建对象,再通过对象去调用方法。就算是简单的Hello World,本质上也包裹在一个类里面。
面向对象的核心逻辑,是把现实世界映射到代码世界里。现实世界里有“人”“车”“订单”“商品”这些概念,代码里就有对应的“类”;现实世界里有一个具体的“张三”“那辆 red 的本田”“昨天那笔 99 元的订单”,代码里就有对应的“对象”。类是一张图纸,对象是按图纸批量造出来的实物。
试想一下,如果没有面向对象,你要描述一个图书馆系统,就得维护十几个数组:书名数组、作者数组、价格数组、库存数组、借出数量数组……下标一错位,数据全乱。但如果定义一个Book类,把书名、作者、价格、库存都封装在一个对象里,再用一个Book[]数组去装,数据结构的清晰程度完全是两个维度。
所以第 12 天的目标很明确:理解“类与对象”这对核心概念,能自己定义一个类,能创建对象,能调用对象的方法。一旦这个坎迈过去,后面学集合、学框架、学源码,都是顺水推舟的事。
2. 面向对象第一课:类与对象到底怎么落地
2.1 类就是图纸,对象就是按图纸造出来的实物
很多人第一次接触“类”和“对象”,总觉得抽象。我后来找到一个比较顺的类比:类 = 饼干的模具;对象 = 用模具压出来的一块饼干。
模具本身不能吃,它是用来“定义饼干长什么样”的:圆的?方的?上面有没有花纹?模具一旦定好,你可以压出无数块饼干,每块饼干的形状一致,但每块饼干又是独立存在的实体——这一块碎了,不影响下一块。
对应到 Java 代码:
// 类定义:就像设计了一款模具 public class Cookie { String shape; String flavor; void showInfo() { System.out.println("这是一块" + flavor + "味的" + shape + "饼干"); } }// 创建对象:就像用模具压出来两块不同的饼干 public class TestCookie { public static void main(String[] args) { Cookie c1 = new Cookie(); c1.shape = "圆形"; c1.flavor = "抹茶"; Cookie c2 = new Cookie(); c2.shape = "方形"; c2.flavor = "巧克力"; c1.showInfo(); c2.showInfo(); } }这段代码干了三件事:定义了Cookie类(图纸),在 main 方法里new了两个对象(压了两块饼干),然后分别调用showInfo()(看看饼干状态)。
这个阶段不用去死记“类是抽象的概念,对象是具体的实例”,你就想着模具和饼干、图纸和房子、菜谱和菜。等代码写多了,理论自然就通了。
2.2 构造方法:对象的出生仪式
光靠new Cookie()创建对象,对象还是“空白”的,成员变量都是默认值,还得逐行赋值,很麻烦。Java 提供了构造方法,专门用于对象创建时的初始化。
构造方法有三个核心特征:
- 方法名必须和类名完全一致;
- 没有返回值类型,连
void都不能写; - 创建对象时通过
new自动调用,不能像普通方法那样手动调。
我把上面Cookie类加上构造方法:
public class Cookie { String shape; String flavor; // 无参构造:可以不写,编译器会自动补一个 public Cookie() { } // 有参构造:创建对象时直接把属性值准备好 public Cookie(String shape, String flavor) { this.shape = shape; this.flavor = flavor; } void showInfo() { System.out.println("这是一块" + flavor + "味的" + shape + "饼干"); } }于是 main 方法可以变成这样:
Cookie c1 = new Cookie("圆形", "抹茶"); Cookie c2 = new Cookie("方形", "巧克力");一行代码就完成了“生产并初始化”,比之前那个两行赋值干净多了。
这里有个细节必须说清楚:如果你在类里一个构造方法都没写,编译器会自动给你生成一个无参的public Cookie() {}。但只要你写了任意一个有参构造,系统就不再自动补无参构造了。也就是说,你写了Cookie(String, String)之后,想用new Cookie()就会编译报错。这个坑,我第 12 天当天就踩了,后文“常见问题”部分专门讲。
2.3 封装:把成员变量关起来再开扇窗
第 12 天真正需要建立的一个概念,是“封装”。封装是面向对象的三大特性之一(还有继承、多态,后面几天才会遇到)。封装的核心诉求很简单:类的成员变量不对外直接暴露,外部必须通过公共方法去访问和修改。
话句话说,一个类的内部数据怎么组织,那是我自己的事,你们外部调用者别插手,也别直接乱改。想读数据?我给你getter。想改数据?我给你setter。我在setter里做检查,拦掉非法数据。
改造Cookie类:
public class Cookie { private String shape; private String flavor; public Cookie() {} public Cookie(String shape, String flavor) { this.setShape(shape); this.setFlavor(flavor); } public String getShape() { return shape; } public void setShape(String shape) { if (shape == null || shape.length() == 0) { System.out.println("形状不能为空,已使用默认值"); this.shape = "圆形"; } else { this.shape = shape; } } public String getFlavor() { return flavor; } public void setFlavor(String flavor) { this.flavor = flavor; } void showInfo() { System.out.println("这是一块" + flavor + "味的" + shape + "饼干"); } }注意看setShape里面的判断:如果外部传入空字符串,我就拒绝并且给一个默认值兜底。这就是封装最实际的价值——数据的合法性由类自己把关,而不是依赖调用者的自觉。
这一组getter / setter是 Java 面试八股里的高频考题。很多初学者觉得“这不是脱裤子放屁吗?直接cookie.shape = "圆形"多方便”。等你做多了项目就懂了,一旦你允许外部直接改属性,将来想加校验、加日志、加权限控制,就得把所有调用点全部翻出来改;而有了setter,只需要动一个方法里的代码。
3. 第12天的代码实操:做一个图书管理模型
3.1 环境自检:先确认JDK和IDE没问题
动手写代码之前,我习惯先做一轮环境自检。这个习惯救了我很多次,尤其是按教程走但总是“明明一样却不运行”的时候。
打开命令行,依次输入:
java -version javac -version如果输出里版本号一致,比如都是17.0.x,说明 PATH 配置基本正常。再看一眼JAVA_HOME:
echo %JAVA_HOME% # Windows 系统 echo $JAVA_HOME # Linux / macOS 系统如果这个变量为空,建议先补上。JAVA_HOME指向 JDK 安装根目录,比如C:\Program Files\Java\jdk-17。很多 IDE(比如 IDEA)和构建工具(Maven、Gradle)都要靠这个变量找 JDK。这个配置在第 1 天就该完成,但我见过太多人装完 JDK 不配环境变量,直接靠 IDE 内嵌的 JBR,结果一到命令行编译就傻眼。
IDE 方面,我个人一直推荐 IntelliJ IDEA 社区版。倒不是别的编辑器不能用,而是 IDEA 对面向对象的代码结构有很直观的提示,比如它能自动帮你生成getter/setter、构造方法、toString,让初学阶段能集中精力理解概念而不是跟打字速度搏斗。
3.2 从零写一个Book类
第 12 天下午,我用 “图书馆图书管理模型”当练习。需求控制在 30 分钟内能完成的程度,但又能把当天学的类、对象、构造、封装全部串起来。
先定义Book类:
public class Book { private String title; // 书名 private String author; // 作者 private double price; // 价格 private int totalCount; // 库存总册数 private int borrowedCount; // 已借出册数 public Book() {} public Book(String title, String author, double price, int totalCount) { this.title = title; this.author = author; this.price = price; this.totalCount = totalCount; this.borrowedCount = 0; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getAuthor() { return author; } public void setAuthor(String author) { this.author = author; } public double getPrice() { return price; } public void setPrice(double price) { if (price < 0) { System.out.println("价格不能为负数,已设置为 0"); this.price = 0; } else { this.price = price; } } public int getTotalCount() { return totalCount; } public void setTotalCount(int totalCount) { this.totalCount = totalCount; } public int getBorrowedCount() { return borrowedCount; } public int getAvailableCount() { return totalCount - borrowedCount; } public boolean borrow() { if (getAvailableCount() <= 0) { System.out.println("《" + title + "》暂时没有可借的库存了"); return false; } borrowedCount++; System.out.println("成功借出《" + title + "》,剩余可借 " + getAvailableCount() + " 册"); return true; } public boolean returnBook() { if (borrowedCount <= 0) { System.out.println("《" + title + "》没有可归还的记录"); return false; } borrowedCount--; System.out.println("归还成功,剩余可借 " + getAvailableCount() + " 册"); return true; } public void showInfo() { System.out.println("《" + title + "》 作者:" + author + " 价格:" + price + " 库存:" + totalCount + " 已借出:" + borrowedCount + " 可借:" + getAvailableCount()); } }这个类里用到的知识点基本都是第 12 天的核心:private成员变量、两个构造方法、getter/setter、成员方法中调用另一个成员方法(比如borrow()内部用getAvailableCount())、在setPrice做参数校验。
注意showInfo()方法里输出用了一长串字符串拼接,看起来有点笨重。我当时是用+直接拼的,教程里通常会告诉你更优的方式是用String.format或StringBuilder,这个改进点后面单独讲。
3.3 对象数组与简单业务逻辑
有了Book类,再写一个Library类来演示对象数组的定义和使用:
public class Library { public static void main(String[] args) { // 1. 创建一个长度为3的Book对象数组 Book[] books = new Book[3]; // 2. 每个元素都需要逐个 new 出来 books[0] = new Book("Java 核心技术卷一", "Cay Horstmann", 149.0, 10); books[1] = new Book("深入理解 Java 虚拟机", "周志明", 129.0, 6); books[2] = new Book("Java 并发编程的艺术", "方腾飞", 99.0, 4); // 3. 遍历对象数组,调用每个对象的 showInfo for (int i = 0; i < books.length; i++) { books[i].showInfo(); } // 4. 业务模拟:借出和归还 System.out.println("--- 开始借书 ---"); books[0].borrow(); books[0].borrow(); books[1].borrow(); System.out.println("--- 当前库存状态 ---"); for (int i = 0; i < books.length; i++) { books[i].showInfo(); } System.out.println("--- 归还操作 ---"); books[0].returnBook(); } }运行结果大致如下:
《Java 核心技术卷一》 作者:Cay Horstmann 价格:149.0 库存:10 已借出:0 可借:10 《深入理解 Java 虚拟机》 作者:周志明 价格:129.0 库存:6 已借出:0 可借:6 《Java 并发编程的艺术》 作者:方腾飞 价格:99.0 库存:4 已借出:0 可借:4 --- 开始借书 --- 成功借出《Java 核心技术卷一》,剩余可借 9 册 成功借出《Java 核心技术卷一》,剩余可借 8 册 成功借出《深入理解 Java 虚拟机》,剩余可借 5 册 --- 当前库存状态 --- 《Java 核心技术卷一》 作者:Cay Horstmann 价格:149.0 库存:10 已借出:2 可借:8 《深入理解 Java 虚拟机》 作者:周志明 价格:129.0 库存:6 已借出:1 可借:5 《Java 并发编程的艺术》 作者:方腾飞 价格:99.0 库存:4 已借出:0 可借:4 --- 归还操作 --- 归还成功,剩余可借 9 册这个逻辑非常基础,但价值在于它第一次让你完整体验了“定义类 → 封装状态 → 对象之间交互?接近了 → 模拟真实业务逻辑”的完整链路。借书能成功,因为borrowedCount被正确维护;库存不足时能拒绝借出,因为borrow()里做了判断。这就是面向对象程序设计的好处——业务规则被写进对象自己的方法里,main 方法只负责“调度”。
这个Book[]对象数组也特别值得多说一句:很多新手愣在这里,因为new Book[3]只是创建了一个“能放 3 本书的数组”,数组里一开始全是null,必须对每个元素new一下才算真的把书放进去了。这个null问题在后面做项目时会反复折磨你,先用这个例子把概念钉死。
4. 新手最容易踩的坑与排查实录
4.1 数组越界和空指针的经典现场
第 12 天到第 13 天,最常见的两个异常可以说是ArrayIndexOutOfBoundsException(数组越界)和NullPointerException(空指针)。
数组越界很好理解,比如:
int[] arr = new int[5]; for (int i = 0; i <= arr.length; i++) { System.out.println(arr[i]); }循环到i = 5时,arr[5]并不存在,因为下标范围是 0 ~ 4。排查思路千篇一律:先打印一下数组的length,再检查循环边界,尤其在for (int i = 0; i < arr.length; i++)和for (int i = 0; i <= arr.length - 1; i++)之间反复折腾时,最容易写混后面那个。
空指针在第 12 天有一个高发地带,就是对象数组里的每个元素没有new:
Book[] books = new Book[3]; books[0].showInfo(); // 报错:NullPointerException刚写完 Book 类很容易犯这个错,因为上一节课的数据数组直接赋值就能用(比如int[] scores = {90, 85, 99};),换成对象数组就忘了每个元素得单独new。记住一句话:基本类型数组是连续的数值,对象数组是连续的“遥控器”,遥控器只有指向了真正的对象才能按键。
排查空指针的另一类典型场景是方法返回值。比如:
public Book findBook(String title) { // 没找到时返回了 null return null; }调用方拿到null后又去调用book.showInfo(),就会炸。一开始可以把返回值调整成Optional<Book>,或者至少在方法内和调用处都写下明确的null判断日志。我习惯的口诀是:谁可能返回 null,谁就必须处理 null;不处理 null 的地方就不要出现 null。
4.2 String和StringBuilder,别再用加号拼循环了
第 12 天写showInfo()的时候,我用的是这种写法:
System.out.println("《" + title + "》 作者:" + author + " 价格:" + price);这种写法在编译后会转换成StringBuilder的append操作,所以少量拼接没问题。但如果在循环里大规模拼接,问题就大了:
String result = ""; for (int i = 0; i < 10000; i++) { result = result + i + ","; }每次循环都会生成一个新的字符串对象,旧的字符串被丢弃,等于是反复创建、反复复制。数据量一大,又慢又占内存。面试题里“String vs StringBuilder vs StringBuffer”就是从这个场景引出来的。
规范化写法是:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i).append(","); } String result = sb.toString();StringBuilder内部是一个可变字符数组,append只是在数组后面追加,不会每操作一次就新建一个对象。至于StringBuffer,它和StringBuilder的 API 基本一样,只是加了同步方法(线程安全),单线程场景下用StringBuilder性能更好,多线程场景下再用StringBuffer。这个知识点初级开发面试经常一问一个准。
4.3 源发行版17警告到底怎么回事
“java: 警告: 源发行版 17 需要目标发行版 17”这条警告在编译环境不匹配时非常高频。我经常看到初学者编译后编译成功了但控制台报一长串警告,一脸茫然。
这个警告的根源是编译器的--release或 source/target 版本设置和当前 JDK 版本、项目 SDK 版本不一致。比如你电脑上装的是 JDK 17,但项目语言级别设置成了 8;或者反过来,你设置了语言级别 17,但用的 IDE 实际解析的 JDK 是 8。
在 IDEA 中,解决步骤就三步:
File → Project Structure(快捷键 Ctrl+Alt+Shift+S),在Project标签页把SDK选成本机安装的 JDK 17;- 同页把
Language level设为 17; - 再检查
Settings → Build, Execution, Deployment → Compiler → Java Compiler,确保 bytecode version 和项目语言级别保持一致。
从命令行直接编译时,我建议养成使用--release参数的习惯:
javac --release 17 Main.java--release N是“目标版本 = N,并且 JDK N 的 API 可用”,比单独设置-source和-target更省心,因为-target只控制生成的字节码版本,但你还是能用高版本 JDK 才有的 API,最后容易把环境搞乱。
5. 第12天的面试视角:这些八股先混个脸熟
5.1 面向对象基础高频题
学完第 12 天,很多经典的 Java 面试八股题已经有资格看第一眼了。这里不是说让初学者立刻背题,而是提前知道“今天学的这些将来考什么”,心里有个锚点。这个阶段能听懂、能口头讲清楚的有这几道:
- 什么是面向对象?和你以前写的面向过程有什么区别?
- 什么是类?什么是对象?它们的关系是什么?
- 构造方法能不能重载?如果类里只定义了有参构造,还能
new ClassName()吗? - private 修饰的成员变量外部能访问吗?setter 里到底能干什么额外的事?
- static 修饰的方法能不能直接访问非 static 的成员变量?为什么不能?
- 重载(Overload)的核心判断标准是什么?
前四个题,第 12 天的知识储备基本够回答了。第五个题要展开讲讲static的本质:static方法是属于类的,在类加载阶段就准备好了,它不依赖任何对象实例;而非 static 的成员变量每个对象都有一份,你都没创建对象,它哪知道该访问哪个对象的字段?所以static方法里不能直接访问非 static 成员,如果想访问,只能先new一个对象,再通过这个对象去访问。
第六个题“重载”今天也能说上:方法名相同、参数列表不同(类型、个数、顺序),和返回值类型无关。
重载最容易记混的一点是“这两个方法只有返回值不同”,这是不允许的,因为 Java 区分重载只看方法签名(方法名 + 参数列表),返回值不算签名的一部分。你写两个只有返回值不同的同名同参方法,编译器直接报错。
5.2 后面几天的学习路线怎么安排
第 12 天只是面向对象的开始,后面还有一大串主线任务。我给当时自己规划的顺序,也是现在比较常用的 Java 自学路线图:
- 第 13-14 天:继承、
super关键字、方法重写(Override)、Object类; - 第 15 天:抽象类和接口,学会用接口约束行为;
- 第 16 天:多态,理解向上转型和动态绑定;
- 第 17-18 天:常用 API,重点啃
String、StringBuilder、Arrays工具类; - 第 19-20 天:集合框架,
ArrayList、LinkedList、HashMap一定要亲手动起来; - 第 21 天:包装类、自动装箱拆箱、基本类型和引用类型对比;
- 第 22-23 天:异常体系,会写
try-catch-finally和自定义异常; - 第 24-25 天:泛型和常用数据结构,开始刷简单算法题(冒泡排序、二分查找)。
等这条线走完后,Java 基础基本闭环,就可以进入 IO 流、多线程、网络编程这些更深的部分。但无论后面的路多长,第 12 天这个“类与对象”的关卡都非常关键,因为它决定了你对整个 Java 世界的组织方式是否有感觉。如果这一关过得不踏实,后面继承、多态、集合那一堆东西会像盖在流沙上的楼,越学越晃。
这块如果后续有空,我再写一篇“Java 第 15 天:继承与重写的进阶笔记”,把super的细节和重写方法的访问权限限制整理一下。如果现在就急着看,建议先重点做两件事:一是把Book类的例子自己从零敲一遍,二是把“构造方法重载”和“static 方法不能直接访问非 static 字段”这两句话用自己的话写进笔记里。
我个人在第 12 天结束时的真实体会是:面向对象不是靠看视频看懂的,是把Cookie改出十个版本之后突然悟的。第一次写Book类时满脑子“为什么要这么绕”,写到第五遍,把price、borrowedCount这些字段的校验逻辑塞进setter,看到 main 方法因为违反业务规则而自动报错误提示时,才真正理解了“封装不是限制,是保护”。所以第 12 天不用急着背八股,先保证自己能把一个简单的类设计得干净、能用、能扛住非法数据,这比什么都重要。