面试官你好,我叫xxx,[我是17毕业的,至今有4年的工作经验了],我的本科专业是计算机科学与技术,自我感觉计算机基础相关知识还是不错,工作后一直从事Java后端开发的相关工作,熟悉的技术栈有Java、SpringBoot、Mybatis、Redis、MySQL、redis等。
最近两份工作分别是教育行业的和
- 专业科班,计算基础还不错,知识体系较全面,数据结构和算法,计算机网络、操作系统、会C语言
- 最近在看Reative响应式编程,基于异步、事件驱动、观察者模式,适合高并发,涉及项目Spring5, gRPC(http2.0,使用protocalbuf定接口跨语言)
设计方案题
设计一个分布式自增id生成服务
你如何设计一个能抗住大流量的系统,说说设计方案
围绕三个方面:高并发、高可用、高性能
高并发:服务拆分可水平扩展、通过服务注册和发现解耦调用
高可用:限流、服务熔断降级、mq削峰、熔断器
高性能:缓存、负载均衡
前端:CDN、DNS负载均衡、页面缓存减少请求次数、动静分离,
架构: 统一的网关入口,限流、mq削峰、只接受请求,路由给下游,三高
数据库层面:分库分表、缓存,sql调优、数据同步、数据一致性
运维:监控告警、灰度发布、快速回滚、CI/CD、网络安全防火墙
没有银弹、架构演变
API网关
分布式相关
微服务优缺点
- 每个微服务模块只做一件事,高内聚,对外隐藏实现
- 可以根据不同模块业务特性,采用不同编程语言开发
- 每个模块根据请求和负载需求,弹性横向扩展
- 增加部署的复杂度
- 分布式事务、数据一致性
- 调用链路复杂,出现问题不好定位
- 服务间通讯成本
eureka的自我保护机制:短时间内接受不到实例的心跳时,会进入自我保护机制,保护注册的实例信息而不删除,故障恢复时自动退出保护模式。
对于数据的一致性是怎么保证的
从两个方面回答:
- 缓存和DB的数据一致性
- 分布式事务
接口幂等性
分布式和单机都会有,产生原因:前端重复提交、失败重试、消息重复消费
查询和删除天然幂等,插入和更新需要保证
问题核心是能够找到一个标识能够区分,两个请求是不是重复请求,即要作相同的工作,如重复扣款、重复扣库存等,然后根据该标识做后续操作
- 全局唯一ID:具体实现上可用分布式锁,防重token。请求前先获取token,后端放redis或db,请求时放在header中,第一次请求时从redis或db中删token,后面获取不到,表示执行过了
在数据库层面:
- 去重表:根据某个唯一字段做主键,建立去重表,如订单号,插入成功则表示第一次,插入失败表示执行过
- 通过数据库的唯一约束,如商品条码唯一,重复插入会限制
- 状态机: 只能从某个状态向前更新
- 数据库乐观锁:
接口如何保证安全
签名:保证数据不被改
加密传输:敏感数据
白名单黑名单
接口幂等
分布式服务接口请求的顺序性如何保证?
- 小流量通过分布式锁,
- 如果用dubbo,可以通过一致性 hash 负载均衡策略,让需要顺序执行的请求落到一台机器上,在服务提供者中构建内存队列顺序执行
- 高并发通过MQ异步接受请求,同时保证顺序,然后依次顺序消费
分布式事务
- 传统的基于XA规范的2PC, 3PC, 支持强一致性,但锁定资源时间长,不适合高并发
- 因此产生了遵循base理论的高并发场景下的柔性事务,有TCC、本地消息表、基于可靠消息的最终一致性、saga将长事务分成多个短事务,每个事务有对应的补偿事务
- Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,
Seata的AT模式本质是2pc,第一阶段,将业务数据和回滚日志在本地事务中提交,第二阶段,快速异步提交,或通过回滚日志反向补偿。
写流程:分支事务各自执行本地事务,提交前要有超时的获取全局锁才能提交
分布式锁
- zk:
- redis:
分布式session
session的理解,有状态,jwt无状态适合无状态,网关校验
- 粘性session:nginx的一致性hash, 配置ip_hash就可
- 服务器session复制,广播给其他节点
- session共享机制,通过redis存储
- session持久化到数据库
zookeeper的作用
- 节点选举/集群管理,zab协议,cap中的cp,强一致性
- 分布式锁
- 统一配置信息管理
- 注册中心
- 发布与订阅
Redis
- 缓存雪崩:短时间内大量缓存失效,
- 缓存穿透:查询大量不存在的key,只能去缓存中取,压力到数据库,布隆过滤器
- 缓存击穿:高热数据keykey过期,状况:redis内存平稳,db挂了,应对策略为预防和监控,伪永久key过期时间,定期延长
String的sds
- 多个结构体,根据字符串buf的大小,表示它长度的类型有多个,buf较短,就用byte/short表示长度,buf较长时用int/long,内存优化到极致
- 减少了分配内存的次数,惰性回收
- 避免缓存区溢出
- 二进制安全
- 字符串长度是O(1)
Zset的跳跃表
- 基于单向链表,
并发环境下,先操作数据库还是先操作缓存?
- 首先读不会存在一致性问题,不一致涉及到更新数据库,下面分情况分析
- 有两个要确定的问题,一是操作缓存是更新还是删除,二是先操作缓存还是DB,另外还有并发请求的问题,一读一写和双写
- 先给出结论:先操作数据库,再删除缓存,但删除可能失败,可通过canal监控binlog,再放进mq通过ack确保删除成功,如果有主从,要监听从,另外如果多从,要监听所有从,都同步到binlog后再删除缓存。
大key优化
读大key超时,删除key阻塞响应
对于单key,可以根据业务拆分成多kv, 采用multiGet,这样在于分拆单次操作的压力,分散压力到各个redis实例上,降低io的影响,减少响应时间
如果可只获取部分value,也可拆分成hash,每部分数据再以小key获取
对于大的hash,可以通过一致性hash算法,在代码层面修改hash的key值,然后达到分散key的效果
删除大key, 评估业务能接受的停顿时长,通过scan获取数据,进行部分删除,3.4redis提供了惰性删除的策略
Spring
Spring线程池
ThreadPoolTaskExecutor 直接使用@Autowired注入使用
Autowried和Resource
- Autowried: 先根据类型,再根据name, 通过AutowriedAnnotationBeanPostProcessor
- Resource: 根据name注入,它是JSR 注解规范定义的,Spring做了兼容,通过CommonAnnotationBeanPostProcessor
循环引用
Bean的大致创建过程:实例化—设置属性—初始化
- 一级缓存:singletonObjects,存放初始化后的单例对象
- 二级缓存:earlySingletonObjects,存放实例化,未完成初始化的单例对象(未完成属性注入的对象)
- 三级缓存:singletonFactories,存放ObjectFactory对象
321转换,单例对象先实例化存在于singletonFactories中,后存在于earlySingletonObjects中,最后初始化完成后放入singletonObjects中。
Spring是如何解决循环依赖问题的?
上面说到Spring是使用三级缓存(Map)解决的循环依赖问题,那具体是怎么做的,看下面的步骤。假设A依赖B,B依赖A,Spring创建A实例的过程如下:
- A依次执行doGetBean方法、依次查询三个缓存是否存在该bean、没有就createBean,实例化完成(早期引用,未完成属性装配),放入三级缓存中,接着执行populateBean方法装配属性,但是发现装配的属性是B对象,走下面步骤。
- 创建B实例,依次执行doGetBean、查询三个缓存、createBean创建实例,接着执行populateBean发现属性中需要A对象。
- 再次调用doGetBean创建A实例,查询三个缓存,在三级缓存singletonFactories得到了A的早期引用(在第一步的时候创建出来了),将它放到二级缓存并移除3级缓存并返回,B完成属性装配,一个完整的对象放到一级缓存singletonObjects中。
- B完成之后就回到A了,A得到完整的B,肯定也完成全部初始化,也存入一级缓存中。
解决了循环依赖问题。这里可能很多初学者很蒙,什么是早期引用,其实学过C语言的指针的话就比较好理解了,这里的引用就是地址,所以先开辟内存而不封装属性,我后面再给它封装属性,那引用得到的永远是这个地址的最新值,所以就可以先给引用地址后面再封装属性值,这个一定要与传值区分开来,单纯的传值和传地址是不一样的
为什么不使用二级缓存?
如果仅仅是解决循环依赖问题,二级缓存也可以,但是如果注入的对象实现了AOP,那么注入到其他bean的时候,只用二级会导致注入的不是最终的代理对象,而是原始的。但是通过三级缓存的ObjectFactory才能实现类最终的代理对象。
一级缓存能不能解决循环依赖问题?
可以解决,但是因为初始化完成和未初始化完成的都放在这个map中,拿到的可能是没有完成初始化的,属性都是空的,直接空指针异常。
事务传播
required
required_new
support: 存在事务加入,不存在就非事务执行
not_support: 强制非事务,原来有事务挂起
never: 强制非事务,原来有就抛错
mandatory: 存在加入,不存在抛错
nested: 嵌入,存在就加入,不存在就按默认
spring类加载机制
SpringBoot
SpringCloud
SpringMVC原理
它的核心实现是一个DispatcherServlet,它只负责接受请求,但不处理请求,作为统一访问点,进行全局的流程控制,
- 然后通过HandlerMapping会把请求映射为HandlerExecutionChain对象(它包含一个Handler处理器对象(Controller 代理对象)、多个HandlerInterceptor拦截器)对象返回
- 再通过HandleAdapter去循环执行拦截器的pre方法,然后执行handler方法,其实就是调用我们的Controller,再循环执行拦截器的post方法,同时返回ModelAndView
- 通过ViewResolver解析到具体的视图,并把数据填充,返回。
Dubbo
Dubbo 和 Spring Cloud 的关系?
Dubbo 是 SOA 时代的产物,它的关注点主要在于服务的调用,流量分发、流量监控和熔断。而 Spring Cloud 诞生于微服务架构时代,考虑的是微服务治理的方方面面,另外由于依托了 Spirng、Spirng Boot 的优势之上,两个框架在开始目标就不一致,Dubbo定位服务治理、Spirng Cloud 是一个生态。
Dubbo 底层是使用 Netty 这样的 NIO 框架,是基于TCP 协议传输的,配合以 Hession 序列化完成 RPC 通信。而 SpringCloud 是基于 Http 协议+Rest 接口调用远程过程的通信,相对来说,Http 请求会有更大的报文,占的带宽也会更多。但是REST相比RPC更为灵活,服务提供方和调用方的依赖只依靠一纸契约,不存在代码级别的强依赖。
Dobbo支持的通讯协议
- dubbo: 适合小数据量高并发的服务调用,及消费者远大于提供者,反之不适合大数据量传送
- gRPC: 2.7开始支持的,带来了Reative响应式编程模式,基于异步、事件驱动、观察者模式,容易实现高并发,Spring5也引入了,RxJava
- thrift: facebook给apache的rpc框架
- redis:
- memcached:
- http: 采用 Spring 的 HttpInvoker 实现
- rest: 从当当合并过来的,实现Java REST API规范
- webservice: 基于apache CXF实现
- JDK的rmi:
- hessian: 底层采用 Http 通讯,采用 Servlet 暴露服务, 缺省内嵌 Jetty 作为服务器实现。
Dubbo支持的序列化协议
- heassion: 默认
- jdk
- json:
- soap:
超时重试
默认会重试2次。Dubbo的路由机制,失败的提供者会把超时的请求路由到其他提供者机器上,而不是本机尝试,
- 消费者:对提供者发起一次请求后,超时时间内没有返回,就会抛一个超时错误,但提供者仍在执行,原理是基于NIO,请求后返回一个ResponseFeture,消费者轮询超市就放弃
- 提供者:执行的方法在超时时间内没执行完,返回超时给消费者
集群容错方案
- Failover Cluster(默认):失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。
- Failfast Cluster:快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
- Failsafe Cluster:失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。
- Failback Cluster:失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
- Forking Cluster:并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks=”2” 来设置最大并行数。
- Broadcast Cluster:广播调用所有提供者,逐个调用,任意一台报错则报错 。通常用于通知所有提供者更新缓存或日志等本地资源信息。
Dubbo集群的负载均衡策略
- 随机:按权重设置随机概率。在一个截面上碰撞的概率高,但调用量越大分布越均匀,而且按概率使用权重后也比较均匀,有利于动态调整提供者权重。(权重可以在dubbo管控台配置)
- 轮循:按公约后的权重设置轮循比率。存在慢的提供者累积请求问题,比如:第二台机器很慢,但没挂,当请求调到第二台时就卡在那,久而久之,所有请求都卡在调到第二台上。
- 最少活跃调用数:相同活跃数的随机,活跃数指调用前后计数差。使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大。
- 一致性Hash:相同参数的请求总是发到同一提供者。当某一台提供者挂时,原本发往该提供者的请求,基于虚拟节点,平摊到其它提供者,不会引起剧烈变动。
请求和响应如何关联
答案是通过调用编号进行串联。
- 发送请求后会返回一个DefaultFuture,它被创建时,会要求传入一个 Request 对象,此时 DefaultFuture 可从 Request 对象中获取调用编号,并将 <调用编号, DefaultFuture 对象> 映射关系存入到静态 Map 中,即 FUTURES。
- 调用线程池中的线程在收到 Response 对象后,会根据 Response 对象中的调用编号到 FUTURES 集合中取出相应的 DefaultFuture 对象,然后再将 Response 对象设置到 DefaultFuture 对象中。
- 最后再唤醒调用get()方法而被阻塞的用户线程,这样用户线程即可从 DefaultFuture 对象中获取调用结果了。
Mybatis
Mybatis是一个半ORM的框架,它内部封装了JDBC
初始化过程
- 根据配置文件信息,生成Configuration对象
- 然后创建SqlSessionFactory, 再创建SqlSession
- SqlSession调用Executor执行SQL
Dao接口的工作原理是什么
Mapper 接口的工作原理是JDK动态代理,Mybatis运行时会使用JDK动态代理为Mapper接口生成代理对象proxy,代理对象会拦截接口方法,根据类的全限定名+方法名,唯一定位到一个MapperStatement并调用执行器执行所代表的sql,然后将sql执行结果返回。
Mapper接口里的方法,是不能重载的,因为是使用 全限名+方法名 的保存和寻找策略。
分页插件的原理是什么?
分页插件的基本原理是使用Mybatis提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的sql,然后重写sql,根据dialect方言,添加对应的物理分页语句和物理分页参数。
简述Mybatis的插件运行原理,以及如何编写一个插件。
Mybatis仅可以编写针对ParameterHandler、ResultSetHandler、StatementHandler、Executor这4种接口的插件,Mybatis使用JDK的动态代理,为需要拦截的接口生成代理对象以实现接口方法拦截功能,每当执行这4种接口对象的方法时,就会进入拦截方法,具体就是InvocationHandler的invoke()方法,当然,只会拦截那些你指定需要拦截的方法。
编写插件:实现Mybatis的Interceptor接口并复写intercept()方法,然后再给插件编写注解,指定要拦截哪一个接口的哪些方法即可,最后在配置文件中配置你编写的插件。
Mybatis是否支持延迟加载?如果支持,它的实现原理是什么?
Mybatis仅支持association关联对象和collection关联集合对象的延迟加载,association指的就是一对一,collection指的就是一对多查询。在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false。
延迟加载的基本原理是,使用CGLIB创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,再将关联信息查出来返回
Mybatis的一级、二级缓存
一级缓存中SqlSession中的缓存,关闭后,将不可用,当创建一个新的SqlSession后,就会创建一个新的Executor,来负责对数据库的各种操作和维护缓存。BaseExecutor中持有一个Cache接口的实现类PerpetualCache
一个新的sqlSession会创建一个新的Executor来执行实际的操作,但是开启二级缓存后,创建Executor时,会用CachingExecutor进行装饰。在使用CachingExecutor执行操作时,会先查询Application级别的二级缓存,如果有直接返回,没有再交给真正的Executor执行查询数据库。之后再放回到缓存中。
Netty/TCP
三次握手/四次挥手
首先要明确这个连接本质是什么?用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。MSS 的值在三次握手时通知对方自己 MSS 的值,然后在两者之间选择一个小值作为 MSS
建立连接前,Server端要先监听在某端口,一般是Client先发起:
- Client先发送一个 SYN 同步包
- Server回复一个 ACK。 由于 TCP 是双工传输,Server 端也会同时向 Client 端发送一个 SYN,申请 Server 向 Client 方向建立连接。
- Client 收到 Server 的 ACK 后,Client 端的连接状态就变成了 ESTABLISHED 状态,同时,Client 向 Server 端发送 ACK,回复 Server 端的 SYN 请求。
TCP 连接的关闭,通信双方都可以先发起,如下过程以Client发起为例:
- Client发送FIN包,表示 Client 端已经没有数据要发送了
- Server 端收到 FIN 后,返回 ACK
- 当 Server 端数据发送完毕后,Server 端会向 Client 端发送 FIN,表示 Server 端也没有数据要发送了
- Client 端收到 Server 端的 FIN 后,回复 ACK,就完成了关闭链接
为什么是三次握手?不是两次、四次?
因为三次握手才能保证双方具有接收和发送的能力。
为什么建连三次握手而断连需要四次?
从交互流程可以看出,无论是建连还是断连,都是需要在两个方向上进行。只不过建连时,Server 端的 SYN 和 ACK 合并为一次发送,而断连时,两个方向上数据发送停止的时间可能不同,所以不能合并发送 FIN 和 ACK。
滑动窗口/流量控制
产生原因:由于接收方和发送方的处理数据的硬件能力和网络环境的复杂性
作用:对发送和接受的流量进行控制
流程:建立连接时会商量一个初始的窗口大小,发送方按照这个大小,顺序的发送数据包,处于发送中即未被接受方回复ACK确认的数据包个数不能超过窗口大小,只有接收方确认了,才能继续发送,另外中间他们也可以通过发送相关请求调整窗口大小。
又于TCP支持全双工,即同时支持发送和接受,所以它有两个滑动窗口:一个用于接收数据,另一个用于发送数据。
SYN 洪水攻击发生的原因
在Linux中建立连接的实现是由两个队列实现的,Client端发送了SYN请求后,Server会回复ACK+SYN请求,并将该链接放入一个半链接的队列中,然后如果收到Client的ACK后,会将该建立完成的链接移动到另一个队列中,SYN 洪水攻击就是在大量的申请半连接状态,充满这个队列,阻碍正常的请求建立链接
粘包/半包
TCP这套协议是面向字节流的协议,即没有界限的一串数据流,但是我们在应用层是要区分消息的,所以在我们发送的消息比较大时,这个大一般指超过发送缓冲区,消息就会被分成多个数据包,依次发送,消息较小时,为了优化传输,可以把几个小包合成一个发送,对应接受端就要相应的消息合起来或拆解。
数据链路层 -> 网络层 -> 传输层 -> 应用层,各层都会产生
- 数据链路层:MSS (maximum segment size)限制,它是 MTU(maximum transmission unit) 刨去 tcp 头和 ip 头后剩余能够作为数据传输的字节数,不同的链路设备的 MTU 值也有所不同,数据包超过时会切分发送,例如以太网MTU默认1500,光纤是4352,本地是65535,MSS 的值在三次握手时通知对方自己 MSS 的值,然后在两者之间选择一个小值作为 MSS
- ip层:nagel算法,粘包
- tcp层:滑动窗口,多个消息堆积在接受缓冲区,粘包,窗口太小只发一部分,就会造成半包
- 应用层:缓冲区大小,太大粘包,太小半包
解决方案:
- 短链接,发一个包建立一次连接,这样连接建立到连接断开之间就是消息的边界,缺点效率太低,如HTTP
- 每一条消息采用固定长度,缺点是,数据包的大小不好把握, 长度定的太大,浪费,长度定的太小,对某些数据包又显得不够
- 每一条消息采用分隔符,例如 \n,缺点需要转义
- 每一条消息分为 head 和 body,head 中包含 body 的长度
JVM
类加载过程
类加载过程即是指JVM虚拟机把.class文件中类信息加载进内存,并进行解析生成对应的class对象的过程,另外,JVM不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次。
这个加载过程是由类加载器来完成的,当它收到了类加载的请求,首先自己并不会去加载这个类,而是将其委派到父类加载器,由父类去加载,如果此时父类无法加载,反馈给子类加载器,子类加载器再尝试去加载。这样保证了我们不会去覆盖系统类。
加载 -> 验证 -> 准备 -> 解析 -> 初始化
- 加载:简单来说,加载指的是把class字节码文件从各个来源通过类加载器装载入内存中。这里有两个重点:
- 字节码来源。一般的加载来源包括从本地路径下编译生成的.class文件,从jar包中的.class文件,从远程网络,以及动态代理实时编译
- 类加载器。一般包括启动类加载器,扩展类加载器,应用类加载器,以及用户的自定义类加载器。
为什么会有自定义类加载器?一方面是由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。
验证: 主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。
准备:主要是为类变量(注意,不是实例变量)分配内存,并且赋予初值。
特别需要注意,初值,不是代码中具体写的初始化的值,而是Java虚拟机根据不同变量类型的默认初始值,但如果是常量则就是常量值
比如8种基本类型的初值,默认为0;引用类型的初值则为null;常量的初值即为代码中设置的值,final static tmp = 456, 那么该阶段tmp的初值就是456解析:将常量池内的符号引用替换为直接引用的过程。两个重点:
- 符号引用。即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。
- 直接引用。可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量
举个例子来说,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。
在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。
- 初始化:这个阶段主要是对类变量初始化,是执行类构造器的过程。换句话说,只对static修饰的变量或语句进行初始化。
如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
Java集合相关
解决哈希冲突的主要方法
- 拉链法: 将key冲突的结点链接在同一个单链表中
- 开放定址法: 也称再散列法,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。
- 线性探测再散列:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。
- 二次探测再散列:冲突发生时,在表的左右进行跳跃式探测,比较灵活。
- 使用多个哈希算法
- 建一个公共溢出区
谈谈你对HashMap的理解
回答顺序:数据结构+继承结构+基本字段+构造方法+添加操作+扩容操作+获取操作+并发问题+与1.8的区别
HashMap在Java1.7和1.8的实现是有不同的,下面先说下1.7:
HashMap 是一种存取高效但不保证有序的常用容器。它的数据结构为“数组+链表”,是解决哈希冲突的产物,也就是我们常说的链地址法。它实现了Map 接口采用K-V 键值对存储数据,并实现了浅拷贝和序列化。
HashMap 的默认初始大小为16,初始化大小必须为2的幂,最大大小为2的30次方。扩容阈值默认为“容量*0.75f”。它的构造函数中并未初始化,真正的初始化都是在第一次添加操作里面实现的。在第一次添加操作中,HashMap 会先判断存储数组有没有初始化,如果没有先进行初始化操作,初始化过程中会取比用户指定的容量大的最近的2 的幂次方数作为数组的初始容量,并更新扩容的阈值。
接着添加操作讲吧。添加操作的执行流程为:
- 先判断有没有初始化
- 再判断传入的key 是否为null,为空保存在table[0] 位置(因为HashMap支持存null值)
- key 不为空就对key 进hash,hash 的结果再& 数组的长度就得到存储的位置
- 如果存储位置为空则创建节点,不为空就说明存在冲突
- 解决冲突HashMap 会先遍历链表,如果有相同的key存在 就更新旧值,否则构建节点添加到链表头
- 添加前还要先判断存储的节点数量是否达到扩容阈值,到达阈值要进行扩容
- 扩容扩2倍,是新建数组所以要先转移节点,转移时都重新计算hash存储位置
- 扩容结束后新插入的元素也得再hash 一遍才能插入。
获取节点的操作和添加差不多,也是
- 先判断是否为空,为空就在table[0] 去找值
- 不为空也是先hash,&数组长度计算下标位置
- 再遍历找相同的key 返回值
线程安全方面:
HashMap 是一个并发不安全的容器,在迭代操作是采用的是fast-fail 机制;在并发添加操作中会出现丢失更新的问题;另外因为采用头插法在并发扩容时会产生环形链表的问题,导致CPU 到达100%,甚至宕机。解决并发问题可以采用
- Java 类库提供的Collections 工具包下的Collections.synchronizedMap()方法,返回一个线程安全的Map
- 或者使用并发包下的 ConcurrentHashMap,ConcurrentHashMap采用分段锁机制实现线程安全
- 使用HashTable (不推荐)
HashMap1.7 和1.8 最大的不同在于1.8 采用了“数组+链表+红黑树”的数据结构,在链表长度超过8 时,把链表转化成红黑树来解决HashMap 因链表变长而查询变慢的问题;还有就是采用了尾插法来避免环形链表的产生。
HashMap死链
1.7及之前,在并发时,多线程同时执行扩容的迁移数据方法中,将冲突的链表转移时,采用了头插法造成死循环
1.8之后,底层数据结构采用了数组+链表或红黑树,迁移链表采用了尾插法,避免死链,链元素超8个会转化成红黑树
ConcurrentHashMap结构
1.7及之前:
- Segament 是一个ConcurrentHashMap内部类,底层结构与 HashMap 一致。另外Segament 继承自 ReentrantLock。Segament数组初始化后不能扩容,决定的并发度。
- 当新元素加入 ConcurrentHashMap 时,首先根据 key hash 值找到相应的 Segament。接着直接对 Segament 上锁,若获取成功,后续操作步骤如同 HashMap。由于锁的存在,Segament 内部操作都是并发安全,同时由于其他 Segament 未被占用,因此可以支持 concurrencyLevel 个线程安全的并发读写。
- size 统计问题:内部有modCount成员变量,即修改计数,每次修改删除元素都会更新它,size方法默认连续两次获取到相同的modCount,就直接返回该值,需注意该值不一定准去,如果连续三次结果都有同,会依次给segament加锁,重新计算结果
Java相关
抽象类和接口
- 抽象类必须要有抽象方法吗? 不需要,抽象类不一定非要有抽象方法。
- 普通类和抽象类有哪些区别? 1.普通类不能包含抽象方法,抽象类可以包含抽象方法。2.抽象类不能直接实例化,普通类可以直接实例化。
- 抽象类能使用 final 修饰吗? 不能,定义抽象类就是让其他类继承的,如果定义为 final 该类就不能被继承,这样彼此就会产生矛盾,所以 final 不能修饰抽象类
- 接口和抽象类有什么区别?
实现:抽象类的子类使用 extends 来继承;接口必须使用 implements 来实现接口。
构造函数:抽象类可以有构造函数;接口不能有。
main 方法:抽象类可以有 main 方法,并且我们能运行它;接口不能有 main 方法。
实现数量:类可以实现很多个接口;但是只能继承一个抽象类。
访问修饰符:接口中的方法默认使用 public 修饰;抽象类中的方法可以是任意访问修饰符。
happendBefore
- 单线程中,书写在前面的代码,一定先于后面代码的执行
- 对volatile的写一定先于对它的读
- 对锁对象的解锁代码一定先与对它的枷锁
- 传递性:
- 线程start方法,一定先于线程其他方法的执行
- 线程调用打断方法,一定先于被打断线程中的代码检测到打断事件发生
- 线程中所有操作都先于线程终止检测
- 对象的初始化先于finalize执行
单例对象会被jvm的gc时回收吗?
不会, 一般单例对象会作为类的静态成员变量,可做为GC Root对象,所以不会
ThreadLocal
volatile原理
当cpu对共享变量写数据时,会将其他cpu缓存中的缓存行失效,然后其他cpu要读数据时现缓存失效了,就会从主存中读
Java1.8的Stream API原理
https://blog.csdn.net/lcgoing/article/details/87918010
- 主要是针对集合的,内部使用了流水线和自动并行,减少了迭代次数,也避免了存储中间结果
- Stream上的所有操作分为两类:中间操作和结束操作,中间操作只是一种标记,只有结束操作才会触发实际计算。中间操作又可以分为无状态的(Stateless)和有状态的(Stateful),无状态中间操作是指元素的处理不受前面元素的影响,而有状态的中间操作必须等到所有元素处理之后才知道最终结果,比如排序是有状态操作,在读取所有元素之前并不能确定排序结果;结束操作又可以分为短路操作和非短路操作,短路操作是指不用处理全部元素就可以返回结果,比如找到第一个满足条件的元素。
- 之所以要进行如此精细的划分,是因为底层对每一种情况的处理方式不同。
仍然考虑上述求最长字符串的程序,一种直白的流水线实现方式是为每一次函数调用都执一次迭代,并将处理中间结果放到某种数据结构中(比如数组,容器等)。具体说来,就是调用filter()方法后立即执行,选出所有以A开头的字符串并放到一个列表list1中,之后让list1传递给mapToInt()方法并立即执行,生成的结果放到list2中,最后遍历list2找出最大的数字作为最终结果。程序的执行流程如如所示:
这样做实现起来非常简单直观,但有两个明显的弊端:
迭代次数多。迭代次数跟函数调用的次数相等。
频繁产生中间结果。每次函数调用都产生一次中间结果,存储开销无法接受。
这些弊端使得效率底下,根本无法接受。如果不使用Stream API我们都知道上述代码该如何在一次迭代中完成
采用这种方式我们不但减少了迭代次数,也避免了存储中间结果,显然这就是流水线,因为我们把三个操作放在了一次迭代当中。只要我们事先知道用户意图,总是能够采用上述方式实现跟Stream API等价的功能,但问题是Stream类库的设计者并不知道用户的意图是什么。如何在无法假设用户行为的前提下实现流水线,是类库的设计者要考虑的问题。
为什么说 Java 语言“编译与解释并存”?
我觉的编译有两层含义:1是Java源代码编译成字节码,2是JIT编译器,会在运行时将热点代码编译成机器码,保存下来下次直接使用。
Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。
在解释执行时,即.class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。
HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation),它是直接将字节码编译成机器码,这样就避免了 JIT 预热等各方面的开销。JDK 支持分层编译和 AOT 协作使用。但是 ,AOT 编译器的编译质量是肯定比不上 JIT 编译器的。
字符型常量和字符串常量的区别?
形式上: 字符常量是单引号引起的一个字符; 字符串常量是双引号引起的 0 个或若干个字符
含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)
占内存大小: 字符常量只占 2 个字节; 字符串常量占若干个字节 (注意: char 在 Java 中占两个字节),
== 和 equals() 和 hashCode()
- 对于基本类型来说,没有equals方法,只能用==, 比较的是值
- 对于引用类型来说,==比较的是引用地址, equals默认情况下,比较的也是引用地址。但是我们可以重写equals方法,来达到比较值的目的。
- 重写equals方法,一定要重写hashcode方法,使两个对象equals返回true时,他们的hashCode相同。 否则会导致使用hash算法的集合出问题。
- 如果两个变量equals返回true, 那么两个变量的hashcode一定相同。 两个变量的hashcode相同,equals方法不一定返回true
hashCode()的作用: 获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置,可以快速找到所需要的对象。hashCode()定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是: Object 的 hashcode 方法是本地方法,也就是用 c 语言或 c++ 实现的,该方法通常用来将对象的 内存地址 转换为整数之后返回。
如何解决hash冲突
hash冲突,又称hash碰撞,指的就是key的hashcode相同
- 链地址法:它的基本思想是将冲突的实体链成一个单向链表,再将其头节点放入通过hashcode定位的哈希表的相应位置,适用于经常添加和删除的场景
- 开放定址法:将key通过hash函数运算后,将值再次hash探测空地址,该hash函数中会有一个变量变化,根据该变量不同,又分为线性探测、二次方探测、随机探测
- 再哈希法:准备多个hash函数,冲突时依次hash直到不冲突,元素不会聚集,但会增加计算时间
- 将哈希表分为基础表和益出表,凡是发生冲突的放入溢出表
final 在 java 中有什么作用?
final作为Java中的关键字可以用于三个地方:用于修饰类、类属性和类方法。
- 修饰类:表示该类不能被继承;
- 修饰方法:表示方法不能被重写;
- 修饰变量:表示变量只能一次赋值以后值不能被修改,但是这里的”不能够被改变”对于不同的数据类型是有不同的含义的。当final修饰的是一个基本数据类型数据时, 这个数据的值在初始化后将不能被改变。当final修饰的是一个引用类型数据时, 也就是修饰一个对象时, 引用在初始化后将永远指向一个内存地址, 不可修改. 但是该内存地址中保存的对象信息, 是可以进行修改的。
Math.round(-1.5) 等于多少?
round:返回四舍五入,负.5小数返回较大整数,如-1.5返回-1。
ceil:返回小数所在两整数间的较大值,如-1.5返回-1。
floor:返回小数所在两整数间的较小值,如-1.5返回-2。
为什么要使用克隆和如何实现对象克隆?
想对一个对象进行处理,又想保存原对象的数据时,可以用克隆
Object 的 clone() 方法是浅拷贝,即如果类中属性有自定义引用类型,只拷贝引用,不拷贝引用指向的对象。
要实现深克隆,有两种方法:
- 实现 Cloneable 接口,如果包含引用类型,其也要实现Cloneable接口,分别重写 clone() 方法,写代码实现克隆
- 实现Serializable 接口,写方法序列化this,再读出来
问题排查
网络安全问题
- DDOS: 分布式拒绝服务攻击,利用TCP三次握手中,Server要建两个队列,一个保存半连接,把该队列占满,正常的服务不能建立
- SQL注入:使用预编译的SQL
- CSRF: Cross-site request forgery跨站请求伪造, 恶意三方网站,拦截拿到cookie,伪造用户请求, Spring Security支持
- CORS:Cross-origin resource sharing跨域资源共享,浏览器强制设置一些Origin, 好让服务端判断是否允许请求,为了解决ajax只能同源的限制
java进程 cpu100%问题排查
- 可能死循环,可能业务代码,也可能是HashMap,查看堆栈,发现程序都卡在了HashMap.get()这个方法上了,重启程序后问题消失。但是过段时间又会来
- 可能内存不够导致gc线程一直在运行回收
排查java cpu100%的问题,大致步骤是固定的,首先找到占用cpu的进程,如果是java进程,则继续查看是哪个线程占用cpu,然后根据线程id从线程栈中找到对应线程栈,到这里,问题基本也就解决了。
- top找到进程
- jstack导出进程的线程栈,如果都是gc线程,表示内存不够用了,要进行内存回收,但是对象都在用,内存不能回收不了,导致一直gc
- jmap导出堆内存,只导出live对象,用jvisualVM查看快照,按照大小排序,找出占用内存最大的类别,居然是字节数组,再定位代码
算法与数据结构
希尔排序
选择一个间隔h 对待排序数组分组,每组采用插入,再减小间隔直到1,整体插入排序时代价很小了
归并排序
将待排序数组分成元素相等的两部分,分别对两部分排序,继续递归分,分到不可再分,再两两合并,合并时排序,最后有序
快速排序
将待排序数组分两部分,保证前一部分都小于后一部分,再对每部分持续分,不可再分时整个数组就有序了。
关键是怎么分,一般是先取一个基准值,用两个指针从头和尾向中间搜索,尾找到一个小于基准值停下,头指针找到一个大于基准值停下,然后两者交换,继续。
广度优先搜索/层序搜索
二叉树的深度优先遍历的非递归的通用做法是采用栈,广度优先遍历的非递归的通用做法是采用队列。