Java 基础
Java 基础
概念
Java 的特点
平台无关性
- 编写一次,运行无处不在。编译器将源码编译成字节码文件,可以在任何安装了 jvm 的系统上运行
面向对象
OOP,封装、多态、继承,以及类、抽象、对象等
封装:将对象的状态和行为封装到一起,隐藏对象内部的实现细节,外部代码通过暴露的公共方法进行访问对象的状态。
继承:子类继承父类的属性、方法,从而实现代码的重用,子类可以扩展或者重写父类的方法,is-a 关系
多态:只同一个方法在不同的对象上表现出不同的行为。主要体现在方法的重载和方法的重写上。核心是通过父类引用指向子类对象,调用的方法在运行时根据实际的对象类型进行动态绑定。
抽象:通过抽象类和接口定义对象的共同行为和属性,而不关注具体实现。抽象类和接口通常作为设计的基础,让不同的具体类进行扩展和实现。抽象类不能直接实例化;抽象类可以包含抽象和具体方法;子类必须实现抽象类的所有方法,除非子类也是抽象类。
接口:只定义抽象方法,不包含任何实现,一个类可以实现多个接口,从而实现多重继承。
类与对象:类是对象的模板,定义对象的属性、行为,类是对象的基本构造单位;对象是类的实例,通过 new 创建,对象拥有类中定义的属性和方法,通过方法操作对象的状态。
内存管理
有自己的垃圾回收机制,自动管理内存和回收不再使用的对象
核心区域是堆内存、栈内存,还有方法区和特定的内存区域
堆内存
所有对象实例和数组都存储在堆内存中
堆内存是垃圾回收机制的主要目标区域
堆内存划分为年轻代、老年代。年轻代包含新创建的对象,垃圾回收比较频繁;老年代,存储生命周期较长的对象,对象在年轻代中经过多次垃圾回收仍然存活会被移动到老年代
栈内存
存储每个线程的局部变量、方法调用、方法参数等
生命周期较短,数据随着方法调用结束而释放
线程独立,每个线程都有自己独立的栈内存,栈中数据不会被 gc 管理
方法区
- 存储类的信息,常量、静态变量、即时编译器编译后的代码等
程序计数器
- 记录当前线程执行的字节码指令的地址
垃圾回收的原理
- 定期检查哪些对象不再被引用,然后释放这些对象占用的内存。核心目标是回收无用对象的内存,防止内存泄漏。
垃圾回收的工作流程
- 标记、清除、压缩(在对象清楚后进行内存压缩,移动存货的对象以消除内存碎片,确保大块连续的内存可用)
垃圾回收算法
- 引用计数法、标记 - 清除算法、标记压缩算法、复制算法、分代收集算法
内存泄漏与内存溢出
泄露:一些对象不再使用,但仍然被引用,无法被 gc 回收
- 根据异常信息排查,使用内存分析工具,jmap、jhat、jstack、jvisualvm,分析 gc 日志
溢出:jvm 不能再分配内存,抛出异常。程序创建了太多的对象,或者存在内存泄漏。
- 常见的场景:静态集合 HashMap、ArrayList 未被适当清理;长生命周期对象引用短生命周期对象;监听器、回调未及时解除;线程池误用;自定义类加载器。
如何避免
- 避免使用不必要的全局变量和静态变量。 尽量缩短对象的生命周期,避免不必要的长时间引用。 使用合适的数据结构,避免内存过度消耗。 通过调整 JVM 的 GC 策略和参数,优化内存使用。例如,使用 G1 GC 或 ZGC 进行低延迟的垃圾回收。 调整堆内存大小参数:-Xms 和-Xmx,确保堆内存足够大以满足应用需求,但也要避免过大的堆内存导致 Full GC 时间过长。 定期监控应用的内存使用情况,及时发现内存泄漏的迹象。 使用内存分析工具,如 MAT 和 VisualVM,进行内存分析和泄漏检测。 设置合理的 GC 日志并进行分析,监控垃圾回收的效率和频率。
新特性
序列化
设计模式
I/O
反射
一种运行机制,允许程序在运行时获取类的结构信息,例如类名、方法、字段、构造函数等,并且能够动态的调用类的方法、访问属性、创建对象等
对象
创建对象的方式
使用 new MyClass obj = new MyClass();
反射机制 使用 Class 的 newInstance(),Constructor.newInstance() ClassName obj = ClassName.class.newInstance(); Constructor<?> constructor = ClassName.class.getConstructor(); ClassName obj = (ClassName) constructor.newInstance();
clone() 方法 ClassName objClone = (ClassName) obj.clone();
通过反序列化
数据类型
八大基本数据类型
byte short int long
- 分别是 1 2 4 8 个字节,一个字节 8 个比特,表示范围是 -2 的(比特 -1)到 2 的(比特 -1)-1
float double
- 4,8
char
- 2 字节
boolean
- 1
为什么用 bigDecimal 不用 double
- double 底层使用二进制浮点数表示,许多十进制小数在二进制中无法精确表示 BigDecimal 使用十进制表示法,是 Java 的一个专门用于处理高精度浮点数运算的类,通过任意精度的整数和十进制数的标度来表示数字,计算效率低。
拆箱与装箱
是基本数据类型与包装类之间相互转换的概念
集合框架例如 List、Set 只能操作对象类型;有些场景例如泛型、反射要求使用对象
性能开销。自动拆箱装箱设计对象的创建和销毁,基本数据类型直接处理内存数据。
包装类的缓存。以 Integer 为例,Java 会缓存 -128 到 127 之间的 Integer 对象
空指针异常。 Integer num = null; int value = num; // 抛出 NullPointerException
抽象类和接口
本质上是抽象与多态。都允许定义对象的共同行为,但在功能和使用上有区别。
相同点:
- 都抽象,都可包含抽象方法,也就是没有具体实现,但类或子类必须提供实现。
- 无法直接实例化对象,只能通过继承抽象类或者实现接口来创建对象。
- 都用来设计对象的通用行为,提供一种规范。
不同点:
- abstract 与 interface
- 抽象类可以包含抽象、非抽象方法,接口只能包含抽象;
- 抽象类 public private protected 修饰,接口只能包含 public static final 常量;
- 只能继承一个抽象类,可以实现多个接口
- 接口方法默认是 public
- 使用场景上,抽象类 is-a 比如 dog 是 animal 的一种,接口是 can-do 关系,例如 bird、airplane 都可以实现 flyable 接口
栈和堆
在 Java 中,堆(Heap)和栈(Stack)是两种用于管理内存的区域,它们在内存分配、管理、以及垃圾回收等方面有着不同的作用和特性。
1. 栈(Stack)
概念:
- 栈是一种后进先出(LIFO, Last In First Out)数据结构,用于存储局部变量和方法调用时的临时数据。
- 每当调用一个方法时,Java 会为该方法创建一个新的栈帧(Stack Frame),栈帧中包含该方法的局部变量、操作数栈、方法返回地址等信息。当方法执行完毕,栈帧被销毁,释放相应的内存。
特点:
- 存储的内容:栈内存主要存储:
- 局部变量(包括基本数据类型,如
int
,float
,char
等)。 - 方法参数。
- 引用类型的变量(但引用对象本身存储在堆中,引用存储在栈中)。
- 局部变量(包括基本数据类型,如
- 分配和释放:栈的内存分配和释放非常快,方法执行时分配,方法返回时自动释放。
- 线程私有:每个线程都有自己的栈内存,不同线程的栈是互不干扰的。
- 有限制:栈的内存相对较小,通常用于存储临时的短期数据。栈的大小是固定的,过深的递归调用或创建大量局部变量可能会导致栈溢出(StackOverflowError)。
例子:
public class StackExample {
public static void main(String[] args) {
int a = 5; // 存储在栈中
int b = 10; // 存储在栈中
int result = add(a, b); // add 方法执行时,a 和 b 都存储在栈中
}
public static int add(int x, int y) {
int sum = x + y; // sum 存储在栈中
return sum;
}
}
局部变量 a
, b
, x
, y
, 和 sum
都存储在栈内存中,方法 add
执行完毕后,栈帧被释放,内存自动回收。
2. 堆(Heap)
概念:
- 堆是用于动态分配对象的内存区域,所有在 Java 中通过
new
关键字创建的对象都存储在堆内存中。堆内存比栈大得多,而且具有较长的生命周期。 - 在 Java 中,堆内存是由 Java 的垃圾回收机制(Garbage Collector)管理的,垃圾回收器会定期清理那些不再被引用的对象。
特点:
- 存储的内容:堆内存存储的是所有对象(不管是普通对象还是数组对象)。即使在栈中声明了对象引用,但引用指向的对象实际上存储在堆中。
- 分配和释放:对象的内存分配由程序在运行时决定,存储在堆中。堆中的对象生命周期长短不一,可能跨越多个方法调用。对象的释放由垃圾回收器负责,而不是程序员手动管理。
- 线程共享:堆内存是线程共享的,所有线程都可以访问堆中的对象。这意味着需要考虑线程安全问题(如同步机制等)。
- 灵活但复杂:由于堆内存的动态分配和垃圾回收机制,它比栈内存的管理要复杂得多。
例子:
public class HeapExample {
public static void main(String[] args) {
// 在堆上创建了一个对象,并将引用存储在栈上
Person person = new Person("John", 30);
}
}
class Person {
String name;
int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
Person
对象是通过 new
关键字在堆上创建的,person
是一个引用,它存储在栈中,但引用的对象本身位于堆中。当对象不再被引用时,垃圾回收器将清理它。
3. 堆和栈的区别
特点 | 栈(Stack) | 堆(Heap) |
---|---|---|
内存分配方式 | 静态内存分配,方法调用时分配,调用结束时释放 | 动态内存分配,通过 new 操作符创建,垃圾回收释放 |
存储内容 | 局部变量、方法参数、引用 | 所有通过 new 创建的对象 |
访问速度 | 速度较快 | 速度较慢(由于动态分配和垃圾回收) |
管理方式 | 由系统自动管理,方法结束时自动释放 | 由垃圾回收器(GC)管理 |
线程安全 | 每个线程有自己的栈,线程间互不干扰 | 堆是线程共享的,因此需要考虑线程安全 |
内存大小 | 相对较小(通常 1 MB 到 8 MB) | 相对较大(可以达到 GB 级别) |
生命周期 | 生命周期短,随方法调用结束即释放 | 生命周期长,跨越方法调用,直到对象不再被引用 |
4. 堆和栈的配合
在 Java 中,栈和堆是相辅相成的:
局部变量和引用存储在栈中,而引用的对象存储在堆中。例如:
java String str = new String("Hello");
在这个例子中,str
是一个引用,存储在栈中,而"Hello"
这个字符串对象则存储在堆中。栈用于方法调用和局部变量的快速访问,方法结束后栈帧会自动释放,不再占用内存。而堆用于存储对象和实例,它们可以在方法结束后继续存在,直到不再被引用,才会由垃圾回收器清理。
为什么栈比堆访问快
内存分配方式不同:
- 栈:栈内存是按顺序连续分配的。每当一个方法被调用时,栈顶指针(stack pointer)会移动来分配新的局部变量和栈帧,当方法返回时,指针向下移动,直接释放内存。这种内存管理方式非常简单高效,因此栈的分配和释放速度极快,通常只需要调整栈指针的移动位置即可。
- 堆:堆内存的分配是动态的,内存可以在程序运行时任意分配和释放。堆中的内存位置并不是连续的,因此需要使用更加复杂的内存管理机制来找到合适的内存块进行分配。这使得堆内存分配和回收的速度远比栈慢。
缓存局部性:
- 栈内存由于是连续分配的,它的局部性非常好,也就是说,当程序访问栈中的一个变量时,紧接着可能会访问到相邻的变量,这样可以有效利用 CPU 缓存。栈的这种顺序性使得 CPU 的缓存机制更容易发挥作用,从而加快访问速度。
- 堆内存由于动态分配且不连续,可能存在大量的内存碎片,导致访问时无法有效利用 CPU 的缓存,从而降低访问速度。
管理开销:
- 栈是由系统自动管理的,分配和释放不需要程序员干预,操作简单且固定,开销非常小。
- 堆则需要程序员手动分配内存(如通过
new
操作符)或依赖垃圾回收器来管理对象的生命周期,这涉及额外的开销。
堆是如何实现的?
堆内存的实现相对复杂,涉及到内存的分配、回收、垃圾回收等机制。
1. 内存分配策略:
堆的内存分配策略是动态的,也就是说程序在运行时,会根据需要动态地分配和释放内存。常见的堆内存分配策略有以下几种:
空闲列表(Free List): 堆内存最常见的实现方式是使用一个空闲列表来记录哪些内存块是空闲的,哪些已经被分配。每当程序需要内存时,堆管理器会遍历空闲列表,找到一个合适大小的内存块进行分配。
空闲列表的管理可以分为以下几种策略:
- 首次适应算法(First Fit):从空闲列表的头部开始寻找第一个大小足够的块进行分配。
- 最佳适应算法(Best Fit):遍历整个空闲列表,找到最小的、但能满足需求的内存块,减少内存浪费。
- 最差适应算法(Worst Fit):从空闲列表中选择最大的内存块进行分配,减少碎片化。
分块(Binning): 在分块策略中,堆管理器会将空闲的内存块按大小分为不同的“桶”(Bin),当需要分配时,直接从合适的“桶”中取出所需的内存。这种方法提高了内存分配的速度,减少了遍历空闲列表的开销。
2. 内存碎片化:
由于堆中的内存分配是动态的,内存块可能会分布在不连续的区域,导致内存碎片化问题。内存碎片可以分为两种:
- 外部碎片:由于多次分配和释放内存,堆中空闲的内存块分散在不同区域,尽管总的空闲内存足够大,但由于不连续,无法分配大块内存。
- 内部碎片:分配的内存块大于实际需要的内存,导致的浪费。
为了解决碎片化问题,堆管理器会使用一些技术,如内存压缩(Compaction)或垃圾回收机制来整理碎片。
3. 垃圾回收机制(Garbage Collection, GC):
Java 的堆内存中,最重要的内存管理机制就是垃圾回收(GC)。堆中的对象并不是立即释放的,而是由 GC 定期扫描堆,找到那些不再被引用的对象并进行回收。Java 的 GC 机制自动管理内存的回收,开发者无需手动释放对象内存。
- 标记-清除(Mark and Sweep):GC 会先“标记”出哪些对象是活跃的,哪些是垃圾对象。然后通过“清除”操作回收这些垃圾对象所占用的内存。
- 标记-整理(Mark-Compact):在清除垃圾对象的同时,将存活对象移到堆的一端,从而减少碎片化,提高后续内存分配的效率。
- 分代垃圾回收(Generational GC):Java 堆分为新生代、老年代等区域。新生代中的对象通常是生命周期较短的临时对象,而老年代中的对象生命周期较长。GC 在新生代和老年代分别使用不同的回收策略,以提高效率。
4. 堆的分代机制:
Java 的垃圾回收机制通常会将堆分为三个区域:
- 新生代(Young Generation):用于存放新创建的对象。新生代又分为三个区域:
- Eden 区:大部分新对象会首先被分配到这个区域。
- Survivor 区:当 Eden 区的对象经过一次 GC 仍然存活时,会移动到 Survivor 区。
- 老年代(Old Generation):如果一个对象在新生代经过多次 GC 仍然存活,那么它将被移动到老年代,老年代存放生命周期较长的对象。
- 永久代(PermGen)或元空间(Metaspace,Java 8 后引入):用于存放类的元数据(方法、字段等信息),以及常量池等。
分代机制的核心思想是:大多数对象的生命周期很短,所以在新生代频繁回收,而对于生命周期长的对象,减少它们的回收频率,以提高效率。
5. 堆的实现及优化:
由于堆内存分配的复杂性,堆的实现有很多优化策略,例如:
- 线程局部堆(Thread-Local Heap):为每个线程分配一块私有的堆区域,减少线程间的竞争,提高并发性能。
- 对象池(Object Pooling):对于生命周期相似、频繁创建和销毁的对象(如连接对象),可以使用对象池来重用这些对象,减少频繁的堆内存分配。
静态变量有什么作用?
静态变量也就是被 static
关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
静态变量是通过类名来访问的,例如StaticVariableExample.staticVar
(如果被 private
关键字修饰就无法这样访问了)。
通常情况下,静态变量会被 final
关键字修饰成为常量。
java
public class StaticVariableExample {
public static int staticVar = 0; // 静态变量
public static final int constantVar = 0; // 常量
}
静态方法为什么不能调用非静态成员?
- 静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
- 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
面向对象
封装
封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。
继承
不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。
关于继承如下 3 点:
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
多态
多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。
多态的特点:
- 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
- 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
- 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。
深拷贝、浅拷贝
深拷贝:复制对象的同时,将对象内部的所有引用类型字段的内容也复制一份,而不是共享引用。深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
浅拷贝:只复制对象的引用,而不复制对象本身的数据。如果被复制的对象包含对其他对象的引用,那么只会复制这些引用,而不复制所指向的实际内容。
实现深拷贝的三种方式:
- 实现 Cloneable 接口重写 clone()
- 使用序列化和反序列化。通过对象序列化为字节流,再从字节流反序列化为对象。要求对象及其引用类型字段都是先 Serializable 接口
- 手动递归复制。
Object
== 与 equals
==
对于基本数据类型,比较值相等
对于引用类型,比如对象,比较两个对象是否指向同一个内存地址
equals
是 Object 的方法,默认情况下与==等效,即比较地址
但许多类,如 String、Integer 都重写了方法,用于比较内容是否相等
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // 输出 true,因为 s1 和 s2 指向同一个常量池中的字符串
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // 输出 false,s1 和 s2 是不同的对象
System.out.println(s1.equals(s2)); // 输出 true,比较的是字符串的内容
hashcode()作用
hashCode()
的作用是获取哈希码(int
整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
- 如果两个对象的
hashCode
值相等,那这两个对象不一定相等(哈希碰撞)。 - 如果两个对象的
hashCode
值相等并且equals()
方法也返回true
,我们才认为这两个对象相等。 - 如果两个对象的
hashCode
值不相等,我们就可以直接认为这两个对象不相等。
### 为什么重写 equals() 时必须重写 hashCode() 方法?
因为两个相等的对象的 hashCode
值必须是相等。也就是说如果 equals
方法判断两个对象是相等的,那这两个对象的 hashCode
值也要相等。
如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals
方法判断是相等的两个对象,hashCode
值却不相等。
String
StringBuffer 和 StringBuild 区别
都用来动态修改字符串的内容,从而不必每次都生成新的对象。 都继承自 AbstractStringBuilder
类
StringBuffer:
线程安全,方法是同步的,适用于多线程环境。
StringBuilder:
非线程安全,方法未同步,性能较高,适用于单线程环境。
StringBuffer 所有方法都 synchronized 同步。多个线程可以安全地访问同一个 StringBuffer 对象,而不会导致数据不一致或异常。
StringBuilder 非线程安全,性能更高,适用于单线程场景。
异常
Exception 和 Error 有什么区别?
Exception
:程序本身可以处理的异常,可以通过catch
来进行捕获。Exception
又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。Error
:Error
属于程序无法处理的错误 。例如 Java 虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
Checked Exception 和 Unchecked Exception 有什么区别?
Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch
或者throws
关键字处理的话,就没办法通过编译。
除了RuntimeException
及其子类以外,其他的Exception
类及其子类都属于受检查异常 。常见的受检查异常有:IO 相关的异常、ClassNotFoundException
、SQLException
...。
Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
RuntimeException
及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):
NullPointerException
(空指针错误)IllegalArgumentException
(参数错误比如方法入参类型错误)NumberFormatException
(字符串转换为数字格式错误,IllegalArgumentException
的子类)ArrayIndexOutOfBoundsException
(数组越界错误)ClassCastException
(类型转换错误)ArithmeticException
(算术错误)SecurityException
(安全错误比如权限不够)UnsupportedOperationException
(不支持的操作错误比如重复创建同一用户)- ......
Throwable 类常用方法有哪些?
String getMessage()
: 返回异常发生时的详细信息String toString()
: 返回异常发生时的简要描述String getLocalizedMessage()
: 返回异常对象的本地化信息。使用Throwable
的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()
返回的结果相同void printStackTrace()
: 在控制台上打印Throwable
对象封装的异常信息
try-catch-finally 如何使用?
try
块:用于捕获异常。其后可接零个或多个catch
块,如果没有catch
块,则必须跟一个finally
块。catch
块:用于处理 try 捕获到的异常。finally
块:无论是否捕获或处理异常,finally
块里的语句都会被执行。当在try
块或catch
块中遇到return
语句时,finally
语句块将在方法返回之前被执行。
泛型
允许类、接口和方法在定义时使用一个或多个类型参数,这些类型参数在使用时可以被指定为具体的类型。
编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如 ArrayList<Person> persons = new ArrayList<Person>()
这行代码就指明了该 ArrayList
对象只能传入 Person
对象,如果传入其他类型的对象就会报错。
反射
赋予了我们在运行时分析类以及执行类中方法的能力。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
可以让我们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利。
在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。
注解
可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。注解本质是一个继承了Annotation
的特殊接口
原理
- 本质是继承了 Annotation 的特殊接口,实现类是 Java 运行时生成的动态代理类
作用域
- 类、方法、字段
注解的解析方法有哪几种
注解只有被解析之后才会生效,常见的解析方法有两种:
- 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用
@Override
注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 - 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的
@Value
、@Component
)都是通过反射来进行处理的。
SPI
Java 的 SPI(Service Provider Interface,服务提供者接口)机制是 Java 提供的一种服务发现机制,允许开发人员可以动态地加载不同实现的服务。
SPI 是 Java 平台的插件机制,特别适用于框架和库中,用于发现并加载第三方实现。
SPI 的主要功能是让一个接口能够有多个实现,并且这些实现可以在运行时动态切换或加载。
SPI 的核心是 服务接口 和 服务提供者 两个概念:
- 服务接口(Service Interface):由服务提供方定义的标准接口,消费者通过该接口来访问服务。
- 服务提供者(Service Provider):实现服务接口的类,由不同的实现方提供。
// SPI 的开发者需要实现接口,API 的使用者可以动态选择实现
public interface PaymentService {
void pay(int amount);
}
public class AliPayService implements PaymentService {
@Override
public void pay(int amount) {
System.out.println("Using AliPay to pay " + amount);
}
}
public class WeChatPayService implements PaymentService {
@Override
public void pay(int amount) {
System.out.println("Using WeChatPay to pay " + amount);
}
}
// SPI 使用者通过 ServiceLoader 动态加载实现
ServiceLoader<PaymentService> loader = ServiceLoader.load(PaymentService.class);
for (PaymentService service : loader) {
service.pay(100); // 运行时决定是用 AliPay 还是 WeChatPay
}
SPI 的使用场景
SPI 机制广泛应用于各种需要动态加载实现的场景,尤其是在框架和中间件中,例如:
- 数据库驱动加载:JDBC 使用了 SPI 机制加载不同的数据库驱动程序。
- 日志框架:如 SLF4J 可以在不同的实现(logback、log4j)之间切换。
- Java Web 框架:Servlet 容器可以通过 SPI 机制加载不同的应用程序组件。
- 其他插件机制:许多插件框架或库依赖 SPI 来加载自定义插件或模块。
SPI 的优缺点
优点:
- 扩展性强:允许增加新实现而无需修改客户端代码。
- 解耦合:接口和实现解耦,服务消费者只需要依赖服务接口,不关心具体实现。
- 动态加载:可以在运行时动态加载和更换服务实现。
缺点:
- 性能开销:
ServiceLoader
会逐个遍历并实例化实现类,加载实现类较多时,可能会带来性能开销。 - 错误隐蔽:实现类必须有无参构造方法,并且需要在
META-INF/services
文件中正确配置。否则,加载时容易出现找不到实现类或实例化失败的问题。 - 难以控制顺序:
ServiceLoader
加载实现类的顺序不一定稳定,有时需要手动管理加载顺序。
Java SPI 常见问题
- 实现类必须提供无参构造方法:
ServiceLoader
使用无参构造方法实例化类,因此实现类必须提供无参构造方法。 - 在多模块项目中的配置问题:在复杂的多模块项目中,多个模块可能会提供不同的实现,此时需要小心配置
META-INF/services
文件,避免冲突。 - 不支持指定实现加载:
ServiceLoader
会加载所有实现,无法指定加载某一个具体实现,这在某些场景下会带来不便。
Java SPI 的改进
Java 标准的 SPI 机制虽然简单易用,但也存在一定的局限性。为了解决这些问题,可以考虑以下改进方式:
- 自定义 ServiceLoader:可以实现一个自定义的
ServiceLoader
,来控制加载顺序或支持筛选实现。 - 借助第三方库:例如 Guava 提供了
ServiceLoader
的扩展功能,Spring 也提供了丰富的依赖注入机制,可以替代 SPI 实现动态加载的功能。 - 支持延迟加载:在不需要时不加载实现类,优化启动性能。
序列化和反序列化
什么是序列化?什么是反序列化?
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
简单来说:
- 序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式
- 反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程
场景
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。
如果有些字段不想进行序列化怎么办
对于不想进行序列化的变量,使用 transient
关键字修饰。
transient
关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient
修饰的变量值不会被持久化和恢复。
关于 transient
还有几点注意:
transient
只能修饰变量,不能修饰类和方法。transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
。static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化。
常见序列化协议有哪些?
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
为什么不推荐使用 JDK 自带的序列化?
我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:
- 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
- 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
- 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读:应用安全:JAVA 反序列化漏洞之殇 。
I/O
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream
/Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream
/Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
为什么要分为字节流和字符流
1. 字节流和字符流的本质区别
字节流(Byte Stream):主要用于处理原始的字节数据,即 8 位的二进制数据,通常用于非文本数据的传输,如图片、音频、视频等。字节流不考虑字符编码,因此更适合无格式的二进制数据。
字符流(Character Stream):主要用于处理文本数据,支持不同的字符编码。字符流在处理数据时,自动将字节数据按照字符编码进行转换,以适应多种文本编码的需求。字符流的设计是为了方便处理人类可读的文本信息。
2. 处理数据类型的需求不同
在编程中,I/O 操作的类型主要分为两类:字节数据(如文件的原始数据)和字符数据(如文本文件内容)。如果 Java 只有一种流,处理文本文件和二进制文件会变得困难,分为字节流和字符流后,便于根据数据的实际需求选择合适的流。
3. 字符编码的影响
字符流的设计主要是为了解决文本数据在不同编码方式下的兼容问题。例如,一个字母在 ASCII 编码下是 1 个字节,而在 UTF-16 编码下可能占 2 个字节。这种差异导致在处理文本文件时,若仅使用字节流,会涉及手动处理编码转换问题。
字符流则自动处理了编码和解码,可以直接处理多种编码的文本数据,无需开发人员手动干预。通过字符流可以直接读取或写入多字节字符,而不用担心底层的编码转换。
Java 中字节流和字符流的层次结构
字节流:以
InputStream
和OutputStream
为基类,常见实现有FileInputStream
、FileOutputStream
、BufferedInputStream
、BufferedOutputStream
等。字符流:以
Reader
和Writer
为基类,常见实现有FileReader
、FileWriter
、BufferedReader
、BufferedWriter
等。
语法糖
语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。
举个例子,Java 中的 for-each
就是一个常用的语法糖,其原理其实就是基于普通的 for 循环和迭代器。
不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看com.sun.tools.javac.main.JavaCompiler
的源码,你会发现在compile()
中有一个步骤就是调用desugar()
,这个方法就是负责解语法糖的实现的。
Java 中有哪些常见的语法糖?
Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。
关于这些语法糖的详细解读,请看这篇文章 Java 语法糖详解 。