Android实习面试准备——数据结构与算法(五)

本文详细介绍了Java HashMap的底层实现原理,包括JDK1.8引入的数组+链表+红黑树的数据结构,以及put方法的实现过程。文章还探讨了何时触发HashMap的扩容及resize()方法的工作机制。此外,对比了HashMap与HashTable、ArrayList的区别,并分析了在多线程环境下HashMap的线程安全性问题以及ConcurrentHashMap的解决方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1、HashMap的底层原理是什么?线程安全么?

        jdk 1.8以前,HashMap使用数组+链表来实现的,jdk 1.8以后,就采用了数组+链表+红黑树来实现了,先从put方法看起:

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

        跳转到了putVal方法:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
        //如果数组为null,或者数组长度为0,用resize方法进行初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //根据hash值来计算数组下标,判断对应下标的值是不是为null,如果是null,就是一个newNode
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {    //如果不为null
            HashMap.Node<K,V> e; K k;
            //如果key相等,直接将对应下标的value值进行覆盖
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果key不相等,则有两种方法,要么是红黑树TreeNode的putTreeVal方法,要么遍历链表
            else if (p instanceof HashMap.TreeNode)
                e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {  //采用拉链法解决hash冲突
                        p.next = newNode(hash, key, value, null);
                       //如果链表长度超过8了,就将链表通过treeifyBin转换为红黑树,增加搜索效率
                        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;
    }

        下面来看看treeifyBin方法,看看如何将链表转换为红黑树:

final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
        int n, index; HashMap.Node<K,V> e;
        //如果数组为null或数组长度小于64,则通过resize方法进行扩容
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        //否则进行红黑树转换
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            HashMap.TreeNode<K,V> hd = null, tl = null;
            do {
                HashMap.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);
        }
    }

//TreeNode是红黑树的一个结点,并且也实现了双向链表
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
}

        可以看出来,在put方法中并没有采用同步,如果是多线程操作HashMap可能会带来数据安全问题,所以HashMap并不是线程安全的。

2、HashMap中put是如何实现的?

        这个在上面已经说过了。

3、谈一下hashMap中什么时候需要进行扩容,扩容resize()又是如何实现的

        看完源码后,我觉得扩容分为两种情况:

        (1)数组大小超过了阈值时(threshold = capacity * load factor)

        (2)出现hash冲突后,遍历链表发现长度超过8,数组长度又小于64时

        通过源代码来看看吧:

        首先,第一种情况在putVal方法中,前面也说过,putVal是在put方法中调用的。比方说,数组大小默认为16,负载因子load factor默认为0.75,那么阈值threshold就位16*0.75=12,当数组满12个,准备放入下一个元素,变为13时那就必须要扩容了。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        /......./

        ++modCount;
        if (++size > threshold)  //threshold = capacity * load factor
            resize();
        afterNodeInsertion(evict);
        return null;
    }

        对于第二种情况,其实也在putVal方法中,之前分析过当出现Hash冲突,且结点是链表的结点而不是红黑树的结点时,会进行一个链表的遍历,看下面代码上的注释就能明白了。这里之所以进行扩容,我认为是因为链表长度太长了,说明hash碰撞比较严重,可能这个时候的数组大小并没有超过12,也就是没有超过阈值,但也得进行扩容。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // 链表长度超过8
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
}


final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //数组大小小于64
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
}

        下面再来看看resize方法是怎么扩容的:

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) {    //初始化过
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 容量扩大为两倍
        }
        //判断是否调用过无参构造,调用无参构造就意味着所有的参数采用默认的值
        else if (oldThr > 0) //未调用过
            newCap = oldThr;
        else { //调用过无参构造              
            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) {
               /...遍历链表,将所有元素重新hash复制到新的散列表中.../
            }
        }
        return newTab;
    }

4、什么是哈希碰撞?怎么解决? 

        hash表本质是个数组,数组中存放的是键值对Entry,hash表将Key通过哈希函数计算一个值,通过这个值来确定Entry放在数组中的哪个位置。如果两个不同的Key值经过hash计算后,得到的值相同,也就是需要放到数组中同一个位置,这就导致了冲突,也就是哈希碰撞。

        哈希碰撞的解决方法有两种,开放寻址法和拉链法。

        开放寻址法指的是,如果位置1被占了,那么就看看位置2有没有被占用,如果也占用了就继续向后寻找,直到找到一个没有被占用的位置(如果查找的过程中,发现一个被占用了但是Key相同的位置,就进行覆盖)。

        拉链法适用于链表,这样数组中的元素存放的就不只是键值对Entry了,还有指向下一个元素地址的next指针,如果发生了哈希碰撞,那么就根据计算得到的值,找到相应的数组位置上的链表,进行遍历,在尾部接上这个键值对(和开放寻址法一样,如果查找的过程中找到了Key相同的结点,就直接进行覆盖)。

5、HashMap和HashTable的区别

        相同点:都是存储的键值对Entry

        不同点:

        (1)HashMap允许Key-value为null,HashTable不允许

        (2)HashMap没有考虑同步,是线程不安全的。HashTable是线程安全的,给各个方法都加入了synchronized修饰

        (3)HashMap继承于AbstractMap类,hashTable继承与Dictionary类

        (4)容量的初始值和增加方式都不一样:HashMap默认的容量大小是16;增加容量时,每次将容量变为"原始容量x2"。Hashtable默认的容量大小是11;增加容量时,每次将容量变为"原始容量x2 + 1"

        (5)添加key-value时的hash值算法不同:HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()

6、concurrentHashMap原理

        在多线程环境下,使用HashMap进行put操作时存在丢失数据的情况。而HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下。

        ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,允许多个修改操作并发进行,其关键在于使用了锁分段技术。它使用了多个锁来控制对hash表的不同部分进行的修改。

        对于JDK1.7版本的实现, ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的Hashtable,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry。

        JDK1.7的时候是通过两次hash来完成的,第一次hash先找到segment,对其加锁,第二次hash再找到相应的链表头结点,然后进行各种操作。JDK1.8锁的粒度降低为Entry,主要是通过synchronized和CAS算法实现的。下面是put方法的源码:

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

        和HashMap一样,最终调用了putVal方法:

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh; K fk; V fv;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                //通过CAS尝试添加
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))  
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else if (onlyIfAbsent // check first node without acquiring lock
                     && fh == hash
                     && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                     && (fv = f.val) != null)
                return fv;
            else {
                V oldVal = null;
                synchronized (f) {
                    /...锁住Entry,进行操作.../
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);  //转换为红黑树
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

        putVal方法的大体流程和HashMap差不多,区别就在加锁的这儿。当没有hash冲突就直接CAS插入,如果这个节点,不为空,也不在扩容,则通过synchronized来加锁,进行添加操作。

7、ArrayList和HashMap的区别,为什么取数快

        ArrayList以数组的方式存储数据,里面的元素是有顺序,可以重复的;而HashMap将数据以键值对的方式存储,键Key的哈希码(hashCode)不可以相同,如果相同,后面的值会将前面的值覆盖,Value值可以重复,里面的元素无序

      当HashMap取值的时候,会直接通过hashCode()方法算出相应的数组下标,查找到相应的值之后返回,这个速度是很快的。而当arraylist去按索引查找时,会先调用checkIndex方法,去数组里比对索引是否越界,然后再去找,所以耗时要比HashMap慢一点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值