2.基础加强版面试题
1.异常
这里引用JavaGuide的一张异常类层次结构图
1.1 Exception和Error的区别
在Java中,所有的异常类有一个共同的父类Throwable存在于java.lang包中。Throwable类有两个子类:
- Exception:程序中可以处理的异常,可通过catch进行捕获;Exception可分为Checked Exception(受检查异常,必须处理)(RuntimeException及其子类)和Unchecked Exception(不受检查异常,可不处理);
- Error:程序本身无法处理的异常;如虚拟机运行异常(Virtual MachineError)、内存溢出异常(OutOfMemory)、类定义错误(NoClassDefFoundError)。
FileNotFoundException是一种受检查异常,是Exception异常类的子类
1.2 Throwable类常见的方法
- String getMessage():返回异常发生时的简要描述;
- String toString:返回异常时的详细信息;
- String getLocalizedMessage():返回异常对象的本地化信息。如果Throwable的子类覆盖该方法,可生成本地化信息。若没有则返回getMessage()方法信息;
- void printStackTrace():控制台打印Throwable对象封装的异常信息
1.3 try-catch-finally 如何使用
try块:捕获异常。可接0个或多个catch块,如果没有catch块,一定要有finally块;
catch块:处理try捕获到的异常;
finally块:无论是否捕获或处理异常,finally块的语句都会被执行;当遇到try或catch中存在return时,会在其之前执行;
代码实例
注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。
1.4 finally一定会执行吗
不一定,在某些情况下,finally不会执行,如终止正在运行的虚拟机(System.exit(1))、关闭CPU。
代码实例和展示
1.5 异常使用需要注意的地方
- 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
- 抛出的异常信息一定要有意义。
- 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出
NumberFormatException
而不是其父类IllegalArgumentException
。 - 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)。
1.6 实际应用拓展(记录自己后续项目中对异常的应用)
2.泛型
2.1 什么是泛型?有什么作用?
泛型(Generics)是JDK5引入的特性,它是一种实现参数化类型的机制。即在定义类、接口、方法时使用参数表示类型,在实际使用时指定具体的类型。使用泛型参数可增强代码的可读性及稳定性。
编译器可对泛型参数进行检测,并通过泛型参数指定传入的对象类型实现自动转换类型。
代码实例
public class Generics<T>{ private T value; private Generics(T value){ this.value = value; } public void setValue(T Value){ this.value = value; } public T getVallue(){ return value; } }
2.2 泛型有哪几种?
泛型有泛型类、泛型接口、泛型方法三种
2.2.1 泛型类
public class Generics<T>{
private T value;
private Generics(T value){
this.value = value;
}
public void setValue(T Value){
this.value = value;
}
public T getVallue(){
return value;
}
}
实例化
Generics<String> generics = new Generics<>("123456");
2.2.2 泛型接口
public interface Generics<T>{
public T method();
}
实现泛型接口,不指定类型,如果不是null类型,那么会报编译错误。当返回值为null时,表示空引用,因此不会报错。
//T即不指定类型
public class GenericsClass<T> implements Generics<T>( @Override public T method(){ retutn 5; } )
可以通过强制转换将其转换为T类型避免报错
代码实例
public class GenericsClass<T> implements Generics<T>( @Override public T method(){ retutn (T)Integer.valueOf(5); } )
实现泛型接口,指定类型
//T即不指定类型
public class GenericsClass<T> implements Generics<String>(
@Override
public String method(){
retutn "6";
}
)
2.2.3 泛型方法
public static <E> void printArray(E[] array){
for (E element:array) {
System.out.println(element);
}
}
注意:public static <E> coid printArray(E[] array)一般被称为静态泛型方法;在Java中,泛型是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递参数,静态方法加载与编译器,先于运行期加载的类实例化对象。即类中的泛型还没有真正传递类型参数时,静态方法已加载完。因此静态方法无法使用类上的泛型,只能使用自己的泛型<E>。
2.3 项目中泛型的用处
- 自定义接口通用返回结果
CommonResult<T>
通过参数T
可根据具体的返回类型动态指定结果的数据类型; - 定义
Excel
处理类ExcelUtil<T>
用于动态指定Excel
导出的数据类型; - 构建集合工具类(参考
Collections
中的sort
,binarySearch
方法)。
2.4 实际应用拓展(记录自己后续项目中对泛型的应用)
3. 反射
3.1 概念
反射是运行时分析类以及执行类中方法的机制。通过反射可以获取任意一个类的所有属性和方法,还可以调用这些属性和方法。
3.2 优缺点
- 优点:灵活,为Spring/Spring Boot、Mybatis等框架提供开箱即用的功能提供了便利;
- 缺点:其在运行时分析类以及执行类中方法能力的同时,增加了安全性问题;如无视泛型参数的安全检查(泛型参数安全检查发生在编译时)。反射性能稍差。
3.3 new一个对象和反射生成一个对象的差异
反射是在运行时动态操作、监测和修改类、对象、方法和属性的机制。使用“new”关键字创建对象是在编译器确定的静态操作。
- 创建对象成本:反射创建对象需要动态解析构造函数、检查访问权限,这些操作在性能上需要额外开销。使用“new”关键字可直接调用构造函数进行对象实例化,成本较低。
3.4 反射应用场景
在Spring/SpringBoot、Mybatis框架中大量使用了反射机制。这些框架大量使用了动态代理模式,而动态代理模式依赖于反射。
JDK实现动态代理,其中使用反射类的Method方法调用指定的方法。
public class DebugInvocationHandler implements InvocationHandler{ private final Object target ;//final修饰的变量必须在声明时或构造函数中进行初始化赋值,不然会编译报错 private DebugInvocationHandler(Object target){ this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("before method" + method.getName()); Object result = method.invoke(target, args); System.out.println("after method" + method.getName()); return result; } }
注意:为什么使用 Spring 的时候 ,一个@Component
注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value
注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?
因为可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
3.5 反射实战
3.5.1 获取Class对象的四种方式
反射动态获取类信息,需要使用Class对象。Class类对象将一个类的方法、属性等信息告诉运行的程序。Java提供四种方式获取Class对象。
1.知道具体类情况下使用:
@Data static class User{ private String name; } public static void main(String[] args) { Class<User> userClass = User.class; System.out.println(userClass); }
我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化
由于我创建的User是一个静态内部类,所以加载的结尾为$User
2.通过Class.forName()传入类的全路径获取
Class<?>aClass = Class.forName("com.ku.test.basic.TestReflect"); System.out.println(aClass);
3.通过instance.getClass()获取
User user = new User(); Class userClass2 = user.getClass();
4.通过类加载器XXXClassLoader传入类路径获取
Class<?> aClass = ClassLoader.getSystemClassLoader("com.ku.test.basic.TestReflect");
3.5.2 反射的一些基本操作(重点)
public class TargetObject{ private String value; private TragetObjext(){ value = "小库" } public void publicMethod(String s){ System.out.println("I love"+s); } public void privateMethod(){ System.out.println("love id "+value); } }
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException, NoSuchFieldException { //反射基本操作 //获取TargetClass的Class对象,并创建TargetObject类实例 Class<?>targetClass = Class.forName("com.ku.test.basic.TargetObject"); //通过Class.newInstance创建一个对象强转为TargetObject类型 TargetObject targetObject = targetClass.newInstance(); //获取TargetObject中定义的所有方法 Method[] mathods = targetClass.getDeclareMethods(); for(Method method : methods){ System.out.println(method.getName()); } //获取指定方法并调用 //传入String.class为参数类型 Method publicMethod = targetClass.getDeclareMethod("publicMethod", String.class); //动态调用 publicMethod.invoke(targetObject, "xiaoku"); //获取指定参数,并对参数进行修改 Field filed = Class.getDeclareField("value"); field.setAccess(true); field.set(tagetObject, "xiaoku"); //执行private方法 Method privateMethod = targetClass.getDeclareMethod("privateMethod"); privateMethod.setAccess(true); privateMethod.invoke(targetObject, "xiaoku");
注意:反射操作涉及多个受检测异常,我这里统一在方法名后抛出。
3.6 代理模式
代理模式是一种设计模式,它通过代理对象对真实对象进行功能扩增。在不修改原目标对象的情况下,扩展原目标对象的功能。代理模式有动态代理和静态代理两种。
代理模式主要作用:扩展目标对象的功能。如在目标对象的某个方法前后增加一些自定义操作。
3.6.1 静态代理
静态代理中,我们对目标对象的每个方法的增强都是手动完成的,非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改,因为目标和代理都需要实现相同的接口)且麻烦(需要对每个目标类都单独写一个代理类)。
从JVM层面分析,静态代理是在编译时就将接口、实现类、代理类编译为一个.class文件。
静态代理实现步骤:
- 定义一个接口及真实实现类;
- 定义一个代理类实现与真实实现类相同的接口;
- 将目标对象注入代理类,再通过代理类的方法调用目标对象的方法(通过代理类屏蔽对目标对象的访问,还可以在目标方法执行前后自定义)
代码实例
public interface SmsService { String smsSend(String message); } public class SmsServiceImpl implements SmsService { @Override public String smsSend(String message) { //实现类对接口方法进行重写 System.out.println("send message:"+message); return message; } } public class SmsProxy implements SmsService { //当变量被final修饰,必须在声明时复制或者在构造方法中初始化 private final SmsService smsService; public SmsProxy(SmsService smsService) { this.smsService = smsService; } @Override public String smsSend(String message) { System.out.println("before send Message"); smsService.smsSend(message); System.out.println("after send Message"); return null; } }
结果
从上述结可以看出,接口与实现类以及代理类之间的耦合度高,当接口增加新方法时,接口实现类和代理类都需要进行代码修改,且每一个单独功能的实现类都需要一个代理类,如我需要在接口上面新增一个接受消息的方法,那么我需要分别在实现类和代理类中重写接受消息的方法。如果有多个目标类,则需要为每个目标类增加一个代理类,因为多个目标类中的功能可能有所不同,因此需要创建多个代理类扩展每个目标对象各自的功能。
代码实例
public interface SmsService { String smsSend(String message); String rcvMessage(String message); } public class SmsServiceImpl1 implements SmsService{ @Override public String smsSend(String message) { //实现类对接口方法进行重写 System.out.println("send message:"+message); return message; } @Override public String rcvMessage(String message) { System.out.println("receive:"+message); System.out.println("我是第二个目标类,有别于第一个目标类"); return message; } } public class SmsProxy1 implements SmsService { private final SmsService smsService; public SmsProxy1(SmsService smsService) { this.smsService = smsService; } @Override public String smsSend(String message) { System.out.println("我是第二个代理类"); System.out.println("before message"); smsService.smsSend(message); System.out.println("after message"); return null; } @Override public String rcvMessage(String message) { System.out.println("我是第二个代理类"); System.out.println("before message"); smsService.rcvMessage(message); System.out.println("after message"); return null; } } public static void main(String[] args) { SmsService smsService = new SmsServiceImpl();//实例化一个接口实现类对象 //将实例化的接口实现类对象注入到代理类中,作为接口的实现类 SmsService smsProxy = new SmsProxy(smsService); smsService.smsSend("proxy"); System.out.println("--------------------"); smsProxy.smsSend("proxy"); smsService.rcvMessage("message"); System.out.println("--------------------"); smsProxy.rcvMessage("proxy"); //创建另一个实现类和代理类,这里使用第一个代理类反向测试,再创建属于第二个目标对象的代理类测试 SmsService smsService1 = new SmsServiceImpl1(); SmsService smsProxy1 = new SmsProxy(smsService1); SmsService smsProxy2 = new SmsProxy1(smsService1); smsService1.smsSend("proxy"); System.out.println("--------------------"); smsProxy1.smsSend("proxy"); System.out.println("--------------------"); smsProxy2.rcvMessage("proxy"); }
运行结果
send message:proxy -------------------- 我是第一个代理类 before send Message send message:proxy after send Message receive:message 我是第一个目标类,有别于第二个目标类 -------------------- 我是第一个代理类 before send Message receive:proxy 我是第一个目标类,有别于第二个目标类 after send Message send message:proxy -------------------- 我是第一个代理类 before send Message send message:proxy after send Message -------------------- 我是第二个代理类 before message receive:proxy 我是第二个目标类,有别于第一个目标类 after message
3.6.2 动态代理
相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。
从JVM层面讲,动态代理时在运行时动态生成类字节码文件,并加载到JVM中。AOP和RPC框架都依赖动态代理。
在Java中,动态代理分为JDK动态代理和CGLIB动态代理。
3.6.2.1 JDK动态代理机制
在Java动态代理机制中InvocationHandler接口和Proxy类是核心。
Proxy类中使用频率最高的方法是newProxyInstance()方法,它主要用来生成一个代理对象。
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException { ...... }
该方法一共有三个参数:
- loader:加载代理类的类加载器;
- interfaces:被代理类实现的一些接口;
- h:实现了InvocationHandler接口的对象;
实现动态代理既需要Proxy的newProxyInstance()方法生成一个代理对象,还必须实现InvocationHandler接口来自定义处理逻辑。当我们的动态代理对象调用一个方法时,该方法的调用会被转发到实现了InvocationHandler接口的实现类的invoke()方法进行调用。
public interface InvocationHandler{ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; }
invoke方法中的三个参数:
- proxy:动态生成的代理类
- method:与代理类的调用方法相对应
- args:当前method方法参数
3.6.2.2 JDK动态代理类使用步骤
- 定义一个一个接口及其实现类;
- 实现InvocationHandler接口并重写invoke()方法,在invoke()方法中调用原生方法(被代理的方法)并自定义一些处理逻辑;
- 通过Proxy类的newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)创建代理对象;
3.6.2.3 代码示例
1.发送短信接口
public interface SmsService{ String send(String msg); }
2.发送短信接口实现类
public class SmsServiceImpl implements SmsService { public String send(String msg) { System.out.println("send message:" + msg); return msg; } }
3.实现InvocationHandler接口,重写invoke()方法
public class DebugInvocationHandler implements InvocationHandler { //代理类中真实对象 private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("before method:"+method.getName()); Object res = method.invoke(target, args); System.out.println("before method:"+method.getName()); return res; } }
4.获取代理对象的工厂类
public class JDKProxyFactory { public static Object getProxy(Object target){ return Proxy.newProxyInstance(target.getClass().getClassLoader(),//目标类的类加载器 target.getClass().getInterfaces(),//代理需要实现的接口,可多个 new DebugInvocationHandler(target)//代理对象对应的自定义InvocationHandler ); } }
getProxy():获取某个类的代理对象
测试
3.6.3 CGLIB动态代理机制
3.6.3.1 简介
JDK动态代理致命的缺点是只能代理实现了接口的类。为解决这个问题,可使用CGLIB动态代理。
CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。
public interface MethodInterceptor extends Callback{ // 拦截被代理类中的方法 public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable; }
- obj : 被代理的对象(需要增强的对象)
- method : 被拦截的方法(需要增强的方法)
- args : 方法入参
- proxy : 用于调用原始方法
通过 Enhancer
类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor
中的 intercept
方法。
3.6.3.2 CGLIB动态代理实现流程
- 定义一个类
- 实现MethodInterceptor接口并重写intercept()方法,intercept()增强被代理类方法;
- 通过Enhancer类的create()方法创建代理类;
3.6.3.3 代码实现
1.导入依赖
<dependency> <groupId>cglib</groupId> <artifactId>cglib</artifactId> <version>3.3.0</version> </dependency>
2.定义一个阿里云发送短信的类
public class AliSmsService { public String send(String message){ System.out.println("send Message:"+message); return message; } }
3.自定义MethodInterceptor接口
public class DebugMethodInterceptor implements MethodInterceptor { @Override public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { System.out.println("before method:"+method.getName()); Object result = methodProxy.invokeSuper(o, objects); System.out.println("after method:"+method.getName()); return result; } }
4.获取代理类
public class CglibProxyFactory { public static Object getProxy(Class<?>clazz){ //1.创建代理增强类 Enhancer enhancer = new Enhancer(); //2.设置加载器 enhancer.setClassLoader(clazz.getClassLoader()); //3.设置被代理类 enhancer.setSuperclass(clazz); //4.设置方法拦截器 enhancer.setCallback(new DebugMethodInterceptor()); //创建代理类 return enhancer.create(); } }
5.测试
3.6.4 JDK动态代理和CGLIB动态代理
- JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
- 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显
3.6.4 动态代理实现拓展
3.7 实际应用拓展(记录自己后续项目中对反射的应用)
4.注解
4.1 简介
Annotation(注解)是Java5引入的新特性,它是一种特殊的注释。主要用于修饰类、方法或变量,提供某些信息供程序在编译或运行时使用。
@Target(ElementType.METHOD)//可以标注在方法上 @Retention(RetentionPolicy.SOURCE)//在编译器后丢弃,只存在于源文件和编译期 public @interface Override { }
注解中的元注解:
@Docimented:会被javadoc工具提取为文档;
@Target:声明在哪些目标元素之前,即注释类型的程序元素的种类
@RetentionPolicy:表示注解的生命周期;
@Inherit:表示子类可以继承该类的注解;
4.2 @Target
- ElementType.PACKAGE:该注解只能声明在一个包名前。
- ElementType.ANNOTATION_TYPE:该注解只能声明在一个注解类型前。
- ElementType.TYPE:该注解只能声明在一个类前。
- ElementType.CONSTRUCTOR:该注解只能声明在一个类的构造方法前。
- ElementType.LOCAL_VARIABLE:该注解只能声明在一个局部变量前。
- ElementType.METHOD:该注解只能声明在一个类的方法前。
- ElementType.PARAMETER:该注解只能声明在一个方法参数前。
- ElementType.FIELD:该注解只能声明在一个类的字段前。
4.3 RetentionPolicy
生命周期对应:Java源文件(.java文件)--->。class文件--->内存中字节码
RetentionPolicy有RetentionPolicy.SOURCE、RetentionPolicy.CLASS、RetentionPolicy.RUNTIME三种。
- RetentionPolicy.SOURCE表示注解只保留在源文件,当Java文件编译成class文件的时候,注解被遗弃,编译期后被丢弃。
- RetentionPolicy.CLASS表示注解被保留到class文件,但jvm加载class文件时候被遗弃,这是默认的生命周期。
- RetentionPolicy.RUNTIME表示注解不仅被保存到class文件中,jvm加载class文件之后,仍然存在。
4.4 注解解析方式
注解只有被解析后才生效,常见解析方式:
- 编译期直接扫描:编译期在编译Java代码得时候扫描对应的注解并处理,如某方法使用@Override注解,编译期在编译的时候就会检测当前方法是否重写父类对应的方法。
- 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的
@Value
、@Component
)都是通过反射来进行处理的。
4.5 实际应用拓展
4.5.1 注解如何发挥作用
注解是干啥的?
注解本身不提供作用,注解只能是被看作元数据,它不包含任何业务逻辑。注解更像是一个标签,一个声明,表面被注释的地方,将具有某种特定的逻辑。
注解常见种类
常见的注解有三大类:JDK的,自定义的,第三方的(比如框架)
注解三板斧
定义、使用、读取
定义:包括名字,能用到哪些地方,有效期,是否可以被继承
使用:定义好之后在允许的地方使用标注即可
光有前两步,没什么用,如最熟悉的@Override注解,为什么能验证重写是否有效,怎么不是验证重载?spring的@Autowired为什么是注入作用,而不是输出一句话?显然,他们在程序中做了实现,使得其注解具有各自的作用,也具有了意义,而赋予灵魂的一步就是读取
读取:让注解发挥作用,给注解注入灵魂
代码实例
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface MyBefore { //注解参数定义 String value() default ""; } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface MyCore { String value() default ""; } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface MyAfter { String value() default ""; }
public class TestAnnotations { //注解不能标注在启动类方法的方法中 @MyBefore() public void init(){ System.out.println("初始化"); } @MyAfter public void destroy(){ System.out.println("销毁"); } @MyCore public void core(){ System.out.println("核心代码"); } public static void main(String[] args) throws IllegalAccessException, InstantiationException, InvocationTargetException { Class<TestAnnotations> testClass = TestAnnotations.class; TestAnnotations test = testClass.newInstance(); //获取Test类的所有方法,并将其添加到对应集合 ArrayList<Method> myBeforeList = new ArrayList<>(); ArrayList<Method> myTestList = new ArrayList<>(); ArrayList<Method> myAfterList = new ArrayList<>(); Method[] methods = testClass.getDeclaredMethods(); for (Method method : methods) { //isAnnotationPresent()指定类型的注解是否存在此元素上 if (method.isAnnotationPresent(MyBefore.class)){ myBeforeList.add(method); continue; } if (method.isAnnotationPresent(MyCore.class)){ myTestList.add(method); continue; } if (method.isAnnotationPresent(MyAfter.class)){ myAfterList.add(method); continue; } } for (Method testMethod : myTestList) { //测试方法钱先执行标有MyBefore注解的方法 for (Method method : myBeforeList) { method.invoke(test); } //调用test方法 testMethod.invoke(test); for (Method method : myAfterList) { method.invoke(test); } } } }
运行结果
初始化
核心代码
销毁
任何注解都是上述的三板斧,后面再结合Spring的注解分析。
参考链接:
Java中的注解是如何发挥作用的? - 知乎 (zhihu.com)
5.序列化和反序列化
5.1 什么是序列化?什么是反序列化?
如果我们需要持久化对象即将Java对象保存在文件中,在网络中传输Java对象,这些场景都需要序列化。
- 序列化:将数据结构或对象转换为二进制字节流的过程;
- 反序列化:将序列化过程中所产生的二进制字节流转换为数据结果或对象的过程;
5.1.1 为什么要序列化?
当两个进程进行远程通信时,可以相互发送各种类型的数据,包括文本、图片、音频、视频等, 而这些数据都会以二进制序列的形式在网络上传送。而Java是面向对象开发,一切皆对象,如果要实现网络传输、文件存储、数据库存储、内存存储,就需要将对象转换成二进制字节流,因此需要序列化和反序列化。
5.1.2 序列化和反序列化的应用场景
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
序列化主要目的:通过网络传输对象,或将对象存储在文件、数据库、内存中。
摘自JavaGuide的一张序列化和反序列化转换的图片如下:
5.1.3 序列化协议对象TCP/IP协议哪一层?
TCP/IP四层协议:
- 应用层
- 传输层
- 网络层
- 网络接口层
在OSI七层协议中,表示层是对应用层的用户数据进行处理转换成二进制字节流。反过来就是将二进制的字节流转换成应用层的用户数据。这就是序列化和反序列化。
OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。
5.2 有些字段不想序列化怎么办?
对于不想序列化的字段,使用transient关键字修饰即可
transient
关键字的作用是:阻止实例中transient修饰的的变量序列化;当对象被反序列化时,被 transient
修饰的变量值不会被持久化和恢复。
注意:
transient
只能修饰变量,不能修饰类和方法。transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
。static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化。
5.3 常见的序列化协议有哪些?
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo(高性能推荐使用)、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性较好,但是性能较差,一般不会选择
5.4 为什么不推荐使用JDK自带的序列化
JDK自带序列化JDK序列化,只需实现Java.io.Serialiable接口即可
@AllArgsConstructor @NoArgsConstructor @Getter @Builder @ToString public class RpcRequest implements Serializable { private static final long serialVersionUID = 1905122041950251207L; private String requestId; private String interfaceName; private String methodName; private Object[] parameters; private Class<?>[] paramTypes; private RpcMessageTypeEnum rpcMessageTypeEnum; }
seriaVersionUid作用:
seriaVersionUid作用是版本控制。反序列化时,会检查seriaVersionUid是否与当前类的seriaVersionUid一致。若不一致,则会抛出InvalidClassException异常。强烈建议每个序列化类自定义seriaVersionUid。若不手动指定,那么会在编译期动态生成默认的seriaVersionUid。
上述RpcRequest代码中,seriaVersionUid不是被static修饰,为什么还是被“序列化”?
static修饰的变量是静态变量,位于方法区,本身不能被序列化。但seriaVersionUid的序列化被特殊处理,序列化时会将其序列化为二进制字节流,反序列化,解析它并做一致性判断。真实原因是它只被JVM识别,并不做序列化处理。
- 不支持跨语言调用:只支持Java语言,不支持其他语言;
- 性能差:序列化后字节数组体积较大,导致传输成本大;
- 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
6.I/O流
6.1 Java I/O流
I/O记Input/Output,数据输入到计算机内存的过程即输入,反之输出到外部存储(如数据库、文件、远程主机)的过程即输出。数据传输过程类似于水流,因此称为I/O流。IO流在Java中分为输入流和输出流,根据数据处理方式分为字节流和字符流。
Java IO流中40 多个类都是从如下 4 个抽象类基类中派生出来的:
InputStream/Reader:前者是所有字节输入流的基类,后者是所有字符输入流的基类;
OutputStream/Writer:前者是所有字节输出流的基类,后者是所有字符输出流的基类;
6.2 I/O流为什么要分为字符流和字节流?
IO流本质灵魂拷问:无论是文件读写,还是网络发送接收,信息的最小存储单位都是字节,那为什么I/O操作要分为字节流操作和字符流操作呢?
字节流操作适用于处理二进制文件和网络数据,而字符流操作适用于处理文本文件和字符数据,并提供了字符编码转换和更高级的字符处理功能。
6.3 字节流
6.3.1 字节输入流
InputStream
用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream
抽象类是所有字节输入流的父类。
InputStream
常用方法:
read()
:返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回-1
,表示文件结束。read(byte b[ ])
: 从输入流中读取一些字节存储到数组b
中。如果数组b
的长度为零,则不读取。如果没有可用字节读取,返回-1
。如果有可用字节读取,则最多读取的字节数最多等于b.length
, 返回读取的字节数。这个方法等价于read(b, 0, b.length)
。read(byte b[], int off, int len)
:在read(byte b[ ])
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字节数)。skip(long n)
:忽略输入流中的 n 个字节 ,返回实际忽略的字节数。available()
:返回输入流中可以读取的字节数。close()
:关闭输入流释放相关的系统资源。
从 Java 9 开始,InputStream
新增多个实用的方法:
readAllBytes()
:读取输入流中的所有字节,返回字节数组。readNBytes(byte[] b, int off, int len)
:阻塞直到读取len
个字节。transferTo(OutputStream out)
:将所有字节从一个输入流传递到一个输出流。
FileInputStream
是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。try { InputStream fis = new FileInputStream("input.txt"); System.out.println("Number of remaining bytes"+fis.available()); int content; long skip = fis.skip(2); System.out.println("The actual bytes are skipped:"+skip); System.out.println("the content read from file:"); while ((content = fis.read()) != -1){ System.out.println((char)content); } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { }
文件位置以及内容
测试结果
Number of remaining bytes:8 The actual bytes are skipped:2 the content read from file:xiaoku
一般不会直接单独使用 FileInputStream
,通常会配合 BufferedInputStream
(字节缓冲输入流)来使用。下面这段代码在项目中就比较常见,通过 readAllBytes()
(Java9中才有)读取输入流所有字节并将其直接赋值给一个 String
对象。
// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt")); // 读取文件的内容并复制到 String 对象中 String result = new String(bufferedInputStream.readAllBytes()); System.out.println(result);
DataInputStream
用于读取指定类型数据,不能单独使用,必须结合其它流,比如 FileInputStream
FileInputStream fileInputStream = new FileInputStream("input.txt"); //必须将fileInputStream作为构造参数才能使用 DataInputStream dataInputStream = new DataInputStream(fileInputStream); //可以读取任意具体的类型数据 dataInputStream.readBoolean(); dataInputStream.readInt(); dataInputStream.readUTF();
ObjectInputStream
用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream
用于将对象写入到输出流(序列化)。
ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data")); MyClass object = (MyClass) input.readObject(); input.close();
另外,用于序列化和反序列化的类必须实现 Serializable
接口,对象中如果有属性不想被序列化,使用 transient
修饰。
6.3.2 字节输出流
OutputStream
用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream
抽象类是所有字节输出流的父类。
OutputStream
常用方法:
write(int b)
:将特定字节写入输出流。write(byte b[ ])
: 将数组b
写入到输出流,等价于write(b, 0, b.length)
。write(byte[] b, int off, int len)
: 在write(byte b[ ])
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字节数)。flush()
:刷新此输出流并强制写出所有缓冲的输出字节。close()
:关闭输出流释放相关的系统资源。
FileOutputStream
是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。
try (FileOutputStream output = new FileOutputStream("output.txt")) { byte[] array = "JavaGuide".getBytes(); output.write(array); } catch (IOException e) { e.printStackTrace(); }
运行结果:
类似于 FileInputStream
,FileOutputStream
通常也会配合 BufferedOutputStream
(字节缓冲输出流)来使用。
FileOutputStream fileOutputStream = new FileOutputStream("output.txt"); BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)
DataOutputStream
用于写入指定类型数据,不能单独使用,必须结合其它流,比如 FileOutputStream
。
// 输出流 FileOutputStream fileOutputStream = new FileOutputStream("out.txt"); DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); // 输出任意数据类型 dataOutputStream.writeBoolean(true); dataOutputStream.writeByte(1);
ObjectOutputStream
将对象写入到输出流(ObjectOutputStream
,序列化)。
ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("file.txt") Person person = new Person("xiaoku"); output.writeObject(person);
6.4 字符流
为什么IO流区分为字节流和字符流?
- 字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时。
- 如果我们不知道编码类型就很容易出现乱码问题。
因此,I/O 流提供了一个直接操作字符的接口,方便对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。
字符流默认采用的是 Unicode
编码,我们可以通过构造方法自定义编码。IO笔试题:常用字符编码所占字节数?utf8
:英文占 1 字节,中文占 3 字节,unicode
:任何字符都占 2 个字节,gbk
:英文占 1 字节,中文占 2 字节。
6.4.1 字符输入流
Reader
用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader
抽象类是所有字符输入流的父类。
Reader
用于读取文本, InputStream
用于读取原始字节。
Reader
常用方法:
read()
: 从输入流读取一个字符。read(char[] cbuf)
: 从输入流中读取一些字符,并将它们存储到字符数组cbuf
中,等价于read(cbuf, 0, cbuf.length)
。read(char[] cbuf, int off, int len)
:在read(char[] cbuf)
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字符数)。skip(long n)
:忽略输入流中的 n 个字符 ,返回实际忽略的字符数。close()
: 关闭输入流并释放相关的系统资源。
InputStreamReader
是字节流转换为字符流的桥梁,其子类 FileReader
是基于该基础上的封装,可以直接操作字符文件。
// 字节流转换为字符流的桥梁 public class InputStreamReader extends Reader { } // 用于读取字符文件 public class FileReader extends InputStreamReader { }
FileReader代码实例
try (FileReader fileReader = new FileReader("input.txt");) { int content; long skip = fileReader.skip(3); System.out.println("The actual number of bytes skipped:" + skip); System.out.print("The content read from file:"); while ((content = fileReader.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); }
input.txt文件内容:
6.4.2 字符输出流
Writer
用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer
抽象类是所有字符输出流的父类。
Writer
常用方法:
write(int c)
: 写入单个字符。write(char[] cbuf)
:写入字符数组cbuf
,等价于write(cbuf, 0, cbuf.length)
。write(char[] cbuf, int off, int len)
:在write(char[] cbuf)
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字符数)。write(String str)
:写入字符串,等价于write(str, 0, str.length())
。write(String str, int off, int len)
:在write(String str)
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字符数)。append(CharSequence csq)
:将指定的字符序列附加到指定的Writer
对象并返回该Writer
对象。append(char c)
:将指定的字符附加到指定的Writer
对象并返回该Writer
对象。flush()
:刷新此输出流并强制写出所有缓冲的输出字符。close()
:关闭输出流释放相关的系统资源。
OutputStreamWriter
是字符流转换为字节流的桥梁,其子类 FileWriter
是基于该基础上的封装,可以直接将字符写入到文件。
// 字符流转换为字节流的桥梁 public class OutputStreamWriter extends Writer { } // 用于写入字符到文件 public class FileWriter extends OutputStreamWriter { }
FileWriter
代码示例:
try (Writer output = new FileWriter("output.txt")) { output.write("你好,我是Guide。"); } catch (IOException e) { e.printStackTrace(); }
运行结果
6.5 字节缓冲流
IO 操作很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁IO 操作,提高流的传输效率。
字节缓冲流这里采用了装饰器模式来增强 InputStream
和OutputStream
子类对象的功能。举个例子,通过 BufferedInputStream
(字节缓冲输入流)来增强 FileInputStream
的功能。
// 新建一个 BufferedInputStream 对象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
字节流和字节缓冲流的性能差别主要体现在使用两者的时候都是调用 write(int b)
和 read()
这两个一次只读取一个字节的方法。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。
BufferedInputStream
内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组。
public class BufferedInputStream extends FilterInputStream { // 内部缓冲区数组 protected volatile byte buf[]; // 缓冲区的默认大小 private static int DEFAULT_BUFFER_SIZE = 8192; // 使用默认的缓冲区大小 public BufferedInputStream(InputStream in) { this(in, DEFAULT_BUFFER_SIZE); } // 自定义缓冲区大小 public BufferedInputStream(InputStream in, int size) { super(in); if (size <= 0) { throw new IllegalArgumentException("Buffer size <= 0"); } buf = new byte[size]; } }
缓冲区的大小默认为 8192 字节,我们也可以通过 BufferedInputStream(InputStream in, int size)
这个构造方法来指定缓冲区的大小。
6.6 字符缓冲流
BufferedReader
(字符缓冲输入流)和 BufferedWriter
(字符缓冲输出流)类似于 BufferedInputStream
(字节缓冲输入流)和BufferedOutputStream
(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。
6.7 打印流
下面这段代码,我们一直都在使用:
System.out.print("Hello!");
System.out.println("Hello!");
System.out
实际是用于获取一个 PrintStream
对象,print
方法实际调用的是 PrintStream
对象的 write
方法。PrintStream
属于字节打印流,与之对应的是 PrintWriter
(字符打印流)。PrintStream
是 OutputStream
的子类,PrintWriter
是 Writer
的子类。
public class PrintStream extends FilterOutputStream implements Appendable, Closeable { } public class PrintWriter extends Writer { }
6.8 随机访问流
随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile
。RandomAccessFile
的构造方法如下,可指定 mode
(读写模式)。
// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除 public RandomAccessFile(File file, String mode) throws FileNotFoundException { this(file, mode, false); } // 私有方法 private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{ // 省略大部分代码 }
读写模式主要有下面四种:
r
: 只读模式。rw
: 读写模式rws
: 相对于rw
,rws
同步更新对“文件的内容”或“元数据”的修改到外部存储设备。rwd
: 相对于rw
,rwd
同步更新对“文件的内容”的修改到外部存储设备。
文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。
RandomAccessFile
中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 RandomAccessFile
的 seek(long pos)
方法来设置文件指针的偏移量(距文件开头 pos
个字节处)。若要获取文件指针当前的位置的话,使用 getFilePointer()
方法。
try { RandomAccessFile accessFile = new RandomAccessFile(new File("input.txt"), "rw"); System.out.println("读之前的偏移量:"+accessFile.getFilePointer()+"当前读取到的字符:" +(char)accessFile.read()+"读之后的偏移量"+accessFile.read()); accessFile.seek(6); System.out.println("读之前的偏移量:"+accessFile.getFilePointer()+"当前读取到的字符:" +(char)accessFile.read()+"读之后的偏移量"+accessFile.read()); // 从偏移量 7 的位置开始往后写入字节数据 accessFile.write(new byte[]{'H', 'I', 'J', 'K'}); // 指针当前偏移量为 0,回到起始位置 accessFile.seek(0); System.out.println("读取之前的偏移量:" + accessFile.getFilePointer() + ",当前读取到的字符" + (char) accessFile.read() + ",读取之后的偏移量:" + accessFile.getFilePointer()); } catch (Exception e) { e.printStackTrace(); }
input.txt文件中原为ABCDEFG,写入后内容变为ABCDEFGHIJK
读之前的偏移量:0, 当前读取到的字符:A读之后的偏移量1 读之前的偏移量:6, 当前读取到的字符:G 读之后的偏移量7 读取之前的偏移量:0, 当前读取到的字符A,读取之后的偏移量:1
RandomAccessFile
的 write
方法在写入对象的时候如果对应的位置已经有数据的话,会将其覆盖掉。
RandomAccessFile
比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,无需重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。
这里引用JavaGuide分片上传的代码图片: