(JVM)双亲委派机制

双亲委派机制

Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

工作原理

  • 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  • 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 3)如果父类加载器可以完成类加载任务,就成功返回(就不会由子类加载器去加载了),倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20200705105151258

举例:

image-20221115204949307

出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类。一看你是java开头的,引导类加载就说了。这是归我管我来加载String(核心API里的String)。因此有父类来加载后,就不会再向下委托了,所以我们new 的这个String对象就是核心API里面的String类对象,而不是我们自定义的String,因此就没有打印出自定义String里的static静态资源里的语句

image-20221115205956137

委托到引导类加载器,它发现你这个包是jvm开头的,不归引导类加载管,就向下委托,也不归扩展类加载器管,所以最后回到系统类加载器来加载,因此最后输出结果就是系统类加载来进行的加载

image-20221115210219781

一直往上委托,就交给到了引导类加载器,它加载了String类以后,然后就想去执行main方法,但是核心API的String里面是没有main方法的,所以就报了 错误: 在类 java.lang.String 中找不到 main 方法. 可知,根本就没有试着想去加载我们自定义的String类,完全忽略掉你了

当我们加载 jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是 jdbc.jar 是基于 SPI 接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载 SPI 核心类,然后在加载 SPI 接口类,接着在进行反向委派,通过线程上下文类加载器进行实现类 jdbc.jar 的加载。

image-20200705105810107

优势

  • 避免类的重复加载
  • 保护程序安全,防止核心 API 被随意篡改
    • 自定义类:java.lang.String
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)

image-20221115212723174

引导类加载器看到是 java.lang开头的,就表示这是归它管,于是就要去加载这个ShkStart类了,但直接直接给它报错了,相当于,要加载java.lang这个包,要想访问是要有权限的,现在报错就是阻止我们去直接用这个java.lang包来自定义这个ShkStart类。其实这也是起到了保护作用和出于安全的考虑,如果允许去加载这个类,加载成功的话,就会导致对引导类加载器本身造成影响,所以这里是直接把引导类加载器给整挂了。所以我们也禁止去用java.lang这样的包名去命名

其实这也是起到了保护作用和出于安全的考虑,如果允许去加载这个种自定义的类,加载成功的话,但里面可能会有一些恶意代码,就可能会会对现有的项目和程序进行破坏

弊端

​ 检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类。

​ 通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。

1.4 代码支持

双亲委派机制在java.lang.ClassLoader.loadClass(String,boolean)接口中体现。该接口的逻辑如下:

(1)先在当前加载器的缓存中查找有无目标类,如果有,直接返回。

(2)判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadClass(name,false)接口进行加载。

(3)反之,如果当前加载器的父类加载器为空,则调用findBootstrapClassorNull(name)接口,让引导类加载器进行加载。

(4)如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。该接口最终会调用java.lang.ClassLoader接口的defineClass系列的native接口加载目标Java类。

双亲委派的模型就隐藏在这第2和第3步中。

1.5 举例

假设当前加载的是java.lang.Object这个类,很显然,该类属于JDK中核心得不能再核心的一个类,因此一定只能由引导类加载器进行加载。当]VM准备加载javaJang.Object时,JVM默认会使用系统类加载器去加载,按照上面4步加载的逻辑,在第1步从系统类的缓存中肯定查找不到该类,于是进入第2步。由于从系统类加载器的父加载器是扩展类加载器,于是扩展类加载器继续从第1步开始重复。由于扩展类加载器的缓存中也一定查找不到该类,因此进入第2步。扩展类的父加载器是null,因此系统调用findClass(String),最终通过引导类加载器进行加载。

1.6 思考

如果在自定义的类加载器中重写java.lang.ClassLoader.loadClass(String)或java.lang.ClassLoader.loadclass(String,boolean)方法,抹去其中的双亲委派机制,仅保留上面这4步中的第l步与第4步,那么是不是就能够加载核心类库了呢?

