0%

Java各模块知识点总结

前言

一些关于Java各个知识点的个人总结,例如Java基础、集合、并发编程、JVM、Redis、MySQL、RabbitMQ等模块内容;

Java IO & 反射

✨1.1 Java反射是什么?如何通过反射调用一个对象的方法?

定义:在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性,这种动态获取信息以及动态调用对象方法的功能称为Java的反射机制(反射就是把Java类中的各种成分映射成一个个的Java对象)

通过反射调用一个对象的方法(以user类为例):

1
2
3
4
5
6
7
8
1.通过反射获取类中的对象
Class clazz = User.class;
2.创建运行时类的对象
User user = (User)clazz.newInstance();
3.获取方法
Method method = clazz.getMethod("具体方法名", "需要传入的参数");
4.通过invoke()进行调用
Object obj = method.invoke(user);

1.2 怎么利用反射获取类中的对象?

  • 获取反射中类Class实例的方法(以获取String类实例为例子)

    1. 调用Class的静态方法:Class.forname(类全路径名);

    当可以知道该类的全路径名时,推荐优先使用使用该方法获取 Class 类对象;

    1
    Class clazz = Class.forName("java.lang.String");
    1. 调用运行时类的属性:类名.class

    这种方法只适合在编译前就知道要操作的 Class;

    1
    Class clazz = String.class;
    1. 通过运行时类的对象:对象名.getClass();
    1
    2
    String str = new String("Hello");
    Class clazz = str.getClass();
    1. 使用类的加载器:ClassLoader;
    1
    2
    ClassLoader classLoader = String.class.getClassLoader();
    Class clazz = classLoader.loadClass("java.lang.String")
  • 创建运行时类的对象

    1. 直接通过new关键字;

    2. 通过 Class类对象的 newInstance() 方法;

1
2
Class clazz = String.class;
String str = (String)clazz.newInstance();

​ 3. 通过 Constructor构造器对象的 newInstance() 方法;

1
2
3
Class clazz = String.class;
Constructor constructor = clazz.getConstructor();
String str = (String)constructor.newInstance();

注意:通过 Constructor 对象创建类对象可以选择指定的构造方法,包括无参和有参的构造器,而通过 Class 对象创建则只能使用默认的无参构造方法。

2. BIO、NIO和AIO概念

  • BIO:同步并阻塞,服务器的实现模式是一个连接一个线程,即客户端有新的连接请求时服务器端就需要启动一个新线程进行处理,这样的模式很明显的一个缺陷是:由于客户端连接数与服务器线程数成正比关系,如果这个连接不做任何事情可能造成不必要的线程开销,严重的还将导致服务器内存溢出。这种情况可以通过线程池机制改善,但并不能从本质上消除这个弊端。

    • BIO编程简单流程

      1. 服务器端启动一个ServerSocket
      2. 客户端启动Socket对服务器进行通信,默认情况下服务器端需要对每个客户各建立一个线程与之通讯
      3. 客户端发出请求后,先咨询服务器是否有线程响应,如果没有则会等待或者被拒绝
      4. 如果有响应,客户端线程会等待请求结束后再继续执行
    • BIO存在问题

      1. 每个请求都需要创建独立的线程与对应的客户端进行数据交互
      2. 当并发数大时,需要创建大量线程来处理连接,系统资源占用较大
      3. 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在读操作上,造成线程资源浪费
  • NIO:在JDK1.4以前,Java的IO模型一直是BIO,但从JDK1.4开始,JDK引入的新的IO模型NIO,它是同步非阻塞的。而服务器的实现模式是多个请求一个线程,即客户端发送的连接请求都会注册到多路复用器Selector上,多路复用器轮询到连接有IO请求时才启动一个线程处理

    • 不同于BIO基于字节流和字符流进行操作,NIO面向缓冲区或者说是面向块编程,数据线会先读取到一个稍后处理的缓冲区,需要时可在缓冲区中前后移动
    • NIO的非阻塞模式,使一个线程发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时就什么都不会获取,而不是保持线程阻塞,所以知道数据变得可以读取之前,该线程可以继续做其他事情,非阻塞写操作同理,一个线程请求写入数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情;
    • NIO其实是对BIO的进一步封装(在原生流中内置Channel)
  • AIO:JDK1.7发布了NIO2.0,这就是真正意义上的异步非阻塞,服务器的实现模式为多个有效请求一个线程,只有有效的请求才启动线程,即先由操作系统完成后才通知服务端程序启动线程去处理(适用于连接数目多且连接时间较长的架构)

3.【NIO和BIO的主要区别】

  • 面向流与面向缓冲区
    Java IO和NIO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区(buffer)的。 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
  • 阻塞与非阻塞IO
    Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
  • 选择器(Selector)
    Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。

NIO提供了与标准IO不同的IO工作方式:

  • Channel and Buffer(通道和缓冲区):标准的IO基于字节流和字符流进行操作的,而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。
  • Asynchronous IO(异步IO):Java NIO可以让你异步的使用IO,例如:当线程从通道读取数据到缓冲区时,线程还是可以进行其他事情。当数据被写入到缓冲区时,线程可以继续处理它。从缓冲区写入通道也类似。
  • Selector(选择器):Java NIO引入了选择器的概念,选择器用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个的线程可以监听多个数据通道。

4.NIO的核心组件有哪几个?

NIO的三大核心组件:Channel通道、Buffer缓冲区、Selector选择器

  • NIO三大核心组件关系

    1. 每个Channel都会对应一个Buffer
    2. 一个Selector会对应一个线程,一个线程对应多个Channel
    3. 一般会有多个Channel注册到一个Selector程序
    4. 程序切换到具体哪个Channel是由事件(Event)决定的
    5. Selector会根据不同的事件在各个通道上切换
    6. Buffer本质就是一个内存块,底层是由一个数组组成
    7. 数据的读取写入是通过Buffer完成的,这个区别于BIO,BIO中不能双向读写,但NIO的Buffer是可以读可以写或者同时读写,需要使用flip()切换;Channel也是双向的
  • 缓冲区Buffer

    • 缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个**容器对象(含数组)**,Channel提供从文件、网络读取数据的渠道,但是读取或写入的数据都需要经由Buffer;
    • 在NIO中Buffer是一个顶级父类,他是一个抽象类,其常用的子类有①ByteBuffer:存储字节数据到缓冲区;②CharBuffer:存储字符数据到缓冲区;
    • Buffer类定义了所有缓冲区都具有的四个属性:
      • Capacity:容量,既可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变
      • Limit:表示缓冲区的当前终点,不能对缓冲区超出极限的位置进行读写操作;极限是可以修改的
      • Position:为止,指下一个要被读或写的元素的索引,每次读写缓冲区数据是都会改变该值,为下次读写做准备
      • Mark:标记
    • Buffer常用操作
      • flip():反转读写模式
      • clear()或compact():清除缓冲区内容
  • 通道Channel

    • 不同于流只能读或者只能写,通道可以同时进行读写
    • 通道可以实现异步读写数据,可以从缓冲区读数据,也可以向缓冲区写数据
    • Channel是一个接口,常用的Channel类有FileChannel、DatagramChannel、ServerSocketChannel和SocketChannel;
      • FileChannel:用于文件的数据读写,
      • DatagramChannel:用于UDP的数据读写,
      • SocketChannel:用于TCP的数据读写
      • ServerSocketChannel:监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel,其本身并不传输数据
  • 选择Selector

    • Selector能够检测多个注册的Channel上是否有事件发生(多个Channel以事件的方式可以注册到同一个Selector)

5. select、poll和epoll的区别?(待补充)

  1. select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标识位数组来存放,所以select会受到最大连接数的限制,而poll不会,而epoll采用红黑树存储也没有最大连接数限制;
  2. select、poll、epoll虽然都会返回就绪的文件描述符数量,但select和poll并不会明确指出是哪些文件描述符处于就绪态,而epoll会。造成的区别就是系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪态,而epoll的epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。
  3. select、poll都需要将有关文件描述符的数据结构从用户空间拷贝进内核之后再拷贝出来。而epoll执行epoll_create会在内核的高速cache区中建立一颗红黑树以及双向链表(该链表存储已经就绪的文件描述符,即触发了监听事件的socket),接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点
  4. select、poll采用轮询的方式对连接进行线性遍历来检查文件描述符是否处于就绪态,而epoll采用回调机制,在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在双向链表中。造成的结果就是随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。

6. 说一说Linux的五种IO模型

概念解释:

  • 阻塞与非阻塞:描述的是用户线程调用内核IO操作的方式。
    • 阻塞:IO操作需要彻底完成后才返回到用户空间;
    • 非阻塞:IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成;
  • 同步与异步:描述的是用户线程与内核的交互方式。
    • 同步:用户线程发起IO请求后,需要等待或者轮询内核IO操作完成后才能继续执行;
    • 异步:用户线程发起IO请求后,仍继续执行其他操作,当内核IO操作完成后会通知用户线程或调用用户线程注册的回调函数;

五种IO模型介绍:

  • 同步IO(区别在于数据准备阶段处理有所不同,数据拷贝阶段处理都相同)

    • 阻塞IO

      应用进程从发起 IO 系统调用后一直等待直到内核返回成功标识,即IO的数据准备和数据拷贝两个阶段都是处于阻塞状态的

    • 非阻塞IO

      应用进程在发起 IO 系统调用后会立刻返回,应用进程可以轮询的发起 IO 系统调用直到内核返回成功标识,这期间数据准备阶段用户进程不断主动轮询进行系统调用处于非阻塞状态(发起一次调用后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好就返回状态码,进程在返回之后可以干别的事情,之后再进行调用),但是数据拷贝阶段仍是处于阻塞状态的

    • 多路复用IO / 事件驱动IO

      多了一个多路复用器(select、poll、epoll函数),多个socket可以注册到同一个多路复用器上,当用户进程调用该多路复用器时,select会监听所有注册好的IO,数据准备阶段如果所有被监听的IO需要的数据都没有准备好时,select调用进程会同时阻塞所有IO。当其中任意一个IO所需的数据准备好之后即可读或可写,select就会对该socket进行返回,数据拷贝阶段仍是处于阻塞状态的

    • 信号驱动IO

      用户进程预先向内核注册一个信号处理函数,然后用户进程返回并且不阻塞,当内核数据准备就绪时会发送一个信号给进程,用户进程便在信号处理函数中开始把数据拷贝的用户空间中。用户进程在数据准备阶段是非阻塞的,在数据拷贝阶段仍是阻塞的;注意与其他同步IO方式不同的是,信号驱动IO的数据准备阶段不需要轮询或等待,是异步的

  • 异步IO

    用户进程发起系统调用操作之后,给内核传递描述符、缓冲区指针、缓冲区大小等,告诉内核当整个操作完成时如何通知进程,然后就立刻去做其他事情了。当内核收到系统调用命令后会立刻返回,然后内核开始等待数据准备,数据准备好以后直接把数据拷贝到用户空间,然后再通知进程本次IO已经完成。用户进程在两个阶段是非阻塞的,且内核在整体阶段是异步状态。

✨7. Maven的scope有哪几个?

  1. compile:默认scope,运行期有效,需要打入包中
  2. provided:编译期有效,运行期不需要提供,不会打入包中
  3. runtime:编译期不需要,在运行期有效,需要导入包中
  4. test:测试需要,不会打入包中
  5. system:非本地仓库引入、存在系统的某个路径下的jar

✨8. String、StringBuilder和StringBuffer的区别?(待补充)

✨9. 子类重写父类方法的规则

  • 参数列表和方法名与被重写方法的参数列表和方法名必须完全相同。
  • 返回类型与被重写方法的返回类型可以不相同,但是必须是父类返回值类型的派生类。
  • 访问权限不能比父类中被重写的方法的访问权限更低。

✨10. 子类中调用父类方法的规则

  1. 子类构造函数中想要调用父类构造函数,用super。
  2. 子类重写父类方法后,若想调用父类中被重写的方法,用super
  3. 调用父类中未被重写的方法可以直接调用,但子类不可以调用父类的私有方法。

✨👏Java集合

✨1. 【介绍一下HashMap的底层实现原理】

  • HashMap的底层是数组+链表+红黑树,基于hash算法实现的,存储对象时通过put(key,value)传入key,它调用hashCode()计算hash值,根据hash值将value保存到对应的bucket位置,获取对象时将key传给get(),它调用hashcode()计算hash值从而得到bucket位置,并进一步调用equals()确定键值对。

HashMap解决哈希冲突的方法:

  • 当计算出的hash值相同时会发生hash冲突(即hash碰撞),为了解决hash碰撞问题,HashMap通过链表将产生碰撞冲突的元素组织起来。在jdk1.8中,如果一个bucket中碰撞冲突的元素超过某个阈值(默认是8),先会尝试进行数组扩容来减小链表长度,如果数组容量>=64时,则会树化使用红黑树来替换链表。

✨2. 为什么在解决哈希冲突时不直接用红黑树而先选择用链表,再转红黑树?

因为红黑树是一种平衡二叉树,需要进行左旋、右旋、变色这些操作来保持平衡,而单链表不需要。当元素个数小于8个时,此时做查询操作链表结构已经能保证查询性能,当元素个数大于8个时,红黑树搜索时间复杂度是O(log n),而链表是O(n),此时可以用红黑树来加快查询速度,但会导致新增节点的效率变慢,因此假如一开始就用红黑树,元素个数少查询性能没提升且新增节点效率又比较慢。

3.1【HashMap为什么说是线程不安全的?】

  • 多线程下扩容会死循环:JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容时有可能导致环形链表的出现形成死循环。到了JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
  • 多线程的put可能导致元素的丢失:多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个key被后一个key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
  • put和get并发时,可能导致get为null:线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。

3.2 【Arraylist为什么说是线程不安全的?】

ArrayList的底层是数组队列,相当于一个动态数组,与数组不同的是容量可以动态增长;

  • 并发环境下进行add操作时可能会导致elementData数组越界

    add()实际执行的过程并非原子操作:

    1
    2
    elementData[size] = e;
    size = size + 1;

✨4.1【HashMap和HashTable的区别?】

  1. 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的, 因为Hashtable内部的方法,例如chear(),基本都经过synchronized修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap ,HashTable基本被淘汰使用了)
  2. 对 Null key 和 Null value 的支持: HashMap允许key和value为null,但null作为键只能有一个,null作为值可以有多个;Hashtable 不允许key和value为null,否则会抛出NullPointerException。(使用ConcurrentHashMap,同Hashtable一样不允许key和value为null)
  3. 初始容量大小和每次扩充容量大小的不同 :HashTable中数组的默认大小是11,增加方式是old*2+1,HashMap中数组的默认大小是16,增加方式是2的指数倍;

✨4.2 【ArrayList和LinkedList的区别?】

  1. ArrayList的实现是基于动态数组,LinkedList的实现是基于双向链表

    • 单链表和双向链表的区别:

      1. 单向链表只有一个指向下一结点的指针,双向链表除了有一个指向下一结点的指针外,还有一个指向前一结点的指针。
      2. 单向链表只能next ,双向链表可以return。
      3. 单链表只能单向读取,双向链表可以通过prev()快速找到前一结点。

      单向链表优缺点:

      1. 优点:单向链表增加删除节点简单。遍历时候不会死循环;
      2. 缺点:只能从头到尾遍历。只能找到后继,无法找到前驱,也就是只能前进。

      双向链表优缺点:

      1. 优点:可以找到前驱和后继,可进可退;
      2. 缺点:增加删除节点复杂,多需要分配一个指针存储空间。
  2. 对于随机访问,ArrayList优于LinkedList,不需要像链式结构从头遍历。

  3. 对于插入和删除操作,LinkedList优于ArrayList,不需要像线性结构那样移动插入位置后面的数据。

  4. LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用指针;

✨5.【ConcurrentHashMap实现线程安全的底层原理是什么?】

  • JDK7中的ConcurrentHashMap由Hash bucket数组组成,每个哈希桶数组切分成多个Segment数组,每个Segment数组中由多个HashEntry组成,即把数据分为一段段存储,然后给每一段数据配一把分段锁,当一个线程占有锁访问其中一段数据时,其他段的数据也能被同时其他线程访问,实现并发访问;Segment继承自ReentrantLock,其中有volatile修饰的HashEntry<K, V>[] table保证在数组扩容时的可见性volatile修饰HashEntry中的属性value和指向下一个节点的指针next,保证了多线程环境下数据获取时的可见性,这也是ConcurrentHashMap的get()不需要加锁的原因
  • JDK8中ConcurrentHashMap选择了与HashMap相同的Node数组+链表+红黑树结构,在锁的实现上抛弃了原有的Segment分段锁,采用CAS+synchronized实现更加细粒度的锁,只需要锁住链表的头节点(或者是红黑树的根节点),而不会影响其他Node数组元素的读写,进一步提高并发度;

6. ConcurrentHashMap不支持key或value为null的原因?

因为ConcurrentHashMap是用于多线程的,如果通过get(key)得到了null,无法判断是映射的value值就为null,还是没有找到对应的key而为null,存在二义性;而key不支持为null是源码规定的,当key和value都为null时会抛出空指针异常;

✨7. 【谈谈HashMap的扩容机制】

什么时候扩容:一般情况下,当元素数量大于等于阈值时, 即当前容器内的元素个数大于当前数组的长度×负载因子的值时便会触发扩容,每次扩容的容量都是之前容量的2倍,并将原来的对象拷贝到新的数组中;

  • 负载因子表示Hash表中元素的填满程度,负载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了,而负载因子是0.75的时候,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率

JDK7中的扩容机制:

resize()中传入新的容量,引用扩容前的Entry数组后进行判断,如果扩容前的数组大小已经达到最大容量,则修改阈值为int的最大值,以后就不会扩容了;之后初始化一个新的Entry数组,将数据通过transfer()将原Entry数组的元素拷贝到新的Entry数组中,HashMap的table属性引用新的Entry数组,并修改新阈值=新容量×负载因子;

7.1 HashMap在JDK8时扩容上做了哪些优化?(待补充)
✨7.2 为什么HashMap扩容的时候是两倍?(待补充)

8. 谈谈ConcurrentHashMap的扩容机制(待补充)

当往ConcurrentHashMap如果新增节点之后,会调用addCount()重新计算map中的size,并检查是否需要进行扩容,当sizeCtl>0.75n时就触发扩容逻辑,若所在链表的元素个数达到了阈值 8,则会调用treeifyBin())把链表转换成红黑树,不过在结构转换之前,会对数组长度进行判断,如果数组长度n小于阈值默认是64,则会调用tryPresize())把数组长度扩大到原来的两倍,并触发transfer方法,重新调整节点的位置。

  • 扩容阈值sizeCtl关键点:
  1. sizeCtl在没有触发扩容时,是用来表示扩容阈值的,这时候sizeCtl是个正数,当map内数据数量达到这个阈值时,会触发扩容逻辑

  2. 当某个线程触发扩容时,会通过CAS修改sizeCtl值,修改的逻辑是将上面生成的扩容邮戳向左位移16位,然后+2,这时候由于符号位是1(因为邮戳的算法决定了把邮戳向左位移16位后,符号位是1),所以sizeCtl一定是个负数,也正是由于是cas操作,所以只会有一个线程cas成功并开启扩容流程,不会有多个扩容流程被开启。

  3. 当sizeCtl为负数时,说明在扩容中,这时候其他线程可以一起扩容,需要先通过cas将sizeCtl+1,这样可以通过sizeCtl的低16位来判断有多少并发线程在一起做扩容,从而判断哪个线程最后完成扩容,然后做收尾工作,这个收尾工作包括将当前对象的table指向新表,将sizeCtl重新设置成表示扩容阈值的正数等。

  • 扩容时的线程安全怎么做的?
    1. 扩容线程间的并发场景:扩容线程间在进行任务分配时,是从数组尾部往头部以桶为单位截取,并且用来标记已分配区域的指针transferIndexvolatile修饰的,所以线程间是可见的,通过cas来修改transferIndex值,保证线程间没有重复的桶
    2. 扩容线程与写线程的并发场景:
      • 在触发扩容流程时,需要通过CAS将sizeCtl从正数改成负数,并且+2,这样只会有一个线程cas成功,避免其他的写线程也触发扩容流程。
      • 扩容线程是遍历桶内的链表或者B树来rehash,如果往已经遍历的链表或者B树中插入新数据,扩容线程是无法感知到的,会导致新表中没有这些数据,对于空桶,不管是put操作还是扩容操作,都是通过cas操作来往空桶中添加数据,所以在出现并发往空桶写时,只会有一个线程成功,而不管是put的线程失败还是扩容的线程失败时,都会重新获取里面的值,再重新触发对应的put或者扩容逻辑,从而避免其他写线程往处于扩容中、扩容完毕的桶里写数据的并发问题;对于有数据的桶,put操作和扩容操作都是通过synchronized在桶上加锁来避免并发写问题;

​ 3. 扩容线程与读线程之间的并发场景ConcurrentHashMap.get(key)是没有加锁的,怎么保证在这个扩容过程中,其他线程的get(key)能获取到正确的值,不出现线程安全问题?

  1. 在转移桶内数据时,不移动桶内数据并且不修改桶内数据的next指针,而是new一个新的node对象放到新表中,这样不会导致读取数据的线程在遍历链表时候因为next引用被更改而查询不到数据;

  2. 在桶内数据迁移完后,在原table的桶内放一个ForwardingNode节点,通过这个节点的find(k)方法能获取到对应的数据;

  3. 在整个扩容完成后,将新表引用赋值给volatile的变量table,这样更新引用的动作对其他线程可见;

    从而保证在这三个过程中都能读取到正确的值。

  • 其他线程怎么感知到扩容状态,从而一起进行扩容?

    在对某个桶进行扩容时候,在完成扩容后会生成一个ForwardingNode放在旧表的对应下标的位置下,当有其他线程修改这个桶内数据时,如果发现这个类型的节点,就会一起进行扩容;

9. 【谈谈ArrayList的扩容机制】

ArrayList扩容的本质是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去,默认情况下,新容量会是旧容量的1.5倍;

JDK8中的扩容机制:

  1. 每次在add()一个元素时,ArrayList都会对这个elementData数组的容量进行判断是否能容纳元素,若能则直接添加到末尾,若不能则进行扩容后再把元素添加到末尾;
  2. 若要求的最低存储能力>elementData数组的容量,说明ArrayList存储能力不足需要扩容,则调用**grow()进行扩容:获取elementData数组的内存空间长度后,扩容至原来的1.5倍,检验容量是否够和检查是否溢出后,调用Array.copyOf()**将elementData数组指向新的内存空间,并将elementData的数据复制到新的内存空间;

10. 谈谈CopyOnWriteArraylist的底层实现原理?其与ReetrantReadWriteLock读写锁的区别?

适用于读多写少的并发场景,CopyOnWriteArrayList容器允许并发读,读操作是无锁的,而写操作则首先将容器复制一份,然后在新副本上执行写操作并加锁,此过程中若有读操作则会作用在当前容器上使得读写分离,写入操作结束之后再将当前容器的引用指向新副本,用volatile保证切换过程对读线程立即可见

CopyOnWriteArraylist缺点:

  • 一是内存占用问题,每次执行写操作都要将原容器拷贝一份,在数据量大时对内存压力较大,可能会引起频繁GC;
  • 二是无法保证实时性,存在数据一致性问题,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList的写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。

与ReetranReadWriteLock读写锁的区别:

相同点

  1. 两者都是通过读写分离的思想实现;

  2. 读线程间是互不阻塞的(读读不互斥),写线程间是阻塞的(写写互斥);

不同点

  1. ReetrantReadWriteLock对读线程而言,为了实现数据实时性,在写锁被获取后读线程和写线程都会等待,或者当读锁被获取后写线程会等待,即读写互斥,从而解决“脏读”等问题,也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。
  2. CopyOnWriteArraylist读写在不同容器上所以读写不互斥,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况。

✨11. HashMap的put()流程?get()流程?(待补充)

以JDK 1.8为例,首先根据key的值计算hash值找到该元素在数组中存储的下标,如果数组是空的则调用resize进行初始化;如果没有发生哈希冲突则直接放在对应的数组下标处;如果发生了哈希冲突且key已经存在,则覆盖掉value;如果发生哈希冲突后发现该节点是链表,判断该链表是否大于8,若大于8且数组容量小于64就进行数组扩容后插入键值对,若数组容量大于64则将链表转换为红黑树。

12. ConcurrentHashMap的put()流程?get()流程?(待补充)

  • JDK 1.7时先会尝试获取锁,如果获取失败利用自旋方式获取锁,如果自旋重试的次数超过了64次,则改为阻塞获取锁;获取到锁后,将当前Segment通过key的hashcode定位到相应HashEntry数组,遍历该HashEntry,如果不为空则判断传入的key和当前遍历的key是否相等,相等则覆盖旧的value,如果为空则新建一个HashEntry并加入Segment中,同时判断是否需要扩容,最后put()成功后释放Segment的锁;
  • JDK 1.8根据key计算出hash值,判断是否需要进行初始化,根据hash值定位到Node,拿到首节点f,对首节点f进行判断:如果f为null,则通过CAS的方式尝试添加;如果f.hash=MOVED=-1,说明其他线程在扩容,就参与一起扩容;如果都不满足,则synchronized锁住首节点f后遍历插入,当链表长度达到8时,数组扩容或将链表转换为红黑树;

✨13. TreeSet和HashSet的区别?

  1. 底层使用的数据存储结构不同:Hashset底层是HashMap实现的,使用哈希表结构储存,而Treeset底层是TreeMap,使用二叉树结构储存;
  2. 元素是否有序:HashSet中的元素是无序的,而TreeSet实现了SortedSet接口,可以确保集合元素处于排序状态,其是有序的;
  3. 保证元素唯一方式不同:HashSet是通过hashCode()和equals()来保证的,而TreeSet是通过实现了Compareable接口的compareTo()来保证的;

✨14. Set集合是如何实现去重的?(Set集合去重原理)

  • HashSet去重原理:通过hashCode()和equals()是否都相同来比较

    put()中调用hashCode()先算出当前元素的哈希值,比较哈希值是否相同,通过equals()比较对象是否相等

    • 如果哈希值不同,则说明元素不相同,将元素添加到集合中;

    • 如果哈希值相同,则继续判断equals()

      • 如果equals返回true,则说明元素的哈希值与内容都相同,该元素重复,不会添加到集合中;
      • 如果equals返回false,则说明元素的哈希值相同,但内容不同,元素不重复,就添加到集合中;
  • TreeSet去重:compareTo()

    调用了comparaTo(),根据比较器的属性去重。如果compareTo()返回0,说明是重复的;如果是负数,则往前面排;如果是正数,往后面排。

✨👏Java并发编程

1. 【请谈谈你对volatile、Java内存模型的理解】

volatile是JVM提供的轻量级的同步机制,能保证可见性,不保证原子性,禁止指令重排保证有序性

  • JMM(Java内存模型)并不真实存在,描述的是一组规范,定义了程序中各个变量的访问方式,它有三个特性是可见性、原子性和有序性

  • JMM规定所有变量都存储在主内存,线程对变量的所有操作(读取、赋值等)必须在工作内存中进行,当需要对共享变量进行修改时,首先要将共享变量拷贝副本到自己的工作内存,然后对变量进行操作,操作完成后再将变量写回主内存;

    • 可见性:当一个线程对主内存的共享变量进行了修改,其他线程能够收到通知得知该变量已经被修改。
  • volatile不保证原子性解释:

    • 原子性:即某个线程正在做某个具体业务时,中间不可以被加塞或者分割,操作需要整体完整。
    • 一个线程a修改了共享变量的值但未写回主内存时,当不保证原子性就可能出现另一个线程b又对同一个共享变量进行操作,但此时a线程工作内存中的共享变量副本对线程b不可见,可能出现写数据丢失情况
    • 如何解决volatile原子性问题?
      • 使用JUC下的AtomicInteger类
  • volatile实现禁止指令重排优化,避免多线程环境下程序出现乱序执行的现象

    • 有序性:不会发生指令重排保证代码原本的执行顺序

    • 内存屏障:又称为内存栅栏,作用有以下两个:

      1. 保证特定操作的执行顺序
      2. 保证某些变量的内存可见性(Lock前缀指令实现了volatile的内存可见性)

    通过插入内存屏障禁止编译器和处理器在内存屏障前后的指令执行重排序优化,并且内存栅栏能强制刷出CPU的各种缓存数据,使线程都能读取到这些数据的最新版本

    • happen-before原则:规定了在一些特殊情况下不允许编译器和指令器对代码进行指令重排,保证代码的有序性;
  • volatile使用场景:

    volatile修饰单例对象的DCL(双端检索机制)的单例模式(原因在于DCl机制不一定线程安全,原因是有指令重排的存在,需要再加入volatile禁止指令重排)

2. 【CAS底层实现原理是什么?】

CAS(Compare and Swap 比较并交换):比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作修改为新的值,否则继续比较直到主内存和工作内存中的值一致为止

  • CAS底层原理:
    • Unsafe:是CAS的核心类,其所有方法都是native修饰,其内部方法操作可以像C的指针一样直接操作内存,基于此类可以直接操作特定内存的数据,CAS操作的执行依赖于Unsafe类的各个方法,可以说CAS就是一条CPU并发原语,功能是判断内存某个位置的值是否为预期值,如果是则更改为新值,不是则进行比较直到和预期值相等为止(原语的执行必须是连续的,在执行过程中不允许被中断,保证CAS的原子性)
  • CAS缺点:
    • 循环时间长开销大,如果CAS失败会一直进行尝试,长时间一直不成功可能会给CPU带来很大的开销;
    • 只能保证一个共享变量的原子操作,对多个变量操作时无法保证操作的原子性;
    • 会引来ABA问题

3. 【使用Atomic原子类解决ABA问题】

问题简介:有两个线程同时去修改一个变量的值,比如线程1、线程2都去更新变量值,将变量值从A更新成B,线程1获取到CPU的时间片,线程2由于某些原因发生阻塞进行等待,此时线程1进行比较更新,成功将变量从A更新成B;更新完毕后,恰好又有线程3进来想把变量的值从B更新成A,线程3进行比较更新,成功将变量从B更新成A;线程2获取到CPU时间片然后进行比较更新,发现值是预期的A,然后就将其更新成B,此值就有了A->B->A的过程。

如何避免ABA问题:

  • 加版本号或者加时间戳(原子引用)
    • (Atomic包下的AtomicStampedReference类:其compareAndSet方法首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,才会以原子方式将该引用的标志值设置为给定的更新值)

4. 【公平锁/非公平锁/可重入锁请谈谈你的理解?】

  • 公平锁:多个线程在等待同一个锁时,必须按照申请锁的先后时间顺序来依次获得释放的锁;
  • 非公平锁:在锁被释放时,任何一个等待该锁的线程都有机会获得锁(可能会造成优先级反转或者线程饥饿现象);
  • 可重入锁:又名递归锁,是指在同一个线程中,在外层方法获取锁的时候,进入内层方法会自动获取锁;

8. 死锁编码及定位分析

死锁理解:

线程T1持有锁L1并且申请获得锁L2,而另一个线程T2持有锁L2并且申请锁L1。因为锁是唯一的,两个线程都在等待对方释放自己需要的锁,所以两个线程都会停留在阻塞状态永远不会结束,这就导致了死锁。

死锁定位:

方法一:

  1. jps查看死锁的线程pid
  2. 使用 jstack -l pid 查看死锁信息
  3. 通过查看所打印信息我们可以找到发生死锁的代码是在哪个位置

方法二:通过Arthas(阿尔萨斯,阿里来源的Java诊断工具)的命令thread -b 就可以查看到死锁信息

10. LockSupport是什么?(待补充)

LockSupport类是用来创建锁和其他同步类的基本线程阻塞原语,其中的park()和unpack()的作用分别是阻塞线程和解除阻塞线程(可理解为线程等待唤醒机制wait/notify的增强版)

三种让线程等待和唤醒的方法:

  1. Object类中的wait和notify方法实现线程等待和唤醒

    • wait和notify方法必须要在同步块或者同步方法里面且成对出现使用
    • 必须要先wait后notify才能起作用
  2. Condition接口中的await和signal方法实现线程等待和唤醒

    • 线程先要获得并持有锁,必须在锁块(synchronized或lock)中
    • 必须要先等待后唤醒,线程才能够被唤醒
  3. LockSupport类中的park等待和unpark唤醒

    • 为什么可以先唤醒线程后阻塞线程?

      因为LOckSupport和每个使用它的线程都有一个许可关联permit,permit最多只有1个,默认是0,调用一次unpark就加1变成1.调用一次park会消费凭证将1变为0同时park立即返回,当再次调用park时会因为permit为0而阻塞,一直到调用unpark将permit变为1

    • 为什么唤醒两次后阻塞两次,但最终结果还会阻塞进程?

      因为凭证数量最多为1,连续调用两次unpark和调用一次unpark效果一样,只会增加一个凭证,而调用两次park需要消费两个凭证,证不够就不能放行

11. 【AQS的实现原理是什么?】

AQS,即抽象队列同步器(基于模板方法模式实现)(AbstractQueuedSynchronizer)使用一个volatile修饰的int型变量表示持有锁的同步状态State,通过内置的先进先出队列(底层是由同步队列+条件队列组成)来完成资源获取的排队工作,将每一条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS操作完成对同步状态值的修改

  • 同步队列管理获取不到锁的线程的排队和释放,条件队列是在一定场景下对同步队列的补充,如获得锁的线程尝试从空队列中拿数据时,条件队列就管理该线程使其阻塞;

AQS中同步队列工作方式:

当前线程获取同步状态失败,同步器将当前线程等待状态等信息构造成一个Node节点加入同步队列中放到队尾,同步器重新设置其为尾节点,加入队列后会阻塞当前线程;当同步状态被释放时同步器重新设置头节点,同步器唤醒同步队列中的第一个节点,让其获取同步状态并成为新的头节点

AQS定义对资源的共享方式:

  • 独占式:只有一个线程能执行,如ReentrantLock,又可分为公平锁和非公平锁
    • 公平锁:按照线程在队列中的排队顺序获取锁
    • 非公平锁:当线程要获取锁是,无视队列排队顺序竞争去抢占获取锁
  • 共享式:多个线程可同时执行,如下文的各个AQS组件

JUC中和AQS相关的同步组件(JUC辅助类):ReentrantLock、CountDownLatch、Semaphore、CyclicBarrier

11.1 Semaphore信号量
  • 区别于synchronizedReentrantLock 一次只允许一个线程访问某个资源,Semaphore可以指定多个线程同时访问某个资源
  • 执行 acquire() 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire() 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量,Semaphore 经常用于限制获取某种资源的线程数量。tryAcquire() 方法则是如果获取不到许可就立即返回 false;
11.2 CountDownLatch倒计时器
  • CountDownLatch 协调多个线程之间的同步控制线程等待,允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。

  • CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。然后,CountDownLatch 会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行。

  • CountDownLatch 的两种典型用法

    1. 某一线程在开始运行前等待 n 个线程执行完毕

    CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上 await()的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。

    1. 实现多个线程开始执行任务的最大并行性

    注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。

  • CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用;

11.3 CyclicBarrier循环栅栏
  • CyclicBarrier 让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会解除,所有被屏障拦截的线程才会继续执行
  • CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过
  • CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。

✨12. 【Synchronized的底层原理是什么?】

Synchronized是一种实现同步互斥的方式,被Synchronized修饰过的代码块在编译前后会被编译器生成monitorenter和monitorexit两个字节码指令,在虚拟机执行到monitorenter指令时,首先尝试获取对象的锁,如果这个对象没有锁定或者当前线程已经拥有了这个锁,则把锁的计数器+1;当执行monitorexit指令时将锁计数器-1;当计数器为0时锁就被释放,通过在对象头设置标记达到获取锁和释放锁的目的

12. 为什么说synchronized是非公平锁?
  1. 当持有锁的线程释放锁时,该线程会执行两个操作:先将锁的持有者owner属性赋为为null,再去唤醒等待链表中的其中一个线程,当两个操作之间如果有其他线程刚好在尝试获取锁就可以马上获取到锁;
  2. 当线程尝试获取锁失败进入阻塞时,其放入等待链表的顺序和最终被唤醒的顺序不一定是一致的;

15. 乐观锁一定是好的吗?

乐观锁虽然避免了悲观锁独占对象的同时也提高了并发性能,但是它有如下缺点:

  1. 乐观锁只能保证一个共享变量的原子操作;
  2. 采用自旋锁的方式(当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环),长时间不成功一直自旋会导致CPU开销大;
  3. 会造成ABA问题(原因在于CAS的核心思想是通过比对内存值与预期值是否一样而判断内存之是否被改过,并不在意中途是否发生过变化),可引入版本号解决;

✨16. 【与Synchronized相比,可重入锁ReentrantLock的实现原理有什么不同?】

其实锁的实现原理基本都是为了达到一个目的:让所有线程都能看到某种标记,Synchronized通过在对象头中设置标记实现这一目的,而ReentrantLock以及所有基于Lock接口的实现类,都是通过用volitile修饰的int型变量,保证每个线程都拥有对该变量的可见性,本质是基于AQS;

16.1 了解ReentrantLock吗?

ReetrantLock是一个可重入的独占锁,支持公平锁和非公平锁,其实现依赖于AQS维护一个阻塞队列,多个线程需要加锁时,竞争失败者会进入阻塞队列等待唤醒,之后重新尝试加锁

其有两个特性:一是可重入,二是可以指定公平或非公平

  • 可重入:指获取独占资源的线程可以重复地获取该独占资源,不需要重复请求;

    • 在请求独占资源时,ReentrantLock都会判断当前持有独占资源的线程是不是当前线程,如果是的话,只是简单地将state值加1后再记录设置当前线程的重入次数。
    • 释放独占资源的时候,都会调用tryRelease(),只有state值为0的时候才会释放资源。即重入多少次就必须释放多少次
  • 公平与非公平:在new ReetrantLock对象的时候,可以指定其支持公平锁还是非公平锁

    • 当设置为公平锁时,FairSync继承Sync,ReentrantLock调用lock方法,最终会调用sync的tryAcquire函数获取资源。FairSync的tryAcquire函数设置if语句,当前线程只有在队列为空或者是队首节点的时候,才能获取资源,否则会被加入到阻塞队列中。
    • 当设置为非公平锁时,
    • NoFairSync同样继承Sync,ReentrantLock调用lock方法,最终会调用sync的tryAcquire函数获取资源。而NoFairSync的tryAcquire函数,会调用父类Sync的方法nofairTryAcquire函数,如果资源释放时,新的线程会尝试CAS操作获取资源,而不管阻塞队列中是否有先于其申请的线程。
16.2 了解ReadWriteLock吗?

ReadWriteLock是一个读写锁接口,ReentrantReadWriteLock是其一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,只有读和写、写和读、写和写之间才会互斥;

16.3 ReentrantReadWriteLock实现原理是什么?

适用于读多写少的并发场景,读写锁内部维护了一个ReadLock和一个WriteLock,基于AQS实现,使用一个state通过“按位切割”的方式实现表示读和写两种状态:state的高16位表示读状态即获取到的读锁的次数;使用低16位表示写状态即获取到写锁的线程的可重入次数;

1
2
为什么要把一个 int 类型变量拆成两半,而不是用两个int型变量分别表示读锁和写锁的状态呢?
这是因为无法用一次CAS同时操作两个int变量,所以用了一个int型的高16位和低16位分别表示读锁和写锁的状态。当state=0时,说明既没有线程持有读锁,也没有线程持有写锁;当state!=0时,要么有线程持有读锁,要么有线程持有写锁,两者不能同时成立,因为读和写互斥。这时再进一步通过sharedCount(state)和exclusiveCount(state)判断到底是读线程还是写线程持有了该锁,通过CAS操作实现读写分离;
  • 写锁的获取与释放:

    • 获取写锁:写锁是一个支持重入的独占锁,tryAcquire()获取独占锁;如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁或者写锁已被其他线程获取,则当前线程进入等待状态;
    • 释放写锁:写锁的释放与ReentrantLock的释放过程基本类似,tryRelease()每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读、写线程能够继续访问读写锁,同时前次写线程的修改对后续的读线程可见。
  • 读锁的获取与释放:

    读锁是一个支持重入的共享锁,它能够被多个线程同时获取;读状态是所有线程获取读锁的总和,而每个线程各自获取读锁的次数保存在ThreadLocal中,由线程自身维护。

    • 获取读锁:tryAcquireShared() 获取共享锁,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入阻塞等待状态。如果当前线程获取了写锁或写锁未被占用,则当前线程增加读状态,成功获取锁。
    • 释放读锁:tryReleaseShared() 利用for循环通过自旋的方式不停的更改读锁状态直到更新为0,成功释放读锁。
  • 锁降级:指写锁可以降级为读锁。如果当前线程拥有写锁,在不释放写锁的情况下,是可以在获取读锁的,获取到读锁后再释放先前获取到的写锁。

    • 锁降级的必要性:主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放写锁之后,线程T才能获取写锁进行数据更新;

17. Java 中用到的线程调度算法有哪些?

有两种调度模型:分时调度模型和抢占式调度模型

  • 分时调度模型是指让所有的线程采用时间片轮转的方式轮流获得CPU的使用权,并且平均分配每个线程占用的CPU时间片;
  • JVM采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用 CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程使其占用 CPU。

18. 为什么使用Executor框架而不是直接new Thread()

  1. 每次执行任务创建线程都new Thread()比较消耗性能
  2. 调用new Thread()创建的线程缺乏管理,而且可以无限制的创建, 线程之间的相互竞争会导致过多占用系统资源而导致系统瘫痪,还有线程之间的频繁交替也会消耗很多系统资源;
  3. 直接使用new Thread() 启动的线程不利于扩展,比如定时执行、定期执行、线程中断等都不便实现。

19. 【什么是阻塞队列?阻塞队列的实现原理是什么?阻塞队列的种类有哪些?】

阻塞队列(BlockingQueue):是一个支持两个附加操作的队列。 这两个附加的操作是:在队列为空时,获取元素的线程即消费者会等待队列变为非空。当队列满时,添加元素的线程即生产者会等待队列可用。

阻塞队列的实现原理:当生产者线程试图向阻塞队列添加元素时,如果队列已满则线程会被阻塞;当消费者线程试图从中获取元素时,如果队列为空则该线程也会被阻塞;

阻塞队列的类型有如下几种:

  • ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列,但是不设置大小时就是Integer.MAX_VALUE。
  • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列

20. 在 Java中CyclicBarriar循环栅栏和CountdownLatch倒计时器有什么区别?

  1. CountDownLatch 简单的说就是一个线程等待,直到他所等待的其他线程都执行完成并且调用 countDown()方法发出通知后,当前线程才可以继续执行;
  2. CyclicBarrier 是所有线程都进行等待,直到所有线程到达进入 await()方法之后,所有线程同时开始执行;
  3. CountDownLatch 的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset() 方法重置实现多次使用。

21. SynchronizedMap 和 ConcurrentHashMap有什么区别?(待补充)

  • SynchronizedMap一次锁住整张表来保证线程安全,所以每次只能有一个线程来访问map;
  • ConcurrentHashMap使用分段锁来保证在多线程下的性能,ConcurrentHashMap 中则是一次锁住一个桶,其默认将 hash 表分为16 个桶(segment),操作只锁当前需要用到的桶,即最多同时可以有 16 条线程操作 ConcurrentHashMap;

22. Java 线程池中 submit() 和 execute()方法有什么区别?

两个方法都可以向线程池提交任务;

  • execute()方法的返回类型是 void,它定义在 Executor 接口中,用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
  • submit()方法定义在 ExecutorService 接口中,用于提交需要返回值的任务,线程池会返回一个持有计算结果的 Future 类型对象,可以通过这个Future对象判断任务是否执行成功。

23. ThreadLocal 是什么以及作用是什么?(待补充)

ThreadLocal ,即线程本地变量。

如果创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量时,实际是操作自己本地拷贝里的变量,从而起到线程隔离的作用,避免了线程安全问题;

应用场景:数据库连接池、会话管理;

23.1 ThreadLocal的实现原理(待补充)
23.2 ThreadLocal的内存泄露问题(待补充)

25. 如果你提交任务时,线程池队列已满,这时会发生什么?

  1. 如果使用的是无界队列比如不设置大小的LinkedBlockingQueue,会继续添加任务到阻塞队列中等待执行,因为 LinkedBlockingQueue 可以近乎认为是一个无穷大的队列,可以无限存放任务;
  2. 如果使用的是有界队列比如 ArrayBlockingQueue,任务首先会被添加到 ArrayBlockingQueue 中,ArrayBlockingQueue 满了,会根据 maximumPoolSize 的值增加线程数量,如果增加了线程数量还是处理不过来, ArrayBlockingQueue 继续满,那么则会使用拒绝策略 RejectedExecutionHandler 处理满了的任务,默认是AbortPolicy;

26. 为什么不能直接调用run()方法,而要先调用start()再执行run()?

new Thread()后线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作;

27. 了解Fork/Join框架吗?(待补充)

  • Fork/Join框架是一个用于并行执行任务的框架,把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果,体现了分而治之的思想;
  • 工作窃取算法:某个线程从其他队列中窃取任务来进行执行的过程,一般是指做得快的线程(盗窃线程)抢慢的线程的任务来做;(为了减少锁竞争,使用双端队列,将快线程和慢线程各放在一端)

28. 【JDK 1.6后对Synchronized的锁优化有哪些?】

  1. 锁膨胀(锁升级)
    • 偏向锁:目的在于减少同一线程获取锁的代价;(当一个线程获得锁时,锁就进入偏向模式,当该线程再次请求锁时,无需再做任何同步操作,只需要检查锁标记位以及当前线程ID即可获取锁);
    • 轻量级锁:由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会升级为轻量级锁,不存在两个线程同时竞争锁,可以是一前一后交替执行同步块;
    • 重量级锁:由轻量级锁升级而来,当同一时间有多个线程竞争同一个锁时,锁就会升级为重量级锁,其使用场景会在追求吞吐量、同步块或者同步方法执行时间较长的场景;
    锁升级原理:在锁对象的对象头有一个threadid字段,在第一次访问时threadid为空,JVM让其持有偏向锁,并将threadid设置为其线程id,再次进入时先判断threadid是否与其线程id一致,如果一致则可以直接使用此对象,如果不一致则升级为轻量级锁通过自旋方式获取锁,执行一定次数后如果还没有正常获取锁,则升级为重量级锁;
  2. 锁消除
    • 在虚拟机即时编译器运行时,对运行上下文进行扫描,消除不可能存在共享数据竞争的锁(例如去除的锁为私有变量时,并不存在竞争关系)
  3. 锁粗化
    • 通过扩大锁同步的范围,避免反复对同一个对象加锁和释放锁
  4. 自旋锁与自适应自旋锁
    • 自旋锁:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取,该线程将会执行固定次数的循环,间隔一段时间后会再次尝试获取锁,如果获取失败则再次等待锁的释放,这种采用循环尝试加锁 -> 等待的机制被称为自旋锁;
    • 自适应自旋锁:对自旋锁进一步优化,其自旋次数不再固定,而是由前一次在同一个锁上的自旋时间及锁拥有者的状态来决定;

29. 【为什么使用线程池?】

✨29.0 创建线程有哪些方式?
  1. 继承Thread类创建线程;
  2. 实现Runnable接口创建线程;
  3. 使用Callable和Future创建线程;(和Runnable接口不一样,Callable接口提供了一个call()作为线程执行体,call()可以有返回值,并且可以声明抛出异常)
  4. 使用线程池创建线程;

使用线程池的好处:

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的资源消耗;
  • 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行;
  • 提高线程可管理性:使用线程池可以对线程进行统一的分配、调优和监控,避免了线程无限制的创建;
✨29.1 说下线程池实现类ThreadPoolExecutor核心参数配置
  • 【corePoolSize】:核心线程数量;定义了最小可以同时运行的线程数量,只要线程池一直运行核心线程就不会停止;
  • 【maximumPoolSize】:线程池最大线程数量(等于核心线程数+非核心线程数),当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数
  • keepAliveTime:非核心线程的最长存活时间,当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程(即非核心线程)不会立即销毁而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • 【workQueue】:阻塞队列,用来存放线程任务,当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中
  • threadFactory:线程工厂,用于新建线程
  • handler:饱和策略(拒绝策略)
    • ThreadPoolExecutor.AbortPolicy :抛出 RejectedExecutionException来拒绝新任务的处理。
    • ThreadPoolExecutor.CallerRunsPolicy :调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。
    • ThreadPoolExecutor.DiscardPolicy :不处理新任务,直接丢弃掉。
    • ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。
✨29.2 线程池执行任务的流程(实现原理)?
  1. 线程池执行execute() / submit()向线程池添加任务,当任务数小于核心线程数corePoolSize时,线程池中可以创建新的线程;
  2. 当任务数大于核心线程数corePoolSize时就向阻塞队列中添加任务;
  3. 如果阻塞队列已满,需要通过比较线程池最大线程数量maximumPoolSize后再在线程池创建新的非核心线程,当线程需求数量大于maximumPoolSize,说明当前设置线程池中线程已经无法处理新的任务,就会执行饱和策略。
29.3 常用的Java线程池类型有哪些?
  • FixedThreadPool:可重用固定线程数的线程池,corePoolSizemaximumPoolSize 都被设置为固定值

  • SingleThreadExecutor:只有一个线程的线程池,corePoolSizemaximumPoolSize 都被设置为1

    FixedThreadPoolSingleThreadExecutor使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列,可能会导致OOM

  • CachedThreadPool:会根据需要创建新线程的线程池,corePoolSize 被设置为0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下会导致耗尽CPU和内存资源,从而导致OOM

  • ScheduledThreadPoolExecutor:主要用来在给定的延迟后运行任务,或者定期执行任务。 这个在实际项目中基本不会被用到,也不推荐使用

29.4 线程池常用的阻塞队列有哪些?

详情见19关于阻塞队列的介绍;

29.5 【如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办?】

必然会导致线程池中的积压任务都丢失

解决办法:在提交一个任务到线程池中之前,可以先在数据库中插入这个任务的信息,更新该任务的状态为未提交,提交成功之后更新其状态为已提交状态;当系统任务宕机重启后,后台线程去扫描数据库中的未提交和已提交状态的任务,可以把任务的信息读取出来重新提交到线程池中去,继续进行执行。

✨JVM

3. 说下你平时工作用过的JVM常用基本配置参数有哪些?

  • -Xms:用于表示堆区的起始内存,等价于-XX:InitialHeapSize
  • -Xmx:用于表示堆区的最大内存,等价于-XX:MaxHeapSize,一旦堆区内存大小超过指定的最大内存时,将会抛出OutOfMemory异常(通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小从而提高性能
  • -Xmn:用于表示堆区中新生代的大小
  • -Xss:用于表示每个线程的栈内存大小

4. 强引用、软引用、弱引用、虚引用分别是什么?有什么区别?

  • 强引用:就是普通的对象引用关系,不会GC被回收
  • 软引用:只有在内存不足时才会被GC回收,如果回收了软引用对象之后仍然没有足够内存,才会抛出OOM内存溢出异常
  • 弱引用:当JVM进行GC垃圾回收时,无论内存是否充足一旦发现都会回收弱引用对象
  • 虚引用:用来跟踪对象被垃圾回收的活动

5. 常见发生OOM(out of memory)的原因及解决办法

  1. Java heap space堆内存不足

    发生原因:

    1. 代码中可能存在大对象分配;
    2. 可能存在内存泄漏问题,导致在多次GC之后还是无法找到一块足够大的内存容纳当前对象。

    解决办法:

    1. 检查是否存在大对象的分配,最有可能的是大数组分配
    2. 通过jmap命令,把堆内存dump下来,使用性能分析工具分析检查是否存在内存泄漏的问题,如果没有找到明显的内存泄漏,使用 -Xmx 加大堆内存的初始容量
  2. GC overhead limit exceeded垃圾回收时间过长

    发生原因:

    当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误(发生在堆内存中

    解决方法:

    1. 检查项目中是否有大量的死循环或有使用大内存的代码,优化代码。

    2. dump内存,检查是否存在内存泄露,如果没有就加大堆内存。

  3. unable to create new native threadfan方法栈溢出

    发生原因:

    出现这种异常基本上都是创建的了大量的线程导致的

    解决方法:

    1. 通过 **-Xss **降低每个线程占用栈大小的容量
  4. Metaspace元空间溢出

    发生原因:

    1. 生成了大量的代理类,导致方法区被撑爆且无法卸载;

    解决方法:

    1. 检查是否永久代空间或者元空间设置的过小
    2. 检查代码中是否存在大量的反射操作,dump之后通过性能分析工具检查是否存在大量由于反射生成的代理类

✨8. 【介绍下Java中的垃圾回收算法和垃圾回收器,各自的优缺点是什么?】

定义:在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾;

垃圾回收算法:标记-清除算法、标记-压缩算法、复制算法

  • 标记-清除算法

    该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象;(用于老年代回收)

    • 标记:收集器从引用根节点开始遍历,标记所有被引用的对象,一般是在对象头中记录为可达对象
    • 清除:收集器对堆内存从头到尾进行线性遍历,如果发现某个对象在其对象头中没有标记为可达对象时则将其回收(清除并不是真的置空,而是把需要清除的对象地址保存在一个空闲地址列表中,当有新对象需要加载时,判断被定位为垃圾的位置空间是否足够,如果够新对象就覆盖存放)
    • 缺点:效率不算高且在进行GC时停止整个应用程序;清除出来的空闲空间不连续的会产生内存碎片;需要维护空闲地址列表;
  • 复制算法

    将内存分为大小相同的两块,每次使用其中的一块。在垃圾回收时将正在使用的内存块中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,最后交换两个内存块的角色完成垃圾回收。(用于新生代回收)

    • 优点:复制过去以后能保证空间的连续性,不会出现内存碎片
    • 缺点:内存和时间花销极大,要求需要复制的存活对象的数量不能太大
  • 标记-压缩(标记-整理)算法

    根据老年代的特点提出的一种标记算法(用于老年代回收)

    • 标记:和标记清除算法一样,从根节点开始标记所有被引用对象

    • 压缩-清除:将所有的存活对象压缩到内存的一端并按顺序排放,之后清除端边界外所有的空间

    • 优点:消除了标记清除算法中内存区域分散的缺点,需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可;消除了复制算法内存减半的代价

    • 缺点:移动对象的同时如果对象被其他对象引用则还需要调整引用的地址;并且移动过程中需要全程暂停用户应用程序


垃圾回收器:Serial、ParNew、Paeallel Scavenge、Serial Old、Paeallel Old、CMS、G1

  • Serial

    采用复制算法,使用一条垃圾收集线程去串行回收,更重要GC时必须暂停其他所有的工作线程直到它收集结束,作为HotSpot中Client模式下的默认新生代垃圾回收器(通常客户端GC使用)

  • ParNew(常用的新生代回收器)

    采用复制算法、并行回收和STW机制的方式执行内存回收,可以看作是Serial收集器的多线程版本,作为HotSpot中Server模式下新生代的默认垃圾回收器(通常服务端GC使用)

  • Parallel Scavenge

    采用复制算法、并行回收和STW机制的方式执行内存回收,同样用于回收新生代,但与ParNew不同,其关注点是吞吐量(高效率的利用 CPU),而CMS 等的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间占总运行时间的比例。

  • Serial Old

    Serial 的老年代版本,采用标记压缩算法,同样采用串行回收和STW机制。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 新生代Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

  • Parallel Old

    采用并行回收和STW机制,但内存回收算法使用标记-压缩算法,用于执行老年代垃圾回收,在JDK1.6后用于替代Serial Old收集器

【8.1 能详细说一下CMS的回收过程吗?CMS的问题是什么?】
  • CMS(重点)

    是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

    使用 标记-清除算法实现的,整个过程分为四个步骤:

    • 初始标记: 仅仅只是标记出GC Roots能直接关联到的对象,需要停顿用户线程,一旦标记完成后就会恢复之前被暂停的所有应用线程,此阶段速度非常快;
    • 并发标记: 同时开启 GC 和用户线程,从GC Roots的直接关联对象开始遍历整个对象图。但在这个阶段结束并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
    • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
    • 并发清除: 开启用户线程,同时 GC 线程清理删除掉前面标记阶段判断已经死亡(未标记)的对象,释放内存空间
  • CMS会产生的问题:

    • 使用标记清除算法因此会产生内存碎片,在进行多次GC后才进行一次碎片整理,导致无法分配大对象的情况下会触发Full GC
    • 在并发阶段虽然不会导致用户停顿,但是垃圾回收线程会占用一部分CPU资源导致CPU资源紧张
    • 无法处理浮动垃圾,如果在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些浮动垃圾对象进行标记,导致这些新产生的垃圾对象没有被及时回收
【8.2 能详细说一下G1的回收过程吗?】
  • G1(重点)

    Carbage First是一个并行回收器,把堆内存分割为多个大小相等的区域,有计划地避免在堆中进行全区域的垃圾回收,跟踪各个区域里的垃圾堆积的价值大小(指回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,根据允许的收集时间,优先回收价值最大(回收能获得的空间大小)的区域

    G1回收器特点:

    • 并行与并发
      • 并行性:回收期间可以有多个GC线程同时工作,缩短用户线程Stop-The-World停顿时间。
      • 并发性:可以与用户线程交替执行,不会在整个回收阶段发生完全阻塞应用程序的情况
    • 分代收集:G1将堆空间分为若干个大小相等的Region区域,这些区域包含了逻辑上的年轻代和老年代,不再要求年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量;与之前的各类回收器不同,G1回收器同时兼顾了年轻代和老年代的收集
    • 空间整合:内存的回收以区域作为基本单位,每个区域(Region)内部局部使用复制算法,但整体上可看作是标记压缩算法,这两种算法都可以进行碎片整理
    • 可预测的停顿(软实时)能让使用者明确指定一个特定长度的时间片段内消耗在垃圾回收上的时间,每次根据允许的收集时间,优先回收价值最大的区域,保证在有限时间内获取尽可能高的收集效率
    • 在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大(在有限的时间内能尽量回收尽可能多的垃圾对象)的Region
8.3 如何利用G1垃圾回收器解决大内存机器的新生代GC过慢的问题?

针对G1垃圾回收器,可以设置一个期望的每次GC的停顿时间,比如我们可以设置一个20ms。 那么G1基于Region内存划分原理,就可以在运行一段时间之后,比如就针对2G内存的Region进行垃圾回收,此时就仅仅停顿 20ms,然后回收掉2G的内存空间腾出来部分内存,接着还可以继续让系统运行。

9. 【生产环境服务器变慢,诊断思路和性能评估能谈谈吗?】

  1. 查看整机指令:

top:输出各指标详细信息

uptime:系统性能命令精简版

  1. 查看CPU指令:

vmstat -n 参数1 参数2(参数1是采样的间隔时间,第二个参数是采样的次数):查看各线程占用CPU情况

  • procs
    • r:运行和等待CPU时间片的进程数,原则上整个系统的运行队列不能超过总核数的2倍
    • b:等待资源的进程数
  • cpu
    • us:用户进程消耗CPU时间百分比,us值高则用户进程消耗CPU时间多,如果长期大于50%需要优化程序
    • sy:内核进程消耗CPU时间百分比
    • us+sy参考值为80%,如果大于80%说明可能存在CPU不足

mpstat -P ALL 参数:查看所有CPU核信息(参数为采样的间隔时间)

pidstat -u -p 参数 PID:查看指定线程使用CPU的用量分解信息(参数为采样的间隔时间)

  1. 查看内存情况:

free -m:查看系统内存详细信息

pidstat -r -p 参数 PID:指定线程使用内存的用量分解信息(参数为采样的间隔时间)

  1. 查看磁盘io情况:

iostat -xdk 参数1 参数2(参数1是采样的间隔时间,第二个参数是采样的次数):用于磁盘io性能评估

pidstat -d -p 参数 PID:指定线程磁盘io的用量分解信息(参数为采样的间隔时间)

10.1【生产环境出现CPU占用过高,请谈谈你的分析思路和定位】

排查分析步骤如下:

  1. 定位耗费CPU的进程:先用top -c显示进程列表,输入P按照CPU使用率排序找出CPU占比最高的进程
  2. 定位耗费CPU的线程:top -Hp 进程ID(PID),再输入P按照CPU使用率排序找出CPU占比最高的线程
  3. 将需要的线程ID(PID)转换为十六进制格式(英文小写格式)
  4. jstack 进程ID | grep tid(十六进制线程ID英文小写)-A60打印该线程相关信息,定位是哪段代码导致CPU占用过高

10.2 【线上机器的一个进程用kill命令杀不死该怎么办?】

  1. ps aux观察STAT那一栏,如果是Z则确定为僵尸进程
  2. ps -ef | grep 进程id,找到该僵尸进程的父进程PPID,kill -9 PPID强制杀掉父进程即可

10.3 【排查OOM异常问题的步骤】

  1. 先用**top **指令查询服务器系统状态,实时显示系统中各个进程的资源占用状况
  2. 找出当前Java进程的PID:ps aux | grep java
  3. 查看当前GC的状况:jstat -gcutil pid interval
  4. jmap -histo:live pid:统计存活对象的分布情况,从高到低查看占据内存最多的对象
  5. jmap -dump:format=b,file=文件名 [pid] 生成当前的堆转储快照dump文件
  6. 使用性能分析工具对dump文件进行分析

10.4 【服务器存储空间快满了,在不影响服务正常运行的情况下如何解决?】

  1. df -h先查看磁盘使用情况,排查是否为日志过多导致,是的话就删除一些冗余日志
  2. du -h >fs_du.log查看各个目录占用的磁盘空间大小,看看是否那个目录下有大量小文件

10.5 【线上出现系统缓慢的排查思路】

  1. 通过 top命令查看CPU情况,如果CPU比较高,则通过top -Hp <pid>命令查看当前进程的各个线程运行情况,找出CPU过高的线程之后,将其线程id转换为十六进制的表现形式,然后在jstack日志中查看该线程主要在进行的工作。这里又分为两种情况:

    • 如果是正常的用户线程,则通过该线程的堆栈信息查看其具体是在哪处用户代码处运行比较消耗CPU;
    • 如果该线程是系统进程,则通过jstat -gcutil <pid> <period> <times>命令监控当前系统的GC状况,然后通过jmap dump:format=b,file=<filepath> <pid>导出系统当前的内存数据。导出之后将内存情况放到分析工具中进行分析即可得出内存中主要是什么对象比较消耗内存,进而可以处理相关代码;
  2. 如果通过 top 命令看到CPU并不高,并且系统内存占用率也比较低。此时就可以考虑是否是由于另外三种情况导致的问题,具体的可以根据具体情况分析:

    • 如果是接口调用比较耗时,并且是不定时出现,则可以通过压测的方式加大阻塞点出现的频率,从而通过jstack查看堆栈信息,找到阻塞点;
    • 如果是某个功能突然出现停滞的状况,这种情况也无法复现,此时可以通过多次导出jstack日志的方式对比哪些用户线程是一直都处于等待状态,这些线程就是可能存在问题的线程;
    • 如果通过jstack可以查看到死锁状态,则可以检查产生死锁的两个线程的具体阻塞点,从而处理相应的问题。

11. 【介绍下JVM的类加载机制(类的生命周期)】

类的加载过程分为三部分:Loading加载→Linking连接(连接阶段包括Verification验证→Preparation准备→Resolution解析)→Initialization初始化→使用和卸载

类的加载过程一:Loading加载
  • 加载的理解:
    • 将Java类的.class字节码文件读入到机器内存中,并在内存中构建出与加载类对应的类模板Class对象
  • 加载完成的操作:
    • 通过一个类的全限定名(即全类名)获取定义此类的二进制字节流
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • 创建java.lang.Class类的实例,作为方法区这个类的各种数据的访问入口
类的加载过程二:Linking连接
  • 验证
    • 目的在于确保Class文件的字节流中包含信息符合当前JVM要求,保证加载的字节码是合法、合理并符合规范的
    • 主要包括四种验证方式:文件格式验证、元数据验证、字节码验证、符号引用验证
  • 准备
    • 为类变量分配内存并且设置该类变量的默认初始值
    • 注意:不包括用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;也不会为实例变量分配初始化,因为类变量会分配在方法区中,而实例变量会随着对象一起分配到堆中;
  • 解析
    • 将常量池内的符号引用转换为直接引用
    • 解析操作会随着JVM在执行完初始化之后再执行
    • 解析主要针对类或接口、字段、类方法、接口方法、方法类型等
类的加载过程三:Initialization初始化
  • 初始化阶段就是执行类构造器方法<clinit>()的过程,注意<clinit>()不等同于类的构造器,虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁
  • 如果类存在直接父类,在初始化该类时,发现其父类还没有被加载和初始化,那么必须先加载并初始化其父类;
✨11.1 什么是类加载器?常用的类加载器有哪些?

JVM支持两种类型的类加载器,分别为引导类加载器Bootstrap ClassLoader、自定义类加载器User-Defined ClassLoader(注意所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器)

常见的3个类加载器:

  1. 启动类加载器(引导类加载器Bootstrap ClassLoader)
    • 嵌套在JVM内部,这个加载器用来加载Java核心库(JAVA_HOME、jre、lib、rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
    • 并不继承自ClassLoader抽象类,没有父加载器
    • 加载扩展类或应用程序类加载器,并指定为他们的父类加载器
    • 出于安全考虑,启动类加载器只加载包名为java、javax、sun等开头的类
  2. 扩展类加载器(Extension ClassLoader)
    • 派生于ClassLoader抽象类、父类加载器为启动类加载器
    • 从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库,如果用户创建的JAR放在此目录下也会自动由扩展类加载器加载
  3. 应用程序类加载器(系统类加载器,AppClassLoader)
    • 派生于ClassLoader抽象类、父类加载器为启动类加载器
    • 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
    • 是程序中默认的类加载器,一般Java应用的类都是由它来完成加载

12. ParNew+CMS的GC,如何保证只做Young GC,JVM参数如何配置?

合理分配Eden、Survivor、老年代的内存大小,合理设置一些参数即可,参考答案如下:

  • 加大分代年龄,比如进入老年代的年龄默认从15加到30;

  • 修改新生代和老年代比例,比如新生代老年代比例从默认的1:2改成2:1

  • 修改Eden区和Survivor区比例,比如从默认8:1:1改成6:2:2;

✨13. 如何判断一个对象是否存活?(JVM垃圾回收的时候如何确定垃圾?)

判断一个对象是否存活的两种方法(用于垃圾标记阶段):引用计数法、可达性算法

  • 引用计数法(Java没有采用)

    给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。

    这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

  • 可达性分析算法

    通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

    • 哪些对象可以作为 GC Roots 呢?

      • 虚拟机栈(栈帧中的局部变量表)中引用的对象
      • 本地方法栈(Native 方法)中引用的对象
      • 方法区中类静态属性引用的对象
      • 方法区中常量引用的对象
      • 所有被同步锁持有的对象
    • 对象可以被回收,就代表一定会被回收吗?

      即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行,即将对象进行回收。(值得注意的是JDK9及后续版本中各个类中的 finalize方法会被逐渐弃用移除)

14. 【Young GC、Old GC和Full GC分别在什么情况下会发生?】

  • 发生Young GC的情况:新生代的Eden区满了之后就会触发,采用复制算法来回收新生代的垃圾。
  • 发生Major GC(Old GC)的情况:即老年代空间也不够放入更多对象了,这时候就要执行Old GC对老年代进行垃圾回收。
    1. 执行Young GC之前进行检查,如果“老年代可用的连续内存空间” < “新生代历次Young GC后升入老年代的对象总和的平均大小”,说明本次Young GC后升入老年代的对象大小可能超过了老年代当前可用内存空间,此时必须先触发一次Old GC给老年代腾出更多的空间,然后再执行Young GC;
    2. 执行Young GC之后有一批对象需要放入老年代,如果老年代没有足够的内存空间存放这些对象了,此时立即触发一次Old GC;
    3. 老年代内存使用率超过了某个设定值,默认为92%,也会直接触发Old GC;

注意:一般Old GC很可能就是在Young GC之前触发或者在Young GC之后触发的,其实在上述几种条件达到的时候,JVM触发的实际上就是一次Full GC(Young GC+Old GC)

15. 【什么是双亲委派机制?为什么需要双亲委派机制?】

定义:当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类在自己负责加载的范围内没找到这个类而不能加载,那么就会下推加载权利给自己的子类加载器,由子类去完成类的加载;

使用双亲委派机制的原因:

  • 可以避免多层级的加载器结构重复加载某些类,保证类的唯一性
  • 保护程序安全,防止核心API被随意篡改
15.1 列举一些你知道的打破双亲委派机制的例子,为什么要打破?
  1. Tomcat每个WebApp的类加载器优先自行加载应用目录下的class,并不是先委派传导给上层类加载器去加载,当自己加载不了才委派给父加载器;

原因如下:

  • 对于各个webapp中的class和lib需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况
  • 使用单独的classloader去装载tomcat自身的类库以免其他恶意或无意的破坏,处于安全性考虑

17. 什么情况下用+运算符进行字符串连接比调用 StringBuffer/StringBuilder 对象的 append 方法连接字符串性能更好?

  1. 字符串拼接操作总结:
  • 常量与常量,或者两者都是常量引用的拼接结果在常量池,原理是编译器优化,常量池中不存在相同内容的常量(注意使用final修饰的变量也算是常量)

  • 只要其中有一个是变量,拼接结果就直接在堆中而不在常量池,原理是StringBuilder

  • 通过StringBuilder的append()方式添加字符串的效率要远高于使用String的字符串+拼接方式,StringBuilder的append()方式自始至终只创建过一个StringBuilder对象,在实际开发中建议使用构造器new StringBuilder(num)指定数组长度优化执行效果

  • 字符串+拼接方式其本质是创建了 StringBuilder 对象进行 append 操作,然后将拼接后的 StringBuilder 对象用 toString 方法处理成 String 对象。

  1. intern()的使用
  • 作用:判断字符串常量池中是否存在该字符串,如果存在则返回常量池中该字符串地址,如果不存在则将此字符串放入常量池中并返回此对象的地址

  • 如果在任意字符串上调用intern()方法,那么其返回结果所指向的那个类实例必须和直接以常量形式出现的字符串实例完全相同

    1
    ("a"+"b"+"c").intern() == "abc"

    String的intern()使用总结:

    • JDK 6时将字符串对象尝试放入字符串常量池,如果字符串常量池有则不会放入,返回已有的字符串常量池中的对象的地址;如果没有,则把此对象复制一份放入字符串常量池并返回字符串常量池中的对象地址
    • JDK 7以后将字符串对象尝试放入字符串常量池,如果字符串常量池有则不会放入,返回已有的字符串常量池中的对象的地址;如果没有,则把对象的引用地址复制一份放入字符串常量池并返回字符串常量池中的引用地址

拓展题目

  1. new String(“ab”)会创建几个对象?

答:创建了两个对象,一个对象是new关键字在堆空间创建的,另一个对象是字符串常量池中的对象ab

  1. new String(“a”) + new String(“b”)又会创建几个对象?

答:创建了六个对象,对象1是new StringBuilder(),对象2是new String(“a”),对象3是字符串常量池中的“a”,对象4是new String(“b”),对象5是字符串常量池中的“b”,对象6是StringBuilder.toString()的new String(“ab”)(注意toString()的调用,在字符串常量池中没有生成对象“ab”)

19. 什么情况下会发生栈内存溢出?

线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出StackOverflow异常,当方法递归调用时可能会出现该问题。可以通过调整参数-XSS来调整虚拟机栈的大小;

20. 谈谈JVM中的常量池?

JVM常量池主要分为:Class文件常量池、运行时常量池、全局字符串常量池、基本类型包装类对象常量池

要注意的事项:

  1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时Hotspot虚拟机对方法区的实现为永久代
  2. JDK1.7 字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
  3. JDK1.8 hotspot 移除了永久代用元空间取而代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间。

21. 【说一下JVM调优的命令】

  • jps:显示系统内所有正在运行的虚拟机进程(包括虚拟机执行主类名称、进程的唯一id)

    1
    2
    3
    4
    -q:只输出进程唯一id(即PID),省略主类名称
    -m:输出进程启动时传递给主类main()函数的参数
    -l:输出主类的全名(如果进程执行的是jar包则输出jar路径)
    -v:输出进程启动时显式指定的JVM参数
  • jstat:显示虚拟机运行时状态信息

    • jstat -gc PID:查看某个进程的堆内存状况(包括Eden区、Survivor区、老年代的容量,已用空间,垃圾回收时间等)

      运行该命令后所显示的列参数解析:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      S0C:这是From Survivor区的大小 
      S1C:这是To Survivor区的大小
      S0U:这是From Survivor区当前使用的内存大小
      S1U:这是To Survivor区当前使用的内存大小
      EC:这是Eden区的大小
      EU:这是Eden区当前使用的内存大小
      OC:这是老年代的大小
      OU:这是老年代当前使用的内存大小
      MC:这是方法区(元空间)的大小
      MU:这是方法区(永久代、元数据区)的当前使用的内存大小
      YGC:这是系统运行迄今为止的Young GC次数
      YGCT:这是Young GC的耗时
      FGC:这是系统运行迄今为止的Full GC次数
      FGCT:这是Full GC的耗时
      GCT:这是所有GC的总耗时
  • jmap:通常用于生成heap dump(堆内存转储快照)文件

    • jmap -heap PID:打印与堆内存相关的一些参数设置,以及当前堆内存里各个区域的基本情况(总容量、剩余容量等)
    • jmap -histo PID:打印堆中对象统计信息,按照对象占用内存空间的大小,降序排列当前jvm中的对象对内存占用的情况
    • jmap -dump:live,format=b,file=<filename> PID:会在当前目录下生成一个堆转储快照文件,其中包含这一时刻JVM堆内存里所有存活对象的快照
  • jhat:与jmap搭配使用,用于分析jmap生成的堆内存转储快照文件

    • jhat dump.hprof -port 7000:可启动jhat服务器,在浏览器上通过图形化的方式去分析堆内存里的对象分布情况
  • jstack:用于生成虚拟机当前时刻的线程快照文件(即每一条线程正在执行的方法堆栈的集合)

    • jstack -l vmid:除了堆栈外还显示关于锁的附加信息

22. 【内存泄漏、内存溢出是什么以及怎么解决?】

  1. 内存溢出(OOM):指没有空闲内存,并且垃圾收集器也无法提供更多的内存的情况
  • 内存溢出产生原因:

    • 元空间区域大小设置过小;不断动态生成了过多的类塞满元空间,且大量的类即使Full GC后还不能被回收;

    • 堆区导致内存溢出的原因:(根本原因即对象太多且都是存活的,即使GC后还是没有空间放下新的对象)

      • 系统承载高并发请求,因为请求量过大系统负载过高,导致大量对象都是存活的,无法继续放入新的对象;
      • 系统有内存泄漏的问题,很多对象都是存活的,没有及时取消对他们的引用,但导致内存实在放不下更多对象;

    可能发生OOM的区域:方法区/元空间、堆区、虚拟机栈

  1. 内存泄漏(Memory Leak):指对象不会再被程序使用,但是垃圾收集器又不能回收它们的情况(内存泄漏可能会导致内存溢出,但不是必然的)
  • 内存泄漏例子:

    • 单例模式

      单例的生命周期和应用程序等长,所有单例程序中如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则可能会导致内存溢出的产生

    • 提供close()的资源未及时关闭

      数据库连接(dataSource.getConnection())、网络连接(Socket)和IO流必须手动close,否则是不能被回收的

✨23. 【介绍下JVM的内存区域(运行时数据区)】

  • 各线程私有(生命周期与线程生命周期保持一致):

    • 本地方法栈

      和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中本地方法栈和虚拟机栈合二为一统称为栈空间。

    • 虚拟机栈

      此内存区域的作用是用来保存每个方法内的局部变量等数据,并参与方法调用的返回,如果线程执行了一个方法,就会对这个方法调用创建对应的一个栈帧并入栈,同时会在栈帧的局部变量表中存放局部变量(栈帧里就有这个方法的局部变量表 、操作数栈、动态链接、方法返回地址等;),方法执行完毕后就将对应栈帧出栈;

      Java 虚拟机栈会出现两种错误:

      • StackOverFlowError 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError错误。
      • OutOfMemoryError Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError错误。
    • 程序计数器(唯一不会出现OOM的区域)

      • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
      • 在多线程的情况下,程序计数器用于用来存储指向下一条指令(将要执行的代码)的地址,即记录当前线程执行到的位置,当线程被切换回来的时候能够知道该线程上次运行到哪。
  • 线程间共享(生命周期与虚拟机的生命周期保持一致):

      • 堆区作用:此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。(从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存)
      • 年轻代和老年代
        • 堆区可以划分为年轻代和老年代,年轻代又可划分为Eden区、两块大小相同的Survivor区(又称为from区和to区,to区总为空),几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象的销毁也都在新生代进行;老年代存放新生代中经历多次GC仍然存活的对象;
        • 对象分配过程:
          1. 大部分new的对象先放到Eden区,当Eden区的空间填满而程序又需要创建对象时,JVM的Minor GC(Young GC)将对Eden区进行垃圾回收,将Eden去中不再被其他对象所引用的对象销毁,再加载新的对象放到Eden区,然后将Eden区中的剩余的存活对象移动到from区;
          2. 如果再次Minor GC,此时上次幸存下来放到from区的对象,如果没有被回收就会被放到to区;
          3. 如果再次经历垃圾回收,此时会重新回到from区,接下来再去to区,不断循环此过程直到计数器到达设置值(默认为15次),如果还没有被回收就从年轻代移动到老年代;
          4. 老年代空间不足时会触发Major GC(也可认为发生了Full GC),如果Major GC之后空间还不足就报OOM异常了
        • 【对象分配原则】:
          • 优先分配到新生代的Eden区
          • 大对象(需要大量连续内存空间的对象,如长字符串或数组)直接分配到老年代
          • 长期存活(指达到晋升老年代的年龄阈值)的对象将分配到老年代
          • 如果一次Young GC后存活的对象过多而Survivor区无法容纳,就直接进入老年代
          • 如果Survivor区中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到-XX:MaxTenuringThreshold要求的年龄阈值(动态对象年龄判断机制)
          • 在每次发生Minor GC之前,JVM必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立则会进行一次能确保安全的Minor GC;如果不满足则会查看是否允许担保失败,允许的话就比较老年代可用内存大小和历次Minor GC之后升入老年代的平均对象大小,大于平均值则进行一次有风险的Minor GC,如果小于平均值或不允许担保失败则进行一次Full GC(空间分配担保机制)
    • 方法区(元空间)

      • 方法区作用:用于存储已被虚拟机加载的类型信息(包括域信息、方法信息)、编译期生成的各种字面量与符号引用(存放在运行时常量池中)、即时编译器编译后的JIT代码缓存等

      • 栈、堆和方法区的交互关系

        1
        Person person = new Person();

        person是方法局部变量,存放在Java虚拟机栈该方法对应栈帧的局部变量表中,指向在堆空间中new Person()创建的对象实例数据,在对象实例数据中有到对象数据类型的指针,指向在方法区中的对象类型数据Person;

24.【Java对象创建过程的五个步骤】

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程

Step2:分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需的内存大小在类加载完成后便可确定。分配方式有 “指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配的两种方式:

  • 指针碰撞 :
    • 适用场合 :堆内存规整(即没有内存碎片)的情况下。
    • 原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表 :
    • 适用场合 : 堆内存不规整(即有内存碎片)的情况下。
    • 原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
    • 使用该分配方式的 GC 收集器:CMS

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。

内存分配并发问题:

在创建对象的时候还有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些设置信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init() 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

✨👏Spring

✨1. 【什么是控制反转(IoC)?什么是依赖注入(DI)?】

控制反转(Inversion of Control)是一种是面向对象编程中的一种设计思想,目的是用来减低计算机代码之间的耦合度。其基本思想是:借助于“第三方”实现具有依赖关系的对象之间的解耦

  • 控制体现在全部对象交给“第三方”IoC容器控制,由IoC容器来控制对象的创建
  • 反转体现在由容器帮我们查找及注入依赖对象,对象只是被动的接受依赖对象,依赖对象的获取关系被反转了

依赖注入(dependency injection)是在编译阶段不知道所需的功能是来自哪个的类的情况下,将其他对象所依赖的功能对象实例化的模式,即由容器动态的将某个依赖关系注入到组件之中

注意:控制反转是一种设计思想,依赖注入是一种设计模式,IoC框架使用依赖注入作为实现控制反转的具体方式,在Spring Framework中使用过函数和setter注入;

2. Spring容器:BeanFactory和ApplicationContext有什么区别?

  1. BeanFactory和ApplicationContext是IoC容器的两种实现方式,其中BeanFactory是Spring内部的使用接口,一般不提供给开发人员进行使用,包含了各种Bean的定义,读取bean配置文档,管理bean的加载、实例化,控制bean的生命周期,维护bean之间的依赖关系。ApplicationContext接口是BeanFactory的子接口,除了提供BeanFactory所具有的功能外,还提供了更完整的框架功能,如继承MessageSource支持国际化、统一的资源文件访问方式、提供在监听器中注册bean的事件、同时加载多个配置文件等;
  2. BeanFactroy采用的是延迟加载形式来注入Bean,在加载配置文件时不会创建对象,在获取对象时才会创建对象;ApplicationContext在容器启动时一次性创建所有的Bean,在加载配置文件时就会把配置文件对象进行创建;

3. Spring 框架中都用到了哪些设计模式?

(1)代理模式:Spring AOP 就是基于动态代理的

(2)单例模式:Spring 中 bean 的默认作用域就是Singleton的。

(3)模板方法模式:Spring 中 jdbcTemplate、hibernateTemplate等以 Template 结尾的对数据库操作的类就使用到了模板模式。

(4)委派模式:Srping 提供了 DispatcherServlet 来对请求进行分发。

(5)工厂模式:通过 BeanFactoryApplicationContext 创建 bean 对象

(6)观察者模式: Spring 事件驱动模型

(7)适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、Spring MVC 中也是用到了适配器模式适配Controller

✨4.【 什么是AOP?了解AOP实现动态代理的两种方式吗?】

AOP(Aspect Oriented Programming)是面向切面编程,目的是为了解耦,基本单元是切面,即将公共的代码逻辑,比如统一处理日志、异常等,抽象出来变成一个切面然后注入到目标对象(具体代码)中去,通过动态代理的方式将需要注入切面的对象进行代理,在进行调用时将公共的逻辑直接添加进去而不需要修改原有业务的逻辑代码,只需要在原来业务逻辑基础上做一些增强功能即可;

名词解释:

  • 切面:关注点模块化,这个关注点可能会横切多个对象,可以使用通用类基于模式的方式或者在普通类中以@Aspect注解方式来实现;
  • 连接点:在程序执行过程中某个特定的点,一个连接点总是代表一个方法的执行;
  • 切点:匹配连接点的断言,通知和切点表达式相关联,并且在满足这个切点的连接点上运行特定通知
  • 通知:在切面的某个特定的连接点上执行的动作,使用before、after注解等实现;

aop常用注解:

  • @Before:前置通知,在目标方法之前执行
  • @After:后置通知,在目标方法之后执行(始终会执行)
  • @AfterReturning:返回后通知,执行方法结束前执行(异常则不执行)
  • @AfterThrowing:异常通知,出现异常时执行
  • @Around:环绕通知,环绕目标方法执行

aop两种实现方式:

  • 静态代理(也称为编译时增强)

    使用AOP框架提供命令进行编译,在编译阶段就生成AOP代理类

  • 动态代理(也称为运行时增强)

    在运行时在内存中生成AOP动态代理类

    • JDK动态代理:通过反射来接收被代理的类,并且要求被代理的类必须实现一个接口,使用Proxy类去创建代理对象,其核心是InvocationHandler接口和Proxy类;
    • CGLIB动态代理:如果目标类没有实现接口,那么Spring AOP会选择使用CGLIB来动态代理目标类,CGLIB类库在运行时动态生成目标类的一个子类作为代理对象(注意:CGLIB通过继承方式进行动态代理,若目标类标记为final,则它是无法使用CGLIB做动态代理的)

    JDK动态代理与Cglib动态代理区别?

    1. 当要代理的对象是实现了某个接口的,Spring AOP会使用JDK动态代理,生成一个实现相同接口的代理类;
    2. 当某个类是没有实现接口的,Spring AOP会使用Cglib动态代理,生成这个类的一个子类,子类动态生成字节码覆盖该类的一些方法,在方法中加入增强代码;
4.1 利用AOP实现Web日志处理
  1. 在pom.xml引入aop相关依赖;
  2. 创建一个WebLogAspect类,在webLog方法上加上@Pointcut注解定义横切点,再配合aop相关注解实现切面逻辑;

5.1 【Spring是如何管理Bean的?Spring的Bean作用域有哪些?】

Spring通过IoC容器来管理Bean,我们可以通过xml或者注解方式配置,来指导IoC容器对Bean的管理;

以下是管理Bean时常用的一些注解:

  1. @ComponentScan用于声明扫描策略,通过它的声明,容器就知道要扫描哪些包下带有声明的类,也可以知道哪些特定的类是被排除在外的。

  2. @Component、@Repository、@Service、@Controller用于声明Bean,它们的作用一样,但是语义不同。@Component用于声明通用的Bean,@Repository用于声明DAO层的Bean,@Service用于声明业务层的Bean,@Controller用于声明视图层的控制器Bean,被这些注解声明的类就可以被容器扫描并创建。

  3. @Autowired、@Qualifier用于注入Bean,即告诉容器应该为当前属性注入哪个Bean。其中,@Autowired是按照Bean的类型进行匹配的,如果这个属性的类型具有多个Bean,就可以通过@Qualifier指定Bean的名称,以消除歧义。

  4. @Scope用于声明Bean的作用域,默认情况下Bean是单例的,即在整个容器中这个类型只有一个实例。可以通过@Scope注解指定prototype值将其声明为多例的,也可以将Bean声明为session级作用域、request级作用域等等,但最常用的还是默认的单例模式。

    • Spring中的Bean的作用域

      类型 说明
      singleton(默认作用域) 在Spring容器中仅存在一个实例,即Bean以单例的形式存在。
      prototype 每次调用getBean()时,都会执行new操作,为每个bean请求提供一个新的实例。
      request 每次HTTP请求都会创建一个新的Bean,请求完成以后bean失效并被GC回收。
      session 同一个HTTP Session共享一个Bean,不同的HTTP Session使用不同的Bean。
      globalSession 同一个全局的Session共享一个Bean,一般用于Portlet环境。
    • 【Spring中的Bean是线程安全的吗?】

      Spring中的Bean是否是线程安全的需要分情况讨论:

      • 对于原型Bean,每次都创建一个新对象,也就是线程之间并不存在Bean共享,自然是不会有线程安全的问题。

      • 对于单例Bean,所有线程都共享一个单例实例Bean,因此存在资源的竞争。如果单例Bean是一个无状态Bean,也就是每个线程中对全局变量、静态变量只有读操作而无写操作即不保存数据,那么这个单例Bean是线程安全的。若有多个线程同时执行写操作,就变成需要保存数据的有状态Bean,可以使用ThreadLocal解决线程安全问题

  5. @PostConstruct、@PreDestroy用于声明Bean的生命周期。其中,被@PostConstruct修饰的方法将在Bean实例化后被调用,@PreDestroy修饰的方法将在容器销毁前被调用。

5.2 什么是Bean的自动装配,它有哪些方式?

bean的自动装配:指的是bean的属性值在进行注入时通过某种特定的规则和方式去容器中查找,并设置到具体的对象属性中,它主要有以下几种方式:

  • no:缺省不自动装配,通过ref属性手动设定,在项目中最常见;
  • byName:根据属性名称自动装配,如果一个bean的名称和其他bean属性名称一样将会兼容并自动装配
  • byType:根据数据类型自动装配,如果一个bean的数据类型和其他bean数据类型一样将会兼容并自动装配

6. 【简述Bean的生命周期】(待补充)

  1. 实例化bean对象,通过反射的方式进行对象的创建,此时的创建只是在堆空间中申请空间,属性都是默认值;
  2. 设置对象属性,给对象中的属性进行值的设置工作;
  3. 检查Aware相关接口并设置相关依赖;
  4. BeanPostProcessor的前置处理,对生成的bean对象进行前置的处理工作;
  5. 检查是否是InitializingBean的子类来决定是否调用afterPropertiesSet方法;
  6. 检查是否配置有自定义的init-method方法,如果当前bean对象定义了初始化方法,那么在此处调用初始化方法;
  7. BeanPostProcessor后置处理,对生成的bean对象进行后置的处理工作;
  8. 注册必要的Destruction相关回调接口

✨7.1 【介绍一下SpringMVC的工作流程】

  1. 客户端发送请求至前端控制器DispatcherServlet;
  2. DispatcherServlet收到请求调用HandlerMapping处理器映射器,解析请求对应的Handler(即Controller);
  3. 处理器映射器根据注解或xml配置找到具体的Handler,生成处理器对象及拦截器后一并返回给DispatcherServlet;
  4. DispatcherServlet调用HandlerAdapter处理器适配器,HandlerAdapter经过适配,调用相应Handler的具体处理器方法;
  5. Handler执行完成返回ModelAndView给HandlerAdapter,HandlerAdapter再将Controller的执行结果ModelAndView返回给DispatcherServlet;
  6. DispatcherServlet将ModelAndView传给ViewResolver视图解析器,ViewResolver解析后返回具体的View给DispatcherServlet;
  7. DispatcherServlet根据View进行视图渲染后返回响应给请求的用户。

7.2 简单介绍Spring MVC的核心组件

  • DispatcherServlet:前端控制器,是请求的入口,负责协调各个组件工作;
  • HandlerMapping:处理器映射器,是请求的处理器匹配器,负责为请求找到合适的处理器执行链,包含处理器和拦截器们;
  • HandlerAdapter:处理器适配器,因为处理器Handler的类型是Object,需要有一个调用者来实现Handler是怎样被执行去具体执行处理器的;
  • ViewResolver:视图解析器,负责获取视图View对象;
  • HandlerExceptionResolver:处理器异常解析器,将处理器执行时发生的异常,解析成对应的ModelAndView结果;

✨8. 【Spring循环依赖是怎么产生的?Spring是如何解决的循环依赖】(待补充)

例子:有一个A对象,创建A的时候发现A对象依赖B,然后去创建B对象的时候,又发现B对象依赖A,即就是A依赖B的同时B也依赖了A,这就产生了最简单的循环依赖;

Spring对循环依赖的处理有三种情况:

  1. 构造器的循环依赖:这种依赖Spring无法处理,直接抛出BeanCurrentlyInCreationException异常;
  2. 单例模式下的setter循环依赖:通过三级缓存处理循环依赖
  3. 非单例循环依赖:无法处理
8.1 Spring是如何解决的循环依赖?

Spring通过三级缓存解决了循环依赖,其中一级缓存为单例池(singletonObjects), 二级缓存为早期曝光对象earlySingletonObjects,三级缓存为早期曝光对象工厂(singletonFactories)。当A、B两个类发生循环引用时,在A完成实例化后,就使用实例化后的对象去创建一个对象工厂,并添加到三级缓存中,如果A被AOP代理,那么通过这个工厂获取到的就是A代理后的对象,如果A没有被AOP代理,那么这个工厂获取到的就是A实例化的对象。当A进行属性注入时,会去创建B,同时B又依赖了A,所以创建B的同时又会去调用getBean(a)来获取需要的依赖,此时的getBean(a)会从缓存中获取,第一步,先获取到三级缓存中的工厂;第二步,调用对象工工厂的getObject方法来获取到对应的对象,得到这个对象后将其注入到B中。紧接着B会走完它的生命周期流程,包括初始化、后置处理器等。当B创建完后,会将B再注入到A中,此时A再完成它的整个生命周期。至此,循环依赖结束!

8.2 为什么要使用三级缓存呢?二级缓存能解决循环依赖吗?

如果要使用二级缓存解决循环依赖,意味着所有Bean在实例化后就要完成AOP代理,这样违背了Spring设计的原则,Spring在设计之初就是通过AnnotationAwareAspectJAutoProxyCreator这个后置处理器来在Bean生命周期的最后一步来完成AOP代理,而不是在实例化后就立马进行AOP代理。

9.1 【Spring的事务实现原理是什么?其如何管理事务?常用的事务传播机制和事务隔离级别有哪些?】

Spring可以让用户以统一的编程模型进行事务管理,Spring支持两种事务编程模型:

  1. 编程式事务

    Spring提供了TransactionTemplate模板,利用该模板可以通过编程的方式实现事务管理,而无需关注资源获取、复用、释放、事务同步及异常处理等操作。

  2. 声明式事务

    Spring声明式事务管理允许我们通过声明的方式,在IoC配置中指定事务的边界和事务属性,Spring会自动在指定的事务边界上应用事务属性。相对于编程式事务来说,只需要在需要做事务管理的方法上增加**@Transactional注解**,以声明事务特征即可。在@Transactional注解中如果不配置rollbackFor属性,那么事务只会在遇到RuntimeException的时候才会回滚,加上rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚;

    • 对于声明式事务,是使用@Transactional进行标注的。这个注解可以标注在类或者方法上。

      • 当它标注在类上时,代表这个类所有公共非静态(public)的方法都将启用事务功能。
      • 当它标注在方法上时,代表这个方法将启用事务功能。

      在@Transactional注解上,我们可以使用isolation属性声明事务隔离级别,使用propagation属性声明事务传播机制

      常用事务传播机制如下:(规定多个事务方法相互调用时,事务如何在这些方法之间进行传播)

      • REQUIRED:如果当前存在事务就加入该事务,如果当前不存在事务就新建一个新事务(默认事务传播机制)
      • SUPPORTS:如果当前存在事务就加入该事务,如果当前不存在事务则以非事务方式执行;
      • MANDATORY:如果当前存在事务就加入该事务,如果当前不存在事务则抛出异常;
      • REQUIRE_NEW:无论当前是否存在事务都创建一个新事务,如果当前存在事务就挂起当前事务;
      • NESTED:如果当前存在事务就创建一个嵌套事务,如果当前不存在事务就按照REQUIRED属性执行;
      • NOT_SUPPORTED:以非事务方式执行,如果当前存在事务则挂起该事务;

      常用事务隔离级别如下:

      Spring的事务隔离级别与数据库的隔离级别一样;(当Spring和数据库的隔离级别不一致时,以Spring的配置为主)

9.2 Spring事务什么时候会失效?

  1. bean对象没有被Spring容器管理;
  2. 方法的访问修饰符不是public;
  3. 数据源没有配置事务管理器;
  4. 数据库不支持事务;
  5. 自身调用问题;

✨10.【说一说你知道的Spring MVC注解】

@RequestMapping:

作用:用来处理请求地址映射的,也就是说将具体的处理器方法映射到url路径上。

属性:

  • method:是让你指定请求的method的类型,比如常用的有get和post。(也可以直接使用派生注解@GetMapping或@PostMapping)
  • value:是指请求的实际地址,如果是多个地址就用{}来指定就可以啦。
  • produces:指定返回的内容类型,当request请求头中的Accept类型中包含指定的类型才可以返回的。
  • consumes:指定处理请求的提交内容类型,比如一些json、html、text等的类型。
  • headers:指定request中必须包含那些的headed值时,它才会用该方法处理请求的。
  • params:指定request中一定要有的参数值,它才会使用该方法处理请求。

@RequestParam:

作用:是将请求参数绑定到你的控制器的方法参数上,是Spring MVC中的接收普通参数的注解,用于获取查询参数。

属性:

  • value是请求参数中的名称。
  • required是请求参数是否必须提供参数,它的默认是true,意思是表示必须提供。

@RequestBody:

作用:作用在形参列表上,用于将前台发送过来固定格式的数据封装为对应的JavaBean对象。

属性:required,是否必须有请求体。它的默认值是true,在使用该注解时,值得注意的当为true时get的请求方式是报错的,如果你取值为false的话,get的请求是null。

@ResponseBody:

作用:作用在方法上,表示该方法的返回结果直接写入HTTP response body中,一般在异步获取数据时使用。

@PathVariable:

作用:该注解是用于绑定url中的占位符,用于获取路径参数;

✨11. 说一下你知道的Spring / SpringBoot注解?SpringBoot的自动装配原理?

@SpringBootApplication:

  • @SpringBootApplication看作是 @Configuration、@EnableAutoConfiguration、@ComponentScan注解的集合
    • @EnableAutoConfiguration:打开自动装配功能

      • SpringBoot的自动装配原理

        @SpringBootApplication注解中组合了一个@EnableAutoConfiguration注解,作用是打开自动装配,而这个注解中又包含了一个@Import注解,在这个注解中引入了一个实现了ImportSelector接口的类,在对应的selectImports()方法中会读取META-INF目录下的spring.factories文件中需要被自动装配的所有配置类,然后通过META-INF下面的spring-autoconfigure-metadata.properties文件做条件过滤,最后返回的就是需要自动装配的相关的对象;

    • @ComponentScan: 默认情况下会扫描当前包及其子包下所有被该注解修饰的Java类;

    • @Configuration:标识为一个Java配置类,允许在Spring上下文中注册额外的 bean 或导入其他配置类;

@Autowired:

  • 自动导入对象到类中,被注入进的类同样要被 Spring 容器管理比如:Service 类注入到 Controller 类中

@Component、@Repository、@Service、@Controller:

我们一般使用 @Autowired 注解让 Spring 容器帮我们自动装配 bean。要想把类标识成可用于 @Autowired 注解自动装配的 bean 的类,可以采用以下注解实现:

  • @Component :通用的注解,可标注任意类为 Spring组件。如果一个 Bean 不知道属于哪个层,可以使用@Component注解标注。
  • @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。
  • @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。
  • @Controller : 对应 Spring MVC 的控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。

@RestController:

@RestController注解是@Controller和@ResponseBody的合集,表示这是个控制器 bean,并且是将函数的返回值直接填入 HTTP 响应体中,是 REST 风格的控制器(用于返回Json)。

全局处理 Controller 层异常注解:

  1. @ControllerAdvice : 注解定义全局异常处理类
  2. @ExceptionHandler : 注解声明异常处理方法

12. SpringBoot项目需要单独的web容器吗?

可以不需要,在SpringBoot项目中添加spring-boot-starter-web依赖,这个依赖中内嵌了Tomcat容器;

✨13. 单例模式的创建方式

👏MyBatis

1. 什么是ORM框架?

ORM(Object Relation Mapping)对象关系映射,是把数据库中的关系数据映射成为程序中的对象。

✨2. MyBatis 中 #{}和 ${}的区别是什么?

  1. #{}是预编译处理,${}是字符串替换;
  2. Mybatis在处理#{}时,会把sql中的#{}替换为?,调用PrepareStatement的set方法来赋值;
  3. Mybatis在处理${}时,就是把${}替换成变量的值;
  4. 使用#{}可以有效的防止sql注入问题,提高系统安全性;

3. MyBatis 有几种分页方式?分页方式区别是什么?

逻辑分页: 使用 MyBatis 自带的 RowBounds 进行分页,它是一次性查询很多数据,然后在数据中再进行检索。

物理分页: 自己手写 SQL 分页或使用分页插件 PageHelper,去数据库查询指定条数的分页数据的形式。

两种分页方式区别:

  • 逻辑分页是一次性查询很多数据,然后再在结果中检索分页的数据。 这样做弊端是需要消耗大量的内存、有内存溢出的风险、对数据库压力较大。
  • 物理分页是从数据库查询指定条数的数据,弥补了一次性全部查出所有数据的缺点。

4. MyBatis 是否支持延迟加载?延迟加载的原理是什么?

MyBatis仅支持 association 关联对象和 collection 关联集合对象的延迟加载,association 指的就是一对一查询,collection 指的就是一对多查询,可通过设置 lazyLoadingEnabled=true / false决定是否延迟加载。

延迟加载的原理:使用cglib创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用 a.getB().getName(),拦截器 invoke()方法发现a.getB()是null值时,那么就会单独发送事先保存好的查询关联 B 对象的 sql,把B查询上来,然后再调用 a.setB(b),于是 a 的对象 b 属性就有值了,接着完成 a.getB().getName()方法的调用。

5. 【说一下 MyBatis 的一级缓存和二级缓存?】

一级缓存:Mybatis的一级缓存是指Session缓存,默认开启一级缓存,其作用域默认是一个SqlSession,也就是在同一个SqlSession中,执行相同的查询SQL,第一次会去数据库进行查询,并写到缓存中,第二次以后是直接去缓存中取;当 Session flush 或 close 之后,MyBatis会把SqlSession的缓存清空。

二级缓存:Mybatis的二级缓存是指mapper映射文件。二级缓存的作用域是同一个namespace下的mapper映射文件内容,由多个SqlSession共享。Mybatis需要手动设置启动二级缓存。

6. 当实体类中的属性名和表中的字段名不一样 ,怎么办 ?

  • 方法一:通过在查询的 sql 语句中定义字段名的别名,让字段名的别名和实体类的属性名一致
  • 方法二:通过映射字段名和实体类属性名的一一对应的关系。

8. 模糊查询 like 语句该怎么写?

  • 方法一:在 Java 代码中添加 sql 通配符,通过#{}赋值。
  • 方法二:在 sql 语句中拼接通配符(注意这种写法会引起 sql 注入问题)

9. Mybatis是如何将sql执行结果封装为目标对象并返回的? 都有哪些映射形式?

  • 第一种是使用标签,逐一定义数据库列名和对象属性名之间的映射关系。
  • 第二种是使用 sql 列的别名功能,将数据库表中列的别名书写为对象属性名。

有了列名与属性名的映射关系后,Mybatis 通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。

10. Mybatis的Xml映射文件中,不同的Xml映射文件,id是否可以重复?

不同的 Xml 映射文件,如果配置了 namespace,那么 id 可以重复;如果没有配置 namespace,那么 id 不能重复; 原因就是 namespace+id 是作为Mapper的 key 使用的,如果没有 namespace,就剩下 id,那么id 重复会导致数据互相覆盖。

11. 什么是 MyBatis 的接口绑定?有哪些实现方式?

接口绑定:就是在 MyBatis 中定义任意接口,然后把接口里面的方法和 SQL 语句绑定, 我们直接调用接口方法就可以了;

接口绑定有两种实现方式:

  • 一种是通过注解绑定,就是在接口的方法上面加上 @Select、@Update 等注解,里面包含 Sql 语句来绑定;(不便于修改)
  • 另外一种就是通过 xxxMapper.xml 里面写 SQL语句来绑定, 在这种情况下,要指定 xml 映射文件里面的 namespace 必须为接口的全路径名。

12. 【使用MyBatis的 mapper接口调用时有哪些注意事项?】

  1. Mapper接口方法名和mapper.xml中定义的每个 sql 的 id 相同;
  2. Mapper接口方法的输入参数类型和mapper.xml中定义的每个sql的parameterType的类型相同;
  3. Mapper接口方法的输出参数类型和mapper.xml中定义的每个sql的resultType的类型相同;
  4. Mapper.xml 文件中的 namespace 即是mapper接口的类路径(即全类名)。

13. 【通常一个 Xml 映射文件,都会写一个 Dao 接口与之对应,请问这个 Dao 接口的工作原理是什么?Dao 接口里的方法参数不同时方法能重载吗?】

  • Dao 接口,就是人们常说的 Mapper 接口,接口的全限名,就是映射文件中的 namespace 的值,接口的方法名,就是映射文件中 MappedStatement 的 id 值,接口方法内的参数,就是传递给 sql 的参数。 Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为 key 值,可唯一定位一个 MappedStatement;在 MyBatis 中,每一个 <select><insert><update><delete> 标签,都会被解析为一个 MappedStatement 对象。

  • Dao 接口的工作原理是 JDK 动态代理,MyBatis 运行时会使用 JDK 动态代理为 Dao 接口生成代理 proxy 对象,代理对象 proxy 会拦截接口方法,转而执行 MappedStatement 所代表的 sql,然后将 sql 执行结果返回。

  • Dao 接口里的方法可以重载,但是 XML 里面的 ID 不允许重复,因为通过 Dao 寻找 XML对应的 sql 时以全限名+方法名的保存和寻找策略。即多个接口对应的映射必须只有一个,且需要满足以下条件:

    • 多个有参方法时,参数数量必须一致。且使用相同的 @Param ,或者使用 param1 这种方式;

14. Mybatis 都有哪些 Executor 执行器?它们之间的区别是什么?

Mybatis 有三种基本的 Executor 执行器:SimpleExecutor、ReuseExecutor、 BatchExecutor。

  1. SimpleExecutor:每执行一次 update 或 select,就开启一个 Statement 对 象,用完立刻关闭 Statement 对象。
  2. ReuseExecutor:执行 update 或 select,以 sql 作为 key 查找 Statement 对象,存在就使用,不存在就创建,用完后不关闭 Statement 对象, 而是放置于Map。
  3. BatchExecutor:完成批处理。

✨消息队列RabbitMQ

1. 说说Broker服务节点、Queue队列、Exchange交换机

  • Broker:一般情况下一个Broker可以看作一个RabbitMQ的服务器
  • Queue:RabbitMQ用于存储消息的内部对象,多个消费者可以订阅同一队列,这时队列中的消息会以轮询方式给多个消费者进行处理;
  • Exchange:生产者将消息发送给交换机,由交换机将消息路由到一个或者多个队列中,当路由不到时,返回给生产者或直接丢弃

2. 如何进行消息队列的技术选型?

2.1 为什么使用消息队列?
  • 解耦:传统的软件开发模式,各个模块之间相互调用,每个模块都要时刻关注其他模块的是否更改或者是否挂掉等等,数据耦合度高,使用消息队列的发布-订阅模式可以避免模块之间直接调用,将所需共享的数据放在消息队列中,对于新增业务模块,只要对该类消息感兴趣,即可订阅该类消息,对原有系统和业务没有任何影响,降低了系统各个模块的耦合度,提高了系统的可扩展性。
  • 异步:消息队列提供了异步处理机制,在很多时候应用不想也不需要立即处理消息,允许应用把一些消息放入消息中间件中,并不立即处理它,在之后需要的时候再慢慢处理(减少响应所需的时间)
  • 削峰高峰期的消息可以被积压起来,在随后的时间内进行平滑的处理完成,而不至于让系统短时间内无法承载而导致崩溃。在电商网站的秒杀抢购这种突发性流量很强的业务场景中,消息队列的强大缓冲能力可以很好的起到削峰作用。
2.2 消息队列有什么缺点?
  • 系统可用性降低:因为如果MQ出故障了就相当于整个系统就崩溃了
  • 系统复杂性提高:MQ存在消息重复消费、处理消息丢失情况、保证消息传递顺序性等问题
  • 数据一致性问题:消息队列可以实现异步确实可以提高系统响应速度。但是万一消息的真正消费者并没有正确消费消息就会导致数据不一致的情况
2.3 【Kafka、RabbitMQ、RocketMQ、ActiveMQ之间的区别】
特 性 ActiveMQ RabbitMQ RocketMQ Kafka
单机吞吐量 万级,吞吐量比RocketMQ和Kafka要低了一个数量级 万级,吞吐量比RocketMQ和Kafka要低了一个数量级 10万级,RocketMQ也是可以支撑高吞吐的一种MQ 10万级别,这是kafka最大的优点,就是吞吐量高。 一般配合大数据类的系统来进行实时数据计算、日志采集等场景
topic数量对吞吐量的影响 topic可以达到几百,几千个的级别,吞吐量会有较小幅度的下降 这是RocketMQ的一大优势,在同等机器下,可以支撑大量的topic topic从几十个到几百个的时候,吞吐量会大幅度下降 所以在同等机器下,kafka尽量保证topic数量不要过多。如果要支撑大规模topic,需要增加更多的机器资源
时效性 ms级 微秒级,这是rabbitmq的一大特点,延迟是最低的 ms级 延迟在ms级以内
可用性 高,基于主从架构实现高可用性 高,基于主从架构实现高可用性 非常高,分布式架构 非常高,kafka是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用
消息可靠性 有较低的概率丢失数据 经过参数优化配置,可以做到0丢失 经过参数优化配置,消息可以做到0丢失
功能支持 MQ领域的功能极其完备 基于erlang开发,所以并发能力很强,性能极其好,延时很低 MQ功能较为完善,还是分布式的,扩展性好 功能较为简单,主要支持简单的MQ功能,在大数据领域的实时计算以及日志采集被大规模使用,是事实上的标准
优劣势总结 非常成熟,功能强大,在业内大量的公司以及项目中都有应用 偶尔会有较低概率丢失消息 而且现在社区以及国内应用都越来越少,官方社区现在对ActiveMQ 5.x维护越来越少,几个月才发布一个版本 而且确实主要是基于解耦和异步来用的,较少在大规模吞吐的场景中使用 erlang语言开发,性能极其好,延时很低; 吞吐量到万级,MQ功能比较完备 而且开源提供的管理界面非常棒,用起来很好用 社区相对比较活跃,几乎每个月都发布几个版本分 在国内一些互联网公司近几年用rabbitmq也比较多一些 但是问题也是显而易见的,RabbitMQ确实吞吐量会低一些,这是因为他做的实现机制比较重。 而且erlang开发,国内有几个公司有实力做erlang源码级别的研究和定制?如果说你没这个实力的话,确实偶尔会有一些问题,你很难去看懂源码,你公司对这个东西的掌控很弱,基本职能依赖于开源社区的快速维护和修复bug。 而且rabbitmq集群动态扩展会很麻烦,不过这个我觉得还好。其实主要是erlang语言本身带来的问题。很难读源码,很难定制和掌控。 接口简单易用,而且毕竟在阿里大规模应用过,有阿里品牌保障 日处理消息上百亿之多,可以做到大规模吞吐,性能也非常好,分布式扩展也很方便,社区维护还可以,可靠性和可用性都是ok的,还可以支撑大规模的topic数量,支持复杂MQ业务场景 而且一个很大的优势在于,阿里出品都是java系的,我们可以自己阅读源码,定制自己公司的MQ,可以掌控 社区活跃度相对较为一般,不过也还可以,文档相对来说简单一些,然后接口这块不是按照标准JMS规范走的有些系统要迁移需要修改大量代码 还有就是阿里出台的技术,你得做好这个技术万一被抛弃,社区黄掉的风险,那如果你们公司有技术实力我觉得用RocketMQ挺好的 kafka的特点其实很明显,就是仅仅提供较少的核心功能,但是提供超高的吞吐量,ms级的延迟,极高的可用性以及可靠性,而且分布式可以任意扩展 同时kafka最好是支撑较少的topic数量即可,保证其超高吞吐量 而且kafka唯一的一点劣势是有可能消息重复消费,那么对数据准确性会造成极其轻微的影响,在大数据领域中以及日志采集中,这点轻微影响可以忽略 这个特性天然适合大数据实时计算以及日志收集

3. 引入MQ后如何保证其高可用性?

  • RabbitMQ的高可用性
    • 普通集群模式(只是用来提高吞吐量,可用性无法保证且可能产生大量数据传输)
    • 镜像集群模式:跟普通集群模式不一样的是,你创建的queue,无论元数据还是queue里的消息都会存在于多个实例上,然后每次你写消息到queue的时候,都会自动把消息拷贝到多个实例的queue里进行消息同步;

4. 【如何保证消息不被重复消费(如何保证消息消费时的幂等性)?】

  • 在生产的每一条消息添加业务id作为唯一标识,使用 redis 将消费过的消息唯一标识存储起来,然后在消费端业务执行之前判断 redis 中是否已经存在这个标识,如果已经存在代表处理过了,不存在就放进 redis 并根据要求设置过期时间接着执行业务。
  • 将业务id作为数据库表的唯一索引
  • 给业务表加一个version字段,每次更新把version作为条件,更新之后version+1。由于MySQL的innoDB是行锁,当其中一个请求成功更新之后,另一个请求才能进来,由于版本号version已经变成 2,更新的 SQL 语句影响行数为0,从而不会影响数据库数据

5. 【如何保证RabbitMQ消息的可靠性传输(如何处理消息丢失的问题)?】

  • 生产者端数据丢失

    • 可以选择使用RabbitMQ提供的事务功能,就是生产者在发送数据之前开启事务然后发送消息,如果消息没有成功被RabbitMQ接收到,那么生产者会收到异常报错,这时就可以回滚事务,然后尝试重新发送,如果收到了消息那么就可以提交事务。但这种方式有明显的缺点,即当RabbitMQ事务开启后,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。
    • 可以开启confirm模式。在生产者那里设置开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后写入了RabbitMQ之中,RabbitMQ会给你回传一个ack消息,告诉你这个消息发送OK了。如果RabbitMQ没能处理这个消息,会回调你一个nack消息,告诉你这个消息失败了,你可以进行重试。而且可以结合这个机制知道在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。

    事务机制是同步的,你提交了一个事物之后会阻塞住,但是confirm机制是异步的,发送消息之后可以接着发送下一个消息,然后RabbitMQ会回调告知成功与否。 一般在生产者这块避免丢失,都是用confirm机制

  • MQ数据丢失

    设置消息持久化到磁盘,设置持久化有两个步骤:

    • 创建queue时将其设置为持久化的,这样就可以保证RabbitMQ持久化queue的元数据,但是不会持久化queue里面的数据。
    • 发送消息的时候将消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时RabbitMQ就会将消息持久化到磁盘上。 必须要同时开启这两个才可以,RabbitMQ 哪怕是挂了,再次重启也会从磁盘上重启恢复这个 queue 里的数据。

    而且持久化可以跟生产者的confirm机制配合起来,避免出现消息写到了 RabbitMQ 中,但是还没来得及持久化到磁盘上就挂了,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前RabbitMQ挂了导致数据丢了,生产者收不到ack回调也会进行消息重发。

  • 消费者端数据丢失

    使用RabbitMQ提供的basicAck机制,首先关闭RabbitMQ的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就返回ack。

6. 【如何保证消息的有序性?(即如何保证顺序消费)】

RabbitMQ 产生该问题是由于不同的消息都发送到了同一个 queue 中,多个消费者都消费同一个 queue 的消息。为了解决这个问题我们可以给 RabbitMQ 创建多个 queue,每个消费者固定消费一个 queue 的消息,生产者发送消息的时候,同一个订单号的消息发送到同一个 queue 中,由于同一个 queue 的消息是一定会保证是有序的,那么同一个订单号的消息就只会被一个消费者顺序消费,从而保证了消息的顺序性。

7.【 如何解决消息积压的情况?】

  • 临时紧急扩容,快速处理堆积信息:先修复consumer的问题,确保其恢复消费速度,然后将现有consumer都停掉,写一个临时的分发数据的consumer程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的10倍数量的queue,接着临时征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的数据,这种做法相当于是临时将queue资源和consumer资源扩大10倍,以正常的10倍速度来消费数据,等快速消费完积压数据之后,就恢复原先部署架构,重新用原先的consumer机器来消费消息
  • 丢弃+批量重导:如果消息积压在MQ里,并且长时间都没处理掉,导致MQ都快写满了,这种情况肯定是临时扩容方案执行太慢,这种时候只好采用 “丢弃+批量重导” 的方式来解决了。首先临时写个程序,连接到MQ里面消费数据,消费一个丢弃一个,快速消费掉积压的消息,降低MQ的压力,然后在流量低峰期时去手动查询重导丢失的这部分数据。

8. MQ处理消息失败了怎么办?

一般生产环境中,都会在使用MQ的时候设计两个队列:一个是核心业务队列,一个是死信队列。核心业务队列,就是比如专门用来让订单系统发送订单消息的,然后另外一个死信队列就是用来处理异常情况的。一旦标志这条消息处理失败了之后,MQ就会把这条消息转入提前设置好的一个死信队列中

9. RabbitMQ和Kafka有什么区别?

它们之间主要有如下的区别:

  1. 应用场景方面

    RabbitMQ:用于实时的,对可靠性要求较高的消息传递上。

    Kafka:用于处于活跃的流式数据,大数据量的数据处理上。

  2. 架构模型方面

    RabbitMQ:以broker为中心,有消息的确认机制。

    Kafka:以consumer为中心,没有消息的确认机制。

  3. 吞吐量方面

    RabbitMQ:支持消息的可靠传递,支持事务,不支持批量操作,基于存储的可靠性要求,存储可以采用内存或硬盘,吞吐量小。

    Kafka:内部采用消息的批量处理,数据的存储和获取是本地磁盘顺序批量操作,消息处理的效率高,吞吐量高。

  4. 集群负载均衡方面

    RabbitMQ:本身不支持负载均衡,需要loadbalancer的支持。

    Kafka:采用zookeeper对集群中的broker,consumer进行管理,可以注册topic到zookeeper上,通过zookeeper的协调机制,producer保存对应的topic的broker信息,可以随机或者轮询发送到broker上,producer可以基于语义指定分片,消息发送到broker的某个分片上。

10. RabbitMQ的构造

(1)Publisher:生产者,生产消息,就是投递消息的一方。消息一般包含两个部分:消息体(payload)和标签(Label)
(2)Consumer:消费者,消费消息,也就是接收消息的一方。消费者连接到RabbitMQ服务器并订阅到队列上。消费消息时只消费消息体,丢弃标签。
(3)Broker:服务节点,表示消息队列服务器实体。一般情况下一个Broker可以看做一个RabbitMQ服务器。
(4)Queue:消息队列,用来存放消息。一个消息可投入一个或多个队列,多个消费者可以订阅同一队列,这时队列中的消息会被平摊(轮询)给多个消费者进行处理。
(5)Exchange:交换器,接收生产者发送的消息,根据路由键将消息路由到绑定的队列上。

  • ✨交换机的类型:
    • direct:消息中的路由键(RoutingKey)如果和绑定中的 bindingKey 完全匹配,交换机就将消息发到对应的队列中。是基于完全匹配、单播的模式;
    • fanout:把发送到交换机的消息路由到所有绑定该交换机的队列中,是基于广播的模式;
    • topic:通过模式匹配的方式对消息进行路由,将路由键和某个路由模式进行匹配,此时队列需要绑定到一个模式上。

(6)Routing Key: 路由键,用于指定这个消息的路由规则,需要与交换器类型和绑定键(Binding Key)联合使用才能最终生效。
(7)Binding:绑定,通过绑定将交换器和队列关联起来,一般会指定一个BindingKey,通过BindingKey,交换器就知道将消息路由给哪个队列了。
(8)Connection :网络连接,比如一个TCP连接,用于连接到具体broker
(9)Channel: 信道,AMQP 命令都是在信道中进行的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为建立和销毁 TCP 都是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接,一个TCP连接可以用多个信道。客户端可以建立多个channel,每个channel表示一个会话任务。
(10)Message:消息,由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。
(11)Virtual host:虚拟主机,用于逻辑隔离,表示一批独立的交换器、消息队列和相关对象。一个Virtual host可以有若干个Exchange和Queue,同一个Virtual host不能有同名的Exchange或Queue。最重要的是,其拥有独立的权限系统,可以做到 vhost 范围的用户控制。当然,从 RabbitMQ 的全局角度,vhost 可以作为不同权限隔离的手段;

11. 生产者生产消息与消费者消费消息的过程

  • 生产者生产消息:Producer 先连接到Broker,建立连接Connection,开启一个信道Channel后,Producer 声明一个交换器和队列并设置好各自相关属性,通过绑定键将交换器和队列绑定起来;Producer 发送消息到 Broker,其中包含路由键、交换器等信息,交换器根据接收到的路由键查找匹配的队列,如果找到,将消息存入对应的队列,如果没有找到,会根据生产者的配置丢弃或者退回给生产者,消息发送完毕后关闭信道;

  • 消费者接收消费消息:Consumer先连接到Broker,建立连接Connection,开启一个信道Channel,向 Broker 请求消费相应队列中消息,设置响应的回调函数。等待 Broker 回应并投递相应队列中的消息后接收消息。消费者在消费消息后,会给消息队列发送一个确认Ack确认已经收到消息,之后从队列中删除已经确定消费的消息,消息消费完毕后关闭信道;

消息队列Kafka

1. Kafka如何保证消息消费的顺序性?

  • 对于Kafka ,生产者在写数据时可以指定一个key,具有相同key的数据会被分发到同一个partition中,且partition 中的消息在写入时都是有序的,可以在里面保证消息的顺序性,但是不同partition之间的消息是不保证有序的
  • 对于topic的一个 partition 只能被同组内部的一个consumer去消费,在单线程处理情况下只要保证消息在MQ内部是由顺序的即可保证消费也是有顺序的;在多线程处理情况下,可以预先设置N个Queue,具有相同key的数据都放到同一个内存Queue中,然后开启N个线程,每个线程分别消费一个内存Queue的数据即可保证顺序性;

2. Kafka如何保证高可用?

Kafka的基本架构组成是由多个broker组成一个集群,每个broker是一个节点(由一个或多个topic组成);当创建一个topic时,这个topic会被划分为多个partition,每个partition只存放topic的一部分数据,可以存放在不同的broker上,在Kafka 0.8后提供了replica副本机制,每个partition上的数据都会同步到其他机器上形成自己的多个replica副本,所有replica会选举一个leader出来,消息的生产者和消费者只与这个leader打交道,其他replica作为follower,写的时候leader会负责将数据同步到所有的follower上,读的时候直接读leader上的数据即可

3. Kafka如何保证消息不丢失?

  • 生产者端数据丢失

    对于生产者端数据丢失的主要情况:生产者发送消息给Kafka,由于网络等原因导致消息丢失

    解决办法:通过在producer端设置acks=all来处理,这个参数是要求leader接收到消息后需要等到所有的follower都同步到消息之后才认为本次写成功,如果没满足这个条件则生产者会自动不断地重试;

  • MQ数据丢失

    对于MQ数据丢失的主要情况:某个partition的leader在宕机时刚好有数据还没同步到follower,当选举某个follower成为leader后就会丢失一部分数据

    解决办法:通过设置如下4个参数来避免

    • 给topic设置replication.factor参数必须大于1,即要求每个partition必须有至少2个副本
    • 在Kafka服务端设置min.insync.replicas参数必须大于1,即leader至少能感知到有一个follower还跟自己保持联系,确保leader宕机了还有一个有相同数据的follower节点
    • 在producer端设置acks=all,及要求每条数据写入所有replica之后才能认为是写成功
    • 在producer端设置retries=MAX,即一旦写入失败就无限重试
  • 消费者端数据丢失

    对于生产者端数据丢失的主要情况:消息还没处理完Kafka就自动提交了offset,认为消费者已经处理完这条消息,如果消费者开始处理消息时宕机则这条消息就丢失了

    解决办法:关闭自动提交offset,在消费者处理完成后再手动提交offset

4. 描述下Kafka中的leader replica和follower replica的区别

只有leader副本才能对外提供读写服务,响应客户端的请求,follower副本知识采用pull的方式被动地同步leader副本中的数据,并且在leader副本所在的broker宕机后随时准备选举为leader副本,不过leader和follower的消息序列在实际场景中可能不一致,确保一致性的主要手段是高水平机制(HW),但高水平值无法保证leader连续变更场景下的数据一致性,后续引入了leader epoch机制来修复高水平值的弊端;

5. 为什么Kafka不支持读写分离?

在Kafka中生产者写入消息和消费者读取消息的操作都是与leader副本进行交互的,实现的是一种主读主写的生产消费模型,不支持主写从读

读写分离存在的2个缺点:

  • 数据一致性问题:数据从主节点复制到从节点必然会有一个延时的时间窗口导致主从节点之间的数据不一致;
  • 延时问题:Kafka的主从同步需要经历网络→主节点内存→主节点磁盘→网络→从节点内存→从节点磁盘这几个阶段,需要耗费大量的时间

✨👏MySQL(索引及其优化、事务、SQL调优、SQL语句编写、数据库表设计)

1. 简单描述 MySQL 中的各种索引(主键索引,唯一索引,联合索引)的区别

索引是一种特殊的文件(InnoDB 数据表上的索引是表空间的一个组成部分),它们包含着对数据表里所有记录的引用指针。

  • 普通索引(由关键字 KEY 或 INDEX 定义的索引)允许被索引的数据列包含重复的值,不附加任何限制条件,只用于提高查询效率,可以创建在任何数据类型中
  • 使用unique约束可以设置为唯一索引,限制该索引的值必须是唯一的,但允许有空值;在一张表中可以有多个唯一性索引;
  • 主键,是一种特殊的唯一索引,在唯一性索引的基础上增加not null的约束,在一张表中只能定义一个主键索引,主键用于唯一标识一条记录,使用关键字PRIMARY KEY来创建。
  • 联合索引可以覆盖多个数据列闯进啊一个索引,如像 INDEX(columnA, columnB)索引,索引指向创建时对应的多个字段,在查询时只有查询条件使用了这些字段中的第一个字段时才会被使用(最左前缀原则)
【1.1 索引的设计原则有哪些?】
  • 适合索引得列是出现在where子句中的列,或者连接子句中指定的列;
  • 在选择索引列是越短越好,可以指定某些列的一部分,没必要用全部字段的值;
  • 不要给表中的每一个字段都创建索引,选择那些区分度较大的列创建;
  • 定义有外键的数据列一定要创建索引;
  • 更新频繁的字段不要有索引;
  • 组合索引的列的个数不建议太多;
  • 大文本、大对象不要创建索引;
✨1.2 数据库索引失效的情况?
  • 使用!=或者<>导致索引失效
  • 在索引列使用函数也是不走索引的
  • 对索引列进行运算(+、-、*、/)也不走索引
  • 把%放在匹配字段前产生的模糊搜索是不走索引的,放在后面才会走索引
  • NOT IN、NOT EXISTS会导致索引失效
1.3 使用索引查询一定能提高查询的性能吗?

不一定,索引本身需要额外的空间来存储,每当记录在表中增减或索引列被修改时,索引本身也会被修改,可能导致部分索引失效,那些不必要的是小索引反而会使查询反应变慢降低查询性能;

✨2. 【介绍一下数据库中的事务特性和隔离级别?】

事务(transaction)是被绑定在一起作为一个逻辑工作单元的 SQL 语句分组。如果组中的所有操作都成功,则认为事务成功,即使只有一个操作失败,事务也不成功。如果所有操作完成,事务则提交,其修改将作用于所有其他数据库进程。如果一个操作失败,则事务将回滚,该事务所有操作的影响都将取消。

数据库事务ACID特性:

  1. 原子性:即不可分割性,事务要么全部被执行,要么就全部不被执行。
  2. 一致性:事务的执行使得数据库从一种正确状态转换成另一种正确状态;
  3. 隔离性:在事务正确提交之前,不允许把该事务对数据的任何改变提供给任何其他事务;
  4. 持久性:事务正确提交后,其结果将永久保存在数据库中,即使在事务提交后有了其他故障,事务的处理结果也会得到保存。

数据并发问题分类:

  1. 脏读:对于两个事务T1和T2,T1读取了被T2更新但还没有提交的字段,若T2回滚,T1读取的内容就是临时且无效的;

  2. 不可重复读:对于两个事务T1和T2,T1读取了一个字段,之后T2更新了该字段,T1再次读取的同一个字段值就不同了;

  3. 幻读:对于两个事务T1和T2,T1从一个表读取了一个字段,之后T2在该表插入了一些新的行,T1再次读取同一个表就会多出几行;

  4. 脏写:对于两个事务T1和T2,T1修改了另一个未提交事务的T2修改过的数据

数据库事务隔离级别:

  1. read uncommitted:读未提交(只可解决脏写)
  2. read committed:读已提交(可避免脏读)
  3. repeatable read:可重复读,是MySQl的默认隔离级别(可避免脏读、不可重复读但不可避免幻读,实际上采用MVCC+临键锁也可以把幻读也避免了)
  4. serializable:可串行化(可避免脏读、不可重复读、幻读)
✨【2.1 数据库并发场景有哪些?事务隔离是如何实现的?】

数据库并发场景有三种,分别是:

  1. 读读:不存在任何问题,也不需要并发控制;
  2. 读写:有线程安全问题,可能造成事务隔离性问题;
  3. 写写:有线程安全问题,可能存在更新丢失问题;

MVCC:多版本并发控制,是一种用来解决读写冲突的无锁并发控制机制,即为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库快照,MVCC可以解决的问题如下:

  1. 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能;

    例如:使用MVCC来进行实现,在B事务开始修改账户且事务未提交时,当A事务需要读取账户余额时,此时会读取到B事务修改操作之前的账户余额的副本数据,但是如果A事务也需要修改账户余额数据就必须要等待B事务提交事务。

  2. 解决并发读写造成的脏读、幻读和不可重复读等事务隔离问题,但是不能解决并发写写更新丢失问题;

注意:MVCC只在REPEATABLE READ和READ COMMITIED两个隔离级别下工作。其他两个隔离级别都和 MVCC不兼容 ,因为READ UNCOMMITIED总是读取最新的数据行,而不是符合当前事务版本的数据行,而SERIALIZABLE则会对所有读取的行都加锁所以这两个级别不需要考虑事务隔离;

2.2 MVCC的实现原理是什么?(待补充)
  • 两种隔离界别下的核心处理逻辑就是判断所有版本中哪个版本是当前事务可见的处理。针对这个问题InnoDB在设计上增加了ReadView的设计,ReadView中主要包含当前系统中还有哪些活跃的读写事务,把它们的事务id放到一个列表中,用于判定该事务可见到的数据版本。

    • 使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView,从而做到保证每次提交后的数据是处于当前的可见状态;

    • 使用REPEATABLE READ 隔离级别下的ReadView在事务开始后第一次读取数据时生成一个ReadView,并且当前的 ReadView 会一直沿用到当前事务提交,以此来保证可重复读。

✨3. 【MyISAM和InnoDB存储引擎的区别有哪些?】

  1. 是否支持事务:InnoDB 支持事务,MyISAM 不支持事务。

  2. 是否支持外键:InnoDB 支持外键,而 MyISAM 不支持外键。

  3. 使用索引类型:InnoDB 有聚簇索引和非聚簇索引,MyISAM 只有非聚簇索引。(索引是存储在磁盘中的)

    3.1 【讲一讲聚簇索引和非聚簇索引?】

    MySQL的索引类型跟存储引擎是相关的,innodb的数据文件和索引文件全部都放在ibd文件中,而myisam的数据文件放在myd文件中,而索引放在myi文件中,区分聚簇索引和非聚簇索引只要判断数据和索引是否存储在一起即可

    聚簇索引(主键索引):叶子节点存储了整行数据,即数据节点将数据与索引放到了一起,找到索引也就找到了数据;

    非聚簇索引(非主键索引、二级索引):叶子节点只存储了该行对应的索引,不存储表中的数据;

    在通常情况下聚簇索引查询只会查一次,而非聚簇索引需要回表查询多次,回表操作属于随机IO,需要回表次数越多就越倾向于使用全表扫描;

    3.2 非聚簇索引一定会回表查询吗?

    不一定,这涉及到查询语句所要求的字段是否全部命中了索引,如果全部命中了索引,那么就不必再进行回表查询,查一次即可(一个索引包含所有需要查询字段的值被称为覆盖索引);

  4. 读写效率:InnoDB读的效率低于MyISAM,但是写的效率高于MyISAM,InnoDB不保存表的具体行数,执行 select count(*) from table 时需要全表扫描。而MyISAM 用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快;

  5. 锁的粒度:InnoDB 最小的锁粒度是行锁,MyISAM 最小的锁粒度是表锁;

  6. InnoDB支持自动增加列AUTO_INCREMENT属性,支持MVCC模式的读写,而MyISAM都不支持;

  7. 清空整个表的时候,InnoDB会一行一行地删除,而MyISAM则会新建表;

4. 数据库设计三范式了解吗?

  • 键和相关属性概念
    • 超键:能唯一标识元组的属性集,超键包含候选键和主键(一个属性,或多个属性组合在一起都可以作为一个超键)
    • 候选键(码):没有冗余属性的超键(最小超键)
    • 主键(主码):用户可以从候选键中选择一个作为主键,是数据表中对存储数据对象予以唯一和完整标识的数据列或属性的组合
    • 外键:在一个表中存在的另一个表的主键
    • 主属性:包含在任一候选键中的属性
    • 非主属性:不包含在任一候选键中的属性
  • 设计三范式
    • 第一范式:要确保数据表中每个字段的值必须具有原子性,每个字段都是不可再次拆分的源自数据项;(对属性的原子性约束)
    • 第二范式:在满足第一范式的基础上,要求满足数据表里的每一条数据记录都是可唯一标识的,而且所有非主键字段都必须完全依赖主键,不能只依赖主键的一部分(对记录的惟一性约束)
    • 第三范式:在满足第二范式基础上,要求数据表中的所有非主键字段不能依赖于其他非主键字段,即非主键属性之间不能有依赖关系,必须相互独立,每个都和主键直接相关;(对字段冗余性的约束)
    • 巴斯科德范式:在满足第三范式的基础上,只有一个候选键,或它的每个候选键都是单属性
  • SQL约束种类
    • NOT NULL:用于控制字段的内容一定不为空
    • UNIQUE:用于控制字段内容不能重复,一个表允许有多个Unique约束
    • PRIMARY KEY:也用于控制字段内容不能重复,但一个表只允许出现一个
    • FOREIGN KEY:用于连接多个表的动作,防止非法数据插入外键列

5. 你了解MySQL的日志吗?

  • 事务日志

    • redo日志:重做日志

      1. 作用:用来保证事务的持久性,内存先往日志中写入修改内容再写入磁盘,只有日志写入成功了才算事务提交成功,进而进行刷盘操作;

      2. 特点:

        1. 在执行事务的过程中每执行一条sql语句就产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,即使用顺序io进行写入磁盘操作;
        2. redo日志是存储引擎层产生的,在事务执行的过程中一直不断地往redo日志顺序记录;
      3. redo的整体流程:

        1. 硬盘中要修改的data先加载到内存的缓冲池中,data修改后在内存中直接更新data buffer;

        2. redo log的写入并不是直接写入磁盘的,data buffer会先写入内存中的redo log buffer中记录更新信息,之后以一定的频率刷入到真正在磁盘中的redo log file中实现持久化;

    • undo日志:回滚日志

      1. 作用:用来保证事物的原子性和一致性,在事务中更新数据之前会先写入一个undo日志;
      2. 特点:
        1. undo是逻辑日志。回滚数据只是将数据库逻辑恢复到原来的样子,所有修改都被逻辑地取消;
        2. 可通过undo来完成MVCC(多版本并发控制,也就是为事务分配单项增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照);

6.【 Innodb如何实现mysql的事务】

事务进⾏过程中,每次sql语句执⾏,都会记录undo log和redo log,然后更新数据形成脏⻚,然后redo log按照时间或者空间等条件进⾏落盘,undo log和脏⻚按照checkpoint进⾏落盘,全部都成功落盘后相应的redo log就可以删除了。此时,事务还未COMMIT,如果发⽣崩溃,则⾸先检查checkpoint记录获取信息,使⽤相应的redo log进⾏数据和undo log的恢复,然后查看undo log的状态发现事务尚未提交,然后就使⽤undo log进⾏事务回滚。事务执⾏COMMIT操作时,会将本事务相关的所有redo log都进⾏落盘,只有所有redo log落盘成功,才算COMMIT成功。然后内存中的数据脏⻚继续按照checkpoint进⾏落盘。如果此时发⽣了崩溃,则只使⽤redo log恢复数据。

✨7. 说一说索引的底层原理实现?

MySQL底层使用B+Tree作为数据结构

7.1 【为什么索引结构默认使用B+Tree,而不是B-Tree呢?】
  • B+树按照节点类型可分为
    • 叶子节点:B+树最底层的节点,存储行记录;
    • 非叶子节点:存储索引键和页面指针,不存储行记录本身;
  • B+树的性质:
    1. 所有的非叶子节点不用来保存数据而是保存数据的索引,可以看成是索引部分,节点中仅含其子树中的最大或最小关键字;
    2. 所有的叶子节点中包含了全部关键字的信息,及指向含这些关键字记录的指针,且叶子节点本身按照关键字的大小自小而大顺序链接
    3. 在B+树中,数据对象的插入和删除仅在叶节点上进行,其有2个头指针,一个是树的根节点,一个是最小关键字的叶节点;
  • B+树如何进行记录检索?
    从B+树的根节点开始,逐层检索直到找到叶子节点,即找到对应的数据页为止,将数据页加载到内存中,页目录中的槽采用二分查找的方式先找到一个粗略的记录分组,然后再在分组中通过链表遍历的方式查找记录;

8. 【为什么MySQL索引结构默认使用B+树而不是B树、Hash,二叉树,红黑树?】

与B-Tree相比较

  1. B+树的磁盘读写代价更低:B+树的内部节点并没有指向关键字具体信息的指针,而是保存在叶子节点,因此其内部节点相比B树更小,如果把同一内部节点的关键字存放在同一盘块中,那么B+树所能容纳关键字数量更多,一次性能读入内存的关键字就更多,降低IO读写次数;
  2. 由于B+树的数据都存储在叶子节点中适合在区间查询,分支节点均为索引不存储数据,并且叶子结点之间用链表方式相连,每个叶子节点都指向相邻的叶子节点的地址,因此进行范围查找时只需遍历一遍叶子节点即可,但B树因为其分支节点同样存储着数据,要找到具体数据需要进行一次中序遍历按序去遍历所有节点才行;

与Hash索引(Memory数据库)比较:

Hash索引基于哈希表实现,只有精确匹配所有的索引查询才有效,对于每一行数据,存储引擎都会对所有的索引列计算一个hashcode,并且Hash索引将所有的hashcode存储在索引表中,同时在索引表中保存指向每个数据行的指针;

  1. 基于Hash表实现,只有Memory存储引擎显式支持哈希索引,其他存储引擎都不支持;
  2. Hash索引不是按照索引值顺序存储的,不能像B+树索引一样利用索引完成排序;
  3. Hash索引始终索引所有列的全部内容,不支持部分索引列的匹配查找
  4. 在有大量重复键值的情况下存在哈希碰撞问题,Hash索引的效率会很低;

与二叉树、红黑树比较:

  • 二叉树的高度不均匀不能自平衡,查找效率跟树的高度有关,并且IO代价高
  • 红黑树的高度随着数据量的增加而增加,IO代价高

9. 为什么需要注意联合索引中的顺序?

在联合索引中如果想要命中索引,需要按照建立索引时的字段顺序有序使用,否则无法命中索引(一般情况下将查询需求频繁或者字段选择性高的列放在前面)

9.1【知道MySQL的最左前缀原则吗?】

最左前缀原则就是最左优先,在创建多列索引时,要根据业务需求,where子句中使用最频繁的几列字段放在最左边,一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配;

9.2 了解前缀索引吗?(待补充)

前缀索引是只把长字段前面的公共部分作为一个索引,可以避免索引字段过长占用内存空间和不利于维护,但要注意order by不支持前缀索引;

9.3 了解索引下推吗?(待补充)

索引下推是通过把索引过滤条件下推到存储引擎,来减少 MySQL 存储引擎访问基表的次数以及 MySQL 服务层访问存储引擎的次数;

✨9.4 【怎么通过执行计划查看MySQL语句有没有用到索引?】

通过EXPLAIN 具体SQL语句来进行查看

  • type:提供了判断查询是否高效的重要依据,可以通过type字段判断此次查询是全表扫描还是索引扫描,如以下几种情况:const(主键索引或唯一二级索引进行等值匹配的情况),ref(普通二级索引列与常量进行等值匹配),index(扫描全表索引的覆盖索引),一般要求必须为ref以上,最好为const,不能为all
  • possible_keys:显示在查询时可选用的各个索引
  • key:显示MySQL在当前查询时真正使用到的索引
  • rows:MySQL查询优化器根据统计信息,SQL要查找到结果需要扫描读取的数据行数的预估值,直观显示SQL语句的效率好坏
9.5 为什么建议使用自增长主键作为索引?

因为自增主键是连续的,在插入过程中尽量减少页分裂并且能减少数据的移动,每次插入都是插入到最后,即能减少分裂和移动的频率;

10.【 了解MySQL数据库锁的实现原理吗?】

  • 从数据操作类型划分:

    • 读锁(共享锁,即S锁):针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不会阻塞,共享锁可以加上多个;
    • 写锁(排他锁,即X锁):当前写操作没有完成前会阻断其他写锁和读锁,确保在给定时间内只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源,排他锁只可以加一个
  • 从锁的粒度划分:

    • 表锁(对当前操作的整张表加锁,锁定粒度最大,开销小加锁快,不会出现死锁,但是锁冲突概率高,MyIsam和InnoDB引擎都支持)

      意向锁:一种不与行锁冲突的表锁,为了协调行锁和表锁的关系,表明“某个事务正在某些行持有了锁或该事务准备去持有锁”,可以分为意向排他锁和意向共享锁;

    • 行锁(只针对当前操作的行进行加锁,锁定粒度最小,锁冲突概率低,但是锁的开销大,加锁慢,容易出现死锁情况,InnoDB引擎支持)

      记录锁:为某行记录加锁,封住该行的索引记录而不是真正的数据记录;

      间隙锁(gap key):不允许别的事务在指定记录前面的间隙插入新记录,不是针对某一记录加锁而是锁定一个范围,gap锁可以用于防止幻读;

      插入意向锁:事务在等待时在内存中生成的锁结构,表明有事务想在某个间隙中插入新记录但是现在在等待,本质上是一种gap锁,只在insert操作时产生;

      临键锁(Next-key log):在锁住某条记录的同时,又阻止其他事务在该记录前边的间隙插入新纪录;(本质上说是记录锁和间隙锁的结合)

    • 页锁(一次锁定相邻的一组记录,会出现死锁,InnoDB和BDB引擎都支持,锁定粒度介于行锁和表锁之间)

  • ✨从对待锁的态度划分:

    • 悲观锁:假定会发生并发冲突,在获取数据时会先加锁确保数据不会被别的线程修改,共享资源每次只给一个线程使用,其他线程阻塞,直到事务提交后才把资源转让给其他线程,适用于多写的使用场景;(使用数据库锁机制实现)

    • 乐观锁:假定不会发生并发冲突,只有在对数据进行更新操作时,才会对数据加锁判断之前数据是否有被修改,不采用数据库自身的锁机制,而是通过程序来实现,适用于多读的应用类型提高吞吐量;(使用版本号或者时间戳机制,或CAS算法实现)

      • 数据版本号(Version)机制

        通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,version值加1。当我们提交更新的时候,将数据库表对应记录的当前版本信息与更新前取出来的version值进行比对,如果数据库表当前版本号与更新前取出来的version值相等,则予以更新,否则认为是版本冲突。

      • 时间戳机制

        同样是在数据库表中增加一个字段,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则予以更新,否则就是版本冲突。

      • CAS算法

        CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不会做任何操作。这处理思想和乐观锁的冲突检查+数据更新的原理是一样的。

10.1 隔离级别与锁的关系
  • 在Read Committed级别下,读操作需要加共享锁,但在语句执行完以后要释放共享锁;
  • 在Repeatable Read级别下,读操作需要加共享锁,但在事务提交之前并不释放共享锁,即必须等待事务执行完毕以后才释放共享锁;
10.2 优化锁的方法
  • 使用较低的隔离级别缩小锁的锁定范围
  • 使用索引去访问数据使得加锁更加精确,减少锁冲突
  • 不同程序访问一组表时尽量约定一个相同的顺序访问各表,减少死锁的机会
  • 数据查询时非必要不要加锁,而采用MVCC实现事务查询

11. 【分库分表相关内容】(后续需要再了解)

11.1 什么是分库分表?

分库 就是将一个数据库中的数据(最多支撑到并发2000,健康单库并发在每秒1000左右)分散到多个不同的数据库上。

下面这些操作都涉及到了分库:

  • 你将数据库中的用户表和用户订单表分别放在两个不同的数据库。
  • 由于用户表数据量太大,你对用户表进行了水平切分,然后将切分后的 2 张用户表分别放在两个不同的数据库。
11.2 如何对数据库进行垂直拆分或水平拆分?

分表 就是对单表的数据(一般单表到几百万)进行拆分,把一个表的数据放到多个表中,查询的时候就查一个表,可以是垂直拆分,也可以是水平拆分:

  • 水平拆分:把一个表的数据放到多个库的多个表中,但是每个库的表结构都一样,只是每个库存放的数据是不同的,所有库表的数据加起来就是全部数据;(即水平拆分是对数据表行的拆分,把一张行比较多的表拆分为多张表)
    • 水平拆分的意义:就是将数据均匀放更多的库里,然后用多个库来抗更高的并发,还有就是用多个库的存储容量来进行扩容。
  • 垂直拆分:把一个有很多字段的表给拆分成多个表或者是多个库上去,每个库表的结构都不一样,每个库表都包含部分字段,一般来说会将访问频率很高的字段放到一个表中,然后将较多的访问频率很低的字段放到其他表中;(即垂直拆分是对数据表列的拆分,把一张字段比较多的表拆分为多张表)
    • 垂直拆分的意义:数据库也是有缓存的,访问频率高的字段越少,就可以在缓存中缓存更多的行,性能就越好。
11.3 分库分表会带来什么问题?
  • join 操作 : 同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。
  • 分布式事务 :同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了,需要使用分布式事务。
  • 分布式 id :分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 id 了。
11.4 如何设计才可以让系统从未分库分表动态切换到分库分表上?
  • 停机迁移方案
  • 不停机双写迁移方案
    • 简单来说,就是在线上系统里面同时对老库和新库执行增删改的操作,这就是所谓的双写。然后新系统部署上线后,用数据迁移工具读取老库数据写入到新库。如果读出来的数据在新库里没有,或者这条数据的最后修改的时间比新库的数据新才会写入,简单来说,就是不允许用老数据覆盖新数据。导完一轮之后,有可能数据还是存在不一致,那么程序自动做一轮校验,对比新老库每个表的每条数据,如果有不一样的就针对那些不一样的地方从老库读数据再次写。经过反复循环,直到两个库每个表的数据都完全一致为止。
11.5 分库分表之后全局id怎么生成?
  • 使用snowflake算法(其他id生成策略还有数据库自增id、UUID和时间戳)

    其核心思想是:使用41bit作为时间戳位,10bit作为工作进程位置(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个预留符号恒为0。

✨12. 【如何处理MySQL的慢查询?】

  1. 开启慢查询日志,准确定位到具体是哪个sql语句出现了问题;

  2. 分析sql语句,观察是否加载了额外的数据,可能是查询了多余的行并且抛弃掉了,可能是加载了很多结果中并不需要的列,对语句进行分析以及重写;

  3. 分析语句的执行计划,查看其使用索引的情况,之后修改语句或者修改索引,使得语句可以尽可能地命中索引;

  4. 如果对语句的优化已经无法进行,可以考虑表中的数据量是否太大,如果是的话可以进行水平分表或垂直分表;

13. 【什么是读写分离?】

读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上,一般情况下,我们都会选择一主多从的架构,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步以保证从库中数据的准确性。

13.1 读写分离会带来什么问题?如何解决主从同步的延时问题?

主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟

参考解决方案如下:

  • 强制将读请求路由到主库处理。可以将那些必须获取最新数据的读请求都交给主库处理,但违背读写分离的初衷。
  • 延迟读取。对于一些对数据比较敏感的场景,你可以在完成写请求之后,避免立即进行读请求操作。比如你支付成功之后,跳转到一个支付成功的页面,当你点击返回之后才返回自己的账户。
  • 分库。将主库拆分为几个主库降低写并发,降低主从延时影响。
13.2 如何实现读写分离?
  1. 部署多个数据库,选择其中的一个作为主数据库,其他的作为从数据库。
  2. 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制
  3. 系统将写请求交给主数据库处理,读请求交给从数据库处理,主库自动把数据同步到从库上去。

落实到项目本身的话,常用的方式有两种:

  1. 代理方式

    在应用和数据中间加了一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。提供类似功能的中间件有 MySQL Router(官方)、Atlas(基于 MySQL Proxy)、MaxscaleMyCat

  2. 组件方式

    可以通过引入第三方组件Sharding-JDBC来帮助我们实现读写请求

✨13.3 【了解MySQL主从复制原理吗?】

MySQL binlog( 即二进制日志文件) 主要记录了 MySQL 数据库中数据的所有变化(数据库执行的所有 DDL 和 DML 语句),其主要目的在于复制和恢复。因此,我们根据主库的 MySQL binlog 日志就能够将主库的数据同步到从库中

具体的过程如下:

  1. 主库将数据库中数据的变化写入到 binlog
  2. 从库连接主库
  3. 从库会创建一个 I/O 线程向主库请求更新的 binlog
  4. 主库会创建一个 binlog dump 线程来发送 binlog ,从库中的 I/O 线程负责接收
  5. 从库的 I/O 线程将接收的 binlog 写入到 relay log 中。
  6. 从库的 SQL 线程读取 relay log 同步数据本地(也就是再执行一遍 SQL )。

14. Sql语句优化方式有哪些?

优化表结构

  • 尽量使用数字型字段。若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。这是因为引擎在处理查询和连接时会逐个比较字符串中每一个字符,而对于数字型而言只需要比较一次就够了。
  • **尽可能的使用 varchar 代替 char**。可变长字段存储空间小,可以节省存储空间,但在能确定字段长度时使用char效率更高。
  • 当索引列大量重复数据时,可以把索引删除掉。比如有一列是性别,只有男、女,这样的索引是无效的。

优化查询

  • 应尽量避免在 where 子句中使用 !=<> 操作符,否则引擎将放弃使用索引而进行全表扫描。
  • 应尽量避免在 where 子句中使用 or 来连接条件,如果一个字段有索引,一个字段没有索引,将导致引擎放弃使用索引而进行全表扫描。建议使用 union 替换 or
  • 应尽量避免在 where 子句中对字段进行 null 值判断,否则将导致引擎放弃使用索引而进行全表扫描
  • innot in 也要慎用,否则会导致全表扫描
  • 优化嵌套查询时可以将子查询尽量替换为多表连接查询(JOIN
  • 任何查询都不要出现select *
  • 确定onusing子句上是否有索引
  • 确保group byorder by只有一个表中的列确保使用索引

优化索引

  • 尽量使用复合索引,而少使用单列索引
  • 最左前缀法则:如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列
  • 对查询进行优化,要尽量避免全表扫描,首先应考虑在 whereorder by 涉及的列上建立索引

优化数据库结构

  • 将字段很多的表垂直分解成多个表,将使用频率低的字段分离出来形成新表;
  • 建立中间表。将需要通过联合查询的数据插入到中间表中,将原来的联合查询改为对中间表的查询;
  • 增加冗余字段,减少表与表之间需要连接查询的情况;

15. MySQL 数据库作发布系统的存储,一天五万条以上的增量, 预计运维三年,怎么优化?

  1. 设计良好的数据库结构,允许部分数据冗余,尽量避免 join 查询,提高效率。

  2. 选择合适的表字段数据类型和存储引擎,适当的添加索引。

  3. MySQL 库主从读写分离。

  4. 找规律分表,减少单表中的数据量提高查询速度。

  5. 添加缓存机制,比如 memcached,redis 等。

16. 【锁优化策略有哪些思路?】

  1. 读写分离

  2. 分段加锁

  3. 减少锁持有的时间

  4. 多个线程尽量以相同的顺序去获取资源

注意:不能将锁的粒度过于细化,不然可能会出现线程的加锁和释放锁次数过多,反而效率不如一次加一把大锁。

17. 【MySQL的CPU突然飙升到500%要怎么处理?】

  1. 先用操作系统命令top观察是否为MySQLd占用导致的,如果不是找出占用高的进程进行相关处理;

  2. 如果是MySQLd造成的,show processlist看里面跑的session情况,找到消耗资源高的sql,explain查看执行计划,观察是否为index缺失,或者是数据量太大造成的;

  3. 如果每个sql消耗资源都不多,是突然间有大量session连进来导致CPU飙升,则需要分析连接数激增的原因再做相应调整,如限制连接数等;

18. MySQL为什么需要主从同步?

  1. 使用主从复制让主库负责写,从库负责读,这样即使主库出现了锁表的情景,通过读从库也可以保证业务的正常运作;(主从读写分离)
  2. 做数据的热备;(主备)
  3. 架构的扩展,随着业务量增大IO访问频率过高,单机无法满足,此时做多库的存储,降低磁盘IO访问的频率,能提高单个机器的IO性能;

✨👏缓存Redis

1. 【Redis 的持久化机制有哪些?各自的优缺点?】(怎么保证redis挂掉之后再重启数据可以进行恢复?)

  1. RDB(Redis DataBase):用数据集快照的方式(半持久化存储)在指定的时间间隔内将内存中的数据集快照写入一个临时文件(全量备份),持久化结束后,用这个临时文件替换磁盘中上次持久化的文件,达到数据恢复。
  • 优点:
    • RDB文件紧凑进行全量备份,适合用于进行备份和灾难恢复。(redis中执行flushall和shutdown命令的时候会触发RDB)
    • 生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作,但创建子线程也需要占用内存
    • RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快
  • 缺点:
    • 当进行快照持久化时,会开启一个子进程专门负责快照持久化,子进程会拥有父进程的内存数据,父进程修改内存子进程不会反应出来,所以在快照持久化期间修改的数据不会被保存,可能丢失数据
    • 数据的完整性和一致性不高,因为RDB可能在最后一次备份时宕机
  1. AOF(Append-only file):指以 redis 命令请求协议的格式(完全持久化存储)保存为aof文件,将每一个收到的写命令都通过write函数追加到aof文件中(增量备份),通俗的理解就是日志记录。
  • 优点:
    • AOF可以更好的保护数据不丢失,一般AOF会每隔1秒,通过一个后台线程执行一次fsync操作,最多丢失1秒钟的数据。
    • AOF日志文件没有任何磁盘寻址的开销,写入性能非常高,文件不容易破损。
    • AOF日志文件的命令通过易读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall命令清空了所有数据,只要这个时候后台rewrite还没有发生,那么就可以立即拷贝AOF文件,将最后一条flushall命令给删了,然后再将该AOF文件放回去,就可以通过恢复机制,自动恢复所有数据
  • 缺点:
    • 对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大,且数据集大时会比RDB启动慢;

如果AOF和RDB同时存在的时候,Redis会优先使用从AOF文件来还原数据库状态,因为AOF中的数据更加完整

如何选择合适的持久化方式:

  • 如果是数据不那么敏感,且可以从其他地方重新生成补回的,那么可以不开启持久化;
  • 如果数据比较重要,不想再从其他地方获取,但可以承受数分钟的数据丢失比如缓存等,那么可以只使用RDB;
  • 如果是用作内存数据库,要使用Redis的持久化,RDB和AOF都开启,或者定期执行bgsave做快照备份,RDB方式更适合做数据备份,AOF可以保证数据不丢失;

2. 【Redis 的同步机制了解么?】(待补充)

Redis第一次同步时,主节点做一次bgsave, 并同时将后续修改操作记录到内存buffer,待完成后将RDB文件全量同步到复制节点,复制节点接收完成后将RDB镜像加载到内存。加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。

2.1 Redis的主从同步是如何实现的?

Redis使用psync命令完成主从数据同步,同步过程分为全量复制和部分复制。全量复制一般用于初次复制的场景,部分复制则用于处理因网络中断等原因造成数据丢失的场景。

3. 【用过哪些常用的Redis命令?】

  • 启动redis服务:redis-server ../redis.conf

  • 在远程 redis 服务上登录:redis-cli -h host -p port -a password

  • 检查给定 key 是否存在:exists key

  • 为给定 key 设置过期时间,以秒计:expire key seconds

  • 移除 key 的过期时间,key 将持久保持:persist key

  • 查找所有符合给定模式( pattern)的 key:keys pattern

  • 以秒为单位,返回给定 key 的剩余生存时间(TTL, time to live):ttl key

  • 如果 key 已经存在并且是一个字符串,将指定的 value 追加到该 key 原来值的末尾:append key value

  • 将key中存储的数字值增/减1:incr/decr key

  • 将key中存储的数字值增减自定义值:incrby/decrby key 步长

  • 根据value选择非阻塞删除:unlink key(仅将key从keyspace元数据中删除,真正的删除会在后续异步操作)

与String字符串相关命令:

  • 当且仅当所有给定的key都不存在时,同时设置一个或多个key-value对:msetnx key1 value1 key2 value2 (具有原子性,有一个失败则都会失败)

  • 设置键值的同时将 key 的过期时间设为 seconds (以秒为单位):setnx key seconds value

  • 获取范围内的值:getrange key 起始位置 结束位置

  • 用value覆写key所存储的字符串值中的字符,从起始位置开始:setrange key 起始位置 value

  • 获取key对应旧值的同时设置为新值value:getset key value

与List列表相关命令:

  • 从左边/右边插入一个或多个值:lpush/rpush key value1 value2
  • 从左边/右边弹出一个值(值在键在):lpop/rpop key
  • 从key1列表右边弹出值后,将该值插到key2列表左边:rpoplpush key1 key2
  • 按照索引下标获取范围内的元素:lrange key start stop(0左边第一个,-1右边第一个,0 -1表示获取所有)
  • 按照索引下标获取指定位置的元素:lindex key index
  • 获取列表长度:llen key
  • 在value的前面/后面插入值:linsert key before/after value newValue
  • 从左边开始删除n个相同的value:lrem key n value
  • 将列表key下标为index的值替换成value:lset key index value

与Set无序集合相关命令:

  • 将一个或多个member元素加入到集合key中,已经存在的member元素将被忽略:sadd key value1 value2
  • 取出该集合中的所有值:smembers key
  • 判断集合key是否含有对应的value:sismember key value(有则为1,没有为0)
  • 返回该集合的元素个数:scard key
  • 删除集合中的某个元素:srem key value1 value2
  • 随机从集合中弹出一个值:spop key
  • 随机从集合中取出n个值,不会从集合中删除:srandmember key n
  • 把集合中一个值从集合1移动到集合2:smove 集合1 集合2 value
  • 返回两个集合的交集元素:sinter key1 key2
  • 返回两个集合的并集元素:sunion key1 key2
  • 返回两个集合的差集元素(key1中有的而key2没有的):sdiff key1 key2

与Hash哈希表相关命令:

  • 给key集合中的field键赋值为value:hset key field value

  • 从key集合的field中取出对应的value:hget key field

  • 批量设置hash值:hmset key field1 value1 field2 value2

  • 查看key中给定的field是否存在:hexists key field

  • 列出该key集合中所有的field:hkeys key

  • 列出该key结合中所有的value:hvals key

  • 为key中的域field的值加上指定增量:hincrby key field 指定增量

  • 将哈希表key中的域field的值设置为value,当且仅当域field不存在:hsetnx key field value

与Zset有序集合相关命令:

  • 将一个或多个member元素及其score值加入到有序集key中:zadd key score1 value1 score2 value2

  • 返回有序集合key中下标在start和stop之间的元素,并让分数一起和值返回到结果集:zrange key start stop withscores

  • 返回有序集合key中所有score值介于min和max之间的成员,有序集合成员按score

  • 递增排列:zrangebyscore key min max

  • 递减排序:zrevrangebyscore key max min

  • 为元素的score加上增量:zincrby key 增量 value

  • 删除有序集合key中指定值的元素:zrem key value

  • 统计集合指定区间内的元素个数:zcount key min max

  • 返回该值在集合中的排名,从0开始:zrank key value

4.【是否使用过 Redis集群,集群的工作原理是什么?】

  • Redis Sentinal着眼于高可用和读写分离,在master宕机时会自动将slave提升为master,继续提供服务。(一主多从情况下每台Redis服务器都存储相同的数据,存在浪费内存的问题)
  • Redis Cluster着眼于扩展性,在单个Redis内存不足时,使用 Cluster进行分布式存储(至少配置6个节点以上,3主3从,其中主节点提供读写操作,从节点作为备用节点不提供请求只作为故障转移使用;自动将数据进行分片,每个master节点存储不同的内容)
    • Redis Cluster采用虚拟槽分区,所有的键根据哈希函数映射到0-16383个整数槽内,每个节点负责维护一部分槽以及槽所映射的键值数据,当有节点宕机时,其上的数据均分到其余存活的节点上;
4.1 Redis Cluster模式的工作原理能说一下吗?
  • Redis Cluster节点间通信机制
    • redis cluster节点间采取gossip协议进行通信,跟集中式不同,不是将集群元数据(节点信息,故障等)集中存储在某个节点上,而是互相之间不断通信,保持整个集群所有节点的数据是完整的;优点是元数据的更新比较分散不是集中在一个地方,更新请求会陆续打到所有节点上去更新,有一定的延时降低了压力,但缺点也在于元数据更新有延时,可能导致集群的一些操作会有一些滞后;

    • 10000端口

      每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口,每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他节点接收到ping之后返回pong响应;

4.Redis cluster如何实现数据分布?
  • redis cluster的hash slot算法

    redis cluster有固定的16384个哈希槽,对每个key计算CRC值,然后对16384取模,可以获取key对应的hash slot,redis cluster中每个master都会持有部分槽,比如有3个master那可能每个master持有5000多个hash slot,每增加一个master,就将其他master的hash slot移动部分过去,减少一个master,就将它的hash slot均分到其他master上;

4.3 Redis cluster方案什么情况下会导致整个集群不可用?

有 A,B,C 三个节点的集群,在没有复制模型的情况下如果节点 B 失败了,那么整个集群就会以为缺少 5501-11000 这个范围的槽而不可用,但是会把失败节点上的槽均匀分布到其他节点上;

5. 【Redis sentinel的工作原理?】

  1. 每个sentinel以每秒钟1次的频率向他所知的master、slave以及其他sentinel实例发送一个ping命令;
  2. 如果一个实例最后一次有效回复ping命令的时间超过down-after-milliseconds选项所指定的值,则该实例会被当前sentinel标记为主观下线;
  3. 如果一个master被标记为主观下线,则正在监视这个master的所有sentinel要以每秒1次的频率确认master的确进入了主观下线状态;
  4. 当大于配置文件指定值数量的sentinel在指定的时间范围内确认master的确进入了主观下线状态,则master会被标记为客观下线;
  5. 当master被sentinel标记为客观下线,sentinel向下线的master的所有slave发送info命令的频率从10秒1次改为每秒1次;
  6. 若没有足够数量的sentinel统一认为master已经下线,master的客观下线状态就会变回主观下线;若master重新向sentinel的ping命令返回有效回复,master的主观下线状态就会被移除;
  7. sentinel节点会与其他sentinel节点进行沟通,若都允许了主备切换,则投票选举一个sentinel节点进行主备切换操作,在slave节点中选举一个作为新的master节点,其他slave节点挂载到新的master节点上并自动复制新的master节点的数据;
    • 从slave中选举一个新的master的标准:
      • 与master断开连接的时长:若断开连接的时长已经超过down-after-milliseconds的10倍+master宕机的时长,则认为该slave不适合选举为master;
      • slave优先级:slave priority越低,优先级越高;
      • 复制offset:slave复制了越多数据,其offset越靠后,优先级越高;

6. 【怎么理解 Redis 事务?】

  • Redis事务并不是传统意义上理解的事务,Redis事务的执行并不是原子性的,可以理解为是一个打包的批量执行脚本但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已执行指令的回滚,也不会造成后续执行不再执行;
  • Redis事务中所有命令都会序列化、按顺序地执行,事务在执行的过程中不会被其他客户端发送来的命令请求所打断;(总是带有隔离性)
  • 在事务开启之前,如果客户端与服务器之间网络断开,则其后所有待执行的语句都不会被服务器执行,如果网络中断发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行;

事务相关的命令:MULTI、EXEC、DISCARD、WATCH

  • MULTI用于开启一个事务,MULTI执行后客户端向服务器发送任意多条命令,这些命令不会立即被执行而是被放到一个队列中,当EXEC命令被调用时所有队列中的命令才会被执行;
  • EXEC用于执行所有事务块内命令,返回事务块内所有命令的返回值,截至此处一个事务已经结束
  • DISCARD用于取消事务;
  • WATCH用于监视一个或多个key,一旦有一个键被修改或删除,之后的事务就不会执行,监控一直持续到EXEC命令;UNWATCH用于取消WATCH对所有key的监控;

7. 【MySQL 里有 2000w 数据,redis 中只存 20w 的数据,如何保证 redis 中的数据都是热点数据?】

Redis内存数据集大小上升到一定大小的时候,就实行数据淘汰策略。

Redis 提供 6 种回收策略(淘汰策略):

  • 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(驱逐):禁止驱逐数据

8. Redis的线程模型是什么?为啥单线程还能有很高的效率?

8.1 【Redis单线程模型】

Redis 内部使用文件事件处理器 file event handler是单线程的,所以 Redis 才叫做单线程的模型。文件事件处理器的结构包含4个部分:①多个socket;②IO多路复用程序;③文件事件分派器;④事件处理器,它采用 IO 多路复用机制同时监听多个 socket,将产生事件的 socket 压入内存队列中,文件事件分派器根据 socket 上的事件类型来选择对应的事件处理器进行处理。

文件事件处理器的工作流程如下:

  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
  • 文件事件处理器是单线程模式运行的,但是通过IO多路复用机制监听多个socket,并根据socket目前执行的任务来为套接字关联不同的事件处理器,可以实现高性能的网络通信模型。又可以跟内部其他单线程的模块进行对接,保证了Redis内部的线程模型的简单性。
8.2 为什么Redis是单线程模型效率也能这么高?

1)纯内存操作

2)核心是基于非阻塞的IO多路复用机制

3)单线程反而避免了多线程的上下文频繁切换问题,预防了多线程可能产生的竞争问题

8.3 Redis 6.0前为什么选择单线程?
  1. 单线程编程简单可维护,不需要像多线程所有的底层数据结构都必须实现成线程安全;
  2. 单线程可以规避进程内频繁的线程切换开销
  3. 避免同步机制的开销
8.4 Redis 6.0后为什么要引入多线程呢?
  1. 可以充分利用服务器CPU资源,目前单线程只能利用一个核去处理任务
  2. 多线程任务可以分摊Reids同步IO读写负荷
8.5 Redis开启多线程后是否会存在线程并发安全问题?

Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍是单线程顺序执行,所以并不需要考虑线程并发安全问题;

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

使用 keys 指令可以扫出指定模式的 key 列表。

9.1 如果这个 redis 正在给线上的业务提供服务,那使用 keys 指令会有什么问题?

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

10. 如果有大量的 key 需要设置同一时间过期,一般需要注意什么?

如果大量的 key 过期时间设置的过于集中,到过期的那个时间点,redis可能会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一些,避免出现缓存雪崩。

11. 【使用过 Redis 做消息队列么,你是怎么用的?】

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

11.1 可不可以不用 sleep 呢?

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

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

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

11.3 pub/sub 有什么缺点?

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

11.4 Redis 如何实现延时队列?

使用sorted set,拿时间戳作为score,消息内容作为 key ,生产者调用 zadd 来生产消息,消费者用 zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。

12. 【了解Redis过期键的删除策略吗?(即Redis的过期策略)】

  1. 定时删除:在设置键的过期时间的同时,创建一个定时器 timer,让定时器在键的过期时间来临时,立即执行对键的删除操作。
  2. 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话就删除该键,如果没有过期,就返回该键。(即只会在取出 key 的时候才对数据进行过期检查
  3. 定期删除:每隔一段时间程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。(即每隔一段时间随机抽取一批 key 执行删除过期 key 操作
12.1 如何设置Redis的过期删除策略比较合理?

【定期删除+惰性删除】

  • 所谓定期删除,指的是redis默认是每隔100ms就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。但是定期删除可能会导致很多过期key到了时间并没有被删除掉,所以结合惰性删除,并不是到时间就删除key,而是在你获取某个key的时候,redis会检查一下 ,这个key如果设置了过期时间且过期了此时就会删除;

✨14. 缓存穿透、缓存击穿、缓存雪崩?

  • 缓存穿透:大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。
    • 解决办法:
      • 缓存无效key并设置较短的过期时间,value统一设置成null值
      • 使用布隆过滤器,在缓存之前再加一个布隆过滤器,将redis中的所有key都存储在布隆过滤器中,在查询redis前先去布隆过滤器查询key是否存在,如果不存在就直接返回不让其访问数据库
  • 缓存击穿:有一些被大量访问的数据(热点数据缓存)在某一时刻失效(一般是缓存时间到期),导致对应的请求直接落到了数据库上。(缓存击穿其实可以算缓存雪崩的特例,缓存击穿是部分热点key过期,缓存雪崩是大量key集中过期)
    • 解决办法:
      • 设置互斥锁。在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。(可以使用 Redis 分布式锁)
      • 预先设置热门数据到redis,加大热门数据key的过期时长或者设置为永不过期
      • 接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
  • 缓存雪崩:缓存在同一时间大面积失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。
    • 解决办法:
      • 由于过期时间集中导致:设置不同的过期时间,比如为每个key的过期时间再加个随机值

      • 由于缓存宕机导致:(严格来说不属于缓存雪崩)

        事前:保证redis高可用(主从+哨兵,redis cluster)避免全盘崩溃

        事中:设置多级失效时间不同的缓存(本地ehcache缓存) + 使用熔断机制,限流降级

        事后:开启redis持久化,快速恢复缓存数据

15. 【怎么保证Redis是高并发和高可用?】

15.1 redis如何通过读写分离来承载读请求QPS超过10万+?

高并发架构做成读写分离的主从架构,一主多从,master节点负责写,并且将数据同步复制到其他slave节点,从节点负责读,所有的读请求全部走从节点,便于进行水平扩容;

15.2 master持久化对于主从架构的安全保障的意义

如果采用了主从架构,那么建议必须开启master node的持久化!

  • 不建议用slave node作为master node的数据热备,因为如果RDB和AOF都关闭了,数据则会全部都在内存中,master宕机后重启是没有本地数据可以恢复的,然后就会直接认为自己的数据是空的,master就会将空的数据集同步到slave上去,所有slave的数据全部清空,导致100%数据丢失的故障;
15.3 redis主从复制原理和断点续传
  • 主从复制机制原理

    • 当启动一个slave node的时候,它会发送一个PSYNC命令给master node

    • 如果这是slave node重新连接master node,那么master node仅仅会复制给slave部分缺少的数据(增量复制); 否则如果是slave node第一次连接master node,那么会触发一次full resynchronization(全量复制)

    • 开始full resynchronization的时候,master会启动一个后台线程,开始在内存中生成一份RDB快照文件,同时还会将从客户端收到的所有写命令缓存在内存中。RDB文件生成完毕之后,master会将这个RDB发送给slave,slave会先写入本地磁盘,然后再从本地磁盘加载到内存中。最后master会将内存中缓存的写命令发送给slave,slave也会同步这些数据,至此就完成了主从复制。

    • slave node如果跟master node有网络故障,断开了连接,会自动重连。master如果发现有多个slave node都来重新连接,仅仅会启动一个rdb save操作,用一份数据服务所有slave node。
  • 断点续传(适用于网络故障断开连接自动重连)

    master node会在内存中创建一个backlog,master和slave都会保存一个replica offset还有一个master id,offset就是保存在backlog中的。如果master和slave网络连接断掉了,slave会让master从上次的replica offset开始继续复制,但是如果没有找到对应的offset,那么就会执行一次full resynchronization;

  • 无磁盘化复制

    master会在内存中直接创建RDB然后发送给slave,不会在自己本地磁盘落盘了;

15.4 redis主从架构下如何才能做到99.99%的高可用性?

高可用架构采用主备切换的主从架构,在master节点故障宕机时,哨兵自动检测并且将某个slave节点自动切换为master节点

  • 哨兵 + redis主从的架构,是不会保证数据零丢失的,只能保证redis集群的高可用性

  • redis哨兵主备切换的数据丢失的情况:异步复制、集群脑裂

    • 异步复制导致数据丢失

      因为master -> slave的复制是异步的,所以可能有部分数据还没复制到slave,此时master就宕机了,这些部分数据就丢失了

    • 集群脑裂导致数据丢失

      master所在机器突然脱离了正常的网络,跟其他slave机器不能正常连接,但是实际上master还运行着,但此时哨兵可能就会认为master宕机了,然后开启选举将其他slave切换成了新的master,此时集群里就会有两个有相同数据的master且作用一致,也就是所谓的脑裂,虽然某个slave被切换成了master,但是可能客户端还没来得及切换到新的master,还继续向旧master写数据,当旧master再次恢复的时候会被作为一个slave挂到新的master上去,自己的数据会清空而重新从新的master处复制数据,导致故障后客户端继续写向旧master的数据就丢失了

  • 解决异步复制和脑裂导致的数据丢失问题

    min-slaves-to-write 1
    min-slaves-max-lag 10

    意思是要求至少要有1个slave,数据复制和同步的延迟不能超过10秒,如果说一旦所有的slave,数据复制和同步的延迟都超过了10秒钟,那么这个时候master就不会再接收任何请求了

    通过上面两个配置可以减少异步复制和脑裂导致的数据丢失影响

    (1)减少异步复制的数据丢失

    有了min-slaves-max-lag这个配置,就可以确保说,一旦slave复制数据和ack延时太长,就认为可能master宕机后损失的数据太多了,那么就拒绝写请求,这样可以把master宕机时由于部分数据未同步到slave导致的数据丢失降低的可控范围内;

    (2)减少脑裂的数据丢失

    如果一个master出现了脑裂,跟其他slave丢了连接,那么上面两个配置可以确保说,如果不能继续给指定数量的slave发送数据,而且slave超过10秒没有给自己ack消息,那么就直接拒绝客户端的写请求,这样脑裂后的旧master就不会接受client的新数据,最多就丢失这10秒的数据;

15.5 由于主从延迟导致读取到过期数据怎样处理?(待补充)
  • 通过scan命令扫库:当Redis中的key被scan时相当于访问了该key,同时也会做过期检测,使用Redis惰性删除的策略;

16. 【说一下Redis的缓存淘汰策略】

结合Redis的12. 过期删除策略,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除+惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了,当写入数据将导致超出maxmemory限制时,Redis会采用maxmemory-policy所指定的策略进行数据淘汰,淘汰策略有如下8种:

策略 描述 版本
noeviction 直接返回错误;
volatile-ttl 从设置了过期时间的键中,选择过期时间最小的键,进行淘汰;
volatile-random 从设置了过期时间的键中,随机选择键,进行淘汰;
volatile-lru 从设置了过期时间的键中,使用LRU算法选择键,进行淘汰;
volatile-lfu 从设置了过期时间的键中,使用LFU算法选择键,进行淘汰; 4.0
allleys-random 从所有的键中,随机选择键,进行淘汰;
allkeys-lru 从所有的键中,使用LRU算法选择键,进行淘汰;
allkeys-lfu 从所有的键中,使用LFU算法选择键,进行淘汰; 4.0
  • LRU(Least Recently Used)是按照最近最少使用原则来筛选数据,即最不常用的数据会被筛选出来;
  • LFU(Least Frequently Used)是Redis4新增的淘汰策略,它根据key的最近访问频率来筛选数据进行淘汰;

17. 【如何保证缓存和数据库的数据双写一致性?】

  • 先更新数据库,再删除缓存,如果失败则采用cache更新重试机制(普遍使用方式)

    如果更新数据库成功,而删除缓存这一步失败的情况的话,简单的解决方案:

    1. 增加 cache 更新重试机制: 如果 cache 服务当前不可用导致缓存删除失败,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入消息队列中,等缓存服务可用之后,再从队列中获取需要删除的key,将缓存中对应的 key 删除即可。
  • 延时双删策略:在写库前后都进行删除缓存操作,并且设置合理的超时时间,基本步骤是先删除缓存再写数据库,在休眠一段时间后再次删除缓存,目的是确保读请求结束,写请求可以删除读请求可能造成的缓存脏数据。

补充说明:无论是先写数据库再删除缓存,还是先删除缓存再写入数据库,都必定会出现数据一致性的问题

  1. 先删除了缓存,但是因为其他某些原因还没来得及写入数据库,另外一个线程就来读取,发现缓存为空,则去数据库读取到之前的数据并写入缓存,此时缓存中为脏数据。

  2. 先写入了数据库,但在缓存被删除前,因为其他原因被中断了没有成功删除掉缓存,也会出现数据不一致的情况。

17.1 三种常用的缓存读写策略(待补充)
  • Cache Aside Pattern(旁路缓存模式)(常用)

    • 对于写操作先直接删除cache,再更新DB。

    • 对于读操作从 cache 中读取数据,读取到就直接返回,cache中读取不到的话就从 DB 中读取数据返回再把数据放到 cache 中。

    • Cache Aside Pattern 的缺陷

      缺陷1:首次请求数据一定不在 cache 的问题

      解决办法:可以将热点数据可以提前放入cache 中。

      缺陷2:写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率 。

      解决办法:

      • 数据库和缓存数据强一致场景 :更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。
      • 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。
  • Read/Write Through Pattern(读写穿透)(不常用)

    • 对于写操作先查 cache,cache 中不存在直接更新 DB。cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)。
    • 对于读操作从 cache 中读取数据,读取到就直接返回 。读取不到的话,先从 DB 加载,写入到 cache 后返回响应。
  • Write Behind Pattern(异步缓存写入)(不常用)

  • Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。但是,两者又有很大不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

✨18. 【Redis都有哪些数据类型?分别在哪些场景下使用比较合适?(在项目中用到哪些Redis的数据结构?)】

  1. string
    • 最常用、简单的key-value类型,普通的key/ value 存储都可以归为此类。
    • 使用场景:常规key-value缓存应用。
    • 实现方式:String在redis内部存储默认就是一个字符串,被redisObject所引用,当遇到incr,decr等操作时会转成数值型进行计算,此时redisObject的encoding字段为int。
  2. hash
    • 是一个键值(key => value)对集合。hash是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象,并且可以像数据库中update一个属性一样只修改某一项属性值。
    • 使用场景:存放结构化数据,比如用户信息。比如Key是用户ID, Value是一个Map,这个Map的field是成员的属性名,value是属性值,这样对数据的修改和存取都可以直接通过其内部Map的Key(Redis里称内部Map的key为field), 也就是通过key(用户ID) + field(属性标签) 就可以操作对应属性数据了,既不需要重复存储数据,也不会带来序列化和并发修改控制的问题。
    • 实现方式:Hash对应Value内部实际就是一个HashMap,当这个Hash的成员比较少时Redis为了节省内存会采用类似一维数组的方式来紧凑存储而不会采用真正的HashMap结构(zipmap),当成员数量增大到一定值时会自动转成真正的HashMap。
  3. list
    • 是一个有序可重复的集合,遵循FIFO原则,底层依赖双向链表实现,支持双向的Pop/Push,一般从左端Push,右端Pop。
    • 使用场景:
      • 各种列表,比如twitter的关注列表、粉丝列表等,最新消息排行、最新回复、每篇文章的评论等也可以用Redis的list结构来实现。
      • 用作消息队列,可以利用List的push操作将任务存在List中,然后工作线程再用pop操作将任务取出执行。
    • 实现方式:Redis list的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销
  4. set
    • 是一种无序天然去重的集合,即key-set,此外提供了交集、并集等一系列直接操作集合的方法
    • 使用场景:
      • 某些需要去重的列表,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的。
      • 可以存储一些集合性的数据,比如在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis还为集合提供了求交集、并集、差集等操作,可以非常方便的实现如共同关注、共同喜好、二度好友等功能。
  5. zset / sorted set
    • 有序集合,相比set,元素放入集合时还要提供该元素的分数score,可根据分数自动排序。
    • 使用场景:
      • 存放一个有序的并且不重复的集合列表
      • 可以做带权重的队列或者排行榜相关的

三种特殊的数据类型:

  1. Bitmap:以位为单位的数组,数组中的每个单元只能存0或1,如果只需要统计数据的二值状态,例如商品有没有、用户在不在等就可以使用Bitmap;
  2. Hyperloglog:用于统计基数,在输入元素的数量或体积即使很大时计算基数所需的空间总是固定的并且很小,适用场景有:统计网页的不重复访客,一个人访问网站多次但还是只计算为1次;
  3. Geospatial:主要用于存储地理位置信息,并对存储的信息进行操作,适用场景如朋友的定位、附近的人、打车距离计算等;
✨18.1 String和Hash类型的区别?

适合用 String 存储的情况:

  • 存储对象信息时,可以用String存储也可以用Hash存储,每次需要访问该对象大量的字段时使用String(大多数存储对象的情况)
  • 存储的结构具有多层嵌套的对象的时候,占用的空间比Hash小

适合用 Hash 存储的情况:

  • 当我们需要存储一个特别大的对象时,而且在大多数情况中只需要访问该对象中少量的字段时,可以考虑使用Hash

19. Redis并发竞争问题如何解决?

使用Redis分布式锁,确保在同一时间只能有一个系统实例在操作某个key,其他实例不允许读和写;每次要写之前,先判断当前这个value的时间戳是否比缓存中的value的时间戳更新,如果更新那么可以写,如果更旧则不能用旧的数据覆盖新的数据;

20. 【如何利用Redis实现分布式Session?】

Spring Session+Redis

给spring session配置基于redis来存储session数据,然后配置一个spring session的过滤器,session相关操作都会交给spring session来管理,直接基于spring sesion从redis中获取数据了。

✨21.1 如何利用Redis实现分布式锁?

使用setnx、del和expire实现分布式锁会存在以下问题:

  1. setnx和expire非原子性

    先拿 setnx key value来争抢锁,抢到之后,再用 expire key 过期时间给锁加一个超时过期时间防止锁忘记释放。但这两个单独命令如果在 setnx 之后执行 expire 之前进程意外挂掉,此时这把锁就没有设置过期时间,别的线程就再也无法获得该锁了;

    加锁同时设置过期时间实现原子操作:set key value nx ex 过期时间(防止死锁)

    释放锁:delete key

  2. 锁误解除

    如果线程A成功获取到了锁并设置了过期时间30s,但线程A的执行时间超过了30s,锁过期自动释放,此时线程B获取到了锁;随后A执行完成并释放锁,但此时线程B加锁还没有执行完成,线程A实际释放的是线程B加的锁;

    在加锁的时候生成一个uuid作为value,充当唯一标识,在解锁之前先验证key对应的value是不是该线程的uuid;或者使用Lua脚本做验证标识和解锁操作

  3. 超时解锁导致并发

    如果线程A成功获取到了锁并设置了过期时间30s,但线程A的执行时间超过了30s,锁过期自动释放,此时线程B获取到了锁,相当于线程A和线程B在并发执行;

    将过期时间设置足够长确保代码逻辑在所释放之前能够执行完成;或为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间

  4. 不可重入

    Redis可通过对锁进行重入计数,加锁时加1,解锁时减1,当计数归0时释放锁

21.2 Redis做分布式锁死锁的情况,如何解决?

  1. 加锁没有释放锁,此时需要加释放锁的操作比如delete key
  2. 加锁后程序还没有执行释放锁就挂了,此时需要用到key的过期机制

22. Redis 是如何判断数据是否过期的呢?

Redis 通过一个叫做过期字典的hash表来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key,过期字典的值是一个 long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间;

23. 【RedLock算法的原理】(待补充)

Redisson完成了对RedLock算法的封装

24. 什么是缓存预热和缓存降级?

  • 缓存预热:指系统上线后提前将相关的缓存数据加载到缓存系统,避免在用户请求的时候先查询数据库再将数据缓存的问题,用户直接查询事先被预热的缓存数据;(优先保证热点数据提前加载到缓存

  • 缓存降级:指缓存失效或缓存服务器挂掉的情况下不去访问数据库,直接返回默认数据或访问服务的内存数据;

25. Redis如何做内存优化?

  • 控制key的数量。对于存储相同的数据内容利用Redis的数据结构降低外层键的数量可以节省大量内存
  • 缩减键值对象。降低Redis内存使用最直接的方式就是缩减key和value的长度
    • key长度:在设计键时,在能完整描述业务情况下键值越短越好;
    • value长度:把业务对象序列化成二进制数组放入Reids,并且在业务上精简业务对象去掉不必要的属性避免存储无效数据;

26. 如果现在有个超高并发的系统,用Redis来抗住大部分读请求,请问要怎么设计?

  • 如果是读高并发的情况,先看读并发的数量级是多少,因为Redis单机的读QPS在万级,可承载每秒几万,使用一主多从+哨兵集群的缓存架构可以来承载每秒10w+的读并发,采用主从复制读写分离的模式,主库负责写,多个从库负责读,更好地支持水平扩容,根据读请求的QPS来决定加多少个Redis从实例;
  • 如果是写并发的情况,需要缓存1T+的数据,一主多从架构就受单主内存容量瓶颈制约不能解决问题,需要选择Redis cluster模式,采用多主多从架构,每个主节点存一部分数据,假设一个master存32G,只需要n*32G>=1T即可,n个master节点就可以支持海量数据的存储了;

分布式

1. 【什么是CAP定理?】

CAP定理指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性)

  • Consistency (一致性):

    “all nodes see the same data at the same time”,即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。

  • Availability (可用性):

    可用性指“Reads and writes always succeed”,即非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。

  • Partition Tolerance (分区容错性):

    分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性或者可用性的服务。分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。

需要补充说明的一点是: 如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证(CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C)

2. 如何实现分布式存储?

实现这种存储架构主要有三种通用的形式:

  • 中间控制节点架构(HDFS)

    在该系统的整个架构中将服务器分为两种类型,一种名为namenode,这种类型的节点负责管理管理数据(元数据),另外一种名为datanode,这种类型的服务器负责管理实际数据;

    如果客户端需要从某个文件读取数据,首先从namenode获取该文件的位置(具体存放在哪个datanode),然后从该位置获取具体的数据。在该架构中namenode通常是主备部署,而datanode则是由大量节点构成一个集群。由于元数据的访问频度和访问量相对数据都要小很多,因此namenode通常不会成为性能瓶颈,而datanode集群可以分散客户端的请求。因此,通过这种分布式存储架构可以通过横向扩展datanode的数量来增加承载能力,也即实现了动态横向扩展的能力。

  • 完全无中心架构—计算模式(Ceph)

    该架构中与HDFS不同的地方在于该架构中没有中心节点namenode。客户端是通过一个设备映射关系计算出来其写入数据的位置,这样客户端可以直接与存储节点通信,从而避免中心节点可能存在的性能瓶颈;

    在Ceph存储系统架构中核心组件有Mon服务、OSD服务和MDS服务等。对于块存储类型只需要Mon服务、OSD服务和客户端的软件即可。其中Mon服务用于维护存储系统的硬件逻辑关系,主要是服务器和硬盘等在线信息。Mon服务通过集群的方式保证其服务的可用性。OSD服务用于实现对磁盘的管理,实现真正的数据读写,通常一个磁盘对应一个OSD服务。 客户端访问存储的大致流程是,客户端在启动后会首先从Mon服务拉取存储资源布局信息,然后根据该布局信息和写入数据的名称等信息计算出期望数据的位置(包含具体的物理服务器信息和磁盘信息),然后该位置信息直接通信,读取或者写入数据。

  • 完全无中心架构—一致性哈希(Swift)

    一致性哈希的方式就是将设备做成一个哈希环,然后根据数据名称计算出的哈希值映射到哈希环的某个位置,从而实现数据的定位;

    为了保证数据分配的均匀性及出现设备故障时数据迁移的均匀性,一致性哈希将磁盘划分为比较多的虚拟分区,每个虚拟分区是哈希环上的一个节点。整个环是一个从0到32位最大值的一个区间,并且首尾相接。当计算出数据(或者数据名称)的哈希值后,必然落到哈希环的某个区间,然后以顺时针,必然能够找到一个节点。那么,这个节点就是存储数据的位置。

5. 【你能说下单点登录SSO吗?】

分布式架构才导致了单点登录,单点登录就是分布式登录,即在分布式系统中一个系统登录,其他所有系统共享登录状态;

单点登录的主要实现方式:将登录功能单独拿出来作为一个独立的模块(即SSO模块),仅且仅与登录相关的操作在该模块中完成;

单一架构将所有的业务都部署到一个Tomcat上,而分布式架构将不同业务部署不同的Tomcat上,形成一个Tomcat集群,这就造成了下面多个Tomcat之间如何共享登录信息的问题

分布式系统存在的问题与解决方案:

  • Session不共享问题

    单点登录的问题是Session是各个系统所独自拥有的,各个系统不知道用户是否登录,无法共享用户的登录状态,解决的切入点是“要让所有的系统就都可以知道现在用户登录没有”;

    • Tomcat集群Session全局复制,即使得集群内每个Tomcat的Session完全同步,在任何时候都完全一样,但当集群内Tomcat数量过多时Session全局复制会导致集群性能下降,不建议使用;
    • 把Session数据缓存在各系统都可以访问的Redis中,这样所有系统就都可以知道现在用户登录状态,建议使用;
    • 使用Token取代Session保存登录状态,Token是用户名登录生成的一个字符串,存在请求头中进行网络传送,使用Token代替Session,Token相对于Session由于其无状态可以实现应用间共享,解决在Tomcat间共享的问题(主流SSO解决方案)
  • Cookie跨域问题

    跨域问题就是不同域名(一级域名相同二级域名不同)的Cookie服务端接收不到,但是Cookie不是重点,Cookie里面存放的Token才是实现自动登录的重点,Token存在于Cookie中,Cookie不能只能让创建cookie的服务端接收到,导致Token只能让创建Cookie的服务端接收到,解决的切入点是”要让Token在全网服务端都能接收到“

    • 放大Cookie作用域使多个域名共享Cookie,即服务端在新建Cookie第一次发送请求给客户端的时候,就设置好Cookie的domain,设置为顶级Cookie,以后请求还是使用Cookie传输Token;
    • 直接将Token放到URL请求地址后面,按照一定规则生成Token,第一次通过Cookie将Token发送请求给客户端后,前端将Token解析出来,以后请求不再使用Cookie来传输Token,而是直接将Token放到URL请求地址后面进行传输;
    • 将Token保存在SessionStroage或LocalStorage中,不使用Cookie,二者都是临时数据存储,SessionStorage表示会话内有效,LocalStorage表示指定时间有效;

单一架构的登录过程:

  1. 首先客户端会发送一个http请求到服务器端(用户登录请求);
  2. 服务器端接收客户端请求后,新建一个保存有用户信息(包含登录状态)的Session并存储在该服务器上,发送一个http响应到客户端,这个响应头部包含了服务器端所给的Cookie,其中sessionId包含在该Cookie中,完成登录认证;
  3. 在客户端发起第二次请求(业务请求)时,浏览器自动在http请求头中添加Cookie;
  4. 服务器端接收到请求后分解Cookie,获取到其中的sessionId验证信息,通过session来判断该用户是否登录,核对成功后返回响应给客户端成功登录;

分布式架构的单点登录过程:

  • 父域Cookie+Session+Redis(同父域下SSO,不支持跨主域名)

    在sso登录模块中登录后,实际上要完成两件事:一是在服务器端的Session中记录包括登录状态等用户信息,同时在客户端下写入保存有SessionId的Cookie,在sso登录以后,将sso的Cookie的域设置为父域,这样所有子域的应用系统都可以访问到父域的Cookie解决了浏览器的Cookie不能跨域的问题,通过将session集中缓存在redis中解决了服务器端Session不能共享的问题。不过这种方式要求应用系统的域名需建立在一个共同的主域名之下。

  • Token+Cookie+CAS认证中心(支持跨主域名SSO)

    部署一个专门负责处理登录请求的sso认证中心,即CAS:

    1. 当用户想要去访问系统A,若系统A发现用户还未登录,就重定向到sso认证中心,并将自己的地址作为参数如www.sso.com?service=www.applicationA.com,sso认证中心发现用户未登录,将用户引导至登录页面统一在认证中心进行登录,用户与认证中心建立全局会话,登录成功后认证中心记录用户的登录状态等信息到授权令牌Token中,并将 Token 写入 Cookie保存在浏览器上(注意这个 Cookie 是认证中心的,应用系统是访问不到的),之后认证中心重定向回系统A,并把Token携带去给系统A,重定向的地址如www.applicationA.com?token=xxx
    2. 系统A去sso认证中心验证这个Token是否有效,如果有效则系统A使用该Token和用户建立局部会话
    3. 当用户想要访问另一个系统B,系统B发现用户还没有登录,于是重定向到sso认证中心,并将自己的地址作为参数同时带上保存在浏览器上的Cookie,认证中心根据带过来的Cookie发现已经与用户建立了全局会话了,就重定向回系统B,并把Token携带过去给系统B,重定向的地址如www.applicationB.com?token=xxx
    4. 系统B去sso认证中心验证这个Token是否有效,如果有效则系统B使用该Token和用户建立局部会话;
  • Token+LocalStorage(支持跨主域名SSO,几乎可以在前端完成)(待补充)

6. 什么是BASE理论?

ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。

BASEBasically Available(基本可用)Soft-state(软状态)Eventually Consistent(最终一致性) 三个短语的缩写。

BASE理论核心思想:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。

BASE理论三要素:

  • 基本可用

    基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是这绝不等价于系统不可用。

    什么叫允许损失部分可用性呢?

    • 响应时间上的损失: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。
    • 系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统部分非核心功能无法使用
  • 软状态

    软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时

  • 最终一致性

    最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态,本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性

7.1 如何保证分布式系统中的接口幂等性?

(1)每个请求必须有一个全局请求唯一id,比如订单支付请求肯定得包含订单id,一个订单id最多支付一次

(2)每次处理完请求之后,必须有一个记录标识已经处理过这个请求,比如说常见的方案是在mysql中记录状态

(3)利用主键唯一索引,且每次接收请求需要进行判断之前是否处理过该逻辑,比如说如果有一个订单已经支付了,就已经有了一条支付流水,那么如果重复发送这个请求,则此时要先插入支付流水,而订单Id已经存在了,唯一约束生效,报错无法插入使整个事务回滚。

7.2 幂等有哪些技术解决方案?(待补充)

✨8. 【谈谈你对分布式事务的理解?如何实现最终一致性分布式事务?】

分布式概念:一个系统分拆多个子系统并部署在不同的服务器上, 然后通过一定的通信协议能够让这些子系统之间相互通信。(核心问题在于解决如何拆分和如何连接这两个问题)

  • 优点:提高可用性、可扩展性;
  • 缺点:数据一致性、网络延迟问题;

分布式事务概念:分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性

实现强一致性分布式事务方案:

  • 二阶段提交方案(2PC) / XA方案

    概念:参与者将操作成败通知事务管理器(即协调者),再由事务管理器根据所有参与者的反馈情报决定各参与者是否要提交操作还是中⽌操作。

    作⽤:主要保证了分布式事务的原⼦性;第⼀阶段为准备阶段,第⼆ 阶段为提交阶段;

    • 第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)操作,并反映是否可以提交;
    • 第二阶段:事务协调器要求每个数据库提交数据(commit),或者回滚数据;

    缺点:

    • 单点问题:事务管理器在整个流程中扮演的角色很关键,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用;
    • 同步阻塞:在准备就绪之后,资源管理器中的资源一直处于阻塞状态,直到提交完成才释放资源,不支持高并发操作;
    • 数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致。

    2PC和3PC提交方案的区别:

    1. 3PC比2PC在pre commit阶段前多了一个can commit阶段,因为2PC在第一阶段会占用资源,而3PC在这阶段不占用资源,只是校验一下sql,如果不能执行就直接返回,减少了资源占用;
    2. 2PC只有协调者有超时机制,超时后发送回滚指令,而3PC的协调者和参与者中都引入超时机制,协调者超时,即can commit和pre commit中如果收不到参与者的反馈,则协调者向参与者发送中断指令;参与者超时,即pre commit阶段参与者进行中断,do commit阶段,参与者进行提交;

实现最终一致性分布式事务方案:

  • 【TCC事务补偿型方案(Try-Confirm-Cancel)- Seata】

    TCC方案适用于服务间是接口同步调用的情况;

    • Try阶段:尝试执行。完成所有业务检查,预留并锁定必须的业务资源;

    • Confirm阶段:确认执行。真正开始执行业务,不作任何业务检查,只使用Try阶段预留的业务资源,如果Try阶段所有分支事务都执行成功后开始执行Confirm操作,Confirm失败后需要进行重试;

    • Cancel阶段:取消执行。释放Try阶段预留的业务资源,如果任何一个服务的业务方法执行出错,就执行前面全部已经执行成功的业务逻辑的回滚操作;

    以电商项目下单扣除库存作为例子:

    • Try阶段:创建订单,并将订单状态设置为待提交,调用库存服务预扣减库存。库存表中库存字段减去订单中的数量,同时在预扣减字段中增加订单中库存数量,以此来预留资源;

    • Confirm阶段:如果try全部成功,则进入confirm阶段。此阶段将订单状态修改为已提交,库存服务则将预扣减库存字段的数量减去订单中的数量,实现真正的减库存。(通常TCC方案我们都认为confirm阶段是不会出错的。就是说只要try成功了,那么confirm就一定会成功。如果confirm出错了,那么就需要引入补偿机制或者人工处理)

    • Cancel阶段:try阶段失败或者出现异常,则进入cancel阶段,订单状态修改为已取消,库存服务将表中库存字段增加订单中的数量,预扣减库存字段减去订单中的数量,以此实现事务回滚。(同样TCC中我们认为cancel阶段一定会执行成功,如果失败也需要引入重试或者人工处理)

    TCC方案中锁定资源的粒度小,有利于提高系统性能;Confirm和Cancel阶段的幂等保证分布式事务执行完成后数据的一致性。由主业务方发起事务,无论是主业务还是分支业务都能集群部署,解决了XA方案的单点问题。但是它的代码需要耦合到业务中,参与分布式事务的每个业务方法都需要try,confirm,cancel阶段,增加开发成本。

    TCC空回滚是解决什么问题的?(待补充)

    如何解决TCC幂等问题?(待补充)

    如何解决TCC中悬挂问题?(待补充)

  • 可靠消息最终一致性方案(基于MQ或本地消息表来实现事务) - RocketMQ】

    可靠消息最终一致性方案:当事务的发起方(消息生产者)执行完本地事务后,同时发出一条消息,事务参与方(消息消费者)一定能够接收消息并可以成功处理自己的事务;

    可靠消息方案简易流程:

    1. 当有请求进来时,事务发起方向MQ发送一个half message,此时这个消息事务参与方还无法消费;
    2. 当事务发起方收到MQ返回的发送成功指令后,开始执行本地事务,如果本地事务执行成功则向MQ发送提交half message请求,如果本地事务执行失败则向MQ发送回滚half message请求;
    3. 当消息被确认提交后,这条消息就可以被事务参与者消费,当消息被回滚后,消息删除不进行投递,事务参与者就无法消费到这条消息;
    4. 当MQ长时间没有收到事务发起方的提交或者回滚请求时,会向事务发起方回查事务状态,事务发起方检查完本地事务的状态后,根据事务状态选择提交或者回滚half message的操作;
  • 最大努力通知型方案

    最大努力通知型方案适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果的情况

    特点如下:

    • 不可靠消息:业务活动主动方在完成业务处理之后,向业务活动的被动方发送消息,直到通知N次后不再通知,允许消息丢失;
    • 定期校对:业务活动的被动方根据定时策略向业务活动主动方查询,由主动方提供查询接口,恢复丢失的业务消息;

9 介绍Spring Cloud核心组件及其作用(说说Spring Cloud的工作原理)

Eureka:各个服务启动时,Eureka Client都会将服务注册到Eureka Server,并且Eureka Client还可以反过来从Eureka Server拉取注册表,从⽽发现其他服务在哪⾥(服务注册与服务发现)

  • Eureka的客户端默认每隔30s会向eureka服务端更新实例,注册中⼼也会定时进⾏检查,发现某个实例默认90s内没有再收到心跳,会注销此实例。(springcloud心跳机制)

Ribbon:服务间发起请求的时候,基于Ribbon做负载均衡,从有该服务的多台机器中选择⼀台(服务负载均衡)

Feign:基于Feign的动态代理机制,根据注解和选择的机器,拼接请求URL地址发起请求(远程服务调用)

Hystrix:发起请求是通过Hystrix的线程池来⾛的,不同的服务⾛不同的线程池,实现了不同服务调⽤的隔离,避免了服务雪崩的问题(服务隔离、降级与熔断)

Zuul:如果前端要调⽤后端系统,统⼀从Zuul⽹关进⼊,由Zuul⽹关转发请求给对应的服务(服务网关)

10. 说一说Spring Cloud服务发现原理(Eureka是如何进行服务注册发现的?)

  1. 每30s发送⼼跳检测重新进⾏租约,如果客户端不能多次更新租约,它将在90s内从服务器注册中⼼移除。
  2. 注册信息和更新会被复制到其他Eureka 节点,来⾃任何区域的客户端可以查找到注册中⼼信息,每30s发⽣⼀次复制来定位他们的服务,并进⾏远程调⽤。
  3. 客户端还可以缓存⼀些服务实例信息,所以即使Eureka Server全挂掉,客户端也是可以定位到服务地址的。

✨11 Hystrix如何实现熔断?(待补充)

Hystrix在运⾏过程中会向每个commandKey对应的熔断器报告成功、失败、超时和拒绝的状态,熔断器维护计算统计的数据,根据这些统计的信息来确定熔断器是否打开。如果打开,后续的请求都会被截断。然后会隔⼀段时间(默认是5s)尝试半开,放⼊⼀部分流量请求进来,相当于对依赖服务进⾏⼀次健康检查,如果恢复则熔断器关闭,随后完全恢复调⽤。

12. RPC和HTTP的区别,使⽤场景? (待补充)

**RPC(Remote Procedure Call)**:远程过程调用,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。

两者区别如下:

  • 传输协议

    • RPC,可以基于TCP协议,也可以基于HTTP协议
    • HTTP,基于HTTP协议
  • 传输效率

    • RPC,使⽤⾃定义的TCP协议,可以让请求报⽂体积更⼩,或者使⽤HTTP2协议,也可以很好的减少报⽂的体积,提⾼传输效率

    • HTTP,如果是基于HTTP1.1的协议,请求中会包含很多⽆⽤的内容,如果是基于HTTP2.0,那么简单的封装以下是可以作为⼀个RPC来使⽤的,这时标准RPC框架更多的是服务治理

  • 性能消耗,主要在于序列化和反序列化的耗时

    • RPC,可以基于thrift实现⾼效的⼆进制传输

    • HTTP,⼤部分是通过json来实现的,字节⼤⼩和序列化耗时都⽐thrift要更消耗性能

  • 负载均衡

    • RPC,基本都⾃带了负载均衡策略
    • HTTP,需要配置Nginx,HAProxy来实现
  • 服务治理(下游服务新增,重启,下线时如何不影响上游调⽤者)

    • RPC,能做到⾃动通知,不影响上游

    • HTTP,需要事先通知,修改Nginx/HAProxy配置

    总结:RPC主要⽤于公司内部的服务调⽤,性能消耗低,传输效率⾼,服务治理⽅便。HTTP主要⽤于对外的异构环境,浏览器接⼝调⽤,APP接⼝调⽤,第三⽅接⼝调⽤等。

13. 分布式id的生成方案有哪些?

  1. UUID:通用唯一标识码,让分布式系统中的所有元素都有唯一的便是信息,而不需要通过中央控制器来指定唯一标识;缺点在于占用16个字符,且不是递增有序的数字;
  2. 数据库自增主键:MySQL数据库设置主键且主键自动增长,缺点在于自增在分库分表时需要改造较为复杂,数据和数据量容易泄露;
  3. Redis自增,通过Redis计数器原子性自增
  4. 雪花算法
    • 雪花算法生成id的组成部分:1位符号位,41位时间戳,10位机器码以及12位序列码;

14. 微服务相关内容补充

14.1 微服务架构原理是什么?

主要是面向服务理念,更小粒度地拆分服务,将功能分解到各个服务当中,从而降低系统的耦合性,并提供更加灵活的服务支持;

14.2 注册中心的原理是什么?

以Eureka为例,服务启动后向Eureka注册,Eureka Server会将注册信息向其他Eureka Server进行同步,当服务消费者要调用服务提供者时,则向服务注册中心获取服务提供者地址然后会将服务提供者地址缓存在本地,下次再调用时则直接从本地缓存中获取服务列表来完成服务调用;

14.3 配置中心的原理是什么?

在服务运行之前,将所需的配置信息从配置仓库拉取到本地服务,达到统一化配置管理的目的;

14.4 配置中心是如何实现自动刷新的?

以Config为例:

  1. 配置中心Server端承担起配置刷新的职责,提交配置触发post请求给Server端的bus/refresh接口,Server端接收到请求并发送给Spring Cloud Bus总线;
  2. Spring Cloud Bus接收到消息并通知给其他连接到总线的客户端,其它客户端接收到通知,请求Server端获取到最新配置;
14.5 Ribbon负载均衡原理是什么?

Ribbon通过ILoadBalancer接口对外提供统一的选择服务器Server的功能,ILoadBalancer通过调用IRule的choose()返回合适的Server给使用者;

14.6 注册中心和服务挂了应该如何处理?

当注册中心挂了可以读取本地持久化里的配置,当服务挂了配有的服务监控中心会感知到服务下线,之后通过配置好的通知机制通知相关人员排查问题;

搜索引擎(待补充)

1. es的分布式架构原理能说一下么(es是如何实现分布式的)

elasticsearch设计的理念就是分布式搜索引擎,底层其实还是基于lucene的。核心思想就是在多台机器上启动多个es进程实例,组成了一个es集群。

es中存储数据的基本单位是索引,比如说你现在要在es中存储一些订单数据,你就应该在es中创建一个索引order_idx,所有的订单数据就都写到这个索引里面去,一个索引差不多就是相当于是mysql里的一张表。index -> type -> mapping -> document -> field

  • index:相当于mysql里的一张表

  • type:没法跟mysql里去对比,一个index里可以有多个type,每个type的字段都是差不多的,但是有一些略微的差别。

    • 好比说,有一个index,是订单index,里面专门是放订单数据的,有些订单是实物商品的订单,而有些订单是虚拟商品的订单,就两种订单大部分字段是一样的,但是少部分字段可能有略微的一些差别。所以就会在订单index里,建两个type,一个是实物商品订单type,一个是虚拟商品订单type,这两个type大部分字段是一样的,少部分字段是不一样的。
    • 很多情况下,一个index里可能就一个type,但是确实如果说是一个index里有多个type的情况,你可以认为index是一个类别的表,具体的每个type代表了具体的一个mysql中的表
    • 每个type有一个mapping,如果你认为一个type是一个具体的一个表,index代表了多个type的同属于的一个类型,mapping就是这个type的表结构定义,你在mysql中创建一个表,肯定是要定义表结构的,里面有哪些字段,每个字段是什么类型;
  • mapping就代表了这个type的表结构的定义,定义了这个type中每个字段名称,字段是什么类型的,然后还有这个字段的各种配置

  • 实际上往index里的一个type里面写的一条数据,叫做一条document,一条document就代表了mysql中某个表里的一行
  • 每个document有多个field,每个field就代表了这个document中的一个字段的值

索引可以拆分成多个shard,每个shard存储部分数据。这个shard的数据实际是有多个备份,就是说每个shard都有一个primary shard负责写入数据,还有几个replica shard。primary shard写入数据之后,会将数据同步到其他几个replica shard。通过这个replica的方案,每个shard的数据都有多个备份,如果某个机器宕机了还有别的数据副本在别的机器上实现高可用;

es集群多个节点,会自动选举一个节点为master节点,这个master节点其实就是干一些管理的工作,比如维护索引元数据,负责切换primary shard和replica shard身份等。要是master节点宕机了,那么会在剩余节点中重新选举一个节点为master节点。如果是非master节点宕机了,那么会由master节点,让那个宕机节点上的primary shard的身份转移到保存在其他机器上的replica shard上。修复了那个宕机机器重启了之后,master节点会控制将宕机节点上原本的primary shard修改为replica shard,让集群恢复正常。

2. es写入数据的工作原理是什么?es查询数据的工作原理是什么?

es写入数据过程:

1)客户端选择一个node发送请求过去,这个node就是coordinating node(协调节点)

2)coordinating node,对document进行路由,将请求转发给对应的有primary shard的node

3)实际的node上的primary shard处理请求,然后将数据同步到replica node

4)coordinating node,如果发现primary node和所有replica node都搞定之后,就返回响应结果给客户端

  • 写入数据底层原理:
    1. 先写入buffer(内存),在buffer里的时候数据是搜索不到的,同时将数据写入translog日志文件;
    2. 如果buffer快满了,或者每隔一定时间,就会将buffer中的数据refresh到一个新的segment file(磁盘文件)中,但是此时数据不是直接进入segment file的,而是先进入os cache(操作系统缓存),这个过程就是refresh操作,只要buffer中的数据被refresh操作刷入os cache中,就代表这个数据就可以被搜索到了;(默认是每隔1秒refresh一次的,所以es是准实时的,因为写入的数据1秒之后才能被看到)
    3. 重复前面步骤,新的数据不断进入buffer和translog,不断将buffer数据写入一个又一个新的segment file中去,每次refresh完buffer清空,translog保留。随着这个过程推进,translog会变得越来越大。当translog达到一定长度的时候,就会触发commit操作。
      • commit操作就是将buffer中现有数据refresh到os cache中去,然后清空buffer,将一个commit point写入磁盘文件,里面标识着这个commit point对应的前面写入的所有segment file;之后强行将os cache中缓存所有的数据都fsync到磁盘中去;最后将现有的translog清空,然后再次重新启用一个translog,此时commit操作完成。默认每隔30分钟会自动执行一次commit,但是如果translog过大,也会触发commit。整个commit的过程叫做flush操作
      • translog日志文件作用:将数据对应的操作写入一个专门的日志文件防止宕机导致写入buffer或os cache内存中的数据丢失,宕机重启后es也会自动读取translog日志文件中的数据,恢复到内存buffer和os cache中去;
      • 可能会丢失数据的,translog其实也是先写入os cache的,默认每隔5秒刷一次到磁盘中去,所以默认情况下有5秒的数据会停留在buffer、translog os cache、segment file os cache中,有5秒的数据不在磁盘上,此时如果宕机,会导致这5秒的数据丢失,如果不希望丢失数据也可以将translog设置成每次写操作必须是直接fsync到磁盘。
    4. buffer每次refresh一次,就会产生一个segment file,所以默认情况下是1秒钟一个segment file,segment file会越来越多,此时会定期执行merge操作
      • 每次merge的时候,会将多个segment file合并成一个,同时这里会将标识为deleted的doc给物理删除掉,然后将新的segment file写入磁盘,这里会写一个commit point,标识所有新的segment file,然后打开segment file供搜索使用,同时删除旧的segment file。

es查询数据过程:

1)客户端发送请求到任意一个node,成为coordinate node

2)coordinate node对document进行路由,将请求转发到对应的node,此时会使用round-robin随机轮询算法,在primary shard以及其所有replica中随机选择一个,让读请求负载均衡

3)接收请求的node返回document给coordinate node

4)coordinate node返回document给客户端

es搜索数据过程:

1)客户端发送请求到一个coordinate node

2)协调节点将搜索请求转发到所有的shard对应的primary shard或replica shard也可以

3)query phase:每个shard将自己的搜索结果(其实就是一些doc id),返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果

4)fetch phase:接着由协调节点,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端

  • 搜索的底层原理:倒排索引

    区别于传统检索是通过文章逐个遍历找到对应关键词的位置,倒排索引是通过分词策略形成词和文章的映射关系表,从词出发记载了这个词在哪些文档中出现过,由词典和倒排表两部分组成;(倒排索引底层实现基于FST数据结构)

3. es在数据量很大的情况下(数十亿级别)如何提高查询性能?

4. es生产集群的部署架构是什么?每个索引的数据量大概有多少?每个索引大概有多少个分片?

(1)es生产集群我们部署了5台机器,每台机器是6核64G的,集群总内存是320G

(2)我们es集群的日增量数据大概是2000万条,每天日增量数据大概是500MB,每月增量数据大概是6亿,15G。目前系统已经运行了几个月,现在es集群里数据总量大概是100G左右。

(3)目前线上有5个索引(这个结合你们自己业务来,看看自己有哪些数据可以放es的),每个索引的数据量大概是20G,所以这个数据量之内,我们每个索引分配的是8个shard,比默认的5个shard多了3个shard。

计算机网络

1. 网络分层模型有哪些以及每一层的作用

OSI分层 (7层):物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。

TCP/IP分层(4层):网络接口层、 网际层、运输层、 应用层。

五层协议 (5层):物理层、数据链路层、网络层、运输层、 应用层。

每一层的作用如下:

物理层:通过媒介传输比特,确定机械及电气规范(比特Bit)

数据链路层:将比特组装成帧和点到点的传递(帧Frame)

网络层:负责数据包从源到宿的传递和网际互连(数据报)

传输层:提供端到端的可靠报文传递和错误恢复(报文段)

会话层:建立、管理和终止会话(会话协议数据单元SPDU)

表示层:对数据进行翻译、加密和压缩(表示协议数据单元PPDU)

应用层:允许访问OSI环境的手段(应用协议数据单元APDU)

2. 计算机网络各层有哪些协议?

每一层的协议如下:

物理层:中继器,集线器,网关

数据链路层:PPP点对点协议、ARQ自动重传请求协议、CSMA/CD停止等待协议

网络层:IP网际协议、ICMP控制消息协议、ARP地址转换协议、RARP反向地址转换协议、OSPF分布式链路状态协议、BGP边界网关协议RIP路由信息协议、IGMP组管理协议

传输层:TCP传输控制协议、UDP用户数据报协议

应用层:FTP文本传输协议、DNS域名解析系统、Telnet远程登录协议、SMTP简单邮件传输协议、HTTP超文本传输协议、SSH安全外壳协议、DHCP动态主机配置协议

2.1 ICMP协议的作用

ICMP协议:控制消息协议,属于网络层协议,主要用于在主机与路由器之间传递控制信息,包括报告错误、交换受限控制和状态信息等。当遇到IP数据无法访问目标、IP路由器无法按当前的传输速率转发数据包等情况时,会自动发送ICMP消息。(ping是工作在TCP/IP网络体系结构中应用层的一个服务命令,基于ICMP协议,主要是向特定的目的主机发送ICMP请求报文,测试目的站是否为可达及了解其有关状态)

3. 【请说一下从浏览器地址栏输入URL到显示主页的过程】

1、客户端通过DNS解析,查找域名对应的IP地址,通过IP地址找到客户端到服务器的路径;
2、客户端发起TCP请求,与服务器通过三次握手建立TCP连接,向IP对应的web服务器发送一个HTTP请求(Cookie会随着请求发送给服务器);
3、服务器响应HTTP请求,返回网页内容;

4、客户端解析html并渲染网页内容;

5、TCP四次挥手释放连接;

3.1 URI和URL的区别
  • URI:统一资源标识符,主要作用是唯一标识一个资源。
  • URL:统一资源定位符,主要作用是提供资源的路径。

4. 【ARP地址解析协议工作原理】

ARP地址解析协议:用于实现IP地址到MAC地址的映射;(广播发送ARP请求,单播发送ARP响应)

  1. 首先,每个主机都会在自己的ARP缓冲区中建立一个ARP列表,以表示IP地址和MAC地址之间的对应关系。

  2. 当源主机要发送数据时,首先检查ARP列表中是否有对应IP地址的目的主机的MAC地址,如果有,则直接发送数据,如果没有,就向本网段的所有主机发送ARP数据报,该数据报包括的内容有:源主机 IP地址,源主机MAC地址,目的主机的IP 地址

  3. 当本网络的所有主机收到该ARP数据报时,首先检查数据报中的IP地址是否是自己的IP地址,如果不是,则忽略该数据报,如果是,则首先从数据报中取出源主机的IP和MAC地址写入到自己的ARP列表中,如果已经存在则覆盖,然后将自己的MAC地址写入ARP响应报中,告诉源主机自己是它想要找的MAC地址。

  4. 源主机收到ARP响应报后。将目的主机的IP和MAC地址写入ARP列表,并利用此信息发送数据。如果源主机一直没有收到ARP响应数据报,表示ARP查询失败。

