31、【对线面试官】G1垃圾收集器

要不这次来聊聊G1垃圾收集器?

  • CMS垃圾收集器的升级

  • G1垃圾收集器可以给你设定一个你希望Stop The Word停顿时间,G1垃圾收集器会根据这个时间尽量满足你

    • 在前面我在介绍JM堆的时候,堆的内存分布是以「物理」空间进行隔离

    • 在G1垃圾收集器的世界上,堆的划分不再是「物理」形式,而是以「逻辑」的形式进行划分

    • 不过的「分代」概念在G1垃圾收集器的世界还是一样奏效的

    • 比如说:新对象一般会分配到Eden区经过默认15次的 Minor GC新生代的对象如果还存活,会移交到老年代等等。

    • 堆被划分了多个同等份的区域,在G1里每个区域叫做Region

    • G1中,还有一种叫 Humongous(大对象)区域,其实就是用来存储特别大的对象(大于 Region内存的一半)

    • 一旦发现没有引用指向大对象,就可直接在年轻代的 Minor GC中被回收掉

    • 之所以要将「堆空间」进行「细分」多个小的区域,是因为像以前的垃圾收集器都是对堆进行「物理」划分,如果堆空间(内存)大的时候,每次进行「垃圾回收」都需要对一整块大的区域进行回收,那收集的时间是不好控制的;而划分多个小区域之后,那对这些「小区域」回收就容易控制它的「收集时间」了

GC过程

  • 在G1收集器中,可以主要分为有Minor GC( Young GC)和 Mixed GC,也有些特殊场景可能会发生 Full GC

    • Minor GC

      • G1的 Minor GC其实触发时机跟前面提到过的垃圾收集器都是一样的

      • 等到Eden区满了之后,会触发 Minor GC。 Minor GCI同样也是会发生 Stop The World的

      • 要补充说明的是:在G1的世界里,新生代和老年代所占堆的空间是没那么固定的(会动态根据「最大停顿时间」进行调整)

      • 这块会给我们提供参数进行配置就好了

      • 所以,动态地改变收集年轻代 Region的个数可以「控制」 Minor GCI的开销

      • Minor GC我认为可以简单分为为三个步骤:根扫描、更新&&处理RSet、复制对象

        1)第一步应该很好理解,因为这跟之前CMS是类似的,可以理解为初始标记的过程

        2)第二步就是处理RSet的信息并且扫描,将老年代对象持有年轻代对象的相关引用都加入到 GC Roots下,避免被回收掉

        ​ 涉及到「Rset」的概念

        ​ (1)上ー次我们聊CMS回收过程的时候,同样讲到了 Minor GC,它是通过「卡表」( cart table)来避免全表扫描老年代的对象

        ​ (2)因为 Minor GC是回收年轻代的对象,但如果老年代有对象引用着年轻代,那这些被老年代引用的对象也不能回收掉

        ​ (3)同样的,在G1也有这种问题(毕竟是Minor GC)。CMS是卡表,而G1解决「跨代引用」的问题的存储一般叫做RSet

        ​ (4)只要记住,RSet这种存储在每个 Region都会有,它记录着「其他 Region引用了当前 Regiong的对象关系」

        ​

        ​ (5)对于年轻代的 Region,它的RSet只保存了来自老年代的引用(因为年轻代的没必要存储啊,自己都要做 Minor GC了

        ​ (6)而对于老年代的 Region来说,它的RSet也只会保存老年代对它的引用(在G1垃圾收集器,老年代回收之前,都会先对年轻代进行回收,所以没必要保存年轻代的引用)

        3)第三步:把扫描之后存活的对象往「空的 Survivor区」或者老年代」存放,其他的Eden区进行清除

        ​ (1)这里要提下的是,在G1还有另一个名词,叫做CSet

        ​ (2)它的全称是 Collection Set,保存了一次GC中「将执行垃圾回收」的 Region。CSet中的所有存活对象都会被转移到别的可用 Region上

        ​ (3)在 Minor GC的最后,会处理下软引用、弱引用、 JNI Weak等引用,结束收集

  • 总结

    • 总结起来就是:扫描、处理跨 Region引用、收集至CSet、复制清除、处理引用

MixedGC过程

  • 当堆空间的占用率达到一定阈值后会触发 Mixed GC(默认45%,由参数决定)
  • Mixed GC会依赖「全局并发标记」统计后的 Region数据
  • 「全局并发标记」它的过程跟CMS非常类型,步骤大概是:初始标记(STW)、并发标记、最终标记(ST)以及清理(ST)
    • 说明: Mixed GC它一定会回收年轻代,并会采集部分老年代的Region进行回收的,所以它是一个混合GC
    • 「初始标记」,
      • 这个过程是「共用」了 Minor GC的 Stop The World(Mixed GC一定会发生 Minor GC),复用了「扫描 GC Roots的操作
      • 在这个过程中,老年代和新生代都会扫
      • 总的来说,「初始标记」这个过程还是比较快的,毕竟没有追溯遍历嘛
    • 「并发标记」
      • 这个阶段不会 Stop The World,GC线程与用户线程一起执行,GC线程负责收集各个 Region的存活对象信息
      • 从 GC Roots往下追溯,査找整个堆存活的对象,比较耗时
    • 「重新标记」
      • 跟CMS又一样,标记那些在「并发标记」阶段发生变化的对象
      • CMS在「重新标记」阶段,应该会重新扫描所有的线程栈和整个年轻代作为root,G1不是
        • 在G1中解決「并发标记」阶段导致引用变更的问题,使用的是SATB算法
        • 可以简单理解为:在GC开始的时候,它为存活的对象做了一次「快照」
        • 在「并发阶段」时,把每一次发生引用关系变化时旧的引用值给记下来
        • 然后在「重新标记」阶段只扫描着块「发生过变化」的引用,看有没有对象还是存活的,加入到「 GC Roots」上
        • 不过SATB算法有个小的问题,就是:如果在开始时,G1就认为它是活的,那就在此次GC中不会对它回收,即便可能在「并发阶段」上对象已经变为了垃圾。
        • 所以,G1也有可能会存在「浮动垃圾」
        • 但是总的来说,对于G1而言,问题不大(毕竟它不是追求一次把所有的垃圾都清除掉,而是注重 Stop The Worlde时间)
    • 「清理」
      • 这个阶段也是会 Stop The World的,主要清点和重置标记状态,会根据「停顿预模型」(其实就是设定的停顿时间),来决定本次GC回收多少 Region
      • 一般来说, Mixed GC会选定所有的年轻代 Region,部分「回收价值高」的老年代 Region(回收价值高其实就是垃圾多)进行采集
      • 最后 Mixed GC进行清除还是通过「拷贝」/「复制」的方式去干的
      • 所以在G1中,一次回收未必是将所有的垃圾进行回收的,G1会依据停顿时间做出选择 Region数量

什么时候发生full GC

  • 如果在 Mixed GC中无法跟上用户线程分配内存的速度,导致老年代填满无法继续进行 Mixed GC,就又会降级到 serial oldGC来收集整个 GC heap
  • 其实跟CMS是非常类似的都是因为空间不足
  • 不过uGC这个场景相较于CMS还是很少的,毕竟G1没有像CMS「内存碎片」这种问题

G1垃圾收集器特点:

  • 从原来的「物理」分代,变成现在的「逻辑」分代,将堆内存「逻辑」划分为多个Region
  • 使用CSet来存储可回收Region的集合
  • 使用RSet来处理跨代引用的问题(注意:RSet不保留 年轻代相关的引用关系)
  • G1可简单分为:Minor GC 和Mixed GC以及Full GC
  • 【Eden区满则触发】Minor GC 回收过程可简单分为:(STW) 扫描 GC Roots、更新&&处理Rset、复制清除
  • 全局并发标记的过程跟CMS过程差不多:初始标记(STW)、并发标记、最终标记(STW)以及清理(STW)
  • 【整堆空间占一定比例则触发】Mixed GC 依赖「全局并发标记」,得到CSet(可回收Region),就进行「复制清除」
  • 使用SATB算法来处理「并发标记」阶段对象引用存在变更的问题
  • 亮点&&重点:提供可停顿时间参数供用户设置(G1会尽量满足该停顿时间来调整 GC时回收Region的数量)
  • R大描述G1原理的时候,他提到:从宏观的角度看G1,主要分为两块「全局并发标记」和「拷贝存活对象」
l

如何实现去重和幂等

去重与幂等

  • 区别
    • 「去重」是对请求或者消息在「一定时间内」进行去重「N次」
    • 「幂等」则是保证请求或消息在「任意时间内」进行处理,都需要保证它的结果是一致
  • 以项目举例,我维护的「消息管理平台」是有「去重」的功能的:「5分钟相同内容消息去重」「1小时内模板去重」「一天内渠道达到N次阈值去重」.
  • 再次强调下「幂等」和「去重」的本质:「唯一Key」+「存储」

实现

  • 不同的业务场景,唯一Key是不一样的,由业务決定
  • 存储选择挺多的,比如「本地缓存」/「 Redis」/「 MYSQL」/「 Hbase」等等,具体选取什么,也跟业务有关
  • 比如说,在「消息管理平台」这个场景下,我存储选择的「 Redis」(读写性能优越), Redis也有「过期时间」方便解决「一定时间内」的问题
  • 而唯一Key,自然就是根据不同的业务构建不同的。
  • 比如说「5分钟相同内容消息去重」,我直接MD5请求参数作为唯一Key。「1小时模板去重」则是「模板ID+ userid」作为唯一Key,「ー天内渠道去重」则是「渠道ID+ userid」作为唯一Key.

提到了「去重」了,你听过布隆过滤器吗?

  • 布隆过滤器的底层数据结构可以理解为bitmap, bitmap也可以简单理解为是一个数组,元素只存储0和1,所以它占用的空间相对较小
  • 当一个元素要存入 bitmap时,其实是要去看存储到 bitmap的哪个位置,这时一般用的就是「哈希算法」,存进去的位置标记为1
  • 标记为1的位置表示存在,标记为0的位置标示不存在
  • 布隆过滤器是可以以较低的空间占用来判断元素是否存在进而用于去重,但是它也有对应的缺点
  • 只要使用哈希算法离不开「哈希冲突」,导致有存在「误判」的情况
  • 在布隆过滤器中,如果元素判定为存在,那该元素「未必」真实存在。如果元素判定为不存在,那就肯定是不存在
  • 这应该不用我多解释了吧?(结合「哈希算法」和「标记为1的位置表示存在,标记为0的位置表示不存在」这两者就能得出上面结论)
  • 布隆过滤器也不能「删除」元素(也是哈希算法的局限性,在布隆过滤器中是不能准确定位一个元素的)
  • 如果要用的话,布隆过滤器的实现可以直接上 guava已经实现好的,不过这个是单机的
  • 而分布式下的布隆过滤器,一般现在会用 Redis,但也不是每个公司都会部暑布隆过潓器的 Redis版(还是有局限,像我以前公司就没有)
  • 所以,目前我负责的项目都是没有用布隆过滤器的

去重开销大

  • 如果「去重」开销比较大,可以考虑建立「多层过滤」的逻辑
  • 比如,先看看『本地缓存』能不能过滤一部分,剩下「强校验」交由『远程存储』(常见的 Redis或者DB)进行二次过滤

kafka场景

  • 当时你说在处理订单时实现了 at least one+幂等
  • 幂等处理时:前置过滤使用的是 Redis,强一致校验时使用的是DB唯一索引,也是为了提高性能,唯一Key好像就是「订单编号+订单状态」

方案的场景适用

  1. 一般我们需要对数据强一致性校验,就直接上 MYSQL(DB),毕竟有事务的支持
  2. 「本地缓存」如果业务适合,那可以作为一个「前置」判断
  3. Redis高性能读写,前置判断和后置均可
  4. 而 Hbasel则一般用于庞大数据量的场景下( Redis内存太贵,DB不够灵活也不适合单表存大量数据)

幂等

  • 至于幂等,一般的方案下存储还是「Redis」和「数据库」
  • 最最最最常见的就是数据库「唯一索」来实现幂等(我所负责的好几个项目都是用这个)
  • 构建「唯一Key」是业务相关的事了(一般是用自己的业务ID进行拼接,生成一个有意义”的唯一Key
  • 当然,也有用「 Redis」和「 MYSQL」实现分布式锁来实现幂等的(:)
  • 但 Redis’分布式锁是不能完全保证安全的,而MNSL实现分布式锁(乐观锁和悲观锁),不过还是看业务吧,我是没用到过的
  • 网上有很多实现「幂等」的方案,本质上都是围绕着「存储」和「唯一Key」做了些变种,然后取了个名字
l

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