跟我去大厂-基础知识

1,082 阅读10分钟

从本篇开始将发布一个新系列,名字就是”跟我去大厂-面经系列",在这个专题中我会收集现在主流大厂面试知识点并附带答案解析。小伙伴们可以多看(bei)看(bei)另外现在无论大厂还是小厂面试都要求手写算法,我也会在系列的每篇文章中按专题的形式提供2个算法题,大家可以复习完对应的面试题之后继续顺道刷一下算法,这样慢慢积累就可以了。话不多说了,来开启我们这篇的面试题-基础知识部分。

常用的集合类

Map接口和Collection接口是所有集合框架的父接口:

  • Collection接口的子接口包括:Set接口和List接口
  • Map接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap以及Properties等
  • Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等
  • List接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector等

List接口下3个子类区别

  • ArrayList  是一个可改变大小的数组.当更多的元素加入到ArrayList中时,其大小将会动态地增长.内部的元素可以直接通过get与set方法进行访问,因为ArrayList本质上就是一个数组.
  • LinkedList  是一个双链表,在添加和删除元素时具有比ArrayList更好的性能.但在get与set方面弱于ArrayList. 当然,这些对比都是指数据量很大或者操作很频繁的情况下的对比,如果数据和运算量很小,那么对比将失去意义.
  • Vector  和ArrayList类似,但属于强同步类。如果你的程序本身是线程安全的(thread-safe,没有在多个线程之间共享同一个集合/对象),那么使用ArrayList是更好的选择。 Vector和ArrayList在更多元素添加进来时会请求更大的空间。Vector每次请求其大小的双倍空间,而ArrayList每次对size增长50%.而 LinkedList 还实现了 Queue 接口,该接口比List提供了更多的方法,包括 offer(),peek(),poll()等. 注意: 默认情况下ArrayList的初始容量非常小,所以如果可以预估数据量的话,分配一个较大的初始值属于最佳实践,这样可以减少调整大小的开销。

如何让ArrayList变成线程安全的

  1. JDK工具类为我们提供了。Collections.synchronizedList()方法其实底层也是在集合的所有方法之上加上了synchronized(默认使用的是同一个monitor对象,也可以自己指定)。
  2. Copy On Write 也是一种重要的思想,在写少读多的场景下,为了保证集合的线程安全性,我们完全可以在当前线程中得到原始数据的一份拷贝,然后进行操作。JDK集合框架中为我们提供了 ArrayList 的这样一个实现:CopyOnWriteArrayList。

HashMap1.7与1.8

JDK1.8主要解决或优化了一下问题:

  1. resize 扩容优化
  2. 引入了红黑树,目的是避免单条链表过长而影响查询效率
  3. 解决了多线程死循环问题,但仍是非线程安全的

什么是红黑树

红黑树是一种特殊的二叉查找树。红黑树的每个结点上都有存储位表示结点的颜色,可以是红(Red)或黑(Black)。

  • 红黑树的每个结点是黑色或者红色
  • 如果一个结点是红色的,则它的子结点必须是黑色的。
  • 每个结点到叶子结点所经过的黑色结点的个数一样的

HashMap初始值为什么是2的n次幂

hash方法实际是让key.hashCode()与key.hashCode()>>>16进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。按照函数注释,因为bucket数组大小是2的幂,计算下标(高效)index = (2的n次幂 - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能。

hashmap的put流程

  1. 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
  2. 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
  3. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
  4. 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向5;
  5. 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
  6. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

hashmap是怎么进行扩容的

这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

为什么HashMap中String、Integer这样的包装类适合作为Key?

String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率

HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标

hashCode()方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置

  • HashMap自己实现了自己的hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;
  • 在保证数组长度为2的幂次方的时候,使用hash()运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配”的问题;

TreeMap

  • TreeMap 是一个有序的key-value集合,它是通过红黑树实现的。
  • TreeMap基于红黑树(Red-Black tree)实现。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。
  • TreeMap是线程非同步的。

HashMap与HashTable

  • 两者最主要的区别在于Hashtable是线程安全,而HashMap则非线程安全。
  • Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null。
  • 实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。
  • 初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。
  • 扩容机制不同:当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
  • 迭代器不同HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。

ConcurrentHashMap 底层具体实现

ConCurrentHashMap 1.8 相比 1.7的话,主要改变为:

  • 去除 Segment + HashEntry + Unsafe 的实现,改为 Synchronized + CAS + Node + Unsafe 的实现其实 Node 和 HashEntry 的内容一样,但是HashEntry是一个内部类。用 Synchronized + CAS 代替 Segment ,这样锁的粒度更小了,并且不是每次都要加锁了,往数组桶位置赋值的时利用 CAS 尝试写入,失败则自旋保证成功,当产生hash碰撞进行往链表或者红黑树上赋值才会加锁。而且只有链表的头节点(红黑树的根节点)需要加锁同步。
  • put()方法中 初始化数组大小时,1.8不用加锁,因为用了个 sizeCtl 变量,将这个变量置为-1,就表明table正在初始化。

ConcurrentHashMap在jdk1.8下为什么使用synchronized,而不是可重入锁 

  1. 减少内存开销 

假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。 

  1. 获得JVM的支持 

可重入锁毕竟是API这个级别的,后续的性能优化空间很小。  synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。

什么是fail-fast

是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值是的话就返回遍历;否则抛出异常,终止遍历。

HashSet实现原理

对于HashSet而言,它是基于HashMap实现的,HashSet底层使用HashMap来保存所有元素,因此HashSet 的实现比较简单,相关HashSet的操作,基本上都是直接调用底层HashMap的相关方法来完成。本质就是HashMap放入key值,value是固定的Obj。

private public protect 描述属性左右范围

算法部分(一)栈和队列

由2个栈组成队列

要求:编写一个类,用两个栈实现队列。满足队列的基本操作 思路:栈的特点是先进后出,而队列的特点是先进先出。我们用两个栈正好能把顺序反过来实现类似队列的操作。但是这有个2点需要注意:

  1. 如果stackPush要往stackPop中压入数据,那么必须一次性把stackPush中的数据全部压入。
  2. 如果stackPop不为空,stackPush绝对不能向stackPop中压入数据。 违反了以上两点都会发生错误。

public class TwoStack {

    Stack<Integer> stackPush = new Stack<>();
    Stack<Integer> stackPop = new Stack<>();

    public void add(int intPush){
        stackPush.push(intPush);
    }

    public int myPeek(){
        if (stackPush.isEmpty() && stackPop.isEmpty()) {
            throw  new RuntimeException("");
        }else{
            if(!stackPush.isEmpty()){
                while (stackPop.isEmpty()){
                    stackPop.push(stackPush.pop());
                }
            }
        }
        return stackPop.peek();
    }
}

用一个栈实现另一个栈的排序

要求:一个栈中元素的类型为整型,现在想将该栈从顶到底按从大到小的顺序排序,只许申请一个栈。除此之外,可以申请新的变量,但不能申请额外的数据结构。 思路:将要排序的栈记为stack,申请的辅助栈记为help。在stack上执行pop操作,弹出的元素记为cur。 其核心逻辑为:

  • 如果help中不为空且cur小于help中栈顶元素那么就把help的栈顶元素弹出放回stack。一次循环判断
  • 如果cur大于等于help栈顶元素就直接放入help中
public class OrderStack {
    public void orderStackVal(Stack<Integer> stack){
        Stack<Integer> help = new Stack<>();

        while (!stack.isEmpty()){
            Integer cur = stack.pop();
            while (!help.isEmpty() && cur < help.peek()) {
                stack.push(help.pop());
            }
            help.push(cur);
        }

        System.out.println(help);
    }

    public static void main(String[] args) {

        OrderStack orderStack = new OrderStack();
        Stack<Integer> stack = new Stack<>();
        stack.push(5);
        stack.push(3);
        stack.push(4);
        stack.push(2);
        stack.push(1);
        orderStack.orderStackVal(stack);
    }

使用递归和栈进行对栈数据进行逆序

要求:例如一个栈依次压入1、2、3、4、5,那么从栈顶到栈底分别为5、4、3、2、1。将这个栈转置后,从栈顶到栈底为 1、2、3、4、5,也就是实现栈中元素的逆序,但是只能用递归函数来实现,不能用其他数据结构。

思路:这个要求用递归实现,所以我们需要有2个递归函数,第一个递归需要查出栈底元素并返回。第二个获取到第一个递归的结果进行放入新的栈中。

public class ReverseOrder {

    //递归查询栈底元素返回
    public static int getStackLast(Stack<Integer> stack) {
        Integer result = stack.pop();
        if (stack.isEmpty()) {
            return result;
        } else {
            int stackLast = getStackLast(stack);
            stack.push(result);
            return stackLast;
        }
    }

    //调用查询栈底的递归
    public static void reverse(Stack<Integer> stack) {
        if(stack.isEmpty()){
            return ;
        }
        int stackLast = getStackLast(stack);
        reverse(stack);
        //递归之后调用下面
        stack.push(stackLast);
        System.out.println(stack);
    }

    public static void main(String[] args) {

        ReverseOrder orderStack = new ReverseOrder();
        Stack<Integer> stack = new Stack<>();
        stack.push(5);
        stack.push(3);
        stack.push(4);
        stack.push(2);
        stack.push(1);
        orderStack.reverse(stack);
    }
}