HashMap源码分析

HashMap源码分析

1. 分析准备

数组

1.1 Hash

hash也叫散列,音译为哈希,意思是将任意长度的输入通过散列算法转换成相同长度的输出,这种输出就叫散列值(哈希值),不同的输入可能会造成相同的输出,因此不能通过散列值来反推出输入内容。

1.2 数组

用一组连续的存储空间来存储数据,存储相同类型的数据,有索引,查询快,增删慢,容量固定。

1.3 链表

一组不连续的区域,一个区域的指针指向下一个区域以此类推,查找慢,增删快。

1.4 哈希表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。 **也可以通俗理解为数组和链表的结合, 即一个一维数组,但数组的每个元素都是一个链表**

1.5二叉树

是树结构的一种,但是每一个节点只有两个分支,且有左右之分

1.6红黑树

自平衡的二叉树 当添加节点后,树状结构会根据当前结点的数量自动旋转,改变根节点以达到树的平衡。

2.源码分析

JDK1.7实现

2.1 基本参数

默认初始容量

   static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

最大容量

   static final int MAXIMUM_CAPACITY = 1 << 30;

负载因子(扩容因子)

   static final float DEFAULT_LOAD_FACTOR = 0.75f;

键值对个数

   transient int size;

修改的次数

   transient int modCount;
   /**由于HashMap是非线程安全的,put,get,remove等方法中都会有这个参数
   当线程b从线程a拿到对象后,modCount的值为1,线程b做了一次修改,modCount的值为2,当线程b提交时发现,此时线程a的modCount值已经为2,则会抛出ConcurrentModificationException()异常;

2.2 构造方法

共有四个构造方法

构造一个空的HashMap,默认长度为16,负载因子为0.75

    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

构造一个空的HashMap,参数内指定初始容量,负载因子为0.75

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

构造一个空的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();
    }

构造一个新的HashMap与指定的相同的映射Map

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

        putAllForCreate(m);
    }

我们最常用的构造方法是无参构造,默认容量为16,负载因子为0.75
通过源码可以得知,前两个方法都是调用第三个方法
第三个构造方法对传进来的初始容量和负载因子做了判断,这时只对必要的一些参数进行赋值,并不会进行初始化操作

2.3 put方法

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

可以看到当 当前数组为空时,进行初始化操作

2.3.1 初始化方法

private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//计算扩容阈值
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

roundUpToPowerOf2(toSize)方法中可以看到,初始化时,无论传进来的初始容量是多少,都会向上取整为2的幂。也就是说,虽然构造方法中可以让用户自定义容量大小,但是进来后也会向上转成2的幂。当然,如果本身就是2的幂,那么就不会转换了,这里看源码,会发现他做了一个-1操作,目的就是为了防止把正确容量也翻一倍。比如你传进来的是15,那么向上取整为16.如果传进来的是16,向上取整就成了32,这是不合理的,所以任何数在取整时都先-1.
2.3.2如果key为null时调用putForNullKey(value)

if (key == null)
            return putForNullKey(value);

可以看到当key为null时单独进入一个方法进行处理

private V putForNullKey(V value) {
        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;
    }

通过代码可以看到,当key为null时,直接放入索引为0的位置,当元素中有key为null的元素时,将新传入的value替换

2.3.3如何计算hash值和索引

根据实际情况考虑,我们希望所有的元素均匀的分布在每个格子里,否则当元素都堆积在一个或多个格子中,那么就退化成了链表,查询速度降低,这里我们可以采用取余操作,有几个格子就对几取余
但是这里存在一个问题,我们计算出来的hash值可能会出现负数,而我们的索引是从0开始的,同时取余的操作耗时大,我们可以直接用二进制去操作,那么有没有一种将两个数进行操作后数值一定小于这两个数,有 & ,所以我们可以这样做 hash&数组长度
举例,默认长度为16,转换为二进制为10000
假设传进来的值计算出hash为10111
那么计算出来的值为10000,转换为10进制还是16
问题来了,我们知道长度为16,但是索引是从0开始的,所以我们的索引为0-15,此时发生异常,索引越界·············
如何解决呢?
我们知道,索引长度为2的幂,转换为二进制后就是1后面若干个0,当我们将索引长度-1后的二进制就为0后面若干个1,此时1跟任何数进行&操作,值都为它本身,既能保证分配均匀,又能保证不越界
此时还会有一个问题
假设有两个key
他们的hash值分别为10010101和11110101
他们的高位不同,低位相同,但是进行&操作时又取不到高位,我们该怎们办,此时我们回到源码

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

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

根据源码一个看到方法结尾处进行了好几次无符号右移操作,保证高位可以参与运算。问题解决
总结,索引计算方法为 key的hash值&(索引长度-1)

2.3.4 添加元素方法     addEntry(int hash, K key, V value, int bucketIndex)

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

从上面代码可以看到,并不是当当前元素个数大于扩容阈值的时候就立即扩容,当当前元素个数大于扩容阈值并且分配给新元素的位置有值时,才会进行扩容,扩容后长度为原长度的2倍(以保证索引长度为2的幂),扩容后会把所有元素重新计算哈希值,然后再插入新的元素

设想一下你的数组中某一个元素的链表已经很长很长了,当你进行扩容后,索引长度发生变化,那么原链表中的数据就会被分散开,这样不就提高了查询速度,也不浪费新扩容的位置

注意:按照源码来说,每次扩容后,会根据哈希值和新的容量去计算新的索引,但不一定每次都会重新计算哈希。是否重新计算hash,源码里写了和哈希种子还有int最大值有关系,但是具体怎么算,就不知道了。只要记住一般情况下并不会rehash就好了。

jdk1.7中插入数据采用的是头插法,也就是新来的元素会加在链表的开头,类似于栈,后来居上。因为开发者认为后加的元素可能被用到的几率更大,所以头插法可以快速查询。

2.3.5 get方法

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

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

这个很简单,根据代码可以看出通过get传一个key进来,获取value。
计算key的hash,然后找到对应的索引位置,然后遍历链表,比对key,取出相对应的value

JDK1.8 实现

多出的基本参数
链表的最大长度,超过此长度会自动转为红黑树:

static final int TREEIFY_THRESHOLD = 8;

红黑树的最小节点数,小于此值会自动转为链表

static final int UNTREEIFY_THRESHOLD = 6;

将链表树化时索引长度至少为64

static final int MIN_TREEIFY_CAPACITY = 64;

JDK1.8中采用了尾插法,所以当插入时,如果链表节点长度超过了8,则会将链表树化成一个红黑树,以保证查询速度
当然再转化时会先检查索引长度是否为64,所过索引长度小于64,则先进行扩容,
当索引长度大于64并且链表节点长度大于8就会将链表树化。

posted @ 2021-08-13 15:49  PangLin-cloud  阅读(72)  评论(0)    收藏  举报