33、【对线面试官】Redis主从架构

要不你来讲讲你公司的Redis是什么架构的咯?

  • 我前公司的Redis架构是「分片集群」,使用的是「Proxy」层来对Key进行分流到不同的Redis服务器上
  • 支持动态扩容、故障恢复等等…

那你来聊下Proxy.层的架构和基本实现原理?

  • 抱歉,这块由中间件团队负责,具体我也没仔细看过

  • 不过,我可以给你讲讲现有常见开源的Redis架构

    • 在之前提到了Redis有持久化机制,即便Redis重启了,可以依靠RDB或者AOF文件对数据进行重新加载

    • 但在这时,只有一台Redis服务器存储着所有的数据,此时如果Redis服务器「暂时」没办法修复了,那依赖Redis的服务就没了

    • 所以,为了Redis「高可用」,现在基本都会给Redis做「备份」:多启一台Redis服务器,形成「主从架构」

    • 「从服务器」的数据由「主服务器」复制过去,主从服务器的数据是一致的

    • 如果主服务器挂了,那可以「手动」把「从服务器」升级为「主服务器」,缩短不可用时间

那「主服务器」是如何把自身的数据「复制」给「从服务器」的呢?

  • 「复制」也叫「同步」,在Redis使用的是「PSYNC」命令进行同步,该命令有两种模型:完全重同步和部分重同步
  • 可以简单理解为:如果是第一次「同步」,从服务器没有复制过任何的主服务器,或者从服务器要复制的主服务器跟上次复制的主服务器不一样,那就会采用「完全重同步」模式进行复制
  • 如果只是由于网络中断,只是「短时间」断连,那就会采用「部分重同步」模式进行复制
  • (假如主从服务器的数据差距实在是过大了,还是会采用「完全重同步」模式进行复制)

同步原理

  • 主服务器要复制数据到从服务器,首先是建立Socket「连接」,这个过程会干一些信息校验啊、身份校验啊等事情

  • 然后从服务器就会发「PSYNC」命令给主服务器,要求同步(这时会带「服务器ID」RUNID和「复制进度」offset参数。如果从服务器是新的,那就没有)

  • 主服务器发现这是一个新的从服务器(因为参数没带上来),就会采用「完全重同步」模式,并把「服务器ID」(runld)和「复制进度」(offset)发给从服务器,从服务器就会记下这些信息。

  • 随后,主服务器会在后台生成RDB文件,通过前面建立好的连接发给从服务器从服务器收到RDB文件后,首先把自己的数据清空,然后对RDB文件进行加载恢复

  • 这个过程中,主服务器也没闲着(继续接收着客户端的请求)

  • 主服务器把生成RDB文件「之后修改的命令」会用「ouffer.」记录下来,等到从服务器加载完RDB之后,主服务器会把「buffer.」记录下的命令都发给从服务器

  • 这样一来,主从服务器就达到了数据一致性了(复制过程是异步的,所以数据是『最终一致性』)

那「部分重同步」的过程呢?

  • 嗯,其实就是靠「offset」来进行部分重同步。每次主服务器传播命令的时候,都会把「offset」给到从服务器

  • 主服务器和从服务器都会将「offset」保存起来(如果两边的offset存在差异,那么说明主从服务器数据未完全同步)

  • 从服务器断连之后进行重连,就会发「PSYNC」命令给主服务器,同样也会带着RUNID和offset(重连之后,这些信息还是存在的)

  • 主服务器收到命令之后,看RUNID是否能对得上,对得上,说明这可能以前就同步过一部分了

  • 接着检查该「offset在主服务器里还是否存在(主服务器记录主从服务器offset的信息用的是环形buffer,如果该ouffer)满了,会覆盖以前的记录。而记录客户端的修改命令用的是另一个buffer)

  • 如果从backlog_buffer找到了,那就把从缺失的一部分offer开始,把对应的修改命令发给从服务器

  • 如果从环形ouffer(backlog._buffer)没找到,那只能使用「完全重同步」模式再次进行主从复制了

    • 懂了,无非就是有个关联关系记录下来,只不过存储是环形(可能会造成覆盖)

Redis主库如果挂了,你还是得「手动」将从库升级为主库啊?你知道有什么办法能做到「自动」进行故障恢复吗?

  • 哨兵

    • 「哨兵」干的事情主要就是:监控(监控主服务器的状态)、选主(主服务器挂了,在从服务器选出一个作为主服务器)、通知(故障发送消息给管理员)和配置(作为配置中心,提供当前主服务器的信息)

    • 可以把「哨兵」当做是运行在「特殊」模式下的Redis服务器,为了「高可用」,哨兵也是集群架构的。

    • 首先它需要跟Redis主从服务器创建对应的连接(获取它们的信息)

    • 每个哨兵不断地用ping命令看主服务器有没有下线,如果主服务器在「配置时间」内没有正常响应,那当前哨兵就「主观」认为该主服务器下线了

    • 其他「哨兵」同样也会ping该主服务器,如果「足够多」(还是看配置)的哨兵认为该主服务器已经下线,那就认为「客观下线」,这时就要对主服务器执行故障转移操作。

    • 「哨兵」之间会选出一个「领头」,选出领头的规则也比较多,总的来说就是先到先得(哪个快,就选哪个)

    • 由「领头哨兵」对已下线的主服务器进行故障转移

      • 过程
        • 首先要在「从服务器」上挑选出一个,来作为主服务器
        • (这里也挑选讲究,比如:从库的配置优先级、要判断哪个从服务器的复制offset最大、RunID大小、跟master断开连接的时长…)
        • 然后,以前的从服务器都需要跟新的主服务器进行「主从复制」
        • 已经下线的主服务器,再次重连的时候,需要让他成为新的主服务器的从服务器

了解,我想问问,Redis在主从复制和故障转移的过程中会导致数据丢失吗

  • 会的

    • 1)从上面的「主从复制」流程来看,这个过程是异步的(在复制的过程中:主服务器会一直接收请求,然后把修改命令发给从服务器)

    • 假如主服务器的命令还没发完给从服务器,自己就挂掉了。这时候想要让从服务器顶上主服务器,但从服务器的数据是不全的

    • 2)还有另一种情况就是:有可能哨兵认为主服务器挂了,但真实是主服务器并没有挂(网络抖动),而哨兵已经选举了一台从服务器当做是主服务器了,此时「客户端」还没反应过来,还继续写向旧主服务器写数据

    • 等到旧主服务器重连的时候,已经被纳入到新主服务器的从服务器了…所以,那段时间里,客户端写进旧主服务器的数据就丢了

  • 上面这两种情况(主从复制延迟&&脑裂),都可以通过配置来「尽可能」避免数据的丢失

  • (达到一定的阈值,直接禁止主服务器接收写请求,企图减少数据丢失的风险)

要不再来聊聊Redis分片集群?

  • 分片集群就是往每个Redis服务器存储一部分数据,所有的Redis服务器数据加起来,才组成完整的数据(分布式)
  • 要想组成分片集群,那就需要对key进行「路由」(分片)
    • 现在一般的路由方案有两种:「客户端路由」(SDK)和「服务端路由」(Proxy)
    • 客户端路由的代表(Redis Cluster),服务端路由的代表(Codis)
    • 区别?

