根据简历指定内容,模拟面试题

本博客用于面试前的突击,文章很多内容都来自网上搜索的资料,这里尤为推荐对线面试官系列,大白话的系列,将知识点能够串起来,不过看这个系列之前,建议把八股文都看一遍,因为毕竟是面试系列,很多知识点讲解并不是那么深。

简历的技能,描述有9个:

  1. 熟练掌握JAVA常用的开源框架 : Mybatis\Spring\SpringMVC\Spring Boot,熟练使用JAVA的微服务框架Spring Cloud(Hystrix、Eureka、Ribbon);
  2. 熟练使用常用的关系型数据库,如MySQL, 具备编写复杂SQL语句的能力,拥有一定的SQL优化的技能,并且理解MySQL索引原理;
  3. 熟练使用非关系型数据库,如Redis, 掌握Redis核心数据结构及应用场景,熟悉分布式锁,分布式事务等常见的分布式技术;熟悉多级缓存架构、缓存穿透、缓存击穿、缓存预热等;
  4. 熟悉Linux操作系统,掌握基础的命令、常用的软件安装;
  5. 熟悉Docker容器部署,并掌握Docker相关操作命令;
  6. 熟悉常见的消息中间件,如RocketMQ、Kafka,熟悉常见的消息丢失、重复消费等问题;
  7. 了解JVM原理,并可以对JVM进行调优;
  8. 有良好的 Java 编程基础,熟悉面向对象的设计原则;
  9. 有微信生态(小程序、公众号、企业微信)开发的实战经验;

针对指定的技能模拟问题

【技能1】java相关框架

问1:mybatis # $ 区别?

${}是 Properties 文件中的变量占位符,属于静态文本替换;
动态拼接SQL语句,存在sql注入漏洞。但是如果产品需要,自定义排序字段的时候,只能这样写,不带有""。
#{}是 sql 的参数占位符,MyBatis 会将 sql 中的#{}替换为? 号,
在 sql 执行前会使用 PreparedStatement 的参数设置方法,按序给 sql 的? 号占位符设置参数值

问2:mybatis中定义的mapper接口如何生成实现类?

mybatis通过JDK的动态代理方式,在启动加载配置文件时,解析mapper xml生成

  1. Mapper 接口在初始SqlSessionFactory 注册的。
  2. Mapper 接口注册在了名为 MapperRegistry 类的 HashMap中, key = Mapper class value = 创建当前Mapper的工厂。
  3. Mapper 注册之后,可以从SqlSession中get
  4. SqlSession.getMapper 运用了 JDK动态代理,产生了目标Mapper接口的代理对象。
  5. 动态代理的 代理类是 MapperProxy ,这里边最终完成了增删改查方法的调用。
问3:springboot ioc

将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注入。
这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。
IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好注解即可,@Autowired @Resource @Qualifier
完全不用考虑对象是如何被创建出来的,Spring会在项目启动时,通过调用reFresh(),进行一系列的处理。
里面有一个核心方法obtainFreshBeanFactory(),会初始化 BeanFactory(这里其实是DefaultListableBeanFactory 实例)、
加载 Bean、注册 Bean 等等。当然,这步结束后,Bean 并没有完成初始化。这里指的是 Bean 实例并未在这一步生成。
这里做的事情只是将bean配置相应的转换为了一个个 BeanDefinition,并存储在key为beanName,value为BeanDefinition的beanDefinitionMap中。
真正的实例化操作在 finishBeanFactoryInitialization(beanFactory); 这里会负责初始化所有的 singleton beans。
而且Spring将bean的初始化的动作包装在 beanFactory.getBean(…) ,使代码看起来更加优雅。

问4:spring aop原理

能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,
便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。
Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,
去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理。
因此在使用spring注解时,需要注意如果aop动态代理的实现是通过cglib的方式实现时,如@Async和@Transactional。
在同一个类里面互相调用事务方法,切面切的是发起事务的方法,而内部不管调用的什么事务方法都会默认为this当前对象去调动普通方法,这些事务注解说白了就是不管用。
事务是根据动态代理生成的动态对象去执行事务的控制,所以在同类方法内部调用其他事务方法必须要获取其对应的代理对象去调用才生效,否则必须放在不同的类里面。
spring动态代理 jdk与cglib的区别:
java动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用具体方法前调用InvokeHandler的invoke()来处理。
而cglib动态代理是利用asm开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理,也就是实现MethodInterceptor接口并重写intercept方法。
1、如果目标对象实现了接口,默认情况下会采用JDK的动态代理实现AOP;
2、如果目标对象实现了接口,可以强制使用CGLIB实现AOP;
3、如果目标对象没有实现了接口,必须采用CGLIB库,spring会自动在JDK动态代理和CGLIB之间转换;

问5:spring bean 生命周期 && java 对象的生命周期 对比

spring bean的生命周期如下:
Bean 容器找到配置文件中 Spring Bean 的定义。
Bean 容器利用 Java 反射 API 创建一个 Bean 的实例。
设置对象属性。
检查Aware相关接口如BeanNameAware、BeanFactoryAware、BeanClassLoaderAware,并设置相关依赖。
BeanPostProcessor前置处理。如果我们想在Spring容器中完成bean实例化、配置以及其他初始化方法前后要添加一些自己逻辑处理,就会用到BeanPostProcessor。
检查是否实现InitializingBean接口,如果实现了InitializingBean接口,执行afterPropertiesSet()方法。
(InitializingBean接口为bean提供了初始化方法的方式,凡是继承该接口的类,在初始化bean的时候都会执行该方法)
如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。
如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法
当要销毁 Bean 的时候,如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。
当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的方法。
java对象的生命周期如下:
java对象的生命周期包括 创建、使用、回收 三个过程,首先是创建的过程:
java代码中,遇到
->1.在遇到 new、putstatic、getstatic、invokestatic 字节码指令时,如果类尚未初始化,则需要先触发初始化。
2.对类进行反射调用时,如果类还没有初始化,则需要先触发初始化。
3.初始化一个类时,如果其父类还没有初始化,则需要先初始化父类。
4.虚拟机启动时,用于需要指定一个包含 main() 方法的主类,虚拟机会先初始化这个主类。
->类加载的过程:
【 加载(获取类的二进制字节流)->
验证(确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全,也就是必须符合java规范及JVM规范)->
准备(为类变量(静态static修饰成员变量)分配内存并设置初始值的阶段。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。)->
解析(虚拟机将常量池内的符号引用替换为直接引用。会把该类所引用的其他类全部加载进来( 引用方式:继承、实现接口、域变量、方法定义、方法中定义的本地变量))->
初始化(类加载过程的最后一步,是执行类构造器 () 方法的过程。也可以总结为,为类的静态变量赋予正确的初始值)

->分配内存(指针碰撞/空闲列表):
指针碰撞:是指将内存划分为空闲的和用过的两块,这两块之间有一个指针作为分界点,当分配内存时将指针往空闲内存块移动。
空闲列表:是由虚拟机维护一个列表,记录哪块内存时有用的,当分配时从该列表中找足够大的空间划分给对象。
具体使用哪种内存分配方式,由垃圾回收器决定。一般带压缩整理能力的垃圾回收器使用指针碰撞,不带整理能力的使用空闲列表。
所以Serial、ParNew等使用指针碰撞,CMS使用空闲列表。
->将分配的内存空间初始化为零值:
这一操作保证了对象的实例非static修饰的字段可以不赋值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
->虚拟机对对象头做必要设置:
这些必要设置包括设置该对象是哪个类的实例、类的元数据信息、对象的哈希码、对象GC分代年龄等信息
->执行init:
Java在编译之后会在字节码文件中生成方法,称之为实例构造器,该实例构造器会将语句块,变量初始化,调用父类的构造器等操作放入到中去执行。
执行方法是把对象按照程序员的意愿进行初始化。
->对象创建成功
其次是使用:
java栈上的reference只存储了对象的引用,我们常用的Sun HotSpot虚拟机则是使用直接指针方式进行对象访问的。
java堆中存储着对象类型数据的指针引用,通过指针,可以去方法区中,定位到具体的对象类型数据。
最后是对象的回收,也就是JVM相关的知识点,GC回收机制,详见下面JVM相关技能。

问6:spring 如何解决 循环依赖 问题,这问题将会把spring的ioc和 对象的生命周期的问题串起来

普通的java对象和spring所管理的bean实例化的过程是有区别的,
在java环境下创建对象简要的步骤可以分为:
java源码被编译为.class文件 -> 等到类需要初始化时,如反射、new等 -> .class文件被JVM通过类加载器加载到JVM -> 初始化对象供我们使用
简单来说,可以理解为他是Class对象作为 模板 进而创建出具体的实例,
而spring所管理的这些bean不同的是,除了Class对象外,还会用到BeanDefinition的实例来面熟对象的信息,
比如说,我们可以在Spring所管理的bean有一系列描述,如@Scope、@Lazy、@DependsOn等,
可以理解为,Class只描述了类的信息,而BeanDefinition描述了对象的信息。
spring在启动的时候需要扫描注解/config中需要被spring管理的bean信息,
随后,会将这些信息封装为BeanDefinition,最后把这些信息放到一个beanDefinitionMap中,
map的key为beanName,value就是BeanDefinition实例对象。
但是到这里其实就是把定义的元数据加载进来,目前真实的对象还没有实例化。
接着会遍历这个beanDefinitionMap,执行BeanFactoryPostProcessor这个工厂后置处理器的逻辑,
比如说,我们平时定义的占位符信息如@Value或者@Configuration,
就是通过BeanFactoryPostProcessor的子类PropertyPlaceholderConfigure进行注入进去。
BeanFactoryPostProcessor后置处理器执行完之后,就到了实例化对象,
在spring里面通过反射来实现,并将实例化的操作封装在了beanFactory.getBean(…) 中,
但是这里的对象实例化,只是把对象创建出来,对象的具体属性还没有注入进去,
比如我我们的业务类为mailService,而mailService对象依赖着userService,这个时候,userService还是null的,
所以下一步,就是把对象的相关属性给注入,实例化 -> 依赖注入
相关属性注入完之后,往下就是接着初始化的工作,
首先判断该Bean是否实现了Aware相关接口,如果存在,则填充相关资源,
比如实现ApplicationContextAware接口,来获取ApplicationContext对象,进而获取bean对象。
Aware相关接口处理完之后,就会到BeanPostProcessor后置处理器,
分别是before和after,这个BeanPostProcessor后置处理器是AOP实现的关键(AnnotationAwareAspectJAutoProxyCreator)
所以执行完Aware相关接口,就会执行BeanPostProcessor的before(),
执行完before,则会执行init相关的方法,比如说@PostConstruct、实现了InitializingBean接口、定义的init-method方法,
这些都是spring给我们提供的扩展,就比如@PostConstruct,对象实例化后,我们做一些初始化的工作,或者启动线程拉数据,
比如我在项目中的应用是,在私域星球的好评返现功能中,添加了一系列的拦截器,实现责任链模式和策略模式,校验一些业务规则。
等init执行完之后,就会执行BeanPostProcessor的after方法,到这里基本的重要流程就走完了,我们就可以获取对象去使用了。
那么,到这里只是把对象实例讲了,循环依赖的问题其实就是A依赖B,而B又依赖A,
从刚刚讲的,对象属性的注入是在对象实例化之后,所以spring的是这样处理的,
首先A对象实例化,然后对属性进行注入,发现依赖B,但是这个B还没有创建出来,所以转头去实例化B,
实现B实例的之后,发现需要依赖A对象,那就把A依赖注入进去,只不过A有些属性还没有注入完而已,但是A已经实例化了,
这个时候B对象创建完成,把B对象返回到A对象的属性注入方法上,A创建完成,把自己实例化好的扔到三级缓存中。
具体的原理其实就是三级缓存,更直观的表达就是3个Map,
首先一级缓存singletonObjects(日常实际获取Bean的map,也就是正式对象),
二级缓存earlySingletoObjects(还没有进行属性注入,由三级缓存放进来,属于半成品),
三级缓存singletonFactories(对象工厂的map),
A对象实例化之后,属性注入之前,spring会吧A对象放入三级缓存中,key是beanName,value是ObjectFactory,
等待A对象属性注入时,发现依赖B,又去实例化B时,B属性注入需要获取A对象,
这里就是从三级缓存里拿出ObjectFactory,进而通过ObjectFactory得到相应的Bean也就是A对象,
再把三级缓存中的A记录清除,然后放到二级缓存的map中,
二次缓存的map中的key为beanName,value为Bean,这里的Bean还没有做完属性注入的相关工作,属于半成品,
等到完全初始化后,再把二级缓存给清除掉,并将对象设置到一级缓存中,
而我们自己去getBean时,实际上就是去一级缓存中拿到的。
至于为什么是三级缓存,是因为有可能A对象依赖的B对象,很有可能是有AOP的,也就是B对象是需要被代理的。
如果只是二级缓存的话,那就需要在放入二级缓存之前就需要把AOP代理都处理好,所以才会有三级缓存,
从三级缓存中拿到ObjectFactory,从里面拿到代理对象。
总结下来就是二级缓存考虑性能(不需要每次都从三级缓存功能里面拿,直接从二次缓存拿),三级缓存 考虑代理。

