异常

异常指不期而至的各种状况,如:文件找不到、网络连接失败、非法参数等。异常是一个事件,它发生在程序运行期间,干扰了正常的指令流程。Java通 过API中Throwable类的众多子类描述各种不同的异常。因而,Java异常都是对象,是Throwable子类的实例,描述了出现在一段编码中的 错误条件。当条件生成时,错误将引发异常。

1.介绍Java的异常体系?

参考链接:https://www.pdai.tech/md/java/basic/java-basic-x-exception.html

Java异常类层次结构图:

Throwable 是 Java 语言中所有错误与异常的超类。

Throwable 包含两个子类:Error(错误)和 Exception(异常),它们通常用于指示发生了异常情况。

Throwable 包含了其线程创建时线程执行堆栈的快照,它提供了 printStackTrace() 等接口用于获取堆栈跟踪数据等信息。

2.Exception 和 Error 有什么区别?

参考链接:https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Java%e6%a0%b8%e5%bf%83%e6%8a%80%e6%9c%af%e9%9d%a2%e8%af%95%e7%b2%be%e8%ae%b2/02%20Exception%e5%92%8cError%e6%9c%89%e4%bb%80%e4%b9%88%e5%8c%ba%e5%88%ab%ef%bc%9f.md

Exception和Error都是继承了Throwable类,在Java中只有Throwable类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

Exception和Error体现了Java平台设计者对不同异常情况的分类。Exception是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。

Error是指在正常情况下,不大可能出现的情况,绝大部分的Error都会导致程序(比如JVM自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如OutOfMemoryError之类,都是Error的子类。

Exception又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。前面我介绍的不可查的Error,是Throwable不是Exception。

不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译期强制要求。


使用说明:

第一,尽量不要捕获类似Exception这样的通用异常,而是应该捕获特定异常

第二,不要生吞(swallow)异常。这是异常处理中要特别注意的事情,因为很可能会导致非常难以诊断的诡异情况。尤其是对于分布式系统,如果发生异常,但是无法找到堆栈轨迹(stacktrace),这纯属是为诊断设置障碍。所以,最好使用产品日志,详细地输出到日志系统里。

原则:Throw Early, Catch Late

1
2
3
4
5
6
public void readPreferences(String filename) {
Objects. requireNonNull(filename);
//...perform other operations...
InputStream in = new FileInputStream(filename);
//...read the preferences file...
}

另外,我们从性能角度来审视一下Java的异常处理机制,这里有两个可能会相对昂贵的地方:

  • try-catch代码段会产生额外的性能开销,或者换个角度说,它往往会影响JVM对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的try包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else、switch)要低效。
  • Java每实例化一个Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个开销可就不能被忽略了。

因此,当我们的服务出现反应变慢、吞吐量下降的时候,检查发生最频繁的Exception也是一种思路。关于诊断后台变慢的问题,我会在后面的Java性能基础模块中系统探讨。

3.如何自定义异常?

习惯上,定义一个异常类应包含两个构造函数,一个无参构造函数和一个带有详细描述信息的构造函数(Throwable 的 toString 方法会打印这些详细信息,调试时很有用), 比如这里用到的自定义MyException:

1
2
3
4
5
6
7
public class MyException extends Exception {
public MyException(){ }
public MyException(String msg){
super(msg);
}
// ...
}

父类 Exception 会将这个 msg 存储为异常的详细信息,这个消息后续可以通过 getMessage() 方法获取到。

1
2
3
4
5
try {
throw new MyException("发生了一个错误");
} catch (MyException e) {
System.out.println(e.getMessage()); // 输出:发生了一个错误
}

Exception 类的这个构造函数还会正确处理异常追踪信息(stack trace),这对于调试非常重要。

4.如何进行异常捕获?

参考链接:https://pdai.tech/md/java/basic/java-basic-x-exception.html

try-catch-finally

同一个 catch 也可以捕获多种类型异常,用 | 隔开

