canal+MQ,会有乱序的问题吗?
1 如何保证消息顺序?
如何实现消息有序?实现顺序消息所必要的条件:顺序发送、顺序存储、顺序消费。
在MQ模型中,顺序需由3个阶段去保障
-
顺序发送: 发送时保持顺序一致
-
顺序存储: broker 存储时保持 顺序一致
-
顺序消费: 消费时 保持 顺序一致
2 消息有序的两大类型
有序消息,又叫顺序消息(FIFO消息),指消息的消费顺序和产生顺序相同。
如订单的生成、付款、发货,这串消息必须按序处理。
顺序消息又可分为全局有序和局部有序:
-
全局有序:整个MQ系统的所有消息严格按照队列先入先出顺序进行消费
-
局部有序:只保证一部分关键信息的消费顺序
2.1 全局顺序
一个Topic内所有的消息都发布到同一Q,按FIFO顺序进行发布和消费:
落地到RocketMQ,如何保证全局有序?
为了 保证Topic全局消息有序的方式,就是将Topic配置成只有一个唯一的MessageQueue队列, 默认是4个MessageQueue。
RocketMQ中,可以在发送者发送消息时指定一个MessageSelector对象,让这个对象来决定消息发入哪一个MessageQueue。这样就可以保证一组有序的消息能够发到同一个MessageQueue里。
适用场景
性能要求不高,所有消息严格按照FIFO进行消息发布和消费的场景。
2.2 分区顺序
对于指定的一个Topic,所有消息按sharding key
进行区块(queue)分区,同一Queue内的消息严格按FIFO发布和消费。
-
Sharding key是顺序消息中用来区分不同分区的关键字段,和普通消息的Key完全不同。
落地到RocketMQ。而MessageQueue是RocketMQ存储消息的最小单元,他们之间的消息都是互相隔离的,在这种情况下,是无法保证消息全局有序的,但是可以保证局部有序。
默认的做法是,发送消息时,会通过MessageQueue轮询的方式保证消息尽量均匀分布到所有的MessageQueue上,而消费者也就同样需要从多个MessageQueue上消费消息。这就做不到局部有序。
对于局部有序的要求,只需要将有序的一组消息都存入同一个MessageQueue里,这样MessageQueue的FIFO设计天生就可以保证这一组消息的有序。
适用场景
性能要求高,根据消息中的sharding key去决定消息发送到哪个queue。
其实大部分的MQ业务场景,我们只需要保证局部有序就可以了。
例如
-
我们用QQ聊天,只需要保证一个聊天窗口里的消息有序就可以了。
-
而对于电商订单场景,也只要保证一个订单的所有消息是有序的就可以了。
2.3 对比
发送方式对比
存储方式对比
-
无
消费方式对比
-
有序消费的消费者类型:ConsumeMessageConcurrentlyService 并发消费服务
-
无序消费的消费者类型:ConsumeMessageOrderlyService
3 应用开发维度的实现消息有序需要做的工作:
实现顺序消息所必要的条件:顺序发送、顺序存储、顺序消费。 顺序存储环节,RocketMQ 里的分区队列 MessageQueue 本身是能保证 FIFO 的。
所以,在应用开发过程中,不能顺序消费消息主要有两个原因:
-
顺序发送环节,消息发生没有序:Producer 发送消息到 MessageQueue 时是轮询发送的,消息被发送到不同的分区队列,就不能保证 FIFO 了。
-
顺序消费环节,消息消费无序:Consumer 默认是多线程并发消费同一个 MessageQueue 的,即使消息是顺序到达的,也不能保证消息顺序消费。
我们知道了实现顺序消息所必要的条件:顺序发送、顺序存储、顺序消费。
顺序存储 由 Rocketmq 完成,所以,在应用开发层, 消息的顺序需要由两个阶段保证:
-
消息发送有序
-
消息消费有序
4:canal+MQ,如何实现有序?
Canal 是阿里巴巴开源的一个增量订阅和消费的中间件,用于基于 MySQL 的数据库增量日志解析(Binlog)。通过 Canal,可以实现对数据库的实时监控和数据同步。
我们在通过Canal把MySQL的Binlog数据发送到MQ(kafak/rocketmq)时,需要关注好几个环节:
-
Cannal 的有序监听。 Binlog本身是有序的,写入到mq之后如何保障顺序
-
Cannal 的有序发送。
-
顺序存储: broker 存储时保持 顺序一致
-
顺序消费: 消费时 保持 顺序一致
4.1 Cannal 的有序发送
canal目前支持kafka和rocketmq,在使用 Canal 进行数据同步时,保证数据的有序性是一个重要的问题,尤其是在分布式环境中。在 Kafka 或 RocketMQ 等消息队列中,消息的顺序性和分区策略至关重要。
canal 本质上都是基于本地文件的方式来支持分区级别的顺序消息,也就是binlog写入mq是可以有一定的顺序性保障,这个保障级别取决于用户的两个配置项:
-
canal.mq.partitionsNum
-
canal.mq.partitionHash
两个配置项 用于控制消息的分区和顺序。两个配置项介绍如下:
-
canal.mq.partitionsNum:
-
描述:设置消息队列的分区数量。
-
作用:决定了消息在消息队列中被分区的数量。不同的分区可以并行处理,但需要注意同一个分区内的消息是有序的。
-
-
canal.mq.partitionHash:
-
描述:设置消息分区的哈希规则。
-
作用:用于指定分区的哈希策略,可以根据特定的字段进行分区。例如,可以根据表名、主键等字段进行分区,以保证某些关键数据的有序性。
-
4.2 Cannal 的有序发送示例
假设我们希望将数据同步到 Kafka,并且需要保证某张表的数据是有序的,可以使用以下配置:
canal.mq.partitionsNum=10
canal.mq.partitionHash=my_database.my_table:id
配置解释
-
canal.mq.partitionsNum=10:
-
表示消息会被分成 10 个分区。每个分区可以并行处理,从而提高处理效率。
-
-
canal.mq.partitionHash=my_database.my_table
-
针对
my_database.my_table
表的数据,根据id
字段进行哈希分区。 -
这样可以确保同一个
id
的所有变更都进入同一个分区,从而保证该id
的变更顺序不变。
-
通过配置 canal.mq.partitionHash
,分区内的消息是有序的,因此只要保证同一实体(如同一行数据)的变更进入同一个分区,就能保证其有序性。
4.3 Cannal 的使用场景
-
实时数据同步:将数据库的变更实时同步到其他系统,如搜索引擎、缓存等。
-
数据备份:实现数据库的实时备份,确保数据的一致性和完整性。
-
事件驱动架构:在事件驱动架构中,利用 Canal 将数据库变更作为事件发布到消息队列,供其他系统消费。
通过合理配置 canal.mq.partitionsNum
和 canal.mq.partitionHash
,可以在数据同步中既保证有序性,又提高处理效率。
600万订单每秒 Disruptor 解决消息不丢失
队列之王 Disruptor 简介
Disruptor是英国外汇交易公司LMAX开发的一个高性能队列,研发的初衷是解决内存队列的延迟问题(在性能测试中发现竟然与I/O操作处于同样的数量级)。
目前,包括Apache Storm、Camel、Log4j 2在内的很多知名项目都应用了Disruptor以获取高性能。
Disruptor通过以下设计来,来实现 单线程能支撑每秒600万订单的问题:
-
核心架构1:无锁架构 生产和消费,都是无锁架构。具体来说,生产者位点/消费者位点的操作,都是无锁操作,或者使用轻量级CAS原子操作。
无锁架构好处是,既没有锁的竞争, 也没有线程的内核态、用户态切换的开销。
-
核心架构2:环形数组架构
数组元素不会被回收,避免频繁的GC,所以,为了避免垃圾回收,采用数组而非链表。
同时,数组对处理器的缓存机制更加友好。数组长度2^n,通过位运算,加快定位的速度。
下标采取递增的形式。不用担心index溢出的问题。index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。
-
核心架构3:cache line padding
两个维度的CPU 缓存行加速,享受到 CPU Cache 那风驰电掣的速度带来的红利:
第一维度: 对 位点等核心组件进行 CPU cache line padding,实现高并发访问(修改和取值)。
第二个维度: ringbuffer 是一个数据,加载的时候一般也会塞满整个 CPU cache line。也就是说 从内存加载数据到 CPU Cache 里面的时候, 如果是加载数组里面的数据(如 Disruptor),那么 CPU 就会加载到数组里面连续的多个数据。 所以,Disruptor 数组的遍历、还是位点的增长, 很容易享受到 CPU Cache 那风驰电掣的速度带来的红利。
SpringBoot + Disruptor 使用实战
使用 Disruptor 实现一个生产消费模型步骤是:
-
准备好简单的一个springboot应用
-
定义事件(Event) : 你可以把 Event 理解为存放在队列中等待消费的消息对象。
-
创建事件工厂 :事件工厂用于生产事件,我们在初始化 Disruptor 类的时候需要用到。
-
创建处理事件的 Handler :Event 在对应的 Handler 中被处理,你可以将其理解为生产消费者模型中的消费者。
-
创建并装配 Disruptor : 事件的生产和消费需要用到Disruptor 对象。
-
定义生产者,并使用生产者发消息
-
对简单的SpringBoot + Disruptor 进行扩容,实现 容量监控预警+ 动态扩容
定义一个Event和工厂
首先定义一个Event来包含需要传递的数据:
由于需要让Disruptor为我们创建事件,我们同时还声明了一个EventFactory来创建Event对象。
public class LongEventFactory implements EventFactory {
@Override
public Object newInstance() {
return new LongEvent();
}
}
定义事件处理器(消费者)
我们还需要一个事件消费者,也就是一个事件处理器。
这个例子中,事件处理器的工作,就是简单地把事件中存储的数据打印到终端:
/**
* 类似于消费者
* disruptor会回调此处理器的方法
*/
static class LongEventHandler implements EventHandler<LongEvent> {
@Override
public void onEvent(LongEvent longEvent, long l, boolean b) throws Exception {
System.out.println(longEvent.getValue());
}
}
disruptor会回调此处理器的方法
定义事件源(生产者)
事件都会有一个生成事件的源,类似于 生产者的角色.
注意,这是一个 600wqps 能力的 异步生产者。 这里定义两个版本:
生产者的角色的接口定义如下
简单 DisruptorProducer 生产者的定义和使用
package com.crazymaker.cloud.disruptor.demo.business.impl;
@Slf4j
public class DisruptorProducer implements AsyncProducer {
//一个translator可以看做一个事件初始化器,publicEvent方法会调用它
//填充Event
private static final EventTranslatorOneArg<LongEvent, Long> TRANSLATOR =
new EventTranslatorOneArg<LongEvent, Long>() {
public void translateTo(LongEvent event, long sequence, Long data) {
event.setValue(data);
}
};
private final RingBuffer<LongEvent> ringBuffer;
public DisruptorProducer() {
this.ringBuffer = disruptor().getRingBuffer();
}
public void publishData(Long data) {
log.info("生产一个数据:" + data + " | ringBuffer.remainingCapacity()= " + ringBuffer.remainingCapacity());
ringBuffer.publishEvent(TRANSLATOR, data);
}
private Disruptor<LongEvent> disruptor() {
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("DisruptorThreadPool").build();
LongEventFactory eventFactory = new LongEventFactory();
int bufferSize = 1024;
Disruptor<LongEvent> disruptor = new Disruptor<>(eventFactory, bufferSize, namedThreadFactory,
ProducerType.MULTI, new BlockingWaitStrategy());
// 连接 消费者 处理器 ,两个消费者
LongEventWorkHandler1 handler1 = new LongEventWorkHandler1();
LongEventWorkHandler2 handler2 = new LongEventWorkHandler2();
disruptor.handleEventsWith(handler1, handler2);
//为消费者配置异常处理器
disruptor.handleExceptionsFor(handler1).with(exceptionHandler);
disruptor.handleExceptionsFor(handler2).with(exceptionHandler);
// 开启 分裂者(事件分发)
disruptor.start();
return disruptor;
}
ExceptionHandler exceptionHandler =...//省略非核心代码 异常处理器实现
}
上面的代码,通过 disruptor() 方法创建和装配 一个Disruptor对象 ,Disruptor 里边有一个环形队列。然后 disruptor() 方法给 Disruptor对象设置消费者,并且为消费者设置异常处理器。
使用这一个简单 DisruptorProducer 生产者
定义一个配置类,用于实例化 生产者
定义controller, 注入这个 生产者,就可以异步发布数据 给消费者了
springboot应用启动之后, 可以通过 httpclient 工具,测试一下:
看一下测试数据
Disruptor:消费数据丢失问题的分析与解决
在处理高并发、大数据量等场景时,Disruptor虽然其高性能、低延迟,然而,在使用过程中,一些用户可能会遇到消费数据丢失问题。
为了解决这些问题,我们需要深入了解Disruptor的工作原理,并采取相应的解决方案。
消费数据丢失问题的根因
消费线程丢失问题通常发生在消费者处理速度跟不上生产者的时候。
由于Disruptor采用环形队列来存储数据,当队列满时,新的数据会覆盖旧的数据。
Disruptor 中,生产和消费的index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。
生产和消费的index 下标采取递增的形式。不用担心index溢出的问题。
生产和消费的index 是通过 取模, 映射到 ring 环形数据的。
如果消费者速度慢, 生产者快,消费跟不上,生产的index(/Sequence)就会越来越大,取模的时候,又会从0开始,去覆盖ring前面的数据,从而导致没有消费的数据被丢失。
从上图可以看到,只要生产者 的Sequence 大于消费者 一个ring 的数量, 就开始 覆盖旧的数据,也就是开始丢失数据。
消费数据丢失问题解决方案:
-
增加消费者数量:增加消费者线程的数量,可以并行处理更多的数据,提高消费速度。
同时,合理配置消费者与生产者的数量比例,确保队列生产者 的Sequence 不会大于消费者 一个ring 的数量。
-
增加ring环形数组的大小:通过增加数组的大小,从而保证一个环可以存放足够多的数据,但这个可能会导致OOM。
-
剩余容量监控与告警: 通过Prometheus 对 remainingCapacity剩余容量 进行实时监控,当remainingCapacity 超过80%(阈值)及时发出告警通知相关人员处理,进行微服务实例的 HPA 横向扩容,或者进行 Disruptor 队列进行动态扩容。
-
Disruptor 动态扩容 对 Disruptor 框架进行合理封装,从单个Disruptor 队列模式,变成 ringbuffer 数组的形式,并且可以结合nacos 或者 Zookeeper 这种发布订阅组件, 对 ringbuffer 数组 进行在线扩容。
总之,通过增加消费者数量、增加ring环形数组的大小、剩余容量监控与告警, Disruptor 动态扩容等方式,可以有效解决 消费数据丢失问题。
高级版:Spring Boot + Prometheus 监控剩余容量 大小
我们的微服务项目中使用了 spring boot,集成 prometheus。
我们可以通过将remainingCapacity 作为指标暴露到 prometheus 中,通过如下代码:
增加这个代码之后,请求 /actuator/prometheus
之后,可以看到对应的返回:
这样,当这个值低于20%,我们就认为这个剩余空间不够,可以扩容了。
Disruptor 如何 动态扩容?
关于 Disruptor 动态扩容的方案,可以实现一个可以扩容的子类
定义一个环形队列的数据
private Disruptor<LongEvent>[] executors;
在构造函数中,初始化为1:
public ResizableDisruptorProducer() {
executors = new Disruptor[1];
executors[0] = disruptor();
this.ringBuffer = executors[0].getRingBuffer();
}
发布事件的时候,通过取模的方式,确定使用executors 数组 其中的一个RingBuffer
在next方法,执行indx 取模和 获取 ringbuffer 的操作
这里参考了netty源码里边 PowerOfTwoEventExecutorChooser 反应器选择的方式,使用位运算取模,从而实现高性能取模。
什么时候扩容呢? 当监控发现超过80%的阈值后,运维会收到告警,然后可以通过naocos、Zookeeper的发布订阅, 通知微服务进行扩容。
微服务 扩容要回调下面的resize方法
在使用Disruptor框架时,需要根据实际情况选择合适的监控和扩容解决方案,并不断优化和调整系统配置,以满足日益增长的业务需求。
网易面试:JDK1.8将HashMap 头插法 改 尾插法,为何?
什么是哈希表
HashMap是Java中的一种基于哈希表实现的,它允许我们使用键值对的形式来存储和获取数据。
从根本上来说,一个哈希表包含一个数组,但是元素访问不是通过 index 编号的形式(比如 array[i]的形式),而是通过特殊的关键码(也就是key)来访问数组中的元素。
哈希表的主要思想是:
-
存放Value的时候,通过一个哈希函数,通过 **关键码(key)**进行哈希运算得到哈希值,然后得到 映射到(map到)的位置, 去存放值 ,
-
读取Value的时候,也是通过同一个哈希函数,通过 **关键码(key)**进行哈希运算得到哈希值,然后得到 映射到(map到)的位置,从那个位置去读取。
非常类似下面的字典图,如果我们要找 “啊” 这个字,只要根据拼音 “a” 去查找拼音索引,
查找 “a” 在字典中的索引位置 “啊”,这个过程就是哈希函数的作用,用公式来表达就是:f(key),而这样的函数所建立的表就是哈希表。
哈希表这么做的优势:主要是为了加快了查找key的速度。
在不存在key的冲突场景,时间复杂度为 O(1),一下就命中。
比起数组和链表查找元素时需要遍历整个集合的情况来说,哈希表明显方便和效率的多。
硬币的反面是:寻址容易,插入和删除困难。
特点:寻址容易,插入和删除困难。
HashMap主要依赖数组来存储数据。 哈希表中的每个元素被称为“bucket” (桶)。当然,也有叫做槽位(slot)的,反正都是这么个意思。
在 hashtable的 数组的每个位置(bucket)上,都可以存放一个元素(键值对),bucket的定位,通过key的hash函数值取模(具体算法依据hash函数去定)之后去获得, 这样,可以O(1)的时间复杂度快速定位到数组的某个位置,取出相应的值,这就是HashMap快速获取数据的原理。
什么是hash冲突(/hash碰撞)
哈希表 通过key的hash函数值取模(具体算法依据hash函数去定)之后去获得 bucket 槽位索引,不同的key值,可能会出现同一个 bucket 槽位,这就是 哈希冲突。
哈希冲突问题,用公式表达就是:
key1 ≠ key2 , f(key1) = f(key2)
以上面的字典图为例,那如果字典中有两个字的拼音相同 (比如安和 按),就是 哈希冲突。
一般来说,哈希冲突是无法避免的,如果要完全避免的话,那么就只能一个key对应一个bucket 槽位索引,也就是一个字就有一个索引 (安 和 按就是两个索引),这样一来,需要大量的内存空间,内存空间就会增大,甚至内存溢出。
那么,有什么哈希冲突的解决办法呢?
常见的哈希冲突解决办法有两种:
-
开放地址法
-
链地址法。
哈希表1.7/哈希表1.8 采用链地址法,解决hash碰撞
采用链地址法解决hash碰撞的极端情况
哈希表的特性决定了其高效的性能,大多数情况下查找元素的时间复杂度可以达到O(1), 时间主要花在计算hash值上,
然而也有一些极端的情况,最坏的就是hash值全都映射在同一个地址上,这样哈希表就会退化成链表,例如下面的图片:
当hash表变成图2的情况时,查找元素的时间复杂度会变为O(n),效率瞬间低下,所以,设计一个好的哈希表尤其重要,如HashMap在jdk1.8后引入的红黑树结构就很好的解决了这种情况。
JDK1.7 中头插法
采用链地址法后,冲突数据使用链表管理。 但是数据插入链表的时候,有两种方式:
-
头插
-
尾插
在 JDK1.7 中HashMap采用的是头插法,就是在链表的头部插入,新插入的 slot槽位数据保存在链表的头部。
比如插入同一个 槽位的三个 key A B C 之后, 示意图如下。
在 JDK1.7 中HashMap采用的是头插法,大致的源码如下:
//newTable表示新创建的扩容后的数组
//rehash表示元素是否需要重新计算哈希值
void transfer(Entry[] newTable, boolean rehash) {
//记录新数组的容量
int newCapacity = newTable.length;
//遍历原数组的桶位置
for (Entry<K,V> e : table) {
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next;
//确定元素在新数组的索引位置
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;
}
}
}
JDK1.7的底层数据结构
JDK1.7的底层数据结构 包括一个 槽位数组 table, 每个桶中的元素都需要一个单独的Entry, 用于存储冲突链表的头。
/**
* An empty table instance to share when the table is not inflated.
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
...
}
JDK1.7之前(1.8之前)使用头插法的好处
使用头插法的好处,总结出如下几点:
-
效率高
-
扩容的时候,插入在头部,效率高一些,时间复杂度为O(1)
-
但如果插入尾部,都要遍历到最后一个节点,时间复杂度为O(N)
-
-
满足时间局部性原理
根据时间局部性原理,最近插入的最有可能被使用
JDK1.7头插法导致的在扩容场景导致恶性死循环的问题
来看看hashmap的扩容。
回顾一下hashmap的内部结构。HashMap底层存储的数据结构如下: 在JDK1.7及前
-
数组
-
链表
在JDK1.8后
-
数组
-
链表 -当链表的长度临近于8时,转为红黑树
-
红黑树
一般情况下,当元素数量超过阈值时便会触发扩容。每次扩容的容量都是之前容量的 2 倍。
HashMap 初始容量默认为16。如果写了初始容量,如果写的为11,他其实初始化的并不是11,而是取2n ,取与11最相近的那个值,必须大于等于11,所以为16。
但是HashMap 的容量是有上限的,必须小于 1<<30,即 1073741824。如果容量超出了 1<<30,即 1073741824这个数,则不再增长,且阈值会被设置为 Integer.MAX_VALUE。
JDK7 中的扩容机制
-
空参构造函数:以默认容量、默认负载因子、默认阈值初始化数组。内部数组是空数组。
-
有参构造函数:根据参数确定容量、负载因子、阈值等。第一次 put 时会初始化数组,其容量变为不小于指定容量的 2 的幂数,然后根据负载因子确定阈值。
-
如果不是第一次扩容,则 新容量=旧容量 x 2 ,新阈值=新容量 x 负载因子 。
JDK8 的扩容机制
-
空参构造函数:实例化的 HashMap 默认内部数组是 null,即没有实例化。第一次调用 put 方法时,则会开始第一次初始化扩容,长度为 16。
-
有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的 2 的幂数,将这个数设置赋值给阈值(threshold)。第一次调用 put 方法时,会将阈值赋值给容量,然后让 阈值 = 容量 x 负载因子。
-
如果不是第一次扩容,则容量变为原来的 2 倍,阈值也变为原来的 2 倍。(容量和阈值都变为原来的 2 倍时,负载因子还是不变)。
此外还有几个细节需要注意:
-
首次 put 时,先会触发扩容(算是初始化),然后存入数据,然后判断是否需要扩容;
-
不是首次 put,则不再初始化,直接存入数据,然后判断是否需要扩容;
JDK1.7中(JDK1.8之前)HashMap触发扩容机制时,会创建新的Entry[ ]数组,将旧的Entry数据进行复制.
这就是头插法数据。当某个entry上具有链式结构时,采用头插方式进行数据迁移,即将旧链表数据从头部遍历,每次取到的数据,插入到重新散列到的slot槽位的新链表的头部。
JDK1.7中HashMap的插入方法采用的是头插法,即新插入的元素会插入到链表的头部。
这样会产生以下问题:
-
破坏了链表元素的插入顺序,链表的顺序被反转:由于头插法是将新插入的元素插入到链表的头部,这样就导致链表的顺序与元素插入的顺序相反,不利于一些需要按照插入顺序遍历的场景。
-
容易引起链表环形问题:是因为多个线程并发扩容时,因为一个线程先期完成了扩容,将原 Map 的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行二次散列,再次将倒序链表变为正序链表。这个过程中会造成链表尾部丢失,形成环形链表,从而开始死循环、甚至CPU 100%的噩梦。
现象1,链表的顺序被反转:
头插法扩容之后, 假设原来的元素重新hash还在同一个槽位(这是假设,大概率不是统一槽位),同一个链表上的元素顺序与元素插入的顺序变了,变反了。
当然,下面这个死循环,才是致命的问题,会导致CPU 100%, 程序直接废了。
这个是两个大的问题:
-
导致的死循环
-
CPU 100%, 程序直接废了
头插法,在扩容时导致的死循环
由于头插法需要修改链表头, JDK1.7 头插法,在扩容时导致的死循环
先扩容的代码
void resize(int newCapacity)
{
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
迁移的代码
//newTable表示新创建的扩容后的数组
//rehash表示元素是否需要重新计算哈希值
void transfer(Entry[] newTable, boolean rehash) {
//记录新数组的容量
int newCapacity = newTable.length;
//遍历原数组的桶位置
for (Entry<K,V> e : table) {
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next;
//确定元素在新数组的索引位置
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;
}
}
}
JDK1.7链表头插法扩容step1
假设两个线程thread 1、thread2 进行重新,并且,都执行到了transfer 方法的 if (rehash) {...}
之前,
此时,thread 1、thread2 都确定了e和next,如下图所示:
JDK1.7链表头插法扩容step2
假设,此时thread 2线程的时间片没了,被操作系统挂起来了
只有thread 2 线程可以向下执行,一个人把活儿干完了,得到可扩容后的 大致结果,如下图:
注意,这个的元素次序已经倒过来了。 如果step1 是正序的话,这里是倒序了。
JDK1.7链表头插法扩容step3
thread 2线程的时间片又有了,继续执行
//确定元素在新数组的索引位置
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;
头插方式执行,这个时候,危险就悄悄来了,把图画细致一点,聚焦到 这个虚拟的 链表,如下图:
咱们的线程,开始执行三面的三句核心的语句:
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i];
//新插入的元素成为桶位置的第一个元素
newTable[i] = e;
//遍历原数组链表的下一个元素
e = next;
先调整 e.next指针,指向 链表头部的newTable[i] 也就是 A,如下图
实际上,这个时候,链表已经变成了 死循环链表了。 链表的 尾部节点已经丢失,形成了环形链表。
形成了环形链表之后, 由于后面的risize 是用null != e 作为条件 去终止内部循环的,大家思考一下,这个循环还有终止的可能吗?
来看看代码,具体如下:
//遍历原数组的桶位置
for (Entry<K,V> e : table) {
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next;
//...省略 插入头部
//遍历原数组链表的下一个元素
e = next;
}
}
上面的链表, 唯一的一个next =null的元素 C, 他的next值,也就不为空了,那么 这个risize的循环,从此,也就永远出不来了。
另外,如果这个时候,来一个线程去get 元素,如果没有找到对应的key,也会死循环。
咱们的扩容step3 还没有结束,第三步结束之后:
这一步,C处理完了,到了头部,但是下一个要处理的,是之前历史资产B, 而不是 A。
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next; //step3 e=C next=B
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i]; // c.next=a,空指针丢失, 循环噩梦开始
//新插入的元素成为桶位置的第一个元素
newTable[i] = e; //c到头部
//遍历原数组链表的下一个元素
e = next; //e=b
}
JDK1.7链表头插法扩容step4
下面正式开始无限插入循环的噩梦。第三步之后的第4步, e变成了B, 进入下一轮循环后, next=C,执行过程大致如下
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next; //step4 e=B next=C
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i]; // B.next=B,自己指向自己, 已经彻底短链了,彻底失控了
//新插入的元素成为桶位置的第一个元素
newTable[i] = e; //B还是到头部
//遍历原数组链表的下一个元素
e = next; //e=C
}
执行之后的结果如下,已经彻底失控了:
JDK1.7链表头插法扩容step5
下面正式开始无限插入循环的噩梦。第三步之后的第5步, e变成了C, 进入下一轮循环后, next=A,执行过程大致如下
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next; //step4 e=C next=A
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i]; // C.next=B,
//新插入的元素成为桶位置的第一个元素
newTable[i] =e; //C到了头部
//遍历原数组链表的下一个元素
e = next; //e=A
}
执行之后的结果如下,已经彻底失控了:
其实已经没有推演下去的意义了,自从丢失空指针null之后, 这个已经是循环链表了。循环永远出不来了。另外,如果来一个,来一个线程去get 元素,如果没有找到对应的key,也会死循环。
如何破解环形链表
就是因为 倒序+ 正序 这种乱序的插入,导致了 尾部的丢失,从而形成了环形链表。
这就像 负负得正 一样的。 第一次头插是倒序, 第二次 头插是 正序, 第三次头插是倒序, 第四次 头插是 正序。 而只要 倒序+ 正序 一组合,就会丢掉尾部的 空指针。
如何破解, 很简单, 保证插入的次序一致就可。
如果保证每一次插入的次序一致呢? 采用尾插法。 新的Entry都插入到尾部, 并且新的尾部的 Entry.next 为空,这样做有两个结果:
-
永远是正序
-
永远有尾部
结论是,不会产生环形链表。 当然也破解了头插法导致的 死循环和CPU 100%的问题。
那么,使用尾插法的不足是什么?
-
扩容的效率低些
扩容的时候,插入在尾部,效率高一些,时间复杂度为O(N)
-
不满足时间局部性原理
根据时间局部性原理,最近插入的最有可能被使用,这时候已经插入到尾部去了,要找到尾部才能找得到。
那么 JDK1.8的底层,用的就是尾插。
JDK1.8的底层数据结构
每个桶中的元素使用Node保存,使用TreeNode支持红黑树,并且Node中的hash属性使用final修饰,一旦确定将不可变。
首先来看看,一个JDK 1.8版本ConcurrentHashMap(HashMap的并发版本)实例的内部结构,示例如图7-16所示。
图7-16 一个JDK 1.8 版本ConcurrentHashMap实例的内部结构
关于table 数组的解释:
table 数组在第一次往HashMap中put元素的时候初始化,如果HashMap初始化的时候没有指定容量,那么初始化table的时候会使用默认的DEFAULT_INITIAL_CAPACITY
参数,也就是16
,作为table初始化时的长度。
如果HashMap初始化的时候指定了容量,HashMap会把这个容量修改为2
的倍数,然后创建对应长度的table。因为table在HashMap扩容的时候,长度会翻倍。所以table的长度肯定是2
的倍数。
请注意:如果要往HashMap中放1000
个元素,又不想让HashMap不停的扩容,最好一开始就把容量设为2048
,设为1024
不行,因为元素添加到769
的时候还是会扩容。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key的hash值
final K key; // 节点的key,类型和定义HashMap时的key相同
V value; // 节点的value,类型和定义HashMap时的value相同
Node<K,V> next; // 该节点的下一节点
...
}
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
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;
...
}
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
底层基础数据结构:数组 + 链表(哈希表或散列表),在1.8中为了提升元素获取速度,引进了红黑树,用以解决冲突时性能问题。
当数组链表变得过长时,HashMap会将链表转为红黑树,以此来提高元素的查找、插入和删除操作的性能。
理解 JDK1.8的HashMap扩容原理
扩容(resize)就是重新计算容量,进行扩大数组容量,以便装入更多的元素。
向hashMap不停的添加元素,当hashMap无法装载新的元素,对象将需要扩大数组容量,以便装入更多的元素。
扩容临界值计算公式:threadshold = loadFactory * capacity。loadFactory 负载因子的默认值是0.75,capacity容量大小默认是16。
也就是说,第1次扩容的动作会在元素个数达到12的时候触发,扩容的大小是原来的2倍。
HashMap的最大容量是Integer.MAX_VALUE也就是2的31次方减1。
注意:以下扩容原理讲解基于JDK1.8
理解 JDK1.8的HashMap的 扩容
创建一个新的的Entry空数组,长度是原数组的2倍。
Node<K,V> loHead = null, loTail = null; //低位链表的头尾结点
Node<K,V> hiHead = null, hiTail = null; //高位链表的头尾结点
Node<K,V> next; //next指针 指向下一个元素
理解 JDK1.8的HashMap的迁移
遍历原Entry数组,把所有的Entry重新Hash(迁移)到新数组。
由于扩容之后,数组长度变大,hash的规则也会随之改变,所以需要重新hash。
-
扩容前,临界监测: 这里将其设置为长度为 8(用8举例主要是为了画图 ,hashMap默认容量是16),扩容临界点 8 * 0.75 = 6
-
数组扩容:长度达到 临界点后开始扩容,扩容后开始迁移。
-
扩容后,迁移数据:重新计算元素的hashCode,并存储到相应位置。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; //数组容量(旧)
int oldThr = threshold; //扩容临界点(旧)
int newCap, newThr = 0; //数组容量(新)、扩容临界点(新)
if (oldCap > 0) {
//如果旧容量大于等于了最大的容量 2^30
if (oldCap >= MAXIMUM_CAPACITY) {
//将临界值设置为Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 新阈值设置2倍
}
else if (oldThr > 0) // HashMap(int initialCapacity, float loadFactor)调用
newCap = oldThr;
else { // 第一次put操作的时候,因为jdk1.8hashMap先添加元素再扩容
//构造函数将jdk1.7的扩容移动到这
newCap = DEFAULT_INITIAL_CAPACITY; //默认容量 16
//临界值 16 *0.75 =12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {//如果新阈值为0,根据负载因子设置新阈值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建新的 槽位数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//迁移数据
for (int j = 0; j < oldCap; ++j) {如果旧的数组中有数据,循环
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; //gc处理
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e; //只有一个节点,赋值,返回
else if (e instanceof TreeNode) //判断是否为红黑树结点
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null; //低位链表
Node<K,V> hiHead = null, hiTail = null; //高位链表
Node<K,V> next;
do {
next = e.next; //指向下个元素结点,做为while循环的条件
if ((e.hash & oldCap) == 0) { //判断是否为低位链表
if (loTail == null) //链表没有元素,则将该元素作为头结点
loHead = e;
else
loTail.next = e; //加在链表的下方
loTail = e;
}
else { {//不为0,元素位置在扩容后数组中的位置发生了改变,新的下
//标位置是(原下标位置+原数组长)
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//遍历完成后,进行数据迁移
if (loTail != null) {
//链表最后
loTail.next = null;
//将元素原位置迁移到新数组中,位置一样
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
//高位链表迁移 + 旧数组容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新数组
return newTab;
}
JDK1.8的HashMap的总结
-
扩容就是将旧表的数据迁移到新表。
-
迁移过去的值需要重新计算hashCode,也就是他的存储位置。
-
关于位置可以这样理解:比如旧表的长度8、新表长度16旧表位置4有6个数据,假如前三个hashCode是一样的,后面的三个hashCode是一样的迁移的时候;就需要计算这6个值的存储位置。
-
如何计算位置?采用低位链表和高位链表;
如果位置4下面的数据e.hash & oldCap等于0,那么它对应的就是低位链表,也就是数据位置不变,e.hash & oldCap不等于0就要重写计算他的位置,也就是j + oldCap(4+8);这个12,就是高位链表位置(新数组12位置)。
JDK1.8链表尾插法性能优化
前面讲到,尾插法在扩容场景进行数据迁移的时候时间复杂度为O(N),头插法在扩容场景进行数据迁移的时候时间复杂度为 O(1)。 但是这个仅仅是理论。
JDK1.8链表除了采用为尾插之外,做了很多性能优化,比如:使用MurmurHash算法提高哈希算法的效率,减少了某些链表长度过长的情况减少遍历的次数等等。
总之针对JDK1.7中死循环问题,将HashMap的插入方法改为了尾插法,即新插入的元素会插入到链表的尾部,这样可以解决很多问题并且有以下优点:
-
避免链表环形问题:尾插法是将新插入的元素插入到链表的尾部,不需要修改链表头,因此可以避免在并发环境下多个线程修改链表头导致的链表环形问题。
-
提高哈希算法的效率:Java8使用的是MurmurHash算法,该算法具有良好的随机性和分布性,能够有效地降低哈希冲突的概率,从而提高HashMap的性能。
-
提高查询效率:尾插法使得链表元素的插入顺序与元素插入的顺序一致,从而方便了元素的查找和遍历操作,提高了HashMap的查询效率。
-
提高链表长度的平衡:尾插法可以使得链表长度比较平衡,减少了某些链表长度过长的情况,从而提高了HashMap的性能。
头插法和尾插法的性能对比
我们可以通过一个简单的例子来说明Java8中HashMap插入方法的改变对性能的影响。
假设有一个HashMap,包含10000个元素,现在需要将一个新元素插入到其中。
为了测试插入操作的性能,我们分别使用Java8之前的版本和Java8及以后的版本实现插入操作,并记录每次插入的时间。
具体代码实现如下:
HashMap<Integer, String> map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
map.put(i, "value" + i);
}
long startTime = System.currentTimeMillis();
map.put(10000, "new value");
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + "ms");
运行多次后,我们可以得到平均插入时间的数据。
假设运行10次,得到的数据如下:
Java8之前的版本 | Java8及以后的版本 |
---|---|
4ms | 3ms |
3ms | 2ms |
4ms | 3ms |
4ms | 2ms |
3ms | 3ms |
4ms | 2ms |
3ms | 3ms |
4ms | 2ms |
3ms | 3ms |
4ms | 2ms |
平均值:3.6ms | 平均值:2.6ms |
从上表可以看出,Java8及以后的版本插入操作的平均时间要比Java8之前的版本快,差距在1ms左右,
这是由于Java8将HashMap的插入方法改为了尾插法,避免了链表环形问题的发生,同时优化了哈希算法和查询效率,从而提高了HashMap的性能。
尾插法和头插法总结
在并发编程中使用HashMap可能会导致死循环,而使用线程安全的HashTable效率又低下。
HashMap 之所以在并发下的扩容造成死循环,是因为多个线程并发进行时,因为一个线程先期完成了扩容,将原 Map 的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链表变为正序链表。
于是形成了一个环形链表,两种场景造成死循环:
-
在扩容场景后面的元素移动过程中,造成死循环。
-
由于环形链表的存在,在后面 get 表中不存在的元素时,也造成死循环。
在Java1.5中,并发编程大师Doug Lea给我们带来了concurrent包,而该包中提供的ConcurrentHashMap是线程安全并且高效的HashMap。 ConcurrentHashMap使用锁保证了扩容的独占性,于是在多线程并发处理下,解决了HashMap在扩容到时候造成链表形成环形结构的问题。
所以,如果存在并发扩容场景,需要使用 ConcurrentHashMap。