相关问题

  1. == 和 equals() 有什么区别?
  2. 为什么重写 equals() 通常也要重写 hashCode()?
  3. Object 类里常见的方法有哪些?
  4. Java 是值传递还是引用传递?
  5. String 为什么是不可变的?
  6. String、StringBuilder、StringBuffer 有什么区别?
  7. 字符串常量池是什么?new String("abc") 创建几个对象?
  8. Integer 缓存是什么?为什么 Integer 127 == 127 可能为 true,而 128 不一定?
  9. 自动装箱和拆箱是什么?
  10. 重载和重写有什么区别?
  11. 接口和抽象类有什么区别?
  12. Java 怎么实现多态?
  13. final 修饰类、方法、变量分别代表什么?
  14. this 和 super 有什么区别?
  15. Java 的异常体系是怎样的?
  16. Checked Exception 和 RuntimeException 有什么区别?
  17. throw 和 throws 有什么区别?
  18. try-catch-finally 的执行顺序是什么?finally 一定会执行吗?
  19. 泛型是什么?为什么需要泛型?
  20. 什么是泛型擦除?
  21. ? extends 和 ? super 有什么区别?
  22. 反射是什么?有哪些典型使用场景?
  23. Class 对象怎么获取?
  24. 反射为什么性能相对差?
  25. 注解是什么?怎么自定义注解?
  26. 注解是怎么被反射读取的?
  27. Java IO 里字节流和字符流有什么区别?
  28. BIO、NIO、AIO 有什么区别?
  29. NIO 里的 Buffer、Channel、Selector 分别是什么?
  30. 什么是零拷贝?
  31. 浅拷贝和深拷贝有什么区别?
  32. 序列化是什么?transient 有什么作用?
  33. 枚举和普通常量相比有什么优势?
  34. Lambda 和匿名内部类有什么区别?
  35. Stream 是什么?中间操作和终止操作有什么区别?

equals/hashCode → String → 面向对象 → 异常 → 泛型 → 反射/注解 → IO/NIO → 其他语言细节。

equals 和 hashCode

Java 中比较两个值时,最直接的是使用 ==。

对于基本类型,== 比较的是值本身;对于引用类型,== 比较的是两个引用是否指向同一个对象。

而 equals() 默认继承自 Object,默认实现本质上也是使用 ==,所以一个类如果没有重写 equals(),那么:

1
a == b

和:

1
a.equals(b)

判断的基本都是“是不是同一个对象”。

但很多时候,我们关心的不是两个引用是不是指向同一块对象,而是它们的内容在业务上是否相同。

比如两个不同的 User 对象:

1
2
User a = new User("1001");
User b = new User("1001");

虽然 a 和 b 是两个不同对象,但业务上可能认为只要用户 id 相同,就是同一个用户。

这时候就需要重写 equals(),定义对象在什么情况下算相等。

而 hashCode() 主要服务于 HashMap、HashSet 这类哈希结构。

哈希集合在查找对象时,不会一开始就拿这个对象和所有元素逐个执行 equals(),而是先通过 hashCode() 缩小查找范围,再在对应位置使用 equals() 做最终判断。

因此 Java 要求:

1
2
两个对象 equals() 相等
→ hashCode() 必须相同

否则两个逻辑上相等的对象可能因为 hashCode 不同,被分到不同的位置,导致 HashMap 或 HashSet 无法正确判断它们相等。

但反过来不成立:

1
2
hashCode() 相同
→ equals() 不一定相等

因为不同对象可能发生哈希冲突。

所以 equals() 和 hashCode() 的关系可以理解成:

equals 决定两个对象逻辑上是不是同一个;hashCode 则让哈希结构能够快速找到可能相等的对象。

因此,只要重写了 equals(),通常就应该同时重写 hashCode(),并保证相等对象具有相同的 hashCode。

String

String 是 Java 中最常用的引用类型之一,它最大的特点是:不可变。

所谓不可变,就是一个 String 对象创建以后,它内部表示的字符串内容不会再被修改。

例如:

1
2
String s = "abc";
s = s + "d";

