【Java面试】第二章:P5级面试(下)

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
云数据库 RDS MySQL Serverless,0.5-2RCU 50GB
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
简介: 【Java面试】第二章:P5级面试

消息丢失,消息重复消费,消息顺序性,大规模消息积压发生的场景和解决方案,几种消息队列的区别以及选型


消息丢失


生产者弄丢了数据


消息生产者和消息系统一般都是独立部署在不同的服务器上,两台服务器之间要通信就要通过网络来完成,网络不稳定可能会发生抖动,那么数据就有可能会丢失,网络发生抖动会有以下两种情况:


情形一:消息在传给消息系统的过程中会发生网络抖动,数据直接丢失。


情形二:消息已经达到消息系统,但是消息系统再给生产这服务器返回信息室,网络发生抖动,此时的数据不一定真正的丢失,很可能只是生产者认为数据丢失。


针对消息在消息生产时丢失,可以采用重投机制,当程序检测到网络异常时,小消息再次投到消息系统。但是当重新投递在情形二情况下,可能造成数据重复。


事务功能: 此时可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。 但是问题是,RabbitMQ 事务机制(同步)太耗性能,会降低吞吐量。

Confirm 模式: 所以一般来说,如果用户需要确保写 RabbitMQ 的消息别丢,可以开启 confirm 模式,在生产者那里设置开启 confirm 模式之后,用户每次写的消息都会分配一个唯一的 id,然后如果写入了 RabbitMQ 中,RabbitMQ 会给用户回传一个 ack 消息,告诉用户说这个消息 ok 了。如果 RabbitMQ 没能处理这个消息,会回调用户的一个 nack 接口,告诉用户这个消息接收失败,用户可以重试。而且用户可以结合这个机制自己在内存里维护每个消息 id 的状态,如果超过一定时间还没接收到这个消息的回调,那么用户可以重发。

区别: 事务机制和 confirm 机制最大的不同在于,事务机制是同步的,用户提交一个事务之后会阻塞在那儿,但是 confirm 机制是异步的,你发送个消息之后就可以发送下一个消息,然后那个消息 RabbitMQ 接收了之后会异步回调你的一个接口通知你这个消息接收到了。


RabbitMQ 弄丢了数据


RabbitMQ 自己弄丢了数据,此时用户必须开启 RabbitMQ 的持久化,就是消息写入之后会持久化到磁盘,哪怕是 RabbitMQ 自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。除非极其罕见的是,RabbitMQ 还没持久化,自己就挂了,可能导致少量数据丢失,但是这个概率较小。解决方案:持久化可以跟生产者那边的 confirm 机制配合起来,只有消息被持久化到磁盘之后,才会通知生产者 ack 了,所以哪怕是在持久化到磁盘之前,RabbitMQ 挂了,数据丢了,生产者收不到 ack,你也是可以自己重发的。 消息中间件的持久化和redis在持久化数据时并不是每次新增一条就立即存入本地磁盘,而是将数据先写入到操作系统的page cache中,当满足一定条件时,再将page cache中的数据刷入到磁盘。因为这样可以减少对磁盘的随机I/O操作,我们知道随机I/O操作时非常耗时的,这样也提高了系统的性能。在某些极端情况下,可能会造成page cache中的数据丢失,比如突然断电或者机器异常重启操作。要解决pagecache中数据丢失问题,可采用集群部署的方式,来尽量保证数据不丢失。


消费端弄丢了数据


RabbitMQ 如果丢失了数据,主要是因为你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,那么就尴尬了,RabbitMQ 认为你都消费了,这数据就丢了。这个时候得用 RabbitMQ 提供的 ack 机制,简单来说,就是用户必须关闭 RabbitMQ 的自动 ack,可以通过一个 api 来调用就行,然后每次用户自己代码里确保处理完的时候,再在程序里 ack 一把。这样的话,如果用户还没处理完,不就没有 ack 了?那 RabbitMQ 就认为你还没处理完,这个时候 RabbitMQ 会把这个消费分配给别的 consumer 去处理,消息是不会丢的。


一条消息消费过程大概分为三步:


消费者拉取消息


消费者处理消息


消费系统更新消费进度


第一步在消息拉取消息时会发生网络抖动异常,第二步在处理消息的时候可能发生一些业务异常,而导致而导致流程并没有走完,如果在第一步第二步发生异常的情况下通知消息系统更新消费进度,那么这条失败的消息就永远不会再处理了,自然就丢失了,其实我们的业务并没有跑完。要避免消息在消费时丢失的情况,可以在消息接收和处理完成之后才更新消费进度,但是在极端情况下会出现消息重复消费的问题,比如某一条消息在处理完成之后消费者宕机了,这时还没有更新消费进度,消费者重启后,这条消息还是会被消费到。


总结:


生产者丢消息:


解决方案一:开启Rabbitmq事务(同步,不推荐)

解决方案二:开启confirm模式(异步,推荐)

MQ丢消息:


解决方案:开启RabbitMq持久化

消费者丢消息:


解决方案:关闭RabbitMQ自动ack


消息重复消费


如何保证消息只被消费一次?消息系统本身不能保证消息仅被消费一次,因为:

  • 消费本身可能重复
  • 下游系统启动拉取重复
  • 失败重试带来的重复
  • 补偿逻辑导致的重复


保证生产者等幂性


保证生产者等幂性,再生产消息的时候,利用雪花算法给消息生成一个全局id,在消息系统中维护消息与id的映射关系,如果在映射表中已经存在相同id,则丢掉这条消息,虽然消息被投递了两次,但实际上就保存了一条,避免了消息重复问题。生产者等幂性跟所选的消息中间件有关系,因为绝大多数情况下消息系统不需要我们自己实现,所以等幂性不太好控制的,消费者等幂性才是我们开发人员控制的重点方向。


保证消费者等幂性


在通用层面,在消费消息时产生全局唯一id,消息被处理成功后,把这个全局id存入数据库中,在处理下一条消息之前,先从数据库中查询这个全局id是否存在,如果存在,则直接放弃该消息。


消息顺序性


一个 queue,多个 consumer。比如,生产者向 RabbitMQ 里发送了三条数据,顺序依次是 data1/data2/data3,压入的是 RabbitMQ 的一个内存队列。有三个消费者分别从 MQ 中消费这三条数据中的一条,结果消费者 2 先执行完操作,把 data2 存入数据库,然后是 data1/data3。这不明显乱了。


20210325193354184.png


解决方案:

拆分多个 queue,每个 queue 一个 consumer,就是多一些 queue 而已,确实是麻烦点;或者就一个 queue 但是对应一个 consumer,然后这个 consumer 内部用内存队列做排队,然后分发给底层不同的 worker 来处理。


20210325193451326.png


消息积压


大量消息在mq里积压了几个小时了还没解决


先修复consumer的问题,确保其恢复消费速度,然后将现有consumer都停掉;

新建一个topic,partition是原来的10倍,临时建立好原先10倍或者20倍的queue数量;

然后写一个临时的分发数据的consumer程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的10倍数量的queue;

接着临时征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的数据;

这种做法相当于是临时将queue资源和consumer资源扩大10倍,以正常的10倍速度来消费数据;

等快速消费完积压数据之后,得恢复原先部署架构,重新用原先的consumer机器来消费消息;

对于 RocketMQ,官方针对消息积压问题,提供了解决方案


提高消费并行度:绝大部分消息消费行为都属于 IO 密集型,即可能是操作数据库,或者调用 RPC,这类消费行为的消费速度在于后端数据库或者外系统的吞吐量,通过增加消费并行度,可以提高总的消费吞吐量,但是并行度增加到一定程度,反而会下降。所以,应用必须要设置合理的并行度。

批量方式消费:某些业务流程如果支持批量方式消费,则可以很大程度上提高消费吞吐量,例如订单扣款类应用,一次处理一个订单耗时 1 s,一次处理 10 个订单可能也只耗时 2 s,这样即可大幅度提高消费的吞吐量。

跳过非重要消息:发生消息堆积时,如果消费速度一直追不上发送速度,如果业务对数据要求不高的话,可以选择丢弃不重要的消息。例如,当某个队列的消息数堆积到 100000 条以上,则尝试丢弃部分或全部消息,这样就可以快速追上发送消息的速度。

优化每条消息消费过程:把 多 次 DB 交互优化,减少和DB交互的次数,那么总耗时就可以减少,总体性能就可以提高。所以应用如果对时延敏感的话,可以把 DB 部署在 SSD 硬盘,相比于 SCSI 磁盘,前者的 RT 会小很多。


消息队列的消息过期失效了


rabbitmq是可以设置过期时间的,就是TTL,如果消息在queue中积压超过一定的时间就会被rabbitmq给清理掉,这个数据就没了。解决办法:批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上12点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入mq里面去,把白天丢的数据给他补回来。也只能是这样了。假设1万个订单积压在mq里面,没有处理,其中1000个订单都丢了,你只能手动写程序把那1000个订单给查出来,手动发到mq里去再补一次。


消息队列满了


消息积压在mq里,那么如果你很长时间都没处理掉,此时导致mq都快写满了,咋办?这个还有别的办法吗?没有,谁让你第一个方案执行的太慢了,你临时写程序,接入数据来消费,消费一个丢弃一个,都不要了,快速消费掉所有的消息。然后走第二个方案,到了晚上再补数据吧。


关于mq选用


从公司基础建设力量角度


中小型软件公司,建议选RabbitMQ,一方面,erlang语言天生具备高并发的特性,而且他的管理界面用起来十分方便。他的弊端也在这里,虽然RabbitMQ是开源的,然而国内有几个能定制化开发erlang的程序员呢?所幸,RabbitMQ的社区十分活跃,可以解决开发过程中遇到的bug,这点对于中小型公司来说十分重要。不考虑RocketMQ和kafka的原因是,一方面中小型软件公司不如互联网公司,数据量没那么大,选消息中间件,应首选功能比较完备的,所以kafka排除。不考虑RocketMQ的原因是,RocketMQ是阿里出品,如果阿里放弃维护RocketMQ,中小型公司一般抽不出人来进行RocketMQ的定制化开发,因此不推荐。

