Java 后端开发面试 50 个高频易混淆知识点详解

一、Java 基础(10 个)

1. == 和 equals () 的区别

核心区别==比较的是内存地址 (引用是否指向同一个对象),而equals()默认也是比较地址,但可以被重写为比较对象内容

对于基本数据类型(byte、short、int、long、float、double、char、boolean),==只能比较值是否相等,因为基本类型没有引用。对于引用类型,==比较的是两个引用是否指向堆内存中的同一个对象。

Object类的equals()方法默认实现就是return (this == obj),直接比较地址。但很多类(如 String、Integer、Date)都重写了equals()方法,改为比较对象的内容是否相等。

示例

复制代码
String s1 = new String("abc");
String s2 = new String("abc");
System.out.println(s1 == s2); // false,两个不同的对象,地址不同
System.out.println(s1.equals(s2)); // true,String重写了equals,比较内容

String s3 = "abc";
String s4 = "abc";
System.out.println(s3 == s4); // true,字符串常量池中的同一个对象
System.out.println(s3.equals(s4)); // true

Integer i1 = 127;
Integer i2 = 127;
System.out.println(i1 == i2); // true,Integer缓存了-128~127的对象
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4); // false,超出缓存范围,创建新对象

2. String、StringBuffer 和 StringBuilder 的区别

核心区别可变性线程安全性性能

String是不可变类,每次对String的修改(如拼接、替换)都会创建一个新的String对象,原对象不变。这是因为String类被final修饰,且底层存储字符的char[]数组也被final修饰。

StringBufferStringBuilder都是可变的字符序列,底层是没有被final修饰的char[]数组,修改时不会创建新对象。StringBuffer是线程安全的,它的所有方法都被synchronized修饰,适合多线程环境;StringBuilder是线程不安全的,没有同步锁,性能更高,适合单线程环境。

性能排序StringBuilder > StringBuffer > String(频繁修改时)

示例

复制代码
// String拼接会产生大量临时对象,性能差
String str = "";
for (int i = 0; i < 10000; i++) {
    str += i; // 每次都创建新的String对象
}

// StringBuilder拼接效率高
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i); // 直接修改底层数组
}
String result = sb.toString();

// StringBuffer适合多线程
StringBuffer sbf = new StringBuffer();
new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        sbf.append("A");
    }
}).start();
new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        sbf.append("B");
    }
}).start();

3. 接口和抽象类的区别

核心区别设计目的方法实现继承限制成员变量

抽象类是对类的抽象,是一种 "is-a" 的关系,用于描述一类事物的共同属性和行为。抽象类可以包含抽象方法(没有实现)和非抽象方法(有实现),可以有构造方法,成员变量可以是各种类型,子类只能继承一个抽象类(Java 单继承)。

接口是对行为的抽象,是一种 "can-do" 的关系,用于定义一组规范。JDK8 之前,接口只能包含抽象方法和常量(public static final);JDK8 之后,接口可以有默认方法(default)和静态方法(static);JDK9 之后,接口可以有私有方法。一个类可以实现多个接口(Java 多实现)。

示例

复制代码
// 抽象类:动物(is-a关系)
abstract class Animal {
    String name;
    
    // 构造方法
    public Animal(String name) {
        this.name = name;
    }
    
    // 抽象方法:所有动物都要吃东西,但具体吃什么不同
    public abstract void eat();
    
    // 非抽象方法:所有动物都有呼吸的共同行为
    public void breathe() {
        System.out.println(name + "在呼吸");
    }
}

// 接口:会飞(can-do关系)
interface Flyable {
    // 常量
    int MAX_SPEED = 1000;
    
    // 抽象方法
    void fly();
    
    // 默认方法(JDK8+)
    default void glide() {
        System.out.println("正在滑翔");
    }
}

// 子类:狗继承动物,不能飞
class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }
    
    @Override
    public void eat() {
        System.out.println(name + "在吃骨头");
    }
}

// 子类:鸟继承动物,实现会飞接口
class Bird extends Animal implements Flyable {
    public Bird(String name) {
        super(name);
    }
    
    @Override
    public void eat() {
        System.out.println(name + "在吃虫子");
    }
    
    @Override
    public void fly() {
        System.out.println(name + "在飞翔,速度不超过" + MAX_SPEED + "km/h");
    }
}

4. 重载 (Overload) 和重写 (Override) 的区别

核心区别发生位置方法签名返回值多态类型

重载发生在同一个类 中,方法名相同,但参数列表不同 (参数个数、类型、顺序不同)。重载与返回值类型无关,与访问修饰符无关,是编译时多态(静态多态),编译器在编译时根据参数列表确定调用哪个方法。

重写发生在子类和父类 之间,子类重写父类的方法,要求方法名、参数列表、返回值类型 都必须相同(返回值可以是父类返回值的子类,即协变返回类型)。重写的方法不能拥有比父类更严格的访问权限,不能抛出比父类更宽泛的受检异常,是运行时多态(动态多态),JVM 在运行时根据对象的实际类型确定调用哪个方法。

示例

复制代码
class Calculator {
    // 重载:同一个类中,方法名相同,参数列表不同
    public int add(int a, int b) {
        return a + b;
    }
    
    public double add(double a, double b) {
        return a + b;
    }
    
    public int add(int a, int b, int c) {
        return a + b + c;
    }
}

class Animal {
    public void makeSound() {
        System.out.println("动物发出声音");
    }
    
    public Animal getInstance() {
        return new Animal();
    }
}

class Cat extends Animal {
    // 重写:子类重写父类方法,方法名、参数列表相同
    @Override
    public void makeSound() {
        System.out.println("猫发出喵喵声");
    }
    
    // 协变返回类型:返回值可以是父类返回值的子类
    @Override
    public Cat getInstance() {
        return new Cat();
    }
}

public class Test {
    public static void main(String[] args) {
        // 编译时多态:重载
        Calculator cal = new Calculator();
        System.out.println(cal.add(1, 2)); // 调用int add(int, int)
        System.out.println(cal.add(1.5, 2.5)); // 调用double add(double, double)
        
        // 运行时多态:重写
        Animal animal = new Cat();
        animal.makeSound(); // 调用Cat的makeSound(),而不是Animal的
    }
}

5. 静态变量和实例变量的区别

核心区别存储位置生命周期访问方式共享性

静态变量(类变量)用static修饰,属于 ,而不是属于某个对象。它存储在方法区(JDK8 及以后是元空间),在类加载时初始化,生命周期与类相同,只要类被加载,静态变量就存在。所有对象共享同一个静态变量,一个对象修改了静态变量,其他对象看到的都是修改后的值。

实例变量(成员变量)不用static修饰,属于对象 。它存储在堆内存中,在创建对象时初始化,生命周期与对象相同,对象被垃圾回收时,实例变量也随之消失。每个对象都有自己的实例变量副本,互不影响。

示例

复制代码
class Student {
    // 静态变量:所有学生共享同一个学校
    public static String school = "清华大学";
    
    // 实例变量:每个学生有自己的名字和年龄
    public String name;
    public int age;
    
    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

public class Test {
    public static void main(String[] args) {
        Student s1 = new Student("张三", 20);
        Student s2 = new Student("李四", 21);
        
        // 访问静态变量:推荐用类名访问
        System.out.println(Student.school); // 清华大学
        System.out.println(s1.school); // 清华大学
        System.out.println(s2.school); // 清华大学
        
        // 修改静态变量:所有对象都能看到变化
        Student.school = "北京大学";
        System.out.println(s1.school); // 北京大学
        System.out.println(s2.school); // 北京大学
        
        // 修改实例变量:只影响当前对象
        s1.age = 22;
        System.out.println(s1.age); // 22
        System.out.println(s2.age); // 21
    }
}

6. final、finally 和 finalize 的区别

核心区别作用使用场景执行时机

final关键字,用于修饰类、方法和变量。修饰类时,类不能被继承;修饰方法时,方法不能被重写;修饰变量时,变量是常量,只能赋值一次(基本类型值不可变,引用类型引用不可变,但对象内容可以变)。

finally异常处理语句块 ,用于在try-catch语句中执行必须执行的代码,无论是否发生异常,finally块都会执行(除非调用System.exit(0)终止 JVM)。通常用于释放资源,如关闭数据库连接、文件流等。

finalize()Object 类的方法,在对象被垃圾回收器回收之前调用,用于执行清理工作。但它的执行时机不确定,甚至可能永远不会执行,不推荐依赖它来释放资源。

示例

复制代码
// final修饰类:不能被继承
final class FinalClass {
    // final修饰变量:常量
    public static final int MAX_VALUE = 100;
    
    // final修饰方法:不能被重写
    public final void finalMethod() {
        System.out.println("这是final方法");
    }
}

public class Test {
    public static void main(String[] args) {
        // final修饰引用类型:引用不可变,但对象内容可变
        final List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        System.out.println(list); // [a, b]
        // list = new ArrayList<>(); // 编译错误:引用不能重新赋值
        
        // finally块
        try {
            int a = 10 / 0;
        } catch (ArithmeticException e) {
            System.out.println("发生算术异常");
        } finally {
            System.out.println("finally块一定会执行");
        }
    }
    
    // finalize()方法
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("对象被垃圾回收了");
    }
}

7. 深拷贝和浅拷贝的区别

核心区别拷贝对象的深度引用类型成员的处理方式

浅拷贝只拷贝对象本身,不拷贝对象中的引用类型成员,拷贝后的对象和原对象共享引用类型成员。也就是说,浅拷贝创建了一个新对象,但新对象的引用类型成员仍然指向原对象的引用类型成员。如果修改了引用类型成员的内容,原对象和拷贝对象都会受到影响。

深拷贝不仅拷贝对象本身,还拷贝对象中的所有引用类型成员,递归地拷贝所有对象。拷贝后的对象和原对象完全独立,互不影响。

实现方式 :浅拷贝可以通过实现Cloneable接口并重写clone()方法实现;深拷贝可以通过重写clone()方法递归拷贝引用类型成员,或者通过序列化和反序列化实现。

示例

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

// 浅拷贝
class Person implements Cloneable {
    String name;
    Address address;
    
    public Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }
    
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone(); // 浅拷贝:只拷贝Person对象,不拷贝Address对象
    }
}

// 深拷贝
class DeepPerson implements Cloneable {
    String name;
    Address address;
    
    public DeepPerson(String name, Address address) {
        this.name = name;
        this.address = address;
    }
    
    @Override
    protected Object clone() throws CloneNotSupportedException {
        DeepPerson person = (DeepPerson) super.clone();
        // 递归拷贝Address对象
        person.address = new Address(this.address.city);
        return person;
    }
}

public class Test {
    public static void main(String[] args) throws CloneNotSupportedException {
        Address addr = new Address("北京");
        
        // 浅拷贝测试
        Person p1 = new Person("张三", addr);
        Person p2 = (Person) p1.clone();
        p2.address.city = "上海";
        System.out.println(p1.address.city); // 上海,原对象也被修改了
        
        // 深拷贝测试
        DeepPerson dp1 = new DeepPerson("李四", new Address("北京"));
        DeepPerson dp2 = (DeepPerson) dp1.clone();
        dp2.address.city = "上海";
        System.out.println(dp1.address.city); // 北京,原对象不受影响
    }
}

8. 受检异常和非受检异常的区别

核心区别是否需要显式处理继承关系设计目的

受检异常(Checked Exception)是Exception及其子类(除了RuntimeException及其子类),必须在代码中显式处理,要么用try-catch捕获,要么用throws声明抛出,否则编译不通过。受检异常表示程序可以预见的、可以恢复的错误,如IOExceptionSQLException等。