1
2
3
4
5
6
7
8
9
private static void readFile(String filePath) {
try {
// code
} catch (FileNotFoundException | UnknownHostException e) {
// handle FileNotFoundException or UnknownHostException
} catch (IOException e){
// handle IOException
}
}

finally:无论如何都会强制执行(包括try中出现return语句的情况)

try-finally可用在不需要捕获异常的代码,可以保证资源在使用后被关闭。例如IO流中执行完相应操作后,关闭相应资源;使用Lock对象保证线程同步,通过finally可以保证锁会被释放;数据库连接代码时,关闭连接操作等等。

1
2
3
4
5
6
7
//以Lock加锁为例,演示try-finally
ReentrantLock lock = new ReentrantLock();
try {
//需要加锁的代码
} finally {
lock.unlock(); //保证锁一定被释放
}

泛型

什么是泛型?有什么作用?

参考链接:https://dunwu.github.io/javacore/pages/33a820/#%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E6%B3%9B%E5%9E%8B

泛型就是”参数化类型”。

List 容器没有指定存储数据类型,这种情况下,可以向 List 添加任意类型数据。此时编译器不会做类型检查,而是默默的将所有数据都转为 Object

假设,最初我们希望向 List 存储的是整形数据,假设,某个家伙不小心存入了其他数据类型。当你试图从容器中取整形数据时,由于 List 当成 Object 类型来存储,你不得不使用类型强制转换。在运行时,才会发现 List 中数据不存储一致的问题,这就为程序运行带来了很大的风险(ClassCastException警告⚠️)。这就是类型安全问题,而泛型的出现解决了类型安全问题。

泛型的作用:

1.类型安全

2.避免类型转换

3.代码复用

1
2
3
4
//打印任意类型的变量
private static <T> void print(T content){
System.out.println(content);
}

4.泛型边界约束

1
2
3
4
5
6
7
8
// 限制泛型类型必须是 Number 或其子类
public <T extends Number> double sum(T[] array) {
double sum = 0.0;
for (T element : array) {
sum += element.doubleValue();
}
return sum;
}

这里的约束也可以是接口。但是当泛型约束为接口时,同样使用extends申明。当同时有类型和接口约束时,类型约束在前,两者用&连接。

泛型原理是什么?

简单来说,泛型的核心原理是类型擦除:在编译期间,编译器会将泛型类型转换为原始类型(Object 或边界类型),同时插入必要的类型检查和类型转换代码,以保证类型安全。

  1. 类型擦除原理

    1
    2
    3
    4
    5
    6
    7
    // 代码中的写法
    List<String> list = new ArrayList<String>();
    list.add("hello");

    // 编译后实际的样子(字节码中)
    List list = new ArrayList();
    list.add(Object);
  2. 类型擦除的规则:

  • 无限制类型擦除:

    1
    2
    3
    4
    5
    public class Box<T> {
    private T data;
    // 编译后变成
    private Object data;
    }
  • 有限制类型擦除:

    1
    2
    3
    4
    5
    public class Box<T extends Number> {
    private T data;
    // 编译后变成
    private Number data;
    }
  1. 泛型擦除后的类型转换:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class Box<T> {
    private T data;

    public T getData() {
    return data;
    }

    // 编译后实际的样子:
    public Object getData() {
    return data;
    }

    // 使用时编译器会自动插入类型转换代码
    String str = box.getData(); // 编译器自动转换为:
    String str = (String) box.getData();
    }
  2. 桥接方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class IntegerBox extends Box<Integer> {
    @Override
    public Integer getData() {
    return super.getData();
    }

    // 编译器会自动生成桥接方法
    // 因为父类擦除后的方法是 Object getData()
    public Object getData() {
    return this.getData(); // 调用上面的方法
    }
    }
  3. 类型擦除的局限性:

1
2
3
4
5
6
7
8
9
10
11
public class Example<T> {
// 不能创建泛型数组
T[] array = new T[10]; // 编译错误

// 不能用instanceof判断泛型类型
if (obj instanceof T) { } // 编译错误

// 不能创建泛型异常类
class MyException<T> extends Exception { } // 编译错误

}

