Exception 和 Error 有什么区别?
Exception 和 Error 都继承自 Throwable,都表示程序运行时出现的问题,但它们的含义和处理方式不同。
1. Exception
Exception 表示程序运行过程中出现的可处理问题。
这类问题通常可以通过代码捕获并处理,比如:
NullPointerExceptionIOExceptionSQLException
特点:
- 一般由程序或外部环境引起
- 程序员通常可以预防或处理
- 可以用
try-catch捕获
2. Error
Error 表示 JVM 或系统层面的严重错误。
这类问题通常说明程序已经处于不正常状态,业务代码一般无法恢复,比如:
OutOfMemoryErrorStackOverflowError
特点:
- 通常是系统级问题
- 一般不建议程序主动捕获处理
- 出现后程序可能无法继续正常运行
3. 核心区别
Exception:程序运行中的可处理异常Error:系统级、严重且通常不可恢复的错误
4. 面试一句话回答
Exception 是程序运行过程中可预期、可处理的问题;Error 是 JVM 或系统层面的严重错误,通常不可恢复。
Checked Exception 和 Unchecked Exception 有什么区别?
Checked Exception 和 Unchecked Exception 的核心区别在于:编译器是否强制处理。
1. Checked Exception(受检异常)
Checked Exception 是在编译阶段就会检查的异常。
如果方法可能抛出这类异常,必须:
- 用
try-catch捕获 - 或者用
throws显式声明抛出
否则代码无法通过编译。
常见例子:
IOExceptionSQLExceptionClassNotFoundException
特点:
- 编译器强制处理
- 通常表示外部环境或业务上可预期的问题
- 调用方需要明确感知并处理
2. Unchecked Exception(非受检异常)
Unchecked Exception 是指编译器不强制处理的异常。
它通常指 RuntimeException 及其子类,比如:
NullPointerExceptionArrayIndexOutOfBoundsExceptionIllegalArgumentException
特点:
- 编译器不要求必须处理
- 通常表示程序逻辑错误或编码问题
- 可以不写
try-catch,在运行时才暴露出来
3. 继承关系区别
从继承结构看:
Checked Exception:继承Exception,但不继承RuntimeExceptionUnchecked Exception:继承RuntimeException
4. 核心区别总结
Checked Exception:编译器要求处理Unchecked Exception:编译器不强制处理
5. 面试一句话回答
Checked Exception 是编译器强制处理的异常,通常表示可预期的外部问题;Unchecked Exception 是运行时异常,编译器不强制处理,通常表示程序逻辑错误。
如何使用 try-with-resources 代替 try-catch-finally?
try-with-resources 是 Java 7 引入的一种语法,用于自动关闭资源,可以代替传统 try-catch-finally 中手动释放资源的写法,使代码更简洁、更安全。
1. 传统写法
传统方式通常需要在 finally 中手动关闭资源:
BufferedReader br = null;try { br = new BufferedReader(new FileReader("test.txt")); String line = br.readLine(); System.out.println(line);} catch (IOException e) { e.printStackTrace();} finally { if (br != null) { try { br.close(); } catch (IOException e) { e.printStackTrace(); } }}这种写法的问题是:
- 代码比较冗长
close()也可能抛异常,需要再套一层try-catch- 容易因为疏忽导致资源泄漏
2. try-with-resources 写法
使用 try-with-resources 后,可以直接把资源写在 try() 中:
try (BufferedReader br = new BufferedReader(new FileReader("test.txt"))) { String line = br.readLine(); System.out.println(line);} catch (IOException e) { e.printStackTrace();}执行结束后,系统会自动调用资源的 close() 方法,不需要再在 finally 中手动关闭。
3. 使用前提
try-with-resources 只能用于实现了 AutoCloseable 或 Closeable 接口的资源,比如:
InputStreamOutputStreamReaderWriterConnectionPreparedStatementResultSet
4. 多个资源的写法
多个资源可以同时写在 try() 中,按顺序声明,关闭时按相反顺序自动关闭:
try ( FileInputStream in = new FileInputStream("a.txt"); FileOutputStream out = new FileOutputStream("b.txt")) { byte[] buffer = new byte[1024]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); }} catch (IOException e) { e.printStackTrace();}5. 优点
相比传统 try-catch-finally,try-with-resources 的优点是:
- 自动关闭资源,避免资源泄漏
- 代码更简洁、可读性更好
- 不需要在
finally中手动写关闭逻辑 - 异常处理更规范
6. 面试一句话回答
try-with-resources 是 Java 7 提供的自动资源管理语法,可以把实现了 AutoCloseable 或 Closeable 接口的资源声明在 try() 中,执行结束后自动调用 close(),从而替代传统 try-catch-finally 中手动关闭资源的写法。
异常使用有哪些需要注意的地方?
异常使用时需要注意的核心是:不要滥用、要分层处理、要保留关键信息、要便于排查。
1. 不要用异常控制正常业务流程
异常表示的是非正常情况,不要把它当成普通的 if-else 来用。
如果本来可以通过条件判断解决,就不要依赖抛异常控制流程,否则会影响代码可读性,也会带来额外性能开销。
2. 尽量抛出具体异常,不要直接抛 Exception
不要动不动就写:
throw new Exception("出错了");应该尽量使用更明确的异常类型,比如:
- 参数错误:
IllegalArgumentException - 状态错误:
IllegalStateException - 空指针问题:
NullPointerException - 下标越界:
IndexOutOfBoundsException
这样更利于定位问题,也方便上层分类处理。
3. 不要只 catch 不处理
以下写法都不推荐:
catch (Exception e) {}或者:
catch (Exception e) { e.printStackTrace();}因为这类写法要么直接吞掉异常,要么没有真正处理问题。
正确做法通常是:
- 记录日志
- 转换异常
- 进行补偿处理
- 或继续向上抛出
4. 封装异常时要保留原始异常
包装异常时一定要带上原始异常 cause,例如:
throw new RuntimeException("保存订单失败", e);如果不保留原始异常,最后只能看到表面现象,排查不到根本原因。
5. 合理选择 Checked Exception 和 Unchecked Exception
- Checked Exception:适合那些调用方需要明确处理的异常
- Unchecked Exception:适合参数错误、状态错误、编程缺陷等问题
在实际 Java 后端开发中,更多时候会优先使用 RuntimeException 体系,但也不能一概而论,要结合场景。
6. 不要过度捕获大而全的异常
不要一上来就写:
catch (Exception e)因为这样容易把真正的异常类型掩盖掉。
更推荐优先捕获具体异常,只有在统一兜底时才捕获较大的异常范围。
7. 资源要及时释放
涉及文件流、数据库连接、网络连接时,要注意资源关闭。
优先使用 try-with-resources,不要只依赖手动 finally 关闭,否则容易造成资源泄漏。
8. 异常信息要清晰,便于排查
抛异常时,信息不要只写“失败了”“出错了”,最好带上关键上下文,比如:
- 订单号
- 用户 ID
- 请求参数摘要
- 关键业务状态
但同时要注意,不要把密码、密钥、身份证号等敏感信息直接打到异常或日志里。
9. 分层处理中要职责清晰
一般来说:
- DAO 层 / 持久层:保留底层异常信息
- Service 层:做业务语义转换
- Controller 层:统一返回友好错误信息
不要把底层技术异常直接暴露给前端,否则既不安全,也不利于统一管理。
10. 日志不要重复打印
同一个异常如果在每一层都 log.error(),最后日志会重复很多遍,反而影响排查。
通常原则是:谁真正处理异常,谁记录关键日志;如果只是向上抛,通常不需要层层都打。
11. 自定义异常不要滥用
自定义异常要有明确业务语义,比如:
OrderNotFoundExceptionInventoryNotEnoughException
不要为了“看起来规范”就给每个小场景都定义一个异常,否则会让异常体系过于臃肿。
12. 面试一句话回答
异常使用时要注意:不要滥用异常控制流程,尽量抛出具体异常,合理捕获并分层处理,封装时保留原始异常,保证资源及时释放,同时让日志和异常信息具备可排查性。
什么是泛型?有什么作用?
1. 什么是泛型
泛型(Generic)是 Java 中的一种类型参数化机制,可以在定义类、接口、方法时,先不指定具体类型,而是在使用时再确定具体类型。
例如:
List<String> list = new ArrayList<>();List<Integer> nums = new ArrayList<>();这里的 String、Integer 就是传入的具体类型参数。
也就是说,泛型本质上就是:把类型当作参数传进去。
2. 泛型的作用
(1)提高代码复用性
如果没有泛型,很多类和方法只能写死某一种类型,或者统一写成 Object。
有了泛型之后,同一套代码可以适配多种类型,不需要重复写很多类似逻辑。
例如:
public class Box<T> { private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }}这里 Box<T> 就可以存放 String、Integer、User 等任意类型。
(2)保证类型安全
泛型可以在编译期检查类型,避免把错误类型的数据放进去。
例如:
List<String> list = new ArrayList<>();list.add("abc");list.add(123); // 编译报错如果不用泛型,使用 List 原始类型,就可能在运行时才出问题。
(3)减少强制类型转换
没有泛型时,从集合中取出元素通常需要强转:
List list = new ArrayList();list.add("hello");String s = (String) list.get(0);使用泛型后:
List<String> list = new ArrayList<>();list.add("hello");String s = list.get(0);这样代码更简洁,也更安全。
(4)让代码语义更清晰
看到:
List<String>就知道这个集合里放的是字符串;看到:
Map<String, Object>就知道 key 是字符串,value 是对象。
可读性会更好。
3. 泛型常见使用位置
泛型通常可以用在三个地方:
(1)泛型类
例如:
class Box<T> { }(2)泛型接口
例如:
interface Comparable<T> { }(3)泛型方法
例如:
public <T> T getFirst(T[] arr) { return arr[0];}4. 泛型的本质
Java 泛型在编译后会发生类型擦除。
也就是说,泛型主要是在编译阶段起作用,编译后字节码中很多泛型信息会被擦除,运行时通常拿不到具体泛型类型。
例如:
List<String>List<Integer>在运行时其实都是 List。
5. 面试一句话回答
泛型就是把类型参数化,在定义类、接口、方法时先不指定具体类型,等到使用时再确定。它的主要作用是提高代码复用性、保证类型安全、减少强制类型转换,并让代码语义更清晰。
什么是反射?
1. 什么是反射
反射(Reflection)是 Java 提供的一种机制,允许程序在运行时动态获取类的信息,并操作类或对象。
通过反射,可以在运行时做到这些事情:
- 获取类的名称、父类、接口、包信息
- 获取类中的构造方法、成员变量、成员方法
- 创建对象
- 调用方法
- 访问和修改属性
- 甚至可以操作
private成员
也就是说,反射让程序具备了“运行时自我分析和动态操作”的能力。
2. 反射能做什么
常见能力包括:
(1)获取类信息
例如获取类名、方法、字段、构造器等。
(2)动态创建对象
不直接 new,而是在运行时通过类信息创建对象。
(3)动态调用方法
运行时根据方法名找到对应方法并执行。
(4)访问和修改属性
包括访问普通属性,甚至可以通过暴力反射访问私有属性。
3. 反射相关核心类
Java 反射主要依赖 java.lang.reflect 包,常用核心类有:
Class:表示类的字节码对象Constructor:表示构造方法Method:表示成员方法Field:表示成员变量
其中最核心的是 Class,因为反射通常都是从拿到 Class 对象开始的。
4. 获取 Class 对象的方式
常见有三种:
(1)通过类名.class
Class<?> clazz = User.class;(2)通过对象.getClass()
User user = new User();Class<?> clazz = user.getClass();(3)通过 Class.forName()
Class<?> clazz = Class.forName("com.demo.User");其中 Class.forName() 最能体现反射的动态性,因为类名可以在运行时传入。
5. 反射的优点
- 提高灵活性和扩展性
- 可以在运行时动态加载和操作类
- 非常适合做框架、组件、通用工具开发
很多框架底层都大量使用反射,比如:
- Spring
- MyBatis
- Hibernate
- JDK 动态代理
6. 反射的缺点
- 性能比直接调用低
- 会破坏封装性,可以访问私有成员
- 代码相对复杂,可读性较差
- 过度使用会增加维护成本
7. 反射的典型应用场景
- 框架中根据配置文件动态加载类
- 依赖注入(IOC)
- 动态代理
- ORM 框架字段映射
- 注解解析
8. 面试一句话回答
反射是 Java 提供的一种运行时机制,允许程序在运行时动态获取类的信息,并创建对象、调用方法、访问属性。它让程序具备更强的灵活性,很多框架底层都依赖反射实现动态扩展。
如何实现动态代理?
动态代理是指:在程序运行时动态生成代理对象,而不是在编译期就写好代理类。
它的核心作用是:在不修改目标对象代码的前提下,对目标方法进行增强,比如加日志、权限校验、事务控制等。
Java 中常见的动态代理实现方式有两种:
- JDK 动态代理
- CGLIB 动态代理
1. JDK 动态代理
(1)适用场景
JDK 动态代理要求目标对象必须实现接口。
它是基于 InvocationHandler 和 Proxy 来实现的。
(2)实现步骤
第一步:定义接口
public interface UserService { void save();}第二步:定义目标类
public class UserServiceImpl implements UserService { @Override public void save() { System.out.println("保存用户"); }}第三步:定义 InvocationHandler
import java.lang.reflect.InvocationHandler;import java.lang.reflect.Method;
public class MyInvocationHandler implements InvocationHandler { private Object target;
public MyInvocationHandler(Object target) { this.target = target; }
@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("方法执行前"); Object result = method.invoke(target, args); System.out.println("方法执行后"); return result; }}第四步:生成代理对象
import java.lang.reflect.Proxy;
public class Test { public static void main(String[] args) { UserService target = new UserServiceImpl();
UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new MyInvocationHandler(target) );
proxy.save(); }}(3)执行结果
方法执行前保存用户方法执行后(4)原理
JDK 动态代理会在运行时生成一个代理类,这个代理类实现了目标对象的接口。
当调用代理对象的方法时,最终会统一进入 InvocationHandler 的 invoke() 方法,在这里可以做增强逻辑,再调用目标方法。
2. CGLIB 动态代理
(1)适用场景
如果目标类没有实现接口,就不能用 JDK 动态代理,这时通常使用 CGLIB。
CGLIB 是通过继承目标类并重写方法来实现代理的。
(2)实现思路
CGLIB 核心是 MethodInterceptor 和 Enhancer。
示例:
import net.sf.cglib.proxy.Enhancer;import net.sf.cglib.proxy.MethodInterceptor;import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
public class UserService { public void save() { System.out.println("保存用户"); }}
public class Test { public static void main(String[] args) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(UserService.class); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println("方法执行前"); Object result = proxy.invokeSuper(obj, args); System.out.println("方法执行后"); return result; } });
UserService proxy = (UserService) enhancer.create(); proxy.save(); }}(3)原理
CGLIB 在运行时生成目标类的子类,并重写父类方法,在重写的方法中加入增强逻辑。
(4)限制
由于是继承实现,所以:
- 不能代理
final类 - 不能代理
final方法
3. JDK 动态代理和 CGLIB 的区别
JDK 动态代理
- 目标类必须实现接口
- 基于接口生成代理对象
- 使用 JDK 自带 API 实现
CGLIB 动态代理
- 目标类不需要实现接口
- 基于继承生成子类代理
- 不能代理
final类和final方法
4. Spring 中的动态代理
Spring AOP 底层就使用了动态代理:
- 如果目标类实现了接口,默认优先使用 JDK 动态代理
- 如果目标类没有实现接口,使用 CGLIB 动态代理
- 当然也可以强制指定使用 CGLIB
5. 面试一句话回答
动态代理是在运行时动态生成代理对象,从而在不修改目标类代码的情况下对方法进行增强。实现方式主要有两种:JDK 动态代理和 CGLIB 动态代理。JDK 动态代理基于接口和 InvocationHandler 实现,要求目标类必须实现接口;CGLIB 基于继承和字节码生成实现,不要求目标类实现接口,但不能代理 final 类和 final 方法。
何为注解?
1. 什么是注解
注解(Annotation)是 Java 提供的一种元数据机制,用于给类、方法、属性、参数等程序元素添加额外信息。
这些信息本身不会直接影响程序业务逻辑,但可以被编译器、JVM 或框架读取并利用,从而实现一些特殊功能。
可以简单理解为:
注解就是给代码打标记。
2. 注解的作用
注解本质上不是业务代码,而是给程序或框架“看的说明信息”。
它常见的作用有:
- 生成文档
- 编译期检查
- 运行时动态处理
- 替代繁琐的 XML 配置
- 配合反射实现框架功能
例如:
@Override:告诉编译器这是重写方法@Deprecated:表示方法或类已过时@SuppressWarnings:抑制警告- Spring 里的
@Component、@Autowired - MyBatis 里的
@Select
3. 注解的常见分类
(1)JDK 内置注解
Java 自带的一些基础注解,例如:
@Override@Deprecated@SuppressWarnings
(2)元注解
元注解是“用来修饰注解的注解”,常见有:
@Target:指定注解能作用在哪些位置@Retention:指定注解保留到哪个阶段@Documented:是否生成到文档中@Inherited:子类是否可以继承父类注解
(3)自定义注解
开发者也可以自己定义注解,用于项目中的特定场景,比如:
- 操作日志注解
- 权限校验注解
- 接口幂等注解
- 参数校验注解
4. 注解和注释的区别
很多人容易把注解和注释混淆,它们完全不同:
注释
- 写给开发人员看的
- 编译后一般不会保留
- 主要用于解释代码含义
注解
- 写给编译器、JVM、框架看的
- 可以参与编译和运行逻辑
- 可被程序读取并处理
所以:
注释是说明文字,注解是程序可识别的元数据。
5. 注解为什么重要
在现代 Java 开发中,注解非常常见,因为很多框架都大量依赖注解来简化开发。
例如:
- Spring 用注解实现 IOC、AOP、依赖注入
- JUnit 用注解标记测试方法
- MyBatis 用注解写 SQL
- Lombok 用注解自动生成代码
也就是说,注解极大提升了代码的简洁性和开发效率。
6. 注解的本质
从本质上说,注解是一种特殊接口,默认继承 java.lang.annotation.Annotation。
程序可以在编译期或运行期读取注解中的信息,并据此执行对应逻辑。
所以注解本身不直接“干活”,真正起作用的是:
- 编译器
- 反射机制
- 框架对注解的解析和处理
7. 面试一句话回答
注解是 Java 提供的一种元数据机制,用于给类、方法、属性等程序元素添加额外信息,供编译器、JVM 或框架在编译期、类加载期或运行期读取和处理,本质上可以理解为给代码打标记。
讲讲 SPI
1. 什么是 SPI
SPI 全称是 Service Provider Interface,即服务提供者接口。
它是 Java 提供的一种扩展机制,允许框架或系统在运行时发现并加载某个接口的实现类,从而实现解耦和可插拔。
可以简单理解为:
API 是“我定义好功能给别人调用”,SPI 是“我定义好接口,让别人来实现,我运行时再加载这些实现”。
2. SPI 的核心思想
SPI 的核心是:
- 定义一个接口
- 不在代码里写死具体实现类
- 由不同的服务提供者去实现这个接口
- 系统在运行时自动加载这些实现类
这样做的好处是:
框架只依赖接口,不依赖具体实现,扩展能力很强。
3. SPI 的实现原理
Java SPI 的标准实现依赖两个东西:
(1)接口定义
先定义一个服务接口,例如:
public interface MyService { void execute();}(2)实现类
不同厂商或模块提供具体实现:
public class MyServiceImpl implements MyService { @Override public void execute() { System.out.println("执行实现类逻辑"); }}(3)配置文件
在 META-INF/services/ 目录下创建一个文件,文件名必须是接口的全限定类名:
META-INF/services/com.demo.MyService文件内容写该接口实现类的全限定类名:
com.demo.MyServiceImpl(4)通过 ServiceLoader 加载
Java 提供了 ServiceLoader 来动态加载实现类:
ServiceLoader<MyService> loader = ServiceLoader.load(MyService.class);for (MyService service : loader) { service.execute();}运行时,ServiceLoader 会去扫描 classpath 下所有 META-INF/services/接口全限定名 文件,把里面配置的实现类加载进来。
4. SPI 的执行流程
SPI 的整体流程可以概括为:
- 框架定义接口
- 第三方实现这个接口
- 在
META-INF/services/下配置实现类 - 框架运行时通过
ServiceLoader扫描配置文件 - 加载并实例化对应实现类
- 使用实现类完成扩展功能
5. SPI 的典型应用场景
(1)JDBC 驱动加载
这是 SPI 最经典的例子。
以前使用 JDBC 时常写:
Class.forName("com.mysql.cj.jdbc.Driver");后来 JDBC 4.0 以后,很多驱动都基于 SPI 自动注册。
只要驱动 jar 包里正确配置了 META-INF/services/java.sql.Driver,JVM 就能自动发现并加载驱动类。
(2)日志框架
一些日志门面或插件扩展机制也会用到 SPI。
(3)Dubbo
Dubbo 对 SPI 做了增强,扩展性非常强,是面试中常提到的例子。
(4)Spring Boot 自动装配的某些思想
虽然 Spring Boot 不完全是标准 SPI,但“通过配置发现扩展实现”的思路和 SPI 很接近。
6. SPI 的优点
(1)解耦
调用方只依赖接口,不依赖具体实现。
(2)可扩展
新增实现类时,不需要修改原有框架代码,只需要新增实现和配置即可。
(3)可插拔
不同实现可以像插件一样动态接入。
7. SPI 的缺点
(1)无法按需精准加载
标准 Java SPI 一般会遍历并实例化所有实现,可能造成资源浪费。
(2)配置方式依赖约定
必须严格按照 META-INF/services/接口全限定名 的格式来写,出错不容易排查。
(3)不够灵活
标准 SPI 只支持按接口查找实现,不支持按名称、条件、优先级等复杂场景。
8. SPI 和 API 的区别
API
- 接口由平台或框架提供
- 调用方来使用这些接口
- 重点是“调用”
SPI
- 接口由平台或框架定义
- 实现由第三方提供
- 平台运行时发现并加载实现
- 重点是“扩展”
一句话理解:
API 是给调用方用的,SPI 是给扩展方实现的。
9. 为什么框架喜欢用 SPI
因为框架本身往往需要预留扩展点。
如果把实现类写死,后面就很难扩展;而 SPI 可以让第三方在不改框架源码的情况下接入自己的实现。
这对中间件、驱动、插件系统、微服务框架都非常重要。
10. 面试一句话回答
SPI 是一种服务发现和扩展机制,框架先定义接口,由第三方提供实现,并通过 META-INF/services 配置文件让系统在运行时自动发现和加载实现类。它的核心价值是解耦、可扩展和可插拔,JDBC 驱动加载就是经典应用场景。
序列化和反序列化
1. 什么是序列化
序列化(Serialization)是指:把 Java 对象转换成可以存储或传输的字节序列的过程。
通俗理解就是:
把内存中的对象,变成可以写到文件、网络、缓存里的数据。
例如:
- 把对象写入文件
- 通过网络传输对象
- 将对象保存到 Redis、MQ、磁盘中
2. 什么是反序列化
反序列化(Deserialization)是指:把字节序列重新恢复成 Java 对象的过程。
也就是说:
- 序列化:对象 → 字节流
- 反序列化:字节流 → 对象
它们通常是成对出现的。
3. 为什么需要序列化
因为 Java 对象本身只存在于 JVM 内存中,不能直接跨进程、跨网络、持久化存储。
如果想让对象“离开内存”,就需要先把它转换成某种可传输、可保存的格式。
常见使用场景有:
(1)对象持久化
把对象保存到文件、数据库、磁盘中。
(2)网络传输
RPC 调用、Socket 通信时,需要把对象转成字节流发送给对方。
(3)缓存
把对象写入 Redis 等缓存系统时,通常也需要序列化。
(4)分布式场景
多个服务之间传输对象数据时,也离不开序列化和反序列化。
4. Java 中如何实现序列化
Java 原生序列化要求对象实现 Serializable 接口。
例如:
import java.io.Serializable;
public class User implements Serializable { private String name; private int age;}然后就可以通过 ObjectOutputStream 进行序列化,通过 ObjectInputStream 进行反序列化。
5. 示例
(1)序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.obj"));oos.writeObject(new User("Tom", 18));oos.close();(2)反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.obj"));User user = (User) ois.readObject();ois.close();6. Serializable 接口的特点
Serializable 是一个标记接口,本身没有任何方法。
它的作用只是告诉 JVM:这个类的对象允许被序列化。
如果一个类没有实现 Serializable,在序列化时会抛出:
NotSerializableException7. serialVersionUID 是什么
serialVersionUID 是序列化版本号,用来标识类的版本一致性。
例如:
private static final long serialVersionUID = 1L;作用:
- 序列化时会把这个版本号一起写入
- 反序列化时会校验本地类和字节流中的版本号是否一致
- 如果不一致,可能抛出
InvalidClassException
为什么建议手动定义?
因为如果不手动写,Java 会根据类结构自动生成。
一旦类发生改动,自动生成的值可能变化,导致以前序列化的数据无法反序列化。
8. transient 关键字的作用
如果某个字段不想参与序列化,可以使用 transient 修饰。
例如:
private transient String password;这样在序列化时,password 字段不会被写入字节流。
常见场景:
- 密码
- 临时计算字段
- 不需要持久化的敏感信息
9. 静态变量会被序列化吗
不会。
因为 static 变量属于类,不属于对象。
序列化保存的是对象状态,而不是类状态。
10. Java 原生序列化的缺点
(1)性能较差
Java 原生序列化生成的字节流通常比较大,序列化和反序列化速度也不够快。
(2)可读性差
序列化后的内容是二进制,不方便阅读和调试。
(3)跨语言支持差
Java 原生序列化主要适用于 Java 体系内部,不适合跨语言系统之间传输。
(4)安全性问题
反序列化如果处理不当,可能存在安全风险,甚至可能被利用进行攻击。
11. 常见替代方案
在实际开发中,很多项目不会直接使用 Java 原生序列化,而会选择更通用的方案,比如:
- JSON:如 Jackson、Gson
- XML
- Hessian
- Kryo
- Protobuf
其中:
- JSON:可读性好,跨语言方便
- Protobuf:体积小、性能高,适合 RPC 和高性能传输
- Kryo:性能高,但更偏 Java 内部使用
12. 序列化和深拷贝的关系
序列化和反序列化还能用于实现深拷贝。
因为对象序列化成字节流后,再反序列化回来,相当于创建了一个全新的对象副本。
不过这种方式性能一般,不是最优方案。
13. 面试中的常见追问
(1)哪些对象可以被序列化?
实现了 Serializable 接口的对象才可以被序列化。
(2)父类没实现 Serializable 可以吗?
可以,但父类必须有无参构造器,因为反序列化时父类部分需要通过构造器初始化。
(3)子类实现 Serializable,父类的字段会被序列化吗?
如果父类没有实现 Serializable,父类字段不会走序列化机制,而是在反序列化时通过父类无参构造器初始化。
(4)transient 修饰的字段一定无法恢复吗?
默认不会被序列化,所以反序列化后一般是默认值。
但如果自定义序列化逻辑,仍然可以手动处理。
14. 面试一句话回答
序列化就是把对象转换成可存储、可传输的字节序列,反序列化就是把字节序列恢复成对象。它主要用于对象持久化、网络传输、缓存和分布式通信。Java 中通常通过实现 Serializable 接口来支持序列化,并可通过 serialVersionUID 控制版本一致性,transient 控制字段不参与序列化。
部分信息可能已经过时









