Skip to content

前端

集合

说一下 Java 提供的常见集合?

Java中提供的集合框架主要分为两类,一个是 Collection 属于单列集合,第二个 Map 属于双列集合。

  • Collection 中有两个字接口 ListSetList 接口中的实现类 ArrayListLinkedList ,是有序可重复的;Set 接口中实现的有 HashSet TreeSet,无序且唯一。
  • Map 接口中常见的实现类有 HashMapTreeMap,还有一个线程安全的Map:ConcurrentHashMap

ArrayList 和 LinkedList 的区别是什么?

ArrayListLinkedList都是 Java 集合框架中常用的列表类,用于存储一组有序的元素,它们在内部结构、性能特点、内存占用等方面存在一些区别,以下是详细介绍:

内部数据结构

  • ArrayList:基于动态数组实现,它在内存中是一块连续的空间,通过索引可以快速访问元素。当数组容量不足时,会自动进行扩容操作。
  • LinkedList:基于双向链表实现,每个元素都包含一个数据域和两个指针域,分别指向前一个节点和后一个节点。通过节点之间的指针链接来表示元素之间的顺序关系。

访问元素的性能

  • ArrayList:通过索引访问元素的速度非常快,时间复杂度为O(1)。因为它可以直接根据索引计算出元素在内存中的位置,进行随机访问。
  • LinkedList:通过索引访问元素的速度相对较慢,需要从链表的头节点或尾节点开始遍历,时间复杂度为O(n),其中n是链表的长度。

插入和删除元素的性能

  • ArrayList:在末尾插入元素的速度较快,时间复杂度为O(1)。但在中间或开头插入元素时,需要移动后面的元素来腾出空间,时间复杂度为O(n)。删除元素时类似,在末尾删除元素时间复杂度为O(1),在中间或开头删除元素时间复杂度为O(n)
  • LinkedList:在链表的任何位置插入或删除元素的速度都比较快,只需要修改相应节点的指针即可,时间复杂度为O(1)。但如果要在指定索引位置插入或删除元素,需要先遍历链表找到该位置,时间复杂度为O(n)

内存占用

  • ArrayList:由于基于数组实现,当创建ArrayList时,会分配一定的初始容量,即使列表中没有元素,也会占用一定的内存空间。随着元素的增加,如果需要扩容,会创建一个更大的数组,并将原数组中的元素复制到新数组中,可能会导致内存的浪费或频繁的内存分配和回收。
  • LinkedList:每个节点除了存储数据外,还需要额外的指针空间来指向前一个和后一个节点,因此在存储相同数量的元素时,LinkedList通常比ArrayList占用更多的内存。

遍历方式

  • ArrayList:支持通过索引的方式进行快速遍历,也可以使用迭代器进行遍历。在使用增强型 for 循环遍历ArrayList时,本质上也是通过迭代器来实现的。
  • LinkedList:通常使用迭代器进行遍历,因为通过索引遍历的效率较低。在使用迭代器遍历LinkedList时,可以方便地在遍历过程中进行插入和删除操作,而不会出现ConcurrentModificationException异常。

适用场景

  • ArrayList:适用于对元素进行随机访问频繁的场景,如按索引获取元素、遍历元素等。当需要对大量数据进行快速排序或二分查找时,ArrayList也具有一定的优势。
  • LinkedList:适用于需要频繁插入和删除元素的场景,特别是在链表的中间或开头进行操作时。例如,实现一个队列或栈时,LinkedList可以方便地作为底层数据结构。

为什么数组索引从 0 开始,从 1 开始不行吗?

数组索引从 0 开始是由寻址公式决定的,是数组的首地址 + 索引 * 数据的类型大小。如果从 1 开始则需要在做一次减法运算,性能不高。

ArrayList list=new ArrayList(10)中的 List 扩容几次?

  • 该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容。

如何实现数组和 List 之间的转换?

  • 数组转换成 List 需要用到 Arrays.asList;
  • List转换成数组需要用到 list.toArray,无参toArray方法返回Object数组,传入初始化长度的数组对象,返回该对象数组。

用Arrays.asList转List后,如果修改了数组内容,list受影响吗

  • 受影响,asList方法只是在内部封装了数组对象,并没有对原有数组对象做重新拷贝。

List用toArray转数组后,如果修改了List内容,数组受影响吗

  • 不受影响,因为toArray方法底层对数组进行了拷贝,跟原来的元素没啥关系,即时list修改了以后,数组也不受影响。

hashset 和 treeset 有什么区别 ?

HashSetTreeSet都是 Java 集合框架中的集合类,用于存储一组不重复的元素,但它们在内部实现、元素存储方式、性能特点等方面存在一些区别,以下是详细介绍:

内部实现

  • HashSet:基于哈希表实现,通过哈希函数计算元素的哈希码来确定元素在哈希表中的存储位置,具有较快的查找、插入和删除速度。
  • TreeSet:基于红黑树实现,红黑树是一种自平衡的二叉查找树,它通过比较元素的大小来确定元素在树中的存储位置,能够保持元素的有序性。

元素存储方式

  • HashSet:元素在哈希表中是无序存储的,即元素的存储顺序与添加顺序无关,也不保证元素的自然顺序。
  • TreeSet:元素在红黑树中是按照自然顺序或指定的比较器顺序进行排序存储的,因此遍历TreeSet时会按照元素的顺序依次返回。

性能特点

  • HashSet:在添加、删除和查找元素时具有常数时间复杂度O(1),性能较高。但在遍历元素时,由于元素无序,可能需要额外的时间来处理。
  • TreeSet:添加、删除和查找元素的时间复杂度为O(log n),其中n是集合中元素的数量。虽然在平均情况下性能也不错,但在元素数量较大时,可能会比HashSet稍慢。不过,TreeSet在需要有序遍历元素时具有优势。

元素要求

  • HashSet:对元素的类型没有严格要求,只要正确重写了hashCode()equals()方法即可。这样可以确保在哈希表中能够正确地判断元素的唯一性。
  • TreeSet:要求元素必须实现Comparable接口或在创建TreeSet时提供一个外部比较器,以便能够比较元素的大小来确定元素在树中的位置。

适用场景

  • HashSet:适用于对元素的添加、删除和查找操作频繁,且不需要保证元素顺序的场景,例如数据去重、缓存等。
  • TreeSet:适用于需要对元素进行排序,或者需要按照顺序遍历元素的场景,例如对数据进行排序输出、实现优先级队列等。

HashMap的 JDK1.7JDK1.8 有什么区别

  • JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
  • JDK1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表

HashSet 与 HashMap 的区别

  • HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对.
  • HashSet 底层其实是用 HashMap 实现存储的, HashSet 封装了一系列 HashMap的方法. 依靠 HashMap 来存储元素值,(利用hashMap的key键进行存储), 而 value值默认为 Object 对象. 所以HashSet 也不允许出现重复值, 判断标准和 HashMap 判断标准相同, 两个元素的 hashCode 相等并且通过 equals() 方法返回 true.

并发

线程与进程的区别

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务。
  • 不同的进程使用不同的存储空间,同一进程下的线程共享内存空间。
  • 线程比进程更轻量,线程上下文的切换比进程上下文的切换要低。

并发与并行的区别

  • 并发是同一时间应对(dealing with)多件事情的能力。一个 CPU 轮流执行多个线程,宏观上是并行,微观上是串行。
  • 并行是同一时间处理(doing)多件事情的能力。

举个例子,食堂阿姨给学生打饭。一个阿姨给两个队伍同时打饭就是并发。两个阿姨同时给两个队伍打饭就是并行。


创建线程有哪些方式

  • 继承 Thread 线程并重写 run 方法,调用 start 启动线程。
  • 重写 Runnable 的 run 方法,并将 Runnable 类放入到 Thread 类中,调用 start 方法启动线程。
  • 重写 Callable 的 call 方法,并将 Callable 放入到创建 TaskFuture 中,再将创建的 TaskFuture 类放入到 Thread 类中,调用 start 方法启动线程。并且可以通过 TaskFuture 的 get 方法获取执行结果。
  • 通过线程池创建对象,调用 sumbit 启动线程,调用 shutdown 关闭线程。

Runnable 和 Callable 有什么区别?

  • Runnable 的 run 方法没有返回值;Callable 的 call 方法有返回值,并且是个范型,可以通过 Future、FutureTask 配合 get 方法获取异步执行的结果。此方法会阻塞主进程继续往下执行,如果不调用则不会阻塞。
  • Runnable 的 run 方法的异常只能在内部消化,不能向上抛,Callable 的 call 方法允许抛出异常。

run 方法和 start 方法有什么区别?

  • start 方法用来启动线程,通过线程调用 run 方法中的代码,start 方法只允许调用一次,而 run 方法封装了要执行的代码,可以多次调用。
  • start 方法是将线程 NEW 状态切换成 RUNNABLE 状态,调用完 run 方法后线程就从 RUNNABLE 状态切换成了 TERMINATE 状态。

线程之间的状态是如何切换的

JDK中的 Thread 线程中有枚举类型定义了线程的六种状态,分别为:新建,运行,阻塞,等待,有限等待,终结。

  • 当一个线程对象被创建并调用了 start 方法,线程就会从 NEW 状态进入到 RUNNABLE 状态,当执行了 run 方法,线程就会从 RUNNABLE 进入 TERMINATE 状态。这是一个线程的正常的状态。
  • 当线程获取锁失败,则会从 RUNNABLE 状态进入 Monitor 的阻塞队列进入到 BLOCK 状态,当持有线程的锁释放以后,就会按照一定的规则唤醒阻塞队列的线程,唤醒后的线程则进入可运行状态去竞争锁。
  • 当线程获取锁成功,但是由于没有满足条件而调用了 wait 方法,就会将 RUNNABLE 的线程状态切换成 WAITING 。当持有线程的锁调用了 notify 或 notifyall 方法后就会重新竞争锁。如果是调用了带参数的 wait 方法则会在等待时间结束以后等待唤醒后去重新竞争锁。
  • 还有一种情况就是在同步代码块中调用了 Thread.sleep 的带参方法也会将线程从 RUNNABLE 进入 TIMED_WAITTING,并且不需要主动唤醒,时间到了以后就会自然恢复到可运行状态。

sleep 方法和 wait 方法的相同点和不同点?

相同点

  • sleep 方法和 wait 方法都是将线程的 RUNNABLE 状态切换成 WAITING状态
  • 都可以清除打断状态

不同点

  • 方法归属不同:sleep 方法属于 Thread 的静态方法,wait 属于 Object 的成员方法
  • 醒来时机不同:sleep 方法和 wait 方法虽然都是等待相应的时间醒来,但是 wait 必须通过notify 进行唤醒,并且 wait 方法不唤醒就会一直等待下去,而 slepp 在等待完时间后就会自动唤醒。
  • 锁特性不同:wait 方法调用必须获取 wait 方法的对象锁,而 sleep 方法没有这个限制。并且 wait 方法执行后释放对象锁,允许其他线程获取该对象锁。如果 sleep 在 synchronized 代码块中执行,并不会释放对象锁。

如何终止一个正在运行的线程。

  • 使用终止标志和关键字 volatile,使线程正常退出,就是让 run 方法执行完成后终止。
  • 使用线程的 interrupt 方法中断线程,内部其实也是使用中断标记来中断线程。
  • 使用线程的 stop 方法强行终止,这个方法在 JDK中已经废弃,不推荐使用。

什么是 Java 内存模型,如何理解?

  • Java 内存模型定义了共享内存中多线程程序读写操作的行为规范,通过这些规范来对保证内存的读写操作正常执行。
  • Java 内存模型把内存分为私有线程的工作区域,称为工作内存;一块是所有线程的共享区域,称为主内存。
  • 线程和线程之间的数据是相互隔离的,线程间的通信必须通过主内存。

导致并发问题的根本原因是什么,如何解决?

  • 导致并发问题根本原因是工作内存对主内存共享变量的修改,当修改共享变量时线程切就会带来原子性问题,编译器对共享变量的缓存带来的可见性问题,CPU指令重排带来的共享变量有序性的问题。
  • 可见性问题是编译器优化造成的,有序性问题是 CPU 指令重排导致的,可以使用 volatile 关键字来解决。原子性问题是切换导致的,可以使用 CAS 解决,加锁可以粗暴的解决这三种问题。

关键字 volatile 有什么功能?

