HashMap的源码实现

HashMap的底层实现相信是Java开发者们面试都会碰到的问题。它方便易用,在Java开发中使用频率非常高。它的实现原理包含很多知识点,所以当面试官问到你HashMap的时候可就要当心了,因为如果基本功不够扎实的话,很容易被带到沟里哦。以下暂时说jdk1.8的HashMap实现,我们都知道在数据结构中,物理存储的数据结构只有两种,即数组和链表,其余的如树,队列,栈等数据结构都是在它们的基础上逻辑抽象而来。它们各有优势,数组存储区域连续,查找快,增删慢,而链表存储区域离散,查找慢,增删快,因此各有各的应用场景。并没有一种既满足查找快也满足增删快的基本数据结构。那能不能把数组和链表组合在一起构成一种新的数据结构呢?HashMap就此诞生了,它的底层就是通过数组加链表组合的方式实现了查找快和增删快的统一。那么怎么样的组合才能使两者达到平衡呢?来看一下HashMap的实现细节

 

在此之前我们先了解一下几种常用的数据结构,从图中也可以看出,它们是组成HashMap的基石。

数组:数组指的就是一组相关类型的变量集合,并且这些变量彼此之间没有任何的关联。占用连续的内存空间存储数据,数组有下标,查询数据快,但是增删比较慢。

链表:链表是一种线性表,不会占用连续的内存空间存储数据,而是每一个节点里存到下一个节点的指针。由于存储区间离散,因而占用内存比较宽松,链表查询比较慢,但是增删比较快;

哈希:哈希是单词Hash的英译,也翻译为散列。把任意长度的输入,通过散列算法变换成固定长度的输出,该输出就是散列值

HashMap的实现

可以看出组成HashMap最基本的单元是Entry。图中紫色框的Entry构成数组,红色框中的Entry构成链表。当有新的Entry需要加入进来时,通过Entry的Key哈希后得到值和table数组的长度位与运算得到数组的下标。再遍历链表,通过equals方法逐个比较key,如果有相同的Key就覆盖,如果没有就追加到链表头部(HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历[tail traversing])。数组和链表就是通过这种方式的组合实现了查找和增删效率的平衡。其中Entry对象除了包含本身的Key和Value外还包含它指向的下一个Entry。在jdk1.8后,对HashMap做了优化,当链表长度不小于8时就将链表转化成红黑树,长度小于8时再转化为链表。既然红黑树能有效的提高查询速度(黑红树查找相当于二分查找),那为什么不一开始就把链表转化为红黑树,而是要在链表长度大于8时才做这样的处理呢?这里个人理解,红黑树固然能提高查询速度,但也带来了维护红黑树的成本。我们知道红黑树是一颗自平衡的二叉树,因此在插入数据时,有可能导致红黑树不平衡,这时需要翻转红黑树使它达到平衡状态。而8这个数字就是链表和数字在插入效率和查询效率上的折中处理,就如同HashMap的负载因子是0.75,而不是0.6或是0.8,是一个道理。在jdk源码中Entry的实现类如下

static class Node<K,V> implements Map.Entry<K,V> {
  	// key的哈希值
      final int hash;
      final K key;
      V value;
      // 下一个Node,没有则为null
      Node<K,V> next;

//省略下面代码
  }

 在HashMap类中还定义了几个静态常量,这几个常量是一些很重要的属性。

