2019年一文读懂HashMap

4 篇文章 0 订阅
2 篇文章 0 订阅

HashMap

一、目录


二、简介

HashMap 底层的数据结构是 “散列链表”,可以称为 Hash表。

我们存储对象【键值对形式】通过 put (key, value)方法,获取对象通过 get (key)方式。

在调用 put 的时候会先根据 key 计算出对应的 hashCode值,再开始存储。

哈希表可以说就是数组链表,底层还是数组但是这个数组每一项就是一个链表。

三、内部成员变量以及特点

2.1 初始化 Map 的大小,未指定时默认是16
// 默认初始容量:2^4 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 容量最大值:2^30 = 1073741824
static final int MAXIMUM_CAPACITY = 1 << 30;

2.2 数据都存储在数组中
// jdk1.7
transient Node<K,V>[] table;

// jdk1.8
transient Node<K,V>[] table;
transient int size;

HashMap 存储的是 key-value 对象,先通过 key 计算出在 table 数组中的下标位置,
再将 key-value 当做一个Node节点对象存储到 table 中。

当 key 计算出的下标位置相同时,会根据 “桶排序算法” 将数据串行在同一个下标中:

  1. 在 jdk1.7 时,每个桶由单链表构成,数据插入采用头插法。

    原因:单链表纵向延伸,采用头插法可以提高插入效率,但容易出现逆序且环形链表死循环问题。

  2. 在 jdk1.8 时,每个桶由单链表 / 红黑树构成,数据采用尾插法。

    (1) 当链表的深度达到8,table 的大小 < 64 时候,会扩容一次;

    (2) 当链表的深度达到8,table 的大小 ≥ 64 时候,会自动 “树化” 将单链表转成红黑树,红黑树提升操作效率,时间复杂度从 O(N) 降到 O(logN),同时能够避免出现逆序且链表死循环的问题。

2.3 容量系数 threshold ,当 HashMap 的 size 大于容量系数时会执行扩容操作
int threshold;

// jdk1.7 初始值
threshold = initialCapacity;
private static int roundUpToPowerOf2(int number) {
    return number >= MAXIMUM_CAPACITY
            ? MAXIMUM_CAPACITY
            : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

// jdk1.8 初始值
this.threshold = tableSizeFor(initialCapacity);
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

2.4 负载因子 loadFactor 【默认0.75】,存储的数据超过 初始容量 * 负载因子 时触发扩容 两倍 容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;
final float loadFactor;

初始容量和负载因子可被设定:

// jdk1.7
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    init();
}
// jdk1.8
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
	...
	if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
    this.loadFactor = loadFactor;
    threshold = initialCapacity; // jdk1.7
    this.threshold = tableSizeFor(initialCapacity); // jdk1.8
}

因为容量和负载因子决定了可用桶的数量:

  1. 空桶太多会浪费空间,使用的太满又影响操作的性能;
  2. 极端情况下,假设只有一个桶被使用,那么它退化成了链表,完全不能提供所谓常数时间的存储性能。

设定负载因子的建议:

  1. 如果没有特别需求,不要轻易修改。因为 JDK 自身默认的负载因子是非常符合通用场景需求的;
  2. 负载因子越大,会增加 Hash冲突,降低 HashMap 的性能;
  3. 负载因子越小,会减小每次扩容的幅度,导致更加频繁的扩容,增加无谓的开销,同时本身访问性能也会收到影响。

2.5 封装了 Key、 Value、 Entry 的遍历工具
transient volatile Set<K>        keySet;
transient volatile Collection<V> values;
transient Set<Map.Entry<K,V>> entrySet;
  1. JDK 1.7
// 在 jdk1.7 时,Key 遍历工具
public Set<K> keySet() {
    Set<K> ks = keySet;
    return (ks != null ? ks : (keySet = new KeySet()));
}

private final class KeySet extends AbstractSet<K> {
    ...
    public Iterator<K> iterator() {
    	return newKeyIterator();
	}
}

private final class KeyIterator extends HashIterator<K> {
    public K next() {
        return nextEntry().getKey();
    }
}

// 在 jdk1.7 时,Value 遍历工具
public Collection<V> values() {
    Collection<V> vs = values;
    return (vs != null ? vs : (values = new Values()));
}

private final class Values extends AbstractCollection<V> {
    ...
    public Iterator<V> iterator() {
    	return newValueIterator();
	}
}

