Java 面试题

Java 语法基础练习题

翻转整数

给定一个 32 位有符号整数,将整数中的数字进行反转。如果反转后整数溢出那么就返回 0。

示例 1:

1
2
输入: 123
输出: 321

示例 2:

1
2
输入: -123
输出: -321
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static int reverse(int num) {
int result = 0;
while (num != 0){
// 获取末尾数字
int pop = num % 10;//123 % 10 = 3; 12 % 10 = 2; 1 % 10 = 1; 0 % 10 = 0
// 获取数字
num /= 10;// 123 / 10 = 12; 12 / 10 = 1; 1 / 10 = 0;
// 判断是否越界
if (result > Integer.MAX_VALUE / 10 || (result == Integer.MAX_VALUE / 10 && pop > 7)){
return 0;
}
if (result < Integer.MIN_VALUE / 10 || (result == Integer.MIN_VALUE / 10 && pop < -8)){
return 0;
}
// 拼接数字
result = result * 10 + pop;// 0 * 10 + 3 = 3; 3 * 10 + 2 = 32; 32 * 10 + 1 = 321;
}
return result;
}

字符串转换整数

请你来实现一个 myAtoi(string s) 函数,使其能将字符串转换成一个 32 位有符号整数。

函数 myAtoi(string s) 的算法如下:

  1. 空格:读入字符串并丢弃无用的前导空格(" "
  2. 符号:检查下一个字符(假设还未到字符末尾)为 '-' 还是 '+'。如果两者都不存在,则假定结果为正。
  3. 转换:通过跳过前置零来读取该整数,直到遇到非数字字符或到达字符串的结尾。如果没有读取数字,则结果为0。
  4. 舍入:如果整数数超过 32 位有符号整数范围 [−231, 231 − 1] ,需要截断这个整数,使其保持在这个范围内。具体来说,小于 −231 的整数应该被舍入为 −231 ,大于 231 − 1 的整数应该被舍入为 231 − 1

返回整数作为最终结果。

示例 1:

输入:s = “42”

输出:42

解释:加粗的字符串为已经读入的字符,插入符号是当前读取的字符。

1
2
3
4
5
6
7
带下划线线的字符是所读的内容,插入符号是当前读入位置。
第 1 步:"42"(当前没有读入字符,因为没有前导空格)
^
第 2 步:"42"(当前没有读入字符,因为这里不存在 '-' 或者 '+')
^
第 3 步:"42"(读入 "42")
^

示例 2:

输入:s = “ -042”

输出:-42

解释:

1
2
3
4
5
6
第 1 步:"   -042"(读入前导空格,但忽视掉)
^
第 2 步:" -042"(读入 '-' 字符,所以结果应该是负数)
^
第 3 步:" -042"(读入 "042",在结果中忽略前导零)
^

示例 3:

输入:s = “1337c0d3”

输出:1337

解释:

1
2
3
4
5
6
第 1 步:"1337c0d3"(当前没有读入字符,因为没有前导空格)
^
第 2 步:"1337c0d3"(当前没有读入字符,因为这里不存在 '-' 或者 '+')
^
第 3 步:"1337c0d3"(读入 "1337";由于下一个字符不是一个数字,所以读入停止)
^

示例 4:

输入:s = “0-1”

输出:0

解释:

1
2
3
4
5
6
第 1 步:"0-1" (当前没有读入字符,因为没有前导空格)
^
第 2 步:"0-1" (当前没有读入字符,因为这里不存在 '-' 或者 '+')
^
第 3 步:"0-1" (读入 "0";由于下一个字符不是一个数字,所以读入停止)
^

示例 5:

输入:s = “words and 987”

输出:0

解释:

读取在第一个非数字字符“w”处停止。

提示:

  • 0 <= s.length <= 200
  • s 由英文字母(大写和小写)、数字(0-9)、' ''+''-''.' 组成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static int parseInt(String str) {
// 去除空格
str = str.trim();
if(str.isEmpty()){
return 0;
}
// 检查符号位
int sign = 1;
int index = 0;
if (str.charAt(0) == '+' || str.charAt(0) == '-') {
sign = str.charAt(0) == '+' ? 1 : -1;
index++;
}
// 转换数字并处理溢出
int result = 0;
while (index < str.length()) {
// 判断是否为数字
char c = str.charAt(index);
// 判断是否为数字
if (!Character.isDigit(c)) break;
// 将字符转换为数字
int digit = c - '0';
// 检查溢出:result * 10 + digit > Integer.MAX_VALUE
if (result > (Integer.MAX_VALUE - digit) / 10) {
return sign == 1 ? Integer.MAX_VALUE : Integer.MIN_VALUE;
}
// 计算结果
result = result * 10 + digit;
// 移动到下一个字符
index++;
}

return sign * result;
}

int digit = c - '0'; 这行代码的作用是将字符 c 转换为对应的数字值。

字符与 ASCII 码的关系

在计算机中,字符通常以 ASCII 码的形式存储。例如:

  • 字符 '0' 的 ASCII 值是 48
  • 字符 '1' 的 ASCII 值是 49
  • 字符 '2' 的 ASCII 值是 50
  • 字符 '9' 的 ASCII 值是 57

因此,字符 '0''9' 的 ASCII 值是连续递增的,差值恰好对应数字值。

代码解析

当你有一个字符变量 c(例如 '3'),执行 c - '0' 时:

  1. c 被隐式转换为对应的 ASCII 值(例如 '3' 的值是 51)。
  2. '0' 的 ASCII 值是 48
  3. 计算差值:51 - 48 = 3,得到的结果就是字符 '3' 对应的数字 3

示例:

1
2
3
char c = '3';
int digit = c - '0'; // 等价于 51 - 48 = 3
System.out.println(digit); // 输出: 3

数组和字符串

Java中String、StringBuffer和StringBuilder的区别是什么?

  • String:不可变字符,每次拼接会创建新的对象。
  • StringBuffer:可变字符,线程安全,多线程环境下性能高。
  • StringBuilder:可变字符,线程不安全,单线程的环境下性能高。

String str = new String(“abc”) 创建了几个对象?

  • 判断字符串常量池中是否存在字面量 "abc"
    • 若不存在:JVM 会在常量池中创建一个 String 对象,值为 "abc"
    • 若已存在:跳过此步骤,不创建新对象。
  • 无论常量池中是否已有 "abc"new String("abc") 都会在堆内存中创建一个新的 String 对象,该对象的值复制自常量池中的 "abc"
1
2
3
4
5
String str1 = new String("abc");  // 最多创建 2 个对象(常量池 + 堆)
String str2 = new String("abc"); // 最多创建 1 个对象(仅堆,常量池已存在 "abc")

System.out.println(str1 == str2); // false,堆中对象地址不同
System.out.println(str1.equals(str2)); // true,内容相同

String str1 = new String(“abc”) 和 String str2 = “abc” 的区别?

  • new String("abc")
    • 无论如何都会在堆中创建新对象,且可能在常量池中创建对象(若字面量不存在)。
    • 内存占用:至少 1 个对象(堆),最多 2 个对象(堆 + 常量池)。
  • String str2 = "abc";
    • 仅在常量池中创建对象(若不存在),否则直接引用常量池中的对象。
    • 内存占用:0 或 1 个对象(仅当常量池不存在 "abc" 时创建)。

intern方法有什么作用?

intern()String 类的一个 native 方法,用于手动将字符串对象添加到 字符串常量池 中,并返回常量池中的引用。其核心作用是 复用字符串对象,节省内存。以下是详细解析:

1. 基本原理

检查常量池:调用str.intern()时,JVM 会先检查字符串常量池中是否已存在与str内容相同equals() 为 true)的字符串。

  • 若存在:直接返回常量池中的引用,即使 str 本身在堆中。
  • 若不存在:将 str 的内容添加到常量池(JDK 7+ 后直接复制引用,而非创建新对象),并返回该引用。