public class HashMap<K,V> extends AbstractMap<K,V> 
	implements Map<K,V>, Cloneable, Serializable {

	 /**
     * HashMap的默认初始容量大小 16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * HashMap的最大容量 2的30次方
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 负载因子,代表了table的填充度有多少,默认是0.75。当数组中的数据大于总长度的0.75倍时
     * HashMap会自动扩容,默认扩容到原长度的两倍。为什么是两倍,而不是1.5倍,或是3倍。这个
     * 2倍很睿智,后面会说到
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 默认阈值,当桶(bucket)上的链表长度大于这个值时会转成红黑树,put方法的代码里有用到
     * 在jdk1.7中链表就是普通的单向链表,很多数据出现哈希碰撞导致这些数据集中在某一个哈希桶上,
     * 因而导致链表很长,会出现效率问题,jdk1.8对此做了优化,默认当链表长度大于8时转化为红黑树
     */
    static final int TREEIFY_THRESHOLD = 8;
    
    /**
     * 和上一个的阈值相对的阈值,当桶(bucket)上的链表长度小于这个值时红黑树退化成链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;
    
    /**
     * 用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap      * 的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
     */
    transient int modCount;
    

}

HashMap有两个有参构造器可以用来设置initialCapacity和loadFactor的值,即HashMap的初始容量和负载因子的值,如果不传参则都使用默认值。在使用HashMap可根据实际情况设置这两个值,能在一定程度上提高效率

HashMap的几个重要方法

HashMap有几个常用的方法,也是比较重要的方法,分别是put,treeify和resize方法,把这几个方法的源码弄懂了,HashMap也就不神秘了,下面分别看一下这几个方法的源码实现。

Put方法

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

 源码中put方法调用了putval方法,OK,继续看putval操作的实现吧。这个方法比较长,涉及到新加元素的代码逻辑,重要的地方都加了注释

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
			   boolean evict) {
			   
	//table全局变量,存储链表头节点数组
	Node<K,V>[] tab; Node<K,V> p; int n, i;
	
	//如果table数组是空的,则创建一个头结点数据,默认长度是16
	if ((tab = table) == null || (n = tab.length) == 0)
		n = (tab = resize()).length;
		
	//n是table长度,根据数组长度和key的哈希值,定位当前key在table中的下标位置,如果为空则新建一个node。不为空走else
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
	else {
		Node<K,V> e; K k;
		// 如果新的key与table中索引处取出的头节点的key相等,且hash值一致,则把新的node替换掉旧的node
		if (p.hash == hash &&
			((k = p.key) == key || (key != null && key.equals(k))))
			e = p;
		//如果头节点不是空,且头节点的类型是树节点类型,则把当前节点插入当前头节点所在的树中(红黑树,防止链表过长,1.8的优化)
		else if (p instanceof TreeNode)
			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
		//map的数据结构处理
		else {
			//遍历链表
			for (int binCount = 0; ; ++binCount) {
				如果当前节点的下一个节点为空,则把新节点插入到下一个节点
				if ((e = p.next) == null) {
					p.next = newNode(hash, key, value, null);
					//如果链表长度大于或等于8,则把链表转化为红黑树,重点转化方法(treeifyBin)
					if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
					//此方法构建红黑树
						treeifyBin(tab, hash);
					break;
				}
				//如果当前节点的key和hash均和待插入的节点相等,则退出循环,(注意此时e的值在前一个if时赋值过,因此当前的e值,就是链表中的当前节点值)
				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;
			//此方法实现的逻辑,是把入参的节点放置在链表的尾部,但在HashMap中是空实现,在LinkedHashMap中有具体实现
			afterNodeAccess(e);
			return oldValue;
		}
	}
    //保证并发访问时,若HashMap内部结构发生变化,快速响应失败
	++modCount;
    //当table[]长度大于临界阈值,调用resize方法进行扩容
	if (++size > threshold)
		resize();
	//此方法在HashMap中是空方法,在LinkedHashMap中有实现
	afterNodeInsertion(evict);
	return null;
	}

Treeify方法

这个方法的主要作用是构建红黑树,在这个方法中比较重要的方法就是treefyBin方法了,此方法以当前节点为头节点,构建一个双向链表(双向链表:链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点

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)
		resize();
	else if ((e = tab[index = (n - 1) & hash]) != null) {
		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.treeify(tab);
	}
}