这里并不是把原来的 "abc" 改成了 "abcd",而是创建了新的字符串对象,然后让 s 指向新的对象。

String 之所以设计成不可变,主要有几个好处。

首先是安全。字符串经常用来表示类名、文件路径、URL、数据库连接信息等,如果内容可以被随意修改,会带来很多不可控问题。

其次是线程安全。因为对象本身不会被修改,所以多个线程同时读取同一个 String 不需要额外同步。

另外,不可变也让字符串常量池成为可能。相同字符串字面量可以安全复用,而不用担心某个地方修改后影响其他代码。

例如:

1
2
String a = "hello";
String b = "hello";

a 和 b 通常会指向字符串常量池中同一个 "hello" 对象,因此:

1
a == b   // true

但如果写成:

1
2
String a = new String("hello");
String b = new String("hello");

new 会创建新的 String 对象,所以:

1
2
a == b        // false
a.equals(b)  // true

这里也体现了 == 和 equals() 的区别:== 判断是不是同一个对象,而 String 重写了 equals(),比较的是字符串内容。

String 本身不适合频繁拼接,因为每次内容变化都可能创建新的对象。

如果需要大量修改字符串,一般使用:

1
StringBuilder

它内部内容可以修改,适合单线程下频繁拼接。

StringBuffer 和 StringBuilder 类似,但它的方法做了同步(synchronized),因此线程安全,代价是性能通常更低。

所以可以简单区分成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
String
→ 不可变
→ 适合普通字符串使用

StringBuilder
→ 可变
→ 单线程频繁拼接

StringBuffer
→ 可变
→ 线程安全

String 这一块最核心的几个面试点就是:不可变、字符串常量池、== 和 equals()、以及 StringBuilder / StringBuffer 的区别。

面向对象

Java 面向对象主要围绕三个核心概念:封装、继承、多态。

封装就是把对象内部的数据和实现细节隐藏起来,只通过公开的方法对外提供能力。这样可以减少外部代码对内部实现的依赖。

继承表示子类可以复用父类已有的属性和方法,并在此基础上继续扩展。

1
2
3
4
5
6
class Animal {
    void speak() {}
}

class Dog extends Animal {
}

Dog 会继承 Animal 的能力。

多态则表示,同一个父类引用在运行时可以指向不同的子类对象,并执行对应子类的方法。

1
2
Animal animal = new Dog();
animal.speak();

如果 Dog 重写了 speak(),最终执行的是 Dog 的实现。

所以多态的核心就是:

编译时看引用类型,运行时看实际对象类型。

面向对象里还经常会问重载和重写。

重载发生在同一个类中,方法名相同,但参数列表不同:

1
2
void test(int a) {}
void test(String a) {}

重写发生在父子类之间,子类重新实现父类的方法:

1
2
3
4
5
6
7
8
class Animal {
    void speak() {}
}

class Dog extends Animal {
    @Override
    void speak() {}
}

重载主要是在编译期确定调用哪个方法,重写则和运行时多态有关。

接口和抽象类也经常放在一起比较。

抽象类更适合表达一组类之间的共同基础,可以有成员变量、构造方法和已经实现的方法;接口更偏向描述一种能力或规范,一个类可以实现多个接口。

所以可以简单理解成:

抽象类强调“是什么”,接口强调“能做什么”。

final 也经常和面向对象一起问。

1
2
3
4
5
6
7
8
final 修饰类
→ 不能被继承

final 修饰方法
→ 不能被重写

final 修饰变量
→ 只能赋值一次

整体来看,面向对象这一块最核心的就是:

封装负责隐藏实现,继承负责复用,多态负责让同一套接口在运行时表现出不同实现。

Java 常见的访问权限有四种:

1
2
3
4
public
protected
default
private

它们控制类、方法、字段能够被哪些地方访问。

权限范围从大到小可以记成:

1
public > protected > default > private

其中:

  • public:任何地方都可以访问
  • protected:同包可以访问,其他包中的子类也可以访问
  • default:不写修饰符时的默认权限,只能在同一个包中访问
  • private:只能在当前类内部访问

访问修饰符本质上也是封装的一部分,用来控制对象内部哪些内容对外暴露。