2. 关键区别(JDK 6 vs JDK 7+)

  • JDK 6 及以前:常量池存放在永久代(方法区),intern() 会在常量池 复制字符串对象(创建新对象)。
  • **JDK 7+**:常量池移至堆内存,intern()直接引用堆中的对象,而非复制。

面向对象

抽象类和接口有什么区别

抽象类 接口
定义与语法
使用 abstract class 声明 使用 interface 声明
可以包含普通方法和抽象方法 只能包含抽象方法(Java 8+ 允许默认方法和静态方法)
抽象方法使用 abstract 关键字声明 方法默认是 public abstract,无需显式声明
可以有构造器 不能有构造器
继承与实现
子类使用 extends 关键字继承 类使用 implements 关键字实现接口
一个类只能继承一个抽象类 一个类可以实现多个接口
子类必须实现所有抽象方法 实现类必须实现所有抽象方法(除非类是抽象类)
成员变量
可以有各种访问修饰符的成员变量 只能有 public static final 常量
示例:protected int age; 示例:int MAX_SPEED = 100;(隐式为 public static final)
设计目的
表示 “is-a” 关系,定义类的基本特征和行为 表示 “can-do” 关系,定义类的额外能力
用于代码复用和共享公共状态 用于实现多态和松耦合
适合作为相关类的基类 适合为不相关类提供通用功能
应用场景
当需要共享代码和状态时 当需要定义行为规范但不关心实现时
当类之间有共同的属性和行为时 当多个不相关的类需要实现同一功能时
例如:java.io.InputStream 例如:java.util.Comparator
  • 使用抽象类:当需要定义类的基本行为,且有共享代码或状态时。
  • 使用接口:当需要定义行为规范,且希望被多个不相关的类实现时。

