典型回答
Hashtable、HashMap、TreeMap都是最常见的一些Map实现,是以键值对的形式存储和操作数据的容器类型。
Hashtable是早期Java类库提供的一个哈希表实现,本身是同步的,不支持null键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
HashMap是应用更加广泛的哈希表实现,行为上大致上与HashTable一致,主要区别在于HashMap不是同步的,支持null键和值等。通常情况下,HashMap进行put或者get操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户ID和用户信息对应的运行时存储结构。
TreeMap则是基于红黑树的一种提供顺序访问的Map,和HashMap不同,它的get、put、remove之类操作都是O(log(n))的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序来判断
知识扩展
Java的集合框架
单例集合
双例集合
Collection 接口介绍
Collection 表示一组对象,它是集中、收集的意思。Collection 接口中常用的两个子接口是 List、Set 接口。
Collection接口中定义的方法
由于List、Set是Collection的子接口,意味着所有List、Set的实现类都有上面的方法。
JDK8之后,Collection接口新增的方法:
List 接口介绍
List是有序、可重复的容器。
- 有序:有序(元素存入集合的顺序和取出的顺序一致)。List中每个元素都有索引标记。可以根据元素的索引标记(在List中的位置)访问元素,从而精确控制这些元素。
- 可重复:List允许加入重复的元素。更确切地讲,List通常允许满足e1.equals(e2) 的元素重复加入容器。
List接口中的常用方法
除了Collection接口中的方法,List多了一些跟顺序(索引)有关的方法,参见下表:
1)ArrayList
ArrayList 是List 接口的实现类。是 List 存储特征的具体实现。
ArrayList 底层是用数组实现的存储。特点:查询效率高,增删效率低,线程不安全。
ArrayList 的特征:
- ArrayList 是由数组实现的,支持随机存取,也就是可以通过下标直接存取元素。
- 从尾部插入和删除元素会比较快捷,从中间插入和删除元素会比较低效,因为涉及到数组元素的复制和移动。
- 如果内部数组的容量不足时会自动扩容,因此当元素非常庞大的时候,效率会比较低。
2)LinkedList
LinkedList 底层用双向链表实现的存储。特点:查询效率低,增删效率高,线程不安全。
双向链表也叫双链表,是链表的一种,它的每个数据节点中都有两个指针,分别指向前一个节点和后一个节点,所以,从双向链表中的任意一个节点开始,都可以很方便地找到所有节点。
- LinkedList 是由双向链表实现的,不支持随机存取,只能从一端开始遍历,直到找到需要的元素后返回。
- 任意位置插入和删除元素都很方便,因为只需要改变前一个节点和后一个节点的引用即可,不像ArrayList 那样需要复制和移动数组元素。
- 因为每个元素都存储了前一个和后一个节点的引用,所以相对来说,占用的内存空间会比 ArrayList 多一些。
LinkedList 容器的使用
2)Vector
Vector底层是用数组实现的,相关的方法都加了同步检查,因此“线程安全,效率低”。 比如,indexOf 方法就增加了synchronized同步标记。
Vector的使用与ArrayList是相同的,因为他们都实现了List接口,对List接口中的抽象方法做了具体实现。
Vector,是一个元老级的类,比 ArrayList 出现得更早。ArrayList 和 Vector 非常相似,只不过 Vector 是线程安全的,像 get、set、add 这些方法都加了 synchronized 关键字,就导致执行执行效率会比较低,所以现在已经很少用了。
如何选用ArrayList、LinkedList、Vector?
- 需要线程安全时,用Vector。
- 不存在线程安全问题时,并且查找较多用ArrayList(一般使用它)
- 不存在线程安全问题时,增加或删除元素较多用LinkedList
Set 接口介绍
Set 接口继承自 Collection 接口,Set接口中没有新增方法,它和Collection 接口保持完全一致。
Set 接口特点
Set 特点:无序、不可重复。
无序指 Set 中的元素没有索引,我们只能遍历查找;不可重复指不允许加入重复元素。更确切地讲,新元素如果和 Set 中某个元素通过 equals() 方法对比为 true ,则只能保留一个。
Set 常用的实现类有:HashSet、LinkedHashSet、TreeSet。
1)HashSet
HashSet 是一个不保证元素的顺序且没有重复元素的集合,是线程不安全的。HashSet 允许有 null 元素。
无序:
在 HashSet 中底层是使用 HashMap 存储元素的。HashMap 底层使用的是数组与链表实现元素的存储。元素在数组中存放时,并不是有序存放的也不是随机存放的。而是对元素的哈希值进行运算决定元素在数组中的位置。
不重复:
当两个元素的哈希值进行运算后得到相同的在数组中的位置时,会调用元素的 equals() 方法判断两个元素是否相同。如果元素相同则不会添加该元素,如果不相同则会使用单向链表保存该元素。
HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。来简单看一下它的源码。
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
}
实际开发中,HashSet 并不常用,比如,如果我们需要按照顺序存储一组元素,那么ArrayList和LinkedList可能更适合;如果我们需要存储键值对并根据键进行查找,那么HashMap可能更适合。
当然,在某些情况下,HashSet仍然是最好的选择。例如,当我们需要快速查找一个元素是否存在于某个集合中,并且我们不需要对元素进行排序时,HashSet可以提供高效的性能。
2)LinkedHashSet
LinkedHashSet 虽然继承自 HashSet,其实是由 LinkedHashMap 实现的。
LinkedHashSet是一种基于哈希表实现的Set接口,它继承自HashSet,并且使用链表维护了元素的插入顺序。因此,它既具有HashSet的快速查找、插入和删除操作的优点,又可以维护元素的插入顺序
3)TreeSet
TreeSet 实现了 Set 接口,是一种基于红黑树实现的有序集合,它实现了 SortedSet 接口,可以自动对集合中的元素进行排序。底层实际是用 TreeMap 实现的,内部维持了一个简化版的 TreeMap,通过 key 来存储元素。TreeSet 内部需要对存储的元素进行排序,因此。我们需要给定排序规则。
需要注意的是,TreeSet 不允许插入 null 元素,否则会抛出 NullPointerException 异常。
总体上来说,Set 集合不是关注的重点,因为底层都是由 Map 实现的,为什么要用 Map 实现呢?
其原因就是 Map 的键是不允许重复、无序的这正好符合 Set 集合的特点。
排序规则实现方式:
-
通过元素自身实现比较规则。
在元素自身实现比较规则时, 需要实现Comparable接口中的compareTo方法, 该方法中用来定义比较规则。 TreeSet通过调用该方法来完成对元素的排序处理。
-
通过比较器指定比较规则。
通过比较器定义比较规则时, 我们需要单独创建一个比较器, 比较器需要实现Comparator接口中的compare方法来定义比较规则。 在实例化TreeSet时将比较器对象交给TreeSet来完成元素的排序处理。 此时元素自身就不需要实现比较规则了。
Queue接口介绍
Queue,也就是队列,通常遵循先进先出(FIFO)的原则,新元素插入到队列的尾部,访问元素返回队列的头部。
1)ArrayDeque
从名字上可以看得出,ArrayDeque 是一个基于数组实现的双端队列,为了满足可以同时在数组两端插入或删除元素的需求,数组必须是循环的,也就是说数组的任何一点都可以被看作是起点或者终点。
这是一个包含了 4 个元素的双端队列,和一个包含了 5 个元素的双端队列。
head 指向队首的第一个有效的元素,tail 指向队尾第一个可以插入元素的空位,因为是循环数组,所以 head 不一定从是从 0 开始,tail 也不一定总是比 head 大。
2)LinkedList
LinkedList 一般应该归在 List 下,只不过,它也实现了 Deque 接口,可以作为队列来使用。等于说,LinkedList 同时实现了 Stack、Queue、PriorityQueue 的所有功能。
换句话说,LinkedList 和 ArrayDeque 都是 Java 集合框架中的双向队列(deque),它们都支持在队列的两端进行元素的插入和删除操作。不过,LinkedList 和 ArrayDeque 在实现上有一些不同:
- 底层实现方式不同:LinkedList 是基于链表实现的,而 ArrayDeque 是基于数组实现的。
- 随机访问的效率不同:由于底层实现方式的不同,LinkedList 对于随机访问的效率较低,时间复杂度为 O(n),而 ArrayDeque 可以通过下标随机访问元素,时间复杂度为 O(1)。
- 迭代器的效率不同:LinkedList 对于迭代器的效率比较低,因为需要通过链表进行遍历,时间复杂度为 O(n),而 ArrayDeque 的* 迭代器效率比较高,因为可以直接访问数组中的元素,时间复杂度为 O(1)。
- 内存占用不同:由于 LinkedList 是基于链表实现的,它在存储元素时需要额外的空间来存储链表节点,因此内存占用相对较高,而 ArrayDeque 是基于数组实现的,内存占用相对较低。
因此,在选择使用 LinkedList 还是 ArrayDeque 时,需要根据具体的业务场景和需求来选择。如果需要在双向队列的两端进行频繁的插入和删除操作,并且需要随机访问元素,可以考虑使用 ArrayDeque;如果需要在队列中间进行频繁的插入和删除操作,可以考虑使用 LinkedList。
// 创建一个 LinkedList 对象
LinkedList<String> queue = new LinkedList<>();
// 添加元素
queue.offer("张三");
queue.offer("王二");
queue.offer("陈清扬");
System.out.println(queue); // 输出 [张三, 王二, 陈清扬]
// 删除元素
queue.poll();
System.out.println(queue); // 输出 [王二, 陈清扬]
// 修改元素:LinkedList 中的元素不支持直接修改,需要先删除再添加
String first = queue.poll();
queue.offer("王大二");
System.out.println(queue); // 输出 [陈清扬, 王大二]
// 查找元素:LinkedList 中的元素可以使用 get() 方法进行查找
System.out.println(queue.get(0)); // 输出 陈清扬
System.out.println(queue.contains("张三")); // 输出 false
// 查找元素:使用迭代器的方式查找陈清扬
// 使用迭代器依次遍历元素并查找
Iterator<String> iterator = queue.iterator();
while (iterator.hasNext()) {
String element = iterator.next();
if (element.equals("陈清扬")) {
System.out.println("找到了:" + element);
break;
}
}
在使用 LinkedList 作为队列时,可以使用 offer() 方法将元素添加到队列的末尾,使用 poll() 方法从队列的头部删除元素。另外,由于 LinkedList 是链表结构,不支持随机访问元素,因此不能使用下标访问元素,需要使用迭代器或者 poll() 方法依次遍历元素。
2)PriorityQueue
PriorityQueue 是一种优先级队列,它的出队顺序与元素的优先级有关,执行 remove 或者 poll 方法,返回的总是优先级最高的元素。
// 创建一个 PriorityQueue 对象
PriorityQueue<String> queue = new PriorityQueue<>();
// 添加元素
queue.offer("李四");
queue.offer("王二");
queue.offer("陈清扬");
System.out.println(queue); // 输出 [李四, 王二, 陈清扬]
// 删除元素
queue.poll();
System.out.println(queue); // 输出 [王二, 陈清扬]
// 修改元素:PriorityQueue 不支持直接修改元素,需要先删除再添加
String first = queue.poll();
queue.offer("张三");
System.out.println(queue); // 输出 [张三, 陈清扬]
// 查找元素:PriorityQueue 不支持随机访问元素,只能访问队首元素
System.out.println(queue.peek()); // 输出 张三
System.out.println(queue.contains("陈清扬")); // 输出 true
// 通过 for 循环的方式查找陈清扬
for (String element : queue) {
if (element.equals("陈清扬")) {
System.out.println("找到了:" + element);
break;
}
}
要想有优先级,元素就需要实现 Comparable 接口或者 Comparator 接口。
双例集合
Map 接口介绍
Map 接口定义了双例集合的存储特征,它并不是 Collection 接口的子接口。双例集合的存储特征是以 key 与 value 结构为单位进行存储。体现的是数学中的函数 y=f(x)感念。
Map 与 Collection 的区别:
- Collection 中的容器,元素是孤立存在的(理解为单身),向集合中存储元素采用一个个元素的方式存储。
- Map 中的容器,元素是成对存在的(理解为现代社会的夫妻)。每个元素由键与值两部分组成,通过键可以找到对应的值。
- Collection 中的容器称为单例集合,Map 中的容器称为双例集合。
- Map 中的结合不能包含重复的键,值可以重复;每个键只能对应一个值。
- Map 中常用的容器为 HashMap,TreeMap 等。
Map接口中常用的方法表
1)HashMap
HashMap 采用哈希算法实现,是 Map 接口最常用的实现类。由于底层采用了哈希表存储数据。我们要求键不能重复,如果发生重复,新的键值对会替换旧的键值对。HashMap 在查找、删除、修改方面都有非常高的效率。
HashMap 的特点:
- HashMap 中的键和值都可以为 null。如果键为 null ,则将该键映射到哈希表的第一位置。
- 可以使用迭代因子或者 forEach 方法遍历 HashMap 中的键值对。
- HashMap 有一个初始容量和一个负载因子。初始容量是指哈希表的初始大小,负载因子是指哈希表在扩容之前可以存储的键值对数量与哈希表大小的比率。默认的初始容量是 16,负载因子是 0.75。
HashMap的底层源码分析
底层存储介绍
HashMap底层实现采用了哈希表,这是一种非常重要的数据结构。对于我们以后理解很多技术都非常有帮助。
数据结构中由数组和链表来实现对数据的存储,他们各有特点。
(1) 数组:占用空间连续。 寻址容易,查询速度快。但是,增加和删除效率非常低。
(2) 链表:占用空间不连续。 寻址困难,查询速度慢。但是,增加和删除效率非常高。
那么,我们能不能结合数组和链表的优点(即查询快,增删效率也高)呢? 答案就是“哈希表”。 哈希表的本质就是“数组+链表”。
HashMap中的数组初始化
在JDK1.8的HashMap中对于数组的初始化采用的是延迟初始化方式。通过 resize 方法实现初始化处理。resize方法既实现数组初始化,也实现数组扩容处理。
HashMap中计算Hash值
-
获得key对象的hashcode
首先调用key对象的hashcode()方法,获得key的hashcode值。 -
根据hashcode计算出hash值(要求在[0, 数组长度-1]区间)hashcode是一个整数,我们需要将它转化成[0, 数组长度-1]的范围。我们要求转化后的hash值尽量均匀地分布在[0,数组长度-1]这个区间,减少“hash冲突”
-
一种极端简单和低下的算法是:
hash值 = hashcode/hashcode;
也就是说,hash值总是1。意味着,键值对对象都会存储到数组索引1位置,这样就形成一个非常长的链表。相当于每存储一个对象都会发生“hash冲突”,HashMap也退化成了一个“链表”。 -
一种简单和常用的算法是(相除取余算法):
hash值 = hashcode%数组长度;
这种算法可以让hash值均匀的分布在[0,数组长度-1]的区间。但是,这种算法由于使用了“除法”,效率低下。JDK后来改进了算法。首先约定数组长度必须为2的整数幂,这样采用位运算即可实现取余的效果:hash值 = hashcode&(数组长度-1)。
2)HashTable
HashTable类和HashMap用法几乎一样,底层实现几乎一样,只不过HashTable的方法添加了synchronized关键字确保线程同步检查,效率较低。
HashMap与HashTable的区别
- HashMap: 线程不安全,效率高。允许key或value为null
- HashTable: 线程安全,效率低。不允许key或value为null
3)LinkedHashMap
HashMap 已经非常强大了,但它是无序的。如果我们需要一个有序的Map,就要用到 LinkedHashMap。LinkedHashMap 是 HashMap 的子类,它使用链表来记录插入/访问元素的顺序。
LinkedHashMap 可以看作是 HashMap + LinkedList 的合体,它使用了哈希表来存储数据,又用了双向链表来维持顺序。
4)TreeMap
TreeMap和HashMap同样实现了Map接口,所以,对于API的用法来说是没有区别的。HashMap效率高于TreeMap;TreeMap是可以对键进行排序的一种容器,在需要对键排序时可选用TreeMap。TreeMap底层是基于红黑树实现的。
在使用TreeMap时需要给定排序规则:
- 元素自身实现比较规则
- 通过比较器实现比较规则
TreeMap的底层源码分析
TreeMap是红黑二叉树的典型实现。我们打开TreeMap的源码,发现里面有一行核心代码:
private transient Entry<K,V> root = null;
root用来存储整个树的根节点。我们继续跟踪Entry(是TreeMap的内部类)的代码:
可以看到里面存储了本身数据、左节点、右节点、父节点、以及节点颜色。 TreeMap的put()/remove()方法大量使用了红黑树的理论。
TreeMap和HashMap实现了同样的接口Map,因此,用法对于调用者来说没有区别。HashMap效率高于TreeMap;在需要排序的Map时才选用TreeMap。
简单聊一下时间复杂度
在比较两者在增删改查时的执行效率时,时间复杂度是衡量执行效率的一个重要标准。
来看下面这段代码:
public static int sum(int n) {
int sum = 0; // 第 1 行
for (int i=0;i<n;i++) { // 第 2 行
sum = sum + 1; // 第 3 行
} // 第 4 行
return sum; // 第 5 行
}
这段代码非常简单,方法体里总共 5 行代码,包括 “}” 那一行。每段代码的执行时间可能都不大一样,但假设我们认为每行代码的执行时间是一样的,比如说 unit_time,那么这段代码总的执行时间为多少呢?
第 1、5 行需要 2 个 unit_time,第 2、3 行需要 2nunit_time,总的时间就是 2(n+1)*unit_time。
一段代码的执行时间 T(n) 和总的执行次数成正比,也就是说,代码执行的次数越多,花费的时间就越多。这个规律可以用一个公式来表达:
T(n) = O(f(n))
f(n) 表示代码总的执行次数,大写 O 表示代码的执行时间 T(n) 和 f(n) 成正比。
这也就是大 O 表示法,它不关心代码具体的执行时间是多少,它关心的是代码执行时间的变化趋势,这也就是时间复杂度这个概念的由来。
对于上面那段代码 sum() 来说,影响时间复杂度的主要是第 2 行代码,其余的,像系数 2、常数 2 都是可以忽略不计的,我们只关心影响最大的那个,所以时间复杂度就表示为 O(n)。
常见的时间复杂度有这么 3 个:
O(1)
代码的执行时间,和数据规模 n 没有多大关系。
括号中的 1 可以是 3,可以是 5,可以 100,我们习惯用 1 来表示,表示这段代码的执行时间是一个常数级别。比如说下面这段代码:
int i = 0;
int j = 0;
int k = i + j;
实际上执行了 3 次,但我们也认为这段代码的时间复杂度为 O(1)。
再举一个简单的例子。当我们访问数组中的一个元素时,它的时间复杂度就是常数时间复杂度 O(1)。
int[] nums = {1, 2, 3, 4, 5};
int x = nums[2]; // 访问数组中下标为2的元素,时间复杂度为 O(1)
O(n)
时间复杂度和数据规模 n 是线性关系。换句话说,数据规模增大 K 倍,代码执行的时间就大致增加 K 倍。
当我们遍历一个数组时,它的时间复杂度就是线性时间复杂度 O(n)。
int[] nums = {1, 2, 3, 4, 5};
for (int i = 0; i < nums.length; i++) { // 遍历整个数组,时间复杂度为 O(n)
System.out.println(nums[i]);
}
O(logn)
时间复杂度和数据规模 n 是对数关系。换句话说,数据规模大幅增加时,代码执行的时间只有少量增加。
来看一下代码示例,
public static void logn(int n) {
int i = 1;
while (i < n) {
i *= 2;
}
}
换句话说,当数据量 n 从 2 增加到 2^64 时,代码执行的时间只增加了 64 倍。
遍历次数 | i
----------+-------
0 | i
1 | i*2
2 | i*4
... | ...
... | ...
k | i*2^k
再举个例子。当我们对一个已排序的数组进行二分查找时,它的时间复杂度就是对数时间复杂度 O(log n)。
int[] nums = {1, 2, 3, 4, 5};
int target = 3;
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
System.out.println("找到了,下标为" + mid);
break;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
平方时间复杂度 O(n^2)
当我们对一个数组进行嵌套循环时,它的时间复杂度就是平方时间复杂度 O(n^2)。
int[] nums = {1, 2, 3, 4, 5};
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < nums.length; j++) {
System.out.println(nums[i] + " " + nums[j]);
}
}
指数时间复杂度 O(2^n)
当我们递归求解一个问题时,每一次递归都会分成两个子问题,这种情况下,它的时间复杂度就是指数时间复杂度 O(2^n)。
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
上面的代码是递归求解斐波那契数列的方法,它的时间复杂度是指数级别的。
Iterator迭代器接口介绍
Collection接口继承了Iterable接口,在该接口中包含一个名为 iterator的抽象方法,所有实现了Collection接口的容器类对该方法做了具体实现。iterator方法会返回一个Iterator接口类型的迭代器对象,在该对象中包含了三个方法用于实现对单例容器的迭代处理。
迭代器Iterator和Iterable有什么区别?
在 Java 中,我们对 List 进行遍历的时候,主要有这么三种方式。
第一种:for 循环。
for (int i = 0; i < list.size(); i++) {
System.out.print(list.get(i) + ",");
}
第二种:迭代器。
Iterator it = list.iterator();
while (it.hasNext()) {
System.out.print(it.next() + ",");
}
第三种:for-each。
for (String str : list) {
System.out.print(str + ",");
}
第一种我们略过,第二种用的是 Iterator,第三种看起来是 for-each,其实背后也是 Iterator,看一下反编译后的代码(如下所示)就明白了。
Iterator var3 = list.iterator();
while(var3.hasNext()) {
String str = (String)var3.next();
System.out.print(str + ",");
}
for-each 只不过是个语法糖,让我们开发者在遍历 List 的时候可以写更少的代码,更简洁明了。
Iterator 是个接口,JDK 1.2 的时候就有了,用来改进 Enumeration 接口:
- 允许删除元素(增加了 remove 方法)
- 优化了方法名(Enumeration 中是 hasMoreElements 和 nextElement,不简洁)
来看一下 Iterator 的源码:
public interface Iterator<E> {
// 判断集合中是否存在下一个对象
boolean hasNext();
// 返回集合中的下一个对象,并将访问指针移动一位
E next();
// 删除集合中调用next()方法返回的对象
default void remove() {
throw new UnsupportedOperationException("remove");
}
}
JDK 1.8 时,Iterable 接口中新增了 forEach 方法。该方法接受一个 Consumer 对象作为参数,用于对集合中的每个元素执行指定的操作。该方法的实现方式是使用 for-each 循环遍历集合中的元素,对于每个元素,调用 Consumer 对象的 accept 方法执行指定的操作。
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
该方法实现时首先会对 action 参数进行非空检查,如果为 null 则抛出 NullPointerException 异常。然后使用 for-each 循环遍历集合中的元素,并对每个元素调用 action.accept(t) 方法执行指定的操作。由于 Iterable 接口是 Java 集合框架中所有集合类型的基本接口,因此该方法可以被所有实现了 Iterable 接口的集合类型使用。
它对 Iterable 的每个元素执行给定操作,具体指定的操作需要自己写Consumer接口通过accept方法回调出来。
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.forEach(integer -> System.out.println(integer));
写得更浅显易懂点,就是:
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.forEach(new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println(integer);
}
});
如果我们仔细观察ArrayList 或者 LinkedList 的“户口本”就会发现,并没有直接找到 Iterator 的影子。
反而找到了 Iterable!
public interface Iterable<T> {
Iterator<T> iterator();
}
也就是说,List 的关系图谱中并没有直接使用 Iterator,而是使用 Iterable 做了过渡。
回头再来看一下第二种遍历 List 的方式。
Iterator it = list.iterator();
while (it.hasNext()) {
}
发现刚好呼应上了。拿 ArrayList 来说吧,它重写了 Iterable 接口的 iterator 方法:
public Iterator<E> iterator() {
return new Itr();
}
返回的对象 Itr 是个内部类,实现了 Iterator 接口,并且按照自己的方式重写了 hasNext、next、remove 等方法。
/**
* ArrayList 迭代器的实现,内部类。
*/
private class Itr implements Iterator<E> {
/**
* 游标位置,即下一个元素的索引。
*/
int cursor;
/**
* 上一个元素的索引。
*/
int lastRet = -1;
/**
* 预期的结构性修改次数。
*/
int expectedModCount = modCount;
/**
* 判断是否还有下一个元素。
*
* @return 如果还有下一个元素,则返回 true,否则返回 false。
*/
public boolean hasNext() {
return cursor != size;
}
/**
* 获取下一个元素。
*
* @return 列表中的下一个元素。
* @throws NoSuchElementException 如果没有下一个元素,则抛出 NoSuchElementException 异常。
*/
@SuppressWarnings("unchecked")
public E next() {
// 获取 ArrayList 对象的内部数组
Object[] elementData = ArrayList.this.elementData;
// 记录当前迭代器的位置
int i = cursor;
if (i >= size) {
throw new NoSuchElementException();
}
// 将游标位置加 1,为下一次迭代做准备
cursor = i + 1;
// 记录上一个元素的索引
return (E) elementData[lastRet = i];
}
/**
* 删除最后一个返回的元素。
* 迭代器只能删除最后一次调用 next 方法返回的元素。
*
* @throws ConcurrentModificationException 如果在最后一次调用 next 方法之后列表结构被修改,则抛出 ConcurrentModificationException 异常。
* @throws IllegalStateException 如果在调用 next 方法之前没有调用 remove 方法,或者在同一次迭代中多次调用 remove 方法,则抛出 IllegalStateException 异常。
*/
public void remove() {
// 检查在最后一次调用 next 方法之后是否进行了结构性修改
if (expectedModCount != modCount) {
throw new ConcurrentModificationException();
}
// 如果上一次调用 next 方法之前没有调用 remove 方法,则抛出 IllegalStateException 异常
if (lastRet < 0) {
throw new IllegalStateException();
}
try {
// 调用 ArrayList 对象的 remove(int index) 方法删除上一个元素
ArrayList.this.remove(lastRet);
// 将游标位置设置为上一个元素的位置
cursor = lastRet;
// 将上一个元素的索引设置为 -1,表示没有上一个元素
lastRet = -1;
// 更新预期的结构性修改次数
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
}
那可能有些小伙伴会问:为什么不直接将 Iterator 中的核心方法 hasNext、next 放到 Iterable 接口中呢?直接像下面这样使用不是更方便?
Iterable it = list.iterator();
while (it.hasNext()) {
}
从英文单词的后缀语法上来看,(Iterable)able 表示这个 List 是支持迭代的,而 (Iterator)tor 表示这个 List 是如何迭代的。
支持迭代与具体怎么迭代显然不能混在一起,否则就乱的一笔。还是各司其职的好。
想一下,如果把 Iterator 和 Iterable 合并,for-each 这种遍历 List 的方式是不是就不好办了?
原则上,只要一个 List 实现了 Iterable 接口,那么它就可以使用 for-each 这种方式来遍历,那具体该怎么遍历,还是要看它自己是怎么实现 Iterator 接口的。
Map 就没办法直接使用 for-each,因为 Map 没有实现 Iterable 接口,只有通过 map.entrySet()、map.keySet()、map.values() 这种返回一个 Collection 的方式才能 使用 for-each。
如果我们仔细研究 LinkedList 的源码就会发现,LinkedList 并没有直接重写 Iterable 接口的 iterator 方法,而是由它的父类 AbstractSequentialList 来完成。
public Iterator<E> iterator() {
return listIterator();
}
LinkedList 重写了 listIterator 方法:
public ListIterator<E> listIterator(int index) {
checkPositionIndex(index);
return new ListItr(index);
}
这里我们发现了一个新的迭代器 ListIterator,它继承了 Iterator 接口,在遍历List 时可以从任意下标开始遍历,而且支持双向遍历。
public interface ListIterator<E> extends Iterator<E> {
boolean hasNext();
E next();
boolean hasPrevious();
E previous();
}
我们知道,集合(Collection)不仅有 List,还有 Set,那 Iterator 不仅支持 List,还支持 Set,但 ListIterator 就只支持 List。
那可能有些小伙伴会问:为什么不直接让 List 实现 Iterator 接口,而是要用内部类来实现呢?
这是因为有些 List 可能会有多种遍历方式,比如说 LinkedList,除了支持正序的遍历方式,还支持逆序的遍历方式——DescendingIterator:
/**
* ArrayList 逆向迭代器的实现,内部类。
*/
private class DescendingIterator implements Iterator<E> {
/**
* 使用 ListItr 对象进行逆向遍历。
*/
private final ListItr itr = new ListItr(size());
/**
* 判断是否还有下一个元素。
*
* @return 如果还有下一个元素,则返回 true,否则返回 false。
*/
public boolean hasNext() {
return itr.hasPrevious();
}
/**
* 获取下一个元素。
*
* @return 列表中的下一个元素。
* @throws NoSuchElementException 如果没有下一个元素,则抛出 NoSuchElementException 异常。
*/
public E next() {
return itr.previous();
}
/**
* 删除最后一个返回的元素。
* 迭代器只能删除最后一次调用 next 方法返回的元素。
*
* @throws UnsupportedOperationException 如果列表不支持删除操作,则抛出 UnsupportedOperationException 异常。
* @throws IllegalStateException 如果在调用 next 方法之前没有调用 remove 方法,或者在同一次迭代中多次调用 remove 方法,则抛出 IllegalStateException 异常。
*/
public void remove() {
itr.remove();
}
}
可以看得到,DescendingIterator 刚好利用了 ListIterator 向前遍历的方式。可以通过以下的方式来使用:
Iterator it = list.descendingIterator();
while (it.hasNext()) {
}
Iterator接口定义了如下方法:
- boolean hasNext(); //判断游标当前位置的下一个位置是否还有元素没有被遍历;
- Object next(); //返回游标当前位置的下一个元素并将游标移动到下一个位置;
- void remove(); //删除游标当前位置的元素,在执行完next后该操作只能执行一次;
Collections工具类
类 java.util.Collections 提供了对Set、List、Map进行排序、填充、查找元素的辅助方法。
排序操作
- reverse(List list):反转顺序
- shuffle(List list):洗牌,将顺序打乱
- sort(List list):自然升序
- sort(List list, Comparator c):按照自定义的比较器排序
- swap(List list, int i, int j):将 i 和 j 位置的元素交换位置
List<String> list = new ArrayList<>();
list.add("王二");
list.add("王三");
list.add("王四");
list.add("王五");
list.add("王六");
System.out.println("原始顺序:" + list);
// 反转
Collections.reverse(list);
System.out.println("反转后:" + list);
// 洗牌
Collections.shuffle(list);
System.out.println("洗牌后:" + list);
// 自然升序
Collections.sort(list);
System.out.println("自然升序后:" + list);
// 交换
Collections.swap(list, 2,4);
System.out.println("交换后:" + list);
查找操作
- binarySearch(List list, Object key):二分查找法,前提是 List 已经排序过了
- max(Collection coll):返回最大元素
- max(Collection coll, Comparator comp):根据自定义比较器,返回最大元素
- min(Collection coll):返回最小元素
- min(Collection coll, Comparator comp):根据自定义比较器,返回最小元素
- fill(List list, Object obj):使用指定对象填充
- frequency(Collection c, Object o):返回指定对象出现的次数
System.out.println("最大元素:" + Collections.max(list));
System.out.println("最小元素:" + Collections.min(list));
System.out.println("出现的次数:" + Collections.frequency(list, "王二"));
// 没有排序直接调用二分查找,结果是不确定的
System.out.println("排序前的二分查找结果:" + Collections.binarySearch(list, "王二"));
Collections.sort(list);
// 排序后,查找结果和预期一致
System.out.println("排序后的二分查找结果:" + Collections.binarySearch(list, "王二"));
Collections.fill(list, "王八");
System.out.println("填充后的结果:" + list);
同步控制
HashMap 是线程不安全的,这个我们前面讲到了。那其实 ArrayList 也是线程不安全的,没法在多线程环境下使用,那 Collections 工具类中提供了多个 synchronizedXxx 方法,这些方法会返回一个同步的对象,从而解决多线程中访问集合时的安全问题。
使用起来也非常的简单:
SynchronizedList synchronizedList = Collections.synchronizedList(list);
看一眼 SynchronizedList 的源码就明白了,不过是在方法里面使用 synchronized 关键字open in new window加了一层锁而已。
static class SynchronizedList<E>
extends SynchronizedCollection<E>
implements List<E> {
private static final long serialVersionUID = -7754090372962971524L;
final List<E> list;
SynchronizedList(List<E> list) {
super(list); // 调用父类 SynchronizedCollection 的构造方法,传入 list
this.list = list; // 初始化成员变量 list
}
// 获取指定索引处的元素
public E get(int index) {
synchronized (mutex) {return list.get(index);} // 加锁,调用 list 的 get 方法获取元素
}
// 在指定索引处插入指定元素
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);} // 加锁,调用 list 的 add 方法插入元素
}
// 移除指定索引处的元素
public E remove(int index) {
synchronized (mutex) {return list.remove(index);} // 加锁,调用 list 的 remove 方法移除元素
}
}
那这样的话,其实效率和那些直接在方法上加 synchronized 关键字的 Vector、Hashtable 差不多(JDK 1.0 时期就有了),而这些集合类基本上已经废弃了,几乎不怎么用。
public class Vector<E>
extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
// 获取指定索引处的元素
public synchronized E get(int index) {
if (index >= elementCount) // 如果索引超出了列表的大小,则抛出数组下标越界异常
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index); // 返回指定索引处的元素
}
// 移除指定索引处的元素
public synchronized E remove(int index) {
modCount++; // 修改计数器,标识列表已被修改
if (index >= elementCount) // 如果索引超出了列表的大小,则抛出数组下标越界异常
throw new ArrayIndexOutOfBoundsException(index);
E oldValue = elementData(index); // 获取指定索引处的元素
int numMoved = elementCount - index - 1; // 计算需要移动的元素个数
if (numMoved > 0) // 如果需要移动元素
System.arraycopy(elementData, index+1, elementData, index,
numMoved); // 将数组中的元素向左移动一位
elementData[--elementCount] = null; // 将最后一个元素设置为 null,等待垃圾回收
return oldValue; // 返回被移除的元素
}
}
正确的做法是使用并发包下的 CopyOnWriteArrayListopen in new window、ConcurrentHashMapopen in new window。
不可变集合
- emptyXxx():制造一个空的不可变集合
- singletonXxx():制造一个只有一个元素的不可变集合
- unmodifiableXxx():为指定集合制作一个不可变集合
List emptyList = Collections.emptyList();
emptyList.add("非空");
System.out.println(emptyList);
这段代码在执行的时候就抛出错误了
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
at com.itwanger.s64.Demo.main(Demo.java:61)
这是因为 Collections.emptyList() 会返回一个 Collections 的内部类 EmptyList,而 EmptyList 并没有重写父类 AbstractList 的 add(int index, E element) 方法,所以执行的时候就抛出了不支持该操作的 UnsupportedOperationException 了。
这是从分析 add 方法源码得出的原因。除此之外,emptyList 方法是 final 的,返回的 EMPTY_LIST 也是 final 的,种种迹象表明 emptyList 返回的就是不可变对象,没法进行增删改查。
public static final <T> List<T> emptyList() {
return (List<T>) EMPTY_LIST;
}
public static final List EMPTY_LIST = new EmptyList<>();
其他
还有两个方法比较常用:
- addAll(Collection<? super T> c, T… elements),往集合中添加元素
- disjoint(Collection<?> c1, Collection<?> c2),判断两个集合是否没有交集
List<String> allList = new ArrayList<>();
Collections.addAll(allList, "王九","王十","王二");
System.out.println("addAll 后:" + allList);
System.out.println("是否没有交集:" + (Collections.disjoint(list, allList) ? "是" : "否"));
CollectionUtils:Spring 和 Apache 都有提供的集合工具类
对集合操作,除了前面说的 JDK 原生 Collections 工具类,CollectionUtils工具类也很常用。
目前比较主流的是Spring的org.springframework.util包下的 CollectionUtils 工具类。
和Apache的org.apache.commons.collections包下的 CollectionUtils 工具类。
Maven 坐标如下:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>
Apache 的方法比 Spring 的更多一些,我们就以 Apache 的为例,来介绍一下常用的方法。
集合判空
通过 CollectionUtils 工具类的isEmpty方法可以轻松判断集合是否为空,isNotEmpty方法判断集合不为空
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(1);
list.add(3);
if (CollectionUtils.isEmpty(list)) {
System.out.println("集合为空");
}
if (CollectionUtils.isNotEmpty(list)) {
System.out.println("集合不为空");
}
对两个集合进行操作
有时候我们需要对已有的两个集合进行操作,比如取交集或者并集等
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(1);
list.add(3);
List<Integer> list2 = new ArrayList<>();
list2.add(2);
list2.add(4);
//获取并集
Collection<Integer> unionList = CollectionUtils.union(list, list2);
System.out.println(unionList);
//获取交集
Collection<Integer> intersectionList = CollectionUtils.intersection(list, list2);
System.out.println(intersectionList);
//获取交集的补集
Collection<Integer> disjunctionList = CollectionUtils.disjunction(list, list2);
System.out.println(disjunctionList);
//获取差集
Collection<Integer> subtractList = CollectionUtils.subtract(list, list2);
System.out.println(subtractList);
泛型
泛型是JDK5.0以后增加的新特性。
泛型的本质就是“数据类型的参数化”,处理的数据类型不是固定的,而是可以作为参数传入。我们可以把“泛型”理解为数据类型的一个占位符(类似:形式参数),即告诉编译器,在调用泛型时必须传入实际类型
参数化类型,白话说就是:
1 把类型当作是参数一样传递。
2 <数据类型> 只能是引用类型。
泛型的好处
在不使用泛型的情况下,我们可以使用Object类型来实现任意的参数类型,但是在使用时需要我们强制进行类型转换。
这就要求程序员明确知道实际类型,不然可能引起类型转换错误;但是,在编译期我们无法识别这种错误,只能在运行期发现这种错误。使用泛型的好处就是可以在编译期就识别出这种错误,有了更好的安全性;
同时,所有类型转换由编译器完成,在程序员看来都是自动转换的,提高了代码的可读性。
总结一下,就是使用泛型主要是两个好处:
- 1 代码可读性更好【不用强制转换】
- 2 程序更加安全【只要编译时期没有警告,运行时期就不会出现ClassCastException异常】
类型擦除
编码时采用泛型写的类型参数,编译器会在编译时去掉,这称之为“类型擦除”。
泛型主要用于编译阶段,编译后生成的字节码class文件不包含泛型中的类型信息,涉及类型转换仍然是普通的强制类型转换。类型参数在编译后会被替换成Object,运行时虚拟机并不知道泛型。
泛型主要是方便了程序员的代码编写,以及更好的安全性检测。
泛型类
泛型标记
定义泛型时,一般采用几个标记:E、T、K、V、N、?。他们约定俗称的含义如下:
泛型类的使用
语法结构
public class 类名<泛型标识符号> {
}
public class 类名<泛型标识符号,泛型标识符号> {
}
示例
public class Generic<T> {
private T flag;
public void setFlag(T flag){
this.flag = flag;
}
public T getFlag(){
return this.flag;
}
}
public class Test {
public static void main(String[] args) {
//创建对象时,指定泛型具体类型。
Generic<String> generic = new Generic<>();
generic.setFlag("admin");
String flag = generic.getFlag();
System.out.println(flag);
//创建对象时,指定泛型具体类型。
Generic<Integer> generic1 = new Generic<>();
generic1.setFlag(100);
Integer flag1 = generic1.getFlag();
System.out.println(flag1);
}
}
泛型接口
泛型接口和泛型类的声明方式一致。
泛型接口的使用
语法结构
public interface 接口名<泛型标识符号> {
}
public interface 接口名<泛型标识符号,泛型标识符号>
{
}
示例
public interface IGeneric<T> {
T getName(T name);
}
//在实现接口时传递具体数据类型
public class IgenericImpl implements Igeneric<String> {
@Override
public String getName(String name) {
return name;
}
}
//在实现接口时仍然使用泛型作为数据类型
public class IGenericImpl2<T> implements IGeneric<T>{
@Override
public T getName(T name) {
return name;
}
}
public class Test {
public static void main(String[] args) {
IGeneric<String> igeneric= new IGenericImpl();
String name = igeneric.getName("oldlu");
System.out.println(name);
IGeneric<String> igeneric1 = new IGenericImpl2<>();
String name1 = igeneric1.getName("itbz");
System.out.println(name1);
}
}
泛型方法
类上定义的泛型,在方法中也可以使用。但是,我们经常需要仅仅在某一个方法上使用泛型,这时候可以使用泛型方法。
调用泛型方法时,不需要像泛型类那样告诉编译器是什么类型,编译器可以自动推断出类型
泛型方法的使用
非静态方法
非静态方法可以使用泛型类中所定义的泛型,也可以将泛型定义在方法上。
语法结构
//无返回值方法
public <泛型标识符号> void getName(泛型标识符号 name){
}
//有返回值方法
public <泛型标识符号> 泛型标识符号 getName(泛型标识符号 name){
}
示例
public class MethodGeneric {
public <T> void setName(T name){
System.out.println(name);
}
public <T> T getAge(T age){
return age;
}
}
public class Test2 {
public static void main(String[] args) {
MethodGeneric methodGeneric = new MethodGeneric();
methodGeneric.setName("oldlu");
Integer age = methodGeneric.getAge(123);
System.out.println(age);
}
静态方法
静态方法中使用泛型时有一种情况需要注意一下,那就是静态方法无法访问类上定义的泛型,所以必须要将泛型定义在方法上。
语法结构
//无返回值静态方法
public static <泛型标识符号> void setName(泛型标识符号 name){
}
//有返回值静态方法
public static <泛型标识符号> 泛型表示符号 getName(泛型标识符号 name){
}
示例
public class MethodGeneric {
public static <T> void setFlag(T flag){
System.out.println(flag);
}
public static <T> T getFlag(T flag){
return flag;
}
}
public class Test4 {
public static void main(String[] args) {
MethodGeneric.setFlag("oldlu");
Integer flag1 = MethodGeneric.getFlag(123123);
System.out.println(flag1);
}
}
泛型方法与可变参数
在泛型方法中,泛型也可以定义可变参数类型。
语法结构
public <泛型标识符号> void showMsg(泛型标识符号... agrs){
}
示例
public class MethodGeneric {
public <T> void method(T...args){
for(T t:args){
System.out.println(t);
}
}
}
public class Test5 {
public static void main(String[] args) {
MethodGeneric methodGeneric = new MethodGeneric();
String[] arr = new String[]{"a","b","c"};
Integer[] arr2 = new Integer[]{1,2,3};
methodGeneric.method(arr);
methodGeneric.method(arr2);
}
}
泛型中的通配符
无界通配符
“?”表示类型通配符,用于代替具体的类型。它只能在“<>”中使用。可以解决当具体类型不确定的问题。
语法结构
public void showFlag(Generic<?> generic){
}
示例
public class Generic<T> {
private T flag;
public void setFlag(T flag){
this.flag = flag;
}
public T getFlag(){
return this.flag;
}
}
public class ShowMsg {
public void showFlag(Generic<?> generic){
System.out.println(generic.getFlag());
}
}
public class Test3 {
public static void main(String[] args) {
ShowMsg showMsg = new ShowMsg();
Generic<Integer> generic = new Generic<>();
generic.setFlag(20);
showMsg.showFlag(generic);
Generic<Number> generic1 = new Generic<>();
generic1.setFlag(50);
showMsg.showFlag(generic1);
Generic<String> generic2 = new Generic<>();
generic2.setFlag("oldlu");
showMsg.showFlag(generic2);
}
}
统配符的上下限定
统配符的上限限定
对通配符的上限的限定:<? extends 类型>
?实际类型可以是上限限定中所约定的类型,也可以是约定类型的子类型;
语法结构
public void showFlag(Generic<? extends
Number> generic){
}
示例
public class ShowMsg {
public void showFlag(Generic<? extends Number> generic){
System.out.println(generic.getFlag());
}
}
public class Test4 {
public static void main(String[] args) {
ShowMsg showMsg = new ShowMsg();
Generic<Integer> generic = new Generic<>();
generic.setFlag(20);
showMsg.showFlag(generic);
Generic<Number> generic1 = new Generic<>();
generic1.setFlag(50);
showMsg.showFlag(generic1);
}
}
通配符的下限限定
对通配符的下限的限定:<? super 类型>
?实际类型可以是下限限定中所约定的类型,也可以是约定类型的父类型;
语法结构
public void showFlag(Generic<? super Integer> generic){
}
示例
public class ShowMsg {
public void showFlag(Generic<? super Integer> generic){
System.out.println(generic.getFlag());
}
}
public class Test6 {
public static void main(String[] args) {
ShowMsg showMsg = new ShowMsg();
Generic<Integer> generic = new Generic<>();
generic.setFlag(20);
showMsg.showFlag(generic);
Generic<Number> generic1 = new Generic<>();
generic1.setFlag(50);
showMsg.showFlag(generic1);
}
}
泛型局限性和常见错误
泛型主要用于编译阶段,编译后生成的字节码class文件不包含泛型中的类型信息。 类型参数在编译后会被替换成Object,运行时虚拟机并不知道泛型。因此,使用泛型时,如下几种情况是错误的:
- 1 基本类型不能用于泛型
Test t; 这样写法是错误,我们可以使用对应的包装类Test t ; - 2 不能通过类型参数创建对象
T elm = new T(); 运行时类型参数 T 会被替换成 Object ,无法创建T类型的对象,容易引起误解,java干脆禁止这种写法。