Java基础面试题

1. Java中的序列化和反序列化是什么?

1.1 回答重点

1.1.1 序列化

将对象转换为字节流的过程,这样对象可以通过网络传输、持久化存储或者缓存。Java 提供了java.io.Serializable接口来支持序列化,只要类实现了这个接口,就可以将该类的对象进行序列化。

1.1.2 反序列化

将字节流重新转换为对象的过程,即从存储中读取数据并重新创建对象。

1.1.3 其它

  • 应用场景:包括网络传输、远程调用、持久化存储(如保存到文件或数据库)、以及分布式系统中数据交换。
  • Java 序列化关键类和接口ObjectOutputStream用于序列化,ObjectInputStream用于反序列化。类必须实现Serializable接口才能被序列化。
  • transient 关键字:在序列化过程中,有些字段不需要被序列化,例如敏感数据,可以使用 transient 关键字标记不需要序列化的字段
  • serialVersionUID:每个Serializable类都应该定义一个serialVersionUID,用于在反序列化时验证版本一致性。如果没有明确指定,Java 会根据类的定义自动生成一个 UID,版本不匹配可能导致反序列化失败。
  • 序列化性能问题:Java的默认序列化机制可能比较慢,尤其是对于大规模分布式系统,可能会选择更加高效的序列化框架(如 Protobuf、Kryo)。
  • 安全性:反序列化是一个潜在的安全风险,因为通过恶意构造的字节流,可能会加载不安全的类或执行不期望的代码。因此,反序列化过程需要进行输入验证,避免反序列化漏洞。

1.2 扩展知识

1.2.1 序列化与反序列化理解

序列化其实就是将对象转化成可传输的字节序列格式,以便于存储和传输。

因为对象在JVM中可以认为是“立体”的,会有各种引用,比如在内存地址Ox1234引用了某某对象,那此时这个对象要传输到网络的另一端时候就需要把这些引用“压扁”。

因为网络的另一端的内存地址Ox1234可以没有某某对象,所以传输的对象需要包含这些信息,然后接收端将这些扁平的信息再反序列化得到对象。

所以,反序列化就是将字节序列格式转换为对象的过程

1.2.2 Java序列化Serializable的意义

首先说一下Serializable,这个接口没有什么实际的含义,就是起标记作用。

来看下源码就很清楚了,除了String、数组和枚举之外,如果实现了这个接口就走writeOrdinaryObject ,否则就序列化就抛错。

ObjectOutputStream源码片段

serialVersionUID又有什么用?

例如我们经常在代码中看见这样代码:

private static final long serialVersionUID = 1L;

这个ID其实就是用来验证序列化的对象和反序列化对应的对象的ID是否是一致的。

所以这个ID的数字其实不重要,无论是1L还是idea自动生成的,只要序列化的时候对象的serialVersionUID和反序列化的时候对象的serialVersionUID一致的话就行。

如果没有显式指定serialVersionUID,则编译器会根据类的相关信息自动生成一个,可以认为是一个指纹。

所以如果你没有定义一个serialVersionUID然后序列化一个对象之后,在反序列化之前把对象的类的结构改了,比如增加了一个成员变量,则此时的反序列化会失败。

因为类的结构变了,生成的指纹就变了,所以serialVersionUID就不一致了。

所以serialVersionUID就是起验证作用

Java序列化不包含静态变量

简单地说就是序列化之后存储的内容不包含静态变量的值,看一下下面的代码就很清晰了。

public class Test implements Serializable {
    private static final long serialVersionUID = 1L;

    public static int age  = 1;

    public static void main(String[] args) {
        try {
            /// 1. 首先将Test对象序列化到文件
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("Java 序列化不包含静态变量测试"));
            out.writeObject(new Test());
            out.close();

            //序列化之后,修改值为2
            Test.age = 2;

            //3. 从文件中反序列化对象
            ObjectInputStream oin = new ObjectInputStream(new FileInputStream("Java 序列化不包含静态变量测试"));
            Test test = (Test) oin.readObject();
            oin.close();

            //此时值是2,而不是1,因为age是 静态变量
            System.out.println(test.age);
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }
}

重要结论:

  • Java序列化只会保存对象的状态,不会保存静态变量的状态
  • 静态变量属于类,而不是对象实例
  • 反序列化时,静态变量的值是当前运行时环境中的值

[!note]

简单的说就是,序列化本身就是存储对象实例的状态,而静态变量是属于类的,在每一个对象中都是相同的。所以序列化不存储静态变量的值。

2. 什么是Java中的不可变类?

2.1 回答重点

不可变类是指在创建后其状态(对象的字段)无法被修改的类。一旦对象被创建,它的所有属性都不能被更改。这种类的实例在整个生命周期内保持不变。

关键特征

  1. 声明类为 final,防止子类继承。
  2. 类的所有字段都是privatefinal,确保它们在初始化后不能被更改。
  3. 通过构造函数初始化所有字段。
  4. 不提供任何修改对象状态的方法(如 setter 方法)。
  5. 如果类包含可变对象的引用,确保这些引用在对象外部无法被修改。例如getter方法中返回对象的副本(new 一个新的对象)来保护不可变对象。

Java 中的经典不可变类有:StringIntegerBigDecimalLocalDate 等。

2.2 扩展知识

2.2.1 不可变类的优缺点

优点

  1. 线程安全:由于不可变对象的状态不能被修改,它们天生是线程安全的,在并发环境中无需同步。
  2. 缓存友好:不可变对象可以安全地被缓存和共享,如 String 的字符串常量池。
  3. 防止状态不一致:不可变类可以有效避免因意外修改对象状态而导致的不一致问题。

