面试题总结

面试题总结

文章目录

Redis

缓存穿透

查询一个在数据库中不存在的数据,所以不会写入缓存,每次查询仍会穿过缓存直达数据库,解决方法是使用布隆过滤器,请求先查询布隆过滤器,布隆过滤器中存在才会查询Redis。Redission提供了布隆过滤器的具体实现。但可能存在误判,可以设置误判率为5%,一般项目中也接受,不至于高并发压倒数据库。

(布隆过滤器:用于检索一个元素是否在一个集合中,底层主要是先初始化一个较大的数组,存放二进制0或1,一开始都是0,当一个key经过3次hash计算,模于数组长度找到下标然后把数组中的0改为1,三个数组位置标明一个key存在)

缓存击穿

对于设置了过期的key,缓存在过期后恰好有大量访问请求,在数据库加载数据到缓存期间,压垮数据库,基于业务场景,满足强一致可以使用互斥锁,当缓存失效后,不立即load db,先使用Redis的sentx设置一个互斥锁,当操作成功返回再进行load db回设缓存,否则重试get缓存方法,满足高可用可以设置逻辑过期,在设置key的时候,设置一个过期时间的字段一起存入缓存中,不给key设置过期时间,查询时判断时间是否过期,过期则开通另一个线程进行数据同步,当前线程正常返回,但数据不是最新。 选择强一致的分布式锁方案,性能不是很高且可能产生死锁,选择高可用的逻辑删除,性能高但是数据同步做不到强一致

缓存雪崩

设置缓存时采用相同的过期时间,导致缓存在某时刻全部失效,请求全部转发到数据库,瞬间压力过重雪崩,击穿是某一个key的缓存,雪崩是多个key的缓存,解决方法是设置不同过期时间的key,可以在原过期时间基础上增加随机值,使过期时间重复率降低

双写一致性

  • 业务中需要保证数据库和redis间高度一致,要求时效性高,采用了读写锁保证强一致性。使用到redisson实现的读写锁,读的时候添加共享锁,保证读写互斥、读读不互斥,写的时候添加排他锁,保证读读、读写都互斥,这样在写数据时就不会有其他线程读数据,避免脏数据,注意读方法和写方法需要使用同一把锁(排他锁如何保证读读、读写互斥:redisson底层使用的也是setnx,保证同时只有一个线程操作锁住方法)
    • 不使用延迟双删:如果是写操作,先把缓存删除,再更新数据库,最后延时删除缓存中的数据,但是延时的时长不太好确定,延时过程中可能出现脏数据,并不能保证强一致性,所以不采用。
  • 业务中的数据同步允许有一定时间的延时,采用canal组件实现数据同步,不用改变业务代码,部署canal服务,把自己伪装成mysql的一个从节点,当mysql更新后,canal读取binlog数据,通过canal的客户端获取数据,更新缓存

数据持久化机制

  • RDB:快照文件,把redis内存存储的数据写入磁盘,当redis宕机恢复数据时,从RDB快照文件中恢复
  • AOF:追加文件,当redis操作写命令时,存储在这个文件中,当redis宕机恢复数据时,从这个文件中再执行一遍命令恢复数据
  • RDB是二进制文件,保证体积小,恢复速度快,但可能会丢数据,AOF恢复速度慢一些,但丢数据的风险小,可设置刷盘策略,当时设置每秒批量写入一次,项目中使用AOF + RDB恢复数据

数据过期删除策略

  • 惰性删除:设置key过期时间后,当需要key时,才会检查是否过期,过期就删除,反之返回该key
  • 定时删除:每隔一段时间,对key进行检,删除过期的key
    • SLOW定时任务,频率默认10hz,每次不超25ms,通过修改配置文件redis.conf的hz选项调整次数
    • FAST频率不固定,每次循环尝试执行,两次间隔不低于2ms,每次耗时不超1ms
  • 过期删除策略:惰性删除 + 定时删除,配合使用

数据淘汰策略

  • 默认是 noeviction,不删除任何数据,内部不直接报错
  • 可在redis配置文件redis.conf配置,一个LRU(最近最少使用,值越大淘汰优先级越高),一个LFU(最少频率使用,值越小淘汰优先级越高)
  • 在项目中设置allkeys-lru,挑选最近最少使用的数据淘汰,把一些经常访问的key留在redis中,保证redis中的数据都是热点数据

分布式锁如何实现

  • 在redis中提供了一个命令setnx(SET if not exists)redis是单线程,用了命令后,只有一个客户端对某个key设置值,在没有过期或删除key的时候,其他客户端不能设置这个key
  • redis的setnx指令不太好控制分布式锁的有效时长,采用的是redis的redisson实现的
  • redisson需手动加锁,可控制锁的失效和等待时间,引入了看门狗机制,当前锁住的业务没执行完时,会隔段时间检查是否持有锁,持有则增加锁的持有时间,直到完成业务释放锁
  • 高并发下,业务执行会很快,在一个线程持有锁时,另一个线程来了不会立马拒绝,而是自旋尝试获取锁,当释放锁后会马上持有锁,性能得到提升
  • redisson实现的分布式锁是可重入的,避免了死锁的产生,内部判断是否是当前线程持有锁,当前线程持有锁就会计数加1,释放锁就会计数减1,存储数据采用hash机构,大key可按照业务定制,小key是当前线程的唯一标识,value是线程重入次数
  • redisson实现的分布式锁不能解决主从一致性问题,当线程1加锁成功,master节点数据同步复制到salve节点,此时当master节点宕机,salve节点被提升为新的master,此时再来个线程2,会在新的master节点加锁成功,会出现两个节点同时持有一把锁问题
    • 解决方法可以使用红锁,会在多个redis实例上创建锁,要求超过半数的节点都成功创建锁,避免线程1加锁成功master宕机,线程2成功加锁到新的master节点的问题
    • 但不建议,因为同时给多个节点加锁,性能会变低,维护成本变高,官方也暂时废弃了红锁

业务保证数据强一致性

redis支持高可用,做到强一致会影响性能,在强一致性要求高的业务中,可使用zookeeper实现分布式锁,保证强一致性

Redis集群

  • 主从复制
  • 哨兵模式
  • 分片集群

主从同步

  • 单节点Redis的并发能力是有上限的,为了进一步提高Redis的并发能力,可搭建主从集群,实现读写分离,一般一主多从,主节点负责写数据,从节点负责读数据。主节点写入数据后把数据同步到从节点中
  • 主从同步分两阶段:全量同步和增量同步
    • 全量同步:指从节点第一次与主节点建立连接时使用全量同步
      1. 从节点请求主节点同步数据,从节点携带自己的replication id和offset偏移量
      2. 主节点判断是否是第一次请求,依据是否为同一个replication id,如不是则说明第一次同步,主节点会把自己的replication id和offset发送给从节点,容主从节点信息保持一致
      3. 同时主节点执行bgsave,生成rdb文件,发送给从节点执行,从节点先将自己的数据清空,然后执行主节点发送的rdb文件,将数据保持一致
      4. 在rdb生成执行期间,依然有请求到了主节点,会以命令的方式记录在缓冲区,这是一个日志文件,最后把这个日志文件发送给从节点,保证主从节点完全一致。后期再同步数据时,都是依赖这个日志文件
    • 增量同步:当从节点服务重启,数据不一致,从节点会请求主节点同步数据,主节点仍会判断是否第一次请求,不是第一次就获取从节点offset,然后主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步

