Java 扩展知识深度解析

一、BigDecimal

1.1 ⭐底层原理

精度丢失问题

Double 是因为转换为二进制数的时候发生了截断,所以会产生精度丢失,如果涉及到金融方面的操作,建议使用没有精度丢失的 BigDecimal

其实如果没有用好 BigDecimal 其实还是会产生精度丢失,比如在阿里巴巴开发规范中就提到了我们 BigDecimal 的构造函数中传入 Double 基本类型的数据时,会有精度缺失问题,正确的使用方法应该是传入 String 对象

BigDecimal 实现原理

原理的话,主要是将原来的小数扩大为整数,其中 intCompact 存储扩大后的整数,scale 存储小数点后的位数,precision 存储值的有效位数,而不是使用浮点型的科学计数法

总结

double 产生精度丢失的根本原因是它采用 IEEE 754 的二进制浮点数表示,很多十进制小数在二进制中无法精确表示,只能近似存储,因此在运算过程中会产生精度误差。

在金融等精度敏感场景中通常使用 BigDecimal,因为 BigDecimal 使用的是十进制定点数模型,通过将小数放大为整数进行计算,内部使用 intCompact 和 scale 来表示数值,不存在二进制截断问题。

但如果使用 BigDecimal 构造函数直接传入 double,也会把 double 的不精确值带入,从而产生精度问题,因此正确的做法是使用字符串构造或 BigDecimal.valueOf 方法。


二、Integer 缓存

2.1 ⭐底层原理

享元模式

Integer 缓存这部分用到了享元模式,这部分主要是 IntegerCache 这个类完成的,一般用到了池技术的都用到了享元模式,主要是为了解决对象太多不能复用的问题

缓存范围

IntegerCache 缓存范围为 -128~127(默认范围),我们也可以自己手动设置,通过 -XX:AutoBoxCacheMax 参数调整,之所以是这个范围是由 JSL(Java Language Specification,java语言规范) 规定的。

当然还要注意一点就是 -XX:AutoBoxCacheMax 设置可以修改缓存池的最大范围,但需要大于 127 才能生效,小于等于 127 时,依然取的是默认值 127

实现机制

这的 IntegerCache 有一个静态的 Integer 数组,在类加载时就将 -128 到 127 的 Integer 对象创建了,并保存在 cache 数组中,一旦程序调用 valueOf 方法,如果变量 i 的值是在-128 到 127 之间就直接在 cache 缓存数组中去取 Integer 对象。如果超出了范围,会从堆区 new 一个 Integer 对象来存放值

自动装箱与拆箱

它自动装箱和自动拆箱使用到的方法分别为 valueOf 和 intValue,其中 valueOf 就会使用到 IntegerCache 这个静态内部类


三、深浅拷贝

3.1 简单介绍

概念区别

深拷贝是完全拷贝一个全新的对象,浅拷贝会共享引用,仅将一个对象中字段的值拷贝到另一个字段,不是全部拷贝,如果引用的对象被修改了会影响到另一个通过浅拷贝生成的对象

实现深拷贝的方法

具体的说,如果我们要实现深拷贝可以有以下两种方法:

  1. 拷贝构造器 - 通过手工的方式将类的各个成员进行赋值
  2. Cloneable接口 - 基于Object对象的clone方法

四、序列化反序列化

4.1 ⭐简单介绍

基本概念

Java序列化是指把Java对象转换为字节序列的过程;而Java反序列化是指把字节序列恢复为Java对象的过程,只有实现了Serializable或者Externalizable接口的类的对象才能被序列化为字节序列(不是则会抛出异常),Serializable 用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化

Serializable 接口

一般实现 Serializable 接口,对于不想序列化的字段加上 transient 修饰即可,且对象反序列化的时候不会调用任何构造器。

Externalizable 接口

Serializable 接口内部序列化是 JVM 自动实现的,如果我们想自定义序列化过程,就可以使用 Externalizable 这个接口来实现,这种方法的优点就是可以自定义序列化方式,不过要重写两个方法 writeExternal 和 readExternal,且对象反序列化的时候会调用该类的构造器,如果不将该类的构造函数设置为 public 会报错

