Java中HashMap底层实现原理

JAVA里面有HashMap、HashTable、HashSet三种常用的Hash集合,由于经常性的使用,所以想了解一下三种集合的底层实现以及区别,在这里进行总结:

一:HashMap和HashTable的区别

1.HashTable是线程安全的,而HashMap是线程不安全的。

在Java中,我们new一个HashTable出来然后查看源码会发现,里面的实现方法都增加了synchronized关键字来确保线程同步,所以是线程安全的,但是HashMap当中却没有确保线程安全的机制。所以,在性能上来说,HashMap的性能比HashTable要高。我们在平时使用的时候,假如没有特殊的要求,尽量去使用HashMap进行操作。但是假设我们需要线程安全的HashMap应该怎么做呢?

a.在外部包装HashMap,实现同步

b.使用Map m = Collections.synchronizedMap(new HashMap(...));实现同步

c.使用HashTable

d.使用java.util.concurrent.ConcurrentHashMap,相对安全,效率高(建议使用)

2.HashMap可以使用null作为key,而Hashtable则不允许null作为key(尽量避免使用)。并且HashMap使用null作为key的时候,总是存储在数组的第一个节点之上的。

3.两者实现的接口和继承的类不同

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable

4.HashMap的初始容量为16,Hashtable初始容量为11,但是两者的填充因子默认都是0.75

5.两者计算hash的方法不同,HashTable只进行一次HashCode()计算,而HashMap会进行两次。

//HashTable
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
//HashMap
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }

6.HashMap和Hashtable的底层实现都是数组+链表结构实现

二:HashSet和HashMap、HashTable的区别

1.HashMap和HashTable都是key-->value的数据结构,但是HashSet是一个Set集合。HashSet内部使用HashMap实现,其中HashSet里面的HashMap所有的value都是同一个Object,里面的值就是HashMap的key。我们看HashSet的源码能够清楚的看到这一点。

//HashSet当中部分方法的源码实现
private static final Object PRESENT = new Object();
private transient HashMap<E,Object> map;
public HashSet() {
    map = new HashMap<E,Object>();
}
public boolean contains(Object o) {
    return map.containsKey(o);
}
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}
public boolean remove(Object o) {
    return map.remove(o)==PRESENT;
}
public void clear() {
    map.clear();
}

三:HashMap的底层实现原理

在JDK1.6或者JDK1.7当中,HashMap的底层实现是数组+链表的结构。在JDK1.8当中,HashMap的底层实现是数组+链表+红黑树实现。

我们先了解HashMap的旧的实现,因为新的实现是在旧的基础之上进行的升级。

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
    int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算

    /**
     * Creates new entry.
     */
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }

其中put方法如下:

public V put(K key, V value) {
    //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    //如果key为null,存储位置为table[0]或table[0]的冲突链上
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
    int i = indexFor(hash, table.length);//获取在table中的实际位置
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
        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++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
    addEntry(hash, key, value, i);//新增一个entry
    return null;
}

首先我们判断table是否已经初始化了,假如没有,进行初始化。下面是初始化的重要的参数:初始化table的默认长度是16,加载因子是0.75f。

/**
* HashMap的默认初始容量 必须为2的n次幂
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
 * 默认的加载因子
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

然后判断key是否为空,假如为空,放在table[0]的位置上面。假如不为空,再进行一次hash计算,确保我们的散列比较均匀(等下讲为什么要确保散列均匀)。然后根据hash值来判断这个值应该放在table当中的哪个位置。在for循环当中,我们判断这个位置是否已经被占用了,假如占用了,执行 了e=e.next。然后接下来判断当中key值对应的数据是否存在,存在就覆盖。假如不存在,执行addEntry()这个方法新增一个entry。

流程图如下:


上面我们讲到了位置被占用了,for循环当中执行了e=e.next。这也就是我们常说的hash冲突的问题:即两个key的值最后算出来的存储下标是一样的。这个时候,会在table[index]的位置上形成一个链表,将最新的元素放在table[index]的位置上,原来的元素通过e=e.next进行链接,这样以链表的形式解决hash冲突的问题。上面我们讲了两个初始化table的重要参数,在这里也起到非常大的作用,假如链表的长度达到CAPACITY*FACTOR(默认16*0.75f)的时候,table就会进行扩容,长度为table.length*2,具体过程如下:先创建一个容量为table.length*2的新table,修改临界值,然后把table里面元素计算hash值并使用hash与table.length*2重新计算index放入到新的table里面这里需要注意下是用每个元素的hash全部重新计算index,而不是简单的把原table对应index位置元素简单的移动到新table对应位置,所以非常消耗性能。

综上所述,我们分析出HashMap的内部存储结构如下图:

四:HashMap在JDK1.8的升级

我们上面讲述了JDK1.7或者以前版本HashMap的实现原理。其中假如链表的长度比较长的时候,也就是Hash冲突比较严重的时候,HashMap查找的速度会显著下降,因为链表的查询特定要求可能需要去遍历整个链表。为了优化这个问题,在JDK1.8当中,当链表的长度>8个的时候,链表的存储会转化成红黑树存储,以此来优化Hash碰撞严重情况下HashMap查找效率的问题(具体红黑树的插入,删除,查找为什么比链表高,还有待研究)。

五:为什么重写equals方法一定要重写hashCode方法

通过以上我们知道,我们判断存储下标的位置是通过hashCode去进行判断,但是我们判断是否相同是通过equals方法去进行判断。假设两个对象的equals的方法返回值都是true,按照常理,这样我们把他们放入HashSet或者用作HashMap的key时是不能够重复的,但是假如他们的hashCode的值不一样,HashSet或者HashMap会认为他们是不同的元素而把他们保存起来,从而严重的破坏集合的特性,有可能会导致比较严重的问题。

那么,我们在重写hashCode方法的时候,一般遵循以下规则:

(1)同一个对象多次调用hashCode()方法应该返回相同的值;
(2)当两个对象通过equals()方法比较返回true时,这两个对象的hashCode()应该返回相等的(int)值;
(3)对象中用作equals()方法比较标准的Filed(成员变量(类属性)),都应该用来计算hashCode值。


猜你喜欢

转载自blog.csdn.net/qq_38455201/article/details/80732839