非受检异常(Unchecked Exception)包括RuntimeException及其子类和Error及其子类,不需要显式处理,编译器不会检查。非受检异常表示程序运行时的错误,通常是由程序逻辑错误引起的,如NullPointerExceptionArrayIndexOutOfBoundsExceptionArithmeticException等。Error表示 JVM 级别的严重错误,如OutOfMemoryErrorStackOverflowError等,程序无法处理。

示例

复制代码
public class ExceptionTest {
    // 受检异常:必须声明throws或捕获
    public void readFile(String path) throws IOException {
        FileReader fr = new FileReader(path);
        BufferedReader br = new BufferedReader(fr);
        String line;
        while ((line = br.readLine()) != null) {
            System.out.println(line);
        }
        br.close();
    }
    
    // 非受检异常:不需要声明throws
    public void divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("除数不能为0");
        }
        System.out.println(a / b);
    }
    
    public static void main(String[] args) {
        ExceptionTest test = new ExceptionTest();
        
        // 调用受检异常方法:必须捕获或继续抛出
        try {
            test.readFile("test.txt");
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        // 调用非受检异常方法:可以不处理,但运行时可能抛出
        test.divide(10, 0); // 运行时抛出ArithmeticException
    }
}

9. 自动装箱和拆箱的原理及陷阱

自动装箱 是指 Java 自动将基本数据类型转换为对应的包装类类型,如intInteger自动拆箱 是指 Java 自动将包装类类型转换为对应的基本数据类型,如Integerint

原理 :自动装箱是通过调用包装类的valueOf()方法实现的,自动拆箱是通过调用包装类的xxxValue()方法实现的(如intValue()doubleValue())。编译器在编译时会自动插入这些方法调用。

常见陷阱

  1. 包装类的缓存机制:IntegerByteShortCharacterLong都有缓存机制,缓存了一定范围内的值(如Integer缓存 - 128~127),超出范围会创建新对象,导致==比较结果不同。
  2. 空指针异常:包装类对象为null时,自动拆箱会抛出NullPointerException
  3. 性能问题:频繁的自动装箱和拆箱会创建大量对象,影响性能。

示例

复制代码
public class BoxTest {
    public static void main(String[] args) {
        // 自动装箱:等价于Integer i = Integer.valueOf(10);
        Integer i = 10;
        // 自动拆箱:等价于int j = i.intValue();
        int j = i;
        
        // 缓存陷阱
        Integer a = 127;
        Integer b = 127;
        System.out.println(a == b); // true,缓存范围内的同一个对象
        
        Integer c = 128;
        Integer d = 128;
        System.out.println(c == d); // false,超出缓存范围,创建新对象
        
        // 空指针陷阱
        Integer e = null;
        // int f = e; // 运行时抛出NullPointerException
        
        // 性能陷阱
        long start = System.currentTimeMillis();
        Integer sum = 0;
        for (int k = 0; k < 1000000; k++) {
            sum += k; // 每次都自动装箱和拆箱
        }
        long end = System.currentTimeMillis();
        System.out.println("耗时:" + (end - start) + "ms"); // 耗时较长
        
        start = System.currentTimeMillis();
        int sum2 = 0;
        for (int k = 0; k < 1000000; k++) {
            sum2 += k; // 基本类型运算,无装箱拆箱
        }
        end = System.currentTimeMillis();
        System.out.println("耗时:" + (end - start) + "ms"); // 耗时很短
    }
}

10. Java 中的四种引用类型

Java 中有四种引用类型,从强到弱依次是:强引用软引用弱引用虚引用,它们的区别在于垃圾回收器对它们的处理方式不同。

强引用 :最常见的引用类型,如Object obj = new Object()。只要强引用存在,垃圾回收器永远不会回收被引用的对象,即使内存不足,JVM 也会抛出OutOfMemoryError而不会回收强引用对象。

软引用SoftReference):用来描述一些有用但非必需的对象。当内存充足时,垃圾回收器不会回收软引用对象;当内存不足时,垃圾回收器会回收软引用对象。软引用适合用来实现缓存,如图片缓存、网页缓存等。

弱引用WeakReference):用来描述非必需的对象,强度比软引用更弱。无论内存是否充足,垃圾回收器在扫描时只要发现弱引用对象,就会回收它。弱引用适合用来实现临时缓存,如 ThreadLocal 中的 key。

虚引用PhantomReference):最弱的引用类型,无法通过虚引用获取对象实例。虚引用的唯一作用是在对象被垃圾回收时收到一个系统通知,用于跟踪对象的垃圾回收过程。虚引用必须和引用队列(ReferenceQueue)一起使用。

示例

复制代码
public class ReferenceTest {
    public static void main(String[] args) {
        // 强引用
        Object strongRef = new Object();
        System.out.println(strongRef); // java.lang.Object@1b6d3586
        
        // 软引用
        SoftReference<Object> softRef = new SoftReference<>(new Object());
        System.out.println(softRef.get()); // java.lang.Object@4554617c
        // 内存不足时会被回收
        
        // 弱引用
        WeakReference<Object> weakRef = new WeakReference<>(new Object());
        System.out.println(weakRef.get()); // java.lang.Object@74a14482
        System.gc(); // 主动触发垃圾回收
        System.out.println(weakRef.get()); // null,已经被回收
        
        // 虚引用
        ReferenceQueue<Object> queue = new ReferenceQueue<>();
        PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
        System.out.println(phantomRef.get()); // null,无法获取对象
        // 对象被回收时,虚引用会被加入到引用队列中
    }
}

二、集合框架(10 个)

11. ArrayList 和 LinkedList 的区别

核心区别底层数据结构访问效率增删效率内存占用

ArrayList底层是动态数组 ,默认初始容量为 10,当元素个数超过容量时,会自动扩容为原来的 1.5 倍。ArrayList支持随机访问,通过索引访问元素的时间复杂度是 O (1),但在中间或头部插入、删除元素时,需要移动后面的所有元素,时间复杂度是 O (n)。ArrayList的内存占用较小,因为每个元素只需要存储数据本身。

LinkedList底层是双向链表 ,每个节点包含数据和指向前一个节点和后一个节点的引用。LinkedList不支持随机访问,通过索引访问元素需要从头或尾遍历链表,时间复杂度是 O (n),但在中间或头部插入、删除元素时,只需要修改节点的引用,时间复杂度是 O (1)。LinkedList的内存占用较大,因为每个节点需要额外存储两个引用。

使用场景ArrayList适合频繁查询的场景;LinkedList适合频繁增删的场景。

示例

复制代码
public class ListTest {
    public static void main(String[] args) {
        List<Integer> arrayList = new ArrayList<>();
        List<Integer> linkedList = new LinkedList<>();
        
        // 尾部添加:两者效率差不多
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            arrayList.add(i);
        }
        long end = System.currentTimeMillis();
        System.out.println("ArrayList尾部添加耗时:" + (end - start) + "ms");
        
        start = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            linkedList.add(i);
        }
        end = System.currentTimeMillis();
        System.out.println("LinkedList尾部添加耗时:" + (end - start) + "ms");
        
        // 随机访问:ArrayList快很多
        start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            arrayList.get(i);
        }
        end = System.currentTimeMillis();
        System.out.println("ArrayList随机访问耗时:" + (end - start) + "ms");
        
        start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            linkedList.get(i);
        }
        end = System.currentTimeMillis();
        System.out.println("LinkedList随机访问耗时:" + (end - start) + "ms");
        
        // 头部添加:LinkedList快很多
        start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            arrayList.add(0, i);
        }
        end = System.currentTimeMillis();
        System.out.println("ArrayList头部添加耗时:" + (end - start) + "ms");
        
        start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            linkedList.add(0, i);
        }
        end = System.currentTimeMillis();
        System.out.println("LinkedList头部添加耗时:" + (end - start) + "ms");
    }
}

12. HashMap 和 HashTable 的区别

核心区别线程安全性性能null 值支持继承关系

HashMap是线程不安全的,没有同步锁,多线程环境下可能会出现数据不一致的问题,甚至在 JDK1.7 中会出现死循环。HashMap的性能较高,因为没有同步开销。HashMap允许keynull(只能有一个),valuenull(可以有多个)。HashMap继承自AbstractMap类。

HashTable是线程安全的,它的所有方法都被synchronized修饰,多线程环境下可以直接使用,但性能较低,因为每次操作都要获取锁。HashTable不允许keyvaluenull,否则会抛出NullPointerExceptionHashTable继承自Dictionary类,是一个遗留类,不推荐使用。

替代方案 :多线程环境下,推荐使用ConcurrentHashMap,它的性能比HashTable高很多。

示例

复制代码
public class MapTest {
    public static void main(String[] args) {
        // HashMap允许null键和null值
        HashMap<String, String> hashMap = new HashMap<>();
        hashMap.put(null, "null key");
        hashMap.put("key", null);
        System.out.println(hashMap.get(null)); // null key
        System.out.println(hashMap.get("key")); // null
        
        // HashTable不允许null键和null值
        Hashtable<String, String> hashtable = new Hashtable<>();
        // hashtable.put(null, "null key"); // 运行时抛出NullPointerException
        // hashtable.put("key", null); // 运行时抛出NullPointerException
        
        // 多线程测试
        HashMap<Integer, Integer> unsafeMap = new HashMap<>();
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            final int num = i;
            executor.submit(() -> {
                unsafeMap.put(num, num);
            });
        }
        executor.shutdown();
        // 可能会出现元素丢失、数据不一致甚至死循环
        System.out.println("HashMap大小:" + unsafeMap.size()); // 可能小于1000
        
        ConcurrentHashMap<Integer, Integer> safeMap = new ConcurrentHashMap<>();
        executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            final int num = i;
            executor.submit(() -> {
                safeMap.put(num, num);
            });
        }
        executor.shutdown();
        System.out.println("ConcurrentHashMap大小:" + safeMap.size()); // 一定是1000
    }
}

13. HashMap 和 TreeMap 的区别

核心区别底层数据结构有序性性能key 要求

HashMap底层是数组 + 链表 + 红黑树 (JDK1.8 及以后),它是无序的,元素的顺序和插入顺序无关。HashMap的增删改查操作的平均时间复杂度是 O (1),性能很高。HashMapkey可以是任意对象,但必须重写hashCode()equals()方法。

TreeMap底层是红黑树 ,它是有序的,默认按照key的自然顺序排序,也可以通过传入Comparator自定义排序规则。TreeMap的增删改查操作的时间复杂度是 O (log n),性能比HashMap低。TreeMapkey必须实现Comparable接口,或者在构造时传入Comparator,否则会抛出ClassCastException

使用场景HashMap适合大多数需要键值对存储的场景;TreeMap适合需要按照key排序的场景。

示例

