JAVA的数据结构
以下是一些常见的 Java 数据结构:
数组(Arrays)
数组(Arrays)是一种基本的数据结构,可以存储固定大小的相同类型的元素。
int[] array = new int[5];
- 特点: 固定大小,存储相同类型的元素。
- 优点: 随机访问元素效率高。
- 缺点: 大小固定,插入和删除元素相对较慢。
列表(Lists)
Java 提供了多种列表实现,如 ArrayList 和 LinkedList。
List<String> arrayList = new ArrayList<>();
List<Integer> linkedList = new LinkedList<>();
ArrayList:
- 特点: 动态数组,可变大小。
- 优点: 高效的随机访问和快速尾部插入。
- 缺点: 中间插入和删除相对较慢。
LinkedList:
- 特点: 双向链表,元素之间通过指针连接。
- 优点: 插入和删除元素高效,迭代器性能好。
- 缺点: 随机访问相对较慢。
集合(Sets)
集合(Sets)用于存储不重复的元素,常见的实现有 HashSet 和 TreeSet。
Set<String> hashSet = new HashSet<>();
Set<Integer> treeSet = new TreeSet<>();
HashSet:
- 特点: 无序集合,基于HashMap实现。
- 优点: 高效的查找和插入操作。
- 缺点: 不保证顺序。
TreeSet:
- 特点: TreeSet 是有序集合,底层基于红黑树实现,不允许重复元素。
- 优点: 提供自动排序功能,适用于需要按顺序存储元素的场景。
- 缺点: 性能相对较差,不允许插入 null 元素。
映射(Maps)
映射(Maps)用于存储键值对,常见的实现有 HashMap 和 TreeMap。
Map<String, Integer> hashMap = new HashMap<>();
Map<String, Integer> treeMap = new TreeMap<>();
HashMap:
- 特点: 基于哈希表实现的键值对存储结构。
- 优点: 高效的查找、插入和删除操作。
- 缺点: 无序,不保证顺序。
TreeMap:
- 特点: 基于红黑树实现的有序键值对存储结构。
- 优点: 有序,支持按照键的顺序遍历。
- 缺点: 插入和删除相对较慢。
栈(Stack)
栈(Stack)是一种线性数据结构,它按照后进先出(Last In, First Out,LIFO)的原则管理元素。在栈中,新元素被添加到栈的顶部,而只能从栈的顶部移除元素。这就意味着最后添加的元素是第一个被移除的。
Stack<Integer> stack = new Stack<>();
Stack 类:
- 特点: 代表一个栈,通常按照后进先出(LIFO)的顺序操作元素。
队列(Queue)
队列(Queue)遵循先进先出(FIFO)原则,常见的实现有 LinkedList 和 PriorityQueue。
Queue<String> queue = new LinkedList<>();
Queue 接口:
- 特点: 代表一个队列,通常按照先进先出(FIFO)的顺序操作元素。
- 实现类: LinkedList, PriorityQueue, ArrayDeque。
堆(Heap)
堆(Heap)优先队列的基础,可以实现最大堆和最小堆。
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
树(Trees)
Java 提供了 TreeNode 类型,可以用于构建二叉树等数据结构。
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
图(Graphs)
图的表示通常需要自定义数据结构或使用图库,Java 没有内建的图类。
以上介绍的只是 Java 中一些常见的数据结构,实际上还有很多其他的数据结构和算法可以根据具体问题选择使用。
其他一些说明
以下这些类是传统遗留的,在 Java2 中引入了一种新的框架-集合框架(Collection),我们后面再讨论。
枚举(Enumeration)
枚举(Enumeration)接口虽然它本身不属于数据结构,但它在其他数据结构的范畴里应用很广。 枚举(The Enumeration)接口定义了一种从数据结构中取回连续元素的方式。
例如,枚举定义了一个叫nextElement 的方法,该方法用来得到一个包含多元素的数据结构的下一个元素。
关于枚举接口的更多信息,请参见枚举(Enumeration)。
位集合(BitSet)
位集合类实现了一组可以单独设置和清除的位或标志。
该类在处理一组布尔值的时候非常有用,你只需要给每个值赋值一"位",然后对位进行适当的设置或清除,就可以对布尔值进行操作了。
关于该类的更多信息,请参见位集合(BitSet)。
向量(Vector)
向量(Vector)类和传统数组非常相似,但是Vector的大小能根据需要动态的变化。
和数组一样,Vector对象的元素也能通过索引访问。
使用Vector类最主要的好处就是在创建对象的时候不必给对象指定大小,它的大小会根据需要动态的变化。
关于该类的更多信息,请参见向量(Vector)
栈(Stack)
栈(Stack)实现了一个后进先出(LIFO)的数据结构。
你可以把栈理解为对象的垂直分布的栈,当你添加一个新元素时,就将新元素放在其他元素的顶部。
当你从栈中取元素的时候,就从栈顶取一个元素。换句话说,最后进栈的元素最先被取出。
关于该类的更多信息,请参见栈(Stack)。
字典(Dictionary)已弃用(deprecated)
字典(Dictionary) 类是一个抽象类,它定义了键映射到值的数据结构。
当你想要通过特定的键而不是整数索引来访问数据的时候,这时候应该使用 Dictionary。
由于 Dictionary 类是抽象类,所以它只提供了键映射到值的数据结构,而没有提供特定的实现。
关于该类的更多信息,请参见字典( Dictionary)。
Dictionary 类在较新的 Java 版本中已经被弃用(deprecated),推荐使用 Map 接口及其实现类,如 HashMap、TreeMap 等,来代替 Dictionary。
Map 接口及其实现类 可以参考:Java 集合框架。
哈希表(Hashtable)
Hashtable类提供了一种在用户定义键结构的基础上来组织数据的手段。
例如,在地址列表的哈希表中,你可以根据邮政编码作为键来存储和排序数据,而不是通过人名。
哈希表键的具体含义完全取决于哈希表的使用情景和它包含的数据。
关于该类的更多信息,请参见哈希表(HashTable)。
属性(Properties)
Properties 继承于 Hashtable.Properties 类表示了一个持久的属性集.属性列表中每个键及其对应值都是一个字符串。
Properties 类被许多Java类使用。例如,在获取环境变量时它就作为System.getProperties()方法的返回值。
关于该类的更多信息,请参见属性(Properties)。
数据类型
基本数据类型和包装类
-
用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
-
存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被
static修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。 -
占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
-
默认值:成员变量包装类型不赋值就是
null,而基本类型有默认值且不是null。 -
比较方式:对于基本数据类型来说,
==比较的是值。对于包装数据类型来说,==比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用equals()方法。
注意:基本数据类型存放在栈中是一个常见的误区! 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆/方法区/元空间中。
包装类型缓存机制
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 TRUE or FALSE。
对于 Integer,可以通过 JVM 参数 -XX:AutoBoxCacheMax=<size> 修改缓存上限,但不能修改下限 -128。实际使用时,并不建议设置过大的值,避免浪费内存,甚至是 OOM。
对于Byte,Short,Long ,Character 没有类似 -XX:AutoBoxCacheMax 参数可以修改,因此缓存范围是固定的,无法通过 JVM 参数调整。Boolean 则直接返回预定义的 TRUE 和 FALSE 实例,没有缓存范围的概念。
Integer 缓存源码:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
private static class IntegerCache {
static final int low = -128;
static final int high;
static {
// high value may be configured by property
int h = 127;
}
}
Character 缓存源码:
public static Character valueOf(char c) {
if (c <= 127) { // must cache
return CharacterCache.cache[(int)c];
}
return new Character(c);
}
private static class CharacterCache {
private CharacterCache(){}
static final Character cache[] = new Character[127 + 1];
static {
for (int i = 0; i < cache.length; i++)
cache[i] = new Character((char)i);
}
}
如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。
两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true
Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22);// 输出 false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false
下面我们来看一个问题:下面的代码的输出结果是 true 还是 false 呢?
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);
Integer i1=40 这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是缓存中的对象。而Integer i2 = new Integer(40) 会直接创建新的对象。
因此,答案是 false 。你答对了吗?
记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。
自动拆装箱
- 装箱:将基本类型用它们对应的引用类型包装起来;
- 拆箱:将包装类型转换为基本数据类型;
Integer i = 10; //装箱
int n = i; //拆箱
装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。
因此,
Integer i = 10等价于Integer i = Integer.valueOf(10)int n = i等价于int n = i.intValue();
注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。
自增和自减
-
前缀形式(例如
++a或--a):先自增/自减变量的值,然后再使用该变量,例如,b = ++a先将a增加 1,然后把增加后的值赋给b。 -
后缀形式(例如
a++或a--):先使用变量的当前值,然后再自增/自减变量的值。例如,b = a++先将a的当前值赋给b,然后再将a增加 1。
口诀:符号在前就先加/减,符号在后就后加/减。
int a = 9;
int b = a++; // a=10 b=9
int c = ++a; // a=11 c=11
int d = c--; // d=11 c=10
int e = --d; // e=10 d=10
字符型常量和字符串常量的区别
- 形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。
- 含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。
- 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节。
⚠️ 注意 char 在 Java 中占两个字节。
Number类
在Java中,Number是一个抽象类,位于java.lang包中。它是所有数值包装类(如 Integer、Double、Float、Long、Short、Byte 等)的父类。
Math类
// abs 方法 返回绝对值
int absInt = Math.abs(-10); // 10
double absDouble = Math.abs(-5.5); // 5.5
// max 或 min 方法 返回两个参数中的较大值或返回两个参数中的较小值
int maxInt = Math.max(10, 20); // 20
double maxDouble = Math.max(5.5, 2.3); // 5.5
int minInt = Math.min(10, 20); // 10
double minDouble = Math.min(5.5, 2.3); // 2.3
// ceil 方法 向上取整
double ceiling = Math.ceil(4.2); // 5.0
// floor 方法 向下取整
double floor = Math.floor(4.8); // 4.0
// round 方法 返回四舍五入后的整数值
long rounded = Math.round(4.5); // 5
int roundedInt = Math.round(4.4f); // 4
// random 方法 返回一个 double 类型的伪随机数,范围在 [0.0, 1.0) 之间
double randomValue = Math.random();
// 示例:生成 1 到 100 之间的随机整数
int randomInt = (int)(Math.random() * 100) + 1;
面向对象
== 和 equals
==:判断两个对象的地址是否相等。即判断两个对象是不是同一个对象(基本数据类型==比较的是值;引用数据类型==比较的是内存地址) equals:它的作用也是判断两个对象是否相等。但它一般有两种情况:
- 情况 1:类没有覆盖 equals() ⽅法。则通过 equals() ⽐该类的两个对象时,等价于通过==比较这两个对象。
- 情况 2:类覆盖了 equals() ⽅法。⼀般,我们都覆盖 equals() ⽅法来比较两个对象的内容是否相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)。
举个例子
说明:
String 中的equals方法是被重写过的,因为Object的equals方法是比较对象的内存地址,而String的equals方法比较的是对象的值。当创建String类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个String对象。
hashCode 与 equals
为什么要有hashCode
hashCode() 作用是获取哈希码,也称散列码;它实际上返回一个int整数。这个哈希码作用是确定该对象在哈希表中的索引位置。Java中任何类中都包含hashCode()方法。 散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利⽤到了散列码!(可以快速找到所需要的对象)
我们以“ HashSet 如何检查重复”为例⼦来说明为什么要有 hashCode?
当你把对象加⼊ HashSet 时, HashSet 会先计算对象的 hashcode 值来判断对象加⼊的位置,同时也会与其他已经加⼊的对象的 hashcode 值作⽐,如果没有相符的 hashcode, HashSet会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调⽤ equals() ⽅ 法来检查 hashcode 相等的对象是否真的相同。如果两者相同, HashSet 就不会让其加⼊操作成 功。如果不同的话,就会重新散列到其他位置。这样我们就⼤⼤减少了 equals 的次数,相应就⼤⼤提⾼了执⾏速度。
为什么重写equals时必须重写hashCode方法
如果两个对象相等,则 hashcode ⼀定也是相同的。两个对象相等,对两个对象分别调⽤ equals⽅法都返回 true。但是,两个对象有相同的 hashcode 值,它们也不⼀定是相等的 。因此,equals ⽅法被覆盖过,则 hashCode ⽅法也必须被覆盖。
为什么两个对象有相同的hashCode值,它们也不一定是相等的
因为 hashCode() 所使⽤的杂凑算法也许刚好会让多个对象传回相同的杂凑值。越糟糕的杂凑算法越容易碰撞,但这也与数据值域分布的特性有关(所谓碰撞也就是指的是不同的对象得到相同的 hashCode 。
我们刚刚也提到了 HashSet ,如果 HashSet 在对⽐的时候,同样的 hashcode 有多个对象,它会使⽤ equals() 来判断是否真的相同。也就是说 hashcode 只是⽤来缩⼩查找成本。
深拷贝和浅拷贝
- 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
- 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
String
String类是一种不可变的对象,独立于java基本数据类型存在。String被设计为final的,表示String对象一经创建后,它的值就不能被修改,任何对String值进行修改的方法就是重写创建一个字符串。
1. 字符串构造方法
// ================== 字符串构造方法 ==================
// 1.使用字符串字面量构造
String s1 = "hello world"; //直接使用字符串字面量进行构造
// 2.通过new关键字创建一个String对象
String s2 = new String("hello world");//通过new关键字创建对象
// 3.使用字符数组进行构造
char[] str = {'h','e','l','l','o','w','o','r','l','d'};
String s3 = new String(str); //使用字符数组进行构造
2. 字符串查找方法
// ================== 字符串查找方法 ==================
// char charAt( int index) 返回index位置上字符
String str1 = "hello world";
char c1 = str1.charAt(1); //e
// int indexOf( int ch) 返回ch第一次出现的位置,没有返回-1
String str2 = "hello world";
int l = str2.indexOf("l"); //2
// int indexOf(int ch, int fromIndex) 从fromIndex位置开始找ch第一次出现的位置,没有返回-1
String str3 = "hello world";
int n = str3.indexOf('o', 6); //7
System.out.println(n);
// int indexOf(String str) 返回str第一次出现的位置,没有返回-1
String str4 = "hello world";
int n = str4.indexOf("wor"); //6
3. 字符串转换方法
// ================== 字符串转换方法 ==================
// 1.数值和字符串转化
// 数值转换成字符串,用String.valueOf( )进行转换
String s1 = String.valueOf(123456);
String s2 = String.valueOf(13.14521);
// 数字字符串转换成数字,用 Interger.valueOf( )进行转换
int n= Integer.valueOf("1234");
double m= Double.valueOf("12.13");
// 2.字母的大小写转换
// 字母大小写转换我们使用String.toUpperCase----小写转大写 和 String.toLowerCase----大写转小写
String upperCase = "hello".toUpperCase();
String lowerCase = "HELLO".toLowerCase();
// 3.字符串转数组
// 将一个字符串转换成一个数组,使用String.toCharArray( )方法进行转换
char[] ch = "hello".toCharArray();
for (char c : ch) {
System.out.println(c);
}
// 数组转字符串
char[] chars = {'h','e','l','l','o'};
// 方式一 new String
String ss1 = new String(chars);
// 方式二 String.valueOf
String ss2 = String.valueOf(chars);
// 4.格式化字符串
// 使用String.format( )方法进行字符串的格式化
String formatString = String.format("%d-%d-%d", 2024, 10,30); //2024-10-30
4. 字符串比较方法 当我们比较基本数据类型的对象的时候,使用 == 可以进行比较,但是对于我们的String类型(引用类型)来说,使用 == 进行比较,比较的是引用中的地址:
// ================== 字符串比较方法 ==================
String s1 = new String("Hello World");
String s2 = new String("Hello World");
System.out.println(s1 == s2); //false
// equal 方法
// equals( )方法的比较方式:按照字典序比较--->比较的是字符大小的顺序
System.out.println(s1.equals(s2)); //true
// compareTo 方法
// compare To( )方法的比较方法:按照字典序进行比较 ----> compare To( )方法与equals( )方法的区别:equals( )方法的返回值是 boolean 类型的值,而 compare To( )方法返回的是 int 类型的值。
// 1. 先按照字典次序大小比较,如果出现不等的字符,直接返回这两个字符的大小差值。
// 2. 如果前k个字符相等(k为两个字符长度最小值),返回值两个字符串长度差值。
System.out.println(s1.compareTo(s2)); //0
String s3 = new String("abc");
String s4 = new String("ab");
String s5 = new String("abc");
String s6 = new String("abcdef");
System.out.println(s3.compareTo(s4)); //1
System.out.println(s3.compareTo(s5)); //0
System.out.println(s3.compareTo(s6)); //-3
5. 字符串替换方法
// ================== 字符串替换方法 ==================
String s = new String("hello");
// replace( )方法能够将一个字符串中指定的字符替换成一个新指定的字符
String s1 = s.replace('l', 'x');
// replaceFirst( )方法能够将字符串中指定的第一个字符串替换成新指定的字符串
String s2 = s.replaceFirst('l', "x");
// replaceAll( )方法能将字符串中所有指定的字符串全部替换成一个新指定的字符串
String s3 = s.replaceAll('l', 'xy');
6. 字符串拆分
// ================== 字符串拆分方法 ==================
String str = new String("hello world");
// String[] split( )方法能够将一个字符串按照指定的字符为分割线,分割成若干个子字符串
String[] str1 = str.split(" ");
for (String s : str1) {
System.out.println(s);
}
// String[] split(String regex, int limit)方法能够将字符串按照指定的格式,分割成 limit 个子字符串
String[] str2 = str.split(" ", 2);
for (String s : str2) {
System.out.println(s);
}
7. 字符串截取
// ================== 字符串截取方法 ==================
String s1 = new String("hello world");
// String substring(int beginIndex)
// 将字符串从下标为 beginIndex 的位置开始截取到字符串结尾
s1.substring(2); //llo world
//String substring(int beginIndex, int endIndex)
//将字符串从下标为 beginIndex 的位置开始截取到 endIndex 位置的前一个字符为止
s1.substring(2,7); //llo w
String、StringBuffer和StringBuild区别
字符串常量池
String s1 = new String("abc") 创建了几个对象? 一个或两个。一个为"abc"会在常量池里创建,另外一个new会在堆中创建一个对象。所以一共两个对象。如果"abc"在常量池已经存在,那么只会创建一个对象。
数组
Java 语言中提供的数组是用来存储固定大小的同类型元素。
// ================== 创建数组 ==================
// 方式一.通过new操作符创建数组,需要指定数组大小
int[] arr1 = new int[5];
for (int i = 0; i < arr1.length; i++) {
arr1[i] = i;
}
System.out.println(Arrays.toString(arr1)); // [0, 1, 2, 3, 4]
// 方式二:声明、分配空间并赋值
int[] arr2 = new int[]{0, 1, 2, 3, 4};
System.out.println(Arrays.toString(arr2)); // [0, 1, 2, 3, 4]
// 方式三:方式二的简化
int[] arr3 = {0, 1, 2, 3, 4};
System.out.println(Arrays.toString(arr3)); // [0, 1, 2, 3, 4]
Arrays 类
Arrays 类能方便地操作数组,它提供的所有方法都是静态的。
// ================== Arrays 类 ==================
// 1.Arrays.toString() 返回数组的字符串表示
int[] arr1 = {1, 2, 3, 4, 5, 6};
System.out.println(Arrays.toString(arr1)); //[1, 2, 3, 4, 5, 6]
// 2.Arrays.copyOf() 复制数组,可以指定新数组的长度
// 原始数组
int[] originalArray = {1, 2, 3, 4, 5};
// 复制数组,指定新数组的长度
// 新数组长度小于原数组长度
int[] newArray1 = Arrays.copyOf(originalArray, 3);
System.out.println("新数组1: " + Arrays.toString(newArray1)); // 输出: [1, 2, 3]
// 新数组长度大于原数组长度
int[] newArray2 = Arrays.copyOf(originalArray, 8);
System.out.println("新数组2: " + Arrays.toString(newArray2)); // 输出: [1, 2, 3, 4, 5, 0, 0, 0]
// 新数组长度等于原数组长度
int[] newArray3 = Arrays.copyOf(originalArray, originalArray.length);
System.out.println("新数组3: " + Arrays.toString(newArray3)); // 输出: [1, 2, 3, 4, 5]
// 3.Arrays.sort() 对数组内容进行排序 升序
int[] arr3 = {11, 33, 99, 22, 88, 44, 55, 66, 77};
Arrays.sort(arr3);
System.out.println(Arrays.toString(arr3)); // 输出: [11, 22, 33, 44, 55, 66, 77, 88, 99]
//带 Comparator 接口的 sort() 方法的使用
Integer[] arr = {11, 33, 99, 22, 88, 44, 55, 66, 77};
// 这里要使用 Integer 包装类
Arrays.sort(arr, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
});
System.out.println(Arrays.toString(arr)); // [99, 88, 77, 66, 55, 44, 33, 22, 11]
// 4.Arrays.fill() 方法用指定的值填充数组
int[] arr4 = new int[10];
Arrays.fill(arr4, 2333);
System.out.println(Arrays.toString(arr4)); // [2333, 2333, 2333, 2333, 2333, 2333, 2333, 2333, 2333, 2333]
// 5.Arrays.equals() 方法用于比较两个一维数组是否相等
int[] array1 = {1, 2, 3};
int[] array2 = {1, 2, 3};
int[] array3 = {1, 2, 4};
// 比较相同的数组
boolean isEqual1 = Arrays.equals(array1, array2);
System.out.println("array1 和 array2 相等? " + isEqual1); // 输出: true
// 比较不同的数组
boolean isEqual2 = Arrays.equals(array1, array3);
System.out.println("array1 和 array3 相等? " + isEqual2); // 输出: false
// 6.Arrays.deepEquals() 方法用于比较两个多维数组是否相等。它会递归地比较数组的每个元素。
// 如果使用 Arrays.equals() 方法的话,则只会比较每一个低一维数组的引用是否相等
int[][] deepArray1 = {{1, 2, 3}, {4, 5, 6}};
int[][] deepArray2 = {{1, 2, 3}, {4, 5, 6}};
int[][] deepArray3 = {{1, 2, 3}, {4, 5, 7}};
// 比较相同的二维数组
boolean isDeepEqual1 = Arrays.deepEquals(deepArray1, deepArray2);
System.out.println("array1 和 array2 相等? " + isDeepEqual1); // 输出: true
// 比较不同的二维数组
boolean isDeepEqual2 = Arrays.deepEquals(deepArray1, deepArray3);
System.out.println("array1 和 array3 相等? " + isDeepEqual2); // 输出: false
// 7.Arrays.asList() 方法可以将指定的数组转换为一个固定大小的列表(List)。
// 这个方法返回一个 List 的视图,修改该列表会影响原始数组,反之亦然。
String[] fruitsArray = {"Apple", "Banana", "Cherry"};
// 使用 Arrays.asList 将数组转为列表
List<String> fruitsList = Arrays.asList(fruitsArray);
// 打印列表
System.out.println("水果列表: " + fruitsList); // 输出: 水果列表: [Apple, Banana, Cherry]
时间时间
java.util.Date中的方法大多已被废弃,而java.time.LocalDate提供了一组新的方法来操作日期,更符合现代编程习惯。
// ================== 日期时间 ==================
// ================== LocalDate (日期) ==================
LocalDate today = LocalDate.now(); // 2025-05-01
LocalDate date = LocalDate.of(2023, Month.JUNE, 15);
int year = date.getYear(); // 2023
Month month = date.getMonth(); // JUNE
int day = date.getDayOfMonth(); // 15
LocalDate nextWeek = today.plusWeeks(1);
boolean isLeap = date.isLeapYear(); // 是否闰年
// ================== LocalTime (时间) ==================
LocalTime localTime = LocalTime.now(); // 03:20:32.595
LocalTime time = LocalTime.of(14, 30, 45); // 14:30:45
int hour = time.getHour(); // 14
int minute = time.getMinute(); // 30
LocalTime nextHour = time.plusHours(1);
// ================== LocalDateTime (日期时间) ==================
LocalDateTime ldt = LocalDateTime.now(); // 2025-05-01T03:23:25.575
LocalDateTime dt = LocalDateTime.of(2023, 6, 15, 14, 30);
LocalDateTime nextMonth = dt.plusMonths(1);
// ================== ZonedDateTime (带时区日期时间) ==================
ZonedDateTime zdt = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
ZonedDateTime nyTime = zdt.withZoneSameInstant(ZoneId.of("America/New_York"));
ZoneId zone = zdt.getZone(); // 获取时区
// ================== Instant (时间戳) ==================
Instant now = Instant.now(); // 获取当前时间戳 2025-04-30T19:23:25.586Z
Instant later = now.plusSeconds(60); // 60秒后
long epochMilli = now.toEpochMilli(); // 获取毫秒时间戳 1746041099595
// ================== DateTimeFormatter 类 格式化日期时间 ==================
LocalDateTime localDateTime = LocalDateTime.now();
// 使用预定义的格式化器
System.out.println(localDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE)); // 2023-11-15
System.out.println(localDateTime.format(DateTimeFormatter.ISO_LOCAL_TIME)); // 14:30:45.123
System.out.println(localDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); // 2023-11-15T14:30:45.123
// 使用自定义模式的格式化器
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedDateTime = localDateTime.format(formatter); // 格式化
System.out.println(formattedDateTime); // 2023-11-15 14:30:45
// 解析
LocalDateTime parsedDateTime = LocalDateTime.parse("2023-11-15 14:30:45", formatter);
反射
工作流程
- 获取
Class对象:首先获取目标类的Class对象。 - 获取成员信息:通过
Class对象,可以获取类的字段、方法、构造函数等信息。 - 操作成员:通过反射 API 可以读取和修改字段的值、调用方法以及创建对象。
以下是 Java 反射的基本使用方式及其常见应用。
1. 获取 Class 对象
每个类在 JVM 中都有一个与之相关的 Class 对象。可以通过以下方式获取 Class 对象:
通过类字面量
Class<?> clazz = String.class;
通过对象实例:
String str = "Hello";
Class<?> clazz = str.getClass();
通过 Class.forName() 方法:
Class<?> clazz = Class.forName("java.lang.String");
2. 创建对象
可以使用反射动态创建对象:
Class<?> clazz = Class.forName("java.lang.String");
Object obj = clazz.getDeclaredConstructor().newInstance();
3. 访问字段
可以通过反射访问和修改类的字段:
Class<?> clazz = Person.class;
Field field = clazz.getDeclaredField("name");
field.setAccessible(true); // 如果字段是私有的,需要设置为可访问
Object value = field.get(personInstance); // 获取字段值
field.set(personInstance, "New Name"); // 设置字段值
4. 调用方法
可以通过反射调用类的方法:
Class<?> clazz = Person.class;
Method method = clazz.getMethod("sayHello");
method.invoke(personInstance);
Method methodWithArgs = clazz.getMethod("greet", String.class);
methodWithArgs.invoke(personInstance, "World");
5. 获取构造函数
可以使用反射获取和调用构造函数:
Class<?> clazz = Person.class;
Constructor<?> constructor = clazz.getConstructor(String.class, int.class);
Object obj = constructor.newInstance("John", 30);
6. 获取接口和父类
可以使用反射获取类实现的接口和父类:
Class<?> clazz = Person.class;
// 获取所有接口
Class<?>[] interfaces = clazz.getInterfaces();
for (Class<?> i : interfaces) {
System.out.println("Interface: " + i.getName());
}
// 获取父类
Class<?> superClass = clazz.getSuperclass();
System.out.println("Superclass: " + superClass.getName());
以下是一个完整的示例,展示了如何使用反射来创建对象、访问字段和调用方法:
public class ReflectionExample {
public static void main(String[] args) throws Exception {
// 获取 Class 对象
Class<?> clazz = Person.class;
// 创建对象
Constructor<?> constructor = clazz.getConstructor(String.class, int.class);
Object person = constructor.newInstance("John", 30);
// 访问字段
Field nameField = clazz.getDeclaredField("name");
nameField.setAccessible(true);
System.out.println("Name: " + nameField.get(person));
// 修改字段
nameField.set(person, "Doe");
System.out.println("Updated Name: " + nameField.get(person));
// 调用方法
Method greetMethod = clazz.getMethod("greet", String.class);
greetMethod.invoke(person, "World");
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void greet(String message) {
System.out.println(name + " says: " + message);
}
}
泛型
泛型里的通配符T,E,K,V
- T(Type) T表示任意类型参数,我们举个例子
pubile class A<T>{
prvate T t;
//其他省略...
}
//创建一个不带泛型参数的A
A a = new A();
a.set(new B());
B b = (B) a.get();//需要进行强制类型转换
//创建一个带泛型参数的A
A<B> a = new A<B>();
a.set(new B());
B b = a.get();
- E(Element) E表示集合中的元素类型
List<E> list = new ArrayList<>();
- K(Key) K表示映射的键的数据类型
Map<K,V> map = new HashMap<>();
- V(Value) V表示映射的值的数据类型
Map<K,V> map = new HashMap<>();
- 通配符 ?
- 无界通配符 <?> 表示未知类型,接收任意类型
// 使用无界通配符处理任意类型的查询结果
public void logQueryResult(List<?> resultList) {
resultList.forEach(obj -> log.info("Result: {}", obj));
}
- 上界通配符 <? extends T> 表示类型是T或者是子类
// 使用上界通配符读取缓存
public <T extends Serializable> T getCache(String key, Class<T> clazz) {
Object value = redisTemplate.opsForValue().get(key);
return clazz.cast(value);
}
- 下界通配符 <? super T> 表示类型是T或者是父类
// 使用下界通配符写入缓存
public void setCache(String key, <? super Serializable> value) {
redisTemplate.opsForValue().set(key, value);
}
泛型擦除
异常
断言
IO
序列化
Java 序列化是一种将对象转换为字节流的过程,以便可以将对象保存到磁盘上,将其传输到网络上,或者将其存储在内存中,以后再进行反序列化,将字节流重新转换为对象。
序列化在 Java 中是通过 java.io.Serializable 接口来实现的,该接口没有任何方法,只是一个标记接口,用于标识类可以被序列化。
为什么需要设置 serialVersionUID
设置 serialVersionUID 的主要原因是确保序列化和反序列化过程中的版本一致性,防止不兼容类对象被反序列化,从而确保数据完整性和避免安全漏洞。
当一个类被序列化时,其 serialVersionUID 字段的值会被保存在序列化后的字节序列中。当该字节序列被反序列化时,会检查 serialVersionUID 字段的值,以确保它与保存时的值相同。如果不相同,则会抛出 InvalidClassException 异常。
序列化对象 使用 ObjectOutputStream 类来将对象序列化为字节流,以下是一个简单的实例:
public class Student implements Serializable{
private static final long serialVersionUID = -1811769855843314666L;
private String name;
private Integer age;
}
try {
Student student = new Student();
FileOutputStream fileOut = new FileOutputStream("student.txt");
ObjectOutputStream out = new ObjectOutputStream(fileOut);
out.writeObject(student);
out.close();
fileOut.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
上述代码将一个名为 student.txt 文件中的 Student 对象序列化。
反序列化对象: 使用 ObjectInputStream 类来从字节流中反序列化对象,以下是一个简单的实例:
try {
FileInputStream fileIn = new FileInputStream("student.txt");
ObjectInputStream in = new ObjectInputStream(fileIn);
Student student = (Student) in.readObject();
System.out.println(student);
in.close();
fileIn.close();
} catch (IOException e) {
throw new RuntimeException(e);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
上述代码从 student.txt 文件中读取字节流并将其反序列化为一个 Student 对象。
网络编程
网络编程是网络在通信协议下,不同计算机上运行的程序,进行的数据传输。java.net提供低层次的通信细节的类和接口让我们可以手动网络编程。
java.net 包中提供了两种常见的网络协议的支持:TCP 和 UDP。
Socket 编程
Socket(套接字) 是计算机网络中用于实现网络通信的一种编程接口。它提供了一组函数和方法,使得应用程序能够通过网络进行数据的发送和接收。
Socket的作用是在不同主机之间建立通信连接,使得这些主机上运行的应用程序能够进行数据交换。Socket支持TCP和UDP两种协议。
客户端代码
import java.net.*;
import java.io.*;
public class GreetingClient {
public static void main(String[] args) {
String serverName = args[0];
int port = Integer.parseInt(args[1]);
try {
System.out.println("连接到主机:" + serverName + " ,端口号:" + port);
Socket client = new Socket(serverName, port);
System.out.println("远程主机地址:" + client.getRemoteSocketAddress());
OutputStream outToServer = client.getOutputStream();
DataOutputStream out = new DataOutputStream(outToServer);
out.writeUTF("Hello from " + client.getLocalSocketAddress());
InputStream inFromServer = client.getInputStream();
DataInputStream in = new DataInputStream(inFromServer);
System.out.println("服务器响应: " + in.readUTF());
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
服务端代码
import java.net.*;
import java.io.*;
public class GreetingServer extends Thread {
private ServerSocket serverSocket;
public GreetingServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
serverSocket.setSoTimeout(10000);
}
public void run() {
while (true) {
try {
System.out.println("等待远程连接,端口号为:" + serverSocket.getLocalPort() + "...");
Socket server = serverSocket.accept();
System.out.println("远程主机地址:" + server.getRemoteSocketAddress());
DataInputStream in = new DataInputStream(server.getInputStream());
System.out.println(in.readUTF());
DataOutputStream out = new DataOutputStream(server.getOutputStream());
out.writeUTF("谢谢连接我:" + server.getLocalSocketAddress() + "\nGoodbye!");
server.close();
} catch (SocketTimeoutException s) {
System.out.println("Socket timed out!");
break;
} catch (IOException e) {
e.printStackTrace();
break;
}
}
}
public static void main(String[] args) {
int port = Integer.parseInt(args[0]);
try {
Thread t = new GreetingServer(port);
t.run();
} catch (IOException e) {
e.printStackTrace();
}
}
}
编译以上两个 java 文件代码,并执行以下命令来启动服务,使用端口号为 6066:
$ javac GreetingServer.java
$ java GreetingServer 6066
等待远程连接,端口号为:6066...
新开一个命令窗口,执行以上命令来开启客户端:
$ javac GreetingClient.java
$ java GreetingClient localhost 6066
连接到主机:localhost ,端口号:6066
远程主机地址:localhost/127.0.0.1:6066
服务器响应: 谢谢连接我:/127.0.0.1:6066
Goodbye!
代理
代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
静态代理
静态代理中,我们对目标对象的每个方法的增强都是手动完成的(后面会具体演示代码),非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改)且麻烦(需要对每个目标类都单独写一个代理类)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。
上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
静态代理实现步骤:
- 定义一个接口及其实现类;
- 创建一个代理类同样实现这个接口
- 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。
下面通过代码展示!
# 1.定义发送短信的接口
public interface SmsService {
String send(String message);
}
# 2.实现发送短信的接口
public class SmsServiceImpl implements SmsService {
public String send(String message) {
System.out.println("send message:" + message);
return message;
}
}
# 3.创建代理类并同样实现发送短信的接口
public class SmsProxy implements SmsService {
private final SmsService smsService;
public SmsProxy(SmsService smsService) {
this.smsService = smsService;
}
@Override
public String send(String message) {
//调用方法之前,我们可以添加自己的操作
System.out.println("before method send()");
smsService.send(message);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("after method send()");
return null;
}
}
# 4.实际应用
public class Main {
public static void main(String[] args) {
SmsService smsService = new SmsServiceImpl();
SmsProxy smsProxy = new SmsProxy(smsService);
smsProxy.send("java");
}
}
#输出结果
before method send()
send message:java
after method send()
可以输出结果看出,我们已经增加了 SmsServiceImpl 的send()方法。
动态代理
相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
说到动态代理,Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。
动态代理在我们日常开发中使用的相对较少,但是在框架中的几乎是必用的一门技术。学会了动态代理之后,对于我们理解和学习各种框架的原理也非常有帮助。
就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态代理等等。
JDK动态代理机制
在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心。
Proxy 类中使用频率最高的方法是:newProxyInstance() ,这个方法主要用来生成一个代理对象。
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
throws IllegalArgumentException
{
......
}
这个方法一共有 3 个参数:
- loader :类加载器,用于加载代理对象。
- interfaces : 被代理类实现的一些接口;
- h : 实现了
InvocationHandler接口的对象;
要实现动态代理的话,还必须需要实现InvocationHandler 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现InvocationHandler 接口类的 invoke 方法来调用。
public interface InvocationHandler {
/**
* 当你使用代理对象调用方法的时候实际会调用到这个方法
*/
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
invoke() 方法有下面三个参数:
- proxy :动态生成的代理类
- method : 与代理类对象调用的方法相对应
- args : 当前 method 方法的参数
也就是说:你通过Proxy 类的 newProxyInstance() 创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler 接口的类的 invoke()方法。 你可以在 invoke() 方法中自定义处理逻辑,比如在方法执行前后做什么事情。
JDK 动态代理类使用步骤
- 定义一个接口及其实现类;
- 自定义
InvocationHandler并重写invoke方法,在invoke方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑; - 通过
Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h)方法创建代理对象;
代码示例
# 1.定义发送短信的接口
public interface SmsService {
String send(String message);
}
# 2.实现发送短信的接口
public class SmsServiceImpl implements SmsService {
public String send(String message) {
System.out.println("send message:" + message);
return message;
}
}
# 3.定义一个JDK动态代理类
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
/**
* @author shuang.kou
* @createTime 2020年05月11日 11:23:00
*/
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 InvocationTargetException, IllegalAccessException {
//调用方法之前,我们可以添加自己的操作
System.out.println("before method " + method.getName());
Object result = method.invoke(target, args);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("after method " + method.getName());
return result;
}
}
# invoke() 方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 invoke() 方法,然后 invoke() 方法代替我们去调用了被代理对象的原生方法。
# 获取代理对象的工厂类
public class JdkProxyFactory {
public static Object getProxy(Object target) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(), // 目标类的类加载器
target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个
new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler
);
}
}
# getProxy():主要通过`Proxy.newProxyInstance()`方法获取某个类的代理对象
# 5.实际使用
SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl());
smsService.send("java");
JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。
为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。
CGLIB动态代理机制
CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。
在 CGLIB 动态代理机制中 MethodInterceptor 接口和 Enhancer 类是核心。
你需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。
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 方法。
CGLIB 动态代理类使用步骤
- 定义一个类;
- 自定义
MethodInterceptor并重写intercept方法,intercept用于拦截增强被代理类的方法,和 JDK 动态代理中的invoke方法类似; - 通过
Enhancer类的create()创建代理类;
代码示例
不同于 JDK 动态代理不需要额外的依赖。CGLIB(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>
1.实现一个使用阿里云发送短信的类
package github.javaguide.dynamicProxy.cglibDynamicProxy;
public class AliSmsService {
public String send(String message) {
System.out.println("send message:" + message);
return message;
}
}
2.自定义 MethodInterceptor(方法拦截器)
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* 自定义MethodInterceptor
*/
public class DebugMethodInterceptor implements MethodInterceptor {
/**
* @param o 被代理的对象(需要增强的对象)
* @param method 被拦截的方法(需要增强的方法)
* @param args 方法入参
* @param methodProxy 用于调用原始方法
*/
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
//调用方法之前,我们可以添加自己的操作
System.out.println("before method " + method.getName());
Object object = methodProxy.invokeSuper(o, args);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("after method " + method.getName());
return object;
}
}
3.获取代理类
import net.sf.cglib.proxy.Enhancer;
public class CglibProxyFactory {
public static Object getProxy(Class<?> clazz) {
// 创建动态代理增强类
Enhancer enhancer = new Enhancer();
// 设置类加载器
enhancer.setClassLoader(clazz.getClassLoader());
// 设置被代理类
enhancer.setSuperclass(clazz);
// 设置方法拦截器
enhancer.setCallback(new DebugMethodInterceptor());
// 创建代理类
return enhancer.create();
}
}
4.实际使用
AliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class);
aliSmsService.send("java");
JDK 动态代理和 CGLIB 动态代理对比
- JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
- 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
静态代理和动态代理的对比
- 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
- JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。