Java基础面试题

1. Java 中 hashCode 和 equals 方法是什么?它们与 == 操作符有什么区别?

1.1 回答重点

hashCode、equals== 都是 Java 中用于比较对象的三种方式,但是它们的用途和实现还是有挺大区别的。

  • hashCode 用于散列存储结构中确定对象的存储位置。可用于快速比较两个对象是否不同,因为如果它们的哈希码不同,那么它们肯定不相等。
  • equals 用于比较两个对象的内容是否相等,通常需要重写自定义比较逻辑。
  • == 用于比较两个引用是否指向同一个对象(即内存地址)。对于基本数据类型,比较它们的值

1.2 扩展知识

1.2.1 hashCode

方法返回对象的哈希码(整数),主要用于支持基于哈希表的集合,用来确定对象的存储位置,如 HashMap、HashSet 等。

Object 类中的默认实现会根据对象的内存地址生成哈希码(native 方法)。

Object中的hashCode方法

Java 中,hashCode 方法和 equals 方法之间有一个 “合约”

  • 如果两个对象根据 equals 方法被认为是相等的,那么它们必须具有相同的哈希码。
  • 如果两个对象具有相同的哈希码,它们并不一定相等,但会被放在同一个哈希桶中。

1.2.2 equals

用于比较两个对象的内容是否相等。Object 类中的默认实现会使用 == 操作符来比较对象的内存地址。

Object中的equals方法

通常我们需要在自定义类中重写 equals 方法,以基于对象的属性进行内容比较。比如你可以自定义两个对象的名字一样就是相等的、年龄一样就是相等,可以灵活按照需求定制。

如果两个对象的 equals 方法返回 true,则它们的hashCode 方法必须返回相同的值,反之则不需要。

对于 equals 定义的比较,实际上还有以下五个要求:

  • 自反性:对于任何非空引用值 xx.equals(x) 必须返回 true
  • 对称性:对于任何非空引用值 xy,如果 x.equals(y) 返回 true,则 y.equals(x) 也必须返回 true
  • 传递性:对于任何非空引用值 xyz,如果 x.equals(y) 返回 truey.equals(z) 返回 true,则 x.equals(z) 也必须返回 true
  • 一致性:对于任何非空引用值 xy,只要对象在比较中没有被修改,多次调用 x.equals(y) 应返回相同的结果。
  • 对于任何非空引用值 xx.equals(null) 必须返回 false

1.2.3 ==

== 操作符用于比较两个引用是否指向同一个对象(即比较内存地址),如果是基本数据类型,== 直接比较它们的值。

2. Java 中的 hashCode 和 equals 方法之间有什么关系?

2.1 回答重点

在 Java 中,hashCode()equals() 方法的关系主要体现在集合类(如 HashMapHashSet)中。

它俩决定了对象的逻辑相等性哈希存储方式

equals() 方法

  • 用于判断两个对象是否相等。默认实现是使用 == 比较对象的内存地址,但可以在类中重写 equals() 来定义自己的相等逻辑。

hashCode() 方法

  • 返回对象的哈希值,主要用于基于哈希的集合(如 HashMapHashSet)。同一个对象每次调用 hashCode() 必须返回相同的值,且相等的对象必须有相同的哈希码。

2.1.2 两者的关系:

如果两个对象根据 equals() 相等,它们的 hashCode() 值必须相同。即a.equals(b) == true,那么 a.hashCode() == b.hashCode() 必须为 true

但是反过来不要求成立:即两个对象的 hashCode() 相同,不一定 equals() 相等。

注意:如果违背上述关系会导致在基于哈希的集合中出现错误行为。例如,HashMap 可能无法正确存储和查找元素。

2.2 扩展知识:

2.2.1 为什么要重写 hashCode() 和 equals()

因为在使用 HashMapHashSet 等集合时,这些集合内部依赖 hashCode()equals() 方法来确定元素的存储位置。如果没有正确地重写这两个方法,集合可能无法正确判断对象的相等性,导致重复存储、查找失败等问题。

2.2.2 重写 equals() 方法的基本规则:

  • 自反性:对于任何非空对象引用 xx.equals(x) 必须为 true
  • 对称性:对于任何非空对象引用 xyx.equals(y) 应当等于 y.equals(x)
  • 传递性:如果 x.equals(y) == truey.equals(z) == true,那么 x.equals(z) 必须为 true
  • 一致性:只要对象未发生改变,多次调用 x.equals(y) 结果应该一致。
  • 对于 null:对于任何非空对象引用 xx.equals(null) 必须返回 false

2.2.3 重写 hashCode()方法的基本规则:

  • 在相同的应用程序执行过程中,对于同一个对象多次调用 hashCode() 必须返回相同的值。
  • 如果两个对象根据 equals() 方法相等,则它们的 hashCode() 值必须相等。
  • 但是,如果两个对象 equals() 不相等,则它们的 hashCode() 值不必不同,但不同的 hashCode() 值可以提高哈希表的性能。

2.2.4 hashCode & equals & 集合源码分析

hashCode 是属于 Object 的一个方法,并且是个 native 方法,本质就是返回一个哈希码,即一个 int 值,一般是一个对象的内存地址转成的整数。

Object中的hashCode方法

equals,我们知道是用来判断两个对象是否相同的,也是属于 Object 的一个方法,并且默认实现如下:

Object中的equals方法

看到这,是不是觉得 hashCode 和 equals 没啥关系啊?为什么要放在一起说?

确实,一般情况下两者是没啥关系。但,如果是将一个对象用在散列表的相关类的时候,是有关系的。

比如 HashSet,我们常用来得到一个不重复的集合。

现在有个A类的 HashSet 集合,我只重写了该类的 equals 方法,表明如果 name 相同就返回 true:

public boolean equals(Object obj) {
    if (this == obj) {
        return true;
    }
    if (obj instanceof A) {
        A other = (A) obj;
        return name.equals(other.name);
    }
    return false;
}

就重写一个 equals 的话,HashSet 中会出现相同 name 的 A对象。

原因就是 hashCode 没有重写,那为什么会这样呢?因为 HashSet 是复用 HashMap 的能力存储对象,而塞入 key 的时候要计算 hash 值,可以看到这里实际会调用对象的 hashCode 方法来计算 hash 值。

HashMap中的put方法

然后在具体执行 putVal 方法的时候,相关的判断条件会先判断 hash 值是否相等,如果 hash 值都不同,那就认为这两个对象不相等,这与我们之前设定的 name 一样的对象就是相等的条件就冲突了,我们简单看下源码就清楚了:

HashMap中的putVal方法

可以看到,相关的判断条件都是先判断 hash 值,如果 hash 值相等,才会接着判断 equals。如果 hash 值不等,这个判断条件直接就 false 了。

因此规定,重写 equals 方法的时候,也要重写 hashCode 方法,这样才能保持条件判断的同步。我建议不管会不会用到散列表,只要你重写 equals 就一起重写 hashCode ,这样肯定不会出错。

3. 什么是 Java 中的动态代理?

3.1 回答重点

