前言
这都是作者准备实习和秋招的自用八股,持续更新中,欢迎大家点赞收藏,有疑问和问题都欢迎在评论区探讨
目录
11.undo log 和 redo log 和 bin log
5.ConcurrentHashMap和HashMap的区别
19.String、StringBuffer、StringBuilder
一.Redis篇
1.缓存穿透
产生原因:查询一个不存在的数据,Redis里面没有,就去查数据库,数据库里也没有,因此也无法写回到Redis中,导致这个不存在的数据的请求每次都会访问数据库,如果有人恶意高强度访问,就会对数据库造成压力
解决办法:通常我们有两种解决办法
1.返回空值缓存,也就是当数据库没有查询到数据的时候,在Redis中保存一个key,其value为空,这样再次请求的时候,直接访问Redis,返回空值,就不会对数据库造成影响。优点是实现简单,缺点是会创建多余key,内存消耗大并且对一致性有影响
2.我们更多的是采用布隆过滤器的方式,通过Redission实现,在客户端和redis之间,设置一个过滤器,这个过滤器会初步判断数据是否存在,存在再访问redis和数据库,不存在直接返回。
布隆过滤器的底层逻辑是hash算法和一个较大的数组,数组里每个元素都是二进制的0或1,最开始都初始化为0,我们可以对数据库和redis中本来就存在的数据,进行一次预热,例如userid/1,这个1经过三次hash计算,模数组的长度得到数组下标,把元素由0改成1,这样三个数组下标就对应一个数据key存在,查找也是这个过程
但是布隆过滤器存在误判,例如userid1经过3次计算为137,userid2为248,userid3不存在,但是经过计算为124,那么就会被误判为存在,这个误判是无法完美解决的,只能通过提高数组长度来降低误判率,我们通常在设置布隆过滤器的时候都可以设置误判率,一般为百分之5,就可以让绝大部分项目接受了
2.缓存击穿
产生原因:缓存击穿也被称为热点key问题,一般是指某个热点key,也就是经常被高强度访问的key失效的时候,在还没有来得及重建缓存这个期间,被高强度并发访问,导致数据库收到极大的压力
解决办法:
通常有两种解决办法,分别是互斥锁和逻辑过期,应对不同的业务进行不同的选择。例如在我的项目中,我是对热点评的商铺访问进行了预防缓存击穿的处理,因为是商铺,即使商家后台修改了商铺数据,对一致性的要求并不是那么高,因此我采用的方式是逻辑过期。
互斥锁的原理是,在线程访问redis缓存失败的时候,获取一个互斥锁,这个互斥锁底层通过redis的set nx 实现,如果获取锁成功,那么开始重建缓存,而其他线程如果获取锁失败,那么就等待并重试获取redis缓存。一直等到第一个获取锁的线程重建完毕redis,才能释放锁,其他线程才可以正常访问运行。优点是保证了数据库和redis的一致性,缺点是造成了阻塞,性能较差
逻辑过期的原理是,在项目启动前,对redis进行一次预热,把一些热点数据的过期时间设置为-1也就是永不过期,但是可以人工添加一个逻辑过期时间,每次请求访问redis缓存时,都需要去判断这个逻辑时间是否过期,如果过期,同样是去获取一个互斥锁,但是区别在于,线程1获取锁成功之后,是单独去开辟一个新的子线程完成缓存重建,而自己本身以及其他在缓存重建完毕之前访问的线程,在缓存重建更新好之前,都返回旧数据即可。这样的好处是,不会造成堵塞,属于高性能的解决方案,缺点是在一致性上有缺陷,会获取到脏数据
补充:另外,以上解决方案都是针对未上线的项目,也就是对击穿做出的预防,针对上线项目,如果已经出现击穿,不能直接修改代码的情况,处理也应该有所变化:
1.首先可以直接拉黑造成击穿的SQL语句,防止数据库继续被持续施压
2.其次也可以下架缓存失效的商品或者店铺,从应用层解决问题
3.缓存无法重建,那么可以手动mock缓存
4.下线产品项目,修复bug之后重新上线
3.缓存雪崩
产生原因:缓存雪崩主要是指,同一时间大量key同时失效,或者redis宕机,导致大量请求访问数据库造成数据库压力增大崩溃。
解决办法:
针对大量key同时失效这个原因,其实对所有key的过期时间加一个1-5分钟的随机时间就可以解决
针对redis宕机,有多种解决方式:
1.利用redis集群去提高可用性
2.使用降级限流策略,这种策略可以作为缓存穿透、击穿、雪崩三种情况的保底策略
3.添加多级缓存
4.双写一致性(数据库和redis数据一致性)
产生原因:更新数据库的时候,redis数据库如果没有更新,那么在redis数据超时过期之前,用户访问到的数据都是过期脏数据
解决方式:
在我的项目里,最开始采用的是延迟双删的策略,也就是更新数据库、删除缓存,然后等一段时间的延时之后,再次删除缓存的方法,来解决数据库和redis数据一致性的问题。不管是先更新数据库再删除缓存还是顺序颠倒,都会存在高并发场景下,延时期间读到脏数据的可能性,再次删除缓存也是为了避免后续读到脏数据,但是这个延时时间是不好把握的,因此我通过学习了解了其他解决方案
第一种方案也就是我认为项目中更常用的,高性能方案:采用异步通知的方式,即修改数据库之后,把消息传递给中间件,然后让中间件去通知缓存删除。例如使用MQ中间件,但是就牵扯到保证MQ中间件的可靠性问题。因此就还有另一个方案,采用阿里的canal组件实现数据一致,canal服务部署后会伪装成一个mysql的子节点,数据库更新后,canal会去读取binlog文件数据,这个文件主要是存放ddl和dml语句的记录,canal会读取这些修改数据库的语句记录,然后去更新缓存
第二种方案是针对需要高一致性项目的方案,通过Redission提供的读写锁来实现,读写锁底层也是互斥锁,通过set nx实现。在用户读数据时,添加共享锁,实现读读共享,读写互斥。在用户写操作也就是要修改数据库的时候,添加互斥锁,实现读读、读写都互斥。需要注意的是,读操作和写操作获取的锁应该是同一个
5.redis数据持久化
redis持久化有两种方式,一种是RDB,一种是AOF
RDB,全称Redis Database Backup File 即Redis数据备份文件
原理是把内存的数据,写到磁盘上,一般来说,在配置文件中都配置了自动备份,备份的命令为save和bgsave,一个是在主进程备份,一个是在子进程备份,一般自动备份都使用的是bgsave,原理是,把主进程的页表fork到子进程,然后让子进程,通过页表的虚拟地址映射到物理地址,把内存数据备份到硬盘上,需要注意的是,fork的时候采用copy on write技术,即fork的时候,主进程可以读内存数据,如果要写数据,就需要先拷贝一份数据,再修改拷贝后的数据,以防子进程和主进程产生冲突
AOF,全程append only file 即追加文件
原理类似命令日志,就是把Redis的每一条命令都记录在AOF,AOF默认是关闭的,需要在redis.conf配置文件中开启,同时在配置文件中我们可以设置记录的频率,也就是刷盘策略,(always、everysec、no)可以每一条都记录,也可以间隔1s,也可以让操作系统自己决定,常用的是everysec,也就是间隔1s记录一次
因为是记录命令,所以存在对多个key多次写的操作,但是最后一次写的数据才有保存意义,因此可以让AOF执行bgrewriteaof命令执行重写功能,redis会在触发阈值的时候自动重写,可以在配置文件配置阈值
重写的底层机制,其实是生成一个新的AOF文件去代替旧的AOF文件,但是新的AOF文件不需要根据旧的AOF创建,底层原理是去读取当前redis库里面值,然后用一条命令来记录这个值,比如之前有sadd animals “cat” sadd animals “dog”两条命令,就直接用sadd animals “cat” “dog”这一条命令来保存数据。这就是重写的底层原理。但是这样生成新的AOF文件需要进行大量的写入操作,redis又是单线程的,为了避免阻塞,采用的是后台子进程重写,为了避免子进程重写的时候,主进程写入新输入造成数据不一致,redis设置了一个AOF重写缓冲区,开启子进程之后,主进程的所有写命令都会记录到AOF重写缓冲区,在新AOF重写完毕后,会把AOF重写缓冲区的内容加进去,然后对新AOF改名覆盖旧AOF
综上,两种方式各有优劣,一般项目中我认为应该结合两者一起使用,RDB是二进制文件,因此体积小,宕机恢复速度快,但是数据完整性较差,AOF因为记录指令较多,体积较大,恢复时间慢,但是胜在书记保存完整。前者可以追求更快启动速度,后者对数据安全性更有保障
6.数据删除策略
两种删除策略
惰性删除:key过期之后不去管他,只有在再次访问的时候,发现过期了才会删掉
优点是对CPU友好,只有使用key的时候才需要检查。缺点是对内存不友好,过期之后还在占用内存
定期删除:每隔一段时间,就对一些key进行检查,删除里面过期的key
定期清理有两种模式
SLOW模式:定时任务,频率为10hz,每次不超过25ms
FLST模式:频率不固定,间隔不低于2ms,每次不超过1ms
优点是控制频率和持续时间,尽可能降低对主进程的影响,并且可以清理掉一直没访问的过期key,缺点是对定期的频率和持续时间不好把握
Redis一般把两种删除策略结合使用
7.数据淘汰策略
数据淘汰与数据删除不同,数据删除是数据过期后需要从内存中清理到
数据淘汰指的是,内存满了之后,要清理掉哪些内存来释放内存空间
有八种数据淘汰策略
1.noeviction:即内存满了禁止写入新数据,这是默认的淘汰策略
2.volatile-ttl:根据设置了ttl的key,优先淘汰ttl小的
3.allkeys-random:对全体key随机淘汰
4.volatile-random:对有ttl的key随机淘汰
5.allkeys-lru:对全体key,根据lru算法淘汰
6.volatile-lru
7.allkeys-lfu:对全体key,根据lfu算法淘汰
8.volatile-lfu
LRU:最近最少使用,距离上一次使用的时间越长越先淘汰
LFU:最少频率使用优先淘汰
平时用的较多的是allkeys-lru策略:保留热点访问的key,删除长时间没有访问的key
内存满了之后,会根据不同的内存淘汰策略进行处理,如果是默认的noeviction策略,那么内存满了就会报错
8.Redis分布式锁使用场景
在我的项目中,运用到分布式锁的关键功能就是优惠卷秒杀,其中为了解决一人一单问题以及超卖问题,就需要用到分布式锁。其中超卖问题可以通过乐观锁解决,但是乐观锁需要修改sql语句或者业务逻辑,影响效率。而添加jvm自带的synchronized锁,在集群模式下又无法保证线程安全问题,因此需要使用到分布式锁
这里详细介绍一下为什么集群模式下会出现问题,因为集群模式下,每个服务器都有自己的jvm,而jvm的锁是单独的,但是数据库是共享的,synchronized这个锁只能对当前服务器的线程有效,还是会出现超卖和一人多单的问题。因此我们需要一个可以在多个服务器集群环境下共享的锁,也就是分布式锁
而分布式锁的关键特点就是:多进程可见的互斥锁
9.Redis分布式锁实现原理
基于Redis中的set nx命令实现
setnx就是set if not exist,如果不存在,则set
获取锁我们往往是
set lock value nx ex 10
需要nx ex一起设置,保证互斥和超时两个功能的原子性,因为这样可以避免,设置锁之后,如果Redis服务宕机导致锁无法自动释放
释放锁就直接del key即可
在我的项目中使用Redission来实现分布式锁,底层是使用了setnx和lua脚本
Redission实现的分布式锁解决了几个痛点问题
1.解决不可重入的问题
底层逻辑是,通过Redis的hash结构的HEXISTS命令,记录锁的线程标识信息和重入次数,在重入时判断是否为当前线程,释放时,把计数-1,如果计数为0,那么就释放锁,如果不为0,表示当前线程还有其他业务在持有锁
2.解决超时释放问题,因为对锁的延迟释放时间不好把握,Redission就提供了看门狗机制,一个线程获取锁之后,会利用看门狗给当前锁续期,默认释放时间是30s,而每隔10s就会重置一次释放时间,在释放锁、或者发生异常之后,才会将看门狗给撤销掉
3.主从一致性问题
可以解决,但是效率太低了,解决逻辑就是加红锁,不在一个Redis实例上创建锁,而是在多个Redis上加锁。可以利用zookeeper来解决
10.Redis解决集群模式下session问题
在集群模式下,会有多个tomcat,每个tomcat上都有不同的session,而用户在这个服务器上登录了,把验证信息保存到了session中,在另一个服务器上获取不到session,就会出现问题。因此需要有一个多个服务器可以共享的工具,也就是Redis。我们利用随机值创建一个token作为key,然后把用户信息作为value存储到Redis,token返回给前端代替cookie,之后所有服务器都可以通过token,去Redis获取用户信息作为验证了
11.主从模式
我项目中主要使用的是主从(1主1从)+哨兵的集群方案
单节点的Redis并发能力是有上限的,为了进一步提高Redis的并发能力,可以搭配主从集群,实现读写分离
主节点负责写数据,从节点负责读数据,主节点写入数据后,把数据同步到从节点中
主从同步数据的流程:
分为两个阶段,一个是全量同步,一个是增量同步
全量同步指的是从节点第一次与主节点建立连接的时候,使用全量同步
1.从节点请求主节点同步数据,从节点会携带自己的replication_id和offset偏移量
2.主节点判断是否是第一次连接,就是看从节点的replication_id是否和主节点一致,如果不同,就是第一次连接,主节点就把自己的replication_id和offset发送给从节点,让主从信息保持一致
3.同时主节点会执行bgsave命令,生成RDB文件,发送给从节点,从节点清空自身数据之后,执行RDB文件,进行数据的复制,这样主从的数据就一致了
4.如果在生成执行RDB文件的时候,主节点又收到了请求命令,那么会把命令记录到缓冲区的repl_baklog日志文件中,最后再把这个日志文件发送给从节点,用来同步数据
5.增量同步指的是,从节点slave重启,或者数据变化后从节点会请求主节点同步数据,主节点判断是否为第一次连接,发现replication id一致,就获取从节点的offset值,然后从repl_baklog日志文件中获取从节点offset之后的命令数据,发送给从节点进行数据同步
12.哨兵模式
哨兵:Sentinel
作用:监控master和slave是否正常工作,如果master故障,sentinel哨兵会将一个slave升级为master,之前的master恢复后,也以升级后的master为主。同时,在Redis集群发生故障时,哨兵会把最新的信息(新的主节点)通知给客户端
原理:每隔1s,哨兵都会向集群的各个主节点发送ping命令,接收各个节点的响应
主观下线:一个哨兵发现某实例未在规定时间内响应,就认为该实例主观下线
客观下线:超过指定数量quorum的哨兵发现某实例主观下线,就是客观下线,quorum值一般为哨兵实例数量的一半
当一个主节点被认定客观下线,就会根据选主规则确定新的主节点
-
判断主从断开时间长短,断开时间超过阈值就排除
-
判断从节点的slave-priority值,越小优先级越高
-
如果slave-priority值一样,判断offset,越大优先级越高,越大就表示和主节点的数据差越少
-
如果offset也一样,就看slave的id,id越小优先级越高
脑裂问题:简单来说就是因为网络问题,主节点依旧存活,但是没有响应哨兵,被哨兵判断为下线了,然后哨兵推选了一个新的master节点,两个master节点同时存在,但是客户端依旧在往老的master写数据,同时,老的master也没有连接从节点,导致大量数据丢失的问题
解决方案:第一可以设置最少的slave节点个数,比如设置为1,意思是客户端在往主节点写数据的时候,必须保证主节点有1个从节点
第二是设置主从数据同步的时间,避免大量数据丢失
13.redis为什么快
1.纯内存操作,数据存放在内存中,处理速度自然快
2.非阻塞IO:redis采用epoll作为IO多路复用技术的实现
3.单线程实现:避免线程切换和竞争锁资源产生的开销
二.数据库篇
1.如何定位慢查询
慢查询就是在项目测试中,发现某个接口响应时间很慢,超过了2s以上,这个时候就需要去检查,是哪个sql出现了问题,所谓慢查询就是查询哪个sql语句执行缓慢
方法一:使用运维工具
例如skywalking,在系统中部署运维工具,就可以通过工具展示的报表中,查找到哪个接口比较慢,然后可以看到每条Sql语句的执行时间,从而定位慢查询
方法二:开启慢日志查询
如果项目中没有部署运维工具,可以开启MySQL自带的慢日志查询,在MySQL的配置文件中可以开启这个功能,并且设置Sql语句超过多少时间,就记录到日志,一般设置为2s,这样运行时间超过2s的Sql语句就会记录到日志里面,我们只需要查看日志就可以定位执行较慢的Sql语句了
2.如何分析执行慢的sql语句
采用MySQL自带的分析工具:explain
在需要分析的sql语句前添加explain,然后分析给出表的详细信息
通过查看key_len和key字段检查是否命中了索引
通过type字段查看sql是否有进一步优化空间,如果存在全索引扫描index或者全盘扫描all,就要对sql进行优化
通过extra建议判断,是否出现回表情况,如果出现,尝试添加索引或修改返回字段
另外,我们也可以通过AI模型,将sql语句和explain给出的结果,去询问AI,给出优化建议
3.索引和索引的底层数据结构
索引index就是帮助MySQL高效获取数据的一种有序的数据结构
数据库除了维护数据之外,还维护着满足特定查找算法的数据结构,其中MySQL使用的是B+树
索引可以提高数据检索的效率,降低数据的IO成本和CPU消耗
底层数据结构:B+树
B+树,是由二叉查找树、平衡二叉树、B树,这样发展而来。我们知道,MySQL的数据是保存在硬盘上的,读取数据的速度相比redis这种内存io的数据库来讲,要慢上千甚至上万倍。因此,我们需要尽可能减少从磁盘中读取数据的次数,也就是减少磁盘io次数。
磁盘中,读取都是按照磁盘块来读取的,如果使用二叉树这种结构,一个节点对应一个磁盘块,一个磁盘块就一份数据,数的高度极高,磁盘io量极大,显然是效率极低的,因此,有了B树。
B树里,每个节点称之为页,也就对应一个磁盘块,每个页里存储了更多的键值对,并且有了更多的阶,或者说子节点,导致整个树高度更低,宽度更大,磁盘io的次数也就少了很多,效率就提高了
B+树就是在B树的基础上,优化而来。
1.B+树的非叶子节点上,不再保存数据,而是只存储键值和指针。这样的好处在于,数据库页的大小是固定的,InnoDB存储引擎页的大小默认是16kb,不用存储数据,只存储键值和指针,意味着相对B树,页可以存储更多的键值,相应的,阶数就会变得更大,树就会更胖更矮。磁盘io的次数就会更少,性能就会更高。实际场景中,假设一个聚集索引,主键为bigint 8字节,指针一般6字节,那么一个非叶子节点就可以存储16kb/14字节 ≈ 1170个键值,叶子节点假设一条数据1kb,可以保存16条数据,那么3层B+树就可以保存1170 x 1170 x 16 约等于两千多万条数据。而查找这两千多w条数据,只需要2次io,因为根节点一般常驻内存
2.B+树的数据,是根据键值进行排序的,并且叶子节点通过双向链表进行连接,每页中的数据,通过单向链表进行连接。这样做的好处是,在我们进行范围查找、排序查找、分组查找等操作时,效率很高。
4.什么是回表查询(聚簇索引和二级索引)
谈到回表查询就必须先引出两个概念
聚簇索引:也叫聚集索引,指的是数据和索引放在一起,B+树叶子节点保存的是整行数据,聚集索引有且只有一个,一般都是放在主键上,必须有且只能有一个
如果存在主键,那么主键索引就是聚集索引,如果没有,那么第一个唯一索引就是聚集索引,如果都没有,那么InnoDB会生成一个rowid作为隐藏的聚集索引
二级索引:也叫非聚簇索引,指的是数据没有和索引放一起,B+树叶子结点保存的是数据所在行对应的主键,可以有多个,一般程序员自己添加的索引都是二级索引
介绍完两个概念,就可以介绍什么是回表查询了
这里模拟一张t_user数据表,字段有id,name,age,其中id为主键,带聚集索引,name我也添加了二级索引
那么在执行select * from t_user where id = 1这个语句的时候,因为是查询所有,而且where条件是id,就直接查聚集索引,返回整行数据
如果执行的是select * from t_user where name = ‘jack’ ,那么就会去name字段添加的二级索引查询,但由于查询的是*,就需要通过二级索引中叶子节点保存到主键值,再一次到聚集索引中查找整行数据,这个过程就是回表
简单来说就是:通过二级索引,查询到的数据不完全包含在索引中,就需要通过索引的主键值再次查询聚集索引,获得整行数据的过程
如何避免回表查询:可以使用联合索引,也就是把需要查的数据都包含在一个索引中。或者在查询的时候,修改返回字段为索引包含的字段,避免去回表查询
5.覆盖索引和超大分页问题
什么是覆盖索引:
指的是,使用索引查询的时候,返回的列必须在索引中能全部找到
例如id为主键,通过主键索引查询,其实就是聚集索引,查到的就是整行数据,性能高
如果是给name添加了二级索引,那么这个索引中能找到的列就是name以及B+树叶子节点保存的主键id,此时如果返回的列中包含了其他列,不存在于索引之中,就会引发回表查询,也就不是覆盖索引了
因此回表查询也可以成为:当这个二级索引不是覆盖索引的时候,就会产生回表查询
超大分页问题:数据量较大的时候,需要通过limit分页查询,需要对数据进行排序,效率很低
解决方案:子查询优化/inner join关联查询
先通过覆盖索引找到符合条件的id,再使用聚集索引查询数据,效率就更高
或者通过缓存分页数据进行优化
超大分页出现的原因:
简单来说就是limit 100000,1这条语句,需要查询100000+1条记录,然后丢到前100000条,返回最后1条,出现这种问题的根本原因是mysql的底层分为server层和存储引擎层,存储引擎查到第1条记录传给server层的时候,才会注意到有limit这个语句,此时server层里面会记录一个limit_count,拿到第1条记录时limit_count=1,就会要求存储引擎去查第2条、第3条、、、一直到100000条,而每次查询都会是回表查询,效率极低,server就会判断回表次数太多,不适合用索引,直接走全表扫描了,索引就会失效
解决方式其实就是通过关联查询或者子查询,避免回表查询,让server层走索引。
6.创建索引的原则
结合自己的项目
1.数据量较大,查询较为频繁的表,需要添加索引,例如点评项目中的用户表,不管是登录还是点赞关注,都会进行查询,因此这种表是适合添加索引的
2.添加索引一般添加在经常作为查询条件、排序条件、分组条件的字段上
3.尽量选择区分度高的字段创建索引,尽量创建唯一索引
4.尽量使用联合索引,这样就减少避免回表查询
5.要控制索引的数量,索引不是越多越好,虽然方便了查数据,但是会提高增删改数据的成本
7.索引失效的情况
1.违反最左前缀法则
指的是使用联合索引的时候,如果没有从最左前列开始或者跳过了中间的列查询,就有可能导致索引失效
但是现代的MySQL都有优化器,可以重新排列顺序,因此,只要查询条件中包含了联合索引内所有的索引,都不会影响索引使用
2.范围查询导致右边的列索引失效
同样是在联合索引里面,如果左边的列或者中间的列进行了范围查询,会导致右边列索引失效
3.不能再索引列上进行运算,也会导致失效
4.字符串没加单引号,类型转化导致的索引失效
5.以%开头的模糊查询导致索引失效
前三个较常见
补充:联合索引的底层结构:
按索引的顺序,从左往右,先根据第一个索引列排序,第一个索引列值相同,再根据第二个,以此类推
因此,也就可以说明,违反最左前缀法则,会导致索引失效,因为都是从最左列开始,依次往右对字段进行索引
通过也可以看出,范围查询之后,左边的列虽然有序,但是右边的列就会存在不有序的情况,无法继续使用索引
8.sql优化的经验
可以从5个方面进行优化
1.表的设计优化
可以参考阿里的开发手册,里面对表的设计做了很多规范,例如经常用到的就是对数据类型的选择
2.索引的优化
参考创建索引的原则
3.sql语句的优化
避免直接使用select * 容易导致回表查询
sql语句避免索引失效
union all 代替union,少一次过滤,效率更高
join优化,尽量使用内连接,外连接要以小表为驱动
4.主从复制,读写分离
5.分库分表
9.事务的特性ACID
可以结合具体场景:例如银行转账
事务:是一组操作的集合,是一个不可再分的工作单位,事务会把一系列操作作为一个整体向系统提交
事务的特性:ACID
A:原子性,即事务是不可再分的工作单位,必须全部成功或全部失败
银行转账业务中,a转账给b,不能a少了钱b没有多钱,只能a少b多或者a不少b不多
C:一致性,即事务完成过程中,所有数据必须保持一致
即转账业务中,a少了1000,b也只能多1000,数据不能发生改动
I:隔离性,数据库提供了隔离机制,保证事务不受外界并发操作的影响
即a向b转钱,不能受到其他事务,例如a同时跟c转钱的干扰
D:持久性,事务一旦提交或者回滚,对数据库中的数据修改是永久性的,也就是会把结果保存在磁盘中,进行落盘操作
10.并发事务问题和隔离机制
并发事务问题有三种:
1.脏读:指的是事务A读取到了事务B还没有提交的数据
例如事务B有一个update操作,但是事务还没提交,事务A这边select就读取到了
2.不可重复读:指的是事务A先后读取同一条数据,但是这条数据期间被事务B修改后提交事务,读取到的不一样
这里要注意,事务B必须提交事务,然后事务A重复读取到不一样才是不可重复读
如果B没有提交事务,事务A重复读取到不一样本质是脏读问题
3.幻读:指的是事务并发过程中,事务A查询了数据之后,事务B插入或者修改了数据,事务A第二次查询数据时发现数据不一致
假设事务A查询数据1没有查询到,想要插入数据,但是并发的事务B已经插入数据了,事务A又发现数据已经存在插入不了,但是重复查询都查询不到,这就是幻读
三种并非事务问题也对应了四种隔离机制:
1.读未提交:存在脏读、不可重复读、幻读三种问题
2.读已提交:解决了脏读,但还存在不可重复读,幻读问题
3.可重复读:存在幻读问题
4.串行化:解决并发问题,但是效率很低,不再是并发事务
其中,默认的也是使用较多的是可重复读
解决幻读问题:
快照读的时候,通过mvcc解决幻读问题,当前读的时候,通过间隙锁来解决幻读问题,意思对查询范围内的间隙进行锁定,让其他事务无法在查询范围内添加新的数据,避免幻读
11.undo log 和 redo log 和 bin log
分工不同
undolog的主要作用是用作事务回滚以及mvcc当中保存数据的版本链。
redolog用于保证事务数据能及时保存到硬盘当中,主要作用是保障事务的持久性。
binlog主要是记录写入数据库的ddl和dml语句,用作数据库宕机崩溃后的数据恢复,同时也用于主从库的数据拷贝。同时,redolog是innodb引擎特有的,而Binlog是MySQL所持有的,与引擎无关
三者的执行流程
整个执行流程是,在事务开启时,undolog会保存数据的历史版本,用于事务回滚。然后数据库写入数据,会先写到buffer pool的脏页当中。再将脏页的数据刷新到磁盘之前,会先把数据写入redolog buffer中,然后通过顺序io刷新到磁盘的redolog file当中,此时将redolog的状态改为prepare,告诉Binlog可以工作了,然后server层会更新Binlog,Binlog更新完成之后,事务才算commit,并将redolog状态改为commit
之所以要将redolog分为两个状态,是需要保证redolog和binlog数据的一致性,如果redolog写失败,事务会回滚,不写binlog。如果redolog成功,binlog失败,也会回滚。这样可以保证主从库数据同步是一致的
12.事务隔离性如何保证(MVCC)
事务隔离性主要是通过MVCC保证的
MVCC:指的是MySQL中的多版本并发控制,指维护数据在并发场景下的多个版本,使多个事务下读写操作没有冲突
MVCC的具体实现主要依赖于数据库记录中的隐藏字段、undo log日志、readview
隐藏字段:
1.trx_id:也就是记录每个事务的id,是自增的
2.roll_pointer:回滚指针,指向当前记录的上一个版本地址,用于配合undo log
undo log:
1.保存逆向语句,用于回滚事务
2.保存版本链:多个事务并发操作一条记录的时候,记录不同事务修改数据的版本,通过roll_pointer回滚指针形成一张链表
readview:
作为MVCC读取数据的依据,readview里面保存了几条属性,并提供了可见性条件判断
结合事务的id和版本链,可以决定当前事务快照读获取的是哪个版本的数据
需要注意,不同隔离级别的快照读是不一样的
1.RC:读已提交,每次执行快照读都会生成一次readview
2.RR:仅在当前事务第一次执行快照读时生成readview
13.主从同步的原理
-
主库在事务提交时记录数据变更到Binlog。
-
从库读取主库的Binlog并写入中继日志(Relay Log)。
-
从库重做中继日志中的事件,反映到自己的数据中。
三.框架篇
1.Spring里的bean是否线程安全
Spring里面有个注解@Scope,默认值是singleton,因此Spring的bean默认条件下是单例的
但是具有线程安全问题
在多并发环境下,多个用户去访问一个服务,容器会给每一个请求分配一个线程,通常项目里的bean,例如Service类和DAO类的bean,是不可变的,因此不存在线程安全问题,但是如果在bean中定义了可以修改的成员变量,那么就需要考虑线程安全问题,可以通过设置多例或者加锁来保证线程安全
2.AOP
AOP:即面向切面编程,是spirng框架的关键,指的是那些与业务逻辑无关,但是对多个业务方法产生影响的公共行为,可以抽取为一个公共模块进行复用,降低耦合
spring的AOP的实现原理就是动态代理,动态代理又分为jdk动态代理和CGLIB动态代理
JDK动态代理:
AOP默认使用的代理模式,只能代理实现了接口的类。底层实现原理运用到了两个重要组件,一个是Proxy类中newProxyInstance()方法,另一个是InstanceHandler接口的实现类。
简单来说,整体流程就是,通过Proxy类的newProxyInstance方法创建代理对象,这个方法里会传入三个参数:委托类的加载器、委托类实现的接口、InstanceHandler实现类。通过代理对象去调用方法时,会转发给InvocationHandler的invoke方法进行调用,invoke方法中就可以添加代理逻辑,然后通过反射调用委托类的方法。
JDK动态代理生成代理对象的底层原理是通过实现委托类的接口,这也是其只能代理实现了接口的类的原因。
CGLIB动态代理:
底层是借助了ASM这个操作Java字节码的框架,通过字节码生成委托类的代理类,而这个代理类继承了委托类,只有在实例化创建代理对象时,会使用到反射。同时,代理类实现MethodInterceptor接口,重写intercept方法,在该方法中添加代理逻辑。
整体流程是,通过ASM操作字节码,生成代理类,将委托类作为其父类,并为父类的非final方法生成一个代理方法,这个代理方法会首先调用MethodInterceptor的intercept拦截器方法,在拦截器方法当中调用目标方法,并在其前后添加代理业务
相较于JDK动态代理,每次都要通过反射调用委托类的方法,CGLIB底层是通过FastClass机制来进行方法调用,CGLIB除了生成代理类之外,还会生成代理类的FastClass和目标类的FastClass,FastClass机制就是对类里面的方法,添加索引。调用方法就不再是通过反射机制调用,而是通过索引来调用响应方法
同时,因为代理类底层是通过继承实现,因此被final修饰的类和方法无法代理
两者对比:
JDK动态代理通过内置的API实现动态代理,创建对象快,但是调用方法需要频繁使用反射,性能较差
CGLIB动态代理,需要用到ASM框架操作字节码,创建对象较慢,但是大部分操作避免了反射的性能开销,性能更好的同时没有JDK动态代理只能代理实现接口类的限制
Spring中默认使用JDK,遇到没有实现接口的类才使用CGLIB,也可以自己配置强制使用CGLIB
项目中使用到的AOP:
1.Spring实现的事务
我们常用的是声明式事务,对业务实现的方法上加@Transaction注解即可,底层调用的还是AOP功能,对方法前后进行拦截,在执行方法前开启事务,执行方法后根据情况提交或者回滚事务
2.手动实现的切面记录日志
首先编写一个切面类,添加Aspect注解,然后通知以方法的方式进行配置,编写一个方法,然后添加通知类型的注解,比如前置通知Before,后置通知AfterReturning,还有环绕通知、异常通知、最终通知。在注解当中配置切点表达式,用于指定切面配置的具体方法。然后这个方法需要携带一个JoinPoint连接点参数,这个参数可以可以调用方法Signature(),获取目标方法的签名,这个Signature就可以干很多事,比如获取方法名、方法参数、请求类型等等,可以把这些都保存到日志当中。而环绕通知中,JoinPoint还有个方法proceed,表示执行目标方法。这就是自己定义一个切面类的过程
总的来说,AOP就是把一些重复但与业务逻辑无关的操作整合起来,方便各种业务方法进行复用,降低耦合度
3.事务失效的情况
1.异常捕获处理
在开启事务的方法中,一旦出现异常,需要抛出被事务检测到,才能进行回滚操作,如果自己在事务方法中添加了try catch进行处理异常,那么事务就捕捉不到异常,导致事务失效。
解决办法就是在catch代码块里面手动抛出一个新的异常让事物检测到即可
2.抛出检查异常
Spring默认只会回滚非检查异常,如果添加事务的方法里,出现了检查异常,例如读取文件不存在这种异常,即使抛出被事务检测到,也不会回滚,导致事务失效。
检查异常就是在编译时会进行检查,必须明确处理,通常与外部资源相关
非检查异常就是编译时不检查,可处理可不处理,通常与程序逻辑相关
解决办法就是在@Transaction注解里配置rollbackFor属性为全部类型异常Exception.class即可
3.非public方法导致事务失效
Spring开启事务需要给方法创建代理,再添加事务通知,前提条件是方法必须是public才行
解决办法就是给方法添加上public就行
4.bean的生命周期
-
通过BeanDefinition获取bean的定义信息
-
调用构造方法实例化bean
-
对bean进行依赖注入
-
处理Aware接口(如果bean实现了各种aware接口,需要实现其方法)
-
bean的后置处理器beanpostprocessor#befor 在初始化之前调用
-
bean的初始化方法(可自定义初始化,也可以实现接口重写方法)
-
bean的后置处理器beanpostprocessor#after 在初始化之后调用
-
销毁bean
其中bean的后置处理器beanpostprocessor需要实现这个接口然后重写方法
一般来说,Spring自带的一些AOP功能都使用到了后置处理器,一般都是在初始化之后调用
5.循环依赖
循环依赖也称之为循环引用,指的是两个或者两个以上的bean,互相持有对方,形成闭环的情况
会导致创建bean的时候,陷入循环,无法创建成功
Spring是允许循环依赖存在的,因为已经通过三级缓存解决了通过set注入的循环依赖情况
这里简单介绍下三级缓存
一级缓存:单例池,主要存放已经经历完整生命周期的,初始化完毕的bean对象
二级缓存:主要存放没有经历完整生命周期的bean对象
三级缓存:主要存放的是ObjectFactory也就是bean工厂,用来创建某个对象或者某个代理对象
具体流程是:
假设两个循环依赖的bean为A和B
A先实例化对象,然后A生成一个ObjectFactory对象到三级缓存
当A需要注入B的时候去实例化B
同时B也生成一个ObjectFactory对象到三级缓存
当B需要注入A的时候
先判断A这个bean是否被后置处理器增强,如果增强那么就需要生成一个A的代理对象
生成A的代理对象之后,先存入二级缓存,并把生成的对象注入B
B就创建完成,然后存入一级缓存
然后再将B给注入A的代理对象,A创建成功,存入一级缓存
二级缓存的主要作用是,保存ObjectFactory对象创建出来的bean对象,后续不需要再创建,直接从二级缓存里面获取
三级缓存只解决了通过set方法注入的循环依赖问题,如果是通过构造方法注入,就无法解决了
因为bean的生命周期中,构造函数是第一个执行的,三级缓存就无法解决了
解决方法是:在A的构造函数中,对依赖的B对象添加@Lazy注解,进行懒加载,意思是什么时候需要使用这个对象,再进行bean对象的创建。也就是说,B对象不在A实例化的时候注入,而是后续啥时候调用B,再创建B的对象
6.spirngmvc的执行流程
首先必须知道SpringMVC的主要组成部分
1.前端控制器DispatcherServlet
2.处理器映射器HandlerMapping
3.处理器适配器HandlerAdapter
4.视图解析器ViewResolver
由于当前的项目开发,大部分已经是前后端分离,还有一小部分老项目采取旧的前后端一体化的方式
因此springmvc的执行流程也分为两种情况
执行流程的前半部分是一样的
1.首先,前端发送请求,被前端控制器给接受到
2.然后前端控制器根据请求去调用处理器映射器handlermapping
3.handlermapping找到具体的处理器,把处理器对象和处理器的拦截器打包为处理器执行链返回给DispatcherServlet
4.然后前端控制器将handler传给处理器适配器handlerAdapter去请求执行handler
5.处理器适配器调用具体的处理器方法之后就产生分支
如果是前后端分离
那么处理器适配器调用具体的处理器方法上一般都带有ResponseBody注解
通过HttpMessageConverter消息转换器接口,把结果转换为JSON响应到前端
如果是一体化项目
处理器适配器会返回modleandview给DispatcherServlet,然后DispatcherServlet会调用视图解析器ViewResolver
视图解析器返回view对象给前端控制器,然后前端控制器去渲染视图,也就是html或者jsp文件,然后响应给前端
7.SpringBoot自动配置的原理
Springboot的引导类上有个@SpringBootApplication注解
这个注解去查看源码可以发现是对另外三个注解进行了封装
@SpringbootConfiguration
@ComponentScan
@EnableAutoConfiguration
其中EnableAutoConfiguration是Springboot自动配置化的核心注解,该注解通过@Import注解导入对应的配置选择器
底层原理是,读取了该项目和该项目引用的Jar包的classpath路径下的spring.factorise文件中所配置的类的全类名,配置类中所定义的bean会根据条件注解所指定的条件来选择是否放入spring的容器管理
条件判断会有像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有就加载该类,把该配置类的所有bean给添加到spring容器管理
8.常见的注解
1.spring
使用在类上用于实例化bean的注解:
@Component @Controller @Service @Repository
后面三个都是第一个的别名,只是为了更好区分,Controller用于接口层,Service用于业务层,Repository用于持久层
使用在字段上进行依赖注入的注解:
@Autowired
@Resource
一些有关切面类的注解:
@Aspect、@Before、@After、@Around、@PointCut
2.springmvc
@RequestMapping
用于映射请求路径,放在类上作为类里方法的父路径
@RequestBody和@ResponseBody
前者是将前端发送的json字符串转化为java对象接收
后者是将返回值通过消息转化器转化为json字符串响应给客户端
@RequestParam和@PathViriable
前者是传统post请求下指定请求参数的名称
后者是适用于RestFul模式下,获取请求路径的参数
@RestController
@ResponseBody+@Controller:即默认当前这个处理器类下的方法都返回json字符串
3.Springboot
@SpringbootApplication下的三个子注解
@ComponentScan
@SpringbootConfiguration
@EnableAutoConfiguration
9.Mybatis的执行流程
-
读取Mybatis的配置文件,加载运行环境和映射文件
-
创建会话工厂,全局一个,一般放在静态代码块里创建,类加载时执行
-
会话工厂创建SqlSession对象,包含了执行Sql语句的所有方法
-
操作数据库的接口Executor执行器
-
Executor执行器里面有一个MappedStatement对象,里面封装了映射信息
-
输入参数映射到MappedStatement
-
MappedStatement去与数据库交接
-
最后输出映射结果
10.IOC
ioc:控制反转,是一种思想,也是一种设计模式,实现的主要方式就是依赖注入
ioc其实我个人理解为就是工厂模式的升华,一种大工厂模式
ioc的容器,也就是我项目中使用的spring容器,就是一个大工厂,只不过相较于传统工厂模式,写死了生产的对象,如果要修改生产的对象,就需要去改代码,耦合度高。而ioc这种设计模式,就是把生产的对象信息给放到xml文件里面,利用反射机制,根据xml文件中的类定义生成对应的对象。
这种方式把工厂和具体生成的对象,给分离开了,降低了耦合度,灵活性更高,生产的具体对象,实在程序运行时才决定的,而不是像传统工厂模式那样,在工厂的代码里面就写好了。ioc利用到的最关键的技术就是反射+
四.集合篇
1.集合的类图
集合的顶级接口有两个
1.Collection单列集合
2.Map双列集合
Collection接口下,主要是List和Set集合,前者有序可重复,后者无序不可重复
其中List主要是ArrayList和LinkedList,ArrayList底层是数组,LinkedList底层是双向链表
Set主要是HashSet和TreeSet,底层分别是哈希表和红黑树
Map接口下,主要有HashMap、HashTable、ConcurrentMap、TreeMap
其中较常用的是HashMap和ConcurrentMap,这俩底层都是哈希表,前者线程不安全,后者线程安全
2.数组的数据结构
数组:开辟了一段连续的内存空间,来存放相同数据类型数据的线性数据结构
为什么数组索引或者说下标都从0开始?
这就要介绍一下数组的寻址公式了,当输入索引后,寻址公式为:数组首地址+索引乘数组元素类型的大小
baseAddress + i * datatypesize
如果索引从1开始,那么就会变成数组首地址+索引-1再乘数组数据大小
这样就会多一次减法操作,对于CPU来说多一条指令,效率就低了
数组查找元素的时间复杂度
1.通过索引查找 O(1)
2.未知下标查找O(n)
3.未知下标但排序,二分查找,O(logn)
数组新增和删除元素的时间复杂度:O(1)~O(n)
3.ArrayList底层源码
ArrayList的底层,是通过动态的数组来实现的
jdk8之后 ArrayList创建时的初始容量为0,第一次添加数据的时候才会初始化为10
ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
添加数据的逻辑:
先计算当前集合的size+1后,数组的容量够不够,如果够那么直接添加数据,如果不够就对数组调用grow方法进行扩容
确保新增的数据有地方存之后,再将新元素存放到下标为size的位置上(因为数组下标从0开始),返回成功布尔值
4.数组和集合之间的转换
数组转换为集合:Array.asList方法
集合转化为数组:toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
例如:
String[] str = {"aaa,"bbb"};
List<String> list = Array.asList(str);
String[] arr = list.toArray(new String[list.size()]);
1,用Arrays.asList转List后,如果修改了数组内容,list受影响吗
会,因为数组转list,底层是调用了Arrays类中的一个ArrayList内部类对数组中的数据进行了封装,并没有new一个ArrayList
最终集合指向的和数组是同一个内存地址
2,List用toArray转数组后,如果修改了List内容,数组受影响吗
不会,因为List使用toArray方法之后,底层是new了一个新数组,对集合的数据进行了元素的拷贝,新旧数组没有关系
5.ArrayList和LinkedList的区别
1.先分析其底层数据结构
ArrayList底层是通过动态的数组实现的,LinkedList底层是通过双向链表来实现的
2.再分析操作数据的效率
从查找上来看:
ArrayList可以根据下标查找指定元素,时间复杂度是O(1)。LinkedList不支持根据下标查询,只能遍历链表
两种List遍历查询未知索引的数据时间复杂度都是O(n)
从增删改来看
ArrayList,只有尾部插入和删除时间复杂度是O1,其他部分需要挪动数组,复杂度是On
LinkedList,从头尾插入或者在已知节点增删复杂度是O1,未知节点增删复杂度是On
3.从内存空间占用来看
ArrayList底层是数组开辟的连续内存空间,内存上较为节省
LinkedList是双向链表,除了存储数据之外,还要存储两个指向前后连接的指针,更占用内存
4.从线程安全来看
两者都不是线程安全的
通常解决方式有两种:
在方法内创建局部变量使用,避免线程安全
使用线程安全的List集合,通过Collection.synchronizedList方法,传入集合对象,可以给集合加锁,使其线程安全
一般来说,选用ArrayList较多,因为开发项目不是做算法题,我们一般都是从数据库里拿数据,用到集合的情况一般更多涉及到的是查询
数据,查询数据ArrayList更方便,同时他占用的内存也更少
6.二叉树和二叉查找树
二叉树:
每个节点两个叉,分别指向左右子节点,子节点和父节点结构相同。
个人感觉普通二叉树非常类似链表,只不过因为是二叉,每个节点都有两种走向罢了
二叉查找树:
也叫有序二叉树,和二叉树的区别就在于,存储的数据是唯一的,也是有序的
规则是,每个节点,其左子树每个节点的值都小于该节点的值,其右子树每个节点的值都大于该节点的值
通常情况下,有序二叉树查找数据的时间复杂度为Ologn
但是有一种极端情况,就是每次插入的数据都更小或者更大,导致二叉树的节点只有左节点或者只有右节点,就变成了链表,这种时候查找数据的时间复杂度就变成了On
为了避免这种情况的出现,我们引出红黑树
7.红黑树
红黑树的底层是一个可以自平衡的二叉搜索树,过去也叫平衡二叉b树
首先先讲解一下什么是平衡二叉树AVL
平衡二叉树,就是在二叉查找树的基础上,添加了一个条件:每个节点的左右子树高度差不能大于一
而之所以加这样一个条件,就是为了防止出现二叉查找树退化成链表的情况,这样查询数据的复杂度就很不稳定
而为了满足这个条件,再插入新数据导致左右子树高度差大于一时,就需要对二叉树进行旋转,来保证其平衡性
讲完了平衡二叉树,再来讲红黑树
红黑树具有5个规则:
1.每个节点不是红色就是黑色
2.根节点必须是黑色
3.叶子节点必须都是黑色
4.红节点的子节点都必须是黑色--可以推导出红节点的父节点必须是黑色,以及不能出现连续的两个红节点
5.从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
需要注意,红黑树的叶子节点都是黑色空结点
这里就需要解释,为什么要有这几个规则
红黑树自己也是一个可以自平衡的二叉查找树,但是他和AVL不一样的是,他没有像AVL那样提出一个明确的平衡因子概念,也就是AVL的
左右子树高度不能相差超过1这条规则,它只通过自己的5条规则就维持了平衡的结构,解决了非平衡树的问题
红黑树的优势在哪?
既然AVL已经能解决非平衡树的问题,为什么HashMap还要使用红黑树呢
主要原因是因为红黑树的插入删除节点比AVL更好操作控制,虽然AVl的时间复杂度优于红黑树,但是差距不大,在目前cpu性能够强的背景下可以忽略不计,因此红黑树整体性能优于AVL
主要体现在,红黑树对数据增删时候的操作更方便,相比AVL来说,旋转更少,有时候不需要旋转,直接修改节点颜色就能满足5条规则
因此简单来说就是,红黑树相比AVL,对于二叉树的平衡要求没那么严格,但是调整效率更高,牺牲部分平衡性换来的是增删节点时,减少了旋转的操作,提高了调整的效率整体性能优于AVL
只有在搜索的次数远大于增删次数时,才会选择AVL
8.散列表/哈希表
HashMap的底层数据结构:哈希表,由数组和链表或红黑树组成
根据key直接访问内存存储的value
其原理是,将key通过Hash函数HashCode计算出hash值,对应当前元素的数组下标,然后通过数组下标去数组里获取value
但是这样就会产生哈希冲突:即多个不同的key,通过Hash函数的计算,指向同一个数组下标
解决方案:拉链法(即添加链表或者红黑树的结构)
指的是数组每个下标位置称之为bucket桶或者slot槽
每个槽会对应一条链表,但是链表的搜素效率很低,因此使用红黑树的结构,至于为什么不使用AVL,上一节有讲解
这样一来,出现哈希冲突的value,就放在相同槽位的链表或者红黑树节点中即可
存取原理:
在存储数据时,如果存在hash值相同的key,有两种情况
a.如果key相同,则覆盖初始的value
b.如果key不相同,就把当前的key-value放入当前下标对应槽的链表或者红黑树中
在获取数据时,直接找到hash值对应的下标,然后判断key是否相同,在链表或红黑树中遍历,找到key相同对应的value
jdk1.8之后HashMap的变化:
-
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
-
jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表
9.HashMap的put流程
-
HashMap是懒惰加载,意思是创建HashMap对象的时候不会初始化数组
-
无参构造中,设置了加载因子是0.75,意思是,实际键值对到达数组最大容量的0.75时,对数组进行扩容,扩容到2倍
具体流程:
-
判断键值对数组table是否为空,也就是是否初始化,如果没有,那么就进行初始化,默认数组容量是16
-
key进行hash计算后,通过hash值得到数组下标i
-
如果table[i] == null ,直接新建节点添加
-
如果table[i] == null 不成立
-
4.1 判断table[i]的首个节点的key是否和添加的key一样,如果一样,直接覆盖value
-
4.2 如果首个节点不一样,那么判断tabl[i]是否是红黑树,是就直接在树中插入节点
-
4.3 如果不是红黑树,那么就是链表,插入链表的尾部。并且判断链表长度是否超过8,数组长度是否超过64,如果满足就把链表改为红黑树
-
插入成功后,判断实际存在的键值对数量size是否超过阈值,数组长度*0.75,超过就进行扩容
扩容机制
-
初始化和添加元素的时候,都需要判断是否使用resize方法进行扩容,初始化长度为16
-
每次扩容都是扩容到之前的两倍,newCap = oldCap * 2
-
扩容之后,创建一个新的数组,把旧数组数据挪到新数组中
-
没有hash冲突的点,使用 e.hash & (newCap - 1) 计算新数组的索引位置
-
如果是红黑树,走红黑树的添加
-
如果是链表,可能需要拆分链表,需要遍历链表的每一个节点,通过判断(e.hash & oldCap)是否为0,决定元素留在本来的位置还是移动到新的位置:原下标+oldCap
这里解释一下为什么判断(e.hash & oldCap)是否为0,其作用其实本质上是判断e.hash这个值与oldCap和newCap取模是否一致,具体的可以去查看源码:
先new两个链表头,分别对应低位和高位,低位就是存放扩容后不改变位置的节点,高位存放扩容后改变位置的节点,在遍历完链表后,(e.hash & oldCap)==0的节点放入低位,其余放入高位,然后低位的链表放入本来的下标位置,高位的链表,放入原下标+oldCap的位置
-
&and与运算,等同取模,也就是%,只不过效率更高
10.HashMap的寻址算法
-
通过hash(key)这个方法,获取key的hash值
-
hash这个方法里面,先获取hashcode(key)的值,然后右移16位,然后进行异或运算,获取hash值,这个也称之为扰动算法,主要目的是为了让hash值更均匀,减少hash冲突
-
获取hash值之后,再与数组长度-1进行and与运算代替取模,获取最终的存储下标
这里就补充到,为什么HashMap的数组长度一定得是2的次幂
1.2的次幂方便进行与运算代替取模
2.方便进行与运算,可以让计算下标时还有进行扩容重新计算下标时,也就是需要利用到数组长度的时候,与运算代替取模,效率更高
11.HashSet和HashMap的区别
一个是Set集合,存储对象,一个是Map,存储的是键值对
HashSet底层是用HashMap实现的,用HashMap的key保存元素值,而value默认是一个Object虚拟对象
12.HashTable和HashMap的区别

hashtable之所以线程安全,是因为其底层方法都被synchronized修饰了,因此才线程安全
五.中间件篇(待更新)
六.多线程篇
1.什么是进程和线程
进程:执行一段程序,开启一项工作,就是进程
线程:一段进程里,可以同时包含多个线程,例如一项工作,可以多个人同时合作去完成
2.什么是并发和并行
并发:多个线程轮流使用一个或者多个CPU
并行:有多个CPU,分别负责一个线程
3.创建线程的四种方式
1.继承Thread类
2.实现runnable接口
3.实现Callable接口
4.利用线程池创建线程
4.线程池
核心参数7个
-
corePoolSize 核心线程数目 - 池中会保留的最多线程数
-
maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
-
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
-
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
-
workQueue - 阻塞队列,当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
-
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
-
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
我们通过银行的场景来记忆:
某日银行只开启了3个柜台,这就是核心线程数目
还有2个柜台没开,一共5个柜台,这就是最大线程数目
银行的等待座位假设有3个,这就是阻塞队列
如果等待座位坐满了,又来了新的顾客,那么就会开启额外2个柜台,这就是救急的线程
如果等待的顾客已经少于等于3了,那么多的柜台就可以暂时关闭了,这就是生存时间
如果5个柜台都在工作,等待的人也超过3个了,那么多的顾客就知道被拒绝,这就是拒绝策略
如果救急线程到达存活时间,但是还有任务没完成,那么救急线程会先完成任务,只有救急线程处于空闲状态,并且到达存活时间,才会被结束
线程池的优点:
提高线程的使用率:如果我们自己手动创建线程,每次创建还要释放,然后使用又要重新创建
提高线程的响应速度:每次创建线程池之后,线程对象已经在池内创建好,直接用即可
便于统一管理
可以控制最大并发数量
阻塞队列的种类
比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue
1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
3.DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

关键区别是:
一个强制有界,一个默认无界
数据结构不一样,一个基于数组,一个基于链表
锁的数量不一样,ArrayBlockingQueue是1把锁,读写互斥,LinkedBlockingQueue是2把锁,读写分离,并发性能更好
核心线程数量
① 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换
② 并发不高、任务执行时间长
-
IO密集型的任务(文件读写,DB读写) --> (CPU核数 * 2 + 1)
-
计算密集型任务(计算型代码,Bitmap转化) --> ( CPU核数+1 )
线程池种类
在jdk中默认提供了4中方式创建线程池
1.FixedThreadPool 特点是构造时可以指定核心线程数量,最大线程数量等于核心线程数量,没有救急线程,但是阻塞队列的长度是Integer.MAX,阻塞队列默认为LinkedBlockingQueue,适用于已经确定好需要多少个线程的业务
2.CachedThreadPool 特点是,核心线程数为0,但是最大线程数是Integer.MAX,阻塞队列是SynchronousQueue不存储元素,也就是说,只要有新的任务进来,需要多的线程,就开启新的救急线程,如果此时不需要多的线程,那么存活时间到了,救急线程也会释放。适合高并发时间短的业务
3.ScheduleThreadPool 特点是可以自己通过构造方法输入参数,自己配置核心线程数、线程工厂、淘汰策略,从而实现周期执行还有定时执行等功能
4.SingleThreadExcutor 特点是核心线程和最大线程都是1,阻塞队列长度为Integer.MAX,意思是全程只有一个线程在执行任务,适用于可以保证任务执行顺序的情况
一般推荐使用ThreadPoolExecutor来自定义创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
如果使用Executors创建线程,他允许的请求队列/创建线程的默认长度是Integer.MAX_VALUE,可能导致大量请求或者线程堆积,导致OOM内存溢出

5.ConcurrentHashMap和HashMap的区别
前者是线程安全的
1.8之后ConcurrentHashMap底层的数据结构就和HashMap一样了:数组+链表+红黑树
放弃了Segment臃肿的设计,采用 CAS + Synchronized来保证并发安全进行实现
1.CAS思想控制数组的初始化、扩容、新节点的插入
-
初始化表时使用 CAS 确保多个线程不会重复初始化
-
初始化表的时候,检查table是否为null,如果为
null,尝试通过 CAS 将table设置为新的数组。
-
-
插入新节点
-
判断当前结点是否为null,如果为null,才会创建一个新的链表节点,而其他线程,判断当前结点不为null之后,就会进行其他逻辑操作,例如加锁对链表或红黑树进行新结点的添加
-
-
扩容
-
对负责扩容的线程设置线程标识,只有一个线程可以初始化新表
-
转移数据的时候,多个线程会协作转移,迁移时通过CAS保证线程安全,也就是迁移前会检查
-
2.synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升
6.CAS
CAS的全称是: Compare And Swap(比较再交换),是一种用来实现乐观锁思想的技术
在CAS中有三个值:
V:要更新的变量var
E:预期值expect
N:新值new
过程就是比较V和E是否相等,如果相等,将V改为N,如果不等,那么说明其他线程已经修改了V,大多情况下CAS与自旋锁一起使用,那么当前线程如果判断V和E不相等,即获取锁失败,会不断循环尝试获取锁的状态,尝试获取锁,这就是自旋锁
可以看出,CAS的底层操作就两个,比较V和E,修改V等于N,那么为了保证多线程的安全问题,必须保证这两个操作的原子性。而Java中CAS的原子性,是通过硬件指令的原子性实现的。在Java中,使用到CAS的方法在Unsafe类下的被native修饰的方法,底层是交给jvm用c或c++语言实现,具体实现是靠操作系统和CPU
据我了解CPU实现原子指令的方式主要有两种:总线锁定和缓存锁定,前者通过Lock#信号,后者通过缓存一致性协议
自旋锁1.6之后默认开启,默认自旋次数是10次,超过自旋次数之后,会让出CPU进入等待状态
另外,CAS常常也伴随着volatile关键字使用,因为被volatile修饰的变量多线程可见,同时,也禁止了指令重排序的可能性
举个例子:
ConcurrentHashMap就是通过CAS自旋锁以及syn来保证线程安全的
我们知道ConcurrentHashMap在jdk1.8之后就不再采取过去多段数组+链表的设计,底层的数据结构转变为何HashMap一样了。而CAS呢,保证了初始化数组、插入新节点、数组扩容等操作的线程安全问题
通过查看底层源码就可以发现,首先,初始化的table以及扩容时创建的新table都是被volatile修饰多线程可见的,同时,初始化的时候,会先判断table是否已经创建,如果table==null或table长度为0,该线程才会初始化数组,这就是典型的CAS运用,可以有效提高性能。但是会面临ABA还有自旋锁占用内存的问题
sync锁在ConcurrentHashMap中呢,细粒度控制在链表和红黑树的首节点上,在CAS锁插入新节点失败的时候,意味着其他线程修改了位置,此时才会退而求其次使用sync锁。或者说,sync锁加在首节点上,只用来处理多线程环境下,多个线程操作数据发生Hash冲突的情况,因此极大避免了线程挂起等阻塞式操作的开销。
另外我了解过的知识当中,sync锁升级机制中的轻量级锁、偏向锁、Lock接口的ReentrantLock实现类底层也都使用了CAS自旋锁
7.volatile
volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能
第一:保证了不同线程对这个变量进行操作时的可见性
即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。
底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
8.synchronized和Lock有什么区别
第一,语法层面
-
synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
-
Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
第二,功能层面
-
二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
-
Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
第三,性能层面
-
在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
-
在竞争激烈时,Lock 的实现通常会提供更好的性能
统合来看,需要根据不同的场景来选择不同的锁的使用。
9.ThreadLocal的理解
ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享
在ThreadLocal内部维护了一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
ThreadLocal会导致内存溢出
是因为ThreadLocalMap 中的 key 被设计为弱引用,value是一个强引用。弱引用会被GC自动回收,强引用不会
因此在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。
10.AQS
全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架
常见的实现类:
ReentrantLock 阻塞式锁
CountDownLatch 倒计时锁
-
在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,0表示无锁,1表示有锁
-
提供了基于 FIFO 的等待队列,底层结构是双向列表,类似于 Monitor 的 EntryList
线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待
如果出现多个线程同时去修改state属性的情况,使用的cas自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待
11.ReentrantLock
和synchronized的区别:
可中断、显式锁、多条件变量、可以实现公平锁
构造方法有两种:
无参构造:默认非公平锁:新的线程和阻塞队列中的线程一起抢锁
有参构造:传入true,设置为公平锁:只让队列中的head,也就是等待最久的线程获取锁
底层是通过AQS+CAS实现的
线程通过CAS修改state,属性exclusiveOwnerThread会指向抢到锁的线程
没抢到锁的线程会进入AQS的等待队列
释放锁之后,exclusiveOwnerThread为空,唤醒等待队列的线程来抢锁
如果说设置了公平锁,那么就会让队列中的head线程获取锁
12.死锁产生的条件和死锁诊断
产生条件:一个线程同时要获取多把锁的时候,两个线程,互相占有了对方需要的锁
用多线程银行转账业务举例
有有两个用户对象A和B
现在需要开启两个线程t1,t1让A给B转账,t2让B给A转账
为了保证线程安全,t1和t2都需要获取A和B对象的锁
那么就可能出现,t1获取了A的锁,t2获取了B的锁,但是都无法获取对方持有的锁,导致死锁
解决办法:给锁添加顺序,例如通过对象的hash值,给锁添加顺序,保证先到的A的锁,才能再抢到B的锁,就避免了死锁
诊断死锁:
方法1:通过jdk自带的工具:jps和jstack
jps可以查看当前java程序的进程id,然后通过jstack查询这个进程,就可以展示死锁问题和具体代码行
方法2:通过可视化工具:例如VisualVM等
13.线程的状态
初始状态:创建了但没start
运行状态:调用start
阻塞状态:获取锁失败,进入阻塞队列
等待状态:调用了wait方法
超时等待:调用了sleep/wait方法,并且配置了等待时间
终止:线程任务执行完毕
14.Callable和Runnable的区别
Callable接口call方法有返回值,调用futuretask.get可以获取,并且允许抛出异常。
15.run和start的区别
start指的是当前线程开始工作,只能调用一次,让当前线程去执行run方法。run方法是封装了线程要执行的代码,可以被调用多次。举个例子就是,一个厨师开始上班炒菜,和炒哪份菜可以炒多次的区别
七.计网篇
1.OSI七层模型和TCP/IP四层模型
OSI
应⽤层,负责给应⽤程序提供统⼀的接⼝; 表示层,负责把数据转换成兼容另⼀个系统能识别的格式; 会话层,负责建⽴、管理和终⽌表示层实体之间的通信会话; 传输层,负责端到端的数据传输; ⽹络层,负责数据的路由、转发、分片 数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址; 物理层,负责在物理⽹络中传输数据帧;
TCP/IP
应用层
传输层
网络层
网络接口层
2.三次握手
1、第一次握手:客户端给服务器发送一个 SYN 报文。
2、第二次握手:服务器收到 SYN 报文之后,会应答一个 SYN+ACK 报文。
3、第三次握手:客户端收到 SYN+ACK 报文之后,会回应一个 ACK 报文。
4、服务器收到 ACK 报文之后,三次握手建立完成。
作用是为了确认双方的接收与发送能力是否正常。
第一次握手:服务器收到客户端发送的网络包,确认了客户端发送能力,服务器接受能力正常
第二次握手:服务器发包,客户端接受,确认了服务器发送能力,客户端接受能力正常
为什么要第三次握手?
因为服务器发包,并不知道客户端是否收到,无法确实客户端接受能力正常
因此客户端接受到之后,回应一个包,让服务器知道客户端接受能力正常
因此,需要三次握手才能让双方确认彼此的接收与发送能力是否正常。
3.四次挥手
挥手请求可以是Client端,也可以是Server端发起的,我们假设是Client端发起:
第一次挥手:客户端向服务器发送FIN报文,报文中指定一个序列号,此时客户端处于FIN_WAIT1状态
第二次挥手:服务器收到FIN之后,会发送ACK报文,ACK的序列号为客户端序列号值+1,表示收到客户端的报文,此时处于CLOSE_WAIT状态,客户端收到ACK后处于FIN_WAIT2状态
第三次挥手:服务器向客户端发送FIN报文,指定一个序列号。此时服务端处于 LAST_ACK 的状态。
第四次挥手:客户端收到FIN之后,会发送ACK报文,ACK的序列号为服务器序列号值+1,表示收到服务器的请求,此时客户端处于一个TIME_WAIT状态,需要等一段时间,确保服务器收到自己的ACK之后再关闭连接进入CLOSE状态
之所以是四次挥手,是因为可以把断开连接分为两个部分,主要是因为FIN报文和ACK报文发送的时效性问题。TCP协议是双全工的,也就是说,数据可以在两个方向上独立传输,建立连接三次握手是为了确认双方状态,四次挥手同样是为了确认双方状态。发送ACK报文是为了满足TCP协议的要求,一方收到FIN就必须马上响应ACK,而发送FIN报文,是当前这一方确认应用层已经处理完数据,才会进行发送,也就是说第二次回收响应ACK报文,和第三次挥手发生FIN报文,严格意义上来说不能合并为一次发送。
4.TCP和UDP的区别
(1)TCP是可靠传输,UDP是不可靠传输;
(2)TCP面向连接,UDP无连接;
(3)TCP传输数据有序,UDP不保证数据的有序性;
(4)TCP不保存数据边界,UDP保留数据边界;
(5)TCP传输速度相对UDP较慢;
(6)TCP有流量控制和拥塞控制,UDP没有;
(7)TCP是重量级协议,UDP是轻量级协议;
(8)TCP首部较长20字节,UDP首部较短8字节;
TCP应用场景:
效率要求相对低,但对准确性要求相对高的场景。举几个例子:文件传输(准确高要求高、但是速度可以相对慢)、接受邮件、远程登录。
UDP应用场景:
效率要求相对高,对准确性要求相对低的场景。举几个例子:QQ聊天、在线视频、网络语音电话(即时通讯,速度要求高,但是出现偶尔断续不是太大问题,并且此处完全不可以使用重发机制)、广播通信(广播、多播)
5.HTTP和HTTPS的区别
1.HTTPS需要用到CA证书,通常是要付费的
2.HTTP是超文本传输协议,明文传输,HTTPS是加密传输,需要消耗更多CPU和内存资源
3.连接方式不同,端口也不一样,一个是80一个是443
简单来说,HTTPS最重要的区别就是,使用了对称和非对称加密,相较于明文传输的HTTP协议,速度较慢,消耗资源较多,但胜在安全
解释一下对称加密和非对称加密
对称加密:加密的密钥和解密的密钥是一样的。
非对称加密:加密的密钥和解密的密钥不一致,有两种情况1.公钥加密,私钥解密 2.私钥加密,公钥解密。
HTTPS的加密方式是两种结合的,加密过程使用的非对称加密,而内容传输上使用的是对称加密
具体流程:分为证书验证和数据传输阶段
证书验证阶段:
浏览器发送HTTPS请求、服务器返回HTTPS证书,证书中携带了公钥,服务器自己保存私钥。客户端会验证证书是否合法。
数据传输阶段:
证书合法后,客户端生成随机字符串,这个随机字符串就是后续传输数据所使用到的密钥key,通过公钥加密发送给服务器
服务器接收到key后,通过私钥解密拿到key,然后通过key构造对称加密。后续的数据传输就是对称加密
证书的作用:防止中间人攻击,简单来说就是服务器和客户端之间有一个中间人,自己模拟服务器和客户端,窃取服务器和客户端之间传输的信息。因此客户端需要验证CA证书的合法性。但是证书是公开的,任何人都可以去官网获取,因此需要非对称加密的私钥,私钥是保存在服务器上的,无法获取,这样即使中间人拿到证书也没办法模拟成合法服务端
6.粘包
产生原因:
1.TCP是基于字节流的,没有边界
2.TCP首部没有表示数据长度的字段
一个数据包中包含了发送端发送的两个数据包的信息,这种现象即为粘包。
解决办法:
-
特殊字符控制;
-
在包头首都添加数据包的长度。
如果使用 netty 的话,就有专门的编码器和解码器解决拆包和粘包问题了。
7.TCP的协议
HTTP:超文本传输协议
FTP:文件传输协议
SMTP:简单邮件传输协议
TELNET:远程登录
SSH:安全外壳协议
8.UDP的协议
DNS:域名解析协议
TFTP:简单文件传输协议
SNMP:简单网络管理协议
9.tcp的流量控制和拥塞控制
流量控制:为了解决发送方和接收方速度不同而导致的数据丢失问题,当发送方发送的太快,接收方来不及接受就会导致数据丢失;
方式:滑动窗口
简单来说就是接收方会将自己可以接受的缓冲区大小存入TCP首部的window字段传给发送端,发送端就不会发送超过这个限额的数据。而之所以称之为滑动窗口,是因为这个缓冲区一旦面临溢出情况,窗口值就会缩小,也就是说,这个窗口是被接收端动态控制的
拥塞控制:为了解决过多的数据注入到网络导致网络崩溃和超负荷问题
方式:拥塞窗口+慢开始门限
简单来说就是发送端通过拥塞窗口去判断网络拥塞状态,通过与慢开始门限的对比去选择采取不同算法调整TCP发包节奏
算法:
1.慢开始:TCP连接开始时,拥塞窗口大小为1,每次传输完成就翻倍,指数增长,一直到慢开始门限的阈值
2.拥塞避免:拥塞窗口增长到大于等于慢开始门限后使用,每次传输完成拥塞窗口+1MSS,变为线性增长
MSS:TCP报文段最大长度,在三次握手中确定
当网络发生拥塞,就会导致丢包,此时就有了下面两种算法
3.快重传:发送端收到3个重复的ACK之后会立即重传丢失的数据包
4.快恢复:快重传后慢开始门限变为拥塞时拥塞窗口的一半,然后把拥塞窗口设置为新慢开始门限+3MSS
10.get请求和post请求的区别
1.get请求是客户端从服务器获取数据,post请求是向服务器传输数据
2.get请求安全性较差,数据会衔接在url的后面,post安全性较高,数据不会显示在url上,而是保存在Request-body请求体当中
3.get请求会被浏览器缓存,历史记录能够查到,也可以被作为书签,post请求不可以
4.get请求的url存在长度限制,最大长度是2048个字节,并且限制使用ASCII码的字符,post请求没有限制,允许使用二进制数据
5.get请求只发送一个数据包,header和data一起发,post请求会先发header,服务器响应100后再发data,才会响应200
不过火狐浏览器的post请求只发一次包
八.JVM虚拟机
1.主要组成部分和运行流程
-
ClassLoader(类加载器)
-
Runtime Data Area(运行时数据区,内存分区)
-
Execution Engine(执行引擎)
-
Native Method Library(本地库接口)
流程:
1.类加载器将java代码转换为字节码加载到JVM当中
2.运行时数据区将字节码加载到内存中,但是字节码不能直接执行
3.执行引擎将字节码转化成底层系统指令,再交给CPU执行,此时需要调用其他语言的本地库接口
2.程序计数器
线程私有的,用来保存字节码的行号。
当一个线程所拥有的占有CPU的时间被用完,就会被挂起,让其他线程去占用CPU,其他线程使用完后,重新轮到当前线程的时候,就需要去查看程序计数器,让当前线程从上一次执行的行号开始继续往下执行
3.堆
堆是内存中开辟出来的一个线程共享的区域,用来存储对象还有数组等。堆满抛出OutOfMemoryError异常
java8之前,堆有三个部分,年轻代,老年代,永久代,年轻代又分为一个Eden区,两个大小相同的survive区
java8之后,取消了永久代,因为永久代里面保存的数据不会被回收,容易造成OOM内存溢出,转而变成了存储在本地内存的元空间。主要保存类信息、静态变量、常量等
4.栈
栈是开辟的,每个线程所需要的内存,由多个栈帧组成,栈溢出抛出StackOverFlowError异常
每个栈帧里面保存了:局部变量表、操作数栈、动态连接、返回地址
栈帧的主要作用就是保存当前线程执行的每个方法的信息
堆内存是依靠垃圾回收器释放的,栈的内存在一个栈帧弹栈后就会自动释放
5.类加载器
类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
-
启动类加载器(BootStrap ClassLoader):
该类并不继承ClassLoader类,其是由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库。
-
扩展类加载器(ExtClassLoader):
该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。
-
应用类加载器(AppClassLoader):
该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类。
-
自定义类加载器:
开发者自定义类继承ClassLoader,实现自定义类加载规则。
类加载的顺序:
加载、验证、准备、解析、初始化
初始化顺序:

6.什么是双亲委派机制
如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就返回成功;只有父类加载器无法完成此加载任务时,才由下一级去加载。
目的:避免重复加载以及恶意篡改核心API库
7.垃圾回收机制:GC
其实就是java提供的自动的垃圾回收机制
如果说一个对象没有被任何的引用指向它了,那么就会被定义为垃圾,被定义为垃圾就有可能被垃圾回收器回收
定义方法主要是:引用计数法和可达性分析算法,现在主要使用后者,原理是判断某对象是否与根对象有直接或间接的引用,如果没有被引用,则可以当做垃圾回收。其中有个finalize方法,每个对象在第一次可回收时会执行一次,在方法中可以设置当前对象和根对象GC roots关联,关联了就不会回收了。这个对象第二次可回收时,就不会执行这个方法,直接被回收
垃圾回收算法:
标记清除算法、复制算法、标记整理算法
分代收集算法:主要使用
java8之后,堆被分为新生代和老年代:默认占比为1:2
新生代又分为3个部分:Eden、s0、s1,默认占比为8:1:1
具体工作机制是:
一个新建的对象会放入新生代中的Eden区域,Eden区要满的时候,触发younggc
younggc之后,存活的对象会存入s0,Eden清空,存活对象年龄+1
第二次触发younggc的时候,会把s0和Eden区的存活对象,存入s1,然后清空,存活对象年龄+1
再一次触发younggc的时候,会把s1和Eden区的存活对象,存入s0,然后清空,存活对象年龄+1
可以理解为,s0和s1就是临时用来相互存放每一轮的存活对象的
当存活对象年龄到达默认值15之后,就会放入老年代,特殊情况是如果有大对象出现,触发younggc就会直接放入老年代
老年代满了之后,就会触发FullGC,同时回收新生代和老年代
垃圾回收器:
串行垃圾回收器:线程都暂停,一个线程进行垃圾回收
并行垃圾回收器:java8默认:线程都暂停,多个线程进行垃圾回收
CMS并发垃圾回收器:针对老年代,其中一个线程进行垃圾回收,其他线程并发运行
G1垃圾回收器:java9之后默认:将堆划分为多个区域,每个区域都可以充当Eden、survivor、old、 humongous区。采用复制算法。
主要采用两种垃圾回收模式:younggc和mixedgc
特点是mixedgc,回收老年代数据的时候,选取的是释放空间最大的区域进行回收,保证释放更多的内存空间
优点是:STW时间少,吞吐量高
8.cms和g1详情
cms:并发垃圾回收器
基于标记清除算法,针对老年代的垃圾回收器,主要特点是以获取最短回收停顿时间为目标
过程主要分为:初始标记和并发标记,然后是需要停顿的重新标记,最后是并发清理
初始标记:标记与gc roots根对象之间关联的对象,需要stw
并发标记:标记gc roots根对象直接和间接关联的对象,并发不需要stw
重新标记:并发标记的时候,产生了新的与gc roots关联的对象,需要重新标记,需要stw
并发清除:清除没有被标记的对象,并发操作
优点:大部分操作是并发执行,不会造成停顿,整体回收流程停顿时间很短,不影响应用运行
缺点:并发意味着会占用一定CPU资源、并且并发清理的时候会产生部分新的垃圾(浮动垃圾)、另外因为使用的是标记清除算法,会导致出现内存碎片
G1:java9之后的默认垃圾回收器
新生代基于复制算法,老年代基于标记整理算法,同时遵循分代收集算法的一种垃圾回收器
主要的特点我认为有这几个
1.使用的是分代收集算法的升级版,我称之为逻辑分代,物理不分代,表现为将堆内存分成了默认为2048份大小相同的region区,每个region区都可以当做Eden区、survive区、老年代和大对象区,其作用是在垃圾回收的时候,会选取垃圾更多的region区,最大效率地进行垃圾回收,这也是其名字的来源garbage first
2.最大的特点,是可以设置最长的停顿时间,或者说期望停顿时间,每次停顿回收时都会进行垃圾回收时间预测
3.吞吐量大,效率高,体现在对于新生代和老年代的回收都是少量多次,新生代默认阈值为Eden区的60%,老年代的默认阈值是45%就会进行young gc和mixed gc,极大地提高了吞吐率,同时也可以缓解full gc的出现概率
4.也采用并发标记,并行回收的机制,可以减少停顿时间
g1垃圾回收器的工作流程主要分为两个部分
1.针对新生代的young gc
Eden区使用率到达设置阈值60%,或者G1计算出回收时间接近用户设定的最大停顿时间,那么就会对新生代进行回收,基于的是复制算法,回收Eden区和survive区,将幸存对象年龄+1放入新survive区或者老年代。
2.针对老年代的mixed gc
老年代使用率达到阈值45%或者分配大对象时会进行mixed gc,针对年轻代和部分老年代,选取策略是垃圾对象最多的region区,确保在指定的期望停顿时间内释放更多的内存对象
主要流程分为:初始标记、并发标记、最终标记、筛选回收
初始标记,需要短暂停顿,标记gc root根对象直接关联的对象
并发标记,不会造成停顿,通过可达性分析算法,标记出所有存活的对象,时间较长,但是并发标记的时候,用户线程没有暂停,会出现多标和漏标的问题,多标会导致浮动垃圾,下次回收就好,漏标就会很严重,导致本来存活的对象被回收,因此有了最终标记
最终标记,需要暂停用户线程,通过SATB修正并发标记期间产生变动的对象
筛选回收,也需要暂停用户线程,将存活对象复制到新的region区,回收旧的region区
优点:能充分利用多核处理器的优势,减少垃圾收集。利用标记整理算法,解决了cms的内存碎片问题。最关键的,是提供了用户可以设置期望的停顿时间
缺点:G1需要记忆集来记录新生代和老年代之间的引用关系,这种数据结构需要占用大量内存,同时维护成本很高,执行负载也会更高,影响效率
两种垃圾回收器,cms适用于小内存以及需要停顿时间尽可能少的应用,G1适用于内存较大,或者需要设置期望停顿时间的应用
大小应用的划分界限一般在6-8之间
据我所知,jdk13就把cms移除了,现在大部分情况都是使用g1
9.四种引用
1.强引用:不会被垃圾回收器回收
2.软引用:内存不够时才会被垃圾回收器回收
3.弱引用:垃圾回收器在回收时会回收
4.虚引用:根本不算做被持有,配合引用队列使用,主要是用于跟踪对象被垃圾回收的时机
九.项目难点介绍
1.登录验证
网页输入手机号,发送post请求给服务器,服务器这边接收请求后,提取出手机号,先对手机号通过正则表达式进行格式判断,判断成功后,因为如果采用邮件形式,需要用到第三方工具,本身与业务无关,因此我选择直接生成一个6位随机数,作为验证码,打印到控制台,以手机号为key,保存到Session
之后,浏览器再输入验证码,发送登录的请求,服务器这边,验证手机号,并从Session中取出验证码进行比对,如果成功,那么就去查询数据库是否存在当前手机号对应的用户。如果没有,就新建一个,如果有,那么就将用户信息保存到我们写好的一个简单用户类UserDTO里面,这个类保存的是一些非敏感用户信息,只有用户的id,昵称。然后把简单用户类对象给保存到Session里面,Session的id是用UUID工具生成的token,最后把token返回给前端
但是Session保存登录验证信息在服务器并发环境下会有问题,因此引入了redis,替换了Session,在本来的业务中,将验证码还有简单用户对象,都转而保存在redis当中
同时,编写双层拦截器,第一层为token刷新拦截器,针对所有请求和页面,验证token是否能获取到redis中的用户信息,成功与否都会放行,成功的话会把用户信息保存到ThreadLocal当中,方便其他业务使用,同时刷新redis里面的token有效时间。然后第二层为登录验证的拦截器,部分请求和页面需要保证有登录认证,判断token是否能获取用户信息,如果认证失败,返回401错误
拦截器:自建一个拦截器类实现Handleininterceptor接口和方法,然后在实现WebMvcConfigurer接口的MvcConfig类里进行配置
2.商铺缓存
在我的热点评项目中,商铺页面肯定是会被经常访问的,如果每次访问都要去对数据库进行读操作,效率是极低的。因此,引入了redis作为缓存工具,其原理就是将数据库查询到的商铺信息,保存在shop类的对象中,然后将shop对象,通过JSONUtil工具类转化为json字符串,保存到redis当中。然后以后每次访问商铺页面,都会优先去缓存里面,尝试获取商铺信息,如果获取不到,再去数据库中获取,从数据库中获取到后,重新保存在redis缓存当中,然后响应给前端
实际测试下来,响应速度得到了极大的提升,由本来的1.6s提升至0.2s,约90%的性能提升
既然使用了redis缓存,好处是提高了读写效率,降低了响应时间,那么肯定是有代价的,代价就是需要解决缓存常见的三个问题:缓存穿透、缓存击穿、缓存雪崩以及使用redis缓存的一个底层问题:数据一致性问题
下面吟唱八股就好
3.秒杀券抢购
实现这个功能,基础逻辑是,用户点击抢购,发送请求到服务器,服务器会将当前的用户id、秒杀券id,以及订单id等信息保存到数据库的订单表里面。
第一个问题:订单id的设计,如果直接使用mysql的自增id,那么会存在两个问题:
1.id规律太明显,容易被外人发现统计出订单量
2.存在单表数据量的限制,订单的量会越来越多,mysql的单表容量不能超过500w,数据量过大就会分表,分表之后逻辑上也是一张表,id也不能重复
因此,我们需要自己去设计一个全局唯一ID生成器
为了安全性,我们将64位的id分为3部分
1位符号位永远为0
31位的时间截:当前时间减去开始时间截,以秒为单位,31位可以用69年
32位的序列号:用一个自增的count表示,同时这也是存在redis当中,记录订单数的value
每生成一个全局ID,count就自增1,而redis中,这个count的key,我使用精确到天的日期格式来取名 order:年:月:日,这样还可以方便查看每日的订单数量
实现了全局ID生成器,我们就可以实现秒杀券抢购的基础逻辑
客户端发送请求携带秒杀券的id,后端拿到秒杀券id后对数据库进行查询,获取当前秒杀券的信息
1.判断秒杀券时间是否开启或结束
2.判断秒杀券的库存是否充足
3.如果是,则扣减库存,创建订单对象。
4.生成订单id,从ThreadLocal获取用户id,将订单id、用户id、秒杀券id存入订单对象中,然后把订单数据保存到数据库表中
5.返回订单id
至此,我们的基本业务流程就实现了,接下来我们需要解决2个关键问题
1.超卖问题
出现的原因是,在多线程并发环境下,加入库存只剩1了,第一个线程判断库存还有之后,开始进行扣减库存的操作,数据库还没有来得及更新数据库的库存之前,有多个线程同样判断当前库存还有剩余,导致超卖
解决方式:乐观锁,在更新数据库的时候,添加一个where条件即可,也就是在修改数据之前判断库存是否大于0即可
2.一人一单问题
为了避免一个人抢多张优惠券,我们在需要加一个判断,就是看当前订单表里面,查看userid与当前这个请求的userid相同的记录,如果发现有,表示当前用户已经抢过优惠券,不能再抢了
但是,在并发环境下,会出现和超卖一样的问题,就是在修改数据库完成之前,有多个线程通过了判断,还是会导致一人多单的问题。而这个问题就没办法通过乐观锁解决,需要使用到悲观锁。具体方法是,将一人一单的业务逻辑单独创建一个方法,然后传入userid,在用户上添加锁,保证锁颗粒度为单个用户。
悲观锁解决了单机情况下的一人一单问题,但是在服务器集群环境下没有解决,原因是,服务器集群环境下,每个tomcat有自己的jvm,每个jvm的syn锁是单独的,也就是说,syn锁只能锁住单机的多线程,其他服务器的线程锁不住。因此就需要分布式锁
最开始呢,我是自己通过redis的setnx命令自建了一个分布式锁,实现原理其实和之前解决缓存击穿时候,编写的互斥锁相似。但是实际运用中会出现问题,就是会出现锁误删的情况。什么意思呢?就是说,线程1获取到锁之后,中途因为网络原因或者业务流程过长的原因,导致锁的ttl过期释放了,线程2拿到了锁,线程2没释放之前,线程1的业务跑完了,释放了线程2的锁,这就是锁误删的情况。为了解决这个问题,我选择在setnx的时候,将线程标识存入value之中,释放锁的时候,需要判断线程标识与redis中分布式锁的线程标识是否一致,如果一致才能对锁进行释放
但是即使如此,在更加极端的情况下也会出现误删问题,就是在判断完线程标识之后,正准备释放锁,结果出现了阻塞,例如垃圾回收时导致的停顿,此时恰好锁的ttl就过期释放了,还是会出现误删问题。那么解决办法就是引入lua脚本,使用lua脚本去操作redis,就能保证判断标识和删锁是一个原子性操作了
还有一个难点是,因为我们把删减库存还有后续的下单业务给单独划分成一个方法,在获取锁之后,直接去调用这个方法。因为涉及到多张表的操作,我们会在这个下单业务的方法上添加事务,但是这里就会产生一个问题,spring的注解式事务,底层是通过AOP实现的,AOP底层又是通过代理对象实现的,如果直接调用这个方法,本质上是this调用,因此我们需要获取到原始的代理对象,然后让代理对象来调用下单的方法
最终的业务流程是,客户端发送请求,后端接收请求之后,通过秒杀券的id去获取当前秒杀券的信息,判断秒杀时间和初步判断库存剩余,判断通过后,会创建我自己编写的分布式锁对象,然后尝试获取锁,这个锁的key是根据当前线程的ThreadLocal中获取的Userid,用来控制这个锁的细粒度为Userid,value就是当前线程的线程标识。成功获取锁之后,就需要去调用创建订单的createOrder方法,需要先获取原始代理对象,然后用代理对象调用,保证事务生效。最后在final代码块里释放锁,释放锁的操作通过lua脚本执行,分别是判断锁的线程标识和当前线程标识是否一致,一致再释放锁
我自己编写的redis分布式锁还存在三个关键问题,第一,不可重入,第二,如果业务时间过长,锁会超时释放,第三,主从一致性问题
后续就是直接使用Redission给我们提供好的分布式锁,吟唱一下Redission提供的分布式锁的优点
4.秒杀券优化-异步秒杀
为什么要实现异步秒杀?
因为之前的秒杀券抢购流程:查询优惠券,判断优惠券时间,判断库存,判断一人一单,删减库存,添加订单等众多操作,并且这些操作都是同一个线程串行完成的,并且需要多次访问数据库,每一次下单都耗时极高,通过jmeter测试,1000个线程并发压力环境下发送1000次请求的平均耗时为500毫秒,吞吐量为900,效率很低,因此我采取异步抢购秒杀券的方式对其进行优化
我发现,判断用户下单成功的关键条件只有两个,一个是判断库存是否充足,一个是判断当前用户是否已经下单用来保证一人一单。只要这两个条件成立,那么就可以创建订单
所以,优化思路就是利用redis内存读取高性能的特性,对库存和一人一单进行判断,判断成功之后,让其他线程在后台去完成数据库的修改操作
这里呢,对于异步的处理,就有两种方案,第一种就是使用jvm自带的阻塞队列,但是阻塞队列的长度有限,高并发环境下容易造成内存溢出,并且数据安全没有保证,因为阻塞队列的订单信息是保存在内存中的,没有持久化的保证
因此,我选择使用消息队列的方式来实现保存订单,异步修改数据库的操作,因为目前对MQ中间件还没有进行系统的学习,只简单学习过延迟队列的使用,因此我是通过redis的Stream数据结构实现的消息队列
具体过程呢,是添加秒杀券的时候,需要将秒杀券的id作为key,库存总数作为value保存到redis当中,同时,需要利用redis当中的set数据类型,来保存当前秒杀券已经抢购过的userid,为了保证判断的原子性,我们需要在lua脚本中,完成库存的判断,以及当前线程获取的Userid是否在set中,使用的是set的sismember方法。判断成功之后呢,我们需要将订单id、用户id、秒杀券id这个三个关键信息,添加到我们的Stream当中,至此,就完成了第一步,完成下单的判断
然后就是实现异步线程去获取消息队列的数据,然后修改数据库了。我是通过Stream的消费者组来进行对消息队列中订单信息的处理的,也就是Stream数据结构的XREADGROUP命令,特点是没有漏读风险,有消息确认机制,确保消息被消费。具体流程是,我创建了一个singlethreadExcutor单线程线程池,保证处理任务的有序性。同时,在秒杀券抢购的实现类里,编写了一个init方法加上postConstruct注解,保证这个类初始化时就会执行init方法,方法里就是调用这个线程去处理Stream的消息队列。具体的处理流程是,while(true)不断循环获取消息队列的最新数据,具体体现在XREADGROUP命令的id设置为>号,表示获取下一个未消费的消息。确认获取数据之后,将数据拆分封装到订单类对象当中,然后添加到数据库表当中,最后需要手动ACK,确认消息被消费。如果中途发生异常,那么需要再catch代码块中执行handlependinglist的方法,这个方法的具体逻辑呢就是保证所有信息都被消费,pending-list是Stream这个数据结构,用来保存那些消费了但没有确认,也就是没有ACK的数据的,在handlependinglist这个方法中,区别在于XREADGROUP命令里的id从>变成了0,指的是从pending-list里的第一条数据开始处理,处理的过程和正常获取消息的流程一样,一直循环到将pending-list里面的数据给处理完,才会退出循环,重新去获取消息队列中的新数据
至此,异步秒杀优化的整体流程就全部完成,通过jmeter的测试,1000次请求的平均耗时降低到了110ms,吞吐量呢也上升到了2000,大大提高了在并发压力环境下的性能
5.点赞排行榜功能
基本业务流程就是,点赞的时候,为了防止同一个用户无限点赞,我们需要利用set集合来保存点赞过的用户,访问某篇笔记的时候,也需要判断当前点赞的set集合里面是否存在当前用户,如果存在,再次点击就变为取消点赞,点赞数就会-1
但是点开笔记详情的时候,我希望展示的点赞用户信息,是按照点赞先后顺序排列的,就类似QQ微信朋友圈的点赞信息一样,会有一个顺序排列。为了完成这个功能,就需要使用到Zset或者sortedset这个数据结构,代替set进行优化
其中较为关键的部分,就是设置Zset的score值,我直接使用system.currentTimeMills当前时间来指定score值,这样从小到大排列,最前面的就是最先点赞的用户
还有一个问题,就是点开笔记详情的时候,我们通过Zset的zrange命令获取前5个点赞的用户信息,但是保存在zset里面的只有用户id,因此,这里还需要去通过用户id去数据库里面拿到具体的用户信息,然后按照顺序放入UserDTO的集合里面,才可以返回
十.AI大模型
1.AI大模型的实现基础
一方面是目前硬件的进步,算力的提高
另一方面是基于深度学习发展的自然语言处理NLP中的关键技术Transformer这种先进的神经网络模型
2.大模型底层原理
主要就是依托大模型语言LLM里面的Transformer技术的推理预测
简单来讲就是一段话会被计算机转换为很多个token,大模型基于上文的信息,Transformer推理接下来跟着的应该是什么内容,推测出新的token之后,加入前文,然后再交给大模型,然后再推测生成新的token,以此来生成大段的内容,这也是为什么AI大模型生成内容是一点一点生成的原因
3.模型部署方案
云部署:部署简单,不需维护。但是安全性低,长期成本高
本地部署:安全性高,定制性强。但是部署复杂,维护麻烦,短期成本极高
开放api:成本极低,即拿即用,适合个人使用
4.大模型和大模型应用
AI大模型和传统编程之间各有利弊
前者善于自然语言处理和模糊预测判断,后者更善于精确计算和确定性逻辑验证
两两结合,互补短板,造就了大模型应用,即传统应用开发中介入AI大模型
分辨大模型和大模型应用
大模型:GPT、DeepSeek-R1等这些是大模型
大模型应用:ChatGPT、豆包、腾讯元宝这些介入了AI大模型开发的应用
5.大模型应用开发技术架构
从开发成本由低到高来看,四种方案排序如下:
Prompt < Function Calling < RAG < Fine-tuning
所以我们在选择技术时通常也应该遵循"在达成目标效果的前提下,尽量降低开发成本"这一首要原则。然后可以参考以下流程来思考:
1.准备测试数据
2.基于对话产品验证纯Prompt方案可行性
-是否需要对接其他系统 --是--> function calling
-是否需要额外知识库 --是--> RAG
-是否需要微调 --是--> fine-tuning
简单来讲
1.纯prompt就是集成了大模型的一个应用罢了,把用户的对话和应用的system设置结合生成prompt,可以实现基本的对话
2.function-calling就是需要修改数据库、执行其他业务的时候,我们把传统业务封装成一个个函数,然后对话时把提示词分割,判断并调用不同提示词对应的函数
3.RAG就是可以理解为拓展提示词,在本来大模型的基础上提供了一个外接的知识库,可以把用户的对话,拿到外接知识库里面,检索知识片段,组成更丰富和专业的提示词
4.fine-tuning 就是在本来大模型的基础上,进行模型微调,通过企业自身的数据,进行专项训练,可以训练出更符合自己企业的大模型,用来开发大模型应用
PS.补充八股
1.谈谈cookie、session、token、JWT
Cookie
简单来说,就是客户端在发送登录请求给服务器之后,服务器生成一段字符串给客户端保存,下次访问网站时,客户端通过这个字符串来识别该用户,省去了登录的麻烦
Session
和Cookie不同的是,把登录信息保存在了服务器,采用键值对的结构,生成一个key与登录信息关联,把key返回给浏览器作为Cookie。下次请求时,浏览器把key传给服务器,服务器通过key获取保存在服务器上的的登录信息,这就是Session,Cookie保存的其实是SessionID
Token
Cookie只在网页中存在,但是我们在小程序APP里面也需要保存登录信息,因此有了我们自己创建维护的Cookie,也就是Token,Token就是客户端保存的SessionId,名字我们也不用Cookie来表示,而是Authorization。但是如果把用户信息都存放在Session,也就是单个服务器里面,在分布式服务器下就会出现问题,因此,一般都会把会话信息放到Redis这种共享的服务器上
JWT
全称叫json web token
如果把登录信息放到Redis里面,依赖性就会太强,如果Redis出现问题,会导致分布式服务器集群全部失效,同时,一些边缘化的服务用不到Redis。
因此,引入了JWT,JWT就是一种无状态的Token,和我们之前的方式不同,意思是不再把登录信息保存在服务器上,而是把登录信息通过JWT的方式存储到客户端
JWT由三个部分组成
Header:头部
保存令牌类型:jwt 和签名的算法:HS256
Payload:载荷
存放部分不关键的用户信息
Signature:签名
将Header和Payload经过Base64编码之后,加上程序员自己提供的秘钥,两者通过Header设置的签名算法进行签名,形成Signature
这三部分组成了JWT,也就是Token
验证JWT的流程:
在登录之后,会把这个信息返回给客户端保存,以后的请求都会包含这个信息。
后续收到请求的时候,会对JWT进行拆分,拆分成三部分
对头部和载荷进行解码,然后根据头部信息指定的算法,将解码后的信息与服务器自己保存的秘钥进行签名,然后把签名与JWT中拆分得到的签名比较,如果一致,证明有效
然后检查载荷信息,例如JWT是否过期
总结:
其实会发现,JWT本质上就是一个升级版的自定义的Cookie,相比而言,JWT有秘钥的存在,比Cookie更加安全,而且支持移动端。同时,把登录信息保存在服务器也解决了集群环境下的认证问题,同时,对服务器的依赖也没有使用Redis缓存那样强。
但是其实可以发现,验证的关键,从过去验证Session和Redis,变成了对秘钥的验证,那么秘钥也会出现集群模式下的不共享问题,对此的解决方案是使用专门的秘钥管理系统
2.基于Redis的Stream数据结构实现的消息队列问题
基本流程:
1.创建一个Stream类型的消息队列
2.在lua脚本中添加逻辑业务,把订单信息传给消息队列
3.创建一个线程池,让当前类初始化之后,就开启一个线程去不断获取消息队列的信息
4.异步通过消费者组获取Stream里的订单信息,获取成功后,异步更新数据库的数据,并且手动ACK,表示当前信息已经被处理
5.如果获取订单信息的中途出现了异常,那么需要到pending—list里面去处理数据
6.pending—list是Redis设计的,用来存放那些已经被消费但是未确定的消息的表,也就是没有ACK的消息都会存放在pending列表里
7.循环处理pending列表一直到没有数据之后,退出循环,继续获取消息队列的数据
Stream实现消息队列的可靠性问题:
1.生产者会不会丢消息
不会,因为把订单信息存入Redis的这个操作是放进lua脚本的,保证了原子性,如果出现问题,在主线程就会反应给前端,显示库存不足或重复下单
2.消费者会不会丢消息
也不会,因为Stream提供了消费者确认机制,在处理完Stream里面的消息之后,都会进行手动ACK的操作,也就是告诉Redis,这条消息已经处理过了,如果中途发生异常,没有进行ACK,会把这个消息放入pendinglist里面,同时,会通过循环优先处理pendinglist里面之前没有处理过的消息,然后再去重新处理新的消息
3.中间件会不会丢消息
答案是会,这也是为什么需要使用RabbitMQ替换Stream的地方,因为Redis不管是在RDB还是AOF的持久化配置中,都会存在数据丢失的可能,AOF默认刷盘策略是everysec,有可能丢失1s的数据,并且Redis集群模式下,主从复制也是异步的,也有丢失数据的风险
4.面对消息积压的情况
Redis保存数据在内存上,如果消息队列出现积压情况,对内存资源压力会很大,但是RabbitMQ等中间件是把消息存放在硬盘上的,内存压力会小很多
但是实际开发的选择还是要根据公司和项目的情况,如果说业务场景足够简单,对数据丢失不敏感,并且消息量不大,Redis的消息队列更轻量一点,也是可以用的
3.RabbitMQ实现超时订单取消的原理
底层是调用了RabbitMQ实现的延迟队列功能
延迟队列一般使用场景:超时订单、限时优惠、定时发布
实现原理:死信交换机+TTL(消息存活时间)实现
死信交换机原理:
死信的出现情况:
1.消费者使用basic.reject或者basic.nack声明消费失败,并且消息的requeue参数设置为fasle,表示消息被拒绝后不会重新入队
2.消息是过期消息,超时无人消费
3.要投递的队列消息满了,最早的消息可能成为死信
如果该Simple队列,配置了dead-letter-exchange属性,指定了一个交换机为死信交换机,那么当前队列的死信就会投递到这个死信交换机当中,同时死信交换机也需要绑定一个dead-letter-routing-key,指向一个专门处理死信的队列
实现延迟队列还有一个关键点就是TTL的设置
设置TTL可以在两个地方,一个是消息本身设置了TTL,另一个是消息所在队列设置了TTL,如果都设置了,取更短的作为TTL
还有一种实现延迟队列的方式,就是安装一个RabbitMQ的插件DelayExchange,声明交换机的时候,添加一个delay的属性,设置为true,发送消息时,设置一个x-delay的属性为超时时间即可
4.Java的特性特点
面向对象
封装:简单来说就是把属性和方法的代码绑定到类上,只允许类对象操作,对外隐藏细节
继承:使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展
多态:父类引用指向子类型的对象,实现接口重用
封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块,多态的作用,就是为了类在继承和派生的时候,保证使用“家谱”中任一类的实例的某一属性时的正确调用。
平台无关性
通过编译Java代码到中间形式(字节码)实现的,然后由JVM在任何平台上解释执行。这使得Java可以跨平台开发
简单性
语法类似于C++,但去除了一些容易引起错误的特性,如指针和运算符重载。
Java还提供了自动内存管理和垃圾回收机制,减轻了程序员的内存管理负担。
Java提供了丰富的标准类库和工具,同时可以引入各种框架和依赖
5.堆栈的区别
1、栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
2、栈内存是线程私有的,而堆内存是线程共有的。
3,、两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
6.Redis和MySQL的区别
7.排序算法
排序的指标
时间复杂度
空间复杂度
排序的稳定性:
对于相同元素,排序之后相对位置不发生改变
举个例子:同一个商品id,生产日期不同,假设我们已经按照生产日期排好序,那么我们再按照商品id排序,那么稳定排序算法,就会让相同id聚在一起,并且生产日期有序排列
原地排序:指的是排序是否需要额外的辅助空间
7.1 选择排序
概念:从未排序的区域里,选择最小的元素,放到排序区的末尾
时间复杂度:n^2
空间复杂度:1
稳定性:不稳定排序算法
原地排序:是
7.2 *冒泡排序
概念:重复地遍历待排序的列表,比较相邻的元素并交换它们的位置,每一轮都让最后一个成为最大/最小的元素
时间复杂度:n—n^2
空间复杂度:1
稳定性:稳定
原地排序:是
7.3 *插入排序
概念:将arr[i]与前面的元素比较,前面的元素比他大则前面的元素向右移动,比他小则在该元素的后面插入
时间复杂度:n—n^2
空间复杂度:1
稳定性:稳定
原地排序:是
原数组越有序,效率越高
7.4 希尔排序
概念:设置一个gap为初始分组数量,将数组分为gap个组,每组单独进行插入排序,然后缩小gap,直到gap=1时,进行最后一次排序
时间复杂度:不好计算,因为gap的取值方法很多
空间:1
稳定性:不稳定
7.5 *快速排序(最常用)
概念:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
时间复杂度:nlogn
空间复杂度:logn
稳定性:不稳定
具体步骤:
选定一个基准值,最好选定最左边或者最右边,选中间会给自己找麻烦。
确定两个指针left 和right 分别从左边和右边向中间遍历数组。
如果选最右边为基准值,那么left指针先走(这里很关键,如果选最左边为基准,就需要right指针先走)
left指针如果遇到大于基准值的数就停下来。
然后右边的指针再走,遇到小于基准值的数就停下来。
交换left和right指针对应位置的值。
重复以上步骤,直到left = right ,最后将基准值与left(right)位置的值交换。
7.6堆排序
特点:基于完全二叉树,分为大根堆和小根堆,大根堆就是每个节点的值都大于等于其孩子结点,小根堆就是每个节点的值都小于等于其孩子结点
排序方式:升序用大根堆,降序用小根堆
每次取出堆顶元素:最大值/最小值,然后将末尾的数放入堆顶,然后调整堆,让堆顶重新变为最大/最小值
特点:无序中选择最大最小值变得简单
7.7归并排序
7.8计数排序
8.如何判断问题是前端还是后端
1xx :接收的请求正在处理
100:客户端应继续请求
101:切换协议
2xx:成功
200:请求成功
201:创建成功并创建新的资源
202:已接收请求但是未处理完成
204:服务器成功处理但无返回内容
3xx:重定向状态码
301:永久重定向
302:临时重定向
4xx:客户端错误(前端出错)
400:请求语法出错
401:需要认证/认证失败
403:被服务器拒绝,禁止访问
404:无法找到请求资源
405:请求方法出错
5xx:服务器错误(后端出错)
500:服务器内部错误,检查代码
503:服务器过载或维护
9.进程和线程的定义
进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程是线程的容器。
线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
10.MySQL的存储引擎
常见的有三种:
1.默认的存储引擎InnoDB
最大的特点是提供了ACID兼容的事务机制,支持外键约束、支持崩溃修复能力和并发控制。一般适用于对事务完整性要求较高,例如银行,还有要求并发实现,例如收票等业务。InnoDB底层使用的数据结构是B+树,提供了良好的索引性能
2.MyISAM
MySQL最早提供的存储引擎,又分为静态、动态、压缩MyISAM。默认是静态的,特点是表中的字段都是非变长字段,这样每个记录都是固定长度的,存储就会非常迅速,插入数据间和内存使用较低,主要用于插入记录和读出记录,效率较高
3.MEMORY
特点是数据都存储在内存中,数据处理得较快,安全性不高。对表的大小有要求,一般只能用于小表
11.spring实现自动装配
1.通过xml文件
在xml配置文件中的bean标签中加入一个属性autowire即可,其中autowire属性可以是bytype和byname
byname就是去找实体类中set方法后面的名字
bytype是自动寻找一个类型对应的bean
2.我们现在常用的都是通过注解
@Autowired注解+@Qualifier注解 spring提供的
前者是根据类型匹配,后者是配合前者,出现多个相同类型,再根据名字匹配
@Resource注解
优先根据名字匹配,是java规范的
12.锁升级机制
java中的锁一般有两种
1.synchronized
有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁:底层使用的Monitor实现,monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,monitor维护了3个变量:WaitSet、EntryList、Owner。其中Owner用来关联获取到锁的线程,EntryList是保存处于阻塞状态的线程,WaitSet是保存代码块中调用了wait方法的线程。上锁过程中,有其他线程来抢锁,就会进入阻塞状态,在锁释放后,就会唤醒EntryList中等待的线程去竞争锁,竞争是非公平的
轻量级锁:线程加锁的时间是错开的(也就是没有竞争)的情况用轻量级锁,底层是通过每次获取锁都进行CAS判断,修改对象头的锁标识,一旦CAS判断获取锁失败,即发生竞争,就会升级为重量级锁
偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁。相对于轻量级锁,只有第一次获取锁时会进行CAS操作,将线程的ID保存到mark word当中,后续只需要判断mark word中的id是否是自己的,不用重复太多次CAS判断
同样,一旦发生锁的竞争,都会升级为重量级锁
2.Lock
java SE 5 之后新增的一个接口,底层是java自己实现的,特点是一个显示的锁,相对于jvm提供的synchronized隐式的获取释放锁,提供了trylock和unlock方法,可以让程序员手动显式地进行获取释放锁
同时还提供了很多synchronized不具备的功能,例如获取等待状态、公平锁、可打断、可超时,还有许多实现类,例如最常用的Reentranklock,但是这些实现类的底层逻辑是通过aqs构建的
AQS
全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架
详谈syn锁
syn锁的起源是解决CPU和内存速度不匹配的问题,内存速度相对CPU来说太慢了,CPU设置了三级缓存,一级二级CPU独享,三级共享。当CPU执行一个线程任务将数据保存到缓存当中时,另一个线程去主存拿到了旧数据,就产生可见性问题,另外CPU或者编译器可以出现指令重排序,就出现有序性问题,还有同一段代码或者说数据可以被多个线程同时访问,就出现了原子性问题。因此有了syn锁的产生
syn锁,本质是jvm底层通过cpp语言编写的,调用的是操作系统底层原语mutex。加锁之后,加锁的代码只有一个线程可以执行,保证原子性,同时编译后写数据调用monitor enter方法,读数据调用monitor exit方法,分别添加了读屏障和写屏障,读屏障保证读到的数据都是主存中的最新数据,写屏障保证写入的数据都刷新到了主存当中,由此保证了可见性。同时syn还采用内存屏障的方式保证了数据的有序性,至此就解决了并发环境下的三个关键问题
然后谈谈锁升级机制,我们知道,syn底层cpp代码编写,执行的是操作系统的底层原语mutex,Java当中的线程是一对一对应操作系统的内核线程的,也就是说,每次切换线程,都会让操作系统从用户态切换到内核态,效率极低。因此有了偏向锁和轻量级锁的诞生。两种锁都是适用于锁竞争不强的情况,实际项目场景中,锁竞争的情况其实是很少出现的。锁升级的过程就是,当一个线程去抢锁,最开始就是从偏向锁开始,它会把自己的线程id保存到对象头的mark word当中,此后这个线程获取锁就只需要对比线程id即可,不需要进行其他操作。如果此时有另一个线程来抢锁,如果获取锁失败,那么会升级为轻量级锁,也就是通过cas加自旋锁去不断获取这个锁。轻量级锁主要是适用于竞争力度小或者说几个线程轮流获取锁,然后获取锁时间不长的情况。因为自旋锁的时间过长会导致CPU占用过高,采用自旋锁的方式也是为了短时间内不让操作系统用户态到内核态,避免开销。当自旋锁自旋次数到达上限之后,还没有获取到锁,或者说有更多的锁来竞争的时候,就会升级到重量级锁了
偏向锁和轻量级锁一个是通过记录线程id一个是通过cas加自旋锁,操作都是在用户态,不需要操作系统切换到内核态,因此性能较好。重量级锁就需要操作系统切换到内核态,因此性能较差。而使用重量级锁,对象头的Mark word指针就会指向monitor锁监视器了。monitor维护了4个属性,owner,entrylist,waitset还有一个计数器。owner用来指向获取锁的线程,entrylist用来存储没有抢到锁阻塞的线程,waitset用来保存调用了wait方法,放弃竞争,等待唤醒的线程。而计数器呢就是用来实现可重入锁的,重入一次,计数器就加一次,释放一次减一次,减到0就释放锁。以上就是对syn锁的一个整体了解
13.多线程转账业务手撕
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class BankTransferWithThreadPool {
// 主方法,程序的入口点
public static void main(String[] args) throws InterruptedException {
// 创建两个银行账户,初始余额分别为1000和2000
BankAccount account1 = new BankAccount(1000);
BankAccount account2 = new BankAccount(2000);
// 创建一个固定大小的线程池,大小为2
ExecutorService executorService = Executors.newFixedThreadPool(2);
// 提交转账任务到线程池
executorService.submit(() -> transfer(account1, account2, 500)); // 从account1转账500到account2
executorService.submit(() -> transfer(account2, account1, 300)); // 从account2转账300到account1
// 关闭线程池,不再接受新任务,但已提交的任务会继续执行
executorService.shutdown();
// 等待线程池中的所有任务完成执行,最多等待1小时
executorService.awaitTermination(1, TimeUnit.HOURS);
// 打印最终的账户余额
System.out.println(account1);
System.out.println(account2);
}
// 转账方法,从from账户转账amount金额到to账户
private static void transfer(BankAccount from, BankAccount to, int amount) {
// 通过比较两个账户对象的哈希值来决定锁定的顺序,避免死锁
BankAccount first = (from.hashCode() > to.hashCode()) ? from : to;
BankAccount second = (from.hashCode() > to.hashCode()) ? to : from;
// 锁定第一个账户
synchronized (first) {
// 锁定第二个账户
synchronized (second) {
// 检查源账户是否有足够的余额
if (from.getBalance() >= amount) {
// 从源账户取款
from.withdraw(amount);
// 向目标账户存款
to.deposit(amount);
// 输出转账信息
System.out.println(amount + " transferred from " + from + " to " + to);
} else {
// 如果余额不足,输出错误信息
System.out.println("Insufficient funds in " + from);
}
}
}
}
// 银行账户类
static class BankAccount {
private int balance; // 账户余额
// 构造方法,初始化账户余额
public BankAccount(int initialBalance) {
this.balance = initialBalance;
}
// 存款方法
public void deposit(int amount) {
balance += amount;
}
// 取款方法
public void withdraw(int amount) {
balance -= amount;
}
// 获取账户余额
public int getBalance() {
return balance;
}
// 重写toString方法,方便打印账户信息
@Override
public String toString() {
return "BankAccount{" +
"balance=" + balance +
'}';
}
}
}
14.动态代理
JDK动态代理和CGLIB动态代理
jdk底层使用反射机制实现、只能代理实现了接口的类
CGLIB底层是操作字节码生成目标类的子类
可以代理未实现接口的类,底层是通过继承目标类来实现代理的,因此不能对final方法进行代理
jdk动态代理创建对象开销小,但是性能较低
CGLIB动态代理创建对象开销较大,性能高
15.zset底层原理
zset就是SortedSet
底层数据结构分为压缩表和跳表
键值对小于128,元素大小小于64的时候,使用压缩表,不符合条件就使用跳表
压缩表
底层是把节点紧挨到一起的压缩列表中保存,实质是双向链表,按照score值排序,查询增删复杂度为On
跳表
本质是一个多层链表,查询增删效率为logn
跳表的最底层也是原始链表层,拥有所有元素,往上分为一级索引层,二级索引层,以此类推
每插入一个元素,会随机生成一个层次数字,该元素会插入到这个层次以及其所有底层,一直到原始链表层
这样导致一个元素如果存在于n级索引层,那么这个元素就会存在于n-1,n-2一直到原始链表层
这是一种空间换时间的概念,这样跳表的查询效率提高了,但是消耗了更多内存空间
redis跳表的结构由头尾节点,跳表节点数量,以及当前最高层数组成
redis当中的跳表节点底层数据结构比较特殊,我在没有查看源码之前,以为同一个数据,不同层数的节点是靠指针连接的,查看源码之后,发现底层其实是靠一个level数组连接的,也就是说,不同层数的同一份数据,之间没有指向关系,而是通过数组下标连接起来的,level0就是原始层。每个跳表节点里面,都有一个数组,数组里面保存的是每一层当前结点的前进指针,指向下一个节点,还保存了一个span值,用来保存当前层这个节点到下一个节点的距离
了解这个之后,我还去看了插入数据的具体操作源码,在插入新节点时,第一步是通过从最高层开始逐层遍历计算update数组和rank数组,update数组的作用是计算每层新节点的上一个节点,rank数组是计算每层update节点到头节点的距离
简单来说,update节点的作用是用来确定新结点的位置,或者说level数组里的前进指针,而rank数组的作用是来计算level数组中的span值。我们知道,原始链表保存了所有数据,也就是说插入新节点的时候,原始链表的update节点肯定与新节点的距离为1,因此level[i]也就是i层的新节点的span值就可以通过rank[0]-rank[i]+1的方式计算得到,这就是rank的作用
在遍历计算完update和rank数组之后,才会随机生成层数,需要注意,redis当中,跳表的最大层数限制为32,同时,redis底层生成层数的随机函数限制了随机系数为0.25,意思是n层的概率是n+1层的4倍,保证了低层的数量高于高层,维护了整体结构
如果随机层数大于目前跳表最高层,就需要去更新一下update和rank数组,新的高层里面,update就是头节点,rank就是0
然后就是插入新节点就行,然后更新一下未涉及到的层的节点的span值,再设置下新节点和新节点下一个节点的后退指针
最后更新跳表节点个数
16.==和equals的区别
==用于比较的是,两个对象的内存地址
equals方法默认与==一样,是比较内存地址,但是equals方法可以重写
例如String类就重写了equals方法,比较的是两个字符串的内容是否相同,而不是比较其内存地址
17.输入网址到显示的整个过程
1.输入网址
2.DNS解析获取域名对应的IP地址
3.三次握手建立TCP连接
4.浏览器发送HTTP请求给服务器
5.服务器接受请求,然后返回一个HTTP响应给浏览器
6.浏览器构建DOM树和CSS树,然后根据DOM和CSS树构建渲染树,根据渲染树布局渲染页面
7.四次挥手断开连接
18.static、final、this、super关键字
1.static
可以修饰成员变量、方法、常量、类,被修饰的变量和方法被称之为静态变量和静态方法、被static修饰的类一定是静态内部类等
特点是:被static修饰的方法和变量,不依赖于当前类的任一对象,是所有实例对象共享的
并且,静态方法和属性变量的调用,不需要new对象,可以直接通过类名访问
2.final
final关键字的含义是:最终 特点是不可被修改
可以修饰属性、方法、类
修饰的属性变为常量,需要初始化且不可被修改
修饰的方法无法被重写
修饰的类也无法被继承
3.this
与super相对应,代表当前对象的引用,访问当前类的成员变量、方法或其他构造方法
不过现在很多时候,this都可以省略,加上this可读性更强
4.super
super主要是用于在子类中引用父类的成员变量、方法或构造函数
super.用于访问父类的成员变量和方法
super()用于调用父类的构造方法
19.String、StringBuffer、StringBuilder
首先,先把String和后两个分类
String是不可变的字符串对象,这个类是被final修饰过的,特点是创建之后不可变,如果对其进行修改,本质上并不会改变原有对象,而是创建一个新的对象。
这种类型适合创建一个固定的字符串,如果要对其进行频繁修改,那么会创建多个对象,影响性能
StringBuffer和StringBuilder都是可变字符串类型
两者都是AbstractStringBuilder的子类,底层其实是维护了一个字符类型的数组
最大的区别是StringBuffer是线程安全的,因为这个类的方法上都加了syn关键字,StringBuilder没有
Buffer适用于多线程环境下创建一个会被频繁修改的字符串,Builder适用于单线程环境,因为没有syn修饰,性能要高于Buffer
20.equals和hashcode
equals相等,hashcode一定相等
hashcode相等,equals不一定相等
equals默认比较的是内存地址,内存地址相等,表示两个对象是相同的,hashcode一定相等
hashcode相等,有可能是两个不同内存地址对象发生hash冲突导致hashcode相等,因此equals不一定相等
为了保证第一条规律,重写equals方法的时候,一定也要重写hashcode方法,不然就会造成,两者equals相等(或者说被我们认作相同的对象),但是hashcode不等,这样存储在哈希表中的位置就不一样了,违反了哈希表的唯一性原则
21.异常和错误的区别
异常:Exception
错误:Error
Error错误,是程序无法处理的,我们无法提前预料的,一般来说,都是虚拟机出现的问题,例如堆内存溢出的OutOfMemoryError,栈内存溢出的StackOverFlowError
Exception异常,是程序可以处理,我们可以提前预料,对其进行捕捉和向上抛出的。
又分为运行异常和编译异常
运行异常是运行时产生的异常,也是通常我们遇到的异常,常见的有算术异常,空指针异常、数组下标越界异常、类型转化异常等
编译异常指的是编译时发生的异常,常见的类似超时、文件读取找不到等异常,特点是如果不处理,程序无法编译
除了这两种分类外,还可以分为检查异常和非检查异常,检查异常和编译异常类似,非检查异常包含运行异常和错误,他们的出现主要是因为,spring的事务机制,默认只能捕捉非检查异常,对于编译异常不会进行捕捉
22.ABA问题
CAS的典型问题
CAS作为一种乐观锁的思想,底层是先比较内存中的值和我们预期的值是否相同,再进行数据修改
但是会出现一个问题,线程1的期望值为A,想要对其进行修改,但是期间线程2先将这个A改成了B,再修改回了A,此时,线程1进行CAS操作也能正常通过判断。
举个简单例子,用户余额有100元,多线程操作下,会安排线程去扣除50元,线程1检查余额为100之前,其实线程2已经扣除过50了,但是又并发操作又转进去50,导致此时余额还是100,线程1就会进行重复扣款的操作
解决方法:Java中提供了版本号和标记两种方式解决ABA问题
23.缓存雪崩的预警
监控缓存命中率、缓存过期事件的频率、以及数据库的请求量
设置阈值,如果超出阈值就进行预警,记录到日志里面,记录触发时间,触发条件,此时缓存命中率、过期事件频率还有数据库请求量
24.如何追踪预警hotkey
1.在客户端进行统计key的调用情况
2.利用redis4.0之后的添加的hotkeys查找特性,redis cli hotkeys指令就可以获取当前的热点key
3.插件进行TCP抓包
25.创建实例的过程
String s = new String("abc");
这行代码jvm中的过程
1.检查常量池
jvm检查字符串常量池中是否存在"abc"
如果存在,直接将常量池的引用赋值给s
如果不存在,那么在常量池中新建一个字符串对象为“abc”
2.堆内存分配
因为有new,因此jvm会在堆内存中为String对象分配一个新的内存空间
3.初始化
初始化String对象,并将常量池中的 "abc" 复制到这个新的对象中
4.引用赋值
将新创建的对象引用赋值给s
所以说这行代码其实是创建了两个对象,一个堆内存对象,一个常量池对象
26.方法重载和方法重写的区别
方法重写:
指的是子类继承父类之后,对父类允许子类访问的方法,进行个性化的改写,但是返回值和形参都不能进行改变。
方法重载:
指的是在一个类里面,写多个方法名相同的方法,但是参数不同,返回类型可以相同,也可以不同
简单来讲,方法重写是发生在父类和子类之间的,让子类可以自定义继承到的父类方法。方法重写,是发生在同一个类当中的,针对某个相同的方法,根据不同的参数,走不同的代码块。使用最广泛的就是构造方法的重载
834

被折叠的 条评论
为什么被折叠?