private final class ValueIterator extends HashIterator<V> {
    public V next() {
        return nextEntry().value;
    }
}

// 在 jdk1.7 时,Entry 遍历工具
private Set<Map.Entry<K,V>> entrySet0() {
    Set<Map.Entry<K,V>> es = entrySet;
    return es != null ? es : (entrySet = new EntrySet());
}

private final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
    ...
    public Iterator<Map.Entry<K,V>> iterator() {
    	return newEntryIterator();
	}
}

private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
    public Map.Entry<K,V> next() {
        return nextEntry();
    }
}
  1. JDK 1.8
// 在 jdk1.8 时,Key 遍历工具
public Set<K> keySet() {
    Set<K> ks;
    return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
}

final class KeySet extends AbstractSet<K> {
    ...
    public final Iterator<K> iterator() {
        return new KeyIterator();
    }
}

final class KeyIterator extends HashIterator
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
}

// 在 jdk1.8 时,Value 遍历工具
public Collection<V> values() {
    Collection<V> vs = values;
    return (vs != null ? vs : (values = new Values()));
}

private final class Values extends AbstractCollection<V> {
	...
     public Iterator<V> iterator() {
         return newValueIterator();
     }
}

private final class ValueIterator extends HashIterator<V> {
    public V next() {
        return nextEntry().value;
    }
}

// 在 jdk1.8 时,Entry 遍历工具
public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
	...
	public final Iterator<Map.Entry<K,V>> iterator() {
    	return new EntryIterator();
	}
}

final class EntryIterator extends HashIterator
    implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return nextNode(); }
}

通过上述代码可以看出:遍历 HashMap 时候,会生成一个迭代器对象,当我们在增强 for 循环时会调用迭代器对象的 next 方法,它最后都通过 HashIterator 类去遍历所有数据,将可能出现的 “Hash冲突” 的同一个索引位置的多条数据取出遍历。

// jdk1.7
final Entry<K,V> nextEntry() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    Entry<K,V> e = next;
    if (e == null)
        throw new NoSuchElementException();
    if ((next = e.next) == null) {
        Entry[] t = table;
        while (index < t.length && (next = t[index++]) == null)
            ;
    }
    current = e;
    return e;
}

// jdk1.8
final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    if ((next = (current = e).next) == null && (t = table) != null) {
        do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
}

HashIterator 迭代类默认会从 table 数组的 0号 索引位开始遍历,如果索引对应的节点存在则会尝试遍历其 “桶” 中的下一位置,依次最后遍历完成。

四、常见问题

3.1 计算 hashCode 是一种算法,但哈希码并不完全唯一
// jdk1.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);
}

// 取模运算
static int indexFor(int h, int length) {
    return h & (length-1);
}

注意

  1. 在 jdk1.7中有 Hash种子(hashSeed),可以把它理解为一个开关,当开关打开时候,并且 key 为String类型时会有特定的优化计算。它的默认值是 0,当 HashMap 的容量大小发生变化时,会重新计算。
  2. 在 jdk1.8中,hashSeed 已经被移除,原因是:sun.misc.Hashing.randomHashSeed 计算 hashSeed 时会调用方法 java.util.Random.nextInt(),该方法使用 AtomicLong,在多线程情况下会有性能问题。
// jdk1.8
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 取模运算
// (n-1)&hash实际上相当于hash % n取余数,但&计算速度更快。
if ((p = tab[i = (n - 1) & hash]) == null) 

