1、自我介绍
面试官你好,我叫平明博,来自河南郑州,19年毕业,所学专业软件工程,之前任职于南京华苏科技,担任开发工程师一职,在职期间主要对省间现货相关项目进行研发,核心就是从多平台自动获取数据,根据不同业务逻辑,我们这边做可视化操作,负责的项目主要是使用微服务的设计思想和分布式部署,相关的框架有Spring Boot
,MyBatis
,常见中间件Redis
,Eureka
,RabbitMQ
等,另外对Redis
源码有一定了解;最后也做过代码重构的任务,因为项目有Sonar
扫描,和Jenkins
部署结合,定期扫描代码,保持项目代码整洁和质量,关于线上问题、性能调优,代码重构有自己的理解,如果有幸加入贵公司一起共事,我想我能第一时间上手并且产出结果,以上就是我的自我介绍,谢谢面试官。
衍生问题
1.1 Spring Boot
1.2 Redis
1.3 RabbitMQ
1.4 Sonar
1.5 性能优化都做过哪方面的处理。
1.6 Jenkins怎么用的
2、说一下项目逻辑,流程情况
技术层面
从技术角度来说,采用是springcloud
微服务架构,以springboot
基础,数据层mybatis
,数据库相关mysql
,oracle
,基于vue
+elementui
封装的前端框架,中间件有redis
,spring
cloud
各种组件服务调用,服务发现,服务注册,网关gateway,rabbitmq
消息队列,为了保证高可用,Redis
哨兵模式+集群,MySQL
也是主从复制,读写分离,目前也是在云上部署。
业务层面
这个系统主要提供给供电所用户使用,根据各平台提供数据,计算各省出清分析与通道业务,推进相对复杂的电价预测与申报策略等功能开发工作,通过曲线图或者折线图可以看出来,当月,当日用电量预测分析,其中涉及模块数据采集,市场分析, 出清电价预测。
3、项目中担任的角色
前期 和开发,测试,产品,运营相关人员开会,进行业务梳理。
中期 根据需求分析会确定的点,结合需求文档进行开发工作,排期,何时测试,何时灰度验证,生产验证。
后期 部署上线等事宜,准备上线手册,测试验证业务场景,没问题。
4、项目中遇到问题该怎么解决
- 一个是技术方面的话,如果是本地进行自测,debug来解决,解决不了查找相关知识点的资料,之后通过百度,谷歌查询有没有相关博客文章 最后实在解决不了请教同事,如果是线上问题,需要从服务器查看实时日志信息,通过日志来排查,搭配开源组件arthas
- 一个是业务方面的话,我们开发之前都有需求讨论,因为这个需求讨论只能从大概方向来确定,一些场景可能无法考虑周全,开发过程中才会遇到或者测试时候才发现,然后跟项目经理沟通,确定合适的解决方案。
5、项目开发流程
我们项目是属于敏捷开发,每个月一般情况两次分支,上半月一次,下半月一次;
然后项目经理跟客户商讨需求细节,之后形成相应的书面文档,之后不同人员分发不同需求点,给几天时间熟悉需求,熟悉之后我们就开始进行需求分析,一般需求都是在原有基础上新增开发点或者根据客户特殊需求进行相应变更,大的方面没有什么问题,就开始讨论数据库表,字段,缓存技术上问题细节问题;其次,会有专门开发创建好新的分支,我们拉取最新的分支其次,按照需求研讨会暂定的需求点进行各自开发,一般是一周开发时间,开发相应功能之后,先自测,之后没啥问题
然后,项目开始上sit环境,提交各自模块代码,jenkins部署对应分支模块,开始sit环境配合前端联调对应功能
然后,uat,准生产测试人员来进行测试,出现技术问题,我们开发者根据测试人员禅道提供bug问题,进行修复
uat环境基本上不会有什么技术问题,逻辑问题;之后准生产验证就是业务整个衔接进行验证。
6、分布式事务
微服务中如果跨服务链路比较多,就会出现分布式事务,数据的一致性问题,就是一个回滚的问题,像一些架构,如果对性能要求不高,可以使用阿里的seata,因为seata采用就行主要解决它的分布式事务问题, 它里面是tcc的一个机制,就是两阶段提交,第一阶段就是说,类似于MySQL的binlog日志,它会获取它的一个原始值,对原始值做一个保留,然后在事务提交之前,它会获取全局锁的一个概念,因为seata里面有一个全局锁和局部锁的概念,因为需要获取全局锁,如果不获取全局锁,不会让它提交事务的,在二阶段提交事务时候,如果出现异常了,就利用它的一个就是一阶段保存的历史性然后回滚,但是这样一来有一个弊端,就是说seata的一个性能问题,因为公司会使用,但有的公司不一定使用它是获取全局锁的一个性能问题,因为全局锁它会从链路开始到结束,它这个全局锁会影响整个性能,为了保证数据一致性,还有解决分布式事务一个方案,代码作为补偿机制,通过消息队列mq异步补偿,如果说我们的服务之间,A服务调用B服务,B服务出现了宕机,或者error,我们就会通过消息队列补偿一个机制,这样性能比seata好一点,但是这样补偿不一定及时或者对数据一致性也会出现一些问题,因为是通过人为代码补偿的。
7、分布式锁如何使用的
8、线上遇到哪些问题,如何解决的
问题场景:服务是一个使用类似dubbo
的RPC
框架以及若干Spring
全家桶组合起来的微服务架构,Java
服务使用的是CMS
的垃圾回收器,然收到一台实例(即一个Java应用)产生FullGC
日志的报警,老年代内存已经不足而触发了FullGC
,并且由于应用虽然STW
,但是请求确还是在堆积,导致一直在持续FullGC
,没有自愈;
然后逻辑上考虑出现fullgc
场景,有人主动调用了System.gc()
,基本上不会有人来调用这个方法,就算是有人调用了,我们服务启动时候开启了一个参数,这个参数可以将System.gc()
转为CMS
的并发gc
,所以并不会触发FullGC
.
老年代空间不足:对象出生于新生代,在挺过了一次次minorGC之后成功熬到了老年代,并且持续在老年代混吃等死,一直到大量的对象都这样在老年代混吃等死把老年代占满之后就会触发FullGC
元空间不足:代码中大量使用动态代理,生成了一大堆的代理类占用了方法区,如果是这个原因引起必然是所有服务都会报FullGC问题,然而其它机器的老年代内存很稳定,所以排查
为什么只有一个实例异常
只有单个服务出现了这样的问题,很有可能不是外部依赖
的超时或者方法区空间不足
造成,而是因为某个刚好落在这个服务上的超大请求
占用了大量的内存
并且耗时久,一直赖在老年代不走导致。
gc日志情况
第一次FullGC发生在2020-07-25 14:51:58
,观察之前的日志可以发现历史上CMS并发回收一般都会将堆内存稳定在3608329K
->1344447K
,从3.6G
左右回收到1.3G
左右,但是从某个时间点开始开始回收效率变差
了。
可以看到,这次回收从5G回收到3.5G,回收完之后还有这么多被占用!这个时间点2020-07-25 14:51:50左右一定发生了什么事情,导致老年代一直保留着一批老赖。
确定问题溯源
定位好时间段之后,接下来找到这个时间段附件的离谱的大请求,由于项目中通过aop全局配置处理日志情况,可以知道每个请求详细信息,通过日志找到一个一个请求,其中请求参数特别长,本来是修改了1000个文件夹的某个属性,但是业务逻辑是如果修改的是某个特殊属性时,会级联修改这些文件夹下的全部文件,实际上修改了1000个文件夹的请求,背后处理了1000个文件夹+几十万w个文件,而修改这些属性由于我们使用的框架的限制,100w个文件在修改前会查主属性表+所有辅属性表(内存根据主键id join),请求耗时90s。导致大量对象 长时间滞留在堆内存中挺过了一波波的minorGC和CMS GC干满老年代,最终触发了这个问题。
优化思路
- 限制此类字段的修改,对于这样需要级联修改的情况时进行校验,不允许API用户传太多文件夹(1000个 --> 100个)
- 微服务的思想,在该服务上层再做一个分发服务,对于这样级联修改的请求将1000个的修改拆成10个每次修改100个的请求去并发请求下面的机器,均摊压力
- 异步化队列,修改文件夹本身属性后即立刻返回,后续级联修改的请求拆成n个放入队列,由其它服务订阅到请求后执行
- 能有监控手段在应用FullGC时从注册中心踢掉,待FullGC自愈后再加入,而不是人工干预重启
- 优化ORM框架,就算修改
100w
个文件的某个属性,也不需要查询出这些文件的全部属性,只查询出主表+需要修改的属性所在的表即可
9、 HashMap
线程安全吗,原因,1.7与1.8区别
9.1 是否线程安全
不是
9.2 原因
简要介绍
在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题
详细说明
JDK 7 的 HashMap
解决冲突用的是拉链法,在拉链的时候用的是头插,每次在链表的头部插入新元素。resize() 的时候用的依然是头插,头插的话,如果某个下标中的链表在新的 table 中依然索引到同一个下标中,那么原链表的顺序会反转。因为链表是顺序访问的,那么每次访问一个节点,会把当前节点插到新 table 链表的头部,这样原链表的最后一个元素在 resize() 后,就变成新链表的头部了(如果它们索引到新 table 的同一个下标中)。这在并发的情况下可能产生环。(死循环)
当两个线程同时put元素时,因为两个都是在自己的工作内存中操作变量,所以可能情况下就是两个线程都认为自己是第一个put然后都触发了resize操作,且都认为oldTab为null,则必然有一个线程的赋值会丢失。(数据丢失)
1.8还是有数据覆盖的问题
9.3 区别
- 数组+链表改成了数组+链表或红黑树;
- 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
- 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
- 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
10 ConCurrentHashMap
了解吗,如何保证线程安全,1.7与1.8区别
10.1 理解
concurrenthashmap
分为两种情况来介绍,一个是jdk1.7和1.8各自情况不太一样,在jdk1.7它的底层是通过数组+链表实现的,然后使用了一种分段锁来保证线程安全,它是将数组分为16段,也就是说给每个segment
配一把锁,然后读取每个segment
时候,先获取锁,所以它最多有16个线程并发去操作;到了jdk1.8时候,它跟hashmap
一样,引入红黑树这种数据结构,同时在并发处理方面不再使用分段锁而是采用cas
+synchronize
关键字来实现更为细粒度的锁,相当于是把锁的控制位于hash
桶这个级别,然后写入键值对可以锁住hash
桶这种链表头结点,这样的话不会影响其他hash
桶写入,从而去提高并发处理能力。
10.2 区别
锁机构
在JDK1.7
中,ConcurrentHashMap
基于Segment
+HashEntry
数组实现的。Segment
是ReentrantLock
的子类,而其内部也维护了一个Entry
数组,这个Entry
数组和HashMap中的Entry
数组是一样的。所以说Segment
其实是一个锁,可以锁住一段哈希表结构,而ConcurrentHashMap
中维护了一个Segment
数组,所以是基于分段锁实现的。 而JDK1.8中,ConcurrentHashMap
摒弃了Segment
,而是采用synchronized
+CAS
+红黑树来实现的。锁的粒度也从段锁缩小为结点锁.
put
执行流程
JDK1.7
中,ConcurrentHashMap
要进行两次定位,先对Segment
进行定位,再对其内部的数组下标进行定位。定位之后会采用自旋锁+锁膨胀的机制进行加锁,也就是自旋获取锁,当自旋次数超过64时,会发生膨胀,直接陷入阻塞状态,等待唤醒。并且在整个put操作期间都持有锁。
而在JDK1.8
中只需要一次定位,并且采用CAS
+synchronized
的机制。如果对应下标处没有结点,说明没有发生哈希冲突,此时直接通过CAS
进行插入,若成功,直接返回。若失败,则使用synchronized
进行加锁插入。
计算size
方式
1.7:采用类似于乐观锁的机制,先是不加锁直接进行统计,最多执行三次,如果前后两次计算的结果一样,则直接返回。若超过了三次,则对每一个Segment
进行加锁后再统计。
1.8:会维护一个baseCount
属性用来记录结点数量,每次进行put
操作之后都会CAS
自增baseCount
10.3 项目中如何使用
11 ArrayList
底层结构
ArrayList是基于数组实现的,是一个动态数组,get和set效率高;其容量能自动增长,内存连续。
初始化过程
new一个ArrayList之后,当添加第一个元素,这个数组提供10个元素空间,当我们添加元素第十一位时候,
会有一个扩容操作,1.5扩容,15个元素的空间,用完之后,当添加第十六个元素时候,进行扩容操作15*1.5《=》
15 >> 1 + 15 = 22;
优缺点
优点
地址连续,get和set效率高
缺点
插入删除元素慢问题,
12 ArrayList与LinkedList区别
底层实现
ArrayList
底层通过动态数组实现,实现RandomAccess
接口,地址是连续的,故而get,set操作效率高
LinkedList
底层通过双向链表,里面包含两个node
类型节点,首节点,尾结点;每个节点通过引用记住上一个节点和下一个节点的地址,从而使得所有元素彼此连接起来,当添加,删除元素时候,修改彼此的引用即可。
读写操作
对于插入操作而言
头插法
LinkedList
快一些,ArrayList
需要大量的位移和复制操作,另外容量不够时候需要扩容;LinkedList
直接定位元素在首位,修改引用即可插入。
尾插法
ArrayList
快一些,ArrayList
不需要大量位移和复制操作,LinkedList
需要大量创建对象
中间插入
ArrayList
定位时间很快,常量级别的时间复杂度,比较耗时就是数据迁移和容量不足时候扩容;
LinkedList
插入元素不怎么耗时,主要是定位元素需要耗费O(n),以及元素实例化操作
获取元素
ArrayList
获取元素,常量级别复杂度,O(1),底层是通过数组实现,获取元素很快
LinkedList
获取元素,需要根据指定位置获取对应node节点,获取对应节点过程,这个过程需要遍历链表一半元素,这个遍历过程有点特殊,获取指定索引位置大于链表元素一般,从最后一个节点往前遍历;如果索引位置小于链表一半元素,从头遍历到后面
应用场景
1、两者均有自己的应用场景,如果不确定使用哪种集合,使用ArrayList
;
2、如果可以确定在首部增加,删除,获取元素,可以使用LinkedList
,这个集合里面很对效率高的方法,addFirst,addLast,getFirst,getLast,removeFirst,removeLast
,这些方法的时间复杂度都是O(1)常量级别
3、添加元素在尾部这种情况,ArrayList
效率高一些,ArrayList
不需要数据拷贝,而LinkedList
需要创建大量node
对象
4、对于这两种结构的集合,并不是说添加删除元素LinkedList
就是很快,获取元素ArrayList
效率高;需要具体问题具体分析
13、synchronize理解
synchronized经常用的,用来保证代码的原子性。
synchronized主要有三种用法:
修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
修饰静态方法:也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。
如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。
修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized(类.class) 表示进⼊同步代码前要获得 当前 class 的锁
14 synchronize升级过程
synchronize
d默认采用的是偏向锁,然后程序运行过程中始终是只有一个线程去获取,这个synchronized
的这个锁,那么java
对象中记录一个线程id,我们下次再获取这个synchronize
的锁时候,只需要比较这个线程id就行了,在运行过程中如果出现第二个线程请求synchronized
的锁时候,分两种情况,在没有发生并发竞争锁情况下,这个synchronized
就会自动升级为轻量级锁,这个时候,第二个线程就会尝试自旋锁方式获取锁,很快便可以拿到锁,所以第二个线程也不会阻塞,但是如果出现两个线程竞争锁情况,这个synchronize
就会升级为重量级锁,这个时候就是只有一个线程获取锁,那么另外一个线程就是阻塞状态,需要等待第一个线程释放锁之后,才能拿到锁。
15 说说synchronized和ReentrantLock的区别?
从概念上说明
- 锁的实现: synchronized是Java语言的关键字,基于JVM实现。而ReentrantLock是基于JDK的API层面实现的(一般是lock()和unlock()方法配合try/finally 语句块来完成。)
- 性能: 在JDK1.6锁优化以前,synchronized的性能比ReenTrantLock差很多。但是JDK6开始,增加了适应性自旋、锁消除等,两者性能就差不多了。
- 功能特点: ReentrantLock 比 synchronized 增加了一些高级功能,如等待可中断、可实现公平锁、可实现选择性通知。
- ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
- ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized不用手动释放锁
有哪些用处,有什么问题
16、CAS呢?CAS了解多少?
CAS叫做CompareAndSwap,⽐较并交换,主要是通过处理器的指令来保证操作的原⼦性的。
CAS 指令包含 3 个参数:共享变量的内存地址 A、预期的值 B 和共享变量的新值 C。
只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C。作为一条 CPU 指令,CAS 指令本身是能够保证原子性的 。
17 CAS 有什么问题?如何解决?
CAS的经典三大问题:
ABA 问题
并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。
怎么解决ABA问题?
- 加版本号
每次修改变量,都在这个变量的版本号上加1,这样,刚刚A->B->A,虽然A的值没变,但是它的版本号已经变了,再判断版本号就会发现此时的A已经被改过了。参考乐观锁的版本号,这种做法可以给数据带上了一种实效性的检验。
Java提供了AtomicStampReference类,它的compareAndSet方法首先检查当前的对象引用值是否等于预期引用,并且当前印戳(Stamp)标志是否等于预期标志,如果全部相等,则以原子方式将引用值和印戳标志的值更新为给定的更新值。
循环性能开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。
怎么解决循环性能开销问题?
在Java中,很多使用自旋CAS的地方,会有一个自旋次数的限制,超过一定次数,就停止自旋。
只能保证一个变量的原子操作
CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。
怎么解决只能保证一个变量的原子操作问题?
- 可以考虑改用锁来保证操作的原子性
- 可以考虑合并多个变量,将多个变量封装成一个对象,通过AtomicReference来保证原子性。
volatile有两个作用,保证可见性和有序性。
volatile怎么保证可见性的呢?
相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。
volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
volatile怎么保证有序性的呢?
重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序
工作中如何使用
1、CAS可以用于实现无锁计数器,允许多个线程同时对计数器进行递增或递减操作。
2、使用CAS,线程可以尝试原子地更新计数器的值。如果更新失败,则说明其他线程已经修改了计数器,可以重试操作直到更新成功。
3、无锁计数器避免了使用传统锁进行互斥访问的开销,提供了更高的并发性能。
18、volatile
当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据
多线程应用中被几个任务共享的变量应该加 volatile
*当读取一个变量时,编译器优化有时会先把变量读取到一个寄存器中;以后,再取变量值时,就直接从寄存器中取值;当内存变量或寄存器变量**因别的线程等而改变了值,**该寄存器的值不会相应改变,从而造成应用程序读取的值和实际的变量值不一致*
加上volatile关键字区别
19 线程池
线程池: 简单理解,它就是一个管理线程的池子。
- 它帮我们管理线程,避免增加创建线程和销毁线程的资源损耗。因为线程其实也是一个对象,创建一个对象,需要经过类加载过程,销毁一个对象,需要走GC垃圾回收流程,都是需要资源开销的。
- 提高响应速度。 如果任务到达了,相对于从线程池拿线程,重新去创建一条线程执行,速度肯定慢很多。
- 重复利用。 线程用完,再放回池子,可以达到重复利用的效果,节省资源。
执行流程
- 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
- 当调用
execute
() 方法添加一个任务时,线程池会做如下判断:
- 如果正在运行的线程数量小于
corePoolSize
,那么马上创建线程运行这个任务; - 如果正在运行的线程数量大于或等于
corePoolSize
,那么将这个任务放入队列; - 如果这时候队列满了,而且正在运行的线程数量小于
maximumPoolSize
,那么还是要创建非核心线程立刻运行这个任务; - 如果队列满了,而且正在运行的线程数量大于或等于
maximumPoolSize
,那么线程池会根据拒绝策略来对应处理。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程无事可做,超过一定的时间(
keepAliveTime
)时,线程池会判断,如果当前运行的线程数大于corePoolSize
,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
线程池如何配置的,有哪些参数
之前我们有一个和第三方对接的需求,需要向第三方推送数据,引入了多线程来提升数据推送的效率,其中用到了线程池来管理线程。
线程池的参数如下:
-
corePoolSize:线程核心参数选择了CPU数×2
-
maximumPoolSize:最大线程数选择了和核心线程数相同
-
keepAliveTime:非核心闲置线程存活时间直接置为0
-
unit:非核心线程保持存活的时间选择了 TimeUnit.SECONDS 秒
-
workQueue:线程池等待队列,使用 LinkedBlockingQueue阻塞队列
同时还用了synchronized 来加锁,保证数据不会被重复推送
线程池拒绝策略
- AbortPolicy :直接抛出异常,默认使用此策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列里最老的任务,也就是队列里靠前的任务
- DiscardPolicy :当前任务直接丢弃
想实现自己的拒绝策略,实现RejectedExecutionHandler接口即可。
线程池有哪几种工作队列
- ArrayBlockingQueue:ArrayBlockingQueue(有界队列)是一个用数组实现的有界阻塞队列,按FIFO排序量。
- LinkedBlockingQueue:LinkedBlockingQueue(可设置容量队列)是基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE,吞吐量通常要高于ArrayBlockingQuene;newFixedThreadPool线程池使用了这个队列
- DelayQueue:DelayQueue(延迟队列)是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排序,否则根据插入到队列的先后排序。newScheduledThreadPool线程池使用了这个队列。
- PriorityBlockingQueue:PriorityBlockingQueue(优先级队列)是具有优先级的无界阻塞队列
- SynchronousQueue:SynchronousQueue(同步队列)是一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene,newCachedThreadPool线程池使用了这个队列
线程池怎么关闭知道吗?
shutdown 和shutdownnow简单来说区别如下:
- shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。
- shutdown()只是关闭了提交通道,而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池。
线程池异常怎么处理知道吗
在使用线程池处理任务的时候,任务代码可能抛出RuntimeException,抛出异常后,线程池可能捕获它,也可能创建一个新的线程来代替异常的线程,我们可能无法感知任务出现了异常,因此我们需要考虑线程池异常情况。
常见的异常处理方式:
20 线程通信方式
volatile
多个线程同时监听一个变量,当该变量发生变化的时候,线程能够感知并执行相应的业务。这是最简单的一种实现方式
synchronized,wait()/notify()/notifyAll()
wait和notify是Object的方法,因此任意类都可以调用。wait和notify必须要加到锁内,且必须持有同一把锁;执行顺序为:开始wait->开始notify->结束notify->结束wait。
notify与notifyall区别
notify()
唤醒正在等待此对象监视器的单个线程。 如果有多个线程在等待,则选择其中一个随机唤醒(由调度器决定),唤醒的线程享有公平竞争资源的权利
notifyAll()
唤醒正在等待此对象监视器的所有线程,唤醒的所有线程公平竞争资源
21、redis
底层数据结构
redis是什么
Redis
(Remote Dictionary Server) 是开源的高性能非关系型键值对数据库,可以存储键和五种不同类型的值之间的映射,键类型只能是字符串,值支持五种数据类型:字符串
,列表
,集合
,散列表
,有序集合
;和传统的管系统数据库不同redis
基于内存处理的,读写速度非常快,故而redis
常常用来作为缓存,分布式锁,事务,持久化机制,多种集群方案。
有哪些数据结构
SDS,双向链表,压缩链表,hash表,整数集合,跳表
常用的结构
sds,hash
STRING | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作 对整数和浮点数执行自增或者自减操作 注:一个键最大能存储512MB | 1、分布式锁:SETNX(Key, Value),释放锁:DEL(Key),2、复杂计数功能缓存(用户量,视频播放量) |
---|---|---|---|
LIST | 列表 | 从两端压入或者弹出元素 对单个或者多个元素进行修剪, 只保留一个范围内的元素 | 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的数据,简单的消息队列的功能 |
SET | 无序集合 | 添加、获取、移除单个元素 检查一个元素是否存在于集合中 计算交集、并集、差集 从集合里面随机获取元素 | 交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集,做全局去重的功能,点赞,转发,收藏; |
HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对 获取所有键值对 检查某个键是否存在 | 结构化的数据,比如一个对象,单点登录 |
ZSET | 有序集合 | 添加、获取、删除元素 根据分值范围或者成员来获取元素 计算一个键的排名 | 去重但可以排序,如获取排名前几名的用户,做排行榜应用,取TOPN操作;延时任务;做范围查找。周榜,月榜,年榜 |
22、redis之SDS了解多少
c语言的缺陷
C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。
1 在 C 语言里,对字符串操作时,char * 指针只是指向字符数组的起始位置,而字符数组的结尾位置就用“\0”表示,意思是指字符串的结束。
举个例子,C 语言获取字符串长度的函数 strlen,就是通过字符数组中的每一个字符,并进行计数,等遇到字符为“\0”后,就会停止遍历,然后返回已经统计到的字符个数,即为字符串长度
C 语言的字符串用 “\0” 字符作为结尾标记有个缺陷。假设有个字符串中有个 “\0” 字符,这时在操作这个字符串时就会提早结束,比如 “xiao\0lin” 字符串,计算字符串长度的时候则会是 4
2 用 char* 字符串中的字符必须符合某种编码(比如ASCII)。这些限制使得 C 语言的字符串只能保存文本数据,不能保存像图片、音频、视频文化这样的二进制数据
3 C 语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。
举个例子,strcat 函数是可以将两个字符串拼接在一起。
c //将 src 字符串拼接到 dest 字符串后面 char *strcat(char *dest, const char* src);
C 语言的字符串是不会记录自身的缓冲区大小的,所以 strcat 函数假定程序员在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,(*这是一个可以改进的地方*)。
而且,strcat 函数和 strlen 函数类似,时间复杂度也很高,也都需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,对字符串的操作效率不高。
sds结构有哪些优化
结构中的每个成员变量分别介绍下:
- len,SDS 所保存的字符串长度。这样获取字符串长度的时候,只需要返回这个变量值就行,时间复杂度只需要 O(1)。
- alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过
alloc - len
计算 出剩余的空间大小,然后用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区益处的问题。 - flags,SDS 类型,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
- buf[],字节数组,用来保存实际数据。不需要用 “\0” 字符来标识字符串结尾了,而是直接将其作为二进制数据处理,可以用来保存图片等二进制数据。它即可以保存文本数据,也可以保存二进制数据,所以叫字节数组会更好点。
总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。
优势
O(1)复杂度获取字符串长度
Redis 的 SDS 结构因为加入了 len 成员变量,那么获取字符串长度的时候,直接返回这个变量的值就行,所以复杂度只有 O(1)
二进制安全
因为 SDS 不需要用 “\0” 字符来标识字符串结尾了,而且 SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的
不会发生缓冲区溢出,自动扩容机制
C 语言的字符串标准库提供的字符串操作函数,大多数(比如 strcat 追加字符串函数)都是不安全的,因为这些函数把缓冲区大小是否满足操作的工作交由开发者来保证,程序内部并不会判断缓冲区大小是否足够用,当发生了缓冲区溢出就有可能造成程序异常结束。
所以,Redis 的 SDS 结构里引入了 alloc 和 leb 成员变量,这样 SDS API 通过 alloc - len
计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。
而且,当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小,以满足修改所需的大小。
在扩展 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。
这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数。
节省内存空间
SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。
Redis 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同,
之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。
23、如何保证数据库与Redis
缓存一致的
问题 | 解决思路 |
先更新数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致 | 先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中 |
数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。完了,数据库和缓存中的数据不一样了 | 方案一:写请求先删除缓存,再去更新数据库,(异步等待段时间)再删除缓存(成功表示有脏数据出现);这种方案读取快速,但会出现短时间的脏数据。 方案二:写请求先修改缓存为指定值,再去更新数据库,再更新缓存。读请求过来后,先读缓存,判断是指定值后进入循环状态,等待写请求更新缓存。如果循环超时就去数据库读取数据,更新缓存。这种方案保证了读写的一致性,但是读请求会等待写操作的完成,降低了吞吐量。 |
24、缓存雪崩,缓存穿透,缓存击穿
缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决:redis高可用
- redis有可能挂掉,多增加几台redis实例,(一主多从或者多主多从),这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群。
缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决:采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力;
接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决:
- 设置热点数据永远不过期。
- 加互斥锁,互斥锁
(1)一个“冷门”key,突然被大量用户请求访问。
(2)一个“热门”key,在缓存中时间恰好过期,这时有大量用户来进行访问。
25、如何解决redis并发竞争的key问题
方案一:分布式锁+时间戳
这种情况,主要是准备一个分布式锁,大家去抢锁,抢到锁就做set操作。
加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。
分布式锁
- 加锁:
SET lock_key $unique_id EX $expire_time NX
- 操作共享资源
- 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁
问题
1、如何避免死锁
2、锁被别人释放怎么办
3、锁过期时间不好评估
时间戳
要求key的操作需要顺序执行,所以需要保存一个时间戳判断set顺序
方案二:
并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。
把Redis.set操作放在队列中使其串行化,必须的一个一个执行。
这种方式在一些高并发的场景中算是一种通用的解决方案。
26、延时队列如何实现,哪几种方案
项目中的流程监控,有几种节点,需要监控每一个节点是否超时。按传统的做法,肯定是通过定时任务,去扫描然后判断,但是定时任务有缺点:
1,数据量大会慢;2,时间不好控制,太短,怕一次处理不完,太长状态就会有延迟。所以就想到用延迟队列的方式去实现。
方案一:redis的zset实现延迟队列
生产者:可以看到生产者很简单,其实就是利用zset的特性,给一个zset添加元素而已,而时间就是它的score。
消费者:消费者的代码也不难,就是把已经过期的zset中的元素给删除掉,然后处理数据。
方案二:rabbitmq通过TTL+死信队列实现延迟队列
我们需要建立2个队列,一个用于发送消息,一个用于消息过期后的转发目标队列,生产者输出消息到Queue1,并且这个消息是设置有有效时间的,比如60s。消息会在Queue1中等待60s,如果没有消费者收掉的话,它就是被转发到Queue2,Queue2有消费者,收到,处理延迟任务。
27、 MySQL
的慢sql
优化一般如何来做?除此外还有什么方法优化?
1、通过相关指令开启慢查询日志
-- 查看是否开启了慢查询日志
show variables like 'slow_query_log';
-- 默认是OFF,不开启,可以手动开启
-- 方式一 set global slow_query_log=1;
-- 修改配置文件my.cnf,加入下面一行命令 slow_query_log = ON
2、慢查询日志找到对应的SQL,分析SQL
-- 查询慢查询日志文件路径
show variables like '%slow_query_log_file%';
-- MySQL提供了分析慢查询日志的工具mysqldumpslow
mysqldumpslow -s t -t 10 /usr/local/mysql/data/localhost_slow.log
-- 例如 ,休眠20s
SELECT sleep(20);
常用参数有 -s: 表示按何种方式排序: c: 访问次数 l: 锁定时间 r: 返回记录 t: 查询时间 al: 平均锁定时间 ar: 平均返回记录数 at: 平均查询时间-t: 返回前面多少条的数据
3、where条件单表查,锁定最小返回记录表。这句话的意思是把查询语句的where都应用到表中返回的记录数最小的表开始查起,单表每个字段分别查询,看哪个字段的区分度最高
看一下accurate_result = 1的记录数:
select count(*),accurate_result from stage_poi group by accurate_result; +----------+-----------------+ | count(*) | accurate_result | +----------+-----------------+ | 1023 | -1 | | 2114655 | 0 | | 972815 | 1 | +----------+-----------------+
我们看到accurate_result这个字段的区分度非常低,整个表只有-1,0,1三个值,加上索引也无法锁定特别少量的数据。
4、explain查看执行计划,是否与1预期一致(从锁定记录较少的表开始查询 )
索引是否应用,联合索引是否完全应用,扫描行数rows
explain select * from a;
explain select * from b;
5、了解业务应用场景,实时数据,历史数据,定期删除等因素,辅助我们更好的分析和优化查询语句
6、加索引时参照建索引的几大原则
最左匹配原则
区分度高的列作为索引,count(distinct name)/count(*),表示字段不重复的比例,比例越大扫描记录越少
索引失效情况
尽量扩展索引,不要新建索引
最佳左前缀原则——如果索引了多列,要遵守最左前缀原则。指的是查询要从索引的最左前列开始并且不跳过索引中的列。
前提条件:表中已添加复合索引(username,password,age)
该查询缺少username,查询条件复合索引最左侧username缺少,违反了最佳左前缀原则,导致索引失效,变为ALL,全表扫描
不在索引列上做任何操作(计算,函数,(自动或者手动)类型装换),会导致索引失效而导致全表扫描
7、mysqldumpslow
使用帮助
-s ORDER what to sort by (al, at, ar, c, l, r, t), 'at' is default # 默认是at 平均查询时间
al: average lock time
ar: average rows sent
at: average query time
c: count
l: lock time
r: rows sent
t: query time # 查询时间排序
-r reverse the sort order (largest last instead of first) # 反转排序顺序
-t n just show the top n queries # 仅仅显示前n行
实践
mysqldumpslow -s t -t 10 localhost-slow.log
总结
1 根据命令mysqldumpslow找到慢查询时间耗时比较长的SQL
2 explain查看执行计划,需要重点关注 type索引是否应用,联合索引是否完全应用,扫描行数rows
3 加索引时参照建索引的几大原则
3.1 最左匹配原则
3.2 区分度高的列作为索引,count(distinct name)/count(*),表示字段不重复的比例,比例越大扫描记录越少
3.3 索引失效情况
3.4 尽量扩展索引,不要新建索引
4 了解业务应用场景,实时数据,历史数据,定期删除等因素,辅助我们更好的分析和优化查询语句
28、 MySQL
的索引结构说一下
MySQL
采用B+树作为索引存储结构。
B+
树是B
树增强版, B
树是一种多路平衡树,用这种存储结构来存储大量数据,它的整个高度会相比二叉树来说,会矮很多。 而对于数据库来说,所有的数据必然都是存储在磁盘上的,而磁盘 IO 的效率实际上是很低的,特别是在随机磁盘 IO
的情况下效率更低。 所以树的高度能够决定磁盘 IO
的次数,磁盘 IO
次数越少,对于性能的提升就越大,这也是为什么采用 B 树作为索引存储结构的原因。
Mysql
的 InnoDB
存储引擎里面,它用了一种增强的 B 树结构,也就是 B+树来作为索引和数据的存储结构。 相比较于 B
树结构,B+
树做了几个方面的优化, B+
树的所有数据都存储在叶子节点,非叶子节点只存储索引,叶子节点数据使用双向链表的方式进行关联;最重要的是,B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低。
29、事务的ACID
,其中把事务的隔离性详细解释一遍
Mysql
里面的事务,满足 ACID
特性,所以在我看来,Mysql
的事务实现原理, 就是 InnoDB
是如何保证 ACID
特性的。
首先,A 表示 Atomic 原子性,也就是需要保证多个 DML
操作是原子的,要么都成功,要么都失败。
那么,失败就意味着要对原本执行成功的数据进行回滚,所以 InnoDB
设计了一 个 UNDO_LOG
表,在事务执行的过程中,把修改之前的数据快照保存到 UNDO_LOG
里面,一旦出现错误,就直接从 UNDO_LOG
里面读取数据执行反 向操作就行了。
其次,C 表示一致性,表示数据的完整性约束没有被破坏,这个更多是依赖于业 务层面的保证,数据库本身也提供了一些,比如主键的唯一约束,字段长度和类 型的保证等等。
接着,I 表示事务的隔离性,也就是多个并行事务对同一个数据进行操作的时候,如何避免多个事务的干扰导致数据混乱的问题。
而 InnoDB
提供了四种隔离级别的实现
RU(未提交读)
RC(已提交读)
RR(可重复读)
Serializable(串行化)
InnoDB
默认的隔离级别是 RR(可重复读),然后使用了 MVCC 机制
解决了脏读和不可重复读的问题,然后使用了行锁/表锁
的方式解决了幻读的问题。
最后一个是 D,表示持久性,也就是只要事务提交成功,那对于这个数据的结果的影响一定是永久性的。
理论上来说,事务提交之后直接把数据持久化到磁盘就行了,但是因为随机磁盘 IO 的效率确实很低,所以 InnoDB
设计了 Buffer Pool
缓冲区来优化,也就是数据发生变更的时候先更新内存缓冲区,然后在合适的时机再持久化到磁盘。 那在持久化这个过程中,如果数据库宕机,就会导致数据丢失,也就无法满足持久性了,所以 InnoDB
引入了 Redo_LOG
文件,这个文件存储了数据被修改之后的值, 当我们通过事务对数据进行变更操作的时候,除了修改内存缓冲区里面的数据以外,还会把本次修改的值追加到 REDO_LOG
里面。
当提交事务的时候,直接把 REDO_LOG
日志刷到磁盘上持久化,一旦数据库出 现宕机,在 Mysql 重启在以后可以直接用 REDO_LOG
里面保存的重写日志读 取出来,再执行一遍从而保证持久性。
因此,在我看来,事务的实现原理的核心本质就是如何满足 ACID
的,在 InnoDB
里面用到了 MVCC
、行锁表
锁、UNDO_LOG
、REDO_LOG
等机制来保证
30、脏读、幻影读、不可重复读
脏读:指一个线程中的事务读取到了另外一个线程中未提交的数据。
脏读是读到了别的事务回滚前的脏数据。比如事务A执行过程中修改了数据X,在未提交前,事务B读取了X,而事务A却回滚了,这样事务B就形成了脏读。
不可重复读:指一个线程中的事务读取到了另外一个线程中提交的 update 的数据。
事务A首先读取了一条数据,然后执行逻辑的时候,事务B将这条数据改变了,然后事务A再次读取的时候,发现数据不匹配了,就是所谓的不可重复读了。
幻读:指一个线程中的事务读取到了另外一个线程中提交的 insert 的数据。
为什么一模一样的SQL语句,第一次查询是10条数据,第二次查询是12条数据?难道刚才出现了幻觉?导致我刚才幻读了?这就是幻读这个名词的由来
31、说说GC过程
新创建的对象会先被分配到到Eden区。JVM刚启动时,Eden区对象数量较少,两个Survivor区S0、S1几乎是空的。
随着时间的推移,Eden
区的对象越来越多。当Eden
区放不下时(占用空间达到容量阈值),新生代就会发生垃圾回收,我们称之为Minor GC
或者Young GC。
发生GC
时,第一步会通过可达性分析算法找到可达对象。如上图,蓝色为可达对象,其他紫色为不可达对象。第二步,被标示的可达对象会被转移到S0(此时S0是From Survivor),此时存活对象年龄加1,三个对象年龄都变为1。第三步,清除Eden区所有对象。
GC
后各区域对象占用情况,如上图所示。
程序继续运行,Eden
区再次达到容量阈值时,会再次发生GC
。这时S0
(From Survivor)已经有了对象。还是同样的步骤,通过可达性分析算法找到可达对象,然后再将Eden
和S0
中的可达对象转移到S1
(To Survivor),各存活对象年龄加1。最后将Eden
和S0
中的所有对象清除。
GC
后S0
区域被清空。如上图所示。S0
和S1
发生了互换,S1
变成了From Survivor,S0变成了To Survivor。
注意,To Survivor
区永远都为空。这实际上是垃圾回收算法-复制算法在年轻代的实际应用。把年轻代分为Eden
,S0,S1
三个区域,每次垃圾回收时把可达对象复制到S0
或S1
,然后再清除掉Eden
和(S1
或S0
)中的所有对象。由于每次GC
时,新生代的可达对象非常少(绝大部分对象要被回收掉),一般不会超过新生代总体空间的10%,所以搜寻可达对象以及复制对象的成本都会非常低。而且这种复制的方式还能避免产生堆内存碎片,提高内存利用率。很多年轻代垃圾收集器都采用复制算法,如ParNew
。
在程序运行过程中,新生代GC
会反复发生,长寿对象会在S0
和S1
之间反复交换,年龄也会越来越大,当对象达到年龄上限时,会被晋升到老年代。这个年龄上限默认是15,可以通过参数-XX:MaxTenuringThreshold设置。如下图,有些年轻代对象年龄达到了上限15,被转移到了老年代。
其他晋升方式。新生代对象晋升到老年代,除了根据年龄正常晋升外。为了提高JVM的性能,JVM设计者还考虑了其他晋升方式。
大对象直接晋升。大对象会跨过年轻代直接分配到老年代。可以通过-XX:PretenureSizeThreshold参数设置对象大小。如果参数被设置成5MB,超过5MB的大对象会直接分配到老年代。这样做的目的,是为了避免大对象在Eden区及两个Survivor区之间大量的内存复制,大对象的内存复制耗时比普通对象要高很多。
注意:PretenureSizeThreshold参数只对Serial和ParNew两种回收器有效。
动态对象年龄判定。如果在Survivor空间中相同年龄对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象会直接进入老年代,而不用等到MaxTenuringThreshold中设置的年龄上限。上图,年龄为1的对象超过了Survivor空间的一半,所以这几个对象会直接进入老年代。
实际上,上面对动态对象年龄判定的描述并不精确。上图的场景也会导致相关对象晋升到老年代。年龄为1的对象加上年龄为2的对象超过了半数,这时包括年龄为2的对象以及年龄更大的对象都会被晋升到老年代。所以上图中年龄为2和3的对象都会被晋升到老年代。
老年代垃圾回收。随着年轻代对象的不断晋升,老年代的对象变得越来越多,达到容量阈值后老年代也会发生垃圾回收,我们称之为Major GC
或者Full GC,Full GC
并不是全局GC,它只发生在老年代。
虽然年轻代和老年代都会发生GC,但是每次GC的时间和成本却大不相同。由于老年代空间大小一般是年轻代的几倍,再加上老年代对象存活率很高,所以整个标记过程比较慢,GC成本也非常高。我们经常说的JVM调优,主要是为了尽量减少老年代Full GC
的时间和频次。
老年代垃圾回收器,很少使用复制算法,主要为了避免大量对象的内存复制带来的时间和空间上的开销,一般采用标记清除、标记整理算法,就地标记回收。例如,老年代垃圾收集器CMS就采用了标记清除算法。对于标记清除算法带来的内存碎片问题,CMS提供了两个参数做碎片整理。
32、bean生命周期
第一个阶段:创建前准备
这个阶段主要作用,bean在开始加载之前,要从上下文和一些配置中去解析并且查找bean有关的扩展实现,比如说像"init-method
" 容器在初始化bean时候会调用方法,destory-method
容器在销毁时候会调用方法,以及beanfactoryprocessor
这类bean加载过程中,前置和后置一些处理扩展实现,这些类或者配置其实是spring
提供给开发者实现bean加载过程中的一些扩展,在很多spring集成的中间件比较常见,比如说像dubbo
第二个阶段:创建实例
这个阶段主要作用,通过反射去创建bean
的实例对象,并且会扫描和解析bean
定义声明的一些属性
第三个阶段: 依赖注入
如果被实例化的bean
, 存在依赖其他bean
对象一些情况,则需要对依赖的bean
进行对象注入,比如常见的@autowired
以及setter
注入等,这样一些配置形式;同时在这个阶段会触发一些扩展的调用,比如说常见的扩展类beanpostProcessors
用来实现bean
初始化前后的扩展回调,比如beanfactoryaware
第四个阶段:容器缓存阶段
主要作用是把bean保存到容器中,以及spring的缓存中;到了这个阶段的bean,bean就可以被开发者使用了,这个阶段常用操作init-method,这个属性的一些方法,或者会被调用以及beanpostprocessors
后置处理器方法,也会在这个阶段触发
第五个阶段:销毁实例阶段
当spring的应用上下文被关闭时候, 那么这个上下文所有的bean
会被销毁,如果存在bean
,实现了DisposableBean
接口,或者配置了destory-method
属性的方法会在这个阶段会被调用
33、SpringMVC流程
1)客户端向web服务器(如tomcat)发送一个http请求,web服务器对http请求进行解析,解析后的url地址如果匹配到DispatchServlet的映射路径(通过web.xml中的servlet-mapping配置),web容器就会将请求交给DispatchServlet处理
(2)DispatcherServlet接收到这个请求后,再对URL进行解析,得到请求资源标识符(URI)。然后调用相应方法得到的HandlerMapping对象,再根据URI,调用这个对象的相应方法获得Handler对象以及它对应的拦截器。(在这里只是获得了Handler对象,并不会操作它,在SpringMVC中,是通过HandlerAdapter对Handler进行调用、控制的)
(3)DispatcherServlet根据得到的Handler对象,选择一个合适的HandlerAdapter,创建其实例对象,执行拦截器中的preHandler()方法。
(4)在拦截器方法中,提取请求中的数据模型,填充Handler入参,所以所有准备工作都已做好,开始执行Handler(我们写的controller代码并不是能被直接执行,需要有刚才那些操作,才能转变为Handler被执行)。
(5)Handler执行完毕后返回一个ModelAndView对象给DispatcherServlet。
(6)这个ModleAndView只是一个逻辑视图,并不是真正的视图,DispatcherServlet通过ViewResolver视图解析器将逻辑视图转化为真正的视图(通俗理解为将视图名称补全,如加上路径前缀,加上.jsp后缀,能指向实际的视图)。
(7)DispatcherServlet通过Model将ModelAndView中得到的处数据解析后用于渲染视图。将得到的最终视图通过http响应返回客户端。
34、Spring Boot自动装配
26.1 如何理解,是什么
自动装配,简单来说就是自动把第三方组件的 Bean
装载到 Spring IOC
器里面,不需要开发人员再去写 Bean
的装配配置;在我看来,Spring
Boot
是约定优于配置这一理念下的产物,所以在很多的地方,都会看到这类的思想。它的出现,让开发人员更加聚焦在了业务代码的编写上,而不需要去关心和业务无关的配置。
26.2 如何使用,怎么做
在 Spring Boot 应用里面,只需要在启动类加上@SpringBootApplication
注解就可以实现自动装配;@SpringBootApplication
是 一 个 复 合 注 解 , 真 正 实 现 自 动 装 配 的 注 解 是@EnableAutoConfiguration
。
26.3 如何实现,为什么
自动装配的实现主要依靠三个核心关键技术
引入 Starter
启动依赖组件的时候,这个组件里面必须要包含@Configuration
配置类,在这个配置类里面通过@Bean
注解声明需要装配到 IOC 容器的 Bean
对象。
这个配置类是放在第三方的 jar
包里面,然后通过 Spring Boot
中的约定优于配置思想,把这个配置类的全路径放在 classpath:/META-INF/spring.factories
文件中。
这样 Spring Boot
就可以知道第三方 jar
包里面的配置类的位置,这个步骤主要是用到了 Spring
里面的 SpringFactoriesLoader
来完成的。
Spring Boot
拿到所第三方 jar
包里面声明的配置类以后,再通过 Spring
提供的ImportSelector
接口,实现对这些配置类的动态加载。
35、 Linux
熟悉吗,用过哪些命令
排查日志会用到一些命令:`vim`,`grep`,`less`,`more`,`tail`
文件基本操作:find
线程操作:ps
,top
磁盘操作:df
,du
ps -ef | grep java
[root@localhost ~]# du -sh *
0 20221108.md
4.0K ab.md
140K arthas-boot.jar
36K arthas-output
4.0K catfile1.md
4.0K catfile2.md
4.0K catfile.md
4.0K dir1
0 dir1_backup
12K dir1_second
44K dir2
4.0K dir_abc
17M highcpuproject-0.0.1-SNAPSHOT.jar
40K logs
17M springboot_oom_demo-0.0.1-SNAPSHOT.jar
4.0K uniqfile_backup.txt
4.0K uniqfile.txt
[root@localhost ~]# df -lh
文件系统 容量 已用 可用 已用% 挂载点
devtmpfs 475M 0 475M 0% /dev
tmpfs 487M 0 487M 0% /dev/shm
tmpfs 487M 7.6M 479M 2% /run
tmpfs 487M 0 487M 0% /sys/fs/cgroup
/dev/mapper/centos-root 17G 4.1G 13G 24% /
/dev/sda1 1014M 156M 859M 16% /boot
tmpfs 98M 0 98M 0% /run/user/0
[root@localhost ~]# ps -ef | grep redis
root 682 1 0 16:10 ? 00:00:01 /usr/local/redis/bin/redis-server 0.0.0.0:6379
root 2360 2181 0 16:25 pts/0 00:00:00 grep --color=auto redis
36、ThreadLocal
36.1 是什么
ThreadLocal,也就是线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。
36.2 如何使用
场景1,ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
场景2,ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。
36.3 底层如何实现的
- Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,每个线程都有一个属于自己的ThreadLocalMap。
- ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal的弱引用,value是ThreadLocal的泛型值。
- 每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
- ThreadLocal本身不存储值,它只是作为一个key来让线程往ThreadLocalMap里存取值。
36.4 ThreadLocal 内存泄露是怎么回事?
我们先来分析一下使用ThreadLocal时的内存,我们都知道,在JVM中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。
所以呢,栈中存储了ThreadLocal、Thread的引用,堆中存储了它们的具体实例。
ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用。
“弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。”
那么现在问题就来了,弱引用很容易被回收,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会造成了内存泄漏问题。
那怎么解决内存泄漏问题呢?
很简单,使用完ThreadLocal后,及时调用remove()方法释放内存空间。
ThreadLocal<String> localVariable = new ThreadLocal();
try {
localVariable.set("鄙人三某”);
……
} finally {
localVariable.remove();
}
那为什么key还要设计成弱引用?
key设计成弱引用同样是为了防止内存泄漏。
假如key被设计成强引用,如果ThreadLocal Reference被销毁,此时它指向ThreadLoca的强引用就没有了,但是此时key还强引用指向ThreadLoca,就会导致ThreadLocal不能被回收,这时候就发生了内存泄漏的问题。
36.5 ThreadLocalMap的结构了解吗?
ThreadLocalMap虽然被叫做Map,其实它是没有实现Map接口的,但是结构还是和HashMap比较类似的,主要关注的是两个要素:元素数组
和散列方法
。
-
元素数组
一个table数组,存储Entry类型的元素,Entry是ThreaLocal弱引用作为key,Object作为value的结构。
private Entry[] table;
-
散列方法
散列方法就是怎么把对应的key映射到table数组的相应下标,ThreadLocalMap用的是哈希取余法,取出key的threadLocalHashCode,然后和table数组长度减一&运算(相当于取余)。
int i = key.threadLocalHashCode & (table.length - 1);
这里的threadLocalHashCode计算有点东西,每创建一个ThreadLocal对象,它就会新增0x61c88647
,这个值很特殊,它是斐波那契数 也叫 黄金分割数。hash
增量为 这个数字,带来的好处就是 hash
分布非常均匀。
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
36.6 ThreadLocalMap怎么解决Hash冲突的?
我们可能都知道HashMap使用了链表来解决冲突,也就是所谓的链地址法。
ThreadLocalMap没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式——开放定址法。开放定址法是什么意思呢?简单来说,就是这个坑被人占了,那就接着去找空着的坑。
如上图所示,如果我们插入一个value=27的数据,通过 hash计算后应该落入第 4 个槽位中,而槽位 4 已经有了 Entry数据,而且Entry数据的key和当前不相等。此时就会线性向后查找,一直找到 Entry为 null的槽位才会停止查找,把元素放到空的槽中。
在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该槽位Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。
36.7 ThreadLocalMap扩容机制了解吗?
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
再着看rehash()具体实现:这里会先去清理过期的Entry,然后还要根据条件判断size >= threshold - threshold / 4
也就是size >= threshold* 3/4
来决定是否需要扩容。
private void rehash() {
//清理过期Entry
expungeStaleEntries();
//扩容
if (size >= threshold - threshold / 4)
resize();
}
//清理过期Entry
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
接着看看具体的resize()
方法,扩容后的newTab
的大小为老数组的两倍,然后遍历老的table数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的newTab
,遍历完成之后,oldTab
中所有的entry
数据都已经放入到newTab
中了,然后table引用指向newTab
36.8 父子线程怎么共享数据?
父线程能用ThreadLocal来给子线程传值吗?毫无疑问,不能。那该怎么办?
这时候可以用到另外一个类——InheritableThreadLocal
。
使用起来很简单,在主线程的InheritableThreadLocal实例设置值,在子线程中就可以拿到了。
public class InheritableThreadLocalTest {
public static void main(String[] args) {
final ThreadLocal threadLocal = new InheritableThreadLocal();
// 主线程
threadLocal.set("不擅技术");
//子线程
Thread t = new Thread() {
@Override
public void run() {
super.run();
System.out.println("鄙人三某 ," + threadLocal.get());
}
};
t.start();
}
}
原理很简单,在Thread类里还有另外一个变量:
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
在Thread.init的时候,如果父线程的inheritableThreadLocals
不为空,就把它赋给当前线程(子线程)的inheritableThreadLocals
。
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
37、idea解决git代码冲突问题
解决上图中的冲突方案如下:
Accept Yours:代表以自己的为准
Accept Theris:代表以更新下来的文件为准
Merge:代表手动合并
一般解决冲突我们都是选择 Merge
36、面试必备
简历,面经,知己知彼,录音设备