缺点

  1. 性能问题:不可变对象需要在每次状态变化时创建新的对象,这可能会导致性能开销,尤其是对于大规模对象或频繁修改的场景(例如 String 频繁拼接)。

2.2.2 举例String

String就是典型的不可变类,当你创建一个 String 对象之后,这个对象就无法被修改。

因为无法被修改,所以像执行s += "a"; 这样的方法,其实返回的是一个新建的String对象,老的s指向的对象不会发生变化,只是s的引用指向了新的对象而已。

所以不要在字符串拼接频繁的场景使用+来拼接,因为这样会频繁的创建对象。

不可变类的好处就是安全,因为知晓这个对象不可能会被修改,因此可以放心大胆的用,在多线程环境下也是线程安全的。

2.2.3 如何实现一个不可变类?

具体按照回答重点内的关键特征实现就行了。我们来一起看下String的设计。

String类用final修饰,表示无法被继承。

String类被final修饰

String本质是一个cha 数组,然后用fina 修饰,不过final限制不了数组内部的数据,所以这还不够。

所以value是用private修饰的,并且没有暴露出set方法,这样外部其实就接触不到value所以无法修改。

当然还是有修改的需求,比如replace方法,所以这时候就需要返回一个新对象来作为结果。

replace方法源码

总结一下就是私有化变量,然后不要暴露 set 方法,即使有修改的需求也是返回一个新对象。

[!caution]

虽然 Java 中的不可变类设计初衷是确保一旦对象被创建,其属性无法被修改,但通过Java的反射机制,理论上是可以修改不可变对象的属性值的,即使这些属性是privatefinal的。

3. Java中Exception和Error有什么区别?

3.1 回答重点

ExceptionError都是Throwable类的子类(在Java代码中只有继承了Throwable类的实例才可以被throw或者被catch)它们表示在程序运行时发生的异常或错误情况。

总结来看:Exception 表示可以被处理的程序异常,Error表示系统级的不可恢复错误

详细说明:

  1. Exception:是程序中可以处理的异常情况,表示程序逻辑或外部环境中的问题,可以通过代码进行恢复或处理。

    常见子类有:IOExceptionSQLExceptionNullPointerExceptionIndexOutOfBoundsException 等。

    Exception 又分为Checked Exception(编译期异常)和Unchecked Exception(运行时异常)。

    • Checked Exception:在编译时必须显式处理(如使用try-catch块或通过throws声明抛出)。如IOException
    • Unchecked Exception运行时异常,不需要显式捕获。常见的如NullPointerExceptionIllegalArgumentException等,继承自 RuntimeException
  2. Error:表示严重的错误,通常是JVM层次内系统级的、无法预料的错误,程序无法通过代码进行处理或恢复。例如内存耗尽(OutOfMemoryError)、栈溢出(StackOverflowError)。

Error不应该被程序捕获或处理,因为一般出现这种错误时程序无法继续运行。

3.2 扩展知识

3.2.1 异常处理时需要注意的六个点

  1. 尽量不要捕获类似Exception这样通用的异常,而应该捕获特定的异常

软件工程是一门协作的艺术,在日常的开发中我们有义务使自己的代码能更直观、清晰地表达出我们想要表达的信息。

但是如果你什么异常都用了Exception,那别的开发同事就不能一眼得知这段代码实际想要捕获的异常,并且这样的代码也会捕获到可能你希望它抛出而不希望捕获的异常。

  1. 不要 “吞”了异常。

如果我们捕获了异常,不把异常抛出,或者没有写到日志里,那会出现什么情况?线上除了bug莫名其妙的没有任何的信息,你都不知道哪里出错以及出错的原因。

这可能会让一个简单的bug变得难以诊断,而且有些同学比较喜欢用catch之后用e.printStackTrace(),在我们产品中通常不推荐用这种方法,一般情况下这样是没有问题的但是这个方法输出的是个标准错误流。

比如是在分布式系统中,发生异常但是找不到stacktrace

所以最好是输入到日志里,我们产品可以自定义一定的格式,将详细的信息输入到日志系统中,适合清晰高效的排查错误。

  1. 不要延迟处理异常。

比如你有个方法,参数是个name,函数内部调了别的好几个方法,其实你的name传的是null值,但是你没有在进入这个方法或者这个方法一开始就处理这个情况,而是在你调了别的好几个方法然后爆出这个空指针。

这样的话明明你的出错堆栈信息只需要抛出一点点信息就能定位到这个错误所在的地方,经过了好多方法之后可能就是一坨堆栈信息。

  1. 只在需要try-catch的地方try-catch,try-catch的范围能小则小

只要必要的代码段使用try-catch,不要不分青红皂白try住一坨代码,因为try-catch中的代码会影响JVM对代码的优化,例如重排序。

  1. 不要通过异常来控制程序流程。

一些可以用if/else的条件语句来判断例如null值等,就不要用异常,异常肯定是比一些条件语句低效的,有CPU分支预测的优化等。

而且每实例化一个Exception都会对栈进行快照,相对而言这是一个比较重的操作,如果数量过多开销就不能被忽略了。

  1. 不要在finally代码块中处理返回值或者直接return

在finally中return或者处理返回值会让发生很诡异的事情,比如覆盖了try中的return ,或者屏蔽的异常。

总结

4. 什么是Java的多态特性?

4.1 回答重点

多态是指同一个接口或父类引用变量可以指向不同的对象实例,并根据实际指向的对象类型执行相应的方法

它允许同一方法在不同对象上表现出不同的行为,是面向对象编程(OOP)的核心特性之一。

