面试必问:java是怎么实现的hashMap?怎么处理冲突?自己该怎么实现呢?
疑惑:hash和hashCode的理解
一、扫盲篇:
1、<大话数据结构>中这样描述散列(hash)技术:
1、散列技术是在记录的存储位置(内存)和它的关键字(Object)之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置 f(key)。散列技术将记录存储在一块连续的存储空间,这块连续的存储空间被称为散列表或哈希表。关键字对应的记录存储位置(f(key)),我们称为 散列地址;
2、整个散列的过程其实也就两步:
存储时,通过散列函数(对应到hash())记录的散列地址,并按照此散列存储该记录。
查找时,按照相同的散列函数计算记录的散列地址,按此散列地址访问该记录。
所以,散列技术即是存储方式,也是一种查找方式。
3、方式:存储一个索引,查找与给定索引相等的记录。
2、Java的hashCode():因为hashCode方法是可以重载的;如没有重载,会用java.lang.Object的hashCode方法,只要是:
1、不同的对象
2、返回值没有超过hashCode()返回值允许的最大值(int 类型,2^32),那么两个对象的hashcode必然不同!
3、关于hash
Hash,一般翻译做“ 散列” ,也有直接音译为“ 哈希” 的,就是把任意长度的输入(又叫做预映射, pre-image ),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列 成相同的输出,而不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
hash主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128 位的编码, 这些编码值叫做HASH 值. 也可以说,hash 就是找到一种数据内容和数据存放地址之间的映射关系。
4、集合与引用
就像引用类型的数组一样,当我们把 Java 对象放入数组之时,并不是真正的把 Java
对象放入数组中,只是把对象的引用放入数组中,每个数组元素都是一个引用变量。对于 HashMap 而言,系统 key-value
当成一个整体进行处理,系统总是根据 Hash 算法来计算 key-value 的存储位置,这样可以保证能快速存、取 Map 的
key-value 对。
二、HashMap理解篇
分析的时候,从散列的常用方法 put(k,v) 和get(k)入手:
1 分析put(先查找,再addEntry)
1.1查看put源码(记录的是put的过程,散列的具体算法从简,所以使用jdk1.6,1.8太复杂)
1 public V put(K key, V value) {
2 if (key == null)
3 return putForNullKey(value);
//根据key的hashcode()计算hash值;
4 int hash = hash(key.hashCode());
//搜索制定hash值在对应table的索引;
5 int i = indexFor(hash, table.length);
//如果i索引的Entry不为null,通过循环不断遍历e元素的下一个元素;
6 for (Entry<K,V> e = table[i]; e != null; e = e.next) {
7 Object k;
//找到指定key与需要放入的key相等(hash相同equals返回true,就覆盖原来位置上value值(使用链表头插法))
8 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
9 V oldValue = e.value;
10 e.value = value;
11 e.recordAccess(this);
12 return oldValue;
13 }
14 }
15 //如果i的位置上还没有Entry为null,那么表明此处还没有Entry
16 modCount++;
17 //将key、value添加到i索引处;
18 addEntry(hash, key, value, i);
19 return null;
20 }
从程序中看出:当系统决定存储 HashMap 中的 key-value 对时,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。那么hashMap存储的时候都是先用key确定好位置,再在该位置上更新或插入value。
1.2 分析hash算法:
1 static int hash(int h) {
2 // This function ensures that hashCodes that differ only by
3 // constant multiples at each bit position have a bounded
4 // number of collisions (approximately 8 at default load factor).
5 h ^= (h >>> 20) ^ (h >>> 12);
6 return h ^ (h >>> 7) ^ (h >>> 4);
7 }
分析:
1、这里的对于任意给定的对象,只要它的 hashCode() 返回值相同,那么程序调用 hash(int h) 方法所计 算得到的 Hash 码值总是相同的。
2、目的:就像普及篇所说,hash就是将存入该容器的所有对象(其实是key值)的hashCode来进行一下hash转化,为了便于存储和查找。另外得到一个压缩版的“hashCode”值。
3、那么问题来了,我们怎么知道hash后的值到底存在table数组的哪个元素下呢? 这就引出了下面的方法:
1 static int indexFor(int h, int length) {
2 return h & (length-1);
3 }
分析:这个方法非常巧妙,它总是通过 h &(table.length -1) 来得到该对象的保存位置——而HashMap底层数组的长度总是 2 的 n 次方。(注:JDK1.8已经改进了,具体还没细看)
当length 总是 2 的倍数时,h & (length-1) 将是一个非常巧妙的设计:假设 h=5,length=16, 那么 h & length - 1 将得到 5;如果 h=6,length=16, 那么 h & length - 1 将得到 6 ……如果 h=15,length=16, 那么 h & length - 1 将得到 15;但是当 h=16 时 , length=16 时,那么 h & length - 1 将得到 0 了;当 h=17 时 , length=16 时,那么 h & length - 1 将得到 1 了……这样 保证计算得到的索引值总是位于 table 数组的索引之内。
注:保证散列后的地址全部都在数组的索引之内!
1.3 处理冲突(拉链法)
根据上面 put 方法的源代码可以看出,当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:
如果两个Entry的key的hashCode() 返回值相同,那它们的存储位置相同。进一步判断:
如果这两个 Entry的key通过equals比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但key不会覆盖。
如果这两个 Entry的key通过equals比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部——具体说明继续看 addEntry() 方法的说明。
1.4 Entry<k,v>节点分析:
1 static class Entry<K,V> implements Map.Entry<K,V> {
2 //自带属性
3 final K key;
4 V value;
5 Entry<K,V> next;
6 final int hash;
7
8 /**
9 * Creates new entry.
10 */
11 Entry(int h, K k, V v, Entry<K,V> n) {
12 value = v;
13 next = n;
14 key = k;
15 hash = h;
16 }
17
18 public final K getKey() {
19 return key;
20 }
21
22 public final V getValue() {
23 return value;
24 }
25 //这个地方注意:hashMap更新节点值的时候,会返回旧的值!
26 public final V setValue(V newValue) {
27 V oldValue = value;
28 value = newValue;
29 return oldValue;
30 }
31 //若hashcode相同,判断equals()
32 //重写equals注意自反性、对称性、一致性、传递性、非空性
33 public final boolean equals(Object o) {
34 if (!(o instanceof Map.Entry))
35 return false;
36 Map.Entry e = (Map.Entry)o;
37 Object k1 = getKey();
38 Object k2 = e.getKey();
39 if (k1 == k2 || (k1 != null && k1.equals(k2))) {
40 Object v1 = getValue();
41 Object v2 = e.getValue();
42 if (v1 == v2 || (v1 != null && v1.equals(v2)))
43 return true;
44 }
45 return false;
46 }
47 //1、key的hash()是会判断该一个bukect里有没有别的Entry,而不会进一步判断两者//是否相同(判断怎么添加,属于addEntry的事情),即判断该位置上有没有人,至于有//人、怎么处理就是add要做的了。
48 //2、在判断两个Entry是否相同的时候,要调用的是这个Entry.hashCode()和Entry.equals()。
//3、也是可以put(null,null)的原因,该Entry的hahsCode为0
49 public final int hashCode() {
50 return (key==null ? 0 : key.hashCode()) ^
51 (value==null ? 0 : value.hashCode());
52 }
53
54 public final String toString() {
55 return getKey() + "=" + getValue();
56 }
57
58 /**
59 * This method is invoked whenever the value in an entry is
60 * overwritten by an invocation of put(k,v) for a key k that's already
61 * in the HashMap.
62 */
63 void recordAccess(HashMap<K,V> m) {
64 }
65
66 /**
67 * This method is invoked whenever the entry is
68 * removed from the table.
69 */
70 void recordRemoval(HashMap<K,V> m) {
71 }
72 }
至此,put结束。
1.5 addEntry分析:
void addEntry(int hash, K key, V value, int bucketIndex) {
//将该位置上的原来的节点指向新的Entry上
Entry<K,V> e = table[bucketIndex];
//添加的Entry放在table上,并指向原来的头节点e
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
//因为addEntry是一个包访问权限方法,直接调用HashMap的size属性和resize方法,
//如果Map的k-v对的数量超过了极限,把table对象的长度扩充到2倍,以空间换取时间! (注:防止链过长,降低查找效率)
if (size++ >= threshold) //size:保存了该hahsMap中包含的键值对的数目;threshold:包含了hashmap能容纳键值对的极限,等于Capcity乘以负载因子(LoadFactor)
resize(2 * table.length); //table是存储bucket的底层数组
}
2 get的分析方法其实和put的分析是一样的,源码:
1 public V get(Object key) {
2 if (key == null)
3 return getForNullKey();
4 int hash = hash(key.hashCode());
5 for (Entry<K,V> e = table[indexFor(hash, table.length)];
6 e != null;
7 e = e.next) {
8 //只不过这里的处理简单了,当有符合条件的就直接返回e.value
9 Object k;
10 if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
11 return e.value;
12 }
13 return null;
14 }
分析:从上面代码中可以看出,如果 HashMap 的每个 bucket 里只有一个 Entry 时,HashMap 可以根据索引、快速地取出该 bucket 里的 Entry;在发生“Hash 冲突”的情况下,单个bucket里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素!
3、然后看一下Object的hashCode()
public native int hashCode();
解释:使用native关键字说明这个方法是原生函数,也就是这个方法是用C/C++语言实现的,并且被编译成了DLL,由java去调用。 这些函数的实现体在DLL中,JDK的源代码中并不包含,你应该是看不到的。对于不同的平台它们也是不同的。这也是java的底层机制,实际上java就是在不同的平台上调用不同的native方法实现对操作系统的访问的。所以找到头了,就不用管啦!!
(引自:http://blog.csdn.net/youjianbo_han_87/article/details/2586375)
4、hashMap的遍历三种方式:
//新建一个Hashmap
public class TestMap {
public static void main(String[] args) {
Map<String, Student> map = new HashMap<String, Student>();
Student s1 = new Student("宋江", "1001", 38);
Student s2 = new Student("卢俊义", "1002", 35);
Student s3 = new Student("吴用", "1003", 34);
map.put("1001", s1);
map.put("1002", s2);
map.put("1003", s3);
Map<String, Student> subMap = new HashMap<String, Student>();
subMap.put("1008", new Student("tom", "1008", 12));
subMap.put("1009", new Student("jerry", "1009", 10));
map.putAll(subMap);
work(map);
workByKeySet(map);
workByEntry(map);
}
//最常规的一种遍历方法:迭代。最常规就是最常用的,虽然不复杂,但很重要,这是我们最熟悉的,就不多说了!!
public static void work(Map<String, Student> map) {
Collection<Student> c = map.values();
Iterator it = c.iterator();
for (; it.hasNext();) {
System.out.println(it.next());
}
}
//利用keyset进行遍历,它的优点在于可以根据你所想要的key值得到你想要的 values,更具灵活性!!
public static void workByKeySet(Map<String, Student> map) {
Set<String> key = map.keySet();
for (Iterator it = key.iterator(); it.hasNext();) {
//想要在迭代中动态的修改容器的内容,不能使用map的方法,只能使用迭代器的方//法!
String s = (String) it.next();
System.out.println(map.get(s));
}
}
//比较复杂的一种遍历在这里,呵呵~~他很暴力哦,它的灵活性太强了,想得到什么就能得到什么~~
public static void workByEntry(Map<String, Student> map) {
Set<Map.Entry<String, Student>> set = map.entrySet();
for (Iterator<Map.Entry<String, Student>> it = set.iterator(); it.hasNext();) {
Map.Entry<String, Student> entry = (Map.Entry<String, Student>) it.next();
System.out.println(entry.getKey() + "--->" + entry.getValue());
}
}
}
5、关于hashMap的EntrySet的形式,个人感觉是一种逻辑结构,但是关键的地方是,每个bucket的最后一个Entry.next怎么确定?Jdk已经给出了答案:
1 //来自 HashMap的HashIterator<Map.Entry<K,V>>内部类
//这个其实是HashMap的自身的迭代器,他在迭代的时候是按照set集合的方式去做,将每个Entry装载到EntrySet(逻辑上的)中去。
//这也是使用链表的一个好处,1、不考虑下标,只考虑前后节点的关系;2、便于插入
2 final Entry<K,V> nextEntry() {
3 if (modCount != expectedModCount)
4 throw new ConcurrentModificationException();
5 Entry<K,V> e = next;
6 if (e == null)
7 throw new NoSuchElementException();
8
9 if ((next = e.next) == null) {
10 Entry[] t = table;
11 while (index < t.length && (next = t[index++]) == null)
12 ;
13 }
14 current = e;
15 return e;
16 }
17 //再结合一下源码来看,是不是感觉有点HashSet的味道
18 private final class KeySet extends AbstractSet<K> {
19 public Iterator<K> iterator() {
20 return newKeyIterator();
21 }
22 public int size() {
23 return size;
24 }
25 public boolean contains(Object o) {
26 return containsKey(o);
27 }
28 public boolean remove(Object o) {
29 return HashMap.this.removeEntryForKey(o) != null;
30 }
31 public void clear() {
32 HashMap.this.clear();
33 }
34 }
35 //进一步,来看看hashSet的构造函数和各大方法
36 1、 public HashSet() {
37 map = new HashMap<E,Object>();
38 }
39 2、 public HashSet(int initialCapacity, float loadFactor) {
40 map = new HashMap<E,Object>(initialCapacity, loadFactor);
41 }
42 3、 public HashSet(int initialCapacity) {
43 map = new HashMap<E,Object>(initialCapacity);
44 }
45 4、 public Iterator<E> iterator() {
46 return map.keySet().iterator();
47 }
48 5、 public int size() {
49 return map.size();
50 }
51 6、public boolean isEmpty() {
52 return map.isEmpty();
53 }
54 7、public boolean contains(Object o) {
55 return map.containsKey(o);
56 }
57 8、public boolean add(E e) {
58 return map.put(e, PRESENT)==null;
59 }
60 //所以说,hashSet的底层都是掉的是HashMap的方法;
6、看一下HashMap的构造函数源码,其他的构造函数调用此函数;
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//当自己指定的长度不是2^n的时候,就找出大于次数的最小的那个2^n作为初始capacity
//table的每一个元素位置是一个bucket,那什么时候查询的效率最高?
//就是每个bucket(桶)里面只有一个Entry<>的时候;不用遍历链表;
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
7、初始因子调整因素:
当创建HashMap时,有一个默认的负载因子(load factor),其默认值为 0.75,这是时间和空间成本上一种折衷:增大负载因子可以减少Hash表(就是那个Entry数组)所占用的内存空 间,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);减小负载因子会提高数据查询的性能,但会增加Hash表所占用的内存空间。
掌握了上面知识之后,我们可以在创建 HashMap 时根据实际需要适当地调整 load factor 的值;如果程序比较关心空间开销、内存比较紧张,可以适当地增加负载因子;如果程序比较关心时间开销,内存比较宽裕则可以适当的减少负载因子。通常情况下,程序员无需改变负载因子的值。
如果开始就知道 HashMap 会保存多个 key-value 对,可以在创建时就使用较大的初始化容量, 如果 HashMap 中 Entry 的数量一直不会超过极限容量(capacity * load factor),HashMap 就无需调用 resize() 方法重新分配 table 数组,从而保证较好的性能。当然,开始就将初始容量 设置太高可能会浪费空间(系统需要创建一个长度为 capacity 的 Entry 数组),因此创建 HashMap 时初始化容量设置也需要小心对待。
三:总结篇:
归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据 Hash 算法来决定其存储位置;当需要取出一个 Entry 时,也会根据 Hash 算法找到其存储位置,直接取出该 Entry。由此可见:HashMap 之所以能快速存、取它所包含的 Entry,完全类似于现实生活中母亲从小教我们的:不同的东西要放在不同的位置,需要时才能快速找到它。
个人感觉:1、hash算法分为两个大步:table(hash算法)和bucket(桶内遍历,JDK1.8有所改进,用树结构存储,有时间再看)
2、hashMap是一个数组和链表的结合体(装着链表的数组,大概这个意思),结合了各自的长处:hash时,使用数组易于查找的优点/插入entry时,使用了链表易于插入和删除的优势;
参考博客:
1、http://alex09.iteye.com/blog/539545/
2、http://zha-zi.iteye.com/blog/1124484
3、《大话数据结构》
4、源码JDK1.6
ps:如需转,转之。码字不易,请标明出处~