一、分布式锁

1.1、分布式锁应该具备的条件

  • 互斥性(排他性)

在分布式系统环境下,对于一把锁而言,在某一时刻下只能有一个客户端持有这把锁。

  • 不能发生死锁

这个锁必须具备锁失效功能,防止在持有锁的客户端在握持这把锁的过程中崩溃时,被握持的锁不能释放,从而导致死锁的发生。

  • 高可用
  • 具备非阻塞锁特性,即没有获取到锁时将直接返回获取锁失败

1.2、分布式锁常见的实现方式

1、基于 MySQL 实现分布式锁

  • 实现思路:

基于 MySQL 实现分布式锁,一般都是利用主键唯一的特性,首先在主程序中生成一个唯一的业务 ID ,然后当有两个客户端同时调用服务时,由于它们插入数据库时采用的是同一个业务 ID 作为主键,所以无法插入两次,所以只会有一个程序返回 1 ,而另一个程序返回 0 .

插入成功返回 1 的那个客户端相当于获取到了这把锁,释放锁即是将这条业务 ID 对应的记录从数据表中删掉

  • 存在的问题
  1. 没有失效时间,容易导致死锁
  2. 依赖 MySQL 的可用性,一旦数据库挂掉,那么锁将不可用
  3. 这把锁只能是非阻塞的,因为数据库的 insert 操作在插入失败后就会立即报错。没有获得这把锁的客户端不会进入排队队列,获取锁失败的客户端要想再次获取锁就要再次发起获取锁操作
  4. 这把锁是不可重入的,因为数据库中数据已经存在了
  • 也可以通过数据库的乐观锁来实现分布式锁

在数据库表中创建一个 version 字段,每次更新成功,则 version + 1,读取字段时,我们将 version 一起读取,更新时需要比对版本号,如果版本号一致则执行此操作,获得锁,更新失败就证明获取锁失败。

2、基于 Redis 实现分布式锁

  • 加锁实现思路:

使用 Redis 的 set key value [PX milliseconds] nx 命令

  1. nx只有当键不存在时,才对键进行设置,否则什么也不做
  2. PX milliseconds:设置这个 key 的过期时间,也就是为这个锁设置过期时间,防止因握持锁的客户端运行时崩溃而导致的锁无法释放问题发生。
  3. 为了保证解锁的原子性,我们需要使用 LUA 脚本来实现解锁操作
  4. 为了防止在解锁操作时误解了其他客户端加的锁,我们需要在使用 set key value [PX milliseconds] nx 命令时,为命令中的 value 设置一个唯一的值,这样一来,在进行解锁操作时比对一下本地的值与 Redis 中的 value 是否相等,如果是则解锁,否则什么都不做。

我们可以使用 UUID 来作为唯一值。

  • 如何解锁:只需要将该 key 对应的记录从 Redis 中删去即可,但是删去前需要查看本地 value 的值和 Redis 上 Value 的值是否一致。

  • 存在的问题:

  1. 可能存在同一个资源被多个客户端握持的状况,这种情况下分布式锁的互斥性被破坏

如果存储 key 对应的那个 Redis 节点挂了的话,那么就可能存在多个客户端同时握持锁的情况:客户端 A 从 Redis Master 节点中获取到分布式锁,而 Master 主节点在将数据同步给 Slave 之前挂掉了,于是 Slave 中不存在客户端 A 握持锁的记录,相当于客户端 A 获取的锁丢失了,这个时候主从切换, Slave 从从机变为主机。此时客户端 B 发起了一次相同的资源请求,就获得了一把与客户端 A 相同的锁(Slave 没有 A 获取锁的记录)。

  1. SETNX 是一个耗时操作,因为它需要判断 Key 是否存在,因为会存在性能问题。
  2. 可能发生逻辑还没有执行完,锁就已经失效的情况

这种情况可以使用 Redis 的看门狗(Watch Dog) 功能,当锁即将过期时自动延长一倍的时间

3、基于 Zookeeper 实现分布式锁

  • Zookeeper 简介

Zookeeper 是一个分布式的、开放源码的分布式应用程序协调组件,它是一个为分布式应用提供一致性服务的软件。

  • Zookeeper 的集群机制

Zookeeper 是为其他分布式程序提供服务的,所以需要保证自身的高可用性,Zookeeper 是基于 CP 来设计的,即任何时候对 Zookeeper 的访问请求都能得到一致的数据结果

Zookeeper 的集群机制采用的是半数存活机制,也就是整个集群的节点中有半数以上的节点存活时,那么整个集群环境可用,所以一般在部署 Zookeeper 集群时最好部署奇数个节点。

  • Zookeeper 实现分布式锁的原理:使用临时顺序节点来实现分布式公平
  1. 一个分布式锁通常使用一个 Znode 节点表示,这个 Znode 节点尽量是持久节点,在进行加锁时,如果对应的 Znode 节点不存在,那么需要先创建 Znode 节点;

一个 Znode 节点就代表了一把分布式锁。

  1. 如果某个客户端希望抢占这把 Znode 锁,那么它需要在 Znode 节点下创建一个临时有序节点,这些临时有序节点尽量公用一个有意义的子节点前缀
  2. 如果客户端是当前 Znode 节点下序号最小的顺序节点,那么创建这个最小顺序节点的客户端就获得了这把锁。
  3. 对于获取锁失败的客户端来说,它会让自己创建的顺序节点去监听它的上一个临时顺序节点

比如说此时有三个客户端抢夺 Znode 这把锁,客户端 A 是当前 Znode 下临时顺序节点列表中序号最小的(01),而客户端 B 和 C 分别创建了序号为 02 和 03 的临时顺序节点,如下图

image.png

  1. 一旦队列后面的节点,获取前一个临时顺序节点变更通知,那么就开始进行判断,判断自己是否为当前子节点列表中序号最小的子节点,如果是,则认为加锁成功;如果不是则继续监听,直到获取锁
  2. 获取锁后,开始处理业务流程。完成业务流程后,删除自己的对应的子节点,完成释放锁的工作,以方面后继节点能捕获到节点变更通知,获得分布式锁。
  • 如何使用 Zookeeper 实现可重入锁?
  1. 我们在代码中加入一个加锁的计数器 lockCount ,计算重复加锁的次数,如果是同一个线程加锁,那么只需要增加 lockCount 并直接返回即可,此时表示加锁成功。

  2. 在可重入锁的解锁方法中,我们主要完成两个工作:

    1. 减少 lockCount 的值,如果最终的值不是 0 ,那么直接返回,表示成功释放了一次锁
    2. 计算减少后 lockCount 的值为 0 ,那么就删除客户端创建的临时顺序节点。

为了尽量保证线程安全,可重入计数器的类型,使用的不是 int 类型,而是 Java 并发包中的原子类型——AtomicInteger

  • 为什么 Zookeeper 可以用来实现分布式锁?
  1. Zookeeper 的每一个节点,都是天然的顺序发号器

在每一个节点下创建临时顺序节点类型,新的子节点后面,会加上一个次序编号,而这个生成的次序编号是上一个生成的次序编号 + 1

  1. ZooKeeper 节点的递增有序性,可以确保锁的公平

为了确保公平,可以简单的规定:编号最小的那个节点,表示获得了锁。所以,每个线程在尝试占用锁之前,首先判断自己是排号是不是当前最小,如果是,则获取锁。

  1. ZooKeeper 的节点监听机制,可以保障占有锁的传递有序而且高效
  2. ZooKeeper 的节点监听机制,能避免羊群效应

ZooKeeper 这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反应

  • 优点:ZooKeeper 分布式锁能有效的解决分布式问题,不可重入问题,使用起来也较为简单。

  • 缺点:性能不太高

Zookeeper 在创建锁和释放锁的过程中,需要动态创建、销毁临时顺序节点;

而 Zookeeper 集群中创建和删除节点只能由 Leader 服务器来执行,然后 Leader 服务器还需要将数据同步到所有的 Follower 机器上,频繁的网络通信会使得性能的短板变得非常突出。

二、Java 面试题

2.1、如何决定使用 HashMap 还是 TreeMap?

1、介绍

  • TreeMap<K,V> 中的 Key 要求实现 java.lang.Comparable 接口,所以迭代的时候 TreeMap 默认是按照 Key 值升序排序的;TreeMap 的实现是基于红黑树结构。适用于按自然顺序或自定义顺序遍历键(key)
  • HashMap<K,V> 的 Key 值实现散列 hashCode()分布是散列的、均匀的,不支持排序;数据结构主要是桶(数组),链表或红黑树。适用于在 Map 中插入、删除和定位元素。

2、结论

  • 如果希望得到一个有序的结果时,那么我们应该使用 TreeMap ,和 TreeMap 相比,HashMap 拥有更好的性能,所以大多数不需要排序的情况下,我们应该使用 HashMap
  • 二者都是线程不安全的

2.2、线程池中阻塞队列的作用?为什么先将多余的任务添加到队列而不是先创建最大线程?

1、阻塞队列的作用

  • 一般的队列只能作为一个有限长度的缓冲区,当往已满的队列插入数据时,那个多余的数据将丢失(数据无法丢失),放在线程池中,就无法保留当前需要执行的任务了,而阻塞队列可以通过阻塞保留住当前想继续入队的任务
  • 阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,让线程进入 wait 状态,释放 CPU 资源
  • 阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的 take 方法挂起,从而维持核心线程的存活,不至于一直占用 CPU 资源

2、为什么先将多余的任务放入队列而不是先扩容到最大现场?

  • 在创建新线程时,需要获取全局锁,这个过程需要阻塞其他线程,会影响整体效率。

2.3、可重入锁(递归锁)

1、简介

指在同一线程在外层方法获取锁时,再进入该线程内部方法会自动获取锁(前提:锁对象是同一个),不会因为之前已经获取过还没释放而阻塞。

Java 中的 synchronized 和 ReentrantLock 都是可重入锁,可重入锁的一个优点是可以再一定程度上避免死锁。

2、种类

  • 隐式锁(synchronized 关键字使用的锁)默认是可重入锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    static Object lockObject = new Object();
   public static void m1() {
       new Thread(() -> {
           synchronized (lockObject) {
               System.out.println(Thread.currentThread().getName() + "\t" + "----->外层调用");
               synchronized (lockObject) {
                   System.out.println(Thread.currentThread().getName() + "\t" + "----->中层调用");
                   synchronized (lockObject) {
                       System.out.println(Thread.currentThread().getName() + "\t" + "----->内层调用");
                  }
              }
          }
      },"t1").start();
  }

   public static void main(String[] args) {
       m1();
  }

查看执行结果

image-20210818222500366

  • 显式锁(Lock)也有 ReentrantLock 这样的可重入锁

3、底层实现

每个锁对象都拥有一个锁计数器和一个指向持有该锁的线程的指针

  • 当执行 monitorenter 指令时,如果当前目标锁对象的计数器为 0 ,那么说明它没有被其他线程所持有, Java 虚拟机会将该锁对象的持有线程设置为当前线程,并将其计数器 + 1
  • 在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器 + 1,否则需要等待,直到所有线程释放该锁
  • 当执行 monitorexit 指令时,Java 虚拟机需要将该锁对象的计数器 - 1,计数器为 0 时代表该锁已经被释放

2.4、如何保证接口幂等性?

1、幂等性是什么?

所谓幂等,就是任意多次执行所产生的影响均与一次执行的影响相同。 幂等性接口是指可以使用相同参数重复执行,并能获得相同结果的接口。

2、什么情况下会产生幂等性问题?

  • 网络波动, 可能会引起重复请求
  • 用户重复操作,用户在操作时候可能会无意触发多次下单交易,甚至没有响应而有意触发多次交易应用
  • 使用了失效或超时重试机制(Nginx 重试、RPC 重试或业务层重试等)
  • 页面重复刷新
  • 使用浏览器后退按钮重复之前的操作,导致重复提交表单
  • 使用浏览器历史记录重复提交表单
  • 浏览器重复的 HTTP 请求
  • 定时任务重复执行
  • 用户双击提交按钮

3、如何解决?

  • 传统方法是在代码中增加前置判断,如果接口已经生效,那么不做调整
  • 构建幂等表来解决幂等性问题
  • 在表中添加 version 字段,使用乐观锁保证接口幂等性

4、使用幂等表保证接口幂等性

这个方案需要用到应用网关(Nginx + LUA) 和 Redis,实现思路如下

  • 强制要求每个请求都在请求头中存放一个唯一的请求 id ,这个 id 由发起者生成
  • 请求通过应用网关进行转发,幂等表存放在 Redis 中,结构为 键 - 值 - expire
  • 在请求到达网关时,网关会判断该请求的 id 是否在幂等表中,如果不在幂等表中,那么将 id 与请求状态(PROC:意为处理中)、过期时间一起放入 Redis 中,然后将请求转发给后台服务

image-20210818231444725

  • 如果此时前台再次发送了请求(id 与上次一致),那么此时由于该请求对应的 id 键已经存在与 Redis 中,且值为 PROC (处理中),所以网关不会再将请求转发到后台,而是直接返回一个结果,告诉前台请求被重复发送

image-20210818231705700

  • 在后台系统处理完第一次的请求后,它会执行两个操作
  1. 向前台返回一个结果
  2. 修改此次请求 id 在 Redis 中对应的状态值,将值从 PROC 该为 OK ,意为此次请求执行完成

image-20210818231931003

关于后台数据服务处理请求后对 Redis 的操作,可以使用 AOP + 自定义注解进行处理

2.5、布隆过滤器

1、缓存穿透

缓存穿透是指用户请求的数据在缓存和数据库中均不存在,缓存穿透可能会使数据库的压力陡增,严重的可能导致数据库宕机瘫痪,使用布隆过滤器可以有效减缓这种情况。

值得注意的是,低频的缓存穿透是不可避免的,但是我们可以拦截大部分的恶意请求,减少数据库的压力。

2、简介

布隆过滤器是 1970 年由布隆提出来的,它由一个很长的 bit 数组(数组元素只有 0 与 1)和一系列 hash 函数组成

布隆过滤器可以用于检索一个元素是否在一个集合中

3、原理

  • 布隆过滤器中数组中的每个元素都只占 1 bit 空间,且只能为 0 或者 1 ,在初始状态中,布隆过滤器中的元素均为 0

image-20210819211609220

  • 除数组以外,布隆过滤器还拥有 k 个哈希函数,当一个元素加入布隆过滤器时,会使用这 k 个哈希函数对其进行 k 次运算,得到 k 个哈希值,并根据得到的哈希值,把数组中对应下标的值置为 1,如果数组下标对应的值已为 1 ,那么不做任何处理。

image-20210819211649851

  • 当我们需要判断某个值是否在集合中时,可以使用上面的 k 个哈希函数对该值进行运算,这 k 次运算会得到 k 个结果,我们可以以这 k 个结果作为下标,到布隆过滤器中查看对应的元素是否都为 1 ,如果 k 个下标对应的元素都为 1 **,那么证明这个值可能存在于集合中,如果 k 个下标对应的元素有一个不为 1** ,那么证明集合中一定没有这个元素

4、布隆过滤器添加步骤

  • 将要添加的元素交给 k 个哈希函数进行运算
  • 得到对应于位数组上的 k 个位置
  • 将这 k 个位置的值置为 1

5、查询元素

  • 将要添加的元素交给 k 个哈希函数进行运算
  • 得到对应于位数组上的 k 个位置
  • 如果这 k 个位置对应的值有一个为 0 ,那么证明这个元素绝对不在集合中
  • 如果这 k 个位置对应的值全部为 1 ,那么证明这个元素可能在集合中