这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用 java.lang.ClassLoader.defineclass(String,byte[],int,int,ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护。

1.8 结论

由于Java虚拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而已。比如在Tomcat中,类加载器所采用的加载机制就和传统的双亲委派模型有一定区别,当缺省的类加载器接收到一个类的加载任务时,首先会由它自行加载,当它加载失败时,才会将类的加载任务委派给它的超类加载器去执行,这同时也是Serylet规范推荐的一种做法。

沙箱安全机制

自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 string 类。这样可以保证对 java 核心源代码的保护,这就是沙箱安全机制。

Redis

1. 说说你对Redis的了解

**得分点 : **Redis概念,Redis优点及用途

标准回答

Redis是一款基于键值对的NoSQL数据库,与其他键值对数据库不同的是,Redis中拥有string(字符串)、hash(哈希)、 list(列表)、set(集合)、zset(有序集合)、Bitmaps(位图)、 HyperLogLog、GEO(地理信息定位)等多种数据结构,这给Redis带来了满足多种应用场景的能力,而且,Redis将所有数据放到内存中的做法让它的读写性能十分惊人。不仅如此,Redis的持久化机制保证了在发生类似断电,机械故障等情况时,内存中的数据不会丢失。此外Redis还提供了键过期、发布订阅、事务、流水线、Lua脚本等多个附加功能。总之,在合适的情况下使用Redis会大大增强系统的性能,减少开发人员工作量。

加分回答

适合Redis使用的场景:

  • 热点数据的缓存:redis访问速度快、支持的数据类型丰富,很适合用来存储热点数据。
  • 限时业务:redis中可以使用expire命令设置一个键的生存时间,到时间后redis会删除它。因此,Redis在限时业务中的表现很亮眼。
  • 计数器:incrby命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式序列号的生成。
  • 排行榜:关系型数据库在排行榜方面查询速度普遍偏慢,所以可以借助redis的SortedSet进行热点数据的排序。
  • 分布式锁:这个主要利用redis的setnx命令进行,在后面的如何用Redis实现一个分布式锁中会进行详解。
  • 延时操作:redis自2.8.0之后版本提供Keyspace Notifications功能,允许客户订阅Pub/Sub频道,以便以某种方式接收影响Redis数据集的事件。
  • 分页查询、模糊查询:Redis的set集合中提供了一个zrangebylex方法,通过ZRANGEBYLEX zset - + LIMIT 0 10 可以进行分页数据查询,其中- +表示获取全部数据;rangebylex key min max 这个就可以返回字典区间的数据可以利用这个特性可以进行模糊查询功能。
  • 点赞,好友等相互关系的存储:Redis set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动排重的,我们可以通过这一点实现类似共同好友等功能。
  • 队列:由于redis有list push和list pop这样的命令,所以能够很方便的执行队列操作。

简介版回答:

Redis是一款基于键值对的NoSQL数据库,Redis中拥有string(字符串),hash(哈希)、list(列表)、set(集合)等多种数据结构,redis将数据写进内存的性能很快,不仅如此,如遇到系统崩溃,内存中的数据不会丢失;redis访问速度快、支持的数据类型丰富,很适合用来储存热点数据、 而且适用业务广,如可以运用expire命令来做限时业务,设置一个键的生存时间,到时间后redis会自动删除它,如排行榜可以借住redis的SortedSet进行热点数据的排序,还有分页查询,模糊查询,点赞好友等

2. 详细的说说Redis的数据类型

得分点

Redis5种数据结构

标准回答

Redis主要提供了5种数据结构:字符串(string)、哈希(hash)、列表(list)、集合(set)、有序集合(zset)。Redis还提供了Bitmap、HyperLogLog、Geo类型,但这些类型都是基于上述核心数据类型实现的(Bitmap基于redis的字符串实现)。5.0版本中,Redis新增加了Streams数据类型,它是一个功能强大的、支持多播的、可持久化的消息队列。

string可以存储字符串、数字和二进制数据,除了值可以是String以外,所有的键也可以是string,string最大可以存储大小为2M的数据。

list保证数据线性有序且元素可重复,它支持lpush、blpush、rpop、brpop等操作,可以当作简单的消息队列使用,一个list最多可以存储2^32-1个元素.

hash的值本身也是一个键值对结构,最多能存储2^32-1个元素.

set是无序不可重复的,它支持多个set求交集、并集、差集, 适合实现共同关注之类的需求,一个set最多可以存储2^32-1个元素.

zset有序不可重复的,它通过给每个元素设置一个分数来作为排序的依据,一个zset最多可以存储2^32-1个元素。

Bitmap: 原本的含义是用一个比特位来映射某个元素的状态。由于一个比特位只能表示 0 和 1 两种状态,所以 BitMap 能映射的状态有限,但是使用比特位的优势是能大量的节省内存空间 Redis 中 BitMap 的使用场景 - 程序员自由之路 - 博客园 (cnblogs.com)

HyperLogLog: 用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的。 Redis HyperLogLog | 菜鸟教程 (runoob.com)

Geo: 主要用于存储地理位置信息,并对存储的信息进行操作,该功能在 Redis 3.2 版本新增。[Redis GEO | 菜鸟教程 (runoob.com)](https://www.runoob.com/redis/redis-geo.html#:~:text=Redis GEO 主要用于存储地理位置信息,并对存储的信息进行操作,该功能在 Redis 3.2,版本新增。 geoadd:添加地理位置的坐标。 geopos:获取地理位置的坐标。 geodist:计算两个位置之间的距离。 georadius:根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。)

加分回答

每种类型支持多个编码,每一种编码采取一个特殊的结构来实现 各类数据结构内部的编码及结构: string:编码分为int、raw、embstr;int底层实现为long,当数据为整数型并且可以用long类型表示时可以用long存储;embstr底层实现为占一块内存的SDS结构,当数据为长度不超过32字节的字符串时,选择以此结构连续存储元数据和值;raw底层实现为占两块内存的SDS,用于存储长度超过32字节的字符串数据,此时会在两块内存中分别存储元数据和值。 list:编码分为ziplist、linkedlist和quicklist(3.2以前版本没有quicklist)。ziplist底层实现为压缩列表,当元素数量小于2且所有元素长度都小于64字节时,使用这种结构来存储;linkedlist底层实现为双端链表,当数据不符合ziplist条件时,使用这种结构存储;3.2版本之后list一般采用quicklist的快速列表结构来代替前两种。 hash:编码分为ziplist、hashtable两种,其中ziplist底层实现为压缩列表,当键值对数量小于2,并且所有的键值长度都小于64字节时使用这种结构进行存储;hashtable底层实现为字典,当不符合压缩列表存储条件时,使用字典进行存储。 set:编码分为inset和hashtable,intset底层实现为整数集合,当所有元素都是整数值且数量不超过2个时使用该结构存储,否则使用字典结构存储。 zset:编码分为ziplist和skiplist,当元素数量小于128,并且每个元素长度都小于64字节时,使用ziplist压缩列表结构存储,否则使用skiplist的字典+跳表的结构存储。


主要:字符串(String),哈希(hash),列表(list),集合(set),有序集合(zset)


3. 说说Redis的持久化策略

得分点 RDB、AOF

标准回答

Redis4.0之后,Redis有RDB持久化(默认)、AOF持久化、RDB-AOF混合持久化这三种持久化方式。

RDB持久化是将当前进程数据以生成快照的方式保存到硬盘的过程,也是Redis默认的持久化机制。RDB会创建一个经过压缩的二进制文件,这个文件以’.rdb‘结尾,内部存储了各个数据库的键值对等信息。RDB持久化过程有手动触发自动触发两种方式。手动触发是指通过SAVE或BGSAVE命令触发RDB持久化操作,创建“.rdb”文件;自动触发是指通过配置选项,让服务器在满足指定条件时自动执行BGSAVE命令。RDB持久化的优点是其生成的紧凑压缩的二进制文件体积小,使用该文件恢复数据的速度非常快;缺点则是BGSAVE每次运行都要执行fork操作创建子进程,这属于重量级操作,不宜频繁执行,因此,RBD没法做到实时的持久化;RDB执行时间长,两次RDB之间写入数据有丢失的风险

image-20230216113518076

AOF(Append Only File)以独立日志的方式记录了每次写入的命令,重启时再重新执行AOF文件中的命令来恢复数据。AOF持久化的优点是与RDB持久化可能丢失大量的数据相比,AOF持久化的安全性要高很多。通过使用everysec(每秒刷盘)选项,用户可以将数据丢失的时间窗口限制在1秒之内。其缺点则是,AOF文件存储的是协议文本,它的体积要比二进制格式的”.rdb”文件大很多。AOF需要通过执行AOF文件中的命令来恢复数据库,其恢复速度比RDB慢很多。AOF在进行重写时也需要创建子进程,在数据库体积较大时将占用大量资源,会导致服务器的短暂阻塞。AOF解决了数据持久化的实时性,是目前Redis主流的持久化方式。

RDB-AOF混合持久化模式是Redis4.0开始引入的,这种模式是基于AOF持久化构建而来的。用户可以通过配置文件中的“aof-use-rdb-preamble yes”配置项开启AOF混合持久化。Redis服务器在执行AOF重写操作时,会像执行BGSAVE命令一样,根据数据库当前的状态生成相应的RDB数据,并将其写入AOF文件中;对于重写之后执行的Redis命令,则以协议文本的方式追加到AOF文件的末尾,即RDB数据之后。 通过使用RDB-AOF混合持久化,用户可以同时获得RDB持久化和AOF持久化的优点,服务器既可以通过AOF文件包含的RDB数据来实现快速的数据恢复操作,又可以通过AOF文件包含的AOF数据来将丢失数据的时间窗口限制在1s之内

加分回答
RDB手动触发分别对应save和bgsave命令: - save 命令会一直阻塞当前Redis服务器到RBD过程完成为止,所以这种方式在操作内存比较大的实例时会造成长时间阻塞,因此线上环境不建议使用,该命令已经被废弃。 - bgsave命令会让Redis进程执行fork创建子进程,由子进程负责RBD持久化过程,完成后自动结束,因此只在fork阶段发生阻塞,一般阻塞的时间也不会很长。因此Redis内部所涉及的几乎所有RDB操作都采用了bgsave的方式。 除了执行命令手动触发之外,Redis内部还存在自动触发RDB的持久化机制,例如以下场景: 1. 使用save相关配置,如“save m n”。表示m秒内数据集存在n次修改 时,自动触发bgsave。 2. 如果从节点执行全量复制操作,主节点自动执行bgsave生成RDB文件并发送给从节点。 3. 执行debug reload命令重新加载Redis时,也会自动触发save操作。 4. 默认情况下执行shutdown命令时,如果没有开启AOF持久化功能则 自动执行bgsave。 AOF默认不开启,需要修改配置项来启用它: appendonly yes # 启用AOF appendfilename “appendonly.aof” # 设置文件名 AOF以文本协议格式写入命令,如: *3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n 文本协议格式具有如下的优点: 1. 文本协议具有很好的兼容性; 2. 直接采用文本协议格式,可以避免二次处理的开销; 3. 文本协议具有可读性,方便直接修改和处理。 AOF持久化的文件同步机制: 为了提高程序的写入性能,现代操作系统会把针对硬盘的多次写操作优化为一次写操作。 1. 当程序调用write对文件写入时,系统不会直接把书记写入硬盘,而是先将数据写入内存的缓冲区中; 2. 当达到特定的时间周期或缓冲区写满时,系统才会执行flush操作,将缓冲区中的数据冲洗至硬盘中; 这种优化机制虽然提高了性能,但也给程序的写入操作带来了不确定性。 1. 对于AOF这样的持久化功能来说,冲洗机制将直接影响AOF持久化的安全性; 2. 为了消除上述机制的不确定性,Redis向用户提供了appendfsync选项,来控制系统冲洗AOF的频率; 3. Linux的glibc提供了fsync函数,可以将指定文件强制从缓冲区刷到硬盘,上述选项正是基于此函数。

4. 如何利用Redis实现一个分布式锁?

得分点: 为什么要实现分布式锁、实现分布式锁的方式

标准回答

在分布式的环境下,会发生多个server并发修改同一个资源的情况,这种情况下,由于多个server是多个不同的JRE环境, 而Java自带的锁局限于当前JRE,所以Java自带的锁机制在这个场景下是无效的,那么就需要我们自己来实现一个分布式锁

采用Redis实现分布式锁,我们可以在Redis中存一份代表锁的数据,数据格式通常使用字符串即可。 首先加锁的逻辑可以通过setnx key value来实现,但如果客户端忘记解锁,那么这种情况就很有可能造成死锁,但如果直接给锁增加过期时间即新增expire key seconds又会发生其他问题,即这两个命令并不是原子性的,那么如果第二步失败,依然无法避免死锁问题。考虑到如上问题,我们最终可以通过set...nx...命令,将加锁、过期命令编排到一起,把他们变成原子操作,这样就可以避免死锁。写法为set key value nx ex seconds 。 解锁就是将代表锁的那份数据删除,但不能用简单的del key,因为会出现一些问题。比如此时有进程A,如果进程A在任务没有执行完毕时,锁被到期释放了。这种情况下进程A在任务完成后依然会尝试释放锁,因为它的代码逻辑规定它在任务结束后释放锁,但是它的锁早已经被释放过了,那这种情况它释放的就可能是其他线程的锁。为解决这种情况,我们可以在加锁时为key赋一个随机值,来充当进程的标识,进程要记住这个标识。当进程解锁的时候进行判断,是自己持有的锁才能释放,否则不能释放。另外判断,释放这两步需要保持原子性,否则如果第二步失败,就会造成死锁。而获取和删除命令不是原子的,这就需要采用Lua脚本,通过Lua脚本将两个命令编排在一起,而整个Lua脚本的执行是原子的。综上所述,优化后的实现分布式锁命令如下: # 加锁 set key random-value nx ex seconds # 解锁 if redis.call(“get”,KEYS[1]) == ARGV[1] then return redis.call(“del”,KEYS[1]) else return 0 end 加分回答 上述的分布式锁实现方式是建立在单节点之上的,它可能存在一些问题,比如有一种情况,进程A在主节点加锁成功,但主节点宕机了,那么从节点就会晋升为主节点。那如果此时另一个进程B在新的主节点上加锁成功而原主节点重启了,成为了从节点,系统中就会出现两把锁,这违背了锁的唯一性原则。 总之,就是在单个主节点的架构上实现分布式锁,是无法保证高可用的。若要保证分布式锁的高可用,则可以采用多个节点的实现方案。这种方案有很多,而Redis的官方给出的建议是采用RedLock算法的实现方案。该算法基于多个Redis节点,它的基本逻辑如下: - 这些节点相互独立,不存在主从复制或者集群协调机制; - 加锁:以相同的KEY向N个实例加锁,只要超过一半节点成功,则认定加锁成功; - 解锁:向所有的实例发送DEL命令,进行解锁; 我们可以自己实现该算法,也可以直接使用Redisson框架。

5. 说说缓存穿透、击穿、雪崩的区别

得分点: 三种问题的发生原因以及解决方式

标准回答

缓存穿透是指客户端查询了根本不存在的数据,使得这个请求直达存储层,导致其负载过大甚至造成宕机。这种情况可能是由于业务层误将缓存和库中的数据删除造成的,当然也不排除有人恶意攻击,专门访问库中不存在的数据导致缓存穿透。 我们可以通过缓存空对象的方式和布隆过滤器两种方式来解决这一问题。缓存空对象是指当存储层未命中后,仍然将空值存入缓存层 ,当客户端再次访问数据时,缓存层直接返回空值。还可以将数据存入布隆过滤器,访问缓存之前以过滤器拦截,若请求的数据不存在则直接返回空值。

缓存击穿当一份访问量非常大的热点数据缓存失效的瞬间,大量的请求直达存储层,导致服务崩溃。 缓存击穿可以通过热点数据不设置过期时间来解决,这样就不会出现上述的问题,这是“物理”上的永不过期。或者为每个数据设置逻辑过期时间,当发现该数据逻辑过期时,使用单独的线程重建缓存。除了永不过期的方式,我们也可以通过加互斥锁的方式来解决缓存击穿,即对数据的访问加互斥锁,当一个线程访问该数据时,其他线程只能等待。这个线程访问过后,缓存中的数据将被重建,届时其他线程就可以直接从缓存中取值。

缓存雪崩是指当某一时刻缓存层无法继续提供服务,导致所有的请求直达存储层,造成数据库宕机。可能是缓存中有大量数据同时过期,也可能是Redis节点发生故障,导致大量请求无法得到处理。 缓存雪崩的解决方式有三种;第一种是在设置过期时间时,附加一个随机数,避免大量的key同时过期。第二种是启用降级和熔断措施,即发生雪崩时,若应用访问的不是核心数据,则直接返回预定义信息/空值/错误信息。或者在发生雪崩时,对于访问缓存接口的请求,客户端并不会把请求发给Redis,而是直接返回。第三种是构建高可用的Redis服务,也就是采用哨兵或集群模式,部署多个Redis实例,这样即使个别节点宕机,依然可以保持服务的整体可用。

6. Redis如何与数据库保持双写一致性

得分点: 四种同步策略及其可能出现的问题,重试机制

标准回答 :

保证缓存和数据库的双写一致性,共有四种同步策略,即先更新缓存再更新数据库、先更新数据库再更新缓存、先删除缓存再更新数据库、先更新数据库再删除缓存先更新缓存的优点是每次数据变化时都能及时地更新缓存,这样不容易出现查询未命中的情况,但这种操作的消耗很大,如果数据需要经过复杂的计算再写入缓存的话,频繁的更新缓存会影响到服务器的性能。如果是写入数据比较频繁的场景,可能会导致频繁的更新缓存却没有业务来读取该数据。 删除缓存的优点是操作简单,无论更新的操作复杂与否,都是直接删除缓存中的数据。这种做法的缺点则是,当删除了缓存之后,下一次查询容易出现未命中的情况,那么这时就需要再次读取数据库。 那么对比而言,删除缓存无疑是更好的选择。 那么我们再来看一下先操作数据库和后操作数据库的区别;先删除缓存再操作数据库的话,如果第二步骤失败可能导致缓存和数据库得到相同的旧数据。先操作数据库但删除缓存失败的话则会导致缓存和数据库得到的结果不一致。出现上述问题的时候,我们一般采用重试机制解决,而为了避免重试机制影响主要业务的执行,一般建议重试机制采用异步的方式执行。当我们采用重试机制之后由于存在并发,先删除缓存依然可能存在缓存中存储了旧的数据,而数据库中存储了新的数据,二者数据不一致的情况。 所以我们得到结论:先更新数据库、再删除缓存是影响更小的方案。如果第二步出现失败的情况,则可以采用重试机制解决问题。

7. 请你说说Redis数据类型中的zset,它和set有什么区别?底层是怎么实现的?

得分点: 有序无序、底层结构

标准回答

Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数, Redis 正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数 ( score ) 却可以重复。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。集合中最大的成员数为 232 – 1 ( 4294967295 ) , 每个集合可存储 40 多亿个成员。

zset底层的存储结构包括ziplist或skiplist,在同时满足有序集合保存的元素数量小于128个和有序集合保存的所有元素的长度小于64字节的时候使用ziplist,其他时候使用skiplist。 当ziplist作为zset的底层存储结构时候,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素保存元素的分值。 当skiplist作为zset的底层存储结构的时候,使用skiplist按序保存元素及分值,使用dict来保存元素和分值的映射关系。

加分回答

实际上单独使用Hashmap或skiplist也可以实现有序集合,Redis使用两种数据结构组合的原因是如果我们单独使用Hashmap,虽然能以O

(1) 的时间复杂度查找成员的分值,但是因为Hashmap是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;而如果单独使用skiplist,虽然能执行范围操作,但查找操作的复杂度却由 O(1)变为了O(logN)。因此Redis使用了两种数据结构来共同实现有序集合。

8. 说说Redis的单线程架构

得分点: 单线程的前提,单线程的优劣,简单的io模型

标准回答

Redis的网络IO和键值对读写是由一个线程来完成的,但Redis的其他功能,例如持久化、异步删除、集群数据同步等操作依赖于其他线程来执行。单线程可以简化数据结构和算法的实现,并且可以避免线程切换和竞争造成的消耗。但要注意如果某个命令执行时间过长,会造成其他命令的阻塞。Redis采用了io多路复用机制,这带给了Redis并发处理大量客户端请求的能力。

Redis单线程实现为什么这么快呢?因为对服务端程序来说,线程切换和锁通常是性能杀手,而单线程避免了线程切换和竞争所产生的消耗。另外Redis的大部分操作是在内存上完成的,这是它实现高性能的一个重要原因;Redis还采用了IO多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率。

加分回答

Redis的单线程主要是指Redis的网络IO和键值对读写是由一个线程来完成的。而Redis的其他功能,如持久化、异步删除、集群数据同步等,则是依赖其他线程来执行的。所以,说Redis是单线程的只是一种习惯的说法,事实上它的底层不是单线程的。

9. 如何实现Redis高可用

得分点: 哨兵模式、集群模式

标准回答

主要有哨兵和集群两种方式可以实现Redis高可用。

哨兵: 哨兵模式是Redis的高可用的解决方案,它由一个或多个Sentinel实例组成Sentinel系统,可以监视任意多个主服务器以及这些主服务器属下的所有从服务器。当哨兵节点发现有节点不可达时,会对该节点做下线标识。如果是主节点下线,它还会和其他Sentinel节点进行“协商”,当大多数Sentinel节点都认为主节点不可达时,它们会选举出一个Sentinel节点来完成自动故障转移的工作,同时会将这个变化实时通知给Redis应用方。 哨兵节点包含如下的特征:

  1. 哨兵节点会定期监控数据节点,其他哨兵节点是否可达;

  2. 哨兵节点会将故障转移的结果通知给应用方;

  3. 哨兵节点可以将从节点晋升为主节点,并维护后续正确的主从关系;

  4. 哨兵模式下,客户端连接的是哨兵节点集合,从中获取主节点信息;

  5. 节点的故障判断是由多个哨兵节点共同完成的,可有效地防止误判;

  6. 哨兵节点集合是由多个哨兵节点组成的,即使个别哨兵节点不可用,整个集合依然是健壮的;

  7. 哨兵节点也是独立的Redis节点,是特殊的Redis节点,它们不存储数据,只支持部分命令。

集群: Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:。

  1. 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;

  2. 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;

  3. 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

哨兵的作用和原理

img

img

img

img

img

10. 说说Redis的主从同步机制

得分点 : psync,全量复制、部分复制

标准回答

Redis主从同步是指任意数量的从节点(slave node)都可以从主节点上(master node)同步数据。而除了多个 slave 可以连接到同一个 master 之外,slave 还可以接受其他 slave 的连接,这就形成一个树形结构,使得Redis可执行单层树复制。 从2.8版本开始,当启动一个 slave node 的时候,它会发送一个 PSYNC 命令给 master node。如果slave node 是第一次连接到 master node,那么会触发一次全量复制。此时 master 会启动一个后台线程,开始生成一份 RDB 快照文件,同时还会将从客户端 client 新收到的所有写命令缓存在内存中。RDB 文件生成完毕后, master 会将这个 RDB 发送给 slave,slave 会先写入本地磁盘,然后再从本地磁盘加载到内存中,接着 master 会将内存中缓存的写命令发送到 slave,slave 也会同步这些数据。slave node 如果跟 master node 有网络故障,断开了连接,会自动重连,连接之后 master node 仅会复制给 slave 部分缺少的数据。

开启主从关系
要配置主从可以使用replicaof 或者slaveof(5.0以前)命令。有临时和永久两种模式:

  • 修改配置文件(永久生效)

    • 在redis.conf中添加一行配置:slaveof
  • 使用redis-cli客户端连接到redis服务,执行slaveof命令(重启后失效):

    • slaveof

image-20230216211538641

数据同步原理

img

img

img

img

img

img

img

img

(9条消息) (分布式缓存)Redis主从_其然乐衣的博客-CSDN博客

11. 说说Redis的缓存淘汰策略(Redis的回收策略)

得分点 : 惰性删除、定期删除,maxmemory-policy

标准回答

Redis有如下两种过期策略:

**惰性删除:**客户端访问一个key的时候,Redis会先检查它的过期时间,如果发现过期就立刻删除这个key。

**定期删除:**Redis会将设置了过期时间的key放到一个独立的字典中,并对该字典进行每秒10次的过期扫描, 过期扫描不会遍历字典中所有的key,而是采用了一种简单的贪心策略。该策略的删除逻辑如下: 1. 从过期字典中随机选择20个key; 2. 删除这20个key中已过期的key; 3. 如果已过期key的比例超过25%,则重复步骤1。 当写入数据将导致超出maxmemory限制时,Redis会采用maxmemory-policy所指定的策略进行数据淘汰,该策略一共包含8种选项,其中除了noeviction直接返回错误之外,筛选键的方式分为volatile和allkeys两种,volatile前缀代表从设置了过期时间的键中淘汰数据,allkeys前缀代表从所有的键中淘汰数据关于后缀,ttl代表选择过期时间最小的键,random代表随机选择键,需要我们额外关注的是lru和lfu后缀,它们分别代表采用lru算法和lfu算法来淘汰数据。因为allkeys是筛选所有的键,所以不存在ttl,余下三个后缀二者都有,lfu算法是再Redis4版本才提出来的。

加分回答

LRU(Least Recently Used)是按照最近最少使用原则来筛选数据,即最不常用的数据会被筛选出来 - 标准LRU:把所有的数据组成一个链表,表头和表尾分别表示MRU和LRU端,即最常使用端和最少使用端。刚被访问的数据会被移动到MRU端,而新增的数据也是刚被访问的数据,也会被移动到MRU端。当链表的空间被占满时,它会删除LRU端的数据。 - 近似LRU:Redis会记录每个数据的最近一次访问的时间戳(LRU)。Redis执行写入操作时,若发现内存超出maxmemory,就会执行一次近似LRU淘汰算法。近似LRU会随机采样N个key,然后淘汰掉最旧的key,若淘汰后内存依然超出限制,则继续采样淘汰。可以通过maxmemory_samples配置项,设置近似LRU每次采样的数据个数,该配置项的默认值为5。 LRU算法的不足之处在于,若一个key很少被访问,只是刚刚偶尔被访问了一次,则它就被认为是热点数据,短时间内不会被淘汰。 LFU算法正式用于解决上述问题,LFU(Least Frequently Used)是Redis4新增的淘汰策略,它根据key的最近访问频率进行淘汰。LFU在LRU的基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用LFU策略淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出内存。如果两个数据的访问次数相同,LFU再比较这两个数据的访问时间,把访问时间更早的数据淘汰出内存。

Redis的回收策略

volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
no-enviction(驱逐):禁止驱逐数据
注意这里的6种机制,volatile和allkeys规定了是对已设置过期时间的数据集淘汰数据还是从全部数据集淘汰数据,后面的lru、ttl以及random是三种不同的淘汰策略,再加上一种no-enviction永不回收的策略。
使用策略规则:
1、如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用allkeys-lru
2、如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用allkeys-random

12. 使用Redis有哪些好处?

(1) 速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)
(2) 支持丰富数据类型,支持string,list,set,zset,hash
(3) 支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行
(4) 丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除

13. Redis的缺点

  1. 不保证数据的可靠性,数据有可能在宕机情况会丢失少部分数据.不能保持数据库和缓存的一致性,如果需要保持一致性,需要付出一定性能代价(加锁串行)
  2. 单线程操作,无法利用多核CPU的优势
  3. 如果进行完整重同步,由于需要生成rdb文件,并进行传输,会占用主机的CPU,并会消耗现网的带宽。不过redis2.8版本以后,已经有部分重同步的功能,但是还是有可能有完整重同步的。比如,新上线的从库。

14. Redis相比memcached有哪些优势?

(1) memcached所有的值均是简单的字符串,redis作为其替代者,支持更为丰富的数据类型
(2) redis的速度比memcached快很多
(3) redis可以持久化其数据
(4) redis支持数据的备份,即master-slave模式的数据备份。
(5) 使用底层模型不同,它们之间底层实现方式 以及与客户端之间通信的应用协议不一样。Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
(6)value大小:redis最大可以达到1GB,而memcache只有1MB

八股文

netty

io

设计模式

ID 标题 地址
1 设计模式面试题(总结最全面的面试题) juejin.cn/post/684490…
2 Java基础知识面试题(总结最全面的面试题) juejin.cn/post/684490…
3 Java集合面试题(总结最全面的面试题) juejin.cn/post/684490…
4 JavaIO、BIO、NIO、AIO、Netty面试题(总结最全面的面试题) juejin.cn/post/684490…
5 Java并发编程面试题(总结最全面的面试题) juejin.cn/post/684490…
6 Java异常面试题(总结最全面的面试题) juejin.cn/post/684490…
7 Java虚拟机(JVM)面试题(总结最全面的面试题) juejin.cn/post/684490…
8 Spring面试题(总结最全面的面试题) juejin.cn/post/684490…
9 Spring MVC面试题(总结最全面的面试题) juejin.cn/post/684490…
10 Spring Boot面试题(总结最全面的面试题) juejin.cn/post/684490…
11 Spring Cloud面试题(总结最全面的面试题) juejin.cn/post/684490…
12 Redis面试题(总结最全面的面试题) juejin.cn/post/684490…
13 MyBatis面试题(总结最全面的面试题) juejin.cn/post/684490…
14 MySQL面试题(总结最全面的面试题) juejin.cn/post/684490…
15 TCP、UDP、Socket、HTTP面试题(总结最全面的面试题) juejin.cn/post/684490…
16 Nginx面试题(总结最全面的面试题) juejin.cn/post/684490…
17 ElasticSearch面试题
18 kafka面试题
19 RabbitMQ面试题(总结最全面的面试题) juejin.cn/post/684490…
20 Dubbo面试题(总结最全面的面试题) juejin.cn/post/684490…
21 ZooKeeper面试题(总结最全面的面试题) juejin.cn/post/684490…
22 Netty面试题(总结最全面的面试题)
23 Tomcat面试题(总结最全面的面试题) juejin.cn/post/684490…
24 Linux面试题(总结最全面的面试题) juejin.cn/post/684490…
25 互联网相关面试题(总结最全面的面试题)
26 互联网安全面试题(总结最全面的面试题)

1. 零碎笔记

image-20221112210028078

image-20221112210242978

image-20221112210338812

image-20221112212347371

Java虚拟机不只是适用于java语言,也适用于其它语言,只要其它语言通过编译器生成的字节码文件遵循java虚拟机的规则,java虚拟机就可以运行

image-20221112212742280

1.5. 虚拟机与 Java 虚拟机

虚拟机

所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。

  • 大名鼎鼎的 Visual Box,Mware 就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
  • 程序虚拟机的典型代表就是 Java 虚拟机,它专门为执行单个计算机程序而设计,在 Java 虚拟机中执行的指令我们称为 Java 字节码指令。

无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。

Java 虚拟机

  • Java 虚拟机是一台执行 Java 字节码的虚拟计算机,它拥有独立的运行机制,其运行的 Java 字节码也未必由 Java 语言编译而成。
  • JVM 平台的各种语言可以共享 Java 虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。
  • Java 技术的核心就是 Java 虚拟机(JVM,Java Virtual Machine),因为所有的 Java 程序都运行在 Java 虚拟机内部。

作用

  • Java 虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条 Java 指令,Java 虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。

特点

  • 一次编译,到处运行
  • 自动内存管理
  • 自动垃圾回收功能

JVM 的位置

image-20200704183048061

JVM 是运行在操作系统之上的,它与硬件没有直接的交互
image-20210507104030823

1.6. JVM 的整体结构

image-20200704183436495

  • HotSpot VM 是目前市面上高性能虚拟机的代表作之一。
  • 它采用解释器与即时编译器并存的架构。
  • 在今天,Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++程序一较高下的地步。

1.7. Java 代码执行流程

image-20200704210429535

操作系统并不能识别字节码指令,只能识别机器指令

1.8. JVM 的架构模型

Java 编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构

具体来说:这两种架构之间的区别:

基于栈式架构的特点

  • 设计和实现更简单,适用于资源受限的系统
  • 避开了寄存器的分配难题:使用零地址指令方式分配
  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
  • 不需要硬件支持,可移植性更好,更好实现跨平台

基于寄存器架构的特点

  • 典型的应用是 x86 的二进制指令集:比如传统的 PC 以及 Android 的 Davlik 虚拟机
  • 指令集架构则完全依赖硬件,可移植性差
  • 性能优秀和执行更高效
  • 花费更少的指令去完成一项操作
  • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主

举例 1

同样执行 2+3 这种逻辑操作,其指令分别如下:

基于栈的计算流程(以 Java 虚拟机为例):

1
2
3
4
5
6
7
8
iconst_2 //常量2入栈
istore_1
iconst_3 // 常量3入栈
istore_2
iload_1
iload_2
iadd //常量2/3出栈,执行相加
istore_0 // 结果5入栈

而基于寄存器的计算流程

1
2
mov eax,2 //将eax寄存器的值设为1
add eax,3 //使eax寄存器的值加3

举例 2

1
2
3
4
5
6
public int calc(){
int a=100;
int b=200;
int c=300;
return (a + b) * c;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
> javap -c Test.class
...
public int calc();
Code:
Stack=2,Locals=4,Args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
}

总结

由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

时至今日,尽管嵌入式平台已经不是 Java 程序的主流运行平台了(准确来说应该是 HotSpotVM 的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?


编译器前端(将源文件编译生成字节码文件),编译器后端(将字节码指令编译成机器指令)

因为机器指令是反复执行的热点代码,所以缓存起来,下次可以直接调用


image-20221112225810473

栈式架构采用的是8位作为一个基本单位的,所以栈的指令集更小,但是指令数多

寄存器架构采用的是16位的双字节的进行设计的,所以指令集大,但指令数更少

比如:

image-20221112225721970

总结

由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。

优点:是跨平台,指令集小,编译器容易实现。

缺点:是性能下降,实现同样的功能需要更多的指令。

栈:

跨平台、指令集小、指令多,执行性能比寄存器差


虚拟机的生命周期

虚拟机的启动

Java 虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。

image-20221113093105220

像上面我们自定义的类,是由系统类加载器来加载的

但(如果)没有明确指定的父类,它的父类就是Object,Object作为核心api,由引导类加载器(bootstrap class loader)加载的

我们要启动一个类,而父类是要早于子类先加载的,但是父类还没加载而它的子类要用,所以我们就需要先启动Java虚拟机

虚拟机的执行

  • 一个运行中的 Java 虚拟机有着一个清晰的任务:执行 Java 程序。

  • 程序开始执行时他才运行,程序结束时他就停止。

  • 执行一个所谓的 Java 程序的时候,真真正正在执行的是一个叫做 Java 虚拟机的进程。

虚拟机的退出

有如下的几种情况:

  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统用现错误而导致 Java 虚拟机进程终止
  • 某线程调用 Runtime 类或 system 类的 exit 方法,或 Runtime 类的 halt 方法,并且 Java 安全管理器也允许这次 exit 或 halt 操作。
  • 除此之外,JNI(Java Native Interface)规范描述了用 JNI Invocation API 来加载或卸载 Java 虚拟机时,Java 虚拟机的退出情况。

image-20221113101445106

image-20221113101718312

编译器前端(将源文件编译生成字节码文件),编译器后端(将字节码指令编译成机器指令)

因为机器指令是反复执行的热点代码,所以缓存起来,下次可以直接调用


HotSpot VM

  • HotSpot 历史
    • 最初由一家名为“Longview Technologies”的小公司设计
    • 1997 年,此公司被 sun 收购;2009 年,Sun 公司被甲骨文收购。
    • JDK1.3 时,HotSpot VM 成为默认虚拟机
  • 目前 Hotspot 占有绝对的市场地位,称霸武林。
    • 不管是现在仍在广泛使用的 JDK6,还是使用比例较多的 JDK8 中,默认的虚拟机都是 HotSpot
    • Sun / Oracle JDK 和 OpenJDK 的默认虚拟机
    • 因此本课程中默认介绍的虚拟机都是 HotSpot,相关机制也主要是指 HotSpot 的 Gc 机制。(比如其他两个商用虚机都没有方法区的概念)
  • 从服务器、桌面到移动端、嵌入式都有应用。
  • 名称中的 HotSpot 指的就是它的热点代码探测技术
    • 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
    • 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡

JRockit

  • 专注于服务器端应用

    • 它可以不太关注程序启动速度,因此 JRockit 内部不包含解析器实现,全部代码都靠即时编译器编译后执行。
  • 大量的行业基准测试显示,JRockit JVM 是世界上最快的 JVM。

    • 使用 JRockit 产品,客户已经体验到了显著的性能提高(一些超过了 70%)和硬件成本的减少(达 50%)。
  • 优势:全面的 Java 运行时解决方案组合

    • JRockit 面向延迟敏感型应用的解决方案 JRockit Real Time 提供以毫秒或微秒级的 JVM 响应时间,适合财务、军事指挥、电信网络的需要
    • MissionControl 服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具。
  • 2008 年,JRockit 被 oracle 收购。

  • Oracle 表达了整合两大优秀虚拟机的工作,大致在 JDK8 中完成。整合的方式是在 HotSpot 的基础上,移植 JRockit 的优秀特性。

  • 高斯林:目前就职于谷歌,研究人工智能和水下机器人

IBM 的 J9

  • 全称:IBM Technology for Java Virtual Machine,简称 IT4J,内部代号:J9
  • 市场定位与 HotSpot 接近,服务器端、桌面应用、嵌入式等多用途 VM
  • 广泛用于 IBM 的各种 Java 产品(一般用于自己的产品)。
  • 目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的 Java 虚拟机(但J9只是在自己的产品上使用时比较快,而通用性的话,还是JRockit好点)。
  • 2017 年左右,IBM 发布了开源 J9VM,命名为 openJ9,交给 EClipse 基金会管理,也称为 Eclipse OpenJ9

总结

​ 具体 JVM 的内存结构,其实取决于其实现,不同厂商的 JVM,或者同一厂商发布的不同版本,都有可能存在一定差异。主要以 Oracle HotSpot VM 为默认虚拟机。


2. 类加载子系统

如果自己想手写一个 Java 虚拟机的话,主要考虑哪些结构呢?

  • 类加载器
  • 执行引擎

**类加载器子系统:**获取字节码文件的信息(常量信息,变量信息,方法,指令等),然后有组织地分配到内存当中(运行时数据区

执行引擎: 需要去解释这些指令


如果不是一个合法的字节码文件,就会在加载的过程中抛出异常。如果有些恶意攻击的话,就会对这个字节码文件进行修改,就不合法了

初始化阶段

  • 初始化阶段就是执行类构造器方法<clinit>()的过程。
  • 此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作静态代码块中的语句合并而来。
  • 构造器方法中指令按语句在源文件中出现的顺序执行。
  • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())
  • 若该类具有父类,JVM 会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。
  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。

image-20221114203139057

当要只能main方法时,先把这个ClinitTest1类加载到内存当中(当然在加载这个类之前先把它的父类加载),加载完后,调用main静态方法,里面要加载Son这个类,要把Son这个类加载进来,但在加载Son类之前要先加载Son的父类Father,加载完父类后,在加载Son的时候,在初始化这个环节,把A的值赋过来,这时A已经等于2,所以main中打印出来的结果就是2

虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。

线程2进来了,但是线程2出不来,它还在初始化这个DeadThread类,线程1也进不去,因为线程正在处于一个加锁的状态

image-20221114213537778

image-20221114210548293

static代码块只执行一次原因:

​ static代码块只在类加载时执行,类是用类加载器来读取的,类加载器是带有一个缓存区的,它会把读取到的类缓存起来,所以在一次虚拟机运行期间,一个类只会被加载一次,这样的话静态代码块只会运行一次


2.3.1. 虚拟机自带的加载器

启动类加载器(引导类加载器,Boostrap ClassLoader),我们获取不到,它是由C/C++语言编写的

**扩展类加载器(Extension ClassLoader)**和 **应用程序类加载器(系统类加载器,AppClassLoader)**都是由java语言编写的

还要记住什么样的类加载器加载什么样的类文件


为什么要自定义类加载器?

  • 隔离加载类 (避免类的冲突)
    • 在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。
  • 修改类加载的方式 (可以实现动态的加载)
    • 类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载
  • 扩展加载源
    • 比如从数据库、网络、甚至是电视机机顶盒进行加载。
  • 防止源码泄漏
    • Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。

获取 ClassLoader 的途径

  • 方式一:获取当前 ClassLoader

    1
    clazz.getClassLoader()
  • 方式二:获取当前线程上下文的 ClassLoader

    1
    Thread.currentThread().getContextClassLoader()
  • 方式三:获取系统的 ClassLoader

    1
    ClassLoader.getSystemClassLoader()
  • 方式四:获取调用者的 ClassLoader

    1
    DriverManager.getCallerClassLoader()

2.5. 双亲委派机制

Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

工作原理

  • 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  • 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 3)如果父类加载器可以完成类加载任务,就成功返回(就不会由子类加载器去加载了),倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20200705105151258

举例:

image-20221115204949307

出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类。一看你是java开头的,引导类加载就说了。这是归我管我来加载String(核心API里的String)。因此有父类来加载后,就不会再向下委托了,所以我们new 的这个String对象就是核心API里面的String类对象,而不是我们自定义的String,因此就没有打印出自定义String里的static静态资源里的语句

image-20221115205956137

委托到引导类加载器,它发现你这个包是jvm开头的,不归引导类加载管,就向下委托,也不归扩展类加载器管,所以最后回到系统类加载器来加载,因此最后输出结果就是系统类加载来进行的加载

image-20221115210219781

一直往上委托,就交给到了引导类加载器,它加载了String类以后,然后就想去执行main方法,但是核心API的String里面是没有main方法的,所以就报了 错误: 在类 java.lang.String 中找不到 main 方法. 可知,根本就没有试着想去加载我们自定义的String类,完全忽略掉你了

当我们加载 jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是 jdbc.jar 是基于 SPI 接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载 SPI 核心类,然后在加载 SPI 接口类,接着在进行反向委派,通过线程上下文类加载器进行实现类 jdbc.jar 的加载。

image-20200705105810107

优势

  • 避免类的重复加载
  • 保护程序安全,防止核心 API 被随意篡改
    • 自定义类:java.lang.String
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)

image-20221115212723174

引导类加载器看到是 java.lang开头的,就表示这是归它管,于是就要去加载这个ShkStart类了,但直接直接给它报错了,相当于,要加载java.lang这个包,要想访问是要有权限的,现在报错就是阻止我们去直接用这个java.lang包来自定义这个ShkStart类。其实这也是起到了保护作用和出于安全的考虑,如果允许去加载这个类,加载成功的话,就会导致对引导类加载器本身造成影响,所以这里是直接把引导类加载器给整挂了。所以我们也禁止去用java.lang这样的包名去命名

其实这也是起到了保护作用和出于安全的考虑,如果允许去加载这个种自定义的类,加载成功的话,但里面可能会有一些恶意代码,就可能会会对现有的项目和程序进行破坏

沙箱安全机制

自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 string 类。这样可以保证对 java 核心源代码的保护,这就是沙箱安全机制。


2.6. 其他

如何判断两个 class 对象是否相同

在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名。
  • 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同。

换句话说,在 JVM 中,即使这两个类对象(class 对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。


自定义UserDetailsServiceImpl

当什么也没有配置的时候,账号和密码是由Spring Security定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。 所以我们要通过自定义逻辑控制认证逻辑。

如果需要自定义逻辑时,只需要实现UserDetailsService接口即可。接口定义如下:

UserDetailsServiceImpl

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.atguigu.aclservice.service.impl;

import com.atguigu.aclservice.entity.User;
import com.atguigu.aclservice.service.PermissionService;
import com.atguigu.aclservice.service.UserService;
import com.atguigu.security.entity.SecurityUser;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.List;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/11
*
* 当什么也没有配置的时候,账号和密码是由Spring Security定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。 所以
* 我们要通过自定义逻辑控制认证逻辑。
* 如果需要自定义逻辑时,只需要实现UserDetailsService接口即可。接口定义如下:
*/
public class UserDetailsServiceImpl implements UserDetailsService {

@Autowired
private UserService userService;

@Autowired
private PermissionService permissionService;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名查询数据
User user = userService.selectByUsername(username);

// 判断
if (user == null) {
// 抛出用户名没有发现异常,系统就知道用户名没有查询到
throw new UsernameNotFoundException("用户不存在");
}
com.atguigu.security.entity.User curUser = new com.atguigu.security.entity.User();
BeanUtils.copyProperties(user, curUser);

// 根据用户查询用户权限列表
List<String> permissionValueList = permissionService.selectPermissionValueByUserId(user.getId());
SecurityUser securityUser = new SecurityUser();
UserDetails userDetails = new SecurityUser();
securityUser.setPermissionValueList(permissionValueList);

return securityUser;
}
}

