HashMap

HashMap

特点

1、HashMap 1.8之前是基于数组+链表数据结构组成,1.8 是基于 数组+链表+红黑树数据结构组成;
1.7 数组是基于Entry实现的;1.8是基于Node实现的都是<K,V>类型的数组,并且都实现了Map.Entry<K,V>;
2、存取是无序的,键和值都可以为null,但是键位置只能有一个null,键是唯一的;
3、阈值(边界值)>8 并且数组长度大于64时,才将链表转换为红黑树,红黑树目的是高效查找;
4、1.7是在构造函数中初始化数组容量,1.8是在put方法中初始化数组容量;
5、是线程不安全的。

一、继承关系:

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

在这里插入图片描述
二、实例变量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 默认容量

static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量

static final float DEFAULT_LOAD_FACTOR(加载因子):0.75 //HashMap的负载因子,可计算出当前table长度下的扩容阈值:threshold = loadFactor * table.length。

static final int TREEIFY_THRESHOLD = 8; //链表元素容量达到8时转换成红黑树,根据泊松分布计算概率是最低的,仅仅为0.0000006。

static final int UNTREEIFY_THRESHOLD = 6;//当链表的值小于6则从红黑树转回链表。

static final int MIN_TREEIFY_CAPACITY = 64;//表示数组长度大于64时链表转换成红黑树。

transient Node<K,V>[] table;//HashMap的哈希桶数组,非常重要的存储结构,用于存放表示键值对数据的Node元素。

transient Set<Map.Entry<K,V>> entrySet; //HashMap将数据转换成set的另一种存储形式,这个变量主要用于迭代功能。

transient int size; //HashMap中实际存在的Node数量,注意这个数量不等于table的长度,甚至可能大于它,因为在table的每个节点上是一个链表(或RBT)结构,可能不止有一个Node元素存在。

transient int modCount; //HashMap的数据被修改的次数,这个变量用于迭代过程中的Fail-Fast机制,其存在的意义在于保证发生了线程安全问题时,能及时的发现(操作前备份的count和当前modCount不相等)并抛出异常终止操作。

int threshold; ,自动扩容容量为原来的二倍。

int threshold = 16*0.75 //HashMap的扩容阈值,在HashMap中存储的Node键值对超过这个数量时是当前数组的最大长度会进行扩容,扩容后的HashMap容量是之前容量的2倍。

加载因为为啥是0.75?
小于0.75 ,会浪费存储空间,很早就会扩容;
大于0.75,发生hash碰撞概率更大,导致链表很长,导致查找元素效率低。

三、构造函数:

public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
public HashMap(int initialCapacity, float loadFactor) {
		//验证容量是否小于0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
         //验证容量是否大于最大值
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
         //验证加载因子是否小于等于0或者是否非负数
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        //判断指定的初始容量是否是2的n次幂,如果不是将其变为2的n次幂
        //但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,
        //并且直接赋值给了threshold边界值了;正常的的边界值=cap*0.75
        //但是,在jdk1.8以后的构造方法中,并没有对table这个成员变量进行初始化,
        //table的初始化放到了put方法中,在put中会对threshold重新计算
        this.threshold = tableSizeFor(initialCapacity);
    }

Hashmap扩容为啥是2的n次幂:为了减少hash冲突。不是2的n次幂,会通过右移 和 按位或运算变为比指定容量大的最小的2的2次幂。

static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;  // | 位或运算规则:参加运算的两个数只要两个数中的一个为1,结果就为1
        n |= n >>> 2;  // >>> :表示右移几位,当前值的二进制右移几位
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

中的cap-1 操作是 防止当指定容量为2的n次幂时,如cap=8,计算容量后变为16,防止容量翻倍。

cap=8
00000000 00000000 00000000 00001000
00000000 00000000 00000000 00000100
>>>1
-----------------------------------
00000000 00000000 00000000 00001100
00000000 00000000 00000000 00000011
>>>2
-----------------------------------
00000000 00000000 00000000 00001111
00000000 00000000 00000000 00000000
>>>4
-----------------------------------
00000000 00000000 00000000 00001111 = 15

