Java 基础核心复习笔记 (Day 02) —— 面向对象与字符串深度(图形化版)

1. 面向对象核心 (OOP Core)

1.1 构造方法 (Constructor)

构造方法是对象初始化的入口。

  • 特点:
    1. 名字必须与类名完全一致
    2. 没有返回值类型,连 void 都不能写(写了 void 就变成普通方法了)。
    3. 自动执行new 对象时 JVM 自动调用。

🛠️ 图解:对象初始化流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户代码: Dog myDog = new Dog("Buddy");
|
+-----------------v-----------------+
| 1. 在堆内存开辟空间 (Heap Alloc) |
+-----------------+-----------------+
|
+-----------------v-----------------+
| 2. 属性赋默认值 (0/null/false) |
+-----------------+-----------------+
|
+-----------------v-----------------+
| 3. 执行构造方法 (Constructor) | -> 将 "Buddy" 赋值给 name
+-----------------+-----------------+
|
+-----------------v-----------------+
| 4. 将地址赋值给引用变量 myDog |
+-----------------------------------+

📝 图解说明: 当你执行 new 操作时,JVM 并不是一步到位的。首先在 堆内存 中挖一块地(开辟空间),然后把所有属性清零(赋默认值),接着才调用你写的 构造方法 进行个性化赋值(如把 name 设为 “Buddy”),最后才把这块地的地址交给栈上的引用变量 myDog

🔥 趁热打铁 - 大厂真题: Q: 子类实例化时,父类的构造方法会被调用吗?为什么? A: 会,且一定先于子类执行。

  • 原理: 子类继承了父类的属性,必须先通过父类构造器初始化这些属性,子类才能使用。JVM 强制要求子类构造器的第一行必须是 super()(如果没写,编译器自动加默认的)。
  • 执行顺序: 父类静态代码块 -> 子类静态代码块 -> 父类构造方法 -> 子类构造方法。

1.2 面向对象三大特征:多态 (Polymorphism)

封装和继承是基础,多态是核心。

定义: 父类的引用指向子类的实例(Parent p = new Child();)。 实现三要素: 继承、重写、父类引用指向子类对象。

🛠️ 图解:编译看左,运行看右

1
2
3
4
5
6
7
8
9
10
11
12
代码:  Parent p = new Child();
p.eat();

[ 栈内存 (Stack) ] [ 堆内存 (Heap) ]
+--------------+ +------------------+
| 变量 p | ------------->| Child 实例对象 |
| (类型: Parent)| | (类型: Child) |
+--------------+ +------------------+
| |
👀 编译期检查 (Compiler) 🏃 运行期执行 (Runtime)
去 Parent 类找 eat() 去 Child 类找重写的 eat()
(找不到则报错) (真正执行的代码)

📝 图解说明: 引用变量 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
2
3
4
5
6
7
8
9
10
11
12
13
1. 静态分派 (重载 Overload) - 看参数外观
func(Human h) <--- 编译器选这个
func(Man m)

Human person = new Man();
func(person); -> 参数声明是 Human,所以调 Human 版本

2. 动态分派 (重写 Override) - 看对象本质
class Father { void say() { "我是爸爸" } }
class Son { void say() { "我是儿子" } }

Father f = new Son();
f.say(); -> 运行时发现 f 指向 Son 对象,调用 Son.say()

📝 图解说明:

  • 静态分派 (左):编译器是“只看表面”的,因为传入参数 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
2
3
4
5
6
     [ 抽象类: 门 (Door) ]           [ 接口: 报警 (Alarm) ]
/ \ |
extends extends implements
/ \ |
[ 木门 ] [ 铁门 ] [ 报警门 ]
(是门的一种) (是门的一种) (不仅是门,还具备报警功能)

📝 图解说明:

  • 抽象类 (门) 代表了 血缘关系,木门和铁门本质上“都是门”,所以用 extends
  • 接口 (报警) 代表了 附加能力,报警门除了是门,还“具备报警功能”,这个功能像是插件一样插上去的,所以用 implements