SecurityUser

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package com.atguigu.security.entity;

import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/10
*/
@Data
@Slf4j
public class SecurityUser implements UserDetails {
//当前登录用户
private transient User currentUserInfo;
//当前权限
private List<String> permissionValueList;
public SecurityUser() {
}
public SecurityUser(User user) {
if (user != null) {
this.currentUserInfo = user;
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) {
continue;
}
SimpleGrantedAuthority authority = new
SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
return authorities;
}
@Override
public String getPassword() {
return currentUserInfo.getPassword();
}
@Override
public String getUsername() {
return currentUserInfo.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

User

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
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.io.Serializable;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/10
*/
@Data
@ApiModel(description = "用户实体类")
public class User implements Serializable {

private static final long serialVersionUID = 1L;

/**
* @ApiModelProperty用于swapper测试的
*/
@ApiModelProperty(value = "微信openid")
private String username;

@ApiModelProperty(value = "密码")
private String password;

@ApiModelProperty(value = "昵称")
private String nickName;

@ApiModelProperty(value = "用户头像")
private String salt;

@ApiModelProperty(value = "用户签名")
private String token;

}
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
@Configuration
public class RequestInterceptor implements WebMvcConfigurer {

@Autowired
private RedisTemplate redisTemplate;

private List<String> patterns = new ArrayList<String>(); //不用过滤的url

public void addInterceptor(InterceptorRegistry registry){

//写一个拦截器
registry.addInterceptor(new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {


//从请求头中获取token
String token = request.getHeader("Authorization");
// 从redis中获取token
if(token != null && redisTemplate.opsForValue().get(token) != null){
//每次认证后就充值为30天 时间单位:天
redisTemplate.expire(token,30, TimeUnit.DAYS);
return true; //取到就返回true
}

//设置响应状态为401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

return false;
}
}).excludePathPatterns(patterns); //不用过滤的url

}
}

JWT 生成Token、解析Token的简单工具类

pom.xml导入依赖:

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

JwtTokenManager工具类

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import com.atguigu.security.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.CompressionCodecs;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/12
*/
public class JwtTokenManager {

/**
* 用于签名的私钥
*/
private final String PRIVATE_KEY = "516Letitbe";

/**
* 签发者
*/
private final String ISSUER = "Letitbe";

/**
* 过期时间 1 小时
*/
private final long EXPIRATION_ONE_HOUR = 3600L;

/**
* 过期时间 1 天
*/
private final long EXPIRATION_ONE_DAY = 3600 * 24;

/**
* 生成Token
* @param user token存储的 实体类 信息
* @param expireTime token的过期时间
* @return
*/
public String createToken(User user, long expireTime) {
// 过期时间
if ( expireTime == 0 ) {
// 如果是0,就设置默认 1天 的过期时间
expireTime = EXPIRATION_ONE_DAY;
}

Map<String, Object> claims = new HashMap<>();
// 自定义有效载荷部分, 将User实体类用户名和密码存储
claims.put("id", user.getId());
claims.put("username", user.getUsername());
claims.put("password", user.getPassword());

String token = Jwts.builder()
// 发证人
.setIssuer(ISSUER)
// 有效载荷
.setClaims(claims)
// 设定签发时间
.setIssuedAt(new Date())
// 设置有效时长
.setExpiration(new Date(System.currentTimeMillis() + expireTime))
// 使用HS512算法签名,PRIVATE_KEY为签名密钥
.signWith(SignatureAlgorithm.HS512, PRIVATE_KEY)
// compressWith() 压缩方法,当载荷过长时可对其进行压缩
// 可采用jjwt实现的两种压缩方法CompressionCodecs.GZIP和CompressionCodecs.DEFLATE
.compressWith(CompressionCodecs.GZIP)
// 生成JWT
.compact();
return token;
}

/**
* 获取token中的User实体类
* @param token
* @return
*/
public User getUserFromToken(String token) {
// 获取有效载荷
Claims claims = getClaimsFromToken(token);
// 解析token后,从有效载荷取出值
String id = (String) claims.get("id");
String username = (String) claims.get("username");
String password = (String) claims.get("password");
// 封装成User实体类
User user = new User();
user.setId( id );
user.setUsername( username );
user.setPassword( password );
return user;
}

/**
* 获取有效载荷
* @param token
* @return
*/
public Claims getClaimsFromToken(String token){
Claims claims = null;
try {
claims = Jwts.parser()
//设定解密私钥
.setSigningKey(PRIVATE_KEY)
//传入Token
.parseClaimsJws(token)
//获取载荷类
.getBody();
}catch (Exception e){
return null;
}
return claims;
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void czyTokenTest() {
User user = new User();
user.setId("1arg232t3tg231235");
user.setUsername("其然乐衣");
user.setPassword("2000516");

String token = jwtTokenManager.createToken(user, 0L);
System.out.println("生成的token: ");
System.out.println(token);
User userFromToken = jwtTokenManager.getUserFromToken(token);
System.out.println("userFromToken:");
System.out.println(userFromToken);
}

image-20221112165845740

Spring Security-安全管理框架

Spring Security-安全管理框架 - 知乎 (zhihu.com)

Spring Security-安全管理框架

Spring Security-安全管理框架

主要内容

  1. Spring Security 简介
  2. 第一个Spring Security项目
  3. UserDetailsService详解
  4. PasswordEncoder密码解析器详解
  5. 自定义登录逻辑
  6. 自定义登录逻辑(数据库访问方式)
  7. 自定义登录页面
  8. 认证过程其他常用配置
  9. 完整认证流程

【尚学堂】SpringSecurity安全管理框架_spring security框架从入门到实战_Spring Security源码解析_哔哩哔哩 (゜-゜)つロ 干杯~-bilibiliwww.bilibili.com/video/BV1R54y1a7Cvimg

一、Spring Security简介

1、概括

Spring Security是一个高度自定义的安全框架。利用Spring IoC/DI和AOP功能,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。

使用Spring Secruity的原因有很多,但大部分都是发现了javaEE的Servlet规范或EJB规范中的安全功能缺乏典型企业应用场景。同时认识到他们在WAR或EAR级别无法移植。因此如果你更换服务器环境,还有大量工作去重新配置你的应用程序。使用Spring Security 解决了这些问题,也为你提供许多其他有用的、可定制的安全功能。

正如你可能知道的关于安全方面的两个主要区域是“认证”和“授权”(或者访问控制)。这两点也是Spring Security重要核心功能。“认证”,是建立一个他声明的主体的过程(一个“主体”一般是指用户,设备或一些可以在你的应用程序中执行动作的其他系统),通俗点说就是系统认为用户是否能登录。“授权”指确定一个主体是否允许在你的应用程序执行一个动作的过程。通俗点讲就是系统判断用户是否有权限去做某些事情。

2、历史

Spring Security 以“The Acegi Secutity System for Spring” 的名字始于2003年年底。其前身为acegi项目。起因是Spring开发者邮件列表中一个问题,有人提问是否考虑提供一个基于Spring的安全实现。限制于时间问题,开发出了一个简单的安全实现,但是并没有深入研究。几周后,Spring社区中其他成员同样询问了安全问题,代码提供给了这些人。2004年1月份已经有20人左右使用这个项目。随着更多人的加入,在2004年3月左右在sourceforge中建立了一个项目。在最开始并没有认证模块,所有的认证功能都是依赖容器完成的,而acegi则注重授权。但是随着更多人的使用,基于容器的认证就显现出了不足。acegi中也加入了认证功能。大约1年后acegi成为Spring子项目。

在2006年5月发布了acegi 1.0.0版本。2007年底acegi更名为Spring Security。

二、第一个Spring Security项目

1、导入依赖

Spring Security已经被Spring boot进行集成,使用时直接引入启动器即可。

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

2、访问页面

导入spring-boot-starter-security启动器后,Spring Security已经生效,默认拦截全部请求,如果用户没有登录,跳转到内置登录页面。

在项目中新建login.html页面后

在浏览器输入:http://localhost:8080/login.html后会显示下面页面

img

默认的username为user,password打印在控制台中。当然了,同学们显示的肯定和我的不一样。

img

在浏览器中输入账号和密码后会显示login.html页面内容。

3、应用场景

3.1 对已有项目添加认证功能

在很多技术中都可能有web访问控制页面。例如:solr就有web管理页面。不需要进行登录,只要知道ip和端口任何人都可以进行访问的。可能导致solr中数据不安全问题。为了保证数据安全性,可以想办法添加Spring Security。(实际上无法添加的,非maven项目)后面还会学很多其他技术,也可以按照这个思想进行操作。

3.2 对常规项目

需要有权限控制的项目都可以使用Spring Security。

4、可以自定义用户名和密码

通过修改application.properties(application.yml)

1
2
spring.security.user.name=smallming
spring.security.user.password=smallming

三、UserDetailsService详解

当什么也没有配置的时候,账号和密码是由Spring Security定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。 所以我们要通过自定义逻辑控制认证逻辑。

如果需要自定义逻辑时,只需要实现UserDetailsService接口即可。接口定义如下:

img

1、返回值

返回值UserDetails是一个接口,定义如下

img

要想返回UserDetails的实例就只能返回接口的实现类。Spring Security中提供了如下的实例。对于我们只需要使用里面的User类即可。注意User的全限定路径是:

org.springframework.security.core.userdetails.User

此处经常和系统中自己开发的User类弄混。

img

在User类中提供了很多方法和属性。

img

其中构造方法有两个,调用其中任何一个都可以实例化UserDetails实现类User类的实例。而三个参数的构造方法实际上也是调用7个参数的构造方法。

  • username:用户名
  • password:密码
  • authorities:用户具有的权限。此处不允许为null

img

此处的用户名应该是客户端传递过来的用户名。而密码应该是从数据库中查询出来的密码。Spring Security会根据User中的password和客户端传递过来的password进行比较。如果相同则表示认证通过,如果不相同表示认证失败。

authorities里面的权限对于后面学习授权是很有必要的,包含的所有内容为此用户具有的权限,如有里面没有包含某个权限,而在做某个事情时必须包含某个权限则会出现403。通常都是通过AuthorityUtils.commaSeparatedStringToAuthorityList(“”)来创建authorities集合对象的。参数时一个字符串,多个权限使用逗号分隔。

2、方法参数

方法参数表示用户名。此值是客户端表单传递过来的数据。默认情况下必须叫username,否则无法接收。

3、异常

UsernameNotFoundException 用户名没有发现异常。在loadUserByUsername中是需要通过自己的逻辑从数据库中取值的。如果通过用户名没有查询到对应的数据,应该抛出UsernameNotFoundException,系统就知道用户名没有查询到。

四、PasswordEncoder密码解析器详解

Spring Security要求容器中必须有PasswordEncoder实例(客户端密码和数据库密码是否匹配是由Spring Security 去完成的,Security中还没有默认密码解析器)。所以当自定义登录逻辑时要求必须给容器注入PaswordEncoder的bean对象

1、接口介绍

encode():把参数按照特定的解析规则进行解析。

matches()验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,则返回true;如果不匹配,则返回false。第一个参数表示需要被解析的密码。第二个参数表示存储的密码。

upgradeEncoding():如果解析的密码能够再次进行解析且达到更安全的结果则返回true,否则返回false。默认返回false。

img

2、内置解析器介绍

在Spring Security中内置了很多解析器。

img

3、BCryptPasswordEncoder简介

BCryptPasswordEncoder是Spring Security官方推荐的密码解析器,平时多使用这个解析器。

BCryptPasswordEncoder是对bcrypt强散列方法的具体实现。是基于Hash算法实现的单向加密。可以通过strength控制加密强度,默认10.

4、代码演示

在项目src/test/java下新建com.bjsxt.MyTest测试BCryptPasswordEncoder用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootTest
@RunWith(SpringRunner.class)
public class MyTest {
@Test
public void test(){
//创建解析器
PasswordEncoder encoder = new BCryptPasswordEncoder();

//对密码进行加密
String password = encoder.encode("123");
System.out.println("------------"+password);

//判断原字符加密后和内容是否匹配
boolean result = encoder.matches("123",password);
System.out.println("============="+result);
}
}

五、自定义登录逻辑

当进行自定义登录逻辑时需要用到之前讲解的UserDetailsService和PasswordEncoder。但是Spring Security要求:当进行自定义登录逻辑时容器内必须有PasswordEncoder实例。所以不能直接new对象。

1、编写配置类

新建类com.bjsxt.config.SecurityConfig 编写下面内容

1
2
3
4
5
6
7
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder getPwdEncoder(){
return new BCryptPasswordEncoder();
}
}

2、自定义逻辑

在Spring Security中实现UserDetailService就表示为用户详情服务。在这个类中编写用户认证逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder encoder;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//1. 查询数据库判断用户名是否存在,如果不存在抛出UsernameNotFoundException

if(!username.equals("admin")){
throw new UsernameNotFoundException("用户名不存在");
}
//把查询出来的密码进行解析,或直接把password放到构造方法中。
//理解:password就是数据库中查询出来的密码,查询出来的内容不是123
String password = encoder.encode("123");

return new User(username,password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}

3、查看效果

重启项目后,在浏览器中输入账号:admin,密码:123。后可以正确进入到login.html页面。

六、自定义登录逻辑(数据库访问方式)

1、新建数据库表结构

根据RBAC设计思想完成数据库原型设计。

因为Spring Security中UserDetails的实现类是User,所以我们尽量不要叫做User

img

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
36
37
38
39
40
41
42
43
44
45
46
47
48
create table users(
id bigint primary key auto_increment,
username varchar(20) unique not null,
password varchar(100)
);
-- 密码zs
insert into users values(1,'张三','$2a$10$dwi9Xv9cFDC1r8zQDp9wzupxoULvlzjtAMoes1zExZuDdLqtxT.rG');
-- 密码ls
insert into users values(2,'李四','$2a$10$Tomc5i8yHA.dUROgqX0eVO.Aa9qOAnvbNkUJhZ1znemqhRWdGGSle');

create table role(
id bigint primary key auto_increment,
name varchar(20)
);

insert into role values(1,'管理员');
insert into role values(2,'普通用户');

create table role_user(
uid bigint,
rid bigint
);

insert into role_user values(1,1);
insert into role_user values(2,2);


create table menu(
id bigint primary key auto_increment,
name varchar(20),
url varchar(100),
parentid bigint,
permission varchar(20)

);

insert into menu values(1,'系统管理','',0,'menu:sys');
insert into menu values(2,'用户管理','',0,'menu:user');


create table role_menu(
mid bigint,
rid bigint
);

insert into role_menu values(1,1);
insert into role_menu values(2,1);
insert into role_menu values(2,2);

2、在项目中添加依赖

添加MyBatis相关依赖

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
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
</dependencies>

3、编写配置文件

新建application.yml

在配置文件中添加Mybatis配置

1
2
3
4
5
6
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/distributed
username: root
password: smallming

4、新建实体类

新建com.bjsxt.pojo.Users

1
2
3
4
5
6
@Data
public class Users {
private Long id;
private String username;
private String password;
}

5、新建配置类

新建com.bjsxt.config.SecurityConfig

1
2
3
4
5
6
7
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}

6、新建Mapper

新建com.bjsxt.mapper.UsersMapper

1
2
3
4
5
@Mapper
public interface UsersMapper {
@Select("select * from users where username=#{username}")
Users selectByUsername(String username);
}

7、修改自定义service逻辑

修改com.bjsxt.service.UsersServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class UsersServiceImpl implements UserDetailsService {
@Autowired
private UsersMapper usersMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Users users = usersMapper.selectByUsername(username);
if(users==null){
throw new UsernameNotFoundException("用户名不存在");
}
return new User(username,users.getPassword(), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}

8、新建启动器

新建com.bjsxt.SecurityApplication

1
2
3
4
5
6
@SpringBootApplication
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class,args);
}
}

