首页技术文章正文

面试官:你了解HashMap吗?【Java培训】

更新时间:2022-08-24 来源:黑马程序员 浏览量:

  一、前言:

   面试过的人都知道,HashMap是Java程序员在面试中最最最经常被问到的一个点,可以说,不了解HashMap都不好意思说自己是做Java开发的。基本上你去面试十家公司,有七八家都会问到你HashMap。那么今天,就带着大家从源码的角度去分析一下,HashMap具体是怎么实现的。

  二、HashMap的构造方法

  1.HashMap构造方法

  我们先来看HashMap的四个构造方法

```java
//initialCapacity给定的初始化容量,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);
    }

public HashMap(int initialCapacity) {
        //内部调用了上边的构造方法
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

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

public HashMap(Map<? extends K, ? extends V> m) {//构造传入一个map,将map中的值放到hashmap中
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
```

  2.构造方法里的putMapEntries方法

  刚才构造方法中提到了putMapEntries这个方法,接下来就让我们一起看一下

//该函数用于将一个map赋值给新的HashMap
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    //定义变量接收旧hashmap的size    
    int s = m.size();
        //判断s的容量是否大于0
        if (s > 0) {
            //判断当前数组有没有初始化
            if (table == null) { // pre-size
                ////求出以  旧hashmap数组容量为阈值  的数组容量赋值给ft
                float ft = ((float)s / loadFactor) + 1.0F;
                //判断是不是大于最大容量,如果是,赋值为最大容量,否则将ft赋值给t
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                //判断t是否大于threshold(数组扩容阈值)
                if (t > threshold)
                  //通过tablesizefor方法求出大于等于t的最小2的次幂值赋值给threshold(数组扩容阈值)
                    threshold = tableSizeFor(t);
            }
            //如果数组长度大于扩容阈值,进行resize扩容操作 
            else if (s > threshold)
                resize();
            //循环遍历取出旧hashmap的值放入当前hashmap
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }
/*  
    if (table == null)分支,是判断当前数组是否初始化,因为在jdk1.8之后,只有当你第一次放值时,才会帮你创建16位的数组。
    
    float ft = ((float)s / loadFactor) + 1.0F经过除运算再加上1.0F是为了向上取整
    
    if (t > threshold)注意一个细节,做判断的的时候,数组还没有初始化,这里的threshold的值还是给定的数组长度的值,也就是capacity的值
    
    else if (s > threshold)说明传入的map集合大于当前的扩容阈值,需要进行resize扩容操作
*/
```

  tableSizeFor方法

  刚才putMapEntries方法中,调用了tableSizeFor方法,接下来我们看一下这个tableSizeFor方法。

  作用:创建hashMap对象时调用有参构造,传入初始容量,需要保证hashMap的初始长度为2的n次幂。

  tableSizeFor方法用来计算大于等于并离传入值最近的2的n次幂的数值,比如传入15,算出结果为16,传入17,算出结果为32。

  通过二进制的位移,第一次右移一位,第二次右移两位,第三次右移四位。。。通过五次位移,将范围内所有的位数都变为1,高位补0,最后得出来的结果再加上1,最后算出来的结果一定是2的n次幂。

//这个方法的作用是找到大于等于给定容量的最小2的次幂值
//>>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0
7--》8
16--》16
static final int tableSizeFor(int cap) {
        //先将数组长度减1,之所以在开始移位前先将容量-1,是为了避免给定容量已经是8,16这样2的幂时,不减一       直接移位会导致得到的结果比预期大。比如预期16得到应该是16,直接移位的话会得到32。
        int n = cap - 1;
        //右移一位,在进行或运算,这个时候最高位和次高位就已经都是1,此时是2个1
        n |= n >>> 1;
        //右移两位,在进行或运算,这个时候由上次运算得出的两个1,变成了四个1
        n |= n >>> 2;
        //右移四位
        n |= n >>> 4;
        //右移八位
        n |= n >>> 8;
        //右移十六位,这个时候所有的位数都变为了1
        n |= n >>> 16;
        //n+1操作,是为了进1,这个时候算出来的数值就一点是 2的n次幂
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
```

  移位的思想

  2的整数幂用二进制表示都是最高有效位为1,其余全是0。

  对任意十进制数转换为2的整数幂,结果是这个数本身的最高有效位的前一位变成1,最高有效位以及其后的位都变为0。

  核心思想是,先将最高有效位以及其后的位都变为1,最后再+1,就进位到前一位变成1,其后所有的满2变0。所以关键是如何将最高有效位后面都变为1。

  先移位,再或运算。

  > 右移一位,再或运算,就有两位变为1;

  >

  > 右移两位,再或运算,就有四位变为1,,,

  >

  > 最后右移16位再或运算,保证32位的int类型整数最高有效位之后的位都能变为1。

  三、HashMap的底层原理

  先来看几个重要的参数:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认数组初始容量

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;//树化最小数组容量

