从本篇开始将发布一个新系列,名字就是”跟我去大厂-面经系列",在这个专题中我会收集现在主流大厂面试知识点并附带答案解析。小伙伴们可以多看(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变成线程安全的
- JDK工具类为我们提供了。Collections.synchronizedList()方法其实底层也是在集合的所有方法之上加上了synchronized(默认使用的是同一个monitor对象,也可以自己指定)。
- Copy On Write 也是一种重要的思想,在写少读多的场景下,为了保证集合的线程安全性,我们完全可以在当前线程中得到原始数据的一份拷贝,然后进行操作。JDK集合框架中为我们提供了 ArrayList 的这样一个实现:CopyOnWriteArrayList。
HashMap1.7与1.8
JDK1.8主要解决或优化了一下问题:
- resize 扩容优化
- 引入了红黑树,目的是避免单条链表过长而影响查询效率
- 解决了多线程死循环问题,但仍是非线程安全的
什么是红黑树
红黑树是一种特殊的二叉查找树。红黑树的每个结点上都有存储位表示结点的颜色,可以是红(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流程
- 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
- 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
- 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
- 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向5;
- 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
- 插入成功后,判断实际存在的键值对数量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,而不是可重入锁
- 减少内存开销
假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
- 获得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点需要注意:
- 如果stackPush要往stackPop中压入数据,那么必须一次性把stackPush中的数据全部压入。
- 如果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);
}
}