Java 基础核心面试题

Java 基础核心面试题

本文件旨在提供一系列Java基础核心面试题,重点考察候选人对Java语言底层原理和核心API的掌握程度。

1. Java 核心概念

  1. == vs equals(): 请解释 ==equals() 方法的根本区别。特别是对于包装类型(如 Integer),请解释以下代码的输出,并说明原因。

    Integer a = 100;
    Integer b = 100;
    Integer c = 200;
    Integer d = 200;
    
    System.out.println(a == b);
    System.out.println(c == d);
    
    String s1 = new String("hello");
    String s2 = new String("hello");
    System.out.println(s1 == s2);
    System.out.println(s1.equals(s2));
    

    答案:

    • ==:

      • 对于基本数据类型(如 int, char),== 比较的是它们的
      • 对于引用数据类型(如 Object, String, Integer),== 比较的是对象的内存地址,即判断两个引用是否指向同一个对象实例。
    • equals():

      • Object 类的一个方法,其默认实现与 == 相同,也是比较内存地址。
      • 很多类(如 String, Integer, Double 等)重写了 equals() 方法,用于比较对象的内容是否相等。

    代码输出及解释:

    1. System.out.println(a == b); -> true

      • 原因: Java 对 Integer 类型使用了缓存机制。对于 -128127 之间的整数,通过 Integer.valueOf(int) 创建的 Integer 对象会被缓存。因此,ab 都指向了缓存池中同一个 Integer 对象。
    2. System.out.println(c == d); -> false

      • 原因: 200 超出了 Integer 的缓存范围 (-128 to 127)。因此,cd 是通过 new Integer(200) 创建的两个不同的对象,它们的内存地址不同。
    3. System.out.println(s1 == s2); -> false

      • 原因: s1s2 是通过 new String("hello") 创建的两个不同的 String 对象,它们位于堆内存中,地址不同。
    4. System.out.println(s1.equals(s2)); -> true

      • 原因: String 类重写了 equals() 方法,用于比较字符串的字符序列内容。因为 s1s2 的内容都是 “hello”,所以结果为 true
  2. String, StringBuilder, StringBuffer: 请比较这三者的异同点,并说明它们各自的适用场景。

    答案:

    特性 String StringBuilder StringBuffer
    可变性 不可变 (Immutable) 可变 (Mutable) 可变 (Mutable)
    线程安全 线程安全 非线程安全 线程安全
    性能 低(每次修改都创建新对象) 中(因同步开销)
    内部实现 final char[] (Java 8) / final byte[] (Java 9+) char[] / byte[] char[] / byte[] (方法加 synchronized)

    详细说明:

    • String:

      • 特点: 字符串常量,一旦创建,其内容不可更改。任何对 String の修改操作(如拼接、替换)都会导致创建一个新的 String 对象。
      • 优点: 不可变性使其天然线程安全,适合用作 HashMap の键。
      • 适用场景: 字符串内容不经常变化的场景。
    • StringBuilder:

      • 特点: 可变字符串,提供了 append(), insert() 等方法来修改内容,操作在原对象上进行,效率高。
      • 缺点: 非线程安全。
      • 适用场景: 单线程环境下,需要频繁进行字符串拼接或修改的场景。例如,循环中构建复杂的字符串。
    • StringBuffer:

      • 特点: 与 StringBuilder 类似,也是可变字符串。但其所有公开方法(如 append, insert)都由 synchronized 关键字修饰,保证了线程安全。
      • 缺点: 因为同步锁的存在,性能低于 StringBuilder
      • 适用场景: 多线程环境下,需要共享一个可变字符串并保证其操作安全的场景。

    选择建议:

    • 优先使用 String,除非需要频繁修改。
    • 单线程下字符串拼接,使用 StringBuilder
    • 多线程下字符串拼接,使用 StringBuffer
    // 适用场景示例
    public class StringUsageExample {
         
        // StringBuilder: 单线程循环拼接
        public String buildQuery(String[] fields) {
         
            StringBuilder sb = new StringBuilder("SELECT ");
            for (int i = 0; i < fields.length; i++) {
         
                sb.append(fields[i]);
                if (i < fields.length - 1) {
         
                    sb.append(", ");
                }
            }
            sb.append(" FROM users;");
            return sb.toString();
        }
    }
    
  3. 重写 (Override) 与重载 (Overload): 请解释它们之间的区别。

    答案:

    重载 (Overloading):

    • 定义: 在同一个类中,允许存在一个以上的同名方法,但它们的参数列表必须不同(参数个数、类型或顺序不同)。
    • 目的: 提高代码的可读性和灵活性,允许用一个方法名处理不同类型的数据。
    • 规则:
      • 方法名必须相同。
      • 参数列表必须不同。
      • 返回类型可以相同也可以不同。
      • 访问修饰符可以相同也可以不同。
    • 编译时多态: 在编译期间,编译器会根据传递的参数类型来决定调用哪个方法。

    重写 (Overriding):

    • 定义: 在子类中定义一个与父类中具有相同方法签名(方法名、参数列表)的方法。
    • 目的: 实现多态性,允许子类提供自己特定的实现来替代父类的实现。
    • 规则 (两同两小一大):
      • 方法名相同参数列表相同
      • 子类的返回类型应小于或等于父类的返回类型(协变返回类型)。
      • 子类方法抛出的异常应小于或等于父类方法抛出的异常。
      • 子类方法的访问修饰符应大于或等于父类方法的访问修饰符 (public > protected > default > private)。
    • 运行时多态: 在运行期间,JVM 会根据对象的实际类型来决定调用哪个方法。

    示例代码:

    // 重载示例
    class Calculator {
         
        public int add(int a, int b) {
         
            return a + b;
        }
    
        public double add(double a, double b) {
         
            return a + b;
        }
    }
    
    // 重写示例
    class Animal {
         
        public void makeSound() {
         
            System.out.println("Animal makes a sound");
        }
    }
    
    class Dog extends Animal {
         
        @Override // 注解 @Override 强制编译器检查是否满足重写规则
        public void makeSound() {
         
            System.out.println("Dog barks");
        }
    }
    
  4. 抽象类 vs 接口: 请比较抽象类 (Abstract Class) 和接口 (Interface) 的区别,尤其是在 Java 8 之后。

    答案:

    抽象类和接口是 Java 中实现抽象的两种核心方式,它们既有相似之处,也有本质区别。

    特性 抽象类 (Abstract Class) 接口 (Interface)
    继承关系 单继承 (一个类只能 extends 一个抽象类) 多实现 (一个类可以 implements 多个接口)
    成员变量 可以包含各种类型的成员变量(实例变量、静态变量) 只能包含 public static final 类型的常量 (隐式)
    构造方法 有构造方法 (用于子类初始化) 没有构造方法
    方法类型 可以包含抽象方法具体方法 Java 8 前只能有抽象方法
    Java 8+ 无变化 增加了 default 方法static 方法
    设计理念 “is-a” 关系,体现一种本质上的归属,强调代码复用 “has-a”“can-do” 关系,体现一种能力的契约,强调行为规范

    Java 8 之后的主要变化:
    Java 8 为接口引入了 default 方法和 static 方法,这使得接口和抽象类的界限变得有些模糊,但它们的根本设计理念没有改变。

    • default 方法: 允许在接口中提供一个方法的默认实现。实现该接口的类可以不重写此方法,直接使用默认实现,也可以根据需要重写它。这解决了在接口中添加新方法时,所有实现类都必须修改的“接口易碎”问题。
    • static 方法: 允许在接口中定义静态方法,这些方法只能通过接口名直接调用,不能被实现类继承或重写。通常用作工具方法。

    如何选择?

    • 优先使用接口:

      • 当你需要定义一组行为规范或契约,而不在乎具体实现时。
      • 当你希望一个类能拥有多种不相关的能力时(利用多实现)。
      • 当你希望为不同层级的类提供通用的、可插拔的功能时。
    • 使用抽象类:

      • 当你想在多个相关的子类之间共享代码(特别是成员变量和非 public 的方法)时。
        . 当你定义的类本质上是一个未完成的基类,需要子类来完善其实现时。
      • 当你需要控制类的继承关系,并确保子类与基类之间是强烈的 “is-a” 关系时。
  5. final, finally, finalize 的区别:

    答案:

    这三个关键字在 Java 中用途完全不同,但因拼写相似而常常被放在一起比较。

    • final:

      • 定义: 一个修饰符,用于表示“最终”状态。
      • 用途:
        • 修饰变量: 如果是基本数据类型,其值一旦初始化后不能再改变;如果是引用类型,其引用地址不能再改变,但引用指向的对象内容本身是可以改变的。
        • 修饰方法: 该方法不能被任何子类重写 (Override)。
        • 修饰类: 该类不能被继承。
    • finally:

      • 定义: 一个关键字,用在 try-catch 异常处理语句块中。
      • 用途: finally 块中的代码总会被执行(除非在 trycatch 中调用了 System.exit() 或 JVM 崩溃)。主要用于确保资源的释放,如关闭文件流、数据库连接、网络连接等。try-with-resources 语句是其最佳实践。
    • finalize:

      • 定义: Object 类的一个方法 (protected void finalize() throws Throwable)。
      • 用途: 当垃圾收集器 (GC) 确定不存在对该对象的更多引用时,由 GC 在回收该对象前调用此方法。它提供了一个对象在被销毁前执行清理操作的机会。
      • 注意: 强烈不推荐使用 finalize。它的执行时机不确定,可能导致性能问题,并且不是一个可靠的资源释放方式。自 Java 9 起,该方法已被废弃 (deprecated)。现代 Java 中,应使用 try-with-resources 语句或 java.lang.ref.Cleaner 来进行资源管理。
  6. Java 异常体系: 请描述 Java 的异常体系结构,以及 Checked Exception 和 Unchecked Exception 的区别。

    答案:

    Java 的异常体系结构基于 Throwable 类,所有异常和错误都继承自它。

    体系结构:

    • Throwable: 所有错误或异常的超类。
      • Error (错误): 表示程序无法处理的严重问题,通常是 JVM 层面或底层资源耗尽等问题。应用程序不应该(也通常无法)捕获或处理 Error
        • 示例: OutOfMemoryError, StackOverflowError
      • Exception (异常): 表示程序本身可以处理的异常情况。这是我们日常编程中主要关注和处理的部分。它又分为两类:
        • Checked Exception (受检异常):
          • 定义: Exception 类及其子类中,除了 RuntimeException 及其子类之外的所有异常。
          • 特点: Java 编译器会强制要求程序员处理这类异常,必须在代码中使用 try-catch 块捕获,或者在方法签名上使用 throws 关键字声明抛出。
          • 目的: 提醒开发者处理那些在正常情况下也可能发生的、可恢复的外部问题。
          • 示例: IOException, SQLException, ClassNotFoundException
        • Unchecked Exception (非受检异常):
          • 定义: RuntimeException 类及其所有子类。
          • 特点: 编译器不强制要求处理。这类异常通常是由程序中的逻辑错误(Bugs)引起的。
          • 目的: 它们应该在代码中被避免,而不是被捕获。
          • 示例: NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException

    核心区别总结:

    特性 Checked Exception Unchecked Exception
    强制处理 (编译器强制)
    继承关系 继承自 Exception (非 RuntimeException) 继承自 RuntimeException
    产生原因 通常是外部因素,可恢复 通常是程序逻辑错误
    处理方式 try-catchthrows 应该修复代码逻辑,而非捕获
  7. Java 的四种引用类型: 请解释 Java 中的四种引用类型(强、软、弱、虚)及其应用场景。

    答案:

    Java 提供了四种不同强度的引用类型,让开发者能更灵活地控制对象的生命周期和与垃圾收集器 (GC) 的交互。

    引用类型 特点 回收时机 应用场景
    强引用 (Strong) 默认的引用类型 (Object obj = new Object()) 只要强引用存在,GC 永远不会回收 普通的对象引用
    软引用 (Soft) 内存不足时才会被回收 当 JVM 即将发生 OutOfMemoryError 之前 实现内存敏感的高速缓存
    弱引用 (Weak) 只能存活到下一次 GC 发生之前 只要发生 GC,无论内存是否充足,都会被回收 ThreadLocalWeakHashMap、防止内存泄漏
    虚引用 (Phantom) 任何时候都可能被回收,必须和 ReferenceQueue 联合使用 与弱引用类似,但主要用于跟踪对象被回收的状态 管理堆外内存 (DirectByteBuffer)

    详细解释:

    • 强引用 (Strong Reference):

      • 我们平时使用最多的引用,如 Object obj = new Object();。只要对象有强引用指向,垃圾收集器就绝不会回收它。如果内存不足,JVM 宁愿抛出 OutOfMemoryError,也不会回收具有强引用的对象。
    • 软引用 (Soft Reference):

      • 用于描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
      • 实现: SoftReference softRef = new SoftReference<>(new Object());
      • 应用: 非常适合做高速缓存。例如,一个图片加载库可以用软引用来缓存图片对象,当内存充足时,图片可以快速从缓存中获取;当内存紧张时,JVM 会自动回收这些缓存的图片,避免程序崩溃。
      • 弱引用 (Weak Reference):

        • 强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
        • 实现: WeakReference weakRef = new WeakReference<>(new Object());
        • 应用:
          • WeakHashMap: key 是弱引用,当 key 没有其他强引用时,这个 entry 就会被 GC 清理。
          • ThreadLocal: ThreadLocalMapkey 是对 ThreadLocal 对象的弱引用,有助于防止内存泄漏。
        • 虚引用 (Phantom Reference):

          • 也称为“幽灵引用”或“幻影引用”,是所有引用类型中最弱的一种。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
          • 唯一目的: 能在这个对象被收集器回收时收到一个系统通知。它必须和 ReferenceQueue 联合使用。
          • 实现: PhantomReference phantomRef = new PhantomReference<>(new Object(), referenceQueue);
          • 应用: 主要用于跟踪对象被垃圾回收的状态。例如,NIO 中的 DirectByteBuffer 使用虚引用来管理堆外内存的释放。当 DirectByteBuffer 对象被回收时,其对应的虚引用会进入 ReferenceQueue,一个专门的线程会处理队列中的引用,并调用 freeMemory 方法释放堆外内存。
          • 多态的实现原理: 请解释 Java 中多态的实现原理。

            答案:

            Java 的多态性(特别是运行时多态)是其面向对象三大特性(封装、继承、多态)之一,其实现核心依赖于动态绑定 (Dynamic Binding),也称为后期绑定 (Late Binding)

            实现原理可以概括为以下几点:

            1. 方法表 (Method Table):

              • 当 JVM 加载一个类时,会在方法区为这个类创建一个方法表vtable)。这个方法表存放了该类所有方法的直接引用(即实际内存地址)。
              • 如果子类没有重写父类的方法,那么子类方法表中的该方法条目会指向父类方法的实现。
              • 如果子类重写了父类的方法,那么子类方法表中的该方法条目会指向子类自己实现的版本。
            2. invokevirtual 指令:

              • 当我们通过一个父类引用调用一个方法时(例如 Animal animal = new Dog(); animal.makeSound();),编译器生成的字节码是 invokevirtual 指令。
              • 这条指令并不直接指定要调用的方法的内存地址。相反,它包含了对方法的一个符号引用(例如 Animal.makeSound)。
            3. 运行时解析:

              • 在程序运行时,当 invokevirtual 指令被执行时,JVM 会执行以下步骤:
                1. 查看栈上操作数,找到该方法所属对象的实际类型(在这个例子中是 Dog)。
                2. 根据对象的实际类型,查找对应的方法表(即 Dog 类的方法表)。
                3. 在方法表中查找与符号引用相匹配的方法(makeSound),获取其直接引用(内存地址)。
                4. 执行该方法。

            总结:
            多态的实现,就是将“调用哪个方法”的决定,从编译期推迟到了运行期。编译器只检查方法是否存在于父类引用类型中(语法检查),而 JVM 在运行时根据对象的真实身份(new 出来的对象类型)来动态选择要执行的具体方法版本。这个过程就是动态绑定。

          • equals()hashCode() 的契约: 为什么重写 equals() 方法时必须重写 hashCode() 方法?

            答案:

            这是 Java 中一条非常重要的规则,主要为了保证在使用哈希集合(如 HashMap, HashSet, Hashtable)时能够正确工作。Object 类的通用约定规定了 hashCode()equals() 之间必须满足的契约:

            1. 等价对象等价哈希码: 如果两个对象通过 equals(Object) 方法比较是相等的,那么调用这两个对象中任意一个的 hashCode() 方法都必须产生相同的整数结果。

              • if (a.equals(b)) { assert a.hashCode() == b.hashCode(); }
            2. 非等价对象不要求不等价哈希码: 如果两个对象通过 equals(Object) 方法比较是不相等的,那么它们的 hashCode() 方法不被要求必须产生不同的结果。但是,为不相等的对象产生不同的哈希码有助于提高哈希表的性能。

            3. 哈希码一致性: 在一个 Java 应用的执行期间,如果一个对象用于 equals 比较的信息没有被修改,那么对该对象多次调用 hashCode() 方法必须始终返回相同的值。

            为什么必须遵守这个契约?

            • 哈希集合在存储和检索对象时,会先使用 hashCode() 来快速定位对象所在的哈希桶 (bucket)
            • 如果只重写 equals() 而不重写 hashCode(),就会导致两个通过 equals() 判断为相等的对象,由于继承了 Object 类的 hashCode() 方法(该方法通常返回对象的内存地址),而拥有不同的哈希码。
            • 后果:
              • 当你试图将这两个“相等”的对象放入 HashSet 时,它们会被分配到不同的哈希桶中,导致 HashSet 认为它们是两个不同的对象,从而破坏了 Set 的唯一性
              • 当你使用其中一个对象作为 keyHashMap 中查找时,由于哈希码不同,你可能永远也找不到另一个“相等”的对象作为 key 存入的 value

            示例:

            class Person {
                 
                String name;
                public Person(String name) {
                  this.name = name; }
            
                @Override
                public boolean equals(Object obj) {
                 
                    if (this == obj) return true;
                    if (obj == null || getClass() != obj.getClass()) return false;
                    Person person = (Person) obj;
                    return name.equals(person.name);
                }
            
                // 如果不重写 hashCode(),就会出现问题
                // @Override
                // public int hashCode() {
                 
                //     return name.hashCode();
                // }
            }
            
            Set<Person> set = new HashSet<>();
            set.add(new Person("Alice"));
            System.out.println(set.contains(new Person("Alice"))); // 如果没重写 hashCode(),这里会是 false
            
          • 泛型通配符 ? extends T? super T: 请解释这两种泛型通配符的区别和使用场景 (PECS 原则)。

            答案:

            ? extends T (上界通配符) 和 ? super T (下界通配符) 是 Java 泛型中用于增加方法灵活性的高级特性。它们的区别可以通过 PECS (Producer Extends, Consumer Super) 原则来理解。

            PECS 原则:

            • Producer Extends: 如果你需要一个只生产(提供、返回)T 类型对象的泛型集合(即你只会从中读取 T),那么使用 ? extends T
            • Consumer Super: 如果你需要一个只消费(接收、使用)T 类型对象的泛型集合(即你只会向其中写入 T),那么使用 ? super T

            ? extends T (上界通配符)

            • 含义: List 表示这个列表可以持有 Number 或其任何子类型(如 Integer, Double)的对象。
            • 限制:
              • 可以安全地读取: 你可以从这个列表中读取元素,因为你知道取出的任何元素都至少是一个 Number
              • 不能安全地写入: 你不能向这个列表中添加任何元素(除了 null)。因为编译器无法确定列表的确切类型。例如,你不能往一个 List 中添加一个 Double 对象。
            • 场景 (Producer): 当一个方法需要从一个集合中获取数据时。
              // 计算列表中所有数字的总和
              public static double sum(Collection<? extends Number> numbers) {
                     
                  double sum = 0.0;
                  for (Number n : numbers) {
                      // 安全地读取 Number
                      sum += n.doubleValue();
                  }
                  // numbers.add(1); // 编译错误!
                  return sum;
              }
              // 调用:
              // sum(new ArrayList());
              // sum(new ArrayList());
              

            ? super T (下界通配符)

            • 含义: List 表示这个列表可以持有 Integer 或其任何父类型(如 Number, Object)的对象。
            • 限制:
              • 可以安全地写入: 你可以向这个列表中添加 Integer 或其子类型的对象,因为它们都可以向上转型为列表声明的任何父类型。
              • 不能安全地读取: 当你从这个列表中读取元素时,你只能确定它是一个 Object,无法确定其具体类型(除非进行强制类型转换)。
            • 场景 (Consumer): 当一个方法需要向一个集合中添加数据时。
              // 将多个整数添加到集合中
              public static void addIntegers(List<? super Integer> list) {
                     
                  list.add(1); // 安全地写入 Integer
                  list.add(2);
                  // Object obj = list.get(0); // 读取时只能确定是 Object
              }
              // 调用:
              // addIntegers(new ArrayList());
              // addIntegers(new ArrayList());
              // addIntegers(new ArrayList());
                
                    
                 
            • String s = new String("xyz"); 创建了几个对象?

              答案:

              这句代码可能创建一个或两个对象,具体取决于字符串常量池(String Constant Pool)中是否已经存在 “xyz”。

              1. 一个对象: 如果字符串常量池中已经存在 “xyz” 的引用。

                • 在这种情况下,new String("xyz") 只会在堆内存中创建一个新的 String 对象,这个对象的内容是 “xyz” 的一个副本。
              2. 两个对象: 如果字符串常量池中不存在 “xyz” 的引用。

                • JVM 会首先在字符串常量池中创建一个 “xyz” 的对象。
                • 然后,new String() 会在堆内存中再创建一个 String 对象,这个对象的内容同样是 “xyz”。
            • 你可能感兴趣的:(java,面试,java,面试)