多态的优点:通过多态,程序可以灵活地处理不同类型的对象,降低代码耦合度,增强系统的可扩展性。新增子类或实现类时,无需修改原有代码,只需通过接口或父类引用调用即可。

4.2 扩展知识

4.2.1 多态的意义(理解版)

多态其实是一种抽象行为,它的主要作用是让程序员可以面对抽象编程而不是具体的实现类,这样写出来的代码扩展性会更强。

大家可能不是很理解什么是抽象什么是具体,我举个可能不是很恰当,但是很好理解的例子:比如某个人很喜欢吃苹果,我们在写文章描述他的时候可以写他很喜欢吃苹果,也可以写他很喜欢吃水果。

水果就是抽象,苹果就是具体的实现类。

假设这个人某天开始换口味了,他喜欢吃桃子了,如果我们之前的文章写的是水果,那么完全不需要改,如果写的是苹果,是不是需要把苹果替换成桃子了?

这就是多态的意义。

再举个代码的例子:

比如 Person person = new Student()

Person是父类,含有一个工作的方法,student重写工作方法,比如上学。

class Person {
   void work() {
       System.out.println("工作");
   }
}

class Student extends Person {
   @Override
   void work() {
       System.out.println("上学");
   }
}

public class Test {
   public static void main(String[] args) {
       Person person = new Student();
       person.work(); // 输出 "上学"
   }
}

这样在使用的时候,对象都是Person,但是new不同的实现类,表现的形式不同,这也就从字面上解释的什么叫多态。

4.2.2 编译时多态和运行时多态

编译时多态和运行时多态是面向对象编程中多态性的两种实现方式,它们分别在不同的阶段决定方法的绑定。

  • 编译时多态:通过方法重载实现,在编译时确定方法的调用。
  • 运行时多态:通过方法重写实现,在运行时确定方法的调用。

4.2.2.1 编译时多态(Compile-time Polymorphism)

编译时多态,也称为静态多态,是在编译阶段确定方法的调用。编译时多态主要通过方法重载(Method Overloading) 实现。

方法重载:指在同一个类中定义多个方法,这些方法的名称相同但参数列表(参数的类型或数量)不同。Java 编译器在编译时会根据方法调用时传入的参数类型和数量,决定调用哪一个重载方法。

示例:

class Example {
    void display(int a) {
        System.out.println("Integer: " + a);
    }

    void display(double a) {
        System.out.println("Double: " + a);
    }

    void display(String a) {
        System.out.println("String: " + a);
    }
}

public class Main {
    public static void main(String[] args) {
        Example obj = new Example();
        obj.display(5);         // 调用 display(int a)
        obj.display(3.14);      // 调用 display(double a)
        obj.display("Hello");   // 调用 display(String a)
    }
}

在这个例子中,根据传入的参数类型,编译器在编译时决定调用哪个display方法。

4.2.2.2 运行时多态(Runtime Polymorphism)

运行时多态,也称为动态多态,是在运行时确定方法的调用。运行时多态通过方法重写(Method Overriding) 实现。

方法重写:子类重写父类的一个或多个方法。通过父类引用调用方法时,实际执行的是子类重写后的方法。这种多态性是在运行时根据对象的实际类型决定的。

示例:

class Animal {
    void sound() {
        System.out.println("Animal makes a sound");
    }
}

class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("Dog barks");
    }
}

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("Cat meows");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal myDog = new Dog(); // Animal reference but Dog object
        Animal myCat = new Cat(); // Animal reference but Cat object

        myDog.sound(); // 输出: Dog barks
        myCat.sound(); // 输出: Cat meows
    }
}

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

5.1 回答重点

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

  • 基本数据类型(如 int, char, boolean等):传递的是值的副本,即基本类型的数值本身。因此,对方法参数的任何修改都不会影响原始变量。
  • 引用数据类型(如对象引用):传递的是引用的副本,即对象引用的内存地址。因此,方法内可以通过引用修改对象的属性,但不能改变引用本身,使其指向另一个对象。

5.2 扩展知识

5.2.1 基本类型与引用类型的区别

  • 基本类型:包括 int, float, double, char, boolean等,存储在栈内存中。方法中对基本类型参数的操作只会影响传递的副本,原始变量的值不受影响。
  • 引用类型:包括所有的对象和数组,引用类型的变量存储的是对象在堆内存中的地址。当引用类型作为参数传递时,传递的是这个地址的副本。因此,方法内的修改可以影响到传入的对象的内容,但不会影响对象引用本身的地址。

示例图

示例代码分析:

public class ParameterPassing {
    public static void main(String[] args) {
        int a = 5;
        modifyPrimitive(a);
        System.out.println("After modifyPrimitive: " + a); // 输出: 5

        MyObject obj = new MyObject();
        obj.value = 10;
        modifyObject(obj);
        System.out.println("After modifyObject: " + obj.value); // 输出: 20

        resetReference(obj);
        System.out.println("After resetReference: " + obj.value); // 输出: 20
    }

    public static void modifyPrimitive(int num) {
        num = 10; // 仅仅修改了副本,不影响原始变量
    }

    public static void modifyObject(MyObject obj) {
        obj.value = 20; // 修改了对象的属性,会影响原始对象
    }

    public static void resetReference(MyObject obj) {
        obj = new MyObject(); // 修改的是引用的副本,不影响原始对象
        obj.value = 30;
    }
}

