java HashMap原理详解以及面试中常见的问题

最近由于工作的原因,我就把HashMap说深入的学习了一下,把知识点做以下总结:

在面试中常见的HashMap的问题一般有以下几个:

1,JDK1.8中的HashMap有那些改动,请说出三点以上。

2,JDK1.8中为什么要使用红黑树。

3,HashMap的扩容机制是怎么样的。

4,为什么重写对象的equales()方法时,要重写hashCode()方法,跟HashMap有关系吗?为什么?

5,HashMap是线程安全的吗?遇到过ConcurrentModificationException异常吗?为什么会出现?如何解决?

6,在使用HashMap的过程中,我们应该注意什么问题?

说到HashMap首先要了解哈希表的用法,哈希表的的存储原理是:根据代存储的的数据的关键字,通过哈希函数的变换得到一个哈希码,这个哈希码就是待存储数据的地址。具体的细节大家可以自行去复习数据结构与算法——哈希表。

下面通过这些代码来看看HashMap的哈希码:

public class HashMapTest {
    public static void main(String args[]){
        HashMap<String,String> hashMap=new HashMap<>();
        hashMap.put("张三","张三");
        hashMap.put("李四","李四");
        hashMap.put("王五","王五");
        hashMap.put("老大","老大");
        hashMap.put("老二","老二");
        hashMap.put("老三","老三");
        for (String key:hashMap.keySet()){
            int hashCode=key.hashCode();
            int index=hashCode%7;
            System.out.println(String.format("%s的hashCode是%s,index是%s",key,hashCode,index));
        }
    }
}

输出:

李四的hashCode是842061,index是3
张三的hashCode是774889,index是3
老二的hashCode是1035947,index是3
王五的hashCode是937065,index是3
老三的hashCode是1035816,index是5
老大的hashCode是1038662,index是2

在这里是不是有个疑惑:hashMap是数组加上链表实现的,也就是形如下面的格式:

         

那么哈希码是这么大的数字,又是在怎么把哈希码转化成数组下标的?转换后可以看到数组下标明显的重复了,又是怎么解决这种冲突的呢?至于第一个问题,这么大的哈希码转换成很小的数组下标,那肯定就是使用哈希表的“除留余数法”来进行定位的,只需要用哈希码值除以数组的长度取余就行了。至于第二个问题,可以看到李四,张三,老二,王五的数组下标都是3,那如果把他们存储在一起,岂不是出错了,那么这里就要使用到链表了,把数组下标一样的元素依次连接起来,这其实也就是解决哈希冲突的一个过程,这个方法叫做——链地址法。

那么问题来了,如果数组下标一样的元素连接起来,是要依次从头部开始连接还是从尾部开始连接?答案是:JDK1.7是从头部开始连接的,JDK1.8是从尾部开始连接的。至于原因,等后边会讲解。

JDK1.7中从头部依次连接,比如L:李四的上面是张三,再上面是老二,,,,这样在查找的时候依次从最上面开始遍历,知道找到要找到元素为止。如图:

下面就试着写一下这种存储的方式:


public class MyHashMap<K,V> {
    private static Integer CAPACITRY=8;
    private Entry[] table;
    private int size;
    public MyHashMap(){
        this.table=new Entry[CAPACITRY];
    }
    public int size(){
        return this.size;
    }
    public V put(K key,V value){
        //Entry entry=new Entry(key,value,null);
        //计算哈希码,通过哈希码来确定下表位置
        int hashCode=key.hashCode();
        int i=hashCode%CAPACITRY;

        //如果key的值相同的话,就要遍历链表,用新值替换老值,并把老值返回
        for (Entry<K,V> entry=table[i];entry!=null;entry=entry.next){
            if (entry.k.equals(key)){
                V oldValue=entry.v;
                entry.v=value;
                return oldValue;
            }
        }
        // 以上确定了数组的位置,下面就要造一个节点,依次插入该下标位置
        addEntry(key,value,i);
        return null;

    }

    public V get(K k){
        //对于get方法也是一样的,先通过key定位哈市Code,在定位数组下标,最后把值返回
        int hashCode=k.hashCode();
        int i=hashCode%CAPACITRY;
        for (Entry<K,V> entry=table[i];entry!=null;entry=entry.next){
            if (entry.k.equals(k)){
                return entry.v;
            }
        }
        return null;
    }

    private void addEntry(K key,V value,int i){
        Entry entry=new Entry(key,value,table[i]);
        table[i]=entry;
        size++;
    }

    class Entry<K,V>{
        public K k;
        public V v;
        public Entry next;
        public Entry(K k,V v,Entry next){
            this.k=k;
            this.v=v;
            this.next=next;
        }
    }


    public static void main(String args[]){
        MyHashMap<String,String> myHashMap=new MyHashMap<>();
        myHashMap.put("张三","学生");
        System.out.println(myHashMap.get("张三"));
    }
}