四、成员方法

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

(1)先计算key的hash:hash(key)
static final int hash(Object key) {
int h;
//key的hashcode,key的hashcode无符号右移运算,进行异或计算。效率高于取模运算,但是结果一样
//将key的hashcode值(由native方法计算得到)再与该值的高16位进行异或运算得到最终的hash值。
//目的尽量使得hash值较为分散。
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); //相当于 key的hashcode()%n:此处n指的是数组长度。
}

(2)存放k-v:返回的值则将是之前在map中实际与key相关联的Value值(也就是旧的值),如果key没有实际映射值的话那就返回null。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 如果map为空即map中的table为空,则进行初始化,即进行首次扩容。
        if ((tab = table) == null || (n = tab.length) == 0)
            //长度n是一个2的次幂数值
            n = (tab = resize()).length; 
        // 使用hash与数组长度减一的值进行异或得到数组下标i,表示key的值或许可能放到这个位置上
        // 如果这个位置的值为null,则新建Node<K,V>节点存放
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //else 表示key的hash计算得到的数组下标这个位置的值 不为null,即发生了hash碰撞
        //即此处采用链表和红黑树来存储
        else {
            Node<K,V> e; K k;
            // 如果key的hash值相等 并且 key也相等
            // 或者key不等于null 并且 key的内容相等 说明两个key是一样的
            // 将当前节点p用临时的e标识
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
			// 如果存在的节点p已经是红黑树类型的节点,表示碰撞已经开始用树来处理了,则走红黑树的插入。
            else if (p instanceof TreeNode)
            	//this 表示当前的map,tab是map中的数组,红黑树的插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//遍历链表
                for (int binCount = 0; ; ++binCount) {
                	// 当前发生碰撞的节点没有后续节点,表示已经到了链表的尾节点,则直接尾部插入,1.8的尾插法
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //如果链表长度大于8了,则进行确认是进行扩容还是进行转成红黑树,重新确认
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            //链表转红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果下个节点和碰撞的节点的hash、key相等或者key的内容相等
                    //用新的节点替换老的节点,退出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key,e是被碰撞的节点,老节点
                V oldValue = e.value;
                //将用新值覆盖旧值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                //返回旧值
                return oldValue;
            }
        }
        //map变更操作的计数器,比如map结构变化、内容增减、rehash,直接导致外部map并发
        // 迭代引起fail-fast问题,该值就是比较的基础
        ++modCount;
        //size map中k-v数量,当容量达到扩容阀值时,进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

Hash碰撞产生及解决
Hashmap里面的bucket出现了单链表的形式,散列表要解决的一个问题就是散列值的冲突问题,通常是两种方法:链表法和开放地址法。
1、链表法就是将相同hash值的对象组织成一个链表放在hash值对应的槽位;
2、开放地址法是通过一个探测算法,当某个槽位已经被占据的情况下继续查找下一个可以使用的槽位。
3、java.util.HashMap采用的链表法的方式,链表是单向链表。

(3)链表转红黑树:链表长度大于8,则

final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //MIN_TREEIFY_CAPACITY==64
        //如果数组为null,或者数组长度小于64,则进行扩容
        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)
            	// tab表开始链接成树
                hd.treeify(tab);
        }
    }

小结:扩容时机:
1、map中put第一个元素
2、map中元素数量大于阀值threshold
3、链表长度大于8并且数组长度小于64时进行resize