Java中的参数传递是按值,还是按引用?

在Java中,参数传递只有按值传递,无论是基本类型还是引用类型。

什么是反射?

反射是 Java 在运行时动态获取类的信息(如方法、字段、构造器等),并可以动态调用对象的方法或操作字段的能力。核心类位于java.lang.reflect包中。

  • 反射原理:

    Java 程序的执行分为编译和运行两步,编译之后会生成字节码(.class)文件,JVM 进行类加载的时候,会加载字节码文件,将类型相关的所有信息加载进方法区,反射就是去获取这些信息,然后进行各种操作。

  • 基本用法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 获取Class对象的三种方式
    Class<?> clazz = MyClass.class; // 方式1:类名.class
    Class<?> clazz = obj.getClass(); // 方式2:对象实例.getClass()
    Class<?> clazz = Class.forName("com.MyClass"); // 方式3:全限定名

    // 通过反射创建对象
    Constructor<?> constructor = clazz.getConstructor();
    MyClass obj = (MyClass) constructor.newInstance();

    // 通过反射调用方法
    Method method = clazz.getMethod("methodName", paramTypes);
    method.invoke(obj, args);

    // 通过反射访问字段
    Field field = clazz.getDeclaredField("fieldName");
    field.setAccessible(true); // 打破私有访问限制
    field.set(obj, value);

反射在你平时写代码或者框架中的应用场景有哪些?

框架 反射应用场景 具体实现
Spring 依赖注入(IoC) 通过反射创建Bean实例并注入依赖
AOP动态代理 动态生成代理类并拦截方法调用
注解处理(@Autowired等) 扫描并解析类/方法/字段上的注解
Hibernate/MyBatis ORM映射 通过反射将数据库结果集映射到Java对象
懒加载机制 动态生成实体类的代理对象
JUnit 测试用例发现和执行 通过反射查找带@Test注解的方法并执行

什么是Java中的动态代理?

动态代理是 Java 在运行时动态创建代理类和对象的机制,它允许在不修改原始类代码的情况下,通过代理对象控制对原始对象的访问。代理对象可以拦截对目标对象的方法调用,在方法执行前后插入自定义逻辑,增强功能(如日志、事务),实现 AOP(面向切面编程)。

核心机制

  1. JDK 动态代理:基于接口实现,要求目标类必须实现至少一个接口。
  2. CGLIB 动态代理:基于继承实现,通过生成目标类的子类拦截方法调用。
  3. 核心类
    • java.lang.reflect.Proxy:生成代理实例。
    • java.lang.reflect.InvocationHandler:处理方法调用的拦截逻辑。

JDK的动态代理和CGLIB的动态代理有什么区别?

特性 JDK 动态代理 CGLIB 动态代理
依赖接口 必须实现接口 无需接口,基于继承
代理方式 生成实现接口的代理类 生成继承目标类的子类
性能 创建快,调用慢(反射) 创建慢,调用快(字节码)
适用场景 接口导向的框架(如 Spring AOP) 无接口的类(如 Spring 的 @Service)
  • JDK 动态代理示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    // 1. 定义接口
    interface UserService {
    void saveUser(String username);
    }

    // 2. 实现接口
    class UserServiceImpl implements UserService {
    @Override
    public void saveUser(String username) {
    System.out.println("Saving user: " + username);
    }
    }

    // 3. 实现InvocationHandler
    class LoggingHandler implements InvocationHandler {
    private final Object target; // 目标对象

    public LoggingHandler(Object target) {
    this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    // 前置增强
    System.out.println("Before method: " + method.getName());

    // 调用目标方法
    Object result = method.invoke(target, args);

    // 后置增强
    System.out.println("After method: " + method.getName());

    return result;
    }
    }

    // 4. 创建代理对象并使用
    public class Main {
    public static void main(String[] args) {
    UserService target = new UserServiceImpl();

    // 创建代理对象
    UserService proxy = (UserService) Proxy.newProxyInstance(
    UserService.class.getClassLoader(),
    new Class<?>[]{UserService.class},
    new LoggingHandler(target)
    );

    // 调用代理方法
    proxy.saveUser("Alice");
    }
    }
  • CGLIB 动态代理示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 1. 定义目标类(无需实现接口)
