JDK 经典操作 之 HashMap 7、8 之间的差别

大家好,相信大家平时学习生活中HashMap肯定用的不少,反正在面试中你熟读其源码,了解其原理,知道其什么地方不合理,会导致什么样的问题

今天带大家看一看JDK1.7和JDK1.8的HashMap的源码

他们两个的差别随便抓一个还在上幼儿园的小盆友都说的头头是道

小朋友奶里奶气的说:1.7是数组+链表,1.8优化成了数组+链表(红黑树)

真的就这吗?

我们来看看其源码(每段源码前我用自己的话进行了一番描述,可能有点丑看不懂,就直接看源码吧,关键地方我也进行了注释)

先看1.7的

属性:

/** 构造函数没传初始化容量就使用该默认容量 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/** 最大容量,构造函数传的初始容量也不能大于这个 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/** 默认负载因子*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/** table未初始化时的table实例 */
static final Entry<?,?>[] EMPTY_TABLE = {
     };

/** 维护的一个table,根据需要调整大小,长度必须为2的幂*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE
    
/** entry的数量*/
transient int size;

/** 阈值,下一个需要调整大小的值,loadFactor * capacity */
// 当table未初始化时,这个就是initial capacity
int threshold;

/** 负载因子 */
final float loadFactor;

/** hash值生成种子,目的是减少hash冲突*/
transient int hashSeed = 0;

put()方法会先判断当前table是否需要初始化

然后会判断待添加的key是否为null

接着会计算出当前key的hash,根据算出的hash值和当前table的长度算出应该存放在index为多少的bucket(bucket可以看作是指定下标的数组)里面

会去遍历该bucket,判断是否有key相同的(判断依据是先判断hash值,再判断地址值或者值是否相等)

有则覆盖之前的值,无则新增一个entry

public V put(K key, V value) {
     
    // 假如当前table为空的,根据threshold(initialCapacity)扩容
    if (table == EMPTY_TABLE) {
     
        inflateTable(threshold);
    }
    if (key == null)
        // 当key为null时的put方法
        return putForNullKey(value);
    int hash = hash(key);
    // 根据hash值和当前table的长度计算应该放在下标为多少的bucket里面
    int i = indexFor(hash, table.length);
    // 这里去遍历指定下标的bucket,看key为当前key的entry有没有
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
     
        Object k;
        // 先判断hash值,再判断key是否==或者equals
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
     
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // key相等的entry没有
    addEntry(hash, key, value, i);
    return null;
}

初始化table方法:

会将initialCapacity向上取整为2的幂作为当前的capacity,并更新当前threshold为capacity * loadFactor

还会初始化hashcode生成种子(注意,这个什么hash种子只存在于1.7,1.8就无了)

private void inflateTable(int toSize) {
     
    // toSize向上取整为2的幂
    int capacity = roundUpToPowerOf2(toSize);
	// 更新当前阈值
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    // 懒加载hashcode种子
    initHashSeedAsNeeded(capacity);
}

当key为null时的put方法:

HashMap默认将key为null的放在index为0的bucket里面,所以这里会去遍历index为0的bucket里面是否有key为null的entry

有则替换,无则新增一个entry

private V putForNullKey(V value) {
     
    // key为null时,默认将entry放在index为0的bucket里面
    // 这里会去遍历index为0的链表,如果找到key为null的会替换
    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++;
    // 没找到这里会新增一个key为null的entry
    addEntry(0, null, value, 0);
    return null;
}

进行新增entry的一些前置工作:

这里涉及到一些扩容操作

如果当前size大于等于threshold并且指定index的bucket不为null时(也就是将要放入的bucket里一个元素都没有,不会进行扩容)

将bucket的长度扩大为现在的两倍,并将hash值和index值重新计算

