面试八股整理

目录

  • 零、JavaEE
    • 0.1 计算机为什么需要十六进制?
    • 0.2 java中只有值传递
    • 0.3 什么是后缀表达式
    • 04 什么是反射,反射的优缺点
    • 05 深拷贝和浅拷贝
    • 06 常用的git命令
    • 07 常用的docker命令
    • 08 常用的linux命令
    • 09 零拷贝是什么
    • 10 跨域
    • 11.java对象内存的分配方式
    • 12.java对象的访问定位
    • 13.内存中的buffer和cache一样么
    • 14.java各种数据类型的长度是多少,默认值是多少
    • 15. 讲一下方法重写和重载的区别
    • 16. 为什么Java中一个类可以实现多个接口,但只能继承一个类?
    • 17. 面向对象设计原则
    • 18. 为什么重写equals之前要重写hashcode
    • 19. java四大权限
    • 20. hash表怎么解决冲突
    • 21.
    • 22.
    • 203.
  • 一、JVM
    • 1.1 调优方案
    • 1.1 加 调优问答
    • 1.2 编译器的语法糖有哪些
    • 1.3 静态变量 什么时候初始化的,为什么静态方法不能用成员变量
    • 1.4常量池中存的内容是什么
    • 1.4 加 实际执行过程中,常量池、方法区等是如何调用的
    • 1.5 GC Roots有哪些对象
    • 1.5 加 如何判断一个类是无用的类
    • 1.5 加 弱引用和虚引用使用的多么
    • 1.6 类加载完成的工作
      • 1.6.1 加载
      • 1.6.2 链接
      • 1.6.3 初始化
      • 1.6.4 类卸载
    • 1.6 加 类加载器讲一讲 ⭐
    • 1.6 加 双亲委派是什么
    • 1.7关于常量池的垃圾回收和类的垃圾回收
    • 1.8 讲一下类会初始化的情况,和不会初始化的情况
    • 1.9 讲一下对象产生的一个全过程
    • 1.10 那你再讲一下如何使用对象
    • 1.11 JVM中新生代为什么要有两个Survivor
    • 1.12 垃圾回收器相关
    • 1.13 JVM内存结构讲一讲
    • 1.14 各种数据存放位置总结
  • 二、JUC
    • 进程和线程的区别
    • 进程的五状态与六状态
    • 2.1上下文切换有哪些情况
    • 2.2 讲一下死锁
    • 2.3 讲一下死锁和活锁的区别
    • 2.4 说说什么是乐观锁,什么是悲观锁
    • 2.5 说说run()和start()区别 execute()和submit()的区别
    • 2.5 加 创建线程三种方式
    • 2.5 加 线程池如何拿到结果
    • 2.6 说说Runnable和Callable的区别
    • 2.7 说说shutdown()和shutdownNow()的区别
    • 2.8 说说isTerminated() 和 isShutdown()的区别
    • 2.加 如何在两个线程间共享数据?
    • 2.9 谈谈线程池
      • 2.9.1 线程池的好处
      • 2.9.2 线程池的参数
      • 2.9.3 线程池的使用顺序
      • 2.9.4 线程池的大小设置
      • 2.9.4 加 线程池参数的动态化怎么做
      • 2.9.4 加 线程池预热怎么做
      • 2.9.5 Executor线程池的种类
      • 2.9.6 线程池的最佳实践
      • 2.9.7 如何优雅地关闭线程池
    • 2.10. 谈谈Excutor框架
    • 2.11 谈谈Future,以及CompletableFuture 好在哪
    • 2.12 聊聊synchronized 和 ReentrantLock 有什么异同?
    • 2.13 ReentrantLock是如何实现的啊
    • 2.14 说说volatile作用
    • 2.14 聊聊volatile与synchronized 的区别
    • 2.15 聊聊threadlocal
    • 2.16 并发编程的三个特性讲一下
    • 2.17 有哪些原子类
    • 2.18 AQS是什么
    • 2.19 对象头是怎么个说法
    • 2.20 关于Monitor ( 管程 ) 了解么
    • 2.21 【wait( ) 和 notify】 【join 和 fork】 【park和unpark】 【sleep】的区别
    • 2.22 什么是保护性暂停
    • 2.23 聊一聊不可变设计的思路
    • 2.24 讲一下协程
    • 2.25 happens-before原则是什么
  • 三、IO
    • 3.1 讲一下IO中的字节流和字符流的区别
    • 3.2 缓冲流包括什么,有什么作用
    • 3.3 IO中涉及到的设计模式有哪些
    • 3.3 加 epoll底层是怎么实现的
    • 3.3 加 epoll为啥要用红黑树加就绪链表的形式
    • 3.4 IO模型有哪几种
    • 3.5 高性能的IO设计模式了解么
    • 3.6 如何能让一个集合的长度和内容不能修改
  • 四、mysql
    • 4.0 讲一下mysql三个范式分别是啥
    • 4.0 加 mysql的组成:
    • 4.1 讲一讲SQL语句在MySQL中的执行过程
    • 4.2 InnoDB和MyISAM的区别
    • 4.2 加 innoDB的三种行锁
    • 4.3 对于使用索引,有建议没
    • 4.4 不同日志文件的区别
    • 4.5 隔离级别对于不同问题的解决情况如何
    • 4.6 讲讲mysql隔离级别是怎么实现的
    • 4.7 为什么不建议用select *
    • 4.8为什么使用B+树
    • 4.9 什么情况下会触发间隙锁(感觉好像不对)
    • 4.10redolog和binlog的区别
    • 4.10 加 binlog日志格式
    • 4.11 Explain结果中各个字段表示什么
    • 4.12存储拆分(分库分表)之后如何解决唯一主键问题
    • 4.13 delete和drop和truncate的区别
    • 4.14 mysql主从同步流程是什么样的
    • 4.15 mysql超大分页怎么处理
    • 丁奇 45讲 金句
  • 五、Redis
    • 5.1 Redis除了做缓存还能做什么
    • 5.2 redis不同数据类型的应用场景都有哪些,底层都是如何
    • 5.3 Redis为什么是单线程
    • 5.4 redis的删除和淘汰机制是怎样的
    • 5.5 讲一下Redis持久化RDB和AOF的区别
    • 5.6 缓存穿透、缓存雪崩、缓存击穿讲讲,还有就是怎么解决
    • 5.7 ⭐⭐⭐如何保证缓存和数据库数据的一致性?⭐⭐⭐(三种读写策略)
    • 5.8 redis集群策略
    • 5.9 讲一下延时双删
    • 5.10 redis的zset怎么翻页
    • 5.11 redis的key怎么存储的
  • 六、操作系统
    • 6.1 加 为什么要设置一个内核态
    • 6.1 加 进程创建的过程是怎样的
    • 6.1 进程的两个状态讲一下
    • 6.2 进程和线程的区别
    • 6.3 进程间通信方式有哪些 ⭐⭐⭐(七种)
    • 6.4 进程调度算法 ⭐⭐⭐
    • 6.5 讲讲操作系统怎么进行内存管理的
    • 6.6 啥是虚拟内存啊,讲讲
    • 6.7 页面置换算法有哪些
    • 6.8 空间换时间的场景,和时间换空间的场景
    • 6.9 哪些情况会中断
  • 七、计算机网络
    • 7.1 四层模型与七层模型说说都有啥,作用是什么
    • 7.2 讲讲TCP和UDP的区别
    • 7.2 TCP、UDP、IP首部
    • 7.3 讲讲三次握手四次挥手
    • 7.4 TCP如何保证传输的可靠性的?
    • 7.4 加 拥塞控制是什么
    • 7.4 加
    • 7.5 可以给我讲一讲从输入URL到页面展示的全过程么
    • 加更:HTTP请求和响应数据的格式如何
    • 7.5 加 怎么解析HTTP请求呢
    • 7.5 加 GET和POST之间的区别
    • 7.6 可以给我列举一下常见的HTTP状态码么
    • 7.7 HTTP和HTTPS有什么区别(重要!)
    • 7.8 HTTP1.0和HTTP1.1有什么区别
    • 7.8 HTTP所有版本区别
    • 7.8 加 HTTP中可以自定义的字段
    • 7.8 加 HTTP传输文件时怎么处理的
    • 7.8 加 重定向和请求转发
    • 7.9 说一下长连接和短连接的区别
    • 7.10 什么是SYN洪范攻击
    • 7.11 说一下token、cookie、session
    • 7.12 DNS过程
    • 7.13 DNS为什么用UDP?
    • 7.14 DNS多级缓存

零、JavaEE

0.1 计算机为什么需要十六进制?

计算机只需要二进制,需要十六进制的是人

每个十六进制中的数字代表4个比特,你可以非常直观的从十六进制中知道对应的二进制是啥,比如给定一个十六进制数,假设其最后一位是9, 那么你立刻就能知道将该十六进制数字转为二进制后最后四位是1001

在十进制中你必须知道所有的进位上的数字后才可以将其转为二进制,这非常不直观,显然如果你想把复杂的十进制数字转为二进制不稍加计算是搞不定的。

因此,十六进制是二进制的好朋友,但十进制不是

关键在于进制数16是2的4次方,2^4 = 16,而进制数10并不是2的整数次幂,因此8进制(23),16进制(2 4),32进制(2^ 5),64进制(2^6)等等都是二进制的好朋友。

有的同学肯定会问,那么为什么我们不使用32进制呢?

使用32进制,每5个比特位可以用一个32进制数字来表示,由于人类的数字系统只有0~9,因此在16进制中10是字母a来表示的、11:b、12:c、13:d、14:e、15:f,但如果我们使用32进制,那么16:g、17:h…31:v,这时给一个32进制数字“apple”,你的大脑可能会一团浆糊,但十六进制对人类来说基本可以应付得来,原因就在于16进制中人类熟悉的数字占据了10个,剩下的只借用了6个字母,还算简单。

因此32进制及以上都不太适合给人使用,原因就在于:
可读性太差。

此外使用十六进制还有一个重要原因:
一个字节有8个比特
我们知道内存是按照字节粒度来寻址的,因此采用的数字系统必须很好的表达一个字节,也就是8比特,从这个角度上看256进制(2^8)是最好的,因为一个256进制就是表达一个字节,但还是基于可读性的原因,256进制对于人类来说记忆负担过重,而16进制则刚刚好,一个16进制数字表示一个字节的一半(4个比特),两个16进制数字正好表示一个字节。

0.2 java中只有值传递

  • 值传递 :方法接收的是实参值的拷贝,会创建副本。
  • 引用传递 :方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。
  • 很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。

看一下下面的例子就知道java的值传递是什么情况了面试八股整理_第1张图片

那么为什么java不引入引用传递呢

  • 出于安全考虑,方法内部对值进行的操作,对于调用者都是未知的(把方法定义为接口,调用方不关心具体实现)。你也想象一下,如果拿着银行卡去取钱,取的是 100,扣的是 200,是不是很可怕。
  • Java 之父 James Gosling 在设计之初就看到了 C、C++ 的许多弊端,所以才想着去设计一门新的语言 Java。在他设计 Java 的时候就遵循了简单易用的原则,摒弃了许多开发者一不留意就会造成问题的“特性”,语言本身的东西少了,开发者要学习的东西也少了。

0.3 什么是后缀表达式

后缀表达式是栈的运用
是一种不需要括号的表达式

四则表达式:9+(3-1)x3+10÷2

后缀表达式:9 3 1 - 3 x + 10 2 / +

中缀表达式就是四则运算表达式

中缀表达式转化为后缀表达式

相信看了前面的规则后,这个你应该也不难理解了

直接上规则:

9+(3-1)x3+10÷2

后缀表达式从左到右遍历,

1,是数字直接写上

2,是任何运算符号

①右括号----) 和优先级 不高于 栈顶元素 则 栈顶元素依次输出 并当前符号进栈(括号直接消失)

04 什么是反射,反射的优缺点

  • 反射能够在程序运行过程中

    • 构造任意一个类对象,并获取任意一个类的成员方法、成员变量和属性,以及调任意一个对象的对象方法
  • java里专门有一个java.lang.reflect的包来实现反射,包括Construct、Field、method,分别获取构造方法、成员变量和方法信息

  • Java反射的优点

    • 增加程序的灵活性,可以在运行的过程中动态对类进行修改和操作
    • 提高代码的复用率,比如动态代理,就是用到了反前来实现
    • 可以在运行时轻松获取任意一个类的方法、属性,并且还能通过反射进行动态调用
  • Java反射的缺点

    • 反前会涉及到动态类型的解析,所WJVM无法对这些代码进行优化,导致性能要比非反射调用更低。
    • 使用反射0后,代码的可读性会下降
    • 反射可以绕过一些限制访问的属性或者方法,可能会导致破坏了代码本身的抽象性

05 深拷贝和浅拷贝

深拷贝和浅拷贝的区别
1.浅拷贝: 将原对象或原数组的引用直接赋给新对象,新数组,新对象/数组只是原对象的一个引用
2.深拷贝: 创建一个新的对象和数组,将原对象的各项属性的“值”(数组的所有元素)拷贝过来,是“值”而不是“引用”

06 常用的git命令

包括工作区、本地仓库和远程仓库
git add 从工作区添加指定文件到暂存区
git commit 将暂存区所有文件添加到本地仓库
git push 将文件添加到远程仓库

git branch [branch-name] 创建分支
git merge 合并本地origin/[branch-name]和HEAD->[branch-name]的代码,并同步到工作空间
git pull 从远程仓库拉取代码到工作空间
面试八股整理_第2张图片

07 常用的docker命令

docker images 查看自己服务器中docker 镜像列表

docker pull 镜像名 拉取镜像 不加tag(版本号) 即拉取docker仓库中 该镜像的最新版本latest 加:tag 则是拉取指定版本

docker run 运行镜像

docker load -i 镜像保存文件位置 加载镜像

08 常用的linux命令

  • pwd 命令 找出您所在的当前工作目录(文件夹)的路径
  • LS 命令用于查看目录的内容
  • cat(连接的缩写)是 Linux 中最常用的命令之一。它用于在标准输出(sdout)上列出文件的内容。
  • mv 命令的主要用途是移动文件,尽管它也可以用于重命名文件。
  • mkdir 命令创建一个新目录
  • touch 命令 新的空白文件
  • locate 命令 您可以使用此命令来定位文件
  • find 命令 在类似定位命令,使用 查找也搜索文件和目录。区别在于,您可以使用 find 命令在给定目录中查找文件。
  • head 命令 所述头命令用于查看任何文本文件的第一行。
  • tail 命令 该命令与 head 命令具有相似的功能,但是 tail 命令将显示文本文件的最后十行,而不是显示第一行。
    • 持续显示文件的新增内容(类似于实时日志监控):tail -n +N filename
    • 显示文件的末尾 N 行:tail -n +N filename
  • diff 命令 diff 命令是差异的缩写,diff 命令逐行比较两个文件的内容。
  • chmod 修改权限

09 零拷贝是什么

零拷贝的好文章

总结:在实际应用开发中,如果我们需要把磁盘中的某个文件发送到远程服务器的话,必须经历几个拷贝的过程
1.从磁盘中读取目标文件的内容,然后拷贝到内核缓冲区
2.CPU控制器把内核缓冲区的数据赋值到用户缓冲区
3.在应用程序中调用write方法把用户缓冲区的数据拷贝到内核下面的socket缓冲区中
4.把内核模式下的socket缓冲区众的数据赋值到网卡的缓冲区
5.网卡缓冲区再把数据传输到目标服务器上
在这个过程中我们可以发现,数据要经历四次拷贝,其中有两次是浪费的,即2、3,而且用户空间和内核空间的切换会带来CPU的上下文切换,对CPU也会造成性能影响。
因此零拷贝就是把两次多余的拷贝省略掉,应用程序就可以直接把磁盘中的数据从内核中传输给socket,而不再需要经过应用程序所在的用户空间
最终方案是零拷贝通过DMA的技术把文件内容复制到内核空间的缓冲区,接着把包含数据位置的长度信息和描述符信息加载到socket缓冲区中,DMA引擎就可以直接把数据从内核空间传递给网卡设备。(在这个过程中数据只经历了两次拷贝就发送到了网卡,也减少了两次CPU上下文切换,对效率有了很大的提升)
需要注意的是:零拷贝不是没有数据赋值,只是减少了不必要的拷贝次数,对用户空间来说不再需要数据拷贝
代码中如何实现:
1.linux底层的sendfile( )方法
2.java中FileChannal.transferTo( )方法底层调用的就是sendfile方法
3.MMAP文件映射机制,原理是将磁盘文件映射到内存,用户通过修改内存就相当于修改了文件,达到数据同步的目的。使用这种方式可以获得很大的I/O提升,从而省去用户空间到内核空间复制的开销
Kakfa的零拷贝采用的是sendfile的方法

详情如下:

使用零拷贝之前

面试八股整理_第3张图片传统IO的读写流程,包括了4次上下文切换(4次用户态和内核态的切换),4次数据拷贝(两次CPU拷贝以及两次的DMA拷贝)

mmap

mmap用了虚拟内存这个特点,它将内核中的读缓冲区与用户空间的缓冲区进行映射,所有的IO都在内核中完成
面试八股整理_第4张图片
mmap+write实现的零拷贝,I/O发生了4次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中,包括了2次DMA拷贝和1次CPU拷贝。

mmap是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,所以节省了一次CPU拷贝‘’并且用户进程内存是虚拟的,只是映射到内核的读缓冲区,可以节省一半的内存空间。

sendfile实现的零拷贝

sendfile表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此可以使用它来实现零拷贝。
面试八股整理_第5张图片
sendfile实现的零拷贝,I/O发生了2次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中,包括了2次DMA拷贝和1次CPU拷贝。那能不能把CPU拷贝的次数减少到0次呢?有的,即带有DMA收集拷贝功能的sendfile!

sendfile+DMA

面试八股整理_第6张图片
sendfile+DMA scatter/gather实现的零拷贝,I/O发生了2次用户空间与内核空间的上下文切换,以及2次数据拷贝。其中2次数据拷贝都是包DMA拷贝。这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输的。

因此常见函数如下

  • sendfile函数(在一些操作系统上):sendfile函数允许将文件内容从一个文件描述符传输到另一个文件描述符,而不需要通过用户空间缓冲区。这在高性能网络编程中非常有用。

  • splice函数(在一些操作系统上):splice函数用于在两个文件描述符之间传输数据,同时可以避免在用户空间中进行数据拷贝。

  • mmap函数:mmap函数允许将文件映射到内存中,从而允许直接访问文件内容而无需将其读入用户空间缓冲区。

零拷贝应用

  • Java当中对零拷贝进行了封装,Mmap方式通过MappedByteBuffer对象进行操作,而transfile通过FileChannel来进行操作。
  • Mmap适合比较小的文件,通常文件大小不要超过1.5G~2G之间。
  • Transfile没有文件大小限制。
  • RocketMQ当中使用Mmap方式来对他的文件进行读写。commitlog。1.G
  • 在kafka当中,他的index日志文件也是通过mmap的方式来读写的。在其他日志文件当中,并没有使用零拷贝的方式。
  • kafka使用transfile方式将硬盘数据加载到网卡。

10 跨域

问:黄老师打扰了,想问您两个问题,一个是关于跨域,之前在html页面上从controller里往页面上调数据,遇到了错误,于是在controller上加了@CrossOrigin注解,发现没问题了,然后我把这个注解去掉后也没问题了,就感觉比较奇怪,然后对跨域这个概念也不是很理解,所以想问一下黄老师跨域一般常用的场景是怎样的,以及我这个问题大概的原因可能是什么
错误如下:Access to XMLHttpRequest at ‘http://localhost:8082/test’ from origin ‘http://localhost:63342’ has been blocked by CORS policy:No ‘Access-Control-Allow-Origin’ header is present on the requested resource

答:跨域问题,跨域问题的本质是一个浏览器的安全防护,涉及到的是前后端的通信机制,比如你访问了一个前端页面,假设暴露在localhost:8080端口上,这个时候你的页面如果调用了localhost:8081的api地址,就会存在跨域问题。

简单来说,就是你浏览器访问的地址,和访问到的页面它自己去请求的后端地址不一致,就存在跨域的问题

这是一个安全问题,你想象一下,比如我抄了个QQ邮箱的前端页面,如果我把这个页面暴露在我自己的服务器上,然后把所有的请求都代理到真正的QQ邮箱后端上去,是不是就是一个典型的钓鱼网站。用户能登录,能收发邮件,所以出于安全考虑,浏览器对于这种跨域的请求做了限制

自己的理解:
应该是因为我前端通过IDE点出来的,走的63342端口,访问后端数据走的自己设置的8082端口,端口不一致,导致了跨域,然后我一开始加注解解决了这个问题,后来我去掉注解,但是前端又从网页自己输的8082端口,不知不觉又可以了,所以才产生了这个疑问。

11.java对象内存的分配方式

1、指针碰撞
假设Java堆中内存是绝对规整的,用过的和空闲的内存各在一边,中间放着一个指针作为分界点的指示器,分配内存就是把那个指针向空闲空间的那边挪动一段与对象大小相等的距离。

2、空闲列表
如果Java堆中的内存不是规整的,虚拟机就需要维护一个列表,记录哪个内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

12.java对象的访问定位

1、直接指针访问
使用直接指针访问的话, reference中存储的直接就是对象地址, 如果只是访问对象本身的话, 就不需要再多一次间接访问的开销

使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访 问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,

2、句柄访问
使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就 是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,

使用句柄来访问的最大好处就是reference中存储的是稳定句柄地 址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference本身不需要被修改。

13.内存中的buffer和cache一样么

总结:不一样。

  • cache是为了解决高速设备和低速设备读写速度之间的速度差距而引入的,主要利用的是局部性原理。
  • buffer的核心是缓冲数据的冲击。把小规模的IO整理成平稳的较大规模的IO,较少磁盘的随机读写次数。比如从往上下载大资料,不可能一次下了一点几兆就写入硬盘,而是积攒到一定的数据量再写入,上传文件也是类似,而buffer的作用就是积攒这个数据。

如果某一个天读取磁盘速度变得也很快,那么cache可能就没有必要存在了,但是buffer却会依然存在。buffer的存在会使得数据变得平稳。cache中的数据读取具有随机性,而buffer中的数据是有顺序的。
比如read cache和read buffer。读缓存的时候,如果缓存中没有,则读取实际数据,然后将数据加入到cache,先进入cache中的数据不一定先被读取。而读buffer不一样,先进入buffer中的数据一定会被先读取出来,具有顺序访问的特点。

14.java各种数据类型的长度是多少,默认值是多少

整数类型:

byte:8位,1字节,默认值为0。
short:16位,2字节,默认值为0。
int:32位,4字节,默认值为0。
long:64位,8字节,默认值为0L。
浮点数类型:

float:32位,4字节,默认值为0.0f。
double:64位,8字节,默认值为0.0。
字符类型:

char:16位Unicode字符,2字节,默认值为空字符 ‘\u0000’。
布尔类型:

boolean:没有固定的字节大小,只有两个值,true和false。
引用类型(包装类型):

Byte:8位,1字节,默认值为0。
Short:16位,2字节,默认值为0。
Integer:32位,4字节,默认值为0。
Long:64位,8字节,默认值为0L。
Float:32位,4字节,默认值为0.0f。
Double:64位,8字节,默认值为0.0。
Character:16位,2字节,默认值为空字符 ‘\u0000’。
Boolean:没有固定的字节大小,只有两个值,true和false。

15. 讲一下方法重写和重载的区别

面试八股整理_第7张图片

16. 为什么Java中一个类可以实现多个接口,但只能继承一个类?

多继承会产生钻石问题(菱形继承)

  • 类 B 和类 C 继承自类 A,且都重写了类 A 中的同一个方法
  • 类 D 同时继承了类 B 和类 C
  • 对于类 B、C 重写的类 A 中的方法,类 D 会继承哪一个?这里就会产生歧义
  • 考虑到这种二义性问题,Java 不支持多重继承

Java 支持类实现多接口

  • 接口中的方法是抽象的,一个类实现可以多个接口
  • 假设这些接口中存在相同方法(方法名与参数相同),在实现接口时,这个方法需要实现类来实现,并不会出现二义性的问题