大型软件公司,根据具体使用在RocketMQ和kafka之间二选一。一方面,大型软件公司,具备足够的资金搭建分布式环境,也具备足够大的数据量。针对RocketMQ,大型软件公司也可以抽出人手对RocketMQ进行定制化开发,毕竟国内有能力改JAVA源码的人,还是相当多的。至于kafka,根据业务场景选择,如果有日志采集功能,肯定是首选kafka了。

从业务场景角度出发


RocketMQ定位于非日志的可靠消息传输(日志场景也OK),目前RocketMQ在阿里集团被广泛应用在订单,交易,充值,流计算,消息推送,日志流式处理,binglog分发等场景。

Kafka是LinkedIn开源的分布式发布-订阅消息系统,目前归属于Apache定级项目。Kafka主要特点是基于Pull的模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输。0.8版本开始支持复制,不支持事务,对消息的重复、丢失、错误没有严格要求,适合产生大量数据的互联网服务的数据收集业务。

RabbitMQ是使用Erlang语言开发的开源消息队列系统,基于AMQP协议来实现。AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。AMQP协议更多用在企业系统内,对数据一致性、稳定性和可靠性要求很高的场景,对性能和吞吐量的要求还在其次。


Dubbo底层运行原理,特性,支持的协议,容错策略,负载均衡策略,Zookeeper底层原理,选举机制,假死以及解决方案,ZooKeeper典型使用场景,Spring MVC工作原理,Mybatis框架优点


工作流程


20210325201059312.png


20210325201114478.png


服务注册与发现


1、Provider(提供者)绑定指定端口并启动服务

2、供者连接注册中心,并发本机 IP、端口、应用信息和提供服务信息发送至注册中心存储

3、Consumer(消费者),连接注册中心 ,并发送应用信息、所求服务信息至注册中心

4、注册中心根据消费者所求服务信息匹配对应的提供者列表发送至Consumer 应用缓存。

5、Consumer 在发起远程调用时基于缓存的消费者列表择其一发起调用。

6、Provider 状态变更会实时通知注册中心、在由注册中心实时推送至Consumer设计的原因:Consumer 与 Provider 解偶,双方都可以横向增减节点数。注册中心对本身可做对等集群,可动态增减节点,并且任意一台宕掉后,将自动切换到另一台

7、去中心化,双方不直接依懒注册中心,即使注册中心全部宕机短时间内也不会影响服务的调用

8、服务提供者无状态,任意一台宕掉后,不影响使用


dubbo的特性


透明远程调用:就像调用本地方法一样调用远程方法;只需简单配置,没有任何 API 侵入

负载均衡机制:Client 端 LB,可在内网替代 F5 等硬件负载均衡器

容错重试机制:服务 Mock 数据,重试次数、超时机制等

自动注册发现:注册中心基于接口名查询服务提 供者的 IP 地址,并且能够平滑添加或删除服务提供者

性能日志监控:Monitor 统计服务的调用次调和调用时间的监控中心

服务治理中心:路由规则,动态配置,服务降级,访问控制,权重调整,负载均衡,等手动配置

自动治理中心:无,比如:熔断限流机制、自动权重调整等(因此可以搭配SpringCloud的熔断机制等进行开发)


协议


Dubbo协议:dubbo 缺省协议 采用单一长连接和NIO异步通讯,适合于小数据量大并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况,不适合传送大数据量的服务,比如传文件,传视频等,除非请求量很低。


连接个数:单连接

连接方式:长连接

传输协议:TCP

传输方式:NIO异步传输

序列化:Hessian 二进制序列化

适用范围:传入传出参数数据包较小(建议小于100K),消费者比提供者个数多,单一消费者无法压满提供者,尽量不要用dubbo协议传输大文件或超大字符串。

适用场景:常规远程服务方法调用


rmi 协议:RMI协议采用JDK标准的java.rmi.*实现,采用阻塞式短连接JDK标准序列化方式 。


  • 连接个数:多连接
  • 连接方式:短连接
  • 传输协议:TCP
  • 传输方式:同步传输
  • 序列化:Java标准二进制序列化
  • 适用范围:传入传出参数数据包大小混合,消费者与提供者个数差不多,可传文件。
  • 适用场景:常规远程服务方法调用,与原生RMI服务互操作


http 协议:基于http表单的远程调用协议


  • 连接个数:多连接
  • 连接方式:短连接
  • 传输协议:HTTP
  • 传输方式:同步传输
  • 序列化:表单序列化 ,即 json
  • 适用范围:传入传出参数数据包大小混合,提供者比消费者个数多,可用浏览器查看,可用表单或URL传入参数,暂不支持传文件。
  • 适用场景:需同时给应用程序和浏览器JS使用的服务。


容错


容错机制调用流程


1、Cluster 将 Directory 中的多个 Invoker 伪装成一个Invoker,对上层透明,伪装过程包含了容错逻辑

2、Router 负责从多个 Invoker 中按路由规则选出子集,比如读写分离,应用隔离等

3、LoadBalance 负责从多个 Invoker 中选出具体的一个用于本次调用,选的过程包含了负载均衡算法

20210325201539786.png

Dubbo 官网提出总共有六种容错策略

1、Failover Cluster失败自动切换,当出现失败,重试其它服务器。(默认)

2、Failfast Cluster快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。

3、Failsafe Cluster失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。

4、Failback Cluster失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。

5、Forking Cluster并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks=”2”来设置最大并行数。

6、Broadcast Cluster广播调用所有提供者,逐个调用,任意一台报错则报错。(2.1.0 开始支持) 通常用于通知所有提供者更新缓存或日志等本地资源信息。


负载均衡


1、Random LoadBalance,随机(默认的负载均衡策略)是加权随机算法的具体实现,可以完全随机,也可以按权重设置随机概率。

2、RoundRobin LoadBalance,轮循。可以轮询和加权轮询。存在响应慢的提供者会累积请求的问题,比如:第二台机器很慢,但没挂,当请求调到第二台时就卡在那,久而久之,所有请求都卡在调到第二台上。

3、LeastActive LoadBalance,最少活跃调用数。活跃调用数越小,表明该服务提供者效率越高,单位时间内可处理更多的请求。此时应优先将请求分配给该服务提供者。

4、ConsistentHash LoadBalance,一致性 Hash。一致性 Hash 算法,相同参数的请求一定分发到一个 provider 上去。provider 挂掉的时候,会基于虚拟节点均匀分配剩余的流量,抖动不会太大。


zookeeper是什么?


Zookeeper会维护一个类似于标准的文件系统的具有层次关系的数据结构。这个文件系统中每个子目录项都被称为znode节点,这个znode节点也可以有子节点,每个节点都可以存储数据,客户端也可以对这些node节点进行getChildren,getData,exists方法,同时也可以在znode tree路径上设置watch(类似于监听),当watch路径上发生节点create、delete、update的时候,会通知到client。client可以得到通知后,再获取数据,执行业务逻辑操作。Zookeeper 的作用主要是用来维护和监控存储的node节点上这些数据的状态变化,通过监控这些数据状态的变化,从而达到基于数据的集群管理。


为什么要用zookeeper作为dubbo的注册中心?能选择其他的吗?


Zookeeper的数据模型是由一系列的Znode数据节点组成,和文件系统类似。zookeeper的数据全部存储在内存中,性能高;zookeeper也支持集群,实现了高可用;同时基于zookeeper的特性,也支持事件监听(服务的暴露方发生变化,可以进行推送),所以zookeeper适合作为dubbo的注册中心区使用。redis、Simple也可以作为dubbo的注册中心来使用。


zookeeper工作原理


官方定义:当一个集群的不同部分在同一时间都认为自己是活动的时候,我们就可以将这个现象称为脑裂症状。通俗的说,就是比如当你的 cluster 里面有两个结点,它们都知道在这个 cluster 里需要选举出一个 master。那么当它们两之间的通信完全没有问题的时候,就会达成共识,选出其中一个作为 master。但是如果它们之间的通信出了问题,那么两个结点都会觉得现在没有 master,所以每个都把自己选举成 master。于是 cluster 里面就会有两个 master


ZooKeeper每个节点都尝试注册一个象征master的临时节点,其他没有注册成功的则成为slaver,并且通过watch机制监控着master所创建的临时节点,Zookeeper通过内部心跳机制来确定master的状态,一旦master出现意外Zookeeper能很快获悉并且通知其他的slaver,其他slaver在之后作出相关反应。这样就完成了一个切换。


Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们分别是恢复模式(选主)和广播模式(同步)。当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。


为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加上了zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一个新的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数。


每个Server在工作过程中有三种状态:


LOOKING:当前Server不知道leader是谁,正在搜寻

LEADING:当前Server即为选举出来的leader

FOLLOWING:leader已经选举出来,当前Server与之同步


zookeeper集群数量为什么是单数?


zookeeper有这样一个特性:集群中只要有过半的机器是正常工作的,那么整个集群对外就是可用的。也就是说如果有2个zookeeper,那么只要有1个死了zookeeper就不能用了,因为1没有过半,所以2个zookeeper的死亡容忍度为0;同理,要是有3个zookeeper,一个死了,还剩下2个正常的,过半了,所以3个zookeeper的容忍度为1;同理你多列举几个:2->0;3->1;4->1;5->2;6->2会发现一个规律,2n和2n-1的容忍度是一样的,都是n-1,所以为了更加高效,何必增加那一个不必要的zookeeper呢。


领导者选举


领导者选举的过程实际上就是比较哪台服务器比较强,比较规则是:1. 谁的数据比较新谁当领导(zxid),2.数据都一样则看谁的服务器Id(myid)比较大,谁就是领导;这个过程是通过各个服务器之间相互投票来进行的,每台服务器会接收其他服务器的投票,在投票信息里就会包含上面说的两个信息zxid, myid,然后进行PK,选出谁比较强,而PK中弱的那一方修改自己的投票,改为投刚刚和自己PK赢的一方,所以按照这个规则,每台服务器都会有一个自己认为最强的那个人,而在整个投票的过程中,每台服务器内部都会存在一个投票箱,该投票箱内存放了其他服务器当前投给了谁,所以每台服务器可以根据这个投票箱内的数据来看是否有超过半数的服务器和我当前投的最强者是同一台服务器,如果超过了则认为选出了Leader(自己当前所投的那个最强者即为Leader),如果发现自己就是这个最强者,则进行领导,如果自己不是,则进行跟随(Follower)。


假死


心跳出现超时可能是master挂了,但是也可能是master,zookeeper之间网络出现了问题,也同样可能导致。这种情况就是假死,master并未死掉,但是与ZooKeeper之间的网络出现问题导致Zookeeper认为其挂掉了然后通知其他节点进行切换,这样slaver中就有一个成为了master,但是原本的master并未死掉,这时候client也获得master切换的消息,但是仍然会有一些延时,zookeeper需要通讯需要一个一个通知,这时候整个系统就很混乱可能有一部分client已经通知到了连接到新的master上去了,有的client仍然连接在老的master上如果同时有两个client需要对master的同一个数据更新并且刚好这两个client此刻分别连接在新老的master上,就会出现很严重问题。


解决方案:


1、添加心跳线。


原来两个namenode之间只有一条心跳线路,此时若断开,则接收不到心跳报告,判断对方已经死亡。此时若有2条心跳线路,一条断开,另一条仍然能够接收心跳报告,能保证集群服务正常运行。2条心跳线路同时断开的可能性比1条心跳线路断开的小得多。再有,心跳线路之间也可以HA(高可用),这两条心跳线路之间也可以互相检测,若一条断开,则另一条马上起作用。正常情况下,则不起作用,节约资源。


2、启用磁盘锁。


由于两个active会争抢资源,导致从节点不知道该连接哪一台namenode,可以使用磁盘锁的形式,保证集群中只能有一台namenode获取磁盘锁,对外提供服务,避免数据错乱的情况发生。但是,也会存在一个问题,若该namenode节点宕机,则不能主动释放锁,那么其他的namenode就永远获取不了共享资源。因此,在HA上使用"智能锁"就成为了必要措施。"智能锁"是指active的namenode检测到了心跳线全部断开时才启动磁盘锁,正常情况下不上锁。保证了假死状态下,仍然只有一台namenode的节点提供服务。


3、设置仲裁机制


脑裂导致的后果最主要的原因就是从节点不知道该连接哪一台namenode,此时如果有一方来决定谁留下,谁放弃就最好了。因此出现了仲裁机制,比如提供一个参考的IP地址,当出现脑裂现象时,双方接收不到对方的心跳机制,但是能同时ping参考IP,如果有一方ping不通,那么表示该节点网络已经出现问题,则该节点需要自行退出争抢资源的行列,或者更好的方法是直接强制重启,这样能更好的释放曾经占有的共享资源,将服务的提供功能让给功能更全面的namenode节点。


ZooKeeper典型使用场景一览


场景类别

典型场景描述(ZK特性,使用方法)

应用中的具体使用

数据发布与订阅 发布与订阅即所谓的配置管理,顾名思义就是将数据发布到zk节点上,供订阅者动态获取数据,实现配置信息的集中式管理和动态更新。例如全局的配置信息,地址列表等就非常适合使用。

1. 索引信息和集群中机器节点状态存放在zk的一些指定节点,供各个客户端订阅使用。2. 系统日志(经过处理后的)存储,这些日志通常2-3天后被清除。


3. 应用中用到的一些配置信息集中管理,在应用启动的时候主动来获取一次,并且在节点上注册一个Watcher,以后每次配置有更新,实时通知到应用,获取最新配置信息。


4. 业务逻辑中需要用到的一些全局变量,比如一些消息中间件的消息队列通常有个offset,这个offset存放在zk上,这样集群中每个发送者都能知道当前的发送进度。


5. 系统中有些信息需要动态获取,并且还会存在人工手动去修改这个信息。以前通常是暴露出接口,例如JMX接口,有了zk后,只要将这些信息存放到zk节点上即可。

————————————————

版权声明:本文为CSDN博主「我是廖志伟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/java_wxid/article/details/115136786

Name Service 这个主要是作为分布式命名服务,通过调用zk的create node api,能够很容易创建一个全局唯一的path,这个path就可以作为一个名称。


分布通知/协调

ZooKeeper 中特有watcher注册与异步通知机制,能够很好的实现分布式环境下不同系统之间的通知与协调,实现对数据变更的实时处理。使用方法通常是不同系统都对 ZK上同一个znode进行注册,监听znode的变化(包括znode本身内容及子节点的),其中一个系统update了znode,那么另一个系统能 够收到通知,并作出相应处理。

————————————————

版权声明:本文为CSDN博主「我是廖志伟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/java_wxid/article/details/115136786

1. 另一种心跳检测机制:检测系统和被检测系统之间并不直接关联起来,而是通过zk上某个节点关联,大大减少系统耦合。2. 另一种系统调度模式:某系统有控制台和推送系统两部分组成,控制台的职责是控制推送系统进行相应的推送工作。管理人员在控制台作的一些操作,实际上是修改 了ZK上某些节点的状态,而zk就把这些变化通知给他们注册Watcher的客户端,即推送系统,于是,作出相应的推送任务。


3. 另一种工作汇报模式:一些类似于任务分发系统,子任务启动后,到zk来注册一个临时节点,并且定时将自己的进度进行汇报(将进度写回这个临时节点),这样任务管理者就能够实时知道任务进度。


总之,使用zookeeper来进行分布式通知和协调能够大大降低系统之间的耦合。

————————————————

版权声明:本文为CSDN博主「我是廖志伟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/java_wxid/article/details/115136786

分布式锁

分布式锁,这个主要得益于ZooKeeper为我们保证了数据的强一致性,即用户只要完全相信每时每刻,zk集群中任意节点(一个zk server)上的相同znode的数据是一定是相同的。锁服务可以分为两类,一个是保持独占,另一个是控制时序。


所 谓保持独占,就是所有试图来获取这个锁的客户端,最终只有一个可以成功获得这把锁。通常的做法是把zk上的一个znode看作是一把锁,通过create znode的方式来实现。所有客户端都去创建 /distribute_lock 节点,最终成功创建的那个客户端也即拥有了这把锁。


控 制时序,就是所有视图来获取这个锁的客户端,最终都是会被安排执行,只是有个全局时序了。做法和上面基本类似,只是这里 /distribute_lock 已经预先存在,客户端在它下面创建临时有序节点(这个可以通过节点的属性控制:CreateMode.EPHEMERAL_SEQUENTIAL来指 定)。Zk的父节点(/distribute_lock)维持一份sequence,保证子节点创建的时序性,从而也形成了每个客户端的全局时序。

————————————————

版权声明:本文为CSDN博主「我是廖志伟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/java_wxid/article/details/115136786


集群管理

1. 集群机器监 控:这通常用于那种对集群中机器状态,机器在线率有较高要求的场景,能够快速对集群中机器变化作出响应。这样的场景中,往往有一个监控系统,实时检测集群 机器是否存活。过去的做法通常是:监控系统通过某种手段(比如ping)定时检测每个机器,或者每个机器自己定时向监控系统汇报“我还活着”。 这种做法可行,但是存在两个比较明显的问题:1. 集群中机器有变动的时候,牵连修改的东西比较多。2. 有一定的延时。


利 用ZooKeeper有两个特性,就可以实时另一种集群机器存活性监控系统:a. 客户端在节点 x 上注册一个Watcher,那么如果 x 的子节点变化了,会通知该客户端。b. 创建EPHEMERAL类型的节点,一旦客户端和服务器的会话结束或过期,那么该节点就会消失。


例 如,监控系统在 /clusterServers 节点上注册一个Watcher,以后每动态加机器,那么就往 /clusterServers 下创建一个 EPHEMERAL类型的节点:/clusterServers/{hostname}. 这样,监控系统就能够实时知道机器的增减情况,至于后续处理就是监控系统的业务了。

2. Master选举则是zookeeper中最为经典的使用场景了。


在 分布式环境中,相同的业务应用分布在不同的机器上,有些业务逻辑(例如一些耗时的计算,网络I/O处理),往往只需要让整个集群中的某一台机器进行执行, 其余机器可以共享这个结果,这样可以大大减少重复劳动,提高性能,于是这个master选举便是这种场景下的碰到的主要问题。


利用ZooKeeper的强一致性,能够保证在分布式高并发情况下节点创建的全局唯一性,即:同时有多个客户端请求创建 /currentMaster 节点,最终一定只有一个客户端请求能够创建成功。


利用这个特性,就能很轻易的在分布式环境中进行集群选取了。


另外,这种场景演化一下,就是动态Master选举。这就要用到 EPHEMERAL_SEQUENTIAL类型节点的特性了。


上 文中提到,所有客户端创建请求,最终只有一个能够创建成功。在这里稍微变化下,就是允许所有请求都能够创建成功,但是得有个创建顺序,于是所有的请求最终 在ZK上创建结果的一种可能情况是这样: /currentMaster/{sessionId}-1 , /currentMaster/{sessionId}-2 , /currentMaster/{sessionId}-3 ….. 每次选取序列号最小的那个机器作为Master,如果这个机器挂了,由于他创建的节点会马上小时,那么之后最小的那个机器就是Master了。

————————————————

版权声明:本文为CSDN博主「我是廖志伟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/java_wxid/article/details/115136786

1. 在搜索系统中,如果集群中每个机器都生成一份全量索引,不仅耗时,而且不能保证彼此之间索引数据一致。因此让集群中的Master来进行全量索引的生成, 然后同步到集群中其它机器。2. 另外,Master选举的容灾措施是,可以随时进行手动指定master,就是说应用在zk在无法获取master信息时,可以通过比如http方式,向 一个地方获取master。

————————————————

版权声明:本文为CSDN博主「我是廖志伟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/java_wxid/article/details/115136786

分布式队列

队列方面,我目前感觉有两种,一种是常规的先进先出队列,另一种是要等到队列成员聚齐之后的才统一按序执行。对于第二种先进先出队列,和分布式锁服务中的控制时序场景基本原理一致,这里不再赘述。


第 二种队列其实是在FIFO队列的基础上作了一个增强。通常可以在 /queue 这个znode下预先建立一个/queue/num 节点,并且赋值为n(或者直接给/queue赋值n),表示队列大小,之后每次有队列成员加入后,就判断下是否已经到达队列大小,决定是否可以开始执行 了。这种用法的典型场景是,分布式环境中,一个大任务Task A,需要在很多子任务完成(或条件就绪)情况下才能进行。这个时候,凡是其中一个子任务完成(就绪),那么就去 /taskList 下建立自己的临时时序节点(CreateMode.EPHEMERAL_SEQUENTIAL),当 /taskList 发现自己下面的子节点满足指定个数,就可以进行下一步按序进行处理了。

————————————————

版权声明:本文为CSDN博主「我是廖志伟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/java_wxid/article/details/115136786



SpringMVC运行原理


流程说明:


(1)客户端(浏览器)发送请求,直接请求到DispatcherServlet。


(2)DispatcherServlet根据请求信息调用HandlerMapping,解析请求对应的Handler。


(3)解析到对应的Handler后,开始由HandlerAdapter适配器处理。


(4)HandlerAdapter会根据Handler来调用真正的处理器开处理请求,并处理相应的业务逻辑。


(5)处理器处理完业务后,会返回一个ModelAndView对象,Model是返回的数据对象,View是个逻辑上的View。


(6)ViewResolver会根据逻辑View查找实际的View。


(7)DispaterServlet把返回的Model传给View。


(8)通过View返回给请求者(浏览器)



20210325222010999.png


Mybaits 的优点


基于 SQL 语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL 写在 XML 里,解除 sql 与程序代码的耦合,便于统一管理;提供 XML标签,支持编写动态 SQL 语句,并可重用。

与 JDBC 相比,减少了 50%以上的代码量,消除了 JDBC 大量冗余的代码,不需要手动开关连接;

很好的与各种数据库兼容(因为 MyBatis 使用 JDBC 来连接数据库,所以只要JDBC 支持的数据库 MyBatis 都支持)。

能够与 Spring 很好的集成;

提供映射标签,支持对象与数据库的 ORM 字段关系映射;提供对象关系映射标签,支持对象关系组件维护。


MyBatis 框架的缺点


  1. SQL 语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL 语句的功底有一定要求。
  2. SQL 语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。


JVM算法,垃圾收集器,垃圾回收机制,JMM和JVM内存模型,JVM调优,双亲委派机制,堆溢出,栈溢出,方法区溢出,你都有哪些手段用来排查内存溢出?


JVM算法


垃圾判断算法:


引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。对象循环引用问题,即对象A引用对象B的,而在对象B中又引用了对象A,那么对于对象A和对象B来说,其引用计数器都为1,难以判断其是否存活。

可达性分析:从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即为不可达对象。

垃圾回收算法:


“标记-清除”算法:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

“复制”算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,降低了内存的利用率,持续复制长生存期的对象则导致效率降低,还有在分配对象较大时,该种算法也存在效率低下的问题。

“标记-整理”算法:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,这种算法克服了复制算法的低效问题,同时克服了标记清除算法的内存碎片化的问题。

分代收集算法:是一种划分的策略,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。


垃圾回收器


Serial收集器


串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法老年代标记-压缩;垃圾收集的过程中会服务暂停。参数控制:-XX:+UseSerialGC 串行收集器


ParNew收集器


ParNew收集器其实就是Serial收集器的多线程版本。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩

参数控制:-XX:+UseParNewGC ParNew收集器 -XX:ParallelGCThreads 限制线程数量参数


Parallel收集器


Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩


参数控制:-XX:+UseParallelGC 使用Parallel收集器+ 老年代串行


Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供


参数控制:-XX:+UseParallelOldGC使用Parallel收集器+ 老年代并行


CMS 收集器


CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器是基于“标记-清除”算法实现的,整个过程分为4个步骤,包括:


初始标记(CMS initial mark)

并发标记(CMS concurrent mark)

重新标记(CMS remark)

并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要服务暂停。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)


优点:并发收集、低停顿

缺点:产生大量空间碎片、并发阶段会降低吞吐量

参数控制:


-XX:+UseConcMarkSweepGC 使用CMS收集器

-XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长

-XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理

-XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

 

20210325224401163.png


G1 收集器


空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。

可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

其他垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分可以不连续Region的集合。


收集步骤:


标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。对应GC log:GC pause (young) (inital-mark)

Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。

Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。

Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。

复制/清除过程后。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域。


zgc收集器


最近一个新的GC收集器概念比较火,JDK团队在JDK 11中即将迎来ZGC,这是一个处于实验阶段的,可扩展的低延迟垃圾回收器。它能够处理从几百M到几T的JAVA堆,与G1相比,吞吐量下降不超过15%。


java垃圾回收机制


内存区域与回收策略


对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在线程本地分配缓冲区上分配。少数情况下也可能会直接分配在老年代中(大对象直接分到老年代),分配的规则并不是百分百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。


对象分配过程


多数情况下,小对象会在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机会发起一次 Minor GC。通过Minor GC之后,Eden区中绝大部分对象会被回收,而那些存活对象,将会送到Survivor的From区。Survivor又分为2个区,一个是From区,一个是To区。Minor GC后,from里面存活下来的对象,将它挪到to区,对象在From变To , To变From二个存活区切换15次,也就是说只有经历16次Minor GC还能在新生代中存活的对象(长期存活的对象),才会被送到老年代。


大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来 “安置” 它们。虚拟机提供了一个XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。


JVM内存模型


程序计数器(PC)


程序计数器是一块很小的内存空间,用于记录下一条要运行的指令。每个线程都需要一个程序计数器,各个线程之中的计数器相互独立,是线程中私有的内存空间


java虚拟机栈


java虚拟机栈也是线程私有的内存空间,它和java线程同一时间创建,保存了局部变量、部分结果,并参与方法的调用和返回


本地方法栈


本地方法栈和java虚拟机栈的功能相似,java虚拟机栈用于管理Java函数的调用,而本地方法栈用于管理本地方法的调用,但不是由Java实现的,而是由C实现的


java堆


为所有创建的对象和数组分配内存空间,被JVM中所有的线程共享


方法区


也被称为永久区,与堆空间相似,被JVM中所有的线程共享。方法区主要保存的信息是类的元数据,方法区中最为重要的是类的类型信息、常量池、域信息、方法信息,其中运行时常量池就在方法区,对永久区的GC回收,一是GC对永久区常量池的回收;二是永久区对元数据的回收


JMM


在JVM内部使用的java内存模型(JMM)将线程堆栈和堆之间的内存分开


线程堆栈(thread stack):


1.运行在java虚拟机上的每个线程都有自己的线程堆栈(thread stack)


2.线程堆栈还包含正在执行的每个方法的所有局部变量,一个线程只能访问它自己的线程堆栈。由线程创建的局部变量对于除创建它的线程之外的所有其他线程都是不可见的。


3.即使两个线程正在执行完全相同的代码,两个线程仍然会在每个线程堆栈中创建该代码的局部变量,一个线程可能会将一个有限变量的副本传递给另一个线程,但它不能共享原始局部变量本身


堆:


1.堆包含在Java应用程序中创建的所有对象,而不管是不是由线程创建的该对象。


2.堆中的对象可以被具有对象引用的所有线程访问。当一个线程访问一个对象时,它也可以访问该对象的成员变量。


3.如果两个线程同时调用同一个对象上的一个方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本


4.堆中的数据是共享的,线程不安全的


JVM调优


1.针对JVM堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值;


2.年轻代和年老代将根据默认的比例(1:2)分配堆内存


.年轻代和年老代设置多大才算合理


1)更大的年轻代必然导致更小的年老代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC


2)更小的年轻代必然导致更大年老代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率


如何选择应该依赖应用程序对象生命周期的分布情况: 如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。


在抉择时应该根 据以下两点:


(1)本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 。


(2)通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给年老代至少预留1/3的增长空间。


4.在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法: -XX:+UseParallelOldGC 。


5.线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。


双亲委派模型


双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。


类随着它的类加载器一起具备了一种带有优先级的层次关系。


例如类java.lang.Object,它由启动类加载器加载。双亲委派模型保证任何类加载器收到的对java.lang.Object的加载请求,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并用自定义的类加载器加载,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。


内存溢出


堆溢出


package kan;
/**
 * VM Args : -Xms1m -Xmx1m
 */
import java.util.ArrayList;
import java.util.List;
public class Overheap {
  static class OOheap {
  }
  public static void main(String[] args) {
    List<OOheap> l = new ArrayList<OOheap>();
    while (true) {
      l.add(new OOheap());
    }
  }
}


栈溢出


/**
 * VM Args : -Xss100k
 * 
 * 
 */