复制代码
public class MapSortTest {
    public static void main(String[] args) {
        // HashMap:无序
        HashMap<Integer, String> hashMap = new HashMap<>();
        hashMap.put(3, "C");
        hashMap.put(1, "A");
        hashMap.put(2, "B");
        System.out.println("HashMap:" + hashMap); // {1=A, 2=B, 3=C} 或其他顺序
        
        // TreeMap:自然排序(升序)
        TreeMap<Integer, String> treeMap = new TreeMap<>();
        treeMap.put(3, "C");
        treeMap.put(1, "A");
        treeMap.put(2, "B");
        System.out.println("TreeMap自然排序:" + treeMap); // {1=A, 2=B, 3=C}
        
        // TreeMap:自定义排序(降序)
        TreeMap<Integer, String> descTreeMap = new TreeMap<>(Collections.reverseOrder());
        descTreeMap.put(3, "C");
        descTreeMap.put(1, "A");
        descTreeMap.put(2, "B");
        System.out.println("TreeMap降序排序:" + descTreeMap); // {3=C, 2=B, 1=A}
        
        // 自定义对象作为key
        class Student {
            int id;
            String name;
            
            public Student(int id, String name) {
                this.id = id;
                this.name = name;
            }
            
            @Override
            public String toString() {
                return "Student{id=" + id + ", name='" + name + "'}";
            }
        }
        
        // HashMap:需要重写hashCode和equals
        HashMap<Student, String> studentHashMap = new HashMap<>();
        studentHashMap.put(new Student(1, "张三"), "一班");
        studentHashMap.put(new Student(2, "李四"), "二班");
        System.out.println(studentHashMap);
        
        // TreeMap:需要实现Comparable或传入Comparator
        TreeMap<Student, String> studentTreeMap = new TreeMap<>((s1, s2) -> s1.id - s2.id);
        studentTreeMap.put(new Student(2, "李四"), "二班");
        studentTreeMap.put(new Student(1, "张三"), "一班");
        System.out.println(studentTreeMap); // 按id升序排列
    }
}

14. HashSet 和 TreeSet 的区别

核心区别底层实现有序性性能元素要求

HashSet底层是基于HashMap实现的,它存储的元素是无序的,不允许重复元素。HashSet的增删改查操作的平均时间复杂度是 O (1),性能很高。HashSet的元素可以是任意对象,但必须重写hashCode()equals()方法。

TreeSet底层是基于TreeMap实现的,它存储的元素是有序的,默认按照元素的自然顺序排序,也可以通过传入Comparator自定义排序规则。TreeSet的增删改查操作的时间复杂度是 O (log n),性能比HashSet低。TreeSet的元素必须实现Comparable接口,或者在构造时传入Comparator,否则会抛出ClassCastException

注意HashSetTreeSet都不允许重复元素,判断元素是否重复的方式不同。HashSet通过hashCode()equals()方法判断;TreeSet通过compareTo()compare()方法判断。

示例

复制代码
public class SetTest {
    public static void main(String[] args) {
        // HashSet:无序,不允许重复
        HashSet<String> hashSet = new HashSet<>();
        hashSet.add("B");
        hashSet.add("A");
        hashSet.add("C");
        hashSet.add("A"); // 重复元素,添加失败
        System.out.println("HashSet:" + hashSet); // [A, B, C] 或其他顺序
        
        // TreeSet:自然排序,不允许重复
        TreeSet<String> treeSet = new TreeSet<>();
        treeSet.add("B");
        treeSet.add("A");
        treeSet.add("C");
        treeSet.add("A"); // 重复元素,添加失败
        System.out.println("TreeSet自然排序:" + treeSet); // [A, B, C]
        
        // TreeSet:自定义排序
        TreeSet<String> descTreeSet = new TreeSet<>(Collections.reverseOrder());
        descTreeSet.add("B");
        descTreeSet.add("A");
        descTreeSet.add("C");
        System.out.println("TreeSet降序排序:" + descTreeSet); // [C, B, A]
        
        // 自定义对象
        class Person {
            int age;
            String name;
            
            public Person(int age, String name) {
                this.age = age;
                this.name = name;
            }
            
            @Override
            public String toString() {
                return "Person{age=" + age + ", name='" + name + "'}";
            }
            
            // HashSet需要重写hashCode和equals
            @Override
            public boolean equals(Object o) {
                if (this == o) return true;
                if (o == null || getClass() != o.getClass()) return false;
                Person person = (Person) o;
                return age == person.age && Objects.equals(name, person.name);
            }
            
            @Override
            public int hashCode() {
                return Objects.hash(age, name);
            }
        }
        
        HashSet<Person> personHashSet = new HashSet<>();
        personHashSet.add(new Person(20, "张三"));
        personHashSet.add(new Person(21, "李四"));
        personHashSet.add(new Person(20, "张三")); // 重复,添加失败
        System.out.println(personHashSet);
        
        TreeSet<Person> personTreeSet = new TreeSet<>((p1, p2) -> p1.age - p2.age);
        personTreeSet.add(new Person(21, "李四"));
        personTreeSet.add(new Person(20, "张三"));
        personTreeSet.add(new Person(20, "王五")); // age相同,被认为是重复元素,添加失败
        System.out.println(personTreeSet); // 按age升序排列
    }
}

15. HashMap 的工作原理(JDK1.7 vs JDK1.8)

HashMap的核心是哈希表 ,它通过哈希函数将key映射到数组的索引位置,从而实现 O (1) 时间复杂度的查找。

JDK1.7 的实现

  • 底层是数组 + 链表 ,数组是Entry[],每个Entry包含keyvaluehashnext引用。
  • 当两个key的哈希值相同时,会发生哈希冲突,JDK1.7 采用拉链法解决冲突,将冲突的元素以链表的形式存储在同一个数组位置。
  • 插入元素时采用头插法,即新元素插入到链表的头部。
  • 当元素个数超过负载因子(默认 0.75)× 数组容量时,会触发扩容,扩容为原来的 2 倍。扩容时需要重新计算所有元素的哈希值和索引位置,然后复制到新数组中。

JDK1.8 的改进

  • 底层是数组 + 链表 + 红黑树,当链表长度超过 ** 阈值(默认 8)** 且数组容量大于等于 64 时,链表会转换为红黑树,以提高查找效率(链表查找时间复杂度 O (n),红黑树 O (log n))。
  • 插入元素时采用尾插法,避免了多线程环境下的死循环问题(但仍然是线程不安全的)。
  • 哈希函数优化:hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16),将高 16 位和低 16 位异或,使哈希值分布更均匀。
  • 扩容优化:不需要重新计算哈希值,而是通过e.hash & oldCap判断元素在新数组中的位置,要么在原位置,要么在原位置 + 旧容量的位置。

示例

复制代码
public class HashMapPrinciple {
    public static void main(String[] args) {
        // 模拟HashMap的哈希计算
        String key = "abc";
        int h = key.hashCode();
        int hash = h ^ (h >>> 16); // JDK1.8的哈希函数
        System.out.println("hashCode: " + h);
        System.out.println("hash: " + hash);
        
        // 模拟索引计算:hash & (length - 1)
        int length = 16; // 默认初始容量
        int index = hash & (length - 1);
        System.out.println("索引位置: " + index);
        
        // 扩容时的索引计算
        int oldCap = 16;
        int newCap = 32;
        if ((hash & oldCap) == 0) {
            // 新索引 = 原索引
            System.out.println("新索引: " + index);
        } else {
            // 新索引 = 原索引 + 旧容量
            System.out.println("新索引: " + (index + oldCap));
        }
    }
}

16. ConcurrentHashMap 的实现原理(JDK1.7 vs JDK1.8)

ConcurrentHashMap是线程安全的HashMap,它通过分段锁(JDK1.7)和 CAS+synchronized(JDK1.8)实现线程安全,性能比HashTable高很多。

JDK1.7 的实现

  • 底层是分段数组 + 链表 ,整个哈希表被分成多个Segment(段),每个Segment是一个独立的哈希表,有自己的锁。
  • 每个Segment包含一个HashEntry[]数组,每个HashEntry是一个链表节点。
  • 当进行写操作时,只需要锁住对应的Segment,而不是整个哈希表,其他Segment的操作不受影响,提高了并发度。
  • 默认有 16 个Segment,最多支持 16 个线程同时写操作。
  • 读操作不加锁,因为HashEntryvaluevolatile的,保证了可见性。

JDK1.8 的改进

  • 底层是数组 + 链表 + 红黑树 ,和 JDK1.8 的HashMap结构相同,抛弃了分段锁的设计。
  • 采用CAS+synchronized 实现线程安全,当数组位置为空时,使用 CAS 插入元素;当数组位置不为空时,使用synchronized锁住该位置的头节点,然后进行插入或修改操作。
  • 锁的粒度更细,只锁住链表或红黑树的头节点,而不是整个段,并发度更高。
  • 当链表长度超过 8 且数组容量大于等于 64 时,链表转换为红黑树,提高查找效率。
  • 引入了sizeCtl变量,用于控制数组的初始化和扩容,使用 CAS 操作保证线程安全。

示例