🔥 趁热打铁 - 大厂真题: Q: 在系统设计中,什么时候选抽象类,什么时候选接口? A: 看需求侧重点。

  • 选抽象类: 当多个类有大量共同的代码逻辑(如模板方法模式)或需要共享状态(成员变量)时。例如 BaseController
  • 选接口: 当需要让不相关的类拥有相同行为,或者实现多重继承的效果时。例如 Serializable, Runnable

1.4 内部类 (Inner Class) 与 内存泄漏 (新增章节)

内部类不仅仅是代码组织方式,更是面试中的内存杀手。

🔥 大厂关键考点: Q: 为什么非静态内部类容易导致内存泄漏? A: 因为非静态内部类(包括匿名内部类)默认隐式持有外部类的强引用

🛠️ 图解:内存泄漏链条

1
2
3
4
5
6
7
8
9
场景:Activity 中开启了一个线程(内部类)处理耗时任务

[ 外部类实例 (Activity) ] <-------(隐式引用 `this$0`)------- [ 内部类实例 (Thread) ]
^ |
| ❌ 用户关闭页面, |
| 理论上应被 GC 回收 | 正在运行中...
| |
+-----------------------------------------------------------+
(GC 发现 Thread 还在跑,且 Thread 抓着 Activity 不放,导致 Activity 无法回收)

📝 图解说明: 当你在 Activity 里 new Thread() 时,这个 Thread 对象其实偷偷藏了一个指针 this$0 指向了 Activity。即使用户退出了界面,只要 Thread 还在跑,它就会死死拽着 Activity 不放,导致 GC 无法回收 Activity 占用的内存,这就是内存泄漏的根源。

🔥 趁热打铁 - 大厂真题: Q: 如何解决上述的 Handler/Thread 内存泄漏问题? A: 切断强引用链。

  1. 把内部类改为 静态内部类 (static class):静态内部类不持有外部引用。
  2. 如果静态内部类需要访问外部类的属性,使用 弱引用 (WeakReference) 来持有外部类对象。

2. 字符串深度解析 (String Family)

2.1 String, StringBuffer, StringBuilder 区别

🛠️ 图解:内存变动对比

1
2
3
4
5
6
7
8
9
10
11
1. String (不可变)
s = "a" + "b";

[ "a" ] [ "b" ] [ "ab" ] <--- s 最终指向这里
(旧对象 "a", "b" 变成垃圾,等待回收)

2. StringBuilder (可变)
sb.append("a").append("b");

[ char[] value: | 'a' | 'b' | _ | _ | ... ] <--- sb 始终指向同一个对象
(直接在原数组上修改,不产生垃圾)

📝 图解说明:

  • String 像是“一次性筷子”,每次拼接都要扔掉旧的,拿一双新的,会产生大量垃圾。
  • StringBuilder 像是“伸缩筷子”,在原有的基础上加长,始终是同一个对象,效率高且环保。

🔥 趁热打铁 - 大厂真题: Q: 为什么 StringBuilder 不是线程安全的? A: 为了性能,牺牲了安全。

  • 查看 append() 源码可以看到,它没有任何 synchronized 锁。
  • 竞态条件: 如果两个线程同时执行 count += len,可能会导致覆盖,或者在扩容时抛出 ArrayIndexOutOfBoundsException

2.2 源码级解析:StringBuilder 扩容机制

扩容公式: 通常是 newCapacity = (oldCapacity * 2) + 2

🛠️ 图解:数组扩容过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
步骤 1: 初始状态 (容量 4)
value -> [ 'H' | 'i' | _ | _ ]

步骤 2: append("World") (长度 5,空间不够)
|
v
步骤 3: 申请新数组 (4 * 2 + 2 = 10)
newVal -> [ _ | _ | _ | _ | _ | _ | _ | _ | _ | _ ]

步骤 4: 拷贝旧数据 + 新数据
newVal -> [ 'H' | 'i' | 'W' | 'o' | 'r' | 'l' | 'd' | _ | _ | _ ]

步骤 5: 丢弃旧数组,value 指向新数组
value -> newVal