(4)扩容:将整个map的k-v进行散列存储,性能低下

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {//第一次不会走这里,因为table为null,oldCap ==0
            //如果扩容前数组大小已经达到了最大值,即2^30,则设置扩容的最大阈值为Int的最大值
            if (oldCap >= MAXIMUM_CAPACITY) { 
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果将就数组容量扩大为原来的2倍小于最大容量,并且旧的容量大于16
            //则将数组大小扩大为原来的2位,阈值扩大为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //如果原始(初始)容量不大于0,并且阈值大于0,则将容量初始化位阈值大小
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
         //初始容量和阈值都不大于0,则用map中的默认值
        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已经是扩容后的table了
        table = newTab;
        //对oldTab中所有元素进行rehash。
        //由于每次扩容是2次幂的扩展(指数组长度/桶数量扩为原来2倍)
        //所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {//如果数组j位置的元素不为null,则将该位置的所有元素全部重新hash
                    oldTab[j] = null;
                    if (e.next == null)//只有一个元素,直接rehash
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)//如果是树,
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                    //桶中是链表结构
                    //JDK1.7中旧链表迁移新链表的时候,用的是头插法,如果在新表的数组索引位置相同,则链表元素会倒置;
                    //JDK1.8不会倒置,用的是双指针,即尾插入
                        Node<K,V> loHead = null, loTail = null;// low位链表,其桶位置不变,head和tail分别代表首尾指针
                        Node<K,V> hiHead = null, hiTail = null;// high位链表,其桶位于追加后的新数组中
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {// 是0的话索引没变,是1的话索引变成“原索引+oldCap”
                                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;// 原索引+oldCap
                        }
                    }
                }
            }
        }
        return newTab;
    }

get方法

