HashMap源码解析

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中基本实现了这些方法,对于一个容器最为关键的就是增加元素和删除元素的方法,那么这里就是put方法和get方法。 而HashMap又是基于哈希表实现的,对于哈希表数据结构,最为关键的两个因素就是:哈希函数和哈希冲突的解决办法。

哈希函数的选取应该使得元素的分布尽可能的平均,常见的哈希函数有

  • 直接定址法
  • 数字分析法
  • 平方取中法
  • 折叠法
  • 除留余数法
  • 随机数法
  • .....

当两个元素的哈希值相等的时候,也就是定位到同一个地方的时候,就会发生哈希冲突,常见的哈希冲突解决办法有

  • 开放定址法
  • 再哈希法
  • 链地址法
  • 建立公共的溢出区
  • ......

接下来通过分析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();
    }

其中 threshold是一个极限值变量,等于加载因子乘以table数组的大小。这里对threshold只是暂时的保存指定的初始容量,并不是最后使用的值,在初始化数组方法inflateTable方法中会重新赋值。

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中有4个成员变量,其中key和value很好理解,还包含一个自身的实例next,其实就是用来解决哈希冲突的,这里使用的链地址法,用于指向同地址的下一个Entry;整形hash变量记录的就是该Entry值,hash是一个跟数组中位置有关的值,同一个位置的元素具有相同的hash值。

再回到上面的构造方法当中,可以发现并没有按照我们指定的大小去初始化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语句是判断存放空间table数组是否为空数组,如果为空(也就是第一次使用put操作),就去开辟数组空间。

第二个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); 
    }

第一句的作用就是寻找一个最小的2的n次幂使之大于toSize(也就是我们指定的初始容量),所有有经验的开发者会直接指定初始容量为2的n次幂。

看一下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;
    }

发现只有一句,并且完成了判断指定的值是否大于最大值和小于1,如果大于最大值,那么返回MAX_CAPACITY,也就是2的30次幂(一个非常大的数)‘如果小于1,那么则返回1;介于两者之间,则调用Integer的highestBoneBit方法,这个方法将返回小于等于给定参数的最大的2的n次幂,与所求刚好相反,所有传入参数的时候,把number先减去1,再乘以2(右移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);
    }

假如i=12,换成二进制就是1100,那么返回最高一位的1(最左边的1),后面的低位用0补齐,也就是1000(也就是10进制数8),如果i=16,二进制10000,返回的也是10000,也就是十进制16.

移位操作的使用,可以使计算机更加快速的计算出结果。翻阅之前的JDK源码,发现不是向上面(JDK1.7)这样计算大于等于初始容量的最小的2的n次幂。

之前的计算是这样的:

int capacity = 1;
while(capacity < initialCapacity) 
<span style="white-space:pre">	</span>capacity <<= 1;

可以发现,这两句的返回结果和上面roundUpToPowerOf2是一样的,都是计算大于等于给定参数的一个最小的数(这个数需要是2的n次幂)。

回归正规,再次观察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;
    }

可以发现,HashMap会把key为null的Entry放到table数组的第0位,也就是第一个位置,如果第0为没有元素,那么直接放入,并返回null;如果有元素,那么依次遍历,找到key等于null的那个,替换之,并返回旧值。需要说明的是,key=null的Entry永远会放在第0位,因为无法根据key计算hash值,从而得到具体的数组位置。第0位除了可以放置key为null的Entry,还是可以防止其他key不为null的Entry对象的,并且一个HashMap中最多可以同时存在一个key为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);
    }

这里就发现了刚刚提到的种子hashSeed,如果种子h不为0,并且key是String类型的,那么直接调用系统的sun.misc.Hashing.stringHash32((String)k)去计算hash值,如果不是,则根据key的hashCode值,再加上倒数第二句的数学运算,得到hash值。

回到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);
    }

回答开篇的时候提到的Hash函数上来,这个方法相当于使用除留余数法,也就是我们所说的取模法,所以上述语句也可以使用h%length ,只是这里使用与运算,更符合计算机的计算思维,也就更加高效。

回到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;
            }
        }

如果该位置没有元素为null,那么就不会进入到for循环体中;如果存在元素,那么就会进入循环体,进入if语句的判定条件。需要说明的是,这里的优先级关系相当于(e.hash == hash && ((k = e.key) == key )|| key.equals(k),也就是&&的优先级高于||。如果第一个e.hash==hash为false,那么就不会为Object  k赋值,导致equals方法为false,所以提醒我们重写equals的时候需要重写hashCode,否则,即时equals相等,这种情况下也无法比较。顺便补充一个关于equals和hashCode的非常有趣的例子

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作为HashMap的key,重写了equals方法,只要两个user的lastName和FirstName一样就认为是同一个对象。但是没有去重写HashCode方法。然后将user1加入到HashMap中,判断是否存在这个key,按照我们的设想,user1应该和user2等价,也就是上述程序的输出应该是true和true,但是实际的输出是true和false。原因就是上面if中的比较 e.hash == hash && ((k = e.key) == key )|| key.equals(k)  因为hashCode值不等,所以,即使原本equals相等,也因为k没有被赋值而变成和null比较。

所以在上面内部类User中需要重写hashCode方法,哪怕像这样:

@Override
	public int hashCode() {
		// TODO Auto-generated method stub
		return 0;
	}

也能够保证输出的是true和true,但是这样都返回同样的hash值会导致hash表极大的浪费和付出很高的性能代价,当元素都扎堆到一个位置,HashMap也就变成了链表结构。所以,推荐上述内部类的hashCode方法应该这样重写:

@Override
	public int hashCode() {
		// TODO Auto-generated method stub
		return (firstName+lastName).hashCode();
	}

顺便再说一下,关于HashMap中key的类型选择,一般选择String类型或者Integer类型,它们都重写了hashCode和equals方法,而且是不可变类,这非常重要,如果根据一个对象计算得到hash值,然后这个对象的状态(和hash值计算相关)如果改变,就会造成永远都无法根据这个对象找到原先那个value,因为hash值已经变化,所以应该尽量选择这些不可变对象作为Key。

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,如果是null,根据前面put方法的分析,则会去第0位去查找,不在赘述。

接着,如果不是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;
    }

除了一些条件判断,我们可以发现,是先根据key调用hash方法计算哈希值,然后在调用indexFor方法根据哈希值计算在数组中的位置,这跟put时是一样的,也就是说无论是存还是取(put和get),都需要计算hash值和获取位置,这也是要求作为key的对象最好不可变的原因,如果key在存入之后改变,那么再次根据key可能就取不出。得到在数组中的位置之后,还可能该位置是一串Entry链表,这是就需要挨个遍历,判断Entry中的key跟给定的key是否相等。

掌握了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源码中可以看出,HashSet是使用的HashMap实现,也可以指定初始容量和负载因子,不过都是调用的HashMap中的方法。

再看一下HashSet的添加元素add方法

public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }

直接是调用的hashMap的put方法,put方法是如果有这个值,则会返回原来的旧值(只要有value都是PRESENT常量),如果没哟旧值,则返回null。




  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
HashMap是Java中常用的数据结构,用于存储键值对,并支持O(1)时间复杂度的插入、查询、删除等操作。 HashMap源码解析如下: 1. HashMap是一个实现了Map接口的类,内部使用数组和链表实现。 2. HashMap中的键值对是以Entry对象的形式存储的,每个Entry对象包含一个键、一个值和指向下一个Entry对象的引用。 3. HashMap内部维护了一个默认容量为16的数组table,负载因子为0.75,默认扩容因子为2。当HashMap中的元素数量超过容量与负载因子的乘积时,即会触发扩容操作。 4. HashMap使用哈希函数将键映射到对应的数组下标上,实现快速查询。 5. 如果哈希函数产生了哈希冲突,即多个键映射到同一个数组下标上,HashMap会使用链表将这些键值对串起来,以便查询时遍历链表查找。 6. 在插入新的键值对时,HashMap会根据哈希函数计算出对应的数组下标,并将新的键值对插入到该位置的链表中。如果该位置的链表长度超过阈值(默认为8),则将这个链表转化为红黑树,以提高查询效率。 7. 在查询、删除键值对时,HashMap根据哈希函数计算出对应的数组下标,并遍历该位置的链表或红黑树,查找对应的键值对。如果链表或红黑树中没有对应的键值对,则返回null。 总之,HashMap是一个高效的数据结构,能够快速地插入、查询、删除键值对。不过,对于高度散列的数据集,也可能导致哈希冲突的增加,进而导致查询效率下降。因此,在使用HashMap时,需要合理地设置容量和负载因子,以及注意键的哈希函数的设计。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值