4.1 有了IP地址为什么还要用MAC地址?
  • 计算机的IP地址可由用户自行更改,管理起来就相对困难,而MAC地址不可更改,所以一般会把IP地址和MAC地址组合起来使用;
  • 为什么要用IP地址呢?是因为IP地址是和地域相关的,对于同一个子网上的设备,IP地址的前缀都是一样的,这样路由器通过IP地址的前缀就知道设备在在哪个子网上了,而只用MAC地址的话,路由器则需要记住每个MAC地址分别在哪个子网,这需要路由器有极大的存储空间是无法实现的;

5. TCP和UDP相关问题

5.1 请简述TCP与UDP的区别
  1. TCP面向连接,UDP不面向连接即发送数据前不需要建立连接
  2. TCP提供可靠的服务(数据传输),UDP无法保证可靠交付
  3. TCP面向字节流,UDP面向报文且没有拥塞控制
  4. TCP只能是点到点的,UDP支持一对一和多对多的交互通信
  5. TCP注重数据安全性,UDP数据传输快,因为不需要连接等待,少了许多操作,但是其安全性却一般
5.2 TCP的粘包和拆包问题

TCP的粘包和拆包:指的是一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送
产生粘包和拆包的原因:

粘包:①要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去;
②接收数据端的应用层没有及时读取接收缓冲区中的数据;

拆包:①要发送的数据大于TCP发送缓冲区剩余空间大小;
②待发送数据大于MSS(最大报文长度);
解决方案:

①发送端将每个数据包封装为固定长度;
②在数据尾部增加特殊字符进行分割;
③将数据分为两部分,一部分是头部,一部分是内容体;其中头部结构大小固定,且有一个字段声明内容体的大小;

5.3 TCP的流量控制与滑动窗口

TCP滑动窗口:是操作系统开辟的一个缓存空间,窗口大小值表示无需等待确认应答,而可以继续发送数据的最大值。TCP头部有个字段叫win,也即那个16位的窗口大小,它告诉对方本端的TCP接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度,从而达到流量控制的目的。

5.4 TCP的拥塞控制
  • TCP拥塞控制:拥塞控制是作用于网络的,防止过多的数据包注入到网络中,避免出现网络负载过大的情况,最大化利用网络上瓶颈链路的带宽。只要网络中没有出现拥塞,拥塞窗口的值就可以再增大一些,以便把更多的数据包发送出去,但只要网络出现拥塞,拥塞窗口的值就应该减小一些,以减少注入到网络中的数据包数。而对比流量控制是作用于接收者的,根据接收端的实际接收能力控制发送速度,防止分组丢失的。
  • 拥塞控制常用算法
    • 慢启动:表示TCP建立连接完成后,一开始不要发送大量的数据,而是先探测一下网络的拥塞程度。由小到大逐渐增加拥塞窗口的大小,如果没有出现丢包,每收到一个ACK,就将拥塞窗口cwnd大小就加1(单位是MSS)每过一个RTT发送窗口增加一倍,呈指数增长,如果出现丢包或超过慢启动阈值,拥塞窗口就减半,进入拥塞避免阶段。
    • 拥塞避免
      cwnd到达慢启动阀值后,每收到一个ACK时,cwnd = cwnd + 1/cwnd;当每过一个RTT时,cwnd = cwnd + 1,避免过快导致网络拥塞问题。
    • 拥塞发生:发生超时重传时慢启动阈值减半,cwnd重置为1,进入新的慢启动过程;发生快速重传时,发送方收到3个连续重复的ACK时,就会快速地重传,不必等待RTO超时再重传。
    • 快速恢复:配合快速重传使用,重传重复的那几个ACK(即丢失的那几个数据包);如果再收到重复的 ACK,那么 cwnd = cwnd +1;如果收到新数据的 ACK 后, cwnd = sshthresh,因为收到新数据的 ACK,表明恢复过程已经结束,可以再次进入了拥塞避免的算法了。
  • TCP的重传机制
    • 超时重传:原理是在发送某一个数据以后就开启一个计时器,在一个RTO如果没有得到发送的数据报的ACK报文,那么就重新发送数据,直到发送成功为止。
    • 快速重传:发送方连着收到三个重复冗余ACK的确认后,知道哪个报文段在传输过程中丢失了;发送方在定时器过期之前,重传该报文段。
    • 带选择确认的重传(SACK):在快速重传的基础上,接收方返回最近收到报文段的序列号范围,这样发送方就知道接收方哪些数据包是没收到的。这样就很清楚应该重传哪些数据包啦。
    • 重复SACK:用来告诉发送方,有哪些数据包,自己重复接受了。DSACK的目的是帮助发送方判断,是否发生了包失序、ACK丢失、包重复或伪重传。让TCP可以更好的做网络流控

6. 【介绍一下TCP三次握手建立连接的过程】

  1. 客户端发送连接请求报文段并进入SYN_SEND状态,等待服务器端确认,无应用层数据;
  2. 服务器端收到客户端的连接请求报文段后,为该TCP连接分配缓存和变量,向客户端返回确认报文段ack允许连接并进入SYN_RCV状态,无应用层数据;
  3. 客户端为该TCP连接分配缓存和变量,并向服务器端返回确认的确认数据报ack,客户端进入ESTABLISHED状态,当服务器端接收到这个确认包时,也进入ESTABLISHED状态,可以携带数据;
6.1 为什么采用三次握手而不是两次握手?

建立连接的过程是利用客户-服务器模式,假设主机A为客户端,主机B为服务器端。

(1)TCP的三次握手过程:主机A向B发送连接请求;主机B对收到的主机A的报文段进行确认;主机A再次对主机B的确认进行确认。

(2)采用三次握手是为了防止失效的连接请求报文段突然又传送到主机B。失效的连接请求报文段是指:主机A发出的连接请求没有收到主机B的确认,于是经过一段时间后,主机A又重新向主机B发送连接请求且建立成功,顺序完成数据传输。考虑这样一种特殊情况,主机A第一次发送的连接请求并没有丢失,而是因为网络节点导致延迟达到主机B,主机B以为是主机A又发起的新连接,于是主机B同意连接,并向主机A发回确认,但是此时主机A根本不会理会,主机B就一直在等待主机A发送数据,导致主机B的资源浪费。

6.2 半连接队列和全连接队列

TCP进入三次握手前,服务器端会从CLOSED状态变为LISTEN状态,同时在内部创建了两个队列:半连接队列(SYN队列)和全连接队列(ACCEPT队列)

  • TCP三次握手时,客户端发送SYN到服务器端,服务器端收到之后,便回复ACK和SYN,状态由LISTEN变为SYN_RCV,此时这个连接就被推入了SYN队列,即半连接队列;
  • 当客户端回复ACK, 服务器端接收后,三次握手就完成了。这时连接会等待被具体的应用取走,在被取走之前,它被推入ACCEPT队列,即全连接队列;

7. 【介绍一下TCP四次挥手释放连接的过程】

  1. 客户端发送连接释放报文段FIN,停止发送数据,主动关闭TCP连接,客户端进入FIN_WAIT_1状态;
  2. 服务器端收到后回送一个确认报文段ACK,客户端到服务器端这个方向的连接就被释放了,但服务器端仍处于关闭等待(Close_Wait)状态,客户端仍可以接收数据,在接收到这个确认报文段之后,进入FIN_WAIT_2状态;
  3. 服务器端发完数据就发出连接释放报文段FIN,主动关闭TCP连接,服务器端进入LAST_ACK状态,等待来自客户端的最后一个ACK;
  4. 客户端回送一个确认报文段ACK,并进入时间等待(Time_Wait)状态,在等待两个最大生命周期2MSL后,连接彻底关闭;
7.1 为什么需要四次挥手?

TCP是全双工模式,这就意味着,当客户端发出FIN报文段时,只是表示客户端告诉服务器,它的数据已经全部发送完毕了,但是这个时候客户端还是可以接收来自服务端的数据;当服务端返回ACK报文段时,表示它已经知道客户端没有数据发送了,但是服务端还是可以发送数据到客户端;当服务端也发送了FIN报文段时,这个时候就表示服务端也没有数据要发送了,就会告诉客户端,我也没有数据要发送了,之后就会中断这次TCP连接。简单地说,前两次挥手用于关闭一个方向的数据通道,后两次挥手用于关闭另外一个方向的数据通道

7.2 如果服务器出现大量CLOSE_WAIT状态如何解决?

服务器端收到客户端发送的连接释放报文FIN后,TCP协议栈就会自动发送ACK,接着进入CLOSE_WAIT状态。但是如果服务器端不执行socket的close()操作,那么就没法进入LAST_ACK,导致大量连接处于CLOSE_WAIT状态,所以如果服务器出现了大量CLOSE_WAIT状态,一般是程序Bug,或者关闭socket不及时,需要去排查程序或者手动执行socket的close()操作;

7.3 TCP四次挥手过程中,为什么需要等待2MSL,才进入CLOSED关闭状态
  1. 为了保证客户端发送的最后一个ACK报文段能够到达服务端。 这个ACK报文段有可能丢失,因而使处在LAST-ACK状态的服务端就收不到对已发送的FIN + ACK报文段的确认。服务端会超时重传这个FIN+ACK 报文段,而客户端就能在 2MSL 时间内(超时 + 1MSL 传输)收到这个重传的 FIN+ACK 报文段。接着客户端重传一次确认,重新启动2MSL计时器。最后,客户端和服务器都正常进入到CLOSED状态。
  2. 防止已失效的连接请求报文段出现在本连接中。客户端在发送完最后一个ACK报文段后,再经过时间2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样就可以使下一个连接中不会出现这种旧的连接请求报文段。

8. 【DNS域名解析原理】

DNS:域名解析系统,是Internet上作为域名和IP相互映射的一个分布式数据库;

DNS解析查找过程:
1.首先会查找浏览器的缓存,看是否能找到域名对应的IP地址,找到就直接返回,否则进行下一步;

2.将请求发给本地DNS服务器,如果查找到也直接返回,否则继续进行下一步;

3.①本地DNS服务器向根域名服务器发送请求,根域名服务器返回负责.com的顶级域名服务器的IP地址的列表;
②本地DNS服务器再向其中一个负责.com的顶级域名服务器发送一个请求,返回负责xxx的权威域名服务器的IP地址列表;
③本地DNS服务器再向其中一个权威域名服务器发送一个请求,返回完整域名所对应的IP地址。

9. 【HTTP和HTTPS协议的区别】

HTTPS:超文本传输安全协议,经由 HTTP 进行通信,但利用 SSL/TLS 来加密数据包

HTTP与HTTPS的区别

  1. HTTP 明文传输,数据都是未加密的,安全性较差,HTTPS(SSL+HTTP) 数据传输过程是加密的,安全性较好。
  2. 使用 HTTPS 协议需要到数字证书认证机构申请证书。
  3. HTTP 页面响应速度比 HTTPS 快,主要是因为 HTTP 使用 TCP 三次握手建立连接,客户端和服务器需要交换 3 个包,而 HTTPS除了 TCP 的三个包,还要加上 ssl 握手需要的 9 个包,所以一共是 12 个包。
  4. HTTP和 HTTPS使用的是完全不同的连接方式,用的端口也不一样,前者是 80,后者是 443。
  5. HTTPS 其实就是建构在 SSL/TLS 之上的 HTTP 协议,所以HTTPS 比 HTTP 要更耗费服务器资源。
9.1 客户端使用HTTPS方式与Web服务器通信步骤

(1)客户使用https的URL请求访问Web服务器,要求与Web服务器建立SSL连接。

(2)Web服务器收到客户端请求后,会将网站的证书信息(证书中包含公钥,私钥由服务器持有)传送一份给客户端。

(3)客户端收到数字证书之后,会验证证书的合法性。如果证书验证通过,就会生成一个随机的对称密钥,用证书的公钥加密,并将公钥加密后的对称密钥传送给Web服务器

(4)Web服务器接收到客户端发来的密文密钥之后,用自己之前保留的私钥对其进行非对称解密,解密之后就得到客户端的密钥,然后用客户端密钥对返回数据进行对称加密,服务器将加密后的密文返回到客户端。

(5)客户端收到后,用对称密钥对其进行对称解密,得到服务器返回的数据。

9.2 什么是数字签名和数字证书?

数字签名:公钥和个人等信息,经过Hash摘要算法加密会形成消息摘要,将消息摘要拿到拥有公信力的认证中心(CA),用它的私钥对消息摘要加密就形成数字签名;
数字证书:指在互联网通讯中标志通讯各方身份信息的一个数字认证,人们可以在网上用它来识别对方的身份,而公钥和个人信息、数字签名共同构成数字证书;

9.3 对称加密和非对称加密的区别

对称加密:指加密和解密使用同一密钥,优点是运算速度较快,缺点是如何安全将密钥传输给另一方。常见的对称加密算法有:DES、AES等。
非对称加密:指的是加密和解密使用不同的密钥(即公钥和私钥)。公钥与私钥是成对存在的,如果用公钥对数据进行加密,只有对应的私钥才能解密。常见的非对称加密算法有RSA。

10. HTTP中GET和POST的区别

GET:对服务器资源的简单获取
POST:用于发送包含用户提交数据的请求

GET和POST具体区别:

  1. 数据报:GET产生一个TCP数据报,POST可能产生两个TCP数据报;
  2. 请求参数:GET把参数包含在URL中,用&连接起来;POST通过响应体传递参数;
  3. 请求缓存:GET会被主动缓存,POST不会缓存除非手动设置

11. 【如何理解HTTP是无状态协议】

当浏览器第一次发送请求给服务器时,服务器响应了;如果同个浏览器发起第二次请求给服务器时,它还是会响应,但是服务器不知道其实就是刚才的那个浏览器。简言之,服务器不会去记住访问的对象,所以是无状态协议,可以使用Cookie进行解决;

11.1 HTTP1.0、1.1与2.0的区别

HTTP1.0:
默认使用短连接,每次请求都需要建立一个TCP连接;

HTTP1.1:

  1. 引入持久连接,即TCP连接默认不关闭,可被多个请求复用;
  2. 分块传输编码。即服务器端每产生一块数据就传送一块,用流模式取代缓存全部数据再传输的模式;
  3. 管道机制,即同一个TCP连接中客户端可同时发送多个请求;

HTTP2.0:

  1. 完全多路复用,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应;
  2. 服务端推送,允许服务器未经请求主动向客户端发送资源;
11.2 HTTP的状态码301和302的区别

301:(永久性转移)请求的网页已被永久移动到新位置。服务器返回此响应时,会自动将请求者转到新位置。

302:(暂时性转移)服务器目前正从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。此代码与响应GET和HEAD请求的301代码类似,会自动将请求者转到不同的位置。

12. forward和redirect的区别?

forward:直接转发方式,客户端和浏览器只发出一次请求,由信息资源响应该请求,在请求对象request中,保存的对象对于每个信息资源是共享的。
redirect:间接转发方式,实际是两次HTTP请求,服务器端在响应第一次请求的时候,让浏览器再向另外一个URL发出请求,从而达到转发的目的。

13. WebSocket与Socket的区别?

WebSocket:是一个持久化的应用层通信协议,它是伴随H5而出的协议,用来解决http不支持持久化连接的问题;
Socket:一个是网编编程的标准接口,等于IP地址 + 端口 + 协议

14. Session和Cookie的区别?

Cookie:是保存在客户端的一小块文本串的数据。客户端向服务器发起请求时,服务端会向客户端发送一个Cookie,客户端就把Cookie保存起来。在客户端下次向同一服务器再发起请求时,Cookie被携带发送到服务器。服务器就是根据这个Cookie来确认身份的。
Session:指的就是服务器和客户端一次会话的过程。Session利用Cookie进行信息处理的,当用户首先进行了请求后,服务端就在用户浏览器上创建了一个Cookie,当这个Session结束时,其实就是意味着这个Cookie就过期了。Session对象存储着特定用户会话所需的属性及配置信息。

14.1 Session和Cookie运行流程
  • 用户第一次请求服务器时,服务器根据用户提交的信息,创建对应的Session,请求返回时将此Session的唯一标识信息SessionID返回给浏览器,浏览器接收到服务器返回的SessionID信息后,会将此信息存入Cookie中,同时Cookie记录此SessionID是属于哪个域名。
  • 当用户第二次访问服务器时,请求会自动判断此域名下是否存在Cookie信息,如果存在,则自动将Cookie信息也发送给服务端,服务端会从Cookie中获取SessionID,再根据 SessionID查找对应的 Session信息,如果没有找到,说明用户没有登录或者登录失效,如果找到Session证明用户已经登录可执行后面操作。

15. 重定向和请求转发的区别?

(1)请求次数:重定向是浏览器向服务器发送一个请求并收到响应后再次向一个新地址发出请求,转发是服务器收到请求后为了完成响应跳转到一个新的地址;重定向至少请求两次,转发请求一次;

(2)地址栏不同:重定向地址栏会发生变化,转发地址栏不会发生变化;

(3)是否共享数据:重定向两次请求不共享数据,转发一次请求共享数据(在request级别使用信息共享,使用重定向必然出错);

(4)跳转限制:重定向可以跳转到任意URL,转发只能跳转本站点资源;

(5)发生行为不同:重定向是客户端行为,转发是服务器端行为。

操作系统

1. Linux里如何查看一个想知道的进程?

查看进程运行状态的指令ps命令。**”ps aux | grep PID”**,用来查看某PID进程状态

2. Linux里如何查看带有关键字的日志文件?

“cat 路径/文件名 | grep 关键词”或者“grep -i 关键词 路径/文件名”

3. Linux修改主机名的命令是什么?

  1. 如果只需要临时更改主机名,可以使用hostname命令。

    1
    sudo hostname <new-hostname> 
  2. 如果想永久改变主机名,可以使用hostnamectl命令

    1
    sudo hostnamectl set-hostname <new-hostname>

4. Linux查看内存的命令是什么?

查看内存使用情况的指令free命令。“free -m”,命令查看内存使用情况。

  • free命令会显示内存的使用情况,包括实体内存,虚拟的交换文件内存,共享内存区段,以及系统核心使用的缓冲区等

查看进程运行状态、查看内存使用情况的指令均可使用top指令

  • top命令会显示当前系统正在执行的进程的相关信息,包括进程 ID、内存占用率、CPU 占用率等

5. Linux中,如何通过端口查进程,如何通过进程查端口?

  1. linux下通过进程名查看其占用端口
1
2
3
4
5
# 先查看进程pid
ps -ef | grep 进程名

# 再通过pid查看占用端口
netstat -nap | grep 进程pid
  1. linux通过端口查看进程
1
netstat -nap | grep 端口号

6. 【Linux服务器怎么部署项目?(以部署Tomcat为例)】

  • tomcat部署(前后端不分离):

1、安装xsheel和xftp。

2、安装环境(jdk,tomcat、数据库)。

3、防火墙配置tomcat端口允许外界访问。

4、测试外界能否通过tomcat访问。

5、本地代码数据库连接修改成linux上面的url连接。

6、代码打包(jar或者war)

7、xftp上传war

8、xsheet重启tomcat服务。

9、调试让系统能够正常访问不报错。

10、完成。

  • ngnix+jre部署(前后端分离):

1、安装xsheel和xftp。

2、安装环境(jdk,tomcat、数据库、ngnix(反向代理等等配置))。

3、防火墙配置ngnix端口允许外界访问。

4、测试外界能否通过ngnix访问。

5、本地代码数据库连接修改成linux上面的url连接。

6、代码打包(前端和后端)

7、xftp上传打包代码、前端放在ngnix里面、后端随便放一个位置。

8、重启ngnix,运行后端java -jar ***.jar

9、调试让系统能够正常访问不报错。

10、完成。

6. 进程与线程的区别?

调度:进程是资源管理的基本单位,一个进程可以有多个线程,线程是程序执行的基本单位。
切换:线程上下文切换比进程上下文切换要快得多。
拥有资源: 进程是拥有资源的一个独立单位,线程不拥有系统资源,但是可以访问隶属于进程的资源。
系统开销: 创建或撤销进程时,系统都要为之分配或回收系统资源,如内存空间,I/O设备等,OS所付出的开销显著大于在创建或撤销线程时的开销,进程切换的开销也远大于线程切换的开销。

7. 协程与线程的区别

7.1 什么是协程?

协程是微线程,在子程序内部执行,可在子程序内部中断,转而执行别的子程序,在适当的时候再返回来接着执行。

7.2 为什么协程比线程切换的开销小?

(1)协程执行效率极高。协程直接操作栈基本没有内核切换的开销,所以上下文的切换非常快,切换开销比线程更小。

(2)协程不需要多线程的锁机制,因为多个协程从属于一个线程,不存在同时写变量冲突,效率比线程高。避免了加锁解锁的开销。

8. 并行和并发的区别

并发就是在一段时间内,多个任务都会被处理;但在某一时刻,只有一个任务在执行。单核处理器可以做到并发。比如有两个进程A和B,A运行一个时间片之后,切换到B,B运行一个时间片之后又切换到A。因为切换速度足够快,所以宏观上表现为在一段时间内能同时运行多个程序。

并行就是在同一时刻,有多个任务在执行。这个需要多核处理器才能完成,在微观上就能同时执行多条指令,不同的程序被放到不同的处理器上运行,这个是物理上的多个进程同时进行。

9. 进程与线程的切换流程

进程切换分两步:

  1. 切换页表以使用新的地址空间,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。

  2. 切换内核栈和硬件上下文。(时间片算法)

线程和进程的最大区别就在于地址空间
对于线程切换,第1步是不需要做的,第2步是进程和线程切换都要做的。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。

9.1 进程切换为什么比线程更消耗资源?(为什么虚拟地址空间切换会比较耗时?)

进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个Cache就是TLB。

由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后TLB就失效了,Cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致TLB失效,因为线程无需切换地址空间,因此我们通常说线程切换要比进程切换快,原因就在这里。

10. 进程间通信方式有哪些及其优缺点

通信方式种类:

  • 管道:管道可以分为两类:匿名管道和命名管道。匿名管道是单向的,只能在有亲缘关系的进程间通信;命名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。

  • 信号: 信号是一种比较复杂的通信方式,信号可以在任何时候发给某一进程,而无需知道该进程的状态。

  • 信号量:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

  • 消息队列:消息队列是消息的链接表,包括Posix消息队列和System V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。

  • 共享内存:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

  • Socket:与其他通信机制不同的是,它可用于不同机器间的进程通信。

各种通信方式的优缺点:

  • 管道:速度慢,容量有限,半双工通信,只在父子进程间使用;
  • Socket:任何进程间都能通讯,但速度慢;
  • 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题;
  • 信号量:不能传递复杂消息,只能用来同步;
  • 共享内存:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存。

11. 进程间互斥同步方式有哪些及其优缺点

  1. 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。
    • 优点:保证在某一时刻只有一个线程能访问数据的简便办法。
    • 缺点:虽然临界区同步速度很快,但却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程。
  2. 互斥量:为协调共同对一个共享资源的单独访问而设计的。互斥量跟临界区很相似,比临界区复杂,互斥对象只有一个,只有拥有互斥对象的线程才具有访问资源的权限。
    • 优点:使用互斥不仅能够在同一应用程序不同线程中实现资源的安全共享,而且可以在不同应用程序的线程之间实现对资源的安全共享。
    • 缺点:互斥量是可以命名的,也就是说它可以跨越进程使用,所以创建互斥量需要的资源更多。
  3. 信号量:为控制一个具有有限数量用户的资源而设计。它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。互斥量是信号量的一种特殊情况,当信号量的最大资源数=1就是互斥量了。
    • 优点:适用于对Socket程序中线程的同步。
    • 缺点:信号量机制必须有公共内存,不能用于分布式操作系统;信号量读写和维护困难,不易控制和管理。
  4. 事件:用来通知线程有一些事件已发生,从而启动后继任务的开始。
    • 优点:事件对象通过通知操作的方式来保持线程的同步,并且可以实现不同进程中的线程同步操作。

12. 线程同步的方式有哪些?

  1. 临界区:当多个线程访问一个独占性共享资源时,可以使用临界区对象。拥有临界区的线程可以访问被保护起来的资源或代码段,其他线程若想访问,则被挂起,直到拥有临界区的线程放弃临界区为止,以此达到用原子方式操 作共享资源的目的。
  2. 事件:事件机制,则允许一个线程在处理完一个任务后,主动唤醒另外一个线程执行任务。
  3. 互斥量:互斥对象和临界区对象非常相似,只是其允许在不同进程间使用,而临界区只限制与同一进程的各个线程之间使用,但是更节省资源,更有效率。
  4. 信号量:当需要一个计数器来限制可以使用某共享资源的线程数目时,可以使用“信号量”对象

13. 线程的分类

从线程的运行空间划分,分为用户级线程和内核级线程:

内核级线程:这类线程依赖于内核,又称为内核支持的线程或轻量级进程。无论是在用户程序中的线程还是系统进程中的线程,它们的创建、撤销和切换都由内核实现。

用户级线程:它仅存在于用户级中,这种线程是不依赖于操作系统核心的。应用进程利用线程库来完成其创建和管理,速度比较快,操作系统内核无法感知用户级线程的存在。

14. 什么是临界区,如何解决冲突?

临界区:每个进程中访问临界资源的那段程序;
临界资源:一次仅允许一个进程使用的资源;

解决冲突的办法
1、如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入,如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待;
2、进入临界区的进程要在有限时间内退出
3、如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等”现象。

15. 什么是死锁?死锁产生的条件是什么?如何处理死锁问题?

死锁概念:在两个或者多个并发进程中,如果每个进程持有某种资源而又等待其它进程释放它或它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组进程产生了死锁。通俗的讲就是两个或多个进程无限期的阻塞、相互等待的一种状态

死锁产生的四个必要条件(有一个条件不成立,则都不会产生死锁)
1、互斥条件:一个资源一次只能被一个进程使用;
2、请求与保持条件:一个进程因请求资源而阻塞时,对已获得资源保持不放;
3、不剥夺条件:进程获得的资源,在未完全使用完之前,不能强行剥夺;
4、循环等待条件:若干进程之间形成一种头尾相接的环形等待资源关系;

如何让处理死锁问题:
1、鸵鸟算法
2、检测死锁并且恢复
3、仔细的对资源进行动态分配以避免死锁
4、破除死锁四个必要条件之一来防止死锁产生

16. 进程调度策略有哪几种?

1、先来先服务:非抢占式的调度算法,按照请求的顺序进行调度。这种调度方式有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。另外,对I/O密集型进程也不利,因为这种进程每次进行I/O操作之后又得重新排队。


2、短作业优先:非抢占式的调度算法,按估计运行时间最短的顺序进行调度。这种调度方式下长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。


3、最短剩余时间优先:最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。


4、时间片轮转:将所有就绪进程按先来先服务的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给递进到队首的进程。


5、优先级调度:为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。

17. 进程有哪些状态以及状态之间如何转换?

进程五种状态:创建、就绪、运行、终止、阻塞;

运行态:进程正在CPU上运行。
就绪态:进程已处于准备运行的状态,即进程获得了除CPU之外的一切所需资源,一旦得到CPU即可运行。
阻塞态:进程正在等待某一事件而暂停运行,比如等待某资源为可用或等待I/O完成。即使CPU空闲,该进程也不能运行。

进程状态切换如下:

运行态→阻塞态:往往是由于等待外设,等待主存等资源分配或等待人工干预而引起的。
阻塞态→就绪态:即等待的条件已满足,只需分配到处理器后就能运行。
运行态→就绪态:不是由于自身原因,而是由于外界原因使运行状态的进程让出处理器,这时候就变成就绪态。例如时间片用完,或有更高优先级的进程来抢占处理器等。
就绪态→运行态:系统按某种策略选中就绪队列中的一个进程占用处理器,此时就变成了运行态。

18. 分页和分段

18.1 什么是分页?

把内存空间划分为大小相等且固定的块,作为主存的基本单位。因为程序数据存储在不同的页面中,而页面又离散的分布在内存中,因此需要一个页表来记录映射关系,以实现从页号到物理块号的映射

访问分页系统中内存数据需要两次的内存访问 :一次是从内存中访问页表,从中找到指定的物理块号,加上页内偏移得到实际物理地址;第二次就是根据第一次得到的物理地址访问内存取出数据。

18.2 什么是分段?

分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)。

分段内存管理当中,地址是二维的,一维是段号,二维是段内地址;其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。由于分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制。

18.3 分页和分段的区别
  1. 分页对程序员是透明的,但是分段需要程序员显式划分每个段。
  2. 分页的地址空间是一维地址空间,分段是二维的。
  3. 页的大小不可变,段的大小可以动态改变。
  4. 分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。

19. 什么是交换空间?

交换空间概念:操作系统把物理内存分成一块一块的小内存,每一块内存被称为页。当内存资源不足时,Linux把某些页的内容转移至硬盘上的一块空间上以释放内存空间。硬盘上的那块空间就叫做交换空间,而这一过程被称为交换。物理内存和交换空间的总容量就是虚拟内存的可用容量。

交换空间用途:

  1. 物理内存不足时一些不常用的页可以被交换出去,腾出内存空间。
  2. 程序启动时很多内存页被用来初始化,之后便不再需要,这些内存页可以被交换出去。

20. 什么是虚拟内存?

虚拟内存:让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。虚拟内存使用部分加载的技术,让一个进程或者资源的某些页面加载进内存,从而能够加载更多的进程,甚至能加载比内存大的进程,这样看起来好像内存变大了,这部分内存其实包含了磁盘或者硬盘,并且就叫做虚拟内存。

21. 什么是IO多路复用?

IO多路复用:是一种同步IO模型,实现一个线程可以监视多个文件句柄; 一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作; 没有文件句柄就绪就会阻塞应用程序,交出CPU,多路是指网络连接,复用指的是同一个线程;

IO多路复用适用如下场合:

1、当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
2、当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
3、如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
4、如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
5、如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
6、与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

22. 中断的处理过程

  1. 保护现场:将当前执行程序的相关数据保存在寄存器中,然后入栈。
  2. 开中断:以便执行中断时能响应较高级别的中断请求。
  3. 中断处理
  4. 关中断:保证恢复现场时不被新中断打扰
  5. 恢复现场:从堆栈中按序取出程序数据,恢复中断前的执行状态。
22.1 中断和轮询的区别?

轮询:CPU对特定设备轮流询问。效率低等待时间长,且CPU利用率不高。

中断:通过特定事件提醒CPU。但容易遗漏问题,CPU利用率不高。

23. 【谈谈你对自旋锁的理解】

自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取,那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采取加锁循环等待的机制被称为自旋锁。

自旋锁有以下特点

  • 用于临界区互斥
  • 在任何时刻最多只能有一个执行单元获得锁
  • 要求持有锁的处理器所占用的时间尽可能短
  • 等待锁的线程进入忙循环

自旋锁存在的问题

  • 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程一直处于循环等待消耗CPU。
  • 无法满足等待时间最长的线程优先获取锁,这种非公平锁就会存在线程饥饿问题。

自旋锁的优点

  • 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  • 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。(线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

自旋锁与互斥锁的区别

  • 自旋锁与互斥锁都是为了实现保护资源共享的机制。
  • 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
  • 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放