这里记录遇到的一些有趣的 java 面试题

基础

语法基础

a = a + b 与 a += b 的区别

核心区别:自动类型转换(隐式窄化转换)

Java 对这两种写法的编译规则有明确区分:

  • a = a + b不会自动进行窄化类型转换,如果运算结果的类型宽于a的类型,必须手动强转,否则编译报错;
  • a += b会自动进行隐式窄化类型转换,编译器会在后台帮你完成强转,无需手动写,但可能导致精度丢失。

+=是 Java 的复合赋值运算符,编译器会自动为运算结果添加对应类型的强转,其语义等价于:

a += b;  <==>  a = (T) (a + b); (T是a的原始类型)

不只是+=-=*=/=%=等复合赋值运算符都遵循 “自动隐式窄化转换” 规则

场景 1:使用a = a + b(编译报错)

byte a = 127;
byte b = 127;
// 当 byte/short/char 类型进行算术运算时,会先自动提升为 int 类型再计算
b = a + b; // error : cannot convert from int to byte
b += a; // ok

场景 2:使用a += b(编译通过,但可能丢精度)

short a = 1;
int b = 2;
a += b; // 编译通过,等价于 a = (short) (a + b);
System.out.println(a); // 输出3(无精度丢失)

// 精度丢失的场景
short c = Short.MAX_VALUE; // short的最大值
int d = 1;
c += d; // 等价于 c = (short) (c + d);
System.out.println(c); // 输出-32768(溢出,精度丢失)

能在 Switch 中使用 String 吗?

Java 7 及以上版本 支持在 switch 语句中使用 String 类型;Java 6 及以下版本不支持,强行使用会直接编译报错。

switch 本质上是基于数值类型(byte、short、int、char,以及它们的包装类)的分支判断,无法直接处理字符串。Java 7 之所以能支持 String 类型的 switch,核心是编译器在编译阶段做了 “语法糖” 转换,将 String 的 switch 转换成基于hashCode()equals()的数值判断,具体步骤如下:

步骤 1:编译器先获取每个 case 中 String 常量的hashCode()(int 类型),将 switch 的 String 参数转换成对该 hashCode 的 int 类型 switch;

步骤 2:在每个 hashCode 对应的分支中,额外通过equals()方法做二次校验(避免哈希冲突);

步骤 3:如果 switch 的 String 参数为 null,会直接抛出NullPointerException(编译期不会检查 null,运行时触发)。

为什么在重写 equals 方法的时候需要重写 hashCode 方法?

1、先明确 Java 官方的核心契约(Object 类规范)

Java 语言规范对这两个方法的核心约定是:

  • 如果两个对象通过equals()方法判断为相等(true),那么它们的hashCode()方法必须返回相同的整数值
  • 如果两个对象的hashCode()返回值不同,那么它们的equals()方法必须返回false(反过来不成立:hashCode 相同,equals 不一定相等)。

2、不重写 hashCode 的严重问题(实战场景)

假设你只重写了equals但未重写hashCode,以Person类为例:

class Person {
    private String id; // 身份证号,作为相等判断的依据
    private String name;

    // 只重写equals,按id判断相等
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(id, person.id);
    }

    // 未重写hashCode,使用Object默认实现(基于对象内存地址)

    // 构造器、getter/setter省略
}

测试代码:

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

        // equals判断相等
        System.out.println(p1.equals(p2)); // 输出true
        // 但hashCode不同(Object默认按内存地址生成)
        System.out.println(p1.hashCode()); // 例如:1163157884
        System.out.println(p2.hashCode()); // 例如:1956725890

        // 放入HashSet(依赖hashCode和equals)
        HashSet<Person> set = new HashSet<>();
        set.add(p1);
        set.add(p2);
        // 预期:Set中只有1个元素(因为p1和p2相等)
        // 实际:Set中有2个元素(违反Set的去重规则)
        System.out.println(set.size()); // 输出2
    }
}

问题根源

哈希容器(HashMap/HashSet)的工作逻辑是 “先哈希,后相等”:

  1. 存储元素时,先通过hashCode计算元素应放入的 “桶位置”;
  2. 查找 / 去重时,先找对应桶,再在桶内通过equals判断是否为同一元素。

如果equals相等但hashCode不同,两个相等的对象会被放入不同的桶,哈希容器会认为它们是不同对象,导致去重、查找等核心功能失效。

3、正确的实现方式(同时重写 equals 和 hashCode)

class Person {
    private String id;
    private String name;

    public Person(String id, String name) {
        this.id = id;
        this.name = name;
    }

    // 重写equals:按id判断相等
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(id, person.id);
    }

    // 重写hashCode:仅基于equals中用到的字段(id)生成
    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    // getter/setter省略
}

测试结果:

Person p1 = new Person("123456", "张三");
Person p2 = new Person("123456", "张三");
System.out.println(p1.equals(p2)); // true
System.out.println(p1.hashCode() == p2.hashCode()); // true
HashSet<Person> set = new HashSet<>();
set.add(p1);
set.add(p2);
System.out.println(set.size()); // 输出1(符合预期)

4、重写 hashCode 的最佳实践

  • 哈希值的生成必须基于equals方法中用到的所有字段(如果 equals 用了 id 和 name,hashCode 也要包含这两个字段),避免 “equals 相等但 hashCode 不同”;
  • 尽量让不同对象的 hashCode 分布均匀(减少哈希冲突),但无需追求绝对唯一;
  • 优先使用Objects.hash()(Java 7+),简洁且能处理 null 值。

finally{}代码块是否在任何情况下都会被执行?

finally 并不是绝对会被执行,大部分场景下它会执行,但存在几种特殊情况会导致finally代码块跳过执行。

1、大多数情况下,finally 会执行

只要try代码块被执行(哪怕try里抛出异常、returnbreak等),finally都会在try/catch结束前执行,这是finally的核心设计目的(保证资源释放等收尾逻辑执行)。

2、这 3 种情况,finally 不会执行

这些情况的核心共性是:JVM 在执行到 finally 之前就已经终止运行,导致 finally 代码块没有机会被执行。

情况 1:JVM 被强制终止(如调用System.exit(0)

System.exit(0)会直接终止 JVM 进程,进程终止后所有代码都无法执行:

public class FinallyDemo {
    public static void main(String[] args) {
        try {
            System.out.println("执行try块");
            System.exit(0); // 强制终止JVM
        } finally {
            System.out.println("执行finally块"); // 不会执行
        }
    }
}
// 输出:
// 执行try块
情况 2:线程被强制中断(Thread.stop()

Thread.stop()是废弃的危险方法,会直接终止线程,导致该线程的finally无法执行(实际开发严禁使用,但需了解该场景):

public class FinallyDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            try {
                System.out.println("线程执行try块");
                Thread.sleep(1000); // 模拟耗时操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                System.out.println("线程执行finally块"); // 不会执行
            }
        });
        t.start();
        Thread.sleep(100);
        t.stop(); // 强制终止线程
    }
}
// 输出:
// 线程执行try块
情况 3:JVM 崩溃(如硬件故障、内存溢出导致进程崩溃)

如果程序运行时出现严重错误(如OutOfMemoryError且无内存可用、CPU / 硬件故障),JVM 进程会直接崩溃,此时finally自然无法执行。比如:

