Java八股文

现代八股

1 Redis篇

  1. Redis使用场景
    • 缓存(穿透,击穿,雪崩 | 双写一致,持久化 | 数据过期,淘汰策略)
    • 分布式锁(setnx,re’disson)
    • 计数器
    • 保存token
    • 消息队列
    • 延迟队列

1.1 缓存穿透

屏幕截023-09-2601017

例如,一个get请求: api/new/getById/1
20230926101238

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

穿透穿透,可以理解为请求的数据穿透了redis,还是动用了mysql,导致缓存没起到作用

解决方案一:缓存空数据,查询返回的数据为空,扔把这个空结果进行缓存
优点:简单
缺点:消耗内存,可能会发生不一致的问题

解决方案二:布隆过滤器
优点:内存占用较少,没有多余的key
缺点:实现复杂,存在误判
20230926101827

布隆过滤器
bitmap(位图):相当于一个以(bit)位为单位的数组,数组中每个单元只能存储二进制数0或1
布隆过滤器的作用:布隆过滤器可以用于检索一个元素是否在一个集合中
2023-09-26102348
两种实现:redisson,guava

但是布隆过滤器也存在误判的可能
20230926102614
误判率:数组越小误判率就越大,数组越大误判率就越小,但是同时也带来了更多的内存消耗

1.2 缓存击穿

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

两种解决方案

  1. 互斥锁
    20230926123451
  2. 逻辑过期
    • 判断数据是否逻辑过期
    • 如已过期,获取互斥锁,开启新线程
      • 新线程中:查询数据库重建缓存数据
      • 写入缓存,重置逻辑过期时间
      • 释放锁
    • 返回过期数据(注意,此时的返回数据,并不需要等待新线程执行完毕,而是直接返回)
      20230926124730

互斥锁和逻辑过期的比较

  1. 互斥锁
    • 强一执
    • 性能差
  2. 逻辑过期
    • 高可用
    • 性能优

1.3 缓存雪崩

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

解决方案

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性(哨兵模式,集群模式)
  • 给缓存业务添加降级限流的策略(nginx,spring cloud gateway)
  • 给业务添加多级缓存(Guava,Caffenie)

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


缓存三兄弟
穿透无中生有key,布隆过滤null隔离
缓存击穿过期key,锁与非期解难题
雪崩大量过期key,过期时间要随机
面试必考三兄弟,可用限流来保底

1.4 双写一致

20230926131024

要结合业务背景,有两种情况

  1. 一致性要求高
  2. 允许延迟一致

一致性要求高

双写一致性,当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致

20230926125923

  • 读操作:缓存命中,直接返回;缓存未命中则查询数据库,写入缓存,设定超时时间
  • 写操作:延迟双删

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

  1. 先删除缓存
    • 正常情况:20230926131855
    • 异常情况,数据不一致(数据库和缓存中的数据不一致)20230926131950
  2. 先操作数据库
    • 正常情况:20230926132240
    • 异常情况,数据不一致20230926132414

所以,无论是先删缓存,还是先修改数据库,都是会有问题的

这时采用延迟双删
20230926132535

虽然还是有脏数据的风险罢了

那有没有强一致性的解决方案呢?

分布式锁

或者说是读写锁,只针对于需要强一致性的场景,因为性能低

  1. 共享锁:读锁readLock,加锁之后,其他线程可以继续共享读操作
  2. 排他锁:独占锁writeLock也叫,加锁之后,阻塞其他线程读写操作
    20230926133140

允许延迟一致

异步通知保证数据的最终一致性
20230926133556

基于Canal的异步通知
20230926133620

canal是基于mysql的主从同步来实现的
二进制日志文件(BINGLOG)记录了所有DDL(数据定义语言)语句和DML(数据操作语言)语句,但不包括数据查询(SELECT,SHOW)语句

3123213213213213

1.5 持久化

20230926221020

  1. RDB
  2. AOF

RDB

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

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

1
2
3
save 900 1
save 300 10
save 60 10000

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

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

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

AOF

AOF全称为Append Only File(追加文件),Redis处理的每一个写命令都会记录在AOF文件,可以看作命令日志文件
223022

AOF默认是关闭的,要在配置文件中开启

1
appendonly yes

AOF的同步频率设置

  • appendfsync always 始终同步,每次Redis的写入都会立刻记入日志,性能较差但数据完整性比较好
  • appendfsync everysec 每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失
  • appendfsync no redis不主动进行同步,把同步时机交给操作系统

因为是记录命令,AOF文件会比RDB文件大的多,而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义,通过执行 bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同的效果

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

总结就是,RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用(这和之前看的redis视频讲的差不多)
20230926224811

1.6 过期策略

20230926225503

惰性删除

Redis数据删除策略-惰性删除

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

优点:对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查
缺点:对内存不友好,如果一个key已过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放

定期删除

Redis数据删除策略-定期删除

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

定期删除有两种模式

  1. SLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf的hz选项来调整这个次数
  2. FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

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


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

1.7 淘汰策略

20230927085536

数据的淘汰策略:当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算法进行淘汰

使用建议

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

其他可能会问的问题

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

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

  1. Redis的内存用完了会发生什么
    主要看数据淘汰策略是什么,如果是默认的配置(noeviction),会直接报错

1.8 分布式锁

20230927091744

当只有一台服务器时,使用 synchronized解决是没问题的,但是当服务是多台服务器时,就不能使用了,还是需要外部锁的来解决
23-09-27092743

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

获取锁,NX是互斥,EX是设置过期时间

1
SET lock value NX EX 10

释放锁

1
DEL key

整体流程:
20230927145211

设置有效时长
20230927145426

  1. 根据业务执行时间预估
  2. 给锁续期

这时就要用到 redisson来实现分布式锁

123132150213

redisson实现的分布式锁-可重入
20230927150634

redisson实现的分布式锁-主从一致性(较少使用)
RedLock(红锁):不能只在一个redis实力上创建锁,应该是在多个redis实例上创建锁(n/2+1),避免在一个redis实例上加锁
20230927151015

  • 实现复杂
  • 性能差
  • 运维繁琐

1.9 集群方案

20230927151710

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

主从复制

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

2023-09-27152040

分为全量同步和增量同步

增量同步:
153922

增量同步(slave重启或者后期数据变化):
20230927154218

回答案例:
20230927154511

哨兵模式

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

  1. 监控:Sentinel会不端检查您的master和slave是否按预期工作
  2. 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master,当故障实例恢复后也以新的master为主
  3. 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis客户端
    20230927154951

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

  1. 主观下线:如果其sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线
  2. 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线,quorum值最好超过Sentinel实例数量的一半
    20230927155336

