Java开发高频面试题汇总(通俗易懂版)

一、基础篇

1. 说说 Java 的面向对象三大特性

封装:把数据和操作数据的方法放在一起,对外只暴露必要的接口,隐藏内部实现细节。就像手机,你只需要按按钮,不用管内部电路怎么工作。

继承:子类可以复用父类的属性和方法,还能扩展自己的新功能。比如「猫」继承「动物」,动物会吃饭,猫也会吃饭,但猫还会抓老鼠。

多态:同一个方法在不同对象上有不同的表现。比如「动物」都有「叫」的方法,狗叫是「汪汪」,猫叫是「喵喵」。写代码时用父类引用指向子类对象,调用同一个方法,实际执行的是子类的逻辑。

如何理解Java是跨平台的语言

java源码编译成字节码后,在其他平台运行不用再次编译

如何理解JVM是跨语言的平台

Java虚拟机根本不关心运行在内部的程序到底使用何种语言编程,只关心字节码文件

什么是基本类型,什么是引用类型

基本类型有8种,byte(1字节)、short(2字节)、int(4字节)、long(8字节)、float(4字节)、double(8字节)、boolean、char(2字节)

引用类型是对象类型,存储的是地址,如数组,创建时通过new关键字创建

重载与重写的区别

重载(Overload) 和**重写(Override)**是 Java 面向对象编程中两个核心概念,它们都体现了多态性,但应用场景和规则完全不同。

重载(Overload) :发生在同一个类 中,指方法名相同,但参数列表不同(参数类型、个数、顺序至少有一项不同)。与返回值类型、访问修饰符无关。重载是编译时多态(静态多态)。

重写(Override) :发生在父子类 之间,指子类重新定义父类中已有的方法,要求方法名、参数列表、返回值类型都相同(或子类返回类型是父类返回类型的子类),访问权限不能比父类更严格。重写是运行时多态(动态多态)。

核心区别对比表:

对比项 重载(Overload) 重写(Override)
发生位置 同一个类中 父子类之间
方法签名 必须不同(参数列表) 必须相同
返回值类型 可以不同 必须相同(或为子类)
访问权限 可以不同 不能比父类更严格
异常声明 可以不同 不能抛出比父类更宽泛的异常
多态类型 编译时多态(静态) 运行时多态(动态)
调用时机 编译时根据参数确定 运行时根据对象实际类型确定

代码示例:

java 复制代码
// 重载示例
class Calculator {
    // 方法1:两个int相加
    public int add(int a, int b) {
        return a + b;
    }
    
    // 方法2:三个int相加(参数个数不同 → 重载)
    public int add(int a, int b, int c) {
        return a + b + c;
    }
    
    // 方法3:两个double相加(参数类型不同 → 重载)
    public double add(double a, double b) {
        return a + b;
    }
}

// 重写示例
class Animal {
    public void makeSound() {
        System.out.println("动物发出声音");
    }
}

class Dog extends Animal {
    @Override // 注解明确表示重写
    public void makeSound() {
        System.out.println("汪汪汪");
    }
}

public class Test {
    public static void main(String[] args) {
        // 重载调用
        Calculator calc = new Calculator();
        System.out.println(calc.add(1, 2));      // 调用 add(int, int)
        System.out.println(calc.add(1, 2, 3));   // 调用 add(int, int, int)
        System.out.println(calc.add(1.5, 2.5));  // 调用 add(double, double)
        
        // 重写调用
        Animal animal = new Dog(); // 父类引用指向子类对象
        animal.makeSound(); // 输出"汪汪汪"(运行时根据实际对象类型调用子类方法)
    }
}

面试回答要点:

  1. 重载编译时多态 ,在同一个类 中通过不同参数列表实现多个同名方法,方便调用者。
  2. 重写运行时多态 ,在父子类重新实现父类方法,实现子类特定行为。
  3. 重载关注方法参数的多样性 ,重写关注继承关系的多态性
  4. 实际开发中,重载常用于工具类(如 Arrays.sort() 支持多种类型),重写用于框架扩展(如 Spring 的 @Override 方法)。

2. == 和 equals 的区别

== 基本类型比较【 】引用类型比较【地址

equals 默认也是比较地址,但很多类(如 String、Integer)重写了它,改成比较「内容」。所以判断两个字符串内容是否相同,要用 equals,不要用 ==。

java 复制代码
String a = new String("abc");
String b = new String("abc");
System.out.println(a == b);      // false,两个不同对象
System.out.println(a.equals(b)); // true,内容相同

hashCode() 与 equals() 之间的关系

核心关系:

  • equals() 相等,hashCode() 必须相等:如果两个对象 equals 返回 true,那么它们的 hashCode 必须相同。这是硬性规定,否则 HashMap 里会出现「equals 相等却找不到」的 bug。
  • hashCode() 相等,equals() 不一定相等:两个对象 hashCode 相同,equals 可能返回 false。因为不同对象可能算出相同的哈希值,这叫「哈希冲突」。
  • 重写 equals() 必须重写 hashCode():如果你只重写 equals 不重写 hashCode,两个 equals 相等的对象 hashCode 不同,会导致 HashMap 把它们当成两个不同的 key,去重失效。

为什么要有这个约定?

HashMap 存数据时,先算 key 的 hashCode 定位到桶,再用 equals 判断桶里是否已有相同元素。如果 equals 相等但 hashCode 不同,两个相等的对象会被分到不同的桶,HashMap 就找不到它们了。

代码示例:

java 复制代码
import java.util.HashMap;
import java.util.Map;

class Person {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 只重写 equals,不重写 hashCode(错误示范)
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person p = (Person) obj;
        return age == p.age && name.equals(p.name);
    }
}

public class Test {
    public static void main(String[] args) {
        Map<Person, String> map = new HashMap<>();
        Person p1 = new Person("张三", 20);
        Person p2 = new Person("张三", 20);

        System.out.println(p1.equals(p2)); // true,内容相同

        map.put(p1, "北京");
        System.out.println(map.get(p2));   // null!equals 相等却取不到值
        /** 为什么 map.put(p1, "北京") 之后,map.get(p2) 取不到值?

        要理解这个问题,先记住 HashMap 的查找流程:先算 hashCode 定位到桶,再用 equals 在桶里                        比对。这两个步骤缺一不可。
现在看代码里的 p1 和 p2:
equals 相等:p1.equals(p2) 返回 true,说明内容相同。
hashCode 不同:因为只重写了 equals,没重写 hashCode,所以 p1 和 p2 用的是 Object 默认的 hashCode,它基于对象的内存地址生成。两个 new 出来的对象地址不同,hashCode 自然不同。 */ 
    }
}

**正确做法:**重写 equals 的同时重写 hashCode,让 equals 相等的对象 hashCode 也相同。

java 复制代码
@Override
public int hashCode() {
    return Objects.hash(name, age); // 用相同字段生成 hashCode
}

**面试时这样答:**equals 相等则 hashCode 必须相等,但 hashCode 相等不代表 equals 相等。重写 equals 必须同时重写 hashCode,否则在 HashMap、HashSet 中会出现去重失效、取不到值的问题。实际开发中用 IDE 自动生成或 Objects.hash() 即可保证两者一致。hashMap 查找流程为:先获取key定位桶,再桶内通过equals进行比较。

3. String、StringBuilder、StringBuffer 的区别

String 是不可变的,每次拼接都会创建新对象,频繁拼接性能差。

StringBuilder 是可变的,拼接效率高,但线程不安全

StringBuffer 也是可变的,方法加了 synchronized,线程安全,但性能略低 StringBuilder。

单线程场景优先用 StringBuilder,多线程共享场景才用 StringBuffer。

4. ArrayList 和 LinkedList 区别

ArrayList 底层是数组,查询快(下标直接定位),但中间插入、删除慢(要移动元素)

LinkedList 底层是双向链表,插入、删除快(只改指针),但查询慢(需要便利)

实际开发中,大多数场景都是「遍历 + 尾部追加」,所以 ArrayList 用得更多。


ArrayList 的扩容机制 :ArrayList 底层是数组,默认初始容量为 10。当添加元素时发现数组已满,就会触发扩容:新容量 = 旧容量 + 旧容量右移一位,也就是扩容为原来的 1.5 倍。扩容时会创建一个新数组,把旧数组的元素全部拷贝过去,所以频繁扩容会有性能开销。为了避免频繁扩容,如果事先能预估元素个数,建议在构造时直接指定初始容量。

LinkedList 的扩容机制LinkedList 底层是双向链表 ,它没有「容量」的概念,也不需要扩容每添加一个元素,就 new 一个节点对象,通过指针把它挂到链表尾部,内存是动态分配的。所以 LinkedList 不存在数组拷贝的开销,但每个节点都要额外存储前驱和后继指针,内存占用比 ArrayList 更大。

面试时这样答:ArrayList 基于数组,默认容量 10,满了按 1.5 倍扩容并拷贝元素;LinkedList 基于双向链表,没有容量限制,添加元素即动态创建节点,无需扩容。

ArrayList线程不安全体现

线程不安全体现在哪里:ArrayList 的底层是数组,它的 add、remove 等操作并不是原子操作。多线程同时往 ArrayList 里添加元素时,可能出现以下问题:

  • 数据覆盖:两个线程同时 add,都判断数组容量够,然后各自在同一个下标写入,后写的把先写的覆盖掉,导致元素丢失。
  • 数组越界:两个线程同时 add 时都发现容量不足,都触发扩容,但只创建了一个新数组,另一个线程写入时可能越界抛异常。
  • 遍历时修改抛异常:一个线程在遍历,另一个线程在增删元素,会抛出 ConcurrentModificationException。

如何解决

  • 使用 Vector:Vector 的方法加了 synchronized,线程安全,但性能较差,现在基本不用。
  • 使用 Collections.synchronizedList:把 ArrayList 包装成线程安全的集合,适合读多写少的场景。
  • 使用 CopyOnWriteArrayList:写操作时复制一份新数组,读操作不加锁,适合读多写少的并发场景,是并发包里的推荐方案。

面试时这样答:ArrayList 线程不安全主要体现在并发 add 时可能数据覆盖、数组越界,以及遍历时被修改会抛 ConcurrentModificationException;解决方式有 Vector、Collections.synchronizedList 和 CopyOnWriteArrayList,其中 CopyOnWriteArrayList 是并发场景下的首选。

5. HashMap 的底层原理

HashMap 底层是「数组 + 链表 + 红黑树」。存数据时先算 key 的 hash 值,定位到数组的某个桶;如果桶里没元素,直接放;如果有,就挂成链表;当链表长度超过 8 且数组长度超过 64 时,链表转成红黑树,提高查询效率。

JDK 1.8 之前是「数组 + 链表」,1.8 之后引入了红黑树优化。

HashMap 扩容机制

  1. HashMap达到阈值时触发扩容机制,阈值为数组长度 * 加载因子(0.75),每次扩容为原来的两倍。默认容量为16,当容量达到12(16 * 0.75 = 16)时,触发扩容变为32,下次使用到24时开始触发扩容变为64。扩容时所有元素会重新计算 hash 并分配到新桶中。

  2. 链表长度超过 8 且数组长度超过 64 时,先触发扩容再将链表转为红黑树。

HashMap 线程不安全体现

HashMap线程不安全体现在:HashMap 的 put、扩容等操作不是原子操作,多线程并发写入时可能出现以下问题:

  • 数据覆盖:两个线程同时 put 到同一个桶,都判断桶为空,然后各自写入,后写的把先写的覆盖掉,导致元素丢失。
  • 扩容死循环(JDK 1.7):JDK 1.7 的 HashMap 在扩容时采用头插法,多线程同时触发扩容,可能形成环形链表,导致 get 时死循环、CPU 飙高。
  • 遍历时修改抛异常:一个线程在遍历,另一个线程在增删元素,会抛出 ConcurrentModificationException。

如何解决

  • 使用 Hashtable:方法加了 synchronized,线程安全,但锁的是整个表,并发度低,性能差,现在基本不用。
  • 使用 Collections.synchronizedMap:把 HashMap 包装成线程安全的 Map,同样锁整个表,适合并发量不高的场景。
  • 使用 ConcurrentHashMap:JDK 1.8 采用「CAS + synchronized」,只锁单个桶而不是整个表,并发度高,读操作基本无锁,是并发场景下的首选。

面试时这样答:HashMap 线程不安全主要体现在并发 put 时可能数据覆盖、JDK 1.7 扩容时可能形成环形链表导致死循环,以及遍历时被修改会抛 ConcurrentModificationException;解决方式有 Hashtable、Collections.synchronizedMap 和 ConcurrentHashMap,其中 ConcurrentHashMap 是并发场景下的首选。

HashSet底层原理

HashSet 底层就是 HashMap,它把元素当作 HashMap 的 key 来存储,value 统一用一个固定的 Object 对象占位。所以 HashSet 的去重逻辑,本质就是 HashMap 的 key 去重逻辑:先算元素的 hash 值定位到桶,再用 equals 判断是否已存在,存在就覆盖 value(不新增),不存在才插入。

扩容机制:HashSet 的扩容完全复用 HashMap 的扩容。默认初始容量 16,加载因子 0.75,当元素个数达到阈值 12(16 × 0.75)时,数组扩容为原来的 2 倍,即 32;之后元素达到 24 时再扩容到 64,以此类推。扩容时所有元素会重新计算 hash 并分配到新桶中。

面试时这样答:HashSet 是「基于 HashMap 实现的集合」,元素作为 key 存储,利用 HashMap 的 key 唯一性实现去重;扩容机制与 HashMap 一致,默认容量 16、加载因子 0.75、达到阈值翻倍扩容。