复制代码
public class ConcurrentHashMapTest {
    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        
        // 多线程写操作
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            final int num = i;
            executor.submit(() -> {
                map.put("key" + num, num);
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println("map大小:" + map.size()); // 1000
        
        // 多线程读操作
        executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            final int num = i;
            executor.submit(() -> {
                Integer value = map.get("key" + num);
                if (value == null || value != num) {
                    System.out.println("数据不一致:key" + num + "=" + value);
                }
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println("所有读操作完成");
    }
}

17. ArrayList 和 Vector 的区别

核心区别线程安全性性能扩容机制

ArrayList是线程不安全的,没有同步锁,多线程环境下可能会出现数据不一致的问题。ArrayList的性能较高,因为没有同步开销。ArrayList的默认初始容量是 10,扩容时会扩容为原来的 1.5 倍。

Vector是线程安全的,它的所有方法都被synchronized修饰,多线程环境下可以直接使用,但性能较低,因为每次操作都要获取锁。Vector的默认初始容量是 10,扩容时默认会扩容为原来的 2 倍,也可以在构造时指定扩容增量。

注意Vector是一个遗留类,不推荐使用。多线程环境下,推荐使用Collections.synchronizedList(new ArrayList())或者CopyOnWriteArrayList

示例

复制代码
public class ListThreadTest {
    public static void main(String[] args) throws InterruptedException {
        // ArrayList:线程不安全
        List<Integer> arrayList = new ArrayList<>();
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            final int num = i;
            executor.submit(() -> {
                arrayList.add(num);
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println("ArrayList大小:" + arrayList.size()); // 可能小于1000
        
        // Vector:线程安全
        List<Integer> vector = new Vector<>();
        executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            final int num = i;
            executor.submit(() -> {
                vector.add(num);
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println("Vector大小:" + vector.size()); // 一定是1000
        
        // 扩容机制
        ArrayList<Integer> list1 = new ArrayList<>();
        for (int i = 0; i < 11; i++) {
            list1.add(i);
        }
        // 初始容量10,添加第11个元素时扩容为15(10*1.5)
        
        Vector<Integer> vector1 = new Vector<>();
        for (int i = 0; i < 11; i++) {
            vector1.add(i);
        }
        // 初始容量10,添加第11个元素时扩容为20(10*2)
        
        Vector<Integer> vector2 = new Vector(10, 5); // 指定扩容增量为5
        for (int i = 0; i < 11; i++) {
            vector2.add(i);
        }
        // 初始容量10,添加第11个元素时扩容为15(10+5)
    }
}

18. Iterator 和 ListIterator 的区别

核心区别遍历方向支持的操作适用范围

Iterator是所有集合都支持的迭代器,只能单向遍历 (从前往后),支持hasNext()next()remove()方法。Iterator可以遍历Collection接口下的所有集合,如ListSetQueue等。

ListIteratorList集合特有的迭代器,继承自Iterator,可以双向遍历 (从前往后和从后往前),除了支持Iterator的所有方法外,还支持hasPrevious()previous()add()set()nextIndex()previousIndex()方法。ListIterator只能遍历List接口下的集合,如ArrayListLinkedList等。

注意 :在使用迭代器遍历集合时,不能直接修改集合的结构(如add()remove()),否则会抛出ConcurrentModificationException。只能使用迭代器的remove()方法(ListIterator还可以使用add()set()方法)修改集合。

示例

复制代码
public class IteratorTest {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("A");
        list.add("B");
        list.add("C");
        
        // Iterator:单向遍历
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            System.out.println(element);
            // list.remove(element); // 抛出ConcurrentModificationException
            if (element.equals("B")) {
                iterator.remove(); // 正确的删除方式
            }
        }
        System.out.println("删除B后:" + list); // [A, C]
        
        // ListIterator:双向遍历
        ListIterator<String> listIterator = list.listIterator();
        // 从前往后遍历
        while (listIterator.hasNext()) {
            int index = listIterator.nextIndex();
            String element = listIterator.next();
            System.out.println("索引" + index + ":" + element);
            if (element.equals("A")) {
                listIterator.set("a"); // 修改元素
            }
        }
        System.out.println("修改A为a后:" + list); // [a, C]
        
        // 从后往前遍历
        while (listIterator.hasPrevious()) {
            int index = listIterator.previousIndex();
            String element = listIterator.previous();
            System.out.println("索引" + index + ":" + element);
            if (element.equals("C")) {
                listIterator.add("B"); // 添加元素
            }
        }
        System.out.println("添加B后:" + list); // [a, B, C]
    }
}

19. fail-fast 和 fail-safe 的区别

核心区别并发修改的处理方式是否抛出异常底层实现

fail-fast(快速失败)是 Java 集合的一种错误检测机制。当多个线程同时对集合进行操作时,如果一个线程在遍历集合的过程中,其他线程修改了集合的结构(如add()remove()clear()),那么迭代器会立即抛出ConcurrentModificationExceptionfail-fast的实现原理是迭代器在遍历过程中会比较modCount(集合修改次数)和expectedModCount(迭代器期望的修改次数),如果两者不相等,就抛出异常。ArrayListHashMapHashSet等都是 fail-fast 的。

fail-safe(安全失败)是指在遍历集合时,不是直接在原集合上遍历,而是先复制一份集合的副本,然后遍历副本。这样,在遍历过程中其他线程对原集合的修改不会影响到遍历,也不会抛出ConcurrentModificationException。但fail-safe有两个缺点:一是需要额外的内存开销来存储副本;二是不能保证遍历到的数据是最新的。CopyOnWriteArrayListConcurrentHashMap等都是 fail-safe 的。

示例

复制代码
public class FailTest {
    public static void main(String[] args) {
        // fail-fast:ArrayList
        List<String> arrayList = new ArrayList<>();
        arrayList.add("A");
        arrayList.add("B");
        arrayList.add("C");
        
        new Thread(() -> {
            try {
                Thread.sleep(100);
                arrayList.add("D"); // 修改集合结构
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        
        // 主线程遍历
        try {
            Iterator<String> iterator = arrayList.iterator();
            while (iterator.hasNext()) {
                System.out.println(iterator.next());
                Thread.sleep(200);
            }
        } catch (ConcurrentModificationException e) {
            System.out.println("抛出ConcurrentModificationException");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        // fail-safe:CopyOnWriteArrayList
        List<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
        copyOnWriteArrayList.add("A");
        copyOnWriteArrayList.add("B");
        copyOnWriteArrayList.add("C");
        
        new Thread(() -> {
            try {
                Thread.sleep(100);
                copyOnWriteArrayList.add("D"); // 修改集合结构
                System.out.println("添加了D");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        
        // 主线程遍历
        try {
            Iterator<String> iterator = copyOnWriteArrayList.iterator();
            while (iterator.hasNext()) {
                System.out.println(iterator.next());
                Thread.sleep(200);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 不会抛出异常,但遍历不到D
    }
}

20. Collections 和 Collection 的区别

核心区别类型作用包含的方法

Collection接口 ,是 Java 集合框架的根接口之一,它定义了所有集合都必须具备的基本方法,如add()remove()contains()size()iterator()等。Collection接口有两个主要的子接口:ListSet

Collections工具类 ,位于java.util包下,它包含了一系列静态方法,用于操作集合,如排序、搜索、线程安全化、不可变集合等。Collections不能被实例化,它的所有方法都是静态的。

常用的 Collections 方法

  • sort(List<T> list):对 List 进行自然排序
  • sort(List<T> list, Comparator<? super T> c):对 List 进行自定义排序
  • binarySearch(List<? extends Comparable<? super T>> list, T key):二分查找
  • reverse(List<?> list):反转 List
  • shuffle(List<?> list):随机打乱 List
  • synchronizedList(List<T> list):将 List 转换为线程安全的 List
  • unmodifiableList(List<? extends T> list):将 List 转换为不可变的 List

示例

复制代码
public class CollectionsTest {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        list.add(3);
        list.add(1);
        list.add(2);
        
        // 排序
        Collections.sort(list);
        System.out.println("排序后:" + list); // [1, 2, 3]
        
        // 反转
        Collections.reverse(list);
        System.out.println("反转后:" + list); // [3, 2, 1]
        
        // 二分查找
        int index = Collections.binarySearch(list, 2);
        System.out.println("元素2的索引:" + index); // 1
        
        // 随机打乱
        Collections.shuffle(list);
        System.out.println("打乱后:" + list);
        
        // 线程安全化
        List<Integer> safeList = Collections.synchronizedList(list);
        
        // 不可变集合
        List<Integer> unmodifiableList = Collections.unmodifiableList(list);
        // unmodifiableList.add(4); // 抛出UnsupportedOperationException
    }
}

三、并发编程(10 个)

21. 进程和线程的区别

核心区别资源分配独立性开销通信方式

进程是程序的一次执行过程,是系统进行资源分配和调度的基本单位。每个进程都有自己独立的地址空间、内存、文件句柄等资源,进程之间相互独立,一个进程崩溃不会影响其他进程。进程的创建和销毁开销很大,因为需要分配和回收系统资源。进程之间的通信比较复杂,常用的方式有管道、消息队列、共享内存、信号量、套接字等。

线程是进程中的一个执行单元,是 CPU 调度和分派的基本单位。一个进程可以包含多个线程,这些线程共享进程的地址空间和资源,每个线程有自己独立的栈和程序计数器。线程之间的通信比较简单,可以直接读写共享变量,但需要注意线程安全问题。线程的创建和销毁开销很小,因为不需要分配新的系统资源。一个线程崩溃可能会导致整个进程崩溃。

示例

复制代码
public class ProcessThreadTest {
    public static void main(String[] args) {
        // 创建两个线程,共享同一个进程的资源
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("线程1:" + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("线程2:" + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        t1.start();
        t2.start();
    }
}

22. 线程的五种状态及转换

Java 线程有五种基本状态:新建(New)就绪(Runnable)运行(Running)阻塞(Blocked)死亡(Terminated)

  1. 新建状态 :当用new关键字创建一个线程对象后,线程就进入了新建状态。此时线程还没有开始执行,只是在堆内存中创建了一个对象。
  2. 就绪状态 :当调用线程的start()方法后,线程就进入了就绪状态。此时线程已经准备好执行,等待 CPU 分配时间片。
  3. 运行状态 :当 CPU 分配时间片给就绪状态的线程后,线程就进入了运行状态,开始执行run()方法中的代码。
  4. 阻塞状态 :当线程因为某些原因放弃 CPU 使用权,暂时停止执行时,就进入了阻塞状态。阻塞状态分为三种:
    • 等待阻塞 :调用wait()方法,线程进入等待队列,等待其他线程唤醒。
    • 同步阻塞 :线程获取synchronized锁失败,进入锁池。
    • 其他阻塞 :调用sleep()join()或发出 I/O 请求时,线程进入阻塞状态。
  5. 死亡状态 :当线程的run()方法执行完毕,或者抛出未捕获的异常时,线程就进入了死亡状态。死亡状态的线程不能再被启动。

状态转换图

复制代码
新建 → 就绪 → 运行 → 死亡
        ↑       ↓
        └── 阻塞 ──┘

示例

复制代码
public class ThreadStateTest {
    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();
        
        Thread t = new Thread(() -> {
            try {
                // 运行状态
                System.out.println("线程运行中");
                
                // 等待阻塞
                synchronized (lock) {
                    lock.wait();
                }
                
                // 被唤醒后回到就绪状态,然后运行
                System.out.println("线程被唤醒");
                
                // 睡眠阻塞
                Thread.sleep(1000);
                
                System.out.println("线程执行完毕");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        
        System.out.println("新建状态:" + t.getState()); // NEW
        
        t.start();
        System.out.println("就绪状态:" + t.getState()); // RUNNABLE
        
        Thread.sleep(100);
        System.out.println("等待阻塞状态:" + t.getState()); // WAITING
        
        // 唤醒线程
        synchronized (lock) {
            lock.notify();
        }
        
        Thread.sleep(100);
        System.out.println("睡眠阻塞状态:" + t.getState()); // TIMED_WAITING
        
        Thread.sleep(2000);
        System.out.println("死亡状态:" + t.getState()); // TERMINATED
    }
}

23. sleep () 和 wait () 的区别

核心区别所属类锁的释放使用场景唤醒方式

sleep()Thread类的静态方法,用于让当前线程暂停执行指定的时间。调用sleep()方法时,线程不会释放锁 ,如果当前线程持有锁,其他线程仍然无法获取锁。sleep()方法通常用于模拟延迟、定时任务等场景。sleep()方法在睡眠时间结束后会自动唤醒,回到就绪状态。

wait()Object类的方法,用于让当前线程等待,直到其他线程调用notify()notifyAll()方法唤醒它。调用wait()方法时,线程会释放锁 ,其他线程可以获取锁并执行。wait()方法必须在同步代码块或同步方法中调用,否则会抛出IllegalMonitorStateExceptionwait()方法通常用于线程间的通信。

示例

复制代码
public class SleepWaitTest {
    public static void main(String[] args) {
        Object lock = new Object();
        
        // sleep()不释放锁
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("线程1获取锁");
                try {
                    Thread.sleep(2000); // 睡眠2秒,不释放锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程1释放锁");
            }
        }).start();
        
        new Thread(() -> {
            try {
                Thread.sleep(100); // 确保线程1先获取锁
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程2尝试获取锁");
            synchronized (lock) {
                System.out.println("线程2获取锁");
            }
        }).start();
        
        // wait()释放锁
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("线程3获取锁");
                try {
                    lock.wait(); // 等待,释放锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程3被唤醒");
            }
        }).start();
        
        new Thread(() -> {
            try {
                Thread.sleep(100); // 确保线程3先获取锁
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程4尝试获取锁");
            synchronized (lock) {
                System.out.println("线程4获取锁");
                lock.notify(); // 唤醒线程3
                System.out.println("线程4唤醒线程3");
            }
        }).start();
    }
}

24. synchronized 和 Lock 的区别

核心区别实现方式锁的获取和释放灵活性性能功能

synchronized是 Java 的关键字 ,是 JVM 层面实现的锁。它的锁的获取和释放是自动的,当进入同步代码块时自动获取锁,退出同步代码块时自动释放锁。synchronized是独占锁,只能是非公平锁,不支持中断和超时获取锁。synchronized的性能在 JDK1.6 之后有了很大的提升,引入了偏向锁、轻量级锁、重量级锁的升级机制。

Lock是 Java 的接口 ,是代码层面实现的锁。它的锁的获取和释放是手动的,需要显式调用lock()方法获取锁,调用unlock()方法释放锁,通常在finally块中释放锁,以避免死锁。Lock支持更多的功能,如公平锁和非公平锁、可中断锁、超时获取锁、读写锁等。Lock的性能在高并发场景下比synchronized更好。

常用的 Lock 实现类ReentrantLock(可重入锁)、ReentrantReadWriteLock(读写锁)。

示例

复制代码
public class LockTest {
    private int count = 0;
    private final Object syncLock = new Object();
    private final Lock lock = new ReentrantLock();
    
    // synchronized实现
    public void incrementSync() {
        synchronized (syncLock) {
            count++;
        }
    }
    
    // Lock实现
    public void incrementLock() {
        lock.lock(); // 获取锁
        try {
            count++;
        } finally {
            lock.unlock(); // 释放锁,必须在finally中
        }
    }
    
    // 可中断锁
    public void lockInterruptibly() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            // 执行任务
        } finally {
            lock.unlock();
        }
    }
    
    // 超时获取锁
    public boolean tryLock() throws InterruptedException {
        return lock.tryLock(1, TimeUnit.SECONDS);
    }
    
    // 公平锁
    private final Lock fairLock = new ReentrantLock(true);
    
    public static void main(String[] args) throws InterruptedException {
        LockTest test = new LockTest();
        
        // 多线程测试
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10000; i++) {
            executor.submit(() -> {
                test.incrementSync();
                test.incrementLock();
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        
        System.out.println("count: " + test.count); // 20000
    }
}

25. volatile 关键字的作用和原理

volatile是 Java 的关键字,用于修饰变量,它有两个主要作用:保证可见性禁止指令重排序

保证可见性 :当一个线程修改了volatile变量的值,其他线程能够立即看到修改后的值。这是因为volatile变量的写操作会立即刷新到主内存,读操作会直接从主内存读取,而不是从线程的工作内存读取。

禁止指令重排序 :编译器和 CPU 为了提高性能,会对指令进行重排序,但重排序不会影响单线程的执行结果,却可能影响多线程的执行结果。volatile关键字通过插入内存屏障来禁止指令重排序,保证指令的执行顺序和代码的顺序一致。

原理volatile变量的写操作会在指令后插入一个StoreStore屏障和一个StoreLoad屏障,读操作会在指令前插入一个LoadLoad屏障和一个LoadStore屏障。这些内存屏障会禁止指令重排序,并保证数据的可见性。

注意volatile不能保证原子性,例如count++操作不是原子的,即使countvolatile变量,多线程环境下仍然会出现数据不一致的问题。

示例

复制代码
public class VolatileTest {
    // 没有volatile,程序可能会一直循环
    private static volatile boolean flag = true;
    
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (flag) {
                // 空循环
            }
            System.out.println("线程退出");
        }).start();
        
        Thread.sleep(1000);
        flag = false;
        System.out.println("flag被设置为false");
    }
}

26. ThreadLocal 的实现原理和内存泄漏问题

ThreadLocal是 Java 提供的一个线程本地变量工具,它可以为每个线程创建一个独立的变量副本,每个线程只能访问自己的变量副本,从而避免了线程安全问题。

实现原理 :每个Thread对象都有一个ThreadLocalMap成员变量,ThreadLocalMap是一个类似HashMap的结构,它的keyThreadLocal对象,value是线程本地变量的值。当调用ThreadLocalset()方法时,会获取当前线程的ThreadLocalMap,然后将ThreadLocal对象作为key,变量值作为value存入ThreadLocalMap。当调用get()方法时,会获取当前线程的ThreadLocalMap,然后以ThreadLocal对象为key获取对应的value

内存泄漏问题ThreadLocalMapkeyThreadLocal对象的弱引用,而value是强引用。如果ThreadLocal对象没有被外部强引用,那么在垃圾回收时,key会被回收,但value不会被回收,因为Thread对象还持有ThreadLocalMap的强引用,ThreadLocalMap持有value的强引用。这样就会导致value永远无法被回收,造成内存泄漏。

解决方法 :使用完ThreadLocal后,显式调用remove()方法,删除对应的key-value对。

示例

复制代码
public class ThreadLocalTest {
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
    
    public static void main(String[] args) {
        new Thread(() -> {
            threadLocal.set("线程1的变量");
            System.out.println(threadLocal.get()); // 线程1的变量
            threadLocal.remove(); // 显式删除,避免内存泄漏
        }).start();
        
        new Thread(() -> {
            threadLocal.set("线程2的变量");
            System.out.println(threadLocal.get()); // 线程2的变量
            threadLocal.remove();
        }).start();
    }
}

27. 线程池的核心参数及工作原理

线程池是一种管理线程的机制,它可以预先创建一定数量的线程,避免频繁创建和销毁线程带来的开销,提高系统性能。Java 中的线程池核心是ThreadPoolExecutor类,它有七个核心参数:

  1. corePoolSize:核心线程数,线程池中长期保持的线程数量。
  2. maximumPoolSize:最大线程数,线程池允许创建的最大线程数量。
  3. keepAliveTime:非核心线程的空闲时间,超过这个时间,非核心线程会被回收。
  4. unitkeepAliveTime的时间单位。
  5. workQueue:任务队列,用于存储等待执行的任务。
  6. threadFactory:线程工厂,用于创建线程。
  7. handler:拒绝策略,当任务队列满了且线程数达到最大线程数时,处理新任务的策略。

工作原理

  1. 当提交一个新任务时,线程池首先检查核心线程数是否已满,如果没有满,就创建一个核心线程执行任务。
  2. 如果核心线程数已满,就检查任务队列是否已满,如果没有满,就将任务加入任务队列。
  3. 如果任务队列已满,就检查最大线程数是否已满,如果没有满,就创建一个非核心线程执行任务。
  4. 如果最大线程数也已满,就执行拒绝策略。

常用的拒绝策略

  • AbortPolicy:直接抛出异常,默认策略。
  • CallerRunsPolicy:用调用者所在的线程执行任务。
  • DiscardPolicy:直接丢弃任务。
  • DiscardOldestPolicy:丢弃队列中最旧的任务,然后尝试执行当前任务。

示例

复制代码
public class ThreadPoolTest {
    public static void main(String[] args) {
        // 创建线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, // 核心线程数
                5, // 最大线程数
                60, // 空闲时间
                TimeUnit.SECONDS, // 时间单位
                new ArrayBlockingQueue<>(10), // 任务队列
                Executors.defaultThreadFactory(), // 线程工厂
                new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
        );
        
        // 提交任务
        for (int i = 0; i < 20; i++) {
            final int num = i;
            executor.submit(() -> {
                System.out.println("线程" + Thread.currentThread().getName() + "执行任务" + num);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        
        // 关闭线程池
        executor.shutdown();
    }
}

28. CountDownLatch、CyclicBarrier 和 Semaphore 的区别

这三个都是 Java 并发包中的同步工具类,用于协调多个线程的执行。

CountDownLatch :允许一个或多个线程等待其他线程完成操作。它有一个计数器,初始值为需要等待的线程数。每个线程完成操作后,调用countDown()方法将计数器减 1。当计数器变为 0 时,等待的线程就会被唤醒。CountDownLatch是一次性的,计数器变为 0 后就不能再使用了。

CyclicBarrier :允许一组线程相互等待,直到所有线程都到达屏障点,然后一起继续执行。它也有一个计数器,初始值为参与的线程数。每个线程到达屏障点后,调用await()方法等待其他线程。当所有线程都到达屏障点时,屏障打开,所有线程继续执行。CyclicBarrier可以重复使用,当所有线程通过屏障后,计数器会重置为初始值。

Semaphore :用于控制同时访问特定资源的线程数量,它维护了一组许可。线程需要获取许可才能访问资源,访问完成后释放许可。如果许可数量为 0,线程就会阻塞,直到有其他线程释放许可。Semaphore可以用于实现限流、资源池等。

示例

复制代码
public class SyncUtilsTest {
    public static void main(String[] args) throws InterruptedException {
        // CountDownLatch示例:主线程等待3个线程完成
        CountDownLatch latch = new CountDownLatch(3);
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + "完成任务");
                latch.countDown();
            }).start();
        }
        latch.await();
        System.out.println("所有线程完成任务,主线程继续执行");
        
        // CyclicBarrier示例:3个线程一起开始执行
        CyclicBarrier barrier = new CyclicBarrier(3, () -> {
            System.out.println("所有线程到达屏障点,一起开始执行");
        });
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + "到达屏障点");
                    barrier.await();
                    System.out.println(Thread.currentThread().getName() + "开始执行");
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }).start();
        }
        
        // Semaphore示例:最多允许2个线程同时访问
        Semaphore semaphore = new Semaphore(2);
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + "获取许可,开始执行");
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + "执行完毕,释放许可");
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

29. 原子类 AtomicInteger 的实现原理

AtomicInteger是 Java 并发包中的原子类,用于实现原子性的整数操作,如getAndIncrement()incrementAndGet()getAndAdd()等。它的实现原理是CAS(Compare And Swap,比较并交换)

CAS 原理:CAS 是一种无锁算法,它包含三个操作数:内存位置 V、预期值 A 和新值 B。当且仅当内存位置 V 的值等于预期值 A 时,才将 V 的值更新为 B,否则什么都不做。CAS 是 CPU 指令级别的操作,是原子性的。

AtomicInteger内部维护了一个volatile修饰的value变量,保证了可见性。它的所有原子操作都是通过调用Unsafe类的 CAS 方法实现的。例如,getAndIncrement()方法的实现是:

复制代码
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

Unsafe类的getAndAddInt()方法会循环执行 CAS 操作,直到成功为止,这就是所谓的自旋锁

优点 :无锁,性能高,避免了死锁问题。缺点:只能保证一个变量的原子性,长时间自旋会消耗 CPU 资源,存在 ABA 问题。

示例

复制代码
public class AtomicIntegerTest {
    private static AtomicInteger count = new AtomicInteger(0);
    
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10000; i++) {
            executor.submit(() -> {
                count.incrementAndGet(); // 原子性自增
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        
        System.out.println("count: " + count.get()); // 10000
    }
}

30. 可见性、原子性和有序性的区别

这三个是 Java 并发编程中的三个核心概念,也是并发问题的根源。

可见性 :当一个线程修改了共享变量的值,其他线程能够立即看到修改后的值。Java 中的volatile关键字可以保证可见性,synchronizedLock也可以保证可见性,因为它们在释放锁时会将工作内存中的数据刷新到主内存。

原子性 :一个操作是不可分割的,要么全部执行,要么全部不执行。Java 中的基本数据类型的读写操作是原子性的,但count++这样的复合操作不是原子性的。synchronizedLock可以保证原子性,原子类(如AtomicInteger)也可以保证原子性。

有序性 :程序的执行顺序和代码的顺序一致。编译器和 CPU 为了提高性能,会对指令进行重排序,重排序在单线程环境下不会影响执行结果,但在多线程环境下可能会影响执行结果。volatile关键字可以禁止指令重排序,保证有序性;synchronizedLock也可以保证有序性,因为它们保证了同一时刻只有一个线程执行同步代码块,相当于单线程执行,不会有重排序问题。

示例

复制代码
public class ConcurrencyPropertiesTest {
    // 可见性问题
    private static boolean flag = true;
    
    // 原子性问题
    private static int count = 0;
    
    // 有序性问题
    private static int a = 0;
    private static boolean ready = false;
    
    public static void main(String[] args) throws InterruptedException {
        // 可见性测试
        new Thread(() -> {
            while (flag) {
                // 空循环
            }
            System.out.println("线程退出");
        }).start();
        
        Thread.sleep(1000);
        flag = false;
        System.out.println("flag被设置为false");
        // 如果flag没有volatile修饰,程序可能会一直循环
        
        // 原子性测试
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10000; i++) {
            executor.submit(() -> {
                count++; // 不是原子操作
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println("count: " + count); // 可能小于10000
        
        // 有序性测试
        new Thread(() -> {
            a = 1;
            ready = true;
            // 可能会被重排序为:ready = true; a = 1;
        }).start();
        
        new Thread(() -> {
            if (ready) {
                System.out.println(a); // 可能输出0
            }
        }).start();
    }
}

四、JVM(8 个)

31. JVM 内存结构(运行时数据区)

JVM 在运行 Java 程序时,会将内存划分为不同的区域,每个区域有不同的用途。JVM 运行时数据区分为:方法区虚拟机栈本地方法栈程序计数器

  1. 程序计数器 :是一块很小的内存区域,用于存储当前线程执行的字节码指令的地址。每个线程都有自己独立的程序计数器,是线程私有的。程序计数器是唯一一个不会抛出OutOfMemoryError的区域。
  2. 虚拟机栈 :每个方法执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法从调用到执行完毕的过程,对应着栈帧在虚拟机栈中入栈和出栈的过程。虚拟机栈是线程私有的。如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverflowError;如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,会抛出OutOfMemoryError
  3. 本地方法栈:与虚拟机栈类似,只不过它是为本地方法(Native Method)服务的。本地方法栈也是线程私有的。
  4. :是 JVM 中最大的一块内存区域,用于存储对象实例和数组。堆是所有线程共享的,在 JVM 启动时创建。堆是垃圾回收的主要区域,因此也被称为 GC 堆。如果堆中没有足够的内存分配给对象,并且无法再扩展,会抛出OutOfMemoryError
  5. 方法区 :用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也是所有线程共享的。JDK8 之前,方法区的实现是永久代(PermGen);JDK8 及以后,方法区的实现是元空间(Metaspace),元空间使用本地内存,而不是 JVM 内存。如果方法区无法满足内存分配需求,会抛出OutOfMemoryError

示例

复制代码
public class JVMMemoryTest {
    // 静态变量:存储在方法区
    public static int staticVar = 10;
    
    // 常量:存储在方法区的运行时常量池
    public static final String CONSTANT = "constant";
    
    public static void main(String[] args) {
        // 局部变量:存储在虚拟机栈的局部变量表
        int localVar = 20;
        
        // 对象实例:存储在堆
        Object obj = new Object();
        
        // 引用:存储在虚拟机栈的局部变量表,指向堆中的对象
        String str = "hello";
    }
}

32. 堆和栈的区别

核心区别存储内容线程私有性空间大小垃圾回收异常类型

:存储对象实例和数组,是所有线程共享的。堆的空间大小很大,通常是 JVM 中最大的内存区域。堆是垃圾回收的主要区域,垃圾回收器会定期回收堆中不再使用的对象。如果堆中没有足够的内存分配给对象,会抛出OutOfMemoryError

:存储局部变量、方法参数、返回值等,是线程私有的。栈的空间大小很小,通常只有几 MB。栈的内存分配和回收是自动的,方法执行完毕后,栈帧会自动出栈,释放内存。如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverflowError;如果栈扩展时无法申请到足够的内存,会抛出OutOfMemoryError

性能对比:栈的访问速度比堆快很多,因为栈是连续的内存空间,分配和回收都是 O (1) 的操作;而堆是不连续的内存空间,分配和回收需要复杂的算法,速度较慢。

示例

复制代码
public class HeapStackTest {
    public static void main(String[] args) {
        // 栈溢出:递归调用太深
        try {
            stackOverflow();
        } catch (StackOverflowError e) {
            System.out.println("栈溢出");
        }
        
        // 堆溢出:创建大量对象
        try {
            List<Object> list = new ArrayList<>();
            while (true) {
                list.add(new Object());
            }
        } catch (OutOfMemoryError e) {
            System.out.println("堆溢出");
        }
    }
    
    private static void stackOverflow() {
        stackOverflow();
    }
}

33. 垃圾回收算法(标记 - 清除、标记 - 复制、标记 - 整理)

垃圾回收(GC)的主要任务是回收堆中不再使用的对象,释放内存空间。常见的垃圾回收算法有三种:标记 - 清除算法标记 - 复制算法标记 - 整理算法

标记 - 清除算法(Mark-Sweep):分为两个阶段:标记阶段和清除阶段。标记阶段会标记出所有需要回收的对象;清除阶段会回收被标记对象的内存空间。

  • 优点:实现简单,不需要移动对象。
  • 缺点:会产生内存碎片,导致大对象无法分配连续的内存空间;标记和清除的效率都不高。

标记 - 复制算法(Copying):将内存分为大小相等的两块,每次只使用其中一块。当这一块内存用完了,就将还存活的对象复制到另一块内存中,然后一次性清除这一块内存。

  • 优点:没有内存碎片,分配内存时只需要移动指针,效率高;标记和复制的效率高。
  • 缺点:内存利用率低,只能使用一半的内存;如果存活对象很多,复制的开销会很大。

标记 - 整理算法(Mark-Compact):分为三个阶段:标记阶段、整理阶段和清除阶段。标记阶段标记出所有需要回收的对象;整理阶段将所有存活的对象移动到内存的一端,按顺序排列;清除阶段清除边界以外的内存空间。

  • 优点:没有内存碎片,内存利用率高。
  • 缺点:需要移动对象,效率较低。

分代收集算法:现代 JVM 都采用分代收集算法,将堆分为新生代和老年代。新生代使用标记 - 复制算法,因为新生代的对象大多朝生夕死,存活对象少;老年代使用标记 - 清除或标记 - 整理算法,因为老年代的对象存活时间长,存活对象多。

示例

复制代码
public class GCAlgorithmTest {
    public static void main(String[] args) {
        // 新生代GC(Minor GC):创建大量朝生夕死的对象
        for (int i = 0; i < 100000; i++) {
            new Object();
        }
        
        // 老年代GC(Major GC/Full GC):创建大对象或长生命周期的对象
        List<byte[]> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            list.add(new byte[1024 * 1024]); // 1MB的对象
        }
    }
}

34. 垃圾收集器(Serial、Parallel、CMS、G1)

垃圾收集器是垃圾回收算法的具体实现,不同的垃圾收集器适用于不同的场景。常见的垃圾收集器有:

  1. Serial 收集器:单线程收集器,在进行垃圾回收时,会暂停所有用户线程(Stop-The-World)。它的实现简单,效率高,适合单核 CPU 和客户端应用。新生代采用标记 - 复制算法,老年代采用标记 - 整理算法。
  2. Parallel 收集器:多线程收集器,也称为吞吐量优先收集器。它在进行垃圾回收时,会使用多个线程并行执行,提高了垃圾回收的效率,减少了 Stop-The-World 的时间。它适合多核 CPU 和注重吞吐量的应用。新生代采用标记 - 复制算法,老年代采用标记 - 整理算法。
  3. CMS 收集器:并发标记清除收集器,也称为低延迟收集器。它的目标是尽可能缩短 Stop-The-World 的时间,适合对响应时间要求高的应用。CMS 分为四个阶段:初始标记、并发标记、重新标记和并发清除。其中初始标记和重新标记需要 Stop-The-World,并发标记和并发清除可以和用户线程并发执行。CMS 采用标记 - 清除算法,会产生内存碎片。
  4. G1 收集器:Garbage-First 收集器,是 JDK9 及以后的默认垃圾收集器。它是一款面向服务端的垃圾收集器,适用于大内存、多核 CPU 的应用。G1 将堆划分为多个大小相等的 Region,每个 Region 可以是新生代或老年代。G1 跟踪各个 Region 的垃圾堆积价值,优先回收价值最高的 Region。G1 的目标是在可控的延迟下实现高吞吐量。

示例

复制代码
// JVM参数设置垃圾收集器
// -XX:+UseSerialGC:使用Serial收集器
// -XX:+UseParallelGC:使用Parallel收集器
// -XX:+UseConcMarkSweepGC:使用CMS收集器
// -XX:+UseG1GC:使用G1收集器
public class GCCollectorTest {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            list.add(new byte[1024 * 1024]); // 1MB的对象
            if (i % 100 == 0) {
                list.clear(); // 释放内存,触发GC
            }
        }
    }
}

35. 类加载过程(加载、验证、准备、解析、初始化)

类加载过程是指 JVM 将.class 文件加载到内存,并对数据进行验证、准备、解析和初始化,最终形成可以被 JVM 使用的 Java 类的过程。类加载过程分为五个阶段:

  1. 加载 :JVM 通过类的全限定名获取.class 文件的二进制字节流,然后将字节流转化为方法区的运行时数据结构,最后在堆中生成一个代表这个类的java.lang.Class对象。
  2. 验证:确保.class 文件的字节流符合 JVM 规范,不会危害 JVM 的安全。验证阶段包括文件格式验证、元数据验证、字节码验证和符号引用验证。
  3. 准备 :为类的静态变量分配内存,并设置默认初始值。例如,public static int i = 10;在准备阶段会被设置为 0,而不是 10。
  4. 解析:将常量池中的符号引用转换为直接引用。符号引用是一组描述目标的字符串,直接引用是指向目标的指针、偏移量或句柄。
  5. 初始化:执行类的静态代码块和静态变量的赋值操作。初始化阶段是类加载过程的最后一个阶段,只有当类被主动使用时才会触发初始化。

主动使用的情况

  • 创建类的实例
  • 调用类的静态方法
  • 访问类的静态变量
  • 使用反射调用类
  • 初始化子类时,先初始化父类
  • 启动类(包含 main () 方法的类)

示例

复制代码
public class ClassLoadingTest {
    static {
        System.out.println("静态代码块执行");
    }
    
    public static int staticVar = 10;
    
    public static void staticMethod() {
        System.out.println("静态方法执行");
    }
    
    public static void main(String[] args) {
        // 主动使用,触发初始化
        System.out.println(ClassLoadingTest.staticVar);
        ClassLoadingTest.staticMethod();
        new ClassLoadingTest();
        
        // 被动使用,不会触发初始化
        System.out.println(SubClass.staticVar); // 只会初始化父类,不会初始化子类
        Class<?> clazz = SubClass.class; // 不会初始化
        SubClass[] array = new SubClass[10]; // 不会初始化
    }
}

class ParentClass {
    static {
        System.out.println("父类静态代码块执行");
    }
    
    public static int staticVar = 10;
}

class SubClass extends ParentClass {
    static {
        System.out.println("子类静态代码块执行");
    }
}

36. 双亲委派模型及破坏场景

双亲委派模型是 Java 类加载的默认机制,它的核心思想是:当一个类加载器收到类加载请求时,它首先会将请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最终都会传送到顶层的启动类加载器。只有当父类加载器无法完成这个请求时,子类加载器才会尝试自己去加载。

类加载器层次

  1. 启动类加载器(Bootstrap ClassLoader) :负责加载 JRE/lib 目录下的核心类库,如 rt.jar。它是用 C++ 实现的,不是ClassLoader的子类。
  2. 扩展类加载器(Extension ClassLoader):负责加载 JRE/lib/ext 目录下的扩展类库。
  3. 应用程序类加载器(Application ClassLoader):负责加载用户类路径(classpath)下的类。
  4. 自定义类加载器:用户自己实现的类加载器,负责加载指定路径下的类。

优点

  • 保证了 Java 核心类库的安全性,防止用户自定义的类覆盖核心类库。
  • 保证了类的唯一性,同一个类被同一个类加载器加载,只会生成一个Class对象。

破坏场景

  1. SPI 机制 :如 JDBC 的DriverManager,需要加载第三方驱动类,而启动类加载器无法加载这些类,因此需要破坏双亲委派模型,使用线程上下文类加载器来加载。
  2. 热部署:如 Tomcat 的类加载器,每个 Web 应用有自己的类加载器,实现了类的热部署。
  3. 自定义类加载器 :重写loadClass()方法,不遵循双亲委派模型。

示例

复制代码
// 自定义类加载器
public class MyClassLoader extends ClassLoader {
    private String classPath;
    
    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            // 读取.class文件的字节流
            byte[] bytes = Files.readAllBytes(Paths.get(classPath + name.replace(".", "/") + ".class"));
            // 定义类
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
    
    public static void main(String[] args) throws ClassNotFoundException {
        MyClassLoader classLoader = new MyClassLoader("target/classes/");
        Class<?> clazz = classLoader.loadClass("com.example.Test");
        System.out.println(clazz.getClassLoader()); // MyClassLoader
        System.out.println(clazz.getClassLoader().getParent()); // ApplicationClassLoader
    }
}

37. 方法区和元空间的区别

方法区是 JVM 规范中定义的一个内存区域,用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区的实现在不同的 JDK 版本中有所不同:

JDK8 之前 :方法区的实现是永久代(PermGen) ,永久代是 JVM 内存的一部分,大小是固定的,可以通过-XX:PermSize-XX:MaxPermSize参数设置。永久代容易出现内存溢出问题,因为它的大小有限,而且动态生成的类(如反射、动态代理)会占用永久代的内存。

JDK8 及以后 :方法区的实现是元空间(Metaspace) ,元空间不再使用 JVM 内存,而是使用本地内存(Native Memory)。元空间的大小只受本地内存的限制,可以通过-XX:MetaspaceSize-XX:MaxMetaspaceSize参数设置。元空间解决了永久代的内存溢出问题,因为本地内存通常比 JVM 内存大很多。

其他区别

  • 永久代的垃圾回收和老年代的垃圾回收是绑定的,而元空间的垃圾回收是独立的。
  • 元空间使用了指针压缩技术,可以节省内存空间。
  • 元空间的内存分配和回收效率更高。

示例

复制代码
// JVM参数设置永久代大小(JDK7及以前)
// -XX:PermSize=64M -XX:MaxPermSize=128M

// JVM参数设置元空间大小(JDK8及以后)
// -XX:MetaspaceSize=64M -XX:MaxMetaspaceSize=256M
public class MethodAreaTest {
    public static void main(String[] args) {
        // 动态生成类,测试元空间
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(Object.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
            enhancer.create();
        }
    }
}

38. 强引用、软引用、弱引用、虚引用的区别

这部分内容在前面的 Java 基础部分已经详细介绍过,这里再简单总结一下:

  1. 强引用:最常见的引用类型,只要强引用存在,垃圾回收器永远不会回收被引用的对象。
  2. 软引用:用来描述有用但非必需的对象,当内存不足时会被回收,适合实现缓存。
  3. 弱引用:用来描述非必需的对象,无论内存是否充足,垃圾回收时都会被回收,适合实现临时缓存。
  4. 虚引用:最弱的引用类型,无法通过虚引用获取对象实例,唯一作用是在对象被垃圾回收时收到通知。

五、Spring 生态(7 个)

39. Spring IoC 的实现原理

**IoC(Inversion of Control,控制反转)** 是 Spring 的核心思想之一,它将对象的创建和依赖关系的管理交给 Spring 容器来完成,而不是由开发者手动创建和管理。

实现原理

  1. 配置元数据:开发者通过 XML 配置文件、注解或 Java 配置类来定义 Bean 的信息,包括 Bean 的类名、属性、依赖关系等。
  2. BeanDefinition :Spring 容器将配置元数据解析为BeanDefinition对象,BeanDefinition包含了 Bean 的所有信息,如类名、属性、依赖关系、作用域等。
  3. BeanFactory :Spring 容器的核心接口,负责创建和管理 Bean。BeanFactory根据BeanDefinition来创建 Bean 实例,并处理 Bean 之间的依赖关系。
  4. 依赖注入(DI):Spring 容器通过依赖注入来实现控制反转。依赖注入有三种方式:构造器注入、setter 方法注入和字段注入。Spring 容器会自动将 Bean 的依赖注入到 Bean 中。
  5. Bean 生命周期 :Spring 容器管理 Bean 的整个生命周期,从创建到初始化到销毁。开发者可以通过实现InitializingBeanDisposableBean接口,或者使用@PostConstruct@PreDestroy注解来定义 Bean 的初始化和销毁方法。

示例

复制代码
// 注解方式配置Bean
@Component
public class UserService {
    @Autowired
    private UserDao userDao;
    
    public void addUser() {
        userDao.addUser();
    }
}

@Repository
public class UserDao {
    public void addUser() {
        System.out.println("添加用户");
    }
}

// 启动Spring容器
public class SpringTest {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext("com.example");
        UserService userService = context.getBean(UserService.class);
        userService.addUser();
    }
}

40. Spring AOP 的实现原理

**AOP(Aspect-Oriented Programming,面向切面编程)** 是 Spring 的另一个核心思想,它将横切关注点(如日志、事务、权限等)与业务逻辑分离,提高了代码的复用性和可维护性。

实现原理

  1. 切面(Aspect):横切关注点的模块化,如日志切面、事务切面。
  2. 连接点(JoinPoint):程序执行过程中的某个点,如方法调用、异常抛出等。
  3. 通知(Advice):切面在连接点上执行的动作,分为前置通知、后置通知、返回通知、异常通知和环绕通知。
  4. 切入点(Pointcut):匹配连接点的表达式,用于确定哪些连接点需要应用通知。
  5. 代理(Proxy) :Spring AOP 通过动态代理来实现,有两种代理方式:JDK 动态代理和 CGLIB 动态代理。
    • JDK 动态代理:基于接口实现,要求目标类必须实现接口。它在运行时动态生成一个实现了目标接口的代理类,代理类调用目标方法时,会先执行通知,然后再调用目标方法。
    • CGLIB 动态代理:基于继承实现,不需要目标类实现接口。它在运行时动态生成一个目标类的子类,重写目标方法,在子类中加入通知逻辑。

示例

复制代码
// 切面
@Aspect
@Component
public class LogAspect {
    // 切入点
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void pointcut() {}
    
    // 前置通知
    @Before("pointcut()")
    public void before(JoinPoint joinPoint) {
        System.out.println("方法执行前:" + joinPoint.getSignature().getName());
    }
    
    // 后置通知
    @After("pointcut()")
    public void after(JoinPoint joinPoint) {
        System.out.println("方法执行后:" + joinPoint.getSignature().getName());
    }
    
    // 环绕通知
    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("环绕通知前");
        Object result = joinPoint.proceed();
        System.out.println("环绕通知后");
        return result;
    }
}

// 目标类
@Service
public class UserService {
    public void addUser() {
        System.out.println("添加用户");
    }
}

// 启动Spring容器
public class AopTest {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext("com.example");
        UserService userService = context.getBean(UserService.class);
        userService.addUser();
    }
}

41. @Autowired 和 @Resource 的区别

核心区别所属框架注入方式匹配规则支持的属性

@Autowired是 Spring 框架提供的注解,默认按照类型 进行注入。如果有多个相同类型的 Bean,会按照名称 进行匹配。如果匹配不到,会抛出异常。可以配合@Qualifier注解使用,指定要注入的 Bean 的名称。@Autowired可以用在字段、构造器、setter 方法和方法参数上。

@Resource是 J2EE 提供的注解,默认按照名称 进行注入。如果找不到名称匹配的 Bean,会按照类型 进行匹配。如果匹配不到,会抛出异常。@Resource有两个重要属性:nametype。如果指定了name属性,就只会按照名称进行匹配;如果指定了type属性,就只会按照类型进行匹配。@Resource可以用在字段和 setter 方法上。

示例

复制代码
@Service
public class UserService {
    // @Autowired默认按类型注入
    @Autowired
    private UserDao userDao;
    
    // 多个相同类型的Bean,配合@Qualifier按名称注入
    @Autowired
    @Qualifier("userDao1")
    private UserDao userDao1;
    
    // @Resource默认按名称注入
    @Resource
    private UserDao userDao;
    
    // 指定name属性,按名称注入
    @Resource(name = "userDao2")
    private UserDao userDao2;
    
    // 指定type属性,按类型注入
    @Resource(type = UserDao.class)
    private UserDao userDao3;
}

@Repository("userDao1")
public class UserDao1 implements UserDao {}

@Repository("userDao2")
public class UserDao2 implements UserDao {}

42. Spring 事务的传播行为

Spring 事务的传播行为是指当一个事务方法被另一个事务方法调用时,事务应该如何传播。Spring 定义了七种事务传播行为:

  1. REQUIRED:默认传播行为。如果当前存在事务,就加入该事务;如果当前没有事务,就创建一个新事务。
  2. SUPPORTS:如果当前存在事务,就加入该事务;如果当前没有事务,就以非事务方式执行。
  3. MANDATORY:必须在事务中执行。如果当前存在事务,就加入该事务;如果当前没有事务,就抛出异常。
  4. REQUIRES_NEW:总是创建一个新事务。如果当前存在事务,就将当前事务挂起,创建一个新事务执行;执行完毕后,恢复原来的事务。
  5. NOT_SUPPORTED:总是以非事务方式执行。如果当前存在事务,就将当前事务挂起,以非事务方式执行;执行完毕后,恢复原来的事务。
  6. NEVER:必须以非事务方式执行。如果当前存在事务,就抛出异常。
  7. NESTED:如果当前存在事务,就嵌套在该事务中执行;如果当前没有事务,就创建一个新事务。嵌套事务是独立的,可以单独提交或回滚,不会影响外部事务;但外部事务回滚会导致嵌套事务回滚。

示例

复制代码
@Service
public class UserService {
    @Autowired
    private UserDao userDao;
    
    @Autowired
    private LogService logService;
    
    @Transactional(propagation = Propagation.REQUIRED)
    public void addUser() {
        userDao.addUser();
        // 调用另一个事务方法
        logService.addLog();
        // 抛出异常,两个方法都会回滚
        int i = 1 / 0;
    }
}

@Service
public class LogService {
    @Autowired
    private LogDao logDao;
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void addLog() {
        logDao.addLog();
    }
}

43. Spring 事务的隔离级别

Spring 事务的隔离级别是指多个事务并发执行时,事务之间的隔离程度。Spring 定义了五种事务隔离级别:

  1. DEFAULT:默认隔离级别,使用数据库的默认隔离级别。MySQL 的默认隔离级别是 REPEATABLE READ,Oracle 的默认隔离级别是 READ COMMITTED。
  2. READ_UNCOMMITTED:读未提交。一个事务可以读取另一个事务未提交的数据。会导致脏读、不可重复读和幻读。
  3. READ_COMMITTED:读已提交。一个事务只能读取另一个事务已提交的数据。可以避免脏读,但会导致不可重复读和幻读。
  4. REPEATABLE_READ:可重复读。一个事务在整个过程中多次读取同一数据,结果是一致的。可以避免脏读和不可重复读,但会导致幻读。MySQL 的 InnoDB 引擎通过 MVCC(多版本并发控制)解决了幻读问题。
  5. SERIALIZABLE:串行化。所有事务串行执行,完全隔离。可以避免所有并发问题,但性能最低。

并发问题

  • 脏读:一个事务读取了另一个事务未提交的数据。
  • 不可重复读:一个事务在同一个事务中多次读取同一数据,结果不一致。
  • 幻读:一个事务在同一个事务中多次查询同一范围的数据,结果不一致。

示例

复制代码
@Service
public class UserService {
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public User getUserById(Long id) {
        return userDao.selectById(id);
    }
    
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void updateUser(User user) {
        userDao.updateById(user);
    }
}

44. Spring Bean 的生命周期

Spring Bean 的生命周期是指 Bean 从创建到初始化到销毁的整个过程,由 Spring 容器管理。Spring Bean 的生命周期可以分为以下几个阶段:

  1. 实例化 :Spring 容器根据BeanDefinition创建 Bean 实例。
  2. 属性赋值:Spring 容器注入 Bean 的属性和依赖。
  3. 初始化
    • 执行Aware接口的方法,如BeanNameAwareBeanFactoryAwareApplicationContextAware等。
    • 执行BeanPostProcessorpostProcessBeforeInitialization()方法。
    • 执行@PostConstruct注解的方法。
    • 执行InitializingBean接口的afterPropertiesSet()方法。
    • 执行init-method属性指定的方法。
    • 执行BeanPostProcessorpostProcessAfterInitialization()方法。
  4. 使用:Bean 可以被应用程序使用。
  5. 销毁
    • 执行@PreDestroy注解的方法。
    • 执行DisposableBean接口的destroy()方法。
    • 执行destroy-method属性指定的方法。

示例

复制代码
@Component
public class UserService implements BeanNameAware, InitializingBean, DisposableBean {
    private String beanName;
    
    @Override
    public void setBeanName(String name) {
        this.beanName = name;
        System.out.println("执行BeanNameAware的setBeanName方法:" + name);
    }
    
    @PostConstruct
    public void postConstruct() {
        System.out.println("执行@PostConstruct注解的方法");
    }
    
    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("执行InitializingBean的afterPropertiesSet方法");
    }
    
    @PreDestroy
    public void preDestroy() {
        System.out.println("执行@PreDestroy注解的方法");
    }
    
    @Override
    public void destroy() throws Exception {
        System.out.println("执行DisposableBean的destroy方法");
    }
}

45. Spring MVC 的工作流程

Spring MVC 是基于 Servlet 的 MVC 框架,它的核心是DispatcherServlet,负责处理所有的 HTTP 请求。Spring MVC 的工作流程如下:

  1. 用户发送请求:用户通过浏览器发送 HTTP 请求到服务器。
  2. DispatcherServlet 接收请求DispatcherServlet拦截所有的 HTTP 请求。
  3. HandlerMapping 映射请求DispatcherServlet调用HandlerMapping,根据请求 URL 找到对应的处理器(Handler)。
  4. HandlerAdapter 调用处理器DispatcherServlet调用HandlerAdapter,由HandlerAdapter调用对应的处理器方法。
  5. 处理器处理请求 :处理器(Controller)处理请求,返回ModelAndView对象。
  6. ViewResolver 解析视图DispatcherServlet调用ViewResolver,根据ModelAndView中的视图名解析出对应的视图(View)。
  7. 渲染视图:视图将模型数据渲染到页面上。
  8. 返回响应DispatcherServlet将渲染后的页面返回给用户。

示例

复制代码
// Controller
@Controller
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;
    
    @GetMapping("/{id}")
    public String getUserById(@PathVariable Long id, Model model) {
        User user = userService.getUserById(id);
        model.addAttribute("user", user);
        return "user"; // 视图名
    }
}

// 视图(user.html)
// <!DOCTYPE html>
// <html>
// <head>
//     <title>用户信息</title>
// </head>
// <body>
//     <h1>用户信息</h1>
//     <p>ID:${user.id}</p>
//     <p>姓名:${user.name}</p>
// </body>
// </html>

六、数据库与持久化(5 个)

46. MyBatis 中 #{} 和 ${} 的区别

核心区别解析方式SQL 注入使用场景

#{} 是预编译处理,MyBatis 会将#{}替换为?,然后使用 JDBC 的PreparedStatement来执行 SQL 语句,参数会被安全地设置到?的位置。#{} 可以防止 SQL 注入,因为参数不会被直接拼接到 SQL 语句中。#{} 适用于传递参数值,如WHERE id = #{id}

${}是字符串替换,MyBatis 会将${}直接替换为参数的值,然后执行 SQL 语句。${}会导致 SQL 注入问题,因为参数会被直接拼接到 SQL 语句中。${}适用于传递 SQL 语句的一部分,如表名、列名、排序字段等,这些内容不能使用预编译处理。

示例

复制代码
<!-- #{}:预编译,防止SQL注入 -->
<select id="getUserById" parameterType="long" resultType="User">
    SELECT * FROM user WHERE id = #{id}
</select>

<!-- ${}:字符串替换,有SQL注入风险 -->
<select id="getUserByTableName" parameterType="string" resultType="User">
    SELECT * FROM ${tableName} WHERE id = 1
</select>

<!-- 排序字段使用${} -->
<select id="getUserList" parameterType="string" resultType="User">
    SELECT * FROM user ORDER BY ${sortField} ${sortOrder}
</select>

47. 事务的 ACID 特性

事务是一组原子性的 SQL 操作,要么全部执行成功,要么全部执行失败。事务具有四个特性,简称 ACID:

  1. 原子性(Atomicity):事务是一个不可分割的工作单位,事务中的所有操作要么全部执行,要么全部不执行。如果事务中的某个操作失败,已经执行的操作会被回滚到事务开始前的状态。
  2. 一致性(Consistency):事务执行前后,数据库的完整性约束没有被破坏。例如,转账事务中,A 账户减少 100 元,B 账户增加 100 元,总金额不变。
  3. 隔离性(Isolation):多个事务并发执行时,事务之间相互隔离,一个事务的执行不会影响其他事务的执行。隔离性通过事务的隔离级别来实现。
  4. 持久性(Durability):一个事务提交后,它对数据库的修改是永久性的,即使数据库发生故障,修改也不会丢失。持久性通过数据库的日志和备份来实现。

示例

复制代码
@Service
public class AccountService {
    @Autowired
    private AccountDao accountDao;
    
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // 原子性:两个更新操作要么都成功,要么都失败
        Account from = accountDao.selectById(fromId);
        Account to = accountDao.selectById(toId);
        
        from.setBalance(from.getBalance().subtract(amount));
        accountDao.updateById(from);
        
        // 模拟异常,事务回滚
        // int i = 1 / 0;
        
        to.setBalance(to.getBalance().add(amount));
        accountDao.updateById(to);
        