public class OverStack {
  public void leakStack() {
    leakStack();
  }
  public static void main(String[] args) {
    OverStack o = new OverStack();
    try {
      o.leakStack();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}


方法区溢出:(偷懒溢出常量池)


/**
 * VM Args: -XX:PremSize=100k -XX:MaxPremSize=100k
 */
import java.util.ArrayList;
import java.util.List;
public class OverPremGen {
  public static void main(String[] args) {
    List l = new ArrayList();
    int i = 0;
    while(true){
      l.add(String.valueOf(i++).intern());
    }
  }
}


JVM 堆内存溢出后,其他线程是否可继续工作?


当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,使用堆的数量,突然间急剧下滑,从而不会影响其他线程的运行!其实发生OOM的线程一般情况下会死亡,也就是会被终结掉,该线程持有的对象占用的heap都会被gc了,释放内存。因为发生OOM之前要进行gc,就算其他线程能够正常工作,也会因为频繁gc产生较大的影响。


什么是溢出和泄漏?


  • 内存溢出:程序在申请内存的时候,没有足够的内存可以分配,导致内存溢出。俗称,内存不够了。
  • 内存泄漏:内存在生命周期完成后,如果得不到及时的释放,就会一直占用内存,造成内存泄漏。随着内存泄漏的堆积,可用的内存空间越来越少,最后会导致内存溢出。


你都有哪些手段用来排查内存溢出?


内存溢出包含很多种情况,我在平常工作中遇到最多的就是堆溢出。有一次线上遇到故障,重新启动后,使用jstat命令,发现Old区在一直增长。我使用jmap命令,导出了一份线上堆栈,然后使用MAT进行分析。通过对GC Roots的分析,我发现了一个非常大的HashMap对象,这个原本是有位同学做缓存用的,但是一个***缓存,造成了堆内存占用一直上升。后来,将这个缓存改成 guava的Cache,并设置了弱引用,故障就消失了。


有什么堆外内存的排查思路?

进程占用的内存,可以使用top命令,看RES段占用的值。如果这个值大大超出我们设定的最大堆内存,则证明堆外内存占用了很大的区域。


使用gdb可以将物理内存dump下来,通常能看到里面的内容。更加复杂的分析可以使用perf工具,或者谷歌开源的gperftools。那些申请内存最多的native函数,很容易就可以找到。


栈溢出


一个栈是大小是固定了的,当你调用的方法太多,线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常,方法递归调用产生这种结果。


还有就是栈的大小可以动态改变,用-Xss改变栈的大小,当尝试扩展的时候无法申请到足够的内存去完成拓展,或者在建立新线程的时候没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个OutOfMemoryError异常。(线程启动过多)


栈是线程私有的,他的生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口灯信息。


栈溢出的原因:


1. 局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。局部变量是存储在栈中的,因此这个很好理解。解决这类问题的办法有两个,一是增大栈空间,二是改用动态分配,使用堆(heap)而不是栈(stack)。


2. 递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。


3. 指针或数组越界。这种情况最常见,例如进行字符串拷贝,或处理用户输入等等。


SpringCould组件说七八个


假设咱们现在开发一个电商网站,要实现支付订单的功能,我们需要有订单服务、库存服务、仓储服务、积分服务,流程为:


2021032606470039.png


订单服务压根儿就不知道人家库存服务在哪台机器上啊!他就算想要发起一个请求,都不知道发送给谁,有心无力!


Eureka


Eureka是微服务架构中的注册中心,专门负责服务的注册与发现。


20210326064750300.png


库存服务、仓储服务、积分服务中都有一个Eureka Client组件,这个组件专门负责将这个服务的信息注册到Eureka Server中。说白了,就是告诉Eureka Server,自己在哪台机器上,监听着哪个端口。而Eureka Server是一个注册中心,里面有一个注册表,保存了各服务所在的机器和端口号。


订单服务里也有一个Eureka Client组件,这个Eureka Client组件会找Eureka Server问一下:库存服务在哪台机器啊?监听着哪个端口啊?仓储服务呢?积分服务呢?然后就可以把这些相关信息从Eureka Server的注册表中拉取到自己本地缓存起来。这时如果订单服务想要调用库存服务,不就可以找自己本地的Eureka Client问一下库存服务在哪台机器?监听哪个端口吗?收到响应后,紧接着就可以发送一个请求过去,调用库存服务扣减库存的那个接口!同理,如果订单服务要调用仓储服务、积分服务,也是如法炮制。


总结一下:


Eureka Client:负责将这个服务的信息注册到Eureka Server中

Eureka Server:注册中心,里面有一个注册表,保存了各个服务所在的机器和端口号

订单服务确实知道库存服务、积分服务、仓库服务在哪里了,同时也监听着哪些端口号了。但是新问题又来了:难道订单服务要自己写一大堆代码,跟其他服务建立连接、构造请求、解析响应的代码。


Feign


直接就是用注解定义一个 FeignClient接口,然后调用那个接口就可以了,Feign Client会在底层根据你的注解,跟你指定的服务建立连接、构造请求、发起靕求、获取响应、解析响应。


首先,如果你对某个接口定义了@FeignClient注解,Feign就会针对这个接口创建一个动态代理

接着你要是调用那个接口,本质就是会调用 Feign创建的动态代理,这是核心中的核心

Feign的动态代理会根据你在接口上的@RequestMapping等注解,来动态构造出你要请求的服务的地址

最后针对这个地址,发起请求、解析响应

202103260655133.png


Ribbon


库存服务部署在了5台机器上,Feign怎么知道该请求哪台机器呢?


每次请求时Ribbon默认使用轮询算法进行负载均衡选择一台机器,均匀的把请求分发到各个机器上


首先Ribbon会从 Eureka Client里获取到对应的服务注册表,也就知道了所有的服务都部署在了哪些机器上,在监听哪些端口号。

然后Ribbon就可以使用默认的Round Robin算法,从中选择一台机器

Feign就会针对这台机器,构造并发起请求。


Hystrix


订单服务在一个业务流程里需要调用三个服务。现在假设订单服务自己最多只有100个线程可以处理请求,然后呢,积分服务不幸的挂了,每次订单服务调用积分服务的时候,都会卡住几秒钟,然后抛出—个超时异常,订单服务的100个线程都会卡在请求积分服务这块,请求订单服务的时候,发现订单服务也挂了,不响应任何请求了。

20210326070151150.png

积分服务挂了,订单服务也可以不用挂,支付订单的时候,只要把库存扣减了,然后通知仓库发货就OK了,积分服务挂了,大不了等他恢复之后,慢慢人肉手工恢复数据!为啥一定要因为一个积分服务挂了,就直接导致订单服务也挂了呢?不可以接受!


Hystrix断路器


Hystrix是隔离、熔断以及降级的一个框架。Hystrix会搞很多个小小的线程池,比如订单服务请求库存服务是一个线程池,请求仓储服务是一个线程池,请求积分服务是一个线程池。每个线程池里的线程就仅仅用于请求那个服务。


调用积分服务的线程都卡死不能工作了啊!但由于订单服务调用库存服务、仓储服务的这两个线程池都是正常工作的,所以这两个服务不会受到任何影响。这个时候如果别人请求订单服务,订单服务还是可以正常调用库存服务扣减库存,调用仓储服务通知发货。积分服务都挂了,每次调用都要去卡住几秒钟干啥呢?有意义吗?当然没有!所以我们直接对积分服务熔断不就得了,比如在5分钟内请求积分服务直接就返回了,不要去走网络请求卡住几秒钟,这个过程,就是所谓的熔断!每次调用积分服务,你就在数据库里记录一条消息,说给某某用户增加了多少积分,因为积分服务挂了,导致没增加成功!这样等积分服务恢复了,你可以根据这些记录手工加一下积分。这个过程,就是所谓的降级。


在ribbon使用断路器:pom文件之后引入依赖,在程序的启动类ServiceRibbonApplication 加@EnableHystrix注解开启Hystrix,业务方法上加上@HystrixCommand注解,注解对该方法创建了熔断器的功能,并指定熔断方法。


Feign是自带断路器的,在D版本的Spring Cloud中,它没有默认打开。需要在配置文件中配置打开它,在配置文件加以下代码:feign.hystrix.enabled=true


在FeignClient的业务接口的注解中加上fallback的指定类,该类需要实现业务接口,并注入到Ioc容器中


20210326070638216.png


Zuul网关


zuul负责网络路由,可以实现统一的降级、限流、认证授权、安全


假设你后台部署了几百个服务,对应有有几百个服务的名称和地址,像android、ios、pc前端、微信小程序、H5等不同请求,需要走不同后端请求,但是每个请求不可能记住服务名称和服务地址吧,这里就可以使用zuul网关。不用去关心后端有几百个服务,就知道有一个网关,所有请求都往网关走,网关会根据请求中的一些特征,将请求转发给后端的各个服务。

20210326071245726.png


config配置中心


在分布式系统中,由于服务数量巨多,为了方便服务配置文件统一管理,实时更新,需要分布式配置中心组件。


支持配置服务放在配置服务的内存中(即本地),也支持放在远程Git仓库中。config 组件中,分两个角色,一是config server,二是config client。config-client可以从config-server获取配置属性。


Bus数据总线


将分布式的节点用轻量的消息代理连接起来。它可以用于广播配置文件的更改或者服务之间的通讯,也可以用于监控。


应用场景:实现通知微服务架构的配置文件的更改。去代码仓库将foo的值改为“foo version 4”,即改变配置文件foo的值。如果是传统的做法,需要重启服务,才能达到配置文件的更新。我们只需要发送post请求:http://localhost:8881/bus/refresh,会发现config-client会重现肚脐配置文件,重新读取配置文件。


案例:当git文件更改的时候,通过pc端用post 向端口为8882的config-client发送请求/bus/refresh/;此时8882端口会发送一个消息,由消息总线向其他服务传递,从而使整个微服务集群都达到更新配置文件。


Sleuth


微服务架构上通过业务来划分服务的,通过REST调用,对外暴露的一个接口,可能需要很多个服务协同才能完成这个接口功能,如果链路上任何一个服务出现问题或者网络超时,都会形成导致接口调用失败。随着业务的不断扩张,服务之间互相调用会越来越复杂。一个 HTTP 请求会调用多个不同的微服务来处理返回最后的结果,在这个调用过程中,可能会因为某个服务出现网络延迟过高或发送错误导致请求失败,所以需要对服务追踪分析,提供一个可视化页面便于排查问题所在。


Sleuth 整合 Zipkin,可以使用它来收集各个服务器上请求链路的跟踪数据,并通过它提供的 REST API 接口来辅助查询跟踪数据以实现对分布式系统的监控程序,从而及时发现系统中出现的延迟过高问题。除了面向开发的 API 接口之外,它还提供了方便的 UI 组件来帮助我们直观地搜索跟踪信息和分析请求链路明细,比如可以查询某段时间内各用户请求的处理时间等。


MySQL优化,索引限制条件


表中字段的宽度设得尽可能小。

尽量把字段设置为NOTNULL,

使用连接(JOIN)来代替子查询(Sub-Queries)

使用联合(UNION)来代替手动创建的临时表

减少表关联,加入冗余字段

使用外键:锁定表的方法可以维护数据的完整性,但是它却不能保证数据的关联性。这个时候我们就可以使用外键。

使用索引

优化的查询语句

集群

读写分离

主从复制

分表分库

适当的时候可以使用存储过程


最左前缀:查询从索引的最左前列开始并且不跳过索引中的列

索引列上不操作,范围之后全失效

不等空值还有OR

like以通配符%开头索引失效会变成全表扫描的操作

字符串不加单引号索引失效


公平锁,非公平锁,可重入锁,递归锁,自旋锁,读写锁,悲观锁,乐观锁,行锁,排它锁,共享锁,表锁,死锁,分布式锁,AQS,Synchronized


公平锁:多个线程按照申请锁的顺序来获取锁的,类似排队打饭,先来后到。每个线程在获取锁的时候会先查看此锁维护的等待队列,如果为空或者当前线程是等待队列的第一个,就占有锁,否则加入到等待队列之后,后面按照规则从队列中获取。

非公平锁:多个线程获取锁的顺序并不是按照申请锁的顺序来到,高并发情况下,后申请的线程可能比先申请的线程优先获取锁。非公平锁上来就直接尝试占有锁,如果尝试失败,在使用公平锁的方式。它的吞吐量比公平锁大。ReentrantLock默认是非公平锁,Synchronized就是非公平锁。

可重入锁(递归锁):同一线程外层方法获取锁之后,内层方法可以自动获取该锁,可以防止死锁,因为是同一把锁。家里的大门有一把锁,厕所没有上锁。我进了大门了,就不用在厕所上锁了。ReentrantLock就是把可重入锁。

自旋锁:获取锁的线程不会立即堵塞,使用循环的方式去尝试获取锁,减少线程上下文切换的消耗,会消耗CPU资源。

独占锁(写锁):锁一次只能被一个线程所持有,ReentrantLock和Synchronized都是独占锁。

共享锁(读锁):锁可以被多个线程持有,可以保证并发的读。ReentrantReadWriteLock的读锁是共享锁,写锁是独占锁。

读写锁:使用ReentrantReadWriteLock解决原子性和独占性,可以很好的解决并发性和数据的一致性。起因:一个线程去写共享资源,其他线程就不能对它进行读写。写的操作原子性和独占性没有得到保证,一个线程正在写入共享资源的时候,其他线程有写入和读取的共享资源操作,导致数据不一致。writeLock和readLock方法都是通过调用Sync方法实现的。AQS的状态state(int类型)是32的,掰成二瓣,读锁使用高16位表示读锁的线程数,写锁使用低16位表示写锁的重入次数。状态值位0表示锁空闲,读状态为2,写状态为3,sharedCount不为0表示分配了读锁,exclusiveCount不为0表示分配了写锁。


private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();


写锁:

lock.writeLock().lock();//加上写锁    
try {
  //执行写操作
} catch (InterruptedException e) {
  e.printStackTrace();
}finally {
  lock.writeLock().unlock();//释放锁
}


读锁:

lock.readLock().lock();//加上读锁
try {
  //执行读操作
} catch (InterruptedException e) {
  e.printStackTrace();
}finally {
  lock.readLock().unlock();//释放读锁
}


悲观锁:


每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。适用于写为居多的场景下。比如行锁,表锁等,读锁,写锁,syncronized实现的锁等。sql中实现悲观锁,使用for update对数据加锁,例如:select num from goods where id = 1 for update;


乐观锁:


每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,在表中增加一个版本(version)或时间戳(timestamp)来实现。适用于读为居多的场景下。乐观锁适用于多读的应用类型,这样可以提高吞吐量。


工作流程:


获取当前数据版本

更新操作版本号+1

提交更新时,获取版本号

比较提交时的版本号与第一次获取的版本号,如果一致,那么认为资源是最新的,可以更新

否则回滚或者抛出异常

案例:


事务一开启,男柜员先执行读操作,取出金额和版本号,执行写操作,此时金额改为 120,版本号为1,事务还没有提交

事务二开启,女柜员先执行读操作,取出金额和版本号,执行写操作,此时金额改为 50,版本号变为 1,事务未提交

现在提交事务一,金额改为 120,版本变为1,提交事务。理想情况下应该变为 金额 = 50,版本号 = 2,但是实际上事务二 的更新是建立在金额为 100 和 版本号为 0 的基础上的,所以事务二不会提交成功,应该重新读取金额和版本号,再次进行写操作。这样,就避免了女柜员 用基于 version=0 的旧数据修改的结果覆盖男操作员操作结果的可能。


乐观锁和悲观锁的使用:


(高性能、高可用、高并发)来说,悲观锁的实现用的越来越少了,但是一般多读的情况下还是需要使用悲观锁的,因为虽然加锁的性能比较低,但是也阻止了像乐观锁一样,遇到写不一致的情况下一直重试的时间。使用悲观锁锁住某个数据后,再遇到其他需要修改数据的操作,那么此操作就无法完成金额的修改,对产品来说是灾难性的一刻,使用乐观锁的版本号机制能够解决这个问题。


行锁和表锁:


MySQL常用引擎有MyISAM和InnoDB,而InnoDB是mysql默认的引擎。MyISAM不支持行锁,而InnoDB支持行锁和表锁。


如何加锁?


MyISAM在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预,因此用户一般不需要直接用LOCK TABLE命令给MyISAM表显式加锁。


显式加锁:


上共享锁(读锁)的写法:lock in share mode,例如:select  math from zje where math>60 lock in share mode;

上排它锁(写锁)的写法:for update,例如:select math from zje where math >60 for update;

表锁:不会出现死锁,发生锁冲突几率高,并发低。

MySQL的表级锁有两种模式:


   表共享读锁

   表独占写锁

读锁会阻塞写,写锁会阻塞读和写


   对MyISAM表的读操作,不会阻塞其它进程对同一表的读请求,但会阻塞对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作。

   对MyISAM表的写操作,会阻塞其它进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作。

MyISAM不适合做写为主表的引擎,因为写锁后,其它线程不能做任何操作,大量的更新会使查询很难得到锁,从而造成永远阻塞

行锁:会出现死锁,发生锁冲突几率低,并发高。


在MySQL的InnoDB引擎支持行锁,与Oracle不同,MySQL的行锁是通过索引加载的,也就是说,行锁是加在索引响应的行上的,要是对应的SQL语句没有走索引,则会全表扫描,行锁则无法实现,取而代之的是表锁,此时其它事务无法对当前表进行更新或插入操作。


for update:


如果在一条select语句后加上for update,则查询到的数据会被加上一条排它锁,其它事务可以读取,但不能进行更新和插入操作。


行锁的实现需要注意:


   行锁必须有索引才能实现,否则会自动锁全表,那么就不是行锁了。

   两个事务不能锁同一个索引。

   insert,delete,update在事务中都会自动默认加上排它锁。

行锁场景:

A用户消费,service层先查询该用户的账户余额,若余额足够,则进行后续的扣款操作;这种情况查询的时候应该对该记录进行加锁。否则,B用户在A用户查询后消费前先一步将A用户账号上的钱转走,而此时A用户已经进行了用户余额是否足够的判断,则可能会出现余额已经不足但却扣款成功的情况。为了避免此情况,需要在A用户操作该记录的时候进行for update加锁。


产生死锁的四个必要条件


互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。

不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。

请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有

处理死锁的方法


   预防死锁:通过设置某些限制条件,去破坏产生死锁的四个必要条件中的一个或几个条件,来防止死锁的发生。

   避免死锁:在资源的动态分配过程中,用某种方法去防止系统进入不安全状态,从而避免死锁的发生。

   检测死锁:允许系统在运行过程中发生死锁,但可设置检测机构及时检测死锁的发生,并采取适当措施加以清除。

   解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来。

避免死锁的技术


   加锁顺序(线程按照一定的顺序加锁)

   加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)

