JAVA面试题-八股文

Java基础

Java基本数据类型(1个字节是8个bit)

整数型:byte(1字节)、short(2字节)、int(4字节)、long(8字节)
浮点型:float(4字节)、double(8字节)
布尔型:boolean(1字节)
字符型:char(2字节)

Java中从字符串中删除空格的方法

  • trim() : 删除字符串开头和结尾的空格。
  • strip() : 删除字符串开头和结尾的空格。
  • stripLeading() : 只删除字符串开头的空格
  • stripTrailing() : 只删除字符串的结尾的空格
  • replace() : 用新字符替换所有目标字符
  • replaceAll() : 将所有匹配的字符替换为新字符。此方法将正则表达式作为输入,以标识需要替换的目标子字符串
  • replaceFirst() : 仅将目标子字符串的第一次出现的字符替换为新的字符串

interrupt/isInterrupted/interrupt区别

  • interrupt() 调用该方法的线程的状态为将被置为"中断"状态(set操作)
  • isinterrupted() 是作用于调用该方法的线程对象所对应的线程的中断信号是true还是false(get操作)。例如我们可以在A线程中去调用B线程对象的isInterrupted方法,查看的是A
  • interrupted()是静态方法:内部实现是调用的当前线程的isInterrupted(),并且会重置当前线程的中断状态(getandset)

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

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

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

动态代理与静态代理区别

  • 静态代理,程序运行前代理类的.class文件就存在了;
  • 动态代理:在程序运行时利用反射动态创建代理对象 < 复用性,易用性,更加集中都调用invoke

JDK1.8新特性

Lambda表达式

java支持函数式编程, 就是说函数既可以作为参数,也可以作为返回值, 大大的简化了代码的开发

default关键字

打破接口里面是只能有抽象方法,不能有任何方法的实现

新时间日期APILocalDate | LocalTime | LocalDateTime

之前使用的java.util.Date月份从0开始,一般会+1使用,很不方便,java.time.LocalDate月份和星期都改成了enum java.util.Date和SimpleDateFormat都不是线程安全的,而LocalDate和LocalTime和最基本的String一样,是不变类型,不但线程安全,而且不能修改。新接口更好用的原因是考虑到了日期时间的操作,经常发生往前推或往后推几天的情况。用java.util.Date配合Calendar要写好多代码,一般的开发人员不一定能写对。

JDK1.7与JDK1.8 ConcurrentHashMap对比
  • JDK1.7版本的ReentrantLock+Segment+HashEntry(数组)
  • JDK1.7采用segment的分段锁机制实现线程安全
  • JDK1.8版本中synchronized+CAS+HashEntry(数组)+,红黑树
  • JDK1.8采用CAS+Synchronized保证线程安全
  • 查询时间复杂度从原来的遍历链表O(n),变成遍历红黑树O(logN)

1.8 HashMap数组+链表+红黑树来实现hashmap,当碰撞的元素个数大于8时 & 总容量大于64,会有红黑树的引入 除了添加之后,效率都比链表高,1.8之后链表新进元素加到末尾

JDK1.8使用synchronized来代替重入锁ReentrantLock?
  • 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差
  • 基于JVM的synchronized优化空间更大
  • 在大数据量下,基于API的ReentrantLock会比基于JVM的内存压力开销更多的内存

数据结构

AVL树与红黑树(R-B树)的区别与联系

  • AVL是严格的平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多;
  • 红黑树是用非严格的平衡来换取增删节点时候旋转次数的降低开销;
  • 所以=,查询多选择AVL树,查询更新次数差不多选红黑树
  • AVL树顺序插入和删除时有20%左右的性能优势,红黑树随机操作15%左右优势,现实应用当然一般都是随机情况,所以红黑树得到了更广泛的应用 索引为B+树 Hashmap为红黑树

为什么redis zset使用跳跃链表而不用红黑树实现

  • skiplist的复杂度和红黑树一样,而且实现起来更简单。
  • 在并发环境下红黑树在插入和删除时需要rebalance,性能不如跳表。

锁的优化机制了解吗?

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

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

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

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

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

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

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

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

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

轻量级锁与偏向锁的区别

1、轻量级锁是通过CAS来避免进入开销较大的互斥操作
2、偏向锁是在无竞争场景下完全消除同步,连CAS也不执行

自旋锁升级到重量级锁条件

1、某线程自旋次数超过10次;
2、等待的自旋线程超过了系统core数的一半

读写锁

常用的读写锁ReentrantReanWritelock,其实和reentrantLock相似,也是基于AQS的,但是这个是基于共享资源的,不是互斥,关键在于state的处理,读写锁把高16为记为读状态,低16位记为写状态,就分开了,读读情况其实就是读锁重入,读写/写读/写写都是互斥的,只要判断低16位就好了。

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

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

  • 对象头:主要是包括两部分,
    • 1.存储自身的运行时数据比如hash码,分代年龄,锁标记等(但不是绝对的,锁状态如果是偏向锁,轻量级锁,是没有hash码的,不固定的)
    • 2.指向类的元数据指针。还有可能存在第三部分,那就是数组类型,会多一块记录数组的长度(因为数组的长度是jvm判断不出来的,jvm只有元数据信息)
  • 实例数据:会根据虚拟机分配策略来定,分配策略中,会把相同大小的类型放在一起,并按照定义顺序排列(父类的变量也会在哦)
  • 对齐填充:这个意义不是很大,主要在虚拟机规范中对象必须是8字节的整数,所以当对象不满足这个情况时,就会用占位符填充

而对象头包含两部分内容,Mark Word中的内容会随着锁标志位而发生变化

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

如果是数组的话,则还包含了数组的长度
在这里插入图片描述

ReentrantLock 是如何实现可重入性的 ?

内部自定义了同步器 Sync,加锁的时候通过CAS 算法 ,将线程对象放到一个双向链表 中,每次获取锁的时候 ,看下当前维护的那个线程ID和当前请求的线程ID是否一样,一样就可重入;

ReentrantLock如何避免死锁?

  • 响应中断lockInterruptibly()
  • 可轮询锁tryLock()
  • 定时锁tryLock(long time)

tryLock 和 lock 和 lockInterruptibly 的区别

1、tryLock 能获得锁就返回 true,不能就立即返回 false,
2、tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false
3、lock 能获得锁就返回 true,不能的话一直等待获得锁
4、lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程, lock 不会抛出异常,而 lockInterruptibly 会抛出异常。

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

  • 都是可重入锁;ReentrantLock是显示获取和释放锁,synchronized是隐式;
  • ReentrantLock更灵活可以知道有没有成功获取锁,可以定义读写锁,是api级别,synchronized是JVM级别;
  • ReentrantLock可以定义公平锁;Lock是接口,synchronized是java中的关键字

ReentrantLock基于AQS(AbstractQueuedSynchronizer 抽象队列同步器)实现。

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

CountDownLatch和CyclicBarrier的区别是什么

  • CountDownLatch是等待其他线程执行到某一个点的时候,在继续执行逻辑(子线程不会被阻塞,会继续执行),只能被使用一次。最常见的就是join形式,主线程等待子线程执行完任务,在用主线程去获取结果的方式(不一定),内部用计数器相减实现的(AQS),AQS的state承担了计数器的作用,初始化的时候,使用CAS赋值,主线程调用await()则被加入共享线程等待队列里面,子线程调用countDown的时候,使用自旋的方式,减1,直到为0,就触发唤醒。
  • CyclicBarrier回环屏障,主要是等待一组线程到底同一个状态的时候,放闸。
  • CyclicBarrier还可以传递一个Runnable对象,可以到放闸的时候,执行这个任务。
  • CyclicBarrier是可循环的,当调用await的时候如果count变成0了则会重置状态,如何重置呢,
  • CyclicBarrier新增了一个字段parties,用来保存初始值,当count变为0的时候,就重新赋值。还有一个不同点,CyclicBarrier不是基于AQS的,而是基于RentrantLock实现的。存放的等待队列是用了条件变量的方式。

可重入锁

1、可重入锁是指同一个线程可以多次获取同一把锁,不会因为之前已经获取过还没释放而阻塞;
2、reentrantLock和synchronized都是可重入锁
3、可重入锁的一个优点是可一定程度避免死锁

公平锁与分公平锁

1、公平锁指在分配锁前检查是否有线程在排队等待获取该锁,优先分配排队时间最长的线程,非公平直接尝试获取锁
2、公平锁需多维护一个锁线程队列,效率低;默认非公平

独占锁与共享锁

1、ReentrantLock为独占锁(悲观加锁策略)
2、ReentrantReadWriteLock中读锁为共享锁
3、JDK1.8 邮戳锁(StampedLock), 不可重入锁读的过程中也允许获取写锁后写入!这样一来,读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁, 乐观锁的并发效率更高,一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行

AQS 原理

Node内部类构成的一个双向链表结构的同步队列,通过控制(volatile的int类型)state状态来判断锁的状态,对于非可重入锁状态不是0则去阻塞;

对于可重入锁如果是0则执行,非0则判断当前线程是否是获取到这个锁的线程,是的话把state状态+1,比如重入5次,那么state=5。而在释放锁的时候,同样需要释放5次直到state=0其他线程才有资格获得锁

AQS两种资源共享方式

  • Exclusive:独占,只有一个线程能执行,如ReentrantLock
  • Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

产生死锁的四个必要条件

  • 互斥:资源x的任意一个时刻只能被一个线程持有
  • 占有且等待:线程1占有资源x的同时等待资源y,并不释放x
  • 不可抢占:资源x一旦被线程1占有,其他线程不能抢占x
  • 循环等待:线程1持有x,等待y,线程2持有y,等待x 当全部满足时才会死锁

多线程

进程和线程的区别?

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

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

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

线程状态

线程池有5种状态:Running,Showdown,Stop,Tidying,Terminated。

  • Running:线程池处于运行状态,可以接受任务,执行任务,创建线程默认就是这个状态了
  • Showdown:调用showdown()函数,不会接受新任务,但是会慢慢处理完堆积的任务。
  • Stop:调用showdownnow()函数,不会接受新任务,不处理已有的任务,会中断现有的任务。
  • Tidying:当线程池状态为showdown或者stop,任务数量为0,就会变为tidying。这个时候会调用钩子函数terminated()。
  • Terminated:执行完成。

在线程池中,用了一个原子类来记录线程池的信息,用了int的高3位表示状态,后面的29位表示线程池中线程的个数。

信号量Semaphore

信号量是一种固定资源的限制的一种并发工具包,基于AQS实现的,在构造的时候会设置一个值,代表着资源数量。信号量主要是应用于是用于多个共享资源的互斥使用,和用于并发线程数的控制(druid的数据库连接数,就是用这个实现的),信号量也分公平和非公平的情况,基本方式和reentrantLock差不多,在请求资源调用task时,会用自旋的方式减1,如果成功,则获取成功了,如果失败,导致资源数变为了0,就会加入队列里面去等待。调用release的时候会加1,补充资源并唤醒等待队列。

Semaphore 应用

  • acquire() release() 可用于对象池,资源池的构建,比如静态全局对象池,数据库连接池;
  • 创建计数为1的S,作为互斥锁(二元信号量)

CAS的原理

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

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

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

CAS有什么缺点吗?

CAS的缺点:

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

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

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

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

volatile变量

1、变量可见性
2、防止指令重排序
3、保障变量单次读,写操作的原子性,但不能保证i++这种操作的原子性,因为本质是读,写两次操作

volatile如何保证线程间可见和避免指令重排

volatile可见性是有指令原子性保证的,在jmm中定义了8类原子性指令,比如write,store,read,load。而volatile就要求write-store,load-read成为一个原子性操作,这样可以确保在读取的时候都是从主内存读入,写入的时候会同步到主内存中(准确来说也是内存屏障),指令重排则是由内存屏障来保证的,由两个内存屏障:

  • 一个是编译器屏障:阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。
  • 第二个是cpu屏障:sfence保证写入,lfence保证读取,lock类似于锁的方式。java多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个lock指令,就是增加一个完全的内存屏障指令。

Volatile原理

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

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

  • 线程A首先获取变量X的值,由于最初两级缓存都是空,所以直接从主内存中读取X,假设X初始值为0,线程A读取之后把X值都修改为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一种内存屏障。

  • StoreStore屏障,保证上面的普通写不和volatile写发生重排序
  • StoreLoad屏障,保证volatile写与后面可能的volatile读写不发生重排序
  • LoadLoad屏障,禁止volatile读与后面的普通读重排序
  • LoadStore屏障,禁止volatile读和后面的普通写重排序
    在这里插入图片描述

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对象,实际上是不会出现这个问题的。
在这里插入图片描述
应用:

  • 用来解决数据库连接,存放connection对象,不同线程存放各自session;
  • 解决simpleDateFormat线程安全问题;
  • 会出现内存泄漏,显式remove…不要与线程池配合,因为worker往往是不会退出的

ThreadLocal 内存泄漏问题

如果是强引用,设置tl=null,但是key的引用依然指向ThreadLocal对象,所以会有内存泄漏,而使用弱引用则不会;但是还是会有内存泄漏存在,ThreadLocal被回收,key的值变成null,导致整个value再也无法被访问到;

解决办法:在使用结束时,调用ThreadLocal.remove来释放其value的引用

如何获取父线程的ThreadLocal值

ThreadLocal是不具备继承性的,所以是无法获取到,但是我们可以用InteritableThreadLocal来实现这个功能。InteritableThreadLocal继承来ThreadLocal,重写了createdMap方法,已经对应的get和set方法,不是在利用了threadLocals,而是interitableThreadLocals变量。

这个变量会在线程初始化的时候(调用init方法),会判断父线程的interitableThreadLocals变量是否为空,如果不为空,则把放入子线程中,但是其实这玩意没啥鸟用,当父线程创建完子线程后,如果改变父线程内容是同步不到子线程的。。。同样,如果在子线程创建完后,再去赋值,也是没什么用了

线程池原理

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

  • 最大线程数maximumPoolSize
  • 核心线程数corePoolSize
  • 活跃时间keepAliveTime
  • 阻塞队列workQueue
  • 拒绝策略RejectedExecutionHandler
  • 提交一个任务,线程池里存活的核心线程数小于corePoolSize时,线程池会创建一个核心线程去处理提交的任务
  • 如果线程池核心线程数已满,即线程数已经等于corePoolSize,一个新提交的任务,会被放进任务队列workQueue排队等待执行。
  • 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列workQueue也满,判断线程数是否达到maximumPoolSize,即最大线程数是否已满,如果没到达,创建非核心线程执行提交的任务。
  • 如果当前的线程数达到了maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。

newFixedThreadPool (固定数目线程的线程池)
阻塞队列为无界队列LinkedBlockingQueue,适用于处理CPU密集型的任务,适用执行长期的任务

newCachedThreadPool(可缓存线程的线程池)
阻塞队列是SynchronousQueue,适用于并发执行大量短期的小任务

newSingleThreadExecutor(单线程的线程池)
阻塞队列是LinkedBlockingQueue,适用于串行执行任务的场景,一个任务一个任务地执行

newScheduledThreadPool(定时及周期执行的线程池)
阻塞队列是DelayedWorkQueue,周期性执行任务的场景,需要限制线程数量的场景

拒绝策略

拒绝策略:

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

终止线程方法

  • 使用退出标志,线程正常退出;
  • 通过判断this.interrupted() throw new InterruptedException()来停止 使用String常量池作为锁对象会导致两个线程持有相同的锁,另一个线程不执行,改用其他如new Object()

Java中的线程池是如何实现的?

  • 线程中线程被抽象为静态内部类Worker,基于AQS实现的存放在HashSet中;
  • 要被执行的线程存放在BlockingQueue中;
  • 基本思想就是从workQueue中取出要执行的任务,放在worker中处理

如果线程池中的一个线程运行时出现了异常,会发生什么?

如果提交任务的时候使用了submit,则返回的feature里会存有异常信息,但是如果数execute则会打印出异常栈。但是不会给其他线程造成影响。之后线程池会删除该线程,会新增加一个worker。

集合

Java的集合框架有哪几种:

两种:collection和map,其中collection分为set和List。

ArrayList
  • ArrayList 初始容量为 10

  • 1.7 扩容算法(原来的容量*3) / 2+1

  • 1.8 初始容量+(初始容量/2)

  • ArrayList就是动态数组,用MSDN中的说法,就是Array的复杂版本,它提供了动态的增加和减少元素,实现了ICollection和IList接口,灵活的设置数组的大小等好处。

ArrayList是线程安全的么?

当然不是,线程安全版本的数组容器是Vector。

Vector的实现很简单,就是把所有的方法统统加上synchronized就完事了。

你也可以不使用Vector,用Collections.synchronizedList把一个普通ArrayList包装成一个线程安全版本的数组容器也可以,原理同Vector是一样的,就是给所有的方法套上一层synchronized。

ArrayList有用过吗?它是一个什么东西?可以用来干嘛?

用过,ArrayList就是数组列表,主要用来装载数据,当装载的是基本类型的数据int,long,boolean,short,byte…的时候只能存储他们对应的包装类,它的主要底层实现是数组Object[] elementData。

与它类似的是LinkedList,和LinkedList相比,它的查找和访问元素的速度较快,但新增,删除的速度较慢。

数据结构:ArrayList底层是用数组实现的存储。
特点:查询效率高,增删效率低,线程不安全。使用频率很高。

为什么线程不安全还使用他?

正常使用的场景中都是用来查询,不会涉及太频繁的增删,如果涉及频繁的增删,可以使用LinkedList,如果你需要线程安全就使用Vector,这就是三者的区别了,实际开发过程中还是ArrayList使用最多的。

不存在一个集合工具是查询效率又高,增删效率也高的,还线程安全的,因为数据结构的特性就是优劣共存的,想找个平衡点很难,牺牲了性能,那就安全,牺牲了安全那就快速。

你说它的底层实现是数组,但是数组的大小是定长的,如果我们不断的往里面添加数据的话,不会有问题吗?

ArrayList可以通过构造方法在初始化的时候指定底层数组的大小。

通过无参构造方法的方式ArrayList()初始化,则赋值底层数Object[] elementData为一个默认空数组Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}所以数组容量为0,只有真正对数据进行添加add时,才分配默认DEFAULT_CAPACITY = 10的初始容量。
在这里插入图片描述

数组的长度是有限制的,而ArrayList是可以存放任意数量对象,长度不受限制,那么他是怎么实现的呢?

其实实现方式比较简单,他就是通过数组扩容的方式去实现的。

就比如我们现在有一个长度为10的数组,现在我们要新增一个元素,发现已经满了,那ArrayList会怎么做呢?
在这里插入图片描述
第一步他会重新定义一个长度为10+10/2的数组也就是新增一个长度为15的数组。
在这里插入图片描述
然后把原数组的数据,原封不动的复制到新数组中,这个时候再把指向原数的地址换到新数组,ArrayList就这样完成了一次改头换面。
在这里插入图片描述

Tip:使用ArrayList的时候一般不会设置初始值的大小,那ArrayList默认的大小就刚好是10。
在这里插入图片描述
他的构造方法,如果你传入了初始值大小,那就使用你传入的参数,如果没,那就使用默认的,一切都是有迹可循的。

我记得你说到了,他增删很慢,你能说一下ArrayList在增删的时候是怎么做的么?主要说一下他为啥慢?

他有指定index新增,也有直接新增的,在这之前他会有一步校验长度的判断ensureCapacityInternal,就是说如果长度不够,是需要扩容的。
在这里插入图片描述
在扩容的时候,老版本的jdk和8以后的版本是有区别的,8之后的效率更高了,采用了位运算,右移一位,其实就是除以2这个操作。
在这里插入图片描述
指定位置新增的时候,在校验之后的操作很简单,就是数组的copy,大家可以看下代码。
在这里插入图片描述
比如有下面这样一个数组我需要在index 5的位置去新增一个元素A
在这里插入图片描述
那从代码里面可以看到,他复制了一个数组,是从index 5的位置开始的,然后把它放在了index 5+1的位置
在这里插入图片描述
给要新增的元素腾出了位置,然后在index的位置放入元素A就完成了新增的操作了
在这里插入图片描述
至于为啥说他效率低,我想我不说你也应该知道了,我这只是在一个这么小的List里面操作,要是我去一个几百几千几万大小的List新增一个元素,那就需要后面所有的元素都复制,然后如果再涉及到扩容啥的就更慢了。

ArrayList(int initialCapacity)会不会初始化数组大小?

不会初始化数组大小,而且将构造函数与initialCapacity结合使用,然后使用set()会抛出异常,尽管该数组已创建,但是大小设置不正确。

使用sureCapacity()也不起作用,因为它基于elementData数组而不是大小。

还有其他副作用,这是因为带有sureCapacity()的静态DEFAULT_CAPACITY。

进行此工作的唯一方法是在使用构造函数后,根据需要使用add()多次。

大家可能有点懵,我直接操作一下代码,大家会发现我们虽然对ArrayList设置了初始大小,但是我们打印List大小的时候还是0,我们操作下标set值的时候也会报错,数组下标越界。
在这里插入图片描述

ArrayList插入删除一定慢么?

取决于你删除的元素离数组末端有多远,ArrayList拿来作为堆栈来用还是挺合适的,push和pop操作完全不涉及数据移动操作。

那他的删除怎么实现的呢?

删除其实跟新增是一样的,不过叫是叫删除,但是在代码里面我们发现,他还是在copy一个数组。

为啥是copy数组呢?

在这里插入图片描述
打个比方,我们现在要删除下面这个数组中的index5这个位置
在这里插入图片描述
那代码他就复制一个index5+1开始到最后的数组,然后把它放到index开始的位置
在这里插入图片描述
ndex5的位置就成功被”删除“了其实就是被覆盖了,给了你被删除的感觉。

