对比学习法:
知道A是A,B是B,记不住,但是知道A和B有不同点C,然后导致A和B的表现差异D。这样可以学会因为C,导致D。
本文主要对比两个问题
1.hashmap和hashtable
2.hashmap在jdk1.6,1.8 这2个版本的源码实现差异
(1.2是一版,但是找不到源码了,1.6-1.7是一版,1.8又改了一部分) 改动不大,但是有区别
什么是散列表
前置知识
数据结构的目的: 提高效率
我们对数据一般有4个操作 (增删改查),其实就是2个基本操作 增和查
- 增 (顺序增加(头尾插入),随机插入)
- 删 (本质是查后删)
- 改 (本质是查后改)
- 查 (这个是难点,一般来讲,存入数据后,主要是查,按结构不同效率不同)
算法的复杂度一般单次操作以
O(1)最优,O(log(n))其次,O(n)最慢
对比常用数据结构的 增删查 时间复杂度
数组:
- 增 插入O(1)
- 删 查找O(1)
- 查 查找O(1)
- 扩容: 数据容量固定,扩容的代价为全部重新插入 O(n)
链表:
- 增 头尾插入O(1),指定位置插入 需要先查O(n),再插入
- 删 先查O(n) 再删 O(1)
- 查 O(n)
- 扩容: 无容量限制
可以查找的树是有序树,例如 二叉搜索树,红黑树,按红黑树来说
红黑树:
- 增 O(log(n))
- 删 先查O(log(n)),再删O(1)
- 查 O(log(n))
- 扩容: 链表实现无容量限制
最理想的增删查效率为
- 增 O(1)
- 删 先查O(1)
- 查 O(1)
- 扩容: 不用扩容或自动扩容
散列表就是一种结合数组+链表的数据结构,在限定范围内,效率可以达到上述的 理想效率
散列表的效率:
定位靠数组, 增删查靠链表,利用多链表,减少每一个链表的长度,最理想状态是,链表长度 = 1,实现理想效率,假设数组长度为N,链表长度为n,n的理想状态为1
- 增 定位O(1) 头插 O(1)
- 查 定位O(1) 查找 O(n)
- 删 先查O(n) 删除O(1)
- 链表扩容和冲突: 无容量限制,冲突就追加链表
- 数组扩容: 为了降低链表长度,保证查询效率,数组会扩容,重散列 O(N)
那么在什么情况下,散列表的效率最优
避免掉所有O(n)的项
1.数组不用扩容 避免O(N)的扩容
2.散列非常均匀,每个链表的长度为1
增,查,删,都是O(1) 理想效率
在什么情况下,散列表的效率最差
1.数组散列极度不均,链表长度为N
2.频繁扩容 O(N)
增 O(1) 查O(N)
散列表的具体实现就不详细说了,资料网上很多
下面看源码解析
主要从四个角度查看区别:
- hash()方法的实现,怎么保证散列均匀 (如何降低链表长度)
- 链表实现,为什么这么实现,如何优化查询效率
- 自动扩容机制,优化空间
- 线程安全
Hashtable jdk 1.0
1.hash方法
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
对 length 求余得 index
2.链表实现
private static class Entry<K,V> implements Map.Entry<K,V> {
int hash;
K key;
V value;
Entry<K,V> next;
// ....
}
链表实现是 普通的单链表
for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return e.value;
}
}
查询方式:顺序遍历链表
查询效率: O(n)
3.自动扩容
public Hashtable() {
this(11, 0.75f);
}
初始容量 11 , 阈值0.75,
// 扩容机制
int newCapacity = oldCapacity * 2 + 1;
// rehash机制
// 创建新的数组,遍历所有节点,rehash,
// 重新构建新的链表 假设插入速度O(1),则扩容效率O(N)
自动扩容 2N+1
4.线程安全
hashtable的方法由 synchronized 修饰 线程安全
但竞争会导致严重的效率低下,例如: 对不同key的put和get会相互阻塞,但是实际这里并没有竞争关系
HashMap jdk 1.6 ~ 1.7
1.hash方法
int hash = (key == null) ? 0 : hash(key.hashCode());
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
后面综合比较效率,这里暂时不对比hashtable
2.链表实现
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
}
链表结构没变化
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
遍历方式没有改变, 查找效率O(n)
3.自动扩容
public HashMap() {
// static final float DEFAULT_LOAD_FACTOR = 0.75f
this.loadFactor = DEFAULT_LOAD_FACTOR;
// static final int DEFAULT_INITIAL_CAPACITY = 16;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
}
默认容量为 16, 阈值为 0.75f
这里有一个优化点:
如果手动指定任意长度为容量, 则会将容量调整为 最接近的2的次方的值
public HashMap(int initialCapacity, float loadFactor) {
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
}
问题: 为什么容量一定要为2的次方,hashtable就不需要。
解答:
我们看indexFor方法 正常思路是 hash % length 得到index
indexFor怎么做的呢? hash & (length - 1) ,如果要 hash & (length - 1) = hash % length 则 length 必须是2的次方
然后 hash & (length - 1) 效率 大于 hash % length
注: 后面有测试结果
扩容算法
int newCapacity = oldCapacity * 2 + 1;
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
初始容量 11 , 阈值0.75, 自动扩容 2N+1
4.线程安全
线程不安全
1.modCount是共享变量,线程不安全
2.put时,线程A往节点N1后面增加节点,同时,线程B往节点N1后面增加节点B1成功,理论上,B1->N1链形成,A应该添加到B1前面,但依然添加到N1前面,导致B1丢失。
3.resize时会导致循环链表,get时陷入死循环
多个线程同时进行rehash操作,关键在于transfer(),这个方法的作用是将旧hash表中的元素rehash到新的hash表中,当多线程同时修改同一个节点的next时,会导致循环链表出现
HashMap jdk 1.8
1.hash方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// index求法 n表示长度length
i = (n - 1) & hash
这里比1.6优化了
求hash的步骤从 4步变为2步 更快了
2.链表实现
static class Node<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
}
链表结构没变化
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
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变化很大,是因为1.8以后,hashMap对链表做了优化:
- 1.链表长度>8时,转化为红黑树 优化查询速度 O(n)->O(log(n))
- 2.红黑树节点少于6时,转化为链表
3.自动扩容
public HashMap() {
// static final float DEFAULT_LOAD_FACTOR = 0.75f
this.loadFactor = DEFAULT_LOAD_FACTOR;
// static final int DEFAULT_INITIAL_CAPACITY = 16;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
}
默认容量为 16, 阈值为 0.75f
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;
}
这个比1.6的循环直到找到大于capacity的2的次方为止的算法要效率高
扩容算法
int newCapacity = oldCapacity * 2 + 1;
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
初始容量 11 , 阈值0.75, 自动扩容 2N+1 和1.6的相同
4.线程安全
线程不安全
存在多线程put导致节点丢失的线程安全问题
但是1.8不存在多线程resize导致get死循环的情况了
性能对比:
1.hash算法效率对比
public class HashMapSpeed {
public static void hashtable(int num,int length) {
long st = System.currentTimeMillis();
long sum = 0;
for(int i = 0 ; i < num ; i++) {
int hash = Integer.hashCode(i);
sum += (hash & 0x7FFFFFFF) % length;
}
long et = System.currentTimeMillis();
System.out.println("sum:"+sum+",time:"+(et-st));
}
public static void hashMap16(Integer num,int length) {
long st = System.currentTimeMillis();
long sum = 0;
for(int i = 0 ; i < num ; i++) {
int h = Integer.hashCode(i);
h ^= (h >>> 20) ^ (h >>> 12);
h ^= (h >>> 7) ^ (h >>> 4);
sum += h & (length - 1);
}
long et = System.currentTimeMillis();
System.out.println("sum:"+sum+",time:"+(et-st));
}
public static void hashMap18(Integer num,int length) {
long st = System.currentTimeMillis();
long sum = 0;
for(int i = 0 ; i < num ; i++) {
int h = Integer.hashCode(i);
h ^= (h >>> 16);
sum += h & (length - 1);
}
long et = System.currentTimeMillis();
System.out.println("sum:"+sum+",time:"+(et-st));
}
public static void main(String[] args) {
//
int num = 100000000;
hashtable(10000000,256);
hashMap16(10000000,256);
hashMap18(10000000,256);
}
}
结果
sum:1274991808,time:83
sum:1274991808,time:49
sum:1275008192,time:34
hash 一亿次,假设长度为256,效率对比如上图。
总结
hashtable
线程安全:通过synchorized实现 效率较低
普通散列表实现,会存在链表长度过长的情况
初始长度为11
hashmap: 线程不安全
想要线程安全请用ConcurrentHashMap
长度设置为 2的倍数,是为了优化hash的速度
从1.8开始,链表长度超过8时,转化为红黑树,低于6时,转化为链表
初始长度为16
hashmap连问
1.为什么用hashmap不用hashtable
hashtable的效率不如hashmap,
hashtable的key,value不支持null
2.hashmap的工作原理是什么
散列表数据结构,
3.HashMap的put过程
jdk 1.8:
1.hash后计算下标
2.找到对应的链表后,查找key值是否存在,
查找过程分为: 链表遍历或红黑树查找
找到以后,修改value值
否则,头插入新节点,
3.判断链表长度是否达到8,转化为红黑树
4.判断是否需要resize(),进行resize();
4.HashMap的get过程
1.hash后计算下标
2.找到对应的链表,查找key是否存在
如果节点是链表节点,遍历查找
如果节点是TreeNode,红黑树节点,二叉搜索树
5.怎么减少碰撞,即hash更散列
从HashMap的角度是: 更好的hash算法
从用户的角度: 更好的HashCode生成,不同的hashcode散列到不同的桶中的概率更低。相同的hashcode则会散列到同样的桶中。
6.HashMap的hash()方法怎么实现的
1.6的
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
1.8的
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
扰动次数更少,效率更高
7.HashMap的链表为什么用红黑树,不用二叉数,为什么需要8以上用红黑树,为什么不一直用红黑树?
二叉树在数据有序情况下可能变成一条链表,等于没有优化。
红黑树的插入比链表更加繁琐,为了保证插入和查找的高效,优先使用链表,
只有当链表长度过长时,才会转化为红黑树,均衡插入和查找的效率
8.说说红黑树
咳咳。。。
9.解决碰撞的其他办法?
1.链表法,HashMap这样插入链表的办法
2.开放寻址法,往数组后面的空位存放
3.再寻址法,再次hash,直到找到空位
10.如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
会进行rehash,数组大小*2,遍历所有节点,重新进行插入
11.重新调整HashMap大小存在什么问题吗?
jdk 1.6~1.7
get无限循环问题
多个线程同时进行rehash,因为插入方式为头插入,会导致A插入的节点以后,B重新将其头插入,形成循环链路,
导致get时无限循环
多线程put导致节点丢失
jdk1.8
修复了get无限循环的问题
还存在多线程put导致节点丢失问题。