   死锁检测 (首先为每一个进程和每一个资源指定一个唯一的号码;而后创建资源分配表和进程等待表)

死锁检测工具:


Jstack命令:用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息,生成java虚拟机当前时刻的线程快照。生成线程快照的主要目的是定位线程出现长时间停顿的缘由,如线程间死锁、死循环、请求外部资源致使的长时间等待等。 线程出现停顿的时候经过jstack来查看各个线程的调用堆栈,就能够知道没有响应的线程到底在后台作什么事情,或者等待什么资源。

JConsole工具:用于链接正在运行的本地或者远程的JVM,对运行在Java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。

什么是死锁?锁等待?如何优化这类问题?通过数据库哪些表可以监控?


死锁是指两个或多个事务在同一资源上互相占用,并请求加锁时,而导致的恶性循环现象。当多个事务以不同顺序试图加锁同一资源时,就会产生死锁。


锁等待:mysql数据库中,不同session在更新同行数据中,会出现锁等待


重要的三张锁的监控表innodb_trx,innodb_locks,innodb_lock_waits


如何优化锁:


1、尽可能让所有的数据检索都通过索引来完成,从而避免Innodb因为无法通过索引键加锁而升级为表级锁定


2、合理设计索引。不经常使用的列最好不加锁


3、尽可能减少基于范围的数据检索过滤条件


分布式锁:


基于数据库实现分布式锁;


对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功。


获取锁:INSERT INTO method_lock (method_name, desc) VALUES ('methodName', 'methodName');


先获取锁的信息: select id, method_name, state,version from method_lock where state=1 and method_name='methodName';


占有锁:update t_resoure set state=2, version=2, update_time=now() where method_name='methodName' and state=1 and version=2;


如果没有更新影响到一行数据,则说明这个资源已经被别人占位了。


缺点:

1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。


解决方案:


1、数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。


2、没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。


3、非阻塞的?搞一个while循环,直到insert成功再返回成功。


4、非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。


基于缓存(Redis等)实现分布式锁;


redis使用setnx作为分布式锁,多个线程setnx调用时,有且仅有一个线程会拿到这把锁,所以拿到锁的执行业务代码,最后释放掉锁。加大了调用次数,执行业务代码需要一点时间,这段时间拒绝了很多等待获取锁的请求。假如redis服务挂掉了,抛出异常了,这时锁不会被释放掉,出现死锁问题,可以添加try finally处理,Redis服务挂掉导致死锁的问题解决了,但是,如果服务器果宕机了,又会导致锁不能被释放的现象,所以可以设置超时时间为10s。如果有一个线程执行需要15s,当执行到10s时第二个线程进来拿到这把锁,会出现多个线程拿到同一把锁执行,在第一个线程执行完时会释放掉第二个线程的锁,以此类推…就会导致锁的永久失效。所以,只能自己释放自己的锁,可以给当前线程取一个名字,永久失效的问题解决了,但是,如果第一个线程执行15s,还是会存在多个线程拥有同一把锁的现象。所以,需要续期超时时间,当一个线程执行5s后对超时时间进行续期都10s,就可以解决了,续期设置可以借助redission工具,加锁成功,后台新开一个线程,每隔10秒检查是否还持有锁,如果持有则延长锁的时间,如果加锁失败一直循环(自旋)加锁。


基于Zookeeper实现分布式锁;


20210326140046498.png


客户端A要获取分布式锁的时候首先到locker下创建一个临时顺序节点(node_n),然后立即获取locker下的所有(一级)子节点。此时因为会有多个客户端同一时间争取锁,因此locker下的子节点数量就会大于1。对于顺序节点,特点是节点名称后面自动有一个数字编号,先创建的节点数字编号小于后创建的,因此可以将子节点按照节点名称后缀的数字顺序从小到大排序,这样排在第一位的就是最先创建的顺序节点,此时它就代表了最先争取到锁的客户端!此时判断最小的这个节点是否为客户端A之前创建出来的node_n,如果是则表示客户端A获取到了锁,如果不是则表示锁已经被其它客户端获取,因此客户端A要等待它释放锁,也就是等待获取到锁的那个客户端B把自己创建的那个节点删除。此时就通过监听比node_n次小的那个顺序节点的删除事件来知道客户端B是否已经释放了锁,如果是,此时客户端A再次获取locker下的所有子节点,再次与自己创建的node_n节点对比,直到自己创建的node_n是locker的所有子节点中顺序号最小的,此时表示客户端A获取到了锁!


AQS


AQS即 队列同步器,是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。AQS是将每一条请求共享资源的线程封装成一个FIFO锁队列的一个结点(Node),来实现锁的分配。它是基于FIFO队列,使用模板方法模式,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物。


ReentrantLock为例,(可重入独占式锁):state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1.之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁。A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。

注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。


以CountDownLatch为例,任务分N个子线程去执行,state就初始化 为N,N个线程并行执行,每个线程执行完之后countDown()一次,state就会CAS减一。当N子线程全部执行完毕,state=0,会unpark()主调用线程,主调用线程就会从await()函数返回,继续之后的动作。


class Mutex implements Lock, java.io.Serializable {
    // 自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 判断是否锁定状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
        // 尝试获取资源,立即返回。成功则返回true,否则false。
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // 这里限定只能为1个量
            if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入!
                setExclusiveOwnerThread(Thread.currentThread());//设置为当前线程独占资源
                return true;
            }
            return false;
        }
        // 尝试释放资源,立即返回。成功则为true,否则false。
        protected boolean tryRelease(int releases) {
            assert releases == 1; // 限定为1个量
            if (getState() == 0)//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断!
                throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);//释放资源,放弃占有状态
            return true;
        }
    }
    // 真正同步类的实现都依赖继承于AQS的自定义同步器!
    private final Sync sync = new Sync();
    //lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。
    public void lock() {
        sync.acquire(1);
    }
    //tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }
    //unlock<-->release。两者语文一样:释放资源。
    public void unlock() {
        sync.release(1);
    }
    //锁是否占有状态
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}


