散列表
java中已知的基于散列表的数据结构有:hashmap,hashset, hashtable,LinkedHashMap,LinkedHashSet。
散列表整合了数组和链表的特点
备注:以下集合的原理均为jdk1.7下的
一.hashMap底层原理
1.1 hashMap数据结构
hashMap的结构如图所示:
对应源码
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
hashMap采用的就是散列表。
HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。HashMap数组每一个元素的初始值都是Null.
1.2 HashMap底层操作原理
对于HashMap,我们最常使用的是两个方法:Get 和 Put。
1.2.1 Put方法的原理
先对对象进行hash计算,找到对应的数组坐标,如果发生hash冲突,则使用头插法插入链表。
详情如下:
比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple"的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):
index = Hash(“apple”)
假定最后计算出的index是2,那么结果如下:
但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:
这时候该怎么办呢?我们可以利用链表来解决。
HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:
参考:漫画:什么是HashMap?https://www.cnblogs.com/qingyunzong/p/9143233.html
注意:
当index发生冲突时会使用头插法的方式。有点类似于栈的思想,最后进去的最先被找到.
源码如下:
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
//获取key的hash值
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
//遍历table中的元素
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//关键代码
// 如果hash相同并且 (两个key的对象相同 或者 两个key的equals()返回true)。
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
//此刻返回已经存储的值,所以不会对新的Entry进行存储
return oldValue;
}
}
//修改次数+1
modCount++;
//对新的Entry进行存储
addEntry(hash, key, value, i);
return null;
}
思考 hashmap如何保证元素的不重复
先判断是否出现hash冲突;
如果有的话,通过链表的遍历方式去逐个遍历,先比较key的对象是否相同,如果不相同则通过equals()看是否相同(通过equals()再比较主要考虑到有些对象可能重写了equals()方法),相同的话用新的value替 换老的value;
如果没有,则在table[index]插入该Entity,把原来在table[index]位置上的Entity赋值给新的 Entity的next,这样插入结束。(即头插法)
总结:数组位置冲突时,先遍历后插入。
参考:Hashpmap的原理,HashMap怎样保证key的唯一性 http://blog.csdn.net/o9109003234/article/details/44107811
1.2.2 Get方法的原理
通过计算hash值,找到对应的数组坐标,然后遍历链表找到对应的值。
详情如下:
首先会把输入的Key做一次Hash映射,得到对应的index:
index = Hash(“apple”)
由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:
第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。
第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。
之所以把Entry6放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。
1.2.3 remove方法的原理
分析一下remove元素的时候做了几步:
1、根据key的hash找到待删除的键值对位于table的哪个位置上
2、记录一个prev表示待删除的Entry的前一个位置Entry,e可以认为是当前位置
3、从table[i]开始遍历链表,假如找到了匹配的Entry,要做一个判断,这个Entry是不是table[i]:
(1)是的话,table[i]就直接是table[i]的下一个节点,后面的都不需要动,直接移除。
(2)不是的话,e的前一个Entry也就是prev,prev的next指向e的后一个节点,也就是next,这样,e所代表的Entry就被踢出了,e的前后Entry就连起来了。(同链表的删除原理)
参考自:http://www.cnblogs.com/xrq730/p/5052323.html
1.3 hashMap的算法
index = HashCode(Key) & (Length - 1)
HashMap扩容
默认大小为16,负载因子0.75,扩容时将容量变为原来的2倍以满足容量为2的幂。当超过16*0.75的大小时将进行扩容。
HashMap默认大小为什么是16?
主要目的就是让分布更加均匀。Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后四位。
详细原因如下:
下面我们以值为“book”的Key来演示整个过程:
1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
假设HashMap的长度是10,重复刚才的运算步骤:
单独看这个结果,表面上并没有问题。我们再来尝试一个新的HashCode 101110001110101110 1011 :
让我们再换一个HashCode 101110001110101110 1111 试试 :
是的,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!
这样,显然不符合Hash算法均匀分布的原则。
反观长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
思考
1.hashMap底层数据结构为何使用内部类这样写?
2. hash的遍历原理
二、WeakHashMap
WeakHashMap,从名字可以看出它是某种 Map,即对象为弱引用。它的特殊之处在于 WeakHashMap 里的entry可能会被GC自动删除,即使程序员没有调用remove()或者clear()方法。
其源码如下:
/**
* The entries in this hash table extend WeakReference, using its main ref
* field as the key.
*/
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
V value;
final int hash;
Entry<K,V> next;
}
@see 浅谈WeakHashMap http://www.importnew.com/23182.html
三、HashSet
HashSet的数据结构和hashmap基本一致,可以说HashSet就是由hashMap演变而来
类成员PRESENT
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
HashMap是键值对K-V模型的,HashSet则是普通的集合类型。这里的PRESENT就是用来填充HashMap中的value的。
3.1 构造函数
HashSet提供了4种构造函数。
// 默认无参构造函数
public HashSet() {
map = new HashMap<>();
}
// 以现有集合构造
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
// 带参构造 initialCapacity: 初始大小 loadFactor:加载因子
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
// 带参构造 initialCapacity: 初始大小
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
// 仅用来构造LinkedHashSet使用
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
由此可以证明HashSet就是由hashMap演变而来
add 方法
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
add方法很简单,直接调用HashMap的put方法实现。value则是用PRESENT进行填充。
remove 方法
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
remove方法也是同样,调用HashMap的remove方法。
HashSet大多数的方法都是通过HashMap的方法来实现的。
获取元素
hashSet没有提供get()方法来获取指定的元素,要想获取元素只有通过遍历的形式来获取:
/**
* 返回对此set中元素进行迭代的迭代器。返回元素的顺序并不是特定的。
*
* 底层实际调用底层HashMap的keySet来返回所有的key。
* 可见HashSet中的元素,只是存放在了底层HashMap的key上,
* value使用一个static final的Object对象标识。
* @return 对此set中元素进行迭代的Iterator。
*/
public Iterator<E> iterator() {
return map.keySet().iterator();
}
hashCode方法 & equals方法
如果我们在使用HashSet来存储自定义的对象时候,要记得重写hashCode方法和equals方法。
我们将通过一个示例来说明重写的必要性。
public class Test {
String a;
public Test(String a) {
this.a = a;
}
public static void main(String[] args) {
Set set = new HashSet();
Test o1 = new Test("abc");
Test o2 = new Test("abc");
set.add(o1);
set.add(o2);
System.out.println(o1.equals(o2));
System.out.println(set.size());
}
上面程序输出应该是:
false
2
为什么要重写?
默认equals方法是通过先比较引用是否一致的,然后比较对应的值是否一致。o1和o2显然是2个对象,他们是不相等的,他们各自的hashcode显然是不同的,所以HashSet会把他们当做2个不同对象来处理。
重写的思路就让两个对象的hashcode值相同如:
@Override
public int hashCode() {
return 123 * 31 + a.hashCode();
}
@Override
public boolean equals(Object o) {
Test test = (Test) o;
return test.a.equals(this.a);
}
参考:源码分析之HashSet https://www.jianshu.com/p/43f92a4b0b6f
四、hashTable
hashtable的底层结构和hashmap基本一致:
不同点:
1、结构不同-继承的父类不同
public class Hashtable extends Dictionary implements Map
public class HashMap extends AbstractMap implements Map
2.线程安全
Hashtable 线程安全很好理解,因为它每个方法中都加入了Synchronize。
如:
public synchronized V put(K key, V value) {}
public synchronized V get(Object key) {}
3、key和value是否允许null值
Hashtable中,key和value都不允许出现null值。
在HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。
4.内部实现使用的数组初始化和扩容方式不同
HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。
Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。
Hashtable和HashMap它们两个内部实现方式的数组的初始大小和扩容的方式。HashTable中hash数组默认大小是11,增加的方式是 old*2+1。
5.哈希值的使用不同,HashTable直接使用对象的hashCode。而HashMap重新计算hash值并取最后那几个。
五、LinkedHashMap
关于LinkedHashMap,先提两点:
1、LinkedHashMap可以认为是HashMap+LinkedList,即它既使用HashMap操作数据结构,又使用LinkedList维护插入元素的先后顺序
2、LinkedHashMap的基本实现思想就是----多态。LinkedHashMap是HashMap的子类,自然LinkedHashMap也就继承了HashMap中所有非private的方法。
其对象结构如下:
其数据结构如下:
其中前面四个是从HashMap.Entry中继承过来的;后面两个,是LinkedHashMap独有的。不要搞错了next和before、After。
next是用于维护HashMap指定table位置上连接的Entry的顺序的,before、After是用于维护Entry插入的先后顺序的。
LinkedHashMap的插入就是HashMap+LinkedList的实现方式,以HashMap维护数据结构,以LinkList的方式维护数据插入顺序。即LinkedHashMap在插入时保证顺序主要是通过before和after节点的改变。
LinkedHashMap的删除同时维护HashMap+LinkedList,HashMap维护数据结构,LinkList维护数据的原先顺序,即LinkedHashMap在删除时会打断before和after节点,然后重组before和after所指引的位置来保护数据的顺序。
LinkedHashMap进行遍历时,迭代的是LinkedList这个双向链表来进行迭代输出,这样就保证了元素的有序性
在LinkedHashMap的get方法中,通过HashMap中的getEntry方法获取Entry对象。即用到的是HashMap,不会访问到before和after节点。
5.1 利用LinkedHashMap实现LRU算法缓存
参考我写的另一篇文章: LinkedHashMap https://blog.csdn.net/sinat_34814635/article/details/79206821
六、LinkedHashSet
LinkedHashSet是对LinkedHashMap的简单包装,对LinkedHashSet的函数调用都会转换成合适的LinkedHashMap方法,因此LinkedHashSet的实现和LinkedHashMap基本一致。其源码如下:
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
......
// LinkedHashSet里面有一个LinkedHashMap
public LinkedHashSet(int initialCapacity, float loadFactor) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
......
public boolean add(E e) {//简单的方法转换
return map.put(e, PRESENT)==null;
}
......
}
具体原理请参考
5)图解集合6:LinkedHashMap http://www.cnblogs.com/xrq730/p/5052323.html
6)Java集合详解5:深入理解LinkedHashMap和LRU缓存https://blog.csdn.net/a724888/article/details/80290276
七、ConcurrentHashMap
为什么引用ConcurrentHashMap?
7.1.高并发下的HashMap不安全
7.1.1 Resize
HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。
这时候,HashMap需要扩展它的长度,也就是进行Resize。
影响发生Resize的因素有两个:
1.Capacity
HashMap的当前长度。上一期曾经说过,HashMap的长度是2的幂。
2.LoadFactor
HashMap负载因子,默认值为0.75f。
衡量HashMap是否进行Resize的条件如下:
HashMap.Size >= Capacity * LoadFactor
2.Hashmap的Resize包含扩容和ReHash两个步骤,
Resize流程
1.扩容
创建一个新的Entry空数组,长度是原数组的2倍。
注意:hashMap的扩容方法void resize(int newCapacity) 是在put(K key, V value)方法里
2.ReHash
遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。
7.1.2 ReHash
假设一个HashMap已经到了Resize的临界点。此时有两个线程A和B,在同一时刻对HashMap的同一个table进行Put操作,就有可能造成形成链表环:
整体情况如图所示:
当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于3的时候,由于位置3带有环形链表,所以程序将会进入死循环!
具体原理请参考:
1.漫画:高并发下的HashMap https://blog.csdn.net/minkeyto/article/details/78667944
2.漫画算法:如何判断链表有环?
https://mp.weixin.qq.com/s?__biz=MzIxMjE5MTE1Nw==&mid=2653189798&idx=1&sn=c35c259d0a4a26a2ee6205ad90d0b2e1&chksm=8c99047cbbee8d6a452fbb171133551553a825c83fb8b0cc66210dcda842c61157a07baaeb6b&scene=21#wechat_redirect
解决方案
1)HashTable或者Collections.synchronizedMap()
2)ConcurrentHashMap
HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占,会产生性能问题。
如下:
7.2 ConcurrentHashMap的数据结构
7.2.1 Segment
Segment是什么呢?
Segment本身就相当于一个HashMap对象。
同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。
单一的Segment结构如下:
像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。Segment的大小size默认为16。
因此整个ConcurrentHashMap的结构如下:
可以说,ConcurrentHashMap是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。
7.3 Segment的并发控制
- 不同Segment的并发写入:不同Segment的写入是可以并发执行的。
- 同一Segment的一写一读:同一Segment的写和读是可以并发执行的。
思考
1 写的时候读,难到不怕读到的不是最新的数据吗?
答:可以使用volatile修饰对应变量,使其保证在其他线程的可见性。
- 同一Segment的并发写入:Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞。
由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
实现原理
Get方法:
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.再次通过hash值,定位到Segment当中数组的具体位置。
Put方法:
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.获取可重入锁
4.再次通过hash值,定位到Segment当中数组的具体位置。
5.插入或覆盖HashEntry对象。
6.释放锁。
Size方法:
Size方法的目的是统计ConcurrentHashMap的总元素数量, 自然需要把各个Segment内部的元素数量汇总起来。但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?
请设想这样的场景:
ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:
1.遍历所有的Segment。
2.把Segment的元素数量累加起来。
3.把Segment的修改次数累加起来。
4.判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。
5.如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。加了锁之后,在统计过程中其他线程就无法进行put等操作了
6.再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
7.释放锁,统计结束。
这种思想应用到了cas和乐观锁转为悲观锁的思想。
官方源代码如下:
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
具体实现原理请参考:漫画:什么是ConcurrentHashMap?http://www.sohu.com/a/205451532_684445
参考资料
1)http://blog.csdn.net/lizhongkaide/article/details/50595719
2)码农翻身:http://chuansong.me/n/2045375251011
3)HashTable和HashMap的区别详解 http://blog.csdn.net/fujiakai/article/details/51585767
4)源码分析之HashSet https://www.jianshu.com/p/43f92a4b0b6f
5)图解集合6:LinkedHashMap http://www.cnblogs.com/xrq730/p/5052323.html
6)Java集合详解5:深入理解LinkedHashMap和LRU缓存https://blog.csdn.net/a724888/article/details/80290276
7)Java集合框架源码剖析:LinkedHashSet 和 LinkedHashMap http://www.cnblogs.com/CarpenterLee/p/5541111.html
8)漫画:高并发下的HashMap https://blog.csdn.net/minkeyto/article/details/78667944
9)漫画:什么是ConcurrentHashMap?http://www.sohu.com/a/205451532_684445