17. 面向对象设计原则

  • 1、单一职责(Single Responsibility Principle)

    • 定义:一个类只负责一个功能另有中的相应职责----------就一个类而言,应该只有一个引起它变化的原因;
    • 优点:实现高内聚,低耦合;
    • 应用场景:
      • 也就是说一个类里最好是放一种类型的方法;比如:
        DAO:只放操作数据库的方法;
        Util:只放某个工具的方法;
  • 2、开闭原则(Open Close Principle)

    • 定义:当有新的需求的时候,我们不用修改现在的代码,只需要添加新的代码就可以
    • 优点:对拓展开放,对内修改关闭。
  • 3、里氏替换原则(Liskov Substitution Principle)

    • 定义:所有引用基类的地方必须能透明地使用其子类的对象,也就是说子类对象可以替换其父类对象,而程序执行效果不变。
    • 里氏替换原则是使代码符合开闭原则的一个重要保证;
    • 优点:
      • 约束继承泛滥,是开闭原则的一种体现;
      • 加强程序的健壮性,同时变更时也可以做到非常好的提高程序的维护性、扩展性。降低需求变更时引入的风险;
    • 要求:
      • ○ 子类可以扩展父类的功能,但不能改变父类原有的功能

      • ○ 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法;

      • ○ 子类可以增加自己特有的方法;

      • ○ 当子类单独的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松;

      • ○ 当子类的方法实现父类的方法时(重载/重写或实现抽象方法)的后置条件(即方法的输出/返回值)要比父类更严格或者相等;

  • 4、接口隔离原则(Interface Segregation Principle)

    • 定义:不能强迫用户去依赖那些他们不使用的接口。即:使用多个专门的接口会比使用单一的总接口好;
    • 优点:避免同一个接口里面包含不同类职责的方法,接口责任划分更加明确,符合高内聚低耦合的思想。
    • 接口分隔原则:
      • 1、一个类对一个类的依赖应该建立在最小的接口上
      • 2、建立单一接口,不要建立庞大臃肿的接口
      • 3、尽量细化接口,接口中的方法尽量少
  • 5、依赖倒置原则(Dependency Inversion Principle)

    • 定义:抽象不应该不依赖于细节,细节应该依赖于抽象;即:针对接口编程,而不是针对实现编程。
    • 优点:可以减少类间的耦合性、提高系统的稳定性,提高代码可读性和可维护性,可降低修改程序所造成的的风险。
    • 要求:
      • 依赖抽象,而不是依赖实现。
      • 抽象不应该依赖细节;细节应该依赖抽象。
      • 高层模块不能依赖低层模块,二者都应该依赖抽象。
  • 6、迪米特原则(Law of Demeter 又名Least Knowledge Principle)

    • 定义:一个对象应该对尽可能少的对象有接触,也就是只接触那些真正需要接触的对象。
    • 优点:降低类与类之间的耦合

18. 为什么重写equals之前要重写hashcode

首先说一下为什么equals需要和hashcode进行配套

先说结论:为了减少equals的比较次数

所有对于需要大量并且快速的对比的话如果都用equals去做显然效率太低
但是hashcode存在一个问题,hashcode相同,不代表equals相等,hashcode不同,可以代表肯定不同,所以它不一定对,因此每次先用hashcode对比,如果一样,再去比较equals,减少次数

然后再说说为什么必须要一起重写

