我将从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<String> {
@Override
public String call() throws Exception {
return "Callable task completed";
}
}
public class Main {
public static void main(String[] args) {
MyCallable myCallable = new MyCallable();
FutureTask<String> 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<String, Integer> 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<Integer> 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 使用示例:```java
import 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