JAVA基础八股文
问:java中序列化是怎么实现的呢?
问:java的流有哪些呢?
从方向方面,主要就是输入流和输出流。
从单位方面,主要就是分为字节流和字符流。字节流主要就是InputStream,OutputStream。字符流主要就是Reader,Writer。
问:抽象类和接口有什么区别呢?
从方法编写方面,抽象类中可以抽象方法和普通方法,而接口中只能编写抽象方法。
从继承和实现方面,抽象方法只能继承一个类并且可以实现多个接口,而接口可以继承多个接口。
在变量的定义方面,接口只能定义静态变量,抽象类可以定义普通变量和静态变量。
问:final关键值有了解过吗?
在修饰方法的时候,说明该方法无法被重写。
在修饰类的时候,说明该类无法被继承。
在修饰属性的时候,说明该变量从创建到销毁过程中不会改变。
在修饰形参的时候,说明该形参的引用在方法执行前后都会不发生改变。
问:异常类有哪些?
异常主要就是Exception,Error。
Exception主要就是运行时异常和检查时异常,为了保证代码恶的正常运行,我们需要主动使用try/catch进行捕获,常见的就是IoEception,SQLExceptinon。
Error主要就是就是发生在运行时的异常,就比如:oom,threadDeath。
问:Filter和Interceptor有说明区别?哪个先执行呢?
- 在作用域方面,Filter作用域每个请求,而interceptor主要就是对Controller方法的请求起作用。
- 在执行时机方面,filter会在请求加入dispatchServlet前和返回给客户端前执行,而interceptor会在执行Controller方法的时候和进行视图渲染前执行。
- Filter会像执行而interceptor随后执行。
问:java中共享变量有哪些?
1.静态变量。
2.使用Voliate修饰java中得变量就会变成共享变量。
Redis八股文
问:Redis数据结构的底层实现由了解过吗?
String类型的底层使用动态字符串实现的。
List类型的底层使用双向链表+压缩数组实现的。
set类型的底层使用哈希表+数组整数数组实现的。
hash类型的底层使用压缩数组+哈希表实现的。
zset类型的底层使用压缩数组+跳表实现的。
问:zset的底层结构有了解过吗?
zset的底层结构主要就是跳表+压缩列表。
压缩列表:本质就是一个数组,在其中记录 列表的长度,尾部得偏移量,列表的个数。
跳表:就是基于二分查找的思想,对链表创建多级索引,就是基于每隔一个节点做索引,以此类推,创建索引的层数就是 logn + 1。整个查找过程就是在多级索引间跳来跳去。
问:我看你在做项目的时候都使用到redis,你在最近的项目中哪些场景下使用redis呢?
缓存和分布式锁都有使用到。
问:说说在缓存方面使用
1.在我最写的物流项目中就使用redis作为缓存,当然在业务中还是比较复杂的。
2.在物流信息查询模块使用中使用二级缓存,一级缓存使用的是Caffeine,二级缓存就是使用redis。
问:对缓存击穿,缓存雪崩,缓存穿透有了解过吗?说说者三个缓存问题的解决方案吧。
1.缓存击穿:某个热点key设置了过期时间,在高并发查询的情况下,该热点key过期了,导致大量的请求去访问数据库,最终压垮数据库。
解决方案:
(1)不给热点key设置过期时间(在redis中设置的过期时间都是逻辑过期时间,通过逻辑字段来判断key是否过期)。
(2)使用分布式锁,保证每次只有一个请求去访问数据库,在每次访问数据库前再做一次查询缓存的操作,然后获取锁并去数据库查询,将查到的数据重新缓存起来,下一次请求就会在缓存中查询到数据,就不会查询数据库,防止压垮数据库。
2.缓存雪崩:第一种情况:大量的key设置了相同的过期时间,在同一大量的key失效,导致大量的请求去访问数据库,导致压垮数据库。第二种情况:redis宕机。
解决方案:
(1) 错开key的过期时间(TTL):在每个key的统一过期时间上在随机加上1~5分钟的过期时间。
(2)设置服务降级,服务熔断,服务限流,到达阈值的时候直接返回自定义的错误信息。(作为系统的兜底策略)
(3)为redis搭建集群,就包括哨兵模式,集群模式。
(4)做二级缓存。(可以重点介绍,就引导面试官到运单信息查询模块,给他介绍二级缓存的实现还有缓存同步的问题,主要讲caffeine缓存同步的问题)
3.缓存穿透:查询的key在缓存和数据库中都不存在,每次都会进入数据库查询,当大量的这种key访问数据库,就会导致缓存穿透。(这种情况多半上是恶意攻击)
解决方案:
(1)在缓存中储存空值:当在数据库中没有查到数据时,将key关联null的键值对存储到缓存中,后续就会走缓存,这种解决方案缺点很明显,就是存储大量无用的存储,浪费空间。
(2)使用布隆过滤器:在查询缓存的时候先去布隆过滤器中查询是否存在缓存,再去查询缓存。
问:具体讲讲你对这个布隆过滤器理解?
布隆过滤器类似hash表叫做bitmap(位图),在没有位置存放的是二进制的数据,1表示存在,0表示不存在。通过哈希函数计算储存位置。在后续查询缓存前先去布隆过滤器中查询数据是否存在,如果存在才去查缓存,如果不存在就直接返回空。
布隆过滤器的优点就是:储存二进制的数据,而非真实的数据,查询速度快。缺点:判断数据是否存在有误判率,不能做删除操作。
为了降低布隆过滤器的误判率,因为两个数据的hashCode可能相同,所以我们可以设置多哈希函数操作,较低过滤器的误判率。
我们主要是根据redisson设置布隆过滤器,可以设置其误判率。在高并发的场景下,一般误判率控制在5%之内就可以了。
问:说说分布式锁方面的使用
在项目的支付模块中的扫码支付为了保证交易单的状态不会被除当前业务的其他业务修改,计算运力,我们都会使用分布式锁。
问:你能简单的讲述一下分布式锁的实现吗?(引导面试官到你的项目中,去解释)
分布式锁
可以跟面试官说说优惠劵超卖的问题。
在redisson中的获取锁方法底层主要是通过Lua(能够调用redis命令,保证命令执行的原子性)实现的,如果获取锁方法没有设置过期时间,则分布式锁会有watch dog来保证延长锁的有效时间。
还可以通过setnx来实现分布式锁(因为redis是单线程的)。 通过 set If not exists并设置过期时间实现分布式锁,但是存在的问题就是无法确定要给锁设置多长的有效时间,所以在项目中比较少使用。
公平锁 (我们项目中使用的分布式锁,保证可重入性)
公平锁主要还是基于redisson实现的。锁的结构采用的是hash结构,以大小key的形式,在我业务中,使用订单id和交易单id进行拼接作为大key,使用当前的线程id作为小key,vlaue存放的就是上锁的次数。在业务修改交易单状态是需要先判断当前交易单是否被上锁(通过订单id,交易单id,进程id来获取对应的锁),如果被上锁,则当前线程的其他业务不能进行操作。保证订单状态的准确性。(判断幂等性) 保证可重入性,根据当前线程id来获取分布式锁,解决死锁的情况(分布式锁的底层实现)。
问:分布式锁可以实现重入吗?
重入的意思就是:在同一个线程中获取锁后继续获取锁。因为我们分布式锁中小key使用的是线程id,value就是上锁的次数,我们每次进行重入时就是value值+1,在释放锁的时候,就将对应的value-1,在value为0的时候就删除对应的信息。其他微服务就可以获取此分布式锁了。通过线程id的不同来保证分布式锁的互斥。(蓝字选答)
问:redisson实现的分布式锁,可以解决主从一致性的问题吗?
不能解决主从一致的问题,单master节点获取锁后就没释放锁就宕机了,此时slave节点变成了master节点,因为新的master没有上锁,所以新的master会进行上锁,破坏了锁的互斥性。为了解决这个问题我们可以使用红锁,也就是给一般以上的节点添加分布式锁,但是这样效率就变的很低,为了提高效率,建议采用zookeeper实现分布式锁。(听说过zookeeper实现分布式锁)
问:在集群的项目中为什么不能用关键字synchronized呢?
在两个微服务中,如果使用synchronized是无法达到同步上锁效果,因为两个微服务是两个单独的JVM。
问:了解过双写一致性吗?
双写一致性:当数据库中的数据发生修改的时候,我们需要修改缓存中的数据,保证数据库和缓存中的信息相同。
在读操作的时候,会先到缓存中查询数据,如果没有命中的话就到数据库中查询。
在写操作的时候,会采用延迟双删。
问:在延迟双删中为什么要延迟删除缓存?
数据库采用的是主从模式,遵循读写分离,需要一些时间来将主数据库中的数据同步到从数据库,但是还是可能出现脏读的情况。
问:在延迟双删中为什么要删除两次缓存呢?
如果只有一次删除缓存操作的话就会有两种情况。
1.情况1:先删除缓存,再修改数据库。线程1删除缓存。在线程1要修改数据库前。线程2去做查询操作,发现没有缓存,就去数据库中查询数据并做缓存,之后线程1修改了数据库中的信息,最终出现缓存数据和数据库数据不相同的情况。(当前线程在删除缓存并且在写数据库前其他线程查询了就的数据)
2.情况2:先修改数据,再删除缓存。当前缓存中方没有数据,线程1做查询操作,准备将旧数据缓存前,线程2做了修改数据和删除缓存的操作(此时是没有缓存的),最终缓存中就存储了旧的数据,出现脏读的情况。(当前线程要将旧的数据进行缓存的过程中进行了写数据库和删除缓存的操作)
为了防脏读的缓存被使用,所以在数据同步的需要两次删除缓存的操作。
问:延迟双删是没法保证强一致性的,有什么强一致性的方法吗?
方法一:使用分布式锁,每次只有一个线程进行操作,效率比较低下。
方法二:因为缓存中的数据大多是读多写少,所以我们可以使用在读取数据的时候使用共享锁, 多个线程同时可以读取缓存但是其他线程不可以写,在修改数据的时候使用排他锁,会阻塞其他线程的读写操作。
排他锁和共享锁可以使用redisson实现,通过redissonClient获取对应的读写锁。
1.读锁也就是共享锁。
2.写锁也就是排他锁。
必须保证读写锁的名字相同。
问:那有了解过最终一致性的方法吗?
在我们支付模块中,每次支付都需要获取支付模板也就是支付宝支付和微信支付模板,这些数据我们基本上不会修改,会将其缓存起来,在修改mysql中模板的数据的时候,通过rabbitmq发送消息,异步修改缓存的数据,达到最终一致的效果。
为什么mq可以实现最终一致的效果呢?
mq中的消息都是按照顺序进行消费的,消息的消费是和事务绑定的,如果事务进行了回滚操作则被消费的消息也会被重新放回队列的原先位置中,并且接收到消息的服务会异步进行处理。
当然我们不仅仅可以使用rabbitmq实现最终一致的效果,我们还可以使用canal实现最终一致的效果。canal主要是通过mysql的主从同步实现的。通过监听bin Log日志的方式来修改缓存中的信息,达到最终一致的效果。(binLog日志主要是储存DDL(数据定义语句)和DML(数据操纵语句))
问:redis做为缓存,数据的持久化是怎么做的呢?
在持久化上用 RDB和AOF两种方式。
问:说说你对RDB和AOF的理解吧。
1.RDB:通过对数据做快照的方式做持久化,将快照存放到磁盘上,后续需要恢复数据的时间就使用该快照进行恢复。
RDB的执行原理:在主进程会有一个页表文件(用于映射数据在物理内存上的数据),通过复制该页表到子进程中,子进程通过页表找到数据并做快照。
当是如果在修改数据的过程中做RDB就会出现脏读的情况,RDB通过设置数据为只读,在修改数据的时候复制一份相同的数据进行修改如何修改页表的映射,最终解决脏读的问题。
2.AOF:在redis做操写的指令的时候,会将这些指令都存储到对应的AOF文件中,在后续数据需要恢复的时候就执行AOF文件中的所有指令。
AOF执行的原理:就是每次去记录操作写的指令到AOF文件中。
问:RDB和AOF有什么区别吗?
1.RDB是对整个内存做快照,而AOF是记录redis每一条执行的语句。
2.RDB的在两次备份间可能会出现数据丢失的情况(redis宕机)完整性低, 而AOF的完整性比较高,其取决去刷盘的策略。
3.RDB的文件比较小,而AOF会记录每日一条指令,所以AOF的文件比较大。
4.RDB的数据恢复比较快,因为AOF文件比较大,需要一条条的执行指令,恢复速度慢。
5.RDB系统占用高,需要大量的CPU和内存的消耗,而AOF系统占用比较小,只要是文件的IO操作,但是在AOF文件重写的时候需要大量的CPU和内存的消耗。
问:RDB和AOF谁的恢复速度更快,我们在平时要怎么选择呢?
RDB的快照文件本质是二进制文件,其体积比较小,而AOF文件需要保存redis中的写操作指令,在体积上比较大,所以在恢复速度上RDb比较快,但是RDB存在数据丢失的风险,在我的项目中只要主要还是使用AOF,像支付方式模板这种比较重要的数据,我们应当降低其丢失的风险,在刷盘策略上使用每秒进行一次刷盘,也就是每秒批量写入一次。
问:redis的key过期后,会立刻做删除操作吗?
redis有两种数据过期策略,分别是惰性删除和定期删除。
惰性删除:我们会为每个key设置一个过期时间,在每次获取数据的时候会先去判断该key是否过期,如果过期直接删除key,如果没有过期则直接返回,也就是说在没有使用数据时不会主动删除。(优点:对CPU友好,只在查询时才做过期判断。缺点:内存消耗大。)
定期删除:定期去判断一定数量的key是否过期,如果过期则直接进行删除操作,定期删除又分为SLOW模式和FAST模式。(优点:内存消耗小。缺点:定期查询key需要消耗大量时间。)
SLOW模式:默认的频率为10hz(一秒内进行十次),每次不大于25ms,我们可以通过配置文件中的hz修改频率。
FAST模式:执行频率是不固定的,两次删除的间隔不小于2ms,每次耗时小于1ms。
redis默认采用的数据过期删除策略:惰性删除+定期删除配合使用。
问:假如缓存过多,内存有限,内存满了怎么办呢?
在redis中提供了八种数据淘汰的策略,那默认的处理就是noeviction,就是直接报错。我们可以提供配置文件修改对应的淘汰策略。策略中有两种重要的思想,分别是LRU和LFU。
LRU:就是将当前使用间距最长的数据进行淘汰。(对应两个)
LFU:就是将当前使用频率最少的数据进行淘汰。(对应两个)
随机淘汰策略。
八个策略的不同就在于key的类型(全部key和设置过期时间的key)和使用的思想不同(LRU和LFU)。
在我的项目中主要使用的淘汰策略就是: allkey-lru,淘汰掉当前使用最少的数据。
问:数据库中有100w条数据,redis只能存储20w数据,如何保证redis中的数据是热点数据呢?
使用 allkey-lru策略,保证经常使用的数据不被淘汰。
问:redis缓存的空间用完了会怎么样?
如果是默认情况下的话,会直接报错,因为默认的淘汰策略就是noeviction,如果设置了其他的策略则会对数据进行淘汰。
问:能介绍一下redis的主从复制和主从复制的流程吗?
单个redis节点的并发能力是有限的,所以为了提高并发能力,我们需要搭建redis集群,就比如:主从复制。
主从复制的流程
主从复制主要分为:全量同步和增量同步。
全量同步:在salve请求数据同步的时候会携带application Id和offset,如果master判断出applid和自己的不一样,就认为slave是第一次进行同步,所以会进行全量同步。 master会执行bgsave生成RDB文件给slave,slave进行同步,在此过程中master可能会进行新的指令,master会将这些指令存储到日志文件,在加载RDB完后再将日志文件传给slave进行最终的同步,master同步applid和offset给slave。
增量同步: 在master判断出slave中的applid和自己一样就认为不是第一次同步,直接进行增量同步,从日志文件中获取到offset的位置,将offset之后的数据发送给slave,进行数据的同步。
问:那主节点宕机了又该怎么办呢?
为了提高redis集群的高可用,我们可以使用哨兵模式,解决主节点宕机的问题。
问:那说说你对哨兵模式的理解吧
哨兵模式:通过sentinel的心跳机制去监测master的状态,当然为了保证高可用,我们也需要对sentinel搭建集群。sentinel每隔1秒就会向集群的节点发送ping指令,当master失效后会就选择出新的master。
在sentinel中有两种概念:主观下线和客观下线。
主观下线:当有一个sentinel发现redis节点没有返回响应就认为其为主观下线。
客观下线:当有一半以上的sentinel节点发现redis节点没有返回响应就认为其为客观下线。
当master发生客观下线就会筛选slave作为新的master。
筛洗新master的优先级为下:
1.判断master与slave断开的时长,如果时长超过指指定值则直接排除。
2.判断slave的权重,如果权重越小优先级就越高。
3.如果权重相同的话,就比较slave的offst值也就是偏移量,如果offset越大优先级就越高。
4.判断slave运行id的大小,如果运行id越小则优先级越高。
问:哨兵模式可能会出现脑裂的情况,有了解过吗?
由于网络不稳定的因素,多个sentinel都没有ping到master,此时master是没有宕机的,而哨兵模式就会选择出新的master,就出现两个master的情况,而客户还是对旧的master进行数据的操作,在网络稳定后,旧的master就会变成新master的slave,最终导致数据操作的丢失。从而形成脑裂的现象。
解决脑裂的方案:
1.在redis的配置文件中设置最少的slave个数(对应的配置项:min-replicas-to-write)。当出现脑裂的情况时,旧的master会监测到没有slave,就不会做数据的操作直接返回错误信息,防止数据操作的丢失。
2.在resdis的配置文件中设置最大数据同步的延迟时间。(对应的配置项:min-replicas-max-lag)。当出现脑裂的情况时。旧的master在做数据的同步时一直找不到slave,当时间超过最大的延迟时间就会直接返回错误信息,防止数据操作的的丢失。
问:你们使用redis是单点还是集群?
我们的redis使用主从模式(一主一从)+ 哨兵模式。当然在容量不够的时候,会为不同的服务配置独立的redis主从节点。
问:redis分片集群有什么作用?
1,存在多个master,这些master存储不同的数据,多个master可以解决并发写的问题。
2.每一个master都可以有多个slave,解决并发读的问题。
3.在分片集群中,不再通过sentinel监测master的健康状态,而是通过master之间ping状态,判断各个master的健康状态,最终达到哨兵模式的效果。
4.客户端在访问对应的数据时会路由到对应的节点上。
问:redis分集群中的数据是如何存储和读取的呢?
redis分片集群采用的是哈希槽的结构实现的,哈希槽总共有16384个。master节点等量的获取哈希槽范围用于存储数据。
在存储数据的时候,通过有效部分取哈希槽总数的模计算出哈希值(这里的有效部分指key前大括号中的有效值(就比如: set {}key value),如果没有大括号key就是有效部分),找到master后做写操作。
在读数据的时候,通过计算出的哈希值确定存储数据的master位置,从该master对应的slave中读取数据,到达读写分离的效果。
问:redis是单线程的,为什么会那么快呢?
1.完全基于内存的,是C语言编写的。
2.采用单线程,避免了不必要的上下文切换可竞争条件,多线程还需要考虑线程问题。
3.采用多路IO复用模型,非阻塞IO模型。
问:说说对阻塞IO和非阻塞IO的理解吧。
我们先需要知道内存的使用情况为:用户空间和内核空间。
用户空间:只能执行受限制的指令,不能直接调用系统资源,需要通过内核提供的接口来调用系统资源。
内核空间:可以执行特权指令,可以直接调用系统资源。
阻塞IO:在用户线程要获取内核中获取数据,而此时内核中没有数据,用户线程就会等待从而导致用户线程阻塞,当内核中有数据后,数据需要重内核缓冲区复制到用户缓冲区,在这个过程中用户线程也需要等待从而导致线程堵塞。在这两个阶段中用户线程都是堵塞的,这就是堵塞IO。
非堵塞IO: 用户线程要从内核中获取数据,但内核中没有数据,此时内核直接返回错误信息给用户线程,反之线程堵塞,用户线程会循环的调用内核的方法直到内核中有数据,当内核中有数据后,用户线程就会等待内核缓冲区中的数据复制到用户缓冲区中,此时用户线程是阻塞的。在第一阶段是不阻塞的,而第二阶段是堵塞的,这就是非堵塞IO。比阻塞IO优化没多少,而且忙等机制可能会导致CPU的空转,CUP使用率暴增。
问:解释一下什么是多路IO复用模型?
单线程同时监听多个Socket(操作客户端)的状态,某个Socket可读可写时得到通知,防止出现忙等的情况,提高CPU的利用率,可能同时存在多个可用Socket,通过循环做读取数据的操作,多路IO复用主要通过epoll模式实现的,将已就绪的socket存到用户空间中,就不需要遍历判断socket是否就绪,从而提高性能。
问:有了解过redis的网络模型吗?
通过多路IO复用 + 事件处理器实现的。事件处理器主要是:连接应答处理器,命令回复处理器,命令请求处理器。在redis6.0之后,使用多线程来处理命令的回复和命令的请求从而实现高效的网络请求,在命令执行的时候依旧是单线程(线程安全的)。
MySQL八股文
问:Mysql的存储引擎有理解过吗?
我比较了解就是 Innodb,myisam,Memory。
Innodb:现在的mysql默认存储引擎就是innodb,主要就是因为它是唯一一个支持事务的存储引擎,支持表级锁和行级锁,其索引的底层结构使用的是B+树,在数据,索引,表结构都存储到.idb中。
Myisam:其不支持事务,仅支持表级锁,其索引的底层结构为B+树,表结构存储到.sdi中,索引存储到.myi,数据存储到.myd中。
Memory:基础内存进行存储的,主要就是用sdi存储表结构。
问:如何定位慢查询?
在我们的项目中,在上线时使用skywalking来定位慢的查询,如果发现是某个SQL执行速度慢,我们就可以使用skywalking的追踪功能,来确定SQL语句。
而在测试环境中,我们使用MySQL提供的慢日志来确定慢查询的位置。mysql是默认没有开启慢日志的,需要通过配置文件开启并设置快查询的最大时间,超过这个时间就认为其为慢查询,我们就可以在慢日志中找到慢查询的sql,在我们的项目中设置最大的时间为2秒。
#开启慢日志
slow_query_log=1
#设置快查询的最大时间
long_query_time=2
问:一个SQL语句执行很慢,应该如何分析呢?
我们可以借助Mysql提供的关键值 explain来展示出某个SQL语句的状态。
在该状态中包含属性 key和key_lenSQL中使用到的索引,如果提供我们的索引出现失效的情况就可以修改和SQL和添加索引。属性type表示SQL的性能,通常其值为const,提供type判断是否存在全索引扫描或全盘扫描。属性extra表示建议属性,提供该属性判断是否出现回表的情况。
问:有了解过索引吗?
1.索引是帮助Mysql高效查询数据的数据结构。
2.索引提高检索效率,大大降低IO成本。
3.通过索引列对数据排序,大大降低了排序的成本。
问:B+树和B树的区别?
- B+树作将数据存放在叶子节点上,非叶子节点就可以组织更宽的结构,就会变的更矮更胖,提高查询速度。
- B+树中叶子节点使用双向链表进行存储的,并且按顺序进行存储的,不同于B树,查询范围数据的时候进行多次的从根节点进行查询,而B+树在查询范围数据的时候只需要从根节点查询一次即可。且排序的性能更好。(B+树在做范围查询的时候只需要从根节点遍历一次,二B树则需要遍历对应数量的个数)
问:什么是聚簇索引和非聚簇索引?
聚簇索引:索引结构和数据是存放在一起的,也就是在B+树的叶子节点中存放整行的数据。
非聚簇索引(也叫二级索引):索引结构和数据不是存放在一起的,也就是在B+树的叶子节点上存放对应的主键且是不唯一的。我们为字段添加索引通常就是二级索引。
问:知道什么是回表操作吗?
通过二级索引查到的主键再去聚簇索引中查询数据行的过程就是回表。而直接查询聚簇索引则不会出现回表的情况。
问:有了过覆盖索引吗?
查询数据通过索引进行查询,返回列都可以在索引的数据中找到(包含在其中),就是覆盖查询。
使用id主键进行查询就是覆盖索引查询,因为聚簇索引的数据中包含id主键,性能高。
在做查询的时候如果返回列吧全部存在于索引中就会回表查询,所以尽量避免使用select *。
问:Mysql超大分页查询怎么进行优化?
Mysql做limit分页查询的时候,需要做排序,这个过程非常耗时。
优化方案:覆盖索引 + 子查询。先通过子查询出分页排序完后的id主键,因为是主键所以会直接进行覆盖索引查询。通过子查询的id关联表中的id查询出分页后的数据。
select * from table t
(select id from table limit 0 10 order by id) s
where t.id = s.id
问:索引的创建原则有哪些?
1.数据量大于十万且查询的频率表较高的表我们才会考虑创建索引。
2.如果一个表需要添加索引,我们应该选择作为查询字段,排序字段,分组字段的字段作为索引,且字段的区分度要高。
3.在添加索引的时候都使用复合索引来创建,尽量使用覆盖查询,降低回表的概率。
4.如果需要对长字符串添加索引我们可以使用前缀索引。
5.控制索引的数量,并不是越多越快,在增删改的时候我们也需要消耗时间来维护索引。
问:什么情况下索引会失效?
复合索引
1.在使用复合索引的时候不遵循最左前缀法则。在做条件查询的时候跳跃某一列字段导致索引失效。
2.在条件查询中的范围查询的右遍的列不能使用索引,使用也会失效。(如果三个都有效的话 key_len应该为六百多,说明此时address字段失效)
3.不能在索引列进行运算操作,这会导致索引失效。
4.在条件查询的时候如果没有加单引号也会导致索引失效。(就比如:0和'0',会进行类型转换,导致索引失效)
5. 在模糊查询的时候,如果字符串中是以%开头的就会导致索引失效。(就比如: "%abc")
在我遇到的随影失效问题就是没有遵循最左前缀原则,只要实在测试的时候通过explain查询SQL语句的执行状态来判断的。
问:谈谈你对SQL的优化经验?
在做SQL优化的时候主要从:建表时,使用索引时,sql语句编写,主从复制,读写分离的方面进行考虑,当数据量过大的时候考虑使用分库分表。
问:创建表的时候你是怎么优化的?
我们主要遵循阿里的开发手册,就比如在使用整数类型的时候就考虑使用:tinyInt,Int,bigInt,如果是逻辑字段就使用tinyInt,在使用字符串是考虑使用:char,varchar,text。
问:那在使用索引的时候如何进行优化?
讲出索引失效的五种情况,再使用SQL的时候避免使用select *,使用覆盖索引减少回表的操作。
问:你平时SQL语句是怎么优化的?
select 指明字段,不要使用select * from防止回表的操作。在使用聚合查询的时候尽量使用union all而不是union,union会多一次过滤,在效率上比较低。使用inner join 而不使用 left join/right join,如果必须使用的,一定要以小表为驱动。
问:事务的特性是什么?可以详细说一下?
这里你可以取钱的例子来引导模式官。
原子性:在事务中的语句要么都成功要么都失败。
一致性:在事务中数据的总量不会变。
持久性: 提交和会滚的数据都会持久化到数据库。
隔离性:事务中间是是相互隔离的,是不会相互影响的。
问:并发事务带来了哪些问题?
并发事务可能会出现三种问题。
1.脏读:事务1读取到事务2未提交的数据。
2.不可重复读:事务2先后读取事务1中的某合个数据,两次的结果不一样。
3.幻读:一个事务在按条件查询数据时没有查到数据吗,但是插入操作时,又发现该数据已经存在。因为其他事务在这个过程中插入数据(选答)
怎么解决解决这些问题?
通过设置过隔离级别来解决。隔离级别包括:
1.读取未提交,无法解决并发事务带来的问题。
2.读取已提交,可以解决脏读。
3.可重复读,可以解决脏读,不可重复读。
4.串行化,事务只能一个一个执行,可以解决脏读,不可重复读,幻读,隔离级别最高,效率最低。
MySQL默认的隔离级别是什么?
Mysql默认使用的隔离级别是:可重复读。
问:undo log和rado log有什么区别?
redo log:用于记录数据页的物理变化,当服务宕机的时候进行数据同步操作。保证了事务的持久性。
undo log:记录逻辑日志,就比如:当做插入操作时会在日志中记录逆向的操作也即是删除,在事务回滚的时候会执行逻辑日志中的指令。保证了事务的持久性和原子性。
问:隔离级别是怎么实现的?
排他锁+MVCC实现的。
问:说说你对MVCC的理解吧?
多版本并发控制。维护一个数据的多个版本,使得读写操作没有冲突。
mvcc主要有三个重点:
1.隐藏字段:trx_id(事务id):记录当前事务的id,其为自增的。 roll-pointer:指向上一个版本的事务记录地址。
2.undo log:回滚日志,存储老版本的数据,版本链:多个同时修改某条记录,产生多版本的数据,通过rool-pointer指针形成链表。
3.readview:解决一个事务查询选择版本的问题。
根据readView的匹配规则和当前事务id找到对应的版本信息。(问规则时答:1.判断是事务id是否为当前事务的id。2.是否是活跃事务id。3.判断事务是否是在readview创建后开启的,也就是事务id大于当前事务。4.判断事务中的数据是否已提交,事务id小于最小的事务id。)
不同的隔离级别快照读是不一样的,最终的访问结果也是不一样的。(问时答:当前读:读取的是最新的数据并且会加锁。快照读:读取的是记录数据的可见版本,不会加锁。)
读已提交:在每次快照读的时候都会生成readview。
可重复读:在有在第一次快照读的时候才会生成readview,后续的快照读都是使用该readview的复制,保证数据的一致性。
问:隔离级别中可重复读有什么缺点呢?
1.无法解决幻读的问题,当我们事务去读取一定范围的数据,且在该过程中其他的事务修改了该范围的数据,此时两次读取就会出现查询结果不一致的情况,最终导致幻读。
2.无法读取到最新的数据,因为可重复读在每次读取的时候都使用同一个readView,且readView并非当前最新的数据,最终导致无法读取到新的数据。
问:MySQL的主从同步有了解过吗?
Mysql主从同步的核心就是bin log(二进制日志),这个日志中主要记录 DDL(表的操作),DML(表中数据的操作)。
1.master中事务提交数据后,会将修改的数据保存到bin log中。
2.slave有个iothread线程会监控的bin log的变化,并将变化写入relay log中。
3.slave有个SQLthread线程会监控relay log,将改变的数据写入slave中。
问:你在项目中有使用过分库分表吗?
在物流项目中的订单服务的数据非常庞大,请求数多且业务累计大。差不多单表的数据有100w条,这时我们就使用分库分表。
分库分表有四种策略:
1.水平分库:通过将一个库中的数据拆分到多个库中,解决海量数据存储和高并发的问题。主要通过sharing-sphere和mtycat实现。
2.水平分表:解决单表存储和性能的问题。
3.垂直分库:根据业务来拆分库中的表,在高并发的情况下提高磁盘IO和网络连接数。每个微服务都有自己的表。
4.垂直分表:冷热数据分离,多表不会相互影响。就比如:表中字段为id,name,des,将id,name和des分离,id和name都是热数据而des为冷数据,访问频率较低。
框架八股文
问:Spring中的设计模式有哪些?
我们目前主要了解的就是工厂模式,代理模式,单例模式,策略模式,责任链模式。
工厂模式:常见的工厂模式主要就是BeanFactory(延迟注入)和ApplicationContext(完全注入),我们无需知道类如何创建,直接从工厂中获取即可。
单例模式:ioc默认情况下就是一个单例模式,每次获取相同恶的类的时候,获取的对象是同一个。不会就行多次的创建。单例模式还分为饿汉式(主动创建单例)及懒汉式(需要时再创建单例)。
代理模式 :aop就是采用代理模式来实现的。当要代理的对象实现接口的时候,我们就会使用JDK Proxy来生成代理对象。如果代理的对象没有实现接口的话,我们就回使用CGLIB生成一个被代理对象的子类作为代理对象。
策略模式:在我们编写项目的时候就有使用到策略模式,因为我要控制分布式锁的失败策略,我们会在枚举类编写一个抽象方法,编写多个内部方法去重新该方法,不同的内部方法就是不同的策略。
责任链模式:stram流就是使用该模式的,按照对应的顺序去执行方法,并向下传递对象。起到解耦合的作用。
问:spring框架的单例bean是线程安全的吗?
在spring框架中有个注解叫@Scope可以设置bean的状态,默认就是singleton也就是单例。
bean进行注入的时都是无状态的,其不会被修改的。所以没有线程安全的问题。但是如果bean中有成员变量时就可能会有线程安全的问题,因为该成员变量可能会被多个线程修改,为了解决这个问题我们可以加锁或将bean设置为多例。(@Scope设置为prototype)
问:什么是AOP?
面向切面编程,将那些于业务无关的复用性比较高的代码快抽取出来,较低代码的耦合度。
问:在你的项目中有使用过AOP吗?
在我的云盘项目中就使用到AOP,在记录日志的时候,我创建有个自定义注解,aop的切面就是这个注解,使用环绕通知在方法中,我们通过传入的参数(joinPoint)获取对应的类和方法,从而获取前端传来的参数和其他主要信息,实现记录日志的效果。
问:Spring中的事务是怎么实现的?
本质就是通过AOP实现的,通过环绕通知对应方法进行前后拦截,在方法执行前开启事务,在执行后提交事务,会对此过程进行try/catch,如果报错直接回滚。(就是@transactional)
问:spring中事务失效场景有哪些?
1.在出现异常后,方法中try/catch了该异常并且没有主动抛出异常,这时候就会导致事务失效。解决方法:在方法try/catch异常后手动的抛出异常。(就会导致事务不知道出现异常了)
2.抛出检查异常时会导致事务失效,spring中的事务只会对runtime异常进行回滚。就比如:Not found Exception就是检查异常。解决方法:在@transactional中设置属性 rollbackFor = Exception.class。使得事务会对所有的异常进行回滚。
3.非public方法会导致事务失效。解决方法:将方法的作用域该为public。
4.在非事务方法中调用了事务的方法,此时就会导致事务失效。(就比如某给没有加事务注解的方法调用了加了事务注解的方法)
5.回滚异常类型不匹配。(我们可能会设置需要进行回滚的异常,就是rollback的值,如果抛出的异常类型不匹配就会导致事务失效)
6.事务的传播行为错误。(就比如在事务方法中调用了其他的事务方法,初始化如果其他的时候方法设置开启新事务的话,在其事务成功后,就不会参与外部事务的回滚操作)
问:事务的传播性有哪些?
主要就是三个:Propagation_Required,Propagation_Required_new,Propagation_nested。
- Propagation_Required:如果B为A的子方法,并且都为该传播类型,那么它们都会在同一个事务中。如果主方法中没有开启事务的话就会开启新事务。
- Propagation_Required_new:如果B为A子方法,B会创建一个独立的事务,B不会受A事务的影响。
- Propagation_nested:如果B为A子方法,B会创建一个事务内嵌到A的事务中,如果A中没有事务的话,就单独开启一个事务。
问:@Transactional的原理有了解过嘛?
事务注解是基于AOP来实现的,也就是在执行方法前开启事务,如果try/cath捕获到异常的话就会进行回滚,在方法执行完成后就会进行提交。那AOP实现涉及到动态代理的流程。如果被代理目标对象实现了接口的话,就会使用JDK Proxy生成代理对象。如果代理对象没有实现接口的话,就会使用CGLIB Proxy生成被代理对象的子类作为代理对象。
问:说说CGLIB的执行流程?
问:spring中bean的生命周期有了解过吗?
结构图:
生命周期的流程为下:
1.通过BeanDefinition获取bean的定义信息。
2.通过构造函数创建bean,可以将当前的bean理解为一个空壳。
3.进行依赖注入,对bean中的属性进行赋值。
4.处理Aware接口,也就是一些以Aware结尾的接口,就比如:beanNameAware,beanFactoryAware,applicationContextAware。如果实现了个接口的话,我们需要重写一些方法。
5加载BeanPostProcessor对象,并执行处理器的前置初始化方法(PostProcessorAfterInitiazilation方法)。
6.执行初始化方法,包括 IntializingBean和自定义的初始化方法。
7.加载BeanPostProcessor对象,并执行处理器的后置初始化方法(PostProcessorBeforeInitiazilation方法)。在此后置处理器中我们可以通过aop对原始的bean做增强也就是进行代理,代理就包括 JVM代理和CGLIB代理。
8.将bean进行销毁。
问:说说CGLIB动态代理的执行流程吧?
aop就是采用代理模式来实现的。当要代理的对象实现接口的时候,我们就会使用JDK Proxy来生成代理对象。如果代理的对象没有实现接口的话,我们就回使用CGLIB生成一个被代理对象的子类作为代理对象。
问:有了解过bean的循环依赖吗?
循环依赖也就是循环引用,两个或以上的bean同时相互依赖对方,最终形成闭环。就比如:A依赖B,B依赖A。
spring提供了解决方案:三级缓存。
1.一级缓存:单例池,存储已经初始化完成的单例bean。
2.二级缓存:缓存早期的bean单例对象,就bean只完成到执行构造方法。
3.三级缓存:缓存创建bean的factory,这些factory用于创建代理对象和普通对象。
三级缓存的解决流程(当前问题为A,B相互依赖):
实例化A,并将生成的A的objectFacttory将其存入三级缓存,因为A依赖B,B也会去实例化,创建对应的objectFactory存入三级缓存。
此时B依赖A,就会通过三级缓存中A的objectFactory生成早期A实例,并将该A实例存储到二级缓存中,将这个早期的A注入B中,此时B就创建完成了,将B实例存储到一级缓存中。
将完整的B注入A中,将A实例存储到一级缓存中,并将二级缓存中A的实例删除。
问:构造方法中出现循环依赖怎么办?
在构造函数中如果存在循环依赖我们在函数的参数上添加@Lazy就可以解决循环依赖的问题,保证bean在需要的时候才去加载。
问:可以只使用二级缓存来解决循环依赖吗?
在没有Aop的时候就可以使用一级和三级缓存来解决依赖。但是呢,如果存在aop的问题,那就必须使用三级缓存,因为在二级缓存中可以保证即使存在多个半成品bean的引用时,始终会返回相同的代理对象。避免Bean有多个代理对象。
问:除了使用三级缓存解决循环依赖,还有什么方法吗?
使用@Lazy就行懒加载,就比如A,B相互依赖。但是呢A添加了@Lazy注解,此时会创建一个B的代理对象,而非B对象,会将这个代理对象注入到A中,完成A的创建。之后会做B的初始化和实例化,会将创建好的A注入到B,最终完成B的创建。
问:SpringMVC的执行流程有了解过吗?
jsp版本:
1.用户发送请求到DispatchServlet。
2.dispatchServlet调用处理器映射器HandlerMappering,处理器就会去Controller中找到对应的方法通过映射的路径,然后将处理器执行链返回给dispatchServlet。
3.dispatchServlet调用处理器适配器HandlerAdaptor找到对应的处理器,该处理器就会处理对应的参数和处理返回值,最终会返回ModelAndView给DispatchServlet。
4.Dispatch调用ViewResolver视图解析器,并将ModelAndView传入,最终返回View给dispatchServlet,通过View渲染视图。(就比如:JSP)
前后端分离版本:
1.用户发送请求搭配DispatchServlet。
2.DispatchServlet调用处理器映射器HandlerMappering,会去Controller中找到对应的方法,最终处理器返回执行链给DispatchServlet。
3.DispatchServlet会调用处理器适配器HandlerAdaptor找到对应的处理器,该处理器就会处理对应的方法的参数和处理返回值,在方法上添加了@ReponseBody。
4.通过HttpMessageConverter将数据转为Json返回。
问:有了解过springboot的自动装配原理吗?
在启动类上有个注解的叫@SpringbootApplication,在该注解中包含springbootConfiguration表示该类为配置类,还有个注解叫@EnableAutoConfiguration,这个就是实现自动装配的核心注解。
在@EnableAutoConfiguration中使用注解@Import引入了一个自动自动装配的选择器。
该选择器会到jar中的 /META-INF/spring.factories中按照条件加载对应的类,并将该类配置到ioc中。这里面添加的注解就包括:@ConditionOnClass表示当某个类存在时才进行类加载。@ConditionOnMissingBean表示当某个bean不存在的时候才进行类加载。
问:spring中有那些常见的注解?
在Spring中主要注解有:@Componment,@Controller,@Service,@Repository将类配置到ioc中,@AutoWired(根据class),@Qualifier(根据名字)实现依赖注入。@scope设置bean的作用范围。@Configration设置配置类。@ComponetScan主键扫描。@Bean将某个方法的返回值配置到ioc中。@Import将某类导入ioc中。@Before,@After,@Aspecr,@Around,@Pointcut切面编程的注解。
在SpringMVC中就是一些关于请求的注解,@RequestMappering,@ReponseBody,@RestController,已经参数的注解 @RequestParam,@PathViriable,@RequestHeader。
在springboot的注解中就包括:@SpringBootConfiguration,@EnableAutoConfiguration自动装配注解,@ComponentScan之间扫描。
问:能说说你对Mybatis的执行流程的理解吗?
1.读取mybatis-config.xml文件,里面就是数据库的配置和mapper的地址。
2.创建SqlSessionFactory。
3.通过SqlSessionFactory创建对应的SqlSession,就是项目和数据库的会话,SqlSession包含执行sql语句的所有方法。
4.执行操作数据库的接口,Executor执行器,同时负责缓存的维护。
5.在Executor执行器中的MapperStatement对象,当操作数据库的时候会将Java的类型转为数据库的类型,当输出结果的时候会将数据库的类型转为Java的类型。
问:Mybatis支持延迟加载吗?
1.延迟加载就是当需要使用数据的时候才去加载数据,不用数据得时候不会主动加载。
2.Mybatis支持一对一关联对象和一对多关联集合的延迟加载。
3.在mybatis的配置文配置文件中的LazyloadEnabled设置为true就开启了全局延迟加载。
问:Mybatis延迟加载的底层有了解过吗?
1.通过GCLIB的代理实现的。
2.当调用目标函数的时候,会调用拦截器的invoke方法,如果发现目标方法中的值为null,则进行sql查询,在获取数据后通过set设置属性值,在后续调用目标方法时就有值了。
问:有了解过Mybatis的一级缓存和二级缓存吗?
一级缓存:基于PrepetualCache的HashMap的本地缓存,是默认开启的,其作用域就是Session,当Session进行了Close,Flush后该Session中的缓存会被全部清空。
二级缓存:其作用域是namespace和mapper,其默认是不开启的,是基于prepetualCache,hashmap存储的。通过在mybatis的配置文件中设置CacheEnabled设置为true,在对应的mapper中添加标签Cache。
问:Mybtis的二级缓存什么时候会清理?
当某个作用域(一级缓存(session)/二级缓存(namespace))中进行了增删改操作时就会清除掉select中的缓存。
问:Mybtis中#{}和${}的区别?
- #{}主要就是做占位符的替换的,在mybatis中会使用?占位,而${}主要就是做文本替换的。替换${}中的文本。
- #{}数据的替换发生在DBAS之中,而${}数据替换发生在DBAS之外。
- #{}在做数据替换的时候会自动添加'',而${}数据替换的时候不会自动添加''。
- #{}可以防止SQL的注入,而${}不可以。
微服务
问:springCloud的五大主件有了解过吗?
springCloud:
注册中心:Eureka。
远程调用:Feign。
负载均衡:Ribbon。
服务保护:Hystrix。
网关:Gateway。
springCloud alibaba:
注册中心:Nacos。
远程调用:Feign。
负载均衡:Ribbon。
服务保护:Sentinel。
网关:Gateway。
问:服务的注册与发现是什么意思?SpringCloud是如何实现服务的注册和发现的?
在我的项目中使用nacos实现服务的注册与发现。那我就以eureka为例吧。
服务的注册:每一个服务的提供者会将自己的信息注册到eureka中, eureka会储存提供者的信息,包括:服务名,ip,端口等等。
服务的发现:消费者会通过eureka拉取对应的服务列表,服务可能会搭建集群,所以会存在多个服务,这时候消费者会根据负载均衡的算法选择一个服务并进行调用。
服务的监控:服务者会每隔30秒发送心跳,保证自己的健康状态,当注册中心发现某个服务90秒都没有发送心跳,则会将该微服务移除。
问:我看你项目中使用nacos,你能说说nacos和eureka的区别吗?
相同点:
1.都支持服务的注册和发现,整个流程也是非常相似的。
2.都支持服务者向注册中心发送心跳,监控监控状态。
不同点:
1.nacos的注册中心支持自动查询服务者的状态,如果服务者是临时实例时则使用心跳模式,如果服务者是非临时实例时就采用自动检测模式。
2.临时实例如果发现心跳不正常时就会移除对应的服务,而非临时实例即使有异常也不会被移除。
3.nacos的注册中心支持向消费者发送更新后的服务列表,更新更及时。
4.nacos的临时实例使用的AP模式(高可用),eureka页使用AP模式,非临时实例使用CP模式(强一致)。
5.nacos还支持作为配置中心,而eureka则不支持。
问:你们项目中的负载均衡是怎么实现的?
通过Ribbon实现的,在我们feign调用服务时的负载均衡就通过Ribbon实现的。
问:Ribbon的负载均衡策略有哪些?
1.轮询策略。
2.权重策略:按照权重的大小选择服务器,响应的时间越长其权重就越小。
3.随机策略。
4.区域敏感策略:根据消费者区域按照就近原则选择服务器,如果没有区域这个概念,其效果就和轮询策略效果一样。
问:如何自定义负载均衡的策略?
1.通过实现接口IRule设置对应策略并设置到ioc中 ,其的作用域是全局的。
2.通过yml配置文件进行配置,需要指定对应的服务者的名字,其作用域是局部的。
问:什么是服务的雪崩呢?怎么解决这个问题呢?
服务的雪崩:当某个服务调用失败后,导致后续调用的服务全部失败,造成服务雪崩发生。
解决方案
服务的降级:如果某个服务调用失败后直接返回自定义的降级信息。通过实现Feign接口,并在Feign接口的@FeignClient中设置fallBack为实现类。
服务熔断:可以通过Hytrix或Sentinel实现服务熔断,那我就以Hytrix为例吧,其默认是没有开启的,需要我们在启动类上添加注解@EnableCircuitBreaker进行开启。当调用某个服务10s时,发现有50%以上的调用失败就会触发熔断机制(开启),每隔5秒会放行一个请求去请求服务(半开),当发现服务调用失败了就会继续熔断,如果请求成功则会解除熔断(关闭)。
问:你们微服务是怎么做监控的?
在我们的项目中主要是用Skywalking完成监控的。
Skywalking主要监控监控,服务的状态。特别是在做压力测试的时候就能够找到对应的慢接口,并对接口进行修改。在Skywlaking中还有服务间调用的拓扑图,方便我去理清调用关系。
我们可以在Skywalking中设置警告规则,当我们的项目上线后,发生错误就会向我们设置的管理者发送短信和邮箱信息,保证第一时间进行修改。
问:你们的项目中有没有使用过限流?是怎么实现的?
之前要学习过抢票的业务就使用到限流,因为当时的QPS到达2000左右,为了保证正常的运行我需要做限流,通过限流将QPS控制在1000左右。
实现方案:
Nginx
1.控制速率:使用漏桶算法实现。可以存储一定数量的请求,按照一定的速率处理请求,如果进入漏桶的个数大于最大值则会直接丢弃。
2.控制并发数:控制单个ip的链接数和并发链接的总数。
网关
通过GateWay中的提供过滤器RequestRateLimiter实现,其思想就是令牌桶思想,每个请求需要携带令牌才能进行访问。我们可以设置令牌桶的容量和令牌每秒生成的个数,在GateWay的配置文件中进行配置。
问:限流的算法有哪些?有什么区别呢?
主要就是漏桶算法和令牌桶算法。不同于漏桶算法按一定速率处理请求,当令牌桶中还有令牌,且被处理的请求数大于生成令牌的速率时,执行的请求个数就会大于每秒生成的令牌数。
问:能解释一下CAP和BASE吗?
CAP:一致性(c),可用性(a),分区容错性(p)。
1.分布式系统的节点都是通过网络连接的。所以一定会出现分区的问题(因为网络的问题)。
2.因为分区的出现我们就不能同时满足可用性和一致性。
3.cp为强一致性(nacos的非临时实例就是采用此模式),ap为可用性(eureka和nacos的临时实例就是常用此模式)
BASE
基本可用:分布式出现故障的时候,允许损失部分的可用性,保证核心可用。
软状态:允许出现中间状态。比如:临时不一致的情况。
最终一致性:在软状态结束后,保证最终的一致。
解决分布式事务的思想和模型
最终一致性:各个分支的事务都进行提交,彼此结果不一样就执行相反的操作(插入->删除)保证最终的一致性。
强一致性:各个分支的事务在执行完业务之后不会提交,都在等待彼此的结果,最终一起提交或回滚。
问:你们是采用哪种分布式事务来解决问题的?
在我们的物流项目种主要使用RabbitMQ来实现分布式事务的。
MQ
在我们调用服务A的时候,做完一系列的判断操作后就去执行数据库的操作,此时也会调用消息队列发送消息到其他的服务去,此时消息队列操作和数据库操作是在同个事务中的。因为其是异步的,所以性能比较好。(可以拿做缓存举例子:双写一致性)
Seata
XA模式:强一致性(cp),各个分支事务不会提交事务,都在等待彼此的结果,最终一起提交或回滚。
TA模式:最终一致性(ap),各个分支事务都会进行提交,当彼此结果不一样的时候就会通过undo(回滚日志)进行逆操作,保证数据最终的一致性。
TCC模式:最终一致性,类是TA模式,性能比较好。但是需要人工编码,耦合度比较高。
问:你们分布式服务中接口的幂等性性时怎么设计的?
在我们物流项目的订单支付模块就需要考虑幂等性的问题,防止用户重复支付。保证多次调用订单支付的接口和调用一次的结果时一样的。我们主要通过分布式锁和交易单状态的判断来保证接口的幂等性。(将模式官引导到物流项目中)
在我们项目中判断交易单的状态有:已结算,免单,支付中,取消订单,挂账(累计结算)。
接口会传入支付状态。
1.如果是已结算和免单直接返回错误信息。
2.如果是支付中,则需要判断支付方式是否改变,如果改变则生成新的交易单作为新的数据,如果没有改变则直接返回错误信息。(传入订单Id,去匹配对应的交易单信息,如果没有就创建)
3.如果是取消订单和挂账则生成新的交易单作为新的数据。
解决方案(以支付为例)
1.redis + token,token通过UUID生成,在生成订单的时候生成token,将token存到redis中,请求会携带这个token进行,先判断token是否存在于redis中,如果存在就进行支付然后删除redis中的token,最终保证幂等性。(但是没有考虑更换支付方式的问题)
2.分布式锁,保证每次只有一个支付请求,性能比较低。
问:xxl-job的路由策略有哪些?
1.轮询:轮流被选择。
2.故障转移:通过心跳机制,找到第一个健康实例。
3.分片广播:通过广播触发触发定时任务。适用于任务数量多的时候。在我们的物流项目中定时任务就是使用分片广播。(引导面试官到你的项目中)
问:xxl-job任务执行失败怎么解决?
故障转移策略 + 设置重试次数 + 在设置邮箱告警。
问:如果大量的任务需要被执行,怎么解决?
使用分片广播,在代码编写上我们可以获取当前xxl-job节点的索引和节点的总数。通过取总数模来确定执行任务的节点。
在我们的计算运力模块中,司机执行运力计算时就需要执行定时任务。通过去模的方式选择对应的节点来执行计算运力。那再这个过程中我们为了保证用户的体验,我们需要给在计算运力的时候添加分布式锁,保证同一个用户的商品尽可能的在同一辆车上。
消息中间件
问:RabbitMq如何保证数据的不丢失?
在我们物流项目中的支付模块中的支付方式的模板做了缓存需要达到mysql,redis双写一致性,我们使用RabbitMq来实现的,此时我们就需要把证Rabbit中数据的不丢失。
我们需要从三个方面进行考虑:
1.开启生产者的确认机制,成功返回ack,失败则返回nack,包括publisher-confirm机制和publisher-return机制,前者作用在生产者发送消息到交换机和消费者消费消息,后者作用在交换机发送消息到消息队列中。
2.开启持久化功能,设置交换机(在创建交换机的函数中可以设置为持久化),消息队列(通过queueBuilder中设置持久化),消息的持久化(可以在创建消息时设置其模式)。
3.开启消费者的确认机制,设置消费者的确认机制的模式为auto,在spring处理消息成功后返回ack(此时就会将消息从消息队列中删除),反之返回nack,我们可以通过spring的retry机制设置重试次数,在我们的项目中设置为三次,如果三次都失败了,则直接将消息发送到异常队列中,人工去处理异常的消息。
问:RabbitMq中的消息重复消费问题怎么解决?
之前在写项目的时候就遇见过这种问题,因为网络的问题,消费者没有及时的发送ack,然后此时消费者服务又宕机了,在服务重启后就会重复消费消息。
解决方案:
1.为每条消息设置一个唯一的Id标识,当消费者在消费消息的时候会先判断id在数据库中是否存在,如果不存在就消费信息,如果将id存储数据库中,如果存在则就不消费消息。
2.使用分布式锁,其性能比较低。
问:RabbitMq的死信队列又了解过吗?(RabbitMq的延迟队列有了解过吗?)
在我们的物流项目中,快递员的上门取件就使用延迟队列,当前时间大于取件时间的前两小时时就会做发送延迟消息。
解决方案:
延迟队列的实现方式就是:死信队列 + TTL
1.在我们的消息队列中通过属性 dead-letter-exchange设置死信交换机。自定义死信交换机和绑定对应的自定义的死信消息队列。给需要延迟发送的消息设置ttl。
2.当消息队列中的消息过期时,消息被拒接消费时,队列满之后,消息就会进入私死信队列中。
3.我们就可以设置一个消息队列用于存储需要延迟的消息,当消息过期后直接进入死信队列中,消费者去消费死信队列中的消息达到延迟处理的效果。我们项目中就是这么实现的。
下载延迟队列插件(问还有什么解决方案时答)
这个我只了解过,其使用就是在配置交换机的时候设置属性delayed为true,然后在创建消息的时候添加头信息 x-delay头,值为过期时间。
问:RabbitMq的消息堆积该怎么解决呢?(如果有100万的堆积在RabbitMq的消息队列中该怎么办)
在项目中目前没有遇到过这种情况,但还是有有了解过解决方案的。
1.消息堆积的原因是因为消费信息的速度小于生成消息的速度,所以我们可以多增加几个消费者。
2.在消费者中开启线程池加快消费速度。
3.找到消息队列的容量。通过间消息队列设置为惰性队列,造创建队列的时候使用lazy方法实现。
惰性队列的优点:存储空间大。缺点:因为消息都是存储在磁盘上的,所以需要做IO操作,效率比较低。
问:RabbitMQ高可用机制有了解过吗?
在我们的生产环境下,当时采用时镜像模式搭建的集群,总共三个主节点,所有的操作都是在主节点上完成的,并会将数据同步到镜像节点上。在主节点宕机后会有镜像节点作为新的主节点。
此时存在一个情况,当主节点操作完成时,就给宕机了,数据没有即使同步,导致数据丢失。
问:那出现这种数据丢失该这么解决呢?
我们可以使用仲裁队列,它和镜像模式一致,也是一种主从的模式,其遵循Raft协议,具有强一致性,其配置起来是很简单的,在创建队列的时候调用可以设置仲裁队列方法(quorum方法),一个q开头的方法,名字现在忘了。
问:kafka是如何保证消息不丢失的?
1.服务者发送消息到broker中时可能会出现数据丢失的情况。
解决方案:在我们异步发送消息的时候会重写一个回调方法,这个方法有个次数就异常类,我们可以判断该异常是否存在进行重写发送消息。可以给kafka设置重试次数的参数配置。
2.消息在broker中可能会出现消息丢失的情况。
解决方案:确认发送就是ack机制,我们可以设置ack的值来防止消息的丢失,ack=all就是保证消息存储到主从节点后才进行确认。但在我们的项目中 一般设置ack=1,也就是leader节点存储数据后就进行确认。
3.消费者从broker中获取的消息丢失。当存在多个消费者时,每个消费者分区进行消费,每隔5秒都会自动提交已消费的各个的偏移量(offset,此时就可能出现提交偏移量后消费和消费后提交偏移量)。情况1:当某个消费者在消费完消息后未提交偏移就宕机了,此时其他的消费者就会使用就得偏移量消费信息,就出现重复消费的情况。情况2:当提交完偏移量后还没消费完服务就宕机了,此时其他消费者就用新的偏移量来消费消息,就会出现消息丢失的情况。
解决方案:关闭主动添加,使用手动提交。提交发送使用 同步 + 异步提交。(先在消费消息后异步提交,在最后再进行同步提交)
问:kafka是如何保证消费的顺序性的?
在kafka存储消息时会将消息存储到不同的分区中不能保证消息的顺序性。
解决方案:
1.为需要顺序性的消息设置相同的分区,通过kafkaTemplate在发送消息时进行设置。
2.为需要顺序性的消息设置相同的key,通过hash函数计算到相同的位置,将信息散列到同一个分区中,通过kafkaTemplate在发送消息时进行设置。
问:kafka的高可用机制有了解过吗?
kafka的高可用机制就是集群和副本复制机制。
集群:在kafka集群中会有多个broker,即使有某个broker宕机也不会服务的使用。
副本复制机制:一个topic会有多个分区,一个分区会有多个副本,副本分为leader和follower,副本会存储在不同的broker上,当副本中的leader挂掉后就会选择一个follower作为新的leader。
问:能解释一些复制机制中的ISR吗?
ISR(in-sync-replica):同步复制。也就是此时的副本需要leader的同步复制,而不同的副本则是异步复制,因为异步复制可能会出现数据丢失的情况,所以ISR副本的数据更加接近leader,在leader挂掉后会优先使用ISR副本作为新的leader。(我们可以在broker的配置文件中设置ISR的个数)
问:kafka的消息清理机制有了解过吗?
先说说数据的存储机制吧。
每个topic会有多个分区,每个分区又有多个分段segment,分段主要以索引文件和日志文件存储在磁盘中的,其优点就是减少单个文件的内容大小,加快查询速度和消息清除的速度(索引和日志文件的清除)。
数据清除策略
1.根据消息的保留时间进行清除,当消息的保留时间超过指定的时间时就将其清除。默认的指定时间是7天。
2.根据topic存储的大小进行清除,当topic存储的大小到达阈值时就会触发清除,删除掉存储最久的消息,默认是不开启的。(在broker配置文件中开启)
问:kafka实现高性能的设计有了解过吗?
1.消息分区:消息不再局限于存储到单个服务器上。可以处理更多的数据。
2.顺序读写:磁盘采用顺序读写,速度更快。
3.页缓存:把磁盘数据先缓存到内存中,在读取数据时将磁盘读取变为内存读取,速度更快。
4.零拷贝:减少数据的拷贝,加快速度。原本的:系统资源->页缓存->kafka->socket缓存区->网卡最终发送给消费者此时为四次拷贝,新的:系统资源->页缓存->网卡最终发送给消费者,新的拷贝比原来少两次。速度更快。
5.消息压缩:减少磁盘io和网络io。
6.分批发送:将消息打包批量发送,减少网络开销。
集合
问:数组的索引为什么从0开始而不是从1开始呢?
数组的寻址公式就是 数组的头地址 + 索引 * 节点的大小。
如果此时我们使用1作为索引头,则公式就变成了 数组的头地址 + (索引 - 1) * 节点的大小。
在公式上多一个减法操作,效率没有从0开始高。
问:ArrayList底层是怎么实现的呢?
1.ArrayList是基于动态数组实现的。
2.在开始的时候ArrayList的长度为0,第一次提交数据的时时候就会将扩容到10。
3.在后续我们做新增的时候会先判断size + 1是否大于当前的数组容量,如果大于的话就将容量扩容到原来的1.5倍,并对数组进行拷贝。
4.新数据就存储到索引为size的位置。最终返回boolean值。
问:new ArrayLIst(10)扩容了几次?
当构造方法传入的是长度的时候,其底层实现就是创建一个object数组,并没有进行扩容。
问:如果实现数组和List之间的转化?
数组转List使用Arrays.asList方法,List转数组使用ArrayList.toArray方法。
问:用Arrays.asList方法后,修改了数组中的内容,List会受影响吗?
在Arrays类中的定义了getArrayList就是将传入的数组进行包装了一下,还是对该数组的引用,所以在数据修改时会受影响。
问:用ArrayList.toArray方法后,修改List中的值,Array会受到影响吗?
返回的数组就是ArrayList中动态数组的拷贝, 因此不会受影响。
问:ArrayList和LinkedList的区别是什么?
1.ArrayList的底层是一个动态的数组,而LinkedList的底层是双向链表。(底层区别)
2.ArrayList的存储空间是连续的存储空间,而LinkedList的每个节点都需要存储值和两个指针,在存储空间上比ArrayList大。(存储空间)
3.查询时:索引条件已知的时候,ArrayList的时间复杂度为O(1),而LinkedList需要遍历查找,其时间复杂度为O(N)。在索引未知的情况下,都需要遍历查找,它们的时间复杂度都是O(N)。
增加和删除时(索引已知):ArrayList的头尾节点的增加和删除时间复杂度为O(1),而其他节点为O(N)。LinkedList的头尾节点增加和删除时间复杂度为O(1),其他节点的时间复杂度为O(N)。(时间复杂度)
4.ArrayList和LinkedList都是线程不安全的,所以我们在使用的时候,将其设置为局部变量。
或者通过使用Collections.synchronizedList方法进行包装,虽然线程安全但是效率比较低。
问:说一下hashMap的原理?
HashMap的底层是通过散列表 + 链表 + 红黑树实现的。
通过调用hash函数计算出值得索引从而进行数据得存储。
当位置有只值得时候:如果key的值相同则进行覆盖,如果不同就使用链表或红黑树进行存储。当然只有当链表的长度大于等于8且HashMap的长度大于等于64的时候才会使用红黑树进行存储。
问:HashMap在JDK1.7和JDK1.8有什么区别?
在JDK1.7时,HashMap在解决hash函数冲突的时候使用拉链法。当链表上存储的大量值得时候其时间复杂度就从O(1)变成了O(N)。
在JDK1.7时,HashMap在解决hash函数冲突得时候使用链表和红黑树解决,当链表的长度大于等于8且HashMap的长度大于等于64时会将链表转化为红黑树,此时它的时间复杂度为O(logN)。
问:说一下hashMap的扩容机制?
1.在进行扩容的时候使用函数resize方法进行扩容,并且在第一次初始化的时候容量为16,容量阀值12,当容量大于12时就会触发扩容。
2.每次扩容都是用来的两倍。
3.扩容之后,会创建新的数组,将旧数组中的数据按照规则(计算索引位置)复制到新的数组中。
没有哈希冲突的时候直接复制到e.hash&(newCap - 1) (旧hash值取新容量的模的值,写成&运算效率更高)索引的位置上。
如果是红黑树,直接做红黑树的添加。
如果是链表,则遍历该链表,此时链表中的数据可能会存储在不同的新节点上。通过e.hash&oldCap表达式判断存储的位置,要么存储到原始位置上,要么存储到原始位置 + 旧容量的位置上。
问:说说hashMap的寻址算法的理解?
通过计算数据的hashCode的值,并将值右移16位做异或操作,计算出新的hash值,做二次hash的目的主要让数据分布的更均匀。在存储数据的时候将该hash值做取模的操作,但是在源码中使用(capacity - 1)& hash其是等价取模的,因为与运算效率更高。
问:为什么hashMap的大小要设置为2的N次幂?
在计算索引的时候效率更高,只有当大小为2的N次幂,与运算才会等价于取模操作。
在扩容是重新计算索引的效率更高,当hash & oldCap == 0时,就存储到原来的位置,反之旧存储到原来的位置 + 旧容量的位置上。
问:有了解过hashMap1.7时的线程死锁的问题吗?
因为hashMap造1.7时使用拉链法,且在扩容的时候使用头插法,在数据迁移的时候就可能出现死循环的情况。
就比如说当前有两个线程:
1.在线程1进行数据迁移的时候,线程2先进行了数据迁移,此时因为使用头插法的原因,顺序会变成原来的倒叙(就比如A->B变为B->A)。
2.在线程2完成迁移后,线程1会进行数据的迁移。此时将A,B插入(最终又变成A,B的顺序),因为线程2的原因B的下个节点为A,所以会继续将A做头插法,并且让A指向B,此时就出现 B->A->B的死循环,最终导致线程死锁的问题。
在JDK1.8之后使用尾插法, 就解决了死锁的问题。
并发编程
问:多线程间进行操作时,如果不加锁的话,为什么会导致数据错误呢?
那我们就来举个例子:在一个类中存在一个静态变量a,有一个方法就是对a做++操作,有两个线程同时做调用方法操作,但是二者获取的是未被对方修改的数据,所以就会导致数据错误。
问:在JUC中常用的类有哪些呢?
我比较理解的就是三个:
- CountDownLatch:计数,等待另一线程执行完任务在执行任务。重要就是通过 wait进行等待,countDown进行计数,并且计数器不能重置,一般使用一次。
- CyclicBarrier:作用和CountDownLatch差不多,大但是呢,它的计数器可以进行重置达到循环的效果,在每次调用wait方法后就会将计数器+1,在数量到达设定值的适合就会释放屏障,然线程执行任务。
问:线程和进程有什么区别?
1.一个程序的运行就是一个进程,在一个进程中会包多个线程。
2.进程间是不共享内存的,二在一个进程中的线程是共享内存的。
3.线程间的上下文切换(线程之间的切换)的成本比进程的上下文切换(进程之间的切换)成本低。
问:并发和并行有什么区别?
就以我们的网络请求为例:
1.并发就是服务同时可以应对多个请求的能力。
2.并行就是一个服务可以同时处理多个请求的能力。(我们服务可能会搭建集群)
问:线程创建的方法有哪些?
1.通过继承Thread实现。
//继承Thread
public class MyThread extends Thread{
@Override
public void run() {
System.out.println("创建线程成功!");
}
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.run();
}
}
2.通过实现接口Runnable。
//实现接口Runnable
public class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("创建线程成功!");
}
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.run();
}
}
3.通过Callable指定对应的泛型,通过FutureTask异步的获取最终的结果(获取call方法的返回值)。
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
//通过实现Callable,需要指定call返回的类型,调用FutureTask获取对应的数据,创建线程
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "创建线程成功!";
}
public static void main(String[] args) throws Exception {
MyCallable myCallable = new MyCallable();
FutureTask<String> stringFutureTask = new FutureTask<>(myCallable);
Thread thread = new Thread(stringFutureTask);
thread.run();
System.out.println(stringFutureTask.get());
}
}
4.通过创建线程池,需要参数Runnable的实现类,最终创建线程。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MyPool implements Runnable{
@Override
public void run() {
System.out.println("线程创建完成!");
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(3);
//创建线程
pool.submit(new MyPool());
//关闭线程
pool.shutdown();
}
}
问:Runnable和Callable有什么区别?
1.Runnable的run方法没有返回值,而Callable的call方法则有返回值,且返回的类型是传入的泛型。
2.Runnable的run方法不能抛出异常(可以捕获异常),而Callable中的Call方法则可以抛出异常。
问:线程中的run方法和start方法有什么区别?
Start方法:用于启动线程。通过调用run方法,执行run中的代码,且start方法只能背调用一次。
Run方法:封装要被线程执行的代码,可以多次被调用。
问:线程包括哪些状态?
NEW(新建),RUNNABLE(可执行状态),TERMINATED(终止状态),WAIT(等待状态),TIME_WAIT(计时等待状态),BLOCKED(阻塞状态)
问:线程之间的状态是怎么变化的?
1.在创建线程的时候就是新建状态。
2.在线程调用start方法后就是可执行状态。
3.在线程运行完之后就是变成了终止状态。
4.在线程处于可执行状态时:
- 线程需要上锁,且此时获取锁失败,那线程的状态就会变成阻塞状态。
- 线程调用wait方法,就会进入等待状态,在其他线程没有调用notify方法时就会一直处于这种状态。
- 线程调用了sleep方法,就会进入计时等待状态。
问:如果创建了T1,T2,T3三个线程,如果保证按照它们的顺序执行?
可以使用对应线程的join方法,在某个线程执行之前,先调用对应上一个线程的join方法,保证执行完上个线程再执行当前线程。
问:notify方法和notifyAll方法有什么区别?
notify方法:随机唤醒某一个在等待(wait)的线程。
notify方法:唤醒所有的在等待(wait)的线程。
问:sleep方法和wait方法的区别?
相同点:都会让当前的线程放弃CPU的使用权。
不同点:
1.归属类不同:sleep方法是Thread的静态方法,wait方法则是Object都有的方法。
2.唤醒的时机不同:sleep(time)和wait(time)方法都会等待对应时间后醒来,而wait()则会一直等待下去,wait方法可以被唤醒通过notify或notifyAll方法。
3.wait方法需要配合synchronized使用,而sleep方法则不需要。
3.释放锁的不同:
- wait方法在执行后会释放对象锁,也就是在睡眠中会释放锁,允许其他的线程获取对象锁。(放弃CPU的使用权,允许其他线程使用)
- sleep方法在执行后则不会释放对象锁,也就是在睡眠中不会释放锁。(放弃CPU使用权,不允许其他线程使用)
问:如何停止一个正在运行的线程?
1.使用退出的布尔标识来停止线程,线程里会做一个循环的操作判断这个标识的值(true时成立),在需要停止时将布尔值设置为false,就会停止线程。
2.使用stop方法强行停止线程。(已淘汰不推荐使用)
3.使用interrupt停止线程:
- 当使用interrupt方法停止堵塞的线程(sleep,wait,join方法)时,线程会抛出InterruptException异常。
- 单使用interrupt方法停止正常的线程的时候,在线程中会循环的判断线程是否中止(状态,通过thread.isInterrupt方法来获取布尔值)来判断是否停线程。(类是布尔标识)
问:Synchronized关键字的底层原理有了解过吗?
synchronized采用互斥的方式至多只有一个线程以获取对应的对象锁。
线程在进入synchronized代码块的时候会去关联Monitor。
Monitor有三个属性:
1.ower:在ower中的线程就是当前获取锁的线程。当ower中没有值的时候,则当前线程直接获取对象锁,并写入ower。
2.enrtyList:在entryList中的线程就是处于堵塞的线程。
3.waitSet:在waitList中就是处于等待的线程,一般是线程调用了wait方法。
问:Mointer是重量级锁,有了解过锁的升级吗?
对象的存储结构中对象头的MarkWord存储的就是锁的状态。线程在获取锁后会在线程栈中存储锁记录,通过CAS指令来修改锁的状态。
重量级锁:基于Monitor实现的,涉及上下线程的切换,效率比较低下。
轻量级锁:通过CAS指令修改MarkWord中的锁状态,出现锁重入的时候,创建的锁记录中的的记录地址为设置为null,此过程还是使用到CAS指令,保证原子性,在解锁锁的时候通过CAS指令来恢复锁的状态(锁记录指针为null时,则将锁记录中锁对象的地址设置为null)。相比于重量级锁性能更好。(在没有线程竞争且线程交替使用时使用轻量级锁)
偏向锁:类似轻量级锁,在第一次获取锁的时候使用CAS指令,而在锁的重入的时候则会直接判断MarkWord中的线程id和当前线程id是否相同即可。相比于轻量级性能更好。(只有一个线程在使用锁时使用偏向锁)
在线程冲突的时候都会使用重量级锁。
问:你谈谈对JMM的理解?
1.JMM(java Momery Model),它规定了共享内存中多线程的读写规则,保证多线程读写的正确性。
2.JMM把内存分为了两个部分,每个线程独有的内存块(私有内存),线程间共享的内存块。
3.每个内存之间是相互隔离的,主要是通过共享内存进行交互的。
问:CAS有理解过吗?
1.CAS:比较后再交换,采用的是乐观锁的思想,在线程进行操作的时候保证数据的原子性。
2.CAS使用的场景很多,就比如我们在做重量级锁升级的时候会使用,在轻量级锁和偏向锁中使用。
3.CAS采用的是自旋锁思想的,在修改共享变量时做一个无限循环判断线程获取的共享变量和当前的共享变量是否相同(共享变量可能在线程获取后,被其他线程修改,所以就会导致两次的变量值不一样,此时我们就需要让线程重新获取共享变量),如果相同则进行修改,反之则重新获取共享变量的数据。(就是一个乐观锁的思想)
4.CAS的底层使用的是unsafe类中的方法,这些方法都是本地系统的方法,是由其他语言实现的。
问:乐观锁和悲观锁有说明区别?
CAS使用的就是乐观锁,不会真正的上锁,多个线程可以同时操作,在修改数据前会匹配版本号,即使修改失败也会进行重试。
synchronized使用的就是悲观锁,每次只有一个线程能够进行数据的操作,其他的线程会处于堵塞状态。
问:谈谈你对Voliate的理解吧?
1.保证线程之间的可见性
在线程中做共享变量的判断循环且循环过大时,可能会被JIT优化为死循环,此时其他线程修改共享变量,该线程也不会读取到。通过对共享变量添加Voliate解决。(做共享变量判断的循环被JIT优化为死循环,导致共享变量修改值读取不到)
2.防止指令进行重排序
当线程数量过多的时候为了效率,指令的顺序可能会进行重排序优化。给共享变量添加volatile,就会给共享变量在读写操作时添加不同的屏障,防止其他读写操作越过屏障,从而防止指令的重排序。
当对共享变量添加Volatile后:在写操作时,阻止共享变量写操作的上方的其他写操作往下走。
在读操作时,阻止共享变量读操作的下方的其他读操作往上走。
3.Voliate不是线程安全的。
因为Voliate是不符合原子性的,就比如我们做++操作的时候,线程之间可能都没有获取到++后的值,最终就会导致结果超过+1的值,不符合原子性,所以不是线程安全的。
使用Volatile的技巧:
1.写操作时,让Volatile修饰在最后一个做写操作的共享变量上。
2.读操作时,让Volatile修饰第一个做读操作的共享变量上。
问:什么是AQS?
1.AQS是多线程中的队列同步器。
2.AQS的内部是一个先进先出的双向队列,线程在队列中进行排队。
3.AQS中的属性state为0时就是无锁状态(默认),当一个线程将state设置为1的时候就获取了该锁。
4.state属性的修改主要使用CAS,保证多线程数据修改的原子性。
问:AQS是公平锁还是非公平锁?(解释一下两个锁)
AQS既可以实现公平锁也可以实现非公平锁。
1.当state=0时,外来的线程会和队列头线程竞争锁,这时候就是一个非公平锁。
2.当state=0时,外来的线程会进入AQS的队列中按顺序进行排队,此时队头的线程就可以获取锁。这时候就是一个公平锁。
问:ReentrantLock的实现原理?
1.ReentrantLock是基于: CAS + AQS实现的,ReentrantLock实现的公平锁和非公平锁都是基于AQS接口实现的。(可以解释一下AQS)
2.ReentranLock是支持锁重入的,在调用Lock方法后可以再次调用lock方法进行锁重入。
3.ReentrantLock是支持公平锁和非公平锁,有两个构造方法,无产构造方法可以创建一个非公平锁,有参构造方法可以创建公平锁或非公平锁。
问:Synchronized和Lock有什么区别?
1.在语法方面:Synchronized支持执行完同步代码后自动释放锁,而Lock则需要使用unlock方法手动释放锁。
2.在功能方面:二者都是采用悲观锁,都支持互斥和锁重入的功能。
Lock还支持 公平锁(通过队列按顺序获取锁),可打断(在线程进行等待的时候可以被打断),可超时(通过tryLock方法获取锁,可以在方法中设置时间,如果获取锁超时则获取锁失败),多条件变量。(可以通过条件来唤醒线程通过signal方法,lock.newCondition方法,通过条件变量来唤醒某些线程)
3.在性能方面:在没有线程竞争的时候,Synchronized性能更好,因为Synchronized支持锁升级,包括轻量级锁和偏向锁。在线程竞争激烈的时候使用Lock性能更好,因为其使用AQS实现的。(引导模式官讲AQS的原理)
问:死锁产生的条件是什么?
当在多线程的时候,每个线程需要获取多把锁,此时可能就会出现死锁的情况。(线程1:先获取A锁再获取B锁。线程2:先获取B锁再获取A锁,此时就会出现死锁的情况)
问:排查死锁的方案有哪些?
1.可以使用JDK自带的工具 jps + jstack来排查。
先通过jps获取对应死锁的进程,再通过jstack出现对应进程中线程的堆栈信息,进行代码修复。
2.通过可视化工具排查死锁,包括 jconsole,visualVM来排查死锁问题。
问:聊一聊ConcurrentHashMap?
1.结构方面:
- 在JDK1.7之前,采用的是数组+链表的方式实现的。
- 在JDK1.8之后,采用数组+链表+红黑树的方式实现的。(就是JDK1.8HashMap的结构)
2.在加锁方面:
- 在JDK1.7之前,采用的是segment的分段锁,底层是通过ReentrantLock实现的,因为每个segment又对应一部分的存储位置,锁住的范围是比较大的。
- 在JDK1.8之后,采用CAS(自旋锁)添加新的节点,会使用Synchronized对链表或红黑树的首地址进行加锁,锁住的范围比较小,效率更高。
问:导致并发程序出现问题的根本原因是什么?(Java程序中如何保证多线程的执行安全?)
1.原子性:包保证操作都成功或者都失败。(就比如:通过加锁,防止超卖的问题)
2.可见性:在线程中做共享变量的判断循环且循环过大时,可能会被JIT优化为死循环,此时其他线程修改共享变量,该线程也不会读取到。通过对共享变量添加Voliate解决。
3.顺序性:当多线程操作时,系统为了效率可能会对指令进行重排序。我们可以通过对共享变量添加Volatile来解决。
问:说一下线程池的核心参数?(线程池的执行原理知道吗?)
1.corePoolSize:线程池中核心线程的个数。
2.maximumPoolSize:线程池中线程的总数。(线程总数=核心线程数 + 救急线程数)
3. keepAliveTime:救急线程的存活时间。(救急线程空闲时的存活时间。)
4.unit:存活时间的单位。
5.BlockingQueue:阻塞队列,存储哪些没有被核心线程执行的任务。
6.ThreadFactory:线程工厂,用于创建线程。
7.RejectExecutionHandler:拒绝策略。(当任务在队列中放不下后,执行的策略)
拒绝策略
AbortPolicy:直接抛出异常。(默认策略)
CallRunsPolicy:直接调用主线程执行对应的任务。
DiscardOldestPolicy:丢掉在堵塞队列中存储最久的任务,将新的任务存储到队列中。
DiscardPolicy:直接丢弃任务。
执行原理
1.在新的任务进来后,先判断核心线程是否已满,如果没满,直接创建个核心线程去执行任务。
2.如果核心线程满了,就判断阻塞队列是否已满,如果没有满,直接存储到阻塞队列中。
3.如果阻塞队列满了,就判断线程的总数是否大于核心线程的总数,如果大于,创建救急线程去执行任务。(当救急线程和核心线程处于空闲的时候就会去执行堵塞队列中的方法)
4.如果小于,就去执行对应的拒绝策略。(默认直接报错)
问:线程池中常见的阻塞队列又哪些?
LinkedBlockQueue和ArrayBlockQueue。都是先进先出的结构。
问:LinkedBlockQueue和ArrayBlockQueue又什么区别?
1.LinkedBlockQueue默认是无界的(默认的无参构造设置的大小为Integer的最大值),可以设置为有界的,ArrayBlockQueue默认是有界的。
2.LinkedBlockQueue就基于链表实现的,而ArrayBlockQueue是基于数组实现的。
3.LinkedBlockQueue是惰性的,在新增操作的时候才会创建节点,而ArrayBlockQueue则是在最开始的时候就创建节点。
4.LinkedBlockQueue在加锁的时候是对队头和队尾进行加锁,而ArrayBlockQueue是队整个数组进行加锁。(在效率上LinkedBlockQueue比较高)
问:如何确定核心线程的个数?
假设计算机的CPU核数为N。
查看计算机的CPU核数
public static void main(String[] args) {
//查看计算机的核数
System.out.println(Runtime.getRuntime().availableProcessors());
}
1.并发高,任务时间短的时候,设置核心线程的个数为 N + 1。减少线程上下文的切换。
2.并发不高,任务时间长的时候:
- 当是io密集型任务时(不会大量消耗cpu的运力,就会导致cpu空闲),所以使用 2N + 1(线程数多设置一点)。
- 当是计算密集型任务时(此时需要消耗大量的cpu计算运力),所以使用 N + 1(线程数少设置一点)。
3.并发高,任务时间长时,先从缓存的优化考虑,再考虑服务器的优化,最后在考虑线程池的优化,分析任务的类型来设置核心线程数。
问:线程池的种类有哪些?
1.固定线程数的线程,核心线程数等于总线程数。适用于任务量已知且任务耗时长的时候(不考虑创建救急线程)。
2.单线程的线程池,就只有一个核心线程。按先进先出的策略进行。适用于按照顺序执行的任务。
3.缓存化线程池,没有核心线程,执行任务都是通过救急线程。当没有线程可以执行任务时就会创建救急线程,可以灵活的控制线程的个数,并且堵塞队列中是不存任务的。适用于任务数比较密集,任务执行时间短的时候。
4.计划线程池,可以执行延迟任务的线程池,按周期去执行任务。
问:为什么不推荐适用Executor创建线程池?
1.Executor创建的 固定线程数的线程池和单线程的线程池使用的堵塞队列都是LinkedBlockQueue,并且其初始化长度为Integer.MAX_VALUE,也就是无界的。会出现MMO的情况(内存溢出)。
2.Executor创建的缓存化线程池的线程总数Integer.MAX_VALUE,会出现MMO的情况(内存溢出)。
所以在开发时推荐使用 ThreadPoolExecutor来创建线程池,可以设置具体的参数。
问:你的项目中有用到线程吗?
1.批量导入:如果数据量过大时,我们直接一次性导入会导致MMO的问题。可以使用线程池 + CountDownLatch来实现。
2.异步线程:为了避免上一个方法的运行影响当前方法,可以使用线程池来执行方法,可以大大提高效率,从串行关系变为并行关系。(可以说说用户下单的模块,通知快递员取件这个流程,可以说在开始的时候使用异步线程来实现,但是效率还是太低了,最后使用MQ来实现异步。有消费者确认机制,如果消费失败也就是返回nack,消息会返回消息队列中的原来位置上,可以进行重试,这是多线程实现异步没有的,这是我们项目中使用mq实现异步调用的原因)
问:如何控制某个方法允许并发访问的线程数量?
多线程中提供了一个工具类 Semaphore,可以控制并发访问的线程数。
使用方法:
1.创建一个Semaphore,需要固定信号量的大小进行初始化。
2.每次请求获取线程的时候调用acquire方法,信号量- 1。(如果没有信号量了,请求就不能进行访问)
3.每次请求使用完线程后要调用release方法,信号量 + 1。
问:谈谈你对ThreadLocal的理解?
作用
可以实现资源的线程隔离,每个线程使用自己的资源,避免线程安全的问题。
实现原理
在ThreadLocal中存储数据的属性就是ThreadLocalMap。
set方法:传入一个数据,使用该数据作为Value,使用当前线程作为Key。在第一次添加数据的时候会初始化ThreadLocalMap大小为16,提供取模的方式存储对应的数据。(使用的是与运算等价于取模)
get方法:使用当前线程作为Key,通过取模的方式获取ThreadLocalMap中对应索引下标的数据。(使用的是与运算等价于取模)
remove方法:使用当前线程作为Key,通过取模的方面确定ThreaadLocalMap中的存储位置,将其中的数据清除。
问:有了解过ThreadLocal内存泄漏的问题吗?
ThreadLocalMap中的每一项的类型是Entry都是虚引用,key是弱引用,在内存不够的时候会被释放,而Value是强引用,不会被释放。所以当key被GC释放内存时,value不会被释放,最终导致内存泄漏。所以在使用ThreadLocal时,要使用remove方法自动释放key和value。
JVM
问 :什么是程序计数器?
线程是私有的,每个线程都有一个程序计数器。程序计数器会存储class字节码中正在执行的指令的地址(类是偏移量。在class字节码中,源码会被分成多个指令执行)。在下次获取CPU使用权时,就会从该指令继续执行。
通过 javap -v xx.class,就可以打印class字节码的信息。
问:你能给我详细的讲一下java的堆吗?
1.java的堆是线程共享区域,存储对象实例和数组,当空间不足的时候会报OOM异常(OutOfMemoryError)。
2.java的堆分为两个部分:新生代和老年代。
- 新生代:包含三个部分,一个Eden区存储新建的对象,两个幸存者区存储生命周期短的对象。
- 老年代:存储生命周期长的对象。
问:说说JDK1.7和JDK1.8中java堆的区别?
在1.7之前栈中还有永久代的部分,存储 类的信息,静态变量,常量,编译后的代码等等。为了降低发生OOM的可能性,在1.8后将这些数据存储到本地内存的元空间里,移除了永久代。
问:什么是虚拟机栈?
1.虚拟机栈是存储局部变量和调用方法的内存。
2.在一个栈中会包含多个栈帧,每个栈帧表示调用方法所占用的内存。
3.每个栈只有一个活跃的栈帧,表示当前的调用的方法。
问:垃圾回收是否涉及栈内存?
垃圾回收主要堆内存,而栈帧会在每次弹出的时候释放内存,所以不涉及垃圾回收。
问:方法内的局部变量是否线程安全?
1.如果局部变量没有逃离方法的作用范围旧不需要考虑线程安全的问题。
2.如果局部变量引用了外部对象且要逃离方法的作用范围时就需要考虑线程安全的问题。(方法传入对象引用和返回方法返回的对象,这些对象可能都会被其他线程修改,就会有线程安全的问题)
问:栈内存分配越大越好吗?
默认每个栈帧的大小为1M,如果扩大栈帧的大小,就会导致可用线程数减小。
问:什么情况下会导致栈内存溢出?
1.当栈帧过多的时候就会导致内存溢出。就比如:死循环。
2.当栈帧分配的大小过大时会导致内存溢出。
问:JVM中栈和堆有什么区别?
1.栈存储的是局部变量和调用方法,而堆存储对象实例和数组。
2.栈是线程私有的每个线程都有独立的栈,而堆则是共享区域。
3.空间不足的时候两者报的异常是不同的。
- 栈空间不足时报StackOverBlowError。
- 堆空间不足时报OutOfMemoryError。
问:能不能解释一下方法区?
1.方法区是多线程共享的区域。
2.主要存储类的信息和常量池。
3.在虚拟机运行时被创建,虚拟机关闭的时候销毁。
4.当无法满足方法区的内存需求时就会报OMM。
问:解释一下,运行时常量池?
我先解释常量池,可以把常量池理解为一张表,虚拟机指令会根据符号引用找到对应的常量。(方法名,参数类型,字面量等信息)
运行时常量池,就是在类被加载的时候,将指令中常量的符号引用替换为常量真实的地址。
问:你有听过直接内存吗?
它不是java的内存结构,是虚拟机的系统内存。在做NIO操作的时候会使用,分配和回收的成本表较高,但是读写操作效率较高,且不受JVM内存回收管理。
问:为什么NIO在文件的读写上比普通IO效率高?
普通IO:在数据拷贝的时候需要向将数据拷贝到系统内存的系统缓冲区中,因为java不能直接读取系统缓冲区,所以还需要将冲系统缓冲区中的数据拷贝到java的堆内存中的缓冲区里。
NIO:在数据的拷贝的时候将数据拷贝到直接内存中,java也可以直接访问直接内存中的数据,少一次拷贝。
所以在读写的效率上NIO比较高。
问:什么是类加载器?
因为JVM只能执行二进制文件,类加载器会将字节码加载到我们的jvm中,保证程序的正常运行。
问:类加载器有哪些?
1.启动类加载器(BootStrap ClassLoader):会去加载/JAVA_HOME/jre/lib中的类。
2.扩展类加载器(ExtClassLoader):会去加载/JAVA_HOME/jre/lib/ext中的类。
3.应用类加载器(APPClassLoader):会去加载ClassPath下的类。
4.自定义加载器:实现自定义的加载规则。
问:什么是双亲委派模型?
在加载来的时候,会先委派上级去加载类,如果上级也有上级就会继续向上委派。如果上级加载了类,则下级就无需加载了。如果上级没有加载类,则下级才会尝试去加载类。
问:为什么jvm要使用双亲委派?(双亲委派模型的优点)
1.因为会先委派上级去加载类,如果加载成功则下级加载器就无需加载类,保证了类加载的唯一性。
2.为了安全,保证类库中的API不会被修改。(就比如:我们编写了一个String类,在String类中有一个main方法。在进行类加载的时候就会报错,因为启动类加载器会去加载已有的String,会发现没有main函数,直接报错,防止库里的API被修改)
问:我们可以更换掉双亲委派策略吗?
可以的,通过继承ClassLoader重写loadClass方法,去覆盖调用我们的双亲委派策略来进行自定义。
问:说一下类装载的执行过程?
1.加载:加载Class文件,在方法区中存储类的信息并在堆内存中类的Class对象。
2.验证:格式检查和判断符号引用是否正确。
3.准备:为静态变量开辟内存空间,并为其初始化值。
4.解析:将类中指令的符号引用转换为直接引用。
5.初始化:对静态变量和静态块初始化。
6.使用:jvm去执行程序代码,就比如:new对象,和调用静态方法等。
7.卸载:当程序代码执行完之后,jvn就会销毁class对象。
问:jvm加载class的时候,class的卸载条件是什么?
1.当类的实例全部被gc了,也就是说jvm中没有类的实例了,就会进行卸载。
2.当类的classloader被gc了,就会进行老垃圾回收。
3.当类的.class没有被任何的引用时,也就是我们无法通过发射获取实例,就会进行卸载。
问:对象什么时候会被垃圾回收器回收?(垃圾定位的方法有哪些?)
当一个对象没有被任何的引用,就会被视为垃圾,通过对该垃圾定位然后通过垃圾回收器进行回收。
定位垃圾的方法有两种:
1.引用计数器:每个对象都有该计数器,在被引用的时候计数器就会+1,当计数器值为0就被定位为垃圾进行回收。但是如果俩个对象相互依赖就会导致定位失败。
2.可达性分析算法(java的垃圾定位法):扫描堆中的对象,如果发现其在GC Root的引用链中找到,则标记为存活对象。
问:什么对象可以作为GC Root呢?
1.栈帧中本地变量的引用对象。
2.方法区中类的静态属性的引用对象。
3.方法区中常量的引用对象。
问:JVM的垃圾回收算法有哪些?
1.标记清除算法:通过GC Root定位出存活对象的位置,并将垃圾回收。缺点:会导致存储空间碎片化,严重时创建不了大数组。
2.标记整理算法:也是通过GC Roo定位出存活对象的位置,将普通的对象都移动到内存的一端,然后回收掉其余的垃圾。优点:存储空间不会碎片化。缺点:需要大量的移动操作,效率比较低。
3.复制算法:内存会被分为两个部分,一个部分用来存储对象,通过可达性分析算法确定存活对象的位置,将普通的对象复制到另一部分中,然后将该部分内存中的对象全部回收。优点:效率高。缺点:内存使用率低。
问:说说JVM分代回收吧?
区域划分
1.在堆中分为两个部分 新生代 + 老年代。(1:2)
2.在新生代中分为三个部分 Eden区 + from幸存者区 + to幸存者区。(8:1:1)
回收策略
1.当Eden中的内存不足的时候,会查询出Eden区和from区的存活对象,并通过复制算法将存活对象复制到to区中,并将Eden和from中的对象释放。
2.当下次Eden中的内存不足的时候,会查询出Eden区和to区的存活对象,并提供复制算法将存活的对象复制到from区中,并将Eden和to中的对象释放。(1,2进行循环)
3.当幸存者区中的对象复制次数大于15次(晋升阈值)后就会晋升到老年代中,当新生代内存不够的时候也会将对象晋升到老年代中。
问:MinorGC,MixedGC,FullGC的区别是什么?
MinorGC:发生在新生代的垃圾回收。停止时间短(STW)。
MixedGC:发生在新生代和老年代的部分区域的垃圾回收。G1收集器是特有的。
FullGC:发生在新生代和老年代完整的垃圾回收。停止时间长。
问:说一下JVM有哪些垃圾回收器?
1.串行垃圾回收器:Serial GC(作用于新生代),Serial Old GC(作用于老年代),由一个线程进行垃圾回收,其他线程处于堵塞。
2.并行垃圾回收器:Parallel Old GC(作用于老年代),Par New GC(作用于新生代)。
3.CMS(并发)垃圾回收器:CMS GC(作用于老年代),通过初始标记,并发标记,重新标记(保证最终标记的是有效的垃圾)实现。
4.G1垃圾收集器,作用于老年代和新生代。
问:讲一下G1垃圾回收器吧?
1.G1回收器作用在新生代和老年代,在JDK9中是默认的垃圾回收器。
2.划分了多个区域,Eden区,幸存者区,老年代区,巨型区(humongous)。
3.常用复制算法。
4.G1回收器分为三个阶段:新生代回收(有STW),并发标记(重新标记中有STW),混合标记。
问:解释一下这三个阶段吧?
新生代回收:Eden区满之后(5%),就会通过复制算法将新生代存在的对象存储到新的幸存者区中,然后释放Eden和旧幸存者区的内存。(类似分代回收)
并发标记:标记那些需要被回收的老年代(因为是并发所以不会暂停),在做重新标记的时候会短暂的暂停,优先标记对象少的区域。(类似并发回收器)
混合回收:对新生代进行垃圾回收,并将标记的老年代(优先标记那些老年代中数据较少的老年代)和幸存者区中可晋升的新生代复制到新的老年代中,如果老年代中的数据过大时就会将其复制到humongous中,混合回收是多次执行的。
问:强引用,软引用,弱引用,虚引用的区别?
1.强引用:能被GCRoot找到,不会被回收。
2.软引用:需要配合 SoftRefrence使用,当我们内存不足的时候才会被回收。主要就是用来做缓存的,当内存不足的时候,我们就会清除一些缓存。
3.弱引用:需要配合WeakRefrence使用,主要被扫描到了就会进行回收,就是内存充足的情况下。解决ThreadLocal内存泄漏的问题。
4.虚引用:需要配合引用队列使用,当虚引用的引用对象被回收时就会将虚引用存放到引用队列中,通过Refrence Header去释放虚引用的其他外部内存,就比如:直接内存。
问:ThreadLocal为什么使用弱引用解决缓存泄漏问题呢?
我们使用弱引用作为ThreadLocalMap中enrty的key,当我们进行垃圾回收的时候,key就会被回收导致内存泄漏,但是呢,即使使用强引用,我们在对key设置为null的时候也会导致内存泄漏的问题,但是呢弱引用可以及时的垃圾回收,并且在set和get方法中会有回收无效key的操作。
问:什么情况下会导致强引用被回收呢?
1.
问:JVM的调优参数可以在哪里设置?
1.在war部署到Tomcat时时候,通过/TOMCAT_HOME/bin/catanial.sh进行设置。
2.在部署jar包的时候通过在启动指令上进行设置。就比如:java -Xmas 100m -Xmax 1024m -jar xxx.jar。
问:JVM调优的参数有哪些?
1.设置堆的大小。-Xmas -Xmax :设置堆的初始大小和最大值,初始值一般为物理内存的1/64,最大值为物理内存的1/4。
2.设置虚拟栈的大小。-Xss 128k
3.设置年轻代中Eden和幸存者区比例。-XXsurvivorRatio = 8 也就是 8 : 1 : 1。
4.设置新生代晋升老年代的阈值。-XX:MaxTenuringThreshold 默认为15。
5.设置垃圾回收器。 -XX:+UseG1GC。
问:说一下JVM的调优工具?
命令工具
1.jps:查询进程的状态。
2.jstack:查看线程的详细信息。
3.jmap:查看堆转的信息。
4.jhat:堆转储快照分析工具。
5.jstat:JVM统计检查工具。
可视化工具
1.jconsole:查询JVM的信息的可视化工具。
2.VisualVM:监控线程和内存的情况。
问:说说java内存泄漏的排查思路?
java的内存泄漏主要就是指堆内存的溢出。我们可以通过jmap或者设置JVM的参数获取堆内存的dump文件。通过VisualVM区分析堆内存的信息,定位内存溢出的代码,最终去修改代码。
问:说说CPU飙高排查方案和思路?
服务器端
1.通过Top指令查询有异常的进程。
2.通过PS指令查询进程中的线程信息。
3.查询到异常线程的id后,将id转为16进制。
4.通过jstack指令定位异常线程,并修改代码。
设计模式
问:有了解过工厂模式吗?
1.简单工厂模式:所有的类都是通过一个工厂进行创建的,在后续添加数据的新类的时候需要修改工厂类中的代码,不符合开闭原则。
2.工厂方法模式:所有的类都对应一个工厂,通过对应恶的工厂创建对应的类,在后续添加类的时候创建新的工厂即可,符合开闭原则。(每次新建都需要创建新的工厂)
3.抽象工厂模式:如果有多个纬度的类需要被创建时,就可以使用抽象工厂模式。就比如有多个品牌,品牌又对应多个商品类型。
在我的项目中就是使用工厂方法模式。在我们支付模块中生成支付二维码的时候,需要获取对应的支付模板信息,采用的就是 ioc + 自定义注解 + 工厂方法模式 + 策略模式。
1. 创建一个basicHandler接口,里面包括生成交易链接,退款等方法,创建阿里支付和微信支付的实现类,通过支付标签从数据库中获取对应的配置信息,包括 appId,公钥,私钥,商户id。实现对应的方法,并将实现类配配置到ioc中。
2.创建自定义注解,设置属性type存储支付标签的信息,配置到我们的实现类上。
3.通过Hutool工具类从ioc中获取所有basicHamdler的实现类,在方法中会传入支付标签的信息,通过遍历匹配对应的实现类,最终获取对应的工厂类(也就是我们的实现类)。(支付标签就是对应的一种策略,遍历获取对应策略)
问:什么时是策略模式?
定义了一系列的算法,并将算法封装起来,使它们可以交替使用。
一个系统需要动态的从几种算法中选择时,可以将每个算法封装到策略类中。
问:什么是责任链模式?
为了避免发送者与多个请求处理者耦合在一起,我们可以将多个请求以上级承接下级的形式,创建一个责任链,通过执行这个责任链中的方法即可。
技术场景
问:单点登录这块是怎么实现的?(有了解过sso吗?)
单体项目
在单体项目中我们一般把生成的token存放到session中,实现单点登录。
微服务项目
在微服务中我们可以通过JWT生成token,并将token存放到cookie中,用户进行访问时就会携带token到网关中进行判断,实现单点登录。
具体流程:在用户分为界面时会去网关中判断token是否有效,如果无效就返回401,并将前端跳转到登录界面。在登录成功后会通过JWT生成token并存放到cookie中。后续调用其他服务时就拿token到网关中校验即可。
但是单点登录有安全性的问题,所以我的云盘项目中将单点登录升级为双token验证(双token三认证)。
问:项目中的权限认证是怎么实现的?
1.主要是通过RBAC模型的实现的。在物流项目中单独做了一个权限的模块。(就是那个权限管家)
2.在该模型中包含5张表,用户表,角色表,用户和角色关系表(多对多的关系),权限表,角色和权限关系表(多对多的关系)
3.主要还是配合springSecurity框架使用的。
问:上传数据的安全性你是怎么考虑的?
我们只要是通过对称加密和非对称加密,给前端一个公钥在对数据进行加密后传给后端,后端再对数据进行操作。
1.当上传的文件较大的时候,使用对称加密,速度比较快,但是不要存储一些敏感的数据。
2.当存储的文件比较小且比较重要的时候,使用非对称加密,比较安全但速度比较慢。
问:你在写项目的时候遇到过哪些比较棘手的问题,你是怎么解决的?
我就说说物流项目吧,让我们印象最深的就是caffeine缓存不一致的问题。(根据自己的业务场景去说,支付模板选择也可以讲)
在物流查询模块中,因为查询物流信息是属于高并发的场景,可能会出现大量用户查询物流信息的情况。我们就使用缓存解决此问题。在缓存中为了防止缓存的雪崩问题,我们做了个二级缓存(caffeine + redis),在进行增删改的时候注解清空缓存,redis通过springCache实现没有问题,但是在做测试的时候,发现其他服务节点可以查询到旧的缓存,这是因为caffeine的数据不是集群共享的,第一次的时候我们使用mq来解决,当时还是会出现相同的问题,原因也就是只有一个服务消费到了清空缓存的消息,为了解决这个问题,我们最终使用redis的发布与订阅模式,让服务订阅该频道,在需要清除缓存的时候发送消息,这样集群中的每个节点都会去做清除缓存的操作。
从而实现缓存一致性。
问:你们项目中是怎么做日志采集的呢?
在我们的物流项目中,主要使用GrayLog来实现日志采集的。并且GrayLog的配置简单。
主要流程就是:将日志的数据存放到ES中,在访问GrayLog时就会去访问ES,并将GrayLog的配置信息存放到Mongdb中。
问:在日志中你通常会记录服务的哪些信息呢?
主要就是记录:服务的名称,日志的级别,时间,调用方法的名字,方法参数的信息,线程信息。
问:在什么时候会记录日志信息呢?
当调用重要方法时和发生异常时会进行记录日志。
问:查询日志的指令有哪些?
1.实时的查看对应日志的信息。(-f实时)
#实时查看对应日志的信息
tail -f xxx.log
#实时查看对应日志的指定前行数的信息
tail -n 100 -f xxx.log
2.按行号查询
#查看日志尾100行的数据
tail -n 100 xxx.log
#查看日志前100行的数据
head -n 100 xxx.log
#查看对应区间的日志信息,查看对应日志100~200行的信息 (使用管道符进行连接)
cat -n xxx.log|tail -n +100|head -n 100
3.按指定关键字进行查询
#查询对应日志中含有debug的信息
cat -n xxx.log|group "debug"
问:生产问题是怎么排查的呢?
1.可以通过查询日志来定位错误日志信息。
2.我们也可以通过远程调试来排查问题。我们一般是通过idea来配合实现的。通过编辑配置创建一个JVM远程调试,配置对应的远程信息即可。通过调用远程的即可。就iu可以在本地进行debug了。主要还是使用第一种方式,远程调试基本不让调试线上代码。
问:怎么定位系统的瓶颈问题呢?(在项目上线前主要做些什么呢?)
1.压测:在我们的项目中主要是使用jmter来进行的,当时在支付模块是生成二维码这个接口就做了压测,当时速度变得非常慢,主要原因就是我们将支付链接生成二维码的这个步骤放在后端实现,后来我们直接将支付链接发给前端使用QRCode.js来生成二维码,速度上就大大优化了。
2.链路追踪:主要通过skywalking来定位执行速度慢的接口,对慢的接口进行优化。