一、Java 基础
1. Java 面向对象三大特性是什么?
"Java 面向对象的三大特性是封装 、继承 和多态。
封装就是把属性和方法封装到类内部,对外只暴露必要接口,这样可以隐藏实现细节、提高安全性。
继承就是子类继承父类的属性和方法,提升代码复用性。
多态就是同一个父类引用可以指向不同子类对象,调用同名方法时表现不同。
2. 封装、继承、多态分别怎么理解?
封装强调的是隐藏细节,把变化留在内部,把稳定接口暴露给外部。
继承强调的是代码复用和层次关系,子类可以复用父类已有能力。
多态强调的是统一接口、不同实现,让代码更灵活。
多态强调的是统一接口、不同实现,让代码更灵活。
3. 抽象类和接口有什么区别?
抽象类和接口都不能 直接实例化,但侧重点不同。
抽象类更像是对一类事物的抽象,可以有成员变量、普通方法和构造器,适合提取公共实现;
接口更像是对能力的抽象,强调'能做什么',更适合做规范约束。
Java 是单继承,所以一个类只能继承一个抽象类,但可以实现多个接口。
实际开发里,如果是复用公共代码 ,我会优先考虑抽象类;如果是解耦和扩展能力,更多会用接口。
4. == 和 equals 有什么区别?
对于基本数据类型,== 比较的是值;对于引用类型,== 比较的是两个引用是否指向同一个对象,也就是地址。
equals 是 Object 类的方法,默认实现也是比较地址 ,但很多类比如 String 重写了 equals,用来比较内容是否相等。
所以可以简单理解为,== 更偏向比较是不是同一个对象 ,equals 更偏向比较逻辑内容是否相等。
5. equals 和 hashCode 有什么关系?
equals 和 hashCode 都来自 Object 类,它们在哈希集合中是配套使用的。像 HashMap、HashSet 在查找元素时,先通过 hashCode 确定桶位置,再通过 equals 判断是不是同一个对象。如果两个对象 equals 相等,那 hashCode 必须相等;但 hashCode 相等,不代表 equals 一定相等,因为可能有哈希冲突。所以如果重写了 equals,一般也必须重写 hashCode,不然在哈希集合里可能出现逻辑异常,比如查不到元素或者重复存储。
6. 为什么重写 equals 时通常也要重写 hashCode?
"因为哈希容器底层先看 hashCode,再看 equals。如果两个对象内容相等,但 hashCode 不相等,它们就会落到不同桶里,如果两个对象内容相等,但 hashCode 不相等,它们就会落到不同桶里,这样集合就无法正确判断它们逻辑上是同一个对象。所以只重写 equals 不重写 hashCode,最典型的问题就是在 HashMap、HashSet 里表现异常。
7. String、StringBuilder、StringBuffer 的区别是什么?
String 是不可变字符串,一旦创建内容就不能修改,每次拼接其实都会生成新对象,所以频繁修改时性能较差。
StringBuilder 和 StringBuffer 都是可变字符串,底层都是可扩容的字符数组,适合频繁拼接。
区别是 StringBuilder 线程不安全,但性能更好;StringBuffer 线程安全,因为很多方法带 synchronized,所以性能相对低一些。StringBuffer 线程安全,因为很多方法带 synchronized,所以性能相对低一些。
8. String 为什么是不可变的?
String 不可变主要是因为它底层字符数组在设计上不允许被随意修改,同时类本身也被 final 修饰,防止被继承后破坏这种特性。
不可变的好处有几个:第一,线程安全;第二,可以安全地作为 hash key;第三,便于常量池复用;第四,也有利于安全性,比如数据库连接串、类加载等场景都更可靠。
9. 字符串常量池是什么?
字符串常量池可以理解成 JVM 为了复用字符串 对象而专门维护的一块区域。像直接写 String s = "abc" 这种字面量,通常会优先放进常量池,如果池里已经有相同内容,就直接复用,不会重复创建对象。
像直接写 String s = "abc" 这种字面量,通常会优先放进常量池,如果池里已经有相同内容,就直接复用,不会重复创建对象。
10. new String("abc") 创建了几个对象?
"这个题严格来说要看常量池里原来有没有 abc。如果常量池里没有,那一般会先在常量池里放一个 abc,再在堆上 new 一个 String 对象,所以是两个对象;如果常量池里已经有了 abc,那就只会在堆上创建一个新对象。
11. 重载和重写有什么区别?
重载发生在同一个类中,方法名相同但参数列表不同,和返回值没关系,属于编译时多态。
重写发生在父子类之间,子类重新实现父类方法,方法名和参数列表必须一致,属于运行时多态。简单记就是,重载看参数,重写看继承。实际开发里,重载更多体现接口灵活性,重写则更多体现面向对象扩展能力。
12. 深拷贝和浅拷贝有什么区别?
浅拷贝只复制对象本身,如果对象内部还有引用类型字段,那么复制后两个对象会共享同一个引用对象。
深拷贝则不仅复制对象本身,还会把内部引用对象也复制一份,这样两个对象互不影响。比如一个对象里有 List 字段,浅拷贝后两个对象可能共用同一个 List,深拷贝则不会。
实际项目里,如果对象结构复杂,我更倾向于显式实现拷贝逻辑,而不是直接依赖 clone。
13. clone() 默认是深拷贝还是浅拷贝?
Object 的 clone 默认更接近浅拷贝 ,它只是按字段值复制一份,如果字段里有引用类型,复制过去的还是同一个引用地址。
所以 clone 默认不能保证真正意义上的深拷贝。如果要做深拷贝,通常还要对内部引用对象继续拷贝,或者通过序列化、拷贝构造器、手动复制等方式实现。
14. Java 是值传递还是引用传递?
Java 严格来说只有值传递。对于基本类型,传递的是值本身;对于引用类型,传递的是引用的副本,也就是地址值的拷贝。
所以在方法里可以通过这个引用副本 去修改对象内部状态 ,但不能直接改变调用方那个引用本身指向哪个对象。
15. final、finally、finalize 有什么区别?
final 是关键字,可以修饰类、方法和变量。修饰类表示不能被继承 ,修饰方法表示不能被重写 ,修饰变量表示值不能再变。
finally 是异常处理里的代码块,通常用来做资源释放,不管是否异常一般都会执行。
finalize 是 Object 的一个方法,原本和垃圾回收前的清理有关,但现在基本已经不使用了,因为不可靠且性能不好。
二、集合框架
16. ArrayList 和 LinkedList 有什么区别?
ArrayList 底层是动态数组 ,支持随机访问,所以查询很快,但中间插入和删除元素时可能需要移动大量元素。
LinkedList 底层是双向链表,插入删除相对方便,但随机访问效率低,因为需要遍历链表。
所以一般查询多、修改少的场景用 ArrayList;如果频繁在首尾插入删除,可以考虑 LinkedList。
17. ArrayList 底层是怎么实现的?
ArrayList 底层是一个可扩容的 Object 数组。它在初始化时不会一下子分配很大空间,而是在第一次 add 时才真正分配默认容量。添加元素时,如果当前数组够用就直接放进去;如果不够,就触发扩容,创建更大的新数组,再把原来元素拷贝过去。
优点: 查询快,因为数组支持按下标直接定位。
缺点: 插入删除时可能要搬移元素,中间操作成本较高。
18. ArrayList 扩容机制是什么?
ArrayList 扩容的核心思路 是空间不够时创建更大的新数组,再把旧数据复制 过去。JDK 里通常是按原容量的1.5 倍左右扩容,这样在减少扩容次数和节省空间之间做折中。扩容本身是有成本的,因为涉及数组复制,所以如果能预估数据量,实际开发里可以提前指定初始容量,减少频繁扩容带来的性能损耗。
19. LinkedList 为什么查询慢、插入删除快?
因为 LinkedList 底层是双向链表 ,不支持像数组那样通过下标直接定位,所以查找时要从头或尾逐个节点遍历,查询慢。
但如果已经定位到目标节点,插入和删除只需要修改前后指针关系,不需要像数组那样整体搬移元素,所以相对更快。
20. HashMap 底层结构是什么?
HashMap 在 JDK1.8 里的底层结构是数组、链表和红黑树。
数组是主体,每个数组位置可以看成一个桶。
插入元素时,先通过 key 的 hash 值 定位到桶,如果桶为空就直接插入;如果桶里已经有元素,就通过链表或红黑树处理冲突。引入红黑树主要是为了避免链表过长时查询性能退化,从而让整体性能更稳定。
21. HashMap 的 put 过程讲一下
HashMap 的 put 过程大概分几步。
第一,先判断数组是否初始化,没有就先初始化 。第二,根据 key 计算 hash,再定位桶下标 。第三,如果桶为空,直接插入 。第四,如果桶不为空,就说明发生哈希冲突 ,这时会先判断 key 是否相同,相同就覆盖旧值,不同就挂到链表或红黑树里。最后,插入完成后还会判断当前元素数量是否超过阈值,如果超过就触发扩容。核心就是定位桶、处理冲突、必要时扩容。
22. HashMap 的 get 过程讲一下
get 的过程相对简单。先根据 key 计算 hash,定位到对应桶。如果桶头节点就匹配,直接返回;如果桶里是链表,就顺着链表往后找;如果已经树化成红黑树,就按树结构查找。找到就返回对应 value,找不到就返回 null。HashMap 查询效率高的前提,是哈希分布尽量均匀,避免大量元素堆在同一个桶里。
23. HashMap 为什么数组长度一般是 2 的幂?
因为 HashMap 定位桶下标时通常使用 (n - 1) & hash ,如果数组长度 n 是 2 的幂,这种位运算就能更均匀地利用 hash 的二进制位,既提高效率,也减少冲突。相比取模运算,位运算更快,所以这是性能和分布均匀性上的一个设计选择。
24. HashMap 如何解决哈希冲突?
哈希冲突就是不同 key 经过计算后落到同一个桶里。HashMap 的解决方式是链地址法,也就是先在同一个桶里用链表存放冲突元素。到了 JDK1.8,如果链表长度过长,并且数组长度达到条件,还会把链表转成红黑树,这样查询效率可以从 O(n) 提升到 O(logn)。先链表再红黑树。
25. JDK1.7 和 JDK1.8 的 HashMap 有什么区别?
JDK1.7 的 HashMap 底层是数组加链表 ,JDK1.8 在此基础上引入了红黑树,变成数组、链表、红黑树 结合的结构。这样做主要是为了优化哈希冲突 严重时的查询效率。另外 JDK1.8 在扩容迁移时也做了优化,并改用了尾插法,避免了 JDK1.7 并发扩容时可能出现链表成环的问题。
26. 为什么链表长度超过 8 才树化?
这个阈值本质上是性能和空间之间的平衡。链表短的时候,维护红黑树的成本并不划算 ,因为树结构更复杂,节点占用空间也更大只有当链表长度足够长,查询性能明显下降时,树化才有意义。JDK 里把这个经验阈值定成 8,就是为了避免过早树化带来的额外开销。
27. 为什么数组长度达到 64 才树化?
因为如果数组本身还比较小,冲突严重往往不一定是数据量太大,而可能只是桶数太少,这种情况下优先扩容通常更有效扩容后元素重新分布,很多长链表可能自然就拆散了。所以 JDK 设计成只有当数组长度达到 64 以后,才认为继续扩容未必能明显改善冲突,这时再考虑树化更合适。"
28. HashMap 什么时候扩容?
HashMap 当元素数量超过阈值时会扩容,这个阈值通常等于容量乘以负载因子 。默认容量是 16,默认负载因子是 0.75,所以默认阈值是 12。扩容时一般会把容量变成原来的 2 倍 ,然后重新分配元素位置。扩容虽然能降低冲突,但本身也有迁移成本。
29. 默认负载因子为什么是 0.75?
因为 0.75 是时间和空间上的一个折中。负载因子太小,会导致数组空位很多,浪费内存;太大则会让桶里元素太多,增加哈希冲突,影响查询性能。
30. HashMap 为什么线程不安全?
HashMap 在并发环境下没有任何同步控制 。多个线程同时 put 或 resize 时,可能出现数据覆盖、数据丢失、读取不一致等问题。JDK1.7 里在极端并发扩容下还可能出现链表成环 。也正因为如此,多线程场景下一般不会直接使用 HashMap,而是用 ConcurrentHashMap ,或者通过额外加锁来保证安全。
31. HashSet 和 TreeSet 有什么区别?
HashSet 底层基于 HashMap,特点是无序、去重、查询效率高 。TreeSet 底层基于红黑树,特点是有序、去重,可以按自然顺序或者自定义比较器排序。
所以如果只是单纯去重,通常用 HashSet;
如果还需要排序,就用 TreeSet。
它们最核心的区别就在于一个偏哈希,一个偏有序树结构。
32. HashSet 为什么说底层基于 HashMap?
因为 HashSet 内部其实就是用 HashMap 来存元素,只不过它把你放进去的元素当作 key,value 则统一放一个固定的占位对象。这样就直接利用了 HashMap key 不可重复的特性,从而实现 Set 的去重能力。所以可以说 HashSet 本质上是对 HashMap 的一层封装。
33. TreeSet 为什么可以排序?
因为 TreeSet 底层是红黑树 ,而红黑树本身是有序 的数据结构。元素插入时会根据自然顺序 或者你传入的比较器 Comparator来决定节点位置,所以遍历时天然就是有序的。
34. ConcurrentHashMap 是怎么实现线程安全的?
ConcurrentHashMap 在 JDK1.7 使用的是 Segment 分段锁 ,也就是把整个 Map 分成多段,不同段 可以并发访问。
JDK1.8 取消了 Segment,底层结构和 HashMap 类似,也是数组、链表、红黑树,但并发控制改成了CAS、synchronized 和 volatile 结合的方式。这样锁粒度更细,只锁局部桶,性能比整表锁更好。
35. JDK1.7 和 JDK1.8 的 ConcurrentHashMap 区别是什么?
JDK1.7 的核心是 Segment 分段锁,每个 Segment 类似一个小 HashMap;
JDK1.8 则把 Segment 去掉,改成更接近 HashMap 的结构,并通过CAS 加局部 synchronized 实现并发控制。
JDK1.8 的好处是结构更统一、锁粒度更细、实现上也更高效。所以面试里通常会总结成:1.7 分段锁,1.8 CAS 加 synchronized。
36. 为什么 ConcurrentHashMap 比 Hashtable 性能好?
因为 Hashtable 基本是整张表加锁,任意线程操作都会竞争同一把锁,并发度很低。
ConcurrentHashMap 的锁粒度更细,JDK1.8 甚至可以做到桶级别控制,很多情况下不同线程可以并发操作不同区域,所以在高并发场景下性能明显更好。
两者的本质区别就是一个粗粒度锁 ,一个细粒度并发控制。