然后在这个方法中构建红黑树,jdk1.8对HashMap的优化核心也在于此方法,可以重点看看

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null; // 定义树的根节点
	//这里的this是通过hd.treeify(tab);方法调用的,因此this就是hd,而hd是table数组中某个值,也就是链表的头节点
	// 以下的代码就是从头节点开始遍历链表
    for (TreeNode<K,V> x = this, next; x != null; x = next) { // 遍历链表,x指向当前节点、next指向下一个节点
        next = (TreeNode<K,V>)x.next; // 下一个节点
        x.left = x.right = null; // 设置当前节点的左右节点为空
        if (root == null) { // 如果还没有根节点
            x.parent = null; // 当前节点的父节点设为空
            x.red = false; // 当前节点的红色属性设为false(把当前节点设为黑色)
            root = x; // 根节点指向到当前节点
        }
        else { // 如果已经存在根节点了
            K k = x.key; // 取得当前链表节点的key
            int h = x.hash; // 取得当前链表节点的hash值
            Class<?> kc = null; // 定义key所属的Class
			//遍历红黑树
            for (TreeNode<K,V> p = root;;) { // 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出
                // GOTO1
                int dir, ph; // dir 标识方向(左右)、ph标识当前树节点的hash值
                K pk = p.key; // 当前树节点的key
                if ((ph = p.hash) > h) // 如果当前树节点hash值 大于 当前链表节点的hash值
                    dir = -1; // 标识当前链表节点会放到当前树节点的左侧
                else if (ph < h)
                    dir = 1; // 右侧
 
                /*
                 * 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
                 * 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class的实例,那么通过comparable的方式再比较两者。
                 * 如果还是相等,最后再通过 tieBreakOrder 比较一次
                 */
                else if ((kc == null &&
				//comparableClassFor返回键的类对象,该类必须实现Comparable接口,否则返回null
                            (kc = comparableClassFor(k)) == null) ||
                            (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);
 
                TreeNode<K,V> xp = p; // 保存当前树节点
 
                /*
                 * 如果dir 小于等于0 : 当前链表节点一定放置在当前树节点的左侧,但不一定是该树节点的左孩子,也可能是左孩子的右孩子 或者 更深层次的节点。
                 * 如果dir 大于0 : 当前链表节点一定放置在当前树节点的右侧,但不一定是该树节点的右孩子,也可能是右孩子的左孩子 或者 更深层次的节点。
                 * 如果当前树节点不是叶子节点,那么最终会以当前树节点的左孩子或者右孩子 为 起始节点  再从GOTO1 处开始 重新寻找自己(当前链表节点)的位置
                 * 如果当前树节点就是叶子节点,那么根据dir的值,就可以把当前链表节点挂载到当前树节点的左或者右侧了。
                 * 挂载之后,还需要重新把树进行平衡。平衡之后,就可以针对下一个链表节点进行处理了。
                 */
				 //如果dir等于-1则,插入到左树,如果是1则插入到右树
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp; // 当前链表节点 作为 当前树节点的子节点
                    if (dir <= 0)
                        xp.left = x; // 作为左孩子
                    else
                        xp.right = x; // 作为右孩子
                    root = balanceInsertion(root, x); // 重新平衡
                    break;
                }
            }
        }
    }
 
    // 把所有的链表节点都遍历完之后,最终构造出来的树可能经历多个平衡操作,根节点目前到底是链表的哪一个节点是不确定的
    // 因为我们要基于树来做查找,所以就应该把 tab[N] 得到的对象一定根节点对象,而目前只是链表的第一个节点对象,所以要做相应的处理。
    moveRootToFront(tab, root); 
}

Resize方法

resize方法的作用是对HashMap进行扩容,当HashMap数组达到设定的阈值时,会自动扩容,扩容机制也是面试中经常会被问到的知识点

