HashMap是Java中使用最多的几种容器之一,和其他List、Set、Queue的各种实现相比,HashSet并没有实现Collection接口,而是实现的是Map接口。HashMap是基于哈希表的Map接口的实现,维护的一个个key - value(键值对)的映射关系,通过使用哈希算法使得对容器中的元素访问更加迅速。在推出HashMap之前,JDK中使用的哈希实现是HashTable,HashMap和HashTable的区别是HashMap不是线程安全的,但是HashMap允许key或者value为null。
先来看一下Map接口的定义
public interface Map<K,V> { //返回键值对的数目 int size(); //判断容器是否为空 boolean isEmpty(); //判断容器是否包含关键字key boolean containsKey(Object key); //判断容器是否包含值value boolean containsValue(Object value); //根据key获取value V get(Object key); //向容器中加入新的key-value对 V put(K key, V value); //根据key移除相应的键值对 V remove(Object key); //将另一个Map中的所有键值对都添加进去 void putAll(Map<? extends K, ? extends V> m); //清除容器中的所有键值对 void clear(); //返回容器中所有的key组成的Set集合 Set<K> keySet(); //返回所有的value组成的集合 Collection<V> values(); //返回所有的键值对 Set<Map.Entry<K, V>> entrySet(); //内部子接口Entry interface Entry<K,V> { //获取该Entry的key K getKey(); //获取该Entry的value V getValue(); //设置Entry的value V setValue(V value); boolean equals(Object o); int hashCode(); } boolean equals(Object o); int hashCode(); }
哈希函数的选取应该使得元素的分布尽可能的平均,常见的哈希函数有
当两个元素的哈希值相等的时候,也就是定位到同一个地方的时候,就会发生哈希冲突,常见的哈希冲突解决办法有
接下来通过分析HashMap的源码,可以发现HashMap也是围绕着这两个因素进行的。
HashMap的源码分析
HashMap有两个非常重要的变量:initialCapacity(初始容量)、loadFactor(加载因子)、以及Entry数组table。
初始容量就是初始构造数组的大小,可以指定任何值,但最后HashMap内部都会帮我们转成一个大于指定值的最小的2的幂,比如指定初始容量12,但最后会变成16,指定16,最后就是16...。
加载因子是控制数组table的饱和度的,一般指定0.75,也就是数组达到容量的75%,就会自动的扩容。
其实通过HashMap的构造方法我们就能够发现,并且可以指定这初始容量和加载因子两个变量的值,如果没有指定,则会使用默认值。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); this.loadFactor = loadFactor; threshold = initialCapacity; init(); }
Entry数组table就是我们加入的元素真正存储的地方。可能会发现我们put进去的是key和value,存放的却是Entry对象,是因为HashMap会用key和value去构建一个它的内部类Entry的对象,然后真正存放在table数组中的是根据key和value构建的Entry对象。再来看一下HashMap内部类Entry类的定义
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; } public final int hashCode() { return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); } public final String toString() { return getKey() + "=" + getValue(); } void recordAccess(HashMap<K,V> m) { } void recordRemoval(HashMap<K,V> m) { } }
再回到上面的构造方法当中,可以发现并没有按照我们指定的大小去初始化Entry数组table,这是因为通过构造方法构造HashMap对象之后(构造方法参数是Map类型的除外),并没有为我们开辟存储空间,而是等到第一次put操作的时候,才去分配空间,这样可以避免空间浪费。
存——put方法的实现
再来看一下核心方法put的源码
public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); //第三句 int i = indexFor(hash, table.length);//第四句 for (Entry<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; }
第二个if语句是判断key是否为空,因为HashMap和HashTable的不同点之一就是HashMap可以允许key或value为null,如果为null,这里就采取相应的操作。
第三句是根据key的值去计算hash值,因为在HashMap中,存放的位置只与key有关,而与value无关。
第四句就是根据计算所得的hash值以及数组长度,获取该hash值对应在数组中的位置。
接下来的for循环就是根据计算得到的数组位置,去该位置判断有没有该元素,如果没有则加入,并返回null;如果有,则替换为新值,并返回旧值(这里如果该位置存在多个元素,也就是之前已经多次发生哈希冲突,那么就需要挨个遍历,寻找有没有相等的元素)。
回到第一句查看inflateTable方法的源码,研究是如何开辟存储数组空间的。inflateTable方法如下:
private void inflateTable(int toSize) { // Find a power of 2 >= toSize int capacity = roundUpToPowerOf2(toSize); //第一句 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//对极限值threhold进行复制 table = new Entry[capacity]; //开辟数组 initHashSeedAsNeeded(capacity); }
看一下roundUpToPowerOf2方法,研究JDK是如何实现计算一个大于等于给定值的最小的2的n次幂。roundUpToPowerOf2方法如下:
private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; }
Integer的highestBoneBit方法:
public static int highestOneBit(int i) { // HD, Figure 3-1 i |= (i >> 1); i |= (i >> 2); i |= (i >> 4); i |= (i >> 8); i |= (i >> 16); return i - (i >>> 1); }
移位操作的使用,可以使计算机更加快速的计算出结果。翻阅之前的JDK源码,发现不是向上面(JDK1.7)这样计算大于等于初始容量的最小的2的n次幂。
之前的计算是这样的:
int capacity = 1; while(capacity < initialCapacity) <span style="white-space:pre"> </span>capacity <<= 1;
回归正规,再次观察inflateTable的最后一句,也就是initHashSeedAsNeeded方法,这其实就是根据capacity容量去计算一个随机的种子hashSeed,hashSeed跟之后计算哈希值有关。hashSeed就好像我们加密时使用的密钥,在StackOverfolw上是这样描述hashSeed的:
The seed parameter is a means for you to randomize the hash function. You should provide the same seed value for all calls to the hashing function in the same application of the hashing function. However, each invocation of your application (assuming it is creating a new hash table) can use a different seed, e.g., a random value.
Why is it provided?
One reason is that attackers may use the properties of a hash function to construct a denial of service attack. They could do this by providing strings to your hash function that all hash to the same value destroying the performance of your hash table. But if you use a different seed for each run of your program, the set of strings the attackers must use changes.
虽然seed近似随机,但在同一个HashMap中必须保证每次的计算Hash值的时候使用的同一个seed,也就相当于保证我们在一个密码系统中加密时,使用同一个密钥。同时使用seed可以抵御攻击,因为每个应用的seed都会一样。OK,这样inflateTable方法就分析完了,我们完成了存储数组table的初始化,以及极限值threshold和种子hashSeed的设置。
再次回到put方法中,第二个if语句是判断key是否为空,如果为空就采取putForNullKey操作,putForNullKey方法如下
private V putForNullKey(V value) { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(0, null, value, 0); return null; }
OK,回到put方法继续往下看,是一个根据key值计算hash值的hash方法,hash方法的源码如下:
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
回到put方法,继续往下看,是indexFor方法,这个方法是根据刚刚计算的hash值以及table数组长度,将hash值映射到数组中的指定位置。indexFor方法如下:
static int indexFor(int h, int length) { // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
回到put方法,综合hash(key) 与 indexFor(hash,length);方法,我们可以发现,HashMap中元素在底层Entry数组中的存放位置,只与关键字key有关,根据key就可以找到在数组中的相应位置。
当我们使用put方法找到了要存放的位置的时候,可能有两种情况,一,该位置为空没有防止元素,那么我们直接把元素放入进去;二、该位置有元素,还可能有多个元素组成的链表,那么我们需要遍历该位置的这些个元素,看是否有等于即将要放入去的元素,如果有,那么用新的value更新原来的旧的value,并返回旧的value,如果没有,则把这个新元素加入链表中。
观察put方法接下来的for循环如下:
for (Entry<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; } }
import java.util.HashMap; public class TestHash { public static void main(String[] args) { HashMap<User, Integer> map = new HashMap<>(); User user1 = new User("David", "Ricard"); User user2 = new User("David", "Ricard"); map.put(user1, 180); System.out.println(user1.equals(user2)); System.out.println(map.containsKey(user2)); } } class User { private String firstName; private String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } @Override public boolean equals(Object obj) { if(this == obj) return true; if(obj.getClass() == User.class) { User u = (User)obj; return u.firstName.equals(firstName) && u.lastName.equals(lastName); } return false; } }
所以在上面内部类User中需要重写hashCode方法,哪怕像这样:
@Override public int hashCode() { // TODO Auto-generated method stub return 0; }
@Override public int hashCode() { // TODO Auto-generated method stub return (firstName+lastName).hashCode(); }
OK,继续回到put方法,接下来使变量modCount加1,modCount是记录HashMap的结构上修改次数,这在遍历HashMap的时候会用到,如果在遍历过程中,发现modCount值变化,则会导致迅速的失败
接下来就是根据key、value、在数组中的位置index、该位置的hash值去创建一个Entry,然后把这个新创建的Entry放到数组中该位置,原先该位置的Entry则作为新Entry内部的next,形成一个链表结构。这样把新加入的元素放在链表的前面,是因为刚加入的元素短时间内可能被再次使用,这样检索速度就会很快。至此,关于核心put方法的分析完成。
取——get方法的实现
再来看一下核心get方法的源码实现:根据指定的关键字key,获取value
public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
接着,如果不是null,就会根据key去找到相应的Entry,在看一下getEntry的实现:
final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); 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 != null && key.equals(k)))) return e; } return null; }
掌握了put和get,以及相关的哈希特性,HashMap也就没什么了。
关于HashSet
虽然HashSet是继承Set接口,是Collection接口系的,但是通过HashSet源码,我们可以发现HashSet的底层是基于HashMap实现的,它的一些相关的方法也是直接调用HashSet的。在HashSet中存放的元素是用HashMap的key保存的,hashMap的value都是用一个静态的final对象(也就是下面代码中的PRESENT),这就保证了不可能有相同的元素存在(equals和hashCode判断)。
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { static final long serialVersionUID = -5024744406713321676L; private transient HashMap<E,Object> map; private static final Object PRESENT = new Object(); public HashSet() { map = new HashMap<>(); } public HashSet(int initialCapacity, float loadFactor) { map = new HashMap<>(initialCapacity, loadFactor); }
再看一下HashSet的添加元素add方法
public boolean add(E e) { return map.put(e, PRESENT)==null; }
原文地址:http://blog.csdn.net/diaorenxiang/article/details/39085069