//node节点,继承了Map.entry,在Entry原有的K,V的基础上追加了hash和next字段
//分别表示key的hash值和下一个节点
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

}
//重写了计算hash的方法
//将 Hash 值的高 16 位右移并与原 Hash 值取异或运算(^),混合高 16 位和低 16 位的值
//得到一个更加散列的低 16 位的 Hash 值。
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
```

  HashMap在JDK1.8之前的实现方式数组+链表,

  但是在JDK1.8后对HashMap进行了底层优化,改为了由 **数组+链表或者数值+红黑树**实现,主要的目的是提高查找效率

  1. Jdk8数组+链表或者数组+红黑树实现,当链表中的元素大于等于8 个并且数组长度大于等于64以后, 会将链表转换为红黑树,当红黑树节点 小于 等于6 时又会退化为链表。

  2. 当new HashMap()时,底层没有创建数组,首次调用put()方法示时,会调用resize方法,底层创建长度为16的数组,jdk8底层的数组是:Node[],而非Entry[],用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用resize方法将数组扩容到原来的两倍,在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能。

  默认的负载因子大小为0.75,数组大小为16。也就是说,默认情况下,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍。

  3. 在我们Java中任何对象都有hashcode,hash算法就是通过hashcode与自己进行向右位移16的异或运算。这样做是为了使高位的16位hash也能参与运算,使运算出来的hash值足够随机,足够分散,还有产生的数组下标足够随机。

  map.put(k,v)实现原理:

  (1)首先将k,v封装到Node对象当中(节点)。

  (2)先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。

  (3)下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。

  HashMap中的put()方法

```java
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) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //判断数组是否未初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            //如果未初始化,调用resize方法 进行初始化
            n = (tab = resize()).length;
        //通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据
        if ((p = tab[i = (n - 1) & hash]) == null)
            //如果没有,直接将数据放在该下标位置
            tab[i] = newNode(hash, key, value, null);
        //该数组下标有数据的情况
        else {
            Node<K,V> e; K k;
            //判断该位置数据的key和新来的数据是否一样
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到
                e = p;
            //判断是不是红黑树
            else if (p instanceof TreeNode)
                //如果是红黑树的话,进行红黑树的操作
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //新数据和当前数组既不相同,也不是红黑树节点,证明是链表
            else {
                //遍历链表
                for (int binCount = 0; ; ++binCount) {
                    //判断next节点,如果为空的话,证明遍历到链表尾部了
                    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;
                }
            }
            //判断e是否为空(e值为修改操作存放原数据的变量)
            if (e != null) { // existing mapping for key
                //不为空的话证明是修改操作,取出老值
                V oldValue = e.value;
                //一定会执行  onlyIfAbsent传进来的是false
                if (!onlyIfAbsent || oldValue == null)
                    //将新值赋值当前节点
                    e.value = value;
                afterNodeAccess(e);
                //返回老值
                return oldValue;
            }
        }
        //计数器,计算当前节点的修改次数
        ++modCount;
        //当前数组中的数据数量如果大于扩容阈值
        if (++size > threshold)
            //进行扩容操作
            resize();
        //空方法
        afterNodeInsertion(evict);
        //添加操作时 返回空值
        return null;
    }
```

  map中resize方法

//扩容、初始化数组
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //如果当前数组为null的时候,把oldCap老数组容量设置为0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //老的扩容阈值
        int oldThr = threshold;
        int newCap, newThr = 0;
        //判断数组容量是否大于0,大于0说明数组已经初始化
        if (oldCap > 0) {
            //判断当前数组长度是否大于最大数组长度
            if (oldCap >= MAXIMUM_CAPACITY) {
                //如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果在最大长度范围内,则需要扩容  OldCap << 1等价于oldCap*2
            //运算过后判断是不是最大值并且oldCap需要大于16
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold  等价于oldThr*2
        }
        //如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,                如果是首次初始化,它的临界值则为0
        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);
        }
        //初始化容量小于16的时候,扩容阈值是没有赋值的
        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;
                //判断当前下标为j的数组如果不为空的话赋值个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 {
                        //比如老数组容量是16,那下标就为0-15
                        //扩容操作*2,容量就变为32,下标为0-31
                        //低位:0-15,高位16-31
                        //定义了四个变量
                        //        低位头          低位尾
                        Node<K,V> loHead = null, loTail = null;
                        //        高位头          高位尾
                        Node<K,V> hiHead = null, hiTail = null;
                        //下个节点
                        Node<K,V> next;
                        //循环遍历
                        do {
                            //取出next节点
                            next = e.next;
                            //通过 与操作 计算得出结果为0
                            if ((e.hash & oldCap) == 0) {
                                //如果低位尾为null,证明当前数组位置为空,没有任何数据
                                if (loTail == null)
                                    //将e值放入低位头
                                    loHead = e;
                                //低位尾不为null,证明已经有数据了
                                else
                                    //将数据放入next节点
                                    loTail.next = e;
                                //记录低位尾数据
                                loTail = e;
                            }
                            //通过 与操作 计算得出结果不为0
                            else {
                                 //如果高位尾为null,证明当前数组位置为空,没有任何数据
                                if (hiTail == null)
                                    //将e值放入高位头
                                    hiHead = e;
                                //高位尾不为null,证明已经有数据了
                                else
                                    //将数据放入next节点
                                    hiTail.next = e;
                               //记录高位尾数据
                                hiTail = e;
                            }
                            //如果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;
    }
```

  map.get(k)实现原理

  (1)、先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。

  (2)、在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着参数K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。

  HashMap中的get()方法

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
```

  getNode()方法

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //判断数组不为null并且长度大于0,并且通过hash算出来的数组下标的位置不为空,证明有数据
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //判断数组的位置的key的hash和内容是否等同与要查询的数据
            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))))
                        //发现key的话就返回
                        return e;
                } while ((e = e.next) != null);
            }
        }
        //没有查询到返回null
        return null;
    }

  四、HashMap常见面试题分析

  为何HashMap的数组长度一定是2的次幂?

  首先,HashMap的初始化的数组长度一定是2的n次的,每次扩容仍是原来的2倍的话,就不会破坏这个规律,每次扩容后,原数据都会进行数据迁移,根据二进制的计算,扩容后数据要么在原来位置,要么在【原来位置+扩容长度】,这样就不需要重新hash,效率上更高。

  HashMap中,如果想存入数据,首先它需要根据key的哈希值去定位落入哪个桶中

  HashMap的做法,我总结的是,三步:>>>无符号右移、^异或、&与

  具体是:拿着key的哈希值,先“>>>”无符号右移16位,然后“^”异或上key的哈希值,得到一个值,再拿着这个值去“&”上数组长度减一

  最后得出一个数(如果数组长度是15的话,那这个数就是一个0-15之间的一个数),这个数就是得出的数组脚标位置,也就是存入的桶的位置。

  由上边可以知道,定位桶的位置最后需要做一个 “&” 与运算,&完了得出一个数,就是桶的位置

  知道了这些以后,再来说为什么HashMap的长度之所以一定是2的次幂?

  至少有以下**两个原因**:

  1、HashMap的长度是2的次幂的话,可以让**数据更散列更均匀的分布**,更充分的利用数组的空间

  怎么理解呢?下面举例子说一下如果不是2的次幂的数的话假设数组长度是一个奇数,那参与最后的&运算的肯定就是偶数,那偶数的话,它二进制的最后一个低位肯定是0,0做完&运算得到的肯定也是0,那意味着&完后得到的数的最低位一定是0最低位一定是0的话,那说明一定是一个偶数,换句话说就是:&完得到的数一定是一个偶数,所以&完获取到的脚标永远是偶数位,那意味着奇数位的脚标永远都没值,**有一半的空间是浪费的**奇数说完了,来说一下偶数,假设数组长度是一个偶数,比如6,那参与&运算的就是55的二进制 00000000 00000000 00000000 00000101发现任何一个数&上5,倒数第二低位永远是0,那就意味着&完以后,最起码肯定得不出2或者3(这点刚开始不好理解,但是好好想一下就能明白)意味着第二和第三脚标位肯定不会有值。

  虽然偶数的话,不会像奇数那么夸张会有一半的脚标位得不到,但是也总会有一些脚标位得不到的。**所以不是2的次幂的话,不管是奇数还是偶数,就肯定注定了某些脚标位永远是没有值的,而某些脚标位永远是没有值的,就意味着浪费空间,会让数据散列的不充分,这对HashMap来说绝对是个灾难!**

  2、HashMap的长度一定是2的次幂,还有另外一个原因,那就是在扩容迁移的时候不需要再重新通过哈希定位新的位置了。扩容后,元素新的位置,要么在原脚标位,要么在原脚标位+扩容长度这么一个位置。

  比如扩容前长度是8,扩容后长度是16
  第一种情况:
  扩容前:
  00000000 00000000 00000000 00000101
  &00000000 00000000 00000000 00000111 8-1=7
  -------------------------------------
  101 ===== 5 原来脚标位是5
  扩容后:
  00000000 00000000 00000000 00000101
  &00000000 00000000 00000000 00001111 16-1=15
  -------------------------------------
  101 ===== 5 扩容后脚标位是5(原脚标位)
  第二种情况:
  扩容前:
  00000000 00000000 00000000 00001101
  &00000000 00000000 00000000 00000111 8-1=7
  -------------------------------------
  101 ===== 5 原来脚标位是5
  扩容后:
  00000000 00000000 00000000 00001101
  &00000000 00000000 00000000 00001111 16-1=15
  -------------------------------------
  1101 ===== 13 扩容后脚标位是13(原脚标位+扩容长度)
  ```

  扩容后到底是在原来位置还是在原脚标位+扩容长度的位置,主要是看新扩容最左边一个1对应的上方数字是0还是1如果是0则扩容后在原来位置,如果是1则扩容后在原脚标位+扩容长度的位置HashMap源码里扩容也是这么做的。

  总结来说,就是hash&(n-1)这个计算有关。如果不为n不为2的n次方的话,那转换为二进制情况下,n-1就会有某一位为0,那与hash做了&运算后,该位置永远为0,那么计算出来的数组位置就永远会有某个下标的数组位置是空的,也就是这个位置永远不会有值。

  JDK1.8中HashMap的性能优化

  JDK1.8在1.7的基础上对一些操作进行了优化。

  | 不同 | JDK1.7 | JDK1.8 |

  | ------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |

  | 存储结构 | 数组+链表 | 数组+链表+红黑树 |

  | 初始化方式 | 单独函数inflateTable() | 集成至扩容方法resize() |

  | hash值计算方式 | 扰动处理=9次扰动=4次位运算+5次异或运算 | 扰动处理=2次扰动=1次位运算+1次异或运算 |

  | 存放数据规则 | 无冲突时,存放数组;冲突时存放链表 | 无冲突时,存放数组;冲突&链表长度<8:c存放单链表;冲突&链表长度 >8:树化并存放红黑树 |

  | 插入数据方式 | 头插法(将原位置的数据移动到后一位,再插入数据到该位置) | 尾插法 (直接插入链表尾部/红黑树) |

  | 扩容后存储位置的计算方式 | 全部按照原来的方法进行运算(hashCode()->>扰动函数->>(h&length-1)) | 按照扩容后的规则计算(即扩容后位置=原位置或原位置+旧容量) |

  注意:JDK1.8里转换为红黑树的时候,数组长度必须大于64,如果数组长度小于64,链表长度达到8的话,会进行resize扩容操作。

  五、总结

   看到这里,我们已经HashMap的源码和实现有了清晰的理解,并且对它的构造方法再到它的一个put数据和get数据的一个源码解析,还有一些它里边比较精妙和重要的一些方法也都探索清楚了,希望这些知识点可以在大家以后的面试中也能够帮助到大家,斩获一个心仪的offer。

分享到:
在线咨询 我要报名
和我们在线交谈!