Hash 算法的特点是:对于相同的参数那么返回的 HashCode 肯定是相同的,对于不相同的参数,函数会尽可能的去返回不相同的 HashCode 。本质分为三步:获取 key 的 hashCode 值位运算取模运算

  1. 获取泛型 key 的 hashCode 值,故引用类型需重新 hashCode 方法;

  2. 位运算:

    • 为什么将数据右移运算?

      因为 hashCode 方法得到的是 32位的 int值,按照使用的正态分布,HashMap一般不需要这么大量的数据,其更关心的是低位数据尽可能不相同。

    • 为什么用 ^ (异或),而不用 & (与)和 |(或)?

      因为按照上述情况,如果用与运算会使数据每一位倾向于1;如果用或运算会使数据每一位倾向于0,就不能保留数据的独特性,但异或运算可以做到。

    • 为什么需要多次右移运算并且异或运算?

      引入了扰动函数的概念,这样运算后尽可能的保留了原数据的数据特征,又能有效降低不同 key 得到相同hash 值的概率。

    • 为什么 jdk1.8 的实现改了?

      因为当数组长度较小时,也可以保证高低位都可以参与到 Hash 的计算中,降低不同 key 得到相同hash 值的概率,同时也降低了计算开销。

  3. 取模运算:

    • 为了实现 key 的 hash值即为数据的下标,但是 hash值一般比数据要大很多,所以用取模运算。

    • 考虑到性能,位运算比直接取余%,效率更优。

    • 二进制的位运算下,2的幂数的高位为1,其余为0。对其减一时会生成高位为0,其余为1的二进制数据。

    • 对上一步生成的数据 & hash数据,会截取保留 hash数据 在上一步生成的数据长度和位置下的数据。

      而 hash数据中高位的数据,一定是当前2的幂数下的倍数。

      例如:hash值为85(0101 0101),当前2的幂数的值为16(0000 1111)

    0001 000016->   0000 1111150101 010185& 0000 111115-----------------
      0000 010150101 000080-- 原hash数据中高位的数据,一定是 16【当前2的幂数】 的倍数
    

    补充:正因为需要使用与运算优化性能,而与运算的场景必须让数组的长度满足2的幂数,所以HashMap的长度(扩容和缩小)要求必须是2的幂次方。

3.2 插入数据操作
// jdk1.7
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;
}

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    createEntry(hash, key, value, bucketIndex);
}

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++;
}
// jdk1.8
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            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;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
  1. 优先判断数组是否为空,初始化数组(不在构造器初始化,懒加载策略)

  2. 查找 key 对应在数组哪个桶,和桶中哪个位置,会出现以下情况:

    • key 的值为 null 时:计算出唯一下标 0,即允许存入时 key为空,但多次存入会覆盖(默认)掉之前的 value。

    • 当产生 "Hash冲突" 时:

      (a) 存入的两组数据的 key 计算出的下标相等(如 key 均为 1)

      (b) 由 (n - 1) & hash 计算出的桶位置相同(如数组大小默认16, key 为1和 “1”)

      会对两个 key 进行 "==" 和 "equals" 判断(如果是引用类型需要重写 equal 方法),如果判断不相等,会采用 "桶排序算法" 存入两组数据。因为不对 value 做判断,所以允许 value 为空

      基本数据类型的存储原理:

      所有的简单数据类型不存在“引用”的概念,基本数据类型都是直接存储在内存中的内存栈上的,数据本身的值就是存储在栈空间里面,Java语言里面八种数据类型是这种存储模型;

      引用类型的存储原理:

      引用类型继承于Object类(也是引用类型)都是按照Java里面存储对象的内存模型来进行数据存储的,使用Java内存堆和内存栈来进行这种类型的数据存储,简单地讲,“引用”(存储对象在内存堆上的地址)是存储在有序的内存栈上的,而对象本身的值存储在内存堆上的;

      包装类型:

      拆箱 - 将引用类型转换成基本类型; 装箱 - 将基本类型包装成引用类型。

      默认实现了 equal 方法,方法内使用数据值判断。

      所以,hashCode 是用于查找使用的,而 equals 是用于比较两个对象的是否相等的。

  3. 校验查找的位置是否存在数据,执行存入数据操作

    • 如果该位置已存有数据,默认覆盖 value。
    • 如果没有数据,在 jdk1.7 时,数据插入采用头插法,容易出现逆序且环形链表死循环问题【引发原因:插入+扩容重组逆序】。
    • 如果没有数据,在 jdk1.8 时,数据插入采用尾插法。为优化效率,当数据量超过一定阀值会将单链表树化成红黑树。
  4. 判断执行扩容操作

3.3 扩容机制

