我将从Java基础、面向对象、多线程与并发等多个方面,为你详细阐述常见面试题及答案,并结合应用实例,帮助你更好地学习和理解。

史上最全的Java面试题独家整理(带答案)

一、Java基础

1. Java中基本数据类型有哪些?

Java有8种基本数据类型,分为4类:

整数类型:byte(1字节)、short(2字节)、int(4字节)、long(8字节)。

浮点类型:float(4字节)、double(8字节)。

字符类型:char(2字节)。

布尔类型:boolean(理论上1位,实际实现中通常是1字节)。

2. 请解释一下自动装箱和拆箱

自动装箱:是指将基本数据类型自动转换为对应的包装类对象。例如,int i = 10; Integer integer = i; 这里int类型的i自动装箱成了Integer类型的integer。

自动拆箱:是指将包装类对象自动转换为对应的基本数据类型。例如,Integer integer = 20; int j = integer; 这里Integer类型的integer自动拆箱成了int类型的j。

3. 简述String、StringBuilder和StringBuffer的区别

可变性:

String是不可变的,一旦创建,其值不能被修改。每次对String进行操作都会创建一个新的String对象。例如,String s = "hello"; s = s + "world"; 在执行s = s + "world";时,会创建一个新的String对象,内容为"helloworld",原来的"hello"对象依然存在于内存中,只是String类型的变量s指向了新的对象。

StringBuilder和StringBuffer是可变的,它们可以在原对象上进行修改,不会创建新对象。例如,StringBuilder sb = new StringBuilder("hello"); sb.append("world"); 这里sb对象本身被修改,其内容变为"helloworld",而不是创建一个新的对象。

线程安全性:

String是线程安全的,因为它是不可变的,多个线程同时访问同一个String对象时,不会出现数据不一致的问题。

StringBuffer是线程安全的,它的方法都使用了synchronized关键字进行同步。例如,StringBuffer sb = new StringBuffer(); sb.append("a"); 当多个线程同时调用sb.append("a");时,由于synchronized的存在,同一时间只有一个线程能够执行该方法,保证了线程安全。

StringBuilder是非线程安全的,但其性能比StringBuffer高,因为不需要进行同步操作。在单线程环境下,使用StringBuilder进行字符串操作可以获得更好的性能。例如,在一个只在单线程中执行的方法中,频繁进行字符串拼接操作,使用StringBuilder会比StringBuffer更高效。

应用场景:

如果字符串操作较少,使用String。例如,只是定义一个固定的字符串常量,如String message = "This is a message";

如果是单线程环境下进行大量字符串拼接,使用StringBuilder。比如在一个单线程的日志记录方法中,需要不断拼接日志信息,使用StringBuilder能提高效率。

如果是多线程环境下进行大量字符串拼接,使用StringBuffer。例如在一个多线程的网络通信模块中,多个线程需要向同一个字符串缓冲区中追加数据,就需要使用StringBuffer来保证线程安全。

二、面向对象

1. 什么是面向对象编程的三大特性?

面向对象编程的三大特性是封装、继承和多态。

封装:是指将数据和操作数据的方法绑定在一起,隐藏对象的内部实现细节,只对外提供必要的接口。这样可以提高代码的安全性和可维护性。例如,一个银行账户类BankAccount,将账户余额balance这个数据以及存款deposit、取款withdraw等操作方法封装在类中,外部代码只能通过调用这些公开的方法来操作账户余额,而不能直接访问和修改balance属性,保证了数据的安全性。

继承:是指一个类可以继承另一个类的属性和方法,被继承的类称为父类(基类),继承的类称为子类(派生类)。继承可以实现代码的复用和扩展。例如,有一个父类Animal,具有eat方法,子类Dog继承自Animal,那么Dog类就自动拥有了eat方法,同时还可以根据Dog类的特点添加自己特有的方法,如bark,这就是对父类功能的扩展。

多态:是指同一个方法调用可以根据对象的不同类型表现出不同的行为。多态通过继承、接口和方法重写来实现。例如,父类Shape有一个draw方法,子类Circle和Rectangle都继承自Shape并重写了draw方法。当创建Circle和Rectangle的对象并调用draw方法时,会根据对象的实际类型(Circle或Rectangle)调用各自重写后的draw方法,从而表现出不同的绘制行为。