哨兵模式

首先可搭建主从集群,再加上使用redis的哨兵模式,哨兵模式可实现主从集群的自动故障恢复,包含对主从服务的监控、自动故障恢复、通知。如果master故障,sentinel将一个salve提升为master,当故障恢复也以新的master为主,同时sentinel也充当redis客户端服务发现来源,当集群发生故障转移时,将最新信息推送给redis的客户端,一般项目中采用sentinel保证redis的高并发高可用

项目中的redis使用单点还是集群

使用的是主从(1主1从)加哨兵,一般单节点不超过10G内存,若Redis内存不足可给不同服务分配独立的redis主从节点,尽量不分片集群,因为这样维护起来会比较麻烦,且集群之间的心跳检测和数据通信会消耗大量网络带宽,没办法使用lua脚本和事务

redis集群脑裂

使用redis的哨兵,有时由于网络等原因出现脑裂,即:由于redis的master节点和salve节点和sentinel处于不同的网络分区,使得sentinel没能心跳感知到master,会通过选举提升一个salve为master,这样存在两个master,导致客户端在旧的master写入数据,新节点无法同步数据,网络恢复后,旧的master降为salve,再从新master同步数据,导致旧master中的大量数据丢失

解决:redis的配置中设置,设置最少的salve节点个数,如至少有一个从节点才能同步数据,也可设置主从数据复制和同步的延迟时间,达不到要求就拒绝请求,可避免大量数据丢失

分片集群

分片集群主要解决海量数据存储问题,集群中有多个master,每个master存储不同数据,还可设置每个master设置多个salve,继续增大集群的高并发能力,同时每个master之间通过ping监测彼此健康状态,类似于哨兵,当客户端请求可访问集群任意节点,最终都会转发到正确节点

分片集群中数据的存储和读取:引入了哈希槽的概念,有16384个哈希槽,集群中每个主节点绑定一定范围的哈希槽,key通过CRC16校验后对16384取模决定放置在哪个槽,通过槽找到对应的节点进行存储,取值逻辑也是一样

Redis单线程还这么快

  • 完全基于内存,C语言编写
  • 采用单线程,避免不必要的上下文切换可竞争条件
  • 使用I/O多路复用模型,NIO(如bgsave和bgewriteaof都是后台执行,不影响主线程使用,不会产生阻塞)

I/O多路复用模型

  • 利用单线程同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效等待,充分利用CPU资源
  • 目前常采用epoll模式实现I/O多路复用,会通知用户进程Socket就绪同时,把已就绪的Socket写入用户空间,不需挨个遍历Socket判断是否就绪,提升性能
  • Redis的网络模型就是使用I/O多路复用结合事件的处理器应对多个Socket请求,如:提供了连接应答处理器、命令回复处理器、命令请求处理器
  • Redis6.0后,为提高性能,在命令回复处理器使用了多线程处理回复事件,将命令的转换使用多线程,增加命令转换速度,执行命令时仍然是单线程

数据库

定位慢查询

系统中部署了运维的监控系Skywalking,在展示的报表中可看到具体是那个接口慢,再分析是接口的那部分慢,可查看sql的执行时间查看是那条sql出了问题

MySQL中也提供了慢日志查询,可在MySQL的系统配置文件开启慢日志功能,设置SQL超过多少时间会记录到日志文件,项目中配置了超过2秒属于慢查询

SQL慢查询分析

使用explain查看这条sql的执行情况,可通过key、key_len检查是否命中索引,添加了索引判断索引是否失效,通过type查看sql是否有进一步优化空间,是否存在全盘扫描或全索引扫描,通过extra建议来判断,是否存在回表情况,可尝试添加索引或修改返回字段修复

索引

  • 索引帮助MySQL高效获取数据,提高数据检索效率,降低数据库IO成本,通过索引对数据排序,降低数据排序成本,降低CPU消耗
  • MySQL默认存储引擎InnoDB,采用B+树数据结构存储索引,使用B+树,阶数更多,路径更短,磁盘读写代价B+树更低,非叶子节点只存储指针,叶子节点存储数据。便于扫库和区间查询,叶子节点是双向链表
  • 聚簇索引:聚集索引,指数据和索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个,一般情况主键作为聚簇索引
  • 非聚簇索引:值是数据与索引分开存储,B+树的叶子节点保存对应的主键,可有多个,一般自定义的索引就是非聚簇索引
  • 回表查询:回表就是通过二级索引找到对应主键值,通过主键值找到聚集索引中所对应的整行数据,这个过程就是回表
  • 覆盖索引:指select查询使用索引,返回的列必须在索引中全部能找到,而使用id查询,直接走聚集索引,一次索引扫描直接返回数据,性能高

B树和B+树的区别

  • B树:非叶子节点和叶子节点都存储数据,
  • B+树:只在叶子节点存储数据,且叶子节点是双向链表,B+树查找效率更稳定,效率更高

MySQL超大分页处理

超大分页在数据量较大时,使用limit分页查询需要对数据排序,效率低,采用覆盖索引和子查询解决,先分页查询数据的id字段,确定id后用子查询来过滤,只查询id列表的数据就行了,查询id时走的覆盖索引,效率提升

索引创建原则

索引不是越多越好,在表中数据过多时(比如超10万),创建索引,添加索引的字段是查询较为频繁的字段,一般是作为查询条件、排序字段、分组字段等,通常使用复合索引创建,一条sql返回值尽量使用覆盖索引,字段区分度不高放在组合索引后面的字段,字段内容较长可考虑前缀索引,过量添加索引也会导致新增改的速度变慢。

索引失效

说一下自己的经验,索引使用时没遵循最左匹配法则、使用了模糊查询、在添加索引的字段上进行了运算操作或类型转换都会导致索引失效。通常使用explain执行计划来分析是否索引失效。

sql优化经验

sql优化在项目中还是很常见的,如果直说sql优化,可以在建表时候、使用索引、sql语句编写、主从复制、读写分离、分库分表来进行优化。

  • 建表优化:参考阿里巴巴的Java开发手册,如定义字段要结合字段内容选择合适类型,数值选择tinyint、int、bigint等,字符串结合存储内容选择char、varchar、text等
  • 索引优化:添加索引的字段是查询较为频繁的字段,一般是作为查询条件、排序字段、分组字段等
  • sql编写:select务必指明字段名称、避免sql造成索引失效、聚合查询用union all代替union(union会多一次过滤,效率较低)、表关联查询尽量使用inner join,不去使用left join、right join,必须使用也是以小表驱动

事务特性(ACID)

  • 原子性
  • 一致性
  • 隔离性
  • 持久性

并发事务问题

  • 脏读:一个事务访问数据并修改,在未提交到数据库时,另一个事务也访问了这个数据,这时读到的这个未提交数据就是脏数据
  • 不可重复读:一个事务多次读同一数据,另一个事务在此期间也访问该数据并修改,导致一个事务内的两次读数据不一致
  • 幻读:一个事务读取了几行数据,在此期间另一个事务插入了一些数据导致事务一发现多了一些原本不存在的记录

隔离级别

  • 读未提交:RU
  • 读已提交:RC 解决脏读
  • 可重复读:RR 解决脏读、不可重复读。MySQL默认隔离级别
  • 串行化:serializable 解决脏读、不可重复读、幻读

