相关问题
==和equals()有什么区别?- 为什么重写
equals()通常也要重写hashCode()? Object类里常见的方法有哪些?- Java 是值传递还是引用传递?
String为什么是不可变的?String、StringBuilder、StringBuffer有什么区别?- 字符串常量池是什么?
new String("abc")创建几个对象? - Integer 缓存是什么?为什么
Integer 127 == 127可能为 true,而 128 不一定? - 自动装箱和拆箱是什么?
- 重载和重写有什么区别?
- 接口和抽象类有什么区别?
- Java 怎么实现多态?
final修饰类、方法、变量分别代表什么?this和super有什么区别?- Java 的异常体系是怎样的?
- Checked Exception 和 RuntimeException 有什么区别?
throw和throws有什么区别?try-catch-finally的执行顺序是什么?finally 一定会执行吗?- 泛型是什么?为什么需要泛型?
- 什么是泛型擦除?
? extends和? super有什么区别?- 反射是什么?有哪些典型使用场景?
Class对象怎么获取?- 反射为什么性能相对差?
- 注解是什么?怎么自定义注解?
- 注解是怎么被反射读取的?
- Java IO 里字节流和字符流有什么区别?
- BIO、NIO、AIO 有什么区别?
- NIO 里的 Buffer、Channel、Selector 分别是什么?
- 什么是零拷贝?
- 浅拷贝和深拷贝有什么区别?
- 序列化是什么?
transient有什么作用? - 枚举和普通常量相比有什么优势?
- Lambda 和匿名内部类有什么区别?
- Stream 是什么?中间操作和终止操作有什么区别?
equals/hashCode → String → 面向对象 → 异常 → 泛型 → 反射/注解 → IO/NIO → 其他语言细节。
equals 和 hashCode
Java 中比较两个值时,最直接的是使用 ==。
对于基本类型,== 比较的是值本身;对于引用类型,== 比较的是两个引用是否指向同一个对象。
而 equals() 默认继承自 Object,默认实现本质上也是使用 ==,所以一个类如果没有重写 equals(),那么:
|
|
和:
|
|
判断的基本都是“是不是同一个对象”。
但很多时候,我们关心的不是两个引用是不是指向同一块对象,而是它们的内容在业务上是否相同。
比如两个不同的 User 对象:
|
|
虽然 a 和 b 是两个不同对象,但业务上可能认为只要用户 id 相同,就是同一个用户。
这时候就需要重写 equals(),定义对象在什么情况下算相等。
而 hashCode() 主要服务于 HashMap、HashSet 这类哈希结构。
哈希集合在查找对象时,不会一开始就拿这个对象和所有元素逐个执行 equals(),而是先通过 hashCode() 缩小查找范围,再在对应位置使用 equals() 做最终判断。
因此 Java 要求:
|
|
否则两个逻辑上相等的对象可能因为 hashCode 不同,被分到不同的位置,导致 HashMap 或 HashSet 无法正确判断它们相等。
但反过来不成立:
|
|
因为不同对象可能发生哈希冲突。
所以 equals() 和 hashCode() 的关系可以理解成:
equals 决定两个对象逻辑上是不是同一个;hashCode 则让哈希结构能够快速找到可能相等的对象。
因此,只要重写了 equals(),通常就应该同时重写 hashCode(),并保证相等对象具有相同的 hashCode。
String
String 是 Java 中最常用的引用类型之一,它最大的特点是:不可变。
所谓不可变,就是一个 String 对象创建以后,它内部表示的字符串内容不会再被修改。
例如:
|
|
这里并不是把原来的 "abc" 改成了 "abcd",而是创建了新的字符串对象,然后让 s 指向新的对象。
String 之所以设计成不可变,主要有几个好处。
首先是安全。字符串经常用来表示类名、文件路径、URL、数据库连接信息等,如果内容可以被随意修改,会带来很多不可控问题。
其次是线程安全。因为对象本身不会被修改,所以多个线程同时读取同一个 String 不需要额外同步。
另外,不可变也让字符串常量池成为可能。相同字符串字面量可以安全复用,而不用担心某个地方修改后影响其他代码。
例如:
|
|
a 和 b 通常会指向字符串常量池中同一个 "hello" 对象,因此:
|
|
但如果写成:
|
|
new 会创建新的 String 对象,所以:
|
|
这里也体现了 == 和 equals() 的区别:== 判断是不是同一个对象,而 String 重写了 equals(),比较的是字符串内容。
String 本身不适合频繁拼接,因为每次内容变化都可能创建新的对象。
如果需要大量修改字符串,一般使用:
|
|
它内部内容可以修改,适合单线程下频繁拼接。
StringBuffer 和 StringBuilder 类似,但它的方法做了同步(synchronized),因此线程安全,代价是性能通常更低。
所以可以简单区分成:
|
|
String 这一块最核心的几个面试点就是:不可变、字符串常量池、== 和 equals()、以及 StringBuilder / StringBuffer 的区别。
面向对象
Java 面向对象主要围绕三个核心概念:封装、继承、多态。
封装就是把对象内部的数据和实现细节隐藏起来,只通过公开的方法对外提供能力。这样可以减少外部代码对内部实现的依赖。
继承表示子类可以复用父类已有的属性和方法,并在此基础上继续扩展。
|
|
Dog 会继承 Animal 的能力。
多态则表示,同一个父类引用在运行时可以指向不同的子类对象,并执行对应子类的方法。
|
|
如果 Dog 重写了 speak(),最终执行的是 Dog 的实现。
所以多态的核心就是:
编译时看引用类型,运行时看实际对象类型。
面向对象里还经常会问重载和重写。
重载发生在同一个类中,方法名相同,但参数列表不同:
|
|
重写发生在父子类之间,子类重新实现父类的方法:
|
|
重载主要是在编译期确定调用哪个方法,重写则和运行时多态有关。
接口和抽象类也经常放在一起比较。
抽象类更适合表达一组类之间的共同基础,可以有成员变量、构造方法和已经实现的方法;接口更偏向描述一种能力或规范,一个类可以实现多个接口。
所以可以简单理解成:
抽象类强调“是什么”,接口强调“能做什么”。
final 也经常和面向对象一起问。
|
|
整体来看,面向对象这一块最核心的就是:
封装负责隐藏实现,继承负责复用,多态负责让同一套接口在运行时表现出不同实现。
Java 常见的访问权限有四种:
|
|
它们控制类、方法、字段能够被哪些地方访问。
权限范围从大到小可以记成:
|
|
其中:
public:任何地方都可以访问protected:同包可以访问,其他包中的子类也可以访问default:不写修饰符时的默认权限,只能在同一个包中访问private:只能在当前类内部访问
访问修饰符本质上也是封装的一部分,用来控制对象内部哪些内容对外暴露。
异常体系
Java 中所有异常相关类型最终都继承自 Throwable。
整体可以分成两类:
|
|
Error 一般表示 JVM 或运行环境层面比较严重的问题,例如:
|
|
这类问题通常不是业务代码通过 try-catch 就能正常恢复的。
Exception 则主要表示程序运行过程中可以处理的异常。
其中又可以分成两类。
RuntimeException 属于运行时异常,例如:
|
|
编译器不会强制要求捕获或者声明。
另一类是受检异常,也就是 Checked Exception,例如:
|
|
这类异常在编译阶段就要求程序必须处理,要么使用 try-catch,要么通过 throws 继续向上抛出。
所以可以理解成:
|
|
throw 和 throws
throw 用来真正抛出一个异常对象:
|
|
throws 则写在方法声明上,表示这个方法可能把某种异常继续向调用方抛出:
|
|
所以:
|
|
try-catch-finally
try 中放可能出现异常的代码,catch 负责处理异常,finally 通常用于释放资源。
|
|
finally 通常都会执行,所以数据库连接、文件流、锁等资源清理逻辑经常放在这里。
不过如果 JVM 直接退出,例如调用:
|
|
那么 finally 就可能没有机会执行。
另外,如果 try 或 catch 中有 return,finally 通常仍然会先执行,然后方法才真正返回。
所以异常这一块最核心的就是:
Error 和 Exception 的区别、Checked 和 RuntimeException 的区别、throw/throws,以及 try-catch-finally 的执行规则。
泛型
泛型的作用,是把“类型”也变成参数,让同一套代码可以安全地处理不同类型的数据。
例如:
|
|
相比直接使用 Object,泛型最大的好处是:
编译期就能检查类型,减少强制类型转换和运行时类型错误。
例如:
|
|
泛型
泛型
泛型就是把“类型”也当成参数传进去,让同一套代码可以处理不同类型,同时保留编译期类型检查。
最常见的写法就是:
|
|
这里的 T 就是一个类型参数,可以先把它理解成“暂时还不知道具体是什么类型的占位符”。
真正使用时再指定:
|
|
这时:
|
|
所以泛型的核心就是:
先把类型抽象成一个参数,真正使用时再决定具体类型。
T 只是最常见的命名习惯,本身没有特殊语法含义。常见的还有:
|
|
比如:
|
|
都只是类型参数的名字。
泛型最大的价值是让编译器提前帮我们检查类型,避免大量强制类型转换和运行时类型错误。
后面再往下,就是泛型擦除,以及 ? extends 和 ? super 这些泛型边界问题。
泛型擦除
Java 的泛型主要在编译阶段生效。
编译以后,很多泛型类型信息会被擦除,这就是类型擦除。
例如:
|
|
运行时通常都只是同一个 List 类型,因此:
|
|
结果是 true。
所以泛型主要解决的是编译期类型安全。
? extends 和 ? super
? extends T 表示实际类型是 T 或者 T 的某个子类。
例如:
|
|
它可能实际是:
|
|
因此读取时,可以确定里面的元素至少都是 Number:
|
|
但不能随便写入:
|
|
因为编译器并不知道它实际是不是 List<Integer>。如果真实类型是 List<Double>,写入 Integer 就会破坏类型安全。
所以:
? extends Number 确定了读取出来一定可以当成 Number,但不确定具体子类型,因此适合读,不适合写。
? super T 则表示实际类型是 T 或者 T 的某个父类。
例如:
|
|
它可能实际是:
|
|
这时可以安全写入 Integer:
|
|
因为无论真实类型是哪一种,都一定能接收 Integer:
|
|
但读取时,只能安全地当成 Object:
|
|
因为编译器不知道这个集合真实保存的到底是哪一个父类型。
所以可以这样理解:
|
|
这也是常见的 PECS 原则:
Producer Extends,Consumer Super。
这里的 Producer 指集合向外提供数据,所以用 extends;Consumer 指集合接收外部写入的数据,所以用 super。
反射
反射就是让程序在运行时获取一个类的信息,并且动态操作这个类。
正常写代码时,我们通常已经明确知道要创建哪个类、调用哪个方法;反射则允许程序在运行时再决定这些事情。
反射的入口通常是 Class 对象,例如:
|
|
拿到 Class 对象以后,就可以获取这个类的字段、方法和构造器,也可以动态创建对象和调用方法。
|
|
所以反射最核心的作用,就是把原本写死在代码里的类操作,变成运行时动态完成。
这种能力在框架里非常重要。比如 Spring 在开发框架本身的时候,并不知道业务项目以后会有哪些 Service、Controller。程序启动以后,Spring 才会扫描业务类,找到需要管理的类型,再通过反射创建对象、注入字段或者调用方法。
因此反射真正解决的是运行时动态发现和操作类型的问题,让框架不需要提前依赖具体业务类。
注解
注解本质上是给类、方法、字段等代码元素附加一些元数据。
例如:
|
|
@Override 本身不会执行任何业务逻辑,它只是描述这个方法是在重写父类方法。
我们也可以定义自己的注解:
|
|
然后使用:
|
|
这里的 @Log 只是给 createOrder() 标记了一层额外信息。真正要执行日志逻辑,还需要其他代码在运行时读取这个注解并进行处理。
这也是为什么注解和反射经常一起使用:注解负责描述信息,反射负责在运行时读取这些信息并操作对应的类或方法。
Spring 中的 @Component、@Autowired、@Transactional 都是类似的思路。注解本身只提供元数据,真正的 Bean 注册、依赖注入、事务处理是框架读取这些信息以后完成的。
注解还可以通过 Retention 控制保留到哪个阶段。SOURCE 只存在于源码中,CLASS 会保留到 class 文件,RUNTIME 则会一直保留到运行时,因此可以被反射读取。
注解给代码增加描述信息,反射让程序能够在运行时发现这些信息,并动态处理对应的类、字段和方法。