class MyObject {
    int value;
}
  • modifyPrimitive 方法中,num是基本类型的副本,因此对它的修改不影响原始变量a
  • modifyObject 方法中,obj是引用类型的副本,但这个副本仍指向原始对象,因此修改value属性会影响原始对象。
  • resetReference 方法中,obj被重新赋值为一个新对象,这个变化只影响副本,不影响原始引用。

不可变类

关于引用的回答后,面试官可能会接着问不可变类。不可变类在多线程环境中不需要额外的同步控制,因为它们的状态一旦创建就不能改变。

更多可看:什么是 Java 中的不可变类?

6. 为什么Java不支持多重继承?

6.1 回答重点

主要是因为多继承会产生菱形继承(也叫钻石继承)问题,Java之父就是吸取C++他们的教训,因此不支持多继承。

所谓的菱形继承很好理解,我们来看下这个图:

示意图

是不是看起来很像一个菱形,BC继承了A,然后D继承了BC, 假设此时要调用D内定义在A的方法,因为B和C都有不同的实现,此时就会出现歧义,不知道应该调用哪个了。

6.2 扩展知识

这里很可能会被面试官追问:既然多继承不行,那为什么接口多实现可以?

6.2 为什么接口可以多实现?

在 Java8之前接口是无法定义具体方法实现的,所以即使有多个接口必须子类自己实现,所以并不会发生歧义。

Java8之后出了默认方法(default method),此时不就又出现的多继承的菱形继承问题了?

所以Java 强制规定,如果多个接口内有相同的默认方法,子类必须重写这个方法。

不然,编译期就会报错:

接口A、B中有相同的默认方法

7. Java方法重载和方法重写之间的区别是什么?

7.1 回答重点

方法重载(Overloading) :在同一个类中,允许有多个同名方法,只要它们的参数列表不同(参数个数、类型或顺序)。主要关注方法的签名变化,适用于在同一类中定义不同场景下的行为。

方法重写(Overriding):子类在继承父类时,可以重写父类的某个方法(参数列表、方法名必须相同),从而为该方法提供新的实现。主要关注继承关系,用于子类改变父类的方法实现,实现运行时多态性。

区别主要如下:

区别 重载 重写
发生的场所 在同一个类中 在继承关系的子类和父类之间
参数列表 必须不同(参数的数量、类型或顺序不同) 必须相同,不能改变参数列表
返回类型 可以不同 必须与父类方法的返回类型相同,或者是父类返回类型的子类(协变返回类型)
访问修饰符 不受访问修饰符影响 子类方法的访问修饰符不能比父类更严格,通常是相同或更宽泛
静态和非静态方法 可以是静态方法或非静态方法 只能重写非静态方法,静态方法不能被重写(静态方法可以被隐藏)
异常处理 方法的异常处理可以不同 子类的异常不能抛出比父类更多的异常(可以抛出更少的或相同类型的异常)

7.2 扩展知识

7.2.1 重载注意点

重载中提到的方法同名但参数列表不同(参数个数、类型或顺序),这里要注意和返回值没有关系,方法的签名仅是名字和参数列表,不包括返回值。

重载通常用于提供同一操作的不同实现,例如构造函数的重载、不同类型输入的处理等

重载简单示例代码:

public class OverloadingExample {
    // 重载方法:参数数量不同
    public void print(int a) {
        System.out.println("Printing int: " + a);
    }

    // 重载方法:参数类型不同
    public void print(String a) {
        System.out.println("Printing String: " + a);
    }

    // 重载方法:参数类型和数量不同
    public void print(int a, int b) {
        System.out.println("Printing two ints: " + a + ", " + b);
    }
}

7.2.2 重写注意点

在重写时,子类方法不能使用比父类更严格的访问级别。例如,父类的方法是protected,子类不能将其修改为private,但可以改为public

且子类方法抛出的异常必须与父类一致,或者是其父类异常的子类。

重写通常用于在子类中提供父类方法的具体实现,以实现多态性。例如,子类对父类方法进行扩展或修改以适应特定需求

重写简单示例代码:

class Parent {
    public void display() {
        System.out.println("Parent display");
    }
}

class Child extends Parent {
    @Override
    public void display() {
        System.out.println("Child display");
    }
}

public class OverridingExample {
    public static void main(String[] args) {
        Parent obj = new Child();
        obj.display(); // 输出 "Child display"
    }
}

还有一个 @Override 注解 ,在重写方法时使用@Override注解,编译器可以帮助检查是否正确实现了重写,以防误操作。

8. 什么是Java内部类?它有什么作用?

8.1 回答重点

Java 内部类是指在一个类的内部定义的类,Java 支持多种类型的内部类,包括成员内部类、局部内部类、匿名内部类和静态内部类。内部类可以访问外部类的成员变量和方法,甚至包括私有的成员。

内部类的作用 主要包括:

  1. 封装性:将逻辑相关的类封装在一起,提高类的内聚性。
  2. 访问外部类成员:内部类可以方便地访问外部类的成员变量和方法,尤其在需要操作外部类对象的场景下非常有用。
  3. 简化代码:对于只在一个地方使用的小类,内部类能减少冗余代码,简化结构。
  4. 事件处理:匿名内部类广泛用于实现回调函数或事件监听,简化了代码结构,特别是对于实现接口或抽象类的场景。

8.2 扩展知识

8.2.1 内部类的类型

  • 成员内部类:非静态类,作为外部类的一个成员。它可以直接访问外部类的所有成员,包括私有成员。
  • 静态内部类:定义为static,无法访问外部类的非静态成员,只能访问外部类的静态成员。
  • 局部内部类:定义在方法或代码块中的类,仅在该方法或代码块内可见,通常用于临时的对象构建。
  • 匿名内部类:没有类名的内部类,通常用于创建短期使用的类实例,尤其是在接口回调或事件处理时被广泛使用。