同理他的效率也低,因为数组如果很大的话,一样需要复制和移动的位置就大了。

ArrayList用来做队列合适么?

队列一般是FIFO(先入先出)的,如果用ArrayList做队列,就需要在数组尾部追加数据,数组头部删除数组,反过来也可以。

但是无论如何总会有一个操作会涉及到数组的数据搬迁,这个是比较耗费性能的。

结论:ArrayList不适合做队列。

那数组适合用来做队列么?

数组是非常合适的。

比如ArrayBlockingQueue内部实现就是一个环形队列,它是一个定长队列,内部是用一个定长数组来实现的。

另外著名的Disruptor开源Library也是用环形数组来实现的超高性能队列,具体原理不做解释,比较复杂。

简单点说就是使用两个偏移量来标记数组的读位置和写位置,如果超过长度就折回到数组开头,前提是它们是定长数组。

ArrayList的遍历和LinkedList遍历性能比较如何?

论遍历ArrayList要比LinkedList快得多,ArrayList遍历最大的优势在于内存的连续性,CPU的内部缓存结构会缓存连续的内存片段,可以大幅降低读取内存的性能开销。

vector和ArrayList和linkedList的区别

ArrayList实现是一个数组,可变数组,默认初始化长度为10,也可以设置容量,但是没有设置的时候是默认的空数组,只有在第一步add的时候会进行扩容至10(重新创建了数组),后续扩容按照3/2的大小进行扩容,是线程不安全的,适用多读取,少插入的情况

linkedList是基于双向链表的实现,使用了尾插法的方式,内部维护了链表的长度,以及头节点和尾节点,所以获取长度不需要遍历。适合一些插入/删除频繁的情况。

Vector是线程安全的,实现方式和ArrayList相似,也是基于数组,但是方法上面都有synchronized关键词修饰。其扩容方式是原来的两倍。

HashMap
说一下HashMap

HashMap是Map的结构,内部用了数组(数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node) + 链表的方式,在1.8后,当链表长度达到8的时候,会变成红黑树,这样子就可以把查询的复杂度变成O(nlogn)了,默认负载因子是0.75,为什么是0.75呢?

当负载因子太小,就很容易触发扩容,如果负载因子太大就容易出现碰撞。所以这个是空间和时间的一个均衡点,在1.8的hashmap介绍中,就有描述了,貌似是0.75的负载因子中,能让随机hash更加满足0.5的泊松分布。

除此之外,1.7的时候是头插法,1.8后就变成了尾插法,主要是为了解决rehash出现的死循环问题,而且1.7的时候是先扩容后插入,1.8则是先插入后扩容(为什么?正常来说,如果先插入,就有可能节点变为树化,那么是不是多做一次树转化,比1.7要多损耗,个人猜测,因为读写问题,因为hashmap并不是线程安全的,如果说是先扩容,后写入,那么在扩容期间,是访问不到新放入的值的,不太合适,所以会先放入值,这样子在扩容期间,那个值是在的)。

1.7版本的时候用了9次扰动,5次异或,4次位移,减少hash冲突,但是1.8就只用了两次,觉得就足够了一次异或,一次位移。

HashMap的put方法的过程

判断key是否是null,如果是null对应的hash值就是0,获得hash值过后则进行扰动,(1.7是9次,5次异或,4次位移,1.8是2次),获取到的新hash值找出所在的index,(n-1)& hash,根据下标找到对应的Node/entity,然后遍历链表/红黑树,如果遇到hash值相同且equals相同,则覆盖值,如果不是则新增。如果节点数大于8了,则进行树化(1.8)。完成后,判断当前的长度是否大于阀值,是就扩容(1.7是先扩容在put)。

HashMap的默认初始化长度是多少?

初始化大小是16

为啥是16?

JDK1.8的 236 行有1<<4就是16,为啥用位运算呢?直接写16不好么?
在这里插入图片描述
我们在创建HashMap的时候,阿里巴巴规范插件会提醒我们最好赋初值,而且最好是2的幂。
在这里插入图片描述
这样是为了位运算的方便,位与运算比算数计算的效率高了很多,之所以选择16,是为了服务将Key映射到index的算法。

所有的key我们都会拿到他的hash,但是我们怎么尽可能的得到一个均匀分布的hash呢?是的我们通过Key的HashCode值去做位运算。

打个比方,key为”张三“的十进制为774889那二进制就是 10111101001011101001
在这里插入图片描述
我们再看下index的计算公式:index = HashCode(Key) & (Length- 1)
在这里插入图片描述
15的的二进制是1111,那10111011000010110100 &1111 十进制就是4

之所以用位与运算效果与取模一样,性能也提高了不少!

那为啥用16不用别的呢?

因为在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。这是为了实现均匀分布

Hashmap中的链表大小超过8个会自动转化为红黑树,当删除小于6时重新变为链表,为啥呢?

根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。

你提到了还有链表,为啥需要链表,链表又是怎样的呢?

我们都知道数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身就存在概率性,就是15和50我们都去hash有一定的概率会一样,极端情况也会hash到一个值上,那就形成了链表。

每一个节点都会保存自身的hash、key、value、以及下个节点,看看Node的源码。
在这里插入图片描述

新的Entry节点在插入链表的时候,是怎么插入的?

Java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,就像上面的例子一样,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。但是,在java8之后,都是使用尾部插入了

什么时候resize(数据扩容)呢?

两个因素:

  • Capacity:HashMap当前长度。
  • LoadFactor:负载因子,默认值0.75f。
    在这里插入图片描述
    比如当前的容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就进行扩容,但是HashMap的扩容也不是简单的扩大点容量这么简单的。
它是怎么扩容的呢?

分为两步

  • 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
  • ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
为什么要重新Hash呢,直接复制过去不香么?

因为长度扩大以后,Hash的规则也随之改变。

Hash的公式: index = HashCode(Key) & (Length - 1)

原来长度(Length)是8,位运算出来的值是2 ,新的长度是16,位运算出来的值明显不一样了。

为啥之前用头插法,java8之后改成尾插?

主要是为了安全,防止环化 因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

举个例子,现在往一个容量大小为2的put两个值,负载因子是0.75是不是我们在put第二个的时候就会进行resize?

2*0.75 = 1 所以插入第二个就要resize了
在这里插入图片描述
现在我们要在容量为2的容器里面用不同线程插入A,B,C,假如在resize之前打个断点,那意味着数据都插入了但是还没resize那扩容前可能是这样的。

可以看到链表的指向 A -> B -> C

Tip:A的下一个指针是指向B的
在这里插入图片描述
因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

就可能出现下面的情况,B的下一个指针指向了A
在这里插入图片描述
一旦几个线程都调整完成,就可能出现环形链表
在这里插入图片描述
如果这个时候去取值,悲剧就出现了—— Infinite Loop。

1.8的尾插是怎么样的呢?

因为java8之后链表有红黑树的部分,可以看到代码已经多了很多if else的逻辑判断了,红黑树的引入巧妙的将原本O(n)的时间复杂度降低到了O(logn)。

Tip:红黑树本章不提,本章不进行拓展,可以看<图解红黑树>这篇文章进行了解

使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。

就是说原本是A -> B,在扩容后那个链表还是A -> B
在这里插入图片描述
Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。

Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。

那是不是意味着Java8就可以把HashMap用在多线程中呢?

我认为即使不会出现死循环,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。

我记得你上面说过他是线程不安全的,那你能跟我聊聊你们是怎么处理HashMap在线程安全的场景么?

在这样的场景,我们一般都会使用HashTable或者CurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是CorruentHashMap。

HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,currentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了。
在这里插入图片描述

为什么修改HashCode方法要修改equals

因为在java中,所有的对象都是继承于Object类。Ojbect类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等的。

在未重写equals方法我们是继承了object的equals方法,那里的 equals是比较两个对象的内存地址,显然我们new了2个对象内存地址肯定不一样

我们知道在map中判断是否是同一个对象的时候,会先判断hash值,在判断equals的,如果我们只是重写了hashcode,没有顺便修改equals,比如Intger,hashcode就是value值,如果我们不改写equals,而是用了Object的equals,那么就是判断两者指针是否一致了,那就会出现valueOf和new出来的对象会对于map而言是两个对象,那就是个问题了

HashMap在多线程环境下存在线程安全问题,你一般都是怎么处理这种情况的?
  • 使用Collections.synchronizedMap(Map)创建线程安全的map集合;
  • Hashtable
  • ConcurrentHashMap

不过出于线程并发度的原因,我都会舍弃前两者使用最后的ConcurrentHashMap,他的性能和效率明显高于前两者。

Hashtable
能跟我聊一下Hashtable么?

跟HashMap相比Hashtable是线程安全的,适合在多线程的情况下使用,但是效率不太乐观。

你能说说他效率低的原因么?

他在对数据操作的时候都会上锁,所以效率比较低下。
在这里插入图片描述

你能说出一些Hashtable 跟HashMap不一样点么?

Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null。

实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。

初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。

扩容机制不同:当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。

迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。

所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,而 Hashtable 则不会。

fail-fast是啥?

快速失败(fail—fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。

他的原理是?

迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。

集合在被遍历期间如果内容发生变化,就会改变modCount的值。

Tip:这里异常的抛出条件是检测到 modCount!= expectedmodCount
这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。

因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。

每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。

说说他的场景?

java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。

Tip:安全失败(fail—safe),java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

为啥 Hashtable 是不允许 KEY 和 VALUE 为 null, 而 HashMap 则可以呢?

这是因为Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。

如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。

但是HashMap却做了特殊处理。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
ConcurrentHashMap
Collections.synchronizedMap是怎么实现线程安全的你有了解过么?

在SynchronizedMap内部维护了一个普通对象Map,还有排斥锁mutex
在这里插入图片描述
我们在调用这个方法的时候就需要传入一个Map,可以看到有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象。

如果没有,则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map。

创建出synchronizedMap之后,再操作map的时候,就会对方法上锁
在这里插入图片描述

说一下ConcurrentHashMap

ConcurrentHashMap是线程安全的map结构,它的核心思想是分段锁。在1.7版本的时候,内部维护了segment数组,默认是16个,segment中有一个table数组(相当于一个segmeng存放着一个hashmap。。。),segment继承了reentrantlock,使用了互斥锁,map的size其实就是segment数组的count和。而在1.8的时候做了一个大改版,废除了segment,采用了cas加synchronize方式来进行分段锁(还有自旋锁的保证),而且节点对象改用了Node不是之前的HashEntity。

Node可以支持链表和红黑树的转化,比如TreeBin就是继承了Node,这样子可以直接用instanceof来区分。1.8的put就很复杂了,会先计算出hash值,然后根据hash值选出Node数组的下标(默认数组是空的,所以一开始put的时候会初始化,指定负载因子是0.75,不可变),判断是否为空,如果为空,则用cas的操作来赋值首节点,如果失败则进行自旋,会进入非空节点的逻辑,这个时候会用synchronize加锁头节点(保证整条链路锁定)这个时候还会进行二次判断,是否是同一个首节点,在分首节点到底是链表还是树结构,进行遍历判断。

说说他的数据结构,以及为啥他并发度这么高?

HashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。

先说一下他在1.7中的数据结构:
在这里插入图片描述
它是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表

Segment 是 ConcurrentHashMap 的一个内部类

static final class Segment<K,V> extends ReentrantLock implements Serializable {

    private static final long serialVersionUID = 2249069246763182397L;

    // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
    transient volatile HashEntry<K,V>[] table;

    transient int count;
        // 记得快速失败(fail—fast)么?
    transient int modCount;
        // 大小
    transient int threshold;
        // 负载因子
    final float loadFactor;

}

HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。

volatile的特性是啥?
  • 保证不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的(实现可见性)
  • 禁止进行指令重排序(实现有序性)
  • volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。
ConcurrentHashMap并发怎么样

他的并发度不够,性能很低

原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。

不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。

每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。

就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();//这就是为啥他不可以put null值的原因
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          
         (segments, (j << SSHIFT) + SBASE)) == null) 
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

他先定位到Segment,然后再进行put操作。

  final V put(K key, int hash, V value, boolean onlyIfAbsent) {
          // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
 				  // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                 // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
               //释放锁
                unlock();
            }
            return oldValue;
        }

首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

  • 尝试自旋获取锁。
  • 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。
那他get的逻辑呢?

get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。

由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁

你有没有发现1.7虽然可以支持每个Segment并发访问,但是还是存在一些问题?

是的,因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。

那你再跟我聊聊jdk1.8他的数据结构是怎样的?

抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

你能跟我聊一下他值的存取操作么?以及是怎么保证线程安全的?

ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:

  • 根据 key 计算出 hashcode 。
  • 判断是否需要进行初始化。
  • 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  • 如果都不满足,则利用 synchronized 锁写入数据。
  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
    在这里插入图片描述
CAS是什么?自旋又是什么?

CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。

CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

这是一种乐观策略,认为并发操作并不总会发生。
在这里插入图片描述
比如我现在要修改数据库的一条数据,修改之前我先拿到他原来的值,然后在SQL里面还会加个判断,原来的值和我手上拿到的他的原来的值是否一样,一样我们就可以去修改了,不一样就证明被别的线程修改了你就return错误就好了。

CAS就一定能保证数据没被别的线程修改过么?

并不是的,比如很经典的ABA问题,CAS就无法判断了。

什么是ABA?

就是说来了一个线程把值改回了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。

但是实际过程中还是需要记录修改过程的,比如资金修改什么的,你每次修改的都应该有记录,方便回溯。

那怎么解决ABA问题?

用版本号去保证就好了,比如说在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。

除了版本号还有别的方法保证么?

其实有很多方式,比如时间戳也可以,查询的时候把时间戳一起查出来,对的上才修改并且更新值的时候一起修改更新时间,这样也能保证,方法很多但是跟版本号都是异曲同工之妙,看场景大家想怎么设计吧。

CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?

synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁

所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。

ConcurrentHashMap的get操作又是怎样的呢?
  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
  • 如果是红黑树那就按照树的方式获取值。
  • 不满足那就按照链表的方式遍历获取值。
    在这里插入图片描述

小结:1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。

ConcurrentHashMap的扩容方式

1.7版本的concurrentHashMap是基于了segment的,segment内部维护了HashEntity数组,所以扩容是在这个基础上的,类比hashmap的扩容

1.8版本的concurrentHashMap扩容方式比较复杂,利用了ForwardingNode,先会根据机器内核数来分配每个线程能分到的busket数(最小是16),这样子可以做到多线程协助迁移,提升速度。然后根据自己分配的busket数来进行节点转移,如果为空,就放置ForwardingNode,代表已经迁移完成,如果是非空节点(判断是不是ForwardingNode,是就结束了),加锁,链路循环进行迁移。

HashMap和HashTable和ConcurrentHashMap的区别

hashMap是map类型的一种最常用的数据结构,其底部实现是数组+链表(在1.8版本后变为了 数组+链表/红黑树 的方式),其key是可以为null的,默认hash值为0。扩容以2的幂等次(为什么。。。因为只有是2的幂等次的时候(n-1)&x==x%n,当然不一定只有一个原因)。是线程不安全的

hashTable的实现形式和hashMap差不多,它是线程安全的,继承了Dictionary,也是key-value的模式,但是其key不能为null。

ConcurrentHashMap是JUC并发包的一种,在hashMap的基础上做了修改,因为hashmap其实是线程不安全的,在并发情况下使用hashTable,但是hashTable是全程加锁的,性能不好,所以采用分段的思想,把原本的一个数组分成默认16段,就可以最多容纳16个线程并发操作,16个段叫做Segment,是基于ReetrantLock来实现的

TreeMap了解吗?

TreeMap是Map中的一种很特殊的map,我们知道Map基本是无序的,但是TreeMap是会自动进行排序的,也就是一个有序Map(使用了红黑树来实现),如果设置了Comparator比较器,则会根据比较器来对比两者的大小,如果没有则key需要是Comparable的子类(代码中没有事先check,会直接抛出转化异常)。

LinkedHashMap呢

LinkedHashMap是HashMap的一种特殊分支,是某种有序的hashMap,和TreeMap是不一样的概念,是用了HashMap+链表的方式来构造的,有两者有序模式:访问有序,插入顺序,插入顺序是一直存在的,因为是调用了hashMap的put方法,并没有重载,但是重载了newNode方法,在这个方法中,会把节点插入链表中,访问有序默认是关闭的,如果打开,则在每次get的时候都会把链表的节点移除掉,放到链表的最后面。这样子就是一个LRU的一种实现方式。

JVM

jre、jdk、jvm的关系

jdk是最小的开发环境,由jre+java工具组成。

jre是java运行的最小环境,由jvm+核心类库组成。

jvm是虚拟机,是java字节码运行的容器,如果只有jvm是无法运行java的,因为缺少了核心类库。

JVM内存模型

1、堆 < 对象,静态变量,共享
2、方法区<存放类信息,常量池,共享>(java8移除了永久代(PermGen),替换为元空间(Metaspace))
3、虚拟机栈<线程执行方法的时候内部存局部变量会存堆中对象的地址等等数据>
4、本地方法栈<存放各种native方法的局部变量表之类的信息>
5、程序计数器<记录当前线程执行到哪一条字节码指令位置>

如何判断一个对象是否存活

一般判断对象是否存活有两种算法,一种是引用计数,另外一种是可达性分析。在java中主要是第二种

java是根据什么来执行可达性分析的

根据GC ROOTS。GC ROOTS可以的对象有:虚拟机栈中的引用对象,方法区的类变量的引用,方法区中的常量引用,本地方法栈中的对象引用。

JVM 类加载顺序

1、加载: 获取类的二进制字节流,将其静态存储结构转化为方法区的运行时数据结构
2、校验: 文件格式验证,元数据验证,字节码验证,符号引用验证
3、准备: 在方法区中对类的static变量分配内存并设置类变量数据类型默认的初始值,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中
4、解析: 将常量池内的符号引用替换为直接引用的过程
5、初始化: 为类的静态变量赋予正确的初始值(Java代码中被显式地赋予的值

JVM三种类加载器

1、启动类加载器(home) 加载jvm核心类库,如java.lang.*等
2、扩展类加载器(ext), 父加载器为启动类加载器,从jre/lib/ext下加载类库
3、应用程序类加载器(用户classpath路径) 父加载器为扩展类加载器,从环境变量中加载类

双亲委派机制

1、类加载器收到类加载的请求
2、把这个请求委托给父加载器去完成,一直向上委托,直到启动类加载器
3、启动器加载器检查能不能加载,能就加载(结束);否则,抛出异常,通知子加载器进行加载
4、保障类的唯一性和安全性以及保证JDK核心类的优先加载

双亲委派模型有啥作用

保证java基础类在不同的环境还是同一个Class对象,避免出现了自定义类覆盖基础类的情况,导致出现安全问题。还可以避免类的重复加载。

如何打破双亲委派模型?

1、自定义类加载器,继承ClassLoader类重写loadClass方法;
2、SPI

Tomcat是如何打破双亲委派模型:

tomcat有着特殊性,它需要容纳多个应用,需要做到应用级别的隔离,而且需要减少重复性加载,

所以划分为:
/common 容器和应用共享的类信息,
/server容器本身的类信息,
/share应用通用的类信息,
/WEB-INF/lib应用级别的类信息。整体可以分为:boostrapClassLoader->ExtensionClassLoader->ApplicationClassLoader->CommonClassLoader->CatalinaClassLoader(容器本身的加载器)
/ShareClassLoader(共享的)->WebAppClassLoader。虽然第一眼是满足双亲委派模型的,但是不是的,因为双亲委派模型是要先提交给父类装载,而tomcat是优先判断是否是自己负责的文件位置,进行加载的。

SPI:(Service Provider interface)

1、服务提供接口(服务发现机制)
2、通过加载ClassPath下META_INF/services,自动加载文件里所定义的类
3、通过ServiceLoader.load/Service.providers方法通过反射拿到实现类的实例

SPI应用?

1、应用于JDBC获取数据库驱动连接过程就是应用这一机制
2、apache最早提供的common-logging只有接口没有实现…发现日志的提供商通过SPI来具体找到日志提供商实现类

双亲委派机制缺陷

1、双亲委派核心是越基础的类由越上层的加载器进行加载, 基础的类总是作为被调用代码调用的API,无法实现基础类调用用户的代码….
2、JNDI服务它的代码由启动类加载器去加载,但是他需要调独立厂商实现的应用程序,如何解决? 线程上下文件类加载器(Thread Context ClassLoader), JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC

导致fullGC的原因

1、老年代空间不足
2、永久代(方法区)空间不足
3、显式调用system.gc()

堆外内存的优缺点

Ehcache中的一些版本,各种 NIO 框架,Dubbo,Memcache 等中会用到,NIO包下ByteBuffer来创建堆外内存,其实就是不受JVM控制的内存。

相比于堆内内存优势:
减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作。加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了复制这项工作。可以扩展至更大的内存空间。比如超过 1TB 甚至比主存还大的空间。

缺点:
堆外内存难以控制,如果内存泄漏,那么很难排查,通过-XX:MaxDirectMemerySize来指定,当达到阈值的时候,调用system.gc来进行一次full gc 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合 jstat查看内存回收概况,实时查看各个分区的分配回收情况, jmap查看内存栈,查看内存中对象占用大小, jstack查看线程栈,死锁,性能瓶颈

JVM七种垃圾收集器

  • Serial 收集器 复制算法,单线程,新生代)
  • ParNew 收集器(复制算法,多线程,新生代)
  • Parallel Scavenge 收集器(多线程,复制算法,新生代,高吞吐量)
  • Serial Old 收集器(标记-整理算法,老年代)
  • Parallel Old 收集器(标记-整理算法,老年代,注重吞吐量的场景下,jdk8默认采用 Parallel Scavenge + Parallel Old 的组合)
  • CMS 收集器(标记-清除算法,老年代,垃圾回收线程几乎能做到与用户线程同时工作,吞吐量低,内存碎片)以牺牲吞吐量为代价来获得最短回收停顿时间-XX:+UseConcMarkSweepGC jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代) jdk1.9 默认垃圾收集器G1