下线后,哨兵就要选择一个从节点作为主节点

  1. 首先判断主与从节点断开时间长短,如超过指定值就排该从节点
  2. 然后判断从节点的slave-priority值,越小优先级越高
  3. 如果slave-prority一样,则判断slave节点和offset值,越大优先级越高
  4. 最后是判断slave节点的运行id大小,越小优先级越高

哨兵模式,可能存在的问题:脑裂
20230927160007

redis中有两个配置参数
min-replicas-to-write 1表示最少的slave节点为1个
min-replicas-max-lag 5表示数据复制和同步的延迟不能超过5秒

设置了这两个参数的,当主节点宕机,不达到这两个条件,是不会选择新的主机的.

分片集群结构

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

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

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

  1. 集群中有多个master,每个master保存不同的数据
  2. 每个master都可以有多个slave节点
  3. master之间通过ping监测彼此健康状态
  4. 客户端请求可以访问集群任意节点,最终都会被转发到正确节点
    20230927205512

Redis分片集群引入了哈希槽的概念,Redis集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽
屏幕截图2023-09-27210240

1.10 Redis其他问题

Redis为什么这么快
20230927210522

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

I/O多路复用是利用单个线程来同时监听多个Socket,并在某个Socket可读,可写时得到通知,从而避免无效的等待,充分利用CPU资源,不过监听Socket的方式,通知的方式又有多种实现,常见的有

  1. select
  2. poll
  3. epoll

差异

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

fdlkajlksfdjlkasf

2 数据库篇

看完了索引那块,这块内容就能很快看完了吧

2.1 优化篇

如何定位慢查询

  1. 调试工具:Arthas
  2. 运维工具:Prometheus,Skywalking
  3. mysql自带的慢查询日志功能,配置流程

一条Sql执行的很慢,如何分析呢?

  • 聚合查询
  • 多表查询
  • 表数据过大查询
  • 深度分页查询

使用Explain来查看sql是否有问题

  1. 查看索引是否失效
  2. 查看是否有优化空间
  3. 覆盖索引

介绍索引

  1. 索引是帮助mysql高效获取数据的数据结构
  2. 提高数据的检索效率,降低数据库的IO成本
  3. 通过索引列对数据进行排序,降低数据排序成本,降低CPU的消耗

B+树

  1. 路径短
  2. 磁盘读写代价B+树低,非叶子节点不存储数据,叶子节点存储数据
  3. B+树便于扫库和区间查询,叶子节点是一个双向链表

聚簇索引以及非聚簇索引

索引类型

简单来说,聚簇索引就是数据和索引放到了一棵B+树中,叶子节点包含了所有的数据

非聚簇索引:叶子节点只包含索引列以及主键值

回表:使用二级索引时,根据二级索引拿到主键值,再用主键值根据聚簇索引来回表查询


覆盖索引
简单来说,索引列+主键包含SELECT到FROM之间查询的列

尽量避免 select*

mysql超大分页怎么处理
可以使用覆盖索引+子查询

先通过id对表数据进行排序,这里就用到了覆盖索引(或者说是聚簇索引),效率很高
10-13190402


索引创建的原则

  1. 针对于数据量较大,且查询比较频繁的表建立索引(单表超过10万)
  2. 针对于常作查询条件(where),排序(order by),分组(group by)操作的字段建立索引
  3. 尽量选择区分度较高的列作为索引
  4. 如果字段的类型是字符串,且长度较长,可以针对字段的特点,建立前缀索引
  5. 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以实现覆盖索引,节省存储空间,避免回表,提高查询效率
  6. 控制索引的数量,因为索引会影响增删改的效率

索引失效

失效案例


sql优化的经验

  1. 表设计优化
  2. 索引优化
  3. sql语句优化
  4. 主从复制,读写分离
  5. 分库分表

2023-10-13200233

2.2 其他面试题

事务相关

事务是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这样操作要么同时成功,要么同时失败

ACID

  • 原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败
  • 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态
  • 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行
  • 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的

并发事务带来的问题:脏读,不可重复读,幻读
2023-10-13202107

隔离级别:读未提交,读已提交,可重复读,串行化


undo log和redo log的区别
2023-10-1310455

  • 缓冲池(buffer poll):主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改查操作时,先操作缓存池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度
  • 数据页(page):是InnoDB存储引擎磁盘管理的最小单元,每个页的大小默认为16KB,页中存储的是行数据

redo log
重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性
该日志文件由两部分组成

  1. 重做日志缓存(redo log buffer),内存中
  2. 重做日志文件(redo log file),磁盘中

当事务提交之后会把所有修改信息都存放到该日志文件中,用于在刷新脏页到磁盘,发生错误时,进行数据恢复使用

undo log
回滚日志,用于记录数据被修改前的信息,作用包含两个:提供回滚MVCC(多版本并发控制),undo log和redo log记录物理日志不一样,它是逻辑日志

  • 可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之依然
  • 当update一条记录时,它记录一条对应相反的update记录,当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚

undo log可以实现事务的一致性和原子性


事务的隔离性是如何保证的呢?

  • 锁:排他锁(如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁)
  • mvcc:多版本并发控制

MVCC-实现原理

  • 记录中的隐藏字段
    023-10-13211625

回滚日志,在insert,update,delete的时候产生的便于数据回滚的日志
当insert的时候,产生的undo log日志只在回滚时需要,在事务提交后,可被立即删除
而update,delete的时候,产生的undo log日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除

??????

这个mvcc抽空看看mysql的课吧

主从同步原理

mysql主从复制的核心就是二进制日志文件

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

023-10-13446

分库分表

这个只有数据量特别大的时候才会用到

记住垂直是分结构,水平是分数据

3 框架篇

花了三天把课程设计给写了写

3.1 Spring

Spring框架的单例Bean是线程安全的吗?

Spring框架中的bean是单例的吗?

bean默认是单例的,但是并不是线程安全的

但是像一个controller方法,中注入的service,由于该service是无状态的(就是不能被修改的),那么此时就是线程安全的.

回答
不是线程安全的,Spring框架中有一个@Scope注解,默认的值就是singleton,单例的,因为一般在Srping的bean中都是注入无状态的对象,没有线程安全问题,如果在bean中定义了可修改的成员变量,是要考虑线程安全问题的,可以使用多例或者加锁来解决


什么是AOP,你们项目中有没有使用到AOP

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

记录操作日志思路
202310171105
获取用户名,请求方式,访问地址,模块名称,登录ip,操作时间,记录到数据库的日志表中

Spring中的事务是如何实现的

  1. 编程式事务控制:需使用TransactionTemplate来进行实现,对业务代码有侵入性,项目中很少使用
  2. 声明式事务管理:声明式事务管理建立在AOP之上的,其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务

2023-10-17114051


Spring事务失效场景

  1. 情况一:异常捕获处理
    2023-10-17 14446
    原因:事务通知只有捕获到了目标抛出的异常,才能进行后续的回滚处理,如果目标自己处理掉异常,事务通知就无法处理,所以可以在catch中再次抛出异常就能解决这个问题
  2. 情况二:抛出检查异常
    2023-10-17140911
    原因是,Spring默认只会回滚非检查异常(运行时异常),对于检查异常(编译时异常),如果在方法上抛出的话,这个事务也会失效,解决办法是修改注解 @Transactional(rollbackFor = Exception.class)
  3. 情况三:非public方法导致的事务失效
    2023-10-17141426
    原因是:Spring为方法创建代理,添加事务通知,前提条件都是该方法是public

Spring的bean的生命周期

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

  • beanClassName: bean的类名
  • initMethodName: 初始化方法名称
  • propertyValues: bean的属性值
  • scope: 作用域
  • lazyInit: 延迟初始化
    64644653

有了BeanDefinition对象后,就可以进行创建bean了
4312431145124

回答:
2023-10-17150052


Spring中的循环引用

可以是两个类互相引用,也可以是多个
2023-10-17150241

循环依赖:
-10-1750714

如何解决?

Spring解决循环依赖是通过三级缓存,对应的三级缓存如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry {

/** Maximum number of suppressed exceptions to preserve. */
private static final int SUPPRESSED_EXCEPTIONS_LIMIT = 100;


/** Cache of singleton objects: bean name to bean instance. */
// 一级缓存
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

// 三级缓存
/** Cache of singleton factories: bean name to ObjectFactory. */
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

// 二级缓存
/** Cache of early singleton objects: bean name to bean instance. */
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
}
缓存名称 源码名称 作用
一级缓存 singletonObjects 单例池,缓存已经经历了完整的生命周期,已经初始化完成了bean对象
二级缓存 earlySingletonObjects 缓存早期的bean对象(生命周期还没走完)
三级缓存 singletonFactories 缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

一级缓存是解决不了循环依赖问题的,它的作用是限制Bean在beanFactory中只存在一份,即实现了singleton scope,解决不了循环依赖,如果想打破循环依赖,就需要一个中间人的参与,也就是二级缓存
2023-10-153536

可以理解为,创建A时,先把A的半成品放到了二级缓存中,然后A需要注入B,此时开始创建B,B的原始对象也放入一级缓存中,B需要注入A时,直接从二级缓存中取出半成品A,然后B就创建成功了,B的对象就会存入一级缓存中,此时由于B创建成功了,所以此时B也注入到A中,A也创建成功,一样存入一级缓存,再清除二级缓存的半成品

但是此时只能解决一般对象的循环依赖,如果有增强的对象(代理对象),还是不行,就要借助三级缓存

三级缓存的解决方案:
023-10-17215004


当然也有Spring框架无法解决的循环依赖:构造方法出现了循环依赖
fkfldasjll21

报错:Is there an unresolvable circular reference

解决

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

3.2 SpringMVC

视图阶段

核心:DispatcherServlet

  1. DispatcherServlet接收前端发来的请求.
  2. DispatcherServlet查询handler,处理器映射器HandlerMapping返回处理器执行链HandlerExecutionChain
  3. DispatcherServlet请求执行handler,处理器适配器HandlerAdaptor请求处理器Handler,然后处理器相应给HandlerAdaptor
  4. 处理器适配器返回ModelAndView给前端控制器
  5. DispatcherServlet再将逻辑视图交给视图解析器,由视图解析器将逻辑视图解析为真正的视图,然后返回给DispatcherServletView对象
  6. 视图展示,渲染视图
    2023-10-19100158

前后端分离阶段(接口开发,异步请求)

  1. DispatcherServlet接收前端发来的请求.
  2. DispatcherServlet查询handler,处理器映射器HandlerMapping返回处理器执行链HandlerExecutionChain
  3. DispatcherServlet请求执行handler,处理器适配器HandlerAdaptor请求处理器Handler
  4. 处理器Handler通过HttpMessageConverter来返回结果转换为JSON并响应
    2023-10-19100709

3.3 SpringBoot

2023-10-1901418

  1. SpringBoot项目中引导类上的@SpringBootApplication注解封装了以下三个注解
  • @SpringBootConfiguration:该注解与@Configuration注解作用相同,用来声明当前也是一个配置类
  • @ComponentScan:组件扫描,默认扫描当前引导类所在包及其子包
  • @EnableAutoConfiguration:SpringBoot实现自动化配置的核心注解
  1. 其中@EnableAutoConfiguration是实现自动化配置的核心注解,该注解通过@Import注解导入对应的配置选择器内部就是读取了该项目和该项目引用的jar包的classpath路径下的META-INF/spring.factories文件中所配置的类的全类名,在这些配置类中所定义的bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中
  2. 条件判断会像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean收入spring容器中使用

常见注解

  1. Spring常见注解
    023-10-19105923
  2. SpringMVC常见注解
    2023-10-19110135
  3. SpringBoot常见注解
    23-10-1910348

3.4 MyBatis

MyBatis的执行流程

023-10-19135635

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

延迟加载

什么叫延迟加载
faafdsfsde

延迟加载的原理

  1. 使用CGLIB创建目标对象的代理对象
  2. 当调用目标方法user.getOrderList()时,进入拦截器invoke方法,发现user.getOrderList()是null值,执行sql查询order列表
  3. 把order查询上来,然后调用user.setOrderList()赋值,接着完成user.getOrderList的调用

一级,二级缓存

一级缓存:基于PerpetualCache的HashMap本地缓存,其存储作用域为Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存
二级缓存:基于namespace和mapper的作用域起作用的,不是依赖于sqlsession,默认也是采用PerpetualCache,HashMap存储

注意事项

  1. 对于缓存数据更新机制,当某个作用域(一级缓存Session/二级缓存Namespace)的进行了新增,修改,删除操作后,默认该作用域下所有的select中的缓存将被clear
  2. 二级缓存需要缓存的数据实现Serializable接口
  3. 只有会话提交或者关闭以后,一级缓存中的数据才会转移到二级缓存中