Hashset线程不安全体现

HashSet 线程不安全体现在哪里:HashSet 底层就是 HashMap,它的 add、remove 等操作并不是原子操作。多线程同时往 HashSet 里添加元素时,可能出现以下问题:

  • 数据覆盖:两个线程同时 add 到同一个桶,都判断桶里没有该元素,然后各自写入,后写的把先写的覆盖掉,导致元素丢失。
  • 扩容问题:多线程同时触发扩容,可能造成数据错乱,甚至像 JDK 1.7 的 HashMap 一样形成环形链表,导致 get 时死循环、CPU 飙高。
  • 遍历时修改抛异常:一个线程在遍历,另一个线程在增删元素,会抛出 ConcurrentModificationException。

如何解决

  • 使用 Collections.synchronizedSet:把 HashSet 包装成线程安全的 Set,方法加了 synchronized,适合并发量不高的场景。
  • 使用 CopyOnWriteArraySet:底层基于 CopyOnWriteArrayList,写操作时复制一份新数组,读操作不加锁,适合读多写少的并发场景。
  • 使用 ConcurrentHashMap.newKeySet():底层基于 ConcurrentHashMap,采用「CAS + synchronized」,只锁单个桶而不是整个表,并发度高,是并发场景下的首选。

面试时这样答:HashSet 线程不安全主要体现在并发 add 时可能数据覆盖、扩容时可能数据错乱,以及遍历时被修改会抛 ConcurrentModificationException;解决方式有 Collections.synchronizedSet、CopyOnWriteArraySet 和 ConcurrentHashMap.newKeySet(),其中 ConcurrentHashMap.newKeySet() 是并发场景下的首选。

7. Java 值传递还是引用传递

Java是值传递。在java变量中分为两种。一种是基本数据类型,另一种是对象类型。基本数据类型存储的是值本身,如 a = 5; 那么存储的值就是5,而对象类型存储的是地址值,如 new Person()对象,存在堆中的是一个地址,如 0x1234。调用方法时,本质是传递的副本。

基本数据类型(传递数据副本)

复制代码
public class Test {
    public static void main(String[] args) {
        int num = 10;
        change(num);
        System.out.println(num); // 输出 10(没变)
    }

    public static void change(int a) {
        a = 20; // 修改的是 num 的副本,原 num 不受影响
    }
}

传递引用类型(传递地址的副本)

因为对象传递的是地址的副本,所以实参和形参的地址是一样的。

  1. 通过形参修改对象属性值,实参对象属性值也会改变。

  2. 重新创建一个新对象赋值给形参,实参对象属性值不变。

  1. **验证 (**通过形参修改对象属性值,实参对象属性值也会改变)

    class Person {
    String name;
    Person(String name) { this.name = name; }
    }

    public class Test {
    public static void main(String[] args) {
    Person p1 = new Person("张三");

    复制代码
         // 场景 A:修改对象属性
         changeName(p1);
         System.out.println(p1.name); // 输出 "李四"(变了,因为指向同一对象)
     }
    
     // 场景 A
     public static void changeName(Person p) {
         p.name = "李四"; // 修改的是同一个对象的属性
     }

    }

2. 验证:重新创建一个新对象赋值给形参,实参对象属性值不变

复制代码
class Person {
    String name;
    Person(String name) { this.name = name; }
}

public class Test {
    public static void main(String[] args) {
        Person p1 = new Person("张三");

        // 场景 B:让形参指向新对象
        changeRef(p1);
        System.out.println(p1.name); // 输出 "李四"(没变!)
    }

   
    // 场景 B(关键证据)
    public static void changeRef(Person p) {
        p = new Person("王五"); // 让副本指向新对象,原 p1 的指向不变
    }
}

8. 深拷贝与浅拷贝区别

浅拷贝:对于基本数据类型, 复制的是值本身,修改副本不影响原对象;对于引用类型, 复制的是地址,本质还是指向原对象,修改副本会改变原对象。

深拷贝 :完全独立,无论修改引用类型还是基本类型,都不会影响原对象

**浅拷贝 基本数据类型,**修改副本不影响原对象

复制代码
// 验证代码
User u1 = new User();
u1.age = 25;

User u2 = (User) u1.clone();  // 浅拷贝
u2.age = 30;

System.out.println(u1.age);   // 输出 25 
System.out.println(u2.age);   // 输出 30

浅拷贝引用类型修改副本会改变原对象

复制代码
class Address {
    String city;
    Address(String city) { this.city = city; }
}

class User implements Cloneable {
    String name;
    int age;
    Address addr;
    
    User(String name, int age, Address addr) {
        this.name = name;
        this.age = age;
        this.addr = addr;
    }
    
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();  // 浅拷贝
    }
}

public class Test {
    public static void main(String[] args) throws Exception {
        User u1 = new User("张三", 20, new Address("杭州"));
        User u2 = (User) u1.clone();  // 浅拷贝
        
        // 修改副本的引用类型属性 → 影响原对象
        u2.addr.city = "北京";
        System.out.println(u1.addr.city);  // 输出:北京 被影响了
        
        // 修改副本的基本类型属性 → 不影响原对象
        u2.age = 30;
        System.out.println(u1.age);  // 输出:20  没变
    }
}

深拷贝,基本数类型和引用数据类型不会被副本修改所影响

复制代码
class Address {
    String city;
    Address(String city) { this.city = city; }
}

class User implements Cloneable {
    String name;
    int age;
    Address addr;
    
    User(String name, int age, Address addr) {
        this.name = name;
        this.age = age;
        this.addr = addr;
    }
    
    // 深拷贝:手动复制引用对象
    @Override
    protected Object clone() throws CloneNotSupportedException {
        User cloned = (User) super.clone();
        cloned.addr = new Address(this.addr.city);  // 关键:新建 Address
        return cloned;
    }
}

public class Test {
    public static void main(String[] args) throws Exception {
        User u1 = new User("张三", 20, new Address("杭州"));
        User u2 = (User) u1.clone();  // 深拷贝
        
        // 修改副本的引用类型属性
        u2.addr.city = "北京";
        System.out.println(u1.addr.city);  // 输出:杭州 ✅ 没变
        
        // 修改副本的基本类型属性
        u2.age = 30;
        System.out.println(u1.age);  // 输出:20 ✅ 没变
        
        // 两个 addr 指向不同对象
        System.out.println(u1.addr == u2.addr);  // 输出:false
    }
}

9. 泛型是什么,为什么泛型不能使用基本类型

泛型就是写代码时不指定具体的类型,先使用占位符代替,当使用到时再指定具体的类型。

Java 编译后所有泛型参数都变成 Object,并且会擦除占位符,而基本类型不是 Object 的孩子。

泛型的作用用代码片段解释

复制代码
// 没有泛型(像杂货铺,什么都往里扔,取出来还得自己辨认)
List list = new ArrayList();
list.add("Hello");
list.add(123);          
String s = (String) list.get(0);  // 需要强制转型,容易出错

// 有泛型(像自动分拣机,类型严格匹配)
List<String> list = new ArrayList<>();
list.add("Hello");      
// list.add(123);       // ❌ 编译就报错,类型安全
String s = list.get(0); // 自动就是 String,不需要转型

为什么泛型不能使用基本类型

复制代码
// 你写的代码
public class Box<T> {
    private T content;
}

// 编译后 JVM 实际看到的(擦掉了 T)
public class Box {
    private Object content;  // T 被替换成 Object
}

既然 T 最终要变成 Object,那传给 T 的必须得是 Object 的子类才行。

  • StringObject 的子类 ✅ → Box<String> 成立

  • IntegerObject 的子类 ✅ → Box<Integer> 成立

  • int 是基本类型,不是 Object 的子类 ❌ → Box<int> 编译报错

10. try-catch-finally执行顺序,finally一定会执行吗

正常情况会执行finally:

  • 有异常:try------catch------finally
  • 无异常:try------finally

但有极端情况不执行:

  • trycatch 中执行了 System.exit(0)
  • JVM 崩溃(如底层内存溢出、断电)
  • try 块中出现死循环或死锁

try 和 finally 都有 return 时返回值是哪个

  1. finally 的return会覆盖 try/catch中的return,即返回finally中的return,并且会吞掉catch中的异常。
  2. finally 块中【不包含】return,但修改了返回值变量
  • 基本数据类型:返回值是基本类型(int、boolean 等)------ 修改无效
  • 引用数据类型:返回值是引用类型(对象、集合等)------ 修改对象内容有效,修改指向无效
复制代码
public static int test() {
    try {
        System.out.println("try 执行");
        return 1;          // 步骤1:准备返回 1,但先记下来,不急着走
    } catch (Exception e) {
        return 2;
    } finally {
        System.out.println("finally 执行");
        return 3;          // 步骤2:直接返回 3,把之前的 1 覆盖掉
    }
}

// 调用结果:打印 "try 执行" 和 "finally 执行",最终返回 3

附加陷阱(异常被吞掉)

复制代码
public static int test() {
    try {
        int i = 1 / 0;   // 这里抛算术异常
        return 1;
    } finally {
        return 2;        // finally 有 return,异常被"吃掉"了!
    }
}

// 调用结果:不会抛异常,最终返回 2(外部调用者完全不知道发生了除以零的错误)

返回值是基本类型(int、boolean 等)------ 修改无效

复制代码
public static int test() {
    int result = 10;
    try {
        return result;   // 步骤1:把 result 的"值"(10)存入临时变量
    } finally {
        result = 20;     // 步骤2:修改 result 变量的值
        System.out.println("finally 修改了 result = 20");
    }
}

// 调用结果:打印 "finally 修改了 result = 20",最终返回 10(不是 20!)

Q: 既然是基本数据类型,那么在变量存储在栈中,修改了值为什么返回的是修改前的?

try 中的 return 会先把 result值拷贝 一份存起来(类似于快照),再去执行 finallyfinally 里改的是变量本身,但 return 要返回的是那份提前拷贝好的快照,所以改不动。

返回值是引用类型(对象、集合等)------ 修改对象内容有效,修改指向无效

复制代码
public static StringBuilder test() {
    StringBuilder sb = new StringBuilder("Hello");
    try {
        return sb;       // 步骤1:把 sb 的"地址值"(比如 0x1234)存入临时变量
    } finally {
        sb.append(" World"); // 步骤2:通过地址找到对象,追加内容
        System.out.println("finally 修改了对象内容");
        // sb = new StringBuilder("New"); // 如果写这行,对返回无效,因为改的是副本指向
    }
}

// 调用结果:返回 "Hello World"(对象内部状态被改了)

tryreturn 存的是地址值 0x1234 的副本。finally 通过这个地址修改了房子里的家具(append),所以外部看到的是修改后的内容。但如果 finally 里写 sb = new StringBuilder(),那只是让形参换个新门牌号,不影响已经存好的那份地址快照。

11. error 和 exception 的区别

error是JVM底层逻辑错误,如内存溢出。

而excception是业务逻辑或外部环境导致的错误,如 空指针或者数据库连接失败。

12. 什么是序列化,什么时候使用?transient关键字作用

什么是序列化

序列化就是将java对象转为二进制流的形式。相反,将二进制流转为java对象成为反序列化。

什么时候使用

  • 对象需要序列化成二进制流在网络中传输
  • 把对象保存到本地磁盘文件,或者存入 Redis 缓存
  • 利用序列化/反序列化实现深拷贝

transient关键字作用:保密、省空间

被 transient 修饰的字段,序列化时会自动跳过,不参与序列化。

反序列化时更具字段类型得到默认值(对象:null, 基本类型:false 或 0)

保密:比如用户的密码、银行卡号、身份证号,这些数据不应该被写入磁盘文件或网络传输,否则有泄露风险。

复制代码
public class User implements Serializable {
    private String username;
    private transient String password;  // 密码不序列化,防止泄露
    private transient String idCard;    // 身份证号也不序列化
}
// 序列化后,password 字段在字节流中为空,反序列化后为 null

省空间:比如缓存了计算结果的中间变量,或者根据年龄推导出来的"出生年份",没必要存,接收方可以自己算。

复制代码
public class Employee implements Serializable {
    private String name;
    private int birthYear;      // 出生年份
    private transient int age;  // 年龄可以由当前年份 - birthYear 算出来,没必要存
    // 反序列化后,手动调用 getAge() 方法重新计算即可
}

Q: **serialVersionUID 是什么?**如果序列化后,类的字段结构变了(比如新增了一个字段),反序列化会报错吗?怎么解决?

**serialVersionUID是**序列化版本号 ,是长整型数字,如果修改类的结构,序列化版本号也会改变,JVM 在反序列化时会比对字节流中的 serialVersionUID 和当前类的 serialVersionUID 是否一致。

手动写死 :比如 private static final long serialVersionUID = 1L;。只要 UID 不变,即使类增加了新字段,旧数据也能反序列化成功(新字段取默认值),这就实现了类升级兼容性

二、JVM 与内存高频题

JVM篇主要是字节码、类的加载、运行时内存、对象内存布局、执行引擎、垃圾回收、JVM性能监控、JVM性能调优这8个篇章。

字节码篇

什么是字节码指令

字节码指令 是 JVM 能够理解和执行的基本操作命令,它是 Java 源代码编译后生成的中间代码(.class 文件)中的指令集。

可以把它想象成 JVM 的"机器语言":

  • Java 源代码 → 人类能看懂的高级语言
  • 字节码指令 → JVM 能执行的"汇编语言"
  • 机器码 → CPU 能执行的二进制指令