final Node<K,V>[] resize() {
	Node<K,V>[] oldTab = table;
	//oldCap---原hashMap的最大容量,oldThr---原hashMap的负载容量
	int oldCap = (oldTab == null) ? 0 : oldTab.length;
	int oldThr = threshold;
	int newCap, newThr = 0;
	if (oldCap > 0) {
		if (oldCap >= MAXIMUM_CAPACITY) {
			threshold = Integer.MAX_VALUE;
			return oldTab;
		}
		//左移一位,就是将原来的容量翻倍,翻倍后的值小于2的30次方,大于原来的容量值
		else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
				 oldCap >= DEFAULT_INITIAL_CAPACITY)
			//原来的负载容量翻倍	 
			newThr = oldThr << 1; // double threshold
	}
	else if (oldThr > 0) // initial capacity was placed in threshold
		newCap = oldThr;
	else {               // zero initial threshold signifies using defaults
		newCap = DEFAULT_INITIAL_CAPACITY;
		newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
	}
	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"})
		Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
	table = newTab;
	if (oldTab != null) {
		for (int j = 0; j < oldCap; ++j) {
			Node<K,V> e;
			if ((e = oldTab[j]) != null) {
				oldTab[j] = null;
				if (e.next == null)
					newTab[e.hash & (newCap - 1)] = e;
				else if (e instanceof TreeNode)
					((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
				else { // preserve order
					Node<K,V> loHead = null, loTail = null;
					Node<K,V> hiHead = null, hiTail = null;
					Node<K,V> next;
					do {
						next = e.next;
						if ((e.hash & oldCap) == 0) {
							if (loTail == null)
								loHead = e;
							else
								loTail.next = e;
							loTail = e;
						}
						else {
							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;
}

HashMap的扩容机制

HashMap容器的扩容机制也是面试常问的点。HashMap中数组的默认初始长度是16。但当数组中有数据的个数超过总数的0.75时,HashMap就会自动扩容。一旦扩容,就意味着,旧的HashMap中的Entry全部要迁移到新的HashMap中。试想一下如果是你做这件事,你会怎么做呢?常规思维,首先要把所有的Entry放到一起,然后遍历所有的Entry,出现哈希碰撞时,就把碰撞的数据组成链表。为什么要遍历所有的Entry,因为table[]的长度变成了原来的两倍,所以所有的Entry都需要通过哈希算法重新定位到新的哈希桶。这样做确实很简单直接。但如果旧的HashMap中数据量非常大,这样做不但需要一个超大的数组暂存所有的Entry,而且HashMap的扩容将是一个巨大且低效率的 “工程” 。HashMap源码的编写者比我要睿智的多。下面是jdk中的哈希算法

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

以及putval方法

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;
       //这里是定位Entry在table[]中的下标,其中n是table[]的长度
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
        }
   }

上面两个方法,第一个是hash算法,这个方法的作用是根据key计算哈希值,使结果尽量的离散,这样当数据很多时,数据才会尽可能均匀的分布在哈希桶中。而第二个方法中数组的长度与哈希值的与计算得到下标。然后就是一个巧妙的设计了,table[]扩容为原来的两倍。这里HashMap的长度是默认值16,我们先来看以下两种情况,第一种是key的hash值小于16,第二种是key的哈希值大于16

1.key的值小于数组长度16时,可以看到扩容前和扩容后key的哈希值不变

2.key的值大于数组长度16时,可以看到扩容前和扩容后key的哈希值变了,但由于是扩容原来的2倍,所以扩容后的哈希值=原来的数组长度+原来的哈希值 

 因此可以得到一个结论,对key值小于16的key在扩容后,key的哈希桶位置保持不变,而对于key值大于16的key在扩容后,key的哈希桶位置=原数组长度+原哈希值。这样就保避免了一部分数据重新计算哈希值。这样就可以使之前已经散列好的数组变动最小,这也是为什么扩容2倍的原因。到这里可以给HashMap的原理做个总结:HashMap由数组加链表组成的,数组是HashMap的哈希桶,链表则是为解决哈希碰撞而存在的,如果定位到的数组位置不含链表(即哈希桶中只有一个Entry),那么对于查找,添加等操作很快,仅需一次寻址即可(数组根据下标寻址,速度很快);如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,也需遍历链表,然后通过key对象的equals方法逐一比较查找。所以,性能考虑,HashMap中的链表出现越少,性能就会越好。(其实也就是key的哈希值越离散,Entry就会尽可能的均匀分布,出现链表的概率也就越低)

equals和hashCode方法

在使用自定义对象作为HashMap的key时,如果重写了equals,也一定要重写hashcode方法。首先来看个小例子,如果在类中重写了equals方法,但是不重写hashCode方法会出现什么样的问题

public class Car {
    private String card;
    private String name;
    private int size;

    public Car(){}

    public Car(String card,String name){
        this.card=card;
        this.name=name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()){
            return false;
        }
        Car person = (Car) o;
        //两辆车,根据车牌判断是否相等
        return this.card.equals(person.card);
    }
    
    //省略set和get方法
    
    
   public static void main(String[] args){
        HashMap<Car,String> map = new HashMap<>();
        Car car1 = new Car("湘B2731","本田");
        map.put(car1,"张三的车");
        Car car2 = new Car("湘B2731","本田");

        System.out.println("结果---"+map.get(car2));
    }
   
}

这里定义了一个汽车类,重写了equals方法,通过判断两辆车的车牌号相等来判断是否为同一辆车,预期结果应该输出 张三的车 ,可实际结果却不是这样的,实际输出如下

结果---null

通过上面HashMap的原理,我们能明白其中的道理,是因为我们没有重写hashCode方法的原因,因为两个对象的哈希值不一样,导致定位到了HashMap的不同哈希桶中,因此没有get到值。但为什么hashCode会不相等呢。这需要搞清楚hashCode方法的原理。equals方法和hashCode方法都属于java基类Object类的方法,其中equals方法是用于比较两个值是否相等的。hashCode主要作用是为了配合基于散列的集合一起正常运行,这样的散列集合包括HashSet、HashMap以及HashTable,可以这么理解hashCode方法就是为了集合而生的。或许很多数人都会想到调用equals方法来逐个进行比较,这个方法确实可行。但是如果集合中已经存在成千上万条数据或者更多,如果采用equals方法遍历比较,效率必然是一个问题。此时hashCode方法的作用就体现出来了,当需要通过集合查找某条数据时,先调用这个对象的hashCode方法,得到对应的hashcode值。这样就把值的范围定位到一小片区域(即哈希桶)中,只要遍历这里面的数据就能找到这条数据了。说了这么多,那么hashCode是怎么计算而来的呢?有些朋友误以为默认情况下,hashCode返回的就是对象的存储地址,这种看法是不全面的,确实有些JVM在实现时是直接返回对象的存储地址,但是大多时候并不是这样,只能说与存储地址有一定关联。下面是HotSpot JVM中生成hash散列值的实现:

static inline intptr_t get_next_hash(Thread * Self, oop obj) {
  intptr_t value = 0 ;
  if (hashCode == 0) {
     // This form uses an unguarded global Park-Miller RNG,
     // so it's possible for two threads to race and generate the same RNG.
     // On MP system we'll have lots of RW access to a global, so the
     // mechanism induces lots of coherency traffic.
     value = os::random() ;
  } else
  if (hashCode == 1) {
     // This variation has the property of being stable (idempotent)
     // between STW operations.  This can be useful in some of the 1-0
     // synchronization schemes.
     intptr_t addrBits = intptr_t(obj) >> 3 ;
     value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
  } else
  if (hashCode == 2) {
     value = 1 ;            // for sensitivity testing
  } else
  if (hashCode == 3) {
     value = ++GVars.hcSequence ;
  } else
  if (hashCode == 4) {
     value = intptr_t(obj) ;
  } else {
     // Marsaglia's xor-shift scheme with thread-specific state
     // This is probably the best overall implementation -- we'll
     // likely make this the default in future releases.
     unsigned t = Self->_hashStateX ;
     t ^= (t << 11) ;
     Self->_hashStateX = Self->_hashStateY ;
     Self->_hashStateY = Self->_hashStateZ ;
     Self->_hashStateZ = Self->_hashStateW ;
     unsigned v = Self->_hashStateW ;
     v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
     Self->_hashStateW = v ;
     value = v ;
  }
 
  value &= markOopDesc::hash_mask;
  if (value == 0) value = 0xBAD ;
  assert (value != markOopDesc::no_hash, "invariant") ;
  TEVENT (hashCode: GENERATE) ;
  return value;
}

该实现位于hotspot/src/share/vm/runtime/synchronizer.cpp文件下。到这里我们就能明白为什么只重写equals方法不行了,既然hashCode和对象内存地址有关系,那么上面那个例子中,在往HahsMap中set和get时,明显是两个不同的对象,因此肯定会得到不同的哈希值。当我们重写了hashCode方法后,就能得到预期的结果了。

HashMap的死循环

HashMap是不能在并发场景下使用的,因为在HashMap的源码中,它的所有方法都没有同步处理。但如果在并发场景下使用HashMap,不仅仅会出现数据同步问题,而且还会出现CPU占用率持续飙升的情况,其根本原因在于HashMap的链表在多线程情况下形成了链表环,出现了死循环,导致该操作HashMap的线程一直占用CPU资源,得不到释放,并最终可能导致宕机。这个问题在JDK1.8时得到了修复,但JDK1.8的HashMap仍然是线程不安全的,不能在并发场景下使用。这里以JDK1.7为例看一下,HashMap为什么会出现链表环。下面是jdk1.7中的resize方法,这里省略了部分代码,直接看核心代码

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //省略无关代码
    //创建一个新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //将Old Hash Table上的数据迁移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

上面这段代码是JDK1.7的扩容方法,HashMap会出现死循环,只会在多线程下对他进行扩容的时候出现,即rehash。当table中的哈希桶超过HashMap默认的0.75时,会自动扩容,这个扩容在单线程下没有任何问题。而在多线程下问题就暴露出来了。在resize方法中调用了transfer方法,transfer方法源码如下

void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //  从OldTable里摘一个元素出来,然后放到NewTable中
    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);
        }
    }
}

