HashMap源码分析

一、HashMap源码分析(基于jdk1.7)

1. 数据结构

在jdk1.7中HashMap的基本数据结构是数组+链表的形式。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mOGbrjcD-1623201380827)(HashMap_yuan.assets/image-20210609085827136.png)]

在HashMap中有一个内部类Entry,每添加一个新的<key, value>就将它们封装到一个Entry对象中。每一个Entry包含一个key-value键值对、一个hash值和一个指向下一个Entry的next指针。

static class Entry<K, V> implements Map.Entry<K, V> {
    final K key;
    V value;
    Entry<K, V> next;//存储指向下一个Entry的引用,单链表结构。
    int hash;

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

2. 元素属性

// 默认数组容量,采用位运算提高速度,把16转化为二进制码相比于1 << 4要慢
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 数组的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 空数组,在数组进行初始化时会使用到
static final Entry<?,?>[] EMPTY_TABLE = {};
// 此table数组用来存储Entry对象,是HashMap保存数据最关键的一步
// transient关键字的作用是:不参与对象的序列化
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
// 表中已存在的<key, value>个数(数组+链表)
transient int size;
// 数组扩容的临界值:threshold = loadFactor * capacity
int threshold;
// 加载因子
final float loadFactor;
// 容器的修改次数:当增加新数据(不代表修改value),删除数据,或者清空使modCount++
// 补充部分具体讲解
transient int modCount;
// 映射容量的默认阈值,高于默认值时,散列度会降低,需要选择新的hash表
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

3. 构造方法

HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值。initialCapacity默认为16,loadFactory默认为0.75。

 //计算Hash值时的key  
    transient int hashSeed = 0;  

    //1、通过初始容量和状态因子构造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();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
    }  

    //2、通过扩容因子构造HashMap,容量去默认值,即16  
    public HashMap(int initialCapacity) {  
        this(initialCapacity, DEFAULT_LOAD_FACTOR);  
    }  

    //3、装载因子取0.75,容量取16,构造HashMap  
    public HashMap() {  
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);  
    }  

    //4、通过其他Map来初始化HashMap,容量通过其他Map的size来计算,装载因子取0.75  
    public HashMap(Map<? extends K, ? extends V> m) {  
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);  
        inflateTable(threshold);//初始化HashMap底层的数组结构  
        putAllForCreate(m);//添加m中的元素  
    }  

4. 核心方法

1、put(K key, V value)
    public V put(K key, V value)