volatile 关键字可以修饰类的成员变量、类的静态成员变量。主要有两个功能

  • 保证了线程间的可见性:用 volatile 修饰共享变量,能够防止编译器优化,让一个线程对共享变量的修改对另一个线程可见。
  • 禁止进行指令重排序:用 volatile 修饰变量,变量在读写时会加入读写屏障,阻止其他读写操作越过屏障,达到阻止重排序的效果。

关键字 synchronized 原理是什么?

synchronized 是基于悲观锁实现的,性能比较低。依赖于 JVM 级别的 monitor,存在于每个 Java 对象的对象头中,synchronized 锁就是通过这种方式获取的,所以 Java 任意对象都可以作为锁。

monitor 内部维护了三个变量,owner 表示持有锁的线程,waitset 保存了等待状态的线程,entrylist 保存了处于阻塞状态的线程。

线程获取成功就是获取 monitor 中的 owner,并且一个 monitor 只有一个 owner,当上锁成功以后有其他的线程来争抢锁则会失败进入到 entrylist 阻塞队列。当获取锁的线程释放后,就会唤醒 entrylist 中等待的线程来竞争锁,竞争的时候是非公平的,有利于提升线程效率。在调用wait方法的线程会在 waitset 中等待,被唤醒后进入阻塞队列重新竞争锁。


你知道 synchronized 锁升级吗?

Java 中为了获得锁和释放锁带来的性能消耗,引入了偏向锁,轻量级锁和重量级锁。对应了只被一个线程持有、不同线程交替持有、多线程竞争三种情况。锁升级的过程是自动的,不需要开发者手动干预。


CAS 是什么,具体流程是怎么样的?

CAS 全程 Compare And Swap 比较在交换;体现的是一种乐观锁思想,在无锁的状态下保证线程操作数据的原子性。

我们举个例子,主内存存在一个共享变量 100。线程有两个线程,线程一将共享变量从主内存中复制一份到工作内存做 count ++ 操作,线程二将共享变量从主内存复制一份到工作内存做 count -- 操作。线程一复制到共享变量副本为 100,count ++ 以后变成了 101,这时我们将共享变量副本值与共享变量的值相等,则将共享变量的值修改为 101。线程二复制的共享变量副本也为 100,count -- 以后变为了 99,将共享变量副本与最新共享变量做对比,100 不等于 101,修改失败。这时线程二会进入自旋操作。


乐观锁和悲观锁的区别

  • 乐观锁是最乐观的估计,不怕别人来修改共享变量,就算修改了也没关系,可以吃亏一点重试。在竞争不激烈的时候可以提升性能。
  • 悲观锁是最悲观的估计,防止别人来修改变量,我上了锁其他线程都不能修改,我修改成功以后其他线程才能修改。

介绍一下 ReentranLock 和 RenntranLock 工作流程

ReentranLock 是属于并发包下的类,是API层面的锁,和 synchronized 一样都是悲观锁实现互斥。通过 lock 方法获取锁,unlock 方法释放锁。支持可重入、可中断、可超时、可以设置公平锁和多个条件变量。底层通过 CAS 和 AQS 队列实现。

ReentranLock 内部维护了 volatile 修饰的共享变量 STATE 来表示资源的状态。当线程来争抢锁后通过 CAS 的方式修改 STATE 状态,修改成功则为1,让 exclusiveOwnerThread 属性指向当前线程,获取锁成功。如果修改状态失败则会进入双向队列进行等待,Head 指向双向队列头部,Tail 指向双向队列尾部。当 exclusiveOwnerThread 为空的时候,会唤醒在双向队列中等待的线程。公平锁体现在按照先后顺序获取锁,非公平体现在不排队的线程也可以争抢锁。


synchronized 和 Lock 有什么区别

  • 语法层面:synchronized 是源码层上的实现,Lock 是 API 层面的实现。使用 synchronized 时,退出同步代码块会自动释放锁,Lock 需要手动调用 unlock 释放锁。
  • 功能层面:二者虽然都是悲观锁,都具备基本的互斥、同步、锁重入。但 Lock 提供了 synchronized 不具备的可打断,锁超时,公平锁,多条件变量。Lock 还提供了 读写锁和可重入锁。
  • 性能层面:虽然在没有竞争的情况下做了很多优化,比如偏向锁、轻量级锁,但是在竞争激烈的情况下,还是 Lock 会提供更好的性能。

谈谈你对 ThreaLocl 的理解。

线程安全的本质是工作线程对主内存共享变量的修改。而 ThreadLocal 就是通过让每个线程只用自己的资源对象,并且在线程内资源共享。避免了因为对主内存变量的修改引发的线程安全问题。

ThreadLocal 内部维护了一个 ThreadLocalMap 类型的成员变量来存储资源对象。当调用 set 方法就将自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中。get 方法和 remove 方法就是通过自己作为key 来获取和删除 ThreadLocalMap 中的资源对象。


为什么 ThreadLocal 会导致内存溢出。

因为 ThreadLocal 中的 key 被设置成弱引用,当发生垃圾回收时 key 会获得内存释放,但是 value 值作为强引用不会被垃圾回收。所以我们在使用 ThreadLocal 的时候需要主动使用 remove 方法释放内存,避免内存溢出。


强引用,软引用,弱引用,虚引用各有什么区别?

  • 强引用是普通的引用方式,表示一个对象处于有用且必须的状态,即使内存不足也不会回收
  • 软引用表示一个对象处于有用且非必须的状态,会在内存不足的时候进行回收。
  • 弱引用表示一个对象处于可能有用且非必须的状态,在 GC 时就会被回收。
  • 虚引用表示一个对象处于无用状态,任何时候都会被回收。

线程池的核心参数有哪些?

  • 核心线程数目,线程池中保留的最多线程数。
  • 最大线程数目指的是核心线程加救济线程的最大数目。
  • 生存时间指的是救急线程的生存时间,如果生存时间没有新任务,此线程资源就会释放。
  • 时间单位指的是救急线程的生存时间单位。
  • 阻塞队列,当没有空闲的核心线程时,新来的任务会加入到此队列排队,队列满了会创建急救线程执行任务。
  • 线程工厂可以定制线程对象的创建,如设置线程名称,是否为守护线程。
  • 拒绝策略是指当所有线程都满时,阻塞队列也放满会触发拒绝策略。
    • 有四种拒绝策略:分别是抛异常,调用者执行任务,丢弃当前任务,丢弃最早的排队任务。默认是直接抛出异常。

线程池是如何提交任务的。

  • 任务在提交的时候,首先判断核心线程是否已经满了,如果没有满则直接加入核心线程。
  • 如果核心线程满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列。
  • 如果阻塞队列也满了,则判断最大线程数是否已满,如果没有满,则使用急救线程执行。
  • 如果最大线程也满了,则走拒绝策略。

核心线程用完了为什么先放在阻塞队列中,而不是直接打到最大线程?

在系统中,我们创建和销毁一个线程的成本是相对比较高的,我们需要最小的线程数来保证一个最大的吞吐量。线程数肯定不是设置的越多越好,但是线程数设计小了,那来一个请求的峰值,我们就频繁的创建线程,就起不到线程池这个缓冲的效果。线程池中阻塞队列的设计就是尽量在高吞吐量的情况下达到一个缓冲的效果。核心线程我们希望一直执行任务,而来了一个峰值之后,不是直接去创建救急线程,而是放到队列中做一个缓冲。这样就能一定程度上应对这种峰值导致了 CPU 负载的频繁变化,达到削峰的效果。


MQ 也能达到削峰的效果,线程池和MQ 应该如何选择?

从削峰和异步化的角度两者都能实现,但是线程池的实现相对简单,不用单独维护中间件。但是线程池里面的任务是没有办法持久化的。机器重启后任务就会丢失,但 MQ 消息的堆积能力是很强的,而且还有持久化机制,一系列手段保证任务不会丢失。而且 MQ 还支持一些其他特性,如果一些重试机制,这种原生重试的设计可以应用到一致性的解决方案里。另外异步任务的堆积能力,我们可以通过集群去拓展 MQ 的内存,而不是像本地的线程池只能放在一个节点的内存里。所以对于可靠性要求比较高的任务,我们考虑用 MQ。


线程池有哪些常见的阻塞队列。

常见的阻塞队列有4种,用的最多是 ArrayBlockingQueue 和 LinkedBlockingQueue

  • LinkedBlockingQueue:基于链表结构的阻塞队列,FIFO。默认是没有边界的,可以设置为有边界,读写各有一把锁,性能比较好
  • ArrayBlockingQueue:基于数组结构的阻塞队列,FIFO。强制有边界,只有一把锁,读写共用,性能相对于 LinkedBlockingQueue 要差一些
  • SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
  • DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的

JUC 的 Executors类中提供了哪些线程池。

  • newFixedThreadPool:创建一个固定线程数的线程池,核心线程和最大线程数相同,没有救急线程,阻塞队列使用的是 LinkedBlockingQueue,最大容量为 Integer.MAX_VALUE。适用于任务量已知,相对耗时的任务。
  • newSingleThreadExecutor:核心数和最大线程数都是1,阻塞队列使用的是 LinkedBlockingQueue,最大容量为 Integer.MAX_VALUE。适用于按照顺序执行的任务。
  • newCachedThreadPool:核心线程为0,最大线程数为 Integer.MAX_VALUE,全是救急线程。阻塞队列为 SynchronousQueue ,不存储元素数组的阻塞队列,每次插入操作都需要等待一个移出。适用于任务比较密集,但每个任务执行时间都比较短的场景。
  • newScheduledThreadPool:适用于有定时和延迟执行的场景。

但是不建议使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式可以让我们更加明确线程池的运行规则,避免资源耗尽的风险。比如 FixedThreadPool, SingleThreadPool 和 CachedThreadPool 允许请求队列的长度都是 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。


如何设置核心线程数?

我们可以把并发高低和任务执行时间的长短分为两个维度。

  • 并发低,执行时间短的我们系统是不需要优化设置的
  • 并发高,执行时间短的我们需要减少线程上下文的切换,CPU 核心数 + 1 即可
  • 并发低,执行时间长的我们需要判断是 IO 密集型还是计算密集型
    • IO 密集型一般是文件的读写,DB读写,网络请求,适用于 CPU 核心数 * 2 + 1
    • 计算密集型一般是代码计算,数据转化,数据排序,适用于 CPU 核心数 + 1
  • 并发高,执行时间长的任务设置核心数不是重点,重点是整体架构的设计。先看看是否可以做缓存,然后是否可以增加服务器,至于线程可以按照 IO 密集型或者 CPU 密集型设置。

ConcurrentHashMap的原理吗?

ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。

  • JDK1.7 的底层采用是分段的数组+链表 实现
  • JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。

在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。

Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元 素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁

在JDK1.8中的 ConcurrentHashMap 做了较大的优化。首先是它的数据结构与 Jdk1.8的 HashMap 数据结构完全一致。其次是放弃了 Segment 的设计,取而代之的是采用 Node + CAS + Synchronized 来保证并发安全进行实现,CAS 用于控制数组节点的添加。synchronized 锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发 , 效率得到提升。

缓存

Redis的常用数据类型有哪些?

支持多种类型的数据结构,主要区别是value存储的数据格式不同:

  • string:最基本的数据类型,二进制安全的字符串,最大512M。

  • list:按照添加顺序保持顺序的字符串列表。

  • set:无序的字符串集合,不存在重复的元素。

  • sorted set:已排序的字符串集合。

  • hash:key-value对格式

什么是缓存穿透,如何解决?

缓存穿透是当查询一个不存在的数据,从缓存中查不到数据,每次都去查询数据库,请求太多就会导致数据库崩溃。一般有这种情况都是数据库遭遇到了攻击,一般采用缓存空数据和布隆过滤两种方法。

  • 缓存空数据实现简单,但是会造成一定的内存浪费,还有可能会导致数据一致性问题
  • 布隆过滤器占用内存少,没有多余的key,但是有一定的误判。

布隆过滤器可以通过 Redisson 实现,实现原理是声明一个比较大的数组,里面存 0。在 Key 经过三次 hash 以后,摸于数组长度知道数组下标的数据,从 0 改到 1,三个数组位置标记一个 Key。查找过程同理。所以布隆过滤器会有一定的误判,一般将误判率设置成 5%,低于 5% 需要增加数组长度,但是会对内存产生更多的消耗。


什么是缓存击穿,如何解决?

缓存击穿是指一个设置过期时间到 Key,在过期时有大量的请求发送过来。这时候所有的查询都走数据库,从而导致数据库崩溃。我们一般互斥锁和逻辑过期有两种方法解决。

  • 互斥锁是在缓存失效的时候设置一个互斥锁,只有拿到互斥锁才能重建缓存,在重建缓存期间的请求都会被阻塞,只有重建完成以后才能重新获取缓存数据。
  • 逻辑过期时给缓存设置一个逻辑过期时间,当缓存要过期时重新开启一个有互斥锁的线程对数据进行重建。在重建的过程中,新线程只能拿到过期的数据,并且因为有互斥锁的缘故,新线程不能重建数据。当数据重建完成后其他线程就能获取到最新数据。

两种方法各有利弊,互斥锁保证了数据的强一致性,但是性能不高,因为缓存在重建的过程中是无法获取数据的。逻辑过期保证了高可用,性能比较高,保证了数据最终一致性,但是没有保证数据的实时一致性。


什么是缓存雪崩,如何解决?

缓存雪崩是指大量 Key 同一时间失效或者 Redis 服务宕机,导致大量的请求直接到达数据库,造成数据库崩溃。

  • 给不同的 Key 设置随机过期时间。
  • 利用 Redis 集群提高服务的可用性。
  • 给业务添加多级缓存
  • 给缓存业务添加降级策略,降级策略作为系统的保底,适用于穿透、击穿、雪崩。

你们项目是如何将数据库和缓存数据进行同步的

对一致性要求没那么高的,采用异步通知的方式对数据进行通过

  • 使用 MQ 作为中间件,当服务数据更新以后,通过消息更新将缓存数据更新
  • 使用 Canal 作为中间件,不需要修改业务代码,伪装成 MySQL 的一个从节点,读取 binlog 日志来将缓存数据更新

如果是强一致性的,采用 Redisson 提供的读写锁。

  • 将业务的查询添加共享锁:读锁 ReadLock,加锁后其他线程可用共享读操作。
  • 将业务的修改添加排他锁:独占锁 WriteLock,加锁后修改会阻塞其他线程的读写操作。

你听说过延迟双删吗,为什么不用延迟双删?

延迟双删,如果是写操作,先删除缓存中的数据,然后再修改数据库,延迟一会儿再删除缓存中的数据。其中这个延迟时间不好确定,延迟的过程中也可能会有脏数据,并不能保证强一致性。并且这样的代码编写复杂,不利于对业务代码的理解。所以我们系统没有采用。


你们项目中的分布式锁是如何实现的?

我们系统使用的是 Redisson 来实现分布式锁的,底层是 sentx 和 Lua 脚本来实现的。因为 Redis 是单线程的,用了命令之后只能有一个客户端对 Key 进行设置值,当 Key 没有过期或者删除,其他客户端都不能设置这个 Key。


Redisson 如何实现锁的有效时长?

Redisson 的锁有一个等待时间和超时时间。当一个锁设置超时时间还没有结束时,会有一个看门狗机制,每隔一段时间就会检查当前业务是否还持有锁,如果还持有锁就增加锁的持有时间,当业务执行完以后就可以直接释放了。

等待时间可以在高并发的情况下提升性能,当线程发生争抢后不会马上进入阻塞,而是进入自旋操作,尝试获取锁。当抢到的线程释放后,没有抢到的线程就可以尝试重新获取锁。

Redisson 锁还支持可重入,这是为了避免出现死锁。重入机制就是在尝试重入的时候会判断是否是当前线程持有的锁,如果是当前线程持有的锁就加一,释放锁的时候当前线程持有减一。存储线程数据是 Hash 结构,大 Key 是自己业务锁,小 Key 是线程ID,Value 是线程重入次数。


Redisson 实现的分布式锁能解决主从一致性问题吗?

Redisson 无法解决分布式锁的一致性问题。当线程一加锁成功后,从节点还没有从主节点获取到同步的数据就宕机了。这时候从节点就会重新升级为主节点,当这时重新进来一个线程二,就会在新的主节点上重新上锁,这时候就会有两个线程都获取到同一把锁。

Redisson 提供了红锁解决了这个问题。红锁主要提供了同时在再多个节点上都创建锁成功才能使用锁,解决了由于主从节点数据延迟同步导致的加锁失败问题。但正因为红锁需要同时在多个节点上加锁,性能就会变的很低,维护成本也很高,所以项目中一般不会使用红锁,并且官方也暂时废除了这个红锁。


如果业务一定要考虑数据强一致性怎么办呢?

Redis 本身设计就是高可用,做到强一致性就非常影响性能,可以考虑使用 Zookeeper 实现分布式锁来保证强一致性。


Redis作为缓存,如何实现数据的持久化?

Redis 持久化数据有两种方式,一种是 RDB ,一种是 AOF

  • RDB 是快照文件,定期将内存中的数据同步到磁盘中,当 Redis 宕机需要恢复数据时则通过 RDB 快照文件进行数据恢复
  • AOF 是追加文件,将 Redis 执行写操作的命令追加到文件中,当 Redis 宕机需要恢复数据则通过 AOF 文件执行所有记录在文件上的写命令。

RDB 是一个二进制文件,数据恢复的很快,但是会有丢失数据的风险。而 AOF 丢失数据风险比较小,有灵活的刷盘策略,但是是通过执行命令的方式,所以恢复数据比较慢。在 Redis 4.0 更新了一种混合持久化,结合了 RDB 和 AOF 文件的优点。在 AOF 文件的前半部分包含了一个完整的快照数据,在 AOF 文件的后半部分记录自生成 RDB 文件之后的所有写操作命令,用来提高数据恢复的效率和丢失数据的风险。


RDB 执行的原理是什么?

Redis 在执行 bgsave 命令后主进程会 fork 一个子进程来共享内存空间。

共享的内存空间是通过页表来维护的,页表是维护虚拟内存和物理内存的关系映射,所以子进程拷贝主进程的数据很快,是纳秒级别的。完成 fork 以后子进程会写新的 RDB 来替换旧的 RDB文件。

fork 采用的是 copy-on-write 技术。当线程来读取数据时,可以读取共享内存的数据。当有写操作,则将共享内存的数据拷贝一份并进行修改,修改完成以后线程则读取最新修改后的数据。


AOF 执行的原理是什么?

AOF 是将写命令记录到日志文件中,当需要恢复数据,则直接讲写命令重新执行即可。由于是追加命令的方式,命令会越来越多,为了解决这个问题。Redis 使用了 AOF rewrite机制。会去除 AOF 中冗余的命令,生成一个新的文件,来减少 AOF 文件大小的目的

AOF rewirite 重写的原理是当执行一条 AOFRW 命令时,主进程会 fork 一个子进程将Redis 的快照数据全部重写到一个临时的AOF 文件中。当主进程继续执行写命令时,会写入一个 aof_buf ,还会写一份 aof_rewrite_buf 进行缓存。在子进程重写的时候,aof_rewrite_buf 会使用 pipe 发送给子进程,并追加到 AOF 临时文件中。

如果主进程有很多的写入命令,导致在重写期间子进程无法将 aof_rewrite_buf 的命令重新执行完,则 aof_rewrite_buf 剩下的数据由主进程处理。当主进程讲未执行完的数据处理完成后就会将临时文件覆盖原来的 AOF 文件,完成整个流程。

AOFRW 存在很多问题,比如 aof_rewrite_buf 和 aof_buf 的数据是重复的,带来额外的内存开销,并且主进程向 aof_rewrite_buf 写数据并发送到子进程,主进程处理子进程没有完成的 aof_rewrite_buf 剩下的数据都非常消耗 CPU。

所以阿里在 Redis 7.0 对 AOFRW 提出了优化,将 AOF 分成三种类型,BASE 代表基础 AOF,由子进程重写产生,INCR 代表增量 AOF,在 AOFRW 执行后创建。HISTORY 表示历史 AOF,每次 AOFRW 完成后将 BASE AOF 和 INCR AOF 转变为 HISTORY,会被 Redis 自动删除。这些文件通过文件清单来管理。在执行 AOFRW 后,主进程依然会 fork 一个子进程将进行重写,并且主进程会打开一个新的 INCR AOF,在子进程重写期间,主进程所有的数据变化都会写入到 INCR AOF。并且子进程的重写操作完全独立,重写操作完成后生成一个 BASE AOF 文件。通过文件清单将这两个数据进行连接就代表了此刻 Redis 的全部数据。通过 Redis 7.0 的优化,重写期间不需要 aof_rewrite_buf,去掉了对应的内存消耗。同时由于主进程和子进程之间也不需要数据传输和控制交互,对应的 CPU 开销也优化了。


怎么保证 Redis 的高并发高可用?

搭建主从节点提升高并发,并使用哨兵模式提升高可用,哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障转移和通知。


你们项目是采用什么样的集群?

我们项目当时采用的是一主两从三哨兵。一般单节点不超过10g内存,如果内存不足则可以通过给不同服务分配独立的 Redis 主从节点,尽量不做分片集群。因为分片集群维护起来比较麻烦,成本比较高,并且集群之间的心跳检测和数据通信会消耗大量的网络请求,并且不可以使用 Redis 的事物和 Lua 脚本。


那你们 Redis 集群脑裂是如何解决的?

当我们主节点信号不好时,哨兵没有感受到主节点的心跳,这时哨兵就会选取一个从节点来作为新的主节点,这时就存在两个主节点,就像大脑分裂一样。这样会导致客户端还在原先的主节点写数据,新节点无法同步数据。当网络恢复以后,哨兵就会将原先的主节点降为从节点,这时再从新的主节点同步数据,脑裂过程中写入的数据则都会丢失。

我们可以在 Redis 设置两个参数来解决脑裂问题。一个参数时设置最少的从节点数,必须要有一个从节点才能同步数据。第二个设置主从复制和同步的延迟时间,达不到要求的就拒绝请求,这样就可以避免数据丢失。


Redis 的数据过期策略有哪些?

Redis 的数据过期策略有两种,一种是惰性删除,还有一种是定时删除。

  • 惰性删除是每次查询 Key 时判断是否过期,过期则删除数据。
  • 定时删除是定期抽样部分 Key 判断是否过期,过期则删除数据。其中定时删除还分为两种模式,SLOW 和 FAST
    • SLOW 是高吞吐模式,默认每秒执行10次,每次不超过 25ms
    • FAST 是低延迟模式,执行的频率不固定,但两次间隔不能低于 2ms,每次耗时不能超过 1ms

Reids 的过期策略是两种结合:惰性删除加上定期删除进行配合使用。


Redis 的数据淘汰策略有哪些?

Redis 总共提供了八种数据淘汰策略。系统默认的数据淘汰策略是不删除任何数据,内存不足直接报错。还有一种是随机删除的数据淘汰策略。其他的配置有两个重要的概念,一个是 LRU ,一个是 LFU。

  • LRU 是最少最近使用,用当前时间减去最后一次访问的时间,这个值越大淘汰的优先级越高。按照最少最近使用有两种数据淘汰策略,一种是所有 Key 按照 LRU 来进行淘汰,一种是只有设置了 TTL 的 Key 按照 LRU 进行淘汰。
  • LFU 是最少频率使用,会统计每个 Key 的访问频率,访问频率越低淘汰的优先级越高。按照最少频率使用有两种数据淘汰策略,一种是所有 Key 按照 LFU 来进行淘汰,一种是只有设置了 TTL 的 Key 按照 LFU 进行淘汰。

还有两种淘汰策略就是对设置了 TTL 的 Key 进行随机淘汰,对设置了过期时间的 TTL,比较 TTL 的剩余值,TTL 越小越先被淘汰。


那你们系统中的数据淘汰策略是如何使用的?

  • 如果系统中没有冷热数据的区分,访问频率差别不大,则建议使用 allkeys-random,随机选择淘汰。
  • 如果系统中有冷热数据区分,则建议使用 allkeys-lru,把最近访问的数据留在缓存中。
  • 如果系有短时高频的数据,则使用 allkeys-lfu
  • 如果业务中有指定的需求,置顶的数据不过期删除,则可以使用 volaitle-ttl,只对设置 TTL 的 Key 进行删除。

Redis 是单线程的,但是为什么还那么快?

  • Redis 是基于内存的数据库,内存的读写速度非常快。
  • 执行命令采用单线程,避免不必要的上下文切换。
  • 使用多路 IO 复用模型,这种技术允许单个线程同时处理多个IO操作,单个IO操作的阻塞不会影响其他IO操作。这种IO操作提高了IO操作的效率,使得 Redis 能够在高并发环境下保持高性能‌

你知道Redis事务机制吗?

Redis事务其实是把一系列Redis命令放入队列,然后批量执行,执行过程中不会有其它事务来打断。不过与关系型数据库的事务不同,Redis事务不支持回滚操作,事务中某个命令执行失败,其它命令依然会执行。

为了弥补不能回滚的问题,Redis会在事务入队时就检查命令,如果命令异常则会放弃整个事务。

因此,只要程序员编程是正确的,理论上说Redis会正确执行所有事务,无需回滚。

如果事务执行一半的时候Redis宕机怎么办?

Redis有持久化机制,因为可靠性问题,我们一般使用AOF持久化。事务的所有命令也会写入AOF文件,但是如果在执行EXEC命令之前,Redis已经宕机,则AOF文件中事务不完整。使用 redis-check-aof 程序可以移除 AOF 文件中不完整事务的信息,确保服务器可以顺利启动。

Redis在项目中的哪些地方有用到?

缓存

  • 原理:将经常被访问的数据存储在 Redis 中,当客户端请求数据时,先从 Redis 中查询,如果存在则直接返回,避免了从后端数据库或其他数据源重复获取数据的开销,大大提高了系统的响应速度。
  • 应用案例:在电商网站中,商品详情页、热门商品列表等数据通常会被缓存到 Redis 中,以应对高并发的访问请求。

消息队列

  • 原理:Redis 提供了多种数据结构和命令,可用于实现消息队列。生产者将消息发送到 Redis 的列表或有序集合等数据结构中,消费者从这些数据结构中获取消息并进行处理。
  • 应用案例:在分布式系统中,如订单系统、物流系统等之间的异步消息传递,可使用 Redis 消息队列来实现。

分布式锁

  • 原理:利用 Redis 的原子命令和数据结构特性,如 SETNX 命令(SET if Not eXists),可以实现分布式锁。当一个客户端需要获取锁时,通过 SETNX 命令在 Redis 中设置一个键值对,如果设置成功,则表示获取了锁,其他客户端则无法获取该锁,直到锁被释放。
  • 应用案例:在分布式系统中的资源竞争场景,如多个节点同时对同一数据库记录进行修改时,可使用 Redis 分布式锁来保证同一时刻只有一个节点能够进行操作。

计数器

  • 原理:Redis 的原子操作命令可以方便地实现计数器功能,如 INCR、DECR 等命令可以对一个键的值进行原子性的自增或自减操作。
  • 应用案例:在社交媒体平台中,用于统计文章的点赞数、评论数、转发数等;在电商平台中,用于统计商品的销量等。

实时数据统计

  • 原理:Redis 提供了多种数据结构,如哈希表、有序集合等,可以方便地对实时数据进行统计和分析。通过对数据的实时更新和聚合操作,可以快速获取到各种统计指标。
  • 应用案例:在网站流量统计中,可使用 Redis 记录每个页面的访问次数、用户的访问时间等信息,并实时计算出网站的 PV、UV 等指标。

分布式会话管理

  • 原理:在分布式系统中,用户的会话信息通常需要在多个节点之间共享。Redis 可以将会话数据存储在内存中,并通过设置合适的过期时间来管理会话的生命周期。
  • 应用案例:在大型网站或微服务架构中,当用户在不同的服务器或服务之间切换时,通过 Redis 存储和共享会话信息,保证用户的登录状态和会话数据的一致性。

任务队列

  • 原理:将需要执行的任务存储在 Redis 中,多个工作线程或进程可以从任务队列中获取任务并执行。通过对任务队列的监控和调度,可以实现任务的异步处理和负载均衡。
  • 应用案例:在数据处理系统中,如数据采集、数据清洗、数据分析等任务,可以使用 Redis 任务队列来进行调度和分配。

地理位置信息存储与查询

  • 原理:Redis 的 Geo 数据结构可以方便地存储地理位置信息,并提供了一系列的地理位置相关的操作命令,如计算两个地点之间的距离、获取指定范围内的地点等。
  • 应用案例:在基于位置的服务中,如附近的商家推荐、打车软件中的附近车辆查找等场景中,可使用 Redis 的 Geo 数据结构来实现。

虚拟机

JVM有什么特性?

JVM 最大的特性是跨平台,对于 C/C++ 语言来说会将源代码编译成 CPU 能够识别的机器码来执行,不同的系统需要重新编译连接。而 Java 语言通过 Javac 将源文件编译成字节码之后,可以通过不同平台的 JVM 将字节码文件实时解释成机器码文件。并且 Java 还是半编译半解释型语言。也可以通过执行引擎的编译器将热点代码进行优化,直接转变为机器码。


JVM实现了哪些功能?

  • 解释与运行:通过执行引擎的解释器将字节码实时解释成CPU能够执行的机器码
  • 即时编译:通过执行引擎的编译器将热点代码进行优化,将字节码直接转化为机器码放在方法区中存储,提高执行效率。
  • 内存管理:通过运行时数据区自动的为对象、方法分配内存空间,通过执行引擎的垃圾回收器自动实现垃圾回收。

JVM由哪些部分组成,如何运行?

JVM 主要由类加载器,运行时数据区,执行引擎,本地库接口组成。Java 源文件通过 Javac 将源代码编译成字节码,然后通过类加载器子系统加载到内存中,然后通过运行时数据区将加载到内存中的字节码自动分配内存。通过执行引擎的解释器将字节码翻译成机器码,在交由 CPU 去执行。如果调用了 C语言,这时就需要调用本地接口。


请详细说明一下 JVM 运行时数据区?

运行时数据区可以分为线程共享区和线程私有区。

  • 线程共享区有堆和本地内存,堆是主要数据存储的地方,存储对象和数组。是垃圾回收器管理的主要部分。本地内存不是虚拟机运行时数据区的一部分,是虚拟机直接向系统申请的内存区域,包含了方法区和直接内存。方法区是存放类的信息,常量、方法、字段、静态变量、即时编译优化后的代码。直接内存常用于 NIO 操作,用于数据缓冲区,分配成本高但是读写性能也高。
  • 线程私有区有虚拟机栈,本地方法栈,程序计数器。虚拟机栈是程序运行的地方,里面存储的是栈帧,栈帧里面存储的是局部变量表、操作数栈、动态链接、方法出口等。本地方法栈与虚拟机栈功能相同,区别是本地方法栈是 Java 调用非 Java 代码的接口。程序计数器存放的是当前线程所执行字节码的行数。

能给我详细介绍一下方法区吗?

  • 方法区是各个线程共享的内存区域,与直接内存一起存储与本地内存中。
  • 主要存储的是被虚拟机加载类的元数据信息。包括类的结构、方法、字段、常量,即时编译器优化的代码。
  • 在 JDK7 方法区称为永久代,并且数据放在堆中,如果加载类数据太多很容易导致 OOM。在 JDK8 中就将方法区的实现放在了本地内存的元空间中,将字符串常量放入了堆中。这样元空间的大小就不受 JVM 限制,并且不需要GC,提升了性能。
  • 如果方法区中无法存放,则会抛出 OutOfMemoryError:Metaspace

字符串常量池和运行时常量池有什么关系?

字符串常量池是存放于 JVM 的堆中,用于存储创建对象时的字符串的字面量引用,主要是为了避免程序在运行时创建大量内容相同的字符串对象,从而节省内存和提高性能‌。

运行时常量池是存放于方法区中,用于存储符号引用和字面量,在编译时就确认了。当类被加载到 JVM 中时,class文件中的常量池内容会被加载到运行时常量池中。


能给我详细介绍一下 Java 堆吗?

Java 堆是线程共享的区域,主要存储对象和数组,是垃圾回收器主要工作的地方。内存不足会报 OutOfMemoryError:Java heap space

主要由年轻代和老年代构成。年轻代有伊甸园区,From区和To区,存放一些年轻的对象;老年代存放的是一些大对象或年老的对象。


什么是虚拟机栈

每个线程运行所需要的内存,称为虚拟机栈,遵循后进先出原则。并且每个栈由多个栈帧组成,对应着方法每次调用所占的内存,活动栈帧只有一个,对应着正在执行的方法。


堆和栈的主要区别是什么?

最本质的区别堆是存储单位,存储对象和数组;栈是运行单位,运行时调用局部变量和房变量。

栈的生命周期比较短,随着方法的入栈出栈终结;而堆的生命周期比较长,知道被垃圾回收器回收。

栈内存是线程私有的,堆内存是线程共享的。当栈空间不足时报 StackOverFlowError,堆内存不足时报 OutOfMemoryError


什么是类加载器,类加载器有哪些?

类加载器将字节码文件加载到 JVM 中,让 Java 程序能够运行起来。常见的类加载器由四个

  • 引导类加载器,是最顶级的加载器,由 C++ 编码实现,主要加载 JAVE_HOME/jre/lib 目录下的库类。
  • 拓展类加载器,是引导类加载器的子类,由 Java 编码实现,主要加载 JAVE_HOME/jre/lib/ext 目录下的库类。
  • 应用类加载器,是拓展类加载器的子类,由 Java 编码实现,主要加载 Classpath 下的类,也就是自己编写的 Java 类。
  • 自定义类加载器,开发者自定义类继承 ClassLoader,实现自定义加载规则。

什么是双亲委派机制,为什么使用这个机制?

如果一个类加载器收到了类加载的请求,首先不会自己去加载,而是把请求给父类加载器,而且每个层级的类加载器都是这个规则。所以最终类的加载都要到顶层的引导类加载器来加载,只有引导类加载不了这个加载请求,子类加载器才会尝试自己去加载。

使用双亲委派机制可以防止 Java 核心库的 API 被篡改,而且可以避免一个类被重复加载。


那你知道类加载器的执行过程吗?

类从加载器加载到虚拟机中,生命周期包括了加载、连接、初始化、使用、卸载。其中连接分为了验证、准备、解析。

首先将类加载到虚拟机中,然后验证来保证加载类的正确性,准备阶段为类分配内存,并设置类变量的初始值,解析阶段将类中的符号引用转化为直接引用。初始化阶段对类的静态变量和静态代码块执行初始化操作。使用就是对 new 完的类进行调用,当数据为空后没有引用关系则将类进行卸载。


能介绍一下垃圾回收器的主要功能吗?

垃圾回收器的主要功能是自动回收不再使用的对象,以防止内存泄露,并优化内存使用,从而保证程序稳定的运行。

基本功能

  • 内存分配:当程序创建对象时,垃圾回收器会在堆内存中找到足够的空闲空间来存储这些对象。
  • 垃圾回收:自动检测并回收不再被程序引用的内存区域,确保系统资源有效利用,防止内存泄露。检测垃圾的算法有两种,引用器计数法和可达性分析算法,引用器计数法没法解决循环引用的依赖问题,所以 Java 使用的是可达性分析算法。
  • 系统稳定:通过定期进行垃圾回收,避免系统长时间运行的程序占用内存时间过长导致的系统资源耗尽和性能下降问题。
  • 简化编程:消除了手动管理内存的复杂性,使得开发者可以更专注业务逻辑实现,不需要关注内存分配和释放的细节。

强引用,软引用,弱引用,虚引用各有什么区别?

  • 强引用是普通的引用方式,表示一个对象处于有用且必须的状态,即使内存不足也不会回收
  • 软引用表示一个对象处于有用且非必须的状态,会在内存不足的时候进行回收。
  • 弱引用表示一个对象处于可能有用且非必须的状态,在 GC 时就会被回收。
  • 虚引用表示一个对象处于无用状态,任何时候都会被回收。

垃圾回收算法有哪些?

垃圾回收算法主要包含标记清除算法,标记整理算法、复制算法、分代收集算法

  • 标记清除算法将垃圾回收分为标记阶段和清除阶段,标记阶段从根结点开始,标记所有可达对象,在清除阶段清除所有没有被标记的对象。效率高,但是有磁盘碎片,内存不连续。
  • 标记整理算法分为标记,清除,整理三个阶段,会将标记清除后存活的对象移动到内存另一段。相比于标记清除算法,效率低,但是没有磁盘碎片,内存连续。
  • 复制算法将内存分为两块,每次只使用一块。在垃圾回收时,将存活的独享复制到另一块内存中,然后清除当前块的内容。虽然没有磁盘随盘,但是造成了内存浪费,回收效率高于标记整理算法但是低于标记清除算法
  • 分代收集算法根据对象的生命周期内存划分为年轻代和老年代,并根据各代的特征采用最合适的收集算法。

详细说明一下分代收集算法?

JDK8版本时,堆被分为了两个部分,一个是新生代,一个是老年代,默认空间占用比例是1:2

新生代内部又被分为三个区域。伊甸园区,FROM区和TO区。默认空间占比为8:1:1

具体的工作流程是这样的:

  • 新创建的对象先放到伊甸园区,当伊甸园区和 FROM 区满时会触发 YoungGC,将存活对象采用复制算法复制到 TO区 并年龄加一,复制完成后伊甸园区和FROM区都会释放内存,并把TO 区改为FROM 区。
  • 当经过一段时间内存又不足时触发 YoungGC,重复上面的步骤,当TO 区存活的对象超过15岁后则晋升为老年代。如果幸存区内存不足或大对象会提前晋升为老年代。
  • 当老年代满了以后会触发FullGC,同时收集年轻代和老年代,这时是只存在FullGC的线程执行,其他线程都会被挂起。我们需要在程序中尽量避免FullGC出现。

垃圾回收器有哪些,你们项目时如何选择的?

垃圾回收器按照工作模式划分可以分为串行垃圾回收器,并行垃圾回收器,并发垃圾回收器。

  • 串行垃圾回收器:Serial 用于新生代,使用复制算法。Serial Old 作用于老年代,采用标记整理算法。在垃圾回收时,只有一个线程在工作,其他线程都需要停下来等待垃圾回收的完成(STW)。适用于内存堆比较小的个人电脑,是 JDK 在 Client 模式下默认使用的垃圾回收器。
  • 并行垃圾回收器:Parallel Scavenge 作用于新生代,采用复制算法。Parallel Old 作用于老年代,采用标记整理算法。在垃圾回收时,有多个线程在工作,并且其他线程都需要停下来等待垃圾回收的完成。是吞吐量优先的收集器,是 JDK在 Server 模式下默认使用的垃圾回收器。
  • 并发垃圾回收器:Parallel New 作用于新生代,采用复制算法。CMS 作用于老年代,采用Serial Old作为备用,采用标记清除算法。是一款响应时间优先的收集器,停顿时间短,用户体验好。但是由于无法处理浮动垃圾,所以需要使用 Seiral Old 作为备用。并且由于浮动垃圾的存在,还会导致频繁的 Full GC,从而导致更大的时间停顿。从 JDK9 开始将 CMS 标记为过时,并且默认使用了 G1 垃圾回收器。

数据库

如何选择存储引擎?

  • InnoDB:是 MySQL 的默认存储引擎,支持事务和外键。如果对事务的完整性有比较高的要求,并且需要在并发的情况下要求数据的一致性,数据除了插入和查询还有很多的更新删除操作,选择 InnoDB 比较合适。
  • MyISAM:如果应用是以读操作和插入操作为主,只有很少的更新和删除操作,并且对事务完整性、并发性要求不高,可以选择 MyISAM。
  • Memory:将所有数据保存在内存中,访问速度快,通常用于临时表和缓存。但对表的大小由限制,太大的表无法缓存在内存中,而且没法保证数据的安全性。

InnoDB 引擎和 MyISAM 引擎的区别

  • InnoDB 引擎支持事务和外键,而 MyISAM 引擎不支持事务和外键。
  • InnoDB 引擎有表锁和行锁,而 MyISAM 引擎只有表锁。

什么是索引?

索引是帮助数据库快速获取数据的数据结构。通过索引可以直接查询到数据而不用进行全表扫描,提高数据查询效率,降低数据 IO 成本;通过索引对数据排序可以降低数据排序的成本,降低 CPU 的消耗。


索引底层数据了解过吗?

MySQL 默认存储引擎 InnoDB 采用的是 B + 树的数据结构来存储索引。B + 树相比于二叉树,层级更少,而且不会造成数据倾斜而退化成链表的情况。相比于 B - 树 ,B + 树磁盘读写效率更高,叶子节点存储索引和数据,非叶子节点存储索引和指针。相比于 Hash 索引,B + 树支持范围查询和排序。


B - 树和 B + 树的区别是什么?

  • 在查询的时候 B + tree 查找效率更稳定。B - tree 非叶子节点和叶子节点都会存储数据,而 B + tree 所有的数据只存储在叶子节点上。
  • 在进行范围查询的时候 B + 树的效率更高,因为B + 树的数据都在叶子节点上存储,并且叶子节点是一个双向链表。

什么是聚簇索引,什么是非聚簇索引?

  • 聚簇索引主要是数据和索引放在一起,B + 树的叶子节点保存了整行数据,并且只有一个,一般情况下主键作为聚簇索引。
  • 非聚簇索引是数据和索引分开存储,B + 树的叶子节点保存了对应的主键,可以有多个。一般自己定义的索引都是非聚簇索引。

什么是回表查询?

通过二级索引(非聚簇索引)找到对应的主键值,然后通过主键值找到聚簇索引中所对应的整行数据,这个过程称为回表查询。


什么是索引覆盖?

当我们使用 select 查询语句使用了索引,并且返回的列能够在索引中全部找到。

  • 当我们使用主键进行查询,会直接走聚簇索引进行查询,一次索引扫表,直接返回数据,性能高。
  • 当我们使用非聚簇索引查询数据时,返回的列中没有创建索引,就会通过非聚簇索引查找到对应的主键值,尽量避免产生回表查询。

什么是索引下推

索引下推是一种数据库查询优化技术,将数据过滤下推到存储引擎层面进行处理,从而减少不必要的数据传输和读取。比如当我们用户信息,我们将名称和年龄作为联合索引进行查询,name like ‘郑%’ and age = 20.当我们没有用索引下推时,存储引擎会先查询出以郑开头的数据,再通过回表查询去查询满足年龄为20岁的用户。而使用索引下推,就可以在回表之间就将联合索引的条件都满足,这样就能减少回表查询的次数。减少了数据读取,提高了查询效率。


MySQL 有哪些锁?

  • 按照锁的机制可以分为悲观锁和乐观锁。
  • 按照锁的兼容性可以分为共享锁和互斥锁。
  • 按照锁的颗粒度可以分为全局锁,表锁和行锁。

元数据锁是什么?

元数据锁主要是维护表元数据的一致性。当一张表进行增删改查的时候,加入元数据共享锁,对表结构的查询共享,对表结构修改互斥。当对表结构进行变更时,加入元数据排他锁,不让增删改查影响到元数据的变更,保证了数据的正确性。


意向锁是什么?

意向锁是为了支持 InnoDB 的多颗粒度,解决表锁和行锁共存的问题。主要是为了避免在增删改查执行时,加的行锁于表锁冲突。如果没有意向锁,我们需要在便利表中所有的数据行来判断是否有行锁。有了意向锁这个表级锁之后,我们直接判断是否有意向锁就知道数据行是否被锁定。


InnoDB 中的行锁是如何实现的?

一个查询的事物,查询条件为大于等于19,如果有ID为19的数据,则会对19这条数据加上行锁,大于19的数据加上临键锁。行锁加上临键锁的集合就是临键锁。当其他事物在查询的时候不会阻塞,执行增删改查语句不在临键锁范围内也不会阻塞。但要修改临键锁的范围则会阻塞,只有事物在提交之后才可以执行。如果19的数据不存在,则会优化为间隙锁,不带条件查询则会升级为表锁。


InnoDB 的存储结构你了解吗

InnoDB 的存储结构由表空间,段,区,页和行组成。

  • 表空间是 InnoDB 结构的最高层,一个 MySQL 有多个表空间,用于存储记录和索引等数据
  • 段分为数据段、索引段、回滚段。 InnoDB 是索引组织表,其中的数据段就是 B+ 树叶子节点,索引段为 B + 树段非叶子节点。段用来管理多个区。
  • 区是表空间的单元结构,每个分区大小为 1MB。每个分区下有 64个连续的页,每个页大小为 16K。为了保证页数据的连续性,InnoDB 每次从磁盘会申请 4-5个区。
  • InnoDb 数据是按照行存储的。在行中会有两个隐藏字段
    • Trx_id:每次对某条记录进行改动时,都会把对应的事务id赋值给trx_id隐藏列。
    • Roll_pointer:每次对某条引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

事务的特性是什么?

ACID,分别是原子性,一致性,隔离性,持久性。我举个例子

  • A 向 B 转账 500,转账成功, A 扣除 500,B 增加 500 。原子性体现在要么都成功,要么都失败
  • 一致性体现在在转账的过程中,数据要一直。A 扣除了 500,B 必须增加 500 。
  • 隔离性体现在在转账的过程中,A 向 B 转账不能受其他事物的干扰
  • 持久性体现在事务在提交之后,要把数据持久化。

并发事务带来哪些问题?

  • 脏读:一个事务读到另一个事务还没有提交的事务。
  • 不可重复读:一个事务先后读取同一条事务,但两次读取的数据不同。
  • 幻读:事务还没有提交之前第一次查询的数据和第二次查询的数据结果集不同。

如何解决这些问题?

解决方案是对事务进行隔离。MySQL支持四种隔离级别。读未提交 (RU) 解决不了任何问题。串行化可以解决前面的所有问题,但是性能很低。读已提交 (RC)能解决脏读的问题,可重复读 (RR)可以解决脏读,不可重复读和幻读问题,但是当快照读和当前读一起使用的时候会有幻读问题,这也是MySQL 的默认隔离级别。


什么是快照读和当前读

  • 快照读:简单的 select 就是快照读,读取的是数据的可见版本,有可能是历史数据。并且在每个隔离级别下读取的数据是不同的。读已提交读取的是其他事务提交以后的数据,可重复读每次查询的都是第一次查询的数据。所以在快照读的情况下解决了幻读的问题。
  • 当前读:读取的是数据库的最新数据,读取时还要保证其他并发事务不能修改当前记录,会对读取的数据加锁。我们常用的 select for update ,update,insert,delete 就是当前读。

MySQL 是如何解决幻读问题的?

  • 在快照读的情况下,MySQL 是通过 MVCC 来解决幻读问题的
  • 在当前读的情况下,MySQL 是通过临键锁来解决的。临键锁是行锁与间隙锁的结合。

你在项目中遇到过由于幻读引发的死锁问题吗

  • 并发事务操作顺序问题:当多个事务并发执行时,如果它们对同一数据范围进行操作,并且操作顺序不当,就可能因幻读而导致死锁。例如,事务 A 先查询了一个范围的数据,然后事务 B 插入了一条符合该范围条件的新记录,接着事务 A 再次查询该范围时出现幻读。如果此时事务 A 试图对新出现的记录进行更新或删除操作,而事务 B 又恰好对该记录进行了其他操作,如再次更新或删除,就可能导致两个事务相互等待对方释放锁,从而引发死锁。
  • 间隙锁与幻读的交互:在一些数据库中,为了防止幻读,会使用间隙锁。间隙锁会锁定一个范围的索引区间,而不仅仅是具体的行。当多个事务同时对有重叠部分的索引区间加间隙锁时,就可能发生死锁。例如,事务 A 对一个范围加了间隙锁,事务 B 也对包含部分相同范围的区间加了间隙锁,然后事务 A 试图获取事务 B 已锁定的部分间隙锁,事务 B 也试图获取事务 A 已锁定的部分间隙锁,这样就会导致两个事务互相等待,形成死锁。

事务的实现原理是什么?

重做日志保证了事务的持久性,回滚日志保证了事务的原子性和一致性。MVCC 和锁保证了事务的隔离性。

  • 重做日志(redo log):采用了 WAL 技术,先写日志,在写磁盘,只有日志写成功了才会提交事物。这里的日志就是 redo log。当发生宕机数据未刷新的到磁盘后,就通过 redo log 来恢复数据。
  • 回滚日志(undo log):回滚日志可以认为当执行一条 delete 语句时,undo log 会记录一条对应的 insert 记录;当执行一条 update 语句则会记录一条相反的 update 语句。当执行 rollback 时就可以通过 undo log 的逻辑记录并进行回滚。
  • MVCC 是通过数据库中的隐式字段、undo log 和 readView 实现的。

redo log 和 undo log 区别

  • redo log 是物理日志,记录的是物理页的变化,服务宕机用来进行对数据的同步。
  • undo log 是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据。

事务中的隔离性是如何保证?

事务的隔离性是由 MVCC 和锁保证的。MVCC 保证了快照读的事务隔离性,维护了一个数据的多个版本。它的底层实现主要分为了三个部分,第一个是隐藏字段,第二个是 undo log ,第三个是 readView 读视图。

MySQL 给每个表都设置了隐藏字段。一个每次操作都会自增的事务id,一个指向上一个事务的回滚指针。undo log 通过记录回滚数据,存储老版本数据,在内部形成一个版本链,在多个事务并行操作某一行记录时,会记录不同事务修改数据的版本,并通过回滚指针形成一个链表。通过 ReadView 解决一个事务查询的版本问题,并且不同隔离级别下访问的结果不同。在 RC 隔离级别下快照读读取的是事务每次提交的数据,RR 隔离级别下每次查询的都是第一次查询的数据。


用过MySQL的分库分表吗?

项目背景 当项目业务数据逐渐增多,业务发展迅速,单表数据量达到1000w或20G以后,磁盘IO,网络IO,文件IO太多导致IO遇到瓶颈;聚合查询,连接数太多导致CPU遇到瓶颈,优化已经解决不了性能问题(主从读写分离,创建索引)。

拆分的策略

  • 垂直分库:以表为依据,根据不同业务将不同表拆分到不同库中,在高并发的情况下提升了磁盘IO和连接数。
  • 垂直分表:以字段为依据,根据字段属性不同将不同字段拆分到不同表中,使冷热数据分离,减少IO过渡争抢,两表互不影响。
  • 水平分库:将一个库的数据拆分到多个库中,解决了单库数据量大,高并发的性能瓶颈问题,提高了系统的稳定性和可用性。
  • 水平分表:将一个表的数据拆分到多个表中,避免单一数据量过大而产生的性能问题,可以减少由于IO争抢导致锁表的几率。

分库之后带来的问题:分布式事务一致性问题,跨界点关联问题,跨界点分页、排序函数,主键重复等问题。


💡思考:说说你对数据库优化的经验

数据库的优化可以考虑这几个方面,合理的表设计和字段、索引优化、SQL语句优化、读写分离,如果数据量超过2000w则可以考虑分库分表

💡思考:创建表的时候,你们是如何优化的呢?

这个我们主要参考的阿里出的那个开发手册《嵩山版》,就比如,在定义字段的时候需要结合字段的内容来选择合适的类型,如果是数值的话,像tinyint、int 、bigint这些类型,要根据实际情况选择。如果是字符串类型,也是结合存储的内容来选择char和varchar或者text类型

💡思考:创建表的时候,你们是如何优化的呢?

  • 针对于数据量较大,且查询比较频繁的表建立索引。
  • 针对于常作为查询条件、排序、分组操作的字段建立索引。
  • 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
  • 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。
  • 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。
  • 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率
  • 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。

💡思考:你平时对 SQL 语句做了哪些优化呢?

  • SQL语句优化SELECT语句务必指明字段名称(避免直接使用select * ),避免回表查询
  • SQL语句要避免造成索引失效的写法。
    • 联合索引如果出现范围查询(>,<),则范围查询后面的索引列将会失效,尽可能的使用 <=>=
    • 违反最左前缀法则会造成索引失效,最左前缀与联合索引顺序有关,与SQL条件编写的先后顺序无关。
    • 不能再索引列上做运算操作,会造成索引失效。
    • 不在字符串上加单引号,MySQL查询优化器会自动类型转换,造成索引失效。
    • 避免使用 <> 或者 != 操作符。不等于操作符会导致查询引擎放弃查询索引,引起全表扫描。通过把不等于操作符改成 or,可以使用索引,避免全表扫描
    • 如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效。
    • 避免在where子句中对字段进行表达式操作和函数操作,会导致索引失效。
  • 尽量用union all代替union,union会多一次过滤,效率低
  • Join优化能用 inner join 就不用 left join right join,如必须使用一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序。

框架

Spring 框架中的单例 Bean 是线程安全的吗?

不是线程安全的,Spring 中有个 @Scope 注解,默认是单例的。当 Spring 的 Bean 对象是无状态的对象,则是线程安全的,如果 Bean 中定义了可修改的成员变量,则需要考虑线程安全问题。可以改变 @Scope 注解,声明为多例模式;或者对共享变量加锁解决,或者使用 ThreadLocal 让每个线程都有自己独立的副本;使用一些线程安全的数据结构。


Spring Bean 的生命周期

Spring 中的循环引用


什么是 AOP,你们项目中使用到了 AOP 吗?

AOP 是面向切面编程。将那些与业务没有关系,但对多个对象产生影响的行为抽取为公共模块来进行复用。

我们系统中的日志文件,菜单权限,字典翻译,缓存处理等等都是通过 AOP 来进行实现的。通过切点表达式获取日志记录的方法,然后通过环绕通知获取请求方法的参数。比如类信息,方法、注解、请求方式等,获取这些参数以后保存到数据库。


Spring 中的事务是如何实现的?

Spring 提供了多种事务管理方式,主要包括编程式事务管理、声明式事务管理。

编程式事务管理:通过编程的方式管理事务,即通过编程方式在代码中显式地声明事务的开始、提交及回滚。

声明式事务管理:通过 AOP 特性,对事务的管理通过注解或者配置文件的方式进行声明,从而使事务管理成为语言无关的行为。


Spring 中有哪些事务传播行为?

当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行。 正常来说有几种解决方案:

  1. 融入事务:直接去掉serviceB中关于开启事务和提交事务的begin和commit,融入到serviceA的事务中。问题:B事务的错误会引起A事务的回滚。
  2. 挂起事务:如果不想B事务的错误引起A事务的回滚,可以开启两个连接,一个执行A一个执行B,互不影响,执行到B的时候把A挂起新起连接去执行B,B执行完了再唤醒A执行。
  3. 嵌套事务:MySQL中可以通过给B事务加savepoint和rollback去模拟嵌套事务,把B设置成伪事务。B 事务失败只会影响自己,但 A 事务失败会都回滚。

Spring 中的事务传播行为

  • PROPAGATION_REQUIRED(需要):A如果存在事务,则B融入A事务,A如果没有事务,则B自己开启一个事务,适用于大部分的修改操作使用
  • PROPAGATION_SUPPORTS(支持):A如果存在事务,则B融入A事务,A如果没有事务,则B非事务运行,适用于大部分的查询操作
  • PROPAGATION_MANDATORY(强制性):A如果有事务,则B融入A事务,A如果没有事务,则B抛出异常。
  • PROPAGATION_REQUIRES_NEW(需要新的):不管 A 有没有事务,都会开启一个新的事务。
  • PROPAGATION_NOT_SUPPORTED(不支持):A 没有事务则 B 以没有事务的方式运行。如果A有事务,则把A事务挂起,B以非事务的方式运行。
  • PROPAGATION_NEVER(从不):如果 A 没有事务则正常运行,A有事务则 B 事务抛出异常。
  • PROPAGATION_NESTED(嵌套的):如果 A 有事务则 B 以嵌套事务的方式运行。如果 A 没有事务则 B自己开启一个事务。

Spring 事务失效有哪些场景

因为事务是由 Spring 的 AOP 来实现的,如果类没有被 Spring 管理,就不能使用 Spring 的事务,如果事务不是 public 修饰则无法进行动态代理,如果是非事务方法调用事务方法,则拿到的类不是动态代理类,也无法使用 Spring 事务。

还有一种情况是 Spring 没有感受到异常或者异常类型和事务声明的异常不符合,事务也会失效。

还有一种情况是事务传播行为不对,比如在子事务中使用了 REQUIRES_NEW 的事务传播行为,则会在子事务中开启一个新的事务,则子事务的报错,原先事务是不会回滚的,因为不在一个事务中。

微服务

Spring Cloud 有哪些组件

早期 Spring Cloud 有五大组件:负责服务与注册的 Eureka,负载均衡的 Ribbon,服务调用的 Feign,服务保护的 Hystrix,服务网关 Zuul。随着 Spring Cloud Alibaba 的兴起,我们项目用了 注册与配置中心 Nacos,服务保护 Sentinel,服务网关 GateWay。


Nacos 与 Eureka 有什么相同点和不同点?

Nacos 与 Eureka 都支持服务注册与发现,都通过心跳来检测服务实例的健康状态。

不同点:

Nacos 将服务实例分为临时实例和非临时实例。临时实例采用心跳模式,检测不正常会被剔除。非临时实例采用主动检测模式,检测不正常不会被剔除,但是会被标记为不健康。Eureka 只支持临时模式,并通过心跳机制来判断服务实例的健康状态,当连续检测到几次心跳未接收则默认服务实例下线。

Nacos 支持定时拉取服务和主动推送变更消息两种模式,服务列表更新更及时。Eureka 只支持定时拉取。

Nacos 集群默认采用 AP 高可用方式,但集群存在非临时实例时,采用 CP 强一致模式;Eureka 采用 AP 高可用方式。

Nacos 还支持配置中心,Eureka 只有注册中心。


Nacos 服务注册表的结构是怎么样的 ?

Nacos 采用了数据的分级存储模型。最外层的是 namespace,用来隔离环境。然后是 Group,用来对服务分组。接下来就是服务,一个服务包含多个实例,有可能处于不同机房。因此服务下有多个集群,集群下是不同的实例。

对应到 Java 代码中,Nacos 采用了一个多层的 Map。结构为 Map<String,Map<String,Service>>。其中最外层 Map 的 key 就是 namespace,值是一个 Map。内层 Map 的 key 是 group 拼接 serviceName。值是 Serivce 对象。Service 内部都是一个 Map。Key 是集群名称,值是集群对象。集群内部维护了实例的集合。


Nacos如何支撑阿里内部数十万服务注册压力?

  • 异步处理机制:Nacos 接收到注册请求时,不会立即写数据到注册表,而是将请求服务添加到阻塞队列,然后立即响应客户端。Nacos 通过线程池读取阻塞队列中的任务,实现了实例的异步更新,提高了并发写的能力,避免以为直接写阻塞导致的性能瓶颈。
  • 服务实例的持久化和缓存:Nacos 区分临时实例和永久实例。临时实例会被保存在服务端的内部缓存中,不会持久到磁盘;而永久实例不仅保存到注册表中,而且会被持久化到磁盘中。这样设计可以在永久实例下线时,系统能够快速恢复。

Nacos如何避免并发读写冲突问题?

首先 Nacos 采用的是异步处理注册请求的方式,当接收到注册写请求后不会同步的写到注册表中,而是添加到阻塞队列。然后通过单线程的线程池获取任务,异步的完成注册表的更新。这种提升了并发写的能力。

其次 Nacos 更新实例时采用的是 CopyOnWrite 的思想。先将旧的一份数据拷贝出来,然后在新的数据上进行更新,添加,删除操作。在更新的过程中,Nacos 还可以读取原有的数据。在更新完成后,将新的实例数据替换成旧的实例数据,从而避免的读写冲突。但是 CopyOnWrite 保证的是数据最终一致性,在更新的过程中 Nacos 读取的还是旧数据。

最后局部加锁避免冲突:在添加实例时,Nacos 会对单个服务加锁,而不是对整个注册表加锁。这种局部加锁的方式避免了不同服务之间的并发写冲突,可以使多个服务并行执行注册操作。


什么是 CopyOnWrite?

CopyOnWrite 是一种编程思想,写入时复制。其核心思想是多个调用者同时请求相同资源时,他们共享这份资源。当某个调用者尝试修改共享资源时,系统为该调用者复制一份专用副本进行修改,其他调用者继续访问原始资源。这种方式主要解决高并发情况下数据一致性问题,同时提高读操作的效率。

  • 读写分离:读操作可以直接执行,写操作通过复制一份副本从而解决了读写冲突。
  • 最终一致性:保证的是数据的最终一致性。在写操作完成后都可以读取最新数据,但是在写操作还没有完成期间,读取的是原先的数据。

读写效率高,适用于读多写少的情况。如果复制的数据多则会造成内存消耗巨大,没有保证数据的实时一致性。


CopyOnWrite 有什么应用场景

Nacos 更新实例时使用的就是 COW 思想。在缓存更新的场景下,新闻发布系统,搜索引擎的黑白关键字等等。


你们项目的负载均衡是如何实现的?

我们项目的负载均衡使用的是 SpringCloud 的 Ribbon 组件实现,Feign 的底层已经自动集成了 Ribbon,当发起远程,Ribbon 先从注册中心拉起服务地址列表,然后按照一定的路由策略选择一个发起远程调用。当时我们项目使用的策略是轮询。


什么是服务雪崩,怎么解决这个问题?

服务雪崩是一个服务失败,导致整条链路的服务都失败的场景。我们一般可以采用服务降级或服务熔断,如果流量太大可以考虑限流。

服务降级是服务的一种自我保护机制,确保服务不会受到请求突增影响服务,导致不可用。一般实际开发过程中与 feign 接口整合,编写降级逻辑。