2. 请解释一下方法重载和方法重写

方法重载(Overloading):是指在同一个类中,多个方法可以有相同的方法名,但参数列表不同(参数的类型、个数或顺序不同)。例如,在一个Calculator类中,可以定义多个add方法:

public class Calculator {

public int add(int a, int b) {

return a + b;

}

public double add(double a, double b) {

return a + b;

}

public int add(int a, int b, int c) {

return a + b + c;

}

}

这里定义了三个add方法,分别接收不同类型和个数的参数,这就是方法重载。调用时,编译器会根据传入参数的实际情况来决定调用哪个add方法。

方法重写(Overriding):是指子类重写父类中具有相同方法名、参数列表和返回值类型的方法。重写时,子类的方法访问权限不能低于父类的方法,抛出的异常范围不能比父类大。例如:

class Animal {

public void makeSound() {

System.out.println("Animal makes a sound");

}

}

class Dog extends Animal {

@Override

public void makeSound() {

System.out.println("Dog barks");

}

}

在这个例子中,Dog类重写了Animal类的makeSound方法,当创建Dog类的对象并调用makeSound方法时,会执行Dog类中重写后的方法,输出"Dog barks"。

三、多线程与并发

1. 如何创建一个线程?

在Java中创建线程有三种方式:

继承Thread类:

class MyThread extends Thread {

@Override

public void run() {

System.out.println("Thread is running");

}

}

public class Main {

public static void main(String[] args) {

MyThread myThread = new MyThread();

myThread.start();

}

}

在这个例子中,定义了一个继承自Thread类的MyThread类,重写了run方法,然后在main方法中创建MyThread类的对象并调用start方法启动线程。

实现Runnable接口:

class MyRunnable implements Runnable {

@Override

public void run() {

System.out.println("Runnable is running");

}

}

public class Main {

public static void main(String[] args) {

MyRunnable myRunnable = new MyRunnable();

Thread thread = new Thread(myRunnable);

thread.start();

}

}

这里定义了一个实现Runnable接口的MyRunnable类,实现了run方法。在main方法中,创建MyRunnable类的对象,并将其作为参数传递给Thread类的构造函数来创建线程,最后调用start方法启动线程。

实现Callable接口:

import java.util.concurrent.Callable;

import java.util.concurrent.ExecutionException;

import java.util.concurrent.FutureTask;

class MyCallable implements Callable {

@Override

public String call() throws Exception {

return "Callable task completed";

}

}

public class Main {

public static void main(String[] args) {

MyCallable myCallable = new MyCallable();

FutureTask futureTask = new FutureTask<>(myCallable);

Thread thread = new Thread(futureTask);

thread.start();

try {

String result = futureTask.get();

System.out.println(result);

} catch (InterruptedException | ExecutionException e) {

e.printStackTrace();

}

}

}

此方式定义了一个实现Callable接口的MyCallable类,实现了call方法,该方法可以有返回值并且可以抛出异常。在main方法中,创建MyCallable类的对象,将其封装在FutureTask中,再通过FutureTask创建线程并启动。最后通过futureTask.get()方法获取call方法的返回值。

2. 什么是线程安全问题?

当多个线程同时访问共享资源时,可能会导致数据不一致、脏读、幻读等问题,这就是线程安全问题。例如,假设有一个银行账户类BankAccount,其中有一个余额属性balance和一个取款方法withdraw:

public class BankAccount {

private int balance = 1000;

public void withdraw(int amount) {

if (balance >= amount) {

try {

Thread.sleep(100);

} catch (InterruptedException e) {

e.printStackTrace();

}

balance = balance - amount;

}

}

public int getBalance() {

return balance;

}

}

现在有两个线程同时调用withdraw方法进行取款操作:

public class Main {

public static void main(String[] args) {

BankAccount account = new BankAccount();

Thread thread1 = new Thread(() -> {

account.withdraw(500);

});

Thread thread2 = new Thread(() -> {

account.withdraw(300);

});

thread1.start();

thread2.start();

try {

thread1.join();

thread2.join();

} catch (InterruptedException e) {

e.printStackTrace();

}

System.out.println("Final balance: " + account.getBalance());

}

}