字节码指令的特点:

  • 平台无关:同一份字节码可以在任何安装了 JVM 的平台上运行
  • 面向栈:大部分指令操作的是操作数栈,而不是寄存器
  • 类型明确:指令会区分操作数的类型(如 iadd 用于整数加法,dadd 用于双精度浮点数加法)

常见字节码指令示例:

java 复制代码
// Java 源代码
int a = 10;
int b = 20;
int c = a + b;

// 对应的字节码指令(简化版)
iconst_10     // 将整数10压入栈顶
istore_1      // 存储到局部变量表第1个位置(a)
iconst_20     // 将整数20压入栈顶
istore_2      // 存储到局部变量表第2个位置(b)
iload_1       // 加载变量a到栈顶
iload_2       // 加载变量b到栈顶
iadd          // 栈顶两个整数相加
istore_3      // 结果存储到局部变量表第3个位置(c)

字节码指令的分类:

  • 加载/存储指令:如 iload、istore(加载和存储局部变量)
  • 算术指令:如 iadd、isub、imul、idiv(加减乘除)
  • 类型转换指令:如 i2l(int转long)、d2f(double转float)
  • 对象创建与操作指令:如 new、getfield、putfield
  • 操作数栈管理指令:如 pop、dup(复制栈顶元素)
  • 控制转移指令:如 ifeq、goto(条件跳转)
  • 方法调用与返回指令:如 invokevirtual、return

面试回答要点:

  1. 字节码指令是 JVM 执行的"机器语言",存储在 .class 文件中
  2. 它是平台无关的中间代码,实现了"一次编译,到处运行"
  3. 指令集丰富,涵盖了加载存储、算术运算、类型转换、对象操作等
  4. 可以通过 javap -c 命令查看类的字节码指令
  5. 理解字节码有助于深入理解 Java 程序的执行机制和性能优化

类加载五个过程

加载、验证、准备、解析、初始化。

其中验证、准备、解析可以统称为链接。

加载读取class文件生成Class对象;

验证校验字节码合法性;

准备静态变量赋默认零值,final常量直接赋值;

解析符号引用转直接引用;

初始化执行静态代码块复制静态变量,主动使用才会初始化。

6种主动使用:创建对象、调用静态方法、反射、子类初始化父类、main类、动态绑定

类的生命周期

类的生命周期指一个类从被加载到虚拟机内存开始,到卸载出内存为止的完整过程,包括加载(Loading)验证(Verification)准备(Preparation)解析(Resolution)初始化(Initialization)使用(Using)卸载(Unloading) 七个阶段。其中前五个阶段(加载、验证、准备、解析、初始化)统称为类加载

1. 加载(Loading)

  • 通过类的全限定名获取定义此类的二进制字节流。
  • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
  • 在内存中生成一个代表该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

2. 验证(Verification)重要步骤

  • 文件格式验证:验证字节流是否符合 Class 文件格式规范。
  • 元数据验证:对类的元数据信息进行语义校验。
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  • 符号引用验证:发生在解析阶段,验证符号引用能否被正确解析。

3. 准备(Preparation)重要步骤

  • 类变量(static 变量)分配内存 并设置默认初始值(零值)。
  • 例如:public static int value = 123; 在准备阶段后 value 的值为 0,而不是 123。
  • 如果类变量是 final 常量(static final),则在准备阶段就会直接赋值为指定的值。

总结:给static变量分配内存赋默认值。静态变量赋默认值(0、nulll、false)。不执行等号右边代码,不执行静态代码块。

eg:

  1. static int A = 10; // 准备阶段赋默认值0,初始化准备阶段赋值10。

  2. static final int A = 10; // 准备阶段赋值

  3. static final int B = new Random().nextInt(10); // 初始化阶段赋值

Q: 上面的例子2和3为什么同样是 static final , 有的在准备阶段赋值,有的在初始化阶段赋值

区分编译时常量和运行时常量

编译期常量: static final int A = 100; 等号右边是字面量,编译就可以算出来的值,在准备阶段可以赋值。

运行时常量 :new Random().nextInt(10) 在编译时无法算出结果,需要运行后才能得到结果,所以需要等到初始化阶段赋值。总结,等号右边有方法、new 、运算编译不出来,需要等到运行时

4. 解析(Resolution)重要步骤

  • 将常量池内的符号引用 替换为直接引用的过程。
  • 符号引用:以一组符号来描述所引用的目标。
  • 直接引用:可以是直接指向目标的指针、相对偏移量或能间接定位到目标的句柄。

总结:将符号引用替换为直接引用的过程,得到类、变量、方法在内存中的指针。如果直接引用存在,那么系统中一定存在该类的变量、方法。相反,只有符号引用则无法确定是否有该类。

5. 初始化(Initialization)重要步骤

  • 执行类构造器 <clinit>() 方法的过程,该方法由编译器自动收集类中所有类变量的赋值动作静态代码块(static{}) 中的语句合并产生。总而言之,初始化就是为类的静态变量赋值的过程
  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步。
  • 触发初始化的时机(主动使用)
    • 创建类的实例(new)。
    • 访问类的静态变量(非 final)或为静态变量赋值。
    • 调用类的静态方法。
    • 使用反射(Class.forName("xxx"))加载类。
    • 初始化一个类的子类(会先触发父类的初始化)。
    • 虚拟机启动时,被标明为启动类的类(包含 main 方法的类)。

6. 使用(Using)

  • 类完成初始化后,就可以被程序正常使用,包括创建对象、调用方法等。

7. 卸载(Unloading)

  • 当类的 Class 对象不再被引用,且该类对应的类加载器被回收时,该类才会被卸载。
  • 由 JVM 自带的类加载器(Bootstrap、Extension、Application)加载的类在 JVM 生命周期中始终不会被卸载。
  • 自定义类加载器加载的类在满足条件时可以被卸载。

面试回答要点 :类的生命周期包括加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中加载到初始化是类加载过程,初始化是执行 <clinit>() 方法的过程,触发条件有 new、访问静态变量/方法、反射、初始化子类、启动类等。准备阶段为静态变量赋零值,解析阶段将符号引用转为直接引用。

触发初始化阶段的时机(主动引用)

  • new 对象
  • 调用静态方法
  • 读取/修改静态变量 (final 静态变量除外)
  • 反射 Class.forName()
  • 初始化子类先触发父类初始化
  • main方法所在类启动

被动引用不触发初始化

子类访问父类static变量,只初始化父类,子类不初始化

通过数组定义类: A arr = new A10,不会初始化A

访问 static final 编译期常量,直接取常量池,不触发类初始化

JVM的生命周期

Java虚拟机的启动通引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成,这个类由虚拟机的具体实现指定。

类只加载一次吗

同一个类加载器下,一个类只会被加载一次;如果有多个不同类加载器,会重复加载同一个类。JVM区分类的唯一标识=全类名+类加载器实例

什么是类加载器,有哪些类加载器

类加载器ClassLoader负责读取class字节码文件,转换成JVM 里面的class对象。

JDK从上到下有四大类加载器

  1. 启动类加载器 BootstrapClassLoader(根加载器)

  2. 扩展类加载器 ExtensionCassLoader

  • 加载jre/lib目录下扩展jar包
  • Java实现

3.应用程序类加载器 AppClassLoader(系统类加载器)

  • 项目代码默认类加载器
  • 加载classpath下写的业务代码、项目引入的jar
  1. 自定义类加载器
  • 继承ClassLoader 重写方法,自己实现加载逻辑

双亲委派模型是什么

双亲委派模型 :当一个类加载器收到类加载请求时,它不会自己先去加载 ,而是先把请求委派给父类加载器 ,父类加载器再往上委派,直到最顶层的启动类加载器(Bootstrap ClassLoader)。只有父类加载器加载不了时,才由子类加载器自己尝试加载。

这样做的核心目的是保证核心类库的安全 。比如你写一个 java.lang.String,最终会被委派到启动类加载器加载 JDK 自带的 String,而不是你写的那个,从而避免核心类被篡改。

能不能打破。常见方式有三种:

  • 重写 loadClass() 方法 :双亲委派的逻辑就在 ClassLoader.loadClass() 里,重写它、不调用父类加载,就打破了委派规则。由于双亲委派机制是后面提出来的,为了兼容旧项目,所以现在还可以重写loadClass()方法。任何加载器都将调用defineClass方法,该方法会执行preDefineClass接口,只要是java开头的API都会被Bootstrap根加载器所加载。
  • 重写 findClass() 方法 :这是官方推荐的「打破」方式,其实不算真正打破,只是把加载逻辑放到 findClass 里,让父类加载失败后再走自己的逻辑。
  • SPI 机制(线程上下文类加载器) :JDBC 等场景中,核心类由启动类加载器加载,但具体实现(如 MySQL 驱动)在应用里,父加载器加载不到,于是通过 Thread.currentThread().getContextClassLoader() 让子类加载器去加载,实现了「父类加载器反过来请子类加载器帮忙」。

面试时这样答:双亲委派就是「先问父类,父类加载不了自己再上」,目的是保证核心类库安全、避免重复加载;可以打破,常见做法是重写 loadClass()、重写 findClass(),以及利用线程上下文类加载器实现 SPI。

双亲委派机制优劣势

优势

  • 安全,避免核心API被篡改。
  • 避免类重复加载,如启动类加载器加载了类其他加载器就不会加载了

劣势

  • 上层的加载器无法访问下层的类,下层的可以访问上层的

Tomcat的类加载机制

Tomcat 没有完全遵循 JDK 的「双亲委派模型」,而是采用自定义的类加载器体系 ,目的是实现 Web 应用的隔离性热部署

Tomcat 类加载器层次结构(从上往下)

  1. Bootstrap、Extension、Application(JDK 自带类加载器):最上层,加载 JDK 核心类库。
  2. CatalinaClassLoader(服务器类加载器) :加载 Tomcat 服务器自身的类(如 catalina.jar)。
  3. CommonClassLoader(公共类加载器) :加载 Tomcat 自身和所有 Web 应用都可能用到的类(如 common/lib 下的 jar)。
  4. SharedClassLoader(共享类加载器) :加载所有 Web 应用共享的类(如 shared/lib 下的 jar)。
  5. WebAppClassLoader(应用类加载器) :每个 Web 应用独立一个,负责加载 WEB-INF/classesWEB-INF/lib 下的类。它优先自己加载,找不到才委托父加载器,这打破了双亲委派。

为什么 Tomcat 要打破双亲委派?

  • 隔离性:不同 Web 应用可能依赖同一个库的不同版本(如 Spring 4 和 Spring 5)。如果遵循双亲委派,先加载的版本会被所有应用共享,导致冲突。Tomcat 让每个 Web 应用用自己的类加载器,实现版本隔离。
  • 热部署:修改 Web 应用代码后,只需重启该应用的类加载器,而不需要重启整个 Tomcat 服务器。
  • 安全性 :防止 Web 应用覆盖 Tomcat 核心类(如 javax.servlet.*)。Tomcat 的类加载器会优先加载核心类,再加载应用类。

Tomcat 类加载顺序(打破双亲委派的具体表现)

  1. WebAppClassLoader 收到类加载请求。
  2. 先检查本地缓存是否已加载过该类。
  3. 检查 JVM 是否已加载(防止重复加载核心类)。
  4. 优先从 WEB-INF/classesWEB-INF/lib 加载(应用自身类)。
  5. 如果找不到,再委托给父加载器(Shared → Common → Catalina → Application → Extension → Bootstrap)。

面试回答要点

Tomcat 采用自定义的类加载器体系,主要包含 WebAppClassLoader、SharedClassLoader、CommonClassLoader、CatalinaClassLoader 等。它打破了双亲委派模型 ,让每个 Web 应用优先从自己的 WEB-INF 目录加载类,实现了应用间的隔离性热部署能力。这样不同应用可以使用同一库的不同版本,修改代码后只需重启应用类加载器,而不影响其他应用和 Tomcat 服务器本身。


内存篇

JVM 内存区域有哪些

主要分五块:

  • :存放对象实例 ,是 GC 的主要区域。
  • 方法区:存放类信息、常量、静态变量(JDK 1.8 后叫元空间)。
  • 虚拟机栈 :每个线程一个,存放局部变量方法 调用栈。
  • 本地方法栈 :为 native 方法服务(底层方法)。
  • 程序计数器 :记录当前线程执行到哪一行字节码。

堆和方法区是公有的 ,虚拟机栈、本地方法栈、程序计数器是私有的。程序计数器是占用内存最小的一块, 也是唯一一个没有内存溢出的部分。虚拟机栈和本地方法栈只涉及出栈和入栈,存在栈溢出,但不存在垃圾回收。垃圾回收只涉及堆和方法区。

如何理解栈管内存,堆管存储

"栈管内存,堆管存储"是 JVM 内存管理的一句经典口诀,它形象地概括了虚拟机栈的核心职责。

栈管内存 :这里的"内存"指的是方法调用时的内存分配 。栈是先进后出。每个线程都有自己的虚拟机栈,栈中存储的是栈帧 。每当调用一个方法,就会创建一个栈帧并入栈,栈帧里存放局部变量表 (基本数据类型的值、对象引用)、操作数栈动态链接方法返回地址 等信息。方法执行完毕,栈帧出栈,其占用的内存(局部变量等)也随之释放。所以栈管理的是方法执行过程中的内存分配与回收,生命周期与线程同步,速度极快,但空间有限。

堆管存储 :这里的"存储"指的是对象实例的存储 。所有通过 new 关键字创建的对象(包括数组)都在堆中分配内存。堆是 JVM 所管理的内存中最大的一块,被所有线程共享。堆中存储的是对象的实际数据 (成员变量等)。堆的内存由垃圾回收器(GC)负责回收,所以对象的生命周期不确定,可能长时间存活。因此,堆管理的是对象数据的存储与回收