输出:

学生

以上就是简单的实现了HashMap,下面往该HashMap中存储是个数据,修改main()函数,如下:

public static void main(String args[]){
        MyHashMap<String,String> myHashMap=new MyHashMap<>();
        for (int i=0;i<10;i++){
            myHashMap.put("张三"+i,"学生"+i);
        }
        System.out.println(myHashMap.get("张三"));
    }
}


打断点Debug调试如下:

可以看到hashMap的数组长度是8,但是存储了十个元素,因此肯定有链表存在。

以上便是HashMap的基本原理,下面开始讲解HashMap的源码:

     /**
     *这是默认的数组的大小,默认是16
     */
    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;

     /**
     *当链表长度大于8的时候就会把这个链表转换成红黑树
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     *
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

接着是构造方法:

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

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

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

为什么JDK1.7只用了链表,而JDK1.8使用了链表加红黑树?

首先要知道链表的特点是插入快,而查找慢,树的特点是查找很快,但是插入肯定是没有链表快,树可以是红黑树,也可以是完全平衡二叉树,至于JDK1.8为什么选择红黑树而不选择完全平衡二叉树,可以通过比较完全平衡二叉树和红黑树的特点来得出结论:

二叉搜索树作为一种数据结构,其查找、插入和删除操作的时间复杂度都为O(logn),底数为2。但是我们说这个时间复杂度是在平衡的二叉搜索树上体现的,也就是如果插入的数据是随机的,则效率很高,但是如果插入的数据是有序的,比如从小到大的顺序,从大到小就是全部在左边,这和链表没有任何区别了,这种情况下查找的时间复杂度为O(N),而不是O(logN)。当然这是在最不平衡的条件下,实际情况下,二叉搜索树的效率应该在O(N)和O(logN)之间,这取决于树的不平衡程度。

那么为了能够以较快的时间O(logN)来搜索一棵树,我们需要保证树总是平衡的(或者大部分是平衡的),也就是说每个节点的左子树节点个数和右子树节点个数尽量相等。红-黑树的就是这样的一棵平衡树,对一个要插入的数据项(删除也是),插入例程要检查会不会破坏树的特征,如果破坏了,程序就会进行纠正,根据需要改变树的结构,从而保持树的平衡。

通过以上分析,红黑树相比较完全平衡二叉树来说,没有完全平衡二叉树那么严谨(必须左右两边相等子节点个数),红黑树的插入效率比完全平衡二叉树要高,,但是没有完全平衡二叉树的查找效率高,因此红黑树的插入和查询效率是处于链表和完全平衡二叉树之间,与因此两者折中使用红黑树是比较合适的。

JDK1.7的HashMap 插入算法之所以比较麻烦(多次左移或者右移),就是因为JDK1.7没有使用红黑树,为了不让一棵树上有太多节点(减少碰撞,提高查询效率),就使用的移位策略。

下面讲解与扩容相关知识:

前面看到有一个0.75,这个0.75乘以16=12,但是根据代码可以看出:如果已经达到12个元素,再来一个元素,如果这个元素和前边的12个元素中有冲突,意思是需要加入链表,那么就扩容,否则不扩容(当然这一点在JDK1.8中已经去掉了,这只是JDK1.7中的原理)。扩容的长度是原来长度的两倍,new出来一个新的是原来长度两倍的数组,把原来数组的值复制到新的数组中。但是在复制的时候会出现一个问题,就是链表底部的会跑到上面,顶部在下面,如果数据一样的话会出现环形链表,导致死锁。(张三——>李四——>李四,按照这个顺序你推理一下是不是会出现循环链表)

再来看JDK1.8的扩容原理:

上面有一行代码:

  /**
     *当链表长度大于8的时候就会把这个链表转换成红黑树
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 当红黑树上面的节点数小于等于6的时候就会把红黑树转换成链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     *
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
 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;
        //取出指定数组下标里面的值,如果没有的值为空的话就把一个新节点赋值过去
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {

            //如果以上两个判断都不满足的话就表示该位置上有值
            Node<K,V> e; K k;
            if (p.hash == hash &&

                //判断key相等的情况
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                //如果是树节点
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //如果是链表的情况,先遍历链表,肯定药去数链表
                for (int binCount = 0; ; ++binCount) {
                    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;
                    }
                    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;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

在上面,如果是链表的时候,新节点插入是从尾部插入的,因为链表要想转成红黑树就需要知道当前链表有多长,所以每次插入一个元素的时候都要遍历链表,遍历一次就会停留在链表尾部,为了节约时间,直接就把插入的节点放在链表尾部。

至于JDK1.8数组扩容问题这里就不再多讲了。

发布了75 篇原创文章 · 获赞 31 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/yaoyaoyao_123/article/details/98045543