一、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修饰。
StringBuffer和StringBuilder都是可变的字符序列,底层是没有被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声明抛出,否则编译不通过。受检异常表示程序可以预见的、可以恢复的错误,如IOException、SQLException等。
非受检异常(Unchecked Exception)包括RuntimeException及其子类和Error及其子类,不需要显式处理,编译器不会检查。非受检异常表示程序运行时的错误,通常是由程序逻辑错误引起的,如NullPointerException、ArrayIndexOutOfBoundsException、ArithmeticException等。Error表示 JVM 级别的严重错误,如OutOfMemoryError、StackOverflowError等,程序无法处理。
示例:
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 自动将基本数据类型转换为对应的包装类类型,如int转Integer;自动拆箱 是指 Java 自动将包装类类型转换为对应的基本数据类型,如Integer转int。
原理 :自动装箱是通过调用包装类的valueOf()方法实现的,自动拆箱是通过调用包装类的xxxValue()方法实现的(如intValue()、doubleValue())。编译器在编译时会自动插入这些方法调用。
常见陷阱:
- 包装类的缓存机制:
Integer、Byte、Short、Character、Long都有缓存机制,缓存了一定范围内的值(如Integer缓存 - 128~127),超出范围会创建新对象,导致==比较结果不同。 - 空指针异常:包装类对象为
null时,自动拆箱会抛出NullPointerException。 - 性能问题:频繁的自动装箱和拆箱会创建大量对象,影响性能。
示例:
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允许key为null(只能有一个),value为null(可以有多个)。HashMap继承自AbstractMap类。
HashTable是线程安全的,它的所有方法都被synchronized修饰,多线程环境下可以直接使用,但性能较低,因为每次操作都要获取锁。HashTable不允许key和value为null,否则会抛出NullPointerException。HashTable继承自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),性能很高。HashMap的key可以是任意对象,但必须重写hashCode()和equals()方法。
TreeMap底层是红黑树 ,它是有序的,默认按照key的自然顺序排序,也可以通过传入Comparator自定义排序规则。TreeMap的增删改查操作的时间复杂度是 O (log n),性能比HashMap低。TreeMap的key必须实现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。
注意 :HashSet和TreeSet都不允许重复元素,判断元素是否重复的方式不同。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包含key、value、hash和next引用。 - 当两个
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 个线程同时写操作。 - 读操作不加锁,因为
HashEntry的value是volatile的,保证了可见性。
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接口下的所有集合,如List、Set、Queue等。
ListIterator是List集合特有的迭代器,继承自Iterator,可以双向遍历 (从前往后和从后往前),除了支持Iterator的所有方法外,还支持hasPrevious()、previous()、add()、set()和nextIndex()、previousIndex()方法。ListIterator只能遍历List接口下的集合,如ArrayList、LinkedList等。
注意 :在使用迭代器遍历集合时,不能直接修改集合的结构(如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()),那么迭代器会立即抛出ConcurrentModificationException。fail-fast的实现原理是迭代器在遍历过程中会比较modCount(集合修改次数)和expectedModCount(迭代器期望的修改次数),如果两者不相等,就抛出异常。ArrayList、HashMap、HashSet等都是 fail-fast 的。
fail-safe(安全失败)是指在遍历集合时,不是直接在原集合上遍历,而是先复制一份集合的副本,然后遍历副本。这样,在遍历过程中其他线程对原集合的修改不会影响到遍历,也不会抛出ConcurrentModificationException。但fail-safe有两个缺点:一是需要额外的内存开销来存储副本;二是不能保证遍历到的数据是最新的。CopyOnWriteArrayList、ConcurrentHashMap等都是 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接口有两个主要的子接口:List和Set。
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):反转 Listshuffle(List<?> list):随机打乱 ListsynchronizedList(List<T> list):将 List 转换为线程安全的 ListunmodifiableList(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)。
- 新建状态 :当用
new关键字创建一个线程对象后,线程就进入了新建状态。此时线程还没有开始执行,只是在堆内存中创建了一个对象。 - 就绪状态 :当调用线程的
start()方法后,线程就进入了就绪状态。此时线程已经准备好执行,等待 CPU 分配时间片。 - 运行状态 :当 CPU 分配时间片给就绪状态的线程后,线程就进入了运行状态,开始执行
run()方法中的代码。 - 阻塞状态 :当线程因为某些原因放弃 CPU 使用权,暂时停止执行时,就进入了阻塞状态。阻塞状态分为三种:
- 等待阻塞 :调用
wait()方法,线程进入等待队列,等待其他线程唤醒。 - 同步阻塞 :线程获取
synchronized锁失败,进入锁池。 - 其他阻塞 :调用
sleep()、join()或发出 I/O 请求时,线程进入阻塞状态。
- 等待阻塞 :调用
- 死亡状态 :当线程的
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()方法必须在同步代码块或同步方法中调用,否则会抛出IllegalMonitorStateException。wait()方法通常用于线程间的通信。
示例:
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++操作不是原子的,即使count是volatile变量,多线程环境下仍然会出现数据不一致的问题。
示例:
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的结构,它的key是ThreadLocal对象,value是线程本地变量的值。当调用ThreadLocal的set()方法时,会获取当前线程的ThreadLocalMap,然后将ThreadLocal对象作为key,变量值作为value存入ThreadLocalMap。当调用get()方法时,会获取当前线程的ThreadLocalMap,然后以ThreadLocal对象为key获取对应的value。
内存泄漏问题 :ThreadLocalMap的key是ThreadLocal对象的弱引用,而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类,它有七个核心参数:
- corePoolSize:核心线程数,线程池中长期保持的线程数量。
- maximumPoolSize:最大线程数,线程池允许创建的最大线程数量。
- keepAliveTime:非核心线程的空闲时间,超过这个时间,非核心线程会被回收。
- unit :
keepAliveTime的时间单位。 - workQueue:任务队列,用于存储等待执行的任务。
- threadFactory:线程工厂,用于创建线程。
- handler:拒绝策略,当任务队列满了且线程数达到最大线程数时,处理新任务的策略。
工作原理:
- 当提交一个新任务时,线程池首先检查核心线程数是否已满,如果没有满,就创建一个核心线程执行任务。
- 如果核心线程数已满,就检查任务队列是否已满,如果没有满,就将任务加入任务队列。
- 如果任务队列已满,就检查最大线程数是否已满,如果没有满,就创建一个非核心线程执行任务。
- 如果最大线程数也已满,就执行拒绝策略。
常用的拒绝策略:
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关键字可以保证可见性,synchronized和Lock也可以保证可见性,因为它们在释放锁时会将工作内存中的数据刷新到主内存。
原子性 :一个操作是不可分割的,要么全部执行,要么全部不执行。Java 中的基本数据类型的读写操作是原子性的,但count++这样的复合操作不是原子性的。synchronized和Lock可以保证原子性,原子类(如AtomicInteger)也可以保证原子性。
有序性 :程序的执行顺序和代码的顺序一致。编译器和 CPU 为了提高性能,会对指令进行重排序,重排序在单线程环境下不会影响执行结果,但在多线程环境下可能会影响执行结果。volatile关键字可以禁止指令重排序,保证有序性;synchronized和Lock也可以保证有序性,因为它们保证了同一时刻只有一个线程执行同步代码块,相当于单线程执行,不会有重排序问题。
示例:
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 运行时数据区分为:方法区 、堆 、虚拟机栈 、本地方法栈 和程序计数器。
- 程序计数器 :是一块很小的内存区域,用于存储当前线程执行的字节码指令的地址。每个线程都有自己独立的程序计数器,是线程私有的。程序计数器是唯一一个不会抛出
OutOfMemoryError的区域。 - 虚拟机栈 :每个方法执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法从调用到执行完毕的过程,对应着栈帧在虚拟机栈中入栈和出栈的过程。虚拟机栈是线程私有的。如果线程请求的栈深度大于虚拟机允许的深度,会抛出
StackOverflowError;如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,会抛出OutOfMemoryError。 - 本地方法栈:与虚拟机栈类似,只不过它是为本地方法(Native Method)服务的。本地方法栈也是线程私有的。
- 堆 :是 JVM 中最大的一块内存区域,用于存储对象实例和数组。堆是所有线程共享的,在 JVM 启动时创建。堆是垃圾回收的主要区域,因此也被称为 GC 堆。如果堆中没有足够的内存分配给对象,并且无法再扩展,会抛出
OutOfMemoryError。 - 方法区 :用于存储已被 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)
垃圾收集器是垃圾回收算法的具体实现,不同的垃圾收集器适用于不同的场景。常见的垃圾收集器有:
- Serial 收集器:单线程收集器,在进行垃圾回收时,会暂停所有用户线程(Stop-The-World)。它的实现简单,效率高,适合单核 CPU 和客户端应用。新生代采用标记 - 复制算法,老年代采用标记 - 整理算法。
- Parallel 收集器:多线程收集器,也称为吞吐量优先收集器。它在进行垃圾回收时,会使用多个线程并行执行,提高了垃圾回收的效率,减少了 Stop-The-World 的时间。它适合多核 CPU 和注重吞吐量的应用。新生代采用标记 - 复制算法,老年代采用标记 - 整理算法。
- CMS 收集器:并发标记清除收集器,也称为低延迟收集器。它的目标是尽可能缩短 Stop-The-World 的时间,适合对响应时间要求高的应用。CMS 分为四个阶段:初始标记、并发标记、重新标记和并发清除。其中初始标记和重新标记需要 Stop-The-World,并发标记和并发清除可以和用户线程并发执行。CMS 采用标记 - 清除算法,会产生内存碎片。
- 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 类的过程。类加载过程分为五个阶段:
- 加载 :JVM 通过类的全限定名获取.class 文件的二进制字节流,然后将字节流转化为方法区的运行时数据结构,最后在堆中生成一个代表这个类的
java.lang.Class对象。 - 验证:确保.class 文件的字节流符合 JVM 规范,不会危害 JVM 的安全。验证阶段包括文件格式验证、元数据验证、字节码验证和符号引用验证。
- 准备 :为类的静态变量分配内存,并设置默认初始值。例如,
public static int i = 10;在准备阶段会被设置为 0,而不是 10。 - 解析:将常量池中的符号引用转换为直接引用。符号引用是一组描述目标的字符串,直接引用是指向目标的指针、偏移量或句柄。
- 初始化:执行类的静态代码块和静态变量的赋值操作。初始化阶段是类加载过程的最后一个阶段,只有当类被主动使用时才会触发初始化。
主动使用的情况:
- 创建类的实例
- 调用类的静态方法
- 访问类的静态变量
- 使用反射调用类
- 初始化子类时,先初始化父类
- 启动类(包含 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 类加载的默认机制,它的核心思想是:当一个类加载器收到类加载请求时,它首先会将请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最终都会传送到顶层的启动类加载器。只有当父类加载器无法完成这个请求时,子类加载器才会尝试自己去加载。
类加载器层次:
- 启动类加载器(Bootstrap ClassLoader) :负责加载 JRE/lib 目录下的核心类库,如 rt.jar。它是用 C++ 实现的,不是
ClassLoader的子类。 - 扩展类加载器(Extension ClassLoader):负责加载 JRE/lib/ext 目录下的扩展类库。
- 应用程序类加载器(Application ClassLoader):负责加载用户类路径(classpath)下的类。
- 自定义类加载器:用户自己实现的类加载器,负责加载指定路径下的类。
优点:
- 保证了 Java 核心类库的安全性,防止用户自定义的类覆盖核心类库。
- 保证了类的唯一性,同一个类被同一个类加载器加载,只会生成一个
Class对象。
破坏场景:
- SPI 机制 :如 JDBC 的
DriverManager,需要加载第三方驱动类,而启动类加载器无法加载这些类,因此需要破坏双亲委派模型,使用线程上下文类加载器来加载。 - 热部署:如 Tomcat 的类加载器,每个 Web 应用有自己的类加载器,实现了类的热部署。
- 自定义类加载器 :重写
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 基础部分已经详细介绍过,这里再简单总结一下:
- 强引用:最常见的引用类型,只要强引用存在,垃圾回收器永远不会回收被引用的对象。
- 软引用:用来描述有用但非必需的对象,当内存不足时会被回收,适合实现缓存。
- 弱引用:用来描述非必需的对象,无论内存是否充足,垃圾回收时都会被回收,适合实现临时缓存。
- 虚引用:最弱的引用类型,无法通过虚引用获取对象实例,唯一作用是在对象被垃圾回收时收到通知。
五、Spring 生态(7 个)
39. Spring IoC 的实现原理
**IoC(Inversion of Control,控制反转)** 是 Spring 的核心思想之一,它将对象的创建和依赖关系的管理交给 Spring 容器来完成,而不是由开发者手动创建和管理。
实现原理:
- 配置元数据:开发者通过 XML 配置文件、注解或 Java 配置类来定义 Bean 的信息,包括 Bean 的类名、属性、依赖关系等。
- BeanDefinition :Spring 容器将配置元数据解析为
BeanDefinition对象,BeanDefinition包含了 Bean 的所有信息,如类名、属性、依赖关系、作用域等。 - BeanFactory :Spring 容器的核心接口,负责创建和管理 Bean。
BeanFactory根据BeanDefinition来创建 Bean 实例,并处理 Bean 之间的依赖关系。 - 依赖注入(DI):Spring 容器通过依赖注入来实现控制反转。依赖注入有三种方式:构造器注入、setter 方法注入和字段注入。Spring 容器会自动将 Bean 的依赖注入到 Bean 中。
- Bean 生命周期 :Spring 容器管理 Bean 的整个生命周期,从创建到初始化到销毁。开发者可以通过实现
InitializingBean和DisposableBean接口,或者使用@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 的另一个核心思想,它将横切关注点(如日志、事务、权限等)与业务逻辑分离,提高了代码的复用性和可维护性。
实现原理:
- 切面(Aspect):横切关注点的模块化,如日志切面、事务切面。
- 连接点(JoinPoint):程序执行过程中的某个点,如方法调用、异常抛出等。
- 通知(Advice):切面在连接点上执行的动作,分为前置通知、后置通知、返回通知、异常通知和环绕通知。
- 切入点(Pointcut):匹配连接点的表达式,用于确定哪些连接点需要应用通知。
- 代理(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有两个重要属性:name和type。如果指定了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 定义了七种事务传播行为:
- REQUIRED:默认传播行为。如果当前存在事务,就加入该事务;如果当前没有事务,就创建一个新事务。
- SUPPORTS:如果当前存在事务,就加入该事务;如果当前没有事务,就以非事务方式执行。
- MANDATORY:必须在事务中执行。如果当前存在事务,就加入该事务;如果当前没有事务,就抛出异常。
- REQUIRES_NEW:总是创建一个新事务。如果当前存在事务,就将当前事务挂起,创建一个新事务执行;执行完毕后,恢复原来的事务。
- NOT_SUPPORTED:总是以非事务方式执行。如果当前存在事务,就将当前事务挂起,以非事务方式执行;执行完毕后,恢复原来的事务。
- NEVER:必须以非事务方式执行。如果当前存在事务,就抛出异常。
- 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 定义了五种事务隔离级别:
- DEFAULT:默认隔离级别,使用数据库的默认隔离级别。MySQL 的默认隔离级别是 REPEATABLE READ,Oracle 的默认隔离级别是 READ COMMITTED。
- READ_UNCOMMITTED:读未提交。一个事务可以读取另一个事务未提交的数据。会导致脏读、不可重复读和幻读。
- READ_COMMITTED:读已提交。一个事务只能读取另一个事务已提交的数据。可以避免脏读,但会导致不可重复读和幻读。
- REPEATABLE_READ:可重复读。一个事务在整个过程中多次读取同一数据,结果是一致的。可以避免脏读和不可重复读,但会导致幻读。MySQL 的 InnoDB 引擎通过 MVCC(多版本并发控制)解决了幻读问题。
- 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 的生命周期可以分为以下几个阶段:
- 实例化 :Spring 容器根据
BeanDefinition创建 Bean 实例。 - 属性赋值:Spring 容器注入 Bean 的属性和依赖。
- 初始化 :
- 执行
Aware接口的方法,如BeanNameAware、BeanFactoryAware、ApplicationContextAware等。 - 执行
BeanPostProcessor的postProcessBeforeInitialization()方法。 - 执行
@PostConstruct注解的方法。 - 执行
InitializingBean接口的afterPropertiesSet()方法。 - 执行
init-method属性指定的方法。 - 执行
BeanPostProcessor的postProcessAfterInitialization()方法。
- 执行
- 使用:Bean 可以被应用程序使用。
- 销毁 :
- 执行
@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 的工作流程如下:
- 用户发送请求:用户通过浏览器发送 HTTP 请求到服务器。
- DispatcherServlet 接收请求 :
DispatcherServlet拦截所有的 HTTP 请求。 - HandlerMapping 映射请求 :
DispatcherServlet调用HandlerMapping,根据请求 URL 找到对应的处理器(Handler)。 - HandlerAdapter 调用处理器 :
DispatcherServlet调用HandlerAdapter,由HandlerAdapter调用对应的处理器方法。 - 处理器处理请求 :处理器(Controller)处理请求,返回
ModelAndView对象。 - ViewResolver 解析视图 :
DispatcherServlet调用ViewResolver,根据ModelAndView中的视图名解析出对应的视图(View)。 - 渲染视图:视图将模型数据渲染到页面上。
- 返回响应 :
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:
- 原子性(Atomicity):事务是一个不可分割的工作单位,事务中的所有操作要么全部执行,要么全部不执行。如果事务中的某个操作失败,已经执行的操作会被回滚到事务开始前的状态。
- 一致性(Consistency):事务执行前后,数据库的完整性约束没有被破坏。例如,转账事务中,A 账户减少 100 元,B 账户增加 100 元,总金额不变。
- 隔离性(Isolation):多个事务并发执行时,事务之间相互隔离,一个事务的执行不会影响其他事务的执行。隔离性通过事务的隔离级别来实现。
- 持久性(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 事务部分已经详细介绍过,这里再简单总结一下:
数据库的隔离级别有四种,从低到高依次是:
- 读未提交(READ UNCOMMITTED):会导致脏读、不可重复读和幻读。
- 读已提交(READ COMMITTED):可以避免脏读,会导致不可重复读和幻读。
- 可重复读(REPEATABLE READ):可以避免脏读和不可重复读,会导致幻读。
- 串行化(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. 索引的类型及工作原理
索引是数据库中用于提高查询效率的数据结构,它类似于书籍的目录,可以快速定位到数据的位置。
常见的索引类型:
- 主键索引:基于主键创建的索引,主键索引的叶子节点存储的是整行数据。主键索引是聚簇索引,数据和索引存储在一起。
- 唯一索引:基于唯一键创建的索引,索引列的值必须唯一,但可以为 NULL。
- 普通索引:基于普通列创建的索引,没有唯一性限制。
- 联合索引:基于多个列创建的索引,遵循最左前缀原则。
- 全文索引:用于全文搜索,支持模糊查询和关键词搜索。
工作原理 :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 格式面试复习资料吗?