理解泛型原理的关键点:

  1. 泛型信息只存在于编译阶段,运行时已经被擦除
  2. 编译器负责保证类型安全和相应的类型转换
  3. 泛型擦除让Java保持了向后兼容性
  4. 桥接方法解决了多态性问题

这就是为什么会有一些看似奇怪的限制,比如不能创建泛型数组 - 因为运行时类型信息已经被擦除了。理解这些原理有助于我们更好地使用泛型,并避免一些常见陷阱。

另外,泛型中不存在继承关系,例如List<String>List<Object>不存在继承关系。

反射

什么是反射?反射作用是什么?

参考视频:https://www.bilibili.com/video/BV1K4421w7zP/?spm_id_from=333.999.0.0&vd_source=894a223b85ae44e61e16dcd1a7356db0

JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。

反射(Reflection)是Java提供的一种机制,允许程序在运行时获取类的所有信息(包括成员变量、方法、构造器等),并且能够操作类或对象的内部属性。反射真正的价值在于处理编译时未知的数据类型,从而写出更加具有通用性的代码。

注:泛型是防止错误输入的,只在编译阶段有效。当进行方法的反射进而绕过编译时,程序在运行时可能出现意想不到的错误(也就是之前提到的类型安全问题)


要使用反射,我们必须首先了解Class对象。

Class对象是由JVM在加载类时自动创建的,通过访问该类和该类的方法能够使我们“逆向”获取普通(Object)类中的成员属性和方法,包括私有的。

为了获取Class对象,你可以直接使用类字面常量。例如User.Class ;

然而,这个方法只能获取静态的类,但是一般反射的使用场景都是动态的或类名不可知的。因此,最佳实践是运用Class下的forName方法创建Class:(通过该方法获取时,初始化会被立即执行,静态代码块等会被立即触发)

1
2
3
4
5
// 普通方式创建对象
Student student = new Student();

// 使用反射方式创建对象
Class<?> clazz = Class.forName("com.example.Student");

Class中的操作类的方法大致如下几种,下图已把它们进行分类:

操作对象时,我们一般调用构造器函数去创建对象。以下是视频中的实践:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//注意:访问私有字段或方法时,需要设置setAccessible函数
public class Main {
public static void main(String[] args) throws ClassNotFoundException{
Class<?> clazz = Class.forName("org.albertshen.User");
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
Object obj = constructor.newInstance("Albert", 20);
Field field = clazz.getDeclaredField("age");
field.setAccessible(true);
System.out.println(field.get(obj));
Method method = clazz.getDeclaredMethod("myPrivateMethod");
method.setAccessible(true);
method.invoke(obj);
}
}

在实践中,我们用配置类存储信息,用Container注册对象和获取服务实例,并在主函数中调用能够获取服务实例的函数即可。


反射的主要作用:

  • 在运行时检查类的信息
  • 在运行时构造对象
  • 在运行时调用对象的方法
  • 在运行时修改属性值
  • 实现通用的数组操作代码
  • 支持注解的实现
  • 实现框架的扩展性(如Spring IOC)

动态代理有几种实现方式?

反射机制是 Java 语言提供的一种基础功能,赋予程序在运行时自省(introspect,官方用语)的能力。通过反射我们可以直接操作类或者对象,比如获取某个对象的类定义,获取类声明的属性和方法,调用方法或者构造对象,甚至可以运行时修改类定义。

动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类似机制做到的,比如用来包装 RPC 调用、面向切面的编程(AOP)。

实现动态代理的方式很多,比如 JDK 自身提供的动态代理,就是主要利用了上面提到的反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似 ASM、cglib(基于 ASM)、Javassist 等。

Java 动态代理基于经典代理模式,引入了一个 InvocationHandlerInvocationHandler 负责统一管理所有的方法调用。


