概要:HashMap是以数组和链表的结构形式存储数据。
数组:查找快(通过位置index查找,准确定位),增删慢(增删需要改变“变动元素”后面所有元素的位置),内存区域是连续的。
链表:增删快(只需要断开连接或者添加一个新的指针元素),查找慢(链表需要遍历所有元素),内存存储不连续,通过指针指向下一个元素
查看HashMap的构造函数
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
int threshold;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
initialCapacity:默认初始集合的容量,默认值16,在JDK1.8上默认容量是4
loadFactor:加载因子。默认值是0.75
默认情况下hashMap的实际容量是threshold 16*0.75 = 12;如果table数组的容量超过实际容量threshold就会扩容,size = 2 * table.length,新数组的长度是老数组的的2倍。
put()方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
int i = indexFor(hash, table.length);
for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
在JDK1.8中首先判定table是否为null,如果为null,则初始化table数组调用inflateTable(threshold),初始化table数组,在1.7中在构造函数中初始化table数组。
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
// Android-changed: Replace usage of Math.min() here because this method is
// called from the <clinit> of runtime, at which point the native libraries
// needed by Float.* might not be loaded.
float thresholdFloat = capacity * loadFactor;
if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
thresholdFloat = MAXIMUM_CAPACITY + 1;
}
threshold = (int) thresholdFloat;
table = new HashMapEntry[capacity];
}
capacity代表table数组的长度,并且是2^n。JDK1.8中创建一个默认长度值是4的table数组。然后回到put函数,首先判定key是否为null,如果为null,则取读取table[0]的元素为链表的头节点,遍历该条链表,如果有key==null的元素则替换value值。
看一下key不为null的情况下的存储,JDK1.8是h& (length-1)的位运算,等价于对length取模,也就是h%length,但是位运算比取余运算具有更高的效率,所以在1.8的时候改为位运算。
int i = indexFor(hash, table.length);
//JDK1.8
static int indexFor(int h, int length) {
return h & (length-1);
}
//JDK1.7
static int indexFor(int h, int length) {
return HashCode(Key) % Length
}
for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
以table[index]为链表的头节点开始遍历所有的元素,根据key的hash值和key相等,则新的value值替换老的value值,并返回老的value,如果当前对象第一次保存,看下面源码
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
HashMapEntry<K,V> e = table[bucketIndex];
table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);
size++;
}
**在没有扩容的情况下:**取出存放在table[bucketIndex]的元素即链表头结点oldEntry,将新对象的指针指向oldEntry。也就是说每次添加新的元素都是添加到table数组上,也就是放到链表的头节点,这种插入方式叫做“头插入”。为什么不插入链表尾部而是头部呢?后面get()查询中会解释。
扩容的情况下:
resize(2 * table.length)以原数组2倍的形式进行扩容。
void resize(int newCapacity) {
HashMapEntry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
HashMapEntry[] newTable = new HashMapEntry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(HashMapEntry[] newTable) {
int newCapacity = newTable.length;
for (HashMapEntry<K,V> e : table) {
while(null != e) {
HashMapEntry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
首先要创建一个新的数组,新的数组创建完毕,需要将老数组的数组全部重新存放到新的数组上面,由于newTable的length发生了变化,所以所有元素需要重排序存放。
- 重新创建一个 newTable = new HashMapEntry[2 * table.length];
- 遍历老数组table
- 遍历每个桶的链表
- 将table数组上的元素存放到newTable。
扩容的过程中需要将原因数组的所有元素重新排序存放到newTable上,遍历数组遍历链表是个很耗时的过程,所以在知道需要存放元素的个数时,将初始容量设置好。初始容量必须是2的N次幂,至于为什么要这样设置后面的问题环节解答。
put方法流程总结:
- 1:key为null时,则存放在table[0]的链表上,如果该链表存在之前key == null的元素,则替换,如果不存在key == null的元素,则直接添加到table[0]作为链表的头节点。
- 2:如果key不为null时,首先通过hash%table.length取余,即为table数组的index,查找table数组的头节点,遍历该链表的元素,判定存在则替换,不存在,则添加,添加时先判定是否需要扩容。扩容是以oldTable.length的2倍的形式。将新添加的元素存放到数组上,老的元素的指针指向上次的元素。
get()方法
明白了put()方法,那get方法就简单了,贴出get()代码
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
for (HashMapEntry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
首先如果key == null,则从table[0]上的链表中区遍历查找,
如果key !=null,通过key的散列运算获取到index,从table[index]上的链表中遍历查找。如果没有则返回null。
上面的问题:为什么不插入链表尾部?
之所以把newEntry放在头节点,因为我们每次查询时首先确定table数组的index,便是链表的头节点,
每次查询都是从链表的头节点开始遍历,最后插入的Entry被放到链表的头节点,这样被查找的可能性更大。
如果放到链表的尾部,那查询时可能需要遍历链表到尾部才能查询的到
2.HashMap的数据结构特点
HashMap融合了数组和链表二者的优点,相对而言比单纯的链表和数组,查找相对快,增删相对快。
HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,如果需要满足线程安全,可以用 Collections的synchronizedMap方法返回一个新的SynchronizedMap具有线程安全的能力,或者使用ConcurrentHashMap。
3.map使用时注意事项
-
扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。初始化容量的值必须的2的幂,例如:需要在map中设置75个元素,需要添加initialCapacity = 75/0.75+1 =101,但是101时不能满足需求的,要求必须是2的幂次方,离101最近的是128,所以需要设置初始化容量是128。
-
为什么初始化容量必须是2的幂?
在JDK1.8,计算key的位置运用的是“与运算”有如下的公式(Length是HashMap的长度):index = HashCode(Key) & (Length - 1)下面我们以值为“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值的最后几位==。
如果不按照lenghth的值是2的幂,有些index结果的出现几率会更大,而有些index结果永远不会出现,如果按照长度是2的幂,index的结果等同于hashCode()后几位的值,
Length-1的值是==所有二进制位全为1==,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
-
HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。那么为什么说HashMap是线程不安全的?在并发的多线程使用场景中使用HashMap可能造成死循环。Hashmap的Resize包含扩容和ReHash两个步骤,ReHash在(多线程)并发的情况下可能会**形成链表环。http://mp.weixin.qq.com/s/dzNq50zBQ4iDrOAhM4a70A
-
hash碰撞带来的问题?数组table的长度大于集合中元素的数量,每个桶包含的元素就比较少(最好就一个数据),但是如果多个hash值落在同一个桶里面(最差全部落在同一个桶里),这些值存储在一个链表上,这样会造成查询的时间复杂度O(1)到 O(n)。
问题
HashCode()算法
Hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5,SHA1,SSL中的公私钥验证都属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。
下面列举几种常用的hash算法:
Object:返回的是object对象内存地址经过处理后的结构,每个对象的内存地址不同所以算法返回的hash值也不同。改方法是native方法,这要取决出JVM的设计,
一般情况下是Java中的hashCode方法就是根据一定的规则将与对象相关的信息(比如对象的存储地址,对象的字段等)映射成一个数值
String:的hash算法s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
关于为什么取31为权?主要是因为31是一个奇质数,所以31*i=32*i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。
Integer: public int hashCode() {
return value;
}
返回的是value的值,如果俩个大小相同的Integer对象,即使不是同一个对象,hashCode值也是一样的。
JDK1.8和1.7比较的优化点在哪里?
JDK1.8:优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,
这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
加入了红黑树,Node数组来存储数据,但这个Node可能是链表结构,也可能是红黑树结构,如果同一个格子里的key不超过8个,使用链表结构存储。
如果超过了8个,那么会调用treeifyBin函数,将链表转换为红黑树。由于红黑树的特点,查找某个特定元素,也只需要O(log n)的开销
也就是说put/get的操作的时间复杂度最差只有O(log n)
JDK1.7在hashcode特别差的情况下,比方说所有key的hashcode都相同,这个链表可能会很长,那么put/get操作都可能需要遍历这个链表
也就是说时间复杂度在最差情况下会退化到O(n)
HashMap和HashTable的比较
(map继承关系图)
(HashTable继承关系图)
HashTable是遗留类,继承于Dictionary并且线程安全,但是其并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,
不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
什么是hash攻击?
通过请求大量的key,虽然key不同,但是key的hash值却相同,让hashMap不断的发生碰撞,变成一个SingleLinkedList,
即单链表。这样put/get()的时候时间复杂度从O(1)变成了O(n),cpu负载直线上升,这种方式叫做hash攻击。
提高HashMap的性能?
1.减少扩容次数。扩容是个相当耗时的过程,将初始容量设置好,避免再次扩容能提高效率。
2.解决碰撞损失:使用高效的HashCode与loadFactor,这个…由于JDK8的高性能出现,这儿问题也不大了。
3.解决数据结构选择的错误:在大型的数据与搜索中考虑使用别的结构比如TreeMap,这个就是积累了,一般需要key排序时,建议使用TreeMap;
让HashMap在多线程的情况下安全的原理
使用Collections.synchronizedMap(map),返回的是一个全新的Map对象,查看源码
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
........................
SynchronizedMap是一个加锁处理的Map的子类,这种加锁简单粗暴,在所有的方法体内都进行同步代码块限制,在高并发的情况下,更多的时候线程需要等待,这种方法效率比较低。HashTable继承于Dictionary,实现原理和SynchronizedMap类似,所有线程都在竞争一把锁,该锁锁住了table数组,它是安全的,但是效率是比较低的。建议使用ConcurrentHashMap,下一次研究一些ConcurrentHashMap的原理。