undo log 和 redo log

  • redo log日志记录数据页的物理变化,服务宕机用来同步数据
  • undo log日志记录数据页的逻辑变化,事务回滚,通过逆操作恢复原来的数据
  • redo log保证事务的持久性、undo log保证事务的原子性、一致性

MVCC原理

  • 事务隔离性由锁和MVCC实现
  • MVCC(多版本并发控制),指维护一个数据的多个版本,使读写操作没有冲突
  • 底层实现分为:隐藏字段、undo log日志、readView读视图
  • 隐藏字段:mysql给每个表设置了隐藏字段,trx_id事务id,记录每次操作的事务id,自增。roll_pointer回滚指针,指向上个版本的事务版本记录地址
  • undo log日志:记录回滚日志,存储老版本的数据,内部形成一个版本链,在多事务并行操作某行记录,记录不同事务修改数据版本,通过roll_pointer指针形成链表
  • readView读视图:解决一个事务查询选择版本问题,内部定义了一些匹配规则和当前一些事务id做判断,访问那个版本数据,不同隔离级别快照读不一致。(RC每执行一次快照读生成ReadView,RR只有第一次执行快照读生成ReadView,后续复用)

MySQL主从同步原理

  • MySQL主从复制的核心就是二进制日志(DDL和DML)
    1. 主库事务提交时,把数据变更记录在二进制日志文件bin log中
    2. 从库读取主库的二进制日志文件bin log,写入到从库的中继日志relay log
    3. 从库重做中继日志relay log中事件,将改变反映它自己的数据

分库分表

  • 垂直拆分:微服务开发,每个微服务对应一个数据库,根据业务进行拆分
  • 水平拆分:一开始单库,随着业务拓展,表中存放数据超过1000万,在做了优化后性能依然很慢,使用了水平分库,一开始做了3台服务器对应3个数据库,采用mycat作为数据库中间件,数据按照id自增取模来存取,对于旧数据做清洗工作,按照id取模规则分别存储到各个数据库,可以让数据库分摊存储和读取压力,解决当前性能问题

框架

Spring框架中的单例Bean线程安全嘛

  • 不是线程安全的
  • 当多用户同时请求一个服务时,容器会给每个请求分配一个线程,多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单列状态的修改(单例的成员属性)必须考虑线程同步
  • Spring框架没有为单例bean进行多线程的封装处理,通常项目中使用的Spring bean都是不可变状态,某种程度上说,单例的bean是线程安全的,而bean是多种状态的话,需自行保证线程安全,最浅显的解决方法就是将多态bean的作用域由”Singleton“改为”prototype“

AOP

  • aop是面向切面编程,在spring中用于将与业务无关,却对多个对象产生影响的公共行为和逻辑抽取公共模块复用,降低耦合,如公共日志、事务处理。
  • 使用了aop记录系统的操作日志。思路是:使用aop的环绕通知+切点表达式,表达式找到记录日志的方法,通过环绕通知的参数获取请求方法的参数,比如类信息、方法信息、注解、请求方式等,获取到这些参数以后,保存到数据库

Spring中的事务

  • spring实现事务本质是aop完成的,对方法前后进行拦截,在执行方法前开启事务,在执行目标方法后根据执行情况提交或回滚事务
  • 事务失效场景:
    • 如果方法上异常捕获处理,自己处理了异常,没抛出,就导致事务失效,一般处理了异常后把异常抛出
    • 如果方法抛出检查异常,报错会导致事务失效,在spring事务的注解上,即@Transactional配置rollbackForException,这样不管什么异常都会回滚事务
    • 方法上不是public修饰也会导致事务失效

Spring bean的生命周期

步骤挺多的,之前看过源码,大概流程是:

  • 首先通过一个非常重要的类,叫做BeanDefinition获取bean的定义信息,这里封装了bean的所有信息,包含:类的全路径、是否延迟加载、是否是单例等等
  • 创建bean时,第一步:调用构造函数实例化bean
  • 第二步:bean的依赖注入,如set方法注入,@Autowire都是这一步完成
  • 第三步:处理Aware接口,某个bean实现了Aware接口就会重写方法执行
  • 第四步:bean的后置处理器BeanPostProcessor,前置处理器
  • 第五步:初始化方法,实现接口InitializingBean或自定义方法init-method标签或@PostContruct
  • 第六步:执行bean的后置处理器BeanPostProcessor,对bean进行增强,可能产生代理对象
  • 第七步:销毁bean

Spring循环引用

循环依赖:即两个或两个以上的bean互相持有对方,最终形成闭环,如A依赖B,B依赖A

  • Spring根据三级缓存解决大部分循环依赖
    • 一级缓存:单例池,缓存已经历了完整生命周期,初始化完成bean对象
    • 二级缓存:缓存早期的bean对象(生命周期没走完),实例化,但未初始化
    • 三级缓存:缓存的ObjectFactory,表示对象工厂,用来创建某个对象
  • 先实例A对象,存入三级缓存,A在初始化时需要用到B对象,就去让B实例化,B实例化存入三级缓存,B同样需要A,通过三级缓存生成A对象到二级缓存,(A可能是普通对象、代理对象),B通过二级缓存中的A成功注入,存入到一级缓存中,回到A对象初始化,B对象已经创建完成可直接注入B,A创建成功存入一级缓存。二级缓存中的临时对象A清楚
  • 构造方法出现了循环依赖,因为bean生命周期中构造函数第一个执行,框架不能解决构造函数依赖注入,可使用@Lazy懒加载,需要对象再进行bean对象创建

SpringMVC执行流程

  • 用户发送请求到前端控制器DispatcherServlet,这是一个调度中心
  • DispatcherServlet收到请求调用HandlerMapping(处理器映射器)
  • HandlerMapping找到具体处理器,生成处理器对象及处理器拦截器,再返回给DispatcherServlet
  • DispatcherServlet调用HandlerAdapter(处理器适配器)
  • HandlerAdapter经过适配调用具体的处理器(Handler / Controller)
  • Controller执行完返回 ModelAndView,HandlerAdapter将这个ModleAndView返回给DispatcherServlet
  • DispatcherServlet将ModelAndView传给ViewReslover(视图解析器)
  • ViewReslover解析返回具体View(视图),DispatcherServlet根据View渲染视图返回响应用户
  • 前后端分离开发,没有视图,是handler中使用Response直接结果返回

Spring自动装配机制

SpringBoot中的引导类有一个注解@SpringBootApplication,这是对三个注解的封装,分别是SpringBootConfigurationEnableAutoConfigurationComponentScan

EnableAutoConfiguration实现自动化配置的核心注解,通过@Import注解导入对应的配置选择器,关键是内部就是读取了该项目和该项目引用的Jar包的classpath路径下META-INF/spring-factories文件中配置的类的全类名

这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要导入到Spring容器中

条件判断会像@ConditionalOnClass这样的注解,判断是否对应的class文件,如果有则加载该类,把配置类的所有Bean放入spring容器中使用

Spring常见注解

  • 声明bean:@Component、@Service、@Repository、@Controller
  • 依赖注入:@Autowired、@Qualifier、@Resourse
  • 设置作用域:@Scope
  • Spring配置;@Configuration、@ComponentScan、@Bean
  • aop相关:@Aspect、@Before、@After、@Around、@Pointcut

