最近在找工作,虽然工作这么多年了,但依然免不了俗,要被面试官的绝技-Java八股文,教育一通。怎么办?只怪自己学艺不精,只好悬梁刺股,下次把场子找回来。。
下文题目的先后顺序不代表难易程度。
八股题1:MySQL的事务隔离级别?
- 读未提交:一个事务可以读取另一个未提交的事务的数据。会出现脏读,不推荐使用。
- 读已提交:一个事务只能读取其他已提交的事务的数据。避免脏读,但会出现不可重复读。即同一事务内两次读取同一数据结果不同(因为其他事务在期间提交了修改)。
- 可重复读:InnoDB存储引擎的默认隔离级别,在同一个事务内,多次读取同一数据的结果保持一致,不受其他事务提交的影响。避免了脏读和不可重复读,可能出现幻读。即事务在查询时,其他事务插入了新数据,导致两次查询结果集不一致(幻读的本质是进行了插入/删除操作,导致结果集变化)。
- 串行化:强制事务串行执行,通过锁机制完全隔离并发事务,避免所有并发问题。彻底解决脏读、不可重复读和幻读。但性能开销大,因为会对所有涉及的表加锁,吞吐量会下降。
扩展1:幻读与不可重复读的区别:
- 不可重复读:针对已有数据的修改(如某条记录的金额被修改),两次查询同一行数据结果不同。
- 幻读:针对新数据的插入 / 删除,两次查询的结果集行数不同(出现 “新行” 或 “消失的行”)。
- 幻读破坏了事务的隔离性,导致同一事务内的查询结果不具备一致性。
扩展2:大多数数据库支持上面四种标准隔离级别,但实现机制和默认级别不同。Spring的隔离级别除了上面四种,还有一种默认级别:使用底层数据库的默认隔离级别。Oracle和SQL Server的默认隔离级别是:读已提交。
八股题2:MySQL索引的数据结构?
- B+Tree索引:默认索引类型:InnoDB和MyISAM引擎的默认索引。
特点:
- 多叉平衡树,所有数据存储在叶子节点,非叶子节点仅存键值和子节点指针。
- 叶子节点通过指针形成有序链表,支持高效的范围查询和排序。
- 哈希索引:基于哈希表(Key-Value结构),仅支持精确匹配查询。
- 全文索引(FULLTEXT):实现方式:倒排索引(Inverted Index),存储单词到文档的映射。适用场景:文本内容搜索(如文章、日志)。
- 空间索引(R-Tree):基于R树(R-Tree),优化多维数据(如地理坐标)。适用场景:地理信息系统(GIS)、位置服务。
八股题3:分析以下代码的输出,并说明 JVM 内存模型如何影响可见性
public class VisibilityTest {
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (running) { /* 空转 */ }
System.out.println("Thread exit");
}).start();
Thread.sleep(1000);
running = false; // 主线程修改标志
}
}
答案:running 未加 volatile,线程可能无法感知修改,可能永远不输出 "Thread exit"(线程缓存 running 为 true),解决方案:加 volatile 或 synchronized
解释:
- JVM 规定所有变量存于主内存,但线程操作时会拷贝到自己的工作内存(CPU 缓存 / 寄存器)。
- 线程第一次读取running时从主内存加载到工作内存,之后循环中可能直接使用缓存值(因为 JVM 认为变量未被修改)。
- 主线程修改running=false时仅更新主内存,不通知线程 A 的工作内存
- 普通变量没有 "内存屏障",线程 A 没有义务重新加载主内存的值。
即使不考虑 JVM 优化,硬件层面也可能导致问题:
- 多核CPU的情况,线程A的CPU核心将running缓存修改为true。
- 主线程的CPU核心修改running缓存为false,JVM 没有触发缓存失效的指令。
- 线程 A 可能一直使用本地缓存的true(尤其是在空循环中,CPU 认为无需更新)
解决方案对比:
| 方案 |
原理 |
性能损耗 |
| volatile running |
强制工作内存与主内存同步(内存屏障) |
~10ns |
| synchronized 块 |
释放锁时强制刷新主内存(隐式屏障) |
~100ns |
| AtomicBoolean |
利用 CAS 的 volatile 语义 |
同 volatile |
| 加入 println () |
输出操作自带隐式的内存屏障(IO 同步) |
高延迟 |
八股题4:自动装箱陷阱,如下代码结果是什么
Integer i1 = 127; Integer i2 = 127; // == ?
Integer i3 = 128; Integer i4 = 128; // == ?
int i5 = 128; Integer i6 = 128; // == ?
答案:true false true
当创建值在 -128 到 127 范围内的 Integer 对象时,Java 会使用 整数缓存机制(通过 Integer.valueOf(int) 实现)来复用对象,而不是每次都创建新的对象。因此第一个是true,第二个是false.
在比较 i5 == i6 时,Java 会自动对 i6 进行 拆箱,拆箱后i5 和 i6 都是基本数据类型 int,比较的是它们的值。所以第三个是true。
八股题5:类加载的执行顺序,如下代码执行结果
class Parent {
static { System.out.println("Parent static block"); }
{ System.out.println("Parent instance block"); }
public Parent() { System.out.println("Parent constructor"); }
}
class Child extends Parent {
static { System.out.println("Child static block"); }
{ System.out.println("Child instance block"); }
public Child() { System.out.println("Child constructor"); }
}
public class ClassLoadTest {
public static void main(String[] args) {
new Child(); // 输出顺序?
}
}
预期答案:Parent static -> Child static -> Parent instance -> Parent constructor -> Child instance -> Child constructor
解释:类加载顺序:父静态 -> 子静态 -> 父实例块 -> 父构造 -> 子实例块 -> 子构造
八股题6:Spring中对循环依赖的处理。
Spring 通过三级缓存和提前暴露 Bean 的早期引用来解决单例 Bean的循环依赖问题。
三级缓存结构:
- 一级缓存(singletonObjects):存放完全初始化好的 Bean。
- 二级缓存(earlySingletonObjects):存放实例化但未完成属性填充和初始化的 Bean(早期引用)。
- 三级缓存(singletonFactories):存放 Bean 工厂对象,用于生成早期引用。
下面以 A-B-A 的循环依赖为例详细说明流程:
- 创建 Bean A
- Spring 开始创建 Bean A,首先从 ** 一级缓存(单例池)** 检查是否存在 A 的实例,发现没有后进入实例化阶段。
- 实例化 A 后(此时 A 尚未完成依赖注入),Spring 将 A 的早期引用(通过 ObjectFactory 包装)放入三级缓存,并标记 A 为 “正在创建中”。
- 注入 A 依赖的 Bean B
- Spring 发现 A 依赖 B,于是开始创建 Bean B。
- 同样,先检查一级缓存中没有 B,进入实例化阶段。
- 实例化 B 后,将 B 的早期引用放入三级缓存,标记 B 为 “正在创建中”。
- 注入 B 依赖的 Bean A
- Spring 发现 B 依赖 A,再次检查一级缓存,仍无 A 的实例。
- 此时,Spring 从三级缓存中获取 A 的 ObjectFactory,生成 A 的早期引用(此时 A 尚未完成初始化)。
- 将 A 的早期引用从三级缓存转移到二级缓存(早期曝光的单例对象),并从三级缓存中移除。
- B 拿到 A 的早期引用后,完成依赖注入并初始化,最终将 B 放入一级缓存。
- 完成 Bean A 的注入和初始化
- 回到 A 的创建流程,此时 B 已存在于一级缓存中,Spring 顺利为 A 注入 B 的实例。
- A 完成初始化后,从二级缓存中移除早期引用,并将最终的 A 实例放入一级缓存。
Spring 的三级缓存虽然复杂,但这是为了支持强大的功能所付出的必要代价。通过三级缓存,Spring 能够在不牺牲灵活性的前提下,解决各种复杂的循环依赖问题,包括单例 Bean、原型 Bean(需配合其他机制)等。
八股题7:dubbo中的SPI机制对循环依赖的处理。
Dubbo SPI 通过 提前缓存实例 + Setter 注入 的组合策略,利用单例模式和递归注入机制,解决了大多数循环依赖场景。其核心思想是 允许半成品对象被引用,通过依赖注入的逐步完成最终初始化。开发者在实践中应避免构造器循环依赖,并关注可能的状态一致性风险。
以 A 依赖 B,B 依赖 A 为例:
- 创建 A 实例 → 存入缓存 → 开始注入 B。
- 发现 B 未创建 → 创建 B 实例 → 存入缓存 → 开始注入 A。
- 此时缓存中已有 A 实例(尽管未完成注入),直接注入给 B。
- B 完成注入后返回,继续完成 A 的注入。
八股题8:介绍下mybatis的一级缓存和二级缓存。
- 一级缓存也被称作会话(Session)级缓存,其作用域是 SqlSession。在同一个 SqlSession 里,执行相同的 SQL 查询时,MyBatis 会先查看缓存中是否存在相应结果,若存在则直接从缓存获取,而非再次执行 SQL 查询。
- 二级缓存也叫应用级缓存,其作用域是 Mapper(命名空间)。不同的 SqlSession 执行相同的SQL查询时,就可以共享二级缓存的数据。
八股题9:下面代码是跑出异常还是正常返回
public static int test1() {
for (int i=0; i< 10; i++) {
try {
throw new Exception("test");
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
continue;
}
}
return 0;
}
答案会正常返回0
因为:异常被抑制,在 Java 中,finally 块中的代码优先于异常传播,始终会被执行。如果 finally 块中有控制流语句(如 return 或 continue),它们会覆盖异常的传播行为。
因此,尽管 throw new RuntimeException(e) 抛出了异常,但由于 finally 块中的 continue,异常被抑制了。
八股题10:下面代码返回的值是什么
public static int test2() {
try {
return 0;
} finally {
return 1;
}
}
答案是返回1
如果 finally 块中有 return 语句,则会覆盖 try 或 catch 块中的返回值。
八股题11:异常捕获顺序陷阱,下面代码会被哪个异常捕获
public class ExceptionOrder {
public static void main(String[] args) {
try {
int[] arr = null;
System.out.println(arr[0]);
} catch (Exception e) { // 1
e.printStackTrace();
} catch (NullPointerException e) { // 2
e.printStackTrace();
}
}
}
答案:Exception
子类异常必须在父类异常之前捕获,因为NullPointerException是Exception的子类,所以catch 块 2 永远无法到达
八股题12:下面代码能否编译通过,不能的话解释原因,能的话写出输出的内容。
public class ExceptionFlow {
public static void main(String[] args) {
try {
System.out.println("Try block");
int i = 1 / 0;
System.out.println("After division"); // 1
} catch (ArithmeticException e) {
System.out.println("Caught ArithmeticException");
throw new NullPointerException("Nested exception"); // 2
} finally {
System.out.println("Finally block"); // 3
}
System.out.println("After try-catch-finally"); // 4
}
}
答案:能
Try block
Caught ArithmeticException
Finally block
受检异常(Checked Exceptions)
- 继承自 java.lang.Exception,但不是 RuntimeException 的子类。
- 例如:IOException、SQLException、ClassNotFoundException。
- 必须显式处理:要么通过 try-catch 块捕获,要么在方法签名中用 throws 声明。
非受检异常(Unchecked Exceptions)
- 包括 RuntimeException 及其子类,以及 Error 及其子类。
- 例如:NullPointerException、ArrayIndexOutOfBoundsException、OutOfMemoryError。
- 无需显式处理:编译器不强制要求捕获或声明。
八股13:如下代码执行是报错还是正常返回
import java.util.HashMap;
public class HashMapNullKey {
public static void main(String[] args) {
HashMap<Object, String> map = new HashMap<>();
map.put(null, "value1");
map.put(null, "value2");
System.out.println(map.size()); // 输出?
System.out.println(map.get(null)); // 输出?
}
}
答案:输出1和value2
HashMap 允许null键,但null键只能有一个(因为null的hashCode固定),后续插入会覆盖原值。
八股题14:泛型擦除陷阱,如下代码输出结果是什么。
import java.util.ArrayList;
import java.util.List;
public class GenericErasure {
public static void main(String[] args) {
List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass()); // 输出?
}
}
答案:true
泛型信息在编译后会被擦除,运行时所有泛型 List 的 Class 对象相同。
八股题15:下面方法能否正常编译
class Parent {
public void method() throws RuntimeException { }
}
class Child extends Parent {
@Override
public void method() throws Exception { } // 能否编译?
}
答案,不能,子类重写方法时,声明的异常不能比父类更宽泛。
八股题16:下面代码执行的结果是什么?
public static void main(String[] args) {
int result = 0;
for(int i = 0; i < 100; i++) {
result = result++;
}
System.out.println(result);
}
执行结果为:0
这是一个常见的 Java 陷阱。result++ 是后置自增操作符。
类似示例:
int a = 0;
int b = a++; // b = 0, a = 1
int c = ++a; // c = 2, a = 2
八股题17:下面代码执行结果是什么?
public class OverloadDemo {
public static void method(long i) {
System.out.println("long");
}
public static void method(Integer i) {
System.out.println("Integer");
}
public static void main(String[] args) {
method(10); // 输出?
}
}
答:输出long
Java重载方法匹配的优先级是:精确匹配 > 基本类型提升 > 自动装箱/拆箱 > 可变参数
再给出一个示例:
public class OverloadDemo {
void process(int x) { System.out.println("int"); }
void process(Integer x) { System.out.println("Integer"); }
void process(Object x) { System.out.println("Object"); }
public static void main(String[] args) {
OverloadDemo demo = new OverloadDemo();
demo.process(10); // 输出 "int"
demo.process(10L); // 输出 "Object"(long → Long → Object)
demo.process(null); // 输出 "Integer"(Integer 比 Object 更具体)
}
}
八股题18:下列树形结构,用前序遍历、中序遍历和后续遍历,得到最后一位的结果是什么?
1
/ \
2 3
/ \ / \
4 5 6 7
/ \ /
8 9 10
答案:10、7、1
解释:
- 前序:根节点永远是第一个访问的。适合需要先处理父节点再处理子节点的场景(如复制树结构)。
- 中序:根节点位于左子树和右子树结果之间。对二叉搜索树(BST)会得到有序序列(升序或降序)。
- 后序:根节点永远是最后一个访问的。适合先处理子节点再处理父节点的场景(如释放内存)。
| 遍历方式 |
遍历顺序 |
结果 |
| 前序遍历 |
根 → 左 → 右 |
1→2→4→8→9→5→3→6→7→10 |
| 中序遍历 |
左 → 根 → 右 |
8→4→9→2→5→1→6→3→10→7 |
| 后序遍历 |
左 → 右 → 根 |
8→9→4→5→2→6→10→7→3→1 |
八股题19:Integer的最大值加1,和最小值减一分别会等于多少?
Integer 的最大值:2,147,483,64
当对 Integer.MAX_VALUE(即 2,147,483,647)进行加一操作时:
二进制形式:0111 1111 1111 1111 1111 1111 1111 1111 + 0000 0000 0000 0000 0000 0000 0000 0001 = 1000 0000 0000 0000 0000 0000 0000 0000
根据补码规则,1000 0000 0000 0000 0000 0000 0000 0000 表示的是 -2,147,483,648(即 Integer.MIN_VALUE)
因此,Integer.MAX_VALUE + 1 的结果是 Integer.MIN_VALUE(即 -2,147,483,648)。
Integer 的最小值:-2,147,483,648
二进制表示:1000 0000 0000 0000 0000 0000 0000 0000(最高位为1表示负数,其余位为0)
当对 Integer.MIN_VALUE(即 -2,147,483,648)进行减一操作时:
二进制形式:1000 0000 0000 0000 0000 0000 0000 0000 - 0000 0000 0000 0000 0000 0000 0000 0001
在二进制补码表示中,这个操作等价于:
取反加一:0111 1111 1111 1111 1111 1111 1111 1111(这是 Integer.MAX_VALUE 的二进制表示) + 1 = 1000 0000 0000 0000 0000 0000 0000 0000(这仍然是 Integer.MIN_VALUE 的二进制表示)
因此,Integer.MIN_VALUE - 1 的结果仍然是 Integer.MIN_VALUE(即 -2,147,483,648)。
全部评论