面试套路问题总结3

1.Java 修饰符

访问控制修饰符

Java中,可以使用访问控制符来保护对类、变量、方法和构造方法的访问。Java 支持 4 种不同的访问权限。

  • default (即默认,什么也不写): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。

  • private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)

  • public : 对所有类可见。使用对象:类、接口、变量、方法

  • protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)

访问控制
修饰符当前类同一包内子孙类(同一包)子孙类(不同包)其他包
publicYYYYY
protectedYYYY/N(说明N
defaultYYYNN
privateYNNNN

2.Spring Boot打包底层细节

Spring Boot打成Jar包的目录结构如图所示:

3.线程池核心设计与实现

 1.总体设计

Java中的线程池核心实现类是ThreadPoolExecutor,本章基于JDK 1.8的源码来分析Java线程池的核心设计与实现。我们首先来看一下ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。

图1 ThreadPoolExecutor UML类图

ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。ExecutorService接口增加了一些能力:(1)扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;(2)提供了管控线程池的方法,比如停止线程池的运行。AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。

ThreadPoolExecutor是如何运行,如何同时维护线程和执行任务的呢?其运行机制如下图所示:

图2 ThreadPoolExecutor运行流程

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

任务缓冲

任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

下图中展示了线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素:

图5 阻塞队列

使用不同的队列可以实现不一样的任务存取策略。在这里,我们可以再介绍下阻塞队列的成员:

阻塞队列的最长使用的例子就是生产者消费者模式,也是各种实现生产者消费者模式方式中首选的方式

任务拒绝

任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池。

拒绝策略是一个接口,其设计如下: 

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

用户可以通过实现这个接口去定制拒绝策略,也可以选择JDK提供的四种已有拒绝策略,其特点如下:

如何设置参数

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    ...
}
  • 默认值
    • corePoolSize=1
    • queueCapacity=Integer.MAX_VALUE
    • maxPoolSize=Integer.MAX_VALUE
    • keepAliveTime=60s
    • allowCoreThreadTimeout=false
    • rejectedExecutionHandler=AbortPolicy()

这个应该是最重要的参数了,所以如何合理的设置它十分重要。

核心线程会一直存活,及时没有任务需要执行。
当线程数小于核心线程数时,即使有线程空闲,线程池也会优先创建新线程处理。
设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭。
如何设置好的前提我们要很清楚的知道CPU密集型和IO密集型的区别。

(1)、CPU密集型

CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading 很高。

在多重程序系统中,大部分时间用来做计算、逻辑判断等CPU动作的程序称之CPU bound。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部分时间用在三角函数和开根号的计算,便是属于CPU bound的程序。

CPU bound的程序一般而言CPU占用率相当高。这可能是因为任务本身不太需要访问I/O设备,也可能是因为程序是多线程实现因此屏蔽掉了等待I/O的时间。

(2)、IO密集型

IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。

I/O bound的程序一般在达到性能极限时,CPU占用率仍然较低。这可能是因为任务本身需要大量I/O操作,而pipeline做得不是很好,没有充分利用处理器能力。

好了,了解完了以后我们就开搞了。

(3)、先看下机器的CPU核数,然后在设定具体参数:

自己测一下自己机器的核数

System.out.println(Runtime.getRuntime().availableProcessors());


即CPU核数 = Runtime.getRuntime().availableProcessors()

(4)、分析下线程池处理的程序是CPU密集型还是IO密集型

CPU密集型:corePoolSize = CPU核数 + 1

IO密集型:corePoolSize = CPU核数 * 2

  • 如何来设置
    • 需要根据几个值来决定
      • tasks :每秒的任务数,假设为500~1000
      • taskcost:每个任务花费时间,假设为0.1s
      • responsetime:系统允许容忍的最大响应时间,假设为1s
    • 做几个计算
      • corePoolSize = 每秒需要多少个线程处理? 
        • threadcount = tasks/(1/taskcost) =tasks*taskcout =  (500~1000)*0.1 = 50~100 个线程。corePoolSize设置应该大于50
        • 根据8020原则,如果80%的每秒任务数小于800,那么corePoolSize设置为80即可
      • queueCapacity = (coreSizePool/taskcost)*responsetime
        • 计算可得 queueCapacity = 80/0.1*1 = 80。意思是队列里的线程可以等待1s,超过了的需要新开线程来执行
        • 切记不能设置为Integer.MAX_VALUE,这样队列会很大,线程数只会保持在corePoolSize大小,当任务陡增时,不能新开线程来执行,响应时间会随之陡增。
      • maxPoolSize = (max(tasks)- queueCapacity)/(1/taskcost)
        • 计算可得 maxPoolSize = (1000-80)/10 = 92
        • (最大任务数-队列容量)/每个线程每秒处理能力 = 最大线程数
      • rejectedExecutionHandler:根据具体情况来决定,任务不重要可丢弃,任务重要则要利用一些缓冲机制来处理
      • keepAliveTime和allowCoreThreadTimeout采用默认通常能满足
  • 以上都是理想值,实际情况下要根据机器性能来决定。如果在未达到最大线程数的情况机器cpu load已经满了,则需要通过升级硬件(呵呵)和优化代码,降低taskcost来处理。

四、线程池队列的选择
workQueue - 当线程数目超过核心线程数时用于保存任务的队列。主要有3种类型的BlockingQueue可供选择:无界队列,有界队列和同步移交。从参数中可以看到,此队列仅保存实现Runnable接口的任务。

这里再重复一下新任务进入时线程池的执行策略:

当正在运行的线程小于corePoolSize,线程池会创建新的线程。
当大于corePoolSize而任务队列未满时,就会将整个任务塞入队列。
当大于corePoolSize而且任务队列满时,并且小于maximumPoolSize时,就会创建新额线程执行任务。
当大于maximumPoolSize时,会根据handler策略处理线程。
1、无界队列

队列大小无限制,常用的为无界的LinkedBlockingQueue,使用该队列作为阻塞队列时要尤其当心,当任务耗时较长时可能会导致大量新任务在队列中堆积最终导致OOM。阅读代码发现,Executors.newFixedThreadPool 采用就是 LinkedBlockingQueue,而博主踩到的就是这个坑,当QPS很高,发送数据很大,大量的任务被添加到这个无界LinkedBlockingQueue 中,导致cpu和内存飙升服务器挂掉。

当然这种队列,maximumPoolSize 的值也就无效了。当每个任务完全独立于其他任务,即任务执行互不影响时,适合于使用无界队列;例如,在 Web 页服务器中。这种排队可用于处理瞬态突发请求,当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性。

2、有界队列

当使用有限的 maximumPoolSizes 时,有界队列有助于防止资源耗尽,但是可能较难调整和控制。常用的有两类,一类是遵循FIFO原则的队列如ArrayBlockingQueue,另一类是优先级队列如PriorityBlockingQueue。PriorityBlockingQueue中的优先级由任务的Comparator决定。

使用有界队列时队列大小需和线程池大小互相配合,线程池较小有界队列较大时可减少内存消耗,降低cpu使用率和上下文切换,但是可能会限制系统吞吐量。

3、同步移交队列