Spring MVC常见注解

  • @RequestMapping:映射请求路径
  • @RequestBody:接收http请求的json数据,将json转换为java对象
  • @RequestParam:指定请求参数名称
  • @PathViriable:从请求路径下获取请求参数,传递给方法的形参
  • @ResponseBody:将controller方法返回对象转化为json对象响应给客户端
  • @RequestHeader:获取指定的请求头数据

Spring Boot常见注解

  • @SpringBootApplication:
    • @SpringBootConfiguration:实现配置文件功能
    • @EnableAutoConfiguration:打开自动配置功能
    • @ComponentScan:Spring组件扫描
  • @SpringBootTest

MyBatis执行流程

  1. 读取MyBatis配置文件,mybatis-config.xml加载运行环境和映射文件
  2. 构造会话工厂SqlSessionFactory,单例且一个项目只需要一个,一般由spring管理
  3. 会话工厂创建SqlSession对象,包含执行SQL语句的所有方法
  4. 操作数据库接口,Executor执行器,同时负责查询缓存的维护
  5. Executor接口的执行方法中有一个MappedStatement类型的参数,封装了映射信息
  6. 输入参数映射
  7. 输出结果映射

MyBatis支持延迟加载

  • 支持,延迟加载:再需要用到数据时才进行加载,不需要用到数据时不加载
  • MyBatis支持一对一、一对多关联集合对象的延迟加载
  • MyBatis中延迟加载默认关闭,可配置启用,lazyLoadingEnabled = true
  • 延迟加载底层原理:底层使用CGLIB动态代理完成
    1. 使用CGLIB创建目标对象的代理对象,目标对象就是开启了延迟加载的mapper
    2. 当调用目标方法时,进入拦截器invoke方法,发现目标方法是null值,再执行sql查询
    3. 获取数据后,调用set方法设置属性值,再继续查询目标方法,就是值了

MyBatis的一二级缓存

  • 提高检索效率,避免每次都查询数据库
  • 一级缓存:默认开启,SqlSession级别缓存,也叫本地缓存,MyBatis将查询出的数据存入本地缓存,后续sql语句命中缓存直接从本地缓存中读取,但无法实现跨SqlSession级别缓存
  • 二级缓存:手动打开,多个用户查询数据,只要有任何一个SqlSession拿到数据,就会放入二级缓存,其他SqlSession从二级缓存加载数据
  • 当某一作用域进行增删改后,默认该作用域下所有select中的缓存将被clear
  • 什么时候用一级,什么时候用二级:
    • (一级缓存实现原理:在SqlSession中有个叫Executor的执行器对象,每个Executor都有个LocalCache对象,用户发起查询,MyBatis根据执行语句在LocalCache中查询,没命中再去查询数据库,并写入LocalCache,否则直接返回,一级缓存只在SqlSession级别,多个SqlSession或分布式环境下可能导致数据库写操作出现脏数据,也就用到二级缓存)
    • (二级缓存实现原理:使用了CachingExecutor对象来对Executor进行封装,即装饰器模式,进入一级缓存之前先通过CachingExecutor进行二级缓存查询,开启二级缓存可被多个SqlSession共享,是个全局缓存)
  • 先查二级缓存,再查一级缓存,最后到数据库
  • 二级缓存相对一级缓存实现SqlSession之间的缓存数据共享,缓存粒度控制在Name space级别,通过Cache接口实现类的不同组合,对Cache的可控性更强

集合

Java常见集合

  • java提供了大量的集合框架,主要分两类:Collection单列集合、Map双列集合
  • Collection:两个子接口List、Set,
    • List:常用实现类 ArrayList 和 LinkedList,
    • Set:常用t实现类 HashSet 和 TreeSet
  • map:常用实现类 HashMap、TreeMap、ConcurrentHashMap

ArrayList

  • add方法:
    1. 确保数据已使用长度(size)加1之后足够存入下一个数据
    2. 计算数组的容量,如果当数组已使用长度 +1 后大于当前数组的长度,则调用grow方法扩容(1.5倍)
    3. 确保新增的数据有地方存储后,则将新元素添加到位于size的位置
    4. 返回添加的成功布尔值
  • ArrayList list=new ArrayList(10)中的list扩容了几次
    • ArrayList源码中提供了带参的构造方法,这个参数指定了集合初始长度,没有扩容
  • 数组和List之间的转换
    • 数组转List:使用JDK工具类Arrays,里面的asList方法
    • LIst转数组:使用List的toArray方法,无参toArray返回Object数组,传入初始化长度的数组对象返回该对象数组
    • Arrays.asList转换list之后,修改数组内容,list会受影响,因为底层使用Arrays类中的一个内部类ArrayList来构造集合,传入这个集合进行包装,最终指向的是同一个内存地址
    • list用toArray转换数组后,修改了list内容,数组不会影响,调用了toArray,底层进行了数组的拷贝,跟原来的元素没关系
  • ArrayList和LinkedList的区别
    • 底层结构:ArrayList是动态数组,LinkedList是双向链表
    • 操作效率:
      • 查询:ArrayList可按索引查询,LinkedList不支持下标查询
      • 增删:ArrayList尾部增删O(1),其他地方O(n),LinkedList头尾增删O(1),其他地方O(n)
    • 内存占用:ArrayList内存连续,节省内存,LinkedList需要存储数据和指针更占内存
  • ArrayList和LinkedList线程不安全
    • 优先在方法中使用,定义为局部变量,这样不会出现线程安全问题
    • 在成员变量中使用,可使用线程安全的集合代替
      • ArrayList可通过Collections的synchronizedList方法将ArrayList转换成线程安全容器后使用
      • LinkedList换成ConcurrentLinkedQueue使用