JDK动态代理是Java原生支持的代理方式,它要求被代理的类必须实现至少一个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MyDynamicProxy {
public static void main (String[] args) {
HelloImpl hello = new HelloImpl();
MyInvocationHandler handler = new MyInvocationHandler(hello);
// 构造代码实例
Hello proxyHello = (Hello) Proxy.newProxyInstance(HelloImpl.class.getClassLoader(), HelloImpl.class.getInterfaces(), handler);
// 调用代理方法
proxyHello.sayHello();
}
}
interface Hello {
void sayHello();
}
class HelloImpl implements Hello {
@Override
public void sayHello() {
System.out.println("Hello World");
}
}
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("Invoking sayHello");
Object result = method.invoke(target, args);
return result;
}
}

上面的 JDK Proxy 例子,非常简单地实现了动态代理的构建和代理操作。首先,实现对应的 InvocationHandler;然后,以接口 Hello 为纽带,为被调用目标构建代理对象,进而应用程序就可以使用代理对象间接运行调用目标的逻辑,代理为应用插入额外逻辑(这里是 println)提供了便利的入口

从 API 设计和实现的角度,这种实现仍然有局限性,因为它是以接口为中心的,相当于添加了一种对于被调用者没有太大意义的限制。我们实例化的是 Proxy 对象,而不是真正的被调用类型,这在实践中还是可能带来各种不便和能力退化。

CGLIB是一个强大的代码生成库,它可以代理没有实现接口的普通类。如果我们选择 cglib 方式,你会发现对接口的依赖被克服了。

省流:创建一个继承原POJO类+实现方法拦截器的intercept函数。好处是性能高,坏处是不能搞定final和static。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 1. 首先定义一个普通类
public class UserService {
public void addUser(String username) {
System.out.println("添加用户: " + username);
}

public void deleteUser(String username) {
System.out.println("删除用户: " + username);
}
}

// 2. 创建方法拦截器
class LogMethodInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy)
throws Throwable {
// 调用原方法
Object result = proxy.invokeSuper(obj, args);
return result;
}
}

// 3. 使用示例
public class CglibTest {
public static void main(String[] args) {
// 创建Enhancer对象
Enhancer enhancer = new Enhancer();
// 设置父类
enhancer.setSuperclass(UserService.class);
// 设置回调
enhancer.setCallback(new LogMethodInterceptor());

// 创建代理对象
UserService proxyService = (UserService) enhancer.create();

// 使用代理对象
proxyService.addUser("张三");
proxyService.deleteUser("张三");
}
}

JDK 动态代理和 CGLIB 动态代理有什么区别?

注解

什么是注解?作用是什么?

参考链接:https://dunwu.github.io/javacore/pages/ecc011/#%E6%B3%A8%E8%A7%A3%E7%9A%84%E5%BD%A2%E5%BC%8F

从本质上来说,注解是一种标签,其实质上可以视为一种特殊的注释,如果没有解析它的代码,它并不比普通注释强。

解析一个注解往往有两种形式:

  • 编译期直接的扫描 - 编译器的扫描指的是编译器在对 java 代码编译字节码的过程中会检测到某个类或者方法被一些注解修饰,这时它就会对于这些注解进行某些处理。这种情况只适用于 JDK 内置的注解类。
  • 运行期的反射 - 如果要自定义注解,Java 编译器无法识别并处理这个注解,它只能根据该注解的作用范围来选择是否编译进字节码文件。如果要处理注解,必须利用反射技术,识别该注解以及它所携带的信息,然后做相应的处理。

注解的书写方式:

注解有许多用途:

  • 编译器信息 - 编译器可以使用注解来检测错误或抑制警告。
  • 编译时和部署时的处理 - 程序可以处理注解信息以生成代码,XML 文件等。
  • 运行时处理 - 可以在运行时检查某些注解并处理。

作为 Java 程序员,多多少少都曾经历过被各种配置文件(xml、properties)支配的恐惧。过多的配置文件会使得项目难以维护。个人认为,使用注解以减少配置文件或代码,是注解最大的用处

