学习笔记
JAVA基础
JAVA基础知识
一、面向对象基础
1.1 面向对象编程三大特性?
核心要点
- 封装:隐藏内部实现,通过访问修饰符控制可见性,提供 getter/setter
- 继承:子类继承父类功能,可重写和扩展,提高代码复用性,体现 is-a 关系
- 多态:父类引用指向子类对象,编译时看父类,运行时看子类,有编译时多态(重载)和运行时多态(重写)
详细解析
1. 封装(Encapsulation)
- 将对象的属性(数据)和行为(方法)捆绑在一起,对外隐藏内部实现细节
- 通过访问修饰符(private、protected、public)控制可见性
- 通常属性设为 private,通过 getter 和 setter 方法提供受控访问
- 好处:提高安全性、降低耦合、便于维护
2. 继承(Inheritance)
- 把不同对象的共同点抽取为父类,子类继承父类的内容
- 子类可以重写父类方法,也可以增加新的数据或功能
- 提高代码复用性,避免重复编写相同代码
- 使用 extends 关键字,体现 “is-a” 关系(如 Dog is a Animal)
- Java 只支持单继承(一个类只能继承一个父类),但支持多层继承
3. 多态(Polymorphism)
- 父类的引用指向子类的对象,运行时表现出不同子类的行为特征
- 编译时看父类,运行时看子类
- 三个前提:有继承或实现关系、有方法重写、父类引用指向子类对象
- 两种形式:
- 编译时多态(静态绑定):方法重载,编译器根据参数列表确定调用哪个方法
- 运行时多态(动态绑定):方法重写,运行时根据对象的实际类型确定调用的方法
结合项目说:
- 比如短信登录校验,如果增加密码登录和微信登录方式,可以抽取一个抽象的登录接口/父类,定义统一的
login()方法,不同登录方式各自实现。这就是多态的应用。 - 商户对象有属性和方法,但业务方法应该放在 Service 层而不是 POJO 中,这体现了封装和单一职责。
面试口头回答
面向对象的三大特性是封装、继承和多态。
第一是封装。封装是把对象的属性和行为捆绑在一起,对外隐藏内部实现细节。通常把类的属性设为 private,通过 getter 和 setter 方法来提供访问。这样提高了代码的安全性和可维护性,外部不需要知道内部怎么实现的。
第二是继承。继承是把不同对象的共同点抽取为父类,让子类继承父类的功能。子类可以重写父类的行为,也可以增加新的功能。这样提高了代码的复用性,避免了重复编写相同的代码。用 extends 关键字实现,体现的是 is-a 的关系,比如狗是一个动物。
第三是多态。多态是指父类的引用指向子类的对象,运行时会表现出不同子类的行为。它的三个前提是:有继承或实现关系、有方法重写、父类引用指向子类对象。多态有两种形式:编译时多态就是方法重载,编译器根据参数列表确定调用哪个方法;运行时多态就是方法重写,运行时根据对象的实际类型来决定调用哪个方法。
结合实际项目来说,比如登录功能,如果有短信登录、密码登录、微信登录多种方式,我会定义一个登录接口或抽象父类,里面声明 login 方法,每种登录方式各自实现。调用时用父类引用指向具体子类对象,这就是多态的典型应用,也方便后续扩展新的登录方式。
1.2 面向对象和面向过程的区别?
核心要点
- 面向过程:以函数为核心,把问题拆成一个个步骤顺序执行
- 面向对象:以对象为核心,先抽象出对象,通过对象之间的交互完成任务
- 面向对象更易维护、易复用、易扩展;面向过程适合简单线性任务
详细解析
| 特性 | 面向对象(OOP) | 面向过程(POP) |
|---|---|---|
| 核心 | 对象 | 过程/函数 |
| 思想 | 万物皆对象,通过对象交互完成任务 | 将程序分解为一系列步骤,按顺序执行 |
| 代码组织 | 围绕对象和数据组织 | 围绕功能步骤组织 |
| 复用性 | 高(继承、多态) | 低(主要靠复制代码) |
| 扩展性 | 好(新增子类即可扩展) | 差(需要修改原有代码) |
| 维护性 | 好(封装隐藏细节) | 差(逻辑分散在多个函数中) |
| 适用场景 | 复杂系统、大型项目 | 简单、线性的任务 |
举例说明:
- 面向过程做"洗衣服":拿衣服 → 放洗衣液 → 开机 → 晾干(按步骤执行)
- 面向对象做"洗衣服":抽象出人、洗衣机、衣服三个对象,人把衣服放进洗衣机,洗衣机执行洗涤,人拿出晾干(对象之间交互)
面试口头回答
面向对象和面向过程是两种不同的编程范式。
面向过程是以函数为核心的,把解决问题的过程拆成一个个方法,通过一个个方法的顺序执行来解决问题。它适合处理一些比较简单、线性的任务。
面向对象是以对象为核心的,先抽象出对象,然后通过对象之间的交互来完成任务。它的核心思想是"万物皆对象"。
主要区别体现在三个方面:
- 易维护:面向对象通过封装隐藏内部细节,代码更容易维护;
- 易复用:通过继承和多态,代码的复用性更强;
- 易扩展:模块化设计使得系统扩展更加容易和灵活。
打个比方,面向过程做洗衣服就是按步骤来:拿衣服、放洗衣液、开机、晾干。面向对象做洗衣服会抽象出人、洗衣机、衣服这些对象,人把衣服放进洗衣机,洗衣机自己执行洗涤,最后人拿出来晾干。对象之间各司其职、互相协作。
1.3 接口和抽象类的区别?
核心要点
- 共同点:都不能直接实例化,都可以包含抽象方法
- 抽象类:用于定义共性基类,可包含具体方法,有构造方法,单继承,有成员变量
- 接口:用于定义行为规范,Java 8 后可有默认方法和静态方法,无构造方法,多实现,只有常量
详细解析
| 对比维度 | 抽象类(abstract class) | 接口(interface) |
|---|---|---|
| 设计目的 | 定义具有共性的基类,强调"是什么"(is-a) | 定义行为规范,强调"能做什么"(can-do) |
| 实例化 | 不能直接实例化 | 不能直接实例化 |
| 构造方法 | 有构造方法,子类实例化时默认调用 | 没有构造方法 |
| 方法 | 可包含抽象方法和具体方法 | Java 8 前只能有抽象方法;Java 8 后可有 default 和 static 方法 |
| 成员变量 | 可以有普通成员变量 | 只能有 public static final 常量 |
| 继承/实现 | 单继承(extends) | 多实现(implements) |
| 访问修饰符 | 方法可以是任意访问修饰符 | 方法默认 public abstract(Java 8 前) |
| 使用场景 | 代码复用,抽取公共实现 | 定义契约,解耦模块 |
实际使用建议:
- 当多个类有共同代码需要复用时,用抽象类
- 当需要定义一种能力/规范,让不相关的类都能实现时,用接口
- Java 8 之后接口增强了(default 方法),两者界限有所模糊,但设计意图仍有区别
面试口头回答
接口和抽象类有共同点也有区别。
共同点是:它们都不能直接实例化,只能被实现或继承后才能创建具体对象;都可以包含抽象方法。
区别主要有四个方面:
第一,设计目的不同。抽象类用于定义具有共性的基类,强调的是所属关系,主要用于代码复用;接口用于定义行为规范,强调的是行为和能力,用于约束类的行为。
第二,方法实现不同。抽象类可以包含抽象方法和具体方法,还可以有构造方法,子类实例化时会默认调用父类构造方法;接口在 Java 8 之前只能有抽象方法,Java 8 之后可以有默认方法和静态方法,但没有构造方法。
第三,继承机制不同。抽象类是单继承的,一个类只能继承一个抽象类;接口是多实现的,一个类可以实现多个接口。
第四,成员变量不同。抽象类可以有普通的成员变量;接口只能有常量,也就是 public static final 修饰的变量。
实际使用中,如果需要代码复用、抽取公共实现,就用抽象类;如果需要定义一种能力让不同的类都能实现,就用接口。比如 Java 的 List 接口定义了列表的行为规范,而 AbstractList 抽象类提供了部分公共实现。
二、反射
2.1 反射的概念与应用场景
核心要点
- 概念:允许程序在运行时动态加载类,获取类的属性、方法和构造器
- 应用场景:框架开发(Spring/MyBatis IOC、AOP)、注解处理、插件化开发、序列化/反序列化、单元测试
详细解析
反射的核心能力:
- 在运行时获知任意一个对象所属的类
- 在运行时构造任意一个类的对象
- 在运行时获知任意一个类所具有的成员变量和方法
- 在运行时调用任意一个对象的方法
- 生成动态代理
应用场景详解:
框架开发:Spring/Spring Boot、MyBatis 等框架利用反射实现 IOC(控制反转)和依赖注入。比如
@Autowired注解,Spring 在启动时通过反射扫描类上的注解,自动创建 Bean 并注入依赖。动态代理与 AOP:运行时动态生成代理类,拦截方法调用并添加额外逻辑(如事务、日志)。JDK 动态代理就是基于反射实现的。
注解处理:
@Component声明一个类为 Spring Bean,@Value读取配置文件中的值,这些都需要反射来解析和执行。插件化开发:在运行时加载外部类并调用其方法,实现模块化、插件化架构。
序列化/反序列化:Jackson、Gson 等框架使用反射获取对象的属性信息,将其转换为 JSON;反序列化时也通过反射创建对象并设置属性。
单元测试:JUnit 通过反射发现和运行测试方法,无需手动指定每个测试用例。
面试口头回答
反射是 Java 的一种动态特性,允许程序在运行时动态加载类,获取类的属性、方法和构造器等信息。
反射的应用场景非常广泛,我结合实际项目说几个主要的:
第一是框架开发。Spring 和 MyBatis 这些框架都大量使用了反射,比如 Spring 的 IOC 控制反转和依赖注入,就是通过反射扫描类上的注解,自动创建 Bean 并注入依赖。
第二是动态代理和 AOP。运行时动态生成代理类,拦截方法调用并添加额外逻辑,比如事务管理、日志记录,JDK 动态代理就是基于反射实现的。
第三是注解处理。像 Spring 里的 Component、Value 这些注解,框架都是通过反射来解析和执行的。
第四是序列化和反序列化。Jackson、Gson 这些 JSON 框架使用反射获取对象的属性信息,把对象转换成 JSON,反序列化时也通过反射创建对象。
第五是单元测试。JUnit 通过反射自动发现和运行测试方法。
我在项目中使用反射主要是配合 Spring 框架做依赖注入,也用过反射来动态调用一些工具类的方法。
2.2 反射的使用方式
核心要点
- 获取 Class 对象:类名.class、Class.forName()、对象.getClass()、类加载器.loadClass()
- 创建对象:getConstructor() / getDeclaredConstructor() + newInstance()
- 调用方法:getMethod() + invoke()
- 访问/修改字段:getDeclaredField() + setAccessible(true) + set()/get()
详细解析
获取 Class 对象的四种方式:
// 1. 知道具体类时
Class<?> clazz1 = Person.class;
// 2. 通过全限定类名(最常用,框架中常见)
Class<?> clazz2 = Class.forName("com.example.Person");
// 3. 通过对象实例
Person p = new Person();
Class<?> clazz3 = p.getClass();
// 4. 通过类加载器
Class<?> clazz4 = ClassLoader.getSystemClassLoader().loadClass("com.example.Person");
创建对象:
// 获取无参构造器并创建对象
Person p1 = (Person) clazz.getConstructor().newInstance();
// 获取有参构造器(更灵活)
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true); // 可访问私有构造器
Person p2 = (Person) constructor.newInstance("Alice", 20);
调用方法:
Method method = clazz.getMethod("sayHello", String.class);
Object result = method.invoke(p1, "World");
访问和修改字段:
Field field = clazz.getDeclaredField("name");
field.setAccessible(true); // 绕过访问控制,访问私有字段
field.set(p1, "Bob"); // 修改字段值
Object value = field.get(p1); // 获取字段值
面试口头回答
反射的使用方式我按步骤来说:
第一步是获取 Class 对象。有四种方式:直接用类名.class;用 Class.forName() 传入全限定类名,这是框架里最常用的;通过对象实例的 getClass() 方法;或者通过类加载器的 loadClass() 方法。
第二步是创建对象。可以通过 getConstructor 获取构造器,再用 newInstance 创建对象。如果要调用私有构造器,需要用 getDeclaredConstructor 并设置 setAccessible(true) 来绕过访问控制。
第三步是调用方法。通过 getMethod 获取方法对象,然后用 invoke 来执行,第一个参数是目标对象,后面是方法参数。
第四步是访问和修改字段。通过 getDeclaredField 获取字段对象,设置 setAccessible(true) 打破访问限制,然后用 set 方法修改字段值,用 get 方法获取字段值。
整个流程就是先获取 Class 对象,然后获取构造器、方法或字段,设置可访问性,最后执行创建、调用或修改操作。
2.3 反射的优点和缺点
核心要点
- 优点:动态性、灵活性高,适合框架开发;调试和测试中有用,可访问私有成员
- 缺点:安全问题(破坏封装、绕过泛型检查)、可读性降低、维护性差、性能开销大
详细解析
优点:
- 动态性和灵活性:运行时动态加载类、创建对象、调用方法,不需要在编译期知道类的具体信息
- 框架开发的基石:Spring、MyBatis 等框架的核心能力都依赖反射
- 调试和测试便利:可以访问私有成员,便于测试私有方法
缺点:
安全问题:
setAccessible(true)可以强制访问 private 成员,破坏了封装性- 无视泛型参数的安全检查(泛型检查发生在编译期,反射在运行时操作)
可读性降低:
- 基于字符串 + 变量引用难以识别是哪个类,如
Class.forName("com.example." + className) - 代码意图不明确,IDE 难以做静态分析
- 基于字符串 + 变量引用难以识别是哪个类,如
代码维护性差:
- 反射调用的类不会在编译时建立依赖关系,运行时才发现类不存在
- 重命名类/方法时,反射相关代码不会自动更新,导致运行时错误
- 堆栈信息复杂,问题定位耗时
性能问题:
- 涉及 JVM 中 Class 对象查找、Method/Field 解析、访问权限检查,都有性能开销
- 比直接调用慢很多(但现代 JVM 有优化,如 MethodHandle、反射调用缓存)
面试口头回答
反射的优点和缺点都很明显。
优点方面:
- 第一是提供了动态性和灵活性,运行时动态加载类、创建对象、调用方法,不需要编译期就知道类的具体信息,非常适合框架开发。
- 第二是在调试和测试中很有用,可以访问私有成员,方便测试私有方法。
缺点方面:
- 第一是安全问题。通过 setAccessible(true) 可以强制访问私有成员,破坏了封装性;而且反射会绕过泛型的编译期类型检查。
- 第二是可读性降低。反射代码基于字符串和变量引用,比如 Class.forName 里拼接类名,很难一眼看出操作的是哪个类,IDE 也不好做静态分析。
- 第三是维护性差。反射调用的类不会在编译时建立依赖,运行时才发现类不存在;而且重命名类或方法时,反射代码不会自动更新,容易导致运行时错误。堆栈信息也比较复杂,出问题不好定位。
- 第四是性能问题。反射涉及 Class 对象查找、Method 解析、访问权限检查,都有性能开销,比直接方法调用慢。
所以实际使用中,反射主要在框架层使用,业务代码中尽量避免滥用反射。
2.4 反射的底层实现原理
核心要点
- Class 对象是反射的核心:每个被 JVM 加载的类在方法区生成对应的 Class 对象,存储类的元数据
- 字段访问:通过 Field 对象获取字段偏移量,基于对象头指针计算绝对内存地址,直接操作堆内存
- 方法调用:解析方法的字码入口地址,动态构建调用栈帧
- 对象实例化:绕过常规 new 字节码,直接调用堆内存分配器
详细解析
Class 对象:
- 每个被 JVM 加载的
.class文件都会在方法区生成一个对应的Class对象 - Class 对象存储该类的元数据:类型描述符(类名、继承体系、泛型信息)、字段偏移量表(字段名→内存偏移量)、方法调用表(方法签名→代码入口地址)、构造器调用模板
字段访问原理:
对象头指针 → 字段偏移量 → 内存读写
- 通过 Field 对象获取字段在对象中的偏移量
- 基于对象头指针 + 偏移量计算绝对内存地址
- 直接对堆内存进行读写操作
方法调用原理:
方法表查找 → 参数装箱 → 栈帧构建 → 执行跳转
- 解析方法的字节码入口地址
- 动态构建调用栈帧
- 进行类型安全检查与参数转换
- 跳转到方法代码执行
对象实例化原理:
分配内存 → 初始化对象头 → 调用指定构造器
- 绕过常规 new 字节码指令
- 直接调用堆内存分配器分配对象内存
- 初始化对象头
- 调用指定的构造器方法
面试口头回答
反射的底层实现与 JVM 紧密相关。
核心是 Class 对象。每个被 JVM 加载的类都会在方法区生成一个对应的 Class 对象,存储该类的元数据,包括类型描述符、字段偏移量表、方法调用表、构造器调用模板等。
字段访问的过程:反射通过 Field 对象获取字段偏移量,然后基于对象头指针加上偏移量计算绝对内存地址,直接对堆内存进行读写操作。
方法调用的过程:反射解析方法的字节码入口地址,动态构建调用栈帧,进行类型安全检查和参数转换,然后跳转到方法代码执行。
对象实例化的过程:反射绕过常规的 new 字节码,直接调用堆内存分配器分配对象内存,初始化对象头,然后调用指定的构造器方法。
总结来说,反射让程序具备了运行时动态操作类的能力,是 Java 动态特性的重要基础。
三、String
3.1 String 什么时候加载?存在 JVM 的哪块区域?
核心要点
- 字面量字符串:编译期存入常量池,类加载时加载到 JVM(运行时常量池)
- new String():运行时创建,在堆中生成新对象
- JDK7 之前:字符串常量池在方法区;JDK7 及之后:字符串常量池移到堆中
详细解析
String 的创建方式与加载时机:
| 创建方式 | 加载时机 | 存储位置 |
|---|---|---|
字面量 "abc" | 编译期存入 class 文件常量池,类加载时进入运行时常量池 | JDK7 前:方法区永久代;JDK7+:堆中的字符串常量池 |
new String("abc") | 运行时 | 堆中创建新对象 |
示例分析:
String s1 = "abc"; // 常量池中的引用
String s2 = "abc"; // 与 s1 指向常量池同一个对象
String s3 = new String("abc"); // 堆中新对象
s1 == s2; // true,指向常量池同一对象
s1 == s3; // false,s3 是堆中新对象
s3.equals("abc"); // true,内容相同
JDK7 的变化:
- JDK6:字符串常量池在方法区(永久代)
- JDK7+:字符串常量池移到堆中
- 原因:永久代大小有限,字符串常量池过大容易导致 OutOfMemoryError
- 移到堆后可以利用堆的垃圾回收机制,更灵活
面试口头回答
String 的加载时间取决于创建方式:
字面量字符串,比如
String s = "hello",在编译期就存入类的常量池,类加载时加载到 JVM 的运行时常量池中。new String() 创建的字符串,在运行时在堆中生成,每次都会创建新对象。
也就是说,字面量字符串是常量池共享对象,而 new String 是堆中独立对象。
存储位置上,String 对象的内容存在堆内存中。从 JDK7 开始,字符串常量池也从方法区移到了堆中。这样做的好处是避免了永久代大小有限导致的内存溢出问题,同时可以利用堆的垃圾回收机制更灵活地管理字符串内存。
举个例子:
String s1 = "abc"; String s2 = "abc";这两行只会在常量池创建一个 “abc” 对象,s1 和 s2 指向同一个对象。但String s3 = new String("abc");会在堆中新建一个对象,s3 和常量池里的 “abc” 不是同一个对象,所以 s3 == “abc” 是 false,但 equals 是 true。
3.2 String 为什么不可变?
核心要点
- String 类被 final 修饰,不能被继承,避免子类破坏
- 保存字符串的 byte[](JDK9+,之前是 char[])被 final 修饰且为私有
- String 类没有提供/暴露修改字符串内容的方法
- 不可变的好处:线程安全、可作为 HashMap 的 key、字符串常量池复用、安全性
详细解析
不可变的实现机制:
- 类被 final 修饰:
public final class String,不能被继承,防止子类修改行为 - 字符数组被 final 和 private 修饰:
private final byte[] value;(JDK9+)- final 保证引用不可变(不能指向新的数组)
- private 保证外部无法直接访问和修改
- 不提供修改方法:String 的所有修改方法(如 replace、substring、concat)都返回新的 String 对象,不会修改原对象
不可变的好处:
- 线程安全:不可变对象天然线程安全,无需同步
- HashMap 的 key:String 是 HashMap 最常用的 key,不可变性保证 hashCode 不变
- 字符串常量池复用:只有不可变才能实现常量池复用,节省内存
- 安全性:防止字符串被篡改(如网络连接地址、文件路径等)
面试口头回答
String 不可变主要有两个原因:
第一,String 类本身被 final 修饰,不能被继承,这样就避免了子类破坏它的行为。
第二,保存字符串内容的数组被 final 修饰且是私有的。从 JDK9 开始 String 内部用 byte 数组存储,这个数组是 private final 的,final 保证引用不可变,private 保证外部无法访问。而且 String 类没有提供任何修改字符串内容的方法,所有的修改操作比如 replace、substring、concat,都是返回一个新的 String 对象,原对象不会被改变。
String 设计成不可变有很多好处:
- 线程安全,不可变对象天然线程安全,不用加锁;
- 可以作为 HashMap 的 key,因为 hashCode 不会变;
- 字符串常量池可以复用对象,节省内存;
- 安全性,防止字符串被恶意篡改,比如网络地址、文件路径这些。
如果 String 是可变的,那常量池就无法复用了,HashMap 的 key 也会出问题,线程安全也没法保证。
3.3 String、StringBuffer、StringBuilder 的区别?
核心要点
- String:不可变,每次修改生成新对象,性能最差
- StringBuffer:可变,线程安全(synchronized),性能较好
- StringBuilder:可变,线程不安全,性能最好
- 单线程用 StringBuilder,多线程用 StringBuffer,很少修改用 String
详细解析
| 特性 | String | StringBuffer | StringBuilder |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全(不可变) | 安全(synchronized) | 不安全 |
| 性能 | 最差(每次修改创建新对象) | 较好(有同步开销) | 最好(无同步开销) |
| 使用场景 | 字符串常量、不经常修改 | 多线程下频繁修改 | 单线程下频繁修改 |
| JDK 版本 | 1.0 | 1.0 | 1.5 |
StringBuffer 的特点:
- 可变性:在原有对象上直接修改,无需创建新对象
- 线程安全:所有方法都用 synchronized 修饰
- 内部也是字符数组,但容量不足时自动扩容(默认 16,扩容 2 倍 + 2)
性能对比:
- 循环拼接字符串时,String 会创建大量中间对象,导致频繁 GC
- StringBuilder 最优,StringBuffer 因同步略有开销
- 现代 JVM 对 String 的
+拼接有优化(编译期转为 StringBuilder),但循环中仍不建议用+
面试口头回答
String、StringBuffer 和 StringBuilder 的主要区别在可变性、线程安全和性能三个方面。
String 是不可变的,每次修改都会生成新对象,比如拼接字符串时会创建大量中间对象,性能最差。但它是线程安全的,因为不可变。
StringBuffer 是可变的,可以在原有对象上直接修改内容,不需要创建新对象。它的所有方法都用 synchronized 修饰,所以是线程安全的。但因为有同步开销,性能比 StringBuilder 略差。
StringBuilder 也是可变的,和 StringBuffer 类似,但它没有 synchronized,所以线程不安全,性能最好。
使用场景上:
- 如果字符串不经常修改,用 String;
- 单线程环境下频繁修改字符串,用 StringBuilder;
- 多线程环境下频繁修改字符串,用 StringBuffer。
实际开发中,局部变量拼接字符串优先用 StringBuilder,避免 String 的
+操作在循环中产生大量临时对象。
四、异常
4.1 Exception 和 Error 的区别?编译异常和运行异常?
核心要点
- Throwable 是顶层父类,分为 Exception 和 Error
- Exception:程序本身可以处理,分为 Checked(受检)和 Unchecked(非受检/运行时)
- Error:程序无法处理的严重错误,不建议捕获,如 OOM、StackOverflowError
- 非受检异常通常用局部 try-catch 或全局 @ControllerAdvice 统一处理
详细解析
异常体系:
Throwable
├── Error(严重错误,不可恢复)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ ├── VirtualMachineError
│ └── NoClassDefFoundError
│
└── Exception(可处理的异常)
├── Checked Exception(受检异常)
│ ├── IOException
│ ├── FileNotFoundException
│ ├── ClassNotFoundException
│ └── SQLException
│
└── Unchecked Exception(非受检异常/运行时异常)
├── RuntimeException
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ ├── ArrayIndexOutOfBoundsException
│ ├── ClassCastException
│ └── ArithmeticException
└── ...
Checked vs Unchecked Exception:
| 特性 | Checked Exception | Unchecked Exception |
|---|---|---|
| 继承自 | Exception(非 RuntimeException) | RuntimeException |
| 编译器检查 | 强制要求 catch 或 throws | 不强制 |
| 处理时机 | 编译期 | 运行期 |
| 常见例子 | IOException、SQLException | NullPointerException、IllegalArgumentException |
| 代表含义 | 外部条件导致的异常,可预期 | 程序逻辑错误 |
非受检异常的处理方式:
- 局部 try-catch:在能恢复的地方捕获处理
- 全局统一捕获:通过
@ControllerAdvice+@ExceptionHandler做统一捕获和响应格式化 - 自定义 RuntimeException:处理业务错误
- 结合日志与告警系统进行监控,确保异常可追踪
面试口头回答
Exception 和 Error 都继承自 Throwable,但有本质区别。
Exception 是程序本身可以处理的异常。可以通过 catch 捕获或者手动 throws 抛出。它又分为两种:
- Checked Exception(受检异常):编译器会强制要求处理,要么 try-catch 要么 throws,比如 FileNotFoundException、SQLException、ClassNotFoundException。这些通常表示外部条件导致的异常,是可预期的。
- Unchecked Exception(非受检异常/运行时异常):编译器不强制处理,继承自 RuntimeException,比如 NullPointerException、IllegalArgumentException、ArrayIndexOutOfBoundsException、ClassCastException。这些通常表示程序逻辑错误。
Error 是程序无法处理的严重错误,不建议通过 catch 捕获。比如 OutOfMemoryError 内存溢出、StackOverflowError 栈溢出、NoClassDefFoundError 类定义错误。这些异常发生时,JVM 一般会选择终止线程。
非受检异常的处理,我在项目中通常采用两种方式:一是在能恢复的地方局部 try-catch;二是在全局通过 @ControllerAdvice 做统一捕获和响应格式化。同时会定义自定义的 RuntimeException 来处理业务错误,结合日志与告警系统进行监控,确保异常可追踪。
五、拷贝
5.1 深拷贝、浅拷贝和引用拷贝的区别?
核心要点
- 引用拷贝:只复制引用地址,两个变量指向同一个对象
- 浅拷贝:创建新对象,但引用类型字段仍指向原对象的地址
- 深拷贝:创建新对象,递归复制对象内部所有引用类型数据
详细解析
引用拷贝:
Person p1 = new Person("Alice", 20);
Person p2 = p1; // 引用拷贝
// p1 和 p2 指向同一个对象
- 只复制对象的引用地址
- 原始变量和新变量都指向同一个对象
- 修改 p2 的属性会影响 p1
浅拷贝:
// 实现 Cloneable 接口,调用 Object.clone()
Person p2 = (Person) p1.clone();
- 创建一个新对象
- 新对象中的基本类型字段会复制值
- 但引用类型字段仍然指向原对象中引用类型的内存地址
- 修改新对象中的引用类型数据会影响原对象
深拷贝:
// 方式一:重写 clone(),对引用类型递归拷贝
// 方式二:序列化(推荐)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(p1);
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
Person p2 = (Person) ois.readObject();
- 创建一个新对象
- 递归复制对象内部的所有引用类型数据
- 修改新对象中的任何数据都不会影响原对象
| 特性 | 引用拷贝 | 浅拷贝 | 深拷贝 |
|---|---|---|---|
| 是否创建新对象 | 否 | 是 | 是 |
| 基本类型字段 | 共享 | 复制 | 复制 |
| 引用类型字段 | 共享 | 共享 | 复制(递归) |
| 修改新对象影响原对象 | 是 | 引用类型会 | 否 |
| 实现方式 | 直接赋值 | clone() | 递归 clone 或序列化 |
面试口头回答
深拷贝、浅拷贝和引用拷贝的核心区别在于是否创建新对象,以及是否递归复制引用类型数据。
引用拷贝就是直接把一个对象的引用赋值给另一个变量,两个变量指向同一个对象,不会复制对象本身。修改其中一个变量会影响另一个。
浅拷贝会创建一个新对象,新对象的基本类型字段会复制值,但引用类型字段仍然指向原对象中的同一个内存地址。也就是说,修改新对象中的引用类型数据会影响原对象。浅拷贝通常实现 Cloneable 接口,直接调用 Object 的 clone 方法。
深拷贝不仅创建新对象,还会递归复制对象内部的所有引用类型数据。修改新对象中的任何数据都不会影响原对象。深拷贝的实现方式有两种:一是实现 Cloneable 接口并重写 clone 方法,对引用类型字段进行递归拷贝;二是使用序列化的方式,把对象序列化为字节流再反序列化为新对象。
实际使用中,如果需要完全独立的对象副本,就用深拷贝;如果只是简单复制且内部引用对象不会修改,可以用浅拷贝。
六、包装类与基础类型
6.1 int 和 Integer 的区别?自动拆箱/装箱?包装类缓存机制?
核心要点
- int 是基本数据类型,直接存数值;Integer 是包装类,是引用类型
- 自动装箱:基本类型 → 包装类(调用 valueOf())
- 自动拆箱:包装类 → 基本类型(调用 intValue() 等)
- 缓存机制:Byte、Short、Integer、Long 缓存 [-128, 127];Character 缓存 [0, 127]
- 包装类对象之间比较用 equals,不要用 ==
详细解析
int vs Integer:
| 特性 | int | Integer |
|---|---|---|
| 类型 | 基本数据类型 | 引用数据类型(包装类) |
| 存储位置 | 栈(局部变量)/ 堆(成员变量) | 堆 |
| 内存占用 | 4 字节 | 约 16 字节(对象头 + 值 + 对齐) |
| == 比较 | 比较值 | 比较内存地址 |
| 功能 | 仅存储数值 | 支持泛型、序列化、方法调用 |
| 默认值 | 0 | null |
自动装箱与拆箱:
// 自动装箱:int → Integer
Integer i = 10; // 等价于 Integer i = Integer.valueOf(10);
// 自动拆箱:Integer → int
int n = i; // 等价于 int n = i.intValue();
- 自动装箱调用
valueOf()方法 - 自动拆箱调用
intValue()、longValue()等方法 - 空指针风险:对 null 的包装类对象进行自动拆箱会抛出 NullPointerException
包装类缓存机制:
Integer a = 100; // 从缓存取
Integer b = 100; // 从缓存取,a == b 为 true
Integer c = 200; // 超出缓存范围,新建对象
Integer d = 200; // 新建对象,c == d 为 false
- Byte、Short、Integer、Long:缓存 [-128, 127]
- Character:缓存 [0, 127]
- Boolean:缓存 true 和 false
- Float、Double:没有缓存
注意:所有整型包装类对象之间值的比较,全部使用 equals 方法,不要用 ==。
面试口头回答
int 和 Integer 的区别主要有四个方面:
第一,类型不同。int 是基本数据类型,直接存储数值;Integer 是包装类,属于引用数据类型。
第二,存储位置。int 的局部变量存放在栈中,成员变量存放在堆中;Integer 作为对象,存在于堆中。
第三,== 比较。int 用 == 比较的是值;Integer 用 == 比较的是对象的内存地址,比较值要用 equals。
第四,功能差异。int 只存储数值;Integer 提供了更多功能,支持集合泛型、序列化,还有缓存机制。
自动装箱和拆箱是编译器自动实现的类型转换。装箱是基本类型转包装类,自动调用 valueOf 方法;拆箱是包装类转基本类型,自动调用 intValue 这些方法。要注意的是,对 null 的包装类对象进行自动拆箱会抛 NullPointerException。
包装类的缓存机制:Integer、Byte、Short、Long 默认缓存了 -128 到 127 范围的对象,Character 缓存了 0 到 127。超出这个范围会创建新对象。所以包装类对象之间比较值一定要用 equals,不要用 ==,不然可能出现 100 == 100 为 true,但 200 == 200 为 false 的情况。
七、泛型
7.1 泛型的概念、使用方式和泛型擦除
核心要点
- 概念:允许创建集合时指定元素的具体类型,编译期提供类型安全检查
- 使用方式:泛型类、泛型接口、泛型方法
- 泛型擦除:泛型仅在编译期可见,运行时类型信息被擦除,转为 Object 或边界类型
- 好处:编译期类型安全,避免 ClassCastException
详细解析
为什么需要泛型:
- 没有泛型时,集合默认存储 Object 类型
- 取出元素时需要手动强制类型转换
- 增加了编程复杂性,还可能引发 ClassCastException
- 泛型在编译期进行类型检查,类型不匹配直接编译报错
使用方式:
// 1. 泛型类
public class Box<T> {
private T data;
public void setData(T data) { this.data = data; }
public T getData() { return data; }
}
// 2. 泛型接口
public interface Comparator<T> {
int compare(T o1, T o2);
}
// 3. 泛型方法
public <T> void printArray(T[] array) {
for (T item : array) System.out.println(item);
}
泛型擦除(Type Erasure):
- Java 泛型是"伪泛型",仅在编译期有效
- 编译后泛型类型信息被擦除:
List<String>和List<Integer>编译后都变成List- 类型参数 T 被替换为 Object(或边界类型)
- 编译器在擦除类型前插入强制类型转换代码
- 优点:兼容 JDK5 之前的代码,减少运行时类型信息的内存开销
- 缺点:运行时无法获取泛型的具体类型(如
new T()不合法)
面试口头回答
泛型是 Java 提供的一种类型参数化机制,允许在创建集合或类时指定具体的类型,在编译期进行类型安全检查。
如果没有泛型,集合默认存储 Object 类型,取出元素时必须手动强制类型转换,不仅增加了编程复杂性,还可能引发 ClassCastException。有了泛型之后,比如
ArrayList<Person>就指明了这个集合只能传入 Person 对象,传入其他类型会在编译期报错。泛型的使用方式有三种:
- 泛型类:在类名后添加类型参数,比如
class Box<T>;- 泛型接口:接口声明类型参数,比如
interface Comparator<T>;- 泛型方法:在方法返回值前声明类型参数,比如
<T> void printArray(T[] array)。泛型擦除是 Java 泛型的一个重要特性。Java 的泛型只在编译期可见,编译器进行类型检查后会将泛型信息擦除。比如
List<String>和List<Integer>编译后都变成List,类型参数 T 会被替换成 Object。这样做的好处是兼容 JDK5 之前的代码,同时减少运行时存储类型信息的内存开销。但缺点是运行时无法获取泛型的具体类型信息。
八、hashCode 与 equals
8.1 hashCode() 方法?为什么要重写?重写 equals 必须重写 hashCode?
核心要点
- hashCode() 计算对象的哈希值,用于确定对象在哈希表中的索引位置
- 默认实现用对象内存地址计算,不能根据对象属性内容去重
- 自定义类作为 HashMap/HashSet 的 key 时,必须重写 hashCode 和 equals
- 只重写 equals 不重写 hashCode:相同属性的对象可能分配到不同哈希桶
- 只重写 hashCode 不重写 equals:相同属性的对象进入同一桶但被认为不同
详细解析
hashCode 的作用:
- Object 类的方法,返回一个 int 类型的哈希值
- 在哈希表中用于快速定位对象的存储位置
- 理想情况下,hashCode 能将对象均匀分布到哈希桶中,实现 O(1) 的增删改查
默认实现的缺陷:
- Object 默认的 hashCode() 和 equals() 都基于对象的内存地址
new两个属性完全相同的对象,内存地址不同,hashCode 不同,equals 也不同- 这会导致在 HashMap/HashSet 中,属性相同的对象会被当作不同的对象存储,违背业务逻辑
重写规则:
只重写 equals,不重写 hashCode:
- equals 认为两个对象相同(按属性比较)
- 但 hashCode 按内存地址计算,两个对象 hashCode 不同
- 结果:两个"相同"的对象被分配到不同的哈希桶,HashMap 中出现重复 key
只重写 hashCode,不重写 equals:
- hashCode 相同,两个对象进入同一个哈希桶
- 但 equals 按内存地址比较,认为它们不同
- 结果:同一个桶里出现"重复"对象
equals 和 hashCode 都重写:
- hashCode 相同 → 进入同一个桶
- equals 比较属性 → 确认是同一个对象 → 覆盖旧值
- 这才是正确的行为
契约(Contract):
- 同一个对象多次调用 hashCode(),必须返回相同的值
- equals 相等的两个对象,hashCode 必须相等
- hashCode 相等的两个对象,equals 不一定相等(哈希冲突)
面试口头回答
hashCode 是 Object 类的一个方法,用来计算对象的哈希值,是一个整数值。它的作用是确定对象在哈希表中的索引位置,实现 O(1) 的快速定位。
自定义类为什么要重写 hashCode?因为 Object 默认的 hashCode 是用对象的内存地址计算的,equals 也是按内存地址比较。假设我们 new 两个对象,它们的 id 一样,但内存地址不一样,默认的 hashCode 会把它们分配到不同的哈希桶里,id 一样的对象就在哈希表中重复存储了,这不符合业务逻辑。
重写 equals 必须重写 hashCode,这是 Java 的规范。如果只重写 equals 不重写 hashCode,会出现这样的问题:equals 认为两个对象相同,但 hashCode 按内存地址计算不一样,结果两个"相同"的对象被分配到不同的哈希桶,HashMap 里就出现重复的 key。反过来如果只重写 hashCode 不重写 equals,两个对象 hashCode 相同进入了同一个桶,但 equals 按内存地址认为它们不同,一个桶里也会出现重复对象。
所以只有 hashCode 和 equals 都重写,都按对象的属性值来判断,才能保证对象去重的正确性。
九、final 和 static
9.1 final 和 static 的区别?
核心要点
- final:表示不可改变,可修饰类、方法、变量
- static:表示静态,属于类而非实例,可修饰成员变量、方法、代码块、内部类
- final 变量可在声明时或构造方法中初始化;static 变量在类加载时初始化
详细解析
final:
- 修饰类:类不能被继承(如 String、Math)
- 修饰方法:方法不能被重写
- 修饰变量:
- 基本类型:值不可变(常量)
- 引用类型:引用地址不可变,但对象内容可变
- 初始化时机:声明时初始化,或在构造方法中初始化,必须在使用前赋值
static:
- 修饰变量:类变量,所有实例共享,类加载时初始化
- 修饰方法:类方法,属于类而非实例,可直接通过类名调用
- 修饰代码块:静态代码块,类加载时执行,只执行一次
- 修饰内部类:静态内部类,不依赖外部类实例就能创建
- 不能修饰顶级类(外部类)
| 特性 | final | static |
|---|---|---|
| 含义 | 不可改变 | 属于类,而非实例 |
| 修饰类 | 可以(不可继承) | 不可以(只能修饰内部类) |
| 修饰方法 | 可以(不可重写) | 可以(类方法) |
| 修饰变量 | 可以(值/引用不可变) | 可以(类变量) |
| 初始化时机 | 声明时或构造方法中 | 类加载时 |
| 使用方式 | 通过对象访问 | 通过类名直接访问 |
面试口头回答
final 和 static 是两个完全不同的关键字,含义和用途都不一样。
final 表示不可改变:
- 修饰类时,表示这个类不能被继承,比如 String 类就是 final 的;
- 修饰方法时,表示这个方法不能被子类重写;
- 修饰变量时,基本类型表示数值一旦初始化就不能改变,引用类型表示引用地址不能改变,但它指向的对象内容是可以变的。
static 表示静态:
- 修饰变量时,这个变量属于类而不是类的实例,所有对象共享同一个静态变量;
- 修饰方法时,这个方法属于类,可以直接通过类名调用,不需要创建对象;
- 修饰代码块时,是静态代码块,类加载时执行,而且只执行一次;
- static 还可以修饰内部类,变成静态内部类,不需要依赖外部类的实例就能创建。
初始化时机也不同:final 成员变量可以在声明时初始化,也可以在构造方法中初始化,但必须在使用前赋值;static 变量在类加载时就会被初始化,而且只会初始化一次。
这两个关键字可以一起用,比如
public static final int MAX = 100;就是定义一个全局常量。
十、== 和 equals
10.1 == 和 equals 的区别?
核心要点
- ==:基本类型比较值,引用类型比较内存地址
- equals:未重写时比较内存地址(Object 的实现),重写后按自定义逻辑比较
- String、Integer 等包装类重写了 equals,比较内容
详细解析
== 运算符:
- 基本数据类型:比较的是值是否相等
- 引用数据类型:比较的是对象的内存地址是否相同(是否为同一个对象)
equals 方法:
- 类没有重写 equals:默认调用 Object 类的实现,比较内存地址,和 == 效果相同
- 类重写了 equals(如 String、Integer):根据重写的逻辑判断两个对象是否"相等"
String 的 equals:
String s1 = new String("abc");
String s2 = new String("abc");
s1 == s2; // false,内存地址不同
s1.equals(s2); // true,内容相同
Integer 的 equals:
Integer a = 1000;
Integer b = 1000;
a == b; // false,超出缓存范围,不同对象
a.equals(b); // true,值相同
面试口头回答
== 和 equals 的区别要分情况来看:
== 运算符:对于基本数据类型,== 比较的是值是否相等;对于引用数据类型,== 比较的是对象的内存地址,也就是判断是不是同一个对象。
equals 方法:如果类没有重写 equals,默认调用的是 Object 类的实现,比较的是内存地址,这和 == 效果是一样的。但如果类重写了 equals,比如 String、Integer,就会按照重写的逻辑来判断两个对象是否相等,通常是比较内容。
举个例子:
String s1 = new String("abc"); String s2 = new String("abc");这时 s1 == s2 是 false,因为内存地址不同;但 s1.equals(s2) 是 true,因为 String 重写了 equals,比较的是字符串内容。实际使用中,比较对象内容一定要用 equals,不要用 ==,除非是刻意判断是否为同一个对象。
十一、重载和重写
11.1 重载和重写的区别?
核心要点
- 重载:同一类中,同名方法不同参数列表,编译时多态(静态绑定)
- 重写:继承/实现时,子类对父类/接口方法的重新实现,运行时多态(动态绑定)
- 重载返回值、异常、访问修饰符无所谓;重写返回值类型 <= 父类,异常 <= 父类,访问修饰符 >= 父类
详细解析
| 特性 | 重载(Overload) | 重写(Override) |
|---|---|---|
| 发生位置 | 同一个类中 | 子类对父类/接口 |
| 方法名 | 必须相同 | 必须相同 |
| 参数列表 | 必须不同(类型、个数、顺序) | 必须相同 |
| 返回值 | 可以不同 | 子类 <= 父类(协变返回) |
| 异常 | 可以不同 | 子类 <= 父类 |
| 访问修饰符 | 可以不同 | 子类 >= 父类 |
| 绑定方式 | 静态绑定(编译时确定) | 动态绑定(运行时确定) |
| 多态类型 | 编译时多态 | 运行时多态 |
重载示例:
public class Calculator {
public int add(int a, int b) { return a + b; }
public double add(double a, double b) { return a + b; }
public int add(int a, int b, int c) { return a + b + c; }
}
重写示例:
class Animal {
public void makeSound() { System.out.println("动物叫声"); }
}
class Dog extends Animal {
@Override
public void makeSound() { System.out.println("汪汪"); }
}
面试口头回答
重载和重写是两个完全不同的概念。
重载是在同一个类中,同名方法根据不同的参数列表来执行不同的逻辑。方法名必须相同,参数列表必须不同,可以是类型不同、个数不同或者顺序不同。返回值、异常、访问修饰符无所谓。重载是静态绑定,编译时根据参数列表确定调用哪个方法,属于编译时多态。
重写是在继承或实现的时候,子类对父类或者接口的方法进行重新实现。方法名和参数列表必须和父类相同。子类的返回值类型要小于等于父类,抛出的异常范围要小于等于父类,访问修饰符的范围要大于等于父类。重写是动态绑定,运行时根据对象的实际类型决定调用哪个方法,属于运行时多态。
简单记就是:重载是"同名不同参",重写是"同名同参子类改实现"。
十二、对象创建
12.1 Java 创建对象的四种常见方式?
核心要点
- new 关键字:最常用,调用构造方法
- 反射:Class.newInstance() / Constructor.newInstance(),运行时动态创建
- clone():实现 Cloneable 接口,复制对象,不调用构造方法
- 反序列化:实现 Serializable 接口,从字节流恢复对象,不调用构造方法
详细解析
1. new 关键字:
Person p = new Person("Alice", 20);
- 最常用、最直接的方式
- 调用类的构造方法进行初始化
2. 反射:
// 方式一:只能调用无参构造
Person p1 = (Person) Class.forName("com.example.Person").newInstance();
// 方式二:可以调用任意构造(包括私有)
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);
Person p2 = (Person) constructor.newInstance("Alice", 20);
- 运行时动态创建对象
- Constructor 方式更灵活,可以调用任意构造方法
3. clone():
Person p1 = new Person("Alice", 20);
Person p2 = (Person) p1.clone();
- 需要实现 Cloneable 接口,重写 clone() 方法
- 创建相同内容的新对象,不调用构造方法
- 默认进行浅拷贝
4. 反序列化:
// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("person.ser"));
oos.writeObject(p1);
// 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("person.ser"));
Person p2 = (Person) ois.readObject();
- 对象必须实现 Serializable 接口
- 从存储或传输的字节流恢复成 Java 对象
- 不调用构造方法
- 性能开销较大,适用于数据存储或网络传输
面试口头回答
Java 创建对象有四种常见方式:
第一种是 new 关键字。这是最常用、最直接的方式,调用类的构造方法进行初始化。
第二种是反射。通过 Class.newInstance 可以创建对象,但只能调用无参构造。更灵活的是用 Constructor.newInstance,可以调用任意构造方法,包括私有构造方法。反射在运行时动态创建对象,是 Spring 这些框架的核心机制。
第三种是 clone 方法。需要实现 Cloneable 接口并重写 clone 方法。它创建一个内容相同的新对象,但不会调用构造方法。要注意的是 clone 默认是浅拷贝,如果需要深拷贝要额外处理。
第四种是反序列化。对象必须实现 Serializable 接口,通过 ObjectInputStream 从字节流中恢复对象。这种方式也不会调用构造方法。性能开销比较大,适用于数据存储或网络传输的场景,不适合频繁创建对象。
12.2 创建对象的六大原则
核心要点
- 单一职责原则(SRP):每个类只关注一个职责
- 开闭原则(OCP):对扩展开放,对修改关闭
- 里氏替换原则(LSP):子类可替换父类不影响程序运行
- 依赖倒置原则(DIP):依赖抽象而非具体实现
- 接口隔离原则(ISP):接口设计精简,避免强制实现不需要的方法
- 迪米特法则(LOD):减少对象之间的耦合
详细解析
1. 单一职责原则(Single Responsibility Principle)
- 每个类只负责一个明确的功能领域
- 一个类承担的职责越多,被修改的原因就越多,维护成本越高
- 例:UserService 只处理用户相关逻辑,不要混进订单逻辑
2. 开闭原则(Open-Closed Principle)
- 类应该对扩展开放,对修改关闭
- 通过抽象和多态,新增功能时不需要修改已有代码
- 常用实现:工厂模式、策略模式、建造者模式
- 例:新增支付方式时,实现 Payment 接口即可,无需修改原有代码
3. 里氏替换原则(Liskov Substitution Principle)
- 子类对象必须能够替换父类对象而不影响程序正确性
- 子类可以扩展父类功能,但不能改变父类原有行为
- 例:正方形继承长方形可能违反 LSP(setWidth 同时改变了 height)
4. 依赖倒置原则(Dependency Inversion Principle)
- 高层模块不应该依赖低层模块,两者都应该依赖抽象
- 抽象不应该依赖细节,细节应该依赖抽象
- 常用实现:接口、抽象类、依赖注入(Spring DI)
5. 接口隔离原则(Interface Segregation Principle)
- 接口设计要精简,避免"胖接口"
- 客户端不应该被迫依赖它不需要的方法
- 例:不要把打印、扫描、传真所有方法都放在一个 Machine 接口里
6. 迪米特法则(Law of Demeter / 最少知道原则)
- 一个对象应该对其他对象有尽可能少的了解
- 只与直接的朋友通信(成员变量、方法参数、返回值)
- 降低类之间的耦合度
面试口头回答
创建对象或者说面向对象设计的六大原则是:
第一是单一职责原则。每个类只关注一个明确的职责,避免一个类承担太多功能,这样被修改的原因就少,更容易维护。
第二是开闭原则。类应该对扩展开放、对修改关闭。新增功能时通过继承或实现接口来扩展,而不是修改已有代码。通常通过工厂模式、策略模式来实现。
第三是里氏替换原则。子类对象必须能够替换父类对象而不影响程序运行。也就是说子类可以扩展功能,但不能改变父类原有的行为。
第四是依赖倒置原则。高层模块和低层模块都应该依赖抽象,而不是依赖具体实现。实际开发中就是通过接口和依赖注入来解耦,比如 Spring 的 DI。
第五是接口隔离原则。接口设计要精简,避免一个接口里塞太多方法,导致实现类被迫实现不需要的方法。
第六是迪米特法则,也叫最少知道原则。一个对象对其他对象的了解应当尽可能少,只和直接的朋友通信,降低对象之间的耦合。
这六大原则在实际开发中不是孤立的,通常需要综合考虑。比如 Spring 框架就很好地体现了这些原则,通过 IOC 和 DI 实现了依赖倒置,通过接口定义实现了接口隔离。
十三、JDK8 新特性
13.1 JDK8 的新特性(补充)
核心要点
- Lambda 表达式:简化匿名内部类写法
- Stream API:函数式操作集合
- 方法引用:简化 Lambda
- 接口默认方法和静态方法
- 新日期时间 API(java.time)
- Optional:避免空指针
- 并行数组排序、重复注解、类型注解等
详细解析
1. Lambda 表达式:
// JDK8 之前
Collections.sort(list, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o1.length() - o2.length();
}
});
// JDK8 Lambda
Collections.sort(list, (o1, o2) -> o1.length() - o2.length());
- 简化匿名内部类的写法
- 要求接口是函数式接口(只有一个抽象方法)
2. Stream API:
List<Integer> result = list.stream()
.filter(x -> x > 10)
.map(x -> x * 2)
.sorted()
.collect(Collectors.toList());
- 对集合进行函数式操作:过滤、映射、排序、聚合
- 支持串行流(stream)和并行流(parallelStream)
3. 接口默认方法和静态方法:
public interface MyInterface {
default void defaultMethod() { // 默认方法
System.out.println("default");
}
static void staticMethod() { // 静态方法
System.out.println("static");
}
}
- 允许接口有默认实现,不破坏已有实现类
- 解决接口的扩展问题
4. 新日期时间 API(java.time):
- LocalDate、LocalTime、LocalDateTime:不可变、线程安全
- 解决了旧 Date/Calendar API 的设计缺陷
5. Optional:
Optional<String> optional = Optional.ofNullable(str);
optional.ifPresent(System.out::println);
String result = optional.orElse("default");
- 显式处理可能为 null 的值
- 避免空指针异常,使代码更清晰
面试口头回答
JDK8 是 Java 非常重要的一个版本,带来了很多新特性,我说几个主要的:
第一是 Lambda 表达式。它简化了匿名内部类的写法,让代码更简洁。比如之前用 Comparator 排序要写一大段匿名内部类,用 Lambda 就一行代码。它要求接口是函数式接口,也就是只有一个抽象方法。
第二是 Stream API。提供了对集合的函数式操作,比如过滤 filter、映射 map、排序 sorted、收集 collect 这些操作,支持链式调用。而且支持并行流 parallelStream,可以利用多核 CPU 并行处理大数据量集合。
第三是接口的默认方法和静态方法。这样接口可以有默认实现,扩展接口时不会破坏已有的实现类,解决了接口的扩展问题。
第四是新的日期时间 API,在 java.time 包下。LocalDate、LocalTime、LocalDateTime 这些类都是不可变且线程安全的,解决了旧的 Date 和 Calendar API 的设计缺陷。
第五是 Optional。用来显式处理可能为 null 的值,避免空指针异常,让代码意图更清晰。
我在项目中主要用了 Lambda 和 Stream API 来简化集合操作,用新的日期 API 替换了旧的 Date,用 Optional 来处理可能为空的返回值。
评论区