七、自定义登录页面

虽然Spring Security给我们提供了登录页面,但是对于实际项目中,大多喜欢使用自己的登录页面。所以Spring Security中不仅仅提供了登录页面,还支持用户自定义登录页面。实现过程也比较简单,只需要修改配置类即可。

说明:在上面代码基础上进行修改

1、修改pom

添加thymeleaf的依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

2、编写登录页面

在resources下新建templates文件夹。其中fail.html和success.html都只有一句话,分别是:“登录失败”和“登录成功”

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>内容</title>
</head>
<body>
<form action="/login" method="post">
<input type="text" name="username"/>
<input type="password" name="password"/>
<input type="submit" value="提交"/>
</form>

</body>
</html>

3、修改配置类

修改配置类中主要是设置哪个页面是登录页面。配置类需要继承WebSecurityConfigurerAdapter,并重写configure方法。

  • successForwardUrl():登录成功后跳转地址
  • loginPage():登录页面
  • loginProcessingUrl:登录页面表单提交地址,此地址可以不真实存在。
  • antMatchers():匹配内容
  • permitAll():允许

**注意:**configure方法中除了FailureForwardUrl()以外其他配置都是必须写的配置

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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置认证
http.formLogin()
// 哪个URL为登录页面
.loginPage("/")
// 当发现什么URL时执行登录逻辑
.loginProcessingUrl("/login")
// 成功后跳转到哪里
.successForwardUrl("/success")
// 失败后跳转到哪里
.failureForwardUrl("/fail");

// 设置URL的授权问题
// 多个条件取交集
http.authorizeRequests()
// 匹配 / 控制器 permitAll() 不需要被认证就可以访问
.antMatchers("/").permitAll()
// anyRequest() 所有请求 authenticated() 必须被认证
.anyRequest().authenticated();

// 关闭csrf
http.csrf().disable();
}

@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}

4、编写控制器

新建com.bjsxt.controller.UserController。

三个方法都是只有显示页面的功能。因为Thymeleaf页面必须通过控制器显示。如果示例代码是拿纯HTMl静态页面演示,是不需要写这些控制器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class UserController {
@RequestMapping("/")
public String showLogin(){
return "login";
}
@RequestMapping("/success")
public String success(){
return "success";
}
@RequestMapping("/fail")
public String fail(){
return "fail";
}
}

1 测试效果

在浏览器输入:http://localhost:8080显示登录页面。输入:账号:张三,密码:zs后会显示“登录成功”

img

八、认证过程其他常用配置

1、设置请求账户和密码的参数名

1.1 源码简介

当进行登录时会执行UsernamePasswordAuthenticationFilter过滤器。

  • usernamePasrameter:账户参数名
  • passwordParameter:密码参数名
  • postOnly=true:默认情况下只允许POST请求。

img

1.2 修改配置