通俗比喻

  • 就像快餐店的餐桌。顾客(线程)来了,点餐(调用方法)后得到一个餐盘(栈帧),吃完(方法执行完)立刻收拾干净(栈帧出栈),给下一位顾客用。周转很快,但空间固定。
  • 就像餐厅后厨的仓库。所有食材(对象)都存放在这里,厨师(线程)需要时就去取。仓库空间大,但东西放久了不用(对象不再被引用)就需要清洁工(GC)定期来清理。

代码示例

java 复制代码
public class StackHeapDemo {
    public static void main(String[] args) {
        // 基本类型变量 a 的值 10 存储在栈帧的局部变量表中
        int a = 10; // 栈管内存:a 的值存在栈里

        // new 出来的 Person 对象实例存储在堆中
        // 变量 p 本身是引用,存储在栈帧的局部变量表中,其值指向堆中对象的地址
        Person p = new Person("张三", 20); // 堆管存储:对象数据在堆里
        p.sayHello();
    }
}

class Person {
    String name; // 成员变量,随对象存储在堆中
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    void sayHello() {
        // 方法调用时,会在栈中为 sayHello 方法创建新的栈帧
        String msg = "Hello"; // 局部变量 msg 存储在 sayHello 方法的栈帧中
        System.out.println(msg + ", I'm " + this.name);
    }
}

面试回答要点

  1. 栈管内存:管理方法执行时的内存,存储局部变量、操作数栈、方法出口等。每个线程私有,生命周期随方法调用结束而结束,自动回收,速度快。
  2. 堆管存储 :管理对象实例的存储,所有 new 出来的对象都在堆里。线程共享,生命周期不确定,由垃圾回收器负责回收。
  3. 核心区别:栈内存分配和回收效率高,但空间小,用于程序执行;堆空间大,用于数据存储,但 GC 有开销。
  4. 关联:栈中的局部变量表可以存放堆中对象的引用(地址),通过这个引用可以操作堆中的对象。

栈帧的结构

局部变量表、操作数栈、动态链接、方法返回地址

局部变量表

局部变量表(Local Variable Table)是栈帧(Stack Frame)的重要组成部分,用于存储方法参数和方法内部定义的局部变量。它在编译期就已确定大小,运行期间不会改变。

主要特点:

  • 存储内容:存放基本数据类型的值、对象引用(reference)以及 returnAddress 类型(指向一条字节码指令的地址)。
  • 容量单位:以变量槽(Variable Slot)为最小单位,一个 Slot 可以存放一个 32 位以内的数据类型(如 int、float、reference)。64 位的数据类型(如 long、double)会占用两个连续的 Slot。
  • 索引访问 :通过索引访问,索引从 0 开始。对于实例方法(非 static),索引 0 位置存储的是 this 引用,之后依次是方法参数和局部变量。
  • 作用域:局部变量的作用域仅限于其定义的方法内部,方法执行结束后,其栈帧被销毁,局部变量表也随之释放。
  • 与操作数栈的关系:局部变量表中的数据可以加载到操作数栈中进行运算,运算结果也可以存储回局部变量表。

示例分析:

java 复制代码
public class LocalVarDemo {
    public int add(int a, int b) { // 实例方法
        int c = a + b; // 局部变量 c
        return c;
    }
}

对于 add 方法,其局部变量表结构如下:

  • Slot 0: this (当前对象引用)
  • Slot 1: 参数 a (int)
  • Slot 2: 参数 b (int)
  • Slot 3: 局部变量 c (int)

面试要点:

  1. 局部变量表是线程私有的,不存在线程安全问题。
  2. 局部变量表的大小在编译期确定,并保存在方法的 Code 属性的 max_locals 数据项中。
  3. 局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
  4. 局部变量没有"准备阶段",必须显式初始化后才能使用,否则编译报错。

操作数栈

当程序要计算一个表达式: 2 * 4 + (3 + 5)/ 2 时,需要用到操作数栈和操作符栈。会从左往右扫描,并时刻对比优先级。

首先 2 入操作数栈,操作符栈为空, *直接入操作符栈,4入操作数栈,+ 发现操作符栈有*,对比发现*优先级高,于是对*进行运算,取出 4和2相乘运算,注意数字先出的在运算符右边,得到的结果8压入操作数栈,操作符栈为空,+直接入操作符栈,(直接入操作符栈,3入操作数栈,+ 入操作符栈,5入操作数栈,发现)需要对栈中符号进行运算,取出5、3得到8入栈操作数栈,/ 比操作符栈+优先级高,直接入操作符栈,2直接入操作数栈,扫描完成,对栈中数据最后处理,取出操作数栈2、8,操作符栈/,得到4入栈,最后取出4、8、+得到12,最后入栈为12就是表达式最终结果。

动态链接

动态链接(Dynamic Linking) 是栈帧中的一个重要组成部分,它负责将方法调用中的符号引用 转换为直接引用

什么是符号引用和直接引用?

  • 符号引用:用一组符号(如全限定类名、方法名、描述符)来描述所引用的目标。在编译期,Java 代码中的方法调用、字段访问等都是符号引用,它们存储在 class 文件的常量池中。
  • 直接引用:直接指向目标方法、字段在内存中实际地址的指针、偏移量或句柄。

动态链接的作用

  1. 支持多态:Java 方法调用有静态绑定(如 private、static、final 方法)和动态绑定(如普通实例方法)。动态链接是实现动态绑定的关键。在运行时,JVM 根据对象的实际类型(而不是引用类型)来确定要调用的方法版本,这个过程就是通过动态链接完成的。
  2. 连接常量池:栈帧中的动态链接区域指向当前方法所属类的运行时常量池。当方法需要引用其他类、方法或字段时,通过这个链接去常量池查找对应的符号引用,并将其解析为直接引用。
  3. 支持动态语言特性:为 invokedynamic 指令(Java 7+ 引入,用于支持动态类型语言)提供运行时链接能力。

举例说明

java 复制代码
public class Animal {
    public void speak() {
        System.out.println("Animal speaks");
    }
}

public class Dog extends Animal {
    @Override
    public void speak() {
        System.out.println("Dog barks");
    }
}

public class Test {
    public static void main(String[] args) {
        Animal animal = new Dog(); // 编译时类型是 Animal,运行时类型是 Dog
        animal.speak(); // 输出 "Dog barks"
    }
}

main 方法中调用 animal.speak() 时:

  • 编译期:只知道 animalAnimal 类型,所以 speak() 调用是一个符号引用(指向 Animal.speak)。
  • 运行期:JVM 通过动态链接,发现 animal 实际指向的是 Dog 对象,于是将符号引用解析为 Dog.speak() 方法的直接引用(内存地址),从而调用正确的方法。

与静态链接的区别

  • 静态链接:在编译期或类加载的解析阶段就完成符号引用到直接引用的转换。适用于静态绑定(private、static、final 方法、构造器)。
  • 动态链接:在运行期才完成转换。适用于动态绑定(普通实例方法、接口方法)。

面试回答要点:动态链接是栈帧的一部分,用于在运行时将方法调用中的符号引用转换为直接引用,是实现 Java 多态(动态绑定)的关键机制。它指向当前方法所属类的运行时常量池,支持 invokedynamic 等动态语言特性。

方法返回地址

**方法返回地址(Return Address)**是栈帧中的一个重要组成部分,它记录了当前方法执行完毕后,应该返回到调用者的哪个位置继续执行。

作用

  1. 确定返回位置:当一个方法被调用时,JVM 会把调用指令的下一条指令地址(即返回地址)压入当前方法的栈帧中。方法执行结束后,JVM 根据这个地址跳回调用者代码继续执行。
  2. 支持方法嵌套调用:每个方法调用都会在栈中创建一个新的栈帧,每个栈帧都有自己的返回地址,从而支持多层方法调用和返回。
  3. 处理异常返回:如果方法执行过程中抛出异常且未被捕获,JVM 会使用异常表(Exception Table)中的信息,结合返回地址机制,跳转到相应的异常处理代码。

存储内容

  • 通常是一个指向调用者方法中某条指令的程序计数器(PC)值
  • 对于正常返回(通过 return 指令),返回地址就是调用指令的下一条指令地址。
  • 对于异常返回(通过 athrow 指令),返回地址由异常处理器表决定。

与程序计数器(PC)的关系

  • 程序计数器是线程私有的,记录当前线程正在执行的字节码指令地址
  • 方法返回地址是栈帧的一部分,记录的是当前方法执行完后应该回到的地址,它本质上是调用发生时程序计数器的一个"快照"。

示例说明

java 复制代码
public class ReturnAddressDemo {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;
        int sum = add(a, b);          // 调用 add 方法时,下一条指令地址(比如 0x1234)作为返回地址压入 add 方法的栈帧
        System.out.println(sum);      // add 方法返回后,从这里继续执行(地址 0x1234)
    }

    static int add(int x, int y) {
        int result = x + y;
        return result;                // 方法执行结束,根据栈帧中的返回地址跳回 main 方法
    }
}

面试回答要点

  1. 方法返回地址是栈帧的组成部分,记录了方法执行完毕后应该返回到调用者的哪个位置。
  2. 它通常是一个 PC 值,在方法调用时被压入栈帧,在方法返回时被使用。
  3. 对于正常返回,地址是调用指令的下一条指令;对于异常返回,由异常处理器表决定。
  4. 它与程序计数器不同:PC 指向当前正在执行的指令,返回地址指向方法返回后要执行的指令。

如何设置栈的大小

jdk 5.0之前,默认栈大小 256k

jdk5.0之后,默认栈大小1M

通过 -Xss 参数设置,如 -Xss1024、 -Xss1m。

注意:设置的是当前线程的栈大小,如果每个线程的栈设置过大,那么栈在一定空间下,创建线程就少,所以不是栈越大越好。

堆的内存结构

Java 堆(Heap)是 JVM 中最大的一块内存区域,被所有线程共享,用于存放对象实例和数组 。堆是垃圾收集器(GC)管理的主要区域,因此也被称为"GC堆"。对象失去引用后变成垃圾,不会立即回收,也不是等GC满了再回收,GC的触发机制有JVM决定。如果程序直接退出,则垃圾不回收,操作系统整体收回内存。

堆的核心分区(JDK 1.8 及之后)

现代 JVM(HotSpot)的堆内存主要分为以下几个区域:

  • 新生代(Young Generation) :存放新创建的对象。新生代又分为:
    • Eden 区:对象初次分配的地方。
    • Survivor 区:分为 Survivor0(S0)和 Survivor1(S1),用于存放经过 Minor GC 后存活下来的对象。
  • 老年代(Old Generation/Tenured Generation):存放长期存活的对象。当对象在新生代中经历一定次数(默认 15 次)的 GC 后仍然存活,就会被晋升到老年代。
  • 元空间(Metaspace,JDK 1.8+):存放类的元数据信息(如类结构、方法、常量池等)。在 JDK 1.8 之前,这部分信息存放在"永久代(PermGen)"中,位于堆内存中。从 JDK 1.8 开始,元空间移到了本地内存(Native Memory),不再属于堆的一部分,从而避免了永久代的 OOM 问题。

堆内存结构示意图(逻辑视图)

flowchart TD A[Java 堆 Heap] --> B[新生代 Young Generation] A --> C[老年代 Old Generation] A --> D[元空间 Metaspace (JDK 1.8+, Native Memory)] B --> E[Eden 区] B --> F[Survivor 区] F --> F0[Survivor0 (S0/From)] F --> F1[Survivor1 (S1/To)] style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#ccf,stroke:#333 style C fill:#cfc,stroke:#333 style D fill:#ffc,stroke:#333 style E fill:#eef,stroke:#333 style F0 fill:#eef,stroke:#333 style F1 fill:#eef,stroke:#333

对象分配与晋升流程

堆内存又分为新生代老年代 。当新生代内存满后,会触发垃圾回收,由于新生代的存活时间比较短 ,因此采用标记-复制算法 ,将存活的对象复制到幸存区,然后清除新生代,每熬过一次垃圾回收,幸存区年龄加1,默认达到15岁 时新生代对象会被晋升到老年代 ,老年代存的对象存活时间比较长 ,因此这里采用标记整理算法。

  1. 对象优先在 Eden 区分配:大多数新创建的对象都在 Eden 区。

  2. Minor GC(Young GC) :当 Eden 区满时,触发 Minor GC。

    • 将 Eden 区中存活的对象复制到空的 Survivor 区(To 区)
    • 同时检查**另一个 Survivor 区(From 区)**中已有的对象,将其中存活的对象也复制到 To 区。
    • 清空 Eden 区和 From 区。
    • 此后,From 区和 To 区角色互换(即原来的 To 区变为下一次 GC 的 From 区)。

    关键理解 :Survivor0 和 Survivor1 并不是固定不变的,它们交替扮演 From 和 To 的角色。每次 Minor GC 时,总有一个 Survivor 区是空的(作为 To 区),另一个 Survivor 区存放着上一次 GC 后存活的对象(作为 From 区)。这样设计是为了避免内存碎片,同时保证每次 GC 后存活对象都集中在一个连续的 Survivor 区中。

  3. 对象年龄计数器:对象每在新生代中经历一次 Minor GC 且存活,年龄就加 1。

  4. 晋升老年代 :当对象年龄达到阈值(默认 15,可通过 -XX:MaxTenuringThreshold 调整)时,下次 GC 会将其晋升到老年代。

  5. 大对象直接进入老年代 :如果对象非常大(如大数组),可能直接分配到老年代,避免在 Eden 区和 Survivor 区之间大量复制。可通过 -XX:PretenureSizeThreshold 设置阈值。

  6. 空间分配担保 :在 Minor GC 之前,JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总大小。如果大于,则 Minor GC 是安全的;否则,会检查是否允许担保失败(-XX:HandlePromotionFailure),如果允许,则继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则尝试 Minor GC(有风险);如果小于,则直接触发 Full GC。