4 集合篇

Java集合体系
2023-10-20091016

4.1 复杂度

复杂度表示O表示法,速记口诀:常对幂指阶

常见复杂度

023-10-203333

只要代码的执行时间不随着n的增大而增大,这样的代码复杂度都是O(1)

看下面这个代码
023-10-20094342

要明白复杂度分析就是代码的执行次数和数据规模n之间的关系
023-10-20094453

所以,这个时间复杂度就是O(log n)


空间复杂度了解即可

空间复杂度全称是渐进空间复杂度,表示算法占用的额外存储空间和数据规模之间的增长关系

一般都是O(1),O(n)

4.2 数组

数组结构

数组是一种用连续的内存空间存储相同类型数据的线性数据结构

数组如何获取其他元素的地址值-寻址公式

寻址公式:a[i] = baseAddress + i*dataTypeSize

  • baseAddress:数组的首地址
  • dataTypeSize:代表数组中元素类型的大小,int型的数据,占4个字节

23-10-20102747


那为什么数组索引从0开始呢?假如从1开始不行吗

如果从1开始,那么寻址公式还要进行减法,对于CPU来说就多了一条指令,性能降低


操作数组的时间复杂度(查找)

  1. 随机查询(根据索引查询):O(1)
    数组元素的的访问时通过下标来访问的,计算机通过数组的首地址和寻址公式能够很快速的找到想要访问的元素
  2. 未知索引查询
    • 遍历数组O(n)
    • 排序后进行二分查找O(logn)

操作数组的时间复杂度(插入,删除)

数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变得很低

如果在数组最后插入或删除数据,那么就是O(1),最坏的情况就是O(n),也就是在数组中插入,平均情况下的时间复杂度是O(n)

ArrayList源码分析

先看构造方法,有两个个带参的构造和一个无参构造

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

private static final long serialVersionUID = 8683452581122892189L;

/**
* Default initial capacity.
*/
private static final int DEFAULT_CAPACITY = 10;

/**
* Shared empty array instance used for empty instances.
*/
private static final Object[] EMPTY_ELEMENTDATA = {};

/**
* Shared empty array instance used for default sized empty instances. We
* distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
* first element is added.
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* will be expanded to DEFAULT_CAPACITY when the first element is added.
*/
transient Object[] elementData; // non-private to simplify nested class access

/**
* The size of the ArrayList (the number of elements it contains).
*
* @serial
*/
private int size;

/**
* Constructs an empty list with the specified initial capacity.
*
* @param initialCapacity the initial capacity of the list
* @throws IllegalArgumentException if the specified initial capacity
* is negative
*/
// 带初始化容量的构造函数
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);
}
}

/**
* Constructs an empty list with an initial capacity of ten.
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

/**
* Constructs a list containing the elements of the specified
* collection, in the order they are returned by the collection's
* iterator.
*
* @param c the collection whose elements are to be placed into this list
* @throws NullPointerException if the specified collection is null
*/
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}

看第一个有参构造方法,可以发现如果容量不合法(大于等于0)

1
2
List<Integer> list = new ArrayList<>(-1);
System.out.println(list);

20231020112308
而无参构造,默认创建的是空集合,DEFAULTCAPACITY_EMPTY_ELEMENTDATA就是一个空数组

第二个有参构造的作用是:将Collection对象转换成数组,然后将数组的地址赋给elementData


添加和扩容操作-第一次添加数据
2023-10-20114851

所以在第一次添加数据时,会将容量设为10

第2至第10次添加数据
-10-20115432

第11次添加数据-重点
3-10-20115958

总结就是,创建集合时,如果没有指定容量,那么创建时是一个空集合,长度为0,第一次添加数据时,会将容量设为10,再之后添加到第10条数据,都不会发生扩容,当添加第11个数据时,就会发生扩容

ArrayList面试题


ArrayList底层的实现原理:

  1. ArrayList底层是用动态数组实现的
  2. ArrayList初始化容量为0,当第一次添加数据时才会初始化容量为10
  3. ArrayList在进行扩容时是原来容量的1.5倍,每次扩容都是拷贝数组
  4. ArrayList在添加数据时
    • 确保数组已使用长度(size)加1之后足够存下下一个数据
    • 计算数组的容量,如果当前数组已使用长度+1后大于当前数组长度,则调用grow方法扩容(原来的1.5倍)
    • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上
    • 返回添加成功布尔值

List<Integer> list = new ArrayList<>(10);中的list扩容了几次
该语句只是声明和实例了一个ArrayList,指定了容量为10,未扩容


如何实现数组和List之间的转换

  1. 数组转List,使用Arrays.asList()即可
  2. List转数组,使用List的toArray(),无参方法返回一个Object类型的数组,传入初始化长度的数组对象,返回该对象数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void testConvert() {
// 数组转集合
String[] strArr = new String[]{"a", "b", "c", "d"};
List<String> list = Arrays.asList(strArr);
for (String s : list) {
System.out.println(s);
}

// 集合转数组
List<Integer> list1 = new ArrayList<>();
list1.add(1);
list1.add(2);
list1.add(3);
Integer[] array = list1.toArray(new Integer[list1.size()]);
for (Integer i : array) {
System.out.println(i);
}
}

再问

  1. 用Arrays.asList转List后,如果修改了数组内容,list受影响吗
  2. List用toArray转数组后,如果修改了List内容,数组受影响吗
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
@Test
public void testConvert() {
// 数组转集合
String[] strArr = new String[]{"a", "b", "c", "d"};
List<String> list = Arrays.asList(strArr);
// list.remove(0); 转换过来的集合是只读的,可以修改单个元素,但是不能添加和删除元素
// 修改原数组,会发现集合中的数据也发生了变化 --- 数组转集合,是涉及到对象的引用,两个对象指向同一个地址
strArr[0] = "xyz";
for (String s : list) {
System.out.println(s);
}

// 集合转数组
List<Integer> list1 = new ArrayList<>();
list1.add(1);
list1.add(2);
list1.add(3);
Integer[] array = list1.toArray(new Integer[list1.size()]);
// 修改原集合 数组不受影响
list1.remove(0);
// 修改数组 集合也不受影响 -- 集合转数组,转换之后两者就没关系了,底层是数组的拷贝
array[2] = 30;

for (Integer i : list1) {
System.out.println(i);
}
System.out.println("====");
for (Integer i : array) {
System.out.println(i);
}
}

4.3 链表

链表结构

单向链表
23-10-2072823

