集合知识点整理
Java 中,数组是保存一组对象的最有效的方式,但是数组的大小是固定的,通常在写代码时,我们不知道对象的确切个数,这个时候,JDK 提供的容器类帮我们解决这个问题。
Java 的容器类分为两类:Collection
和Map
。所有元素序列对象都实现了Collection
接口,键值对对象则实现Map
接口。在接口和实现之间有一层Abstract
的抽象集合类,一方面,它们提供了集合的通用功能,比如toString
方法,通用遍历方法,HashMap
的几个视图方法,keySet
,entrySet
、values
等;另一方面,这些抽象集合类为想要自定义实现集合的人提供了标准。
ArrayList
- 本质是一个数组:
Object[]
- 默认初始容量:10
- 扩容机制:扩容为原来大小的1.5倍。使用方法
System.arraycopy
,代码位置:private void grow(int minCapacity) { ... int newCapacity = oldCapacity + (oldCapacity >> 1); // 右移一位相当于除以2 ... }
LinkedList
一个双向链表,同时提供了首、尾两个节点的数据。
public class LinkedList<E> {
transient Node<E> first;
transient Node<E> last;
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
}
随机访问一个数据时,使用二分法,根据给定的位置判断是在前半部分还是后半部分,然后从头或尾部遍历。
点击查看代码
public E get(int index) {
...
return node(index).item;
}
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
ArrayList 和 LinkedList 的区别
- 数据结构不同。ArrayList 基于数组,LinkedList 基于双向链表。
- 随机数据访问效率不同。随机访问数据时,数组快于链表。因为数组能根据索引直接访问到每个数据;而双向链表,它通过前驱跟后继指针来维持数据与数据之间的逻辑关系,随机访问一个数据,LinkedList 都要从头还或者尾开始遍历,一直到遍历该索引的位置。
- 数据插入与删除效率不同。插入与删除,LinkedList 要快于 ArrayList。因为 LinkedList 插入或删除一个元素只涉及三个节点的指针变化;而 ArrayList 却要集体移动插入或删除位置后的所有元素的位置。
- 内存占用不同。因为 LinkedList 的元素除了数据域,还要额外维护两个指针域,它占用内存更大。
对于第 2、3 点,理论上是对的。实际上在如今设备环境下,他俩的性能差别不大。为什么呢?ArrayList 集体移动数据使用System.arraycopy
方法(它是一个native
方法,直接操作内存)将数组拷贝到新的位置,它的速度是很快的;同样的,LinkedList 随机访问一个数据使用的是二分查找,这大大降低了循环查找的次数,而循环的操作也仅仅是不停指向下一个节点,直到要查找的位置,不涉及什么耗时操作,速度也不会慢。
实际上,我也测试过一千万条数据(超过可能造成内存溢出)下两种集合操作耗时对比,几乎没有差别,因此实际使用时小数据量无需考虑两者性能问题,但是大数据量或者频繁操作的场景,仍然要依据他们各自的特性来选择使用哪种集合。
HashMap
HashMap 是基于哈希表的 Map 接口的实现,以键值对的形式存储。它的特点是:
- 存取无序
- 键和值都可以为
null
- 键是唯一的
- 非线程安全
- Java8的数据结构为:数组+链表+红黑树。HashMap 的数据载体是一个数组,数组中存放的是链表,当数组长度和阈值达到一定值时为把链表转成红黑树。这个红黑树的数据类型是
TreeNode
,它继承自链表Node
,即数组中存放是的Node
或者TreeNode
。
什么是哈希冲突?
哈希冲突是不同的 key 根据 hash 函数计算得到了相同的结果。在HashMap中,根据 key 的 hash 值计算数组的索引,如果该数组位置已经有值了,说明产生了哈希冲突,这个时候 value 会存入链表中,当链表中的值达到一定数量会将链表转换为红黑树。
那我有一个问题,哈希冲突的时候不是不同的Key计算得到了相同的数组索引,Value被存入一个链表或者红黑树吗,那这个Value又是怎么被取出来的呢?
要知道,外界访问链表,只能从头开始遍历,HashMap通过Key来获取一个Value的时候,就是从头节点开始,循环查找它的下一个节点,直到找到相同的节点返回。红黑树也是一个链式的数据结构,也是从根节点逐层往下查找,找到为止。
为什么引入红黑树?
JDK1. 8 以前HashMap
的实现是数组+链表,即使哈希函数取得再好,也很难达到元素完全均匀分布。当HashMap
中有大量的元素都存放在同一个桶中的时候,这个桶下有一条长长的链表,这个时候HashMap
就相当于一个单链表,加入单链表有n个元素,遍历的时候复杂度就是O(n),完全失去了它的优势。针对这种情况,JDK1.8中引入了红黑树(查找时间复杂度为O(logn))来优化这个问题。当链表长度很小时,即使遍历,速度也很快,但是当链表长度不断变长,引入红黑树能提高查询效率。
为什么是红黑树而不是其他的树?
红黑树是一棵自平衡的二叉排序函树,它提高数据查找效率。为啥平衡树能提高数据查找效率?以平衡二叉树为例,平衡二叉树是一颗排序树(又称查找树),每新增一个节点,就要通过旋转节点来维持树的平衡,也就是树的有序性以及子树高度差的要求。一棵非平衡的二叉树,如果全是右孩子,那跟一个链表没有区别,查找一个数据就得从根开始遍历;如果是平衡的呢,只要跟父节点做一次比较,就能判断下次是走左子树还是右字树,节约至少一半时间。为什么不用其他的平衡树?这就要对比很多其他树的特点了,目前我只能这么回答:数据结构的选择最终都是时间与空间权衡的结果,就是这颗树维持平衡的代价和占用空间大小之间的权衡。
Key重写了 equals 方法,是否也要重写 hashCode 方法?
肯定是要的。HashMap 是这么判断 key 是否重复的:
// p是已有的链表节点, hash是key计算而来
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
只有key的哈希值相等、equals相等同时满足时,才会判断为两个key相同。如果HashMap的key只重写了equals方法,不重写hashCode,由此可能导致原本相同的key可能被HashMap判断为不同,导致put进重复的数据,或者get不出来的现象,这是违背HashMap的设计原则的。
HashMap 的初始化容量为什么要求是 2 的 n 次幂?有什么作用?如果不是会怎么样?
当我们根据key的hash函数来确定它在数组中的位置时,如果初始化容量为2的n次幂,可以保证数据均匀的插入,如果不是,会加大哈希冲突,可能造成数组的一些位置空闲,浪费数组空间。当然这些都是为了效率。
如果初始化容量不是2的n次幂,HashMap会把这个初始化容量转换为最接近的一个2的n次幂的数。方法为:
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
HashMap的装载因子是什么?
loadFactor加载因子,是衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率。加载因子太大导致查找效率变低,太小导致数组利用变低,存放的数据很分散。官方给出默认加载因子是0.75。
HashMap 的并发问题
Java8 的HashMap
在并发场景下可能出现值被覆盖的情况,具体原因可看put
的源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 这里在多线程场景下会出现值被覆盖的情况
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else ...
可能线程 A 刚执行完(p = tab[i = (n - 1) & hash]) == null
,CPU 又切换到线程 B 执行这行代码,导致tab[i] = newNode(hash, key, value, null)
执行了两次,造成值覆盖。
简言之,在判断某个哈希桶是否为null
时,在并发场景下会出现多个线程对这个条件的判断都成立,导致同一个哈希桶的值被其他线程覆盖。
ConcurrentHashMap
ConcurrentHashMap
是一个线程安全的集合,功能和HashMap
完全一致,在并发场景下,把HashMap
替换为ConcurrentHashMap
就好了。
ConcurrentHashMap 线程安全的原理
Java8 中ConcurrentHashMap
和HashMap
的数据结构一样,都是 数组+链表+红黑树,Java8 摒弃了 Java7 中的分段锁设计,使用了CAS + synchronized
实现了粒度更细的锁,可以这么理解:Node 数组的每个位置都加了一把锁。因为它锁住的是一个哈希桶,不会影响其他哈希桶的读写,提高了并发度。具体实现思路:
- 当塞入一个值的时候,先计算 key 的 hash 后的下标,如果计算到的下标还未有 Node ,那么就通过 cas 塞入新的 Node。
- 如果已经有 node 则通过 synchronized 将这个 node 上锁,这样别的线程就无法访问这个 node 及其之后的所有节点。
- 然后判断 key 是否相等,相等则是替换 value ,反之则是新增一个 node,这个操作和 HashMap 是一样的。
Java中的集合工具类Collections
也提供了线程安全集合的转换方法,如Collections.synchronizedMap
,它本质上是一个装饰器,对外提供同步方法,以此保证线程安全,这种性能比较差,一搬不推荐使用。
ConcurrentHashMap 的 key 和 value 为什么不能为空?
ConcurrentHashMap
的value
不能为空的原因很好理解,在并发场景下,如果map.get(key) == null
,我们无法判断key
是不存在还是在get
之前被其他线程put
进了一个null
值,这是一个歧义问题。
至于key
为什么不能为null
,网上有人就这个问题向ConcurrentHashMap
的作者 Doug Lea 提问,他也没有给出具体原因,只说是集合设计时遗留的一个问题,并且他给出了建议,如果在使用ConcurrentHashMap
集合时确实有key
为null
的情况,可以考虑把null
值替换为一个空的Object
对象。
这是这个问题的原文链接:这道面试题我真不知道面试官想要的回答是什么
ConcurrentHashMap 的 get 需要加锁吗?
不需要加锁。保证 put 的时候线程安全之后,get 的时候只需要保证可见性即可,而可见性不需要加锁。具体是通过Unsafe#getObjectVolatile
和用 volatile 来修饰节点的 val 和 next 指针来实现的。
TreeMap
TreeMap是有序的,遍历TreeMap时元素会根据key值排序,默认是升序。它的排序是基于Comparable
接口或者指定一个排序接口,因此使用TreeMap时,要么键对象要实现Comparable
接口,要么个它传入一个Comparator
接口的一个实现,否则使用就会报错。
LinkedHashMap
LinkedHashMap
是有序的,它继承HashMap,在哈希桶的基础上增加了两个引用,一个before
,一个after
,且记录了头结点和尾节点,LinkedHashMap就是以此保证元素的插入顺序的。
具体是怎么实现的呢?
首先,结构上,LinkedHashMap 继承 HashMap, 它共用HashMap 的数据操作方法,LinkedHashMap 的内部类 Entry 继承了 HashMap 的内部类 Node。
其次,在 put 方法添加元素时,LinkedHashMap 重写了 HashMap 的 newNode 方法,使得在新增数据时数组的类型是 Entry,且在 Entry 中额外维护了两个指针,before 和 after。也就是说,HashMap 是一个 Node 数组,LinkedHashMap 则是 Entry 数组。
然后,他会处理元素之间的关系,新增元素都会把元素放到链表的结尾。并通过 before,after 维护元素之间的关系。
HashMap 内部新增了一系列的包访问级的方法供 LinkedHashMap 重写,来帮助 LinkedHashMap 维护它的特性。
HashSet
Set内部是基于Map实现的。
HashSet是基于HashMap实现的,它其实就是一个value为空Object
对象的一个HashMap,因此他的特点是元素不可重复。
LinkedHashSet
有序的 Set,继承 HashSet,在构造器中创建的是LinkedHashMap
。
TreeSet
TreeSet也是基于TreeMap的,TreeMap有序,因此TreeSet也是有序的,并且在构造时需要制定一个排序接口或者TreeSet的元素要实现Comparable
接口。
过时的 Vector 和 Hashtable
Vector
和Hashtable
都是在方法上添加 synchronized 保证线程安全,并发性能差。对应新的并发集合:CopyOnWriteArrayList
和ConcurrentHashMap
。
CopyOnWriteArrayList
适用场景:
- 读操作可以尽可能地快,而写即使慢一些也没有太大关系
- 读多写少
CopyOnWrite 的含义:创建新副本,读写分离。读和写使用不同的容器,读的数据虽然实时性会差一些,但可以并发读取。
存在的问题:
- 数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。如果希望写入的数据马上能读到,不要用 CopyOnWrite 容器。
- 内存占用问题:因为 CopyOnWrite 的写是复制机制,所以在进行写操作时,内存里会同时驻扎两个对象的内存。
Queue
队列,它是一个接口。
Deque
双端队列,它也是一个接口,继承了Queue。他有两个经典实现,ArrayDeque
、LinkedList
。双端队列是指队列的头部和尾部都可以同时入队和出队的数据结构。
PriorityQueue
优先级队列。它的一个典型应用就是任务调度。Java7和Java8有区别。优先队列是一种特殊的队列,它并不是先进先出的,而是优先级高的元素先出队。