一文吃透Java集合:从基础到实战,带你玩转数据容器(上)
下一篇一文吃透Java集合:从基础到实战,带你玩转数据容器(下)
一、Java 集合初相识
在 Java 的编程世界里,集合(Collections)可是一个超级重要的角色,就像是我们日常生活中的收纳盒,只不过这个 “收纳盒” 更加智能和强大,能帮我们管理各种数据。不管是开发一个小型的命令行工具,还是构建大型的企业级应用,集合都无处不在,是 Java 程序员必不可少的工具之一。
想象一下,你要管理一个班级学生的成绩,如果没有集合,你可能得用数组来存储成绩,但是数组的大小是固定的,一旦学生人数有变动,那可就麻烦了。而集合就不一样啦,它可以动态地调整大小,轻松应对各种变化。这只是集合的一个小小应用场景,实际上,在数据处理、算法实现、Web 开发等众多领域,集合都发挥着关键作用。
Java 集合框架提供了一套丰富的接口和类,让我们可以方便地操作和管理数据。它就像是一个大家族,包含了各种不同类型的 “成员”,每个 “成员” 都有自己独特的功能和用途。接下来,就让我们一起深入这个大家族,看看都有哪些重要的 “成员” 吧!
二、集合与数组的爱恨情仇
在 Java 的世界里,数组和集合就像是两个性格迥异的小伙伴,虽然都能用来存储数据,但在很多方面都有着明显的不同。
2.1 定义与声明
-
数组:数组是一种固定长度的数据结构,在声明时就需要指定其大小。比如,我们要创建一个存储整数的数组,可以这样写:
int[] numbers = new int[5];
,这里就明确了数组numbers
的长度是 5,之后这个长度就不能再改变了。如果我们想要存储不同类型的数据,就需要使用对象数组,比如Object[] objects = new Object[3];
,但这样会失去类型安全的优势。 -
集合:集合则是可变长度的数据结构,它的大小可以根据需要动态调整。以
ArrayList
为例,我们可以这样创建一个存储整数的集合:ArrayList<Integer> list = new ArrayList<>();
,这里并没有指定集合的初始大小,它会在添加元素时自动扩容。
2.2 元素类型
-
数组:数组只能存储相同类型的数据,这是它的一个限制。如果我们需要存储不同类型的数据,就需要使用
Object
数组,但这样会失去类型安全的保障,在使用时需要进行类型转换,容易出错。 -
集合:集合可以存储不同类型的对象,通过泛型的支持,我们可以在编译时确保类型安全。比如,
ArrayList<String> stringList = new ArrayList<>();
,这个集合就只能存储String
类型的对象,如果尝试添加其他类型的对象,编译器会报错。
2.3 内存分配
-
数组:数组在内存中是连续分配的,这使得它在访问元素时非常高效,因为可以通过索引直接计算出元素的内存地址。例如,
int[] arr = {1, 2, 3, 4, 5};
,数组arr
的元素在内存中是紧密排列的,访问arr[2]
时,通过数组的基地址加上偏移量就能快速找到元素 3。但是,这种连续内存分配也带来了一些问题,比如在插入或删除元素时,可能需要移动大量的元素,效率较低。 -
集合:集合中的元素在内存中不一定是连续存储的。以
ArrayList
为例,它底层是通过动态数组实现的,在内存不足时会进行扩容,重新分配内存空间,将原来的元素复制到新的内存中。而LinkedList
则是基于链表实现,每个节点包含元素和指向下一个节点的引用,节点在内存中是分散存储的。这种内存分配方式使得集合在插入和删除元素时更加灵活,但在随机访问元素时效率相对较低。
2.4 操作方法
-
数组:数组提供的操作方法相对较少,主要是通过索引来获取和设置元素。例如,
arr[0] = 10;
可以设置数组arr
的第一个元素为 10,int value = arr[1];
可以获取数组arr
的第二个元素的值。如果要进行排序、查找等操作,需要使用Arrays
类提供的静态方法,如Arrays.sort(arr);
对数组arr
进行排序。 -
集合:集合框架提供了丰富的操作方法,比如添加元素(
add
)、删除元素(remove
)、查找元素(contains
)、排序(Collections.sort
)等。以ArrayList
为例,list.add(1);
可以向集合list
中添加一个元素 1,list.remove(0);
可以删除集合list
中的第一个元素,boolean exists = list.contains(2);
可以判断集合list
中是否包含元素 2。这些方法使得对集合的操作更加方便和灵活。
2.5 使用场景
-
数组:当数据量固定,并且需要频繁进行随机访问操作时,数组是更好的选择。例如,存储一周的天气数据,由于一周的天数是固定的,并且我们可能经常需要根据索引获取某一天的天气情况,这时使用数组就非常合适。此外,数组在存储基本数据类型时,比集合更节省内存空间,因为集合只能存储对象,需要额外的内存开销来存储对象的元数据。
-
集合:当数据量不确定,或者需要频繁进行插入、删除操作时,集合更为适合。比如,处理一个用户输入的动态数据列表,数据量在运行时是未知的且可能频繁变动,使用集合就能轻松应对。而且,集合提供的丰富操作方法,使得在进行数据处理、算法实现等场景下,比数组更具优势。例如,在实现一个简单的购物车功能时,使用集合可以方便地添加、删除商品,计算总价等。
总的来说,数组和集合各有优缺点,在实际开发中,我们需要根据具体的需求来选择使用哪种数据结构。就像选择工具一样,合适的才是最好的。
三、Java 集合家族大揭秘
3.1 家族体系总览
Java 集合框架就像是一个庞大的家族,而Collection
和Map
就是这个家族中最核心的两大接口,它们各自带领着一群 “子子孙孙”,共同构成了丰富多彩的集合世界。
先来看Collection
接口,它是所有单列集合的根接口,就像是家族中的大家长,定义了一些通用的操作方法,比如添加元素、删除元素、判断集合是否包含某个元素等。Collection
接口有三个重要的子接口:List
、Set
和Queue
。
List
接口代表一个有序、可重复的集合,就像一个有序的清单,每个元素都有其对应的顺序索引。常见的实现类有ArrayList
和LinkedList
,ArrayList
基于动态数组实现,查询效率高,但增删元素时可能需要移动大量元素;LinkedList
基于双向链表实现,增删元素效率高,但随机访问元素时需要遍历链表,效率较低。
Set
接口代表一个无序、不可重复的集合,就像一个独特物品的收纳盒,里面的元素不能重复。常见的实现类有HashSet
、LinkedHashSet
和TreeSet
。HashSet
基于哈希表实现,插入、删除和查找操作的时间复杂度通常为 O (1),但不保证元素的遍历顺序;LinkedHashSet
在哈希表的基础上维护了一个双向链表,因此可以保证元素的插入顺序;TreeSet
基于红黑树实现,元素会按照自然顺序或自定义顺序排序。
Queue
接口用于模拟队列这种数据结构,遵循先进先出(FIFO)的原则,就像排队买票一样,先到的先处理。常见的实现类有PriorityQueue
和LinkedList
,PriorityQueue
基于堆结构实现,可以根据元素的优先级进行排序;LinkedList
既可以作为链表使用,也可以作为队列使用。
再看Map
接口,它和Collection
接口不同,它存储的是键值对(Key - Value)形式的数据,就像一本字典,通过键可以快速查找对应的值。Map
接口的常见实现类有HashMap
、LinkedHashMap
、TreeMap
和Hashtable
。HashMap
基于哈希表实现,查询效率高,允许一个null
键和多个null
值,但不保证元素的顺序;LinkedHashMap
继承自HashMap
,通过维护一个双向链表来保持元素的插入顺序或访问顺序;TreeMap
基于红黑树实现,键会按照自然顺序或自定义顺序排序;Hashtable
是一个古老的实现类,线程安全,但不允许null
键和null
值,性能相对较低。
下面是 Java 集合体系的类图,通过这个图可以更直观地了解它们之间的关系:
3.2 Collection 接口详解
Collection
接口是 Java 集合框架中所有单列集合的根接口,它定义了一系列操作集合的方法,这些方法是所有单列集合都必须实现的。可以把Collection
接口想象成一个模板,它规定了集合应该具备的基本功能。
常用方法
-
添加元素:
boolean add(E e)
:向集合中添加一个元素,如果添加成功则返回true
,否则返回false
。比如:
Collection<String> collection = new ArrayList<>();
boolean added = collection.add("apple");
System.out.println(added); // 输出 true
boolean addAll(Collection<? extends E> c)
:将指定集合中的所有元素添加到当前集合中,如果当前集合因为这个操作而发生了改变,则返回true
。例如:
Collection<String> collection1 = new ArrayList<>();
collection1.add("apple");
collection1.add("banana");
Collection<String> collection2 = new ArrayList<>();
collection2.add("cherry");
boolean addedAll = collection1.addAll(collection2);
System.out.println(addedAll); // 输出 true
System.out.println(collection1); // 输出 [apple, banana, cherry]
-
删除元素:
boolean remove(Object o)
:从集合中移除指定的元素,如果移除成功则返回true
,否则返回false
。比如:
Collection<String> collection = new ArrayList<>();
collection.add("apple");
boolean removed = collection.remove("apple");
System.out.println(removed); // 输出 true
boolean removeAll(Collection<?> c)
:从当前集合中移除指定集合中包含的所有元素,如果当前集合因为这个操作而发生了改变,则返回true
。例如:
Collection<String> collection1 = new ArrayList<>();
collection1.add("apple");
collection1.add("banana");
collection1.add("cherry");
Collection<String> collection2 = new ArrayList<>();
collection2.add("banana");
boolean removedAll = collection1.removeAll(collection2);
System.out.println(removedAll); // 输出 true
System.out.println(collection1); // 输出 [apple, cherry]
-
查找元素:
boolean contains(Object o)
:判断集合中是否包含指定的元素,如果包含则返回true
,否则返回false
。比如:
Collection<String> collection = new ArrayList<>();
collection.add("apple");
boolean contains = collection.contains("apple");
System.out.println(contains); // 输出 true
boolean containsAll(Collection<?> c)
:判断当前集合是否包含指定集合中的所有元素,如果包含则返回true
,否则返回false
。例如:
Collection<String> collection1 = new ArrayList<>();
collection1.add("apple");
collection1.add("banana");
Collection<String> collection2 = new ArrayList<>();
collection2.add("apple");
boolean containsAll = collection1.containsAll(collection2);
System.out.println(containsAll); // 输出 true
-
其他方法:
-
int size()
:返回集合中元素的个数。 -
boolean isEmpty()
:判断集合是否为空,如果集合中没有元素则返回true
,否则返回false
。 -
void clear()
:清空集合中的所有元素。 -
Object[] toArray()
:将集合转换为一个数组。 -
<T> T[] toArray(T[] a)
:将集合转换为指定类型的数组。
-
3.3 Map 接口详解
Map
接口是 Java 集合框架中用于存储键值对(Key - Value)数据的接口,它提供了一种通过键来快速访问对应值的数据结构,就像我们日常生活中的字典,通过单词(键)可以查到对应的释义(值)。
常用方法
-
添加键值对:
V put(K key, V value)
:将指定的键值对添加到Map
中,如果Map
中已经存在该键,则会用新的值替换旧的值,并返回旧值;如果Map
中不存在该键,则直接添加键值对,并返回null
。例如:
Map<String, Integer> map = new HashMap<>();
Integer oldValue = map.put("apple", 1);
System.out.println(oldValue); // 输出 null
oldValue = map.put("apple", 2);
System.out.println(oldValue); // 输出 1
-
获取值:
V get(Object key)
:根据指定的键获取对应的值,如果Map
中不存在该键,则返回null
。比如:
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
Integer value = map.get("apple");
System.out.println(value); // 输出 1
value = map.get("banana");
System.out.println(value); // 输出 null
-
删除键值对:
V remove(Object key)
:从Map
中移除指定键对应的键值对,并返回被移除的值,如果Map
中不存在该键,则返回null
。例如:
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
Integer removedValue = map.remove("apple");
System.out.println(removedValue); // 输出 1
removedValue = map.remove("banana");
System.out.println(removedValue); // 输出 null
-
判断是否包含键或值:
-
boolean containsKey(Object key)
:判断Map
中是否包含指定的键,如果包含则返回true
,否则返回false
。 -
boolean containsValue(Object value)
:判断Map
中是否包含指定的值,如果包含则返回true
,否则返回false
。
-
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
boolean containsKey = map.containsKey("apple");
System.out.println(containsKey); // 输出 true
boolean containsValue = map.containsValue(1);
System.out.println(containsValue); // 输出 true
-
获取所有键或值的集合:
-
Set<K> keySet()
:返回Map
中所有键的集合,类型为Set
,因为键是唯一的。 -
Collection<V> values()
:返回Map
中所有值的集合,类型为Collection
,值可以重复。 -
Set<Map.Entry<K, V>> entrySet()
:返回Map
中所有键值对的集合,每个键值对被封装成一个Entry
对象,类型为Set
。
-
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
Set<String> keySet = map.keySet();
System.out.println(keySet); // 输出 [apple, banana]
Collection<Integer> values = map.values();
System.out.println(values); // 输出 [1, 2]
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
for (Map.Entry<String, Integer> entry : entrySet) {
System.out.println(entry.getKey() + " : " + entry.getValue());
}
// 输出 apple : 1
// banana : 2
Collection
接口和Map
接口是 Java 集合框架的核心,它们定义了丰富的方法来操作集合和键值对数据,熟练掌握这些接口及其方法,是成为 Java 集合高手的关键一步。
四、List 集合 —— 有序可重复的宝藏库
在 Java 集合家族中,List
集合就像是一个有序的宝藏库,它允许元素重复,并且每个元素都有其对应的顺序索引,就像电影院里的座位,每个座位都有编号,而且不同的人可以坐在相同编号的不同场次座位上。List
集合提供了丰富的方法来操作这些元素,让我们可以方便地管理和访问数据。下面,我们就来深入了解一下List
集合的几个重要实现类。
4.1 ArrayList
ArrayList
是List
接口的一个常用实现类,它基于动态数组实现,就像是一个可以自动调整大小的数组。当我们向ArrayList
中添加元素时,如果当前数组的容量不足,它会自动扩容,创建一个更大的数组,并将原来数组中的元素复制到新数组中。
底层实现原理
ArrayList
的底层是一个Object
类型的数组elementData
,用来存储元素。在创建ArrayList
对象时,如果使用无参构造函数,会初始化一个空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA
,当第一次添加元素时,会将数组扩容为默认容量 10;如果使用带初始容量参数的构造函数,则会创建一个指定大小的数组。
// ArrayList的部分源码
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
transient Object[] elementData;
private int size;
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
}
扩容机制
当向ArrayList
中添加元素时,如果当前数组的容量不足以容纳新元素,就会触发扩容机制。扩容时,新的容量会变为原来容量的 1.5 倍(int newCapacity = oldCapacity + (oldCapacity >> 1);
) ,然后使用Arrays.copyOf
方法将原来数组中的元素复制到新数组中。
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
优缺点和适用场景
-
优点:
-
随机访问效率高:由于底层是数组,通过索引可以直接访问元素,时间复杂度为 O (1),就像通过座位号可以快速找到对应的座位。
-
遍历方便:可以使用普通
for
循环或者增强for
循环进行遍历,代码简洁明了。 -
适用于数据量相对稳定的场景:如果数据的规模相对稳定,不会频繁进行大量的插入和删除操作,尤其是在中间位置的插入和删除,那么
ArrayList
是比较合适的选择。
-
-
缺点:
-
插入和删除效率较低:在数组中间插入或删除元素时,需要移动大量元素,时间复杂度为 O (n),就像在电影院中间的座位插入或删除一个人,需要让后面的人都移动位置。
-
扩容时性能开销大:扩容时需要创建新数组并复制元素,当数据量较大时,这个过程会比较耗时。
-
线程不安全:
ArrayList
不是线程安全的,如果多个线程同时操作一个ArrayList
,可能会出现数据不一致的问题。
-
-
适用场景:
-
需要频繁随机访问元素的场景:比如实现一个学生成绩管理系统,需要频繁根据学生的序号获取成绩,这时使用
ArrayList
就非常合适。 -
数据量变化不大且以查询为主的场景:例如存储一个城市的固定公交线路站点信息,线路站点一般不会频繁变动,而经常需要查询某个站点的位置,
ArrayList
能够很好地满足这种需求。
-
代码示例
import java.util.ArrayList;
import java.util.List;
public class ArrayListExample {
public static void main(String[] args) {
// 创建一个ArrayList对象
List<String> list = new ArrayList<>();
// 添加元素
list.add("apple");
list.add("banana");
list.add("cherry");
// 打印集合
System.out.println("List: " + list);
// 获取指定位置的元素
String element = list.get(1);
System.out.println("Element at index 1: " + element);
// 修改指定位置的元素
list.set(1, "orange");
System.out.println("List after modification: " + list);
// 删除指定位置的元素
String removedElement = list.remove(2);
System.out.println("Removed element: " + removedElement);
System.out.println("List after removal: " + list);
// 遍历集合
System.out.println("Traversing the list:");
for (String s : list) {
System.out.println(s);
}
}
}
4.2 LinkedList
LinkedList
也是List
接口的一个重要实现类,它的底层是基于双向链表实现的,每个节点包含元素值、指向前一个节点的引用和指向后一个节点的引用,就像一条链子,每个环节都连接着前后两个环节。
底层实现原理
LinkedList
内部定义了一个Node
静态内部类来表示链表节点,每个节点包含三个部分:元素值item
、指向前一个节点的引用prev
和指向后一个节点的引用next
。LinkedList
还维护了两个重要的引用first
和last
,分别指向链表的第一个节点和最后一个节点。
// LinkedList的部分源码
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
transient int size = 0;
transient Node<E> first;
transient Node<E> last;
增删改查的时间复杂度
-
添加元素:在链表末尾添加元素的时间复杂度为 O (1),因为只需要修改几个指针的指向;在链表中间插入元素时,虽然插入操作本身的时间复杂度为 O (1),但需要先找到插入位置,这个查找过程的时间复杂度为 O (n),所以总的时间复杂度为 O (n)。
-
删除元素:删除链表中的某个元素时,同样需要先找到该元素,查找时间复杂度为 O (n),找到后删除操作本身的时间复杂度为 O (1),因此总的时间复杂度为 O (n)。
-
修改元素:修改指定位置的元素时,首先要找到该位置的节点,查找时间复杂度为 O (n),找到后修改元素值的时间复杂度为 O (1),总体时间复杂度为 O (n)。
-
查找元素:由于链表不支持随机访问,查找指定位置的元素需要从链表头或链表尾开始遍历,时间复杂度为 O (n)。
优缺点和适用场景
-
优点:
-
插入和删除效率高:在链表的任意位置插入或删除元素,只需修改几个指针的指向,时间复杂度为 O (1)(不考虑查找位置的时间),非常适合需要频繁进行插入和删除操作的场景。
-
内存利用率高:链表的内存分配是动态的,不需要预先分配大量连续的内存空间,当链表中的元素数量变化时,不会造成内存的浪费。
-
适合动态大小调整:不需要像
ArrayList
那样进行数组扩容或缩容操作,因此在不确定集合大小或需要频繁调整大小时更为灵活。
-
-
缺点:
-
随机访问效率低:查找指定位置的元素需要遍历链表,时间复杂度为 O (n),相比
ArrayList
的随机访问效率低很多。 -
遍历效率低:由于不能像数组那样通过索引直接访问元素,遍历链表时需要依次访问每个节点,效率相对较低。
-
内存开销大:每个节点除了存储元素值外,还需要额外存储两个指针,用于指向前一个节点和后一个节点,因此内存占用比
ArrayList
大。
-
-
适用场景:
-
需要频繁插入和删除元素的场景:比如实现一个聊天软件的消息列表,用户可能会频繁地发送和撤回消息,使用
LinkedList
可以高效地处理这些操作。 -
数据量不确定且变化频繁的场景:例如实现一个任务队列,任务的数量和顺序可能随时发生变化,
LinkedList
能够很好地适应这种动态变化。
-
代码示例
import java.util.LinkedList;
import java.util.List;
public class LinkedListExample {
public static void main(String[] args) {
// 创建一个LinkedList对象
List<String> list = new LinkedList<>();
// 添加元素
list.add("apple");
list.add("banana");
list.add("cherry");
// 打印集合
System.out.println("List: " + list);
// 获取指定位置的元素
String element = list.get(1);
System.out.println("Element at index 1: " + element);
// 修改指定位置的元素
list.set(1, "orange");
System.out.println("List after modification: " + list);
// 删除指定位置的元素
String removedElement = list.remove(2);
System.out.println("Removed element: " + removedElement);
System.out.println("List after removal: " + list);
// 遍历集合
System.out.println("Traversing the list:");
for (String s : list) {
System.out.println(s);
}
}
}
4.3 Vector
Vector
同样是List
接口的一个实现类,它的底层也是基于动态数组实现的,和ArrayList
有很多相似之处,但Vector
是线程安全的,就像一个有专人维护秩序的宝藏库,多个线程同时访问时也不会出现混乱。
底层实现原理
Vector
的底层也是一个Object
类型的数组elementData
,用于存储元素。它也有一个容量和当前元素个数的属性,当元素个数超过容量时,会进行扩容操作。
// Vector的部分源码
protected Object[] elementData;
protected int elementCount;
protected int capacityIncrement;
public Vector() {
this(10);
}
public Vector(int initialCapacity) {
this(initialCapacity, 0);
}
public Vector(int initialCapacity, int capacityIncrement) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
this.elementData = new Object[initialCapacity];
this.capacityIncrement = capacityIncrement;
}
与 ArrayList 的区别(线程安全)
Vector
和ArrayList
最大的区别在于线程安全性。Vector
的大部分方法都使用synchronized
关键字进行同步,保证在多线程环境下的安全访问;而ArrayList
没有进行同步处理,在多线程环境下可能会出现数据不一致的问题。例如,当一个线程在读取ArrayList
中的元素时,另一个线程可能正在修改这个ArrayList
,导致读取到的数据不准确。
// Vector的add方法,使用了synchronized关键字
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
// ArrayList的add方法,没有使用同步
public boolean add(E e) {
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}
优缺点和适用场景
-
优点:
-
线程安全:在多线程环境下可以安全地使用,不需要额外的同步措施,这使得
Vector
在多线程并发访问的场景中非常有用。 -
功能丰富:提供了和
ArrayList
类似的丰富方法,如添加、删除、查找元素等,能够满足大多数集合操作的需求。
-
-
缺点:
-
性能较低:由于使用了同步机制,在单线程环境下,
Vector
的性能比ArrayList
低,因为同步操作会带来额外的开销。 -
扩容策略不同:
Vector
的扩容策略和ArrayList
有所不同,默认情况下,Vector
扩容时会将容量增加一倍,如果指定了容量增量capacityIncrement
,则每次扩容时会增加capacityIncrement
个容量;而ArrayList
扩容时是增加原来容量的一半。这种扩容策略可能会导致Vector
在某些情况下占用更多的内存。
-
-
适用场景:
-
多线程环境下需要线程安全的集合:比如在一个多线程的服务器应用中,多个线程可能同时访问和修改一个共享的集合,这时使用
Vector
可以确保数据的一致性和线程安全。 -
对线程安全有严格要求且数据量不是特别大的场景:由于
Vector
的性能在多线程环境下相对较低,当数据量非常大时,可能会影响系统的整体性能,因此更适合数据量不是特别大但对线程安全要求较高的场景。
-
代码示例
import java.util.Vector;
public class VectorExample {
public static void main(String[] args) {
// 创建一个Vector对象
Vector<String> vector = new Vector<>();
// 添加元素
vector.add("apple");
vector.add("banana");
vector.add("cherry");
// 打印集合
System.out.println("Vector: " + vector);
// 获取指定位置的元素
String element = vector.get(1);
System.out.println("Element at index 1: " + element);
// 修改指定位置的元素
vector.set(1, "orange");
System.out.println("Vector after modification: " + vector);
// 删除指定位置的元素
String removedElement = vector.remove(2);
System.out.println("Removed element: " + removedElement);
System.out.println("Vector after removal: " + vector);
// 遍历集合
System.out.println("Traversing the vector:");
for (String s : vector) {
System.out.println(s);
}
}
}
ArrayList
、LinkedList
和Vector
虽然都实现了List
接口,但它们在底层实现、性能特点和适用场景上都有所不同。在实际开发中,我们需要根据具体的需求来选择合适的集合类,就像选择工具一样,只有选择了最合适的工具,才能高效地完成任务。
五、Set 集合 —— 无序不可重复的神秘花园
在 Java 集合的世界里,Set
集合就像是一个神秘的花园,它里面的元素无序且不可重复,每一个元素都是独一无二的存在。Set
集合主要有三个常用的实现类:HashSet
、TreeSet
和LinkedHashSet
,它们各自有着独特的特点和用途。接下来,就让我们一起走进这个神秘的花园,探索它们的奥秘吧!
5.1 HashSet
HashSet
是Set
接口的一个常用实现类,它的底层是基于HashMap
实现的,就像是在HashMap
的基础上搭建了一个独特的 “花园”。
底层实现原理
HashSet
内部维护了一个HashMap
对象,当我们向HashSet
中添加元素时,实际上是将元素作为HashMap
的键(Key)存储进去,而HashMap
的值(Value)统一为一个固定的常量PRESENT
,这个常量只是一个占位符,没有实际的意义。
// HashSet的部分源码
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
元素唯一性的实现机制
HashSet
利用HashMap
的键唯一性来保证元素的唯一性。当添加元素时,HashSet
会先计算元素的哈希值(通过元素的hashCode()
方法),然后根据哈希值确定该元素在HashMap
中的存储位置(桶索引)。如果该位置已经存在元素,就会调用元素的equals()
方法与已存在的元素进行比较,如果两个元素的equals()
方法返回true
,则认为这两个元素是重复的,不会进行添加操作;如果equals()
方法返回false
,则会将新元素添加到HashMap
中。
例如,我们自定义一个类Person
,并向HashSet
中添加Person
对象:
import java.util.HashSet;
import java.util.Set;
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// 重写hashCode方法
@Override
public int hashCode() {
int result = 17;
result = 31 * result + name.hashCode();
result = 31 * result + age;
return result;
}
// 重写equals方法
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && name.equals(person.name);
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
public class HashSetExample {
public static void main(String[] args) {
Set<Person> set = new HashSet<>();
set.add(new Person("Alice", 20));
set.add(new Person("Bob", 25));
set.add(new Person("Alice", 20)); // 重复元素,不会添加成功
for (Person person : set) {
System.out.println(person);
}
}
}
在这个例子中,Person
类重写了hashCode()
和equals()
方法,以确保HashSet
能够正确判断元素的唯一性。运行结果只会输出两个不同的Person
对象,说明重复的元素没有被添加到HashSet
中。
优缺点和适用场景
-
优点:
-
插入、删除和查找效率高:在理想情况下,
HashSet
的这些操作的平均时间复杂度为 O (1),因为它是基于哈希表实现的,通过哈希值可以快速定位元素的存储位置。 -
去重功能强大:能够自动去除重复元素,非常适合需要保证元素唯一性的场景。
-
-
缺点:
-
不保证元素的顺序:
HashSet
中的元素是无序的,遍历HashSet
时,元素的顺序可能与添加的顺序不同。 -
依赖哈希值和
equals()
方法:如果自定义类没有正确重写hashCode()
和equals()
方法,可能会导致HashSet
无法正确判断元素的唯一性。
-
-
适用场景:
-
需要快速查找和去重的场景:比如统计一篇文章中出现的单词,使用
HashSet
可以快速判断某个单词是否已经出现过,并且能高效地存储所有不同的单词。 -
对元素顺序没有要求的场景:例如实现一个简单的用户权限管理系统,只需要知道用户是否拥有某个权限,而不需要关心权限的顺序,这时
HashSet
是一个不错的选择。
-
5.2 TreeSet
TreeSet
是Set
接口的另一个重要实现类,它的底层是基于红黑树(一种自平衡的二叉搜索树)实现的,就像是在红黑树的基础上精心打造的一个有序花园。
底层实现原理
TreeSet
内部维护了一个TreeMap
对象,当向TreeSet
中添加元素时,元素会作为TreeMap
的键存储,而TreeMap
的值同样是一个固定的常量PRESENT
。红黑树的特性保证了TreeSet
中的元素是有序的,并且插入、删除和查找操作的时间复杂度都为 O (log n)。
// TreeSet的部分源码
private transient NavigableMap<E,Object> m;
private static final Object PRESENT = new Object();
public TreeSet() {
this(new TreeMap<>());
}
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}
元素排序的实现机制
TreeSet
支持两种排序方式:自然排序和自定义排序。
- 自然排序:元素必须实现
Comparable
接口,通过compareTo()
方法定义排序规则。例如,Integer
类和String
类都实现了Comparable
接口,所以可以直接使用自然排序。
import java.util.TreeSet;
public class TreeSetNaturalOrderExample {
public static void main(String[] args) {
TreeSet<Integer> set = new TreeSet<>();
set.add(3);
set.add(1);
set.add(2);
for (Integer num : set) {
System.out.println(num);
}
}
}
运行结果会按照升序输出 1、2、3,因为Integer
类实现了Comparable
接口,其compareTo()
方法定义了升序的比较规则。
- 自定义排序:通过构造方法
TreeSet(Comparator<? super E> comparator)
传入一个自定义的比较器(Comparator
),覆盖自然排序规则。例如,我们可以定义一个按照字符串长度排序的比较器:
import java.util.Comparator;
import java.util.TreeSet;
public class TreeSetCustomOrderExample {
public static void main(String[] args) {
TreeSet<String> set = new TreeSet<>(Comparator.comparingInt(String::length));
set.add("apple");
set.add("banana");
set.add("cherry");
for (String str : set) {
System.out.println(str);
}
}
}
运行结果会按照字符串长度升序输出apple
、cherry
、banana
,因为我们传入的比较器定义了按照字符串长度排序的规则。
优缺点和适用场景
-
优点:
-
元素有序:
TreeSet
中的元素会按照自然顺序或自定义顺序排序,非常适合需要有序存储元素的场景。 -
高效的范围查询:提供了一些方法,如
subSet(E fromElement, E toElement)
、headSet(E toElement)
和tailSet(E fromElement)
,可以高效地进行范围查询。
-
-
缺点:
-
插入、删除和查找操作的时间复杂度相对较高:由于是基于红黑树实现,时间复杂度为 O (log n),相比
HashSet
的 O (1) 要慢一些。 -
元素必须实现
Comparable
接口或提供自定义比较器:如果元素不满足这个条件,会抛出ClassCastException
异常。
-
-
适用场景:
-
需要对元素进行排序的场景:比如存储学生的成绩,需要按照成绩从高到低或从低到高进行排序,使用
TreeSet
可以轻松实现。 -
需要进行范围查询的场景:例如在一个存储员工年龄的集合中,查找年龄在某个范围内的员工,
TreeSet
的范围查询方法可以高效地完成这个任务。
-
5.3 LinkedHashSet
LinkedHashSet
是HashSet
的一个子类,它的底层是基于LinkedHashMap
实现的,就像是在HashSet
的花园里又添加了一条有序的小径,既保证了元素的唯一性,又维护了元素的插入顺序。
底层实现原理
LinkedHashSet
内部维护了一个LinkedHashMap
对象,LinkedHashMap
继承自HashMap
,它在HashMap
的基础上增加了一个双向链表,用于维护元素的插入顺序或访问顺序(默认是插入顺序)。当向LinkedHashSet
中添加元素时,元素会作为LinkedHashMap
的键存储,值同样是PRESENT
。
// LinkedHashSet的部分源码
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
public LinkedHashSet() {
super(16, .75f, true);
}
// 其他构造方法省略
}
// HashSet中为LinkedHashSet提供的构造方法
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
元素有序性和唯一性的实现机制
-
元素唯一性:和
HashSet
一样,利用LinkedHashMap
的键唯一性来保证元素的唯一性,通过计算元素的哈希值和调用equals()
方法来判断元素是否重复。 -
元素有序性:通过
LinkedHashMap
维护的双向链表来保证元素的插入顺序。在添加元素时,新元素会被添加到双向链表的末尾;在遍历LinkedHashSet
时,会按照双向链表的顺序依次访问元素,从而保证了遍历顺序与插入顺序一致。
优缺点和适用场景
-
优点:
-
保证元素的插入顺序:在需要保持元素插入顺序的场景中非常有用,比如记录用户的操作历史,使用
LinkedHashSet
可以按照操作的先后顺序存储操作记录。 -
具有
HashSet
的高效性:由于底层基于HashMap
,插入、删除和查找操作的平均时间复杂度仍然为 O (1),同时又保证了元素的唯一性。
-
-
缺点:
- 额外的内存开销:因为需要维护双向链表,相比
HashSet
会占用更多的内存空间。
- 额外的内存开销:因为需要维护双向链表,相比
-
适用场景:
-
需要保持元素插入顺序且去重的场景:比如实现一个简单的浏览器历史记录功能,使用
LinkedHashSet
可以存储用户访问过的网址,并且按照访问顺序显示,同时去除重复的网址。 -
对内存空间要求不是特别严格的场景:由于会占用额外的内存,所以在内存资源紧张的情况下,需要谨慎使用。
-
HashSet
、TreeSet
和LinkedHashSet
虽然都实现了Set
接口,但它们在底层实现、元素特性和适用场景上都有所不同。在实际开发中,我们需要根据具体的需求来选择合适的Set
实现类,让这个神秘的花园绽放出最绚丽的光彩。
六、Queue 集合 —— 按特定规则排队的队伍
在 Java 集合的大家庭中,Queue
集合就像是一条按特定规则排队的队伍,它遵循先进先出(FIFO,First In First Out)的原则,就像我们日常生活中排队买票、排队上车一样,先到的先处理。Queue
集合在很多场景中都有着重要的应用,比如任务调度、消息传递等。接下来,我们就来深入了解一下Queue
集合的两个重要实现类:LinkedList
和PriorityQueue
。
6.1 队列的基本概念
队列是一种特殊的线性数据结构,它的特点是只允许在一端进行插入操作,而在另一端进行删除操作。这就好比一个单行道,车辆只能从一端进入,从另一端驶出。在队列中,进行插入操作的一端称为队尾(Rear),进行删除操作的一端称为队首(Front)。
队列的常见操作有以下几种:
-
入队(Enqueue):将元素添加到队尾的操作,就像在队伍的末尾加入一个人。例如,在 Java 的
Queue
接口中,add(E e)
和offer(E e)
方法都可以用于入队操作。add
方法在添加元素成功时返回true
,如果队列已满(对于有容量限制的队列),则会抛出IllegalStateException
异常;而offer
方法添加成功时返回true
,添加失败(队列已满)时返回false
。 -
出队(Dequeue):从队首移除并返回元素的操作,就像队伍中第一个人完成事务后离开队伍。在
Queue
接口中,remove()
和poll()
方法用于出队操作。remove
方法移除并返回队首元素,如果队列为空,会抛出NoSuchElementException
异常;poll
方法同样移除并返回队首元素,但队列为空时返回null
。 -
查看队首元素(Peek):获取队首元素,但不将其从队列中移除,就像看看队伍的第一个人是谁,但他还在队伍里。
element()
和peek()
方法可以实现这个功能。element
方法返回队首元素,如果队列为空,抛出NoSuchElementException
异常;peek
方法返回队首元素,队列为空时返回null
。
6.2 LinkedList 作为队列
LinkedList
不仅是List
接口的实现类,还实现了Queue
接口,这使得它可以方便地作为队列来使用。由于LinkedList
基于双向链表实现,在队列的两端进行插入和删除操作的时间复杂度都为 O (1),非常高效。
实现队列功能的方式
当LinkedList
作为队列使用时,我们可以使用Queue
接口中定义的方法:
- 入队操作:使用
offer(E e)
方法将元素添加到队列的末尾,例如:
Queue<String> queue = new LinkedList<>();
queue.offer("apple");
queue.offer("banana");
- 出队操作:使用
poll()
方法从队列的头部移除并返回元素,例如:
String element = queue.poll();
System.out.println(element); // 输出 apple
- 查看队首元素:使用
peek()
方法获取队列头部的元素,但不移除它,例如:
String peekElement = queue.peek();
System.out.println(peekElement); // 输出 banana
优缺点和适用场景
-
优点:
-
高效的插入和删除操作:在队列的两端进行插入和删除操作的时间复杂度为 O (1),非常适合需要频繁进行插入和删除操作的场景,比如实现一个消息队列,消息可以快速地入队和出队。
-
灵活的使用场景:
LinkedList
既可以作为队列使用,也可以作为双端队列(Deque
)使用,还可以作为普通的列表使用,具有很高的灵活性。 -
允许空队列:与某些数组实现的队列不同,
LinkedList
作为队列时允许空队列存在,不会因为空数组而抛出异常。
-
-
缺点:
-
随机访问性能差:由于基于链表实现,随机访问元素的时间复杂度为 O (n),如果需要频繁进行随机访问操作,
LinkedList
不是一个好的选择。 -
内存开销大:相比于数组实现的队列,
LinkedList
需要额外的内存来存储节点的指针,每个节点除了存储元素本身,还需要存储指向前一个节点和后一个节点的引用。
-
-
适用场景:
-
需要快速插入和删除元素的场景:如实现一个打印任务队列,打印任务可以快速地添加到队列中,并且按照添加的顺序依次被处理。
-
作为队列或双端队列使用:特别是在需要从两端进行元素添加或移除的操作中,
LinkedList
能够很好地满足需求。例如,在实现一个简单的浏览器历史记录功能时,可以使用LinkedList
作为双端队列,用户可以向前和向后浏览历史记录,新的访问记录可以添加到队列的一端,而最早的记录可以从另一端移除。
-
6.3 PriorityQueue
PriorityQueue
是一个基于优先级的队列,它的出队顺序与元素的优先级有关,而不是简单的先进先出。这就好比在一个医院的急诊室里,病情严重的患者会优先得到治疗,而不是按照到达的先后顺序。
底层实现原理(基于堆)
PriorityQueue
的底层是基于堆(一种特殊的完全二叉树)实现的。堆有两个关键特性:结构性和堆序性。结构性指除了最后一层外,其他层都是完全填满的,最后一层从左到右填充;堆序性指每个节点都大于等于(大顶堆)或小于等于(小顶堆)其子节点。PriorityQueue
默认实现小顶堆,即根节点是最小元素。
在 Java 中,堆是通过数组来实现的,通过索引可以计算父子关系:对于索引i
的节点,父节点索引为(i - 1) >>> 1
(无符号右移,等同于(i - 1) / 2
但性能更优),左子节点索引为2 * i + 1
,右子节点索引为2 * i + 2
。
元素优先级的实现机制
PriorityQueue
中元素的优先级可以通过两种方式确定:
- 自然顺序:元素实现
Comparable
接口,通过compareTo()
方法定义元素之间的比较规则,从而确定优先级。例如,Integer
类实现了Comparable
接口,所以PriorityQueue<Integer>
中的元素会按照数值大小进行排序,数值小的优先级高。
PriorityQueue<Integer> queue = new PriorityQueue<>();
queue.add(3);
queue.add(1);
queue.add(2);
while (!queue.isEmpty()) {
System.out.println(queue.poll());
}
// 输出 1
// 2
// 3
- 自定义比较器:在创建
PriorityQueue
时传入一个Comparator
接口的实现类,通过compare(T o1, T o2)
方法来定义元素的比较规则。例如,我们可以定义一个按照字符串长度从大到小排序的比较器:
PriorityQueue<String> queue = new PriorityQueue<>(Comparator.comparingInt(String::length).reversed());
queue.add("apple");
queue.add("banana");
queue.add("cherry");
while (!queue.isEmpty()) {
System.out.println(queue.poll());
}
// 输出 banana
// cherry
// apple
优缺点和适用场景
-
优点:
-
高效的元素获取:获取优先级最高(或最低)的元素的时间复杂度为 O (1),因为堆顶元素就是优先级最高(或最低)的元素。
-
自动排序:根据元素的优先级自动对元素进行排序,不需要手动进行排序操作。
-
-
缺点:
-
不保证同优先级元素的顺序:对于优先级相同的元素,
PriorityQueue
不保证它们的出队顺序与入队顺序一致。 -
不允许存储
null
元素:PriorityQueue
中不允许存储null
元素,否则会抛出NullPointerException
异常。
-
-
适用场景:
-
需要按照优先级处理元素的场景:如实现一个任务调度系统,任务可以根据优先级被优先执行,高优先级的任务会先被从队列中取出并执行。
-
在海量数据中获取前
k
个最大(或最小)元素:利用PriorityQueue
可以高效地实现这个功能,例如在一个包含大量学生成绩的集合中,获取成绩最高的前 10 名学生。
-
Queue
集合的LinkedList
和PriorityQueue
实现类在不同的场景中都有着重要的应用。LinkedList
适合需要频繁插入和删除元素的场景,而PriorityQueue
则适用于需要按照优先级处理元素的场景。在实际开发中,我们要根据具体的需求选择合适的队列实现类,让程序更加高效和灵活。