        // 一致性:转账前后总金额不变
    }
}

48. 数据库的隔离级别

这部分内容在前面的 Spring 事务部分已经详细介绍过,这里再简单总结一下:

数据库的隔离级别有四种,从低到高依次是:

  1. 读未提交(READ UNCOMMITTED):会导致脏读、不可重复读和幻读。
  2. 读已提交(READ COMMITTED):可以避免脏读,会导致不可重复读和幻读。
  3. 可重复读(REPEATABLE READ):可以避免脏读和不可重复读,会导致幻读。
  4. 串行化(SERIALIZABLE):可以避免所有并发问题,但性能最低。

49. 乐观锁和悲观锁的区别

核心区别加锁时机实现方式性能适用场景

悲观锁 :总是假设最坏的情况,认为每次操作都会有其他线程修改数据,因此在操作数据之前会先加锁,确保同一时间只有一个线程可以操作数据。悲观锁的实现方式是使用数据库的行锁或表锁,如SELECT ... FOR UPDATE。悲观锁的优点是保证了数据的一致性,缺点是性能较低,因为加锁会导致其他线程阻塞。悲观锁适用于写多读少的场景。

乐观锁 :总是假设最好的情况,认为每次操作都不会有其他线程修改数据,因此在操作数据之前不会加锁,而是在提交更新时检查数据是否被其他线程修改过。乐观锁的实现方式通常是使用版本号或时间戳,如UPDATE table SET column = value, version = version + 1 WHERE id = #{id} AND version = #{version}。如果更新行数为 0,说明数据已经被修改,需要重试。乐观锁的优点是性能较高,因为没有加锁开销,缺点是在并发冲突较多的情况下会导致大量的重试。乐观锁适用于读多写少的场景。