总结

Redis实现高可用

  • AOF/RDB持久化机制
  • 主从架构(主服务器挂了,手动由从服务器顶上)
  • 引入哨兵机制自动故障转义

主从复制原理

  • PSYNC命令两种模式:完全重同步、部分重同步
  • 完全重同步:主从服务器建立连接、主服务器生成RDB文件发给从服务器、主服务器不阻塞(相关修改命令记录至buffer)、将修改命令发给从服务器
  • 部分重同步:从服务器断线重连,发送RunId和offset给主服务器,主服务器判断offset和runId,将还未同步给从服务器的offset相关指令进行发送

哨兵机制

  • 哨兵可以理解为特殊的Redis服务器,一般会组成哨兵集群
  • 哨兵主要工作是监控、告警、配置以及选主
  • 当主服务器发生故障时,会「选出」一台从服务器来顶上「客观下线」的服务器,由「领头哨兵」进行切换

数据丢失

  • Redis的主从复制和故障转移阶段都有可能发生数据丢失问题(通过配置尽可能避免)
l

34、【对线面试官】Redis分片集群

要不接着上一次的话题呗?聊下Redis的分片集群,先聊Redis Clusters好咯?

  • 基础

    • 在前面聊Redisl的时候,提到的Redis都是「单实例」存储所有的数据

    • 1.主从模式下实现读写分离的架构,可以让多个从服务器承载「读流量」,但面对「写流量」时,始终是只有主服务器在抗。

    • 2.「纵向扩展」升级Redis服务器硬件能力,但升级至一定程度下,就不划算了。

    • 纵向扩展意味着「大内存」,Redis:持久化时的”成本”会加大(Redis做RDB持久化,是全量的,fork子进程时有可能由于使用内存过大,导致主线程阻塞时间过长)

    • 所以,「单实例」是有瓶颈的这里的。单实例我指的是:某台Redis服务器存储着某业务的所有数据

    • 「纵向扩展」不行,就「横向扩展」呗。

    • 用多个Redis实例来组成一个集群,按照一定的规则把数据「分发」到不同的Redis实例上。当集群所有的Redis实例的数据加起来,那这份数据就是全的

    • 其实就是「分布式」的概念(:只不过,在Redis.里,好像叫「分片集群」的人比较多?

    • 从前面就得知了,要「分布式存储」,就肯定避免不了对数据进行「分发」(也是路由的意思)

    • 从Redis Clusteri讲起吧,它的「路由」是做在客户端的(SDK已经集成了路由转发的功能)

    • Redis Cluster)对数据的分发的逻辑中,涉及到「哈希槽」(Hash Solt)的概念

    • Redis Cluster默认一个集群有16384个哈希槽,这些哈希槽会分配到不同的Redis实例中

    • 至于怎么「瓜分」,可以直接均分,也可以「手动」设置每个Redis实例的哈希槽,全由我们来决定

    • 重要的是,我们要把这16384个都得瓜分完,不能有剩余!

    • 当客户端有数据进行写入的时候,首先会对key按照CRC16算法计算出16bit的值(可以理解为就是做hash),然后得到的值对16384进行取模

    • 取模之后,自然就得到其中一个哈希槽,然后就可以将数据插入到分配至该哈希槽的Redis实例中

那问题就来了,现在客户端通过hash算法算出了哈希槽的位置,那客户端怎么知道这个哈希槽在哪台Redis实例上呢?

  • 是这样的,在集群的中每个Redis实例都会向其他实例「传播」自己所负责的哈希槽有哪些。这样一来,每台Redis实例就可以记录着「所有哈希槽与实例」的关系了
  • 有了这个映射关系以后,客户端也会「缓存」一份到自己的本地上,那自然客户端就知道去哪个Redis实例上操作

那我又有问题了,在集群里也可以新增画者删除Redis:实例啊,这个怎么整?(扩容、缩容很常见的操作)

  • 当集群删除或者新增Redis实例时,那总会有某Redis实例所负责的哈希槽关系会发生变化
  • 发生变化的信息会通过消息发送至整个集群中,所有的Redis实例都会知道该变化,然后更新自己所保存的映射关系
  • 但这时候,客户端其实是不感知的
  • 所以,当客户端请求时某Key时,还是会请求到「原来」的Redis实例上。
  • 而原来的Redis实例会返回「noved」命令,告诉客户端应该要去新的Redis:实例上去请求啦
  • 客户端接收到「moved」命令之后,就知道去新的Redis实例请求了,并且更新客户端自身「哈希槽与实例之间的映射关系」
  • 总结起来就是:数据迁移完毕后被响应,客户端会收到「moved」命令,并且会更新本地缓存
  • 那数据还没完全迁移完呢?
  • 如果数据还没完全迁移完,那这时候会返回客户端「ask」命令。也是让客户端去请求新的Redis实例,但客户端这时候不会更新本地缓存

那你知道为什么哈希槽是16384个吗?

  • 嗯,这个。是这样的,Redis:实例之间「通讯」会相互交换「槽信息」,那如果槽过多(意味着网络包会变大),网络包变大,那是不是就意味着会「过度占用」网络的带宽
  • 另外一块是,Redis作者认为集群在一般情况下是不会超过1000个实例
  • 那就取了16384个,即可以将数据合理打散至Redis:集群中的不同实例,又不会在交换数据时导致带宽占用过多

那你知道为什么对数据进行分区在Redis中用的是「哈希槽」这种方式吗?而不是一致性哈希算法

  • 在我理解下,一致性哈希算法就是有个「哈希环」,当客户端请求时,会对Key进行hash,确定在哈希环上的位置,然后顺时针往后找,找到的第一个节点
  • 一致性哈希算法比「传统固定取模」的好处就是:如果集群中需要新增或删除某实例,只会影响一小部分的数据
  • 但如果在集群中新增或者删除实例,在一致性哈希算法下,就得知道是「哪一部分数据」受到影响了,需要进行对受影响的数据进行迁移
  • 而哈希槽的方式,我们通过上面已经可以发现:在集群中的每个实例都能拿到槽位相关的信息(去中心化)
  • 当客户端对key进行hash运算之后,如果发现请求的实例没有相关的数据,实例会返回「重定向」命令告诉客户端应该去哪儿请求
  • 集群的扩容、缩容都是以「哈希槽」作为基本单位进行操作,总的来说就是「实现」会更加简单(简洁,高效,有弹性)
  • 过程大概就是把部分槽进行重新分配,然后迁移槽中的数据即可,不会影响到集群中某个实例的所有数据。

那你了解「服务端路由」的大致原理吗?

  • 嗯,服务端路由一般指的就是,有个代理层专门对接客户端的请求,然后再转发到Redis集群进行处理

  • 上次最后面试的时候,也提到了,现在比较流行的是Codis

  • 它与Redis Clusteri最大的区别就是,Redis Cluster是直连Redis实例的,而Codis则客户端直连Proxy,再由Proxy进行分发到不同的Redis实例进行处理

  • 在Codis对Key路由的方案跟Redis Cluster很类似,Codis初始化出1024个哈希槽,然后分配到不同的Redis服务器中

  • 哈希槽与Redis实例的映射关系由Zookeeper进行存储和管理,Proxy会通过CodisDashBoard得到最新的映射关系,并缓存在本地上

那如果我要扩容Codis Redis实例的流程是怎么样的?

  • 简单来说就是:把新的Redis:实例加入到集群中,然后把部分数据迁移到新的实例上
  • 大概的过程就是:
    • 1.「原实例」某一个Solt的部分数据发送给「目标实例」
    • 2.「目标实例」收到数据后,给「原实例」返回ack
    • 3.「原实例」收到ack之后,在本地删除掉刚刚给「目标实例」的数据
    • 4.不断循环1、2、3步骤,直至整个solt迁移完毕
  • Codis和Redis Cluster迁移的步骤都差不多的
  • 不过Codis:是支持「异步迁移」的,针对上面的步骤2,「原实例」发送数据后,不等待「目标实例」返回ack,就可以继续接收客户端的请求
  • 未迁移完的数据标记为「只读」,就不会影响到数据的一致性
  • 如果对迁移中的数据存在「写操作」,那会让客户端进行「重试」,最后会写到「目标实例」上
  • 还有就是,针对bigkey,异步迁移采用了「拆分指令」的方式进行迁移,比如有个set元素有10000个,那「原实例」可能就发送10000条命令给「目标实例」,而不是一整个bigkey一次性迁移(因为大对象容易造成阻塞)

Redis Cluster和Codis的总体区别

总结1

  • 说白了就是,如果集群Redis实例存在变动,由于Redis实例之间会「通讯」
  • 所以等到客户端请求时,Redis实例总会知道客户端所要请求的数据在哪个Redis实例上
  • 如果已经迁移完毕了,那就返回「move」命令告诉客户端应该去找哪个Redis实例要数据,并且客户端应该更新自己的缓存(映射关系)
  • 如果正在迁移中,那就返回「ack」命令告诉客户端应该去找哪个Redis实例要数据

总结2

分片集群诞生理由:写性能在高并发下会遇到瓶颈&&无法无限地纵向扩展(不划算)

分片集群:需要解决「数据路由」和「数据迁移」的问题

Redis Cluster数据路由

  • Redis Cluster默认一个集群有16384个哈希槽,哈希槽会被分配到Redis集群中的实例中
  • Redis集群的实例会相互「通讯」,交互自己所负责哈希槽信息(最终每个实例都有完整的映射关系)
  • 当客户端请求时,使用CRC16算法算出Hash值并模以16384,自然就能得到哈希槽进而得到所对应的Redis实例位置

为什么16384个哈希槽:16384个既能让Redis实例分配到的数据相对均匀,又不会影响Redis实例之间交互槽信息产生严重的网络性能开销问题

Redis Cluster 为什么使用哈希槽,而非一致性哈希算法:哈希槽实现相对简单高效,每次扩缩容只需要动对应Solt(槽)的数据,一般不会动整个Redis实例

Codis数据路由:默认分配1024个哈希槽,映射相关信息会被保存至Zookeeper集群。Proxy会缓存一份至本地,Redis集群实例发生变化时,DashBoard更新Zookeeper和Proxy的映射信息

Redis Cluster和Codis数据迁移:Redis Cluster支持同步迁移,Codis支持同步迁移&&异步迁移

l

35、【对线面试官】系统需求多变时,如何设计

我现在有个系统会根据请求的入参,做出不同动作。但是,这块不同的动作很有可能是会发生需求变动的,这块系统你会怎么样设计?

实际的例子:现在有多个第三方渠道,系统需要对各种渠道进行订单归因

但是归因的逻辑很有可能会发生变化,不同的渠道归因的逻辑也不太一样,此时系统里的逻辑相对比较复杂

如果让你优化一下,你会怎么设计?

  • 问题转化

    • 归根到底,就是处理的逻辑相对复杂,if else的判断太多了
    • 虽然新的需求来了,都可以添加if else进行解决
    • 但你想要的就是,系统的可扩展性和可维护性更强
    • 想要我这边出一个方案,来解决类似的问题
  • 回答

    • 在这之前,一般上网搜如何解决if else,大多数都说是策略模式

    • 但是举的例子我又没感同身受,很多时候看完就过去了

    • 实际上,在项目里边,用策略模式还是蛮多的,可能无意间就已经用上了(毕竟面向接口编程嘛)

    • 而我认为,策略模式不是解决if else的关键

    • 这个问题,我的项目里的做法是:责任链模式

      • 把每个流程单独抽取成一个Process(可以理解为一个模块或节点),然后请求都会塞进Context中

      • 比如,之前维护过一个项目,也是类似于不同的渠道走不同的逻辑

      • 我们这边的做法是:抽取相关的逻辑到Process中,为不同的渠道分配不同的责任链

      • 比如渠道A的责任链是:WhiteListProcess->DataAssembleProcess->ChannelAProcess->SendProcess

      • 而渠道B的责任链是:WhiteListProcess->DataAssembleProcess->ChannelBProcess->SendProcess

      • 在责任链基础之上,又可以在代码里内嵌「脚本」

      • 比如在SendProcess上,内置发送消息的脚本(脚本可以选择不同的运营商进行发送消息)。有了「脚本」以后,那就可以做到对逻辑的改动不需要重启就可以生效。

      • 有人把这一套东西叫做「规则引擎」

      • 比如,规则引擎中比较出名的实现框架「Drools」就可以做到类似的事

      • 把易改动的逻辑写在「脚本」上(至少我们认为,脚本和我们的应用真实逻辑是分离)

      • (脚本我这里指的是规则集,它可以是Drools的dsl,也可以是Groovy,也可以是aviator等等)

      • 在我之前的公司,使用的是Groovyl脚本

具体怎么做的

  • 大致的实现逻辑就是:有专门后台对脚本进行管理,然后会把脚本写到「分布式配置中心」(实时刷新),客户端监听「分布式配置中心」所存储的脚本是否有改动

  • 如果存在改动,则通过Groovy类加载器重新编译并加载脚本,最后放到Spring容器对外使用

  • 我目前所负责的系统就是这样处理多变以及需求变更频繁的业务(责任链+规则引擎)

  • 不过据我了解,我们的玩法业务在实现上在「责任链」多做了些事情(所谓的可配置化)

  • 「责任链」不再从代码里编写,而是下沉到平台去做「服务编排」,就是由程序员去「服务编排后台」上配置信息(配置责任链的每一个节点)

  • 在业务系统里使用「服务编排」的客户端,请求时只要传入「服务编排」的ID,就可以按「服务编排」的流程执行代码

  • 这样做的好处就是:业务链是在后台配置的,不用在系统业务上维护链,灵活性更高(写好的责任链节点可以随意组合)

总结

遇到这道题之后,其实我当时答得不太行(当时只是简单说了下责任链和脚本)

于是面试题发给前同事A,让他给我出出意见,同事A给我回答的内容是:「抽象,模块化,配置化

光看这几个词,他说得也没错,但我理解不了。让他具体点,他也不展开了

于是,我又厚着脸皮去找别的前同事B,得出的回答是:

  • 是否可以做成配置化、动态替换、插件式、不需要人去开发
  • 规则引擎

当我问他,什么是「规则引擎」时,反手就被教育了,问我到底这两年学了什么,这都不懂,这也太菜了

有了”方向”以后,我花了点时间去搜了下「规则引擎」的资料,顺便入门了下「Drools」,发现这玩意不就类似于我之前在公司用的Groovy脚本平台

(当时还在纳闷想为啥那后台的名字叫做规则平台)…

于是又去简单翻了下我们的Groovy脚本平台是怎么实现这套东西的

「服务编排」这块之前在公司里因为项目的缘故,自己也没接入过,但一直听有其他的团队在用,顺便也简单看了下代码(:

后来再去找同事B时,他说现在自己公司用的是「流程引擎」,画图就ok了

其实,搞了半天,还是写if else 舒服!

l

36、【对线面试官】设计模式

熟悉哪些常见的设计模式?

  • 常见的工厂模式、代理模式、模板方法模式、责任链模式、单例模式、包装设计模式、策略模式等都是有所了解的
  • 项目手写代码用得比较多的,一般就模板方法模式、责任链模式、策略模式、单例模式吧
  • 像工厂模式、代理模式这种,手写倒是不多,但毕竟Java后端一般环境下都用Spring嘛,所以还是比较熟悉的

手写单例模式

  • 单例模式一般会有好几种写法
    • 饿汉式、简单懒汉式(在方法声明时加锁)、DCL双重检验加锁(进阶懒汉式)、静态内部类(优雅懒汉式)、枚举
    • 所谓「饿汉式」指的就是还没被用到,就直接初始化了对象。所谓「懒汉式」指的就是等用到的时候,才进行初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
//DCL懒汉式
public class Singleton1 {
//第一次减少锁的开销、第二次防止重复、volatile防止重排序导致实例化未完成
private Singleton1(){}
private volatile static Singleton1 singleton;
public static Singleton1 getInstance(){
if(singleton == null){ //线程1,2,3到达这里
synchronized (Singleton1.class){//线程1到这里开始继续往下执行,线程2,3等待
if(singleton == null){//线程1到这里发现instance为空,继续执行if代码块
//执行晚后退出同步区域,然后线程2进入同步代码块,如果在这里不再加一次判断
//就会造成instance再次实例化
singleton = new Singleton1();
//new Singleton1();可以分解为3行伪代码
//1、memory = allocate() //分配内存
//2、ctorInstanc(memory) //初始化对象
//3.调用构造函数,
//4.返回地址给引用。而cpu为了优化程序,可能会进行指令重排序,打乱这3,4这几个步骤,导致实例内存还没分配,就被使用了。
//线程A和线程B举例。线程A执行到new Singleton(),开始初始化实例对象,由于存在指令重排序,这次new操作,先把引用赋值了,还没有执行构造函数。
//这时时间片结束了,切换到线程B执行,线程B调用new Singleton()方法,发现引用不等于null,就直接返回引用地址了,然后线程B执行了一些操作,就可能导致线程B使用了还没有被初始化的变量。
//volatile防止重排序导致实例化未完成,就将对象赋值使用
}
}
}
return singleton;
}
}

//静态内部类 懒汉式
public class Singleton2 {
private Singleton2() {
}

private static class SingletonHolder {
private final static Singleton2 INSTANCE = new Singleton2();
}

public static Singleton2 getInstance() {
return SingletonHolder.INSTANCE;
}
}
//枚举
public class Singleton3 {

private Singleton3(){

}

/**
* 枚举类型是线程安全的,并且只会装载一次
*/
private enum Singleton{
INSTANCE;
private final Singleton3 instance;

Singleton(){
instance = new Singleton3();
}

private Singleton3 getInstance(){
return instance;
}
}

public static Singleton3 getInstance(){

return Singleton.INSTANCE.getInstance();
}
}
//枚举
public class Singleton4 {
private Singleton4(){}
public enum SingletonEnum {
SINGLETON;
private Singleton4 instance = null;
SingletonEnum(){
instance = new Singleton4();
}
public Singleton4 getInstance(){
return instance;
}
}
public static Singleton4 getInstance() {
return SingletonEnum.SINGLETON.getInstance();
}
}

那你们用的哪种比较多?

  • 一般我们项目里用静态内部类的方式实现单例会比较多(如果没有Springl的环境下),代码简洁易读
  • 如果有Spring环境,那还是直接交由Spring容器管理会比较方便(Spring默认就是单例的)
  • 枚举一般我们就用它来做「标识」吧,而DCL这种方式也有同学会在项目里写(在一些源码里也能看到其身影),但总体太不利于阅读和理解
  • 总的来说,用哪一种都可以的,关键我觉得要看团队的代码风格吧(保持一致就行),即便都用「饿汉式」也没啥大的问题(现在内存也没那么稀缺,我认为可读性比较重要)

我看你在DCL的单例代码上,写了volatile修饰嘛?为什么呢?

  • 指令是有可能乱序执行的(编译器优化导致乱序、CPU缓存架构导致乱序、CPU原生重排导致乱序)
  • 在代码new Object的时候,不是一条原子的指令,它会由几个步骤组成,在这过程中,就可能会发生指令重排的问题,而volatile这个关键字就可以避免指令重排的发生。

那你说下你在项目里用到的设计模式吧?

  • 嗯,比如说,我这边在处理请求的时候,会用到责任链模式进行处理(减免if else并且让项目结构更加清晰)

    • 在处理公共逻辑时,会使用模板方法模式进行抽象,具体不同的逻辑会由不同的实现类处理(每种消息发送前都需要经过文案校验,所以可以把文案校验的逻辑写在抽象类上)

  • 代理模式手写的机会比较少(因为项目一般有Spring:环境,直接用Spring的AOP代理就好了)

    • 我之前使用过AOP把「监控客户端」封装以「注解」的方式进行使用(不用以硬编码的方式来进行监控,只要有注解就行)

那你能聊聊Spring使用到的常见设计模嘛?

  • 比如,Spring IOC容器可以理解为应用了「工厂模式」(通过ApplicationContext或者BeanFactory去获取对象)
  • Spring的对象默认都是单例的,所以肯定是用了「单例模式」(源码里对单例的实现是用的DCL来实现单例)
  • Spring AOP的底层原理就是用了「代理模式」,实现可能是JDK动态代理,也可能是CGLIB动态代理
  • Spring有很多地方都用了「模板方法模式」,比如事务管理器(AbstractPlatformTransactionManager),getTransaction定义了框架,其中很多都由子类实现
  • Spring的事件驱动模型用了「观察者模式」,具体实现就是ApplicationContextEvent、ApplicationListener
l

JVM调优

今天要不来聊聊JVM调优相关的吧?你曾经在生产环境下有过调优JVM的经历吗?

  • 没有

  • 嗯.是这样的,我们一般优化系统的思路是这样的

    • 1.一般来说关系型数据库是先到瓶颈,首先排查是否为数据库的问题

      • (这个过程中就需要评估自己建的索引是否合理、是否需要引入分布式缓存是否需要分库分表等等)
    • 2.然后,我们会考虑是否需要扩容(横向和纵向都会考虑)

      • (这个过程中我们会怀疑是系统的压力过大或者是系统的硬件能力不足导致系统频繁出现问题)
    • 3.接着,应用代码层面上排查并优化

      • 扩容是不能无止境的,里头里外都是钱阿,这个过程中我们会审视自己写的代码是否存在资源浪费的问题,又或者是在逻辑上可存在优化的地方,比如说通过并行的方式处理某些请求)
    • 4.再接着,JVM层面上排查并优化

    • 审视完代码之后,这个过程我们观察JVM是否存在多次GC问题等等)

    • 5.最后,网络和操作系统层面排查

      • (这个过程查看内存CPU/网络/硬盘读写指标是否正常等等)
  • 绝大多数情况下,到第三步就结束了, 一般经过「运维团队」给我们设置的JVM和机器上的参数,已经满足绝大多数的需求了。

    调优顺序

  • 之前有过其他团队在「大促」发现接口处理超时的问题,那吋候查各种监控怀疑是 FULL GC频率稍大所导致的

    • 第一想法不是说去调节各种JVM参数来进行优化,而是直接加机器
    • (用最粗暴的方法,解决问题是最简单的,扩容)
    • 不过,我是学过JM相关的调优命令和思路的。
    • 在我的理解下,调优JVM其实就是在「理解」JM内存结构以及各种垃圾收集器前提下,结合自己的现有的业务来「调整参数」,使自己的应用能够正常稳定运行
  • jvm调优

    • 一般调优JVM我们认为会有几种指标可以参考:『吞吐量』、『停顿时间』和垃圾回收频率』

    • 基于这些指标,我们就有可能需要调整

      • 1.内存区域大小以及相关策略(比如整块堆内存占多少、新生代占多少、老年代占多少、 Survivor占多少、晋升老年代的条件等等
        • 比如(-Xmx:设置堆的最大值、-Xms:设置堆的初始值、-Xmn:表示年轻代的大小、- XX: Survivorratio:伊甸区和幸存区的比例等等
        • 按经验来说:IO密集型的可以稍微把「年轻代」空间加大些,因为大多数对象都是在年轻代就会灭亡。内存计算密集型的可以稍微把「老年代」空间加大些,对象存活时间会更长些)
      • 2.垃圾回收器(选择合适的垃圾回收器,以及各个垃圾回收器的各种调优参数)
        • 比如(-XX:+UseG1GC:指定JVM使用的垃圾回收器为G1、- XX: Maxgcpause Miliis:设置目标停顿时间、-XX:InitiatingHeapoccupancypercent:当整个堆内存使用达到一定比例,全局并发标记阶段就会被启动等等)
        • 没错,这些都是因地制宜,具体问题具体分析(前提是得懂JVM的各种基础知识,基础知识都不懂,谈何调优)
        • 在大多数场景下,JWM已经能够达到「开箱即用」

      调优

    • 一般我们是「遇到问题」之后才进行调优的,而遇到问题后需要利用各种的「工具」进行排查

      • 1.通过ps命令查看Java进程「基础」信息(进程号、主类)。这个命令很常用的就是用来看当前服务器有多少Java进程在运行,它们的进程号和加载主类是啥
      • 2.通过stat命令査看Java进程「统计类」相关的信息(类加载、编译相关信息统计,各个内存区域GC概况和统计)。这个命令很常用于看GC的情况
      • 3.通过jnfo命令来查看和调整Java进程的「运行参数」
      • 4.通过imap命令来査看Java进程的「内存信息」。这个命令很常用于把JVM内存信息dump到文件,然后再用MAT(Memory Analyzer tool内存解析工具)把文件进行分析
      • 5.通过 stack命令来查看JVM「线程信息」。这个命令用常用语排查死锁相关的问题
      • 6.还有近期比较热门的 Arthas(阿里开源的诊断工具),涵盖了上面很多命令的功能且自带图形化界面。这也是我这边常用的排查和分析工具

    监控工具

之前聊JVM的时候,你也提到过在「解释」阶段,会有两种方式把字节码信息解释成机器指令码,一个是字节码解释器、一个是即时编译器(JIT)。我想问问,你了解JVM的JT优化技术嘛?

  • JT优化技术比较出名的有两种:方法内联和逃逸分析

    • 所谓方法内联就是把「目标方法」的代码复制到「调用的方法」中,避免发生真实的方法调用
      • 因为每次方法调用都会生成栈帧(压栈出栈记录方法调用位置等等)会带来定的性能损耗,所以「方法内联」的优化可以提高一定的性能
      • 在JVM中也有相关的参数给予我们指定(-XX: Maxfreainlinesize-xx: Maxinliresize等等)
    • 而「逃逸分析」则是判断一个对象是否被外部方法引用或外部线程访问的分析技术,如果「没有被引用」,就可以对其进行优化
  • 下面我举几个可优化的例子(思路)

    1.锁消除(同步忽略):该对象只在方法內部被访问,不会被别的地方引用,那么就一定是线程安全的,可以把锁相关的代码给忽略掉

    2.栈上分配:该对象只会在方法內部被访问,直接将对象分配在「栈」中(Java默认是将对象分配在「堆」中,是需要通过JM垃圾回收期进行回收,需要损耗一定的性能,而栈内分配则快很多)

    3.标量替换分离对象:当程序真正执行的时候可以不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了

    JIT常见优化

l

今天要不来聊聊HTTP吧

今天要不来聊聊HTTP吧?

  • HTTP「协议」是客户端和服务器「交互」的一种通迅的格式
    • 所谓的「协议」实际上就是双方约定好的「格式」,让双方都能看得懂的东西而已
    • 所谓的交互实际上就是「请求」和「响应」

那你知道HTTP各个版本之间的区别吗?

  • HTP1.0默认是短连接,每次与服务器交互,都需要新开一个连接
  • HTTP1.1版本最主要的是「默认持久连接」。只要客户端服务端没有断开TCP连接,就一直保持连接,可以发送多次HTTP请求
  • 其次就是「断点续传」( Chunked transfer-coding)。利用HTTP消息头使用分块传输编码,将实体主体分块进行传输
  • HTTP/2不再以文本的方式传输,采用「二进制分帧层」,对头部进行了「压缩」,支持「流控」,最主要就是HTTP/2是支持「多路复用」的(通过单一的TCP连接「并行」发起多个的请求和响应消息)
  • HTTP/3跟前面版本最大的区别就是:HTTP1.x和HTTP/2底层都是TCP,而HTTP/3底层是UDP。使用HTTP/3能够減少RTT「往返时延」(TCP三次握手,TLS握手)

嗯,稍微打断下。我知道HTTP1.1版本有个管线化( pipelining)理论,但默认是关闭的。管线化这个跟HTTP/2的「多路复用」是很类似的,它们有什么区别呀?

  • HTTP1.1提出的「管线化」只能「串行」(一个响应必须完全返回后,下个请求才会开始传输)

  • HTTP/2多路复用则是利用「分帧」数据流,把HTTP协议分解为「互不依赖」的帧(为每个帧「标序」发送,接收回来的时候按序重组),进而可以「乱序」发送避免「一定程度上」的队首阻塞问题

  • 但是,无论是HTTP1.1还是HTTP/2,respanel响应的「处理顺序」总是需要跟request请求顺序保持一致的。假如某个请求的 response响应慢了,还是同样会有阻塞的问题

  • 这受限于HTTP底层的传输协议是TCP,没办法完全解決「线头阻塞」的问题

那你了解HTPS的过程吗?

  • 对于HTTPS,我的理解下:就是「安全」的HTTP协议(客户端与服务端的传输链路中进行加密)

  • HTPS首先要解決的是:认证的问题

    • 客户端是需要确切地知道服务端是不是「真实」,所以在HTPS中会有一个角色:CA(公信机构)
    • 服务端在使用HTTPSI前,需要去认证的CA机构申请一份「数字证书」。数字证书里包含有证书持有者、证书有效期「服务器公钥」等信息
    • CA机构也有自己的一份公私钥,在发布数字证书之前,会用自己的「私钥」对这份数字证书进行加密
    • 等到客户端请求服务器的时候,服务端返回证书给客户端。客户端用CA的公钥对证书解密(因为CA是公信机构,会内置到浏览器或操作系统中,所以客户端会有公钥)。这个时候,客户端会判断这个「证书是否可信有无被簒改」
    • 私钥加密,公钥解密我们叫做「数字签名」(这种方式可以查看有无被簒改)
    • 到这里,就解决了「认证」的问题,至少客户端能保证是在跟「真实的服务器」进行通信。

  • 保密问题

    • 客户端与服务器的通讯内容在传输中不会泄露给第三方
    • 客户端从CA拿到数字证书后,就能拿到服务端的公钥
    • 客户端生成一个Key作为「对称加密」的秘钥,用服务端的「公钥加密」传给服务端
    • 服务端用自己的「私钥解密」客户端的数据,得到对称加密的秘钥
    • 之后客户端与服务端就可以使用「对称加密的秘钥」愉快地发送和接收消息

l

3、【对线面试官】Java NIO

这次咱们就来聊聊Java的NIO呗?你对NIO有多少了解?

  1. 嗯,我对Java NIO还是有一定的了解的,NIO是JDK1.4开始有的,其目的是为了提高速度。NIO翻译成no-blockingo或者newio都无所谓啦,反正都说得通

在真实项目中写过NIO相关

  1. 这块在我所负责的系统中,一般用不上N10,要不我跟你讲讲NIO相关的知识点呗?

可以吧,你先来讲讲NIO和传统IO有什么区别吧

  1. 传统IO是一次一个字节地处理数据,NIO是以块(缓冲区)的形式处理数据。最主要的是,NIO可以实现非阻塞,而传统IO只能是阻塞的。
  2. IO的实际场景是文件IO和网络IO,NIO 在网络IO场景下提升就尤其明显了。
  3. 在Java NIO有三个核心部分组成。分别是Buffer(缓冲区)、Channel(管道)以及Selector(选择器)
  4. 可以简单的理解为:Buffer是存储数据的地方,Channel是运输数据的载体,而Selector用于检查多个Channel的状态变更情况,

有写过相关的Demo代码吗?

  1. 我曾经写过一个NIO Demo,面试官可以看看。

  2. 大概的实现就是:服务端接收图片后保存,能够通知客户端已经收到图片。而客户端发送图片给客户端,并接收服务端的响应

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    //服务端
    public class NoBlockServer {

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

    // 1.获取通道
    ServerSocketChannel server = ServerSocketChannel.open();

    // 2.切换成非阻塞模式
    server.configureBlocking(false);

    // 3. 绑定连接
    server.bind(new InetSocketAddress(6666));

    // 4. 获取选择器
    Selector selector = Selector.open();

    // 4.1将通道注册到选择器上,指定接收“监听通道”事件
    server.register(selector, SelectionKey.OP_ACCEPT);

    // 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
    while (selector.select() > 0) {
    // 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

    // 7. 获取已“就绪”的事件,(不同的事件做不同的事)
    while (iterator.hasNext()) {

    SelectionKey selectionKey = iterator.next();

    // 接收事件就绪
    if (selectionKey.isAcceptable()) {

    // 8. 获取客户端的链接
    SocketChannel client = server.accept();

    // 8.1 切换成非阻塞状态
    client.configureBlocking(false);

    // 8.2 注册到选择器上-->拿到客户端的连接为了读取通道的数据(监听读就绪事件)
    client.register(selector, SelectionKey.OP_READ);

    } else if (selectionKey.isReadable()) { // 读事件就绪

    // 9. 获取当前选择器读就绪状态的通道
    SocketChannel client = (SocketChannel) selectionKey.channel();

    // 9.1读取数据
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 9.2得到文件通道,将客户端传递过来的图片写到本地项目下(写模式、没有则创建)
    FileChannel outChannel = FileChannel.open(Paths.get("2.png"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);

    while (client.read(buffer) > 0) {
    // 在读之前都要切换成读模式
    buffer.flip();

    outChannel.write(buffer);

    // 读完切换成写模式,能让管道继续读取文件的数据
    buffer.clear();
    }
    }
    // 10. 取消选择键(已经处理过的事件,就应该取消掉了)
    iterator.remove();
    }
    }

    }
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    //客户端
    public class NoBlockClient {

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

    // 1. 获取通道
    SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 6666));

    // 1.1切换成非阻塞模式
    socketChannel.configureBlocking(false);

    // 1.2获取选择器
    Selector selector = Selector.open();

    // 1.3将通道注册到选择器中,获取服务端返回的数据
    socketChannel.register(selector, SelectionKey.OP_READ);

    // 2. 发送一张图片给服务端吧
    FileChannel fileChannel = FileChannel.open(Paths.get("X:\\Users\\ozc\\Desktop\\面试造火箭\\1.png"), StandardOpenOption.READ);

    // 3.要使用NIO,有了Channel,就必然要有Buffer,Buffer是与数据打交道的呢
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 4.读取本地文件(图片),发送到服务器
    while (fileChannel.read(buffer) != -1) {

    // 在读之前都要切换成读模式
    buffer.flip();

    socketChannel.write(buffer);

    // 读完切换成写模式,能让管道继续读取文件的数据
    buffer.clear();
    }


    // 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
    while (selector.select() > 0) {
    // 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

    // 7. 获取已“就绪”的事件,(不同的事件做不同的事)
    while (iterator.hasNext()) {

    SelectionKey selectionKey = iterator.next();

    // 8. 读事件就绪
    if (selectionKey.isReadable()) {

    // 8.1得到对应的通道
    SocketChannel channel = (SocketChannel) selectionKey.channel();

    ByteBuffer responseBuffer = ByteBuffer.allocate(1024);

    // 9. 知道服务端要返回响应的数据给客户端,客户端在这里接收
    int readBytes = channel.read(responseBuffer);

    if (readBytes > 0) {
    // 切换读模式
    responseBuffer.flip();
    System.out.println(new String(responseBuffer.array(), 0, readBytes));
    }
    }

    // 10. 取消选择键(已经处理过的事件,就应该取消掉了)
    iterator.remove();
    }
    }
    }

    }

    就考考相关的概念原理呗

    你知道IO模型有几种吗

    1. 在Unix下IO模型分别有:阻塞IO、非阻塞IO、IO复用、信号驱动以及异步I/0。在开发中碰得最多的就是阻塞I0、非阻塞IO以及IO复用。

    来重点讲讲|O复用模型吧

    1. 我就以Linux系统为例好了,我们都知道Linux对文件的操作实际上就是通过文件描述符(fd)
    2. 1O复用模型指的就是:通过一个进程监听多个文件描述符,一旦某个文件描述符准备就绪,就去通知程序做相对应的处理
    3. 这种以通知的方式,优势并不是对于单个连接能处理得更快,而是在于它能处理更多的连接。
    4. 在Linux下IO复用模型用的函数有select/poll和epoll

    那你来讲讲这select和epll函数的区别呗?

    • select

      1. select函数它支持最大的连接数是1024或 2048,因为在select函数下要传入fd_set参数,这个fd_set的大小要么1024或2048(其实就看操作系统的位数)
      2. fd_set就是bitmap的数据结构,可以简单理解为只要位为0,那说明还没数据到缓冲区,只要位为1,那说明数据已经到缓冲区。
      3. 而select函数做的就是每次将fd_set遍历,判断标志位有没有发现变化,如果有变化则通知程序做中断处理。
    • epoll

      1. epoll是在Linux2.6内核正式提出,完善了select的一些缺点。
      2. 它定义了epoll_event结构体来处理,不存在最大连接数的限制。
      3. 并且它不像select函数每次把所有的文件描述符(fd)都遍历,简单理解就是epoll把就绪的文件描述符(fd)专门维护了一块空间,每次从就绪列表里边拿就好了,不再进行对所有文件描述符(fd)进行遍历。