Map

  • HashMap的实现原理
    1. 底层使用hash表数据,数组 + 链表 / 红黑树
    2. 添加数据时,计算key值确定元素在数组中的下标,key相同则替换,不同则存入链表或红黑树中
    3. 获取数据通过key的hash计算数组下标获取元素
  • HashMap的jdk1.7和jdk1.8的区别
    • jdk1.8之前:采用拉链法,数组 + 链表
    • jdk1.8之后:采用数组 + 链表 + 红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树
  • put方法的具体流程
    1. 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
    2. 根据键值key计算hash值得到数组索引
    3. 判断table[i] == null,条件成立,直接新建节点添加
    4. 如果table[i] == null,不成立
      1. 判断table[i]的首个元素是否和key一样,相同则直接覆盖value
      2. 判断table[i]是否为treeNode,即table[i]是否是红黑树,是则直接在树中插入键值对
      3. 遍历table[i],链表尾部插入数据,判断链表长度是否大于8,大于8则把链表转为红黑树,在红黑树中执行插入操作,遍历过程中若发现key存在则直接覆盖
    5. 插入成功,判断实际存在的键值对数量size是否超过最大容量threeshold(数组长度 * 0.75)超过则扩容
  • HashMap的实现原理的扩容机制
    • 添加元素或初始化时,调用resize进行扩容,第一次添加数据初始化数组长度为16,以后达到扩容阈值(数组长度 * 0.75)进行扩容
    • 每次扩容是扩容之前容量的2倍
    • 扩容之后,新创建一个数组,把老数组中数据挪到新数组
    • 没有hash冲突的节点。直接使用e.hash & (newCap - 1)计算新数组的索引位置
    • 是红黑树则走红黑树添加
    • 是链表则遍历链表。可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素位置要么还在原始位置,要么移动到原始位置 + 增加数组大小的位置上
  • HashMap的寻址算法
    • 哈希方法首先计算key的hashCode值,通过这个hash值右移16位后的二进制进行按位异或运算得到最后的hash值
    • 在putValue方法中,计算数组下标时,使用hash值与数组长度取模得到存储数据下标的位置。hashmap为了性能更好,没直接取模,而是使用数组长度 -1得到值,按位与运算hash值,最终得到数组位置
  • HashMap的数组长度一定是2次幂
    • 计算索引时效率更高,2的n次幂可使用位与运算代替取模
    • 扩容时重新计算索引效率更高,进行扩容会进行判断hash值按位与运算旧数组长度是否等于0,等于0则把元素留在原位置,否则新位置等于旧位置的下标 + 旧数组长度
  • HashMap在1.7情况下的多线程死循环
    • jdk1.7的数据结构是:数组 + 链表
    • 数组进行扩容,链表使用的头插法,进行数据迁移过程中可能导致死循环
      • 当前两个线程,线程一读取当前hashmap数据,数据中一个链表,准备扩容时线程二介入
      • 线程二也读取hashmap,直接扩容,头插法,链表顺序颠倒,线程二结束
      • 线程一再继续执行时会出现死循环,即线程一先将A移入新链表,再将B插入到链头,由于另一个线程中B指向A,所以现在BAB形成循环
    • 在jdk1.8将扩容算法做了调整,使用尾插法,避免死循环
  • HashMap不是线程安全的,可采用ConcurrentHashMap
  • HashSet和HashMap区别
    • HashSet底层就是用HashMap实现存储的,HashSet封装了一系列HashMap方法,在HashMap的key传入值,在value有个占位符,判断是否重复
  • Hashtable和HashMap区别
    • 数据结构:Hashtable是数组 + 链表,HashMap在1.8后是数组 + 链表 + 红黑树
    • 存储数据:Hashtable不允许存储null,HashMap可以
    • hash算法:Hashtable本地修饰的hashcode值,HashMap经过二次hash
    • 扩容方式:Hashtable是当前容量翻倍 + 1,HashMap是当前容量翻倍
    • 线程安全:Hashtable是线程安全的,HashMap是线程不安全的
    • 实际开发中,不建议使用Hashtable,在多线程环境下可以使用ConcurrentHashMap类

JUC

并发和并行

  • 并发:两个及两个以上的作业同一时间间隔内执行
  • 并行:两个及两个以上的作业同一时刻执行

线程和进程

  • 进程:正在运行程序的实例,进程包含线程,每个线程执行不同任务
  • 线程:不同线程使用不同内存空间,进程下所有线程可共享捏村空间

创建线程

  • 继承Thread类
  • 实现runnable接口
  • 实现callable接口
  • 线程池创建

runnable 和 callable 两个接口创建线程区别

  • 返回值:
    • runnable接口:run方法无返回值
    • callable接口:call方法有返回值,是个泛型,和Future、FutureTask配合用来获取异步执行结果
  • 异常处理:
    • runnable接口:run方法只能抛出运行时异常,也无法捕获处理
    • callable接口:call方法允许抛出异常,可以获取异常信息
  • 实际开发中,如果需要拿到执行结果,需要使用callable接口创建线程,调用FutureTask.get()得到返回值,此方法会阻塞主进程继续往下执行,不调用不会阻塞

线程包含状态

  • 新建:线程被创建但未调用start方法
  • 可运行:调用了start方法
  • 等待:调用了wait方法
  • 超时等待:**调用了sleep(long)**方法
  • 阻塞:线程获取锁失败,进入Monitor的阻塞队列
  • 终结:线程内代码执行完毕

sleep() 和 wait() 的区别

  • 方法归属:sleep()是Thread的静态方法,而wait(),是Object成员方法,每个对象都有
  • 线程醒来时机:sleep()会等待相应毫秒后醒来,wait()需要被notify()唤醒,不唤醒就一直等待
  • 锁特性:wait()调用先获取wait对象的锁,而sleep()无此限制,wait()方法执行后会释放对象锁,允许其他线程获取该对象锁,sleep在synchronized代码块中执行,并不会释放对象锁

新建T1、T2、T3三个线程,如何保证按顺序执行

  • 多线程中有多种方法让线程按特定顺序执行,可用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成后该线程继续执行
  • 使用join方法,T3调用T2,T2调用T1,这样就能确保T1会先完成而T3最后完成

线程run()和start()的区别

  • start方法:用来启动线程,通过线程调用run方法执行run方法中所定义的逻辑代码,start方法只能被调用一次
  • run方法:封装了要被线程执行的代码,可被调用多次

停止一个正在运行的线程

  • 使用退出标志,使线程正常退出,即当run方法完成后线程终止,加一个标记
  • 使用线程的stop方法强行终止,一般不推荐,已作废
  • 使用线程的inter’rupt方法中断线程,内部也是使用中断标志中断线程

synchronized关键字的底层原理

synchronized底层使用的JVM级别中的Monitor来决定当前线程是否获得了锁,如果获取锁,没释放锁之前,其他线程都不能获得锁,synchronized属于悲观锁

Monitor

  • monitor对象存在于每个对象的对象头中,synchronized锁通过这种方式获取锁,这也是为什么Java中任意对象可作为锁的原因
  • monitor内部维护了三个变量:
    • WaitSet:保存处于Waiting状态的线程
    • EntryList:保存处于Blocked状态的线程
    • Owner:持有锁的线程
  • 只有一个线程获得到标志,就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner,在上锁过程中,若其他线程来抢锁,则进入EntryList进行阻塞,当获得锁的线程执行完了,释放锁,会唤醒EntryList中等待的线程竞争锁,竞争的时候是非公平的

synchronized锁升级的情况

  • Java中的synchronized有无锁、偏向锁、轻量级锁、重量级锁,分别对应锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁。
  • 重量级锁:底层使用Monitor实现,里面涉及到用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
  • 轻量级锁:线程加锁的时间是错开的,即没有竞争,可使用轻量级锁优化,轻量级锁修改对象头的锁标志,相对重量级锁性能提升很多,每次修改都是CAS操作,保证原子性
  • 偏向锁:一段很长时间内都只被一个线程使用锁,可使用偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需判断markword中是否时自己的线程id即可,不是开销相对较大的CAS命令,一旦锁发生了竞争,都会升级为重量级锁

synchronized在高并发下的性能不高,在项目中如何控制使用锁(ReentranLock)

  • 在高并发下,可以采用ReentrantLock来加锁
  • ReentrantLock的使用方法和底层原理
    • ReentrantLock是可重入锁,调用lock方法获取锁后,再次调用lock,不会阻塞,内部直接增加重入次数,标识线程已经重复获取一把锁而不需等待锁释放
    • ReentrantLock是属于juv报下的类,属于api层面的锁,跟synchronized一样,也是悲观锁,通过lock()获取锁,unlock()释放锁
    • 底层实现原理是利用:CAS + AQS队列实现,支持公平锁和非公平锁,构造方法接受可选公平参数,设置为true则表示公平锁,默认非公平锁,因为非公平锁效率更高

CAS和AQS

CAS:

  • Compare And Swap(比较于交换),它体现了一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性
  • 使用场景:AQS框架、AtomicXXX类
  • 操作共享变量时使用自旋锁,效率上更高,底层调用Unsafe类中方法,由操作系统提供,其他语言实现