class UserService {
public void saveUser(String username) {
System.out.println("Saving user: " + username);
}
}

// 2. 实现MethodInterceptor
class LoggingInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("Before method: " + method.getName());
Object result = proxy.invokeSuper(obj, args); // 调用父类(目标类)方法
System.out.println("After method: " + method.getName());
return result;
}
}

// 3. 创建代理对象
public class Main {
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserService.class); // 设置父类(目标类)
enhancer.setCallback(new LoggingInterceptor());

UserService proxy = (UserService) enhancer.create();
proxy.saveUser("Bob");
}
}

什么是Java 的SPI 机制?

SPI(Service Provider Interface) 是 Java 提供的一种服务发现机制,允许第三方实现接口并通过配置文件动态加载,它通过在运行时动态加载接口实现类,实现模块解耦可插拔架构。SPI 的核心思想是”面向接口编程 + 约定优于配置“。

  • 工作流程

    graph TD
        A[定义服务接口] --> B[创建实现类]
        B --> C[配置META-INF/services文件]
        C --> D[ServiceLoader加载]
        D --> E[获取实现实例]

    示例代码:

    1. 定义服务接口

      1
      2
      3
      4
      // 服务接口
      public interface DataSource {
      Connection getConnection();
      }
    2. 实现服务接口

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // MySQL实现
      public class MySQLDataSource implements DataSource {
      @Override
      public Connection getConnection() {
      return new MySQLConnection();
      }
      }

      // PostgreSQL实现
      public class PostgreSQLDataSource implements DataSource {
      @Override
      public Connection getConnection() {
      return new PostgreSQLConnection();
      }
      }
    3. 配置实现类

      META-INF/services/com.example.DataSource文件中写入:

      1
      2
      com.example.MySQLDataSource
      com.example.PostgreSQLDataSource
    4. ServiceLoader加载并使用

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      ServiceLoader<DataSource> loader = ServiceLoader.load(DataSource.class);

      // 遍历所有实现
      for (DataSource ds : loader) {
      Connection conn = ds.getConnection();
      // 使用连接...
      }

      // 获取指定实现(需自行判断)
      Optional<DataSource> mysqlDs = loader.stream()
      .filter(p -> p.type().getName().contains("MySQL"))
      .map(ServiceLoader.Provider::get)
      .findFirst();

SPI 与 API 的区别