垃圾回收与堆的关系

  • Minor GC / Young GC:只回收新生代(Eden + Survivor)。
  • Major GC / Old GC:只回收老年代(目前只有 CMS 收集器有单独的老年代收集)。
  • Full GC :回收整个堆(新生代 + 老年代 + 方法区/元空间)。触发条件包括:
    • 老年代空间不足。
    • 方法区/元空间空间不足。
    • 调用 System.gc()(建议 JVM 执行 Full GC,但不保证立即执行)。
    • Minor GC 后存活对象太多,Survivor 区放不下,且老年代也放不下。
    • 空间分配担保失败。

面试回答要点

  1. 堆是 JVM 管理的最大内存区域,被所有线程共享,用于存放对象实例。
  2. 堆主要分为新生代(Eden、Survivor0、Survivor1)和老年代。JDK 1.8 后,元空间移出堆,放在本地内存。
  3. 新对象优先在 Eden 区分配,经历多次 Minor GC 后存活的对象会晋升到老年代。
  4. 堆大小通过 -Xms-Xmx 设置,新生代比例通过 -XX:NewRatio-XX:SurvivorRatio 调整。
  5. 垃圾回收主要针对堆,分为 Minor GC(新生代)、Major GC(老年代)和 Full GC(整个堆)。

设置堆内存的大小

堆内存默认最大值计算方式:如果物理内存小于192M,那么堆最大值为物理内存的一半。如果物理内存大于等于1G, 那么堆内存的最大值为物理内存的1/4。

堆内存默认最小值计算方式 :最少不少于8M,如果物理内存大于等于1G,那么默认值为物理内存的1/64,即1024/64=16M。最小堆内存再jvm启动时会被初始化。

各区域默认比例与调整参数

  • 新生代与老年代比例 :默认 -XX:NewRatio=2,表示新生代:老年代 = 1:2(即新生代占堆的 1/3)通常不修改。
  • Eden 与 Survivor 比例 :默认 -XX:SurvivorRatio=8,表示 Eden:Survivor0:Survivor1 = 8:1:1(即每个 Survivor 占新生代的 1/10)。
  • 堆大小设置
    • -Xms:堆初始大小(如 -Xms512m)。
    • -Xmx:堆最大大小(如 -Xmx2g)。
  • 新生代大小设置
    • -Xmn:新生代大小(如 -Xmn256m)。

什么是空间分配担保

在新生代GC之前,JVM提前检查老年代连续内存空间,判断是否可满足本次GC后要晋升老年代的存活对象。因为Full GC很慢,尽量避免Full GC(回收整个堆内存)。

在jdk 6 update 24之前读取HandlePromotionFailure参数 ,如果为true, 使用历次晋升老年代平均大小做判断,老年代连续内存空间大于 历次晋升空间大小,则尝试一次 Minor GC 。如果为false 或者老年代连续内存空间小于 历次晋升空间大小,则进行一次Full GC

在jdk 6 update 24 之后HandlePromotionFailure参数不在读取这个配置。改变为只要老年代的连续空间大于新生代对象总大小或者大于历次晋升的平均大小就会进行Minor GC(老年代垃圾回收),否则 Full GC(整个堆内存回收)

MinorGC、MajorGC、FullGC

MinorGC(Young GC) :新生代空间不足时触发。这里新生代满了是指Eden区满,Survivor满不会触发GC。MinorGC会引发STW(Stop-The-World),暂停所有用户线程,等垃圾回收结束后,用户线程才会恢复运行。

MajorGC(Old GC):老年代空间不足时触发。Major GC 通常(但不是绝对)会伴随一次 Minor GC。Major GC 的速度比 Minor GC 慢10倍以上,STW时间更长。如果 Major GC 后,内存仍然不足,会触发FullGC,还不足则会报 OOM(OutOfMemoryError)。

FullGC:回收整个堆内存(新生代 + 老年代 + 方法区/元空间)。触发 Full GC 的情况包括:

  • 老年代空间不足(当 Major GC 无法回收足够内存时)
  • 方法区/元空间空间不足
  • 调用 System.gc()(建议JVM执行Full GC,但不保证立即执行)
  • Minor GC 前检查空间分配担保失败(老年代连续空间小于新生代所有对象总大小)
  • 大对象直接进入老年代导致空间不足

关键区别

  • Minor GC:只回收新生代(Eden + Survivor)
  • Major GC:只回收老年代(某些GC收集器如CMS有单独的老年代回收)
  • Full GC:回收整个堆(新生代 + 老年代 + 方法区/元空间)

注意:在实际的GC日志中,Major GC 和 Full GC 有时会被混用,但概念上 Full GC 的范围更大。大多数情况下,当老年代空间不足时,JVM 会先尝试 Major GC,如果还不够,则会触发 Full GC。

JVM为什么要分代,不分代有何影响

分代的本质时提高吞吐量,降低整体的GC开销。新生代里大部分对象存活时间很短,MinorGC清理,只复制少量存活对象,开销很小。老年代对象存货时间长,回收频率低。FullGC代价高。

如果不分代,所有对象放一块,每次GC都要扫描整个堆内存,STW时间暴涨,GC停顿时间长。

创建对象的步骤

  1. 判断对象对应的类是否加载、链接、初始化

  2. 为对象分配内存(指针碰撞、空闲列表)

  3. 处理并发安全问题 (CAS加锁失败重试、TLAB)

  4. 初始化分配到的空间 (初始化为默认值0、false)

  5. 设置对象的对象头

  6. 执行init方法进行初始化


指针碰撞(内存规整):如果内存规整,虚拟机将采用指针碰撞法为对象分配内存。也就是说通过指针将内存分为两部分,一边是空闲内存,一边是使用中的内存。分配内存移动指针即可。