单向链表的查询复杂度

  1. 只有在查询头节点的时候不需要遍历链表,时间复杂度是O(1)
  2. 查询其他结点时需要遍历链表,时间复杂度是O(n)

插入/删除复杂度

  1. 只有在添加和删除头节点时不需要遍历链表,时间复杂度是O(1)
  2. 添加或删除其他节点需要遍历链表找到对应节点后,才能完成新增或删除节点,时间复杂度是O(n)

双向链表
23-10-20173741

  • 每个节点不止有一个后继指针next指向后面的结点
  • 有一个前去指针prev指向前面的结点

对比单链表的区别

  • 双向链表需要额外的一个空间来存储前驱结点的地址
  • 支持双向遍历,这样也带来了双向链表的灵活性

双向链表的复杂度分析

  1. 查询复杂度
    • 查询头尾结点的时间复杂度是O(1)
    • 平均的查询时间复杂度是O(n)
    • 给定节点找前驱结点的时间复杂度O(1)
  2. 增删操作
    • 头尾结点增删的时间复杂度为O(1)
    • 其他部分结点增删的时间复杂度是O(n)
    • 给定结点增删的时间复杂度为O(1)

ArrayList与LinkedList的区别是什么

  1. 底层数据结构
    • ArrayList底层是动态数组
    • LinkedList是双向链表
  2. 操作数据的效率
    • ArrayList按照下标查询的时间复杂度是O(1),而LinkedList不能这么查询
    • 未知索引查询,两个都需要遍历,都是O(n)
    • 新增和删除
      • ArrayList尾部插入和删除,时间复杂度是O(1),其他部分增删需要挪动数组,时间复杂度是O(n)
      • LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
  3. 内存空间占用
    • ArrayList底层是数组,内存连续,节省内存
    • LinkedList是双向链表需要存储数据,和两个指针,更占用内存
  4. 线程安全
    • 都不是线程安全的

那既然两个都不是线程安全的,那如何保证线程安全?

  • 在方法内使用,局部变量则是线程安全的
  • 使用List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());来包装成一个线程安全的集合

4.4 二叉树

二叉搜索树

二叉搜索树(BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型,二叉查找树要求,在树种的任意一个节点,其左子树中的每个结点的值,都要小于这个节点的值,而右子树结点的值都大于这个节点的值

二叉搜索树
023-10-2082832

查找,插入,删除的时间复杂度都是O(logn)

红黑树

自平衡的二叉搜索树

特性如下:

  1. 节点要么是红色,要么是黑色
  2. 根节点是黑色
  3. 叶子节点都是黑色的空节点
  4. 红黑树中红色节点的子节点都是黑色
  5. 从任一同深度节点到叶子节点的所有路径都包含相同数目的黑色节点
    2023-10-2083833

在添加或删除节点时,如果不符合这些性质则会发生旋转,以达到所有的性质,来保证平衡


红黑树的复杂度

  • 查找
    红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为O(logn)
  • 添加
    添加先要从根节点开始找到元素添加的位置,时间复杂度为O(logn),添加完成后涉及到复杂度为O(1)的旋转调整操作,故整体复杂度为O(logn)
  • 删除
    首先从根节点开始找到被删除元素的位置,时间复杂度为O(logn),删除完成后涉及到复杂度为O(1)的旋转调整操作,故整体复杂度为O(logn)

4.4 散列表

在HashMap中最重要的一个数据结构就是散列表,在散列表中又使用到了红黑树和链表

散列表(HashTable)又名为哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性

将键(Key)映射为数组下标的函数叫做散列函数,可以表示为hashValue = hash(key)

散列函数的基本要求

  1. 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标
  2. 如果key1==key2,那么经过hash后得到的哈希值也比相同hash(key1) == hash(key2)
  3. 如果key1!=key2,那么经过hash后得到的哈希值也必不相同即:hash(key1)!=hash(key2)

散列冲突
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)

要解决散列冲突,有以下方法

4.5 链表法

链表法(拉链)解决散列冲突

在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中
23-10-2011816

插入操作时,通过散列函数计算得到对应的散列槽位,将其插入对应链表中即可,插入的时间复杂度为O(1)

查找,删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除

  • 平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)
  • 散列表可能会退化为链表,查询的时间复杂度就从O(1)退化为O(n)
    023-10-20212737
  • 将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是O(logn)

4.6 HashMap面试题

HashMap的实现原理
HashMap的数据结构:底层使用hash表数据结构,即数组和链表或红黑树

  1. 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
  2. 存储时,如果出现hash值相同的key,此时有两种情况
    • 如果key相同,则覆盖原始值
    • 如果key不同(出现冲突),则将当前key-value放入链表或红黑树
  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值
    2023-10-20214201

链表的长度大于8且数组长度大于等于64,转换为红黑树


HashMap的jdk1.7和jdk1.8有什么区别

  • JDK1.8之前采用的是拉链法,将链表和数组相结合,也就是说创建一个链表数组,数组中每一格就是一个链表,若遇到哈希冲突,则将冲突的值加到链表中即可
  • JDK1.8在解决哈希冲突时,当链表长度大于阈值(8)时并且数组长度到达64,将链表转化为红黑树,以减少搜索时间,扩容resize()时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表

HashMap的put方法的具体流程

初始化一个HashMap

  • HashMap是懒惰加载,在创建对象时并没有初始化数组
  • 在无参的构造方法中,设置了默认的加载因子为0.75f

看看源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建一个HashMap
Map<String, String> map = new HashMap<>();
map.put("name", "zzmr");

// 源码
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 而这个DEFAULT_LOAD_FACTOR 就是上面声明的加载因子
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

添加的具体流程
2023-10-2090844

  1. 判断键值对数组table是否为空或者null,否则执行resize()进行扩容(初始化)
  2. 根据键值key计算hash值得到数组索引
  3. 判断table[i]==null,条件成立,直接新建节点添加
  4. 如果table[i]==null不成立
    • 判断table[i]的首个元素是否和key一样,如果相同则直接覆盖value
    • 判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对
    • 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,遍历过程中若发现key已存在则直接覆盖value
  5. 插入成功后,判断实际存在的键值对数量size是否超过了最大容量threshold(数组长度*0.75),如果超过,进行扩容

扩容的具体流程
2023-10-21095051

  1. 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每个每次扩容都是达到了扩容阈值(数组长度*0.75)
  2. 每次扩容的时候,都是扩容之前容量的2倍
  3. 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
    • 没有hash冲突的节点,则直接使用e.hash & (newCap -1)计算新数组的索引位置
    • 如果是红黑树,走红黑树的添加
    • 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