如果不希望任务在队列中等待而是希望将任务直接移交给工作线程,可使用SynchronousQueue作为等待队列。SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。只有在使用无界线程池或者有饱和策略时才建议使用该队列。

4.Spring,SpringMVC,SpringBoot,SpringCloud有什么区别和联系?

Spring是一个轻量级的控制反转(IoC)和面向切面(AOP)的容器框架。Spring使你能够编写更干净、更可管理、并且更易于测试的代码。

Spring MVC是Spring的一个模块,一个web框架。通过Dispatcher Servlet, ModelAndView 和 View Resolver,开发web应用变得很容易。主要针对的是网站应用程序或者服务开发——URL路由、Session、模板引擎、静态Web资源等等。

Spring配置复杂,繁琐,所以推出了Spring boot,约定优于配置,简化了spring的配置流程。

Spring Cloud构建于Spring Boot之上,是一个关注全局的服务治理框架。

Spring和SpringMVC:

Spring是一个一站式的轻量级的java开发框架,核心是控制反转(IOC)和面向切面(AOP),针对于开发的WEB层(springMvc)、业务层(Ioc)、持久层(jdbcTemplate)等都提供了多种配置解决方案;

SpringMVC是Spring基础之上的一个MVC框架,主要处理web开发的路径映射和视图渲染,属于Spring框架中WEB层开发的一部分;

总结下来:

  • Spring是核心,提供了基础功能;
  • Spring MVC 是基于Spring的一个 MVC 框架 ;
  • Spring Boot 是为简化Spring配置的快速开发整合包;
  • Spring Cloud是构建在Spring Boot之上的服务治理框架。

5.6种延时队列的实现方案

一、延时队列的应用

什么是延时队列?顾名思义:首先它要具有队列的特性,再给它附加一个延迟消费队列消息的功能,也就是说可以指定队列中的消息在哪个时间点被消费。

延时队列在项目中的应用还是比较多的,尤其像电商类平台:

1、订单成功后,在30分钟内没有支付,自动取消订单

2、外卖平台发送订餐通知,下单成功后60s给用户推送短信。

3、如果订单一直处于某一个未完结状态时,及时处理关单,并退还库存

4、淘宝新建商户一个月内还没上传商品信息,将冻结商铺等

。。。。

上边的这些场景都可以应用延时队列解决。

二、延时队列的实现

我个人一直秉承的观点:工作上能用JDK自带API实现的功能,就不要轻易自己重复造轮子,或者引入三方中间件。一方面自己封装很容易出问题(大佬除外),再加上调试验证产生许多不必要的工作量;另一方面一旦接入三方的中间件就会让系统复杂度成倍的增加,维护成本也大大的增加。

1、DelayQueue 延时队列

JDK 中提供了一组实现延迟队列的API,位于Java.util.concurrent包下DelayQueue

DelayQueue是一个BlockingQueue(无界阻塞)队列,它本质就是封装了一个PriorityQueue(优先队列),PriorityQueue内部使用完全二叉堆(不知道的自行了解哈)来实现队列元素排序,我们在向DelayQueue队列中添加元素时,会给元素一个Delay(延迟时间)作为排序条件,队列中最小的元素会优先放在队首。队列中的元素只有到了Delay时间才允许从队列中取出。队列中可以放基本数据类型或自定义实体类,在存放基本数据类型时,优先队列中元素默认升序排列,自定义实体类就需要我们根据类属性值比较计算了。

先简单实现一下看看效果,添加三个order入队DelayQueue,分别设置订单在当前时间的5秒10秒15秒后取消。
在这里插入图片描述

要实现DelayQueue延时队列,队中元素要implements Delayed 接口,这哥接口里只有一个getDelay方法,用于设置延期时间。Order类中compareTo方法负责对队列中的元素进行排序。

public class Order implements Delayed {
    /**
     * 延迟时间
     */
    @JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    private long time;
    String name;
    
    public Order(String name, long time, TimeUnit unit) {
        this.name = name;
        this.time = System.currentTimeMillis() + (time > 0 ? unit.toMillis(time) : 0);
    }
    
    @Override
    public long getDelay(TimeUnit unit) {
        return time - System.currentTimeMillis();
    }
    @Override
    public int compareTo(Delayed o) {
        Order Order = (Order) o;
        long diff = this.time - Order.time;
        if (diff <= 0) {
            return -1;
        } else {
            return 1;
        }
    }
}

DelayQueueput方法是线程安全的,因为put方法内部使用了ReentrantLock锁进行线程同步。DelayQueue还提供了两种出队的方法 poll() 和 take() , poll() 为非阻塞获取,没有到期的元素直接返回null;take() 阻塞方式获取,没有到期的元素线程将会等待。

public class DelayQueueDemo {

    public static void main(String[] args) throws InterruptedException {
        Order Order1 = new Order("Order1", 5, TimeUnit.SECONDS);
        Order Order2 = new Order("Order2", 10, TimeUnit.SECONDS);
        Order Order3 = new Order("Order3", 15, TimeUnit.SECONDS);
        DelayQueue<Order> delayQueue = new DelayQueue<>();
        delayQueue.put(Order1);
        delayQueue.put(Order2);
        delayQueue.put(Order3);

        System.out.println("订单延迟队列开始时间:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        while (delayQueue.size() != 0) {
            /**
             * 取队列头部元素是否过期
             */
            Order task = delayQueue.poll();
            if (task != null) {
                System.out.format("订单:{%s}被取消, 取消时间:{%s}\n", task.name, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            }
            Thread.sleep(1000);
        }
    }
}

上边只是简单的实现入队与出队的操作,实际开发中会有专门的线程,负责消息的入队与消费。

执行后看到结果如下,Order1Order2Order3 分别在 5秒10秒15秒后被执行,至此就用DelayQueue实现了延时队列。

订单延迟队列开始时间:2020-05-06 14:59:09
订单:{Order1}被取消, 取消时间:{2020-05-06 14:59:14}
订单:{Order2}被取消, 取消时间:{2020-05-06 14:59:19}
订单:{Order3}被取消, 取消时间:{2020-05-06 14:59:24}

2、Quartz 定时任务

3、Redis sorted set

Redis的数据结构Zset,同样可以实现延迟队列的效果,主要利用它的score属性,redis通过score来为集合中的成员进行从小到大的排序。
在这里插入图片描述
通过zadd命令向队列delayqueue 中添加元素,并设置score值表示元素过期的时间;向delayqueue 添加三个order1order2order3,分别是10秒20秒30秒后过期。

zadd delayqueue 3 order3

消费端轮询队列delayqueue, 将元素排序后取最小时间与当前时间比对,如小于当前时间代表已经过期移除key