使用场景:

  • 应用程序对停顿比较敏感 – JVM中,有相对较多存活时间较长的对象(老年代比较大)会更适合使用CMS

定位频繁full GC,堆内存满 oom

第一步:jps获取进程号
第二步:jmap -histo pid | head -20 得知有个对象在不断创建 备注:jmap如果线上服务器堆内存特别大,会卡死需堆转存(一般会说在测试环境压测,导出转存) -XX:+HeapDumpOnOutOfMemoryError或jmap -dumpLformat=b,file=xxx pid 转出文件进行分析 (arthas没有实现jmap命令)heapdump --live /xxx/xx.hprof导出文件

G1垃圾回收器

回收过程
1、young gc(年轻代回收)-- 当年轻代的Eden区用尽时–stw 第一阶段,扫描根。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等 第二阶段,更新RS(Remembered Sets)。处理dirty card queue中的card,更新RS。此阶段完成后,RS可以准确的反映老年代对所在的内存分段中对象的引用 第三阶段,处理RS。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。第四阶段,复制对象。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段 第五阶段,处理引用。处理Soft,Weak,Phantom,Final,JNI Weak 等引用。

2、concrruent marking(老年代并发标记) 当堆内存使用达到一定值(默认45%)时,不需要Stop-The-World,在并发标记前先进行一次young gc

3、混合回收(mixed gc) 并发标记过程结束以后,紧跟着就会开始混合回收过程。混合回收的意思是年轻代和老年代会同时被回收

4、Full GC? Full GC是指上述方式不能正常工作,G1会停止应用程序的执行,使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。要避免Full GC的发生,一旦发生需要进行调整。

什么时候发生Full GC呢?

比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决

尽管G1堆内存仍然是分代的,但是同一个代的内存不再采用连续的内存结构

年轻代分为Eden和Survivor两个区,老年代分为Old和Humongous两个区

新分配的对象会被分配到Eden区的内存分段上

Humongous区用于保存大对象,如果一个对象占用的空间超过内存分段Region的一半;

如果对象的大小超过一个甚至几个分段的大小,则对象会分配在物理连续的多个Humongous分段上。

Humongous对象因为占用内存较大并且连续会被优先回收

为了在回收单个内存分段的时候不必对整个堆内存的对象进行扫描(单个内存分段中的对象可能被其他内存分段中的对象引用)引入了RS数据结构。RS使得G1可以在年轻代回收的时候不必去扫描老年代的对象,从而提高了性能。每一个内存分段都对应一个RS,RS保存了来自其他分段内的对象对于此分段的引用

JVM会对应用程序的每一个引用赋值语句object.field=object进行记录和处理,把引用关系更新到RS中。但是这个RS的更新并不是实时的。G1维护了一个Dirty Card Queue

为什么不在引用赋值语句处直接更新RS呢?

这是为了性能的需要,使用队列性能会好很多。

线程本地分配缓冲区(TLAB:Thread Local Allocation Buffer)?

栈上分配 -> tlab -> 堆上分配 由于堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候需要加锁以进行同步。为了避免加锁,提高性能每一个应用程序的线程会被分配一个TLAB。TLAB中的内存来自于G1年轻代中的内存分段。当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于创建此对象的线程的TLAB中。这样分配会很快,因为TLAB隶属于线程,所以不需要加锁

PLAB:Promotion Thread Local Allocation Buffer

G1会在年轻代回收过程中把Eden区中的对象复制(“提升”)到Survivor区中,Survivor区中的对象复制到Old区中。G1的回收过程是多线程执行的,为了避免多个线程往同一个内存分段进行复制,那么复制的过程也需要加锁。为了避免加锁,G1的每个线程都关联了一个PLAB,这样就不需要进行加锁了

说说你对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虚拟机会对指令重排做出一些规则限制,并不能让所有的指令都随意的改变执行位置,主要有以下几点:

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

工作内存和主内存是什么?

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

数据库

MySQL性能怎么样?

我之前做过测试,在MySQL5.5版本,普通8核16G的机器,一张100万的常规表,顺序写性能在2000tps,读性能的话,如果索引有效,tps在5000左右。

当然,实际性能取决于表结构、SQL语句以及索引过滤等具体情况,需要以测试结果为准。

那要怎么找到MySQL执行慢的语句呢?

可以看慢查询日志,它是MySQL提供的一种日志记录,用来记录在MySQL中响应时间超过阀值的语句,这个阈值通常默认为10s,也可以按需配置。Mysql是默认关闭慢查询日志的,所以需要我们手动开启。

怎么查看它的执行计划?

使用explain命令,它可以获取到MySQL语句的执行计划 ,包括会使用的索引、扫描行数、表如何连接等信息。通过这个命令,我们很容易就看出一条语句是否使用了我们预期的索引,并进行相应的调整。

怎么调整呢?

数据是在不断变化的,同时执行器也有判断失误的情况,MySQL有时候的执行计划,会出乎意料。
这种情况,我们可以使用语句强行指定索引:
select xx from table_name force index (index_name) where …

索引对性能影响大吗?

索引可以说是给数据库的性能插上了翅膀。没有索引查找一个Key时间复杂度需要O(n),有索引就降低到了O(logn)。

在数据少的时候还不明显,多一些数据,比如100万条数据,不走索引需要遍历100万条数据,如果能走索引,只需要查找1000条数据。所以有无索引,性能天差地别。

如果查询压力太大该怎么办?

如果SQL语句已经足够优秀。那么就看请求压力是否符合二八原则,也就是说80%的压力都集中在20%的数据。

如果是,我们可以增加一层缓存,常用的实现是在MySQL前加个Redis缓存。当然,如果实在太大了,那么只能考虑分库分表啦。
在这里插入图片描述

如果是写入压力太大呢?

写缓冲。一般而言可以增加消息队列来缓解。这样做有两个好处,一个是缓解数据库压力,第二个可以控制消费频率。
在这里插入图片描述

如果发现线上Insert导致cpu很高,你会怎么解决?

1.查看是不是请求量突然飙升导致,如果是攻击,则增加对应的防护;
2.查看是否因为数据规模达到一个阈值,导致MySQL的处理能力发生了下降;
3.查看二级索引是否建立过多,这种情况需要去清理非必要索引。

为什么二级索引过多,会导致性能下降?

因为一个二级索引,就相当于一棵B+树。如果我们建了10个索引,这10个索引就相当于10次随机I/O,那粗略估算性能至少也会慢10倍。

分页操作为什么在offset过大的时候会很慢?

以offset 10000, limit 10为例。慢的原因有两点:第一,由于offset是其实就是先找到第几大的数字,因此没法使用树的结构来快速检索。只能使用底层链表顺序找10000个节点,时间复杂度O(n),
在这里插入图片描述
其次,即使这10000个节点是不需要的,MySQL也会通过二级索引上的主键id,去聚簇索引上查一遍数据,这可是10000次随机I/O,自然慢成哈士奇。这和它的优化器有关系,也算是MySQL的一个大坑,时至今日,也没有优化。

那我们怎么优化呢?

一般有两种优化方案:

  • 方案一:绕过去。将分页替换为上一页、下一页。这样子就可以通过和上次返回数据进行比较,搭上树索引的便车。在ios,android端,上下页是很常见的。
  • 方案二:正面刚。有一个概念叫索引覆盖,就是当辅助索引查询的数据只有主键id和辅助索引本身,那么就不必再去查聚簇索引。

如此一来,减少了offset时10000次随机I/O,只有limit出来的10个主键id会去查询聚簇索引,这样只会十次随机I/O,可以大幅提升性能,通常能满足业务要求。

那你说一下这两种方式的优缺点吧。

本质上来说,上下页方案属于产品设计优化。索引覆盖是技术方案优化。

  • 上下页方案能利用树的分支结构实现快速过滤,还能直接通过主键索引查找,性能会高很多。但是它的使用场景受限,而且把主键ID暴露了。
  • 索引覆盖方案维持了分页需求,适用场景更大,性能也提升了不少,但二级索引还是会走下层链表遍历。
    在这里插入图片描述
    如果产品本身,可以接受上下页页面结构,且没用其它过滤条件,可以用方案一。方案二更具有普适性,同时由于合理分表的大小,一般也就500w,二级索引上O(n)的查找损耗,通常也在可接受范围。

Count操作的性能怎么优化?

有几种查询场景通用性优化方案。
第一种,是用Redis缓存来计数。每次服务启动,就将个数加载进Redis,当然,无论是Cache Aside还是Write Through,缓存和存储之间都会存在偏差,可以考虑用一个离线任务来矫正Redis中的个数。这种方案适用于对数据精确度要求不是特别高的场景
在这里插入图片描述
第二种,为count的筛选条件建立联合索引。这样可以实现索引覆盖,在二级索引表中就可以得到结果,不用再回表,回表可是O(n)次随机I/O呢。这种方案适用于有where条件的情况,并且与其它方案不冲突,可共同使用

第三种,可以多维护一个计数表,通过事务的原子性,维持一个准确的计数。这种方案适用于对数据精度高,读多写少场景
在这里插入图片描述

你对MySQL分表有了解吗?

随着业务持续扩张,单表性能一定会达到极限,分表是把一个数据库中的数据表拆分成多张表,通过分布式思路提供可扩展的性能。

那有哪些分表方式?

通常来说有水平分表、垂直分表两种划分方式。

垂直分表将一张表的数据,根据场景切分成多张表,本质是由于前期抽象不足,需要将业务数据进一步拆分。

水平分表则是将一张大表拆成多个结构相同的子表。直观来看表结构都是一样的,可以按某个字段来进行业务划分,也可以按照数据量来划分,划分的规则实际就是按某种维度,预判数据量进行拆分。
在这里插入图片描述

那你做过的项目中,分表逻辑怎么实现的?

分表逻辑一定是在一个公共的,可复用的位置来实现。我之前做的项目,是实现了一个本地依赖包,即将分表逻辑写在公共的代码库里,每个需要调用服务的客户方都集成该公共包,就接入了自动分表的能力。

优点在于简单,不引入新的组件,不增加运维难度。缺点是公共包更改后每个客户端都需要更新。

如果初期没做分表,已有3000W数据,此时要分库分表怎么做?

最复杂的情况,持续比较大的访问流量下,并且要求不停服。我们可以分几个阶段来操作:

    1. 双写读老阶段:通过中间件,对write sql同时进行两次转发,也就是双写,保持新数据一致,同时开始历史数据拷贝。本阶段建议施行一周;
      在这里插入图片描述
  1. 双写双读阶段:采用灰度策略,一部分流量读老表,一部分流量读新表,读新表的部分在一开始,还可以同时多读一次老表数据,进行比对检查,观察无误后,随着时间慢慢切量到新表。本阶段建议施行至少两周;
    在这里插入图片描述
  2. 双写读新阶段:此时基本已经稳定,可以只读新表,为了安全保证,建议还是多双写一段时间,防止有问题遗漏。本阶段建议周期一个月;
    在这里插入图片描述
  3. 写新读新阶段:此时已经完成了分表的迁移,老表数据可以做个冷备
    在这里插入图片描述
    看着很简单的四个步骤,但在业务量已经比较庞大的情况下,操作也是非常复杂的。首先为了安全,每一阶段通常需要比较大的流转时间,也就是说可能已经跨越了多个开发版本。

其次是会带来短期性能损失——无论是双写,还是读检查,都做了额外的数据请求。在同样的请求量下,服务响应时间至少增大了一倍。

数据库三范式

1、第一范式:数据库中的字段具有原子性,不可再分,并且是单一职责
2、第二范式:非主键列不存在对主键的部分依赖 (要求每个表只描述一件事情)
3、第三范式:满足第二范式,并且表中的列不存在对非主键列的传递依赖

数据库主从复制原理

1、主库db的更新事件(update、insert、delete)被写到binlog
2、主库创建一个binlog dump thread线程,把binlog的内容发送到从库
3、从库创建一个I/O线程,读取主库传过来的binlog内容并写入到relay log
4、从库还会创建一个SQL线程,从relay log里面读取内容写入到slave的db

复制方式分类

  • 异步复制(默认) 主库写入binlog日志后即可成功返回客户端,无须等待binlog日志传递给从库的过程,但是一旦主库宕机,就有可能出现丢失数据的情况。
  • 半同步复制(5.5版本之后) :(安装半同步复制插件)确保从库接收完成主库传递过来的binlog内容已经写入到自己的relay log(传送log)后才会通知主库上面的等待线程。如果等待超时,则关闭半同步复制,并自动转换为异步复制模式,直到至少有一台从库通知主库已经接收到binlog信息为止

存储引擎

  • Myiasm是mysql默认的存储引擎,不支持数据库事务,行级锁,外键;插入更新需锁表,效率低,查询速度快,Myisam使用的是非聚集索引
  • innodb 支持事务,底层为B+树实现,适合处理多重并发更新操作,普通select都是快照读,快照读不加锁。InnoDb使用的是聚集索引

聚集索引

  • 聚集索引就是以主键创建的索引
  • 每个表只能有一个聚簇索引,因为一个表中的记录只能以一种物理顺序存放,实际的数据页只能按照一颗 B+ 树进行排序
  • 表记录的排列顺序和与索引的排列顺序一致
  • 聚集索引存储记录是物理上连续存在
  • 聚簇索引主键的插入速度要比非聚簇索引主键的插入速度慢很多
  • 聚簇索引适合排序,非聚簇索引不适合用在排序的场合,因为聚簇索引叶节点本身就是索引和数据按相同顺序放置在一起,索引序即是数据序,数据序即是索引序,所以很快。非聚簇索引叶节点是保留了一个指向数据的指针,索引本身当然是排序的,但是数据并未排序,数据查询的时候需要消耗额外更多的I/O,所以较慢
  • 更新聚集索引列的代价很高,因为会强制innodb将每个被更新的行移动到新的位置

非聚集索引

  • 除了主键以外的索引
  • 聚集索引的叶节点就是数据节点,而非聚簇索引的叶节点仍然是索引节点,并保留一个链接指向对应数据块
  • 聚簇索引适合排序,非聚簇索引不适合用在排序的场合
  • 聚集索引存储记录是物理上连续存在,非聚集索引是逻辑上的连续。

使用聚集索引为什么查询速度会变快?

使用聚簇索引找到包含第一个值的行后,便可以确保包含后续索引值的行在物理相邻

建立聚集索引有什么需要注意的地方吗?

在聚簇索引中不要包含经常修改的列,因为码值修改后,数据行必须移动到新的位置,索引此时会重排,会造成很大的资源浪费

InnoDB 表对主键生成策略是什么样的?

优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id隐藏列作为主键。

MyISAM 与 InnoDB 的区别是什么?

  • InnoDB支持事务,MyISAM不支持
  • InnoDB 支持外键,而 MyISAM 不支持
  • InnoDB是聚集索引,使用B+Tree作为索引结构,数据文件是和索引绑在一起的,必须要有主键。MyISAM是非聚集索引,也是使用B+Tree作为索引结构,索引和数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。
  • InnoDB 不保存表的具体行数。MyISAM 用一个变量保存了整个表的行数
  • Innodb 有 redolog 日志文件,MyISAM 没有
  • Innodb存储文件有frm、ibd,而Myisam是frm、MYD、MYI
    • Innodb:frm是表定义文件,ibd是数据文件
    • Myisam:frm是表定义文件,myd是数据文件,myi是索引文件
  • InnoDB 支持表、行锁,而 MyISAM 支持表级锁
  • InnoDB 必须有唯一索引(主键),如果没有指定的话 InnoDB 会自己生成一个隐藏列Row_id来充当默认主键,MyISAM 可以没有

为什么推荐使用自增 id 作为主键?

1.普通索引的 B+ 树上存放的是主键索引的值,如果该值较大,会导致普通索引的存储空间较大

2.使用自增 id 做主键索引新插入数据只要放在该页的最尾端就可以,直接按照顺序插入,不用刻意维护

3.页分裂容易维护,当插入数据的当前页快满时,会发生页分裂的现象,如果主键索引不为自增 id,那么数据就可能从页的中间插入,页的数据会频繁的变动,导致页分裂维护成本较高

一条查询语句是怎么执行的?

在这里插入图片描述
执行流程大致如下:

  • 1、首先客户端发送请求到服务端,建立连接。
  • 2、服务端先看下查询缓存,对于更新某张表的SQL,该表的所有查询缓存都失效。
  • 3、接着来到解析器,进行语法分析,一些系统关键字校验,校验语法是否合规。
  • 4、然后优化器进行SQL优化,比如怎么选择索引之类,然后生成执行计划。
  • 5、执行引擎去存储引擎查询需要更新的数据。
  • 6、存储引擎判断当前缓冲池中是否存在需要更新的数据,存在就直接返回,否则去从磁盘加载数据。
  • 7、执行引擎调用存储引擎API去更新数据。
  • 8、存储引擎更新数据,同时写入undo_log、redo_log信息。
  • 9、执行引擎写binlog,提交事务,流程结束。

注:1 ~ 6步可以认为是查询SQL执行过程,1 ~ 9则是更新/删除/插入SQL执行过程

Innodb 事务为什么要两阶段提交?

  • 先写 redolog 后写binlog。假设在 redolog 写完,binlog 还没有写完的时候,MySQL 进程异常重启,这时候 binlog 里面就没有记录这个语句。然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
  • 先写 binlog 后写 redolog。如果在 binlog 写完之后 crash,由于 redolog 还没写,崩溃恢复以后这个事务无效,所以这一行c的值是0。但是 binlog 里面已经记录了“把c从0改成1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致

非聚集索引最多可以有多少个?

每个表你最多可以建立249个非聚簇索引。非聚簇索引需要大量的硬盘空间和内存

BTree 与 Hash 索引有什么区别?

  • BTree索引可能需要多次运用折半查找来找到对应的数据块
  • HASH索引是通过HASH函数,计算出HASH值,在表中找出对应的数据
  • 大量不同数据等值精确查询,HASH索引效率通常比B+TREE高
  • HASH索引不支持模糊查询、范围查询和联合索引中的最左匹配规则,而这些Btree索引都支持

数据库索引优缺点

  • 需要查询,排序,分组和联合操作的字段适合建立索引
  • 索引过多,数据更新表越慢,尽量使用字段值不重复比例大的字段作为索引,联合索引比多个独立索引效率高
  • 对数据进行频繁查询进建立索引,如果要频繁更改数据不建议使用索引
  • 当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,降低了数据的维护速度。

索引的底层实现是B+树,为何不采用红黑树,B树?

  • B+Tree非叶子节点只存储键值信息,降低B+Tree的高度,所有叶子节点之间都有一个链指针,数据记录都存放在叶子节点中
  • 红黑树这种结构,h明显要深的多,效率明显比B-Tree差很多
  • B+树也存在劣势,由于键会重复出现,因此会占用更多的空间。但是与带来的性能优势相比,空间劣势往往可以接受,因此B+树的在数据库中的使用比B树更加广泛

为什么采用 B+ 树,而不是 B- 树

B+ 树只在叶子结点储存数据,非叶子结点不存具体数据,只存 key,查询更稳定,增大了广度,而一个节点就是磁盘一个内存页,内存页大小固定,那么相比 B 树,B- 树这些可以存更多的索引结点,宽度更大,树高矮,节点小,拉取一次数据的磁盘 IO 次数少,并且 B+ 树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,效率更高。

WAl 是什么?有什么好处?

WAL 就是 Write-Ahead Logging,其实就是所有的修改都先被写入到日志中,然后再写磁盘,用于保证数据操作的原子性和持久性。

好处:

  • 1.读和写可以完全地并发执行,不会互相阻塞
  • 2.先写入 log 中,磁盘写入从随机写变为顺序写,降低了 client 端的延迟就。并且,由于顺序写入大概率是在一个磁盘块内,这样产生的 io 次数也大大降低
  • 3.写入日志当数据库崩溃的时候可以使用日志来恢复磁盘数据

什么是回表?

在这里插入图片描述
回表就是先通过数据库索引扫描出该索引树中数据所在的行,取到主键 id,再通过主键 id 取出主键索引数中的数据,即基于非主键索引的查询需要多扫描一棵索引树.

什么是事务?其特性是什么?

事务是指是程序中一系列操作必须全部成功完成,有一个失败则全部失败。

特性:

  • 1.原子性(Atomicity):要么全部执行成功,要么全部不执行。
  • 2.一致性(Consistency):事务前后数据的完整性必须保持一致。
  • 3.隔离性(Isolation):隔离性是当多个事务同事触发时,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。
  • 4.持久性(Durability):事务完成之后的改变是永久的。

数据库事务说是如何实现的?

  • 通过预写日志方式实现的,redo和undo机制是数据库实现事务的基础
  • redo日志用来在断电/数据库崩溃等状况发生时重演一次刷数据的过程,把redo日志里的数据刷到数据库里,保证了事务 的持久性(Durability)
  • undo日志是在事务执行失败的时候撤销对数据库的操作,保证了事务的原子性

数据库默认隔离级别,为什么选他作为默认隔离级别?

Oracle

1.默认是Read Committed(读已提交)