API SPI
应用程序编程接口:框架或库提供的功能调用接口,用于被应用程序调用。 服务提供者接口:框架定义的标准接口,允许第三方提供实现,用于框架扩展。
调用方向:应用程序→API→框架 调用方向:框架→SPI→第三方实现
示例:Java 标准库的java.util.List接口 示例:JDBC 的java.sql.Driver接口
  • API:你调用别人提供的功能(如使用HashMap

    image-20250708144051789
  • SPI:别人调用你实现的扩展(如为JDBC提供新驱动)

    image-20250708144106322

单例模式有几种实现方式?

  1. 饿汉式(线程安全,非懒加载)

    类加载时直接初始化实例,借助 JVM 类加载机制保证线程安全(类加载过程是单线程的)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class HungrySingleton {

    // 类加载时直接实例化
    private static final HungrySingleton INSTANCE = new HungrySingleton();

    //私有构造器,防止外部实例化
    private HungrySingleton(){};

    //全局访问点
    private static HungrySingleton getInstance(){
    return INSTANCE;
    }
    }

    特点

    • 优点:实现简单,线程安全(类加载时初始化)。
    • 缺点:非懒加载(类加载即实例化),若实例占用资源大且长期不使用,会浪费内存。
  2. 懒汉式(线程不安全,懒加载)

延迟初始化实例,仅在第一次调用getInstance()时创建,但多线程环境下会产生多个实例(线程不安全)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class LazySingletonUnsafe {

private static LazySingletonUnsafe instance;

private LazySingletonUnsafe(){}

// 线程不安全:多线程同时进入if判断时,会创建多个实例
private static LazySingletonUnsafe getInstance() {
if (instance == null){
instance = new LazySingletonUnsafe();
}
return instance;
}
}
  • 优点:懒加载,节省资源。
  • 缺点:线程不安全,多线程环境下失效,实际开发中禁止使用。
  1. 懒汉式(线程安全,同步方法)

    getInstance()方法上添加synchronized关键字,强制多线程串行执行,保证线程安全。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class LazySingletonSafeSyncMethod {

    private static LazySingletonSafeSyncMethod instance;

    private LazySingletonSafeSyncMethod(){};

    // 同步方法:多线程需排队执行,保证线程安全
    private static synchronized LazySingletonSafeSyncMethod getInstance(){
    if (instance == null){
    instance = new LazySingletonSafeSyncMethod();
    }
    return instance;
    }
    }
    • 优点:线程安全,懒加载。
    • 缺点:synchronized修饰整个方法,并发效率低(即使实例已创建,每次调用仍需排队)。
  2. 双重检查锁(DCL,线程安全,懒加载)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class DCLSingleton {

    // volatile防止指令重排:避免"半初始化"实例被其他线程获取
    private static volatile DCLSingleton instance;

    private DCLSingleton (){}

    public static DCLSingleton getInstance() {
    // 第一次判空:避免已创建实例后仍进入同步块(提高效率)
    if (instance == null){
    synchronized (DCLSingleton.class){
    // 第二次判空:防止多线程同时通过第一次判空后,重复创建实例
    if (instance == null){
    instance = new DCLSingleton();
    // 注:new操作分3步:1.分配内存 2.初始化 3.赋值给instance
    // 若不加volatile,可能发生指令重排(1→3→2),导致其他线程获取"半初始化"实例
    }
    }
    }
    return instance;
    }
    }

    特点

    • 优点:懒加载,线程安全,并发效率高(仅首次创建时同步)。
    • 关键:volatile关键字不可省略,否则可能因指令重排导致线程安全问题。
  3. 静态内部类(线程安全,懒加载)

    利用 JVM “静态内部类按需加载” 的特性实现懒加载,同时借助类加载机制保证线程安全。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class StaticInnerClassSingleton {
    // 静态内部类:仅在调用getInstance()时才会加载
    private static class InnerClass {
    private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
    }

    private StaticInnerClassSingleton() {}

    public static StaticInnerClassSingleton getInstance() {
    return InnerClass.INSTANCE;
    }
    }

    原理

    • 外部类加载时,静态内部类不会被加载,实现懒加载。
    • 首次调用getInstance()时,静态内部类加载,其静态变量INSTANCE初始化(JVM 保证类加载过程线程安全),确保唯一实例。
      特点:实现简洁,线程安全,懒加载,效率高,是推荐的实现方式之一。
  4. 枚举单例(线程安全,天然防反射 / 序列化)

    利用枚举的特性(JVM 保证枚举实例唯一且不可变)实现单例,是《Effective Java》推荐的最佳方式。

    1
    2
    3
    4
    5
    6
    7
    8
    public enum EnumSingleton {
    INSTANCE; // 唯一实例

    // 枚举可定义方法
    public void doSomething() {
    // 业务逻辑
    }
    }

    特点

    • 优点:线程安全(JVM 保证枚举实例仅被初始化一次),天然防止反射和序列化破坏单例,实现极简。
    • 缺点:无法懒加载(枚举类加载时即初始化实例),若实例依赖参数初始化,灵活性较低。

如何避免反射和序列化破坏单例?

  1. 反射破坏单例及解决方案

    反射破坏原理:通过Class.getDeclaredConstructor()获取私有构造器,调用setAccessible(true)取消访问检查后,可多次调用newInstance()创建实例。

    示例(破坏 DCL 单例)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class TestReflect {
    public static void main(String[] args) throws Exception {
    // 获取单例实例
    DCLSingleton instance1 = DCLSingleton.getInstance();

    // 反射获取私有构造器
    Constructor<DCLSingleton> constructor = DCLSingleton.class.getDeclaredConstructor();
    constructor.setAccessible(true); // 绕过私有访问限制
    DCLSingleton instance2 = constructor.newInstance(); // 创建新实例

    System.out.println(instance1 == instance2); // false(单例被破坏)
    }
    }

    解决方案:在私有构造器中添加判断,若已有实例则抛出异常,阻止重复创建。

    1
    2
    3
    4
    5
    6
    private DCLSingleton() {
    // 防止反射破坏:若已有实例,抛异常
    if (instance != null) {
    throw new RuntimeException("禁止通过反射创建实例");
    }
    }
  2. 序列化破坏单例及解决方案

    序列化破坏原理:单例实例序列化后写入磁盘,反序列化时会通过反射创建新实例(即使构造器私有),导致单例失效。

    示例(破坏静态内部类单例)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class TestSerialize {
    public static void main(String[] args) throws Exception {
    // 获取单例实例
    StaticInnerClassSingleton instance1 = StaticInnerClassSingleton.getInstance();

    // 序列化
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.obj"));
    oos.writeObject(instance1);

    // 反序列化
    ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.obj"));
    StaticInnerClassSingleton instance2 = (StaticInnerClassSingleton) ois.readObject();

    System.out.println(instance1 == instance2); // false(单例被破坏)
    }
    }

    解决方案:在单例类中重写readResolve()方法,返回已有的单例实例,覆盖反序列化生成的新对象。

    1
    2
    3
    4
    5
    6
    7
    8
    public class StaticInnerClassSingleton implements Serializable {
    // ... 其他代码同上 ...

    // 反序列化时,JVM会调用此方法,返回已有实例
    private Object readResolve() {
    return InnerClass.INSTANCE;
    }
    }
  3. 枚举单例为何天然防破坏?

    • 防反射:JVM 对枚举的构造器做了特殊保护,反射调用newInstance()时会直接抛出IllegalArgumentException,无法创建新实例。
    • 防序列化:枚举的序列化机制与普通类不同,反序列化时不会创建新实例,而是直接返回枚举常量(通过Enum.valueOf()获取已有实例)。

泛型

Java中泛型擦除是什么?

泛型擦除是 Java 泛型的核心机制,指在编译后,泛型类型参数会被擦除,替换为其上限类型(通常是Object),从而使代码在运行时不再保留泛型类型信息

Java集合框架

ArrayList 和 LinkedList 有什么区别?

维度 ArrayList LinkedList
数据结构 动态数组 双向链表
随机访问 支持高效随机访问(O(1) 不支持高效随机访问(O(n)
插入 / 删除效率 尾部插入快(O(1)),中间插入慢(O(n) 任意位置插入 / 删除快(O(1),需先定位)
内存占用 连续内存空间,可能有预分配冗余 非连续内存,每个节点包含前后引用
扩容机制 自动扩容(默认增长原来的 1.5 倍) 无需扩容,按需分配节点
适用场景 频繁随机访问,较少插入删除 频繁插入删除,较少随机访问

ArrayList线程安全吗?把ArrayList变成线程安全有哪些方法?

不是线程安全的,ArrayList变成线程安全的方式有:

  • 使用Collections类的synchronizedList方法将ArrayList包装成线程安全的List:

    1
    List<String> synchronizedList = Collections.synchronizedList(arrayList);
  • 使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现:

    1
    CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>(arrayList);
  • 使用Vector类代替ArrayList,Vector是线程安全的List实现:

    1
    Vector<String> vector = new Vector<>(arrayList);

说一下CopyOnWriteArrayList

CopyOnWriteArrayList是Java为读多写少场景设计的线程安全列表,它通过写时复制机制避免了读操作的锁开销,实现了读写分离。但由于每次写操作都要复制数组,会带来内存和性能开销,因此适用于读密集、写较少且允许弱一致性的场景。在实际项目中,我们需要根据读写比例和一致性要求选择合适的容器。

  • 原理
    • 写操作:在执行add()remove()等修改操作时,会先复制原数组,在新数组上完成修改,最后用新数组替换原数组。
    • 读操作:直接访问原数组,无需加锁,因此读操作无阻塞。
  • 优点:读操作高效,线程安全,迭代器弱一致性避免并发修改异常。
  • 缺点:写操作开销大(复制数组),占用更多内存,不适合写频繁的场景。

说一下 Collections.synchronizedList

Collections.synchronizedList 是 Java 提供的一个工具方法,它位于 java.util.Collections 类中。用于将普通列表(如 ArrayListLinkedList)转换为线程安全的列表。它会对每个访问方法(如 get, set, add, remove)进行同步(加 synchronized 锁),从而保证线程安全。

CopyOnWriteArrayList和Collections.synchronizedList的区别

特性 Collections.synchronizedList CopyOnWriteArrayList
实现方式 同步包装器,使用 synchronized 写时复制(Copy-On-Write)
锁粒度 全局锁(所有操作串行化) 无锁读,写操作加锁
读性能 低(需获取锁) 高(无需锁)
写性能 中(仅需获取锁) 低(需复制数组)
迭代器安全性 不安全,需手动同步 安全(基于快照)
适用场景 读写频率相近,且需要弱一致性 读多写少,且允许弱一致性

ArrayList的扩容机制?

当往 ArrayList 中添加元素时,会先检查是否需要扩容,如果当前容量+1 超过数组长度,就会进行扩容。

扩容后的新数组长度是原来的 1.5 倍,然后再把原数组的值拷贝到新数组中。

说一下HashMap的底层数据结构

HashMap 的底层是数组 + 链表 + 红黑树的复合结构(JDK1.7之前是数组 + 链表 )。

  • 数组是主体,每个元素是一个桶;当发生哈希冲突时,冲突的元素会形成链表;
  • 当链表长度超过 8 且数组长度 ≥64 时,链表会转换为红黑树以提升查询效率。
  • 这种设计平衡了空间和时间复杂度,既避免了开放寻址法的扩容开销,又通过树化优化了链表过长的问题。
  • 在 JDK 8 中,插入方式从头插法改为尾插法,避免了多线程环境下的链表成环问题,但仍不保证线程安全。

哈希冲突解决方法有哪些?

  • 拉链法(链地址法):每个哈希桶中维护一个链表(或其他数据结构),冲突的元素都存入该链表。
    • 链表法:
      • 原理:JDK 7 及以前的 HashMap 采用此方法。当冲突发生时,将元素插入链表头部(头插法)。
      • 优点:实现简单,无需扩容整个哈希表,适用于冲突频繁的场景。
      • 缺点:链表过长时查询效率为 O (n)。
    • 链表 + 红黑树:
      • 原理:JDK 8 后的 HashMap 优化:当链表长度超过阈值(8)且哈希表容量 ≥64 时,将链表转换为红黑树(查询效率 O (log n))。
      • 优点:高效处理大量冲突,避免链表过长导致的性能问题。
  • 再哈希法:当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
  • 开放寻址法:当发生冲突时,直接在哈希表中寻找下一个空闲位置。常见的开放寻址方法包括线性探测、二次探测和双重散列。

HashMap和HashTable的区别

  1. 线程安全:HashMap 非线程安全,HashTable 线程安全(但性能差,推荐用 ConcurrentHashMap)。
  2. 空值支持:HashMap 允许 null 键和 null 值,HashTable 不允许。
  3. 历史设计:HashMap 继承自 AbstractMap,HashTable 继承自 Dictionary(已过时)。
  4. 容量与扩容:HashMap 初始容量 16 且必须为 2 的幂,HashTable 初始 11,扩容为 2n+1。
  5. 性能:HashMap 单线程性能更高,HashTable 因全局锁性能较差。

在实际开发中,优先使用 HashMap,仅在需要线程安全时考虑 ConcurrentHashMap,避免使用 HashTable。

ConcurrentHashMap和HashTable的区别

  1. 锁机制:ConcurrentHashMap 在 JDK 8 采用 CAS + synchronized 锁定桶节点,并发度更高;HashTable 使用全局锁。
  2. 性能:ConcurrentHashMap 读无锁,写仅锁冲突节点,性能远优于 HashTable。
  3. 数据结构:JDK 8 的 ConcurrentHashMap 采用数组+链表+红黑树;HashTable与 HashMap 一致,但所有操作同步。
  4. 空值支持:两者均不允许 null 键值。
  5. 迭代器:ConcurrentHashMap 弱一致性,不抛异常;HashTable 可能抛出 ConcurrentModificationException。

在实际开发中,强烈推荐使用 ConcurrentHashMap 替代 HashTable,尤其在高并发场景下,其性能优势显著。

HashSet和HashMap的区别

  1. 接口与功能:HashSet 实现 Set 接口,存储唯一元素;HashMap 实现 Map 接口,存储键值对。
  2. 底层实现:HashSet 依赖 HashMap,将元素作为 key 存储,value 固定为 PRESENT。
  3. null 支持:两者均允许 1 个 null 键(HashSet 的 null 元素),但 HashMap 还允许多个 null 值。
  4. 应用场景:HashSet 用于去重和存在性检查,HashMap 用于键值映射。

在实际开发中,若只需存储不重复元素,选择 HashSet;若需键值关联,选择 HashMap。

HashMap的put流程

  1. 计算 key 的哈希值(高 16 位与低 16 位异或),减少哈希冲突。

    1
    2
    3
    4
    5
    6
    7
    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    //确定桶位置。
    index = (n - 1) & hash // n 为数组长度,必须是 2 的幂次方
  2. 若数组未初始化,先进行扩容(初始容量 16)。

  3. 通过哈希值和数组长度取模确定桶位置。

  4. 处理桶内节点:

    • 若桶为空,直接插入新节点。
    • 若首节点 key 匹配,覆盖旧值。
    • 若是树节点,调用树插入逻辑。
    • 若是链表,遍历链表:
      • 找到相同 key 则覆盖。
      • 未找到则链尾插入,若链表长度 ≥8 且数组长度 ≥64,将链表转换为红黑树。
  5. 插入后检查元素数量是否超过阈值(容量 × 加载因子),超过则扩容。

JDK 8 对 put 方法进行了优化,采用尾插法避免多线程下的链表成环问题,并通过红黑树优化了链表过长时的性能。

HashMap的扩容机制

  1. 当元素数量超过阈值(capacity * loadFactor)即 容量(初始化16) * 负载因子(默认 0.75) 时,触发扩容。

  2. 创建一个新的数组,容量为原来的2倍,并重新计算阈值。

    1
    newCap = oldCap << 1(例如,1632)。
  3. 数据迁移

    • 通过 hash & oldCap 将节点分为两类:结果为 0 则留在原索引,否则移至原索引+oldCap。
    • JDK 8 优化:将链表拆分为低位链和高位链,避免链表反转,提高效率。
  4. 树化与反树化

    • 若链表长度 ≥8 且容量 ≥64,转换为红黑树。
    • 扩容后若树节点数 ≤6,红黑树退化为链表。

讲讲 LinkedHashMap

LinkedHashMap 是 HashMap 的子类,它在哈希表的基础上增加了双向链表,用于维护元素的插入顺序或访问顺序。

LinkedHashMap 在 HashMap 的基础上维护了一个双向链表,通过 before 和 after 标识前置节点和后置节点。

讲讲TreeMap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TreeMap 是基于红黑树实现的有序 Map,主要特点如下:

1. **键有序**:
- 自然顺序:键必须实现 Comparable 接口。
- 自定义顺序:通过构造函数传入 Comparator。

2. **核心数据结构**:
- 红黑树:自平衡二叉搜索树,确保操作的时间复杂度为 O(log n)。
- 每个节点包含颜色属性,通过旋转和变色维持平衡。

3. **导航方法**:
- 提供 ceilingKey、floorKey 等方法,高效查找相邻键。
- 支持子Map操作,返回指定范围的视图。

4. **适用场景**:
- 需要按键排序的场景(如时间范围查询、数值区间统计)。
- 需频繁查找相邻元素的场景。

讲讲ConcurrentHashMap

ConcurrentHashMap 是 Java 提供的线程安全哈希表,主要特点如下:

  1. JDK 8 优化
    • 数据结构:数组 + 链表 + 红黑树,与 HashMap 一致。
    • 锁机制:抛弃JDK1.7之前使用的分段锁,采用 CAS + synchronized 锁定桶头节点,并发度更高。
  2. 核心优势
    • 高效并发:读操作无锁,写操作仅锁冲突的桶,性能远优于 HashTable。
    • 弱一致性:迭代器不抛出 ConcurrentModificationException,允许遍历期间修改。
  3. 原子操作
    • 提供 putIfAbsent、computeIfAbsent 等原子方法,避免复合操作的竞态条件。
  4. 适用场景
    • 高并发读写场景(如缓存、计数器)。
    • 需线程安全但不希望使用全局锁的场景。

相比 HashTable,ConcurrentHashMap 的锁粒度更小,读操作无锁,性能显著提升;相比 Collections.synchronizedMap,它提供了更丰富的原子方法和更高的并发度。

ConcurrentHashMap 为什么 key 和 value 不能为 null?

  1. 避免歧义
    • 在并发场景下,若允许 null,当 get(key) 返回 null 时,无法区分是 key 不存在还是 value 为 null,可能导致逻辑错误。
  2. 原子操作的语义
    • 原子方法(如 putIfAbsent)依赖返回值判断操作结果。若允许 null,返回 null 无法明确是插入成功还是 key 已存在,破坏原子性。
  3. 并发复杂性
    • 弱一致性迭代器难以处理 null 值的语义,增加实现复杂度。

相比之下,HashMap 允许 null 是为了单线程下的灵活性,但在并发环境中,这种灵活性会带来安全隐患。因此,ConcurrentHashMap 选择严格设计,强制用户处理 null 的情况,避免潜在问题。