一、异常(Exceptions)
1. Java 异常体系结构是怎样的?Error 和 Exception 有什么区别?
Java 异常体系的核心是 Throwable 类,它有两个主要子类:Error 和 Exception。
-
Throwable:所有异常和错误的超类。 -
Error:表示程序在运行时发生了不可恢复的严重问题,例如OutOfMemoryError、StackOverflowError。这类错误通常不由程序来处理,而是由 JVM 自身或系统问题引起。 -
Exception:表示程序在运行时发生的可恢复问题,通常是由于程序逻辑错误或外部因素(如文件找不到、网络中断)引起的。Exception又分为:- Checked Exception(受检查异常) :在编译时就强制要求处理的异常,例如
IOException、SQLException。如果方法中可能抛出这类异常,要么用try-catch捕获,要么用throws声明。 - Unchecked Exception(非受检查异常/运行时异常) :在编译时不会强制检查,但在运行时才可能发生的异常,例如
NullPointerException、ArrayIndexOutOfBoundsException。这类异常通常是由于程序逻辑错误引起的,建议通过改进代码逻辑来避免,而不是频繁捕获。
- Checked Exception(受检查异常) :在编译时就强制要求处理的异常,例如
2. try-catch-finally 中,finally 块什么时候会执行?有什么作用?
finally 块无论 try 块中是否发生异常,也无论 catch 块是否捕获了异常,都会被执行。
作用: finally 块通常用于执行资源清理操作,例如关闭文件流、数据库连接、网络连接等。这能确保即使在异常情况下,关键资源也能被正确释放,避免资源泄露。
特殊情况: 只有在极少数情况下 finally 块不会执行,比如:
- 在
try或catch块中执行了System.exit(0)强制退出 JVM。 - 在
try或catch块中 JVM 崩溃(如OutOfMemoryError)。
3. throw 和 throws 有什么区别?
-
throw:- 用于在方法内部手动抛出一个异常对象。
- 后面跟的是一个异常实例。
- 语法:
throw new MyException("错误信息");
-
throws:- 用于在方法签名中声明该方法可能抛出的异常类型。
- 后面跟的是异常类名,多个异常类用逗号分隔。
- 表示该方法不处理这些异常,而是将它们抛给调用者处理。
- 主要用于声明 Checked Exception。
- 语法:
public void readFile() throws IOException, FileNotFoundException { ... }
4. 说说自定义异常的步骤。
-
继承
Exception或其子类(通常是RuntimeException) :- 如果希望自定义的异常是受检查异常,继承
Exception。 - 如果希望是运行时异常,继承
RuntimeException。
- 如果希望自定义的异常是受检查异常,继承
-
提供构造方法:通常提供一个无参构造方法和带一个字符串参数(表示异常信息)的构造方法。
Java
public class MyCustomException extends Exception { public MyCustomException() { super(); } public MyCustomException(String message) { super(message); } public MyCustomException(String message, Throwable cause) { super(message, cause); } public MyCustomException(Throwable cause) { super(cause); } }
二、I/O(Input/Output)
1. Java I/O 流的分类有哪些?各自的特点是什么?
Java I/O 流主要根据数据类型和流向进行分类:
按数据类型分:
-
字节流(Byte Stream) :
- 以字节为单位读写数据,适用于处理任何类型的文件(如图片、视频、文本等)。
- 基类是
InputStream(输入) 和OutputStream(输出)。 - 常见的实现类有
FileInputStream/FileOutputStream、BufferedInputStream/BufferedOutputStream。
-
字符流(Character Stream) :
- 以字符为单位读写数据,专门用于处理文本数据。字符流会自动处理字符编码转换。
- 基类是
Reader(输入) 和Writer(输出)。 - 常见的实现类有
FileReader/FileWriter、BufferedReader/BufferedWriter、InputStreamReader/OutputStreamWriter。
按流向分:
- 输入流(Input Stream/Reader) :从数据源读取数据到程序。
- 输出流(Output Stream/Writer) :将程序中的数据写入到数据目的地。
2. 字节流 和 字符流 有什么区别和联系?什么时候用哪个?
区别:
- 处理单位: 字节流以字节为单位,字符流以字符为单位。
- 编码: 字符流在读写时会自动进行字符编码和解码(例如,将字节转换为字符,或将字符转换为字节),而字节流不会。
- 适用范围: 字节流适用于所有文件类型(二进制和文本),字符流只适用于文本文件。
联系:
- 字符流是基于字节流构建的。例如,
InputStreamReader是将字节输入流转换为字符输入流的桥梁,OutputStreamWriter是将字符输出流转换为字节输出流的桥梁。
选择:
- 处理文本文件时,优先使用字符流,因为它能更好地处理字符编码,避免乱码。
- 处理**非文本文件(二进制文件)**时,必须使用字节流,例如图片、视频、音频等。
3. 什么是缓冲流?使用缓冲流有什么好处?
缓冲流(Buffered Stream) 是在基本 I/O 流之上包装的一层流,它内部维护一个缓冲区(一个字节或字符数组)。
- 字节缓冲流:
BufferedInputStream和BufferedOutputStream。 - 字符缓冲流:
BufferedReader和BufferedWriter。
好处:
- 提高读写效率: 缓冲流会一次性从磁盘读取/写入一块数据到缓冲区,而不是每次读写一个字节/字符,这大大减少了实际的磁盘 I/O 操作次数,从而显著提高读写性能。当缓冲区满或调用
flush()方法时,数据才会真正写入或读取。 - 提供额外功能: 例如
BufferedReader提供了readLine()方法,可以按行读取文本,非常方便。
4. Serializable 接口有什么作用?
Serializable 是一个标记接口(不包含任何方法)。当一个类实现了 Serializable 接口时,表示该类的对象可以被序列化(Serialization) 。
作用:
- 对象持久化: 将对象转换为字节序列,以便存储到文件或数据库中。
- 网络传输: 将对象通过网络传输到另一台机器。
- 进程间通信: 在不同进程之间传递对象。
注意事项:
transient关键字: 被transient关键字修饰的字段在对象序列化时会被忽略。static字段:static字段属于类,不属于对象实例,因此也不会被序列化。- 版本兼容:
serialVersionUID用于控制序列化和反序列化的版本兼容性。
三、集合(Collections)
1. Java 集合框架的顶层接口有哪些?它们之间有什么关系?
Java 集合框架的顶层接口主要有三个:
Collection:表示一组不重复的元素(通常无序)。它是所有单列集合的根接口,包括List、Set、Queue。List:继承自Collection,表示有序的集合,元素可以重复。Set:继承自Collection,表示无序的集合,元素不允许重复。Map:表示键值对(key-value)的集合,键不允许重复,值可以重复。Map接口不继承自Collection接口,它是独立存在的。
关系图:
Iterable
|
Collection
/ | \
List Set Queue
Map
2. ArrayList 和 LinkedList 有什么区别?
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 底层实现 | 基于动态数组 | 基于双向链表 |
| 随机访问 | O(1) ,通过索引直接访问,效率高 | O(n) ,需要从头或尾遍历查找,效率低 |
| 插入/删除 | O(n) ,需要移动大量元素,效率低 | O(1) ,只需要修改相邻节点的指针,效率高 |
| 内存开销 | 连续内存,但可能需要扩容,涉及数组复制 | 不连续内存,每个节点需要额外的空间存储前后指针 |
| 适用场景 | 频繁随机访问元素,但插入/删除操作较少 | 频繁插入/删除元素,但随机访问较少 |
3. HashMap 和 Hashtable 有什么区别?
| 特性 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 非线程安全 | 线程安全 (通过 synchronized 实现) |
| null 键值 | 允许 null 键和 null 值 | 不允许 null 键和 null 值 |
| 性能 | 性能较高,因为没有同步开销 | 性能较低,因为所有方法都加锁,并发性能差 |
| 初始容量/扩容 | 初始容量 16,扩容因子 0.75,扩容为原容量的 2 倍 | 初始容量 11,扩容因子 0.75,扩容为原容量的 2 倍 + 1 |
| 父类 | 继承 AbstractMap | 继承 Dictionary |
| 推荐 | 绝大多数情况下推荐使用 HashMap | 较少使用,在多线程环境下可使用 ConcurrentHashMap 替代 |
4. HashMap 的底层实现原理是什么?(Java 8 之后)
HashMap 在 Java 8 之后底层实现是数组 + 链表 + 红黑树。
- 数组(
Node[] table) :HashMap的主干是一个Node数组,每个Node存储键值对、哈希值和下一个节点的引用。 - 链表:当不同的键通过哈希函数计算出相同的索引(哈希冲突)时,这些键值对会以链表的形式存储在该索引位置。
- 红黑树:当某个链表中的元素数量超过阈值(默认为 8) ,且数组长度达到一定大小时(默认为 64),链表会转换为红黑树。当红黑树的节点数量低于阈值(默认为 6)时,会重新退化为链表。红黑树能将查找、插入、删除操作的平均时间复杂度从 O(n) 降低到 O(log n) ,提高了极端情况下的性能。
核心流程:
- 计算哈希值:
key.hashCode()方法计算哈希值。 - 扰动函数: 对哈希值进行扰动(高位运算),减少哈希冲突。
- 确定索引:
(n - 1) & hash,其中n是数组长度,确定元素在数组中的索引位置。 - 存储/查找: 如果该位置为空,直接存储。如果存在元素,则遍历链表或红黑树进行比较(
equals()方法)以查找或插入。
5. ConcurrentHashMap 如何保证线程安全?
ConcurrentHashMap 在不同版本有不同的实现策略,但核心思想都是分段锁(Segment)或CAS + synchronized。
-
JDK 1.7 及之前:
- 采用**分段锁(Segment)**机制。
ConcurrentHashMap内部维护一个Segment数组,每个Segment都是一个独立的HashEntry数组,并继承了ReentrantLock。 - 每次对
ConcurrentHashMap的操作(如put、remove)会先根据键的哈希值确定要操作哪个Segment,然后对该Segment加锁。 - 这样,不同
Segment上的操作可以并发进行,提高了并发性能。只有在扩容时才可能需要全表加锁。
- 采用**分段锁(Segment)**机制。
-
JDK 1.8 及之后:
- 取消了
Segment,而是采用CAS (Compare And Swap) + synchronized 的方式。 - 在哈希冲突时,如果链表或红黑树的头节点不为 null,则使用 CAS 操作尝试更新,失败则自旋。
- 对于链表或红黑树中的节点操作,只对当前链表或红黑树的头节点进行
synchronized加锁。这意味着,不同链表或红黑树的操作可以并发进行,同一链表或红黑树的操作则串行化。 - 由于锁的粒度更细(从
Segment级别细化到单个桶的头节点),进一步提高了并发性能。
- 取消了
6. Set 和 List 有什么区别?
| 特性 | List | Set |
|---|---|---|
| 元素顺序 | 有序,元素有索引,可以根据索引访问 | 无序(通常,LinkedHashSet 除外) |
| 元素重复 | 允许重复元素 | 不允许重复元素 |
| 常用实现类 | ArrayList、LinkedList、Vector | HashSet、LinkedHashSet、TreeSet |
| 典型用途 | 存储需要保持插入顺序或需要按索引访问的元素 | 存储不重复的元素,常用于去重 |
导出到 Google 表格
四、多线程(Multithreading)
1. 创建线程的几种方式?
主要有三种方式:
-
继承
Thread类并重写run()方法:Java
class MyThread extends Thread { @Override public void run() { // 线程执行的任务 System.out.println("Thread extending Thread is running."); } } // 使用:new MyThread().start();缺点: Java 是单继承的,继承
Thread后就不能再继承其他类了。 -
实现
Runnable接口并实现run()方法:Java
class MyRunnable implements Runnable { @Override public void run() { // 线程执行的任务 System.out.println("Thread implementing Runnable is running."); } } // 使用:new Thread(new MyRunnable()).start();优点: 避免了单继承的限制,推荐使用。
-
实现
Callable接口并实现call()方法(配合FutureTask或线程池) :Java
import java.util.concurrent.*; class MyCallable implements Callable<String> { @Override public String call() throws Exception { // 线程执行的任务,可以有返回值,可以抛出异常 Thread.sleep(1000); return "Callable task finished!"; } } // 使用: // ExecutorService executor = Executors.newFixedThreadPool(1); // Future<String> future = executor.submit(new MyCallable()); // String result = future.get(); // 获取返回值 // executor.shutdown();优点:
call()方法可以有返回值,并且可以抛出异常。
2. Thread 类中的 start() 和 run() 方法有什么区别?
-
start()方法:- 用于启动一个新线程,让 JVM 调用该线程的
run()方法。 - 当调用
start()方法时,操作系统会为该线程分配资源,并将其放入线程调度器中,等待 CPU 调度执行。 - 每个线程只能调用一次
start()方法。
- 用于启动一个新线程,让 JVM 调用该线程的
-
run()方法:- 包含线程真正执行的任务逻辑。
- 直接调用
run()方法不会启动新线程,而是在当前线程中同步执行run()方法的代码,就像调用一个普通方法一样。
总结: 调用 start() 才是多线程的体现,它会创建一个新的线程并使其进入就绪状态;直接调用 run() 只是单线程的普通方法调用。
3. 线程的生命周期有哪些状态?
线程的生命周期主要有以下六种状态:
-
NEW(新建) :线程被创建但尚未启动。
-
RUNNABLE(可运行) :线程已调用
start()方法,正在等待 JVM 调度执行,或正在执行。它包括:- READY(就绪) :等待 CPU 调度。
- RUNNING(运行中) :正在执行
run()方法中的代码。
-
BLOCKED(阻塞) :线程正在等待获取一个监视器锁(例如,进入
synchronized代码块/方法),而该锁被其他线程持有。 -
WAITING(等待) :线程无限期地等待另一个线程执行特定操作(例如,调用
Object.wait()、Thread.join()、LockSupport.park())。 -
TIMED_WAITING(有时限等待) :线程在指定的时间内等待另一个线程执行特定操作(例如,调用
Thread.sleep(long millis)、Object.wait(long timeout)、Thread.join(long millis)、LockSupport.parkNanos()、LockSupport.parkUntil())。 -
TERMINATED(终止) :线程的
run()方法执行完毕,或者因异常退出。
4. synchronized 关键字的作用?它能作用在哪些地方?
synchronized 关键字是 Java 中用于实现同步的机制,它能保证同一时刻只有一个线程访问被同步的代码块或方法,从而防止多线程环境下数据的不一致性。
作用:
- 原子性: 保证操作的原子性,即一个操作要么全部执行,要么全部不执行,不会被中断。
- 可见性: 保证对共享变量的修改对其他线程立即可见。
- 有序性: 保证被同步代码的执行顺序。
作用位置:
-
同步实例方法: 锁住当前对象的实例(
this)。Java
public synchronized void method() { // 同步代码 } -
同步静态方法: 锁住当前类的
Class对象。Java
public static synchronized void staticMethod() { // 同步代码 } -
同步代码块:
-
同步实例对象: 锁住指定对象实例。
Java
public void method() { synchronized (this) { // 锁住当前对象实例 // 同步代码 } }或锁住其他对象:
synchronized (otherObject) -
同步 Class 对象: 锁住指定类的
Class对象,效果等同于同步静态方法。Java
public void method() { synchronized (MyClass.class) { // 锁住 MyClass 类 // 同步代码 } }
-
5. volatile 关键字的作用?它能保证原子性吗?
volatile 关键字主要用于修饰共享变量,它能保证:
- 可见性(Visibility) :当一个线程修改了
volatile变量的值,新值会立即被刷新到主内存,并且其他线程在读取该变量时会强制从主内存中获取最新值,而不是使用自己的工作内存副本。 - 有序性(Ordering) :通过插入内存屏障,防止指令重排序,保证
volatile变量读写操作的有序性。
volatile 能保证原子性吗?
不能! volatile 不能保证原子性。
原子性操作是指一个操作不可中断,要么全部执行成功,要么全部不执行。volatile 只能保证单个读写操作的原子性(例如 i = 10;),但对于复合操作(如 i++;,它包含读取、修改、写入三个步骤)则无法保证原子性。在并发环境下,i++ 仍然可能出现问题。要保证复合操作的原子性,需要使用 synchronized 或 java.util.concurrent.atomic 包中的原子类(如 AtomicInteger)。
6. wait()、notify() 和 notifyAll() 方法有什么区别?它们为什么必须和 synchronized 关键字一起使用?
这些方法都定义在 Object 类中,用于线程间的协作。
-
wait():- 使当前线程释放持有的锁,并进入
WAITING或TIMED_WAITING状态。 - 线程会一直等待,直到被其他线程调用
notify()或notifyAll()唤醒,或者达到指定的超时时间(带参数的wait())。 - 被唤醒后,线程需要重新竞争锁才能继续执行。
- 使当前线程释放持有的锁,并进入
-
notify():- 唤醒一个在当前对象上等待的线程。具体是哪个线程被唤醒,取决于 JVM 的调度,不确定。
- 被唤醒的线程不会立即执行,它仍需等待当前线程释放锁后,才能去竞争锁。
-
notifyAll():- 唤醒所有在当前对象上等待的线程。
- 所有被唤醒的线程都会进入锁的竞争队列,只有一个线程能获得锁并继续执行。
为什么必须和 synchronized 关键字一起使用?
- 避免
IllegalMonitorStateException:wait()、notify()和notifyAll()方法的调用者必须是当前线程所持有的锁对象。如果不在synchronized代码块/方法中使用它们,会导致IllegalMonitorStateException。 - 保证线程安全和状态一致性: 这些方法是线程间通信的手段,它们依赖于对共享资源的同步访问。
synchronized确保了在调用这些方法时,线程能拥有对象的锁,从而保证了共享数据的可见性和一致性,避免了竞态条件和死锁。线程在等待时释放锁,在被唤醒时重新竞争锁,也正是通过synchronized实现的互斥机制。
7. 说说 ThreadLocal 的作用和原理?
作用:
ThreadLocal 提供了一种线程局部变量的机制。这意味着,每个线程都拥有其自身独立的一个变量副本,互不影响。它解决了在多线程环境下,为每个线程保存其独立状态的问题,避免了线程安全问题,也无需进行同步。
常见应用场景:
- 数据库连接: 每个线程持有自己的数据库连接,避免连接共享和竞争。
- 用户会话信息: 在 Web 应用中,每个请求线程可以保存当前用户的会话信息。
- 线程上下文: 传递一些与线程相关的上下文信息。
原理:
ThreadLocal 的原理是,在每个 Thread 对象内部维护一个 ThreadLocalMap(一个弱引用 WeakReference 的 Entry 数组)。
- 当调用
ThreadLocal.set(value)方法时,ThreadLocal会获取当前线程,并以当前ThreadLocal实例作为ThreadLocalMap的键,value作为值存入该线程的ThreadLocalMap中。 - 当调用
ThreadLocal.get()方法时,ThreadLocal会获取当前线程,然后从该线程的ThreadLocalMap中以自身为键获取对应的值。
内存泄漏问题:
ThreadLocalMap 中的键是 ThreadLocal 实例的弱引用,而值是对实际对象的强引用。
- 如果
ThreadLocal实例不再被外部引用,它会被 GC 回收。但此时ThreadLocalMap中对应的Entry的键会变为null。 - 如果不对该
Entry进行清理,它的值(实际对象)仍然被ThreadLocalMap强引用着,导致该值无法被 GC 回收,从而引发内存泄漏。
最佳实践: 为了避免内存泄漏,在使用完 ThreadLocal 后,务必调用 ThreadLocal.remove() 方法来显式地移除当前线程中对应的变量副本。
8. 什么是线程池?使用线程池有什么好处?
线程池(Thread Pool) 是一种管理和复用线程的机制。它在应用启动时预先创建一定数量的线程,这些线程处于等待状态。当有任务到来时,线程池会分配一个空闲线程来执行任务;任务执行完毕后,线程不会被销毁,而是返回线程池中等待下一个任务。
使用线程池的好处:
- 降低资源消耗: 避免了线程的频繁创建和销毁带来的性能开销。
- 提高响应速度: 任务到达时,无需等待线程创建,可立即执行。
- 提高线程的可管理性: 统一管理线程,方便进行监控、调优和限制线程数量,避免因创建过多线程导致系统资源耗尽。
- 提供更多功能: 线程池提供了定时执行、周期性执行等高级功能。
9. 线程池的核心参数有哪些?
使用 ThreadPoolExecutor 创建线程池时,主要有以下几个核心参数:
-
corePoolSize(核心线程数) :- 线程池中一直保持活动状态的线程数量,即使它们处于空闲状态。
- 只有当工作队列已满时,才会创建新线程超过这个数量。
-
maximumPoolSize(最大线程数) :- 线程池允许创建的最大线程数量。
- 当工作队列已满,且当前线程数小于
maximumPoolSize时,线程池会创建新线程来处理任务。
-
keepAliveTime(空闲线程存活时间) :- 当线程池中的线程数量超过
corePoolSize时,这些多余的空闲线程在终止之前等待新任务的最长时间。 - 默认单位是毫秒,可以通过
TimeUnit指定其他单位。
- 当线程池中的线程数量超过
-
unit(时间单位) :keepAliveTime参数的时间单位,例如TimeUnit.SECONDS、TimeUnit.MILLISECONDS。
-
workQueue(工作队列/任务队列) :-
用于存放等待执行的任务的阻塞队列。
-
常用的队列类型:
ArrayBlockingQueue:基于数组的有界阻塞队列。LinkedBlockingQueue:基于链表的有界(默认无界)阻塞队列。SynchronousQueue:不存储元素的阻塞队列,每个插入操作必须等待一个对应的移除操作。PriorityBlockingQueue:支持优先级的无界阻塞队列。
-
-
threadFactory(线程工厂) :- 用于创建新线程的工厂,可以自定义线程的命名、优先级、是否为守护线程等。
-
RejectedExecutionHandler(拒绝策略) :-
当工作队列已满且线程池中的线程数达到
maximumPoolSize时,新提交的任务将触发拒绝策略。 -
常用的策略有:
AbortPolicy(默认):直接抛出RejectedExecutionException。CallerRunsPolicy:由提交任务的线程(调用者线程)自己来执行任务。DiscardOldestPolicy:丢弃队列中等待时间最长的任务,然后尝试提交当前任务。DiscardPolicy:直接丢弃当前任务,不抛异常。
-
10. 乐观锁 和 悲观锁 有什么区别?各有什么优缺点和使用场景?
这是并发控制的两种思想。
-
悲观锁(Pessimistic Lock) :
- 概念: 顾名思义,对数据修改持悲观态度,认为数据每次修改都会发生冲突,所以在每次操作数据之前,都会先获取锁,确保同一时刻只有一个线程能操作数据。
- 实现: Java 中通过
synchronized关键字和ReentrantLock实现。数据库中通过SELECT ... FOR UPDATE实现行级锁。 - 优点: 简单粗暴,能有效保证数据一致性。
- 缺点: 性能开销大,容易产生死锁,如果并发度不高,会降低效率。
- 使用场景: 适用于写多读少的场景,或并发冲突严重的场景,确保数据强一致性。
-
乐观锁(Optimistic Lock) :
-
概念: 对数据修改持乐观态度,认为数据冲突的概率较低,所以在操作数据时,不会先加锁,而是通过某种机制(如版本号或 CAS 算法)在更新时判断数据是否被其他线程修改过。如果被修改过,则操作失败,需要重试。
-
实现:
- 版本号(Version)机制: 在数据表中增加一个
version字段,每次更新数据时,都会检查当前version是否与数据库中的version一致,一致则更新并version+1,不一致则表示数据已被修改,更新失败。 - CAS (Compare And Swap) 算法: Java 中
Atomic包就是基于 CAS 实现的。它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置更新为新值 B;否则,处理器不做任何操作。无论哪种情况,它都会返回该位置的旧值。这个操作是原子性的。
- 版本号(Version)机制: 在数据表中增加一个
-
优点: 性能开销小,不会死锁,适用于读多写少的场景。
-
缺点: 如果冲突频繁,会导致大量重试,反而降低性能。需要额外的代码逻辑来处理重试。
-
使用场景: 适用于读多写少的场景,并发冲突较少,追求高并发性能。
-