2.Oracle只只支持ANSI/ISO SQL定义的Serializable和Read Committed,其实,根据Oracle官方文档给出的介绍,Oracle支持三种隔离级别:Read Committed、Serializable和Read-Only。

Read-Only只读隔离级别类似于可序列化隔离级别,但是只读事务不允许在事务中修改数据,除非用户是SYS。

在Oracle这三种隔离级别中,Serializable和Read-Only显然都是不适合作为默认隔离级别的,那么就只剩Read Committed这个唯一的选择了。

MySQL

1.默认是Repeatable Reads(可重复读)

2.主要是因为MySQL在主从复制的过程是通过bin log 进行数据同步的,而MySQL早期只有statement这种bin log格式,这种格式下,bin log记录的是SQL语句的原文。

当出现事务乱序的时候,就会导致备库在 SQL 回放之后,结果和主库内容不一致。

为了解决这个问题,MySQL默认采用了Repetable Read这种隔离级别,因为在 Repeatable Reads 中,会在更新数据的时候增加记录锁的同时增加间隙锁。可以避免这种情况的发生。

事务的隔离级别?

  • 1.读提交: 即能够读取到那些已经提交的数据
  • 2.读未提交: 即能够读取到没有被提交的数据
  • 3.可重复读: 可重复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据都是一致的
  • 4.可串行化: 最高事务隔离级别,不管多少事务,都是依次按序一个一个执行
隔离级别脏读不可重复读幻读
读未提交read-uncommitted
不可重复读read-committed×
可重复读repeatable-read <默认>××
串行化serializable×××

脏读
脏读指的是读到了其他事务未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。读到了并一定最终存在的数据,这就是脏读

不可重复读
对比可重复读,不可重复读指的是在同一事务内,不同的时刻读到的同一批数据可能是不一样的

幻读
幻读是针对数据插入(INSERT)操作来说的。假设事务A对某些行的内容作了更改,但是还未提交,此时事务B插入了与事务A更改前的记录相同的记录行,并且在事务A提交之前先提交了,而这时,在事务A中查询,会发现好像刚刚的更改对于某些数据未起作用,但其实是事务B刚插入进来的这就叫幻读

binlog 是做什么的?

binlog 是归档日志,属于 Server 层的日志,是一个二进制格式的文件,用于记录用户对数据库更新的SQL语句信息

主要作用 : 主从复制,数据恢复

undolog 是做什么的?

undolog 是 InnoDB 存储引擎的日志,用于保证数据的原子性,保存了事务发生之前的数据的一个版本,也就是说记录的是数据是修改之前的数据,可以用于回滚,同时可以提供多版本并发控制下的读(MVCC)。

主要作用 : 事务回滚,实现多版本控制(MVCC)

relaylog 是做什么的?

relaylog 是中继日志,在主从同步的时候使用到,它是一个中介临时的日志文件,用于存储从master节点同步过来的binlog日志内容。
在这里插入图片描述
master 主节点的 binlog 传到 slave 从节点后,被写入 relay log 里,从节点的 slave sql 线程从 relaylog 里读取日志然后应用到 slave 从节点本地。从服务器 I/O 线程将主服务器的二进制日志读取过来记录到从服务器本地文件,然后 SQL 线程会读取 relay-log 日志的内容并应用到从服务器,从而使从服务器和主服务器的数据保持一致

redolog 是做什么的?

redolog 是 InnoDB 存储引擎所特有的一种日志,用于记录事务操作的变化,记录的是数据修改之后的值,不管事务是否提交都会记录下来。

可以做数据恢复并且提供 crash-safe 能力,当有增删改相关的操作时,会先记录到 Innodb 中,并修改缓存页中的数据,等到 mysql 闲下来的时候才会真正的将 redolog 中的数据写入到磁盘当中

redolog 是怎么记录日志的?

在这里插入图片描述
InnoDB 的 redo log 是固定大小的,比如可以配置为一组4个文件,每个文件的大小是1GB,那么总共就可以记录4GB的操作。从头开始写,写到末尾就又回到开头循环写

所以,如果数据写满了但是还没有来得及将数据真正的刷入磁盘当中,那么就会发生内存抖动现象,从肉眼的角度来观察会发现 mysql 会宕机一会儿,此时就是正在刷盘了。

redolog 和 binlog 的区别是什么?

  • 1.redologInnodb 独有的日志,而binlogserver层的,所有的存储引擎都有使用到
  • 2.redolog 记录了具体的数值,对某个页做了什么修改,binlog 记录的操作内容
  • 3.binlog 大小达到上限或者 flush log 会生成一个新的文件,而 redolog有固定大小只能循环利用
  • 4.binlog 日志没有 crash-safe 的能力,只能用于归档。而 redo log 有 crash-safe 能力。

说一说 mvcc 吧,有什么作用?

MVCC:多版本并发控制,是现代数据库(包括 MySQL、Oracle、PostgreSQL 等)引擎实现中常用的处理读写冲突的手段,目的在于提高数据库高并发场景下的吞吐性能

在 MVCC 协议下,每个读操作会看到一个一致性的快照,这个快照是基于整个库的,并且可以实现非阻塞的读,用于支持读提交和可重复读隔离级别的实现

MVCC 允许数据具有多个版本,这个版本可以是时间戳或者是全局递增的事务 ID,在同一个时间点,不同的事务看到的数据是不同的,这个修改的数据是记录在 undolog 中的。

一条 Sql 语句查询一直慢会是什么原因?

  • 没有用到索引 - 比如函数导致的索引失效,或者本身就没有加索引
  • 表数据量太大 - 建议考虑分库分表吧
  • 优化器选错了索引 - 建议考虑使用force index 强制走索引

一条 Sql 语句查询偶尔慢会是什么原因?

  • 数据库在刷新脏页 - 比如redolog 写满了,内存不够用了释放内存如果是脏页也需要刷,mysql 正常空闲状态刷脏页
  • 没有拿到锁

Mysql 主从之间是怎么同步数据的?

  • 1.master 主库将此次更新的事件类型写入到主库的 binlog 文件
  • 2.master 创建 log dump 线程通知 slave 需要更新数据
  • 3.slave向 master 节点发送请求,将该 binlog 文件内容存到本地的 relaylog 中
  • 4.slave 开启 sql 线程读取 relaylog 中的内容,将其中的内容在本地重新执行一遍,完成主从数据同步
    在这里插入图片描述
    同步策略:
  • 1.全同步复制:主库强制同步日志到从库,等全部从库执行完才返回客户端,性能差
  • 2.半同步复制:主库收到至少一个从库确认就认为操作成功,从库写入日志成功返回ack确认

主从延迟要怎么解决?

  • 1.MySQL 5.6 版本以后,提供了一种并行复制的方式,通过将 SQL 线程转换为多个 work 线程来进行重放
  • 2.提高机器配置(王道)
  • 3.在业务初期就选择合适的分库、分表策略,避免单表单库过大带来额外的复制压力
  • 4.避免长事务
  • 5.避免让数据库进行各种大量运算
  • 6.对于一些对延迟很敏感的业务直接使用主库读

删除表数据后表的大小却没有变动,这是为什么?

在使用 delete 删除数据时,其实对应的数据行并不是真正的删除,是逻辑删除,InnoDB 仅仅是将其标记成可复用的状态,所以表空间不会变小

为什么 VarChar 建议不要超过255?

  • 定义varchar长度小于等于255时,长度标识位需要一个字节(utf-8编码)
  • 当大于255时,长度标识位需要两个字节,并且建立的索引也会失效

分布式式事务怎么实现?

  • 本地消息表
  • 消息事务
  • 二阶段提交
  • 三阶段提交
  • TCC
  • 最大努力通知
  • Seata 框架

分布式事务解决方案: 点击直达

Mysql 中有哪些锁?

以下并不全,主要理解下锁的意义即可

  • 基于锁的属性分类:共享锁、排他锁
  • 基于锁的粒度分类:表锁、行锁、记录锁、间隙锁、临键锁
  • 基于锁的状态分类:意向共享锁、意向排它锁、死锁

为什么不要使用长事务?

  • 1.并发情况下,数据库连接池容易被撑爆
  • 2.容易造成大量的阻塞和锁超时
    • 长事务还占用锁资源,也可能拖垮整个库,
  • 3.执行时间长,容易造成主从延迟
  • 4.回滚所需要的时间比较长
    • 事务越长整个时间段内的事务也就越多
  • 5.undolog 日志越来越大
    • 长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

buffer pool 是做什么的?

buffer pool 是一块内存区域,为了提高数据库的性能,当数据库操作数据的时候,把硬盘上的数据加载到 buffer pool,不直接和硬盘打交道,操作的是 buffer pool 里面的数据,数据库的增删改查都是在 buffer pool 上进行

buffer pool 里面缓存的数据内容也是一个个数据页

其中有三大双向链表:

  • free 链表:用于帮助我们找到空闲的缓存页
  • flush 链表:用于找到脏缓存页,也就是需要刷盘的缓存页
  • lru 链表:用来淘汰不常被访问的缓存页,分为热数据区和冷数据区,冷数据区主要存放那些不常被用到的数据

预读机制:
Buffer Pool 有一项特技叫预读,存储引擎的接口在被 Server 层调用时,会在响应的同时进行预判,将下次可能用到的数据和索引加载到 Buffer Pool

说说你的 Sql 调优思路吧

  • 1.表结构优化
    • 1.1拆分字段
    • 1.2字段类型的选择
    • 1.3字段类型大小的限制
    • 1.4合理的增加冗余字段
    • 1.5新建字段一定要有默认值
  • 2.索引方面
    • 2.1索引字段的选择
    • 2.2利用好mysql支持的索引下推,覆盖索引等功能
    • 2.3唯一索引和普通索引的选择
  • 3.查询语句方面
    • 3.1避免索引失效
    • 3.2合理的书写where条件字段顺序
    • 3.3小表驱动大表
    • 3.4可以使用force index()防止优化器选错索引
  • 4.分库分表

七种事务传播行为

  • Propagation.REQUIRED<默认> 如果当前存在事务,则加入该事务,如果当前不存在事务,则创建一个新的事务。
  • Propagation.SUPPORTS 如果当前存在事务,则加入该事务;如果当前不存在事务,则以非事务的方式继续运行。
  • Propagation.MANDATORY 如果当前存在事务,则加入该事务;如果当前不存在事务,则抛出异常。
  • Propagation.REQUIRES_NEW 重新创建一个新的事务,如果当前存在事务,延缓当前的事务。
  • Propagation.NOT_SUPPORTED 以非事务的方式运行,如果当前存在事务,暂停当前的事务。
  • Propagation.NEVER 以非事务的方式运行,如果当前存在事务,则抛出异常。
  • Propagation.NESTED 如果没有,就新建一个事务;如果有,就在当前事务中嵌套其他事务。

索引

什么是索引?

相信大家小时候学习汉字的时候都会查字典,想想你查字典的步骤,我们是通过汉字的首字母 a~z 一个一个在字典目录中查找,最终找到该字的页数。想想,如果没有目录会怎么样,最差的结果是你有可能翻到字典的最后一页才找到你想要找的字。

索引就相当于我们字典中的目录,可以极大的提高我们在数据库的查询效率。

索引失效的场景有哪些?