1
2
3
4
5
6
7
8
9
10
11
// 没有注解时的配置方式
<bean id="userService" class="com.example.UserService">
<property name="userDao" ref="userDao"/>
</bean>

// 使用注解后
@Service
public class UserService {
@Autowired
private UserDao userDao;
}

JDK 中内置了以下注解:

  • @Override用于表明被修饰方法覆写了父类的方法。
  • @Deprecated用于标明被修饰的类或类成员、类方法已经废弃、过时,不建议使用。
  • @SuppressWarnnings用于关闭对类、方法、成员编译时产生的特定警告。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@SuppressWarnings({"uncheck", "deprecation"})
public class InternalAnnotationDemo {

/**
* @SuppressWarnings 标记消除当前类的告警信息
*/
@SuppressWarnings({"deprecation"})
static class A {
public void method1() {
System.out.println("call method1");
}

/**
* @Deprecated 标记当前方法为废弃方法,不建议使用
*/
@Deprecated
public void method2() {
System.out.println("call method2");
}
}

/**
* @Deprecated 标记当前类为废弃类,不建议使用
*/
@Deprecated
static class B extends A {
/**
* @Override 标记显示指明当前方法覆写了父类或接口的方法
*/
@Override
public void method1() { }
}

public static void main(String[] args) {
A obj = new B();
obj.method1();
obj.method2();
}
}

  • @SafeVarargs(JDK7 引入)告诉编译器,在可变长参数中的泛型是类型安全的。可变长参数是使用数组存储的,而数组和泛型不能很好的混合使用。该注解可以用于构造方法、staticfinal 方法。
  • @FunctionalInterface(JDK8 引入)用于指示被修饰的接口是函数式接口。

怎么自定义注解?

元注解:注解的注解。元注解的作用就是用于定义其它的注解

Java 中提供了以下元注解类型:

  • @Retention指明了注解的保留级别。
  • @Target指定注解可以修饰的元素类型
  • @Documented表示无论何时使用指定的注解,都应使用 Javadoc(默认情况下,注释不包含在 Javadoc 中)。
  • @Inherited(JDK8 引入)表示注解类型可以被继承(默认情况下不是这样)
  • @Repeatable(JDK8 引入)表示注解可以重复使用。

常用前两个进行自定义注释。

自定义注释的规范如下:

现在我们进行举例:我需要一个注解的名字叫RunThreeTimes,作用在成员方法上,用途是当有这个注解时函数执行三次。注解的默认参数是3,但是自己也可以传入更大的值。

①:定义注解

1
2
3
4
5
@Retention(RetentionPolicy.RUNTIME)  // 运行时生效
@Target(ElementType.METHOD) // 作用在方法上
public @interface RunThreeTimes {
int times() default 3; // 默认执行3次,可以传入更大的值
}

②:使用注解

1
2
3
4
5
6
7
8
9
10
11
public class TestService {
@RunThreeTimes
public void test1() {
System.out.println("test1执行");
}

@RunThreeTimes(times = 5)
public void test2() {
System.out.println("test2执行");
}
}

③:通过反射实现注解处理器,测试运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class AnnotationRunner {
public static void runMethod(Object obj, String methodName) throws Exception {
Method method = obj.getClass().getDeclaredMethod(methodName);

// 判断是否有注解
if (method.isAnnotationPresent(RunThreeTimes.class)) {
// 获取注解
RunThreeTimes annotation = method.getAnnotation(RunThreeTimes.class);
// 获取执行次数
int times = annotation.times();

// 执行指定次数
for (int i = 0; i < times; i++) {
method.invoke(obj);
}
} else {
// 没有注解时只执行一次
method.invoke(obj);
}
}
}

public class Test {
public static void main(String[] args) throws Exception {
TestService service = new TestService();
AnnotationRunner.runMethod(service, "test1"); // 将执行3次
AnnotationRunner.runMethod(service, "test2"); // 将执行5次
}
}