void addEntry(int hash, K key, V value, int bucketIndex) {
     
    // 替换是不涉及扩容的,这里新增才会涉及
    if ((size >= threshold) && (null != table[bucketIndex])) {
     
        // bucket长度扩大为现在的两倍
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

扩容:

扩容方法,先对旧table进行容量判断,如果旧的容量已经最大了,那么不能再大了,直接不扩容

否则会生成一个新的table,将旧table里的entry全部转放到新的table里面,会重新生成hashcode种子,并判断是否需要重新hash

threshold设置为newCapacity * loadFactor

void resize(int newCapacity) {
     
    // 检查当前长度越界没有
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
     
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    // 重新生成hash种子会判断是否需要重新hash,再将现有的entry转移到new table里面
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

将扩容后的entry从旧table 转移到 new table里面:

遍历每一个bucket,再遍历每一个bucket的链表,如果需要hash会重新hash,并重新计算该放入的bucket的index

注意:这里的操作会使链表反转(记住,要考的)

void transfer(Entry[] newTable, boolean rehash) {
     
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
     
        // e是每一个bucket
        while(null != e) {
     
            Entry<K,V> next = e.next;
            // 需要重新hash就会重新hash
            if (rehash) {
     
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 重新计算bucket的下标
            int i = indexFor(e.hash, newCapacity);
            // 经典将链表反转
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

真 - 添加entry方法:没什么好说的,只有一个头插法比较6(要考哦)

void createEntry(int hash, K key, V value, int bucketIndex) {
     
        Entry<K,V> e = table[bucketIndex];
    	// 也是经典头插法
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

get()会先判断需要查找的key是否为null,感觉get()没什么好说的,相比put()来说简直太清爽,随便看一看就好

public V get(Object key) {
     
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}

获取key为null的value的方法:

private V getForNullKey() {
     
    // 如果当前size为0,直接返回null
    if (size == 0) {
     
        return null;
    }
    // key为null会将其放在index为0的bucket里面
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
     
        if (e.key == null)
            return e.value;
    }
    return null;
}

获取指定key的方法:

final Entry<K,V> getEntry(Object key) {
     
    if (size == 0) {
     
        return null;
    }
	// 计算带查找key的hash值	
    int hash = (key == null) ? 0 : hash(key);
    // 根据当前table的长度和hash值计算bucket的index,并遍历
    for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) {
     
        Object k;
        // 判断依据还是和添加时一样的,先判断hash,再判断地址值或者值
        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

线程安全问题:在扩容时采用头插法,头插法会使元素顺序倒转,多线程情况(两个线程同时扩容,一个线程先扩容完成,另一个线程看到的链表就是被倒转的)下会产生环形链表导致死循环以及数据丢失的问题

看完清爽的1.7,接下来看1.8(不得不说,就算1.8优化的再6,思想再6,1.8的代码写的是真的臭,比1.7清爽的代码不知道差到天上去,自己写时爽,读时火葬场)

1.8的HashMap:

因为1.7在发生严重hash冲突时整个Map会退化成链表,导致效率会很低,1.8做了一些优化

1.8将内部的Entry改为Node(这个操作谁给我解释一下),除此之外好像没什么毛区别

1.8的属性与1.7相比基本变,只是新增了几个属性,还有就是没有了EMPTY_TABLE这个属性

/** list->tree的阈值,必须大于2且至少为8?*/
static final int TREEIFY_THRESHOLD = 8;

/** tree->list的阈值,最多为6,*/
static final int UNTREEIFY_THRESHOLD = 6;

/** 树化的最小容量 至少为4 * TREEIFY_THRESHOLD*/
static final int MIN_TREEIFY_CAPACITY = 64;

1.8的hash()方法相比1.7的更简单了,1.7还有什么hash种子,1.8已经没有了

1.7的:

final int hash(Object k) {
     
    int h = hashSeed;
    if (0 != h && k instanceof String) {
     
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

1.8的:

static final int hash(Object key) {
     
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

原因可能(我也不知道,猜的,抓作者过来问一问就知道了)是因为在1.7的时候,应该要尽量避免hash冲突,否则会导致整个表退化成为一个链表,而1.8引进了红黑树,相对而言,比1.7不那么怕了,所以整个hash值的运算变简单了

put():put()方法就是一个玩具,下面才是正题

public V put(K key, V value) {
     
    return putVal(hash(key), key, value, false, true);
}

putVal():

onlyIfAbsent如果为true的话,表示不覆盖key已经存在的值,evict为false,表示当前table处于创建模式

putVal()会先判断当前table是否需要初始化

接着会判断待插入节点应放在table的位置是否为null(也就是待插入节点应放在的数组位置,现在是不是为null),为null就直接放入了不需要后面的操作

不为null的话接着判断数组(链表)的第一个元素的key和待插入元素的key相同不,判断依据是hash值是否相同,以及内存地址或者值是否相同(与1.7好像无异)

不相同会判断是否为treeNode,假如是,会依据treeNode的方式去添加元素

以上都不成立,会遍历当前链表,找到最后一个空位置将其插入,或者是找到链表上是否有key相同的节点

假如找到了最后一个空位置会判断是否需要树化

之前找到的相同key的位置会放到现在来覆盖,最后会判断是否需要扩容

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
     
    Node<K,V>[] tab; 
    Node<K,V> p; 
    int n, i;
    // 如果当前table还未初始化(为null或者长度为0)
    if ((tab = table) == null || (n = tab.length) == 0)
        // 初始化当前table
        n = (tab = resize()).length;
    // 当前数组元素是否为null
    // 这里顺便确定了一下当前entry应该放在index为多少的数组里面
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 为null直接新增一个node(list)放在链表头(也就是数组位置)
        tab[i] = newNode(hash, key, value, null);
    else {
     
        Node<K,V> e; K k;
        // p是当前数组的元素(也就是链表的第一个元素)
        // 判断当前元素与待插入元素的key是否相同
        // 依据是hash值是否相同,以及内存地址或者值是否相同
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            // 这里是tree版的putVal()
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
     
            for (int binCount = 0; ; ++binCount) {
     
                // 这里在遍历链表
                if ((e = p.next) == null) {
     
                    // 找到最后一个节点插入
                    p.next = newNode(hash, key, value, null);
                    // 当前链表长度大于等于树化阈值就会树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 找到了key相同的节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // 迭代链表节点
                p = e;
            }
        }
        
        // e不为null说明找到了key相同的节点
        if (e != null) {
      // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // afterNodeAccess()是空实现,为LinkedHashMap准备的
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 添加完新节点后size大于阈值需要resize
    if (++size > threshold)
        resize();
    // afterNodeInsertion(),这个也是空实现,同上
    afterNodeInsertion(evict);
    return null;
}

resize():

首先会判断当前容量是否大于0,大于0说明是扩容,则只要不超过最大容量的情况下将容量和阈值分别扩大两倍即可

如果不为扩容,会判断是否当前阈值大于0,当前阈值大于0说明从构造函数里面传入了初始容量,只是用阈值这个值来暂放一下,会把新容量设置为当前阈值

都不是(既不是扩容,构造函数也没传入初始容量),使用默认容量,配合默认负载因子确定阈值(其实也是默认的)

接着判断如果新容量是当前阈值,将当前阈值更新(根据老公式)

以上是确定新容量和新阈值,下面是将旧table里的元素放入新table,对应1.7里的transfer()

如果旧table不为null,说明是扩容,会去遍历没每一个数组元素,再遍历每一条链表(或许不为链表)

假如这个链表只有一个元素,直接计算其在新table里面的下标放入即可

如果是treeNode,则按照treeNode的方式转移

如果都不是,就会遍历当前链表,依据e.hash & oldCap是否为0,将原链表hash为高低位两个链表

低位的链表保留在与old table同一index的位置,高位的链表放在old table的index + old容量的位置 ,且这里与1.7不同点在于,注释里面都写了preserve order(保留顺序)

final Node<K,V>[] resize() {
     
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 大于0说明是扩容
    if (oldCap > 0) {
     
        // 不能扩大到MAXIMUM_CAPACITY之外
        if (oldCap >= MAXIMUM_CAPACITY) {
     
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 将新容量新阈值扩大为两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 初始化容量为0,阈值不为0,则初始化容量就为当前阈值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 使用的是空构造
    else {
                    // zero initial threshold signifies using defaults
        // 默认容量1 << 4 aka 16
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 默认阈值 0.75 * 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // new阈值为0表示初始化容量使用了当前阈值
    if (newThr == 0) {
     
        // 更新一下当前阈值,实则是缩小了
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    // 创建一个新的table
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 下面要将old table的entry转移到new table,相当于1.7的transfer()
    if (oldTab != null) {
     
        // 遍历每一个数组元素
        for (int j = 0; j < oldCap; ++j) {
     
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
     
                // 虽然他没写,我帮他写一个help GC
                oldTab[j] = null;
                // 这个链表只有一个元素
                if (e.next == null)
                    // 计算出新table的index并放入
                    newTab[e.hash & (newCap - 1)] = e;
                // 树节点的放入
                else if (e instanceof TreeNode)
                    // 这里也是将树拆分为高位和低位,如果树太小就变为list
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 不同于1.7,这里要保留当前顺序
                else {
      // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
     
                        next = e.next;
                        // 这里根据e.hash & oldCap是否为0,将原链表hash为高低位两个链表
                        if ((e.hash & oldCap) == 0) {
     
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
     
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 低位的链表保留在与old table同一index的位置
                    if (loTail != null) {
     
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 高位的链表放在old table的index + old容量的位置 
                    if (hiTail != null) {
     
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

get():get()方法好像还是继承了HashMap的老传统,没什么好说的

public V get(Object key) {
     
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode():

首先会判断table是否为空,以及当前key对应的数组元素是否为null

不为null的话会检查第一个元素是否是要查找的元素,

如果第一个不是且有next元素的话,会遍历查找

treeNode按照tree的方式,链表按照链表的方式

final Node<K,V> getNode(int hash, Object key) {
     
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // table不为null且table的长度不为0且key对应的数组元素不为null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
     
        // 检查第一个元素是否是要找的元素
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 第一个不是且有next元素会遍历的找
        if ((e = first.next) != null) {
     
            // 是treeNode会按照treeNode方式获取
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 遍历链表
            do {
     
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

线程安全问题:jdk1.8采用尾插法,解决了1.7存在的问题

又出现了新的问题:

1、多线程并发插入数据时,resize调用split()拆分树,接着会调用untreeify()将当前treeNode转为listNode,故原有的treeNode会被gc,但是并没有,会出现OOM

2、put时会覆盖掉一些值,导致数据丢失

3、多线程情况下,size()值也不准确

总结:1.8结构、思想、实现比1.7 6,代码写的是真臭,比如说下面这段代码

if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
     }

您不能写成

tab = table;
n = tab.length;
first = tab[(n - 1) & hash];

if (tab != null && n > 0 && first != null) {
     }

这样吗?

反正1.8的代码不憋着火你是没法看进去的

其他总结就不总结了,你们来总结,写的不好的地方欢迎评论区交流哦,谢谢大家

你可能感兴趣的:(hashmap,java,数据结构)