服务熔断系统是默认关闭的,需要手动打开。检测到 10s 内请求的失败率超过 50% 就触发熔断机制,之后每隔 5s 重新尝试请求,如果服务不可用则继续熔断,如果服务可用则恢复正常。


微服务是怎么监控的?

我们项目中采用的 skywalking 进行监控的

skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中哪些服务和接口比较慢,我们可以针对性的分析和优化。

我们还在 skywalking 设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发短信和发邮件,第一时间知道项目的bug情况,第一时间修复。


常见的限流算法有哪些?

  • 计数器算法,在固定的时间窗口内统计请求的数量,超过的请求会被拒绝。时间窗口结束则会重新开始统计。实现简单,适合稳定且均匀的请求场景。如果在时间窗口的边界可能会出现流量激增现象。
  • 滑动窗口算法,将时间窗口分为多个小周期,每个小周期单独计数。随着时间的滑动,过期的小周期数据被删除,通过滑动窗口内所有小窗的请求总量来控制流量,更精确地限制请求速率。决解了固定窗口算法的突发效应问题,能够更平滑地控制流量。
  • 漏桶算法是把请求存入到桶中,以固定速率从桶中流出,可以让我们的服务做到绝对的平均,起到很好的限流作用。
  • 令牌桶算法在桶中存储的是令牌,按照一定的速率生成令牌。每个请求都要先申请令牌,申请到令牌以后才能正常请求,也可以起到很好的限流作用。

哪种限流算法在实际应用中最常用?

在实际应用中,‌滑动窗口算法‌和‌令牌桶算法‌是较为常用的限流算法。

‌滑动窗口算法‌因其能够更精确地控制流量,尤其是在处理短时间内突发的高请求量时表现优异,因此在实际应用中非常受欢迎。它通过对时间窗口的滑动来管理请求的计数,从而实现对请求速率的限制。滑动窗口算法适用于需要精细控制访问频率的场景,如API接口访问限制、防止恶意攻击等。

‌令牌桶算法‌也是一种常见的流量控制算法,被广泛应用于分布式系统、网络设备和中间件等场景。它使用一个固定容量的桶来存放令牌,这些令牌以恒定的速率被添加到桶中。当数据包需要发送到网络时,它们会消耗桶中的令牌。如果桶中有足够的令牌,数据包将被允许发送;否则,数据包可能需要等待或被丢弃。令牌桶算法的优点在于能够处理突发流量,并且可以通过调整令牌的添加速率和容量大小来适应不同的业务需求和性能指标要求。


Sentinel 的限流与 Gateway 的限流有什么差别?

他们的差别主要体现在侧重点、定位、限流算法上的不同。

首先 Gateway 作为流量的入口,主要控制的是进入网关的流量。Sentinel 则作为一个应用,侧重于应用内部的流量控制。

其次他们在定位上也有不同。Gateway 作为网关本身并不是做流量控制的,只控制流量入口,无法对进入网关后的流量进行兜底保护。而 Sentinel 则是专门的流量控制组件,提供了更为丰富的流量控制功能。

最后在限流算法上。Sentinel 内部采用滑动时间窗口的默认算法,这种算法统计数据少,内存使用不高。并且还支持令牌桶算法和漏桶算法。而 Gateway 一般采用基于 Redis 实现的令牌桶算法进行限流。


Sentinel 的线程隔离与 Hystix 的线程隔离有什么差别?


分布式服务接口的幂等是如何设计的?

幂等性意味着当一个操作被多次执行,其效果与第一次执行的一样。在分布式系统中,由于网络的不稳定或客户端问题,可能会多次发送请求,因此幂等性尤其重要。

幂等性的实现有以下策略:

  • 唯一标识符‌:客户端为每个请求生成一个唯一的标识符(如UUID),服务器可以通过检查这个标识符来识别重复请求,并避免重复处理‌。
  • 乐观锁/版本控制‌:使用数据库记录的版本号来防止并发更新,确保数据的一致性‌。
  • token机制‌:在请求改变数据状态的接口之前,由后端生成一个token并保存在redis中,同时返回给前端。前端在调用接口时携带这个token,后端通过校验token来确保请求的幂等性‌。
  • 数据库唯一索引‌:利用数据库的唯一索引特性,保证在插入数据时不会产生重复数据,从而实现幂等性‌。

当时我们的理财系统的购买接口是通过数据库唯一索引来实现幂等的,在购买前会有一个业务类的校验,如果业务校验通过则会生成一个唯一的交易流水,保证插入数据时不会重复。


什么是 CAP 理论?

CAP 主要是分布式项目下的一个理论。包含了三项,一致性、可用性、分区容错性。

  • 一致性 (Consistency)指的是更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致。这是强一致性,不存在中间状态。
  • 可用性(Availability)指的是服务必须一直处于可用的状态,对于请求操作总是能够在有限的时间内返回结果。
  • 分区容错 (Partition tolerance)指的是当网络故障导致分布式系统中的部分节点与其他节点失去连接而形成了独立分区,但整个系统也要持续对外提供服务。

为什么无法同时保证一致性和可用性?

在分布式系统中,系统间的网络不能百分之百保证健康,一定会有故障的情况,而服务必须对外保证服务。因此分区容错是一定要满足的。如果这个时候节点接收到数据变更,如果要保证一致性,就必须等到网络恢复后完成数据同步后,整个集群才能对外提供服务。在这期间服务处于阻塞状态,不满足高可用。如果要保证高可用,就不能等待网络恢复,那节点之间的数据就不能保持一致。


什么是 BASE 理论

BASE 理论是对 CAP 的一种解决思路,包含了三个思想:

  • BA 是基本可用 (Basically Available):分布式出现故障时,允许损失部分可用性,保证核心可用。
  • S 是软状态 (Soft State):在一定时间内,允许出现中间状态,比如临时的不一致状态
  • E 是最终一致性 (Eventually Consistent):虽然无法保证强一致,但是在软状态结束后,达到数据的最终一致性。

分布式事务解决方案有哪些?

  • XA 模式:需要互相等待各个分支事务提交,保证了强一致性,但是性能较差。
  • AT 模式:底层使用 undo log 实现,保证了高可用性,性能较好。
  • TCC 模式:将业务逻辑分为了 Try 预留资源,Confirm 提交事务,Cancel 事务失败回滚三个阶段。性能较好,但是需要人工编码。对代码的侵入性较高。
  • MQ 模式:通过分布式系统,在 A 服务写数据的时候,在同一个事物内发送消息到另一个事务。异步,性能最好。

请详细说一下 XA 模式?


请详细说一下 AT 模式?


你们项目中是如何使用分布式事务的?

额度的扣减 和 TCC


xxl-job 路由策略有哪些?

xxl-job 提供了很多的路由策略,我们平时使用较多的就是轮训、一致性 HASH、分片广播。我们的理财代销系统平时需要和对端有大量的文件交互,对于数据量不大,没有业务强关联的文件,我们对这些任务进行分片广播策略。比如产品行情文件,产品文件,产品日历,客户交易流水。对于数据量很大的,没有业务关联的文件,比如客户份额明细,我们按照数据对文件进行分割,然后按照文件数进行分片广播。对于业务有强关联的,我们使用一致性 HASH 算法并且带上了校验逻辑,必须执行完以后才能继续执行。比如对客户的份额进行确认。


xxl-job 任务执行失败怎么解决?

我们路由策略选择故障转移,优先使用健康的实例来执行任务。如果还有失败的,我们在创建任务时,设置重试次数。如果超过重试次数还是失败,我们就可以查看日志或者配置邮箱告警来通知相关负责人来解决。

中间件

RabbitMQ 如何保证消息不丢失

我们当时 MySQL 和 Redis 数据双写一致性就是采用 RabbitMQ 实现同步的,这里面就要求了消息的高可用性,保证数据不丢失。主要从三个层面考虑。

第一我们要卡其生产者确认机制,保证生产者的消息能到达消息队列,如果报错记录在日志中,然后去修复数据

第二我们要开启持久化功能,确保消息没有消费前不会在队列中丢失,其中交换机,队列,消息都需要做持久化。

第三我们开启消费者确认机制为 auto,通过 Spring 确认消息处理后完成 ack,也设置了3次重试机制,如果重试3次没有收到消息就将失败后的消息投递到异常交换机,交由人工处理。


RabbitMQ如何保证消息的有序性?

其实RabbitMQ是队列存储,天然具备先进先出的特点,只要消息的发送是有序的,那么理论上接收也是有序的。不过当一个队列绑定了多个消费者时,可能出现消息轮询投递给消费者的情况,而消费者的处理顺序就无法保证了。

因此,要保证消息的有序性,需要做的下面几点:

  • 保证消息发送的有序性
  • 保证一组有序的消息都发送到同一个队列
  • 保证一个队列只包含一个消费者

RabbitMQ 消息重复消费问题如何解决?

消息重复消费的原因多种多样,不可避免。所以只能从消费者端入手,只要能保证消息处理的幂等性就可以确保消息不被重复消费。

幂等性的保证又有很多方案:

  • 给每一条消息都添加一个唯一id,在本地记录消息表及消息状态,处理消息时基于数据库表的id唯一性做判断
  • 同样是记录消息表,利用消息状态字段实现基于乐观锁的判断,保证幂等
  • 基于业务本身的幂等性。比如根据id的删除、查询业务天生幂等;新增、修改等业务可以考虑基于数据库id唯一性、或者乐观锁机制确保幂等。本质与消息表方案类似。

如何通过分布式锁实现幂等?


RabbitMQ 如何实现消息堆积?

解决消息堆积有三种思路:

  • 增加更多的消费者,提高消费速度。使用工作队列模式,设置多个消费者消费同一个队列的消息
  • 在消费者内开启线程池加快消息处理速度。
  • 扩大队列容积,提高堆积上线,采用惰性队列。惰性队列收到消息后直接存储到磁盘而不是内存,消息上线高,但是性能比较差,受限于磁盘IO,所以时效性会降低。

如何保证RabbitMQ的高可用

要实现RabbitMQ的高可用无外乎下面两点:

  • 做好交换机、队列、消息的持久化
  • 搭建RabbitMQ的镜像集群,做好主从备份。当然也可以使用仲裁队列代替镜像集群。

使用MQ可以解决那些问题

RabbitMQ能解决的问题很多,例如:

  • 解耦合:将几个业务关联的微服务调用修改为基于MQ的异步通知,可以解除微服务之间的业务耦合。同时还提高了业务性能。
  • 流量削峰:将突发的业务请求放入MQ中,作为缓冲区。后端的业务根据自己的处理能力从MQ中获取消息,逐个处理任务。流量曲线变的平滑很多
  • 延迟队列:基于RabbitMQ的死信队列或者DelayExchange插件,可以实现消息发送后,延迟接收的效果。

Kafka是如何保证消息不丢失

嗯,这个保证机制很多,在发送消息到消费者接收消息,在每个阶段都有可能会丢失消息,所以我们解决的话也是从多个方面考虑

第一个是生产者发送消息的时候,可以使用异步回调发送,如果消息发送失败,我们可以通过回调获取失败后的消息信息,可以考虑重试或记录日志,后边再做补偿都是可以的。同时在生产者这边还可以设置消息重试,有的时候是由于网络抖动的原因导致发送不成功,就可以使用重试机制来解决

第二个在broker中消息有可能会丢失,我们可以通过kafka的复制机制来确保消息不丢失,在生产者发送消息的时候,可以设置一个acks,就是确认机制。我们可以设置参数为all,这样的话,当生产者发送消息到了分区之后,不仅仅只在leader分区保存确认,在follwer分区也会保存确认,只有当所有的副本都保存确认以后才算是成功发送了消息,所以,这样设置就很大程度了保证了消息不会在broker丢失

第三个有可能是在消费者端丢失消息,kafka消费消息都是按照offset进行标记消费的,消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次,如果出现重平衡的情况,可能会重复消费或丢失数据。我们一般都会禁用掉自动提价偏移量,改为手动提交,当消费成功以后再报告给broker消费的位置,这样就可以避免消息丢失和重复消费了


Kafka是如何保证消费的顺序性

kafka默认存储和消费消息,是不能保证顺序性的,因为一个topic数据可能存储在不同的分区中,每个分区都有一个按照顺序的存储的偏移量,如果消费者关联了多个分区不能保证顺序性