put源码

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
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;
}

get源码

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
public V get(Object key) {
Node<K,V> e;
//hash(key),获取key的hash值
//调用getNode方法,见下面方法
return (e = getNode(hash(key), key)) == null ? null : e.value;
}


final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//找到key对应的桶下标,赋值给first节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判断hash值和key是否相等,如果是,则直接返回,桶中只有一个数据(大部分的情况)
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;

if ((e = first.next) != null) {
//该节点是红黑树,则需要通过红黑树查找数据
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);

//链表的情况,则需要遍历链表查找数据
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

其他面试题

HashMap的寻址算法

  1. 计算对象的hashCode()
  2. 再进行调用hash()方法进行二次哈希,hashcode值右移16位再异或运算,让哈希分布更为均匀
  3. 最后(capacity - 1)&hash得到索引(capacity是数组长度)

为何hashMap的数组长度一定是2的次幂

  1. 计算索引时效率更高,如果是2的n次幂可以使用位运算代替取模
  2. 扩容时重新计算索引效率更高:hash&odlCap == 0的元素留在原来位置,否则新位置=旧位置+oldCap

5 并发编程篇

5.1 线程的基础知识

线程和进程的区别

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

并行和并发有什么区别

并发,同一时间段内做多件事情-一个CPU轮流执行多个线程
并行,同一时刻做多件事情-4核CPU同时执行4个线程


创建线程的方式有哪些

  • 继承Thread类
  • 实现Runnable接口
  • 实现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
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
public class TestThread {

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

// 继承Thread类
MyThread myThread = new MyThread();
myThread.start();

// 实现Runnable接口
MyThread1 myThread1 = new MyThread1();
Thread t1 = new Thread(myThread1);
t1.start();

// 实现Callable接口
MyCallable myCallable = new MyCallable();
FutureTask<String> ft = new FutureTask<>(myCallable);
Thread ct1 = new Thread(ft);
Thread ct2 = new Thread(ft);
ct1.start();
ct2.start();
String result = ft.get();
System.out.println(result);

// 使用线程池
ExecutorService threadPool = Executors.newFixedThreadPool(3);
threadPool.submit(new MyThread1());

threadPool.shutdown();
}

}

// 继承Thread类
class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread");
}
}

// 实现Runnable接口
class MyThread1 implements Runnable {

@Override
public void run() {
System.out.println("MyThread1");
}
}

// 实现Callable接口
class MyCallable implements Callable<String> {

@Override
public String call() throws Exception {
System.out.println(Thread.currentThread().getName());
return "ok";
}
}

runnable和callable有什么区别

  1. Runnable接口run方法没有返回值
  2. Callable接口call方法有返回值,是个泛型,和Future,FutureTask配合可以用来获取异步执行的结果
  3. Callable接口的call方法允许抛出异常,而Runnable接口的run方法的异常只能在内部消化,不能继续上抛

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


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

使用join,t2中调用t1.join(),然后t3中调用t2.join()


notify和notifyAll有什么区别

  • wait():线程一旦执行此方法,就进入等待状态,同时,会释放对同步监视器的调用
  • notify()一旦执行,就会唤醒被wait()的线程中优先级最高的那个,如果多个线程的优先级相同,则随机唤醒一个,被唤醒的线程从wait的位置继续执行
  • notifyAll(),一旦执行此方法,就会唤醒所有被wait的方法

wait和sleep方法有什么区别

参考这里

共同点

wait,sleep的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态

不同点

  1. 方法归属不同
    • sleep是Thread的静态方法
    • wait是Object的成员方法,每个对象都有
  2. 醒来时机不同
    • 执行sleep和wait(long)的线程都会在等待相应毫秒后醒来
    • wait可以被notify唤醒
    • 它们都可以被打断唤醒
  3. 锁特征不同(重点)
    • wait方法的调用必须先获取wait对象的锁,而sleep则无此限制
    • wait方法执行后会释放对象锁,允许其他线程获得该对象锁
    • sleep如果在synchronized代码块中执行,并不会释放对象锁

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

  1. 使用退出标志,使线程正常退出,也就是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
    public class MyThread implements Runnable {

    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 myThread = new MyThread();
    Thread t1 = new Thread(myThread);

    t1.start();

    Thread.sleep(6000);
    myThread.flag = true;
    }
    }
  2. 使用stop方法强行停止(不推荐,已废弃)
  3. 使用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 MyThread2 {

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

      // 打断阻塞的线程
      /*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());*/


      // 打断正常的线程
      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();

      }
      }

5.2 线程中并发安全

synchronized关键字的底层原理

用法参考这里

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

底层原理-Monitor,监视器,由JVM提供,C++语言实现

023-10-2712324


synchronized关键字的底层原理-进阶

Monitor实现的锁属于重量级锁,你了解过锁升级吗?

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

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

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

偏向锁
轻量级锁在没有竞争时(就自己一个线程),每次冲入仍然需要执行CAS操作,Java6中引入了偏向锁来做进一步优化,只有第一次使用CAS将线程ID设置到对象的MarkWord头,之后发现这个线程id是自己的就表示没有竞争,不用重新CAS,以后只要不发生竞争,这个对象就归线程所有

接上面的问题:Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Java中的synchronized有偏向锁,轻量级锁,重量级锁三种形式,分别对应了锁只被一个线程持有,不同线程交替持有锁,多线程竞争锁三种情况

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

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

JMM

JMM(Java内存模型)

  • 定义了共享内存多线程程序读写操作的行为规范,通过这些规则里规范对内存的读写操作从而保证指令的正确性
  • JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享内存
  • 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存

2023-11-1155226

CAS

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

涉及到自旋

一个线程拿到了一个共享数据a,想修改a,修改完a之后,想把修改后的a同步到主内存中,那么这时会先拿原来的a值与共享内存中的a进行对比,如果相同(表示a并没有被修改),此时进行更新操作

而自旋,就是重新发现数据被修改后,重新获取新的数据,进行重复的修改操作.

优势

  • 没有加锁,所以线程不会陷入阻塞,效率较高
  • 如果竞争激烈,重试频繁繁盛,效率会受影响

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

1
2
3
4
5
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

乐观锁和悲观锁

  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,重试就行
  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量

volatile关键字

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

  • 保证线程间得可见性
    用volatile修饰得共享变量,能够防止编译器等优化发生,让一个线程对共享变量得修改对另一个线程可见
    2023-11-113645
  • 禁止进行指令重排序
    用volatile修饰得共享变量会在读,写共享变量时加入不同得屏障,阻止其他读写操作越过屏障,从而达到阻止重排序得效果