6、优点

  • 插入和查询的时间复杂度均为 O(K) ,k 为哈希函数个数
  • 哈希函数之间没有关系,方便由硬件并行实现
  • 布隆过滤器不存储元素本身,在某些需要保密的场合中具有优势
  • 布隆过滤器可以表示全集

7、缺点

  • 误算率

随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,那么使用散列表就已经足够

  • 不能删除元素

布隆过滤器因为某一位二进制可能被多个编号 hash 引用,所以布隆过滤器无法直接处理删除数据的情况

有以下两种解决方案

  1. 使用计数布隆过滤器
  2. 使用定时任务异步重建布隆过滤器

8、在项目中的使用流程

  • 启动应用时初始化布隆过滤器
  • 接收用户发来的请求时,先使用布隆过滤器判断该编号是否在集合中
  1. 如果不存在,那么直接返回数据不存在回应
  2. 如果存在,那么先读取 Redis 中的数据,如果 Redis 中没有对应缓存时,再读取 MySQL ,之后将数据载入缓存中

2.6、Redis 缓存与数据库双写一致性解决方案

1、需求起因

在高并发的业务场景下,数据库大多数情况下是用户并发访问中最薄弱的环节。所以,就需要使用 Redis 做一个缓冲操作,让请求先访问 Redis ,而不是直接访问数据库。

  • 在引入 Redis 中,访问流程可以用下图表示

image-20210819220736658

上面的流程中,如果涉及到数据更新操作,那么就容易出现缓存(Redis)和数据库间数据不一致的问题

一般来说,对数据库和缓存的操作主要有以下两种方式

  • 先删缓存,然后更新数据库
  • 先更新数据库,然后删缓存

2、先删缓存后更新数据库

这种操作方式下,先删除缓存,如果数据库还没有更新成功,那么此时读取缓存时,由于缓存不存在,那么会去数据库中读取没有更新成功的旧数据,此时缓存不一致发生

image-20210819222509054

  • 解决方案

使用延时双删

延时双删的方案的思路是,为了避免更新数据库的时候,其他线程从缓存中读取不到数据,就在更新完数据库之后,再 Sleep 一段时间,然后再次删除缓存,Sleep 的时间需要根据业务读写缓存的时间做出评估,需要保证 Sleep 的时间大于读写缓存的时间。

流程如下:

  1. 线程 1 删除缓存,然后去更新数据库。
  2. 线程 2 来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程 1 还没有更新完成,所以读到的是旧值,然后把旧值写入缓存。
  3. 线程 1 根据评估的时间进行休眠,由于 Sleep 的时间大于线程 2 读数据 + 写缓存的时间,所以缓存被再次删除。

如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值。

3、先更新数据库后删缓存

更新数据库成功,如果删除缓存失败或者还没有来得及删除,那么,其他线程从缓存中读取到的就是旧值,还是会发生不一致。

image-20210819223636869

  • 可以使用消息队列的重试机制来实现缓存同步,达到最终一致性的效果

但为了保证缓存一致性便引入一个新的中间件,得不偿失

  • 设置缓存过期时间

对于一些不频繁变动的数据,可以使用这种方案

4、为什么是删除缓存而不是更新缓存?

  • 如果我们需要对缓存进行更新操作,那么当数据库在短时间内发生多次变动时(假设发生了 1000 次变动),那么我们需要对缓存进行一千次操作,但是这个缓存在这段时间内只被读取了一次,那么我们对缓存做的这些更新操作将会变得得不偿失
  • 如果是删除的话,那么即使缓存更新了 1000 次,我们也只需要删除一次,且当只有缓存被真正读取到时,才会去数据库中加载。

2.7、泛型

1、泛型标记和泛型限定

  • 在使用泛型之前,我们需要了解一下有哪些泛型标记
序号泛型标记说明
1E - Element在集合中使用,表示在集合中存放的元素
2T - Type表示 Java 类,包括基本的类和我们自定义的类
3K - Key表示键,比如 Map 中的 Key
4V - Value表示值
5N - Number表示数值类型
6?表示不确定的 Java 类型
  • 类型通配符使用 ? 来表示所有具体的参数类型,比如说 List<?> 在逻辑上是 List、 List等所有 List< 具体类型实参 > 的父类

在使用泛型时,如果我们希望将类的继承关系加入到泛型应用中,就需要对泛型做限定,具体的泛型限定有对泛型上限的限定对泛型下限的限定

  1. 对泛型上限的限定:<? extends T>

在 Java 中使用通配符 ?extends 关键字指定泛型的上限,具体用法为 <? extends T> ,它表示该通配符所代表的类型是 T 类的子类或者接口 T 的子接口

  1. 对泛型下限的限定:<? super T>

在 Java 中使用通配符 ?super 关键字指定泛型的下限,具体用法为 <? super T> ,它表示该通配符所代表的类型是 T 类的父类或者接口 T 的父接口

2、类型擦除

  • 在编码阶段采用泛型时加上的类型参数,会被编译器在编译阶段去除,这个过程被称为类型擦除。
  • 泛型主要用于编译阶段,在编译后生成的 Java 字节码文件中不含有泛型中的类型信息

比如说,List<Integer>List<String> 在经过编译后统一为 List,而 JVM 所读取的也只是 List ,由泛型附加的类型信息对 JVM 来说是不可见的

  • 擦除过程
  1. 找到用于替换参数的具体类,该类一般是 Object ,也就是说:List<Integer>List<String> 在经过类型擦除后,参数类一般都会被替换为 Object
  2. 如果指定了类型参数的上界(<? extends T>),那么以该上界作为替换时的具体类

比如说,对于 List<? extends Person> ,在进行擦除后会将其替换为 Person

  1. 将代码中的类型参数都替换为具体的类。

2.8、序列化

  • Java 对象在 JVM 运行时被创建、更新和销毁,当 JVM 退出时,对象随之销毁,也就是说,这些对象的生命周期不会比 JVM 的生命周期更长。
  • 现实中,我们常常需要将对象及其状态在多个应用中传递、共享或者持久化,然后在其他地方重新读取被保存、转移的对象,这就需要通过将 Java 对象序列化来实现。
  • 在使用 Java 序列化技术保存对象及其状态信息时,对象及其状态信息会被保存在一组字节数组中,在我们需要时,就将字节数组重新反序列化为一个对象

类中的静态变量不会被序列化

  • 除了持久化,在 RPC 或者网络传输中也会经常使用序列化技术。

1、Java 序列化技术的使用

Java 序列化 API 为处理对象序列化提供了一个标准机制,我们在使用 Java 序列化时需要注意以下几点

  • 如果一个类要实现序列化功能,那么它必须实现 java.io.Serializable 接口即可
  • 序列化和反序列必须保持序列化的 ID 一致,我们一般在类中声明一个私有的静态常量来定义序列化 id:private static final long serialVersionUID
  • 序列化不保存静态变量
  • 在需要序列化父类变量时,要求父类也实现 java.io.Serializable 接口。
  • 使用 transient 关键字可以阻止对象的某个成员变量被序列化,在反序列化后,被 transient 修饰的变量会被赋予对应类型的初始值,比如说 int 是 0 ,boolean 是 false

2、序列化和反序列化

在 Java 中有很多优秀的序列化框架,比如 protobufthriftfastjson 等,我们也可以基于 JDK 原生的 ObjectOutputStreamObjectInputStream 类实现对象的序列化和反序列化,并调用其 writeObjectreadObject 方法实现自定义序列化策略。

2.9、线程池的拒绝策略

1、AbortPolicy

直接抛出异常,阻止线程正常运行,这是默认的拒绝策略,JDK 的源码如下

1
2
3
4
5
6
7
8
9
public static class AbortPolicy implements RejectedExecutionHandler {
public AbortPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// 直接抛出异常信息,不做任何处理
throw new RejectedExecutionException("Task " + r.toString() +
" rejected from " +
e.toString());
}
}

2、CallerRunsPolicy

如果被丢弃的线程任务未关闭,则执行该线程任务CallerRunsPolicy 拒绝策略不会真的丢弃任务,将线程任务交给提交者执行,JDK 实现源码如下

1
2
3
4
5
6
7
8
9
  public static class CallerRunsPolicy implements RejectedExecutionHandler {
public CallerRunsPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
// 直接调用 run 方法,让提交任务的线程执行方法
r.run();
}
}
}

3、DiscardOldestPolicy

移除线程队列中最早的一个线程任务,然后尝试提交当前任务,具体实现如下

1
2
3
4
5
6
7
8
9
10
11
  public static class DiscardOldestPolicy implements RejectedExecutionHandler {
public DiscardOldestPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
// 将阻塞队列中的队首元素丢弃,队首元素就是最早提交的任务
e.getQueue().poll();
// 尝试提交线程任务
e.execute(r);
}
}
}

4、DiscardPolicy

直接丢弃当前的线程任务,不做任何处理,如果系统允许在资源不足的情况下丢弃部分任务,那么这将是一个保证系统稳定安全的一种非常合适的方案

1
2
3
4
5
6
  public static class DiscardPolicy implements RejectedExecutionHandler {
public DiscardPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// 这是一个空方法,直接丢弃线程,不做任何处理
}
}

5、自定义拒绝策略

以上 4 种拒绝策略均实现 RejectedExecutionHandler 接口,若无法满足实际需要,那么我们可以自定义拒绝策略, 这个拒绝策略的实现类需要实现 RejectedExecutionHandler 接口。

2.10、Java 阻塞队列

队列是一种只允许在表的前端进行进行删除操作而在表的后端进行插入操作的线性表。阻塞队列和一般队列的不同之处在于阻塞队列是“阻塞” 的,这里的阻塞指的是操作队列的线程的一种状态。在阻塞队列种,线程阻塞有如下两种状态:

  • 消费者阻塞:在队列为空时,消费者端的线程都会被自动阻塞(挂起),直到有数据放入到队列,当有数据放入到队列时,消费者端线程会被自动唤醒并消费数据

image.png

  • 生产者阻塞:在队列已满且没有可用空间时,生产者端的线程都会被自动阻塞(挂起),直到队列中有空的位置腾出,当队列中有新的可用空间时,线程会自动被唤醒并生产数据。

image.png

2.11、CDN 的原理

1、介绍

CDN 全称 Content Delivery Network ,即内容分发网络,指基于部署在各地的机房服务器,通过中心平台的负载均衡、内容分发、调度的能力,使用户就近获取所需内容,降低网络延迟,提升用户访问的响应速度和体验度

2、CDN 的关键技术

CDN 的关键技术包括内容发布、内容路由、内容交换和性能管理

  • 内容发布:借助建立索引、缓存、流分裂、组播等技术,将内容发布到网络上距离用户最近的中心机房
  • 内容路由:通过内容路由器中的重定向(DNS)机制,在多个中心机房的服务器上负载均衡用户的请求,使用户从最近的中心机房获取数据。
  • 内容交换:根据内容的可用性、服务器的可用性及用户的背景,在缓存服务器上利用应用层交换、流分裂、重定向等技术,智能地平衡负载流量
  • 性能管理:通过内部和外部监控系统,获取网络部件的信息,测量内容发布的端到端性能,保证网络处于最佳运行状态

3、CDN 的主要特点

  • 本地缓存加速:将用户经常访问的数据(尤其是静态数据)缓存在本地,以提升系统的响应速度和稳定性
  • 镜像服务:消除不同运营商之间的网络差异,实现跨运营商的网络加速。
  • 远程加速:利用 DNS 负载均衡技术为用户选择服务质量最近的服务器,加快用户远程访问的速度
  • 带宽优化:自动生成服务器的远程镜像缓存服务器,远程用户在访问时从最近的缓存服务器上读取数据,减少远程访问的带宽,分担网络流量,降低原站点的 Web 服务器负载

4、内容分发系统

  • 将用户请求的数据分发到就近的各个中心机房,以保障为用户提供快速、高速的内容服务
  • 缓存的内容包括静态图片、视频、文本、用户最近访问的数据等。
  • 缓存技术包括内容环境、分布式缓存、本地文件缓存等。
  • 缓存策略主要考虑缓存更新和缓存淘汰机制。

2.12、Redis 的发布订阅

  • Redis 的发布和订阅本质上是一种消息通信模式:发送者向频道发送消息,订阅者接收频道上的消息。
  • Redis 客户端可以订阅任意数量的频道,发送者也可以向任意频道发送数据。

image.png

在上图中,频道 channel0 被三个订阅者所订阅,在发布者向频道 channel0 发送一条数据后,订阅了这个频道的所有接收者都可以收到这条消息。

2.13、Redis 集群数据复制的原理

  • Redis 提供了复制功能,可以实现在主数据库(Master)中的数据更新后,自动将新的数据同步到从数据库(Slave)的功能。
  • 一个主数据库可以拥有多个从数据库,而一个从数据库只能拥有一个主数据库。
  • Redis 的主从复制原理如下:

image.png

  1. 一个从数据库启动后,会向主数据库发送 SYNC 命令
  2. 主数据库在接收到 SYNC 命令后会开始在后台保存快照(即 RDB 持久化的过程),并将保存快照期间接收到的命令缓存起来。在持久化过程中会形成一个 .rdb 文件
  3. 在主数据库快照执行完成后,Redis 会将快照文件和所有在保存快照期间缓存的命令以 .rdb 文件的形式发送给从数据库。
  4. 从数据库收到主数据库的 .rdb 快照文件后,载入该快照文件到本地
  5. 从数据库执行载入后的 .rdb 快照文件,将数据写入到内存中。

以上过程被称为复制初始化

  1. 在复制初始化结束后,主数据库在每次收到写命令时都会将命令同步给从数据库,从而保证主从数据库的数据一致。
  • 如何开启 Redis 的主从复制功能?

主数据库无须进行任何配置,只需要在从数据库的配置文件中添加如下配置即可

1
2
3
4
# 指定主数据库的ip和端口号
slaveof ip port
# 如果主数据库有密码,那么需要配置密码
masterauth=123

2.14、分布式缓存设计的核心问题

1、缓存预热

缓存预热指用户请求数据前先将数据预先加载到缓存系统中,用户查询事先被预热的缓存数据,以提高系统查询效率。

缓存预热一般有系统启动加载定时加载等方式

2、缓存淘汰策略

  • FIFO(First In First Out,先进先出):判断缓存被存储的时间,离目前最远的数据优先被淘汰
  • LRU(Least Recently Used,最近最少使用):判断缓存最近被使用的时间,距离当前时间最远的数据优先被淘汰
  • LFU(Least Frequently Used,最不经常使用):在一段时间内,被使用次数最少的数据优先被淘汰。

2.15、HTTPS 加密流程

image.png

  • 发起请求:客户端在通过 TCP 和服务器建立连接之后(443 端口),发出一个请求证书的消息给服务器,在该请求中包含自己可实现的算法列表和其他需要的消息
  • 证书返回:服务器端在接收到消息后回应客户端并返回证书,在证书中包含服务器消息、域名、申请证书的公司、公钥、数据加密算法等。
  • 证书验证:客户端收到证书后,判断证书签发机构是否正确,并使用该签发机构的公钥确认签名是否有效,客户端还会确保在证书中列出的域名是否就是它正在连接的域名。如果客户端确认证书有效,则生成对称密钥,并使用公钥将对称密钥加密。
  • 密钥交换:客户端将加密后的对称密钥发送给服务器,服务器在接收到对称密钥后使用私钥解密。
  • 数据传输:经过上述步骤,客户端和服务器就已经完成了密钥对的交换,在之后的数据传输过程中,客户端和服务端就可以基于对称加密对数据加密后在网络上传输,这保证了网络数据传输的安全性。