如果有这样的需求的话,我们是可以解决的,把消息都存储同一个分区下就行了,有两种方式都可以进行设置,第一个是发送消息时指定分区号,第二个是发送消息时按照相同的业务设置相同的key,因为默认情况下分区也是通过key的hashcode值来选择分区的,hash值如果一样的话,分区肯定也是一样的


Kafka 的高可用机制有了解过嘛

主要是有两个层面,第一个是集群,第二个是提供了复制机制

kafka集群指的是由多个broker实例组成,即使某一台宕机,也不耽误其他broker继续对外提供服务。

复制机制是可以保证kafka的高可用的,一个topic有多个分区,每个分区有多个副本,有一个leader,其余的是follower,副本存储在不同的broker中;所有的分区副本的内容是都是相同的,如果leader发生故障时,会自动将其中一个同步数据的follower提升为leader,保证了系统的容错性、高可用性。


解释一下复制机制中的 ISR

ISR 的意思是 in-sync replica,就是需要同步复制保存的 follower

其中分区副本有很多的 follower,分为了两类,一个是 ISR,与 leader 副本同步保存数据,另外一个普通的副本,是异步同步数据,当 leader 挂掉之后,会优先从 ISR 副本列表中选取一个作为 leader,因为 ISR 是同步保存数据,数据更加的完整一些,所以优先选择 ISR 副本列表


Kafka数据清理机制了解过嘛

Kafka中topic的数据存储在分区上,分区如果文件过大会分段存储segment

每个分段都在磁盘上以索引 (xxxx.index) 和日志文件 (xxxx.log) 的形式存储,这样分段的好处是,第一能够减少单个文件内容的大小,查找数据方便,第二方便 kafka 进行日志清理。

在kafka中提供了两个日志的清理策略:

  • 第一,根据消息的保留时间,当消息保存的时间超过了指定的时间,就会触发清理,默认是168小时( 7天)
  • 第二是根据topic存储的数据大小,当topic所占的日志文件大小大于一定的阈值,则开始删除最久的消息。这个默认是关闭的

这两个策略都可以通过kafka的broker中的配置文件进行设置


Kafka中实现高性能的设计有了解过嘛

Kafka 高性能,是多方面协同的结果,包括宏观架构、分布式存储、ISR 数据同步、以及高效的利用磁盘、操作系统特性等。主要体现有这么几点:

  • 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
  • 顺序读写:磁盘顺序读写,提升读写效率
  • 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
  • 零拷贝:减少上下文切换及数据拷贝
  • 消息压缩:减少磁盘IO和网络IO
  • 分批发送:将消息打包批量发送,减少网络开销

为什么选择了RabbitMQ而不是其它的MQ?

kafka 是以吞吐量高而闻名,不过其数据稳定性一般,而且无法保证消息有序性。我们公司的日志收集也有使用,业务模块中则使用的RabbitMQ。

阿里巴巴的RocketMQ基于Kafka的原理,弥补了Kafka的缺点,继承了其高吞吐的优势,其客户端目前以Java为主。但是我们担心阿里巴巴开源产品的稳定性,所以就没有使用。

RabbitMQ基于面向并发的语言Erlang开发,吞吐量不如Kafka,但是对我们公司来讲够用了。而且消息可靠性较好,并且消息延迟极低,集群搭建比较方便。支持多种协议,并且有各种语言的客户端,比较灵活。Spring对RabbitMQ的支持也比较好,使用起来比较方便,比较符合我们公司的需求。

综合考虑我们公司的并发需求以及稳定性需求,我们选择了RabbitMQ。

场景题

导致并发问题的根本原因是什么,如何解决?

  • 导致并发问题根本原因是多线程情况下工作内存对主内存共享变量的修改,当修改共享变量时线程切就会带来原子性问题,编译器对共享变量的缓存带来的可见性问题,处理器对指令重排带来的共享变量有序性的问题。
  • 可见性问题是编译器优化造成的,有序性问题是 CPU 指令重排导致的,可以使用 volatile 关键字来解决。原子性问题是切换导致的,可以使用 CAS 解决,加锁可以粗暴的解决这三种问题。

实现线程安全的方式有哪些

  • 阻塞式的解决方案:synchronizedReentrantLock
  • 非阻塞式的解决方案:CAS + volatile,Atomic类
  • 无同步方案:线程局部存储,无状态,不可变

如何优化多线程场景

在多线程场景中,我们可以对线程管理,共享资源,任务分配,数据结构,系统监控等方面来对多线程进行优化。

比如我们需要设置合理的线程数量。过多的线程会频繁的导致上下文的切换,增加系统开销;过少的线程则不能充分利用系统资源。我们可以把并发高低和任务长短分为两个维度,并发低,任务时间短的不需要优化,并发高,时间长的不在于线程数,在于系统的整体架构设计。对于并发高但是时间任务短的我们需要减少上下文的切换,线程数取 CPU 核心数 + 1 即可。并发低,任务时间长的则需要判断是 IO 密集型还是 CPU 密集型 。IO 密集型一般是文件的读写,IO读写,网络请求,CPU核心数 * 2 + 1 。CPU 密集型一般是计算代码,数据转换,排序,CPU 核心数 + 1。

使用线程池来避免频繁的创建和销毁。但是不建议使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式可以让我们更加明确线程池的运行规则,避免资源耗尽的风险。比如 FixedThreadPool, SingleThreadPool 和 CachedThreadPool 允许请求队列的长度都是 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

缩小锁的粒度。在多线程访问共享资源时,使用锁来保证数据的一致性。但如果锁的颗粒度太大,会导致线程的并发度会降低。我们要尽量缩小锁的范围,只对关键部分加锁。比如我们在对一个链表进行操作时,如果只需要修改一个节点就只对该节点加锁,而不是对整个链表加锁。

使用更高效的同步机制。除了传统 JVM 提供的 synchronized 关键字外,还可以使用 ReentrantLock、Semaphore、CountDownLatch 等更灵活高效的同步机制。这些锁同步机制在分布式服务下则会失效,需要使用分布式锁。

避免死锁,死锁通常由于多个线程互相等待对方释放资源而导致的,我们通过设置合理的线程顺序、避免嵌套锁使用来预防死锁。

将任务分解后并行化。将大任务分成多个小任务,多个小任务并行执行,提高任务的效率。我们项目中在购买交易时,就需要用到产品服务,客户服务,额度服务,就通过异步编排的方式将数据进行汇总。

减少线程间的依赖,让线程能够独立的运行,提高线程的并发度。比如用户在搜索的时候我们需要保存用户的搜索记录。为了保证不影响用户的正常搜索,采用异步的方式对用户搜索记录进行保存。

使用线程安全的数据结构。在多线程的环境我们需要使用安全的数据结构来存储和操作数据。ConcurrentHashMap是线程安全的哈希表,CopyOnWriteArrayList是线程安全的动态数组,它们在多线程环境下能够提供高效的读写操作,而不需要额外的加锁操作。

并且我们需要完善的异常处理机制,避免异常导致的整个系统崩溃。通过 JConsole 或 VisualVM 工具对多线程程序进行性能监控。实时查看线程的状态、CPU 使用率、内存使用率等指标。

如何快速检查项目中的线程安全问题?

我们可以从几个方面入手,比如做好代码审查和测试验证,使用工具检测和日志监控,遵循最佳实践。

代码审查时,我们需要检查代码中是否存在多个线程访问共享资源,如果有是否做了适当的同步处理。锁的获取和释放是否在正确的位置,是否存在死锁的可能。查看线程间的通行,如通过共享内存、消息队列是否存在数据竞争或者不一致的场景,并且是否做好幂等处理。对多线程代码做好单元测试和验证测试,模拟高并发场景,查看多个线程访问共享资源时是否出现预期之外或者性能下降的场景。

使用专门的代码检查工具和日志监控工具,比如 FindBugs 和 VisualVM 。FindBugs 对代码进行检查,可以检测出一些可能存在的线程安全问题,VisualVM 在程序运行时检查线程安全问题,对静态代码的检查进行补充

遵循最佳实践虽然不能检查出线程安全问题,但是能很好的预防线程安全问题,并且提升多线程下的运行效率。

比如尽量使用不可变对象,避免在多个线程中修改同一个对象。线程局部存储,对于线程私有的数据,使用线程局部存储,避免使用全局变量。合理的并发设计:在设计阶段,充分考虑多线程的情况,采用合理的并发设计模式。如生产者消费者模式、线程池、避免过度并发导致的线程安全问题。

项目中有哪些使用多线程的场景?

  • 异步编排。当时我们项目在购买交易的时候,需要获取到产品信息,客户信息,交易信息。分别对应了产品服务和客户服务。如果一个一个操作互相等待的时间比较久。所以我们通过线程池配合异步编程,通过客户号获取客户信息,通过客户理财交易账号获取份额信息,通过产品代码获取产品信息,让多个线程同时执行,然后最终通过异步编排汇总。

  • 批量导入。当时我们项目有很多和对端机构交互的文件。我们通过 CountDownLatch 加线程池的方式将文件导入。我们将文件进行分页。有多少页就创建多少个 CountDownLatch。然后将 CountDownLatch 放入线程池中,每执行完一次就调用 await 方法,大大提升了批量执行的效率。 后期我们将这个方案优化了一下,原先只支持一个服务器执行,并且文件也没有支持分片。后期使用理财登记中心协议,支持了数据分片。我们通过 XXL-JOB 的分片功能来配合CountDownLatch 来实现。如何实现?

  • 异步操作。我们在购买成功后需要对份额的日志,额度的日志进行操作。为了不影响购买的正常流程,我们采用异步的方式然后配合线程池进行保存数据的保存,采用的是什么异步方式?

项目中如何解决读写性能瓶颈?如何提升服务器的并发能力 ?

提升项目中的读写能力可以从三个方面入手,提升单机的并发能力,对服务进行水平拓展,做好服务保护和监控。提升单机的并发能力主要侧重的是业务方面的,对服务水平拓展主要侧重运维方面,对服务保护和监控主要是线上监控层面的。

  • 提升单机的并发能力,可以从读和写两个方面来进行优化。
    • 对于读多写少的场景,我们可以优化代码和 SQL。创建合适的索引,对区分度高的数据创建唯一索引,对于字段长的字符串类型创建前缀索引,尽量使用联合索引,联合索引可以节约存储空间,避免回表,还有索引下推优化。根据执行计划,判断是否走了索引,避免走全表扫描。
    • 添加缓存,对 Key 设置为固定格式 [业务名]:[数据名]:[id],不包含特殊字符,并且不超过 44 字节。当大于 44 字节,底层编码会从 embstr 转为 raw 模式,这样内存空间是不连续的。对于 Value 设置为合理的数据结构,对于一个对象我们存储为 Json 字符串实现简单但是数据耦合,不够灵活。使用 Hash 底层使用 Ziplist,空间占用小访问灵活,但是实现复杂。要杜绝有 BigKey,BigKey 会造成网络堵塞,数据倾斜,Redis 线程阻塞。对大数据的 Hash 拆分成小 Hash,内存占用更小。因为 Hash 的 entry 超过 512 或超过64字节,底层数据结构就会成 ziplist 转为 Dist,内存占用更多,修改 entry 会影响性能。
    • 对于写多读少的场景,我们在插入数据的时候尽量按自增 ID 顺序插入,每插入数据都会在 B + Tree 最后节点中存储。如果是无序主键,频繁插入数据会破坏原有树的结构,造成树分裂以及节点移动。
    • 将同步写改为异步写,将任务添加到阻塞队列中。然后通过线程池获取任务,异步的完成写数据,起到一个削峰和异步化。线程池使用简单,但是当机器重启之后任务就会丢失,所以我们对可靠性要求比较高的任务我们使用 MQ。
    • 消息队列。通过消息队列暂存消息,起到流量削峰的作用,无序等待复杂业务处理,大大减少了响应时间,并且降低了写数据库评率,减轻数据库并发压力。适用于业务复杂,链路长的业务,但是没有减少数据库写次数。
    • 合并写请求。将写评率较高,业务比较简单的场景不直接写到数据库,而实现缓存到 Redis,然后定期将缓存中的数据写入数据库。

数据库分库分表

做好服务的高可用

  • Redis 的高可用
  • RabbitMQ 的高可用

你们项目中有哪些用到 Redis 的场景

缓存数据

额度控制 Redis + MQ

分布式锁

你们数据的缓存是如何同步的?

项目中是如何进行 JVM 监控与调优 ?

项目中用到了哪些设计模式?

你做过团队技术leader没,带过啥项目,什么架构,为什么选这个方式,解决哪些痛点