AQS

全称Abstract Queued Synchronizer,即抽象队列同步器,它是构建锁或者其他同步组件得基础框架

AQS与Synchronized的区别

synchronized AQS
关键字,c++语言实现 java语言实现
悲观锁,自动释放锁 悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供了多种解决方案
  • AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程
  • 在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0,如果队列中有一个线程修改成功了state为1,则当前线程就相当于获取了资源
  • 在对state修改的时候使用cas操作,保证多个线程修改的情况下的原子性

2023-11-11165809

ReentrantLock的实现原理

ReentrantLock可重入锁,相对于synchronized具备以下特点

  • 可中断
  • 可以设置超时时间
  • 可以设置公平锁
  • 支持多个条件变量
  • synchronized一样,都支持重入
1
2
3
4
5
6
7
8
9
// 创建锁对象
ReentrantLock lock = new ReentrantLock();
try{
// 获取锁
lock.lock();
}finally{
// 释放锁
lock.unlock();
}

ReentrantLock主要利用CAS+AQS来实现,它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁,公平锁的效率没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量

2023-11-11170707

lock

synchronized与lock的区别

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

死锁产生条件

相关文章

很简单例子就是:线程1持有A的锁等待获取B锁,线程2持有B锁等待获取A锁

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

2023-11-12093442

ConcurrentHashMap

出现频率很高

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

底层数据结构

  • JDK1.7底层采用分段的数组+链表实现
  • JDK1.8采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑树

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

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

2023-11-1294632

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

(Java程序中怎么保证多线程的执行安全)

Java并发编程三大特征

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

5.3 线程池

线程池的核心参数

线程池的执行原理

线程池类的一个构造方法

1
2
3
4
5
6
7
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
  • corePoolSize核心线程数目
  • maximumPoolSize最大线程数目=(核心线程+就急线程的最大数目)
  • keepAliveTime生存时间-救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
  • unit时间单位-救急线程的生存时间的单位,如秒,毫秒等
  • workQueue当没有空闲核心线程时,新来任务会加入到此队列排队,队列满时会创建救急线程执行任务
  • threadFactory线程工厂-可以定制线程对象的创建,例如设置线程名字,是否是守护线程等
  • handler拒绝策略-当所有线程都在忙时,workQueue也放满时,会触发决绝策略

线程池的执行原理
2023-11-1201038

阻塞队列

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

  • ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO
  • LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO
  • DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
  • SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作

2023-11-1201656

如何确定核心线程数

  • IO密集型任务
    一般来说,文件读写,DB读写,网络请求等:核心线程数大小设置为2N+1
  • CPU密集型任务
    一般来说,计算型代码,Bitmap转换,Gson转换等:核心线程数大小设置为N+1

查看CPU的逻辑处理器数量

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

线程池的种类

  1. 创建使用固定线程数的线程池
    • 核心线程数与最大线程数一样,没有救急线程
    • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
      1
      2
      3
      4
      5
      public static ExecutorService newFixedThreadPool(int nThreads) {
      return new ThreadPoolExecutor(nThreads, nThreads,
      0L, TimeUnit.MILLISECONDS,
      new LinkedBlockingQueue<Runnable>());
      }
  2. 单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行
    • 核心线程数和最大线程数都是1
    • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
      1
      2
      3
      4
      5
      6
      public static ExecutorService newSingleThreadExecutor() {
      return new FinalizableDelegatedExecutorService
      (new ThreadPoolExecutor(1, 1,
      0L, TimeUnit.MILLISECONDS,
      new LinkedBlockingQueue<Runnable>()));
      }
  3. 可缓存线程池(适合任务数比较密集,但是每个任务执行时间较短的情况)
    • 核心线程数为0
    • 最大线程数是Integer.MAX_VALUE
    • 阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
      1
      2
      3
      4
      5
      public static ExecutorService newCachedThreadPool() {
      return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
      60L, TimeUnit.SECONDS,
      new SynchronousQueue<Runnable>());
      }
  4. 提供了延迟和周期执行功能的ThreadPoolExecutor
    2023-11-12104552

总结
023-11-124813


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

阿里开发者手册-嵩山版
2023-11-12104946

5.4 使用场景

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

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

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

还有一个就是异步调用

为了避免下一级方法影响上一级方法(性能考虑),可以使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升响应速度


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

Semaphore信号量,是JUC包下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量

使用场景:通常用于哪些资源有明确访问数量限制的场景,常用于限流

5.5 ThreadLocal

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

(可以参考苍穹外卖项目中的ThreadLocal用法)


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

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

ThreadLocal中的内存泄漏问题
Java对象的四种引用类型,强引用,软引用,弱引用,虚引用

  • 强引用:最普通的引用方式,表示一个对象处于有用且必须的装填,如果一个对象具有强引用,则GC并不会回收它,即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
    User user = new User()
  • 弱引用:表示一个对象处于可能有用且非必须的状态,在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象,对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收
    1
    2
    User user = new User()
    WeakReference weakReference = new WeakReference(user);

每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference,其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本

就是ThreadLocalMap中的key是弱引用,值是强引用,key会被GC释放内存,关联value的内存并不会被释放

防止内存泄漏,要及时使用remove方法

6 JVM篇

6.1 JVM组成

程序计数器

程序计数器(PC Register)
线程私有的,内部保存的字节码的行号,用于记录正在执行的字节码指令的地址

PC寄存器用来存储指向下一条指令的地址,也就是要执行的指令代码,由执行引擎读取下一条指令

这个里面内容挺少的?

具体可看这里

线程共享的区域,主要用来存储对象实例,数组等,当堆中没有内存空间分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常

具体可看这里

虚拟机栈

  • 每个线程运行时所需要的内存,成为虚拟机栈,先进后出
  • 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

垃圾回收是否设计栈内存
垃圾回收主要指的就是堆内存,当栈帧出栈以后,内存就会释放

栈内存分配越大越好吗
未必,默认的栈内存通常为1024KB,栈帧过大会导致线程数变少,例如,及其总内存为512M,目前能活动的线程数则为512个,如果把栈内存改为2048K,那么能活动的栈帧就会减半

方法内的局部变量是否线程安全的

  • 如果方法内部局部变量没有逃离方法的作用范围,它是线程安全的
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

栈内存溢出情况

  • 栈帧过多导致栈内存溢出,比如递归调用
  • 栈帧过大导致栈内存溢出

