开启掘金成长之旅!这是我参与「掘金日新计划 · 4 月更文挑战」的第 24 天,点击查看活动详情
集合回顾
Java中集合类是Java编程中使用最频繁、最方便的类。集合类作为容器类可以存储任何类型的数据,当然也可以结合泛型存储指定的类型(不过泛型仅仅在编译期有效,运行时是会被擦除的)。集合类中存储的仅仅是对象的引用,并不存储对象本身。集合类的容量可以在运行期间进行动态扩展,并且还提供很多很方便的方法,如求集合的并集、交集等。
Java中的集合包含多种数据结构,如链表、队列、哈希表等。从类的继承结构来说,可以分为两大类,一类是继承自Collection接口,这类集合包含List、Set和Queue等集合类。另一类是继承自Map接口,这主要包含了哈希表相关的集合类。
List、Set和Queue

图中的绿色的虚线代表实现,绿色实线代表接口之间的继承,蓝色实线代表类之间的继承。
List
我们用的比较多List包括ArrayList和LinkedList,这两者的区别也很明显,从其名称上就可以看出。ArrayList的底层的通过数组实现,所以其随机访问的速度比较快,但是对于需要频繁的增删的情况,效率就比较低了。而对于LinkedList,底层通过链表来实现,所以增删操作比较容易完成,但是对于随机访问的效率比较低。至于Vector,它是ArrayList的线程安全版本。
Queue
一般可以直接使用LinkedList完成,从上述类图也可以看出,LinkedList继承自Deque,所以LinkedList具有双端队列的功能。PriorityQueue的特点是为每个元素提供一个优先级,优先级高的元素会优先出队列(默认排序是自然排序,队头元素是最小元素,可以通过comparator比较器修改排序的比较方式)。
Set
Set与List的主要区别是Set是不允许元素重复的,而List则可以允许元素重复的。判断元素的重复需要根据对象的hash方法和equals方法来决定。这也是我们通常要为集合中的元素类重写hashCode方法和equals方法的原因。
对于集合中元素,hashCode值不同的元素一定不相等,但是不相等的元素,hashCode值可能相同。HashSet和LinkedHashSet的区别在于后者LinkedHashSet可以保证元素插入集合的元素顺序与输出顺序保持一致。而TreeSet的区别在于其排序是按照Comparator来进行排序的,默认情况下按照字符的自然顺序进行升序排列。
常见集合比较
| 集合 | 特点 | 说明 |
|---|---|---|
| List | 1. 有序,可重复2. 可以通过索引访问(下标) | 数组(ArrayList)或者链表(LinkedList)存储 |
| Set | 1. 无序,不重复 | 使用Map存储(HashSet - HashMap) |
| ArrayList | 1. 有序2. 随机访问快(下标)3. 插入,删除慢(移位) | 底层基于动态数组,内存连续,查询速度快,删除添加要移动数据,性能低下 |
| LinkedList | 1. 有序2. 查询慢3. 插入,删除快 | 双向链表,内存可以不连续,删除,添加快,查询慢 |
| Vector | 1. 有序2.线程安全(synchronized)ArrayList的前任 | 数组 , 同步锁保证安全性,性能低,不推荐用 |
| PriorityQueue | 1. 优先队列2. 默认最小的在前面,按照自然顺序排序 | 二叉小顶堆(二叉树) |
| TreeSet | 1. 可以保证元素排序(默认自然顺序,可定制)2. 是线程不安全3. 元素不重复(compareTo判断)4. 插入的元素必须实现Comparable接口5. 不允许放入null值 | 基于TreeMap的KeySet存储数据,使用红黑树实现需要排序时使用TreeSet |
| HashSet | 1. 线程不安全2. 元素不重复(通过hashCode和equals方法判断)3. 元素是无序的4. 只能放入一个null | 基于 HashMap 的KeySet储存数据,使用Hash表实现 性能高于TreeSet,优先使用它 |
| LinkedHasHSet extends HashSet | 1. 线程不安全2. 元素不重复3. 可以保证元素插入的顺序4. 迭代性能高于HashSet | 基于LinkHashMap存储数据,哈希表和双向链表 |
| HashMap | 1. 非线程安全 2. 基于哈希表实现3. 通过hashcode实现元素快速查找4. 元素无序5. 可以出现一个null键,多个null值 | 基于哈希表实现适用于在Map中插入、删除和定位元素 |
| TreeMap | 1. 非线程安全2. 基于红黑树实现 | 基于红黑树实现适用于按自然顺序或自定义顺序遍历键(key) |
| HashTable | 1. 线程安全的2. 性能低下3. 不能有null键和null值 | 哈希表实现,线程安全,性能低,已经不用了,多线程使用concurrentHashMap |
Iterable
从这个图里面可以看到Collection类继承自Iterable,该接口的作用是提供元素遍历的功能,也就是说所有的集合类(除Map相关的类)都提供元素遍历的功能。Iterable里面包含了Iterator的迭代器,其源码如下,大家如果熟悉迭代器模式的话,应该很容易理解。