异常体系

Java 中所有异常相关类型最终都继承自 Throwable。

整体可以分成两类:

1
2
3
4
5
Throwable
├── Error
└── Exception
    ├── RuntimeException
    └── 其他受检异常

Error 一般表示 JVM 或运行环境层面比较严重的问题,例如:

1
2
OutOfMemoryError
StackOverflowError

这类问题通常不是业务代码通过 try-catch 就能正常恢复的。

Exception 则主要表示程序运行过程中可以处理的异常。

其中又可以分成两类。

RuntimeException 属于运行时异常,例如:

1
2
3
NullPointerException
IndexOutOfBoundsException
IllegalArgumentException

编译器不会强制要求捕获或者声明。

另一类是受检异常,也就是 Checked Exception,例如:

1
2
IOException
SQLException

这类异常在编译阶段就要求程序必须处理,要么使用 try-catch,要么通过 throws 继续向上抛出。

所以可以理解成:

1
2
3
4
5
6
7
8
Checked Exception
→ 编译器强制处理

RuntimeException
→ 编译器不强制处理

Error
→ 更严重的 JVM / 系统级问题

throw 和 throws

throw 用来真正抛出一个异常对象:

1
throw new IllegalArgumentException("参数错误");

throws 则写在方法声明上,表示这个方法可能把某种异常继续向调用方抛出:

1
2
public void read() throws IOException {
}

所以:

1
2
3
4
5
throw
→ 真正抛异常

throws
→ 声明可能抛异常

try-catch-finally

try 中放可能出现异常的代码,catch 负责处理异常,finally 通常用于释放资源。

1
2
3
4
5
6
7
try {
    // 业务代码
} catch (Exception e) {
    // 异常处理
} finally {
    // 资源清理
}

finally 通常都会执行,所以数据库连接、文件流、锁等资源清理逻辑经常放在这里。

不过如果 JVM 直接退出,例如调用:

1
System.exit(0);

那么 finally 就可能没有机会执行。

另外,如果 try 或 catch 中有 return,finally 通常仍然会先执行,然后方法才真正返回。

所以异常这一块最核心的就是:

Error 和 Exception 的区别、Checked 和 RuntimeException 的区别、throw/throws,以及 try-catch-finally 的执行规则。

泛型

泛型的作用,是把“类型”也变成参数,让同一套代码可以安全地处理不同类型的数据。

例如:

1
2
List<String> names = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();

相比直接使用 Object,泛型最大的好处是:

编译期就能检查类型,减少强制类型转换和运行时类型错误。

例如:

1
2
3
List<String> list = new ArrayList<>();
list.add("A");
// list.add(123);  // 编译阶段就会报错

泛型

泛型

泛型就是把“类型”也当成参数传进去,让同一套代码可以处理不同类型,同时保留编译期类型检查。