当执行 put 时,如果发现目前的桶占用程度已经超过负载因子所希望的比例,那么就会发生扩容。扩容的过程简单说就是将数组扩大2倍(不超过最大限制),遍历所有的桶以及桶中的所有节点,重新计算每个节点在新的数组中的下标,把节点再放入新的数组中(拷贝引用)。

  • jdk1.7 保持头插法,jdk1.8 保持尾插法,同一个桶中的数据顺序在新桶中可能会发生改变;

  • 因为使用的是2次幂的扩展(每次扩展会是原来的2倍),所以原节点的新位置:

    (1) 要么是原位置:

    hash: 			         1100 1001 0000 1111 1010 0000 1100 0110
    oldCap - 1:	 	 	 0000 0000 0000 0000 0000 0000 0000 1111
    hash & (oldCap - 1):	 0000 0000 0000 0000 0000 0000 0000 0110 // 0110
    
    hash: 			         1100 1001 0000 1111 1010 0000 1100 0110
    newCap - 1:	 	 	 0000 0000 0000 0000 0000 0000 0001 1111
    hash & (oldCap - 1):	 0000 0000 0000 0000 0000 0000 0000 0110 // 0110
    

    (2) 要么是原位置再移动2次幂的位置(原位置 + oldCapacity):

    hash: 			         1100 1001 0000 1111 1010 0000 1111 0110
    oldCap - 1:	 	 	 0000 0000 0000 0000 0000 0000 0000 1111
    hash & (oldCap - 1):	 0000 0000 0000 0000 0000 0000 0000 0110 // 00110
    
    hash: 			         1100 1001 0000 1111 1010 0000 1111 0110
    newCap - 1:	 	 	 0000 0000 0000 0000 0000 0000 0001 1111
    hash & (oldCap - 1):	 0000 0000 0000 0000 0000 0000 0001 0110 // 10110
    

    因为HashCode 的高位出现 0 和 1 的概率均等,所以从概率上说扩容的过程又将同一桶下的节点平均分配到两个桶中,但具体拆分后均分与否仍由存入的 key 值计算出的 hashCode 决定

  • 在 jdk1.8 中直接用原数据中 key 的 HashCode 值计算在新数组中的下标,与 jdk1.7 相比节省一部分重新计算 hash 的时间。

    // jdk1.7
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) { // 有几率需要重新计算key的Hash值,在jdk1.8中去掉
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
    

3.4 树化与回退操作

为了避免 Hash冲突 带来的链表过长,操作效率降低的问题,jdk1.8 中数据达到阀值时会过长的桶中链表进行红黑树改造。当链表中数据减少到一阀值时会将红黑树回退到链表。

3.4.1 红黑树的特性:
  1. 节点是红色或者黑色;
  2. 根是黑色;
  3. 所有叶子节点(NIL)是黑色;
  4. 每个红色节点必须有两个黑色子节点(从每个叶子到根的所有路径上不能有2个连续的红色节点);
  5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点(黑高)。
3.4.2 红黑树的应用场景

主要用于存储有序的数据,时间复杂度为O(logN)。

3.4.3 代码理解
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;

// 树化改造判断1:结点数量达到阈值,转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
    treeifyBin(tab, hash);

/**
 * 将普通节点链表转换成树形节点链表
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 树化改造判断2:桶数组容量小于 MIN_TREEIFY_CAPACITY,优先进行扩容而不是树化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd 为头节点(head),tl 为尾节点(tail)
        TreeNode<K,V> hd = null, tl = null;
        do {
            // 将普通节点替换成树形节点,简单地拷贝值并初始化赋值到一个TreeNode对象
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null) // 第一次循环会进入这个分支,赋值头节点
                hd = p;
            else { // 双向链表赋值
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null); // 将普通链表转成由树形节点链表
        if ((tab[index] = hd) != null) // 赋值新的头节点
            // 将树形链表转换成红黑树
            hd.treeify(tab);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next); // 保留了链表数据的next引用
}
  • 当前桶中链表长度 ≥ 8,尝试开始树化(treeifyBin)

  • 第二步校验当前桶数组的长度 ≥ 64,真正开始树化,否则只是扩容(resize)

    分析:

    当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。

    容量小时,优先扩容可以避免一些列的不必要的树化过程。

  • 在树化后,链表的 pre/next 顺序是保留的,这也是 TreeNode 继承自Node类的必要性,也方便后续红黑树转化回链表结构。

static final int UNTREEIFY_THRESHOLD = 6;

// 分割树形
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
	TreeNode<K,V> b = this;
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    // 红黑树节点仍然保留了 next 引用,故仍可以按链表方式遍历红黑树。
    // 循环是对红黑树节点进行分组
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        // bit是旧table容量,
        // 根据是否(e.hash & bit) == 0切分到两个桶,
        // 切分同时对链表长度进行计数
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
    
	if (loHead != null) {
        // 回退改造判断1:loHead不为空,且链表长度≤6,将红黑树转成链表
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            // hiHead == null 时,表明扩容后,
            // 所有节点仍在原位置,树结构不变,无需重新树化
            if (hiHead != null)
                loHead.treeify(tab);
        }
    }
    if (hiHead != null) {
        // 回退改造判断2:与上面类似
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    // 遍历 TreeNode 链表,并用 Node 替换
    for (Node<K,V> q = this; q != null; q = q.next) {
        // 将树形节点回退到普通节点
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}
  • 当前桶中链表长度 ≤ 6,开始将红黑树转成链表。

3.5 长度为什么是2的幂次方?
  1. 为了实现 key 的 hashCode 即为存储数组中的下标,但 hashCode 值一般比数据长度要大很多,所以需要进行取模运算(%);
  2. 取模运算时考虑到性能要求,位运算比直接取余%,效率要更优;
  3. 已知二进制的位运算下,2的幂数的高位为1,其余为0时,对其减一会生成高位为0,其余为1的二进制数;
  4. 对上一步生成的数据 & hash 数据,会截取保留 hash 数据在 上一步生成的数据长度和位置下的数据。而 hash 数据中高位的数据,一定是当前2的幂数下的倍数,在取模运算时被约去。故两者相 & 后的结果就是hash % 2的幂数。【当 b = 2^n 时,a % b = a & (b - 1) 】
3.5.1 为什么不用非2的n次幂?
  • 使用 &运算计算下标时,key 的 hashCode 中必定有部分位是不能参与到运算中,导致 Hash冲突概率增大;

    原因:非2的n次幂的计算结果为数组的长度,长度 - 1后的数据在二进制下必定有部分位等于0,在&计算时使得 hashCode 中的部分位的特征无法保留。【(0/1) & 0 = 0】

  • 浪费数组的容量。

    原因:当数组的容量为非2的n次幂时,无论 hashCode 如何变化,总有一些数组的下标无法被计算得到。因为&运算的结果一些位始终为0。

3.6 为什么是线程不安全的?
3.6.1 多线程进行数据插入时出现数据不一致

例子:有两个线程A和B,首先A希望插入一键值对到HashMap中,计算出数组的索引下标 index,然后获取到对应下标的桶的头节点,此时线程A的时间片用完了。开始执行线程B,和线程A一样,线程B用不同的 key 成功插入到数组的同一个索引下标 index的桶中。当线程B执行完成后,线程A再次被调度运行,但它依然持有过期的表头(对线程B的操作一无所知),以至于按照逻辑直接覆盖线程B刚插入的记录。这样线程B的插入数据就相当于被删除了,造成数据不一致。

3.6.2 多线程进行扩容时引发死循环(jdk1.7 头插法引发)

例子:有两个线程A和B,两个线程都准备对原有的旧HashMap(Entry对象:1->2)扩容。线程A扩容中,执行到遍历单向链表时,线程A的时间片用完了(持有1和2的引用)。开始执行线程B,线程B执行完了所有的扩容操作(2->1)。此时再切换回线程A时,它依然持有过期的表头以及下一引用,继续执行头插法时,由于倒序问题,会出现 1->2 和 2->1 (死循环)。接下来再想通过 get 方法获取元素时,会出现死循环。

3.7 JDK7 与 JDK8 的区别
  1. 引入了红黑树,在桶节点数 ≥ 8 并且桶数组的长度 ≥ 64时,使用红黑树,操作效率得到提升;
  2. 优化 hash 算法,同时去掉了引入 sun.misc.Hashing 包,提升计算效率;
  3. 桶中插入数据的方法,由头插法改为尾插法,解决了多线程下逆序且环形链表死循环问题;
  4. 优化扩容算法,减少扩容带来的rehash性能消耗。

3.8 HashMap 与 HashTable 的区别

HashTable 是遗留类,很多映射常用功能与 HashMap 相似,它继承于 Dictionary 类,是线程安全的,但并发不如 ConcurrentHashMap(jdk1.5 提供) ,因为 ConcurrentHashMap 引入分段锁。

  1. HashTable 是线程安全的,任一时间只能有一个线程能写 HashTable,HashMap不是;

  2. HashMap 可以接受 key 和 value 为 null,但 HashTable 不支持;

  3. HashMap 的迭代器 Iterator 是 fail-fast 迭代器,而 HashTable 的迭代器 Enumeration 是非 fail-fast 迭代器。

fail-fast 策略:

如果在使用迭代器的过程中有其他线程修改了map,那么将抛出 ConcurrentModificationException,但迭代器本身 remove 调用移除元素则不会触发异常。

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值