写在前面
节选自:
黑马程序员:新版Java面试专题视频教程:https://www.bilibili.com/video/BV1yT411H7YK?p=1
javaguide:https://javaguide.cn/home.html
集合
ConcurrentHashMap
JDK1.7:分段数组+链表,用ReentrantLock锁的是HashEntry数组,粒度大(锁住一段数组)
JDK1.8:和HashMap一致,数组+链表/红黑树,用CAS+Sync锁保证线程安全。空节点就用CAS,链表/红黑树就用Sync锁住首节点,粒度小,并发高。
Redis
缓存穿透
查询不存在的数据,也没写入缓存,导致每次查询控制都直接访问数据库
1、缓存空值;
2、布隆过滤器:用多个hash code为1标识值存在,查询时都为1,表示有可能存在,其中一个为0,则一定不存在。
缓存击穿
缓存过期时,刚好有大量访问该key,都会访问数据库
1、缓存未命中,多个线程争抢互斥锁,申请到的才访问DB并加入缓存,否则睡眠后重新尝试获取缓存和锁
2、不设置redis TTL,而是在value写入逻辑过期时间,手动判断过期。如果过期,则获取锁,返回旧值,由子线程查询DB并更新缓存。若获取锁失败,也返回旧值。(其实着中国逻辑,可以理解为缓存是一直存在的)
缓存雪崩
大量key同时失效;redis宕机
1、设置key的TTL时添加随机值
2、使用集群提高redis可用性
3、给缓存业务添加降级限流策略(nginx、Spring Cloud Gateway)(对多种问题都适用)
4、给业务添加多级缓存(Guava、Caffeine)
双写一致性
先介绍业务,根据业务选择实际策略:强一致?最终一致?
1、先更新数据库再删除缓存:因为删除缓存的速度远快于DB,因此更新完DB后再删除缓存时被中断的可能性小。而先删除缓存再更新数据库,有可能在删除完缓存,在等待DB的时候被中断,导致其他线程用旧的值重新赋上
2、延时双删:删缓存 - 修改数据库 - 子线程延时删缓存。延时需要提供时间给数据库做主从同步,且预留时间给其他读线程把旧值写入缓存,再删除旧值。会有一段时间脏数据,时间难控制。
3、强一致:读写都加锁。读用共享锁,写用排他锁。
4、最终一致(略延迟):1、修改数据库 - MQ - 更新缓存;2、Canal:修改数据库 - Canal监听binlog - 更新缓存,基于mysql主从实现
持久化
1、RDB(Redis Database Backup file),所有数据都记录到磁盘,fork子进程共享主进程的内存数据库(页表),写入RDB文件
进程都没办法直接操作物理内存,只能操作虚拟内存,并通过页表进行映射。因此子进程只需要拷贝页表即可共享主进程内存,fork速度快,再写入新的RDB文件替换旧文件。
在备份过程中如果有新的写入,那主进程会备份原始数据,对备份数据进行读写。
2、AOF(Append Only File),记录所有写命令,默认关闭。AOF文件会比RDB大得多。
AOF重写:多次写的命令,只保留最后一次生效的命令
数据过期删除策略
1、惰性删除:用到时判断过期才删除,对CPU友好,但可能占用内存
2、定期删除:每隔一段时间检测过期并删除
通常配合使用。如果中间因为策略遗漏了过期key的删除,还有数据淘汰机制兜底。
数据淘汰
缓存过多,内存被占满
默认noevictrion,不删除任何数据,直接报错
还有对allkeys、volatile维度的ttl、random、lru、lfu的删除策略。
allkeys是对全体keys的策略,
volatile是只对设置了TTL的keys的策略,便于保留置顶数据。
ttl是TTL小的先淘汰,
random是随机,
lru Least Recently Used,最近访问时间越久的先淘汰,
lfu Least Frequently Used,最近访问频率低的先淘汰
分布式锁
结合业务:定时任务、抢单、幂等性
Redis的setnx命令,SET if not exist,根据返回值判断设置成功/失败,但控制锁的时长不好控制。太短可能提前释放,太长可能客户端宕机,没有手动释放,要等一段时间。
可以用redisson的看门狗机制,只要不显式设置过期时间,就会触发该机制,后台还是会设置过期时间,并且开启看门狗线程,定期的续期该分布式锁。都是基于lua脚本完成的,可以保证原子性。
redisson分布式锁可重入,在背后做了记录,key是锁的key,field是线程名,value是重入数
主从数据的锁一致问题
Redis主节点获取锁,还没同步给slave就挂了。
发生概率低,可以用红锁,即在多个Redis实例(n / 2 + 1)上加锁。但性能低,官方也不推荐。
AP思想:高可用redis,CP思想:强一致用zookeeper
集群方案
主从复制:高并发
读写分离,写master,读slave,一般一主多从,无法保证高可用
数据同步
通过replication id判断是同一个数据集,不一致则RDB全量同步,同时记录下同步期间的命令至repl_baklog,随后也进行同步。
否则增量同步,根据offset去获取repl_baklog中之后的命令数据。
哨兵模式:Sentinel,高可用
Sentinel集群持续监控主备设备的状态,若发现故障,会提升slave为master并通过Redis客户端。其中 通过ping命令监控。
主观下线:一个Sentinel认为master下线
客观下线:超过一定数量(quorum)的Sentinel认为该实例主观下线,则该实例客观下线。quorum最好超过Sentinel实例数量一半。
选主规则:
从前往后的顺序判断
1、主从断开时间超过阈值的淘汰,证明丢失数据过多;
2、选优先级高的;
3、选offset值高的;
4、选index大的(实例序号而已,无实际意义)
脑裂
master和sentinel连接出现问题,但和Redis客户端连接正常,sentinel会在slave中重新选主。那么在客户端连接新的master之前,成功发给旧master的信息都会被吞掉,旧master恢复连接变为slave后,会被新master同步并刷为旧数据。
解决:
1、配置最少slave数为1,否则不让写
2、降低主从节点的同步间隔,减少数据丢失,并尽快识别出没有slave节点
分片集群:海量数据存储、高并发写
多个master,每个master都有slave,master之间通过ping检测健康,客户端能访问任意一台master,都会被转发到正确的master上。
每个master分配一定区间的哈希槽,根据请求计算hash,转发到对应master上。如果请求提供了有效部分,用有效部分计算hash,否则用key计算。
华为云的Redis的主备模式,实际上是使用哨兵模式来管理,只是客户不感知哨兵存在。
为什么单线程这么快
1、纯内存操作;
2、单线程避免了上下文切换、线程安全问题;
3、使用IO多路复用模型,非阻塞IO;
4、内置了多种优化后的数据结构
因此性能瓶颈是网络IO而非运行速度,IO多路复用模型就是为了解决这个瓶颈问题的。
linux内存分为用户空间和内核空间,用户进程要访问硬件设备(网卡)时,需要:用户缓冲区 - 内核缓冲区 - 硬件设备 的交互,反之亦然。中间的效率问题涉及到:
1、等待数据就绪;
2、数据拷贝
三种IO方式:
1、阻塞IO:阻塞等待数据就绪,阻塞等待数据拷贝完成
2、非阻塞IO:非阻塞等待数据,但会不断询问是否就绪,导致CPU空转;阻塞等待数据拷贝完成
3、IO多路复用:一次性获取所有数据已就绪的socket列表,单线程循环地阻塞拷贝已就绪的数据。
监听socket、获取通知的方式:
1、select;2、poll;3、epoll
前两种只会通知用户进程有socket就绪,需要用户进程遍历socket列表,确认哪个就绪。epoll会在通知socket就绪的同时,就把已就绪的socket写入用户空间。
IO多路复用用的就是epoll
Redis网络模型
IO多路复用+事件派发。
每个socket会处理不同的请求,把准备就绪的请求派发给对应的处理器。
6.0之后引入多线程,因为瓶颈是网络IO,所以对涉及网络读写的请求、回复模块使用多线程解析,但具体操作命令还是单个主线程,线程安全。
mysql
慢查询
定位:
端到端定位:Arthas、Prometheus、Skywalking
mysql定位:开启慢查询日志记录(开关、时间阈值)
分析:
使用EXPLAIN或DESC获取SQL语句的执行计划
EXPLAIN可以查看索引使用情况、是否有回表查询、命令的查询类型(const、eq_ref…)等
索引
帮忙mysql高效索引数据的数据结构,有序的B+树。InnoDB存储引擎的索引结构就是B+树。
B+树
1、B+树更矮胖,故IO次数更少;
2、数据只存在于叶子节点,每次访问次数相近,查询性能更稳定;
3、叶子节点间有双相指针,范围查询更方便;
聚簇索引、非聚簇索引
聚簇索引:Clustered index。将数据存储和索引结构放到一起,索引结构的叶子节点存储了行数据。这种索引必须有,且只有一个。
二级索引、非聚簇索引:Secondary index。将数据和索引分开存储,叶子节点只存放主键,用于与数据关联。这种索引可以存在多个。
回表查询
在二级索引取到主键,再拿主键去聚簇索引取行数据。
覆盖索引
查询时使用了索引,并且需要的列,在该索引中能全部找到。不用回表查询。
并发编程
1、原子性
2、可见性
3、有序性(指令重排只会保证单个线程的最终一致性,不保证多线程)
JMM
Java Memory Model
JAVA自己提供的一套内存模型,能屏蔽操作系统的内存模型实现跨平台。
有主内存作为各线程的共享内存,每个线程有各自的工作内存,备份了共享变量副本,需要通过共享内存相互同步。
JAVA内存区域和JAVA内存模型的关系?
JAVA内存区域划分了数据的存储区域,例如堆存放对象实例;
JAVA内存模型和并发编程、跨平台相关,规范了线程和主内存间共享变量的使用规范,增强了程序的可移植性。
happens-before原则
为了平衡JMM下的并发问题和编译器、处理器的优化性能,只要不改变程序的执行结果(单线程和正确的多线程),不管怎么优化重排都行,否则禁止重排
CAS
Compare And Swap,在无锁下保证变量操作的原子性,JUC(Java.Util.Concurrent)内、AQS(AbstractQueuedSynchronizer)框架、AtomicXXX类都用到。
在修改共享变量时,对比共享内存的值和自身缓存的值是否一致,一致则合入共享内存,否则拷贝进来 重新操作再对比,直到一致为止,因此也叫自旋锁。
底层是依赖OS的CAS操作。
是乐观锁的思想。
可能有的问题:
1、ABA问题:仅对比值一致,不代表值没有被其他现场修改过,有可能被改为B又改回A了。
解决思路是:变量内加入版本号、时间戳等唯一标识符。AtomicStampedReference的compareAndSet就是检查引用一致和标志是否符合预期。
2、循环时间长:JVM支持暂停,延迟尝试。
volatile
表示这个共享变量是不稳定的
1、可见性:保证了可见性,(多线程的)共享变量被volatile修饰,修改后会立刻从线程内存写到主内存。否则修改后不确定何时写入。
2、可见性:避免了JIT对代码优化导致读共享变量失败,例如子线程while(flag)被优化为while(true)
3、有序性:volatile变量的写操作阻止上方其他写操作到下面,读操作组织下方其他操作到上面
使用场景:1、作为子线程的状态标识;2、单例模式的double check
锁
sync 重量级锁
本质是monitor,是重量级锁,底层依赖操作系统的Mutex Lock实现,OS实现线程间切换涉及到用户态和内核态的切换、线程上下文切换,因此性能较低。
javap -v xx.class查看字节码信息,用monitorenter和monitorexit框起来,其中monitorexit会有两次,是为了防止线程抛异常导致死锁。
对象锁,会关联Monitor结构,具体是用对象的mark word指向Monitor。
Monitor包含WaitSet、EntryList、Owner。WaitSet是线程等待,EntryList是线程阻塞,Owner是获得锁。Owner释放后,EntryList不是排队,会争抢Owner,非公平锁。
轻量级锁(自旋锁)
同步代码块不存在竞争、或者线程是交替执行该代码块,会用轻量级锁。
用CAS交换Lock Record开头和mark word,用完了再换回。
支持同线程重入,第二个线程起,Lock Record开头值为null,用于计算重入次数。
如果多线程冲突(CAS失败N次),就升级为重量级锁。
CAS自旋是cpu空转,但轻微的自旋空转,能换取用户态和内核态切换的开销。
偏向锁
只有一个线程会用到该对象锁。
只有第一次CAS时将线程ID放到mark word,重入时不用再CAS,只要判断现场ID是自己。
偏向锁 → 轻量级锁 → 重量级锁,是单向的升级过程,不会重新降级。
悲观锁和乐观锁
悲观锁:
用sync关键字或ReentrantLock类等锁住代码块。
通常用于写比较多的情况下(多写场景,竞争激烈),避免频繁失败和重试影响性能,且开销是固定的。
乐观锁:
提交修改时再验证是否被其他线程修改,可以用版本号、CAS等思想
AQS
AbstractQueuedSynchronizer,抽象队列同步器,是构建锁或者其他同步组件的基础框架。
实现有:
ReentrantLock 阻塞式锁、
Semaphore 信号量、
CountDownLatch 倒计时器
内部维护一个FIFO的队列 和 state状态量
线程用CAS的方式修改state,修改成功则获得锁,否则进入队列。
state释放后,可以实现公平锁,也可以实现非公平锁的争抢。
ReentrantLock
主要利用CAS+AQS实现,还支持了其他功能:超时释放、公平锁、多个条件变量等。
ThreadLocal
内存泄露
1、ThreadLocal是每个Thread线程内部维护了一个ThreadLocalMap的成员变量,Map中的每个元素是一个Entry,key-value形式,key是对ThreadLocal对象的弱引用,value是具体set和get的Obect。
弱引用是如果引用的对象只剩下弱引用,那么GC的时候就会把该对象回收,因此key被回收了,则无法通过key找到value,且Entry中对value是强引用,value将一直不会被回收。
会有这种情况,在new ThreadLocal的时候,并没有绑定到某个变量上,那么这个ThreadLocal(即key)则会只剩下Entry的弱引用。
2、如果线程池中有固定的核心线程数,线程使用了ThreadLocal存储对象,如果在线程任务结束时不手动remove对象,则对象会随着核心现场的存活一直存在。
因此:
1、ThreadLocal需要强引用绑定到变量,通常绑定到静态变量上;
2、用完ThreadLocal线程结束前,手动remove。
Map冲突处理
ThreadLocalMap和HashMap类似,通过hash code取模得到数组的下标idx。如果遇到冲突,HashMap是在该idx上使用链表或红黑树存储,而ThreadLocalMap是使用线性探测法找到合适的idx,直到遇到相同的key进行替换value,或者为空直接存入。