黑马面试课程

准备篇

企业筛选简历的规则

HR如何筛选简历

学历、院校、经验、年龄、跳槽频率

部门负责人筛选

  • 符合当前项目的技术栈
  • 符合业务条件(比如做过银行、电商、物流)
  • 额外加分项
    • 有高可用高并发经验优先
    • 熟悉基于公有云的开发经验
    • 有团队管理经验

简历注意事项

  • 简历整体结构
  • 个人技能该如何描述
  • 项目该如何描述

简历整体结构

一份完整的简历应该包含如下几个部分:

  1. 基本信息
  2. 教育背景
  3. 求职意向
  4. 工作经历
  5. 职业技能
  6. 项目经历
  7. 个人优势
  8. 个人荣誉

第一到第四点在真实的前提下,可以适当的美化、第五和第六点是重点被问到的、第七第八点不要过于夸张、关键在于难点,亮点。如果没有可以不写。

职业技能

  • 放到简历的黄金位置、第一页中间或中下部分(HR刷选简历的重要参考)
  • 基本准则:写在简历上的必须能聊,不然就别写
  • 参考公式:职业技能=必要技术+其他技术
  • 针对性的引导面试官(让他问一些你想让他问的)
必要技术+其他技术
  • 1-2年(springboot+ssm+redis+数据库)+(2-3)技术(微服务、ES、MQ、源码、高并发、jvm、技术选型、设计能力)
  • 3-5年(springboot+ssm+redis+数据库)+(3-4)技术(微服务、ES、MQ、源码、高并发、jvm、技术选型、设计能力
  • 5年以上(springboot+ssm+redis+数据库)+(5+)技术(微服务、ES、MQ、源码、高并发、jvm、技术选型、设计能力

项目经历

  1. 项目个数以自己的工作经历为准,时间比较久的可以只写标题或不写(面试官一般也不问)
  2. 项目要体现业务深度或技术深度
  3. 有没有主导设计过xx模块的开发(0-1开发或1-2的改造)
  4. 尽可能展示指标数据(如达到了多少QPS、达到了多少数据量)

如何找到练手的项目

项目来源

在gitee或github上搜索比较感兴趣的技术点或者业务点。

  • 初级要求

    本地快速运行起来,debug跟踪代码逻辑梳理完业务之后,自己能否独立完成。

  • 中高级要求

    找到一、二点深度挖掘,多方位参考

    • gitee或github其他相关项目
    • b站搜索其他项目
    • 博客文章

应该学习那些模块

目标:增加简历的项目模块业务深度、技术含金量、真实度

image-20240218134410845

模块该如何吃透(比如权限认证)

  1. 功能实现

    • 业务功能实现:用户密码登录,二维码登录,手机短信登录,用户、角色、权限管理和分配
    • 技术方法支撑:RBAC模型、SpringSecurity或Apache Shiro
  2. 常见的问题

    token刷新问题、加密、解密、XSS防跨站攻击

  3. 权限系统设计

    可扩展性、高可用性、通用性

JAVA程序员的面试过程

面试形式

企业在招聘的时候,不同公司面试的轮次不太一样

  • 单轮面试

    只有技术面试。中小企业、创业型公司、外包

  • 多轮面试

    • 两轮:第一、二轮技术面(大部分公司)
    • 三轮:一、二轮技术面、HR终面(上市公司、大厂)
    • N轮

面试官角色

  • 资深开发人员(技术经理)

    技术最好,多数参与首轮面试

  • 业务部门经理

    技术一般,多参与终面,可以决定你的薪资(思考能力、抗压能力)

  • HR

    辅助业务部门考察候选人(性格、沟通能力、合作能力、学习能力)

面试过程

面试最重要的是:准备+复盘

整体讲解结构:总分结构表述

  1. 自我介绍

    我叫什么,我的工作经历主要分为3个阶段。。。。

  2. 项目介绍

  3. 具体模块

  4. 业务深度

  5. 技术实现

如何准备面试

找出自己的不足,针对性补强

求其上,得其中;求其中,得其下;求其下,必败

​ 如果你想进中厂,就要做进大厂的准备。如果你想找到月薪1W+的工作,就需要做月薪1W5+的准备。如果你的目标就是找到工作,起码要做冲击中小厂的准备。如果你的目标就是找个小公司混日子,大概率找不到工作。

Redis篇

image-20240218152226751

  • 使用场景
    • 缓存(穿透、击穿、雪崩、双写一致、持久化、数据过期、淘汰策略)
    • 分布式锁(setnx、redisson)
    • 计数器
    • 保存token(使用String类型)
    • 消息队列(list集合)
    • 延迟队列(zSet集合)
  • 其他面试题
    • 集群(主从、哨兵模式、集群)
    • 事务
    • Redis为什么那么快

使用场景

一般问:你最近在项目的那些场景使用了redis,原因是

  1. 验证你的项目场景的真实性
  2. 作为深入发问的切入点

缓存穿透

假设有个get请求:api/news/getById/1

正常获取数据的流程如下

  1. 请求根据id查询文章接口
  2. 接口逻辑为根据id查询redis
    1. 如果查询到了,则返回结果
    2. 如果redis没查询到,就去查询数据库,并将查询到的结果存入redis中和返回结果

缓存穿透:就是查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库,这样就会增加数据库的压力

解决方案一:缓存空数据

缓存空数据、查询返回的数据为空,仍把这个空结果进行缓存{key:1,value:null}

优点:简单

缺点:消耗内存,可能会发生数据不一致的问题(假设redis存的为null,后面这个键有值的话,会导致数据不一致)

解决方案二:布隆过滤器

image-20240218155410956

加一个布隆过滤器,查询之前,先判断数据存不存在,如果不存在的话就直接拦截。

注意在缓存预热时,需要预热布隆过滤器(就是将一些数据放在缓存中后也要放入布隆过滤器中)

优点:内存占用少、没有多余的key

缺点:实现复杂,存在误判

布隆过滤器介绍
  • bitmap(位图)

    相当于是一个以(bit)位为单位的数组,数组中每个单元只能存储二进制数0或1

  • 作用

    可以用于检索一个元素是否在一个集合中

  • 存储数据

    假设id为1的数据,通过多个hash函数获取hash值,根据计算后的hash值,将数组对应的位置由0改为1

  • 查询数据

    使用相同的hash函数获取hash值,判断数组对应位置是否都为1

image-20240218160238084

  • 误判率

    假设存入三个数据,前两个数据是正常存入,第三个存入数据计算的hash值可以在第一第二个的数据计算的hash值中找到,这样去判断的话,第三个值也存在布隆过滤器中,这就是误判率。

    数组越小,误判率就越大,数组越大误判率就越小,但是同时带来了更多的内存消耗

    image-20240218160358427

缓存击穿

给某一个热点key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发请求可能瞬间把DB压垮

方案一:互斥锁

  • 强一致性
  • 性能差

image-20240218164503769

方案二:逻辑过期

不设置key的过期时间,在缓存中新增一个逻辑过期字段,根据这个时间去判断过不过期

  • 高可用
  • 性能优,不能保证数据绝对一致

image-20240218164645312

缓存雪崩

在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

解决方案:固定时间+随机时间

  • 给不同的key的过期时间添加随机值

  • 利用Redis集群提高服务的可用性(哨兵、集群模式)

  • 给缓存业务添加降级限流策略(Nginx或getway设置限流规则)

    降级可作为系统的保底策略,适用于穿透、击穿、雪崩

  • 给业务添加多级缓存(可以使用Guava或Caffeine作为一级缓存,redis为二级缓存)

总结

《缓存三兄弟》

穿透无中生有key,布隆过滤null隔离

缓存击穿过期key,锁与非期解难题

雪崩大量过期key,过期时间要随机

面试必考三兄弟,可用限流来保底

双写一致性

image-20240220165530138

当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要怎么才能保持一致?

适用问题:redis做为缓存,mysql的数据如何与redis进行同步呢?

一定、一定要设置前提,先介绍自己的业务背景

一致性要求高

延迟双删(不被使用)

删除缓存时,是先删除缓存还是先修改数据库

  • 上述两种删除方式都会造成数据的不一致

造成原因是:线程是交替执行的

  • 先删除缓存,在操作数据库

    image-20240218171301848

  • 先操作数据库,在删除缓存

    image-20240218171440646

为什么要删除两次缓存

​ 先删除缓存,在操作数据库,有可能导致缓存和数据库的数据不一致,所以在更新数据之后,再去删除缓存一次。

为什么要延时删除

因为数据库是主从模式的,所以我们要延迟一会,要主节点把数据同步到从节点,但是不确定具体的延迟时间,所以延迟双删只是极大的控制了脏数据的风险,做不到绝对的强一致性的

读写锁(使用)

​ 我们从redis中获取数据一般都是读多写少,所以可以通过加读写锁的方式去保证redis中数据的强一致性,但是这种方式性能会比较低。

image-20240220155952631

  • 共享锁

    读锁readLock,加锁之后,其他线程可以共享读操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public Item getById(Integet id){
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("TIME_READ_WRITE_LOCK");
    //读之前加读锁,读锁的作用就是等待该lockkey释放写锁以后再读
    RLock readLock = readWriteLock.readLock();
    try{
    //开锁
    readLock.lock();
    Item item = (Item) redisTemplate.opsForValue().get("item:"+id);
    if(item !=null){
    return item;
    }
    //查询业务数据
    item = new Item(id,"华为手机","手机",5999.00);
    //写入缓存
    redisTemplate.opsForValue().set("item:"+id,item);
    //返回数据
    return item;
    }finally{
    readLock.unlock();
    }
    }
  • 排他锁

    独占锁writeLock,加锁之后,阻塞其他线程读写操作。

    其实排他锁底层使用也是setnx,保证了同时只能有一个线程操作锁住的方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public void updateById(Integer id){
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
    //写之前加写锁,写锁加锁成功,读锁只能等待
    RLock writeLock = readWriteLock.writeLock();
    try{
    //开锁
    writeLock.lock();
    //更新业务数据
    Item item = new Item(id,"小米手机","手机",5299.00);
    try{
    Thread.sleep(10000);
    }catch(InterruptedException e){
    e.printStackTrace();
    }
    //删除缓存
    redisTemplate.delete("item:"+id);
    }finally{
    writeLock.unlock();
    }
    }

注意:读方法和写方法上需要使用同一把锁才行。

数据最终一致

MQ异步通知

​ 当去修改数据库数据时,会发送一个异步通知到消息队列,在根据消息队列的数据去更新缓存中的数据,这样去保证数据的最终一致性。

image-20240220164950719

Canal异步通知

​ 原理:它是把canle作为mysql的从节点,通过数据库的binlog文件去更新缓存,这种好处是无侵入性的,不需要改变代码。

​ 二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句。

image-20240220165118701

Redis持久化

RDB

​ RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。

备份方式

​ 可以在redis-cli窗口主动备份

  • save

    由Redis主进程来执行RDB,会阻塞所有命令

  • bgsave

    开启子进程执行RDB,避免主进程受到影响,一般推荐使用这个

Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:

1
2
3
4
5
6
7
8
# 900秒内,如果至少有1个key被修改,则执行bgsave
save 900 1

# 300秒内,如果至少有10个key被修改,则执行bgsave
save 300 10

# 60秒内,如果至少有10000个key被修改,则执行bgsave
save 60 10000
执行原理

​ bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入RDB文件。

​ fork采用的是copy-on-write技术

  • 当主进程执行读操作时,访问共享内存
  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作

image-20240220173336769

页表:记录虚拟地址与物理地址的映射关系

进程只能操作虚拟内存,物理内存可以是内存条

AOF

​ AOF全称为Append Only File(追加文件),redis处理的每一个命令都会记录在AOF文件,可以看做是命令日志文件。

  • AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF
1
2
3
4
#是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"
  • AOF的命令记录的频率也可以通过redis.conf文件来配置
1
2
3
4
5
6
#表示每执行一次写命令,立即记录到AOF文件
appendfsync always
#写命令执行完写放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
#写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
配置项刷盘时机优点缺点
Always同步刷盘可靠性高,几乎不丢失数据性能影响大
everysec每秒刷盘性能适中最多丢失一秒数据
no操作系统控制性能最好可靠性较差,可能丢失大量数据
  • 因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少得命令达到相同效果。

image-20240221105434456

  • Redis也会在出发阈值的时候自动去重写AOF文件,阈值也可以在redis.conf中配置

    1
    2
    3
    4
    # AOF文件比上次文件 增长超过多少百分比则触发重写
    auto-aof-rewrite-percentage 100
    # AOF文件体积最小多大以上才触发重写
    auto-aof-rewrite-min-size 64mb

RDB与AOF对比

RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用

RDBAOF
持久化方式定时对整个内存做快照记录每一次执行的命令
数据完整性不完整,两次备份之间会丢失相对完整,取决于刷盘策略
文件大小会有压缩,文件体积小记录命令,文件体积很大
宕机恢复速度很快
数据恢复优先级低、因为数据完整性不如AOF高、因为数据完整性更高
系统资源占用高,大量CPU和内存消耗低,主要是磁盘IO资源,但AOF重写时会占用大量CPU和内存资源
使用场景可以容忍数据分钟的数据丢失,追求更快的启动速度对数据安全性要求较高、常见

数据过期策略

​ Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称之为数据的删除策略(数据过期策略)

Redis的采用的过期策略:惰性删除+定期删除两种策略进行配合使用

惰性删除

​ 设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。

  • 优点

    对cpu友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查

  • 缺点

    对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放。

定期删除

​ 每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)

​ 定期清理有两种模式

SLOW模式

是一个定时任务,执行频率默认为10hz,每次不超过25ms,可以通过修改配置文件redis.conf的hz选项来调整这个次数

FAST模式

这个模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

优缺点
  • 优点

    可以通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响。另外定期删除,也能有效释放过期键占用的内存

  • 缺点

    难以确定删除操作执行的时长和频率

数据淘汰策略

​ 当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中数据删除掉,这种数据删除规则被称之为内存的淘汰策略。

Redis支持8种不同策略来选择要删除的key

  • noeviction

    不淘汰任何key,但是内存满时不允许写入新数据,默认就是这总策略

  • volatile-ttl

    对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰

  • allkeys-random

    对全体key,随机进行淘汰

  • volatile-random

    对设置了TTL的key,随机进行淘汰

  • allkeys-lru

    对全体key,基于LRU算法进行淘汰

  • volatile-lru

    对设置了TTL的key,基于LRU算法进行淘汰

  • allkeys-lfu

    对全体key,基于LFU算法进行淘汰

  • volatile-lfu

    对设置了TTL的key,基于LFU算法进行淘汰

LRU

​ Least Recently Used 最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。

LFU

​ Least Frequently Used 最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

使用建议

  1. 优先使用allkeys-lru策略。充分利用LRU算法的优势,把最近最常访问的数据留在缓存中,如果业务有明显的冷热数据区分,建议使用
  2. 如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用allkeys-random,随机选择淘汰
  3. 如果业务中有置顶的需求,可以使用volatile-lru策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据
  4. 如果业务中有短时高频访问的数据,可以使用allkeys-lfu或volatile-lfu策略

关于数据淘汰策略面试题

  1. 数据库有1000万数据,Redis只能缓存20w数据,如何保证Redis中的数据都是热点数据

    使用allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略,留下来的都是经常访问的热点数据

  2. Redis的内存用完了,会发生什么

    主要看数据的淘汰策略是什么,如果是默认的配置(noeviction),会直接报错。

分布式锁

通常情况下,分布式锁使用的场景:

集群情况下的定时任务、抢单、幂等性场景

在单服务器单体服务中一般就是添加互斥锁去保证多线程中数据的准确性,但是在服务集群部署中,就需要用到分布式锁了。

image-20240222115943840

Redis实现分布式锁主要利用Redis的setnx命令。setnx是SET if not exists(如果不存在,则set)的简写

  • 获取锁

    set lock value NX EX 10 (NX是互斥、EX是设置超时时间)

  • 释放锁

    DEL key(直接删除即可)

image-20240222144932378

分布式锁如何合理控制锁的有效时间

  • 根据业务执行时间预估
  • 给锁续期

redisson实现分布式锁

执行流程

加锁、设置过期时间等操作都是基于lua脚本完成,来保证锁的原子性

image-20240222145722575

  • 当线程1加锁成功时,会新增一个Watch dog(看门狗),它会每隔(过期时间/3)的时间做一次续期,一般是10s,这样就可以避免锁的有效期问题
  • 当线程2尝试去加锁,如果成功,就直接去redis取值,如果失败,会通过while循环一直去尝试加锁,当尝试到一定的次数时,就会停止,这样提升分布式锁的效率。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void redisLock() throws Exception{
//获取锁(重入锁),执行锁的名称
RLock lock = redissonClient.getLock("lock");
try{
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(10,TimeUnit.SECONDS);
//判断是否获取成功
if(isLock){
System.out.println("执行业务...");
}
}finally{
//释放锁
lock.unlock();
}
}
可重入

利用hash结构记录线程id和重入次数

image-20240222153811673

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void add1(){
RLock lock = redissonClient.getLock("lock");
boolean isLock = lock.tryLock();
//执行业务
add2();
//释放锁
lock.unlock();
}

public void add2(){
RLock lock = redissonClient.getLock("lock");
boolean isLock = lock.tryLock();
//执行业务
//释放锁
lock.unlock();
}
  • 当执行add1方法时,会在redisson中添加一个key为lock,value中field字段设置为当前线程id,value中的value设置为1(表示重入的次数)
  • 继续执行add2,会去redisson中查询该锁是否存在,存在则将value中的value该为2,执行完业务之后再释放锁value的值变为1
主从一致性

​ 当存在一个redis集群,数据通过主从复制同步

image-20240222160025572

  • 线程1获取分布式锁,然后redis主服务器崩溃,锁数据没有同步到从服务器,从服务器经过选举,变为主服务
  • 线程2从新的主服务器获取分布式锁,结果有两个名字一样的分布式锁

为了解决上述问题,可以使用RedLock(红锁):不能只在一个redis实例上创建锁,应该是在多个实例上创建锁(n/2+1),避免在一个redis实例上加锁

image-20240222161343941

但是不建议使用RedLock,因为实现复杂,性能差,运维繁琐,如果非要实现分布式锁的强一致性,可以使用zookeeper

Redis集群

  • 主从复制
  • 哨兵模式
  • 分片集群

主从复制

​ 单节点Redis的并发能力是有上限的,需要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离

image-20240223133848400

主节点一般用于写操作,从节点用于读操作,因为读数据会比较多,所以从节点会多一些。

主从数据同步原理

  • Replication Id

    简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid。

  • offset

    偏移量,随着记录在repl_backlog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。

主从全量同步

image-20240223135724703

  1. 从节点请求主节点同步数据(replication id 、offset)
  2. 主节点判断是否是第一次请求,是第一次就与从节点同步版本信息(replication id和offset)
  3. 主节点执行bgsave,生成rdb文件,发送给从节点去执行
  4. 在rdb生成执行期间,主节点会以命令的方式记录到缓冲区(一个日志文件)
  5. 把生成之后的命令文件发送给从节点进行同步
增量同步

​ 从节点重启或后期数据增量变化

image-20240223140048580

  1. 从节点请求主节点同步数据,主节点判断不是第一次请求,不是第一次就获取从节点的offset值
  2. 主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步。

哨兵模式

​ Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。哨兵的结构和作用如下:

image-20240223173323254

  • 监控

    Sentinel会不断检查您的master和slave是否按预期工作

  • 自动故障恢复

    如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主

  • 通知

    Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给你Redis的客户端。

服务状态监控

​ Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:

  • 主观下线

    如果某sentinel节点发现某实例未在规定时间内响应,则认为该实例主关下线

  • 客观下线

    若超过指定数量(quorum)的Sentinel都认为该实例主观下线。则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

哨兵选主规则(筛选+打分)

  • 首先判断主节点与从节点断开时间次数,如果超过指定阈值就排除该从节点(筛选)
  • 然后判断从节点的slave-priority值,值越小优先级越高(打分)
  • 如果slave-priority一样,则判断slave节点的offset值,offset值越大优先级越高
  • 最后是判断slave节点的运行id大小,id值越小优先级越高

分片集群结构

​ 主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决

  • 海量数据存储问题
  • 高并发写的问题

使用分片集群可以解决上述问题,分片集群的特征

  • 集群中有多个master,每个master保存不同数据(解决海量数据存储问题,有过个master也能解决高并发写的问题)
  • 每个master都可以有多个slave节点(解决高并发读)
  • master之间通过ping监测彼此健康状态(监控状态,类似哨兵模式)
  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点。(因为有路由规则,引入哈希槽概念。)

image-20240226113635750

数据读写

Redis分片集群引入哈希槽的概念,Redis集群有16438个哈希槽,每个可以通过CRC16校验后对16438取模来决定放置哪个槽,集群的每个节点负责一部分hash槽。

image-20240226114101578

  • 如果设置的name有{}的话,就是{}中为有效值,如果没有{}就是取键值

Redis速度快

速度快的原因

  • Redis是C语言开发的,是纯内存操作,执行速度非常快
  • 采用单线程,避免不必要的上下文切换,多线程还需要考虑线程安全问题
  • 使用I/O多路复用模型

I/O多路复用模型

​ Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,I/O多路复用模型主要就是实现了高效的网路请求。

​ 想要了解IO模型需要先了解下列信息

  • 用户空间和内核空间
  • 常见的IO模型
    • 阻塞IO(Blocking IO)
    • 非阻塞IO(Nonblocking IO)
    • IO多路复用(IO Multiplexing)
  • Redis网络模型

用户空间和内核空间

  • Linux系统中一个进程使用的内存情况划分两部分:内核空间、用户空间
  • 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核空间提供的接口来访问
  • 内核空间可以执行特权命令(Ring0),调用一切系统资源

Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区

  • 写数据时,要把用户缓冲区数据拷贝到内核缓冲区,然后写入设备
  • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

image-20240226142847082

阻塞IO

​ 顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

  • 阶段一:

    1. 用户进程尝试读取数据(比如网卡数据)

    2. 此时数据尚未到达,内核需要等待数据

    3. 此时用户进程也处于阻塞状态

  • 阶段二

    1. 数据到达并拷贝到内核缓冲区,代表已就绪
    2. 将内核数据拷贝到用户缓冲区
    3. 拷贝过程中,用户进程依然阻塞等待
    4. 拷贝完成,用户进程接触阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态

image-20240226144004203

非阻塞IO

​ 顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

  • 阶段一
    1. 用户进程尝试读取数据(比如网卡数据)
    2. 此时数据尚未到达,内核需要等待数据
    3. 返回异常给用户进程
    4. 用户进程拿到error后,再次尝试读取
    5. 循环往复,直到数据就绪
  • 阶段二
    1. 将内核数据拷贝到用户缓冲区
    2. 拷贝过程中,用户进程依旧阻塞等待
    3. 拷贝完成,用户进程解除阻塞,处理数据

可以看到,非阻塞IO模型中,用户进程在第一阶段时非阻塞的,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且会导致CPU空转,CPU使用率暴增。

image-20240226145250470

IO多路复用

​ 是利用单个线程来同事监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源

  • 阶段一
    1. 用户进程调用select,指定要监听的Socket集合
    2. 内核监听对应的多个socket
    3. 任意一个或多个socket数据就绪则返回readable
    4. 此过程中用户进程阻塞
  • 阶段二
    1. 用户进程找到就绪的socket
    2. 依次调用recvfrom读取数据
    3. 内核将数据拷贝到用户空间
    4. 用户进程处理数据

image-20240226150133151

监听Socket方式
  • select
  • poll
  • epoll
差异
  • select和poll只会通知用户进程有Socket就绪,但不确定具体是哪个Socket,需要用户进程逐个遍历Socket来确认
  • epoll则会在通知用户进程Socket就绪的同事,把已就绪的Socket写入用户空间

Redis网络模型

​ Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库

就是使用I/O多路复用结合事件的处理器来应对多个Socket请求

image-20240226151008204

  • 连接应答处理器

  • 命令回复处理器

    用于处理网路请求回复,在redis6.0之后,为了提升更好的性能,使用了多线程来处理回复事件

  • 命令请求处理器

    用于接受网络命令请求,在redis6.0之后,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程。

框架篇

Spring

Bean线程安全问题

​ Spring中通过配置scope属性去修改Bean是否单例,默认值为singleton。

1
2
3
@Service
@Scope("singleton")
public class UserServiceImpl implements UserService{}
  • singleton

    bean在每个Spring IOC容器中只有一个实例(对象),只有一个就避免对象的频繁创建与销毁,达到了bean对象的复用,性能高。

  • prototype

    一个bean的定义可以有多个实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
@RequestMapping("/user")
public class UserController{
//成员方法需要考虑线程安全
private int count;

@Autowired
private UserService userService;

@GetMapping("/getById/{id}")
public User getById(@PathVariable("id")Integer id){
count++;
System.out.println(count);
return userService.getById(id);
}
}

由上述代码执行后可知:

​ Spring中单例bean不是线程安全的。

​ 当多用户同时请求一个服务时,容器会给每个请求分配一个线程,这时多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单例状态的修改(体现为该单例的成员属性),则必须考虑线程同步问题。

​ Spring框架并没有对单例bean进行任何多线程的封装处理,关于单例bean的线程安全和并发问题需要开发者自行去搞定

​ 比如:我们通常在项目中使用的Spring bean都是不可变的状态(比如Service类和Dao类),所以在某种程度上说Spring的单例bean是线程安全的

​ 如果你的bean有多种状态的话(比如View Model对象,可以理解为实体类),就需要自行保证线程安全。最浅显的解决办法就是将多态bean的范围由singleton变更为prototype。

AOP

​ AOP称为面向切面编程,用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取并封装一个可重用模块,这个模块被命名为切面(Aspect),减少系统中的重复代码,降低模块间的耦合度,同时提高了系统的可维护性。

  • 连接点(JoinPoint)

    程序执行的任意位置,在springAOP中,理解为方法的执行

  • 切入点(Pointcut)

    匹配连接点的式子,可以描述一个具体方法,也可以匹配多个方法

  • 通知(Advice)

    在切入点执行的操作

  • 通知类

    定义通知的类叫做通知类

  • 切面(Aspect)

    描述通知和切入点的对应关系

常见的AOP使用场景

  • 记录操作日志

    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
    @Component
    @Aspect
    public class SysAspect{
    @Pointcut("@annotation(com.itheima.annotation.Log)")
    private void pointcut(){}

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint)throws Throwable{
    //获取用户名
    //需要通过解析session或token获取

    //获取被增强类和方法的信息
    Signature signature = joinPoint.getSignature();
    MethodSignature methodSignature = (MethodSignature) signature;
    //获取被增强的方法对象
    Method method = methodSignature.getMethod();
    //从方法中解析注解
    if(method !=null){
    Log logAnnotation = method.getAnnotation(Log.class);
    System.out.println(logAnnotation.name());
    }
    //方法名字
    String name = method.getName();
    System.out.println(name);

    //通过工具类获取Request对象
    RequestAttributes reqa = RequestContextHolder.getRequestAttributes();
    ServletRequestAttributes sra = (ServletRequsetAttributes)reqa;
    HttpServletRequest request = sra.getRequest();

    //访问的url
    String url = request.getRequestURI().toString();
    System.out.println(url);
    //请求方式
    String methodName = request.getMethod();
    System.out.println(methodName);

    //登录id
    String ipAddr = getIpAddr(request);
    System.out.println(ipAddr);

    //操作时间
    System.out.println(new Date());

    //保存到数据库(操作日志)
    return joinPoint.proceed();
    }
    }
  • 缓存处理

  • Spring中内置的事务处理