最常见的写法就是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Box<T> {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

这里的 T 就是一个类型参数,可以先把它理解成“暂时还不知道具体是什么类型的占位符”。

真正使用时再指定:

1
2
Box<String> stringBox = new Box<>();
Box<Integer> intBox = new Box<>();

这时:

1
2
Box<String> 里的 T 就是 String
Box<Integer> 里的 T 就是 Integer

所以泛型的核心就是:

先把类型抽象成一个参数,真正使用时再决定具体类型。

T 只是最常见的命名习惯,本身没有特殊语法含义。常见的还有:

1
2
3
4
T  → Type
E  → Element
K  → Key
V  → Value

比如:

1
2
Map<K, V>
List<E>

都只是类型参数的名字。

泛型最大的价值是让编译器提前帮我们检查类型,避免大量强制类型转换和运行时类型错误。

后面再往下,就是泛型擦除,以及 ? extends 和 ? super 这些泛型边界问题。

泛型擦除

Java 的泛型主要在编译阶段生效。

编译以后,很多泛型类型信息会被擦除,这就是类型擦除。

例如:

1
2
List<String>
List<Integer>

运行时通常都只是同一个 List 类型,因此:

1
2
3
new ArrayList<String>().getClass()
==
new ArrayList<Integer>().getClass()

结果是 true。

所以泛型主要解决的是编译期类型安全。

? extends 和 ? super

? extends T 表示实际类型是 T 或者 T 的某个子类。

例如:

1
List<? extends Number> list;

它可能实际是:

1
2
3
List<Integer>
List<Double>
List<Long>

因此读取时,可以确定里面的元素至少都是 Number:

1
Number n = list.get(0);

但不能随便写入:

1
list.add(1); // 不允许

因为编译器并不知道它实际是不是 List<Integer>。如果真实类型是 List<Double>,写入 Integer 就会破坏类型安全。

所以:

? extends Number 确定了读取出来一定可以当成 Number,但不确定具体子类型,因此适合读,不适合写。

? super T 则表示实际类型是 T 或者 T 的某个父类。

例如:

1
List<? super Integer> list;

它可能实际是:

1
2
3
List<Integer>
List<Number>
List<Object>

这时可以安全写入 Integer:

1
list.add(1);

因为无论真实类型是哪一种,都一定能接收 Integer:

1
2
3
Integer 可以放进 List<Integer>
Integer 可以放进 List<Number>
Integer 可以放进 List<Object>

但读取时,只能安全地当成 Object:

1
Object value = list.get(0);

因为编译器不知道这个集合真实保存的到底是哪一个父类型。

所以可以这样理解:

1
2
3
4
5
6
7
? extends T
→ 确定“读出来至少是 T”
→ 适合读取

? super T
→ 确定“至少能接收 T”
→ 适合写入

这也是常见的 PECS 原则:

Producer Extends,Consumer Super。

这里的 Producer 指集合向外提供数据,所以用 extends;Consumer 指集合接收外部写入的数据,所以用 super。

反射

反射就是让程序在运行时获取一个类的信息,并且动态操作这个类。

正常写代码时,我们通常已经明确知道要创建哪个类、调用哪个方法;反射则允许程序在运行时再决定这些事情。

反射的入口通常是 Class 对象,例如:

1
2
3
Class<?> clazz = User.class;
Class<?> clazz2 = Class.forName("com.example.User");
Class<?> clazz3 = user.getClass();

拿到 Class 对象以后,就可以获取这个类的字段、方法和构造器,也可以动态创建对象和调用方法。

1
2
3
4
5
6
7
clazz.getDeclaredFields();
clazz.getDeclaredMethods();

Object user = clazz.getDeclaredConstructor().newInstance();

Method method = clazz.getDeclaredMethod("getName");
method.invoke(user);

所以反射最核心的作用,就是把原本写死在代码里的类操作,变成运行时动态完成。

这种能力在框架里非常重要。比如 Spring 在开发框架本身的时候,并不知道业务项目以后会有哪些 Service、Controller。程序启动以后,Spring 才会扫描业务类,找到需要管理的类型,再通过反射创建对象、注入字段或者调用方法。

因此反射真正解决的是运行时动态发现和操作类型的问题,让框架不需要提前依赖具体业务类。

注解

注解本质上是给类、方法、字段等代码元素附加一些元数据。

例如:

1
2
3
4
@Override
public String toString() {
    return "...";
}

@Override 本身不会执行任何业务逻辑,它只是描述这个方法是在重写父类方法。

我们也可以定义自己的注解:

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}

然后使用:

1
2
3
@Log
public void createOrder() {
}

这里的 @Log 只是给 createOrder() 标记了一层额外信息。真正要执行日志逻辑,还需要其他代码在运行时读取这个注解并进行处理。

这也是为什么注解和反射经常一起使用:注解负责描述信息,反射负责在运行时读取这些信息并操作对应的类或方法。

Spring 中的 @Component、@Autowired、@Transactional 都是类似的思路。注解本身只提供元数据,真正的 Bean 注册、依赖注入、事务处理是框架读取这些信息以后完成的。

注解还可以通过 Retention 控制保留到哪个阶段。SOURCE 只存在于源码中,CLASS 会保留到 class 文件,RUNTIME 则会一直保留到运行时,因此可以被反射读取。

注解给代码增加描述信息,反射让程序能够在运行时发现这些信息,并动态处理对应的类、字段和方法。