1. 写在前头
有一肚子的话想说出来,到现在又不知该如何表达了,如果屏幕能传递感情,就好了
我也经历了秋招和春招,把积累的一些心得和知识分享出来,趁着春招还没结束,应该还能给大家一些帮助,在牛课上潜水索取了这么久,也是时候回馈了,这篇帖子开始写于河工大图书馆,也用来纪念为期不多的大学时光
特此感谢@大明宫巴菲特 (之前不是瑶瑶公主吗?哈哈哈),大佬整理的面经我看的七七八八,给我了不少帮助,非常感谢了,大家可以关注一下!!!
下面的一些经验不一定全对也不一定全部有用,也仅仅是把我知道的一些技巧性的东西分享出来罢了,如果能对大家产生一点点帮助,都是我无比荣幸的事情
2. 春招历程(截至2021年3月31日)
- 字节跳动 大力教育后端开发 北京:2月17日牛客内推投递
2月23日一面 被面试官发现非科班,基本上问的全是计算机网络和操作系统相关 挂 - 京东物流 Java开发工程师 北京:2月17日官网内推码投递
3月3日 一面 一个多小时,口干舌燥也畅快琳琳 过
3月4日 二面 四十多分钟,问的比较发散,不局限于八股文,更关注具体场景业务分析 过
3月10日 HR面 大概只有六七分钟
3月12日 查询状态变为HR面完成,Offer灯亮起
3月15日 收到正式Offer - 美团 支付部门后端开发 北京:3月4日牛客内推投递
3月13日 笔试,2AC,剩下3道题每题只骗了18%
3月19日 一面,体验非常好的一次面试,面试官很和气,过
3月22日 二面,时间蛮长的,一个多小时,过
3月31日 三面(HR面),十七分钟,薛定谔的美团面试结果 - 笔试过没回应的:携程,VIVO,跟谁学
投了没有回应的:华为,OPPO,陌陌,作业帮,小米,搜狗
给了笔试没笔的:顺丰科技,猿辅导,好未来,滴滴,便利蜂
3. 整理出来的面经
3.1 Java相关
3.1.1 ArrayList
使用场景:ArrayList的底层是一个数组,适合快速匹配,不适合频繁的增删
允许add null 值,会自动扩容,其中size(),isEmpty(),get(),add()方法的复杂度为O(1)
使用Collentions.synchronizedList(),实现线程安全或者Vector也可(Vector在方法上加的synchronized锁)
调用无参构造函数的时候,在JDK1.8默认为空数组(DEFAULT_EMPTY_ELEMENTDATA = {}),数字大小为10是我们第一次调用add方法是进行扩容的数组大小
若我们在执行构造函数传入的数组大小为0时,它使用的不是DEFAULT_EMPTY_ELEMENTDATA,而是另一个空数组EMPTY_ELEMENTDATA = {}(这个知识点面试没说过)add方法的过程
先确定数组大小是否足够,如果我们创建ArrayList的时候指定了大小,那么则以给定的大小创建一个数组,否则默认大小为10;容量够大的情况,直接赋值;如果容量不够大,则进行扩容方法grow(),扩容的大小为原来大小的1.5倍(newCapicity = oldCapicity + oldCapicity >> 1,其中>>1,右移一位除以2),如果扩容后的大小还不够的话,则会将数组大小直接设置为我们需要的大小,扩容的最大值为Integer.MAX_VALUE,之后会调用Arrays.copyOf()方法将原数组中的数组复制过来
其中Arrays.copyOf()底层调用的是System.arrayCopy(),大家可以去简单了解下remove方法
该方将被删除位置后的元素向前复制,底层调用的也是System.arrayCopy()方法,复制完成后,将数组元素的最后一个设置为null(因为向前复制一个位置,所以最后位置的元素是重复的),这样就解决了复制重复元素的问题迭代器和增强for是一样的(这是一个Java语法糖,我后边还会再写语法糖相关的),过程中会判断modCount的值是否符合循环过程中的期望,如果不符合的话则会抛出并发修改异常,比较常见的情况就是在增强for中进行删除操作
3.1.2 LinkedList
使用场景:适合增删,不适合快速匹配
底层数据结构是双向链表,每一个节点为Node,有pre和next属性
提供从头添加和从尾添加的方法,节点删除也提供了从头删除和从尾删除的方法
3.1.3 HashMap
底层数据结构:数组 + 链表 + 红黑树
允许put null 值,HashMap在调用hash算法时,如果key为null,那么hash值为0,这一点区别于HashTable和ConcurrentHashmap
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);loadFactor:负载因子默认为0.75,是均衡了时间和空间损耗计算出来的,较高的值会减少空间的开销,扩容减小,数组大小增加速度变慢,但是增加了查找的成本,hash冲突增加,链表变长
如果有很多需要储存到HashMap中的数据,要在一开始把它的容量设置为足够大,防止出现不断扩容
通过Collections.synchronizedMap()来实现线程安全或者使用ConcurrentHashmap
需要记住的字段如下
DEFAULT_INITIAL_CAPICITY = 1 << 4; 默认大小为16 MAXIMUM_CAPACITY = 1 << 30; 最大容量 DEFAULT_LOAD_FACTOR = 0.75f; 默认负载因子 TREEIFY_THRESHOLD = 8; UNTREEIFY_THRESHOLD = 6; 树化和退化为链表的阈值 MIN_TREEIFY_CAPACITY = 64; 链表转化为红黑树时需要的数组大小 threshold 表示扩用的阈值,大小为 数组大小*负载因子
put过程
首先会判断数组有没有进行初始化,没有的话,先执行初始化操作,resize()方法
(n - 1) & hash用来定位到数组中具体的位置,如果数组中的该位置为空,直接在该位置添加值
如果数组当前位置有值的话,如果是链表,采用的是尾插发,并且当链表长度大于等于8时,会进行树化操作;如果是红黑树的话,则会调用红黑树的插入值的方法;添加完成后,会判断size是否大于threshold,是否需要扩容,若扩容的话,数组大小为之前的2倍大小,扩容完成后,将原数组上的节点移动到新数组上。
一篇我觉得写得不错的博客儿:HashMap扩容时的rehash方法中(e.hash & oldCap) == 0算法推导为什么树化操作的阈值是8?
链表的查询时间复杂度为O(n),红黑树的查询时间复杂度为O(logn),在数据量不多的时候,使用链表比较快,只有当数据量比较大的时候,才会转化为红黑树,但是红黑树占用的空间大小是链表的2倍,考虑到时间和空间上的损耗,所以要设置边界值(其实链表长度为8的概率很低,在HashMap注释中写了,出现的概率不择千万分之一,红黑树只是为了在极端情况下来保证性能)为什么还要有一个阈值是6?(去年面试快手的时候问过)
避免频繁的进行树退化为链表的操作,因为退化也是有开销的,当我们移除一个红黑树上的值的时候,如果只有阈值8的话,那么它会直接退化,我们若再添加一个值,它有可能又需要变为红黑树了,添加阈值6相当于添加了一个缓冲hash算法
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16),右移16位的操作使得hash值更加分散为什么数组大小始终为2的n次幂?
因为在确定某个值在数组位置的下标时,采用的是(数组大小 - 1)位与上hash值,而数组大小减一之后,用2进制表示最后几位都是1,这样每位在位与运算之后,不是0就是1,如果我们hash值是均匀分布的话,那么我们得到的数组下表也是均匀分布的,而如果我们的数组容量不是2的n次幂,那么就没有这个特性了数组大小为什么默认是16?
16是一个经验值,2,4,8有些小,会频繁的扩容,32有些大,这样就多占用了空间为什么JDK1.8采用了尾插法?
JDK1.7时采用的是头插法,它在扩容后rehash,会使得链表的顺序颠倒,引用关系发生了改变,那么在多线程的情况下,会出现链表成环而死循环的问题,而尾插法就不会有这样的问题,rehash后链表顺序不变,引用关系也不会发生改变,也就不会发生链表成环的问题红黑树的5个特点
根节点是黑***r>所有叶子节点是黑***r>其他节点是红色或黑***r>从每个叶子节点到根节点所有路径上不能有两个连续的红色节点;
从任一节点到每个叶子节点的所有简单路径上包含相同数量的黑色节点HashMap和Hashtable的区别
实现方式不同:Hashtable:继承了Dictionary类,而HashMap继承的是AbstractMap类
初始容量不同:HashMap的初始容量为16,Hashtable为11,负载因子都是0.75
扩容机制不同:HashMap是翻2倍,Hashtable是翻两倍+1
3.1.4 HashSet、TreeMap、TreeSet、LinkedHashMap、LinkedHashSet
- HashSet底层基于HashMap实现,若想实现线程安全,需要使用Collections.synchronizedSet();
它在底层组合的HashMap,并没有继承关系,其中Value值使用的都是被声明为Object的PRESENT对象
private static final Object PRESENT = new Object(); - TreeMap的底层数据结构是红黑树,会对key进行排序,维护key的大小关系
我们可以传入比较器Comparator或者让作为key对象的类实现Comparable接口重写compareTo方法
禁止添加null值 - LinkedHashMap 本身继承了HashMap,拥有HashMap的所有特性,在此基础上添加了两个新的特性:
能按照插入的顺序进行访问(不过它仅仅提供了单向访问,即按照插入的顺序从头到尾访问);
能实现访问最少最先删除的功能(LRU算法) - LinkedHashSet 底层基于LinkedHashMap实现
3.1.5 ConcurrentHashMap(JDK1.8)
- 底层基于CAS + synchronized实现,所有操作都是线程安全的,允许多个线程同时进行put、remove等操作
- 底层数据结构:数组、链表和红黑树的基础上还添加了一个转移节点,在扩容时应用
- table数组被volatile修饰
- 其中有一个比较重要的字段,sizeCtl
= -1 时代表table正在初始化
table未初始化时,代表需要初始化的大小
table初始化完成,表示table的容量,默认为0.75table大小 - put过程
key和value都是不能为空的,否则会产生空指针异常,之后会进入自旋(for循环自旋),如果当前数组为空,那么进行初始化操作,初始化完成后,计算出数组的位置,如果该位置没有值,采用CAS操作进行添加;如果当前位置是转移节点,那么会调用helptransfer方法协助扩容;如果当前位置有值,那么用synchronized加锁,锁住该位置,如果是链表的话,采用的是尾插发,如果是红黑树,则采用红黑树新增的方法,新增完成后需要判断是否需要扩容,大于sizeCtl的话,那么执行扩容操作 - 初始化过程
在进行初始化操作的时候,会将sizeCtl利用CAS操作设置为-1,CAS成功之后,还会判断数组是否完成初始化,有一个双重检测的过程
过程:进入自旋,如果sizeCtl < 0, 线程礼让(Thread.yield())等待初始化;否则CAS操作将sizeCtl设置为-1,再次检测是否完成了初始化,若没有则执行初始化操作 - 在JDK1.7采用的是Segment分段锁,默认并发度为16
3.1.6 CopyOnWriteArrayList
- 线程安全的,通过锁 + 数组拷贝 + volatile 保证线程安全(底层数组被volatile修饰)
- 每次进行数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作之后再赋值回去
- 对数组的操作,一般分为四步
1 加锁
2 从原数组中拷贝出新数组
3 在新数组上进行操作,并把新数组赋值给原引用
4 解锁 - 已经加锁了,为什么还需要拷贝新数组?
因为在原数组上进行修改,没有办法触发volatile的可见性,需要修改内存地址,即将新拷贝的数组赋值给原引用 - 在进行写操作的时候,是能读的,但是读的数据是老数组的,能保证数组最终的一致性,不能保证实时一致性;
- 存在内存占用问题,写时复制比较影响性能
3.1.7 String
- 不变性:类被final修饰,不可被继承;String中保存的是一个字符数组,被final修饰,这样它的内存地址是不能改变的,另外它的访问权限是private,外部无法访问,也没有公开出对其直接修改的API,所以能保持不变
- equals方法得看一看
public boolean equals(Object anObject) { if (this == anObject) { //内存地址一致的话,为true return true; } //判断是不是String类 if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; //判断字符串长度是否相等,不等直接返回不等 if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; //依次比较每个字符 while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; }
3.1.8 基本类型包装类
- 自动装箱与拆箱是Java语法糖,发生在编译期(深入理解JVM中的前端编译优化)
- Character的缓存为0-127;Byte、Short、Integer、Long的缓存为 -128-127,若使用的值是这个范围的值,则直接在缓存中取
- float和double在计算中发生精度损失的问题
十进制数能转化为二进制数;而小数有时候不能用二进制数进行表示,会造成精度丢失
解决办法:使用BigDecimal,传入构造函数的参数是String
3.1.9 hashCode()和equals()方法
- hashCode是Object类中一个被native修饰的方法,通常是将对象的内存地址转换为整数后返回
- 为什么重写hashCode必须重写equals?
两个对象相等,hashCode一定相等;而hashCode相等,两个对象不一定相等,需要用equals进一步比较
3.1.10 封装、继承和多态
- 你是如何理解面向对象的三个特征的?(京东一面问过)
面向对象的特性是封装、继承和多态,封装就是将一类事物的属性和行为抽象成一个类,使其属性私有化,行为公开化,提高了数据的隐秘性的同时,使代码模块化,这样做使得代码的复用性更高;继承则是进一步将一类事物共有的属性和行为抽象成一个父类,而每一个子类是一个特殊的父类–有父类的行为和属性,也有自己特有的行为和属性,这样做扩展了已存在的代码块,进一步提高了代码的复用性;多态是为了实现接口重用,多态的一大作用就是为了解耦,允许父类引用(或接口)指向子类(或实现类)对象。多态的表现形式有重写和重载 - 说说重写和重载
重写发生在父类与子类之间,方法名相同,参数列表相同,返回值可以“变小”,抛出的异常可以“变小”,访问修饰符权限不能变小,发生在运行期
重载实在一个类中,方法名相同,参数列表不同(参数顺序不同也行),返回值和访问修饰符可以不同,发生在编译期
3.1.11 反射
- 对于任何一个类,都能获取它的方法和属性,动态获取信息和动态调用方法的功能是反射
3.1.12 Java语法糖(《深入理解JVM 第三版》第10章 前端编译优化)
- 泛型 Java选择的是“类型擦除似泛型”,在.java源代码经过编译成.class文件后,泛型相关的信息就消失了,泛型是在编译器层面来保证的
泛型上界 <? extends T>, 编译器指导里边存的是T的子类,但是不知道是什么具体的类型,只能取,不能往里放
泛型下界 <? super T>, 能往里放,也能往外拿,但是拿出来的全是Object类型,这就使得元素类型失效了 - 自动装箱和拆箱
- 增强for循环,编译后会变为使用迭代器的形式
- 条件编译,在if条件中,若条件为布尔常量,编译器会把分支中不成立的代码消除掉
- 字符串拼接,编译时会自动创建StringBuilder对象执行append方法拼接
- 枚举,编译器会自动创建一个被final修饰的枚举类继承了Enum,所以自定义枚举类型是无法被继承的
- 还有其他的语法糖,lambda表达式等等,大家感兴趣可以再去了解...
3.2 JVM (《深入理解JVM 第三版》 紫皮儿的,求求了,看看吧,真的挺好的)
3.2.1 Java内存区域
- 程序计数器:当前线程的字节码行号指示器,字节码解释器的工作就是通过改变计数器的值来来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
线程私有,没有OutOfMemoryError情况 - Java虚拟机栈:Java方法执行的线程内存模型,每个方法被执行的时候,Java虚拟机都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接、方法出口等信息
局部变量表中存储的是基本数据类型,对象的引用和returnAddress类型
线程私有,生命周期与线程相同
如果线程请求的栈深度大于虚拟机所允许的深度,会发生StackOverflowError,若栈容量支持动态扩展,那么可以发生OutOfMemoryError情况,在HotSpot虚拟机中不会发生OutOfMemoryError - 本地方法栈:为被native修饰的方法提供服务,与虚拟机栈类似
- Java堆:所有对象实例以及数组都在堆上分配内存,也是垃圾回收器主要管理的内存区域
被所有线程共享的一块区域,当堆内存不够用时,会抛出OutOfMemoryError - 方法区
用于储存被虚拟机加载的:类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据;在JDK1.8采用元空间来实现
也是线程共享的区域,垃圾回收主要针对常量池的回收和类型卸载,但是类型卸载比较苛刻
当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError - 类型卸载的条件
该类所有实例已经被回收
加载该类的类加载器已经被回收(Java自带的三个类加载器不会被回收,那么只有我们自己创建的类加载器加载的类型能被回收)
该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
3.2.2 对象的创建过程?
- 当Java虚拟机遇到一条new指定后,首先检查这个这个指定的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过(简单点儿说,首先检查对应的类型是否被加载过),若没有,则需要先执行类加载的过程,类加载通过后,为新生对象分配内存,并附上初始值0值并设置对象头信息,之后执行构造函数,进行对象的初始化,完成对象的创建
- 对象的组成?
对象头:MarkWord和类型指针(确定该对象是哪个类的实例)
实例数据:存储各种类型的字段
对其填充:任何对象都是8字节的整数倍,不足时用它来补齐,非必须有 - 啥是MarkWord?
MarkWord是一个有着动态定义的数据结构,包括哈希码,GC分代年龄,线程持有的锁,偏向线程Id,偏向时间戳等(图:《深入理解JVM 》 p482)
3.2.3 内存溢出你给我说说?OutOfMemoryError和StackOverFlowError
- 如何产生OutOfMemory?
堆内存不够用了,会抛出这个OutOfMemoryError - 你能用什么方法来抛出这个Error?
可以通过把堆内存通过参数-Xmx调小一些,然后写一个while的死循环,不断的执行append操作 - 那如何产生Stack Overflow Error?
这个是栈溢出,我们可以通过写两个方法,A方法调用B方法,B方法在调用A方法,这样可以产生这个Error - 你还知道其他的JVM参数嘛?
知道,-Xms指定堆的初始大小,-Xss指定栈的大小,-XX:+HeapDumpOnOutOfMemoryError内存快照的Dump文件,可以分析Dump文件来查看OutOfMemoryError - 列举一些垃圾回收的参数(大家随便看看吧,面试没人问过我)
指定期望的GC的停顿时间(在Parallel Scavenge、Parallel Old和G1回收器中指定):-XX:MaxGCPauseMills
改变G2的Rigion容量:-XX:G1HeapRegionSize
年轻代大小:-Xmn
比例:-XX:SurvivorRatio=8(8:1:1)
大对象直接进入老年代的阈值:-XX:PretenureSizeThreshold
3.2.4 判断对象已死的算法?
- 引用计数算法
Java没有采用这种算法,如果产生对象的循环引用会使对象无法被回收 - 可达性分析算法
从GC Roots根据引用关系乡下搜索,搜索过程中所走过的路径为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能再使用的 - 你给我说说啥样的对象是GC Roots?(《深入理解JVM 》 p70)
在虚拟机栈中引用的对象
在方法区中静态属性引用的对象
在方法区中常量引用的对象
在本地方法栈中引用的对象
Java虚拟机内部的引用(基本数据类型对应的Class对象,一些常驻的异常对象:NullPointException,OutOfMemoryError,还有系统类加载器)
被同步锁持有的对象
反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等(面试我从没有说过这一条,再往下问我我不知道该怎么解释) - 对象不能被GCRoots引用关联就立即被回收嘛?
其实也不是的,通过可达性分析算法分析后发现对象没有和GCRoots发生引用,那么它会第一次被标记为可回收,对象可以实现finalize()方法,如果在该方法中能够使得对象再次发生与GCRoots引用,那么便可以避免被回收,这个方法只会被调用一次
3.2.5 引用关系 (《深入理解JVM 》 p71)
- 强引用:引用赋值操作 Object o = new Object(); 无论什么情况下只用有强引用关系存在,那么对象就不会被回收
- 软引用:SoftReference,还有用但是非必须的对象,在发生内存溢出前,会对这些对象进行回收,如果回收完成后再不够用,便抛出内存异常错误
- 弱引用:WeakReference,在进行垃圾回收时,不论当前内存是否够用,都会将该引用的对象回收掉
- 虚引用:PhantomReference,最弱的引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例,为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
3.2.6 垃圾回收算法
- 标记复制算法:年轻代采用的都是标记复制算法,当一块内存用完了就把仍然存活的对象复制到另一块内存, 必然会产生一定空间的浪费,但是不会出现空间碎片的情况
- 为什么HotSpot虚拟机采用8:1:1的比例分区?
根据绝大多数的对象都熬不过第一轮GC,Hotspot采用8:1:1的分配策略,90%的空间为新生代可用内存空间,浪费的是10%,符合对象朝生夕灭的特点,但是如果10%的内存不够用时,有逃生门策略来分配对象(逃生门指的是不够分配的对象直接到老年代) - 跨代引用的问题是如何解决的?(《深入理解JVM 》 p84)
垃圾回收器在新生代中建立了记忆集数据结构,用来避免进行垃圾回收的时候把整个老年代的GC Roots都扫描一遍(卡表是记忆集的一种具体实现) - 标记清除算法:CMS垃圾回收器采用的算法,这种算***产生空间碎片
- 标记整理算法:让所有的存活对象都向内存空间的一端移动,然后清理掉边界以外的内存,没有空间碎片的烦恼
3.2.7 几种垃圾回收器
- Serial,面向年轻代的,单线程的的垃圾回收器,采用的是标记复制算法,在进行垃圾回收的时候,必须执行Stop the world
- ParNew,实际上是Serial的多线程版本,同样是标记复制算法,也需要在垃圾回收的时候Stop the world
- Parallel Scavenge,面向年轻代,也是多线程的,关注的是达到一个可控制的吞吐量,采用的是标记复制算法,也需要在垃圾回收的时候Stop the world
- Serial Old,Serial的老年代版本,采用的是标记整理算法,执行垃圾回收需要Stop the world
- Parallel Old,是Parallel Scavenge的老年代版本,支持多线程并行收集,采用标记整理算法,同样也是关注吞吐量
- CMS,获取最短回收停顿为目标,更加关注服务器的响应速度,希望给用户更好交互体验,采用的是标记清除算法,执行过程分为如下四步(两停顿两并发),会产生空间碎片,无法解决“浮动垃圾”
1 初始标记:标记GC Roots直接关联的对象,需要Stop the world
2 并发标记:从GC Roots遍历能引用到的所有对象
3 重新标记:对并发标记阶段的标记进行修正,需要Stop the world
4 并发清除:与用户线程一起运行,执行垃圾回收 - Garbage First,一个浪漫的名字,它是一款面向服务器端应用的垃圾回收器,发布的初衷是为了替代掉CMS垃圾回收器,它的垃圾回收机制是面向整个堆,并将其划分为各个大小相等的Region,采用的算法是标记复制算法,它会维护一个优先级列表,根据我们设置的停顿时间来选择回收收益最大的Region进行垃圾回收,将存活的对象复制到空的Region中,通过设置停顿时间可以达到在吞吐量和响应速度上的协调,它还有一个Humongous区域,只要对象大小超过Region的一半,便直接放在这个区域中,它的执行过程为以下四个步骤(三停顿一并发)
1 初始标记:标记GC Roots直接关联的对象,需要Stop the world
2 并发标记:从GC Roots遍历能引用到的所有对象 (前连个阶段和CMS基本一致???)
3 最终标记:处理并发标记后的修正操作,需要Stop the world
4 筛选回收:对各个Rigion的回收价值进行排序,根据用户期望的停顿时间按计划回收,并将被回收的Region中存活的对象复制到空的Region中,再清理掉旧的Region,需要Stop the world - Shenandoha和ZGC这个不大问
3.2.8 类加载(《深入理解JVM 》 第七章)
讲讲类的生命周期
类的生命周期是七个阶段,首先类的加载,然后是连接,连接阶段分为三个步骤,是验证、准备和解析,连接完成之后是初始化,完成初始化之后是类的使用,最后是类的卸载说说类加载的过程
类加载分三个阶段,加载、连接和初始化,其中连接阶段分为验证、准备和解析。
1 加载主要是加载二进制字节流,比如Class文件,在方法区中生成Class对象
2 验证阶段是确保Class文件中的字节流包含的信息是否符合《Java虚拟机规范》的全部约束要求,保证这些信息不会危害虚拟机的安全
(有文件格式验证、元数据验证、字节码验证、符号引用验证,我面试从没被问过具体的这几个阶段)
3 准备阶段是为类中定义的变量(静态变量)分配内存并设置变量的初始值,但是有一种特殊情况,被final修饰的话,则会直接赋值为我们要指定的值(初始值!就是0,false,null那种,初始化阶段才是我们程序员写的值,谨记)
4 解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,有类和接口的解析,字段解析、方法解析和接口方法解析(符号引用是以一组符号来描述所引用的目标,它是编译原理方面的概念,有被模块到处或者开放的包,类和接口的全限定名,字段的名称和描述符,方法的名称和描述符,方法的句柄和方法类型,动态调用点和动态常量 《深入理解JVM 》 p218;直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄)
5 初始化阶段是类加载的最后一个步骤,它会收集所有为类变量赋值和静态语句块中的语句,为这些静态变量赋值类加载的触发条件
1 使用new关键字实例化对象的时候
2 读取或设置一个类型的静态字段
3 调用一个类型的静态方法
4 使用反射的方法对类型进行反射调用的时候
5 进行类初始化时,如果父类没有初始化,要先触发其父类的初始化
6 当一个接口中定义了JDK8加入的默认方法,如果有这个接口的实现类发生了初始化,那么接口需要在这之前完成初始化比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被用一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
双亲委派机制
工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父类反馈自己无法完成这个加载请求,子加载器才会尝试自己去完成类加载的过程。双亲委派机制的作用:使得Java中的类随它的类加载器一起具备了层级关系,例如Object类,无论哪个类加载器要加载这个类,最终都会被启动类加载器加载,这样就使得Object类在类加载环境中始终是同一个类,若没有双亲委派机制的话,我们自己定义一个在java.lang目录下的Object类,那么系统中就会出现多个Object类
如何破坏双亲委派机制?
重写ClassLoader中的loadClass()方法
3.2.9 如果大家有书的话,可以看看《深入理解JVM 》的第十章和第十一章,前端编译优化和后端编译优化,可能第十章的语法糖能在面试的时候被问一下,第十一章就不大问,不过我实习的时候,带我的mentor说还蛮重要的
3.2.10 JVM对锁的优化(《深入理解JVM 》 p479)
- 自旋锁与自适应自旋:互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,如果我们可以通过上后面请求锁的线程自旋一会儿,那么将会避免线程切换的开销,但是它还是要占用处理器时间的,如果自旋时间很短的话,它的效果很好,否则长时间的自旋只会白白的浪费处理器时间,自旋的默认值是10次;自适应自旋意味着自旋次数不再是固定的了,而是由前一个在同一个锁上的自旋时间及锁的拥有者的状态来决定的,如果很可能获得到锁,那么将自旋等待的次数增多,否则直接省略掉自旋过程,进入阻塞状态,避免浪费处理器资源
- 锁消除:对被检测到不可能存在共享数据竞争的锁进行消除
public String method(String s1, String s2, String s3) { StringBuffer stringBuffer = new StringBuffer(); stringBuffer.append(s1); stringBuffer.append(s2); stringBuffer.append(s3); return stringBuffer.toString(); }
- 锁粗化:如果一系列连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,虚拟机将会把同步锁的范围扩大到整个操作序列的外部,比如上例中,加锁到第一个append操作,解锁到最后一个append结束
- 轻量级锁:是通过MarkWord来实现的,在进入同步块的时候,虚拟机会在当前线程的栈帧空开辟出锁记录的空间,用来存储锁对象的MarkWord的拷贝,加锁操作是使用一次CAS操作把对象的MarkWord更新为指向锁记录的指针,解锁操作也是通过一次CAS操作实现的,把复制到锁记录空间的MarkWord替换回来;但是轻量级锁不能发生竞争,如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效了,必须要膨胀为重量级锁
- 偏向锁:在无竞争的情况下把整个同步都消除掉,连CAS操作都省去了,(偏就是偏袒的意思,会偏向第一个获取到它的线程,如果在接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将永远不再需要进行同步),当锁对象第一次被线程获取的时候,通过CAS操作把获取到这个锁的线程ID记录到对象的MarkWord中,CAS成功的话,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都不会再进行任何同步操作,一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式立刻宣告结束。
当对象进入偏向状态的时候,MarkWord大部分空间都用于存储持有锁的线程ID了,若计算一次哈希值后,就需要在该位置存储哈希值,而不能再进入偏向锁模式了,而当一个对象处于偏向锁模式,又收到了需要计算其一致性哈希值的请求,它的偏向模式会理解被撤销,并且锁会膨胀为重量级锁
3.3 Java并发编程
3.3.1 创建线程的3种方式
public class StarThread1 extends Thread{ @Override public void run() { System.out.println("开启线程的第一种方式,继承Thread并重写它的run方法"); } public static void main(String[] args) { StarThread1 starThread1 = new StarThread1(); starThread1.start(); } }
public class StarThread2 implements Runnable{ @Override public void run() { System.out.println("实现runnable接口,重写run方法,并在开启线程时将其传入"); } public static void main(String[] args) { new Thread(new StarThread2()).start(); } }
public class StarThread3 implements Callable<Void> { @Override public Void call() { System.out.println("创建线程的第三种方式,实现Callable接口"); System.out.println("用Callable接口创建任务,用线程池对其就行提交,返回值为Future,再调用get()方法,获取结果"); System.out.println("或者将其作为参数传入新建的线程中"); return null; } public static void main(String[] args) throws ExecutionException, InterruptedException { ExecutorService pool = Executors.newFixedThreadPool(1); Future<Void> task = pool.submit(new StarThread3()); task.get(); pool.shutdown(); System.out.println("--------------------------------------"); StarThread3 starThread3 = new StarThread3(); FutureTask<Void> task1 = new FutureTask<>(starThread3); new Thread(task1).start(); } }
3.3.2 线程池相关
import java.util.concurrent.*; public class LearnThreadPoolExecutor { public static void main(String[] args) throws InterruptedException { //CPU密集型,设置最大线程数为:CPU核数 Runtime.getRuntime().availableProcessors(); //IO密集型,根据io任务的线程数来规定最大线程数量 //核心线程数 //最大线程数,线程池的伸缩性,达到开启条件后,才会不断开启 //开启条件:当阻塞队列是ArrayBlockingQueue的时候,核心线程全部都处于工作状态,↓ //且阻塞队列已经被任务塞满了,那么再来新的任务请求,便会开启新的线程 //若是LinkedBlockingQueue的话,它会不断的存储任务,永远都不会向最大线程数进行线程的扩展!!! //活跃时间和活跃时间的单位,当线程的空闲时间超过活跃时间,线程就会被回收 //阻塞队列:全部核心线程处于忙碌状态,新来的任务放在阻塞队列中 // 最大承载:队列大小(如果是ArrayBlockingQueue的话)+最大的线程数 //线程工厂,用于创建线程 //拒绝策略:AbortPolicy:超过最大承载的话,会发生异常RejectedExecutionException //CallerRunsPolicy:哪来的去哪里执行,这里安排不了了 //DiscardPolicy():多的任务都给我扔了,不执行! //DiscardOldestPolicy():将最早执行的任务停止掉,来执行新来的任务 ExecutorService threadPool = new ThreadPoolExecutor(3, 5, 5, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(3), Executors.defaultThreadFactory(), new ThreadPoolExecutor.DiscardOldestPolicy()); for (int i = 0; i < 33; i++) { int finalI = i; threadPool.execute(() -> { System.out.println(Thread.currentThread().getName() + " ok " + finalI); }); } threadPool.shutdown(); //线程池的作用:统一管理线程,实现线程的复用,更好的利用系统资源 //四大方法,单个线程的池子;固定线程数的池子;自由伸缩的池子;执行定时任务的池子 //前两个的阻塞队列为LinkedBlockingQueue //CachedThreadPool的阻塞队列为SynchronousQueue ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); ExecutorService fixedThreadPool = Executors.newFixedThreadPool(8); ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(10); for (int i = 0; i < 33; i++) { cachedThreadPool.execute(() -> { System.out.println(Thread.currentThread().getName()); }); } //关闭别忘了 cachedThreadPool.shutdown(); //TODO 线程池的五种状态 //RUNNING 接收新任务并处理排队中的任务 //SHUTDOWN 不接受新任务,处理剩下的任务 //STOP 不再接收新任务,不处理剩下的任务 //TIDYING 所与线程都执行完了 //TERMINATED 线程池终止了 //TODO 线程池的执行流程 //1. 如果要执行的线程小于核心线程数的话,开启核心线程,直接执行 //2. 如果大于核心线程数的话,将进程放入阻塞队列中进行排队 //3. 如果队列中满了话,会开启临时线程执行线程任务 //4. 如果线程任务超过最大的阈值,也就是大于最大线程数+阻塞队列的值的话,就会采用拒绝策略 //判断是否停止了 cachedThreadPool.isTerminated(); //等待3秒后再进行判断 cachedThreadPool.awaitTermination(3, TimeUnit.SECONDS); cachedThreadPool.execute( () -> System.out.println("在shutDown之后,将不再能继续执行任务")); //强制的立即结束 fixedThreadPool.shutdownNow(); } }
美团技术团队的博客:Java线程池实现原理及其在美团业务中的实践
3.3.3 锁!
synchronized的原理
依赖对象头中的MarkWord和monitor监视器,在Hotspot虚拟机中,是ObjectMonitor对象
其中MarkWord是实现偏向锁和轻量级锁的关键
monitor是实现重量级锁的原理,当系统检测到是重量级锁的时候,会把等待想到获取锁的线程进行阻塞,被阻塞的线程不会消耗CPU,但是阻塞和唤醒一个线程时,都需要操作系统来实现,而要完成用户态与内核态之间的转换,状态转换的开销会很大,对应的字节码指令是monitorenter和monitorexitsynchronized和Lock的区别
1 synchronized是java的关键字,在JVM层面;Lock是Java的接口
2 synchronized是非公平锁;Lock可以设置为公平锁;都是可重入锁
3 synchronized被线程获取后,其他线程必须等待;Lock可以使用tryLock方法尝试获取锁,获取不到可以不等待
4 synchronized会自动释放锁;Lock需要在finally块中手动释放锁
5 ReentrantLock可以绑定多个锁条件(Condition)ReentrantLock的原理(你了解AQS嘛?)
ReentrantLock的静态内部类Sync实现了抽象类AQS(AbstractQueuedSynchronizer),其中有一个重要的字段是state,它在ReentrantLock中代表的是重入次数,为0是代表锁没有被任何线程持有,为1是被一个线程持有,每重入一次,state加一,每执行一次unlock方法,state减一;而ReentrantLock的公平锁和非公平锁机制是通过AQS中的队列来实现的,若是公平锁的话,每有一个线程想要获取这个锁,需要进入队列排队,而且不能插队,若是非公平锁的话,队列中的线程是可以插队的CountDownLatch、Semaphore的底层也是AQS,也可以看看
3.3.4 CAS是啥呀?
- CAS,比较并交换,实现它有三个重要的值,内存值、期望值和要修改的值,如果内存值和我们的期望值一致的话,才会将内存值修改为我们要修改的值,否则不进行修改
- ABA问题,简单去了解一下吧,可以用AtomicStampedReference来避免
3.3.5 volatile?
- 它有两个特性,一个是可见性,另一个是禁止指令重排
可见性是通过JMM(Java内存模型)来实现的,JMM中有主内存,每个线程有自己的工作内存,线程需要对变量进行操作的时候需要将主内存中的值读到工作内存中,修改完成后,volatile会使其他线程工作内存中的值失效,而且该线程必须将该值同步到主内存中,这样来保证可见性
禁止指令重排是通过内存屏障来实现的,在汇编层面,多执行了一个“lock addl $0x0,(%esp)”,指令重排序是不能把后面的指令重排序到内存屏障之前的位置(《深入理解JVM p448》)
3.3.6 其实说到volatile,建议主动交代一下单例模式
双重检测锁单例
public class DCLSingle { private static volatile DCLSingle single; public static DCLSingle getSingle () { if (single == null) { synchronized (DCLSingle.class) { if (single == null) single = new DCLSingle(); } } return single; } }
饿汉式
public class Hungry { private static final Hungry hungrySingle = new Hungry(); public static Hungry getInstance() { return hungrySingle; } }
懒汉式
public class Lazy { private static Lazy lazySingle; public Lazy() { } public static Lazy getLazySingle() { if (lazySingle == null) { lazySingle = new Lazy(); } return lazySingle; } }
静态内部类
public class InnerClassLazy { //静态内部类单例的持有者 private static class InnerSingleHolder { public static InnerClassLazy INSTANCE = new InnerClassLazy(); } public static InnerClassLazy getSingleton () { return InnerSingleHolder.INSTANCE; } }
枚举
public enum EnumSingle { ENUM_SINGLE; public static EnumSingle getEnumSingle() { return ENUM_SINGLE; } }
3.3.7 ThreadLocal
3.3.8 引用逃逸(this escape)
public class ThisEscape { //this引用逃逸 在构造函数中使用了this,该this代表的就是正在执行构造函数的对象(实例中 B构造的对象) //我们按线程来调断点 //我们在B线程中执行的时候先把obj的引用暴露出去了,并让B线程在这里停下(打一个断点) //然后A线程会提前拿到这个引用,然而B线程中该构造器并没有执行完,是的i和j的值都为0,这就是引用逃逸 int i; int j; public static ThisEscape obj; public ThisEscape() { obj = this; this.i = 1; this.j = 1; } public static void main(String[] args) { Thread threadB = new Thread(() -> { obj = new ThisEscape(); },"B"); Thread threadA = new Thread( () -> { ThisEscape objB = obj; System.out.println(objB.j); System.out.println(objB.i); },"A"); threadB.start(); threadA.start(); } }
- this引用逃逸,一般和final的禁止指令重排联系起来
被final修饰的变量在若在构造器中赋值,它是不会被重排到构造器之外的,除非出现this的引用逃逸,若没有逃逸的话,执行完构造函数,那么被final修饰的值一定是我们代码中给它赋的值
3.4 Spring、SpringMVC,MyBatis,SpringBoot(我只了解个皮毛罢了)
BeanFactory和ApplicationContext
BeanFactory是Spring的底层接口,实现了对Bean的配置和管理;ApplicationContext是BeanFactory的子接口,并且扩展了一些功能,包括AOP,国际化,事件驱动,BeanPostProcessor和BeanFactoryPostProcessorBean的生命周期
它分两个阶段,一个是BeanDefinition阶段,另一个是Bean实例阶段
在BeanDefinition阶段,加载xml配置文件,将声明的<bean>
转换为BeanDefinition,解析注解配置类,在refresh()方法的beanDefinitionRegistryPostProcessor执行阶段,实现对@Bean注解方法的解析并封装成BeanDefinition
在Bean实例阶段,首先进行Bean的实例化,在refresh()方法的finishBeanFactoryInitialization()方法中,初始化所有非延迟加载的bean,实例化入口是getBean(),doGetBean(),先从三级缓存中去拿,如果缓存中没有的话,执行createBean(),doCreateBean()方法,通过调用createBeanInstance()方法生成Bean实例;之后进行Bean的初始化,执行populateBean()方法进行属性赋值和依赖注入,以及初始化阶段的方法回调,回调方法分别是被@PostConstract注解修饰的方法,之后执行的是InitializingBean的afterPropertiesSet()方法,再之后是init-method方法,之后再是后置处理器的后置回调;接下来是Bean的使用阶段;使用完成后要进行Bean的销毁,同样有三个回调方法,被@PreDestroy注解修饰的方法,DisposableBean的destroy()方法,destroy-method方法(注:初始化回调和销毁回调都是有先后顺序的,我就是按执行的先后顺序介绍的)Bean的实例化方式
1 通过<bean>
, @Bean, @Component的方式注册Bean后实例化
2 借助FactoryBean实例化Bean(factory-bean + factory-method)
3 使用静态工厂实例化Bean(factory-method)Spring容器的三级缓存
//一级缓存:用于存放完全初始好的bean,拿出来可以直接使用 Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); //二级缓存:提前曝光的单例对象缓存,存放原始的bean对象(未填充属性),用于解决循环依赖 Map<String, Object> earlySingletonObjects = new HashMap<>(16); //三级缓存:单例对象缓存,存放bean工厂对象,用于解决循环依赖 Map<String, ObjectFactory> singltonFactories = new HashMap<>(16);
Spring事务的传播行为
1 REQUIRED:必需的(默认值)
如果当前没有事务运行,则会开启一个新的事务;如果当前已经有事务运行,则方***运行在当前事务中,“你没有,我开启,你有了,我加入”
2 REQUIRES_NEW:新事务
如果当前没有事务事务运行,则会开启一个新的事务;如果当前已经有事务运行,则会将原事务挂起,重新开启一个新的事务。当新的事务运行完毕后,再将原来的事务释放,“你没有我开启,你有了我造新的”
3 SUPPORTS:支持
如果当前有事务运行,则方法运行在当前事务中;如果没有事务运行,则不在事务中运行,“有就有,没有拉到”
4 NOT_SUPPORTED:不支持
如果当前事务运行,则会将事务挂起,如果当前没有事务运行,则它也不会运行在事务中,“有我不要,没有正好”
5 MANDATORY:强制
当前方法必须在事务中,如果没有事务,则直接抛出异常,“要干活必须有,没有就打死不干”
6 NEVER:不允许
当前方法不允许运行在事务中,如果当前已经有事务运行,则抛出异常,“要干活不准有,有了不干活”
7 NESTED:嵌套
如果当前没有事务运行,则开启一个新的事务;如果当前已经有事务运行,则会记录一个保存点,并继续运行在当前事务中。如果子事务运行中出现异常,则不会全部回滚,而是回滚到哦上一个保存点Spring MVC的处理流程
所有的请求发送给DispatcherServlet进行处理,DispatcherSerclet去请求HandlerMapping,找出容器中被@Controller注解修饰的Bean以及被@RequestMapping修饰的方法,生成Handler和HandlerInterceptor封装起来并以HandlerExcutionChain对象返回,之后DispatcherServlet会将HandlerExcutionChain发送给HandlerAdaptor,通过HandlerAdaptor执行Handler的方法,执行完成后返回ModelAndView对象,DispatcherServlet会把ModelAndView发送给ViewResolver进行解析,解析完成后返回View对象,并进行渲染发送给客户端MyBatis缓存
使用缓存来减少与数据库交互的次数,从而提高运行效率,进行查询后,将结果放在缓存中,查询时从缓存中拿
一级缓存:是SQLSession级别的,操作数据库需要SQLSession对象,在对象中有一个HashMap用来缓存数据,在同一个SQLSession中执行两次相同的查询时,第一次会进行缓存,第二次从缓存中拿,执行修改操作后,缓存失效,保证数据的有效性
二级缓存:默认是关闭的,是Mapper级别的,当多个SQLSession使用同一个Mapper的SQL语句操作数据库的时候,得到的数据会在二级缓存中,也用HashMap存,作用域是Mapper的namespace,不同的SQLSession两次执行相同的SQL,第二次会从二级缓存中拿
3.5 计算机网络和操作系统(准备这里的面试,我更像是一个赌徒)
3.5.1 七层和五层
- 七层:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层
- 五层:应用层、传输层、网络层、数据链路层、物理层
- 应用层定义应用进程之间的通信和交互规则,有支持域名的DNS,支持万维网的HTTP,支持邮件的SMTP
- 传输层负责两台主机进程之间的通信,有TCP和UDP协议
- 网络层负责不同主机间的通信,分数据平面和控制平面,数据平面负责转发,从输入链路接口到输出链路接口;控制平面负责路由选择,从源主机到目的主机的路由器该如何转发数据,有IP协议
- 数据链路层是将网络层传递下来的IP数据报组装成帧,在两个相邻的节点之间链路上传输帧
3.5.2 TCP
- TCP是面向连接的传输层协议,提供全双工通信,连接是点对点的,提供可靠的交付服务
- TCP报文结构:源端口和目的端口;序号(客户端和服务端随机生成一个初始值);确认号(期望收到对方下一个报文段的第一个序号);标志字段(URG紧急标志,SYN同步标志,FIN终止标志);接收窗口;校验和
- TCP可靠原理
1 使用检验和来验证传输报文中的错误
2 使用定时器来用于超时重传
3 使用序号来检测丢失和冗余,使用确认应答来告诉发送方确认收到信息
4 TCP使用流量控制和拥塞控制来保证可靠性
流量控制:发送方的发送速率与接收方的接收速率相匹配,通过报文中的接收窗口大小来指定流量
拥塞控制:在网络中对资源的请求超过资源可用量的情况叫拥塞(吞吐量小于理想吞吐量),TCP的发送方会根据在目的路径之间没什么拥塞而增加发送速率,若有拥塞则降低发送速率。通过超时或连续接收到3个冗余的ACK来判断拥塞,发送速率由拥塞窗口来控制 - 拥塞控制算法
发送方维护一个叫做拥塞窗口的状态变量Cwnd,其值取决于网络拥塞程度,动态变化,慢开始算法阈值ssthresh,发送窗口大小Swnd
慢开始算法:发送窗口大小 = 拥塞窗口大小(一个较小的值MSS),随着双方通信,收到确认应答报文,拥塞窗口指数级增长,超过慢开始阈值后,使用拥塞避免算法
拥塞避免算法:拥塞窗口随着传输轮次,呈线性增长
快重传算法:在传输过程中有报文丢失,发送方累计连续3次收到重复确认报文,就将相应的报文段立即重传,而不是在该报文段的超时重传计时器超时重传
快恢复:减小拥塞窗口的大小,再进行线性增长,开启拥塞避免算法 - 三次握手
第一次握手:客户端发送建立连接的请求报文给服务器,其中SYN = 1,ACK = 0
第二次握手:服务端收到连接请求报文后,发送一个确认建立连接的应答报文,其中SYN = 1, ACK = 1
第三次握手:客户端收到服务器确认建立连接报文后,还要发送确认应答报文给服务器,其中ACK = 1 - 三次握手的原因
1 防止已经失效的连接请求报文突然传送到服务器产生脏连接
2 为了实现可靠的数据传输,TCP连接双方都需要维护一个序列号,若是两次握手的话,服务端发送的序列号得不到确认(客户端可以接收到序列号,但是没有确认收到的回应发送给服务器) - 四次挥手
第一次挥手:客户端向服务器发送一个终止连接报文,FIN = 1
第二次挥手:服务器收到该报文后,发送给客户端一个确认报文,ACK = 1
第三次挥手:服务器在发送完数据,准备释放连接连接时,会向客户端发送终止连接报文,FIN = 1
第四次挥手:客户端收到后,发送确认报文,等待2MSL后,进入关闭状态 - 四次挥手的原因
客户端和服务器采用的是全双工通信,发送方和接收方都需要发送FIN和ACK报文才能断开 - 为什么要等待2MSL后才能释放连接?
1 等待2MSL可以保证连接的所有报文都会从网络上消失,防止新旧连接的混淆
2 保证服务端能接收到客户端发送的确认报文(如果该报文丢失,服务端没收收到就会超时重传之前的终止连接报文,若客户端直接进入closed状态,则无法收到该报文,也不会发送确认报文,那么服务器就无法正常进入closed状态) - UDP
UDP是面向非连接的,不维护连接状态,支持同时向多个客户端传输相同的信息,报文头只有8字节,尽最大努力交付,不保证数据可达 - TCP和UDP的应用场景
TCP:文件传输,这种数据要求可靠性高的场景;浏览器访问(HTTP);发送电子邮件
UDP:语音聊天;在线视频
3.5.3 HTTP
- HTTP请求类型
1 GET请求:请求获取数据,是幂等的,像数据库的查询请求,只是用来查询不对数据进行修改
2 POST请求:向服务端提交数据进行处理,数据包含在请求体中,可能导致新的资源建立或修改
3 PUT请求:向服务端发送数据进行修改
4 DELETE请求:是用来删除某一资源的
5 HEAD请求:当服务器收到HEAD请求时,将会一个HTTP报文进行响应,但并不返回请求对象,常用于方法调试跟踪(当我们发送一个HEAD请求到服务器时,会收到一个HTTP响应报文,但是其中并不包含我们请求的对象) - HTTP请求报文
请求行:方法,URL字段,HTTP版本
首部行:主机名;Connection(表示是否使用持续连接);User-agent(显示浏览器类型)
空行
实体体:包含我们请求需要传递的数据 - HTTP响应报文
状态行:HTTP版本,状态码,状态信息
首部行:Connection;Date(时间);Server(服务器);Last-Modified(最后修改时间);Content-length:对象字节数;Content-type(对象类型)
实体体:包含我们请求的对象 - HTTP状态码(列出的我记不太住的...)
400 BadRequest 客户端请求语法错误
401 Unauthorized 请求未经授权
403 Forbidden 拒绝访问
500 Internal Server Error 服务器内部错误
503 Server Unavailable 服务器当前不能处理客户端的请求 - HTTP1.0和HTTP1.1
HTTP1.0 使用的是非持续性连接,另外每次请求都会有2倍的RTT开销,另外客户和服务器每一次建立新的TCP连接都要分配缓存和变量,非持续性连接对服务器压力较大
HTTP1.1 使用的持续性连接,服务器会在发送响应后一段时间内继续保持这条连接,使同一个浏览器和服务器可以继续在这条连接上传输后续的HTTP请求和响应报文,支持请求流水线处理,在一个TCP连接上可以传送多个请求和响应 - HTTP 2.0
使用多了复用技术,做到同一个连接并发处理多个请求,相比于HTTP1.1在同一时间同一域名的限制好了几个数量级
HTTP2.0支持使用HPACK算法对HTTP首部进行压缩,数据体积小,传输更快了
服务器推送:服务器会顺便把一些客户端需要的资源一起推送到客户端,免得客户端再次请求获取数据 - HTTPS是以安全为目标的HTTP通道,通过S.S.L,Secure Sockets Layer 安全套接字协议
对称加密:使用相同的密钥进行加密,效率快,安全性好
非对称加密:加密和解密的密钥不同,通过双方接收到对方的公钥进行加密,使用私钥进行解密,效率慢,但是安全
HTTPS采用混合加密机制,使用非对称加密用于传输对称加密使用的密钥,之后用对称加密进行通信,保证通信效率 - SSL握手流程
浏览器和服务器建立TCP连接后,会发送一个请求,包含了自己可实现的算法列表和必要信息
服务器收到请求后,会选择加密算法,然后返回证书,包含非对称加密的公钥,加密算法,服务器信息,申请证书的公司,域名等
浏览器收到之后,检查签发证书的机构是否正确,公钥签名是否有效,若有效则生成对称密钥,利用公钥进行加密,发送给服务器
服务器收到密钥后,用私钥进行解密,之后浏览器和服务器就可以基于对称加密进行数据通信 - 在浏览器输入URL对峙,回车后会发生什么?
1 先查询web缓存器,如果有的话则直接显示
2 通过DNS域名解析服务解析IP地址,先从浏览器缓存中查询,如果没有则查询本地DNS服务器的缓存
3 通过TCP的三次握手建立连接,建立连接后,向服务器发送HTTP请求
4 服务器收到浏览器的请求后,进行处理并发送响应报文
5 浏览器收到服务器的响应报文后,如果可以,进行缓存
6 浏览器渲染页面并呈现给用户
7 四次挥手断开连接
3.5.4 操作系统(我只赌的这两个问题)
进程和线程的区别(谨慎参考)
进程是资源分配的基本单位,线程是CPU调度的基本单位
进程包含线程,一条线程只能在一个进程中
进程与进程之间是独立的,而线程与线程之间资源可以是共享的死锁的条件
互斥条件;不可剥夺条件;请求与保持条件;循环等待条件
3.6 Linux命令(仅仅被问过一次,简历上没写Linux)
- ls列出目录;cd切换目录;pwd显示当前目录;mkdir创建目录;rmdir删除目录
- cp复制目录或文件;rm移除文件或目录;mv移动文件或目录
- cat从第一行正序显示内容;tac从最后一行开始显示内容;head -n20 显示前20行;tail看尾几行
- top 常用的Linux性能分析命令,可以查看各个进程的资源占用状况
- 开启端口 firewall-cmd --zone=public --add-port=80/tcp --permanent;firewall-cmd --state 查看状态;service firewall start/restar/stop 开启/重启/停止
- ps -ef|grep redis 查看redis的进程信息 (被问过这个)
3.7 MySQL(《MySQL是怎样运行的》 这本书无敌!真的,大家有时间一定看看,掘金上也有电子版)
- 三大范式(每一范式都在前一范式的基础上)
第一范式:每一列都是不可分割的原子项
第二范式:每一列都与主键列相关
第三范式:每一列与主键列直接相关 - MySQL聚合函数
AVG(),SUM(),COUNT(),MAX(),MIN()...
3.7.1 索引(第六章和第七章)
- 为什么用的是B+树而不是B树?
B+树仅在叶子节点中存储全部的用户记录,而其他节点存储的只有主键和对应的页号,相比于B树在每个节点都存储完整的用户记录,B+树的树高更低,查询效率更高 - 区分一下聚簇索引和非聚簇索引(二级索引)
聚簇索引:以主键值的大小作为页和记录的排序规则,在叶子节点处存储的记录包含了表中所有的列
非聚簇索引:以索引列的大小作为页和记录的排序规则,在叶子节点处存储的记录内容是索引列 + 主键值,若要获取完整的用户记录需要进行回表查询 - 哈希索引
查询效率很高,但是只有精确匹配索引列的查询才有效,Memory印象显示支持哈希索引,也是它的默认索引
哈希索引不能进行排序;哈希索引不支持任何范围查询;哈希索引不能利用部分索引查询,对于联合索引,必须把所有的列全用上再进行计算hash值
自适应哈希索引是InnoDB引擎的一个特殊功能,当它注意到某些值被使用的非常频繁时,会在内存中基于B+Tree索引上再建一个哈希索引(完全自动的内部行为) - 使用索引的时候我们有哪些需要注意的?
1 只为用于搜索、排序或分组的列创建索引
2 索引列的类型应尽量小
3 可以只为索引列前缀创建索引,以减小索引占用的存储空间
4 尽可能的让主键拥有AUTO_INCREMENT属性,避免发生页分裂的情况
5 查询时尽量使用覆盖索引,避免回表操作带来的性能损耗 - 查询时用不到索引的情况(索引失效的情况)
1 使用索引比全表扫描慢,不用索引
2 联合索引没使用到第一部分
3 模糊查询以通配符%开头
4 若为字符串索引,与数值进行匹配的时候,数值没用引号,不走索引
5 在搜索条件中,索引列不以单独的列名存在,而使用表达式或函数进行操作的时候,不走索引
3.7.2 explain SQL 对应的type列解析(第十章)
- const:用主键或唯一二级索引的常数匹配(非常快!)
- ref:二级索引进行常数匹配,形成单点扫描区间
- ref_or_null:二级索引进行常数匹配(包含空值)
- range:范围查询
- index:扫描全部二级索引或order by主键
- all:全表扫描,扫描全部聚簇索引记录
- 我为什么要说这个知识点儿?我面京东的时候被问过explain后我们怎么发现走没有索引或者优化,我是这么解释的,type列如果对应的是all的话,我们就需要谨慎一些,因为它没有走索引,我们要看看是不是SQL语句使索引失效了还是我们没有为查询列创建索引,反过来如果是const这种类型的话,它的执行效率是非常高的
- 索引合并
Intersection索引合并:是从二级索引回表查询时,获取的主键是有序的,主键取交集查询(where中 and条件)
Union索引合并:也是要求从二级索引回表查询获取的主键值有序,取主键值并集查询(or条件)
Sort-Union:对从二级索引回表中获取的主键值进行排序,排序完成后取并集查询
3.7.3 事务(第十八、二十一、二十二章)
- ACID原则
原子性:事务作为一个不可在分割的单位,其中的操作要么全部执行成功,要么全部执行失败回滚
隔离性:多个事务间不相互干扰,在并发执行期间相互隔离
一致性:事务执行前后,数据都保持一致性
持久性:事务执行完成后,对数据的影响是永久性的(强调结果) - 隔离级别
READ UNCOMMITTED:可能发生脏读、不可重复读和幻读
READ COMMITTED:可能发生不可重复读和幻读
REPEATABLE READ:可能发生幻读
SERIALIZABLE:各种问题都不会发生
实际上,MySQL在REPEATABLE READ隔离级别下是可以很大程度上避免幻读出现的(很大程度上!不是完全) - 脏读:一个事务读到了另一个未提交事务修改过的数据
- 不可重复读:同一条件下,两次读取的值不相同
- 幻读:幻读强调的是一个事务在按照某个相同的搜索条件下多次读取纪录时,读到了之前没有读到的数据(出现了幻行)
3.7.4 日志(第十九、二十章)
- redo log(重启恢复数据时才是爸爸)
对数据库的修改并不会立即同步到磁盘中,而是会在Buffer Pool(缓存池)中进行缓存,由flush链表(脏链表)来维护脏页,那么若在未同步时发生断电,需要redo log对这些修改数据的操作进行记录,以便重启后对数据进行恢复
产生的redo log存放在block中,block也相当于页,也会现在log buffer缓存区中缓存,它进行同步的时机有 事务提交时;log buffer超过50%空间已用;后台线程以秒为单位刷新;正常关闭服务器;做checkpoint时 - 对于已经刷新到磁盘的数据,那么对应的redo日志已经没用了,可以对这些空间进行覆盖重用
- undo log(主要用于回滚操作),每对记录进行一次增删改都会产生一条undo log
- 这个redo log 和 undo log 在面试的时候从没被问过...,暂时列举这么多吧,估计用不太上
3.7.5 MVCC(第二十一章)
什么是MVCC?
多版本并发控制,在提交读和可重复读两个隔离级别下,执行select操作会产生readview快照,根据readview快照来进行数据读取的过程
这两个隔离界别下,产生快照的时机是不同的,在提交读下,每进行一次一次select操作都会产生一个新的快照,快照就相当于给数据拍照片,在这个隔离级别下,每查一次就拍一次照,那么获取的数据都是新的,所以有不可重复读和幻读的情况发生;而在可重复读隔离级别下,只在第一次查询的时候拍一张照,每次再查询都要看最开始生成的这样照片,那么它就避免了不可重复读的情况,也在一定程度上避免了幻读
为什么是一定程度上而不是完全避免?如果我们现在在A事务中执行了一次查询,那么它已经有了readview快照,那么事务B插入一条数据进来,事务A在进行查询的话,还是看不到这条数据的(在匹配条件符合的情况下),但是A事务是能够对这条数据进行修改的!重点在这里,对这条数据修改之后,该行数据的隐藏列的事务id就会变成A事务的事务id,这时,A事务在进行查询,就能看见这条新增的数据了,所以是不能完全避免幻读,避免幻读需要使用gapLock间隙锁,它能防止其他事务插入数据
Readview快照访问数据的原理,ReadView中存储的是最小事务id,最大事务id,当前正在运行的事务id,生成该Readview的事务id,根据这些id号去访问数据,只有数据的事务id号小于最小事务id或者是事务id是生成该Readview的事务id才能被访问到
3.7.6 锁(第二十二章)
- 被问的比较少
- SELECT ... LOCK IN SHARE MODE 语句为读取的记录加共享锁
- SELECT ... FOR UPDATE 语句为读取的记录加排他锁
- Record Lock:只会对记录本身加锁(提交读隔离级别下下采用的是这个锁)
- Gap Lock:锁住的是记录前的间隙,防止别的事务向该间隙插入新记录,避免幻读
- Next-Key Lock:Record Lock 和 Gap Lock的结合体,既保护记录本身,也防止别的事务向该间隙插入新纪录(可重复读下采用的是这个锁)
- 死锁发生时,InnoDB会选择一个较小的事务进行回滚,可以通过查看死锁日志来分析死锁的发生过程
3.8 Redis(《Redis设计与实现》这本书非常好,非常值得一读,行文很流畅!)
关于读这本书的一个技巧,可以从第八章开始读,然后每一个对象类型都会对应不同的编码格式,每看到一个编码格式,就去翻前边的章节看它写的是怎么回事儿,这样我觉得更清晰一些,后边的章节顺序读就好
Redis缓存穿透、击穿、雪崩的解决方案
缓存穿透:指查询一个不存在的数据(缓存中没有,数据库中也没有),如果从存储层查询不到,也不写入缓存,这将导致这个不存在的数据每次请求都会DB查询,可能导致DB挂掉;
解决方案:1 查询的数据为空时,仍把这个空结果进行缓存,设定一个比较短的过期时间
2 采用布隆过滤器,不存在的数据会被布隆过滤器拦截,从而避免了对DB的查询
3 接口增加校验,对不合法的参数直接return
缓存穿透:指缓存中没有,但是数据库中有的数据,有大量的请求过来,引起数据库压力过大
解决方案:设置热点数据永不过期
缓存雪崩:大量的key设置了相同的过期时间,导致缓存在某一时刻的失败,使得请求全部到DB上,导致DB压力过大
解决方案:将缓存失效时间分散开,为key的过期时间加上一个随机值Redis key的删除策略和淘汰策略
在redis.conf文件中设置了配置最大可用内存(maxmemory),在未达到最大使用内存时,键自身设置了过期时间的情况下,Redis会删除过期键,采用定期删除或惰性删除,在达到最大内存使用限制时,会根据配置项maxmemory-policy来进行相应的淘汰策略
删除策略:1 惰性删除,程序只会在使用键时对键进行过期检查,过期删除,比较浪费内存
2 定期删除:每隔一段时间执行一次删除过期键的操作,必须在设置时间和删除频率来达到节省内存和合理使用CPU的目的
3 定时删除(Redis没采用这种,书中 p108),使用定时器来对key进行删除,对内存最友好
淘汰策略:1 volatile-lru,从设置了过期时间的数据集中选出最近最少使用的数据淘汰
2 volatile-ttl,从设置了过期时间的数据集中优先删除剩余时间短的key
3 volatile-random,从设置了过期时间的数据集中岁间选择数据进行删除
4 allkeys-lru,从所有key中,挑选最近最少使用的数据淘汰
5 allkeys-random,从所有key中,选择任意数据淘汰
6 no-eviction,禁止淘汰数据
3.8.1 五种对象类型(第八章)
- String类型
编码格式:int,embstr,raw
int保存的是可以用long类型保存的整数
embstr保存的是字符串长度小于等于39字节的短字符串
raw保存字符串大于39字节
其中embstr和raw使用的都是SDS(简单动态字符串类型),它的底层是一个字符数组,其中存有字符串长度和字符数组中未使用的长度
long和double类型也作为字符串类型保存,对其进行操作时会转换为浮点数,再转为字符串存起来 - List类型
编码格式ziplist(压缩列表),linkedlist(双端链表)
ziplist是在节点数小于512个且每个字符长度小于64字节时使用,它是一种连续内存的顺序型数据结构
linkedlist,具有双端列表的特性 - hash类型
编码格式:ziplist,hashtable
ziplist要求数量小于512个,键和值长度小于64字节,键和值紧挨着连续存储,用两个节点
hashtable,使用map作为底层实现 - set类型
编码格式:intset,hashtable
intset要求元素都是整数,且小于512个
hashtable只使用字典的key,而value值为null - zset类型(这个被问的几率大一些)
编码格式:ziplist和skiplist
ziplist要求数量小于512,元素大小小于64字节,每个元素用两个紧挨着的压缩列表节点,第一个保存元素的成员,第二个保存分值,并会从小到大的顺序排序,分值较小的放置在靠近表头的位置
skpilist使用的是map和跳表两种数据结构,它们共享zset中的对象,不会复制元素而造成浪费
map的作用是能够以O(1)的时间复杂度来获取对象的score,键时元素成员,值是元素的分值(ZSCORE命令)
跳表是有序的数据结构,对象不可重,分值可重,分值重的按对象大小排序,每个节点都有一个随机的层高,每层保留前往下一个节点的指针和跨度,能够进行快速匹配,调用zrank和zrange进行范围操作
3.8.2 RDB持久化机制(第十章)
- 对应的命令SAVE和BGSAVE,BGSAVE会fork出一条子进程,由子进程创建RDB文件,父进程继续处理命令请求
- 若有AOF文件的时候,不会选择RDB文件进行数据恢复
- 在配置文件中,我们可以配置执行RDB持久化的频率,在多少秒内进行了多少次修改就进行一次持久化
save 900 1
save 300 10
save 60 10000 - 原理:底层会用一个数组保存上边的信息,还会维护一个dirty计数器来保存修改次数和lastsave属性来存储上一次执行BGSAVE的时间,redis调用周期函数检查是否符合RDB持久化的条件
3.8.3 AOF持久化机制(第十一章)
- 通过保存Redis服务器的写命令来记录数据库的状态,载入时将命令全执行一遍,配置文件中有3种同步机制
always,将aof缓冲区中的内容写入aof文件并立即同步到磁盘中
everysecond,将aof缓冲区中的内容写入aof文件,并检查上次操作是否超过一秒,超过则进行同步
no,不进行同步,具体时机由系统来决定 - aof重写,解决aof文件体积太大的问题,创建一个新的aof文件来替换原文件,对应的命令是BGREWRITEAOF
- 重写原理:先读出所有的键值,之后用一条命令去记录,代替之前的多条命令。子进程在进行aof文件重写时,服务器还会对数据进行修改,这就会产生数据不一致的问题,为了解决该问题,会使用aof重写缓冲区,执行的命令会在重写过程中向缓冲区中追加,当子进程完成重写操作时,它会向父进程发送信号,此时父进程会将aof重写缓冲区中的所有内容写入到aof文件中,并将原aof文件进行覆盖,完成重写操作
3.8.4 主从复制(第十五章)
- 主从复制的实现包括同步和命令传播两步,主从服务器都会维护一个复制偏移量,主服务器每执行一条命令都会使偏移量增加,从服务器也一样,数据相同时,偏移量也相同
- 初次同步,从服务器向主服务器发送PSYNC命令,主服务器执行BGSAVE,在后台生成一个RDB文件,并使用缓冲区记录从现在开始执行的所有写命令,之后会将RDB文件发送给从服务器,从服务器载入这个RDB文件,并执行从主服务器缓冲区中传过来的写命令,使主从服务器状态一致
- 命令传播,主服务器会将之后执行的写操作命令发送给从服务器执行
- 断线重连后,可能会执行部分重同步或者全同步,如果在复制积压缓冲区中,仍然记录有从服务器偏移量之后的数据,那么便可以执行部分重同步,将这些命令发送给从服务器,相反的话,就执行全同步;从服务器还会存有主服务的ID,如果重连的一致,那么将进行重同步或部分重同步,如果不一致,直接全同步
- 心跳检测:检测主从服务器的网络连接状态,如果超过一秒没有收到心跳检测命令,那主服务器就知道从服务器出了问题;检测命令丢失,如果主服务器向从服务器传播的命令由于网络原因丢失,那么会根据心跳检测的复制偏移量来进行检测,不同的话会采用命令传播进行部分重同步
3.8.5 哨兵模式(第十六章)
Sentienl实例可以监控一个或多个主服务器和从服务器,当有主服务器下线的时候,自动将下线主服务器属下的某个从服务器升级为新的主服务器
Sentinel实例中会存有master,slave和其他sentinel的信息
检测主观下线:sentinel会以1秒1次的频率向其他服务器(主、从、sentinel)发送PING命令,若超过我们在配置文件中配置的超时时间,在该时间内是无效回复,那么该sentinel会认为它主观下线
检测客观下线:sentinel会询问其他监视该服务器的sentinel,当认为该服务器主观下线的数量大于等于我们在配置文件中的客观下线票数时,会认为它客观下线,认为该主服务器客观下线后,监视这个主服务器的sentinel会挑选出一个零头sentinel,并由该sentinel进行故障转移操作
故障转移
1 在已经下线的主服务器的从服务器里边,挑选一个作为主服务器
2 让其他的从服务器复制新的主服务器
3 将已下线的主服务器设置为新主服务器的从服务器,它上线后直接成从服务器重要的配置信息
sentinel monitor master1 127.0.0.1 6379 2 //2为所需支持的投票数量 sentinel down-after-milliseconds master1 30000 //30000毫秒实例未响应的时间,超时主观下线
4.我准备了以上知识,该怎么写简历
- 这只不过是给大家提了一个引子,就是我们简历上写的我们都得会,面试官问我们,我们一定不会说不上来,而且尽量把每个点写的细一点儿,也算是给面试官列了一个提纲,能让人家照着简历问我们
5.碎碎念
这篇帖子也写了有一段时间了,现在写完,心中难免还有一些惆怅和不舍。
曾经我也有过很多矫情的感慨,也有过期待和失望。
这一路孤军奋战走来,可以说很不容易,但是现在回头看,也不过如此罢了。
曾经秋招上岸的时候,我感觉我行了,而现在抓住了更好的机会,反而感觉还有很长的路要走,要一直保持谦卑和低调。
能抓住机会,也并不只出于自己的努力,而是很多人的帮助和赏识。
感受到了太多人的帮助和鼓励,我一时也不知该如何去回馈,我能做的也就是将这些铭记在心中,随时等候你们的呼唤罢了。
最后还要感谢感谢牛客网这个平台,可以说改变了我这个处于信息洼地的普通人的命运。
好了,这篇帖子也该结束了,祝愿大家都好,大家都会好的!
“少年不识愁滋味,爱上层楼,为赋新词强说愁。而今识得愁滋味,欲说还休,却道天凉好个秋”
全部评论
(51) 回帖