以下随便列举几个,不同版本的 mysql 场景不一

  • 1.最左前缀法则(带头索引不能死,中间索引不能断
  • 2.不要在索引上做任何操作(计算、函数、自动/手动类型转换),不然会导致索引失效而转向全表扫描
  • 3.不能继续使用索引中范围条件(bettween、<、>、in等)右边的列,如:
select a from user where c > 5 and b = 4
  • 4.索引字段上使用(!= 或者 < >)判断时,会导致索引失效而转向全表扫描
  • 5.索引字段上使用 is null / is not null 判断时,会导致索引失效而转向全表扫描。
  • 6.索引字段使用like以通配符开头(‘%字符串’)时,会导致索引失效而转向全表扫描,也是最左前缀原则。
  • 7.索引字段是字符串,但查询时不加单引号,会导致索引失效而转向全表扫描
  • 8.索引字段使用 or 时,会导致索引失效而转向全表扫描

什么是索引下推?

如果存在某些被索引的列的判断条件时,MySQL 将这一部分判断条件传递给存储引擎,然后由存储引擎通过判断索引是否符合 MySQL 服务器传递的条件,只有当索引符合条件时才会将数据检索出来返回给 MySQL 服务器

什么是覆盖索引?

覆盖索引(covering index)指一个查询语句的执行只用从索引中就能够取得,不必从数据表中读取,可以减少回表的次数。比如:

select id from t where age = 1;

id 为主键索引,age 为普通索引,age 这个索引树存储的就是逐渐信息,可以直接返回

什么是最左前缀原则?

例如下面这一张表
在这里插入图片描述
按照 name 字段来建立索引的话,采用B+树的结构,大概的索引结构如下
在这里插入图片描述
如果要进行模糊查找,查找name 以“张"开头的所有人的ID,即 sql 语句为

select ID from table where name like '张%'

由于在B+树结构的索引中,索引项是按照索引定义里面出现的字段顺序排序的,索引在查找的时候,可以快速定位到 ID 为 100的张一,然后直接向右遍历所有开头的人,直到条件不满足为止。

也就是说,我们找到第一个满足条件的人之后,直接向右遍历就可以了,由于索引是有序的,所有满足条件的人都会聚集在一起。

而这种定位到最左边,然后向右遍历寻找,就是我们所说的最左前缀原则

为什么用 B+ 树做索引而不用哈希表做索引?

1、哈希表是把索引字段映射成对应的哈希码然后再存放在对应的位置,这样的话,如果我们要进行模糊查找的话,显然哈希表这种结构是不支持的,只能遍历这个表。而B+树则可以通过最左前缀原则快速找到对应的数据。

2、如果要进行范围查找,例如查找ID为100 ~ 400的人,哈希表同样不支持,只能遍历全表。

3、索引字段通过哈希映射成哈希码,如果很多字段都刚好映射到相同值的哈希码的话,那么形成的索引结构将会是一条很长的链表,这样的话,查找的时间就会大大增加。

主键索引和非主键索引有什么区别?

例如下面这个表(其实就是上面的表中增加了一个k字段),且ID是主键。
在这里插入图片描述
主键索引和非主键索引的示意图如下:
在这里插入图片描述
其中R代表一整行的值。

从图中不难看出,主键索引和非主键索引的区别是:非主键索引的叶子节点存放的是主键的值,而主键索引的叶子节点存放的是整行数据,其中非主键索引也被称为二级索引,而主键索引也被称为聚簇索引

根据这两种结构我们来进行下查询,看看他们在查询上有什么区别。
1、如果查询语句是 select * from table where ID = 100,即主键查询的方式,则只需要搜索 ID 这棵 B+树。

2、如果查询语句是 select * from table where k = 1,即非主键的查询方式,则先搜索k索引树,得到ID=100,再到ID索引树搜索一次,这个过程也被称为回表。

普通索引和唯一索引该怎么选择?

查询

  • 当普通索引为条件时查询到数据会一直扫描,直到扫完整张表
  • 当唯一索引为查询条件时,查到该数据会直接返回,不会继续扫表

更新

  • 普通索引会直接将操作更新到 change buffer 中,然后结束
  • 唯一索引需要判断数据是否冲突

所以唯一索引更加适合查询的场景,普通索引更适合插入的场景

为什么建议使用主键自增的索引?

对于这颗主键索引的树
在这里插入图片描述
如果插入 ID = 650 的一行数据,那么直接在最右边插入就可以了
在这里插入图片描述
但是如果插入的是 ID = 350 的一行数据,由于 B+ 树是有序的,那么需要将下面的叶子节点进行移动,腾出位置来插入 ID = 350 的数据,这样就会比较消耗时间,如果刚好 R4 所在的数据页已经满了,需要进行页分裂操作,这样会更加糟糕。

但是,如果主键是自增的,每次插入的 ID 都会比前面的大,那么每次只需要在后面插入就行, 不需要移动位置、分裂等操作,这样可以提高性能。也就是为什么建议使用主键自增的索引。

框架

Spring

Spring框架的七大模块

  • Spring Core:框架的最基础部分,提供 IoC 容器,对 bean 进行管理。
  • Spring Context:继承BeanFactory,提供上下文信息,扩展出JNDI、EJB、电子邮件、国际化等功能。
  • Spring DAO:提供了JDBC的抽象层,还提供了声明性事务管理方法。
  • Spring ORM:提供了JPA、JDO、Hibernate、MyBatis 等ORM映射层.
  • Spring AOP:集成了所有AOP功能
  • Spring Web:提供了基础的 Web 开发的上下文信息,现有的Web框架,如JSF、Tapestry、Structs等,提供了集成
  • Spring Web MVC:提供了 Web 应用的 Model-View-Controller 全功能实现。

Bean定义5种作用域

  • singleton(单例)
  • prototype(原型)
  • request
  • session
  • global session

Spring IOC初始化流程

Resource(资源)定位, 即寻找用户定义的bean资源,由 ResourceLoader通过统一的接口Resource接口来完成 beanDefinition载入, BeanDefinitionReader读取、解析Resource定位的资源成BeanDefinition, 载入到ioc中(通过HashMap进行维护BD) BeanDefinition注册, 即向IOC容器注册这些BeanDefinition, 通过BeanDefinitionRegistery实现

BeanDefinition加载流程

定义BeanDefinitionReader解析xml的document BeanDefinitionDocumentReader解析document成beanDefinition

DI依赖注入流程 (实例化,处理Bean之间的依赖关系)

过程在Ioc初始化后,依赖注入的过程是用户第一次向Ioc容器索要Bean时触发

  • 如果设置lazy-init=true,会在第一次getBean的时候才初始化bean, lazy-init=false,会容器启动的时候直接初始化(singleton bean);
  • 调用BeanFactory.getBean()生成bean的;
  • 生成bean过程运用装饰器模式产生的bean都是beanWrapper(bean的增强);
依赖注入怎么处理bean之间的依赖关系?

其实就是通过在beanDefinition载入时,如果bean有依赖关系,通过占位符来代替,在调用getbean时候,如果遇到占位符,从ioc里获取bean注入到本实例来

Bean的生命周期

  • 实例化Bean:Ioc容器通过获取BeanDefinition对象中的信息进行实例化,实例化对象被包装在BeanWrapper对象中
  • 设置对象属性(DI):通过BeanWrapper提供的设置属性的接口完成属性依赖注入;
  • 注入Aware接口(BeanFactoryAware, 可以用这个方式来获取其它 Bean,ApplicationContextAware):Spring会检测该对象是否实现了xxxAware接口,并将相关的xxxAware实例注入给bean
  • BeanPostProcessor:自定义的处理(分前置处理和后置处理)
  • InitializingBean和init-method:执行我们自己定义的初始化方法
  • 使用
  • destroy:bean的销毁

IOC(控制反转):将对象的创建权,由Spring管理
DI(依赖注入):在Spring创建对象的过程中,把对象依赖的属性注入到类中

Spring的IOC注入方式

构造器注入 setter方法注入, 注解注入, 接口注入

@Transaction注解

底层实现是AOP动态代理

  • 实现是通过Spring代理来实现的。生成当前类的代理类,调用代理类的invoke()方法,在invoke()方法中调用 TransactionInterceptor拦截器的invoke()方法;
  • 非public方式其事务是失效的
  • 自调用也会失效,因为动态代理机制导致
  • 多个方法外层加入try…catch,解决办法是可以在catch里 throw new RuntimeException()来处理

怎么检测是否存在循环依赖?

Bean在创建的时候可以给该Bean打标,如果递归调用回来发现正在创建中的话,即说明了循环依赖了。

Spring如解决Bean循环依赖问题?

Spring中循环依赖场景有:

  • 构造器的循环依赖
  • 属性的循环依赖
    • singletonObjects:第一级缓存,里面放置的是实例化好的单例对象;
    • earlySingletonObjects:第二级缓存,里面存放的是提前曝光的单例对象;
    • singletonFactories:第三级缓存,里面存放的是要被实例化的对象的对象工厂
  • 创建bean的时候Spring首先从一级缓存singletonObjects中获取。如果获取不到,并且对象正在创建中,就再从二级缓存earlySingletonObjects中获取,如果还是获取不到就从三级缓存singletonFactories中取(Bean调用构造函数进行实例化后,即使属性还未填充,就可以通过三级缓存向外提前暴露依赖的引用值(提前曝光),根据对象引用能定位到堆中的对象,其原理是基于Java的引用传递),取到后从三级缓存移动到了二级缓存完全初始化之后将自己放入到一级缓存中供其他使用,
  • 因为加入singletonFactories三级缓存的前提是执行了构造器,所以构造器的循环依赖没法解决。
  • 构造器循环依赖解决办法:在构造函数中使用@Lazy注解延迟加载。在注入依赖时,先注入代理对象,当首次使用时再创建对象说明:一种互斥的关系而非层次递进的关系,故称为三个Map而非三级缓存的缘由完成注入

Spring 中使用了哪些设计模式?

  • 工厂模式:spring中的BeanFactory就是简单工厂模式的体现,根据传入唯一的标识来获得bean对象;
  • 单例模式:提供了全局的访问点BeanFactory;
  • 代理模式:AOP功能的原理就使用代理模式(1、JDK动态代理。2、CGLib字节码生成技术代理。)
  • 装饰器模式:依赖注入就需要使用BeanWrapper;
  • 观察者模式:spring中Observer模式常用的地方是listener的实现。如ApplicationListener。
  • 策略模式:Bean的实例化的时候决定采用何种方式初始化bean实例(反射或者CGLIB动态字节码生成)

AOP 核心概念

1、切面(aspect):类是对物体特征的抽象,切面就是对横切关注点的抽象

2、横切关注点:对哪些方法进行拦截,拦截后怎么处理,这些关注点称之为横切关注点。

3、连接点(joinpoint):被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在Spring 中连接点指的就是被拦截到的方法,实际上连接点还可以是字段或者构造器。

4、切入点(pointcut):对连接点进行拦截的定义

5、通知(advice):所谓通知指的就是指拦截到连接点之后要执行的代码,通知分为前置、后置、异常、最终、环绕通知五类。

6、目标对象:代理的目标对象

7、织入(weave):将切面应用到目标对象并导致代理对象创建的过程

8、引入(introduction):在不修改代码的前提下,引入可以在运行期为类动态地添加方法或字段。

解释一下AOP

传统oop开发代码逻辑自上而下的,这个过程中会产生一些横切性问题,这些问题与我们主业务逻辑关系不大,会散落在代码的各个地方,造成难以维护,aop思想就是把业务逻辑与横切的问题进行分离,达到解耦的目的,提高代码重用性和开发效率

AOP 主要应用场景

  • 记录日志
  • 监控性能
  • 权限控制
  • 事务管理

AOP源码分析

  • @EnableAspectJAutoProxy给容器(beanFactory)中注册一个AnnotationAwareAspectJAutoProxyCreator对象;
  • AnnotationAwareAspectJAutoProxyCreator对目标对象进行代理对象的创建,对象内部,是封装JDK和CGlib两个技术,实现动态代理对象创建的(创建代理对象过程中,会先创建一个代理工厂,获取到所有的增强器(通知方法),将这些增强器和目标类注入代理工厂,再用代理工厂创建对象);
  • 代理对象执行目标方法,得到目标方法的拦截器链,利用拦截器的链式机制,依次进入每一个拦截器进行执行

AOP应用场景

  • 日志记录
  • 事务管理
  • 线程池关闭等

AOP使用哪种动态代理?

  • 当bean的是实现中存在接口或者是Proxy的子类,—jdk动态代理;不存在接口,spring会采用CGLIB来生成代理对象;
  • JDK 动态代理主要涉及到 java.lang.reflect 包中的两个类:Proxy 和 InvocationHandler。
  • Proxy 利用 InvocationHandler(定义横切逻辑) 接口动态创建目标类的代理对象。

Spring MVC

Spring MVC流程

1、用户请求发送给DispatcherServlet,DispatcherServlet调用HandlerMapping处理器映射器;

2、HandlerMapping根据xml或注解找到对应的处理器,生成处理器对象返回给DispatcherServlet;

3、DispatcherServlet会调用相应的HandlerAdapter;

4、HandlerAdapter经过适配调用具体的处理器去处理请求,生成ModelAndView返回给DispatcherServlet

5、DispatcherServlet将ModelAndView传给ViewReslover解析生成View返回给DispatcherServlet;

6、DispatcherServlet根据View进行渲染视图;

->DispatcherServlet->HandlerMapping->Handler ->DispatcherServlet->HandlerAdapter处理handler->ModelAndView ->DispatcherServlet->ModelAndView->ViewReslover->View ->DispatcherServlet->返回给客户

Mybatis

Mybatis原理

  • sqlsessionFactoryBuilder生成sqlsessionFactory(单例)
  • 工厂模式生成sqlsession执行sql以及控制事务
  • Mybatis通过动态代理使Mapper(sql映射器)接口能运行起来即为接口生成代理对象将sql查询到结果映射成pojo

SqlSessionFactory构建过程

  • 解析并读取配置中的xml创建Configuration对象 (单例)
  • 使用Configruation类去创建sqlSessionFactory(builder模式)

Mybatis一级缓存与二级缓存

默认情况下一级缓存是开启的,而且是不能关闭的。

  • 一级缓存是指 SqlSession 级别的缓存 原理:使用的数据结构是一个 map,如果两次中间出现 commit 操作 (修改、添加、删除),本 sqlsession 中的一级缓存区域全部清空
  • 二级缓存是指可以跨 SqlSession 的缓存。是 mapper 级别的缓存;原理:是通过 CacheExecutor 实现的。CacheExecutor其实是 Executor 的代理对象

分布式微服务

SpringBoot启动流程

  • new springApplication对象,利用spi机制加载applicationContextInitializer, applicationLister接口实例(META-INF/spring.factories);
  • 调run方法准备Environment,加载应用上下文(applicationContext),发布事件 很多通过lister实现
  • 创建spring容器, refreshContext() ,实现starter自动化配置,spring.factories文件加载, bean实例化

SpringBoot自动配置的原理
@EnableAutoConfiguration找到META-INF/spring.factories(需要创建的bean在里面)配置文件
读取每个starter中的spring.factories文件

Spring Boot 的核心注解

核心注解是@SpringBootApplication 由以下三种组成

  • @SpringBootConfiguration:组合了 - @Configuration 注解,实现配置文件的功能。
  • @EnableAutoConfiguration:打开自动配置的功能。
  • @ComponentScan:Spring组件扫描。

SpringBoot常用starter

  • spring-boot-starter-web:Web 和 RESTful 应用程序;
  • spring-boot-starter-test:单元测试和集成测试;
  • spring-boot-starter-jdbc:传统的 JDBC;
  • spring-boot-starter-security:使用 SpringSecurity 进行身份验证和授权;
  • spring-boot-starter-data-jpa:带有 Hibernate 的 Spring Data JPA;
  • spring-boot-starter-data-rest:使用 Spring Data REST 公布简单的 REST 服务

Spring Boot 的核心配置文件

1、Application.yml 一般用来定义单个应用级别的
2、Bootstrap.yml(先加载) 系统级别的一些参数配置,这些参数一般是不变的

Zuul与Gateway区别

1、zuul则是netflix公司的项目集成在spring-cloud中使用而已, Gateway是spring-cloud的 一个子项目;

2、zuul不提供异步支持流控等均由hystrix支持, gateway提供了异步支持,提供了抽象负载均衡,提供了抽象流控;理论上gateway则更适合于提高系统吞吐量(但不一定能有更好的性能),最终性能还需要通过严密的压测来决定

3、两者底层实现都是servlet,但是gateway多嵌套了一层webflux框架

4、zuul可用至其他微服务框架中,内部没有实现限流、负载均衡;gateway只能用在springcloud中;

Zuul原理分析

1、请求给zuulservlet处理(HttpServlet子类) zuulservlet中有一个zuulRunner对象,该对象中初始化了RequestContext(存储请求的数据),RequestContext被所有的zuulfilter共享;

2、zuulRunner中有 FilterProcessor(zuulfilter的管理器),其从filterloader 中获取zuulfilter;

3、有了这些filter之后, zuulservelet执行的Pre -> route -> post 类型的过滤器,如果在执行这些过滤器有错误的时候则会执行error类型的过滤器,执行完后把结果返回给客户端.

Gateway原理分析

1、请求到达DispatcherHandler,DispatchHandler在IOC容器初始化时会在容器中实例化HandlerMapping接口
2、用handlerMapping根据请求URL匹配到对应的Route,然后有对应的filter做对应的请求转发最终response返回去

Zookeeper 工作原理

Zookeeper 的核心是原子广播,这个机制保证了各个 server 之间的同步。实现这个机制的协议叫做 Zab 协议。Zab 协议有两种模式,它们分别是恢复模式和广播模式。

Zookeeper(以下简称zoo)与eureka(以下简称eur)区别

  • zookeeper保证cp(一致性)
  • eureka保证ap(可用性)
  • zoo在选举期间注册服务瘫痪,期间不可用
  • eur各个节点平等关系,只要有一台就可保证服务可用,而查询到的数据可能不是最新的,可以很好应对网络故障导致部分节点失联情况
  • zoo有leader和follower角色,eur各个节点平等
    • zoo采用半数存活原则(避免脑裂),eur采用自我保护机制来解决分区问题
  • eur本质是个工程,zoo只是一个进程 ZooKeeper基于CP,不保证高可用,如果zookeeper正在选主,或者Zookeeper集群中半数以上机器不可用,那么将无法获得数据。Eureka基于AP,能保证高可用,即使所有机器都挂了,也能拿到本地缓存的数据。作为注册中心,其实配置是不经常变动的,只有发版(发布新的版本)和机器出故障时会变。对于不经常变动的配置来说,CP是不合适的,而AP在遇到问题时可以用牺牲一致性来保证可用性,既返回旧数据,缓存数据。所以理论上Eureka是更适合做注册中心。而现实环境中大部分项目可能会使用ZooKeeper,那是因为集群不够大,并且基本不会遇到用做注册中心的机器一半以上都挂了的情况。所以实际上也没什么大问题。

Hystrix原理

通过维护一个自己的线程池,当线程池达到阈值的时候,就启动服务降级返回fallback默认值

为什么需要hystrix熔断

防止雪崩,及时释放资源,防止系统发生更多的额级联故障,需要对故障和延迟进行隔离,防止单个依赖关系的失败影响整个应用程序;

微服务优缺点

  • 每个服务高内聚,松耦合,面向接口编程;
  • 服务间通信成本,数据一致性,多服务运维难度增加,http传输效率不如rpc

eureka自我保护机制

  • eur不移除长时间没收到心跳而应该过期的服务
  • 仍然接受新服务注册和查询请求,但是不会同步到其它节点(高可用)
  • 当网络稳定后,当前实例新注册信息会同步到其它节点(最终一致性)

zookeeper实现分布式锁

1、利用节点名称唯一性来实现,加锁时所有客户端一起创建节点,只有一个创建成功者获得锁,解锁时删除节点。

2、利用临时顺序节点实现,加锁时所有客户端都创建临时顺序节点,创建节点序列号最小的获得锁,否则监视比自己序列号次小的节点进行等待

3、方案2比1好处是当zookeeper宕机后,临时顺序节点会自动删除释放锁,不会造成锁等待;

4、方案1会产生惊群效应(当有很多进程在等待锁的时候,在释放锁的时候会有很多进程就过来争夺锁)。

5、由于需要频繁创建和删除节点,性能上不如redis锁

Dubbo流程

1、生产者(Provider)启动,向注册中心(Register)注册
2、消费者(Consumer)订阅,而后注册中心通知消费者
3、消费者从生产者进行消费
4、监控中心(Monitor)统计生产者和消费者

Dubbo推荐使用什么序列化框架,还有哪些?

推荐使用Hessian序列化,还有Duddo、FastJson、Java自带序列化

Dubbo默认使用的是什么通信框架,还有哪些?

默认使用 Netty 框架,也是推荐的选择,另外内容还集成有Mina、Grizzly。

Dubbo有哪几种负载均衡策略,默认是哪种?

1、随机调用<默认>
2、权重轮询
3、最少活跃数
4、一致性Hash

RPC流程

1、消费者调用需要消费的服务,
2、客户端存根将方法、入参等信息序列化发送给服务端存根
3、服务端存根反序列化操作根据解码结果调用本地的服务进行相关处理
4、本地服务执行具体业务逻辑并将处理结果返回给服务端存根
5、服务端存根序列化
6、客户端存根反序列化
7、服务消费方得到最终结果

RPC框架的实现目标PC框架的实现目标是把调用、编码/解码的过程给封装起来,让用户感觉上像调用本地服务一样的调用远程服务

分布式事务

XA方案

有一个事务管理器的概念,负责协调多个数据库(资源管理器)的事务 不适合高并发场景,严重依赖数据库层面,同步阻塞问题;协调者故障则所有参与者会阻塞

TCC方案

严重依赖代码补偿和回滚,一般银行用,和钱相关的支付、交易等相关的场景,我们会用TCC Try,对各个服务的资源做检测,对资源进行锁定或者预留 Confirm,在各个服务中执行实际的操作 Cancel,如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,即执行已操作成功的业务逻辑的回滚操作

可靠消息最终一致性方案

1):本地消息服务 本地消息表其实是国外的 ebay 搞出来的这么一套思想。主动方是认证服务,有个消息异常处理系统,mq,还有消息消费端应用系统,还有采集服务;

  • 在我认证返回数据中如果有发票是已经认证的,在处理认证数据的操作与发送消息在同一个本地事务中,业务执行完,消息数据也同时存在一条待确认的数据;
  • 发送消息给mq,mq发送消息给消息消费端服务,同时存一份消息数据,然后发送给采集服务,进行抵账表更新操作;
  • 采集服务逻辑处理完以后反馈给消息消费端服务,其服务删除消息数据,同时通知认证服务,把消息记录改为已确认成功费状态;
  • 对于异常流程,消息异常处理系统会查询认证服务中过期未确认的消息发送给mq,相当于重试

2):独立消息最终一致性方案:A 主动方应用系统,B消息服务子系统,C消息状态确认子系统,C2消息管理子系统 D 消息恢复子系统,mq ,消息消费端E ,被动系统F

 流程:
A预发送消息给B,然后执行A业务逻辑,B存储预发送消息,A执行完业务逻辑发送业务操作结果给B,B更新预发送消息为确认并发送消息状态同时发送消息给mq,然后被E监听然后发送给F消费掉
C:对预发送消息异常的处理,去查询待确认状态超时的消息,去A中查询进行数据处理,如果A中业务处理成功了,那么C需改消息状态为确认并发送状态,然后发送消息给mq;如果A中业务处理失败了..那么C直接把消息删除即可.
C2 : 查询消息的页面,对消息的可视化,以及批量处理死亡消息;
D:B给mq放入数据如果失败,,通过D去重试,多次重试失败,消息设置为死亡 
E:确保F执行完成,发送消息给B删除消息
优化建议: 
 (1)数据库:如果用redis,持久化要配置成appendfsync always,确保每次新添加消息都能持久化进磁盘
 (2)在被动方应用业务幂等性判断比较麻烦或者比较耗性能情况下,增加消息日志记录表.用于判断之前有无发送过;
最大努力通知性(定期校对)
  • 业务主动方完成业务处理之后,设置时间阶梯型通知规则向业务活动的被动方发送消息,允许消息丢失.
  • 被动方根据定时策略,向主动方查询,恢复丢失的业务消息
  • 被动方的处理结果不影响主动方的处理结果
  • 需增加业务查询,通知服务,校对系统服务的建设成本
  • 适用于对业务最终一致性的时间敏感度低,跨企业的业务通知活动
  • 比如银行通知,商户通知,交易业务平台间商户通知,多次通知,查询校对等
Seata(阿里)

应用层基于SQL解析实现了自动补偿,从而最大程度的降低业务侵入性;将分布式事务中TC(事务协调者)独立部署,负责事务的注册、回滚;通过全局锁实现了写隔离与读隔离。

详情<分布式事务解决方案&分布式事务原理>

缓存

缓存有哪些类型?

缓存是高并发场景下提高热点数据访问性能的一个有效手段,在开发项目时会经常使用到。

缓存的类型分为:本地缓存分布式缓存多级缓存

本地缓存:
本地缓存就是在进程的内存中进行缓存,比如我们的 JVM 堆中,可以用 LRUMap 来实现,也可以使用 Ehcache 这样的工具来实现。

本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。

分布式缓存:
分布式缓存可以很好得解决这个问题。

分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。

多级缓存:
为了平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。

在目前的一线大厂中,这也是最常用的缓存方案,单考单一的缓存方案往往难以撑住很多高并发的场景。

Memcache

特点:

  • 处理请求时使用多线程异步 IO 的方式,可以合理利用 CPU 多核的优势,性能非常优秀;
  • 功能简单,使用内存存储数据;
  • 对缓存的数据可以设置失效期,过期后的数据会被清除;
  • 失效的策略采用延迟失效,就是当再次使用数据时检查是否失效;
  • 当容量存满时,会对缓存中的数据进行剔除,剔除时除了会对过期 key 进行清理,还会按 LRU 策略对数据进行剔除。

另外,使用 MC 有一些限制,这些限制在现在的互联网场景下很致命,成为大家选择Redis、MongoDB的重要原因:

  • key 不能超过 250 个字节;
  • value 不能超过 1M 字节;
  • key 的最大失效时间是 30 天;
  • 只支持 K-V 结构,不提供持久化和主从同步功能。

Redis实现持久化流程

详见: Redis 持久化

Redis 的数据恢复

Redis 的数据恢复有着如下的优先级:

  • 如果只配置 AOF ,重启时加载 AOF 文件恢复数据;
  • 如果同时配置了 RDB 和 AOF ,启动只加载 AOF 文件恢复数据;
  • 如果只配置 RDB,启动将加载 dump 文件恢复数据。
    拷贝 AOF 文件到 Redis 的数据目录,启动 redis-server AOF 的数据恢复过程:Redis 虚拟一个客户端,读取 AOF 文件恢复 Redis 命令和参数,然后执行命令从而恢复数据,这些过程主要在 loadAppendOnlyFile() 中实现。

拷贝 RDB 文件到 Redis 的数据目录,启动 redis-server 即可,因为 RDB 文件和重启前保存的是真实数据而不是命令状态和参数。

Redis 为什么早期版本选择单线程?

官方解释
因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是 机器内存的大小 或者 网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。

简单总结一下

  • 使用单线程模型能带来更好的 可维护性,方便开发和调试;
  • 使用单线程模型也能 并发 的处理客户端的请求;(I/O 多路复用机制)
  • Redis 服务中运行的绝大多数操作的 性能瓶颈都不是 CPU;

字典是如何实现的?Rehash 了解吗?

字典
字典是 Redis 服务器中出现最为频繁的复合型数据结构。除了 hash 结构的数据会用到字典外,整个 Redis 数据库的所有 key 和 value 也组成了一个 全局字典,还有带过期时间的 key 也是一个字典。(存储在 RedisDb 数据结构中)

字典内部结构和 rehash
Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 “数组 + 链表” 的 链地址法 来解决部分 哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点。

字典结构内部包含 两个 hashtable,通常情况下只有一个 hashtable 有值,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式搬迁 (rehash),这时候两个 hashtable 分别存储旧的和新的 hashtable,待搬迁结束后,旧的将被删除,新的 hashtable 取而代之。

扩缩容的条件
正常情况下,当 hash 表中 元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是 原数组大小的 2 倍。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会强制扩容。

当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是元素个数低于数组长度的 10%,缩容不会考虑 Redis 是否在做 bgsave。

为什么要用缓存?为什么使用 Redis?

在日常的 Web 应用对数据库的访问中,读操作的次数远超写操作,比例大概在 1:9 到 3:7,所以需要读的可能性是比写的可能大得多的。当我们使用 SQL 语句去数据库进行读写操作时,数据库就会 去磁盘把对应的数据索引取回来,这是一个相对较慢的过程。

如果我们把数据放在 Redis 中,也就是直接放在内存之中,让服务端直接去读取内存中的数据,那么这样 速度 明显就会快上不少 (高性能),并且会 极大减小数据库的压力 (特别是在高并发情况下)。

但是使用内存进行数据存储开销也是比较大的,限于成本 的原因,一般我们只是使用 Redis 存储一些 常用和主要的数据,比如用户登录的信息等。

一般而言在使用 Redis 进行存储的时候,我们需要从以下几个方面来考虑:

  • 业务数据常用吗?命中率如何? 如果命中率很低,就没有必要写入缓存;
  • 该业务数据是读操作多,还是写操作多? 如果写操作多,频繁需要写入数据库,也没有必要使用缓存;
  • 业务数据大小如何? 如果要存储几百兆字节的文件,会给缓存带来很大的压力,这样也没有必要;

在考虑了这些问题之后,如果觉得有必要使用缓存,那么就使用它!

什么是 RDB 内存快照?

在 Redis 执行指令过程中,内存数据会一直变化。所谓的内存快照,指的就是 Redis 内存中的数据在某一刻的状态数据。

好比时间定格在某一刻,当我们拍照的,通过照片就能把某一刻的瞬间画面完全记录下来。

Redis 跟这个类似,就是把某一刻的数据以文件的形式拍下来,写到磁盘上。这个快照文件叫做 RDB 文件,RDB 就是 Redis DataBase 的缩写。
在这里插入图片描述
在做数据恢复时,直接将 RDB 文件读入内存完成恢复。

在生成 RDB 期间,Redis 可以同时处理写请求么?

可以的,Redis 使用操作系统的多进程写时复制技术 COW(Copy On Write) 来实现快照持久化,保证数据一致性。

Redis 在持久化时会调用 glibc 的函数fork产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。

当主线程执行写指令修改数据的时候,这个数据就会复制一份副本, bgsave 子进程读取这个副本数据写到 RDB 文件。

这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。
在这里插入图片描述

如何实现数据尽可能少丢失又能兼顾性能呢?

重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。

Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。

于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。

你知道 哨兵集群原理么?

哨兵是 Redis 的一种运行模式,它专注于对 Redis 实例(主节点、从节点)运行状态的监控,并能够在主节点发生故障时通过一系列的机制实现选主及主从切换,实现故障转移,确保整个 Redis 系统的可用性。

他的架构图如下:
在这里插入图片描述
Redis 哨兵具备的能力有如下几个:

  • 监控:持续监控 master 、slave 是否处于预期工作状态。
  • 自动切换主库:当 Master 运行故障,哨兵启动自动故障恢复流程:从 slave 中选择一台作为新 master。
  • 通知:让 slave 执行 replicaof ,与新的 master 同步;并且通知客户端与新 master 建立连接。

什么是 Cluster 集群?

Redis 集群是一种分布式数据库方案,集群通过分片(sharding)来进行数据管理(分治思想的一种实践),并提供复制和故障转移功能。

将数据划分为 16384 的 slots,每个节点负责一部分槽位。槽位的信息存储于每个节点中。