public class FinallyDemo {
    public static void main(String[] args) {
        try {
            System.out.println("执行try块");
            // 无限创建大对象,导致OOM崩溃
            List<byte[]> list = new ArrayList<>();
            while (true) {
                list.add(new byte[1024 * 1024 * 100]);
            }
        } finally {
            System.out.println("执行finally块"); // 大概率不会执行(JVM崩溃)
        }
    }
}

try-finally 结构里 return 的执行顺序和最终返回结果

结论:无论 try 块中是否抛出异常,finally 块总会在 try 块的 return 执行之前运行;如果 finally 块也有 return,则会覆盖 try 块的 return 结果

场景 1:try 中有 return,finally 中无 return(最常见)

try 块的 return 会先计算返回值(暂存),然后执行 finally 块,最后返回暂存的结果。

public static int test1() {
    int num = 10;
    try {
        // 先计算return的表达式(num+10=20),暂存这个值
        return num + 10;
    } finally {
        // finally执行(修改num,但不影响已暂存的返回值)
        num = 100;
        System.out.println("finally块执行,num=" + num);
    }
}

public static void main(String[] args) {
    System.out.println("最终返回值:" + test1());
}

执行结果:

finally块执行,num=100
最终返回值:20

场景 2:try 中有 return,finally 中也有 return

finally 块的 return 会覆盖 try 块的 return,且 try 块暂存的返回值会被丢弃。

public static int test2() {
    int num = 10;
    try {
        return num + 10; // 算出20暂存,但不会返回
    } finally {
        num = 100;
        return num; // 直接返回100,覆盖try的return
    }
}

public static void main(String[] args) {
    System.out.println("最终返回值:" + test2());
}

执行结果:

最终返回值:100

场景 3:try 中抛出异常,catch 有 return,finally 也有 return

如果 try 抛出异常进入 catchcatchreturn 也会被 finallyreturn 覆盖。

public static int test3() {
    int num = 10;
    try {
        int a = 1 / 0; // 抛出算术异常
        return num + 10;
    } catch (ArithmeticException e) {
        return num + 20; // 算出30暂存,但不会返回
    } finally {
        num = 100;
        return num; // 覆盖catch的return,返回100
    }
}

public static void main(String[] args) {
    System.out.println("最终返回值:" + test3());
}

执行结果:

最终返回值:100

场景 4:finally 中有 return,掩盖异常

如果 try 抛出异常,且 finallyreturn,则异常会被掩盖(不会向上抛出),这是开发中的

public static int test4() {
    try {
        int a = 1 / 0; // 抛出异常
        return 1;
    } finally {
        return 2; // 执行return,异常被掩盖
    }
}

public static void main(String[] args) {
    System.out.println("最终返回值:" + test4()); // 输出2,无异常抛出
}

try 抛出的异常本应向上传递,但 finallyreturn 会终止方法,异常被 “吞掉”,调试时很难定位问题。

开发建议

  1. 禁止在 finally 中使用 return:这是 Java 编码的最佳实践,会导致返回值覆盖、异常掩盖等问题,增加调试难度;
  2. finally 仅用于释放资源(如关闭流、数据库连接),不要包含业务逻辑或返回操作。

泛型的上限和下限?

ava 泛型中上限(Upper Bounds)下限(Lower Bounds)是通过通配符? 配合extends(上限)和super(下限)来限制泛型类型的取值范围,核心目的是解决泛型的 “不可变性” 问题(比如List<String>不是List<Object>的子类),让泛型使用更灵活。

一、先理解核心前提:泛型的不可变性

泛型默认是 “不可变” 的,即即使类 A 是类 B 的子类,List<A>也不是List<B>的子类,直接赋值会编译报错:

List<String> strList = new ArrayList<>();
// 编译错误:不兼容的类型,List<String> 无法转换为 List<Object>
List<Object> objList = strList; 

泛型上下限就是为了在这种场景下,通过通配符放宽类型限制,同时保证类型安全。

二、泛型上限(Upper Bounds):? extends T

限制泛型类型必须是T 类本身,或 T 的子类 / 实现类(T 可以是类或接口),语法:<? extends T>

特点:

  • 只能读取,不能写入(除了null):因为编译器无法确定泛型的具体类型是 T 的哪个子类,写入会有类型安全风险;
  • 读取时只能赋值给 T 类型:保证读取的对象一定是 T 类型的实例。
import java.util.ArrayList;
import java.util.List;

public class GenericBoundsDemo {
    // 泛型上限:只能接收List<Number>或List<Number的子类>(Integer、Double等)
    public static void printNumberList(List<? extends Number> list) {
        // 合法:读取,赋值给Number类型(所有元素都是Number的子类)
        for (Number num : list) {
            System.out.println(num);
        }

        // 编译错误:无法写入(编译器不知道list实际是List<Integer>还是List<Double>)
        list.add(10);
        // 唯一例外:可以写入null(null是所有类型的默认值)
        list.add(null);
    }

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();
        intList.add(1);
        intList.add(2);

        List<Double> doubleList = new ArrayList<>();
        doubleList.add(3.14);

        // 合法:Integer是Number的子类
        printNumberList(intList);
        // 合法:Double是Number的子类
        printNumberList(doubleList);

        List<String> strList = new ArrayList<>();
        printNumberList(strList); // 编译错误:String不是Number的子类
    }
}

三、泛型下限(Lower Bounds):? super T

限制泛型类型必须是T 类本身,或 T 的父类 / 接口(T 可以是类或接口),语法:<? super T>

特点:

  • 只能写入 T 类型(或 T 的子类),读取时只能赋值给 Object 类型:因为编译器能确定泛型的具体类型是 T 的父类,写入 T 类型一定安全;但读取时无法确定具体父类类型,只能用 Object 接收。
import java.util.ArrayList;
import java.util.List;

public class GenericBoundsDemo {
    // 泛型下限:只能接收List<Integer>或List<Integer的父类>(Number、Object等)
    public static void addInteger(List<? super Integer> list) {
        // 合法:写入Integer类型(父类集合可以接收子类对象)
        list.add(10);
        list.add(20);
        // 合法:写入Integer的子类(Integer没有子类,这里仅演示逻辑)
        // list.add(new MyInteger()); 

        // 编译错误:写入非Integer类型(Double不是Integer的子类)
        list.add(3.14); 

        // 读取时只能赋值给Object
        for (Object obj : list) {
            System.out.println(obj);
        }
    }

    public static void main(String[] args) {
        // 合法:List<Object>是Integer的父类
        List<Object> objList = new ArrayList<>();
        addInteger(objList);

        // 合法:List<Number>是Integer的父类
        List<Number> numList = new ArrayList<>();
        addInteger(numList);

        // 合法:List<Integer>本身
        List<Integer> intList = new ArrayList<>();
        addInteger(intList);

        // 编译错误:List<Double>不是Integer的父类
        List<Double> doubleList = new ArrayList<>();
        // addInteger(doubleList); 
    }
}

经典应用:PECS 原则