AQS:

  • 是jdk提供的类,AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架
  • 内部有一个属性state属性表示资源的状态,默认state为0,即没有获取锁,等于1表明获取到了锁,通过CAS机制设置state状态,其内部提供了基于FIFO的等待队列,是一个双向列表,其中tail指向队列最后一个元素,head指向队列中最久的一个元素。ReentrantLock底层实现的就是一个AQS

synchronized和Lock有什么区别

  • 语法层面:
    • synchronized是关键字,源码在JVM中,用C++实现,退出同步代码块锁会自动释放
    • lock是接口,源码由jdk提供,用java语言实现,需要手动调用unlock方法释放锁
  • 功能层面:
    • 二者都是悲观锁,都具备互斥、同步、锁重入功能,lock提供许多synchronized不具备的功能,如:获取等待状态、公平锁、可打断、可超时、多条件变量。同时lock可实现不同场景,如ReentrantLock、ReentrantReadWriteLock
  • 性能层面:
    • 没有竞争时:synchronized做了很多优化,如:偏向锁、轻量级锁,性能不错
    • 竞争激烈时:Lock的实现提供更好的性能

死锁

  • 一个线程需要同时获取多把锁,容易死锁,如T1线程获得A对象锁,接下来想获取B对象锁,T2线程获得B对象锁,接下来想获取A对象锁,两线程互相持锁等待对方线程释放锁,产生死锁
  • 通过jdk自动工具,先通过jps查看当前java运行的进程id,通过jstack查看进程id,展示死锁的问题。定位代码具体行号范围,找到对应代码进行排查

volatile

  • 关键字,可修饰成员变量、类的静态成员变量
  • 保证不同线程对变量操作时的可见性:即线程修改某变量值,对其他线程是立即可见的,强制将修改的写入内存
  • 禁止指令重排序:可保证代码执行有序性,添加了内存屏障,通过插入屏障禁止内存屏障前后指令执行指令排序优化

ConcurrentHashMap的原理

线程安全的高效Map集合,jdk1.7采用分段锁 + 链表实现,jdk1.8采用HashMap1.8结构一样

  • jdk1.7中ConcurrentHashMap里包含一个Segment数组,其结构和HashMap类似,是种数组和链表的结构,一个Segment包含一个HashEntry数组,每个HashEntry是个链表结构,每个Segment守护一个HashEntry数组中的元素, 当对HashEntry数组的数据进行修改,必须先获得Segment锁
  • jdk1.8种放弃了Segment臃肿的设计,采用Node + CAS + Synchronized保证并发安全,synchronized只锁定当前链表或红黑树的首节点,只要hash不冲突,就不会产生并发,效率提升

线程池

  • 创建线程池方式:
    • newCachedThreadPool创建一个可缓存线程池,线程池长度超过处理需要,可灵活回收空闲线程,无可回收则新建线程
    • newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出线程会在队列中等待
    • newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务
    • newSingleThreadExecutor 创建一个单线程化的线程池,只创建唯一工作线程执行任务,保证所有任务按指定顺序执行
  • 线程池中7个核心参数:
    • corePoolSize:核心线程数,池中保留最多线程数
    • maximumPoolSize:最大线程数,核心线程 + 救急线程
    • keepAliveTime:生存时间,救急线程的生存时间,生存时间内没新任务线程资源会释放
    • unit:时间单位,救急线程的生存时间单位
    • workQueue:没有空闲核心线程,新来的任务加入到此队列,队列满了创建救急线程
    • threadFactory:定制线程对象的创建,设置线程名字、是否是守护线程
    • handler:拒绝策略,当所有线程繁忙、workQueue放慢,触发拒绝策略
  • 拒绝策略:
    • 抛异常(默认)
    • 调用者执行任务
    • 丢弃当前任务
    • 丢弃最早排队任务
  • 确定核心线程数
    • 根据当时部署的服务器的CPU核数决定,规则是CPU核数 + 1为最终核心线程数
  • 线程池的执行原理
    • 先判断线程池里的核心线程是否都在执行任务,不是则创建一个新的工作线程来执行任务。若核心线程都执行任务则线程池判断工作队列是否已满,工作队列没满则将新的提交的任务存储在工作队列中,队列已满则判断线程池里的线程是否都处工作状态,没有则创建新的工作线程执行任务,都满了则交给拒绝策略处理
  • 不建议使用Executors创建线程池
    • 这在阿里开发手册中也提到了,主要因为使用Executors创建线程池,允许请求队列默认长度为Integer.MAX_VALUE,可能堆积大量请求,导致OOM,一般推荐ThreadPoolExecutor创建线程池,可明确规定线程池的参数,避免资源耗尽

控制某一个方法允许并发访问线程的数量

  • 在jdk中提供了一个Semaphore类,提供了两个方法
    • semaphore.acquire() 请求信号量,可限制线程的个数,若为-1表示已经用完了信号量,其他线程阻塞了
    • semaphore.release()释放信号量,此时信号量的个数+1

Java程序在多线程的情况下执行安全

jdk提供了很多类帮助解决多线程安全的问题

  • jdk Atomic开头的原子类、synchronized、Lock,可解决原子性问题
  • synchronized、volatile、Lock可以解决可见性问题
  • Happens-Before可解决有序性问题

多线程应用场景

  • 文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),为不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用

ThreadLocal理解

  • ThreadLocal主要功能有两个,可实现资源对象的线程隔离,让每个线程各用各的资源,避免线程安全问题,实现线程内资源共享
  • ThreadLocal底层原理:内部维护了ThreadLocalMap类型的成员变量,用来存储资源对象,调用set方法,以ThreadLocal自己为key,资源对象为value,放入当前线程的ThreadLocalMap集合中。调用get方法,以ThreadLocal自己为key,到当前线程中查找关联的资源值,调用remove方法,以ThreadLocal自己为key,移除当前线程关联资源值
  • ThreadLocal导致内存溢出:ThreadLocalMap 中的 key 被设计为弱引用,GC调用释放key,但不会释放value,因为value是强引用,建议主动的remove 释放 key

JVM

JVM由那些部分组成,运行流程

JVM中共有四大部分,分别是ClassLoader(类加载器)、Runtime Data Area(运行时数据区,内存分区)、Execution Engine(执行引擎)、Native Method Library(本地库接口)

运行流程:

  • 类加载器(ClassLoader)把Java代码转换为字节码
  • 运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,不能直接交给底层系统执行,而是由执行引擎运行
  • 执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能

JVM运行时数据区

运行时数据区包含:堆、方法区、栈、本地方法栈、程序计数器这几部分,每个功能作用不一样

  • 堆:解决对象实例存储问题,垃圾回收器管理的主要区域
  • 方法区:可认为是堆的一部分,用于存储已被虚拟机加载的信息、常量、静态变量、即时编译器编译后的代码
  • 栈:解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息
  • 本地方法栈:与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码接口
  • 程序计数器(PC寄存器):存放的是当前线程所执行的字节码行数,JVM工作时通过改变计数器的值来选取下一个需要执行的字节码指令

程序计数器

java虚拟机对多线程通过线程轮换并分配线程执行时间,任何时间点,一个处理器只会处理执行一个线程,若当前被执行的线程所分配的执行时间用完就会挂起,处理器会切换到另一个线程进行执行,这个线程执行时间用完会再来执行这个被挂起线程,而程序计数器在来回切换线程中记录他上一次执行的行号,然后接着继续往下执行