它是去中心化的,如图所示,该集群由三个 Redis 节点组成,每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样。
在这里插入图片描述
三个节点相互连接组成一个对等的集群,它们之间通过 Gossip协议相互交互集群信息,最后每个节点都保存着其他节点的 slots 分配情况。

使用 Redis Cluster 集群,主要解决了大数据量存储导致的各种慢问题

哈希槽又是如何映射到 Redis 实例上呢?

  • 根据键值对的 key,使用 CRC16 算法,计算出一个 16 bit 的值;
  • 将 16 bit 的值对 16384 执行取模,得到 0 ~ 16383 的数表示 key 对应的哈希槽。
  • 根据该槽信息定位到对应的实例。

键值对数据、哈希槽、Redis 实例之间的映射关系如下:
在这里插入图片描述

Cluster 如何实现故障转移?

Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变。比如一个节点发现某个节点失联了 (PFail),它会将这条信息向整个集群广播,其它节点也就可以收到这点失联信息。

如果一个节点收到了某个节点失联的数量 (PFail Count) 已经达到了集群的大多数,就可以标记该节点为确定下线状态 (Fail),然后向整个集群广播,强迫其它节点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换。

Redis如何做内存优化?

可以好好利用Hash,list,sorted set,set等集合类型数据,因为通常情况下很多小的Key-Value可以用更紧凑的方式存放到一起。尽可能使用散列表(hashes),散列表(是说散列表里面存储的数少)使用的内存非常小,所以你应该尽可能的将你的数据模型抽象到一个散列表里面。比如你的web系统中有一个用户对象,不要为这个用户的名称,姓氏,邮箱,密码设置单独的key,而是应该把这个用户的所有信息存储到一张散列表里面

Redis线程模型

Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器(file event handler)。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。

  • 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。

Redis单线程为什么执行速度这么快?

Redis采用的是基于内存的采用的是单进程单线程模型的 KV 数据库,由C语言编写,官方提供的数据是可以达到100000+的QPS(每秒内查询次数)。

  • 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。它的,数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
  • 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;
  • 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
  • 使用多路I/O复用模型,非阻塞IO;
  • 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;

Redis是单线程的,如何提高多核CPU的利用率?

可以在同一个服务器部署多个Redis的实例,并把他们当作不同的服务器来使用,在某些时候,无论如何一个服务器是不够的, 所以,如果想使用多个CPU,可以考虑一下分片(shard)。

为什么要做Redis分区?

分区可以让Redis管理更大的内存,Redis将可以使用所有机器的内存。如果没有分区,你最多只能使用一台机器的内存。分区使Redis的计算能力通过简单地增加计算机得到成倍提升,Redis的网络带宽也会随着计算机和网卡的增加而成倍增长。

你知道有哪些Redis分区实现方案?

  • 客户端分区就是在客户端就已经决定数据会被存储到哪个redis节点或者从哪个redis节点读取。大多数客户端已经实现了客户端分区。
  • 代理分区 意味着客户端将请求发送给代理,然后代理决定去哪个节点写数据或者读数据。代理根据分区规则决定请求哪些Redis实例,然后根据Redis的响应结果返回给客户端。redis和memcached的一种代理实现就是Twemproxy
  • 查询路由(Query routing) 的意思是客户端随机地请求任意一个redis实例,然后由Redis将请求转发给正确的Redis节点。Redis Cluster实现了一种混合形式的查询路由,但并不是直接将请求从一个redis节点转发到另一个redis节点,而是在客户端的帮助下直接redirected到正确的redis节点。

Redis分区有什么缺点?

  • 涉及多个key的操作通常不会被支持。例如你不能对两个集合求交集,因为他们可能被存储到不同的Redis实例(实际上这种情况也有办法,但是不能直接使用交集指令)。
  • 同时操作多个key,则不能使用Redis事务.
  • 分区使用的粒度是key,不能使用一个非常长的排序key存储一个数据集(The partitioning granularity is the key, so it is not possible to shard a dataset with a single huge key like a very big sorted set)
  • 当使用分区的时候,数据处理会非常复杂,例如为了备份你必须从不同的Redis实例和主机同时收集RDB / AOF文件。
    分区时动态扩容或缩容可能非常复杂。Redis集群在运行时增加或者删除Redis节点,能做到最大程度对用户透明地数据再平衡,但其他一些客户端分区或者代理分区方法则不支持这种特性。然而,有一种预分片的技术也可以较好的解决这个问题。

啥是上下文切换么?

就好比你看一本英文书,你看到第十页发现有个单词不会读,你加了个书签,然后去查字典,过了一会你又回来继续从书签那里读,ok到目前为止没啥问题。

如果一个人读肯定没啥问题,但是你去查的时候,别的小伙伴好奇你在看啥他就翻了一下你的书,然后溜了,哦豁,你再看的时候就发现书不是你看的那一页了。不知道到这里为止我有没有解释清楚,以及为啥会线程不安全,就是因为你一个人怎么看都没事,但是人多了换来换去的操作一本书数据就乱了。可能我的解释很粗糙,但是道理应该是一样的。

那他是单线程的,我们现在服务器都是多核的,那不是很浪费?

是的他是单线程的,但是,我们可以通过在单机开多个Redis实例嘛。

既然提到了单机会有瓶颈,那你们是怎么解决这个瓶颈的?

我们用到了集群的部署方式也就是Redis cluster,并且是主从同步读写分离,类似Mysql的主从同步,Redis cluster 支撑 N 个 Redis master node,每个master node都可以挂载多个 slave node。

这样整个 Redis 就可以横向扩容了。如果你要支撑更大数据量的缓存,那就横向扩容更多的 master 节点,每个 master 节点就能存放更多的数据了。

他们之间是怎么进行数据交互的?以及Redis是怎么进行持久化的?Redis数据都在内存中,一断电或者重启不就木有了嘛?

是的,持久化的话是Redis高可用中比较重要的一个环节,因为Redis数据在内存的特性,持久化必须得有,我了解到的持久化是有两种方式的。

  • RDB:RDB 持久化机制,是对 Redis 中的数据执行周期性的持久化。
  • AOF:AOF 机制对每条写入命令作为日志,以 append-only 的模式写入一个日志文件中,因为这个模式是只追加的方式,所以没有任何磁盘寻址的开销,所以很快,有点像Mysql中的binlog。

两种方式都可以把Redis内存中的数据持久化到磁盘上,然后再将这些数据备份到别的地方去,RDB更适合做冷备,AOF更适合做热备,比如我杭州的某电商公司有这两个数据,我备份一份到我杭州的节点,再备份一个到上海的,就算发生无法避免的自然灾害,也不会两个地方都一起挂吧,这灾备也就是异地容灾,地球毁灭他没办法。

tip:两种机制全部开启的时候,Redis在重启的时候会默认使用AOF去重新构建数据,因为AOF的数据是比RDB更完整的。

两种持久化机制各自优缺点是啥 ?

RDB

优点:
他会生成多个数据文件,每个数据文件分别都代表了某一时刻Redis里面的数据,这种方式,有没有觉得很适合做冷备,完整的数据运维设置定时任务,定时同步到远端的服务器,比如阿里的云服务,这样一旦线上挂了,你想恢复多少分钟之前的数据,就去远端拷贝一份之前的数据就好了。

RDB对Redis的性能影响非常小,是因为在同步数据的时候他只是fork了一个子进程去做持久化的,而且他在数据恢复的时候速度比AOF来的快。

缺点:
RDB都是快照文件,都是默认五分钟甚至更久的时间才会生成一次,这意味着你这次同步到下次同步这中间五分钟的数据都很可能全部丢失掉。AOF则最多丢一秒的数据,数据完整性上高下立判。

还有就是RDB在生成数据快照的时候,如果文件很大,客户端可能会暂停几毫秒甚至几秒,你公司在做秒杀的时候他刚好在这个时候fork了一个子进程去生成一个大快照,哦豁,出大问题。

AOF

优点:
上面提到了,RDB五分钟一次生成快照,但是AOF是一秒一次去通过一个后台的线程fsync操作,那最多丢这一秒的数据。

AOF在对日志文件进行操作的时候是以append-only的方式去写的,他只是追加的方式写数据,自然就少了很多磁盘寻址的开销了,写入性能惊人,文件也不容易破损。

AOF的日志是通过一个叫非常可读的方式记录的,这样的特性就适合做灾难性数据误删除的紧急恢复了,比如公司的实习生通过flushall清空了所有的数据,只要这个时候后台重写还没发生,马上拷贝一份AOF日志文件,把最后一条flushall命令删了就完事了。

tip:我说的命令你们别真去线上系统操作啊,想试去自己买的服务器上装个Redis试,别到时候来说是我把你服务器搞崩的,Redis官网上的命令都去看看,不要乱试!!!

缺点:
一样的数据,AOF文件比RDB还要大。

AOF开启后,Redis支持写的QPS会比RDB支持写的要低,他不是每秒都要去异步刷新一次日志嘛fsync,当然即使这样性能还是很高,记得ElasticSearch也是这样的,异步刷新缓存区的数据去持久化,为啥这么做呢,不直接来一条怼一条呢,那我会告诉你这样性能可能低到没办法用。

两者怎么选择?

小孩子才做选择,我全都要,单独用RDB会丢失很多数据,单独用AOF,数据恢复没RDB来的快,真出什么时候第一时间用RDB恢复,然后AOF做数据补全,真香!冷备热备一起上,才是互联网时代一个高健壮性系统的王道。

听你提到了高可用,Redis还有其他保证集群高可用的方式么?

哨兵必须用三个实例去保证自己的健壮性的,哨兵+主从并不能保证数据不丢失,但是可以保证集群的高可用。

为啥必须要三个实例呢?先看看两个哨兵会咋样。
在这里插入图片描述
master宕机了 s1和s2两个哨兵只要有一个认为你宕机了就切换了,并且会选举出一个哨兵去执行故障,但是这个时候也需要大多数哨兵都是运行的。

那这样有啥问题呢?M1宕机了,S1没挂那其实是OK的,但是整个机器都挂了呢?哨兵就只剩下S2个裸屌了,没有哨兵去允许故障转移了,虽然另外一个机器上还有R1,但是故障转移就是不执行。

经典的哨兵集群是这样的:
在这里插入图片描述
M1所在的机器挂了,哨兵还有两个,两个人一看他不是挂了嘛,那我们就选举一个出来执行故障转移不就好了。

哨兵组件的主要功能:

  • 集群监控:负责监控 Redis master 和 slave 进程是否正常工作。
  • 消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
  • 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
  • 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。

能说一下主从之间的数据怎么同步的么?

单机QPS是有上限的,而且Redis的特性就是必须支撑读高并发的,一台机器又读又写,肯定是顶得住的啊!但是你让这个master机器去写,数据同步给别的slave机器,他们都拿去读,分发掉大量的请求那是不是好很多,而且扩容的时候还可以轻松实现水平扩容。

回归正题,他们数据怎么同步的呢?

启动一台slave 的时候,他会发送一个psync命令给master ,如果是这个slave第一次连接到master,会触发一个全量复制。master就会启动一个线程,生成RDB快照,还会把新的写请求都缓存在内存中,RDB文件生成后,master会将这个RDB发送给slave的,slave拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存,然后master会把内存里面缓存的那些新命名都发给slave。

数据传输的时候断网了或者服务器挂了怎么办啊?

传输过程中有什么网络问题啥的,会自动重连的,并且连接之后会把缺少的数据补上的。

大家需要记得的就是,RDB快照的数据生成的时候,缓存区也必须同时开始接受新请求,不然你旧的数据过去了,你在同步期间的增量数据咋办?是吧?

说一下他的内存淘汰机制么,来手写一下LRU代码?

Redis的过期策略,是有定期删除+惰性删除两种。

定期好理解,默认100ms就随机抽一些设置了过期时间的key,去检查是否过期,过期了就删了。

为啥不扫描全部设置了过期时间的key呢?

假如Redis里面所有的key都有过期时间,都扫描一遍?那太恐怖了,而且线上基本上也都是会设置一定的过期时间的。全扫描跟你去查数据库不带where条件不走索引全表扫描一样,100ms一次,Redis累都累死了。

如果一直没随机到很多key,里面不就存在大量的无效key了?

惰性删除,见名知意,惰性嘛,我不主动删,我懒,我等你来查询了我看看你过期没,过期就删了还不给你返回,没过期该怎么样就怎么样。

定期没删,我也没查询,那可咋整?

内存淘汰机制

官网给到的内存淘汰机制是以下几个:

  • noeviction:返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但DEL和几个例外)
  • allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。
  • volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
  • allkeys-random: 回收随机的键使得新添加的数据有空间存放。
  • volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。
  • volatile-ttl: 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放。

如果没有键满足回收的前提条件的话,策略volatile-lru, volatile-random以及volatile-ttl就和noeviction 差不多了。

tip:Redis为什么不使用真实的LRU实现是因为这需要太多的内存。不过近似的LRU算法对于应用而言应该是等价的。使用真实的LRU算法与近似的算法可以通过下面的图像对比。

LRU只是个预测键将如何被访问的模型。另外,如果你的数据访问模式非常接近幂定律,大部分的访问将集中在一个键的集合中,LRU的近似算法将处理得很好。

LinkedHashMap 中的 Lru算法

final Map<Long,TimeoutInfoHolder> timeoutInfoHandlers 
	Collections.synchronizedMap(new LinkedHashMap<Long, TimeoutInfoHolder>(100.75F, true) (
		@Override
		protected boolean removeEldestEntry(Map.Entry eldest)[
			return size()> 100:
	}
});

当容量超过100时,开始执行LRU策略:将最近最少未使用的 TimeoutInfoHolder 对象 evict 掉。

真实面试中会让你写LUR算法,你可别搞原始的那个,那真TM多,写不完的,找一个数据结构实现下Java版本的LRU还是比较容易的,知道啥原理就好了。

Redis 优缺点

优点
  • 读写性能优异, Redis能读的速度是 110000 次/s,写的速度是 81000 次/s。
  • 支持数据持久化,支持 AOF 和 RDB 两种持久化方式。
  • 支持事务,Redis 的所有操作都是原子性的,同时 Redis 还支持对几个操作合并后的原子性执行。
  • 数据结构丰富,除了支持 string 类型的 value 外还支持 hash、set、zset、list 等数据结构。
  • 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。
缺点
  • 数据库 容量受到物理内存的限制,不能用作海量数据的高性能读写,因此 Redis 适合的场景主要局限在较小数据量的高性能操作和运算上
  • Redis 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的 IP 才能恢复。
  • 主机宕机,宕机前有部分数据未能及时同步到从机,切换 IP 后还会引入数据不一致的问题,降低了 系统的可用性。
  • Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。

Redis数据结构

基本数据类型

String:
String 类型是 Redis 中最常使用的类型,内部的实现是通过 SDS(Simple Dynamic String )来存储的。SDS 类似于 Java 中的 ArrayList,可以通过预分配冗余空间的方式来减少内存的频繁分配。

应用场景:共享用户Session,计数器

Hash:
类似 Map 的一种结构,这个一般就是可以将结构化的数据,比如一个对象(前提是这个对象没嵌套其他的对象)给缓存在 Redis 里,然后每次读写缓存的时候,可以就操作 Hash 里的某个字段。

  • Reids的Hash采用链地址法来处理冲突,然后它没有使用红黑树优化。
  • 哈希表节点采用单链表结构。
  • rehash优化 (采用分而治之的思想,将庞大的迁移工作量划分到每一次CURD中,避免了服务繁忙)

应用场景:保存结构体信息可部分获取不用序列化所有字段

List:
列表(list)用于存储多个有序的字符串,可以充当栈和队列的角色。

list的实现为一个双向链表,即可以支持反向查找和遍历,当然一般有序会采用数组或者是双向链表,其中双向链表由于有前后指针实际上会很浪费内存。

应用场景:twitter的关注列表,粉丝列表,消息队列

Set:
内部实现是一个 value为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员 是否在集合内的原因。

应用场景:去重的场景,交集(sinter)、并集(sunion)、差集(sdiff)、实现如共同关注、共同喜好、二度好友

Zset:
内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。跳表:每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的

去重但可以排序,写进去的时候给一个分数,自动根据分数排序。

应用场景:实现延时队列、 排行榜

高级数据类型

Bitmap :
位图是支持按 bit 位来存储信息,可以用来实现 布隆过滤器(BloomFilter);

HyperLogLog:
提供不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计 UV;

Geospatial:
可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。

pub/sub:
功能是订阅发布功能,可以用作简单的消息队列。

Pipeline:
可以批量执行一组指令,一次性返回全部结果,可以减少频繁的请求应答。

如何解决 Redis 的并发竞争 Key 问题

所谓 Redis 的并发竞争 Key 的问题也就是多个系统同时对一个 key 进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同!

推荐一种方案:分布式锁(zookeeper 和 redis 都可以实现分布式锁)。(如果不存在 Redis 的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能)

基于zookeeper临时有序节点可以实现的分布式锁。大致思想为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。完成业务流程后,删除对应的子节点释放锁。

分布式Redis是前期做还是后期规模上来了再做好?为什么?

既然Redis是如此的轻量(单实例只使用1M内存),为防止以后的扩容,最好的办法就是一开始就启动较多实例。即便你只有一台服务器,你也可以一开始就让Redis以分布式的方式运行,使用分区,在同一台服务器上启动多个实例。

一开始就多设置几个Redis实例,例如32或者64个实例,对大多数用户来说这操作起来可能比较麻烦,但是从长久来看做这点牺牲是值得的。

这样的话,当你的数据不断增长,需要更多的Redis服务器时,你需要做的就是仅仅将Redis实例从一台服务迁移到另外一台服务器而已(而不用考虑重新分区的问题)。一旦你添加了另一台服务器,你需要将一半的Redis实例从第一台机器迁移到第二台机器。

什么是 RedLock

Redis 官方站提出了一种权威的基于 Redis 实现分布式锁的方式名叫 Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:

  • 安全特性:互斥访问,即永远只有一个 client 能拿到锁
  • 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区
  • 容错性:只要大部分 Redis 节点存活就可以正常提供服务

Redis事务执行流程

  • Multi开启事务
  • Exec执行事务块内命令
  • Discard 取消事务
  • Watch 监视一个或多个key,如果事务执行前key被改动,事务将打断

Redis事务支持隔离性吗

Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。

Redis事务保证原子性吗,支持回滚吗

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

Redis事务其他实现

  • 基于Lua脚本,Redis可以保证脚本内的命令一次性、按顺序地执行,其同时也不提供事务运行错误的回滚,执行过程中如果部分命令运行错误,剩下的命令还是会继续运行完
  • 基于中间标记变量,通过另外的标记变量来标识事务是否执行完成,读取数据时先读取该标记变量判断是否事务执行完成。但这样会需要额外写代码实现,比较繁琐

Redis事务的实现特征

1、所有命令都将会被串行化的顺序执行,事务执行期间,Redis不会再为其它客户端的请求提供任何服务,从而保证了事物中的所有命令被原子的执行

2、Redis事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行

3、在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行

4、当使用Append-Only模式时,Redis会通过调用系统函数write将该事务内的所有写操作在本次调用中全部写入磁盘。

然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。

Redis服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。此时,我们就要充分利用Redis工具包中提供的redis-check-aof工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动Redis服务器了

主从同步了解吗?

在这里插入图片描述
主从复制,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 主节点(master),后者称为 从节点(slave)。且数据的复制是 单向 的,只能由主节点到从节点。Redis 主从复制支持 主从同步 和 从从同步 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。