空闲列表(内存不规整 因为内存中空闲空间可能不是连续的,空闲列表记录内存空闲的空间地址,当需要分配空间时,找一块能够存储的空间分配,并从空闲列表移除被分配的地址。注意:当一块内存空间不再使用时,里面的内容不会清空,只在空闲列表记录该地址为可用,只有写入同样大小的数据将其覆盖,之前的内容才会丢失。这就是为什么我们将手机或电脑重置还有方法将其找回的原因。

垃圾回收篇

什么是垃圾

垃圾就是程序运行时没有被指针指向对象称为垃圾。如果不回收垃圾,JVM内存满了就会报错,就像家里重来不打扫卫生房间被占满了一样。

垃圾判定算法

引用计数算法 :每个对象保存一个整型的引用计数器属性,入对于一个对象A, 只要有任何一个对象引用A,则A的引用计数器就加1, 当引用失效,引用计数器就减1.只要对象A的引用计数器的值为0,标识A对象不可能再被使用,可进行回收。

优点:操作简单,只需要维护一张表记录;吞吐量高。

缺点:没法处理循环依赖问题,如一个对象A包含对象B,对象B被对象C引用,当对象A不再被使用时,此时A对象是垃圾,A中包含的对象B也无法被回收,所以java没选择该算法。


可达性分析算法 :也叫跟踪性垃圾收集。以根节点为判定标准,如果根节点被引用,则判定对象还存活,也就是说根节点被引用,就不会被判定为垃圾。如一个对象A包含对象B和C,对象B和对象C不再被引用,但对象A被使用,虽然此时B,C对象是垃圾,但由于对象A还被引用,所以对象BC也不会被回收。有可能会造成内存泄漏。

垃圾回收算法有哪些

1. 标记-清除算法

将存活对象标记,然后清理垃圾。

优点:简单

缺点:会产生大量不连续空间


2. 标记-复制算法

准备两个区域,每次只使用其中一块区域。将存活对象复制到空白区域,剩下的直接清理。

优点:实现简单,运行高效。

缺点:内存利用率低


3. 标记-整理算法

和标记-清除算法一样,标记后不直接清理,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。避免了内存碎片。

System.gc() 与 finalize() 的区别

System.gc() 是 JVM 提供的一个「建议触发垃圾回收 」的方法。调用它只是向 JVM 发出一个回收请求,JVM 不一定会立刻执行 GC,具体什么时候回收由 JVM 自己决定。它只是「建议」,不是「命令」。

finalize() 是 Object 类里的一个方法,在对象被垃圾回收器判定为垃圾、真正回收之前,JVM 会调用一次该对象的 finalize() 方法,给对象一个「临终自救」的机会。比如对象可以在 finalize() 里重新把自己赋值给一个引用,从而避免被回收。

核心区别:

对比项 System.gc() finalize()
本质 一个静态方法,用来请求 JVM 执行垃圾回收 Object 类的一个实例方法,对象被回收前由 JVM 调用
谁调用 程序员手动调用 JVM 自动调用(程序员无法主动触发)
作用 建议触发 GC,回收堆中的垃圾对象 对象被回收前的最后一次清理/自救机会
执行时机 调用后 JVM 自行决定何时回收 对象被判定为垃圾、真正回收之前
执行次数 可多次调用 每个对象最多执行一次

**面试时这样答:**System.gc() 是程序员主动请求 JVM 执行垃圾回收的静态方法,但 JVM 不保证立即执行;finalize() 是对象被回收前 JVM 自动调用的方法,用于资源清理或自救。两者一个是「触发回收的入口」,一个是「回收前的钩子」,没有直接调用关系。实际开发中不建议依赖 finalize() 做资源清理,因为执行时机不确定,推荐用 try-with-resources 或显式 close() 释放资源。

强引用、软引用、弱引用、虚引用

强引用(Strong Reference)不回收 ,最常见的引用类型,只要强引用存在,垃圾回收器就永远不会回收这个对象。比如 Object obj = new Object(); 这里的 obj 就是强引用。

软引用(SoftReference)当内存不足时回收,垃圾回收器会回收软引用指向的对象。适合做缓存,比如图片缓存,内存不够时自动清理。

弱引用(WeakReference)发现即回收,只要发生垃圾回收,不管内存够不够,都会回收弱引用指向的对象。常用于 WeakHashMap、ThreadLocal 等场景,防止内存泄漏。

虚引用(PhantomReference) :最弱的引用,无法通过虚引用获取对象实例,主要用于跟踪对象被垃圾回收的状态,必须和 ReferenceQueue 一起使用。

什么是内存泄漏和内存溢出

内存泄漏(Memory Leak)程序不再使用的内存没有被回收,长期占用导致内存空间越来越小。就像家里堆了一堆用不上的旧东西,越积越多,可用空间越来越小。

内存溢出(Out Of Memory,OOM)程序申请的内存大于可用空间,系统没法分配足够大的空间导致报错。

**两者的关系:**内存泄漏是「病根」,内存溢出是「结果」。内存泄漏不断累积,最终往往会导致内存溢出。但内存溢出不一定都是内存泄漏引起的,也可能是某个瞬间申请了超大对象(比如一次性加载超大文件),直接把内存撑爆。

常见的内存泄漏场景:

  • 静态集合类持有对象 :比如 static List<Object> list 不断往里 add,却从不 remove,对象一直被强引用,无法回收。
  • 连接未关闭:数据库连接、IO 流、网络连接用完没 close,底层资源一直被占用。
  • 监听器/回调未移除:注册了监听器或回调,对象销毁时没有注销,导致被外部长期引用。
  • ThreadLocal 使用不当:线程池中的线程复用时,ThreadLocal 里的值没及时 remove,会一直残留在线程上。
  • 缓存没有淘汰策略:往缓存里塞数据却从不清理,越积越多。

面试时这样答: 内存泄漏是对象不再使用但仍有引用,导致 GC 无法回收,内存被白白占用;内存溢出是 JVM 无法再分配内存时抛出的 OutOfMemoryError。内存泄漏是内存溢出的常见诱因之一,排查时通常用 jmap 导出堆快照,再用 MAT 分析哪些对象占用了大量内存且没有被释放。

垃圾回收器有哪些

**垃圾回收器(GC)**是 JVM 中真正负责回收垃圾的组件。JDK 8 及之前主流的垃圾回收器主要有 7 种,按「分代」划分:新生代有 Serial、ParNew、Parallel Scavenge,老年代有 Serial Old、Parallel Old、CMS,还有一款能回收整个堆的 G1。

1. Serial(串行回收器):单线程回收,回收时会暂停所有用户线程(STW)。简单高效,适合单核 CPU 或内存很小的场景,是客户端模式下的默认新生代回收器。

2. ParNew(并行新生代回收器):Serial 的多线程版本,回收时多个线程并行工作,但同样会 STW。它是 CMS 的搭档,配合 CMS 使用时新生代默认用它。

3. Parallel Scavenge(并行回收器) :新生代回收器,采用标记-复制算法,多线程并行回收。它的特点是关注吞吐量,可以通过参数控制吞吐量,适合后台计算等对停顿不敏感的场景。JDK 8 默认的新生代回收器。

4. Serial Old(串行老年代回收器):Serial 的老年代版本,单线程,采用标记-整理算法,主要给 Serial 做老年代回收兜底。

5. Parallel Old(并行老年代回收器):Parallel Scavenge 的老年代版本,多线程并行,采用标记-整理算法。JDK 8 默认的老年代回收器,和 Parallel Scavenge 搭配使用。

6. CMS(并发标记清除回收器) :老年代回收器,采用标记-清除算法,目标是缩短 STW 停顿时间。它让垃圾回收线程和用户线程尽量并发执行,适合对响应时间要求高的场景(如 Web 服务)。缺点是会产生内存碎片,且并发阶段会占用 CPU 资源。

7. G1(Garbage First,垃圾优先回收器) :JDK 9 及之后的默认回收器,把堆划分为多个大小相等的 Region,不再严格分代,而是通过维护一个优先级列表,优先回收垃圾最多的 Region。它既能控制停顿时间,又能兼顾吞吐量,是面向服务端应用的推荐选择。

面试时这样答:垃圾回收器按分代划分,新生代有 Serial、ParNew、Parallel Scavenge,老年代有 Serial Old、Parallel Old、CMS,还有一款不分代、面向整个堆的 G1。JDK 8 默认是 Parallel Scavenge + Parallel Old,JDK 9 之后默认是 G1。CMS 追求低停顿但会产生碎片,G1 在控制停顿的同时兼顾吞吐量,是目前的主流选择。

怎么排查线上 OOM 问题

除了程序计数器,其他四块JVM内存区都会内存溢出。

  1. 一次性申请太多对象,可以做分页

  2. 内存资源未释放

  3. 本身内存资源不够。

根据端口查看进程ID【netstat -ano | findstr : 程序端口号

使用【jmap -heap 程序进程ID】 查看堆信息。

一般步骤

1. 系统已经报了OOM挂了

程序运行前设置参数,也就是在线上运行jar包程序时设置。系统报了OOM时会自动导出 dump 文件,这个文件记录了堆的参数。

如 java -jar -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=导出dump文件的路径

java 复制代码
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=导出dump文件的路径

然后通过 JVisualVM的工具载入 dump 文件进行定位OOM。

2. 系统运行中还未OOM

但值得一提的是,程序运行中导出dump文件,会让程序中所有线程会中断 ,并且会进行一次 Full GC

java 复制代码
jmap -dump:format=b,file=导出dunmp文件路径 进程ID

-dump:format=b :指定导出格式为二进制(binary) 格式。这是必须的,因为后续分析工具(如 Eclipse MAT、VisualVM)只认这种格式的 .hprof 文件。

如果不想让程序 Full GC, 也可以使用命令查看最占内存的对象,但是无法定位到具体哪行代码。

java 复制代码
jmap -histo:live 程序进程ID
  1. 上面导出dump文件后,利用 jvisualVM 工具分析业务占内存最多的对象,找到 GCRoot , 查看线程栈。

三、并发编程高频题

并行与并发

并行(Parallelism) :多个任务在同一时刻 真正同时执行,需要多核 CPU 支持。就像两条流水线同时开工,互不干扰,是「同时做多件事」。

并发(Concurrency) :多个任务在同一时间段 交替执行,通过 CPU 时间片快速切换,看起来像同时进行,但同一时刻只有一个任务在执行。就像一个人同时烧水、洗菜、切菜,其实是来回切换着做,是「交替做多件事」。

核心区别:并行是「物理上的同时」,并发是「逻辑上的同时」。单核 CPU 只能并发,多核 CPU 才能并行。

面试时这样答:并行是多个任务在同一时刻真正同时执行,依赖多核 CPU;并发是多个任务在同一时间段内交替执行,通过时间片切换实现。并行关注「同时做」,并发关注「交替做」,单核只能并发,多核才能并行。

说说 synchronized 和 ReentrantLock 的区别

sychronized是java的关键字。自动加锁和释放锁。不可中断锁,需要代码块执行完毕或者代码抛出异常才会中断。

ReentrantLock是JDK提供的类。需要手动调用lock添加锁、unlock释放锁。可中断锁,调用 lock.lockInterruptibly() 尝试获取锁,如果锁空闲直接获取锁;若没获取到锁,进入等待状态,如果此时调用interrupt()方法中断锁,会抛出异常从而退出等待锁。

下面通过一个实例演示 ReentrantLock 的可中断特性:当线程在等待锁的过程中被中断时,会抛出 InterruptedException,从而可以及时响应中断,避免长时间阻塞。

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockInterruptDemo {

    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        // 线程1先获取锁并持有
        Thread t1 = new Thread(() -> {
            lock.lock();
            try {
                System.out.println("线程1 获取到锁,开始执行");
                Thread.sleep(5000); // 持有锁5秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
                System.out.println("线程1 释放锁");
            }
        });

        // 线程2尝试获取锁,可被中断
        Thread t2 = new Thread(() -> {
            try {
                System.out.println("线程2 尝试获取锁");
                // lockInterruptibly() 本身不会主动中断线程,
                // 它只是让线程在"等待锁"的过程中能够响应外部的中断信号。
                // 真正发起中断的是下面主线程调用的 t2.interrupt()。
                lock.lockInterruptibly(); // 可中断获取锁:等待锁时若收到中断信号,立即抛出 InterruptedException
                System.out.println("线程2 获取到锁");
            } catch (InterruptedException e) {
                // 只有外部调用了 t2.interrupt(),这里才会被触发
                System.out.println("线程2 在等待锁时被中断,不再等待");
            } finally {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        });

        t1.start();
        Thread.sleep(100); // 确保线程1先拿到锁
        t2.start();
        Thread.sleep(100); // 确保线程2进入等待
        t2.interrupt();    // 真正的中断动作:向线程2发送中断信号,触发上面的 InterruptedException
    }
}

运行结果说明:线程2在调用 lockInterruptibly() 等待锁的过程中,被主线程调用 t2.interrupt() 中断,立即抛出 InterruptedException 并退出等待,而不是一直阻塞到线程1释放锁。这正是 ReentrantLock 相比 synchronized 的「可中断」优势。

公平锁和非公平锁

公平锁就是线程获取锁时,依次按照线程锁请求的先后顺序进行执行,就像排队买东西先来先买。

非公平锁就是线程获取锁时,不按照线程锁请求顺序执行,执行效率更高,但会导致某些线程长时间获取不到锁,就像排队买东西有人插队。为什么会执行效率更高呢?因为不用维护线程顺序,新来的线程可能获取到刚释放的锁,减少线程切换时间。

ConcurrentHashMap 为什么线程安全

JDK1.7 采用 Segment分段锁,JDK 1.8 的 ConcurrentHashMap 采用「CAS + synchronized」:插入时先通过 CAS 尝试,失败再对桶头节点加 synchronized 锁。它只锁住单个桶,而不是锁整个表,所以并发度很高,读操作基本无锁。

11. volatile 关键字的作用

volatile 有两个作用:

  • 保证可见性:一个线程修改了变量,其他线程能立刻看到最新值。
  • 禁止指令重排:保证代码执行顺序符合预期。

但它不保证原子性,比如 i++ 这种操作,多个线程同时执行还是会出问题。

创建线程的方式

  • 继承 Thread 类:重写 run() 方法,然后 new 出来调用 start() 启动。简单直接,但 Java 是单继承,继承了 Thread 就不能再继承别的类。
  • 实现 Runnable 接口 :重写 run() 方法,把 Runnable 对象传给 Thread 构造器。比继承更灵活,推荐使用。(无返回值,不能抛出异常
  • 实现 Callable 接口 :类似 Runnable,但 run() 没有返回值,而 call() 可以返回结果,还能抛出异常,配合 FutureTask 获取返回值。(有返回值, 能抛出异常)
  • 使用线程池(ExecutorService):通过 Executors 或 ThreadPoolExecutor 创建线程池,提交任务即可。避免频繁创建销毁线程,实际开发中最常用。
java 复制代码
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;

public class CreateThreadDemo {

    // 方式一:继承 Thread 类
    static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("方式一:继承 Thread 类");
        }
    }

    // 方式二:实现 Runnable 接口
    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("方式二:实现 Runnable 接口");
        }
    }

    // 方式三:实现 Callable 接口(有返回值)
    static class MyCallable implements Callable<String> {
        @Override
        public String call() {
            return "方式三:实现 Callable 接口,返回结果";
        }
    }

    public static void main(String[] args) throws Exception {
        // 方式一
        new MyThread().start();

        // 方式二
        new Thread(new MyRunnable()).start();

        // 方式三
        FutureTask<String> task = new FutureTask<>(new MyCallable());
        new Thread(task).start();
        System.out.println(task.get()); // 获取 call() 的返回值

        // 方式四:线程池
        ExecutorService pool = Executors.newFixedThreadPool(2);
        pool.execute(() -> System.out.println("方式四:线程池"));
        pool.shutdown();
    }
}

面试时建议这样回答:核心是「继承 Thread、实现 Runnable、实现 Callable、线程池」四种,其中 Runnable 和 Callable 本质都是把任务交给 Thread 执行,线程池是生产环境最常用的方式。

12. 线程池的核心参数有哪些

核心参数有七个:

  • corePoolSize:核心线程数,常驻线程。
  • maximumPoolSize:最大线程数。
  • keepAliveTime:非核心线程空闲存活时间。
  • unit:时间单位。
  • workQueue:任务队列。
  • threadFactory:线程工厂。
  • handler:拒绝策略。

执行流程:先跑满核心线程,再往队列里放任务,队列满了再创建非核心线程,线程数到最大值后触发拒绝策略。

13. 线程池的拒绝策略有哪些

  • AbortPolicy:直接抛异常(默认)。
  • CallerRunsPolicy:让提交任务的线程自己执行。
  • DiscardPolicy:直接丢弃任务。
  • DiscardOldestPolicy:丢弃队列里最老的任务,再尝试提交。

为什么不建议使用Excutores创建线程池

因为Excutores创建的线程池任务队列开辟了 Integer.MAX_VALUE 的空间大小,换句话说任务队列可以放21亿个任务,在高并发场景下,任务队列过多容易造成OOM。

i++ 为什么线程不安全

因为i++不是原子操作,需要先读取数值再运算,高并发场景下,可能会读取到旧值,相互覆盖。可以使用 synchronized 解决。

什么是死锁

死锁就是两个或多个线程互相持有对方想要的锁,谁也不肯先放手,结果大家都卡住不动了。就像两个人过独木桥,甲拿着钥匙要过桥,乙也拿着钥匙要过桥,甲说「你先让我过去」,乙说「你先让我过去」,结果谁都不让,两个人都过不去。

死锁产生的四个必要条件(缺一不可):

  • 互斥:资源同一时刻只能被一个线程占用,别人不能用。
  • 持有并等待:线程已经拿着一个锁,还在等另一个锁。
  • 不可剥夺:别人不能强行抢走线程手里的锁,只能等它自己释放。
  • 循环等待:线程 A 等线程 B 的锁,线程 B 等线程 A 的锁,形成环路。

代码示例:

java 复制代码
public class DeadLockDemo {

    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        // 线程1:先拿 lockA,再拿 lockB
        new Thread(() -> {
            synchronized (lockA) {
                System.out.println("线程1 拿到 lockA");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockB) {
                    System.out.println("线程1 拿到 lockB");
                }
            }
        }).start();

        // 线程2:先拿 lockB,再拿 lockA
        new Thread(() -> {
            synchronized (lockB) {
                System.out.println("线程2 拿到 lockB");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockA) {
                    System.out.println("线程2 拿到 lockA");
                }
            }
        }).start();
    }
}

如何避免死锁:

  • 按固定顺序加锁:所有线程都先拿 lockA 再拿 lockB,就不会形成循环等待。
  • 使用 tryLock 加超时:拿不到锁就放弃,不一直死等。
  • 尽量缩小锁的范围:减少持锁时间,降低死锁概率。

**面试时这样答:**死锁是多个线程互相持有对方需要的锁,形成循环等待,导致所有线程都无法继续执行。产生死锁需要互斥、持有并等待、不可剥夺、循环等待四个条件同时满足。避免死锁的常用手段是让所有线程按固定顺序加锁,或者用 tryLock 加超时机制,拿不到锁就放弃而不是死等。

sleep() 与 wait() 区别

sleep()Thread 类的静态方法wait()Object 类的实例方法。这是两者最根本的区别。

核心区别:

对比项 sleep() wait()
所属类 Thread 类的静态方法 Object 类的实例方法
锁的释放 不释放锁,线程抱着锁睡觉 释放锁,进入等待池,让出锁给其他线程
调用前提 任何地方都能调用,不需要持有锁 必须在 synchronized 同步块/方法中调用,否则抛 IllegalMonitorStateException
唤醒方式 时间到自动醒来,不需要别人唤醒 必须由其他线程调用 notify() / notifyAll() 唤醒,或设置超时时间
使用场景 模拟耗时操作、控制执行节奏、定时任务 线程间通信、生产者-消费者模式

代码示例:

java 复制代码
public class SleepWaitDemo {

    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 场景一:sleep() 不释放锁
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("线程1 拿到锁,开始 sleep");
                try {
                    Thread.sleep(2000); // 抱着锁睡2秒,不释放锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程1 sleep 结束,释放锁");
            }
        });

        // 场景二:wait() 释放锁
        Thread t2 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("线程2 拿到锁,开始 wait");
                try {
                    lock.wait(2000); // 释放锁,等待2秒后自动唤醒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程2 wait 结束,重新拿到锁");
            }
        });

        t1.start();
        Thread.sleep(100); // 确保线程1先拿到锁
        t2.start();
    }
}