问7:SpringCloud 断路器如何使用,源码

Hystrix把执行都包装成一个HystrixCommand,并启用线程池实现多个依赖执行的隔离。
Hystrix每个command都有对应的commandKey可以认为是command的名字,默认是当前类的名字getClass().getSimpleName(),
每个command也都一个归属的分组,这两个东西主要方便Hystrix进行监控、报警等。
HystrixCommand使用的线程池也有线程池key,以及对应线程相关的配置。
熔断机制是应对雪崩效应的一种微服务链路保护机制,当链路中的某个微服务出错不可用或者响应时间太长时,
会进行服务的降级,进而熔断该节点微服务的调用,快速返回错误的响应信息。当检测到该节点微服务调用响应正常后,恢复调用链路。
当失败的调用满足特定条件,如5秒内20次请求,10次失败,就会启动熔断机制,熔断机制的注解是@HystrixCommand。

问8:Hystrix是如何通过线程池实现线程隔离的

Hystrix通过命令模式,将每个类型的业务请求封装成对应的命令请求,
比如查询订单->订单Command,查询商品->商品Command,查询用户->用户Command。
每个类型的Command对应一个线程池。创建好的线程池是被放入到ConcurrentHashMap中,比如查询订单:
当第二次查询订单请求过来的时候,则可以直接从Map中获取该线程池。
执行依赖代码的线程与请求线程(比如Tomcat线程)分离,请求线程可以自由控制离开的时间,这也是我们通常说的异步编程。
通过设置线程池大小来控制并发访问量,当线程饱和的时候可以拒绝服务,防止依赖问题扩散。
好处:
应用程序会被完全保护起来,即使依赖的一个服务的线程池满了,也不会影响到应用程序的其他部分。
在高并发情况下,防止用户一直等待,把请求线程打满。
线程池隔离:每个服务接口,都有自己独立的线程池,每个线程池互不影响。
信号量隔离:使用一个原子计数器(或信号量)来记录当前有多少个线程在运行,当请求进来时先判断计数器的数值,若超过设置的最大线程个数则拒绝该请求,
若不超过则通行,这时候计数器+1,请求返回成功后计数器-1。
涉及到的相关配置:
Hystrix还有一个queueSizeRejectionThreshold属性,这个属性是控制队列最大阈值的,而Hystrix默认只配置了5个,因此就算我们把maxQueueSize的值设置再大,也是不起作用的。
maxQueueSize和queueSizeRejectionThreshold 两个属性必须同时配置
hystrix.threadpool.default.coreSize: 100 #并发执行的最大线程数,默认10
hystrix.threadpool.default.maximumSize: 200
##默认1分钟
##hystrix.threadpool.default.keepAliveTimeMinutes: 1
hystrix.threadpool.default.maxQueueSize: 600
hystrix.threadpool.default.queueSizeRejectionThreshold: 50 #即使maxQueueSize没有达到,达到queueSizeRejectionThreshold该值后,请求也会被拒绝,默认值5
尽管线程池提供了线程隔离,我们底层代码也必须要有超时设置,不能无限制的阻塞以致线程池一直饱和。

问9:Hystrix工作流程

当调用出现错误时,开启一个时间窗(默认 10秒)
在这个时间窗内,统计调用次数是否达到最小请求数?
如果没有达到,则重置统计信息,回到第1步
如果没有达到最小请求数,即使请求全部失败,也会回到第1步
如果达到了,则统计失败的请求数占所有请求数的百分比,是否达到阈值?
如果达到,则跳闸(不再 请求对应的服务)
如果没有达到,则重置统计信息,回到第1步
如果跳闸,则会开启一个活动窗口(默认5秒),每隔5秒,Hystrix 会让一个请求通过,到达那个正在苦苦挣扎的服务,看是否调用成功
如果成功,重置断路器,回到第1步
如果失败,回到第3步
总结:
出现调用错误 -> 是否达到最小请求数 -> 错误是否达到阈值 -> 跳闸 -> 每5秒请求确认远程服务是否已经正常 -> 重置断路器,调用可以通过
详细流程补充描述:
熔断打开:
​ 请求不再进行调用当前服务,内部设置时钟一般为MTTR(平均故障处理时间),当打开时长达到所设时钟则进入熔断状态
熔断关闭:
​ 熔断关闭不会对服务进行熔断,而是暂时关闭服务,等待片刻就会进入半开状态
熔断半开:
​ 部分请求根据规则调用当前服务,如果请求成功且符合规则则认为当前服务恢复正常,关闭熔断
涉及到断路器的三个重要参数:快照时间窗、请求总数阀值、错误百分比阀值。
快照时间窗:断路器确定是否打开需要统计一些请求和错误数据,而统计的时间范围就是快照时间窗,默认为最近的10秒。
请求总数阀值:在快照时间窗内,必须满足请求总数阀值才有资格熔断。默认为20,意味着在10秒内,如
果该hystrix命令的调用次数不足20次,即使所有的请求都超时或其他原因失败,断路器都不会打开。
错误百分比阀值:当请求总数在快照时间窗内超过了阀值,比如发生了30次调用,如果在这30次调用中,有15次发生了超时异常,
也就是超过50%的错误百分比,在默认设定50%阀值情况下,这时候就会将断路器打开。
断路器开启或者关闭的条件
当满足一定阀值的时候(默认10秒内超过20个请求次数)
当失败率达到一定的时候(默认10秒内超过50%请求失败)
到达以上阀值,断路器将会开启
当开启的时候,所有请求都不会进行转发
一段时间之后(默认是5秒),这个时候断路器是半开状态,会让其中一个请求进行转发。如果成功,断路器会关闭,若失败,继续开启。
断路器打开之后,再有请求调用的时候,将不会调用主逻辑,而是直接调用降级fallback。通过断路器,实现了自动地发现错误并将降级逻辑切换为主逻辑,减少响应延迟的效果。
原来的主逻辑要如何恢复呢?
对于这一问题,hystrix也为我们实现了自动恢复功能。
当断路器打开,对主逻辑进行熔断之后,hystrix会启动一个休眠时间窗,在这个时间窗内,
降级逻辑是临时的成为主逻辑,当休眠时间窗到期,断路器将进入半开状态,释放一次请求到原来的主逻辑上,如果此次请求正常返回
那么断路器将继续闭合,主逻辑恢复,如果这次请求依然有问题,断路器继续进入打开状态,休眠时间窗重新计时。

问10:Eurka 保证 AP

Eureka Server 各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。
而 Eureka Client 在向某个 Eureka 注册时,如果发现连接失败,则会自动切换至其它节点。
只要有一台 Eureka Server 还在,就能保证注册服务可用(保证可用性),只不过查到的信息可能不是最新的(不保证强一致性)。
Eurka 工作流程:
Eureka Server 启动成功,等待服务端注册。在启动过程中如果配置了集群,集群之间定时通过 Replicate 同步注册表,每个 Eureka Server 都存在独立完整的服务注册表信息
Eureka Client 启动时根据配置的 Eureka Server 地址去注册中心注册服务
Eureka Client 会每 30s 向 Eureka Server 发送一次心跳请求,证明客户端服务正常
当 Eureka Server 90s 内没有收到 Eureka Client 的心跳,注册中心则认为该节点失效,会注销该实例
单位时间内 Eureka Server 统计到有大量的 Eureka Client 没有上送心跳,则认为可能为网络异常,进入自我保护机制,不再剔除没有上送心跳的客户端
当 Eureka Client 心跳请求恢复正常之后,Eureka Server 自动退出自我保护模式
Eureka Client 定时全量或者增量从注册中心获取服务注册表,并且将获取到的信息缓存到本地
服务调用时,Eureka Client 会先从本地缓存找寻调取的服务。如果获取不到,先从注册中心刷新注册表,再同步到本地缓存
Eureka Client 获取到目标服务器信息,发起服务调用
Eureka Client 程序关闭时向 Eureka Server 发送取消请求,Eureka Server 将实例从注册表中删除
服务续约的两个重要属性:

# 服务续约任务的调用间隔时间,默认为30秒
eureka.instance.lease-renewal-interval-in-seconds=30

# 服务失效的时间,默认为90秒。
eureka.instance.lease-expiration-duration-in-seconds=90


# 获取服务是服务消费者的基础,所以必有两个重要参数需要注意:
# 启用服务消费者从注册中心拉取服务列表的功能
eureka.client.fetch-registry=true

# 设置服务消费者从注册中心拉取服务列表的间隔
eureka.client.registry-fetch-interval-seconds=30

