Java学习笔记

崩天的勾玉 2021年8月6日00:43:21
评论
217 57936字

Java基础

说说进程和线程的区别?

进程是程序的一次执行,是系统进行资源分配和调度的独立单位,他的作用是是程序能够并发执行提高资源利用率和吞吐率。

由于进程是资源分配和调度的基本单位,因为进程的创建、销毁、切换产生大量的时间和空间的开销,进程的数量不能太多,而线程是比进程更小的能独立运行的基本单位,他是进程的一个实体,可以减少程序并发执行时的时间和空间开销,使得操作系统具有更好的并发性。

线程基本不拥有系统资源,只有一些运行时必不可少的资源,比如程序计数器、寄存器和栈,进程则占有堆、栈。

知道synchronized原理吗?

synchronized是java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。

执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。

执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。

synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。

从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。

实际上大部分时候我认为说到monitorenter就行了,但是为了更清楚的描述,还是再具体一点

如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。

  1. 当多个线程进入同步代码块时,首先进入entryList
  2. 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  3. 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
  4. 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

那锁的优化机制了解吗?

从JDK1.6版本之后,synchronized本身也在不断优化锁的机制,有些情况下他并不会是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。

锁的状态从低到高依次为无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到高,降级在一定条件也是有可能发生的。

自旋锁:由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining来开启,自旋的默认次数是10次,可以使用-XX:PreBlockSpin设置。

自适应锁:自适应锁就是自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定。

锁消除:锁消除指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。

锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。

偏向锁:当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,之后这个线程再次进入同步块时都不需要CAS来加锁和解锁了,偏向锁会永远偏向第一个获得锁的线程,如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进行同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁。可以用过设置-XX:+UseBiasedLocking开启偏向锁。

轻量级锁:JVM的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁。

整个锁升级的过程非常复杂,我尽力去除一些无用的环节,简单来描述整个升级的机制。

简单点说,偏向锁就是通过对象头的偏向线程ID来对比,甚至都不需要CAS了,而轻量级锁主要就是通过CAS修改对象头锁记录和自旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞。

那对象头具体都包含哪些内容?

在我们常用的Hotspot虚拟机中,对象在内存中布局实际包含3个部分:

  1. 对象头
  2. 实例数据
  3. 对齐填充

而对象头包含两部分内容,Mark Word中的内容会随着锁标志位而发生变化,所以只说存储结构就好了。

  1. 对象自身运行时所需的数据,也被称为Mark Word,也就是用于轻量级锁和偏向锁的关键点。具体的内容包含对象的hashcode、分代年龄、轻量级锁指针、重量级锁指针、GC标记、偏向锁线程ID、偏向锁时间戳。
  2. 存储类型指针,也就是指向类的元数据的指针,通过这个指针才能确定对象是属于哪个类的实例。

如果是数组的话,则还包含了数组的长度

对于加锁,那再说下ReentrantLock原理?他和synchronized有什么区别?

相比于synchronized,ReentrantLock需要显式的获取锁和释放锁,相对现在基本都是用JDK7和JDK8的版本,ReentrantLock的效率和synchronized区别基本可以持平了。他们的主要区别有以下几点:

  1. 等待可中断,当持有锁的线程长时间不释放锁的时候,等待中的线程可以选择放弃等待,转而处理其他的任务。
  2. 公平锁:synchronized和ReentrantLock默认都是非公平锁,但是ReentrantLock可以通过构造函数传参改变。只不过使用公平锁的话会导致性能急剧下降。
  3. 绑定多个条件:ReentrantLock可以同时绑定多个Condition条件对象。

ReentrantLock基于AQS(AbstractQueuedSynchronizer 抽象队列同步器)实现。别说了,我知道问题了,AQS原理我来讲。

AQS内部维护一个state状态位,尝试加锁的时候通过CAS(CompareAndSwap)修改值,如果成功设置为1,并且把当前线程ID赋值,则代表加锁成功,一旦获取到锁,其他的线程将会被阻塞进入阻塞队列自旋,获得锁的线程释放锁的时候将会唤醒阻塞队列中的线程,释放锁的时候则会把state重新置为0,同时当前线程ID置为空。

CAS的原理呢?

CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性,它包含三个操作数:

  1. 变量内存地址,V表示
  2. 旧的预期值,A表示
  3. 准备设置的新值,B表示

当执行CAS指令时,只有当V等于A时,才会用B去更新V的值,否则就不会执行更新操作。

那么CAS有什么缺点吗?

CAS的缺点主要有3点:

ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。

Java中有AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。

循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。

只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。

好,说说HashMap原理吧?

HashMap主要由数组和链表组成,他不是线程安全的。核心的点就是put插入数据的过程,get查询数据以及扩容的方式。JDK1.7和1.8的主要区别在于头插和尾插方式的修改,头插容易导致HashMap链表死循环,并且1.8之后加入红黑树对性能有提升。

put插入数据流程

往map插入元素的时候首先通过对key hash然后与数组长度-1进行与运算((n-1)&hash),都是2的次幂所以等同于取模,但是位运算的效率更高。找到数组中的位置之后,如果数组中没有元素直接存入,反之则判断key是否相同,key相同就覆盖,否则就会插入到链表的尾部,如果链表的长度超过8,则会转换成红黑树,最后判断数组长度是否超过默认的长度*负载因子也就是12,超过则进行扩容。

get查询数据

查询数据相对来说就比较简单了,首先计算出hash值,然后去数组查询,是红黑树就去红黑树查,链表就遍历链表查询就可以了。

resize扩容过程

扩容的过程就是对key重新计算hash,然后把数据拷贝到新的数组。

那多线程环境怎么使用Map呢?ConcurrentHashmap了解过吗?

多线程环境可以使用Collections.synchronizedMap同步加锁的方式,还可以使用HashTable,但是同步的方式显然性能不达标,而ConurrentHashMap更适合高并发场景使用。

ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使用Segment+HashEntry分段锁的方式实现,1.8则抛弃了Segment,改为使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过长导致性能的问题。

1.7分段锁

从结构上说,1.7版本的ConcurrentHashMap采用分段锁机制,里面包含一个Segment数组,Segment继承于ReentrantLock,Segment则包含HashEntry的数组,HashEntry本身就是一个链表的结构,具有保存key、value的能力能指向下一个节点的指针。

实际上就是相当于每个Segment都是一个HashMap,默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。

put流程

其实发现整个流程和HashMap非常类似,只不过是先定位到具体的Segment,然后通过ReentrantLock去操作而已,后面的流程我就简化了,因为和HashMap基本上是一样的。

  1. 计算hash,定位到segment,segment如果是空就先初始化
  2. 使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功
  3. 遍历HashEntry,就是和HashMap一样,数组中key和hash一样就直接替换,不存在就再插入链表,链表同样

get流程

get也很简单,key通过hash定位到segment,再遍历链表定位到具体的元素上,需要注意的是value是volatile的,所以get是不需要加锁的。

1.8CAS+synchronized

1.8抛弃分段锁,转为用CAS+synchronized来实现,同样HashEntry改为Node,也加入了红黑树的实现。主要还是看put的流程。

put流程

  1. 首先计算hash,遍历node数组,如果node是空的话,就通过CAS+自旋的方式初始化
  2. 如果当前数组位置是空则直接通过CAS自旋写入数据
  3. 如果hash==MOVED,说明需要扩容,执行扩容
  4. 如果都不满足,就使用synchronized写入数据,写入数据同样判断链表、红黑树,链表写入和HashMap的方式一样,key hash一样就覆盖,反之就尾插法,链表长度超过8就转换成红黑树

get查询

get很简单,通过key计算hash,如果key hash相同就返回,如果是红黑树按照红黑树获取,都不是就遍历链表获取。

volatile原理知道吗?

相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,他没有上下文切换的额外开销成本。使用volatile声明的变量,可以确保值被更新的时候对其他线程立刻可见。volatile使用内存屏障来保证不会发生指令重排,解决了内存可见性的问题。

我们知道,线程都是从主内存中读取共享变量到工作内存来操作,完成之后再把结果写回主内存,但是这样就会带来可见性问题。举个例子,假设现在我们是两级缓存的双核CPU架构,包含L1、L2两级缓存。

  1. 线程A首先获取变量X的值,由于最初两级缓存都是空,所以直接从主内存中读取X,假设X初始值为0,线程A读取之后把X值都修改为1,同时写回主内存。这时候缓存和主内存的情况如下图。

  1. 线程B也同样读取变量X的值,由于L2缓存已经有缓存X=1,所以直接从L2缓存读取,之后线程B把X修改为2,同时写回L2和主内存。这时候的X值入下图所示。
    那么线程A如果再想获取变量X的值,因为L1缓存已经有x=1了,所以这时候变量内存不可见问题就产生了,B修改为2的值对A来说没有感知。

那么,如果X变量用volatile修饰的话,当线程A再次读取变量X的话,CPU就会根据缓存一致性协议强制线程A重新从主内存加载最新的值到自己的工作内存,而不是直接用缓存中的值。

再来说内存屏障的问题,volatile修饰之后会加入不同的内存屏障来保证可见性的问题能正确执行。这里写的屏障基于书中提供的内容,但是实际上由于CPU架构不同,重排序的策略不同,提供的内存屏障也不一样,比如x86平台上,只有StoreLoad一种内存屏障。

  1. StoreStore屏障,保证上面的普通写不和volatile写发生重排序
  2. StoreLoad屏障,保证volatile写与后面可能的volatile读写不发生重排序
  3. LoadLoad屏障,禁止volatile读与后面的普通读重排序
  4. LoadStore屏障,禁止volatile读和后面的普通写重排序

那么说说你对JMM内存模型的理解?为什么需要JMM?

本身随着CPU和内存的发展速度差异的问题,导致CPU的速度远快于内存,所以现在的CPU加入了高速缓存,高速缓存一般可以分为L1、L2、L3三级缓存。基于上面的例子我们知道了这导致了缓存一致性的问题,所以加入了缓存一致性协议,同时导致了内存可见性的问题,而编译器和CPU的重排序导致了原子性和有序性的问题,JMM内存模型正是对多线程操作下的一系列规范约束,因为不可能让程序员的代码去兼容所有的CPU,通过JMM我们才屏蔽了不同硬件和操作系统内存的访问差异,这样保证了Java程序在不同的平台下达到一致的内存访问效果,同时也是保证在高效并发的时候程序能够正确执行。

原子性:Java内存模型通过read、load、assign、use、store、write来保证原子性操作,此外还有lock和unlock,直接对应着synchronized关键字的monitorenter和monitorexit字节码指令。

可见性:可见性的问题在上面的回答已经说过,Java保证可见性可以认为通过volatile、synchronized、final来实现。

有序性:由于处理器和编译器的重排序导致的有序性问题,Java通过volatile、synchronized来保证。

happen-before规则

虽然指令重排提高了并发的性能,但是Java虚拟机会对指令重排做出一些规则限制,并不能让所有的指令都随意的改变执行位置,主要有以下几点:

  1. 单线程每个操作,happen-before于该线程中任意后续操作
  2. volatile写happen-before于后续对这个变量的读
  3. synchronized解锁happen-before后续对这个锁的加锁
  4. final变量的写happen-before于final域对象的读,happen-before后续对final变量的读
  5. 传递性规则,A先于B,B先于C,那么A一定先于C发生

说了半天,到底工作内存和主内存是什么?

主内存可以认为就是物理内存,Java内存模型中实际就是虚拟机内存的一部分。而工作内存就是CPU缓存,他有可能是寄存器也有可能是L1\L2\L3缓存,都是有可能的。

说说ThreadLocal原理?

ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。

ThreadLocal有一个静态内部类ThreadLocalMap,ThreadLocalMap又包含了一个Entry数组,Entry本身是一个弱引用,他的key是指向ThreadLocal的弱引用,Entry具备了保存key value键值对的能力。

弱引用的目的是为了防止内存泄露,如果是强引用那么ThreadLocal对象除非线程结束否则始终无法被回收,弱引用则会在下一次GC的时候被回收。

但是这样还是会存在内存泄露的问题,假如key和ThreadLocal对象被回收之后,entry中就存在key为null,但是value有值的entry对象,但是永远没办法被访问到,同样除非线程结束运行。

但是只要ThreadLocal使用恰当,在使用完之后调用remove方法删除Entry对象,实际上是不会出现这个问题的。

那引用类型有哪些?有什么区别?

引用类型主要分为强软弱虚四种:

  1. 强引用指的就是代码中普遍存在的赋值方式,比如A a = new A()这种。强引用关联的对象,永远不会被GC回收。
  2. 软引用可以用SoftReference来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
  3. 弱引用可以用WeakReference来描述,他的强度比软引用更低一点,弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够。
  4. 虚引用也被称作幻影引用,是最弱的引用关系,可以用PhantomReference来描述,他必须和ReferenceQueue一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。

线程池原理知道吗?

首先线程池有几个核心的参数概念:

  1. 最大线程数maximumPoolSize
  2. 核心线程数corePoolSize
  3. 活跃时间keepAliveTime
  4. 阻塞队列workQueue
  5. 拒绝策略RejectedExecutionHandler

当提交一个新任务到线程池时,具体的执行流程如下:

  1. 当我们提交任务,线程池会根据corePoolSize大小创建若干任务数量线程执行任务
  2. 当任务的数量超过corePoolSize数量,后续的任务将会进入阻塞队列阻塞排队
  3. 当阻塞队列也满了之后,那么将会继续创建(maximumPoolSize-corePoolSize)个数量的线程来执行任务,如果任务处理完成,maximumPoolSize-corePoolSize额外创建的线程等待keepAliveTime之后被自动销毁
  4. 如果达到maximumPoolSize,阻塞队列还是满的状态,那么将根据不同的拒绝策略对应处理

拒绝策略有哪些?

主要有4种拒绝策略:

  1. AbortPolicy:直接丢弃任务,抛出异常,这是默认策略
  2. CallerRunsPolicy:只用调用者所在的线程来处理任务
  3. DiscardOldestPolicy:丢弃等待队列中最近的任务,并执行当前任务
  4. DiscardPolicy:直接丢弃任务,也不抛出异常

JVM

这是面试专题系列第五篇JVM篇。

说说JVM的内存布局?

Java虚拟机主要包含几个区域:

:堆Java虚拟机中最大的一块内存,是线程共享的内存区域,基本上所有的对象实例数组都是在堆上分配空间。堆区细分为Yound区年轻代和Old区老年代,其中年轻代又分为Eden、S0、S1 3个部分,他们默认的比例是8:1:1的大小。

:栈是线程私有的内存区域,每个方法执行的时候都会在栈创建一个栈帧,方法的调用过程就对应着栈的入栈和出栈的过程。每个栈帧的结构又包含局部变量表、操作数栈、动态连接、方法返回地址。

局部变量表用于存储方法参数和局部变量。当第一个方法被调用的时候,他的参数会被传递至从0开始的连续的局部变量表中。

操作数栈用于一些字节码指令从局部变量表中传递至操作数栈,也用来准备方法调用的参数以及接收方法返回结果。

动态连接用于将符号引用表示的方法转换为实际方法的直接引用。

元数据:在Java1.7之前,包含方法区的概念,常量池就存在于方法区(永久代)中,而方法区本身是一个逻辑上的概念,在1.7之后则是把常量池移到了堆内,1.8之后移除了永久代的概念(方法区的概念仍然保留),实现方式则是现在的元数据。它包含类的元信息和运行时常量池。

Class文件就是类和接口的定义信息。

运行时常量池就是类和接口的常量池运行时的表现形式。

本地方法栈:主要用于执行本地native方法的区域

程序计数器:也是线程私有的区域,用于记录当前线程下虚拟机正在执行的字节码的指令地址

知道new一个对象的过程吗?

当虚拟机遇见new关键字时候,实现判断当前类是否已经加载,如果类没有加载,首先执行类的加载机制,加载完成后再为对象分配空间、初始化等。

  1. 首先校验当前类是否被加载,如果没有加载,执行类加载机制
  2. 加载:就是从字节码加载成二进制流的过程
  3. 验证:当然加载完成之后,当然需要校验Class文件是否符合虚拟机规范,跟我们接口请求一样,第一件事情当然是先做个参数校验了
  4. 准备:为静态变量、常量赋默认值
  5. 解析:把常量池中符号引用(以符号描述引用的目标)替换为直接引用(指向目标的指针或者句柄等)的过程
  6. 初始化:执行static代码块(cinit)进行初始化,如果存在父类,先对父类进行初始化