spring事务的实现

Spring支持编程式事务管理和声明式事务管理两种方式

  • 编程式事务控制

    需使用TransactionTemplate来进行实现,对业务代码有侵入性,项目中很少使用

  • 声明式事务管理

    声明式事务管理建立在AOP之上的。其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

    image-20240227171135851

Spring事务失效

  • 异常捕获处理

    自己处理了异常,没有抛出,解决:手动抛出

  • 抛出检查异常

    配置rollbackFor属性为Exception

  • 非public方法

    改为public

异常捕获处理

​ 下列代码是一个异常捕获处理导致业务失效的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Transactional
public void update(Integer form,Integer to,Double money){
try{
//转账的用户不能为空
Account fromAccount = accountDao.selectById(from);
//判断用户的钱是否转账
if(fromAccount.getMoney()-money>=0){
fromAccount.setMoney(fromAccount.getMoney()-money);
accountDao.updateById(fromAccount);

//异常
int a = 1/0;

//被转账的用户
Account toAccount = accountDao.selectById(to);
toAccount.setMoney(toAccount.getMoney()+money);
accountDao.updateById(toAccount);

}catch(Exception e){
e.printStackTrace();
}
}
}

原因:

事务通知只有捕捉到了目标抛出的异常,才能进行后续的回滚处理,如果目标自己处理掉异常,事务通知无法知悉

解决:

在catch块中添加throw new RuntimeException(e)抛出

抛出检查异常

​ 在方法上抛出异常后,@Transactional没有配置rollbackFor属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional
public void update(Integer from,Integer to,Double money)throws FileNotFoundException{
//转账的用户不能为空
Account formAccount = accountDao.selectById(from);
//判断用户的钱是否能够转账
if(formAccount.getMoney()-money>=0){
fromAccount.setMoney(fromAccount.getMoney()-money);
accountDao.updateById(fromAccount);
//读取文件
new FileInputStream("ddd");
//被转账的用户
Account toAccount = accountDao.selectById(to);
toAccount.setMoney(toAccount.getMoney()+money);
accountDao.updateById(toAccount);
}
}

​ 原因:

Spring默认只回滚非检查异常(只会回滚runtimeException)

​ 解决:
配置rollbackFor属性@Transactional(rollbackFor=Exception.class)

非public方法导致的事务失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional(rollbackFor=Exception.class)
void update(Integer from,Integer to,Double money)throws FileNotFoundException{
//转账的用户不能为空
Account formAccount = accountDao.selectById(from);
//判断用户的钱是否能够转账
if(formAccount.getMoney()-money>=0){
fromAccount.setMoney(fromAccount.getMoney()-money);
accountDao.updateById(fromAccount);
//读取文件
new FileInputStream("ddd");
//被转账的用户
Account toAccount = accountDao.selectById(to);
toAccount.setMoney(toAccount.getMoney()+money);
accountDao.updateById(toAccount);
}
}

​ 原因:

spring为方法创建代理、添加事务通知、前提条件都是该方法是public的

​ 解决:

改为public方法

Spring的Bean的生命周期

​ 我们要了解Bean的生命周期,需要先去了解下BeanDefinition

BeanDefinition

​ Spring容器在进行实例化时,会将xml配置的bean的信息封装成一个BeanDefinition对象。spring根据BeanDefinition来创建Bean对象,里面有很多的属性用来描述Bean

1
2
3
<bean id="userDao" class="com.itheima.dao.impl.UserDaoImpl" lazy-init="true"/>
<bean id="userService" class="com.itheima.service.UserServiceImpl" scope="singleton"> <property name="userDao" ref="userDao"></property>
</bean>
  • beanClassName

    bean的类名

  • initMethodName

    初始化方法名称

  • propertyValues

    bean的属性值

  • scope

    作用域

  • lazy-init

    延迟初始化

Bean的创建流程(生命周期)

image-20240228103437356

  1. 通过BeanDefinition获取bean的定义信息
  2. 调用构造函数实例化bean(空参构造方法)
  3. bean的依赖注入(一般采用set方法注入)
  4. 处理Aware接口(BeanNameAware、BeanFactoryAware、ApplicationContextAware)
  5. Bean的后置处理器BeanPostProcessor-前置
  6. 初始化方法(InitializingBean、init-method)
  7. Bean的后置处理器BeanPostProcessor-后置
  8. 销毁bean

代码验证

创建一个user的bean的代码过程

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
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

//通过注解交由IOC容器管理
@Component
public class User implements BeanNameAware, BeanFactoryAware, ApplicationContextAware, InitializingBean {

//构造函数实例化bean
public User() {
System.out.println("User的构造方法执行了.........");
}
//设置name属性的值
private String name ;

//通过set方法依赖注入name的值为张三
@Value("张三")
public void setName(String name) {
System.out.println("setName方法执行了.........");
}

//通过实现BeanNameAware接口,重写setBeanName方法,形参是bean的名称
@Override
public void setBeanName(String name) {
System.out.println("setBeanName方法执行了.........");
}

//通过实现BeanFactoryAware接口,重写setBeanFactory方法,获取bean工厂
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
System.out.println("setBeanFactory方法执行了.........");
}

//通过实现applicationContextAware接口,重写setApplicationContext方法,获取IOC容器的上下文对象
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
System.out.println("setApplicationContext方法执行了........");
}

//自己定义的bean的初始化方法
@PostConstruct
public void init() {
System.out.println("init方法执行了.................");
}

//实现InitializingBean接口,重写afterPropertiesSet初始化方法
@Override
public void afterPropertiesSet() throws Exception {
System.out.println("afterPropertiesSet方法执行了........");
}

//bean的销毁方法
@PreDestroy
public void destory() {
System.out.println("destory方法执行了...............");
}

}

自定义bean创建时的后置处理器

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
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.InvocationHandler;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Component
public class MyBeanPostProcessor implements BeanPostProcessor {

//bean的后置处理器的前置方法
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("user")) {
System.out.println("postProcessBeforeInitialization方法执行了->user对象初始化方法前开始增强....");
}
return bean;
}

//bean的后置处理器的后置方法
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("user")) {
System.out.println("postProcessAfterInitialization->user对象初始化方法后开始增强....");
//cglib代理对象
Enhancer enhancer = new Enhancer();
//设置需要增强的类
enhancer.setSuperclass(bean.getClass());
//执行回调方法,增强方法
enhancer.setCallback(new InvocationHandler() {
@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
//执行目标方法
return method.invoke(method,objects);
}
});
//创建代理对象
return enhancer.create();
}
return bean;
}

}

获取user bean的代码执行结果

image-20240228110038165

循环引用

​ 循环引用其实就是循环依赖,也就是两个或两个以上的bean互相持有对方,最终形成闭环,比如在创建A对象的同时需要使用B对象,在创建B对象的同时需要使用到A对象。

​ 循环依赖在spring中是允许存在的,spring框架依据三级缓存已经解决了大部分的循环依赖。

1
2
3
4
5
6
7
8
9
10
11
@Component
public class A{
@Autowired
private B b;
}

@Component
public class B{
@Autowired
private A a;
}

image-20240228135337218

三级缓存解决循环依赖

主要解决bean赋值循环依赖的情况

​ 对应的三级缓存如下所示

1
2
3
4
5
6
7
8
9
10
//单例对象注册器
public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry{
private static final int SUPPRESSED_EXCEPTIONS_LIMIT = 100;
//一级缓存
private final Map<String, Object> singletonObjects = new ConcurrentHashMap(256);
//三级缓存
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap(16);
//二级缓存
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap(16);
}
缓存名称源码名称作用
一级缓存singletonObjects单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
二级缓存earlySingletonObjects缓存早期的bean对象(生命周期还没有走完)
三级缓存singletonFactories缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

一级缓存作用:限制bean在beanFactory中只存一份,即实现singleton scope,解决不了循环依赖。

如果想要打破循环依赖,就需要一个中间人的参与,这个中间人就是二级缓存

image-20240228143933349

  • 将半成品的bean对象放入到二级缓存中,需要用到在拿出来

如果对象是代理对象的话,通过一级、二级缓存并不能完全解决循环依赖,这时候就需要使用到三级缓存。下面是三级缓存的流程:

image-20240228144528551

  • 通过ObjectFactory对象控制是创建一个代理对象还是原始对象
  • 上述二级缓存的作用是防止重复创建半成品对象,需要是直接获取即可,减少对象的损耗。

解决构造方法循环依赖

​ 以下代码是:A依赖于B,B依赖于A,注入的方法是是构造函数

原因:由于bean的生命周期中构造函数式第一个执行的,spring框架并不能解决构造函数的依赖注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class A {
// B成员变量
private B b;

public A(B b){
System.out.println("A的构造方法执行了..."); this.b = b ;
}
}


@Component
public class B {
// A成员变量
private A a;
public B(A a){
System.out.println("B的构造方法执行了..."); this.a = a ;
}
}

报错信息:Is there an unresolvable circular reference?

解决办法: 使用@Lazy注解进行懒加载,什么时候需要对象在进行bean对象的创建

1
2
3
4
public A(@Lazy B b){
System.out.println("A的构造方法执行了...");
this.b = b ;
}

SpringMVC

执行流程

现在执行流程分为两个版本

  • 视图阶段(老旧JSP等)
  • 前后端分离阶段(接口开发,异步)

视图阶段

image-20240228155744762

  1. 用户发送请求到前端控制器DispatcherServlet(理解为调度器)
  2. DispatcherServlet收到请求调用HandlerMapping(处理器映射器)
  3. HandlerMapping找到具体的处理器,生成处理器对象以及处理器拦截器(如果有),在一起返回给DispatcherServlet(有个map,key时请求路径,值是对应的类名+方法名)
  4. DispatcherServlet调用HandlerAdapter(处理器适配器)
  5. HandlerAdapter经过适配调用具体的处理器(Handler/Controller)
  6. Controller执行完成返回ModelAndView对象
  7. HandlerAdapter将Controller执行结果ModelAndView返回给DispatherServlet
  8. DispatcherServlet将ModelAndView传给ViewReslover(视图解析器)
  9. ViewReslover解析后返回具体View(视图)
  10. DispatcherServlet根据View进行渲染视图(即将模型数据填充至视图中)
  11. DispatcherServlet响应用户

前后端分离阶段

image-20240228160957712

  1. 用户发送请求到前端控制器DispatcherServlet(理解为调度器)
  2. DispatcherServlet收到请求调用HandlerMapping(处理器映射器)
  3. HandlerMapping找到具体的处理器,生成处理器对象以及处理器拦截器(如果有),在一起返回给DispatcherServlet(有个map,key时请求路径,值是对应的类名+方法名)
  4. DispatcherServlet调用HandlerAdapter(处理器适配器)
  5. HandlerAdapter经过适配调用具体的处理器(Handler/Controller)
  6. 方法上添加了@ResponseBody
  7. 通过HttpMessageConverter来返回结果转换为JSON并响应

SpringBoot

自动装配原理

image-20240228163223980

  • @SpringBootConfiguration

    该注解与@Configuration注解相同,用来声明当前也是一个配置类

  • @ComponentScan

    组件扫描,默认扫描当前引导类所在包及其子包

  • @EnableAutoConfiguration

    SpringBoot实现自动化配置的核心注解