主从复制主要的作用

  • 数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
  • 故障恢复: 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 (实际上是一种服务的冗余)。
  • 负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 (即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
  • 高可用基石: 除了上述作用以外,主从复制还是哨兵和集群能够实施的 基础,因此说主从复制是 Redis 高可用的基础。

实现原理
在这里插入图片描述

哨兵模式了解吗?

在这里插入图片描述
上图 展示了一个典型的哨兵架构图,它由两部分组成,哨兵节点和数据节点

  • 哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据;
  • 数据节点: 主节点和从节点都是数据节点;

在复制的基础上,哨兵实现了 自动化的故障恢复 功能,下方是官方对于哨兵功能的描述:

  • 监控(Monitoring): 哨兵会不断地检查主节点和从节点是否运作正常。
  • 自动故障转移(Automatic failover): 当 主节点 不能正常工作时,哨兵会开始 自动故障转移操作,它会将失效主节点的其中一个 从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
  • 配置提供者(Configuration provider): 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
  • 通知(Notification): 哨兵可以将故障转移的结果发送给客户端。

其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移。而配置提供者和通知功能,则需要在与客户端的交互中才能体现。

新的主服务器是怎样被挑选出来的?

故障转移操作的第一步 要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送 slaveof no one 命令,将这个从服务器转换为主服务器。但是这个从服务器是怎么样被挑选出来的呢?

简单来说 Sentinel 使用以下规则来选择新的主服务器:

  • 在失效主服务器属下的从服务器当中, 那些被标记为主观下线、已断线、或者最后一次回复 PING 命令的时间大于五秒钟的从服务器都会被 淘汰。
  • 在失效主服务器属下的从服务器当中, 那些与失效主服务器连接断开的时长超过 down-after 选项指定的时长十倍的从服务器都会被 淘汰。
  • 在经历了以上两轮淘汰之后 剩下来的从服务器中, 选出 复制偏移量(replication offset)最大 的那个 从服务器 作为新的主服务器;如果复制偏移量不可用,或者从服务器的复制偏移量相同,那么 带有最小运行 ID 的那个从服务器成为新的主服务器

Redis 集群使用过吗?原理?

在这里插入图片描述
上图 展示了 Redis Cluster 典型的架构图,集群中的每一个 Redis 节点都互相两两相连,客户端任意 直连 到集群中的 任意一台,就可以对其他 Redis 节点进行 读写 的操作。
在这里插入图片描述
Redis 集群中内置了 16384 个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 集群的配置信息,当客户端具体对某一个 key 值进行操作时,会计算出它的一个 Hash 值,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,Redis 会根据节点数量 大致均等 的将哈希槽映射到不同的节点。

再结合集群的配置信息就能够知道这个 key 值应该存储在哪一个具体的 Redis 节点中,如果不属于自己管,那么就会使用一个特殊的 MOVED 命令来进行一个跳转,告诉客户端去连接这个节点以获取数据

Get X
-MOVED 3999 127.0.0.1:6381

MOVED 指令第一个参数 3999 是 key 对应的槽位编号,后面是目标节点地址,MOVED 命令前面有一个减号,表示这是一个错误的消息。客户端在收到 MOVED 指令后,就立即纠正本地的 槽位映射表,那么下一次再访问 key 时就能够到正确的地方去获取了。

集群的主要作用

  • 数据分区: 数据分区 (或称数据分片) 是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加;另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。Redis 单机内存大小受限问题,在介绍持久化和主从复制时都有提及,例如,如果单机内存太大,bgsave 和 bgrewriteaof 的 fork 操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出……
  • 高可用: 集群支持主从复制和主节点的 自动故障转移 (与哨兵类似),当任一节点发生故障时,集群仍然可以对外提供服务。

集群中数据如何分区?

方案一:哈希值 % 节点数
哈希取余分区思路非常简单:计算 key 的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。

不过该方案最大的问题是,当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要重新计算映射关系,引发大规模数据迁移。

方案二:一致性哈希分区
一致性哈希算法将 整个哈希值空间 组织成一个虚拟的圆环,范围是 [0 - 232 - 1],对于每一个数据,根据 key 计算 hash 值,确数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器
在这里插入图片描述
与哈希取余分区相比,一致性哈希分区将增减节点的影响限制在相邻节点。以上图为例,如果在 node1 和 node2 之间增加 node5,则只有 node2 中的一部分数据会迁移到 node5;如果去掉 node2,则原 node2 中的数据只会迁移到 node4 中,只有 node4 会受影响。

一致性哈希分区的主要问题在于,当 节点数量较少 时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。还是以上图为例,如果去掉 node2,node4 中的数据由总数据的 1/4 左右变为 1/2 左右,与其他节点相比负载过高。

方案三:带有虚拟节点的一致性哈希分区(Redis 采用方案)
该方案在 一致性哈希分区的基础上,引入了 虚拟节点 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 槽(slot)。槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。

在使用了槽的一致性哈希分区中,槽是数据管理和迁移的基本单位。槽 解耦 了 数据和实际节点 之间的关系,增加或删除节点对系统的影响很小。仍以上图为例,系统中有 4 个实际节点,假设为其分配 16 个槽(0-15);

槽 0-3 位于 node1;4-7 位于 node2;以此类推…
如果此时删除 node2,只需要将槽 4-7 重新分配即可,例如槽 4-5 分配给 node1,槽 6 分配给 node3,槽 7 分配给 node4;可以看出删除 node2 后,数据在其他节点的分布仍然较为均衡。

节点之间的通信机制了解吗?

集群的建立离不开节点之间的通信,例如我们启动六个集群节点之后通过 redis-cli 命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 CLUSTER MEET 命令发送 MEET 消息完成的。

两个端口
哨兵系统 中,节点分为 数据节点哨兵节点, 前者存储数据,后者实现额外的控制功能。在 集群 中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个 TCP 端口:

  • 普通端口: 即我们指定端口 (7000等)。普通端口主要用于为客户端提供服务 (与单机节点类似);但在节点间数据迁移时也会使用。
  • 集群端口: 端口号是普通端口 + 10000 (10000是固定值,无法改变),如 7000 节点的集群端口为 17000。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。

Gossip 协议
节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。

  • 广播是指向集群内所有节点发送消息。
    • 优点: 集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),
    • 缺点: 每条消息都要发送给所有节点,CPU、带宽等消耗较大。
  • Gossip 协议的特点是:在节点数量有限的网络中,每个节点都 “随机” 的与部分节点通信 (并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。
    • 优点: 有负载 (比广播) 低、去中心化、容错性高 (因为通信有冗余) 等;
    • 缺点: 主要是集群的收敛速度慢。

消息类型
集群中的节点采用 固定频率(每秒10次) 的 定时任务 进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。

节点间发送的消息主要分为 5 种:meet 消息、ping 消息、pong 消息、fail 消息、publish 消息。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的:

  • MEET 消息: 在节点握手阶段,当节点收到客户端的 CLUSTER MEET 命令时,会向新加入的节点发送 MEET 消息,请求新节点加入到当前集群;新节点收到 MEET 消息后会回复一个 PONG 消息。
  • PING 消息: 集群里每个节点每秒钟会选择部分节点发送 PING 消息,接收者收到消息后会回复一个 PONG 消息。PING 消息的内容是自身节点和部分其他节点的状态信息,作用是彼此交换信息,以及检测节点是否在线。PING 消息使用 Gossip 协议发送,接收节点的选择兼顾了收敛速度和带宽成本,具体规则如下:(1)随机找 5 个节点,在其中选择最久没有通信的 1 个节点;(2)扫描节点列表,选择最近一次收到 PONG 消息时间大于 cluster_node_timeout / 2 的所有节点,防止这些节点长时间未更新。
  • PONG消息: PONG 消息封装了自身状态数据。可以分为两种:第一种 是在接到 MEET/PING 消息后回复的 PONG 消息;第二种 是指节点向集群广播 PONG 消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播 PONG 消息。
  • FAIL 消息: 当一个主节点判断另一个主节点进入 FAIL 状态时,会向集群广播这一 FAIL 消息;接收节点会将这一 FAIL 消息保存起来,便于后续的判断。
    PUBLISH 消息: 节点收到 PUBLISH 命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该 PUBLISH 命令。

假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如何将它们全部找出来?

使用 keys 指令可以扫出指定模式的 key 列表。但是要注意 keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan 指令,scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys 指令长。

Redis的同步机制

  • slave第一次启动时,连接Master,发送PSYNC命令,
  • master会执行bgsave命令来生成rdb文件,期间的所有写命令将被写入缓冲区。
  • master bgsave执行完毕,向slave发送rdb文件
  • slave收到rdb文件,丢弃所有旧数据,开始载入rdb文件
  • rdb文件同步结束之后,slave执行从master缓冲区发送过来的所以写命令。
  • 此后 master 每执行一个写命令,就向slave发送相同的写命令。
  • 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态

Redis集群模式性能优化

  • Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件
  • 如果数据比较重要,某个Slave开启AOF备份数据,策略设置为每秒同步一次
  • 为了主从复制的速度和连接的稳定性,Master和Slave最好在同一个局域网内
  • 尽量避免在压力很大的主库上增加从库
  • 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3…这样的结构方便解决单点故障问题,实现Slave对Master的替换。如果Master挂了,可以立刻启用Slave1做Master,其他不变。

Redis集群方案

  • 官方cluster方案
  • twemproxy
    • 代理方案twemproxy是一个单点,很容易对其造成很大的压力,所以通常会结合keepalived来实twemproy的高可用
  • codis 基于客户端来进行分片

集群不可用场景

  • master挂掉,且当前master没有slave
  • 集群超过半数以上master挂掉,无论是否有slave集群进入fail状态

Redis 最适合的场景

  • 缓存:减轻 MySQL 的查询压力,提升系统性能;
  • 排行榜:利用 Redis 的 SortSet(有序集合)实现;
  • 计算器/限速器:利用 Redis 中原子性的自增操作,我们可以统计类似用户点赞数、用户访问数等。这类操作如果用 MySQL,频繁的读写会带来相当大的压力;限速器比较典型的使用场景是限制某个用户访问某个 API 的频率,常用的有抢购时,防止用户疯狂点击带来不必要的压力;
  • 好友关系:利用集合的一些命令,比如求交集、并集、差集等。可以方便解决一些共同好友、共同爱好之类的功能;
  • 消息队列:除了 Redis 自身的发布/订阅模式,我们也可以利用 List 来实现一个队列机制,比如:到货通知、邮件发送之类的需求,不需要高可靠,但是会带来非常大的 DB 压力,完全可以用 List 来完成异步解耦;
  • Session 共享:Session 是保存在服务器的文件中,如果是集群服务,同一个用户过来可能落在不同机器上,这就会导致用户频繁登陆;采用 Redis 保存 Session 后,无论用户落在那台机器上都能够获取到对应的 Session 信息。

Redis 不适合的场景

数据量太大、数据访问频率非常低的业务都不适合使用 Redis,数据太大会增加成本,访问频率太低,保存在内存中纯属浪费资源。

Redis 有哪些常见的功能?

  • 数据缓存功能
  • 分布式锁的功能
  • 支持数据持久化
  • 支持事务
  • 支持消息队列

Redis 怎么实现分布式锁?

Redis 为单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对 Redis 的连接并不存在竞争关系。Redis 中可以使用 SETNX 命令实现分布式锁。一般使用 setnx(set if not exists) 指令,只允许被一个程序占有,使用完调用 del 释放锁。

Redis 内存淘汰策略有哪些?

  • volatile-lru:从已设置过期时间的数据集(server. db[i]. expires)中挑选最近最少使用的数据淘汰;
  • volatile-ttl:从已设置过期时间的数据集(server. db[i]. expires)中挑选将要过期的数据淘汰。
  • volatile-random:从已设置过期时间的数据集(server. db[i]. expires)中任意选择数据淘汰。
  • allkeys-lru:从数据集(server. db[i]. dict)中挑选最近最少使用的数据淘汰。
  • allkeys-random:从数据集(server. db[i]. dict)中任意选择数据淘汰。
  • no-enviction(驱逐):禁止驱逐数据。

Redis 常见性能问题和解决方案?

  • Master 最好不要做任何持久化工作,如 RDB 内存快照和 AOF 日志文件。如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次;
  • 为了主从复制的速度和连接的稳定性, Master 和 Slave 最好在同一个局域网内;
  • 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3…

Redis过期key删除策略

  • 惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
  • 定时过期(不常用):每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
  • 定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。

Redis中同时使用了惰性过期和定期过期两种过期策略。

我们知道通过expire来设置key 的过期时间,那么对过期的数据怎么处理呢?我们知道通过expire来设置key 的过期时间,那么对过期的数据怎么处理呢?

除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),我们还可以根据具体的业务需求进行自定义的缓存淘汰,常见的策略有两种:

  • 定时去清理过期的缓存;
  • 当有用户请求过来时,判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。

两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!具体用哪种方案,可以根据自身应用场景来权衡。

Hash 冲突怎么办?

Redis 通过链式哈希解决冲突:也就是同一个桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差可能,所以 Redis 为了追求快,使用了两个全局哈希表。用于 rehash 操作,增加现有的哈希桶数量,减少哈希冲突。

开始默认使用 hash 表 1 保存键值对数据,hash 表 2 此刻没有分配空间。当数据越来越多触发 rehash 操作,则执行以下操作:

  • hash 表 2 分配更大的空间;
  • hash 表 1 的数据重新映射拷贝到 hash 表 2 中;
  • 释放 hash 表 1 的空间。

值得注意的是,将 hash 表 1 的数据重新映射到 hash 表 2 的过程中并不是一次性的,这样会造成 Redis 阻塞,无法提供服务。

而是采用了渐进式 rehash,每次处理客户端请求的时候,先从 hash 表 1 中第一个索引开始,将这个位置的 所有数据拷贝到 hash 表 2 中,就这样将 rehash 分散到多次请求过程中,避免耗时阻塞。

缓存常见问题

缓存更新方式

这是决定在使用缓存时就该考虑的问题。

缓存的数据在数据源发生变更时需要对缓存进行更新,数据源可能是 DB,也可能是远程服务。更新的方式可以是主动更新。数据源是 DB 时,可以在更新完 DB 后就直接更新缓存。

当数据源不是 DB 而是其他远程服务,可能无法及时主动感知数据变更,这种情况下一般会选择对缓存数据设置失效期,也就是数据不一致的最大容忍时间。

这种场景下,可以选择失效更新,key 不存在或失效时先请求数据源获取最新数据,然后再次缓存,并更新失效期。

但这样做有个问题,如果依赖的远程服务在更新时出现异常,则会导致数据不可用。改进的办法是异步更新,就是当失效时先不清除数据,继续使用旧的数据,然后由异步线程去执行更新任务。这样就避免了失效瞬间的空窗期。另外还有一种纯异步更新方式,定时对数据进行分批更新。实际使用时可以根据业务场景选择更新方式。

数据不一致

只要使用缓存,就要考虑如何面对这个问题。缓存不一致产生的原因一般是主动更新失败,例如更新 DB 后,更新 Redis 因为网络原因请求超时;或者是异步更新失败导致。

解决方案
如果服务对耗时不是特别敏感可以增加重试;如果服务对耗时敏感可以通过异步补偿任务来处理失败的更新,或者短期的数据不一致不会影响业务,那么只要下次更新时可以成功,能保证最终一致性就可以。

缓存穿透

在这里插入图片描述
解决方案:

  • 对不存在的用户,在缓存中保存一个空对象进行标记,防止相同 ID 再次访问 DB。不过有时这个方法并不能很好解决问题,可能导致缓存中存储大量无用数据。
  • 使用 BloomFilter 过滤器,BloomFilter 的特点是存在性检测,如果 BloomFilter 中不存在,那么数据一定不存在;如果 BloomFilter 中存在,实际数据也有可能会不存在。非常适合解决这类的问题。
缓存击穿

缓存击穿,就是某个热点数据失效时,大量针对这个数据的请求会穿透到数据源。

解决方案:

  • 可以使用互斥锁更新,保证同一个进程中针对同一个数据不会并发请求到 DB,减小 DB 压力。
  • 使用随机退避方式,失效时随机 sleep 一个很短的时间,再次查询,如果失败再执行更新。
  • 针对多个热点 key 同时失效的问题,可以在缓存时使用固定时间加上一个小的随机数,避免大量热点 key 同一时刻失效。
缓存雪崩

在这里插入图片描述
解决方案:

  • 使用快速失败的熔断策略,减少 DB 瞬间压力;
  • 使用主从模式和集群模式来尽量保证缓存服务的高可用。

实际场景中,这两种方法会结合使用。

Redis阻塞原因

  • 数据结构使用不合理bigkey
  • CPU饱和
  • 持久化阻塞,rdb fork子线程,aof每秒刷盘等

hot key出现造成集群访问量倾斜解决办法

  • 使用本地缓存
  • 利用分片算法的特性,对key进行打散处理(给hot key加上前缀或者后缀,把一个hotkey 的数量变成 redis 实例个数N的倍数M,从而由访问一个 redis key 变成访问 N * M 个redis key)

Redis分布式锁

2.6版本以后lua脚本保证setnx跟setex进行原子性(setnx之后,未setex,服务挂了,锁不释放) a获取锁,超过过期时间,自动释放锁,b获取到锁执行,a代码执行完remove锁,a和b是一样的key,导致a释放了b的锁。

解决办法:remove之前判断value(高并发下value可能被修改,应该用lua来保证原子性)

Redis如何做持久化

bgsave做镜像全量持久化,aof做增量持久化。因为bgsave会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据 ,所以需要aof来配合使用。在redis实例重启时,会使用bgsave持久化文件重新构建内存,再使用aof重放近期的操作指令来 实现完整恢复重启之前的状态。

那如果突然机器掉电会怎样?

取决于aof日志sync属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的要求下每次都sync是不现实的,一般都使用定时sync,比如1s 1次,这个时候最多就会丢失1s的数据.

Redis锁续租问题?

  • 基于redis的redission分布式可重入锁RLock,以及配合java集合中lock;
  • Redission 内部提供了一个监控锁的看门狗,不断延长锁的有效期,默认检查锁的超时时间是30秒
  • 此方案的问题:如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master ,slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。

接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁

解决办法:只需要将新的redis实例,在一个TTL时间内,对客户端不可用即可,在这个时间内,所有客户端锁将被失效或者自动释放.

bgsave的原理是什么?

fork和cow。fork是指redis通过创建子进程来进行bgsave操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写进的页面数据会逐渐和子进程分离开来。

RDB与AOF区别

  • RDB文件格式紧凑,方便数据恢复,保存rdb文件时父进程会fork出子进程由其完成具体持久化工作,最大化redis性能,恢复大数据集速度更快,只有手动提交save命令或关闭命令时才触发备份操作;
  • AOF记录对服务器的每次写操作(默认1s写入一次),保存数据更完整,在redis重启是会重放这些命令来恢复数据,操作效率高,故障丢失数据更少,但是文件体积更大

如何使用Redis做异步队列?

一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。

可不可以不用sleep呢?

list还有个指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来。

能不能生产一次消费多次呢?

使用pub/sub主题订阅者模式,可以实现1:N的消息队列。

pub/sub有什么缺点?

在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如rabbitmq等。

Redis如何实现延时队列?

使用sortedset,想要执行时间的时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。

为啥Redis zset使用跳跃链表而不用红黑树实现?

  • skiplist的复杂度和红黑树一样,而且实现起来更简单。
  • 在并发环境下红黑树在插入和删除时需要rebalance,性能不如跳表。

消息队列

项目中用过消息队列么?你为啥用消息队列?

我们公司本身的业务体量很小,所以直接单机一把梭啥都能搞定了,但是后面业务体量不断扩大,采用微服务的设计思想,分布式的部署方式,所以拆分了很多的服务,随着体量的增加以及业务场景越来越复杂了,很多场景单机的技术栈和中间件以及不够用了,而且对系统的友好性也下降了,最后做了很多技术选型的工作,我们决定引入消息队列中间件。

什么场景用到了消息队列?

异步:
有很多步骤都是在一个流程里面需要做完的,就比如说我的下单系统吧,本来我们业务简单,下单了付了钱就好了,流程就走完了。

但是后面来了个产品经理,搞了个优惠券系统,OK问题不大,流程里面多100ms去扣减优惠券。

后来产品经理灵光一闪说我们可以搞个积分系统啊,也行吧,流程里面多了200ms去增减积分。

再后来后来隔壁的产品老王说:下单成功后我们要给用户发短信,也将就吧,100ms去发个短信。

目前我只是举例两三个操作,实际业务中超过三个或更多,这个链路这样下去,时间长得一批,用户发现我买个东西你特么要花几十秒,垃圾电商我不在你这里买了,(如果像并夕夕这么便宜的除外)

Tip:我之前在的电商老东家要求所有接口的RT(ResponseTime响应时间)在200ms内,超出的全部优化,我现在所负责的系统QPS也是9W+就是抖动一下网络集群都可能炸锅那种,RT基本上都要求在50ms以内。

链路长了就慢了,但是我们发现上面的流程其实可以同时做的呀,你支付成功后,我去校验优惠券的同时我可以去增减积分啊,还可以同时发个短信啊。

那正常的流程我们是没办法实现的呀,怎么办,异步。

你对比一下是不是发现,这样子最多只用100毫秒用户知道下单成功了,至于短信你迟几秒发给他他根本不在意的。

你可能会说,异步? 那我用线程,线程池去做不是一样的么?

实际上线程池确实可以实现,但是我们在写代码的时候不仅要关注实现,同时要考虑一下可拓展性

解耦:
这里我需要提几个点(作为一个优秀研发工程师,实现是绝对得做到的,其次应该考虑代码解耦,拓展等…),说了那么多! 其实我想表达的是:线程池确实有适用场景,但目前该任务考虑消息队列更合适

为什么?
因为用线程去做,你是不是要写代码?

你一个订单流程,你扣积分,扣优惠券,发短信,扣库存。。。等等这么多业务要调用这么多的接口,每次加一个你要调用一个接口然后还要重新发布系统,写一次两次还好,写多了你就说你就卧槽起来了

而且真的全部都写在一起的话,不单单是耦合这一个问题,你出问题排查也麻烦,流程里面随便一个地方出问题搞不好会影响到其他的点,小伙伴说我每个流程都 try catch 不就行了,相信我别这么做,这样的代码就像个 定时炸弹💣,你不知道什么时候爆炸,平时不炸偏偏在你做活动的时候炸,你就领个P0故障收拾书包提前回家过年吧。

Tip:P0—PN 是互联网大厂经常用来判定事故等级的机制,P0是最高等级了。

但是你用了消息队列,耦合这个问题就迎刃而解了呀。

怎么说?

你下单了,你就把支付成功的消息告诉别的系统,他们收到了去处理就好了,你只用走完自己的流程,把自己的消息发出去,那后面要接入什么系统简单,直接订阅你发送的支付成功消息,你支付成功了我监听就好了

你可能再问,你的流程走完了就不用管别人是否成功么?比如你下单了积分没加,优惠券没扣怎么办?

问题是个好问题,但是没必要考虑,业务系统本身就是自己的开发人员维护的,你积分扣失败关我下单的什么事情?你管好自己下单系统的就好了。

好吧,话说的有点不负责任了,其实这就是一个典型的数据一致性问题

这个其实是分布式服务本身就存在的一个问题,不仅仅是消息队列的问题,但是放在这里说是因为用了消息队列这个问题会暴露得比较严重一点。

所有的服务都成功才能算这一次下单是成功的,那怎么才能保证数据一致性呢?

分布式事务:把下单,优惠券,积分。。。都放在一个事务里面一样,要成功一起成功,要失败一起失败。

这里不展开,可以去看我另外一篇文章分布式事务解决方案&分布式事务原理