Java 中的动态代理是一种在运行时创建代理对象的机制。动态代理允许程序在运行时决定代理对象的行为,而不需要在编译时确定。它通过代理模式为对象提供了一种机制,使得可以在不修改目标对象的情况下对其行为进行增强或调整。

代理可以看作是调用目标的一个包装,通常用来在调用真实的目标之前进行一些逻辑处理,消除一些重复的代码。

静态代理指的是我们预先编码好一个代理类,而动态代理指的是运行时生成代理类。

3.2 扩展知识

3.2.1 动态代理主要用途

  • 简化代码:通过代理模式,可以减少重复代码,尤其是在横切关注点(如日志记录、事务管理、权限控制等)方面。
  • 增强灵活性:动态代理使得代码更具灵活性和可扩展性,因为代理对象是在运行时生成的,可以动态地改变行为。
  • 实现 AOP:动态代理是实现面向切面编程(AOP, Aspect-Oriented Programming)的基础,可以在方法调用前后插入额外的逻辑。

3.2.2 Java 动态代理与 CGLIB 代理:

  • Java 动态代理:只能对接口进行代理,不支持对类进行代理。
  • CGLIB 代理:通过字节码技术动态生成目标类的子类来实现代理,支持对类(非接口)进行代理

JDK动态代理示例代码:

package com.example.proxy;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

/**
 * @author 念心卓
 * @version 1.0
 * @description: JDK动态代理示例
 * @date 2025/3/8 10:00
 */
public class JdkDynamicDemo {
    public static void main(String[] args) {
        // 1. 创建目标对象实例
        IServiceOne serviceOne = new ServiceOneImpl();

        // 2. 创建代理对象
        IServiceOne proxyInstance = (IServiceOne) Proxy.newProxyInstance(
                serviceOne.getClass().getClassLoader(), // 使用目标类的类加载器
                serviceOne.getClass().getInterfaces(),   // 代理目标类实现的所有接口
                new ServiceInvocationHandler(serviceOne) // 自定义调用处理器
        );

        // 3. 通过代理对象调用方法
        proxyInstance.doSomething();
    }
}

/**
 * 目标接口(JDK 动态代理必须基于接口)
 */
interface IServiceOne {
    void doSomething();
}

/**
 * 目标接口实现类
 */
class ServiceOneImpl implements IServiceOne {
    @Override
    public void doSomething() {
        System.out.println("我正在演示Java动态代理中的JDK动态代理....");
    }
}

/**
 * 调用处理器
 */
class ServiceInvocationHandler implements InvocationHandler {
    // 持有目标对象(必须是接口实现类)
    private final IServiceOne target;

    ServiceInvocationHandler(IServiceOne target) {
        this.target = target;
    }


    /**
     * 代理方法拦截逻辑
     *
     * @param proxy  代理对象(通常避免直接使用)
     * @param method 被调用的方法
     * @param args   方法参数
     * @return 方法执行结果
     */
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 前置增强
        System.out.println("代理之【前】做一些其他事情....");
        // 调用目标对象的方法(注意:此处必须使用 target,而非 proxy)
        Object res = method.invoke(target, args);
        // 后置增强
        System.out.println("代理之【后】做一些其他事情....");
        return res;
    }
}

JDK动态代理执行结果

工作原理:

+-------------------+          +-----------------------+
|    Client Code    |          |   Proxy Instance      |
|-------------------|          |-----------------------|
| - Call doSomething| -------> | - InvocationHandler   |
+-------------------+          +-----------------------+
                                      | 委托调用
                                      v
                               +-----------------------+
                               |   Target Object       |
                               | (ServiceOneImpl)      |
                               +-----------------------+

CGLIB示例代码:

/**
 * @author 念心卓
 * @version 1.0
 * @description: CGLIB动态代理示例
 * @date 2025/3/8 10:18
 */
public class CGLIBDynamicDemo {
    public static void main(String[] args) {
        // 1. 创建CGLIB增强器
        Enhancer enhancer = new Enhancer();
        // 2. 设置父类(被代理类)
        enhancer.setSuperclass(ServiceTwo.class);
        // 3. 设置方法拦截器(代理逻辑)
        enhancer.setCallback(new ServiceMethodInterceptor());
        // 4. 创建代理对象
        ServiceTwo proxy = (ServiceTwo) enhancer.create();
        // 5. 通过代理对象调用方法
        proxy.doSomething();
    }
}

/**
 * 被代理的目标类(注意:CGLIB要求类和方法不能是final的)
 */
class ServiceTwo {
    public void doSomething() {
        System.out.println("我正在演示Java动态代理中的CGLIB动态代理....");
    }
}

/**
 * 方法拦截器
 */
class ServiceMethodInterceptor implements MethodInterceptor {
    /**
     * 代理拦截方法
     *
     * @param obj    生成的代理对象
     * @param method 被拦截的方法
     * @param args   方法参数
     * @param proxy  用于调用父类方法的代理
     * @return 方法执行结果
     */
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("代理之【前】做一些其他事情....");
        Object res = proxy.invokeSuper(obj, args);
        System.out.println("代理之【后】做一些其他事情....");
        return res;
    }
}

CGLIB动态代理执行结果

工作原理:

+-------------------+          +-----------------------+
|    Client Code    |          |   Enhanced Proxy      |
|-------------------|          |-----------------------|
| - Call doSomething| -------> | - interceptor         |
+-------------------+          | - superclass methods  |
                               +-----------------------+
                                      |  ^
                                      |  |
                                      v  |
                               +-----------------------+
                               |   MethodInterceptor   |
                               |-----------------------|
                               | - pre-processing      |
                               | - invokeSuper()       |
                               | - post-processing     |
                               +-----------------------+

3.2.3 对比 CGLIB 动态代理

| 特性 | JDK 动态代理 | CGLIB 动态代理 |
| ———— | —————- | ——————– |
| 代理方式 | 基于接口 | 基于类继承 |
| 性能 | 较慢(反射调用) | 较快(直接调用) |
| 依赖 | 无需额外库 | 需引入 CGLIB 库 |
| 目标类要求 | 必须实现接口 | 类和方法不能是 final |
| 方法拦截范围 | 仅接口声明方法 | 所有非 final 方法 |

4. Java 中的注解原理是什么?

4.1 回答重点

注解其实就是一个标记,是一种提供元数据的机制,用于给代码添加说明信息。可以标记在类上、方法上、属性上等,标记自身也可以设置一些值。

注解本身不影响程序的逻辑执行,但可以通过工具或框架来利用这些信息进行特定的处理,如代码生成、编译时检查、运行时处理等。

4.2 扩展知识

4.2.1 注解的使用

定义注解:注解是一种特殊的接口,以 @interface 关键字:

@interface MyAnnotation {
 String value() default ""; //可以在注解中为属性指定默认值
}

使用注解:在类、方法、字段等代码元素上:

@MyAnnotation(value = "example")
public class MyClass {
 @MyAnnotation
 public void myMethod() {}
}

处理注解

  • 编译时处理:使用 javax.annotation.processing 包进行注解处理器的开发。
  • 运行时处理:使用反射机制访问注解,通过 Class.getAnnotation()Field.getAnnotation() 等方法获取注解信息。