Synchronized


Synchronized如何从轻量级到重量级?Synchronized实现原理?


一个对象在内存中分为对象头(MarkWord(hashCode,GC分代年龄,锁信息),指向类的指针,数组长度(只有数组对象才有)),实例变量(存本对象的属性信息),和对其填充(使该对象保持占用8字节的整数倍)。


锁是升级的过程是怎样的?MarkWord是怎么变化的?


1.无锁状态:首先,当对象没有被当初一个锁时,此时MarkWord记录着对象的hashcode,锁标志为为01,是否偏向为0。


2.偏向锁状态:当线程A使用该对象加锁时,MarkWord中将记录该线程的ID,并将是否偏向改为1。即此时使用偏向锁。当某线程再次获取该锁时,会比较线程ID和MarkWord中的线程ID是否一致,如一致则再次获取锁,若不一致说明有竞争,不过由于偏向锁并不会主动释放锁,所以会使用CAS获取锁,获取成功则该线程获取到锁,将MarkWord中的线程ID改为自己ID。锁CAS获取锁失败,进入步骤3.


3.轻量级锁状态:进入此步骤说明,有多个线程竞争同一个锁,那么将在锁升级为轻量级锁。升级过程是,在线程中开辟锁记录空间(Lock Record),用户储存MarkWord的拷贝,并将MarkWord和Lock Record相互指向,此操作为CAS操作。将MarkWord锁标志置为00。如果CAS成功,说明此线程获取到轻量级锁。若失败,进入步骤4.


4.自旋锁:自旋锁不是一种锁状态,而是一种策略。由于此时需要阻塞没有拿到锁的线程。不过阻塞线程需要CPU从用户态转为内核态,开销较大。如果下一刻就可以拿到锁了,一没拿到锁就进入内核态很显然是不合适的。所以,此时使用自旋来减少开销。自旋一段时间成功获得锁,则仍在轻量级锁状态。否则轻量级锁膨胀为重量级锁。即步骤5.


5.重量级锁状态:将锁标志为置为10,将MarkWord中指针指向重量级所monitor。并阻塞所有没有获取到锁的线程。


幂等性实现,单点登录,金额篡改问题,秒杀场景设计,库存超卖问题


幂等性实现


乐观锁:数据库:通过version或者时间戳防止其他操作并发更新,更新失败要有一定的重试机制。CAS比较与交换也是乐观锁。

去重表:在插入数据的时候,插入去重表,利用数据库的唯一索引特性,保证唯一的逻辑。这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚,不做任何操作,实现了幂等。

悲观锁:select for update,当线程A执行for update,数据会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程A释放锁之后,才可以获取锁,继续后续操作。事物提交时,for update获取的锁会自动释放。如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,我们的web服务中的线程数量一般都是有限的,如果大量线程由于获取for update锁处于等待状态,不利于系统并发操作。

状态机:在消费者业务表中存在状态字段,并在消费消息后是变更状态,状态流转是单向不可逆的。例如:status :[1  → 2  →  3  →  4...] 基于数据库乐观锁CAS方式。例如: `update table set status = 2...  where status = 1`

数据库唯一约束:消息消费者在业务表中需要存储上游业务唯一id,在消息这业务表中加入上游业务唯一id并设置为唯一约束。


单点登录


20210326145148466.png


用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数

sso认证中心发现用户未登录,将用户引导至登录页面

用户输入用户名密码提交登录申请

sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌

sso认证中心带着令牌跳转会最初的请求地址(系统1)

系统1拿到令牌,去sso认证中心校验令牌是否有效

sso认证中心校验令牌,返回有效,注册系统1

系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源

用户访问系统2的受保护资源

系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数

sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌

系统2拿到令牌,去sso认证中心校验令牌是否有效

sso认证中心校验令牌,返回有效,注册系统2

系统2使用该令牌创建与用户的局部会话,返回受保护资源

单点登录过程实质是sso客户端与服务端通信的过程


sso-client


拦截子系统未登录用户请求,跳转至sso认证中心

接收并存储sso认证中心发送的令牌

与sso-server通信,校验令牌的有效性

建立局部会话

拦截用户注销请求,向sso认证中心发送注销请求

接收sso认证中心发出的注销请求,销毁局部会话

sso-server


验证用户的登录信息

创建全局会话

创建授权令牌

与sso-client通信发送令牌

校验sso-client令牌有效性

系统注册

接收sso-client注销请求,注销所有会话


金额篡改问题


案例:拿到别人的URL,篡改数据(金额)发送给系统


方法一:对插入的操作进行校验:一个请求的URL传入进来,根据参数找到对应的用户关联表,查询到用户的userid和用户登录后保存到redis中的userid进行对比。例如:传入参数为(订单id)和(优惠券id),拿(订单id)查询该订单的用户id,拿来和登录的用户id进行对比,判断是否为本人操作。拿(优惠券id)查询用户表是否领取了该优惠券,该优惠券是否可用。

方法二:前端传入一个加密的信息数据,后端给这个给这个数据解密,判断是否为同一用户。例如:将用户id+项目id+密钥生成一个token,传入后端解密,拿到用户id,项目id,密钥对比是否一致

方法三:权限框架:可以指定某些角色,用户的登录名称密码正确才可以访问,修改。例如:1.Spring Security  2.apache shiro

 

秒杀场景设计


流量过滤

20210326150102564.png

本质上,参与秒杀的用户很多,但是商品的数量是有限的,真正能抢到的用户并不多,那么第一步就是要过滤掉大部分无效的流量。


活动开始前前端页面的Button置灰,防止活动未开始无效的点击产生流量。

前端添加验证码或者答题,防止瞬间产生超高的流量,可以很好的起到错峰的效果,现在的验证码花样繁多,题库有的还要做个小学题,而且题库更新频繁,想暴力破解怕是很难。当然我知道的还有一种人工打码的方式,不过这个也是需要时间的,不像机器无限刷你的接口。

活动校验,既然是活动,那么活动的参与用户,参加条件,用户白名单之类的要首先做一层校验拦截,还有其他的比如用户终端、IP地址、参与活动次数、黑名单用户的校验。比如活动主要针对APP端的用户校验,那么根据参数其他端的用户将被拦截,针对IP、mac地址、设备ID和用户ID可以对用户参与活动的次数做校验,黑名单根据平时的活动经验拦截掉一部分羊毛党等异常用户。

非法请求拦截,做了以上拦截如果还有用户能绕过限制,那不得不说太牛X了。比如双11零点开始还做了答题限制,那么正常人怎么也需要1秒的时间来答题吧,就算单身30年手速我想也不能超过0.5秒了,那么针对刚好0点或者在0.5秒以内的请求就可以完全拦截掉。

限流,使用不同类型的限流器,

请求限流器:该限流器限制每个用户每秒可发送 N 个请求。

并发请求限流器:限制每秒最高请求数,这种限流器则是限制最高并发请求数。

基于使用量的负载降级

基于 Worker 利用率的负载降级:worker 太忙,无法处理分配给它的请求,它会缓慢降级非关键请求

有什么限流算法?以及如何实现?


令牌桶算法 来进行流量限制。该算法有一个集中的桶,为每一个请求分配一个令牌,并不断地缓慢地在桶中放入令牌。 如果桶为空,则拒绝该请求。在我们的例子中,每个用户都被分配一个桶,每当他们产生一个请求时,我们从这个桶中移除一个令牌。


性能优化:


页面静态化,参与秒杀活动的商品一般都是已知的,可以针对活动页面做静态化处理,缓存到CDN。假设我们一个页面300K大小,1千万用户的流量是多少?这些请求要请求后端服务器、数据库,压力可想而知,缓存到CDN用户请求不经过服务器,大大减小了服务器的压力。

活动预热,针对活动的活动库存可以独立出来,不和普通的商品库存共享服务,活动库存活动开始前提前加载到redis,查询全部走缓存,最后扣减库存再视情况而定。