(分析1// 1. 若 哈希表未初始化(即 table为空) 
        // 则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table  
        if (table == EMPTY_TABLE) { 
        inflateTable(threshold); 
    }  
        // 2. 判断key是否为空值null
(分析2// 2.1 若key == null,则将该键-值 存放到数组table 中的第1个位置,即table [0]
        // (本质:key = Null时,hash值 = 0,故存放到table[0]中)
        // 该位置永远只有1个value,新传进来的value会覆盖旧的value
        if (key == null)
            return putForNullKey(value);

(分析3// 2.2 若 key ≠ null,则计算存放数组 table 中的位置(下标、索引)
        // a. 根据键值key计算hash值
        int hash = hash(key);
        // b. 根据hash值 最终获得 key对应存放的数组Table中位置
        int i = indexFor(hash, table.length);

        // 3. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
			// 3.1 若该key已存在(即 key-value已存在 ),则用 新value 替换 旧value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue; //并返回旧的value
            }
        }
				// 修改次数加一
        modCount++;

(分析4// 3.2 若 该key不存在,则将“key-value”添加到table中
        addEntry(hash, key, value, i);
        return null;
    }

put(K key, V value)流程:

1、首先,若 哈希表未初始化(即 table为空) 。则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table 。

2、判断key == null?如果为空,则将该键值对 存放到数组table 中的第1个位置,即table [0]。

3、key不为null,通过 (tab.length - 1) &hash得到当前元素存放的索引位置**( **tab.length指的是数组的长度)。

4、如果当前位置存在元素,即发生哈希冲突,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束。

5、如果没有相同的key,新元素插入到链表头中。这里要考虑扩容。

深入分析
分析1:inflateTable(threshold)
private void inflateTable(int toSize) {
	// 取容量为大于等于toSize的2的指数次幂,原因在后面讲解
    int capacity = roundUpToPowerOf2(toSize);
	// 临界值最大只能取MAXIMUM_CAPACITY+1
	// 如果未指定capacity和loadFactor,那么threshold=12
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    // 计算hashSeed,不超过MAXIMUM_CAPACITY就会一直保持为0,映射了最后一个属性
    initHashSeedAsNeeded(capacity);
}

分析2:putForNullKey()
private V putForNullKey(V value) {
	// 虽然这里对链表进行了遍历,但是在if判断中是e.key == null
	// 可以得出结论,HashMap只能保存一个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++;
    addEntry(0, null, value, 0);
    return null;
}

分析3:indexFor(int h, int length)
static int indexFor(int h, int length) {
    return h & (length-1);
}

HashMap的容量控制在2的幂次方,这主要便于定位key在table中的位置。table.length - 1相当于一个低位的掩码,和哈希值取与,可以得到最终的index,因为这其中运用到了位运算,所有效率较高。

分析4:addEntry(int hash, K key, V value, int bucketIndex)

涉及到扩容resize

void addEntry(int hash, K key, V value, int bucketIndex) {
	// 如果<key, value>数量大于等于临界值 并且 当前数组下标不为空则进行数组扩容
	// 注意:这里的size是映射数量(包括链表),并不是数组中存储的有效值的数量
    if ((size >= threshold) && (null != table[bucketIndex])) {
    	// 因为原本的capacity是2的指数次幂,所以扩容将原capacity*2即可
        resize(2 * table.length);
        // 扩容后hash表可能产生了变化(超过最大值时),所以需要重新计算hash值
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
	 // 头插法
    createEntry(hash, key, value, bucketIndex);
}
  1. 插入前,先判断容量是否足够(容量>阈值)。如果不够,进行扩容resize()。

  2. 首先保存旧数组,旧容量(数组长度);

  3. 如果旧数组已经是系统默认最大容量,则将阈值设置成整型的最大值。退出。

  4. 根据新容量,新建一个数组。新table[]。

  5. 将旧数组上的数据(键值对)转移到新table中。完成扩容。

    转移到新table过程:transfer()。遍历原来table中每个位置的链表,并对每个元素进行找索引位置,在新的newTable找到归宿,头插法插入。

  6. 重新设置阈值。

  7. 如果容量足够,则创建一个新的数组元素(Entry)放入数组中。

从扩容条件之一的 size >= threshold 可以看出,临界值threshold关乎到是否需要扩容,又threshold = capacity * loadFactor,所以真正影响扩容的是加载因子loadFactor,默认的loadFactor = 0.75f,可能会有读者好奇,为什么要取这么一个值?默认情况下,size = 12时就应该扩容,那么容量为16的数组就会浪费4个存储空间(并不一定,理想情况下是如此),既然造成浪费,为什么不将加载因子设为1呢?原因是,数组查找的效率为O(1),若数组存储接近满状态才扩容,就极易提高哈希碰撞的概率,遍历链表会降低查找速度,而HashMap就因为其出色的查找性能而被广泛使用,所以就需要牺牲空间来换时间了。

2、resize() —多线程死循环问题

HashMap1.7中在多线程扩容时可能会出现死循环

void resize(int newCapacity) {  

// 1. 保存旧数组(old table) 
Entry[] oldTable = table;  

// 2. 保存旧容量(old capacity ),即数组长度
int oldCapacity = oldTable.length; 

// 3. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出    
if (oldCapacity == MAXIMUM_CAPACITY) {  
    threshold = Integer.MAX_VALUE;  
    return;  
}  
  
// 4. 根据新容量(2倍容量)新建1个数组,即新table  
Entry[] newTable = new Entry[newCapacity];  

// 5. 将旧数组上的数据(键值对)转移到新table中,从而完成扩容 ->>分析1.1 转移
transfer(newTable); 

// 6. 新数组table引用到HashMap的table属性上
table = newTable;  

// 7. 重新设置阈值  
threshold = (int)(newCapacity * loadFactor); 
}

transfer(Entry[] newTable, boolean rehash) 转移方法是出现死锁的元凶

//将老的表中的数据拷贝到新的结构中  
    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) {//如果是重新Hash,则需要重新计算hash值  。一般不用。重新hash是为了更加散列。jdk1.8没有rehash.
                    e.hash = null == e.key ? 0 : hash(e.key);  
                }  
                int i = indexFor(e.hash, newCapacity);//定位Hash桶  
                e.next = newTable[i];//元素连接到桶中,这里相当于单链表的插入,总是插入在最前面
                newTable[i] = e;//newTable[i]的值总是最新插入的值
                e = next;//继续下一个元素  
            }  
        }  
    }  