Comparable和Comparator
Comparable接口:
可比较的实现,该接口表示:这个类的实例可以比较大小,可以进行自然排序,定义了默认的比较规则,其实现类需要实现compareTo()方法,compareTo()方法返回正数表示大,负数表示小0表示相等,
Comparator接口
比较工具接口用于定义临时比较规则,而不是默认比较规则,其实现类需要实现compare()方法,Comparable和Comparator都是Java集合框架的成员
Map

图中的绿色的虚线代表实现,绿色实线代表接口之间的继承,蓝色实线代表类之间的继承。
Map类型的集合最大的优点在于其查找效率比较高,理想情况下可以实现O(1)的时间复杂度。Map中最常用的是HashMap,LinkedHashMap与HashMap的区别在于前者LinkedHashMap能够保证插入集合的元素顺序与输出顺序一致。这两者与TreeMap的区别在于TreeMap是根据键值进行排序的,当然其底层的实现也有本质的区别,如HashMap底层是一个哈希表,而TreeMap的底层数据结构是一棵树。
HashMap与TreeMap的区别,与之前提到的HashSet与TreeSet的区别是一致的, HashSet和TreeSet本质上分别是通过HashMap和TreeMap来实现的,所以它们的区别自然也是相同的。HashTable现在已经很少使用了,与HashMap的主要区别是HashTable是线程安全的,不过由于其效率比较低,所以通常使用HashMap,在多线程环境下,通常用CurrentHashMap来代替。
注:什么是时间复杂度?
HashMap底层原理分析
(以下分析仅为jdk1.8底层实现)
HashMap也是我们使用非常多的Map,它是基于哈希表的 Map 接口的实现,以key-value的形式存在。在HashMap中,key-value总是会当做一个整体来处理,系统会根据hash算法来来计算key-value的存储位置,我们总是可以通过key快速地存、取value。
HashMap定义
HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8; (jdk1.8以后才有)
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 负载因子
final float loadFactor;
}
注:transient:java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。换句话来说就是,用transient关键字标记的成员变量不参与序列化过程。
HashMap底层结构示意图
Jdk1.8中,HashMap底层基于数组、链表、红黑树实现。示意图如下:

红黑树:
红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。
特点:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色
- 红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
- 对于每个节点,从该点至null(树尾端)的任何路径,都含有相同个数的黑色节点。
在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整使得查找树重新满足红黑树的条件。如果有删除或者插入节点,使用左旋和右旋;
HashMap底层代码解析
HashMap提供了4个构造函数:
HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。
HashMap(Map<? extends K, ? extends V> m):传入一个map以构造一个新的map,使用默认加载因子(0.75)。
在这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
存值put(K key, V value)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//检测table是否为空,如果为空,则使用扩容函数进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果通过hash值取模得到的桶为空,则直接把新生成的节点放入该桶
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {//以下为该桶不为空的逻辑
Node<K,V> e; K k;
//判断桶的第一个元素的key值是否相同(hash值相同,且能equals)
//如果相同,则返回当前元素(函数末尾进行统一处理)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)//桶元素采用的是红黑树结构
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//桶元素采用的是链表结构
for (int binCount = 0; ; ++binCount) {
//如果遍历到了链表末端,则直接在链表末端插入新元素
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//插入之后,检查是否达到了转成红黑树结构的标准
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果在遍历过程中,发现了key值相同,则返回当前元素(函数末尾进行统一处理)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//处理相同元素的情况
if (e != null) { // existing mapping for key
V oldValue = e.value;
//如果onlyIfAbsent为ture,则在oldValue为空时才替换
//否则直接替换
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//修改次数+1
//map的size加1,然后判断是否达到了threshold,否则进行扩容
//threshold由Node[] table的长度及loadFactor控制
if (++size > threshold)
resize();
//执行回调函数
afterNodeInsertion(evict);
return null;
}

- 判断当前桶是否为空,空的就需要初始化数组(resize() 中会判断是否进行初始化)。
- 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
- 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
- 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
- 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
- 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
- 如果在遍历过程中找到 key 相同时直接退出遍历。
- 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
- 最后判断是否需要进行扩容。
取值 get(Object key)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果table不为空,则再进行查询操作
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//先检查第一个元素是否key相同
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果为红黑树结构,则走红黑树的查询逻辑
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//否则遍历链表
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
Get 方法看起来就要简单许多了。
- 首先将 key hash 之后取得所定位的桶。
- 如果桶为空则直接返回 null 。
- 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
- 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
- 红黑树就按照树的查找方式返回值。
- 不然就按照链表的方式遍历匹配返回值。
从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。
遍历方式
HashMap 的遍历方式,通常有以下几种:

强烈建议使用第一种 EntrySet 进行遍历。第一种可以把 key value 同时取出,第二种还得需要通过 key 取一次 value,效率较低。
总结
PUT的流程:
put过程是先计算hash然后通过hash与table.length取摸计算index值,然后将key放到table[index]位置,当table[index]已存在其它元素时,会在table[index]位置形成一个链表,将新添加的元素放在table[index],原来的元素通过Entry的next进行链接,这样以链表形式解决hash冲突问题,当元素数量达到临界值(capactiy_factor)时,则进行扩容,是table数组长度变为table.length_2;
Hash冲突:
如果通过hash运算计算出来的index值与数组中某个index值相同,说明此存储地址已经被其他元素所占有,这种现象称之为hash碰撞或hash冲突。 HashMap底层采用数组加链表加红黑树的方式,当存储地址index相同的时候,判断当前确定的索引位置是否存在相同hashcode和相同key的元素,如果存在相同的hashcode和相同的key的元素,那么新值覆盖原来的旧值,并返回旧值。
如果存在相同的hashcode,那么他们确定的索引位置就相同,这时判断他们的key是否相同,如果相同,这时就是产生了hash冲突。
Hash冲突后,那么HashMap的单个bucket里存储的不是一个 Entry,而是一个 Entry 链。
系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。
HashTable性能不高,HashMap性能不安全,多线程环境作何选择:
ConcurrentHashMap又安全有效率高. HashMap不安全,效率高,HashTable是安全,但是效率低
分段HashTable,每个HashTable就是都有一个锁,在访问这个HashTable时,其他的不影响.
面试题
- HashMap底层用到了那些数据结构?
- 为什么要用到链表结构?
- 为什么要用到红黑树?
- 链表和红黑树在什么情况下转换的?
- HashMap在什么情况下扩容?HashMap如何扩容的?
- HashMap是如何Put一个元素的?
- HashMap是如何Get一个元素的?
- 什么是Hash冲突
- HashMap是如何解决Hash冲突的?
- 还有哪些解决Hash冲突的方式?
此文章为5月Day3学习笔记,内容来源于极客时间《10小时吃透MySQL底层原理》