由于withdraw方法中存在Thread.sleep(100);模拟业务逻辑处理时间,当两个线程同时执行到if (balance >= amount);判断时,都可能认为余额足够,然后分别进行取款操作,导致最终余额出现错误。这就是典型的线程安全问题。

3. 解决线程安全问题的方法

使用synchronized关键字:可以修饰方法或代码块,保证同一时间只有一个线程可以访问被修饰的资源。例如,将上述BankAccount类的withdraw方法修改为:

public class BankAccount {

private int balance = 1000;

public synchronized void withdraw(int amount) {

if (balance >= amount) {

try {

Thread.sleep(100);

} catch (InterruptedException e) {

e.printStackTrace();

}

balance = balance - amount;

}

}

public int getBalance() {

return balance;

}

}

此时,当一个线程进入withdraw方法时,其他线程必须等待该线程执行完毕才能进入,从而保证了线程安全。

使用Lock接口:如ReentrantLock,它提供了更灵活的锁机制。例如:

import java.util.concurrent.locks.Lock;

import java.util.concurrent.locks.ReentrantLock;

public class BankAccount {

private int balance = 1000;

private Lock lock = new ReentrantLock();

public void withdraw(int amount) {

lock.lock();

try {

if (balance >= amount) {

try {

Thread.sleep(100);

} catch (InterruptedException e) {

e.printStackTrace();

}

balance = balance - amount;

}

} finally {

lock.unlock();

}

}

public int getBalance() {

return balance;

}

}

在这个例子中,通过ReentrantLock的lock和unlock方法来手动控制锁的获取和释放,在try - finally块中释放锁,确保即使出现异常也能正确释放锁,避免死锁。

使用并发集合:如ConcurrentHashMap、CopyOnWriteArrayList等,这些集合在设计上已经考虑了线程安全问题。例如,使用ConcurrentHashMap来存储数据:

import java.util.concurrent.ConcurrentHashMap;

public class Main {

public static void main(String[] args) {

ConcurrentHashMap map = new ConcurrentHashMap<>();

Thread thread1 = new Thread(() -> {

map.put("key1", 1);

});

Thread thread2 = new Thread(() -> {

map.put("key2", 2);

});

thread1.start();

thread2.start();

try {

thread1.join();

thread2.join();

} catch (InterruptedException e) {

e.printStackTrace();

}

System.out.println(map);

}

}

ConcurrentHashMap允许多个线程同时进行读操作,并且在写操作时采用了分段锁等机制,大大提高了并发性能,同时保证了线程安全。

四、集合框架

1. 简述ArrayList和LinkedList的区别

数据结构:

ArrayList是基于动态数组实现的,它可以随机访问元素,通过索引可以快速定位元素。例如,ArrayList list = new ArrayList<>(); list.add(1); list.add(2); int value = list.get(1); 这里通过list.get(1);可以直接获取索引为1的元素,时间复杂度为O(1)。

LinkedList是基于双向链表实现的,每个节点包含数据和指向前一个节点和后一个节点的引用。例如,在LinkedList中插入一个元素时,只需要修改相关节点的引用即可,对于在链表中间插入元素的操作,时间复杂度为O(1)。

性能:

随机访问:ArrayList的随机访问性能更好,时间复杂度为O(1)。因为它可以通过数组的索引直接定位到元素在内存中的位置。

插入和删除:LinkedList在插入和删除元素时性能更好,尤其是在列表中间插入或删除元素,时间复杂度为O(1)。而ArrayList在插入和删除元素时需要移动元素,时间复杂度为O(n)。例如,在ArrayList的中间位置插入一个元素,需要将插入位置后面的所有元素向后移动一位。

内存占用:

ArrayList会预先分配一定的内存空间,可能会造成内存浪费。如果预先分配的空间过大,而实际存储的元素较少,就会有一部分内存没有被充分利用。

LinkedList每个节点需要额外的引用,会占用更多的内存。因为每个节点除了存储数据外,还需要存储指向前一个节点和后一个节点的引用。

2. 请解释一下HashMap的工作原理

HashMap是基于哈希表实现的,它通过key的hashCode()方法计算哈希值,然后根据哈希值找到对应的桶(数组的索引位置)。

