深入理解HashMap(底层,put源码分析,HashTable,ConcurrentHashMap,HashSet,LinkedSet,LinkedList
一、Map集合
1、HashMap
1、描述
- HashMap是常用的Java集合之一,是基于哈希表的Map接口的实现。设计目标是尽量实现哈希表O(1)级别的增删改查效果,与HashTable主要区别为**不支持同步和允许null作为key和value**。
- HashMap线程不安全,主要表现在:
- 多线程同时put时可能会丢失值(前面的put被后面的覆盖)。
- 多线程扩容时会出现环状结构,造成死循环。
- 多线程使用迭代器时会触发fast-fail机制。
2、底层实现
-
JDK1.8之前:
1、实现:
采用 数组+链表 实现,形成一个数组带着多个桶的结构,每个数组元素就是一个桶,而数组索引就是每个桶中链表的表头,每一个Map元素的数据结构是一个 Entry。
- 数组存储区间连续,占用内存较多,寻址容易,插入和删除困难。
- 链表存储区间离散,占用内存较少,寻址困难,插入和删除容易。
2、遗留问题:
在某些极端情况下,会导致大量元素都存放在同一个桶(数组索引是链表的表头)的链表中,此时的HashMap 就相当于一个单链表,假设链表中的元素个数为n个,则其==操作时间复杂度就变成了O(n)==,完全失去了哈希表的优势。
-
JDK1.8及以后:
1、实现:
采用 数组+链表+红黑树 实现。每个Map元素的数据结构是 Node(链表) 或 TreeNode(红黑树)。
2、解决遗留问题:
jdk1.8时默认还是使用 数组+链表 实现,只是元素的数据结构从 Entry 变为了 Node,当存储在同一个桶中的元素过多时,才会将链表转换为红黑树实现,保证其操作时间复杂度为 O(logn)。
3、链表与红黑树的转换条件
-
链表->红黑树:
整个 HashMap 中的元素个数 >= 64 且 同一个桶下的链表中的元素个数 > 8;
此时Map元素的数据结构从 Node 转为 TreeNode ;
-
红黑树->链表:
同一个桶下的链表中的元素个数 < 6;
此时元素的数据结构为 Node;
4、链表转红黑树的阈值为什么是8?
因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
5、为什么是红黑树,二叉平衡树不行吗?
-
因为平衡二叉是高度平衡的树, 当平衡被破坏时,需要要 rebalance(再平衡), 开销会比红黑树大.
-
原因:
-
插入引起的不平衡:
插入一个node引起了树的不平衡,平衡二叉树和红黑树都是最多只需要2次旋转操作,即两者都是O(1);
-
删除引起的不平衡:
最坏情况下,平衡二叉树需要维护从被删node到root这条路径上所有node的平衡性,因此需要旋转的量级是O(logN)此,而红黑树最多只需3次旋转,只需要O(1)的复杂度。
-
5、JDK1.8中HashMap的插入源码分析
-
/**
* 将指定参数key和指定参数value插入map中,如果key已经存在,那就替换key对应的value
* put(K key, V value)可以分为三个步骤:
* 1.通过hash(Object key)方法计算key的哈希值。
* 2.通过putVal(hash(key), key, value, false, true)方法实现功能。
* 3.返回putVal方法返回的结果。
*
* @param key 指定key
* @param value 指定value
* @return 如果value被替换,则返回旧的value,否则返回null。当然,可能key对应的value就是null
*/
public V put(K key, V value) {
// 倒数第二个参数false:表示允许旧值替换
// 最后一个参数true:表示HashMap不处于创建模式
return putVal(hash(key), key, value, false, true);
}
/**
* Map.put和其他相关方法的实现需要的方法
* putVal方法可以分为下面的几个步骤:
* 1.如果哈希表为空,调用resize()创建一个哈希表。
* 2.如果指定参数hash在表中没有对应的桶,即为没有碰撞,直接将键值对插入到哈希表中即可。
* 3.如果有碰撞,遍历桶,找到key映射的节点
* 3.1桶中的第一个节点就匹配了,将桶中的第一个节点记录起来。
* 3.2如果桶中的第一个节点没有匹配,且桶中结构为红黑树,则调用红黑树对应的方法插入键值对。
* 3.3如果不是红黑树,那么就肯定是链表。遍历链表,如果找到了key映射的节点,就记录这个节点,退出循环。如果没有找到,在链表尾部插入节点。插入后,如果链的长度大于TREEIFY_THRESHOLD这个临界值,则使用treeifyBin方法把链表转为红黑树。
* 4.如果找到了key映射的节点,且节点不为null
* 4.1记录节点的vlaue。
* 4.2如果参数onlyIfAbsent为false,或者oldValue为null,替换value,否则不替换。
* 4.3返回记录下来的节点的value。
* 5.如果没有找到key映射的节点(2、3步中讲了,这种情况会插入到hashMap中),插入节点后size会加1,这时要检查size是否大于临界值threshold,如果大于会使用resize方法进行扩容。
*
* @param hash 指定参数key的哈希值
* @param key 指定参数key
* @param value 指定参数value
* @param onlyIfAbsent 如果为true,即使指定参数key在map中已经存在,也不会替换value
* @param evict 如果为false,数组table在创建模式中
* @return 如果value被替换,则返回旧的value,否则返回null。当然,可能key对应的value就是null。
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
//如果哈希表为空,调用resize()创建一个哈希表,并用变量n记录哈希表长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
* 如果指定参数hash在表中没有对应的桶,即为没有碰撞
* Hash函数,(n - 1) & hash 计算key将被放置的槽位
* (n - 1) & hash 本质上是hash % n,位运算更快
*/
if ((p = tab[i = (n - 1) & hash]) == null)
//直接将键值对插入到map中即可
tab[i] = newNode(hash, key, value, null);
else {// 桶中已经存在元素
Node<K, V> e;
K k;
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
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, value>与put操作<key, value>相同时,不做重复操作,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 找到或新建一个key和hashCode与插入元素相等的键值对,进行put操作
if (e != null) { // existing mapping for key
// 记录e的value
V oldValue = e.value;
/**
* onlyIfAbsent为false或旧值为null时,允许替换旧值
* 否则无需替换
*/
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 更新结构化修改信息
++modCount;
// 键值对数目超过阈值时,进行rehash
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
3、各项默认值
-
初始容量(capacity):16(1<<4),即**2^4**。
-
最大容量:(1<<30),即**2^30**。
-
默认扩容因子(loadFactor):0.75。
-
自动扩容阈值:当前HashMap**元素个数 > capacity * loadFactor**,会自动扩容,并重新hash。
-
扩容机制:扩容至**当前HashMap容量*2.0,且每次扩容后的容量必须为2的n次方**。
-
容量必须为2的n次方的原因:
HashMap对元素进行读、写操作时,需要将Map元素的Key的哈希值对数组长度(HashMap 的容量)进行取模运算,结果作为该元素在数组中的索引(index),在计算机中,取模运算的代价远高于位运算的代价,当数组长度为 2^n 时,可以将Map元素的Key的hashcode对 (2^n)-1 进行 与运算(&),效果与对数组长度进行取模运算相等,所以为了提高 HashMap 的操作效率,规定 HashMap 的容量必须为2的n次方,即2^n。
4、线程安全
-
是否线程安全?不安全的场景。
HashMap线程不安全,主要表现在:
- 多线程同时put时可能会丢失值(前面的put被后面的覆盖)。
- 多线程扩容时会出现环状结构,造成死循环。
- 多线程使用迭代器时会触发fast-fail机制。
-
如何解决?
- 使用 Collections 的 synchronizedMap() 对其进行包装,使其线程安全。(锁整个表,效率差)
- 直接使用 线程安全的ConcurrentHashMap。(分段锁/CAS,效率相对较高)
2、HashTable
1、描述
- Hashtable 继承于 Dictionary 类(Dictionary类声明了操作键值对的接口方法),实现Map接口(定义键值对接口);
- 与HashMap一样也是哈希表(散列表),存储元素也是键值对。
- Hashtable 大部分情况下是线程安全的。
2、底层实现
继承于 Dictionary 类,实现Map接口,采用 Entry数组+链表 实现。
3、各项默认值
- 初始容量(capacity):11。
- 最大容量:Integer.MAX_VALUE – 8,即**(2^31-1)-8**,可能会导致内存溢出。
- 默认扩容因子(loadFactor):0.75。
- 自动扩容阈值:当前HashMap**元素个数 > capacity * loadFactor**,会自动扩容,并重新hash。
- 扩容机制:扩容至**当前HashMap容量*2.0+1**。
4、线程安全
Hashtable大部分方法采用 synchronized 修饰,说明Hashtable大部分情况下是线程安全的,但它采用的是 独占锁 机制,并发时会**锁住整个hash表,导致效率十分低下,且在执行一些复合操作时,也会有线程安全隐患。**
复合操作:
- 若不存在则添加。
- 若存在则删除。
- 等。
5、HashTable与 HashMap 的区别
HashMap | HashTable | |
---|---|---|
线程安全 | 不安全 | 大部分情况线程安全 |
允许为null | key和value都允许为null | key和value都不允许为null |
初始容量 | 16 | 11 |
扩容机制 | 扩容至当前容量的2倍 | 扩容至当前容量的2倍+1 |
3、ConcurrentHashMap
1、描述
ConcurrentHashMap 在 JDK1.5 时被加入,是 HashMap 线程安全的版本,其使用方式与 HashMap 一样。
2、底层实现
其他实现与 HashMap 一样,只是添加了线程安全的保障,这里主要讲线程安全的实现:
-
JDK1.7:
- 线程安全采用**分段锁机制来实现,底层数据结构仍然是数组+链表。与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组**,每个Segment下管理一个与HashMap数据结构差不多的table数组,在并发操作加锁时,锁住的是整个Segment,也就是说Segment的个数就是ConcurrentHashMap的最大并发数。
- 默认有16个Segment,即最大并发数为16。,这个值可以通过构造函数改变,但一经创建就不可更改。
- 尽管采用分段锁使锁的粒度小了很多,不再像HashTable那样锁住整个Hash表,但JDK1.7的ConcurrentHashMap的并发量还是受到了Segment个数的影响,为了能提供更大的并发量,JDK1.8的ConcurrentHashMap直接抛弃了分段锁机制,转而采用**CAS算法+synchronized**实现。
[外链图片转存失败(img-t71o1ESE-1563951531836)(C:/Users/Yang/Desktop/%E9%9D%A2%E8%AF%95%E5%87%86%E5%A4%87/img/ConcurrentHashMap-java7.jpg)]
-
JDK1.8及以后:
- 为进一步提高并发性,JDK1.8放弃了分段锁机制,将锁的级别控制在了更细粒度的**table元素级别上**,也就是说,只需要锁住一个table元素链表的头节点(head),并不会影响其他的table元素的读写,好处在于锁的粒度更细,影响更小,从而并发效率更好。
- 使用**CAS算法【Compare And Swap】 + synchronized关键字** 来保证**put操作**的线程安全,步骤如下:
- 先判断当前被put进Map的元素的Key在table中所对应的数组元素是否为null,若为null,则**通过CAS操作**将其设置为当前put的元素的value。
- 如果Key对应的数组元素(也即链表表头或者红黑树的根元素)不为null,则对该数组元素使用synchronized关键字申请锁,然后再进行操作。
[外链图片转存失败(img-J9lyk2v2-1563951531837)(C:/Users/Yang/Desktop/%E9%9D%A2%E8%AF%95%E5%87%86%E5%A4%87/img/ConcurrentHashMap-java8.jpg)]
-
CAS算法原理:
CAS即CompareAndSwap,中文意思是:比较并替换,是由底层硬件提供的一种同步算法,大致原理如下:
- CAS需要有3个操作数:
- 内存地址V:即将要修改的主存变量所在的地址。
- 旧的预期值A:即将要修改的主存变量所在的地址的值。
- 计算后得到的新值B,即将要更新的目标值。
- CAS改值操作步骤:
- 读取到主存变量的内存地址(V);
- 得到内存地址V的值(A)【旧的预期值】;
- 通过计算得到新的值(B);
- 在将要更新V的值之前,先将A与V的值进行比较(Compare);
- 当且仅当A==V的值时,才会将V的值改为B,否则什么都不做。
- CAS核心:当且仅当内存地址V的值与预期值A相等时,才将内存地址V的值修改为B,否则就什么都不做。整个CAS(比较并替换)操作是一个原子操作。
- CAS需要有3个操作数:
-
JDK1.8是基于CAS算法的轮询访问改值方式实现同步(即一直循环CAS算法去尝试改值,直到修改成功为止),虽然对CUP的消耗会大些,但如此实现的**线程同步是非阻塞式**的,并发量将得到很大提高。
3、各项默认值
- 默认最大并发数:16,即为 Segment 的个数。(jdk1.7)
- 其他默认值与HashMap一致。
二、List集合
1、ArrayList
1、描述
- ArrayList是List接口可调整大小的数组实现。实现所有可选的List操作,并允许所有元素,包括null,元素可重复。
- ArrayList是线程不安全的,体现在并发修改时会触发**快速失败(fail-fast)**机制。
2、底层实现
可调整大小的数组实现。
3、各项默认值
- 初始容量(capacity):10。
- 最大容量:Integer.MAX_VALUE - 8,即**Integer.MAX_VALUE - 8**,可能会导致内存溢出。
- 默认扩容因子(loadFactor):1。
- 自动扩容阈值:当前ArrayList**元素个数 > capacity * loadFactor**,会自动扩容。
- 扩容机制:扩容至**当前ArrayList容量*1.5**。
4、线程安全
-
是否线程安全?不安全场景?
ArrayList 是**线程不安全**的,并发修改就会触发 **快速失败(fail-fast)**机制。
-
如何解决线程不安全
- 使用 Collections.synchronizedList() 对其进行包装。
- 使用 CopyOnWriteArrayList 。
2、Vector
1、描述
- 与ArrayList一样,底层是数组结构。但因它的所有方法都用**synchronized**修饰,所以Vector是线程安全的。
- 因为线程安全,所以它的效率比ArraList低。
2、底层实现
数组。
3、各项默认值
- 初始容量(capacity):10。
- 最大容量:Integer.MAX_VALUE - 8,即**Integer.MAX_VALUE - 8**,可能会导致内存溢出。
- 默认扩容因子(loadFactor):1。
- 自动扩容阈值:当前ArrayList**元素个数 > capacity * loadFactor**,会自动扩容。
- 扩容机制:扩容至**当前ArrayList容量*2**。
4、线程安全
Vector是线程安全的。
3、LinkedList
1、描述
- LinkedList是List和Deque接口的**双向链表**的实现。实现了所有可选List操作,并允许包括null值。
- LinkedList是线程不安全的。
2、底层实现
双向链表。
3、各项默认值
无,因为是双向链表。
4、线程安全
LinkedList是线程不安全的,需要用 Collections.synchronizedList() 对其进行包装。
4、ArrayList、LinkedList、Vector的区别
ArrayList | LinkedList | Vector | |
---|---|---|---|
底层实现 | 数组 | 双向链表 | 数组 |
性能 | 索引查询快,添加、删除慢(主要是扩容) | 添加、删除快,索引查询慢 | 比ArrayList慢 |
扩容 | 动态扩容 | 双向链表,不用扩容 | 动态扩容 |
线程安全 | 不安全 | 不安全 | 安全 |
三、Set集合
1、HashSet
1、描述
- HashSet是Set接口的实现,元素无序、不可重复,底层是一个HashMap,用以保存数据。
- 不能保证元素的排列顺序,顺序有可能发生变化。
- 线程不安全。
- 集合元素可以是null,但只存在一个null。
2、底层实现
HashMap。
3、各项默认值
与 HashMap 一致。
4、线程安全
HashSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。
2、LinkedSet
1、描述
- LinkedSet是Set接口的实现,继承于HashSet,元素不可重复,底层是一个LinkedMap,用以保存数据。
- LinkedSet**可保证元素的插入顺序。**
2、底层实现
LinkedMap
3、各项默认值
与 HashMap 一致。
4、线程安全
LinkedSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。
5、与HashSet的区别
LinkedHashSet在迭代访问Set中的全部元素时,性能比HashSet好,但是插入时性能稍微逊色于HashSet。即**顺序访问性能好,插入、删除性能差。**
3、TreeSet
1、描述
- TreeSet是SortedSet接口的唯一实现类,元素不可重复。底层是一个TreeMap,用以保存数据。
- TreeSet**默认可保证元素的自然顺序(元素需要实现Comparable接口),可指定排序规则(需要重写元素的hashCode()方法和equals()方法)**。
2、底层实现
TreeMap
3、各项默认值
与 HashMap 一致。
4、线程安全
TreeSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。
4、HashSet、LinkedSet、TreeSet的区别
HashSet | LinkedSet | TreeSet | |
---|---|---|---|
顺序 | 无序 | 保证插入顺序 | 默认保证自然顺序 |
底层实现 | HashMap | LinkedMap | TreeMap |
线程安全 | 不安全 | 不安全 | 不安全 |
四、Java8新特性
1、速度更快
更改了底层数据结构,如HashMap、HashSet。
2、代码更少
新增语法:lambda表达式。
3、有强大的Stream API
Stream API
4、便于并行
优化 Fork/Join 框架。
5、最大化减少空指针异常
新增 Optional 容器类。
6、总结
eSet的区别
HashSet | LinkedSet | TreeSet | |
---|---|---|---|
顺序 | 无序 | 保证插入顺序 | 默认保证自然顺序 |
底层实现 | HashMap | LinkedMap | TreeMap |
线程安全 | 不安全 | 不安全 | 不安全 |
四、Java8新特性
1、速度更快
更改了底层数据结构,如HashMap、HashSet。
2、代码更少
新增语法:lambda表达式。
3、有强大的Stream API
Stream API
4、便于并行
优化 Fork/Join 框架。
5、最大化减少空指针异常
新增 Optional 容器类。
6、总结
最主要的核心还是 Lambda 表达式和 Stream API