📝 图解说明: 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
2
3
4
5
6
7
8
9
JDK 8 (UTF-16 char[]):
String s = "Java";
[ 00 4A | 00 61 | 00 76 | 00 61 ] (8 bytes)
'J' 'a' 'v' 'a'

JDK 9+ (Latin-1 byte[]):
String s = "Java";
[ 4A | 61 | 76 | 61 ] (4 bytes) -> 节省 50% 内存!
'J' 'a' 'v' 'a'

📝 图解说明: 在 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
2
3
4
5
6
7
8
9
10
11
Person p1 = new Person();
Person p2 = p1;

[ 变量 p1 ] ----+
|
v
[ 对象 Person ]
^
|
[ 变量 p2 ] ----+
(两个人看同一台电视,换台大家都受影响)

📝 图解说明: 引用拷贝 实际上没产生新对象,只是多了一个指向同一个对象的指针。就像给了朋友一把你家大门的备用钥匙。

2. 浅拷贝 (Shallow Copy)

1
2
3
4
5
6
7
Person p2 = p1.clone();

[ p1 ] ----> [ Person对象 1 ] ----> [ 引用类型的属性(如 Address) ]
^
|
[ p2 ] ----> [ Person对象 2 ] --------------+
(复制了人,但大家共用一个家庭地址 Address。p1 改地址,p2 也跟着搬家)

📝 图解说明: 浅拷贝 就像“分家不分产”。确实生成了一个新的人(对象壳子是新的),但他名下的资产(引用类型成员,如房子地址)还是指向原来的。

3. 深拷贝 (Deep Copy)

1
2
3
4
[ p1 ] ----> [ Person对象 1 ] ----> [ Address 1 ]

[ p2 ] ----> [ Person对象 2 ] ----> [ Address 2 ]
(完全独立。p1 怎么改都影响不到 p2)

📝 图解说明: 深拷贝 是最彻底的克隆。不仅人是新的,连名下的资产(Address)也重新买了一份全新的。两者从此互不相干。

🔥 趁热打铁 - 大厂真题: Q: ArrayList 的 clone() 方法是深拷贝还是浅拷贝? A: 是浅拷贝。

  • list.clone() 会创建一个新的 List 列表对象,但列表里面存的元素引用,依然指向旧列表里的那些对象。如果修改了元素内部的属性,新旧列表都会受影响。

4. JVM 与 String 进阶 (High Level)

4.1 字符串常量池与字节码分析

你提供的字节码非常有代表性,这是 String s = new String("abc"); 的执行过程。

🛠️ 图解:new String(“abc”) 的内存结构

1
2
3
4
5
6
7
8
假设常量池中还没有 "abc"

栈 (Stack) 堆 (Heap) 方法区 (常量池)
+-------------+ +----------------+ +-----------------+
| 变量 s | ---> | String 对象 | ----> | "abc" (字面量) |
+-------------+ | (value 指针) | +-----------------+
+----------------+
(这是第1个对象) (这是第2个对象)

📝 图解说明: 这行代码其实很“浪费”。因为 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
2
3
4
5
6
7
8
9
10
11
12
String s = new String("he") + new String("llo"); // 生成堆中的 "hello"
s.intern();

[ 堆内存 (Heap) ] [ 字符串常量池 (String Pool) ]
+-------------------+ +--------------------------+
| String对象 "hello"| <---------------| 引用 (地址 0x999) |
| (地址 0x999) | | (不再存一份新的"hello") |
+-------------------+ +--------------------------+

String s2 = "hello"; // s2 直接去常量池拿引用
// 结果: s2 拿到的就是堆中那个对象的地址 (0x999)
// 所以 s == s2 为 true

📝 图解说明: 在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class User {
static int count; // 静态变量
String name; // 实例变量
}

[ 方法区 / 元空间 ] [ 堆内存 ]
+------------------+ +----------------+
| Class: User | | User 实例 A |
| [ static count ] | <-------- | [ name="Tom" ] |
+------------------+ +----------------+
^
| +----------------+
+-------------------- | User 实例 B |
| [ name="Bob" ] |
+----------------+
(count 只有一份,所有实例共享; name 每个实例一份)