这是泛型上下限的核心使用准则,全称:Producer Extends, Consumer Super(生产者用 Extends,消费者用 Super):

  • 生产者(Producer):如果泛型对象是 “产出” 数据(供你读取),用? extends T(比如List<? extends Fruit>,你从里面取 Fruit);
  • 消费者(Consumer):如果泛型对象是 “消费” 数据(你向里面写),用? super T(比如List<? super Apple>,你向里面存 Apple)。

示例(Java 集合工具类Collections.copy的源码核心逻辑):

// dest是消费者(接收元素),用super;src是生产者(提供元素),用extends
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (int i = 0; i < src.size(); i++) {
        dest.set(i, src.get(i)); // 安全:src取T,dest存T
    }
}

异常

Java 异常的底层实现机制

Java 的异常处理(try-catch-finally)在源码层面是语法糖,底层完全依赖 JVM 字节码中的Exception Table(异常表)实现 ——异常表是 JVM 记录 “异常捕获范围、处理位置、异常类型” 的核心数据结构,所有 try-catch 逻辑最终都会被编译器转换为异常表条目,而非源码层面的 “分支判断”。

一、先明确核心结论

  1. Java 源码中的try-catch块,编译器不会生成 “if-else” 式的分支代码,而是在字节码中生成Exception Table
  2. 当程序抛出异常时,JVM 会遍历当前方法的异常表,匹配 “异常发生的位置” 和 “异常类型”,找到对应的处理逻辑;
  3. finally块的执行则是编译器通过 “复制字节码 + 异常表兜底” 实现,本质也依赖异常表。

二、Exception Table 的结构(字节码层面)

Exception Table是JVM执行异常处理时的查找表,它记录了方法中每个异常处理器的信息。当方法抛出异常时,JVM会查找这个表来确定应该跳转到哪里执行。每个异常表项包含以下信息:

  • from : 异常处理器生效的起始位置(字节码索引)
  • to : 异常处理器生效的结束位置(字节码索引)
  • target: 异常处理代码的起始位置(catch块的字节码索引)
  • type : 要捕获的异常类型(类的全限定名)

    Exception Table的工作原理。当异常发生时:

  1. JVM在当前方法中查找匹配的异常处理器
  2. 从当前抛出异常的指令位置开始,在异常表中查找:
    • 异常发生的指令索引在 [from, to) 范围内
    • 抛出的异常类型匹配 type(或typeany
  3. 找到后,JVM跳转到target位置的代码执行
  4. 如果没有找到,方法会异常退出,异常抛给调用者

注意事项

  • from包含,to不包含:异常处理区间是[from, to)
  • type为”any”:表示捕获所有类型的异常(finally块的情况)
  • 异常表不影响正常控制流:只有异常发生时才会查询
  • 性能考虑:异常表的存在不会影响正常代码的执行性能

三、分析

通过一个简单的 try-catch 示例,一步步拆解底层逻辑:

步骤 1:编写测试源码
public class ExceptionTableDemo {
    public static void test() {
        try {
            int a = 1 / 0; // 可能抛出ArithmeticException
        } catch (ArithmeticException e) {
            System.out.println("捕获算术异常");
        }
    }

    public static void main(String[] args) {
        test();
    }
}
步骤 2:编译并查看字节码(重点看 Exception Table)
  1. 编译:javac ExceptionTableDemo.java

  2. 查看字节码(带异常表):javap -v -c ExceptionTableDemo.class

  3. 核心输出(test 方法的字节码 + 异常表):

    // test方法的字节码指令
    public static void test();
      descriptor: ()V
      flags: ACC_PUBLIC, ACC_STATIC
      Code:
        stack=2, locals=1, args_size=0
           0: iconst_1
           1: iconst_0
           2: idiv                // 执行除法,可能抛出ArithmeticException
           3: istore_0            // 把结果存入局部变量0(正常执行时的逻辑)
           4: goto          16    // 正常执行完try块,跳转到方法结束
           7: astore_0            // 捕获异常,把异常对象存入局部变量0
           8: getstatic     #3    // Field java/lang/System.out:Ljava/io/PrintStream;
          11: ldc           #4    // String 捕获算术异常
          13: invokevirtual #5    // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          16: return            // 方法结束
        Exception table:        // 异常表。核心
           from    to  target type
               0     4     7   Class java/lang/ArithmeticException
    ...
    
步骤 3:解读异常表和执行逻辑

异常表条目:from 0 to 4 target 7 type ArithmeticException

  • from 0:监控从字节码偏移量 0 开始(try 块的第一条指令:iconst_1);
  • to 4:监控到字节码偏移量 4 结束(不包含 4,即 try 块的最后一条指令是 idiv/istore_0);
  • target 7:如果在 0-4 范围内抛出ArithmeticException,跳转到字节码偏移量 7 执行(catch 块逻辑);
  • type:要捕获的异常类型是 ArithmeticException。

JVM 执行流程

  1. 正常执行:0→1→2→3→4→16→return(无异常,跳过 catch);
  2. 抛出异常:0→1→2(执行 idiv,除数为 0,抛出 ArithmeticException)→JVM 暂停正常执行→遍历异常表→匹配到 “0-4 范围 + ArithmeticException”→跳转到 7 执行 catch 块→8→11→13→16→return。

四、finally 块的底层实现(依赖异常表兜底)

finally 的执行逻辑更复杂,它会在异常表中出现多次:

  1. 正常路径:在 try 块结束(goto)前,插入 finally 的字节码;
  2. 异常路径:在异常表中新增一条catch_type=0的条目,兜底所有异常,执行 finally 后再抛异常。

示例(带 finally 的源码)

public static void test() {
    try {
        int a = 1 / 0;
    } catch (ArithmeticException e) {
        System.out.println("捕获算术异常");
    } finally {
        System.out.println("执行finally");
    }
}

异常表新增条目(核心变化)

Code:
  stack=2, locals=2, args_size=0
     0: iconst_1
     1: iconst_0
     2: idiv
     3: istore_0
     4: getstatic     #2                  
     7: ldc           #3                  
     9: invokevirtual #4                  
    12: goto          46
    15: astore_0
    16: getstatic     #2                  
    19: ldc           #6                  
    21: invokevirtual #4                  
    24: getstatic     #2                  
    27: ldc           #3                  
    29: invokevirtual #4                  
    32: goto          46
    35: astore_1
    36: getstatic     #2                  
    39: ldc           #3                  
    41: invokevirtual #4                  
    44: aload_1
    45: athrow
    46: return
  Exception table:
     from    to  target type
         0     4    15   Class java/lang/ArithmeticException
         0     4    35   any            // 处理所有其他异常
        15    24    35   any            // catch块中的异常处理

反射

反射的核心价值是在运行时动态获取类的信息、创建对象、调用方法、修改属性,突破编译期的访问限制

反射的核心本质是:在运行时获取类的 Class 对象,通过这个对象操作类的所有成员(字段、方法、构造器)

  • 编译期:代码写好后,编译器只知道类的表面信息,无法动态操作类的私有成员;
  • 运行时:JVM 会为每个加载的类创建一个唯一的 Class 对象,反射就是通过这个对象 “窥探” 并操作类的所有细节。

反射的核心类(都在 java.lang.reflect 包下)。反射的所有操作都围绕以下 4 个核心类展开,而这些类都需要通过 Class 对象获取:

核心类 作用 获取方式(示例)
Class 代表类的元信息(反射的入口) Class clazz = User.class;
Field 代表类的字段(成员变量) Field field = clazz.getDeclaredField("name");
Method 代表类的方法 Method method = clazz.getDeclaredMethod("sayHello");
Constructor 代表类的构造器 Constructor constructor = clazz.getDeclaredConstructor(String.class);

一、先准备测试类(基础载体)

定义一个包含不同访问修饰符的类,用于演示反射操作:

import java.util.Arrays;

// 测试类:包含构造方法、成员变量、成员方法(不同访问权限)
public class User {
    // 成员变量(不同访问修饰符)
    public String username;
    private Integer age;
    protected String address;
    String phone; // 默认访问权限

    // 构造方法
    public User() {} // 无参构造
    public User(String username, Integer age) { // 有参构造
        this.username = username;
        this.age = age;
    }
    private User(String username) { // 私有构造
        this.username = username;
    }

    // 成员方法
    public void sayHello() {
        System.out.println("Hello, " + username);
    }
    private String getInfo(String prefix) {
        return prefix + ": " + username + ", " + age;
    }

    @Override
    public String toString() {
        return "User{" +
                "username='" + username + '\'' +
                ", age=" + age +
                ", address='" + address + '\'' +
                ", phone='" + phone + '\'' +
                '}';
    }
}

二、反射的核心使用步骤(分模块)

反射的所有操作都以获取Class对象为起点,Class对象是反射的“入口”。

1. 第一步:获取Class对象(3种方式)
public class ReflectionDemo {
    public static void main(String[] args) throws Exception {
        // 方式1:Class.forName("全类名")(最常用,动态加载)
        Class<?> clazz1 = Class.forName("User");

        // 方式2:类名.class(编译期确定,静态加载)
        Class<?> clazz2 = User.class;

        // 方式3:对象.getClass()(已有实例时使用)
        User user = new User();
        Class<?> clazz3 = user.getClass();

        // 验证:三个Class对象是同一个(JVM中每个类只有一个Class实例)
        System.out.println(clazz1 == clazz2); // true
        System.out.println(clazz1 == clazz3); // true
    }
}
2. 第二步:通过反射创建对象(操作Constructor)

支持调用无参构造、有参构造、私有构造

public class ReflectionDemo {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = User.class;

        // 1. 调用无参构造(public)
        User user1 = (User) clazz.newInstance(); // JDK9后标记为过时,推荐用getConstructor()
        user1.username = "张三";
        System.out.println(user1); // User{username='张三', age=null, address='null', phone='null'}

        // 2. 调用有参构造(public)
        // 获取有参构造:参数类型为String、Integer
        Constructor<?> constructor = clazz.getConstructor(String.class, Integer.class);
        User user2 = (User) constructor.newInstance("李四", 20);
        System.out.println(user2); // User{username='李四', age=20, address='null', phone='null'}

        // 3. 调用私有构造(private)
        // getDeclaredConstructor:获取所有构造(包括private)
        Constructor<?> privateConstructor = clazz.getDeclaredConstructor(String.class);
        privateConstructor.setAccessible(true); // 突破private访问限制
        User user3 = (User) privateConstructor.newInstance("王五");
        System.out.println(user3); // User{username='王五', age=null, address='null', phone='null'}
    }
}
3. 第三步:通过反射操作成员变量(操作Field)

支持读取/修改任意访问权限的成员变量(包括private):

public class ReflectionDemo {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = User.class;
        User user = (User) clazz.newInstance();

        // 1. 操作public变量
        Field usernameField = clazz.getField("username"); // getField:仅获取public变量
        usernameField.set(user, "赵六"); // 设置值
        System.out.println(usernameField.get(user)); // 获取值:赵六

        // 2. 操作private变量
        Field ageField = clazz.getDeclaredField("age"); // getDeclaredField:获取所有变量(包括private)
        ageField.setAccessible(true); // 关闭访问检查(突破private)
        ageField.set(user, 25);
        System.out.println(ageField.get(user)); // 25

        // 3. 操作protected/default变量(同private逻辑)
        Field addressField = clazz.getDeclaredField("address");
        addressField.setAccessible(true);
        addressField.set(user, "北京");

        Field phoneField = clazz.getDeclaredField("phone");
        phoneField.setAccessible(true);
        phoneField.set(user, "13800138000");

        System.out.println(user); // User{username='赵六', age=25, address='北京', phone='13800138000'}

        // 4. 获取所有成员变量
        Field[] allFields = clazz.getDeclaredFields();
        for (Field field : allFields) {
            field.setAccessible(true);
            System.out.println(field.getName() + ": " + field.get(user));
        }
    }
}
4. 第四步:通过反射调用成员方法(操作Method)

支持调用任意访问权限的方法(包括private):

public class ReflectionDemo {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = User.class;
        User user = (User) clazz.getConstructor(String.class, Integer.class).newInstance("孙七", 30);

        // 1. 调用public方法
        Method sayHelloMethod = clazz.getMethod("sayHello"); // getMethod:仅获取public方法
        sayHelloMethod.invoke(user); // 调用方法:Hello, 孙七

        // 2. 调用private方法
        // getDeclaredMethod:参数1=方法名,参数2=方法参数类型
        Method getInfoMethod = clazz.getDeclaredMethod("getInfo", String.class);
        getInfoMethod.setAccessible(true); // 突破private限制
        // invoke:参数1=调用对象,参数2=方法入参
        String result = (String) getInfoMethod.invoke(user, "用户信息");
        System.out.println(result); // 用户信息: 孙七, 30

        // 3. 获取所有方法(包括继承的public方法)
        Method[] allMethods = clazz.getDeclaredMethods();
        System.out.println("所有方法:" + Arrays.toString(allMethods));
    }
}

三、反射的核心使用场景

  1. 框架开发:Spring/IOC、MyBatis等框架的核心底层(如Spring通过反射创建Bean、MyBatis通过反射封装结果集);
  2. 动态代理:AOP的底层实现(JDK动态代理通过反射调用目标方法);
  3. 序列化/反序列化:JSON工具(如FastJSON)通过反射读取/设置对象属性;
  4. 注解处理:自定义注解的解析(通过反射获取注解标注的字段/方法);
  5. 动态加载类:如插件化开发、热部署(运行时加载未知类)。

四、反射的注意事项

  1. 性能问题:反射跳过编译期检查,性能比直接调用低(约10-100倍),高频调用场景需缓存Class/Method/Field对象;
  2. 安全问题:突破访问权限(如修改private变量),可能破坏封装性,需谨慎使用;
  3. 兼容性问题:依赖类的结构(字段名、方法名),类结构变更会导致反射代码报错;
  4. 访问权限:操作private成员时,需调用setAccessible(true)(关闭安全检查),在有安全管理器的环境下可能被禁止。

总结

  1. 反射核心流程:获取Class对象 → 操作Constructor(创建对象)/Field(操作属性)/Method(调用方法),其中getDeclaredXXX可获取所有访问权限的成员,setAccessible(true)突破private限制;
  2. 核心价值:运行时动态操作类/对象,是框架开发的基础;
  3. 使用约束:注意性能损耗和封装性破坏,非必要场景(如普通业务代码)不建议使用。

SPI机制