嗯,了解了,另外你知道什么叫做零拷贝吗?

  1. 知道的。我们以读操作为例,假设用户程序发起一次读请求。
  2. 其实会调用read相关的「系统函数」,然后会从用户态切换到内核态,随后CPU会告诉DMA去磁盘把数据拷贝到内核空间。
  3. 等到「内核缓冲区」真正有数据之后,CPU会把「内核缓存区」数据拷贝到「用户缓冲区」,最终用户程序才能获取到。
  4. 稍微解释一下上面的意思~
  5. 为了保证内核的安全,操作系统将虚拟空间划分为「用户空间」和「内核空间」,所以在读系统数据的时候会有状态切换
  6. 因为应用程序不能直接去读取硬盘的数据。从上面描述可知读写需要依赖「内核缓冲区」
  7. 一次读操作会让DMA拷贝(direct memory access 直接内存拷贝,不使用cpu)将磁盘数据拷贝到内核缓冲区,CPU将内核缓冲区数据拷贝到用户缓冲区。
  8. 所谓的零拷贝就是将「CPU将内核缓冲区数据拷贝到用户缓冲区」这次CPU拷贝给省去,来提高效率和性能
  9. 常见的零拷贝技术有mmap(内核缓冲区与用户缓冲区的共享) 、sendfile(系统底层函数支持)。
  10. 零拷贝可以提高数据传输的性能,这块在Kafka等框架也有相关的实践。
l

4、【对线面试官】Java反射 & 动态代理.md

今天要不来聊聊Java反射?你对Java反射了解多少?

  1. 嗯,Java反射在JavaSE基础中还是很重要的。
  2. 简单来说,反射就是Java可以给我们在运行时获取类的信息
    在初学的时候可能看不懂、又或是学不太会反射,因为初学的时候往往给的例子都是用反射创建对象,用反射去获取对象上的方法/属性什么的,感觉没多大用
  3. 但毕竟我已经不是以前的我了,跟以前的看法就不一样了。
  4. 理解反射重点就在于理解什么是「运行时」,为什么我们要在「运行时」获取类的信息
  5. 在当时学注解的时候,我们可以发现注解的生命周期有三个枚举值(当时我还告诉过面试官你呢~)
  6. 分别是SOURCE、CLASS和RUNTIME,其实一样的,RUNTIME就是对标着运行时
  7. 我们都知道:我们在编译器写的代码是j ava文件,经过javac编译会变成.class文件,class文件会被JVM装载运行(这里就是真正运行着我们所写的代码(虽然是被编译过的),也就所谓的运行时。

嗯,你说了那么多,就讲述了什么是运行时,还是快点进入重点吧

  1. 在运行时获取类的信息,其实就是为了让我们所写的代码更具有「通用性」和「灵活性」
  2. 要理解反射,需要抛开我们日常写的业务代码。以更高的维度或者说是抽象的思维去看待我们所写的“工具”
  3. 所谓的“工具”:在单个系统使用叫做“Utils”、被多个系统使用打成jar包叫做“组件”、组件继续发展壮大就叫做“框架”
  4. 一个好用的“工具”是需要兼容各种情况的。
  5. 你肯定是不知道用该“工具“的用户传入的是什么对象,但你需要帮他们得到需要的结果。
  6. 例如SpringMVC你在方法上写上对象,传入的参数就会帮你封装到对象上
  7. Mybatis可以让我们只写接口,不写实现类,就可以执行SQL
  8. 你在类上加上@Component注解,Sprin g就帮你创建对象
  9. 这些统统都有反射的身影:约定大于配置,配置大于硬编码。
  10. 通过”约定“使用姿势,使用反射在运行时获取相应的信息(毕竟作为一个”工具“是真的不知道你是怎么用的),实现代码功能的「通用性」和「灵活性」

结合之前说的泛型,想问下:你应该知道泛型是会擦除的,那为什么反射能获取到泛型的信息呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 抽象类,定义泛型<T>
public abstract class BaseDao<T> {
public BaseDao(){
Class clazz = this.getClass();
ParameterizedType pt = (ParameterizedType) clazz.getGenericSuperclass();
clazz = (Class) pt.getActualTypeArguments()[0];
System.out.println(clazz);
}
}

// 实现类
public class UserDao extends BaseDao<User> {
public static void main(String[] args) {
BaseDao<User> userDao = new UserDao();

}
}
// 执行结果输出
class com.entity.User
  1. 嗯,这个问题我在学习的时候也想过
  2. 其实是这样的,可以理解为泛型擦除是有范围的,定义在类上的泛型信息是不会被擦除的。
  3. Java编译器仍在class文件以Signature 属性的方式保留了泛型信息
  4. Type作为顶级接口,Type下还有几种类型,比如TypeVariable、 ParameterizedT ype、 WildCardType、 GenericArrayType、以及Class。通过这些接口我们就可以在运行时获取泛型相关的信息。

你了解动态代理吗?

  1. 嗯,了解的。动态代理其实就是代理模式的一种,代理模式是设计模式之一。
  2. 代理模型有静态代理和动态代理。静态代理需要自己写代理类,实现对应的接口,比较麻烦。
  3. 在Java中,动态代理常见的又有两种实现方式:JDK动态代理和CGLIB代理
  4. JDK动态代理其实就是运用了反射的机制,而CGLIB代理则用的是利用ASM框架,通过修改其字节码生成子类来处理。
  5. JDK动态代理会帮我们实现接口的方法,通过invokeHandler对所需要的方法进行增强。
  6. 动态代理这一技术在实际或者框架原理中是非常常见的
  7. 像上面所讲的Mybatis不用写实现类,只写接口就可以执行SQL,又或是SpringAOP等等好用的技术,实际上用的就是动态代理。
l

5、【对线面试官】多线程基础

首先你来讲讲进程和线程的区别吧?

  1. 进程是系统进行资源分配和调度的独立单位,每一个进程都有它自己的内存空间和系统资源
  2. 进程实现多处理机环境下的进程调度,分派,切换时,都需要花费较大的时间和空间开销
  3. 为了提高系统的执行效率,减少处理机的空转时间和调度切换的时间,以及便于系统管理,所以有了线程,线程取代了进程了调度的基本功能
  4. 简单来说,进程作为资源分配的基本单位,线程作为资源调度的基本单位

那我们为什么要用多线程呢?你平时工作中用得多吗?

  1. 使用多线程最主要的原因是提高系统的资源利用率。
  2. 现在CPU基本都是多核的,如果你只用单线程,那就是只用到了一个核心,其他的核心就相当于空闲在那里了。
  3. 在平时工作中多线程是随时都可见的。
  4. 比如说,我们系统Web服务器用的是Tomcat,Tomcat处理每一个请求都会从线程连接池里边用一个线程去处理。
  5. 又比如说,我们用连接数据库会用对应的连接池Druid/C3P0/DBCP等等
  6. 等等这些都用了多线程的。
  7. 上面这些框架已经帮我们屏蔽掉「手写」多线程的问题

嗯,了解,那你实际开发中有用过吗?

  1. 当然有了,在我所负责的系统也会用到多线程的。

  2. 比如说,现在要跑一个定时任务,该任务的链路执行时间和过程都非常长,我这边就用一个线程池将该定时任务的请求进行处理。

  3. 这样做的好处就是可以及时返回结果给调用方,能够提高系统的吞吐量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 请求直接交给线程池来处理
    public void push(PushParam pushParam) {
    try {
    pushServiceThreadExecutor.submit(() -> {
    handler(pushParam);
    });
    } catch (Exception e) {
    logger.error("pushServiceThreadExecutor error, exception{}:", e);
    }
    }
  4. 还有就是我的系统中用了很多生产者与消费者模式,会用多个线程去消费队列的消息,来提高并发度

你如果在项目中用到了多线程,那肯定得考虑线程安全的问题的吧

  1. 在我的理解下,在Java世界里边,所谓线程安全就是多个线程去执行某类,这个类始终能表现出正确的行为,那么这个类就是线程安全的。
  2. 比如我有一个count变量,在service方法不断的累加这个count变量。
  3. 假设相同的条件下,count变量每次执行的结果都是相同,那我们就可以说是线程安全的干
  4. 显然下面的代码肯定不是线程安全的
1
2
3
4
5
6
7
8
9
10
11
12
13
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;

public long getCount() {
return count;
}

public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {

++count;
// To something else...
}
}

那你平时是怎么解决,或者怎么思考线程安全问题的呢?

  1. 其实大部分时间我们在代码里边都没有显式去处理线程安全问题,因为这大部分都由框架所做了。
  2. 正如上面提到的Tomcat、Druid、SpringMVC等等。
  3. 很多时候,我们判断是否要处理线程安全问题,就看有没有多个线程同时访问一个共享变量。
  4. 像SpringMVC这种,我们日常开发时,不涉及到操作同一个成员变量,那我们就很少需要考虑线程安全问题。
  5. 我个人解决线程安全问题的思路有以下:
    • 能不能保证操作的原子性,考虑atomi c包下的类够不够我们使用。
    • 能不能保证操作的可见性,考虑volatil e关键字够不够我们使用
    • 如果涉及到对线程的控制(比如一次能使用多少个线程,当前线程触发的条件是否依赖其他线程的结果),考虑CountDownLatch/Semaphore等等。
    • 如果是集合,考虑java.util.concurrent 包下的集合类。
    • 如果synchronized无法满足,考虑lock 包下的类
  6. 总的来说,就是先判断有没有线程安全问题,如果存在则根据具体的情况去判断使用什么方式去处理线程安全的问题。
  7. 虽然synchronized很牛逼,但无脑使用synchronized会影响我们程序的性能的。