Java堆

堆:即线程中共享的区域,用来保存对象实例、数组等,当堆中没有内存空间可分配给实例,也无法扩展时,抛出OutOfMemoryError异常。堆内存在年轻代和老年代

  • Young区被划分为三部分,Eden区和两个相同大小的Survivor区,Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留作垃圾收集时复制对象用,在Eden区变满时,GC就存活的对象移到空闲的Survivor区间,根据JVM策略,在经过几次垃圾收集后,仍然存活于Survivor对象被移动到老年代
  • Tenured区保存一些生命周期长的对象,一般是比较老的对象,在Young复制转移一定次数后,对象会转移到Tenured区

方法区

与虚拟机栈类似,本地方法栈为虚拟机执行本地方法时提供服务的,不需要进行GC,本地方法一般是由其他语言编写。

Java栈

虚拟机栈描述方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行同时创建栈帧,保存执行方法时的局部变量、动态连接信息、方法返回地址信息等,方法开始执行时会进栈,方法执行完会出栈(清空数据)所以这块区域不需进行GC

堆和栈区别

  • 栈内存一般用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的
  • 堆会GC回收,栈不会
  • 栈内存是线程私有的,堆内存是线程公有的
  • 两者异常不同,栈内存或堆内存不足都会抛异常,
    • 栈空间不足:java.lang.StackOverFlowError
    • 堆空间不足:java.lang.OutOfMemoryError

直接内存

堆外内存,线程共享的区域,Java8之前有个永久代,用永久代实现了JVM规范定义了方法区功能,主要存储类的信息、常量、静态常量,即时编译器编译后代码等,这部分由于在堆中实现,受GC管理,由于永久代有 -XX:MaxPermSize的上限,所以如果大量动态生成(将类信息放入永久代)容易造成OOM, Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能。

类加载器

JVM只运行二进制文件, 而类加载器(ClassLoader)主要作用就是将字节码文件加载到JVM,让Java程序能够启动起来

  • 启动类加载器:BootSrap ClassLoader
  • 扩展类加载器:ExtClassLoader
  • 应用类加载器:AppClassLoader
  • 自定义类加载器:自定义

类加载执行过程

加载、连接(验证、准备、解析)、初始化、使用、卸载

  • 加载:查找和导入class文件
  • 验证:保证加载类的准确性
  • 准备:为类变量分配内存并设置类变量初始值
  • 解析:把类中的符号引用转换为直接引用
  • 初始化:对类的静态变量,静态代码块执行初始化操作
  • 使用:JVM开始从入口方法开始执行用户的程序代码
  • 卸载:当用户程序代码执行完毕,JVM便开始销毁创建Class对象,最后负责运行JVM也退出内存

双亲委派机制

类加载器收到了类加载请求,首先不会自己尝试加载这个类,而是把这个请求委派给父类去完成,每一个层次的类加载器都是如此,所有加载请求最终都传到顶层的启动类加载器中,只有当父类加载器返回自己无法完成这个加载请求(每找到所需的类),子类加载器才会尝试加载

JVM采用双亲委派机制

  • 通过双亲委派机制可避免某个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性
  • 为了安全,保证类库API不会被修改

垃圾回收

  • 为了程序员更专注代码实现,不用考虑内存释放问题,Java有了自动垃圾回收机制,即GC
  • 垃圾回收机制后,程序员只关心内存申请,内存释放由系统自动识别完成
  • 垃圾回收时,不同对象引用类型,GC会采用不同的回收时机

强、软、弱、虚区别

强引用:表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收

软引用:表示一个对象处于有用且非必须的状态,内存空间足够的情况下,GC机制并不会回收它,内存空间不足时,则会在OOM异常出现之前对其进行回收。但值得注意的是,因为GC线程优先级较低,软引用并不会立即被回收

弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。同样的,因为GC线程优先级较低,所以弱引用也并不是会被立刻回收

虚引用:表示一个对象处于无用的状态。在任何时候都有可能被垃圾回收。虚引用的使用必须和引用队列Reference Queue联合使用

对象什么时候被垃圾回收

一个或多个对象没有任何的引用指向它,那这个对象现在就是垃圾,定位了垃圾,则有可能会被垃圾回收器回收。

有两种方式来确定垃圾,第一个是引用计数法,第二是可达性分析,通常使用第二种方法

JVM垃圾回收算法

  • 标记清除算法
  • 复制算法
  • 标记整理算法
  • 分代回收

分代回收

在java8时,堆被分为了两份:新生代和老年代,默认空间占比是1:2,新生代内部又被分为了三个区域。Eden区,S0区,S1区默认空间占比是8:1:1

具体工作机制:

  1. 创建一个对象的时候,那么这个对象会被分配在新生代的Eden区。当Eden区要满了时候,触发YoungGC
  2. 进行YoungGC后,此时在Eden区存活的对象被移动到S0区,并且当前对象的年龄会加1,清空Eden区
  3. 当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S0中的对象,移动到S1区中,这些对象的年龄会加1,清空Eden区和S0区
  4. 当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S1中的对象,移动到S0区中,这些对象的年龄会加1,清空Eden区和S1区
  5. 对象的年龄达到了某一个限定的值(默认15岁 ),那么这个对象就会进入到老年代中

特殊情况,若进入Eden区的是一个大对象,在触发YoungGC的时候,会直接存放到老年代

老年代满了之后,触发FullGCFullGC同时回收新生代和老年代,当前只会存在一个FullGC的线程进行执行,其他的线程全部会被挂起。 我们在程序中要尽量避免FullGC的出现。

新生代、老年代、永久代

  • 新生代:主要用来存放新生的对象
  • 老年代:主要存放应用中生命周期长的内存对象
  • 永久代:指的是永久保存区域,主要存放Class和Meta(元数据)的信息。在Java8中,永久代被移除,取而代之是元空间,与永久代类似,不过最大区别是元空间不在虚拟机中,而使用本地内存,默认情况下元空间大小仅受本地内存限制

JVM 的垃圾回收器

  • 串行垃圾收集器
  • 并行垃圾收集器(JDK8默认)
  • CMS(并发)垃圾收集器
  • G1垃圾收集器(JDK9默认)

Minor GC、Major GC、Full GC

指的是不同代之间的垃圾回收

  • Minor GC 发生在新生代的垃圾回收,暂停时间短
  • Major GC 老年代区域的垃圾回收,老年代空间不足时,会先尝试触发Minor GC。Minor GC之后空间还不足,则会触发Major GC,Major GC速度比较慢,暂停时间长
  • Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免

JVM调优

  • 调优的参数在哪里设置参数值
    • 当时的项目是springboot项目,可以在项目启动的时候,java -jar中加入参数就行了
  • JVM 调优的参数都有哪些
    • 设置堆的大小,像-Xms和-Xmx
    • 设置年轻代中Eden区和两个Survivor区的大小比例
    • 设置使用哪种垃圾回收器等等。具体的指令还真记不太清楚。

调试 JVM的工具

使用jdk自带的一些工具

  • jps 输出JVM中运行的进程状态信息
  • jstack查看java进程内线程的堆栈信息
  • jmap 用于生成堆转存快照
  • jstat用于JVM统计监测工具
  • 还有一些可视化工具,像jconsole和VisualVM等

