java 对象
Java面向对象核心知识点总结
本文档总结了Java面向对象编程(OOP)中的核心概念和面试高频考点。
1. 多态(Polymorphism)
封装和继承相对好理解,我们重点说下多态。
多态解决了什么问题?
核心是解耦,提高代码的可扩展性和可维护性。它允许我们“面向接口编程,而不是面向实现编程”。
- 可扩展性:当需要添加一个新的子类时(例如,动物类增加了“老虎”),只要它遵守共同的接口(例如
eat()方法),那么已有的、依赖于这个接口的代码(例如feed(Animal animal))完全不需要修改。这符合开闭原则。 - 可维护性:代码不再依赖于具体的实现类,而是依赖于抽象的父类或接口,使得代码更加通用和灵活。
多态体现在哪几个方面?
- 方法重写(Overriding):子类重写父类的方法,运行时JVM会根据对象的实际类型(而不是引用类型)来调用相应的方法。这是运行时多态的核心。
- 方法重载(Overloading):在一个类中定义多个同名但参数列表不同的方法。这是编译时多态。
- 接口:一个类可以实现多个接口,并根据接口引用指向不同的实现类对象。
2. 重载(Overloading)与重写(Overriding)的区别
这是面试高频题,关键区别要记牢。
| 特性 | 重写 (Overriding) | 重载 (Overloading) |
|---|---|---|
| 发生位置 | 子类与父类之间 | 同一个类中 |
| 方法签名 | 方法名、参数列表必须相同 | 方法名相同,但参数列表必须不同(类型、数量、顺序) |
| 返回类型 | 可以是父类返回类型的子类,或完全相同 | 可以不同,但不能作为区分重载的唯一依据 |
| 访问修饰符 | 不能比父类更严格(例如,父类是public,子类不能是protected) |
可以任意修改 |
| 抛出异常 | 不能抛出比父类更宽泛的异常(可以是父类异常的子类或不抛出) | 可以任意修改 |
| 多态性 | 体现了运行时多态 | 体现了编译时多态 |
3. 抽象类(Abstract Class)与接口(Interface)的区别
这是另一个核心考点,尤其是在Java 8引入default方法后,两者界限变得模糊,但本质区别依然存在。
| 特性 | 抽象类 (Abstract Class) | 接口 (Interface) |
|---|---|---|
| 设计目的 | is-a 关系,体现“是不是”的继承关系。用于代码复用和模板设计。 | can-do 关系,体现“能不能”的功能扩展。用于定义规范和行为契约。 |
| 继承关系 | 单继承,一个类只能继承一个抽象类。 | 多实现,一个类可以实现多个接口。 |
| 成员变量 | 可以有各种类型的成员变量(实例变量、静态变量,final或非final)。 | 只能有public static final类型的常量(修饰符可省略)。 |
| 构造函数 | 有构造函数,但不能被new实例化。主要用于子类构造时调用super()。 |
没有构造函数。 |
| 方法 | 可以包含抽象方法和具体实现的方法。 | Java 8之前:只能有public abstract方法。Java 8及之后:可以额外包含 default方法(带方法体)和static方法。 |
| final修饰 | 不能用final修饰,因为抽象类就是为了被继承的。 |
无此概念,接口本身不能被final修饰。 |
核心选择原则:
- 当你想在不同子类中共享代码实现时,优先考虑抽象类。
- 当你想定义一组行为规范,让不同的类去遵守时,使用接口。
4. 面向对象设计原则(SOLID)
这是衡量代码质量的重要标准,能说出并简单解释会非常加分。
- S - 单一职责原则 (Single Responsibility Principle):一个类只做一件事。
- O - 开闭原则 (Open/Closed Principle):对扩展开放,对修改关闭。这是多态解决的核心问题。
- L - 里氏替换原则 (Liskov Substitution Principle):子类对象必须能够替换掉父类对象,并且程序行为不变。这是继承正确性的保证。
- I - 接口隔离原则 (Interface Segregation Principle):不应强迫客户端依赖它不使用的方法。接口要小而专。
- D - 依赖倒置原则 (Dependency Inversion Principle):高层模块不应依赖低层模块,两者都应依赖于抽象;抽象不应依赖于细节,细节应依赖于抽象(即面向接口编程)。
5. 内部类(重点难点)
非静态内部类 vs. 静态内部类
| 特性 | 非静态内部类 (Inner Class) | 静态内部类 (Static Nested Class) |
|---|---|---|
| 与外部类关系 | 强依赖,是外部类实例的一部分。 | 独立,更像一个被“包装”在外部类里的普通类。 |
| 持有外部引用 | 持有外部类实例的引用 (Outer.this)。 |
不持有外部类实例的引用。 |
| 访问外部成员 | 可以直接访问外部类的所有成员(包括private的实例成员和静态成员)。 |
只能访问外部类的静态成员。 |
| 创建方式 | 必须先有外部类实例才能创建。Outer outer = new Outer();Outer.Inner inner = outer.new Inner(); |
可以直接创建,无需外部类实例。Outer.StaticNested nested = new Outer.StaticNested(); |
难点解析:非静态内部类如何访问外部类成员?
这是一个很好的深入问题,能展现你对JVM和编译原理的理解。
答案是:编译器在背后做了手脚。
当你定义一个非静态内部类时,编译器会:
- 隐式传递外部类引用:为内部类添加一个指向外部类实例的、私有的final成员变量。这个变量通常叫
this$0(名字可能因编译器而异)。 - 修改内部类构造函数:在内部类的构造函数中,额外添加一个参数,用于接收外部类的实例引用,并赋值给上面创建的
this$0变量。 - 重写访问逻辑:当你在内部类中访问外部类的成员(如
outerField)时,编译器会自动将代码翻译成this$0.outerField。
这就是为什么非静态内部类能“看到”外部类的所有成员,因为它持有了外部类的一个完整实例引用。这也解释了为什么非静态内部类会额外消耗内存,并且可能导致内存泄漏(如果内部类实例的生命周期比外部类实例长)。
6. 静态变量与静态方法 (Static Members)
使用 static 关键字修饰的成员被称为静态成员或类成员。它们属于类本身,而不是类的某个特定实例。
核心特性
- 生命周期: 静态成员随着类的加载而加载,随着类的卸载而销毁。其生命周期从类加载开始,到程序结束为止,贯穿整个应用程序。
- 共享性: 静态变量被类的所有实例共享。任何一个实例修改了静态变量的值,其他所有实例访问到的都将是这个新值。
- 存储位置: 它们存储在内存的特定区域(Java 8及以后通常在堆的元空间Metaspace中),与对象实例分开。
- 访问方式:
- 推荐: 通过类名直接访问,如
ClassName.staticVariable或ClassName.staticMethod()。 - 不推荐: 也可以通过实例对象访问,如
instance.staticMethod(),但这样做会引起混淆,可读性差。
- 推荐: 通过类名直接访问,如
静态方法内部的限制
- 不能直接访问非静态成员(实例变量、实例方法),因为非静态成员依赖于具体的对象实例。在调用静态方法时,可能还没有任何实例存在。
- 不能使用
this或super关键字,因为这两个关键字都与当前实例绑定,而静态方法不与任何实例绑定。 - 可以自由访问类中的其他静态成员(静态变量和其他静态方法)。
使用场景
- 静态变量:
- 当一个值需要被所有实例共享时,如在线用户计数器、全局配置项。
- 定义一组常量,如
Math.PI。
- 静态方法:
- 作为工具方法,这些方法的操作不依赖于任何对象的状态,如
Arrays.sort()、Collections.reverse()。 -
用于实现单例模式中的
getInstance()方法或作为工厂方法。
- 作为工具方法,这些方法的操作不依赖于任何对象的状态,如
7. 深拷贝 (Deep Copy) vs. 浅拷贝 (Shallow Copy)
核心区别
简单来说,区别在于当对象内部含有其他引用类型的字段时,拷贝操作如何处理这些字段。
- 浅拷贝 (Shallow Copy)
- 行为:创建一个新对象,然后将原始对象中的字段值“按位”复制到新对象中。如果字段是基本类型(
int,double等),就复制其值;如果字段是引用类型(如String,Array,另一个对象),则只复制其引用(内存地址)。 - 结果:拷贝对象和原始对象内部的引用类型字段将指向同一个堆内存中的对象。因此,通过一个对象修改这个内部对象,会影响到另一个对象。
- 好比:你复印了一张藏宝图。你得到了自己的图(拷贝对象),但图上标记的宝藏地点(内部引用对象)还是同一个。任何人根据这张图挖走了宝藏,你手里的图也就没用了。
- 行为:创建一个新对象,然后将原始对象中的字段值“按位”复制到新对象中。如果字段是基本类型(
- 深拷贝 (Deep Copy)
- 行为:创建一个新对象,并递归地创建原始对象内部所有引用类型字段的拷贝。
- 结果:拷贝对象和原始对象是完全独立的。它们内部引用的对象也是全新的、独立的拷贝。修改任何一方,都不会影响另一方。
- 好比:你不仅复印了藏宝图,还把宝藏也复制了一份,并把新图的目的地改成了新宝藏的地点。两份宝藏和两张图完全独立。
对比总结
| 特性 | 浅拷贝 (Shallow Copy) | 深拷贝 (Deep Copy) |
|---|---|---|
| 基本类型字段 | 复制值 | 复制值 |
| 引用类型字段 | 复制引用地址 | 递归地创建新对象并复制内容 |
| 关联性 | 原对象和拷贝对象共享内部的引用对象 | 原对象和拷贝对象完全独立 |
| 实现方式 | Object.clone() 的默认行为 |
需要手动实现或使用序列化等技巧 |
实现深拷贝的三种主流方法
1. 重写 clone() 方法 (手动递归拷贝)
这是Java提供的“标准”方式,但实现起来最繁琐。
- 步骤:
- 让需要拷贝的类以及其内部包含的引用类型类都实现
Cloneable接口(这是一个标记接口)。 - 重写
clone()方法。 - 在
clone()方法内部,首先调用super.clone()来获得一个当前对象的浅拷贝。 - 然后,对新拷贝出的对象中的每一个可变引用类型字段,手动调用它们的
clone()方法,并将返回的新引用赋给新对象。
- 让需要拷贝的类以及其内部包含的引用类型类都实现
- 优点:
- Java原生支持,无需引入第三方库。
- 性能通常是最好的。
- 缺点:
- 实现复杂,容易出错。如果对象嵌套层次很深,需要层层重写
clone(),一旦漏掉一层,就变成了不彻底的深拷贝。 Cloneable接口的设计本身在社区中存在一些争议。
- 实现复杂,容易出错。如果对象嵌套层次很深,需要层层重写
2. 利用序列化 (Serialization)
这是一种非常巧妙且常用的方法。
- 步骤:
- 让需要拷贝的类以及其内部所有引用类型类都实现
Serializable接口。 - 将原始对象通过
ObjectOutputStream写入到一个ByteArrayOutputStream中(即序列化到内存里)。 - 再从这个
ByteArrayOutputStream中通过ObjectInputStream读取出来(即从内存中反序列化),得到的新对象就是原始对象的一个深拷贝。
- 让需要拷贝的类以及其内部所有引用类型类都实现
- 优点:
- 实现简单,不易出错,能处理复杂的对象图结构。
- 缺点:
- 性能开销大,远慢于
clone()方法。 - 被拷贝的类及其所有引用的类都必须实现
Serializable接口,否则会抛出异常。 transient和static修饰的字段在序列化过程中会被忽略,因此不会被拷贝。
- 性能开销大,远慢于
3. 使用第三方库 (如 Gson, Jackson, Apache Commons Lang)
这是在实际项目中最推荐的方法,因为它兼顾了简洁性和健壮性。
- 步骤(以Google的Gson库为例):
- 引入Gson库。
- 将原始对象序列化成一个JSON字符串。
- 再将这个JSON字符串反序列化成一个新的对象。
// 示例代码 Gson gson = new Gson(); String json = gson.toJson(originalObject); MyClass deepCopyObject = gson.fromJson(json, MyClass.class); - 优点:
- 代码极其简洁,一行代码即可搞定。
- 非常稳定,由专业团队维护。
- 缺点:
- 需要引入额外的项目依赖。
-
性能通常介于
clone()和Java序列化之间,对于性能极端敏感的场景可能需要斟酌。
8. 对象的创建、访问与回收 (Object Creation, Access, and Garbage Collection)
创建对象的 5 种方式
除了最常见的 new 关键字,Java还提供了其他几种机制来创建对象。
- 使用
new关键字- 这是最直接、最常用的方式。它会调用类的构造函数,在堆上分配内存并返回对象的引用。
MyClass obj = new MyClass();
- 使用反射 (Reflection)
java.lang.reflect包提供了在运行时动态创建对象的能力,它甚至可以调用私有构造函数。- 方式一:
Class.newInstance(): (自 Java 9 起已废弃) 只能调用公共的无参构造函数。 - 方式二:
Constructor.newInstance(): 推荐方式。可以获取任意构造函数(包括私有、带参的)并用其创建实例。Constructor<MyClass> constructor = MyClass.class.getDeclaredConstructor(String.class); constructor.setAccessible(true); // 如果构造函数是私有的 MyClass obj = constructor.newInstance("arg");
- 使用
clone()方法- 在现有对象的基础上创建一个副本。这个过程不会调用任何构造函数。
- 要使用
clone(),类必须实现Cloneable接口并重写clone()方法。 MyClass clone = (MyClass) originalObject.clone();
- 使用反序列化 (Deserialization)
- 从一个字节流(如来自文件或网络)中恢复一个对象。这个过程也不会调用任何构造函数。
- 类必须实现
Serializable接口。 ObjectInputStream in = ...; MyClass obj = (MyClass) in.readObject();
总结: new 和反射会调用构造函数,而 clone 和反序列化不会。
对象的回收:垃圾回收 (GC) 基础
核心问题:对象何时被回收?
一个常见的误解是“当对象离开作用域时就会被回收”。正确的理解是:当一个对象不再被任何“GC Roots”引用时,它就成为垃圾,并有资格被回收。
- 核心原则:可达性分析 (Reachability Analysis)
- JVM的垃圾回收器(GC)会从一系列称为“GC Roots”的根对象开始,遍历所有可触达的对象。所有从GC Roots无法到达的对象,都被认为是垃圾。
- 什么是GC Roots?
- 可以简单理解为代码中当前所有“活的”引用,主要包括:
- 虚拟机栈中的引用:即方法内部的局部变量。
- 静态变量引用的对象:类级别的静态字段。
- 方法区中常量引用的对象:如字符串常量池里的引用。
- 本地方法栈中JNI引用的对象:与Native方法相关的对象。
- 可以简单理解为代码中当前所有“活的”引用,主要包括:
- 回收时机
- 当一个对象变得“不可达”后(例如,引用它的局部变量所在的方法已执行完毕,或将其引用赋值为
null),它只是有资格被回收。 - 真正的回收动作是由GC在后台不确定地执行的。我们无法也无需精确控制一个对象何时被回收。调用
System.gc()只是一个“建议”,JVM不一定会立即执行。
- 当一个对象变得“不可达”后(例如,引用它的局部变量所在的方法已执行完毕,或将其引用赋值为
如何访问私有成员(包括“私有对象”)
你提到的“获取私有对象”通常指两种情况:创建拥有私有构造函数的类的实例或访问一个对象内部的私有字段/方法。这两种情况都可以通过反射 (Reflection) 实现。
- 核心工具:
java.lang.reflect包- 反射是Java提供的一种在运行时检查和操作类、接口、字段和方法的能力。它打破了编译期的封装限制。
- 操作步骤:
- 访问私有字段
MyClass instance = new MyClass(); Field privateField = MyClass.class.getDeclaredField("privateFieldName"); privateField.setAccessible(true); // 关键:取消访问权限检查 Object value = privateField.get(instance); // 读取私有字段的值 - 调用私有方法
MyClass instance = new MyClass(); Method privateMethod = MyClass.class.getDeclaredMethod("privateMethodName", arg1.class, ...); privateMethod.setAccessible(true); // 关键:取消访问权限检查 privateMethod.invoke(instance, "arg1_value", ...); // 调用私有方法 - 创建带私有构造器的对象 (已在创建方式中提及)
- 通过
getDeclaredConstructor()获取构造器,然后setAccessible(true),最后newInstance()。
- 通过
- 访问私有字段
- 为什么要慎用反射?
- 破坏封装:违背了面向对象的设计原则。
- 性能开销:反射操作比直接调用要慢得多。
- 安全性问题:
setAccessible(true)会绕过安全检查。 - 代码可读性差:使得代码逻辑变得复杂和不直观。
结论:反射是把双刃剑。它在框架(如Spring, Hibernate)、注解处理器和测试工具中非常有用,但在常规的业务代码中应极力避免使用。