8.2.2 内部类举例

  1. 成员内部类:定义在另一个类中的类,可以使用外部类的所有成员变量以及方法,包括 private 的。

    public class OuterClass {
        private String outerField = "Outer Field";
    
        class InnerClass {
            void display() {
                System.out.println("Outer Field: " + outerField);
            }
        }
    
        public void createInner() {
            InnerClass inner = new InnerClass();
            inner.display();
        }
    }
    
  2. 静态内部类:只能访问外部类的静态成员变量以及方法,其实它就等于一个顶级类,可以独立于外部类使用,所以更多的只是表明类结构和命名空间。

    public class OuterClass {
        private static String staticOuterField = "Static Outer Field";
    
        static class StaticInnerClass {
            void display() {
                System.out.println("Static Outer Field: " + staticOuterField);
            }
        }
    
        public static void createStaticInner() {
            StaticInnerClass staticInner = new StaticInnerClass();
            staticInner.display();
        }
    }
    
  3. 局部内部类:指在方法中定义的类,只在该方法内可见,可以访问外部类的成员以及方法中的局部变量(需要声明为finaleffectively final)。

    public class OuterClass {
        void outerMethod() {
            final String localVar = "Local Variable";
    
            class LocalInnerClass {
                void display() {
                    System.out.println("Local Variable: " + localVar);
                }
            }
    
            LocalInnerClass localInner = new LocalInnerClass();
            localInner.display();
        }
    }
    
  4. 匿名类:指的是没有类名的内部类。用于简化实现接口和继承类的代码,仅在创建对象时使用,例如回调逻辑定义场景。

    public class OuterClass {
        interface Greeting {
            void greet();
        }
    
        public void sayHello() {
            Greeting greeting = new Greeting() {
                @Override
                public void greet() {
                    System.out.println("Hello, World!");
                }
            };
            greeting.greet();
        }
    }
    

局部内部类用的比较少,常用成员内部类、静态内部类和匿名内部类。

实际上内部类是一个编译层面的概念,像一个语法糖一样,经过编译器之后其实内部类会提升为外部顶级类,和外部类没有任何区别,所以在JVM中是没有内部类的概念的

9. JDK8有哪些新特性?

9.1 回答重点

JDK8 较为重要和平日里经常被问的特性如下:

  1. 用元空间替代了永久代
  2. 引入了 Lambda 表达式
  3. 引入了日期类、接口默认方法、静态方法
  4. 新增 Stream 流式接口
  5. 引入 Optional 类
  6. 新增了 CompletableFuture 、StampedLock 等并发实现类。

如果你对 HashMap、ConcurrentHashMap 面试题有准备的话,这时候也可以抛出来,引导面试官来询问。比如:Java8修改了HashMap和 ConcurrentHashMap的实现。

9.2 扩展知识

9.2.1 元空间替代了永久代

因为JDK8要把JRockit虚拟机和Hotspot虚拟机融合,而JRockit没有永久代,所以把Hotspot永久代给去了(本质也是永久代回收效率太低)。

9.2.2 Lambda表达式

Lambda是Java8引入的一种匿名函数,可以把Lambda表达式理解为是一段可以传递的代码(将代码像数据一样进行传递)。使用它可以写出更简洁、更灵活的代码。

其本质是作为函数式接口的实例。例如:

// 传统方式
Runnable runnable1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("....");
    }
};

// Lambda 表达式
Runnable runnable2 = () -> System.out.println("...");

9.2.3 日期类

Java8引入了新的日期和时间API(位于 java.time 包中),它们更加简洁和易于使用,解决了旧版日期时间API的许多问题。

例如DateCalendar都是可变类且线程不安全。而新的日期类都是不可变的,一旦创建就不能修改,这样可以避免意外的修改,提升代码的安全性和可维护性。

LocalDate date = LocalDate.now();
LocalTime time = LocalTime.now();
LocalDateTime dateTime = LocalDateTime.now();

Date本身不包含时区信息,必须使用Calendar类来处理时区,但使用起来非常复杂且容易出错。

新API提供了专门的时区类(如ZonedDateTime, OffsetDateTime, ZoneId 等),简化了时区处理,并且这些类的方法更加直观和易用。

9.2.4 接口默认方法、静态

默认方法允许在接口中定义方法的默认实现,这样接口的实现类不需要再实现这些方法。之所以提供静态方法,是为了将相关的方法内聚在接口中,而不必创建新的对象。

interface MyInterface {
    default void defaultMethod() {
        System.out.println("Default Method");
    }

    static void hello() { 
        System.out.println("Hello, New Static Method Here"); 
    } 
}

9.2.5 Stream流式接口

Stream API提供了一种高效且易于使用的方式来处理数据集合。它支持链式操作、惰性求值和并行处理。

List<String> list = Arrays.asList("a", "b", "c", "d");
List<String> result = list.stream()
                          .filter(s -> s.startsWith("a"))
                          .collect(Collectors.toList());

9.2.6 Optional

Optional类用来解决可能出现的NullPointerException问题,提供了一种优雅的方式来处理可能为空的值。

Optional<String> optional = Optional.of("mianshiya.com");
optional.ifPresent(System.out::println);

9.2.7 CompletableFuture

CompletableFuture提供了一个新的异步编程模型,简化了异步任务的编写和管理。

CompletableFuture.supplyAsync(() -> "Hello")
                 .thenApply(s -> s + " World")
                 .thenAccept(System.out::println);

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

10.1 回答重点