1
2
3
4
5
6
7
8
// 表单认证
http.formLogin()
.loginProcessingUrl("/login") //当发现/login时认为是登录,需要执行UserDetailsServiceImpl
.successForwardUrl("/toMain") //此处是post请求
.failureForwardUrl("/fail") //登录失败跳转地址
.loginPage("/login.html")
.usernameParameter("myusername")
.passwordParameter("mypassword");

1.3 修改页面

1
2
3
4
5
<form action = "/login" method="post">
用户名:<input type="text" name="myusername"/><br/>
密码:<input type="password" name="mypassword"/><br/>
<input type="submit" value="登录"/>
</form>

2、登录成功三种配置方式

2.1 转发源码分析

使用successForwardUrl()时表示成功后转发请求到地址。内部是通过successHandler()方法进行控制成功后交给哪个类进行处理。

img

orwardAuthenticationSuccessHandler内部就是最简单的请求转发。由于是请求转发,当遇到需要跳转到站外或在前后端分离的项目中就无法使用了。

img

当需要控制登录成功后去做一些事情时,可以进行自定义认证成功控制器。

1.2 自定义成功逻辑

在配置类使用.successHandler自定义成功逻辑。

1
2
3
4
5
6
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
httpServletResponse.sendRedirect("http://www.bjsxt.com");
}
})

2.3 重定向方法支持

可以直接使用defaultSuccessUrl(); 可以进行重定向到特定页面.

1
.defaultSuccessUrl("/success123")

3、登录失败时三种配置方式

3.1 转发源码分析

failureForwardUrl()内部调用的是failureHandler()方法

img

ForwardAuthenticationFailureHandler中也是一个请求转发,并在request作用域中设置SPRING_SECURITY_LAST_EXCEPTION的key,内容为异常对象。

img

3.2 自定义登录失败逻辑

在配置类中使用.failureHandler设置

1
2
3
4
5
6
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.sendRedirect("http://www.smallming.com");
}
})

3.3 登录失败后重定向到指定地址

1
.failureUrl("http://www.smallming.com")

完整认证流程(包含自定义页面和自定义登录逻辑)

img

  1. 用户在浏览器中随意输入一个URL
  2. Spring Security 会判断当前是否已经被认证(登录)如果已经认证,正常访问URL。如果没有被认证跳转到loginPage()对应的URL中,显示登录页面。
  3. 用户输入用户名和密码点击登录按钮后,发送登录url
  4. 如果url和loginProcessingUrl()一样才执行登录流程。否则需要重新认证。
  5. 执行登录流程时首先被UsernamePasswordAuthenticationFilter进行过滤,取出用户名和密码,放入到容器中。根据usernameParameter和passwordParameter进行取用户名和密码,如果没有配置这两个方法,默认为请求参数名username和password
  6. 执行自定义登录逻辑UserDetailsService的实现类。判断用户名是否存在和数据库中,如果不存在,直接抛出UsernameNotFoundException。如果用户名存在,把从数据库中查询出来的密码通过org.springframework.security.core.userdetails.User传递给Spring Security。Spring Security根据容器中配置的Password encoder示例把客户端传递过来的密码和数据库传递过来的密码进行匹配。如果匹配成功表示认证成功。
  7. 如果登录成功,跳转到successForwardUrl(转发)/successHandler(自己控制跳转方式)/defaultSuccessUrl(重定向)配置的URL
  8. 如果登录失败,跳转到failureForwardUrl/failureHandler/failureUrl

SpringSecurity 请求间共享认证信息(源码剖析)

SpringSecurity 请求间共享认证信息

对应教程视频:39-尚硅谷-SpringSecurity-源码剖析-认证信息共享详解_哔哩哔哩_bilibili

一般认证成功后的用户信息是通过 Session 在多个请求之间共享,那么 Spring Security 中是如何实现将已认证的用户信息对象 Authentication 与 Session 绑定的进行具体分析

image-20221111211841097

⚫ 在前面讲解认证成功的处理方法 successfulAuthentication() 时,有以下代码:

image-20221111211945405

⚫ 查 看 SecurityContext 接 口 及 其 实 现 类 SecurityContextImpl , 该 类 其 实 就 是 对Authentication 的封装:

⚫ 查 看 SecurityContextHolder 类 , 该 类 其 实 是 对 ThreadLocal 的 封 装 , 存 储SecurityContext 对象:

image-20221111212010159

image-20221111212020024

image-20221111212029106

image-20221111212041020

SecurityContextPersistenceFilter 过滤器

前面提到过,在 UsernamePasswordAuthenticationFilter 过滤器认证成功之后,会在认证成功的处理方法中将已认证的用户信息对象 Authentication 封装进

SecurityContext,并存入 SecurityContextHolder。之后,响应会通过 SecurityContextPersistenceFilter 过滤器,该过滤器的位置在所有过滤器的最前面,请求到来先进它,响应返回最后一个通过它,所以在该过滤器中处理已认证的用户信息对象 Authentication 与 Session 绑定。

认证成功的响应通过 SecurityContextPersistenceFilter 过滤器时,会从SecurityContextHolder 中取出封装了已认证用户信息对象 Authentication 的SecurityContext,放进 Session 中。当请求再次到来时,请求首先经过该过滤器,该过滤器会判断当前请求的 Session 是否存有 SecurityContext 对象,如果有则将该对象取出再次放入 SecurityContextHolder 中,之后该请求所在的线程获得认证用户信息,后续的资源访问不需要进行身份认证;当响应再次返回时,该过滤器同样从 SecurityContextHolder 取出SecurityContext 对象,放入 Session 中。具体源码如下:

image-20221111212148379

image-20221111212156838