目录
0:哈希表
0.1:什么是哈希表(散列表)
不同的数据结构在内存上的物理存储只有两种:
顺序存储结构:顺序存储结构在内存上是连续的存储位置,顺序表的空间需要预先设置
优点:
(1):方法简单,各种语言都有数组,容易实现
(2):只需要存储数据,不用为节点的上下指针浪费内存开销
(3):按照下标访问,查询、修改速度快,耗时为:0(1)
缺点:
(1):添加、删除速度慢,因为数组字段需要移动,耗时为0(n)
(2):因为预设了数组长度,会造成内存浪费
链式存储结构:
优点:
(1):在内存上存在的区域不是连续的,便于利用碎片空间
(2):由于不是连续的所有插入,删除运算方便,耗时为0(1)
缺点:
(1):查询得时候需要遍历链式结构
(2):链表存储元素有元素前后缀指针,需要耗费多余的内存空间
哈希表也叫作散列表,哈希表的主干就是数组,哈希表的结构如下:
存储位置 = f(关键字)
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
新增逻辑:
新增该元素通过哈希函数把该元素的hashcode映射到连续的数组某一个位置,当需要新增另外一个元素的时候,哈希函数把该元素的hashcode取模映射到连续的数组一个index位置,每次新增哈希函数保证生成一个唯一的值,然后映射道不同的位置
查询逻辑:
哈希函数根据查询的关键字,找到hashcode,然后再把哈希code映射到内存中的该区域,找到该元素
0.2:哈希冲突
然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希函数运算,得到同一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,当插入的时候这个位置已经被占有了,那么哈希表如何解决这种冲突呢?
1:可以接着寻找下一块没有被占用的区域(开放地址法)
2:也可以在该位置下挂靠链表,将地址相同的元素挂靠的链表中(链地址法)
HashMap采用的就是链地址法
0.3:HashMap数据结构(数组+链表JDK1.8之前)
HashMap的数据结构采用的是链地址法,即数组+链表的数据结构,主干就是数组
插入:在插入时候由哈希函数根据生成的hashcode映射到数组中的某一个位置,然后插入,当这个位置有数据的时候,就在该数据的链表结构下挂靠新的元素
查询:根据哈希函数得到具体的地址位置,如果是链表就遍历,不是链表就取当前元素,
修改和删除也是类似
1:什么是Map
1.1:Map容器结构和优点
相较于第一章的Collection中的List和Set,map提供了键值对的存储方式也就是(key-value),在存储数据和获取数据是提供了更大的自由度,不用每一次都遍历整个容器的所有数据,只需要指定key的值即可,相对于List和Set,因此在一些情况下实用性更强,
map结构树如下:根据JDK1.8源码分析,
图中的蓝色方框是接口,绿色方框是实现类,红色连线是继承(extends),蓝色连线是实现(implements)
1.2:如何学习Map
Map接口有很多不同的实现类,这些不同的实现类有不同的优缺点,他们为什么有这些有些缺点呢?主要是
1:因为他们使用了不同的数据结构
2:底层源码的处理方式不同
所以我们学习HashMap,TreeMap,LinkedHashMap,HashTable和ConcurrentHashMap是主要从使用的数据结构和源码这两个维度来分析,在最后对Map类做一个横向对比总结,来结束Map的大体学习。
1.3:Map接口方法
map接口定义了操作map的通用方法,比如添加,删除,获取,比较等等方法,这些方法在不同的实现类有不同的方法实现,不同的实现决定了这些容器不同的特性和属性,这里就用JDK_API里边Map接口方法示例。
equals(Object o) |
将指定的对象与此映射进行比较以获得相等性。 |
forEach(BiConsumer<? super K,? super V> action) |
对此映射中的每个条目执行给定的操作,直到所有条目都被处理或操作引发异常。 |
get(Object key) |
返回到指定键所映射的值,或 null如果此映射包含该键的映射。 |
hashCode() |
返回此地图的哈希码值。 |
isEmpty() |
如果此地图不包含键值映射,则返回 true 。 |
keySet() |
返回此地图中包含的键的Set视图。 |
merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction) |
如果指定的键尚未与值相关联或与null相关联,则将其与给定的非空值相关联。 |
put(K key, V value) |
将指定的值与该映射中的指定键相关联(可选操作)。 |
remove(Object key) |
如果存在(从可选的操作),从该地图中删除一个键的映射。 |
remove(Object key, Object value) |
仅当指定的密钥当前映射到指定的值时删除该条目。 |
replace(K key, V value) |
只有当目标映射到某个值时,才能替换指定键的条目。 |
size() |
返回此地图中键值映射的数量。 |
values() |
返回此地图中包含的值的Collection视图。 |
HashMap,TreeMap,LinkedHashMap,HashTable和ConcurrentHashMap
2:HashMap(数组+链表)
2.1:HashMap特点(在JDK1.8)
1:由于hashmap是数组+链表组成的(JDK1.8之前),数组是主体初始长度为16(2的四次方),链表为了解决哈希冲突,在极端情况下可能数组上只有一个位置有数据,其他的数据在数组的桶结构上形成了一长串的链表,这是极端情况,会造成查询复杂度剧增的极限情况。所以1.8优化了hashmap的结构
2:在JDK1.8之后。HashMap时候数组+链表+红黑树,数据遍历解析是无序的(跟插入顺序不一致)
3:如果该位置不是链表,查询和添加都很快,为0(1),如果是链表需要遍历链表,或者红黑树也比较快为0(n),
4:修改删除都是先查看是否有链表,有的话遍历,没有的话就直接操作,数据无序
5:可以存储null键和null值,初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂,扩容因子是0.75.超过四分之三就回扩容
2.2:HashMap数据结构
jdk1.8之前的结构:采用数组+链表:
即使用链表处理冲突,同一hash值的节点都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。
jdk1.8之后的结构:采用数组+链表+红黑树:
通过数据结构红黑树的大体了解,我们知道平衡二叉数红黑树解决了二叉树层级过高的性能退化问题,即当一个hash值映射到同一个物理地址的时候,当数据量大于8的时候,会把链表转换成红黑树,结构如图
源码分析:可以每一个Node的结构如下,实现了Map.Entry接口
//添加方法参数key的哈希code=hash,键=key,值=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;
if ((tab = table) == null || (n = tab.length) == 0)
//第一步:tab 为空,初始化长度为16的node数组
//第一次添加初始化长度为DEFAULT_INITIAL_CAPACITY = 1 << 4;
n = (tab = resize()).length;
//第二步:根据hashcode判断数组该位置是否有数据
if ((p = tab[i = (n - 1) & hash]) == null)
//第三步:没有的话。在该位置添加数据
tab[i] = newNode(hash, key, value, null);
else {
//第四部:如果数据key的值存在并且相等,则tab覆盖数据
Node<K, V> e;
K k;
//第五步:判断key是否相等,相等覆盖 使用==和equal的原因是Integer的key有缓冲区
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 {
//第七步:在链表末尾中插入数据,next不存在直接插入,否则检查key的值
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
//第八步:长度超过TREEIFY_THRESHOLD=8,转换成红黑树
treeifyBin(tab, hash);
break;
}
//
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果key的值相等,覆盖数据,但是mod不用++,结束程序
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//多线程并发容易出问题 size和modCount容易被相互覆盖,或者不同步
if (++size > threshold)
//如果size长度超过threshold(16),则扩容,扩容因子是0.75F
resize();
afterNodeInsertion(evict);
return null;
}
HashMap的get方法
2.3:HashMap线程安全
线程不安全,导致线程不安全的原因(包括前边的ArrayList和LinkedList)都是因为在类中有全局变量,但是添加和删除等方法都没有加锁,导致运行添加等方法的时候,最造成多个线程操作到相size,互相覆盖导致数据不一致的情况
还有一些知识点:
/*
*
* 1:这里为什么不直接返回key.hashCode() 而是要进行^运算
*
* h >>> 16的意思是将hashcode右移16位,高位补0,高位全部是0,低位16是原来的高位
*
* key.hashCode()高位不变,h >>> 16高位是0,低位变成高位
*
* ^的意思是 如果相对应位值相同,则结果为0,否则为1
*
* 运算结果高16位不变,低16位变化很大(将高低位进行混合 充分利用高位忒性得到新的低位 这样做的目的是下边的&运算 能防止hash容易碰撞)
*
*
* tab[i = (16 - 1) & hash 屏蔽了hash的高位,只和低位运算
*
* 16-1=15 二进制是1111
*
* &的作用是如果相对应位都是1,则结果为1,否则为0
*
* 15&hash 只有最后的4位进行异或运算 找到表上的位置
*
*
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3:TreeMap
3.1:TreeMap特点
1:数据是无序的,插入顺序和解析解析顺序不一致,但是用迭代器查询是有顺序的:因为红黑树节点大小有规律,如果key是int型的话
2:底层数据结构的红黑树
3:查询修改速度快,添加和删除略慢一点。时间复杂度不是0(1),也不是0(n),是0(logN)
4:线程不安全
3.2:TreeMap数据结构
TreeMap的底层实现是红黑树,红黑树源码结构如下:
添加方法源码,删除方法类似,效率也是比较快的,需要重新着色和偏移
//红黑树的插入方法
public V put(K key, V value) {
//第一步:第一次插入,根节点为空,
Entry<K,V> t = root;
if (t == null) {
compare(key, key); // type (and possibly null) check
//第二步:创建根节店,颜色为黑
root = new Entry<>(key, value, null);
//长度为1
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
//比较器选择
Comparator<? super K> cpr = comparator;
if (cpr != null) {
//次处省略了,定义比较器方法和默认比较器方法一致。
}
else {
//采用默认比较器
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
//do while 循环,
do {
//根节店
parent = t;
//比较算法,和父节店循环比较
cmp = k.compareTo(t.key);
if (cmp < 0)
//比父节点小,插入左边
t = t.left;
else if (cmp > 0)
//比父节点大,插入右边
t = t.right;
else
//相等则覆盖value
return t.setValue(value);
} while (t != null);
}
//最后遍历到整个树节点的最终需要插入的位置
Entry<K,V> e = new Entry<>(key, value, parent);
//然后在这个节点中插入数据
if (cmp < 0)
parent.left = e;
else
parent.right = e;
//对数据进行调色和旋转处理,变成红黑树
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
查找方法和修改方法会很快,遍历层级结构即可,由于红黑树层级结构一般都很低。
3.3:TreeMap线程安全
treemap线程不安全,在多个线程操作的时候,其他线程对他进行增加删除操作,可能会影响到其他的线程
4:LinkedHashMap
4.1:LinkedHashMap特点
1:继承了HashMap,数据有序,底层是双向链表,跟LinkedList底层数据结构相似
2:添加数据后再after之后添加,保证顺序,查询和删除效率都不高,优点是能保证顺序
3:线程不安全
4:数据结构底层是双向链表,初始化数组为16
4.2:LinkedHashMap数据结构
底层是双向链表
底层数据结构在hashmap的基础上维护了一个双向链表:
增加方法:
LinkedHashMap并没有重写任何put方法。但是其重写了构建新节点的newNode()方法.
newNode()会在HashMap的putVal()方法里被调用,putVal()方法会在批量插入数据putMapEntries(Map<? extends K, ? extends V> m, boolean evict)或者插入单个数据public V put(K key, V value)时被调用。
LinkedHashMap重写了newNode(),在每次构建新节点时,通过linkNodeLast(p);将新节点链接在内部双向链表的尾部。
删除方法:
LinkedHashMap也没有重写remove()方法,因为它的删除逻辑和HashMap并无区别。
但它重写了afterNodeRemoval()这个回调方法。该方法会在Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable)方法中回调,removeNode()会在所有涉及到删除节点的方法中被调用,上文分析过,是删除节点操作的真正执行者。
查询方法:
遍历双链表,效率比较低
4.3:LinkedHashMap线程安全
线程不安全
5:HashTable(线程安全)
5.1:HashTable特点
0:默认构造函数长度是11,加载因子是0.75
1:线程安全,即所有的操作增删方法,都加了方法锁(synchronized),保证在多线程下只有一个线程能访问,效率比较低
2:底层数据结构也是哈希表(数组+单链表)。跟jdk1.8之前的hashmap结构一致,但是线程安全。
3:键值都不能为null,数据无序,源码会检查
4:初始size为11,扩容:newsize = oldsize*2+1;扩容因子也是0.75查过四分之三就会扩容
5.2:HashTable数据结构
底层是哈希表(数组+链表)
1:构造函数和代码分析
//添加方法加锁
public synchronized V put(K key, V value) {
// Make sure the value is not null
//value不能为空,值
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
//默认长度是11
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
//根据哈希code映射到数组的下标
Entry<K,V> entry = (Entry<K,V>)tab[index];
//如果下标有值,遍历循环,找到key相等,然后覆盖老的entity
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
//直接添加新的entry
addEntry(hash, key, value, index);
return null;
}
//添加逻辑方法
private void addEntry(int hash, K key, V value, int index) {
modCount++;
Entry<?,?> tab[] = table;
//元素页数小于阀值,默认阀值为8
if (count >= threshold) {
// 元素超过法制扩容哈希和设置新的阀值
rehash();
tab = table;
hash = key.hashCode();
//将元素映射到新的下标
index = (hash & 0x7FFFFFFF) % tab.length;
}
// Creates the new entry.
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>) tab[index];
//在该下标添加元素
tab[index] = new Entry<>(hash, key, value, e);
//元素个数加一
count++;
}
数据模型图 添加逻辑如下:
首先我们假设一个容量为5的table,存在8.8、10.10、13.13、16.16、17.17、21.21。他们在table中位置如下:
假如我们想要添加16的值,会根据key的哈希code找到1的位置,然后遍历桶(单链表结构,找到key相等的数据,然后新value覆盖老的value)其他的修改和删除逻辑类似
5.2:HashTable线程安全
hashmap线程安全,因为方法都加了锁,防止多线程同时操作带来的风险,会造成效率偏低
6:ConcurrentHashMap(线程安全)
5.1:ConcurrentHashMap特点
1:线程安全,即所有的操作增删方法,都加了分段锁(synchronized)和CAS,保证在多线程下只有一个线程能访问,首先在多线程的条件下,在数组上使用cas操作没有数据add数据,尝试添加数据,然后冲突的话才会使用synchronize获取锁
2:底层数据结构也是哈希表(数组+单链表+红黑树)jdk1.8升级。跟jdk1.8之前的hashmap(数组+链表)结构一致,但是线程安全。
3:键值都不能为null,初始长度为16,put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。
4:数据无序,key value都不能为空
5.2:ConcurrentHashMap数据结构
jdk1.8:数组+单链表+红黑树
jdk1.8之前:数组+链表
5.2:ConcurrentHashMap线程安全
采用分段锁机制,不在方法上加锁,锁了桶,提升了并发效率,并且采用CAS添加桶上明日有数据的槽位。