它们都是 Java 中处理字符串的类,区别主要体现在可变性线程安全性性能上:

10.1.1 String

  • 不可变String是不可变类,字符串一旦创建,其内容无法更改。每次对String进行修改操作(如拼接、截取等),都会创建新的String对象。
  • 适合场景String适用于字符串内容不会频繁变化的场景,例如少量的字符串拼接操作或字符串常量。

10.1.2 StringBuffer

  • 可变StringBuffer是可变的,可以进行字符串的追加、删除、插入等操作。
  • 线程安全StringBuffer是线程安全的,内部使用了synchronized关键字来保证多线程环境下的安全性。
  • 适合场景StringBuffer适用于在多线程环境中需要频繁修改字符串的场景。

10.1.3 StringBuilder

  • 可变StringBuilder也是可变的,提供了与StringBuffer类似的操作接口。
  • 非线程安全StringBuilder不保证线程安全,性能比StringBuffer更高。
  • 适合场景StringBuilder适用于单线程环境中需要大量修改字符串的场景,如高频拼接操作。

10.1.4 总结

  • String:不可变,适合少量字符串操作。
  • StringBuffer:可变且线程安全,适合多线程环境中的频繁字符串修改。
  • StringBuilder:可变且非线程安全,适合单线程环境中的高性能字符串处理。

10.2 扩展知识

10.2.1 Java8中的优化

在 Java8及以后,编译器会对字符串的常量拼接做优化,将字符串拼接转换为StringBuilder操作。这种优化提高了代码性能,但是在动态拼接或多线程场景下,手动使用StringBuilderStringBuffer仍然更合适。

10.2.2 从演进角度看待三者

String是Java中基础且重要的类,并且String也是Immutable类的典型实现,被声明为final class,除了hash这个属性其它属性都声明为final

因为它的不可变性,所以例如拼接字符串时候会产生很多无用的中间对象,如果频繁的进行这样的操作对性能有所影响。

StringBuffer就是为了解决大量拼接字符串时产生很多中间对象问题而提供的一个类,提供appendinsert方法,可以将字符串添加到已有序列的末尾或指定位置。

它的本质是一个线程安全的可修改的字符序列,把所有修改数据的方法都加上了synchronized。但是保证了线程安全是需要性能的代价的。

在很多情况下我们的字符串拼接操作不需要线程安全,这时候StringBuilder登场了,StringBuilderJDK1.5发布的,它和StringBuffer本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销

StringBufferStringBuilder二者都继承了AbstractStringBuilder,底层都是利用可修改的char数组(JDK 9 以后是byte数组)。

所以如果我们有大量的字符串拼接,如果能预知大小的话最好在new StringBuffer或者StringBuilder的时候设置好capacity,避免多次扩容的开销(扩容要抛弃原有数组,还要进行数组拷贝创建新的数组)。

11.Java的StringBuilder是怎么实现的?

11.1 回答重点

StringBuilder主要是为了解决String对象的不可变性问题,提供高效动态的字符串拼接和修改操作。大致需要实现append、insert...等功能。

大致核心实现如下:

  • 内部使用字符数组 (char[] value) 来存储字符序列
  • 通过方法如append()、insert()等操作,直接修改内部的字符数组,而不会像String那样创建新的对象。
  • 每次进行字符串操作时,如果当前容量不足,它会通过扩展数组容量来容纳新的字符,按2倍的容量扩展,以减少扩展次数,提高性能。

11.2 扩展深入剖析StringBuilder

对于这类题目,因为已经有现有的实现作为参考,所以回答诸如此类的问题,不要急,先回想一下平日用这StringBuilder都用了哪些方法。

  • append
  • insert
  • delete
  • replace
  • charAt
  • ....

大致就这么几个,没必要说太全,这不是小学课文背诵,关键方法提出来就行了。

脑子浮现这几个方法之后,直接按上述的回答重点说出来即可。

实际上StringBuilder底层使用char数组来存储字符,并且用count来记录存放的字符数。

StringBuilder继承了AbstractStringBuilder这个类

回答重点提到了char数组,这里可能会被面试官插入问:String底层不也是用的char数组存放吗?两者有啥区别?

String中的char数组

展示的机会就来了呀!String被final修饰,且内部的char也被private和final修饰了,所以是不可变的,是典型的Immutable类,因此其不可变性,保证了线程安全,能实现字符串常量池等。

由于StringBuilder底层是用char数组存放字符,而数组是连续内存结构,为了防止频繁地复制和申请内存,需要提供capacity参数来设置初始化数组的大小,这样在预先已经知晓大字符串的情况下,可以减少数组的扩容次数,有效的提升效率!

StringBuilder的初始容量

这里一定要点破:数组是连续内存的结构,并且要体现出你有节省内存和提高效率的意识,熟悉HashMap的同学对这类操作应该很有经验。

我们来看下调用AbstractStringBuilder这个父类的构造器。

AbstractStringBuilder指定容量的构造器

可以看到,就是直接new申请数组没啥花头。

接下来我们来看看append操作。

在AbstractStringBuilder中append的实现

可以看到append有多个实现,毕竟我们平日不管啥类型都直接append,那底层是怎么实现这些类型转换的呢?

我们拿append(int)来举个例子,其他类型本质都是一样的。

append原理

主要逻辑已经在图中标识了,熟悉HashMap八股文的同学一看就知道老套路了,先看看append的in值转成char需要占数组的几位,然后计算一下现在的数组够不够放,如果不够就扩容一下,然后再把int转成char放进去,再更新现有的字符数。

所以面试回答append实现的时候,直接把上面那段话的思路说一下即可。

