带着问题学习HashMap源码

前言

本文主要讲解JDK1.8下HashMap的源码,并且会比较JDK1.7和JDK1.8的不同,我们来看一下有关HashMap的问题,让我们带着问题学HashMap。

  • HashMap1.7和1.8的区别?
  • HashMap是如何计算下标值?
  • HashMap数组的大小为什么规定是2的次方幂?
  • HashMap插入元素的过程是怎么样的?
  • HashMap如何扩容?
  • HashMap线程安全吗?

继承关系

在这里插入图片描述
HashMap继承了AbstractMap,实现了CloneableSerializable接口


存储结构

JDK1.7

HashMap底层是基于哈希表(散列表)实现的,哈希表其实就是数组+链表的形式,拥有数组快速随机访问的特性,然后使用链地址法来解决哈希冲突,所以对于JDK1.8之前HashMap的存储结构如下图。
在这里插入图片描述
当哈希冲突很严重的时候,对于哈希表来说有可能退化成一个长的链表,那么查找元素的时间复杂度就会变成O(N),N为链表的长度。

JDK1.8

在JDK1.8中对HashMap的存储结构做了优化,增加了红黑树(自平衡的二叉排序树),当链表的节点数大于8并且HashMap中的元素个数大于64的时候,就会将链表转换为红黑树,如图所示。

在这里插入图片描述


成员变量

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;

static final int TREEIFY_THRESHOLD = 8;

static final int UNTREEIFY_THRESHOLD = 6;

static final int MIN_TREEIFY_CAPACITY = 64;

transient Node<K,V>[] table;

transient int size;

transient int modCount;

int threshold;

final float loadFactor;

DEFAULT_INITIAL_CAPACITY:HashMap的默认容量为16。
MAXIMUM_CAPACITY:HashMap的最大容量为 2 30 2^{30} 230
DEFAULT_LOAD_FACTOR:默认的负载因子为0.75,扩容的时候需要使用。
TREEIFY_THRESHOLD:树化的阈值为8。
UNTREEIFY_THRESHOLD:由红黑树转换为链表的阈值为6。
MIN_TREEIFY_CAPACITY:最小的树化容量,元素个数达到64才可以树化。
table:Node类型的数组。
size:HashMap的元素个数。
modCount:记录HashMap结构变化次数。
threshold:扩容的阈值,threshold=size*loadFactor。
loadFactor:负载因子。


构造方法

带参构造函数

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

HashMap(int initialCapacity)传入的是初始化容量参数,然后调用HashMap(int initialCapacity, float loadFactor)方法,负载因子使用默认的负载因子。

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;
        this.threshold = tableSizeFor(initialCapacity);
}

HashMap(int initialCapacity, float loadFactor)传入了初始化容量参数和负载因子参数,然后通过tableSizeFor()方法进行初始化,tableSizeFor()保证了数组的长度是2的次方幂,如下

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

无参构造函数

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

put()方法

JDK1.8中的put方法的流程图如下
在这里插入图片描述

  1. 如果是第一次插入键值对,那么会调用resize()扩容方法来进行初始化。
  2. 计算键值对的下标值。
  3. 判断下标位置是否为空,如果为空,则直接赋值。
  4. 如果不为空,则判断key是否相等,如果相等,选择覆盖。
  5. 如果不相等,则判断该位置是否是一颗红黑树,如果是,则执行红黑树插入操作。
  6. 如果不是红黑树,则遍历链表,在链表中找到相同的则覆盖,否则采用尾插法的方式插入键值对,然后判断链表的长度是否大于8,HashMap中元素个数是否大于等于63,如果是则转化为红黑树。
  7. modCount++,并且判断是否需要扩容。

详细代码可以参考下面的图
在这里插入图片描述

Q:如何计算下标值?

  1. 首先会通过扰动函数hash()计算出key对应的哈希值,JDK1.8中的扰动函数如下
public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

将key的hashCode的高16位与低16位进行一个异或操作,扰动了2次,而JDK1.8之前是扰动了9次。

  1. 将第一步计算出来的哈希值与数组长度-1进行与运算得到下标值。
if ((p = tab[i = (n - 1) & hash]) == null)

Q:为什么数组的大小要是2的次方幂?

  1. 首先不能直接用key的hashcode作为下标,这是因为hashcode是Integer类型(4字节),大约由40亿个数,所以HashMap是放不下的,所以一般的做法就是将hashcode跟数组的长度取余,这样可以将hashcode均匀的分布在数组中,而数组的大小位2的次方幂时,(n-1)&hash等同于hash%n,将取余运算改成位运算,提高性能
  2. 如果数组的长度是2的次方,那么n-1的结果最后一位肯定是1,那么&hash的结果的最后一位可能是0,也可能是1,但是如果不是2的次方,那么n-1的结果的最后一位是0,&hash的结果的最后一位就是0,所以只能存放在偶数下标的位置,浪费了一半的空间。

get()方法