示例代码(运行时处理):

package com.example.mianshi.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;

/**
 * @author 念心卓
 * @version 1.0
 * @description: 自定义注解示例
 * @date 2025/3/8 11:02
 */
public class AnnotationDemo {
    @MyAnnotation("测试自定义注解")
    public void myMethod(){}


    public static void main(String[] args) throws NoSuchMethodException {
        Method method = AnnotationDemo.class.getMethod("myMethod");
        //判断该方法上是否存在指定注解
        if (method.isAnnotationPresent(MyAnnotation.class)){
            //获取到指定注解
            MyAnnotation annotation = method.getAnnotation(MyAnnotation.class);
            System.out.println("Annotation value: " + annotation.value());
        }
    }
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@interface MyAnnotation {
    String value() default "自定义注解";
}

执行结果

4.2.2 元注解

元注解,即注解的注解,如 @Retention、@Target、@Inherited(表示注解是否可以被继承)。

4.2.2.1 注解的三大保留策略:

@Retention:定义注解的保留策略,即注解的有效范围

  • RetentionPolicy.SOURCE:注解仅在源码中存在,编译时被丢弃。
  • RetentionPolicy.CLASS:注解存在于编译后的 .class 文件中,但运行时不可用。
  • RetentionPolicy.RUNTIME:注解在运行时可用,可以通过反射机制访问。

4.2.2.2 Target

@Target指定注解可以应用于哪些代码元素

  • ElementType.TYPE:类、接口(包括注解类型)或枚举。
  • ElementType.FIELD:字段(包括枚举常量)。
  • ElementType.METHOD:方法。
  • ElementType.PARAMETER:方法参数。
  • ElementType.CONSTRUCTOR:构造方法。
  • ElementType.LOCAL_VARIABLE:局部变量。
  • ElementType.ANNOTATION_TYPE:注解类型。
  • ElementType.PACKAGE:包。

4.2.3 常见注解例子

例如 Override:

@Override注解信息

是给编译器用的,编译器编译的时候检查没问题就 ok 了,class 文件里面不会有 Override 这个标记。

再比如 Spring 常见的 Autowired,是 RUNTIME 的。所以在运行的时候可以通过反射得到注解的信息,还能拿到标记的值 required:

@Autowired注解

所以注解就是一个标记,可以给编译器用、也能运行时候用。

5. 你使用过 Java 的反射机制吗?如何应用反射?

5.1 回答重点

Java 的反射机制是指在运行时获取类的结构信息(如方法、字段、构造函数)并操作对象的一种机制。反射机制提供了在运行时动态创建对象、调用方法、访问字段等功能,而无需在编译时知道这些类的具体信息。

反射机制的优点

  • 可以动态地获取类的信息,不需要在编译时就知道类的信息。
  • 可以动态地创建对象,不需要在编译时就知道对象的类型。
  • 可以动态地调用对象的属性和方法,在运行时动态地改变对象的行为。

5.2 扩展知识

一般在业务编码中不会用到反射,在框架上用的较多,因为很多场景需要很灵活,不确定目标对象的类型,届时只能通过反射动态获取对象信息。

例如 Spring 使用反射机制来读取和解析配置文件,从而实现依赖注入和面向切面编程等功能。

5.2.1 反射的性能考虑:

反射操作相比直接代码调用具有较高的性能开销,因为它涉及到动态解析和方法调用。

所以在性能敏感的场景中,尽量避免频繁使用反射。可以通过缓存反射结果。例如把第一次得到的 Method 缓存起来,后续就不需要再调用 Class.getDeclaredMethod 也就不需要再次动态加载了,这样就可以避免反射性能问题。

5.2.2 反射基本概念:

Class 类:反射机制的核心,通过 Class 类的实例可以获取类的各种信息。

反射的主要功能

  • 创建对象:通过 Class.newInstance()Constructor.newInstance() 创建对象实例。
  • 访问字段:使用 Field 类访问和修改对象的字段。
  • 调用方法:使用 Method 类调用对象的方法。
  • 获取类信息:获取类的名称、父类、接口等信息。

5.2.3 反射的使用:

  1. 获取 Class 对象

    Class<?> clazz = Class.forName("填入类的全限定名");
    // 或者
    Class<?> clazz = MyClass.class;
    // 或者
    Class<?> clazz = obj.getClass();
    
  2. 创建对象

    Object obj = clazz.newInstance(); // 已过时
    Constructor<?> constructor = clazz.getConstructor();
    Object obj = constructor.newInstance();
    
  3. 访问字段

    Field field = clazz.getField("myField");//访问public访问权限的字段
    Field field = clazz.getDeclaredField("myField");//访问 private 或其他访问级别的字段
    
    field.setAccessible(true); // 允许访问 private 字段
    Object value = field.get(obj);//获取字段的值
    field.set(obj, newValue);//obj为目标对象,newValue为新设置字段的值
    
  4. 调用方法

    Method method = clazz.getMethod("myMethod", String.class);
    Object result = method.invoke(obj, "param");
    

5.2.4 反射的最佳实践:

  • 限制访问:尽量避免过度依赖反射,尤其是在性能关键的代码中。
  • 使用缓存:缓存反射获取的类、方法、字段等信息,减少反射操作的频率。
  • 遵循设计原则:在设计系统时,尽量使用更稳定和易于维护的设计方案,只有在确实需要时才使用反射。

5.2.5 总结

Java反射机制是指在运行状态中,对于任意一个,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性。这种动态获取信息以及动态调用对象方法的功能称为 Java 语言的反射机制。

用大白话来说就是反射在运行状态时可以操作任意一个类的全部功能,比如调用方法、修改成员变量的值和调用构造方法等,就算是私有也可以暴力反射,sorry~反射就是可以为所欲为

6. 什么是 Java 的 SPI(Service Provider Interface)机制?

6.1 回答重点

SPI 是一种插件机制,用于在运行时动态加载服务的实现。它通过定义接口(服务接口)并提供一种可扩展的方式来让服务的提供者(实现类)在运行时注入,实现解耦和模块化设计。

SPI 依赖于 java.util.ServiceLoader,它通过读取 META-INF/services/ 目录下的 配置文件,动态加载实现类并返回实例。

SPI 机制的核心概念

  • 服务接口:接口或抽象类,定义某个服务的规范或功能。
  • 服务提供者:实现了服务接口的具体实现类。
  • 服务加载器(ServiceLoader):Java 提供的工具类,负责动态加载服务的实现类。通过 ServiceLoader 可以在运行时发现和加载多个服务提供者。
  • 配置文件:服务提供者通过在 META-INF/services/ 目录下配置服务接口的文件来声明自己。这些文件的内容是实现该接口的类的完全限定名,文件的名称是接口全限定名

SPI 机制的优缺点

优点

  1. 解耦:调用方不依赖具体实现类,而是基于接口编程。
  2. 可扩展性:新增实现类时,只需添加配置文件,无需修改原代码。
  3. JDK 内置支持:不需要额外的依赖,ServiceLoader 直接支持。

缺点