但是在微服务架构下服务之间通常都是跨进程调用,网络通信往往会面临着各种问题,比如微服务状态正常,网络分区故障,导致此实例被注销。
固定时间内大量实例被注销,可能会严重威胁整个微服务架构的可用性。为了解决这个问题,Eureka 开发了自我保护机制,那么什么是自我保护机制呢?
Eureka Server 在运行期间会去统计心跳失败比例在 15 分钟之内是否低于 85%,如果低于 85%,Eureka Server 即会进入自我保护机制。
Eureka Server 进入自我保护机制,会出现以下几种情况:
(1 Eureka 不再从注册列表中移除因为长时间没收到心跳而应该过期的服务
(2 Eureka 仍然能够接受新服务的注册和查询请求,但是不会被同步到其它节点上(即保证当前节点依然可用)
(3 当网络稳定时,当前实例新的注册信息会被同步到其它节点中
Eureka 自我保护机制是为了防止误杀服务而提供的一个机制。当个别客户端出现心跳失联时,则认为是客户端的问题,剔除掉客户端;
当 Eureka 捕获到大量的心跳失败时,则认为可能是网络问题,进入自我保护机制;当客户端心跳恢复时,Eureka 会自动退出自我保护机制。
所以如果在保护期内刚好这个服务提供者非正常下线了,此时服务消费者就会拿到一个无效的服务实例,即会调用失败。
对于这个问题需要服务消费者端要有一些容错机制,如重试,断路器等。
通过在 Eureka Server 配置如下参数,开启或者关闭保护机制,生产环境建议打开:

eureka.server.enable-self-preservation=true
问11:注册中心对比 zk 与eureka

Zookeeper在设计的时候遵循的是CP原则,即一致性,Zookeeper会出现这样一种情况,
当master节点因为网络故障与其他节点失去联系时剩余节点会重新进行leader选举,问题在于,选举leader的时间太长:30~120s,
且选举期间整个Zookeeper集群是不可用的,这就导致在选举期间注册服务处于瘫痪状态,在云部署的环境下,
因网络环境使Zookeeper集群失去master节点是较大概率发生的事情,虽然服务能够最终恢复,但是漫长的选举时间导致长期的服务注册不可用是不能容忍的。
Eureka在设计的时候遵循的是AP原则,即可用性。Eureka各个节点(服务)是平等的, 没有主从之分,
几个节点down掉不会影响正常工作,剩余的节点(服务) 依然可以提供注册与查询服务,而Eureka的客户端在向某个Eureka注册或发现连接失败,
则会自动切换到其他节点,也就是说,只要有一台Eureka还在,就能注册可用(保证可用性), 只不过查询到的信息不是最新的(不保证强一致),
除此之外,Eureka还有自我保护机制,如果在15分钟内超过85%节点都没有正常心跳,那么eureka就认为客户端与注册中心出现了网络故障,此时会出现一下情况:
(1:Eureka 不再从注册列表中移除因为长时间没有收到心跳而过期的服务。
(2:Eureka 仍然能够接收新服务的注册和查询请求,但是不会被同步到其它节点上(即保证当前节点可用)
(3: 当网络稳定后,当前实例新的注册信息会被同步到其它节点中

问12:ribbon负载均衡

简单轮询负载均衡(RoundRobin)
以轮询的方式依次将请求调度不同的服务器,即每次调度执行i = (i + 1) mod n,并选出第i台服务器。
随机负载均衡 (Random)
随机选择状态为UP的Server
加权响应时间负载均衡 (WeightedResponseTime)
生产环境slb挂载使用阿里云服务器,qps最大能到每秒5w。

【技能2】

问1:MySQL索引 结构,使用的好处,注意事项

使用索引可以加快查询速度,其实就是把数据从无序变为有序(有序能够加快检索速度),InnoDB引擎中,索引结构为B+数据库表存在一定数据量,就需要有对应的索引。
B+的好处,需要和其他的数据结构作对比才能体现出来,因为MySQL的数据是存储在硬盘里,在查询的时候,一般不能 一次性 把全部数据都加载到内存中,
红黑树是 二叉查找树的变种,一个Node节点只能存储一个Key和一个Value,B和B+树和红黑树不一样,他们属于多路搜索树,想叫与二叉搜索树而言,
一个Node节点可以存储的信息更多,所以多路搜索树的高度会比二叉搜索树更低,检索效率也就越好。
在数据不能一次性加载至内存中,数据就需要被有效的检索出来并返回,而最终选择B+树的理由就是,
B+树的非叶子节点不存储数据,在相同的数据量下,B+树的非叶子节点就可以存储更多的索引,所以B+树也就比B树更加矮壮。
B+树叶子节点之间,还会组成一个链表,方便遍历查询。所以MySQL的InnoDB引擎,没创建一个索引,就相当于生成一个B+树。
如果该索引是 聚簇索引 ,那当前B+树的叶子节点存储着 主键和当前行的数据。
如果该索引是 非聚簇索引,那当前B+树的叶子节点存储着 主键和当前索引列值。
比如select * from XX where id >= 10,那只要定位到id为10的记录,然后再叶子节点之间通过遍历链表即可找到后续的记录了。
由于B树是会在非叶子节点也存储数据,要遍历数据的时候,就需要跨层检索,相对麻烦些。
所以最终MySQL选择了B+树作为索引存储的数据结构。
对于哈希结构,InnoDB有 自适应 哈希索引,自动优化创建,我们干预不了,也就没有去深入研究。
什么是回表?
回表就是当我们使用非聚簇索引的时候,检索出来的数据可能包含其他列,但走索引树叶子结点只能查到当前列值以及主键ID,
所以需要根据主键ID再去查一遍数据,得到SQL所需列。
比如:select order_id, order_name from order_detail where order_id = “123”,
SQL走订单ID索引,但在订单ID的索引叶子节点中只有order_id和主键id,而我们还想检索出order_name,
所以MySQL会那主键id再去查询order_name给我们返回,这样的操作就是回表。
避免回表,可以使用覆盖索引。
覆盖索引就是你想查找的列刚好在叶子节点上存在,比如创建order_id,order_name的组合索引,
而刚好查询的字段也就是order_id和order_name,这些数据都存在索引树的叶子节点上,
这个时候就不需要回表操作了。
其中联合索引需要注意最左匹配原则,而且慎用 > < between like 这种范围查询,容易导致索引失效。
另外主键id,用MySQL自增主键,因为索引的特点是有序,如果向uuid那样插入的时候性能会比较差。
因为可能需要一定磁盘块,比如,块内存当前时刻已经存储慢了,但新生成的uuid需要插入已满的块内,就会移动数据。
总结一下吧:
为什么B+树?数据无法一次load到内存,B+树是多路搜索树,只有叶子节点才存储数据,叶子节点之间链表进行关联。(树矮,易遍历)
什么是回表?非聚簇索引在叶子节点只存储列值以及主键ID,有条件下尽可能用覆盖索引避免回表操作,提高查询速度
什么是最左匹配原则?从最左边为起点开始连续匹配,遇到范围查询终止
主键非自增会有什么问题?插入效率下降,存在移动块的数据问题

问1-0:MySQL索引失效场景

(1:是否能使用 覆盖索引,减少 回表 所消耗的时间,这就意味着,我们select的时候,一定要指明对应的列,而不使用select *
(2:考虑组建 联合索引,尽量将区分度最高的放在最左边,并且要考虑最左匹配原则。
(3:对索引进行函数操作或者表达式计算会导致索引失效。
(4:利用子查询优化超多分页场景,比如limit offset,n 在MySQL是获取offset+n的记录,再返回n条。
而子查询则则是查出n条,通过ID检索对应的记录出来,提高查询效率。
(5:通过explain命令来查看SQL执行计划,看自己的sql是否走了索引,走了哪个索引,
通过show profile来查看sql对系统资源的耗损情况,不过一般很少用。
(6:开启事物后,在事务内尽可能只操作数据库,并减少锁的持有时间,
如事务内需要插入和修改数据,可以先插入,后修改,因为修改会加行锁。如果先更新,并发下可能会导致多个实物的请求等待行锁释放。
(7: 加上limit 1后,只要找到了对应的一条记录,就不会继续向下扫描了,效率将会大大提高。
(8:使用or可能会使索引失效,从而全表扫描。对于or+没有索引的age这种情况,假设它走了userId的索引,但是走到age查询条件时,它还得全表扫描,也就是需要三步过程:全表扫描+索引扫描+合并
如果它一开始就走全表扫描,直接一遍扫描就完事。mysql是有优化器的,处于效率与成本考虑,遇到or条件,索引可能失效,看起来也合情合理。
(9:like很可能让你的索引失效,把%放前面,并不走索引,如下: 把% 放关键字后面,还是会走索引的
(10:Inner join 、left join、right join,
使用left join,左边表结果尽量小
Inner join 内连接,在两张表进行连接查询时,只保留两张表中完全匹配的结果集, 但需要注意,inner join效率没有leftjoin效率高,因为会过滤结果集
left join 在两张表进行连接查询时,会返回左表所有的行,即使在右表中没有匹配的记录。
right join 在两张表进行连接查询时,会返回右表所有的行,即使在左表中没有匹配的记录。
(11: 慎用distinct
带distinct的语句cpu时间和占用时间都高于不带distinct的语句。因为当查询很多字段时,
如果使用distinct,数据库引擎就会对数据进行比较,过滤掉重复数据,然而这个比较、过滤的过程会占用系统资源,cpu时间。

问1-1:MySQL如何调优
  • 读性能有瓶颈:

离线备份后,将备份后的数据删除,减少表中的数据量,不过,只要不是复杂表连接sql,上亿的数据查询返回性能还是很好的,mysql没有什么压力。
如果查询频繁,可以考虑走缓存,不过也得确认业务能不能忍受读取的 非真实实时 的数据,毕竟redis 和mysql的数据一致性需要保证,
而且如果查询条件相对复杂多变的话,涉及到各种group by 和sum ,那么走缓存也不是一个好办法,毕竟放在缓存里面的数据希望更新操作尽量的少,
而且过于复杂的结构,redis的维护也不方便。另外再看看是不是有 字符串 检索的场景导致查询低效,如果有的话,考虑将数据迁移到ES中,
后续查询走ES,迁移一般就是MySQL的binlog,解析binlog到ES中。
如果还不行,考虑数据聚合,后续请求查询聚合表,不查询原表。思路大致就是 空间换时间,提高查询效率。

  • 写性能瓶颈:

首先看架构,如果是单库,考虑升级主从,实现读写分离。
主从之间的数据同步是通过 主库 向 从库 发送binlog进行数据的更新同步,一般是异步老保证最终一致性。
如果主从架构还存在瓶颈,考虑分库分表。最明显的好处就是分散请求,我们公司分库分表是根据集团维度,如集团id,
这样一个集团的数据会集中在一个库中,事务相对好控制,业务不同,也可以考虑userId。同样的思路,空间换时间。
分库分表之后,就会产生分布式下的唯一ID的问题,如何保证ID唯一,我们公司用的是雪花算法,实现ID全局唯一的。
还有就是分库分表以后,数据迁移的过程也需要注意,一般迁移的时候,需要开启双写配置,
将旧表的数据迁移至新库的同时,增量的数据,新旧库表都要写一份,最终迁移完校验新老数据是否正常,
既然开了双写,那么查询数据的时候,必然涉及到了双读,这个过程相当于灰度上线的过程。观察一段时间,
关闭旧库的读流量,停止老表的写入。最后一定要记得,提前准备回滚方案,保证临时切换失败,能快速恢复正常业务,
以及修正相关脏数据,如果数据修正很困难,可以考虑一些营销手段,给用户赠一些东西作为补偿。
总结一下吧:
数据库表存在一定数据量,就需要有对应的索引。
发现慢查询时,检查是否走对索引,是否能用更好的索引进行优化查询速度,查看使用索引的姿势有没有问题。
当索引解决不了慢查询时,一般由于业务表的数据量太大导致,利用空间换时间的思想(NOSQL、聚合、冗余…)。
当读写性能均遇到瓶颈时,先考虑能否升级数据库架构即可解决问题,若不能则需要考虑分库分表。
分库分表虽然能解决掉读写瓶颈,但同时会带来各种问题,需要提前调研解决方案和踩坑。

问2:MySQL 事务&&锁机制&&MVCC

事务的四大特性,ACID,分别对应原子性,一致性,隔离性,持久性。
MySQL的持久性是通过redolog来保证,当我们要修改数据的时候,MySQL是先把这条记录所在的 页 找到,然后将页加载到内存中,在进行修改。
为了防止内存修改完,MySQL宕机,内存修改完数据后,还会写一份redolog,这份redolog记在这这次在某个页上做了什么修改。
即便MySQL中途挂了,我们还可以根据redolog对数据进行恢复。redolog是顺序写的,写入速度很快,
并且记录的是物理修改,文件体积很小,恢复速度也很快。
再来聊一下 隔离级别:读未提交、读已提交、可重复读、串行。
主要看应用场景,隔离级别越低,事务的并发性能也就越高。
为了保证主从数据一致,MySQL默认隔离级别为 可重复读,MySQL默认的隔离级别为可重复读,防止 主从数据不一致 的问题。
redo log(重做日志)让InnoDB存储引擎拥有了崩溃恢复能力。
binlog(归档日志)保证了MySQL集群架构的数据一致性。
再继续聊隔离级别的话,还得说一下MySQL锁机制:
所粒度分类:行锁和表锁。
当我们查询命中了索引,那锁住的就是命中条件内的索引节点,也就是行锁,如果没有命中索引,那我们锁住的就是整个索引树(表锁)。
简单来说,锁住的是整棵树还是某几个节点,完全取决于SQL条件是否有命中到对应的索引节点。
而且行锁又分为读锁(共享锁/S锁)和写锁(排他锁/X锁)。
读锁是共享的,多个事务可以同时读取到同一个资源,但不允许其他事务修改,写锁是排他,写锁会阻塞其他的写锁和读锁。
在回过来说隔离级别,
在读未提交的场景下,
事务B读取到了A还没有提交的事务,也就是脏读。
在锁的维度下,就是读未提交的隔离级别,读没有加锁,而有写锁,写锁也就无法排他了(这里指的是读和插入,同一条数据修改会被限制住)。
对于更新而言,InnoDB肯定会加写锁,所以同一条数据修改只会有一个事务,而读操作没有任何锁,所以就会出现脏读。
而如果读加锁,那就意味着更新数据的时候,读取效率就会被降低。
这里就引入了MySQL 的 MVCC 也就是多版本并发控制。
MVCC读写不阻塞,解决了脏读。
MVCC是通过数据快照,并用这个快照来提供一定级别(语句级别或事务级)的一致性读取。
回到事务级别,读已提交生成的是语句级快照,而可重复读,生成的是事务级快照。
那么读未提交存在脏读,那么读已提交是怎么解决的呢?
它是通过在读取的时候生成一个版本号,在等到其他事务commit之后,才会读取最新commit的版本号数据。
比如,事务A读取记录,生成一个版本号,
事务B修改了记录,此时会加上写锁,
事务A再来读取的时候,会根据最新的版本号来读取,
如果B commit了,那么会生成一个新的版本号,A读取的是最新的版本号数据。
如果B 没有commit,那么A读取的还是之前的版本号的数据。
所以 读已提交 通过版本号,就可以解决 脏读 ,做到对应版本对应快照数据。
但是还是有其他问题,如不可重复读,一个事务读到了另一个事务已经提交的数据,也就是说一个事务可以看到其他事务所做的修改。
更简单的理解。每次A查询的结果,都收到了B修改的影响。
那么可重复读是怎么解决的呢?
MySQL的可重复读的隔离级别,是 事务级别 的快照,每次读取的都是当前事务的版本,即使当前数据被其他事务修改了(并且commit了),
也只会读取当前事务版本的数据。
可重复读和脏读都讲完了,下面是幻读(插入或者删除数据,2次查询数据不一样),到底MySQL的可重复读的隔离级别有没有解决幻读这个问题呢?
如果事务中都使用快照读,那么就不会产生幻读现象,但是快照读和当前读混用就会产生幻读。
一般的 select * from … where … 语句都是快照读
什么情况下使用的是当前读:(当前读,会在搜索的时候加锁)
select * from … where … for update、 select * from … where … lock in share mode、 update … set … where … 、delete from. . where …
那么怎么避免幻读呢?如果改为可串行化,并发度又太低,那怎么通过在可重复读的隔离级别下,解决幻读呢?
用间隙锁,即锁住了数据之间的间隙,不能修改范围之间的数据,故不能导致插入或者删除导致幻读。
例如 update test_innodb set name=‘javayz3’ where id>1 and id<10。
最后是串行的隔离级别,不允许事务并发,事务与事务之间串行,效率最低,最安全。
那么MVCC的实现源码呢,简单聊一下,
MVCC主要是通过 read view 和 undolog 实现的,undolog会记录修改的数据之前的信息,事务中的原子性就是通过undolog实现的,
所以undolog可以帮助我们找到 版本 的数据。
而read view 实际上就是在查询的时候,InnoDB会生成一个 read view,其中有几个重要的字段,分别为:
尚未提交的commit的事务版本号集合、下一次要生成的事务ID值、尚未提交的版本号的事务ID最小值、以及当前事务的版本号。
在每行数据有两列隐藏字段,分别是记录当前ID和上一个版本数据在undolog里的位置指针。
MVCC就是通过对比版本来实现读写不阻塞,而版本数据存在在undolog中。
而针对不同的隔开级别,无非就是RC读已提交每次都会产生一个新的read view,
而RR可重复读则是每次事务只获取一个read view。
总结一下吧:
事务为了保证数据的最终一致性
事务有四大特性,分别是原子性、一致性、隔离性、持久性
原子性由undo log保证
持久性由redo log 保证
隔离性由数据库隔离级别供我们选择,分别有read uncommit,read commit,repeatable read,serializable。
一致性是事务的目的,一致性由应用程序来保证。
事务并发会存在各种问题,分别有脏读、重复读、幻读问题。上面的不同隔离级别可以解决掉由于并发事务所造成的问题,而隔离级别实际上就是由MySQL锁来实现的。
频繁加锁会导致数据库性能低下,引入了MVCC多版本控制来实现读写不阻塞,提高数据库性能。
MVCC原理即通过read view 以及undo log来实现。

问3:如何看执行计划:

Explain中的“Type”
连接类型(the join type)。它描述了找到所需数据使用的扫描方式。
最为常见的扫描方式有:
system:系统表,少量数据,往往不需要进行磁盘IO;
const:常量连接;
eq_ref:主键索引(primary key)或者非空唯一索引(unique not null)等值扫描;
ref:非主键非唯一索引等值扫描;
range:范围扫描;
index:索引树扫描;
ALL:全表扫描(full table scan);
上面各类扫描方式由快到慢:
system > const > eq_ref > ref > range > index > ALL

1.1 system
扫码类型为system,说明数据已经加载到内存里,不需要进行磁盘IO。
这类扫描是速度最快的。
但是我没有遇到过,遇到了我再来补充!
1.2 const
explain select id from account_user_base where id =1;
const扫描的条件为:
(1)命中主键(primary key)或者唯一(unique)索引;
(2)被连接的部分是一个常量(const)值;
1.3 eq_ref
eq_ref扫描的条件为:对于前表的每一行(row),后表只有一行被扫描。
我也没有遇到!
1.4 ref
explain select * from account_user_base t1,account_user_security t2 where t1.id = t2.user_id;
对于前表的每一行(row),后表可能有多于一行的数据被扫描。
1.5 range
explain select * from account_user_base where id > 4;
range类型,它是索引上的范围查询,它会在索引上扫码特定范围内的值。
1.6 index
explain select id from account_user_base;
index类型,需要扫描索引上的全部数据。
1.7 ALL
explain select * from account_user_base;
全表扫描。
1.8总结
system最快:不进行磁盘IO
const:PK或者unique上的等值查询
eq_ref:PK或者unique上的join查询,等值匹配,对于前表的每一行(row),后表只有一行命中
ref:非唯一索引,等值匹配,可能有多行命中
range:索引上的范围扫描,例如:between/in/>
index:索引上的全集扫描
ALL最慢:全表扫描(full table scan)

Explain中的“Extra”
2.1 Using where
explain select * from account_user_base where id > 4;
Extra为Using where说明,SQL使用了where条件过滤数据。
2.2 Using index
explain select id from account_user_base;
Extra为Using index说明,SQL所需要返回的所有列数据均在一棵索引树上,而无需访问实际的行记录。
2.3 Using index condition
explain select * from account_user_security t1, account_user_base t2 where t1.user_id = t2.id;
Extra为Using index condition说明,确实命中了索引,但不是所有的列数据都在索引树上,还需要访问实际的行记录。
2.4 Using filesort
explain select id from account_user_base order by nick_name;
Extra为Using filesort说明,得到所需结果集,需要对所有记录进行文件排序。
典型的,在一个没有建立索引的列上进行了order by,就会触发filesort,常见的优化方案是,在order by的列上添加索引,避免每次查询都全量排序。
2.5 Using temporary
explain select nick_name, COUNT(*) from account_user_base GROUP BY nick_name order by nick_name;
Extra为Using temporary说明,需要建立临时表(temporary table)来暂存中间结果。
这类SQL语句性能较低,往往也需要进行优化。
典型的,group by和order by同时存在,且作用于不同的字段时,就会建立临时表,以便计算出最终的结果集。

【技能3】

问1:redis6.0一些新特性,为什么会引入所线程,异步处理一般都是哪些场景?

4.0版本之后就已经开始加入多线程的支持,不过当时4.0的多线程主要是针对一些大key的删除操作。
6.0之前不使用多线程主要是因为:
1)单线程容易维护。
2)Redis性能不在于CPU,而是网络和内存IO。
3)多线程存在死锁,线程上下文切换等,甚至会影响性能。
而6.0引入多线程,主要是为了提高IO读写性能,Redis的多多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行的。
6.0多线程的原理流程主要是:
主线程接收建立的socket连接请求,将获取到的socket放入全局等待读处理队列。主线程处理完读事件后,通过RR轮询的方式将这个socket连接分配给IO线程。
主线程此时阻塞,等待多线程IO读取socket完毕。
读取socket连接之后,主线程通过单线程的方式顺序执行请求命令,请求数据读取并解析完成,但并不执行。
主线程阻塞等待IO线程将数据回写socket完毕,最后解除绑定,清空等待队列。
总结下来就是,IO多线程的操作主要是socket的绑定,读取socket、解析请求,回写socket以及解除绑定。
这样设计的好处就是IO线程要么同时在读socket,要么就是同时在回写socket,不会同时读或写。
IO多线程只负责读写socket解析命令,而具体的Redis执行命令还是由主线程顺序执行操作的。
也就是Redis的多线程部分只是用于处理网络读写和协议解析。

问2:Redis数据结构以及应用场景?

string,简单的key-value类型,基础命令为set、get、exists、decr、incr、setex,应用场景:访问次数,热点数据的数量计数等。
list,双向链表,命令为rpush、rpop、lpush、lpop,应用场景:实现消息队列
hash,数组+链表,类似HashMap,命令为hset,hget、hvals、hkeys、hexists、hgetall等,应用场景:系统中对象数据的存储。
set,无序集合,类似HashSet,命令sadd、spop、smembers、scrad、sismember等,应用场景:存放数据不能重复的集合或便于操作数据源交集并集。
zset,有序排序集合,命令zadd、zcrad、zrem、zscore、zrange、zrevrange等,应用场景:权重排序,各种排名操作,实时排名等。
bitmap,存储二进制数字,0和1,一般用于位运算,极大的节省存储空间,常用命令,setbit、getbit、bitcount、bitop等。
应用场景,保存一些状态信息,用于后续的信息分析,如用户签到,活跃情况,用户的行为统计(打标签后续做喜好推送等)。

问3:Redis的内存淘汰策略?

1)过期的数据集中最近最少使用 的数据淘汰;
2)过期数据集中挑选要过期 的数据淘汰;
3)过期数据集中任意选择 数据淘汰
4)allkeys 最近最少使用的key 淘汰;
5)allkeys 任意数据淘汰;
6)过期的数据集中挑选最不经常使用的数据淘汰;
7)allkeys 移除最不经常使用的key。

问4:Redis的备份机制?

Redis的持久化有2种机制,分别是RDB和AOF,
RDB指的是:我们根据自己配置的时间,或者手动去执行BGSAVE或SAVE命令,Redis会生成RDB文件,这个文件实际就是一个经过压缩的二进制文件,
Redis就是通过这个文件在启动的时候来还原我们的数据。
AOF则是把Redis接收到的所有写命令都记录到日志中,Redis宕机的话重新跑一遍这个记录下的日志文件,就相当于还原了数据。
这里面以RDB持久化为例,Redis有自己的一套事件处理机制,主要处理文件事件(如:命令请求和应答),和时间事件(RDB持久化,过期key清理)。
所以RDB其实是一个时间事件,线程会不停轮询就绪的事件,发现RDB事件可执行,则调用BGSAVE命令。
而BGSAVE命令其实就是fork出来一个子进程来进行持久化,也就是生成RDB文件。
在fork的过程中,父进程(主进程)肯定是阻塞的。在fork完之后,fork之后的子进程就可以去完成持久化了。
还有就是Redis较新的版本中,很多地方都用到了多线程来进行处理,比如:一些大key的清除操作,对网络数据的解析。
只不过核心的处理命令请求和响应还是单线程。
RDB是通过fork子进程来实现,
AOF则是在命令执行完之后,把命令写在buffer缓冲区,一般是每秒一次把命令都执行存盘。Redis会启动一个线程去刷盘,这个也不是主线程去做的。
AOF还有重写文件机制,不会一直把所有文件都写入磁盘,因为这样做会让AOF文件越来越大,命令集合会会一直膨胀。
所以Redis会fork一个子进程会对原始命令集合进行重写。说简单点就是压缩文件,压缩完替换原来文件就好。
但是由于fork一个子进程对AOF文件进行重写,这个主进程被阻塞,但是还得继续接受命令,
所以Redis为了避免fork进程的时候,AOF丢失命令,会把新接收到的命令再写到另外一个缓冲区。
至于具体选用RDB还是AOF持久化,主要看业务需求,如果业务上允许重启的时候,丢失部分数据,那么RDB就可以了。
RDB在启动的时候,恢复数据比AOF要快很多。不过一般还是会AOF和RDB一起组合使用。
如果Redis内存不够,而且新数据还需要不断写入,我们会淘汰哪些不活跃的数据,然后把新的数据写进去。

问5:Redis的集群架构

我们公司架构是主从架构,保证Redis集群的高可用。
Redis是使用PSYNC命令进行同步,该命令有两种模型,完全重同步和部分重同步。
可以简单理解为,如果第一次同步,从服务器没有复制过任何的主服务器,或者从服务器要复制的主服务器和上次复制的主服务器不一样,
那就会采用 完全重同步 模式进行复制。
如果只是由于网络中断,只是短时间的断连,就会采用 部分重同步模式进行复制。
加入主从服务器的数据差距实在是过大了,还是会采用 完全重同步 的模式进行复制。
同步的机制,
主服务器要复制数据到从数据库,首先建立socket,校验信息,如身份校验等,然后从服务器发送PSYNC命令到住服务器,
要求同步(会带上服务器ID RUNID 和 复制进度 offset参数)。如果服务器是新的,那就没有。
主服务器发现这个一个新的从服务器,就会采用完全重同步,把RUNID 和offset发送到从服务器(从服务器记录下信息),
随后主服务器会在后台生成RDB文件,通过建立好的连接转发给从服务器。从服务器收到RDB文件后,首先把自己的数据清空,然后对RDB文件进行加载恢复。
这个时候主服务器还会继续接收客户端的请求。主服务器会把生成的RDB文件(之后的修改命令)会有buffer缓冲记录下来,等到从服务器加载完RDB之后,
主服务器会把 buffer 记录下的命令都发给从服务器。复制过程是异步的,所以数据是 最终一致性。
部分重同步的话就是靠offset来进行同步,每次主服务器会把offset给到从服务器,两边都会保存offset,
如果两边offset存在差异,那么就说明主从数据未完全同步。
从服务器断连之后,进行重连,就会发PSYNC给主服务器,同样携带RUNID和offset。
主服务器收到后,如果RUNID对的上,说明之前就同步过一部分了,接着检查offset,
主服务器记录主从服务器的offset是用的环形buffer,如果该buffer满了,会覆盖以前的记录,而记录客户端的修改命令是另外一个buffer。
如果在backlog_buffer找到了,那就把缺失的一部分对应的修改命令发给从服务器,如果么有找到,就只能 完全重同步 模式再次进行主从复制。
总结下来就是,
Redis从服务器PSYNC(RUNID,offset)
第一次没有携带参数,则完全重同步,有offset参数,则主服务器判断是否可以进行部分重同步,在环形buffer中没有找到offset则完全重同步。
主从架构有一个点需要注意,如果Redis主库挂掉了,需要我们手动将从库升级为主库,如果想自动升级的话,就需要有监控机制,
哨兵架构主要做的事情就是:监控主服务器的状态、选主(主服务器挂了,在从服务器里面选出一个作为主服务器)、通知(故障发送消息给管理员)、
配置(作为配置中心,提供当前主服务器的信息)。
哨兵首先会跟Redis的主从服务器创建对应的链接,获取服务器的信息,每个哨兵都会不停的ping,确认主服务器的状态,
如果在配置事时间内没有正常响应,就会对主服务器进行故障转移操作。
挑选从服务器也会有很多设置,如:从库的配置优先级,哪个从库的offset最大,RunId大小,与主库断开连接的时长等。
然后从服务器需要与最新的主库进行主从复制。
需要注意的是,从主从复制的流程来看,这个过程是异步操作的,也就说明,复制的时候,主库会一直接收最新的命令,然后把请求发给从服务器。
假如主服务器的命令还没有发给从服务器,这个时候主库宕机了,如果想从从库中选出来,作为新的主库,但是从库数据不全,这个时候就会丢失部分数据。
还有另一种情况,有可能哨兵认为主库挂掉了,但其实没有挂掉,只是网络抖动,此时哨兵就会选举从库为主库,但是客户端可能在这个过程中,
还在继续向旧的主库写数据,等旧的主库重连,此时已经变味从库了,这个时候,写进旧主库的数据就相当于丢失了,这个场景被称为Redis脑裂。
所以主从复制的延迟和脑裂都很难避免数据丢失,我们只能从配置上尽可能避免。

问6:Redis的分片集群架构有了解吗?

聊分片集群前可以先说主从架构,做一下对比,
主从模式下Redis实现了读写分离的架构,可以让多个从服务器承载 读流量 ,但是面对 写流量 时,始终是只有主服务器在抗,
纵向扩展 升级Redis服务器硬件能力,但一直升级,成本就不划算了。纵向扩展意味着大内存,Redis持久化的成本将会被放大,
Redis的RDB持久化是全量的,fork子进程有可能由于使用内存过大,导致主线程阻塞时间过长。
所以单实例是有性能瓶颈的,存储的数据量越大,RDB阻塞主线程的时间就越长。
既然纵向扩展会有性能瓶颈,那么就考虑横向扩展。用多个Redis构建一个集群,按照一定的规则把数据分发到不同的Redis实例上,
当句群所有的Redis实例的数据加起来,那这份数据就是全的。我理解就是分布式集群,只不过Redis里面叫做分片集群人比较多?
既然是分片集群,那就一定会涉及到数据分发,Redis集群的路由是做到了客户端,SDK已经集成了路由的转发功能。
而Redis的分发逻辑中涉及到了hash槽,默认会有16384个哈希槽,这些哈希槽会分配到不同的Redis实例中,也就是,
每台Redis实例都会瓜分一定数量的哈希槽。至于怎么瓜分,我们可以手动去配置,重要的是,一定要把16384个哈希槽分完,不能剩余。
当客户端写入数据是,对key进行hash,然后得到的值与16384取模,最后的结果就是代表数据应在被写入哪个哈希槽中,也就是哪个Redis实例中。
而客户端算出了哈希槽,而集群中每个Redis实例都会向其他实例传播自己负责的哈希槽有哪些,也就是记录着关联关系,
客户端也会把这个映射关系缓存到本地,这样客户端计算出了哈希槽,也就相当于知道该去哪个Redis实例上操作了。

问7:Redis集群中新增和删除Redis实例怎么做?

有了上面的知识,我们可以知道集群是有哈希槽的分配的,那么新增和删除Redis实例的时候,就意味着哈希槽会有变化,
那么发生变化的Redis实例会把消息发送到集群中,所有的Redis实例都会更新自己保存的映射关系,
在这个过程中,客户端时无感知的,所以可能会发生客户端还会请求 原来 的实例,Redis实例收到了不是自己的请求后,
会返回moved命令,告诉客户端去新的Redis实例上请求,而客户端要做的事情就是,重新请求新的实例,并更新自己的本地缓存映射。
总结下来就是,数据迁移完毕后被响应,客户端收到moved命令,重新请求新的实例并且会更新本地缓存。
如果没有完全迁移完数据,这个时候客户端会收到ack命令,也是让客户端去请求新的实例,但不会更新本地缓存。
说的直白一点就是集群Redis实例存在变动,而Redis实例之间会通讯,所以等到客户端请求时,
Redis总会知道客户端实际应该要请求的数据是哪个实例的,
如果迁移完毕,就返回moved命令,告诉客户端去找哪个实例要数据,并更新自己的缓存映射。
如果正在迁移中,那就返回 ack 命令,告诉客户端去哪个实例要数据。
数据的迁移过程大致为: 把新的Redis实例加入到集群中,然后把部分数据迁移到新的实例上,过程为:
1)原实例 某一个槽的 部分 数据发给 目标实例。
2)目标实例收到数据后,给原实例发送ack。
3)原实例收到ack后,在本地删除槽数据(已经给目标实例发过的数据)。
4)不断循环1/2/3步骤,知道槽所有数据迁移完毕。

问8:Redis集哈希槽为什么是16384个,以及为什么使用哈希槽实现扩容缩容?

Redis实例之间通讯会互相交换槽信息,如果槽过大,网络包就会过大,就意味着过度占用网络的宽带。
所以我理解可能是Redis作者这样设计是为了可以将数据合理打散至Redis集群中不同的实例,又不会导致交换数据的时候导致宽带占用过多。
而为什么要选用哈希槽,而不用一致性哈希算法,我的理解是一致性哈希算法就是有一个哈希环,
当客户请求是,会对key进行hash,确定哈希环上的位置,然后顺时针往后找,找到第一个节点。
一致性哈希算法比传统取模的好处就是,新增或删除实例,只会影响一小部分的数据,同理也得知道是哪一小部分的数据受到影响,需要迁移哪一部分的数据。
而哈希槽,每个实例都可以拿到槽位信息,对客户端的key进行hash运算后,如果得到的结果实例没有相关数据,实例会重定向命令告诉客户端去哪里请求,
集群的扩容和缩容都是以哈希槽的方式来实现,流程会更加简单,弹性。
总结下来就是,把部分槽位进行重分配,然后迁移槽中数据即可,不会影响到集群中某个实例的所有数据。
分片集群诞生理由:写性能在高并发下会遇到瓶颈&&无法无限地纵向扩展(不划算)
分片集群:需要解决「数据路由」和「数据迁移」的问题
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支持同步迁移&&异步迁移

问9:消息去重、消息幂等,解决方案?

首先是去重:
如果去重开销比较大,可以考虑多层过滤的逻辑,
本地缓存先过滤一部分、剩下的强校验交给远程存储,比如Redis 和 DB数据库,进行二次过滤。
一般我们需要数据强一致性的校验,就直接上MySQL,毕竟有事务的支持。本地缓存如果业务合适,可以作为前置判断。
Redis高性能的读写,前置判断和后置判断均可以。
至于幂等:
一般的解决方案还是Redis和数据库。 最常见的就是数据库的唯一索引。
一般唯一的key是业务相关实现,一般都是用自己的业务ID进行拼接,生成有意义的 唯一key。
也可以使用Redis的分布式锁来实现幂等。
最终的实现方法思路本质上都是围绕着 存储和 唯一key来实现的。

熟悉分布式锁,分布式事务等常见的分布式技术;
熟悉多级缓存架构、缓存穿透、缓存击穿、缓存预热等;

【技能4】

linux常用的指令,需要背下来
df -h
free

【技能5】

docker常用的指令

【技能6】

问1:Kafka如何实现这么大的负载?

消息队列的核心就是把生产者的数据存储起来,然后给各个业务把数据在读取出来,kafka的存储和读取过程中又做了很多优化。
比如,我们往一个topic中发送消息和读取消息,实际上内部会有多个partition在并行处理,在存储消息的时候,
kafka内部会顺序写磁盘,并领用操作系统的缓冲区来提高性能(append + cache),在读写数据的时候减少cpu拷贝的次数。
零拷贝的技术,举了例子,我们正常read函数的时候,会发生以下几个步骤,
1)DMA把磁盘数据拷贝到读内核缓存区
2)CPU把内核缓冲区的数据拷贝到用户空间
而调用write函数的时候,
1)CPU把用户空间的数据拷贝到内核缓存区
2)DMA把内核缓冲区的数据拷贝到磁盘
所以,一次读写要用到2次DMA和2次CPU的拷贝。所以零拷贝就是减少CPU的拷贝。
而且为了避免用户进程直接操作内核,保证内核安全,应用程序在调用系统函数时,会发生上下文切换。
所以为了减少拷贝,就有了缓冲区地址和用户空间地址的映射,实现读内核缓冲区和应用缓冲区共享。
从而减少从读缓冲区到用户缓冲区的一次CPU拷贝。
总结下来就是,kafka能这么快,就是实现了并行,充分利用操作系统cache,顺序写和零拷贝。