当然,此处也可以通过AOP进行操作,但是就一个测试类没必要。

SPI

SPI是什么?有什么好处?

参考链接:https://dunwu.github.io/javacore/pages/496a7e/#spi-%E7%AE%80%E4%BB%8B

图片来源与视频参考:https://www.bilibili.com/video/BV1RY4y1v7mN/?spm_id_from=333.788&vd_source=894a223b85ae44e61e16dcd1a7356db0

SPI 全称 Service Provider Interface,是 Java 提供的,旨在由第三方实现或扩展的 API,它是一种用于动态加载服务的机制。

SPI 提供了一种组件发现和注册的方式,可以用于实现各种插件,或者灵活替换框架所使用的组件,实现面向接口编程。

Java 中 SPI 机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是 解耦

Java SPI 有四个要素:

  • SPI 接口:为服务提供者实现类约定的的接口或抽象类。
  • SPI 实现类(ServiceProvider):实际提供服务的实现类。
  • SPI 配置:Java SPI 机制约定的配置文件,提供查找服务实现类的逻辑。配置文件必须置于 META-INF/services 目录中,并且,文件名应与服务提供者接口的完全限定名保持一致。文件中的每一行都有一个实现服务类的详细信息,同样是服务提供者类的完全限定名称。
  • **ServiceLoader**:Java SPI 的核心类,用于加载 SPI 实现类。 ServiceLoader 中有各种实用方法来获取特定实现、迭代它们或重新加载服务。一般使用load()方法直接加载服务

举例:

①:定义Service接口(此处为InternetService接口与connectInternet方法)

②:实现Service Provider类

③:确认配置文件无误

④:启用ServiceLoader加载服务。


SPI应用:

1.JDBC

2.SpringBoot自动配置

序列化与I/O

参考链接:https://dunwu.github.io/javacore/pages/b165ad/#unix-i-o-%E6%A8%A1%E5%9E%8B

这篇真心无敌!🤗🤗🤗🤗

什么是序列化?什么是反序列化?

  • 序列化(serialize):序列化是将对象转换为二进制数据。
  • 反序列化(deserialize):反序列化是将二进制数据转换为对象。

序列化用途

  • 序列化可以将对象的字节序列持久化——保存在内存、文件、数据库中。
  • 在网络上传送对象的字节序列。
  • RMI(远程方法调用)

Java是怎么实现序列化的?

Java 通过 ObjectOutputStream 的 writeObject() 方法实现序列化

通过 ObjectInputStream 的 readObject() 方法实现反序列化

序列化过程中会不断加入特殊分隔符用于反序列化时截断

序列化的数据包含:头部数据(协议版本)、类元数据(类名、签名等)、属性数据(属性名、类型、值)

但是不建议使用JDK 自带序列化。原因之一是容易被攻击。

Java 官网安全编码指导方针中说明:“对不信任数据的反序列化,从本质上来说是危险的,应该予以避免”。可见 Java 序列化是不安全的。

我们知道对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。

这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。

对于需要长时间进行反序列化的对象,不需要执行任何代码,也可以发起一次攻击。攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。

原因之二是,序列化后的流太大以及性能较差。

常见的序列化协议有哪些?

二进制序列化:

  • Protobuf (Google开发,高效,支持多语言)
  • Thrift (Apache开发,支持多语言)
  • Hessian (性能好,格式紧凑)
  • Kryo (专门针对 Java 的高性能序列化)
  • FST (对 JDK 序列化的改进)

JSON 序列化:

  • Jackson (Spring 默认)
  • Gson (Google 开发)
  • Fastjson (阿里开发,性能最好)