2.16、Redis 持久化

1、持久化简介

Redis 是内存数据库,所有的数据全部存储在内存中,如果突然宕机,那么数据就会全部丢失,因此必须有一套机制来保证 Redis 中的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制,它会将内存中的数据库状态保存到硬盘中。

2、持久化方式一 – RDB

Redis 快照是最简单的 Redis 持久化模式。当满足特定条件时,它将生成数据集的时间点快照。此条件可以由用户配置 Redis 实例来控制,也可以在运行时修改而无需重新启动服务器。快照作为包含整个数据集的单个 .rdb 文件生成。

  • Redis 是单线程的程序,当我们进行 Redis 持久化时,会使用到操作系统的 Copy On Write 机制与 fork 函数

Redis 在持久化时会 fork 出一个子进程,可以简单理解为基于当前的 父进程 复制出一个进程,主进程和子进程会共享内存里面的代码块和数据段

  • 在 fork 出子进程后,Redis 会将快照持久化工作完全交给子进程来处理,而父进程则继续处理客户端请求。

子进程只对数据做持久化工作,而不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化到磁盘中。

父进程会持续接收客户端请求,然后不断对内存数据结构进行修改。

  • 对于子进程来说,内存中的数据在子进程产生的一瞬间就凝固了,再也不会改变,这也是为什么 Redis 的 RDB 持久化被称为快照的原因。
  • RDB 不能完全保证数据的可靠性,如果运行 Redis 的计算机宕机,那么写入 Redis 的最新数据将丢失。(RDB 可能丢失最后一次同步后的数据)

3、持久化方式二 – AOF

  • AOF (Append Of File)记录对服务器的每次写操作,在 Redis 重启时会重放这些命令来恢复原数据。
  • AOF 命令以 Redis 协议追加和保存每次写操作到文件末尾,Redis 还能对 AOF 文件进行后台重写,使得 AOF 文件的体积不至于过大。
  • AOF 的特点有:
  1. 可以采用不同的 fsync 策略(无 fsync 、 每秒 fsync 和 每次写的时候 fsync
  2. 只有某些操作追加命令到文件中,操作效率高
  3. AOF 文件是日志的格式,更容易被操作。
  • Redis 收到客户端修改指令后,会先进行参数校验、逻辑处理,如果没有问题,就立即将该指令文本存储到 AOF 日志中,也就是说,Redis 是先执行指令再将日志存盘,这一点不同于 MySQL

这样做的目的可能是因为 AOF 文件会比较大,为了避免写入无效指令(错误指令),所以必须做指令检查,也就是先执行指令。执行指令可以过滤掉大部分无效指令。

  • AOF 重写

Redis 在长期运行的过程中,AOF 的日志会变得越来越长,如果实例宕机后重启,重放整个 AOF 日志就会变得非常耗时,导致长时间 Redis 无法对外提供服务,所以需要对 AOF 文件进行重写瘦身。

Redis 提供了 bgrewriteaof 指令用于对 AOF 文件进行瘦身,原理是开辟一个子进程对内存进行遍历并转换为一系列的 Redis 操作指令集,然后将其序列化到一个新的 AOF 文件中序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 文件中,追加完毕后用最新的 AOF 日志取代旧的 AOF 日志,这样就完成了重写工作。

4、混合持久化

  • 使用 RDB 恢复内存状态的效率高,但可能会丢失大量数据。
  • 使用 AOF 恢复内存状态的效率不高,但数据相对完整
  • 混合持久化是 Redis 4.0 引入的一个新的持久化选项,它可以尽量发挥两个持久化方式的优点

先将 RDB 文件的内容和增量的 AOF 日志文件存在一起,这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的 AOF 日志,这段日志通常很小

image.png

在 Redis 重启时,可以先加载 RDB 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率大大提升。

2.17、Redis 主从复制的作用

  • 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式
  • 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速地故障恢复
  • 负载均衡:在主从复制地基础上,配合读写分离,可以由主节点提供写服务,从节点提供读服务,分担服务器负载。

尤其是在写少读多地场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器地并发量

  • 高可用基石:主从复制是哨兵和集群能够实施的基础,也就是说,主从复制是 Redis 高可用的基础。

2.18、Redis 数据类型对应的编码和数据结构

1、String

  • String 是最常用的一种数据类型,普通的 key / value 存储都可以归结为 String 类型,value 不仅是 String ,也可以是数字。其他几种数据类型的构成元素也都是字符串

Redis 规定字符串的长度不能超过 512M

  • 字符串对象的编码可以是 intrawembstr
  1. int 编码

保存的是可以用 long 类型表示的整数值

  1. raw 编码

保存长度大于 44 字节的字符串,用于保存长字符串

  1. embstr 编码

保存长度小于 44 字节的字符串,用于保存短字符串,它是专门保存短字符串的一种优化编码

  • 编码的转换
  1. int 编码保存的值不再是整数,或大小超过 long 类型的范围时,编码会自动转换为 raw
  2. 对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),所以在对 embstr 对象进行修改时,都会先转换为 raw 在进行修改,因此,只要是修改 embstr 对象,那么修改后的对象一定是 raw 编码

2、List

  • List 是列表,它是简单的字符串列表,可以任意从头部 / 尾部存取位置,我们可以用 List 来模拟栈、队列等特殊的数据结构。

  • List 对象的编码可以是 ziplist (压缩列表)和 linkedlist (双端链表)

  1. ziplist 压缩列表

当满足列表元素个数小于 512 个,且每个元素长度小于 64 字节时,会使用压缩列表对对象进行编码

  1. linkedlist 双端链表

不能满足以上两个条件其中之一时,使用双端链表对对象进行编码

3、Set

  • Set 是无序集合
  • Set 对象的编码可以是 intset 或者 hashtable
  1. intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合中。
  2. hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,这里的每个字符串对象就是一个集合中的元素,而字典的值全部设置为 null

这里的实现有点像 Java 中 HashMap 和 HashSet 的关系。

  • 当集合中的所有元素都是整数,且集合对象中的元素数量不超过 512 时,使用 intset 编码

  • 应用场景

  1. 利用 Set 的交集、并集、差集等操作,可以计算共同爱好,全部爱好、自己独有的爱好和可能认识的人等功能。
  2. Set 底层基于字典实现,且元素不允许有重复,我们可以用它来判断用户名是否注册和全局去重。

4、ZSet

  • 与 Set 相比,ZSet 有序集合是有序的。与列表使用索引下标作为排序依据不同,有序集合为每一个元素设置一个分数(score)作为排序依据

  • ZSet 的编码可以是 ziplistskiplist (跳表)

  1. ziplist 编码的有序集合对象使用压缩链表作为底层实现,每个集合元素使用两个紧挨在一起的压缩链表节点来存储,第一个节点保存元素的成员,第二个节点保存元素的 score

压缩链表中的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。

  1. skiplist 编码的有序集合对象使用 zset 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表
1
2
3
4
5
6
typedef struct zset {
// 跳跃表
zskiplist * zsl;
// 字典
dict * dice;
}

字典的键保存元素的值,字典的值保存元素的分值(score),跳跃表的 object 属性保存元素的成员,跳跃表节点的 score 属性保存元素的分值。这两种数据结构会通过指针来共享相同元素的成员和分支,不会造成空间浪费。

  • 当有序集合对象中保存的元素数量小于 128 且所有元素长度均小于 64 字节时,会使用 ziplist 编码,否则使用 skiplist 编码

5、Hash

  • Hash 对象的键是一个字符串类型,值是一个键值对集合,结构形似 key : {field1: value1, field2: value2 ...}
  • Hash 对象的编码可以是 ziplisthashtable

当使用 ziplist 作为底层实现时,新增的键值总是保存到压缩列表的表尾。

  • 当 Hash 对象中保存的元素个数小于 512 个,且每个元素长度小于 64 字节时,使用 ziplist 编码

  • Hash 是特别适合存储对象

2.19、跳跃表

1、简介

跳表是一种可以与平衡树媲美的层次化链表结构,它的查找、删除、添加等操作都可以在对数期望时间下完成

image.png

  • Redis 中的 ZSet 就是依赖一种名为 跳跃链表 的数据结构完成的。
  • 以空间换时间的思想

2、为什么要使用跳跃表?

  • 由于 ZSet 需要支持随机的插入和删除,所以它不宜使用数组来实现
  • 二叉树中的红黑树与平衡树也具有排序的功能,为什么 Redis 不使用这样的一些结构呢?
  1. 性能考虑:在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及到局部;同时树形数据结构对范围搜索的支持不太友好,而范围搜索又是 ZSet 中经常使用的命令
  2. 实现考虑:在复杂度与红黑树相同的情况下,跳跃表实现起来更简单也更直观。

2.20、统计在线用户数

假设现在一个应用中有 20 亿个用户,希望快速统计出有多少个用户在线,要怎么设计?

1、使用 MySQL

可以设计一张 MySQL 表,在用户进行登录时,将表对应记录值置为 1,退出时值为 0 ,然后查询统计出条件为 1 的数量即可,数据库表的设计如下

image.png

由于用户基数非常大,同时每个用户登录时都需要向数据库进行写入操作,MySQL 的写入压力会非常大,IO 多,会严重拖累系统性能。

2、使用 Redis

我们可以在 Redis 中定义一个 Set 类型的 Key ,这个 Set 用于存储当前登录的用户 id ,当用户登录时,将用户 id 放入 set 中,退出时取出。

我们可以使用 scard 命令获取登录的用户数,还可以用 O(1) 时间复杂度去判断用户是否登录。

image.png

这种做法需要占用的内存非常高,假设有 10 亿个用户处于登录态,那么集合中就有 10 亿个 userid ,假设每个 userid 占用 4byte,那么 10 亿个 userid 就是 40 亿字节。

3、使用 Bitmaps

Bitmaps 的开源实现有 jdk 的 java.util.Bitset,谷歌的 EWAHCompressedBitmap 和 Redis 6.0 新增的 bitmaps

  • 我们可以在 Redis 创建一个 key 为 user_login_status 的位图,以用户 id 作为位图的 offset ,值为该用户的登录状态
  • 如果 offset 对应的值为 1 ,那么表示 offset 对应的用户在线,为 0 则表示不在线
  1. 用户登录

使用 setbit 命令将用户 id 对应的 offset 的值设置为 1 ,假设当前用户 id 为 123 的用户登录了

1
setbit user_login_status 123 1
  1. 用户下线

同样使用 setbit 命令将用户 id 对应的 offset 的值设置为 0 ,假设当前用户 id 为 123 的用户下线了

1
setbit user_login_status 123 0
  1. 判断用户是否在线

使用 getbit 命令,我们可以取出用户 id 对应 offset 的值是否为 1 ,如果是表示用户在线,否则表示用户不在线

1
getbit user_login_status 123
  1. 统计在线人数

使用 bitcount 命令即可

1
bitcount  user_login_status

4、扩展

  • 如何获取已登录且手机为 IOS 的用户 数?

我们可以创建另一个位图,这个位图用于记录手机为 IOS 的用户,同样使用用户 id 作为 offset ,值为 1 表示该用户为 IOS ,为 0 表示为其他系统的用户。

我们可以对两个集合求交集,使用 bitop 命令,将两个位图交集的结果放到另一个 key 表示的位图中,然后对这个位图进行统计即可。

image.png

  • 如何获取已登录或手机为 IOS 的用户数?

将上一个问题的 and 改为 or ,然后对结果位图进行统计即可。

5、Bitmap 总结

  • 优点:
    • 排序、查找、去重等运算的效率非常高
    • 占用的空间小
  • 缺点:
    • 数据不能重复,因为 offset 只有一个
    • 如果数据稀疏度高,那么意味着会有大量 offset 对应的 value 会被浪费,比如说数据只有两个,1 和 10000000,那么使用 Bitmap 中 2 - 9999999 下标都会被浪费(可以使用谷歌的 EWAHCompressedBitmap ,这个开源工具实现了压缩算法)。所以说只有当数据比较密集时,Bitmap 的优势才能发挥出来。

2.21、Atomic 原子类

1、简介

Atomic 翻译为中文是原子的意思,在 Java 中,Atomic 指一个操作是不可中断的。即使处于多线程并发条件下,一个原子性操作一旦开始,就不会被其他线程所干扰

原子类就是指具有原子 / 原子操作特征的类,并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 包下,如下图所示:

image.png