  1. 全局加载ServiceLoader 会加载所有实现,可能会有性能开销。
  2. 无法传递参数ServiceLoader 只能使用无参构造函数创建实例。
  3. 懒加载但不可控ServiceLoader 采用懒加载方式,但没有优先级控制。

6.2 扩展知识

6.2.1 SPI 通俗理解

SPI 可以通俗地理解为一种插件机制,用于在程序运行时动态加载某些功能的实现

6.2.2 打个比方

假设你有一个音乐播放器(相当于一个程序),这个播放器可以播放不同格式的音乐,比如 MP3、WAV、AAC 等格式。你作为用户,并不关心播放器内部是如何解码这些格式的,你只需要它能正常播放音乐。

  • SPI 就像是播放器的插槽:播放器本身并不内置所有的解码器(MP3 解码器、WAV 解码器等),而是有一个标准接口(SPI),允许外部开发者(服务提供者)开发并“插入”解码器(不同格式的处理实现)。
  • 插件机制:当播放器启动时,它通过 SPI 机制去寻找并加载外部提供的解码器,选择合适的解码器来处理不同的音乐格式。这些解码器可以是程序事先知道的,也可以是后期动态加入的,只要遵循 SPI 规定的接口规范。

6.2.3 带入 Java 中理解

  • Java SPI 就是一个类似的机制。你定义一个接口(类似播放器的插槽),然后不同的开发者实现这个接口,提供不同的实现(类似各种解码器)。
  • Java 会通过 SPI 自动加载这些实现,在运行时决定用哪个实现,而不用你手动去修改代码。

总结:SPI 机制的好处是灵活,能让程序根据需求动态地加载或更换某些功能实现,就像给一个音乐播放器加装不同的解码器插件,而不需要每次都修改播放器的核心代码。

一个典型的 SPI 应用场景是 JDBC(Java 数据库连接库),不同的数据库(mysql、oracle、sqlserver 等)有不同的实现,它们根据 JDBC 定制自己的数据库驱动程序,我们根据 SPI 机制使用它们的实现,而不需要修改 JDBC 核心代码。

6.2.2 SPI 的使用步骤

  1. 定义一个服务接口

    首先,创建一个服务接口,它定义了服务的功能:

    public interface MyService {
       void execute();
    }
    
  2. 提供服务实现

    创建多个实现类,分别提供不同的服务:

    public class MyServiceImplA implements MyService {
       @Override
       public void execute() {
           System.out.println("执行 MyServiceImplA");
       }
    }
    
    public class MyServiceImplB implements MyService {
       @Override
       public void execute() {
           System.out.println("执行 MyServiceImplB");
       }
    }
    
  3. 创建 SPI 配置文件

    resources/META-INF/services/ 目录下创建一个以接口全限定名命名的文件:

    文件路径以及文件名设置

    文件内容(列出实现类的全限定名,每行一个):

    com.example.mianshi.spi.MyServiceImplA
    com.example.mianshi.spi.MyServiceImplB
    

    如果文件正确创建,那么你在输入文件内容的时候是有提示的:

    文件内容提示

  4. 在代码中使用 ServiceLoader 发现服务

    使用 ServiceLoader 进行动态加载

    public class SPIDemo {
       public static void main(String[] args) {
           ServiceLoader<MyService> services = ServiceLoader.load(MyService.class);
           for (MyService service : services) {
               service.execute();
           }
       }
    }
    

    执行结果

后续如果要替换实现类,仅需新建一个实现类,然后修改配置文件中的全限定名即可替换,无需修改使用代码。

6.2.3 SPI对于API

| 方式 | 提供接口 | 提供实现 | 目的 |
| —- | ——– | ——– | ———————- |
| API | 第三方 | 第三方 | 你调用现成的功能 |
| SPI | 你 | 第三方 | 你允许别人扩展你的功能 |

相当于我调用了一个我提供的一个接口,别人去实现这个接口并在模块中写好实现类的配置文件打成jar包,然后由我引入之后就可以使用这个实现类了。和API差不多只是API是接口和实现类都是由第三方提供,SPI则是由我提供接口第三方提供实现类。

6.2.4 总结

SPI 是 Java 提供的标准扩展机制,允许框架和应用程序在运行时 动态发现和加载实现类

核心组件是 ServiceLoader,它通过 META-INF/services/ 目录下的配置文件查找和加载实现类。

广泛应用于 JDBC、日志、Spring Boot 自动装配等场景,但需要注意其局限性和优化方法。

7. Java 泛型的作用是什么?

7.1 回答重点

Java 泛型的作用是通过在编译时检查类型安全,允许程序员编写更通用和灵活的代码,避免在运行时发生类型转换错误。

总结作用

  • 类型安全:泛型允许在编译时进行类型检查,确保在使用集合或其他泛型类时,不会出现类型不匹配的问题,减少了运行时的 ClassCastException 错误。
  • 代码重用:泛型使代码可以适用于多种不同的类型,减少代码重复,提升可读性和维护性。
  • 消除显式类型转换:泛型允许在编译时指定类型参数,从而消除了运行时需要显式类型转换的麻烦。

示例代码:

List<String> list = new ArrayList<>();
list.add("Hello");
String str = list.get(0); // 不需要类型转换

7.2 扩展知识

7.2.1 泛型的实际应用

  • 集合框架:Java 的集合框架广泛使用了泛型。List<T>Set<T>Map<K, V> 等接口可以针对不同的数据类型实现统一的操作。

  • 泛型方法:不仅可以定义泛型类,还可以定义泛型方法,使得方法能够处理多种不同的数据类型。

    例如:

    public static <T> void printArray(T[] array) {
      for (T element : array) {
          System.out.println(element);
      }
    }
    

7.2.2 为什么需要泛型通俗理解

在 Java5 之前是没有泛型的,没泛型都能用的好好的,那为什么要加个泛型呢,能给我们带来什么呢?

我们先来看下下面这段代码:

List list = new ArrayList();
list.add("hello"); // 加入string
list.add(233); // 加入int

在没有泛型的时候,加入的集合的数据并不会做任何约束,都会被当作成 Object 类型

可能有人说,这很好呀,多自由!确实,自由是自由了,但是代码的约束能力越低,就越容易出错,使用上也有诸多不便,比如获取的时候需要强转。

提供Object类型,但是开发者想要String类型

如果一不小心取错类型,编译的时候能过,但是运行的时候却抛错:

运行时发现类型转换失败

综上,Java 引入了泛型。

而泛型的作用就是加了一层约束,约束了类型。

有了这一层约束就好办事儿了,由于声明了类型,可以在编译的时候就识别出不准确的类型元素。使得错误提早抛出,避免运行时才发现。

编译时就可检验出类型不匹配了

并且也不需要在代码上显示的强转,从以下代码可以看出,能直接获取 String 类型元素。

直接获取,无需强转

我们再小结一下泛型的好处:

  • 提高了代码的可读性,一眼就能看出集合(其它泛型类)的类型。
  • 可在编译期检查类型安全,增加程序的健壮性。
  • 省心不需要强转(其实内部帮做了强转,下面会说)。
  • 提高代码的复用率,定义好泛型,一个方法(类)可以适配所有类型 (其实以前 Object 也行,就是比较麻烦)。

7.2.3 为什么都说 Java的泛型是伪泛型

我们来看一段代码:

通过反射添加非约束类型数据

可以看到,我声明的是一个 String 类型的集合,但是通过反射往集合中插入了 int 类型的数据,居然成功了?

这说明在运行时泛型根本没有起作用!也就是说在运行的时候 JVM 获取不到泛型的信息,也会不对其做任何的约束

你可以认为 Java 的泛型就是编译的时候生效,运行的时候没有泛型,所以大家才说 Java 是伪泛型!

因此,虽然在 IDE 写代码的时候泛型生效了,而实际上在运行的时候泛型的类型是被擦除的

一言蔽之,Java的泛型只在编译时生效,JVM 运行时没有泛型

[!note]

个人理解,Java泛型最直接明了的作用:能够将运行时异常转为编译时异常。

7.2.4 泛型相关面试题

8. Java 泛型擦除是什么?

8.1 回答重点

泛型擦除指的是 Java 编译器在编译时将所有泛型信息删除的过程,以确保与 Java 1.4 及之前的版本保持兼容。

泛型参数在运行时会被替换为其上界(通常是 Object),这样一来在运行时无法获取泛型的实际类型。

作用:泛型擦除确保了 Java 代码的向后兼容性,但它也限制了在运行时对泛型类型的操作。

影响:由于类型擦除,无法在运行时获取泛型的实际类型,也不能创建泛型类型的数组或对泛型类型使用 instanceof 检查。

示例:

public <T> void printList(List<T> list) {
   for (T element : list) {
       System.out.println(element);
   }
}

在编译时,类型 T 会被擦除为 Object,因此编译后的代码类似于:

public void printList(List list) {
   for (Object element : list) {
       System.out.println(element);
   }
}

8.2 扩展知识

8.2.1 为什么 Java 泛型的实现是类型擦除?

回答重点提到主要原因是为了向下兼容,即兼容 Java5 之前的编译的 class 文件。

例如 Java 1.2 上正在跑的代码,可以在 Java 5 的 JRE 上运行。

也是因为需要向下兼容,才使得 Java 实现的是伪泛型

我从现有的实现倒推伪泛型的设计可能思路(我个人瞎掰的,您随意听听)是这样的:

  1. 这 Java 5 以前的版本,线上已经有很多应用在跑了,我好像不能新加一套,影响推广还可能被骂的很惨。
  2. 咋办,泛型毕竟是加一个约束,以前的代码没这个约束啊,该如何兼容?
  3. 有了,要不我在编译器上动手脚,在编译的时候识别和约束泛型,然后编译过了就把泛型的信息擦除了。这样运行的时候约束不是没了吗?不就和之前保持一致了吗?好,就这样干了!

总而言之,就是为了向下兼容才采用类型擦除来实现的。

这里还有个坑,也就是泛型不支持基本类型,比如 int。因为泛型擦除后就变成了Object,这个 int 和 Object 兼容有点麻烦。

参考网上 R 大的解释:

GJ / Java 5说:这个问题有点麻烦,赶不及在这个版本发布前完成了,就先放着不管吧。于是Java 5 的泛型就不支持原始类型,而我们不得不写恶心的 ArrayList<Integer>ArrayList<Long> … 这就是一个偷懒了的地方。

这说明啥?写 Java 的也是程序员,也是要发版有上线需求的,所以说…

8.2.2 既然擦除了类型,为什么在运行期通过反射可以获得类型?

我们来看下这段代码:

List<String> list = new ArrayList<>();
list.add("hello");

String str = list.get(0);

上述定义了泛型类型为 String 的 list,并且获取的 str 不需要强转,这一步是怎么做的呢?

可以执行如下命令查看字节码:

javap -c 自己的.class文件

字节码信息

我们从反编译看生成的字节码可以看到, new 的 list 没有保存泛型的信息,所以是被擦除了。

然后看到 #7 没,有个 checkcast ,强转的类型是 String,看到这大伙儿应该都明白,为什么类型擦除了,但是我们 get 的时候不需要强转呢?

因为编译器隐性的帮我们插入了强转的代码!所以我们的 Java 代码中不需要写强转。

再回到此小节标题:既然擦除了类型,为什么在运行期仍能反射获得类型?

答案就藏在 class 文件中。我们来看下这段代码:

public class GenericsDemo {
    private List<String> list;
    public static void main(String[] args) throws Exception {
        //获取私有的list字段
        Field field = GenericsDemo.class.getDeclaredField("list");
        //获取字段的泛型类型信息,并强制转换为 ParameterizedType
        ParameterizedType genericType = (ParameterizedType) field.getGenericType();
        //从泛型类型中获取实际的类型参数(比如 List<String> 中的 String)
        Type type = genericType.getActualTypeArguments()[0];

        //执行结果输出  class java.lang.String
        System.out.println(type);
    }
}

代码以及执行结果

通过反射,我确实获得了 list 的类型。那既然类型被擦除了,这又是怎么做到的呢?

我们直接进行一手 javap -v,反编译看到字节码里面有这样的记录:

反编译详细信息

这下很好理解了,class 文件里面存了这个信息,所以我们通过反射自然而然的就能得到这个类型。没错,就是这么简单。

也正因为原理如此,所以我们只能对以下三种情况利用反射获取泛型类型:

  • 成员变量的泛型
  • 方法入参的泛型
  • 方法返回值的泛型

对于局部变量这种是无能为力的。

8.2.3 泛型相关面试题

9. 什么是 Java 泛型的上下界限定符?

9.1 回答重点

Java 泛型的上下界限定符 用于对泛型类型参数进行范围限制 ,主要有上界限定符(Upper Bound Wildcards)下界限定符(Lower Bound Wildcards)

  1. 上界限定符 (? extends T)

    定义? extends T 表示通配符类型必须是 T 类型或 T 的子类。

    作用:允许使用 T 或其子类型作为泛型参数,通常用于读取操作,确保可以读取为 TT 的子类的对象

    public void process(List<? extends Number> list) {
       Number num = list.get(0); // 读取时是安全的,返回类型是 Number 或其子类
       // list.add(1); // 编译错误,不能往其中添加元素
    }
    
  2. 下界限定符 (? super T)

    定义? super T 表示通配符类型必须是 T 类型或 T 的父类。

    作用:允许使用 T 或其父类型作为泛型参数,通常用于写入操作,确保可以安全地向泛型集合中插入 T 类型的对象

    public void addToList(List<? super Integer> list) {
       list.add(1); // 可以安全地添加 Integer 类型的元素
       // Integer value = list.get(0); // 编译错误,不能安全地读取
    }
    

总结:

  • 上界(extends):指定类型的上限,确保泛型类型是某个类的子类。
  • 下界(super):指定类型的下限,确保泛型类型是某个类的父类。

这两个限定符有助于增强泛型的灵活性和类型安全性。

9.2 扩展知识

9.2.1 上界限定符 (extends) 使用示例

上界限定符通常用于限定泛型必须是某个类或接口的子类。如果使用了不符合上界的类型,将导致编译错误。

package com.example.mianshi.generics;

import java.util.ArrayList;
import java.util.List;

/**
 * @author 念心卓
 * @version 1.0
 * @description: 泛型上界限定符示例
 * @date 2025/3/9 15:19
 */
public class GenericsUpperBoundDemo {
    // 泛型方法,T 必须是 Number 的子类
    private static <T extends Number> void printNumbers(List<T> list) {
        for (T num : list) {
            System.out.println(num);
        }
    }

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

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

        // 正确的调用:Integer 和 Double 都是 Number 的子类
        printNumbers(integerList);
        printNumbers(doubleList);

        // 编译错误:String 不是 Number 的子类
        List<String> stringList = new ArrayList<>();
        stringList.add("Hello");
        printNumbers(stringList); // 编译错误
    }
}

String不是Number的子类编译错误

printNumbers() 方法要求 T 必须是 Number 的子类,因此当我们尝试传入 List<String> 时会导致编译错误,因为 String 不是 Number 的子类。

9.2.2 下界限定符 (super) 使用示例

下界限定符用于限定泛型必须是某个类或接口的父类。如果不符合下界要求,同样会导致编译错误。

package com.example.mianshi.generics;

import java.util.ArrayList;
import java.util.List;

/**
 * @author 念心卓
 * @version 1.0
 * @description: 泛型下界限定符示例
 * @date 2025/3/9 15:19
 */
public class GenericsLowerBoundDemo {
    // 泛型方法,T 是 Integer 或其父类
    private static void addIntegers(List<? super Integer> list) {
        list.add(1);
        list.add(2);
    }

    public static void main(String[] args) {
        List<Number> numberList = new ArrayList<>();
        addIntegers(numberList); // 正确,Number 是 Integer 的父类

        List<Object> objectList = new ArrayList<>();
        addIntegers(objectList); // 正确,Object 是 Integer 的父类

        // 编译错误:List<Double> 不符合下界条件
        List<Double> doubleList = new ArrayList<>();
        addIntegers(doubleList); // 编译错误
    }
}

Double不是Integer的父类

addIntegers() 方法要求 T 必须是 Integer 或其父类,因此 List<Number>List<Object> 是有效的,但 List<Double> 不符合条件,因为 Double 不是 Integer 的父类。

9.2.3 泛型类型转换使用示例

使用泛型时,类型的转换必须严格遵循泛型的定义,否则会产生编译错误。例如,下界不能保证类型安全写入,这会导致问题。

package com.example.mianshi.generics;

import java.util.ArrayList;
import java.util.List;

/**
 * @author 念心卓
 * @version 1.0
 * @description: 泛型类型转换示例
 * @date 2025/3/9 15:30
 */
public class GenericsTypeCastDemo {
    public static void main(String[] args) {
        List<? extends Number> numberList = new ArrayList<>();

        // 编译错误:不能向上界类型中添加元素
        numberList.add(10);  // 编译错误

        List<? super Integer> integerList = new ArrayList<>();
        integerList.add(10); // 正确,可以添加 Integer 类型
        integerList.add(20);

        // 编译错误:虽然是 Integer 的父类,但不能保证是 Integer 类型,因此不能读取为 Integer
        Integer num = integerList.get(0);  // 编译错误
    }
}

上下界使用错误

List<? extends Number> 限定了泛型上界为 Number 的子类,但无法保证具体是哪种类型,因此不能添加元素(除了 null),否则会违反类型安全性。尝试执行 numberList.add(10) 时会导致编译错误。

List<? super Integer> 限定了泛型下界为 Integer 的父类,虽然可以向列表中添加 Integer 类型的元素,但从列表中读取时无法确定具体类型,因此不能安全地将其赋值为 Integer,编译器会阻止这种操作。

9.2.4 为何需要上下界限定符

泛型提供了类型安全性,但有时我们希望泛型参数的类型在某个范围内,这样可以确保在不同场景下使用泛型时既能获得灵活性,又能保证类型安全

上下界限定符正是为此设计的,允许我们定义类型的范围,而不是具体类型。

常见使用场景

  • 上界限定符(? extends T):常用于协变场景,允许我们对泛型集合进行只读操作。比如,我们可以从 List<? extends Number> 中读取 Number 或其子类,但不能往其中添加对象。
  • 下界限定符(? super T):常用于逆变场景,允许我们对泛型集合进行写入操作。比如,我们可以向 List<? super Integer> 中添加 Integer 或其父类,但不保证读取到的对象类型。

9.2.5 什么是协变和逆变:

它们主要用于描述类型之间的兼容性关系

协变(Covariance):子类型可以替换父类型(派生类替换基类)。

  • 场景:当一个泛型容器(或方法返回类型)允许子类型替代父类型时,就是协变
  • 特点:类型的方向是一致的(从父类到子类)。
  • 关键词输出方向(比如方法的返回值)。

协变代码示例:

class Animal {}
class Dog extends Animal {}

List<? extends Animal> animals;  // 协变
animals = new ArrayList<Dog>();  // 子类型(Dog)替换父类型(Animal)

这里的 List<? extends Animal> 允许 Dog 作为 Animal 的子类,体现了协变的特性。

逆变(Contravariance) :父类型可以替换子类型(基类替换派生类)。

  • 场景:当一个泛型容器(或方法参数类型)允许父类型替代子类型时,就是逆变
  • 特点:类型的方向是相反的(从子类到父类)。
  • 关键词输入方向(比如方法的参数)。

逆变代码示例:

class Animal {}
class Dog extends Animal {}

List<? super Dog> dogs;       // 逆变
dogs = new ArrayList<Animal>();  // 父类型(Animal)替换子类型(Dog)

这里的 List<? super Dog> 允许 Animal 作为 Dog 的父类,体现了逆变的特性。

为什么要有协变和逆变?

  1. 协变:主要解决返回值的灵活性问题,允许更具体的类型返回。
  2. 逆变:主要解决参数传递的灵活性问题,允许更广泛的类型输入。

9.2.6 PECS 原则

PECS 原则是 Producer Extends, Consumer Super 的缩写,帮助理解何时使用上界和下界限定符:

  • Producer Extends:如果某个对象提供数据(即生产者),使用 extends(上界限定符)。
  • Consumer Super:如果某个对象使用数据(即消费者),使用 super(下界限定符)。

9.2.7 类型擦除与泛型边界

Java 泛型是通过类型擦除实现的,即在编译时会将泛型信息移除,用实际类型替代泛型参数。上下界限定符通过边界限制(Bounded Type Parameters)确保在擦除时可以限制类型的范围,保证了类型的安全性和灵活性。

例如:

public <T extends Number> void print(T value) {
    System.out.println(value);
}

9.2.8 泛型相关面试题

10. Java 中的深拷贝和浅拷贝有什么区别?

10.1 回答重点

深拷贝:深拷贝不仅复制对象本身,还递归复制对象中所有引用的对象。这样新对象与原对象完全独立,修改新对象不会影响到原对象。即包括基本类型和引用类型,堆内的引用对象也会复制一份。

浅拷贝:拷贝只复制对象的引用,而不复制引用指向的实际对象。也就是说,浅拷贝创建一个新对象,但它的字段(若是对象类型)指向的是原对象中的相同内存地。

深拷贝创建的新对象与原对象完全独立,任何一个对象的修改都不会影响另一个。而修改浅拷贝对象中引用类型的字段会影响到原对象,因为它们共享相同的引用。

10.2 扩展知识

10.2.1 图示深浅拷贝区别

比如现在有个 teacher 对象,然后成员里面有一个 student 列表。

teacher 深拷贝之后堆内有 2 个 student 列表,之间不会影响,而浅拷贝的话堆内还是只有一个 student 列表。

深浅拷贝示意图

所以,如果是深拷贝,那么原对象对 student 列表的修改并不会影响拷贝对象,而浅拷贝则会影响。

10.2.2 如何实现浅拷贝

使用 Object.clone() 方法是浅拷贝的常见方式。默认情况下,clone() 方法只是对对象的字段进行字段拷贝,对于基本类型的字段会复制值,对于引用类型的字段则复制引用。

示例代码:

class Person implements Cloneable {
    String name;
    int age;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone(); // 浅拷贝
    }
}

10.2.3 如何实现深拷贝

深拷贝可以通过递归调用 clone() 方法手动实现,也可以通过序列化与反序列化实现。序列化方式简单易用,但性能相对较低,尤其是在深层嵌套对象或大对象的情况下。

递归方式:

class Address implements Cloneable {
    String city;
    public Address(String city) { this.city = city; }
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone(); // 浅拷贝
    }
}

class Person implements Cloneable {
    String name;
    int age;
    Address address;

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

    @Override
    protected Object clone() throws CloneNotSupportedException {
        Person cloned = (Person) super.clone(); //浅拷贝
        cloned.address = (Address) address.clone(); // 深拷贝
        return cloned;
    }
}

序列化方式:

public static Object deepCopy(Object object) {
    try {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(object);
        ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bis);
        return ois.readObject();
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

[!note]

个人总结:

  • 浅拷贝:浅拷贝后的对象和原对象共享引用对象的实例。

  • 深拷贝:会重现创建一个实例,使得深拷贝后的对象与原对象完全独立。修改一个对象不会影响另一个对象。

11. 什么是 Java 的 Integer 缓存池?

11.1 回答重点

Java 的 Integer 缓存池(Integer Cache) 是为了提升性能和节省内存。根据实践发现大部分的数据操作都集中在值比较小的范围,因此缓存这些对象可以减少内存分配和垃圾回收的负担,提升性能。

-128127 范围内的 Integer 对象会被缓存和复用。

原理:Java 在自动装箱时,对于值在 -128127 之间的 int 类型,会直接返回一个已经缓存的 Integer 对象,而不是创建新的对象。

缓存池的使用场景

  • 自动装箱(Auto-boxing):当基本类型 int 转换为包装类 Integer 时,若数值在缓存范围内,返回缓存对象。
  • 值比较:由于相同范围内的整数使用同一个缓存对象,使用 == 可以正确比较它们的地址(引用相同),而不需要使用 equals()。但是要注意对于超过缓存范围的 Integer 对象,== 比较的是对象引用,而不是数值。要比较数值,应使用 equals() 方法。

11.2 扩展知识

11.2.1 缓存池的可配置范围

在 Java 8 及以后的版本中,可以通过 JVM 参数 -XX:AutoBoxCacheMax=size 来调整缓存池的上限。 比如:

java -XX:AutoBoxCacheMax=500

这样可以将缓存范围扩展到 -128500

11.2.2 缓存池实现原理分析

实现的原理是 int 在自动装箱的时候会调用 Integer.valueOf,进而用到了 IntegerCache。

Integer.valueOf方法源码

实现很简单,就是判断下值是否在范围之内,如果是的话去 IntegerCache 中取。

IntegerCache 在静态块中会初始化好缓存值:

静态内部类IntegerCache源码

所以这里还有个面试题,就是为什么 Integer 127 之内的相等,而超过 127 的就不等了,因为 127 之内的就是同一个对象,所以当然相等。

不仅 Integer 有缓存池,Long 也是有的,不过范围是写死的 -128 到 127(无法配置):

Long.valueOf方法源码

11.2.3 其他包装类型的缓存机制

  • LongShortByte 这 3 种包装类缓存范围也是-128127 的。
  • FloatDouble 没有缓存池,因为是小数,能存的数太多了。
  • Character 缓存范围是 \u0000\u007F(即 0 到 127,代表 ASCII 字符集)
  • Boolean 只缓存两个值,即 true 和 false

12. Java 的类加载过程是怎样的?

12.1 回答重点

类加载指的是把类加载到 JVM 中。把二进制流存储到内存中,之后经过一番解析、处理转化成可用的 class 类。

二进制流可以来源于 class 文件,或通过字节码工具生成的字节码或来自于网络。只要符合格式的二进制流,JVM 来者不拒。

类加载流程分为:

  1. 加载
  2. 连接
  3. 初始化

连接还能拆分为:验证、准备、解析三个阶段。

所以总的来看可以分为 5 个阶段:

  1. 加载:将二进制流读入内存中,生成一个 Class 对象。
  2. 验证:主要是验证加载进来的二进制流是否符合一定格式,是否规范,是否符合当前 JVM 版本等等之类的验证。
  3. 准备:为静态变量(类变量)赋初始值,也即为它们在方法区划分内存空间。这里注意是静态变量,并且是初始值,比如 int 的初始值是 0。
  4. 解析:将常量池的符号引用转化成直接引用。符号引用可以理解为只是个替代的标签,比如你此时要做一个计划,暂时还没有人选,你设定了个 A 去做这个事。然后等计划真的要落地的时候肯定要找到确定的人选,到时候就是小明去做一件事。解析就是把 A(符号引用) 替换成小明(直接引用)。符号引用就是一个字面量,没有什么实质性的意义,只是一个代表。直接引用指的是一个真实引用,在内存中可以通过这个引用查找到目标。
  5. 初始化:这时候就执行一些静态代码块,为静态变量赋值,这里的赋值才是代码里面的赋值,准备阶段只是设置初始值占个坑。

12.2 扩展知识

类加载机制一问基本上就会接着问双亲委派和类加载器。

12.2.1 双亲委派模型

类加载器先将类加载请求委派给父类加载器处理,只有父加载器找不到类时,才由当前类加载器加载。

  • 什么是 Java 中的双亲委派模型?(待完成)

类加载器

类加载器负责加载类,可以是系统自带的(如 Bootstrap、Extension、Application ClassLoader),也可以是用户自定义的类加载器。

  • 你了解 Java 的类加载器吗?(待完成)

13. 什么是 Java 的 BigDecimal?

13.1 回答重点

BigDecimal 是 Java 中提供的一个用于高精度计算的类,属于 java.math 包。它提供对浮点数和定点数的精确控制,特别适用于金融和科学计算等需要高精度的领域。

主要特点:

  • 高精度:BigDecimal 可以处理任意精度的数值,而不像 float 和 double 存在精度限制。
  • 不可变性:BigDecimal 是不可变类,所有的算术运算都会返回新的 BigDecimal 对象,而不会修改原有对象(所以要注意性能问题)。
  • 丰富的功能:提供了加、减、乘、除、取余、舍入、比较等多种方法,并支持各种舍入模式。

13.2 扩展知识

通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。

想要解决浮点数运算精度丢失这个问题,可以直接使用 BigDecimal 来定义浮点数的值,然后再进行浮点数的运算操作即可。

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");

BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);

System.out.println(x.compareTo(y));// 0

13.2.1 如何创建 BigDecimal 对象

可以通过多种方式创建 BigDecimal 对象:

  1. 使用字符串(推荐方式,因为字符串可以精确表示数值)

    BigDecimal bd1 = new BigDecimal("123.45");
    
  2. 使用数值(不推荐,因为 double 和 float 有精度问题)

    BigDecimal bd2 = new BigDecimal(123.45); // 可能会引入精度问题
    
  3. 使用 BigDecimal.valueOf 方法(推荐方式)

    BigDecimal bd3 = BigDecimal.valueOf(123.45);
    

13.2.2 四舍五入模式介绍

  • RoundingMode.UP:向远离零的方向舍入。
  • RoundingMode.DOWN:向接近零的方向舍入。
  • RoundingMode.CEILING:向正无穷方向舍入。
  • RoundingMode.FLOOR:向负无穷方向舍入。
  • RoundingMode.HALF_UP:向“最近”的数字舍入,如果有两个相等的最近数字,则向上舍入。
  • RoundingMode.HALF_DOWN:向“最近”的数字舍入,如果有两个相等的最近数字,则向下舍入。
  • RoundingMode.HALF_EVEN:向“最近”的数字舍入,如果有两个相等的最近数字,则向相邻的偶数舍入

13.2.3 MySQL 中存储金额数据,应该使用什么数据类型?

在 MySQL 中存储金额数据,应该使用什么数据类型?(待完成)

14. BigDecimal 为什么能保证精度不丢失?

14.1 回答重点

BigDecimal 能够保证精度,是因为它使用了任意精度的整数表示法,而不是浮动的二进制表示。

BigDecimal 内部使用两个字段存储数字,一个是整数部分 intVal,另一个是用来表示小数点的位置 scale,避免了浮点数转化过程中可能的精度丢失。

计算时通过整数计算,再结合小数点位置和设置的精度与舍入行为,控制结果精度,避免了由默认浮点数舍入导致的误差。

简化版源码示意:

public class BigDecimal extends Number implements Comparable<BigDecimal> {
    private final BigInteger intVal;  // 存储整数部分
    private final int scale;          // 存储小数点的位置

    public BigDecimal(String val) {
        // 使用 BigInteger 来表示数值
        intVal = new BigInteger(val.replace(".", ""));
        scale = val.contains(".") ? val.length() - val.indexOf(".") - 1 : 0;
    }
}

例如 BigDecimal("0.123")intVal 存储 123,而 scale 存储 3,表示这个数字有三位小数。

14.2 扩展知识

14.2.1 举例说明:0.1 * 0.2

首先,让我们看看在使用 double 类型进行乘法时会发生什么:

public class FloatPrecisionExample {
    public static void main(String[] args) {
        double a = 0.1;
        double b = 0.2;
        System.out.println(a * b); // 输出 0.020000000000000004
    }
}

输出:0.020000000000000004

虽然我们期望的结果是 0.02,但是由于浮点数(double)在内存中使用二进制表示法,它不能精确表示某些十进制小数(例如 0.10.2),所以在乘法运算时就产生了微小的误差。

接下来,我们用 BigDecimal 来进行相同的乘法计算:

import java.math.BigDecimal;

public class BigDecimalPrecisionExample {
    public static void main(String[] args) {
        BigDecimal a = new BigDecimal("0.1");
        BigDecimal b = new BigDecimal("0.2");
        System.out.println(a.multiply(b));  // 输出 0.02
    }
}

输出:0.02

为什么 BigDecimal 能保证精度?

BigDecimal 内部不使用二进制浮点数表示,而是使用 BigInteger 来精确存储数字。对于 0.10.2BigDecimal 会将它们存储为整数(去除小数点)并通过 scale(小数点的位置)来跟踪小数点的位置。

  • 0.1 会被表示为 BigInteger 存储 1scale1(表示小数点后有1位)。
  • 0.2 会被表示为 BigInteger 存储 2scale1

BigDecimal 中进行乘法运算时,它会将两个 BigInteger 数字相乘,然后调整结果的 scale(小数点的位置)。具体的源码实现大致如下:

public BigDecimal multiply(BigDecimal val) {
    // 计算两个 BigDecimal 的乘积
    BigInteger result = this.intVal.multiply(val.intVal);
    // 计算新的 scale:两个数的 scale 相加
    int resultScale = this.scale + val.scale;
    return new BigDecimal(result, resultScale);
}

在乘法操作中:

  • this.intVal.multiply(val.intVal):将两个整数部分相乘,得到乘积。
  • this.scale + val.scale:乘法后,scale 会根据两个操作数的 scale 相加。因为两个操作数都有1位小数,所以最终结果的 scale2

例如:

  • 0.1 可以表示为 1 * 10^-1,即 BigInteger("1")scale = 1
  • 0.2 可以表示为 2 * 10^-1,即 BigInteger("2")scale = 1

相乘后,得到 1 * 2 = 2,并且 scale 被调整为 2(即 0.02)。

14.2.2 相关题目引用

什么是 Java 的 BigDecimal?

15. 使用 new String(“nxz”) 语句在 Java 中会创建多少个对象?

15.1 回答重点

会创建 1 或 2 个字符串对象。

主要有两种情况:

  1. 如果字符串常量池中不存在字符串对象“nxz”的引用,那么它会在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。
  2. 如果字符串常量池中已存在字符串对象“nxz”的引用,则只会在堆中创建 1 个字符串对象“nxz”。

可以看下这个图再理解一下:

原理图

15.2 扩展知识

15.2.1 详细分析

如果字符串常量池中不存在字符串对象“nxz”的引用,那么它会在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。

示例代码(JDK 1.8):

String s = new String("nxz");

对应的字节码:

对应的字节码

ldc 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。

如果字符串常量池中已存在字符串对象“nxz”的引用,则只会在堆中创建 1 个字符串对象“nxz”。

// 字符串常量池中已存在字符串对象“nxz”的引用
String s1 = "nxz";
// 下面这段代码只会在堆中创建 1 个字符串对象“nxz”
String s2 = new String("nxz");

对应的字节码:

对应的字节码

这里的过程与上面差不多,我们可以看一下,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象 “nxz”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象 “nxz” 了。 7 这个位置执行 ldc命令会直接返回字符串常量池中字符串对象“nxz”对应的引用。