问2:消息丢失

生产者发消息,可能会出现消息丢失。如果不想丢消息,就在发送消息的时候,选择有callback的api进行发送,
如果发送失败,收到回调之后在业务层面重试就好了。
消息发送成功后,集群环境下也可能由于服务器宕机导致消息还没有同步给其他的服务器,数据自然也就丢了。
也就是异步刷盘这个过程也会导致数据丢失。
重复消费:业务逻辑层面,消息做幂等处理(Redis+DB,通过业务唯一key做落库处理,避免重复消费)
总的来说就是Redis做前置处理,DB唯一索引最终保证来实现幂等操作。
顺序消息:消息补偿机制,另一个进行消费相同的topic数据,消息落盘,延迟处理,将消息与DB进行对比,如果数据不一致,在重新发送消息至主进程处理。
指定partition:一个partition由一个consumer消费。

《RocketMQ技术内幕》:https://blog.csdn.net/prestigeding/article/details/85233529
关于 RocketMQ 对 MappedByteBuffer 的一点优化:https://lishoubo.github.io/2017/09/27/MappedByteBuffer%E7%9A%84%E4%B8%80%E7%82%B9%E4%BC%98%E5%8C%96/
十分钟入门RocketMQ:https://developer.aliyun.com/article/66101
分布式事务的种类以及 RocketMQ 支持的分布式消息:https://www.infoq.cn/article/2018/08/rocketmq-4.3-release
滴滴出行基于RocketMQ构建企业级消息队列服务的实践:https://yq.aliyun.com/articles/664608
基于《RocketMQ技术内幕》源码注释:https://github.com/LiWenGu/awesome-rocketmq