    /**
     * 消费消息
     */
    public void pollOrderQueue() {

        while (true) {
            Set<Tuple> set = jedis.zrangeWithScores(DELAY_QUEUE, 0, 0);

            String value = ((Tuple) set.toArray()[0]).getElement();
            int score = (int) ((Tuple) set.toArray()[0]).getScore();
            
            Calendar cal = Calendar.getInstance();
            int nowSecond = (int) (cal.getTimeInMillis() / 1000);
            if (nowSecond >= score) {
                jedis.zrem(DELAY_QUEUE, value);
                System.out.println(sdf.format(new Date()) + " removed key:" + value);
            }

            if (jedis.zcard(DELAY_QUEUE) <= 0) {
                System.out.println(sdf.format(new Date()) + " zset empty ");
                return;
            }
            Thread.sleep(1000);
        }
    }

我们看到执行结果符合预期

2020-05-07 13:24:09 add finished.
2020-05-07 13:24:19 removed key:order1
2020-05-07 13:24:29 removed key:order2
2020-05-07 13:24:39 removed key:order3
2020-05-07 13:24:39 zset empty 

4、Redis 过期回调

Redis 的key过期回调事件,也能达到延迟队列的效果,简单来说我们开启监听key是否过期的事件,一旦key过期会触发一个callback事件。

修改redis.conf文件开启notify-keyspace-events Ex

notify-keyspace-events Ex

Redis监听配置,注入Bean RedisMessageListenerContainer

@Configuration
public class RedisListenerConfig {
    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}

编写Redis过期回调监听方法,必须继承KeyExpirationEventMessageListener ,有点类似于MQ的消息监听。

@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
 
    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String expiredKey = message.toString();
        System.out.println("监听到key:" + expiredKey + "已过期");
    }
}

到这代码就编写完成,非常的简单,接下来测试一下效果,在redis-cli客户端添加一个key 并给定3s的过期时间。

set xiaofu 123 ex 3

在控制台成功监听到了这个过期的key

监听到过期的key为:xiaofu

5、RabbitMQ 延时队列

利用 RabbitMQ 做延时队列是比较常见的一种方式,而实际上RabbitMQ 自身并没有直接支持提供延迟队列功能,而是通过 RabbitMQ 消息队列的 TTL和 DXL这两个属性间接实现的。

先来认识一下 TTL和 DXL两个概念:

Time To Live(TTL) :

TTL 顾名思义:指的是消息的存活时间,RabbitMQ可以通过x-message-tt参数来设置指定Queue(队列)和 Message(消息)上消息的存活时间,它的值是一个非负整数,单位为微秒。

RabbitMQ 可以从两种维度设置消息过期时间,分别是队列消息本身

  • 设置队列过期时间,那么队列中所有消息都具有相同的过期时间。
  • 设置消息过期时间,对队列中的某一条消息设置过期时间,每条消息TTL都可以不同。

如果同时设置队列和队列中消息的TTL,则TTL值以两者中较小的值为准。而队列中的消息存在队列中的时间,一旦超过TTL过期时间则成为Dead Letter(死信)。

Dead Letter ExchangesDLX

DLX即死信交换机,绑定在死信交换机上的即死信队列。RabbitMQ的 Queue(队列)可以配置两个参数x-dead-letter-exchange 和 x-dead-letter-routing-key(可选),一旦队列内出现了Dead Letter(死信),则按照这两个参数可以将消息重新路由到另一个Exchange(交换机),让消息重新被消费。

x-dead-letter-exchange:队列中出现Dead Letter后将Dead Letter重新路由转发到指定 exchange(交换机)。

x-dead-letter-routing-key:指定routing-key发送,一般为要指定转发的队列。

队列出现Dead Letter的情况有:

  • 消息或者队列的TTL过期
  • 队列达到最大长度
  • 消息被消费端拒绝(basic.reject or basic.nack)

下边结合一张图看看如何实现超30分钟未支付关单功能,我们将订单消息A0001发送到延迟队列order.delay.queue,并设置x-message-tt消息存活时间为30分钟,当到达30分钟后订单消息A0001成为了Dead Letter(死信),延迟队列检测到有死信,通过配置x-dead-letter-exchange,将死信重新转发到能正常消费的关单队列,直接监听关单队列处理关单逻辑即可。
在这里插入图片描述

发送消息时指定消息延迟的时间

public void send(String delayTimes) {
        amqpTemplate.convertAndSend("order.pay.exchange", "order.pay.queue","大家好我是延迟数据", message -> {
            // 设置延迟毫秒值
            message.getMessageProperties().setExpiration(String.valueOf(delayTimes));
            return message;
        });
    }
}

设置延迟队列出现死信后的转发规则

/**
     * 延时队列
     */
    @Bean(name = "order.delay.queue")
    public Queue getMessageQueue() {
        return QueueBuilder
                .durable(RabbitConstant.DEAD_LETTER_QUEUE)
                // 配置到期后转发的交换
                .withArgument("x-dead-letter-exchange", "order.close.exchange")
                // 配置到期后转发的路由键
                .withArgument("x-dead-letter-routing-key", "order.close.queue")
                .build();
    }

6、时间轮

6.git rebase 和 git merge 的区别

git pull

git pull 是 git fetch + git merge FETCH_HEAD 的缩写。所以,默认情况下,git pull就是先fetch,然后执行merge 操作,如果加–rebase 参数,就是使用git rebase 代替git merge。

merge 和 rebase

merge 是合并的意思,rebase是复位基底的意思。

现在我们有这样的两个分支,test和master,提交如下:

       D---E test
      /
 A---B---C---F master

在master执行git merge test,然后会得到如下结果:

       D--------E
      /          \
 A---B---C---F----G   test, master

 

在master执行git rebase test,然后得到如下结果:

A---B---D---E---C'---F' test, master

可以看到,merge操作会生成一个新的节点,之前的提交分开显示。而rebase操作不会生成新的节点,是将两个分支融合成一个线性的提交。

### 其他内容放这里

通过上面可以看到,想要更好的提交树,使用rebase操作会更好一点。这样可以线性的看到每一次提交,并且没有增加提交节点。

在我们操作过程中。merge 操作遇到冲突的时候,当前merge不能继续进行下去。手动修改冲突内容后,add 修改,commit 就可以了。

而rebase 操作的话,会中断rebase,同时会提示去解决冲突。解决冲突后,将修改add后执行git rebase –continue继续操作,或者git rebase –skip忽略冲突。

5.RocketMQ集群及教程

这里写图片描述

这里写图片描述

这里写图片描述

Kafka、RabbitMQ、RocketMQ等消息中间件的对比

有关测试结论

Kafka的吞吐量高达17.3w/s,不愧是高吞吐量消息中间件的行业老大。这主要取决于它的队列模式保证了写磁盘的过程是线性IO。此时broker磁盘IO已达瓶颈。

RocketMQ也表现不俗,吞吐量在11.6w/s,磁盘IO %util已接近100%。RocketMQ的消息写入内存后即返回ack,由单独的线程专门做刷盘的操作,所有的消息均是顺序写文件。

RabbitMQ的吞吐量5.95w/s,CPU资源消耗较高。它支持AMQP协议,实现非常重量级,为了保证消息的可靠性在吞吐量上做了取舍。我们还做了RabbitMQ在消息持久化场景下的性能测试,吞吐量在2.6w/s左右。

在服务端处理同步发送的性能上,Kafka>RocketMQ>RabbitMQ。

对比了最简单的小消息发送场景,Kafka暂时胜出。但是,作为经受过历次双十一洗礼的RocketMQ,在互联网应用场景中更有它优越的一面。