Serializable 接口的特点

  1. 序列化类的属性没有实现 Serializable 那么在序列化就会报错,比如类之间组合的时候
  2. 在反序列化过程中,它的父类如果没有实现序列化接口,那么将需要提供无参构造函数来重新创建对象
  3. 一个实现 Serializable 接口的子类也是可以被序列化的
  4. 静态成员变量是不能被序列化,因为序列化是针对对象属性的,而静态成员变量是属于类的
  5. transient 标识的对象成员变量不参与序列化,一般用于我们自己手动序列化,比如 ArrayList 的 transient Object[] elementData,就是手动序列化和反序列化的
  6. Serializable 在序列化和反序列化过程中大量使用了反射,因此其过程会产生的大量的内存碎片

serialVersionUID 的作用

在进行反序列化时,JVM会把传进来的字节流中的serialVersionUID与本地实体类中的serialVersionUID进行比较,如果相同则认为是一致的,便可以进行反序列化,否则就会报序列化版本不一致的异常

之所以 JVM 规范强烈建议我们手动声明一个版本号,这个数字可以是随机的,只要固定不变就可以,是因为如果我们没有手动去写这个 serialVersionUID 常量,那么 JVM 内部会根据类结构去计算得到这个 serialVersionUID 值,在类结构发生改变时(属性增加,删除或者类型修改了)会导致 serialVersionUID 发生变化

4.2 总结

Java 序列化是将对象转换为字节序列的过程,反序列化是将字节序列恢复为对象,只有实现 Serializable 或 Externalizable 接口的类才能被序列化。

Serializable 是一个标记接口,序列化过程由 JVM 自动完成,支持 transient 关键字控制字段不参与序列化。反序列化时不会调用当前类构造器,但会调用第一个未实现 Serializable 的父类的无参构造方法。

Externalizable 提供完全自定义的序列化能力,需要实现 writeExternal 和 readExternal 方法,反序列化时会调用 public 无参构造。

serialVersionUID 用于版本校验,JVM 强烈建议手动声明,否则类结构变更会导致反序列化失败。


五、注解原理

5.1 ⭐底层原理

注解的本质

实际上我们通过反编译一个注解类,会发现注解其实就是一个继承了 Annotation 接口的接口,所有的注解类型都继承自这个普通的接口(Annotation),注解在代码运行时可以被反射读取并进行相应的操作,而如果没有使用反射或者其他检查,那么注解是没有任何真实作用的,也不会影响到程序的正常运行结果(在spring框架中加注的注解会影响到程序的运行,是因为spring内部使用反射操作了对应的注解)

反射操作注解

可以使用反射来操作注解,反射可以获取到Class对象,进而获取到Constructor、Field、Method等实例,点开源码结构发现Class、Constructor、Field、Method等均实现了AnnotatedElement接口,以上元素均可以通过反射获取该元素上标注的注解

注解的解析方式

解析一个类或者方法的注解往往有两种形式,一种是编译期直接的扫描,一种是运行期反射。

编译期的扫描指的是编译器在对 java 代码编译字节码的过程中会检测到某个类或者方法被一些注解修饰,这时它就会对于这些注解进行某些处理,典型的就是注解 @Override,一旦编译器检测到某个方法被修饰了 @Override 注解,编译器就会检查当前方法的方法签名是否真正重写了父类的某个方法

元注解

『元注解』是用于修饰注解的注解,通常用在注解的定义上,一般用于指定某个注解生命周期以及作用目标等信息,比如:

  • @Target:注解的作用目标,用于指明被修饰的注解最终可以作用的目标是谁
  • @Retention:注解的生命周期,指定了被修饰的注解的生命周期等等

预定义注解

除了上述四种元注解外,JDK 还为我们预定义了另外三种注解:

  • @Override
  • @Deprecated
  • @SuppressWarnings

注解原理总结