阿里云RocketMQ官方文档:https://help.aliyun.com/document_detail/201002.html

【技能7】

对象的finalize()方法的执行时机:执行可达性分析算法-》对象标记为可回收-》若重写了finalize()-》将对象加入F-Queue-》低优先级线程执行finalize()
JVM Jmm 常用的 查看内存相关的指令

问1:JMM

参考链接: https://mp.weixin.qq.com/s?__biz=MzU4NzA3MTc5Mg==&mid=2247484535&idx=1&sn=af9676b6defcfd862db297b1ee3f4aea&chksm=fdf0ec28ca87653e84aedd1b5db916d776c46c158afb727467d42a941af85d948046da24b5e5&cur_album_id=1657204970858872832&scene=190#rd

并发问题产生的三大根源是「可见性」「有序性」「原子性」
可见性:CPU架构下存在高速缓存,每个核心下的L1/L2高速缓存不共享(不可见)
有序性:主要有三部分可能导致打破(编译器和处理器可以在不改变「单线程」程序语义的情况下,可以对代码语句顺序进行调整重新排序)
编译器优化导致重排序(编译器重排)
指令集并行重排序(CPU原生重排)
内存系统重排序(CPU架构下很可能有store buffer /invalid queue 缓冲区,这种「异步」很可能会导致指令重排)
原子性:Java的一条语句往往需要多条 CPU 指令完成(i++),由于操作系统的线程切换很可能导致 i++ 操作未完成,其他线程“中途”操作了共享变量 i ,导致最终结果并非我们所期待的。
在CPU层级下,为了解决「缓存一致性」问题,有相关的“锁”来保证,比如“总线锁”和“缓存锁”。
总线锁是锁总线,对共享变量的修改在相同的时刻只允许一个CPU操作。
缓存锁是锁缓存行(cache line),其中比较出名的是MESI协议,对缓存行标记状态,通过“同步通知”的方式,来实现(缓存行)数据的可见性和有序性
但“同步通知”会影响性能,所以会有内存缓冲区(store buffer/invalid queue)来实现「异步」进而提高CPU的工作效率
引入了内存缓冲区后,又会存在「可见性」和「有序性」的问题,平日大多数情况下是可以享受「异步」带来的好处的,但少数情况下,需要强「可见性」和「有序性」,只能"禁用"缓存的优化。
“禁用”缓存优化在CPU层面下有「内存屏障」,读屏障/写屏障/全能屏障,本质上是插入一条"屏障指令",
使得缓冲区(store buffer/invalid queue)在屏障指令之前的操作均已被处理,进而达到 读写 在CPU层面上是可见和有序的。
不同的CPU实现的架构不一样,Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果。
为什么存在Java内存模型???
答:
Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果。
(线程之间的 共享变量 存储在主内存中,每个线程都有自己的私有的 本地内存 ,本地内存 存储了该线程以读|写共享变量的副本)
Java内存模型抽象结构:线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。
线程对变量的所有操作都必须在「本地内存」进行,而「不能直接读写主内存」的变量
happen-before规则:Java内存模型规定在某些场景下(一共8条),前面一个操作的结果对后续操作必须是可见的。这8条规则成为happen-before规则。
volatile:volatile是Java的关键字,修饰的变量是可见性且有序的(不会被重排序)。
可见性&&有序性由Java内存模型定义的「内存屏障」完成,实际HotSpot虚拟机实现Java内存模型规范,汇编底层是通过Lock指令来实现。