面试回答要点:

  1. 所属不同:sleep() 是 Thread 的静态方法,wait() 是 Object 的实例方法。
  2. 锁的释放不同:sleep() 不释放锁,wait() 会释放锁并进入等待池。
  3. 调用条件不同:wait() 必须在 synchronized 块中调用,sleep() 没有这个限制。
  4. 唤醒机制不同:sleep() 到时间自动醒来,wait() 需要 notify()/notifyAll() 唤醒或设置超时。
  5. 使用场景不同:sleep() 用于控制节奏,wait() 用于线程间通信。

notify 和 notifyAll 区别

notify()notifyAll() 都是 Object 类的实例方法,用于唤醒在 wait() 上等待的线程,但唤醒的范围不同。

核心区别:

对比项 notify() notifyAll()
唤醒数量 随机唤醒一个等待线程 唤醒所有等待线程
唤醒方式 由 JVM 从等待池中随机挑选一个线程唤醒 等待池中的所有线程全部进入就绪状态,重新竞争锁
锁的获取 被唤醒的线程需要重新竞争锁,拿到锁才能继续执行 所有线程一起竞争锁,只有一个能拿到锁继续执行,其余继续阻塞
使用场景 只有一个线程在等待,或唤醒任意一个即可满足条件 多个线程在等待,且不确定哪个能满足条件,或需要唤醒全部
风险 可能唤醒一个不满足条件 的线程,导致它继续 wait,甚至发生信号丢失 相对安全,所有线程都会检查条件,但性能开销略大

代码示例:

java 复制代码
public class NotifyDemo {

    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 创建 3 个等待线程
        for (int i = 1; i <= 3; i++) {
            final int id = i;
            new Thread(() -> {
                synchronized (lock) {
                    try {
                        System.out.println("线程" + id + " 进入等待");
                        lock.wait(); // 释放锁,进入等待池
                        System.out.println("线程" + id + " 被唤醒,继续执行");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }

        Thread.sleep(100); // 确保 3 个线程都进入等待

        synchronized (lock) {
            // 只唤醒一个线程
            // lock.notify();
            // 唤醒所有线程
            lock.notifyAll();
            System.out.println("主线程调用 notifyAll()");
        }
    }
}

运行结果说明:

  • 使用 notify():3 个等待线程中只有1 个被唤醒并继续执行,另外 2 个仍在等待池中阻塞。
  • 使用 notifyAll():3 个等待线程全部被唤醒,进入就绪状态竞争锁,但同一时刻只有 1 个能拿到锁执行,其余线程拿到锁后依次执行。

面试回答要点:

  1. notify() 随机唤醒一个 等待线程,notifyAll() 唤醒所有等待线程。
  2. 两者都必须在 synchronized 块/方法 中调用,否则抛 IllegalMonitorStateException
  3. 被唤醒的线程都要重新竞争锁,拿到锁才能继续执行。
  4. 实际开发中优先用 notifyAll(),避免 notify() 唤醒到不满足条件的线程导致信号丢失。

四、Spring 框架高频题

14. 说说 Spring 的 IOC 和 AOP

IOC(控制反转):对象的创建和管理交给 Spring 容器,而不是自己 new。你需要什么对象,直接声明依赖,容器帮你注入。好处是解耦,方便替换实现。

AOP(面向切面编程) :把日志、事务、权限等通用逻辑抽出来,在不改业务代码的情况下织入。就像给方法「加一层拦截」,典型应用是声明式事务 @Transactional

15. Spring Bean 的生命周期

大致流程:实例化 → 属性填充 → 初始化(InitializingBean / init-method)→ 使用 → 销毁(DisposableBean / destroy-method)。Spring 容器关闭时,会调用销毁方法释放资源。

16. Spring 事务失效的场景有哪些

常见失效场景:

  • 方法不是 public。
  • 类没有被 Spring 管理(没加 @Service 等注解)。
  • 自调用:同类里一个方法调用另一个 @Transactional 方法,事务不生效。
  • 异常被 catch 吞掉,没有抛出 RuntimeException。
  • 数据库引擎不支持事务(如 MyISAM)。

为什么SpringBoot 的 jar 包可以直接运行

核心原因:Spring Boot 的 Maven 插件(spring-boot-maven-plugin)在打包时,会把项目编译后的 class 文件、依赖的所有第三方 jar 包、以及内嵌的 Tomcat 服务器,全部重新组织并打进一个「可执行 jar」里。

这个可执行 jar 的结构和普通 jar 不一样,它把依赖的 jar 包放在 BOOT-INF/lib 目录下,业务代码放在 BOOT-INF/classes 目录下,同时提供了一个特殊的启动类 org.springframework.boot.loader.JarLauncher 作为 Main-Class。

当我们执行 java -jar xxx.jar 时,JVM 会先找到清单文件(MANIFEST.MF)里指定的 Main-Class,也就是 JarLauncher 。JarLauncher 会创建一个自定义的类加载器 ,专门负责从 BOOT-INF/lib加载依赖的 jar 包 ,从 BOOT-INF/classes 里加载业务代码,然后再调用我们自己写的、带有 main 方法的启动类(Spring Boot 项目里通常是 XxxApplication),从而启动整个 Spring 容器。

为什么普通 jar 不能这样直接运行? 因为普通 jar 打包时,依赖的第三方 jar 并不会被塞进同一个文件里,而是分散在外部。运行时需要手动用 -classpath 指定依赖路径,或者依赖外部安装的 Tomcat 等容器。而 Spring Boot 的可执行 jar 把「依赖」和「内嵌服务器」都打包在一起,做到了真正的「开箱即用」。

**面试时这样答:**Spring Boot 的 jar 能直接运行,是因为 spring-boot-maven-plugin 在打包时把业务代码、所有依赖 jar 和内嵌 Tomcat 都打进了同一个可执行 jar,并通过 JarLauncher 自定义类加载器来加载这些内部资源,最终调用项目的 main 方法启动 Spring 容器。这样部署时只需一个 jar 文件,无需额外安装 Tomcat 或配置 classpath。

SpringBoot 有哪些核心注解

SpringBoot 的核心注解可以分成「启动类注解」「配置类注解」「条件装配注解」「自动配置相关注解」四类来记。

1. 启动类注解:@SpringBootApplication

这是 Spring Boot 项目启动类上的核心注解,它是一个「组合注解」,由下面三个注解组成:

  • @SpringBootConfiguration:本质是 @Configuration,表示当前类是一个配置类,会被 Spring 容器扫描并加载。
  • @EnableAutoConfiguration:开启自动配置,Spring Boot 会根据 classpath 下的依赖,自动帮你装配好默认的 Bean(比如引入了 spring-boot-starter-web,就自动配置内嵌 Tomcat 和 SpringMVC)。
  • @ComponentScan:默认扫描启动类所在包及其子包下的 @Component、@Service、@Repository、@Controller 等组件。

2. 配置类注解

  • @Configuration:声明一个类为配置类,相当于 Spring XML 配置文件,里面可以用 @Bean 声明 Bean。
  • @Bean:标注在方法上,把方法的返回值注册为一个 Bean,交给 Spring 容器管理。
  • @ConfigurationProperties:把 application.yml 里的配置项批量绑定到一个 Java 对象的属性上,比如 @ConfigurationProperties(prefix = "server")。
  • @Value:读取单个配置项,比如 @Value("${server.port}")。
  • @PropertySource:加载指定的 properties 配置文件。

3. 条件装配注解(自动配置的核心)

  • @ConditionalOnClass:classpath 下存在某个类时才生效,比如 @ConditionalOnClass(name = "com.mysql.cj.jdbc.Driver")。
  • @ConditionalOnMissingBean:容器中不存在某个 Bean 时才生效,用于允许用户覆盖默认配置。
  • @ConditionalOnProperty:配置项满足指定值时才生效,比如 @ConditionalOnProperty(name = "spring.redis.enabled", havingValue = "true")。
  • @ConditionalOnWebApplication:当前是 Web 应用时才生效。

4. 其他常用注解

  • @RestController:@Controller + @ResponseBody 的组合,返回 JSON 数据。
  • @RequestMapping / @GetMapping / @PostMapping:映射 HTTP 请求路径。
  • @Autowired / @Resource:依赖注入。
  • @Service / @Repository / @Component:声明 Bean 的组件注解。
  • @Transactional:声明式事务。

**面试时这样答:**Spring Boot 最核心的注解是启动类上的 @SpringBootApplication,它由 @SpringBootConfiguration、@EnableAutoConfiguration 和 @ComponentScan 组合而成。其中 @EnableAutoConfiguration 是自动配置的入口,它通过 @Import 引入 AutoConfigurationImportSelector,再结合 @ConditionalOnClass、@ConditionalOnMissingBean 等条件注解,按需装配 Bean。配置类上常用 @Configuration + @Bean 声明 Bean,用 @ConfigurationProperties 批量绑定配置项。

SpringBoot自动配置原理

SpringBoot自动装配主要依赖以下几个关键机制:

  • @SpringBootApplication注解:这是一个组合注解,包含了@EnableAutoConfiguration、@ComponentScan 和 @SpringBootConfiguration。
  • spring.factories文件:在META-INF/spring.factories文件中,配置了所有自动配置类的全限定名。
  • 条件注解:如@ConditionalOnClass、@ConditionalOnMissingBean等,控制配置类是否生效。

ComponentScan注解的作用是扫描当前包和子包,将Controller、service、Repository、Component 注解注入到容器。

SpringBootConfiguration 注解里包含了Configuration注解,作用是申明为配置类,免去了xml配置。

EnableAutoConfiguration就是自动配置的核心注解了。这个注解里包含了 Import 注解,这个Import 注解引入了 AutoConfigurationImportSelect的类,其主要作用是读取classpath下的META-INF/spring.factorys 配置文件,根据条件注解控制配置类是否生效,同时读取application.yml的配置文件。

假设你的项目中引入了Redis依赖,自动装配过程如下:

第一步:SpringBoot扫描spring.factories文件,发现RedisAutoConfiguration类。

第二步:检查条件注解@ConditionalOnClass({RedisOperations.class}),发现类路径下确实有RedisOperations类(因为引入了依赖),条件满足。

第三步:检查@ConditionalOnMissingBean(name = "redisTemplate"),发现容器中还没有redisTemplate这个Bean,于是自动创建并注册。

第四步:同时读取application.yml中的spring.redis配置(如host、port),注入到RedisProperties中。

SpringBoot启动原理

  1. 准备阶段 new SpringApplication , 从srping.factories 读取 ApplicationListener(事件监听 器) ApplicationContextInitializer(上下文初始化器) 。

  2. 运行run方法

  3. 读取环境变量、配置信息

  4. 创建 springApplication 上下文:ServletWebServerApplicationContext

  5. 预初始化上下文,将启动类作为配置类进行读取,将配置注册为 BeanDefiniton

  6. 调用 refresh 加载ioc容器

通过 import 注解 加载所有自动配置类

创建(内置)servlet容器

SpringBoot内置Tomcat启动原理

  1. 添加 srping-boot-start-web依赖后,会自动在SpringBoot中添加 ServletWebServerFactoryAutoConfiguration 自动配置类

  2. 该自动配置类通过Import注解导入可用的一个Web容器工厂(默认tomcat)

  3. 在内置的tomcat中配置了一个TomcatServletWebServerFactory的Bean(Web容器工厂)

  4. 在SpringBoot启动时,加载IOC容器,创建内置tomcat并启动

SpringBoot外部Tomcat启动原理

首先需要在web依赖中排除tomcat并打包为war包。然后重新定义一个启动类继承 SpringBootServletInitializer,tomcat扫描到这个类会调用onStartup 方法,这个方法会加载@SpringBootApplication启动类。

五、MySQL 高频题

17. 说说索引失效的常见场景

  • 对索引列使用函数或计算 ,如 WHERE YEAR(create_time) = 2024
  • 隐式类型转换,如索引列是字符串,查询时传了数字。
  • 左模糊查询 ,如 LIKE '%abc'
  • 联合索引不满足最左前缀原则。如有联合索引ABC,条件直接使用
  • 使用OR 连接非索引列

18. 什么是事务的隔离级别

MySQL 有四种隔离级别:

  • 读未提交:能读到别人未提交的数据,有脏读问题。
  • 读已提交:只能读到已提交的数据,解决了脏读,但有不可重复读。
  • 可重复读:同一个事务内多次读结果一致,解决了不可重复读(MySQL 默认级别)。
  • 串行化:事务串行执行,性能最差。

19. 说说 MVCC 是什么

MVCC(多版本并发控制)是 InnoDB 实现「可重复读」的核心机制。它通过隐藏字段(事务 ID、回滚指针)和 undo log,让读操作读到某个时间点的快照,读不加锁,写加锁,从而提升并发性能。

事务四大特性ACID

A 原子性 :最小的操作单元,要么全部成功、要么全部失败

C一致性:事务完成,数据保存一致。转账与收款人账户总额相加不变。

I 隔离性:并发事务互不影响。

D持久性 :事务一旦提交或回滚,数据的改变是永久的。成功修改一个数据,数据不能撤回。

MySQL 4种隔离级别,InnoDB默认是什么,会产生什么问题

读未提交(RU)Read uncommitted: 脏读、不可重复读、幻读

读已提交(RC)Read committed:不可重复读、幻读

可重复读(RR)Repeatable Read 【MySQL默认】:幻读

可串行化(SR)Serializable:


脏读:一个事务读取到另一个未提交的事务。事务A修改了一条数据但提交,另一个事务B读取了修改后的数据。

不可重复读:一个事务A先后读取同一条数据,事务B在读取期间对数据进行修改,导致2次读取数据不一致。

幻读:一个事务没有查询到对应数据,插入数据时发现这条数据存在,就像出现了幻读。

什么是行锁、表锁、意向锁

一句话先记住: 行锁锁的是某一行 ,表锁锁的是整张表 ,意向锁是 InnoDB 为了协调行锁和表锁而加的一种「预告」锁,它本身不锁数据,只是告诉别人「这张表里已经有行被锁了」。

1. 行锁(Record Lock)

行锁是 InnoDB 默认的锁粒度,只锁住被操作的那一行记录,其他行不受影响,并发度最高。

**案例:**两个人同时买不同商品:

  • 事务 A:UPDATE product SET stock = stock - 1 WHERE id = 1; ------ 只锁 id=1 这一行。
  • 事务 B:UPDATE product SET stock = stock - 1 WHERE id = 2; ------ 锁 id=2 这一行,完全不受影响,可以同时执行。

这就是行锁的好处:并发度高,不同行互不干扰。

2. 表锁(Table Lock)

表锁会锁住整张表,一旦加了表锁,其他事务对这张表的读写都会被阻塞,并发度最低。

案例: 某张表执行 LOCK TABLES user WRITE; 后,其他事务想读这张表都会被阻塞,直到释放锁。

MyISAM 引擎只支持表锁,所以并发写性能差;InnoDB 支持行锁,所以并发能力强。

3. 意向锁(Intention Lock)

意向锁是 InnoDB 特有的表级锁 ,它本身不锁任何数据,只是打个「标记」:

  • 意向共享锁(IS) :表示「这张表里有行被加了共享锁」。
  • 意向排他锁(IX) :表示「这张表里有行被加了排他锁」。

为什么要意向锁? 因为行锁和表锁要能快速判断是否冲突 。如果没有意向锁,事务想给整张表加表锁时,就得一行一行扫描看有没有行锁,效率极低。有了意向锁,直接看表上有没有 IX/IS 标记就能判断。

案例:

  • 事务 A 执行 UPDATE user SET name = '张三' WHERE id = 1; ------ 先给表加意向排他锁(IX) ,再给 id=1 这行加行锁
  • 事务 B 想执行 LOCK TABLES user WRITE; ------ 发现表上已有 IX 锁,立刻知道有行被锁了,直接阻塞等待,不用扫描每一行。

**面试时这样答:**行锁锁单行、并发度高,是 InnoDB 默认锁粒度;表锁锁整张表、并发度低,MyISAM 只有表锁;意向锁是 InnoDB 的表级「预告锁」,分为 IS 和 IX,用来快速判断行锁与表锁是否冲突,避免逐行扫描,提升加锁效率。

什么是间隙锁、临建锁、什么时候产生

一句话先记住: 间隙锁锁的是索引记录之间的空隙 ,防止其他事务在空隙中插入数据;临键锁(Next-Key Lock)是行锁 + 间隙锁的组合,既锁住记录本身,又锁住记录前面的空隙,是 InnoDB 在可重复读(RR)隔离级别下默认使用的锁。

1. 间隙锁(Gap Lock)

间隙锁锁的是索引记录之间的空隙 ,它不锁任何具体记录 ,只锁住「这个区间内不能插入新数据」。间隙锁的主要目的是防止幻读

**案例:**假设 user 表 id 有 1、5、10 三条记录:

  • 事务 A:SELECT * FROM user WHERE id > 5 AND id < 10 FOR UPDATE; ------ 锁住 (5, 10) 这个空隙。
  • 事务 B:INSERT INTO user (id) VALUES (7); ------ 想往空隙里插入 id=7,被阻塞,直到事务 A 提交或回滚。

注意:间隙锁只锁空隙,不锁记录本身。上例中 id=5 和 id=10 这两条记录本身没有被锁,其他事务仍然可以修改它们。

2. 临键锁(Next-Key Lock)

临键锁是行锁(Record Lock)+ 间隙锁(Gap Lock)的组合 ,它既锁住某条记录本身,又锁住该记录前面的空隙 。可以理解为:临键锁 = 行锁 + 间隙锁

**案例:**假设 user 表 id 有 1、5、10 三条记录:

  • 事务 A:SELECT * FROM user WHERE id = 5 FOR UPDATE; ------ 在 RR 隔离级别下,实际加的是临键锁,锁住 (1, 5] 这个区间。
  • 事务 B:INSERT INTO user (id) VALUES (3); ------ 想往 (1, 5) 空隙里插入 id=3,被阻塞
  • 事务 C:UPDATE user SET name = '张三' WHERE id = 5; ------ 想修改 id=5 这条记录本身,也被阻塞

可以看到,临键锁既防住了「往空隙插入数据」(防幻读),又防住了「修改已锁记录」(保证当前读的一致性)。

3. 什么时候产生

  • 间隙锁 :在可重复读(RR)隔离级别 下,当查询条件使用了索引 ,但命中的记录不存在 ,或者范围查询时,InnoDB 会对扫描到的空隙加间隙锁。
  • 临键锁 :在可重复读(RR)隔离级别 下,当查询条件使用了索引命中了具体记录时,InnoDB 默认加临键锁(行锁 + 间隙锁)。
  • 读已提交(RC)隔离级别 :只加行锁,不加间隙锁,所以 RC 级别下不会产生间隙锁和临键锁,这也是 RC 级别并发度更高的原因之一。

**面试时这样答:**间隙锁锁的是索引记录之间的空隙,防止其他事务插入数据,用于解决幻读;临键锁是行锁和间隙锁的组合,既锁记录又锁前面的空隙,是 InnoDB 在可重复读隔离级别下默认的锁。间隙锁和临键锁都在 RR 隔离级别下、使用索引查询时产生;RC 级别只加行锁,不加间隙锁。

count(*)、count(1)、count(字段)区别

统计数量上差异:是否统计 null 行数据

count(*):统计所有行,包括字段值为 null 的行(整行为null也算),行存在就算一条数据。

count(1):统计所有行,数值1是常量,不可能为null, 统计表中所有行,和count(*)效果一致。

count(字段):统计该字段不为null 的行,如果该行字段为null, 则该行数据不计入总数。

例如:一张表有10条数据,有2条age字段为null,那么count(*) 和 count(1) 为10,而count(age) 为 8,因为要排除age字段为null的数据。

注意:count(*) 和count(1) 虽然功能没区别,但count(1)本质是一个表达式,数据库发现表达式1永远不会为null, 所以和count(*)等价。


性能上差异

count(*) 和 count(1) 性能几乎无区别,优化器在执行时,count(1)内部重写 count(*)的统计方式,走同样的执行计划。

count(主键字段):由于主键索引是聚簇索引,数据量大时,走主键索引扫描可能比二级索引慢,因为主键索引叶子节点包含了整条数据,扫描代价更高。

count(普通字段)

  1. 如果这个字段有索引,有限扫描这个索引(索引覆盖),速度快。

  2. 如果这个字段没有索引,只能全表扫描,性能最差。

InnoDB 为什么要使用 B+Tree 索引结构

  1. 相对于二叉树,二叉树索引再极端情况下形成链表,而B+树层级少,效率高

  2. 对于Hash索引,Hash索引只能支持等值查询,不支持范围查询

  3. 对于B树索引,无论是叶子节点还是非叶子节点,都会保存数据,导致一页中的存储键值减少,保存大量数据,层级就会变多,导致性能降低

什么是聚簇索引、非聚簇索引

聚簇索引 :数据和索引在一起。聚簇索引的叶子节点下面挂的是这行数据。一张表中必须有聚集索引,如果没有设置,默认主键为聚集索引;如果没有主键,使用第一个唯一索引为聚集索引;如果没有主键或合适的唯一索引,会自动生成 rowid 作为隐藏的聚集索引。

非聚簇索引 :数据和索引是分开存储的,也叫二级索引。非聚集索引的叶子节点下面挂的是对应的id。

回表是什么,怎么避免回表

回表就是先查询二级索引拿到主键,再通过主键查询整行数据

假设执行 SELECT * FROM user WHERE age = 20;age 是普通索引):

  1. age 索引树上找到 age=20 的叶子节点,获取对应的主键 id(比如 id=5)。

  2. 拿着 id=5,再到主键索引树(聚簇索引)的叶子节点,找到这一整行数据

  3. 返回结果。

第二步就是"回表"。


  1. 通过主键查询永远不会回表,因为主键本身是聚簇索引,叶子节点下挂在整行数据。

  2. 使用索引覆盖。

**反例:**select name, age from user where age = 20

如果age是单列索引,需要回表获取name字段的值

正例:创建联合索引(age, name)

此时索引树的叶子节点包含 agename,执行查询时直接从索引中返回数据,无需回表。

索引设计原则

  1. 数据量大,且查询比较频繁的表建立索引

  2. 常作为查询条件的字段建立索引

  3. 区分度高的字段作为索引,尽量建立唯一索引,区分度越高,索引效率越高

  4. 字符串类型的字段,字段长度比较长,考虑前缀索引

  5. 尽量使用联合索引,减少单列索引,可以避免回表查询,提高查询效率

  6. 索引不是越多越好,需要维护索引结构,影响增删改的效率

  7. 索引列不能为空,用 非空约束,当优化器知道每列是否包含 null 值时,可以更好地选择索引用于查询。

SQL慢查询排查步骤

六、Redis 高频题

20. Redis 的数据类型有哪些

五种基本类型:String(字符串)、Hash(哈希)、List(列表)、Set(集合)、ZSet(有序集合)。还有三种高级类型:Bitmap(位图)、HyperLogLog(基数统计)、Geo(地理位置)。

21. 缓存穿透、缓存击穿、缓存雪崩的区别

  • 缓存穿透 :查询一个不存在的 key,每次都打到数据库。解决:布隆过滤器、缓存空值。
  • 缓存击穿 :某个热点 key 过期瞬间,大量请求打到数据库。解决:互斥锁、逻辑过期。
  • 缓存雪崩 :大量 key 同时过期,或 Redis 宕机,请求全部打到数据库。解决:过期时间加随机值、集群高可用。

22. 怎么保证缓存和数据库的一致性

常用方案是「先更新数据库,再删除缓存」。删除失败时,可以引入消息队列重试,或者用延迟双删:先删缓存,更新数据库,再延迟几百毫秒删一次缓存。

七、消息队列高频题

23. 消息丢失怎么处理

分三段处理:

  • 生产者:开启确认机制,发送失败重试。
  • Broker:开启持久化,消息落盘。
  • 消费者:关闭自动 ACK,处理成功后再手动确认。

24. 消息重复消费怎么解决

核心思路是「幂等」。常见做法:消费前查数据库判断是否处理过;或者用 Redis 记录消息 ID,处理过就跳过;或者利用数据库唯一约束去重。

八、分布式与微服务高频题

25. 说说分布式事务的常见方案

  • 2PC(两阶段提交):先准备再提交,强一致但性能差。
  • TCC:Try、Confirm、Cancel 三段式,业务侵入大。
  • 本地消息表:本地事务 + 消息表,最终一致。
  • MQ 事务消息:如 RocketMQ 事务消息,最终一致。
  • Seata AT 模式:自动补偿,侵入小,实际项目常用。

26. 说说 CAP 理论

CAP 指一致性 (Consistency)、可用性 (Availability)、分区容错性(Partition tolerance)。分布式系统必须满足 P,然后在 C 和 A 之间做取舍。比如 Eureka 偏向 AP,ZooKeeper 偏向 CP。

九、项目经验与综合题

27. 你做过最有挑战的项目是什么

建议用 STAR 法则回答:

  • S(背景):项目是什么,解决什么问题。
  • T(任务):你负责哪部分。
  • A(行动):具体怎么做的,用了什么技术。
  • R(结果):取得了什么效果,最好有量化数据。

重点突出你遇到的难点和解决思路,而不是罗列技术名词。

28. 线上接口变慢,你怎么排查

排查思路:

  1. 先看是不是数据库慢查询,用慢日志定位 SQL。
  2. 再看是不是 Redis 缓存失效,大量请求打到数据库。
  3. 用 Arthas 看接口耗时分布,定位是哪个方法慢。
  4. 检查是否有锁竞争、线程池队列堆积。
  5. 最后看 GC 是否频繁,用 jstat 观察。

29. 说说你平时怎么学习新技术

我的方法是:先看官方文档了解核心概念,再写 Demo 跑通最小示例,然后结合项目场景思考怎么落地,最后通过写博客或做分享把知识输出一遍,加深理解。遇到问题优先查官方文档和源码,而不是只搜博客。

相关推荐
k4m7v2pz21 分钟前
用 rust-verb-shell 重写进程管理:从 game.sh 到 .rvs 的迁移实录
开发语言·后端·rust
2603_9658966223 分钟前
JavaScript 对象零基础详解|属性、方法、遍历、增删改查
开发语言·javascript·ecmascript
我命由我1234524 分钟前
Android 设备的日志缓冲区被写满,logcat: Unexpected EOF!
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
dear_bi_MyOnly28 分钟前
数组字符串深度解析:从入门到卡牌实战
开发语言·c++·学习
xcl092530 分钟前
分销返利商城系统开发实战:返利算法与架构设计指南
java·spring boot
正在走向自律32 分钟前
从 Python 基础到大模型落地:读《深入浅出 Python 人工智能》,吃透 AI 工程化 CRUD 实战
开发语言·人工智能·python·机器学习·知识脉络
171320330计算机毕设编程34 分钟前
2027计算机毕设五大方向对比&选题推荐
java·ide·python·算法·django·php·推荐算法
171320330计算机毕设编程35 分钟前
基于SpringBoot的在线拍卖系统
android·java·spring boot·小程序·课程设计
未秃头的程序猿40 分钟前
一次秒杀把服务打挂了,我用Sentinel规则配置化救了回来
java·后端·spring cloud