实践中,哪些类需要实现序列化?

  1. 网络传输场景
  • 在分布式系统中,对象需要在网络上传输
  • 使用 RPC(远程过程调用)框架时的传输对象
  • Web 服务中的数据传输对象(DTO)
  • Socket 通信时传输的对象
  1. 数据持久化场景
  • 需要将对象保存到文件中
  • 需要将对象存储到数据库的 BLOB 字段
  • 需要将对象保存到缓存中(如 Redis)
  • Session 会话信息的对象存储
  1. 进程间通信场景
  • Android 中的 Intent 传递对象
  • 不同 JVM 进程间传递对象
  • 使用消息队列传递对象
  1. 框架要求场景
  • Spring 中的 Session 作用域的 Bean
  • Hibernate 等 ORM 框架的实体类
  • Web 容器中需要序列化的 Bean
  • Cache 框架中需要缓存的对象
  1. 需要深拷贝的场景
  • 当需要实现对象的深拷贝时,可以先将对象序列化,再反序列化得到一个新的对象副本

需要注意的是:

  1. 实现 Serializable 接口的类,其所有属性也必须是可序列化的
  2. 如果父类实现了 Serializable,则子类自动具有可序列化能力
  3. 建议显式声明 serialVersionUID,以控制序列化版本
  4. 对于不需要序列化的字段,可以使用 transient 关键字修饰

举个例子:

1
2
3
4
5
6
7
public class User implements Serializable {
private static final long serialVersionUID = 1L;

private String username; // 需要序列化
private transient String password; // 敏感信息不需要序列化
private Address address; // Address 类也需要实现 Serializable
}

在实现了 Serializable 接口的类的对象中,会生成一个 serialVersionUID 的版本号,这个版本号有什么用呢?它会在反序列化过程中来验证序列化对象是否加载了反序列化的类,如果是具有相同类名的不同版本号的类,在反序列化中是无法获取对象的。

BIO/NIO/AIO 有什么区别?

所谓的I/O,就是计算机内存与外部设备之间拷贝数据的过程。由于 CPU 访问内存的速度远远高于外部设备,因此 CPU 是先把外部设备的数据读到内存里,然后再进行处理。

对于一个网络 I/O 通信过程,比如网络数据读取,会涉及两个对象,一个是调用这个 I/O 操作的用户线程,另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间,用户线程不能直接访问内核空间。

当用户线程发起 I/O 操作后,网络数据读取操作会经历两个步骤:

  • 用户线程等待内核将数据从网卡拷贝到内核空间。
  • 内核将数据从内核空间拷贝到用户空间。

各种 I/O 模型的区别就是:它们实现这两个步骤的方式是不一样的。


一个形象的比喻:

  • BIO = 排队打饭,需要一直排队等待(同步阻塞)
  • NIO = 餐厅叫号,可以先去干其他事(同步非阻塞)
  • AIO = 外卖送餐,送到了自然会通知你(异步)

BIO(blocking IO) 即阻塞 IO。指的主要是传统的 java.io 包,它基于流模型实现。

BIO 简介

java.io 包提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。

很多时候,人们也把 java.net 下面提供的部分网络 API,比如 SocketServerSocketHttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。

BIO 的优点是代码比较简单、直观;缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。

BIO 的性能缺陷

BIO 会阻塞进程,不适合高并发场景

采用 BIO 的服务端,通常由一个独立的 Acceptor 线程负责监听客户端连接。服务端一般在while(true) 循环中调用 accept() 方法等待客户端的连接请求,一旦接收到一个连接请求,就可以建立 Socket,并基于这个 Socket 进行读写操作。此时,不能再接收其他客户端连接请求,只能等待当前连接的操作执行完成。

如果要让 BIO 通信模型 能够同时处理多个客户端请求,就必须使用多线程(主要原因是socket.accept()socket.read()socket.write() 涉及的三个主要函数都是同步阻塞的),但会造成不必要的线程开销。不过可以通过 线程池机制 改善,线程池还可以让线程的创建和回收成本相对较低。

即使可以用线程池略微优化,但是会消耗宝贵的线程资源,并且在百万级并发场景下也撑不住。如果并发访问量增加会导致线程数急剧膨胀可能会导致线程堆栈溢出、创建新线程失败等问题,最终导致进程宕机或者僵死,不能对外提供服务。