问2:java从.class到编译执行,都经历了哪些操作?

Java跨平台因为有JVM屏蔽了底层操作系统
Java源码到执行的过程,从JVM的角度看可以总结为四个步骤:编译->加载->解释->执行
「编译」经过 语法分析、语义分析、注解处理 最后才生成会class文件
「加载」又可以细分步骤为:装载->连接->初始化。
装载|加载 则把class文件装载至JVM,
连接则校验class信息、分配内存空间及赋默认值(连接里又可以细化为:验证、准备、解析),
初始化则为变量赋值为正确的初始值。
「解释」则是把字节码转换成操作系统可识别的执行指令,在JVM中会有字节码解释器和即时编译器(JIT)。
在解释时会对代码进行分析,查看是否为「热点代码」(通过热点探测:也就是计数器,计数器又分为方法调用计数器和回边计数器),
如果为「热点代码」则触发JIT编译,下次执行时就无需重复进行解释,提高解释速度。
「执行」调用系统的硬件执行最终的程序指令。

问3:类加载器、双亲委派模型

首先为了防止内存中存在多份同样的字节码,使用了双亲委派机制
(双亲委派机制模型:自己不会尝试去加载类,而是吧请求委托给父加载器去完成,依次向上,顺序以Tomcat为例,详情见下面)
打破双亲委派就是加载类的顺序,不是按照 App ClassLoader -> Ext ClassLoader -> Bootstart ClassLoader这个顺序,就算打破了模型。
比如我们自定义一个ClassLoader,重写loadClass方法,不依照这个顺序寻找类加载器,就实现了打破双亲委派的模型。
以Tomcat为例:
Bootstrap ClassLoader -> Ext ClassLoader -> App ClassLoader ->
Common ClassLoader(Web应用程序&&Tomcat共享的类) -> Catalina ClassLoader(Tomcat自身的类)
-> Share ClassLoader(Web应用程序共享的类) -> WebApp ClassLoader(Web应用程序独享的类)
再以JDBC为例聊双亲委派模型:
由于类加载有个规则,如果一个类加载器由类加载器A加载,那么这个类的依赖类也是有相同的类加载器去加载。
而JDBC定义接口,具体实现类有各个厂商进行实现,比如MySQL:
我们用JDBC的时候,是使用DriverManager进而获取Connection,DriverManager是在java.sql包下,
按照双亲委派模型来说,应该由Bootstrap类加载器加载,但是Bootstrap又找不到对应的包,无法加载各个厂商的类,
所以解决方法就是,DriverManager初始化的时候,得到 线程上线文加载器 ,去获取Connection的时候,
是使用线程上下文加载器去加载Connection的,而这里的线程上下文加载器实际上还是App ClassLoader。
也就是说,在获取Connection的时候,还是会找Ext ClassLoader和Bootstrap ClassLoader的,
只不过这两个加载器是加载不到,所以最终还是由App ClassLoader去加载。
而App ClassLoader是通过线程上下文加载器去获取的。
总结下来就是,
前置知识:JDK中默认类加载器有三个:AppClassLoader、Ext ClassLoader、BootStrap ClassLoader。
AppClassLoader的父加载器为Ext ClassLoader、Ext ClassLoader的父加载器为BootStrap ClassLoader。
这里的父子关系并不是通过继承实现的,而是组合。
什么是双亲委派机制:加载器在加载过程中,先把类交由父类加载器进行加载,父类加载器没找到才由自身加载。
双亲委派机制目的:为了防止内存中存在多份同样的字节码(安全)
类加载规则:如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。
如何打破双亲委派机制:自定义ClassLoader,重写loadClass方法(只要不依次往上交给父加载器进行加载,就算是打破双亲委派机制)
打破双亲委派机制案例:Tomcat
为了Web应用程序类之间隔离,为每个应用程序创建WebAppClassLoader类加载器
为了Web应用程序类之间共享,把ShareClassLoader作为WebAppClassLoader的父类加载器,如果WebAppClassLoader加载器找不到,则尝试用ShareClassLoader进行加载
为了Tomcat本身与Web应用程序类隔离,用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类
为了Tomcat与Web应用程序类共享,用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器
ShareClassLoader、CatalinaClassLoader、CommonClassLoader的目录可以在Tomcat的catalina.properties进行配置
线程上下文加载器:由于类加载的规则,很可能导致父加载器加载时依赖子加载器的类,
导致无法加载成功(BootStrap ClassLoader无法加载第三方库的类),
所以存在「线程上下文加载器」来进行加载。

