【八股】暑期实习八股复盘(二月)
针对一下个人面试答的不算太熟练的八股复盘一下,这帮人问的问题怎么都是这些,真是题题又库库啊。
1. Java 基础
1.1 hashCode 和 equals
重写equals()
时必须同步重写hashCode()
,确保两者逻辑一致(例如,基于相同属性生成哈希值)
也就是说 hashCode() 方法是专为集合类而生的。
1.2 JIT & AOT
1.2.1 JIT 即时编译
JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT(Just in Time Compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言 。
1.2.2 AOT 提前编译
JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation) 。和 JIT 不同的是,这种编译模式会在程序被执行前就将其编译成机器码,属于静态编译(C、 C++,Rust,Go 等语言就是静态编译)。AOT 避免了 JIT 预热等各方面的开销,可以提高 Java 程序的启动速度,避免预热时间长。并且,AOT 还能减少内存占用和增强 Java 程序的安全性(AOT 编译后的代码不容易被反编译和修改),特别适合云原生场景。
AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。然而,很多框架和库(如 Spring、CGLIB)都用到了这些特性。
2. Java 线程基础
2.1 Java 开线程的方式
实际上,Java 所有开线程的方式归根结底都会汇为一种:
new Thread.start();
只不过是给这个 Thread 构造任务的方式有很多而已:
- 直接继承 Thread 类,重写 run() 方法
- 实现 Runnable 接口
- 实现 Callable 接口(FutureTask类),可以拿到线程执行完的返回值
- 使用线程池的 ExecutorService::submit() 方法或者 Executor::execute() 方法
- CompletableFuture 执行异步任务
- 等等……
2.2 wait 和 sleep 的区别
之前一直是 wait 和 notify 一起记的,现在 wait 和 sleep 放一起让我的大脑直接宕机了好几回。真是为了八股而八股啊。
2.2.1 锁对象的 wait 与 notify 机制
wait() 、notify、notifyAll 这三个方法都是被 synchronized 块中关联的锁(monitor)对象调用的方法,定义在 Object 类,因此调用这三个方法是必须在 synchronized 块中的。
wait
方法使线程进入等待状态,并对当前线程释放锁。notify
方法唤醒一个等待的线程。notifyAll
方法唤醒所有等待的线程。
Object lockObject = new Object(); synchronized (lockObject) { while (conditionIsNotMet) { lockObject.wait(); } // 继续执行 } synchronized (lockObject) { // 修改条件 conditionIsMet = true; lockObject.notifyAll(); } // 如果 synchronized 修饰在方法上,锁对象为当前对象,因此直接是 this.wait() public synchronized void put(int value) { while (available) { try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } this.value = value; available = true; notifyAll(); }
2.2.2 为什么 sleep()
方法定义在 Thread
中?
因为
sleep()
是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
sleep()
方法没有释放锁,而wait()
方法释放了锁 。wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法。
引用自 JavaGuide Thread#sleep() 方法和 Object#wait() 方法对比
2.3 Thread 的生命周期和状态
- NEW:Thread 对象实例化,但是没有调用 start() ;
- RUNNABLE:Thread 对象调用 start() 后,操作系统层面 RUNNING 与 READY 的集合;
- BLOCKED:线程没有获取到 monitor 对象锁,被动阻塞;
- WAITING:线程主动放弃锁并等待,需要被其他线程唤醒;
- TIMEED_WAITING:附带超时机制的 WAITING;
- TERMINATED:线程执行完毕。
2.4 ThreadLocal
- 面试官:你ThreadLocal都不会你Java并发编程还能会啥?
- 我:?
2.4.1 用法
ThreadLocal
是 Java 中的一个类,用于在每个线程中存储独立的变量副本,确保线程间的数据隔离。每个线程可以独立访问自己的变量副本,而不会影响其他线程的副本。
使用场景
- 线程安全:避免多线程共享变量时的竞争条件。
- 上下文传递:在方法调用链中传递上下文信息,如用户会话、事务 ID 等。
基本用法
- 创建 ThreadLocal 变量
- 设置值
- 获取值
- 移除值
示例代码
public class ThreadLocalExample { private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { Runnable task = () -> { // 设置线程局部变量 threadLocal.set((int) (Math.random() * 100)); System.out.println(Thread.currentThread().getName() + " set value: " + threadLocal.get()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } // 获取线程局部变量 System.out.println(Thread.currentThread().getName() + " get value: " + threadLocal.get()); // 移除线程局部变量 threadLocal.remove(); }; // 创建多个线程 Thread thread1 = new Thread(task, "Thread-1"); Thread thread2 = new Thread(task, "Thread-2"); thread1.start(); thread2.start(); } }
输出示例
Thread-1 set value: 42 Thread-2 set value: 87 Thread-1 get value: 42 Thread-2 get value: 87
注意事项
- 内存泄漏:
ThreadLocal
可能导致内存泄漏,尤其是在使用线程池时。线程池中的线程会复用,如果不及时调用remove()
方法,可能会导致ThreadLocal
的旧值一直存在,从而引发内存泄漏。 - 初始值:可以通过重写
initialValue()
方法为ThreadLocal
提供初始值。
private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>() { @Override protected Integer initialValue() { return 0; // 初始值为 0 } };
2.4.2 基本工作原理
Thread 类里面有这么一个属性:
class Thread implements Runnable { /* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ ThreadLocal.ThreadLocalMap threadLocals = null; }
ThreadLocal 的 get、set 方法实际上就是通过 ThreadLocalMap 的 get、set 方法实现的。
public class ThreadLocal<T> { // ... public T get() { // 获取当前线程的 ThreadLocalMap Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { // 根据当前 ThreadLocal 对象获取值 ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); } public void set(T value) { // 获取当前线程的 ThreadLocalMap Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { // 根据当前 ThreadLocal 对象存入值 map.set(this, value); } else { createMap(t, value); } } }
2.4.3 弱引用机制
ThreadLocalMap
的 Entry
对 Key
使用弱引用,是为了:
- 防止
ThreadLocal
对象无法被回收:当开发者不再持有ThreadLocal
的强引用时,GC 可以回收它。 - 触发清理过期 Entry:
Key
被回收后,ThreadLocalMap
会在后续操作中清理Key
为null
的Entry
,释放Value
的内存。 - 开发者仍需注意:显式调用
remove()
方法或在设计上避免线程长期持有ThreadLocal
数据,才能彻底避免内存泄漏。
ThreadLocalMap 的 节点Entry 对于 key 是弱引用的:
static class ThreadLocalMap { /** * The entries in this hash map extend WeakReference, using * its main ref field as the key (which is always a * ThreadLocal object). Note that null keys (i.e. entry.get() * == null) mean that the key is no longer referenced, so the * entry can be expunged from table. Such entries are referred to * as "stale entries" in the code that follows. */ static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
如果 ThreadLocal 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 ThreadLocalMap 中对应的 key 变为 null。这样 value 就一直是强引用,而如果当前线程一直存活,ThreadLocalMap 与 Entry 对象就一直存在,不会被垃圾回收,最终导致内存泄漏。
也就是说,内存泄漏的发生需要同时满足两个条件:
ThreadLocal
实例不再被强引用;- 线程持续存活,导致
ThreadLocalMap
长期存在。
解决方案:在使用完 ThreadLocal
后,调用 remove()
方法。 remove()
方法会从 ThreadLocalMap
中显式地移除对应的 entry,彻底解决内存泄漏的风险。
2.4.4 线程安全机制
上次面试被问到 ThreadLocal 有锁机制,但是我翻了下源码,只有实例化 ThreadLocal 对象时,其 threadLocalHashCode 属性的初始化用到了 nextHashCode() 静态方法,这个静态方法里面用到了原子类是需要原子递增的。
3. MySQL
3.1 InnoDB 索引为什么用B+树
- 高扇出,低树高:B+ 树的非叶子节点仅存储键(不存储数据),每个节点可容纳更多键值对,树高更低(通常 3~4 层即可支持千万级数据),减少磁盘 I/O 次数。
- 范围查询高效:所有数据存储在叶子节点,且叶子节点通过双向链表连接,范围查询(如 BETWEEN、ORDER BY)只需遍历链表,无需回溯非叶子节点。
- 查询稳定:所有查询最终落到叶子节点,时间复杂度稳定为 O(log n),避免 B 树因数据分布在非叶子节点导致的查询性能波动。
- 支持全表扫描优化:叶子节点链表结构便于全表顺序扫描,适合大数据量的聚合操作。
- 减少锁竞争:行锁和 MVCC 的实现依赖 B+ 树索引结构,支持高并发事务。
3.2 什么情况下不建议使用索引?
- 数据唯一性差的字段(如性别只有两种可能数据)不要使用索引
- 频繁更新的字段不使用索引,如登录次数
- 对索引列使用不等于
- 其他索引失效的场景
3.3 InnoDB 页
注意其与操作系统内存管理那个页的区分:
4. Redis 与 缓存
4.1 缓存三问
说实在的,道理我都懂,但是我就是记不住概念,尤其是记混缓存击穿和缓存穿透!
4.2 如何保证缓存与DB的数据一致性?
1. Cache-Aside(旁路缓存)
- 读流程:先读缓存,命中则返回;未命中则读数据库,写入缓存。
- 写流程:直接更新数据库,然后删除缓存(非更新缓存)。
- 关键点:删除而非更新缓存:避免并发写导致缓存与数据库不一致。先更新数据库再删缓存:降低脏数据风险(但非完全消除)。
2. Write-Through(穿透写)
- 写流程:先更新缓存,缓存组件同步更新数据库。
- 优点:强一致性,缓存即权威数据源。
- 缺点:性能较差,适合写少读多的场景。
3. Write-Behind(异步回写)
- 写流程:先更新缓存,异步批量更新数据库。
- 优点:高吞吐,适合写多场景(如点赞计数)。
- 缺点:存在数据丢失风险(如缓存宕机)。
4. 延迟双删(Double Delete)
- 场景:应对“先删缓存后更新数据库”时,其他线程读到旧数据并回填缓存。
- 步骤:删除缓存更新数据库等待短暂时间(如 500ms)再次删除缓存
- 原理:在数据库主从同步完成后二次清理缓存。
5. Java 集合与数据结构
***,没复习数据结构,真有点难顶吧我说
5.1 数组和链表的区别?
- 数组:内存分配连续、访问效率适合随机访问、数据量固定、插入删除效率低、有内存浪费或扩容性能损耗;
- 链表:内存分配不连续、每个节点需要存储指针、高效插入删除、适合频繁增删、数据量动态变化
使用场景:
- 数组:
- 图像像素存储:二维数组直接映射像素矩阵,支持快速访问和矩阵运算。
- 链表:
- HashMap 链地址法
- 实现LRU缓存淘汰算法,需频繁调整节点顺序,链表操作效率高于数组。
5.2 红黑树简介
红黑树有四大性质:
- 左根右:红黑树的前提是二叉搜索树(左 < 根 < 右)
- 根叶黑:根和叶子节点(NULL)都是黑色
- 不红红:不存在连续的两个红色节点
- 黑路同:任一节点到叶子节点所有路径黑节点数量相同
相比 AVL 树,红黑树有如下特征:
- AVL 树:
- 任一节点左右子树的高度相差绝对值不超过 1
- 查询效率高,但是修改性能大
- 红黑树:
- 任意节点左右子树高度相差不超过两倍(由“不红红”和“黑路同”两个性质决定)
- 查询效率略低,但是修改和删除更高效
对于红黑树的插入,也有三条口诀(插入节点默认是红色节点):
- 插入节点是根节点:直接变黑
- 插入节点的叔叔是红色:叔父爷变色,爷爷变成插入节点
- 插入节点的叔叔是黑色(包括NULL):和 AVL 树一样(LL,RR,LR,RL)旋转,然后变色
5.3 数组 & List & Set
使用场景总结
- 数组:适用于数据量固定、需高性能访问的场景(如数学计算、底层数据存储)。
- List:ArrayList:频繁随机访问,数据量变化较小。LinkedList:频繁增删元素,尤其是中间位置的操作。
- Set:HashSet:快速去重,无需顺序。LinkedHashSet:去重且保留插入顺序。TreeSet:去重且需要排序(如按字母、数值排序)。
动态变化、丰富API、泛型支持、框架支持、面向接口等待
优先使用 List
:适用于 95% 的日常业务场景,因其动态性、安全性和生态兼容性。