最后大概梳理一下注解的原理:

  1. 首先通过键值对的形式为注解属性赋值
  2. 编译器检查注解的使用范围 (将注解信息写入元素属性表 attribute)
  3. 接着当你进行反射的时候,虚拟机将所有生命周期在 RUNTIME 的注解取出来放到一个 memberValues 这个 map 中,并创建一个 AnnotationInvocationHandler 实例,把这个 map 传递给它
  4. 最后虚拟机将采用 JDK 动态代理机制生成一个目标注解的代理类(也就是通过注解增强了该方法),调用invoke方法,通过传入方法名返回注解对应的属性值

于是一个注解的实例就创建出来了,它本质上就是一个代理类,一句话概括就是,通过方法名返回注解属性值

5.2 总结

Java 注解本质上是一个继承了 Annotation 接口的接口,注解属性本质是接口中的抽象方法。注解本身不会对程序产生任何影响,只有在编译器或框架通过反射或扫描解析注解时,才会产生实际效果。

编译期注解由 javac 处理,如 @Override;运行期注解需要通过反射获取,其生命周期必须是 RUNTIME。

在运行期,JVM 会读取 class 文件中的注解信息,将注解属性解析为一个 Map,并通过 AnnotationInvocationHandler 使用 JDK 动态代理生成注解实例,最终通过方法名返回注解对应的属性值。


六、异常字节码原理

6.1 ⭐底层原理

异常体系

Java 异常由 Throwable 类开始分为 Error 和 Exception 类

异常表机制

Java 的异常是靠异常表来实现的,如果程序触发了异常,Java 虚拟机会按照序号遍历异常表,当触发的异常在这条异常处理器的监控范围内(from 和 to),且异常类型(type)与该异常处理器一致时,Java 虚拟机就会跳转到该异常处理器的起始位置(target)开始执行字节码;如果程序没有触发异常,那么虚拟机会使用 goto 指令跳过 catch 代码块,执行后面的代码

异常表详解

无论是哪种形式的异常表,我们可以知道的是,异常表中每一行就代表一个异常处理器。

如果程序触发了异常,Java 虚拟机会按照序号遍历异常表,当触发的异常在这条异常处理器的监控范围内(from 和 to),且异常类型(type)与该异常处理器一致时,Java 虚拟机就会跳转到该异常处理器的起始位置(target)开始执行字节码。

如果程序没有触发异常,那么虚拟机会使用 goto 指令跳过 catch 代码块,执行 finally 语句或者方法返回。

finally 的实现

在 JVM 中,所有异常路径(如try、catch)以及所有正常执行路径的出口都会被附加一份 finally 代码块,在异常表中也会生成多个异常处理器

如果有多个 return 的话记住以最后的 finally 的 return 为主, try 和 catch 中的 return 将会被忽略,异常也是如此,都以 finally 中的为主

6.2 总结

Java 的异常是通过字节码中的异常表来实现的。

当异常发生时,JVM 会遍历异常表,判断异常是否发生在处理器的监控范围内,并且异常类型是否匹配,匹配成功则跳转到对应的 catch 或 finally 代码块执行。

finally 并不是运行时动态插入的,而是由编译器在所有可能的退出路径上复制生成,因此 finally 一定会执行。

如果 finally 中存在 return 或 throw,会覆盖 try / catch 中的 return 或异常,因此不建议在 finally 中使用 return。


七、StringBuffer 和 StringBuilder

7.1 ⭐线程安全问题

线程安全性对比

前者是线程安全的,后者不是线程安全的,因为前者,也就是 StringBuffer 的所有公开方法都是 synchronized 修饰的,而 StringBuilder 并没有,这也是它的性能比 StringBuilder 低的原因。

toString 性能优化

StringBuffer 每次获取 toString 都会直接使用缓存区的 toStringCache 值来构造一个字符串。而 StringBuilder 则每次都需要复制一次字符数组,再构造一个字符串

可以简单的记忆为,之所以 StringBuffer 有缓冲区优化 toStringCache(返回最后一次toString的缓存值,一旦StringBuffer被修改就清除这个缓存值) 是因为它本身是同步的,性能比较低,所以才会有这样的优化

性能总结

所以在数据量比较小的时候 StringBuffer 的性能比 StringBuilder 的性能还要高