100道Java经典面试题
- (一)、准备篇
- 1.HR如何筛选简历?
- 2.部门负责人如何筛选简历?
- 3.简历模块布局
- 4.应届生如何找到合适的练手项目?
- 5.深入学习哪些业务模块呢?
- 6.Java程序员的面试过程
- (二)、Redis篇
- 1.redis经常使用在哪些场景?
- 2.Redis进行查询的流程是什么?
- 3.什么是缓存穿透 ? 怎么解决 ?
- 4.你能介绍一下布隆过滤器吗?
- 5.什么是缓存击穿 ? 怎么解决 ?
- 6.什么是缓存雪崩 ? 怎么解决 ?
- 7.redis做为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)
- 8.redis做为缓存,数据的持久化是怎么做的?
- 9.RDB与AOF有什么区别吗?
- 10.假如redis的key过期之后,会立即删除吗?
- 11. Redis的数据过期策略有哪些 ?
- 12.假如缓存过多,内存是有限的,内存被占满了怎么办?
- 13.Redis的数据淘汰策略有哪些 ?
- 14.Redis的数据淘汰策略使用建议
- 15.数据库有1000万数据 ,Redis只能缓存20w数据, 如何保证Redis中的数据都是热点数据 ?
- 16.Redis的内存用完了会发生什么?
- 17.为什么要使用Redis分布式锁?
- 18.Redis分布式锁如何实现 ?
- 19.如何控制Redis实现分布式锁有效时长呢?
- 20.redisson实现的分布式锁是可重入的吗?
- 21.redisson实现的分布式锁能解决主从一致性的问题吗?
- 22.Redis集群有哪些方案, 知道嘛 ?
- 23.Redis主从同步数据流程
- 24.什么是哨兵模式?
- 25.Redis集群脑裂,该怎么解决呢?
- 26.怎么保证Redis的高并发高可用?
- 27.你们使用Redis是单点还是集群,哪种集群
- 28.什么是Redis分片集群?
- 29.Redis分片集群中数据是怎么存储和读取的?
- 30.Redis是单线程的,但是为什么还那么快?
- 31.Linux的用户空间和内核空间
- 32.阻塞IO、非阻塞IO、多路复用IO
- 33.实现IO多路复用有哪些方式?
- 34.Redis的网络模型
- 33.能解释一下Redis的I/O多路复用模型?
- (三)、MySQL
- 1.MySQL中,如何定位慢查询?
- 2.那这个SQL语句执行很慢, 如何分析呢?
- 3.什么是索引?
- 4.索引的底层数据结构了解过嘛 ?
- 5.什么是聚簇索引什么是非聚簇索引 ?
- 6.知道什么是回表查询嘛 ?
- 7.知道什么叫覆盖索引嘛 ?
- 8. MySQL超大分页怎么处理 ?
- 9.索引创建原则有哪些?
- 10.什么情况下索引会失效?
- 11.谈谈你对SQL优化的经验?
- 12.创建表的时候,你们是如何优化的呢?
- 13.你平时对SQL语句做了哪些优化呢?
- 14.为什么要进行主从复制、读写分离?
- 15.事务的特性是什么?可以详细说一下吗?
- 16.并发事务带来哪些问题?
- 17.怎么解决这些问题呢?MySQL的默认隔离级别是?
- 18.undo log和redo log的区别
- 19.事务中的隔离性是如何保证的呢?(你解释一下MVCC)
- 20.什么是MVCC?
- 21.MySQL主从同步原理
- 22.你们项目用过MySQL的分库分表吗?
- (四)、框架篇
- 1.Spring框架中的单例bean是线程安全的吗?
- 2.什么是AOP?
- 3.Spring中事务失效的场景有哪些?
- 4.Spring的bean的生命周期
- 5.Spring中的循环引用
- 6.SpringMvc的执行流程
- 7.Springboot自动配置原理
- 8.Spring 的常见注解有哪些?
- 9. SpringMVC常见的注解有哪些?
- 10.Springboot常见注解有哪些?
- 11.MyBatis执行流程
- 12.Mybatis是否支持延迟加载?
- 13.延迟加载的底层原理知道吗?
- 13.MyBatis的一级缓存、二级缓存用过吗?
- 15.Mybatis的二级缓存什么时候会清理缓存中的数据
- (五)、微服务
- 1. Spring Cloud 5大组件有哪些?
- 2.服务注册和发现是什么意思?Spring Cloud 如何实现服务注册发现?
- 3. 我看你之前也用过nacos、你能说下nacos与eureka的区别?
- 4.你们项目负载均衡如何实现的 ?
- 5.Ribbon负载均衡策略有哪些 ?
- 6.如果想自定义负载均衡策略如何实现 ?
- 7.什么是服务雪崩,怎么解决这个问题?
- 8.你们的微服务是怎么监控的?
- 9.你们项目中有没有做过限流 ? 怎么做的 ?
- 10.限流常见的算法有哪些呢?
- 11.什么是CAP理论?
- 12.什么是BASE理论?
- 13.你们采用哪种分布式事务解决方案?
- 14.分布式服务的接口幂等性如何设计?
- 15.你们项目中使用了什么分布式任务调度
- 16.xxl-job路由策略有哪些?
- 17.xxl-job任务执行失败怎么解决?
- 18.如果有大数据量的任务同时都需要执行,怎么解决?
- (六)、集合
- 1.为什么要进行复杂度分析?
- 2.什么是时间复杂度?
- 3.什么是空间复杂度?
- 4.什么是数组?
- 5.数组如何获取其他元素的地址值?
- 6.为什么数组索引从0开始? 从1开始不行吗?
- 7.查找数组的时间复杂度
- 8.插入/删除数组的时间复杂度
- 9.ArrayList源码分析
- 10.添加和扩容操作(第1次添加数据)
- 11.ArrayList底层的实现原理是什么?
- 12.ArrayList list=new ArrayList(10)中的list扩容几次?
- 13.如何实现数组和List之间的转换?
- 14.用Arrays.asList转List后,如果修改了数组内容,list受影响吗?
- 15.List用toArray转数组后,如果修改了List内容,数组受影响吗?
- 16.什么是单向链表?
- 17.单向链表时间复杂度分析?
- 18.什么是 双向链表?
- 19.双向链表时间复杂度分析
- 20.ArrayList和LinkedList的区别是什么?
- 21.什么是二叉树?
- 22.Java实现二叉树有几种方式?
- 23.基于二叉树演变的树有哪些?
- 24.什么是二叉搜索树?
- 25.二叉搜索树的时间复杂度分析?
- 26.什么是红黑树?
- 27.红黑树的时间复杂度
- 28. 什么是散列表
- 29.散列函数和散列冲突
- 30.怎么解决散列冲突?
- 31.散列表的时间复杂度
- 32.说一下HashMap的实现原理?
- 33. HashMap的jdk1.7和jdk1.8有什么区别
- 34.HashMap的put方法的具体流程
- 35.HashMap的扩容机制?
- 36.hashMap的寻址算法
- 37.为何HashMap的数组长度一定是2的次幂?
- 38.hashmap在1.7情况下的多线程死循环问题
- 39.HashSet与HashMap的区别
- 40.HashTable与HashMap的区别
- (七)、线程
- 1.线程和进程的区别?
- 2.并行和并发有什么区别?
- 3.创建线程的四种方式
- 4. runnable 和 callable 有什么区别
- 5.线程的 run()和 start()有什么区别?
- 6.线程包括哪些状态,状态之间是如何变化的?
- 7.新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
- 8.notify()和 notifyAll()有什么区别?
- 9.在 java 中 wait 和 sleep 方法的不同?
- 10.如何停止一个正在运行的线程?
- 11. 讲一下synchronized关键字的底层原理?
- 12.Monitor实现的锁属于重量级锁,你了解过锁升级吗?
- 13. Monitor重量级锁 (存在竞争)
- 14.轻量级锁 (交替执行,不存在竞争)
- 15.偏向锁 (可以避免轻量级的重复CAS)
- 16.重量级锁、轻量级锁、偏向锁总结
- 17.你谈谈 JMM(Java 内存模型)
- 18.CAS 你知道吗?
- 19. CAS 底层实现原理?
- 20.乐观锁和悲观锁
- 21.请谈谈你对 volatile 的理解
- 22.什么是AQS?
- 23.ReentrantLock的实现原理
- 24.synchronized和Lock有什么区别 ?
- 25.死锁产生的条件是什么?
- 26.如何诊断死锁?
- 27.什么是ConcurrentHashMap ?
- 28.导致并发程序出现问题的根本原因是什么
- 29.说一下线程池的核心参数(线程池的执行原理知道嘛)
- 30.线程池中有哪些常见的阻塞队列?
- 31.如何确定核心线程数?
- 32.线程池的种类有哪些?
- 33.为什么不建议用Executors创建线程池
- 34.线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)
- 35.真实案列_CountDownLatch (es数据批量导入)
- 36.真实案列_Future多线程 (数据汇总)
- 37.真实案列_@Ansy_(异步调用)
- 38.如何控制某个方法允许并发访问线程的数量?
- 39.谈谈你对ThreadLocal的理解
- 40.ThreadLocal的实现原理&源码解析
- 41.ThreadLocal-内存泄露问题
- (八)、JVM
- 1.JVM是什么
- 2.什么是程序计数器? (⭐不会发生GC)
- 3.你能给我详细的介绍Java堆吗? (⭐经常发生GC)
- 4.Java7和Java8 堆内存结构有什么不同嘛?
- 5.什么是元空间(MetaSpace)? (⭐很少GC)
- 6.什么是虚拟机栈? (⭐不会GC)
- 7.堆和栈的区别是什么?
- 8.能不能解释一下方法区?(⭐很少GC)
- 9.你听过直接内存吗?
- 10.Java虚拟机内存有什么组成?
- 11. 什么是类加载器,类加载器有哪些?
- 12.什么是双亲委派机制?
- 13.JVM为什么会采用双亲委派机制呢?
- 14.说一下类装载的执行过程?
- 15.简述Java垃圾回收机制?(GC是什么?为什么要GC)
- 16.对象什么时候可以被垃圾器回收?
- 17.如何定位垃圾?
- 18.哪些对象可以作为 GC Root?
- 19.JVM垃圾回收算法有哪些?
- 20.什么是JVM的分代回收?
- 21.MinorGC、 Mixed GC 、 FullGC的区别是什么?
- 22.说一下 JVM 有哪些垃圾回收器?
- 23.详细聊一下G1垃圾回收器
- 24.强引用、软引用、弱引用、虚引用的区别?
- 25. JVM 调优的参数可以在哪里设置参数值?
- 26.用的 JVM 调优的参数都有哪些?
- 27.说一下 JVM 调优的工具?
- 28.java内存泄露的排查思路?
- 29. CPU飙高排查方案与思路?(Linux)
- (九)、常见技术场景
- 1.单点登录这块怎么实现的
- 2.权限认证是如何实现的?
- 3.上传数据的安全性你们怎么控制?
- 4.你负责项目的时候遇到了哪些比较棘手的问题?
- 5.你们项目中日志怎么采集的?
- 6.查看日志的命令有哪些?
- 7.生产问题怎么排查?
- 8. 怎么快速定位系统的瓶颈
(一)、准备篇
全网最全10w字Java面试八股文
1.HR如何筛选简历?
- HR是不懂技术的,只负责招人。所以我们查看 Boss直聘/智联招聘 的后台发现,我们只要触发关键词即可。
eg: 学校、院校、经验、年龄、跳槽频率…
2.部门负责人如何筛选简历?
- 技术: 是否符合项目的技术栈。
- 业务条件: 符合业务条件(银行、电商、物流)。
- 额外加分: (高并发经验、公有云、博客)。
3.简历模块布局
- 基本信息: (个人信息)
- 教育信息: (教育信息)
- 求职意向: (什么岗位)
- 工作经历: (实习经历)
- 职业技能: (⭐技能栈:)
- 写在简历上的一定能聊 (不要浮夸)
- 职业技能=必要技术+其他技术
- 针对性的引导面试官(让他问一些你想让她问的)
- 项目经历: (⭐成品描述)
- 演示视频链接
- 项目要体现业务深度或技术深度
- 有没有主导设计过xx模块开发(0-1,1-2)
- 尽可能展示指标数据 (如: 达到了多少QPS、达到了多少的数据量)
- 个人优势: (大学四年几乎每天都至少敲码4小时+、博客)
- 个人荣誉: (软件开发大赛、orcal数据库认证工程师)
4.应届生如何找到合适的练手项目?
- 初级程序员: CRUD。
- 中高级程序员: 掌握一个业务的全方位需求 (登入业务)
5.深入学习哪些业务模块呢?
- 我们可以深入学习以下模块:
- 怎么深入学习权限认证?
- 功能实现: (基本实现)
- 常见问题: (常见隐患)
- 权限系统设计: (设计模式)
6.Java程序员的面试过程
- 面试形式 : 企业招聘的时候,不同的公司面试的伦次不一样。
- 单论面试: 只有技术面。
- 多轮面试: 技术面+HR终面
- 面试官角色:
- 资深开发人员 (开发经理): 技术最好、参与首轮面试。
- 业务部门经理: 技术一般,决定你的薪资。
- HR: 辅助业务部门考察候选人(性格、沟通能力、学习能力)。
- 整体讲解结构: 采用
总分结构
描述
(二)、Redis篇
1.redis经常使用在哪些场景?
- 缓存: (缓存三兄弟穿透、击穿、雪崩。双写一致、持久化、数据过期策略、数据淘汰策略)。
- 分布式锁: (setnx、redisson)。
- 消息队列、延迟队列: (数据类型)。
基本包括16种常见使用场景: 缓存
、数据共享分布式、分布式锁
、全局 ID、计数器、限流、位统计、购物车、用户消息时间线 timeline、消息队列
、抽奖、点赞、签到、打卡、商品标签、商品筛选、用户关注、推荐模型、排行榜.
详细讲解16种使用场景: https://blog.csdn.net/zxl646801924/article/details/123202381
2.Redis进行查询的流程是什么?
用户使用id进行查询文章的时候,首先会查询redis数据库,如果在redis中命中数据,那么redis就向用户返回查询的结果。如果在redis数据库中查询不到,那么就接着查询DB数据库。在DB查询到结果之后,在返回给用户之前,先将查询的数据备份到redis中,然后再返回给用户。
3.什么是缓存穿透 ? 怎么解决 ?
缓存穿透是指查询一个一定不存在的数据,如果从存储层(MySQL)查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。(DB不存在)
比如: 我们在使用 api/news/getById/1。 假如编号1在redis缓存中不存在且数据库中也没有,这样用户一直进行请求的话会跳过redis一直访问数据库,给数据库造成巨大压力。
解决方案
第一种方案: 缓存空数据。查询返回的数据为空,仍然把这个空结果进行缓存到我们的redis数据库中。(优点: 简单。缺点: 消耗内存,可能发生不一致的问题)。
第二种方案: 布隆过滤器。
我们在redis缓存的前面添加一个布隆过滤器,假如请求经过布隆过滤器的时候,我们发现不存在数据那么我们就直接返回。
4.你能介绍一下布隆过滤器吗?
布隆过滤器主要是用于检索一个元素是否在一个集合中。我们当时使用的是redisson实现的布隆过滤器。
它的底层主要是先去初始化一个比较大数组,里面存放的二进制0或1,在一开始都是0。
存储数据时: 当一个key来了之后经过3次hash计算,模于数组长度找到数据的下标然后把数组中原来的0改为1,这样的话,三个数组的位置就能标明一个key的存在。
查询数据时: 使用相同的hash函数获取hash值,判断对应的位置是否都为1。
结论: 当存储数据时生成指定位置上的结果和查询数据找到指定位置上的都为1的时候,就说明这个值是存在的,反之不存在进行过滤器拦截。
布隆过滤器缺点
当然是有缺点的,布隆过滤器有可能会产生一定的误判,我们一般可以设置这个误判率,大概不会超过5%,其实这个误判是必然存在的,要不就得增加数组的长度,其实已经算是很划分了,5%以内的误判率一般的项目也能接受,不至于高并发下压倒数据库。
eg: 假如id1经过hash算法之后得到的下标是: (1,3,7)。id2经过hash算法之后得到的下标是: (9,12,14)。此时id3经过hash算法之后得到下标分别在id1和id2的位置上,那么布隆过滤器也认为这个值是存在的进行放行!!!
5.什么是缓存击穿 ? 怎么解决 ?
缓存击穿的意思是对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把 DB 压垮。(DB存在数据但Redis时间过期)
eg: 查询到redis数据中的时候key过期了,那么就转向访问DB,在查询DB的时候消耗的时间较长,没有来得及同步到redis数据库中,此时有大量的请求融入会一下子击穿数据库。
解决方案:
第一种: 互斥锁。线程一进行查询redis缓存的时候没有进行命中,先尝试获取互斥锁,互斥锁获取成功后进行DB查询数据,查询到数据之后我们向redis缓存中存放数据,当存放成功后我们才对其进行释放互斥锁。保证一个资源只能被一个线程使用,避免了高并发的融入。
特点: 强一致性但性能差。
第二种:逻辑过期。不设置过期时间,线程一进行查询缓存的时候发现逻辑时间已经过期,那么就尝试获取互斥锁,获取互斥锁成功后线程一开启一个新的线程叫做线程2,线程2进行查询数据库并写入redis缓存中,最后释放锁。当线程1正在运行的时候出现了线程3也在进行访问,查询缓存数据已经过期,进行尝试获取互斥锁,获取锁失败后返回过期的数据。
特点: 高可用,性能优但数据将可能出现误差。
6.什么是缓存雪崩 ? 怎么解决 ?
缓存雪崩意思是设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB 瞬时压力过重雪崩。与缓存击穿的区别:(雪崩是很多key,击穿是某一个key缓存)。
解决方案
- 给不同的key的TTL添加随机值过期。
- 利用Redis集群提高服务的高可用性。
- 给缓存业务添加降级限流策略。
- 给业务添加多级缓存。
7.redis做为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)
双写一致性: 当修改了数据库的数据也要同时修改更新缓存的数据,缓存和数据库的数据要保持一致。
先删除数据库好? 还是先删除缓存好?
先删除缓存,再操作数据库。 当线程1删除了缓存,还没进行修改数据库的时候,线程2进行读取了数据库并将数据库的旧数据放入了我们的redis缓存中,然后线程1进行修改数据库。就会出现数据不一致的问题。
先操作数据库,再删除缓存。假如线程1进行查询缓存的时候发现缓存已经过期了,就会去访问数据库。然后线程2进行更新数据库,并删除缓存。然会线程1将以前的旧数据写入缓存。
解决办法
第一种: 延迟双删。先删除一次redis缓存,然后再删除数据库,但数据库同步之后我们再次进行删除我们的缓存。使用延时的原因是为了给数据库足够的时间进行修改和同步。
第二种: 分布式锁。采用redisson分布式锁进行加锁操作。
-
读操作的时候: 在读的时候添加共享锁,可以保证
读读不互斥,读写互斥
。
-
写操作的时候: 当我们更新数据的时候,添加排他锁,它是读写,读读都互斥,这样就能保证在写数据的同时是不会让其他线程读数据的,避免了脏数据。
- 注意: 这里面需要注意的是读方法和写方法上需要使用同一把锁才行,也就是说加锁的时候名字要一样。
特点: 强一致性、性能低。
第三种: 异步通知保证数据的一致性。(RabbitMQ)
第四种: 基于Canal的异步通知。
8.redis做为缓存,数据的持久化是怎么做的?
在Redis中提供了两种数据持久化的方式:RDB 和 AOF 。
什么是RDB?
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。
在redis客户端执行以下两个命令,手动启动RDB
# 由Redis主进程来执行RDB,会阻塞所有命令。
save
# 开启子进程执行RDB,避免主进程受到影响
bgsave
Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下
RDB的执行原理?
bgsave开始时会得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入 RDB 文件。
在Linux虚拟机中,谁都不能够直接操作物理内存,虚拟机就会给主进程开启一个页表(类似于数据卷)进行维持主进程的数据和物理内存的数据一致的。在进行RDB操作的时候,子进程克隆父进程,子进程修改子进程自己身上的页表也会进行读写操作。
- 当主进程执行读操作时,访问共享内存。
- 当主进程执行写操作时,则会拷贝一份数据,执行写操作。
什么是AOF?
AOF全称为Append Only File(追加文件)。Redis记录所有的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:
AOF的命令记录的频率也可以通过redis.conf文件来配:
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof
命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
Redis也会在触发阈值时自动去重写AOF文件。值也可以在redis.conf中配置:
9.RDB与AOF有什么区别吗?
RDB是一个快照文件,它是把redis内存存储的数据写到磁盘上,当redis实例宕机恢复数据的时候,方便从RDB的快照文件中恢复数据。
AOF的含义是追加文件,当redis操作写命令的时候,都会存储这个文件中,当redis实例宕机恢复数据的时候,会从这个文件中再次执行一遍命令来恢复数据
RDB因为是二进制文件,在保存的时候体积也是比较小的,它恢复的比较快,但是它有可能会丢数据,我们通常在项目中也会使用AOF来恢复数据,虽然AOF恢复的速度慢一些,但是它丢数据的风险要小很多,在AOF文件中可以设置刷盘策略,我们当时设置的就是每秒批量写入一次命令。
10.假如redis的key过期之后,会立即删除吗?
Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称之为数据的删除策略(数据过期策略)。
11. Redis的数据过期策略有哪些 ?
redis提供了两种过期策略: 惰性删除和定期删除
第一种是惰性删除,在设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。
- 优点: 对CPU比较友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查。
- 缺点:对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直在内存中,内存永远不会释放。
第二种是 定期删除,就是说每隔一段时间,我们就对一些key进行检查,删除里面过期的key。
定期清理的两种模式:
-
SLOW
模式是定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf 的 hz 选项来调整这个次数 -
FAST
模式执行频率不固定,每次事件循环会尝试执行,但两次间隔不低于2ms,每次耗时不超过1ms- 优点:可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除,也能有效释放过期键占用的内存。
- 缺点:难以确定删除操作执行的时长和频率。
Redis的过期删除策略:惰性删除 + 定期删除两种策略进行配合使用。
12.假如缓存过多,内存是有限的,内存被占满了怎么办?
其实想问的就是数据的淘汰策略。
数据的淘汰策略: 当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。
13.Redis的数据淘汰策略有哪些 ?
-
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算法进行淘汰。
14.Redis的数据淘汰策略使用建议
- 优先使用allkeys-lru,挑选最近最少使用的数据淘汰,把一些经常访问的key留在redis中。
- 如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用allkeys-random,随机选择淘汰。
- 如果业务中有置顶的需求,可以使用volatile-lru策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据。
- 如果业务中有短时高频访问的数据,可以使用allkeys-lfu 或 volatile-lfu。
15.数据库有1000万数据 ,Redis只能缓存20w数据, 如何保证Redis中的数据都是热点数据 ?
可以使用 allkeys-lru (挑选最近最少使用的数据淘汰)淘汰策略,那留下来的都是经常访问的热点数据。
16.Redis的内存用完了会发生什么?
嗯~,这个要看redis的数据淘汰策略是什么,如果是默认的配置,redis内存用完以后则直接报错。我们当时设置的 allkeys-lru 策略。把最近最常访问的数据留在缓存中。
17.为什么要使用Redis分布式锁?
因为在集群环境下,JVM层面的互斥锁满足不了我们集群环境下的多线程操作。
基于JVM层面的互斥锁: synchorized (单体服务下)
介绍多并发情况下的抢票执行流程: 会出现超卖的情况
- 添加互斥锁: 在单个服务器下是安全的,可行的。
2. 添加互斥锁: 在多个服务器下是不可行的,因为synchorized是一个JVM变量,每一个服务器都一个自己的JVM。所以在多服务器的时候就会出现超卖的问题。
在集群部署的环境下,因为synchorized是jvm的。
- 分布式锁: (集群环境下)
分布式锁: 主要运用于我们的集群环境中,在集群环境下进行加锁的话,主要使用的是分布式锁
18.Redis分布式锁如何实现 ?
Redis实现分布式锁主要利用Redis的setnx命令。setnx是SETif not exists(如果不存在,则 SET)的简写。
// 假如这个值不存在就进行上锁,过期时间设置为10s
set lock value NX EX 10
// 解锁
del key
19.如何控制Redis实现分布式锁有效时长呢?
因为我们手动设置的过期时间,具有很大的不稳定性。假如业务在我们自己手动设置的过期时间后仍然没有完成对应的业务,就释放锁了,会出现严重的问题。
共有两种解决方案:
- 第一种: 根据业务执行时间预估。
- 第二种: 给锁续期(redisson)。(也就是说假如A线程执行业务的时间超过了自己设定的时间,那么就开启一个新的线程当作看门狗,用来监视我们的业务)。
redis的setnx指令不好控制这个问题,我们当时采用的redis的一个框架redisson实现的。
在redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间,当锁住的一个业务还没有执行完成的时候,在redisson中引入了一个看门狗机制,就是说每隔一段时间就检查当前业务是否还持有锁,如果持有就增加加锁的持有时间,当业务执行完成之后需要使用释放锁就可以了
。默认每次redisson续期是10秒/次。
还有一个好处就是,在高并发下,一个业务有可能会执行很快,先客户1持有锁的时候,客户2来了以后并不会马上拒绝,它会自旋不断尝试获取锁,如果客户1释放之后,客户2就可以马上持有锁,性能也得到了提升。
20.redisson实现的分布式锁是可重入的吗?
锁重入概念: 是指任意线程在获取到锁之后,再次获取该线程的同一个锁而不会被该锁所阻塞。
redisson分布式锁是可以重入的。这样做是为了避免死锁的产生。这个重入其实在内部就是判断是否是当前线程持有的锁,如果是当前线程持有的锁就会计数,如果释放锁就会在计算上减一。在存储数据的时候采用的hash结构,大key可以按照自己的业务进行定制,其中小key是当前线程的唯一标识,value是当前线程重入的次数。
21.redisson实现的分布式锁能解决主从一致性的问题吗?
这个是不能的,比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。
这样的话可能会出现脏数据,主1的数据可能会丢失!!!
红锁 RedLock
RedLock(红锁):不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁 (n/2+1)
,避免在一个redis实例上加锁。
我们可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。
但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁。如果项目中非要保证数据的强一致性,建议采用zookeeper实现的分布式锁。
22.Redis集群有哪些方案, 知道嘛 ?
在Redis中提供的集群方案总共有三种:
- 主从复制: 单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据。
-
哨兵模式: Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。
-
Redis分片集群
23.Redis主从同步数据流程
主从同步分为了两个阶段,一个是全量同步,一个是增量同步。
全量同步
全量同步是指: 从节点第一次与主节点建立连接的时候使用全量同步
,流程是这样的:
-
第一:从节点请求主节点同步数据,其中从节点会携带自己的
replication id
和offset
偏移量。 -
第二:主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致。
- Replication ld:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replidslave则会继承master节点的replid。
- offset:偏移量,随着记录在repl baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset.如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
-
第三:在同时主节点会执行
bgsave
,生成rdb文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致。
当然,如果在rdb生成执行期间,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区,缓冲区是一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件,这个就是全量同步。
增量同步
增量同步指的是,当从节点服务重启之后,数据就不一致了,所以这个时候,从节点会请求主节点同步数据,主节点还是判断不是第一次请求,不是第一次就获取从节点的offset值,然后主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步。
24.什么是哨兵模式?
哨兵模式: Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。
哨兵模式的功能
-
监控功能: Sentinel会不断地检查你的master和salver是否按照预期进行工作。(每隔1秒向集群的每个实例发送ping命令)
- 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
- 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
-
自动故障恢复功能: 如果master发生故障,Sentinel会将一个savler提升为一个master。当故障恢复后也依然将新选举的master当作老大。
-
通知: Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移的时候,会将最新信息推送给Redis的客户端。
哨兵选主规则
- 首先判断主与从节点断开时间长短,如超过指定值就排该从节点。
- 然后判断从节点的slave-priority值,越小优先级越高。
- 如果slave-prority一样,则判断slave节点的offset值,越大优先级越高。(也就是写入的数据)
- 最后是判断slave节点的运行id大小,越小优先级越高
25.Redis集群脑裂,该怎么解决呢?
有的时候由于网络等原因可能会出现脑裂的情况,就是说,由于redis master节点和redis salve节点和sentinel处于不同的网络分区,使得sentinel没有能够心跳感知到master,所以通过选举的方式提升了一个salve为master,这样就存在了两个master,就像大脑分裂了一样,这样会导致客户端还在old master那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将old master降为salve,这时再从新master同步数据,这会导致old master中的大量数据丢失。
解决方案:
- 方案一: 可以设置最少的salve节点个数,比如设置至少要有一个从节点才能同步数据。(当主节点宕机选举了新主节点也就意味着老主节点没有了从节点,近而至少一个从节点可以对其完美验证。)
- 方案二: 可以设置主从数据复制和同步的延迟时间,达不到要求就拒绝请求,就可以避免大量的数据丢失。
redis中有两个配置参数:
min-replicas-to-write 1 //表示最少的salve节点为1个
min-replicas-max-lag 5 //表示数据复制和同步的延迟不能超过5秒
26.怎么保证Redis的高并发高可用?
首先可以搭建主从集群,再加上使用redis中的哨兵模式,哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障恢复、通知;如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主;同时Sentinel也充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端,所以一般项目都会采用哨兵的模式来保证redis的高并发高可用。
27.你们使用Redis是单点还是集群,哪种集群
我们当时使用的是主从(1主1从)加哨兵。一般单节点不超过10G内存,如果Redis内存不足则可以给不同服务分配独立的Redis主从节点。尽量不做分片集群。因为集群维护起来比较麻烦,并且集群之间的心跳检测和数据通信会消耗大量的网络带宽,也没有办法使用lua脚本和事务。
28.什么是Redis分片集群?
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决。
- 海量数据存储问题
- 高并发写的问题
分片集群主要解决的是: 海量数据存储的问题。
- 集群中有多个master,每个master保存不同数据。(高并发写)
- 每个master可以设置多个slave节点,就可以继续增大集群的高并发能力。(主从)
- 每个master之间通过ping监测彼此健康状态,就类似于哨兵模式了。(哨兵)
- 当客户端请求可以访问集群任意节点,最终都会被转发到正确节点。 (路由)
29.Redis分片集群中数据是怎么存储和读取的?
- Redis 分片集群引入了哈希槽的概念,Redis 集群有 16384 个哈希槽
- 将16384个插槽分配到不同的实例
- 读写数据:根据key的有效部分计算哈希值,对16384取余(有效部分,如果key前面有大括号,大括号的内容就是有效部分,如果没有,则以key本身做为有效部分)余数做为插槽,寻找插槽所在的实例.
30.Redis是单线程的,但是为什么还那么快?
-
完全基于内存的,C语言编写。
-
采用单线程,避免不必要的上下文切换可竞争条件。Redis6.0引入了多线程IO。
-
使用多路I/O复用模型,非阻塞IO。
31.Linux的用户空间和内核空间
- Linux系统中一个进程使用的内存情况划分两部分: 内核空间、用户空间。
- 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源必须通过内核提供的接口来访问。
- 内核空间可以执行特权命令(Ring0),调用一切系统资源。
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
- 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备。
- 读数据时,要先从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区。
32.阻塞IO、非阻塞IO、多路复用IO
阻塞IO (BIO)
阻塞IO概念: 阻塞IO就是用户进程在两个阶段都必须阻塞等待。
阶段一:
- 用户进程尝试读取数据(比如网卡数据)
- 此时数据尚未到达,内核需要等待数据
- 此时用户进程也处于阻塞状态
阶段二:
- 数据到达并拷贝到内核缓冲区,代表已就绪
- 将内核数据拷贝到用户缓冲区
- 拷贝过程中,用户进程依然阻塞等待
- 拷贝完成,用户进程解除阻塞,处理数据
非阻塞IO (NIO) 【打电话问朋友】
非阻塞IO概念: 非阻塞I0的recvfrom操作会立即返回结果而不是阻塞用户进程。
阶段一:
- 用户进程尝试读取数据(比如网卡数据)
- 此时数据尚未到达,内核需要等待数据
- 返回异常给用户进程
- 用户进程拿到error后,再次尝试读取
- 循环往复,直到数据就绪
阶段二:
- 将内核数据拷贝到用户缓冲区
- 拷贝过程中,用户进程依然阻塞等待
- 拷贝完成,用户进程解除阻塞,处理数据
多路IO (IOM) 【在家等朋友打电话】
I0多路复用: 是利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
阶段一:
- 用户进程调用select,指定要监听的Socket集合
- 内核监听对应的多个socket
- 任意一个或多个socket数据就绪则返回readable
- 此过程中用户进程阻塞
阶段二:
- 用户进程找到就绪的socket
- 依次调用recvfrom读取数据
- 内核将数据拷贝到用户空间
- 用户进程处理数据
33.实现IO多路复用有哪些方式?
I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听Socket的方式、通知的方式有多种实现。
共三种: select、poll、epoll。
差异:
- select和poI只会通知用户进程有Socket就绪,但不确定具体是哪个Socket,需要用户进程逐个遍历Socket来确认
- epoll则会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间。
select和poll是使用灯泡、epoll是电脑确认到具体的桌子。
34.Redis的网络模型
Redis通过10多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装, 提供了统一的高性能事件库。
33.能解释一下Redis的I/O多路复用模型?
I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。
其中Redis的网络模型就是使用 I/O多路复用
结合事件的处理器
来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;
在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度。在命令执行的时候,依然是单线程。
- 命令转换: 多线程。
- 命令执行: 单线程。
(三)、MySQL
1.MySQL中,如何定位慢查询?
两种方案:
方案一: 开源工具
- 调试工具: Arthas。
- 运维工具: Prometheus、Skywalking。
方案二: 慢日志查询
- 慢日志查询。(手动开启慢日志查询开关,然后会自动记录我们的慢SQL)
我们当时做压测的时候有的接口非常的慢,接口的响应时间超过了2秒以上,因为我们当时的系统部署了运维的监控系统Skywalking ,在展示的报表中可以看到是哪一个接口比较慢,并且可以分析这个接口哪部分比较慢,这里可以看到SQL的具体的执行时间,所以可以定位是哪个sql出了问题。
如果,项目中没有这种运维的监控系统,其实在MySQL中也提供了慢日志查询的功能,可以在MySQL的系统配置文件中开启这个慢日志的功能,并且也可以设置SQL执行超过多少时间来记录到一个日志文件中,我记得上一个项目配置的是2秒,只要SQL执行的时间超过了2秒就会记录到日志文件中,我们就可以在日志文件找到执行比较慢的SQL了。
2.那这个SQL语句执行很慢, 如何分析呢?
可以采用 EXPLAIN
或者 DESC
命令获取 MySQL 如何执行 SELECT 语句的信息。
- possible key 当前sql可能会使用到的索引
- type 这条sql的连接的类型,性能由好到差为NULL、system、const、eq_ref、ref、range、index、all
NULL
: (开发过程中没有使用到具体的表)。system
: (开发过程中查询的是系统中的表)。const
: (根据主键查询)。eq_ref
: (主键索引查询或唯一索引查询)。ref
: (索引查询)range
: (范围查询)index
: (索引树扫描)all
: (不走索引,全盘扫描)
- key 当前sql实际命中的索引
- key_len 索引占用的大小
- Extra 额外的优化建议
如果一条sql执行很慢的话,我们通常会使用mysql自动的执行计划explain来去查看这条sql的执行情况,比如在这里面可以通过key和key_len检查是否命中了索引,如果本身已经添加了索引,也可以判断索引是否有失效的情况,第二个,可以通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描,第三个可以通过extra建议来判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复。
3.什么是索引?
索引(index)是帮助MySQL高效获取数据的数据结构(有序)。主要是用来提高数据检索的效率,降低数据库的IO成本,同时通过索引列对数据进行排序,降低数据排序的成本,也能降低了CPU的消耗。
4.索引的底层数据结构了解过嘛 ?
MySQL的默认的存储引擎InnoDB采用的B+树的数据结构来存储索引。
B+树相比二叉树、红黑树的特点
- B+树相对于二叉树结构更稳定。(不会出现更坏的情况)
- B+树相对于红黑树层级更低。(不会出现高层的情况)
B+树相比B树的优点
- B+树查询效率更稳定。(数据在叶子节点)
- B+树便于扫库和区间查询。(双向指针)
- B+树磁盘读写代价更低。(数据在叶子节点)
选择B+树的主要的原因是:第一 阶数更多,路径更短,第二个磁盘读写代价B+树更低,非叶子节点只存储指针,叶子阶段存储数据,第三是B+树便于扫库和区间查询,叶子节点是一个双向链表。
5.什么是聚簇索引什么是非聚簇索引 ?
-
聚簇索引: 主要是指数据与索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个,一般情况下主键在作为聚簇索引的。
-
非聚簇索引: 指的是数据与索引分开存储,B+树的叶子节点保存对应的主键,可以有多个,一般我们自己定义的索引都是非聚簇索引
聚集索引选取规则
- 如果存在主键约束、主键约束就是唯一索引。
- 如果不存在主键约束、将使用第一个唯一索引作为聚集索引。
- 如果没有主键约束、或没有合适的唯一索引,则InnoDB会自动生成一个rowid作为隐藏的聚集索引。
6.知道什么是回表查询嘛 ?
其实跟刚才介绍的聚簇索引和非聚簇索引是有关系的,回表的意思就是通过二级索引找到对应的主键值,然后再通过主键值找到聚集索引中所对应的整行数据,这个过程就是回表。
7.知道什么叫覆盖索引嘛 ?
覆盖索引: 使用索引进行查询的时候,需要返回的列不经过回表就能全部找到的索引。
8. MySQL超大分页怎么处理 ?
在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。
解决办法: (覆盖索引+子查询)
优化思路: 一般分页查询时,通过创建 覆盖索引 能够比较好地提高性能,可以通过覆盖索引加子查询形式进行优化。
优化后的数据
超大分页一般都是在数据量比较大时,我们使用了limit分页查询,并且需要对数据进行排序,这个时候效率就很低,我们可以采用覆盖索引和子查询来解决。先排序再分页查询数据的id字段,确定了id之后,再用子查询来过滤,只查询这个id列表中的数据就可以了。因为查询id的时候,走的覆盖索引,所以效率可以提升很多。
9.索引创建原则有哪些?
- 针对于数据量较大,且查询比较频繁的表建立索引。(单表超过10w数据)。⭐
- 针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。⭐
- 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
- 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。⭐
- 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。⭐
- 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率。⭐
- 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。
10.什么情况下索引会失效?
- 违反最左前缀法则。
- 索引列运算。
- 索引字符串没有添加单引号,会发生类型转换导致失效。
- 模糊查询的时候在索引前面加了 “%”。
- 索引使用了范围查询。
- 范围查询右边的列会失效。
11.谈谈你对SQL优化的经验?
主要有六个方面: 表的设计优化、索引优化、SQL语句优化、主从复制、读写分离、分库分表。
12.创建表的时候,你们是如何优化的呢?
表的设计优化 (参考阿里开发手册《嵩山版》)
- 比如设置合适的数值(tinyint int bigint),要根据实际情况选择。
- 比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低。
13.你平时对SQL语句做了哪些优化呢?
- select语句务必指明字段名称 (避免直接使用select*).
- SQL语句要避免造成索引失效的写法.
- 尽量用union all代替union,union会多一次过滤,效率低
- 避免在where子句中对字段进行表达式操作
- Join优化 能用inner join 就不用left join rightjoin,如必须使用 一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left ioin 或 right join,不会重新调整顺序。
14.为什么要进行主从复制、读写分离?
如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响 可以采用读写分离的架构。
读写分离解决的是,数据库的写入,影响了查询的效率。
15.事务的特性是什么?可以详细说一下吗?
ACID: 分别指的是:原子性、一致性、隔离性、持久性;
- 原子性: (事务要么全部成功、要么全部失败)。
- 一致性: (事务更改前和数据更改后,要保持数据一致)。
- 隔离性: (一个事务不会被另一个事务影响)。
- 持久性:(事务一旦提交或回滚,它对数据永久改变了)。
16.并发事务带来哪些问题?
- 脏读: 一个事务读取到了另一个事务未提交的数据。
- 不可重复读: 一个事务两次读取操作结果不一致。
- 幻读: 一个事务第一次读取的时候发现A数据不存在,但是插入的时候却不让插入。
17.怎么解决这些问题呢?MySQL的默认隔离级别是?
- 读未提交: 解决不了任何问题。
- 读已提交: 能够解决脏读。
- 可重复读: 能够解决脏读、不可重复读。
- 串行化: 能够解决脏读、不可重复读、幻读。
18.undo log和redo log的区别
提出脏页问题
当我们执行CRUD的时候,我们不会直接操纵磁盘结构,而是先操纵我们的内存结构中的页,因为还没有同步到磁盘结构,所以内存中的页叫做脏页。假如脏页还没有同步到磁盘结构就发生了崩盘,就会出现数据不同步的问题。
- 缓冲池(buffer pool): 主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改査操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘I0,加快处理速度。
- 数据页(page):是InnoDB 存储引擎磁盘管理的最小单元,每个页的大小默认为 16KB。页中存储的是行数据。
解决问题: 使用重做日志redo (记录的是物理日志)
重做日志: 记录的是事务提交时数据页的物理修改,是用来实现事务的持久性。
该日志文件由两部分组成: 重做日志缓冲(redo log buffer)以及重做日志文件(redolog file),前者是在内存中,后者在磁盘中。当事务提交之后会把所有修改信息都存到该日志文件中,用于在刷新脏页到磁盘,发生错误时,进行数据恢复使用。
undolog (逻辑日志)
回滚日志: 用于记录数据被修改前的信息,作用包含两个: 提供回滚 和 MVCC(多版本并发控制)。undolog和redo log记录物理日志不一样,它是逻辑日志。
- 可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然。
- 当update一条记录时,它记录一条对应相反的update记录。当执行回滚时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。
- 回滚日志,在insert、update、delete的时候产生的便于数据回滚的日志。
- 当insert的时候,产生的undolog日志只在回滚时需要,在事务提交后,可被立即删除。
- 而update、delete的时候,产生的undolog日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除。
redo log日志记录的是数据页的物理变化,服务宕机可用来同步数据。而undo log 不同,它主要记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据,比如我们删除一条数据的时候,就会在undo log日志文件中新增一条delete语句,如果发生回滚就执行逆操作。
总结: redolog保证持久性。undolog保证事务的一致性和原子性。
19.事务中的隔离性是如何保证的呢?(你解释一下MVCC)
- 事务的隔离性是由排他锁和MVCC实现的。
20.什么是MVCC?
MVCC: 全称 Multi-Version Concurrency Control,多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突。它的底层实现主要是分为了三个部分,第一个是隐藏字段,第二个是undo log日志,第三个是readView读视图。
-
隐藏字段: 在mysql中给每个表都设置了三个隐藏字段。第一个是trx_id(事务id),记录每一次操作的事务id,是自增的。第二个字段是roll_pointer(回滚指针),指向上一个版本的事务版本记录地址。第三个row_id(隐藏主键),如果表结构没有指定主键,就会生成隐藏字段,否则不生产。
-
undo log: 主要的作用是记录回滚日志,存储老版本数据,在内部会形成一个版本链。在多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表。
-
readview:ReadView(读视图)是 快照读 SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id。
当前读
: 读取的是记录的最新版本
,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如: (select … lock in share mode(共享锁),select… for update、update、insert、delete(排他锁)) 都是一种当前读。(不管阻塞还是非阻塞都能读取到最新的数据)快照读
: 简单的select(不加锁)就是快照读,快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。(读取的是提交事务之前的旧数据)读已提交
: 每次select,都生成一个快照读。可重复读
: 开启事务后第一个select语句才是快照读的地方。
21.MySQL主从同步原理
MySQL主从复制的核心就是二进制日志(DDL(数据定义语言)语句和 DML(数据操纵语言)语句)。
- 第一:主库在事务提交时,会把数据变更记录在二进制日志文件 Binlog 中。
- 第二:从库读取主库的二进制日志文件 Binlog ,写入到从库的中继日志 Relay Log 。
- 第三:从库重做中继日志中的事件,将改变反映它自己的数据。
22.你们项目用过MySQL的分库分表吗?
我们都是微服务开发,每个微服务对应了一个数据库,是根据业务进行拆分的,这个其实就是垂直拆分。
分库分表的前提:
- 项目业务数据逐渐增多,或业务发展比较迅速。(单表数据超过1000w或20G以后)。
- 优化已解决不了性能问题(主从读写分离、查询索引)。
- IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)。
分库分表策略
-
垂直分库: 以表为依据,根据业务将不同表拆分到不同的库。
-
垂直分表:以字段为依据,根据业务将不同的字段拆分到不同的表。(可以在同一个库中)
- (冷热数据分离、减少IO过度争抢,两表互不影响)。
- (冷热数据分离、减少IO过度争抢,两表互不影响)。
-
水平分库:将一个库水平复制几份。
- (提升高并发、系统稳定性和可用性)
-
水平分表: 将一个表水平复制几份。(可以在同一个库中)
- (优化单一表数据量过大而引起的性能问题)
- 避免IO争抢并减少锁表的概率
(四)、框架篇
1.Spring框架中的单例bean是线程安全的吗?
- singleton: bean在每个Spring lOC容器中只有一个实例。
- prototype: 一个bean的定义可以有多个实例。
在没有产生竞态条件的情况下是线程安全的
- 读: 线程安全的。 (不修改成员变量)
- 写: 携程不安全的。(修改成员变量)
在有产生竞态条件的情况下不是线程安全的
不是线程安全的,是这样的
当多用户同时请求一个服务时,容器会给每一个请求分配一个线程,这是多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单列状态的修改(体现为该单例的成员属性),则必须考虑线程同步问题。
Spring框架并没有对单例bean进行任何多线程的封装处理。关于单例bean的线程安全和并发问题需要开发者自行去搞定。
比如:我们通常在项目中使用的Spring bean都是不可可变的状态(比如Service类和DAO类),所以在某种程度上说Spring的单例bean是线程安全的。
如果你的bean有多种状态的话(比如 View Model对象),就需要自行保证线程安全。最浅显的解决办法就是将多态bean的作用由“singleton”变更为“prototype”。(单列变成多列)
2.什么是AOP?
aop是面向切面编程,在spring中用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取公共模块复用,降低耦合,一般比如可以做为公共日志保存,缓存处理、事务处理等。
3.Spring中事务失效的场景有哪些?
有三种情况: 分别如下
- 异常捕获处理
- 抛出检查异常
- 非public方法
第一种: 异常捕获处理 (方法体内)
如果方法上异常捕获处理,自己处理了异常,没有抛出,就会导致事务失效,所以一般处理了异常以后,别忘了抛出去就行了。
1.手动捕获了异常,但是没有进行抛出处理,造成事务失效!(ACID保证不了)
解决: 2. 自己进行了抛出异常。不会出现事务失效(ACID)
第二种. 抛出检查异常 (方法声明上)
如果方法抛出检查异常,如果报错也会导致事务失效。原因是因为spring默认只会回滚非检查约束。最后在spring事务的注解上,就是Transactional注解上配置rollbackFor属性为Exception,这样别管是什么异常,都会回滚事务。
方法上抛出异常,如果报错也会导致我们的事务失效
解决: 在注解中添加参数。
第三种: 非public方法
如果方法上不是public修饰的,也会导致事务失效。原因是因为Spring为方法创建代理、添加事务通知、前提条件都是该方法是public的。
解决: 添加上public
事务失效产生的结果: 不能保证原子性。(转账问题)
4.Spring的bean的生命周期
BeanDefinition: Spring容器在进行实例化时,会将xml配置的<bean>
的信息封装成一个BeanDefinition对象,Spring根据BeanDefinition来创建Bean对象,里面有很多的属性用来描述Bean。
首先会通过一个非常重要的类,叫做BeanDefinition获取bean的定义信息,这里面就封装了bean的所有信息,比如: 类的全路径,是否是延迟加载,是否是单例等等这些信息。
Bean生命周期七大步骤
- 在创建bean的时候,第一步是调用构造函数实例化bean.
- 第二步是bean的依赖注入,比如一些set方法注入,像平时开发用的@Autowire都是这一步完成.
- 第三步是处理Aware接口,如果某一个bean实现了Aware接口就会重写方法执行.
- 第四步是bean的后置处理器BeanPostProcessor,这个是前置处理器.
- 第五步是初始化方法,比如实现了接口InitializingBean或者自定义了方法init-method标签或@PostContruct.
- 第六步是执行了bean的后置处理器BeanPostProcessor,主要是对bean进行增强,有可能在这里产生代理对象.
- 最后一步是销毁bean.
5.Spring中的循环引用
Spring 循环引用通常发生在Spring容器中的Bean相互依赖时,例如,Bean A依赖于Bean B,而Bean B也依赖于Bean A。这种情况下,Spring需要先创建Bean A来满足Bean B的依赖,然后再创制Bean B来满足Bean A的依赖。
循环依赖可能产生的问题:
可能会产生死循环
三级缓存解决循环依赖
Spring解决循环依赖是通过三级缓存,对应的三级缓存如下所示
循环依赖在spring中是允许存在,spring框架依据三级缓存已经解决了大部分的循环依赖
①一级缓存:单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
②二级缓存:缓存早期的bean对象(生命周期还没走完)
③三级缓存:缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的
一级缓存和二级缓存联合解决循环依赖。
只使用一级缓存,解决不了循环依赖的问题❌
一级缓存作用: 限制bean在beanFactory中只存一份,即实现singleton scope,解决不了循环依赖。(因为产生了死循环、所以是没有任何一个实列能够走完一个完整的生命周期的)。
使用一级缓存和二级缓存共同解决我们的循环依赖✅
实列化A -> 原始对象A -> (将半成品对象A放到二级缓存),紧接着因为原始对象A初始化需要注入B,因为B不存在所以继续进行实列化B,进行创建原始对象B同时将原始对象B也存入二级缓存中。这时候原始对象B需要注入A,因为对象A还没有创建完,所以我们直接从二级缓存中取半成品A,然后B创建成功。当B创建成功之后放入我们的单列池(一级缓存中)随便删除二级缓存的B。因为B已经有了,这个时候变可以将B注入给A,A创建成功,然后将A放入单列池中(一级缓存)随便删除二级缓存的A。
一级缓存和二级缓存只能解决一般的对象,不能够解决代理对象的循环依赖。
联合使用一级二级三级缓存进行解决代理对象的循环依赖
6.SpringMvc的执行流程
存在视图的SpringMVC流程
不存在视图的SpringMVC
7.Springboot自动配置原理
- @SpringBootConfiguration:该注解与 @Configuration 注解作用相同,用来声明当前也是一个配置类。
- @ComponentScan:组件扫描,默认扫描当前引导类所在包及其子包。
- @EnableAutoConfiguration: SpringBoot实现自动化配置的核心注解。
详解: EnableAutoConfiguration 注解
在Spring Boot项目中的引导类上有一个注解@SpringBootApplication,这个注解是对三个注解进行了封装,分别是:
-
@SpringBootConfiguration
-
@EnableAutoConfiguration
-
@ComponentScan
其中@EnableAutoConfiguration
是实现自动化配置的核心注解。
该注解通过@Import
注解导入对应的配置选择器。关键的是内部就是读取了该项目和该项目引用的Jar包的的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名。
在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中。
一般条件判断会有像@ConditionalOnClass
这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用。
8.Spring 的常见注解有哪些?
-
第一类是:声明bean,有@Component、@Service、@Repository、@Controller
-
第二类是:依赖注入相关的,有@Autowired、@Qualifier、@Resourse
-
第三类是:设置作用域 @Scope
-
第四类是:spring配置相关的,比如@Configuration,@ComponentScan 和 @Bean
-
第五类是:跟aop相关做增强的注解 @Aspect,@Before,@After,@Around,@Pointcut
9. SpringMVC常见的注解有哪些?
-
有@RequestMapping:用于映射请求路径;
-
@RequestBody:注解实现接收http请求的json数据,将json转换为java对象;
-
@RequestParam:指定请求参数的名称;
-
@PathViriable:从请求路径下中获取请求参数(/user/{id}),传递给方法的形式参数;
-
@ResponseBody:注解实现将controller方法返回对象转化为json对象响应给客户端。
-
@RequestHeader:获取指定的请求头数据,还有像@PostMapping、@GetMapping这些。
10.Springboot常见注解有哪些?
Spring Boot的核心注解是@SpringBootApplication , 他由几个注解组成 :
- @SpringBootConfiguration: 组合了- @Configuration注解,实现配置文件的功能;
- @EnableAutoConfiguration:打开自动配置的功能,也可以关闭某个自动配置的选项
- @ComponentScan:Spring组件扫描
- ConfigurationProperties 读取配置
11.MyBatis执行流程
①读取MyBatis配置文件:mybatis-config.xml加载运行环境和映射文件。 (确定数据库位置和Mapper文件)。
②构造会话工厂SqlSessionFactory,一个项目只需要一个,单例的,一般由spring进行管理。
③会话工厂创建SqlSession对象,这里面就含了执行SQL语句的所有方法。(通过这个对象调用CRUD方法)。
④操作数据库的接口,Executor执行器,同时负责查询缓存的维护。(JDBC底层数据库连接)。
⑤Executor接口的执行方法中有一个MappedStatement类型的参数,封装了映射信息。(接受的参数映射和返回的结果映射)。
⑥输入参数映射。
⑦输出结果映射。
12.Mybatis是否支持延迟加载?
延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。
Mybatis支持一对一关联对象和一对多关联集合对象的延迟加载
在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false,默认是关闭的。
13.延迟加载的底层原理知道吗?
延迟加载在底层主要使用的CGLIB动态代理完成的
-
第一个是,使用CGLIB创建目标对象的代理对象,这里的目标对象就是开启了延迟加载的mapper
-
第二个是当调用目标方法时,进入拦截器invoke方法,发现目标方法是null值,再执行sql查询
-
第三个是获取数据以后,调用set方法设置属性值,再继续查询目标方法,就有值了
13.MyBatis的一级缓存、二级缓存用过吗?
本地缓存基于PerpetualCache,本质是一个HashMap。
- 一级缓存:作用域是session级别。
- 二级缓存:作用域是namespace和mapper的作用域,不依赖于session。
一级缓存: sqlSession级别
mybatis的一级缓存: 基于 PerpetualCache
的 HashMap
本地缓存,其存储作用域为 Session,当Session进行 flush
或 close
之后,该Session中的所有Cache就将清空,默认打开一级缓存。
相同的sqlSession我们发现只进行了一次SQL查询!!!
两个不同的sqlSession我们发现会执行两次SQL
二级缓存:
mybatis的二级缓存是基于namespace
和mapper
的作用域起作用的,不是依赖于SQL session,默认也是采用 PerpetualCache,HashMap 存储。如果想要开启二级缓存需要在全局配置文件和映射文件中开启配置才行。
结论: 开启二级缓存之后,不同的sqlSession对象只要在同一个namespace空间下我们就只需要进行一次SQL查询就好了。
15.Mybatis的二级缓存什么时候会清理缓存中的数据
- 对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear。
- 二级缓存需要缓存的数据 实现Serializable 接口。
- 只有会话提交或者关闭以后,一级缓存中的数据才会转移到二级缓存中。
(五)、微服务
1. Spring Cloud 5大组件有哪些?
早期我们一般认为的Spring Cloud五大组件是
- Eureka : 注册中心
- Ribbon : 负载均衡
- Feign : 远程调用
- Hystrix : 服务熔断
- Zuul/Gateway : 网关
随着SpringCloudAlibba在国内兴起 , 我们项目中使用了一些阿里巴巴的组件
-
注册中心/配置中心 Nacos
-
负载均衡 Ribbon
-
服务调用 Feign
-
服务保护 sentinel
-
服务网关 Gateway
2.服务注册和发现是什么意思?Spring Cloud 如何实现服务注册发现?
服务注册和发现是什么意思?
注册中心主要三块大功能: 分别是服务注册
、服务发现
、服务状态监控
。
常见的注册中心: eureka、nacos、zookeeper。
-
服务注册:服务提供者需要把自己的信息注册到eureka,由eureka来保存这些信息,比如服务名称、ip、端口等等
-
服务发现:消费者向eureka拉取服务列表信息,如果服务提供者有集群,则消费者会利用负载均衡算法,选择一个发起调用
-
服务监控:服务提供者会每隔30秒向eureka发送心跳,报告健康状态,如果eureka服务90秒没接收到心跳,从eureka中剔除
SpringCloud如何进行服务注册与发现的
3. 我看你之前也用过nacos、你能说下nacos与eureka的区别?
Nacos的工作流程
共同点
Nacos与eureka都支持服务注册和服务拉取,都支持服务提供者心跳方式做健康检测
Nacos与Eureka的区别
-
①Nacos支持服务端主动检测提供者状态:
临时实例采用心跳模式,非临时实例采用主动检测模式
。 -
②临时实例心跳
不正常会被剔除
,非临时实例则不会被剔除
。 -
③Nacos支持服务列表变更的消息推送模式,服务列表更新更及时。
-
④Nacos集群默认采用AP方式,当集群中存在非临时实例时,采用CP模式;Eureka采用AP方式。
4.你们项目负载均衡如何实现的 ?
在服务调用过程中的负载均衡一般使用SpringCloud的Ribbon 组件实现 , Feign的底层已经自动集成了Ribbon , 使用起来非常简单。
当发起远程调用时,Ribbon先从注册中心拉取服务地址列表,然后按照一定的路由策略选择一个发起远程调用,一般的调用策略是轮询。
5.Ribbon负载均衡策略有哪些 ?
-
RoundRobinRule
:简单轮询服务列表来选择服务器 -
WeightedResponseTimeRule
:按照权重来选择服务器,响应时间越长,权重越小 -
RandomRule
:随机选择一个可用的服务器 -
ZoneAvoidanceRule
:区域敏感策略,以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询 (默认)。 -
BestAvailableRule
: 忽略那些短路的服务器,并选择并发数较低的服务器。 -
RetryRule
:重试机制的选择逻辑
6.如果想自定义负载均衡策略如何实现 ?
提供了两种方式:
第一种: 创建类实现IRule接口,可以指定负载均衡策略,这个是全局
的,对所有的远程调用都起作用。(全部)
第二种: 在客户端的配置文件中,可以配置某一个服务调用的负载均衡策略,只是对配置的这个服务生效远程调用。(单个)
7.什么是服务雪崩,怎么解决这个问题?
服务雪崩是指一个服务失败,导致整条链路的服务都失败的情形。
两种解决方案:
一般我们在项目解决的话就是两种方案。
- 第一个是服务降级、熔断。(解决)
- 第二个是限流。(预防不能解决)
服务降级
服务降级:服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与feign接口整合,编写降级逻辑。
服务熔断
Hystrix 熔断机制,用于监控微服务调用情况,默认是关闭的。如果需要开启需要在引导类上添加注解:@EnableCircuitBreaker
如果检测到 10 秒内请求的失败率超过50%
,就触发熔断机制。之后每隔5秒
重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求。
当服务降级之后才会触发服务熔断!!!!
触发机制: 当服务降级后,10秒内请求的失败率超过50%就会触发熔断机制。
8.你们的微服务是怎么监控的?
为什么需要进行监控?
- 问题定位。(如何定位到出故障的微服务)
- 性能分析。(如何定位到慢服务的性能)
- 服务关系。(如何维护服务与服务之间的关系)
- 服务告警。(服务出问题了,自动报警)
使用skywalking工具进行监控
一个分布式系统的应用程序性能监控工具(Application Performance Managment
),提供了完善的链路追踪能力apache的顶级项目(前华为产品经理吴晟主导开源)。
1,skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中哪些服务和接口比较慢,我们可以针对性的分析和优化。
2,我们还在skywalking设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发短信和发邮件,第一时间知道项目的bug情况,第一时间修复。
9.你们项目中有没有做过限流 ? 怎么做的 ?
为什么要进行限流?
- 并发的确大(突发流量抢票)
- 防止用户恶意刷接口
限流的方式
- Tomcat:可以设置最大连接数
- Nginx,漏桶算法
- 网关,令牌桶算法
- 自定义拦截器
Nginx: 漏桶算法
Nginx第一种方式: 使用控制速率的方式进行限流
我们当时采用的nginx限流操作,nginx使用的漏桶算法来实现过滤,让请求以固定的速率处理请求,可以应对突发流量,我们控制的速率是按照ip进行限流,限制的流量是每秒10个请求被处理。
不管多大的流量全部放到桶里,只有一个口开着
- 语法: limit req zone key zone ratekey:定义限流对象。
- binary_remote _addr就是一种key,基于客户端ip限流。
- Zone:定义共享存储区来存储访问信息,10m可以存储16wip地址访问信。
- Rate:最大访问速率,rate=10r/s表示每秒最多请求10个请求。
- burst=20:相当于桶的大小。(最多存20个请求)
- Nodelay:快速处理。(如果有多的请求超过桶容量,进行快速抛弃)
Nginx第二种方式: 控制并发连接数限流
- limit_conn perip 20: 对应的key是 $binary_remote_addr,表示限制单个IP同时最多能持有20个连接。
- limit conn perserver 100: 对应的key是$server name,表示虚拟主机(server)同时能处理并发连接的总数。
网关限流: 令牌桶算法
我们当时采用的是spring cloud gateway中支持局部过滤器RequestRateLimiter来做限流,使用的是令牌桶算法,可以根据ip或路径进行限流,可以设置每秒填充平均速率,和令牌桶总容量。
- key-resolver:定义限流对象(ip、路径、参数),需代码实现,使用spel表达式获取。
- replenishRate:令牌桶每秒填充平均速率。
- urstCapacity:令牌桶总容量。
10.限流常见的算法有哪些呢?
比较常见的限流算法有漏桶算法和令牌桶算法。
-
漏桶算法是把请求存入到桶中,以固定速率从桶中流出,可以让我们的服务做到绝对的平均,起到很好的限流效果。
-
令牌桶算法在桶中存储的是令牌,按照一定的速率生成令牌,每个请求都要先申请令牌,申请到令牌以后才能正常请求,也可以起到很好的限流作用。
它们的区别是: 漏桶和令牌桶都可以处理突发流量,其中漏桶可以做到绝对的平滑,令牌桶有可能会产生突发大量请求的情况
。一般nginx限流采用的漏桶,spring cloud gateway中可以支持令牌桶算法。
11.什么是CAP理论?
CAP主要是在分布式项目下的一个理论。包含了三项: 一致性
、可用性
、分区容错性
。
-
一致性(Consistency)是指更新操作成功并返回客户端完成后,
所有节点在同一时间的数据完全一致
(强一致性),不能存在中间状态。
-
可用性(Availability) 是指系统提供的
服务必须一直处于可用
的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。
-
分区容错性(Partition tolerance) 是指分布式系统在遇到任何网络分区故障时,仍然需要能够
保证对外提供满足一致性和可用性
的服务,除非是整个网络环境都发生了故障。
一致性: 当网络崩溃之后,我么可以选择等一会等到网络恢复后在进行处理节点三,但是这样就会违背可用性。
可用性:当网络崩溃之后,我们可以强制选择可用性,将节点1和节点2等正常的网络进行更改操作(新数据),不正常的节点3不进行更改操作(旧数据),这样就违背一致性了。
结论: 在分区容错性下只能由 AP 和 CP 供选择
- 分布式系统节点之间肯定是需要网络连接的,分区P是必然存在的.
- 如果保证访问的高可用性(A),可以持续对外提供服务,但不能保证数据的强一致性–> AP
- 如果保证访问的数据强一致性C,就要放弃高可用性–> CP
12.什么是BASE理论?
BASE是CAP理论中AP方案的延伸,核心思想是即使无法做到强一致性(StrongConsistency,CAP的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性(Eventual Consitency)。它的思想包含三方面:
-
Basically Available(基本可用):基本可用是指分布式系统在
出现不可预知的故障的时候,允许损失部分可用性,保证核心可用
但不等于系统不可用。 -
Soft state(软状态):即是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即
允许系统在不同节点的数据副本之间进行数据同步的过程存在延时
。 -
Eventually consistent(最终一致性):强调系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。其本
质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性
。
13.你们采用哪种分布式事务解决方案?
Seata架构
- TC(Transaction Coordinator)-事务协调者: 维护全局和分支事务的状态,协调全局事务提交或回滚.
- TM (Transaction Manager)- 事务管理器: 定义全局事务的范围、开始全局事务、提交或回滚全局事务.
- RM (Resource Manager)- 资源管理器: 管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
seata的AT模型分为两个阶段:
-
阶段一RM的工作:① 注册分支事务 ② 记录undo-log(数据快照)③ 执行业务sql并提交 ④报告事务状态。
-
阶段二提交时RM的工作:删除undo-log即可。
-
阶段二回滚时RM的工作:根据undo-log恢复数据到更新前。
at模式牺牲了一致性,保证了可用性,不过,它保证的是最终一致性。
14.分布式服务的接口幂等性如何设计?
幂等: 多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。
使用场景
- 用户重复点击(网络波动)
- MQ消息重
- 复应用使用失败或超时重试机制
防止重复提交:
接口幂等
基于RESTfuI API的角度对部分常见类型请求的幂等性特点进行分析。
如果是新增或者更新操作不是幂等的
token+redis 进行设计接口幂等性 ⭐
第一次请求,也就是用户打开了商品详情页面,我们会发起一个请求,在后台生成一个唯一token存入redis,key就是用户的id,value就是这个token,同时把这个token返回前端。
第二次请求,当用户点击了下单操作会后,会携带之前的token,后台先到redis进行验证,如果存在token,可以执行业务,同时删除token;如果不存在,则直接返回,不处理业务,就保证了同一个token只处理一次业务,就保证了幂等性。
分布式锁进行设计接口幂等性设计 ⭐
15.你们项目中使用了什么分布式任务调度
xx1-job解决的问题
- 解决集群任务的重复执行问题
- cron表达式定义灵活
- 定时任务失败了,重试和统计
- 任务量大,分片执行
16.xxl-job路由策略有哪些?
xxl-job提供了很多的路由策略,我们平时用的较多就是:轮询、故障转移、分片广播…
17.xxl-job任务执行失败怎么解决?
有这么几个操作
-
第一:路由策略选择故障转移,优先使用健康的实例来执行任务。
-
第二: 如果还有失败的,我们在创建任务时,可以设置重试次数。
-
第三: 如果还有失败的,就可以查看日志或者配置邮件告警来通知相关负责人解决。
18.如果有大数据量的任务同时都需要执行,怎么解决?
我们会让部署多个实例,共同去执行这些批量的任务,其中任务的路由策略是分片广播
在任务执行的代码中可以获取分片总数和当前分片,按照取模的方式分摊到各个实例执行就可以了。
(六)、集合
- ArrayList底层实现是数组
- LinkedList底层实现是双向链表
- HashMap的底层实现使用了众多数据结构,包含了数组、链表、散列表、红黑树等。
1.为什么要进行复杂度分析?
-
指导你编写出性能更优的代码
-
评判别人写的代码的好坏
2.什么是时间复杂度?
- 案例
时间复杂度分析:简单来说就是评估代码的执行耗时
的,大家还是看刚才的代码:
/**
** *求**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;
}
分析这个代码的时间复杂度,分析过程如下:
-
假如每行代码的执行耗时一样:1ms
-
分析这段代码总执行多少行?3n+3
-
代码耗时总时间: T(n) = (3n + 3) * 1ms
- T(n):就是代码总耗时
我们现在有了总耗时,需要借助大O表示法来计算这个代码的时间复杂度。
- 大O表示法
大O表示法:不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。
刚才的代码示例总耗时公式为:T(n) = (3n + 3) * 1ms
其中 (3n + 3) 是代码的总行数,每行执行的时间都一样,所以得出结论:
T(n)与代码的执行次数成正比(代码行数越多,执行时间越长)
不过,大O表示法只需要代码执行时间与数据规模的增长趋势,公式可以简化如下:
T(n) =O(3n + 3)------------> T(n) = O(n)
当n很大时,公式中的
低阶
,常量
,系数
三部分并不左右其增长趋势。因此可以忽略,我们只需要记录一个最大的量级就可以了
下图也能表明数据的趋势
- 常见复杂度表示形式
速记口诀:常对幂指阶
越在上面的性能就越高,越往下性能就越低
下图是一些比较常见时间复杂度的时间与数据规模的趋势:
- 时间复杂度O(1)
常量可以忽略不记,所以可以写成1
。
一句话总结:只要代码的执行时间不随着n的增大而增大,这样的代码复杂度都是O(1)。
- 时间复杂度O(n)
- 时间复杂度O(logn)
- 时间复杂度O(n * log n)
3.什么是空间复杂度?
空间复杂度全称是渐进空间复杂度
,表示算法占用的额外存储空间与数据规模之间的增长关系
看下面代码:
我们常见的空间复杂度就是 O(1),0(n),0(n^2),其他像对数阶的复杂度几乎用不到,因此空间复杂度比时间复杂度分析要简单的多。
4.什么是数组?
数组(Array)是一种用连续的内存空间存储相同数据类型数据的线性数据结构。
int[] array = {22,33,88,66,55,25};
5.数组如何获取其他元素的地址值?
在数组在内存中查找元素的时候,是有一个寻址公式的,如下:
arr[i] = baseAddress + i * dataTypeSize
-
baseAddress
:数组的首地址,目前是10。 -
dataTypeSize
:代表数组中元素类型的大小,目前数组重存储的是int型的数据,dataTypeSize=4个字节。 -
arr
:指的是数组。 -
i
:指的是数组的下标。
6.为什么数组索引从0开始? 从1开始不行吗?
- 在根据数组索引获取元素的时候,会用索引和寻址公式来计算内存所对应的元素数据,寻址公式是:
数组的首地址+索引乘以存储数据的类型大小
。 - 如果数组的索引从1开始,寻址公式中,就需要增加一次减法操作,对于CPU来说就多了一次指令,性能不高。(需要进行额外的进行减法)。
7.查找数组的时间复杂度
- 随机查询(根据索引查询)
数组元素的访问是通过下标来访问的,计算机通过数组的首地址和寻址公式能够很快速的找到想要访问的元素。
- 未知索引查询O(n)或O(log2n)
情况一: 查找数组内的元素,查找55号数据,遍历数组时间复杂度为O(n)
情况二:查找排序后数组内的元素,通过二分查找算法查找55号数据时间复杂度为O(logn)
8.插入/删除数组的时间复杂度
数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变的很低。
假设数组的长度为 n,现在如果我们需要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来给新来的数据,我们需要将第 k~n 这部分的元素都顺序地往后挪一位
。如下图所示:
最好的情况下是插入或删除最后一个元素!!!
。
总结:
插入操作和删除操作,最好情况下是O(1)的,最坏情况下是O(n)的,平均情况下的时间复杂度是O(n)。
9.ArrayList源码分析
分析ArrayList源码主要从三个方面去翻阅:成员变量,构造函数,关键方法。
- 成员变量
-
DEFAULT_CAPACITY = 10; 默认初始的容量(10)
-
EMPTY_ELEMENTDATA = {}; 用于空实例的共享空数组实例
-
DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};用于默认大小的空实例的共享空数组实例
-
Object[] elementData; 存储元素的数组缓冲区
-
int size; ArrayList的大小(它包含的元素数量)
- 构造函数
-
第一个构造是带初始化容量的构造函数,可以按照指定的容量初始化数组
-
第二个是无参构造函数,默认创建一个空集合
- 将collection对象转换成数组,然后将数组的地址的赋给elementData。
10.添加和扩容操作(第1次添加数据)
什么时候触发扩容机制?
- 当第一次添加数据的时候,初始化为10。
- 当添加的长度等于数组的长度的时候,那么就会进行扩容为原来容量的1.5倍。
11.ArrayList底层的实现原理是什么?
-
底层数据结构: ArrayList底层是用动态的数组实现的。
-
初始容量: ArrayList 初始容量为0,当第一次添加数据的时候才会初始化容量为10。
-
扩容逻辑: ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组。
-
添加逻辑
-
确保数组已使用长度(
size+1
)之后足够存下下一个数据。 -
计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(
原来的1.5倍
)。 -
确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
-
返回添加成功布尔值。
-
12.ArrayList list=new ArrayList(10)中的list扩容几次?
该语句只是声明和实例了一个 ArrayList,只是指定了容量为 10,未扩容。
13.如何实现数组和List之间的转换?
-
数组转List ,使用JDK中
java.util.Arrays
工具类的asList
方法。 -
List转数组,使用List的
toArray
方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组。
14.用Arrays.asList转List后,如果修改了数组内容,list受影响吗?
Arrays.asList转换list之后,如果修改了数组的内容,list会受影响。因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合
。在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址。
15.List用toArray转数组后,如果修改了List内容,数组受影响吗?
list用了toArray转数组后,如果修改了list内容,数组不会影响。当调用了toArray以后,在底层是它是进行了数组的拷贝
,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响。
16.什么是单向链表?
-
链表中的每一个元素称之为
结点
(Node)。 -
物理存储单元上
非连续、非顺序
的存储结构。 -
单向链表:每个结点包括两个部分:
- 一个是存储数据元素的数据域。
- 另一个是存储下一个结点地址的指针域。
- 记录下个结点地址的指针叫作
后继指针 next
17.单向链表时间复杂度分析?
查询操作
-
只有在查询头节点的时候不需要遍历链表,时间复杂度是O(1)
-
查询其他结点需要遍历链表,时间复杂度是O(n)
插入和删除操作
- 只有在添加和删除头节点的时候不需要遍历链表,时间复杂度是O(1)
- 添加或删除其他结点需要遍历链表找到对应节点后,才能完成新增或删除节点,时间复杂度是O(n)
18.什么是 双向链表?
而双向链表,顾名思义,它支持两个方向
-
每个结点不仅有一个后继指针 next 指向后面的结点
-
每个节点还有一个前驱指针 prev 指向前面的结点
对比单链表:
-
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。
-
支持双向遍历,这样也带来了双向链表操作的灵活性。
19.双向链表时间复杂度分析
查询操作
-
查询头尾结点的时间复杂度是O(1)
-
平均的查询时间复杂度是O(n)
-
给定节点找前驱节点的时间复杂度为O(1)
增删操作
-
头尾结点增删的时间复杂度为O(1)
-
其他部分结点增删的时间复杂度是 O(n)
-
给定节点增删的时间复杂度为O(1)
20.ArrayList和LinkedList的区别是什么?
-
底层数据结构
-
ArrayList 是动态数组的数据结构实现
-
LinkedList 是==双向链表的数据结构实现
-
-
操作数据效率
- arrayList: 查询快,增删慢。
- LinkedList: 增删快,查询慢。
-
内存空间占用
-
ArrayList底层是数组,内存连续,节省内存。
-
LinkedList 是双向链表需要存储数据,和两个指针,更占用内存。
-
-
线程安全
- ArrayList和LinkedList都不是线程安全的
- 如果需要保证线程安全,有两种方案:
- 在方法内使用,局部变量则是线程安全的
- 使用线程安全的ArrayList和LinkedList
21.什么是二叉树?
二叉树: 每个节点最多有两个 “叉",也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
- 二叉树每个节点的左子树和右子树也分别满足二叉树的定义。
22.Java实现二叉树有几种方式?
java中有两个方式实现二叉树:数组存储,链式存储。
基于链式存储实现的二叉树
23.基于二叉树演变的树有哪些?
二叉树中,比较常见的有:
-
满二叉树: 除最后一层外,每一层上的所有结点都有两个子结点。
-
完全二叉树: 完全二叉树就和名字有点不太一样了,除最后一层节点外,其他层节点都必须要有两个子节点,并且最后一层节点都要
左排列
。 -
二叉搜索树: 二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
-
红黑树:
24.什么是二叉搜索树?
二叉搜索树(Binaxy Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
25.二叉搜索树的时间复杂度分析?
实际上由于二叉查找树的形态各异,时间复杂度也不尽相同,我画了几棵树我们来看一下插入,查找,删除的时间复杂度。
插入,查找,删除的时间复杂度O(logn)
极端情况下二叉搜索的时间复杂度
对于图中这种情况属于最坏的情况,二叉查找树已经退化成了链表,左右子树极度不平衡,此时查找的时间复杂度肯定是O(n)。
26.什么是红黑树?
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)。
红黑树的特质
- 性质1: 节点要么是
红色
,要么是黑色
。 - 性质2: 根节点是
黑色
。 - 性质3: 叶子节点都是
黑色的空节点
。 - 性质4: 红黑树中
红色节点的子节点都是黑色
- 性质5: 从
任一节点到叶子节点的所有路径都包含相同数目的黑色节点
。
黑路同,根叶黑,不红红
27.红黑树的时间复杂度
-
查找:
- 红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为:O(log n)
-
添加:
- 添加先要从根节点开始找到元素添加的位置,时间复杂度O(log n)
- 添加完成后涉及到复杂度为O(1)的旋转调整操作
- 故整体复杂度为:O(log n)
-
删除:
- 首先从根节点开始找到被删除元素的位置,时间复杂度O(log n)
- 删除完成后涉及到复杂度为O(1)的旋转调整操作
- 故整体复杂度为:O(log n)
28. 什么是散列表
在HashMap中的最重要的一个数据结构就是散列表,在散列表中又使用到了红黑树和链表。
散列表(Hash Table)又名哈希表
/Hash表,是根据键(Key)直接访问
在内存存储位置值(Value)
的数据结构,它是由数组演化而来
的,利用了数组支持按照下标进行随机访问数据的特性。
假设有100个人参加马拉松,编号是1-100,如果要编程实现根据选手的编号迅速找到选手信息?
可以把选手信息存入数组中,选手编号就是数组的下标,数组的元素就是选手的信息。
当我们查询选手信息的时候,只需要根据选手的编号到数组中查询对应的元素就可以快速找到选手的信息,如下图:
数组可以实现数字编号的存储:
现在需求升级了:
假设有100个人参加马拉松,不采用1-100的自然数对选手进行编号,编号有一定的规则比如:2023ZHBJ001,其中2023代表年份,ZH代表中国,BJ代表北京,001代表原来的编号,那此时的编号2023ZHBJ001不能直接作为数组的下标,此时应该如何实现呢?
我们目前是把选手的信息存入到数组中,不过选手的编号不能直接作为数组的下标。不过,可以把选手的选号进行转换,转换为数值就可以继续作为数组的下标了?
转换可以使用散列函数进行转换
29.散列函数和散列冲突
将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
散列函数的基本要求:
-
散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
-
如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)
-
如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)
散列冲突
散列冲突: 就是说假如key1不等于key2,那么hash值也有可能会出现一样。如果两个不同的key的hash值是一样的,那么就叫做hash冲突。
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)
30.怎么解决散列冲突?
链表法(拉链法)
在散列表中,数组的每个下标位置我们可以称之为桶(bucket)
或者槽(slot)
,每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
31.散列表的时间复杂度
-
插入操作,通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,插入的时间复杂度是 O(1)
-
当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。
- 平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)
- 散列表可能会退化为链表,查询的时间复杂度就从 O(1) 退化为 O(n)
- 将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)
将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击。
DDos 攻击:
分布式拒绝服务攻击(英文意思是Distributed Denial of Service,简称DDoS)
指处于不同位置的多个攻击者同时向一个或数个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个
32.说一下HashMap的实现原理?
HashMap的数据结构: 底层使用hash表数据结构,即数组
和链表
或红黑树
。
-
当我们往
HashMap
中 put 元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标 -
存储时: 如果出现
hash
值相同的key,此时有两种情况。-
如果key相同,则覆盖原始值。
-
如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中 。
-
-
获取时: 直接找到
hash
值对应的下标,在进一步判断key是否相同,从而找到对应值。
33. HashMap的jdk1.7和jdk1.8有什么区别
-
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
-
jdk1.8在解决哈希冲突时有了较大的变化,当
链表长度大于阈值(默认为8) 时并且数组长度达到64
时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个
,则退化成链表。
34.HashMap的put方法的具体流程
hashMap常见属性
- 默认的初始容量DEFAULT INITIAL CAPACITY (16)
- 默认的加载因子DEFAULT LOAD FACTOR (0.75 )
扩容阈值 = 数组容量 *加载因子
源码分析
-
HashMap是懒惰加载,在创建对象时并没有初始化数组
-
在无参的构造函数中,设置了默认的加载因子是0.75
添加数据流程图
第一次调用put()方法进行添加数据:
第二次以及之后调用put()方法
首先判断数组是否为空,假如为空那么就初始化长度为16;否则就根据key计算数组的索引值。然后根据key计算数组的索引值,假如索引在数组里面没有值,那么就直接进行插入的操作。然后进行 ++size判断是否需要进行扩容处理。
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;
}
-
判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
-
根据键值key计算hash值得到数组索引
-
判断table[i]==null,条件成立,直接新建节点添加
-
如果table[i]==null ,不成立
4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
4.2 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
-
插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
35.HashMap的扩容机制?
第一次扩容:
第二次扩容及以后:
//扩容、初始化数组
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,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
36.hashMap的寻址算法
在putVal方法中,有一个hash(key)方法,这个方法就是来去计算key的hash值的,看下面的代码
首先获取key的hashCode值,然后右移16位 异或运算 原来的hashCode值,主要作用就是使原来的hash值更加均匀,减少hash冲突。
有了hash值之后,就很方便的去计算当前key的在数组中存储的下标,看下面的代码:
hash是hash值,n是2的n次方。
(n-1)&hash
: 得到数组中的索引,按位与运算代替取模,性能更好,数组长度必须是2的n次幂。
37.为何HashMap的数组长度一定是2的次幂?
-
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
-
扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
38.hashmap在1.7情况下的多线程死循环问题
jdk7的的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环。
单线程下无死锁问题:
多线程下出现死锁问题:
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,
所以B->A->B,形成循环。
当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题。
39.HashSet与HashMap的区别
(1)HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对.
(2)HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法. 依靠HashMap来存储元素值,(利用hashMap的key键进行存储), 而value值默认为Object对象. 所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true.
40.HashTable与HashMap的区别
区别 | HashTable | HashMap |
---|---|---|
数据结构 | 数组+链表 | 数组+链表+红黑树 |
是否可以为null | Key和value都不能为null | 可以为null |
hash算法 | key的hashCode() | 二次hash |
扩容方式 | 当前容量翻倍 +1 | 当前容量翻倍 |
线程安全 | 同步(synchronized)的,线程安全 | 非线程安全 |
在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类。
(七)、线程
1.线程和进程的区别?
程序由指令和数据组成。程序要运行,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
一个进程之内可以分为一到多个线程。
一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行。
Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows 中进程是不活动的,只是作为线程的容器。
二者对比
- 进程是正在运行程序的实例,
进程中包含了线程,
每个线程执行不同的任务 - 不同的进程使用不同的内存空间,在当前进程下的
所有线程可以共享内存空间
- 线程更轻量,
线程上下文切换成本一般上要比进程上下文切换低
(上下文切换指的是从一个线程切换到另一个线程)
2.并行和并发有什么区别?
单核CPU
-
单核CPU下线程实际还是串行执行的
-
操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。
-
总结为一句话就是: 微观串行,宏观并行
一般会将这种线程轮流使用CPU的做法称为并发(concurrent)
-
并发(concurrent)是同一时间应对(dealing with)多件事情的能力
-
并行(parallel)是同一时间动手做(doing)多件事情的能力
3.创建线程的四种方式
有四种方式可以创建线程,分别是:继承Thread类
、实现runnable接口
、实现Callable接口
、线程池创建线程
。
实现Callable接口
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("MyCallable...call...");
return "OK";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 创建MyCallable对象
MyCallable mc = new MyCallable() ;
// 创建F
FutureTask<String> ft = new FutureTask<String>(mc) ;
// 创建Thread对象
Thread t1 = new Thread(ft) ;
Thread t2 = new Thread(ft) ;
// 调用start方法启动线程
t1.start();
// 调用ft的get方法获取执行结果
String result = ft.get();
// 输出
System.out.println(result);
}
}
线程池创建
public class MyExecutors implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建线程池对象
ExecutorService threadPool = Executors.newFixedThreadPool(3);
threadPool.submit(new MyExecutors()) ;
// 关闭线程池
threadPool.shutdown();
}
}
4. runnable 和 callable 有什么区别
- Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果.
- Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
- Callable接口的call()方法允许上抛异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛。
5.线程的 run()和 start()有什么区别?
-
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。①start方法只能被调用一次,如果调用多次会报错。②会开启一个新的线程
-
run(): 封装了要被线程执行的代码,①可以被调用多次,②但实际上都是主线程在调用不会开启一个新的线程。
6.线程包括哪些状态,状态之间是如何变化的?
分别是
- 新建
- 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
- 此时未与操作系统底层线程关联
- 可运行
- 调用了 start 方法,就会由新建进入可运行
- 此时与底层线程关联,由操作系统调度执行
- 终结
- 线程内代码已经执行完毕,由可运行进入终结
- 此时会取消与底层线程关联
- 阻塞
- 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
- 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
- 等待
- 当获取锁成功后,但由于条件不满足,调用了
wait()
方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间 - 当其它持锁线程调用
notify()
或notifyAll()
方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
- 当获取锁成功后,但由于条件不满足,调用了
- 有时限等待
- 当获取锁成功后,但由于条件不满足,调用了
wait(long)
方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间 - 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
- 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
- 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态
- 当获取锁成功后,但由于条件不满足,调用了
7.新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的 join() 方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
代码举例:
为了确保三个线程的顺序,你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成。
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();
}
}
8.notify()和 notifyAll()有什么区别?
-
notifyAll:唤醒所有wait的线程。
-
notify:只随机唤醒一个 wait 线程。
9.在 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,你们也用不了,或造成死锁)
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...");
}
}
}
10.如何停止一个正在运行的线程?
有三种方式可以停止线程
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
- 使用 stop方法 强行终止(不推荐,方法已作废)
- 使用 interrupt 方法中断线程
① 使用退出标志,使线程正常退出。
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方法强行终止
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异常。
- 打断正常的线程,可以根据打断状态来标记是否退出线程
package com.itheima.basic;
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();
}
}
11. 讲一下synchronized关键字的底层原理?
示列
如下抢票的代码,如果不加锁,就会出现超卖或者一张票卖给多个人
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。
ublic class TicketDemo {
static Object lock = new Object();
int ticketNum = 10; // ⭐这里会产生竞态条件
public synchronized 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需要借助javap命令查看clsss的字节码,比如以下代码:
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
,反编译效果如下:
- monitorenter 上锁开始的地方。
- monitorexit 解锁的地方。
- 其中被monitorenter和monitorexit包围住的指令就是上锁的代码
- 有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁。
monitor主要就是跟这个对象产生关联,如下图
Monitor内部具体的存储结构:
-
Owner:存储当前获取锁的线程的,只能有一个线程可以获取
-
EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
-
WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程
具体的流程:
- ①代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否为空
- 如果Owner为空,则让当前线程持有,表示该线程获取锁成功
- 如果Owner不为空,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁(也就是变空),在EntryList中的线程去竞争锁的持有权(非公平)
- 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待。
参考回答:
-
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
-
它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
-
在monitor内部有三个属性,分别是owner、entrylist、waitset
-
其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程。
12.Monitor实现的锁属于重量级锁,你了解过锁升级吗?
-
Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
-
在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
Java对象的内存结构
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和 对齐填充 。
我们需要重点分析MarkWord对象头
MarkWord 对象头
-
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表示重量级锁
13. Monitor重量级锁 (存在竞争)
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
简单说就是:每个对象的对象头都可以设置monoitor的指针,让对象与monitor产生关联
14.轻量级锁 (交替执行,不存在竞争)
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码
。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
static final Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized (obj) {
// 同步块 B
}
}
轻量级锁_加锁的流程
-
在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
-
通过
CAS
指令将Lock Record的地址存储在对象头的mark word中
(数据进行交换),如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
-
如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
-
如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
轻量级锁解锁过程
- 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
- 如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
- 如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。
15.偏向锁 (可以避免轻量级的重复CAS)
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
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) {
}
}
偏向锁的加锁的流程
- 在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
- 通过CAS指令将Lock Record的 线程id 存储在对象头的mark word中,同时也设置偏向锁的标识为101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁。
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。与轻量级锁不同的时,这里不会再次进行cas操作,
只是判断对象头中的线程id是否是自己,因为缺少了cas操作,性能相对轻量级锁更好一些
。
16.重量级锁、轻量级锁、偏向锁总结
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
描述 | |
---|---|
重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性 |
偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令 |
一旦锁发生了竞争,都会升级为重量级锁
17.你谈谈 JMM(Java 内存模型)
JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
- 所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)。
- 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存。
18.CAS 你知道吗?
CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作
-
AbstractQueuedSynchronizer(AQS框架)
-
AtomicXXX类
例子:
我们还是基于刚才学习过的JMM内存模型进行说明:
一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功。
-
线程1与线程2都从主内存中获取变量int a = 100,同时放到各个线程的工作内存中
-
线程1操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 101 (a++)
- 线程1拿A的值与主内存V的值进行比较,判断是否相等
- 如果相等,则把B的值101更新到主内存中
-
线程2操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 99(a- -)
- 线程2拿A的值与主内存V的值进行比较,判断是否相等(目前不相等,因为线程1已更新V的值99)
- 不相等,则线程2更新失败
-
自旋锁操作
-
因为没有加锁,所以线程不会陷入阻塞,效率较高
-
如果竞争激烈,重试频繁发生,效率会受影响
-
-
需要不断尝试获取共享内存V中最新的值(101),然后再在新的值(101)的基础上进行更新操作,如果失败就继续尝试获取新的值,直到比对成功。
19. CAS 底层实现原理?
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令。
都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现。
在java中比较常见使用有很多,比如ReentrantLock
和Atomic
开头的线程安全类,都调用了Unsafe中的方法
- ReentrantLock中的一段CAS代码
20.乐观锁和悲观锁
-
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
-
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
21.请谈谈你对 volatile 的理解
volatile是java提供的最轻量级锁,保证了线程间的可见性,但是禁止了指令重排。
保证线程间的可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
一个典型的例子:永不停止的循环
package com.itheima.basic;
// 可见性例子
// -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优化解决方案:
-
第一种:在程序运行的时候加入vm参数
-Xint
表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用) -
第二种:在修饰
stop
变量的时候加上volatile
,表示当前代码禁用了即时编辑器,问题就可以解决,代码如下:
static volatile boolean stop = false;
禁止进行指令重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。
指令重排序解决方案
在变量上添加volatile,禁止指令重排序,则可以解决问题
-
下面代码使用volatile修饰了y变量
屏障添加的示意图
-
写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
-
读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上
上面可以解决🔼🔼🔼🔼🔼🔼🔼🔼🔼
我们上面的解决方案是把volatile加在了int y这个变量上,我们能不能把它加在int x这个变量上呢?
- 下面代码使用volatile修饰了x变量
屏障添加的示意图
这样显然是不行的,主要是因为下面两个原则: 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上
所以,现在我们就可以总结一个volatile使用的小妙招:
- 写变量让volatile修饰的变量的在代码最后位置
- 读变量让volatile修饰的变量的在代码最开始位置
22.什么是AQS?
全称是 AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架
。
AQS与Synchronized的区别
synchronized | AQS |
---|---|
关键字,c++ 语言实现 | java 语言实现 |
悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供了多种解决方案 |
AQS常见的实现类
-
ReentrantLock 阻塞式锁
-
Semaphore 信号量
-
CountDownLatch 倒计时锁
AQS工作机制?
- 在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,
0表示无锁,1表示有锁
- 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
- 线程0来了以后,去尝试修改state属性,
如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
。 - 线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,
它们都会到FIFO队列中进行等待
。 - FIFO是一个双向队列,head属性表示头结点,tail表示尾结点。
如果多个线程共同去抢这个资源是如何保证原子性的呢?
在去修改state状态的时候,使用的 cas自旋锁来保证原子性
,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待。
AQS是公平锁吗,还是非公平锁?
-
新的线程与队列中的线程共同来抢资源,是非公平锁。
-
新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁。
比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源。
23.ReentrantLock的实现原理
ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
-
可中断:
(打断正在阻塞队列中的线程,让他不再等待,直接放弃锁。LockInterruptibly();)
-
可以设置超时时间:
(Lock实现通过使用非阻塞尝试获取锁,如果获取不到就放弃,不进入阻塞队列。tryLock())
-
可以设置公平锁
-
支持多个条件变量
-
与synchronized一样,都支持重入。
实现原理
ReentrantLock主要利用 CAS+AQS
队列来实现。它支持公平锁和非公平锁,两者的实现类似。
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
查看ReentrantLock源码中的构造方法:
-
提供了两个构造方法,不带参数的默认为非公平。
-
如果使用带参数的构造函数,并且传的值为true,则是公平锁。
其中NonfairSync和FairSync这两个类父类都是Sync
而Sync的父类是AQS
,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的。
工作流程
底层和AQS一样
-
线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让
exclusiveOwnerThread (类似于owner)属性指向当前线程
,获取锁成功。 -
假如修改状态失败,则会进入双向队列中等待,
head指向双向队列头部,tail指向双向队列尾部
。 -
当 exclusiveOwnerThread 为 null 的时候,则
会唤醒在双向队列中等待的线程
。 -
公平锁则体现在按照
先后顺序获取锁
,非公平体现在不在排队的线程也可以抢锁
。
24.synchronized和Lock有什么区别 ?
语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现。
- Lock 是接口,源码由 jdk 提供,用 java 语言实现。
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁。
功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能。
- Lock 提供了许多 synchronized 不具备的功能,例如获取
等待状态、公平锁、可打断(打断正在阻塞队列中的线程,让他不再等待,直接放弃锁,LockInterruptibly())、可超时(Lock实现通过使用非阻塞尝试获取锁,如果获取不到就放弃,不进入阻塞队列,trylock())、多条件变量
。 - Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock。
性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖。
- 在竞争激烈时,Lock 的实现通常会提供更好的性能。
25.死锁产生的条件是什么?
死锁:一个线程需要同时获取多把锁,这时就容易发生死锁。
26.如何诊断死锁?
当程序出现了死锁现象,我们可以使用jdk自带的工具:jps
+ jstack
,和可视化工具 jconsole
。
第一种: jps+jstack
第一:查看运行的线程
第二:使用jstack查看线程运行的情况,下图是截图的关键信息运行。
命令:jstack -l 46032
第二种: 可视化工具 jconsole
- jconsole
用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行
第三种: VisualVM
- VisualVM:故障处理工具
能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行
27.什么是ConcurrentHashMap ?
ConcurrentHashMap 是一种线程安全的高效Map集合
底层数据结构:
-
JDK1.7底层采用分段的数组+链表实现。底层采用分段锁,性能比较低。
-
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。底层优化成了行锁,锁粒度低,性能比较好。
JDK1.7中concurrentHashMap
数据结构:
- 提供了一个
segment
数组,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容。 - 在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的。
- 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表。
存储流程
- 先去计算key的hash值,然后
确定segment数组下标
。 - 再通过hash值确定
hashEntry数组中的下标存储数据
- 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,
为了线程安全使用的是ReentrantLock进行加锁,如果获取锁是被会使用cas自旋锁进行尝试
。
缺点: 当数据插入的时候会锁住一整个 segment 。
JDK1.8中concurrentHashMap
在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表
采用 CAS + Synchronized
来保证并发安全进行实现
-
CAS控制数组节点的添加
-
synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升
28.导致并发程序出现问题的根本原因是什么
Java并发编程三大特性
-
原子性: (一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行)。
-
可见性: (让一个线程对共享变量的修改对另一个线程可见)。
-
有序性: (不让JIT进行优化的操作)。
原子性
一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行。
比如,如下代码能保证原子性吗?
以上代码会出现超卖或者是一张票卖给同一个人,执行并不是原子性的。
解决方案:
1.synchronized:同步加锁
2.JUC里面的lock:加锁
可见性
内存可见性:让一个线程对共享变量的修改对另一个线程可见。
比如,以下代码不能保证内存可见性
解决方案:
-
synchronized
-
volatile(推荐)
-
LOCK
有序性
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
还是之前的例子,如下代码:
解决方案:
- volatile
29.说一下线程池的核心参数(线程池的执行原理知道嘛)
线程池的核心参数
线程池核心参数主要参考ThreadPoolExecutor这个类的7个参数的构造函数。
-
corePoolSize 核心线程数目。 (
线程池中主要执行任务的数量
) -
maximumPoolSize 最大线程数目 =
(核心线程+救急线程的最大数目
) -
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放。(
存活时间
) -
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等。
-
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务。(
任务队列
) -
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等。(
工厂
) -
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略。(任务队列满后如何操作)
线程池的执行原理
-
任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行。
-
如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列。
-
如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行溢出队列的任务。
-
如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务。
-
如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略。
import lombok.extern.log4j.Log4j;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.FutureTask;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author Jsxs
* @Date 2024/3/26 20:01
* @PackageName:PACKAGE_NAME
* @ClassName: test
* @Description: TODO
* @Version 1.0
*/
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);
}
}
拒绝策略:
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务,假如溢出队列的任务大于救急线程数就会触发拒绝策略。
-
AbortPolicy:直接抛出异常,默认策略;
-
CallerRunsPolicy:用调用者所在的线程来执行任务;(主线程)
-
DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;(顶替队列第一个)
-
DiscardPolicy:直接丢弃任务;(抛弃)
30.线程池中有哪些常见的阻塞队列?
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务。
比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue
-
ArrayBlockingQueue
:基于数组结构的有界阻塞队列,FIFO。 -
LinkedBlockingQueue
:基于链表结构的有界阻塞队列,FIFO。 -
DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的。(短作业优先)。
-
SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。(一手交线程,一手交任务)。
ArrayBlockingQueue的LinkedBlockingQueue区别
- 无界: 就是是否可以指定容量
- 有界: 就是指可以通过构造函数可以设置容量大小。
LinkedBlockingQueue | ArrayBlockingQueue |
---|---|
默认无界,支持有界 | 强制有界 |
底层是链表 | 底层是数组 |
是懒惰的,创建节点的时候添加数据 | 提前初始化 Node 数组 |
入队会生成新 Node | Node需要是提前创建好的 |
左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式
- LinkedBlockingQueue读和写各有一把锁,性能相对较好。
- ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些。
31.如何确定核心线程数?
在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型。
IO密集型任务: (读写多)
一般来说:文件读写、DB读写、网络请求等
推荐:核心线程数大小设置为 2N+1 (N为计算机的CPU核数)
CPU密集型任务 (计算多)
一般来说:计算型代码、Bitmap转换、Gson转换等
推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)
32.线程池的种类有哪些?
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种。
固定大小线程池
-
核心线程数与最大线程数一样,没有救急线程。
-
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE。
-
适用场景:
适用于任务量已知,相对耗时的任务
。
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
-
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
-
适用场景:适用于按照顺序执行的任务
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();
}
}
- 可缓存线程池
创建多个线程,会占用大量内存。假如发现已经存在线程在存活时间内是空闲的才会去复用这个线程,否则不会复用,而是创建新的线程去执行。
-
核心线程数为0
-
最大线程数是Integer.MAX_VALUE,也就是救急线程为最大线程。
-
阻塞队列为SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
-
适用场景:适合任务数比较密集,但每个任务执行时间较短的情况。
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();
}
}
- 定时线程池
提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
适用场景:有定时和延迟执行的任务
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();
}
}
33.为什么不建议用Executors创建线程池
参考阿里开发手册《Java开发手册-嵩山版》
34.线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)
CountDownLatch
CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行
)。王者荣耀10人游戏,全部同时加载完毕之后才能进去。
-
其中构造参数用来初始化等待计数值
-
await() 用来等待计数归零
-
countDown() 用来让计数减一
案例代码:
设置一局游戏有三个人,当三个人全部都加载完毕之后。主线程才会继续运行,游戏才会开始。
import java.util.concurrent.CountDownLatch;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
//初始化了一个倒计时锁 参数为 3 ⭐
CountDownLatch latch = new CountDownLatch(3);
// 1.线程1
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();
// 2.线程2
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();
// 3.线程3
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...");
}
}
Future
Future是一个接口,它提供了异步计算结果的功能,允许用户检查计算是否已完成,等待计算结果,并在计算完成后获取结果,它还提供了取消任务执行的方法
,但一旦计算完成,结果就不能被取消。
FutureTask
FutureTask实现了Runnabl和Future接口,这意味着它可以作为Runnable被线程执行。也可以作为Future使用,获取Callable任务的返回结果,FutureTask提供了检查任务是否已完成、取消任务执行以及获取结果的方法
。与Future相比,FutureTask可以直接创建对象,而Future需要配合线程池使用
。
FutureTask可用于异步获取执行结果或取消执行任务的场景。通过传入Runnable或者Callable的任务给FutureTask,直接调用其run方法
或者放入线程池
执行,之后可以在外部通过FutureTask的get方法异步获取执行结果,因此,FutureTask非常适合用于耗时的计算,主线程可以在完成自己的任务后,再去获取结果(调用get()就会等待任务类似于join())。另外,FutureTask还可以确保即使调用了多次run方法,它都只会执行一次Runnable或者Callable任务,或者通过cancel取消FutureTask的执行等
35.真实案列_CountDownLatch (es数据批量导入)
在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制就能避免一次性加载过多,防止内存溢出。
整体流程就是通过CountDownLatch+线程池配合去执行
详细实现流程:
- 首先进行分页,一页2000条数据。将2000存入到我们的CountDownLatch中。
- 分页查询文章数据。先查询当前页的文章,然后创建任务量导入到es,导入一条数据我们执行一次countDown()方法。当2000条数据执行完毕之后,我们进行提交线程。
- 当然在分页查询数据的时候,我们需要调用await()方法。
代码实现
线程池配置: ThreadPoolConfig
package com.itheima.cdl.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Configuration
public class ThreadPoolConfig {
/**
* 核心线程池大小 2n+1
*/
private static final int CORE_POOL_SIZE = 17;
/**
* 最大可创建的线程数
*/
private static final int MAX_POOL_SIZE = 50;
/**
* 队列最大长度
*/
private static final int QUEUE_CAPACITY = 1000;
/**
* 线程池维护线程所允许的空闲时间
*/
private static final int KEEP_ALIVE_SECONDS = 500;
@Bean("taskExecutor")
public ExecutorService executorService(){
AtomicInteger c = new AtomicInteger(1);
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>(QUEUE_CAPACITY);
return new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_SECONDS,
TimeUnit.MILLISECONDS,
queue,
r -> new Thread(r, "itheima-pool-" + c.getAndIncrement()),
new ThreadPoolExecutor.DiscardPolicy()
);
}
}
ApArticleServiceImpl
package com.itheima.cdl.service.impl;
import com.alibaba.fastjson.JSON;
import com.itheima.cdl.mapper.ApArticleMapper;
import com.itheima.cdl.pojo.SearchArticleVo;
import com.itheima.cdl.service.ApArticleService;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
@Service
@Transactional
@Slf4j
public class ApArticleServiceImpl implements ApArticleService {
@Autowired
private ApArticleMapper apArticleMapper;
@Autowired
private RestHighLevelClient client;
@Autowired
private ExecutorService executorService;
// 1.ES的索引库
private static final String ARTICLE_ES_INDEX = "app_info_article";
// 2.每页多少条
private static final int PAGE_SIZE = 2000;
/**
* 批量导入
*/
@SneakyThrows
@Override
public void importAll() {
//总条数
int count = apArticleMapper.selectCount();
//总页数
int totalPageSize = count % PAGE_SIZE == 0 ? count / PAGE_SIZE : count / PAGE_SIZE + 1;
//开始执行时间
long startTime = System.currentTimeMillis();
//一共有多少页,就创建多少个CountDownLatch的计数
CountDownLatch countDownLatch = new CountDownLatch(totalPageSize);
int fromIndex;
List<SearchArticleVo> articleList = null;
// 根据总页数进行遍历
for (int i = 0; i < totalPageSize; i++) {
//起始分页条数
fromIndex = i * PAGE_SIZE;
//查询文章
articleList = apArticleMapper.loadArticleList(fromIndex, PAGE_SIZE);
//创建线程,做批量插入es数据操作。 传入2000条数据和countDownLatch
TaskThread taskThread = new TaskThread(articleList, countDownLatch);
//线程池执行线程
executorService.execute(taskThread);
}
//调用await()方法,用来等待计数归零
countDownLatch.await();
long endTime = System.currentTimeMillis();
log.info("es索引数据批量导入共:{}条,共消耗时间:{}秒", count, (endTime - startTime) / 1000);
}
class TaskThread implements Runnable {
List<SearchArticleVo> articleList;
CountDownLatch cdl;
public TaskThread(List<SearchArticleVo> articleList, CountDownLatch cdl) {
this.articleList = articleList;
this.cdl = cdl;
}
@SneakyThrows
@Override
public void run() {
//批量导入
BulkRequest bulkRequest = new BulkRequest(ARTICLE_ES_INDEX);
for (SearchArticleVo searchArticleVo : articleList) {
bulkRequest.add(new IndexRequest().id(searchArticleVo.getId().toString())
.source(JSON.toJSONString(searchArticleVo), XContentType.JSON));
}
//发送请求,批量添加数据到es索引库中
client.bulk(bulkRequest, RequestOptions.DEFAULT);
//让计数减一
cdl.countDown();
}
}
}
36.真实案列_Future多线程 (数据汇总)
在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?
代码实现
线程池配置: ThreadPoolConfig
package com.itheima.cdl.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Configuration
public class ThreadPoolConfig {
/**
* 核心线程池大小 2n+1
*/
private static final int CORE_POOL_SIZE = 17;
/**
* 最大可创建的线程数
*/
private static final int MAX_POOL_SIZE = 50;
/**
* 队列最大长度
*/
private static final int QUEUE_CAPACITY = 1000;
/**
* 线程池维护线程所允许的空闲时间
*/
private static final int KEEP_ALIVE_SECONDS = 500;
@Bean("taskExecutor")
public ExecutorService executorService(){
AtomicInteger c = new AtomicInteger(1);
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>(QUEUE_CAPACITY);
return new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_SECONDS,
TimeUnit.MILLISECONDS,
queue,
r -> new Thread(r, "itheima-pool-" + c.getAndIncrement()),
new ThreadPoolExecutor.DiscardPolicy()
);
}
}
OrderDetailController :处理
package com.itheima.cdl.controller;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
@RestController
@RequestMapping("/order_detail")
@Slf4j
public class OrderDetailController {
@Autowired
private RestTemplate restTemplate;
@Autowired
private ExecutorService executorService;
@SneakyThrows
@GetMapping("/get/detail_new/{id}")
public Map<String, Object> getOrderDetailNew() {
long startTime = System.currentTimeMillis();
// 利用future可以接受线程池和返回Callable的结果
Future<Map<String, Object>> f1 = executorService.submit(() -> {
Map<String, Object> r =
restTemplate.getForObject("http://localhost:9991/order/get/{id}", Map.class, 1);
return r;
});
// 利用future可以接受线程池和返回Callable的结果
Future<Map<String, Object>> f2 = executorService.submit(() -> {
Map<String, Object> r =
restTemplate.getForObject("http://localhost:9991/product/get/{id}", Map.class, 1);
return r;
});
// 利用future可以接受线程池和返回Callable的结果
Future<Map<String, Object>> f3 = executorService.submit(() -> {
Map<String, Object> r =
restTemplate.getForObject("http://localhost:9991/logistics/get/{id}", Map.class, 1);
return r;
});
// 通过外部调用可以获取到值
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("order", f1.get());
resultMap.put("product", f2.get());
resultMap.put("logistics", f3.get());
long endTime = System.currentTimeMillis();
log.info("接口调用共耗时:{}毫秒", endTime - startTime);
return resultMap;
}
// 单线程执行非常耗时间。 ×
@SneakyThrows
@GetMapping("/get/detail/{id}")
public Map<String, Object> getOrderDetail() {
long startTime = System.currentTimeMillis();
Map<String, Object> order = restTemplate.getForObject("http://localhost:9991/order/get/{id}", Map.class, 1);
Map<String, Object> product = restTemplate.getForObject("http://localhost:9991/product/get/{id}", Map.class, 1);
Map<String, Object> logistics = restTemplate.getForObject("http://localhost:9991/logistics/get/{id}", Map.class, 1);
long endTime = System.currentTimeMillis();
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("order", order);
resultMap.put("product", product);
resultMap.put("logistics", logistics);
log.info("接口调用共耗时:{}毫秒", endTime - startTime);
return resultMap;
}
}
在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+Future来提升性能。
报表汇总:
37.真实案列_@Ansy_(异步调用)
在很多APP中我们都很难避免使用搜索组件,在用户输入搜索的内容的时候点击搜索按钮,那么应该查找用户搜索的信息并同时将搜索记录保存下来,保存用户搜索记录不应该耽搁搜索的功能。
代码
线程池配置: ThreadPoolConfig
package com.itheima.cdl.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Configuration
public class ThreadPoolConfig {
/**
* 核心线程池大小 2n+1
*/
private static final int CORE_POOL_SIZE = 17;
/**
* 最大可创建的线程数
*/
private static final int MAX_POOL_SIZE = 50;
/**
* 队列最大长度
*/
private static final int QUEUE_CAPACITY = 1000;
/**
* 线程池维护线程所允许的空闲时间
*/
private static final int KEEP_ALIVE_SECONDS = 500;
@Bean("taskExecutor") // 线程池注入后的名字⭐
public ExecutorService executorService(){
AtomicInteger c = new AtomicInteger(1);
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>(QUEUE_CAPACITY);
return new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_SECONDS,
TimeUnit.MILLISECONDS,
queue,
r -> new Thread(r, "itheima-pool-" + c.getAndIncrement()),
new ThreadPoolExecutor.DiscardPolicy()
);
}
}
ApUserSearchServiceImpl:
package com.itheima.cdl.service.impl;
import com.itheima.cdl.service.ApUserSearchService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class ApUserSearchServiceImpl implements ApUserSearchService {
/**
* 保存搜索历史记录
* @param userId
* @param keyword
*/
@Async("taskExecutor") // ⭐指定用哪一个线程池执行
@Override
public void insert(Integer userId, String keyword) {
//保存用户记录 mongodb或mysql
//执行业务
log.info("用户搜索记录保存成功,用户id:{},关键字:{}",userId,keyword);
}
}
ArticleSearchServiceImpl : 搜索
package com.itheima.cdl.service.impl;
import com.alibaba.fastjson.JSON;
import com.itheima.cdl.service.ApUserSearchService;
import com.itheima.cdl.service.ArticleSearchService;
import com.itheima.cdl.util.ThreadLocalUtil;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.Operator;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
@Slf4j
public class ArticleSearchServiceImpl implements ArticleSearchService {
@Autowired
private RestHighLevelClient client;
private static final String ARTICLE_ES_INDEX = "app_info_article";
private int userId = 1102;
@Autowired
private ApUserSearchService apUserSearchService;
/**
* 文章搜索
* @return
*/
@Override
public List<Map> search(String keyword) {
try {
// 从索引库中读取数据
SearchRequest request = new SearchRequest(ARTICLE_ES_INDEX);
//设置查询条件
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//第一个条件
if(null == keyword || "".equals(keyword)){
// 假如关键字为空,就查询所有
request.source().query(QueryBuilders.matchAllQuery());
}else {
// 假如关键字不为空,就进行关键字过滤
request.source().query(QueryBuilders.queryStringQuery(keyword).field("title").defaultOperator(Operator.OR));
//开启一个新的线程,保存搜索历史 ⭐
apUserSearchService.insert(userId,keyword);
}
//分页
request.source().from(0);
request.source().size(20);
//按照时间倒序排序
request.source().sort("publishTime", SortOrder.DESC);
//搜索
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析结果
SearchHits searchHits = response.getHits();
//获取具体文档数据
SearchHit[] hits = searchHits.getHits();
List<Map> resultList = new ArrayList<>();
for (SearchHit hit : hits) {
//文档数据
Map map = JSON.parseObject(hit.getSourceAsString(), Map.class);
resultList.add(map);
}
return resultList;
} catch (IOException e) {
throw new RuntimeException("搜索失败");
}
}
}
开启异步调用
package com.itheima.cdl;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.client.RestTemplate;
@MapperScan("com.itheima.cdl.mapper")
@SpringBootApplication
@EnableAsync //开启异步调用 ⭐
public class CDLApplication {
public static void main(String[] args) {
SpringApplication.run(CDLApplication.class,args);
}
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
SpringBoot的@Async异步操作详解…
38.如何控制某个方法允许并发访问线程的数量?
Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,我们可以通过其限制执行的线程数量,达到限流的效果。
当一个线程执行时先通过其方法进行获取许可操作,获取到许可的线程继续执行业务逻辑,当线程执行完成后进行释放许可操作,未获取达到许可的线程进行等待或者直接结束。
Semaphore两个重要的方法
-
lsemaphore.acquire()
: 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)。 -
lsemaphore.release()
:释放一个信号量,此时信号量个数+1。
线程任务类:
import java.util.concurrent.Semaphore;
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();
}
}
}
39.谈谈你对ThreadLocal的理解
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本
(也就是说线程与线程之间是相互隔离的)从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享。
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
ThreadLocal基本使用
-
set(value) 设置值。
-
get() 获取值。
-
remove() 清除值。
public class ThreadLocalTest {
// 创建本地线程
static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
// 线程1
new Thread(() -> {
String name = Thread.currentThread().getName();
// 设置值
threadLocal.set("itcast");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t1").start();
// 线程2
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();
}
}
相互隔离,各自取各自的!
40.ThreadLocal的实现原理&源码解析
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离。ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】避免争用引发的线程安全问题。
每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象。
- a)调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中。
- b)调用 get 方法,就是以 ThreadLocal自己作为 key,到当前线程中查找关联的资源值。
- c)调用remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值。
在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap。
ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置。
set方法
get方法/remove方法
41.ThreadLocal-内存泄露问题
Java对象中的四种引用类型:强引用
、软引用
、弱引用
、虚引用
.
- 强引用:最为普通的引用方式,表示一个对象处于
有用且必须
的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收。
- 弱引用:表示一个对象处于可能
有用且非必须
的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。
在ThreadLocal中每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference
。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本。
解决: 在使用ThreadLocal的时候,强烈建议:务必手动remove。
(八)、JVM
1.JVM是什么
Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)
好处:
-
一次编写,到处运行。
-
自动内存管理,垃圾回收机制。
JVM由哪些部分组成,运行流程是什么?
从图中可以看出 JVM 的主要组成部分
- ClassLoader(类加载器)
- Runtime Data Area(运行时数据区,内存分区)
- Execution Engine(执行引擎)
- Native Method Library(本地库接口)
运行流程:
-
类加载器(ClassLoader)把Java代码转换为字节码。
-
运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行。
-
执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能。
2.什么是程序计数器? (⭐不会发生GC)
程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。
创建一个类,并生成字节码文件
【单线程情况下:】
javap -v xxx.class: 生成字节码文件
查看我们的字节码文件并解释
javap -verbose xx.class 打印堆栈大小,局部变量的数量和方法的参数。
java虚拟机对于多线程是通过线程轮流切换并且分配线程执行时间。在任何的一个时间点上,一个处理器只会处理执行一个线程,如果当前被执行的这个线程它所分配的执行时间用完了【挂起】。处理器会切换到另外的一个线程上来进行执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。
那么现在有一个问题就是,当前处理器如何能够知道,对于这个被挂起的线程,它上一次执行到了哪里?那么这时就需要从程序计数器中来回去到当前的这个线程他上一次执行的行号,然后接着继续向下执行。
程序计数器是JVM规范中唯一一个没有规定出现OOM的区域,所以这个空间也不会进行GC。
3.你能给我详细的介绍Java堆吗? (⭐经常发生GC)
线程共享的区域:主要用来保存对象实例
,数组
等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError
异常。
- 年轻代被划分为三部分,
Eden区和两个大小严格相同的Survivor区
,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到老年代区间。 - 老年代主要保存生命周期长的对象,一般是一些
老的对象
和大对象
。 - 元空间 保存的
类信息
、静态变量
、常量
、编译后的代码
。
4.Java7和Java8 堆内存结构有什么不同嘛?
为了避免方法区出现OOM,所以在java8中将堆上的方法区【永久代】给移动到了本地内存上,重新开辟了一块空间,叫做元空间。那么现在就可以避免掉OOM的出现了。
5.什么是元空间(MetaSpace)? (⭐很少GC)
在 HotSpot JVM 中,永久代( ≈ 方法区)中用于存放类
和方法
的元数据以及常量池
,比如Class 和 Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。
永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即OutOfMemoryError,为此不得不对虚拟机做调优。
那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?
-
由于 PermGen(永久代) 内存经常会溢出,引发OutOfMemoryError
,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM。 -
移除 PermGen 可以
促进 HotSpot JVM 与 JRockit VM 的融合
,因为 JRockit 没有永久代。
准确来说,Perm 区中的字符串常量池被移到了堆内存中是在 Java7 之后,Java 8 时,PermGen 被元空间代替
,其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区。比如 java/lang/Object 类元信息、静态属性 System.out、整型常量等。
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现
。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
6.什么是虚拟机栈? (⭐不会GC)
Java Virtual machine Stacks (java 虚拟机栈)
-
主要为java的方法服务,用于
存储局部变量表
、操作数栈
、动态链接
和方法出口
等信息。(不是存储方法) -
每个线程运行时所需要的内存,称为虚拟机栈,先进后出。(一个线程对应一个栈内存)
-
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存。
-
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
垃圾回收是否涉及栈内存?
垃圾回收主要指就是堆内存
,当栈帧弹栈以后,内存就会释放。
栈内存分配越大越好吗?
未必,默认的栈内存通常为1024k
栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半。
方法内的局部变量是否线程安全?
-
如果方法内
局部变量没有逃离方法的作用范围
,它是线程安全的。 -
如果是
局部变量引用了对象,并逃离方法的作用范围
,需要考虑线程安全。
栈内存溢出情况
- 无限递归循环调用(最常见)。
- 执行了大量方法,导致线程栈空间耗尽。
- 方法内声明了海量的局部变量。
- 栈帧过大导致栈内存溢出。
7.堆和栈的区别是什么?
- 栈内存一般会用来存
储局部变量和方法调用
,但堆内存是用来存储Java对象和数组
的。 - 堆会GC垃圾回收,而栈不会。
- 栈内存是
线程私有
的,而堆内存是线程共有
的。 - 两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
- 栈空间不足:java.ang.StackOverFlowError.
- 堆空间不足:java.lang.OutOfMemoryError。
8.能不能解释一下方法区?(⭐很少GC)
-
方法区(Method Area)是各个
线程共享
的内存区域 -
主要存储
类的信息
、运行时常量池
、字段
、静态属性
、方法
。 -
虚拟机启动的时候创建,关闭虚拟机时释放。
-
如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace。
常量池
可以看作是一张表,虚拟机指令根据这张常量表找到要执行的 类名
、方法名
、参数类型
、字面量
等信息。
查看字节码结构(类的基本信息、常量池、方法定义)javap -v xx.class
比如下面是一个test类的main方法执行,源码如下:
public class test {
public static void main(String[] args) {
System.out.println("hello world");
}
}
找到类对应的class文件存放目录,执行命令:javap -v test.class
查看字节码结构
Classfile /E:/Ideal源码/test/target/classes/test.class
Last modified 2024-3-31; size 515 bytes //最后修改时间
MD5 checksum 958df220c3963a5fd05e5e19faf0a3d4 // 签名
Compiled from "test.java" // 从哪个源码编译的
public class test // 报名和类名
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 // test
#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 Ltest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 test.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 test
#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 test(); // 提供默认的构造函数,虽然我们没有设置
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 9: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltest;
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 // ⭐ 后面带#号的都会区常量池里面查
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 11: 0
line 12: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "test.java"
下图,左侧是main方法的指令信息,右侧constant pool 是常量池。
main方法按照指令执行的时候,需要到常量池中查表翻译找到具体的类和方法地址去执行。
运行时常量池
常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
。
9.你听过直接内存吗?
直接内存: 不受 JVM 内存回收管理,是虚拟机的系统内存
,常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高
,不受 JVM 内存回收管理。
举例: (NIO和BIO)
需求: 在本地电脑中的一个较大的文件(超过100m)从一个磁盘挪到另外一个磁盘.
import lombok.SneakyThrows;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
/**
* 演示 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
}
// 使用直接内存 NIO
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);
}
// 使用IO
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) {
// 每次读取内容为1MB
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的数据传输流程 (常规IO -> BIO)
存在两个缓冲区: 需要存两份缓存效率比较低
NIO传输数据的流程
在这个里面主要使用到了一个直接内存,不需要在堆中开辟空间进行数据的拷贝,jvm可以直接操作直接内存,从而使数据读写传输更快。
10.Java虚拟机内存有什么组成?
组成部分:堆
、方法区
、栈
、本地方法栈
、程序计数器
。
- 堆解决的是对象实例存储的问题,垃圾回收器管理的主要区域。
- 方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。
- 栈解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息。
- 本地方法栈与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码的接口。
- 程序计数器(PC寄存器)程序计数器中存放的是当前线程所执行的字节码的行数。JVM工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。
11. 什么是类加载器,类加载器有哪些?
要想理解类加载器的话,务必要先清楚对于一个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,实现自定义类加载规则。
上述三种类加载器的层次结构如下如下:
类加载器的体系并不是“继承”体系,而是委派体系,类加载器首先会到自己的parent中查找类或者资源,如果找不到才会到自己本地查找。类加载器的委托行为动机是为了避免相同的类被加载多次。
12.什么是双亲委派机制?
如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就返回成功;只有父类加载器无法完成此加载任务时,才由下一级去加载。
13.JVM为什么会采用双亲委派机制呢?
-
通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
-
为了安全,保证类库API不会被修改。
在工程中新建java.lang包,接着在该包下新建String类,并定义main函数。
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库。
14.说一下类装载的执行过程?
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载
、验证
、准备
、解析
、初始化
、使用
和卸载
这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。
- 加载
-
通过类的全名,获取类的二进制数据流
。 -
解析类的
二进制数据流为方法区内的数据结构
(Java类模型) -
在
堆中创建java.lang.Class类的实例,表示该类型
。作为方法区这个类的各种数据的访问入口。
- 验证
验证类是否符合JVM规范,安全性检查
- 文件格式验证:
- 是否符合Class文件的规范
- 元数据验证
- 这个类是否有父类(除了Object这个类之外,其余的类都应该有父类)
- 这个类是否继承(extends)了被final修饰过的类(被final修饰过的类表示类不能被继承)
- 类中的字段、方法是否与父类产生矛盾。(被final修饰过的方法或字段是不能覆盖的)
- 字节码验证
- 主要的目的是通过对数据流和控制流的分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证
-
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量。
-
Class文件在其
常量池会通过字符串记录自己将要使用的其他类或者方法,检查它们是否存在
。
-
比如:int i = 3;字面量:3; 符号引用:i 。
-
文件格式+元数据验证+字节码验证 (格式检查)
- 准备
为类变量分配内存并设置类变量初始值
-
static变量,分配空间在准备阶段完成(
设置默认值
),赋值在初始化阶段完成
-
static变量是final的基本类型,以及字符串常量,
值已确定,赋值在准备阶段完成
-
static变量是final的引用类型,那么赋值也会在初始化阶段完成
总结: 只有是static修饰final的基本类型 和 字符串常量在准备阶段赋值,其他的都是在初始化阶段。
- 解析
把类中的符号引用转换为直接引用
比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。
5.初始化
对类的静态变量,静态代码块执行初始化操作
-
如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
-
如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
- 使用
JVM 开始从入口方法开始执行用户的程序代码
-
调用静态类成员信息(比如:静态字段、静态方法)
-
使用new关键字为其创建对象实例
- 卸载
当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。
15.简述Java垃圾回收机制?(GC是什么?为什么要GC)
为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC(Garbage Collection)。
有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。
在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机
。
换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。
当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。
16.对象什么时候可以被垃圾器回收?
简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收
。
17.如何定位垃圾?
如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法
,第二个是可达性分析算法
。
引用计数法
一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收。
String demo = new String("123");
String demo = null;
引用计数法也是存在弊端: 当对象间出现了循环引用的话,则引用计数法就会失效。
目前上方的引用关系和计数都是没问题的,但是,如果代码继续往下执行,如下图。
虽然 a和b 都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。
优点:
- 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。
- 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报OOM错误。
- 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
缺点:
- 每次对象被引用时,都需要去更新计数器,有一点时间开销。
- 浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。
- 无法解决循环引用问题,会引发内存泄露。(最大的缺点)
可达性分析算法
现在的虚拟机采用的都是通过可达性分析算法
来确定哪些内容是垃圾。
会存在一个根节点【GC Roots】,引出它下面指向的下一个节点,再以下一个节点节点开始找出它下面的节点,依次往下类推。直到所有的节点全部遍历完毕。
-
根对象是那些肯定不能当做垃圾回收的对象,就可以当做根对象。
-
局部变量,静态方法,静态变量,类信息。
-
核心是:判断某对象是否与根对象有直接或间接的引用,如果没有被引用,则可以当做垃圾回收。
X,Y这两个节点是可回收的,但是并不会马上的被回收!! 对象中存在一个方法【finalize】。当对象被标记为可回收后,当发生GC时,首先会判断这个对象是否执行了finalize方法,如果这个方法还没有被执行的话,那么就会先来执行这个方法,接着在这个方法执行中,可以设置当前这个对象与GC ROOTS产生关联,那么这个方法执行完成之后,GC会再次判断对象是否可达,如果仍然不可达,则会进行回收,如果可达了,则不会进行回收
。
finalize方法对于每一个对象来说,只会执行一次
。如果第一次执行这个方法的时候,设置了当前对象与RC ROOTS关联,那么这一次不会进行回收。 那么等到这个对象第二次被标记为可回收时,那么该对象的finalize方法就不会再次执行了
。
18.哪些对象可以作为 GC Root?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
/**
* demo是栈帧中的本地变量,当 demo = null 时,由于此时 demo 充当了 GC Root 的作用,demo与原来指向的实例 new Demo() 断开了连接,对象被回收。
*/
public class Demo {
public static void main(String[] args) {
Demo demo = new Demo();
demo = null;
}
}
- 方法区中类静态属性引用的对象
/**
* 当栈帧中的本地变量 b = null 时,由于 b 原来指向的对象与 GC Root (变量 b) 断开了连接,所以 b 原来指向的对象会被回收,而由于我们给 a 赋值了变量的引用,a在此时是类静态属性引用,充当了 GC Root 的作用,它指向的对象依然存活!
*/
public class Demo {
public static Demo a;
public static void main(String[] args) {
Demo b = new Demo();
b.a = new Demo();
b = null;
}
}
- 方法区中常量引用的对象
/**
* 常量 a 指向的对象并不会因为 demo 指向的对象被回收而回收
*/
public class Demo {
public static final Demo a = new Demo();
public static void main(String[] args) {
Demo demo = new Demo();
demo = null;
}
}
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象.
19.JVM垃圾回收算法有哪些?
复制算法 (年轻代常用)
复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收
。
如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。
-
将内存区域分成两部分,每次操作其中一个。
-
当进行垃圾回收时,
将正在使用的内存区域中的存活对象移动到未使用的内存区域。当移动完对这部分内存区域一次性清除
。 -
周而复始。
优点:
- 在垃圾对象多的情况下,效率较高
- 清理后,内存无碎片
缺点:
- 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低
标记清除算法
标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
-
根据可达性分析算法得出的垃圾进行标记。
-
对这些标记为可回收的内容进行垃圾回收。
可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。
同样,标记清除算法也是有缺点的:
- 效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。
- (重要)通过标记清除算法清理出来的内存,
碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的
。
标记整理算法 (老年代常用)
标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题。
标记垃圾。
2)需要清除向右边走,不需要清除的向左边走。
3)清除边界以外的垃圾。
优缺点同标记清除算法,解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。
与复制算法对比:复制算法标记完就复制,但标记整理算法得等把所有存活对象都标记完毕,再进行整理。
20.什么是JVM的分代回收?
在java8时,堆被分为了两份:新生代和老年代【1:2】,在java7时,还存在一个永久代。
-
对于新生代,内部又被分为了三个区域。
Eden区,S0区,S1区【8:1:1】
-
当对新生代产生GC:
MinorGC
【young GC】 -
当对老年代代产生GC:
Major GC
-
当对新生代和老年代产生FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免。
分代回收工作机制
- 新创建的对象,都会先分配到eden区
- 当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象,将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸园和 from 内存都得到释放。
- 经过一段时间后伊甸园的内存又出现不足,标记eden区域to区存活的对象,将存活的对象复制到from区。其他的进行消除!
- 当幸存区对象熬过几次回收(最多15次),晋升到老年代(
幸存区内存不足或大对象会导致提前晋升
)。比如说A被移动了15次,那么就会移动到老年代。
21.MinorGC、 Mixed GC 、 FullGC的区别是什么?
-
Minor GC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)。
-
Mixed GC
新生代 + 老年代部分区域的垃圾回收
,G1 收集器特有。 -
FullGC:
新生代 + 老年代完整垃圾回收
,暂停时间长(STW),应尽力避免。
STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收的完成。
22.说一下 JVM 有哪些垃圾回收器?
在jvm中,实现了多种垃圾收集器,包括:
-
串行垃圾收集器。
-
并行垃圾收集器。
-
CMS(并发)垃圾收集器。
-
G1垃圾收集器。
串行垃圾回收器 (企业开发用的少)
Serial
和 Serial Old
串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑
-
Serial 作用于新生代,采用复制算法
-
Serial Old 作用于老年代,采用标记-整理算法
垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
并行垃圾收集器 (JDK8常用)
arallel New
和 Parallel Old
是一个并行垃圾回收器,JDK8默认使用此垃圾回收器
-
Parallel New作用于新生代,采用复制算法
-
Parallel Old作用于老年代,采用标记-整理算法
垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
CMS(并发)垃圾收集器 【针对老年代】
CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。
重新标记:
23.详细聊一下G1垃圾回收器
-
应用于新生代和老年代,在JDK9之后默认使用G1
-
划分成多个区域,每个区域都可以充当
eden
,survivor
,old
,humongous
,其中 humongous 专为大对象准备 -
采用复制算法
-
响应时间与吞吐量兼顾
-
分成三个阶段:
新生代回收(stw)
、并发标记(重新标记时会swt)
、混合收集
-
如果并发失败(
即回收速度赶不上创建新对象速度
),会触发Full GC
Young Collection(年轻代垃圾回收)
-
初始时,所有区域都处于空闲状态
-
创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象
eden存放对象
-
当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
垃圾回收的时候,将存活的对象复制到A幸存区中
复制完之后,对eden进行回收处理
-
随着时间流逝,伊甸园的内存又有不足,将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代。
随着时间流逝,伊甸园的内存又有不足
eden区满了,A幸存区也满了,就会将A幸存区和enden区的存活对象复制到B幸存区。
其中A幸存区中较老对象晋升至老年代
Young Collection + Concurrent Mark (年轻代垃圾回收+并发标记)
- 当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程.
- 并发标记之后,会有
重新标记阶段解决漏标问题,此时需要暂停用户线程
。 - 这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是 Gabage First 名称的由来)。
红色的就是垃圾多的,存活对象少的老年代
。
Mixed Collection (混合垃圾回收)
- 混合收集阶段,参与复制的有 eden、survivor、old
将幸存区、老年代、满的eden区 复制到新的eden区
- 复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集。
然后在将新的eden区存活的对象复制到新幸存区。
- 其中H叫做巨型对象,如果对象非常大,会开辟一块连续的空间存储巨型对象。
24.强引用、软引用、弱引用、虚引用的区别?
强引用
强引用:只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
。
User user = new User();
软引用 【两次】
软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会才会再次触发垃圾回收
。
User user = new User();
SoftReference softReference = new SoftReference(user);
弱引用 【一次】
弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
。
User user = new User();
WeakReference weakReference = new WeakReference(user);
- 延伸话题:ThreadLocal内存泄漏问题
ThreadLocal用的就是弱引用,看以下源码:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v; //强引用,不会被回收
}
}
Entry
的key是当前ThreadLocal,value值是我们要设置的数据。
WeakReference
表示的是弱引用,当JVM进行GC时,一旦发现了只具有弱引用的对象,不管当前内存空间是否足够,都会回收它的内存。但是value
是强引用,它不会被回收掉。
ThreadLocal使用建议:使用完毕后注意调用清理方法。
虚引用
虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队
,由 Reference Handler 线程调用虚引用相关方法释放直接内存。
User user = new User();
ReferenceQueue referenceQueue =new ReferenceQueue();
PhantomReference phantomReference =new PhantomReference(user,queue);
25. JVM 调优的参数可以在哪里设置参数值?
我们在idea中配置的参数都是临时参数,面试官通常问这个问题都是在虚拟机中或者Tomcat中怎么调优。
tomcat的设置vm参数
修改 TOMCAT_HOME/bin/catalina.sh
文件,如下图
JAVA_OPTS="-Xms512m -Xmx1024m" : 堆内存最小为512,最大为1024
springboot项目jar文件启动
通常在linux系统下直接加参数启动springboot项目
nohup java -Xms512m -Xmx1024m -jar xxxx.jar --spring.profiles.active=prod &
-
nohup : 用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行
-
参数 & :让命令在后台执行,终端退出后命令仍旧执行。
26.用的 JVM 调优的参数都有哪些?
对于JVM调优,主要就是调整年轻代、年老代、元空间的内存空间大小及使用的垃圾回收器类型。
https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
- 设置堆空间大小
- 虚拟机栈的设置
- 年轻代中Eden区和两个Survivor区的大小比例
- 年轻代晋升老年代阈值
- 设置垃圾回收收集器
设置堆空间大小
设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值
。
不指定单位默认为字节;指定单位,按照指定的单位设置。
-Xms:设置堆的初始化大小
-Xmx:设置堆的最大大小
堆空间设置多少合适?
- 最大大小的默认值是
物理内存的1/4
,初始大小是物理内存的1/64
。 - 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,
会产生stw,暂停用户线程
。 - 堆内存大肯定是好的,存在风险,假如发生了fullgc,它会扫描整个堆空间,暂停用户线程的时间长。
堆中的年轻代和两个幸存区
设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1
。Java官方通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优
。
-XXSurvivorRatio=3,表示年轻代中的分配比率:survivor:eden = 2:3
年轻代和老年代默认比例为1:2
。可以通过调整二者空间大小比率来设置两者的大小。
-XX:newSize 设置年轻代的初始大小
-XX:MaxNewSize 设置年轻代的最大大小, 初始大小和最大大小两个值通常相同
虚拟机栈的设置
每个线程默认会开启1M的堆栈
,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
-Xss 对每个线程stack大小的调整,-Xss128k
年轻代晋升老年代阈值
-XX:MaxTenuringThreshold=threshold
- 默认为15。(当移动次数大于15次之后就会移动到老年代)
- 取值范围0-15。(可以设定预制的设置)
- 一般情况下,
年轻对象放在eden区
。当第一次GC后,如果对象还存活,放到survivor区,此后,每GC一次,年龄增加1,当对象的年龄达到阈值,就被放到enured老年区。这个阈值可以同构-XX:MaxTenuringThreshold
设置。如果想让对象留在年轻代,可以设置比较大的阈值。 - 对于占用内存比较多的大对象,一般会选择在老年代分配内存。
设置垃圾回收收集器
(1)-XX:+UseParallelGC:年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。
(2)-XX:+UseParallelOldGC:设置老年代使用并行垃圾回收收集器。
使用非占用的垃圾收集器。
-XX:+UseConcMarkSweepGC老年代使用CMS收集器降低停顿。
27.说一下 JVM 调优的工具?
- 命令工具
-
jps: 进程状态信息
-
jstack: 查看java进程内线程的堆栈信息
-
jmap: 查看堆转信息
-
jhat: 堆转储快照分析工具
-
jstat: JVM统计监测工具
-
- 可视化工具
- jconsole用于对ivm的内存,线程,类 的监控
- VisualVM能够监控线程,内存情况
jps: 进程状态信息 (查看进程)
jstack 查看java进程内线程信息
jstack [option] <pid>
jmap 用于生成堆转内存快照、内存使用情况
jmap [options] pid 内存映像信息
jmap -heap pid 显示Java堆的信息
导出文件到哪个文件夹
jmap -dump:format=b,file=heap.hprof pid
- eg: jmap -dump:format=b,file=e://abc.hprof 32800。
- format=b表示以hprof二进制格式转储Java堆的内存
- file= < filename >用于指定快照dump文件的文件名。
- dump是什么: 它是一个进程或系统在某一给定的时间的快照。比如在进程崩溃时,甚至是任何时候,我们都可以通过工具将系统或某进程的内存备份出来供调试分析用,dump文件中包含了程序运行的模块信息、线程信息、堆栈调用信息、异常信息等数据,方便系统技术人员进行错误排查。
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.
jstat 是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。
常见参数:
- 总结垃圾回收统计
jstat -gcutil pid
字段 | 含义 |
---|---|
S0 | 幸存1区当前使用比例 |
S1 | 幸存2区当前使用比例 |
E | 伊甸园区使用比例 |
O | 老年代使用比例 |
M | 元数据区使用比例 |
CCS | 压缩使用比例 |
YGC | 年轻代垃圾回收次数 |
YGCT | 年轻代垃圾回收消耗时间 |
FGC | 老年代垃圾回收次数 |
FGCT | 老年代垃圾回收消耗时间 |
GCT | 垃圾回收消耗总时间 |
- 垃圾回收统计
jstat -gc pid
jconsole 可视化工具
用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe
就行
VisualVM:故障处理工具
能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe
就行
28.java内存泄露的排查思路?
-
如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量的时候,java虚拟机将抛出一个StackOverFlowError异常
-
如果java虚拟机栈可以动态拓展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成拓展,或者在建立新线程的时候没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个OutOfMemoryError异常
-
如果一次加载的类太多,元空间内存不足,则会报OutOfMemoryError: Metaspace
解决步骤
- 获取堆内存快照dump。
有的情况是内存溢出之后程序则会直接中断,而jmap只能打印在运行中的程序,所以建议通过参数的方式的生成dump文件,配置如下:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/home/app/dumps/ 指定生成后文件的保存目录
- VisualVM去分析dump文件。
如果是linux系统中的程序,则需要把dump文件下载到本地(windows环境)下,打开VisualVM工具分析。VisualVM目前只支持在windows环境下运行可视化。
文件–>装入—>选择dump文件即可查看堆快照信息
- 通过查看堆信息的情况,定位内存溢出问题
29. CPU飙高排查方案与思路?(Linux)
- 使用top命令查看占用cpu的情况
- 通过top命令查看后,可以查看是哪一个进程占用cpu较高,上图所示的进程为:30978。
- 查看当前线程中的进程信息
ps H -eo pid,tid,%cpu | grep 30978
-
pid 进行id
-
tid 进程中的线程id
-
% cpu使用率
4. 通过上图分析,在进程30978中的线程30979占用cpu较高
注意:上述的线程id是一个十进制
,我们需要把这个线程id转换为16进制才行
,因为通常在日志中展示的都是16进制的线程id名称
转换方式: 在linux中执行命令
printf "%x\n" 30979
- 可以根据线程 id 找到有问题的线程,进一步定位到问题代码的源码行号
执行命令
jstack 30978 此处是进程id
(九)、常见技术场景
1.单点登录这块怎么实现的
单点登录的英文名叫做: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解决单点登录的流程如下:
2.权限认证是如何实现的?
后台的管理系统,更注重权限控制,最常见的就是RBAC模型来指导实现权限。
RBAC(Role-Based Access Control) 基于角色的访问控制
-
3个基础部分组成:用户、角色、权限
-
具体实现
- 5张表(
用户表、角色表、权限表、用户角色中间表、角色权限中间表
) - 7张表(用户表、角色表、权限表、菜单表、用户角色中间表、角色权限中间表、权限菜单中间表)
- 5张表(
RBAC权限模型
最常见的5张表的关系
3.上传数据的安全性你们怎么控制?
这里的安全性。主要说的是,浏览器访问后台,需要经过网络传输,有可能会出现安全的问题。
解决方案:使用非对称加密(或对称加密),给前端一个公钥让他把数据加密后传到后台,后台负责解密后处理数据。
对称加密
文件加密和解密使用相同的密钥,即加密密钥也可以用作解密密钥。
-
数据发信方将明文和加密密钥一起经过特殊的加密算法处理后,使其变成复杂的加密密文发送出去,
-
收信方收到密文后,若想解读出原文,则需要使用加密时用的密钥以及相同加密算法的逆算法对密文进行解密,才能使其回复成可读明文。
-
在对称加密算法中,使用的密钥只有一个,收发双方都使用这个密钥,这就需要解密方事先知道加密密钥。
优点: 对称加密算法的优点是算法公开、计算量小、加密速度快、加密效率高。
缺点: 没有非对称加密安全.
用途: 一般用于保存用户手机号、身份证等敏感但能解密的信息。
常见的对称加密算法有: AES、DES、3DES、Blowfish、IDEA、RC4、RC5、RC6、HS256
非对称加密
两个密钥:公开密钥(publickey)和私有密钥,公有密钥加密,私有密钥解密。
加密与解密:
- 私钥加密,持有公钥才可以解密
- 公钥加密,持有私钥才可解密
签名:
- 私钥签名, 持有公钥进行验证是否被篡改过.
优点: 非对称加密与对称加密相比,其安全性更好;
缺点: 非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。
用途: 一般用于签名和认证。私钥服务器保存, 用来加密, 公钥客户拿着用于对于令牌或者签名的解密或者校验使用.
常见的非对称加密算法有: RSA、DSA(数字签名用)、ECC(移动设备用)、RS256 (采用SHA-256 的 RSA 签名)
4.你负责项目的时候遇到了哪些比较棘手的问题?
这个面试题主要考察的是,
- 你是否有过开发经验
- 是否是核心开发人员
有4个方面可以回答,只要挑出一个回答就行了
- 设计模式
- 工厂模式+策略
- 责任链模式
举例:
①:介绍登录业务(一开始没有用设计模式,所有的登录方式都柔和在一个业务类中,不过,发现需求经常改)。
②:登录方式经常会增加或更换,每次都要修改业务层代码,所以,经过我的设计,使用了工厂设计模式和策略模式,解决了,经常修改业务层代码的问题。
③:详细介绍一下工厂模式和策略模式(参考前面设计模式的课程)。
- 线上BUG
- CPU飙高
- 内存泄漏
- 线程死锁
- …
回答方式参考上面的回答思路,具体问题可以参考前面的课程(JVM和多线程相关的面试题)
- 调优
- 慢接口
- 慢SQL
- 缓存方案
- 组件封装
- 分布式锁
- 接口幂等
- 分布式事务
- 支付通用
5.你们项目中日志怎么采集的?
- 为什么要采集日志?
日志是定位系统问题的重要手段,可以根据日志信息快速定位系统中的问题。
- 采集日志的方式有哪些?
-
ELK:即Elasticsearch、Logstash和Kibana三个软件的首字母
-
常规采集:按天保存到一个日志文件
- ELK基本架构
ELK即Elasticsearch、Logstash和Kibana三个开源软件的缩写
-
Elasticsearch
Elasticsearch 全文搜索和分析引擎,对大容量的数据进行接近实时的存储、搜索和分析操作。 -
Logstash
Logstash是一个数据收集引擎,它可以动态的从各种数据源搜集数据,并对数据进行过滤、分析和统一格式等操作,并将输出结果存储到指定位置上 -
Kibana
Kibana是一个数据分析和可视化平台,通常与Elasticsearch配合使用,用于对其中的数据进行搜索、分析,并且以统计图标的形式展示。
6.查看日志的命令有哪些?
目前采集日志的方式:按天保存到一个日志文件
也可以在logback配置文件中设置日志的目录和名字
需要掌握的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
-
7.生产问题怎么排查?
已经上线的bug排查的思路:
-
先分析日志,通常在业务中都会有日志的记录,或者查看系统日志,或者查看日志文件,然后定位问题。
-
远程debug(通常公司的正式环境(生产环境)是不允许远程debug的。一般远程debug都是公司的测试环境,方便调试代码)
远程debug配置
前提条件:远程的代码和本地的代码要保持一致
- 远程代码需要配置启动参数,把项目打包放到服务器后启动项目的参数:(在linux启动的时候,需要添加以下参数)
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,其它端口也可以
- idea中设置远程debug,找到idea中的 Edit Configurations…
3. idea中启动远程debug
4. 访问远程服务器,在本地代码中打断点即可调试远程
8. 怎么快速定位系统的瓶颈
-
压测(性能测试),项目上线之前测评系统的压力
- 压测目的:给出系统当前的性能状况;定位系统性能瓶颈或潜在性能瓶颈
- 指标:响应时间、 QPS、并发数、吞吐量、 CPU利用率、内存使用率、磁盘IO、错误率
- 压测工具:LoadRunner、Apache Jmeter …
- 后端工程师:根据压测的结果进行解决或调优(接口慢、代码报错、并发达不到要求…)
-
监控工具、链路追踪工具,项目上线之后监控
- 监控工具:Prometheus+Grafana
- 链路追踪工具:skywalking、Zipkin
-
线上诊断工具Arthas(阿尔萨斯),项目上线之后监控、排查
-
官网:https://arthas.aliyun.com/
-
核心功能:
-