削峰:
你平时流量很低,但是你要做秒杀活动00 :00的时候流量疯狂怼进来,你的服务器,Redis,MySQL各自的承受能力都不一样,你直接全部流量照单全收肯定有问题啊,直接就打挂了。

那怎么办?
简单,把请求放到队列里面,然后至于每秒消费多少请求,就看自己的服务器处理能力,你能处理5000QPS你就消费这么多,可能会比正常的慢一点,但是不至于打挂服务器,等流量高峰下去了,你的服务也就没压力了。

你看阿里双十一12:00的时候这么多流量瞬间涌进去,他有时候是不是会慢一点,但是人家没挂啊,或者降级给你个友好的提示页面,等高峰过去了又是一条好汉了。

使用消息队列带来的一些问题

  • 系统可用性降低: 系统可用性在某种程度上降低,为什么这样说呢?在加入MQ之前,你不用考虑消息丢失或者说MQ挂掉等等的情况,但是,引入MQ之后你就需要去考虑了!
  • 系统复杂性提高: 加入MQ之后,需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题!
  • 一致性问题: 消息队列可以实现异步,消息队列带来的异步确实可以提高系统响应速度。但是,万一消息的真正消费者并没有正确消费消息就会导致数据不一致的情况了!

如何保证消息不丢失

就市面上常见的消息队列而言,只要配置得当,我们的消息就不会丢。先来看看这个图,
在这里插入图片描述
可以看到一共有三个阶段,分别是生产消息、存储消息和消费消息。从这三个阶段分别入手来看看如何确保消息不会丢失。

生产消息
生产者发送消息至Broker,需要处理Broker的响应,不论是同步还是异步发送消息,同步和异步回调都需要做好try-catch,妥善的处理响应,如果Broker返回写入失败等错误消息,需要重试发送。当多次发送失败需要作报警,日志记录等。

存储消息
存储消息阶段需要在消息刷盘之后再给生产者响应,假设消息写入缓存中就返回响应,那么机器突然断电这消息就没了,而生产者以为已经发送成功了。

如果Broker是集群部署,有多副本机制,即消息不仅仅要写入当前Broker,还需要写入副本机中。那配置成至少写入两台机子后再给生产者响应。这样基本上就能保证存储的可靠了。一台挂了还有一台还在呢(假如怕两台都挂了…那就再多些)。

消费消息
这里经常会有同学犯错,有些同学当消费者拿到消息之后直接存入内存队列中就直接返回给Broker消费成功,这是不对的。

你需要考虑拿到消息放在内存之后消费者就宕机了怎么办。所以我们应该在消费者真正执行完业务逻辑之后,再发送给Broker消费成功,这才是真正的消费了。

所以只要我们在消息业务逻辑处理完成之后再给Broker响应,那么消费阶段消息就不会丢失。

小结一下
可以看出,保证消息的可靠性需要三方配合

生产者需要处理好Broker的响应,出错情况下利用重试、报警等手段。

Broker需要控制响应的时机,单机情况下是消息刷盘后返回响应,集群多副本情况下,即发送至两个副本及以上的情况下再返回响应。

消费者需要在执行完真正的业务逻辑之后再返回响应给Broker

但是要注意消息可靠性增强了,性能就下降了,等待消息刷盘、多副本同步后返回都会影响性能。因此还是看业务,例如日志的传输可能丢那么一两条关系不大,因此没必要等消息刷盘再响应。

如果处理重复消息

先来看看能不能避免消息的重复。

假设我们发送消息,就管发,不管Broker的响应,那么我们发往Broker是不会重复的。

但是一般情况是不允许这样的,这样消息就完全不可靠了,我们的基本需求是消息至少得发到Broker上,那就得等Broker的响应,那么就可能存在Broker已经写入了,当时响应由于网络原因生产者没有收到,然后生产者又重发了一次,此时消息就重复了。

再看消费者消费的时候,假设消费者拿到消息消费了,业务逻辑已经走完了,事务提交了,此时需要更新Consumer offset了,然后这个消费者挂了,另一个消费者顶上,此时Consumer offset还没更新,于是又拿到刚才那条消息,业务又被执行了一遍。于是消息又重复了。

可以看到正常业务而言消息重复是不可避免的,因此只能从另一个角度来解决重复消息的问题。

关键点就是幂等。既然我们不能防止重复消息的产生,那么我们只能在业务上处理重复消息所带来的影响。

幂等处理重复消息
幂等是数学上的概念,我们就理解为同样的参数多次调用同一个接口和调用一次产生的结果是一致的。

例如这条 SQL update t1 set money = 150 where id = 1 and money = 100; 执行多少遍money都是150,这就叫幂等。

因此需要改造业务处理逻辑,使得在重复消息的情况下也不会影响最终的结果。

可以通过上面那条 SQL 做个前置条件判断,即money = 100情况,并且直接修改,更通用的是做个version即版本号控制,对比消息中的版本号和数据库中的版本号。

或者通过数据库的约束例如唯一键,例如 insert into update on duplicate key…。

或者记录关键的key,比如处理订单这种,记录订单ID,假如有重复的消息过来,先判断下这个ID是否已经被处理过了,如果没处理再进行下一步。当然也可以用全局唯一ID等等。

基本上就这么几个套路,真正应用到实际中还是得看具体业务细节。

如何保证消息的有序性

有序性分:全局有序和部分有序。

全局有序
如果要保证消息的全局有序,首先只能由一个生产者往Topic发送消息,并且一个Topic内部只能有一个队列(分区)。消费者也必须是单线程消费这个队列。这样的消息就是全局有序的!

不过一般情况下我们都不需要全局有序,即使是同步MySQL Binlog也只需要保证单表消息有序即可。
在这里插入图片描述
部分有序
因此绝大部分的有序需求是部分有序,部分有序我们就可以将Topic内部划分成我们需要的队列数,把消息通过特定的策略发往固定的队列中,然后每个队列对应一个单线程处理的消费者。这样即完成了部分有序的需求,又可以通过队列数量的并发来提高消息处理效率。
在这里插入图片描述
图中我画了多个生产者,一个生产者也可以,只要同类消息发往指定的队列即可。

如果处理消息堆积

消息的堆积往往是因为生产者的生产速度与消费者的消费速度不匹配。有可能是因为消息消费失败反复重试造成的,也有可能就是消费者消费能力弱,渐渐地消息就积压了。

因此我们需要先定位消费慢的原因,如果是bug则处理 bug ,如果是因为本身消费能力较弱,我们可以优化下消费逻辑,比如之前是一条一条消息消费处理的,这次我们批量处理,比如数据库的插入,一条一条插和批量插效率是不一样的。

假如逻辑我们已经都优化了,但还是慢,那就得考虑水平扩容了,增加Topic的队列数和消费者数量,注意队列数一定要增加,不然新增加的消费者是没东西消费的。一个Topic中,一个队列只会分配给一个消费者

当然你消费者内部是单线程还是多线程消费那看具体场景。不过要注意上面提高的消息丢失的问题,如果你是将接受到的消息写入内存队列之后,然后就返回响应给Broker,然后多线程向内存队列消费消息,假设此时消费者宕机了,内存队列里面还未消费的消息也就丢了。

网络

网络分层

OSI 七层、TCP/IP 四层的关系和区别?

OSI 七层从下往上依次是:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层
在这里插入图片描述
TCP/IP 四层从下往上依次是:网络接口层、网络层、传输层、应用层。与 OSI 七层的映射关系如下:
在这里插入图片描述
特点:

  • 层与层之间相互独立又相互依靠
  • 上层依赖于下层,下层为上层提供服务

TCP/IP 四层是 OSI 七层的简化版,已经成为实事国际标准。

TCP/IP

TCP 与 UDP 的区别?

在这里插入图片描述

  • TCP 向上层提供面向连接的可靠服务 ,UDP 向上层提供无连接不可靠服务。
  • UDP 没有 TCP 传输可靠,但是可以在实时性要求搞的地方有所作为。
  • 对数据准确性要求高,速度可以相对较慢的,可以选用TCP。

TCP 如何实现数据的可靠性?

通过 校验和序列号确认应答超时重传连接管理流量控制拥塞控制等机制来保证可靠性。

校验和
数据传输过程中,将发送的数据段都当做一个16位的整数,将这些整数加起来,并且前面的进位不能丢弃,补在最后,然后取反,得到校验和。

- 发送方:发送数据之前计算校验和,并进行校验和的填充。
- 接收方:收到数据后,对数据以同样的方式进行计算,求出校验和,与发送方进行比较。

序列号
TCP 传输时将每个字节的数据都进行了编号,这就是序列号。序列号的作用不仅仅是应答作用,有了序列号能够将接收到的数据根据序列号进行排序,并且去掉重复的数据。

确认应答
TCP 传输过程中,每次接收方接收到数据后,都会对传输方进行确认应答,也就是发送 ACK 报文,这个 ACK 报文中带有对应的确认序列号,告诉发送方,接收了哪些数据,下一次数据从哪里传。

超时重传
进行 TCP 传输时,由于存在确认应答与序列号机制,也就是说发送方发送一部分数据后,都会等待接收方发送的 ACK 报文,并解析 ACK 报文,判断数据是否传输成功。如果发送方发送完数据后,迟迟都没有接收到接收方传来的 ACK 报文,那么就对刚刚发送的数据进行重发。

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

流量控制
如果发送方的发送速度太快,会导致接收方的接收缓冲区填充满了,这时候继续传输数据,就会造成大量丢包,进而引起丢包重传等等一系列问题。TCP 支持根据接收端的处理能力来决定发送端的发送速度,这就是流量控制机制。

具体实现方式:接收端将自己的接收缓冲区大小放入 TCP 首部的『窗口大小』字段中,通过 ACK 通知发送端。

拥塞控制
TCP 传输过程中一开始就发送大量数据,如果当时网络非常拥堵,可能会造成拥堵加剧。所以 TCP 引入了慢启动机制,在开始发送数据的时候,先发少量的数据探探路。

TCP 协议如何提高传输效率?

TCP 协议提高效率的方式有滑动窗口快重传延迟应答捎带应答等。

滑动窗口
如果每一个发送的数据段,都要收到 ACK 应答之后再发送下一个数据段,这样的话效率很低,大部分时间都用在了等待 ACK 应答上了。

为了提高效率我们可以一次发送多条数据,这样就能使等待时间大大减少,从而提高性能。窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。

快重传
快重传也叫高速重发控制
那么如果出现了丢包,需要进行重传。一般分为两种情况:

情况一:数据包已经抵达,ACK被丢了。这种情况下,部分ACK丢了并不影响,因为可以通过后续的ACK进行确认;
情况二:数据包直接丢了。发送端会连续收到多个相同的 ACK 确认,发送端立即将对应丢失的数据重传。

延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口大小可能比较小。

  • 假设接收端缓冲区为1M,一次收到了512K的数据;如果立刻应答,返回的窗口就是512K;但实际上可能处理端处理速度很快,10ms之内就把512K的数据从缓存区消费掉了;在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;如果接收端稍微等一会在应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M;

窗口越大,网络吞吐量就越大,传输效率就越高;我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

捎带应答
在延迟应答的基础上,很多情况下,客户端服务器在应用层也是一发一收的。这时候常常采用捎带应答的方式来提高效率,而ACK响应常常伴随着数据报文共同传输。如:三次握手。

TCP 如何处理拥塞吗?

网络拥塞现象是指到达通信网络中某一部分的分组数量过多,使得该部分网络来不及处理,以致引起这部分乃至整个网络性能下降的现象,严重时甚至会导致网络通信业务陷入停顿,即出现死锁现象。拥塞控制是处理网络拥塞现象的一种机制。

拥塞控制的四个阶段:慢启动拥塞避免快速重传快速恢复

TCP三次握手

在这里插入图片描述

TCP 链接需要三次握手,两次不可以么?

  • 两次握手只能保证单向连接是畅通的
    • 第一步,客户端给服务端发送一条消息:你好,服务端。第二步,服务端收到消息,同时给客户端回复一条消息:收到!你好客户端。

    • 这样的两次握手过程, 客户端给服务端打招呼,服务端收到了,说明客户端可以正常给服务端发送数据。但是服务端给客户端打招呼,服务端没有收到反馈,也就不能确保服务端是否能正常给客户端发送消息。

  • 只有经过第三次握手,才能确保双向都可以接收到对方的发送的数据 第三步,客户端收到服务端发送的消息,回复:收到!这样就证明了客户端能正常收到服务端的消息。

TCP四次挥手

  • 客户端发送终止命令FIN
  • 服务端收到后回复ACK,处于close_wait状态
  • 服务器将关闭前需要发送信息发送给客户端后处于last_ack状态
  • 客户端收到FIN后发送ack后处于tim-wait而后进入close状态
    在这里插入图片描述

为什么要四次挥手?

TCP 是全双工协议,也就是说双方都要关闭,每一方都向对方发送 FIN 和回应 ACK。

IP地址的分类

IP 的基本特点:

  • IP地址由四段组成,每个字段是一个字节,8位,最大值是255。
  • IP地址由两部分组成,即网络地址和主机地址。网络地址表示其属于互联网的哪一个网络,主机地址表示其属于该网络中的哪一台主机。

IP 地址主要分为A、B、C三类及特殊地址D、E这五类
在这里插入图片描述

  • A类:(1.0.0.0-126.0.0.0) 一般用于大型网络。
  • B类:(128.0.0.0-191.255.0.0) 一般用于中等规模网络。
  • C类:(192.0.0.0-223.255.255.0) 一般用于小型网络。
  • D类:是多播地址,地址的网络号取值于224~239之间,一般用于多路广播用户。
  • E类:是保留地址。地址的网络号取值于240~255之间。

HTTP

http1.1 和 http2 有什么区别?

HTTP1.1

  • 持久连接
  • 请求管道化
  • 增加缓存处理(新的字段如cache-control)
  • 增加 Host 字段、支持断点传输等

HTTP2.0

  • 二进制分帧
  • 多路复用(或连接共享)
  • 头部压缩
  • 服务器推送

HTTP 和HTTPS 的区别?

  • HTTPS 协议需要到 CA 申请证书,一般免费证书较少,因而需要一定费用。
  • HTTP 是超文本传输协议,信息是明文传输,HTTPS 则是具有安全性的 SSL 加密传输协议。
  • HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
  • HTTP 的连接很简单,是无状态的;HTTPS 协议是由 SSL+ HTTP 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 协议安全。

对称加密和非对称加密的区别和原理

对称密钥加密是指加密和解密使用同一个密钥的方式,这种方式存在的最大问题就是密钥发送问题,即如何安全地将密钥发给对方;

而非对称加密是指使用一对非对称密钥,即公钥私钥,公钥可以随意发布,但私钥只有自己知道。发送密文的一方使用对方的公钥进行加密处理,对方接收到加密信息后,使用自己的私钥进行解密。

由于非对称加密的方式不需要发送用来解密的私钥,所以可以保证安全性;但是和对称加密比起来,它比较,所以我们还是要用对称加密来传送消息,但对称加密所使用的密钥我们可以通过非对称加密的方式发送出去。

常见的状态码有哪些?

  • 1×× : 请求处理中,请求已被接受,正在处理
  • 2×× : 请求成功 - 200(请求被成功处理)
  • 3×× : 重定向,要完成请求必须进行进一步处理 - 301(永久性转移) 302(暂时性转移) 304(已缓存)
  • 4×× : 客户端错误,请求不合法 - 400(Bad Request,请求有语法问题) 403(拒绝请求) 404(客户端所访问的页面不存在)
  • 5×× : 服务器端错误,服务器不能处理合法请求 - 500(服务器内部错误 ) 503 (服务不可用,稍等)

http中常见的header字段有哪些?

  • cookie:请求时传递给服务端的cookie信息
  • set-cookie:响应报文首部设置要传递给客户端的cookie信息
  • allow:支持什么HTTP方法
  • last-modified:资源的最后修改时间
  • expires:设置资源缓存的失败日期
  • content-language:实体的资源语言
  • content-encoding:实体的编码格式
  • content-length:实体主体部分的大小单位是字节
  • content-range:返回的实体的哪些范围
  • content-type:哪些类型
  • accept-ranges:处理的范围请求
  • age:告诉客户端服务器在多久前创建了响应
  • vary:代理服务器的缓存信息
  • location:用于指定重定向后的URI
  • If-Match:值是资源的唯一标识
  • User-Agent:将创建请求的浏览器和用户代理名称等信息传递给服务器
  • Transfer-Encoding:传输报文的主体编码方式
  • connection:管理持久连接
  • keep-alive , close Cache-Control:控制浏览器的强缓存

Get与POST的区别

1、GET 一般用来从服务器上获取资源,POST 一般用来创建资源;

2、GET 是幂等的,即读取同一个资源,总是得到相同的数据,而 POST 不是幂等的。GET 不会改变服务器上的资源,而 POST 会对服务器资源进行改变;

3、从请求参数形式上看,GET 请求的数据会附在URL之后;而 POST 请求会把提交的数据则放置在是HTTP请求报文的请求体中。

4、POST 的安全性要比 GET 的安全性高,因为 GET 请求提交的数据将明文出现在 URL 上,而 POST 请求参数则被包装到请求体中,相对更安全。

5、GET 请求的长度受限于浏览器或服务器对URL长度的限制,允许发送的数据量比较小,而POST请求则是没有大小限制的。

DNS 的寻址过程

1、在浏览器中输入www.baidu.com域名,操作系统会先检查自己本地的 hosts 文件是否有这个网址映射关系,如果有就先调用这个IP地址映射,完成域名解析。

2、如果 hosts 里没有这个域名的映射,则查找本地 DNS 解析器缓存,是否有这个网址映射关系,如果有直接返回,完成域名解析。

3、如果 hosts 与本地 DNS 解析器缓存都没有相应的网址映射关系,首先会找 TCP/IP 参数中设置的首选 DNS 服务器,在此我们叫它本地 DNS 服务器,此服务器收到查询时,如果要查询的域名,包含在本地配置区域资源中,则返回解析结果给客户机,完成域名解析,此解析具有权威性。

4、如果要查询的域名,不由本地 DNS 服务器区域解析,但该服务器已缓存了此网址映射关系,则调用这个 IP 地址映射,完成域名解析,此解析不具有权威性。

5、如果本地 DNS 服务器本地区域文件与缓存解析都失效,则根据本地 DNS 服务器的设置(是否设置转发器)进行查询,如果未用转发模式,本地 DNS 就把请求发至13台根 DNS ,根 DNS 服务器收到请求后会判断这个域名(.com)是谁来授权管理,并会返回一个负责该顶级域名服务器的一个IP。本地 DNS 服务器收到IP信息后,将会联系负责 .com 域的这台服务器。这台负责 .com 域的服务器收到请求后,如果自己无法解析,它就会找一个管理.com域的下一级DNS服务器地址(baidu.com)给本地 DNS 服务器。当本地 DNS 服务器收到这个地址后,就会找 baidu.com 域服务器,重复上面的动作,进行查询,直至找到 www.baidu.com 主机。

6、如果用的是转发模式,此 DNS 服务器就会把请求转发至上一级 DNS 服务器,由上一级服务器进行解析,上一级服务器如果不能解析,或找根 DNS 或把转请求转至上上级,以此循环。不管是本地 DNS 服务器用是是转发,还是根提示,最后都是把结果返回给本地 DNS 服务器,由此 DNS 服务器再返回给客户机。

在浏览器中输入一个www.baidu.com后执行的全部过程?

域名解析 -> 建立TCP连接(三次握手)-> 发起http请求 -> 服务器响应http请求,浏览器得到html代码 -> 浏览器解析html代码,并请求html代码中的资源(如 js、css、图片等)-> 浏览器对页面进行渲染呈献给用户。

Session、Cookie 的区别

  • session 在服务器端,cookie 在客户端(浏览器)
  • session 默认被存储在服务器的一个文件里(不是内存)
  • session 的运行依赖 session id,而 session id 是存在 cookie 中的,也就是说,如果浏览器禁用了 cookie ,同时 session 也会失效(但是可以通过其它方式实现,比如在 url 中传递 session_id)
  • session 可以放在 文件、数据库、或内存中都可以。
  • 用户验证这种场合一般会用 session

有哪些 web 性能优化技术?

  • DNS查询优化
  • 客户端缓存
  • 优化TCP连接
  • 避免重定向
  • 网络边缘的缓存
  • 条件缓存
  • 压缩和代码极简化
  • 图片优化

网络安全

什么是 XSS 攻击?

XSS 即(Cross Site Scripting)中文名称为:跨站脚本攻击。XSS的重点不在于跨站点,而在于脚本的执行。

XSS的原理
恶意攻击者在web页面中会插入一些恶意的script代码。当用户浏览该页面的时候,那么嵌入到web页面中script代码会执行,因此会达到恶意攻击用户的目的。

XSS攻击最主要有如下分类:反射型存储型、及 DOM-based型。反射性和DOM-baseed型可以归类为非持久性XSS攻击。存储型可以归类为持久性XSS攻击

什么是跨站攻击CSRF?

CSRF(Cross Site Request Forgery,跨站域请求伪造)是一种网络的攻击方式,它在 2007 年曾被列为互联网 20 大安全隐患之一,也被称为『One Click Attack』或者 『Session Riding』,通常缩写为CSRF或者XSRF,是一种对网站的恶意利用。

听起来像跨站脚本(XSS),但它与XSS非常不同,并且攻击方式几乎相左。

XSS利用站点内的信任用户,而CSRF则通过伪装来自受信任用户的请求来利用受信任的网站。与XSS攻击相比,CSRF攻击往往不大流行(因此对其进行防范的资源也相当稀少)和难以防范,所以被认为比XSS更具危险性。

  • 23
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值