假设现在有A、B两个线程,两个线程都执行了Entry<K,V> next = e.next这条语句,则出现了下面这个画面,其中A.e -> A.next; B.e->B.next

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5eXsrlME-1623201380829)(HashMap_yuan.assets/image-20210609013522063.png)]

如果B线程刚执行完Entry<K,V> next = e.next后时间片段到了,则换A线程继续执行,直到transfer()方法执行完毕,会出现下面的情况(注意是前插法,所以顺序会颠倒)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aWHcv9hD-1623201380829)(HashMap_yuan.assets/image-20210609013547978.png)]

Entry2 -> Entry1,而B.e -> B.next,线程B会继续执行transfer()后面的方法,继续将B.e指向的Entry1前插到Entry2上,这样就已经形成了循环链表,线程B将永远处在transfer()方法中,形成死锁。

最后一步可以看出,jdk1.7中,HashMap中链表节点的增加使用的是前插法。

createEntry(int hash, K key, V value, int 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++;
}

至此,put()方法就已经基本讲解完毕了。小结:

  • put(key,value)
  • int hash = hash(key);
  • int index = hashcode & (length - 1)
  • 遍历index位置的链表,如果存在相同的key,则进行value覆盖,并且返回之前的value值
  • 当达到数组的临界值,需要对数组进行扩容
  • 将key,value封装为节点对象(Entry)
  • 将节点插在index位置上的链表的头部
  • 将链表头节点移动到数组上

3、get()

get()方法比较简单。

1、 当key == null时,则到 以哈希表数组中的第1个元素(即table[0])为头结点的链表去寻找对应 key == null的键

2、key != null,hash(key)求得key的hash值,然后调用indexFor(hash)求得hash值对应的table的索引位置 i。遍历索引位置的链表,如果存在key,则把key对应的Entry返回,否则返回null。

5. modcount作用:fast-fail

modcount++; 代表修改次数,每修改一次就会+1。

为什么要有modcount?

产生快速失败的机制(fast-fail)。出现ConcurrentModificationException异常。

在多线程中使用HashMap都会遇到这个异常,出现这个异常的场合一般都是遍历keySet、values、entrySet时对HashMap进行了添加、删除等操作。

这里以entrySet作为代表分析。如果HashMap结构未发生变更,每次遍历的顺序都是一致,但都不是插入的顺序。下面来看遍历的相关核心实现:

entrySet遍历Map元素与并发更新fail fast原理
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
    Node<K,V>[] tab;
    if (action == null)
        throw new NullPointerException();
    if (size > 0 && (tab = table) != null) {
        // 缓存modCount
        int mc = modCount;
        // 遍历tbale
        for (int i = 0; i < tab.length; ++i) {
            // 对每个桶根据next顺序进行遍历
            for (Node<K,V> e = tab[i]; e != null; e = e.next)
                action.accept(e);
        }
        // 如果遍历过程,Map被修改过,则抛ConcurrentModificationException
        if (modCount != mc)
            throw new ConcurrentModificationException();
    }
}

如果我们需要在遍历过程删除元素,需要使用Map的迭代器来进行遍历,否则在遍历过程检测到modCount发生变化,会抛出异常。

    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源码分析(基于jdk1.8)

1. 数据结构

在jdk1.8中HashMap的基本数据结构是数组+链表/红黑树的形式。
在这里插入图片描述

变化: JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)且当前数组的长度>= 64时,将链表转化为红黑树,以减少搜索时间。

引入原因:提高hashmap的性能。解决了发生hash冲突时,链表过长而导致索引效率慢的问题。是利用红黑树快速增删改查的特点;时间复杂度从O(N)到O(logN)。

  • HashMap中的数组元素 & 链表节点 采用 Node类 实现

  • HashMap中的红黑树节点 采用 TreeNode 类 实现

2. 元素属性

  • HashMap中的主要参数 同 JDK 1.7 ,即:容量、加载因子、扩容阈值。
  • 但由于数据结构中引入了 红黑树,故加入了 与红黑树相关的参数。具体介绍如下:
 /** 
   * 主要参数 同  JDK 1.7 
   * 即:容量、加载因子、扩容阈值(要求、范围均相同)
   */

  // 1. 容量(capacity): 必须是2的幂 & <最大容量(2的30次方)
  static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十进制的2^4=16
  static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 =  2的30次方(若传入的容量过大,将被最大值替换)

  // 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度 
  final float loadFactor; // 实际加载因子
  static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认加载因子 = 0.75

  // 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表(即扩充HashMap的容量) 
  // a. 扩容 = 对哈希表进行resize操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数
  // b. 扩容阈值 = 容量 x 加载因子
  int threshold;

  // 4. 其他
  transient Node<K,V>[] table;  // 存储数据的Node类型 数组,长度 = 2的幂;数组的每个元素 = 1个单链表
  transient int size;// HashMap的大小,即 HashMap中存储的键值对的数量
 

  /** 
   * 与红黑树相关的参数
   */
   // 1. 桶的树化阈值:即 链表转成红黑树的阈值,在存储数据时,当链表长度 > 该值时,则将链表转换成红黑树
   static final int TREEIFY_THRESHOLD = 8; 
   // 2. 桶的链表还原阈值:即 红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
   static final int UNTREEIFY_THRESHOLD = 6;
   // 3. 最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)
   // 否则,若桶内元素太多时,则直接扩容,而不是树形化
   // 为了避免进行扩容、树形化选择的冲突,这个值不能小于64
   static final int MIN_TREEIFY_CAPACITY = 64;
  

3、核心方法

1、put

此处有2个主要讲解点:

  • 计算完存储位置后,具体该如何 存放数据 到哈希表中。
  • 具体如何扩容,即 扩容机制

需进行多次数据结构的判断:数组、红黑树、链表

1、判断hash表是否初始化。未初始化,resize进行初始化。

2、根据key的hash定位key应在table数组的索引位i和首节点p。table[i]桶为空,直接新建节点,插入。

3、桶不为空。

3.1、判断 table[i]的元素的key是否与 需插入的key一样,若相同则 直接用新value 覆盖 旧value。

3.2、继续判断。需插入的数据结构是否为红黑树 or 链表。优先判断是否红黑树。

3.2.1若是红黑树。在树中插入节点 或者更新节点。

3.2.2若是链表。在链表中插入节点 或者更新节点。

i:遍历table[i]。判断key是否已经存在。若已存在,则新value覆盖旧的;

ii:若到尾部了也没找到相同key。即key目前不存在。链表尾部插入数据。

​ 插入节点后,若链表节点>=树阈值。链表转换为红黑树。树化。跳出循环。

3.3插入成功后,判断实际存在的键值对数量>最大容量(size > threshold)。

大于则扩容。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
// tab是table的本地副本,p是根据hash计算的key在table中的所属桶的首节点
// n是table长度,i是key在table的索引位置
Node<K,V>[] tab; Node<K,V> p; int n, i;
  
// table未初始化或者长度为0,通过resize进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中
  
if ((p = tab[i = (n - 1) & hash]) == null)
    // 桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    tab[i] = newNode(hash, key, value, null);
  
else {
    // 桶中已经存在元素
    Node<K,V> e; K k;
    // 比较桶中第一个元素(数组中的结点)和传入key的hash值、引用地址或equals方法是否相等
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
            // 将第一个元素赋值给e,用e来记录
            e = p;
  
    // hash值不相等,即key不相等;
    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;
            }
            // 判断链表中结点的key值与插入的元素的key值是否相等
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                // 相等,跳出循环
                break;
            // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
            p = e;
        }
    }
    // 表示在桶中找到key值、hash值与插入元素相等的结点
    if (e != null) { 
        // 记录e的value
        V oldValue = e.value;
        // onlyIfAbsent为false或者旧值为null
        if (!onlyIfAbsent || oldValue == null)
            //用新值替换旧值
            e.value = value;
        // 访问后回调
        afterNodeAccess(e);
        // 返回旧值
        return oldValue;
    }
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
    resize();
// 插入后回调(默认实现为空)。
afterNodeInsertion(evict);
return null;
}

2、treeifyBin()树化

在扩容的过程中,满足树化的要求

1.链表长度大于等于 TREEIFY_THRESHOLD 8

2.桶数组容量大于等于 MIN_TREEIFY_CAPACITY 64
  • 考虑树化,加入第二个条件的原因在于:

1.当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。

2.容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。

  • 这里值得讨论的是树化后,如何决定每个key-value的存储顺序,源码中的实现如下:

1.比较键与键之间 hash 的大小,设置比较值dir。更小dir =-1,大dir=1;如果 hash 相同,继续往下比较。

2.检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较

3.如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder,实现是先比较类名,再比较System.identityHashCode:

dir<=0,放左子树; >0,放右子树。

  • 在源码中,链表的pre/next顺序是保留的,这也是TreeNode继承自Node类的必要性,也方便后续红黑树转化回链表结构。
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
  //如果当前数组为空,或者当前数组容量小于最小树化容量,直接扩容不进行树化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //64
        resize();
   //当前找的链表第一个节点e不为空
    else if ((e = tab[index = (n - 1) & hash]) != null) {						
     
      
     //TreeNode树节点。hd红黑树链表头,红黑树链表尾(
        TreeNode<K,V> hd = null, tl = null;
        do { //遍历链表里每个元素
            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放在数组上,且hd存在,将链表进行红黑树化
            hd.treeify(tab);
    }
}
2.1 treeify() 树节点生成红黑树
 final void treeify(Node<K, V>[] tab) {
        TreeNode<K, V> root = null;
        for (TreeNode<K, V> x = this, next; x != null; x = next) {
            //根据链表进行遍历
            next = (TreeNode<K, V>) x.next;
            x.left = x.right = null;
            if (root == null) {//设置根结点
                //如果根节点还没设置则当前节点设置为根节点root
                x.parent = null;
                //根节点一定是黑色的
                x.red = false;
                root = x;
            } else { //往根结点下面插入
                //获取当前循环节点的key和哈希值
                K k = x.key;
                int h = x.hash;
                Class<?> kc = null; //k的class类型。
                //每次都从根节点开始循环
                TreeNode<K, V> p = root;
                for (; ; ) {  //遍历当前红黑树
                    int dir;
                    //获得p的hash值和key
                    int ph = p.hash;
                    K pk = p.key;  //红黑树里面已经有的根结点
                    //1、比较hash值,然后根据比较值dir决定插入左边还是右边
                    if (ph > h) {//哈希值更小,往左走
                        dir = -1;  
                    } else if (ph < h) {//往右走
                        dir = 1;
//2、哈希值相等则:检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较
                    } else if ((kc == null && (kc = comparableClassFor(k)) == null) ||
                            (dir = compareComparables(kc, k, pk)) == 0) {

//3.如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder,实现是先比较类名,再比较System.identityHashCode:
                        dir = tieBreakOrder(k, pk);
                    }
                    TreeNode<K, V> xp = p;
                    p = (dir <= 0) ? p.left : p.right;
//仅当当前要插入的位置上没有节点时, 才进行插入。
                    if (p == null) {
                        //设置父节点
                        x.parent = xp;
                        //根据dir值设置为父节点的左右子节点
                        if (dir <= 0) {
                            xp.left = x;
                        } else {
                            xp.right = x;
                        }
                        //插入成功后平衡红黑树。新的根结点
                        root = balanceInsertion(root, x);
                        //跳出当前循环
                        break;
                    }
                }
            }
        }
        //确保当前的新的根结点root是直接落在table数组上的
        moveRootToFront(tab, root);
    }

3 、resize()扩容

在第一次触发初始化HashMap或扩容时都会触发resize函数调整。在进行扩容前,会根据不同触发条件,计算扩容后的阈值和容量,初始化新table,而后将旧table的元素迁移到新table中。整个扩容的核心算法在元素迁移部分。

迁移算法原理

迁移算法是:

1.遍历table,过滤出不为null的桶首节点。

2.如果没有后续节点,直接计算新的索引位置存放在新的table中

3.否则如果是树节点,则拆分树节点再进行重新映射:

4.最后是普通链表节点,则先分组再映射,具体实现是根据(e.hash & oldCap)是否为0分成两条链表:高位链表和低位链表。

final Node<K,V>[] resize() {
   // 当前table保存
   Node<K,V>[] oldTab = table;
   // 保存table大小
   int oldCap = (oldTab == null) ? 0 : oldTab.length;
   // 保存当前阈值 
   int oldThr = threshold;
   // 阈值=容量*loadFactor
   int newCap, newThr = 0;
   // 如果旧容量大于0,在没有超过最大容量,会对旧容量oldCab和阈 值oldThr进行翻倍,存储在newCap和newThr中
   if (oldCap > 0) {
      // 如果超过最大容量,规整为最大容量
      if (oldCap >= MAXIMUM_CAPACITY) {
          threshold = Integer.MAX_VALUE;
          return oldTab;
      }
      // 容量翻倍,使用左移,效率更高
      else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
          oldCap >= DEFAULT_INITIAL_CAPACITY)
          // 阈值翻倍,如果能进来证明此map是扩容而不是初始化
          newThr = oldThr << 1; // double threshold
  }
  // 旧容量为0,阈值大于0
  else if (oldThr > 0)
      newCap = oldThr;
  // oldCap = 0并且oldThr = 0
  else {           
      // 创建map时用的无参构造进入此if:
      // 使用缺省值(如使用HashMap()构造函数,之后再插入一个元素会调用resize函数,会进入这一步)
      newCap = DEFAULT_INITIAL_CAPACITY;
      newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  }
  // 新阈值为0
  if (newThr == 0) {
      // 新阈值=新容量*负载因子
      float ft = (float)newCap * loadFactor;
      newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
  }
  // 重新赋值阈值
  threshold = newThr;
  @SuppressWarnings({"rawtypes","unchecked"})
  // 初始化table
  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  table = newTab;
  // 之前的table已经初始化过。
  if (oldTab != null) {
      // 复制元素,重新进行hash,遍历旧table
      for (int j = 0; j < oldCap; ++j) {
          Node<K,V> e;
          // 数组元素首节点不为空才继续操作
          if ((e = oldTab[j]) != null) {
              // 先设为空
              oldTab[j] = null;
              // 每个桶仅有首节点,没有后续节点
              if (e.next == null)
                  // 根据hash计算在新表的索引位置,存入新表中
                  newTab[e.hash & (newCap - 1)] = e;
              else if (e instanceof TreeNode)
                  // 有子节点,且为树,进行树迁移操作
                  ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
              else { //两个链表全部连起来之后,再一起放到新数组。
                  Node<K,V> loHead = null, loTail = null;
                  Node<K,V> hiHead = null, hiTail = null;
                  Node<K,V> next;
                  // 将同一桶中的元素根据(e.hash & oldCap)是否为0进行分割,分成两个不同的链表,完成rehash
                  // 为0放在lo链表,不为0放在hi链表
                  do {
                      next = e.next;
                      if ((e.hash & oldCap) == 0) {
                          if (loTail == null) // 尾为空,设置头部,第一次进入循环触发
                              loHead = e;
                          else // 接尾部
                              loTail.next = e;
                          loTail = e; // 更新尾部
                      }
                      else {//类似上面操作。索引与!=0
                          if (hiTail == null)
                              hiHead = e;
                          else
                              hiTail.next = e;
                          hiTail = e;
                      }
                  } while ((e = next) != null);
                  if (loTail != null) { // 存放在新表相同索引位
                      loTail.next = null;
                      newTab[j] = loHead;
                  }
                  if (hiTail != null) { // 计算新索引位存放在新表
                      hiTail.next = null;
                      newTab[j + oldCap] = hiHead;
                  }
              }
          }
      }
  }
  return newTab;
}  

从代码中我们可以看到,对于每个桶:

如果是链表节点,在经过扩容后,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中,但新表中两个桶的节点顺序并未发生改变。 如果是树节点,会对树进行拆分,再重新映射到新table中,具体实现在下一节分析。 每次进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的,但经过一次扩容处理后,元素会更加均匀的分布在各个桶中,会提升访问效率。

4、split() 迁移

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) {
        // 如果 loHead 不为空,且链表长度小于等于 6,则将红黑树转成链表
        if (lc <= UNTREEIFY_THRESHOLD)//<=6 。红黑树转为链表
            tab[index] = loHead.untreeify(map);
        else {//不用转红黑树
            tab[index] = loHead;
            /* 
             * hiHead == null 时,表明扩容后,
             * 所有节点仍在原位置,树结构不变,无需重新树化
             */
            if (hiHead != null) //高位也分到了节点。
                loHead.treeify(tab); //低位要重新树化。
         // 否则,什么也不做,是直接拿过去的,不用树化。
        }
    }
    // 与上面类似
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

从源码上可以看得出,重新映射红黑树的逻辑和重新映射链表的逻辑基本一致。

不同的地方在于,重新映射后,会将红黑树拆分成两条由 TreeNode 组成的链表。如果链表长度小于 UNTREEIFY_THRESHOLD,则将链表转换成普通链表。否则根据条件重新将 TreeNode 链表树化。

4、数据丢失(与内存可见性有关)或size不准确

在jdk1.8之前,resize操作时,当两个线程同时触发resize操作,基于头插法可能会导致链表节点的循环引用,在下次调用get操作查找一个不存在的key时,会在循环链表中出现死循环。

在jdk1.8中,发现找不到transfer函数,因为jdk1.8直接在resize函数中完成了数据迁移。另外说一句,jdk1.8在进行元素插入时使用的是尾插法。从上述分析可以看到,在多线程操作的情况下,无非是第二个线程重复第一个线程一模一样的操作,因而不再有多线程put导致死循环。但是依然有其他的弊端,比如数据丢失(与内存可见性有关)或size不准确。因此多线程情况下还是建议使用ConcurrentHashMap。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值