功能消息队列 RocketMQApache RocketMQ
(开源)
消息队列 KafkaApache Kafka
(开源)
RabbitMQ
(开源)
安全防护支持不支持支持不支持支持
主子账号支持支持不支持支持不支持不支持
可靠性- 同步刷盘
- 同步双写
- 超3份数据副本
- 99.99999999%
- 同步刷盘
- 异步刷盘
- 同步刷盘
- 同步双写
- 超3份数据副本
- 99.99999999%
异步刷盘,丢数据概率高同步刷盘
可用性- 非常好,99.95%
- Always Writable
- 非常好,99.95%
- Always Writable
横向扩展能力- 支持平滑扩展
- 支持百万级 QPS
支持- 支持平滑扩展
- 支持百万级 QPS
支持- 集群扩容依赖前端
- LVS 负载均衡调度
Low Latency支持不支持支持不支持不支持
消费模型Push / PullPush / PullPush / PullPullPush / Pull
定时消息支持(可精确到秒级)支持(只支持18个固定 Level)暂不支持不支持支持
事务消息支持不支持不支持不支持不支持
顺序消息支持支持暂不支持支持不支持
全链路消息轨迹支持不支持暂不支持不支持不支持
消息堆积能力百亿级别
不影响性能
百亿级别
影响性能
百亿级别
不影响性能
影响性能影响性能
消息堆积查询支持支持支持不支持不支持
消息回溯支持支持支持不支持不支持
消息重试支持支持暂不支持不支持支持
死信队列支持支持不支持不支持支持
性能(常规)非常好
百万级 QPS
非常好
十万级 QPS
非常好
百万级 QPS
非常好
百万级 QPS
一般
万级 QPS
性能(万级 Topic 场景)非常好
百万级 QPS
非常好
十万级 QPS
非常好
百万级 QPS
性能(海量消息堆积场景)非常好
百万级 QPS
非常好
十万级 QPS
非常好
百万级 QPS

在这里插入图片描述

rabbitmq比kafka可靠,kafka更适合IO高吞吐的处理,比如ELK日志收集**

RocketMQ 相比于 RabbitMQ、kafka 具有主要优势特性有:

  • 支持事务型消息(消息发送和DB操作保持两方的最终一致性,RabbitMQ 和kafka 不支持)
  • 支持结合 RocketMQ 的多个系统之间数据最终一致性(多方事务,二方事务是前提)
  • 支持18个级别的延迟消息(RabbitMQ 和 kafka 不支持)
  • 支持指定次数和时间间隔的失败消息重发(kafka不支持,RabbitMQ 需要手动确认)
  • 支持 consumer 端 tag 过滤,减少不必要的网络传输(RabbitMQ 和 kafka不支持)
  • 支持重复消费(RabbitMQ 不支持,kafka支持)

RabbitMq和kafka的区别

在实际生产应用中,通常会使用kafka作为消息传输的数据管道,rabbitmq作为交易数据作为数据传输管道,主要的取舍因素则是是否存在丢数据的可能;rabbitmq在金融场景中经常使用,具有较高的严谨性,数据丢失的可能性更小,同事具备更高的实时性;而kafka优势主要体现在吞吐量上,虽然可以通过策略实现数据不丢失,但从严谨性角度来讲,大不如rabbitmq;而且由于kafka保证每条消息最少送达一次,有较小的概率会出现数据重复发送的情况;

1.应用场景方面

RabbitMQ:用于实时的,对可靠性要求较高的消息传递上。

kafka:用于处于活跃的流式数据,大数据量的数据处理上。

2.架构模型方面

producer,broker,consumer

RabbitMQ:以broker为中心,有消息的确认机制

kafka:以consumer为中心,无消息的确认机制

3.吞吐量方面

RabbitMQ:支持消息的可靠的传递,支持事务,不支持批量操作,基于存储的可靠性的要求存储可以采用内存或硬盘,吞吐量小。

kafka:内部采用消息的批量处理,数据的存储和获取是本地磁盘顺序批量操作,消息处理的效率高,吞吐量高。

4.集群负载均衡方面

RabbitMQ:本身不支持负载均衡,需要loadbalancer的支持

kafka:采用zookeeper对集群中的broker,consumer进行管理,可以注册topic到zookeeper上,通过zookeeper的协调机制,producer保存对应的topic的broker信息,可以随机或者轮询发送到broker上,producer可以基于语义指定分片,消息发送到broker的某个分片上。

 

1.RabbitMQ的消息应当尽可能的小,并且只用来处理实时且要高可靠性的消息。

2.消费者和生产者的能力尽量对等,否则消息堆积会严重影响RabbitMQ的性能。

3.集群部署,使用热备,保证消息的可靠性。

 

1.应当有一个非常好的运维监控系统,不单单要监控Kafka本身,还要监控Zookeeper。(kafka强烈的依赖于zookeeper,如果zookeeper挂掉了,那么Kafka也不行了)

2.对消息顺序不依赖,且不是那么实时的系统。

3.对消息丢失并不那么敏感的系统。

4.从 A 到 B 的流传输,无需复杂的路由,最大吞吐量可达每秒 100k 以上。

 

RabbitMQ和Kafka相比没价值了吗?

很多亲们读到这里,就会想RabbitMQ好像也不怎么样呀。和Kafka相比没什么价值可言了,但是我前面说了一些Kafka的坑,我就在这里面揭示一下。

  1. Kafka大量依赖Zookeeper,它的broker并不保存任何状态,如果Zookeeper集群不幸悲剧了,那么整个Kafka集群的消息就全完蛋了。

  2. 上面问题有人会说这概率好小,我也同样认为这个概率很小,那么一个broker当机呢?当一个broker当机了整个消息队列由于负载均衡的算法,在一瞬间消费者和生产者之间的消息就全乱掉了。很多需要保证消息顺序的系统一下子就完蛋了。

这就是RabbitMQ存在的价值和意义,同时RabbitMQ使用了MirrorQueue的机制,也可以做到多个机器进行热备。

 

queue  点对点模式   不存在数据丢失问题

     “负载均衡”模式,如果当前没有消费者,消息也不会丢弃;如果有多个消费者,那么一条消息也只会发送给其中一个消费者,并且要求消费者ack信息ack确认机制。

Producer

package com.sean;
 
import com.alibaba.rocketmq.client.producer.DefaultMQProducer;
import com.alibaba.rocketmq.client.producer.SendResult;
import com.alibaba.rocketmq.common.message.Message;
 
public class Producer {
	public static void main(String[] args){
		DefaultMQProducer producer = new DefaultMQProducer("Producer");
		producer.setNamesrvAddr("192.168.58.163:9876"); 
		try {
			producer.start();
			
			Message msg = new Message("PushTopic", 
					"push", 
					"1", 
					"Just for test.".getBytes());
			
			SendResult result = producer.send(msg);
			System.out.println("id:" + result.getMsgId() +
					" result:" + result.getSendStatus());
			
			msg = new Message("PushTopic", 
					"push", 
					"2", 
					"Just for test.".getBytes());
			
			result = producer.send(msg);
			System.out.println("id:" + result.getMsgId() +
					" result:" + result.getSendStatus());
			
			msg = new Message("PullTopic", 
					"pull", 
					"1", 
					"Just for test.".getBytes());
			
			result = producer.send(msg);
			System.out.println("id:" + result.getMsgId() +
					" result:" + result.getSendStatus());
		} catch (Exception e) {
			e.printStackTrace();
		}finally{
			producer.shutdown();
		}
	}
}

Consumer

package com.sean;
 
import java.util.List;
 
import com.alibaba.rocketmq.client.consumer.DefaultMQPushConsumer;
import com.alibaba.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import com.alibaba.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import com.alibaba.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import com.alibaba.rocketmq.common.consumer.ConsumeFromWhere;
import com.alibaba.rocketmq.common.message.Message;
import com.alibaba.rocketmq.common.message.MessageExt;
 
public class Consumer {
	public static void main(String[] args){
		DefaultMQPushConsumer consumer = 
				new DefaultMQPushConsumer("PushConsumer");
		consumer.setNamesrvAddr("192.168.58.163:9876"); 
		try {
			//订阅PushTopic下Tag为push的消息
			consumer.subscribe("PushTopic", "push");
			//程序第一次启动从消息队列头取数据
			consumer.setConsumeFromWhere(
					ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
			consumer.registerMessageListener(
				new MessageListenerConcurrently() {
					public ConsumeConcurrentlyStatus consumeMessage(
							List<MessageExt> list,
							ConsumeConcurrentlyContext Context) {
						Message msg = list.get(0);
						System.out.println(msg.toString());
						return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
					}
				}
			);
			consumer.start();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

可靠性

  •  所有发往broker的消息,有同步刷盘和异步刷盘机制,总的来说,可靠性非常高

  •  同步刷盘时,消息写入物理文件才会返回成功,因此非常可靠

  •  异步刷盘时, 只有机器宕机,才会产生消息丢失,broker挂掉可能会发生,但是机器宕机崩溃是很少发生的,除非突然断电

RocketMQ 怎么保证的消息不丢失

一、消息发送过程

我们将消息流程分为如下三大部分,每一部分都有可能会丢失数据。

  • 生产阶段:Producer通过网络将消息发送给Broker,这个发送可能会发生丢失,比如网络延迟不可达等。

  • 存储阶段:Broker肯定是先把消息放到内存的,然后根据刷盘策略持久化到硬盘中,刚收到Producer的消息,再内存中了,但是异常宕机了,导致消息丢失。

  • 消费阶段:消费失败了其实也是消息丢失的一种变体吧。

二、Producer生产阶段

Producer通过网络将消息发送给Broker,这个发送可能会发生丢失,比如网络延迟不可达等。

1、解决方案一

1.1、说明

有三种send方法,同步发送、异步发送、单向发送。我们可以采取同步发送的方式进行发送消息,发消息的时候会同步阻塞等待broker返回的结果,如果没成功,则不会收到SendResult,这种是最可靠的。其次是异步发送,再回调方法里可以得知是否发送成功。单向发送(OneWay)是最不靠谱的一种发送方式,我们无法保证消息真正可达。

1.2、源码

/**
 * {@link org.apache.rocketmq.client.producer.DefaultMQProducer}
 */
 
// 同步发送
public SendResult send(Message msg) throws MQClientException, RemotingException,      MQBrokerException, InterruptedException {}
 
// 异步发送,sendCallback作为回调
public void send(Message msg,SendCallback sendCallback) throws MQClientException, RemotingException, InterruptedException {}
 
// 单向发送,不关心发送结果,最不靠谱
public void sendOneway(Message msg) throws MQClientException, RemotingException, InterruptedException {}

2、解决方案二

2.1、说明

发送消息如果失败或者超时了,则会自动重试。默认是重试三次,可以根据api进行更改,比如改为10次:

producer.setRetryTimesWhenSendFailed(10);

2.2、源码 

/**
 * {@link org.apache.rocketmq.client.producer.DefaultMQProducer#sendDefaultImpl(Message, CommunicationMode, SendCallback, long)}
 */
 
// 自动重试次数,this.defaultMQProducer.getRetryTimesWhenSendFailed()默认为2,如果是同步发送,默认重试3次,否则重试1次
int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1;
int times = 0;
for (; times < timesTotal; times++) {
      // 选择发送的消息queue
    MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName);
    if (mqSelected != null) {
        try {
            // 真正的发送逻辑,sendKernelImpl。
            sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);
            switch (communicationMode) {
                case ASYNC:
                    return null;
                case ONEWAY:
                    return null;
                case SYNC:
                    // 如果发送失败了,则continue,意味着还会再次进入for,继续重试发送
                    if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
                        if (this.defaultMQProducer.isRetryAnotherBrokerWhenNotStoreOK()) {
                            continue;
                        }
                    }
                    // 发送成功的话,将发送结果返回给调用者
                    return sendResult;
                default:
                    break;
            }
        } catch (RemotingException e) {
            continue;
        } catch (...) {
            continue;
        }
    }
}
说明:

 

这里只是总结出核心的发送逻辑,并不是全代码。可以看出如下:

 

重试次数同步是1 + this.defaultMQProducer.getRetryTimesWhenSendFailed(),其他方式默认1次。

this.defaultMQProducer.getRetryTimesWhenSendFailed()默认是2,我们可以手动设置producer.setRetryTimesWhenSendFailed(10);

调用sendKernelImpl真正的去发送消息

如果是sync同步发送,且发送失败了,则continue,意味着还会再次进入for,继续重试发送

发送成功的话,将发送结果返回给调用者

如果发送异常进入catch了,则continue继续下次重试。

3、解决方案三

3.1、说明

假设Broker宕机了,但是生产环境一般都是多M多S的,所以还会有其他master节点继续提供服务,这也不会影响到我们发送消息,我们消息依然可达。因为比如恰巧发送到broker的时候,broker宕机了,producer收到broker的响应发送失败了,这时候producer会自动重试,这时候宕机的broker就被踢下线了, 所以producer会换一台broker发送消息。

4、总结

Producer怎么保证发送阶段消息可达?

失败会自动重试,即使重试N次也不行后,那客户端也会知道消息没成功,这也可以自己补偿等,不会盲目影响到主业务逻辑。再比如即使Broker挂了,那还有其他Broker再提供服务了,高可用,不影响。

总结为几个字就是:同步发送+自动重试机制+多个Master节点

三、Broker存储阶段

Broker肯定是先把消息放到内存的,然后根据刷盘策略持久化到硬盘中,刚收到Producer的消息,再内存中了,但是异常宕机了,导致消息丢失。

1、解决方案一

MQ持久化消息分为两种:同步刷盘和异步刷盘。默认情况是异步刷盘,Broker收到消息后会先存到cache里然后立马通知Producer说消息我收到且存储成功了,你可以继续你的业务逻辑了,然后Broker起个线程异步的去持久化到磁盘中,但是Broker还没持久化到磁盘就宕机的话,消息就丢失了。同步刷盘的话是收到消息存到cache后并不会通知Producer说消息已经ok了,而是会等到持久化到磁盘中后才会通知Producer说消息完事了。这也保障了消息不会丢失,但是性能不如异步高。看业务场景取舍。

修改刷盘策略为同步刷盘。默认情况下是异步刷盘的,如下配置


## 默认情况为 ASYNC_FLUSH,修改为同步刷盘:SYNC_FLUSH,实际场景看业务,同步刷盘效率肯定不如异步刷盘高。
flushDiskType = SYNC_FLUSH 

对应的Java配置类如下:

package org.apache.rocketmq.store.config;
 
public enum FlushDiskType {
    // 同步刷盘
    SYNC_FLUSH,
    // 异步刷盘(默认)
    ASYNC_FLUSH
}

异步刷盘默认10s执行一次,源码如下:

/*
 * {@link org.apache.rocketmq.store.CommitLog#run()}
 */
 
while (!this.isStopped()) {
    try {
        // 等待10s
        this.waitForRunning(10);
        // 刷盘
        this.doCommit();
    } catch (Exception e) {
        CommitLog.log.warn(this.getServiceName() + " service has exception. ", e);
    }
}

2、解决方案二

集群部署,主从模式,高可用。

即使Broker设置了同步刷盘策略,但是Broker刷完盘后磁盘坏了,这会导致盘上的消息全TM丢了。但是如果即使是1主1从了,但是Master刷完盘后还没来得及同步给Slave就磁盘坏了,不也是GG吗?没错!

所以我们还可以配置不仅是等Master刷完盘就通知Producer,而是等Master和Slave都刷完盘后才去通知Producer说消息ok了。

## 默认为 ASYNC_MASTER
brokerRole=SYNC_MASTER

3、总结

若想很严格的保证Broker存储消息阶段消息不丢失,则需要如下配置,但是性能肯定远差于默认配置。

# master 节点配置
flushDiskType = SYNC_FLUSH
brokerRole=SYNC_MASTER
 
# slave 节点配置
brokerRole=slave
flushDiskType = SYNC_FLUSH

上面这个配置含义是:

Producer发消息到Broker后,Broker的Master节点先持久化到磁盘中,然后同步数据给Slave节点,Slave节点同步完且落盘完成后才会返回给Producer说消息ok了。

四、Consumer消费阶段

消费失败了其实也是消息丢失的一种变体。

1、解决方案一

消费者会先把消息拉取到本地,然后进行业务逻辑,业务逻辑完成后手动进行ack确认,这时候才会真正的代表消费完成。而不是说pull到本地后消息就算消费完了。举个例子

 consumer.registerMessageListener(new MessageListenerConcurrently() {
     @Override
     public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
         for (MessageExt msg : msgs) {
             String str = new String(msg.getBody());
             System.out.println(str);
         }
         // ack,只有等上面一系列逻辑都处理完后,到这步CONSUME_SUCCESS才会通知broker说消息消费完成,如果上面发生异常没有走到这步ack,则消息还是未消费状态。而不是像比如redis的blpop,弹出一个数据后数据就从redis里消失了,并没有等我们业务逻辑执行完才弹出。
         return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
     }
 });

2、解决方案二

消息消费失败自动重试。如果消费消息失败了,没有进行ack确认,则会自动重试,重试策略和次数(默认15次)如下配置

/**
 * Broker可以配置的所有选项
 */
public class org.apache.rocketmq.store.config.MessageStoreConfig {
    private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
}

如果处理重复消息

我们先来看看能不能避免消息的重复。

假设我们发送消息,就管发,不管Broker的响应,那么我们发往Broker是不会重复的。

但是一般情况我们是不允许这样的,这样消息就完全不可靠了,我们的基本需求是消息至少得发到Broker上,那就得等Broker的响应,那么就可能存在Broker已经写入了,当时响应由于网络原因生产者没有收到,然后生产者又重发了一次,此时消息就重复了。

再看消费者消费的时候,假设我们消费者拿到消息消费了,业务逻辑已经走完了,事务提交了,此时需要更新Consumer offset了,然后这个消费者挂了,另一个消费者顶上,此时Consumer offset还没更新,于是又拿到刚才那条消息,业务又被执行了一遍。于是消息又重复了。

可以看到正常业务而言消息重复是不可避免的,因此我们只能从另一个角度来解决重复消息的问题。

关键点就是幂等。既然我们不能防止重复消息的产生,那么我们只能在业务上处理重复消息所带来的影响。

幂等处理重复消息

幂等是数学上的概念,我们就理解为同样的参数多次调用同一个接口和调用一次产生的结果是一致的。

例如这条 SQL update t1 set money = 150 where id = 1 and money = 100; 执行多少遍money都是150,这就叫幂等。

因此需要改造业务处理逻辑,使得在重复消息的情况下也不会影响最终的结果。

可以通过上面我那条 SQL 一样,做了个前置条件判断,即money = 100情况,并且直接修改,更通用的是做个version即版本号控制,对比消息中的版本号和数据库中的版本号。

或者通过数据库的约束例如唯一键,例如insert into update on duplicate key...

或者记录关键的key,比如处理订单这种,记录订单ID,假如有重复的消息过来,先判断下这个ID是否已经被处理过了,如果没处理再进行下一步。当然也可以用全局唯一ID等等。

基本上就这么几个套路,真正应用到实际中还是得看具体业务细节

如何保证消息的有序性

有序性分:全局有序和部分有序

全局有序

如果要保证消息的全局有序,首先只能由一个生产者往Topic发送消息,并且一个Topic内部只能有一个队列(分区)。消费者也必须是单线程消费这个队列。这样的消息就是全局有序的!

不过一般情况下我们都不需要全局有序,即使是同步MySQL Binlog也只需要保证单表消息有序即可。

部分有序

因此绝大部分的有序需求是部分有序,部分有序我们就可以将Topic内部划分成我们需要的队列数,把消息通过特定的策略发往固定的队列中,然后每个队列对应一个单线程处理的消费者。这样即完成了部分有序的需求,又可以通过队列数量的并发来提高消息处理效率。

图中我画了多个生产者,一个生产者也可以,只要同类消息发往指定的队列即可。

如果处理消息堆积

消息的堆积往往是因为生产者的生产速度与消费者的消费速度不匹配。有可能是因为消息消费失败反复重试造成地,也有可能就是消费者消费能力弱,渐渐地消息就积压了。

因此我们需要先定位消费慢的原因,如果是bug则处理 bug ,如果是因为本身消费能力较弱,我们可以优化下消费逻辑,比如之前是一条一条消息消费处理的,这次我们批量处理,比如数据库的插入,一条一条插和批量插效率是不一样的。

假如逻辑我们已经都优化了,但还是慢,那就得考虑水平扩容了,增加Topic的队列数和消费者数量,注意队列数一定要增加,不然新增加的消费者是没东西消费的。一个Topic中,一个队列只会分配给一个消费者

当然你消费者内部是单线程还是多线程消费那看具体场景。不过要注意上面提高的消息丢失的问题,如果你是将接受到的消息写入内存队列之后,然后就返回响应给Broker,然后多线程向内存队列消费消息,假设此时消费者宕机了,内存队列里面还未消费的消息也就丢了。

6.elk:Elasticsearch、Logstash 和Kibana 

7.SpringBoot自动加载的原理

首先我们准备一个spring boot的项目,找到spring boot的启动类,它的结构大致是这样的

@SpringBootApplication
public class MiniWebApplication {

    public static void main(String[] args) {
        SpringApplication.run(MiniWebApplication.class, args);
    }

}

我们知道,java程序执行都是从main方法开始的。在main中,执行了SpringApplication.run()方法,这个方法表示SpringApplication的执行入口。在执行run()方法的时候,SpringBoot就已经将所有的配置加载进来了。

@SpringBootApplicationSpring Boot应用标注在某个类上说明这个是SpringBoot的主配置类,SpringBoot应该允许类的main方法启动Spring Boot应用。

我们进入这个注解的定义可以看到:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

@EnableAutoConfiguration,该注解为springboot自动加载配置信息的入口(@SpringBootApplication依赖该注解)

该注解告诉springboot扫描项目下的所有jar包,查找classpath:META-INF/spring.factories文件,加载该文件中的配置信息,格式如下:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=core.bean.MyConfig

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {

loadFactoryNames方法的作用是把/spring.factories文件中的配置类转化为对象,我们可以看到SpringApplication对象实例化时就是在这里加载META-INF/spring.factories文件。

@Import(AutoConfigurationImportSelector.class) 表示导入哪些组件的选择器,将所有需要导入的组件以全类名的方式返回,之后导入到spring容器中;

这里会导入非常多的自动配置类(xxxAutoConfiguration) 就是给容器中导入这个场景需要的所有组件,并配置好这些组件;免去了手动编写配置类的工作;

程序会在AutoConfigurationImportSelector类中读取配置,并将它们加载到bean容器中去。

protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
		List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
				getBeanClassLoader());
		Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you "
				+ "are using a custom packaging, make sure that file is correct.");
		return configurations;
	}

Spring Boot在启动的时候,会去META-INF/spring.properties文件中获取EnableAutoConfiguration的指定的值,将这些值通过反射的方式将对应的类导入到容器中,自动配置类生效;

每一个xxxAutoConfiguration类都是容器中的一个组件,都可以加入到容器中,使用他们作为自动配置。这个时候每一个自动配置类进行自动配置。

Spring Boot 在启动的时候会自动加载默认配置,会对spring-boot-autoconfiguration包下的METF-INF/spring.factories文件进行扫描,读取可以加载到的类名,通过IOC将符合条件的类加载到容器中去。

每一个自动加载类都会有一个有参构造器,传入spring boot 默认以及用户的配置,这样就实现了自动加载配置的过程。

 

7.Hashmap的结构,1.7和1.8有哪些区别

(1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

(2)扩容后数据存储位置的计算方式也不一样:1. 在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1)

2、而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。

这里写图片描述

在计算hash值的时候,JDK1.7用了9次扰动处理=4次位运算+5次异或,而JDK1.8只用了2次扰动处理=1次位运算+1次异或。

扩容流程对比图:

这里写图片描述

 

(3)JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)

(2)为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢(面试蘑菇街问过)?

  • 如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
  • 还有一点重要的就是由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值

1结构区别

Jdk1.8

HashMap1.8的底层数据结构是数组+链表+红黑树。

Jdk1.7

HashMap 1.7的底层数据结构是数组加链表

区别:

  • 一般情况下,以默认容量16为例,阈值等于12就扩容,单条链表能达到长度为8的概率是相当低的,除非Hash攻击或者HashMap容量过大出现某些链表过长导致性能急剧下降的问题,红黑树主要是为了结果这种问题。
  • 在正常情况下,效率相差并不大。

2.节点区别

区别:

Jdk1.8

  • hash是final修饰,也就是说hash值一旦确定,就不会再重新计算hash值了。
  • 新增了一个TreeNode节点,为了转换为红黑树。

Jdk1.7

  • hash是可变的,因为有rehash的操作。

HashMap 1.7

static class Entry<K,V> implements Map.Entry<K,V> {  
final K key;  
V value;  
Entry<K,V> next;  
int hash;  
}  

 

HashMap1.8

static class Node<K,V> implements Map.Entry<K,V> {  
final int hash;  
final K key;  
V value;  
Node<K,V> next;  
}  
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {  
TreeNode<K,V> parent;  // red-black tree links  
TreeNode<K,V> left;  
TreeNode<K,V> right;  
TreeNode<K,V> prev;    // needed to unlink next upon deletion  
boolean red;  
}  

3.Hash算法区别

区别

  • 1.8计算出来的结果只可能是一个,所以hash值设置为final修饰。
  • 1.7会先判断这Object是否是String,如果是,则不采用String复写的hashcode方法,处于一个Hash碰撞安全问题的考虑

Jdk1.7

final int hash(Object k) {  
int h = hashSeed;  
if (0 != h && k instanceof String) {  
return sun.misc.Hashing.stringHash32((String) k);  
}  
  
h ^= k.hashCode();  
  
// This function ensures that hashCodes that differ only by  
// constant multiples at each bit position have a bounded  
// number of collisions (approximately 8 at default load factor).  
h ^= (h >>> 20) ^ (h >>> 12);  
return h ^ (h >>> 7) ^ (h >>> 4);  
} 

Jdk1.8

static final int hash(Object key) {  
int h;  
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
} 

4对Null的处理

Jdk1.7

Jdk1.7中,对null值做了单独的处理

简单的说,HashMap会遍历数组的下标为0的链表,循环找key=null的键,如果找到则替换。

如果当前数组下标为0的位置为空,即e==null,那么直接执行添加操作,key=null,插入位置为0。

public V put(K key, V value) {  
//判断是否是空值  
if (key == null)  
return putForNullKey(value);  
...  
} 
private V putForNullKey(V value) {  
for (Entry<K,V> e = table[0]; e != null; e = e.next) {  
if (e.key == null) {  
V oldValue = e.value;  
e.value = value;  
e.recordAccess(this);  
return oldValue;  
}  
}  
modCount++;  
addEntry(0, null, value, 0);  
return null;  
}  

Jdk1.8

而1.8中,由于Hash算法中会将null的hash值计算为0,插入时0&任何数都是0,插入位置为数组的下标为0的位置,所以我们可以认为,1.8中null为键和其他非null是一样的,也有hash值,也能别替换。只是计算结果为0而已。

static final int hash(Object key) {  
int h;  
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
}  

区别

  • Jdk1.7中,null是一个特殊的值,单独处理
  • Jdk1.8中,null的hash值计算结果为0,其他地方和普通的key没区别。

5初始化的区别

我们常说Jdk1.8是懒加载,真的是这样吗?

Jdk1.8

transient Node<K,V>[] table;

构造方法

public HashMap(int initialCapacity, float loadFactor) {  
...  
this.loadFactor = loadFactor;  
this.threshold = tableSizeFor(initialCapacity);  
}  
  
public HashMap(int initialCapacity) {  
this(initialCapacity, DEFAULT_LOAD_FACTOR);  
}  
  
public HashMap() {  
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted  
} 

我们简答看一下tableSizeFor()方法,其实这个算法和Integer的highestOneBit()方法一样。

static final int tableSizeFor(int cap) {  
int n = cap - 1;  
n |= n >>> 1;  
n |= n >>> 2;  
n |= n >>> 4;  
n |= n >>> 8;  
n |= n >>> 16;  
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;  
} 

官方解释:Returns a power of two size for the given target capacity. (返回给定目标容量的二次幂。)

也就是获取比传入参数大的最小的2的N次幂。
比如:传入8,就返回8,传入9,就返回16.

Jdk1.7

Jdk1.7中,table在声明时就初始化为空表。

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

构造方法和Jdk1.8一致,但是没有立刻根据给定的初始容量去计算那个2的次幂。

public HashMap(int initialCapacity, float loadFactor) {  
...  
this.loadFactor = loadFactor;  
threshold = initialCapacity;  
}  
  
public HashMap(int initialCapacity) {  
this(initialCapacity, DEFAULT_LOAD_FACTOR);  
}  
  
public HashMap() {  
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);  
} 

我们可以看一下HashMap1.7的计算容量的方法

首先是put方法时,发现是空表,初始化。传入threshold,也就是我们之前传入的initCapactity自定义初始容量

public V put(K key, V value) {  
//判断是否是空表  
if (table == EMPTY_TABLE) {  
//初始化  
inflateTable(threshold);  
}  
...  
}  

这个方法也有官方的注释,意思就是找到大于等给定toSize的最小2的次幂

private void inflateTable(int toSize) {  
// Find a power of 2 >= toSize  
int capacity = roundUpToPowerOf2(toSize);  
...  
}  

我们发现,这个方法没有什么操作难度,是个人都可以写的出来

private static int roundUpToPowerOf2(int number) {  
// assert number >= 0 : "number must be non-negative";  
return number >= MAXIMUM_CAPACITY  
? MAXIMUM_CAPACITY  
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;  
}  

最终调用了Integer的计算2次幂的方法。

public static int highestOneBit(int i) {  
// HD, Figure 3-1  
i |= (i >>  1);  
i |= (i >>  2);  
i |= (i >>  4);  
i |= (i >>  8);  
i |= (i >> 16);  
return i - (i >>> 1);  
}  

和1.8的是一致的,但是我们阅读源码发现1.8更趋向于一个方法完成一个大的功能,比如putVal,resize,代码阅读性比较差,而1.7趋向于尽可能的方法拆分,提升阅读性,但是也增加了嵌套关系,结构复杂。

区别

Jdk1.7:

  • table是直接赋值给了一个空数组,在第一次put元素时初始化和计算容量。

  • table是单独定义的inflateTable()初始化方法创建的。

Jdk1.8

  • 的table没有赋值,属于懒加载,构造方式时已经计算好了新的容量位置(大于等于给定容量的最小2的次幂)。
  • table是resize()方法创建的。

6扩容的区别

扩容的区别总结

Jdk1.7:

  • 头插法,添加前先判断扩容,当前准备插入的位置不为空并且容量大于等于阈值才进行扩容,是两个条件
  • 扩容后可能会重新计算hash值。

Jdk1.8:

  • 尾插法,初始化时,添加节点结束之后和判断树化的时候都会去判断扩容。我们添加节点结束之后只要size大于阈值,就一定会扩容,是一个条件
  • 由于hash是final修饰,通过e.hash & oldCap==0来判断新插入的位置是否为原位置。

7节点插入的区别

区别

  • jdk1.7无论是resize的转移和新增节点createEntry,都是头插法
  • jdk1.8则都是尾插法,为什么这么做呢为了解决多线程的链表死循环问题。

究极总结
 

比较HashMap1.7HashMap1.8
数据结构数组+链表数组+链表+红黑树
节点EntryNode TreeNode
Hash算法较为复杂异或hash右移16位
对Null的处理单独写一个putForNull()方法处理作为以一个Hash值为0的普通节点处理
初始化赋值给一个空数组,put时初始化没有赋值,懒加载,put时初始化
扩容插入前扩容插入后,初始化,树化时扩容
节点插入头插法尾插法

 

并发的HashMap为什么会引起死循环?

今天研读Java并发容器和框架时,看到为什么要使用ConcurrentHashMap时,其中有一个原因是:线程不安全的HashMap, HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表形成环形数据结构,查找时会陷入死循环。纠起原因看了其他的博客,都比较抽象,所以这里以图形的方式展示一下,希望支持!

 

(1)当往HashMap中添加元素时,会引起HashMap容器的扩容,原理不再解释,直接附源代码,如下:

/** 
    * 
    * 往表中添加元素,如果插入元素之后,表长度不够,便会调用resize方法扩容 
    */  
   void addEntry(int hash, K key, V value, int bucketIndex) {  
Entry<K,V> e = table[bucketIndex];  
       table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
       if (size++ >= threshold)  
           resize(2 * table.length);  
   }  
  
   /** 
    * resize()方法如下,重要的是transfer方法,把旧表中的元素添加到新表中
    */  
   void resize(int newCapacity) {  
       Entry[] oldTable = table;  
       int oldCapacity = oldTable.length;  
       if (oldCapacity == MAXIMUM_CAPACITY) {  
           threshold = Integer.MAX_VALUE;  
           return;  
       }  
  
       Entry[] newTable = new Entry[newCapacity];  
       transfer(newTable);  
       table = newTable;  
       threshold = (int)(newCapacity * loadFactor);  
   }  

(2)参考上面的代码,便引入到了transfer方法,(引入重点)这就是HashMap并发时,会引起死循环的根本原因所在,下面结合transfer的源代码,说明一下产生死循环的原理,先列transfer代码(这是里JDK7的源偌),如下:

/**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
 
            while(null != e) {
                Entry<K,V> next = e.next;            ---------------------(1)
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); 
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } // while
 
        }
    }

3)假设:

Map<Integer> map = new HashMap<Integer>(2);  // 只能放置两个元素,其中的threshold为1(表中只填充一个元素时),即插入元素为1时就扩容(由addEntry方法中得知)
//放置2个元素 3 和 7,若要再放置元素8(经hash映射后不等于1)时,会引起扩容

假设放置结果图如下:

 现在有两个线程A和B,都要执行put操作,即向表中添加元素,即线程A和线程B都会看到上面图的状态快照

执行顺序如下:

               执行一:  线程A执行到transfer函数中(1)处挂起(transfer函数代码中有标注)。此时在线程A的栈中

e = 3
next = 7

执行二:线程B执行 transfer函数中的while循环,即会把原来的table变成新一table(线程B自己的栈中),再写入到内存中。如下图(假设两个元素在新的hash函数下也会映射到同一个位置

执行三: 线程A解挂,接着执行(看到的仍是旧表),即从transfer代码(1)处接着执行,当前的 e = 3, next = 7, 上面已经描述。

                          

         1. 处理元素 3 , 将 3 放入 线程A自己栈的新table中(新table是处于线程A自己栈中,是线程私有的,不肥线程2的影响),处理3后的图如下:

2.  线程A再复制元素 7 ,当前 e = 7 ,而next值由于线程 B 修改了它的引用,所以next 为 3 ,处理后的新表如下图

  3. 由于上面取到的next = 3, 接着while循环,即当前处理的结点为3, next就为null ,退出while循环,执行完while循环后,新表中的内容如下图:

4. 当操作完成,执行查找时,会陷入死循环!

总结

HashMap的方法不是线程安全的。HashMap在并发执行put操作时发生扩容,可能会导致节点丢失,产生环形链表等情况。

  • 节点丢失,会导致数据不准
  • 生成环形链表,会导致get()方法死循环。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值