什么是NIO?如何用NIO实现多路复用?

NIO(non-blocking IO) 即非阻塞 IO。指的是 Java 1.4 中引入的 java.nio 包。

为了解决 BIO 的性能问题, Java 1.4 中引入的 java.nio 包。NIO 优化了内存复制以及阻塞导致的严重性能问题。

java.nio 包提供了 ChannelSelectorBuffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。

NIO 有哪些性能优化点呢?

使用缓冲区优化读写流使用缓冲区优化读写流

NIO 与传统 I/O 不同,它是基于块(Block)的,它以块为基本单位处理数据。在 NIO 中,最为重要的两个组件是缓冲区(Buffer)和通道(Channel)。

Buffer 是一块连续的内存块,是 NIO 读写数据的缓冲。**Buffer 可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据。**Channel 表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口。

使用 DirectBuffer 减少内存复制

NIO 还提供了一个可以直接访问物理内存的类 DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存。

数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而 DirectBuffer 则是直接将步骤简化为从内核空间复制到外部设备,减少了数据拷贝。

这里拓展一点,由于 DirectBuffer 申请的是非 JVM 的物理内存,所以创建和销毁的代价很高。DirectBuffer 申请的内存并不是直接由 JVM 负责垃圾回收,但在 DirectBuffer 包装类被回收时,会通过 Java 引用机制来释放该内存块。

优化 I/O,避免阻塞

传统 I/O 的数据读写是在用户空间和内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。

NIO 的 Channel 有自己的处理器,可以完成内核空间和磁盘之间的 I/O 操作。在 NIO 中,我们读取和写入数据都要通过 Channel,由于 Channel 是双向的,所以读、写可以同时进行。

I/0多路复用是什么?

它允许一个线程或进程同时处理多个 I/0 操作。并能够在任何一个 I/0 操作准备就绪时通知程序进行处理,而不会因等待某个I/0 操作完成而阻塞程序执行。

select 和 epoll 有什么区别?

IO 多路复用技术演进

  • select (1983年引入)
  • poll (1986年引入)
  • epoll (2002年引入,Linux 2.5.44首次引入)

区别:

  1. 工作原理不同
  • select:采用轮询方式,每次都会线性扫描整个 fd_set 集合,找出就绪的文件描述符
  • epoll:采用回调方式,内核维护了一个事件表,通过事件驱动机制,当 fd 就绪时,立即回调函数通知
  1. 数据结构差异
  • select:使用固定大小的 bitmap 来表示文件描述符集合,最大支持 1024 个
  • epoll:使用红黑树来管理文件描述符,支持的数量受系统内存限制
  1. 效率对比
  • select:
    • 需要拷贝 fd_set 到内核态,开销大
    • 每次调用都需要重新设置所有要监听的 fd
    • 随着 fd 数量增加,性能下降明显
  • epoll:
    • 通过 mmap 实现内核与用户空间的内存共享,无需拷贝
    • 只有第一次需要注册 fd,之后直接使用
    • 性能不会随 fd 数量增加而线性下降
  1. 应用场景
  • select:适合连接数较少且连接都很活跃的场景
  • epoll:适合连接数多但活跃连接少的场景(如 Web 服务器)
  1. 系统支持
  • select:几乎所有平台都支持,兼容性好
  • epoll:Linux 特有,在 2.6 以上内核支持

示意代码比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
// select 示例
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
select(sockfd + 1, &readfds, NULL, NULL, &timeout);

// epoll 示例
int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
epoll_wait(epfd, events, maxevents, timeout);

性能对比:

  • 当连接数少于 1000,select 和 epoll 差异不大
  • 当连接数超过 1000,epoll 的性能优势明显
  • 当连接数达到 10000+,select 的性能严重下降,而 epoll 仍然保持高性能

这就是为什么现代高性能网络服务器(如 Nginx)都采用 epoll 机制的原因。