堆和栈的区别是什么

  • 栈内存一般会用来存储局部变量表和方法调用,但堆内存是用来存储Java对象和数组的,堆会GC垃圾回收,而栈不会
  • 栈内存是线程私有的,而堆内存是线程共有的

方法区

  • 方法区是各个线程共享的内存区域
  • 主要存储类的信息,运行时常量池
  • 虚拟机启动的时候创建,关闭虚拟机时释放
  • 如果方法区域中的内存无法满足分配请求,则会抛出OOM:Metaspace

常量池
可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量等信息

而运行时常量池,是在类被加载后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

直接内存

直接内存:并不属于JVM中的内存结构,不由JVM进行管理,系统内存,常见于NIO操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高

6.2 类加载器

主要作用:用于装载字节码文件(.class文件)

JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来

具体可看这里

双亲委派机制

把加载类的请求交给上一级处理

通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性

为了安全,保证类库API不会被修改

见这里

类装载的执行过程

类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载,验证,准备,解析,初始化,使用和卸载这7个阶段,其中,验证,准备,和解析这三个部分统称为连接(linking)

  1. 加载
    通过类的全类名,获取类的二进制数据流
    解析类的二进制数据流为方法区内的数据结构(Java类模型)
    创建java.lang.Class类的实例,表示该类型,作为方法区这个类的各种数据的访问入口
  2. 验证
    验证类是否符合JVM规范,安全性检查(格式检查,符号引用验证,常量池中的类/方法是否存在)
  3. 准备
    为类变量分配内存并设置类变量初始值
    static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
    static变量是final的基本类型,以及字符串常量,值已确定,赋值在准备阶段完成
    static变量是final的引用类型,那么赋值也会在初始化阶段完成
  4. 解析
    把类中的符号引用转换为直接引用
    比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法
  5. 初始化
    对类的静态变量,静态代码块执行初始化操作
    如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类
    如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行
  6. 使用
    JVM从入口方法开始执行用户的程序代码
  • 调用静态类成员信息(比如静态字段,静态方法)
  • 使用new关键字为其创建对象实例
  1. 卸载

6.3 垃圾回收

对象什么时候可以被垃圾回收器回收

如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收

引用计数法
一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收,但是如果对象间出现了循环引用的话,则引用计数法就会失效

还有一种就是可达性分析算法
现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾

  • Java虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,找不到,表示可以回收

JVM垃圾回收算法有哪些

  • 标记清除算法
    • 根据可达性分析算法得出的垃圾进行标记
    • 对这些标记为可回收的内容进行垃圾回收
    • 优点是标记和清除速度较快,缺点是碎片化较为严重,内存不连贯
  • 标记整理算法(老年代经常使用)
    • 标记清除之后,会对内存进行整理
    • 对象移动内存位置,其效率有一定影响
  • 复制算法(年轻代经常使用)
    • 优点就是垃圾较多时,效率较高
    • 清理之后,内存无碎片
    • 缺点就是内存使用率较低

分代回收

分代,主要是说新生代和老年代
20231112222814

看这里


JVM中的垃圾回收期

  • 串行垃圾收集器
  • 并行垃圾收集器
  • CMS(并发)垃圾收集器
  • G1垃圾收集器

串行垃圾收集器
Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑

  • Serial作用于新生代,采用复制算法
  • Serial Old作用于老年代,采用标记-整理算法
    垃圾回收时,只有一个线程在工作,并且Java应用中的所有线程都要暂停(STW),等待垃圾回收的完成

并行垃圾收集器
Parallel New和Parallel Old是一个并行垃圾回收器,JDK默认使用此垃圾回收器

  • Parallel New作用于新生代,采用复制算法
  • Parallel Old作用于老年代,采用标记-整理算法
    垃圾回收时,多个线程在工作,并且Java应用中的所有线程都要暂停(STW),等待垃圾回收的完成

CMS(并发)垃圾收集器
CMS全称Concurrent Mark Sweep,是一款并发的,使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验好,其最大特点是在进行垃圾回收时,应用仍然能正常运行


G1垃圾收集器

  • 应用于新生代和老年代,在JDK9之后默认使用G1
  • 划分为多个区域,每个区域都可以充当eden,survivor,old,humongous,其中humongous转为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段,新生代回收(STW),并发标记(重新标记STW),混合收集
  • 如果并发失败(即回收速度赶不上创建新对象速度),会触发Full GC

强引用,软引用,弱引用,虚引用的区别

  • 强引用:只有当所有GC Roots对象都不能通过强引用引用该对象,该对象才能被垃圾回收
    1
    User user = new User();
    20231113100017
  • 软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时才会再次触发垃圾回收
    1
    2
    User user = new User();
    SoftReference softReference = new SoftReference(user);
    20231113100200
  • 弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
    1
    2
    User user = new User()
    WeakReference weakReference = new WeakReference(user);
    20231113100342
  • 虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存
    1
    2
    3
    User user = new User();
    ReferenceQueue referenceQueue = new ReferenceQueue();
    PhantomReference phantomReference = new PhantomReference(user.queue);

6.4 JVM实践

JVM调优的参数可以在哪里设置

  1. war包形式,在tomcat中设置,修改TOMCAT_HOME/bin/catalina.sh文件
    2023-11-1300955
  2. jar包形式,通常在linux系统下直接加参数启动springboot项目
    2023-11-1301132

JVM调优的参数都有哪些
对于JVM调优,主要就是调整年轻代,老年代,元空间的内存空间大小及使用的垃圾回收器类型
参考这里

关于虚拟机栈也可以调整,每个线程默认会开启1MB的内存,用于存放栈帧,调用参数,局部变量等,但一般256K就足够,通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统
-Xss 对每个线程stack大小的调整-Xss128k

设置垃圾回收器,通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收器

1
2
3
4
-XX:+UseParallelGC
-XX:+UseParalleOldGC
# 使用G1GC
-XX:+UseG1GC

JVM常用的命令工具
2023-11-102050


Java内存泄漏的排查思路
运行时数据区中,有3块可能发生内存泄漏

  1. 虚拟机栈
  2. 方法区/元空间

排查方案

  1. 获取堆内存快照dump
  2. VisualVM去分析dump文件
  3. 通过查看堆信息的情况,定位内存溢出的问题

CPU飙高排查方案与思路
首先使用top命令查看具体的进程id
然后使用ps H -eo pid,tid,%cpu | grep 2266
就可以查询到该进程对应的所有线程占用情况

再使用jstack 2266,再找到线程id(注意,2266是10进制,jstack中是十六进制)

就可以找到问题所在了