示例

复制代码
// 悲观锁
@Transactional
public void updateUserPessimistic(Long id, String name) {
    // 加行锁
    User user = userDao.selectByIdForUpdate(id);
    user.setName(name);
    userDao.updateById(user);
}

// 乐观锁
@Transactional
public void updateUserOptimistic(Long id, String name, Integer version) {
    int rows = userDao.updateByIdAndVersion(id, name, version);
    if (rows == 0) {
        throw new RuntimeException("数据已被修改,请重试");
    }
}

// MyBatis映射文件
<select id="selectByIdForUpdate" parameterType="long" resultType="User">
    SELECT * FROM user WHERE id = #{id} FOR UPDATE
</select>

<update id="updateByIdAndVersion">
    UPDATE user SET name = #{name}, version = version + 1 
    WHERE id = #{id} AND version = #{version}
</update>

50. 索引的类型及工作原理

索引是数据库中用于提高查询效率的数据结构,它类似于书籍的目录,可以快速定位到数据的位置。

常见的索引类型

  1. 主键索引:基于主键创建的索引,主键索引的叶子节点存储的是整行数据。主键索引是聚簇索引,数据和索引存储在一起。
  2. 唯一索引:基于唯一键创建的索引,索引列的值必须唯一,但可以为 NULL。
  3. 普通索引:基于普通列创建的索引,没有唯一性限制。
  4. 联合索引:基于多个列创建的索引,遵循最左前缀原则。
  5. 全文索引:用于全文搜索,支持模糊查询和关键词搜索。