根据操作的数据类型,我们可以将 JUC 包中的原子类分为 4 类:

  • 基本类型:使用原子的方式更新基本类型 `
  1. AtomicInteger :整型原子类
  2. AtomicLong:长整型原子类
  3. AtomicBoolean:布尔型原子类
  • 数组类型:使用原子的方式更新数组中的某个元素
  1. AtomicIntegerArray :整型数组原子类
  2. AtomicLongArray :长整型数组原子类
  3. AtomicReferenceArray :引用类型数组原子类
  • 引用类型
  1. AtomicReference:引用类型原子类
  2. AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来
  3. AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

2、AtomicInteger 类介绍

  • 常用方法
1
2
3
4
5
6
7
public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
  • 优势:在多线程环境下,使用原子类可以保证线程安全

2.22、代理模式

1、简介

  • 代理模式简单来说就是使用代理对象来代替对真实对象的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能
  • 代理模式的主要作用是扩展目标对象的功能
  • 代理模式有静态代理动态代理两种方式
  • 从 JVM 的角度来说,静态代理就是在编译期就将接口、实现类、代理类这些都变为一个个实际的 class 文件

2、静态代理

在静态代理中,我们对目标对象的每个方法的增强都是手动完成的,非常不灵活。

  • 静态代理实现步骤:
  1. 定义一个接口及其实现类;
  2. 创建一个代理类同样实现这个接口;
  3. 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。

这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且在目标方法执行前后进行自定义扩展

  • 实例
  1. 定义一个发送短信的接口,这个接口中有一个抽象方法 send(message)
1
2
3
4
5
6
/**
* @author caibighead
*/
public interface MessageService {
String sendMessage(String message);
}
  1. 创建一个实现类,这个类的实例对象就是我们要增强的目标对象
1
2
3
4
5
6
7
8
9
10
/**
* @author caibighead
*/
public class MessageServiceImpl implements MessageService {
@Override
public String sendMessage(String message) {
System.out.println("发送短信,短信内容为: " + message);
return message;
}
}
  1. 创建一个代理类并同样实现接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MessageProxy implements MessageService {
private final MessageService messageService;

public MessageProxy(MessageService messageService) {
this.messageService = messageService;
}

@Override
public String sendMessage(String message) {
// 调用方法前后进行自定义扩展
System.out.println("调用方法前进行增强...");
messageService.sendMessage(message);
System.out.println("调用方法后进行增强...");
return message;
}
}
  1. 使用
1
2
3
4
5
public static void main(String[] args) {
MessageService messageService = new MessageServiceImpl();
MessageProxy proxy = new MessageProxy(messageService);
proxy.sendMessage("java");
}

结果如下:

image.png

3、动态代理

与静态代理相比,动态代理更加灵活,我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类

  • 从 JVM 的角度来说,动态代理就是在运行时动态地生成代理类的字节码文件,并加载到 JVM 中。

Spring AOP 的实现依赖动态代理技术,Java 动态代理的实现方式有很多种,比如 JDK 动态代理与 CGLib 动态代理

4、JDK 动态代理

  • 介绍

在 JDK 动态代理中,InvocationHandler 接口和 Proxy 类是核心。

  1. Proxy 类中使用频率最高的方法是:newPrxoyInstance() ,这个方法主要用来生成一个代理对象
1
2
3
4
5
6
7
   public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
throws IllegalArgumentException
{
...
}

这个方法一共有三个参数:

ClassLoader loader 表示类加载器,用于加载代理对象

Class<?>[] interfaces 表示被代理类实现的一些接口

InvocationHandler h 表示实现了 InvocationHandler 接口的对象

  1. 要实现动态代理的话,还必须实现 InvocationHandler 来自定义处理逻辑,当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到 InvocationHandler 接口类的 invoke 方法来调用。
1
2
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;

这个方法有三个参数:

Object proxy 表示动态生成的代理类

Method method 表示代理类对象调用的方法相对应

Object[] args 表示当前 method 的参数

也就是说,通过 Proxy 类的 newProxyInstance() 创建的代理对象在调用方法时,实际上会调用 InvocationHandler 的实现类的 invoke() 方法。

我们可以在 invoke() 方法中自定义增强逻辑

  • JDK 动态代理实现步骤
  1. 定义一个接口及其实现类;
  2. 自定义 InvocationHandler 实现类并重写 invoke() 方法,在 invoke() 方法中我们会调用原生方法(被代理对象的方法)并自定义增强逻辑
  3. 通过 Proxy.newProxyInstance(...) 方法创建增强对象。

5、CGLIB 动态代理机制

  • 介绍

JDK 动态代理有一个致命缺点是其只能代理实现了接口的类。也就是说,它要求被代理对象所属的类至少实现了一个接口。

为解决这个问题,可以使用 CGLIB 动态代理来避免。

  • CGLIB 介绍

CGLIB (Code Generation Library) 是一种基于 ASM 的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。

CGLIB 通过继承方式实现代理,许多知名的开源框架都使用了 CGLIB ,例如在 Spring 的 AOP 模块中:如果目标对象所属的类实现了接口,那么默认采用 JDK 动态代理,否则使用 CGLIB 动态代理

6、JDK 动态代理和 CGLIB 动态代理对比

  • JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理为实现任何接口的类。另外, CGLIB 动态是通过生成一个被代理的子类来拦截被代理类的方法调用,因此 CGLIB 不能代理声明为 final 的类和方法。
  • 就二者的效率来说,大部分情况是 JDK 动态代理更优秀,随着 JDK 版本升级,这个优势将更加明显。

2.23、Spring 的事件通知机制

1、观察者模式

观察者模式是常用的设计模式之一,是一种一对多的组合关系,在观察者模式中存在观察者被观察者两种角色,观察者和被观察者之间没有直接的类调用关系,有着清晰的模块划分界限,从而提高了软件的可维护性

image.png

  • 被观察者是我们感兴趣的对象,我们希望了解被观察者的状态变化,并且被动地接受变化消息。

当被观察者状态改变时,发送消息通知观察者。

  • 观察者可以有多个,互相之间一般没有依赖,也不需要确保观察者接收消息的先后顺序。

2、Spring 的事件通知机制

  • Spring 中的事件通知机制就是观察者模式的一种实现,其中被观察者是消息发送者(ApplicationEventListener),而观察者是消息接收方(ApplicationListener)

  • 当被观察者状态发生变化时,它会调用 ApplicationEventListenerpublishEvent 方法发送一个事件对象(Spring Event) 来通知所有观察者

3、优势

  • 通过 Spring Event 可以解耦代码,观察者和被观察者得以分开开发,中间通过事件作为联系,不用关心另一方如何实现。
  • 由于存在多个观察者,所以对于同一个事件,不同的观察者可以有多种不同的处理方式,增加了灵活性

2.24、try-catch-finally 的执行顺序

try-catch-finally 的执行顺序结论:

  • 不管有没有出现异常,finally 块的代码都会执行。
  • 当 try 和 catch 中有 return 时,finally 的代码依然会执行
  • finally 是在 return 后面的表达式运算后执行的

此时并没有返回运算后的值,而是先把要返回的值保存起来,不管 finally 中的代码怎么样,返回的值都不会改变,任然是之前保存的值),所以函数返回值是在 finally 执行前确定的

  • finally 中最好不要包含 return,否则程序会提前退出,返回值不是 try 或者 catch 中保存的返回值。
  1. 测试案例一
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
System.out.println(testFinally1());

}
public static int testFinally1() {
int x = 1;
try {
x++;
return x;
} finally {
x = 3;
}
}

由于返回值在执行 finally 块前就已经确定,所以返回的 x 为 2

image.png

2.25、构造函数和构造代码块的区别和应用

1、概念说明

  • 构造代码块:在类中直接使用 {} 定义的代码块称为构造代码块,每 new 对象都会执行其中的代码
  • 构造函数:给与之对应的对象进行初始化,它具有针对性,是函数中的一种。

2、区别

  • 共同点:都是用来初始化对象的
  • 不同点:构造代码块用于给所有的对象进行统一初始化,而构造函数是给对应的对象进行初始化。

构造代码块中的代码其实也是在构造方法中执行的,在编译时编译器会默认将构造代码块中的代码移动到构造函数中,并且移动到构造函数的内容前,也就是说,构造代码块中的代码的执行时机在构造函数的代码之前。

2.26、什么时候触发 GC

  • 程序调用 System.gc() 方法时可以触发
  • 系统自身来决定 GC 触发的时机

GC 又分为 Minor GC 和 Full GC

  1. Minor GC 触发条件:当新生代的 Eden 区满时,触发 Minor GC
  2. Full GC 触发条件:
    1. 调用 System.gc() 时,系统建议执行 Full GC
    2. 老年代空间不足
    3. 方法区空间不足
    4. 通过 Minor GC 后进入老年代的平均大小大于老年代的可用空间
    5. 由 Eden 区、From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

2.27、delete、truncate 和 drop 的区别

1、从执行速度上来说

drop > truncate >> delete

2、原理

  • delete:
  1. delete 属于数据库的 DML 语言,只删除数据不删除表结构,会走事务,执行时会触发 trigger
  2. 在 InnoDB 中,delete 不会真正地把数据删除,而是在记录地删除标记列上打上标记来表示记录已删除,因此使用 delete 删除表中数据时,表文件在磁盘上所占空间大小不会减少,存储空间不会被释放

虽然未释放磁盘空间,但当新插入记录时可以使用这部分被删除记录占用的空间

  1. delete 执行时,会将所有先删除数据缓存到 rollback_segment 中,事务 commit 后生效
  2. 删除表中全部数据时,如果使用的存储引擎是 InnoDB ,那么不会立即释放磁盘空间,而使用 MyISAM 时会立即释放磁盘空间
  3. 对于带条件的删除,无论是 InnoDB 还是 MyISAM 都不会立即释放磁盘空间

如果希望立即释放磁盘空间,那么可以在 delete 命令后使用 optimize table table_name,这个操作会立即释放磁盘空间

  • truncate:
  1. truncate 属于数据库的 DDL 语言,不走事务,操作不触发 trigger ,同时不会将数据放入到 rollback_segment 中,执行后立即生效,且数据无法找回。
  2. truncate 可以立即释放磁盘空间,它类似于将表 drop 后再创建了一张新的表
  3. truncate 会重置 auto_increment 的值
  4. 使用 truncate 要非常小心
  • drop:
  1. drop 属于数据库的 DDL 语言,同 truncate
  2. 它也会立即释放磁盘空间,同时 drop 会删除表的结构及其表下的所有约束、索引和触发器。
  3. 小心使用 drop

2.28、Spring 异步线程池框架

1、为什么要使用异步框架?

  • 在 Spring Boot 的日常开发中,一般都是同步调用的。但经常有特殊业务需要做异步处理,例如:注册新用户,送 100 个积分,或下单成功发送消息等等。

  • 为什么要做异步处理?以第一个场景为例

  1. 容错性,如果送积分与注册用户是同步执行的,那么送积分失败可能导致用户注册失败

注册用户是主要功能,送积分是次要功能,即使送积分异常也要提示用户注册成功,然后后面再针对积分异常进行补偿处理。

  1. 提升性能,例如注册用户花了 20 ms ,送积分花了 50 ms ,那么同步就需要耗费 70ms,而如果改为异步,则注册功能无需等待积分,耗时 20ms。

2、如何在 Spring Boot 中进行异步调用?

在 Spring Boot 中使用异步调用非常简单,只需要使用 @Async 注解即可实现异步调用。

  • 创建一个配置类,在配置类上添加 @EnableAsync 注解来开启异步调用
  • 在需要进行异步调用的方法上添加 @Async 注解

3、为 @Async 自定义一个线程池

  • 为什么要为 @Async 自定义线程池?

因为在默认情况下,@Async 使用的是 SimpleAsyncTaskExecutor这个线程池不是真正意义上的线程池,因为线程不重用,每次调用都会创建一个新线程。

@Async 注解异步框架提供了多种线程池

SimpleAsyncTaskExecutor(默认)不是真的线程池,这个类不重用线程,而是每次调用都会创建一个新线程
SyncTaskExecutor这个类没有实现异步调用,而是一个同步操作。只适用于不需要多线程的地方。
ConcurrentTaskExecutorExecutor 的适配类,不推荐使用,如果 ThreadPoolTaskExecutor 不满足要求时才推荐使用
ThreadPoolTaskScheduler可以使用 cron 表达式
ThreadPoolTaskExecutor最常使用,推荐,它的本质是对 java.util.concurrent.ThreadPoolExecutor 的包装
  • 创建一个配置类,为 @Async 自定义一个线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* @author caibighead
*/
@EnableAsync
@Configuration
public class SyncConfiguration {
/**
* 在这个方法内构造一个 ThreadPoolTaskExecutor 对象,自定义线程池参数后返回
* @return
*/
@Bean("threadPoolTaskExecutor")
public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
// 设置线程池的核心线程数
threadPoolTaskExecutor.setCorePoolSize(10);
// 设置线程池的最大线程数
threadPoolTaskExecutor.setMaxPoolSize(100);
// 设置阻塞队列的长度
threadPoolTaskExecutor.setQueueCapacity(50);
// 设置非核心线程池的空闲存活时间,单位:秒
threadPoolTaskExecutor.setKeepAliveSeconds(200);
// 设置线程内线程名称前缀
threadPoolTaskExecutor.setThreadNamePrefix("async-thread-");
// 设置线程池的拒绝策略,与 java.util.concurrent.ThreadPoolExecutor 中的拒绝策略一样,这里不展开说明
threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 初始化线程池
threadPoolTaskExecutor.initialize();
// 返回线程池对象
return threadPoolTaskExecutor;
}
}
  • 在调用 @Async 时,在 @Async 注解中指定要使用的线程池名称
1
2
@Async("threadPoolTaskExecutor")
...

2.29、如何保证消息不丢失

image.png

1、生产端保证消息不丢失

生产者发送消息到 Broker ,需要处理 Broker 的响应,无论是同步还是异步发送消息都要做好 try-catch ,妥善的处理响应。

  • 如果 Broker 返回写入失败等错误消息时,需要进行重试发送。
  • 如果多次发送失败需要进行报警,作日志处理等等。

2、Broker 存储消息

在 Broker 端,我们需要保证消息在刷盘之后再给生产者响应,假设消息写入内存就返回响应,那么如果此时机器断电,存储在内存中的消息会丢失,而生产者接收到响应后会认为消息已经发送成功。

  • 如果 Broker 是集群部署,有多副本机制(Kafka),那么消息不仅要写入当前 Broker ,还需要写入到副本中。
  • 我们可以配置成至少需要等消息写入到另一台副本机中式才返回响应,这样即使主 Broker 所在的机器断电,也会有另一台副本机可以保证消息不丢失,这样就可以保证基本的可靠。

3、消费者保证消息不丢失

我们需要等到消费者真正执行完业务逻辑后,再发送响应给 Broker 表示消费成功,如果将消息存入消费者内存后就给 Broker 返回响应,那么此时如果消费者宕机,处于内存中的消息就会丢失。

所以只要我们在消息业务逻辑处理完成之后再给 Broker 响应,那么消费阶段消息就不会丢失。

4、小结

  • 生产者需要处理好 Broker 的响应,出错时需要利用重试、报警等手段。
  • Broker 需要控制响应的时机,单机情况下是消息刷盘后返回响应,集群多副本情况下,即发送至两个副本及以上的情况下再返回响应。
  • 消费者需要在执行完真正的业务逻辑之后再返回响应给 Broker。

但是要注意消息可靠性增强了,性能就下降了,等待消息刷盘、多副本同步后返回都会影响性能。因此还是看业务,例如日志的传输可能丢那么一两条关系不大,因此没必要等消息刷盘再响应。

2.30、HTTP 和 HTTPS

1、HTTP 特点

  • 简单

HTTP 基本的报文格式是 header + body ,头部信息也是 key - value 简单文本的形式,易于理解。

  • 灵活且易于扩展
  1. HTTP 协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。
  2. HTTP 在 OSI 第七层(应用层),它的下层没有固定要求,可以随意变化。
  3. HTTP 非常容易扩展,HTTPS 就是在 HTTP 与 TCP 之间增加了 SSL/TLS 安全传输层
  • 应用广泛和跨平台
  • 无状态
  1. 好处:由于服务器不会去记录 HTTP 的状态,所以服务器端不需要额外的资源来记录状态信息,这可以减低服务器端的负担,将更多的资源用于提供对外服务上。
  2. 缺点:既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。

我们需要使用一些手段让服务器知道访问用户的信息,Cookie 就是一种不错的解决方案;

  • 明文传输
  1. 好处:明文意味着在传输过程中的信息,是可方便阅读的,通过浏览器的 F12 控制台或 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。
  2. 缺点:但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,信息的内容都毫无隐私可言,很容易就能被窃取
  • 不安全
  1. 使用明文传输,所以通信过程中传输的信息可能被窃听,比如说账号信息容易泄露
  2. 不验证通信方的身份,所以可能有冒充风险
  3. 无法证明报文的完整性,所以通信内容可能会遭到篡改

2、HTTP/1.1 的性能

HTTP 协议是基于 TCP/IP,并且使用了「请求-应答」的通信模式,所以性能的关键就在这两点里。

  • 长连接
  1. 早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销
  2. 为了解决上述 TCP 连接问题,HTTP/1.1 提出了长连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。

持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。

image.png

  • 管道网络传输
  1. HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能

即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。

image.png

  1. 但是服务器还是按照顺序,先回应 A 请求,完成后再回应 B 请求。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为「队头堵塞」。

因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」。好比上班的路上塞车。

image.png

3、HTTP 与 HTTPS

  • HTTP 和 HTTPS 有哪些区别?
  1. HTTP 是超文本传输协议,信息是明文传输,存在安全风险。HTTPS 则解决了 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文可以加密传输
  2. HTTP 连接建立相对简单,TCP 三次握手之后便可以进行 HTTP 的报文传输。而 HTTPS 在 TCP 层三次握手后,还需要进行 SSL/TLS 的握手过程,才可以进行加密报文传输。
  3. HTTP 的端口号为 80 ,HTTPS 的端口号是 443
  4. HTTPS 协议需要向 CA 申请数字证书,以此来保证服务器的身份是可信的。
  • HTTPS 解决了哪些问题?

由于 HTTP 是明文传输,所以存在以下三个风险:

  1. 窃听风险
  2. 篡改风险
  3. 冒充风险

对应的,HTTPS 在 HTTP 层与 TCP 层加入了 SSL/TLS 层,很好地解决了上述的风险:

  1. 信息加密:混合加密的方式实现信息的机密性,解决了窃听的风险。
  2. 篡改风险:使用摘要算法来实现数据完整性校验,它可以为数据生成独一无二的指纹,指纹用于校验数据的完整性
  3. 身份证书:将服务器公钥放到数字证书中,解决了冒充的风险
  • 混合加密

HTTPS 使用混合加密的方式来保证信息的机密性

image.png

HTTPS 使用了非对称加密对称加密结合的混合加密方式

  1. 通信建立前使用非对称加密的方式交换会话密钥,后续不再使用非对称加密
  2. 通信过程中全部使用对称加密的方式,使用会话密钥加密明文数据

为什么使用混合加密?

  1. 对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。
  2. 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。
  • 摘要算法

摘要算法用来实现完整性,能够为数据生成独一无二的「指纹」,用于校验数据的完整性,解决了篡改的风险。

image.png

在发送消息前先通过明文与摘要算法生成明文的指纹;

发送消息时将明文和指纹一起加密为密文,发送给对方;

接收方收到密文后,使用对称密钥进行解密,得到指纹和明文,然后接收方通过相同的摘要算法计算明文,并校验得到的指纹是否和接收方发送过来的指纹一致,如果是,证明没有被篡改。

  • 数字证书

客户端先向服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,用自己的私钥解密,但这需要保证服务器发放的公钥不被篡改,所以这里就需要引入第三方权威机构 CA (数字整数认证机构),将服务器公钥放在数字整数中,只要证书是可信的,那么公钥就是可信的

  1. 服务器将自己的公钥注册到 CA 中
  2. CA 使用自己的私钥将服务器的公钥数字签名,并颁发数字证书(数字证书 = CA 的数字签名 + 服务器公钥),CA 的公钥事先已经置入到了操作系统和浏览器中。
  3. 客户端拿到服务器的数字证书后,使用 CA 的公钥确认服务器的数字证书的真实性
  4. 从数字证书中获取服务器公钥

2.31、LFU 算法

LFU(Least Frequently Used)算法根据数据的历史访问频率来淘汰数据,其核心思想是:如果数据过去被访问多次,那么将来被访问的频率也更高

LFU 的每个数据块都有一个引用计数,所有数据块按照引用计数排序,具有相同引用计数的数据块则按照时间排序。

image.png

  • 新加入缓存的数据插入到队列尾部(引用计数为 1 )
  • 队列中的数据被访问后,引用计算增加,队列重新排序;
  • 当需要淘汰数据时,将已经排序的列表最后的数据删除。

2.32、判断线程池是否全部完成的几种方法

1、使用 isTeminated() 方法

  • 如果关闭后线程池中所有的任务都已经完成,那么返回 true

注意,除非首先调用 shutdown()shutdownNow() 方法,否则 isTeminated() 方法永不为 true

  1. shutdown() :启动有序关闭,其中先前提交的任务将被执行,但不会接受任何新任务。如果已经关闭,调用没有额外的作用。
  2. shutdownNow() :尝试停止所有主动执行的任务,停止等待任务的处理,并返回正在等待执行的任务列表。 从此方法返回时,这些任务将从任务队列中删除。通过 Thread.interrupt() 取消任务。

2、使用 ThreadPoolExecutorgetCompletedTaskCount() 方法进行判断

ThreadPoolExecutorgetCompletedTaskCount() 方法 会返回线程池中的完成任务数,我们可以判断完成任务数与全部任务数的关系。

  • 相关方法
  1. getTaskCount() :返回计划执行的任务总数。由于任务和线程的状态可能在计算过程中动态变化,因此返回的值只是一个近似值。
  2. getCompletedTaskCount() :返回完成执行的任务的大致总数。因为任务和线程的状态可能在计算过程中动态地改变,所以返回的值只是一个近似值,但是在连续的调用中并不会减少。
  • 优点:完全使用了 ThreadPoolExecutor 提供的方法,并且不必关闭线程池,避免了创建和销毁带来的损耗。
  • 缺点:上面的解释也看到了,使用这种判断存在很大的限制条件;必须确定,在循环判断过程中,没有新的任务产生。差不多意思就是,这个线程池只能在这条线程中使用。

3、使用 java.util.concurrent 包下的 CountDownLatch 工具类

  • 优点:代码优雅,不需要对线程池进行操作,将线程池作为 Bean 的情况下有很好的使用场景。
  • 缺点:需要提前直到线程数量,还需要处理异常,防止主线程被阻塞。

4、使用 boolean waitTermination(long timeOut, TimeUnit unit)

timeout 和 unit 两个参数,用于设定超时的时间及单位,这个方法会阻塞当前线程,直到

  1. 线程池中所有已提交的任务(包括正在执行和在队列中等待的任务)执行完。
  2. 或者超时时间结束(参数设置的超时时间)
  3. 或者线程被终端,此时会抛出 InterruptedException

然后会监测 ExecutorService 是否已经关闭,返回 true(shutdown 请求后所有任务执行完毕)或 false(已超时)

2.33、栈帧结构

Java 虚拟机以方法作为最基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。

  • 栈帧存储了方法的局部变量表操作数栈动态链接方法返回地址等信息。
  1. 局部变量表用于存放方法参数和方法内部定义的变量,它以变量槽为最小单位
  2. 操作数栈也称为操作栈,操作数栈的每一个元素都可以是包括 long 和 double 在内的任意数据类型,在一个方法刚开始执行时,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。

比如说在进行算术运算时会通过讲运算涉及的操作数压入栈顶后调用运算指令来进行的;

比如说在调用其他方法时是通过操作数栈来进行方法参数的传递

  1. 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用中的动态链接。Class 文件中的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的引用作为参数。这个符号引用一部分会在类加载阶段或者第一次使用时就转换为直接引用,这种方法称为静态解析。另外一部分将在每一次运行期间都转换为直接引用,这部分称为动态链接。
  • 每一个方法从调用开始到执行结束的过程都对应一个栈帧在虚拟机栈里面从入栈到出栈的过程。

1645712951(1).png

在编译 Java 程序源码时,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表中的 Code 属性之中。

2.34、JVM 中方法调用的两种方式

JVM 中的方法调用可以分为两种方式,分别是解析分派

1、解析

所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转换为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变的。

换句话说:调用目标在程序代码写好、编译器进行编译的那一刻就已经确定下来,这类方法的调用被称为解析

Java 虚拟机支持以下 5 条方法调用字节码指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器 <init>() 方法、私有方法和父类中的方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,需要在运行时再确定一个实现该接口的对象。
  • invokedynamic:先在运行时动态解析出的调用点限定符所引用的方法,然后执行该方法。

只要能被 invokestaticinvokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 中符合这些条件的方法有静态方法、私有方法、父类方法和构造器方法,除此之外,还有被 final 修饰的方法(尽管它被 invokevirtual 调用)

这五种方法调用在类加载时就可以将符号引用直接转换为该方法的直接引用,所以这些方法也被称为非虚方法

2、分派

分派又分为静态分派动态分派

  • 静态分派发生在编译阶段,静态分派的最典型应用表现就是方法重载。
  • 动态分派是 Java 语言多态性的一种重要体现 – 重写有着密切的关联,动态分派其实就是动态定位到实现类的方法进行调用。

2.35、多态

1、特点

  • 在 Java 中,对象变量是多态的。比如说,一个 Student 类既可以引用 Student 类对象,也可以引用一个 Student 类的任何一个子类的对象。
  • 多态体现为父类引用变量(可以是接口)可以指向子类对象,定义格式为 Father instance = new Son()
  • 多态就是同一个方法具有多个不同表现形式或形态的能力,或者是同一个接口使用不同实现类对象而执行不同的操作。

2、特点

  • 多态成员变量:编译运行看左边
1
2
3
Father instance = new Son();
System.out.println(instance.value);
instance.show();

value 是 Father 中的成员变量,此时只能取到父类的值。

  • 多态成员方法:编译看左边,运行看右边

也就是说,在运行期时,上述代码中 instance 对象所属的实际类型其实是 Son ,所以此时调用的是 instance 中的 show() 方法。

3、向上转型和向下转型

  • 向上转型:多态本身就是向上转型的过程
  • 向下转型:一个已经向上转型的子类对象可以通过强制类型转换的格式,将父类引用转为子类引用类型,当我们需要使用到子类中的特有功能时,可以执行此操作
1
Son son = (Son) instance;

2.36、TCP 第三次握手失败会发生什么?

第三次握手由客户端发起,它会向服务端发送一个 ACK 包,此时无论 服务端 是否接收到这个 ACK ,客户端都会认为链接已经建立

如果此时 ACK 在网络中丢失,那么在超过计时器后,服务端会再次发送 SYN+ACK 包,重传次数默认为 5 ,如果重传指定次数到达上限后仍然没有接收到 ACK 应答,那么一段时间后,服务端会自动关闭这个链接.

如果客户端往服务端写数据,那么服务端会以 RST 包响应客户端,以告知错误.

这样做是为了防范 SYN 洪范攻击

2.37、长连接与短连接

1、长连接

连接-> 传输数据-> 保持连接 -> 传输数据-> ………..-> 直到一方关闭连接,多是客户端关闭连接

长连接指建立 SOCKET 连接后不管是否使用都保持连接,直到有一方关闭连接。

  • 优点:可以省去较多 TCP 建立 / 关闭的操作,减少资源浪费,节省时间,对于频繁请求资源的用户,较适合使用长连接
  • 缺点:随着客户越来越多,服务端的压力也会随着越来越大,过多的长连接会拖垮服务端。

解决策略:关闭一些长时间不进行读写操作(空闲)的连接,这样可以避免一些恶意连接导致服务端服务受损,如果条件再允许,我们可以以客户端为颗粒度,限制每个客户端的最大连接数。

  • 从 HTTP / 1.1 开始,默认使用长连接,用以保持连接特性,使用长连接的 HTTP 协议,会在响应头中加入这行代码
1
Connection: keep-alive
  • HTTP 协议的长连接,实际上就是 TCP 的长连接

2、短连接

连接-> 传输数据-> 关闭连接

比如 HTTP 是无状态的的短链接,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,但任务结束就中断连接。

  • 优点:短连接对于服务器来说较为简单,存在的连接都是有用的连接,不需要额外的控制
  • 缺点:客户端连接频繁,会在 tcp 的建立和关闭上浪费时间。

2.38、为什么四次挥手中客户端最后还要等待 2 MSL?

1、四次挥手过程

  • 客户端进程发出连接释放 FIN 报文,并且停止发送数据,此时客户端进入 FIN-WAIT-1 (终止等待 1)状态
  • 服务端接收到连接释放 FIN 报文后,发出确认 ACK 报文,此时服务端就进入了 CLOSE-WAIT (关闭等待)状态,此时客户端处于半关闭状态,即客户端已经没有数据要发送了,但是服务器如果发送数据,那么客户端仍然要接收,这个状态还需要持续一段时间,也就是整个 CLOSE-WAIT 状态持续的时间。
  • 客户端接收到服务器的确认 ACK 报文后,客户端会进入倒 FIN-WAIT-2 (终止等待 2 )状态,等待服务器发送连接释放报文,在这之前还需要接收服务器发送的最后的数据。
  • 服务端发送完最后的数据后,就向客户端发送连接释放 FIN-ACK 报文,此时服务端就进入了 LAST-ACK (最后确认)状态,等待客户端的确认。
  • 客户端接收到服务端发送的连接释放报文后,必须发出确认,此时客户端就进入了 TIME-WAIT(时间等待)状态,此时 TCP 连接还没有完全释放,客户端还需要等待 2 * MSL (最长报文段寿命)的时间后,才进入 CLOSED(关闭)状态
  • 服务器只要接收到客户端发出的确认后就立即进入倒 CLOSED 状态,服务端结束 TCP 的时间要比客户端早一些。

image.png

2、为什么客户端最后还要等待 2MSL?

  • 保证客户端发送的最后一个 ACK 报文可以到达服务端,因为这个 ACK 报文可能丢失,对于服务端而言,如果它发送的 FIN-ACK 报文没有得到客户端的响应,那么它会认为自己发送 FIN-ACK 报文在传输过程中丢失了,所以它会重新发送一次 FIN-ACK 报文,而客户端就可以在等待的 2 MSL 时间内重新发送 ACK 报文,然后重启 2MSL 计时。
  • 客户端在发送完最后一个确认报文后,会在这 2MSL 的等待时间内,将本次连接持续的时间内所产生的所有报文段都从网络中清除。这样新的连接中就不会出现旧连接的请求报文。

2.39、SSL/TLS 协议说明

不使用 SSL/TLS 协议的 HTTP 通信是不加密的通信,SSL/TLS 握手是为了安全地协商出一份对称加密的密钥。

1、基本思路

SSL/TLS 协议的基本思路是采用公钥加密法,也就是说,客户端现象服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,使用自己的私钥进行解密。

  • 如何保证公钥不被篡改?

将公钥放在数字证书中,只要证书是可信的,那么公钥就是可信的。

  • 基本过程
  1. 客户端向服务端索要并验证公钥
  2. 双方协商生成对话密钥
  3. 双方采用对话密钥进行加密通信。

2、SSL 协议握手过程

  • 客户端向服务端发送一个 Client Hello 消息,这个消息里面包含了以下几部分内容
  1. 客户端可以支持的 SSL 最高版本号
  2. 一个客户端生成的 32 字节的随机数 Client Random
  3. 客户端支持的加密算法
  4. 一个用于确定会话的会话 ID
  5. 一个客户端可以支持的密码套件列表

密码套件列表格式:每个套件都以 SSL 开头,紧跟着密钥交换算法。用 WITH 这个词把密钥交换算法、加密算法、散列算法分开

  1. 一个客户端可以支持的压缩算法列表
  • 服务端向客户端发送一个 Server Hello 消息,在此之前,服务器确认双方所使用的加密算法,并给出数字证书,以及一个服务器生成的随机数 Server Random ,这个消息包含以下内容
  1. SSL 版本号,取客户端支持的最高版本号和服务端支持的最高版本号的较低者
  2. 一个服务端生成的随机数 Server Random ,长度依然为 32 字节
  3. 会话 ID
  4. 从客户端的密码套件中选择一个密码套件,其实就是选择加密算法
  5. 从客户端的压缩方法的列表中选择的压缩算法
  • 在经过上述两次握手后,客户端和服务端就知道了以下内容
  1. SSL 版本
  2. 密钥交换算法、散列算法和加密算法
  3. 压缩算法
  4. 有关密钥生成的两个随机数
  • 客户端确认数字证书有效,然后生成一个新的随机数(Premaster secret),并使用数字证书的公钥加密这个随机数,发送给服务端
  • 服务端使用自己的私钥加密客户端发送过来的密文,得到随机数(Premaster secret)。
  • 客户端和服务端按照约定的加密方法,使用前面的三个随机数(Client Random、Server Random 和 Premaster secret)生成对称加密密钥,用于后续通信过程的数据加密。

3、Session ID 的作用

握手阶段是用于建立 SSL 连接的,如果出于某些原因,对话中止,那么就需要重新握手,这个时候有两种方法可以恢复原来的 Session

  1. Session ID
  2. Session Ticket

Session ID 的作用就是用于恢复原来的 Session 的,如果对话中断,那么只要客户端给出这个 Session ID ,且服务器有这个编号的记录,那么双方就可以重新使用已有的对话密钥,不需要重新再生成一把。

2.40、常见的加密算法

加密算法可以分为:可逆加密和不可逆加密,而可逆加密又可以分为非对称加密和对称加密。

1、对称加密

常见的对称加密算法有 DESAES

2、非对称加密

常见的非对称加密算法有 RSADSA

3、不可逆加密

常见的不可逆加密算法有 SHAMD5 ,由于这种加密都是不可逆的,因此比较常见的场景是用来加密用户密码,验证过程就是通过比较两个加密后的字符串是否一样。

2.41、HTTP 1.1 / 2.0 的区别

1、HTTP 1.1

  • HTTP 1.1 使用了摘要算法来进行身份验证
  • HTTP 1.1 默认使用长连接,长连接就是只需一次建立就可以传输多次数据,传输完成后,只需要一次切断连接即可。长连接的连接时长可以通过请求头中的 keep-alive 来设置
  • HTTP 1.1 支持断点续传,通过使用请求头中的 Range 来实现。
  • HTTP 1.1 使用了虚拟网络,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个 IP 地址。

2、HTTP 2.0

  • 头部压缩,在 1.X 版本中,首部用文本格式传输,通常会给每个传输增加 500-800 字节的开销。现在打开一个网页上百个请求已是常态,而每个请求带的一些首部字段都是相同的,例如 cookie、user-agent 等。HTTP2 为此采用 HPACK 压缩格式来压缩首部。头部压缩需要在浏览器和服务器端之间:
  1. 维护一份相同的静态字典,包含常见的头部名称,以及常见的头部名称和值的组合
  2. 维护一份相同的动态字典,可以动态的添加内容
  3. 通过静态 Huffman 编码对传输的首部字段进行编码

下面截取了一部分静态字典中的内容

image.png

  • 二进制格式,HTTP 2.0 使用了更加靠近 TCP/IP 的二进制格式,而抛弃了 ASCII 码,提升了解析效率
  • 强化安全,由于安全已经成为重中之重,所以 HTTP2.0 一般都跑在 HTTPS 上。
  • 多路复用,HTTP/2 实现了多路复用,HTTP/2 仍然复用 TCP 连接,但是在一个连接里,客户端和服务器都可以同时发送多个请求或回应,而且不用按照顺序一一发送,这样就避免了”队头堵塞”的问题。

队头阻塞是由 HTTP 基本的“请求 - 应答”模型所导致的。HTTP 规定报文必须是“一发一收”,这就形成了一个先进先出的“串行”队列。队列里的请求是没有优先级的,只有入队的先后顺序,排在最前面的请求会被最优先处理。如果队首的请求因为处理的太慢耽误了时间,那么队列里后面的所有请求也不得不跟着一起等待,结果就是其他的请求承担了不应有的时间成本,造成了队头堵塞的现象。

  • Server Push ,HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送。

使用服务器推送提前给客户端推送必要的资源,这样就可以相对减少一些延迟时间。这里需要注意的是 http2 下服务器主动推送的是静态资源,和 WebSocket 以及使用 SSE 等方式向客户端发送即时数据的推送是不同的

image.png

2.42、隔离级别与锁的关系

  • Read Uncommitted 级别下,读操作不需要加共享锁,这样就不会根被修改的数据上的排他锁冲突

  • Read Committed 级别下,读操作需要加共享锁,但是在语句执行后之后就释放共享锁。

  • Repeatable Read 级别下,读操作需要加共享锁,但是在事务提交之前并不释放共享锁,也就是必须等待事务执行完毕以后才释放共享锁。

  • SERIALIZABLE 是限制性最强的隔离级别,因为该级别 锁定整个范围的键 ,并一直持有锁,直到事务完成。

2.43、volatile 保证有序性

1、CPU 的乱序执行

程序不是真的按照代码编写顺序执行的,为了提高效率, CPU 会在等待费时的指令执行的时候,优先执行后面的指令

  • 线程的 as-if-serial

单个线程,两条语句,未必会按照代码顺序执行,但是对于单线程指令的重排序而言,它必须保证最终结果的一致性。

  • 代码乱序执行会造成什么后果?

在多线程条件下,它可能会产生我们意料之外的结果,比如说在单例模式中,由于指令的重排序可能会导致获取到的实例对象是一个不完整的对象

2、乱序种类

乱序可以分为编译期乱序指令乱序执行

  • 编译期乱序

指程序被编译后,代码的顺序就已经发生了顺序更换了,只要是前后没有依赖关系的代码都可能在编译期发生乱序

  1. 可能会发生乱序
1
2
int x = 1;
int y = 3;

在上述代码中,由于两句代码间没有依赖关系,那么可能会在编译期被更换顺序

  1. 不可能发生编译期乱序的情况
1
2
int x = 1;
int y = x + 1;

由于 y 依赖于 x ,所以上述代码不会发生乱序。

  • 指令乱序执行:CPU 在执行代码的时候为了提高效率调换语句顺序,这种情况可以使用内存屏障来阻止指令乱序执行。

3、内存屏障

内存屏障是一种特殊指令,在遇到这种指令时,必须等到前面的代码执行完,后面的代码才能继续执行。相当于在挂号排队时在人与人之间插入一个屏障,防止后面的人插到前面的队伍中。

4、JVM 的内存屏障

所有实现 JVM 规范的虚拟机,都必须实现四个屏障,JVM 的内存屏障是一种逻辑上的屏障

  • LoadLoad 屏障
1
2
3
Load1;
LoadLoad;
Load2;

对于上面的语句,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数被读取完毕。

  • StoreStore 屏障
1
2
3
Store1;
StoreStore;
Store2;

对于上面的语句, 在 Store2 及其后续写入操作执行前,保证 Store1 的写入操作对其他处理器可见。

  • LoadStore 屏障
1
2
3
Load1;
LoadStore;
Store2;

对于上面的语句,在 Store2 及其后面写入操作被刷出,保证 Load1 要读取的数据被读取完毕。

  • StoreLoad 屏障
1
2
3
Store1;
StoreLoad;
Load2;

对于上面的语句, 在 Load2 及其后续的所有读操作被执行前,保证 Store1 的写入对所有处理器可见

5、volatile 的实现细节

  • volatile 写

JVM 规定,在对被 volatile 修饰的变量内存进行写操作时,必须在写操作的前面添加一个 StoreStore 屏障,然后在写操作的后面添加一个 StoreLoad 屏障

image.png

  1. 必须等前面所有的写操作执行完毕,才对这个 volatile 变量进行写操作
  2. 必须等 volatile 写操作执行完毕,别人才能继续执行读取操作
  • volatile 读

JVM 规定,在对被 volatile 修饰的变量进行读操作时,必须在读操作后面添加两个屏障,分别是 LoadLoad 屏障和 LoadStore 屏障

image.png

  1. 必须等到对 volatile 变量的读取操作结束后,才能进行后续读操作的进行
  2. 必须等到对 volatile 变量的读取操作结束后,才能进行后续写操作的进行

2.44、请求报文

HTTP 的请求报文是由以下几部分组成:

  • 请求行:请求行由请求方法请求 URLHTTP 协议版本 三部分组成,它们之间使用空格隔开
  • 请求头部:请求头部由关键字 / 值组成,每行一对,其中关键字与值使用 : 分隔,请求头部通知服务器有关于客户端请求的信息,是客户端发送给服务器的一些附加信息,一次请求可以有任意多个请求头
  1. User-Agent:产生请求的浏览器类型
  2. Accept:表示客户端希望接收的响应 Body 的数据类型,常见的类型有
类型描述
text/htmlHTML 格式
text/plain纯文本格式
text/xmlXML 格式
image/gifGIF 图片格式
image/jpegJPG 图片格式
application/xmlXML 数据格式
application/jsonJSON 数据格式
  1. Accept-Encoding:客户端可以接收的编码压缩格式
  2. Host:请求的主机名
  3. Cookie:存储与客户端的扩展字段,向同一域名的服务端发送属于该域的 Cookie
  4. Connection:连接方式(close 与 keep-alive)

Connection 请求头表示是否需要持久连接,如果 Servlet 观察到 Connection 的值为 keep-alive ,或者看到使用的是 HTTP1.1(1.1 默认使用持久连接),那么它会使用持久连接,当服务器端想明确断开连接时,则指定 Connection 首部字段为 Close

Close:告诉 WEB 服务器或者代理服务器,在本次请求响应结束后,直接断开 TCP 连接,不需要等待本次连接的后续请求了。

Keep-alive:告诉 WEB 服务器或者代理服务器,在完成本次请求的响应后,保持连接,等待本次连接的后续请求。

  1. Keep-Alive:如果浏览器请求保持连接,那么这个请求头就表明期望 WEB 服务器保持连接多长时间(秒)

例如:Keep-Alive: 300 表示客户端希望服务器保持连接 300 秒

  1. Cache-Control:通过指定首部字段 Cache-Control 的指令,就能操作缓存的工作机制

Cache-Control 指令的参数是可选的,多个指令之间通过 , 分割

1
Cache-Control: private, max-age=0, no-cache

缓存请求指令:

指令参数说明
no-cache强制向源服务器再次验证,验证这个资源是否在服务端被修改过,在这之前不能被复用。这意味着 no-cache 会和服务器进行一次通讯,确保返回的资源没被修改过。
no-store不缓存请求或响应的任何内容
max-age=x必须这个指令告诉浏览器端或者中间者,响应资源能够在它被请求之后的多长时间以内被复用
例如,max-age 等于 3600 意味着响应资源能够在接下来的 60 分钟以内被复用,而不需要从服务端重新获取

缓存响应指令:

指令参数说明
public可向任意方提供响应的缓存
private可省略仅向特定用户返回响应
no-cache可省略缓存前必须先确认缓存有效性

no-cache 指令:使用这个指令的命令是为了防止从缓存中获取已过期的资源

image.png

max-age 指令:如果缓存没有超过设置的时间,那么直接从缓存中获取资源,当 max-age = 0 时,那么缓存服务器通常需要将请求转发给源服务器

  1. Upgrade

这个首部字段用于检测 HTTP 协议及其他协议是否可使用更高的版本进行通信,其参数值可以指定一个完全不同的通信协议。

使用首部字段 Upgrade 时,还需要额外指定 Connection: Upgrade ,比如说使用 HTTP 握手将 HTTP 协议升级为 WebSocket 协议时,首部为

1
2
Connection: Upgrade
Upgrade: WebSocket
  • 空行:最后一个请求头之后是一个空行,它用于通知服务器以下不再有请求头
  • 请求体:一般来说,GET 请求是不携带请求体的,请求体一般出现在 POST 等请求中,但这不表示 GET 不能携带请求体,而且对于不同的服务器而言,对 GET 请求携带的请求体的处理策略可能有所不同,有些服务器可能不会接收 GET 请求发送过来的请求体(GET 的请求体是一个未定义行为,很可能不被支持)

image.png

2.45、响应报文

HTTP 的响应报文主要由以下几部分组成:

  • 状态行:响应报文状态行由 HTTP 版本服务器返回的响应状态码状态码的描述 组成

状态码:状态码负责表示客户端请求的返回结果、标记服务器端是否正常、通知出现的错误。状态代码由三位数字组成,第一个数字定义了响应的类别,且有 五种 可能取值

取值说明常用状态码
1XX表示服务端成功接收请求,要求客户端继续提交下一次请求才能完成整个处理过程101(表示服务器应客户端升级协议的请求对协议进行切换,常出现在 HTTP 升级为 WebSocket 的场景)
2XX表示成功接收请求并已完成整个处理过程200(表示请求成功)
3XX表示服务器要求客户端重定向;301(表示永久重定向)、302(表示资源发生暂时性转移)
4XX客户端错误–请求有语法错误或请求无法实现400(表示客户端请求有语法错误,不能被服务器所理解)、401(表示请求未经认证)、403(表示请求未经授权)、404(请求资源不存在)
5XX表示服务器未能正常处理客户端的请求而出现意外错误;500(表示服务器发生不可预期的错误,导致无法完成客户端的请求)、502(bad gateway)
  • 响应头
  1. Location:Location 响应报头域用于重定向接受者到一个新的位置

例如:客户端所请求的页面已不存在原先的位置,为了让客户端重定向到这个页面新的位置,服务器端可以发回 Location 响应报头后使用重定向语句,让客户端去访问新的域名所对应的服务器上的资源;

  1. Server:Server 响应报头域包含了服务器用来处理请求的软件信息及其版本。

它和 User-Agent 请求报头域是相对应的,前者发送服务器端软件的信息,后者发送客户端软件(浏览器)和操作系统的信息。

  1. Connection、Keep-Alive、Cache-Control:连接方式、连接持续时间、缓存控制(与请求头中的含义相同)
  2. Date:响应报文的时间
  • 空行:最后一个响应头部之后是一个空行,发送回车符和换行符,通知服务器以下不再有响应头部。
  • 响应体:

image.png

2.46、JWT 如何避免被篡改和冒充?

1、JWT 的构成

JWT 即 JSON Web Token ,它由三部分组成

  • header

JWT 的头部承载两部分信息:

  1. 声明类型,声明是 JWT
  2. 声明加密的算法,通常使用 SHA256

下面就是一个典型的 JWT 头部

1
2
3
4
{
'typ': 'JWT',
'alg': 'HS256'
}

之后对头部进行 base 64 编码,就构成了 JWT 的第一部分

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • payload

载荷实际上就是存放有效信息的地方,一些常使用的声明如下:

  1. jti:jwt 的唯一身份标识,可以将其看为一个唯一 ID
  2. exp: jwt 的过期时间,这个过期时间必须要大于签发时间

我们可以在 payload 处存放一些不敏感的用户信息,比如说头像、id 、用户名。

1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

同样进行 base 64 后我们可以得到 JWT 的第二部分

1
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
  • signature

jwt 的第三部分是一个签证信息,这个信息由三部分组成:

  1. header (base 64 编码后的)
  2. payload (base 64 编码后的)
  3. secret

这部分需要 base 64 编码后的 header 和 base 64 编码后的 payload 使用 . 连接成的字符串,然后通过 header 中声明的加密算法与加盐密钥 secret 进行组合加密,然后就构成了 JWT 的第三部分。

1
2
3
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
// 获取签名
var signature = HMACSHA256(encodedString, 'secret'); // SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

得到的第三部分,我们假设它为:TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

我们将上面得到的三个字符串用 . 连接为一个完整的字符串,就得到最后的 JWT

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

2、如何判断 JWT 有没有被篡改?

在服务端根据传入 JWT 的 payload 再计算一次 signature

1
signature = 加密算法(base64(header) + "." + base64(payload), secret);

然后对比 JWT 中的签名与新生成的签名是否一致,如果是,那么证明没有被篡改。

3、注意点

  • 在进行加密时,使用的 secret 密钥是保存在服务器的,而 JWT 也是在服务器端生成的,所以 secret 是服务器的私钥,在任何场景都不应该流露出去。
  • 一旦客户端得知这个 secret ,那就意味着客户端就可以自我签发 JWT 了。

2.47、PC 端微信扫码登录过程

1、PC 端二维码生成阶段

  • 用户打开 PC 端登录界面,此时会向服务端发送 PC 端的唯一标识,比如说设备标识、MAC 地址等。
  • 服务器生成 PC 端二维码 ID ,并将这个二维码 ID 与 PC 端的特征绑定对应,此时二维码状态为待扫描
  • PC 端根据服务器生成的二维码 ID 生成对应二维码并展示

image.png

2、手机扫码阶段

  • 用户使用微信 APP 扫描二维码,提取出二维码中的二维码 ID
  • 由于手机端在扫描前就已经登录,所以此时会将手机端保存的 Token 和 PC 二维码 ID 发送给服务器
  • 服务端利用手机端发送过来的 Token 和 PC 二维码进行绑定,生成一个临时 Token ,临时 Token 与手机、PC 等标识信息绑定。此时更新二维码状态为已扫描
  • 服务端返回临时 Token 给手机端,在手机端页面生成一个提示确认登录的按钮

为什么需要临时 Token ?

因为此时用户还没有确定登录,使用临时 Token 是为了保证确认登录操作是同一台设备发出的。

  • PC 端会定时轮询服务端关于二维码的状态,在明确状态更改为已扫描后,PC 端会展示当前登录用户的头像与昵称信息。

image.png

3、用户确定登录阶段

  • 用户在手机端点击确认登录,此时手机端会将临时 Token 发送附在请求头上发送给服务器端
  • 服务端验证临时 Token 是否与本次请求来自同一部手机,同时将临时 Token 作废,为 PC 端生成一个正式 Token ,同时将正式 Token 与 PC 端特性进行绑定,更新二维码状态为确认登录
  • 服务告知手机端确认登录操作成功。
  • PC 端定时轮询服务器端,一旦它发现二维码状态变为确认登录,那么它就会提取 PC 端 Token,并展示主界面,此时登录成功。

PC 端所用的 Token 是与 PC 端特征绑定在一起的,所以是比较安全的。

2.42、MySQL 主从复制造成的数据一致性时延应该如何处理?

假设我们现在的 MySQL 是一主两从的架构,其中主服务器负责数据写入,而从服务器中的数据来自主服务器的数据同步

image.png

  • 如果主从之间存在数据同步延迟,那么就会出现主从数据不一致的情况,比如说写入的数据在从属服务器中无法查询到。

image.png

  • 解决方法一:延迟查询(不推荐),这种做法是为主从同步留出足够的时间

我们无法很好的估计需要预留的时间,长了会浪费时间,短了解决不了问题,这种方案可以作为临时方案。

image.png

  • 解决方法二:利用读写分离框架特性,如 Sharding JDBC 可以要求下一条 SELECT 强制走主库

这种方案是比较推荐的做法,Sharding JDBC 通过重写 Data Source 数据源方式实现读写分离,很少的代码修改便可以实现适配,这种方案的缺点是会导致主库压力增大,可能会出现性能瓶颈

image.png

  • 解决方案三:采用 MGR 全同步复制,强一致性数据同步,在没有完成主从同步之前,对数据库的写入操作会一致阻塞。

这种方式推荐使用在新项目中,MGR 是一个强一致性方案。

2.43、MySQL 主从复制

1、MySQL 异步复制

MySQL 的异步复制无法保证从属服务器接收到 BinLog 日志

2、MySQL 全同步复制

主从强一致性方案,这个方案对 MySQL 的 binlog 有要求(要求 binlog 的日志格式为 row ),在 MySQL 5.7 后需要配合 MGR 使用,仅支持 InnoDB

image.png

2.44、Keep-Alive + VIP 实现高可用

规避单点是高可用架构设置最基本的考量。

image.png

对于上面的 Nginx 来说,它就是单点的,如果它挂掉,那么会导致整个集群不可用。

1、Keep-Alive + VIP 是什么?

  • KeepAlived 是 Linux 中轻量级别的高可用解决方案,它的部署和使用非常简单,所有的配置只需要一个配置文件即可以完成。
  • VIP (Virtual IP)是虚拟的 IP ,与实际网卡绑定的 IP 地址不同, VIP 在内网中被动态的映射到不同的 MAC 地址上,也就是映射到不同的机器设备上

KeepAlived 通过心跳机制检测服务器状态,Master 宕机则自动将 IP 漂移到备机上实现高可用

image.png

在 KeepAlived 中,备机主要作为备份使用,即使主机压力非常大,它也不会参与工作,主机会每隔两秒向备机发送一个心跳包,告诉备机自己还活着;如果主机宕机,那么它就会停止向从机发送心跳包,此时备机会自动升级为 Master ,产生 IP 漂移继续提供服务。

image.png

由于客户端是通过虚拟 IP 来访问的,所以在发送 IP 漂移后,客户端不需要做任何行动。

image.png

在原来的 Master 恢复后,KeepAlived 会自动将 IP 漂移回原 Master ,而新 Master 会自动降级回备机。

2、基于 Keep-Alive 的 Nginx 高可用架构的落地配置

  • 如果使用 Nginx 进行反向代理的话,那么又会出现一个新的问题,就是如果 KeepAlived 没有挂掉,而 Nginx 挂掉的话,那么此时 KeepAlived 可以正常发包(因为只是 Nginx 挂了),此时无法发生 IP 漂移,所以我们需要让 KeepAlived 观测 Nginx 的存活状态。

image.png

  • 我们需要编写一个脚本,这个脚本用于检测 Nginx 的存活状态,在编写完脚本后,我们在 KeepAlived 配置文件中进行配置,那么 KeepAlived 就会每隔一段时间执行这个脚本检测 Nginx 状态,从而实现了对 Nginx 的监控。

我们可以访问 Nginx 所代理服务器的某一个接口,如果返回码为 200 ,那么我们认为 Nginx 正常,否则我们可以使用一个 killall keepalived 命令杀死所有的 keepalived 进程,从而让备机感知到。

3、使用 DNS 来让备机工作

  • 我们使用 DNS ,为域名配置两个 IP 虚拟 IP ,并设置规则为轮询
  • 使用两个 KeepAlived,让两台机器互为主从

image.png

4、Keep-Alive 的 VIP 抢占问题

在 KeepAlived 配置文件中,可以配置 KeepAlived 为非抢占模式(nopreempt),在设置后,一旦发生 IP 漂移,那么新主就会一直作为主

2.45、消息通知

如果你是微博架构师,在大 V 更新动态通知采用推送还是拉取比较合适?

1、推送模式(Push)和拉取模式(Pull)有什么不同?

  • 使用推送模式

image.png

如果是推送模式,那么在消息发送后,微博后台会为每个粉丝都创建一个动态队列,然后将消息放入到动态队列中,这个动态队列是逻辑上的,可以使用多种方式来实现,比如说数据库、Redis 或者消息队列。

  • 使用拉取模式

本质上是使用轮询,也就是粉丝根据某些条件调用后台的接口,获取近期未读消息。

image.png

这种方式要求每个粉丝维护一张自己关注的大 V 的列表,然后定时的轮询接口,对每个大 V 的信息进行轮询。

  • 两种方法的区别
Push 模式Pull 模式
实时性较好,通过网络管道准实时发送较差,取决于定时轮询时间
服务器状态有状态,需要持久化粉丝动态队列无状态,根据请求实时查询
风险项大 V 动态的并发写扩散问题,大量动态队列的持久化会造成磁盘的高 IO大量粉丝准点读扩散问题,大 V 粉丝准点并发查询可能会拖垮服务器
应用场景微信微博(早期)

2、读扩散和写扩散

  • 读扩散

比如说某大 V 准点发送了一条抽奖动态,那么可能会有大量的粉丝在准点同时发起同一个请求,这么多的查询请求发送到后台服务器上,可能会拖垮后台服务器。

  • 写扩散

比如说某大 V 有一亿个粉丝,那么在他发送消息后,我们就需要向这一亿个粉丝的动态队列中都写入一条消息,这样即使使用了分库分表、扩容等措施,也会给磁盘造成非常非常大的压力。

3、写扩散优化

  • 设置上限,比如说微信好友最多只有 5000 个,限制了好友数量后,就在一定程度上减少了写扩散的规模
  • 限流策略,在一定时间内陆续地完成消息发送,也就是对 IO 的压力形成了分摊
  • 优化存储策略,采用 NoSQL 或大数据方案

4、读扩散优化

  • 使用 MQ 进行削峰填谷,对于超出队列接收上限的请求直接拒绝
  • 增加轮询间隔,减少请求次数
  • 服务端增加缓存,提高查询效率
  • 增加验证码,分散时间,减少机器人刷票

5、推特的混合模式

  • 当粉丝量小于 X 时,使用 Push 模式
  • 当粉丝量大于 X 时,使用 Pull 模式

2.46、Redis 持久化补充

1、RDB 文件的创建

有两个 Redis 命令可以用于生成 RDB 文件,一个是 SAVE ,另一个是 BGSAVE .

  • SAVE 命令会阻塞 Redis 服务器进程,直到 RDB 文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
  • BGSAVE 命令会 fork 出一个子进程,然后由子进程负责创建一个 RDB 文件,服务器进程继续处理命令请求

2、RDB 文件的载入

Redis 中 RDB 文件的载入工作是在服务器启动时自动执行的,所以 Redis 中并没有专门用于载入 RDB 文件的命令,只要 Redis 服务器在启动时检测到 RDB 文件存在,那么它就会自动载入 RDB 文件。

  • 由于 AOF 文件的更新频率通常比 RDB 文件的更新频率高,所以:
  1. 如果服务器开启了 AOF 持久化功能,那么服务器会优先使用 AOF 文件来还原数据库状态
  2. 只有在 AOF 持久化功能被关闭时,服务器才会使用 RDB 文件来恢复数据库状态。
  • 服务器在载入 RDB 文件期间会一直处于阻塞状态,直到载入工作完成为止

3、AOF 持久化

image.png

AOF 通过保存 Redis 服务器所执行的写命令来记录数据库状态。

2.47、Redis 事件

Redis 是一个事件驱动程序,服务器需要处理以下两类事件:

  • 文件事件:Redis 服务器通过套接字与客户端进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信会产生对应的文件事件,而服务器通过监听并处理这些事件来完成一系列网络通信操作。
  • 时间事件:Redis 服务器中的一些操作需要在给定的时间点执行,时间事件就是对这类定时任务的抽象。

1、文件事件

Redis 基于 Reactor 模式开发了自己的网络事件处理器(file event handler)

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

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

  • 文件事件处理器的构成:由套接字、I/O 多路复用程序、文件事件分派器和事件处理器组成。

image.png

  1. 文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,就会产生一个文件事件。

因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。

  1. I / O 多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。
  2. 尽管多个文件事件可能会并发地出现,但 I / O 多路复用程序总会将所有产生事件的套接字全部放到一个队列中,然后通过这个队列,以有序、同步、每次一个套接字套接字地方式向文件事件分派器传送套接字

当上一个套接字产生的事件被处理完毕后,I / O 多路复用程序才会继续向文件事件分派器传送下一个套接字

image.png

文件事件分派器接收 I / O 多路复用程序传来的套接字,并根据套接字产生的事件的类型调用对应的事件处理器。

服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作。

  • I / O 多路复用程序的实现

Redis 的 IO 多路复用程序的所有功能都是通过包装常见的 selectepollevportkqueue 这些 IO 多路复用函数库来实现的。

2.48、Redis 主从复制过程

当客户端向从服务器发送 SLAVEOF 命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作,也就是将从服务器的数据库状态更新至主服务器当前所处的数据库状态。

从服务器对主服务器的同步操作需要向主服务器发送 SYNC 命令来完成,以下是该命令的执行步骤:

  • 从服务器向主服务器发送 SYNC 命令。
  • 收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令
  • 当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收并载入这个 RDB 文件,将自己的数据库状态更新至主服务器执行 BGSAVE 命令时的数据库状态。
  • 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。

image.png

2.49、集群

Redis 集群是 Redis 提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移

1、节点

  • 一个 Redis 集群通常有多个节点组成,在刚开始时,每个节点都是相互独立的,它们都处于一个只包括自己的集群中,要组建一个可工作的集群,那么需要将各个独立的节点连接起来,构成一个包含多个节点的集群。
  • 连接各个节点的工作可以使用 CLUSTER MEET 命令完成,该命令的格式如下:
1
CLUSTER MEET <IP> <PORT>
  • 向一个节点发送 CLUSTER MEET 命令,可以让 node 节点与 ip 和 port 指定的节点进行握手,握手成功后,node 节点就会将 ip 和 port 指定的节点添加到 node 节点当前所在的集群中。

image.png

2、槽指派

Redis 集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为 16384 个槽,数据库中的每个键都属于这 16384 个槽的其中一个,集群 中的每个节点可以处理 0 个或最多 16384 个槽

2.50、String

1、String 的不可变性

在 Java 中, String 对象在创建后是不可改变的,String 通过以下几个规则来使得它的对象不可变。

  • 类内部的所有字段都是被 final 修饰的

在 JDK 9 之前,String 内部使用一个 final 修饰的 char 数组来保存内容,对于这个 final 修饰的数组而言,它的地址一旦被确定后就不可被修改,但是数组对象中的内容是可以被改变的,所以使用 final 修饰 char 数组不是 String 不可变的唯一原因。

  • 类内部所有的字段都是私有的
  • 类不能够被继承和扩展
  • 类不能够对外提供用于修改内部状态的方法,即使是 setter 方法也不行

这样就杜绝了从外部修改 String 对象内容

2、补充

  • String 内部有许多诸如 substringreplacereplaceAll 的方法,既然 String 是不可变的,那么这些方法是怎么实现的?

在堆内开辟一个新内存,原来的对象没有改变,以下是 replaceAll 的底层实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String replaceAll(String replacement) {
reset();
boolean result = find();
if (result) {
StringBuilder sb = new StringBuilder();
do {
appendReplacement(sb, replacement);
result = find();
} while (result);
appendTail(sb);
return sb.toString();
}
return text.toString();
}
  • 有什么方法可以改变 String 对象?

使用反射。

3、为什么 Java 要将 String 设置为不可变的?

  • 在 Java 中 String 类型使用得非常频繁,将 String 设置为不可变是考虑到 Java 中含有字符串常量池,将 String 设置为不可变可以提高效率同时减少内存分配。
  • 由于字符串无论在任何 Java 系统中都广泛使用,会用来存储敏感信息,如账号,密码,网络路径,文件处理等场景里,保证字符串 String 类的安全性就尤为重要了,如果字符串是可变的,容易被篡改,那我们就无法保证使用字符串进行操作时,它是安全的,很有可能出现 SQL 注入,访问危险文件等操作。
  • 线程安全

2.51、不使用 volatile 如何保证懒汉式单例对象是一个完整对象?

  • 添加 volatile 时的代码
1
2
3
4
5
6
7
8
9
10
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
  • 不使用 volatile 的代码

我们将原来的代码修改如下,为什么这样就可以保证获取到的对象是一个完整的对象?

因为懒汉式双检锁出现问题的原因时,线程一在最里层代码发生指令重排序,线程二刚好走到最外层的判空。

而下面的代码会让 temp 先构造为一个完整的对象。

1
2
3
4
5
6
7
8
9
10
11
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
Singleton temp = new Singleton();
INSTANCE = temp;
}
}
}
return INSTANCE;
}

三、Spring Boot 自动装配原理

3.1、核心注解

1、@SpringBootApplication

@ SpringBootApplication 是一个组合注解,它由三个注解组成,分别是 @SpringBootConfiguration@EnableAutoConfiguration@ComponentScan,这个注解的作用是标志此应用是一个 Spring Boot 应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

@AliasFor(annotation = EnableAutoConfiguration.class)
Class<?>[] exclude() default {};


@AliasFor(annotation = EnableAutoConfiguration.class)
String[] excludeName() default {};

@AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
String[] scanBasePackages() default {};


@AliasFor(annotation = ComponentScan.class, attribute = "basePackageClasses")
Class<?>[] scanBasePackageClasses() default {};

@AliasFor(annotation = Configuration.class)
boolean proxyBeanMethods() default true;

}

下面,我们逐一介绍这三个注解的作用

2、@SpringBootConfiguration

1
2
3
4
5
6
7
8
9
10
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
public @interface SpringBootConfiguration {
@AliasFor(
annotation = Configuration.class
)
boolean proxyBeanMethods() default true;
}

进入 @SpringBootConfiguration 的源码,我们可以看到 @SpringBootConfiguration 注解底层就是一个 @Configuration 注解,这个注解作用在类上,表明某个类是一个配置类

3、@ComponentScan

指定扫描哪些包

4、@EnableAutoConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {

String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

Class<?>[] exclude() default {};

String[] excludeName() default {};

}

@EnableAutoConfiguration@AutoConfigurationPackage@Import 注解的合成

  • @AutoConfigurationPackage

这个注解实际上给 Spring 容器中导入了一个名为 AutoConfigurationPackages.Registrar 的组件。

1
2
3
4
5
6
7
8
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(AutoConfigurationPackages.Registrar.class)
public @interface AutoConfigurationPackage {

}

这个组件的作用是,使用 Registrar 为 Spring 容器中导入一系列组件它会将指定的一个包下(SpringBoot 启动类所在的包)的所有组件导入进来

1
2
3
4
5
6
7
8
9
10
static class Registrar implements ImportBeanDefinitionRegistrar, DeterminableImports {
@Override
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
register(registry, new PackageImport(metadata).getPackageName());
}
@Override
public Set<Object> determineImports(AnnotationMetadata metadata) {
return Collections.singleton(new PackageImport(metadata));
}
}
  • @Import(AutoConfigurationImportSelector.class)

利用 AutoConfigurationImportSelector 类中的 getAutoConfigurationEntry 方法给容器中导入一些组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata,
AnnotationMetadata annotationMetadata) {
if (!isEnabled(annotationMetadata)) {
return EMPTY_ENTRY;
}
AnnotationAttributes attributes = getAttributes(annotationMetadata);
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
configurations = removeDuplicates(configurations);
Set<String> exclusions = getExclusions(annotationMetadata, attributes);
checkExcludedClasses(configurations, exclusions);
configurations.removeAll(exclusions);
configurations = filter(configurations, autoConfigurationMetadata);
fireAutoConfigurationImportEvents(configurations, exclusions);
return new AutoConfigurationEntry(configurations, exclusions);
}

可以看到,调用 List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes); 获取到所有需要导入到容器的配置类。

image.png

查看 getCandidateConfigurations 方法,可以看到,它的底层调用了 SpringFactoriesLoader.loadFactoryNames

1
2
3
4
5
6
7
protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
getBeanClassLoader());
Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you "
+ "are using a custom packaging, make sure that file is correct.");
return configurations;
}

继续跟进,可以看到底层调用了 loadSpringFactories 方法

1
2
3
4
public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
String factoryTypeName = factoryType.getName();
return (List)loadSpringFactories(classLoader).getOrDefault(factoryTypeName, Collections.emptyList());
}

最后可以看到是通过 Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) 方法得到所有要加载的组件。

这个方法会加载 jar 包下的 META-INF/spring.factories 文件,然后将这些文件中所有的组件加载到容器中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
MultiValueMap<String, String> result = (MultiValueMap)cache.get(classLoader);
if (result != null) {
return result;
} else {
try {
Enumeration<URL> urls = classLoader != null ? classLoader.getResources("META-INF/spring.factories") : ClassLoader.getSystemResources("META-INF/spring.factories");
LinkedMultiValueMap result = new LinkedMultiValueMap();

while(urls.hasMoreElements()) {
URL url = (URL)urls.nextElement();
UrlResource resource = new UrlResource(url);
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
Iterator var6 = properties.entrySet().iterator();

while(var6.hasNext()) {
Entry<?, ?> entry = (Entry)var6.next();
String factoryTypeName = ((String)entry.getKey()).trim();
String[] var9 = StringUtils.commaDelimitedListToStringArray((String)entry.getValue());
int var10 = var9.length;

for(int var11 = 0; var11 < var10; ++var11) {
String factoryImplementationName = var9[var11];
result.add(factoryTypeName, factoryImplementationName.trim());
}
}
}

cache.put(classLoader, result);
return result;
} catch (IOException var13) {
throw new IllegalArgumentException("Unable to load factories from location [META-INF/spring.factories]", var13);
}
}
}

spring-boot-autoconfigure.jar 包中也存在一个 META-INF/spring.factories 文件,这个文件中就包含了上面说到的那 127 个自动配置类。

3.2、按需开启自动配置项

虽然我们 127 个场景的所有自动配置类在启动时默认全部加载, 但是最终会进行按需配置。

这些配置项会配合 @Conditional 系列注解实现按需配置,我们以 Aop 的自动配置类为例

image.png

可以看到,只有在 @ConditionalOnClass 注解中的条件生效,也就容器中存在 Advice 类时,才会执行下面的代码。

同样的,只有当 @ConditionalOnProperty(prefix = "spring.aop", name = "auto", havingValue = "true", matchIfMissing = true) 条件生效时,才会加载 AopAutoConfiguration 自动配置类,这就是按需加载自动配置。

  • Spring Boot 先加载所有的自动配置类,即 XxxAutoConfiguration
  • 每个自动配置类按照条件进行生效,默认都会绑定配置文件中指定的值,即 XxxProperties 类对象中的值,XxxProperties 又与配置文件中的配置进行绑定。
  • 生效的自动配置类会为容器中装配很多组件。
  • 只要容器中有这些组件,那么相当于这些功能也就有了。
  • 如果用户配置了相同类型的组件,那么以用户配置的优先。

四、Redis

排序的依据是热度,简单的热度算法为:热度 = 转发 + 点赞 + 评论数,里面按小时、24 小时(天)、周、月来排行。

4.1、Redis 实现热度排行榜

  • 像微博百度这种高并发实时计算的排行榜,根据就不可能使用关系型数据库实现,关系型数据库实现小时、天、周、月的排行榜,难度及其大,而且表结构的设计也非常困难,再者关系型数据库也没办法承受那么大的并发量,所以高并发的实时排行榜天然适合使用 Redis 来实现。

  • 整体的技术实现是使用 Redis 的 ZSet 来实现,每条微博是一个 member ,每条微博的热度值是一个 Score

  • 如何将小时、天、周、月的数据进行实时计算?

  1. 我们以小时为单位,每小时计算一个 ZSet
  2. 如果要计算近 24 个小时,那么就合并 24 个 ZSet
  3. 如果要计算近 7 天,就合并 24 * 7 个 ZSet
  4. 如果要计算近一个月,那么就合并 24 * 30 个 ZSet
  • 如何实现以每个小时为一个 ZSet?
  1. 将当前事件转换为毫秒的时间戳,然后除以一个小时,即以 当前时间 T / 1000 * 60 * 60 = 小时 key,然后用这个小时序号作为 ZSet 的 Key

例如:

2020-01-12 15:30:00 = 1578814200000 毫秒 ,转换为小时 key = 1578814200000 / 1000 * 60 * 60 = 438559,获取小时 key 为 438559

2020-01-12 15:59:00 = 1578814200000 毫秒,转换为小时 key = 1578815940000 / 1000 * 60 * 60 = 438559,获取小时 key 为 438559

2020-01-12 16:30:00 = 1578817800000 毫秒,转换为小时 key = 1578817800000 / 1000 * 60 * 60 = 438560,获取小时 key 为 438560

  1. 每次每个微博热度有变化时,先计算当前的小时 key ,然后将当前微博作为 member ,热度作为 score ,加入到 ZSet 中。

ZSet 命令如下:

1
ZADD 小时key 热度值 微博内容
  1. 定时一段时间,合并统计天、周、月的排行榜

4.2、Redis 实现点赞功能

之前使用 Redis 的 hash 来实现了点赞功能,现在介绍使用 Redis 的 Set 数据结构实现点赞功能。

1、业务场景分析

点赞的业务场景,它有两个接口:

  1. 点赞或取消点赞
  2. 查看文章信息时,通过用户 id 和文章 id,查看该帖子的点赞数和用户的点赞状态

2、技术方案

  • 点赞的关键技术就是判断该用户是否点赞,已经点过赞的不允许重复点赞,即过滤重复,虽然业务不复杂,可以使用数据库来实现。

  • 但是如果遇到并发量高的情况,那么以上场景会对数据库造成很大的读写压力,所以我们使用 Redis 来实现。

  • 采用 Redis 的 Set 数据结构完成点赞功能

其中 key = like:postId ,value 为点赞了这篇文章的用户 id 集合。即 value = {userId}

  1. 使用 Redis Set 的 SADD 命令,为文章添加点赞用户

比如说现在有三个用户,它们的 id 为 101、102、103,它们都点赞了 id 为 1000 的文章,那么使用 sadd 命令如下

1
2
3
4
# id 为 101 的用户点赞了 id 为 1000 的文章
sadd like:1000 101
sadd like:1000 102
sadd like:1000 103
  1. 使用 smembers key 命令可以查看某篇文章的所有点赞人 id ,比如说我们查看有哪些人为 id 为 1000 的文章点了赞
1
2
3
4
> smembers like:1000
1) "101"
2) "102"
3) "103"
  1. 使用 srem key value 来完成取消点赞的功能,当用户取消点赞后,就将其 id 从文章点赞列表中移除

比如说现在 id 为 101 的用户对 id 为 1000 的文章取消了点赞

1
srem like:1000 101

现在查看文章的所有点赞人

image.png

  1. 通过 scard 命令查看点赞总数

image.png

  1. 使用 sismember 命令判断用户对某篇文章的点赞状态
1
sismember like:1000 102

image.png

返回的结果是 1 ,证明用户已经点赞

1
sismember like:1000 101

image.png

返回的结果是 0 ,证明用户没有点赞

4.3、Java 对象的存储

在 Redis 中,我们通常使用两种数据结构来存储 Java 对象,即 String 与 Hash

  • String 中存储的对象通常用在频繁的读操作中,它的存储格式是 JSON 字符串,即将 Java 对象序列化为 JSON 后再存入到 Redis 中

对于一些只读缓存,可以使用 JSON 来存储。

  • Hash 中存储的对象通常用在频繁的写操作中,即当对象的某个属性频繁修改时,此时不适用 string + json 的数据结构,因为不灵活,每次修改都需要将 JSON 从 Redis 中取出,然后反序列化为 对象,修改属性后再将对象序列化为 JSON 字符串后存入 Redis 中;这个时候如果使用 Hash ,就可以针对某个属性单独进行修改。

例如:商品的库存,价格,库存数,评论数经常发生改变时,就可以用 hash 存储。

五、设计模式

5.1、设计模式六大原则

1、开放封闭原则

尽量通过扩展软件实体来解决需求问题,而不是通过修改已有的代码来完成变化,也就是程序对扩展开放,对修改关闭

2、里氏代换原则

所有引用基类的地方必须能透明地使用其子类的对象,里氏代换原则对继承进行了规则上的约束,这种约束主要体现在四个方面:

  • 子类必须实现父类的抽象方法,但不得覆盖父类中已经实现的方法
  • 子类可以添加自己特有的方法
  • 当子类覆盖或实现父类的方法时,方法的形参要比父类方法的输入参数更宽松
  • 当子类覆盖或实现父类的方法时,方法的返回值要比父类的方法更严格

3、依赖倒转原则

4、接口隔离原则

5、迪米特法则

6、单一职责原则

一个方法、一个类只负责一个职责,各个职责的程序改动不影响其他程序。

六、TCP

6.1、TCP 的重传机制

1、超时重传

重传机制的其中一个方式,就是在发送数据时设置一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传

  • TCP 会在以下两种情况发生超时重传
  1. 发送方数据包丢失
  2. 接收方确认应答丢失

image.png

  • RTT (往返时延):数据包的往返时间。
  • RTO(超时重传时间)
  1. 如果 RTO 太长,那么降低了网络传输效率(丢了半天才重发数据包)
  2. 如果 RTO 太短,那么可能会导致数据包没有丢失就重发了,而这样会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。

RTO 的值应该略大于 RTT 的值

2、快速重传

TCP 还有另外一种 快速重传(Fast Retransmit)机制 ,它 不以时间为驱动,而是以数据驱动重传

image.png

在上图,发送方发出了 1,2,3,4,5 份数据:

  • 第一份 Seq1 先送到了,于是就 Ack 回 2;

  • 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;

  • 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;

  • 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。

  • 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。

快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。

6.2、滑动窗口

1、为什么要引入滑动窗口?

  • TCP 是每发送一个数据,就要进行一次确认应答,当上一个数据包收到应答后在发送下一个,这种方式效率比较低,包的往返时间越长,网络的吞吐量就越低
  • 为了解决这个问题,TCP 引入了窗口这个概念,有了窗口后,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值
  • 窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保存已发送的数据,如果按期收到 ACK ,那么就可以讲数据从缓冲区清除。
  • 假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。

image.png

图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者 累计应答

  • 窗口大小由哪方决定?

TCP 中存在一个 Window 字段,这个字段用于表示窗口大小。

这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以,窗口大小通常由接收方的窗口大小来决定的,发送方发送的数据不能大于接收方的窗口大小,否则接收方就无法正常接收到数据。

  • 发送方的滑动窗口

发送方窗口由 4 部分组成,分别是已发送且收到 ACK 的数据已发送但没有收到 ACK 的数据还没有发送但处于接收方处理范围的数据未发送但大小超过接收方处理范围的数据

image.png

在发送方讲接收方可处理范围数据全部发送出去后,发送方的可用窗口大小变为 0 ,表示可用窗口耗尽,在没有收到接收方 ACK 之前无法继续发送数据。

image.png

在接收到接收方的 ACK 后,如果窗口大小没有发生变化,那么滑动窗口会往右边右移,此时发送方就可以发送下面的数据了

image.png

  • 接收方的滑动窗口

接收方的滑动窗口可以分为已经成功接收并 ACK 的数据未收到数据但可以接收的数据未收到数据并不可以接收的数据

  • 接收窗口和发送窗口的大小关系

并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。

新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉对方的。

6.3、TCP 的流量控制

  • 一般来说,我们总是希望数据传输得快一些,但如果发送方把数据发送得过快,那么接收方就可能来不及接收,这样就会造成数据得丢失。
  • 所谓流量控制(flow control)就是让发送方的发送速率不要太快,要让接收方来得及接收
  • 使用滑动窗口机制可以很方便地在 TCP 连接上实现对发送方的流量控制。
  • TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。

6.4、TCP 的拥塞控制

  • 在网络出现拥堵时,如果继续发送大量数据包,那么可能会导致数据包时延、丢失等,这个时候 TCP 会重传数据,但是一重传就会导致网络负担更加严重,于是会导致更大的延迟以及更多的丢包,造成恶性循环
  • 所以,当网络发送拥塞时, TCP 会自我牺牲,降低发送的数据量(它太团队了)
  • 拥塞控制的目的是为了避免发送方的数据填满整个网络
  • 为了在「发送方」调节所要发送数据的量,定义了一个叫做「 拥塞窗口 」的概念。

拥塞窗口 cwnd发送方维护的一个状态变量,它会根据网络的拥塞程度进行动态变化,在加入拥塞窗口后,发送窗口 swnd接收窗口 rwnd拥塞窗口 cwnd 三者的关系是 swnd = min(rwnd, cwnd)

  • 如何知道当前网络发生了拥塞?

其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞。