<返回更多

搞懂hashMap底层原理

2023-08-03  微信公众号  码农本农
加入收藏

说明

hashMap在JAVA1.7和java1.8版本中有做一些调整,我们本篇只说java1.7的hashMap。

数据结构

搞懂hashMap底层原理

hashMap的数据结构是由数组和链表组成,table是一个存放Entry对象的数组,每个Entry对象由4个属性组成,分别是key、value、next、hash,key和value是我们熟知的键值对,不需要过多解释,next是当前元素在链表中指向下一个元素的引用,hash是计算出来的hashcode,hashMap中的hsah是通过对key.hashcode()进行一定操作得出的,并不是直接使用key.hashcode()方法计算数来的值。

属性信息

先来了解下hashMap中一些重要的属性

//Hashmap的初始化大小,初始化的值为16,1往右移4位为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//HashMap是动态扩容的,就是容量大小不能大于 1<<30
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认的扩容因子,这个值可以通过构造修改
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//空数据,默认构造的时候赋值为空的Entry数组,在添加元素的时候
//会判断table=EMPTY_TABLE ,然后就扩容
static final Entry<?,?>[] EMPTY_TABLE = {};

//表示一个空的Hashmap
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

//Hashmap的大小
transient int size;

//threshold表示当HashMap的size大于threshold时会执行resize操作。
DEFAULT_INITIAL_CAPACITY=16

//扩容的阈值
int threshold;

//扩容因子,没有传入就使用默认的DEFAULT_LOAD_FACTOR = 0.75f
final float loadFactor;

//数据操作次数,用于迭代检查修改异常
transient int modCount;

static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

put方法

步骤:

  1. 判断table是否为空,如果为空则初始化,不为空则下一步
  2. 判断key是否为null,如果为null则处理,不为null则下一步
  3. 根据key计算下标
  4. 如果下标处的桶不为空,则从下标处开始遍历链表,如果找到key相同的则更新覆盖并返回旧值,如果为空则下一步
  5. 插入前判断是否需要扩容,如果需要则进行扩容,如果不需要就下一步
  6. 头插法插入桶中

接下来我们对每一步骤分别展开讨论

1.table初始化

private void inflateTable(int toSize) {
    // 找到大于等于指定数组长度的2的n次方
    int capacity = roundUpToPowerOf2(toSize);
 // 扩容阈值,数组长度*负载因子
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    // 创建数组
    table = new Entry[capacity];
    // 是否重新赋值hashSeed 
    initHashSeedAsNeeded(capacity);
}

private static int roundUpToPowerOf2(int number) {
    // 使用Integer的highestOneBit方法找到大于等于指定数组长度的2的n次方
    return number >= MAXIMUM_CAPACITY
            ? MAXIMUM_CAPACITY
            : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}


数组初始化其实就三个步骤:计算数组容量,创建数组,判断是否重hash

「数组容量」: 如果指定数组长度值大于MAXIMUM_CAPACITY(最大数组容量:2的30次方),就用最大值;如果指定数组长度值小于等于1,就用1;如果指定数组长度值大于1,就用下面的方法得到大于等于指定数组长度的第一个2的n次方的值。

Integer.highestOneBit((number - 1) << 1)

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

这个方法内部是通过位移和亦或操作得到的值,感兴趣的可以直接看下源码。

「创建数组」:直接用计算出来的数组长度创建Entry数组table,元素类型为Entry。

「判断是否重hash」

重hash就是对同个key重新计算hash值,那么为什么要重新计算hash值,实际上只是为了让hsah值更复杂,在计算下标的时候就会更加散列,减少hash冲突。

那么什么样的条件下才会重hash呢,从源码可以看出switching为true表示需要重hash,影响switching取值的是下面这段代码

final boolean initHashSeedAsNeeded(int capacity) {
 // hashSeed 初始值为0,false
    boolean currentAltHashing = hashSeed != 0;
    // 数组长度是否 >= 2^31-1 
    boolean useAltHashing = sun.misc.VM.isBooted() &&
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    // 使用异或,currentAltHashing 为false,只有数组长度>= 2^31-1时返回true
    boolean switching = currentAltHashing ^ useAltHashing;
    //switching为true,则hashSeed重新赋值(一般不会出现)
    if (switching) {
        hashSeed = useAltHashing
            ? sun.misc.Hashing.randomHashSeed(this)
            : 0;
    }
    return switching;
}

initHashSeedAsNeeded方法用来判断是否进行重hash,如果需要重hash,会同步更新hash种子,最后返回boolean类型的值。

sun.misc.VM.isBooted()指jvm运行状态,一般为true;

hashSeed初始为0,所以currentAltHashing一定为false;

Holder.ALTERNATIVE_HASHING_THRESHOLD取的是环境变量jdk.map.althashing.threshold配置的值(程序员配置),如果没有配置就默认取Integer.MAX_VALUE。

通过上面的分析可以知道是否进行重hash只会受到capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD的影响。

「我们可以得出一个结论:」如果程序员不配置环境变量jdk.map.althashing.threshold,那么就永远不会进行重hash,因为数组长度capacity最大是2的30次方,而Integer.MAX_VALUE的值是2的31次方减1,即这个条件永远不会满足,但是你可能会说扩容的时候传入的capacity正好是最大值2的30次方的2倍,但是我会告诉你,如果数组成都达到2的30次方,是不允许扩容的。

所以说如果程序员不设置环境变量,initHashSeedAsNeeded方法实际上没有意义的。

那为什么要更新hash种子呢?

这就要了解下hsah值是怎么生成的了:

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

hashMap的hsah值是由key调用自身的hashCode方法得到的值与hashSeed进行5次亦或操作4次位移运算得到的。

所以相同的key只有在hashSeed变化后才有生成不同的hash值,否则生成的永远是相同的hsah值,所以需要重hash的时候就必须改变hashSeed,否则重hash的结果会和原来一样。

2.处理key为null