java内存泄露的排查思路

第一,可以通过jmap指定打印他的内存快照 dump文件,不过有的情况打印不了,我们会设置vm参数让程序自动生成dump文件

第二,可以通过工具去分析 dump文件,jdk自带的VisualVM就可以分析

第三,通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题

第四,找到对应的代码,通过阅读上下文的情况,进行修复即可

服务器CPU持续飙高的排查方案与思路

第一可以使用使用top命令查看占用cpu的情况

第二通过top命令查看后,可以查看是哪一个进程占用cpu较高,记录这个进程id

第三可以通过ps 查看当前进程中的线程信息,看看哪个线程的cpu占用较高

第四可以jstack命令打印进行的id,找到这个线程,就可以进一步定位问题代码的行号

中间件

RabbitMQ-如何保证消息不丢失

当时MYSQL和Redis的数据双写一致性就是采用RabbitMQ实现同步的,这里面就要求了消息的高可用性,我们要保证消息的不丢失。主要从三个层面考虑

  • ​ 开启生产者确认机制,确保生产者的消息能够到达队列,若保存则先记录到日志中,再修复数据
  • 开启持久化功能,确保消息未消费前在队列中不会丢失,其中的交换机、队列、消息都要做持久化
  • 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack,设置一定的重试次数,我们设置了3次,重试3次还未收到消息,就将失败消息投递到异常交换机,有人工处理

RabbitMQ-解决重复消费问题

当时消费者是设置了自动确认机制,当服务还没来得及给MQ确认时,服务宕机了,导致服务重启后,又消费了次消息,导致了重复消费,我们当时处理的业务有个唯一标识,再处理消息时,先到数据库查询,数据是否存在,不存在则没处理,就正常处理,已存在则表示消息重复消费,不需再消费

这就是典型的幂等问题,如redis分布式锁、数据库锁都可以

RabbitMQ中死信交换机(RabbitMQ延迟队列)

延迟队列就是用死信交换机和TTL实现的,消息超时未消费就变成死信,在RabbitMQ中如果消息成为死信,队列可绑定一个死信交换机,在死信交换机上可绑定其他队列,在我们发消息的时候按照需求指定的TTL时间,实现了延迟队列功能

RabbitMQ还有一种方法可实现延迟队列,在RabbitMQ中安装一个死信插件,这样方便一些,只需要在声明交换机时,指定这个就是死信交换机,在发消息是直接指定过期时间,相当于死信交换机 + TTL

100万消息堆积在MQ

  • 提高消费者的消费能力,使用多线程消费任务
  • 增加更多消费者,提高消费速度
    • 使用工作队列,设置多个消费者消费同一队列消息
  • 扩大队列容积,提高堆积上限
    • RabbitMQ惰性队列,惰性队列接收到消息直接存入磁盘而非内存,消费者消费消息才会从磁盘读取并加载到内存,支持百万条消息存储

RabbitMQ高可用机制

生产环境下使用了集群,搭建的是镜像模式集群,使用了3台机器,镜像队列一主一从,所有操作都是主节点完成,同步给镜像节点,如果主节点宕机后,镜像节点会替换成新的主节点,不过在主从同步完成前,主节点已宕机,可能出现数据丢失

数据丢失采用仲裁队列,与镜像队列一样,主从模式,支持主从数据同步,主从同步基于Raft协议,强一致。使用简单,不需额外配置,声明队列时只要指定这个仲裁队列即可

Kafka保证消息不丢失

保证机制很多,在发送消息到消息者接受消息,每个阶段都可能丢失消息

  • 在生产者发送消息,使用异步回调发送,消息发送失败则回调获取失败后的消息信息,可考虑重试或记录日志,后面在做补偿,同时生产者可设置消息重试,有时由于网络抖动导致发送不成功,使用重试机制解决
  • 在broker消息中可能会丢失,可通过kafka复制机制来确保消息不丢失,在生产者发送消息时,可设置acks,就是确认机制,设置参数为all,当生产者发送发送消息到了分区后,不仅仅会在leader分区保存,在follower分区也会保存确认,只有当所有副本都保存确认才算成功发送消息,很大程度保证消息不在broker丢失
  • 在消费者端可能丢失消息,kafka消费消息都是按照offset进行标记消费,消费者默认自动按期提交已经消费的偏移量,默认每隔5s提交一次,若出现重平衡,可能重复消费或丢失数据。会禁用掉自动提交偏移量,改为手动提交,消息消费成功报告给broker消费位置,避免重复消费和丢失数据

Kafka中重复消费

kafka消费消息都是按照offset进行标记消费的,消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次,若出现重平衡,可能会重复消费或丢失数据。禁用掉自动提交偏移量,改为手动提交,消费成功后再报告给broker消费的位置,避免消息丢失和重复消费

为了消息的幂等,可设置唯一主键进行区分,或加锁,数据库锁,redis分布式锁,解决幂等

Kafka保证消费的顺序性

kafka默认存储和消费消息,不保证顺序性,一个topic数据可能存储在不同的分区中,每个分区都有有按顺序存储的偏移量,若消费者关联多个分区不保证顺序性,则把消息都存储到一个分区下就可以了。实现方法有:发送消息时指定分区号,发送消息时按照相同业务设置相同的key,默认情况下分区也是通过key的hashCode值来分区的,hash值一样分区也一样

Kafka的高可用机制

  • 集群:多个broker实例组成,即某一台宕机,也不耽误其他broker继续对外提供服务
  • 复制机制:保证kafka高可用,topic有多个分区,每个分区有多副本,有一个leader多个follower,副本存储在不同broker中,所有分区副本内容都相同,若leader发生故障,会自动将其中一个follower提升为leader,保证系统容错性,高可用

复制机制中的ISR

ISR的意思是in-sync replica,就是需要同步复制保存的follower,分区副本有很多的follower,分为了两类,一个是ISR,与leader副本同步保存数据,另外一个普通的副本,是异步同步数据,当leader挂掉之后,会优先从ISR副本列表中选取一个作为leader,因为ISR是同步保存数据,数据更加的完整一些,所以优先选择ISR副本列表

Kafka数据清理机制

Kafka中topic的数据存储在分区上,分区如果文件过大会分段存储segment。

每个分段都在磁盘上以索引(xxxx.index)和日志文件(xxxx.log)的形式存储,好处是:

  1. 能够减少单个文件内容的大小,查找数据方便
  2. 方便kafka进行日志清理

两个日志的清理策略,都可通过broker中的配置文件设置:

  • 根据消息保留时间,当消息保存时间超过指定时间,触发清理,默认7天
  • 根据topic存储数据大小,当topic所占日志文件大小大于一定阈值,删除最久消息,默认关闭

Kafka实现高性能的设计

Kafka高性能是多方面协同结果,包括宏观架构、分布式存储、ISR数据同步、高效利用磁盘、操作系统特性

  • 消息分区:不受单台服务器限制,可不受限处理更多的数据
  • 顺序读写:磁盘顺序读写,提升读写效率
  • 页缓存:把磁盘中数据缓存到内存中,把对磁盘访问变为对内存访问
  • 零拷贝:减少上下文切换及数据拷贝
  • 消息压缩:减少磁盘IO和网络IO
  • 分批发送:将消息打包批量发送,减少网络开销
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

今天你学Java了吗

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值