image-20240228164043240

  • @EnableAutoConfiguration通过@Import注解导入对应的配置选择器。

    内部就是读取该项目和该项目引用的jar包的classpath路径下``META-INF/spring.factories`文件中的所有配置的全类名。

    在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中

  • 条件判断会有像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用

常见注解

Spring注解

注解说明
@Component、@Controller、@Service、@Repository使用在类上用于实例化bean
@Autowired使用在字段上,用于根据类型依赖注入
@Qualifier结合@Autowried一起使用,用于根据名称进行依赖注入
@Scope标注Bean的作用范围
@Configuration指定当前类是一个Spring配置类,当创建容器时会从该类上加载注解
@ComponentScan用于指定Spring在初始化容器要扫描的包
@Bean使用方法上,标注将该方法的返回值存储到spring容器中
@Import使用@Import导入的类会被spring加载到IOC容器中
@Aspect、@Before、@After、@Around、@Pointcut用于切面编程(AOP)

SpringMVC注解

注解说明
@RequestMapping用于映射请求路径,可以定义在类上和方法上。用于类上,则表示类中所有的方法都是以该地址作为父路径
@RequestBody注解实现接受http请求的json数据,将json转换为java对象
@RequestParam指定请求参数的名称
@PathViriable从请求路径中获取请求参数,传递给方法的形式参数
@ResponseBody注解实现将Controller方法返回对象转化为json对象响应给客户端
@RequestHeader获取指定的请求头数据
@RestController@Controller+@EnableAutoConfiguration

SpringBoot注解

注解说明
@SpringBootConfiguration类似@Configuration注解,实现配置文件的功能
@EnableAutoConfiguration打开自动配置功能,也可以关闭某个自动配置的选项
@ComponentScanSpring组件扫描

Mybatis

Mybatis执行流程

image-20240228180037135

  1. 读取Mybatis配置文件:mybatis-config.xml加载运行环境和映射文件
  2. 构造会话工厂SqlSessionFactory
  3. 会话工厂创建SqlSession对象(包含了执行SQL语句的所有方法)
  4. 操作数据库的接口,Executor执行器,同时负责查询缓存的维护
  5. Executor接口的执行方法中有一个MappedStatement类型的参数,封装了映射信息
  6. 将输入参数映射成数据库所需要的类型
  7. 将输出结果映射java对象所需要的类型

延迟加载

  • 延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。

  • Mybatis支持一对一关联对象和一对多关联集合对象的延迟加载

  • 在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false,默认是关闭的

延迟加载原理
  1. 使用CGLIB创建目标对象的代理对象
  2. 当调用目标方法时,进入拦截器invoke方法,发现目标方法的值是null值,执行sql查询
  3. 获取到数据之后,然后调用set方法设置属性值,在继续查询目标方法,就有值了

image-20240229110354147

Mybatis一、二级缓存

  • 本地缓存,基于PerpetualCache,本质是一个HashMap
  • 一级缓存:作用域是sqlsession级别
  • 二级缓存:作用域是namespace和mapper的作用域,不依赖于sqlsession
一级缓存

基于PerpetualCache的HashMap本地缓存,其存储作用域为sqlsession,当session进行flush或close之后,该sqlsession中所有的cache就将清空,默认打开一级缓存

1
2
3
4
5
6
7
8
9
10
11
//2. 获取SqlSession对象,用它来执行sql
SqlSession sqlSession = sqlSessionFactory.openSession();
//3. 执行sql
//3.1 获取UserMapper接口的代理对象
UserMapper userMapper1 = sqlSession.getMapper(UserMapper.class);
UserMapper userMapper2 = sqlSession.getMapper(UserMapper.class);
User user = userMapper1.selectById(6);
System.out.println(user);
System.out.println("---------------------");
User user1 = userMapper2.selectById(6);
System.out.println(user1);

代码执行结果:只会执行一次sql查询

二级缓存

基于namespace和mapper的作用域起作用的,不是依赖于SQL session,默认也是采用PerpetualCache的HashMap存储

  • 二级缓存时默认关闭的

  • 开启方式

    • 全局配置文件

      1
      2
      3
      <settings>
      <setting name="cacheEnabled" value="true"></setting>
      </settings>
    • 映射文件

      使用标签让当前mapper生效二级缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//2. 获取SqlSession对象,用它来执行sql
SqlSession sqlSession1 = sqlSessionFactory.openSession();
//3. 执行sql
//3.1 获取UserMapper接口的代理对象
UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class);
User user1 = userMapper1.selectById(6);
System.out.println(user1);
sqlSession1.close();
SqlSession sqlSession2 = sqlSessionFactory.openSession();
System.out.println("---------------------");
UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);
User user2 = userMapper2.selectById(6);
System.out.println(user2);
//4.关闭资源
sqlSession2.close();

上述查询由于获取的不同的SqlSession,一级缓存没有效果,所以查询了两次sql,需要开启二级缓存,才会查询一次sql

注意事项

  1. 对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear

  2. 二级缓存需要缓存的数据实现Serializable接口

  3. 只有会话提交或者关闭以后,一级缓存中的数据才会转移到二级缓存中

微服务篇

SpringCloud

image-20240229114050553

SpringCloud的组成

通常情况:

  • Eureka:注册中心
  • Ribbon:负载均衡
  • Feign:远程调用
  • Hystrix:服务熔断
  • Zuul/Geteway:网关

SpringCloud Alibaba一站式解决方案

  • Nacos:注册中心/配置中心
  • Ribbon:负载均衡
  • Feign:服务调用
  • Sentinel:服务保护
  • Gateway:服务网关

注册中心

​ 微服务中必须要使用的组件,核心作用是:服务注册和发现。

常见的注册中心:Eureka、nocos、zookeeper

Eureka

image-20240229142056229

  • 服务注册

    服务提供者需要把自己的信息注册到eureka,由Eureka来保存这些信息,比如服务名称、ip、端口等等

  • 服务发现

    消费者向Eureka拉取服务列表信息,如果服务提供者有集群、则消费者会利用负载均衡算法,选择一个发起调用

  • 服务监控

    服务提供者会每隔30秒向eureka发送心跳,报告健康状态,如果eureka服务90秒没有接受到心跳,从Eureka中剔除。

Nacos

image-20240229143444531

  • 注册到nacos的服务默认是创建临时实例,它的服务注册、发现、监控都是和Eureka一致

  • 当在服务的提供者中设置以下属性,将设置为非临时实例

    image-20240229144032456

    • nacos就会主动询问,临时实例心跳不正常会被剔除,非临时实例则不会被剔除
    • nacos还会将服务列表变更的消息推送模式,让服务列表更新更及时

Eureka与Nacos对比

相同点
  • 都可以作为注册中心
  • 都支持服务注册和服务拉取
  • 都支持服务提供者心跳方式做健康检测
不同点
  • Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
  • 临时实例心跳不正常会被剔除、非临时实例则不会被剔除
  • Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
  • Nacos集群默认采用AP(高可用)方式,当集群中存在非临时实例时,采用CP模式(强一致性);eureka采用AP方式
  • Nacos还支持了配置中心,eureka则只有注册中心,也是选择使用Nacos的一个重要原因

Ribbon负载均衡

当服务之间发起远程调用Feign就会使用到Ribbon实现请求的负载均衡

负载均衡的流程

image-20240229155400024

  1. 服务消费者发起请求去调用服务提供者的过程会经过Ribbon负载均衡
  2. Ribbon会去注册中心拉取对应需要的服务
  3. 注册中心返回需要的服务列表
  4. ribbon根据设置的负载均衡策略请求到对应的服务

负载均衡策略

  • RoundRobinRule

    简单轮询服务列表来选择服务器

  • WeightedResponseTimeRule

    按照权重来选择服务器,响应时间越长,权重越小

  • RandomRule

    随机选择一个可用的服务器

  • ZoneAvoidanceRule

    以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为第一个机房、一个机架等。而后在对Zone内的多个服务做轮询。

  • BestAvailableRule

    忽略那些短路的服务器,并选择并发数较低的服务器

  • RetryRule

    重试机制的选择逻辑

  • AvailabilityFilteringRule

    可用性敏感策略,先过滤非健康的,再选择连接数较小的实例

自定义负载均衡策略

​ 可以自己创建类实现IRule接口,然后通过配置类或者配置文件配置即可,通过定义IRule实现可以修改负载均衡规则,有以下规则

  1. 全局生效(创建类实现IRule接口,将自己的负载均衡策略交给Spring容器管理)

    1
    2
    3
    4
    @Bean
    public IRule randomRule(){
    return new RandomRule();
    }
  2. 局部生效(在服务消费者的配置文件中,配置需要消费的服务设置对应的负载均衡规则)

    1
    2
    3
    4
    userservice:
    ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule# 负载均衡规则

服务雪崩

一个服务失败,导致整条链路的服务都失败的情形。可以通过服务的熔断降级去解决或者限流去预防

Hystix熔断、降级(解决)

服务降级

​ 服务降级是服务自我保护的一种方式,或者保护下游服务的一种方式,用与确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与Feign接口整合,编写降级逻辑

image-20240229163730968

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@FeignClient(value="leadnews-article",fallback=ArticleClientFallback.class)
public interface ArticleClient{

@PostMapping("/api/v1/article/save")
public ResponseReuslt saveArticle(@RequestBody ArticleDto dto);
}


@Compoent
public class ArticleClientFallback implements ArticleClient{
@Override
public ResponseResult saveArticle(ArticleDto dto){
return ResponseResult.errorResult(AppHttpCodeEnum.SERVER_ERROR,"获取数据失败");
}
}
服务熔断

​ 用于监控微服务调用情况,默认是关闭的,如果需要开启,需要再引导类上添加注解@EnableCircuitBreaker。

​ 如果检测到10秒内请求失败率超过50%,就触发熔断机制。

​ 之后每隔5秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求。

image-20240229165338734

服务监控

​ 有以下工具可以进行微服务的监控

  • Springboot-admin
  • Prometheus+Grafana
  • zipkin(链路追踪工具)
  • skywalking(链路追踪工具)

随着业务的不断扩张,服务之间互相调用会越来越复杂,这个庞大的分布式系统调用网络可能会变的,那随之而来的就是我们将会面临的诸多困扰:

  • 问题定位

    当某一个服务节点出现问题导致整个调用失败,无法快速清晰地定位问题服务

  • 性能分析

    服务存在相互依赖调用的关系,当某一个服务接口耗时过长,会导致整个接口调用变的很慢,我们无法明确每一个接口的耗时

  • 服务拓扑图

    随着需求迭代,系统之间调用关系变化频繁,靠人工很难梳理清楚系统之间的调用关系

  • 服务告警

    当服务出现问题,我们无法做到由系统自动通知相关人员。

为了解决这些问题,分布式链路追踪应运而生。它会将一次分布式请求还原成调用链路,将一次分布式请求的调用情况集中展示,比如各个服务节点上的耗时、请求具体到达哪台机器上、每个服务节点的请求状态、生成服务调用拓扑图等等。也就是说我们要设计并开发一些分布式追踪系统来帮助我们解决这些问题

skywalking

​ 一个分布式系统的应用程序性能监控工具(ApplicationContext Performance Managment),提供了完善的链路追踪能力,Apache的顶级项目(前华为产品经理主导开源)

image-20240229172550701

  • 服务(service)

    业务资源应用系统(微服务)

  • 端点(endpoint)

    应用系统对外暴露的功能接口(接口)

  • 实例(instance)

    物理机

如何进行监控
  1. skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中那些服务和接口比较慢,我们可以针对性的分析和优化
  2. 我们还在skywalking设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发送短信和邮件,第一时间知道项目的bug情况,第一时间修复。

业务相关

限流

限流原因:

  1. 并发的确大(突发流量)
  2. 防止用户恶意刷接口

限流实现方式:

  • Tomcat:可以设置最大连接数(maxThreads)

    1
    <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" maxThreads="150" redirectPort="8443"></Connector>
  • Nginx:漏桶算法

  • 网关:令牌桶算法

  • 自定义拦截器

Nginx限流

控制速率(突发流量)

image-20240301095738092

1
2
3
4
5
6
7
8
9
10
11
http{
limit_req_zone $binary_remote_addr zone=service1RateLimit:10m rate=10r/s
server{
listen 80;
server_name localhost;
location / {
limit_req_zone = service1RateLimit burst=20 nodelay;
proxy_pass http://targetserver;
}
}
}
  • 语法:limit_req_zone key zone rate
  • key:定义限流对象,binary_remote_addr就是一种key,基于客户端ip限流
  • Zone:定义共享存储区来存储访问信息,10m可以存储16万ip地址访问信息
  • Rate:最大访问速率,rate=10r/s 表示每秒最多请求10个请求
  • burst=20:相当于桶的大小
  • Nodelay:快速处理
控制并发连接数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
http{
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server{
listen 80;
server_name localhost;
location / {
...
limit_conn perip 20;
limit_conn perserver 100;
proxy_pass http://targetserver;
}
}
}
  • limit_conn_perip 20

    对应的key是$binary_remote_addr,表示限制单个IP同时最多能持有20个连接

  • limit_conn_perserver 100

    对应的key是$server_name,表示虚拟主机(server)同时能处理并发连接的总数

网关限流

yml配置文件中,微服务路由设置添加局部过滤器RequestRateLimiter

令牌桶算法可能会出现某个时间段超过每秒生成的速率

image-20240301103738999

1
2
3
4
5
6
7
8
9
10
11
12
13
- id: gateway-consumer
uri: lb://GATEWAY-CONSUMER
predicates:
- Path=/order/**
filters:
-name: RequestRateLimiter
args:
# 使用SpEL从容器中获取对象
key-resolver: '#{@pathKeyResolver}'
# 令牌桶每秒填充平均速率
redis-rate-limiter.replenishRate: 1
# 令牌桶的上限
redis-rate-limiter.burstCapacity: 3
  • key-resolver

    定义限流对象(ip、路径、参数),需要代码实现,使用spel表达式获取

  • replenishRate

    令牌桶每秒填充平均速率

  • urstCapacity

    令牌桶总容量

分布式系统理论

CAP定理

分布式系统有三个指标

  • Consistency(一致性)
  • Availability(可用性)
  • Partition tolerance(分区容错性)

分布式系统无法同时满足这三个指标,这个结论就叫做CAP定理。

Consistency

一致性,用户访问分布式系统中的任意节点,得到的数据必须一致

image-20240301110917620

Availability

可用性,用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝

image-20240301111039181

Partition tolerance
  • Partition(分区)

    因为网络故障或其它原因导致分布式系统中的部分节点与其他节点是去连接,形成独立分区

  • Tolerance(容错)

    在集群出现分区时,整个系统,也要持续对外提供服务

image-20240301111312065

结论
  • 分布式系统节点之间肯定是需要网络连接的,分区(P)是必然存在的
  • 如果保证访问的高可用性(A),可以持续对外提供服务,但不能保证数据的强一致性(AP)
  • 如果保证访问的数据一致性(C),就要放弃高可用性(CP)

BASE理论

​ BASE理论是对CAP的一种解决思路,包含三个思想

  • Basically Available(基本可用)

    分布式系统在出现系统故障时,允许损失部分可用性,即保证核心可用

  • Soft State(软状态)

    在一定时间内,允许出现中间状态,比如临时的不一致状态

  • Eventually Consistent(最终一致性)

    虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

最终一致思想

​ 各分支事务分别执行并提交,如果有不一致的情况,在想办法恢复数据(AP)

强一致思想

​ 各分支事务执行完业务不要提交,等待彼此结果,而后统一提交或回滚(CP)

分布式事务解决方案

  • Seata框架(XA、AT、TCC)
  • MQ

Seata架构

Seata事务管理中有三个重要的角色:

  • TC(Transaction Coordinator)

    事务协调者,维护全局和分支事务的状态,协调全局事务提交或回滚

  • TM(Transaction Manager)

    事务管理器,定义全局事务的范围,开始全局事务、提交或回滚全局事务

  • RM(Resource Manager)

    资源管理器,管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

image-20240301134809160

Seata的XA模式

具体的流程步骤:

  1. 开启全局事务
  2. 调用分支

RM一阶段工作

  1. 注册分支事务到TC
  2. 执行分支业务sql但不提交
  3. 报告执行状态到TC

TC二阶段工作

  • TC检测各分支事务执行状态
    • 如果都成功,通知所有RM提交事务
    • 如果有失败,通知所有RM回滚事务

RM二阶段的工作

  • 接收TC指令,提交或回滚事务
AT模式

​ AT模式同样是分阶段提交的事务模式,不过弥补了XA模型中资源锁定周期过长的缺陷。

image-20240301140400703

​ 具体的流程步骤:

  1. 开启全局事务
  2. 调用分支

RM一阶段工作

  1. 注册分支事务到TC
  2. 记录undo-log(记录数据更新前后快照)
  3. 执行业务sql并提交
  4. 报告事务状态

TC二阶段工作

  • TC检测各分支事务执行状态

    • 如果都成功,通知所有RM提交事务

    • 如果有失败,通知所有RM回滚事务

RM二阶段工作

  • 提交时,删除undo-log即可
  • 回滚时,根据undo-log恢复数据到更新前
TCC模式
  1. Try:资源的检测和预留
  2. Confirm:完成资源操作业务,要求Try成功Confirm一定要成功
  3. Cancel:预留资源释放,可以理解为try的反向操作。

image-20240301141510530

总结
  1. seata的XA模式,需要互相等待各个分支事务提交,可以保证强一致性,性能差(CP,适用于银行业务)
  2. seata的AT模式,底层使用undo-log实现,性能好(AP,适用于互联网业务)
  3. seata的TCC模式,性能较好,不过需要人工编码实现(AP,适用于银行业务)

MQ分布式事务

image-20240301141727470

MQ模式实现分布式事务,在A服务写数据的时候,需要在同一个事务内发送消息到另一个事务,异步,性能好(保证数据的最终一致性,适用于互联网业务)

接口幂等性

​ 多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

需要幂等场景

  • 用户重复点击(网络波动)
  • MQ消息重复
  • 应用使用失败或超时重试机制

基于RESTful API的角度对部分常见类型请求的幂等性特点进行分析

请求方式说明
GET查询操作,天然幂等
POST新增操作,请求一次与请求多次造成的结果不同,不是幂等
PUT更新操作,如果是以绝对值更新,则是幂等。如果是通过增量的方式更新,则不是幂等
DELETE删除操作,根据唯一值删除,是幂等的

sql解释修改操作

1
2
update t_item set money=500 where id=1;
update t_item set money=money+500 where id =1;

存在以下几种方式解决接口幂等问题

  1. 数据库唯一索引(解决新增)
  2. token+redis (解决新增、修改)
  3. 分布式锁(解决新增、修改)

token+redis

适用于创建商品、提交订单、转账、支付等操作

image-20240301150438856

  • 第一次请求,生成一个唯一token存入redis,返回给前端
  • 第二次请求,业务处理,携带之前的token,到redis进行验证,如果存在,可以执行业务,删除token,如果不存在,则直接返回,不处理业务。

分布式锁

性能较低

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void saveOrder(Item item) throws InterruptedException {
//获取锁(重入锁),执行锁的名称
RLock lock = redissonClient.getLock("heimalock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(10, TimeUnit.SECONDS);
try {
//判断是否获取成功
if (!isLock) {
log.info("下单操作获取锁失败,order:{}",item);
throw new RuntimeException("新增或修改失败");
}
//下单操作
} finally {
//释放锁
lock.unlock();
}
}

使用分布式锁有以下注意事项

  • 快速失败(对于抢不到锁的线程快速返回失败的结果)
  • 控制锁的粒度(锁用完需要及时释放锁)

分布式任务调度

​ 有很多分布式任务调度工具,这边主要介绍下xxl-job

xxl-job

该分布式任务调度主要解决以下几个问题

  • 解决集群任务的重复执行问题
  • cron表达式定义灵活
  • 定时任务失败了,重试和统计
  • 任务量大,分片执行
xxl-job路由策略
  1. FIRST(第一个):固定选择第一个机器
  2. LAST(最后一个):固定选择最后一个机器
  3. ROUND(轮询)
  4. RANDOM(随机):随机选择在线的机器
  5. CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上
  6. LEAST_FREQUENTLY_USED(最近不经常使用):使用频率最低的机器优先被选取
  7. LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选取
  8. FAILVOER(故障转移):按照顺序依次进行心跳检测,第一个心跳检查成功的机器选定为目标执行器并发起调度
  9. BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度
  10. SHARDING_BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务
xxl-job任务执行失败如何处理

​ 故障转移+失败重试,查看日志分析—>邮件告警

  • 选用调度类型为故障转移
  • 在创建任务实例的时候设置失败重试的次数
  • 设置报错通知的邮箱
如果有大数据量的任务同时都需要执行,怎么解决

执行器集群部署时(微服务),任务路由策略选择分片广播情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务。

  • 让多个实例一块去执行(部署集群)
  • 在任务执行的代码中可以获取分片总数和当前分片,按照取模的方式分摊到各个实例

下列代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@XxlJob("shadingSample")
public void shardingJobHandler() throws Exception {
// 分片参数
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
XxlJobHelper.log("分片参数:当前分片序号 = {}, 总分片数 = {}", shardIndex, shardTotal);
// 业务逻辑
List<Integer> list = getList();
for (Integer integer : list) {
if(integer % shardTotal == shardIndex){
System.out.println("第"+shardIndex+"分片执行,执行数据为:"+integer);
}
}
}

分片参数

  • index:当前分片序号(从0开始),执行器集群列表中当前执行器的序号
  • total:总分片数,执行器集群的总机器数量

消息组件篇

image-20240304140655539

RabbitMQ

​ 主要应用于一下场景

  • 异步发送(验证码、短信、邮件)
  • Mysql和Redis,ES之间的数据同步
  • 分布式事务
  • 削峰填谷

RabbitMQ如何保证消息不丢失

首先消息丢失有可能在以下几个地方

  1. 生产者的消息未到达交换机
  2. 交换机中的消息未到达队列
  3. 队列中的消息丢失
  4. 消费者未接收到消息

image-20240304141455616

在RabbitMQ中提供了以下几种机制确保消息不丢失

生产者确认机制

​ RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功

image-20240304141927635

消息失败之后如何处理?

  • 回调方法即时重发
  • 记录日志
  • 保存到数据库然后定时重发,成功发送后即刻删除表中数据

消息持久化

​ MQ默认是内存存储消息,开始持久化功能可以确保缓存在MQ中的消息不会丢失

  1. 交换机持久化

    1
    2
    3
    4
    5
    @Bean
    public DirectExchange simpleExchange(){
    //三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
    return new DirectExchange("simple.direct",true,false);
    }
  2. 队列持久化

    1
    2
    3
    4
    5
    @Bean
    public Queue simpleQueue(){
    //使用QueueBuilder构建队列,durable就是持久化的
    return QueueBuilder.durable("simple.queue").build();
    }
  3. 消息持久化,SpringAMQP中的消息默认是持久的,可以通过MessageProperties中的DeliveryMode来指定

    1
    2
    3
    4
    Message msg = MessageBuilder
    .withBody(message.getBytes(StandardCharserts.UTF_8))//消息体
    .setDeliveryMode(MessageDeliverMode.PERSISTENT)//持久化
    .build();

消费者确认

​ RabbitMQ支持消费者确认机制,即:消费者处理消息后可以向MQ发送ack回执,MQ收到ack回执后才会删除该消息。

而SpringAMQP则允许配置三种确认模式:

  1. manual

    手动ack,需要在业务代码结束之后,调用api发送ack

  2. auto

    自动ack,由Spring检测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回ack

  3. none

    关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除

我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,设置重试次数,当次数达到了以后,如果消息依然失败,将消息投递到异常交换机,交由人工处理。

image-20240304143823402

RabbitMQ消息重复消费

有以下问题可能导致消息的重复消费

  • 网络抖动
  • 消费者挂了

解决方法

这里的方法适用的是所有的消息组件

  • 每条消息设置一个唯一的标识id
  • 幂等方案:分布式锁、数据库锁(悲观锁、乐观锁)

image-20240304144229861

延迟队列

延迟队列:进入队列的消息会被延迟消费的队列,用到了死信交换机和TTL(消息存活时间)实现的

使用场景:

  • 超时订单,比如买火车票创建订单有半个小时的支付时间
  • 限时优惠
  • 定时发布

RabbitMQ死信交换机

​ 当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter)

  • 消费者使用basic.reject或basic.nack声明消费失败,并且消息的requeue参数设置为false
  • 消息是一个过期消息,超时无人消费
  • 要投递的队列消息堆积满了,最早的消息可能成为死信

如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为死信交换机(Dead Letter Exchange,简称DLX)

image-20240304145129549

1
2
3
4
5
6
7
@Bean
public Queue ttlQueue(){
return QueueBuilder.durable("simple.queue")//指定队列名称,并持久化
.ttl(10000)//设置队列的超时时间 10秒
.deadLetterExchange("dl.direct")//指定死信交换机
.build();
}

TTL

​ 也就是Time-To-Live。如果一个队列中的消息TTL结束仍未消费,则会变为死信,ttl超时分为两种情况:

  1. 消息所在的队列设置了存活时间
  2. 消息本身设置了存活时间

注意:上述两个情况,那个设置的时间最短,就以那个时间为准

image-20240304145736132

1
2
3
4
5
6
7
8
//创建消息
Message message = MessageBuilder.withBody("hello,ttl message".getBytes(StandardCharsets.UTF_8))
.setExpiration("5000")
.build();
//消息ID,需要封装到CorrelationData中
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
//发送消息
rabbitTemplate.convertAndSend("ttl.direct","ttl",message,correlationData)

延迟队列插件

​ DelayExchange插件,需要安装在RabbitMQ中。官方的插件社区:地址

​ DelayExchange的本质还是官方的三种交换机,只是添加了延迟功能。因此使用只需要声明一个交换机,交换机可以是任意类型,然后设定delayed属性为true即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RabbitListener(bindings=@QueueBinding(
value = @Queue(name="delay.queue",durable="true"),
exchange=@Exchange(name="delay.direct",delayed="true"),
key="delay"))
public void listenDelayedQueue(String msg){
log.info("接收到delay.queue的延迟消息{}",msg);
}


//创建消息
Message message = MessageBuilder.withBody("hello,ttl message".getBytes(StandardCharsets.UTF_8))
.setHeader("x-delay",10000)
.build();
//消息ID,需要封装到CorrelationData中
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
//发送消息
rabbitTemplate.convertAndSend("delay.direct","delay",message,correlationData);

消息堆积

​ 当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是消费堆积问题。

解决消息堆积有三种思路:

  • 增加更多消费者,提高消费速度
  • 在消费者开启线程池加快消息处理速度(具体需要看CPU性能)
  • 扩大队列容积,提高堆积上限(这里可以使用惰性队列)

惰性队列

​ 惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存
  • 消费者要消费消息时才会从磁盘中读取并加载到内存
  • 支持数百万的消息存储
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RabbitListener(queuesToDeclare = @Queue(
name = "lazy.queue",
durable="true",
arguments = @Argument(name="x-queue-mode",value="lazy")))
public void listenLazyQueue(String msg){
log.info("接收到lazy.queue的消息");
}

@Bean
public Queue lazyQueue(){
return QueueBuilder.durable("lazy.queue")
.lazy() //开启x-queue-mode为lazy
.build();
}

RabbitMQ高可用机制

  • 在生产环境,使用集群来保证高可用性
  • 普通集群、镜像集群、仲裁队列

普通集群

​ 又叫做标准集群(classic cluster),具备下列特征:

  • 会在集群的各个节点共享部分数据。包括交换机,队列元信息。不包含队列中的消息
  • 会访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回
  • 队列所在节点宕机,队列中的消息就会丢失

镜像集群

​ 本质是主从模式,具备下面的特征

  • 交换机、队列、队列中的消息会在各个mq的镜像节点之间同步备份
  • 创建队列的节点被称为该队列的主节点,备份到的其他节点叫做该队列的镜像节点
  • 一个队列的主节点可能是另一个队列的镜像节点
  • 所有操作都是主节点完成,然后同步给镜像节点
  • 主机宕机后,镜像节点会替代成为新的主节点

image-20240304154405416

仲裁队列

仲裁队列司3.8版本以后才有的新功能,用来替代镜像队列,具备以下特征:

  • 与镜像队列一样,都是主从模式,支持主从数据同步
  • 使用非常简单,没有复杂的配置
  • 主从同步基于Raft协议,强一致
1
2
3
4
5
6
@Bean
public Queue quorumQueue(){
return QueueBuilder.durable("quorum.queue")//持久化
.quorum()//仲裁队列
.build();
}

Kafka

Kafka如何保证消息不丢失

使用Kafka在消息的收发过程都会出现消息丢失,Kafka分别给出了解决方法

  • 生产者发送消息到Brocker丢失
  • 消息在Brocker中存储丢失
  • 消费者从Brocker接收消息丢失

image-20240304171534895

生产者发送消息到Brocker丢失

  • 设置异步发送

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //同步发送
    RecordMetadata recordMetadata = kafkaProducer.send(record).get();
    //异步发送
    kafkaProducer.send(record,new Callback(){
    @Override
    public void onCompletion(RecordMetadata recordMetadata,Exception e){
    if(e!=null){
    System.out.println("消息发送失败|记录日志");
    }
    long offset = recordMetadata.offset();
    int partition = recordMetadata.partition();
    String topic = recordMetadata.topic();
    }
    });
  • 消息重试

    1
    2
    //设置重试次数
    prop.put(ProducerConfig.RETRIES_CONFIG,10);

消息在Brocker中存储丢失

image-20240304172345741

  • 发送确认机制acks

    确认机制说明
    acks=0生产者在成功写入消息之前不会等待任何来自服务器的响应,消息有丢失的风险,但是速度很快
    acks=1(默认值)只要集群首领节点收到消息,生产者就会收到一个来自服务器的成功响应
    acks = all只有当所有参与赋值的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应

消费者从Brocker接收消息丢失

image-20240304172823020

  • Kafka中的分区机制指的是将每个主题划分成多个分区(Partition)
  • topic分区中消息只能由消费者组中的唯一一个消费者处理,不同的分区分配给不同的消费者(同一个消费组)

消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次,如果出现重平衡的情况,可能会重复消费会丢失数据

image-20240304174002530

解决丢失方案:

  • 禁用自动提交偏移量,改为手动,有以下提交方式
    • 同步提交
    • 异步提交
    • 同步+异步组合提交(推荐)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
try{
while(true){
ConsumerRecords<String,String> records = consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String,String> record:records){
System.out.println(record.value());
System.out.println(record.key());
}
//异步提交
consumer.commitAsync();
}
}catch(Exception e){
e.printStackTrace();
System.out.println("记录错误信息:"+e);
}finally{
try{
//同步提交
consumer.commitSync();
}finally{
consumer.close();
}
}

Kafka如何保证消费的顺序性

应用场景:

  • 即时消息中的单对单聊天和群聊,保证发送方消息发送顺序与接收方的顺序一致
  • 充值转账两个渠道在同一个时间进行余额变更,短信通知必须有顺序

问题原因

一个topic的数据可能存储在不同的分区中,每个分区都有一个按照顺序的存储的偏移量,如果消费者关联了多个分区不能保证顺序性。

解决方案

​ topic分区中消息只能由消费者组中的唯一一个消费者处理,所以消息肯定是按照先后顺序进行处理的。

但是它也仅仅是保证Topic的一个分区顺序处理,不能保证跨分区的消息先后处理顺序。 所以,如果你想要顺序的处理Topic的所有消息,那就只提供一个分区。

  • 发送消息时指定分区号
  • 发送消息时按照相同业务设置相同的key
1
2
3
4
//指定分区
kafkaTemplate.send("springboot-kafka-topic",0,"key-001","value-001");
//相同的业务key
kafkaTemplate.send("springboot-kafka-topic","key-001","value-001");

Kafka高可用机制

集群模式

image-20240304175652592

  • Kafka的服务器端成为Broker的服务进程构成,即一个Kafka集群由多个Broker组成
  • 这样如果集群中某一台机器宕机,其他机器上的Broker也依然能够对外提供服务。这其实就是Kafka提供高可用的手段之一

分区备份机制

image-20240304175839918

  • 一个topic有多个分区,每个分区有多个副本,其中有一个leader,其余的是follower,副本存储在不同的broker中
  • 所有的分区副本的内容都是相同的,如果leader发生故障时,会自动将其一个follower提升为leader
ISR(in-sync-replica)

image-20240304180038405

需要同步复制保存的follower

如果leader失效后,需要选出新的leader,选举的原则如下:

  1. 选举时优先从ISR中选定,因为这个列表的follower的数据是与leader同步的
  2. 如果ISR列表中的follower都不行了,就只能从其他follower中选取了

ISR设置参数:

1
2
3
4
# 一个topic默认分区的replication个数,不能大于集群中Broker的个数,默认为1
default.replication.factor=3
#最小的ISR副本个数
min.insync.replicas=2

Kafka数据清理机制

  • Kafka文件存储机制
  • 数据清理机制

Kafka文件存储机制

Kafka中topic的数据存储在分区上,分区如果文件过大会分段存储segment

image-20240305090445984

存储过后,在磁盘中存在对应的三种后缀的文件

  • index 索引文件
  • log 数据文件
  • timeindex 时间索引文件

为什么要分段?

  • 删除无用文件方便,提高磁盘利用率
  • 查找数据便捷

数据清理机制

日志的清理策略有两个

  1. 根据消息的保留时间,当消息在Kafka中保存的时间超过了指定的时间,就会触发清理过程(默认168小时,也就是7天)

    1
    log.retention.hours=168
  2. 根据topic存储的数据太小,当topic所占的日志文件大小大于一定的阈值,则开始删除最久的消息,需要手动开启

    1
    log.retention.bytes=1073741824

Kafka实现高性能机制设计

  • 消息分区:不受单台服务器器的限制,可以不受限的处理更多的数据
  • 顺序读写:磁盘顺序读写,提升读写效率
  • 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的范文变为对内存的访问
  • 零拷贝:减少上下文切换以及数据拷贝
  • 消息压缩:减少磁盘IO和网络IO
  • 分批发送:将消息打包批量发送,减少网络开销

零拷贝

image-20240305092435780

多线程篇

线程基础

线程和进程的区别

当一个程序被运行,从磁盘加载到这个程序的代码到内存,这时就开启了一个进程。

一个进程之内可以分为一到多个线程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行

java中,线程作为最小调度单位,进程作为资源分配的最小单位。在windows中进程是不活动的,只是作为线程的容器。、

二者对比

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

并行和并发有什么区别

单核CPU

  • 单核CPU下线程实际还是串行执行的
  • 操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为15毫秒)分给不同的程序使用,只是由于cpu在线时间(时间片很短)的切换非常快,人类感觉是同时运行的。
  • 总结一句话就是:微观串行,宏观并行

一般会将这种线程轮流使用CPU的做法称为并发(concurrent)

image-20221026105607248

多核CPU

​ 每个核(core)都可以调度运行线程,这时候线程可以是并行的

image-20230503203330700

现在都是多核CPU,在多核CPU下

  • 并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
  • 并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程

举例:

  • 家庭主妇做饭,打扫卫生,她一个人轮流交替做多件事,这时候就是并发
  • 家庭主妇雇了个保姆,她们一起做这些事,这时候既有并发也有并行(这时候会产生竞争,例如一口锅,一个人用锅时,另一个人就得等待)
  • 雇了2个保姆,一个专做饭,一个专打扫,互不干扰,这时是并行

创建线程的四种方式

​ 共有四种方式可以创建线程,分别是:继承Thread类、实现Runnable接口、实现Callable接口、线程池创建线程

继承Thread类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MyThread extends Thread{
@Override
public void run(){
System.out.println("MyThread....run...");
}
}

@Test
public void test1(){
//创建MyThread对象
MyThread myThread1 = new MyThread();
MyThread myThread2 = new MyThread();

//调用start()方法启动线程
myThread1.start();
myThread2.start();
}

实现Runnable接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("myRunnable...run...");
}
}



@Test
public void test2(){
//创建MyRunnable对象
MyRunnable runnable = new MyRunnable();

//创建Thread对象
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);

//调用start方法启动线程
thread1.start();
thread2.start();
}

实现Callable接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MyCallable implements Callable<String>{
@Override
public String call() throws Exception {
System.out.println("MyCallable...call");
return "线程执行后返回";
}
}



@Test
public void test3()throws Exception{
MyCallable myCallable = new MyCallable();
//创建Future对象
FutureTask<String> ft = new FutureTask<String>(myCallable);
//创建Thread对象
Thread t1 = new Thread(ft);
//调用start方法启动线程
t1.start();
//调用ft的get方法获取执行结果
String result = ft.get();
//输出结果
System.out.println(result);
}

线程池创建线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MyExecutors implements Runnable{
@Override
public void run() {
System.out.println("myRunnable...run...");
}
}

@Test
public void test4(){
//创建线程池对象
ExecutorService threadPool = Executors.newFixedThreadPool(3);
threadPool.submit(new MyExecutors());
//关闭线程池
threadPool.shutdown();
}

runnable和callable有什么区别

  1. Runnable接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取线程执行的结果
  2. Callable接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞
  3. Callable接口的call方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部处理,不能继续上抛

线程run()和start()有什么区别

  • start()

    用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

  • run()

    封装了要被线程执行的代码,可以被调用多次。

线程包括哪些状态,状态之间是如何变化的

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
public enum State {
/**
* 尚未启动的线程的线程状态
*/
NEW,

/**
* 可运行线程的线程状态。处于可运行状态的线程正在 Java 虚拟机中执行,但它可能正在等待来自 * 操作系统的其他资源,例如处理器。
*/
RUNNABLE,

/**
* 线程阻塞等待监视器锁的线程状态。处于阻塞状态的线程正在等待监视器锁进入同步块/方法或在调 * 用Object.wait后重新进入同步块/方法。
*/
BLOCKED,

/**
* 等待线程的线程状态。由于调用以下方法之一,线程处于等待状态:
* Object.wait没有超时
* 没有超时的Thread.join
* LockSupport.park
* 处于等待状态的线程正在等待另一个线程执行特定操作。
* 例如,一个对对象调用Object.wait()的线程正在等待另一个线程对该对象调用Object.notify() * 或Object.notifyAll() 。已调用Thread.join()的线程正在等待指定线程终止。
*/
WAITING,

/**
* 具有指定等待时间的等待线程的线程状态。由于以指定的正等待时间调用以下方法之一,线程处于定 * 时等待状态:
* Thread.sleep
* Object.wait超时
* Thread.join超时
* LockSupport.parkNanos
* LockSupport.parkUntil
* </ul>
*/
TIMED_WAITING,

/**
* 已终止线程的线程状态。线程已完成执行
*/
TERMINATED;
}

根据Thread类的源码中发现有个state的枚举有以下状态

  • 新建(NEW,通过new关键字创建线程对象)

  • 可运行(RUNNABLE)

  • 阻塞(BLOCKED)

  • 等待(WAITING)

  • 时间等待(TIMED_WAITING)

  • 终止/死亡(TERMINATED)

线程状态之间的变化

image-20230503203629212

  • 新建
    • 当一个线程对象被创建,但还未调用start方法时处于新建状态
    • 此时未与操作系统底层线程关联
  • 可运行
    • 调用了start方法,就会由新建进入可运行
    • 此时与底层线程关联,由操作系统调度执行
  • 死亡
    • 线程内代码已经执行完毕,由可运行进入死亡
    • 此时会取消与底层线程关联
  • 阻塞
    • 当获取锁失败后,由可运行进入Monitor(监视器)的阻塞队列阻塞,此时不占用cpu时间
    • 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
  • 等待
    • 当获取锁成功后,但由于条件不满足,调用了wait方法,此时从可运行状态释放锁,进入Monitor等待集合等待,同样不占用cpu时间
    • 当其他持锁线程调用notify或者notifyAll方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
  • 有时限等待
    • 当获取锁成功后,但由于条件不满足,调用了wait(long)方法,此时从可运行状态释放锁进入Monitor等待集合进行有时限等待,同样不占用CPU时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
    • 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
    • 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态

新建三个线程,如何保证它们按顺序执行

在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的join方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

join():等待线程运行结束

代码举例:

为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成

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
public class JoinTest {

public static void main(String[] args) {

// 创建线程对象
Thread t1 = new Thread(() -> {
System.out.println("t1");
}) ;

Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}) ;


Thread t3 = new Thread(() -> {
try {
t2.join(); // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}) ;

// 启动线程
t1.start();
t2.start();
t3.start();

}
}

notify()和notifyAll()有什么区别

  • notifyAll:唤醒所有wait的线程
  • notify:只随机唤醒一个wait线程
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
public class WaitNotify {

static boolean flag = false;
static Object lock = new Object();

public static void main(String[] args) {

Thread t1 = new Thread(() -> {
synchronized (lock){
while (!flag){
System.out.println(Thread.currentThread().getName()+"...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+"...flag is true");
}
});

Thread t2 = new Thread(() -> {
synchronized (lock){
while (!flag){
System.out.println(Thread.currentThread().getName()+"...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+"...flag is true");
}
});

Thread t3 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " hold lock");
lock.notifyAll();
flag = true;
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
t3.start();

}

}

在java中的wait和sleep方法的不同

共同点

  • wait()、wait(long)和sleep(long)的效果都是让当前线程暂时放弃cpu的使用权,进入阻塞状态

不同点

  • 方法归属不同
    • sleep(long)是Thread的静态方法
    • 而wait(),wait(long)都是Object的成员方法,每个对象都有
  • 醒来时机不同
    • 执行sleep(long)和wait(long)的线程都会在等待相应毫秒后醒来
    • wait(long)和wait()还可以被notify唤醒,wait()如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)
    • wait方法的调用必须先获取wait对象的锁,而sleep则无此限制
    • wait方法执行后会释放对象锁,允许其他线程获得该对象锁(我放弃cpu,但你们还可以用)
    • 而sleep如果在synchronized代码块中执行,并不会释放锁(我放弃cpu,你们也用不了)
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
public class WaitSleepCase {

static final Object LOCK = new Object();

public static void main(String[] args) throws InterruptedException {
sleeping();
}

private static void illegalWait() throws InterruptedException {
LOCK.wait();
}

private static void waiting() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("waiting...");
LOCK.wait(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();

Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}

}

private static void sleeping() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("sleeping...");
Thread.sleep(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();

Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}
}
}

如何停止一个正在运行的线程

有三种方式可以停止线程

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
  • 使用stop方法强行终止(不推荐,方法已作废)
  • 使用interrupt方法中断线程

使用退出标志,使线程正常退出

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
public class MyInterrupt1 extends Thread {

volatile boolean flag = false ; // 线程执行的退出标记

@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) throws InterruptedException {

// 创建MyThread对象
MyInterrupt1 t1 = new MyInterrupt1() ;
t1.start();

// 主线程休眠6秒
Thread.sleep(6000);

// 更改标记为true
t1.flag = true ;

}
}

使用stop方法强行终止(不推荐)

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
public class MyInterrupt2 extends Thread {

volatile boolean flag = false ; // 线程执行的退出标记

@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) throws InterruptedException {

// 创建MyThread对象
MyInterrupt2 t1 = new MyInterrupt2() ;
t1.start();

// 主线程休眠2秒
Thread.sleep(6000);

// 调用stop方法
t1.stop();

}
}

使用interrupt方法中断线程

  • 打断阻塞的线程(sleep,wait,join)的线程,线程会抛出InterruptedException异常
  • 打断正常的线程,可以根据打断状态来标记是否退出线程
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
public class MyInterrupt3 {

public static void main(String[] args) throws InterruptedException {

//1.打断阻塞的线程 或报异常
/*Thread t1 = new Thread(()->{
System.out.println("t1 正在运行...");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
Thread.sleep(500);
t1.interrupt();
System.out.println(t1.isInterrupted());*/


//2.打断正常的线程
Thread t2 = new Thread(()->{
while(true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
if(interrupted) {
System.out.println("打断状态:"+interrupted);
break;
}
}
}, "t2");
t2.start();
Thread.sleep(500);
t2.interrupt();

}
}

线程安全

synchronized关键字底层原理

基本使用

​ 如下抢票的代码,如果不加锁,就会出现超卖或者一张票卖给多人。

​ synchronized(对象锁)采用互斥的方式让同一时刻最多只有一个线程能持有(对象锁),其他线程再想获取这个(对象锁)时就会阻塞住。

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

public class TicketDemo {

static Object lock = new Object();
int ticketNum = 10;


public void getTicket() {
synchronized (this) {
if (ticketNum <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
// 非原子性操作
ticketNum--;
}
}

public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}


}

Monitor

​ Monitor被翻译为监视器,是由jvm提供,c++语言实现

  • Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
  • 在JDK1.6引入两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下,因使用传统锁机制带来的性能开销问题。

在代码中想要体现monitor需要借助javap命令查看class的字节码,比如一下代码

1
2
3
4
5
6
7
8
9
10
public class SyncTest{
static final Object lock = new Object();
static int counter = 0;

public static void main(String[] args){
synchronized(lock){
counter++;
}
}
}

找到这个类的class文件,在class文件目录下执行javap -v SyncTest.class,反编译效果如下:

image-20230504165342501

  • monitorenter 上锁开始的地方
  • monitorexit 解锁的地方
  • 其中被monitorenter和monitorexit包围住的指令就是上锁的代码
  • 有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁

在使用了synchornized代码时需要指定一个对象,所以synchornized也被成为对象锁

monitor主要就是跟这个对象产生关联,如下图

image-20230504165833809

Monitor内部具体的存储结构

  • Owner:存储当前获取锁的线程,只能有一个线程可以获取
  • EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
  • WaitSet:关联调用wait方法的线程,处于Waiting状态的线程

具体的流程:

  1. 代码进入synchronized代码块,先让lock(对象锁)关联的Monitor,然后判断Owner是否有线程持有
  2. 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
  3. 如果有线程持有,则让当前线程进入entryList进行阻塞,如果owner持有的线程已经释放了锁,在entryList中的线程会去竞争锁的持有权(非公平)
  4. 如果代码块中调用了wait()方法,则会进入WaitSet中进行等待

对象的内存结构

​ 在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充

image-20230504172253826

我们需要重点分析MarkWord对象头

MarkWord

image-20230504172541922

  • hashcode:25位的对象标识Hash码
  • age:对象分代年龄占4位
  • biased_lock:偏向锁标识,占1位,0表示没有开始偏向锁,1表示开启了偏向锁
  • thread:持有偏向锁的线程ID,占23位
  • epoch:偏向时间戳,占2位
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占30位
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针,占30位

我们可以通过lock的标识,来判断是哪一种锁的等级

  • 后三位是001表示无锁
  • 后三位是101表示偏向锁
  • 后两位是00表示轻量级锁
  • 后两位是10表示重量级锁

Monitor重量级锁

​ 每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针

image-20230504172957271

简单来说就是:每个对象的对象头都可以设置Monitor的指针,让对象与Monitor产生关联

轻量级锁

在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步代码块中的代码。这种情况下,用重量级锁时没必要的,因此JVM引入轻量级锁的概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static final Object obj = new Object();

public static void method1(){
synchronized(obj){
//同步块A
method2();
}
}


public static void method2(){
synchronized(obj){
//同步块B
}
}
加锁的流程

image-20230504173520412

  1. 在线程栈中创建一个Lock Record,将其obj字段(Object reference)指向锁对象

  2. 通过CAS指令将Lock Record的地址存储在对象头的Mark word中(数据进行交换),如果对象处于无所状态,则修改成功,代表该线程获得了轻量级锁

    image-20230504173611219

  3. 如果当前线程已经持有该锁了,代表这是一次锁重入,设置Lock Record第一部分为null,起到了一个重入计数器的作用

    image-20230504173922343

  4. 如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁

解锁过程
  1. 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record

  2. 如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue

    image-20230504173955680

  3. 如果Lock Record的Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则为膨胀为重量级锁

    image-20230504174045458

偏向锁

​ 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。

Java6中引入偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static final Object obj = new Object();

public static void m1() {
synchronized (obj) {
// 同步块 A
m2();
}
}

public static void m2() {
synchronized (obj) {
// 同步块 B
m3();
}
}

public static void m3() {
synchronized (obj) {

}
}
加锁的流程
  1. 在线程栈中创建一个Lock Record,将其obj字段指向锁对象

    image-20230504174525256

  2. 通过CAS指令将Lock Record的线程id存储在对象头的mark word中,同时也设置偏向锁的标识101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁。

    image-20230504174505031

  3. 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。与轻量级锁不同时,这里不会再次进行CAS操作,只是判断对象头中的线程id是否为自己,因为缺少了CAS操作,性能相对轻量级更好一些

    image-20230504174736226

解锁流程参考轻量级锁。

参考回答

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

描述
重量级锁底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
偏向锁一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

一旦锁发生了竞争,都会升级为重量级锁

JMM(Java内存模型)

JMM(java memory model)java内存模型,是java虚拟机规范中所定义的一种内存模型。

描述了java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

image-20230504181638237

特点:

  1. 所有的共享变量都存储于主内存,这里说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有,因此不存在竞争问题.
  2. 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本
  3. 线程对变量的所有操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。

CAS

概述和基本工作流程

CAS全称是:Compare And Swap(比较再交换),它体现一种乐观锁的思想,在无锁的情况下保证线程操作共享数据的原子性。

在JUC(java.util.concurrent)包下实现的很多类都用到了CAS操作

  • AbstractQueuedSynchronizer(AQS框架)
  • AtomicXXX类

例子:

基于JMM内存模型进行说明

  • 线程1和线程2都从主内存中获取变量int a=100,同时放到各个线程的工作内存中

    image-20230504181947319

  • 线程1操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 101 (a++)

    • 线程1拿A的值与主内存V的值进行比较,判断是否相等

    • 如果相等,则把B的值101更新到主内存中

    image-20230504182129820

  • 线程2操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 99(a–)

    • 线程2拿A的值与主内存V的值进行比较,判断是否相等(目前不相等,因为线程1已更新V的值99)

    • 不相等,则线程2更新失败

    image-20230504181827330

  • 自旋锁操作

    • 因为没有加锁,所以线程不会陷入阻塞,效率较高
    • 如果竞争激烈,重试频繁发生,效率会受影响
    1
    2
    3
    4
    5
    6
    7
    8
    //需要不断尝试
    while(true){
    int 旧值A = 共享变量V;
    int 结果B = 旧值+1;
    if(compareAndSwap(旧值,结果)){
    //成功,退出循环
    }
    }

    需要不断尝试获取共享内存V中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功

CAS底层实现

​ CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令。

​ 都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现

​ 在java中比较常见使用有很多,比如ReentrantLock和Atomic开头的线程安全类,都调用了Unsafe中的方法

  • RerntrantLock中的一段CAS代码

    image-20230504182958703

乐观锁和悲观锁

  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗
  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会

volatile关键字

一旦一个共享变量(类的成员变量,类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  1. 保证线程间的可见性
  2. 禁止进行指令重排序

保证线程间的可见性

​ 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

一个典型的例子:永不停止的循环

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
// 可见性例子
// -Xint
public class ForeverLoop {
static boolean stop = false;

public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
System.out.println("modify stop to true...");
}).start();
foo();
}

static void foo() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("stopped... c:"+ i);
}
}

当执行上述代码的时候,发现foo方法中的循环结束不了,也就是说读取不到共享变量的值去结束循环。

主要是因为在JVM虚拟机中有一个JIT(即时编辑器)给代码做了个优化(在很短的时间内,这个代码执行的次数太多了,当达到了一个阈值,JIT就会优化此代码,比如将!stop直接改为ture,这样就和stop的值不同了)

解决方案:

  1. 在程序运行的时候加入vm参数-Xint表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用)
  2. 在修改stop变量的时候加上volatile,表示当前代码禁用了即时编辑器,问题就可以解决

禁止进行指令重排序

用volatitle修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int x;
int y;

@Actor
public void actor1(){
x =1;
y =1;
}

@Actor
public void actor2(II_Result r){
r.r1 = y;
r.r2 = x;
}

在获取上面的结果时,有可能会出现4种情况

  1. 先执行actor2获取结果—>0,0(正常)
  2. 先执行actor1中的第一行代码,然后执行actor2获取结果–>0,1(正常)
  3. 先执行actor1中所有的代码,然后执行actor2获取结果—>1,1(正常)
  4. 先执行actor1中第二行代码,然后actor2获取结果—<1,0(发生了指令重排序,影响结果)

解决方案

​ 在变量上添加volatile,禁止指令重排序,则可以解决问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int x;
int y;

@Actor
public void actor1(){
x =1;
y =1;
}

@Actor
public void actor2(II_Result r){
r.r1 = y;
r.r2 = x;
}

屏障添加的示意图

image-20230505082923729

  • 写操作加的屏障是阻止上方其他写操作越过屏障排到volatile变量写之下
  • 读操作加的屏障是阻止下方其他读操作越过屏障排到volatitle变量读之上
其他补充

我们上面的解决方案是把volatile加在了int y这个变量上,我们能不能把它加在int x这个变量上呢?

加在x上的屏障示意图

image-20230505083217904

这样显然是不行的,主要是因为下面两个原则:

  • 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
  • 读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上

所以,现在我们就可以总结一个volatile使用的小妙招:

  • 写变量让volatile修饰的变量的在代码最后位置
  • 读变量让volatile修饰的变量的在代码最开始位置

AQS

概述

全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架,它是构建锁或者其他同步组件的基础框架

AQS与Synchronized的区别
synchronizedAQS
关键字,c++ 语言实现java 语言实现
悲观锁,自动释放锁悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差锁竞争激烈的情况下,提供了多种解决方案

AQS常见的实现类

  • ReentrantLock 阻塞式锁

  • Semaphore 信号量

  • CountDownLatch 倒计时锁

工作机制

  • 在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,0表示无锁,1表示有锁
  • 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet

image-20230505083840046

工作流程:

  1. 线程0来了之后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
  2. 线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待
  3. FIFO是一个双向队列,head属性表示头结点,tail表示尾节点
如何保证资源原子性

image-20230505084451193

在去修改state状态的时候,使用的CAS自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待。

AQS是公共平锁还是非公平锁

都可能,取决于是那种模式

  • 新的线程与队列中的线程共同来抢资源,是非公平锁
  • 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁

比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源

ReentrantLock实现原理

ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:

  • 可中断

    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
    @Test
    public void test6()throws Exception{
    //创建锁对象
    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(()->{
    //开启可中断的锁
    try {
    lock.lockInterruptibly();
    } catch (InterruptedException e) {
    e.printStackTrace();
    System.out.println("等待的过程被打断");
    return;
    }

    try{
    System.out.println(Thread.currentThread().getName()+",获得了锁");
    }finally {
    lock.unlock();
    }
    },"t1");
    lock.lock();
    System.out.println("主线程获得了锁");
    //这里t1线程只是进入可执行状态,具体什么时候执行代码需要取竞争cpu的
    t1.start();

    try {
    ////主线程调用sleep方法,并不会释放锁,会全部等待1秒钟
    Thread.sleep(1000);
    //执行t1线程的打断
    t1.interrupt();
    System.out.println("执行打断");
    }finally {
    lock.unlock();
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    主线程获得了锁
    执行打断
    java.lang.InterruptedException
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchronizer.java:898)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1222)
    at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
    at com.zhuixun.test.ThreadDemo.lambda$test6$2(ThreadDemo.java:126)
    at java.lang.Thread.run(Thread.java:748)
    等待的过程被打断
  • 可以设置超时时间

    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
    @Test
    public void timeOutLock()throws Exception{
    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(()->{
    //尝试获取锁,如果获取锁成功,返回true,否则返回false
    try {
    //尝试获取锁失败后,在等3秒钟,如果还是失败就打印获取锁失败
    if (!lock.tryLock(3,TimeUnit.SECONDS)){
    System.out.println("t1-获取锁失败");
    return;
    }
    } catch (InterruptedException e) {
    e.printStackTrace();
    }

    try{
    System.out.println("t1线程-获得了锁");
    }finally {
    lock.unlock();
    }
    },"t1");
    lock.lock();
    System.out.println("主线程获得了锁");
    t1.start();
    try {
    Thread.sleep(2000);
    }finally {
    lock.unlock();
    }
    }
  • 可以设置公平锁(有个构造方法,参数传递为true就开启了公平锁)

  • 支持多个条件变量

    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
    @Test
    public void conditionTest(){
    ReentrantLock lock = new ReentrantLock();
    //条件1
    Condition c1 = lock.newCondition();
    //条件2
    Condition c2 = lock.newCondition();

    new Thread(()->{
    lock.lock();
    try {
    //进入c1条件的等待
    c1.await();
    System.out.println(Thread.currentThread().getName()+",acquire lock....");
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }finally {
    lock.unlock();
    }
    },"t1").start();


    new Thread(()->{
    lock.lock();
    try {
    //进入c2条件的等待
    c2.await();
    System.out.println(Thread.currentThread().getName()+",acquire lock....");
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }finally {
    lock.unlock();
    }
    },"t2").start();

    new Thread(()->{
    lock.lock();
    try {
    //唤醒c1条件的线程
    c1.signal();
    //唤醒c2条件的线程
    c2.signal();
    System.out.println(Thread.currentThread().getName()+",acquire lock....");
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }finally {
    lock.unlock();
    }
    },"t3").start();
    }


    //控制台打印顺序:t3、t2、t1
  • 与synchronized一样,都支持重入

1
2
3
4
5
6
7
8
9
10
//创建锁对象
ReentrantLock lock = new ReentrantLock();

try{
//获取锁
lock.lock();
}finally{
//释放锁
lock.unlock();
}

实现原理

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似。

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

查看ReentrantLock源码中的构造方法:

1
2
3
4
5
6
7
public ReentrantLock(){
sync = new NonfairSync();
}

public ReentrantLock(boolean fair){
sync = fair?new FairSync():new NonfairSync();
}
  • 提供了两个构造方法,不带参数的默认为非公平

  • 如果使用带参数的构造函数,并且传的值为true,则是公平锁

其中NonfairSync和FairSync这两个类的父类都是Sync,而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的

image-20230505091833629

工作流程

image-20230505092340431

  1. 线程来抢锁候使用CAS的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功
  2. 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
  3. 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程
  4. 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁

synchronized和Lock有什么区别

  • 语法层面
    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
  • 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
  • 性能层面
    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈是,Lock的实现通常会提供更好的性能

死锁产生的条件

死锁:一个线程需要同时获取多把锁,这是就容易发生死锁

例如:

  • t1线程获得A对象锁,接下来想获取B对象的锁
  • t2线程获得B对象锁,接下来想获取A对象的锁

代码如下

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
import static java.lang.Thread.sleep;

public class Deadlock {

public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("lock A");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("lock B");
System.out.println("操作...");
}
}
}, "t1");

Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println("lock B");
try {
sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println("lock A");
System.out.println("操作...");
}
}
}, "t2");
t1.start();
t2.start();
}
}

控制台输出结果:

image-20220902171032898

此时程序并没有结束,这种现象就是死锁现象,线程t1持有A的锁,等待获取B锁,线程t2持有B的锁等待获取A的锁

死锁诊断

当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack

步骤如下

  1. 查看运行线程

    image-20220902171426738

  2. 使用jstack查看线程运行的情况(根据上面得到的id运行如下指令:jstack -l 46032)

    image-20220902172229567

可视化工具

jconsole

用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具

打开方式:java安装目录bin目录下直接启动jconsole.exe就行

VisuaIVM

能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈

打开方式:java安装目录bin目录下直接启动jvisualvm.exe就行

ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的高效Map集合

底层数据结构:

  • JDK1.7底层采用分段的数组+链表实现

  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

JDK1.7中的ConcurrentHashMap

数据结构

image-20230505092654811

  • 提供了一个segment数组,在初始化ConcurrentHashMap的时候可以指定数组的长度,默认是16,一旦初始化之后,中间不可扩容
  • 在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的
  • 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表
存储流程

image-20230505093055382

  • 先去计算key的hash值,然后确定segment数组下标
  • 再通过hash值确定hashEntry数组中的下标存储数据
  • 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁是被会使用cas自旋锁进行尝试

JDK1.8中ConcurrentHashMap

在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表

采用 CAS + Synchronized来保证并发安全进行实现

  • CAS控制数组节点的添加

  • synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升

image-20230505093507265

导致并发程序出现问题的根本原因

Java并发编程的三大特性

  • 原子性
  • 可见性
  • 有序性

原子性

一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行

解决方案

  1. synchronized:同步加锁

  2. JUC里面的lock:加锁

内存可见性

让一个线程对共享变量的修改对另一个线程可见

解决方案:

  • synchronized

  • volatile(推荐)

  • LOCK

有序性

指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

解决方案:

  • volatile

线程池

核心参数以及执行原理

核心参数

主要参考ThreadPoolExecutor这个类的7个参数的构造函数

1
2
3
4
5
6
7
public ThreadPoolExcutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExceptionHandler handler)
  • corePoolSize

    核心线程数目

  • maximumPoolSize

    最大线程数目=(核心线程+救急线程的最大数目)

  • keepAliveTime

    生存时间,救急线程的生存时间,生存时间内没有新任务,此线程资源会释放

  • unit

    时间单位,救急线程的生存时间单位,如秒、毫秒等

  • workQueue

    阻塞队列,当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  • threadFactory

    线程工厂,可以定制线程对象的创建,例如设置线程名字,是否是守护线程等

  • handle

    拒绝策略,当所有线程都在繁忙,workQueue也放满时,会触发拒绝策略

工作流程

image-20230505220701835

  1. 任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
  2. 如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
  3. 如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
  4. 如何核心或临时线程执行完后,会检查阻塞队列中是否需要执行的线程,如果有,则使用线程执行任务
  5. 如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略

拒绝策略

  1. AbortPolicy

    直接抛出异常,默认策略

  2. CallerRunPolicy

    用调用者所在的线程来执行任务

  3. DiscardOldestPolicy

    丢弃阻塞队列中最靠前的任务,并执行当前任务

  4. DiscardPolicy

    直接丢弃任务

参考代码

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
public class TestThreadPoolExecutor {

static class MyTask implements Runnable {
private final String name;
private final long duration;

public MyTask(String name) {
this(name, 0);
}

public MyTask(String name, long duration) {
this.name = name;
this.duration = duration;
}

@Override
public void run() {
try {
LoggerUtils.get("myThread").debug("running..." + this);
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

@Override
public String toString() {
return "MyTask(" + name + ")";
}
}

public static void main(String[] args) throws InterruptedException {
AtomicInteger c = new AtomicInteger(1);
ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(2);
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2,
3,
0,
TimeUnit.MILLISECONDS,
queue,
r -> new Thread(r, "myThread" + c.getAndIncrement()),
new ThreadPoolExecutor.AbortPolicy());
showState(queue, threadPool);
threadPool.submit(new MyTask("1", 3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("2", 3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("3"));
showState(queue, threadPool);
threadPool.submit(new MyTask("4"));
showState(queue, threadPool);
threadPool.submit(new MyTask("5",3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("6"));
showState(queue, threadPool);
}

private static void showState(ArrayBlockingQueue<Runnable> queue, ThreadPoolExecutor threadPool) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
List<Object> tasks = new ArrayList<>();
for (Runnable runnable : queue) {
try {
Field callable = FutureTask.class.getDeclaredField("callable");
callable.setAccessible(true);
Object adapter = callable.get(runnable);
Class<?> clazz = Class.forName("java.util.concurrent.Executors$RunnableAdapter");
Field task = clazz.getDeclaredField("task");
task.setAccessible(true);
Object o = task.get(adapter);
tasks.add(o);
} catch (Exception e) {
e.printStackTrace();
}
}
LoggerUtils.main.debug("pool size: {}, queue: {}", threadPool.getPoolSize(), tasks);
}

}

常见的阻塞队列

比较常见的有4个,用的最多的是ArrayBlockingQueue和LinkedBlockingQueue

  1. ArrayBlockingQueue

    基于数组结构的有界阻塞队列,FIFO(先进先出)

  2. LinkedBlockingQueue

    基于链表结构的有界阻塞队列,FIFO(先进先出)

  3. DelayedWorkQueue

    是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的

  4. SynchronousQueue

    不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作

ArrayBlockingQueue与LinkedBlockingQueue区别

LinkedBlockingQueueArrayBlockingQueue
默认无界,支持有界(new时不需要设置大小,但是有对应设置大小的构造方法)强制有界(new时需要设置大小)
底层是链表底层是数组
是懒惰的,创建节点的时候添加数据提前初始化 Node 数组
入队会生成新 NodeNode需要是提前创建好的
两把锁(头尾)一把锁

左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式

  • LinkedBlockingQueue读和写各有一把锁,性能相对较好
  • ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些

image-20230505221424359

如何确定核心线程数

在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型

  • IO密集型任务

    一般来说,文件读写、DB读写、网络请求等

​ 推荐:核心线程数大小设置为2N+1(N为计算机的CPU核数)

  • CPU密集型任务

    一般来说,计算型代码,Bitmap转换,Gson转换等

    推荐:核心线程数大小设置为N+1(N为计算机的CPU核数)

java代码查看cpu核数

1
2
3
public static void main(String[] args){
System.out.println(Runtime.getRuntime().availableProcessors());
}

参考回答

  1. 高并发、任务执行时间短,推荐使用cpu核数+1,减少线程上下文的切换
  2. 并发不高、任务执行时间长
    • IO密集型任务–>(cpu核数*2+1)
    • 计算密集型任务–>(cpu核数+1)
  3. 高并发、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,参考第二点

线程池种类

在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见的就有四种

创建使用固定线程数的线程池

1
2
3
4
5
6
public static ExecutorService newFixedThreadPool(int nThreads){
return new ThreadPoolExecutor(nThreads,nThreads,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>);
}
  • 核心线程与最大线程数一样,没有救急线程

  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE

  • 使用场景:适用于任务量已知,相对耗时的任务

  • 案例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class FixedThreadPoolCase {

    static class FixedThreadDemo implements Runnable{
    @Override
    public void run() {
    String name = Thread.currentThread().getName();
    for (int i = 0; i < 2; i++) {
    System.out.println(name + ":" + i);
    }
    }
    }

    public static void main(String[] args) throws InterruptedException {
    //创建一个固定大小的线程池,核心线程数和最大线程数都是3
    ExecutorService executorService = Executors.newFixedThreadPool(3);

    for (int i = 0; i < 5; i++) {
    executorService.submit(new FixedThreadDemo());
    Thread.sleep(10);
    }

    executorService.shutdown();
    }
    }

单线程化的线程池

它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行

1
2
3
public static ExecutorService newSingleThreadExecutor(){
return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1,1,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}
  • 核心线程数和最大线程数都是1

  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE

  • 使用场景:适用于按照顺序执行的任务

  • 案例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class NewSingleThreadCase {

    static int count = 0;

    static class Demo implements Runnable {
    @Override
    public void run() {
    count++;
    System.out.println(Thread.currentThread().getName() + ":" + count);
    }
    }

    public static void main(String[] args) throws InterruptedException {
    //单个线程池,核心线程数和最大线程数都是1
    ExecutorService exec = Executors.newSingleThreadExecutor();

    for (int i = 0; i < 10; i++) {
    exec.execute(new Demo());
    Thread.sleep(5);
    }
    exec.shutdown();
    }

    }

可缓存线程池

1
2
3
public static ExecutorService newCachedThreadPool(){
return new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L,TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
  • 核心线程数为0

  • 最大线程数是Integer.MAX_VALUE

  • 阻塞队列为SynchronousQueue,不存储元素的阻塞队列,每个插入操作都必须等待一个移除操作

  • 适用场景:适合任务数比较密集,但每个任务执行时间较短的情况

  • 案例:

    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
    public class CachedThreadPoolCase {

    static class Demo implements Runnable {
    @Override
    public void run() {
    String name = Thread.currentThread().getName();
    try {
    //修改睡眠时间,模拟线程执行需要花费的时间
    Thread.sleep(100);

    System.out.println(name + "执行完了");
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }

    public static void main(String[] args) throws InterruptedException {
    //创建一个缓存的线程,没有核心线程数,最大线程数为Integer.MAX_VALUE
    ExecutorService exec = Executors.newCachedThreadPool();
    for (int i = 0; i < 10; i++) {
    exec.execute(new Demo());
    Thread.sleep(1);
    }
    exec.shutdown();
    }

    }

提供了延迟和周期执行功能的线程池

image-20230505222203615

  • 适用场景:有定时和延迟执行的任务

  • 案例

    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
    public class ScheduledThreadPoolCase {

    static class Task implements Runnable {
    @Override
    public void run() {
    try {
    String name = Thread.currentThread().getName();

    System.out.println(name + ", 开始:" + new Date());
    Thread.sleep(1000);
    System.out.println(name + ", 结束:" + new Date());

    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }

    public static void main(String[] args) throws InterruptedException {
    //按照周期执行的线程池,核心线程数为2,最大线程数为Integer.MAX_VALUE
    ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2);
    System.out.println("程序开始:" + new Date());

    /**
    * schedule 提交任务到线程池中
    * 第一个参数:提交的任务
    * 第二个参数:任务执行的延迟时间
    * 第三个参数:时间单位
    */
    scheduledThreadPool.schedule(new Task(), 0, TimeUnit.SECONDS);
    scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS);
    scheduledThreadPool.schedule(new Task(), 5, TimeUnit.SECONDS);

    Thread.sleep(5000);
    // 关闭线程池
    scheduledThreadPool.shutdown();
    }
    }

为什么不建议使用Executors创建线程池

image-20220821003816845

主要原因是:允许创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM

线程使用场景问题

项目哪里用到了多线程

CountDownLatch

​ countDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)

  • 其中构造参数用来初始化等待计数值
  • await()用来等待计数归零
  • countDown()用来让计数减一

image-20230505223014946

案例代码:

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
public class CountDownLatchDemo {

public static void main(String[] args) throws InterruptedException {
//初始化了一个倒计时锁 参数为 3
CountDownLatch latch = new CountDownLatch(3);

new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
String name = Thread.currentThread().getName();
System.out.println(name + "-waiting...");
//等待其他线程完成
latch.await();
System.out.println(name + "-wait end...");
}

}

es数据批量导入

在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出

整体流程就是通过CountDownLatch+线程池配合去执行

image-20230505223219951

详细实现流程:

image-20230505223246059

数据汇总

在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?

image-20230505223442924

  • 在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能
  • 报表汇总

异步调用

image-20230505223640038

在进行搜索的时候,需要保存用户的搜索记录,而搜索记录不能影响用户的正常搜索,我们通常会开启一个线程去执行历史记录的保存,在新开启的线程在执行的过程中,可以利用线程提交任务

如何控制某个方法允许并发访问线程数量

Semaphore(信号量),是JUC包下的一个工具类,我们可以通过其限制执行线程的数量,达到限流的效果。

当一个线程执行时,先通过其方法进行获取许可操作,获取到许可的线程继续执行业务逻辑,当线程执行完成之后进行释放许可操作,未获取达到许可的线程进行等待或者直接结束。

  • lsemaphore.acquire

    请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,即信号量个数变为负数时,再次请求的时候就是阻塞,直到其他线程释放了信号量)

  • lsemaphore.release()

    释放一个信号量,此时信号量个数+1

线程任务类

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
public class SemaphoreCase {
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {

try {
// 3. 获取许可
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
System.out.println("running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
} finally {
// 4. 释放许可
semaphore.release();
}
}).start();
}
}
}

其他

ThreadLocal的理解

概述

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享

案例:

使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。

image-20230505224057228

基本使用

三个主要方法:

  • set(value) 设置值
  • get() 获取值
  • remove() 清除值
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
public class ThreadLocalTest {
static ThreadLocal<String> threadLocal = new ThreadLocal<>();

public static void main(String[] args) {
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itcast");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t1").start();
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itheima");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t2").start();
}

static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + threadLocal.get());
//清除本地内存中的本地变量
threadLocal.remove();
}

}

实现原理&源码解析

ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离

image-20230505224341410

在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap

ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置

set方法

image-20230505224626253

get方法/remove方法

image-20230505224715087

内存泄露问题

Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用

  • 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收

    1
    User user = new User();
  • 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收

    1
    2
    User user = new User();
    WeakReference weakReference = new WeakReference(user);

每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为强引用,所以key在CG时会被回收,而value则不会回收,如果数据多起来则会造成内存泄露。

image-20230505224857538

在使用ThreadLocal的时候,强烈建议:务必手动remove释放key,value,避免内存泄露的问题

JVM篇

JVM组成

JVM是什么

Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)

好处:

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收机制

image-20230506094254360

JVM组成

image-20230506094411247

从图中可以看出JVM的主要组成部分:

  • classLoader(类加载器)
  • Runtime Data Area (运行时数据区,内存分区)
  • Execution Engine(执行引擎)
  • Native Method Library(本地库接口)

从图中可以看出JVM内存结构如下:

  • 程序计数器(PC Register)
  • 虚拟机栈(JVM Stacks)
  • 本地方法栈(Native Method Stack)
  • 堆(Heap)
  • 方法区/元空间(Method Area/MateSpace)

运行流程

  1. 类加载器(calssLoader)把java代码转换为字节码
  2. 运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行
  3. 执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序功能

JVM内存结构

​ 组成部分:堆、方法区、虚拟机栈、本地方法栈、程序计数器

  1. 堆解决的是对象实例存储的问题,垃圾回收器管理的主要区域
  2. 方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量,静态变量,即时编译器编译后的代码
  3. 栈解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口信息等
  4. 本地方法栈与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码的接口
  5. 程序计数器(PC寄存器),存放的是当前线程所执行的字节码行数。JVM工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令

什么是程序计数器

​ 程序计数器是线程私有的,内部保存的字节码的行号,用于记录正在执行的字节码指令的地址。

可以通过java -verbose xx.class 打印堆栈大小,局部变量的数量和方法的参数

image-20230506094602329

​ java虚拟机对于多线程是通过线程轮流切换并且分配线程执行时间。在任何的一个时间节点上,一个处理器只会处理执行一个线程,如果当前被执行的这个线程它所分配的执行时间用完了[挂起]。

处理器会切换到另外一个线程上来执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。

​ 那么现在有一个问题就是,当前处理器如何能够知道,对于这个被挂起的线程,它上一次执行到了哪里?这时就需要通过程序计数器中记录的行号,回到当前线程上一次执行到的位置,然后接着继续向下执行。

程序计数器是JVM规范中唯一一个没有规定出现OOM的区域,所以这个空间也不会进行GC

Java堆

​ 线程共享区域:主要用来保存对象实例,数组等。当堆中没有内存空间可分配给实例,也无法扩展时,则抛出OutOfMemoryError异常

image-20230506094803545

  • 年轻代被划分为三部分:Eden区(伊甸园区)和两个大小严格相同的Survivor区(幸存区),根据JVM策略,在经过几次垃圾收集后,仍然存活与幸存区的对象将被移动到老年代区间
  • 老年代主要保存生命周期长的对象,一般是一些老的对象
  • 元空间保存的类信息、静态变量、常量、编译后的代码

为了避免方法区出现OOM,所在Java8将堆上的方法区(或者叫永久代)给移动到了本地内存上,重新开辟了一块空间,叫做元空间。

image-20230506094938843

元空间

​ 在HotSpot JVM中,永久代( ≈ 方法区)中用于存放类和方法的元数据以及常量池,比如Class和Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。

​ 永久代是有大小限制的,因此如果加载的类太多,很可能导致永久代内存溢出。即OutOfMemoryError,为此不得不对虚拟机做调优。

​ 准确来说,永久代中的字符串常量池被移到了堆内存中是在Java7之后,java8时,永久代被元空间替代,其他内容比如:类元信息(java/lang/Object)、字段、静态属性(System.out)、方法、常量等都移动到元空间区。

​ 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

什么是虚拟机栈

  • 每个线程运行时所需要的内存,称为虚拟机栈,先进后出
  • 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

image-20230506095140595

垃圾回收是否涉及栈内存

​ 垃圾回收主要指的就是堆内存,当栈帧弹栈以后,内存就会释放。

栈内存分配越大越好吗

​ 未必,默认的栈内存通常为1024k,栈帧过大会导致线程数变少,例如,机器总内存为512M,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半

方法内的局部变量是否线程安全

  • 如果方法内,局部变量没有逃离方法的作用范围,它是线程安全的

  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

  • 示例

    image-20240308113958144

栈内存溢出情况

  • 栈帧过多导致栈内存溢出(java.lang.StackOverflowError),典型问题:递归调用
  • 栈帧过大导致栈内存溢出

堆栈的区别

  • 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组。堆会GC垃圾回收,而栈不会
  • 栈内存是线程私有的,而堆内存是线程共有的
  • 两次异常错误不同,但如果栈内存或者堆内存不足都会抛出异常
    • 栈空间不足:java.lang.StackOverFlowError
    • 堆空间不足:java.lang.OutOfMemoryError

方法区

概述

  • 方法区是各个线程共享的内存区域
  • 主要存储类的信息、运行时常量池
  • 虚拟机启动的时候创建,关闭虚拟机时释放
  • 如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace

image-20230506095504213

常量池

可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

查看字节码结构(类的基本信息、常量池、方法定义)javap -v xx.class

比如下面的一个Application类的main方法执行,源码如下:

1
2
3
4
5
public class Application {
public static void main(String[] args) {
System.out.println("hello world");
}
}

找到类对应的class文件存放目录,执行命令:javap -v Application.class 查看字节码结构

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
D:\code\jvm-demo\target\classes\com\heima\jvm>javap -v Application.class
Classfile /D:/code/jvm-demo/target/classes/com/heima/jvm/Application.class
Last modified 2023-05-07; size 564 bytes //最后修改的时间
MD5 checksum c1b64ed6491b9a16c2baab5061c64f88 //签名
Compiled from "Application.java" //从哪个源码编译
public class com.heima.jvm.Application //包名,类名
minor version: 0
major version: 52 //jdk版本
flags: ACC_PUBLIC, ACC_SUPER //修饰符
Constant pool: //常量池
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/heima/jvm/Application
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/heima/jvm/Application;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Application.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/heima/jvm/Application
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
{
public com.heima.jvm.Application(); //构造方法
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/heima/jvm/Application;

public static void main(java.lang.String[]); //main方法
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "Application.java"

下图,左侧是main方法的指令信息,右侧constant pool 是常量池

main方法按照指令执行的时候,需要到常量池中查表翻译找到具体的类和方法地址去执行

image-20230506095634842

运行时常量池

常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

image-20230506100142724

直接内存

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

示例:

需求,在本地电脑中的一个较大的文件(超过100M)从一个磁盘挪到另外一个磁盘

代码如下:

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
/**
* 演示 ByteBuffer 作用
*/
public class Demo1_9 {
static final String FROM = "E:\\编程资料\\第三方教学视频\\youtube\\Getting Started with Spring Boot-sbPSjI4tt10.mp4";
static final String TO = "E:\\a.mp4";
static final int _1Mb = 1024 * 1024;

public static void main(String[] args) {
io(); // io 用时:1535.586957 1766.963399 1359.240226
directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
}

private static void directBuffer() {
long start = System.nanoTime();
try (FileChannel from = new FileInputStream(FROM).getChannel();
FileChannel to = new FileOutputStream(TO).getChannel();
) {
ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
while (true) {
int len = from.read(bb);
if (len == -1) {
break;
}
bb.flip();
to.write(bb);
bb.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
}

private static void io() {
long start = System.nanoTime();
try (FileInputStream from = new FileInputStream(FROM);
FileOutputStream to = new FileOutputStream(TO);
) {
byte[] buf = new byte[_1Mb];
while (true) {
int len = from.read(buf);
if (len == -1) {
break;
}
to.write(buf, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("io 用时:" + (end - start) / 1000_000.0);
}
}

可以发现,使用传统的IO的时间要比NIO操作的时间长了很多了,也就说NIO的读性能更好。

这个是跟我们的JVM的直接内存是有一定关系,如下图,是传统阻塞IO的数据传输流程

image-20230506100548455

下图是NIO传输数据的流程,在这个里面主要使用到了一个直接内存,不需要在堆中开辟空间进行数据的拷贝,jvm可以直接操作直接内存,从而使数据读写传输更快。

image-20230506100621146

类加载器

概述

​ 要想理解类加载器的话,务必要先清楚对于一个Java文件,它从编译到执行的整个过程

  • 类加载器:用于装载字节码文件(.class文件)
  • 运行时数据区:用于分配存储空间
  • 执行引擎:执行字节码文件或本地方法
  • 垃圾回收器:用于对JVM中的垃圾内容进行回收

JVM只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。

​ 现有的类加载器基本上都是java.lang.ClassLoader的子类,该类的主要职责就是用于将指定的类找到或生成对应的字节码文件,同时类加载器还会负责加载程序所需的资源。

类加载器种类

​ 类加载器根据各自加载范围的不同,划分为四种类加载器

  • 启动类加载器(BootStrap ClassLoader)

    该类并不继承ClassLoader类,其实是由C++编写实现的,用于加载JAVA_HOME/jre/lib目录下的类库

  • 扩展类加载器(ExtClassLoader)

    该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库

  • 应用类加载器(AppClassLoader)

    该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类

  • 自定义类加载器

    开发者自定义类继承ClassLoader,实现自定义类加载规则

上述三种类加载器的层次结构如下:

image-20230506100746624

类加载器的体系并不是继承体系,而委派体系,类加载器首先会到自己的parent中查找类或者资源,如果找不到才会到自己本地查找。类加载器的委托行为动机是为了避免相同的类加载多次。

双亲委派机制

​ 如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载可以完成类加载任务,就返回成功;只有父类加载器无法加载此任务时,才由下一级去加载。

采用该机制原因

  1. 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。

  2. 为了安全,保证类库API不会被修改

    在工程中新建java.lang包,接着在该包下新建String类,并定义main函数

    1
    2
    3
    4
    5
    6
    7
    public class String {

    public static void main(String[] args) {

    System.out.println("demo info");
    }
    }

    此时执行main函数,会出现异常,在类 java.lang.String 中找不到 main 方法。

    出现该信息是因为由双亲委派的机制,java.lang.String的在启动类加载器(Bootstrap classLoader)得到加载,因为在核心jre库中有其相同名字的类文件,但该类中并没有main方法。这样就能防止恶意篡改核心API库。

类加载的执行过程

​ 类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用、卸载这个7个进阶段。其中,验证、准备和解析这个三个部分统称为连接(linking)

image-20230506101032605

加载

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口。

image-20230506101213373

验证

验证类是否符合JVM规范,安全性检查

  1. 文件格式验证:是否符合Class文件的规范

  2. 元数据验证验证

    1. 这个类是否有父类(除Object这个类之外,其余的类都应该有父类)
    2. 这个类是否继承(extends)了被final修饰过的类(被final修饰过的类表示类不能被继承)
    3. 类中的字段、方法是否与父类产生矛盾。(被final修饰过的方法或字段是不能覆盖的)

  3. 字节码验证

    ​ 主要的目的是通过数据流和控制流的分析,确定程序语义是合法的,符合逻辑的

  4. 符号引用验证

符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量。

比如:int i =3;字面量:3 符号引用 i

准备

为类变量分配内存并设置类变量初始值

  • static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
  • static变量是final的基本类型,以及字符串常量,值已确定,赋值在准备阶段完成
  • static变量是final的引用类型,那么赋值会在初始化阶段完成
1
2
3
4
5
6
public class Application{
static int b =10;
static final int c = 20;
static final String d = "hello";
static final Object obj = new Object();
}

解析

把类中的符号引用转换为直接引用

比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。

image-20230506102311951

初始化

对类的静态变量,静态代码块执行初始化操作

  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

使用

JVM 开始从入口方法开始执行用户的程序代码

  • 调用静态类成员信息(比如:静态字段、静态方法)

  • 使用new关键字为其创建对象实例

卸载

当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存

垃圾回收

简述Java垃圾回收机制

​ 为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC(Garbage Coollection)

有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。

在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机。

对象什么时候可以被垃圾器回收

简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收机制回收。

如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法

引用计数法

一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收

1
String demo = new String("123");

image-20230506111102825

1
String demo = null;

image-20230506111136231

当对象间出现了循环引用的话,则引用计数法就会失效。

image-20230506111255401

先执行右侧代码的前4行代码

image-20230506111327590

目前上方的引用关系和计数都是没问题的,但是,如果代码继续往下执行,如下图

image-20230506111512450

虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。

优点
  • 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,则可以直接回收。
  • 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报OOM错误
  • 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象
缺点
  • 每次对象被引用时,都需要更新计数器,有一点时间开销

  • 浪费cpu资源,即使内存够用,仍然在运行时进行计数器的统计

  • 无法解决循环引用问题,会引发内存泄露(最大的缺点)

可达性分析算法

现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾。

会存在一个根节点【GC Roots】,引出它下面指向的下一个节点,再以下一个节点节点开始找出它下面的节点,依次往下类推。直到所有的节点全部遍历完毕。

核心是:判断某对象是否与根对象有直接或间接的引用,如果没有被引用,则可以当做垃圾回收

可以当做根对象的有以下几种(肯定不能当做垃圾回收的对象)

  • 局部变量
  • 静态方法
  • 静态变量
  • 类信息

image-20220904010634153

​ 上图中,x、y两个节点是可以回收的,但是并不会马上被回收。

对象中存在一个finalize方法,当对象被标记为可回收后,当发生GC时,首先会判断这个对象是否执行了finalize方法,如果这个方法还没有被执行的话,,那么就会先执行这个方法,接着在这个方法执行中,可以设置当前这个对象与GC ROOTS产生关联,那么这个方法执行完成之后,GC会再次判断对象是否可达,如果仍然不可以不可达,则会进行回收,如果可达,则不会进行回收。

​ finalize方法对于每个对象来说,只会执行一次。如果第一次执行这个方法的时候,设置了当前对象与GC ROOTS关联,那么这一次不会进行回收。那么等到这个对象第二次被标记为可回收时,那么该对象的finalize方法就不会在执行了。

GC ROOTS
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

    1
    2
    3
    4
    5
    6
    public class Demo{
    public static void main(String[] args){
    Demo demo = new Demo();
    demo = null;
    }
    }

    demo是局部变量,存储在栈帧的本地变量表中,当demo=null时,由于此时demo充当了GC Root的作用,demo与原来指向的实例new Demo断开了连接,对象被回收

  • 方法区中类静态属性引用的对象

    1
    2
    3
    4
    5
    6
    7
    8
    public class Demo{
    public static Demo a;
    public static void main(String[] args){
    Demo b = new Demo();
    b.a = new Demo();
    b = null;
    }
    }

    当栈帧中的把本地变量b=null时,由于b原来执行的对象与GC Root(变量b)断开连接,所以b原来指向的对象会被回收,由于我们给a赋值了变量的引用,a在此时是类静态属性引用,充当了GC Root的作用,它执行的对象依然存活。

  • 方法区中常量引用的对象

    1
    2
    3
    4
    5
    6
    7
    public class Demo{
    public static final Demo a = new Demo();
    public static void main(String[] args){
    Demo demo = new Demo();
    demo = null;
    }
    }

    常量a指向的对象并不会因为demo指向的对象被回收而回收

  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

JVM垃圾回收算法

标记清除算法

​ 标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除

  1. 根据可达性分析算法得出的垃圾,进行标记
  2. 对这些标记为可回收的内容进行垃圾回收

image-20230506112047190

可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。

同样,标记清除算法也有缺点的:

  • 效率较低

    标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。

  • 通过标记清除算法清理出来的内存,碎片化比较严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯。

复制算法

​ 复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾回收。

​ 如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。

image-20230506111919008

  1. 将内存区域分成两部分,每次操作其中一个
  2. 当进行垃圾回收时,将正在使用的内存区域中的存活对象移动到未使用的内存区域。当移动完对这部分内存区域一次性清除
  3. 周而复始
优点
  • 在垃圾对象多的情况下,效率较高
  • 清理后,内存无碎片
缺点
  • 分配的2块内存空间,在同一时刻,只能使用一半,内存使用率较低

标记整理算法

​ 标记整理算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题。

image-20230506111957793

  1. 标记垃圾
  2. 需要清除向右边走,不需要清除向左边走
  3. 清除边界以外的垃圾

优缺点同标记清除算法,解决了标记清除算法的碎片化的问题。

同时,标记整理算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。

与复制算法对比:复制算法标记完就复制,但标记整理算法得等把所有存活对象都标记完毕,在进行整理。

分代收集算法

概述

​ 在java8时,堆被分为两份,新生代和老年代【1:2】,在java7时代,还存在一个永久代

image-20230506131229649

对于新生代,内部又被分为了三个区域。Eden区,S0区,S1区【8:1:1】

当对新生代产生GC:MinorGC【young GC】

当对老年代产生GC:Major GC

当对新生代和老年代产生FullGC:新生代+老年代完整垃圾回收,暂停时间长,应尽力避免

工作机制

image-20230506131308654

  • 新创建的对象,都会先分配到eden区

image-20230506131415418

  • 当伊甸园内存不足,标记伊甸园与form(现阶段没有)的存活对象
  • 将存活对象采用复制算法复制到to中,复制完毕后,伊甸园和form内存都得到释放

image-20230506131442503

  • 经过一段时间后伊甸园的内存又出现不足,标记eden区、to区域存活的对象,将存活的对象复制到from区

image-20230506131544447

image-20230506131607645

  • 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足,或大对象会导致提前晋升)

MinorGC、MixedGC、FullGC

image-20230506131640893

  • MinorGC【young GC】

    发生新生代的垃圾回收,暂停时间段(STW)

  • Mixed GC

    新生代+老年代部分区域的垃圾回收,G1收集器特有

  • FullGC

    新生代+老年代完整垃圾回收,暂停时间长(STW)

STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收的完成

JVM有哪些垃圾回收器

在JVM中,实现了多种垃圾收集器,包括:

  • 串行垃圾收集器
  • 并行垃圾收集器
  • CMS(并发)垃圾收集器
  • G1垃圾收集器

串行垃圾收集器

Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑

  • Serial作用于新生代,采用复制算法
  • Serial Old作用于老年代,采用标记-整理算法

垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成

image-20230506154006266

并行垃圾收集器

Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器

  • Parallel New作用于新生代,采用复制算法
  • Parallel Old作用于老年代,采用标记整理算法

垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成

image-20230506154042673

CMS(并发)垃圾收集器

CMS全称Concurrent Mark Sweep。是一款并发的、使用标记清除算法的垃圾回收器。

该回收器是针对老年代垃圾进行回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。

其最大的特点就是在进行垃圾回收时,应用仍然能正常运行。

image-20230506154117857

最后需要重新标记的原因是:有可能会出现新的关联节点,所以需要重新标记

G1垃圾回收器

  • 应用于新生代和老年代,在jdk9之后默认使用G1
  • 划分成多个区域,每个区域都可以充当eden,survivor,old,humongous,其中humongous专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段
    • 新生代回收
    • 并发标记
    • 混合收集
  • 如果并发失败(即回收速度赶不上创建新对象的速度),就会出发Full GC

image-20230506154323950

Young Collection(年轻代垃圾回收)
  • 初始时,所有区域都处于空闲状态

    image-20230506154542687

  • 创建了一些对象,挑出一些空闲区作为伊甸园区存储这些对象

    image-20230506154607558

  • 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程

    image-20230506154633118

    image-20230506154705088

  • 随着时间流逝,伊甸园的内存又有不足

  • 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老的对象晋升为老年代

    image-20230506154759809

    image-20230506154826981

    image-20230506154859985

Young Collection + Concurrent Mark(年轻代垃圾回收+并发标记)
  • 当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程

    image-20230506155000503

  • 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程

  • 这些都完成后就知道了老年代有哪些存活对象,随后进行混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是Gabage First名称的由来)

    image-20230506155047765

Mixed Collection (混合垃圾回收)

复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集

image-20230506155116267

其中H叫做巨型对象,如果对象非常大,会开辟一块连续的空间存储巨型对象

image-20230506155146370

强引用、软引用、弱引用、虚引用的区别

强引用

只有所有GC Roots对象都不通过引用该对象,该对象才能被垃圾回收

1
User user = new User();

image-20230506155341703

软引用

仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次发生垃圾回收

1
2
User user  =  new User();
SoftReference softReference = new SoftReference(user);

image-20230506155416293

弱引用

仅用弱引用引用改对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象

1
2
User user = new User();
WeakReference weakReference = new WeakReference(user);

image-20230506155501557

虚引用

必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存

1
2
3
User user = new User();
ReferenceQueue = referenceQueue = new ReferenceQueue();
PhantomReference phantomReference new PhantomReference(user,queue);

image-20230506155552693

JVM调优

在哪里设置JVM调优参数

Tomcat的设置vm参数

修改TOMCAT_HOME/bin/catalina.sh文件,如下图

JAVA_OPTS="-Xms512m -Xmx1024m"

image-20220904151948778

springboot项目jar文件启动

通常在Linux系统下直接加参数启动Springboot项目

1
nohup java -Xms512m -Xmx1024m -jar xxxx.jar --spring.profiles.active=prod &
  • nohup

    用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行

  • &

    让命令在后台执行,终端退出后命令仍旧执行。

JVM调优参数

​ 对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小以及使用的垃圾回收器类型

官网地址

  1. 设置堆的初始大小和最大值大小,为了防止垃圾收集器的初始太小,最大值大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值

    • -Xms:设置堆的初始化大小
    • -Xmx:设置堆的最大值
  2. 设置年轻代Eden区和两个Survivir区的大小比例。该值如果不设置,则默认比例为8:1:1。Java官方通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但是Eden区满的时候,由于占用空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优

    • -XXSurvivorRatio=3

      表示年轻代中的分配比率:survivor:eden = 2:3

  3. 年轻代和老年代默认比例为1:2。可以通过调整二者空间大小比率来设置两者的大小

    • -XX:newSize

      设置年轻代的初始大小

    • -XX:MaxNewSize

      设置年轻代的最大值,初始大小和最大大小两个值通常相同

  4. 线程堆栈信息设置,每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,这实际上还受限于操作系统

    • -Xss

      对每个线程stack大小的调整,-Xss128k

  5. 一般来说,当survivor区不够大或者占用量达到50%,就会把一些对象放到老年区。通过设置合理的eden区,survivor区及使用率,可以将年轻对象保存在年轻代,从而避免full GC。

    • 使用-Xmn设置年轻代的大小
  6. 系统CPU持续飙高的话,首先先排查代码问题,如果代码没问题,则咨询运维或者云服务器供应商,通常服务器重启或者服务器迁移即可解决。

  7. 对于占用内存比较多的大对象,一般会选择在老年代分配内存。如果在年轻代给大对象分配内存,年轻代内存不够了,就要在eden区移动大量对象到老年代,然后这些移动的对象可能很快消亡,因此导致full GC。

    • 通过设置参数:-XX:PetenureSizeThreshold=1000000,单位为B,标明对象大小超过1M时,在老年代(tenured)分配内存空间。
  8. 一般情况下,年轻对象放在eden区,当第一次GC后,如果对象还存活,放到survivor区,此后,每GC一次,年龄增加1,当对象的年龄达到阈值,就被放到tenured老年区。如果想让对象留在年轻代,可以设置比较大的阈值。

    • -XX:MaxTenuringThreshold
  9. 年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。

    • -XX:+UseParallelGC
  10. 设置老年代使用并行垃圾回收收集器。

    • -XX:+UseParallelOldGC
  11. 尝试使用大的内存分页:使用大的内存分页增加CPU的内存寻址能力,从而系统的性能。

    • -XX:+LargePageSizeInBytes (设置内存页的大小)
  12. 使用非占用的垃圾收集器

    • -XX:+UseConcMarkSweepGC

      老年代使用CMS收集器降低停顿。

JVM调优工具

命令工具

  • jps(Java Process Status)

输出JVM中运行的进程状态信息(现在一般使用jconsole)

  • jstack

查看java进程内线程的堆栈信息

  • jmap

    用于生成堆转存快照

    • jmap [options] pid 内存映像信息

    • jmap -heap pid 显示Java堆的信息

    • jmap -dump:format=b,file=heap.hprof pid

      format=b表示以hprof二进制格式转储Java堆的内存
      file=用于指定快照dump文件的文件名。

    例:显示了某一个java运行的堆信息

    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
    C:\Users\yuhon>jmap -heap 53280
    Attaching to process ID 53280, please wait...
    Debugger attached successfully.
    Server compiler detected.
    JVM version is 25.321-b07

    using thread-local object allocation.
    Parallel GC with 8 thread(s) //并行的垃圾回收器

    Heap Configuration: //堆配置
    MinHeapFreeRatio = 0 //空闲堆空间的最小百分比
    MaxHeapFreeRatio = 100 //空闲堆空间的最大百分比
    MaxHeapSize = 8524922880 (8130.0MB) //堆空间允许的最大值
    NewSize = 178257920 (170.0MB) //新生代堆空间的默认值
    MaxNewSize = 2841640960 (2710.0MB) //新生代堆空间允许的最大值
    OldSize = 356515840 (340.0MB) //老年代堆空间的默认值
    NewRatio = 2 //新生代与老年代的堆空间比值,表示新生代:老年代=1:2
    SurvivorRatio = 8 //两个Survivor区和Eden区的堆空间比值为8,表示S0:S1:Eden=1:1:8
    MetaspaceSize = 21807104 (20.796875MB) //元空间的默认值
    CompressedClassSpaceSize = 1073741824 (1024.0MB) //压缩类使用空间大小
    MaxMetaspaceSize = 17592186044415 MB //元空间允许的最大值
    G1HeapRegionSize = 0 (0.0MB)//在使用 G1 垃圾回收算法时,JVM 会将 Heap 空间分隔为若干个 Region,该参数用来指定每个 Region 空间的大小。

    Heap Usage:
    PS Young Generation
    Eden Space: //Eden使用情况
    capacity = 134217728 (128.0MB)
    used = 10737496 (10.240074157714844MB)
    free = 123480232 (117.75992584228516MB)
    8.000057935714722% used
    From Space: //Survivor-From 使用情况
    capacity = 22020096 (21.0MB)
    used = 0 (0.0MB)
    free = 22020096 (21.0MB)
    0.0% used
    To Space: //Survivor-To 使用情况
    capacity = 22020096 (21.0MB)
    used = 0 (0.0MB)
    free = 22020096 (21.0MB)
    0.0% used
    PS Old Generation //老年代 使用情况
    capacity = 356515840 (340.0MB)
    used = 0 (0.0MB)
    free = 356515840 (340.0MB)
    0.0% used

    3185 interned Strings occupying 261264 bytes.
  • jhat

    用于分析jmap生成的堆转存快照(一般不推荐使用,而是使用Ecplise Memory Analyzer)

  • jstat

    是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。

常见参数

  1. 总结垃圾回收统计

    1
    jstat -gcutil pid

    image-20220904114511854

    字段含义
    S0幸存1区当前使用比例
    S1幸存2区当前使用比例
    E伊甸园区使用比例
    O老年代使用比例
    M元数据区使用比例
    CCS压缩使用比例
    YGC年轻代垃圾回收次数
    YGCT年轻代垃圾回收消耗时间
    FGC老年代垃圾回收次数
    FGCT老年代垃圾回收消耗时间
    GCT垃圾回收消耗总时间
  2. 垃圾回收统计

    1
    jstat -gc pid

    image-20220904115157363

可视化工具

jconsole

用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具

打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行

image-20220904115936095

可以内存、线程、类等信息

image-20220904120057211

VisualVm故障处理工具

能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈

打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行

image-20220904120356174

监控程序运行

image-20220904132011289

查看运行中的dump

image-20220904132134095

查看堆中的信息

image-20220904132346495

Java内存泄露排查思路

原因

如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量的时候,java虚拟机将抛出一个StackOverFlowError异常

如果java虚拟机栈可以动态拓展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成拓展,或者在建立新线程的时候没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个OutOfMemoryError异常

如果一次加载的类太多,元空间内存不足,则会报OutOfMemoryError: Metaspace

思路

  1. 通过jmap指定打印内存快照dump

    有的情况是内存溢出之后程序则会直接中断,而jmap只能打印在运行中的程序,所以建议通过参数的方式的生成dump文件,配置如下:

    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/home/app/dumps/ 指定生成后文件的保存目录

  2. 通过工具, VisualVM(Ecplise MAT)去分析 dump文件

    VisualVM可以加载离线的dump文件,如下图

    文件–>装入—>选择dump文件即可查看堆快照信息

    image-20220904132925812

    如果是Linux系统中的程序,则需要把dump文件下载到windows下,visualVm目前只支持在windows下运行可视化

  3. 通过查看堆信息的情况,可以大概定位内存溢出时哪行代码出了问题

    image-20220904133722905

  4. 找到对应的代码,通过阅读上下文情况,进行修复即可

CPU飙高排查方案与思路

  1. 使用top命令查看占用cpu的情况

    image-20220904161818255

  2. 通过top命令查看后,可以查看是哪一个进程占用cpu较高,上图所示进程为:30978

  3. 查看当前线程中的进程信息

    1
    ps H -eo pid,tid,%cpu | grep 40940
    • pid:进程id
    • tip:进程中的线程id
    • %:cpu使用率
  4. 通过上图分析,在进程30978中的线程30979占用cpu较高

    注意:上述线程id是一个十进制,我们需要把这个线程id转为16进制才行,因为通常在日志中展示的都是16进制的线程id名称

    • 在Linux执行命令转换

      1
      printf "%x\n" 30979
  5. 可以根据转换为十六进制的id去找到对应的线程,进一步定位到问题代码的源码行号

    执行命令:jstack 30978 此处是进程id

    image-20220904162941977

Mysql篇

Mysql优化

定位慢查询

​ 慢查询的表象就是:页面加载过慢、接口压测响应时间过长(超过1s)。以下查询可能出现慢查询情况

  • 聚合查询(尝试新增一个临时表去解决)

    就是使用聚合函数对表中的数据进行统计、求和、平均值等操作

  • 多表查询(优化SQL语句的结构)

  • 表数据量过大查询(添加索引)

  • 深度分页查询

开源工具

  • 调试工具:Arthas
  • 运维工具:Prometheus、Skywalking

Mysql自带慢日志

​ 慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志。如果要开启慢查询日志,需要在Mysql的配置文件(/etc/my.cnf)中配置如下信息:

1
2
3
4
# 开启MySQL慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒,SQL语句执行时间超过2秒,就会视为慢查询,记录慢查询日志
long_query_time=2

配置完毕之后,通过以下指令重新启动MySQL服务器进行测试,查看慢日志文件中记录的信息:/var/lib/mysql/localhost-slow.log

SQL执行计划

​ 可以采用EXPLAIN或者DESC命令获取MySQL如何执行SELECT语句的信息

image-20240224143739717

  • possible_key

    当前sql可能会使用到的索引

  • key

    当前sql实际命中的索引

  • key_len

    索引占用的大小

  • Extra

    额外的优化建议

    Extra含义
    Using wher;Using Index查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据
    Using index condition查找使用了索引,但是需要回表查询数据
  • type

    这条sql的连接的类型,性能由好到差为

    1. NULL

      sql语句在查询的时候没有使用到表,比较少见,无需关注

    2. system

      查询系统中的表

    3. const

      根据主键索引查询

    4. eq_ref

      主键索引查询或者是唯一索引查询,只会返回一条数据

    5. ref

      索引查询,其他字段设置为索引,可能返回多条数据

    6. range

      sql执行的时候走的是索引,但是范围查询

    7. index

      走的是全索引查询,遍历整个索引树去查询

    8. all

      不走索引,进行全盘数据扫描

主要是通过key、key_len两个查看是否可能会命中索引

索引

​ 索引(index)是帮助mysql高效获取数据的数据结构(有序)。

在数据之外,数据库系统还维护着满足特定查找算法的数据结构(B+树),这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。

数据结构对比

​ mysql默认使用的索引底层数据结构是B+树,为什么不使用二叉树和红黑树呢

image-20240224151319746

  • 上述第一幅图是比较理想的二叉树,可以有效节省数据查询的时间,但是可能出现第二幅图这种最坏的情况,变成一个类似链条的数据,这样并没有节省查询数据的时间
  • 第三幅图,是红黑树,在大量数据面前,可能导致层级太多,也会导致数据的查询时间过长

B-tree树

B树是一种多叉路平衡查找树,相对于二叉树,B树的每个节点可以有多个分支,即多叉。是一个矮胖的数据,它的层级较少,所以查询效率就更高。

以一颗最大度数(max-degree)为5(5阶)的b-tree为例,那么B树的每个节点最多存储4个key

image-20240224152317121

B+tree

​ 是在BTree基础上的一种优化,使其更适合实现外存储索引结构,InnoDB存储引擎就是B+Tree实现其索引结构。

B+树与B树的区别就是在非叶子节点只存指针,不存数据,只会在叶子节点存储数据

image-20240224153640037

B树与B+树对比

  1. 磁盘读写代价,B+树更低

    因为B+树只在叶子节点存储数据,非叶子节点存储指针,这样B+树在查找数据的时候不需要额外的再去加载其他的数据,所以B+树磁盘的读写代价更低

  2. 查询效率B+树更稳定

    B+树的数据所有数据都存储在叶子节点,查询数据的时候都要查询到叶子节点,查询数据的效率都是差不多的,所以B+树更加的稳定。

  3. B+树便于扫库和区间查询

    因为在叶子节点有双向指针将数据连接在一起,不需要再从根节点重新查找数据,可以通过双向指针直接查询到另一区间的数据。

聚簇索引、非聚簇索引

​ 聚簇索引又叫聚集索引,非聚簇索引叫非聚集索引或二级索引

分类含义特点
聚集索引(Clustered Index)将数据存储与索引放到一块,索引结构的叶子节点保存了行数据必须有,而且只有一个
二级索引(Secondary Index)将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键可以存在多个
聚集索引选取规则
  • 如果存在主键,主键索引就是聚集索引
  • 如果不存在主键,将使用第一个唯一(UNIQUE)索引作为聚集索引
  • 如果没有主键,或者没有合适的唯一索引,则InnoDB会自动生成一个rowid作为隐藏的聚集索引
回表查询

image-20240224160252733

  1. 执行sql,因为name有创建索引,会找到对应的叶子节点
  2. 而name二级索引叶子节点存储的是数据的主键,现在sql查询是查询整行数据。
  3. 那么就需要根据对应的id去查找聚集索引,在聚集索引的叶子节点中存在该id主键的行数据
  4. 以上的查询数据流程就被称之为回表查询

覆盖索引

​ 是指查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到。

image-20240224160935209

查看上述图中信息,简单分析下列语句是否是覆盖索引

  • select * from tb_user where id =1

    分析:根据id查询,而id是主键索引,也是聚集索引,在叶子节点存储着整行的数据,而sql的*是查询整行的数据,满足查询使用了索引,并且需要返回的列,在主键索引中都能找到,所以是覆盖索引

  • select id,name form tb_user where name = ‘Arm’

    分析:根据name普通索引去查询,叶子节点存储的是id主键和name的值,sql返回字段也是id和name,所以也是覆盖索引

  • select id,name,gender from tb_user where name = ‘Arm’

    分析:根据name普通索引只能找到id和name,需要根据id去进行回表查询,才能获取到gender的值,不满足覆盖索引的条件,所以这个是非覆盖索引

Mysql超大分页处理

​ 在数据量比较大时,如果limit分页查询,在查询时,越往后,分页查询效率越低。因为大数据的排序查询代价很大,比如下面这个查询

1
select * from tb_sku limit 900000,10

优化思路

一般分页查询时,通过创建覆盖索引能够比较好的提高性能,可以通过覆盖索引加子查询进行优化

1
2
3
select * from tb_sku t,
(select id tb_sku order by id limit 90000000,10)a
where t.id = a.id
  • 先根据id排序,再去分页查询到id并返回,因为这个子查询使用的覆盖索引,索引查询效率更快(不需要id主键也可以,只要是覆盖索引都有这个效果)
  • 在将子查询的返回的id与之前的表关联,返回对应的数据

索引创建原则

​ 索引主要分为以下几种

  • 主键索引
  • 唯一索引
  • 复合索引(根据业务创建的索引)
创建原则
  1. 针对数据量较大,且查询比较频繁的表建立索引(单表超过10万数据,增加用户体验)

  2. 针对经常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引

  3. 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。

    image-20240224164322347

    如果选择地址作为索引,区分度不高,因为在该表中有很多门店都是同一个市,不能最大的提升查询效率。

  4. 如果是字符串类型的字段,字段的长度较长,可以针对与字段的特点,建立前缀索引。

  5. 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表查询,提高查询效率。

  6. 控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率

  7. 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定那个索引最有效地用于查询。

索引失效

  1. 违反最左前缀法则

    如果是复合索引,要最遵守最左前缀法则。指的是查询从索引的最左列开始,并且不跳过索引中的列(假设复合索引有三个字段,顺序分别为1、2、3,只要查询条件按照(1),(1,2),(1,2,3)索引都会生效)

  2. 范围查询右边的列,不能使用索引

    1
    select * from tb_seller where name = ‘哈哈’ and status > '1' and address = '北京市'

    name和status索引都生效了,但是addres索引失效

  3. 不要在索引列上进行运算操作(包括加减或者字符串截取等拼接等等运算),索引将失效

  4. 字符串不加单引号,造成索引失效

    mysql的查询优化器,会自动的进行类型转换,造成索引失效

  5. 以%开头的like模糊查询,索引失效。如果仅仅是尾部模糊查询,索引不会失效,如果是头部模糊匹配,索引失效

SQL优化

  • 表的设计优化
  • 索引优化(参考优化创建原则和索引失效)
  • SQL语句优化
  • 主从复制、读写分离
  • 分库分表

表的设计优化

​ 参考阿里巴巴开发手册《嵩山版》

  1. 比如设置合适的数值(tinyint,int,bigint),要根据实际情况选择
  2. 比如设置合适的字符串类型(char和varchar),char定长效率高,varchar可变长度,效率稍低

SQL语句优化

  1. SELECT语句务必指明字段名称(避免直接使用select *,造成回表的操作)

  2. SQL语句要避免造成索引失效的写法

  3. 尽量使用union all 代替union,union会多一次过滤重复数据,效率低

  4. 避免在where条件中对字段进行表达式操作(substring、concat等)

  5. Join优化,能用inner join就不用left join、right join,如必须使用一定要以小表为驱动

    内连接会对两个表进行优化,优先把小表放到外边,把大表放在里边。left join 或right join不会重新调整顺序

主从复制、读写分离

​ 如果数据库的使用场景读的操作比较多,为了避免写的操作所造成的性能影响,可以采用读写分离的架构。

​ 读写分离解决的是,数据库的写入,影响了查询效率

image-20240302132129072

Mysql事务

​ 事务是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同事成功,要么同时失败。

ACID

  • 原子性(Atomicity)

    事务是不可分割的最小操作单元,要么全部成功,要么全部失败

  • 一致性(Consistency)

    事务完成时,必须使所有的数据都保持一致状态

  • 隔离性(Isolation)

    数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行

  • 持久性(Durability)

    事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。

并发事务问题

  • 脏读
  • 不可重复读
  • 幻读
问题描述
脏读一个事务读到另外一个事务还没有提交的数据
不可重复读一个事务先后读取同一条记录,但两次读取的数据不同,称之为不可重复读
幻读一个事务按照条件查询数据时,没有对应的数据行,但是在插入数据时,又发现这行数据已经存在,好像出现了一个幻影

事务隔离级别

隔离级别脏读不可重复读幻读
Read uncommitted 读未提交
Read committed 读已提交×
Repeatable Read(默认)可重复读××
Serializable 串行化×××

注意:事务隔离级别越高,数据越安全、但是性能越低

undo log和 redo log区别

  • 缓冲池(buffer pool)

    主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改查操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度

  • 数据页(page)

    是InnoDB存储引擎磁盘管理的最小单元,每个页的大小默认为16kb。页中存储的行数据

redo log

​ 重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性

​ 该日志文件由两部分组成:重做日志缓存(redo log buffer)以及重做日志文件(redo log file),前者是在内存中,后者是在磁盘中。

当事务提交之后会把所有修改信息都存在该日志文件中,用于在刷新脏页到磁盘,发生错误时,进行数据恢复使用。

image-20240302153338241

undo log

​ 回滚日志,用于记录数据被修改前的信息,作用包含两个:提供滚回滚和MVCC(多版本并发控制)。undo log和redo log 记录物理日志不一样,它是逻辑日志

  • 可以认为当delete一条记录时,undo log 中会记录一条对应的insert记录,反之亦然
  • 当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚

undo log 可以实现事务的一致性和原子性

事务中的隔离性如何保证?

  • 锁:排它锁(如一个事务获取了一个数据行的排它锁,其他事务就不能再获取该行的其他锁)
  • mvcc

MVCC

​ 全称 Multi-Version Concurrency Control,多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突。

MVCC的具体实现,主要依赖于数据库记录中隐式字段、undo log日志、readView

隐藏字段

隐藏字段含义
DB_TRX_ID最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务id
DB_ROLL_PTR回滚指针,指向这条记录的上一个版本,用于配合undo log ,指向上一个版本
DB_ROW_ID隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段

image-20240302155103839

undo log

  • 回滚日志,在insert、update、delete的时候产生的便于数据回滚的日志。

  • 当insert的时候,产生的undo log日志只在回滚时需要,在事务提交后,可被立即删除

  • 而update、delete的时候,产生的undo log日志不仅在回滚时需要,mvcc版本访问也需要,不会立即删除

image-20240302155625789

不同事务或相同事务对同一条记录进行修改,会导致该记录的undolog生成一条记录版本链条,链表的头部是最新的旧记录,链表尾部是最早的旧记录。

readview

​ ReadView(读视图)是快照读,SQL执行时,MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id

字段含义
m_ids当前活跃的事务ID集合
min_trx_id最小活跃事务ID
max_trx_id预分配事务ID,当前最大事务ID+1(因为事务ID是自增的)
creator_trx_idReadView创建者的事务ID

版本链数据访问规则

trx_id代表是当前事务ID

  1. trx_id == creator_trx_id (可以访问该版本)

    成立,说明数据时当前这个事务更改的

  2. trx_id < min_trx_id(可以访问该版本)

    成立,说明数据已经提交了

  3. trx_id > max_trx_id(不可以访问该版本)

    成立,说明该事务是在ReadView生成后才开启

  4. min_trx_id <= trx_id <= max_trx_id(可以访问该版本)

    如果trx_id不在m_ids中是可以访问该版本的,成立说明数据已经提交

当前读

​ 读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select …lock in share mode(共享锁),select …for update、insert、delete(排他锁)都是一种当前读

快照读

​ 简单的select(不加锁)就是快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读

  • Read Committed

    每次select,都生成一个快照读

    image-20240302162113229

  • Repeatable Read

    开启事务后第一个select语句才是快照读的地方

image-20240302162134829

主从同步

​ mysql主从复制的核心就是二进制日志

二进制日志(BINLOG)记录了所有的DDL(数据定义语言)语句和DML(数据操纵语言)语句,但不包括数据查询(select,show)语句

复制分成三步

  1. Master主库在事务提交时,会把数据变更记录在二进制日志文件Binlog中
  2. 从库读取主库的二进制日志文件Binlog,写入到从库的中继日志Relay Log
  3. slave重做中继日志中的事件,将改变反映它自己的数据

image-20240302162523407

分库分表

image-20240302162612872

分库分表的时机:

  1. 前提:项目业务数据逐渐增多,或业务发展比较迅速。单表数据量到达1000W或20G以后
  2. 优化已解决不了性能问题(主从读写分离、查询索引…)
  3. IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)

拆分策略

  • 垂直拆分(一般采用这个)
    • 垂直分库(对应一个微服务对应一个数据库)
    • 垂直分表
  • 水平拆分
    • 水平分库
    • 水平分表

垂直分库

image-20240302163024387

​ 以表为依据,根据业务将不同表拆分到不同库中。

特点:

  1. 按业务对数据分级管理、维护、监控、扩展
  2. 在高并发下,提高磁盘IO和数据量连接数

垂直分表

image-20240302163202672

​ 以字段为依据,根据字段属性将不同字段拆分到不同表中

拆分规则:

  • 把不常用的字段单独放在一张表
  • 把text、blob等大字段拆分出来放在附表中

特点:

  1. 冷热数据分离
  2. 减少IO过度争抢,两表互不影响

水平分库

image-20240302163502396

​ 将一个库的数据拆分到多个库中。

特点:

  1. 解决了单库大数量,高并发的性能瓶颈问题
  2. 提高了系统的稳定性和可用性

路由规则(应用访问分库数据)

  • 根据id节点取模
  • 按id也就是范围路由,节点1(1-100万),节点2(100-200万)

水平分表

image-20240302163807227

​ 将一个表的数据拆分到多个表中(可以在同一个库内)

特点:

  1. 避免单一表数据量过大而产生的性能问题
  2. 避免IO争抢并减少锁表的几率

分库之后的问题

  • 分布式事务一致性问题
  • 跨节点关联查询
  • 跨节点分页、排序函数
  • 主键避免重复

可以使用中间件处理上述问题

  • sharding-sphere
  • mycat

集合篇

算法复杂度分析

为什么要进行复杂度分析?

  • 指导你编写出性能更优的代码
  • 评判别人写的代码的好坏

时间复杂度分析

案例

时间复杂度分析:简单来说就是评估代码的执行耗时的

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 求1~n的累加和
* @param n
* @return
*/
public int sum (int n){
int sum = 0;
for (int i = 1; i <= n; i++) {
sum = sum + i;
}
return sum;
}

分析这个代码的时间复杂度,分析过程如下:

  1. 假如每行代码的执行的耗时一样:1ms
  2. 分析这段代码总执行多少行:3n+3
    • 其中for循环的次数不确定,用n代替,而循环行数为3,所以为3n
    • 3表示固定行数代码,比如调用sum(int n)、int sum =0、return sum
  3. 代码耗时总时间:T(n) = (3n+3)*1ms

我们现在有了总耗时,需要借助大O表示法来计算这个代码复杂度

大O表示法

不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势

  • 刚才的代码示例总耗时公式为:T(n) = (3n+3)*1ms

  • 其中(3n+3)是代码的总行数,每行执行的时间都一样,所以得出结论:

  • T(n)与代码的执行行数成正比(代码行数越多,执行时间越长)

  • 不过大O表示法只表示代码执行时间与数据规模的增长趋势,公式可以简化为:T(n) = O(3n+3)———————>T(n)=O(n)

  • 当n很大时,公式中的低阶、常量、系数三部分并不左右其增长趋势,因此可以忽略,我们只需要记录一个最大的量级就可以了

    image-20230427173120668

常见复杂度表示形式

image-20230427173742389

速记口诀:常对幂指阶

越在上面的性能就越高,越往下性能就越低,下图是一些比较常见的时间复杂度的时间与数据规模的趋势

image-20230427173937663

时间复杂度O(1)

示例代码:

1
2
3
4
5
public int test01(int n){
int i =0;
int j = 1;
return i+j;
}

代码只有三行,它的复杂度也是O(1),而不是O(3)

再看如下代码:

1
2
3
4
5
6
7
8
public void test02(int n){
int i =0;
int sum = 0;
for(;int <100;i++){
sum = sum+i;
}
System.out.println(sum);
}

整个代码中因为循环次数是固定的,就是100次,这样的代码复杂度我们认为也是O(1)

一句话总结:只要代码的执行时间不随着n的增大而增大,这样的代码复杂度都是O(1)

时间复杂度O(n)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 求1~n的累加和
* @param n
* @return
*/
public int sum(int n) {
int sum = 0;
for ( int i = 1; i <= n; i++) {
sum = sum + i;
}
return sum;
}

一层for循环时间复杂度及时O(n)

示例代码2:

1
2
3
4
5
6
7
8
9
public static int sum2(int n){
int sum = 0;
for (int i = 1; i < n; ++i) {
for (int j = 1; j < n; ++j) {
sum = sum + i * j;
}
}
return sum;
}

这个代码的执行行数为:O(3n^2+3n+3),不过,依据大O表示的规则:常量、系数、低阶可以忽略

所以这个代码最终的复杂度为:O(n^2)

时间复杂度O(log n)

对数复杂度非常的常见,但相对比较难以分析,示例代码:

1
2
3
4
5
6
public void test04(int n){
int i = 1;
while(i<=n){
i = i*2;
}
}

分析这个代码的复杂度,我们必须在强调一个前提:复杂度分析就是要弄清楚代码的执行次数和数据规模n之间的关系

以上代码最关键的一行是i=i*2,这行代码可以决定这个while循环执行代码的行数,i的值时可以无限接近n的值。如果i一旦大于等于n,则循环条件就不满足了。

也就是说达到了最大的行数,我们可以分析下i这个值得变化过程

分析过程如下:

image-20230427174832858

由此可知,代码的时间复杂度表示为O(log n)

时间复杂度O(n*log n)

分析完O( log n ),那O( n * log n )就很容易理解了,比如下列代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void test05(int n){
int i=0;
for(;i<=n;i++){
test04(n);
}
}

public void test04(int n){
int i=1;
while(i<=n){
i = i * 2;
}
}

空间复杂度分析

​ 空间复杂度全称是渐进空间复杂度,表示算法占用的额外存储空间和数据规模之间的增长关系

看下面代码:

1
2
3
4
5
6
7
8
public void test(int n){
int i=0;
int sum=0;
for(;i<n;i++){
sum = sum+i;
}
System.out.println(sum);
}

代码执行并不需要占用额外的存储空间,只需要常量级的内存空间大小,因此空间复杂度是O(1)

再来看一个其他例子:

1
2
3
4
5
6
7
8
9
10
void print(int n) {
int i = 0;
int[] a = new int[n];
for (i; i <n; ++i) {
a[i] = i * i;
}
for (i = n-1; i >= 0; --i) {
System.out.println(a[i]);
}
}

传入一个变量n,决定申请多少的int数组空间内存,此段代码的空间复杂度为O(n)

我们常见的空间复杂度就是O(1),O(n),O(n ^2),其他像对数阶的复杂度几乎用不到,因此空间复杂度比时间复杂度分析要简单的多。

List

数组

​ 数组(Array)是一种连续的内存空间 存储相同数据类型数据 的线性数据结构

1
int[] array = {22,33,88,66,55,25};

image-20230427175545402

我们定义了一个数组之后,在内存的表示是这样的:

image-20230427175633253

现在假如,我们通过array[1],想要获得下标为1这个元素,但是现在栈内存中指向的堆内存数组的首地址,它是如何获取下标为1这个数据呢?

image-20230427175849493

寻址公式

​ 为了方便理解,我们把数组的内存地址稍微改了一下,都改成了数字,如下图:

image-20230427180056509

在数组的内存中查找元素的时候,是有一个寻址公式的:

1
arr[i] = baseAddress + i*dataTypeSize
  • baseAddress

    数组的首地址,目前是10

  • dataTypeSize

    代表数组中元素类型的大小,目前数组中存储的是int类型,所以dataTypeSize=4字节

  • i

    指的是数组的下标

代入到寻址公式,获取下标为1的元素

1
array[1] = 10+i*4 =14

获取到14这个地址,就能获取到下标为1的这个元素了

操作数组的时间复杂度

随机查询(根据索引查询)

数组元素的访问是通过下标来访问的,计算机通过数组的首地址和寻址公式能够快速的找到想要访问的元素

1
2
3
public int test01(int[] a,int i){
return a[i];
}

代码的执行次数并不会随着数组的数据规模大小而变化,是常数级的,所以查询数据操作的时间复杂度是O(1)

未知索引查询O(n)或O(log2n)
  • 查找数组内的元素,查找55号数据,遍历数组时间复杂度为O(n)

    image-20221007101831281

  • 查找排序后数组内的元素,通过二分查找法查找55号数据,时间复杂度为O(log 2n)

    image-20221007101811885

插入O(n)

数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变得很低。

假设数组的长度为n,现在如果我们需要将一个数据插入到数组中的第k个位置。为了把第k个位置腾出来给新来的数据,我们需要将k~n这部分的元素都顺序的往后挪以为,如下图:

image-20220820104903422

新增之后的数据变化,如下

image-20220820104950846

所以,插入操作最好的情况是O(1),最坏情况下是O(n),平均情况下的时间复杂度是O(n)

删除O(n)

同理可得:如果我们要删除第k个位置数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了,时间复杂度仍然为O(n)

ArrayList源码分析

​ 分析ArrayList源码主要从是三个方面去翻阅:成员变量、构造函数、关键方法

成员变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 默认初始的容量(CAPACITY)
private static final int DEFAULT_CAPACITY =10;

//用于空实例的共享数组实例
private static final Object[] EMPTY_ELEMENTDATA = {};

//用于默认大小的空实例的共享数组实例,我们将其与EMPTY_ELEMENTDATA区分开,以了解添加第一个元素是要膨胀多少
private static final Object DEFAULTCAPACITY_EMPTY_ELEMENTDATA={};

/**
* 存储ArrayList元素的数组缓冲区,ArrayList的容量就是这个数组缓冲区的长度、
* 当添加第一个元素是,任何具有elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA的空ArrayList都将扩展为DEFAULT_CAPACITY
* 当前对象不参与序列化
*/
transient Object[] elementData;

// ArrayList的大小(它包含的元素数量)
private int size;

构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}

public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
  • 第一个构造方法是带初始化容量的构造函数,可以按照指定的容量初始化数组
  • 第二个是无参构造函数,默认创建一个空集合

将collection对象转换为数组,然后将数组的地址赋给elementData

源码分析

添加数据的流程:

image-20230427192644244

结论
  • 底层数据结构

    ArrayList底层是用动态的数组实现的

  • 初始容量

    ArrayList调用空参构造方法的初始容量为0,当第一次添加数据的时候才会初始化容量为10

  • 扩容逻辑

    ArrayList在进行扩容的时候是原来的1.5倍,每次扩容都需要拷贝数组

    • 确保数组已使用长度(size)加1之后足够存下下一个数据
    • 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
    • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上
    • 返回添加成功布尔值。

ArrayList list = new ArrayList(10)中list扩容几次

根据ArrayList有参构造方法可知:

1
2
3
4
5
6
7
8
9
10
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}

该语句只是声明和实例化了一个ArrayList,指定容量为10,未扩容

如何实现数组和List之间的转换

如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//数组转List
public static void testArray2List(){
String[] strs = {"aaa","bbb","ccc"};
List<String> list = Arrays.asList(strs);
for(String s :list){
System.out.println(s);
}
}

//List转数组
public static void testList2Array(){
List<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
String[] array = list.toArray(new String[list.size()]);
for(String s:array){
System.out.println(s);
}
}
  • 数组转list

    使用JDK中java.util.Arrays工具类的asList方法

  • List转数组

    使用List的toArray方法。无参toArray方法返回Object数组,传入初始长度的数组对象,返回该对象数组

面试官再问:

  1. 用Arrays.asList转List后,如果修改了数组内容,list受影响吗

    数组转List受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址。

    image-20240312100255677

  2. List用toArray转数组后,如果修了List内容,数组受影响吗

    List转数组不受影响,当调用了toArray以后,在底层时它是进行了数组的拷贝,更原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响。

    image-20240312101711200

链表

单向链表

  • 链表中的每一个元素称之为节点(Node)

  • 物理存储单元上,非连续、非顺序的存储结构

  • 单向链表:每个节点包括两个部分,一个是存储数据元素的数据域,另一个是存储下一个节点地址的指针域。记录下个节点地址的指针叫做后继指针next

    image-20230428185922776

代码实现参考:

1
2
3
4
5
6
7
8
9
private static class Node<E>{
E item;
Node<E> next;

Node(E element,Node<E> next){
this.item = element;
this.next = next;
}
}

链表中的某个节点为B,B的下一个节点为C,表示B.next==C

单向链表时间复杂度分析
  1. 查询操作

    image-20230428190130901

    • 只有在查询头节点的时候不需要编辑链表,时间复杂度是O(1)
    • 查询其他节点需要遍历链表,时间复杂度是O(n)
  2. 插入和删除操作

    image-20230428190210915

    • 只有在添加和删除头节点或尾节点的时候不需要遍历链表,时间复杂度是O(1)
    • 添加或删除其他节点需要遍历链表找到对应节点后,才能完成新增或删除节点,时间复杂度是O(n)

双向链表

​ 双向链表,顾名思义,它支持两个方向

  • 每个节点不止有一个后继指针next指向后面的节点
  • 有一个前驱指针prev指向前面的节点

参考代码:

1
2
3
4
5
6
7
8
9
10
11
private static class Node<E>{
E item;
Node<E> next;
Node<E> prev;

Node(Node<E> prev,E element,Node<E> next){
this.item = element;
this.next = next;
this.prev = prev;
}
}

image-20230428190353286

对比单链表

  • 双向链表需要额外的两个空间来存储后继节点和前驱节点的地址
  • 支持双向遍历,这样也带来了双向链表操作的灵活性
双向链表时间复杂度分析
  1. 查询操作
    • 查询头尾节点的时间复杂度是O(1)
    • 平均的查询时间复杂度是O(n)
    • 给定节点找前驱节点的时间复杂度为O(1)
  2. 增删操作
    • 头尾节点增删的时间复杂度为O(1)
    • 其他部分节点增删的时间复杂度是O(n)
    • 给定节点增删时间的复杂度是O(1)

ArrayList和LinkedList的区别

  • 底层数据结构

    • ArrayList是动态数组的数据结构实现
    • LinkedList是双向链表的数据结构实现
  • 操作数据效率

    • ArrayList根据下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】,Linked不支持下标查询
    • 查找(未知索引):ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)
    • 新增和删除
      • ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
      • LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
  • 内存空间占用

    • ArrayList底层是数组,内存连续,节省内存

    • LinkedList 是双向链表需要存储数据,和两个指针,更占用内存

  • 线程安全

    • ArrayList和LinkedList都不是线程安全的

    • 如果需要保证线程安全,有两种方案:

      • 在方法内使用,局部变量则是线程安全的

      • 使用线程安全的ArrayList和LinkedList

        1
        2
        Collection<String> strings = Collections.synchronizedCollection(new ArrayList<String>());
        Collection<Integer> integers = Collections.synchronizedCollection(new LinkedList<Integer>());

HashMap

二叉树

​ 二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。

​ 二叉树每个节点的左子树和右子树也分别满足二叉树的定义。

image-20230428194831426

Java中有两个方式实现二叉树:数组存储、链式存储

基于链式存储的树的节点可定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TreeNode{
int val;
TreeNode left;
TreeNode right;

TreeNode(){}
TreeNode(int val){
this.val = val;
}
TreeNode(int val,TreeNode left,TreeNode right){
this.val = val;
this.left = left;
this.right = right;
}
}

image-20230428194931132

在二叉树中,比较常见的二叉树有:

  • 满二叉树
  • 完全二叉树
  • 二叉搜索树
  • 红黑树

二叉树搜索树

概述

​ 二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型。

​ 二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

image-20230428195206422

时间复杂度分析

​ 实际上由于二叉查找树的形态各异,时间复杂度也不尽相同,我们画了几棵树我们来看一下插入,查找,删除的时间复杂度

image-20230428195341917

插入,查找,删除的时间的时间复杂度O(log n)

极端情况下二叉搜索树的时间复杂度

image-20230428195449799

对于图中这种情况属于最坏的情况,二叉查找树已经退化成了链表,左右子树极度不平衡,此时查找的时间复杂度肯定是O(n)

红黑树

概述

​ 红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)

image-20230428195832724

红黑树特质
  1. 节点要么是红色,要么是黑色
  2. 根节点是黑色
  3. 叶子节点都是黑色的空节点(就是最下的层级)
  4. 红黑树中红色节点的子节点都是黑色
  5. 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质,保证红黑树的平衡

红黑树的复杂度
  • 查找:

    • 红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为:O(log n)
  • 添加:

    • 添加先要从根节点开始找到元素添加的位置,时间复杂度O(log n)
    • 添加完成后涉及到复杂度为O(1)的旋转调整操作
    • 故整体复杂度为:O(log n)
  • 删除:

    • 首先从根节点开始找到被删除元素的位置,时间复杂度O(log n)
    • 删除完成后涉及到复杂度为O(1)的旋转调整操作
    • 故整体复杂度为:O(log n)

散列表

​ 在HashMap中的最重要的一个数据结构就是散列表,在散列表中又使用到了红黑树和链表

概述

​ 散列表(Hash Table)又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。

假设有100个人参加马拉松,编号是1-100,如果要编程实现根据选手的编号迅速找到选手信息?

可以把选手信息存入数组中,选手编号就是数组的下标,数组的元素就是选手的信息。

当我们查询选手信息的时候,只需要根据选手的编号到数组中查询对应的元素就可以快速找到选手的信息,如下图:

image-20230428201000814

现在需求升级了:

假设有100个人参加马拉松,不采用1-100的自然数对选手进行编号,编号有一定的规则比如:2023ZHBJ001,其中2023代表年份,ZH代表中国,BJ代表北京,001代表原来的编号,那此时的编号2023ZHBJ001不能直接作为数组的下标,此时应该如何实现呢?

image-20230428201321607

我们目前是把选手的信息存入到数组中,不过选手的编号不能直接作为数组的下标,不过,可以把选手的选号进行转换,转换为数值就可以继续作为数组的下标了?

转换可以使用散列函数进行转换

散列函数和散列冲突

​ 将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)

散列函数的基本函数:

  • 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标
  • 如果key1==key2,那么经过hash后得到的哈希值也必相同,hash(key1) == hash(key2)
  • 如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)

实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)

image-20230428203219225

散列冲突-链表法(拉链)

​ 在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。

image-20230428203437910

​ 简单就是,如果有多个key最终的hash值是一样的,就会存入数组的同一个下标中,下标中挂一个链表存入多个数据

时间复杂度-散列表

  1. 插入操作,通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,插入的时间复杂度O(1)

    image-20230428203711269

  2. 当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除

    • 平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)
    • 散列表可能会退化为链表,查询的时间复杂度就从 O(1) 退化为 O(n)

    image-20230428203858903

    • 将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)

    image-20230428203924816

    将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击

DDos 攻击:

分布式拒绝服务攻击(英文意思是Distributed Denial of Service,简称DDoS)

指处于不同位置的多个攻击者同时向一个或数个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个

HashMap的实现原理

​ HashMap的数据结构:底层使用hash表数据结构,即数组和链表或红黑树

  1. 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
  2. 存储时,如果出现hash值相同的key,此时有两种情况:
    • 如果key相同,则覆盖原始值
    • 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中
  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值

image-20230428204902016

HashMap1.7与1.8的区别

  • jdk1.8之前采用的是拉链法(将链表和数组相结合)。也就是说创建一个数组链表,数组中每一格就是一个链表。若是遇到哈希冲突,则将冲突的值加到链表中即可。
  • jdk1.8在解决哈希冲突时较大的变化,当链表长度大于阈值(默认为8)时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容resize()时,红黑树拆分成的树的节点数小于等于临界值6个,则退化成链表

HashMap的put方法的具体流程

hashMap常见属性

image-20230428210404117

  • DEFAULT_INITIAL_CAPACITY:默认的初始容量
  • DEFAULT_LOAD_FACTOR:默认的加载因子
  • 扩容阈值 == 数组容量*加载因子

源码分析

1
2
3
4
5
6
Map<String,String> map = new HashMap<>();
map.put("name","it");

public HashMap(){
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
  • HashMap是懒惰加载,在创建对象时并没有初始化数组
  • 在无参的构造函数中,设置了默认的加载因子是0.75

添加数据流程图:

image-20230428210624847

具体源码:

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
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否未初始化
if ((tab = table) == null || (n = tab.length) == 0)
//如果未初始化,调用resize方法 进行初始化
n = (tab = resize()).length;
//通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据
if ((p = tab[i = (n - 1) & hash]) == null)
//如果没有,直接将数据放在该下标位置
tab[i] = newNode(hash, key, value, null);
//该数组下标有数据的情况
else {
Node<K,V> e; K k;
//判断该位置数据的key和新来的数据是否一样
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到
e = p;
//判断是不是红黑树
else if (p instanceof TreeNode)
//如果是红黑树的话,进行红黑树的操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//新数据和当前数组既不相同,也不是红黑树节点,证明是链表
else {
//遍历链表
for (int binCount = 0; ; ++binCount) {
//判断next节点,如果为空的话,证明遍历到链表尾部了
if ((e = p.next) == null) {
//把新值放入链表尾部
p.next = newNode(hash, key, value, null);
//因为新插入了一条数据,所以判断链表长度是不是大于等于8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果是,进行转换红黑树操作
treeifyBin(tab, hash);
break;
}
//判断链表当中有数据相同的值,如果一样,证明为修改操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//把下一个节点赋值为当前节点
p = e;
}
}
//判断e是否为空(e值为修改操作存放原数据的变量)
if (e != null) { // existing mapping for key
//不为空的话证明是修改操作,取出老值
V oldValue = e.value;
//一定会执行 onlyIfAbsent传进来的是false
if (!onlyIfAbsent || oldValue == null)
//将新值赋值当前节点
e.value = value;
afterNodeAccess(e);
//返回老值
return oldValue;
}
}
//计数器,计算当前节点的修改次数
++modCount;
//当前数组中的数据数量如果大于扩容阈值
if (++size > threshold)
//进行扩容操作
resize();
//空方法
afterNodeInsertion(evict);
//添加操作时 返回空值
return null;
}
  1. 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
  2. 根据键值key计算hash值得到数组索引
  3. 判断table[i]==null.条件成立,直接新建节点添加
  4. 如果table[i]==null,不成立
    1. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
    2. 判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对
    3. 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,遍历过程若发现key已经存在则直接覆盖value
  5. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。

HashMap的扩容机制

扩容的流程:

image-20230428211031968

源码:

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
117
118
119
120
121
122
123
124
125
126
127
128
129
//扩容、初始化数组
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//如果当前数组为null的时候,把oldCap老数组容量设置为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//老的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
//判断数组容量是否大于0,大于0说明数组已经初始化
if (oldCap > 0) {
//判断当前数组长度是否大于最大数组长度
if (oldCap >= MAXIMUM_CAPACITY) {
//如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果在最大长度范围内,则需要扩容 OldCap << 1等价于oldCap*2
//运算过后判断是不是最大值并且oldCap需要大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 等价于oldThr*2
}
//如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在, 如果是首次初始化,它的临界值则为0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//数组未初始化的情况,将阈值和扩容因子都设置为默认值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//初始化容量小于16的时候,扩容阈值是没有赋值的
if (newThr == 0) {
//创建阈值
float ft = (float)newCap * loadFactor;
//判断新容量和新阈值是否大于最大容量
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//计算出来的阈值赋值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//根据上边计算得出的容量 创建新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//赋值
table = newTab;
//扩容操作,判断不为空证明不是初始化数组
if (oldTab != null) {
//遍历数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//判断当前下标为j的数组如果不为空的话赋值个e,进行下一步操作
if ((e = oldTab[j]) != null) {
//将数组位置置空
oldTab[j] = null;
//判断是否有下个节点
if (e.next == null)
//如果没有,就重新计算在新数组中的下标并放进去
newTab[e.hash & (newCap - 1)] = e;
//有下个节点的情况,并且判断是否已经树化
else if (e instanceof TreeNode)
//进行红黑树的操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//有下个节点的情况,并且没有树化(链表形式)
else {
//比如老数组容量是16,那下标就为0-15
//扩容操作*2,容量就变为32,下标为0-31
//低位:0-15,高位16-31
//定义了四个变量
// 低位头 低位尾
Node<K,V> loHead = null, loTail = null;
// 高位头 高位尾
Node<K,V> hiHead = null, hiTail = null;
//下个节点
Node<K,V> next;
//循环遍历
do {
//取出next节点
next = e.next;
//通过 与操作 计算得出结果为0
if ((e.hash & oldCap) == 0) {
//如果低位尾为null,证明当前数组位置为空,没有任何数据
if (loTail == null)
//将e值放入低位头
loHead = e;
//低位尾不为null,证明已经有数据了
else
//将数据放入next节点
loTail.next = e;
//记录低位尾数据
loTail = e;
}
//通过 与操作 计算得出结果不为0
else {
//如果高位尾为null,证明当前数组位置为空,没有任何数据
if (hiTail == null)
//将e值放入高位头
hiHead = e;
//高位尾不为null,证明已经有数据了
else
//将数据放入next节点
hiTail.next = e;
//记录高位尾数据
hiTail = e;
}

}
//如果e不为空,证明没有到链表尾部,继续执行循环
while ((e = next) != null);
//低位尾如果记录的有数据,是链表
if (loTail != null) {
//将下一个元素置空
loTail.next = null;
//将低位头放入新数组的原下标位置
newTab[j] = loHead;
}
//高位尾如果记录的有数据,是链表
if (hiTail != null) {
//将下一个元素置空
hiTail.next = null;
//将高位头放入新数组的(原下标+原数组容量)位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新的数组对象
return newTab;
}
  • 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次扩容都是达到了扩容阈值(数组*0.75)
  • 每次扩容的时候,都是扩容之前容量的2倍
  • 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
    • 没有hash冲突的节点,则直接使用e.hash&(newCap-1)计算新数组的索引位置
    • 如果是红黑树,走红黑树的添加
    • 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash&oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

HashMap的寻址算法

1
2
3
public V put(K key,V value){
return putVal(hash(key),key,value,false,true);
}

在putVal方法中,有一个hash(key)方法,这个方法就是来计算key的hash值的,看下面代码:

1
2
3
4
static final int hash(Object key){
int h;
return (key==null)?0:(h=key.hashCode()) ^ (h>>>16);
}
  • 首先获取key的hashCode值,然后右移16位 异或运算 原来的hashCode值,主要作用就是使原来的hash值更加均匀,减少hash冲突

  • 有了hash值之后,就很方便的去计算当前key的在数组中存储的下标,看下面的代码

    1
    2
    3
    4
    5
    final V putVal(int hash,K key,V value,boolean onlyIfAbsent,boolean evict){
    .........
    if((p=tab[i=(n-1)&hash])==null)
    ..................
    }
  • (n-1)&hash : 得到数组中的索引,代替取模,性能更好,数组长度必须是2的n次幂

为什么HashMap的数组长度一定是2的次幂

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

hashmap在1.7情况下的多线程死循环问题

jdk7的的数据结构是:数组+链表

在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环

image-20230428213115071

  • 变量e指向的是需要迁移的对象

  • 变量next指向的是下一个需要迁移的对象

  • Jdk1.7中的链表采用的头插法

  • 在数据迁移的过程中并没有新的对象产生,只是改变了对象的引用

产生死循环的过程:

线程1和线程2的变量e和next都引用了这个两个节点

image-20230428213533483

线程2扩容后,由于头插法,链表顺序颠倒,但是线程1的临时变量e和next还引用了这两个节点

image-20230428214732877

第一次循环

由于线程2迁移的时候,已经把B的next执行了A

image-20230428214806072

第二次循环

image-20230428214908652

第三次循环

image-20230428214937231

参考回答:

在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环

比如说,现在有两个线程

线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入

线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。

线程一:继续执行的时候就会出现死循环的问题。

线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,

所以B->A->B,形成循环。

当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题。

HashSet与HashMap的区别

  • HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对.

  • HashSet底层其实是用HashMap实现存储的。 HashSet封装了一系列HashMap的方法. 依靠HashMap来存储元素值(利用hashMap的key键进行存储), 而value值默认为Object对象.,所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true

    image-20221007110404375

HashTable与HashMap的区别

主要区别:

区别HashTableHashMap
数据结构数组+链表数组+链表+红黑树
是否可以为nullKey和value都不能为null可以为null
hash算法key的hashCode()二次hash
扩容方式当前容量翻倍 +1当前容量翻倍
线程安全同步(synchronized)的,线程安全非线程安全

在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类

设计模式

工厂模式

需求:设计一个咖啡店点餐系统。

​ 设计一个咖啡类(Coffee),并定义两个子类美式咖啡和拿铁咖啡,在设计一个咖啡店类,咖啡店具有点咖啡的功能。

具体类设计如下:

简单工厂

上图中的符号的含义:

  • +:表示public

  • -:表示private

  • #:表示protected

  • 泛化关系(继承extends)用带空心三角箭头的实线来表示

  • 实现关系(实现implement)用带空心三角箭头的虚线表示

  • 依赖关系使用带箭头的虚线来表示

根据上图含义可以得出以下代码:

  1. 一个Coffee接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * @Title: Coffee
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 9:54
    * @description: 咖啡接口
    */
    public interface Coffee {

    //获取咖啡名称
    String getName();

    //添加牛奶方法
    void addMilk();

    //添加糖
    void addSugar();
    }
  2. 美式咖啡和拿铁咖啡类去实现咖啡接口

    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
    package com.zhuixun.test.designPatterns;

    /**
    * @Title: LatteCoffee
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 10:28
    * @description: 拿铁咖啡类
    */
    public class LatteCoffee implements Coffee{
    @Override
    public String getName() {
    return "拿铁咖啡";
    }

    @Override
    public void addMilk() {
    System.out.println("拿铁咖啡添加牛奶");
    }

    @Override
    public void addSugar() {
    System.out.println("拿铁咖啡添加糖");
    }
    }


    package com.zhuixun.test.designPatterns;

    /**
    * @Title: AmericanCoffee
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 9:53
    * @description: 美式咖啡类
    */
    public class AmericanCoffee implements Coffee{

    @Override
    public String getName() {
    return "美式咖啡";
    }

    @Override
    public void addMilk() {
    System.out.println("美式咖啡添加牛奶");
    }

    @Override
    public void addSugar() {
    System.out.println("美式咖啡添加糖");
    }

    }

  3. 咖啡店类去点咖啡

    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
    package com.zhuixun.test.designPatterns;

    /**
    * @Title: CoffeeStore
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 10:29
    * @description: 咖啡店类
    */
    public class CoffeeStore {

    public static void main(String[] args) {
    createCoffee("拿铁");
    }

    public static Coffee createCoffee(String coffeeType){
    Coffee coffee = null;
    if (coffeeType.equals("拿铁")){
    coffee = new LatteCoffee();
    String name = coffee.getName();
    System.out.println("创建"+name+"成功");
    }else if(coffeeType.equals("美式")){
    coffee = new AmericanCoffee();
    String name = coffee.getName();
    System.out.println("创建"+name+"成功");
    }
    //添加配料
    coffee.addMilk();
    coffee.addSugar();
    return coffee;
    }

    }

​ 在java中,万物皆对象,这些对象都需要创建,如果创建的时候,直接new该对象,就会对该对象耦合严重,假如我们要更换对象,所有new对象的地方都需要修改一遍,这显然违背了软件设计的开闭原则

[^开闭原则:对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修原有代码,实现一个热插拔的效果,简而言之,是为了使程序的扩展性好,易于维护和升级。]:

​ 如果我们使用工厂来生产创建对象,我们就只和工厂打交道就可以了,彻底和对象解耦,如果需要更换对象,直接在工厂里面更换该对象即可,达到了与对象解耦的目的,所以说工厂模式最大的优点是解耦

存在以下三种工厂:

  • 简单工厂模式
  • 工厂方法模式
  • 抽象工厂模式

简单工厂模式

​ 简单工厂不是一种设计模式,反而比较像一种编程习惯

结构

简单工厂包含如下角色:

  • 抽象产品

    定义了产品的规范,描述了产品的主要特征和功能

  • 具体产品

    实现或者继承抽象产品的子类

  • 具体工厂

    提供了创建产品的方法,调用这通过该方法来获取产品

实现

现在使用简单工厂对上面案例进行改进:

image-20230521102022928

工厂代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @Title: CoffeeFactory
* @Author huan
* @Package com.zhuixun.test.designPatterns
* @Date 2024/3/11 11:06
* @description: 咖啡工厂类
*/
public class CoffeeFactory {
public static Coffee createCoffee(String coffeeType){
Coffee coffee = null;
if (coffeeType.equals("拿铁")){
coffee = new LatteCoffee();
String name = coffee.getName();
System.out.println("创建"+name+"成功");
}else if(coffeeType.equals("美式")){
coffee = new AmericanCoffee();
String name = coffee.getName();
System.out.println("创建"+name+"成功");
}
return coffee;
}
}

咖啡店代码:

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
package com.zhuixun.test.designPatterns;

/**
* @Title: CoffeeStore
* @Author huan
* @Package com.zhuixun.test.designPatterns
* @Date 2024/3/11 10:29
* @description: 咖啡店类
*/
public class CoffeeStore {

public static void main(String[] args) {
orderCoffee("美式");
}


//订单咖啡
public static void orderCoffee(String type){
//通过工厂获得对象,不需要知道对象实现的细节
CoffeeFactory coffeeFactory = new CoffeeFactory();
Coffee coffee = coffeeFactory.createCoffee(type);
coffee.addMilk();
coffee.addSugar();
}
}

​ 工厂处理创建对象的细节,一旦有了coffeeFactoy,CoffeeStore类中的orderCoffee()就变成此对象的客户,后期如果需要Coffee对象直接从工厂中获取即可,这样也就解除了和Coffee实现类的耦合,同时又产生了新的耦合CoffeeStore和CoffeeFactory工厂对象的耦合,工厂对象和商品对象的耦合。

​ 后期如果再加新品种的咖啡,我们势必要修改CoffeeFactory的代码,违反了开闭原则。

优点

​ 封装了创建对象的过程,可以通过参数直接获取对象,把对象的创建和业务逻辑层分开,这样以后就避免了修改客户代码,如果要实现新产品直接修改工厂类,而不需要在原代码中修改,这样就降低了客户代码修改的可能性,更加容易扩展

缺点

​ 增加新产品时还是需要修改工厂类的代码,违反了开闭原则

工厂方法模式

​ 定义一个用于创建对象的接口,让子类决定实例化哪个产品类对象。工厂方法使一个产品类的实例化延迟到其他工厂的子类

结构

工厂方法模式的主要角色:

  • 抽象工厂(Abstract Factory)

    提供了创建产品的接口,调用者通过它访问具体工厂的工厂方法来创建产品

  • 具体工厂(ConcreteFactory)

    主要是实现抽象工厂中的抽象方法,完成具体产品的创建

  • 抽象产品(Product)

    定义了产品的规范,描述了产品的主要特性和功能

  • 具体产品(ConcreteProduct)

    实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间一一对应

实现

使用工厂方法模式对上例进行改进,类图如下:

image-20230521102122950

流程:

image-20230521102156863

代码如下:

抽象工厂:

1
2
3
public interface CoffeeFactory {
Coffee createCoffee();
}

具体工厂:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class LatteCoffeeFactory implements CoffeeFactory {

public Coffee createCoffee() {
return new LatteCoffee();
}
}

public class AmericanCoffeeFactory implements CoffeeFactory {

public Coffee createCoffee() {
return new AmericanCoffee();
}
}

咖啡店类:

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
public class CoffeeStore{
//成员变量
private CoffeeFactory coffeeFactory;
//构造方法
public CoffeeStore(coffeeFactory){
this.coffeeFactory = coffeeFactory;
}


//咖啡订单
public Coffee orderCoffee(){
Coffee coffee = coffeeFactory.createCoffee();
//添加配料
coffee.addMilk();
coffee.addSugar();
retutn coffee;
}

public static void main(String[] args){
//可以根据不同的工厂,创建不同的产品
CoffeeStore coffeeStore = new CoffeeStore(new LatteCoffeeFactory());
Coffee latte = coffeeStore.orderCoffee();
System.out.println(latte,getName());
}
}

从以上的编写的代码可以看到,要增加产品类时也要相应地增加工厂类,不需要修改工厂类的代码了,这样就解决了简单工厂模式的缺点。

工厂方法模式是简单工厂模式的进一步抽象。由于使用了多态性,工厂方法模式保持了简单工厂模式的优点,而且克服了它的缺点。

优点

  • 用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程;
  • 在系统增加新的产品时只需要添加具体产品类和对应的具体工厂类,无须对原工厂进行任何修改,满足开闭原则;

缺点

  • 每增加一个产品就要增加一个具体产品类和一个对应的具体工厂类,这增加了系统的复杂度。

抽象工厂模式

​ 前面介绍的工厂方法模式中考虑的是一类产品的生产,如畜牧场只养动物、电视机厂只生产电视机等

​ 同种类产品称为同等级产品,也就是说:工厂方法模式只考虑生产同等级的产品。

​ 在现实生活中许多工厂是综合型的工厂,能生产多等级(种类) 的产品,如电器厂既生产电视机又生产洗衣机或空调,大学既有软件专业又有生物专业等。

​ 抽象工厂模式将考虑多等级产品的生产,将同一个具体工厂所生产的位于不同等级的一组产品称为一个产品族,下图所示

  • 产品族:一个品牌下面的所有产品;例如华为下面的电脑、手机称为华为的产品族;
  • 产品等级:多个品牌下面的同种产品;例如华为和小米都有手机电脑为一个产品等级;

image-20220913115948157

概念

​ 是一种为访问类提供一个创建一组组或相关依赖对象的接口,且访问类无需指定所要产品的具体类就能得到同族的不同等级的产品的模式结构

​ 抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产一个等级的产品,而抽象工厂模式可以生产多个等级的产品

一个超级工厂创建其他工厂。该超级工厂又被称为其他工厂的工厂

结构

抽象工厂模式的主要角色如下:

  • 抽象工厂(Abstract Factory)

    提供了创建产品的接口,它包含多个创建产品的方法,可以创建多个不同等级的产品

  • 具体工厂(Concrete Factory)

    主要是实现抽象工厂中的多个抽象方法,完成具体产品的创建

  • 抽象产品(Product)

    定义了产品的规范,描述了产品的主要特性和功能,抽象工厂模式有多个抽象产品

  • 具体产品(ConcreteProduct)

    实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间是多对一的关系

实现

现在咖啡店业务发生改变,不仅要生产咖啡还要生产甜点

  • 同一个产品等级(产品分类)
    • 咖啡:拿铁咖啡、美式咖啡
    • 甜点:提拉米苏、抹茶慕斯
  • 同一个风味,就是同一个产品族(相当于同一个品牌)
    • 美式风味:美式咖啡、抹茶慕斯
    • 意大利风味:拿铁咖啡、提拉米苏

要是按照工厂方法模式,需要定义提拉米苏类、抹茶慕斯类、提拉米苏工厂、抹茶慕斯工厂、甜点工厂类,很容易发生爆炸情况。

所以这个案例可以使用抽象工厂模式实现,类图如下:

image-20230521102319997

整体调整思路:

image-20220913124542154

优点

当一个产品族中的多个对象被设计成一起工作时,它能保证客户端始终只使用同一个产品族中的对象

缺点

当产品族中需要增加一个新的产品时,所有的工厂类都需要进行修改。

使用场景

  • 当需要创建的对象是一系列相互关联或相互依赖的产品族时,如电器工厂中的电视机、洗衣机、空调等
  • 系统中有多个产品族,但每次只使用其中的某一族产品。如有人只喜欢穿某一个品牌的衣服和鞋。
  • 系统中提供了产品的类库,且所有产品的接口相同,客户端不依赖产品实例的创建细节和内部结构。
  • 输入法换皮肤,一整套一起换。生成不同操作系统的程序。

策略模式

​ 我们去旅游选择出行模式有很多种,可以骑自行车、可以坐汽车、可以坐火车、可以坐飞机。

​ 该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户。策略模式属于对象行为模式,它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委派给不同的对象对这些算法进行管理。

结构

策略模式的主要角色如下:

  • 抽象策略(Strategy)类:这是一个抽象角色,通常由一个接口或抽象类实现。此角色给出所有的具体策略类所需的接口。
  • 具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现或行为。
  • 环境(Context)类:持有一个策略类的引用,最终给客户端调用。

案例实现

【例】促销活动

一家百货公司在定年度的促销活动。针对不同的节日(春节、中秋节、圣诞节)推出不同的促销活动,由促销员将促销活动展示给客户。类图如下

image-20220913125209804

  • 聚合关系可以用带空心菱形的实线来表示

代码如下:

定义百货公司所有促销活动的共同接口

1
2
3
public interface Strategy {   
void show();
}

定义具体策略角色(Concrete Strategy):每个节日具体的促销活动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//为春节准备的促销活动A
public class StrategyA implements Strategy {

public void show() {
System.out.println("买一送一");
}
}

//为中秋准备的促销活动B
public class StrategyB implements Strategy {

public void show() {
System.out.println("满200元减50元");
}
}

//为圣诞准备的促销活动C
public class StrategyC implements Strategy {

public void show() {
System.out.println("满1000元加一元换购任意200元以下商品");
}
}

定义环境角色(Context):用于连接上下文,即把促销活动推销给客户,这里可以理解为销售员

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SalesMan {                        
//持有抽象策略角色的引用
private Strategy strategy;

public SalesMan(Strategy strategy) {
this.strategy = strategy;
}

//向客户展示促销活动
public void salesManShow(){
strategy.show();
}
}

综合案例

系统存在多种方式可以进行登录

  • 用户名密码登录

  • 短信验证码登录

  • 微信登录

  • QQ登录

  • ….

一般自己的代码都是使用前端传递过来的登录类型去if else判断去走对应的登录方式,这样会出现一个问题,如果业务发生变更,需要新增一个登录方式,这个时候就需要修改业务层代码,违反了开闭原则

解决:

使用工厂方法设计模式+策略模式

  1. 整体思路

    改造之后,不在service中写业务逻辑,让service调用工厂,然后通过service传递不同的参数来获取不同的登录策略(登录方式)

  2. 具体实现

    • 抽象策略类:UserGranter

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      /**
      * 抽象策略类
      */
      public interface UserGranter{

      /**
      * 获取数据
      * @param loginReq 传入的参数
      * @return map值
      */
      LoginResp login(LoginReq loginReq);
      }
    • 具体的策略:AccountGranter、SmsGranter、WeChatGranter

      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
      /**
      * 策略:账号登录
      **/
      @Component
      public class AccountGranter implements UserGranter{

      @Override
      public LoginResp login(LoginReq loginReq) {

      System.out.println("登录方式为账号登录" + loginReq);
      // TODO
      // 执行业务操作

      return new LoginResp();
      }
      }
      /**
      * 策略:短信登录
      */
      @Component
      public class SmsGranter implements UserGranter{

      @Override
      public LoginResp login(LoginReq loginReq) {

      System.out.println("登录方式为短信登录" + loginReq);
      // TODO
      // 执行业务操作

      return new LoginResp();
      }
      }
      /**
      * 策略:微信登录
      */
      @Component
      public class WeChatGranter implements UserGranter{

      @Override
      public LoginResp login(LoginReq loginReq) {

      System.out.println("登录方式为微信登录" + loginReq);
      // TODO
      // 执行业务操作

      return new LoginResp();
      }
      }
    • 工程类UserLoginFactory

      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
      /**
      * 操作策略的上下文环境类 工具类
      * 将策略整合起来 方便管理
      */
      @Component
      public class UserLoginFactory implements ApplicationContextAware {

      private static Map<String, UserGranter> granterPool = new ConcurrentHashMap<>();

      @Autowired
      private LoginTypeConfig loginTypeConfig;

      /**
      * 从配置文件中读取策略信息存储到map中
      * {
      * account:accountGranter,
      * sms:smsGranter,
      * we_chat:weChatGranter
      * }
      *
      * @param applicationContext
      * @throws BeansException
      */
      @Override
      public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
      loginTypeConfig.getTypes().forEach((k, y) -> {
      granterPool.put(k, (UserGranter) applicationContext.getBean(y));
      });
      }

      /**
      * 对外提供获取具体策略
      *
      * @param grantType 用户的登录方式,需要跟配置文件中匹配
      * @return 具体策略
      */
      public UserGranter getGranter(String grantType) {
      UserGranter tokenGranter = granterPool.get(grantType);
      return tokenGranter;
      }

      }
    • 在application.yml文件中新增自定义配置

      1
      2
      3
      4
      5
      login:
      types:
      account: accountGranter
      sms: smsGranter
      we_chat: weChatGranter
    • 新增读取数据配置类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      Getter
      @Setter
      @Configuration
      @ConfigurationProperties(prefix = "login")
      public class LoginTypeConfig {

      private Map<String,String> types;

      }

    • 改造service

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      @Service
      public class UserService {

      @Autowired
      private UserLoginFactory factory;

      public LoginResp login(LoginReq loginReq){

      UserGranter granter = factory.getGranter(loginReq.getType());
      if(granter == null){
      LoginResp loginResp = new LoginResp();
      loginResp.setSuccess(false);
      return loginResp;
      }
      LoginResp loginResp = granter.login(loginReq);
      return loginResp;
      }
      }

举一反三

其实像这样的需求,在日常开发中非常常见,场景有很多,以下的情景都可以使用工厂模式+策略模式解决比如:

  • 订单的支付策略
    • 支付宝支付
    • 微信支付
    • 银行卡支付
    • 现金支付
  • 解析不同类型excel
    • xls格式
    • xlsx格式
  • 打折促销
    • 满300元9折
    • 满500元8折
    • 满1000元7折
  • 物流运费阶梯计算
    • 5kg以下
    • 5kg-10kg
    • 10kg-20kg
    • 20kg以上

一句话总结:只要代码中有冗长的 if-else 或 switch 分支判断都可以采用策略模式优化

责任链设计模式

概述

​ 在现实生活中,常常会出现这样的事例:一个请求有多个对象可以处理,但每个对象的处理条件或权限不同。

​ 例如,公司员工请假,可批假的领导有部门负责人、副总经理、总经理等,但每个领导能批准的天数不同,员工必须根据自己要请假的天数去找不同的领导签名,也就是说员工必须记住每个领导的姓名、电话和地址等信息,这增加了难度。

​ 这样的例子还有很多,如找领导出差报销、生活中的“击鼓传花”游戏等。

定义

​ 又名职责链模式,为了避免请求发送者与多个请求处理者耦合在一起,将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。

比较常见的springmvc中的拦截器,web开发中的filter过滤器

image-20230521111455359

结构

职责链模式主要包含以下角色:

  • 抽象处理者(Handler)角色:定义一个处理请求的接口,包含抽象处理方法和一个后继连接。
  • 具体处理者(Concrete Handler)角色:实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。
  • 客户类(Client)角色:创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。

案例实现

处理订单的操作

image-20230521111943231

类图:

image-20230521112108550

代码:

  • 抽象处理者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /**
    * 抽象处理者
    */
    public abstract class Handler {

    protected Handler handler;

    public void setNext(Handler handler) {
    this.handler = handler;
    }

    /**
    * 处理过程
    * 需要子类进行实现
    */
    public abstract void process(OrderInfo order);
    }
  • 订单信息类:

    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
    import java.math.BigDecimal;

    public class OrderInfo {

    private String productId;
    private String userId;

    private BigDecimal amount;

    public String getProductId() {
    return productId;
    }

    public void setProductId(String productId) {
    this.productId = productId;
    }

    public String getUserId() {
    return userId;
    }

    public void setUserId(String userId) {
    this.userId = userId;
    }

    public BigDecimal getAmount() {
    return amount;
    }

    public void setAmount(BigDecimal amount) {
    this.amount = amount;
    }
    }
  • 具体处理者

    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
    /**
    * 订单校验
    */
    public class OrderValidition extends Handler {

    @Override
    public void process(OrderInfo order) {
    System.out.println("校验订单基本信息");
    //校验
    handler.process(order);
    }

    }

    /**
    * 补充订单信息
    */
    public class OrderFill extends Handler {
    @Override
    public void process(OrderInfo order) {
    System.out.println("补充订单信息");
    handler.process(order);
    }

    }

    /**
    * 计算金额
    */
    public class OrderAmountCalcuate extends Handler {
    @Override
    public void process(OrderInfo order) {
    System.out.println("计算金额-优惠券、VIP、活动打折");
    handler.process(order);
    }

    }

    /**
    * 订单入库
    */
    public class OrderCreate extends Handler {
    @Override
    public void process(OrderInfo order) {
    System.out.println("订单入库");
    }
    }

  • 客户类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class Application {

    public static void main(String[] args) {
    //检验订单
    Handler orderValidition = new OrderValidition();
    //补充订单信息
    Handler orderFill = new OrderFill();
    //订单算价
    Handler orderAmountCalcuate = new OrderAmountCalcuate();
    //订单落库
    Handler orderCreate = new OrderCreate();

    //设置责任链路
    orderValidition.setNext(orderFill);
    orderFill.setNext(orderAmountCalcuate);
    orderAmountCalcuate.setNext(orderCreate);

    //开始执行
    orderValidition.process(new OrderInfo());
    }

    }

优点

  • 降低了对象之间的耦合度

    该模式降低了请求发送者和接收者的耦合度。

  • 增强了系统的可扩展性

    可以根据需要增加新的请求处理类,满足开闭原则。

  • 增强了给对象指派职责的灵活性

    当工作流程发生变化,可以动态地改变链内的成员或者修改它们的次序,也可动态地新增或者删除责任。

  • 责任链简化了对象之间的连接

    一个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句。

  • 责任分担

    每个类只需要处理自己该处理的工作,不能处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。

缺点

  • 不能保证每个请求一定被处理。由于一个请求没有明确的接收者,所以不能保证它一定会被处理,该请求可能一直传到链的末端都得不到处理。
  • 对比较长的职责链,请求的处理可能涉及多个处理对象,系统性能将受到一定影响。
  • 职责链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于职责链的错误设置而导致系统出错,如可能会造成循环调用。

举一反三

  • 内容审核(视频、文章、课程….)

    image-20230521112731617

  • 订单创建

    image-20230521112802886

  • 简易流程审批

    image-20230521112831065

常见技术场景

单点登录

概述

​ 单点登录的英文名叫做:Single Sign On(简称SSO),只需要登录一次,就可以访问所有信任的应用系统

​ 在以前的时候,一般我们就单系统,所有的功能都在同一个系统上。

单体系统的session共享

  • 登录:将用户信息保存在Session对象中

    • 如果在Session对象中能查到,说明已经登录
    • 如果在Session对象中查不到,说明没登录(或者已经退出了登录)
  • 注销(退出登录):从Session中删除用户的信息

后来,我们为了合理利用资源和降低耦合性,于是把单系统拆分成多个子系统。

多系统即可能有多个Tomcat,而Session是依赖当前系统的Tomcat,所以系统A的Session和系统B的Session是不共享的。

解决系统之间Session不共享问题有一下几种方案:

  • Tomcat集群Session全局复制(最多支持5台tomcat,不推荐使用)
  • JWT(常见)
  • Oauth2
  • CAS
  • 自己实现(redis+token)

JWT解决单点登录

使用jwt解决单点登录的流程如下:

image-20230521113941467

回答要点

  1. 先解释什么是单点登录

    单点登录的英文名叫做:Single Sign On(简称SSO

  2. 介绍自己项目中涉及到的单点登录(即使没涉及过,也可以说实现的思路)

  3. 介绍单点登录的解决方案,以JWT为例

    • 用户访问其他系统,会在网关判断token是否有效
    • 如果token无效则会返回401(认证失败)前端跳转到登录页面
    • 用户发送登录请求,返回浏览器一个token,浏览器把token保存到cookie
    • 再去访问其他服务的时候,都需要携带token,由网关统一验证后路由到目标服务

权限认证

概述

后台的管理系统,更注重权限控制,最常见的就是RBAC模型来指导实现权限

RBAC(Role-Based Access Control)基于角色的访问控制

  • 3个基础部分组成:用户、角色、权限

  • 具体实现

    • 5张表(用户表、角色表、权限表、用户角色中间表、角色权限中间表)
    • 7张表(用户表、角色表、权限表、菜单表、用户角色中间表、角色权限中间表、权限菜单中间表)

RBAC权限模型

最常见的5张表的关系

image-20230521114305463

数据流转

张三具有什么权限呢?

流程:张三登录系统—> 查询张三拥有的角色列表—>再根据角色查询拥有的权限

image-20230521114432028

在实际的开发中,也会使用权限框架完成权限功能的实现,并且设置多种粒度,常见的框架有:

  • Apache shiro
  • Spring security(推荐)

上传数据安全如何控制

这里的安全性,主要说的是,浏览器访问后台,需要经过网络传输,有可能会出现安全的问题

解决方案:使用非对称加密(或对称加密),给前端一个公钥让他把数据加密后传到后台,后台负责解密后处理数据

对称加密

文件加密和解密使用相同的密钥,即加密密钥也可以用作解密密钥

一般用于保存用户手机号、身份证等敏感但能解密的信息。

常见的对称加密算法有: AES、DES、3DES、Blowfish、IDEA、RC4、RC5、RC6、HS256

image-20230521125012727

  • 数据发信方将明文和加密密钥一起经过特殊的加密算法处理后,使其变成复杂的加密密文发送出去,

  • 收信方收到密文后,若想解读出原文,则需要使用加密时用的密钥以及相同加密算法的逆算法对密文进行解密,才能使其回复成可读明文。

  • 在对称加密算法中,使用的密钥只有一个,收发双方都使用这个密钥,这就需要解密方事先知道加密密钥。

优点

对称加密算法的优点是算法公开、计算量小、加密速度快、加密效率高。

缺点

没有非对称加密安全

非对称加密

两个密钥:公开密钥(publickey)和私有密钥,公有密钥加密,私有密钥解密

一般用于签名和认证。私钥服务器保存, 用来加密, 公钥客户拿着用于对于令牌或者签名的解密或者校验使用.

常见的非对称加密算法有: RSA、DSA(数字签名用)、ECC(移动设备用)、RS256 (采用SHA-256 的 RSA 签名

image-20230521125136717

解释: 同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端.

加密与解密:

  • 私钥加密,持有公钥才可以解密
  • 公钥加密,持有私钥才可解密

签名:

  • 私钥签名, 持有公钥进行验证是否被篡改过.

优点

非对称加密与对称加密相比,其安全性更好

缺点

非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。

回答要点

使用非对称加密(或对称加密),给前端一个公钥让他把数据加密后传到后台,后台解密后处理数据

  • 传输的数据很大建议使用对称加密,不过不能保存敏感信息
  • 传输的数据较小,要求安全性高,建议采用非对称加密

项目遇到那些棘手问题

有4个方面可以回答,只要挑出一个回答就行了

设计模式

  • 工厂模式+策略
  • 责任链模式

举例:

  1. 介绍登录业务(一开始没有用设计模式,所有的登录方式都柔和在一个业务类中,不过,发现需求经常改)

  2. 登录方式经常会增加或更换,每次都要修改业务层代码,所以,经过我的设计,使用了工厂设计模式和策略模式,解决了,经常修改业务层代码的问题

  3. 详细介绍一下工厂模式和策略模式(参考前面设计模式的课程)

线上bug

  • CPU飙高
  • 内存泄漏
  • 线程死锁

调优

  • 慢接口
  • 慢SQL
  • 缓存方案

组件封装

  • 分布式锁
  • 接口幂等
  • 分布式事务
  • 支付通用

项目中日志怎么采集

日志是定位系统问题的重要手段,可以根据日志信息快速定位系统中的问题

日志采集方式

  • ELK:即Elasticsearch、Logstash和Kibana三个软件的首字母

  • 常规采集:按天保存到一个日志文件

ELK基本架构

ELK即Elasticsearch、Logstash和Kibana三个开源软件的缩写

image-20230521232913086

  • Elasticsearch
    Elasticsearch 全文搜索和分析引擎,对大容量的数据进行接近实时的存储、搜索和分析操作。

  • Logstash
    Logstash是一个数据收集引擎,它可以动态的从各种数据源搜集数据,并对数据进行过滤、分析和统一格式等操作,并将输出结果存储到指定位置上

  • Kibana
    Kibana是一个数据分析和可视化平台,通常与Elasticsearch配合使用,用于对其中的数据进行搜索、分析,并且以统计图标的形式展示。

参考回答

  • 我们搭建了ELK日志采集系统

  • 介绍ELK的三个组件:

    • Elasticsearch是全文搜索分析引擎,可以对数据存储、搜索、分析
    • Logstash是一个数据收集引擎,可以动态收集数据,可以对数据进行过滤、分析,将数据存储到指定的位置
    • Kibana是一个数据分析和可视化平台,配合Elasticsearch对数据进行搜索,分析,图表化展示

linux查看日志的命令

目前采集日志的方式:按天保存到一个日志文件

也可以在logback配置文件中设置日志的目录和名字

image-20230521233220905

需要掌握的Linux中的日志:

  • 实时监控日志的变化

    实时监控某一个日志文件的变化:tail -f xx.log;实时监控日志最后100行日志: tail –n 100 -f xx.log

  • 按照行号查询

    • 查询日志尾部最后100行日志:tail – n 100 xx.log

    • 查询日志头部开始100行日志:head –n 100 xx.log

    • 查询某一个日志行号区间:cat -n xx.log | tail -n +100 | head -n 100 (查询100行至200行的日志)

  • 按照关键字找日志的信息

    查询日志文件中包含debug的日志行号:cat -n xx.log | grep “debug”

  • 按照日期查询

    sed -n ‘/2023-05-18 14:22:31.070/,/ 2023-05-18 14:27:14.158/p’xx.log

  • 日志太多,处理方式

    • 分页查询日志信息:cat -n xx.log |grep “debug” | more

    • 筛选过滤以后,输出到一个文件:cat -n xx.log | grep “debug” >debug.txt

生产问题怎么排查

已经上线的bug排查的思路:

  1. 先分析日志,通常在业务中都会有日志的记录,或者查看系统日志,或者查看日志文件,然后定位问题
  2. 远程debug(通常公司的正式环境(生产环境)是不允许远程debug的。一般远程debug都是公司的测试环境,方便调试代码)

远程debug配置

前提条件:远程的代码和本地的代码要保持一致

  1. 远程代码需要配置启动参数,把项目打包放到服务器后启动项目的参数

    1
    java -jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 project-1.0-SNAPSHOT.jar
    • -agentlib:jdwp 是通知JVM使用(java debug wire protocol)来运行调试环境

    • transport=dt_socket 调试数据的传送方式

    • server=y 参数是指是否支持在server模式

    • suspend=n 是否在调试客户端建立起来后,再执行JVM。

    • address=5005 调试端口设置为5005,其它端口也可以

  2. idea中设置远程debug,找到idea中的 Edit Configurations…

    image-20230521233554657

    image-20230521233600556

  3. idea中启动远程debug

  4. 访问远程服务器,在本地代码中打断点即可调试远程

快速定位系统瓶颈

  • 压测(性能测试),项目上线之前测评系统的压力

    • 压测目的:给出系统当前的性能状况;定位系统性能瓶颈或潜在性能瓶颈
    • 指标:响应时间、 QPS、并发数、吞吐量、 CPU利用率、内存使用率、磁盘IO、错误率
    • 压测工具:LoadRunner、Apache Jmeter …
    • 后端工程师:根据压测的结果进行解决或调优(接口慢、代码报错、并发达不到要求…)
  • 监控工具、链路追踪工具,项目上线之后监控

    • 监控工具:Prometheus+Grafana
    • 链路追踪工具:skywalking、Zipkin
  • 线上诊断工具Arthas(阿尔萨斯),项目上线之后监控、排查


黑马面试课程
http://example.com/2024/02/14/面试总结/黑马面试课程/
作者
zhuixun
发布于
2024年2月14日
许可协议