面试官可能会追问:怎么扩容的呀?

我们直接看下ensureCapacityInternal这个方法的实现:

ensureCapacityInternal实现原理

直接就是Arrays.copyOf,进行一波扩容加拷贝,扩容之后的数组容量为之前的两倍+2。

这时候想必有很多同学好奇,前面是如何根据传入的int来计算得知所占的字符位数?即上面代码的Integer.stringSize 方法,注意这个方法已经跑到 Integer这个类中啦!不是AbstractStringBuilder的实现了。

Integer.stringSize的实现

哈哈,你以为会经过一番看不懂的位运算?

实际上就是查表法!直接列了各个位数的边界值依次存放在数组中,然后判断大小再根据数组下标算出位数,就是这么简单、方便、高效!

再来看下int是如何转换成char然后插入到数组中的,即Integer.getChars方法:

Integer.getChars实现

身为底层实现,还是很细的,可以仔细看下上面的逻辑,位运算看不懂没事,注释已经把原有的公式写出来的,对照着看看,还是能理解的,这里我就不再赘述了。

然后各位也应该注意到上面的DigitOnes、DigitTens这两个数组了,没错还是熟悉的查表法!

DigitOnes、DigitTens两个数组

你们可以选几个数字带入算一算,很准的,哈哈,至于digits也一样,还是查表。

图片其实我们常用的String.valueOf(int i),内部实现一样也是通过Integer.stringSizeInteger.getChars 来完成的:

digits

12. Java中包装类型和基本类型的区别是什么?

12.1 回答重点:

基本类型:Java中有8种基本数据类型(intlongfloatdoublecharbytebooleanshort),它们是直接存储数值的变量,位于栈上(局部变量在栈上、成员变量在堆上、静态(类)字段在方法区),性能较高,且不支持 null

包装类型:每个基本类型都有一个对应的包装类型(IntegerLongFloatDoubleCharacterByteBooleanShort)。包装类型是类,存储在堆中,可以用于面向对象编程,并且支持 null

12.1.1 区别总结

性能区别

  • 基本类型:占用内存小,效率高,适合频繁使用的简单操作。
  • 包装类型:因为是对象,涉及内存分配和垃圾回收,性能相对较低。

比较方式不同

  • 基本类型:比较用==,直接比较数值。
  • 包装类型:比较时,==比较的是对象的内存地址,而equals()比较的是对象的值。

默认值不同

  • 基本类型:默认值是0,false等。
  • 包装类型:默认为null。

初始化的方式不同

  • 基本类型:直接赋值。
  • 包装类型:需要采用new的方式创建。

存储方式不同

  • 基本类型:如果是局部变量则保存在栈上面,如果是成员变量则在堆中。
  • 包装类型:保存在堆上(成员变量,在不考虑JIT优化的栈上分配时,都是随着对象一起保存在堆上的)。

12.2 扩展知识

12.2.1 自动装箱与拆箱

因为Java是一种面向对象语言,很多地方都需要使用对象而不是基本数据类型。比如,在集合类中,我们是无法将int 、double等类型放进去的。因为集合的容器要求元素是Object类型。

为了让基本类型也具有对象的特征,就出现了包装类型,它相当于将基本类型“包装起来”,使得它具有了对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作。

  • 装箱:基本类型自动转换为包装类型对象。
  • 拆箱:包装类型对象自动转换为基本类型的值。

12.2.2 缓存机制:

包装类型中的ByteShortIntegerLong对某些范围内的值(例如Integer缓存 -128到127)会使用对象缓存来提升性能。因此,同一数值的包装类型对象可能是同一个实例。

例如:

Integer a = 100;
Integer b = 100;
System.out.println(a == b);  // true
Integer c = 200;
Integer d = 200;
System.out.println(c == d);  // false

12.2.3 基础类型与包装类长度和范围

分类 基本数据类型 包装类 长度 表示范围
布尔型 boolean Boolean / /
byte Byte 1 字节 -128 ~ 127
short Short 2 字节 -32768 ~ 32767
整型 int Integer 4 字节 -2,147,483,648 ~ 2,147,483,647
long Long 8 字节 -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
字符型 char Character 2 字节 Unicode 字符集中的任何字符
浮点型 float Float 4 字节 约 -3.4E38 ~ 3.4E38
double Double 8 字节 约 -1.7E308 ~ 1.7E308

13. 接口和抽象类有什么区别?

13.1 回答重点

接口和抽象类在设计动机上有所不同。

接口的设计是自上而下的。我们知晓某一行为,于是基于这些行为约束定义了接口,一些类需要有这些行为,因此实现对应的接口。

抽象类的设计是自下而上的。我们写了很多类,发现它们之间有共性,有很多代码可以复用,因此将公共逻辑封装成一个抽象类,减少代码冗余。

所谓的自上而下指的是先约定接口,再实现。而自下而上的是先有一些类,才抽象了共同父类(可能和学校教的不太一样,但是实战中很多时候都是因为重构才有的抽象)。

13.1.1 其他区别

  1. 方法实现

    接口中的方法默认是publicabstract(但在 Java8之后可以设置default方法或者静态方法)。

    抽象类可以包含abstract方法(没有实现)和具体方法(有实现)。它允许子类继承并重用抽象类中的方法实现。

  2. 构造函数和成员变量

    接口不能包含构造函数,接口中的成员变量默认为public static final,即常量。

    抽象类可以包含构造函数,成员变量可以有不同的访问修饰符(如 private、protected、public),并且可以不是常量。

  3. 多继承

    抽象类只能单继承,接口可以有多个实现。