在transfer方法中,外层的for循环遍历的是HashMap的table[],即所有哈希桶,而里层的do…while循环遍历的是哈希桶中的链表。现在假设有两个线程在执行put方法时,都出现了HashMap的扩容。两个线程怎么同时出现需要扩容的情况呢,继续看addEntry方法源码

void addEntry(int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize
    if (size++ >= threshold)   // ② <--线程挂起
        resize(2 * table.length);
}

上面两方法的源码中,我分别标注了①和②两个断点。这两个点就是环形链表的形成的原因,为了方便分析,再假设服务器cpu为单核。在②处代码是关于HashMap是否需要扩容的判断,由于此方法没有加synchronized修饰。于是当线程1运行到②处时,线程判断当前的HashMap需要扩容,就在此时线程1被挂起(线程是操作系统调度的最小单元,是否被挂起,什么时候被挂起,什么时候继续运行,都是由操作系统的线程调度说了算)。这时线程2也运行到了②处,这时由于线程1被挂起了,HashMap实际上还没有进行扩容操作。因此线程2也判断当前的HashMap需要扩容。于是两个线程都会执行resize方法。这里就会出现环形链表。

HashMap和Hashtable的区别

HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。主要的区别有:线程安全性,同步(synchronization),以及速度。

  1. HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。
  2. HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
  3. 另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。
  4. 由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。
  5. HashMap不能保证随着时间的推移Map中的元素次序是不变的。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值