private V putForNullKey(V value) {
 // 当key为null时,数组下标指定为0
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
     // 判断现在有没有key为null的Entry
        if (e.key == null) {
         //value替换为新值,返回旧的value
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    //修改次数+1
    modCount++;
    // table[0]中没有值或没有匹配到null的key,创建一个Entry放入table[0]
    addEntry(0, null, value, 0);
    return null;
}

上面这段代码是对key为null的情况的处理,同时也说明hashmap是允许key为null的,通过上面可以看出hashMap中key为null的元素只会存储在数组下标为0的桶中,如果桶中有多个,就遍历链表找到key为null的元素进行覆盖更新,如果桶中无元素,就调用addEntry方法插入元素,这里只需要知道调用addEntry方法的结果是将数据插入数组下标为0的桶中,addEntry方法我们下面再具体看。

3.计算下标

// 获取key的hash值
    int hash = hash(key);
    // 根据hash值和数组长度计算要放入的数组下标位置
    int i = indexFor(hash, table.length);

计算下标实际上分为两步,计算hsah值和计算下标,计算下标的原理是用hash值除以数组容量,得到的余数就是下标,用这种方式可以保证不同的key一定会放在数组中的某个桶中,不会越界,而hash值可以让不同的key在数组中的分部足够分散,减少hsah冲突。

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

上面已将说过hash方法,这里再来说一次,我们知道java中每个类都默认由hashcode方法生成hashcode,但是hashMap中并没有直接用此方法生成的hashcode,而是对其生成的hahshcode与hash种子进行亦或和位移操作,1.7的hashMap在计算hsah的时候进行了5次亦或操作和4次位移运算,这样做的目的就是使得不同的key计算出来的hash更加分散,更加能减少hash冲突。

static int indexFor(int h, int length) {
    // 计算数组下标位置,数组长度必须为2的n次方
    return h & (length-1);
}

我们上面说了hash除以数组容量得到数组下标,但是这种做法在java中太慢了,而和此做法同效的一种方式就是h & (length-1),就是数组容量减去1,再与hash做&操作。这种方式在java中是非常高效的。

4.遍历查找key

运行到这里,数组已经有了,key对应的下标也有了,接下来就是插入操作,插入之前会先查看下标对应的桶中是否为空桶,如果不为空桶就要先遍历查找是否有相同key。

for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 判断key的值是否相等
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
         // key的值相等则写入新的value值,将旧value返回
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    
   // 修改次数+1
    modCount++;
    // 下标位置为空或没有能够匹配的key值,创建Entry放入链表
    addEntry(hash, key, value, i);

因为hashMap是由数组和链表组成,数组的每个桶中都由链表组成,所以需要遍历链表查找相同的key,如果存在相同的key就更新覆盖,如果没有,就调用addEntry方法进行插入。

//addEntry方法
void addEntry(int hash, K key, V value, int bucketIndex) {
 // 如果当前数组长度>=扩容阈值 并且 当前数组下标位置不为null
    if ((size >= threshold) && (null != table[bucketIndex])) {
     // 数组扩容为当前长度*2
        resize(2 * table.length);
        // key是否为null,不为null计算hash值,null则直接hash值为0
        hash = (null != key) ? hash(key) : 0;
        // 根据hash值和新数组长度计算下标位置
        bucketIndex = indexFor(hash, table.length);
    }
 //创建Entry放入table中
    createEntry(hash, key, value, bucketIndex);
}

可以看到在正式添加元素之前会先判断是否需要扩容,如果需要则先进行扩容。

5.扩容处理

从上面的源码可以知道扩容需要满足两个条件:

如果满足条件则进入resize方法进行扩容

void resize(int newCapacity) {
 // 原数组
    Entry[] oldTable = table;
    // 原数组长度
    int oldCapacity = oldTable.length;
    // 如果原数组长度为2^30,不进行扩容,把扩容阈值修改为2^31-1
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
 // 根据新的数组长度,创建数组
    Entry[] newTable = new Entry[newCapacity];
    // 转移数组数据 
    // 调用initHashSeedAsNeeded方法,根据新数组的长度判断是否会hashSeed重新赋值
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // table指向新数组
    table = newTable;
    // 计算新的扩容阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

// transfer方法
void transfer(Entry[] newTable, boolean rehash) {
 // 新数组长度
    int newCapacity = newTable.length;
    // 遍历原数组的Entry
    for (Entry<K,V> e : table) {
     // Entry不为null
        while(null != e) {
         // 先找到链表中下一个要转移的Entry
            Entry<K,V> next = e.next;
            // 如果hashSeed变了,重新进行hash计算
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 重新计算数组下标,结果分两种:1.与原下标一直 2.原下标+本次扩容了多长
            int i = indexFor(e.hash, newCapacity);
            /*原数组统一链表中的数据转移到新数组中时,所处链表顺序颠倒,因此多线程的情况下可能会出现环形链表问题*/
            // Entry的next指向新数组的链表头
            e.next = newTable[i];
            // Entry放入新数组的链表中
            newTable[i] = e;
            // 下一次进行操作的就是原数组链表的下一个
            e = next;
        }
    }
}

扩容步骤:

  1. 首先传入的newCapacity是数组容量的2倍,也是2的n次方
  2. 如果数组的容量已经达到2的30次方,则不进行扩容,直接返回
  3. 用新的数组长度newCapacity创建Entry数组
  4. initHashSeedAsNeeded判断是否要重hash,并更新hash种子
  5. transfer方法进行扩容处理

「transfer方法进行具体的扩容处理:」

实际上就是遍历旧数组,从旧数组的第一个桶中拿到链表,从链表头部开始遍历,一个一个的取出计算新的下标(如果需要重hash就会用新的hsah种子计算hsah值,如果不需要重hash就用原来的hsah值,最终用hsah值和新数组容量计算下标),然后头插法插到新的数组中。

「你会发现两个规律:」

我们通过一个例子来分析一下第二条规律。

假设table.length=16,现在有两个key,key1对应的hash值为68,key2对应hash值为84,根据公式h & (length-1)计算,&运算规则是遇0为0,结果如下:

68 key1 0100 0100 & 0000 1111  =0000 0100  =4 84 key2 0101 0100 & 0000 1111  =0000 0100  =4

可见两个值都落在table[4]这个桶中。

扩容一次后table.length=32,再根据公式h & (length-1)计算结果如下

68 key1 0100 0100 & 0001 1111  =0000 0100  =4 84 key2 0101 0100 & 0001 1111  =0001 0100  =20

可见68依然被放在新数组的table[4],而84被放在table[20]

再扩容一次后table.length=64,再根据公式h & (length-1)计算结果如下

68 key1 0100 0100 & 0011 1111  =0000 0100  =4 84 key2 0101 0100 & 0011 1111  =0001 0100  =20

可见两个值还在原来的数组下标对应的桶中。

结论:同一个桶中的链表中的数据,扩容后,在新数组中的下标要么和原数组相同,要么是原数组下标加扩容的长度。

这个规律是怎么形成的呢?

这得益于数组的容量都为2的n次方,数组每次扩容的后结果都是原来数组容量的两倍,例如:16,32,64...,length-1的结果分别是15,31,63,而对应的二进制如下:

0000 1111

0001 1111

0011 1111

可以看的出,每次扩容都是高位加1,就导致在计算的时候只需要看hash值中与高位对应的那个位上是0还是1,也就导致新数组中的下标只有两种可能:如果是0下标不变,如果是1下标就会变化。

这个规律有什么好处呢?

这个规律可以使原来在同个桶中的数据可以分散到其他桶中,使得数组分部更加均匀,减少hash冲突,扩容的时候同个桶中的数据将会被分部到新数组中的哪个桶中,可以通过只判断高位就能确定,所以可以以此来优化扩容效率。

6.头插法

// createEntry方法
void createEntry(int hash, K key, V value, int bucketIndex) {
 // 获取当前数组下标位置的链表头
    Entry<K,V> e = table[bucketIndex];
    // 在链表头位置创建新的Entry,next指向原链表头(头插法)
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    // 数组长度+1
    size++;
}

头插法的源码就很简单了,就是新建一个Entry对象,新建Entry对象的next属性指向当前坐标下的头部Entry对象,再把新的Entry对象引用赋值给当前数组下标处。

这里的文字说明可能比自己看源码更绕。自己看源码应该一目了然。

问题汇总

为什么要计算得到大于等于指定数组长度的第一个2的n次方的值

只有在数组长度为2的n次方时,数组长度-1转换为2进制时才可以转换为低位全部为1的二进制,和hash值进行&运算时才能等效于hash值除以数组容量求余的结果。

为什么要用头插法?

实际上无论是头插法还是尾插法,都是需要遍历链表的,如果在遍历过程中找到key相同的,则更新覆盖,这种情况不会有插入操作,所以无所谓头插法和尾插法,但是如果没有找到key相同的元素,那这时肯定已经遍历到链表的尾部了,所以但凡插入,不管是头插法还是尾插法都不会在遍历链表上节省时间,又因为链表的插入仅仅是更换next属性的指针,所以两种插入方式的效率是没有区别的。

java1.7之所以使用头插法应该和自身的代码结构有关,因为插入方法是独立的,如果用尾插法,就要在遍历的时候记录最后一个元素的值,而头插法就不需要了,但是我觉得这个不是主要原因,个人觉得java开发者只不过是两者选择了一个而已,没有什么特殊考虑,否则也不至于会有循环链表的问题了。

hashMap为什么是线程不安全的

hashMap线程不安全主要表现在两个方面

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++;
}

上面是头插法的代码逻辑,在多线程操作下,如果两个线程同时走到方法内第一行,那么获取到的e是相同的,然后两个线程分别创建Entry对象,并且Entry对象的next属性赋值为e,这样一来总会有一个线程的数据被丢掉了。

循环链表发生在多线程扩容的情况下,下面是扩容的部分代码:

for (Entry<K,V> e : table) {
    while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
          e.hash = null == e.key ? 0 : hash(e.key);
        }
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
    }

这段代码逻辑是把旧数组中的数据从链表头部一个个利用头插法插入到新数组中。

假设有两个线程同时进行扩容,两条线程都执行到下面这行代码:

Entry<K,V> next = e.next;

此时第一条线程继续执行,第二条线程卡住,等到第一条线程整个循环执行结束后,第二条线程才继续执行。

此时第一条线程扩容完成,链表的指向和原数组的顺序相反。假设原数组的某个桶中链表方向是1>2>3>4,扩容后又恰好都落在同一个新的桶中,那么新的链表方向是4>3>2>1。

此时第二条线程开始执行循环:

第一轮循环开始 e指的是1,next指的是2 头插法插入新的数组,新数组的链表为1>null

第二轮循环开始 e指的是2,next指的是1 头插法插入新的数组,新数组的链表为2>1>null

第三轮循环开始 e指的是1,next指的是null 头插法插入新的数组,新数组的链表为1>2>1

此时循环链表出现了。

关键词:hashMap      点击(4)
声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多hashMap相关>>>