Java中的SPI(Service Provider Interface)机制的定义、核心原理、使用方式和应用场景,这是考察对Java模块化扩展、框架底层设计思路理解的重要知识点,也是中间件/框架开发中常用的扩展方式。

详细解答

一、SPI机制的核心定义

SPI(Service Provider Interface)是Java提供的一种服务发现机制,核心思想是:将接口的定义与实现分离,通过配置文件指定接口的实现类,程序运行时动态加载实现类,从而实现“接口标准化、实现模块化、扩展可插拔”。

简单来说:

  • 你定义一个接口(服务标准);
  • 其他人按照这个接口写实现类(服务提供者);
  • 程序通过SPI机制,在运行时自动找到并加载这些实现类,无需硬编码指定。

二、SPI的核心设计与执行流程

SPI机制的核心依赖「接口 + 配置文件 + 加载器」三部分,执行流程如下:

1. 核心组成
组件 作用
服务接口(Service Interface) 定义标准化的服务规范(如java.sql.Driver
服务实现类(Provider) 第三方实现的接口实现类(如MySQL的com.mysql.cj.jdbc.Driver
配置文件 META-INF/services/目录下,以「接口全类名」命名的文件,内容是实现类的全类名
服务加载器(ServiceLoader) JDK提供的java.util.ServiceLoader类,负责加载配置文件中的实现类
2. 标准执行流程(五步)
flowchart TD
    A[定义服务接口] --> B[第三方实现接口]
    B --> C[在META-INF/services/下创建配置文件]
    C --> D[程序中用ServiceLoader加载实现类]
    D --> E[遍历使用加载的实现类]

三、SPI机制的实战示例(从零实现)

通过一个简单的“日志框架扩展”示例,直观理解SPI的使用:

步骤1:定义服务接口(核心规范)
// 日志服务接口(SPI的核心接口)
public interface LogService {
    void log(String message);
}
步骤2:编写服务实现类(不同厂商的实现)
// 控制台日志实现
public class ConsoleLogService implements LogService {
    @Override
    public void log(String message) {
        System.out.println("[控制台日志] " + message);
    }
}

// 文件日志实现
public class FileLogService implements LogService {
    @Override
    public void log(String message) {
        System.out.println("[文件日志] " + message);
    }
}
步骤3:创建SPI配置文件(关键)
  1. 在项目的resources目录下,创建目录结构:META-INF/services/

  2. 在该目录下创建文件,文件名必须是接口的全类名com.example.spi.LogService

  3. 文件内容为实现类的全类名(每行一个):

    1. com.example.spi.ConsoleLogService
      com.example.spi.FileLogService
      
步骤4:通过ServiceLoader加载并使用实现类
import java.util.ServiceLoader;

public class SpiDemo {
    public static void main(String[] args) {
        // 1. 获取ServiceLoader实例(传入服务接口类)
        ServiceLoader<LogService> serviceLoader = ServiceLoader.load(LogService.class);

        // 2. 遍历加载所有实现类(懒加载:遍历到才会加载)
        for (LogService logService : serviceLoader) {
            // 3. 调用实现类的方法
            logService.log("SPI机制测试");
        }
    }
}
步骤5:运行结果
[控制台日志] SPI机制测试
[文件日志] SPI机制测试

四、SPI机制的核心特点

1. 优点
  • 解耦:接口与实现分离,无需硬编码依赖实现类,新增实现只需添加配置文件,无需修改核心代码;
  • 可插拔:实现类可动态替换(如替换日志实现、数据库驱动);
  • 标准化:遵循统一的加载规范,框架/中间件之间可无缝扩展。
2. 缺点
  • 懒加载:ServiceLoader是懒加载模式,只有遍历到实现类时才会加载,可能导致首次调用延迟;
  • 线程不安全:ServiceLoader本身不是线程安全的,多线程场景需手动加锁;
  • 无法指定加载顺序:配置文件中的实现类按顺序加载,无法自定义优先级;
  • 异常处理不友好:加载失败时仅打印日志,不抛出异常,排查问题较麻烦。

五、SPI机制的典型应用场景

  1. 数据库驱动加载:JDBC的java.sql.Driver接口是SPI的经典应用——不同数据库(MySQL/Oracle)提供各自的Driver实现,JDK通过SPI加载驱动,无需手动Class.forName("com.mysql.cj.jdbc.Driver")
  2. Spring框架:Spring中大量使用SPI扩展(如ApplicationContextInitializerBeanDefinitionRegistryPostProcessor);
  3. Dubbo框架:Dubbo的扩展机制基于SPI实现(增强了原生SPI,支持优先级、自适应扩展等);
  4. 日志框架:SLF4J通过SPI加载不同的日志实现(Log4j/Logback);
  5. JDK内置扩展:如java.nio.charset.spi.CharsetProvider(字符集扩展)、java.util.spi.LocaleServiceProvider(本地化扩展)。

六、SPI与API的区别(易混点)

维度 SPI API
设计角度 面向实现者(服务提供者) 面向调用者(服务使用者)
调用关系 接口由调用方定义,实现由第三方提供 接口由实现方定义,调用方直接使用
核心目的 扩展现有功能 提供可用的功能
示例 JDBC Driver、Dubbo扩展 Spring Bean、StringUtils工具类

总结

  1. 核心本质:SPI是Java的服务发现机制,通过「接口+配置文件+ServiceLoader」实现接口与实现的解耦,支持动态加载实现类;
  2. 核心流程:定义接口 → 实现接口 → 配置META-INF/services/文件 → ServiceLoader加载并使用;
  3. 典型应用:JDBC驱动、Dubbo/Spring扩展、日志框架适配,是框架/中间件实现可插拔扩展的核心方式。

集合

Collection

集合有哪些类?

  • Set
    • TreeSet 基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。
    • HashSet 基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。
    • LinkedHashSet 具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。
  • List
    • ArrayList 基于动态数组实现,支持随机访问。
    • Vector 和 ArrayList 类似,但它是线程安全的。
    • LinkedList 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
  • Queue
    • LinkedList 可以用它来实现双向队列。
    • PriorityQueue 基于堆结构实现,可以用它来实现优先队列。

ArrayList Fail-Fast 机制

一、Fail-Fast机制的核心定义

Fail-Fast(快速失败)是ArrayList(以及HashMap、HashSet等非线程安全集合)的一种错误检测机制:当多个线程对集合进行结构性修改(如添加/删除元素),或单线程在迭代器遍历过程中直接修改集合结构时,迭代器会立即抛出 ConcurrentModificationException 异常,而不是容忍错误继续执行,以此快速暴露问题。

简单来说:迭代器遍历集合时,不允许集合的结构被意外修改,否则立刻报错

二、触发Fail-Fast的典型场景

场景1:单线程遍历中直接修改集合(最常见)

import java.util.ArrayList;
import java.util.Iterator;

public class FailFastDemo {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("A");
        list.add("B");
        list.add("C");

        // 错误方式:遍历中直接remove元素,触发Fail-Fast
        for (String s : list) { // 增强for循环底层是迭代器
            if (s.equals("A")) {
                list.remove(s); // 直接修改集合结构
            }
        }
    }
}

运行结果:

Exception in thread "main" java.util.ConcurrentModificationException
 at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
 at java.util.ArrayList$Itr.next(ArrayList.java:859)
 at FailFastDemo.main(FailFastDemo.java:12)

场景2:多线程并发修改

import java.util.ArrayList;

public class FailFastDemo {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("A");
        list.add("B");

        // 线程1:遍历集合
        new Thread(() -> {
            for (String s : list) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(s);
            }
        }).start();

        // 线程2:修改集合结构
        new Thread(() -> {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add("C"); // 结构性修改
        }).start();
    }
}