独立部署,资源充足的情况下可以考虑针对秒杀活动单独部署一套环境,这套环境中可以剥离一些可能无用的逻辑,比如不用考虑使用优惠券、红包、下单后赠送积分的一些场景,或者这些场景可以活动结束后异步的统一发放。这只是一个举例,实际上单独针对秒杀活动的话你肯定有很多无用的业务代码是可以剥离的,这样可以提高不少性能。

超卖:


针对秒杀建议选择下单扣库存的方式


首先查询redis缓存库存是否充足先扣库存再落订单数据,可以防止订单生成了没有库存的超卖问题扣库存的时候先扣数据库库存,再扣减redis库存,保证在同一个事务里,无论两者哪一个发生了异常都会回滚。有一个问题是可能redis扣成功了由于网络问题返回失败,事务回滚,导致数据库和缓存不一致,这样实际少卖了,可以放到下轮秒杀去。


质量保障


为了保证系统的稳定性,防止你的系统被秒杀,一些质量监控就不得不做。


熔断限流降级,老生常谈,根据压测情况进行限流,可以使用sentinel或者hystrix。另外前端后端都该有降级开关。监控,该上的都上,QPS监控、容器监控、CPU、缓存、IO监控等等。演练,大型秒杀事前演练少不了,不能冒冒失失的就上了吧。核对、预案,事后库存订单 金额、数量核对,是否发生超卖了?金额是否正常?都是必须的。预案可以在紧急情况下进行降级。


数据统计


前端埋点

数据大盘,通过后台服务的打点配合监控系统可以通过大盘直观的看到一些活动的监控和数据

离线数据分析,事后活动的数据可以同步到离线数仓做进一步的分析统计


库存超卖


库存超卖问题是有很多种技术解决方案的,比如悲观锁,分布式锁,乐观锁


悲观锁


  • 采用排他锁(悲观锁)
  • 当用户同时到达更新操作,同时到达的用户一个个执行
  • 在当前这个update语句commit之前,其他用户等待执行


分布式锁


采用Redis的队列实现,用于抢购

先从MySQL读取库存数,放到Redis的队列中

用户直接操作队列,当队列为空时提醒售空

当抢购结束后可执行更新库存表操作

redis分布式锁还是zookeeper分布式锁?


redis分布式锁类似于自旋锁的方式需要自己不断尝试去获取锁,这个是比较耗性能的。zk获取不到锁的话则可以注册监听器,不需要不断尝试,这样的活性能开销较小;其次,redis锁有一个问题就是,如果获取到锁的客户端崩溃了或者没有正常释放锁则会导致只能等到过期时间完了才能获取到锁,而zk建立的由于是临时节点,客户端崩溃了或者挂了,临时节点会自动删除,此时会自动释放锁;最后,这个redis的实现方式如果采用RedLock算法的话较为复杂并且还存在争议,普通的算法存在单点故障和主从同步的问题,所以一般来说,个人认为zk分布式锁要比redis分布式锁更可靠并且易用。


乐观锁


采用乐观锁原理

在数据表中加入版本号字段

当读取数据时,将version字段的值一同读出,数据每更新一次,对当前version值加一,当并发数据进行出库操作时新version版本号不同而停止

这样虽然不能避免脏读,但是能避免脏读后对数据产生的影响,对比悲观锁需要一直锁数据来说性能提升很大。


Linux常用命令,生产环境服务器变慢诊断,线上排查,性能评估


  • 查看整机:top
  • 查看CPU:vmstat
  • 查看内存:free
  • 查看硬盘:df
  • 查看磁盘IO:lostat -xdk 间隔秒数 次数
  • 查看网络IO:ifstat



CPU占用过高定位分析思路

使用top命令找出cpu占比最高的

20210326160027533.png


使用ps -ef或者jps进一步定位,得知是怎样的一个后台程序出问题了

使用ps -mp pid(线程id) -o THREAD,tid,time 定位到具体的线程或者代码


20210326160203440.png


使用printf “%x\n” tid 把线程ID转换为16进制格式


2021032616023173.png


使用jstack pid |grep tid -A 30打印线程的堆栈信息,定位具体的行数


20210326160238615.png


看了这么多,在给你面试一下,看看你学的了多少


面试演练


一、开场白


简单的介绍一下自己的工作经历与职责,在校或者工作中主要的工作内容,主要负责的内容;(你的信息一清二白的写在简历上,这个主要为了缓解面试者的压力)

介绍下自己最满意的,有技术亮点的项目或平台,重点介绍下自己负责那部分的技术细节;(主要考察应聘者对自己做过的事情是否有清晰的描述,判断做的事情的复杂度)


二、Java多线程


线程池的原理,为什么要创建线程池?创建线程池的方式;

线程的生命周期,什么时候会出现僵死进程;

说说线程安全问题,什么实现线程安全,如何实现线程安全;

创建线程池有哪几个核心参数? 如何合理配置线程池的大小?

volatile、ThreadLocal的使用场景和原理;

ThreadLocal什么时候会出现OOM的情况?为什么?

synchronized、volatile区别、synchronized锁粒度、模拟死锁场景、原子性与可见性;


三、JVM相关


JVM内存模型,GC机制和原理;

GC分哪两种,Minor GC 和Full GC有什么区别?什么时候会触发Full GC?分别采用什么算法?

JVM里的有几种classloader,为什么会有多种?

什么是双亲委派机制?介绍一些运作过程,双亲委派模型的好处;

什么情况下我们需要破坏双亲委派模型;

常见的JVM调优方法有哪些?可以具体到调整哪个参数,调成什么值?

JVM虚拟机内存划分、类加载器、垃圾收集算法、垃圾收集器、class文件结构是如何解析的;


四、Java扩展篇


红黑树的实现原理和应用场景;

NIO是什么?适用于何种场景?

Java9比Java8改进了什么;

HashMap内部的数据结构是什么?底层是怎么实现的?(还可能会延伸考察

ConcurrentHashMap与HashMap、HashTable等,考察对技术细节的深入了解程度);

说说反射的用途及实现,反射是不是很慢,我们在项目中是否要避免使用反射;

说说自定义注解的场景及实现;

List 和 Map 区别,Arraylist 与 LinkedList 区别,ArrayList 与 Vector 区别;


五、Spring相关


Spring AOP的实现原理和场景?

Spring bean的作用域和生命周期;

Spring Boot比Spring做了哪些改进? Spring 5比Spring4做了哪些改进;

如何自定义一个Spring Boot Starter?

Spring IOC是什么?优点是什么?

SpringMVC、动态代理、反射、AOP原理、事务隔离级别;


六、中间件篇


Dubbo完整的一次调用链路介绍;

Dubbo支持几种负载均衡策略?

Dubbo Provider服务提供者要控制执行并发请求上限,具体怎么做?

Dubbo启动的时候支持几种配置方式?

了解几种消息中间件产品?各产品的优缺点介绍;

消息中间件如何保证消息的一致性和如何进行消息的重试机制?

Spring Cloud熔断机制介绍;Spring Cloud对比下Dubbo,什么场景下该使用Spring Cloud?


七、数据库篇


锁机制介绍:行锁、表锁、排他锁、共享锁;

乐观锁的业务场景及实现方式;

事务介绍,分布式事物的理解,常见的解决方案有哪些,什么事两阶段提交、三阶段提交;

MySQL记录binlog的方式主要包括三种模式?每种模式的优缺点是什么?

MySQL锁,悲观锁、乐观锁、排它锁、共享锁、表级锁、行级锁;

分布式事务的原理2阶段提交,同步异步阻塞非阻塞;

数据库事务隔离级别,MySQL默认的隔离级别、Spring如何实现事务、JDBC如何实现事务、嵌

套事务实现、分布式事务实现;

SQL的整个解析、执行过程原理、SQL行转列;


八、Redis


  1. Redis为什么这么快?redis采用多线程会有哪些问题?
  2. Redis支持哪几种数据结构;
  3. Redis跳跃表的问题;
  4. Redis单进程单线程的Redis如何能够高并发?
  5. Redis如何使用Redis实现分布式锁?
  6. Redis分布式锁操作的原子性,Redis内部是如何实现的?
相关文章
|
19天前
|
Java 程序员
java线程池讲解面试
java线程池讲解面试
37 1
|
9天前
|
Java 关系型数据库 MySQL
大厂面试题详解:Java抽象类与接口的概念及区别
字节跳动大厂面试题详解:Java抽象类与接口的概念及区别
33 0
|
18天前
|
存储 缓存 算法
Java入门高频考查基础知识4(字节跳动面试题18题2.5万字参考答案)
最重要的是保持自信和冷静。提前准备,并对自己的知识和经验有自信,这样您就能在面试中展现出最佳的表现。祝您面试顺利!Java 是一种广泛使用的面向对象编程语言,在软件开发领域有着重要的地位。Java 提供了丰富的库和强大的特性,适用于多种应用场景,包括企业应用、移动应用、嵌入式系统等。下是几个面试技巧:复习核心概念、熟悉常见问题、编码实践、项目经验准备、注意优缺点、积极参与互动、准备好问题问对方和知其所以然等,多准备最好轻松能举一反三。
46 0
Java入门高频考查基础知识4(字节跳动面试题18题2.5万字参考答案)
|
22天前
|
Java 程序员 API
java1.8常考面试题
在Java 1.8版本中,引入了很多重要的新特性,这些特性常常成为面试的焦点
42 8
|
27天前
|
NoSQL Java 关系型数据库
整理Java面试题
整理Java面试题
|
28天前
|
安全 算法 Java
Java 并发编程 面试题及答案整理,最新面试题
Java 并发编程 面试题及答案整理,最新面试题
88 0
|
28天前
|
存储 算法 安全
Java 面试题及答案整理,最新面试题
Java 面试题及答案整理,最新面试题
80 1
|
28天前
|
消息中间件 Dubbo Java
互联网 Java 工程师1000道面试题(485页)
互联网 Java 工程师1000道面试题(485页)
27 0
|
1月前
|
缓存 Java 关系型数据库
Java开发面试题 | 2023
Java开发面试题 | 2023
|
SQL 缓存 安全
Java高频面试题目
面试时面试官最常问的问题总结归纳!
100 0