equals方法与hashCode方法根本就是配套使用的。对于任何一个对象,不论是使用继承自Object的equals方法还是重写equals方法。hashCode方法实际上必须要完成的一件事情就是,为该equals方法认定为相同的对象返回相同的哈希值。如果只重写equals方法没有重写hashCode方法,就会导致``equals`认定相同的对象却拥有不同的哈希值。

Object类中的equals方法区分两个对象的做法是比较地址值,即使用==。如果根据业务需求改写了equals方法的实现,那么也应当同时改写hashCode方法的实现。否则hashCode方法依然返回的是依据Object类中的依据地址值得到的integer哈希值。

19. java四大权限

面试八股整理_第8张图片

20. hash表怎么解决冲突

开放地址法(Open Addressing):

  • 线性探测(Linear Probing):如果发生冲突,就在散列表中线性地查找下一个可用的槽,直到找到一个空槽来存储键值对。
  • 二次探测(Quadratic Probing):类似于线性探测,但步长逐渐增加,以避免一系列的冲突在同一位置发生。
  • 双重散列(Double Hashing):使用第二个散列函数来计算下一个位置,以解决冲突。

链地址法(Chaining):

  • 每个散列桶(哈希表的槽)维护一个链表,所有散列到同一位置的键值对都存储在该链表中。当发生冲突时,只需将新键值对附加到链表的末尾。
  • 链地址法是解决冲突最常见的方法之一,适用于大多数情况,特别是当哈希表大小可调整时。

再散列(Rehashing):

  • 当哈希表中的负载因子(即键值对数量与哈希表大小的比率)达到一定阈值时,可以选择对哈希表进行再散列。重新创建一个更大的哈希表,将所有键值对从旧表重新散列到新表中。
  • 再散列可以有效地减少冲突,但需要重新分配内存并重新计算散列函数。

建立更好的哈希函数:

使用更好的散列函数可以减少冲突的发生。一个好的散列函数应该将键均匀地分布在哈希表中,减少冲突的概率。

21.

22.

203.

一、JVM

1.1 调优方案

新生代调优:
1.把新生代调大
2.幸存区较大:如果比较小,一些对象就可能提前晋升到老年代,就导致那些对象得等到老年代full gc再垃圾回收
3.晋升阈值配置得当,让长时间存活对象尽快晋升:新生代复制算法主要时间就在复制上,如果不能即使晋升,则会一直被复制来复制去

老年代调优:
观察full gc时老年代内存占用,将老年代内存预设调大1/4~1/3
老年代占堆的多少,开始进行老年代的CMS垃圾回收,一般是75%

几种常见情况:
案例1:Minor GC和Full GC频繁
解决:一般是新生代太小,导致一直满,然后新生代阈值很低就得晋升,导致老年代也满的很快。扩大新生代内存,提高晋升阈值

案例2:请求高峰期发生full gc 单次暂停时间特别长(CMS)
解决:初始标记和并发标记一般都很快,重新标记比较慢,重新标记需要扫描整个堆内存,新生代如果很多,还需要找root,就很慢。因此重新标记之前,应该先清理一次新生代。

案例3 老年代充裕情况下,发生full gc(CMS 1.7)
解决:1.7是永久代,永久代的空间不足,也会full gc。可以尝试1.8

设置参数如下:

1.调整最大堆内存和最小堆内存

  • -Xmx –Xms:指定 java 堆最大值和初始 java 堆最小值
  • 默认空余堆内存小于 40%时,JVM 就会增大堆直到-Xmx 的最大限制.,默认空余堆内存大于 70%时,JVM 会减少堆直到 -Xms 的最小限制。
    • 开发过程中,通常会将 -Xms 与 -Xmx 两个参数配置成相同的值,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。

2.调整新生代和老年代的比值

  • -XX:NewRatio — 新生代(eden+2*Survivor)和老年代(不包含永久区)的比值
  • 例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的 1/5。
  • 在 Xms=Xmx 并且设置了 Xmn 的情况下,该参数不需要进行设置。

3.调整 Survivor 区和 Eden 区的比值

  • -XX:SurvivorRatio(幸存代)— 设置两个 Survivor 区和 eden 的比值
  • 例如:8,表示两个 Survivor:eden=2:8,即一个 Survivor 占年轻代的 1/10

4.设置年轻代和老年代的大小

  • -XX:NewSize — 设置年轻代大小
  • -XX:MaxNewSize — 设置年轻代最大值

5.JVM 的栈参数调优

  • 调整每个线程栈空间的大小,通过-Xss:调整每个线程栈空间的大小
  • 设置线程栈的大小,-XXThreadStackSize:

1.1 加 调优问答

  • 候选者:一般调优JVM我们认为会有几种指标可以参考:『吞吐量』、『停顿时间』和『垃圾回收频率』
  • 候选者:基于这些指标,我们就有可能需要调整:
  • 候选者:1. 内存区域大小以及相关策略(比如整块堆内存占多少、新生代占多少、老年代占多少、Survivor占多少、晋升老年代的条件等等)
  • 候选者:比如(-Xmx:设置堆的最大值、-Xms:设置堆的初始值、-Xmn:表示年轻代的大小、-XX:SurvivorRatio:伊甸区和幸存区的比例等等
  • 候选者:(按经验来说:IO密集型的可以稍微把「年轻代」空间加大些,因为大多数对象都是在年轻代就会灭亡。内存计算密集型的可以稍微把「老年代」空间加大些,对象存活时间会更长些)
  • 候选者:2. 垃圾回收器(选择合适的垃圾回收器,以及各个垃圾回收器的各种调优参数)
  • 候选者:比如(-XX:+UseG1GC:指定 JVM 使用的垃圾回收器为 G1、-XX:MaxGCPauseMillis:设置目标停顿时间、-XX:InitiatingHeapOccupancyPercent:当整个堆内存使用达到一定比例,全局并发标记阶段 就会被启动等等)
  • 候选者:没错,这些都是因地制宜,具体问题具体分析(前提是得懂JVM的各种基础知识,基础知识都不懂,谈何调优)

1.2 编译器的语法糖有哪些

自动无参构造

自动拆装箱

泛型集合取值 泛型擦除

可变参数

foreach循环

switch字符串:先比hashcode再逼equals 原因:比hashcode 要比 比字符串 快得多,但hashCode可能有冲突

try-with-resources

匿名内部类

1.3 静态变量 什么时候初始化的,为什么静态方法不能用成员变量

静态的是在类加载阶段进行内存分配和初始化的,具体是在链接的准备阶段

类初始化顺序:
静态变量、静态代码块初始化
构造函数
自定义构造函数

不能用成员变量的原因:static成员是在JVM的CLASSLOADER加载类的时候初始化的,而非static的成员是在创建对象,即new 操作的时候才初始化的;类加载的时候初始化static的成员,此时static 已经分配内存空间,所以可以访问;非static的成员还没有通过new创建对象而进行初始化,所以必然不可以访问。

1.4常量池中存的内容是什么

字面量和符号引用(符号引用自不必说,用来把符号地址变为真实地址)

解释:创建一个对象会用到new关键字,但是给一个基本数据类型变量赋值是不需要new关键字滴,基本类型的变量在java中是一种特别的内置数据类型,并非某个对象
定义:给基本类型变量赋值的方式就叫做字面量或者字面值

1.4 加 实际执行过程中,常量池、方法区等是如何调用的

  • 类加载过程: 在类加载过程中,类的字节码被加载到方法区。同时,类文件中的常量池信息也被解析并存储到运行时常量池,包括类名、方法名、字段名等的符号引用。

  • 方法调用准备阶段: 当程序中有方法调用发生时,Java虚拟机需要解析该方法的符号引用。这个过程发生在方法调用的准备阶段,通常在方法被实际执行之前。虚拟机会检查运行时常量池,找到被调用方法的符号引用。

  • 动态链接: 在方法调用准备阶段,虚拟机会将方法的符号引用转换为直接引用,这个过程就是动态链接。这个过程涉及到从运行时常量池中获取类的名字、方法名和描述符等信息,然后在方法区中查找该类的具体方法的信息(方法表)。

  • 方法执行: 一旦方法的符号引用被解析并转换为直接引用,虚拟机就可以定位到方法区中的具体方法字节码,然后执行该方法。

总结一下,方法调用涉及到以下关键步骤:

  • 类加载过程将常量池中的信息存储到运行时常量池。
  • 方法调用的准备阶段中,虚拟机从运行时常量池获取方法的符号引用。
  • 动态链接将方法的符号引用转换为方法区中的直接引用。
    方法执行阶段执行方法字节码。
  • 1.5 GC Roots有哪些对象

虚拟机栈(栈帧中的本地变量表)中引用的对象
本地方法栈(Native 方法)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
所有被同步锁持有的对象

1.5 加 如何判断一个类是无用的类

需要满足三个条件

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

1.5 加 弱引用和虚引用使用的多么

在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

1.6 类加载完成的工作

关于类加载的究极好文章

总结如下

  • 加载
    • 通过全类名获取定义此类的二进制字节流。
    • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(Java类模型)。
    • 在内存中生成一个代表该类的 Class 对象(类模板对象),作为方法区这些数据的访问入口。
  • 连接
    • 验证:这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
    • 准备:准备阶段为类的静态变量分配内存,并将其初始化为默认值
    • 解析:解析阶段是虚拟机将常量池内的类、接口、字段和方法的符号引用转为直接引用
  • 初始化:这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
    • 简言之,为类的静态变量赋予正确的初始值。
    • 初始化阶段是执行初始化方法 ()方法的过程,是类加载的最后一步

详细如下(可了解):

1.6.1 加载

加载这一步主要是通过我们后面要讲到的 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)

主要完成工作

  • 通过全类名获取定义此类的二进制字节流。
  • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(Java类模型)。
  • 在内存中生成一个代表该类的 Class 对象(类模板对象),作为方法区这些数据的访问入口。
    • 所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用
    • 反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射

这里需要注意区分类模型和类模板对象

  • 类模型存储在方法区
  • class实例/类模板对象
    • 类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。

面试八股整理_第9张图片

注意,有特例:数组类不用加载
数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建

1.6.2 链接

一、验证

  • 主要工作:这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

二、准备

  • 主要工作:准备阶段为类的静态变量分配内存,并将其初始化为默认值
  • 特殊情况
    • public static final int value=111 ,那么准备阶段 value 的值就不是0,而是被赋值为 111,这是因为final在编译的时候就会分配了,准备阶段会显式赋值
    • 实例变量是对象”初始化“时赋值
  • 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。

三、解析

  • 主要工作:解析阶段是虚拟机将常量池内的类、接口、字段和方法的符号引用转为直接引用
  • 这里注意和动态链接的区别
    • 作用相似性: 解析阶段和动态链接的共同点在于,它们都涉及将符号引用转换为直接引用,以便能够准确地定位方法或字段等的位置,从而实现方法调用和字段访问。
    • 执行时机不同: 主要的区别在于执行时机。解析阶段在类加载过程的末尾进行,而动态链接是在方法调用的准备阶段执行。解析是类加载的一部分,而动态链接是方法调用的一部分。

1.6.3 初始化

  • 主要工作:初始化阶段是执行初始化方法 < clinit> ()方法的过程,是类加载的最后一步
    • < clinit>()方法是编译之后自动生成的
      • 该方法仅能由Java编译器生成并由JVM调用
      • 它是由类静态成员的赋值语句以及static语句块合并产生的
    • < clinit>是线程安全的
      • 如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待
  • 简言之,为类的静态变量赋予正确的初始值。

关于什么时候会初始化,什么时候不会初始化,参考1.8

1.6.4 类卸载

卸载类即该类的 Class 对象被 GC。
满足三个要求,上面也提过

  • 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  • 该类没有在其他任何地方被引用
  • 该类的类加载器的实例已被 GC

所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

1.6 加 类加载器讲一讲 ⭐

类加载器的作用

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
  • 每个 Java 类都有一个引用指向加载它的 ClassLoader。
  • 类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象),并将这些内容转换成方法区中的运行时数据结构

什么类不需要类加载器

  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

加载规则

  • JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载,也就是用到再加载,这样对内存更好
  • 已经加载的类会被放在 ClassLoader 中,对于一个类加载器来说,相同二进制名称的类只会被加载一次

类加载器分类

  • BootstrapClassLoader(启动类加载器)
    • %JAVA_HOME%/lib目录下
    • 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的
      • 因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的
  • ExtensionClassLoader(扩展类加载器)
    • %JRE_HOME%/lib/ext
  • AppClassLoader(应用程序类加载器)
    • 加载当前应用 classpath 下的所有 jar 包和类
  • 自定义类加载器
    • 关键方法
      • loadClass
        • 加载指定二进制名称的类,实现了双亲委派机制
      • findClass
        • 根据类的二进制名称来查找类
    • 关于是否打破双亲委派
      • 如果想打破双亲委派模型则需要重写 loadClass() 方法
      • 如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载

关于是否是同一个类

  • 1.类全名是否一样
  • 2.类加载器是否一样

1.6 加 双亲委派是什么

双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式

首先要明白,双亲委派不是继承

  • 类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码
  • 在面向对象编程中,有一条非常经典的设计原则: 组合优于继承,多用组合少用继承

流程:

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

好处

  • 避免类的重复加载
  • 保证了 Java 的核心 API 不被篡改
    • 保证了 Java 程序的稳定运行
    • 比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

打破双亲委派的方法,如tomcat

  • 1.自定义加载器的话,需要继承 ClassLoader
  • 2.如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法
  • 3.如果想打破双亲委派模型则需要重写 loadClass() 方法

1.7关于常量池的垃圾回收和类的垃圾回收

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型
因此HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了,主要有三个条件
1 该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
2 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
3 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

1.8 讲一下类会初始化的情况,和不会初始化的情况

必须初始化的情况,是在类被主动使用时,包括五种情况
1.当遇到new、getstatic、pubstatic、invokestatic时
2.进行反射时,如果没有初始化需要初始化
3.初始化一个类时,如果父类还没初始化,则要初始化
4.虚拟机启动时,用户定义的执行的主类(包括main的),先初始化这个
5.MethodHandle反射调用机制(这个先不记了)

在另一个文章里,也有说是下面五种情况的,结合理解吧
1 T 是一个类,而且一个 T 类型的实例被创建;
2 T 是一个类,且 T 中声明的一个静态方法被调用;
3 T 中声明的一个静态字段被赋值;
4 T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段;
5 T 是一个顶级类(top level class,见 java 语言规范的§7.6),而且一个断言语句嵌套在 T 内部被执行。

不会初始化的情况包括两种
一、字节码不会产生< clinit>方法的
主要有三种情况
1.类中没有声明任何的类变量,也没有静态代码块
2.类中声明了变量,但没有任何使用类变量的初始化语句,或者静态代码块
3.类中包含static final基本数据类型的字段,这些字段在编译的时候就会分配了,准备阶段会显式赋值

二、被动使用的类(不会调用,不代表没有
1.当通过子类引用父类的静态变量,不会导致子类初始化,只会父类初始化。因为当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。
2.通过数组定义类引用,不会触发此类的初始化
3.引用常量(也就是final)
4.调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化

1.9 讲一下对象产生的一个全过程

  • Step1:类加载检查
  • Step2:分配内存
    • 对应两种方式
      • 指针碰撞 : 对应垃圾回收的标记整理和复制算法
      • 空闲列表 : 对应垃圾回收的标记清除算法
  • Step3:初始化零值
    • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)
  • Step4:设置对象头
    • 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  • Step5:执行 init 方法
    • 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。

对象头详情见2.19

1.10 那你再讲一下如何使用对象

Java 程序通过栈上的 reference动态链接 数据来操作堆上的具体对象

因此可以通过几种方式

  • 句柄
    • 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

面试八股整理_第10张图片

  • 直接指针
    • 如果使用直接指针访问,reference 中存储的直接就是对象的地址。

面试八股整理_第11张图片

  • 两种方式各有优势,一个稳定,一个速度快

    • 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,
    • 而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

1.11 JVM中新生代为什么要有两个Survivor

如果只有一个Survivor区,第一次YGC时Eden区的可以进入Survivor区,那下一次YGC时呢,由于Survivor已满,那么其实本次YGC进入Survivor区的对象必须在下次YGC时进入年老代或被回收,这样YGC的效率就不高了,这肯定都不是我们希望的。

而两个Survivor的话就不一样了,第一次YGC时Eden区和Survivor 0的可以进入Survivor 1区,然后清理掉Eden和刚才用过的Survivor 0,接下来把Survivor 0 留作下次GC时,Eden和Survivor1存活下来的对象的存放地方,也即是说两个Survivor区来回交替使用。

从设计的角度上来说,两个survivor区域会比一个survivor区域效率要高。

假设现在只有一个survivor区域,那么当触发minor gc的时候,eden区域的存活对象进入survivor区域,但survivor区域本身可能也有需要回收的对象(这是很有可能的),这时该怎么办呢?

1.12 垃圾回收器相关

笔记

1.13 JVM内存结构讲一讲

面试八股整理_第12张图片
面试八股整理_第13张图片

  • 一、程序计数器(线程私有)

    • 特点
      • 是线程私有的,某个线程被阻塞,再回来时,还能记住该从哪里继续
      • 不会存在内存溢出(主要就存一个地址,也不可能溢出。。)
    • 物理上本质是寄存器,记住下一条jvm指令的执行地址
  • 二、虚拟机栈(线程私有)

    • 组成
      • 栈帧:每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
      • 活动栈帧:对应当前正在执行的方法
      • 栈帧的死亡:Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
      • 栈帧的组成:
        • 局部变量表:是变量槽,主要存储方法的参数,所有的基本类型数据和对象地址,以及返回地址类型
        • 返回地址:要返回下一条要执行代码位置的值,也就是程序计数器的值
        • 动态链接:主要服务一个方法需要调用其他方法的场景
          • 栈帧中保存了一个方法的引用,当执行方法的时候,可以拿着这个引用到运行时常量池中找到这个方法
          • 动态链接的作用就是将这些方法的符号引用转换为调用方法的直接引用
        • 操作数栈:保存计算过程的中间结果
        • 锁记录 :对象指针,锁住的对象的地址,记录对象的mark word
    • 垃圾回收是否需要虚拟机栈的内存?不需要,因为用完的就弹出了
    • 栈内存的分配越大越好么?划分的大了,线程数目就少了
    • 线程安全问题:方法内的局部变量是否线程安全(其实就是变量是线程私有还是共享)
      • 如果方法内局部变量没有逃离方法的作用范围,则安全
      • 如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全
    • 常见问题:栈内存溢出 StackOverflowError
      • 1.栈帧过多,原因可能是不停地调用,比如递归
      • 2.栈帧过大,很少出现
    • 线程运行诊断原因
      • 1.cpu占用过多,解决方案:
        • 1.用top定位哪个进程对cpu占用过高
        • 2.ps H -eo pid,tid,%cpu |grep 进程id 用ps命令进一步定位哪个线程引起的cpu占用过高
        • 3.jstack 进程id 可以根据线程id找到有问题的线程,并定位到问题代码的源码行数
      • 2.程序运行很长时间没有结果,原因一般是死锁
  • 三、堆(线程共享)

    • 通过new关键字,创建对象都会使用堆内存。唯一的目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存

      • 为什么说是”几乎“呢,因为存在逃逸:
      • Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
    • 特点

      • 线程共享
      • 有垃圾回收机制
    • 堆内存溢出OutOfMemoryError,问题是有垃圾回收,怎么还会溢出呢

      • 因为一直使用着的堆,不会垃圾回收,加着加着就溢出了
    • 堆内存诊断工具

      • jps:查看当前系统有哪些java进程
      • jmap:查看堆内存占用情况
      • jconsole:图形界面的,多功能的监测工具,可以连续监测
    • 举例说明:堆和栈帧的区别:

      • List list1 = new ArrayList<>;
      • list1是局部变量,在栈帧中
      • new ArrayList是被引用的对象,在堆中
  • 四、本地方法栈(线程私有)

    • 即本地方法运行时所用的内存
    • 不是由java代码编写的方法
    • 和操作系统底层打交道
    • 一般是C/C++
  • 五、方法区:逻辑上是堆的一部分(线程共享)

    • 运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。
    • 类加载之后的字节码 放在方法区。存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
    • 是可以被垃圾收集器进行回收的,主要针对类型的卸载和常量池的回收
    • 实现
      • 1.6的实现:永久代,占堆内存
      • 1.8的实现 元空间,占本地内存,但StringTable在堆里
      • 为什么替换永久代
        • 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
        • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
        • 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
    • 组成
      • 元空间:
        • 包括常量池:
          • 存放编译期生成的各种字面量(Literal)和符号引用
          • 常量池表会在类加载后存放到方法区的运行时常量池中。
          • 运行常量池在元空间
          • 常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
          • 就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
          • 常量池主要存储的是「字面量」以及「符号引用」等信息
      • 字符串常量池(在堆里):
        • JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
        • StringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。
  • 六、直接内存(线程共享)

    • 不属于jvm内存管理,而是属于系统内存
    • 常见于NIO操作时,用于数据缓冲区
    • 分配回收成本较高,但读写性能高
    • 不受JVM内存回收管理

1.14 各种数据存放位置总结

  • 基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中
  • 基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中
  • 包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中
    • 为什么有个几乎
      • HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存
  • 对象引用存放在栈内存中
  • 找类的信息,去方法区
  • 运行方法,去栈
  • 找方法,去方法区类别信息上的方法表

二、JUC

进程和线程的区别

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位

  • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

  • 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

  • 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的

进程的五状态与六状态

对于操作系统来说,有五个状态

  • 创建状态(new) :进程正在被创建,尚未到就绪状态。
  • 就绪状态(ready) :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
  • 运行状态(running) :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
  • 阻塞状态(waiting) :又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
  • 结束状态(terminated) :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。

面试八股整理_第14张图片

running 状态被 interrupt 向 ready 状态转换的箭头方向反了。

JUC中有六个状态

  1. NEW
    • 尚未启动的线程的线程状态。就是已经创建了的Thread但未调用start方法启动
  2. RUNNABLE
    • 可以将"RUNNABLE"状态理解为线程要么正在执行,要么处于就绪状态。在就绪状态下,线程已经准备好执行,但是等待CPU分配时间片来运行。当线程获得了CPU时间片并开始执行时,它会从就绪状态切换到正在执行状态。在这两种状态下,线程都可以执行其任务。

    • 在"RUNNABLE"状态下,线程通常表示它已经准备好执行,并且可以在任何时刻被调度为运行,只要CPU资源可用。这与阻塞状态和等待状态不同,这些状态表示线程暂时无法继续执行,因为它们需要等待某些条件的满足,比如等待锁或等待某个事件发生。而"RUNNABLE"状态下的线程可以立即执行或等待CPU时间片的分配。

  3. BLOCKED
    • 处于锁竞争状态中未获取到锁的阻塞的状态
  4. WAITING
    • 处于等待状态的线程正在等待另一个线程执行特定的操作
    • 从runnable变成waiting的方法
      • wait( )
      • join( )
      • park( )
  5. TIMED_WAITING
    • 具有指定等待时间的等待线程的线程状态
  6. TERMINATED
    • 运行完了的线程

面试八股整理_第15张图片
状态的切换过程
面试八股整理_第16张图片

为什么就绪和运行都算runnable状态

现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。

2.1上下文切换有哪些情况

主要有三种

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。

2.2 讲一下死锁

首先是死锁产生的必要条件:护球不带

  • 互斥条件:该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

然后是如何预防:

  • 破坏请求与保持条件 :一次性申请所有的资源。
  • 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

然后是如何避免

  • 在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态

死锁分为 预防、避免、检测(资源分配图)、解除,详情见”操作系统Xmind“

2.3 讲一下死锁和活锁的区别

  • 死锁是因为线程互相持有对象想要的锁,并且都不释放,最后到时线程阻塞,停止运行的现象。

  • 活锁是因为线程间修改了对方的结束条件,而导致代码一直在运行,却一直运行不完的现象。

避免活锁的方法:在线程执行时,中途给予不同的间隔时间即可。

2.4 说说什么是乐观锁,什么是悲观锁

  • 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。
  • 也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
  • synchronized和ReentrantLock等独占锁就是悲观锁思想的实现

  • 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了。
  • 乐观锁通常多于写比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。
  • 乐观锁一般会使用版本号机制或 CAS 算法实现
  • 乐观锁也存在问题:
    • 1.ABA

2.5 说说run()和start()区别 execute()和submit()的区别

run()和start()区别:
1、在一个线程中执行run()方法,不会生成新的线程,而是将run()当成一个方法直接执行
2、start()会生成一个新的线程去执行run()方法。
3、run()是普通方法,所以可以重复执行
4、start()不能重复执行

execute()和submit()的区别:
1、二者都是线程池的执行方法
2、execute()只能执行Runnable类型方法,无返回值
3、submit可以执行Runnable类型和Callable类型方法,可以得到返回值,其中,Runnable类型方法返回值为null

2.5 加 创建线程三种方式

thread
runnable
callable
线程池

2.5 加 线程池如何拿到结果

通过submit方法提交一个任务给线程池,它返回一个Future对象,然后您可以使用get方法来等待任务完成并获取结果。

2.6 说说Runnable和Callable的区别

  • Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例
  • Runnable 接口不会返回结果或抛出检查异常,但是 Callable 接口可以
  • 如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。

2.7 说说shutdown()和shutdownNow()的区别

  • shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了
    但是队列里的任务得执行完毕
  • shutdownNow() :关闭线程池,线程的状态变为 STOP
    线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List

2.8 说说isTerminated() 和 isShutdown()的区别

  • isShutDown 当调用 shutdown() 方法后返回为 true
  • isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true

2.加 如何在两个线程间共享数据?

如果一个类继承Thread,则不适合资源共享。但是如果实现了Runable接口的话,则很容易的实现资源共享。实现Runnable接口或callable接口,适合多个相同或不同的程序代码的线程去共享同一个资源。

1.如果线程执行的代码相同,多个线程共享同一个runnable对象时,将共享数据放在runnable对象

2.如果多个线程执行不同的Runnable实现类中的代码,此时共享数据和操作共享数据的方法封装到一个对象中,在不同的Runnable实现类中调用操作共享数据的方法。

3.如果多个线程执行的代码不同,将共享数据作为外部类的final成员变量,将不同的runnable对象作为内部类主动取数据

4.将数据声明为static的方式()

如果一个类继承Thread,则不适合资源共享。并不是不可以实现资源共享

实现Runnable接口比继承Thread类所具有的优势:

  1. 适合多个相同的程序代码的线程去共享同一个资源。

  2. 可以避免java中的单继承的局限性。

  3. 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。

  4. 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类。

2.9 谈谈线程池

2.9.1 线程池的好处

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

2.9.2 线程池的参数

  • corePoolSize 核心线程数
  • maximumPoolSize 最大线程数
  • workQueue 工作队列:不同的线程池会选用不同的阻塞队列
    • 一、无界队列LinkedBlockingQueue
      • 没有救急线程
      • 适用于FixedThreadPool 和 SingleThreadExector
    • 二、同步队列SynchronousQueue
      • 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务
      • 线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM
      • 适用于CachedThreadPool
      • 使用SynchronousQueue队列,提交的任务不会被保存,总是会马上提交执行。如果用于执行任务的线程数量小于maximumPoolSize,则尝试创建新的进程,如果达到maximumPoolSize设置的最大值,则根据你设置的handler执行拒绝策略。因此这种方式你提交的任务不会被缓存起来,而是会被马上执行,在这种情况下,你需要对你程序的并发量有个准确的评估,才能设置合适的maximumPoolSize数量,否则很容易就会执行拒绝策略;
    • 三、延迟阻塞队列DelayedWorkQueue
      • 并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序
      • DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞
      • 适用于ScheduledThreadPool 和 SingleThreadScheduledExecutor
    • 四、有界阻塞队列ArrayBlockingQueue
    • 五、优先任务队列PriorityBlockingQueue
      • PriorityBlockingQueue它其实是一个特殊的无界队列,它其中无论添加了多少个任务,线程池创建的线程数也不会超过corePoolSize的数量,只不过其他队列一般是按照先进先出的规则处理任务,而PriorityBlockingQueue队列可以自定义规则根据任务的优先级顺序先后执行。
  • 存活时间
    • 线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • 时间单位
  • 线程工厂:用来创建线程
  • 拒绝策略
    • ① AbortPolicy抛出异常拒绝新任务
    • ② CallerRunsPolicy执行自己线程运行任务
      • 也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。
      • 如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
    • ③ DiscardPolicy不处理新任务,直接丢弃掉
    • ④ DiscardOldestPolicy将丢弃最早的未处理的任务请求

这里可以细说说四种拒绝策略的应用场景

  • CallerRunsPolicy经分析触发时会阻塞主线程,所以像咱们插入一些操作日志对吧,咱们就可以采用该策略,因为插入就算阻塞主线程,对应用也不会有太大影响。

  • DiscardOldestPolicy我们就可以用在报表导出,像咱们导出时不想阻塞线程,避免前端等待过久,咱们都会采用异步的方式(文件导出后写入到表中,业务可以后续在页面上查看)去导出对吧,那么就可以采用该策略,因为就算丢弃了咱们可以重新触发嘛。

  • AbortPolicy(线程池默认的拒绝策略)一触发就报错,一般应用于一些对数据处理非常敏感,例如对一些数据落盘时,数据插入可能有多个逻辑处理,并且还要存储多个地方,例如mysql、redis、es,而且这些如果落盘失败可能会导致业务后续处理失败,那么咱们就可以采用该策略,资源不足时立即报错,阻止一些脏数据落库,避免影响整体业务流程,问题早发现早解决。

  • DiscardPolicy也可以应用于数据导出这种场景,但是尽量不要应用于插入操作日志这种场景,特别是对于一些敏感类的操作日志。

  • Java 在 1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时,这意味着如果项目很闲,就会将项目组的成员都撤走。

2.9.3 线程池的使用顺序

  • 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  • 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  • 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  • 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝

2.9.4 线程池的大小设置

  • 过小会导致程序不能充分地利用系统资源、容易导致饥饿,

  • 过大会导致更多的线程上下文切换,占用更多内存。

  • 因此应该分为CPU密集型和I/O密集型去具体分析

    • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。这个地方还有个需要注意的小点就是,如果你的服务器上部署的不止一个应用,你就得考虑其他的应用的线程池配置情况。经过精密的计算,你咔一下设置为核心数,结果项目部署上去了,发现还有其他的应用在和你抢 CPU,你想想难不难受。
    • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
      • 对于 I/O 密集型的计算场景,比如前面我们的例子中,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那多少个线程合适呢?是 3 个线程,如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。即最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
      • 不过上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

对于 I/O 密集型计算场景,I/O 耗时和 CPU 耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计。不过工程上,原则还是将硬件的性能发挥到极致,所以压需要根据压测时的QPS等数据来设置线程数

一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。(这是经验之谈了,)

2.9.4 加 线程池参数的动态化怎么做

尽管经过谨慎的评估,仍然不能够保证一次计算出来合适的参数,那么我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?

基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:

面试八股整理_第17张图片
threadPoolExecutor本来就可以通过setCorePoolSize和setMaxiMumPoolSize来设置核心线程数和最大线程数

对于setCorePoolSize

在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略。

  • 对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idel的时候也会被回收;
  • 对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务
  • 有的小伙伴就会问了:如果调整之后把活动线程数设置的值太大了,岂不是业务低峰期我们还需要人工把值调的小一点?可以设置allowCoreThreadTimeOut ,当 allowCoreThreadTimeOut 参数设置为 true 的时候,核心线程在空闲了 keepAliveTime 的时间后也会被回收的,相当于线程池自动给你动态修改了。

对于setMaxiMumPoolSize

  • 首先是参数合法性校验。

  • 然后用传递进来的值,覆盖原来的值。

  • 判断工作线程是否是大于最大线程数,如果大于,则对空闲线程发起中断请求。

对于动态设置队列长度

源码中队列的 capacity 是被 final 修饰了

实际上重新写一个队列就行了,把 LinkedBlockingQueue 粘贴一份出来,修改个名字,然后把 Capacity 参数的 final 修饰符去掉,并提供其对应的 get/set 方法。

2.9.4 加 线程池预热怎么做

  • 全部启动:prestartAllCoreThreads
  • 只启动一个:prestartCoreThread

2.9.5 Executor线程池的种类

  • FixedThreadPool : 该方法返回一个固定线程数量的线程池。
    • 无界队列,因此只会有核心线程,不会有救急线程
    • 缺点:不会拒绝任务,在任务比较多的时候会导致 OOM
  • SingleThreadExecutor: 该方法返回一个只有一个线程的线程池
    • 使用无界队列,因此只会有一个核心线程,不会有救急线程
    • 缺点:不会拒绝任务,在任务比较多的时候会导致 OOM
  • CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池
    • 使用同步队列,核心线程设为空,最大线程为Integer.MAX.VALUE
    • 任务先交给同步队列,如果有空闲线程,则交给空闲线程做(先交给队列是个与众不同之处),如果最大线程数为空,或者没有空闲线程,CachedThreadPool 就创建新线程
    • 缺点:如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源,从而导致 OOM
  • ScheduledThreadPool :该返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池
    • 缺点:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

2.9.6 线程池的最佳实践

1、正确声明线程池

  • 通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类创建线程池

2、监测线程池运行状态

  • 比如 SpringBoot 中的 Actuator 组件

3、建议不同类别的业务用不同的线程池

4、别忘记给线程池命名

  • 利用 guava 的 ThreadFactoryBuilder
  • 自己实现 ThreadFactor。

5、正确配置线程池参数

6、线程池使用的一些小坑

  • 重复创建线程池的坑
  • Spring 内部线程池的坑
  • 线程池和 ThreadLocal 共用的坑

2.9.7 如何优雅地关闭线程池

第一步:ShutDown 而言它可以安全的停止一个线程池,它有几个关键点

  • ShutDown 会首先将线程设置成 SHUTDOWN 状态,然后中断所有没有正在运行的线程
  • 正在执行的线程和已经在队列中的线程并不会被中断,说白了就是使用shutDown 方法其实就是要等待所有任务正常全部结束以后才会关闭线程池
  • 调用 shutdown() 方法后如果还有新的任务被提交,线程池则会根据拒绝策略直接拒绝后续新提交的任务。
ExecutorService executorService = Executors.newFixedThreadPool(5);
// 停止接受新任务
executorService.shutdown();

第二步:awaitTermination等待任务完成
一旦停止接受新任务,您可以使用 awaitTermination() 方法来等待线程池中的任务执行完成。这个方法接受一个超时时间参数,如果在超时时间内任务没有全部完成,它会返回 false,否则返回 true。

try {
    if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
        // 超时处理,可以选择强制关闭
        executorService.shutdownNow();
    }
} catch (InterruptedException e) {
    // 处理中断异常
    executorService.shutdownNow();
}

第三步:强制关闭时也可以拿到未执行的任务列表

List<Runnable> uncompletedTasks = executorService.shutdownNow();

第四步:释放资源

最后,一旦线程池关闭,确保释放线程池占用的资源,例如调用 executorService = null,以便让垃圾回收器回收线程池对象。

2.10. 谈谈Excutor框架

好的,Excutor框架主要由三部分构成

  • 任务(Runnable /Callable)
    • 执行任务需要实现的 Runnable 接口 或 Callable接口
  • 任务的执行(Executor)
    • 核心接口Executor的子类中包括ThreadPoolExecutor
  • 异步计算的结果(Future)
    • Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。
    • 当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象,excute不会返回)

因此框架的流程是:
1.先创建实现 Runnable 或者 Callable 接口的任务对象。
2.把该对象交给ExcutorService执行excute或者submit
3.submit的话,会返回一个实现FutureTask对象
4.主线程可以执行FutureTask.get( )来等待任务执行完成,也可以FutureTask.cancel取消任务

2.11 谈谈Future,以及CompletableFuture 好在哪

创建 CompletableFuture 对象主要靠4个静态方法

  • 1 runAsync(Runnable runnable)

    • Runnable 接口的 run() 方法没有返回值
  • 2 supplyAsync(Supplier supplier)

    • Supplier 接口的 get() 方法是有返回值的
  • 前两个方法和后两个方法的区别在于:后两个方法可以指定线程池参数。

  • 3 runAsync(Runnable runnable, Executor executor)

  • 4 supplyAsync(Supplier supplier, Executor executor)

是异步思想的典型运用
由于程序一直原地等待耗时任务执行完成,执行效率太低

因此当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。

它的功能包括:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。

CompletableFuture 是异步的Future
CompletableFuture 是jdk1.8中引入的一个基于事件驱动的一个异步回调类。简单来说,就是说当前使用异步线程去执行一个任务的时候,我们希望在这个任务结束之后触发一个后续的动作,CompletableFuture 就可以实现这个功能

举个简单的例子,比如在一个批量支付的业务逻辑里面,涉及到查询订单、支付、发送邮件通知这三个逻辑。
这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知。
而这种设计方式导致这个方法的执行性能比较慢。

所以,这里可以直接使用CompletableFuture,也就是说把查询订单的逻辑放在一个异步线程池里面去处理。
然后基于CompletableFuture 的事件回调机制的特性,可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。从而极大的提升这个这个业务场景的处理性能!
面试八股整理_第18张图片
CompletableFuture 提供了5 种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。

  • 第一种,thenCombine,把两个任务组合在一起,当两个任务都执行结束以后触发事件回调。
  • 第二种,thenCompose,把两个任务组合在一起,这两个任务串行执行,也就是第一个任务执行完以后自动触发执行第二个任务。
  • 第三种,thenAccept,第一个任务执行结束后触发第二个任务,并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值。
  • 第四种,thenApply,和thenAccept 一样,但是它有返回值。
  • 第五种,thenRun,就是第一个任务执行完成后触发执行一个实现了Runnable接口的任务。

除此之外还有很多种回调方法,用于回调
注意CompletableFuture 的get同样是同步阻塞的,这点与Future一样

最后,我认为,CompletableFuture 弥补了原本Future 的不足,使得程序可以在非阻塞的状态下完成异步的回调机制。

2.12 聊聊synchronized 和 ReentrantLock 有什么异同?

共同点:

  • 都是可重入锁

不同点:

  • synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
    为什么说依赖于API?
    API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成
  • ReentrantLock 比 synchronized 增加了一些高级功能
    • 等待可中断
    • 可实现公平锁
    • 可实现选择性通知(锁可以绑定多个条件)
      • 与synchronized的wait和notify不同,借助于Condition接口和newCondition()方法
      • Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程

synchronized 原理
好文章
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。不过两者的本质都是对对象监视器 monitor 的获取。

jdk1.6之前synchronized属于重量级锁,之后对它实现了大量的优化

  • 偏向锁:
    CAS操作加锁和释放锁的开销较大。
    若发现大概率只有一个线程会竞争该锁,那么就会给此锁维护一个偏好(Bias),后续加锁和释放锁基于Bias进行,不需要通过CAS。
    若有偏好之外的线程来竞争该锁,则回收之前分配的偏好。

    偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。
    为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。
    总结:撤销偏向的操作需要在全局检查点执行。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不在拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否 允许重偏向(rebiasing),获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁 升级为轻量级锁,线程B自旋请求获得锁。

  • 轻量级锁:
    若偏向锁没能成功实现,说明不同线程竞争加锁频繁,就会尝试采用轻量级锁。将对象头的Mark Word里的轻量级锁指针尝试指向持有锁的线程,判断是否为自己加的锁。
    若是,直接执行后续代码。
    否则,加锁失败,有他人已加锁,升级为重量级锁

    之所以是轻量级,是因为它仅仅使用 CAS 进行操作,实现获取锁。
    线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录(Lock Record)的指针, 如上图所示。如果成功,当前线程获得轻量级锁,如果失败,虚拟机先检查当前对象头的 Mark Word 是否指向当前线程的栈帧,如果指向,则说明当前线程已经拥有这个对象的锁,则可以直接进入同步块 执行操作,否则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数 达到界限值(threshold),轻量级锁将会膨胀为重量级锁。

  • 重量级锁
    它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。

  • 自旋锁:

    • JIT编译器对锁做的另一个优化。
      适用于粒度小的,指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

    • 也有缺陷:
      例子:线程A占用锁,线程B自旋10次未获取到锁,然后放弃了,不巧的是,线程B前脚刚走,线程A就好了,把锁释放了,呀!好尴尬呀。。
      解决:JDK1.6引入自适应的自旋锁,夸一下,JDK1.6牛皮!

    • 自适应的自旋锁的自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

  • 锁消除:
    JIT编译器在编译的时候,进行逃逸分析。分析synchronized锁对象是不是只可能被一个线程加锁,不存在其他线程来竞争加锁的情况。这时就可以消除该锁了,提升执行效率。
    编译就不用加入monitorenter和monitorexit指令。

  • 锁粗化:
    JIT编译时,发现一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。

synchronized 有四个等级:
无锁、偏向锁状态、轻量级锁状态、重量级锁状态,可以升级,但不可以降级,这种策略是为了提高获得锁和释放锁的效率

面试八股整理_第19张图片

2.13 ReentrantLock是如何实现的啊

相对于 synchronized 它具备如下特点:

  • 可中断

    • 如果某个线程处于阻塞状态,可以调用其 interrupt 方法让其停止阻塞
  • 可以设置超时时间

    • tryLock 方法可以指定等待时间
  • 可以设置为公平锁

    • 默认是不公平锁,需要在创建时指定为公平锁: ReentrantLock lock = new ReentrantLock(true);
  • 支持多个条件变量

    • synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待。ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的
    • 使用要点:
      • await 前需要获得锁。
      • await 执行后,会释放锁,进入 conditionObject 等待。
      • await 的线程被唤醒(或打断、或超时)时重新竞争 lock 锁。
      • 竞争 lock 锁成功后,从 await 后继续执行。

ReentrantLock实现原理

首先看看继承、实现关系
面试八股整理_第20张图片

结构有点类似于monitor
面试八股整理_第21张图片

  • 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList。
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet。

具体说说ReentrantLock的原理

  • 首先是锁的竞争,ReentrantLock 是通过互斥变量,使用CAS 机制来实现的。
  • 没有竞争到锁的线程,使用了AbstractQueuedSynchronizer 这样一个队列同步器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从AQS 队列里面的头部唤醒下一个等待锁的线程。
  • 公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断AQS 队列存在等待中的线程。
  • 最后,关于锁的重入特性,在AQS 里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。

2.14 说说volatile作用

当一个变量定义为 volatile 之后,将具备两种特性:

  • 1.保证此变量对所有的线程的可见性,这里的“可见性”,如本文开头所述,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。

  • 2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。

2.14 聊聊volatile与synchronized 的区别

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性

关于内存屏障

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
  • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。

因此

  • 对 volatile 变量的写指令后会加入写屏障。
  • 对 volatile 变量的读指令前会加入读屏障。

无锁的思想就是CAS+volatile

  • 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。

  • 打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大。

  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。

2.15 聊聊threadlocal

好的,threadlocal主要是存放每个线程的私有数据

它的结构其实是一个ThreadLocalMap
ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值
面试八股整理_第22张图片
ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构,它是以ThreadLocal为 key ,Object 对象为 value 的键值对。

threadLocal存在内存泄漏的问题,但是解决了

内存泄漏的原因是ThreadLocalMap 中使用。的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露

因此ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录,同时我们使用完 ThreadLocal方法后 最好手动调用remove()方法。


threadlocal的原理

  • ThreadLocal 是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
  • 在多线程访问共享变量的场景中,一般的解决办法是对共享变量加锁,从而保证在同一时刻只有一个线程能够对共享变量进行更新,并且基于Happens-Before规则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。
  • 但是加锁会带来性能的下降,所以ThreadLocal 用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。

ThreadLocal是jdk提供的除了加锁之外保证线程安全的方法,其实现原理是在Thread类中定义了两个ThreadLocalMap类型变量threadLocals、inheritableThreadLocals用来存储当前操作的ThreadLocal的引用及变量对象,这样就可以把当前线程的变量和其他的线程的变量之间进行隔离,从而实现了线程的安全性。

在同一个ThreadLocal变量在父线程中被设置值之后,在子线程中是获取不到的(由二中的实例输出可以看出),threadLocals中为当前调用线程的本地变量,所以子线程是无法获取父线程的变量的;一开始我们介绍的时候说Thread类中还有一个inheritableThreadLocals变量,其值是存储的子线程的变量,所以可以通过InheritableThreadLocal类获取父线程的变量;

InheritableThreadLocal类是ThreadLocal类的子类,重写了chidValue、getMap、createMap三个方法,其中createMap方法在被调用的时候创建的是inheritableThreadLocals变量值(ThreadLocal类中创建的是threadLocals变量的值),getMap方法在被get或set方法调用的时候返回的也是线程的inheritableThreadLocals变量。

InheritableThreadLocal主要用于子线程创建时,需要自动继承父线程的ThreadLocal变量,方便必要信息的进一步传递。

后来还了解到,线程池和Threadlocal共用可能存在问题

  • 线程池会复用线程对象,与线程对象绑定的类的静态属性 ThreadLocal 变量也会被重用,这就导致一个线程可能获取到其他线程的ThreadLocal 值。
  • 解决上述问题比较建议的办法是使用阿里巴巴开源的 TransmittableThreadLocal(TTL)。TransmittableThreadLocal类继承并加强了 JDK 内置的InheritableThreadLocal类,在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。

2.16 并发编程的三个特性讲一下

好的,包括原子性、可见性和有序性

原子性:

  • 是指一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
  • 实现方式有
    • synchronized
    • Lock
    • CAS

可见性:

  • 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
  • 实现方式有
    • volatile
    • synchronized
    • Lock

有序性:

  • 由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
    指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致
  • 实现方式:volatile

2.17 有哪些原子类

AtomicInteger:整型原子类

  • AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升
  • CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

AtomicLong:长整型原子类
AtomicBoolean :布尔型原子类
AtomicReference:引用类型原子类

常见面试题:AtomicLong和LongAdder的区别:

起因:高并发下N多线程同时去操作一个变量会造成大量线程CAS失败,然后处于自旋状态,导致严重浪费CPU资源,降低了并发性。既然AtomicLong性能问题是由于过多线程同时去竞争同一个变量的更新而降低的,那么如果把一个变量分解为多个变量,让同样多的线程去竞争多个资源。高并发下LongAdder的用时短,效率高。

面试八股整理_第23张图片
面试八股整理_第24张图片

2.18 AQS是什么

AQS 就是一个抽象类,主要用来构建锁和同步器,它提供了一些通用功能的实现

先了解一个概念
CLH队列锁

它是一个虚拟的双向队列,CLH队列中的每个节点代表一个线程,保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)
面试八股整理_第25张图片
注意:
state 表示同步状态
state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况

AQS的资源共享方式包括:独占式和共享式,包括一些方法:

  • 独占式Exclusive(独占,只有一个线程能执行,如ReentrantLock):

    • tryAcquire:独占方式。尝试获取资源
    • tryRelease:独占方式。尝试释放资源
  • 共享式Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch):

    • tryAcquireShared:共享方式。尝试获取资源
    • tryReleaseShared:共享方式。尝试释放资源

AQS 使用了模板方法模式,自定义同步器时需要重写上面几个 AQS 提供的钩子方法

几种实现

  • Semaphore信号量,是一种共享锁。控制同时访问特定资源的线程数量。

    • 初始的资源个数为 1 的时候,Semaphore 退化为排他锁
    • 有两种模式
      • 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
      • 非公平模式: 抢占式的。
  • CountDownLatch倒计时器,是一种共享锁,允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕

    • 总之就是使用await阻塞,直到state==0时才会继续进行
    • CountDownLatch 的计数器是大于或等于线程数的,个线程中可以进行多次扣减
    • 流程
      • 当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞。CountDownLatch 会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行
    • 是一次性的
      • 当 CountDownLatch 使用完毕后,它不能再次被使用
      • 计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值
  • CyclicBarrier循环栅栏,是独占锁,是基于 ReentrantLock和 Condition 的(CountDownLatch 的实现是基于 AQS 的)

    • 目的是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活
    • 和CountDownLatch的区别:CyclicBarrier是一定等于线程数,CountDownLatch 的计数器是大于或等于线程数的,个线程中可以进行多次扣减
    • 和CountDownLatch的区别(我自己总结):CountDownLatch阻塞是为了最终运行主线程,CyclicBarrier阻塞,是为了最终被阻塞的大家,再一起继续。也就是说阻塞的目的不同
      • 举例说明:
        • CountDownLatch
          比如LOL在游戏开始时需要玩家全部准备完毕之后才开始,开始游戏可以理解为“主线程”,玩家准备理解为“其他线程”,在“其他线程”没有准备完毕之前,“主线程时等待状态”,当“其他线程”准备完毕之后“主线程”就会执行下一步开始游戏的动作
        • cyclicbarrier
          假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推比如跨栏比赛,我们修改一下规则,当所有选手都跨过第一个栏杆是,才去跨第二个,以此类推,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。

面试八股整理_第26张图片

2.19 对象头是怎么个说法

每个对象都有对象头,对象的结构如下图,包括对象头、实例数据、对齐补充
面试八股整理_第27张图片

以 32 位虚拟机为例,普通对象的对象头结构如下,其中的 Klass Word 为指针,指向对应的 Class 对象。
面试八股整理_第28张图片
其中Mark Word结构为
面试八股整理_第29张图片

参数

  • hashcode:对象的hashCode,采用延迟计算,计算后会把结果写到该对象头中。当对象被锁定时,该值会移动到Monitor中。

  • age 「分代年龄」表示java对象被GC的次数,每次GC的时候,如果对象在Survivor区复制一下,年龄增加1。当对象达到设定的阀值时,将会晋升到老年代。这个参数占4bit,也就是最大值是2^4-1 =15。这是JVM参数

这也就引出

  • biased_lock 是否是偏向锁,0表示是无锁

  • lock类型 01 00 10 11

  • thread 「线程ID:」在偏向模式中,当某个线程持有该对象,则该对象头的线程ID位置存储的是这个线程的ID。这样在后面的操作中,就不需要再进行获取锁的动作

  • epoch 偏向锁时间戳,用于在CAS锁操作过程中,偏向性标识,表示更偏向哪个锁

  • ptr_to_lock_record:在轻量级锁的状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象头中设置指向锁记录的指针

  • ptr_to_heavyweight_monitor:在重量级锁的状态下,指向管程Montior的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor心管理等待的线程。在重量级锁定的情况下,JVM设置ptr_to_heavyweight_monitor指向Montior。

2.20 关于Monitor ( 管程 ) 了解么

面试八股整理_第30张图片
每个 java 对象都可以关联一个 Monitor,如果使用 synchronized 给对象上锁(重量级),该对象头的 Mark Word 中就被设置为指向 Monitor 对象的指针。

流程:

  1. 刚开始时 Monitor 中的 Owner 为 null;

  2. 当 Thread-2 执行 synchronized (obj){} 代码时就会将 Monitor 的所有者 Owner 设置为 Thread-2,上锁成功,Monitor 中同一时刻只能有一个 Owner;

  3. 当 Thread-2 占据锁时,如果线程 Thread-3 ,Thread-4 也来执行 synchronized (obj){} 代码,就会进入 EntryList(阻塞队列) 中变成 BLOCKED(阻塞) 状态;

  4. Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的;

  5. 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析。

2.21 【wait( ) 和 notify】 【join 和 fork】 【park和unpark】 【sleep】的区别

对于wait 和notify

  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争

obj.wait () 让进入 object 监视器的线程到 waitSet 等待。

obj.notify () 在 object 上正在 waitSet 等待的线程中挑一个唤醒。

obj.notifyAll () 让 object 上正在 waitSet 等待的线程全部唤醒。

Wait 与 Sleep 的区别:

  • Sleep 是 Thread 类的静态方法,Wait 是 Object 的方法,Object 又是所有类的父类,所以所有类都有 Wait 方法。
  • Sleep 在阻塞的时候不会释放锁,而 Wait 在阻塞的时候会释放锁,它们都会释放 CPU 资源。
  • Sleep 不需要与 synchronized 一起使用,而 Wait 需要与 synchronized 一起使用(对象被锁以后才能使用)
  • 使用 wait 一般需要搭配 notify 或者 notifyAll 来使用,不然会让线程一直等待。

什么时候适合使用 wait?

当线程不满足某些条件,需要暂停运行时,可以使用 wait 。这样会将对象的锁释放,让其他线程能够继续运行。如果此时使用 sleep,会导致所有线程都进入阻塞,导致所有线程都没法运行,直到当前线程 sleep 结束后,运行完毕,才能得到执行。


对于park和unpark

LockSupport类使用类似信号量的机制

  • park,unpark 不必配合 Object Monitor 一起使用(wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用)

  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】

  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify

park unpark的原理

每个线程都有自己的一个 Parker 对象,由三部分组成 _counter, _cond 和 _mutex(互斥锁)。

  • 打个比喻,线程就像一个旅人,Parker 就像他随身携带的背包,条件变量 _ cond 就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足);

  • 调用 park 就是要看需不需要停下来歇息:

    • 如果备用干粮耗尽,那么钻进帐篷歇息;
    • 如果备用干粮充足,那么不需停留,继续前进。
  • 调用 unpark,就好比令干粮充足:

    • 如果这时线程还在帐篷,就唤醒让他继续前进;
    • 如果这时线程还在运行,那么下次它调用 park 时,仅是消耗掉备用干粮,不需停留继续前进;
    • 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮。

什么是Fork/Join(这里其实只是词语相像,其实和另外几个概念没啥关系)

将一个大的任务拆分成多个子任务进行并行处理,最后将子任务结果合并成最后的计算结果,并进行输出。

Fork/Join框架首先进行Fork(分),递归的将任务分解成更小的、独立的子任务,直到它们足够简单,能被异步执行。

之后,开始进行Join(结果的合并),所有子任务的执行结果将会递归的进行合并,对于没有返回值的任务,程序将会等待子任务执行结束。

总结:Fork/Join 是一个并行计算的框架,主要就是用来支持分治任务模型的,这个计算框架里的 Fork 对应的是分治任务模型里的任务分解,Join 对应的是结果合并。Fork/Join 计算框架主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTask。这两部分的关系类似于 ThreadPoolExecutor 和 Runnable 的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型 ForkJoinTask。


join和yield

join方法是使当前线程暂停执行,等待调用该方法的线程结束后再继续执行本线程

  • 线程t1中调用t2的join,t1会等待t2完成后再执行,相当于t1会被挂到t2的waitSet上
  • join的实质就是wait,j2调用完成之后,会在退出前调用notifyAll()通知所有的等待线程继续执行

yield方法可暂停当前线程执行,允许其他线程执行,该线程仍可以运行,不转为阻塞状态,此时,系统选择其他相同或更高优先级线程执行,若没有,则该线程继续执行

  • yield让出CPU并不代表当前线程不执行了。当前线程让出CPU之后,还会进行CPU资源的争夺,但是否能再次被分配到就不一定了
  • 我自己的感觉就是,重新加入竞争(按优先级)

或者说

  • join:当某个线程拥有cpu资源时,它决定把资源让给另一个特定的线程

  • yield:当某个线程获得cpu时,它让出这个机会,给与它优先级相同或者更高的线程

2.22 什么是保护性暂停

即 Guarded Suspension,用在一个线程等待另一个线程的执行结果。

有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject。

JDK 中,join 、Future 的实现,采用的就是此模式。

2.23 聊一聊不可变设计的思路

final 的使用

  • 发现该类、类中所有属性都是 final 的:

    • 属性用 final 修饰保证了该属性是只读的,不能修改。
    • 类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性。
    • String类不可变,就是因为被final修饰
  • 这里再介绍一下final的原理

    • final 变量的赋值操作都必须在定义时或者构造器中进行初始化赋值,并且发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障

保护性拷贝 使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,如何实现

  • 构造新字符串对象时,会生成新的 char [] value,对内容进行复制

2.24 讲一下协程

关于协程的,非常棒的文章

也可称为纤程(Fiber)

解决什么问题:

传统的J2EE系统都是基于每个请求占用一个线程去完成完整的业务逻辑(包括事务)。所以系统的吞吐能力取决于每个线程的操作耗时。如果遇到很耗时的I/O行为,则整个系统的吞吐立刻下降,比如JDBC是同步阻塞的,这也是为什么很多人都说数据库是瓶颈的原因。这里的耗时其实是让CPU一直在等待I/O返回,说白了线程根本没有利用CPU去做运算,而是处于空转状态。暴殄天物啊。另外过多的线程,也会带来更多的ContextSwitch开销。

Java的JDK里有封装很好的ThreadPool,可以用来管理大量的线程生命周期,但是本质上还是不能很好的解决线程数量的问题,以及线程空转占用CPU资源的问题。

先阶段行业里的比较流行的解决方案之一就是单线程加上异步回调。

因此相关的Promise,CompletableFuture等技术都是为解决相关的问题而产生的。但是本质上还是不能解决业务逻辑的割裂。

现在比较成熟的是Quasar

为什么协程在Java里一直那么小众

如果你希望你的代码能够跑在Fiber里面,需要一个很大的前提条件,那就是你所有的库,必须是异步无阻塞的。而Java里基本上所有的库都是同步阻塞的,很少见到异步无阻塞的。而且得益于J2EE,以及Java上的三大框架(SSH)洗脑,大部分Java程序员都已经习惯了基于线程,线性的完成一个业务逻辑,很难让他们接受一种将逻辑割裂的异步编程模型。

但是随着golang的推广,人们越来越知道如何更好的榨干CPU性能(让CPU避免不必要的等待,减少上下文切换),阻塞的行为基本发生在I/O上,如果能有一个库能把所有的I/O行为都包装成异步阻塞的话,那么Quasar就会有用武之地。

JVM上公认的是异步网络通信库是Netty,通过Netty基本解决了网络I/O问题,另外还有一个是文件I/O,而这个JDK7提供的NIO2就可以满足,通过AsynchronousFileChannel即可。剩下的就是如何将他们封装成更友好的API了。目前能达到生产级别的这种异步工具库,JVM上只有Vert.x3,封装了Netty4,封装了AsynchronousFileChannel,而且Vert.x官方也出了一个相对应的封装了Quasar的库vertx-sync。

线程和协程的区别如下:

  • 并发模型:

    • 线程:线程是操作系统级别的并发执行单元。每个线程都有自己的堆栈和上下文,并且由操作系统进行调度。多线程应用程序可以在多个物理核心上并行执行,但线程之间的切换会引入额外的开销。
    • 协程:协程是一种更高级别的并发机制,由程序本身进行控制。协程在同一个线程内执行,并且可以在协程之间自由切换,而无需操作系统干预。这使得协程的切换速度更快,减少了开销。
  • 切换开销:

    • 线程:线程的切换由操作系统管理,通常涉及保存和恢复线程的上下文。这会引入较大的开销。
    • 协程:协程的切换是由程序员显式控制的,通常在适当的时候进行切换。这样可以减小切换的开销。
  • 内存消耗:

    • 线程:每个线程都有自己的堆栈,因此创建大量线程可能会导致内存消耗增加。
    • 协程:协程在同一个线程内共享相同的堆栈,因此内存消耗通常较低。
  • 同步和通信:

    • 线程:线程之间的通信通常需要使用锁和其他同步机制,以避免竞态条件和数据竞争。
    • 协程:协程通常使用异步编程模型,如异步/await,来进行非阻塞的并发编程,避免了大部分显式的锁和同步操作。

2.25 happens-before原则是什么

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

Happens-Before的7个规则:

  • (1).程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • (2).管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。
  • (3).volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序。
  • (4).线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  • (5).线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • (6).线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • (7).对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

Happens-Before的1个特性:传递性。

实质

  • happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面
  • 它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里

三、IO

3.1 讲一下IO中的字节流和字符流的区别

好的面试官,Java中的字节流包括输入流InputStream和输出流OutputStream

  • InputStream用于从源头(通常是文件)读取数据(字节信息)到内存中
  • OutputStream用于将数据(字节信息)写入到目的地(通常是文件)

那么为什么需要字符流呢,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。我们平时比较熟悉的常用字符编码包括:

  • utf8 :英文占 1 字节,中文占 3 字节
  • unicode:任何字符都占 2 个字节
  • gbk:英文占 1 字节,中文占 2 字节

字节流包括输入流Reader和输出流Writer

  • Reader用于从源头(通常是文件)读取数据(字符信息)到内存中
  • Writer用于将数据(字符信息)写入到目的地(通常是文件)
  • 需要注意的是:
    • InputStreamReader 是字节流转换为字符流的桥梁
    • OutputStreamWriter 是字符流转换为字节流的桥梁

3.2 缓冲流包括什么,有什么作用

IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。

  • 字节缓冲流包括
    • 字节缓冲输入流BufferedInputStream
    • 字节缓冲输出流BufferedOutputStream
  • 字符缓冲流包括
    • BufferedReader (字符缓冲输入流)
    • BufferedWriter(字符缓冲输出流)
  • 字节缓冲流这里采用了装饰器模式来增强 InputStream 和OutputStream子类对象的功能

3.3 IO中涉及到的设计模式有哪些

一、装饰器模式

  • 通过组合替代继承来扩展原始类的功能
  • 在IO中的经典体现:对于字节流来说, FilterInputStream (对应输入流)和FilterOutputStream(对应输出流)是装饰器模式的核心,分别用于增强 InputStream 和OutputStream子类对象的功能。
  • 自己的理解:其实就是例如像InpuStream这种类,子类实在太多了,一个功能一个类的话,继承就乱了套了,所以可以用这种增强的方式
  • 要求:装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口
    • 比如 IO 相关的装饰类和原始类共同的父类是 InputStream 和OutputStream
    • 比如BufferedInputStream和ZipInputStream有共同的父类InputStream

二、适配器模式

  • 用于接口互不兼容的类的协调工作
  • IO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。
  • IO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。
    • InputStreamReader 使用 StreamDecoder (流解码器)对字节进行解码,实现字节流到字符流的转换
    • OutputStreamWriter 使用StreamEncoder(流编码器)对字符进行编码,实现字符流到字节流的转换。
    • InputStream 和 OutputStream 的子类是被适配者, InputStreamReader 和 OutputStreamWriter是适配器。

装饰器和适配器的区别

  • 装饰器模式 更侧重于动态地增强原始类的功能
    • 装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口
    • 装饰器模式支持对原始类嵌套使用多个装饰器。
  • 适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作

三、工厂模式

  • NIO 中大量用到了工厂模式
    • Files 类的 newInputStream 方法用于创建 InputStream 对象,用到了静态工厂
      • NIO是什么?IO是面向流的,其中NIO是面向缓冲区的

四、观察者模式

  • NIO 中的文件目录监听服务使用到了观察者模式
    • WatchService 属于观察者
    • Watchable 属于被观察者

3.3 加 epoll底层是怎么实现的

epoll 使用了三个主要的数据结构来管理事件:

  • 红黑树(Red-Black Tree):epoll 使用一个红黑树来维护已经注册的文件描述符(FD)和关联的事件信息。这个红黑树允许快速查找和插入操作,使得 epoll 能够高效地管理大量的 FD。

  • 就绪链表(Ready List):epoll 使用一个就绪链表来存储已经就绪的事件。当一个 FD 上发生事件时,它会被放入就绪链表中,等待用户程序处理。

  • 等待队列(Wait Queue):epoll 使用一个等待队列来管理等待事件的进程或线程。如果没有 FD 处于就绪状态,等待队列中的进程会被置于休眠状态,直到有事件发生。

epoll 提供了三个主要的系统调用函数,用于操作事件:

  • epoll_create:创建一个 epoll 实例,返回一个文件描述符(epoll FD),用于后续的操作。

  • epoll_ctl:用于向 epoll 实例中添加、修改或删除监听的 FD 和关联的事件。可以设置关注的事件类型,如可读、可写、错误等。

  • epoll_wait:用于等待事件的发生。它会阻塞直到有事件发生,然后返回就绪的 FD 列表供用户程序处理。

工作流程

  1. 用户程序通过 epoll_create 创建一个 epoll 实例,并使用 epoll_ctl 添加需要监听的 FD 和事件。
  2. 当监听的 FD 上发生关注的事件时,内核将这个 FD 加入到就绪链表中,唤醒等待队列中的进程。
  3. 用户程序使用 epoll_wait 阻塞等待事件,当有事件发生时,epoll_wait 返回就绪的 FD 列表,用户程序可以处理这些事件。
  4. 用户程序可以不断地重复 epoll_wait、处理事件、再次等待的循环,以实现高效的事件驱动编程模型。

3.3 加 epoll为啥要用红黑树加就绪链表的形式

在 epoll 中使用红黑树和就绪链表的形式,是为了在高效地管理大量文件描述符(FD)和事件的同时,提供快速的事件查找和通知机制。这种组合结构的优点包括:

  • 快速查找:红黑树是一种自平衡二叉搜索树,具有 O(log N) 复杂度的查找性能。在 epoll 中,已注册的 FD 存储在红黑树中,这意味着可以高效地查找特定的 FD 并确定它是否处于就绪状态。

  • 快速插入和删除:红黑树的插入和删除操作也是 O(log N) 复杂度的,这使得在添加和删除监听的 FD 时能够快速进行操作。

  • 事件通知:就绪链表用于存储已经就绪的事件,这允许内核在 FD 上发生事件时将该 FD 添加到就绪链表中,等待用户程序处理。这种设计可以避免在内核空间和用户空间之间频繁传递大量的事件信息,而只需要将就绪的 FD 添加到链表即可,减少了内核与用户程序之间的数据传输次数。

  • 可扩展性:由于红黑树的平衡性,它对于大量 FD 的管理是高效的。此外,使用就绪链表意味着内核只需将 FD 添加到链表中,而不需要在 FD 的数量上发生频繁的变化,这提高了系统的可扩展性。

3.4 IO模型有哪几种

I/O 是分为两个过程的:

  • 数据准备的过程
  • 数据从内核空间拷贝到用户进程缓冲区的过程

根据上面两个步骤的不同,IO操作可以进一步细分为下面五种


总共有五种
1.阻塞IO模型
2.非阻塞IO模型
3.IO复用模型
4.信号驱动IO模型
5.异步IO模型

让我来分别介绍一下五种模型

1.阻塞IO模型 .BIO

  • 面试八股整理_第31张图片
  • 数据没准备好就一直阻塞

2.非阻塞IO模型

  • 是同步非阻塞 IO 模型
  • 实现方式:通过轮询操作,避免了一直阻塞
    • 进程发起IO系统调用后,如果内核缓冲区没有数据,需要到IO设备中读取,进程返回一个错误而不会被阻塞。
    • 进程发起IO系统调用后,如果内核缓冲区有数据,内核就会把数据返回进程。
  • 特点
    • 非阻塞 IO 模型中,应用程序会一直发起 read 调用
    • 等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。(下面的多路复用也是如此)
  • 缺点
    • 应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的

面试八股整理_第32张图片


3.IO复用模型 (一个线程就可以管理多个socket)

  • 多路复用IO模型是目前使用得比较多的模型。Java NIO实际上就是多路复用IO。
  • 我个人的理解,Java NIO=多路复用的基础上+多线程/线程池(多路复用只有一个线程),这样的话速度更快了。
  • 解决了第二个模型的问题:IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗
  • 特有概念:选择器 ( Selector ) 也叫做多路复用器,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
  • 原理:多个进程的IO可以注册到一个复用器(select)上,然后会有一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作。
  • 效果:因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
  • redis就是这个原理
  • 多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。
  • 不足之处:要注意的是,多路复用IO模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。

面试八股整理_第33张图片
1.select模型,使用的是数组来存储Socket连接文件描述符,容量是固定的,需要通过轮询来判断是否发生了IO事件
2.poll模型,使用的是链表来存储Socket连接文件描述符,容量是不固定的,同样需要通过轮询来判断是否发生了10事件
3.epoll模型,epol和poll是完全不同的, epll是一种事件通知模型,当发生了IO事件时,应用程序才进行IO操作,不需要像pol模型那样主动去轮询

如上图所示,流程:

  • 首先发起 select 调用,询问内核数据是否准备就绪
  • 等内核把数据准备好了,用户线程再发起 read 调用
  • read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。

4.信号驱动IO模型

  • 当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。

  • 这个一般用于UDP中,对TCP套接口几乎是没用的,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么事情


5.异步IO模型(AIO)

  • 是NIO 2 , 是最理想的IO模型。如下图,应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

  • 异步 IO 是基于事件和回调机制实现的

  • 在异步IO模型中,IO操作的两个阶段(准备数据和拷贝数据)都不会阻塞用户线程,这两个阶段都是由内核自动完成,也就是说,已经完全完成了!!(可以和信号驱动对比一下)

    • 在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用iO函数进行实际的读写操作
  • 总之,前面四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。

面试八股整理_第34张图片

常见的IO模型效果图如下
面试八股整理_第35张图片
这里需要说一下英文全拼

  • BIO(Blocking I/O)
  • NIO(Non-Blocking IO)
  • AIO,全程 Asynchronous IO

三者深层次区别,见文章

3.5 高性能的IO设计模式了解么

传统的网络服务设计模式中,有两种比较经典的模式
一种是多线程,一种是线程池。

但是他们有他们的弊端

  • 多线程的弊端:
    • 服务器为每个client的连接都采用一个线程去处理,使得资源占用非常大,而且不好管理
  • 线程池的弊端
    • 如果连接大多是长连接,因此可能会导致在一段时间内,线程池中的线程都被占用,那么当再有用户请求连接时,由于没有可用的空闲线程来处理,就会导致客户端连接失败,从而影响用户体验。
    • 因此,线程池比较适合大量的短连接应用。

因此诞生了两种高性能IO设计模式:Reactor和Proactor

  • NIO的模式是Reactor
  • AIO的模式是Proactor
  • 在Reactor模式中,会先对每个client注册感兴趣的事件,然后有一个线程专门去轮询每个client是否有事件发生,当有事件发生时,便顺序处理每个事件,当所有事件处理完之后,便再转去继续轮询。
  • 多路复用IO就是采用Reactor模式,为了提高事件处理速度,可以通过多线程或者线程池的方式来处理事件。Java NIO使用的就是这种。
  • 在Proactor模式中,当检测到有事件发生时,会新起一个异步操作,然后交由内核线程去处理,当内核线程完成IO操作之后,发送一个通知告知操作已完成,可以得知,异步IO模型采用的就是Proactor模式。Java AIO使用的这种

3.6 如何能让一个集合的长度和内容不能修改

要创建一个长度和内容都不能修改的集合,你可以使用Java中的

  • Collections.unmodifiableList
  • Collections.unmodifiableSet
  • Collections.unmodifiableMap

等方法来包装你的集合对象。这将创建一个只读视图,阻止对集合的内容和长度进行修改。或者使用Java 9及更高版本的List.of、Set.of和Map.of来创建不可修改的集合对象,这将阻止对内容的修改。

使用final关键字可以确保引用不可变,但它不能确保集合的内容不可变。当你声明一个集合为final时,你仍然可以向其中添加、删除或修改元素。只要集合对象本身没有被重新赋值,它就是不可变的引用,但集合的内容是可变的。

四、mysql

4.0 讲一下mysql三个范式分别是啥

  • 1NF(第一范式)
    • 属性不可再分。
    • 也就是这个字段只能是一个值,不能再分为多个其他的字段了
    • 1NF 是所有关系型数据库的最基本要求 ,也就是说关系型数据库中创建的表一定满足第一范式
  • 2NF(第二范式)
    • 在 1NF 的基础之上,消除了非主属性对于码的部分函数依赖
    • 第二范式在第一范式的基础上增加了一个列,这个列称为主键,非主属性都依赖于主键。
  • 3NF(第三范式)
    • 3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖
    • 基本上解决了数据冗余过大,插入异常,修改异常,删除异常的问题

4.0 加 mysql的组成:

  • server层
    • 1.连接器: 身份认证和权限相关(登录 MySQL 的时候)。
    • 2.查询缓存: 执行查询SELECT语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。
    • 3.分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。
      • 第一步词法分析,分析干啥
      • 第二步语法分析,分析语句对不对
    • 4.优化器: 按照 MySQL 认为最优的方案去执行。
    • 5.执行器: 执行语句,然后从存储引擎返回数据。 执行语句之前会先判断是否有权限,如果没有权限的话,就会报错。
  • 存储引擎
    • 6.插件式存储引擎 : 主要负责数据的存储和读取,采用的是插件式架构,支持 InnoDB、MyISAM、Memory 等多种存储引擎

4.1 讲一讲SQL语句在MySQL中的执行过程

查询语句

  • 例子
    • select * from tb_student A where A.age=‘18’ and A.name=’ 张三 ';
  • 流程
    • 1.验证权限

    • 2.查看是否有缓存

      • 有的话返回,没有的话下一步
    • 3.分析此提取sql语句的关键元素,进行词法分析。然后判断是否有语法错误,没有的话下一步

    • 4.优化器进行确定执行方案

      • 优化器根据自己的优化算法进行选择执行效率最好的一个方案(优化器认为,有时候不一定最好)。那么确认了执行计划后就准备开始执行了。
    • 5.权限校验

      • 没有权限就会返回错误信息
      • 有权限就会调用数据库引擎接口,返回引擎的执行结果

更新语句

  • 例子
    • update tb_student A set A.age=‘19’ where A.name=’ 张三 ';
  • 与查询的不同:执行更新的时候肯定要记录日志
    • MySQL 自带的日志模块是 binlog(归档日志) 所有的存储引擎都可以使用
    • InnoDB 引擎还自带了一个日志模块 redo log(重做日志)
  • 流程
    • 1.分析器
    • 2.拿到语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交
    • 3.执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。
    • 4.更新完成。
  • 为什么要用两个日志模块,用一个日志模块不行吗
    mysql本身没有InnoDB、而redo log又是InnoDB特有的。这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档,这样就总共两个日志模块了
  • 用两个日志模块,但是不要这么复杂行不行,为什么 redo log 要引入 prepare 预提交状态?
    • 先写 redo log 直接提交,然后写 binlog
      • 假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 binlog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
    • 先写 binlog,然后写 redo log
      • 假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。
    • 正确做法
      • 写完 binlog 后,然后再提交 redo log
      • 但此时也会有一个极端情况:假设 redo log 处于预提交状态,binlog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:
        • 判断 redo log 是否完整,如果判断是完整的,就立即提交。
        • 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。

二者区别

  • 查询语句的执行流程如下:权限校验(如果命中缓存)—>查询缓存—>分析器—>优化器—>权限校验—>执行器—>引擎
  • 更新语句执行流程如下:分析器---->权限校验及执行器—>引擎—redo log(prepare 状态)—>binlog—>redo log(commit状态)

4.2 InnoDB和MyISAM的区别

1.是否支持行级锁

  • MyISAM 只有表级锁
  • InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁,这也是为什么 InnoDB 在并发写的时候,性能更牛皮

2.是否支持事务

  • MyISAM 不提供事务支持
  • InnoDB 提供事务支持

4.是否支持数据库异常崩溃后的安全恢复

  • MyISAM 不支持
  • InnoDB 支持,依赖于 redo log

5.是否支持 MVCC

  • MyISAM 不支持
  • InnoDB 支持,MVCC 可以看作是行级锁的一个升级,可以有效减少加锁操作,提高性能。

6.索引实现不一样。

  • 虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样
  • InnoDB 引擎,其数据文件本身就是索引文件
  • MyISAM中,索引文件和数据文件是分离的

7.性能有差别。

  • 随着 CPU 核数的增加,InnoDB 的读写能力呈线性增长。MyISAM 因为读写不能并发,它的处理能力跟核数没关系

MyISAM 最大的问题就是 不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复。

4.2 加 innoDB的三种行锁

  • Record Lock(记录锁):单个行记录上的范围 (锁住某一行记录)
  • Gap Lock(间隙锁):间隙锁,锁定一个范围,但不包含记录本身 (锁住一段左开右开的区间)
  • Next-key Lock(临键锁):Gap Lock + Record Lock,锁定一个范围,并且锁定记录本身 (锁住一段左开右闭的区间)

4.3 对于使用索引,有建议没

1 选择合适的字段创建索引

  • 不为 NULL 的字段
  • 被频繁查询的字段
  • 被作为条件查询的字段
  • 频繁需要排序的字段
  • 被经常频繁用于连接的字段

2 被频繁更新的字段应该慎重建立索引

3 限制每张表上的索引数量

4 尽可能的考虑建立联合索引而不是单列索引

5 注意避免冗余索引:在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引

6 字符串类型的字段使用前缀索引代替普通索引

7 避免索引失效,常见的导致索引失效的情况:

  • 索引列上参与计算会导致索引失效
  • 使用 SELECT * 进行查询;
  • 创建了组合索引,但查询条件未遵守最左匹配原则;
  • 在索引列上进行计算、函数、类型转换等操作;
  • 以 % 开头的 LIKE 查询比如 like ‘%abc’;
  • 查询条件中使用 or,且 or 的前后条件中有一个列没有索引,涉及的索引都不会被使用到;
  • 发生隐式转换;

8 删除长期未使用的索引

9 用explain进行分析

4.4 不同日志文件的区别

先说说redo log和binlog

丁奇:

  • redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

javaguide:

  • redo log(重做日志)让InnoDB存储引擎拥有了崩溃恢复能力。
    • 是物理日志
  • binlog(归档日志)保证了MySQL集群架构的数据一致性。
    • 是逻辑日志
    • 前两个都属于持久化的保证,但是侧重点不同
    • 二者的写入时机不同
      • redo log在事务执行过程中可以不断写入
      • binlog只有在提交事务时才写入
    • 二者还涉及到一个两阶段提交,即redo log的prepare和commit

面试八股整理_第36张图片

  • undolog
    • 是逻辑日志
    • 在异常发生时,对已经执行的操作进行回滚
    • 回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。
    • 在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的
      • 所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作
      • 如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!
    • 注意undolog和redolog的区别
      • redoLog是重做,而undoLog是回滚,redolog是记录物理修改,从而恢复数据页。而undolog是用来回滚行记录到某个版本

总结:
InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性
使用 undo log(回滚日志) 来保证事务的原子性
MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性
redo log 是重做日志,提供 前滚 操作;undo log 是回退日志,提供 回滚 操作

4.5 隔离级别对于不同问题的解决情况如何

面试八股整理_第37张图片

脏读(Dirty Read):

  • 脏读是指一个事务读取了另一个事务尚未提交的数据。当一个事务读取到了另一个事务的未提交数据,如果另一个事务最终回滚,那么读取的数据就会是无效的,因此称为脏读。

修改丢失(Lost Update):

  • 修改丢失是指两个或多个事务同时读取同一数据,并尝试更新该数据,但只有一个事务的更改能够成功提交,导致其他事务的更新被丢失。这可能会导致数据的不一致性。

不可重复读(Non-Repeatable Read):

  • 不可重复读是指在同一事务中,两次读取相同数据,但在第二次读取时,数据已经发生了变化。这可以是因为另一个事务在两次读取之间修改了数据,导致数据不一致。

幻读(Phantom Read):

  • 幻读是指在同一事务中,两次执行相同的查询,但在第二次执行时,返回的数据集合发生了变化。这可以是因为另一个事务在两次查询之间插入、更新或删除了符合查询条件的数据行,导致数据集合不一致。

4.6 讲讲mysql隔离级别是怎么实现的

在这里插入图片描述
基于 MVCC 实

  • READ-COMMITTED(读取已提交)
  • REPEATABLE-READ

通过锁来实现的

  • SERIALIZABLE

4.7 为什么不建议用select *

1) 增加查询分析器解析成本,分析器就能分析所有的列
2) 增减字段,容易与resultMap 配置不一致
3)无用字段增加网络消耗、磁盘IO开销
对于第三点,真正害怕的是大字段,比如text。本来想查询一下 意见的反馈人名 ,或者是 查询博客的标题,结果因为懒或者不注意,写了select *., 查询的时候带出来这些 大字段。那么显然,这时候读取的内容数据就是真的比原先初衷要大很多

4)无法使用索引覆盖

4.8为什么使用B+树

1、B树只适合随机检索,而B+树同时支持随机检索和顺序检索;
2、B+树空间利用率更高
3、B+树查询效率更加稳定
4、 B+树范围查询性能更优
(根据空间局部性原理:如果一个存储器的某个位置被访问,那么将它附近的位置也会被访问)
5、B+树增删文件(节点)时,效率更高,不需要重新调整树

4.9 什么情况下会触发间隙锁(感觉好像不对)

  • 1.普通索引

    • 1 二级索引中存储的主键,会参于二级索引排序,比如age索引进行排序时,实际用的是(age,uid)来进行排序。而之所以会使用uid参与排序我想大部分原因应该是B+树内不允许存储相同的值。使用age,uid进行拼接之后可以保证所有的二级索引,在B+树中的值一定是惟一的。
      2 换句话说,我们无法单纯的锁住age=4这一条件,因为可能会存在(age,uid)= (4,1)/(4,2)/(4,5)等任意索引。
      3 二级索引在拼接时,由于age在前uid在后,因此age的值在一定程度上就代表了整个索引值。这也是为什么间隙锁可以锁住age=4这一条件。

    • 换句话说,我们无法单纯的锁住age=4这一条件,因为可能会存在(age,uid)= (4,1)/(4,2)/(4,5)等任意索引。

    • 也就是说如果条件为5,那么mysql会通过5查询左右两边的一个间隙,也就是比5小的第一个值和比5大的第一个值,然后加一个间隙锁,比如数据库还有两条数据的索引值为 3 和 7,那么mysql会加一个(3-5)[5](5-7]的这么一个间隙锁

  • 2.唯一索引的多行

  • 3.范围

    • SELECT 语句,涉及到一个范围的值(例如 SELECT * FROM mytable WHERE id > 10 AND id < 20;)。
    • UPDATE 语句,涉及到一个范围的值(例如 UPDATE mytable SET col1 = ‘foo’ WHERE id > 10 AND id < 20;)。
    • DELETE 语句,涉及到一个范围的值(例如 DELETE FROM mytable WHERE id > 10 AND id < 20;)。

4.10redolog和binlog的区别

  • redo log和binlog区别
    • 1 redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。

    • 2 redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。

    • 3 redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

    • 4 参数上

      • redolog三种策略
        • 0 :设置为 0 的时候,表示每次事务提交时不进行刷盘操作
        • 1 :设置为 1 的时候,表示每次事务提交时都将进行刷盘操作(默认值)
        • 2 :设置为 2 的时候,表示每次事务提交时都只把 redo log buffer 内容写入 page cache(文件系统缓存)
        • InnoDB 存储引擎有一个后台线程,每隔1 秒,就会把 redo log buffer 中的内容写到文件系统缓存(page cache),然后调用 fsync 刷盘
      • binlog三种策略
        • 0:write和fsync的时机,可以由参数sync_binlog控制,默认是0。为0的时候,表示每次提交事务都只write到page cache,由系统自行判断什么时候执行fsync。
        • 1:为了安全起见,可以设置为1,表示每次提交事务都会执行fsync,就如同 redo log 日志刷盘流程 一样
        • N:还有一种折中方式,可以设置为N(N>1),表示每次提交事务都write,但累积N个事务后才fsync
      • redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数我建议你设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。
      • sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数我也建议你设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。
    • 5 结构上

      • 二者都需要page cache
      • redolog多一个redo log buffer
      • binlog多一个binlog cache(因为一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache)

4.10 加 binlog日志格式

针对不同的使用场景,Binlog 也提供了可定制化的服务,提供了三种模式来提供不同详细程度的日志内容。

  • Statement 模式:基于 SQL 语句的复制(statement-based replication-SBR)
    • 在Statement模式下,Binlog 记录的是执行的 SQL 语句。这意味着 Binlog 中包含了复制操作所使用的原始 SQL 语句。
    • 这种模式相对较为轻量,因为它只记录 SQL 语句,不包含具体行数据的改变。
    • Statement模式适用于那些 SQL 语句相对简单,且对性能要求较高的场景。
  • Row 模式:基于行的复制(row-based replication-RBR)
    • Row模式下,Binlog 记录的是对行数据的更改。具体来说,它记录了哪些行被插入、更新或删除,以及这些行的新值。
    • 这种模式提供了最详细的变更信息,但可能会产生较大的Binlog文件,特别是在进行大规模数据更改时。
    • Row模式适用于需要完全精确的数据同步的场景,因为它记录了每个数据行的变更。
  • Mixed 模式:混合模式复制(mixed-based replication-MBR)
    • Mixed模式是一种混合模式,它根据具体的操作类型来选择记录 SQL 语句或行数据的更改。
    • 对于某些 SQL 语句,Binlog 中会记录原始 SQL 语句,类似于Statement模式。对于其他操作,如复杂的数据更改,Binlog 会记录行数据的更改,类似于Row模式。
    • Mixed模式试图平衡性能和详细程度的需求,适用于大多数情况下。

4.11 Explain结果中各个字段表示什么

id,查询语句每出现一个select关键字,就会被分配一个id
table 表名
type 单表的查询方式(全表扫描、索引)
possible_keys 可能用到的索引
key 实际上使用的索引
extra 一些额外的信息,比如排序、是否使用索引下推等

4.12存储拆分(分库分表)之后如何解决唯一主键问题

  • UUID:简单、性能好,没有顺序,没有业务含义,存在泄漏mac地址的风险

  • 数据库主键:实现简单,单调递增,具有一定的业务可读性,强依赖db、存在性能瓶颈,存在暴露业务信息的风险(比如在url请求中,判断order=100,就可以判断这个商家当天卖没卖到100份)

    • 数据库主键在单表中,比如是1-100,那分表之后,每张表不能再是1-100了,那就重复了,就要规定步长,比如第一张表db1存1、3、5,第二张表db2存2、4、6,但是这样的问题是,不好扩展。比如对于我们举得135、246的例子,已经排满了,没法加db3,就需要改步长,进行数据迁移,就很麻烦了
  • redis,mongodb,zk等中间件:增加了系统的复杂度和稳定性

  • 雪花算法

4.13 delete和drop和truncate的区别

面试八股整理_第38张图片

4.14 mysql主从同步流程是什么样的

主从同步有三种方式:

  • 异步复制
  • 同步复制
  • 半同步复制

我们这里着重讲一下异步复制:
默认情况下,MySQL 采用异步复制的方式,执行事务操作的线程不会等复制 Binlog 的线程。
面试八股整理_第39张图片

MySQL 主库在收到客户端提交事务的请求之后,会先写入 Binlog,然后再提交事务,更新存储引擎中的数据,事务提交完成后,给客户端返回操作成功的响应。

同时,从库会有一个专门的 复制线程,从主库接收 Binlog,然后把 Binlog 写到一个中继日志里面,再给主库返回复制成功的响应。

从库还有另外一个 回放 Binlog 的线程,去读中继日志,然后回放 Binlog 更新存储引擎中的数据

提交事务和复制这两个流程在不同的线程中执行,互相不会等待,这是异步复制。

在异步复制的情况下,为什么主库宕机存在丢数据的风险?为什么读写分离存在读到脏数据的问题?

产生这些问题,都是因为 异步复制它没有办法保证数据能第一时间复制到从库上。

异步复制的优势是性能好,缺点是数据的安全性比较差。在某一刻主从之间的数据差异可能较大,主机挂掉之后从机接管,可能会丢失一部分数据。

同步复制和半同步复制其实就能解决上述问题,但是缺点也很明显,性能差,尤其是同步

全同步复制跟半同步复制的区别是,全同步复制必须收到所有从库的ack,才会提交事务。

同步复制这种方式在实际项目中,基本上没法用,原因有两个:

  • 一是性能很差,因为要复制到所有节点才返回响应;
  • 二是可用性也很差,主库和所有从库任何一个数据库出问题,都会影响业务。

全同步复制的数据一致性最好,但是性能也是最差的。

为了解决这个问题,MySQL 从 5.7 版本开始,增加一种 半同步复制(Semisynchronous Replication)的方式。

  • 异步复制是,事务线程完全不等复制响应;
  • 同步复制是,事务线程要等待所有的复制响应;
  • 半同步复制介于二者之间,事务线程不用等着所有的复制成功响应,只要一部分复制响应回来之后,就可以给客户端返回了。

master更新操作写入binlog之后会主动通知slave,slave接收到之后写入relay log 即可应答,master只要收到至少一个ack应答,则会提交事务。

可以发现,相比较于异步复制,半同步复制需要依赖至少一个slave将binlog写入relay log,在性能上有所降低,但是可以保证至少有一个从库跟master的数据是一致的,数据的安全性提高。

4.15 mysql超大分页怎么处理

  • 使用LIMIT和OFFSET子句:LIMIT子句用于指定返回的行数,OFFSET子句用于指定从哪一行开始。在分页查询中,要谨慎使用OFFSET,因为它会跳过大量行,可能导致性能问题。如果可能的话,可以考虑其他方法,如基于主键范围的分页。
  • 使用索引:确保查询中涉及到的列有适当的索引。索引可以显著加速数据检索。特别是,要确保用于排序和过滤的列有索引,以减少查询时间。
  • 使用缓存:对于不经常变化的数据,可以考虑将分页结果缓存起来,以减少对数据库的负载。这需要谨慎处理数据一致性。
  • 数据分页预处理:如果可能的话,可以在后台对数据进行分页预处理,将分页结果保存到临时表中。然后,应用程序可以从临时表中检索结果,而不是每次都进行分页查询。
  • 垂直分割和水平分割:考虑将数据表拆分成更小的表,以减少每个查询返回的数据量。垂直分割是指将表拆分成具有不同列的表,而水平分割是将表拆分成具有相同列但包含不同行的表。

丁奇 45讲 金句

  • 一、基础结构

    • 连接器:连接器负责跟客户端建立连接、获取权限、维持和管理连接(平时用账号密码登录mysql)
    • 查询缓存:往往弊大于利
    • 分析器 词法分析+语法分析
    • 优化器:
      • 在表里面有多个索引的时候,决定使用哪个索引
      • 或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序
    • 执行器:判断权限之后执行
  • 二、日志系统 redo log(重做日志)和 binlog(归档日志)

    • WAL技术:WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘
    • 有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe。
    • redo log和binlog区别
      • redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
      • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
      • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
      • redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数我建议你设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。
      • sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数我也建议你设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。
  • 三、事务隔离

    • MyISAM 引擎就不支持事务

    • 隔离级别

      • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
      • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
      • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
      • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
    • 在实现上,数据库里面会创建一个视图readview,访问的时候以视图的逻辑结果为准

      • 在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。
      • 在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。
      • 这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;
      • 而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
    • 在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。(也就是readView+undolog)

    • 回滚日志总不能一直保留吧,什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的 read-view 的时候。

    • 尽量不要使用长事务。长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

    • 事务的启动方式

    • 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。

    • set autocommit=0,这个命令会将这个线程的自动提交关掉

      • 有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。因此,我会建议你总是使用 set autocommit=1, 通过显式语句的方式来启动事务。
  • 四、深入浅出索引 上

    • 自己的理解:每张表至少有一个主键索引,因此每张表都有多个B+树,新建索引就是新增一个B+树,查询不走索引就是遍历主B+树(主键索引)
    • 不论是删除主键还是创建主键,都会将整个表重建,所以只有重新建表才能重建索引,不然索引文件会越来越多!!!
    • 4.1 索引的常见模型
    • 哈希表:
      • 因为不是有序的,所以哈希索引做区间查询的速度是很慢的。
      • 哈希表这种结构适用于只有等值查询的场景
    • 有序数组
      • 有序数组在等值查询和范围查询场景中的性能就都非常优秀
      • 但是,在需要更新数据的时候就麻烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。
      • 有序数组索引只适用于静态存储引擎,比如你要保存的是 2017 年某个城市的所有人口信息,这类不会再修改的数据。
    • 搜索树
      • 实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。
      • 你可以想象一下一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块。在机械硬盘时代,从磁盘随机读一个数据块需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间,这个查询可真够慢的。
      • 为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不应该使用二叉树,而是要使用“N 叉”树。这里,“N 叉”树中的“N”取决于数据块的大小。以 InnoDB 的一个整数字段索引为例,这个 N 差不多是 1200。
      • N 叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。
    • 4.2 InnoDB 的索引模型
      • 索引类型分为主键索引和非主键索引
      • 基于主键索引和普通索引的查询有什么区别?
        • 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
        • 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。
        • 也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。
    • 4.3 索引维护
      • 页分裂:

        • B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如果插入新的行 ID 值为 700,则只需要在 R5 的记录后面插入一个新记录。如果新插入的 ID 值为 400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。而更糟的情况是,如果 R5 所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约 50%。
      • 页合并:

        • 当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。
      • 自增主键:在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。

        • 自增主键关于性能和空间
          • 自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
          • 除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约 20 个字节,而如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节。
          • 显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。
      • 什么场景适合用业务字段直接做主键的:

        • 只有一个索引;该索引必须是唯一索引。
        • 由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。
  • 穿插一下什么是最左匹配
    最左优先,以最左边的为起点任何连续的索引都能匹配上。同时遇到范围查询(>、<、between、like)就会停止匹配。
    例如:b = 2 如果建立(a,b)顺序的索引,是匹配不到(a,b)索引的;但是如果查询条件是a = 1 and b = 2或者a=1(又或者是b = 2 and b = 1)就可以,因为优化器会自动调整a,b的顺序。再比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,因为c字段是一个范围查询,它之后的字段会停止匹配。

  • 五、深入浅出索引 下

    • 5.1 覆盖索引:在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引

      • 由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。
    • 5.2 单独为一个不频繁的请求创建一个(身份证号,地址)的索引又感觉有点浪费,怎么办,

      • 可以创建联合索引
      • 那么,如果既有联合查询,又有基于 a、b 各自的查询呢?查询条件里面只有 b 的语句,是无法使用 (a,b) 这个联合索引的,这时候你不得不维护另外一个索引,也就是说你需要同时维护 (a,b)、(b) 这两个索引。这时候,我们要考虑的原则就是空间了
    • 5.3 索引下推

      • 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
      • 比如mysql5.6之前,没有索引下推,如果我们select语句中用了范围查找,比如范围卡在>ID3,那么IDS之后的数据就得一个一个回表,后面的判断条件就得在回表的时候查找判断了。而有了索引下推之后,虽然根据最左匹配索引停了,但是后面的匹配条件不符合的,不会回表,从而减少了回表次数。
    • 5.4 课堂上留的一个问题

    • 一个表中有联合主键(a,b),字段c也有索引,那么此时对于下面两个语句,需要"ca"和"cb"索引么

      • select * from geek where c=N order by a limit 1;
        select * from geek where c=N order by b limit 1;
      • 首先,主键 a,b 的聚簇索引组织顺序相当于 order by a,b ,也就是先按 a 排序,再按 b 排序,c 无序。
      • 其次,索引 ca 的组织是先按 c 排序,再按 a 排序,同时记录主键
      • 索引 cb 的组织是先按 c 排序,在按 b 排序,同时记录主键
      • 可以发现 ca的方式和主键本身的方式其实是一样的,所以ca索引没有必要构造,cb是有必要的
    • 5.5 我自己的总结

      • 1、覆盖索引:如果查询条件使用的是普通索引(或是联合索引的最左原则字段),查询结果是联合索引的字段或是主键,不用回表操作,直接返回结果,减少IO磁盘读写读取正行数据
      • 2、最左前缀:联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符
      • 3、联合索引:根据创建联合索引的顺序,以最左原则进行where检索,比如(age,name)以age=1 或 age= 1 and name=‘张三’可以使用索引,单以name=‘张三’ 不会使用索引,考虑到存储空间的问题,还请根据业务需求,将查找频繁的数据进行靠左创建索引。
      • 4、索引下推:like 'hello%’and age >10 检索,MySQL5.6版本之前,会对匹配的数据进行回表查询。5.6版本后,会先过滤掉age<10的数据,再进行回表查询,减少回表率,提升检索速度
  • 六、全局锁和表锁

    • 全局锁和表锁是Server层实现的
    • 6.1 锁的种类
      • 全局锁
      • 表锁
      • 行锁
    • 6.2 全局锁
      • 全局锁就是对整个数据库实例加锁
      • MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
      • 全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。不加锁的话,备份得到的视图是逻辑不一致的
      • 因此可以用之前讲过的可重复读隔离级别来替代这种锁,隔离级别可以保证获得一致性视图。但是前提是!!引擎支持事务的隔离级别!!因此MyISAM只支持FTWRL命令。
      • 设置数据库为只读模式也可以替代全局锁的功能,但是readonly有弊端
        • 1 readonly可能被用来判断一个库是主库还是从库
        • 2 异常时,全局锁会释放,而只读状态不会改变。
    • 6.3 表级别锁
      • MYSQL表级别的锁有两种

        • 表锁
        • 元数据锁
      • 6.3.1 表锁

        • 语法是 lock tables … read/write
        • 与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。
        • 在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式
      • 6.3.2 元数据锁MDL

        • MDL作用是防止DDL和DML并发的冲突

        • MDL释放的情况就是,事务提交

        • MDL 不需要显式使用,在访问一个表的时候会被自动加上。

        • MDL 的作用是,保证读写的正确性

        • 当对一个表做增删改查操作(DQL DML)的时候,加 MDL 读锁;当要对表做结构变更操作(DDL)的时候,加 MDL 写锁

          • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
          • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
        • 存在的问题就是,如果MDL加锁过程中,发生了DML和DDQ读锁写锁的互斥,则会发生阻塞,且之后再进来的DML、DQL也无法执行。

          • 一般的解决方法:暂停 DDL,或者 kill 掉这个长事务
          • 好的解决方法:在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。
            • MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT n 这个语法。
        • 事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。

  • 七、行锁功过

    • 评论区的好说法:行锁是通过锁住索引来实现的,因此如果update时,where查询的列没建索引,更新就是走主键索引树,逐行扫描满足条件的行,等于将主键索引所有的行上了锁

    • 表锁通过lock table和unlock table,行锁通过begin 和 commit

    • MySQL 的行锁是在引擎层由各个引擎自己实现的,如innoDB
      MyISAM 引擎就不支持行锁。
      不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。

    • 概念:行锁就是针对数据表中行记录的锁。这很好理解,比如事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。

    • 7.1 两阶段锁

      • 在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
      • 如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。这就最大程度地减少了事务之间的锁等待,提升了并发度。
    • 7.2 死锁和死锁检测

      • 概念:当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。
      • 解决方法
        • 1.直接进入等待,设置超时时间,直到超时。但是这个时间不好设置,设置短了的话,可以误伤到普通的锁等待,长的坏,等待时间又无法接受。因此一般用2
        • 2.发起死锁检测。主动回滚死锁中的某一个事务,让其他事务继续执行。但是每个被堵住的线程都检测,会消耗大量的CPU资源。那么怎么解决由这种热点行更新导致的性能问题呢?问题的症结在于,死锁检测要耗费大量的 CPU 资源。
          • 2.1 一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。
          • 2.2 另一个思路是控制并发度。并发控制要做在数据库服务端。如果你有中间件,可以考虑在中间件实现;如果你的团队有能修改 MySQL 源码的人,也可以做在 MySQL 里面。基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在 InnoDB 内部就不会有大量的死锁检测工作了。
          • 因此,减少死锁的主要方向,就是控制访问相同资源的并发事务量。!
    • 7.3 有一个问题:

      • 如果你要删除一个表里面的前 10000 行数据,有以下三种方法可以做到:
        第一种,直接执行 delete from T limit 10000;
        第二种,在一个连接中循环执行 20 次 delete from T limit 500;
        第三种,在 20 个连接中同时执行 delete from T limit 500。
        选哪一种呢?
      • 答案:第二种
        第一种方式,单个语句占用时间长,锁的时间也比较长;而且大事务还会导致主从延迟。
        第三种方式(即:在 20 个连接中同时执行 delete from T limit 500),会人为造成锁冲突。
    • 关于死锁可能有两个误解

        1. 一致性读不会加锁,就不需要做死锁检测;
        1. 并不是每次死锁检测都都要扫所有事务。比如某个时刻,事务等待状态是这样的:
          B在等A,
          D在等C,
          现在来了一个E,发现E需要等D,那么E就判断跟D、C是否会形成死锁,这个检测不用管B和A
  • 八、事务到底是隔离的还是不隔离的

    • 8.1 “快照”在 MVCC 里是怎么工作的?

    • 在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。

      • 注意,假如数据库有100G,快照并不是拷贝100G数据,具体怎么实现呢
        • InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

        • 而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

        • 也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id,就是一个记录被多个事务连续更新后的状态。
          面试八股整理_第40张图片

        • 前面说过,语句更新会生成 undo log,图中的三个箭头,就是 undo log。而 V1、V2、V3 版本并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来

        • 有了以上原理,接下来探究快照怎么生成。

          • 1 在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。
          • 2 数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
          • 3 而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。这个视图数组把所有的 row trx_id 分成了几种不同的情况。
          • 在这里插入图片描述
        • 对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能。

          • 1 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
          • 2 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
          • 3 如果落在黄色部分,那就包括两种情况
            • a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,或者是版本已提交,但是是在视图创建后提交的, 不可见;
            • b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。
        • 通过对数据版本进行限制,相当于只能取到“合规的最新的版本”,事实上是个静态的快照,因此InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

    • 8.1 很好的总结:其实不用判断那么多数字,就按三条规则来

      • 版本未提交,不可见;
      • 版本已提交,但是是在视图创建后提交的,不可见;
      • 版本已提交,而且是在视图创建前提交的,可见。
    • 8.2 更新逻辑-当前读

      • 刚才讲的是快照读,那么什么情况需要用到当前读呢

        • 更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
          因此更新时,获得的都是当前读,而不是快照读
        • 除了 update 语句外,select 语句如果加锁,也是当前读。
        • 所以,如果把事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以读到版本号是 101 的数据,返回的 k 的值是 3。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。
          • 如mysql> select k from t where id=1 lock in share mode;
          • mysql> select k from t where id=1 for update;
      • 可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待(之前提的两阶段)。

      • 而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

        • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
        • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
    • 8.3 一个问题:要把所有“字段 c 和 id 值相等的行”的 c 值清零,但是却发现了一个“诡异”的、改不掉的情况

五、Redis

5.1 Redis除了做缓存还能做什么

面试八股整理_第41张图片

5.2 redis不同数据类型的应用场景都有哪些,底层都是如何

String(字符串)

  • 需要存储常规数据的场景
    • 缓存 session、token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)
    • 相关命令 : SET、GET。
  • 需要计数的场景
    • 用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数
    • 相关命令 :SET、GET、 INCR、DECR 。
  • 分布式锁
    • SETNX key value 命令可以实现一个最简易的分布式锁

List(列表)

  • 信息流展示
    • 最新文章、最新动态。
    • 相关命令 : LPUSH、LRANGE。
  • 消息队列
    • 不建议这样做

Set(集合)

  • 需要存放的数据不能重复的场景
    • 文章点赞、动态点赞等场景
    • 相关命令:SCARD(获取集合数量) 。
  • 需要获取多个数据源交集、并集和差集的场景
    • 共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集) 、订阅号推荐(差集+交集) 等场景。
    • 相关命令:SINTER(交集)、SINTERSTORE (交集)、SUNION (并集)、SUNIONSTORE(并集)、SDIFF(差集)、SDIFFSTORE (差集)
  • 需要随机获取数据源中的元素的场景
    • 抽奖系统、随机
    • 相关命令:SPOP(随机获取集合中的元素并移除,适合不允许重复中奖的场景)、SRANDMEMBER(随机获取集合中的元素,适合允许重复中奖的场景)

hash

  • 对象数据存储场景
    • 用户信息、商品信息、文章信息、购物车信息。
    • 相关命令 :HSET (设置单个字段的值)、HMSET(设置多个字段的值)、HGET(获取单个字段的值)、HMGET(获取多个字段的值)

Zset (Sorted Set(有序集合))

  • 需要随机获取数据源中的元素根据某个权重进行排序的场景
    • 排行榜
    • 相关命令 :ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。
  • 需要存储的数据有优先级或者重要程度的场景
    • 优先级任务队列
    • 相关命令 :ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)

HyperLogLogs(基数统计)

  • 数量量巨大(百万、千万级别以上)的计数场景
    • 热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计
    • 相关命令 :PFADD、PFCOUNT

Bitmap (位存储)

  • 需要保存状态信息(0/1 即可表示)的场景
    • 签到情况
    • 用户行为统计(比如是否点赞过某个视频)
    • 相关命令 :SETBIT、GETBIT、BITCOUNT、BITOP

Geospatial (地理位置)

  • 需要管理使用地理空间数据的场景
    • 附近的人
    • 相关命令: GEOADD、GEORADIUS、GEORADIUSBYMEMBER

底层实现:

面试八股整理_第42张图片

String(字符串)

  • 不是C语言本身的字符串,而是基于 C 语言编写的 SDS
    • Simple Dynamic String,简单动态字符串
    • SDS 共有五种实现方式 SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。
  • 后四种实现都包含了下面这 4 个属性
    • len :字符串的长度也就是已经使用的字节数
    • alloc:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小
    • buf[] :实际存储字符串的数组
    • flags :低三位保存类型标志
  • SDS 相比于 C 语言中的字符串有如下提升
    • 可以避免缓冲区溢出
    • 获取字符串长度的复杂度较低
    • 减少内存分配次数
    • 二进制安全
    • 具体见网站

所谓压缩链表

其实就是一个数组,标注了一些属性,有利于快速寻找首尾
在这里插入图片描述
redis什么时候采用压缩链表呢,以下两个条件同时满足
1.有序集合保存的元素数量小于128个
2.有序集合保存的所有元素的长度小于64字节

所谓跳表

增加了多级索引,通过多级索引位置的转跳,实现了快速查找元素。
面试八股整理_第43张图片
数据量特别大时,跳表的时间复杂度是logn,类似于二分法

为什么zset不用红黑树或者二叉树呢,不也是logn么
因为zset经常范围查找,跳表效率比红黑树高

List的底层是(双向链表和压缩链表)

Hash的底层是(压缩链表和哈希表)

Set的底层是(整数数组和哈希表)

Sorted Set底层(压缩链表和跳表)

HyperLogLogs(基数统计)

Bitmap (位存储)

Geospatial (地理位置):基于 Sorted Set 实现

5.3 Redis为什么是单线程

  • 基于 Reactor 模式设计开发了一套高效的事件处理模型
    • Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石
    • Reactor模式本质上指的是使用”IO多路复用(IO multiplexing) + 非阻塞IO(non-blocking IO)”的模式
  • 事件处理模型对应的是 Redis 中的文件事件处理器
    • 由于文件事件处理器是单线程方式运行的,所以我们一般都说 Redis 是单线程模型

文件事件处理器

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

面试八股整理_第44张图片

  • 通过 IO 多路复用程序 来监听来自客户端的大量连接, I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗

  • 总结

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

redis单线程却快的原因:

  • 1.纯内存操作
  • 2.核心是基于非阻塞的IO多路复用机制
  • 3.单线程反而避免了多线程频繁的上下文切换带来的性能问题。

Redis6.0 之前为什么不使用多线程?

  • 单线程编程容易并且更容易维护;
  • Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
  • 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

5.4 redis的删除和淘汰机制是怎样的

  • 过期的数据的删除策略(如何删除)
    • 定时过期(对内存友好,对CPU不友好):

      • 每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量
    • 惰性删除(对CPU友好,对内存不友好)

      • 只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除
    • 定期删除

      • 每隔一段时间抽取一批 key 执行删除过期 key 操作
      • 并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
    • 区别:

      • 定期删除对内存更加友好,惰性删除对 CPU 更加友好
        • 所以 Redis 采用的是 定期删除+惰性/懒汉式删除
    • 遗留问题

      • 无论怎么删除,还是有可能漏掉过期key,导致OOM,因此需要淘汰机制
  • Redis 内存淘汰机制了解么?
    • volatile-LRU

      • 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
    • volatile-ttl

      • 从已设置过期时间的数据集中挑选将要过期的数据淘汰
    • volatile-random

      • 从已设置过期时间的数据集中任意选择数据淘汰
    • allkeys-lru√√√⭐⭐⭐

      • 当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(最常用的)
    • allkeys-random

      • 从数据集中任意选择数据淘汰
    • no-eviction

      • 禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错
    • volatile-lfu

      • 从已设置过期时间的数据集中挑选最不经常使用的数据淘汰
    • allkeys-lfu

      • 当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

5.5 讲一下Redis持久化RDB和AOF的区别

RDB是存储一个快照,AOF是每执行一条就放入AOF BUF缓存中,并同步到硬盘中一次

  • 快照 RDB持久化

    • Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本
    • 目的
      • 可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能)
      • 还可以将快照留在原地以便重启服务器的时候使用。
    • 快照持久化是 Redis 默认采用的持久化方式
  • 只追加文件 AOF 持久化

    • 实时性更好

      • 已成为主流的持久化方案
    • 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。

    • appendfsync 配置

      • appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
      • appendfsync everysec #每秒钟同步一次,显式地将多个写命令同步到硬盘
      • appendfsync no #让操作系统决定何时进行同步
    • 建议appendfsync everysec

      • Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据
    • AOF重写

      • 当 AOF 变得太大时
        • Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件
        • 新AOF和旧AOF状态一样,但体积更小

面试八股整理_第45张图片

二者如何选择!!!!

  • RDB 比 AOF 优秀的地方 :

    • 1 RDB比AOF小,更适合备份和灾难恢复

      • RDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会必 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF。新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过, Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
    • 2 RDB恢复速度快很多

      • 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。
  • AOF 比 RDB 优秀的地方 :

    • 1 AOF安全性强于RDB

      • RDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比较繁重的, 虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。
    • 2 老版本RDB不兼容新版本

      • RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。
    • 3 AOF包含的日志 易于理解和解析

      • AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行FLUSHALL命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态

因此Redis4.0之后进行了融合

  • Redis 4.0 对于持久化机制做了什么优化?
    • Redis 4.0 开始支持 RDB 和 AOF 的混合持久化
    • AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

5.6 缓存穿透、缓存雪崩、缓存击穿讲讲,还有就是怎么解决

缓存穿透

  • 问题

    • 大量请求的 key 根本不存在于缓存中,也不存在于数据库中
    • 请求最终都落到了数据库上,对数据库造成了巨大的压力
  • 解决办法

    • 缓存无效 key
      • 需要设置过期时间
      • 不然黑客每次构建不同的key,就有一堆null
    • 布隆过滤器:可能误判
      • 判断一个给定数据是否存在于海量数据中

      • 具体做法

        • 把所有可能存在的请求的值都存放在布隆过滤器中
        • 用户请求时判断是否存在,存在就继续,不存在返回
      • 原理:

        • 加入时
          • 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
          • 根据得到的哈希值,在位数组中把对应下标的值置为 1。
        • 判断一个元素是否存在时
          • 对给定元素再次进行相同的哈希计算;
          • 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
        • 为什么会误判:
          • 不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。
          • 所以布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在

缓存雪崩

  • 问题
    • 缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上
  • 解决办法
    • 针对 Redis 服务不可用的情况:
      • 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
      • 限流,避免同时处理大量的请求。
    • 针对热点缓存失效的情况:
      • 设置不同的失效时间比如随机设置缓存的失效时间。
      • 设置二级缓存。
      • 缓存永不失效(不太推荐,实用性太差)。

缓存击穿

  • 问题

    • 请求的 key 对应的是 热点数据,存在于数据库中,但不存在于缓存中。导致瞬时大量的请求直接打到了数据库上,弄宕机了
  • 解决办法

    • 设置逻辑过期(也就是永不过期)
    • 互斥锁
    • 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期
  • 缓存击穿和穿透的区别

    • 缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。
    • 缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。

5.7 ⭐⭐⭐如何保证缓存和数据库数据的一致性?⭐⭐⭐(三种读写策略)

这就涉及三种读写策略

  • Cache Aside Pattern(旁路缓存模式)重要⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

  • Read/Write Through Pattern(读写穿透)

  • Write Behind Pattern(异步缓存写入)

关于缓存一致性的方案选择,具体看这篇文章

言简意赅地总结如下:

  • 情况一:

    • 为什么不采用先更新数据库再更新缓存 / 先更新缓存再更新数据库这样的“同时更新方案”
    • 回答:
      • 首先是这样做存在逻辑上的异常。无论是哪个先,只要第一步成功,第二步失败,就会有问题。
      • 其次是并发问题,两个线程同时操作,总共四步操作,这四步交叉,同样会有问题,会覆盖掉数据。
      • 第三还有缓存利用率的问题,只要db发生变更,就一定要更新缓存,这是不合理的,大部分缓存数据并不会被马上读取,浪费了资源。而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列「计算」得出一个值,才把这个值才写到缓存中。
      • 因此需要删除缓存
  • 情况二:

    • 先删除缓存,后更新数据库
    • 回答:
      • 首先只要是第二步有错误,就会有问题,所以这里先不描述
      • 这里给个特殊情况,当发生「读+写」并发时,存在数据不一致的情况(且概率不算很小)
        • 线程 A 要更新 X = 2(原值 X = 1)
        • 线程 A 先删除缓存
        • 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
        • 线程 A 将新值写入数据库(X = 2)
        • 线程 B 将旧值写入缓存(X = 1)
  • 情况三:

    • 先更新数据库,后删除缓存
    • 回答:
      • 首先只要是第二步有错误,就会有问题,所以这里先不描述
      • 给个特殊情况,也有不一致
        • 1.缓存中 X 不存在(数据库 X = 1)
        • 2.线程 A 读取数据库,得到旧值(X = 1)
        • 3.线程 B 更新数据库(X = 2)
        • 4.线程 B 删除缓存
        • 5.线程 A 将旧值写入缓存(X = 1)
      • 但是上述概率很小,因为需要满足条件“更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)”,而写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的

对于第二步失败的问题,应该“异步重试”,消息队列或者canal订阅binlog

针对于读写分离+主从库延迟 这一问题,则需要延迟双删

旁路缓存模式

  • 对于写
    • 先更新 db
    • 然后直接删除 cache 。
  • 对于读
    • 从 cache 中读取数据,读取到就直接返回
    • cache 中读取不到的话,就从 db 中读取数据返回
    • 再把数据放到 cache 中。

几个问题:

  • 在写数据的过程中,可以先删除 cache ,后更新 db 么?

    • 不可以
    • 可能会造成 数据库(db)和缓存(Cache)数据不一致
    • 请求 1 先把 cache 中的 A 数据删除 -> 请求 2 从 db 中读取数据->请求 1 再把 db 中的 A 数据更新
  • 在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?

    • 有,一个是并发问题,一个是第二步错误问题
    • 对于并发问题,也就是在重建缓存的过程中,又有别的线程来改变了数据库。理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。

面试八股整理_第46张图片

    • 对于第二步执行「失败」导致数据不一致的问题,无论是先更新数据库,再删除缓存,还是先删除缓存再重建数据库,第二步失败肯定都 是不行的,可以采用两种方式解决。
        1. 消息队列异步重试
        1. 订阅变更日志

面试八股整理_第47张图片

旁路缓存 这种方法也不是没有缺点
面试八股整理_第48张图片

读写穿透

和旁路不同:
1.写的时候redis存在的话,先写到redis,再更新db
2.读的时候,如果缓存没有,先从db存入redis,再从redis返回,总之都是从redis读数据(旁路是没有的话直接从db返回,再去更新缓存)

面试八股整理_第49张图片

异步缓存写入

  • 只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
  • 应用场景
    • 消息队列中消息的异步写入磁盘、
    • MySQL 的 Innodb Buffer Pool 机制都用到了这种策略
  • 优点
    • 写性能非常高
    • 非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量

5.8 redis集群策略

Redis提供了三种集群策略:

  • 1 主从模式:这种模式比较简单,主库可以读写,并且会和从库进行数据同步,这种模式下,客户端直接连主库或某个从库,但是但主库或从库宕机后,客户端需要手动修改IP,另外,这种模式也比较难进行扩容,整个集群所能存储的数据受到某台机器的内存容量,所以不可能支持特大数据量
  • 2 哨兵模式:这种模式在主从的基础上新增了哨兵节点,但主库节点宕机后,哨兵会发现主库节点宕机,然后在从库中选择一个库作为进的主库,另外哨兵也可以做集群,从而可以保证但某一个哨兵节点宕机后,还有其他哨具栉点可以继续工作,这种模式可以比较好的保证Redis集群的高可用,但是仍然不能很好的解决Redis的容量上限问题。
  • 3 Cluster模式: Cluster模式是用得比较多的模式,它支持多主多从,这种模式会按照key进行槽位的
    分配,可以使得不同的key分散到不同的主节点上,利用这种模式可以使
    得整个集群支持更大的数据容量,同时每个主节点可以拥有自己的多个从节点,如果该主节点宕机,会从它的从节点中选举一个新的主节点。

对于这三种模式,如果Redis要存的数据量不大,可以选择哨兵模式,如果Redis要存的数据量大,并且需要持续的扩容,那么选择Cluster模式.

5.9 讲一下延时双删

一般我们在更新数据库数据时,需要同步redis中缓存的数据 所以我们一般会给出两种方案:

第一种方案:先执行update操作,再执行缓存清除。

第二种方案:先执行缓存清除,再执行update操作。

都有问题,可以延时双删

1)先删除缓存

2)再写数据库

3)休眠500毫秒(根据具体的业务时间来定)

4)再次删除缓存。

那么,这个500毫秒怎么确定的,具体该休眠多久呢?

需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

自己总结:无论是先删除缓存还是先更新数据库,都有可能把旧值重新存入redis,因此睡眠时间>这个存入的时间,再把它删掉,一致性的可能性就更大了
延时双删好文章

5.10 redis的zset怎么翻页

假设你有一个ZSET存储了一些有序数据,现在你想要按页获取数据。

首先,你需要知道每页的大小(每页包含多少个元素)以及要获取的页数。假设每页大小为N,要获取第M页的数据。

以下是一种基本的方法:

  1. 使用ZCARD命令获取有序集合的总元素个数,假设为total。

  2. 计算页数total_pages,即总元素数除以每页大小,向上取整。

  3. 计算起始索引start_index,即(M - 1) * N,这是第M页的起始位置,索引从0开始。

  4. 使用ZRANGE命令获取指定范围的元素,范围为start_index到start_index + N - 1。

5.11 redis的key怎么存储的

  • 字典结构(Dict):Redis内部使用了哈希表(Hash Table)来实现Key的存储和查找。这使得Redis能够以常数时间(O(1))的复杂度来执行插入、删除和查找操作。哈希表的结构使得Redis可以高效地处理大量的Key。

  • 分散存储:Redis将Key按照特定的规则散列到不同的哈希槽(Hash Slot)中,然后将这些哈希槽均匀地分布在多个物理节点上。这种分散存储方式使得Redis可以构建分布式数据库,称为Redis集群(Redis Cluster),实现数据的水平扩展和高可用性。

如果多个key哈希到一起呢

在Redis中,如果多个Key被散列到哈希表的同一个位置(即哈希冲突),Redis采用了开放地址法(Open Addressing)来处理这种情况。开放地址法是一种解决哈希冲突的方法,它的基本思想是在哈希表中查找下一个可用的位置来存储冲突的Key,而不是简单地在同一个位置上覆盖原有的Key。

Redis使用的具体开放地址法是线性探测(Linear Probing),它的工作方式如下:

  1. 当发生哈希冲突时,Redis会从冲突位置开始向后逐个检查每个连续的位置,直到找到一个空闲的位置或达到哈希表的末尾。

  2. Redis将冲突的Key插入到找到的空闲位置中。

  3. 如果哈希表已满(没有足够的空闲位置),Redis会根据需要进行扩容,以增加哈希表的大小,从而减少冲突的概率。

六、操作系统

6.1 加 为什么要设置一个内核态

  • 为了保证系统的稳定性、安全性,需要在系统中划分内核态、用户态。所有涉及IO操作、内存操作等,均在内核态中完成,因为当这些操作出现差错时,可能会导致整个计算机系统的崩溃。用户写的程序可能是含有导致这些操作出现差错的bug的,所以,用户编写的不涉及IO、内存等操作的程序在用户态中完成,而涉及这些操作时,则需要进行用户态到内核态的切换。将实际操作交付给内核态,内核态完成操作后,将结果传递至用户态。
  • 注意,用户态、内核态之间的切换是十分耗费性能资源的,不过再耗资源,也比不安全强。

6.1 加 进程创建的过程是怎样的

1 分配进程标识符:

  • 操作系统为新进程分配一个唯一的进程标识符(通常是一个整数或句柄),以便在系统中唯一标识该进程

2分配资源:

  • 新进程需要分配必要的资源,包括内存空间、CPU 时间片、文件描述符等。这些资源的分配可能受到操作系统的策略和限制的影响。

3初始化进程控制块(PCB):

  • 操作系统创建一个数据结构,通常称为进程控制块(PCB),用于存储有关该进程的重要信息,如进程状态、程序计数器、寄存器值、打开的文件、进程优先级等。

4 加载程序代码:

  • 操作系统加载要执行的程序代码,通常从可执行文件或其他源中读取程序的二进制数据,并将其加载到新进程的内存空间中。

5 设置上下文:

  • 操作系统设置新进程的初始上下文,包括设置堆栈指针、程序计数器等寄存器的初始值。

6 分配堆栈空间:

  • 操作系统为新进程分配堆栈空间,用于存储函数调用和局部变量。堆栈通常位于进程的内存空间中,并根据需要动态增长。

7 终止进程:

  • 进程执行完其任务或由于某种原因需要终止时,它会通知操作系统,操作系统会进行清理工作,释放资源,并标记进程为终止状态。

6.1 进程的两个状态讲一下

  • 用户态(user mode) : 用户态运行的进程可以直接读取用户程序的数据。

  • 系统态(kernel mode):可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。

  • 在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成(系统调用就是在用户态中,调用系统态级别的子功能)

    • 设备管理。完成设备的请求或释放,以及设备启动等功能。
    • 文件管理。完成文件的读、写、创建及删除等功能。
    • 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
    • 进程通信。完成进程之间的消息传递或信号传递等功能。
    • 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。

6.2 进程和线程的区别

  • 进程是资源分配的最小单位,而线程是计算机中独立运行、CPU调度的最小单元
  • 一个进程中可以有多个线程,多个线程共享进程的堆和方法区
    • 但是每个线程有自己的程序计数器、虚拟机栈** 和 本地方法栈

6.3 进程间通信方式有哪些 ⭐⭐⭐(七种)

  • 管道/匿名管道(Pipes) :

    • 用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
    • 存在于内存
  • 有名管道(Names Pipes) :

    • 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
    • 存在于磁盘
  • 信号(Signal) :

    • 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生
  • 消息队列

    • 存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除
    • 消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。
  • 信号量(Semaphores) :

    • 信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步
  • 共享内存(Shared memory) :

    • 使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
  • 套接字(Sockets) :

    • 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。

6.4 进程调度算法 ⭐⭐⭐

  • 先到先服务(FCFS)调度算法
  • 短作业优先(SJF)的调度算法
  • 时间片轮转调度算法 :
  • 多级反馈队列调度算法
    • 既能使高优先级的作业得到响应又能使短作业(进程)迅速完成
  • 优先级调度
    • 具有相同优先级的进程以 先到先服务方式执行

(没有LRU哦)

6.5 讲讲操作系统怎么进行内存管理的

好的面试官,我分几个角度来说吧
首先是内存管理方式,一般我们不用连续的分配方式(块式管理),而是用非连续的分配管理方式,包括

  • 页式管理
  • 段式管理
  • 段页式管理机制

简单对这几个概念解释一下的话

  • 页式管理通过页表对应逻辑地址和物理地址。把主存分为大小相等且固定的一页一页的形式,提高了内存利用率,减少了碎片。
  • 段式管理则是逻辑单位,可以更好满足用户的需求
  • 段页式管理则是把主存先分成若干段,每个段又分成若干页
  • 物理上应该都是页

除了这几个以外,常用的就是快表和多级页表

  • 快表相当于是个cache,加快速度,在cache里也存了逻辑地址对应的物理地址。如果快表里没有才去页表,并把映射添加到快表里
  • 快表的单位应该也是页(物理上都是页),当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。
  • 对于多级页表,引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。所以暂时不需要的先放硬盘里。
  • 快表还是多级页表实际上都利用到了程序的局部性原理
  • 多级页表属于时间换空间的典型场景。

上面所述,是内存的管理方式,管理好了,还需要寻址啊,这就涉及到CPU寻址

  • 首先先说明一下,地址分为逻辑(虚拟)地址和物理地址(注意要区分虚拟地址和虚拟内存,不是一个东西)
  • CPU采用虚拟(逻辑)寻址
  • 为什么要有虚拟地址空间呢?
    • 没有虚拟地址空间的时候,程序直接访问和操作的都是物理内存
    • 用户程序可以访问任意内存,寻址内存的每个字节,这样就很容易(有意或者无意)破坏操作系统,造成操作系统崩溃。
  • 通过虚拟地址访问内存有以下优势:
    • 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
    • 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
    • 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。

6.6 啥是虚拟内存啊,讲讲

虚拟内存概念:

  • 虚拟内存 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换
  • 虚拟内存的重要意义是它定义了一个连续的虚拟地址空间,并且 把内存扩展到硬盘空间
  • 流程
    • 由于外存往往比内存大很多,所以我们运行的软件的内存大小实际上是可以比计算机系统实际的内存大小大的。
    • 1 在程序执行过程中,当所访问的信息不在内存时,由操作系统将所需要的部分调入内存,然后继续执行程序。
    • 2 另一方面,操作系统将内存中暂时不使用的内容换到外存上,从而腾出空间存放将要调入内存的信息。
    • 这样,计算机好像为用户提供了一个比实际内存大得多的存储器——虚拟存储器。

用到了局部性原理:说白了就是如果最近用过,(本身及附近)就可能再用

  • 时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
  • 空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
  • 基于局部性原理,在程序装入时,可以将程序的一部分装入内存,而将其他部分留在外存,就可以启动程序执行

虚拟内存的实现方式:

  • 请求分页存储管理 ⭐
    • 建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能
    • 请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分段即可运行。
    • 假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存(请求调页)
    • 同时操作系统也可以将暂时不用的页面置换到外存中。(页面置换)
  • 请求分段存储管理

6.7 页面置换算法有哪些

  • 地址映射过程中,若在页面中发现所要访问的页面不在内存中,则发生缺页中断 。
  • 当发生缺页中断时,如果当前内存中并没有空闲的页面,操作系统就必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。用来选择淘汰哪一页的规则叫做页面置换算法

算法包括:

  • OPT 页面置换算法(最佳页面置换算法)
    • 被淘汰页面将是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率
    • 但是人们无法预知,所以无法实现
  • FIFO(First In First Out) 页面置换算法(先进先出页面置换算法)
    • 淘汰最先进入内存的页面
  • LRU (Least Recently Used)页面置换算法(最近最久未使用页面置换算法)
    • LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的
  • LFU (Least Frequently Used)页面置换算法(最少使用页面置换算法)
    • 该置换算法选择在之前时期使用最少的页面作为淘汰页

6.8 空间换时间的场景,和时间换空间的场景

  • 时间换空间

    • 多级页表属于时间换空间的典型场景。
    • 虚拟存储器:用 CPU 的计算时间,页的调入调出花费的时间,换来了一个虚拟的更大的空间来支持程序的运行
  • 空间换时间

    • threadlocal

6.9 哪些情况会中断

  1. 系统调用:这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现

  2. 异常:当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常

  3. 外围设备的中断:当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。(比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。)

七、计算机网络

7.1 四层模型与七层模型说说都有啥,作用是什么

面试八股整理_第50张图片

一、应用层:(对应七层的应用+表示+会话)

  • 应用层

    • 功能:为计算机用户提供服务
    • 应用层交互的数据单元称为报文。
  • 表示层

    • 功能:数据处理,包括编码、解码、加密、解密、压缩、解压缩
  • 会话层

    • 功能:管理应用程序直接的会话
  • 协议:

    • DNS 域名系统
      • DNS 同时支持 UDP 和 TCP 协议
    • HTTP 超文本传输协议
      • 基于 TCP协议,发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手
      • HTTP 协议是”无状态”的协议,它无法记录客户端用户的状态,一般我们都是通过 Session 来记录客户端用户的状态
    • FTP 文件传输协议
      • 基于TCP协议,提供文件传输服务
      • 在客户端与 FTP 服务器之间建立两个连接:
        控制连接:用于传送控制信息(命令和响应)
        数据连接:用于数据传送;
    • SMTP 简单邮件传输(发送)协议
      • 基于 TCP 协议,用来发送电子邮件
    • POP3/IMAP:邮件接收的协议
      • 基于 TCP 协议,IMAP 协议相比于POP3更新一点
    • Telnet 远程登陆协议
      • 基于 TCP协议, 通过一个终端登陆到其他服务器
      • 缺点:明文发送,因此被SSH取代
    • SSH:安全的网络传输协议
      • 基于 TCP协议
      • Telnet 和 SSH 之间的主要区别在于 SSH 协议会对传输的数据进行加密保证数据安全性。
    • DHCP 协议(动态主机配置)
      • 基于 UDP协议

二、传输层:

  • 功能:为两台主机进程之间的通信提供通用的数据传输服务
  • 传输的叫报文段
  • 协议:
    • TCP:提供 面向连接 的,可靠的 数据传输服务。

      • 报文段结构
      • 可靠数据传输
      • 流量控制
      • 拥塞控制
    • UDP 提供 无连接 的,尽最大努力的数据传输服务

      • 报文段结构
      • RDT(可靠数据传输协议)
    • SSL TLS

三、网络层:

  • 功能:路由和寻址
  • 传输的是数据报
  • 任务:
    • 网络层把运输层产生的报文段或用户数据报封装成分组和包进行传送。由于网络层使用 IP 协议,因此分组也叫 IP 数据报,简称数据报。
    • 选择合适的路由,使源主机运输层所传下来的分组,能通过网络层中的路由器找到目的主机
  • 协议:
    • IP:网际协议
      • 网际协议 IP 是TCP/IP协议中最重要的协议之一,也是网络层最重要的协议之一
      • IP协议的作用包括寻址规约、定义数据包的格式等等
    • ARP
      • 解决的是网络层地址和链路层地址之间的转换问题
      • 因为一个IP数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但IP地址属于逻辑地址,而MAC地址才是物理地址,ARP协议解决了IP地址转MAC地址的一些问题。
    • NAT:网络地址转换协议
      • 网络地址转换,应用于内部网到外部网的地址转换过程中
      • 在一个小的子网(局域网,LAN)内,各主机使用的是同一个LAN下的IP地址,但在该LAN以外,在广域网(WAN)中,需要一个统一的IP地址来标识该LAN在整个Internet上的位置。
    • ICMP 协议(控制报文协议,用于发送控制消息)

四、网络接口层(数据链路+物理):

  • 数据链路层

    • 功能:帧编码 、 误差纠正控制
  • 物理层

    • 功能:透明传输比特流

7.2 讲讲TCP和UDP的区别

好的面试官,区别主要从七个方面讲起

  • 是否面向连接 :
    • UDP 在传送数据之前不需要先建立连接
    • TCP 提供面向连接的服务,在传送数据之前必须先建立连接,数据传送结束后要释放连接。
  • 是否是可靠传输:
    • TCP 提供可靠的传输服务,TCP 在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。
    • 远地主机在收到 UDP 报文后,不需要给出任何确认,并且不保证数据不丢失,不保证是否顺序到达
  • 是否有状态 :
    • UDP 是无状态服务
    • TCP 传输是有状态的,为此 ,TCP 需要维持复杂的连接状态表
  • 传输效率 :
    • 由于使用 TCP 进行传输的时候多了连接、确认、重传等机制,所以 TCP 的传输效率要比 UDP 低很多
  • 传输形式
    • TCP 是面向字节流的,UDP 是面向报文的
  • 首部开销 :
    • TCP 首部开销(20 ~ 60 字节)比 UDP 首部开销(8 字节)要大
  • 是否提供广播或多播服务 :
    • TCP 只支持点对点通信,UDP 支持一对一、一对多、多对一、多对多
      面试八股整理_第51张图片

7.2 TCP、UDP、IP首部

IP首部,它共有20个字节,它的结构为
面试八股整理_第52张图片

版本:4位版本号,目前应用最广泛的是4(1000),即IPv4;

头长:4位首部的长度,它以4字节为单位,最小值为5,即首部长度最小为20字节。我们上图给出的就是20字节头长,不带任何选项 的IP首部,首部的最大长度为4*15=60字节

服务类型:8 位TOS 字段有3 个位用来指定IP 数据报的优先级(目前已经废弃不用),还有4 个位表示可选的服务类型(最小延迟、最大呑吐量、最大可靠性、最小成本),还有一个位总是0。

包裹总长:当前数据包的总长度(包括IP首部与IP数据)

重组标识:发送主机赋予的标识,每传一个IP数据报,16位标识加1,可用于分片和重新组装数据报。

标志:3位标志,

  • 1位保留;

  • 1位为不分段位:0表示允许数据包分段,1为不允许;

  • 1位为更多段位:0表示数据包后面没有包,该包为最后包,1表示数据包后有更多包

段偏移量:13位段偏移量与更多段位组合,帮助接收方组合分段的报文

生存时间:PING命令看到的TTL就是这个。每过一个路由器就把该值减1,如果减到0 就表示路由已经太长了仍然找不到目的主机的网络,就丢弃该包,因此这个生存时间的单位不是秒,而是跳(hop)

协议代码:使用该包裹的上层协议。TCP是6,ICMP是1,IGMP是2,UDP是17;

头校验和:16位IP首部的校验和。

32位源IP地址与32位目的IP地址。


TCP首部,它共有20个字节,它的结构为
面试八股整理_第53张图片

2字节的源端口和2字节的目的端口:用于寻找发端和收端应用进程。这两个值加上IP首部中的源端IP地址和目的端IP地址唯一确定一个TCP连接。有时,一个IP地址和一个端口号也称为一个插口或套接字(socket) 。这个术语出现在最早的TCP规范(RFC793)中,后来它也作为表示伯克利版的编程接口 。插口或套接字对(socket pair)(包含客户IP地址、客户端口号、服务器 IP地址和服务器端口号的四元组 )可唯一确定互联网络中每个TCP连接的双方。

数据序号:TCP为发送的每一个字节都编写一个号码,这里存储当前数据包的第一字节的序号。如果将字节流看作在两个应用程序间的单向流动,则 TCP用序号对每个字节进行计数。

确认序号:TCP告诉接受者希望它下次接收到数据包的第一字节序号。

偏移:4位偏移,类似IP表明数据包头有多少个32位;

6个标志比特:

U:URG紧急比特。当URG=1时,表明此报文端有紧急数据应尽快传送;

A:ACK确认比特。只有当ACK=1时,确认字段才有效,(TCP三次握手);

P:PSH用于通知用户一个更高的数据吞吐量被需要,如果可能数据必须以更高的速率通信;

R:RST复位比特。表明TCP连接过程中出现严重错误必须释放连接,然后重新建立连接(TCP三次握手);

S:SYN同步比特。表明这是一个连接请求或连接接收报文;

F:FIN终止比特。表明此报文段的发送端的数据已发送完毕,并要求释放连接。

窗口字段:用于控制对方发送的数据量,单位为字节。TCP连接的一段根据设置的缓冲空间大小确定自己接收窗口大小,然后通知对方以确定对方的发送窗口的上限;

包校验和:包括首部与数据两部分,在计算校验和时,要在TCP报文段的前面加上12字节的伪首部。

紧急指针:指出在本报文段中的紧急数据的最后一个字节的序号。


对于UDP包,在IP首部后面跟随的是UDP首部。UDP首部共8个字节,它的结构是

面试八股整理_第54张图片

7.3 讲讲三次握手四次挥手

面试八股整理_第55张图片

面试八股整理_第56张图片

  • SYN同步信号
  • SEQ是序号
  • ACK是确认号

首先回答一下三次握手

关于这个问题,我会从下面三个方面来回答

  • 第一个
    • TCP是一种可靠的、基于字节流的、面向连接的传输层协议,可靠性体现在TCP协议通信双方的数据传输是稳定的,即便是网络不好的情况下,TCP都能够保证数据传输到目标端,然后TCP通讯双方的数据传输是通过字节流来实现传输的,最后面向连接是说数据传输之前必须建立一个连接,然后基于这个连接进行数据传输
  • 第二个
    • 因为TCP是面向连接的协议,所以建立数据通信之前需要建立可靠的连接,TCP采用了三次握手的方式来实现连接的建立,所谓的三次握手,就是通信双方一共需要发送三次请求,才能确保一个可靠连接的建立
    • 第一次是客户端向服务端发送连接请求,并且携带同步序号SYN
    • 第二次是服务端收到请求后,发送SYN和ACK,这里的SYN表示是服务端的一个同步序列号,ACK表示是对前面客户端请求的一个确认,表示告诉客户端我收到你的请求了
    • 第三次,客户端收到服务端请求后,再一次发送ACK,而这个ACK是针对服务端连接的确认,表示告诉服务端,我收到了你的请求
  • 第三个
    • 之所以TCP设计三次握手,我认为有三个原因
      1. TCP是可靠的通信协议,所以TCP协议的通信双方都必须要维护一个序列号,去标记已经发送出去的数据包,哪些是已经被对方签收的。而三次握手就是通信双方相互告知序列号的起始值,为了确保这个序列号被收到,所环双方都需要有一个确认的操作
      2. TCP协议需要在一个不可靠的网络环境下实现可靠的数据传输,意味着通信双方必须要通过某种手段来实现一个可靠的数据传输通道,而三次通信是建立这样一个通道的最小值,当然还可以四次,五次,只是没必要浪费这个资源
      3. 防止历史的重复连接初始化造成的混乱问题,比如说在网络比较差的情况下,客户端连续多次发送建立连接的请求,假设只有两次握手,那么服务端只能选择接受成者拒绝这个连接请求,但是服务端不知道这欣请求是不是之前因为网络增塞而过期的请求,也就是说服务端不和知道当前客户端的连接是有效还是无效

面试八股整理_第57张图片

面试八股整理_第58张图片

  • FIN标记为,表示“请求释放连接“
  • SEQ是序号
  • ACK确认号

然后回答一下四次挥手

  • 第一次挥手 :客户端发送一个 FIN(SEQ=X) 标志的数据包->服务端,用来关闭客户端到服务器的数据传送。然后,客户端进入 FIN-WAIT-1 状态。
  • 第二次挥手 :服务器收到这个 FIN(SEQ=X) 标志的数据包,它发送一个 ACK (SEQ=X+1)标志的数据包->客户端 。然后,此时服务端进入CLOSE-WAIT状态,客户端进入FIN-WAIT-2状态。
  • 第三次挥手 :服务端关闭与客户端的连接并发送一个 FIN (SEQ=y)标志的数据包->客户端请求关闭连接,然后,服务端进入LAST-ACK状态。
  • 第四次挥手 :客户端发送 ACK (SEQ=y+1)标志的数据包->服务端并且进入TIME-WAIT状态,服务端在收到 ACK (SEQ=y+1)标志的数据包后进入 CLOSE 状态。此时,如果客户端等待 2MSL 后依然没有收到回复,就证明服务端已正常关闭,随后,客户端也可以关闭连接了。
  • 只要四次挥手没有结束,客户端和服务端就可以继续传输数据!

那再问你一些问题?
为什么不能把服务器发送的 ACK 和 FIN 合并起来,变成三次挥手?

  • 总结:自己知道了,但是可能东西还没发完
  • 第三次挥手的目的是为了告诉主动方,“被动方没有数据要发了”。
  • 服务器收到客户端断开连接的请求时,可能还有一些数据没有发完,这时先回复 ACK,表示接收到了断开连接的请求。等到数据发完之后再发 FIN,断开服务器到客户端的数据传送

可不可以合并成三次挥手

  • 如果服务端没有数据要发了,可以合并
  • 如果有数据
    • 也不一定,TCP可以延迟确认,接收方收到数据以后不需要立刻马上回复ACK确认包。

如果第二次挥手时服务器的 ACK 没有送达客户端,会怎样?

  • 客户端没有收到 ACK 确认,会重新发送 FIN 请求

为什么第四次挥手客户端需要等待 2*MSL(报文段最长寿命)时间后才进入 CLOSED 状态?

  • 因为ACK可能丢失了
  • 客户端发送给服务器的 ACK 有可能丢失,如果服务端因为某些原因而没有收到 ACK 的话,服务端就会重发 FIN,如果客户端在 2*MSL 的时间内收到了 FIN,就会重新发送 ACK 并再次等待 2MSL,防止 Server 没有收到 ACK 而不断重发 FIN。
    • MSL(Maximum Segment Lifetime) : 一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间
  • 注意和超时重传不一样
    • 最后ACK假如丢失,服务器重新发送FIN-ACK,启动的是超时重传计时器
    • 客户端发送最后一个ACK之后启动的是时间等待计时器
    • 如果有问题,客户端不是超时,而是在一定时间内,又发回来了重传信息

7.4 TCP如何保证传输的可靠性的?

1.基于数据块传输(也就是报文段)
2.对失序数据包重新排序以及去重

  • 为了保证不发生丢包,就给每个包一个序列号
  • 从而进行排序和去重

3.校验和

  • TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。

4.超时重传

  • 当发送方发送数据之后,它启动一个定时器,等待目的端确认收到这个报文段。接收端实体对已成功收到的包发回一个相应的确认信息(ACK)。如果发送端实体在合理的往返时延(RTT)内未收到确认消息,那么对应的数据包就被假设为已丢失open in new window并进行重传。

5.流量控制

  • 利用滑动窗口实现流量控制
    • 接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。
  • 当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失
  • TCP 发送窗口可以划分成四个部分 :
    • 已经发送并且确认的TCP段(已经发送并确认);
    • 已经发送但是没有确认的TCP段(已经发送未确认);
    • 未发送但是接收方准备接收的TCP段(可以发送);
    • 未发送并且接收方也并未准备接受的TCP段(不可发送)。
  • TCP 接收窗口可以划分成三个部分 :
    • 已经接收并且已经确认的 TCP 段(已经接收并确认);
    • 等待接收且允许发送方发送 TCP 段(可以接收未确认);
    • 不可接收且不允许发送方发送TCP段(不可接收)。
    • 接收窗口的大小是根据接收端处理数据的速度动态调整的。 如果接收端读取数据快,接收窗口可能会扩大。 否则,它可能会缩小。

6.拥塞控制

  • 当网络拥塞时,减少数据的发送
  • 和流量控制的区别
    • 拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。
  • 为了进行拥塞控制,TCP 发送方要维持一个 拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。
    • 发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。
  • 过程
    • 慢开始
    • 拥塞避免
    • 快重传和快恢复

在这里需要对超时重传所用的协议ARQ进行一个说明

  • 即自动重传请求
    • 如果发送方在发送后一段时间之内没有收到确认信息(Acknowledgements,就是我们常说的 ACK),它通常会重新发送,直到收到确认或者重试超过一定的次数
  • 分类
    • 停止等待 ARQ 协议
      • 每发完一个分组就停止发送,等待对方确认
      • 如果过了一段时间(超时时间后),还是没有收到 ACK 确认,说明没有发送成功,需要重新发送,直到收到确认后再发下一个分组;
    • 连续 ARQ 协议
      • 发送方维持一个发送窗口,凡位于发送窗口内的分组可以连续发送出去,而不需要等待对方确认。接收方一般采用累计确认,对按序到达的最后一个分组发送确认,表明到这个分组为止的所有分组都已经正确收到了
      • 优点
        • 信道利用率高,容易实现
      • 缺点
        • 不能向发送方反映出接收方已经正确收到的所有分组的信息
        • 比如:发送方发送了 5 条 消息,中间第三条丢失(3 号),这时接收方只能对前两个发送确认。发送方无法知道后三个分组的下落,而只好把后三个全部重传一次。这也叫 Go-Back-N(回退 N),表示需要退回来重传已经发送过的 N 个消息。

7.4 加 拥塞控制是什么

cwd:拥塞避免窗口,它是一个状态值,不是滑动窗口的大小,它不决定发送数据包的多少,它是发送数据包数量的最大值,即一个状态变量。

1)慢开始

  • 当网络中一次性出现大量的数据包时,可能会达到网络的负荷,那么慢开始的角度就是从小到大地增加拥塞窗口的值,这样达到拥塞之前我们就可以及时降低拥塞窗口的值,它增长的规律是从1开始,指数式增长。

2) 拥塞避免

  • 我们会提前设置一个慢开始门限值,当 慢开始的拥塞窗口值达到门限值,就会执行拥塞避免算法,即每次只增加一个cwd值,呈线性增长,以此来降低拥塞窗口值的增长速度,当出现了超时,则将门限值改为原来的一半,指向快恢复算法。

3) 快重传

  • 用于ARQ协议的原因,我们在发送数据包后正常情况下都会收到一个确认,如果接收方没有收到它本应收到的数据包,他一旦收到发送方的数据包,他就会持续发送重复的确认包,当发送方连续收到3个重复的确认包,他就会重新发送已经丢失的那个数据包。

4) 快恢复

  • 当发送方连续收到三个重复的确认包,门限值减为原来的一半,以门限值作为初始值,执行拥塞避免算法的线性加法增长算法,每次增长一个cwd,这便是快恢复算法。

面试八股整理_第59张图片

拥塞控制的作用

  1. 它是TCP可靠性传输的一个重要实现;
  2. 良好地维护了良好的网络通信环境。

7.4 加

7.5 可以给我讲一讲从输入URL到页面展示的全过程么

1 URL 输入

浏览器会对输入进行判断,检查输入的内容是否是一个合法的 URL 链接。

  • 是,则判断输入的 URL 是否完整。如果不完整,浏览器可能会对域进行猜测,补全前缀或者后缀。
  • 否,将输入内容作为搜索条件,使用用户设置的默认搜索引擎来进行搜索。

2 DNS 解析

  • 因为浏览器不能直接通过域名找到对应的服务器 IP 地址,所以需要进行 DNS 解析,查找到对应的 IP 地址进行访问
  • 流程
    • 先本地DNS服务器
    • 再DNS根服务器
    • 再 顶级域DNS服务器
    • 再权威DNS服务器
  • 从请求主机到本地 DNS 服务器的查询是递归查询,DNS 服务器获取到所需映射的查询过程是迭代查询。

3 TCP 连接

HTTP报文是包裹在TCP报文中发送的,服务器端收到TCP报文时会解包提取出HTTP报文

4 发送 HTTP 请求
可以称为前端工程师眼中的HTTP

  • 可以用HTTPS
    • HTTPS在传输数据之前需要客户端与服务器进行一个握手(TLS/SSL握手)
    • TLS/SSL使用了非对称加密,对称加密以及hash等
  • HTTP请求报文是由三部分组成: 请求行, 请求报头和请求正文。

5 服务器处理请求并返回 HTTP 报文
这个对应的就是后端工程师眼中的HTTP

后端从在固定的端口接收到TCP报文开始,这一部分对应于编程语言中的socket。它会对TCP连接进行处理,对HTTP协议进行解析,并按照报文格式进一步封装成HTTP Request对象,供上层使用。这一部分工作一般是由Web服务器去进行,我使用过的Web服务器有Tomcat

6 浏览器解析渲染页面

  • 浏览器是一个边解析边渲染的过程
  • 首先浏览器解析HTML文件构建DOM树,然后解析CSS文件构建渲染树,等到渲染树构建完成后,浏览器开始布局渲染树并将其绘制到屏幕上。

7 连接结束

四次挥手

这个说法更好
1.浏览器解析用户输入的URL,生成一个HTTP格式的请求
2.先根据URL域名从本地hosts文件查找是否有映射IP,如果没有就将域名发送给电脑所配置的DNS进行域名解析,得到IP地址
3.浏览器通过操作系统将请求通过四层网络协议发送出去
4.途中可能会经过各种路由器、交换机,最终到达服务器
5.服务器收到请求后,根据请求所指定的端口,将请求传递给绑定了该端口的应用程序,比如8080被tomcat占用了
6. tomcat接收到请求数据后,按照http协议的格式进行解析,解析得到所要访问的servlet
7.然后servlet来处理这个请求,如果是SpringMVC中的DispatcherServlet,那么则会找到对应的Controller中的方法,并执行该方法得到结果
8.Tomcat得到响应结果后封装成HTTP响应的格式,并再次通过网络发送给浏览器所在的服务器
9.浏览器所在的服务器拿到结果后再传递给浏览器,浏览器则负责解析并渲染
10.然后忘说了一点就是在这个过程中也会有三次握手和四次挥手的过程

加更:HTTP请求和响应数据的格式如何

面试八股整理_第60张图片

可以看到,当使用get请求时,username参数等位于请求行,只有POST请求时,才会位于请求体

面试八股整理_第61张图片

7.5 加 怎么解析HTTP请求呢

非常好的文章
面试八股整理_第62张图片

一般HTTP请求可以分为四个部分
1.首行
2.请求头:header
3.空行
4.正文:body

  1. 请求行:一般包含三个部分,之间使用空格来区分。

    • GET:HTTP的方法;

    • https://www.baidu.com/:URL,也就是俗称的网址,也称为唯一资源定位符,标识着互联网上的唯一的资源的位置。这里还涉及到另一个概念:URI,表示唯一资源标识符,主要是身份标识,为了和别的资源区分开。而实际上,URL也可以起到身份标识的作效果,所以URL也可以视为一个URI。

    • HTTP/1.1:版本号;

  2. 请求头

    • body 内的数据格式通过 header 中的 Content-Type 指定. body 的长度由header 中的 Content-Length 指定。一般而言,body部分存放的内容和格式,是由程序员自定义的内容。
    • Host:大概描述了服务器主机所在的地址和端口,此处的地址和端口,用来描述最终要访问的目标。
    • Content-Length,Content-Type: 分别表示body中的数据长度和数据格式
    • User-Agent: 主要描述了浏览器和操作系统的版本,如今User-Agent也多用来区分PC和移动端。
    • Referer: 当前页面的"来源",表示这个页面是从哪个页面跳转过来的
    • Cookie
  3. 空行

    • 空行一般就表示header的结束。
    • 因为 HTTP 协议并没有规定报头部分的键值对有多少个, 空行就相当于是 " 报头的结束标记 ", 或者是 " 报头和正文之间的分隔符 "。
    • HTTP 在传输层依赖 TCP 协议, TCP 是面向字节流的 . 如果没有这个空行 , 就会出现 " 粘包问题 "。
  4. body

    • 正文的具体格式取决于 Content-Type。
    • 例如:text/html,text/css,image/png,image/jpg,application/javascript,application/json…此时正文就会以对应的形式出现。

总结:

  1. 接收请求: 服务器从网络连接中接收到原始的HTTP请求数据。
  2. 解析请求行: 首先,服务器会解析请求行,请求行包括三个部分:HTTP方法、请求的URI(Uniform Resource Identifier)以及HTTP协议版本。例如:GET /index.html HTTP/1.1。
  3. 解析请求头: 服务器解析HTTP请求头部,头部包含了关于请求的附加信息,比如User-Agent、Accept、Host等。头部以空行为分隔,后面是请求体(如果有的话)。
  4. 解析请求体: 如果请求是包含请求体的(比如POST请求),服务器会根据Content-Length等头部信息来解析请求体数据。
  5. 处理请求: 一旦请求被解析,服务器会根据请求的内容来执行相应的处理逻辑,这可能涉及查询数据库、处理业务逻辑等。
  6. 生成响应: 在处理完请求后,服务器会生成一个HTTP响应。响应包括状态行、响应头和响应体。
  7. 发送响应: 服务器将生成的HTTP响应数据发送回到客户端,这会包括状态行、响应头和响应体。

如果你希望实际操作来解析HTTP请求,最好使用编程语言提供的库或框架,Java中可以使用HttpServer类等等。这些库通常会将解析和处理HTTP请求的复杂性抽象化,使你能够更轻松地构建Web应用程序

7.5 加 GET和POST之间的区别

  1. GET请求一般是用于从服务器获取数据,POST一般是用于给服务器提交数据;但是采用GET来进行提交,POST来进行获取,也是可以的。

  2. GET也可以给服务器传递一些信息,GET传递的信息一般都是放在 query string,POST传递信息则是通过 body。而此处只是一个习惯用法,GET也不是不能有body,POST也不是不能有query string,只不过是少见。

  3. GET通常会设计成幂等的,POST不要求幂等。(幂等表示相同的输入,得到的结果也是确定的)

  4. GET 可以被缓存的,POST一般不能被缓存。(GET把请求的结果保存下来了,下一次请求的时候就不需要再真请求了,直接读取缓存结果即可。)

7.6 可以给我列举一下常见的HTTP状态码么

  • 1XX 响应中:临时状态码,表示请求已经接受,告诉客户端应该继续请求或者如果它已经完成则忽略

  • 2XX 成功:表示请求已经被成功接收,处理已完成

  • 3XX 重定向:重定向到其它地方:它让客户端再发起一个请求以完成整个处理。

  • 4XX 客户端错误:处理发生错误,责任在客户端,如:客户端的请求一个不存在的资源,客户端未被授权,禁止访问等

  • 5XX 服务器端错误:处理发生错误,责任在服务端,如:服务端抛出异常,路由出错,HTTP版本不支持等

面试八股整理_第63张图片

7.7 HTTP和HTTPS有什么区别(重要!)

  1. 明文传输:对称加密和非对称加密
  2. 报文验证:hash算法
  3. 数字证书:CA认证
  • 端口号
    • HTTP 默认是 80
    • HTTPS 默认是 443
  • URL前缀
    • HTTP 的 URL 前缀是 http://
    • HTTPS 的 URL 前缀是 https://
  • 安全性和资源消耗
    • HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份
    • HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。

HTTP

  • 通信流程
    • 服务器在 80 端口等待客户的请求。
    • 浏览器发起到服务器的 TCP 连接(创建套接字 Socket)。
    • 服务器接收来自浏览器的 TCP 连接。
    • 浏览器(HTTP 客户端)与 Web 服务器(HTTP 服务器)交换 HTTP 消息。
    • 关闭 TCP 连接。
  • 优点
    • 扩展性强、速度快、跨平台支持性好

HTTPS

  • 优点
    • 保密性好、信任度高
  • 原理
    • 非对称加密:采用两个密钥

      • 一个公钥,一个私钥

      • 私钥仅由解密者保存,公钥由任何一个想与解密者通信的发送者(加密者)所知

      • 似乎可以理解为,公钥加密,私钥解密

      • 公私钥对的生成算法依赖于单向陷门函数。

        • 单向函数:已知单向函数 f,给定任意一个输入 x,易计算输出 y=f(x);而给定一个输出 y,假设存在 f(x)=y,很难根据 f 来计算出 x。单向陷门函数:一个较弱的单向函数。已知单向陷门函数 f,陷门 h,给定任意一个输入 x,易计算出输出 y=f(x;h);而给定一个输出 y,假设存在 f(x;h)=y,很难根据 f 来计算出 x,但可以根据 f 和 h 来推导出 x。
        • 在这里,函数 f 的计算方法相当于公钥,陷门 h 相当于私钥。公钥 f 是公开的,任何人对已有输入,都可以用 f 加密,而要想根据加密信息还原出原信息,必须要有私钥才行。
      • 问题
        实际通信过程中,非对称加密计算的代价较高,效率太低
        面试八股整理_第64张图片
        注意,对于非对称加密来说,私钥、公钥都是同一边生成的,比如Alice生成公钥和私钥,然后把公钥发给Bob

    • 对称加密

      • SSL/TLS 实际对消息的加密使用的是对称加密

      • 原理

        • 对称加密:通信双方共享唯一密钥 k,加解密算法已知,加密方利用密钥 k 加密,解密方利用密钥 k 解密,保密性依赖于密钥 k 的保密性。
      • 最终解决方案

        • 但是这个密钥如何传递呢,网络通信是不安全的,密钥肯定不能直接在网络信道中传输
        • 因此,使用非对称加密,对对称加密的密钥进行加密,保护该密钥不在网络信道中被窃听。这样,通信双方只需要一次非对称加密,交换对称加密的密钥,在之后的信息通信中,使用绝对安全的密钥,对信息进行对称加密,即可保证传输消息的保密性。

面试八股整理_第65张图片
面试八股整理_第66张图片
注意,对于对称加密来说,私钥、公钥都是同一边生成的,比如Bob生成私钥和密钥,把公钥传给Alice,这样第一次两人可以通过这个公钥和私钥传输一个对称加密的密钥,之后就用这个密钥通信就行了

  • 问题分析:公钥传输的信赖性(既然公钥传输,肯定是非对称加密的过程中,或者是对称加密刚开始利用非对称加密的时候)
    • 问题:可能有人伪造公钥,去骗密钥,因此要确定拿到的公钥就是自己想要的公钥。

    • 分析:这里需要用到数字签名算法和数字证书

    • 解决

      • 证书颁发机构CA 附上数字签名
      • 当客户端(浏览器)向服务器发送 HTTPS 请求时,一定要先获取目标服务器的证书,并根据证书上的信息,检验证书的合法性
      • RSA 非对称加密,可以实现CA的这种方案视频
    • RSA数字签名算法 描述:

      • 1 小明根据RS加密算法生成公钥和私钥,对文件进行哈希运算,得出文件哈希值H,使用私钥根据RSA解密公式对文件哈希值进行签名运算,得出数字签名S,至此小明完成了对文件的数字签名
      • 2 公钥、文件及数字签名被发送到互联网上,如果小红要验证文件是否已被小明签名,使用公钥根据RSA加密公式对数字签名进行验证运算得出文件哈希值H。(这是因为RSA加密算法是双向的,既可加密也可解密,签名时使用解密公式,验证时使用加密公式)
      • 3 同时对文件进行哈希运算,得到哈希值H,讲刚才得到的H和现在的H进行对比,如果两者相同,证明签名成立,不同则签名不成立
        • 解释一下如何确保文件不被篡改:如果文件被篡改了,文件的哈希值肯定发生变化,两者就不相同,签名验证也就不能通过了
        • 解释一下如何确保签名是小明本人签署的呢:数字签名只能由私钥拥有者生成,也就确保了签字者身份一定是私钥拥有者,但是我们并不能确定私钥拥有者一定是小明,因为其他人自己也可生成一套公钥私钥并进行数字签名冒充成小明签名,这就涉及到了数字证书
    • 数字证书的逻辑:

      • 1.公钥私钥是配对生成的,某一私钥对应唯一公钥,因此只需证明公钥生产者的身份为小明即可,这就是在数字证书的作用
      • 2.小明将公钥及部分个人信息发到CA机构,CA核实小明身份后,颁发一个数字证书,该证书包含小明身份信息和公钥数据,也就是证明了该公钥生成者为小明
      • 3.小明把证书放到互联网上,小红就可以通过证书知道,哪一个 公钥是小明的了。这样就防止了其他人的公钥来捣乱
        • 此时互联网上还有其他人放的数字签名,不过也没用,因为这些数字签名用小明的公钥,无法通过签名验证
      • 4.那么如何确保的数字证书不被伪造呢?
        • 这是因为数字证书是CA数字签名的,CA本身也生产公钥私钥,使用私钥对“小明身份信息及公钥数据”这一内容进行数字签名,并放到数字证书中,数字证书相当于是文件和数字签名的结合体。而每个人电脑或手机系统里,默认安装了根证书,根证书记录了可以信赖的CA机构信息及其公钥,根证书预先安装在系统中可以杜绝CA公钥被伪造的可能性,通过根证书里的CA公钥就可以验证数字证书里的数字签名,从而拿到数字证书中“小明身份信息及公钥数据”这一内容
          面试八股整理_第67张图片

和CSRF区分一下:
CSRF 是 用户自己点别的网页,被盗取了cookie session
而这里的骗密钥是别人拿着假冒的公钥,来主动获取你的私钥

7.8 HTTP1.0和HTTP1.1有什么区别

一是连接方式

  • HTTP 1.0 为短连接
    • 每进行一次 HTTP 操作,就建立一次连接,任务结束就中断连接
    • 导致有大量的“握手报文”和“挥手报文”占用了带宽
  • HTTP 1.1 支持长连接
    • 如果 TCP 连接一直保持的话也是对资源的浪费,因此,一些服务器软件(如 Apache)还会支持超时时间的时间。在超时时间之内没有新的请求达到,TCP 连接才会被关闭。
    • 注意1.0可以在请求头中加入Connection: Keep-alive从而支持长连接
      1.1也可以在请求头中加入Connection: close关闭长连接
    • HTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。
    • 实现长连接需要客户端和服务端都支持长连接

二是状态码

  • HTTP/1.1中新加入了大量的状态码,光是错误响应状态码就新增了24种
  • 如100:存在某些较大的文件请求,服务器可能不愿意响应这种请求,此时状态码100可以作为指示请求是否会被正常响应

三是缓存处理

1.0面试八股整理_第68张图片

1.1
请求头中增加Cache-control

四是带宽优化及网络连接的使用

  • HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能
  • HTTP1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206,这样就方便了开发者自由的选择以便于充分利用带宽和连接

五是Host头的处理

HTTP/1.1在请求头中加入了Host字段,从而可以让多个主机名绑定在同一个IP地址

面试八股整理_第69张图片

面试八股整理_第70张图片

7.8 HTTP所有版本区别

HTTP 1.0:

  1. 持久连接: 默认情况下,HTTP 1.0使用短连接,即每个请求/响应都会建立一个新的TCP连接。在HTTP 1.0中,使用Connection: keep-alive头部可以启用持久连接。
  2. 请求头部: HTTP 1.0中的请求头部相对较简单,没有过多的优化和扩展。
  3. 管道化: HTTP 1.0支持请求管道化,即在一个连接上可以发送多个请求,但响应必须按照请求的顺序返回,且不能交叉。

HTTP 1.1:

  1. 持久连接: HTTP 1.1默认启用持久连接,无需显式使用Connection: keep-alive头部。
  2. 请求头部: HTTP 1.1引入了更多的请求头部,以及新的功能,如Host头部、Range头部、Content-Encoding头部等。
  3. 管道化: HTTP 1.1中的管道化更加灵活,支持多个未完成的请求交叉存在,服务器会尽力按照请求的顺序返回响应。
  4. 分块传输编码: HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能;HTTP 1.1引入了分块传输编码,使服务器可以将响应分成多个块逐步发送,有助于传输大文件。
  5. 虚拟主机: HTTP 1.1增加了对虚拟主机的支持,即在同一个IP地址上可以托管多个域名。
  6. 缓存控制: HTTP 1.1引入了更多的缓存控制指令,如Cache-Control头部,允许更精细地控制缓存行为。
  7. 状态响应码: HTTP/1.1相比1.0新加入了大量的状态码,光是错误响应状态码就新增了24种

HTTP 2.0:

  1. 多路复用: HTTP 2.0引入了多路复用,允许在一个连接上同时进行多个请求和响应,减少了连接建立的开销。
  2. 二进制协议: HTTP 2.0采用二进制协议,将头部信息和数据分别编码,提高了传输效率。
  3. 首部压缩: HTTP 2.0使用HPACK算法对首部进行压缩,减少了数据传输量。
  4. 优先级: HTTP 2.0允许发送端指定请求的优先级,使得服务器可以有针对性地优先处理某些请求。
  5. 流控制: HTTP 2.0引入了流控制机制,防止接收端被发送端的数据压垮。

7.8 加 HTTP中可以自定义的字段

HTTP协议中,还是有不少地方是可以程序员自定义的

  1. URL中的路径;

  2. URL中的 query string;

  3. header中的键值对;

  4. header中的cookie键值对;

  5. body;

7.8 加 HTTP传输文件时怎么处理的

HTTP支持分块传输编码,这意味着文件可以分成多个块来传输,而不需要将整个文件加载到内存中。

  • 客户端:将文件分成块并使用分块传输编码发送块数据。
  • 服务器:接收块并逐个处理它们。

同时指定Content-Type 请求头用于指定请求的媒体类型。对于文件上传,通常使用 multipart/form-data 来表示表单中包含文件数据。

在@RequestMapping中需要标注consumes=MediaType.MULTIPART_FORM_DATA_VALUE。consumes 属性来限制该方法可以处理的请求的媒体类型,它告诉 Spring MVC 该方法只处理包含 “multipart/form-data” 媒体类型的请求。这对于处理文件上传非常重要,因为文件上传通常使用这种媒体类型。

7.8 加 重定向和请求转发

重定向是,当前的域名不用了,返回一个新的域名给浏览器,然后浏览器再通过新域名来进行访问。

请求转发是,当前的域名不用了,但是服务器会直接调用新域名的相关代码,返回给浏览器。
面试八股整理_第71张图片

由此可见,重定向是可以重定向到外部资源的,也就是可以跳转到别的网站;而请求转发只能在该服务器内部的资源之间进行转发,少了一次交互,但也更高效。各有利弊。

7.9 说一下长连接和短连接的区别

  • 在HTTP/1.0中默认使用短连接。也就是说,客户端和服务器每进行一次HTTP操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个HTML或其他类型的Web页中包含有其他的Web资源(如JavaScript文件、图像文件、CSS文件等),每遇到这样一个Web资源,浏览器就会重新建立一个HTTP会话。

  • 而从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头加入这行代码:
    Connection:keep-alive

  • 在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接需要客户端和服务端都支持长连接。

  • HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。

  • 优缺点:

    • 长连接可以省去较多的TCP建立和关闭的操作,减少浪费,节约时间。对于频繁请求资源的客户来说,较适用长连接,不过管理成本比较高
    • 短连接对于服务器来说管理较为简单,存在的连接都是有用的连接,不需要额外的控制手段。但如果客户请求频繁,将在TCP的建立和关闭操作上浪费时间和带宽。
    • 长连接多用于操作频繁,点对点的通讯,而且连接数不能太多情况
    • 而像WEB网站的http服务一般都用短链接

7.10 什么是SYN洪范攻击

SYN洪泛攻击属于DOS攻击的一种,它利用TCP协议缺陷,通过发送大量的半连接请求,耗费CPU和内存资源。

原理:

  • 在三次握手过程中,服务器发送[ SYN / ACK ]包(第二个包)之后、收到客户端的[ ACK ]包 ( 第三个包 ) 之前的TCP连接称为半连接(half-open connect),此时服务器处于SYN_RECV(等待客户端响应)状态。如果接收到客户端的[ACK],则TCP连接成功,如果未接受到,则会不断重发请求直至成功。
  • SYN攻击的攻击者在短时间内伪造大量不存在的IP地址,向服务器不断地发送[SYN]包,服务器回复[SYN/ACK]包,并等待客户的确认
    由于源地址是不存在的,服务器需要不断的重发直至超时。
  • 这些伪造的 [SYN]包将长时间占用未连接队列,影响了正常的SYN,导致目标系统运行缓憬、网络堵塞甚至系统瘫痪。

检测:当在服务器上看到大星的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。

防范:

  • 通过防火墙、路由器等过滤网关防护。
  • 通过加固TCP/IP协议栈防范,如增加最大半连接数,缩短超时时间。
  • SYN cookies技术。SYN Cookies是对TCP服务器端的三次握手做一些修改,专门用来防范SYN洪泛攻击的一种手段。

7.11 说一下token、cookie、session

很好的文章,自己写的

7.12 DNS过程

递归查询: 如果主机所询问的本地域名服务器不知道被查询域名的 IP 地址,那么本地域名服务器就以 DNS 客户端的身份,向其他根域名服务器继续发出查询请求报文,即替主机继续查询,而不是让主机自己进行下一步查询。

迭代查询:当根域名服务器收到本地域名服务器发出的迭代查询请求报文时,要么给出所要查询的IP 地址,要么告诉本地服务器下一步应该找哪个域名服务器进行查询,然后让本地服务器进行后续的查询。

面试八股整理_第72张图片


面试八股整理_第73张图片

7.13 DNS为什么用UDP?

更正确的答案是 DNS 既使用 TCP 又使用 UDP。当进行区域传送(主域名服务器向辅助域名服务器传送变化的那部分数据)时会使用 TCP,因为数据同步传送的数据量比一个请求和应答的数据量要多,而 TCP 允许的报文长度更长,因此为了保证数据的正确性,会使用基于可靠连接的 TCP。

当客户端向 DNS 服务器查询域名 ( 域名解析) 的时候,一般返回的内容不会超过 UDP 报文的最大长度,即 512 字节。用 UDP 传输时,不需要经过 TCP 三次握手的过程,从而大大提高了响应速度,但这要求域名解析器和域名服务器都必须自己处理超时和重传从而保证可靠性。

7.14 DNS多级缓存

  • 首先搜索浏览器自身的DNS缓存,如果存在,则域名解析到此完成。
  • 如果浏览器自身的缓存里面没有找到对应的条目,那么会尝试读取操作系统的hosts文件看是否存在对应的映射关系,如果存在,则域名解析到此完成。
  • 如果本地hosts文件不存在映射关系,则查找本地DNS服务器(ISP服务器,或者自己手动设置的DNS服务器),如果存在,域名到此解析完成。
  • 如果本地DNS服务器还没找到的话,它就会向根服务器发出请求,进行递归查询。

你可能感兴趣的:(java,java)