运行结果(大概率触发):

A
Exception in thread "Thread-0" java.util.ConcurrentModificationException
 at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
 ...
三、Fail-Fast的底层实现原理(modCount和expectedModCount)

ArrayList的Fail-Fast核心依赖两个变量:

  1. modCount:ArrayList类中的成员变量,记录集合的结构性修改次数(添加、删除元素时自增,修改元素值不会变化);
  2. expectedModCount:ArrayList内部迭代器(Itr类)的成员变量,初始化时等于当前的modCount,代表迭代器预期的集合修改次数。

核心执行流程:

flowchart TD
    A[创建迭代器] --> B[expectedModCount = modCount]
    B --> C["迭代器调用next()/remove()"]
    C --> D[检查modCount == expectedModCount?]
    D -- 是 --> E[正常执行]
    D -- 否 --> F[抛出ConcurrentModificationException]

源码层面拆解(ArrayList核心片段):

public class ArrayList<E> extends AbstractList<E> {
    // 1. 集合的结构性修改次数(父类AbstractList中定义)
    protected transient int modCount = 0;

    // 2. 内部迭代器类
    private class Itr implements Iterator<E> {
        int expectedModCount = modCount; // 初始化时同步modCount

        @Override
        public E next() {
            // 核心:每次调用next()都会检查修改次数
            checkForComodification();
            // ... 其他逻辑
        }

        // 检查是否触发Fail-Fast
        final void checkForComodification() {
            if (modCount != expectedModCount) {
                // 抛出快速失败异常
                throw new ConcurrentModificationException();
            }
        }
    }

    // 3. 结构性修改方法(如add)会修改modCount
    public boolean add(E e) {
        modCount++; // 结构性修改,自增
        // ... 添加元素逻辑
        return true;
    }

    // 4. 直接调用list.remove()也会修改modCount
    public boolean remove(Object o) {
        modCount++; // 结构性修改,自增
        // ... 删除元素逻辑
        return true;
    }
}

场景1报错原因

  • 增强for循环底层是迭代器,初始化时expectedModCount = modCount = 3
  • 遍历到”A”时,调用list.remove()modCount自增为4;
  • 迭代器下一次调用next()时,检查modCount(4) != expectedModCount(3),抛出异常。
四、Fail-Fast的“例外情况”(不触发的场景)

并非所有修改都会触发Fail-Fast,核心看是否修改modCount或是否通过迭代器修改:

  1. 迭代器自身的remove()方法:迭代器的remove()会同步更新expectedModCount,不会触发异常:

    // 正确方式:通过迭代器remove
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        String s = iterator.next();
        if (s.equals("A")) {
            iterator.remove(); // 迭代器自身的remove,同步expectedModCount
        }
    }
    
  2. 修改元素值(非结构性修改):仅修改list.set(index, value)不会改变modCount,不会触发异常;

  3. 遍历最后一个元素时修改:若遍历到最后一个元素时调用list.remove(),迭代器已无next()调用,不会触发检查(不推荐依赖此特性)。

五、如何避免Fail-Fast?

根据场景选择不同方案:

  1. 单线程场景:使用迭代器的remove()方法(而非集合的remove());
  2. 多线程场景
    1. 方案1:使用线程安全集合(如CopyOnWriteArrayList,基于“写时复制”实现,无Fail-Fast);
    2. 方案2:遍历前加锁(如synchronizedReentrantLock),保证遍历和修改互斥;
    3. 方案3:使用Collections.synchronizedList()包装ArrayList(底层加锁,性能较低)。

示例:CopyOnWriteArrayList(推荐多线程场景)

import java.util.ArrayList;
import java.util.concurrent.CopyOnWriteArrayList;

public class FailFastDemo {
    public static void main(String[] args) {
        // 替换为CopyOnWriteArrayList,无Fail-Fast
        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(new ArrayList<>());
        list.add("A");
        list.add("B");
        list.add("C");

        // 遍历中直接修改,不触发异常
        for (String s : list) {
            if (s.equals("A")) {
                list.remove(s);
            }
        }
        System.out.println(list); // [B, C]
    }
}
六、Fail-Fast的核心特点
  1. 不是线程安全机制:Fail-Fast只是错误检测机制,不能保证线程安全,仅能快速暴露并发修改问题;
  2. 不保证100%触发:由于CPU调度等原因,多线程场景下可能出现“漏检”(modCount刚好相等),因此不能依赖它作为并发安全的判断依据;
  3. 仅检测结构性修改:修改元素值(set())不会改变modCount,不会触发异常。
总结
  1. 核心原理:ArrayList的Fail-Fast依赖modCount(集合修改次数)和expectedModCount(迭代器预期修改次数)的对比,不一致则抛出ConcurrentModificationException
  2. 触发条件:迭代器遍历过程中,集合被直接修改(单线程)或并发修改(多线程)结构(添加/删除元素);
  3. 解决方案:单线程用迭代器remove(),多线程用CopyOnWriteArrayList或加锁,避免直接修改集合。

Map

Map有哪些类?

  • TreeMap 基于红黑树实现。
  • HashMap 1.7基于哈希表实现,1.8基于数组+链表+红黑树。
  • HashTable 和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高(1.7 ConcurrentHashMap 引入了分段锁, 1.8 引入了红黑树)。
  • LinkedHashMap 使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。

JDK7 HashMap如何实现?

哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java7 *HashMap*采用的是冲突链表方式

JDK 7的HashMap核心是数组 + 链表的组合结构(也叫“哈希桶”结构),核心设计围绕“哈希计算→桶定位→冲突处理→扩容”展开,下面从核心结构、核心流程、关键机制三个维度拆解:

一、核心数据结构

// jdk1.7
package java.util;
public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable
{
    transient Entry<K,V>[] table;

    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        init();
    }
    // ...
}

JDK 7 HashMap的底层由两个核心部分组成:

  1. 数组(table):类型为Entry[],数组的每个元素称为“桶(bucket)”,桶中存储的是Entry链表的头节点;

  2. 链表(Entry):解决哈希冲突(不同key计算出相同桶索引),Entry是HashMap的内部类,核心属性:

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next; // 指向下一个Entry,形成链表
        int hash; // key的哈希值(提前计算,避免重复计算)
    
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
    }
    

二、核心操作流程(put/get)

1. put方法(存储键值对)

put通过“哈希计算→桶定位→头插法插入→扩容判断”实现,get通过“哈希定位→链表遍历匹配”实现;

这是HashMap最核心的方法,完整流程如下:

flowchart TD
    A["调用put(K key, V value)"] --> B["计算key的哈希值:hash(key)"]
    B --> C["通过哈希值计算桶索引:index = hash & (table.length - 1)"]
    C --> D{检查桶是否为空?}
    D -- 空 --> E[创建新Entry,放入该桶(作为链表头)]
    D -- 非空 --> F[遍历桶中的Entry链表]
    F --> G{是否存在相同key?}
    G -- 是 --> H[替换旧value,返回旧值]
    G -- 否 --> I[新Entry插入链表头部(头插法)]
    I --> J{检查是否需要扩容?(size > 阈值)}
    J -- 是 --> K["执行扩容:resize(),重新哈希并迁移数据"]
    J -- 否 --> L[结束]

关键步骤拆解:

  • 步骤1:哈希值计算

    JDK 7对key的hashCode做了二次哈希(扰动函数),减少哈希冲突:

    final int hash(Object k) {
        int h = 0;
        // 处理String类型的key,优化哈希计算(JDK 7特有)
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }
        h ^= k.hashCode(); // 异或hashCode
        // 二次哈希(扰动):减少高位未参与计算导致的冲突
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    
    
  • 步骤2:桶索引计算

    hash & (table.length - 1)替代取模运算(效率更高),前提是table长度为2的幂次:

    int index = hash & (table.length - 1);
    
  • 步骤3:冲突处理(头插法)

    新Entry插入链表头部(而非尾部),原因是JDK 7认为“最新插入的元素被访问的概率更高”,头插法效率更高;

  • 步骤4:扩容触发条件

    size(实际存储的键值对数量) > threshold(阈值)时触发扩容,阈值计算公式:threshold = table.length * loadFactor(负载因子,默认0.75)

2. get方法(获取键值对)

流程比put简单,核心是“定位桶→遍历链表匹配key”:

flowchart TD
    A["调用get(Object key)"] --> B["计算key的哈希值:hash(key)"]
    B --> C["计算桶索引:index = hash & (table.length - 1)"]
    C --> D[遍历桶中的Entry链表]
    D --> E{匹配key(hash相等 + equals相等)?}
    E -- 是 --> F[返回对应value]
    E -- 否 --> G[返回null]

三、核心机制:扩容(resize)

扩容是JDK 7 HashMap的核心痛点,也是与JDK 8的关键差异:

1. 扩容流程
  1. 新建一个长度为原数组2倍的新数组(保证长度为2的幂次);
  2. 遍历原数组的每个桶,将桶中的Entry链表重新哈希,迁移到新数组的对应桶中;
  3. 迁移完成后,将table引用指向新数组,更新阈值(新阈值 = 新长度 * 负载因子)。
2. 扩容的核心问题:链表环 & 数据丢失(多线程场景)

JDK 7 HashMap的扩容采用头插法迁移链表,在多线程环境下会导致严重问题:

  • 问题1:链表形成环线程A和线程B同时扩容,迁移同一链表时,头插法会导致链表节点的next指针指向错乱,形成环形链表,后续get操作会陷入死循环;
  • 问题2:数据丢失多线程同时插入节点,头插法可能覆盖已插入的节点,导致部分键值对丢失;
  • 结论:JDK 7 HashMap非线程安全,多线程场景严禁使用(需用Hashtable或ConcurrentHashMap)。

四、JDK 7 HashMap的关键参数

参数名 默认值 作用
initialCapacity 16 初始数组长度(必须是2的幂次,若传入非2幂次,会自动调整为最近的2幂次)
loadFactor 0.75 负载因子:平衡空间与时间效率,0.75是权衡后的最优值(减少冲突+减少扩容)
threshold 12 扩容阈值(16*0.75),size超过12触发扩容
size 0 实际存储的键值对数量
modCount 0 结构性修改次数(用于Fail-Fast机制)

JDK8 HashMap如何实现?

JDK 8 HashMap 在 JDK 7 的基础上做了重大优化,核心解决了 JDK 7 扩容时的链表环、数据丢失等问题,同时提升了哈希冲突后的查询效率。其底层实现依然围绕「哈希计算→桶定位→冲突处理→扩容」展开,但在数据结构、冲突处理、扩容机制上均有升级,核心是 数组 + 链表 + 红黑树 的组合结构。

一、核心数据结构(核心升级点)

// HashMap 类中定义的关键常量
/**
 * 链表转红黑树的阈值:当桶中链表长度 ≥ 8 时,触发树化(前提是数组长度 ≥ 64)
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 红黑树转回链表的阈值:当红黑树节点数 ≤ 6 时,触发链表化
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 触发树化的最小数组长度:只有数组长度 ≥ 64 时,才会真正树化;否则只扩容
 */
static final int MIN_TREEIFY_CAPACITY = 64;

// HashMap 的链表转红黑树逻辑主要集中在 treeifyBin 方法,红黑树转回链表在 untreeify 方法,阈值定义在类常量中
/**
 * 将指定桶中的链表转换为红黑树(树化)
 * @param tab 哈希表数组
 * @param hash 桶的哈希值(对应数组索引)
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index;
    Node<K,V> e;
    // 核心判断:如果数组长度 < MIN_TREEIFY_CAPACITY(64),则不树化,而是扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize(); // 扩容(减少哈希冲突,替代树化)
    // 如果数组长度 ≥ 64,且桶中存在节点,则真正将链表转为红黑树
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        // 1. 将普通 Node 节点转为 TreeNode 节点
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        // 2. 将 TreeNode 链表替换原数组桶中的普通链表
        if ((tab[index] = hd) != null)
            hd.treeify(tab); // 3. 真正构建红黑树结构
    }
}

// 辅助方法:将普通 Node 转为 TreeNode
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

/**
 * 将红黑树转回普通链表(链表化)
 * @return 转回后的链表头节点
 */
final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    // 遍历红黑树节点,逐个转回普通 Node
    for (Node<K,V> q = this; q != null; q = q.next) {
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

// 辅助方法:将 TreeNode 转回普通 Node
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}

JDK 8 保留了 JDK 7「数组+链表」的基础结构,但新增了红黑树作为链表的升级形态,当链表长度超过阈值时,自动转为红黑树,大幅提升查询效率(链表查询时间复杂度 O(n),红黑树 O(logn))。

1. 数组(table)

类型改为 Node[](JDK 7 是 Entry[]),Node 是 HashMap 的内部类,替代了 JDK 7 的 Entry,核心属性与 Entry 类似,但新增了红黑树相关的指针(for 红黑树节点):

// 普通链表节点
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next; // 链表下一个节点

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

// 红黑树节点(继承自Node,用于链表转红黑树)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent; // 红黑树父节点
    TreeNode<K,V> left;   // 左子节点
    TreeNode<K,V> right;  // 右子节点
    TreeNode<K,V> prev;   // 用于回退链表(便于红黑树转链表)
    boolean red;          // 红黑树节点颜色(红/黑)
}
2. 红黑树触发条件

当同一个桶(数组索引)中的链表长度 ≥ 8,且数组长度 ≥ 64 时,链表自动转为红黑树;若数组长度 < 64,不会转红黑树,而是先触发扩容(减少冲突);当红黑树节点数量 ≤ 6 时,自动转回链表(节省空间)。

