一、Java中集合框架有哪些核心接口
二、ArrayList 与 LinkedList 有什么区别
三、HashMap 与HashTable 有什么区别?
四、什么是Java序列化
五、说说你对lambda的理解
六、说说你对泛型的理解
七、notify() 与notifyAll()有什么区别
八、静态内部类与非静态内部类有什么区别
九、Strings 与 new String 有什么区别
十、反射中 Class.forName 和ClassLoader 的区别
一、Java中集合框架有哪些核心接口
-
Collection接口- 概述:
Collection是 Java 集合框架中的最基本接口,它定义了集合类的通用操作方法。所有的集合类(除了Map相关的集合)都直接或间接实现了这个接口。它就像是一个集合类的 “总纲”,规定了集合应该具备的基本功能。
- 主要方法:
-
添加元素:
add(E e):用于向集合中添加一个元素。这里的E是集合中元素的类型。例如,对于一个ArrayList集合(ArrayList实现了Collection接口),可以使用list.add("element")来添加一个字符串元素。如果添加成功,返回true;如果集合不允许添加重复元素且该元素已经存在,或者由于其他原因(如集合已满,对于一些有容量限制的集合)添加失败,则返回false。addAll(Collection<? extends E> c):用于将另一个集合c中的所有元素添加到当前集合中。例如,有两个ArrayList集合list1和list2,可以使用list1.addAll(list2)将list2中的所有元素添加到list1中。如果在添加过程中,当前集合发生了改变(即至少添加了一个元素),则返回true;否则返回false。
-
删除元素:
remove(Object o):用于从集合中删除指定元素o。例如,在一个HashSet集合(HashSet也是Collection接口的实现类)中,如果要删除一个元素,可以使用set.remove("element")。如果集合中存在该元素并且成功删除,返回true;如果集合中不存在该元素,则返回false。removeAll(Collection<?> c):用于从当前集合中删除包含在另一个集合c中的所有元素。例如,对于集合list1和list2,使用list1.removeAll(list2)会从list1中删除所有在list2中出现的元素。如果当前集合因为这个操作发生了改变(即至少删除了一个元素),则返回true;否则返回false。
-
查询元素相关:
contains(Object o):用于检查集合中是否包含指定元素o。例如,在一个LinkedList集合中,可以使用linkedList.contains("element")来检查是否包含某个字符串元素。如果集合中包含该元素,则返回true;否则返回false。size():返回集合中元素的数量。例如,对于一个TreeSet集合,通过set.size()可以获取集合中元素的个数。
-
遍历集合相关:
iterator():返回一个Iterator对象,用于遍历集合中的元素。Iterator接口提供了hasNext()和next()方法,用于逐个访问集合中的元素。例如,对于一个ArrayList集合list,可以使用以下方式遍历:
Iterator iterator = list.iterator(); while (iterator.hasNext()) { E element = iterator.next(); // 对元素进行操作 }
-
- 概述:
-
List接口- 概述:
List接口继承自Collection接口,它代表一个有序的集合,其中的元素可以重复。这个有序性是指元素的存储顺序和取出顺序是一致的,并且每个元素都有一个对应的索引。就像一个可以存储各种物品的有序列表,每个物品都有自己的位置编号。
- 主要方法(除
Collection接口中的方法外):- 按索引操作元素:
get(int index):用于获取指定索引index处的元素。例如,对于一个List<String>集合list,如果要获取索引为 2 的元素,可以使用list.get(2)。如果索引超出范围(小于 0 或者大于等于集合的大小),会抛出IndexOutOfBoundsException异常。set(int index, E element):用于将指定索引index处的元素替换为新元素element。例如,在一个List<Integer>集合中,如果要将索引为 3 的元素替换为新的值,可以使用list.set(3, 10)。同样,如果索引超出范围,会抛出IndexOutOfBoundsException异常。add(int index, E element):用于在指定索引index处插入一个新元素element。插入后,原来索引为index及其后面的元素会依次向后移动一个位置。例如,在一个List<Double>集合中,使用list.add(1, 3.14)会在索引为 1 的位置插入3.14这个元素。如果索引超出范围(小于 0 或者大于集合的大小),会抛出IndexOutOfBoundsException异常。remove(int index):用于删除指定索引index处的元素。删除后,后面的元素会依次向前移动一个位置。例如,在一个List<Character>集合中,使用list.remove(2)会删除索引为 2 的元素。如果索引超出范围,会抛出IndexOutOfBoundsException异常。
- 按索引操作元素:
- 概述:
-
Set接口- 概述:
Set接口也继承自Collection接口,它代表一个无序的集合,其中的元素不可以重复。这个无序是指元素的存储顺序和取出顺序不一定相同,并且集合会自动去除重复的元素,就像一个不允许有重复物品的袋子。
- 主要方法(除
Collection接口中的方法外):- 由于
Set接口主要强调元素的唯一性,所以它没有像List接口那样按索引操作元素的方法。但是,Set接口的实现类通常会对add方法进行特殊的实现,以确保元素的唯一性。例如,HashSet是通过元素的哈希码(hashCode)和equals方法来判断元素是否重复。当调用add方法添加一个元素时,如果集合中已经存在一个元素,其哈希码和equals方法比较的结果与要添加的元素相同,那么这个元素就不会被添加进去。
- 由于
- 概述:
-
Queue接口- 概述:
Queue接口用于表示队列这种数据结构,它继承自Collection接口。队列遵循先进先出(FIFO - First In First Out)的原则,就像排队买票一样,先到的人先被服务。
- 主要方法(除
Collection接口中的方法外):- 入队操作:
offer(E e):用于将元素e插入到队列的尾部。如果插入成功,返回true;如果队列已满(对于有容量限制的队列),则返回false。例如,对于一个LinkedList实现的队列(LinkedList实现了Queue接口),可以使用queue.offer("element")将元素插入到队列中。
- 出队操作:
poll():用于移除并返回队列头部的元素。如果队列是空的,则返回null。例如,在一个ArrayDeque队列中,使用queue.poll()可以获取并移除队列头部的元素。remove():与poll()类似,用于移除并返回队列头部的元素,但如果队列是空的,会抛出NoSuchElementException异常。所以在使用remove()方法时,需要确保队列不为空。
- 查看队首元素:
peek():用于返回队列头部的元素,但不移除它。如果队列是空的,则返回null。例如,在一个PriorityQueue中,使用queue.peek()可以查看队列头部的元素。
- 入队操作:
- 概述:
-
Map接口- 概述:
Map接口用于存储键 - 值(key - value)对,它与Collection接口不同,不是存储单个元素的集合。Map接口可以看作是一个字典,通过键(key)来查找对应的的值(value),就像通过单词查找其对应的释义一样。
- 主要方法:
- 添加和修改键 - 值对:
put(K key, V value):用于将指定的键 - 值对添加到Map中。如果键key已经存在,则会用新的值value替换原来的值,并返回原来的值;如果键不存在,则直接添加键 - 值对,并返回null。例如,对于一个HashMap(HashMap是Map接口的实现类),可以使用map.put("key", "value")来添加一个键 - 值对。
- 删除键 - 值对:
remove(K key):用于从Map中删除指定键key对应的键 - 值对,并返回被删除的值。如果键不存在,则返回null。例如,在一个TreeMap中,使用map.remove("key")可以删除指定键对应的键 - 值对。
- 查询键 - 值对相关:
get(K key):用于获取指定键key对应的的值。如果键不存在,则返回null。例如,在一个LinkedHashMap中,使用map.get("key")可以获取键对应的的值。containsKey(K key):用于检查Map中是否包含指定的键key。如果包含,则返回true;否则返回false。containsValue(V value):用于检查Map中是否包含指定的值value。如果包含,则返回true;否则返回false。不过,这个操作在一些Map实现类(如HashMap)中的效率可能相对较低,因为需要遍历所有的值来进行检查。size():返回Map中键 - 值对的数量。例如,对于一个Hashtable,通过map.size()可以获取其中键 - 值对的个数。
- 添加和修改键 - 值对:
- 概述:
二、ArrayList 与 LinkedList 有什么区别
-
数据结构层面
- ArrayList:
-
ArrayList 是基于动态数组实现的。在内存中,它是一段连续的存储空间,就像一排紧密排列的盒子,每个盒子可以存放一个元素。这种连续的存储结构使得它在访问元素时非常高效,因为可以通过计算偏移量直接定位到元素的位置。例如,在一个
ArrayList对象list中,要访问索引为i的元素,计算其在内存中的位置的时间复杂度是,就像你知道每个盒子的编号,能很快找到对应的盒子一样。
-
- LinkedList:
- LinkedList 是基于链表实现的。链表中的每个节点包含数据元素以及指向下一个节点(单链表)或前一个节点和下一个节点(双链表)的引用。这就好比是用绳子串起来的珠子,每个珠子是一个节点,珠子之间通过绳子(引用)相连。这种结构使得在插入和删除元素时相对灵活,因为只需要修改节点之间的引用关系,而不需要像数组那样移动大量元素。例如,在一个
LinkedList中插入一个新节点,只需要调整插入位置前后节点的引用即可。
- LinkedList 是基于链表实现的。链表中的每个节点包含数据元素以及指向下一个节点(单链表)或前一个节点和下一个节点(双链表)的引用。这就好比是用绳子串起来的珠子,每个珠子是一个节点,珠子之间通过绳子(引用)相连。这种结构使得在插入和删除元素时相对灵活,因为只需要修改节点之间的引用关系,而不需要像数组那样移动大量元素。例如,在一个
- ArrayList:
-
性能方面
- 随机访问性能:
- ArrayList:
-
由于其基于数组的特性,在随机访问(通过索引访问)元素时性能很好。例如,对于一个存储了大量元素的
ArrayList,使用list.get(i)来获取索引为i的元素的操作非常快,时间复杂度是。
-
- LinkedList:
-
在随机访问元素时性能较差。因为要访问链表中的某个元素,需要从链表头(或尾,取决于实现方式)开始逐个遍历节点,直到找到目标元素。如果要访问索引为
i的元素,平均时间复杂度是,其中
n是链表的长度。就好像在一串珠子中找特定的珠子,可能需要一个一个地查看。
-
- ArrayList:
- 插入和删除性能:
- ArrayList:
-
在列表中间插入或删除元素时性能较差。因为这可能涉及到大量元素的移动。例如,在索引为
i的位置插入一个元素,需要将索引i及其后面的元素都向后移动一位,时间复杂度是,其中
n是列表的长度。同样,删除一个元素也可能需要移动大量元素。
-
- LinkedList:
-
在链表头部或尾部插入或删除元素的性能很好,时间复杂度是
。因为只需要修改头节点或尾节点以及相邻节点的引用即可。在链表中间插入或删除元素的性能也相对较好,时间复杂度是
(找到插入或删除位置后),因为主要操作是修改节点之间的引用,不需要移动大量元素。
-
- ArrayList:
- 随机访问性能:
-
内存占用方面
- ArrayList:
- ArrayList 需要预留一定的连续内存空间来存储元素。当元素数量增加,超过了初始容量时,会进行扩容操作。扩容通常是创建一个更大的新数组,并将原数组中的元素复制到新数组中,这可能会导致额外的内存开销和性能损耗。而且,由于它是连续存储的,如果实际存储的元素数量较少,而预留的空间较大,会造成一定的内存浪费。
- LinkedList:
- LinkedList 每个节点除了存储元素本身,还需要存储指向前一个节点和 / 或后一个节点的引用,这会占用额外的内存空间。但是,它不需要像 ArrayList 那样预留大量连续的内存空间,所以在存储元素数量不确定,且元素频繁插入和删除的情况下,可能在内存利用效率上更有优势。
- ArrayList:
-
适用场景方面
- ArrayList:
- 适用于频繁进行随机访问元素,而插入和删除操作相对较少的场景。例如,在一个数据统计程序中,需要存储大量的数据点,并且经常需要通过索引访问这些数据点来进行计算和分析,这时使用 ArrayList 会比较合适。
- LinkedList:
- 适用于需要频繁进行插入和删除操作,而随机访问操作相对较少的场景。例如,在一个实现队列(先进先出)或者栈(后进先出)的数据结构时,LinkedList 是一个很好的选择,因为插入和删除操作主要发生在头部或尾部,能够发挥其性能优势。
- ArrayList:
三、HashMap 与HashTable 有什么区别?
-
线程安全性
- HashMap:
- HashMap 是非线程安全的。这意味着在多线程环境下,如果多个线程同时对一个 HashMap 对象进行读写操作,可能会导致数据不一致或其他并发问题。例如,当一个线程正在对 HashMap 进行插入操作,另一个线程同时对同一 HashMap 进行删除操作,可能会出现数据丢失、死循环等问题。因此,如果要在多线程环境下使用 HashMap,通常需要通过额外的同步措施,如使用
Collections.synchronizedMap()方法来包装 HashMap,使其变为线程安全的,或者使用ConcurrentHashMap这种专门为并发环境设计的类。
- HashMap 是非线程安全的。这意味着在多线程环境下,如果多个线程同时对一个 HashMap 对象进行读写操作,可能会导致数据不一致或其他并发问题。例如,当一个线程正在对 HashMap 进行插入操作,另一个线程同时对同一 HashMap 进行删除操作,可能会出现数据丢失、死循环等问题。因此,如果要在多线程环境下使用 HashMap,通常需要通过额外的同步措施,如使用
- HashTable:
- HashTable 是线程安全的。它的所有方法(如
put、get、remove等)都使用了synchronized关键字进行修饰。这保证了在同一时刻,只有一个线程能够访问 HashTable 的方法,从而避免了多线程并发访问时的数据冲突。例如,在一个多线程的服务器应用程序中,如果多个线程需要共享一个存储配置信息的哈希表,HashTable 可以确保这些线程安全地访问和修改配置信息。不过,这种同步机制也导致了 HashTable 在性能上可能不如 HashMap,因为多个线程访问 HashTable 时需要等待锁的释放,会产生一定的阻塞和性能损耗。
- HashTable 是线程安全的。它的所有方法(如
- HashMap:
-
null 值的允许情况
- HashMap:
- HashMap 允许键(key)为
null,但只允许一个键为null。同时,也允许值(value)为null。这在某些情况下为编程提供了一定的灵活性。例如,在一个数据存储的场景中,可能需要使用null作为特殊的标记或者未初始化的状态来表示某些键值对。不过,在使用null键时需要注意,因为在哈希表中,null键的存储和查找方式与非null键可能会有所不同。
- HashMap 允许键(key)为
- HashTable:
- HashTable 不允许键(key)和值(value)为
null。如果试图将null作为键或者值插入到 HashTable 中,会抛出NullPointerException。这是因为 HashTable 的设计初衷是为了提供一个严格的、不允许null值的哈希表结构,以避免在一些早期的编程语言中,null值可能导致的模糊性和错误。
- HashTable 不允许键(key)和值(value)为
- HashMap:
-
继承关系和初始容量与扩容机制
- 继承关系:
- HashMap 继承自
AbstractMap类,实现了Map接口。这使得它在继承体系中有更多的灵活性,可以在AbstractMap类的基础上进行功能扩展。例如,AbstractMap类提供了一些通用的方法实现,如equals和hashCode方法的默认实现,HashMap 可以利用这些实现并根据自身的特性进行适当的修改。 - HashTable 继承自
Dictionary类,实现了Map接口。Dictionary类是一个比较古老的类,在 Java 2 之后,推荐使用Map接口来代替Dictionary类进行编程。这种继承关系也反映了 HashTable 的历史遗留性质。
- HashMap 继承自
- 初始容量与扩容机制:
- HashMap 的默认初始容量是 16,加载因子是 0.75。当 HashMap 中的元素数量超过
容量 * 加载因子时,就会进行扩容。扩容时,会创建一个容量为原来两倍的新数组,并重新计算每个元素在新数组中的位置(通过重新计算哈希值和索引)。例如,初始容量为 16,当元素个数达到 16 * 0.75 = 12 时,就会触发扩容操作。 - HashTable 的默认初始容量是 11,加载因子是 0.75。扩容时,新容量是原来容量的 2 倍加 1。这种扩容机制与 HashMap 有所不同,而且在实际应用中,由于初始容量和扩容方式的差异,可能会对性能和空间利用率产生一定的影响。不过,在选择使用 HashMap 还是 HashTable 时,初始容量和扩容机制通常不是主要的考虑因素,除非对内存使用和性能有非常精细的要求。
- HashMap 的默认初始容量是 16,加载因子是 0.75。当 HashMap 中的元素数量超过
- 继承关系:
四、什么是Java序列化
-
定义和概念
- Java 序列化是一种将对象转换为字节序列的机制,这个字节序列可以被存储在文件中、通过网络传输,或者在内存中以字节流的形式存在。之后,通过反序列化过程,可以将这个字节序列重新恢复为原始的对象。就好像是把一个复杂的物体(对象)拆分成一个个小零件(字节),方便存储和运输,之后再根据这些小零件重新组装成原来的物体。
-
用途和应用场景
- 对象持久化:
- 可以将对象的状态保存到文件中。例如,在一个游戏中,玩家的游戏进度(包括角色等级、装备、游戏场景等信息)可以通过序列化存储到本地文件。当玩家下次打开游戏时,通过反序列化从文件中读取字节序列,恢复游戏进度对象,玩家就可以继续之前的游戏。
- 网络传输:
- 在分布式系统或者网络应用中,对象需要在不同的计算机之间传输。序列化可以将对象转换为字节流,通过网络协议(如 TCP/IP)发送到其他计算机,接收方再进行反序列化得到原始对象。比如,在一个基于客户端 - 服务器架构的应用程序中,服务器端可能会将一些数据对象(如用户信息、订单信息等)序列化后发送给客户端,客户端接收到字节流后进行反序列化来使用这些对象。
- 对象持久化:
-
实现方式和要求
-
实现
Serializable接口:- 要使一个类的对象能够被序列化,这个类必须实现
java.io.Serializable接口。这个接口是一个标记接口,没有任何方法需要实现,它只是告诉 Java 虚拟机(JVM)这个类的对象可以被序列化。例如:
import java.io.Serializable; class Person implements Serializable { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } }
- 要使一个类的对象能够被序列化,这个类必须实现
-
-
当一个类实现了
Serializable接口后,它的对象就可以通过ObjectOutputStream进行序列化。例如,将Person对象序列化到文件中:import java.io.FileOutputStream; import java.io.ObjectOutputStream; public class SerializationExample { public static void main(String[] args) throws Exception { Person person = new Person("John", 30); FileOutputStream fos = new FileOutputStream("person.ser"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(person); oos.close(); fos.close(); } } -
serialVersionUID(序列化版本号):-
它是一个用于验证序列化和反序列化兼容性的版本标识符。当一个类被序列化时,JVM 会根据类的结构(如成员变量、方法等)生成一个默认的
serialVersionUID。不过,强烈建议手动定义serialVersionUID,因为如果类的结构发生变化(如添加或删除成员变量、修改成员变量的类型等),默认的serialVersionUID可能会改变,这会导致在反序列化时出现InvalidClassException异常。例如:import java.io.Serializable; class AnotherPerson implements Serializable { private static final long serialVersionUID = 1L; private String name; private int age; public AnotherPerson(String name, int age) { this.name = name; this.age = age; } }
-
-
序列化过程中的对象引用和循环引用处理:
- 在序列化一个包含对象引用的复杂对象时,Java 会自动处理对象引用。如果一个对象被多个其他对象引用,在序列化时只会序列化一次,之后通过引用的方式来处理。对于循环引用(如对象 A 引用对象 B,对象 B 又引用对象 A)的情况,Java 也能够正确处理,保证在序列化和反序列化过程中不会出现无限循环的问题。例如,有一个双向链表结构的类,节点之间相互引用,Java 序列化机制可以正确地将这个链表结构序列化和反序列化。
- 与其他相关技术的比较和关联
- 与 JSON/XML 序列化的比较:
- Java 序列化是 Java 特有的二进制序列化方式,它的优点是可以精确地还原对象的状态,并且在 Java 环境内部效率相对较高。而 JSON(JavaScript Object Notation)和 XML(eXtensible Markup Language)序列化是一种文本格式的序列化方式,它们更具通用性,适用于不同语言之间的数据交换。不过,JSON/XML 序列化后的文本数据相对二进制的 Java 序列化数据体积可能更大,并且在反序列化时需要进行解析,效率可能稍低。例如,在一个跨平台的 Web 应用中,可能会使用 JSON 来进行前后端的数据传输,因为 JavaScript 可以方便地处理 JSON 数据;而在纯 Java 内部的数据存储和传输场景中,Java 序列化可能是更好的选择。
- 与克隆(
Clone)的比较:- 克隆是创建一个与原始对象具有相同状态的新对象,它主要是在内存中进行对象复制。而序列化和反序列化不仅可以复制对象状态,还可以将对象存储和传输到其他地方,并且在不同的时间和空间恢复对象。克隆通常是浅克隆(只复制基本数据类型和对象引用,不复制引用对象的内容)或深克隆(复制基本数据类型和引用对象的内容),而序列化和反序列化在处理对象引用时有自己的一套机制,可以根据需要灵活处理。例如,在需要备份一个对象的状态或者在不同的 JVM 进程之间传递对象时,序列化是更合适的选择;而如果只是在同一个内存空间中简单地复制一个对象,克隆可能更方便快捷。
- 与 JSON/XML 序列化的比较:
五、说说你对lambda的理解
-
定义和语法基础
- Lambda 表达式是 Java 8 引入的一个重要特性,它本质上是一种匿名函数。语法形式为
(parameters) -> expression或(parameters) -> { statements; }。其中,parameters是参数列表,可以为空或包含一个或多个参数;->是 Lambda 操作符,用于将参数列表和函数体分隔开;expression是一个简单的表达式,当执行 Lambda 表达式时,这个表达式的值会被返回;{ statements; }是一个代码块,用于包含多条语句,在这种情况下,需要使用return语句来返回值(如果有返回值要求)。 - 例如,一个简单的 Lambda 表达式用于计算两个整数的和可以写成
(int a, int b) -> a + b。这里(int a, int b)是参数列表,a + b是一个简单的表达式,这个 Lambda 表达式的功能就是接收两个整数参数并返回它们的和。
- Lambda 表达式是 Java 8 引入的一个重要特性,它本质上是一种匿名函数。语法形式为
-
函数式接口与 Lambda 表达式的关联
- Lambda 表达式主要用于实现函数式接口。函数式接口是指只包含一个抽象方法的接口。例如,
java.util.function包中的Consumer接口,它只有一个抽象方法void accept(T t),这个接口用于表示对一个给定类型T的对象进行某种操作(消费这个对象),没有返回值。可以使用 Lambda 表达式来实现这个接口,如Consumer<String> consumer = (String s) -> System.out.println(s);,这里的 Lambda 表达式(String s) -> System.out.println(s)实现了Consumer接口的accept方法,用于打印给定的字符串。 - 另外,Java 中有许多内置的函数式接口,如
Supplier(用于提供一个对象)、Function(用于将一个对象转换为另一个对象)等,它们都可以通过 Lambda 表达式来实现,方便在代码中进行函数式编程。
- Lambda 表达式主要用于实现函数式接口。函数式接口是指只包含一个抽象方法的接口。例如,
-
使用场景和优势
-
简化代码结构:
- 在处理集合操作时,Lambda 表达式可以大大简化代码。例如,在 Java 8 之前,要遍历一个
List并对每个元素进行打印,可能需要使用for - each循环,如:
List list = Arrays.asList("a", "b", "c"); for (String s : list) { System.out.println(s); }
- 在处理集合操作时,Lambda 表达式可以大大简化代码。例如,在 Java 8 之前,要遍历一个
-
-
使用 Lambda 表达式和
forEach方法,可以将代码简化为:List<String> list = Arrays.asList("a", "b", "c"); list.forEach((String s) -> System.out.println(s)); -
而且,如果编译器可以从上下文推断出参数类型,还可以进一步简化为
list.forEach(s -> System.out.println(s));,这样代码更加简洁明了。 -
支持函数式编程风格:
-
Lambda 表达式使得 Java 能够更好地支持函数式编程的理念,如将函数作为参数传递给其他函数或者方法。例如,有一个方法用于对一个整数列表中的每个元素进行某种操作,这个操作可以通过一个函数式接口来定义,然后将 Lambda 表达式作为参数传递给这个方法。假设定义一个方法
processList(List<Integer> list, Function<Integer, Integer> operation),用于对列表list中的每个元素应用operation函数。可以这样使用:List numbers = Arrays.asList(1, 2, 3); Function<Integer, Integer> squareFunction = (Integer n) -> n * n; List squaredNumbers = processList(numbers, squareFunction);
-
-
这种编程风格使得代码更加灵活,能够方便地实现各种自定义的操作逻辑,并且可以提高代码的复用性。
-
与 Stream API 结合使用:
-
Java 8 的 Stream API 用于对集合进行高效的操作,如过滤、映射、排序等。Lambda 表达式在 Stream API 中起到了关键作用。例如,要从一个整数列表中过滤出所有大于 5 的数,然后将这些数转换为它们的平方,并求和,可以使用如下代码:
List numbers = Arrays.asList(1, 6, 3, 8, 4); int sum = numbers.stream() .filter(n -> n > 5) .map(n -> n * n) .reduce(0, (a, b) -> a + b);
-
-
这里的
filter、map和reduce方法都接受 Lambda 表达式作为参数,通过这些 Lambda 表达式定义了具体的过滤、映射和聚合操作,使得对集合的复杂操作变得简洁而高效。
- 与匿名内部类的比较
-
语法简洁性:
- Lambda 表达式比匿名内部类更加简洁。例如,定义一个
Runnable接口的实现,使用匿名内部类可能是这样:
new Runnable() { @Override public void run() { System.out.println("Running"); } };
- Lambda 表达式比匿名内部类更加简洁。例如,定义一个
-
- 而使用 Lambda 表达式则可以简化为
() -> System.out.println("Running");,明显减少了代码量,并且更易读。 - 语义清晰性:
- Lambda 表达式更侧重于表达 “做什么”,而匿名内部类更侧重于 “怎么做”。在 Lambda 表达式中,重点是参数和操作的逻辑关系,而匿名内部类需要定义完整的类结构(虽然是匿名的),包括可能的构造方法、成员变量等。例如,在使用函数式接口时,Lambda 表达式能够直接体现函数的输入和输出关系,使代码的意图更加清晰。
- 性能方面(在某些情况下):
- Lambda 表达式在编译后可能会生成更高效的字节码。因为编译器可以对 Lambda 表达式进行更多的优化,而匿名内部类由于其完整的类结构,可能会有一些额外的开销,如对象创建、方法调用等。不过,这种性能差异在大多数情况下可能并不显著,除非是在对性能要求极高的场景中。
六、说说你对泛型的理解
- 定义和基本概念
-
泛型是 Java 中的一种强大的特性,它允许在定义类、接口和方法时使用类型参数。这些类型参数可以在使用时被具体的类型所替代,从而实现代码的通用性和类型安全性。简单来说,泛型就像是一个模板,你可以在这个模板中使用一个占位符(类型参数)来表示某种类型,在实际使用这个模板(类、接口或方法)时,再确定这个占位符具体代表什么类型。
-
例如,定义一个简单的泛型类
Box,用于存放某种类型的对象:class Box { private T content; public Box(T content) { this.content = content; } public T getContent() { return content; } }
-
在这个Box类中,T就是一个类型参数。它可以代表任何类型,比如Box<Integer>就表示这个Box类用于存放Integer类型的对象,Box<String>则表示用于存放String类型的对象。
- 泛型的用途和优势
-
类型安全:
- 在没有泛型的情况下,如果要创建一个可以存放不同类型对象的容器类,可能会使用
Object类型来实现。但是这样会导致类型不安全的问题。例如:
class NonGenericBox { private Object content; public NonGenericBox(Object content) { this.content = content; } public Object getContent() { return content; } }
- 在没有泛型的情况下,如果要创建一个可以存放不同类型对象的容器类,可能会使用
-
可以这样使用这个类:
NonGenericBox box = new NonGenericBox("string");
Integer value = (Integer) box.getContent(); // 运行时会抛出ClassCastException
因为在获取对象时需要进行强制类型转换,而且如果转换类型错误,只有在运行时才会发现问题。而使用泛型,编译器可以在编译阶段就检查类型是否正确,例如对于Box<String>类型的对象,编译器会阻止你将其内容当作Integer类型来使用,从而提高了代码的安全性。
- 代码复用:
-
泛型可以大大提高代码的复用性。通过使用类型参数,可以编写一个通用的代码结构,适用于多种不同类型。例如,定义一个泛型方法用于交换两个变量的值:
public static void swap(T[] array, int i, int j) { T temp = array[i]; array[i] = array[j]; array[j] = temp; }
-
这个swap方法可以用于交换任何类型数组中的两个元素,无论是Integer数组、String数组还是其他自定义类型的数组,只要数组元素类型相同即可。这样就避免了为每种类型都编写一个专门的交换方法,减少了代码的重复。
- 泛型的类型参数限定
-
上界限定:
- 可以使用
extends关键字来限定类型参数的上界。这意味着类型参数必须是指定类型或其子类型。例如,定义一个泛型方法用于计算一个集合中元素的最大值,假设集合中的元素实现了Comparable接口,以便能够比较大小:
public static <T extends Comparable> T max(List list) { T max = list.get(0); for (T element : list) { if (element.compareTo(max) > 0) { max = element; } } return max; }
- 可以使用
-
这里T extends Comparable<T>表示类型参数T必须是实现了Comparable接口的类型,并且比较方法的参数类型也是T自身。这样可以确保在方法内部能够正确地比较集合中的元素大小。
- 下界限定(相对较少使用):
- 使用
super关键字来限定类型参数的下界。例如,在一个方法中,如果需要将一个对象添加到一个集合中,并且这个集合的元素类型是某个类型或其超类型,可以使用下界限定。不过,在实际应用中,下界限定的使用场景相对较少,而且理解和使用起来比上界限定稍微复杂一些。
- 使用
-
泛型在集合框架中的应用
- 集合框架(如
ArrayList、HashMap等)广泛使用了泛型。例如,ArrayList<String>表示这个列表中只能存放String类型的元素,HashMap<Integer, String>表示这个哈希表的键是Integer类型,值是String类型。这种使用泛型的方式使得集合的操作更加类型安全,并且在获取元素时不需要进行大量的强制类型转换。例如,在ArrayList<Integer>中获取元素时,编译器已经知道元素是Integer类型,所以可以直接使用int变量来接收,而不会出现类型不匹配的问题。
- 集合框架(如
-
泛型的类型擦除
- 在 Java 中,泛型是通过类型擦除来实现的。这意味着在编译后的字节码中,泛型类型信息会被擦除。例如,对于
Box<Integer>和Box<String>这两个类型,在字节码层面,它们的类型都是Box(类型参数被擦除)。不过,编译器会在编译阶段根据泛型信息进行类型检查和必要的类型转换插入。类型擦除主要是为了兼容旧版本的 Java 代码,因为在泛型引入之前,已经有大量的非泛型代码存在。但是,类型擦除也会带来一些限制,比如无法在运行时获取泛型的实际类型参数(不过可以通过一些技巧,如在类中添加一个类型标记来间接获取)。
- 在 Java 中,泛型是通过类型擦除来实现的。这意味着在编译后的字节码中,泛型类型信息会被擦除。例如,对于
七、notify() 与notifyAll()有什么区别
-
定义和基本功能
notify()方法:- 它是
java.lang.Object类中的一个方法,用于唤醒在该对象监视器(锁)上等待的单个线程。当一个线程调用notify()方法时,会从在该对象上等待的线程池中随机选择一个线程,并将其唤醒,使这个线程从等待状态变为就绪状态,等待 CPU 调度后继续执行。例如,在一个生产者 - 消费者模型中,如果消费者线程因为没有产品可消费而等待,生产者生产出产品后,可以调用notify()方法来唤醒一个等待的消费者线程。
- 它是
notifyAll()方法:- 同样是
Object类中的方法,用于唤醒在该对象监视器(锁)上等待的所有线程。与notify()不同的是,它会把所有因为调用该对象的wait()方法而进入等待状态的线程全部唤醒,这些被唤醒的线程都会从等待状态变为就绪状态,然后竞争 CPU 资源,等待被调度执行。例如,在一个线程池实现中,如果多个线程因为任务队列空了而等待任务,当有新任务加入任务队列时,可以调用notifyAll()方法来唤醒所有等待的线程,让它们来竞争新的任务。
- 同样是
-
使用场景和效果对比
- 使用场景:
notify():- 适用于只需要唤醒一个等待线程就能满足需求的场景。比如,在一个简单的资源池实现中,当一个资源被释放时,只需要唤醒一个等待获取资源的线程即可,因为一个资源只能被一个线程使用。如果使用
notifyAll(),可能会导致多个线程被唤醒后竞争资源,造成不必要的开销和可能的资源冲突。
- 适用于只需要唤醒一个等待线程就能满足需求的场景。比如,在一个简单的资源池实现中,当一个资源被释放时,只需要唤醒一个等待获取资源的线程即可,因为一个资源只能被一个线程使用。如果使用
notifyAll():- 适用于多个等待线程都需要被唤醒的场景。例如,在一个多生产者多消费者模型中,当生产者往缓冲区添加了多个产品后,为了让所有等待的消费者线程都有机会获取产品进行消费,就需要调用
notifyAll()方法。
- 适用于多个等待线程都需要被唤醒的场景。例如,在一个多生产者多消费者模型中,当生产者往缓冲区添加了多个产品后,为了让所有等待的消费者线程都有机会获取产品进行消费,就需要调用
- 效果对比:
notify():- 调用
notify()方法后,只有一个线程会被唤醒,这可能导致某些线程长时间得不到唤醒,从而产生饥饿现象。例如,在一个有多个优先级相同的等待线程的场景中,如果总是唤醒同一个线程,其他线程就会一直处于等待状态。
- 调用
notifyAll():- 调用
notifyAll()方法会唤醒所有等待线程,这可能会导致 “惊群效应”。即多个被唤醒的线程会竞争锁和资源,可能会造成系统资源的浪费,因为在很多情况下,只有部分线程能够真正获取到所需的资源并执行,其他线程在竞争失败后又会进入等待状态。不过,在一些情况下,如需要让所有等待线程重新评估它们的等待条件时,notifyAll()是必要的。
- 调用
- 使用场景:
-
代码示例和潜在问题
-
代码示例(生产者 - 消费者模型):
- 以下是一个简单的生产者 - 消费者模型示例,展示
notify()和notifyAll()的不同用法。 - 首先是使用
notify()的情况:
class Buffer { private int data; private boolean isEmpty = true; public synchronized void put(int value) { while (!isEmpty) { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } data = value; isEmpty = false; notify(); } public synchronized int get() { while (isEmpty) { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } isEmpty = true; notify(); return data; } } class Producer implements Runnable { private Buffer buffer; public Producer(Buffer buffer) { this.buffer = buffer; } @Override public void run() { for (int i = 0; i < 10; i++) { buffer.put(i); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } class Consumer implements Runnable { private Buffer buffer; public Consumer(Buffer buffer) { this.buffer = buffer; } @Override public void run() { for (int i = 0; i < 10; i++) { int value = buffer.get(); System.out.println("Consumed: " + value); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } public class Main { public static void main(String[] args) { Buffer buffer = new Buffer(); Thread producerThread = new Thread(new Producer(buffer)); Thread consumerThread = new Thread(new Consumer(buffer)); producerThread.start(); consumerThread.start(); } }
- 以下是一个简单的生产者 - 消费者模型示例,展示
-
在这个示例中,Buffer类是一个简单的缓冲区,put方法用于生产者向缓冲区放入数据,get方法用于消费者从缓冲区获取数据。当缓冲区为空时,消费者线程会等待;当缓冲区为满时,生产者线程会等待。在put和get方法中,使用notify()来唤醒等待的线程。这种方式在只有一个生产者和一个消费者的情况下可以正常工作,但如果有多个消费者,可能会出现某些消费者线程长时间等待的情况。
-
下面是使用
notifyAll()的情况:class BufferWithNotifyAll { private int data; private boolean isEmpty = true; public synchronized void put(int value) { while (!isEmpty) { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } data = value; isEmpty = false; notifyAll(); } public synchronized int get() { while (isEmpty) { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } isEmpty = true; notifyAll(); return data; } } class ProducerWithNotifyAll implements Runnable { private BufferWithNotifyAll buffer; public ProducerWithNotifyAll(BufferWithNotifyAll buffer) { this.buffer = buffer; } @Override public void run() { for (int i = 0; i < 10; i++) { buffer.put(i); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } class ConsumerWithNotifyAll implements Runnable { private BufferWithNotifyAll buffer; public ConsumerWithNotifyAll(BufferWithNotifyAll buffer) { this.buffer = buffer; } @Override public void run() { for (int i = 0; i < 10; i++) { int value = buffer.get(); System.out.println("Consumed: " + value); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } public class MainWithNotifyAll { public static void main(String[] args) { BufferWithNotifyAll buffer = new BufferWithNotifyAll(); Thread producerThread = new Thread(new ProducerWithNotifyAll(buffer)); Thread[] consumerThreads = new Thread[3]; for (int i = 0; i < 3; i++) { consumerThreads[i] = new Thread(new ConsumerWithNotifyAll(buffer)); consumerThreads[i].start(); } producerThread.start(); } }
在这个示例中,BufferWithNotifyAll类的put和get方法使用notifyAll()来唤醒等待的线程。这样在有多个消费者的情况下,可以让所有消费者线程都有机会被唤醒,从而避免某个消费者线程长时间等待的情况,但可能会导致多个消费者线程频繁竞争资源的问题。
- 潜在问题:
notify()的潜在问题:- 如前面提到的,可能导致线程饥饿。而且,如果在不恰当的时机使用
notify(),可能会导致程序逻辑错误。例如,如果在等待条件还没有完全满足的情况下就调用notify(),可能会唤醒一个线程,但这个线程醒来后发现条件仍然不满足,又会再次进入等待状态,这会造成不必要的线程唤醒和等待开销。
- 如前面提到的,可能导致线程饥饿。而且,如果在不恰当的时机使用
notifyAll()的潜在问题:- 主要是惊群效应带来的性能问题。当大量线程被唤醒后竞争资源,会消耗大量的 CPU 资源用于线程调度和竞争,降低系统的整体性能。另外,在一些复杂的并发场景下,如果没有正确处理被唤醒线程的逻辑,可能会导致数据不一致或者死锁等问题。例如,多个被唤醒的线程同时访问和修改共享资源,没有正确的同步机制,可能会导致数据被破坏。
八、静态内部类与非静态内部类有什么区别
- 与外部类的关联关系
-
非静态内部类:
- 非静态内部类是与外部类的实例相关联的。它可以访问外部类的所有成员,包括私有成员,就好像它是外部类的一个成员一样。这是因为非静态内部类对象隐式地持有外部类对象的引用。例如,假设有一个外部类
Outer和一个非静态内部类Inner:
class Outer { private int outerVariable = 10; class Inner { public void accessOuterVariable() { System.out.println(outerVariable); } } }
- 非静态内部类是与外部类的实例相关联的。它可以访问外部类的所有成员,包括私有成员,就好像它是外部类的一个成员一样。这是因为非静态内部类对象隐式地持有外部类对象的引用。例如,假设有一个外部类
-
在Inner类的accessOuterVariable方法中,可以直接访问Outer类的private成员变量outerVariable,这是因为每个Inner类的对象都与一个Outer类的对象相关联,在内部类对象的内部,通过这个隐含的引用可以访问外部类的成员。
- 静态内部类:
-
静态内部类不依赖于外部类的实例,它更像是外部类的一个静态成员。静态内部类不能直接访问外部类的非静态成员,因为它没有外部类对象的引用。但是,它可以访问外部类的静态成员。例如:
class OuterWithStaticInner { private static int outerStaticVariable = 20; static class StaticInner { public void accessOuterStaticVariable() { System.out.println(outerStaticVariable); } } }
-
在StaticInner类的accessOuterStaticVariable方法中,可以访问OuterWithStaticInner类的静态成员变量outerStaticVariable,但无法直接访问外部类的非静态成员。
- 创建对象的方式
-
非静态内部类:
- 要创建非静态内部类的对象,必须先有一个外部类的对象。因为非静态内部类的对象是与外部类对象相关联的。例如,对于前面的
Outer和Inner类:
Outer outerObject = new Outer(); Outer.Inner innerObject = outerObject.new Inner();
- 要创建非静态内部类的对象,必须先有一个外部类的对象。因为非静态内部类的对象是与外部类对象相关联的。例如,对于前面的
-
首先创建了外部类Outer的对象outerObject,然后通过outerObject.new Inner()这种方式来创建内部类Inner的对象innerObject。
- 静态内部类:
-
创建静态内部类的对象不需要外部类的实例。可以像创建普通类对象一样创建静态内部类的对象,只是需要使用外部类来限定它的名称。例如,对于
OuterWithStaticInner和StaticInner类:OuterWithStaticInner.StaticInner staticInnerObject = new OuterWithStaticInner.StaticInner();
-
-
在内存中的存储方式和生命周期
- 非静态内部类:
- 非静态内部类的对象是和外部类对象紧密相连的,它们在内存中的存储位置与外部类对象相关。当创建一个非静态内部类对象时,会在内存中为其分配空间,并且这个空间包含了一个指向外部类对象的引用。非静态内部类对象的生命周期依赖于外部类对象的生命周期。当外部类对象被垃圾回收时,与之关联的非静态内部类对象也会被回收(前提是没有其他引用指向该内部类对象)。
- 静态内部类:
- 静态内部类对象的存储和生命周期与外部类的实例无关。它在内存中的存储方式更类似于普通的类,只要类被加载,静态内部类就可以被使用,其对象的生命周期由垃圾回收机制根据对象是否可达来决定,和外部类的对象创建和销毁没有直接关系。
- 非静态内部类:
-
使用场景和用途
- 非静态内部类:
- 适用于内部类需要频繁访问外部类的实例成员,并且内部类的存在是和外部类的实例紧密相关的情况。例如,在图形用户界面(GUI)编程中,一个按钮(外部类的一个实例)的事件处理类(内部类)可能需要访问按钮的一些属性(如按钮的文本、状态等),这时使用非静态内部类就很合适。
- 静态内部类:
- 适用于内部类不需要访问外部类的非静态成员,并且内部类本身可以独立存在的情况。比如,一个工具类(静态内部类)用于处理外部类相关的一些通用操作,但这些操作不依赖于外部类的具体实例,就可以将其定义为静态内部类。例如,外部类是一个数据库连接类,内部有一个静态内部类用于处理数据库连接的配置信息,这个配置信息与具体的数据库连接实例无关,使用静态内部类可以更好地组织代码。
- 非静态内部类:
九、Strings 与 new String 有什么区别
-
字符串常量池的概念
- 在 Java 中,为了提高性能和节省内存,存在一个字符串常量池。字符串常量池用于存储字符串常量,当创建一个字符串常量时,Java 会首先在常量池中查找是否已经存在相同内容的字符串。如果存在,就直接返回常量池中已有的字符串对象的引用;如果不存在,则在常量池中创建一个新的字符串对象并返回其引用。
-
String s = "字符串"的方式-
存储位置和对象复用:
- 当使用
String s = "字符串"这种方式创建字符串时,Java 会首先在字符串常量池中查找是否已经存在内容为 “字符串” 的字符串对象。如果有,就将s指向常量池中已有的这个字符串对象;如果没有,就在常量池中创建一个新的字符串对象,并将s指向这个新对象。例如:
String s1 = "hello"; String s2 = "hello"; System.out.println(s1 == s2); // 输出为true
- 当使用
-
在这个例子中,s1和s2都指向字符串常量池中同一个 “hello” 字符串对象,因为在创建s2时,发现常量池中已经存在 “hello” 这个字符串,所以直接复用了已有的对象,==比较的是两个对象的引用,所以结果为true。
- 性能优势和内存节省:
- 这种方式在内存使用上比较高效,因为可以复用常量池中的字符串对象。特别是当程序中大量使用相同的字符串常量时,能够减少内存中字符串对象的数量,降低内存开销。而且,在比较两个通过这种方式创建的相同内容的字符串时(使用
==),速度也比较快,因为实际上比较的是同一个对象的引用。
- 这种方式在内存使用上比较高效,因为可以复用常量池中的字符串对象。特别是当程序中大量使用相同的字符串常量时,能够减少内存中字符串对象的数量,降低内存开销。而且,在比较两个通过这种方式创建的相同内容的字符串时(使用
String s = new String("字符串")的方式-
存储位置和对象创建过程:
- 当使用
String s = new String("字符串")创建字符串时,首先会在字符串常量池中查找是否存在内容为 “字符串” 的字符串对象。如果不存在,会在常量池中创建一个;然后,无论常量池中是否已经存在,都会在堆内存中创建一个新的String对象,这个新对象的内容是从常量池中复制过来的,最后将s指向堆内存中的这个新对象。例如:
String s3 = new String("world"); String s4 = new String("world"); System.out.println(s3 == s4); // 输出为false
- 当使用
-
在这里,虽然字符串常量池中只有一个 “world” 字符串对象,但在堆内存中创建了两个不同的String对象,s3和s4分别指向这两个不同的堆内存中的对象,所以==比较结果为false。
- 使用场景和潜在问题:
- 这种方式在某些特定场景下可能是必要的,比如需要创建一个可修改的字符串副本(虽然
String本身是不可变的,但可以通过StringBuilder或StringBuffer来修改副本),或者在需要明确地创建一个新的字符串对象而不考虑常量池中的复用情况时。然而,它的缺点是会占用更多的内存,因为除了常量池中的对象(如果不存在则创建),还会在堆内存中创建新的对象。而且在比较字符串内容时,如果使用==,可能会得到不符合预期的结果,应该使用equals方法来比较字符串的内容。
- 这种方式在某些特定场景下可能是必要的,比如需要创建一个可修改的字符串副本(虽然
十、反射中 Class.forName 和ClassLoader 的区别
-
基本概念和用途
Class.forName()方法:Class.forName()是java.lang.Class类中的一个静态方法。它的主要作用是根据给定的全限定类名(包括包名和类名)来加载类,并返回对应的Class对象。这个方法在加载类的同时,还会执行类的静态初始化块(如果有的话)。例如,在使用 JDBC 连接数据库时,通常会使用Class.forName("com.mysql.jdbc.Driver")来加载 MySQL 的驱动类。加载这个驱动类后,会执行驱动类中的静态代码块,完成一些必要的初始化工作,比如注册驱动。
ClassLoader类:ClassLoader是一个抽象类,它的主要职责是负责加载类。Java 中有不同类型的类加载器,如引导类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用程序类加载器(Application ClassLoader)。ClassLoader提供了一种机制,可以从不同的来源(如本地文件系统、网络等)加载类文件,并且可以自定义类加载的过程。例如,在一些需要动态加载插件或者自定义类加载策略的场景中,就会用到ClassLoader。它可以通过loadClass()方法来加载类,不过与Class.forName()不同的是,loadClass()方法默认情况下不会执行类的静态初始化块。
-
加载过程和执行细节对比
- 类的加载和链接阶段:
-
Class.forName():- 当调用
Class.forName()方法时,它会经历完整的类加载过程,包括加载、链接(验证、准备、解析)和初始化。在初始化阶段,会执行类中的静态初始化块和静态变量的初始化。例如,假设有一个类MyClass,其中有一个静态初始化块和一个静态变量:
class MyClass { static { System.out.println("MyClass静态初始化块执行"); } static int staticVariable = 10; }
- 当调用
-
- 类的加载和链接阶段:
如果执行Class.forName("MyClass"),就会看到控制台输出 “MyClass 静态初始化块执行”,并且staticVariable也会被初始化。
-
ClassLoader的loadClass()方法:-
当使用
ClassLoader的loadClass()方法加载类时,默认情况下,它只会执行类加载过程中的加载和链接阶段,不会自动执行类的初始化。例如,使用应用程序类加载器(ClassLoader.getSystemClassLoader())加载MyClass:ClassLoader classLoader = ClassLoader.getSystemClassLoader(); try { Class<?> loadedClass = classLoader.loadClass("MyClass"); // 此时不会执行MyClass的静态初始化块和静态变量初始化 } catch (ClassNotFoundException e) { e.printStackTrace(); }
-
-
只有在使用加载后的
Class对象的一些方法(如newInstance()方法来创建类的实例)或者访问类的静态成员时,才会触发类的初始化。 -
对类加载器层次结构的影响:
Class.forName():- 它使用的是当前线程的上下文类加载器(
Thread.currentThread().getContextClassLoader())来加载类。这个类加载器通常是应用程序类加载器,但在一些复杂的环境(如在 Web 容器中),上下文类加载器可能会被修改。例如,在一个 Web 应用程序中,当使用Class.forName()加载一个自定义的类时,它会根据 Web 容器设置的上下文类加载器来查找和加载类。
- 它使用的是当前线程的上下文类加载器(
ClassLoader类:- 可以明确指定使用哪个类加载器来加载类。例如,可以使用引导类加载器(虽然它是用本地方法实现的,通过
null来表示)、扩展类加载器或者自定义的类加载器。这在一些需要严格控制类加载路径或者需要从特殊来源加载类的场景中非常有用。比如,自定义一个类加载器从加密的类文件或者远程服务器上加载类。
- 可以明确指定使用哪个类加载器来加载类。例如,可以使用引导类加载器(虽然它是用本地方法实现的,通过
- 使用场景和优势对比
Class.forName()的使用场景和优势:- 数据库驱动加载:如前面提到的 JDBC 驱动加载场景,使用
Class.forName()可以方便地加载驱动类,并且执行驱动类中的静态初始化代码,确保驱动能够正确注册。这种方式在传统的数据库连接操作中非常常见,简单直接。 - 简单的动态类加载需求且需要初始化类:如果只是偶尔需要动态加载一个类并且希望在加载时就执行类的初始化,
Class.forName()是一个不错的选择。例如,在一个小型的插件系统中,如果插件类比较简单,并且插件类的初始化过程比较重要(如加载配置文件等),可以使用Class.forName()来加载插件类。
- 数据库驱动加载:如前面提到的 JDBC 驱动加载场景,使用
ClassLoader的使用场景和优势:- 自定义类加载策略:当需要从特定的位置(如自定义的目录、网络资源等)加载类,或者需要对类加载过程进行更多的控制(如解密类文件、验证类来源等)时,
ClassLoader就发挥作用了。例如,在一个安全要求较高的系统中,自定义类加载器可以检查类文件的数字签名,只有通过验证的类文件才会被加载。 - 实现类的热插拔和动态更新:在一些需要动态更新类的应用场景中,如开发工具、游戏开发中的脚本系统等,可以使用
ClassLoader来实现类的热插拔。通过创建新的类加载器或者替换原有的类加载器来加载更新后的类,而不影响已经加载的其他类的运行。这种机制可以实现应用程序在运行过程中动态更新部分功能,而不需要重新启动整个应用。
- 自定义类加载策略:当需要从特定的位置(如自定义的目录、网络资源等)加载类,或者需要对类加载过程进行更多的控制(如解密类文件、验证类来源等)时,