工作原理 :MySQL 的 InnoDB 引擎使用B + 树作为索引的数据结构。B + 树是一种平衡多路查找树,它的特点是:

  • 所有数据都存储在叶子节点,非叶子节点只存储索引。
  • 叶子节点之间通过双向链表连接,便于范围查询。
  • 树的高度较低,通常只有 3-4 层,查询效率很高。

聚簇索引和非聚簇索引的区别

  • 聚簇索引:数据和索引存储在一起,叶子节点存储的是整行数据。一个表只能有一个聚簇索引,通常是主键索引。
  • 非聚簇索引:数据和索引分开存储,叶子节点存储的是主键值。查询时需要先通过非聚簇索引找到主键值,然后再通过聚簇索引找到整行数据,这个过程称为回表。

示例

复制代码
-- 创建普通索引
CREATE INDEX idx_name ON user(name);

-- 创建唯一索引
CREATE UNIQUE INDEX idx_phone ON user(phone);

-- 创建联合索引
CREATE INDEX idx_name_age ON user(name, age);

-- 最左前缀原则:可以使用idx_name_age索引
SELECT * FROM user WHERE name = '张三';
SELECT * FROM user WHERE name = '张三' AND age = 20;

-- 不能使用idx_name_age索引
SELECT * FROM user WHERE age = 20;

需要我把这 50 个知识点整理成一份可直接打印的 PDF 格式面试复习资料吗?

相关推荐
码语智行1 小时前
应用启动和关闭监听器功能分析
java·spring boot
Resky08181 小时前
什么是 Spring IOC:倒过来让容器帮你 new,而不是你到处 new
java·spring
AutumnWind04201 小时前
【JDK动态代理源码梳理】
java·后端·spring
AI进阶客栈1 小时前
开源 MQ Master:Spring Boot 统一管控 5 大消息队列
spring boot·后端·开源
暗夜猎手-大魔王1 小时前
转载--Hermes Agent 10 | 7 层安全防线:从用户授权到输入净化
java·数据库·安全
idolao3 小时前
Oligo 7.60 安装教程:引物设计+Java 环境配置
java·开发语言
做个文艺程序员6 小时前
第04篇:K8s 弹性伸缩实战:HPA、VPA、KEDA——Java SaaS 应对流量洪峰的秘密武器
java·容器·kubernetes·弹性伸缩·自动扩容·ai 推理伸缩
石山代码10 小时前
ArrayList / HashMap / ConcurrentHashMap
java·开发语言