// 触发树化(链表化)的入口
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // ... 省略其他逻辑 ...

    // 遍历桶中链表,插入新节点后检查长度
    for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
            p.next = newNode(hash, key, value, null);
            // 关键:如果链表长度 ≥ 8,触发树化
            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 因为从 0 计数
                treeifyBin(tab, hash);
            break;
        }
        // ... 省略重复键判断逻辑 ...
    }
    // ... 省略扩容等其他逻辑 ...
}

二、核心操作流程(put/get,与JDK 7对比)

JDK 8 的 put/get 流程在 JDK 7 基础上优化了冲突处理和扩容逻辑,核心流程如下:

1. put方法(存储键值对,核心优化)

put 是 JDK 8 HashMap 最核心的方法,优化了哈希计算、冲突插入方式、红黑树转换和扩容逻辑,流程如下:

关键步骤拆解(与JDK 7对比)

  • 哈希值计算(简化扰动):JDK 7 做了4次扰动(移位+异或),JDK 8 简化为1次异或,减少计算开销,同时保证哈希分布均匀: static final int hash(Object key) { ` int h; // 核心:key.hashCode() 异或 高位哈希(h >>> 16),让高位参与计算 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }` 解释:将 hashCode 的高位(16位)与低位(16位)异或,让高位哈希值也参与桶索引计算,减少因高位未参与导致的哈希冲突。
  • 冲突处理(尾插法替代头插法):JDK 7 用头插法,多线程扩容易形成链表环;JDK 8 用尾插法,保证链表顺序,彻底解决扩容时的链表环和数据丢失问题(但仍非线程安全,多线程插入仍可能覆盖数据)。
  • 红黑树转换:当链表长度≥8且数组长度≥64时,转为红黑树,提升查询效率;避免小数组时转红黑树(红黑树占用空间比链表大,小数据量反而不划算)。

2. get方法(获取键值对,优化查询)

流程与 JDK 7 类似,但新增了红黑树的查询逻辑,效率更高:

三、核心机制:扩容(resize,重大优化)

JDK 8 扩容机制在 JDK 7 基础上做了两大优化:解决链表环问题、提升扩容效率,核心流程与 JDK 7 一致(新建2倍长度数组、迁移数据、更新阈值),但数据迁移逻辑大幅优化。

1. 扩容触发条件(与JDK 7一致)

size(实际存储的键值对数量) > threshold(阈值) 时触发扩容,阈值计算公式:threshold = table.length * loadFactor(默认0.75);此外,当链表长度≥8但数组长度<64时,也会触发扩容(而非直接转红黑树)。

2. 扩容核心优化:数据迁移(无需重新计算哈希)

JDK 7 扩容时,需要对每个节点重新计算哈希值,再定位新桶索引;JDK 8 利用「数组长度是2的幂次」的特性,无需重新计算哈希,直接通过「哈希值的高位bit」判断迁移方向:

  • 原数组长度为oldCap,新数组长度为 newCap = oldCap * 2
  • 对于每个节点的哈希值 hash,判断 hash & oldCap 的结果:
    • 结果为0:节点留在原桶对应的新桶(索引不变);
    • 结果为1:节点迁移到新桶(索引 = 原索引 + oldCap);

优势:无需重新计算哈希,仅通过一次位运算就能确定迁移方向,大幅提升扩容效率;同时,尾插法迁移数据,避免链表环问题。

3. 扩容后的红黑树处理

若原桶中是红黑树,扩容后会判断红黑树节点数量:若节点数量 ≤ 6,自动转回链表;否则,将红黑树拆分到两个新桶中,分别形成新的红黑树(或链表)。

四、JDK 8 HashMap 的关键参数(与JDK 7对比)

参数名 默认值 作用 与JDK 7差异
initialCapacity 16 初始数组长度(必须是2的幂次) 无差异
loadFactor 0.75 负载因子,平衡空间与时间效率 无差异
threshold 12(16*0.75) 扩容阈值 无差异,但触发红黑树转换时会额外扩容
size 0 实际存储的键值对数量 无差异
modCount 0 结构性修改次数(Fail-Fast机制) 无差异
TREEIFY_THRESHOLD 8 链表转红黑树的阈值 JDK 7 无此参数
UNTREEIFY_THRESHOLD 6 红黑树转链表的阈值 JDK 7 无此参数
MIN_TREEIFY_CAPACITY 64 链表转红黑树的最小数组长度 JDK 7 无此参数

五、JDK 8 HashMap 的核心特点(与JDK 7对比)

1. 优点

  • 解决了 JDK 7 扩容时的链表环、数据丢失问题(尾插法);
  • 哈希冲突严重时(链表过长),自动转为红黑树,查询效率从 O(n) 提升到 O(logn);
  • 简化哈希计算,扩容时无需重新计算哈希,提升扩容效率;
  • 支持 null key 和 null value(null key 固定存放在索引0的桶中)。

2. 缺点(仍存在)

  • 非线程安全:多线程并发插入、扩容时,仍可能出现数据覆盖(如两个线程同时插入同一个桶,尾插法可能覆盖节点);
  • 红黑树转换有开销:链表转红黑树、红黑树转链表,以及红黑树的插入、平衡操作,均有一定性能开销(但总体利大于弊)。

六、JDK 7 与 JDK 8 HashMap 核心差异总结

对比维度 JDK 7 HashMap JDK 8 HashMap
核心数据结构 数组 + 链表 数组 + 链表 + 红黑树
冲突处理方式 头插法(多线程扩容易出问题) 尾插法(解决链表环、数据丢失)
哈希计算 4次扰动(移位+异或) 1次扰动(hashCode ^ 高位哈希)
扩容数据迁移 重新计算哈希值定位新桶 位运算判断迁移方向(无需重新哈希)
查询效率(冲突严重时) O(n)(链表) O(logn)(红黑树)
线程安全问题 扩容易形成链表环、数据丢失 无链表环,仍可能数据覆盖(非线程安全)

总结

JDK 8 HashMap 是对 JDK 7 的针对性优化,核心目标是 提升查询效率、解决扩容时的线程安全隐患(部分)

  1. 核心结构:数组+链表+红黑树,链表过长时转红黑树,平衡查询效率和空间开销;
  2. 核心优化:尾插法解决链表环,简化哈希计算、优化扩容迁移,提升性能;
  3. 注意点:仍非线程安全,多线程场景需用 ConcurrentHashMap;红黑树转换有一定开销,适用于哈希冲突较多的场景;
  4. 设计核心:依然遵循「数组长度为2的幂次」「负载因子0.75」的原则,平衡空间与时间效率。

JDK 7与JDK 8 HashMap的核心差异

维度 JDK 7 HashMap JDK 8 HashMap
数据结构 数组 + 链表 数组 + 链表 + 红黑树(链表长度≥8时转红黑树)
冲突处理 头插法 尾插法(避免扩容时链表环问题)
哈希计算 多次扰动(4次异或+移位) 简化扰动(1次异或)
扩容迁移 重新计算哈希值 利用高位哈希值,直接判断迁移方向(效率更高)
失败机制 Fail-Fast Fail-Fast
线程安全问题 扩容易形成链表环 无链表环问题,但仍非线程安全

HashSet是如何实现的?

HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成合适的HashMap方法

并发

并发基础

多线程的出现是要解决什么问题的? 本质什么?

CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异;// 导致可见性问题
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致原子性问题
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致有序性问题

IO

JVM


YOLO