Ps:静态代码块是绝对线程安全的,只能隐式被java虚拟机在类加载过程中初始化调用!(此处该有问题static代码块线程安全吗?)

当类加载完成之后,紧接着就是对象分配内存空间和初始化的过程

  1. 首先为对象分配合适大小的内存空间
  2. 接着为实例变量赋默认值
  3. 设置对象的头信息,对象hash码、GC分代年龄、元数据信息等
  4. 执行构造函数(init)初始化

知道双亲委派模型吗?

类加载器自顶向下分为:

  1. Bootstrap ClassLoader启动类加载器:默认会去加载JAVA_HOME/lib目录下的jar
  2. Extention ClassLoader扩展类加载器:默认去加载JAVA_HOME/lib/ext目录下的jar
  3. Application ClassLoader应用程序类加载器:比如我们的web应用,会加载web程序中ClassPath下的类
  4. User ClassLoader用户自定义类加载器:由用户自己定义

当我们在加载类的时候,首先都会向上询问自己的父加载器是否已经加载,如果没有则依次向上询问,如果没有加载,则从上到下依次尝试是否能加载当前类,直到加载成功。

说说有哪些垃圾回收算法?

标记-清除

统一标记出需要回收的对象,标记完成之后统一回收所有被标记的对象,而由于标记的过程需要遍历所有的GC ROOT,清除的过程也要遍历堆中所有的对象,所以标记-清除算法的效率低下,同时也带来了内存碎片的问题。

复制算法

为了解决性能的问题,复制算法应运而生,它将内存分为大小相等的两块区域,每次使用其中的一块,当一块内存使用完之后,将还存活的对象拷贝到另外一块内存区域中,然后把当前内存清空,这样性能和内存碎片的问题得以解决。但是同时带来了另外一个问题,可使用的内存空间缩小了一半!

因此,诞生了我们现在的常见的年轻代+老年代的内存结构:Eden+S0+S1组成,因为根据IBM的研究显示,98%的对象都是朝生夕死,所以实际上存活的对象并不是很多,完全不需要用到一半内存浪费,所以默认的比例是8:1:1。

这样,在使用的时候只使用Eden区和S0S1中的一个,每次都把存活的对象拷贝另外一个未使用的Survivor区,同时清空Eden和使用的Survivor,这样下来内存的浪费就只有10%了。

如果最后未使用的Survivor放不下存活的对象,这些对象就进入Old老年代了。

PS:所以有一些初级点的问题会问你为什么要分为Eden区和2个Survior区?有什么作用?就是为了节省内存和解决内存碎片的问题,这些算法都是为了解决问题而产生的,如果理解原因你就不需要死记硬背了

标记-整理

针对老年代再用复制算法显然不合适,因为进入老年代的对象都存活率比较高了,这时候再频繁的复制对性能影响就比较大,而且也不会再有另外的空间进行兜底。所以针对老年代的特点,通过标记-整理算法,标记出所有的存活对象,让所有存活的对象都向一端移动,然后清理掉边界以外的内存空间。

那么什么是GC ROOT?有哪些GC ROOT?

上面提到的标记的算法,怎么标记一个对象是否存活?简单的通过引用计数法,给对象设置一个引用计数器,每当有一个地方引用他,就给计数器+1,反之则计数器-1,但是这个简单的算法无法解决循环引用的问题。

Java通过可达性分析算法来达到标记存活对象的目的,定义一系列的GC ROOT为起点,从起点开始向下开始搜索,搜索走过的路径称为引用链,当一个对象到GC ROOT没有任何引用链相连的话,则对象可以判定是可以被回收的。

而可以作为GC ROOT的对象包括:

  1. 栈中引用的对象
  2. 静态变量、常量引用的对象
  3. 本地方法栈native方法引用的对象

垃圾回收器了解吗?年轻代和老年代都有哪些垃圾回收器?

年轻代的垃圾收集器包含有Serial、ParNew、Parallell,老年代则包括Serial Old老年代版本、CMS、Parallel Old老年代版本和JDK11中的船新的G1收集器。

Serial:单线程版本收集器,进行垃圾回收的时候会STW(Stop The World),也就是进行垃圾回收的时候其他的工作线程都必须暂停

ParNew:Serial的多线程版本,用于和CMS配合使用

Parallel Scavenge:可以并行收集的多线程垃圾收集器

Serial Old:Serial的老年代版本,也是单线程

Parallel Old:Parallel Scavenge的老年代版本

CMS(Concurrent Mark Sweep):CMS收集器是以获取最短停顿时间为目标的收集器,相对于其他的收集器STW的时间更短暂,可以并行收集是他的特点,同时他基于标记-清除算法,整个GC的过程分为4步。

  1. 初始标记:标记GC ROOT能关联到的对象,需要STW
  2. 并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,不需要STW
  3. 重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生改变的标记,需要STW
  4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象,不需要STW

从整个过程来看,并发标记和并发清除的耗时最长,但是不需要停止用户线程,而初始标记和重新标记的耗时较短,但是需要停止用户线程,总体而言,整个过程造成的停顿时间较短,大部分时候是可以和用户线程一起工作的。

G1(Garbage First):G1收集器是JDK9的默认垃圾收集器,而且不再区分年轻代和老年代进行回收。

G1的原理了解吗?

G1作为JDK9之后的服务端默认收集器,且不再区分年轻代和老年代进行垃圾回收,他把内存划分为多个Region,每个Region的大小可以通过-XX:G1HeapRegionSize设置,大小为1~32M,对于大对象的存储则衍生出Humongous的概念,超过Region大小一半的对象会被认为是大对象,而超过整个Region大小的对象被认为是超级大对象,将会被存储在连续的N个Humongous Region中,G1在进行回收的时候会在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先回收收益最大的Region。

G1的回收过程分为以下四个步骤:

  1. 初始标记:标记GC ROOT能关联到的对象,需要STW
  2. 并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,扫描完成后还会重新处理并发标记过程中产生变动的对象
  3. 最终标记:短暂暂停用户线程,再处理一次,需要STW
  4. 筛选回收:更新Region的统计数据,对每个Region的回收价值和成本排序,根据用户设置的停顿时间制定回收计划。再把需要回收的Region中存活对象复制到空的Region,同时清理旧的Region。需要STW

总的来说除了并发标记之外,其他几个过程也还是需要短暂的STW,G1的目标是在停顿和延迟可控的情况下尽可能提高吞吐量。

什么时候会触发YGC和FGC?对象什么时候会进入老年代?

当一个新的对象来申请内存空间的时候,如果Eden区无法满足内存分配需求,则触发YGC,使用中的Survivor区和Eden区存活对象送到未使用的Survivor区,如果YGC之后还是没有足够空间,则直接进入老年代分配,如果老年代也无法分配空间,触发FGC,FGC之后还是放不下则报出OOM异常。

YGC之后,存活的对象将会被复制到未使用的Survivor区,如果S区放不下,则直接晋升至老年代。而对于那些一直在Survivor区来回复制的对象,通过-XX:MaxTenuringThreshold配置交换阈值,默认15次,如果超过次数同样进入老年代。

此外,还有一种动态年龄的判断机制,不需要等到MaxTenuringThreshold就能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

频繁FullGC怎么排查?

这种问题最好的办法就是结合有具体的例子举例分析,如果没有就说一般的分析步骤。发生FGC有可能是内存分配不合理,比如Eden区太小,导致对象频繁进入老年代,这时候通过启动参数配置就能看出来,另外有可能就是存在内存泄露,可以通过以下的步骤进行排查:

  1. jstat -gcutil或者查看gc.log日志,查看内存回收情况

S0 S1 分别代表两个Survivor区占比

E代表Eden区占比,图中可以看到使用78%

O代表老年代,M代表元空间,YGC发生54次,YGCT代表YGC累计耗时,GCT代表GC累计耗时。

[GC [FGC 开头代表垃圾回收的类型

PSYoungGen: 6130K->6130K(9216K)] 12274K->14330K(19456K), 0.0034895 secs代表YGC前后内存使用情况

Times: user=0.02 sys=0.00, real=0.00 secs,user表示用户态消耗的CPU时间,sys表示内核态消耗的CPU时间,real表示各种墙时钟的等待时间

这两张图只是举例并没有关联关系,比如你从图里面看能到是否进行FGC,FGC的时间花费多长,GC后老年代,年轻代内存是否有减少,得到一些初步的情况来做出判断。

  1. dump出内存文件再具体分析,比如通过jmap命令jmap -dump:format=b,file=dumpfile pid,导出之后再通过Eclipse Memory Analyzer等工具进行分析,定位到代码,修复

这里还会可能存在一个提问的点,比如CPU飙高,同时FGC怎么办?办法比较类似

  1. 找到当前进程的pid,top -p pid -H 查看资源占用,找到线程
  2. printf “%x\n” pid,把线程pid转为16进制,比如0x32d
  3. jstack pid|grep -A 10 0x32d查看线程的堆栈日志,还找不到问题继续
  4. dump出内存文件用MAT等工具进行分析,定位到代码,修复

JVM调优有什么经验吗?

要明白一点,所有的调优的目的都是为了用更小的硬件成本达到更高的吞吐,JVM的调优也是一样,通过对垃圾收集器和内存分配的调优达到性能的最佳。

简单的参数含义

首先,需要知道几个主要的参数含义。

  1. -Xms设置初始堆的大小,-Xmx设置最大堆的大小
  2. -XX:NewSize年轻代大小,-XX:MaxNewSize年轻代最大值,-Xmn则是相当于同时配置-XX:NewSize和-XX:MaxNewSize为一样的值
  3. -XX:NewRatio设置年轻代和年老代的比值,如果为3,表示年轻代与老年代比值为1:3,默认值为2
  4. -XX:SurvivorRatio年轻代和两个Survivor的比值,默认8,代表比值为8:1:1
  5. -XX:PretenureSizeThreshold 当创建的对象超过指定大小时,直接把对象分配在老年代。
  6. -XX:MaxTenuringThreshold设定对象在Survivor复制的最大年龄阈值,超过阈值转移到老年代
  7. -XX:MaxDirectMemorySize当Direct ByteBuffer分配的堆外内存到达指定大小后,即触发Full GC

调优

  1. 为了打印日志方便排查问题最好开启GC日志,开启GC日志对性能影响微乎其微,但是能帮助我们快速排查定位问题。-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log
  2. 一般设置-Xms=-Xmx,这样可以获得固定大小的堆内存,减少GC的次数和耗时,可以使得堆相对稳定
  3. -XX:+HeapDumpOnOutOfMemoryError让JVM在发生内存溢出的时候自动生成内存快照,方便排查问题
  4. -Xmn设置年轻代的大小,太小会增加YGC,太大会减小老年代大小,一般设置为整个堆的1/4到1/3
  5. 设置-XX:+DisableExplicitGC禁止系统System.gc(),防止手动误触发FGC造成问题

[========]

Mysql

想进大厂,mysql不会那可不行,来接受mysql面试挑战吧,看看你能坚持到哪里?

1. 能说下myisam 和 innodb的区别吗?

myisam引擎是5.1版本之前的默认引擎,支持全文检索、压缩、空间函数等,但是不支持事务和行级锁,所以一般用于有大量查询少量插入的场景来使用,而且myisam不支持外键,并且索引和数据是分开存储的。

innodb是基于聚簇索引建立的,和myisam相反它支持事务、外键,并且通过MVCC来支持高并发,索引和数据存储在一起。

2. 说下mysql的索引有哪些吧,聚簇和非聚簇索引又是什么?

索引按照数据结构来说主要包含B+树和Hash索引。

假设我们有张表,结构如下:

create table user(
  id int(11) not null,
  age int(11) not null,
  primary key(id),
  key(age)
);

B+树是左小右大的顺序存储结构,节点只包含id索引列,而叶子节点包含索引列和数据,这种数据和索引在一起存储的索引方式叫做聚簇索引,一张表只能有一个聚簇索引。假设没有定义主键,InnoDB会选择一个唯一的非空索引代替,如果没有的话则会隐式定义一个主键作为聚簇索引。

这是主键聚簇索引存储的结构,那么非聚簇索引的结构是什么样子呢?非聚簇索引(二级索引)保存的是主键id值,这一点和myisam保存的是数据地址是不同的。

最终,我们一张图看看InnoDB和Myisam聚簇和非聚簇索引的区别

3. 那你知道什么是覆盖索引和回表吗?

覆盖索引指的是在一次查询中,如果一个索引包含或者说覆盖所有需要查询的字段的值,我们就称之为覆盖索引,而不再需要回表查询。

而要确定一个查询是否是覆盖索引,我们只需要explain sql语句看Extra的结果是否是“Using index”即可。

以上面的user表来举例,我们再增加一个name字段,然后做一些查询试试。

explain select * from user where age=1; //查询的name无法从索引数据获取
explain select id,age from user where age=1; //可以直接从索引获取

4. 锁的类型有哪些呢

mysql锁分为共享锁排他锁,也叫做读锁和写锁。

读锁是共享的,可以通过lock in share mode实现,这时候只能读不能写。

写锁是排他的,它会阻塞其他的写锁和读锁。从颗粒度来区分,可以分为表锁行锁两种。

表锁会锁定整张表并且阻塞其他用户对该表的所有读写操作,比如alter修改表结构的时候会锁表。

行锁又可以分为乐观锁悲观锁,悲观锁可以通过for update实现,乐观锁则通过版本号实现。

5. 你能说下事务的基本特性和隔离级别吗?

事务基本特性ACID分别是:

原子性指的是一个事务中的操作要么全部成功,要么全部失败。

一致性指的是数据库总是从一个一致性的状态转换到另外一个一致性的状态。比如A转账给B100块钱,假设中间sql执行过程中系统崩溃A也不会损失100块,因为事务没有提交,修改也就不会保存到数据库。

隔离性指的是一个事务的修改在最终提交前,对其他事务是不可见的。

持久性指的是一旦事务提交,所做的修改就会永久保存到数据库中。

而隔离性有4个隔离级别,分别是:

read uncommit 读未提交,可能会读到其他事务未提交的数据,也叫做脏读。

用户本来应该读取到id=1的用户age应该是10,结果读取到了其他事务还没有提交的事务,结果读取结果age=20,这就是脏读。

read commit 读已提交,两次读取结果不一致,叫做不可重复读。

不可重复读解决了脏读的问题,他只会读取已经提交的事务。

用户开启事务读取id=1用户,查询到age=10,再次读取发现结果=20,在同一个事务里同一个查询读取到不同的结果叫做不可重复读。

repeatable read 可重复复读,这是mysql的默认级别,就是每次读取结果都一样,但是有可能产生幻读。

serializable 串行,一般是不会使用的,他会给每一行读取的数据加锁,会导致大量超时和锁竞争的问题。

6. 那ACID靠什么保证的呢?

A原子性由undo log日志保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的sql

C一致性一般由代码层面来保证

I隔离性由MVCC来保证

D持久性由内存+redo log来保证,mysql修改数据同时在内存和redo log记录这次操作,事务提交的时候通过redo log刷盘,宕机的时候可以从redo log恢复

7. 那你说说什么是幻读,什么是MVCC?

要说幻读,首先要了解MVCC,MVCC叫做多版本并发控制,实际上就是保存了数据在某个时间节点的快照。

我们每行数据实际上隐藏了两列,创建时间版本号,过期(删除)时间版本号,每开始一个新的事务,版本号都会自动递增。

还是拿上面的user表举例子,假设我们插入两条数据,他们实际上应该长这样。

id name create_version delete_version

这时候假设小明去执行查询,此时current_version=3

select * from user where id<=3;

同时,小红在这时候开启事务去修改id=1的记录,current_version=4

update user set name='张三三' where id=1;

执行成功后的结果是这样的

id name create_version delete_version

如果这时候还有小黑在删除id=2的数据,current_version=5,执行后结果是这样的。

id name create_version delete_version

由于MVCC的原理是查找创建版本小于或等于当前事务版本,删除版本为空或者大于当前事务版本,小明的真实的查询应该是这样

select * from user where id<=3 and create_version<=3 and (delete_version>3 or delete_version is null);

所以小明最后查询到的id=1的名字还是'张三',并且id=2的记录也能查询到。这样做是为了保证事务读取的数据是在事务开始前就已经存在的,要么是事务自己插入或者修改的

明白MVCC原理,我们来说什么是幻读就简单多了。举一个常见的场景,用户注册时,我们先查询用户名是否存在,不存在就插入,假定用户名是唯一索引。

  1. 小明开启事务current_version=6查询名字为'王五'的记录,发现不存在。
  2. 小红开启事务current_version=7插入一条数据,结果是这样:
id Name create_version delete_version
  1. 小明执行插入名字'王五'的记录,发现唯一索引冲突,无法插入,这就是幻读。

8. 那你知道什么是间隙锁吗?

间隙锁是可重复读级别下才会有的锁,结合MVCC和间隙锁可以解决幻读的问题。我们还是以user举例,假设现在user表有几条记录

id Age

当我们执行:

begin;
select * from user where age=20 for update;

begin;
insert into user(age) values(10); #成功
insert into user(age) values(11); #失败
insert into user(age) values(20); #失败
insert into user(age) values(21); #失败
insert into user(age) values(30); #失败

只有10可以插入成功,那么因为表的间隙mysql自动帮我们生成了区间(左开右闭)

(negative infinity,10],(10,20],(20,30],(30,positive infinity)

由于20存在记录,所以(10,20],(20,30]区间都被锁定了无法插入、删除。

如果查询21呢?就会根据21定位到(20,30)的区间(都是开区间)。

需要注意的是唯一索引是不会有间隙索引的。

9. 你们数据量级多大?分库分表怎么做的?

首先分库分表分为垂直和水平两个方式,一般来说我们拆分的顺序是先垂直后水平。

垂直分库

基于现在微服务拆分来说,都是已经做到了垂直分库了

垂直分表

如果表字段比较多,将不常用的、数据较大的等等做拆分

水平分表

首先根据业务场景来决定使用什么字段作为分表字段(sharding_key),比如我们现在日订单1000万,我们大部分的场景来源于C端,我们可以用user_id作为sharding_key,数据查询支持到最近3个月的订单,超过3个月的做归档处理,那么3个月的数据量就是9亿,可以分1024张表,那么每张表的数据大概就在100万左右。

比如用户id为100,那我们都经过hash(100),然后对1024取模,就可以落到对应的表上了。

10. 那分表后的ID怎么保证唯一性的呢?

因为我们主键默认都是自增的,那么分表之后的主键在不同表就肯定会有冲突了。有几个办法考虑:

  1. 设定步长,比如1-1024张表我们设定1024的基础步长,这样主键落到不同的表就不会冲突了。
  2. 分布式ID,自己实现一套分布式ID生成算法或者使用开源的比如雪花算法这种
  3. 分表后不使用主键作为查询依据,而是每张表单独新增一个字段作为唯一主键使用,比如订单表订单号是唯一的,不管最终落在哪张表都基于订单号作为查询依据,更新也一样。

11. 分表后非sharding_key的查询怎么处理呢?

  1. 可以做一个mapping表,比如这时候商家要查询订单列表怎么办呢?不带user_id查询的话你总不能扫全表吧?所以我们可以做一个映射关系表,保存商家和用户的关系,查询的时候先通过商家查询到用户列表,再通过user_id去查询。
  2. 打宽表,一般而言,商户端对数据实时性要求并不是很高,比如查询订单列表,可以把订单表同步到离线(实时)数仓,再基于数仓去做成一张宽表,再基于其他如es提供查询服务。
  3. 数据量不是很大的话,比如后台的一些查询之类的,也可以通过多线程扫表,然后再聚合结果的方式来做。或者异步的形式也是可以的。
List<Callable<List<User>>> taskList = Lists.newArrayList();
for (int shardingIndex = 0; shardingIndex < 1024; shardingIndex++) {
    taskList.add(() -> (userMapper.getProcessingAccountList(shardingIndex)));
}
List<ThirdAccountInfo> list = null;
try {
    list = taskExecutor.executeTask(taskList);
} catch (Exception e) {
    //do something
}

public class TaskExecutor {
    public <T> List<T> executeTask(Collection<? extends Callable<T>> tasks) throws Exception {
        List<T> result = Lists.newArrayList();
        List<Future<T>> futures = ExecutorUtil.invokeAll(tasks);
        for (Future<T> future : futures) {
            result.add(future.get());
        }
        return result;
    }
}

12. 说说mysql主从同步怎么做的吧?

首先先了解mysql主从同步的原理

  1. master提交完事务后,写入binlog
  2. slave连接到master,获取binlog
  3. master创建dump线程,推送binglog到slave
  4. slave启动一个IO线程读取同步过来的master的binlog,记录到relay log中继日志中
  5. slave再开启一个sql线程读取relay log事件并在slave执行,完成同步
  6. slave记录自己的binglog

由于mysql默认的复制方式是异步的,主库把日志发送给从库后不关心从库是否已经处理,这样会产生一个问题就是假设主库挂了,从库处理失败了,这时候从库升为主库后,日志就丢失了。由此产生两个概念。

全同步复制

主库写入binlog后强制同步日志到从库,所有的从库都执行完成后才返回给客户端,但是很显然这个方式的话性能会受到严重影响。

半同步复制

和全同步不同的是,半同步复制的逻辑是这样,从库写入日志成功后返回ACK确认给主库,主库收到至少一个从库的确认就认为写操作完成。

13. 那主从的延迟怎么解决呢?

  1. 针对特定的业务场景,读写请求都强制走主库
  2. 读请求走从库,如果没有数据,去主库做二次查询

Redis

这是面试题系列第三篇--redis专题。

说说Redis基本数据类型有哪些吧

  1. 字符串:redis没有直接使用C语言传统的字符串表示,而是自己实现的叫做简单动态字符串SDS的抽象类型。C语言的字符串不记录自身的长度信息,而SDS则保存了长度信息,这样将获取字符串长度的时间由O(N)降低到了O(1),同时可以避免缓冲区溢出和减少修改字符串长度时所需的内存重分配次数。
  2. 链表linkedlist:redis链表是一个双向无环链表结构,很多发布订阅、慢查询、监视器功能都是使用到了链表来实现,每个链表的节点由一个listNode结构来表示,每个节点都有指向前置节点和后置节点的指针,同时表头节点的前置和后置节点都指向NULL。
  3. 字典hashtable:用于保存键值对的抽象数据结构。redis使用hash表作为底层实现,每个字典带有两个hash表,供平时使用和rehash时使用,hash表使用链地址法来解决键冲突,被分配到同一个索引位置的多个键值对会形成一个单向链表,在对hash表进行扩容或者缩容的时候,为了服务的可用性,rehash的过程不是一次性完成的,而是渐进式的。
  4. 跳跃表skiplist:跳跃表是有序集合的底层实现之一,redis中在实现有序集合键和集群节点的内部结构中都是用到了跳跃表。redis跳跃表由zskiplist和zskiplistNode组成,zskiplist用于保存跳跃表信息(表头、表尾节点、长度等),zskiplistNode用于表示表跳跃节点,每个跳跃表的层高都是1-32的随机数,在同一个跳跃表中,多个节点可以包含相同的分值,但是每个节点的成员对象必须是唯一的,节点按照分值大小排序,如果分值相同,则按照成员对象的大小排序。
  5. 整数集合intset:用于保存整数值的集合抽象数据结构,不会出现重复元素,底层实现为数组。
  6. 压缩列表ziplist:压缩列表是为节约内存而开发的顺序性数据结构,他可以包含多个节点,每个节点可以保存一个字节数组或者整数值。

基于这些基础的数据结构,redis封装了自己的对象系统,包含字符串对象string、列表对象list、哈希对象hash、集合对象set、有序集合对象zset,每种对象都用到了至少一种基础的数据结构。

redis通过encoding属性设置对象的编码形式来提升灵活性和效率,基于不同的场景redis会自动做出优化。不同对象的编码如下:

  1. 字符串对象string:int整数、embstr编码的简单动态字符串、raw简单动态字符串
  2. 列表对象list:ziplist、linkedlist
  3. 哈希对象hash:ziplist、hashtable
  4. 集合对象set:intset、hashtable
  5. 有序集合对象zset:ziplist、skiplist

Redis为什么快呢?

redis的速度非常的快,单机的redis就可以支撑每秒10几万的并发,相对于mysql来说,性能是mysql的几十倍。速度快的原因主要有几点:

  1. 完全基于内存操作
  2. C语言实现,优化过的数据结构,基于几种基础的数据结构,redis做了大量的优化,性能极高
  3. 使用单线程,无上下文的切换成本
  4. 基于非阻塞的IO多路复用机制

那为什么Redis6.0之后又改用多线程呢?

redis使用多线程并非是完全摒弃单线程,redis还是使用单线程模型来处理客户端的请求,只是使用多线程来处理数据的读写和协议解析,执行命令还是使用单线程。

这样做的目的是因为redis的性能瓶颈在于网络IO而非CPU,使用多线程能提升IO读写的效率,从而整体提高redis的性能。

知道什么是热key吗?热key问题怎么解决?

所谓热key问题就是,突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机引发雪崩。

针对热key的解决方案:

  1. 提前把热key打散到不同的服务器,降低压力
  2. 加入二级缓存,提前加载热key数据到内存中,如果redis宕机,走内存查询

什么是缓存击穿、缓存穿透、缓存雪崩?

缓存击穿

缓存击穿的概念就是单个key并发访问过高,过期时导致所有请求直接打到db上,这个和热key的问题比较类似,只是说的点在于过期导致请求全部打到DB上而已。

解决方案:

  1. 加锁更新,比如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
  2. 将过期时间组合写在value中,通过异步的方式不断的刷新过期时间,防止此类现象。

https://tva

缓存穿透

缓存穿透是指查询不存在缓存中的数据,每次请求都会打到DB,就像缓存不存在一样。

针对这个问题,加一层布隆过滤器。布隆过滤器的原理是在你存入数据的时候,会通过散列函数将它映射为一个位数组中的K个点,同时把他们置为1。

这样当用户再次来查询A,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。

显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要我们的数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好了。

缓存雪崩

当某一时刻发生大规模的缓存失效的情况,比如你的缓存服务宕机了,会有大量的请求进来直接打到DB上,这样可能导致整个系统的崩溃,称为雪崩。雪崩和击穿、热key的问题不太一样的是,他是指大规模的缓存都过期失效了。

针对雪崩几个解决方案:

  1. 针对不同key设置不同的过期时间,避免同时过期
  2. 限流,如果redis宕机,可以限流,避免同时刻大量请求打崩DB
  3. 二级缓存,同热key的方案。

Redis的过期策略有哪些?

redis主要有2种过期删除策略

惰性删除

惰性删除指的是当我们查询key的时候才对key进行检测,如果已经达到过期时间,则删除。显然,他有一个缺点就是如果这些过期的key没有被访问,那么他就一直无法被删除,而且一直占用内存。

定期删除

定期删除指的是redis每隔一段时间对数据库做一次检查,删除里面的过期key。由于不可能对所有key去做轮询来删除,所以redis会每次随机取一些key去做检查和删除。

那么定期+惰性都没有删除过期的key怎么办?

假设redis每次定期随机查询key的时候没有删掉,这些key也没有做查询的话,就会导致这些key一直保存在redis里面无法被删除,这时候就会走到redis的内存淘汰机制。

  1. volatile-lru:从已设置过期时间的key中,移除最近最少使用的key进行淘汰
  2. volatile-ttl:从已设置过期时间的key中,移除将要过期的key
  3. volatile-random:从已设置过期时间的key中随机选择key淘汰
  4. allkeys-lru:从key中选择最近最少使用的进行淘汰
  5. allkeys-random:从key中随机选择key进行淘汰
  6. noeviction:当内存达到阈值的时候,新写入操作报错

持久化方式有哪些?有什么区别?

redis持久化方案分为RDB和AOF两种。

RDB

RDB持久化可以手动执行也可以根据配置定期执行,它的作用是将某个时间点上的数据库状态保存到RDB文件中,RDB文件是一个压缩的二进制文件,通过它可以还原某个时刻数据库的状态。由于RDB文件是保存在硬盘上的,所以即使redis崩溃或者退出,只要RDB文件存在,就可以用它来恢复还原数据库的状态。

可以通过SAVE或者BGSAVE来生成RDB文件。

SAVE命令会阻塞redis进程,直到RDB文件生成完毕,在进程阻塞期间,redis不能处理任何命令请求,这显然是不合适的。

BGSAVE则是会fork出一个子进程,然后由子进程去负责生成RDB文件,父进程还可以继续处理命令请求,不会阻塞进程。

AOF

AOF和RDB不同,AOF是通过保存redis服务器所执行的写命令来记录数据库状态的。

AOF通过追加、写入、同步三个步骤来实现持久化机制。

  1. 当AOF持久化处于激活状态,服务器执行完写命令之后,写命令将会被追加append到aof_buf缓冲区的末尾
  2. 在服务器每结束一个事件循环之前,将会调用flushAppendOnlyFile函数决定是否要将aof_buf的内容保存到AOF文件中,可以通过配置appendfsync来决定。
always ##aof_buf内容写入并同步到AOF文件
everysec ##将aof_buf中内容写入到AOF文件,如果上次同步AOF文件时间距离现在超过1秒,则再次对AOF文件进行同步
no ##将aof_buf内容写入AOF文件,但是并不对AOF文件进行同步,同步时间由操作系统决定

如果不设置,默认选项将会是everysec,因为always来说虽然最安全(只会丢失一次事件循环的写命令),但是性能较差,而everysec模式只不过会可能丢失1秒钟的数据,而no模式的效率和everysec相仿,但是会丢失上次同步AOF文件之后的所有写命令数据。

怎么实现Redis的高可用?

要想实现高可用,一台机器肯定是不够的,而redis要保证高可用,有2个可选方案。

主从架构

主从模式是最简单的实现高可用的方案,核心就是主从同步。主从同步的原理如下:

  1. slave发送sync命令到master
  2. master收到sync之后,执行bgsave,生成RDB全量文件
  3. master把slave的写命令记录到缓存
  4. bgsave执行完毕之后,发送RDB文件到slave,slave执行
  5. master发送缓存中的写命令到slave,slave执行

这里我写的这个命令是sync,但是在redis2.8版本之后已经使用psync来替代sync了,原因是sync命令非常消耗系统资源,而psync的效率更高。

哨兵

基于主从方案的缺点还是很明显的,假设master宕机,那么就不能写入数据,那么slave也就失去了作用,整个架构就不可用了,除非你手动切换,主要原因就是因为没有自动故障转移机制。而哨兵(sentinel)的功能比单纯的主从架构全面的多了,它具备自动故障转移、集群监控、消息通知等功能。

哨兵可以同时监视多个主从服务器,并且在被监视的master下线时,自动将某个slave提升为master,然后由新的master继续接收命令。整个过程如下:

  1. 初始化sentinel,将普通的redis代码替换成sentinel专用代码
  2. 初始化masters字典和服务器信息,服务器信息主要保存ip:port,并记录实例的地址和ID
  3. 创建和master的两个连接,命令连接和订阅连接,并且订阅sentinel:hello频道
  4. 每隔10秒向master发送info命令,获取master和它下面所有slave的当前信息
  5. 当发现master有新的slave之后,sentinel和新的slave同样建立两个连接,同时每个10秒发送info命令,更新master信息
  6. sentinel每隔1秒向所有服务器发送ping命令,如果某台服务器在配置的响应时间内连续返回无效回复,将会被标记为下线状态
  7. 选举出领头sentinel,领头sentinel需要半数以上的sentinel同意
  8. 领头sentinel从已下线的的master所有slave中挑选一个,将其转换为master
  9. 让所有的slave改为从新的master复制数据
  10. 将原来的master设置为新的master的从服务器,当原来master重新回复连接时,就变成了新master的从服务器

sentinel会每隔1秒向所有实例(包括主从服务器和其他sentinel)发送ping命令,并且根据回复判断是否已经下线,这种方式叫做主观下线。当判断为主观下线时,就会向其他监视的sentinel询问,如果超过半数的投票认为已经是下线状态,则会标记为客观下线状态,同时触发故障转移。

能说说redis集群的原理吗?

如果说依靠哨兵可以实现redis的高可用,如果还想在支持高并发同时容纳海量的数据,那就需要redis集群。redis集群是redis提供的分布式数据存储方案,集群通过数据分片sharding来进行数据的共享,同时提供复制和故障转移的功能。

节点

一个redis集群由多个节点node组成,而多个node之间通过cluster meet命令来进行连接,节点的握手过程:

  1. 节点A收到客户端的cluster meet命令
  2. A根据收到的IP地址和端口号,向B发送一条meet消息
  3. 节点B收到meet消息返回pong
  4. A知道B收到了meet消息,返回一条ping消息,握手成功
  5. 最后,节点A将会通过gossip协议把节点B的信息传播给集群中的其他节点,其他节点也将和B进行握手

槽slot

redis通过集群分片的形式来保存数据,整个集群数据库被分为16384个slot,集群中的每个节点可以处理0-16383个slot,当数据库16384个slot都有节点在处理时,集群处于上线状态,反之只要有一个slot没有得到处理都会处理下线状态。通过cluster addslots命令可以将slot指派给对应节点处理。

slot是一个位数组,数组的长度是16384/8=2048,而数组的每一位用1表示被节点处理,0表示不处理,如图所示的话表示A节点处理0-7的slot。

当客户端向节点发送命令,如果刚好找到slot属于当前节点,那么节点就执行命令,反之,则会返回一个MOVED命令到客户端指引客户端转向正确的节点。(MOVED过程是自动的)

如果增加或者移出节点,对于slot的重新分配也是非常方便的,redis提供了工具帮助实现slot的迁移,整个过程是完全在线的,不需要停止服务。

故障转移

如果节点A向节点B发送ping消息,节点B没有在规定的时间内响应pong,那么节点A会标记节点B为pfail疑似下线状态,同时把B的状态通过消息的形式发送给其他节点,如果超过半数以上的节点都标记B为pfail状态,B就会被标记为fail下线状态,此时将会发生故障转移,优先从复制数据较多的从节点选择一个成为主节点,并且接管下线节点的slot,整个过程和哨兵非常类似,都是基于Raft协议做选举。

了解Redis事务机制吗?

redis通过MULTI、EXEC、WATCH等命令来实现事务机制,事务执行过程将一系列多个命令按照顺序一次性执行,并且在执行期间,事务不会被中断,也不会去执行客户端的其他请求,直到所有命令执行完毕。事务的执行过程如下:

  1. 服务端收到客户端请求,事务以MULTI开始
  2. 如果客户端正处于事务状态,则会把事务放入队列同时返回给客户端QUEUED,反之则直接执行这个命令
  3. 当收到客户端EXEC命令时,WATCH命令监视整个事务中的key是否有被修改,如果有则返回空回复到客户端表示失败,否则redis会遍历整个事务队列,执行队列中保存的所有命令,最后返回结果给客户端

WATCH的机制本身是一个CAS的机制,被监视的key会被保存到一个链表中,如果某个key被修改,那么REDIS_DIRTY_CAS标志将会被打开,这时服务器会拒绝执行事务。

MQ

继之前的mysql夺命连环之后,我发现我这个标题被好多套用的,什么夺命zookeeper,夺命多线程一大堆,这一次,开始面试题系列MQ专题,消息队列作为日常常见的使用中间件,面试也是必问的点之一,一起来看看MQ的面试题。

你们为什么使用mq?具体的使用场景是什么?

mq的作用很简单,削峰填谷。以电商交易下单的场景来说,正向交易的过程可能涉及到创建订单、扣减库存、扣减活动预算、扣减积分等等。每个接口的耗时如果是100ms,那么理论上整个下单的链路就需要耗费400ms,这个时间显然是太长了。

如果这些操作全部同步处理的话,首先调用链路太长影响接口性能,其次分布式事务的问题很难处理,这时候像扣减预算和积分这种对实时一致性要求没有那么高的请求,完全就可以通过mq异步的方式去处理了。同时,考虑到异步带来的不一致的问题,我们可以通过job去重试保证接口调用成功,而且一般公司都会有核对的平台,比如下单成功但是未扣减积分的这种问题可以通过核对作为兜底的处理方案。

使用mq之后我们的链路变简单了,同时异步发送消息我们的整个系统的抗压能力也上升了。

那你们使用什么mq?基于什么做的选型?

我们主要调研了几个主流的mq,kafka、rabbitmq、rocketmq、activemq,选型我们主要基于以下几个点去考虑:

  1. 由于我们系统的qps压力比较大,所以性能是首要考虑的要素。
  2. 开发语言,由于我们的开发语言是java,主要是为了方便二次开发。
  3. 对于高并发的业务场景是必须的,所以需要支持分布式架构的设计。
  4. 功能全面,由于不同的业务场景,可能会用到顺序消息、事务消息等。

基于以上几个考虑,我们最终选择了RocketMQ。

你上面提到异步发送,那消息可靠性怎么保证?

消息丢失可能发生在生产者发送消息、MQ本身丢失消息、消费者丢失消息3个方面。

生产者丢失

生产者丢失消息的可能点在于程序发送失败抛异常了没有重试处理,或者发送的过程成功但是过程中网络闪断MQ没收到,消息就丢失了。

由于同步发送的一般不会出现这样使用方式,所以我们就不考虑同步发送的问题,我们基于异步发送的场景来说。

异步发送分为两个方式:异步有回调和异步无回调,无回调的方式,生产者发送完后不管结果可能就会造成消息丢失,而通过异步发送+回调通知+本地消息表的形式我们就可以做出一个解决方案。以下单的场景举例。

  1. 下单后先保存本地数据和MQ消息表,这时候消息的状态是发送中,如果本地事务失败,那么下单失败,事务回滚。
  2. 下单成功,直接返回客户端成功,异步发送MQ消息
  3. MQ回调通知消息发送结果,对应更新数据库MQ发送状态
  4. JOB轮询超过一定时间(时间根据业务配置)还未发送成功的消息去重试
  5. 在监控平台配置或者JOB程序处理超过一定次数一直发送不成功的消息,告警,人工介入。

一般而言,对于大部分场景来说异步回调的形式就可以了,只有那种需要完全保证不能丢失消息的场景我们做一套完整的解决方案。

MQ丢失

如果生产者保证消息发送到MQ,而MQ收到消息后还在内存中,这时候宕机了又没来得及同步给从节点,就有可能导致消息丢失。

比如RocketMQ:

RocketMQ分为同步刷盘和异步刷盘两种方式,默认的是异步刷盘,就有可能导致消息还未刷到硬盘上就丢失了,可以通过设置为同步刷盘的方式来保证消息可靠性,这样即使MQ挂了,恢复的时候也可以从磁盘中去恢复消息。

比如Kafka也可以通过配置做到:

acks=all 只有参与复制的所有节点全部收到消息才返回生产者成功这样的话除非所有的节点都挂了消息才会丢失
replication.factor=N,设置大于1的数这会要求每个partion至少有2个副本
min.insync.replicas=N设置大于1的数这会要求leader至少感知到一个follower还保持着连接
retries=N设置一个非常大的值让生产者发送失败一直重试

虽然我们可以通过配置的方式来达到MQ本身高可用的目的,但是都对性能有损耗,怎样配置需要根据业务做出权衡。

消费者丢失

消费者丢失消息的场景:消费者刚收到消息,此时服务器宕机,MQ认为消费者已经消费,不会重复发送消息,消息丢失。

RocketMQ默认是需要消费者回复ack确认,而kafka需要手动开启配置关闭自动offset。

消费方不返回ack确认,重发的机制根据MQ类型的不同发送时间间隔、次数都不尽相同,如果重试超过次数之后会进入死信队列,需要手工来处理了。(Kafka没有这些)

你说到消费者消费失败的问题,那么如果一直消费失败导致消息积压怎么处理?

因为考虑到时消费者消费一直出错的问题,那么我们可以从以下几个角度来考虑:

  1. 消费者出错,肯定是程序或者其他问题导致的,如果容易修复,先把问题修复,让consumer恢复正常消费
  2. 如果时间来不及处理很麻烦,做转发处理,写一个临时的consumer消费方案,先把消息消费,然后再转发到一个新的topic和MQ资源,这个新的topic的机器资源单独申请,要能承载住当前积压的消息
  3. 处理完积压数据后,修复consumer,去消费新的MQ和现有的MQ数据,新MQ消费完成后恢复原状

那如果消息积压达到磁盘上限,消息被删除了怎么办?

这。。。他妈都删除了我有啥办法啊。。。冷静,再想想。。有了。

最初,我们发送的消息记录是落库保存了的,而转发发送的数据也保存了,那么我们就可以通过这部分数据来找到丢失的那部分数据,再单独跑个脚本重发就可以了。如果转发的程序没有落库,那就和消费方的记录去做对比,只是过程会更艰难一点。

说了这么多,那你说说RocketMQ实现原理吧?

RocketMQ由NameServer注册中心集群、Producer生产者集群、Consumer消费者集群和若干Broker(RocketMQ进程)组成,它的架构原理是这样的:

  1. Broker在启动的时候去向所有的NameServer注册,并保持长连接,每30s发送一次心跳
  2. Producer在发送消息的时候从NameServer获取Broker服务器地址,根据负载均衡算法选择一台服务器来发送消息
  3. Conusmer消费消息的时候同样从NameServer获取Broker地址,然后主动拉取消息来消费

为什么RocketMQ不使用Zookeeper作为注册中心呢?

我认为有以下几个点是不使用zookeeper的原因:

  1. 根据CAP理论,同时最多只能满足两个点,而zookeeper满足的是CP,也就是说zookeeper并不能保证服务的可用性,zookeeper在进行选举的时候,整个选举的时间太长,期间整个集群都处于不可用的状态,而这对于一个注册中心来说肯定是不能接受的,作为服务发现来说就应该是为可用性而设计。
  2. 基于性能的考虑,NameServer本身的实现非常轻量,而且可以通过增加机器的方式水平扩展,增加集群的抗压能力,而zookeeper的写是不可扩展的,而zookeeper要解决这个问题只能通过划分领域,划分多个zookeeper集群来解决,首先操作起来太复杂,其次这样还是又违反了CAP中的A的设计,导致服务之间是不连通的。
  3. 持久化的机制来带的问题,ZooKeeper 的 ZAB 协议对每一个写请求,会在每个 ZooKeeper 节点上保持写一个事务日志,同时再加上定期的将内存数据镜像(Snapshot)到磁盘来保证数据的一致性和持久性,而对于一个简单的服务发现的场景来说,这其实没有太大的必要,这个实现方案太重了。而且本身存储的数据应该是高度定制化的。
  4. 消息发送应该弱依赖注册中心,而RocketMQ的设计理念也正是基于此,生产者在第一次发送消息的时候从NameServer获取到Broker地址后缓存到本地,如果NameServer整个集群不可用,短时间内对于生产者和消费者并不会产生太大影响。

那Broker是怎么保存数据的呢?

RocketMQ主要的存储文件包括commitlog文件、consumequeue文件、indexfile文件。

Broker在收到消息之后,会把消息保存到commitlog的文件当中,而同时在分布式的存储当中,每个broker都会保存一部分topic的数据,同时,每个topic对应的messagequeue下都会生成consumequeue文件用于保存commitlog的物理位置偏移量offset,indexfile中会保存key和offset的对应关系。

CommitLog文件保存于${Rocket_Home}/store/commitlog目录中,从图中我们可以明显看出来文件名的偏移量,每个文件默认1G,写满后自动生成一个新的文件。

由于同一个topic的消息并不是连续的存储在commitlog中,消费者如果直接从commitlog获取消息效率非常低,所以通过consumequeue保存commitlog中消息的偏移量的物理地址,这样消费者在消费的时候先从consumequeue中根据偏移量定位到具体的commitlog物理文件,然后根据一定的规则(offset和文件大小取模)在commitlog中快速定位。

Master和Slave之间是怎么同步数据的呢?

而消息在master和slave之间的同步是根据raft协议来进行的:

  1. 在broker收到消息后,会被标记为uncommitted状态
  2. 然后会把消息发送给所有的slave
  3. slave在收到消息之后返回ack响应给master
  4. master在收到超过半数的ack之后,把消息标记为committed
  5. 发送committed消息给所有slave,slave也修改状态为committed

你知道RocketMQ为什么速度快吗?

是因为使用了顺序存储、Page Cache和异步刷盘。

  1. 我们在写入commitlog的时候是顺序写入的,这样比随机写入的性能就会提高很多
  2. 写入commitlog的时候并不是直接写入磁盘,而是先写入操作系统的PageCache
  3. 最后由操作系统异步将缓存中的数据刷到磁盘

什么是事务、半事务消息?怎么实现的?

事务消息就是MQ提供的类似XA的分布式事务能力,通过事务消息可以达到分布式事务的最终一致性。

半事务消息就是MQ收到了生产者的消息,但是没有收到二次确认,不能投递的消息。

实现原理如下:

  1. 生产者先发送一条半事务消息到MQ
  2. MQ收到消息后返回ack确认
  3. 生产者开始执行本地事务
  4. 如果事务执行成功发送commit到MQ,失败发送rollback
  5. 如果MQ长时间未收到生产者的二次确认commit或者rollback,MQ对生产者发起消息回查
  6. 生产者查询事务执行最终状态
  7. 根据查询事务状态再次提交二次确认

最终,如果MQ收到二次确认commit,就可以把消息投递给消费者,反之如果是rollback,消息会保存下来并且在3天后被删除。

Spring

1.说说Spring 里用到了哪些设计模式?

单例模式:Spring 中的 Bean 默认情况下都是单例的。无需多说。

工厂模式:工厂模式主要是通过 BeanFactory 和 ApplicationContext 来生产 Bean 对象。

代理模式:最常见的 AOP 的实现方式就是通过代理来实现,Spring主要是使用 JDK 动态代理和 CGLIB 代理。

模板方法模式:主要是一些对数据库操作的类用到,比如 JdbcTemplate、JpaTemplate,因为查询数据库的建立连接、执行查询、关闭连接几个过程,非常适用于模板方法。

2.谈谈你对IOC 和 AOP 的理解?他们的实现原理是什么?

IOC 叫做控制反转,指的是通过Spring来管理对象的创建、配置和生命周期,这样相当于把控制权交给了Spring,不需要人工来管理对象之间复杂的依赖关系,这样做的好处就是解耦。在Spring里面,主要提供了 BeanFactory 和 ApplicationContext 两种 IOC 容器,通过他们来实现对 Bean 的管理。

AOP 叫做面向切面编程,他是一个编程范式,目的就是提高代码的模块性。Spring AOP 基于动态代理的方式实现,如果是实现了接口的话就会使用 JDK 动态代理,反之则使用 CGLIB 代理,Spring中 AOP 的应用主要体现在 事务、日志、异常处理等方面,通过在代码的前后做一些增强处理,可以实现对业务逻辑的隔离,提高代码的模块化能力,同时也是解耦。Spring主要提供了 Aspect 切面、JoinPoint 连接点、PointCut 切入点、Advice 增强等实现方式。

3. JDK 动态代理和 CGLIB 代理有什么区别?

JDK 动态代理主要是针对类实现了某个接口,AOP 则会使用 JDK 动态代理。他基于反射的机制实现,生成一个实现同样接口的一个代理类,然后通过重写方法的方式,实现对代码的增强。

而如果某个类没有实现接口,AOP 则会使用 CGLIB 代理。他的底层原理是基于 asm 第三方框架,通过修改字节码生成成成一个子类,然后重写父类的方法,实现对代码的增强。

4. Spring AOP 和 AspectJ AOP 有什么区别?

Spring AOP 基于动态代理实现,属于运行时增强。

AspectJ 则属于编译时增强,主要有3种方式:

  1. 编译时织入:指的是增强的代码和源代码我们都有,直接使用 AspectJ 编译器编译就行了,编译之后生成一个新的类,他也会作为一个正常的 Java 类装载到JVM。
  2. 编译后织入:指的是代码已经被编译成 class 文件或者已经打成 jar 包,这时候要增强的话,就是编译后织入,比如你依赖了第三方的类库,又想对他增强的话,就可以通过这种方式。

  1. 加载时织入:指的是在 JVM 加载类的时候进行织入。

总结下来的话,就是 Spring AOP 只能在运行时织入,不需要单独编译,性能相比 AspectJ 编译织入的方式慢,而 AspectJ 只支持编译前后和类加载时织入,性能更好,功能更加强大。

5. FactoryBean 和 BeanFactory有什么区别?

BeanFactory 是 Bean 的工厂, ApplicationContext 的父类,IOC 容器的核心,负责生产和管理 Bean 对象。

FactoryBean 是 Bean,可以通过实现 FactoryBean 接口定制实例化 Bean 的逻辑,通过代理一个Bean对象,对方法前后做一些操作。

6.SpringBean的生命周期说说?

SpringBean 生命周期简单概括为4个阶段:

  1. 实例化,创建一个Bean对象
  2. 填充属性,为属性赋值
  3. 初始化
  • 如果实现了xxxAware接口,通过不同类型的Aware接口拿到Spring容器的资源
  • 如果实现了BeanPostProcessor接口,则会回调该接口的postProcessBeforeInitialzationpostProcessAfterInitialization方法
  • 如果配置了init-method方法,则会执行init-method配置的方法

  1. 销毁
  • 容器关闭后,如果Bean实现了DisposableBean接口,则会回调该接口的destroy方法
  • 如果配置了destroy-method方法,则会执行destroy-method配置的方法

7.Spring是怎么解决循环依赖的?

首先,Spring 解决循环依赖有两个前提条件:

  1. 不全是构造器方式的循环依赖
  2. 必须是单例

基于上面的问题,我们知道Bean的生命周期,本质上解决循环依赖的问题就是三级缓存,通过三级缓存提前拿到未初始化完全的对象。

第一级缓存:用来保存实例化、初始化都完成的对象

第二级缓存:用来保存实例化完成,但是未初始化完成的对象

第三级缓存:用来保存一个对象工厂,提供一个匿名内部类,用于创建二级缓存中的对象

假设一个简单的循环依赖场景,A、B互相依赖。

A对象的创建过程:

  1. 创建对象A,实例化的时候把A对象工厂放入三级缓存

  1. A注入属性时,发现依赖B,转而去实例化B
  2. 同样创建对象B,注入属性时发现依赖A,一次从一级到三级缓存查询A,从三级缓存通过对象工厂拿到A,把A放入二级缓存,同时删除三级缓存中的A,此时,B已经实例化并且初始化完成,把B放入一级缓存。

  1. 接着继续创建A,顺利从一级缓存拿到实例化且初始化完成的B对象,A对象创建也完成,删除二级缓存中的A,同时把A放入一级缓存
  2. 最后,一级缓存中保存着实例化、初始化都完成的A、B对象

因此,由于把实例化和初始化的流程分开了,所以如果都是用构造器的话,就没法分离这个操作,所以都是构造器的话就无法解决循环依赖的问题了。

8. 为什么要三级缓存?二级不行吗?

不可以,主要是为了生成代理对象。

因为三级缓存中放的是生成具体对象的匿名内部类,他可以生成代理对象,也可以是普通的实例对象。

使用三级缓存主要是为了保证不管什么时候使用的都是一个对象。

假设只有二级缓存的情况,往二级缓存中放的显示一个普通的Bean对象,BeanPostProcessor去生成代理对象之后,覆盖掉二级缓存中的普通Bean对象,那么多线程环境下可能取到的对象就不一致了。

9.Spring事务传播机制有哪些?

  1. PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,这也是通常我们的默认选择。
  2. PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。
  3. PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则按REQUIRED属性执行。
  4. PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
  5. PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
  6. PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。
  7. PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行。‘

10.最后,说说Spring Boot 启动流程吧?

这个流程,网上一搜基本都是这张图了,我也不想再画一遍了。那其实主要的流程就几个步骤:

  1. 准备环境,根据不同的环境创建不同的Environment
  2. 准备、加载上下文,为不同的环境选择不同的Spring Context,然后加载资源,配置Bean
  3. 初始化,这个阶段刷新Spring Context,启动应用
  4. 最后结束流程

Zookeeper

谈谈你对Zookeeper的理解?

Zookeeper是一个开源的分布式协调服务,由雅虎公司创建,由于最初雅虎公司的内部研究小组的项目大多以动物的名字命名,所以后来就以Zookeeper(动物管理员)来命名了,而就是由Zookeeper来负责这些分布式组件环境的协调工作。

他的目标是可以提供高性能、高可用和顺序访问控制的能力,同时也是为了解决分布式环境下数据一致性的问题。

集群

首先,Zookeeper集群中有几个关键的概念,Leader、Follower和Observer,Zookeeper中通常只有Leader节点可以写入,Follower和Observer都只是负责读,但是Follower会参与节点的选举和过半写成功,Observer则不会,他只是单纯的提供读取数据的功能。

通常这样设置的话,是为了避免太多的从节点参与过半写的过程,导致影响性能,这样Zookeeper只要使用一个几台机器的小集群就可以实现高性能了,如果要横向扩展的话,只需要增加Observer节点即可。

Zookeeper建议集群节点个数为奇数,只要超过一半的机器能够正常提供服务,那么整个集群都是可用的状态。

数据节点Znode

Zookeeper中数据存储于内存之中,这个数据节点就叫做Znode,他是一个树形结构,比如/a/b/c类似。

而Znode又分为持久节点、临时节点、顺序节点三大类。

持久节点是指只要被创建,除非主动移除,否则都应该一直保存在Zookeeper中。

临时节点不同的是,他的生命周期和客户端Session会话一样,会话失效,那么临时节点就会被移除。

还有就是临时顺序节点和持久顺序节点,除了基本的特性之外,子节点的名称还具有有序性。

会话Session

会话自然就是指Zookeeper客户端和服务端之间的通信,他们使用TCP长连接的方式保持通信,通常,肯定会有心跳检测的机制,同时他可以接受来自服务器的Watch事件通知。

事件监听器Wather

用户可以在指定的节点上注册Wather,这样在事件触发的时候,客户端就会收到来自服务端的通知。

权限控制ACL

Zookeeper使用ACL来进行权限的控制,包含以下5种:

  1. CREATE,创建子节点权限
  2. DELETE,删除子节点权限
  3. READ,获取节点数据和子节点列表权限
  4. WRITE,更新节点权限
  5. ADMIN,设置节点ACL权限

所以,Zookeeper通过集群的方式来做到高可用,通过内存数据节点Znode来达到高性能,但是存储的数据量不能太大,通常适用于读多写少的场景。

Zookeeper有哪些应用场景?

  1. 命名服务Name Service,依赖Zookeeper可以生成全局唯一的节点ID,来对分布式系统中的资源进行管理。
  2. 分布式协调,这是Zookeeper的核心使用了。利用Wather的监听机制,一个系统的某个节点状态发生改变,另外系统可以得到通知。
  3. 集群管理,分布式集群中状态的监控和管理,使用Zookeeper来存储。
  4. Master选举,利用Zookeeper节点的全局唯一性,同时只有一个客户端能够创建成功的特点,可以作为Master选举使用,创建成功的则作为Master。
  5. 分布式锁,利用Zookeeper创建临时顺序节点的特性。

说说Wather监听机制和它的原理?

Zookeeper可以提供分布式数据的发布/订阅功能,依赖的就是Wather监听机制。

客户端可以向服务端注册Wather监听,服务端的指定事件触发之后,就会向客户端发送一个事件通知。

他有几个特性:

  1. 一次性:一旦一个Wather触发之后,Zookeeper就会将它从存储中移除
  2. 客户端串行:客户端的Wather回调处理是串行同步的过程,不要因为一个Wather的逻辑阻塞整个客户端
  3. 轻量:Wather通知的单位是WathedEvent,只包含通知状态、事件类型和节点路径,不包含具体的事件内容,具体的时间内容需要客户端主动去重新获取数据

主要流程如下:

  1. 客户端向服务端注册Wather监听
  2. 保存Wather对象到客户端本地的WatherManager中
  3. 服务端Wather事件触发后,客户端收到服务端通知,从WatherManager中取出对应Wather对象执行回调逻辑

Zookeeper是如何保证数据一致性的?

Zookeeper通过ZAB原子广播协议来实现数据的最终顺序一致性,他是一个类似2PC两阶段提交的过程。

由于Zookeeper只有Leader节点可以写入数据,如果是其他节点收到写入数据的请求,则会将之转发给Leader节点。

主要流程如下:

  1. Leader收到请求之后,将它转换为一个proposal提议,并且为每个提议分配一个全局唯一递增的事务ID:zxid,然后把提议放入到一个FIFO的队列中,按照FIFO的策略发送给所有的Follower
  2. Follower收到提议之后,以事务日志的形式写入到本地磁盘中,写入成功后返回ACK给Leader
  3. Leader在收到超过半数的Follower的ACK之后,即可认为数据写入成功,就会发送commit命令给Follower告诉他们可以提交proposal了

ZAB包含两种基本模式,崩溃恢复和消息广播。

整个集群服务在启动、网络中断或者重启等异常情况的时候,首先会进入到崩溃恢复状态,此时会通过选举产生Leader节点,当集群过半的节点都和Leader状态同步之后,ZAB就会退出恢复模式。之后,就会进入消息广播的模式。

那么,Zookeeper如何进行Leader选举的?

Leader的选举可以分为两个方面,同时选举主要包含事务zxid和myid,节点主要包含LEADING\FOLLOWING\LOOKING3个状态。

  1. 服务启动期间的选举
  2. 服务运行期间的选举

服务启动期间的选举

  1. 首先,每个节点都会对自己进行投票,然后把投票信息广播给集群中的其他节点
  2. 节点接收到其他节点的投票信息,然后和自己的投票进行比较,首先zxid较大的优先,如果zxid相同那么则会去选择myid更大者,此时大家都是LOOKING的状态
  3. 投票完成之后,开始统计投票信息,如果集群中过半的机器都选择了某个节点机器作为leader,那么选举结束
  4. 最后,更新各个节点的状态,leader改为LEADING状态,follower改为FOLLOWING状态

服务运行期间的选举

如果开始选举出来的leader节点宕机了,那么运行期间就会重新进行leader的选举。

  1. leader宕机之后,非observer节点都会把自己的状态修改为LOOKING状态,然后重新进入选举流程
  2. 生成投票信息(myid,zxid),同样,第一轮的投票大家都会把票投给自己,然后把投票信息广播出去
  3. 接下来的流程和上面的选举是一样的,都会优先以zxid,然后选择myid,最后统计投票信息,修改节点状态,选举结束

那选举之后又是怎样进行数据同步的?

那实际上Zookeeper在选举之后,Follower和Observer(统称为Learner)就会去向Leader注册,然后就会开始数据同步的过程。

数据同步包含3个主要值和4种形式。

PeerLastZxid:Learner服务器最后处理的ZXID

minCommittedLog:Leader提议缓存队列中最小ZXID

maxCommittedLog:Leader提议缓存队列中最大ZXID

直接差异化同步 DIFF同步

如果PeerLastZxid在minCommittedLog和maxCommittedLog之间,那么则说明Learner服务器还没有完全同步最新的数据。

  1. 首先Leader向Learner发送DIFF指令,代表开始差异化同步,然后把差异数据(从PeerLastZxid到maxCommittedLog之间的数据)提议proposal发送给Learner
  2. 发送完成之后发送一个NEWLEADER命令给Learner,同时Learner返回ACK表示已经完成了同步
  3. 接着等待集群中过半的Learner响应了ACK之后,就发送一个UPTODATE命令,Learner返回ACK,同步流程结束

先回滚再差异化同步 TRUNC+DIFF同步

这个设置针对的是一个异常的场景。

如果Leader刚生成一个proposal,还没有来得及发送出去,此时Leader宕机,重新选举之后作为Follower,但是新的Leader没有这个proposal数据。

举个栗子:

假设现在的Leader是A,minCommittedLog=1,maxCommittedLog=3,刚好生成的一个proposal的ZXID=4,然后挂了。

重新选举出来的Leader是B,B之后又处理了2个提议,然后minCommittedLog=1,maxCommittedLog=5。

这时候A的PeerLastZxid=4,在(1,5)之间。

那么这一条只存在于A的提议怎么处理?

A要进行事务回滚,相当于抛弃这条数据,并且回滚到最接近于PeerLastZxid的事务,对于A来说,也就是PeerLastZxid=3。

流程和DIFF一致,只是会先发送一个TRUNC命令,然后再执行差异化DIFF同步。

仅回滚同步 TRUNC同步

针对PeerLastZxid大于maxCommittedLog的场景,流程和上述一致,事务将会被回滚到maxCommittedLog的记录。

这个其实就更简单了,也就是你可以认为TRUNC+DIFF中的例子,新的Leader B没有处理提议,所以B中minCommittedLog=1,maxCommittedLog=3。

所以A的PeerLastZxid=4就会大于maxCommittedLog了,也就是A只需要回滚就行了,不需要执行差异化同步DIFF了。

全量同步 SNAP同步

适用于两个场景:

  1. PeerLastZxid小于minCommittedLog
  2. Leader服务器上没有提议缓存队列,并且PeerLastZxid不等于Leader的最大ZXID

这两种场景下,Leader将会发送SNAP命令,把全量的数据都发送给Learner进行同步。

有可能会出现数据不一致的问题吗?

还是会存在的,我们可以分成3个场景来描述这个问题。

查询不一致

因为Zookeeper是过半成功即代表成功,假设我们有5个节点,如果123节点写入成功,如果这时候请求访问到4或者5节点,那么有可能读取不到数据,因为可能数据还没有同步到4、5节点中,也可以认为这算是数据不一致的问题。

解决方案可以在读取前使用sync命令。

leader未发送proposal宕机

这也就是数据同步说过的问题。

leader刚生成一个proposal,还没有来得及发送出去,此时leader宕机,重新选举之后作为follower,但是新的leader没有这个proposal。

这种场景下的日志将会被丢弃。

leader发送proposal成功,发送commit前宕机

如果发送proposal成功了,但是在将要发送commit命令前宕机了,如果重新进行选举,还是会选择zxid最大的节点作为leader,因此,这个日志并不会被丢弃,会在选举出leader之后重新同步到其他节点当中。

如果作为注册中心,Zookeeper 和Eureka、Consul、Nacos有什么区别?

Nacos Eureka Consul Zookeeper

最后,你对于CAP理论怎么理解?

CAP是一个分布式系统设计的定理,他包含3个部分,并且最多只能同时满足其中两个。

  1. Consistency一致性,因为在一个分布式系统中,数据肯定需要在不同的节点之间进行同步,就比如Zookeeper,所以一致性就是指的是数据在不同的节点之间怎样保证一致性,对于纯理论的C而言,默认的规则是忽略掉延迟的,因为如果考虑延迟的话,因为数据同步的过程无论如何都会有延迟的,延迟的过程必然会带来数据的不一致。
  2. Availability可用性,这个指的是对于每一个请求,节点总是可以在合理的时间返回合理的响应,比如Zookeeper在进行数据同步时,无法对外提供读写服务,不满足可用性要求。这里常有的一个例子是说Zookeeper选举期间无法提供服务不满足A,这个说法并不准确,因为CAP关注的是数据的读写,选举可以认为不在考虑范围之内。所以,可以认为对于数据的读写,无论响应超时还是返回异常都可以认为是不满足A。
  3. Partition-tolerance分区容错性,因为在一个分布式系统当中,很有可能由于部分节点的网络问题导致整个集群之间的网络不连通,所以就产生了网络分区,整个集群的环境被分隔成不同的的子网,所以,一般说网络不可能100%的不产生问题,所以P一定会存在。

为什么只能同时满足CAP中的两个呢?

以A\B两个节点同步数据举例,由于P的存在,那么可能AB同步数据出现问题。

如果选择AP,由于A的数据未能正确同步到B,所以AB数据不一致,无法满足C。

如果选择CP,那么B就不能提供服务,就无法满足A。

Dubbo

这是面试专题系列第四篇,Dubbo系列。Dubbo本身并不复杂,而且官方文档写的非常清楚详细,面试中dubbo的问题一般不会很多,从分层到工作原理、负载均衡策略、容错机制、SPI机制基本就差不多了,最大的一道大题一般就是怎么设计一个RPC框架了,但是如果你工作原理分层都搞明白了这个问题其实也就相当于回答了不是吗。

说说Dubbo的分层?

从大的范围来说,dubbo分为三层,business业务逻辑层由我们自己来提供接口和实现还有一些配置信息,RPC层就是真正的RPC调用的核心层,封装整个RPC的调用过程、负载均衡、集群容错、代理,remoting则是对网络传输协议和数据转换的封装。

划分到更细的层面,就是图中的10层模式,整个分层依赖由上至下,除开business业务逻辑之外,其他的几层都是SPI机制。

能说下Dubbo的工作原理吗?

  1. 服务启动的时候,provider和consumer根据配置信息,连接到注册中心register,分别向注册中心注册和订阅服务
  2. register根据服务订阅关系,返回provider信息到consumer,同时consumer会把provider信息缓存到本地。如果信息有变更,consumer会收到来自register的推送
  3. consumer生成代理对象,同时根据负载均衡策略,选择一台provider,同时定时向monitor记录接口的调用次数和时间信息
  4. 拿到代理对象之后,consumer通过代理对象发起接口调用
  5. provider收到请求后对数据进行反序列化,然后通过代理调用具体的接口实现

为什么要通过代理对象通信?

主要是为了实现接口的透明代理,封装调用细节,让用户可以像调用本地方法一样调用远程方法,同时还可以通过代理实现一些其他的策略,比如:

1、调用的负载均衡策略

2、调用失败、超时、降级和容错机制

3、做一些过滤操作,比如加入缓存、mock数据

4、接口调用数据统计

说说服务暴露的流程?

  1. 在容器启动的时候,通过ServiceConfig解析标签,创建dubbo标签解析器来解析dubbo的标签,容器创建完成之后,触发ContextRefreshEvent事件回调开始暴露服务
  2. 通过ProxyFactory获取到invoker,invoker包含了需要执行的方法的对象信息和具体的URL地址
  3. 再通过DubboProtocol的实现把包装后的invoker转换成exporter,然后启动服务器server,监听端口
  4. 最后RegistryProtocol保存URL地址和invoker的映射关系,同时注册到服务中心

说说服务引用的流程?

服务暴露之后,客户端就要引用服务,然后才是调用的过程。

  1. 首先客户端根据配置文件信息从注册中心订阅服务
  2. 之后DubboProtocol根据订阅的得到provider地址和接口信息连接到服务端server,开启客户端client,然后创建invoker
  3. invoker创建完成之后,通过invoker为服务接口生成代理对象,这个代理对象用于远程调用provider,服务的引用就完成了

有哪些负载均衡策略?

  1. 加权随机:假设我们有一组服务器 servers = [A, B, C],他们对应的权重为 weights = [5, 3, 2],权重总和为10。现在把这些权重值平铺在一维坐标值上,[0, 5) 区间属于服务器 A,[5, 8) 区间属于服务器 B,[8, 10) 区间属于服务器 C。接下来通过随机数生成器生成一个范围在 [0, 10) 之间的随机数,然后计算这个随机数会落到哪个区间上就可以了。
  2. 最小活跃数:每个服务提供者对应一个活跃数 active,初始情况下,所有服务提供者活跃数均为0。每收到一个请求,活跃数加1,完成请求后则将活跃数减1。在服务运行一段时间后,性能好的服务提供者处理请求的速度更快,因此活跃数下降的也越快,此时这样的服务提供者能够优先获取到新的服务请求。
  3. 一致性hash:通过hash算法,把provider的invoke和随机节点生成hash,并将这个 hash 投射到 [0, 2^32 - 1] 的圆环上,查询的时候根据key进行md5然后进行hash,得到第一个节点的值大于等于当前hash的invoker。

  1. 加权轮询:比如服务器 A、B、C 权重比为 5:2:1,那么在8次请求中,服务器 A 将收到其中的5次请求,服务器 B 会收到其中的2次请求,服务器 C 则收到其中的1次请求。

集群容错方式有哪些?

  1. Failover Cluster失败自动切换:dubbo的默认容错方案,当调用失败时自动切换到其他可用的节点,具体的重试次数和间隔时间可用通过引用服务的时候配置,默认重试次数为1也就是只调用一次。
  2. Failback Cluster失败自动恢复:在调用失败,记录日志和调用信息,然后返回空结果给consumer,并且通过定时任务每隔5秒对失败的调用进行重试
  3. Failfast Cluster快速失败:只会调用一次,失败后立刻抛出异常
  4. Failsafe Cluster失败安全:调用出现异常,记录日志不抛出,返回空结果
  5. Forking Cluster并行调用多个服务提供者:通过线程池创建多个线程,并发调用多个provider,结果保存到阻塞队列,只要有一个provider成功返回了结果,就会立刻返回结果
  6. Broadcast Cluster广播模式:逐个调用每个provider,如果其中一台报错,在循环调用结束后,抛出异常。

了解Dubbo SPI机制吗?

SPI 全称为 Service Provider Interface,是一种服务发现机制,本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类,这样可以在运行时,动态为接口替换实现类。

Dubbo也正是通过SPI机制实现了众多的扩展功能,而且dubbo没有使用java原生的SPI机制,而是对齐进行了增强和改进。

SPI在dubbo应用很多,包括协议扩展、集群扩展、路由扩展、序列化扩展等等。

使用方式可以在META-INF/dubbo目录下配置:

key=com.xxx.value

然后通过dubbo的ExtensionLoader按照指定的key加载对应的实现类,这样做的好处就是可以按需加载,性能上得到优化。

如果让你实现一个RPC框架怎么设计?

  1. 首先需要一个服务注册中心,这样consumer和provider才能去注册和订阅服务
  2. 需要负载均衡的机制来决定consumer如何调用客户端,这其中还当然要包含容错和重试的机制
  3. 需要通信协议和工具框架,比如通过http或者rmi的协议通信,然后再根据协议选择使用什么框架和工具来进行通信,当然,数据的传输序列化也要考虑
  4. 除了基本的要素之外,像一些监控、配置管理页面、日志是额外的优化考虑因素。

那么,本质上,只要熟悉一两个RPC框架,就很容易想明白我们自己要怎么实现一个RPC框架。

网络

谈一谈你对TCP/IP四层模型,OSI七层模型的理解?

为了增强通用性和兼容性,计算机网络都被设计成层次机构,每一层都遵守一定的规则。

因此有了OSI这样一个抽象的网络通信参考模型,按照这个标准使计算机网络系统可以互相连接。

物理层:通过网线、光缆等这种物理方式将电脑连接起来。传递的数据是比特流,0101010100。

数据链路层: 首先,把比特流封装成数据的格式,对0、1进行分组。电脑连接起来之后,数据都经过网卡来传输,而网卡上定义了全世界唯一的MAC地址。然后再通过广播的形式向局域网内所有电脑发送数据,再根据数据中MAC地址和自身对比判断是否是发给自己的。

网络层:广播的形式太低效,为了区分哪些MAC地址属于同一个子网,网络层定义了IP和子网掩码,通过对IP和子网掩码进行与运算就知道是否是同一个子网,再通过路由器和交换机进行传输。IP协议属于网络层的协议。

传输层:有了网络层的MAC+IP地址之后,为了确定数据包是从哪个进程发送过来的,就需要端口号,通过端口来建立通信,比如TCP和UDP属于这一层的协议。

会话层:负责建立和断开连接

表示层:为了使得数据能够被其他的计算机理解,再次将数据转换成另外一种格式,比如文字、视频、图片等。

应用层:最高层,面对用户,提供计算机网络与最终呈现给用户的界面

TCP/IP则是四层的结构,相当于是对OSI模型的简化。

  1. 数据链路层,也有称作网络访问层、网络接口层。他包含了OSI模型的物理层和数据链路层,把电脑连接起来。
  2. 网络层,也叫做IP层,处理IP数据包的传输、路由,建立主机间的通信。
  3. 传输层,就是为两台主机设备提供端到端的通信。
  4. 应用层,包含OSI的会话层、表示层和应用层,提供了一些常用的协议规范,比如FTP、SMPT、HTTP等。

总结下来,就是物理层通过物理手段把电脑连接起来,数据链路层则对比特流的数据进行分组,网络层来建立主机到主机的通信,传输层建立端口到端口的通信,应用层最终负责建立连接,数据格式转换,最终呈现给用户。

说说TCP 3次握手的过程?

建立连接前server端需要监听端口,所以初始状态是LISTEN。

  1. client端建立连接,发送一个SYN同步包,发送之后状态变成SYN_SENT
  2. server端收到SYN之后,同意建立连接,返回一个ACK响应,同时也会给client发送一个SYN包,发送完成之后状态变为SYN_RCVD
  3. client端收到server的ACK之后,状态变为ESTABLISHED,返回ACK给server端。server收到之后状态也变为ESTABLISHED,连接建立完成。

为什么要3次?2次,4次不行吗?

因为TCP是双工传输模式,不区分客户端和服务端,连接的建立是双向的过程。

如果只有两次,无法做到双向连接的建立,从建立连接server回复的SYN和ACK合并成一次可以看出来,他也不需要4次。

挥手为什么要四次?因为挥手的ACK和FIN不能同时发送,因为数据发送的截止时间不同。

那么四次挥手的过程呢?

  1. client端向server发送FIN包,进入FIN_WAIT_1状态,这代表client端已经没有数据要发送了
  2. server端收到之后,返回一个ACK,进入CLOSE_WAIT等待关闭的状态,因为server端可能还有没有发送完成的数据
  3. 等到server端数据都发送完毕之后,server端就向client发送FIN,进入LAST_ACK状态
  4. client收到ACK之后,进入TIME_WAIT的状态,同时回复ACK,server收到之后直接进入CLOSED状态,连接关闭。但是client要等待2MSL(报文最大生存时间)的时间,才会进入CLOSED状态。

为什么要等待2MSL的时间才关闭?

  1. 为了保证连接的可靠关闭。如果server没有收到最后一个ACK,那么就会重发FIN。
  2. 为了避免端口重用带来的数据混淆。如果client直接进入CLOSED状态,又用相同端口号向server建立一个连接,上一次连接的部分数据在网络中延迟到达server,数据就可能发生混淆了。

TCP怎么保证传输过程的可靠性?

校验和:发送方在发送数据之前计算校验和,接收方收到数据后同样计算,如果不一致,那么传输有误。

确认应答,序列号:TCP进行传输时数据都进行了编号,每次接收方返回ACK都有确认序列号。

超时重传:如果发送方发送数据一段时间后没有收到ACK,那么就重发数据。

连接管理:三次握手和四次挥手的过程。

流量控制:TCP协议报头包含16位的窗口大小,接收方会在返回ACK时同时把自己的即时窗口填入,发送方就根据报文中窗口的大小控制发送速度。

拥塞控制:刚开始发送数据的时候,拥塞窗口是1,以后每次收到ACK,则拥塞窗口+1,然后将拥塞窗口和收到的窗口取较小值作为实际发送的窗口,如果发生超时重传,拥塞窗口重置为1。这样做的目的就是为了保证传输过程的高效性和可靠性。

说下浏览器请求一个网址的过程?

  1. 首先通过DNS服务器把域名解析成IP地址,通过IP和子网掩码判断是否属于同一个子网
  2. 构造应用层请求http报文,传输层添加TCP/UDP头部,网络层添加IP头部,数据链路层添加以太网协议头部
  3. 数据经过路由器、交换机转发,最终达到目标服务器,目标服务器同样解析数据,最终拿到http报文,按照对应的程序的逻辑响应回去。

知道HTTPS的工作原理吗?

  1. 用户通过浏览器请求https网站,服务器收到请求,选择浏览器支持的加密和hash算法,同时返回数字证书给浏览器,包含颁发机构、网址、公钥、证书有效期等信息。
  2. 浏览器对证书的内容进行校验,如果有问题,则会有一个提示警告。否则,就生成一个随机数X,同时使用证书中的公钥进行加密,并且发送给服务器。
  3. 服务器收到之后,使用私钥解密,得到随机数X,然后使用X对网页内容进行加密,返回给浏览器
  4. 浏览器则使用X和之前约定的加密算法进行解密,得到最终的网页内容

负载均衡有哪些实现方式?

DNS:这是最简单的负载均衡的方式,一般用于实现地理级别的负载均衡,不同地域的用户通过DNS的解析可以返回不同的IP地址,这种方式的负载均衡简单,但是扩展性太差,控制权在域名服务商。

Http重定向:通过修改Http响应头的Location达到负载均衡的目的,Http的302重定向。这种方式对性能有影响,而且增加请求耗时。

反向代理:作用于应用层的模式,也被称作为七层负载均衡,比如常见的Nginx,性能一般可以达到万级。这种方式部署简单,成本低,而且容易扩展。

IP:作用于网络层的和传输层的模式,也被称作四层负载均衡,通过对数据包的IP地址和端口进行修改来达到负载均衡的效果。常见的有LVS(Linux Virtual Server),通常性能可以支持10万级并发。

按照类型来划分的话,还可以分成DNS负载均衡、硬件负载均衡、软件负载均衡。

其中硬件负载均衡价格昂贵,性能最好,能达到百万级,软件负载均衡包括Nginx、LVS这种。

说说BIO/NIO/AIO的区别?

BIO:同步阻塞IO,每一个客户端连接,服务端都会对应一个处理线程,对于没有分配到处理线程的连接就会被阻塞或者拒绝。相当于是一个连接一个线程

NIO:同步非阻塞IO,基于Reactor模型,客户端和channel进行通信,channel可以进行读写操作,通过多路复用器selector来轮询注册在其上的channel,而后再进行IO操作。这样的话,在进行IO操作的时候再用一个线程去处理就可以了,也就是一个请求一个线程

AIO:异步非阻塞IO,相比NIO更进一步,完全由操作系统来完成请求的处理,然后通知服务端开启线程去进行处理,因此是一个有效请求一个线程

那么你怎么理解同步和阻塞?

首先,可以认为一个IO操作包含两个部分:

  1. 发起IO请求
  2. 实际的IO读写操作

同步和异步在于第二个,实际的IO读写操作,如果操作系统帮你完成了再通知你,那就是异步,否则都叫做同步。

阻塞和非阻塞在于第一个,发起IO请求,对于NIO来说通过channel发起IO操作请求后,其实就返回了,所以是非阻塞。

谈一下你对Reactor模型的理解?

Reactor模型包含两个组件:

  1. Reactor:负责查询、响应IO事件,当检测到IO事件时,分发给Handlers处理。
  2. Handler:与IO事件绑定,负责IO事件的处理。

它包含几种实现方式:

单线程Reactor

这个模式reactor和handler在一个线程中,如果某个handler阻塞的话,会导致其他所有的handler无法执行,而且无法充分利用多核的性能。

单Reactor多线程

由于decode、compute、encode的操作并非IO的操作,多线程Reactor的思路就是充分发挥多核的特性,同时把非IO的操作剥离开。

但是,单个Reactor承担了所有的事件监听、响应工作,如果连接过多,还是可能存在性能问题。

多Reactor多线程

为了解决单Reactor的性能问题,就产生了多Reactor的模式。其中mainReactor建立连接,多个subReactor则负责数据读写。

分布式事务

对于分布式事务,相信所有人都应该很了解,为什么会有分布式事务?无论是数据量导致的分库,还是现在微服务盛行的场景都是他出现的原因。

这一篇内容还是避免不了俗套,主要的范围无非是XA、2PC、3PC、TCC,再最后到Seata。

但是,我认为这东西,只是适用于面试和理论的了解,你真要说这些方案实际生产中有人用吗?

有,但是会实现的更简单,不会套用理论来实现,大厂有大厂的解决方案,中小公司用框架或者压根就不存在分布式事务的问题。

那,为什么还要写这个?

为了你面试八股文啊,小可爱。

事务

要说分布式事务,首先还是从事务的基本特征说起。

A原子性:在事务的执行过程中,要么全部执行成功,要么都不成功。

C一致性:事务在执行前后,不能破坏数据的完整性。一致性更多的说的是通过AID来达到目的,数据应该符合预先的定义和约束,由应用层面来保证,还有的说法是C是强行为了ACID凑出来的。

I隔离性:多个事务之间是互相隔离的,事务之间不能互相干扰,涉及到不同事务的隔离级别的问题。

D持久性:一旦事务提交,数据库中数据的状态就应该是永久性的。

XA

XA(eXtended Architecture)是指由X/Open 组织提出的分布式事务处理的规范,他是一个规范或者说是协议,定义了事务管理器TM(Transaction Manager),资源管理器RM(Resource Manager),和应用程序。

事务管理器TM就是事务的协调者,资源管理器RM可以认为就是一个数据库。

2PC

XA定义了规范,那么2PC和3PC就是他的具体实现方式。

2PC叫做二阶段提交,分为投票阶段和执行阶段两个阶段。

投票阶段

TM向所有的参与者发送prepare请求,询问是否可以执行事务,等待各个参与者的响应。

这个阶段可以认为只是执行了事务的SQL语句,但是还没有提交。

如果都执行成功了就返回YES,否则返回NO。

执行阶段

执行阶段就是真正的事务提交的阶段,但是要考虑到失败的情况。

如果所有的参与者都返回YES,那么就执行发送commit命令,参与者收到之后执行提交事务。

反之,只要有任意一个参与者返回的是NO的话,就发送rollback命令,然后执行回滚的操作。

2PC的缺陷

  1. 同步阻塞,可以看到,在执行事务的过程当中,所有数据库的资源都被锁定,如果这时候有其他人来访问这些资源,将会被阻塞,这是一个很大的性能问题。
  2. TM单点问题,只要一个TM,一旦TM宕机,那么整个流程无法继续完成。
  3. 数据不一致,如果在执行阶段,参与者脑裂或者其他故障导致没有收到commit请求,部分提交事务,部分未提交,那么数据不一致的问题就产生了。

3PC

既然2PC有这么多问题,所以就衍生出了3PC的概念,也叫做三阶段提交,他把整个流程分成了CanCommit、PreCommit、DoCommit三个步骤,相比2PC,增加的就是CanCommit阶段。

CanCommit

这个阶段就是先询问数据库是否执行事务,发送一个canCommit的请求去询问,如果可以的话就返回YES,反之返回NO。

PreCommit

这个阶段就等同于2PC的投票阶段了,发送preCommit命令,然后去执行SQL事务,成功就返回YES,反之返回NO。

但是,这个地方的区别在于参与者有了超时机制,如果参与者超时未收到doCommit命令的话,将会默认去提交事务。

DoCommit

DoCommit阶段对应到2PC的执行阶段,如果上一个阶段都是收到YES的话,那么就发送doCommit命令去提交事务,反之则会发送abort命令去中断事务的执行。

相比2PC的改进

对于2PC的同步阻塞的问题,我们可以看到因为3PC加入了参与者的超时机制,所以原来2PC的如果某个参与者故障导致的同步阻塞的问题时间缩短了,这是一个优化,但是并没有完全避免。

第二个单点故障的问题,同样因为超时机制的引入,一定程度上也算是优化了。

但是数据不一致的问题,这个始终没有得到解决。

举个栗子:

在PreCommit阶段,某个参与者发生脑裂,无法收到TM的请求,这时候其他参与者执行abort事务回滚,而脑裂的参与者超时之后继续提交事务,还是有可能发生数据不一致的问题。

那么,为什么要加入DoCommit这个阶段呢?就是为了引入超时机制,事先我们先确认数据库是否都可以执行事务,如果都OK,那么才会进入后面的步骤,所以既然都可以执行,那么超时之后说明发生了问题,就自动提交事务。

TCC

TCC的模式叫做Try、Confirm、Cancel,实际上也就是2PC的一个变种而已。

实现这个模式,一个事务的接口需要拆分成3个,也就是Try预占、Confirm确认提交、最后Cancel回滚。

对于TCC来说,实际生产我基本上就没看见过有人用,考虑到原因,首先是程序员的本身素质参差不齐,多个团队协作你很难去约束别人按照你的规则来实现,另外一点就是太过于复杂。

如果说有简单的应用的话,库存的应用或许可以算做是一个。

一般库存的操作,很多实现方案里面都会会在下单的时候先预占库存,下单成功之后再实际去扣减库存,最终如果发生了异常再回退。

冻结、预占库存就是2PC的准备阶段,真正下单成功去扣减库存就是2PC的提交阶段,回滚就是某个发生异常的回滚操作,只不过在应用层面来实现了2PC的机制而已。

SAGA

Saga源于1987 年普林斯顿大学的 Hecto 和 Kenneth 发表的如何处理 long lived transaction(长活事务)论文。

主要思想就是将长事务拆分成多个本地短事务。

如果全部执行成功,就正常完成了,反之,则会按照相反的顺序依次调用补偿。

SAGA模式有两种恢复策略:

  1. 向前恢复,这个模式偏向于一定要成功的场景,失败则会进行重试
  2. 向后恢复,也就是发生异常的子事务依次回滚补偿

由于这个模式在国内基本没看见有谁用的,不在赘述。

消息队列

基于消息队列来实现最终一致性的方案,这个相比前面的我个人认为还稍微靠谱一点,那些都是理论啊,正常生产的实现很少看见应用。

基于消息队列的可能真正在应用的还稍微多一点。

一般来说有两种方式,基于本地消息表和依赖MQ本身的事务消息。

本地消息表的这个方案其实更复杂,实际上我也没看到过真正谁来用。这里我以RocketMQ的事务消息来举例,这个方式相比本地消息表则更完全依赖MQ本身的特性做了解耦,释放了业务开发的复杂工作量。

  1. 业务发起方,调用远程接口,向MQ发送一条半事务消息,MQ收到消息之后会返回给生产者一个ACK
  2. 生产者收到ACK之后,去执行事务,但是事务还没有提交。
  3. 生产者会根据事务的执行结果来决定发送commit提交或者rollback回滚到MQ
  4. 这一点是发生异常的情况,比如生产者宕机或者其他异常导致MQ长时间没有收到commit或者rollback的消息,这时候MQ会发起状态回查。
  5. MQ如果收到的是commit的话就会去投递消息,消费者正常消费消息即可。如果是rollback的话,则会在设置的固定时间期限内去删除消息。

这个方案基于MQ来保证消息事务的最终一致性,还算是一个比较合理的解决方案,只要保证MQ的可靠性就可以正常实施应用,业务消费方根据本身的消息重试达到最终一致性。

框架

以上说的都是理论和自己实现的方式,那么分布式事务就没有框架来解决我们的问题吗?

有,其实还不少,但是没有能扛旗者出现,要说有,阿里的开源框架Seata还有阿里云的GTS。

GTS(Global Transaction Service 全局事务服务)是阿里云的中间件产品,只要你用阿里云,付钱就可以用GTS。

Seata(Simple Extensible Autonomous Transaction Architecture)则是开源的分布式事务框架,提供了对TCC、XA、Saga以及AT模式的支持。

那么,GTS和Seata有什么关系呢?

实际上最开始的时候他们都是基于阿里内部的TXC(Taobao Transaction Constructor)分布式中间件产品,然后TXC经过改造上了阿里云就叫做GTS。

之后阿里的中间件团队基于TXC和GTS做出了开源的Seata,其中AT(Automatic Transaction)模式就是GTS原创的方案。

至于现在的版本,可以大致认为他们就是一样的就行了,到2020年,GTS已经全面兼容了Seata的 GA 版本。

整个GTS或者Seata包含以下几个核心组件:

  • Transaction Coordinator(TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
  • Transaction Manager(TM):控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
  • Resource Manager(RM):控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。

无论对于TCC还是原创的AT模式的支持,整个分布式事务的原理其实相对来说还是比较容易理解。

  1. 事务开启时,TM向TC注册全局事务,并且获得全局事务XID
  2. 这时候多个微服务的接口发生调用,XID就会传播到各个微服务中,每个微服务执行事务也会向TC注册分支事务。
  3. 之后TM就可以管理针对每个XID的事务全局提交和回滚,RM完成分支的提交或者回滚。

AT模式

原创的AT模式相比起TCC的方案来说,无需自己实现多个接口,通过代理数据源的形式生成更新前后的UNDO_LOG,依靠UNDO_LOG来实现回滚的操作。

执行的流程如下:

  1. TM向TC注册全局事务,获得XID
  2. RM则会去代理JDBC数据源,生成镜像的SQL,形成UNDO_LOG,然后向TC注册分支事务,把数据更新和UNDO_LOG在本地事务中一起提交
  3. TC如果收到commit请求,则会异步去删除对应分支的UNDO_LOG,如果是rollback,就去查询对应分支的UNDO_LOG,通过UNDO_LOG来执行回滚

TCC模式

相比AT模式代理JDBC数据源生成UNDO_LOG来生成逆向SQL回滚的方式,TCC就更简单一点了。

  1. TM向TC注册全局事务,获得XID
  2. RM向TC注册分支事务,然后执行Try方法,同时上报Try方法执行情况
  3. 然后如果收到TC的commit请求就执行Confirm方法,收到rollback则执行Cancel

XA模式

  1. TM向TC注册全局事务,获得XID
  2. RM向TC注册分支事务,XA Start,执行SQL,XA END,XA Prepare,然后上报分支执行情况
  3. 然后如果收到TC的commit请求就执行Confirm方法,收到rollback则执行Cancel

SAGA模式

  1. TM向TC注册全局事务,获得XID
  2. RM向TC注册分支事务,然后执行业务方法,并且上报分支执行情况
  3. RM收到分支回滚,执行对应的业务回滚方法

总结

这里从事务的ACID开始,向大家先说了XA是分布式事务处理的规范,之后谈到2PC和3PC,2PC有同步阻塞、单点故障和数据不一致的问题,3PC在一定程度上解决了同步阻塞和单点故障的问题,但是还是没有完全解决数据不一致的问题。

之后说到TCC、SAGA、消息队列的最终一致性的方案,TCC由于实现过于麻烦和复杂,业务很少应用,SAGA了解即可,国内也很少有应用到的,消息队列提供了解耦的实现方式,对于中小公司来说可能是较为低成本的实现方式。

最后再说目前国内的实现框架,云端阿里云的GTS兼容Seata,非云端使用Seata,它提供了XA、TCC、AT、SAGA的解决方案,可以说是目前的主流选择。

分布式锁

开个头,这是篇技术文章,但是昨天一天太恶心了,忍不住还是简单说下昨天的事情。

昨天早上11点飞大理,结果9点钟要出门的时候发现密码锁坏了,不用密码都能打开,一边司机师傅在催着走,一边连忙打电话给房东和客服找人维修,这是第一。

然后飞机晚点,11点20飞到4点钟才要落地,下降的过程那叫一个颠簸,我以为都要没了,这也是第一次晕飞机,简直快吐了,这是第二。

然后快4点了,飞机总算快要降落了,轮子都快着地了,结果愣是拔起来又起飞了,最后知道是大理8级大风,机长不敢落地。。。这是第三。

最后通知起飞不知道什么时候,要等大理那边通知,没有办法,我们只好下飞机转高铁,急急忙忙的一路转,总算赶上了最后7点前的高铁,否则就要等到9点以后了,最后一路周转,9点多总算到了酒店,好在酒店还算行,没有让我太过于失望。

这一天搞下来,整个一人在囧途,太累了。好吧,废话就这么多,文章开始。

说说分布式锁吧?

对于一个单机的系统,我们可以通过synchronized或者ReentrantLock等这些常规的加锁方式来实现,然而对于一个分布式集群的系统而言,单纯的本地锁已经无法解决问题,所以就需要用到分布式锁了,通常我们都会引入三方组件或者服务来解决这个问题,比如数据库、Redis、Zookeeper等。

通常来说,分布式锁要保证互斥性、不死锁、可重入等特点。

互斥性指的是对于同一个资源,任意时刻,都只有一个客户端能持有锁。

不死锁指的是必须要有锁超时这种机制,保证在出现问题的时候释放锁,不会出现死锁的问题。

可重入指的是对于同一个线程,可以多次重复加锁。

那你分别说说使用数据库、Redis和Zookeeper的实现原理?

数据库的话可以使用乐观锁或者悲观锁的实现方式。

乐观锁通常就是数据库中我们会有一个版本号,更新数据的时候通过版本号来更新,这样的话效率会比较高,悲观锁则是通过for update的方式,但是会带来很多问题,因为他是一个行级锁,高并发的情况下可能会导致死锁、客户端连接超时等问题,一般不推荐使用这种方式。

Redis是通过set命令来实现,在2.6.2版本之前,实现方式可能是这样:

setNX命令代表当key不存在时返回成功,否则返回失败。

但是这种实现方式把加锁和设置过期时间的步骤分成两步,他们并不是原子操作,如果加锁成功之后程序崩溃、服务宕机等异常情况,导致没有设置过期时间,那么就会导致死锁的问题,其他线程永远都无法获取这个锁。

之后的版本中,Redis提供了原生的set命令,相当于两命令合二为一,不存在原子性的问题,当然也可以通过lua脚本来解决。

set命令如下格式:

key 为分布式锁的key
value 为分布式锁的值,一般为不同的客户端设置不同的值
NX 代表如果要设置的key存在返回成功,否则返回失败
EX 代表过期时间为秒,PX则为毫秒,比如上面示例中为10秒过期

Zookeeper是通过创建临时顺序节点的方式来实现。

  1. 当需要对资源进行加锁时,实际上就是在父节点之下创建一个临时顺序节点。
  2. 客户端A来对资源加锁,首先判断当前创建的节点是否为最小节点,如果是,那么加锁成功,后续加锁线程阻塞等待
  3. 此时,客户端B也来尝试加锁,由于客户端A已经加锁成功,所以客户端B发现自己的节点并不是最小节点,就会去取到上一个节点,并且对上一节点注册监听
  4. 当客户端A操作完成,释放锁的操作就是删除这个节点,这样就可以触发监听事件,客户端B就会得到通知,同样,客户端B判断自己是否为最小节点,如果是,那么则加锁成功

你说改为set命令之后就解决了问题?那么还会不会有其他的问题呢?

虽然set解决了原子性的问题,但是还是会存在两个问题。

锁超时问题

比如客户端A加锁同时设置超时时间是3秒,结果3s之后程序逻辑还没有执行完成,锁已经释放。客户端B此时也来尝试加锁,那么客户端B也会加锁成功。

这样的话,就导致了并发的问题,如果代码幂等性没有处理好,就会导致问题产生。

锁误删除

还是类似的问题,客户端A加锁同时设置超时时间3秒,结果3s之后程序逻辑还没有执行完成,锁已经释放。客户端B此时也来尝试加锁,这时客户端A代码执行完成,执行释放锁,结果释放了客户端B的锁。

那上面两个问题你有什么好的解决方案吗?

锁超时

这个有两个解决方案。

  1. 针对锁超时的问题,我们可以根据平时业务执行时间做大致的评估,然后根据评估的时间设置一个较为合理的超时时间,这样能一大部分程度上避免问题。
  2. 自动续租,通过其他的线程为将要过期的锁延长持有时间

锁误删除

每个客户端的锁只能自己解锁,一般我们可以在使用set命令的时候生成随机的value,解锁使用lua脚本判断当前锁是否自己持有的,是自己的锁才能释放。

#加锁
SET key random_value NX EX 10
#解锁
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

了解RedLock算法吗?

因为在Redis的主从架构下,主从同步是异步的,如果在Master节点加锁成功后,指令还没有同步到Slave节点,此时Master挂掉,Slave被提升为Master,新的Master上并没有锁的数据,其他的客户端仍然可以加锁成功。

对于这种问题,Redis作者提出了RedLock红锁的概念。

RedLock的理念下需要至少2个Master节点,多个Master节点之间完全互相独立,彼此之间不存在主从同步和数据复制。

主要步骤如下:

  1. 获取当前Unix时间
  2. 按照顺序依次尝试从多个节点锁,如果获取锁的时间小于超时时间,并且超过半数的节点获取成功,那么加锁成功。这样做的目的就是为了避免某些节点已经宕机的情况下,客户端还在一直等待响应结果。举个例子,假设现在有5个节点,过期时间=100ms,第一个节点获取锁花费10ms,第二个节点花费20ms,第三个节点花费30ms,那么最后锁的过期时间就是100-(10+20+30),这样就是加锁成功,反之如果最后时间<0,那么加锁失败
  3. 如果加锁失败,那么要释放所有节点上的锁

那么RedLock有什么问题吗?

其实RedLock存在不少问题,所以现在其实一般不推荐使用这种方式,而是推荐使用Redission的方案,他的问题主要如下几点。

性能、资源

因为需要对多个节点分别加锁和解锁,而一般分布式锁的应用场景都是在高并发的情况下,所以耗时较长,对性能有一定的影响。此外因为需要多个节点,使用的资源也比较多,简单来说就是费钱。

节点崩溃重启

比如有1~5号五个节点,并且没有开启持久化,客户端A在1,2,3号节点加锁成功,此时3号节点崩溃宕机后发生重启,就丢失了加锁信息,客户端B在3,4,5号节点加锁成功。

那么,两个客户端A\B同时获取到了同一个锁,问题产生了,怎么解决?

  1. Redis作者建议的方式就是延时重启,比如3号节点宕机之后不要立刻重启,而是等待一段时间后再重启,这个时间必须大于锁的有效时间,也就是锁失效后再重启,这种人为干预的措施真正实施起来就比较困难了
  2. 第二个方案那么就是开启持久化,但是这样对性能又造成了影响。比如如果开启AOF默认每秒一次刷盘,那么最多丢失一秒的数据,如果想完全不丢失的话就对性能造成较大的影响。

GC、网络延迟

对于RedLock,Martin Kleppmann提出了很多质疑,我就只举这样一个GC或者网络导致的例子。(这个问题比较多,我就不一一举例了,心里有一个概念就行了,文章地址:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

从图中我们可以看出,client1线获取到锁,然后发生GC停顿,超过了锁的有效时间导致锁被释放,然后锁被client2拿到,然后两个客户端同时拿到锁在写数据,问题产生。

时钟跳跃

同样的例子,假设发生网络分区,4、5号节点变为一个独立的子网,3号节点发生始终跳跃(不管人为操作还是同步导致)导致锁过期,这时候另外的客户端就可以从3、4、5号节点加锁成功,问题又发生了。

那你说说有什么好的解决方案吗?

上面也提到了,其实比较好的方式是使用Redission,它是一个开源的Java版本的Redis客户端,无论单机、哨兵、集群环境都能支持,另外还很好地解决了锁超时、公平非公平锁、可重入等问题,也实现了RedLock,同时也是官方推荐的客户端版本。

那么Redission实现原理呢?

加锁、可重入

首先,加锁和解锁都是通过lua脚本去实现的,这样做的好处是为了兼容老版本的redis同时保证原子性。

KEYS[1]为锁的key,ARGV[2]为锁的value,格式为uuid+线程ID,ARGV[1]为过期时间。

主要的加锁逻辑也比较容易看懂,如果key不存在,通过hash的方式保存,同时设置过期时间,反之如果存在就是+1。

对应的就是hincrby', KEYS[1], ARGV[2], 1这段命令,对hash结构的锁重入次数+1。

解锁

  1. 如果key都不存在了,那么就直接返回
  2. 如果key、field不匹配,那么说明不是自己的锁,不能释放,返回空
  3. 释放锁,重入次数-1,如果还大于0那么久刷新过期时间,反之那么久删除锁

watchdog

也叫做看门狗,也就是解决了锁超时导致的问题,实际上就是一个后台线程,默认每隔10秒自动延长锁的过期时间。

默认的时间就是internalLockLeaseTime / 3internalLockLeaseTime默认为30秒。

最后,实际生产中对于不同的场景该如何选择?

首先,如果对于并发不高并且比较简单的场景,通过数据库乐观锁或者唯一主键的形式就能解决大部分的问题。

然后,对于Redis实现的分布式锁来说性能高,自己去实现的话比较麻烦,要解决锁续租、lua脚本、可重入等一系列复杂的问题。

对于单机模式而言,存在单点问题。

对于主从架构或者哨兵模式,故障转移会发生锁丢失的问题,因此产生了红锁,但是红锁的问题也比较多,并不推荐使用,推荐的使用方式是用Redission。

但是,不管选择哪种方式,本身对于Redis来说不是强一致性的,某些极端场景下还是可能会存在问题。

对于Zookeeper的实现方式而言,本身就是保证数据一致性的,可靠性更高,所以不存在Redis的各种故障转移带来的问题,自己实现也比较简单,但是性能相比Redis稍差。

不过,实际中我们当然是有啥用啥,老板说用什么就用什么,我才不管那么多。


面试题

2020年已经接近尾声了,跳槽的季节又来了,刚好,最近有好几个读者拿到了腾讯、阿里大厂的offer,在我厚颜无耻的追问之下,他们终于给我透露出了面试题的细节,这份热乎乎、滚滚烫的面经分享给大家,希望对大家有所帮助。

bigo面试

第一位读者经过1个多月的刷题、看书,成功拿下bigo和腾讯的offer,这位读者之前也是985高材生,但是一直在小公司,之前和我聊了聊,透露出想去大厂的想法,这不,还是挺简单的嘛,一把就过了,成功斩获bigo、腾讯offer。

bigo一面

第一面的话,我觉得比较基础,都是针对Java、SQL基础的一些问题,然后扩展了一下对JVM对应到生产上的使用、调优经验,看是不是真的做过、解决过问题,要有思路。

内存泄露怎么分析?怎么知道整条内存泄露的链路?

一般方法,jmap dump出转储文件,然后通过MAT等一些工具来做具体的分析。

用的什么垃圾收集器?GC一次多久?线上多久一次Full GC?

垃圾收集器比较简单,背背书就可以了,然后GC的频率这个就是根据现在公司的场景举例子说明。

怎么进行JVM调优?

说了一点JVM调优的参数,使用之类,然后结合线上的一次问题回答了怎么发现问题,最终调整JVM参数解决问题的过程。

项目里有用过ConcurrentHashMap吗?ConcurrentHashMap底层结构有了解吗?

这个八股文看书就行了,分段锁到CAS+synchronized改变,get、put、resize过程。

你知道JDK7和8之间的区别吗

说了下Stream API使用、lambda表达式,HashMap头插尾插的改变,ConcurrentHashMap实现方式的变化。

用过Stream吗,讲讲

就根据平时使用说就好了,比较简单。

sql优化的经历

也比较简单,平时用到的一些慢SQL优化的经历说下就行了,但是平时要有总结,不然的话就会东一棒槌西一棒槌。

算法,链表相加

通用答案,用刷题大法。

bigo二面

二面会偏中间件一点,考察了项目的细节,会被问的很细,然后其他的问题都是看看书就知道了,虽然都不难,但是还是要多看书、多总结才行。

深挖项目

项目一定要准备好,每个细节的点,有问题的地方要自己多思考,不然被问到了回答不了就很尴尬。

讲讲ES,ES文档数据太多了怎么办?

基本上把ES的所有点都讲了一遍,就差不多OK了,因为我做的搜索业务,所以这块的问题比较多。

RocketMQ集群的原理,消息堆积怎么办,推拉模式优劣?

也是看书就行的,堆积的解决方案可以看我的MQ文章系列。

说下Raft协议?

也就说说主要工作原理,Leader选举、日志复制这些。

分布式ID的设计方案?

很多,雪花算法,国内美团、滴滴、百度开源的记得一两个就可以了,然后找一个说说实现的原理。

比较简单的一个算法题,印象不是很清晰了,但是依稀记得是考并发工具包的设计

bigo三面

三面一上来其实还是问项目,扣细节,这一面是技术的终面了,可能是老板面,所以没有很多的技术上的难题,针对的还是个人思维方式,平时解决问题的想法和思路。

Redis集群的特性,分布式锁的设计?

这个一般也没什么好说的,该背书就背书,分布式锁也是老生常谈的问题了。

问了项目架构,项目难点

再次被扣细节,平时要理解深刻。

算法是二分法的一个变形题,也不算难

bigo面试总结

面试难度总体来说一般,都是在网上能看得到的问题,但是必须都要会,比较顺利的拿下offer。

腾讯面试

因为读者已经先拿了bigo的offer,接下来腾讯的面试也算是更有信心了,至少有一个offer打底。不过腾讯一面问的非常广泛,提问速度也很快,如果讲的明白的话,立刻就开始下一个问题... ...

腾讯一面

HTTP/HTTPS,网络安全问题?

说了说他们的区别,Https通信的机制,证书、密钥保证安全一些东西。

volatile和synchronize的区别?

八股文,背!

JAVA内存模型?

JMM一套规则,工作内存、主内存,原子性、可见性、有序性,happens-before等等都说了。

Redis分布式锁?

这个挺简单的,大家都会的,另外还要说下和zookeeper实现方式的一些区别,实际应用的过程。

Innodb讲讲?

把知道的都说出来就好了,行锁啊,MVCC,外键,一致性读一些东西。

ZAB讲讲?

就说整个ZAB协议的过程,选举、发现、同步、广播的流程。

怎么分库分表?

这个其实还是需要点经验的,没有对应到数量级的项目的话可能还是靠背书了,参考我的分库分表文章。

怎么自己实现IOC?

如果自己看过实现,这个就比较简单。

用过哪些设计模式,讲讲?

举例一些常见的模式,平时怎么使用的说说就行了。

怎么判断一个链表是不是有环?

刷题就好了。

一面的内容非常多,后面Kafka,Redis,Zookeeper,ES,计算机网络都有被问到,有一些回答的不是很好,不过还是过了。

腾讯二面

这一面比上一面还是好一点吧,没有那么多问题,感觉上比一面还稍微容易一点,还有一些简单的问题有点回忆不上了,项目的问题,我已经很熟了。

自我介绍?

自我介绍要准备好,不要太长也不要太短,几句话说明自己的职业生涯的情况,重点的项目,用到的技能点概括进去就行。

深挖项目,问了下商品表的设计,项目有什么亮点,或者认为有什么缺陷,怎么改进,并发有多少等等?

还是项目,深挖,没什么好说的了。

ES讲了个遍,包括基础原理和优化?

又重新说了一遍。

分布式ID的生成方式?

还是老问题。

再次聊了下项目,还有分布式事务相关知识,保证数据一致性?

也是老生常谈题,面试必问。两阶段、三阶段提交,TCC方案,还有强一致性、最终一致性等等。

为什么要用框架做分布式,没有行不行?

这种开放性的问题,说自己的思路就行了。举例子说明比如Dubbo这种框架解决了什么问题,如服务治理、服务编排、降级等。

腾讯总结

腾讯的面试相比bigo更加全面,更多的考察的是中间件的原理和使用,还有就是分布式系统下的一些常规的解决方案,平时这些知识点都碰到过,但是要多总结。感觉下来,整体难度也是一般。

附赠快手

读者非常优秀,临到采访结束之际,还要附送我一轮快手面试,只能勉为其难收入囊中。

数据库连接不上了,怎么排查?

还是看思路的问题,思考比如网络是否正常,数据库服务是否正常、权限等因素。

双亲委派模型,有什么好处?

说下原理,好处说了下安全、避免重复加载之类。

ThreadLocal讲讲?

看过知道就能说上来。

一次接口调用,在日志文件里打印”kuaishou ”+耗时,比如“kuaishou 20ms”,"kuaishou 50ms", "kuaishou 100ms",有十万条,用linux的命令怎么查出来耗时最短的十条?

这个不知道,然后面试官还一直硬要我手写出来... ...

安装了一个软件,怎么在linux找到他的路径?

我说了whereis。

怎么查看jvm里线程状态?

jstack进程ID就可以了。

CountDownLatch和CyclicBarrier有什么区别?

这个看过就知道了,具体可以看我的文章有写道。

jps -m ,jps -l 用过吗?

-m可以输出主函数的传参,-l可以输出完整包名。

讲一下Spring事务底层是怎么实现的?

这个问题也要看过源码,AOP动态代理实现。

算法题:树的镜像,不能用递归写。

还是那句话,刷题完事儿。

快手总结

快手的问题,嗯... 比较奇怪,然后没有什么太大问题...一轮游了。

来自年初和最近朋友的大厂面试题。

阿里巴巴

  1. 对象如何进行深拷贝,除了clone
  2. happen-before原则
  3. jvm调优的实践
  4. 单例对象会被jvm的gc时回收吗
  5. redis如果list较大,怎么优化
  6. tcp的沾包与半包
  7. socket编程相关的一些api和用法
  8. 建立和处理连接的是同一个socket吗,socket中两个队列分别是啥
  9. 项目中有使用过netty吗
  10. TSL1.3新特性
  11. AES算法原理
  12. redis集群的使用
  13. mysql与mogo对比
  14. 场景题:设计一个im系统包括群聊单聊
  15. 场景题:设计数据库连接池
  16. 场景题:秒杀场景的设计

美团

  1. 项目详细信息,涉及一些aiot交互处理,怎么实现大量的不同设备的指令编解码和指令转化,服务器的架构,自己责任模块
  2. OOM的故障处理
  3. 有没有用过分布式锁,怎么实现的,讲讲原理
  4. redis的跳表用在哪,为什么用跳表
  5. mysql优化的实践经验
  6. hashMap的1.8与1.7区别
  7. netty的原理和使用
  8. tcp的连接过程
  9. socket有几个队列
  10. 一台服务器能支持多少连接,为什么
  11. tcp各个参数怎么设置
  12. redis底层基本数据类型,redis集群原理,cluster集群的使用
  13. mysql存储引擎类型,索引类型,innodb数据存储方式
  14. 线程池的参数说明,rejectHandler说明
  15. volatile的原理
  16. jvm有哪几种垃圾回收器,各自的应用场景
  17. g1回收器的特征
  18. jvm结构
  19. 负载均衡器的四层和七层负载均衡原理
  20. 场景题:设计一个高可用高并发的电商系统

腾讯

  1. kafka生产端怎么实现幂等的
  2. kafka如何实现分布式消息
  3. kafka的slave的同步机制
  4. kafka怎么进行消息写入的ack
  5. 为什么实现equals必须先实现hash方法
  6. 一个对象new出来后的结构,怎么保存的
  7. 讲一讲类加载的过程
  8. redis的hash数据结构和如何扩容
  9. mysql快照读怎么实现的
  10. msyql 的事务隔离级别,不可重复读和幻读区别

YY

  1. JVM调优思路
  2. redis cluster集群扩容怎么数据平滑过度,从客户端设计
  3. mysql 的sql本身没问题的情况下,没走索引原因(反复强调sql没问题,不需要从sql角度考虑)
  4. kafka如何确保消息不丢失
  5. 分库分表如何进行跨库联合查询
  6. 限流设计用java实现,不能用工具类库
  7. dubbo的设计和完整调用过程(要详细)
  8. es的脑裂问题怎么解决

毒(得物)

  1. new 一个对象的过程发生了什么
  2. spring循环引用解决的原理是什么?
  3. FactoryBean 和 BeanFactory区别
  4. Synchronized原理?
  5. CAS volatile原理?
  6. 内存模型?什么是主内存?什么是工作内存?
  7. 数据库索引类型?原理?
  8. Spring Bean 生命周期?
  9. mysql优化经验?
  10. mysql锁类型?
  11. redis使用过程中应该注意什么问题?
  12. JVM调优参数?
  13. 线程池原理?属性代表含义?
  14. HashMap ConcurrentHashMap原理?

饿了么

  1. 项目介绍,怎么不断优化项目、架构升级?如果业务量剧增,怎么保证系统高可用、扩展性?
  2. 订单量、日新增多少?分库分表怎么做?基于什么维度去做?
  3. 检测到jvm内存大于配置jvm的xmx配置的内存, 三台机器中的一台机器有上面这种现象,如何解释?
  4. redis热key怎么解决?
  5. kafka为什么性能高?
  6. OOM场景分析?
  7. mysql集群是怎么部署的,主从同步?
  8. 怎么设置使用什么GC方式?不同年代GC收集器有哪些?
  9. 线上CPU很高怎么排查
  10. jdk1.8的新特性
  11. BIO\NIO了解
  12. mq怎么保证消息可靠性?
  13. 系统负载过高怎么办、什么问题导致的?怎么排查?
  14. linux操作系统简单介绍有哪些东西?

中通

  1. JVM介绍
  2. JMM模型
  3. gc root有哪些?
  4. JVM调优经验?
  5. 线程池注意事项,异常处理
  6. 分布式锁使用和原理?
  7. redis怎么持久化?高可用?
  8. rpc框架实现原理?
  9. 接口调用变慢排查
  10. 业务系统架构,业务量
  11. 数据库设计,优化方案

鱼泡泡(比心)

  1. 比较有成就的项目
  2. 清结算怎么实现的?
  3. 统一收银台设计?
  4. rocketMq 和 kafka区别,选型?
  5. kafka消息从生产到消费的流转过程?
  6. hashMap hashTable区别?
  7. 对线程安全的理解?
  8. CAS实现原理?
  9. 代码加锁有几种实现方式?
  10. 快速排序算法
  11. 分布式锁获取锁失败的处理,线程间的同步?
  12. redis线程模型,过期机制,淘汰策略?
  13. 线程池参数,使用场景,参数设置分析?
  14. mysql存储引擎,索引结构,分库分表
  15. 场景题:设计一个抢红包系统

您可能感兴趣的文章

版权:文章来自凡蜕博客,转载请带上地址。微信公众号: 『崩天的勾玉』
匿名

发表评论

匿名网友