问4:JVM的内存结构?

JVM的内存结构,指的其实就是JVM定义的 运行时的数据区域。
分为5大块:方法区、堆、程序计数器、虚拟机栈、本地方法栈。
(其中,线程共享的:堆和方法区。线程隔离的:虚拟机栈、本地方法栈、程序计数器)
1)程序计数器:
我们应用多线程的时候,很多场景下,线程数是多余CPU的数,这就意味着,会出现 线程切换的现象,
切换意味着 中断 和 恢复 ,那自然需要一块区域来保存当前线程的执行信息。
程序计数器的作用就是记录各个线程执行的字节码地址(分支、循环、跳转、异常、线程恢复等)。
2)虚拟机栈:(用于管理java函数的调用)
每个线程在创建的时候都会创建一个虚拟机栈,每次方法调用都会创建一个栈帧,
每个栈帧会包含几块内容,分别为:局部变量表、操作数栈、动态连接和返回地址(也就是方法返回值)。
虚拟机栈的作用就是:保存方法的局部变量、部分变量的计算并参与了方法的调用和返回。
3)本地方法栈:(管理本地方法的调用)
与虚拟机栈类似,只不过本地方法指的是非java方法,一般是由c实现的。
4)方法区:
JVM运行时数据区属于JVM的规范,具体的实现不同的虚拟机厂商可能不一样,国内大部分用的是HotSpot,所以以HotSpot为例来解读一下方法区。
在JDK8前,HotSpot用永久代实现了方法区,而JDK8后,已经用元空间来替代永久代,作为方法区的实现。
方法区主要存放已被JVM加载的 类的相关信息(包括类信息(又包括了类的版本、字段、方法、接口、父类信息)和常量池(静态常量池和运行时常量池))。
静态常量池:存储 字面量 和 符号引用,静态常量池同时包括了我们熟知的 字符串常量池。
运行时常量池:存储的是 类加载是生成的 直接引用的信息。
其中,需要重点注意的是,逻辑分区角度而言,常量池属于方法区。
但JDK7以后,就把运行时常量池和静态常量池 移到了 堆中,所以 物理分区的话 运行时常量池和静态常量池属于堆。
总结下来就是,逻辑分区和物理实际存储的位置,是不一样的。
JDK8把方法区的实现从永久代变为元空间,区别在于:
元空间存储不在虚拟机中,而是在使用本地内存,JVM不会再出现方法区的内存溢出,以往永久代经常因为内存不够导致OOM。
JDK8版本总结就是,类信息(也可以叫做类信息常量池)存储在元空间。而常量池在JDK7开始,从物理角度来讲就是在堆中的。
方法区(类信息(元空间:使用本地内存)、常量池(JVM堆中))。
5)最后就是堆:(线程共享,几乎类的实例和数组分配的内存都来自这里)
堆(包含新生代和老年代),新生代游戏化分为Eden和Survivor区,Survivor又分为from和to区。
最后对比下Java内存模型和Java内存结构:
Java内存模型:
与并发相关,是为了屏蔽底层细节而提出的规范,希望在Java层面在操作内存时,在不同的平台上也有相同的效果。
Java内存结构:
又称为运行时的数据区域,他描述这当我们class文件加载至虚拟机后,各个分区的 逻辑结构 ,每个分区承担的作用和角色。

问5:垃圾回收GC机制?

什么是垃圾:只要对象不再被使用,那即是垃圾。
如何判断为垃圾:可达性分析算法和引用计算算法,JVM使用的是可达性分析算法。
什么是GC Roots:GC Roots是一组必须活跃的引用,跟GC Roots无关联的引用即是垃圾,可被回收。
常见的垃圾回收算法:标记清除、标记复制、标记整理。
为什么需要分代:大部分对象都死得早,只有少部分对象会存活很长时间。
在堆内存上都会在物理或逻辑上进行分代,为了使「stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率。
Minor GC:当Eden区满了则触发,从GC Roots往下遍历,年轻代GC不关心老年代对象。
什么是card table【卡表】:空间换时间(类似bitmap),能够避免扫描老年代的所有对应进而顺利进行Minor GC (案例:老年代对象持有年轻代对象引用)。
堆内存占比:年轻代占堆内存1/3,老年代占堆内存2/3。Eden区占年轻代8/10,Survivor区占年轻代2/10(其中From 和To 各站1/10)。

问6:CMS垃圾回收机制?

CMS垃圾回收器设计目的:为了避免「老年代 GC」出现「长时间」的卡顿(Stop The World)。
CMS垃圾回收器回收过程:初始标记、并发标记、并发预处理、重新标记和并发清除。初始标记以及重新标记这两个阶段会Stop The World。
CMS垃圾回收器的弊端:会产生内存碎片&&需要空间预留:停顿时间是不可预知的。
1)初始标记:(一层标记)Stop the World。但是只有一层,所以时间会很短暂。
GC Roots 直接关联的对象
年轻代指向老年代的对象
2)并发标记:(追溯GC Roots下的对象)
3)并发预处理(减少下一个阶段 重新标记 的处理时间)
并发阶段没有停止用户线程
老年代引用发生变化(标记dirty),重新标记处理
新生代有可能有新的引用指向老年代,重新标记处理(这个过程中有可能发生Minor GC来减少扫描时间)
4)重新标记(Stop the World)
停止用户线程,扫描老年代的dirty card 和年轻代,找出存活的老年代对象。
这个阶段停顿的时间很大程度上取决于上面的并发预处理阶段。
而且可以发现,这是一个追赶的过程,一边标记存活对象,一边用户线程在执行产生垃圾。
5)并发清除
GC线程回收不可达对象。
用户线程不会停止,可能会不断的产生垃圾,不过这个垃圾只能交给下次GC去处理,这个过程产生的垃圾叫做 浮动垃圾。

问7: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,主要分为两块「全局并发标记」和「拷贝存活对象」。
与CMS比较:在 重新标记阶段,CMS是会重新扫描所有线程栈和整个年轻代作为root。
G1:它使用的是一种算法SATB,可以简单理解为,GC开始的时候,会存快照,在并发阶段,
把每一次发生变化的引用的旧值保存下来,然后在重新标记阶段只需要扫描 发生变化的引用,并把存活的对象加入到GC Roots上。

问8:JVM的调优经验?

1)一般来说关系型数据库是先到瓶颈的,所以优先排查是否存在慢sql,导致rds被打满,
这个过程就需要我们评估和检验索引是否合理、是否需要引入分布式缓存、是否需要分库分表等。
2)考虑是否需要扩容(横向扩容和纵向扩容都需要考虑),这个时候我们会考虑会不会硬件能力不足导致系统频繁出问题。
3)代码层面排查并优化
扩容虽然能解决问题,但是不能无止境的扩容,毕竟成本是有上限的,所以就需要我们排查代码,
是否存在资源浪费,逻辑是否可以优化,是否可以并行处理等。
4)JVM层面是否可以优化
JVM是否频繁GC。
5)网络和操作系统层面排查
查看内存|CPU|网络|硬盘读写指标是否正常。
不过一般来说,我们排查问题的时候,大部分到第三步就结束了,因为一般情况下,4|5都是配置好的,满足大多数的需求了。
总的来说的排查逻辑就是:数据库 -> 扩容 -> 代码 -> JVM -> 操作系统。
如果线上出问题,最粗暴的方法,也是最简单的方法,就是扩容,扩容的时,保留现场机器,用于后续分析,但一定记得,要把流量摘掉。
其中大家最喜欢关注的点,就是JVM层面如何优化:
我理解是有几种指标可以帮助我们参考如何配置这些JVM的参数:
吞吐量、停顿时间、垃圾回收频率。
比如:
1)内存区域大小以及相关策略
整块堆内存占用多少、新生代占用多少、老年代占比、Survivor占比、晋升老年代的条件等。
-Xmx:最大堆的最大值
-Xms:堆的初始值,一般线上配置都是一样的,也就是说,堆不会动态去扩容。
-Xmn:年轻代大小
-XX:SurvivorRatio:伊甸区和幸存区的比例等。一般都会用默认的。
公司默认配置好的参数:如下
-Xmx5g -Xms5g -XX:InitialCodeCacheSize=256m -XX:ReservedCodeCacheSize=512m
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -XX:NewRatio=4
-XX:+PrintHeapAtGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -
Xloggc:/tmp/logs/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/logs -Xrunjdwp:transport=dt_socke
2)垃圾回收机制
-XX:+UseG1GC:指定JVM使用G1的垃圾回收器
-XX:MaxGCPauseMillis:设置目标停顿时间
-XX:InitiatingHeapOccupancyPercent:当整个堆内存使用达到一定比例,全局并发标记阶段就会被启动等。
具体情况具体分析,大多数情况,JVM都可以开箱即用。
我们分析的点是如何提高吞吐量,减少停顿时间(也就是延迟时间,Stop the World)
调优点就是内存分配合理,垃圾回收器的相关配置参数了解原理。
3)遇到问题,才会调优,那么就需要排查工具帮助我们明确问题
jps:查看java进程,可以看到基础信息(进程号、主类);帮助我们看当前服务器有多少个java进程在运行。
jstat:java进程统计类(类加载,编译相关信息统计,各个内存区域gc概况和统计);常用于看GC情况。
jinfo:查看和调整java进程的运行参数。
jmap:java进程的内存信息,常用于生成dump文件,查看内存信息。
jstack:线程信息,用于排查死锁。例如:jstack -l 1 >1.txt 导出线程的信息
arthas:生产环境抓包,常用于看请求的方法的入参和出参情况。
4)JIT热点代码的优化
方法内联和逃逸分析。
方法内联:目标方法的代码复制到调用的方法中,避免真实的方法调用。
因为每次方法调用的时候都会生成栈帧(压栈|出栈,记录方法调用位置等),会有一定的性能损耗。
JVM也有参数可以指定:
-XX:MaxFreqInlineSize
-XX:MaxInlineSize
逃逸分析:判断一个对象是都被外部方法引用,或外部线程访问,如果没有,就可以进行优化。
锁消除:对象只在方法内部被访问,不会被别的地方引用,说明是线程安全的,把锁相关代码去掉。
栈上分配:只在方法内部被访问,直接将对象分配在栈中(因为Java默认会把对象分配在堆中,需要GC回收,会耗损性能)
标量替换|分离对象:当程序真正执行的时候,可以不创建这个对象,而是直接创建它的成员变量来替换。
将对象拆分后,可以分配的对象成员变量在栈或寄存器上,原本的对象就无需分配内存