存储过程:当添加一个键值对时,首先计算key的哈希值,例如int hash = key.hashCode(); 然后通过哈希值与数组长度进行取模运算得到桶的索引位置,即int index = hash % table.length; 如果该桶为空,直接将键值对封装成一个Entry对象存储在该桶中。如果该桶不为空,就发生了哈希冲突。在JDK 1.7及之前,采用链表法来解决冲突,即新的键值对会被添加到链表的头部(后插入的元素在链表头部)。在JDK 8及以后,当链表长度达到8且数组长度达到64时,链表会转换为红黑树,以提高查找效率。例如:

2. 请解释一下HashMap的工作原理

HashMap 是一种常用的数据结构,用于存储键值对(key-value pairs),并能高效地根据键(key)进行查找、插入和删除操作。下面详细解释其工作原理:

基本结构:数组 + 链表 / 红黑树HashMap 本质上是一个 哈希表(Hash Table),其核心结构是一个 数组,每个数组元素称为一个 桶(bucket) 或 槽(slot)。当发生哈希冲突时,桶内会以 链表 或 红黑树 的形式存储多个元素。

JDK 8 及以后:当链表长度超过阈值(默认 8)且数组长度 ≥ 64 时,链表会转换为红黑树,以提高查找效率(时间复杂度从 O (n) 降为 O (log n))。

哈希函数与索引计算HashMap 使用键的 hashCode() 方法计算哈希值,然后通过 哈希值与数组长度取模 确定元素在数组中的位置:

index = (n - 1) & hash // 等效于 hash % n,但位运算效率更高

哈希冲突的处理当两个不同的键计算出相同的索引时,就会发生 哈希冲突。HashMap 使用 链地址法(Chaining) 解决冲突:

链表:冲突的元素会被添加到对应桶的链表中。红黑树:当链表长度超过阈值(默认 8)且数组长度 ≥ 64 时,链表会转换为红黑树,以优化查找性能。

插入与查找流程插入(put)流程:计算键的哈希值,确定数组索引。若桶为空,直接插入新节点。若桶不为空:若为链表,遍历链表,找到相同键则覆盖值,否则插入尾部。若为红黑树,调用树的插入方法。插入后检查链表长度是否超过阈值(8),若是则转换为红黑树。检查元素总数是否超过扩容阈值(容量 × 负载因子),若是则扩容。查找(get)流程:计算键的哈希值,确定数组索引。若桶为空,返回 null。若为链表,遍历链表查找键。若为红黑树,调用树的查找方法。

扩容机制当 HashMap 中的元素数量超过 扩容阈值(threshold) 时,会触发扩容:

扩容阈值 = 数组容量 × 负载因子(默认 0.75)。扩容步骤:创建一个新数组,容量为原数组的 2 倍。重新计算每个元素的哈希值和索引,将所有元素迁移到新数组中。容量要求:数组长度始终为 2 的幂次方,确保 (n - 1) & hash 等效于取模运算。

关键特性无序性:元素的存储顺序与插入顺序无关。允许 null 键和 null 值:null 键始终存储在数组的第一个位置(索引 0)。非线程安全:多线程环境下需使用 ConcurrentHashMap 或通过 Collections.synchronizedMap 包装。初始容量与负载因子:初始容量(默认 16):创建 HashMap 时的数组大小。负载因子(默认 0.75):控制扩容的时机,过小会频繁扩容,过大会增加哈希冲突。示例代码以下是一个简单的 HashMap 使用示例:

```javaimport java.util.HashMap;

public class HashMapExample {

public static void main(String[] args) {

// 创建 HashMap,键为 String,值为 Integer HashMap map = new HashMap<>();

// 插入元素

map.put("apple", 1);

map.put("banana", 2);

map.put(null, 0); // 允许 null 键

// 查找元素

System.out.println(map.get("apple")); // 输出: 1

System.out.println(map.get(null)); // 输出: 0

// 遍历元素

for (String key : map.keySet()) {

System.out.println(key + ": " + map.get(key));

}

}

}```总结HashMap 通过哈希函数将键映射到数组索引,并使用链表或红黑树处理冲突,从而实现高效的插入、查找和删除操作。其核心优势在于 平均时间复杂度为 O (1),但需注意哈希冲突和扩容对性能的影响。Java 面试题,2025 面试题,Java 基础面试题,Java 多线程面试题,JVM 面试题,Spring 面试

代码获取方式https://pan.quark.cn/s/14fcf913bae6