死锁你了解吗?什么情况会造成死锁?要是你能给我讲清楚死锁,我就录取你了

  1. 要是你录取我,我就给你讲清楚死锁
  2. 造成死锁的原因可以简单概括为:当前线程拥有其他线程需要的资源,当前线程等待其他线程已拥有的资源,都不放弃自己拥有的资源。
  3. 避免死锁的方式一般有以下方案:
    • 固定加锁的顺序,比如我们可以使用Hash值的大小来确定加锁的先后
    • 尽可能缩减加锁的范围,等到操作共享变量的时候才加锁。
    • 使用可释放的定时锁(一段时间申请不到锁的权限了,直接释放掉)

价值体现

嗯,其实我想问,就是我要是..去到贵公司是做什么内容?还有就是..

l

6、【对线面试官】CAS

今天我们来聊聊CAS吧?你对CAS了解多少?

  1. CAS的全称为compare and swap,比较并交换
  2. 虽然翻译过来是「比较并交换」,但它是一个原子性的操作,对应到CPU指令为cmpxchg
  3. cpu指令你都知道?->这没什么,都是背的。
  4. 回到CAS上吧,CAS的操作其实非常简单。
  5. CAS有三个操作数:当前值A、内存值V、要修改的新值B
  6. 假设当前值A跟内存值V相等,那就将内存值V改成B
  7. 假设当前值A跟内存值V不相等,要么就重试,要么就放弃更新
  8. 将当前值与内存值进行对比,判断是否有被修改过,这就是CAS的核心

确实,那为什么要用CAS呢?

  1. 嗯,要讲到CAS就不得不说synchronized锁了,它是Java锁..然后..
  2. ok,其实就是synchronized锁每次只会让一个线程去操作共享资源
  3. 而CAS相当于没有加锁,多个线程都可以直接操作共享资源,在实际去修改的时候才去判断能否修改成功
  4. 在很多的情况下会synchronized锁要高效很多
  5. 比如,对一个值进行累加,就没必要使用synchronized锁,使用juc包下的Atomic类就足以。

了解,那你知道CAS会有什么缺点吗?

  1. CAS有个缺点就是会带来ABA的问题
  2. 从CAS更新的时候,我们可以发现它只比对当前值和内存值是否相等,这会带来个问题,下面我举例说明下:
  3. 假设线程A读到当前值是10,可能线程B把值修改为100,然后线程C又把值修改为10。
  4. 等到线程A拿到执行权时,因为当前值和内存值是一致的,线程A是可以修改的!
  5. 站在线程A的角度来说,这个值是从未被修改的(:
  6. 这是不合理的,因为我们从上帝的角度来看,这个变量已经被线程B和线程C修改过了。
  7. 这就是所谓的ABA问题
  8. 要解决ABA的问题,Java也提供了AtomicStampedReference类供我们用,说白了就是加了个版本,比对的就是内存值+版本是否一致

阿里巴巴开发手册提及到推荐使用LongAdder对象,比AtomicLong性能更好(减少乐观锁的重试次数)

  1. AtomicLong做累加的时候实际上就是多个线程操作同一个目标资源
  2. 在高并发时,只有一个线程是执行成功的,其他的线程都会失败,不断自旋(重试),自旋会成为瓶颈
  3. 而LongAdder的思想就是把要操作的目标资源「分散」到数组Cell中
  4. 每个线程对自己的Cell变量的value进行原子操作,大大降低了失败的次数
  5. 这就是为什么在高并发场景下,推荐使用LongAdder的原因
l