public V get(Object key) {
        Node<K,V> e;
        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;
        //如果table不为null并且key所在的桶不为null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //先计算桶的头结点是否是想要的节点
            if (first.hash == hash && // always check first node
            	//如果key相等并且key的内容也相等,则返回key对应的value
                ((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 {
                	//如果key的hash值和key的地址都相等或者key的内容也相等,则返回key对应的value
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

在 Java8 中,HashMap 中的桶可能是链表结构,也可能是树结构。

如果是链结构
将旧链表拆分成两条新的链表,通过 e.hash & oldCap 来计算新链表在扩容后的数组中的新下标。
当 e.hash & oldCap = 0,则节点在新数组中的索引值与旧索引值相同。
当 e.hash & oldCap = 1,则节点在新数组中的索引值为旧索引值+旧数组容量。
在 Java8 中,HashMap 插入元素使用尾插法,扩容时使用了首尾指针保证了链表元素顺序不会倒置,从而解决了 Java7 扩容时产生的环问题。
在 Java7中,HashMap 使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。(线程1准备处理节点,线程二把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。

①HashMap的工作原理
HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。
当两个不同的键对象的hashcode相同时会发生什么? 它们会储存在同一个bucket位置的链表中。键对象的equals()方法用来找到键值对。

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

要注意的一些重要术语:

  1. sychronized意味着在一次仅有一个线程能够更改Hashtable。就是说任何线程要更新Hashtable时要首先获得同步锁,其它线程要等到同步锁被释放之后才能再次获得同步锁更新Hashtable。

  2. Fail-safe和iterator迭代器相关。如果某个集合对象创建了Iterator或者ListIterator,然后其它的线程试图“结构上”更改集合对象,将会抛出ConcurrentModificationException异常。但其它线程可以通过set()方法更改集合对象是允许的,因为这并没有从“结构上”更改集合。但是假如已经从结构上进行了更改,再调用set()方法,将会抛出IllegalArgumentException异常。

  3. 结构上的更改指的是删除或者插入一个元素,这样会影响到map的结构。

我们能否让HashMap同步?
HashMap可以通过下面的语句进行同步:
Map m = Collections.synchronizeMap(hashMap);

③HashMap和HashSet的区别
HashMap和HashSet的区别是Java面试中最常被问到的问题。如果没有涉及到Collection框架以及多线程的面试,可以说是不完整。而Collection框架的问题不涉及到HashSet和HashMap,也可以说是不完整。HashMap和HashSet都是collection框架的一部分,它们让我们能够使用对象的集合。collection框架有自己的接口和实现,主要分为Set接口,List接口和Queue接口。它们有各自的特点,Set的集合里不允许对象有重复的值,List允许有重复,它对集合中的对象进行索引,Queue的工作原理是FCFS算法(First Come, First Serve)。

首先让我们来看看什么是HashMap和HashSet,然后再来比较它们之间的分别。

什么是HashSet
HashSet实现了Set接口,它不允许集合中有重复的值,当我们提到HashSet时,第一件事情就是在将对象存储在HashSet之前,要先确保对象重写equals()和hashCode()方法,这样才能比较对象的值是否相等,以确保set中没有储存相等的对象。如果我们没有重写这两个方法,将会使用这个方法的默认实现。

public boolean add(Object o)方法用来在Set中添加元素,当元素值重复时则会立即返回false,如果成功添加的话会返回true。

什么是HashMap
HashMap实现了Map接口,Map接口对键值对进行映射。Map中不允许重复的键。Map接口有两个基本的实现,HashMap和TreeMap。TreeMap保存了对象的排列次序,而HashMap则不能。HashMap允许键和值为null。HashMap是非synchronized的,但collection框架提供方法能保证HashMap synchronized,这样多个线程同时访问HashMap时,能保证只有一个线程更改Map。

public Object put(Object Key,Object value)方法用来将元素添加到map中。

HashSet和HashMap的区别
HashMap HashSet
HashMap实现了Map接口 HashSet实现了Set接口
HashMap储存键值对 HashSet仅仅存储对象
使用put()方法将元素放入map中 使用add()方法将元素放入set中
HashMap中使用键对象来计算hashcode值 HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false
HashMap比较快,因为是使用唯一的键来获取对象 HashSet较HashMap来说比较慢

④面试题
HashMap的工作原理是近年来常见的Java面试题。几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道Hashtable和HashMap之间的区别,那么为何这道面试题如此特殊呢?是因为这道题考察的深度很深。这题经常出现在高级或中高级面试中。投资银行更喜欢问这个问题,甚至会要求你实现HashMap来考察你的编程能力。ConcurrentHashMap和其它同步集合的引入让这道题变得更加复杂。让我们开始探索的旅程吧!

“你用过HashMap吗?” “什么是HashMap?你为什么用到它?”

然后回答HashMap的一些特性,譬如HashMap可以接受null键值和值,而Hashtable则不能;HashMap是非synchronized;HashMap很快;以及HashMap储存的是键值对等等。

“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”

但一些面试者可能可以给出答案,“HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。”这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。但是这仅仅是故事的开始,当面试官加入一些Java程序员每天要碰到的实际场景的时候,错误的答案频现。下个问题可能是关于HashMap中的碰撞探测(collision detection)以及碰撞的解决方法:

“当两个对象的hashcode相同会发生什么?” 从这里开始,真正的困惑开始了,一些面试者会回答因为hashcode相同,所以两个对象是相等的,HashMap将会抛出异常,或者不会存储它们。然后面试官可能会提醒他们有equals()和hashCode()两个方法,并告诉他们两个对象就算hashcode相同,但是它们可能并不相等。一些面试者可能就此放弃,而另外一些还能继续挺进,他们回答“因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。”这个答案非常的合理,虽然有很多种处理碰撞的方法,这种方法是最简单的,也正是HashMap的处理方法。但故事还没有完结,面试官会继续问:

“如果两个键的hashcode相同,你如何获取值对象?” 面试者会回答:当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。面试官提醒他如果有两个值对象储存在同一个bucket,他给出答案:将会遍历链表直到找到值对象。面试官会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?除非面试者直到HashMap在链表中存储的是键值对,否则他们不可能回答出这一题。

其中一些记得这个重要知识点的面试者会说,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。完美的答案!

许多情况下,面试者会在这个环节中出错,因为他们混淆了hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

如果你认为到这里已经完结了,那么听到下面这个问题的时候,你会大吃一惊。“如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?”除非你真正知道HashMap的工作原理,否则你将回答不出这道题。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

如果你能够回答这道问题,下面的问题来了:“你了解重新调整HashMap大小存在什么问题吗?”你可能回答不上来,这时面试官会提醒你当多线程的情况下,可能产生条件竞争(race condition)。

当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?:)

为什么String, Interger这样的wrapper类适合作为键? String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
我们可以使用自定义的对象作为键吗? 这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。
我们可以使用CocurrentHashMap来代替Hashtable吗?这是另外一个很热门的面试题,因为ConcurrentHashMap越来越多人用了。我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。看看这篇博客查看Hashtable和ConcurrentHashMap的区别。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值