Java 基础核心复习笔记day02
Java 基础核心复习笔记 (Day 02) —— 面向对象与字符串深度(图形化版)
1. 面向对象核心 (OOP Core)
1.1 构造方法 (Constructor)
构造方法是对象初始化的入口。
- 特点:
- 名字必须与类名完全一致。
- 没有返回值类型,连
void都不能写(写了void就变成普通方法了)。 - 自动执行:
new对象时 JVM 自动调用。
🛠️ 图解:对象初始化流程
1 | 用户代码: Dog myDog = new Dog("Buddy"); |
📝 图解说明: 当你执行
new操作时,JVM 并不是一步到位的。首先在 堆内存 中挖一块地(开辟空间),然后把所有属性清零(赋默认值),接着才调用你写的 构造方法 进行个性化赋值(如把 name 设为 “Buddy”),最后才把这块地的地址交给栈上的引用变量myDog。
🔥 趁热打铁 - 大厂真题: Q: 子类实例化时,父类的构造方法会被调用吗?为什么? A: 会,且一定先于子类执行。
- 原理: 子类继承了父类的属性,必须先通过父类构造器初始化这些属性,子类才能使用。JVM 强制要求子类构造器的第一行必须是
super()(如果没写,编译器自动加默认的)。- 执行顺序: 父类静态代码块 -> 子类静态代码块 -> 父类构造方法 -> 子类构造方法。
1.2 面向对象三大特征:多态 (Polymorphism)
封装和继承是基础,多态是核心。
定义: 父类的引用指向子类的实例(Parent p = new Child();)。 实现三要素: 继承、重写、父类引用指向子类对象。
🛠️ 图解:编译看左,运行看右
1 | 代码: Parent p = new Child(); |
📝 图解说明: 引用变量
p就像一个戴着 “Parent” 眼镜的人,编译时它只能看到 Parent 类里定义的方法;但实际上它手里牵着的是一只 “Child” 对象。到了 运行时,眼镜摘下,JVM 发现牵着的原来是 Child,于是执行 Child 重写后的eat()方法。这就是多态的本质。
🔥 趁热打铁 - 大厂真题: Q: 如果 Parent 类和 Child 类都有一个同名的成员变量 int age,p.age 输出谁的值? A: 输出 Parent 的值。
- 原因: 多态只针对方法(因为方法可以重写),不针对变量。
- 口诀: 访问成员变量时,编译运行全看左边。千万别被多态搞混了,变量没有动态绑定一说。
1.2.1 多态的 JVM 原理:静态分派 vs 动态分派 (新增深度)
为什么“编译看左边,运行看右边”?
- 静态分派 (Static Dispatch):
- 对应 重载 (Overload)。
- 编译期确定。编译器根据参数的静态类型决定调用哪个方法。
- 动态分派 (Dynamic Dispatch):
- 对应 重写 (Override)。
- 运行期确定。JVM 在运行时检查对象的实际类型,调用实际类型的方法。
🛠️ 图解:静态分派 vs 动态分派
1 | 1. 静态分派 (重载 Overload) - 看参数外观 |
📝 图解说明:
- 静态分派 (左):编译器是“只看表面”的,因为传入参数
person的声明类型是Human,所以它锁定了func(Human),不管你实际上是不是Man。- 动态分派 (右):运行时是“看本质”的,虽然
f声明是Father,但实际对象是Son,所以 JVM 动态调用了Son的方法。
🔥 趁热打铁 - 大厂真题: Q: 私有方法 (private) 能被重写吗?多态对它有效吗? A: 不能重写,多态无效。
- 原因:
private方法对子类不可见,子类就算写了个同名方法,那也是子类自己的新方法,不是重写。- JVM指令: 私有方法调用使用的是
invokespecial指令(静态绑定,编译期锁定),而多态依赖的是invokevirtual指令。
1.3 接口 (Interface) vs 抽象类 (Abstract Class)
不要只背语法,要理解设计理念。
| 特性 | 抽象类 (Abstract Class) | 接口 (Interface) |
|---|---|---|
| 设计核心 | Is-a (是不是): 也是一种模板设计。 | Can-do (能不能): 定义行为规范/契约。 |
| 继承/实现 | 单继承 (extends) |
多实现 (implements) |
| 成员变量 | 可以有各种类型的变量 | 只能是 public static final (常量) |
🛠️ 图解:设计理念差异
1 | [ 抽象类: 门 (Door) ] [ 接口: 报警 (Alarm) ] |
📝 图解说明:
- 抽象类 (门) 代表了 血缘关系,木门和铁门本质上“都是门”,所以用
extends。- 接口 (报警) 代表了 附加能力,报警门除了是门,还“具备报警功能”,这个功能像是插件一样插上去的,所以用
implements。
🔥 趁热打铁 - 大厂真题: Q: 在系统设计中,什么时候选抽象类,什么时候选接口? A: 看需求侧重点。
- 选抽象类: 当多个类有大量共同的代码逻辑(如模板方法模式)或需要共享状态(成员变量)时。例如
BaseController。- 选接口: 当需要让不相关的类拥有相同行为,或者实现多重继承的效果时。例如
Serializable,Runnable。
1.4 内部类 (Inner Class) 与 内存泄漏 (新增章节)
内部类不仅仅是代码组织方式,更是面试中的内存杀手。
🔥 大厂关键考点: Q: 为什么非静态内部类容易导致内存泄漏? A: 因为非静态内部类(包括匿名内部类)默认隐式持有外部类的强引用。
🛠️ 图解:内存泄漏链条
1 | 场景:Activity 中开启了一个线程(内部类)处理耗时任务 |
📝 图解说明: 当你在 Activity 里
new Thread()时,这个 Thread 对象其实偷偷藏了一个指针this$0指向了 Activity。即使用户退出了界面,只要 Thread 还在跑,它就会死死拽着 Activity 不放,导致 GC 无法回收 Activity 占用的内存,这就是内存泄漏的根源。
🔥 趁热打铁 - 大厂真题: Q: 如何解决上述的 Handler/Thread 内存泄漏问题? A: 切断强引用链。
- 把内部类改为 静态内部类 (static class):静态内部类不持有外部引用。
- 如果静态内部类需要访问外部类的属性,使用 弱引用 (WeakReference) 来持有外部类对象。
2. 字符串深度解析 (String Family)
2.1 String, StringBuffer, StringBuilder 区别
🛠️ 图解:内存变动对比
1 | 1. String (不可变) |
📝 图解说明:
- String 像是“一次性筷子”,每次拼接都要扔掉旧的,拿一双新的,会产生大量垃圾。
- StringBuilder 像是“伸缩筷子”,在原有的基础上加长,始终是同一个对象,效率高且环保。
🔥 趁热打铁 - 大厂真题: Q: 为什么 StringBuilder 不是线程安全的? A: 为了性能,牺牲了安全。
- 查看
append()源码可以看到,它没有任何synchronized锁。- 竞态条件: 如果两个线程同时执行
count += len,可能会导致覆盖,或者在扩容时抛出ArrayIndexOutOfBoundsException。
2.2 源码级解析:StringBuilder 扩容机制
扩容公式: 通常是 newCapacity = (oldCapacity * 2) + 2。
🛠️ 图解:数组扩容过程
1 | 步骤 1: 初始状态 (容量 4) |
📝 图解说明: StringBuilder 就像一个箱子,装满了想再装时,它会先买一个两倍大的新箱子,把旧箱子里的东西搬过去,再把新东西放进去,最后把旧箱子扔掉。
🔥 趁热打铁 - 大厂真题: Q: 如果预先知道字符串大概有多长,如何优化 StringBuilder? A: 使用构造方法指定初始容量 new StringBuilder(128)。
- 好处: 避免了中间多次的“扩容 -> 数组拷贝 -> 垃圾回收”过程,性能提升非常显著。
2.3 为什么 String 设计为不可变?
不仅仅是因为 final。
2.3.1 Java 9 的变革:Compact Strings (新增深度)
- JDK 9 及以后:
private final byte value[]+byte coder。
🛠️ 图解:Compact Strings 存储优化
1 | JDK 8 (UTF-16 char[]): |
📝 图解说明: 在 JDK 8 中,不管存英文还是中文,每个字符都占 2 字节,存 “Java” 这种纯英文很浪费(高8位都是0)。JDK 9 开始,如果只有英文,自动压缩成 1 字节存储,像 “Java” 只需要 4 字节,内存直接省一半。
🔥 趁热打铁 - 大厂真题: Q: 如果字符串中混合了中文和英文(如 “Java你好”),JDK 9 会怎么存? A: 回退到双字节存储 (UTF-16)。
- 只要有一个字符无法用 Latin-1 (1 byte) 表示,整个字符串的
coder标记就会变为 UTF-16,数组里每个字符都会占用 2 字节,和 JDK 8 一样。
3. 内存与对象拷贝 (Memory & Copy)
3.1 引用拷贝 vs 浅拷贝 vs 深拷贝
这是理解 Java 内存模型的重要一环。
3.2 内存布局图解
🛠️ 图解:三种拷贝模式
1. 引用拷贝 (Reference Copy)
1 | Person p1 = new Person(); |
📝 图解说明: 引用拷贝 实际上没产生新对象,只是多了一个指向同一个对象的指针。就像给了朋友一把你家大门的备用钥匙。
2. 浅拷贝 (Shallow Copy)
1 | Person p2 = p1.clone(); |
📝 图解说明: 浅拷贝 就像“分家不分产”。确实生成了一个新的人(对象壳子是新的),但他名下的资产(引用类型成员,如房子地址)还是指向原来的。
3. 深拷贝 (Deep Copy)
1 | [ p1 ] ----> [ Person对象 1 ] ----> [ Address 1 ] |
📝 图解说明: 深拷贝 是最彻底的克隆。不仅人是新的,连名下的资产(Address)也重新买了一份全新的。两者从此互不相干。
🔥 趁热打铁 - 大厂真题: Q: ArrayList 的 clone() 方法是深拷贝还是浅拷贝? A: 是浅拷贝。
list.clone()会创建一个新的 List 列表对象,但列表里面存的元素引用,依然指向旧列表里的那些对象。如果修改了元素内部的属性,新旧列表都会受影响。
4. JVM 与 String 进阶 (High Level)
4.1 字符串常量池与字节码分析
你提供的字节码非常有代表性,这是 String s = new String("abc"); 的执行过程。
🛠️ 图解:new String(“abc”) 的内存结构
1 | 假设常量池中还没有 "abc" |
📝 图解说明: 这行代码其实很“浪费”。因为
new强制在 堆 里创建了一个 String 对象;而"abc"字面量又会在 常量池 里创建一个对象。堆里的那个对象,底层其实是指向常量池里那个 “abc” 的。
🔥 趁热打铁 - 大厂真题: Q: 比较 String s1 = “a” + “b”; 和 String s2 = new String(“a”) + new String(“b”); 的区别? A: 天壤之别。
s1: 编译器优化,直接变成"ab"放入常量池。s2: 运行时拼接,底层是用StringBuilder(Java 8) 拼接,最后产生一个新的堆对象。
4.2 String#intern() 方法
🛠️ 图解:JDK 1.7+ intern() 的“省空间”大法
1 | String s = new String("he") + new String("llo"); // 生成堆中的 "hello" |
📝 图解说明: 在 JDK 1.7 之前,常量池必须存完整的对象。但在 JDK 1.7+,如果堆里已经有了 “hello”,调用
intern()时,常量池为了省地儿,直接存了一个 指向堆中对象的引用。这样s2去常量池找,找到的其实就是堆里的那个s,实现了“大一统”。
🔥 趁热打铁 - 大厂真题: Q: 如果在 s.intern() 之前,常量池里已经有 “hello” 了,会发生什么? A: intern() 不会做任何修改,直接返回常量池里那个 “hello” 的地址。
- 此时
s指向堆里的对象,s.intern()返回常量池里的对象,两者地址不同 (false)。
4.3 JVM 方法调用指令集 (新增章节)
| 指令 | 描述 | 图解记忆 |
|---|---|---|
invokestatic |
调静态方法 | 类名.方法() -> 🔒 编译期锁死 |
invokespecial |
调构造/私有 | new / super() -> 🔒 编译期锁死 |
invokevirtual |
调实例方法 | obj.method() -> 🎲 运行时看对象类型 (多态) |
invokeinterface |
调接口方法 | iface.method() -> 🎲 运行时搜索实现类 |
5. 关键字精讲:Static & Final
5.1 Static 关键字
🛠️ 图解:Static 存储位置
1 | class User { |
📝 图解说明:
static变量就像 公司的公共饮水机(在方法区),所有员工(实例)都共用这一台。而name就像 员工自己的水杯(在堆内存),每个人都有自己独立的一个。
🔥 趁热打铁 - 大厂真题: Q: 为什么静态方法里不能访问非静态变量(如 this.name)? A: 时空悖论。
- 时间: 静态方法在类加载时就存在了,而非静态变量只有
new了对象才产生。- 逻辑: 调用静态方法时,可能根本没有创建任何对象,JVM 根本不知道你说的
name是哪一个对象的name。
5.2 Final 关键字
记忆口诀: “断子绝孙,不可变”。
🛠️ 图解:Final 变量的不可变性
1 | final int x = 10; |
📝 图解说明:
final修饰引用变量时,锁住的是 指针的方向。就像你被拷在了一个人身上,你不能换人(不能指向新对象),但这个人的发型、衣服(对象内部属性)是可以变的。
6. 泛型 (Generics) 与 类型擦除 (Type Erasure) (新增章节)
6.1 什么是类型擦除?
🛠️ 图解:编译前 vs 编译后
1 | 源代码 (.java): |
📝 图解说明: 泛型在 Java 里是“皇帝的新衣”。源代码里写得明明白白
<String>,但编译器一跑完,字节码里全变成了原生List(存 Object)。取数据时,编译器会自动帮你补上一句(String)强转。
🔥 趁热打铁 - 大厂真题: Q: 为什么 Java 不支持 new T[] 这样的泛型数组创建? A: 类型擦除会导致类型安全问题。
- 如果允许
new T[],擦除后就变成了new Object[]。- 你可能会把一个
Integer放进本该是String[]的数组里,而数组在运行时是需要检查类型的,这与泛型的“擦除”机制冲突(数组是协变的,泛型是不变的)。
6.2 PECS 原则
🛠️ 图解:PECS 选择指南
1 | [ ? extends T ] [ ? super T ] |
📝 图解说明:
? extends就像 只读阅览室,你只能看书(读取 Number),不能把自己的书塞进去(add 报错),因为不知道阅览室具体收哪种书。? super就像 垃圾桶,你只管往里扔东西(add Integer),但你要想从里面捡东西出来,只知道捡出来的是个“物体”(Object),具体是啥不知道。
7. 延伸阅读与练习
推荐阅读
- Java Guide: Java 基础常见面试题总结(中)
- Oracle Docs: The Java™ Tutorials - String
思考题
代码分析:
1
2
3
4String s1 = "a" + "b" + "c";
String s2 = "abc";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));- 答案:
true,true。 - 原因: 编译器优化 (Constant Folding)。
"a"+"b"+"c"在编译时直接被合并成了"abc",所以s1和s2指向常量池中的同一个对象。
- 答案:
设计题: 如果让你设计一个不可变类(类似 String),你需要注意哪些点?
- 类声明为 final。
- 所有成员变量 private final。
- 不提供 setter。
- 防御性拷贝: 如果构造器传入的是引用类型(如 Date, Array),由于外部持有引用可能修改内部状态,需要在构造器中 clone 一份再赋值。





