Java面试八股文汇总--持续更新

2025-03-19 22:57
437
0

最近在找工作,虽然工作这么多年了,但依然免不了俗,要被面试官的绝技-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)。

 

全部评论