📝 图解说明: static 变量就像 公司的公共饮水机(在方法区),所有员工(实例)都共用这一台。而 name 就像 员工自己的水杯(在堆内存),每个人都有自己独立的一个。

🔥 趁热打铁 - 大厂真题: Q: 为什么静态方法里不能访问非静态变量(如 this.name)? A: 时空悖论。

  • 时间: 静态方法在类加载时就存在了,而非静态变量只有 new 了对象才产生。
  • 逻辑: 调用静态方法时,可能根本没有创建任何对象,JVM 根本不知道你说的 name 是哪一个对象的 name

5.2 Final 关键字

记忆口诀: “断子绝孙,不可变”。

🛠️ 图解:Final 变量的不可变性

1
2
3
4
5
6
7
8
9
10
final int x = 10;
[ x: 10 ] -> ❌ 无法修改为 20

final Person p = new Person("A");
[ p ] --------> [ 堆: Person(name="A") ]
| |
| ❌ 指针不可变 | ✅ 内容可变
v v
[ 堆: Person(B) ] [ 堆: Person(name="B") ]
(报错!) (p.name="B" 允许)

📝 图解说明: final 修饰引用变量时,锁住的是 指针的方向。就像你被拷在了一个人身上,你不能换人(不能指向新对象),但这个人的发型、衣服(对象内部属性)是可以变的。

6. 泛型 (Generics) 与 类型擦除 (Type Erasure) (新增章节)

6.1 什么是类型擦除?

🛠️ 图解:编译前 vs 编译后

1
2
3
4
5
6
7
8
9
10
11
12
13
源代码 (.java):
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);

|
v javac 编译器 (类型擦除)
|

字节码 (.class):
List list = new ArrayList(); // <String> 没了,变成原生类型
list.add("hello"); // 实际上存的是 Object
String s = (String) list.get(0); // 自动插入强转代码

📝 图解说明: 泛型在 Java 里是“皇帝的新衣”。源代码里写得明明白白 <String>,但编译器一跑完,字节码里全变成了原生 List (存 Object)。取数据时,编译器会自动帮你补上一句 (String) 强转。

🔥 趁热打铁 - 大厂真题: Q: 为什么 Java 不支持 new T[] 这样的泛型数组创建? A: 类型擦除会导致类型安全问题。

  • 如果允许 new T[],擦除后就变成了 new Object[]
  • 你可能会把一个 Integer 放进本该是 String[] 的数组里,而数组在运行时是需要检查类型的,这与泛型的“擦除”机制冲突(数组是协变的,泛型是不变的)。

6.2 PECS 原则

🛠️ 图解:PECS 选择指南

1
2
3
4
5
6
7
8
9
   [ ? extends T ]                  [ ? super T ]
(上界通配符) (下界通配符)

只读不写 (生产者 Producer) 只写不读 (消费者 Consumer)
| |
v v
List<? extends Number> List<? super Integer>
Number n = list.get(0); ✅ list.add(100); ✅
list.add(100); ❌ Object o = list.get(0); ⚠️(只能转Object)

📝 图解说明:

  • ? extends 就像 只读阅览室,你只能看书(读取 Number),不能把自己的书塞进去(add 报错),因为不知道阅览室具体收哪种书。
  • ? super 就像 垃圾桶,你只管往里扔东西(add Integer),但你要想从里面捡东西出来,只知道捡出来的是个“物体”(Object),具体是啥不知道。

7. 延伸阅读与练习

推荐阅读

思考题

  1. 代码分析:

    1
    2
    3
    4
    String 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",所以 s1s2 指向常量池中的同一个对象。
  2. 设计题: 如果让你设计一个不可变类(类似 String),你需要注意哪些点?

    • 类声明为 final。
    • 所有成员变量 private final。
    • 不提供 setter。
    • 防御性拷贝: 如果构造器传入的是引用类型(如 Date, Array),由于外部持有引用可能修改内部状态,需要在构造器中 clone 一份再赋值。