public V get(Object key) {
        Node<K,V> e;
        //1. 计算键值对对应的下标值
        return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //2. 如果数组不为空,并且下标值位置也不为空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //则判断hash和key是否跟下标值位置的相同
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
            	//判断下标值位置是不是红黑树
                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;
}
  1. 计算键值对对应的下标值。
  2. 如果数组不为空,并且下标值位置也不为空,则判断hash和key是否跟下标值位置的相同,如果是,则直接返回,如果不是执行3。
  3. 判断下标值位置是不是红黑树,如果是,则执行红黑树的查找方法,如果不是,则遍历链表。

扩容机制

JDK1.7中的扩容

扩容机制主要讲解JDK1.7扩容机制和存在的死循环问题,然后会讲JDK1.8中对扩容的优化。

void addEntry(int hash, K key, V value, int bucketIndex) {
	Entry<K,V> e = table[bucketIndex];
	table[bucketIndex] = new Entry<>(hash, key, value, e);
	if (size++ >= threshold)
		resize(2 * table.length);
}

JDK1.7中当需要扩容的时候,新容量是旧容量的2倍。

void resize(int newCapacity) {
	Entry[] oldTable = table;
	int oldCapacity = oldTable.length;
	//判断旧容量是否达到最大容量,如果是,将扩容阈值更改为Integer所能表示的最大值,返回
	if (oldCapacity == MAXIMUM_CAPACITY) {
		threshold = Integer.MAX_VALUE;
		return;
	}
	//定义一个新的数组
	Entry[] newTable = new Entry[newCapacity];
	//将旧数组中的元素复制到新数组中
	transfer(newTable);
	//更新数组为新数组
	table = newTable;
	//更新扩容阈值
	threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
	//src代表旧数组
	Entry[] src = table;
	int newCapacity = newTable.length;
	for (int j = 0; j < src.length; j++) {
		Entry<K,V> e = src[j];
		if (e != null) {
			src[j] = null;
			do {
				Entry<K,V> next = e.next;
				//重新计算旧数组中每一个非空元素的下标值
				int i = indexFor(e.hash, newCapacity);
				//采用头插法的方式插入到新数组中
				e.next = newTable[i];
				newTable[i] = e;
				e = next;
			} while (e != null);
		}
	}
}

JDK1.7的扩容方法主要是申请新的数组,容量是旧容量的两倍,然后重新计算旧数组中每个元素在新数组的下标值,然后复制到新数组中。
在JDK1.7中,在多线程进行扩容的情况下,会出现环链,导致get()操作陷入死循环的问题。

JDK1.8中对扩容的改进

  1. 在JDK1.8中插入元素采用的尾插法,可以避免了环链的问题。
  2. 在JDK1.8中不需要重新计算key的哈希值,因为数组的大小是2的次方,所以元素在新数组中的位置,要么是原位置,要么是原位置+2的次方,取决于元素hash值新增的那一位,如果是0,则在原位置,如果是1则在原位置+2的次方。

HashMap的遍历

public static void main(String[] args) {
        HashMap<String,String> hashMap = new HashMap<>();
        hashMap.put("key1","value1");
        hashMap.put("key2","value2");
        hashMap.put("key3","value3");

        //第一种遍历方式:使用Map.entrySet
        for(Map.Entry<String,String> entry : hashMap.entrySet()) {
            System.out.println("key:"+entry.getKey()+",value:"+entry.getValue());
        }

        //第二种遍历方式:使用迭代器
        Iterator iterator = hashMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry<String,String> entry = (Map.Entry<String,String>)iterator.next();
            System.out.println("key:"+entry.getKey()+",value:"+entry.getValue());
        }

        //第三种遍历方式:通过Map.keySet,二次取值
        for (String key : hashMap.keySet()) {
            System.out.println("key:"+key+",value:"+hashMap.get(key));
        }

        //第三种遍历方式:通过Map.values遍历
        for (String value:hashMap.values()) {
            System.out.println("value:"+value);
        }
}


HashMap的线程问题

  1. 死循环:JDK1.7中在扩容的过程中,因为采用头插法,所以在多线程的情况下,会产生环链,导致get的时候出现死循环
  2. 数据丢失:在JDK1.8中虽然解决了环链的问题,但是依然存在线程安全的问题,那就是在put操作的时候,如果有两个线程A和B同时执行put操作,两条不同数据的在数组中的下标值相同,并且下标处为空,这个时候线程A挂起,然后线程B执行完put操作,这个时候线程A继续执行,不用再判断该位置是否为空,而是直接覆盖了线程B的值,造成了数据丢失。

小结

JDK1.8相对于JDK1.7的变化是

  1. 底层数据结构:1.7使用数组+链表,1.8新增了红黑树,提高查询效率。
  2. 元素插入的方式:1.7使用的是头插法,1.8使用尾插法,再扩容的时候避免了出现环链。
  3. 扩容机制:1.7扩容需要重新计算hash值然后采用头插法,1.8不需要重新计算hash值,并且采用尾插法。
  4. hash()方法:1.7采用了9次扰动,1.8只有2次扰动,就可以降低哈希碰撞概率。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值