【技能8】

问1:多线程,线程安全解决的思路?

1)原子性操作,考虑automic包下的类是否满足场景使用。
2)可见性的操作,volitaile关键字,这里还涉及的java的内存模型。
3)如果涉及到多线程的控制,比如当前线程触发的条件是否依赖其他线程的结果,可以使用countDownLatch、Semaphore等。
4)如果需要使用集合,使用concurrent包下集合。
5)如果synchronized无法满足,使用lock包的类。
另外如果代码中出现了死锁,一般的解决思路,
1)固定加锁顺序,如hash值确定加锁的先后。
2)缩减加锁范围,等到操作共享变量的时候再加锁。
3)使用可释放的定时锁,(可以根据业务决定释放锁,或者续时)。
其中CAS也可以处理线程安全的问题,但是CAS相当于没有加锁,多个线程都可以直接操作共享资源。而实际去修改的时候才去判断是否可以修改。
但是CAS存在ABA的问题,java提供了AtomicStampedReference类,通过增加版本号的方式来解决ABA的问题。
java中用到CAS思想的,有很多,比如AtomicLong,做累加的是时候实际就是多个线程操作同一个资源,高并发时,只有一个线程是执行成功的,
其他线程都会失败,不断自旋重试,自旋会有瓶颈。所以阿里巴巴开发手册不建议使用AtomicLong,而建议使用LongAdder替换。
LongAdder的思想是把操作的资源分散到数组cell中,每个线程对Cell变量,对value进行原子操作,大大降低了失败次数。
所以在高并发场景下,推荐使用LongAdder。
synchronized是一种互斥锁,一次只能允许一个线程进入被锁住的代码块|方法,
1)synchronized修饰的是实例方法,对应的锁则是对象实例。
2)synchronized修饰的是静态方法,对应锁的则是Class实例。
3)synchronized修饰的是代码块,对应的锁则是传入的synchronized的对象实例。
jdk1.6以后,对synchronized的优化:
在内存中,对象一般是三部分组成,分别是对象头,对象的实际数据和对齐填充,而我们主要关注的则是对象头,
在对象头中我们又重点关注的为Mark Word信息。Mark Word会记录对象锁的信息。又因为每个对象都会有一个与之对应的monitor对象。
monitor对象中存储着当前锁的线程以及等待锁的线程队列。
在1.6之前,synchronized属于重量级锁,线程进入同步代码块时,monitor对象就会把当前进入线程的id进行存储,
设置Mark Word的monitor地址,并把阻塞的线程存储到monitor的等待线程队列中,他的加锁实现是依赖底层操作系统的mutex相关指令实现,
所以会有用户态和内核态之间的切换,性能损耗十分明显。
而1.6以后,引入了偏向锁,轻量级锁,在JVM层面实现加锁逻辑,不依赖底层操作系统,就没有切换的消耗。
所以1.6后Mark Word对锁的状态记录一共有4种:无锁、偏向锁、轻量级锁和重量级锁。
其中偏向锁指的就是JVM认为只有某个线程才会执行同步代码(没有竞争的环境),所以Mark Word会直接记录线程ID,
只要有线程来执行代码了,会对比线程ID是都相等,相等则当前线程直接获取锁,执行同步代码,如果不相等,CAS修改当前线程,
CAS修改成功,获取锁,执行同步代码,由此可见,synchronized是可重入锁。如果CAS修改失败,说明有竞争环境,锁升级为轻量级锁。
轻量级锁状态下,当前线程会把在栈帧下创建LockRecord,LockRecord会把Mark Word的信息拷贝进去,线程执行同步代码块时,
使用CAS修改Mark Word的线程栈帧的LockRecord,修改成功,获得锁,执行同步代码块,失败,则自旋重试,到一定次数后升级为重量级锁。
总结一下就是:
原来只有重量级锁,依赖操作系统mutex指令,需要在用户态和内核态切换,性能耗损明显。
1.6后,偏向锁有Mark Word对比线程ID,轻量级锁是拷贝Mark Word到Lock Record,用CAS+自旋方式获取。
引入偏向锁和轻量级锁,就是为了不同使用场景使用不同的锁,进而提升效率。
另外,锁只有升级没有降级。
1)只有一个线程进入临界区,偏向锁
2)多个线程交替进入临界区,轻量级锁
3)多线程同时进入临界区,重量级锁

问2:CountDownLatch和CyclicBarrier的区别,

假设有50个任务,等这50个任务执行完才能继续下面的流程,怎么设计?
注意面试主要是沟通,信息不足的话,可以问清楚,这是一次谈话,切记信息不足就下结论。
答:
首先可以使用CountDownLatch和CyclicBarrier,不过二者在使用上还是有区别的,具体业务具体分析。
CountDownLatch允许一个或多个线程一直等待,直到这些线程完成它们的操作。
而CyclicBarrier不一样,它往往是当线程到达某状态后,暂停下来等待其他线程,等到所有线程均到达后,才会继续执行。
也就是说,两个等待的主体是不一样的,
CountDownLatch调用await()通常是主线程或调用线程,而CyclicBarrier调用await()是在任务线程调用的。
所以CyclicBarrier阻塞的是任务线程,主线程不受影响。CountDownLatch是会阻塞主线程,等所有任务线程都执行完,主线程才会继续。
接下来对比下二者的实现机制:
首先是CountDownLatch,它是基于AQS实现的,大概说一下,我熟悉的流程,
当我们构建CountDownLatch的时候,传入的值会赋值给AQS的关键变量state,执行countDown()时,就是state-1,
执行await()的时候,就是判断state是否为0,不为0则加入到队列中,
将该线程阻塞掉(除了头结点,因为头结点会一直自旋等待state为0,当state为0时,头结点把剩余的在队列中阻塞的节点也一并唤醒),
再说回来CyclicBarrier上,重点在await(),它的实现机制并不是AQS的state变量,而是通过ReentrantLock+Condition等待唤醒实现的。
在构建CyclicBarrier时,传入的值会赋值给内部维护的count变量,也会赋值给parties变量(这是可以复用的关键)。
每次调用await,会count-1,操作count值是通过ReentrantLock来保证线程安全,如果count不为0,则添加到Condition队列中,
如果count为0,则把节点从condition队列添加至AQS队列中进行全部唤醒,并且将parties的值重新赋值为count值(以此实现复用)。
总结下来就是,
CountDownLatch是基于AQS实现的,会将构造CountDownLatch的入参传递至state,
countDown就是利用CAS将state-1,await就是让头节点一直等待state为0,释放所有等待的线程。
CyclicBarrier则利用ReentrantLock和Condition,自身维护count和parties变量,每次调用await将count-1,
并将线程加入到condition队列中,等待count为0,则将condition队列的节点移交至AQS队列,并全部释放。

设计模式,主要聊项目中用到的
锁、限流,熔断,监控

【技能9】

聊功能,如access_token刷新,封装调用的api 实战经验 OOM溢出 如何排查

【其他补充,结合项目】

注解加反射实现自定义注解,用在方法上,用于前置缓存,请求拦截。
原生Java除了提供了一种元注解,用于修饰注解,常用的元注解有@Retention和@Target。
@Retention注解可以理解为设置注解的生命周期,
@Target表示这个注解可以修饰哪些地方(方法、成员变量、包)
@Retention注解传入的是枚举,有三个常量,SOURCE、CLASS、RUNTIME。

问1:系统需求多变时,如何设计?

大概回答的思路应该是:
1)统一入口
2)找到对应的配置
可配置的任务链(服务编排)
规则引擎(使用脚本灵活改动)
3)执行配置
这里面需要重点关注的点是:抽象、模块化、配置化。
比如需要优化点:是否可以做成配置化、动态替换、插件式、不需要做人工开发,
再来就是规则引擎的实现,是否可以支持动态扩展,比如开闭原则之类的,流程引擎现在是趋势。

问2:http原理:
  • http协议就是客户端和服务端交互的一种通讯格式,所谓的协议就是双方约定好的格式,通过请求和响应来实现交互。

ES + WebSocket 技术应用需要自己补充描述,结合项目场景聊

【曹操出行】面试题

2、如何设计一个高并发的业务系统,以具体业务为例
3、支付或者推送时掉单的分布式事务的方案
4、消息消费端jvm发生oom问题,会是哪些原因导致的
5、redis6.0的优势在哪,为什么提出这些优化点
6、布隆过滤器的误判率来自于什么原因,优化点有哪些

二面:
2、领域设计了解多少,有没有主导过大型领域设计
3、消息在rebalance时如何保证消息有序
4、自己做过最成功的项目整体介绍下,遇到了什么问题,怎么解决的,设计方案的选型怎么确定的
5、看过哪些开源的源码

三面(HR):
1、了解曹操吗,如何看待曹操的业务发展
2、过往跳槽的理由与公司经历
3、自我评价优缺点,背景履历细节询问
4、举例子说明自己的解决问题能力
5、当前薪资情况,入职时间,期望薪资与职

1.jdk8流式写法和普通写法的性能差异
2.影响mysql数据库b+树高度的因素是什么
3.表死锁的原因是什么?
4.数据库优化有哪些
5.线上系统fullgc出现的原因有哪些
6.最近在研究的技术方向有哪些
7.怎么找出系统的性能瓶颈,怎么优化

1.innodb和myisam的selectcount(*)有什么区别
2.如何停止线程,非标记位 比如线程调用其他方法类的方法锁死了 如何停止
3.clickhouse分片存储数据如何实现
4.feign中http接口如何做到复用
5.innodb中的聚簇索引如何保证肯定存在
6.列式数据库,hbase是列式么
7.反射在jdk中如何实现
8.mybatis中定义的mapper接口如何生成实现类

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值