为什么 Java 不支持多重继承?

13.2 扩展知识

13.2.1 接口的演变

  • Java8:引入了default和static方法,使得接口不仅仅是方法的声明,还可以提供具体的实现。default方法允许在接口中添加新的方法实现,而不影响已经实现该接口的类。
  • Java9:引入了私有方法,允许在接口中定义私有方法,用于default方法的内部逻辑复用。
  • Java14:引入了sealed接口(仅在某些子类中使用),进一步增强了接口的功能

14. JDK和JRE有什么区别?

14.1 回答重点

JRE(Java Runtime Environment)指的是Java运行环境,包含了JVM、核心类库和其他支持运行Java程序的文件。

  • JVM(Java Virtual Machine):执行Java字节码,提供了Java程序的运行环境。
  • 核心类库:一组标准的类库(如java.lang、java.util等),供Java程序使用。
  • 其他文件:如配置文件、库文件等,支持JVM的运行。

JDK(Java Development Kit)可以视为JRE的超集,是用于开发Java程序的完整开发环境,它包含了 JRE,以及用于开发、调试和监控Java应用程序的工具。

  • JRE:JDK包含了完整的JRE,因此它也能运行Java程序。
  • 开发工具:如编译器(javac)、调试器(jdb)、打包工具(jar)等,用于开发和管理Java程序。
  • 附加库和文件:支持开发、文档生成和其他开发相关的任务。

14.2 扩展知识

列举一下JDK提供的主要工具:

  • javac:Java编译器,用于将Java源代码(.java文件)编译成字节码(.class文件)。
  • java:Java应用程序启动器,用于运行Java应用程序。
  • javadoc:文档生成器,用于从Java源代码中提取注释并生成HTML格式的API文档。
  • jar:归档工具,用于创建和管理JAR(Java ARchive)文件。
  • jdb:Java调试器,用于调试Java程序。
  • jps:Java进程状态工具,用于列出当前所有的Java进程。
  • jstat:JVM统计监视工具,用于监视JVM统计信息。
  • jstatd:JVM统计监视守护进程,用于在远程监视JVM统计信息。
  • jmap:内存映射工具,用于生成堆转储(heap dump)、查看内存使用情况。
  • jhat:堆分析工具,用于分析堆转储文件。
  • jstack:线程栈追踪工具,用于打印Java 线程的栈追踪信息。
  • javap:类文件反汇编器,用于反汇编和查看Java类文件。
  • jdeps:Java类依赖分析工具,用于分析类文件或JAR文件的依赖关系。

15. 你使用过哪些JDK提供的工具?

15.1 回答重点

JDK 提供的主要工具

  • javac:Java编译器,负责将Java源代码编译成字节码(.class 文件)。
  • java:运行Java应用程序的命令,使用JVM来解释并执行编译后的字节码文件。
  • javadoc:生成API文档的工具,能够根据源代码中的注释生成HTML格式的文档。
  • jar:用于创建和管理JAR文件的工具,可以将多个.class文件打包为单一文件,便于分发和管理。
  • jdb:Java调试工具,用于在命令行中调试Java应用程序,支持断点设置、变量查看等功能。

性能监控和分析工具

  • jps:Java进程工具,显示所有正在运行的Java进程,便于监控和诊断。
  • jstack:生成线程堆栈信息的工具,常用于分析死锁和线程问题。
  • jmap:内存映射工具,可以生成堆转储(heap dump)文件,便于内存泄漏分析和垃圾回收优化。
  • jhat:堆分析工具,配合jmap使用,分析生成的堆转储文件,帮助开发者了解内存使用情况。
  • jstat:JVM统计监控工具,实时监控垃圾回收、内存、类加载等信息,帮助开发者调优JVM性能。
  • jconsole:图形化的JVM监控工具,可以监控应用程序的内存、线程和类加载情况,常用于监控和调试。
  • jvisualvm:功能强大的性能分析工具,支持堆、线程、GC的详细监控,还提供内存分析和CPU性能分析。

诊断工具

  • jinfo:用于查看和修改正在运行的JVM参数,便于动态调优和调整JVM行为。
  • jstatd:远程JVM监控工具,可以通过网络远程监控JVM的状态,适合分布式系统中的性能监控。

15.2 扩展知识

15.2.1 高级调试和性能优化工具

  • Java Mission Control (JMC):一个功能强大的工具,用于分析和优化Java应用程序的性能,提供了基于飞行记录器(Java Flight Recorder,JFR)的性能分析功能,可以详细查看垃圾回收、线程活动、CPU使用率等指标,是进行深度性能分析的利器。
  • Java Flight Recorder (JFR):低开销的监控工具,能够记录JVM的运行时数据,适合生产环境中的性能分析,尤其是在高并发系统中使用频率较高。

15.2.2 GC调优和内存分析

jmap和jhat常用于排查内存泄漏或内存占用过高的问题。通过jmap生成堆转储文件后,开发者可以使用jhat或其他工具(如 Eclipse MAT)分析对象的引用链,从而发现潜在的内存问题。

15.2.3 线程和死锁分析

jstack是用于调试线程状态的利器,尤其是在分析线程死锁时。当应用卡死或响应时间异常时,通过jstack可以捕获应用的线程状态,并分析是否存在死锁情况。

15.2.4 建议

虽然面试这样答可能就差不多了,但还是希望大家可以自己找机会用用,没机会就自己给自己创造机会。

因为这属于线上排查能力,只有真正实践了,到时候自己负责的项目真的出了问题,才不至于手忙脚乱。我们毕竟是工程师,问题解决能力必须掌握。

总结