揭开 MyBatis 的神秘面纱:从 SQL 到对象的奇妙旅程

前言

对于许多Java开发者来说,MyBatis 是一个熟悉又可靠的老朋友。我们习惯于编写一个 Mapper 接口,然后在 XML 中写下对应的 SQL,神奇的事情就发生了——不需要任何实现类,数据就从数据库流转到了我们的 Java 对象中。但你是否曾停下来思考过,这背后究竟隐藏着怎样的设计与逻辑?

仅仅停留在“会用”的层面,意味着当遇到复杂的性能问题或诡异的 bug 时,我们可能束手无策。理解其底层原理,不仅能让我们成为更好的问题解决者,更能让我们领略到软件设计模式的精妙与优雅。

今天,就让我们一起踏上这场探索之旅,揭开 MyBatis 的神秘面纱,看看从一行代码调用到一次数据库交互,它内部究竟发生了什么。

宏观蓝图:认识核心组件

在深入细节之前,我们首先需要认识一下 MyBatis 世界中的几位关键“角色”。正是它们的协同工作,才构成了整个框架的骨架。

  • Configuration全局配置总管。它是一个大管家,MyBatis 启动时解析的所有配置(包括 mybatis-config.xml 和所有 Mapper 文件)都被装载到这个唯一的实例中。它是所有后续操作的数据中心。
  • SqlSessionFactory重量级的会话工厂。它基于 Configuration 对象被创建,是线程安全的,整个应用的生命周期中通常只存在一个。它的唯一职责就是生产 SqlSession
  • SqlSession轻量级的数据库会话。这是与数据库进行一次交互的直接代表,类似于 JDBC 的 Connection。它是线程不安全的,因此必须在每次请求时创建,并在使用完毕后立即关闭。
  • ExecutorSQL 执行器SqlSession 本身并不直接执行 SQL,而是将这个重任委托给 ExecutorExecutor 负责管理数据库连接、事务、一级缓存等所有底层操作。
  • MappedStatementSQL 指令手册。XML 中每一个 <select><insert> 等标签,都会被解析成一个 MappedStatement 对象,它包含了这条 SQL 的所有信息:SQL 文本、参数类型、返回值类型、缓存配置等。
  • 三大处理器 (StatementHandler, ParameterHandler, ResultSetHandler)Executor 的三个得力助手,分别负责处理语句创建、参数设置和结果集映射。

奇妙旅程四部曲:从调用到返回

现在,让我们跟随一次典型的数据库查询 userMapper.selectUser(1),完整地走一遍 MyBatis 的内部旅程。

第一幕:创世纪 - 加载配置与构建工厂

一切始于这行我们无比熟悉的代码:

1
2
InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

这短短两行代码的背后,MyBatis 正在进行一场有条不紊的“创世”工程,其核心是建造者模式(Builder Pattern)

  1. SqlSessionFactoryBuilder 作为一个生命周期短暂的“建筑工”,接收了配置文件的输入流。
  2. 它内部会创建一个 XMLConfigBuilder,这个“解析大师”开始逐行解析 mybatis-config.xml,将<settings>, <plugins>, <mappers> 等所有配置信息,全部装填进一个全局唯一的 Configuration 对象。

  1. 当解析到 <mappers> 标签时,它会为每一个 Mapper XML 文件创建一个 XMLMapperBuilder

  1. XMLMapperBuilder 负责将具体的 Mapper XML 文件解析成一个个 MappedStatement 对象。这个过程最关键的一步,是为每个 SQL 指令生成一个全局唯一的ID

    mybatis-6

    ID 生成规则 = namespace + . + SQL标签的id

    例如:com.example.mapper.UserMapper.selectUser

mybatis-6

  1. 所有 MappedStatement 都被注册到 Configuration 内部一个巨大的 Map<String, MappedStatement> 中,形成了一个完整的指令“地址簿”。

  1. 最后,SqlSessionFactoryBuilder 根据这个满载信息的 Configuration 对象,创建出 SqlSessionFactory 并功成身退。

至此,一个包含了所有执行蓝图的重量级工厂已经准备就绪。

第二幕:指尖魔法 - getMapper 与动态代理

当我们调用 UserMapper mapper = session.getMapper(UserMapper.class); 时,MyBatis 最神奇的部分登场了。我们从未写过 UserMapper 的实现类,那这个 mapper 对象从何而来?

答案是:JDK 动态代理 (Dynamic Proxy)

  1. session.getMapper() 会请求 Configuration 对象,Configuration 内部的 MapperRegistry 会为 UserMapper.class 这个接口创建一个代理工厂 (MapperProxyFactory)。
  2. 这个工厂通过 Proxy.newProxyInstance() 创造出一个实现了 UserMapper 接口的代理对象
  3. 这个代理对象的核心是一个名为 MapperProxy 的调用处理器 (InvocationHandler)。

现在,我们拿到的 mapper 变量,其实是一个“傀儡”。所有对它方法的调用(如 mapper.selectUser(1)),都会被 MapperProxyinvoke() 方法拦截。

第三幕:深入工坊 - Executor 的执行链

MapperProxy 拦截到方法调用后,它的使命是找到正确的 SQL 指令并执行它。

  1. 动态寻址MapperProxy 会根据被调用的方法(selectUser)和它所属的接口(com.example.mapper.UserMapper),动态地拼接出那个全局唯一的 ID:“com.example.mapper.UserMapper.selectUser”。

  2. 查找指令:它拿着这个 ID,去 Configuration 的“地址簿”中,准确地找到了对应的 MappedStatement 对象。

  3. 委托执行MapperProxy 将 ID 和参数(id=1)传递给 SqlSessionSqlSession 再次将任务委托给真正的劳动者——Executor

    Executor 的工作流是**模板方法模式(Template Method Pattern)**的完美体现:

  1. 检查一级缓存Executor 首先生成独一无二的key,然后去查询自身维护的一级缓存(一个 Map),如果同样的查询已经执行过,直接返回结果。

  2. 获取数据库连接:若缓存未命中,则从数据源获取一个 Connection


6. 创建处理器:它会创建一个 StatementHandler,由它来与 JDBC 直接对话。
7. 参数设置StatementHandler 会调用 ParameterHandler,由后者负责将我们的 Java 参数(id=1)安全地设置到 PreparedStatement? 占位符上。
8. 执行SQLStatementHandler 调用 JDBC 的 execute() 方法,将 SQL 发送给数据库。

第四幕:化茧成蝶 - ResultSet 的结果映射

数据库返回了一个 ResultSet 结果集,这是原始的、丑陋的数据。MyBatis 需要将它变为我们喜爱的、优雅的 Java 对象。

  1. ResultSetHandler 登场StatementHandlerResultSet 交给结果集处理器 ResultSetHandler
  2. 反射大法ResultSetHandler 根据 MappedStatement 中定义的返回类型(resultTyperesultMap),利用 Java 反射 创建出 User 类的实例。
  3. 逐一赋值:它遍历 ResultSet 的每一行,根据列名和 User 对象的属性名进行匹配,并将值赋给对象的相应字段。
  4. 类型转换:在赋值过程中,如果遇到数据库类型(如 TIMESTAMP)和 Java 类型(如 java.util.Date)不匹配的情况,会调用相应的 TypeHandler 进行转换。
  5. 返回对象:最终,一个或多个填充完毕的 User 对象被返回,并在此之前被存入一级缓存。

至此,从一次方法调用到得到一个Java对象的奇妙旅程全部完成。

神来之笔:插件机制 (Interceptor)

如果说以上流程是 MyBatis 的主干,那么插件机制就是它伸向四面八方的灵活触手。MyBatis 允许我们通过插件(Interceptor),在四大核心组件(Executor, StatementHandler, ParameterHandler, ResultSetHandler)的关键方法执行前后,插入我们自己的逻辑。

其原理同样是动态代理。MyBatis 会为被拦截的组件创建层层代理,形成一个责任链。这使得诸如通用分页 (PageHelper)性能监控数据加解密等功能可以“无侵入”地集成到框架中,极大地增强了其扩展性。

结语

揭开 MyBatis 的底层面纱,我们看到的不是什么魔法,而是一幅由工厂、建造者、代理、模板方法等经典设计模式精心编织而成的优雅画卷。它通过命名约定将接口与SQL绑定,通过动态代理解耦调用与执行,通过反射实现了数据与对象的自动映射。

理解了这些原理,MyBatis 对我们而言,将不再是一个“黑盒”,而是一个值得信赖、可被掌控的强大工具。下一次,当你在调试一个棘手的数据库问题时,或许就能胸有成竹地 Step Into,深入源码,直抵问题的核心。

基本原理

来解读一下 Java 中 ReentrantLocklock()unlock() 方法的底层实现原理。一言以蔽之,其核心精髓在于 AQS (AbstractQueuedSynchronizer) 这个抽象框架,ReentrantLock身只是一个“门面”,它内部定义了一个 Sync 类型的成员,而 Sync 正是 AQS 的一个具体实现。ReentrantLock 提供了两种模式:公平锁(FairSync)和非公平锁(NonfairSync),这两种模式都继承自 Sync,通过注释可以看到AbstractQueuedSynchronizer是ReetrantLock这个锁同步控制的基础,提供公平锁与非公平锁,通过AQS state来控制这个锁

核心基石:AQS (AbstractQueuedSynchronizer)

AQS 是一个用于构建锁和同步器的框架。它内部维护了几个关键元素:

  • state (一个 volatile int 变量): 这是同步状态的核心。在 ReentrantLock 中,state 用来表示锁的“重入”次数。

    • state == 0: 表示锁未被任何线程持有。
    • state > 0: 表示锁已被某个线程持有。这个值就是该线程成功 lock() 的次数。

  • 一个持有锁的线程 (exclusiveOwnerThread): AQS 内部通过 setExclusiveOwnerThread()getExclusiveOwnerThread() 方法来记录和获取当前持有锁的线程。

  • 一个等待队列 (CLH 队列的变体): 这是一个先进先出(FIFO)的双向队列,用于存放那些未能获取到锁而被阻塞的线程。队列中的每个节点(Node)都封装了一个等待的线程

ReentrantLocklockunlock 操作,实际上是委托给了内部 Sync 对象(也就是 AQS)的 acquirerelease 方法来完成

lock() 方法:一场惊心动魄的锁竞争

lock() 的目标是获取锁。如果获取不到,线程就会被阻塞,直到成功获取。这个过程根据公平与否,略有不同

非公平锁 (NonfairSync) 的 lock() 实现

这是 ReentrantLock 的默认模式,追求的是更高的吞吐量。一句话总结:新来的线程直接插队,尝试抢锁,抢不到再乖乖排队。

源码调用链路:lock() -> initialTryLock ->nonfairTryAcquire-> acquire()

详细步骤分解:
  1. 首次尝试 (CAS 插队):

    • 当一个线程调用 lock() 时,它会首先通过 CAS (Compare-And-Swap) 操作,尝试将 state 从 0 修改为 1。
    • 如果成功,意味着此时没有其他线程持有锁,该线程就成功“插队”获取了锁。同时,AQS 会将 exclusiveOwnerThread 设置为当前线程。整个 lock() 过程结束,非常高效。
    • 如果失败,说明锁已经被其他线程持有,进入下一步。
  2. 处理重入或进入队列:

    • 程序会调用 acquire(1) 方法。这个方法内部会再次尝试获取锁。
    • 检查是否重入: 首先判断当前持有锁的线程(getExclusiveOwnerThread())是否就是当前线程自己。
      • 如果是,那么这就是一次“重入”。它会安全地将 state 的值加 1,然后成功返回。这也是 ReentrantLock(可重入锁)名字的由来。
      • 如果不是,说明锁被其他线程占用,该线程获取锁失败。
  3. 入队与阻塞:

    • 获取锁失败的线程会被封装成一个 Node 对象,并加入到 AQS 等待队列的 队尾
    • 加入队列后,线程并不会立即阻塞,而是会进行“自旋”尝试。在自旋过程中,它会再次检查自己是否可以获得锁(比如前一个节点释放了锁)。
    • 如果多次自旋后仍然无法获取锁,最终线程会通过 LockSupport.park(this) 方法被 挂起(park),进入休眠状态,等待被唤醒

下面注释是

重复执行以下步骤:

  • 检查节点是否现在是第一个
  • 如果是,确保头节点稳定,否则确保有有效的 predecessor
  • 如果节点是第一个或尚未入队,尝试获取
  • 否则,如果节点尚未创建,则创建它
  • 否则,如果尚未入队,尝试一次入队
  • 否则,如果从 park 状态被唤醒,重试(最多 postSpins 次)
  • 否则,如果未设置 WAITING 状态,设置并重试
  • 否则,park 并清除 WAITING 状态,然后检查取消情况

公平锁 (FairSync) 的 lock() 实现

公平锁保证了线程获取锁的顺序与它们发出请求的顺序一致。

一句话总结:新来的线程先看一眼等待队列,如果有人在排队,就必须去队尾排队。

源码调用链路:lock() -> tryAcquire() -> acquire()

详细步骤分解:

  1. 检查等待队列 (唯一的区别):

    • 与非公平锁不同,当一个线程调用 lock() 时,它首先会检查 AQS 的等待队列中 是否存在正在等待的线程(通过 hasQueuedPredecessors() 方法)。
    • 如果队列中已经有其他线程在等待,那么为了保证公平,当前线程 不会尝试获取锁,而是直接进入下面的入队流程。
    • 如果队列是空的,它才会像非公平锁一样,尝试通过 CAS 获取锁。

  2. 后续流程 (与非公平锁类似):

    • 如果尝试获取锁失败,或者因为队列中已有等待者而放弃尝试,接下来的流程就和非公平锁一样了:判断是否可重入、入队、自旋、最后被挂起

unlock() 方法:释放锁并唤醒他人

unlock() 的过程相对简单,它的核心职责是释放锁,并在锁被完全释放后,唤醒等待队列中的下一个线程。

源码调用链路:unlock() -> sync.release(1)

详细步骤分解:

  1. 检查线程合法性:

    • unlock() 方法首先会检查当前线程是否就是持有锁的线程(getExclusiveOwnerThread())。如果不是,直接抛出 IllegalMonitorStateException 异常。这防止了非锁持有者线程错误地释放锁。
  2. 状态递减:

    • 如果是持有者,程序会安全地将 state 的值减 1。这一步并不会使用 CAS,因为只有一个线程(锁的持有者)可以执行这个操作,不存在竞争。
  3. 判断是否完全释放:

    • state 减 1 后,会检查其值是否变为 0。
      • 如果 state 仍大于 0,说明这是一个重入锁的内层释放,锁并没有被完全释放,unlock 方法直接返回,其他等待线程不会被唤醒。
      • 如果 state 等于 0,说明锁已经被 完全释放
  4. 唤醒等待者:

    • state 变为 0 时,AQS 会将 exclusiveOwnerThread 设置为 null
    • 然后,它会查看等待队列的头节点(Head Node),并唤醒其后继节点(Head’s next Node)中封装的线程。
    • 被唤醒的线程会从 LockSupport.park() 的休眠中醒来,再次尝试获取锁。由于此时锁已被释放,它通常能够成功获取锁,成为新的锁持有者

虚拟滚动的实现,主要依靠三个“魔法”技巧,我们来一一拆解

魔法一:制造“假”的滚动条 (The Phantom / 幻影)

思考:如果我只渲染 10 条数据,那滚动条就会很短,用户怎么会感觉有一百万条数据呢?

解决方法:这就是 virtual-list-phantom (幻影元素)的作用。
我们先计算出“如果一百万条数据全部渲染出来,会有多高?”。这个计算很简单:总高度 = 单项高度 × 总数量 (例如: 40px * 1,000,000 = 40,000,000px)。然后,我们在页面上创建一个看不见的 div(就是这个幻影),强行把它的高度设置为这个巨大的计算值(4000万像素)。效果:这个巨大的、看不见的幻影 div 会把滚动容器撑开,浏览器看到这么高的内容,就会自动生成一个又细又长的、看起来能滚动很久的滚动条。至此,第一步成功了:我们从视觉上欺骗了用户,让他感觉内容真的有很多。

魔法二:监听滚动,实时计算 (The Calculator)

思考:用户开始滚动了,我怎么知道他现在想看哪几条数据呢?

解决方法:我们给滚动容器绑定一个 @scroll 事件。这个事件会实时告诉我们一个关键信息:scrollTop,也就是**“用户已经从顶部向下滚动了多少距离”**。得到 scrollTop 后,我们就能进行核心计算了。比如,用户向下滚动了 800px,而我们知道每一项的高度是 40px。开始的索引 (startIndex) = 滚动距离 / 单项高度 = 800px / 40px = 20。Aha! 我们立刻就算出,用户现在想看的是第 20 条数据。接着,我们再根据可视区域的高度,算出屏幕上大概能显示多少条,比如能显示 15 条。那么 结束的索引 (endIndex) = 20 + 15 = 35。至此,第二步成功了:我们知道了当前应该在屏幕上显示从第 20 条到第 35 条的数据。 我们会从一百万条总数据中,用 slice 方法把这一小片数据切出来,存到 visibleData 里。

魔法三:精确定位,无缝衔接 (The Transformer)

思考:我已经拿到了第 20 到 35 条数据,怎么把它们放到正确的位置上,让用户感觉滚动是平滑的呢?

解决方法:这就是 transform: translateY() 的舞台了。我们有一个专门用于渲染真实内容的 div 或 table(就是 .virtual-list-content)。
我们需要计算这个内容区应该向上偏移多少。这个偏移量(startOffset)其实就是已经被滚出屏幕上方那部分内容的总高度。偏移量 (startOffset) = 开始的索引 × 单项高度 = 20 * 40px = 800px。然后,我们给内容区设置 CSS 样式 transform: translateY(800px)。
效果:这会将我们当前渲染的这 15 条数据,精确地向下推 800px,正好出现在用户滚动到的位置。从用户的视角看,他向下滚动了 800px,然后内容就完美地出现在了那里,整个过程天衣无缝。

逻辑总结 (一句话流程)

用一个“假”的超高元素撑出滚动条 → 监听滚动位置 → 计算出该位置应该显示哪几条数据 → 只把这几条数据渲染出来,并用 transform 把它们推到正确的位置上

直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
<template>
<div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
<table class="sticky-header-table" ref="headerRef">
<colgroup>
<col v-for="column in columns" :key="column.key" :style="{ width: column.width + 'px' }" />
</colgroup>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
{{ column.title }}
</th>
</tr>
</thead>
</table>

<div class="virtual-list-body">
<div class="virtual-list-phantom" :style="{ height: phantomHeight + 'px' }"></div>

<table class="virtual-table-content" :style="{ transform: `translateY(${startOffset}px)` }">
<colgroup>
<col v-for="column in columns" :key="column.key" :style="{ width: column.width + 'px' }" />
</colgroup>
<tbody>
<tr
class="virtual-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemHeight + 'px' }"
>
<td v-for="column in columns" :key="column.key">
{{ item[column.key] }}
</td>
</tr>
</tbody>
</table>
</div>

</div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue';

// --- 组件的输入属性 (Props) ---
const props = defineProps({
// 全部列表数据 (例如一百万条)
allData: { type: Array, required: true },
// 每一项的固定高度,这是计算的基础
itemHeight: { type: Number, default: 40 },
// 用于定义表格列的配置数组
columns: { type: Array, required: true },
});


// --- 核心响应式状态 (Refs) ---

// 用于获取 DOM 元素的引用
const containerRef = ref(null); // 指向最外层滚动容器
const headerRef = ref(null); // 指向固定的表头表格

// 核心的动态数据
const scrollTop = ref(0); // 记录用户已经滚动了多少距离 (px)
const containerHeight = ref(0); // 记录可视区域的高度 (px)


// --- 核心计算属性 (Computed) - 这里是所有魔法的计算逻辑 ---

// 计算幻影元素应该有的总高度
const phantomHeight = computed(() => props.allData.length * props.itemHeight);

// 根据滚动距离,计算当前可视区顶部的项目索引
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight));

// 为了防止快速滚动时出现白屏,我们在可视区域的上下方额外渲染一些数据作为“缓冲区”
const bufferCount = 5;

// 计算总共需要渲染多少个 DOM 节点(可视区数量 + 上下缓冲区数量)
const visibleItemCount = computed(() => {
return Math.ceil(containerHeight.value / props.itemHeight) + bufferCount * 2;
});

// 计算考虑了上方缓冲区后,我们应该从总数据中截取的真正开始索引
const effectiveStartIndex = computed(() => {
// 保证 startIndex 不会是负数
return Math.max(0, startIndex.value - bufferCount);
});

// 计算截取数据的结束索引
const endIndex = computed(() => {
return effectiveStartIndex.value + visibleItemCount.value;
});

// 从全部数据中,切片出当前需要渲染的一小部分数据
const visibleData = computed(() => {
return props.allData.slice(effectiveStartIndex.value, endIndex.value);
});

// 计算真实内容区应该向下偏移的距离(px),以保证它出现在正确的位置
const startOffset = computed(() => {
// 偏移量 = 真实开始的索引 * 单项高度
return effectiveStartIndex.value * props.itemHeight;
});


// --- 事件处理 ---

// 滚动事件的处理函数
function handleScroll(event) {
// 当用户滚动时,从事件对象中获取最新的 scrollTop 值,并更新我们的响应式状态
scrollTop.value = event.target.scrollTop;
}


// --- 生命周期钩子 ---

onMounted(() => {
// 当组件被挂载到页面上后,我们需要获取一些元素的实际尺寸
if (containerRef.value && headerRef.value) {
// 获取表头的实际高度
const headerHeight = headerRef.value.clientHeight;
// 计算出真正可用于滚动的内容区域的高度 = 容器总高度 - 表头的高度
containerHeight.value = containerRef.value.clientHeight - headerHeight;
}
});
</script>

<style scoped>
/* 最外层滚动容器 */
.virtual-list-container {
height: 100%;
width: 100%;
overflow: auto; /* 这是产生滚动条的原因 */
}

/* 固定表头的表格 */
.sticky-header-table {
position: sticky; /* 核心CSS属性,实现“粘性”定位 */
top: 0; /* 当滚动到顶部时,粘在 top: 0 的位置 */
left: 0;
z-index: 10; /* 提高层级,确保它在滚动内容之上 */
width: 100%;
background-color: #ffffff; /* 设置背景色,防止下方滚动的内容透过来 */
border-collapse: collapse;
}

.sticky-header-table th {
background-color: #f5f7fa;
padding: 8px 12px;
text-align: left;
border-bottom: 2px solid #e0e0e0;
}

/* 滚动内容的主体区域 */
.virtual-list-body {
position: relative; /* 作为内部绝对定位元素的定位父级 */
}

/* 幻影元素,用于撑开滚动条 */
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1; /* 把它藏在最底层,我们不需要看见它 */
}

/* 真实渲染内容的表格 */
.virtual-table-content {
position: absolute; /* 绝对定位,脱离文档流,以便用 transform 控制位置 */
top: 0;
left: 0;
width: 100%;
border-collapse: collapse;
}

/* 列表项(表格行)的样式 */
.virtual-list-item td {
padding: 8px 12px;
border-bottom: 1px solid #eee;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<div class="app-container">
<VirtualTable
:all-data="tableData"
:columns="columns"
:item-height="45"
/>
</div>
</template>

<script setup>
import { ref } from 'vue';
import VirtualTable from "@/components/VirtualList.vue";

// 1. 定义表格的列
const columns = ref([
{ key: 'id', title: 'ID', width: 100 },
{ key: 'name', title: '姓名', width: 200 },
{ key: 'email', title: '邮箱', width: 300 },
{ key: 'address', title: '地址', width: 400 },
]);

// 2. 生成十万条模拟数据
const tableData = ref([]);
for (let i = 0; i < 100000; i++) {
tableData.value.push({
id: i,
name: `用户-${i}`,
email: `user_${i}@example.com`,
address: `虚拟城市虚拟街道 ${i} 号`,
});
}
</script>

<style>
.app-container {
height: 600px;
width: 1000px;
border: 1px solid #ccc;
margin: 20px;
}
</style>

什么是锁?

锁(Lock)是用于控制多线程对共享资源访问的一种同步机制,目的是确保线程安全,防止多个线程同时修改共享资源导致数据不一致或竞争条件(race condition)

实现方式

  • 内置锁(synchronized):通过 synchronized 关键字实现,基于 JVM 的监视器(Monitor)机制。
  • 显式锁(Lock 接口):java.util.concurrent.locks 包中的锁,如 ReentrantLock、ReadWriteLock 和 StampedLock
  • 其他机制:如 volatile 关键字(提供可见性但非互斥锁)或基于 CAS(Compare-And-Swap)的原子操作

内置锁(synchronized)的使用形式

使用形式 锁定的对象 (Lock Object) 作用范围
修饰实例方法 当前实例对象 (this) 对同一个实例的调用互斥
修饰静态方法 类的 Class 对象 (ClassName.class) 对类的所有实例的调用都互斥
修饰代码块 括号内指定的任意对象 灵活,对锁定同一个对象的代码块互斥

内置锁(synchronized)的实现原理

synchronized是Java中最基础的同步工具。它的实现依赖于每个Java对象都关联一个的Monitor(监视器锁)。当一个线程试图进入一个synchronized保护的代码块时,它必须先获取该代码块所指定的对象的Monitor,synchronized的实现细节在JVM层面,通过特定的字节码指令来完成。

实现原理:Monitor与字节码

  • Monitor: 可以理解为一个计数器。当一个线程获取锁(Monitor)时,计数器加1。当该线程再次获取同一个锁时(即可重入性),计数器会继续累加。当线程退出同步块时,计数器减1。当计数器归零时,锁被完全释放,其他等待的线程可以竞争该锁。

  • 字节码指令monitorenter: 在同步代码块开始的位置,JVM会插入monitorenter指令。线程执行到这里时,会尝试获取对象所对应的Monitor的所有权。monitorexit: 在同步代码块结束和异常退出的位置,JVM会插入monitorexit指令。这个指令会将Monitor的计数器减1,当计数器为0时,锁就被释放

具体代码案例分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
package org.pt.thread;

/**
* @ClassName SynchronizedAllCases
* @Author pt
* @Description
* @Date 2025/6/18 20:51
**/
/**
* 该类全面演示了synchronized关键字的所有使用情况。
*/
public class SynchronizedAllCases {

private final Object instanceLock = new Object();

// 锁对象:当前类的实例对象 (this)
public synchronized void syncInstanceMethod() {
System.out.println("Thread-" + Thread.currentThread().getId() + " 进入 syncInstanceMethod,锁是 [this] instance: " + this.hashCode());
try {
// 模拟业务耗时
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread-" + Thread.currentThread().getId() + " 离开 syncInstanceMethod");
}

// 锁对象:当前类的Class对象 (SynchronizedAllCases.class)
public static synchronized void syncStaticMethod() {
System.out.println("Thread-" + Thread.currentThread().getId() + " 进入 syncStaticMethod,锁是 [Class] object: " + SynchronizedAllCases.class.hashCode());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread-" + Thread.currentThread().getId() + " 离开 syncStaticMethod");
}

/**
*
* 锁对象:当前类的实例对象 (this),效果等同于 Case 1
*/
public void syncBlockOnThis() {
synchronized (this) {
System.out.println("Thread-" + Thread.currentThread().getId() + " 进入 syncBlockOnThis,锁是 [this] instance: " + this.hashCode());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread-" + Thread.currentThread().getId() + " 离开 syncBlockOnThis");
}
}

/**
*
* 锁对象:成员变量 instanceLock
*/
public void syncBlockOnObject() {
synchronized (instanceLock) {
System.out.println("Thread-" + Thread.currentThread().getId() + " 进入 syncBlockOnObject,锁是 [instanceLock] object: " + instanceLock.hashCode());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread-" + Thread.currentThread().getId() + " 离开 syncBlockOnObject");
}
}

/**
*
* 锁对象:当前类的Class对象 (SynchronizedAllCases.class),效果等同于 Case 2
*/
public void syncBlockOnClass() {
synchronized (SynchronizedAllCases.class) {
System.out.println("Thread-" + Thread.currentThread().getId() + " 进入 syncBlockOnClass,锁是 [Class] object: " + SynchronizedAllCases.class.hashCode());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread-" + Thread.currentThread().getId() + " 离开 syncBlockOnClass");
}
}


public static void main(String[] args) throws InterruptedException {
SynchronizedAllCases demo1 = new SynchronizedAllCases();
SynchronizedAllCases demo2 = new SynchronizedAllCases();

System.out.println("======== 演示实例锁(同一个实例)========");
// 两个线程竞争同一个实例(demo1)的锁,会互斥
new Thread(demo1::syncInstanceMethod, "T1-A").start();
new Thread(demo1::syncBlockOnThis, "T1-B").start();
Thread.sleep(5000); // 等待上面执行完

System.out.println("\n======== 演示实例锁(不同实例)========");
// 两个线程分别作用于不同实例(demo1, demo2),锁对象不同,不会互斥
new Thread(demo1::syncInstanceMethod, "T2-A").start();
new Thread(demo2::syncInstanceMethod, "T2-B").start();
Thread.sleep(5000);

System.out.println("\n======== 演示类锁(Class锁)========");
// 两个线程竞争同一个Class锁,即使作用于不同实例,依然会互斥
new Thread(demo1::syncBlockOnClass, "T3-A").start();
new Thread(SynchronizedAllCases::syncStaticMethod, "T3-B").start();
Thread.sleep(5000);

System.out.println("\n======== 演示实例锁和类锁互不影响 ========");
// 一个线程获取实例锁(demo1),一个线程获取类锁,锁对象不同,不会互斥
new Thread(demo1::syncInstanceMethod, "T4-A").start();
new Thread(SynchronizedAllCases::syncStaticMethod, "T4-B").start();
Thread.sleep(5000);

System.out.println("\n======== 演示不同实例成员锁互不影响 ========");
// 两个线程在同一个实例上,但锁的是不同的成员对象,不会互斥
new Thread(demo1::syncBlockOnThis, "T5-A").start();
new Thread(demo1::syncBlockOnObject, "T5-B").start();
}
}

执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
======== 演示实例锁(同一个实例)========
Thread-15 进入 syncInstanceMethod,锁是 [this] instance: 837019185
Thread-15 离开 syncInstanceMethod
Thread-16 进入 syncBlockOnThis,锁是 [this] instance: 837019185
Thread-16 离开 syncBlockOnThis

======== 演示实例锁(不同实例)========
Thread-17 进入 syncInstanceMethod,锁是 [this] instance: 837019185
Thread-18 进入 syncInstanceMethod,锁是 [this] instance: 1275581963
Thread-18 离开 syncInstanceMethod
Thread-17 离开 syncInstanceMethod

======== 演示类锁(Class锁)========
Thread-19 进入 syncBlockOnClass,锁是 [Class] object: 1706377736
Thread-19 离开 syncBlockOnClass
Thread-20 进入 syncStaticMethod,锁是 [Class] object: 1706377736
Thread-20 离开 syncStaticMethod

======== 演示实例锁和类锁互不影响 ========
Thread-21 进入 syncInstanceMethod,锁是 [this] instance: 837019185
Thread-22 进入 syncStaticMethod,锁是 [Class] object: 1706377736
Thread-21 离开 syncInstanceMethod
Thread-22 离开 syncStaticMethod

======== 演示不同实例成员锁互不影响 ========
Thread-23 进入 syncBlockOnThis,锁是 [this] instance: 837019185
Thread-24 进入 syncBlockOnObject,锁是 [instanceLock] object: 425224686
Thread-23 离开 syncBlockOnThis
Thread-24 离开 syncBlockOnObject

底层实现-字节码

先将java文件编译为class文件,class文件编译为字节码

1
javac SynchronizedAllCases.java
1
javap -v SynchronizedAllCases 

字节码查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
警告: 文件 ./SynchronizedAllCases.class 不包含类 SynchronizedAllCases
Compiled from "SynchronizedAllCases.java"
public class org.pt.thread.SynchronizedAllCases {
public org.pt.thread.SynchronizedAllCases();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class java/lang/Object
8: dup
9: invokespecial #1 // Method java/lang/Object."<init>":()V
12: putfield #7 // Field instanceLock:Ljava/lang/Object;
15: return

public synchronized void syncInstanceMethod();
Code:
0: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
3: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
6: invokevirtual #25 // Method java/lang/Thread.getId:()J
9: aload_0
10: invokevirtual #29 // Method java/lang/Object.hashCode:()I
13: invokedynamic #33, 0 // InvokeDynamic #0:makeConcatWithConstants:(JI)Ljava/lang/String;
18: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
21: ldc2_w #43 // long 2000l
24: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
27: goto 35
30: astore_1
31: aload_1
32: invokevirtual #51 // Method java/lang/InterruptedException.printStackTrace:()V
35: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
38: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
41: invokevirtual #25 // Method java/lang/Thread.getId:()J
44: invokedynamic #54, 0 // InvokeDynamic #1:makeConcatWithConstants:(J)Ljava/lang/String;
49: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
52: return
Exception table:
from to target type
21 27 30 Class java/lang/InterruptedException

public static synchronized void syncStaticMethod();
Code:
0: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
3: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
6: invokevirtual #25 // Method java/lang/Thread.getId:()J
9: ldc #8 // class org/pt/thread/SynchronizedAllCases
11: invokevirtual #29 // Method java/lang/Object.hashCode:()I
14: invokedynamic #57, 0 // InvokeDynamic #2:makeConcatWithConstants:(JI)Ljava/lang/String;
19: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
22: ldc2_w #43 // long 2000l
25: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
28: goto 36
31: astore_0
32: aload_0
33: invokevirtual #51 // Method java/lang/InterruptedException.printStackTrace:()V
36: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
39: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
42: invokevirtual #25 // Method java/lang/Thread.getId:()J
45: invokedynamic #58, 0 // InvokeDynamic #3:makeConcatWithConstants:(J)Ljava/lang/String;
50: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
53: return
Exception table:
from to target type
22 28 31 Class java/lang/InterruptedException

public void syncBlockOnThis();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
7: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
10: invokevirtual #25 // Method java/lang/Thread.getId:()J
13: aload_0
14: invokevirtual #29 // Method java/lang/Object.hashCode:()I
17: invokedynamic #59, 0 // InvokeDynamic #4:makeConcatWithConstants:(JI)Ljava/lang/String;
22: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
25: ldc2_w #43 // long 2000l
28: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
31: goto 39
34: astore_2
35: aload_2
36: invokevirtual #51 // Method java/lang/InterruptedException.printStackTrace:()V
39: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
42: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
45: invokevirtual #25 // Method java/lang/Thread.getId:()J
48: invokedynamic #60, 0 // InvokeDynamic #5:makeConcatWithConstants:(J)Ljava/lang/String;
53: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
56: aload_1
57: monitorexit
58: goto 66
61: astore_3
62: aload_1
63: monitorexit
64: aload_3
65: athrow
66: return
Exception table:
from to target type
25 31 34 Class java/lang/InterruptedException
4 58 61 any
61 64 61 any

public void syncBlockOnObject();
Code:
0: aload_0
1: getfield #7 // Field instanceLock:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter
7: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
10: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
13: invokevirtual #25 // Method java/lang/Thread.getId:()J
16: aload_0
17: getfield #7 // Field instanceLock:Ljava/lang/Object;
20: invokevirtual #29 // Method java/lang/Object.hashCode:()I
23: invokedynamic #61, 0 // InvokeDynamic #6:makeConcatWithConstants:(JI)Ljava/lang/String;
28: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
31: ldc2_w #43 // long 2000l
34: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
37: goto 45
40: astore_2
41: aload_2
42: invokevirtual #51 // Method java/lang/InterruptedException.printStackTrace:()V
45: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
48: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
51: invokevirtual #25 // Method java/lang/Thread.getId:()J
54: invokedynamic #62, 0 // InvokeDynamic #7:makeConcatWithConstants:(J)Ljava/lang/String;
59: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
62: aload_1
63: monitorexit
64: goto 72
67: astore_3
68: aload_1
69: monitorexit
70: aload_3
71: athrow
72: return
Exception table:
from to target type
31 37 40 Class java/lang/InterruptedException
7 64 67 any
67 70 67 any

public void syncBlockOnClass();
Code:
0: ldc #8 // class org/pt/thread/SynchronizedAllCases
2: dup
3: astore_1
4: monitorenter
5: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
8: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
11: invokevirtual #25 // Method java/lang/Thread.getId:()J
14: ldc #8 // class org/pt/thread/SynchronizedAllCases
16: invokevirtual #29 // Method java/lang/Object.hashCode:()I
19: invokedynamic #63, 0 // InvokeDynamic #8:makeConcatWithConstants:(JI)Ljava/lang/String;
24: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
27: ldc2_w #43 // long 2000l
30: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
33: goto 41
36: astore_2
37: aload_2
38: invokevirtual #51 // Method java/lang/InterruptedException.printStackTrace:()V
41: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
44: invokestatic #19 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
47: invokevirtual #25 // Method java/lang/Thread.getId:()J
50: invokedynamic #64, 0 // InvokeDynamic #9:makeConcatWithConstants:(J)Ljava/lang/String;
55: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
58: aload_1
59: monitorexit
60: goto 68
63: astore_3
64: aload_1
65: monitorexit
66: aload_3
67: athrow
68: return
Exception table:
from to target type
27 33 36 Class java/lang/InterruptedException
5 60 63 any
63 66 63 any

public static void main(java.lang.String[]) throws java.lang.InterruptedException;
Code:
0: new #8 // class org/pt/thread/SynchronizedAllCases
3: dup
4: invokespecial #65 // Method "<init>":()V
7: astore_1
8: new #8 // class org/pt/thread/SynchronizedAllCases
11: dup
12: invokespecial #65 // Method "<init>":()V
15: astore_2
16: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
19: ldc #66 // String ======== 演示实例锁(同一个实例)========
21: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
24: new #20 // class java/lang/Thread
27: dup
28: aload_1
29: dup
30: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
33: pop
34: invokedynamic #74, 0 // InvokeDynamic #10:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
39: ldc #78 // String T1-A
41: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
44: invokevirtual #83 // Method java/lang/Thread.start:()V
47: new #20 // class java/lang/Thread
50: dup
51: aload_1
52: dup
53: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
56: pop
57: invokedynamic #86, 0 // InvokeDynamic #11:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
62: ldc #87 // String T1-B
64: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
67: invokevirtual #83 // Method java/lang/Thread.start:()V
70: ldc2_w #89 // long 5000l
73: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
76: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
79: ldc #91 // String \n======== 演示实例锁(不同实例)========
81: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
84: new #20 // class java/lang/Thread
87: dup
88: aload_1
89: dup
90: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
93: pop
94: invokedynamic #74, 0 // InvokeDynamic #10:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
99: ldc #93 // String T2-A
101: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
104: invokevirtual #83 // Method java/lang/Thread.start:()V
107: new #20 // class java/lang/Thread
110: dup
111: aload_2
112: dup
113: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
116: pop
117: invokedynamic #74, 0 // InvokeDynamic #10:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
122: ldc #95 // String T2-B
124: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
127: invokevirtual #83 // Method java/lang/Thread.start:()V
130: ldc2_w #89 // long 5000l
133: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
136: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
139: ldc #97 // String \n======== 演示类锁(Class锁)========
141: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
144: new #20 // class java/lang/Thread
147: dup
148: aload_1
149: dup
150: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
153: pop
154: invokedynamic #99, 0 // InvokeDynamic #12:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
159: ldc #100 // String T3-A
161: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
164: invokevirtual #83 // Method java/lang/Thread.start:()V
167: new #20 // class java/lang/Thread
170: dup
171: invokedynamic #102, 0 // InvokeDynamic #13:run:()Ljava/lang/Runnable;
176: ldc #105 // String T3-B
178: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
181: invokevirtual #83 // Method java/lang/Thread.start:()V
184: ldc2_w #89 // long 5000l
187: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
190: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
193: ldc #107 // String \n======== 演示实例锁和类锁互不影响 ========
195: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
198: new #20 // class java/lang/Thread
201: dup
202: aload_1
203: dup
204: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
207: pop
208: invokedynamic #74, 0 // InvokeDynamic #10:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
213: ldc #109 // String T4-A
215: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
218: invokevirtual #83 // Method java/lang/Thread.start:()V
221: new #20 // class java/lang/Thread
224: dup
225: invokedynamic #102, 0 // InvokeDynamic #13:run:()Ljava/lang/Runnable;
230: ldc #111 // String T4-B
232: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
235: invokevirtual #83 // Method java/lang/Thread.start:()V
238: ldc2_w #89 // long 5000l
241: invokestatic #45 // Method java/lang/Thread.sleep:(J)V
244: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
247: ldc #113 // String \n======== 演示不同实例成员锁互不影响 ========
249: invokevirtual #37 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
252: new #20 // class java/lang/Thread
255: dup
256: aload_1
257: dup
258: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
261: pop
262: invokedynamic #86, 0 // InvokeDynamic #11:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
267: ldc #115 // String T5-A
269: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
272: invokevirtual #83 // Method java/lang/Thread.start:()V
275: new #20 // class java/lang/Thread
278: dup
279: aload_1
280: dup
281: invokestatic #68 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
284: pop
285: invokedynamic #117, 0 // InvokeDynamic #14:run:(Lorg/pt/thread/SynchronizedAllCases;)Ljava/lang/Runnable;
290: ldc #118 // String T5-B
292: invokespecial #80 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;Ljava/lang/String;)V
295: invokevirtual #83 // Method java/lang/Thread.start:()V
298: return
}

可以看到除了1,2方法外,都有明显的monitorenter, monitorexit那为什么synchronized标识在方法上就没有这种标识

同步方法 (Synchronized Method)无monitorenter / monitorexit标识?

答案不是的

同步方法在字节码层面则有所不同,它不直接使用monitorenter/monitorexit指令,而是通过方法访问标志(access flags)来声明。

  • ACC_SYNCHRONIZED 标志:当synchronized修饰方法时,编译器会为该方法在字节码中添加一个名为 ACC_SYNCHRONIZED 的标志。

当JVM在调用一个被标记为 ACC_SYNCHRONIZED 的方法时,它会自动执行与monitorentermonitorexit类似的操作。

  • 对于实例方法:自动获取this对象的Monitor。
  • 对于静态方法:自动获取该方法所在类的Class对象的Monitor

字节码查看同步方法

使用-v参数输出附加信息

1
javap -v SynchronizedAllCases


1. Kafka 中的 ISR (InSyncRepli)、OSR (OutSyncRepli)、AR (AllRepli) 代表什么?

  • AR (All Replicas):表示一个分区(Partition)中所有的副本。这包括 Leader 副本和所有的 Follower 副本。AR = ISR + OSR。
  • ISR (In-Sync Replicas):表示与 Leader 副本保持同步的副本集合。这个集合里的副本,其 LEO(Log End Offset,后面会解释)与 Leader 的 LEO 之间的差距在设定的阈值 (replica.lag.time.max.ms) 之内。只有 ISR 中的副本才有可能被选举为新的 Leader,以保证数据不丢失。
  • OSR (Out-of-Sync Replicas):表示未与 Leader 副本保持同步的副本集合。这些副本的 LEO 已经落后 Leader 太多(超过 replica.lag.time.max.ms),或者已经停止工作。它们无法被选举为 Leader,因为它们的数据不是最新的,如果被选为 Leader 会导致数据丢失。

2. Kafka 中的 HW、LEO 等分别代表什么?

  • LEO (Log End Offset)日志末端偏移量。LEO 是 Kafka 日志文件中下一条待写入消息的偏移量。对于 Leader 副本,LEO 代表它已写入的所有消息的下一条偏移量;对于 Follower 副本,LEO 代表它已从 Leader 复制并写入到本地日志中的下一条消息的偏移量。
  • HW (High Watermark)高水位HW 是一个分区的 Leader 副本和所有 ISR 集合中的 Follower 副本都已成功提交的最小偏移量
    • 生产者可见性:生产者发送的消息,只有当其偏移量小于或等于 HW 时,才会被消费者可见。这确保了消息的持久性和一致性:消息在所有 ISR 副本上都已同步,即使 Leader 宕机,也不会丢失。
    • 消费者可见性:消费者只能消费到小于或等于 HW 的消息。
  • 其他相关概念:
    • MinISR (Minimum In-Sync Replicas):一个分区正常工作所需的最小 ISR 副本数。如果 ISR 数量少于这个值,分区将无法写入消息(Kafka 1.0 版本后可以通过 unclean.leader.election.enable 配置决定是否允许 OSR 选举)。

3. Kafka 中是怎么体现消息顺序性的?

Kafka 保证同一个分区内的消息是有序的

  • 生产者发送: 生产者发送到同一个分区的消息,会按照发送的顺序写入日志,并被赋予递增的偏移量。
  • 消费者消费: 消费者从同一个分区消费消息时,会严格按照这些消息写入的顺序进行消费。
  • 跨分区不保证: Kafka 不保证跨分区的消息顺序。如果你将同一个 Topic 的消息发送到不同的分区,它们在消费者端接收到的顺序可能是乱的。
  • 保证顺序性的关键: 如果业务上需要严格保证一组消息的全局顺序性,你需要将这些消息发送到同一个分区。通常可以通过指定消息的 Key 来实现,因为 Kafka 会根据 Key 的哈希值将消息路由到特定分区。

4. Kafka 中的分区器、序列化器、拦截器是否了解?它们之间的处理顺序是什么?

是的,它们是 Kafka 生产者客户端的重要组成部分。

  • 分区器 (Partitioner)
    • 作用:决定生产者发送的每条消息应该被发送到 Topic 的哪个分区。
    • 默认实现:默认分区器会根据消息的 Key 进行哈希,然后取模计算分区。如果消息没有 Key,则轮询发送到不同分区。
    • 自定义:你可以实现 org.apache.kafka.clients.producer.Partitioner 接口来自定义分区逻辑。
  • 序列化器 (Serializer)
    • 作用:将 Java 对象(如消息的 Key 和 Value)转换为字节数组,因为 Kafka 只能传输字节数据。
    • 默认提供:Kafka 提供了常用的序列化器,如 StringSerializerIntegerSerializerByteArraySerializer 等。
    • 自定义:你可以实现 org.apache.kafka.common.serialization.Serializer 接口来自定义序列化逻辑。
  • 拦截器 (Interceptor)
    • 作用:允许用户在消息发送前和发送结果返回后对消息进行拦截、修改或记录。
    • 分为两类
      • ProducerInterceptor:生产者拦截器。
      • ConsumerInterceptor:消费者拦截器。
    • 链式调用:可以配置多个拦截器,它们会形成一个拦截器链。

它们之间的处理顺序 (针对生产者客户端):

  1. 序列化器 (Serializer):最先执行。当生产者准备发送一条消息时,首先会使用 Key 和 Value 的序列化器将其转换为字节数组。
  2. 分区器 (Partitioner):紧接着序列化器执行。消息被序列化为字节数组后,分区器会根据 Key(或无 Key 时的轮询)计算出消息应该发送到的目标分区。
  3. 生产者拦截器 (ProducerInterceptor)
    • onSend() 方法:在消息被序列化和确定分区之后,但在消息被发送到 Kafka 之前调用。你可以在这里修改消息(例如添加 Header)、过滤消息或记录日志。
    • onAcknowledgement() 方法:在消息被 Kafka Broker 成功接收(或发送失败)并收到确认之后调用。你可以在这里记录发送结果、统计成功率等。
    • 顺序:如果有多个拦截器,onSend() 会按照配置顺序依次调用,onAcknowledgement() 则会按照配置顺序的逆序调用。

5. Kafka 生产者客户端的整体结构是什么样子的?使用了几个线程来处理?分别是什么?

Kafka 生产者客户端的整体结构主要由以下几个核心组件和线程组成:

整体结构:

  1. 主线程 (调用线程):你的应用程序代码运行的线程,负责调用 producer.send() 方法来发送消息。
  2. Producer Record (消息):你通过 producer.send() 发送的实际消息对象,包含 Topic、可选的 Key、Value、分区、时间戳等信息。
  3. RecordAccumulator (消息累加器/缓冲池):一个线程安全的缓冲池,用于存储未发送的消息。producer.send() 方法会将消息先写入到这里。消息会根据目标分区被分组,每个分区对应一个双端队列 (Deque)。
  4. Sender 线程 (后台发送线程):一个独立的后台线程,负责从 RecordAccumulator 中取出消息批次 (RecordBatch),并将其发送给 Kafka Broker。
  5. NetworkClient (网络客户端):负责与 Kafka Broker 进行实际的网络通信(建立连接、发送请求、接收响应)。
  6. Metadata (元数据):存储 Topic 和分区信息,以及 Broker 列表等。NetworkClient 会定期更新这些元数据。

使用了几个线程来处理?分别是什么?

主要使用了两个核心线程

  1. 主线程 (Main Thread / User Thread)

    • 职责:执行应用程序代码,包括创建 KafkaProducer 实例、调用 producer.send() 方法。
    • 特点:send() 方法是一个异步操作,它将消息放入缓冲池后就立即返回,不会阻塞主线程。
  2. Sender 线程 (Background Thread)

    • 职责:这是

      1
      KafkaProducer

      内部启动的一个后台守护线程它负责:

      • RecordAccumulator批量获取消息(RecordBatch)。
      • 将这些批次通过 NetworkClient 发送到对应的 Kafka Broker。
      • 处理来自 Broker 的响应(ACKs),包括成功提交的确认或错误信息。
      • 调用用户提供的回调函数 (Callback) 或生产者拦截器的 onAcknowledgement() 方法。
      • 刷新元数据
    • 特点:这是一个单线程,负责所有分区的消息发送。它不断循环地从消息队列中拉取消息并发送。

总结图示:

1
2
3
4
5
6
7
8
9
10
User Application Thread  ---send()---> RecordAccumulator (Buffer)
|
V
Sender Thread (Background)
|
V
NetworkClient (I/O)
|
V
Kafka Brokers

6.“消费组中的消费者个数如果超过 topic 的分区,那么就会有消费者消费不到数据”这句话是否正确?

正确。

  • 分区是消费的最小并行单位: Kafka 中一个分区在任意给定时间只能被一个消费组内的一个消费者实例消费。这是为了保证分区内的消息顺序性。
  • 消费者与分区的关系:
    • 如果消费者数量少于分区数量:一个消费者会消费多个分区。
    • 如果消费者数量等于分区数量:每个消费者大致消费一个分区(理想情况下)。
    • 如果消费者数量多于分区数量:有些消费者将分配不到任何分区。它们将处于空闲状态,无法消费任何数据。

所以,为了达到最佳的消费并行度,通常建议将消费组中的消费者数量设置得等于或略小于 Topic 的分区数量。


7. 消费者提交消费位移时提交的是当前消费到的最新消息的 offset 还是 offset+1?

消费者提交消费位移时提交的是下一条待消费消息的 offset,也就是**当前消费到的最新消息的 offset + 1**。

  • 为什么要 +1? 偏移量 (offset) 代表的是消息在分区内的位置。当消费者提交 X 作为其消费位移时,Kafka 认为消费者已经成功消费了所有偏移量小于 X 的消息,并且下次开始消费时应该从偏移量 X 的消息开始。
  • 例子: 如果你消费了一条偏移量为 100 的消息,并成功处理了它,那么你应该提交的位移是 101。这表示你已经消费到 100,下次从 101 开始。

8. 有哪些情形会造成重复消费?

重复消费是 Kafka 消费者可能面临的一个问题,通常发生在**“at least once”**的语义下。常见的情形有:

  1. 消费者提交位移失败:
    • 消费者成功处理了消息,但在提交位移到 Kafka 或外部存储(如 ZooKeeper、数据库)时发生网络抖动、宕机等错误,导致位移提交失败。当消费者重启或分区被重新分配给其他消费者时,会从上次成功提交的位移处开始消费,从而再次消费到之前已处理过的消息。
  2. 消费者进程崩溃/重启:
    • 消费者在处理消息的过程中或处理完成后(但在位移提交前)崩溃或被强制终止。当它重启或其分区被其他消费者接管时,由于位移未提交,会从上一次提交的位移开始重新消费。
  3. 网络分区/Rebalance 期间:
    • 在消费者组 Rebalance 期间,如果旧的消费者提交位移太慢或失败,而新的消费者已经开始消费,就可能导致消息被多个消费者重复消费。
  4. 消费业务逻辑异常后未提交位移:
    • 消费者从 Kafka 拉取到一批消息,但在处理这些消息的业务逻辑中途发生异常,并且没有正确地捕获异常并提交位移。下一次拉取时,这些消息会再次被拉取到。
  5. 位移错乱/手动设置错误:
    • 开发者或运维人员手动调整了消费者组的位移到一个较旧的值。
    • 消费位移被非法篡改或存储的位移信息损坏。

解决重复消费的方案: 业务代码层面实现幂等性。即无论一条消息被处理多少次,其最终结果都是一致的。例如,使用消息的唯一 ID 进行去重。


9. 哪些情景会造成消息漏消费?

消息漏消费是更严重的问题,通常发生在**“at most once”**的语义下,这意味着消息可能丢失。常见的情形有:

  1. 消费者先提交位移后处理消息:
    • 消费者拉取到消息后,优先提交了位移,然后才开始处理这些消息。如果在这个处理过程中消费者崩溃、断电或发生其他异常,导致消息未能处理完成,那么由于位移已经提交,下次再消费时将从已提交的位移之后开始,之前未处理的消息就丢失了。
  2. 消息处理过程中发生不可恢复的异常:
    • 消费者成功拉取并开始处理消息,但在业务逻辑处理过程中发生致命错误(例如,数据格式错误导致无法解析,或依赖的外部服务不可用),导致该批次消息无法被正确处理,并且消费者跳过了这些消息(或者被强制关闭,而其位移已经提前或自动提交)。
  3. 自动提交位移(enable.auto.commit=true)间隔过大或时机不当:
    • 如果开启了自动提交位移,并且提交间隔较大。在这段时间内,消费者拉取并处理了一部分消息,但自动提交尚未触发,此时消费者崩溃,那么这批已处理但未提交位移的消息就会丢失。
  4. acks 配置不当:
    • 生产者配置 acks=0(不等待任何确认)或 acks=1(只等待 Leader 确认)。在这种情况下,如果 Leader 收到消息后立即崩溃,而 Follower 还没来得及同步,新选举的 Leader 可能不包含该消息,导致消息丢失。
  5. 生产者发送后未确认:
    • 生产者发送消息后,没有检查返回的 Future 对象,或者没有正确处理发送结果(例如,网络错误、Broker 故障导致消息未能成功写入)。
  6. 消费者被强制退出/被 Kill -9:
    • 消费者进程没有优雅关闭,导致其没有机会提交最终的消费位移。

解决漏消费的方案:

  • 将消费者提交位移的策略设置为**“先处理消息,后提交位移”**。
  • 生产者设置 acks=-1 (或 all),并结合 retries 重试机制。
  • 业务逻辑中对消息处理失败进行重试或降级处理,而不是简单跳过。
  • 使用事务性的生产者和消费者来保证Exactly Once语义(Kafka 0.11+)。

10. 当你使用 kafka-topics.sh 创建(删除)了一个 topic 之后,Kafka 背后会执行什么逻辑?

当你使用 kafka-topics.sh 命令创建(或删除)一个 Topic 时,Kafka 内部会经历一个协调过程,主要涉及 ZooKeeperController Broker

创建 Topic 的逻辑:

  1. kafka-topics.sh 发送请求给任一 Broker: 客户端(kafka-topics.sh)连接到集群中的任何一个 Broker,并发送创建 Topic 的请求。
  2. 请求被转发给 Controller: 这个 Broker 会将请求转发给当前的 Kafka Controller
  3. Controller 在 ZooKeeper 中创建 znode:
    • Controller 负责实际的 Topic 创建工作。它会在 ZooKeeper 的 /brokers/topics 路径下创建一个新的临时 znode,例如 /brokers/topics/your_topic_name
    • 这个 znode 中包含了 Topic 的元数据,如分区数、副本因子等信息。
  4. ZooKeeper 触发 Controller 监听:
    • 由于 Controller 会对 ZooKeeper 中与 Topic 相关的路径设置监听器(Watcher),当 /brokers/topics 路径下有新 znode 创建时,Controller 会收到 ZooKeeper 的通知。
  5. Controller 分配分区 Leader 和 Follower:
    • Controller 接收到通知后,开始执行 Topic 的创建逻辑。它会根据配置的分区数和副本因子,决定每个分区的 Leader 副本和 Follower 副本分别应该落在哪些 Broker 上。这个信息也会被写入 ZooKeeper。
  6. Controller 发送 LeaderAndIsr 请求给相关 Broker:
    • Controller 会向每个分配到该 Topic 分区副本的 Broker 发送 LeaderAndIsr 请求
    • 这些 Broker 收到请求后,会在本地创建相应的分区日志目录和文件,并初始化其作为 Leader 或 Follower 的角色。
  7. Broker 更新本地元数据并响应 Controller:
    • Broker 完成分区创建后,会更新自己的本地元数据缓存,并向 Controller 返回响应。
  8. Controller 更新自身和集群的元数据缓存:
    • Controller 收到所有 Broker 的响应后,会更新自身维护的集群元数据缓存,并广播给集群中的所有 Broker,使得所有 Broker 的元数据都保持一致。

删除 Topic 的逻辑:

删除 Topic 的过程类似,但方向相反。kafka-topics.sh 客户端发送删除请求给任意 Broker,请求转发给 Controller。Controller 会在 ZooKeeper 的 /admin/delete_topics 路径下创建一个待删除 Topic 的 znode。Controller 的监听器感知到这个 znode 后,会向相关 Broker 发送停止服务和删除分区的请求,Broker 删除本地日志文件,最后 Controller 会移除 ZooKeeper 中的相关元数据。


11. Topic 的分区数可不可以增加?如果可以怎么增加?如果不可以,那又是为什么?

Topic 的分区数可以增加。

  • 如何增加:

    你可以使用 kafka-topics.sh 命令来增加一个 Topic 的分区数。

    1
    bin/kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic your_topic_name --partitions <new_total_partitions>

    其中,<new_total_partitions> 必须是大于当前分区数的新分区总数。例如,如果当前是 3 个分区,你想增加到 5 个,那么 <new_total_partitions> 就是 5。

  • 增加原理:

    • 当执行增加分区命令后,请求会发给 Controller。
    • Controller 会更新 ZooKeeper 中的 Topic 元数据。
    • 然后,Controller 会向所有 Broker 发送 UpdateMetadata 请求,通知它们新的分区布局。
    • 受影响的 Broker 会在本地创建新的分区目录和文件。
    • 新的分区一开始没有历史数据,也不会有历史消息。只有当生产者开始向这些新分区发送消息时,它们才会开始写入数据。
  • 注意事项:

    • 增加分区通常是在线操作,不会中断服务。
    • 增加分区不会导致现有数据的重新分布。旧分区的数据仍在旧分区中,新数据会根据分区器分布到新旧分区中。
    • 增加分区后,分区器行为会改变。如果你的生产者使用默认分区器(基于 Key 的哈希),那么增加分区后,相同的 Key 可能会被分配到不同的分区,这会破坏基于 Key 的消息顺序性。你需要谨慎处理这种情况,可能需要调整生产者逻辑或接受新的分区分配。
    • 增加分区后,消费者组可能需要进行 Rebalance,以确保所有分区都被消费者消费。

12. Topic 的分区数可不可以减少?如果可以怎么减少?如果不可以,那又是为什么?

Topic 的分区数不可以直接减少。

  • 为什么不可以?

    • 数据丢失风险: 减少分区意味着删除现有的分区。如果这些分区中有未消费的消息,或者即使已消费但需要保留历史数据,这些消息都会被永久删除。Kafka 的设计哲学是尽可能保证数据不丢失。
    • 消息顺序性问题: 如果强行减少分区,并尝试将旧分区的消息合并到新分区,那么原先在不同分区中、各自有序但整体无序的消息可能会被强制排序,从而破坏原有分区的顺序性保证。这会使系统的行为变得不可预测。
    • 实现复杂性: 在保证数据不丢失和顺序性的前提下,实现分区缩减的逻辑非常复杂,可能需要大量的数据迁移和元数据更新,且难以做到无缝在线进行。
  • 替代方案(曲线救国):

    虽然不能直接减少,但如果你真的需要更少的分区,可以考虑以下“曲线救国”的方案:

    1. 创建新 Topic: 创建一个分区数符合要求的新 Topic。
    2. 迁移数据: 编写一个程序,将旧 Topic 中的所有数据逐条读取并写入到新的 Topic 中。这个过程需要小心处理消息的顺序性和幂等性。
    3. 切换生产者和消费者: 将所有生产者和消费者切换到新的 Topic 上。
    4. 删除旧 Topic: 确认所有数据已迁移且服务已稳定运行在新 Topic 上后,再删除旧 Topic。

这种方法虽然繁琐,但能确保数据不丢失和顺序性不被破坏。


13. Kafka 有内部的 topic 吗?如果有是什么?有什么作用?

**是的,Kafka 有内部 Topic。**最常见的内部 Topic 是:

  1. __consumer_offsets
    • 作用:用于存储消费者组的消费位移 (offset)
    • 为什么需要:在 Kafka 0.8.2 版本之前,消费位移是存储在 ZooKeeper 中的。但 ZooKeeper 不擅长高并发的写入,且位移提交是高频操作,容易成为性能瓶颈。Kafka 从 0.8.2 版本开始,将消费位移的存储从 ZooKeeper 迁移到了 Kafka 自身的内部 Topic __consumer_offsets 中。
    • 特点
      • 这个 Topic 是自动创建的,默认是 50 个分区(可通过 offsets.topic.num.partitions 配置)且副本因子为 3(可通过 offsets.topic.replication.factor 配置)。
      • 消息的 Key 是 group.id + topic + partition,Value 是对应的位移信息。
      • 消费位移数据会进行压缩周期性清理(通过 Log Compaction)。
      • 消费者组提交位移时,实际上就是向这个 Topic 发送一条消息。
    • 重要性:它是实现消费者组高可用和消费位移持久化的核心机制。
  2. __transaction_state (或 __transaction_coordinator_state 在早期版本中):
    • 作用:用于存储事务状态
    • 为什么需要:从 Kafka 0.11 版本开始引入了事务(Exactly Once 语义)。事务协调器 (Transaction Coordinator) 需要持久化事务的状态,例如事务的 ID、状态(开启、提交、中止)以及包含的生产者 ID 和分区信息等。这些信息就存储在这个内部 Topic 中。
    • 特点:同样是自动创建和管理的。
  3. __cluster_metadata (Kafka 2.8+,KIP-500/KRaft 模式下):
    • 作用:在 KRaft 模式下(移除了对 ZooKeeper 的依赖),这个 Topic 用于存储 Kafka 集群的所有元数据,包括 Broker 信息、Topic 信息(分区、副本、ISR 等)、控制器信息、用户配额等。
    • 特点:它是 KRaft 模式下取代 ZooKeeper 的核心。

总结:这些内部 Topic 是 Kafka 集群正常运行的基石,它们使得 Kafka 能够高效地管理消费者位移、事务状态和集群元数据,从而实现其高可用和强大的功能。它们通常对用户是透明的,不需要手动管理。


14. Kafka 分区分配的概念?

Kafka 分区分配是指在消费者组 (Consumer Group) 内,将一个或多个 Topic 的所有分区,分配给组内各个消费者实例的过程。这个过程的目标是确保每个分区只被组内的一个消费者消费,并且尽可能地实现负载均衡。

  • 分配时机:

    分区分配发生在消费者组进行 Rebalance (再平衡)时。Rebalance 在以下几种情况会触发:

    1. 新消费者加入消费组。
    2. 消费者离开消费组。 (正常关闭或非正常崩溃)
    3. Topic 分区数增加或减少。 (虽然分区不能减少,但可以增加)
    4. 消费者组订阅的 Topic 发生变化。
    5. 消费者心跳超时。
  • 分配流程 (高层):

    1. 当触发 Rebalance 时,消费者组会选举出一个 Group Coordinator (通常是某个 Broker)。
    2. Group Coordinator 负责协调 Rebalance 过程,并选举出组内的一个 Leader Consumer
    3. Leader Consumer 根据所配置的分区分配策略,生成一个分区到消费者的分配方案。
    4. Leader Consumer 将分配方案提交给 Group Coordinator。
    5. Group Coordinator 将这个方案广播给组内所有消费者。
    6. 每个消费者根据分配方案,开始消费自己被分配到的分区。
  • 分区分配策略 (Partition Assignment Strategy):

    Kafka 提供了多种内置的分区分配策略,你可以通过消费者参数

    1
    partition.assignment.strategy

    进行配置:

    • RangeAssignor (范围分配策略):默认策略。按 Topic 排序,然后将每个 Topic 的分区按范围(partitionId)分配给消费者。例如,Topic A 有 10 个分区,3 个消费者。消费者 1 可能分到 A-0, A-1, A-2, A-3;消费者 2 分到 A-4, A-5, A-6;消费者 3 分到 A-7, A-8, A-9。这种策略可能导致某些消费者分配到的分区数不均匀,尤其是在分区数不能被消费者数整除时。
    • RoundRobinAssignor (轮询分配策略):将所有 Topic 的所有分区“混合”在一起,然后轮询分配给消费者。它通常能实现更均匀的分区分配,但可能导致单个 Topic 的分区不连续。
    • StickyAssignor (粘性分配策略):从 Kafka 0.11 版本引入。它在 Rebalance 时,力求在保持均衡的同时,尽可能地保留之前分配给消费者的分区。这减少了不必要的分区迁移,降低了 Rebalance 的开销。它是目前推荐使用的策略,因为它在保持负载均衡的同时,最小化了分区移动。
    • CooperativeStickyAssignor (协作式粘性分配策略):从 Kafka 2.4 版本引入,是 StickyAssignor 的增强版,支持增量式 Rebalance (Incremental Rebalance)。它允许消费者在 Rebalance 过程中继续消费部分分区,减少了 Rebalance 停顿时间,提高了可用性。
  • 自定义分配策略: 你也可以通过实现 org.apache.kafka.clients.consumer.ConsumerPartitionAssignor 接口来定义自己的分区分配策略。


15. 简述 Kafka 的日志目录结构?

Kafka Broker 在其配置的日志目录(通过 log.dirs 配置,可以配置多个目录,用逗号分隔)下,会为每个 Topic 的每个分区创建独立的子目录来存储消息数据。

典型的日志目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
/opt/kafka/data/  <-- log.dirs 配置的根目录之一
├── topic1-0/ <-- topic1 的 partition 0
│ ├── 00000000000000000000.log <-- 日志段文件
│ ├── 00000000000000000000.index <-- 偏移量索引文件
│ ├── 00000000000000000000.timeindex <-- 时间戳索引文件
│ └── leader-epoch-checkpoint <-- Leader Epoch 检查点文件
├── topic1-1/ <-- topic1 的 partition 1
│ ├── ...
├── topic2-0/ <-- topic2 的 partition 0
│ ├── ...
└── __consumer_offsets-0/ <-- 内部 Topic 的 partition 0
├── ...

目录及文件说明:

  1. Topic-Partition 目录 (<topic_name>-<partition_id>/):
    • 每个 Topic 的每个分区(无论是 Leader 还是 Follower 副本)都会在 log.dirs 指定的目录下拥有一个独立的子目录。例如,my_topic-0 代表 my_topic 的第 0 个分区。
    • 这个目录就是存储该分区所有日志数据和索引文件的位置。
  2. 日志段文件 (.log 文件):
    • 00000000000000000000.log
    • 这是实际存储消息数据的文件。消息是追加写入到这些文件中。
    • 当一个日志段文件达到一定大小(由 log.segment.bytes 配置,默认 1GB)或达到一定时间(由 log.roll.mslog.roll.hours 配置)时,会进行日志分段(Log Roll),创建一个新的 .log 文件。
    • 文件名中的数字(例如 00000000000000000000)表示该日志段中第一条消息的起始偏移量 (base offset)
    • 这是 Kafka 高性能的核心之一:顺序读写磁盘,利用了操作系统的页缓存。
  3. 偏移量索引文件 (.index 文件):
    • 00000000000000000000.index
    • 用于存储消息的稀疏索引。它映射逻辑偏移量 (relative offset) 到消息在 .log 文件中的物理位置 (position)
    • 当消费者请求某个偏移量的消息时,Kafka 会先在这个 .index 文件中查找,快速定位到 .log 文件中的大致位置,然后进行顺序读取。
  4. 时间戳索引文件 (.timeindex 文件):
    • 00000000000000000000.timeindex
    • 用于存储时间戳到偏移量的映射。
    • 主要用于按时间查找消息(例如,consumer.seek(timestamp))。它同样是稀疏索引,映射时间戳到对应的偏移量。
  5. leader-epoch-checkpoint 文件:
    • 这是一个文本文件,记录了 Leader 副本的纪元 (Epoch) 信息。
    • 每当一个分区的 Leader 发生变化时,就会创建一个新的 Leader Epoch。这个文件帮助 Broker 在 Leader 选举和恢复时保持一致性。
  6. replication-offset-checkpoint 文件:
    • 记录了 Follower 副本从 Leader 复制的最新偏移量信息,用于恢复 Follower 的复制进度。

16. 如果我指定了一个 offset,Kafka Controller 怎么查找到对应的消息?

Kafka Controller 不直接负责查找到对应的消息,它的主要职责是集群元数据管理和 Leader 选举。查找到对应消息是 Broker 的职责。

当一个消费者指定一个偏移量 offset 来查找消息时(例如通过 consumer.seek(partition, offset)),其流程大致如下:

  1. 消费者发送 FetchRequest 给 Leader Broker: 消费者客户端会向指定分区(Partition)的当前 Leader 副本所在的 Broker 发送一个 FetchRequest,请求从给定 offset 开始的消息。

  2. Leader Broker 接收请求并处理:

    目标 Broker 收到

    1
    FetchRequest

    后,会执行以下步骤来查找消息:

    • 确定目标日志段: Broker 会利用分区目录下的 .index 文件(偏移量索引)来快速定位包含指定 offset 的日志段(.log 文件)。日志段的文件名就是其起始偏移量。
    • 在日志段中查找: 一旦确定了目标 .log 文件,Broker 会在该文件的 .index 索引文件中查找与指定 offset 最接近且不大于 offset 的索引条目。这个索引条目会告诉 Broker 在 .log 文件中的物理位置
    • 顺序读取消息: 从找到的物理位置开始,Broker 会在 .log 文件中顺序读取消息数据,直到达到请求的最大字节数或读取到指定 offset 的消息(包括它自己)及其后续消息。
    • 返回消息给消费者: Broker 将读取到的消息数据返回给消费者客户端。

总结:

  • Kafka Controller: 负责整个集群的元数据(Topics、分区、副本、Broker 信息)管理和 Leader 选举。它知道哪个 Broker 是哪个分区的 Leader。
  • Leader Broker: 负责存储分区的实际消息数据。当需要查找特定偏移量的消息时,是由该分区的 Leader Broker 来完成的。它通过日志段文件.log)和偏移量索引文件.index)来高效地定位和读取消息。

17. 聊一聊 Kafka Controller 的作用?

Kafka Controller 是 Kafka 集群中一个非常重要的角色,它是集群的大脑和管理者。在一个 Kafka 集群中,只有一个 Broker 会被选举为 Controller,其他 Broker 都是普通的 Follower Broker。

Controller 的主要作用和职责包括:

  1. Leader 选举 (Leader Election):
    • 这是 Controller 最核心的功能。当一个分区的 Leader 副本宕机或不可用时,Controller 会负责从该分区的 ISR 列表中选举出新的 Leader。
    • 它会更新 ZooKeeper 中的 Leader 和 ISR 信息,并通知所有相关 Broker。
  2. 集群元数据管理和同步 (Metadata Management and Synchronization):
    • Controller 维护着集群中所有 Broker、Topic、分区、副本、消费者组的元数据信息。
    • 它负责将这些元数据的最新状态同步给集群中的所有 Broker,确保所有 Broker 都拥有最新的集群视图。
  3. Topic 管理 (Topic Management):
    • 创建/删除 Topic: 当用户创建或删除 Topic 时,Controller 负责实际执行这些操作,包括在 ZooKeeper 中创建/删除相应的 znode,并在所有相关 Broker 上创建/删除分区目录。
    • 增加分区: 当 Topic 增加分区时,Controller 负责分配新分区,并通知相关 Broker 创建。
  4. 副本状态管理 (Replica State Management):
    • Controller 负责监控所有分区的副本状态(Leader、Follower、In-Sync、Out-of-Sync)。
    • 当 Follower 副本与 Leader 失去同步时(落后太多),Controller 会将其从 ISR 列表中移除。当 Follower 追上 Leader 时,Controller 会将其重新加回 ISR。
  5. Broker 故障检测和处理 (Broker Failure Detection and Handling):
    • Controller 会在 ZooKeeper 上监听 /brokers/ids 路径。当有 Broker 上线或下线时,Controller 会收到通知。
    • 如果一个 Broker 宕机,Controller 会检测到,并:
      • 将该 Broker 上所有的 Leader 副本转移到其他健康的 Broker 上(Leader 选举)。
      • 将该 Broker 上所有的 Follower 副本标记为不可用。
      • 在 Broker 恢复上线后,Controller 会帮助其进行数据同步和副本状态恢复。
  6. 分区重分配 (Partition Reassignment):
    • 当用户手动执行分区重分配(例如为了负载均衡或 Broker 扩容)时,Controller 负责协调整个重分配过程,确保数据平稳迁移。
  7. Preferred Leader 选举 (Preferred Leader Election):
    • 为了均衡负载,Controller 会周期性地检查并尝试将分区的 Leader 恢复到 Preferred Leader(在 AR 列表中排名第一的副本),前提是 Preferred Leader 也在 ISR 中。

Controller 的选举:

Controller 是通过在 ZooKeeper 上创建临时节点来选举产生的。第一个成功创建 /controller znode 的 Broker 就会成为当前的 Controller。如果当前的 Controller 宕机,这个 znode 会消失,其他 Broker 会竞争创建新的 znode,从而选举出新的 Controller。

总结:Controller 是 Kafka 集群高可用和自动化管理的核心组件。它的存在使得 Kafka 能够在 Broker 故障、元数据变更等情况下保持稳定运行和数据一致性。


18. Kafka 中有那些地方需要选举?这些地方的选举策略又有哪些?

Kafka 中主要有以下几个地方需要选举:

  1. Controller 选举:
    • 作用: 选举出整个 Kafka 集群的唯一大脑,负责集群元数据管理和 Leader 选举。
    • 选举策略:
      • 基于 ZooKeeper 的公平选举: 所有 Broker 都会尝试在 ZooKeeper 的 /controller 路径下创建一个临时(Ephemeral)znode。ZooKeeper 保证只有一个 Broker 能成功创建。第一个成功创建的 Broker 就成为 Controller。
      • Leader Epoch: 为了防止脑裂(Split-Brain)问题,Controller 每次选举成功后会递增一个 Leader Epoch ID,并将其持久化。所有 Broker 在与 Controller 交互时都会带上这个 Epoch ID,旧的 Controller 会被新的 Epoch 拒绝。
    • 时机: Kafka 集群启动时;当前 Controller 宕机时。
  2. 分区 Leader 选举:
    • 作用: 为每个分区选择一个 Leader 副本,所有生产和消费请求都通过 Leader 副本进行。
    • 选举策略:
      • 基于 Controller 的 ISR 选举(默认): 当分区的 Leader 副本宕机时,Controller 会从该分区的 ISR (In-Sync Replicas) 列表中选择第一个健康的副本作为新的 Leader。这是最安全的策略,因为它只从同步副本中选择,保证数据不丢失。
      • 非同步副本选举(Unclean Leader Election):
        • 可以通过配置 unclean.leader.election.enable=true 启用。
        • 在这种情况下,如果 ISR 中没有可用的副本,Controller 会从 OSR (Out-of-Sync Replicas) 甚至所有 AR (All Replicas) 中选择一个副本作为 Leader。
        • 风险: 启用此选项可能会导致数据丢失,因为 OSR 副本可能不包含 Leader 之前的所有消息。但它可以在极端情况下(所有 ISR 副本都宕机)提高可用性,避免分区长时间不可用。
      • Preferred Leader Election (优选 Leader 选举):
        • 为了负载均衡,Controller 会定期(或手动触发)检查每个分区的 AR 列表中的第一个副本是否是当前 Leader,并且是否在 ISR 中。如果是,Controller 会尝试将其选举为 Leader。
        • 这个策略旨在将 Leader 分布到所有 Broker 上,避免某些 Broker 负载过重。
    • 时机: 分区 Leader 宕机;分区 Leader 所在的 Broker 宕机;执行 kafka-preferred-replica-election.sh 命令;Broker 重启后。
  3. 消费者组 Leader 选举 (Group Coordinator/Leader Consumer):
    • Group Coordinator (协调器):
      • 作用: 协调整个消费者组的 Rebalance 过程,并存储消费者组的位移信息。
      • 选举策略: 消费者组的每个消费者会根据 group.id 的哈希值,连接到一个特定的 Broker 作为其 Group Coordinator。这个 Broker 会在启动时被选举出来(通过 Broker ID 的哈希值等)。
      • 时机: 消费者组第一次启动时;当前 Group Coordinator 宕机时。
    • Leader Consumer (组内协调者):
      • 作用: 在一次 Rebalance 中,由它负责根据分配策略生成分区分配方案。
      • 选举策略: Group Coordinator 会选择组内第一个加入的消费者,或者某个特定的消费者作为 Leader Consumer。
      • 时机: 每次 Rebalance 开始时。

19. 失效副本是指什么?有那些应对措施?

失效副本 (Offline Replica 或 Out-of-Sync Replica) 通常指以下几种情况的副本:

  1. 处于 OSR 列表中的副本: 这是最常见的“失效副本”情况。指 Follower 副本的 LEO 已经远远落后于 Leader 副本的 LEO(超过 replica.lag.time.max.ms 配置的时间),或者该 Follower 副本所在的 Broker 已经宕机。Controller 会将这些副本从 ISR 列表中移除。
  2. 副本所在的 Broker 宕机: 如果某个 Broker 宕机,那么它上面承载的所有分区副本都会变为“失效”状态。如果这些副本是 Leader,会触发 Leader 选举;如果是 Follower,会从 ISR 中移除。
  3. 副本自身故障: 比如磁盘损坏、网络隔离等,导致副本无法正常与 Leader 同步。

应对措施:

  1. Leader 选举 (自动处理):
    • 如果失效副本是 Leader,Controller 会立即从其 ISR 中选举出新的 Leader,确保分区可用性。
  2. 副本同步与恢复:
    • 当失效的 Follower 副本所在的 Broker 恢复上线,或者网络/磁盘问题解决后,该 Follower 副本会从新的 Leader 副本那里重新开始同步数据
    • 它会尝试从 Leader 的 HW 开始复制,直到追上 Leader 的 LEO,并重新回到 ISR 列表。
  3. unclean.leader.election.enable 配置:
    • 如前所述,如果所有 ISR 副本都失效,为了可用性,可以配置 unclean.leader.election.enable=true 允许从 OSR 中选举 Leader。但这会牺牲数据一致性。
  4. 增加副本因子 (Replica Factor):
    • 在 Topic 创建时设置更高的副本因子(例如 3 或 5),可以增加容错能力。即使多个副本失效,也能保证 ISR 中有足够多的副本,从而避免分区不可用。
  5. 监控和告警:
    • 密切监控 Broker 的健康状况、ISR 副本的数量(例如 kafka.server:type=ReplicaManager,name=IsrShrinksPerSec 指标),以及磁盘使用情况。
    • 当 ISR 数量低于 min.insync.replicas 或有 Broker 宕机时,及时发出告警。
  6. 分区重分配 (Partition Reassignment):
    • 如果某个 Broker 长期失效或磁盘损坏,可以考虑将它上面的分区副本手动迁移到其他健康的 Broker 上,以避免数据丢失风险和恢复服务能力。
    • 使用 kafka-reassign-partitions.sh 工具。
  7. 日志压缩 (Log Compaction) 和保留策略:
    • 对于 __consumer_offsets 等内部 Topic 或一些需要保留最新状态的 Topic,使用日志压缩可以防止日志无限增长。
    • 对于其他 Topic,合理配置 log.retention.mslog.retention.bytes,确保日志不会无限期地保留,以免耗尽磁盘空间。

20. Kafka 的哪些设计让它有如此高的性能?

Kafka 之所以能达到如此高的吞吐量和性能,得益于其一系列精妙的设计和工程实现:

  1. 顺序读写磁盘 (Sequential Disk I/O):
    • 设计: Kafka 将消息以追加 (append-only) 的方式写入日志文件(Segment Log),并采用顺序读写。
    • 优势: 顺序 I/O 相比随机 I/O 效率高出几个数量级,因为它避免了磁头的频繁寻道,几乎能达到内存读写速度。即使是 SSD,顺序写入也能发挥最佳性能。
  2. 零拷贝 (Zero-Copy):
    • 设计: 在消息传输给消费者时,Kafka 充分利用了操作系统的 sendfile 系统调用 (或类似的零拷贝技术)。
    • 优势: 避免了数据在内核空间和用户空间之间多次复制,减少了 CPU 和内存开销。数据直接从磁盘文件系统缓存发送到网络套接字,显著提高了吞吐量和降低了延迟。
  3. 批处理 (Batching):
    • 设计: 生产者在发送消息时,不是每条消息都立即发送,而是会将多条消息聚合到一起形成一个消息批次 (RecordBatch),然后一次性发送。同样,消费者也是批量拉取消息。
    • 优势: 减少了网络请求的次数和 I/O 操作的开销,分摊了 TCP/IP 协议栈的开销,提高了网络利用率。
  4. 分区 (Partitioning) 和并行度:
    • 设计: Topic 被划分为多个分区,每个分区是一个独立的日志。分区可以分布在不同的 Broker 上,甚至同一个 Broker 上的不同磁盘上。
    • 优势:
      • 可伸缩性: 允许集群水平扩展,通过增加 Broker 和分区来提高整体吞吐量。
      • 并行处理: 生产者可以并行向多个分区发送消息,消费者组可以并行消费多个分区。
  5. 消息压缩 (Message Compression):
    • 设计: Kafka 支持多种压缩算法 (如 Gzip, Snappy, LZ4, Zstandard),可以在生产者端对消息进行批量压缩,然后在 Broker 存储,消费者拉取后解压。
    • 优势: 显著减少了网络传输的数据量和磁盘存储空间,尤其对于大量重复或文本消息效果显著。
  6. 文件系统缓存 (Page Cache):
    • 设计: Kafka 严重依赖操作系统的页缓存 (Page Cache) 来缓存消息数据。
    • 优势: 大部分读写操作都在内存中完成,速度极快。操作系统会智能地管理内存,将最近访问的数据保留在缓存中,提高缓存命中率。即使重启 Broker,页缓存也能保留。
  7. 低延迟的持久化:
    • 设计: 消息写入磁盘后,并非立即 fsync 刷盘,而是异步刷盘或依赖操作系统定期刷盘。只在重要操作(如 Leader 选举、副本同步)时才确保数据持久化。
    • 优势: 减少了磁盘 IO 的阻塞,提升了写入性能。Kafka 通过副本机制来保证数据可靠性,即使少数数据未及时刷盘而 Broker 宕机,也能从其他副本恢复。
  8. 简洁的消费者和生产者 API:
    • 设计: 生产者和消费者客户端 API 简洁高效,减少了不必要的复杂逻辑和额外的开销。
    • 优势: 降低了使用门槛,也减少了客户端的计算开销。
  9. 去中心化的 Leader 选举和协调:
    • 设计: 虽然有 Controller,但每个分区的 Leader 选举和数据复制是独立的,这使得系统更加健壮和并行。Group Coordinator 也分散在不同的 Broker 上。
    • 优势: 避免了单点瓶颈。

这些设计原则和实现细节共同造就了 Kafka 作为高吞吐量、低延迟的分布式消息系统的卓越性能。

分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package org.pt;

/**
* @ClassName DistributedLockService
* @Author pt
* @Description
* @Date 2025/6/16 15:53
**/

import jakarta.annotation.Resource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service(value = "DistributedLockService")
public class DistributedLockService {

private static final String LOCK_KEY_PREFIX = "lock:";

@Resource
private StringRedisTemplate stringRedisTemplate;

private static final DefaultRedisScript<Long> LOCK_SCRIPT;
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
// 初始化加锁脚本
LOCK_SCRIPT = new DefaultRedisScript<>();
LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/lock.lua")));
LOCK_SCRIPT.setResultType(Long.class);

// 初始化解锁脚本
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/unlock.lua")));
UNLOCK_SCRIPT.setResultType(Long.class);
}



/**
* 尝试获取锁
* @param resourceName 锁定的资源名称
* @param lockValue 锁的持有者标识
* @param expireTime 过期时间
* @param unit 时间单位
* @return true 如果成功获取锁, false otherwise
*/
public boolean tryLock(String resourceName, String lockValue, long expireTime, TimeUnit unit) {
String key = LOCK_KEY_PREFIX + resourceName;
long expireMillis = unit.toMillis(expireTime);
Long result = stringRedisTemplate.execute(
LOCK_SCRIPT,
Collections.singletonList(key),
lockValue,
String.valueOf(expireMillis)
);

return result != null && result == 1L;
}

/**
* 释放锁
* @param resourceName 锁定的资源名称
* @param lockValue 锁的持有者标识 (必须与加锁时相同)
*/
public void unlock(String resourceName, String lockValue) {
String key = LOCK_KEY_PREFIX + resourceName;
stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), lockValue);
}

public void processOrder(String orderId) {
String lockValue = UUID.randomUUID().toString();
// 尝试获取订单锁,最长等待30秒
if (tryLock("order:" + orderId, lockValue, 30, TimeUnit.SECONDS)) {
try {
System.out.println("成功获取锁,开始处理订单:" + orderId);
Thread.sleep(500); // 模拟业务处理
System.out.println("订单处理完成:" + orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 确保释放锁
unlock("order:" + orderId, lockValue);
System.out.println("释放锁:" + orderId);
}
} else {
System.out.println("获取锁失败,请稍后重试:" + orderId);
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
-- KEYS[1]: 锁的 key
-- ARGV[1]: 锁的 value (通常是唯一的请求ID或线程ID)
-- ARGV[2]: 锁的过期时间(毫秒)

-- 尝试获取锁,使用 set 命令的 NX 和 PX 选项
-- 如果 key 不存在(NX),则设置 key 和 value,并设置过期时间(PX)
local result = redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])

if result then
return 1
else
return 0
end
1
2
3
4
5
6
7
8
9
10
11
12
13
-- KEYS[1]: 锁的 key
-- ARGV[1]: 锁的 value (用于验证是否是自己的锁)

-- 先获取锁的 value
local lockValue = redis.call('get', KEYS[1])
-- 检查锁是否存在,并且 value 与期望的 value 是否一致
if lockValue == ARGV[1] then
-- 如果是自己的锁,则删除,释放锁
return redis.call('del', KEYS[1])
else
-- 不是自己的锁,或者锁已不存在,不做任何操作
return 0
end

限流

方式一(string)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package org.pt;

/**
* @ClassName RateLimiterService
* @Author pt
* @Description
* @Date 2025/6/16 16:40
**/

import jakarta.annotation.Resource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
import java.util.Collections;

@Service
public class RateLimiterService {

@Resource
private StringRedisTemplate stringRedisTemplate;

private final static DefaultRedisScript<Long> rateLimitScript;

static {
rateLimitScript = new DefaultRedisScript<>();
rateLimitScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/ratelimit.lua")));
rateLimitScript.setResultType(Long.class);
}

/**
* 检查某个操作是否被允许
* @param key 限流的唯一标识
* @param windowSeconds 时间窗口(秒)
* @param maxRequests 最大请求数
* @return true 如果允许, false 如果被限流
*/
public boolean isAllowed(String key, int windowSeconds, int maxRequests) {
Long result = stringRedisTemplate.execute(
rateLimitScript,
Collections.singletonList(key),
String.valueOf(windowSeconds),
String.valueOf(maxRequests)
);
return result != null && result == 1L;
}

public void handleApiRequest(String userId) {
String key = "ratelimit:api:user:" + userId;
// 每60秒内,只允许用户访问10
if (isAllowed(key, 60, 10)) {
System.out.println("用户 " + userId + " 访问成功。");
} else {
System.out.println("用户 " + userId + " 访问过于频繁,已被限流。");
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- KEYS[1]: 限流的 key (例如: ratelimit:user:123)
-- ARGV[1]: 时间窗口(秒)
-- ARGV[2]: 窗口内的最大请求数

local current_requests = tonumber(redis.call('get', KEYS[1]) or "0")

if current_requests < tonumber(ARGV[2]) then
-- 未达到阈值,计数器加1
local new_val = redis.call('incr', KEYS[1])
-- 如果是第一次设置,需要设置过期时间
if new_val == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
return 1 -- 允许访问
else
-- 已达到阈值
return 0 -- 拒绝访问
end

方式二(sorted set)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package org.pt;

/**
* @ClassName SlidingWindowRateLimiterService
* @Author pt
* @Description
* @Date 2025/6/16 16:58
**/
import jakarta.annotation.Resource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
import java.util.Collections;

/**
* 基于 Redis 有序集合 (Sorted Set) 实现的滑动窗口限流服务。
*/
@Service
public class SlidingWindowRateLimiterService {

@Resource
private StringRedisTemplate stringRedisTemplate;

private final static DefaultRedisScript<Long> slidingWindowScript;

static {
slidingWindowScript = new DefaultRedisScript<>();
slidingWindowScript.setResultType(Long.class);
slidingWindowScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/sliding_window.lua")));
}

/**
* 检查某个操作在滑动窗口内是否被允许。
*
* @param key 要限流的资源的唯一键 (e.g., "ratelimit:user:pengtao")
* @param windowSeconds 时间窗口的大小,单位为秒 (e.g., 10)
* @param maxRequests 在时间窗口内允许的最大请求数 (e.g., 3)
* @return true 如果请求被允许, false 如果请求被拒绝
*/
public boolean isAllowed(String key, int windowSeconds, int maxRequests) {
// 调用 Redis 执行 Lua 脚本
Long result = stringRedisTemplate.execute(
slidingWindowScript,
Collections.singletonList(key), // KEYS[1]
String.valueOf(windowSeconds), // ARGV[1]
String.valueOf(maxRequests) // ARGV[2]
);

// 如果脚本返回 1,则表示允许
return result != null && result == 1L;
}

/**
* 使用示例:模拟一个需要限流的 API 请求。
* @param userId 用户ID
*/
public void handleApiRequest(String userId) {
String key = "ratelimit:api:sliding:" + userId;
// 设置规则:每 10 秒最多允许 3 次请求
if (isAllowed(key, 10, 3)) {
System.out.println("用户 " + userId + " 的请求被允许。");
// 在这里执行核心业务逻辑...
} else {
System.out.println("用户 " + userId + " 的请求被拒绝,访问过于频繁!");
// 在这里可以抛出异常或返回错误信息
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
--[[
基于 Redis 有序集合实现的滑动窗口限流器。

KEYS[1]: 需要被限流的资源的唯一键 (例如: "rate_limit:user:123")。

ARGV[1]: 时间窗口的大小,单位为秒 (例如: 60)。
ARGV[2]: 在时间窗口内允许的最大请求数 (例如: 100)。
--]]
-- 从 Redis 服务器获取当前时间。TIME 命令返回一个表: {秒, 微秒}。
local now = redis.call('TIME')
local current_seconds = tonumber(now[1])
local current_microseconds = tonumber(now[2])

-- 将秒和微秒组合成一个高精度的时间戳分数。
-- 这个值也将作为本次请求的唯一成员。
local current_timestamp_score = current_seconds * 1000000 + current_microseconds
-- 从参数中获取窗口大小和最大请求数。
local window_size_seconds = tonumber(ARGV[1])
local max_requests = tonumber(ARGV[2])
-- 计算出有效窗口的最小分数(即窗口的起始时间)。
-- 任何分数小于此值的请求都被认为是“过期的”,将会被移除。
local window_start_score = current_timestamp_score - (window_size_seconds * 1000000)
-- 1. 清理旧记录: 原子性地从有序集合中移除所有过期的成员。
-- 这些都是在当前滑动窗口开始之前发生的请求。
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', window_start_score)
-- 2. 统计数量: 获取当前窗口内的请求总数。
local request_count = redis.call('ZCARD', KEYS[1])

-- 3. 检查阈值: 将当前计数与允许的最大请求数进行比较。
if request_count < max_requests then
-- 未达到限流阈值。
-- 4. 添加新请求: 将当前请求的时间戳添加到有序集合中。
-- 我们使用高精度的时间戳同时作为分数(score)和成员(member)。
redis.call('ZADD', KEYS[1], current_timestamp_score, current_timestamp_score)

-- 5. 设置过期时间: 给这个 Key 本身设置一个过期时间。这是一个良好实践,
-- 用于自动清理非活跃用户的键,以节省内存。
-- 过期时间设置为窗口大小。
redis.call('EXPIRE', KEYS[1], window_size_seconds)

-- 返回 1 表示请求被允许。
return 1
else
-- 已经达到限流阈值。
-- 返回 0 表示请求应该被拒绝。
return 0
end

秒杀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package org.pt;

/**
* @ClassName SeckillService
* @Author pt
* @Description
* @Date 2025/6/16 17:22
**/
import jakarta.annotation.Resource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
import java.util.Collections;

@Service
public class SeckillService {

@Resource
private StringRedisTemplate stringRedisTemplate;

private final static DefaultRedisScript<Long> stockDeductScript;

static {
stockDeductScript = new DefaultRedisScript<>();
stockDeductScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/stock_deduct.lua")));
stockDeductScript.setResultType(Long.class);
}

/**
* 扣减库存
* @param productId 商品ID
* @return true 如果扣减成功, false otherwise
*/
public boolean deductStock(String productId) {
String key = "stock:product:" + productId;
// 每次扣减 1 个库存
Long result = stringRedisTemplate.execute(
stockDeductScript,
Collections.singletonList(key),
"1"
);
if (result == null) {
return false;
}
if (result >= 0) {
System.out.println("商品 " + productId + " 库存扣减成功,剩余库存:" + result);
return true;
} else if (result == -1) {
System.out.println("商品 " + productId + " 库存不足!");
return false;
} else { // result == -2
System.out.println("商品 " + productId + " 不存在或已售罄!");
return false;
}
}

// 初始化库存以供测试
public void setStock(String productId, int stock) {
String key = "stock:product:" + productId;
stringRedisTemplate.opsForValue().set(key, String.valueOf(stock));
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- scripts/stock_deduct.lua
-- KEYS[1]: 商品库存的 key (例如: stock:product:1001)
-- ARGV[1]: 本次要扣减的数量 (通常是 1)

local stock = tonumber(redis.call('get', KEYS[1]))
-- 检查库存是否存在且大于0
if stock and stock > 0 then
-- 检查库存是否足够本次扣减
local requested_amount = tonumber(ARGV[1])
if stock >= requested_amount then
-- 库存充足,执行扣减操作
return redis.call('decrby', KEYS[1], requested_amount)
else
-- 库存不足
return -1
end
else
-- 库存不存在或已售罄
return -2
end

堆内存的分布

1. 经典分代模型 (适用于 ParNew, Parallel, CMS 等收集器)

在传统的垃圾收集中,堆内存被物理上划分为连续的两大块:

  • 新生代 (Young Generation / New Generation)
    • 作用: 存放新创建的、生命周期通常很短的对象。新生代是 Minor GC / Young GC 发生的主要场所。
    • 内部结构
      • 伊甸园区 (Eden Space): 绝大多数对象诞生的地方。
      • 幸存者区 (Survivor Space): 有两个,通常称为 S0S1。它们大小相等,采用“复制算法”进行垃圾回收,在任何时刻总有一个是空的。用于存放每次 Minor GC 后仍然存活的对象。
  • 老年代 (Old Generation / Tenured Generation)
    • 作用: 存放生命周期长的对象。这些对象通常是在新生代的 Survivor 区中“熬过”了多次 Minor GC 后晋升上来的,或者是“大对象”。
    • 老年代的 GC 通常被称为 Major GCFull GC,其回收频率远低于 Minor GC,但耗时更长。
  • 方法区 (Method Area) / 元空间 (Metaspace)
    • 这是一个重要的补充:在 JDK 8 之前,有一个叫做 永久代 (Permanent Generation) 的区域,它在逻辑上属于方法区,但在物理上是堆的一部分
    • 从 JDK 8 开始,永久代被彻底移除,取而代之的是元空间 (Metaspace)。元空间使用的是本地内存(Native Memory),而不是堆内存
    • 作用: 存放类的元数据信息(如类名、字段、方法)、常量池、即时编译器(JIT)编译后的代码等。

2. G1 垃圾收集器的堆分布模型

G1(Garbage-First)虽然在逻辑上仍然遵循分代的概念,但在物理上彻底改变了内存布局

  • 不再是连续空间: G1 将整个堆划分为大量大小相等的、不连续的区域 (Region)。每个 Region 的大小通常是 1MB 到 32MB 之间的 2 的幂。
  • 动态的角色: 每个 Region 在同一时间只扮演一种角色,但这个角色是动态变化的。一个 Region 在此刻可能是 Eden,在被回收后可能就变成了 Free(空闲),之后又可能被用于存放老年代对象。
  • Region 的角色类型
    • Eden Region: 逻辑上构成新生代的 Eden 区。
    • Survivor Region: 逻辑上构成新生代的 Survivor 区。
    • Old Region: 逻辑上构成老年代。
    • Humongous Region (大对象区): 专门用于存放“大对象”(大小超过 Region 容量 50% 的对象)。一个大对象可能会跨越多个连续的 Humongous Region。逻辑上它属于老年代。
    • Free Region: 未被使用的空闲区域。

这种基于 Region 的设计使得 G1 可以根据需要灵活地调整新生代和老年代的大小,并且在回收时可以选择收益最高的若干个 Region进行回收(这也是“Garbage-First”名字的由来),从而更好地满足设定的停顿时间目标

各种 GC 的触发条件

GC 类型 (GC Type) 回收区域 (Collection Area) 核心触发条件 (Core Triggering Conditions) 特点与影响 (Characteristics & Impact)
Minor GC / Young GC 仅新生代 (Eden + From Survivor) 1. Eden 区空间不足:当应用程序需要分配一个新对象,而 Eden 区没有足够的连续空间时,就会触发。这是最常见的触发条件。 高频、快速、STW短。只回收新生代,存活对象被复制到 Survivor 区或晋升到老年代。这是常规且健康的 GC 模式。
G1 特有:因大对象分配的 Young GC 仅新生代 1. 分配大对象 (Humongous Object):当需要分配一个大对象时,G1 会检查是否有足够的连续空闲 Region。如果没有,它会优先尝试触发一次 Young GC,期望通过清理新生代来释放出足够的连续空间。 STW 同样很短。这是一种为满足大对象分配的预处理操作。日志标识为 (G1 Humongous Allocation)
G1 特有:Mixed GC (混合回收) 整个新生代 + 部分老年代 Region 1. 老年代占用率达到阈值:当老年代的占用率超过 InitiatingHeapOccupancyPercent (IHOP,默认 45%) 阈值时,G1 会启动一个并发标记周期(与应用程序并行,基本无 STW)。
2. 并发标记完成:在标记周期成功完成后,G1 就知道了哪些老年代 Region 的“垃圾”最多。它会在接下来的几次 Young GC 中,顺带回收一部分“垃圾最多”的老年代 Region。这种“Young GC + 部分 Old GC”的组合就叫 Mixed GC。
STW 可控。这是 G1 的核心优势,它避免了一次性回收整个老年代带来的长停顿,通过多次、小规模地清理老年代来控制停顿时间。
Full GC (全局回收) 整个堆 (新生代 + 老年代) + 元空间 1. System.gc() 调用:代码中显式调用该方法,强烈建议 JVM 执行一次 Full GC。
2. 晋升失败 (Promotion Failure):在 Young GC 之后,存活的对象需要晋升到老年代,但老年代没有足够的连续空间来容纳它们。
3. 元空间不足 (Metaspace is full):如果加载的类过多,导致元空间耗尽,会触发 Full GC。
4. 并发模式失败 (Concurrent Mode Failure):在 CMS 或 G1 中,如果并发标记/回收的速度跟不上应用程序创建对象的速度,导致老年代被填满,JVM 会放弃并发,退化(Fallback)为一次有长时间 STW 的 Full GC。
5. G1 大对象分配失败:当分配一个大对象时,即使在触发了一次 Young GC 后,依然没有足够的连续空间来容纳它,可能会退化成 Full GC。
低频、极慢、STW长。这是性能的“杀手”,会暂停所有应用线程,直到回收完成。在调优中,避免或减少 Full GC 的发生是首要目标之一。

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package org.pt.gc;

/**
* @ClassName GcBehaviorDemo
* @Author pt
* @Description
* @Date 2025/6/14 21:36
**/
import java.util.ArrayList;
import java.util.List;

public class GcBehaviorDemo {

// 定义每个对象的大小 (1 MB)
private static final int OBJECT_SIZE = 400 * 1024;

// 用于持有对象的引用,防止被立即回收
private static final List<byte[]> objectHolder = new ArrayList<>();

public static void main(String[] args) throws InterruptedException {
printMemoryUsage("程序启动");
// --- 阶段1 在Eden区快速分配小对象,触发Minor GC ---
System.out.println("\n===== 阶段1: 快速分配小对象,填满Eden区,触发Minor GC =====");
for (int i = 0; i < 15; i++) {
allocateAndPrint(i);
Thread.sleep(2000); // 短暂休眠,让GC日志更容易观察
}
printMemoryUsage("阶段1结束");

// --- 行为2: 让部分对象存活下来,观察其晋升到老年代 ---
// 在阶段1中,部分对象(被objectHolder持有)已经在多次Minor GC中存活下来,
// 应该已经被晋升到老年代了。我们再分配一些,确保老年代有数据。
System.out.println("\n===== 阶段2: 持续分配,观察对象晋升至老年代 =====");
for (int i = 0; i < 10; i++) {
allocateAndPrint(15 + i);
Thread.sleep(200);
}
printMemoryUsage("阶段2结束");
// --- 行为3: 分配一个大对象,可能直接进入老年代 ---
System.out.println("\n===== 阶段3: 分配一个大对象 =====");
System.out.println("准备分配一个 8 MB 的大对象...");
// G1中,超过Region大小一半的对象被视为大对象(Humongous Object)
// 会被直接分配到专门的Humongous Region,逻辑上属于老年代
byte[] largeObject = new byte[8 * 1024 * 1024];
System.out.println("大对象分配完毕。");
printMemoryUsage("阶段3结束");

// --- 行为4: 手动触发Full GC ---
System.out.println("\n===== 阶段4: 手动触发 Full GC =====");
System.out.println("清空所有引用...");
objectHolder.clear();
largeObject = null; // 清除大对象引用
System.out.println("调用 System.gc() 建议执行 Full GC...");
System.gc();
Thread.sleep(2000); // 等待GC完成
printMemoryUsage("Full GC后");

System.out.println("\n程序结束。请分析控制台输出的GC日志。");
}

/**
* 分配一个1MB的对象,并将其加入到持有列表中
*/
private static void allocateAndPrint(int count) {
System.out.printf("分配第 %d 个对象 (1 MB)...%n", count + 1);
objectHolder.add(new byte[OBJECT_SIZE]);
}

/**
* 打印当前堆内存的使用情况
*/
private static void printMemoryUsage(String stage) {
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
System.out.printf("[%s] - 堆内存: 已用 %.2f MB, 空闲 %.2f MB, 总共 %.2f MB%n",
stage,
bytesToMb(usedMemory),
bytesToMb(freeMemory),
bytesToMb(totalMemory));
}

private static double bytesToMb(long bytes) {
return (double) bytes / 1024 / 1024;
}
}

运行命令

1
2
java -Xms60m -Xmx60m -XX:+UseG1GC -XX:+UnlockExperimentalVMOptions -XX:MaxGCPauseMillis=200 -XX:G1NewSizePercent=40 -XX:G1MaxNewSizePercent=40 -XX:MaxTenuringThreshold=2 '-Xlog:gc*:file=gc.log' org.pt.gc.GcBehaviorDemo

日志输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
[0.005s][info][gc] Using G1
[0.005s][info][gc,init] Version: 17.0.11+7-LTS-207 (release)
[0.005s][info][gc,init] CPUs: 8 total, 8 available
[0.005s][info][gc,init] Memory: 8192M
[0.005s][info][gc,init] Large Page Support: Disabled
[0.005s][info][gc,init] NUMA Support: Disabled
[0.005s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.005s][info][gc,init] Heap Region Size: 1M
[0.005s][info][gc,init] Heap Min Capacity: 64M
[0.005s][info][gc,init] Heap Initial Capacity: 64M
[0.005s][info][gc,init] Heap Max Capacity: 64M
[0.005s][info][gc,init] Pre-touch: Disabled
[0.005s][info][gc,init] Parallel Workers: 8
[0.005s][info][gc,init] Concurrent Workers: 2
[0.005s][info][gc,init] Concurrent Refinement Workers: 8
[0.005s][info][gc,init] Periodic GC: Disabled
[0.009s][info][gc,metaspace] CDS archive(s) mapped at: [0x000000a800000000-0x000000a800be0000-0x000000a800be0000), size 12451840, SharedBaseAddress: 0x000000a800000000, ArchiveRelocationMode: 1.
[0.009s][info][gc,metaspace] Compressed class space mapped at: 0x000000a801000000-0x000000a841000000, reserved size: 1073741824
[0.009s][info][gc,metaspace] Narrow klass base: 0x000000a800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
[2.698s][info][gc,start ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[2.698s][info][gc,task ] GC(0) Using 2 workers of 8 for evacuation
[2.700s][info][gc,phases ] GC(0) Pre Evacuate Collection Set: 0.1ms
[2.700s][info][gc,phases ] GC(0) Merge Heap Roots: 0.0ms
[2.700s][info][gc,phases ] GC(0) Evacuate Collection Set: 1.3ms
[2.700s][info][gc,phases ] GC(0) Post Evacuate Collection Set: 0.2ms
[2.700s][info][gc,phases ] GC(0) Other: 0.5ms
[2.700s][info][gc,heap ] GC(0) Eden regions: 2->0(24)
[2.700s][info][gc,heap ] GC(0) Survivor regions: 0->1(4)
[2.700s][info][gc,heap ] GC(0) Old regions: 0->0
[2.700s][info][gc,heap ] GC(0) Archive regions: 2->2
[2.700s][info][gc,heap ] GC(0) Humongous regions: 26->26
[2.700s][info][gc,metaspace] GC(0) Metaspace: 323K(512K)->323K(512K) NonClass: 299K(384K)->299K(384K) Class: 24K(128K)->24K(128K)
[2.700s][info][gc ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 28M->27M(64M) 2.214ms
[2.700s][info][gc,cpu ] GC(0) User=0.00s Sys=0.00s Real=0.01s
[2.700s][info][gc ] GC(1) Concurrent Undo Cycle
[2.700s][info][gc,marking ] GC(1) Concurrent Cleanup for Next Mark
[2.700s][info][gc,marking ] GC(1) Concurrent Cleanup for Next Mark 0.435ms
[2.700s][info][gc ] GC(1) Concurrent Undo Cycle 0.492ms
[2.901s][info][gc,start ] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[2.902s][info][gc,task ] GC(2) Using 2 workers of 8 for evacuation
[2.903s][info][gc,phases ] GC(2) Pre Evacuate Collection Set: 0.1ms
[2.903s][info][gc,phases ] GC(2) Merge Heap Roots: 0.0ms
[2.903s][info][gc,phases ] GC(2) Evacuate Collection Set: 1.2ms
[2.903s][info][gc,phases ] GC(2) Post Evacuate Collection Set: 0.2ms
[2.903s][info][gc,phases ] GC(2) Other: 0.4ms
[2.903s][info][gc,heap ] GC(2) Eden regions: 1->0(24)
[2.903s][info][gc,heap ] GC(2) Survivor regions: 1->1(4)
[2.903s][info][gc,heap ] GC(2) Old regions: 0->0
[2.903s][info][gc,heap ] GC(2) Archive regions: 2->2
[2.903s][info][gc,heap ] GC(2) Humongous regions: 28->28
[2.904s][info][gc,metaspace] GC(2) Metaspace: 324K(512K)->324K(512K) NonClass: 300K(384K)->300K(384K) Class: 24K(128K)->24K(128K)
[2.904s][info][gc ] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 29M->29M(64M) 2.060ms
[2.904s][info][gc,cpu ] GC(2) User=0.00s Sys=0.00s Real=0.00s
[2.904s][info][gc ] GC(3) Concurrent Mark Cycle
[2.904s][info][gc,marking ] GC(3) Concurrent Clear Claimed Marks
[2.904s][info][gc,marking ] GC(3) Concurrent Clear Claimed Marks 0.018ms
[2.904s][info][gc,marking ] GC(3) Concurrent Scan Root Regions
[2.905s][info][gc,marking ] GC(3) Concurrent Scan Root Regions 0.959ms
[2.905s][info][gc,marking ] GC(3) Concurrent Mark
[2.905s][info][gc,marking ] GC(3) Concurrent Mark From Roots
[2.905s][info][gc,task ] GC(3) Using 2 workers of 2 for marking
[2.907s][info][gc,marking ] GC(3) Concurrent Mark From Roots 2.221ms
[2.907s][info][gc,marking ] GC(3) Concurrent Preclean
[2.907s][info][gc,marking ] GC(3) Concurrent Preclean 0.039ms
[2.907s][info][gc,start ] GC(3) Pause Remark
[2.907s][info][gc ] GC(3) Pause Remark 31M->31M(64M) 0.264ms
[2.907s][info][gc,cpu ] GC(3) User=0.00s Sys=0.00s Real=0.00s
[2.907s][info][gc,marking ] GC(3) Concurrent Mark 2.665ms
[2.907s][info][gc,marking ] GC(3) Concurrent Rebuild Remembered Sets
[2.907s][info][gc,marking ] GC(3) Concurrent Rebuild Remembered Sets 0.009ms
[2.907s][info][gc,start ] GC(3) Pause Cleanup
[2.907s][info][gc ] GC(3) Pause Cleanup 31M->31M(64M) 0.017ms
[2.907s][info][gc,cpu ] GC(3) User=0.00s Sys=0.00s Real=0.00s
[2.907s][info][gc,marking ] GC(3) Concurrent Cleanup for Next Mark
[2.908s][info][gc,marking ] GC(3) Concurrent Cleanup for Next Mark 0.501ms
[2.908s][info][gc ] GC(3) Concurrent Mark Cycle 4.343ms
[3.111s][info][gc,start ] GC(4) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[3.111s][info][gc,task ] GC(4) Using 2 workers of 8 for evacuation
[3.113s][info][gc,phases ] GC(4) Pre Evacuate Collection Set: 0.1ms
[3.113s][info][gc,phases ] GC(4) Merge Heap Roots: 0.0ms
[3.113s][info][gc,phases ] GC(4) Evacuate Collection Set: 1.0ms
[3.113s][info][gc,phases ] GC(4) Post Evacuate Collection Set: 0.1ms
[3.113s][info][gc,phases ] GC(4) Other: 0.3ms
[3.113s][info][gc,heap ] GC(4) Eden regions: 1->0(24)
[3.113s][info][gc,heap ] GC(4) Survivor regions: 1->1(4)
[3.113s][info][gc,heap ] GC(4) Old regions: 0->1
[3.113s][info][gc,heap ] GC(4) Archive regions: 2->2
[3.113s][info][gc,heap ] GC(4) Humongous regions: 30->30
[3.113s][info][gc,metaspace] GC(4) Metaspace: 325K(512K)->325K(512K) NonClass: 301K(384K)->301K(384K) Class: 24K(128K)->24K(128K)
[3.113s][info][gc ] GC(4) Pause Young (Concurrent Start) (G1 Humongous Allocation) 31M->31M(64M) 1.671ms
[3.113s][info][gc,cpu ] GC(4) User=0.00s Sys=0.00s Real=0.00s
[3.113s][info][gc ] GC(5) Concurrent Mark Cycle
[3.113s][info][gc,marking ] GC(5) Concurrent Clear Claimed Marks
[3.113s][info][gc,marking ] GC(5) Concurrent Clear Claimed Marks 0.013ms
[3.113s][info][gc,marking ] GC(5) Concurrent Scan Root Regions
[3.114s][info][gc,marking ] GC(5) Concurrent Scan Root Regions 1.265ms
[3.114s][info][gc,marking ] GC(5) Concurrent Mark
[3.114s][info][gc,marking ] GC(5) Concurrent Mark From Roots
[3.114s][info][gc,task ] GC(5) Using 2 workers of 2 for marking
[3.116s][info][gc,marking ] GC(5) Concurrent Mark From Roots 2.053ms
[3.117s][info][gc,marking ] GC(5) Concurrent Preclean
[3.117s][info][gc,marking ] GC(5) Concurrent Preclean 0.062ms
[3.117s][info][gc,start ] GC(5) Pause Remark
[3.117s][info][gc ] GC(5) Pause Remark 33M->33M(64M) 0.480ms
[3.117s][info][gc,cpu ] GC(5) User=0.00s Sys=0.00s Real=0.00s
[3.117s][info][gc,marking ] GC(5) Concurrent Mark 2.905ms
[3.117s][info][gc,marking ] GC(5) Concurrent Rebuild Remembered Sets
[3.118s][info][gc,marking ] GC(5) Concurrent Rebuild Remembered Sets 0.511ms
[3.118s][info][gc,start ] GC(5) Pause Cleanup
[3.118s][info][gc ] GC(5) Pause Cleanup 33M->33M(64M) 0.043ms
[3.118s][info][gc,cpu ] GC(5) User=0.00s Sys=0.00s Real=0.00s
[3.118s][info][gc,marking ] GC(5) Concurrent Cleanup for Next Mark
[3.118s][info][gc,marking ] GC(5) Concurrent Cleanup for Next Mark 0.083ms
[3.118s][info][gc ] GC(5) Concurrent Mark Cycle 5.033ms
[3.320s][info][gc,start ] GC(6) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[3.320s][info][gc,task ] GC(6) Using 2 workers of 8 for evacuation
[3.321s][info][gc,phases ] GC(6) Pre Evacuate Collection Set: 0.1ms
[3.321s][info][gc,phases ] GC(6) Merge Heap Roots: 0.0ms
[3.321s][info][gc,phases ] GC(6) Evacuate Collection Set: 0.5ms
[3.321s][info][gc,phases ] GC(6) Post Evacuate Collection Set: 0.2ms
[3.321s][info][gc,phases ] GC(6) Other: 0.4ms
[3.321s][info][gc,heap ] GC(6) Eden regions: 1->0(24)
[3.321s][info][gc,heap ] GC(6) Survivor regions: 1->1(4)
[3.321s][info][gc,heap ] GC(6) Old regions: 1->1
[3.321s][info][gc,heap ] GC(6) Archive regions: 2->2
[3.321s][info][gc,heap ] GC(6) Humongous regions: 32->32
[3.321s][info][gc,metaspace] GC(6) Metaspace: 325K(512K)->325K(512K) NonClass: 301K(384K)->301K(384K) Class: 24K(128K)->24K(128K)
[3.322s][info][gc ] GC(6) Pause Young (Concurrent Start) (G1 Humongous Allocation) 33M->33M(64M) 1.452ms
[3.322s][info][gc,cpu ] GC(6) User=0.00s Sys=0.00s Real=0.00s
[3.322s][info][gc ] GC(7) Concurrent Mark Cycle
[3.322s][info][gc,marking ] GC(7) Concurrent Clear Claimed Marks
[3.322s][info][gc,marking ] GC(7) Concurrent Clear Claimed Marks 0.016ms
[3.322s][info][gc,marking ] GC(7) Concurrent Scan Root Regions
[3.322s][info][gc,marking ] GC(7) Concurrent Scan Root Regions 0.075ms
[3.322s][info][gc,marking ] GC(7) Concurrent Mark
[3.322s][info][gc,marking ] GC(7) Concurrent Mark From Roots
[3.322s][info][gc,task ] GC(7) Using 2 workers of 2 for marking
[3.325s][info][gc,marking ] GC(7) Concurrent Mark From Roots 2.776ms
[3.325s][info][gc,marking ] GC(7) Concurrent Preclean
[3.325s][info][gc,marking ] GC(7) Concurrent Preclean 0.044ms
[3.325s][info][gc,start ] GC(7) Pause Remark
[3.325s][info][gc ] GC(7) Pause Remark 35M->35M(64M) 0.256ms
[3.325s][info][gc,cpu ] GC(7) User=0.00s Sys=0.00s Real=0.00s
[3.325s][info][gc,marking ] GC(7) Concurrent Mark 3.214ms
[3.325s][info][gc,marking ] GC(7) Concurrent Rebuild Remembered Sets
[3.326s][info][gc,marking ] GC(7) Concurrent Rebuild Remembered Sets 0.489ms
[3.326s][info][gc,start ] GC(7) Pause Cleanup
[3.326s][info][gc ] GC(7) Pause Cleanup 35M->35M(64M) 0.218ms
[3.326s][info][gc,cpu ] GC(7) User=0.00s Sys=0.00s Real=0.00s
[3.326s][info][gc,marking ] GC(7) Concurrent Cleanup for Next Mark
[3.326s][info][gc,marking ] GC(7) Concurrent Cleanup for Next Mark 0.193ms
[3.326s][info][gc ] GC(7) Concurrent Mark Cycle 4.417ms
[3.528s][info][gc,start ] GC(8) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[3.528s][info][gc,task ] GC(8) Using 2 workers of 8 for evacuation
[3.529s][info][gc,phases ] GC(8) Pre Evacuate Collection Set: 0.0ms
[3.529s][info][gc,phases ] GC(8) Merge Heap Roots: 0.0ms
[3.529s][info][gc,phases ] GC(8) Evacuate Collection Set: 0.4ms
[3.529s][info][gc,phases ] GC(8) Post Evacuate Collection Set: 0.1ms
[3.529s][info][gc,phases ] GC(8) Other: 0.3ms
[3.529s][info][gc,heap ] GC(8) Eden regions: 1->0(24)
[3.529s][info][gc,heap ] GC(8) Survivor regions: 1->1(4)
[3.529s][info][gc,heap ] GC(8) Old regions: 1->1
[3.529s][info][gc,heap ] GC(8) Archive regions: 2->2
[3.529s][info][gc,heap ] GC(8) Humongous regions: 34->34
[3.529s][info][gc,metaspace] GC(8) Metaspace: 325K(512K)->325K(512K) NonClass: 301K(384K)->301K(384K) Class: 24K(128K)->24K(128K)
[3.529s][info][gc ] GC(8) Pause Young (Concurrent Start) (G1 Humongous Allocation) 35M->35M(64M) 1.077ms
[3.529s][info][gc,cpu ] GC(8) User=0.00s Sys=0.00s Real=0.00s
[3.529s][info][gc ] GC(9) Concurrent Mark Cycle
[3.529s][info][gc,marking ] GC(9) Concurrent Clear Claimed Marks
[3.529s][info][gc,marking ] GC(9) Concurrent Clear Claimed Marks 0.019ms
[3.529s][info][gc,marking ] GC(9) Concurrent Scan Root Regions
[3.529s][info][gc,marking ] GC(9) Concurrent Scan Root Regions 0.077ms
[3.529s][info][gc,marking ] GC(9) Concurrent Mark
[3.529s][info][gc,marking ] GC(9) Concurrent Mark From Roots
[3.529s][info][gc,task ] GC(9) Using 2 workers of 2 for marking
[3.532s][info][gc,marking ] GC(9) Concurrent Mark From Roots 3.013ms
[3.532s][info][gc,marking ] GC(9) Concurrent Preclean
[3.532s][info][gc,marking ] GC(9) Concurrent Preclean 0.048ms
[3.532s][info][gc,start ] GC(9) Pause Remark
[3.532s][info][gc ] GC(9) Pause Remark 37M->37M(64M) 0.294ms
[3.532s][info][gc,cpu ] GC(9) User=0.00s Sys=0.00s Real=0.00s
[3.532s][info][gc,marking ] GC(9) Concurrent Mark 3.487ms
[3.533s][info][gc,marking ] GC(9) Concurrent Rebuild Remembered Sets
[3.533s][info][gc,marking ] GC(9) Concurrent Rebuild Remembered Sets 0.562ms
[3.533s][info][gc,start ] GC(9) Pause Cleanup
[3.533s][info][gc ] GC(9) Pause Cleanup 37M->37M(64M) 0.044ms
[3.533s][info][gc,cpu ] GC(9) User=0.00s Sys=0.00s Real=0.00s
[3.533s][info][gc,marking ] GC(9) Concurrent Cleanup for Next Mark
[3.533s][info][gc,marking ] GC(9) Concurrent Cleanup for Next Mark 0.171ms
[3.533s][info][gc ] GC(9) Concurrent Mark Cycle 4.603ms
[3.730s][info][gc,start ] GC(10) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[3.731s][info][gc,task ] GC(10) Using 2 workers of 8 for evacuation
[3.731s][info][gc,phases ] GC(10) Pre Evacuate Collection Set: 0.1ms
[3.731s][info][gc,phases ] GC(10) Merge Heap Roots: 0.0ms
[3.731s][info][gc,phases ] GC(10) Evacuate Collection Set: 0.4ms
[3.731s][info][gc,phases ] GC(10) Post Evacuate Collection Set: 0.1ms
[3.731s][info][gc,phases ] GC(10) Other: 0.3ms
[3.731s][info][gc,heap ] GC(10) Eden regions: 1->0(25)
[3.732s][info][gc,heap ] GC(10) Survivor regions: 1->0(4)
[3.732s][info][gc,heap ] GC(10) Old regions: 1->1
[3.732s][info][gc,heap ] GC(10) Archive regions: 2->2
[3.732s][info][gc,heap ] GC(10) Humongous regions: 36->36
[3.732s][info][gc,metaspace] GC(10) Metaspace: 325K(512K)->325K(512K) NonClass: 301K(384K)->301K(384K) Class: 24K(128K)->24K(128K)
[3.732s][info][gc ] GC(10) Pause Young (Concurrent Start) (G1 Humongous Allocation) 37M->37M(64M) 1.106ms
[3.732s][info][gc,cpu ] GC(10) User=0.00s Sys=0.00s Real=0.00s
[3.732s][info][gc ] GC(11) Concurrent Mark Cycle
[3.732s][info][gc,marking ] GC(11) Concurrent Clear Claimed Marks
[3.732s][info][gc,marking ] GC(11) Concurrent Clear Claimed Marks 0.018ms
[3.732s][info][gc,marking ] GC(11) Concurrent Scan Root Regions
[3.732s][info][gc,marking ] GC(11) Concurrent Scan Root Regions 0.053ms
[3.732s][info][gc,marking ] GC(11) Concurrent Mark
[3.732s][info][gc,marking ] GC(11) Concurrent Mark From Roots
[3.732s][info][gc,task ] GC(11) Using 2 workers of 2 for marking
[3.735s][info][gc,marking ] GC(11) Concurrent Mark From Roots 2.881ms
[3.735s][info][gc,marking ] GC(11) Concurrent Preclean
[3.735s][info][gc,marking ] GC(11) Concurrent Preclean 0.052ms
[3.735s][info][gc,start ] GC(11) Pause Remark
[3.735s][info][gc ] GC(11) Pause Remark 39M->39M(64M) 0.282ms
[3.735s][info][gc,cpu ] GC(11) User=0.00s Sys=0.00s Real=0.00s
[3.735s][info][gc,marking ] GC(11) Concurrent Mark 3.411ms
[3.735s][info][gc,marking ] GC(11) Concurrent Rebuild Remembered Sets
[3.736s][info][gc,marking ] GC(11) Concurrent Rebuild Remembered Sets 0.561ms
[3.736s][info][gc,start ] GC(11) Pause Cleanup
[3.736s][info][gc ] GC(11) Pause Cleanup 39M->39M(64M) 0.049ms
[3.736s][info][gc,cpu ] GC(11) User=0.00s Sys=0.00s Real=0.00s
[3.736s][info][gc,marking ] GC(11) Concurrent Cleanup for Next Mark
[3.736s][info][gc,marking ] GC(11) Concurrent Cleanup for Next Mark 0.127ms
[3.736s][info][gc ] GC(11) Concurrent Mark Cycle 4.413ms
[3.935s][info][gc,start ] GC(12) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[3.935s][info][gc,task ] GC(12) Using 2 workers of 8 for evacuation
[3.936s][info][gc,phases ] GC(12) Pre Evacuate Collection Set: 0.1ms
[3.936s][info][gc,phases ] GC(12) Merge Heap Roots: 0.0ms
[3.936s][info][gc,phases ] GC(12) Evacuate Collection Set: 0.4ms
[3.936s][info][gc,phases ] GC(12) Post Evacuate Collection Set: 0.2ms
[3.936s][info][gc,phases ] GC(12) Other: 0.4ms
[3.936s][info][gc,heap ] GC(12) Eden regions: 1->0(25)
[3.936s][info][gc,heap ] GC(12) Survivor regions: 0->0(4)
[3.936s][info][gc,heap ] GC(12) Old regions: 1->1
[3.936s][info][gc,heap ] GC(12) Archive regions: 2->2
[3.937s][info][gc,heap ] GC(12) Humongous regions: 38->38
[3.937s][info][gc,metaspace] GC(12) Metaspace: 325K(512K)->325K(512K) NonClass: 301K(384K)->301K(384K) Class: 24K(128K)->24K(128K)
[3.937s][info][gc ] GC(12) Pause Young (Concurrent Start) (G1 Humongous Allocation) 39M->39M(64M) 1.941ms
[3.937s][info][gc,cpu ] GC(12) User=0.00s Sys=0.00s Real=0.00s
[3.937s][info][gc ] GC(13) Concurrent Mark Cycle
[3.937s][info][gc,marking ] GC(13) Concurrent Clear Claimed Marks
[3.937s][info][gc,marking ] GC(13) Concurrent Clear Claimed Marks 0.012ms
[3.937s][info][gc,marking ] GC(13) Concurrent Scan Root Regions
[3.937s][info][gc,marking ] GC(13) Concurrent Scan Root Regions 0.010ms
[3.937s][info][gc,marking ] GC(13) Concurrent Mark
[3.937s][info][gc,marking ] GC(13) Concurrent Mark From Roots
[3.937s][info][gc,task ] GC(13) Using 2 workers of 2 for marking
[3.940s][info][gc,marking ] GC(13) Concurrent Mark From Roots 3.079ms
[3.940s][info][gc,marking ] GC(13) Concurrent Preclean
[3.941s][info][gc,marking ] GC(13) Concurrent Preclean 0.058ms
[3.941s][info][gc,start ] GC(13) Pause Remark
[3.941s][info][gc ] GC(13) Pause Remark 41M->41M(64M) 0.269ms
[3.941s][info][gc,cpu ] GC(13) User=0.00s Sys=0.00s Real=0.00s
[3.941s][info][gc,marking ] GC(13) Concurrent Mark 3.664ms
[3.941s][info][gc,marking ] GC(13) Concurrent Rebuild Remembered Sets
[3.942s][info][gc,marking ] GC(13) Concurrent Rebuild Remembered Sets 0.585ms
[3.942s][info][gc,start ] GC(13) Pause Cleanup
[3.942s][info][gc ] GC(13) Pause Cleanup 41M->41M(64M) 0.037ms
[3.942s][info][gc,cpu ] GC(13) User=0.00s Sys=0.00s Real=0.00s
[3.942s][info][gc,marking ] GC(13) Concurrent Cleanup for Next Mark
[3.942s][info][gc,marking ] GC(13) Concurrent Cleanup for Next Mark 0.083ms
[3.942s][info][gc ] GC(13) Concurrent Mark Cycle 4.547ms
[4.143s][info][gc,start ] GC(14) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[4.143s][info][gc,task ] GC(14) Using 2 workers of 8 for evacuation
[4.144s][info][gc,phases ] GC(14) Pre Evacuate Collection Set: 0.1ms
[4.144s][info][gc,phases ] GC(14) Merge Heap Roots: 0.1ms
[4.144s][info][gc,phases ] GC(14) Evacuate Collection Set: 0.5ms
[4.145s][info][gc,phases ] GC(14) Post Evacuate Collection Set: 0.2ms
[4.145s][info][gc,phases ] GC(14) Other: 0.3ms
[4.145s][info][gc,heap ] GC(14) Eden regions: 1->0(25)
[4.145s][info][gc,heap ] GC(14) Survivor regions: 0->0(4)
[4.145s][info][gc,heap ] GC(14) Old regions: 1->1
[4.145s][info][gc,heap ] GC(14) Archive regions: 2->2
[4.145s][info][gc,heap ] GC(14) Humongous regions: 40->40
[4.145s][info][gc,metaspace] GC(14) Metaspace: 325K(512K)->325K(512K) NonClass: 301K(384K)->301K(384K) Class: 24K(128K)->24K(128K)
[4.145s][info][gc ] GC(14) Pause Young (Concurrent Start) (G1 Humongous Allocation) 41M->41M(64M) 1.359ms
[4.145s][info][gc,cpu ] GC(14) User=0.00s Sys=0.00s Real=0.00s
[4.145s][info][gc ] GC(15) Concurrent Mark Cycle
[4.145s][info][gc,marking ] GC(15) Concurrent Clear Claimed Marks
[4.145s][info][gc,marking ] GC(15) Concurrent Clear Claimed Marks 0.025ms
[4.145s][info][gc,marking ] GC(15) Concurrent Scan Root Regions
[4.145s][info][gc,marking ] GC(15) Concurrent Scan Root Regions 0.018ms
[4.145s][info][gc,marking ] GC(15) Concurrent Mark
[4.145s][info][gc,marking ] GC(15) Concurrent Mark From Roots
[4.145s][info][gc,task ] GC(15) Using 2 workers of 2 for marking
[4.148s][info][gc,marking ] GC(15) Concurrent Mark From Roots 3.433ms
[4.148s][info][gc,marking ] GC(15) Concurrent Preclean
[4.148s][info][gc,marking ] GC(15) Concurrent Preclean 0.046ms
[4.149s][info][gc,start ] GC(15) Pause Remark
[4.149s][info][gc ] GC(15) Pause Remark 43M->43M(64M) 0.284ms
[4.149s][info][gc,cpu ] GC(15) User=0.00s Sys=0.00s Real=0.00s
[4.149s][info][gc,marking ] GC(15) Concurrent Mark 3.897ms
[4.149s][info][gc,marking ] GC(15) Concurrent Rebuild Remembered Sets
[4.149s][info][gc,marking ] GC(15) Concurrent Rebuild Remembered Sets 0.418ms
[4.149s][info][gc,start ] GC(15) Pause Cleanup
[4.149s][info][gc ] GC(15) Pause Cleanup 43M->43M(64M) 0.040ms
[4.149s][info][gc,cpu ] GC(15) User=0.00s Sys=0.00s Real=0.00s
[4.149s][info][gc,marking ] GC(15) Concurrent Cleanup for Next Mark
[4.150s][info][gc,marking ] GC(15) Concurrent Cleanup for Next Mark 0.117ms
[4.150s][info][gc ] GC(15) Concurrent Mark Cycle 4.734ms
[4.346s][info][gc,start ] GC(16) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[4.346s][info][gc,task ] GC(16) Using 2 workers of 8 for evacuation
[4.347s][info][gc,phases ] GC(16) Pre Evacuate Collection Set: 0.1ms
[4.347s][info][gc,phases ] GC(16) Merge Heap Roots: 0.1ms
[4.347s][info][gc,phases ] GC(16) Evacuate Collection Set: 0.2ms
[4.347s][info][gc,phases ] GC(16) Post Evacuate Collection Set: 0.1ms
[4.347s][info][gc,phases ] GC(16) Other: 0.1ms
[4.347s][info][gc,heap ] GC(16) Eden regions: 1->0(25)
[4.347s][info][gc,heap ] GC(16) Survivor regions: 0->0(4)
[4.347s][info][gc,heap ] GC(16) Old regions: 1->1
[4.347s][info][gc,heap ] GC(16) Archive regions: 2->2
[4.347s][info][gc,heap ] GC(16) Humongous regions: 42->42
[4.347s][info][gc,metaspace] GC(16) Metaspace: 325K(512K)->325K(512K) NonClass: 301K(384K)->301K(384K) Class: 24K(128K)->24K(128K)
[4.347s][info][gc ] GC(16) Pause Young (Concurrent Start) (G1 Humongous Allocation) 43M->43M(64M) 0.753ms
[4.347s][info][gc,cpu ] GC(16) User=0.00s Sys=0.01s Real=0.00s
[4.347s][info][gc ] GC(17) Concurrent Mark Cycle
[4.347s][info][gc,marking ] GC(17) Concurrent Clear Claimed Marks
[4.347s][info][gc,marking ] GC(17) Concurrent Clear Claimed Marks 0.010ms
[4.347s][info][gc,marking ] GC(17) Concurrent Scan Root Regions
[4.347s][info][gc,marking ] GC(17) Concurrent Scan Root Regions 0.007ms
[4.347s][info][gc,marking ] GC(17) Concurrent Mark
[4.347s][info][gc,marking ] GC(17) Concurrent Mark From Roots
[4.347s][info][gc,task ] GC(17) Using 2 workers of 2 for marking
[4.352s][info][gc,marking ] GC(17) Concurrent Mark From Roots 4.678ms
[4.352s][info][gc,marking ] GC(17) Concurrent Preclean
[4.352s][info][gc,marking ] GC(17) Concurrent Preclean 0.031ms
[4.352s][info][gc,start ] GC(17) Pause Remark
[4.352s][info][gc ] GC(17) Pause Remark 45M->45M(64M) 0.189ms
[4.352s][info][gc,cpu ] GC(17) User=0.00s Sys=0.00s Real=0.00s
[4.352s][info][gc,marking ] GC(17) Concurrent Mark 5.034ms
[4.352s][info][gc,marking ] GC(17) Concurrent Rebuild Remembered Sets
[4.353s][info][gc,marking ] GC(17) Concurrent Rebuild Remembered Sets 0.711ms
[4.353s][info][gc,start ] GC(17) Pause Cleanup
[4.353s][info][gc ] GC(17) Pause Cleanup 45M->45M(64M) 0.208ms
[4.353s][info][gc,cpu ] GC(17) User=0.00s Sys=0.00s Real=0.00s
[4.353s][info][gc,marking ] GC(17) Concurrent Cleanup for Next Mark
[4.353s][info][gc,marking ] GC(17) Concurrent Cleanup for Next Mark 0.328ms
[4.353s][info][gc ] GC(17) Concurrent Mark Cycle 6.505ms
[4.548s][info][gc,start ] GC(18) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[4.548s][info][gc,task ] GC(18) Using 2 workers of 8 for evacuation
[4.549s][info][gc,phases ] GC(18) Pre Evacuate Collection Set: 0.1ms
[4.549s][info][gc,phases ] GC(18) Merge Heap Roots: 0.0ms
[4.549s][info][gc,phases ] GC(18) Evacuate Collection Set: 0.4ms
[4.549s][info][gc,phases ] GC(18) Post Evacuate Collection Set: 0.2ms
[4.549s][info][gc,phases ] GC(18) Other: 0.3ms
[4.549s][info][gc,heap ] GC(18) Eden regions: 1->0(25)
[4.549s][info][gc,heap ] GC(18) Survivor regions: 0->0(4)
[4.549s][info][gc,heap ] GC(18) Old regions: 1->1
[4.550s][info][gc,heap ] GC(18) Archive regions: 2->2
[4.550s][info][gc,heap ] GC(18) Humongous regions: 44->44
[4.550s][info][gc,metaspace] GC(18) Metaspace: 326K(512K)->326K(512K) NonClass: 302K(384K)->302K(384K) Class: 24K(128K)->24K(128K)
[4.550s][info][gc ] GC(18) Pause Young (Concurrent Start) (G1 Humongous Allocation) 45M->45M(64M) 1.235ms
[4.550s][info][gc,cpu ] GC(18) User=0.00s Sys=0.00s Real=0.01s
[4.550s][info][gc ] GC(19) Concurrent Mark Cycle
[4.550s][info][gc,marking ] GC(19) Concurrent Clear Claimed Marks
[4.550s][info][gc,marking ] GC(19) Concurrent Clear Claimed Marks 0.025ms
[4.550s][info][gc,marking ] GC(19) Concurrent Scan Root Regions
[4.550s][info][gc,marking ] GC(19) Concurrent Scan Root Regions 0.020ms
[4.550s][info][gc,marking ] GC(19) Concurrent Mark
[4.550s][info][gc,marking ] GC(19) Concurrent Mark From Roots
[4.550s][info][gc,task ] GC(19) Using 2 workers of 2 for marking
[4.553s][info][gc,marking ] GC(19) Concurrent Mark From Roots 3.313ms
[4.553s][info][gc,marking ] GC(19) Concurrent Preclean
[4.553s][info][gc,marking ] GC(19) Concurrent Preclean 0.044ms
[4.553s][info][gc,start ] GC(19) Pause Remark
[4.554s][info][gc ] GC(19) Pause Remark 47M->47M(64M) 0.262ms
[4.554s][info][gc,cpu ] GC(19) User=0.00s Sys=0.00s Real=0.00s
[4.554s][info][gc,marking ] GC(19) Concurrent Mark 3.834ms
[4.554s][info][gc,marking ] GC(19) Concurrent Rebuild Remembered Sets
[4.554s][info][gc,marking ] GC(19) Concurrent Rebuild Remembered Sets 0.739ms
[4.555s][info][gc,start ] GC(19) Pause Cleanup
[4.555s][info][gc ] GC(19) Pause Cleanup 47M->47M(64M) 0.065ms
[4.555s][info][gc,cpu ] GC(19) User=0.00s Sys=0.00s Real=0.00s
[4.555s][info][gc,marking ] GC(19) Concurrent Cleanup for Next Mark
[4.555s][info][gc,marking ] GC(19) Concurrent Cleanup for Next Mark 0.164ms
[4.555s][info][gc ] GC(19) Concurrent Mark Cycle 5.174ms
[4.756s][info][gc,start ] GC(20) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[4.756s][info][gc,task ] GC(20) Using 2 workers of 8 for evacuation
[4.757s][info][gc,phases ] GC(20) Pre Evacuate Collection Set: 0.0ms
[4.757s][info][gc,phases ] GC(20) Merge Heap Roots: 0.0ms
[4.757s][info][gc,phases ] GC(20) Evacuate Collection Set: 0.4ms
[4.757s][info][gc,phases ] GC(20) Post Evacuate Collection Set: 0.1ms
[4.757s][info][gc,phases ] GC(20) Other: 0.3ms
[4.757s][info][gc,heap ] GC(20) Eden regions: 1->0(24)
[4.757s][info][gc,heap ] GC(20) Survivor regions: 0->1(4)
[4.757s][info][gc,heap ] GC(20) Old regions: 1->1
[4.757s][info][gc,heap ] GC(20) Archive regions: 2->2
[4.757s][info][gc,heap ] GC(20) Humongous regions: 46->46
[4.757s][info][gc,metaspace] GC(20) Metaspace: 326K(512K)->326K(512K) NonClass: 302K(384K)->302K(384K) Class: 24K(128K)->24K(128K)
[4.757s][info][gc ] GC(20) Pause Young (Concurrent Start) (G1 Humongous Allocation) 47M->47M(64M) 1.073ms
[4.757s][info][gc,cpu ] GC(20) User=0.00s Sys=0.00s Real=0.00s
[4.757s][info][gc ] GC(21) Concurrent Mark Cycle
[4.757s][info][gc,marking ] GC(21) Concurrent Clear Claimed Marks
[4.757s][info][gc,marking ] GC(21) Concurrent Clear Claimed Marks 0.017ms
[4.757s][info][gc,marking ] GC(21) Concurrent Scan Root Regions
[4.757s][info][gc,marking ] GC(21) Concurrent Scan Root Regions 0.069ms
[4.757s][info][gc,marking ] GC(21) Concurrent Mark
[4.757s][info][gc,marking ] GC(21) Concurrent Mark From Roots
[4.757s][info][gc,task ] GC(21) Using 2 workers of 2 for marking
[4.760s][info][gc,marking ] GC(21) Concurrent Mark From Roots 3.337ms
[4.760s][info][gc,marking ] GC(21) Concurrent Preclean
[4.760s][info][gc,marking ] GC(21) Concurrent Preclean 0.042ms
[4.760s][info][gc,start ] GC(21) Pause Remark
[4.761s][info][gc ] GC(21) Pause Remark 49M->49M(64M) 0.265ms
[4.761s][info][gc,cpu ] GC(21) User=0.00s Sys=0.00s Real=0.00s
[4.761s][info][gc,marking ] GC(21) Concurrent Mark 3.786ms
[4.761s][info][gc,marking ] GC(21) Concurrent Rebuild Remembered Sets
[4.761s][info][gc,marking ] GC(21) Concurrent Rebuild Remembered Sets 0.626ms
[4.762s][info][gc,start ] GC(21) Pause Cleanup
[4.762s][info][gc ] GC(21) Pause Cleanup 49M->49M(64M) 0.045ms
[4.762s][info][gc,cpu ] GC(21) User=0.00s Sys=0.00s Real=0.00s
[4.762s][info][gc,marking ] GC(21) Concurrent Cleanup for Next Mark
[4.762s][info][gc,marking ] GC(21) Concurrent Cleanup for Next Mark 0.135ms
[4.762s][info][gc ] GC(21) Concurrent Mark Cycle 4.900ms
[4.963s][info][gc,start ] GC(22) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[4.963s][info][gc,task ] GC(22) Using 2 workers of 8 for evacuation
[4.964s][info][gc,phases ] GC(22) Pre Evacuate Collection Set: 0.1ms
[4.964s][info][gc,phases ] GC(22) Merge Heap Roots: 0.0ms
[4.964s][info][gc,phases ] GC(22) Evacuate Collection Set: 0.4ms
[4.964s][info][gc,phases ] GC(22) Post Evacuate Collection Set: 0.2ms
[4.964s][info][gc,phases ] GC(22) Other: 0.4ms
[4.964s][info][gc,heap ] GC(22) Eden regions: 1->0(24)
[4.964s][info][gc,heap ] GC(22) Survivor regions: 1->1(4)
[4.964s][info][gc,heap ] GC(22) Old regions: 1->1
[4.964s][info][gc,heap ] GC(22) Archive regions: 2->2
[4.964s][info][gc,heap ] GC(22) Humongous regions: 48->48
[4.964s][info][gc,metaspace] GC(22) Metaspace: 326K(512K)->326K(512K) NonClass: 302K(384K)->302K(384K) Class: 24K(128K)->24K(128K)
[4.965s][info][gc ] GC(22) Pause Young (Concurrent Start) (G1 Humongous Allocation) 49M->49M(64M) 1.350ms
[4.965s][info][gc,cpu ] GC(22) User=0.00s Sys=0.00s Real=0.00s
[4.965s][info][gc ] GC(23) Concurrent Mark Cycle
[4.965s][info][gc,marking ] GC(23) Concurrent Clear Claimed Marks
[4.965s][info][gc,marking ] GC(23) Concurrent Clear Claimed Marks 0.014ms
[4.965s][info][gc,marking ] GC(23) Concurrent Scan Root Regions
[4.965s][info][gc,marking ] GC(23) Concurrent Scan Root Regions 0.050ms
[4.965s][info][gc,marking ] GC(23) Concurrent Mark
[4.965s][info][gc,marking ] GC(23) Concurrent Mark From Roots
[4.965s][info][gc,task ] GC(23) Using 2 workers of 2 for marking
[4.968s][info][gc,marking ] GC(23) Concurrent Mark From Roots 3.004ms
[4.968s][info][gc,marking ] GC(23) Concurrent Preclean
[4.968s][info][gc,marking ] GC(23) Concurrent Preclean 0.050ms
[4.968s][info][gc,start ] GC(23) Pause Remark
[4.968s][info][gc ] GC(23) Pause Remark 51M->51M(64M) 0.289ms
[4.968s][info][gc,cpu ] GC(23) User=0.00s Sys=0.00s Real=0.00s
[4.968s][info][gc,marking ] GC(23) Concurrent Mark 3.530ms
[4.968s][info][gc,marking ] GC(23) Concurrent Rebuild Remembered Sets
[4.969s][info][gc,marking ] GC(23) Concurrent Rebuild Remembered Sets 0.496ms
[4.969s][info][gc,start ] GC(23) Pause Cleanup
[4.969s][info][gc ] GC(23) Pause Cleanup 51M->51M(64M) 0.039ms
[4.969s][info][gc,cpu ] GC(23) User=0.00s Sys=0.00s Real=0.00s
[4.969s][info][gc,marking ] GC(23) Concurrent Cleanup for Next Mark
[4.969s][info][gc,marking ] GC(23) Concurrent Cleanup for Next Mark 0.106ms
[4.969s][info][gc ] GC(23) Concurrent Mark Cycle 4.409ms
[5.172s][info][gc,start ] GC(24) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[5.172s][info][gc,task ] GC(24) Using 2 workers of 8 for evacuation
[5.173s][info][gc,phases ] GC(24) Pre Evacuate Collection Set: 0.0ms
[5.173s][info][gc,phases ] GC(24) Merge Heap Roots: 0.0ms
[5.173s][info][gc,phases ] GC(24) Evacuate Collection Set: 0.4ms
[5.173s][info][gc,phases ] GC(24) Post Evacuate Collection Set: 0.1ms
[5.173s][info][gc,phases ] GC(24) Other: 0.3ms
[5.173s][info][gc,heap ] GC(24) Eden regions: 1->0(24)
[5.173s][info][gc,heap ] GC(24) Survivor regions: 1->1(4)
[5.173s][info][gc,heap ] GC(24) Old regions: 1->1
[5.173s][info][gc,heap ] GC(24) Archive regions: 2->2
[5.173s][info][gc,heap ] GC(24) Humongous regions: 50->50
[5.173s][info][gc,metaspace] GC(24) Metaspace: 327K(512K)->327K(512K) NonClass: 303K(384K)->303K(384K) Class: 24K(128K)->24K(128K)
[5.173s][info][gc ] GC(24) Pause Young (Concurrent Start) (G1 Humongous Allocation) 51M->51M(64M) 0.978ms
[5.173s][info][gc,cpu ] GC(24) User=0.01s Sys=0.00s Real=0.00s
[5.173s][info][gc ] GC(25) Concurrent Mark Cycle
[5.173s][info][gc,marking ] GC(25) Concurrent Clear Claimed Marks
[5.173s][info][gc,marking ] GC(25) Concurrent Clear Claimed Marks 0.012ms
[5.173s][info][gc,marking ] GC(25) Concurrent Scan Root Regions
[5.173s][info][gc,marking ] GC(25) Concurrent Scan Root Regions 0.185ms
[5.173s][info][gc,marking ] GC(25) Concurrent Mark
[5.173s][info][gc,marking ] GC(25) Concurrent Mark From Roots
[5.174s][info][gc,task ] GC(25) Using 2 workers of 2 for marking
[5.175s][info][gc,start ] GC(26) Pause Young (Normal) (G1 Preventive Collection)
[5.175s][info][gc,task ] GC(26) Using 2 workers of 8 for evacuation
[5.176s][info][gc,phases ] GC(26) Pre Evacuate Collection Set: 0.0ms
[5.176s][info][gc,phases ] GC(26) Merge Heap Roots: 0.0ms
[5.176s][info][gc,phases ] GC(26) Evacuate Collection Set: 0.1ms
[5.176s][info][gc,phases ] GC(26) Post Evacuate Collection Set: 0.1ms
[5.176s][info][gc,phases ] GC(26) Other: 0.1ms
[5.176s][info][gc,heap ] GC(26) Eden regions: 0->0(24)
[5.176s][info][gc,heap ] GC(26) Survivor regions: 1->1(1)
[5.176s][info][gc,heap ] GC(26) Old regions: 1->1
[5.176s][info][gc,heap ] GC(26) Archive regions: 2->2
[5.176s][info][gc,heap ] GC(26) Humongous regions: 59->59
[5.176s][info][gc,metaspace] GC(26) Metaspace: 328K(512K)->328K(512K) NonClass: 304K(384K)->304K(384K) Class: 24K(128K)->24K(128K)
[5.176s][info][gc ] GC(26) Pause Young (Normal) (G1 Preventive Collection) 60M->60M(64M) 0.445ms
[5.176s][info][gc,cpu ] GC(26) User=0.00s Sys=0.00s Real=0.00s
[5.177s][info][gc,task ] GC(27) Using 2 workers of 8 for full compaction
[5.177s][info][gc,start ] GC(27) Pause Full (System.gc())
[5.178s][info][gc,phases,start] GC(27) Phase 1: Mark live objects
[5.179s][info][gc,phases ] GC(27) Phase 1: Mark live objects 1.305ms
[5.179s][info][gc,phases,start] GC(27) Phase 2: Prepare for compaction
[5.179s][info][gc,phases ] GC(27) Phase 2: Prepare for compaction 0.229ms
[5.179s][info][gc,phases,start] GC(27) Phase 3: Adjust pointers
[5.180s][info][gc,phases ] GC(27) Phase 3: Adjust pointers 0.526ms
[5.180s][info][gc,phases,start] GC(27) Phase 4: Compact heap
[5.180s][info][gc,phases ] GC(27) Phase 4: Compact heap 0.264ms
[5.180s][info][gc,heap ] GC(27) Eden regions: 1->0(25)
[5.180s][info][gc,heap ] GC(27) Survivor regions: 1->0(1)
[5.180s][info][gc,heap ] GC(27) Old regions: 1->2
[5.180s][info][gc,heap ] GC(27) Archive regions: 2->2
[5.180s][info][gc,heap ] GC(27) Humongous regions: 59->0
[5.180s][info][gc,metaspace ] GC(27) Metaspace: 329K(512K)->329K(512K) NonClass: 304K(384K)->304K(384K) Class: 24K(128K)->24K(128K)
[5.180s][info][gc ] GC(27) Pause Full (System.gc()) 60M->1M(64M) 2.887ms
[5.180s][info][gc,cpu ] GC(27) User=0.01s Sys=0.00s Real=0.01s
[5.180s][info][gc,marking ] GC(25) Concurrent Mark From Roots 6.900ms
[5.180s][info][gc,marking ] GC(25) Concurrent Mark Abort
[5.180s][info][gc ] GC(25) Concurrent Mark Cycle 7.187ms
[7.188s][info][gc,heap,exit ] Heap
[7.188s][info][gc,heap,exit ] garbage-first heap total 65536K, used 2347K [0x00000007fc000000, 0x0000000800000000)
[7.188s][info][gc,heap,exit ] region size 1024K, 1 young (1024K), 0 survivors (0K)
[7.188s][info][gc,heap,exit ] Metaspace used 329K, committed 512K, reserved 1114112K
[7.188s][info][gc,heap,exit ] class space used 24K, committed 128K, reserved 1048576K

日志分析

程序阶段与时间戳 (Phase & Timestamp) 核心代码行为 (Core Code Action) GC 日志关键证据 (Key GC Log Evidence) 深入解读与分析 (In-depth Interpretation and Analysis)
阶段 1:启动至首次 GC
(0.009s ~ 4.364s)
程序启动,并开始循环分配 400KB 的“小对象”。 (此阶段无 GC 日志) 解读:由于对象大小(400KB)小于大对象阈值(500KB),它们被作为普通对象分配在 Eden 区重要的是,在您的代码循环开始前,JVM自身初始化(加载类、JIT编译、执行println等)已经占用了部分Eden空间。 因此,并不需要您的代码分配全部24MB来触发GC,JVM自身的内存消耗是填充Eden区的主要部分。
阶段 1:首次 GC
GC(0) @ 4.364s
JVM初始化循环中最初几个400KB对象的分配共同填满了Eden区。 [4.364s][info][gc,start ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)

[4.366s][info][gc,heap ] GC(0) Eden regions: 24->0(24)
[4.366s][info][gc,heap ] GC(0) Survivor regions: 0->1(4)
[4.366s][info][gc,heap ] GC(0) Old regions: 15->16
解读
1. 触发原因: 时间戳证明GC发生在阶段1循环的早期。是JVM自身内存占用 + 代码中最初1-2个400KB对象这“最后一根稻草”共同填满了Eden区,触发了这次Young GC。
2. Eden 区: 24->0(24),24MB的Eden区被完全清空。
3. Survivor 区: 0->1(4)追踪对象:这些存活并被复制到Survivor区的对象,主要是JVM自身的一些对象,以及您代码中最开始被创建并放入objectHolder的那一两个400KB的byte[]数组。它们成功逃离了本次Eden回收,年龄变为1。
4. 老年代: 晋升的1MB对象不是我们代码循环中的对象,因为它们的年龄才刚变为1,还未达到晋升阈值2。
阶段 2:快速分配
GC(1) @ 4.568s

GC(7) @ 5.176s
循环快速分配 400KB 对象,持续填满 Eden 区,反复触发 Young GC。 [4.568s][info][gc,start ] GC(1) Pause Young (Normal) (G1 Evacuation Pause)

[4.s][info][gc,heap ] GC(1) Survivor regions: 1->1(4)
[4.569s][info][gc,heap ] GC(1) Old regions: 31->36
解读
1. GC 模式: 这是标准的、由 Eden 耗尽驱动的 Young GC 循环。
2. Survivor 区流转: Survivor regions: 1->1(4) 体现了 Survivor 区的复制机制。GC(0) 的幸存者和本次 Eden 的幸存者(都是被objectHolder持有的 byte[] 数组)一起被复制到另一个 Survivor 区。
3. 对象晋升: Old regions: 31->36对象晋升到老年代的直接证据。追踪对象:这增加的 5MB 老年代空间,主要就是用来存放那些在 GC(0) 中存活(年龄变为1),并在本次 GC(1) 中再次存活(年龄达到2)的 400KB byte[] 对象。它们现在完成了从 Eden -> Survivor -> Old 的完整晋升路径
阶段 3:分配大对象
GC(8) @ 5.176s
代码执行 new byte[8 * 1024 * 1024] [5.176s][info][gc,start ] GC(8) Pause Young (Concurrent Start) (G1 Humongous Allocation)

[5.179s][info][gc,heap ] GC(8) Humongous regions: 0->8
解读
1. 行为突变: 追踪对象:与之前所有 400KB 的对象不同,这个 8MB 的 byte[] 数组由于体积巨大(>0.5MB),被 G1 识别为大对象,触发了 (G1 Humongous Allocation)
2. 大对象区证据: Humongous regions: 0->8 证明这个 8MB 对象没有进入 Eden,而是被直接分配到了老年代的 8 个连续 Humongous Region 中。
阶段 4:手动 Full GC
GC(9) @ 5.385s
执行 objectHolder.clear()System.gc() [5.385s][info][gc,start ] GC(9) Pause Full (System.gc())

[5.388s][info][gc,heap ] GC(9) Humongous regions: 8->0
[5.388s][info][gc,heap ] GC(9) Old regions: 51->29
解读
1. 全局回收: (System.gc()) 触发了一次对整个堆的回收。
2. 大对象回收: Humongous regions: 8->0追踪对象:在阶段3创建的 8MB largeObject 因为引用被设为 null,在此次 Full GC 中被完全回收
3. 老年代回收: Old regions: 51->29追踪对象:这被回收的 22MB 对象,正是之前从新生代一路晋升上来、并被 objectHolder 持有的那些 400KB 的 byte[] 数组。在调用 objectHolder.clear() 后,它们失去了强引用,因此在 Full GC 中被识别为垃圾并回收。

提到Java I/O,相信你一定不陌生。你可能使用I/O操作读写文件,也可能使用它实现Socket的信息传输…这些都是我们在系统中最常遇到的和I/O有关的操作。

我们都知道,I/O的速度要比内存速度慢,尤其是在现在这个大数据时代背景下,I/O的性能问题更是尤为突出,I/O读写已经成为很多应用场景下的系统性能瓶颈,不容我们忽视

什么是I/O?

I/O是机器获取和交换信息的主要渠道,而流是完成I/O操作的主要方式。在计算机中,流是一种信息的转换。流是有序的,因此相对于某一机器或者应用程序而言,我们通常把机器或者应用程序接收外界的信息称为输入流(InputStream),从机器或者应用程序向外输出的信息称为输出流(OutputStream),合称为输入/输出流(I/O Streams)。

机器间或程序间在进行信息交换或者数据交换时,总是先将对象或数据转换为某种形式的流,再通过流的传输,到达指定机器或程序后,再将流转换为对象数据。因此,流就可以被看作是一种数据的载体,通过它可以实现数据交换和传输。

Java的I/O操作类在包java.io下,其中InputStream、OutputStream以及Reader、Writer类是I/O包中的4个基本类

传统I/O的性能问题

I/O操作分为磁盘I/O操作和网络I/O操作。前者是从磁盘中读取数据源输入到内存中,之后将读取的信息持久化输出在物理磁盘上;后者是从网络中读取信息输入到内存,最终将信息输出到网络中。但不管是磁盘I/O还是网络I/O,在传统I/O中都存在严重的性能问题

1.多次内存复制

在传统I/O中,我们可以通过InputStream从源数据中读取数据流输入到缓冲区里,通过OutputStream将数据输出到外部设备(包括磁盘、网络)。具体流程如下:

  • JVM会发出read()系统调用,并通过read系统调用向内核发起读请求;
  • 内核向硬件发送读指令,并等待读就绪;
  • 内核把将要读取的数据复制到指向的内核缓存中;
  • 操作系统内核将数据复制到用户空间缓冲区,然后read系统调用返回。

在这个过程中,数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这就发生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换,从而降低I/O的性能

2.阻塞

在传统I/O中,InputStream的read()是一个while循环操作,它会一直等待数据读取,直到数据就绪才会返回。这就意味着如果没有数据就绪,这个读取操作将会一直被挂起,用户线程将会处于阻塞状态。

在少量连接请求的情况下,使用这种方式没有问题,响应速度也很高。但在发生大量连接请求时,就需要创建大量监听线程,这时如果线程没有数据就绪就会被挂起,然后进入阻塞状态。一旦发生线程阻塞,这些线程将会不断地抢夺CPU资源,从而导致大量的CPU上下文切换,增加系统的性能开销

如何优化I/O操作

面对以上两个性能问题,不仅编程语言对此做了优化,各个操作系统也进一步优化了I/O。JDK1.4发布了java.nio包(new I/O的缩写),NIO的发布优化了内存复制以及阻塞导致的严重性能问题。JDK1.7又发布了NIO2,提出了从操作系统层面实现的异步I/O。下面我们就来了解下具体的优化实现

1.使用缓冲区优化读写流操作

在传统I/O中,提供了基于流的I/O实现,即InputStream和OutputStream,这种基于流的实现以字节为单位处理数据。NIO与传统 I/O 不同,它是基于块(Block)的,它以块为基本单位处理数据。在NIO中,最为重要的两个组件是缓冲区(Buffer)和通道(Channel)。Buffer是一块连续的内存块,是 NIO 读写数据的中转地。Channel表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口。传统I/O和NIO的最大区别就是传统I/O是面向流,NIO是面向Buffer。Buffer可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据。虽然传统I/O后面也使用了缓冲块,例如BufferedInputStream,但仍然不能和NIO相媲美。使用NIO替代传统I/O操作,可以提升系统的整体性能,效果立竿见影

2. 使用DirectBuffer减少内存复制

NIO的Buffer除了做了缓冲块优化之外,还提供了一个可以直接访问物理内存的类DirectBuffer。普通的Buffer分配的是JVM堆内存,而DirectBuffer是直接分配物理内存(非堆内存)。我们知道数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而在Java中,在用户空间中又存在一个拷贝,那就是从Java堆内存中拷贝到临时的直接内存中,通过临时的直接内存拷贝到内存空间中去。此时的直接内存和堆内存都是属于用户空间。

你肯定会在想,为什么Java需要通过一个临时的非堆内存来复制数据呢?如果单纯使用Java堆内存进行数据拷贝,当拷贝的数据量比较大的情况下,Java堆的GC压力会比较大,而使用非堆内存可以减低GC的压力。DirectBuffer则是直接将步骤简化为数据直接保存到非堆内存,从而减少了一次数据拷贝。以下是JDK源码中IOUtil.java类中的write方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);

// Substitute a native buffer
int pos = src.position();
int lim = src.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
bb.put(src);
bb.flip();
// ...............

这里拓展一点,由于DirectBuffer申请的是非JVM的物理内存,所以创建和销毁的代价很高。DirectBuffer申请的内存并不是直接由JVM负责垃圾回收,但在DirectBuffer包装类被回收时,会通过Java Reference(强引用,软引用,弱引用,虚引用)机制来释放该内存块。DirectBuffer只优化了用户空间内部的拷贝,而之前我们是说优化用户空间和内核空间的拷贝,那Java的NIO中是否能做到减少用户空间和内核空间的拷贝优化呢?答案是可以的,DirectBuffer是通过unsafe.allocateMemory(size)方法分配内存,也就是基于本地类Unsafe类调用native方法进行内存分配的。而在NIO中,还存在另外一个Buffer类:MappedByteBuffer,跟DirectBuffer不同的是,MappedByteBuffer是通过本地类调用mmap进行文件内存映射的,map()系统调用方法会直接将文件从硬盘拷贝到用户空间,只进行一次数据拷贝,从而减少了传统的read()方法从硬盘拷贝到内核空间这一步。

测试案例

传统io

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package org.pt.io;

import java.io.*;

/**
* @ClassName TraditionalIOFileCopier
* @Author pt
* @Description
* @Date 2025/6/13 20:12
**/
public class TraditionalIOFileCopier {
/**
* 使用传统的带缓冲的流来复制文件。
*
* @param source 源文件路径
* @param dest 目标文件路径
* @throws IOException IO异常
*/
protected void copyFile(File source, File dest) throws IOException {
// 使用try-with-resources确保流能被自动关闭
try (InputStream in = new BufferedInputStream(new FileInputStream(source));
OutputStream out = new BufferedOutputStream(new FileOutputStream(dest))) {
byte[] buffer = new byte[8192]; // 8KB的缓冲区
int bytesRead;
// 循环从输入流读取数据到缓冲区,再从缓冲区写入到输出流
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
}
}
}

基于堆缓冲区的nio

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package org.pt.io;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
* @ClassName NIOHeapBufferFileCopier
* @Author pt
* @Description
* @Date 2025/6/13 20:18
**/
public class NIOHeapBufferFileCopier {
/**
* 使用NIO的Channel和基于JVM堆的HeapBuffer来复制文件。
*
* @param source 源文件路径
* @param dest 目标文件路径
* @throws IOException IO异常
*/
public void copyFile(File source, File dest) throws IOException {
try (FileChannel sourceChannel = new FileInputStream(source).getChannel();
FileChannel destChannel = new FileOutputStream(dest).getChannel()) {
// 创建一个基于JVM堆内存的缓冲区(HeapBuffer)这是与DirectBuffer的关键区别
ByteBuffer buffer = ByteBuffer.allocate(8192); // 8KB的堆缓冲区
while (sourceChannel.read(buffer) != -1) {
// 切换缓冲区为读模式
buffer.flip();
// 确保缓冲区的数据全部写入目标通道
while (buffer.hasRemaining()) {
destChannel.write(buffer);
}
// 清空缓冲区,为下一次读取做准备
buffer.clear();
}
}
}
}

基于本地内存缓冲区的nio

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package org.pt.io;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
* @ClassName NIOOutHeapBufferFileCopier
* @Author pt
* @Description
* @Date 2025/6/13 20:23
**/
public class NIOOutHeapBufferFileCopier {
/**
* 使用NIO的Channel和基于JVM堆外的HeapBuffer来复制文件。
*
* @param source 源文件路径
* @param dest 目标文件路径
* @throws IOException IO异常
*/
public void copyFile(File source, File dest) throws IOException {
try (FileChannel sourceChannel = new FileInputStream(source).getChannel();
FileChannel destChannel = new FileOutputStream(dest).getChannel()) {
// 创建一个基于本地内存的缓冲区
ByteBuffer buffer = ByteBuffer.allocateDirect(8192);; // 8KB的堆缓冲区
while (sourceChannel.read(buffer) != -1) {
// 切换缓冲区为读模式
buffer.flip();
// 确保缓冲区的数据全部写入目标通道
while (buffer.hasRemaining()) {
destChannel.write(buffer);
}
// 清空缓冲区,为下一次读取做准备
buffer.clear();
}
}
}
}

测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
package org.pt.io;

/**
* @ClassName IOvsNIOTest
* @Author pt
* @Description
* @Date 2025/6/13 20:20
**/

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Random;

public class IOvsNIOTest {

private static final String FILE_NAME_PREFIX = "test_file_";
private static final int FILE_SIZE_MB = 500; // 定义测试文件大小(MB)

public static void main(String[] args) {
// 准备测试文件
File sourceFile = null;
File traditionalDestFile = new File(FILE_NAME_PREFIX + "traditional_copy.tmp");
File nioDestFile = new File(FILE_NAME_PREFIX + "nio_heap_copy.tmp");
File nioDirectDestFile = new File("copy_nio_direct.tmp");
try {
System.out.println("正在创建大小为 " + FILE_SIZE_MB + "MB 的测试文件,请稍候...");
sourceFile = createLargeTestFile(FILE_NAME_PREFIX + "source.tmp", FILE_SIZE_MB);
System.out.println("测试文件创建完成: " + sourceFile.getAbsolutePath());
System.out.println("-------------------------------------------------");
// 测试传统 I/O
TraditionalIOFileCopier traditionalCopier = new TraditionalIOFileCopier();
long startTimeTraditional = System.nanoTime();
traditionalCopier.copyFile(sourceFile, traditionalDestFile);
long endTimeTraditional = System.nanoTime();
long traditionalDuration = (endTimeTraditional - startTimeTraditional) / 1_000_000;
System.out.printf("传统 I/O (Stream) 复制耗时: %d ms%n", traditionalDuration);
// 测试 NIO (Heap Buffer)
NIOHeapBufferFileCopier nioHeapCopier = new NIOHeapBufferFileCopier();
long startTimeNIOHeap = System.nanoTime();
nioHeapCopier.copyFile(sourceFile, nioDestFile);
long endTimeNIOHeap = System.nanoTime();
long nioHeapDuration = (endTimeNIOHeap - startTimeNIOHeap) / 1_000_000;
System.out.printf("NIO (Heap Buffer) 复制耗时: %d ms%n", nioHeapDuration);

// 测试 NIO (Direct Buffer / 本地内存)
NIOOutHeapBufferFileCopier nioDirectCopier = new NIOOutHeapBufferFileCopier();
long startTimeNIODirect = System.nanoTime();
nioDirectCopier.copyFile(sourceFile, nioDirectDestFile);
long endTimeNIODirect = System.nanoTime();
long nioDirectDuration = (endTimeNIODirect - startTimeNIODirect) / 1_000_000;
System.out.printf("NIO (Direct Buffer) 复制耗时: %d ms%n", nioDirectDuration);
// 性能对比
System.out.println("-------------------------------------------------");
// 对比传统IO vs NIO(Heap)
if (nioHeapDuration > 0 && traditionalDuration > nioHeapDuration) {
double improvement = ((double) (traditionalDuration - nioHeapDuration) / traditionalDuration) * 100;
System.out.printf("-> NIO (Heap) 比 传统I/O 快了约: %.2f%%%n", improvement);
}

// 对比NIO(Heap) vs NIO(Direct)
if (nioDirectDuration > 0 && nioHeapDuration > nioDirectDuration) {
double improvement = ((double) (nioHeapDuration - nioDirectDuration) / nioHeapDuration) * 100;
System.out.printf("-> NIO (Direct) 比 NIO (Heap) 快了约: %.2f%%%n", improvement);
}

// 对比传统IO vs NIO(Direct)
if (nioDirectDuration > 0 && traditionalDuration > nioDirectDuration) {
double improvement = ((double) (traditionalDuration - nioDirectDuration) / traditionalDuration) * 100;
System.out.printf("-> NIO (Direct) 比 传统I/O 快了约: %.2f%%%n", improvement);
}

} catch (IOException e) {
System.err.println("测试过程中发生错误: " + e.getMessage());
e.printStackTrace();
} finally {
// 5. 清理测试文件
System.out.println("-------------------------------------------------");
System.out.println("正在清理测试文件...");
cleanup(sourceFile, traditionalDestFile, nioDestFile);
System.out.println("清理完成。");
}
}

// 创建文件
private static File createLargeTestFile(String fileName, int sizeInMB) throws IOException {
File file = new File(fileName);
file.deleteOnExit();
byte[] junk = new byte[1024 * 1024];
new Random().nextBytes(junk);

try (FileOutputStream out = new FileOutputStream(file)) {
for (int i = 0; i < sizeInMB; i++) {
out.write(junk);
}
}
return file;
}

private static void cleanup(File... files) {
for (File file : files) {
if (file != null && file.exists()) {
if (!file.delete()) {
System.err.println("警告: 未能删除文件 " + file.getAbsolutePath());
}
}
}
}
}

结果对比

什么是副本机制?

所谓的副本机制(Replication),也可以称之为备份机制,通常是指分布式系统在多台网络互联的机器上保存有相同的数据拷贝

  1. 提供数据冗余。即使系统部分组件失效,系统依然能够继续运转,因而增加了整体可用性以及数据持久性。
  2. 提供高伸缩性。支持横向扩展,能够通过增加机器的方式来提升读性能,进而提高读操作吞吐量。
  3. 改善数据局部性。允许将数据放入与用户地理位置相近的地方,从而降低系统延时。

这些优点都是在分布式系统教科书中最常被提及的,但是有些遗憾的是,对于Apache Kafka而言,目前只能享受到副本机制带来的第1个好处,也就是提供数据冗余实现高可用性和高持久性

Kafka是有主题概念的,而每个主题又进一步划分成若干个分区。副本的概念实际上是在分区层级下定义的,每个分区配置有若干个副本。

所谓副本(Replica),本质就是一个只能追加写消息的提交日志。根据Kafka副本机制的定义,同一个分区下的所有副本保存有相同的消息序列,这些副本分散保存在不同的Broker上,从而能够对抗部分Broker宕机带来的数据不可用。

在实际生产环境中,每台Broker都可能保存有各个主题下不同分区的不同副本,因此,单个Broker上存有成百上千个副本的现象是非常正常的,接下来我们来看一张图,它展示的是一个有3台Broker的Kafka集群上的副本分布情况。从这张图中,我们可以看到,主题1分区0的3个副本分散在3台Broker上,其他主题分区的副本也都散落在不同的Broker上,从而实现数据冗余

Leader: 负责处理该分区所有读写请求的 Broker 。

Replicas: 该分区所有副本所在的 Broker ID 列表。

Isr: “in-sync” 副本列表。只有这个列表里的副本被认为是与 Leader 完全同步的,可以随时被选举为新的 Leader

从图片可以看出,test-replication主题有三个分区,分别是0,1,2,0分区的leader为1,1分区的leader为2,2分区的leader为3

副本内容如何保持一致?

既然分区下能够配置多个副本,而且这些副本的内容还要一致,那么很自然的一个问题就是:我们该如何确保副本中所有的数据都是一致的呢?特别是对Kafka而言,当生产者发送消息到某个主题后,消息是如何同步到对应的所有副本中的呢?针对这个问题,最常见的解决方案就是采用基于领导者(Leader-based)的副本机制。Apache Kafka就是这样的设计

第一,在Kafka中,副本分成两类:领导者副本(Leader Replica)和追随者副本(Follower Replica)。每个分区在创建时都要选举一个副本,称为领导者副本,其余的副本自动称为追随者副本。上面图片中的Leader是领导者副本,Repilcas是追随者副本,有多个

第二,Kafka的副本机制比其他分布式系统要更严格一些。在Kafka中,追随者副本是不对外提供服务的。这就是说,任何一个追随者副本都不能响应消费者和生产者的读写请求。所有的请求都必须由领导者副本来处理,或者说,所有的读写请求都必须发往领导者副本所在的Broker,由该Broker负责处理。追随者副本不处理客户端请求,它唯一的任务就是从领导者副本异步拉取消息,并写入到自己的提交日志中,从而实现与领导者副本的同步。

第三,当领导者副本挂掉了,或者说领导者副本所在的Broker宕机时,Kafka依托于ZooKeeper提供的监控功能能够实时感知到,并立即开启新一轮的领导者选举,从追随者副本中选一个作为新的领导者。老Leader副本重启回来后,只能作为追随者副本加入到集群中

模拟broker宕机,Leader重新选举

分区 0 和 1 的 Leader 分别是 Broker 1 和 2。我们把 Broker 2 停掉。 Zookeeper 会检测到 kafka2 已下线并触发 Leader 重新选举

可以看到分区1的leader变为broker 1了

怎么才算同步?

追随者副本不提供服务,只是定期地异步拉取领导者副本中的数据而已。既然是异步的,就存在着不可能与Leader实时同步的风险。在探讨如何正确应对这种风险之前,我们必须要精确地知道同步的含义是什么。或者说,Kafka要明确地告诉我们,追随者副本到底在什么条件下才算与Leader同步。

基于这个想法,Kafka引入了In-sync Replicas,也就是所谓的ISR副本集合。ISR中的副本都是与Leader同步的副本,相反,不在ISR中的追随者副本就被认为是与Leader不同步的。那么,到底什么副本能够进入到ISR中呢?

我们首先要明确的是,Leader副本天然就在ISR中(图中可以看到)。也就是说,ISR不只是追随者副本集合,它必然包括Leader副本。甚至在某些情况下,ISR只有Leader这一个副本,如果Leader副本当前写入了10条消息,Follower1副本同步了其中的6条消息,而Follower2副本只同步了其中的3条消息。现在,请你思考一下,对于这2个追随者副本,你觉得哪个追随者副本与Leader不同步?

答案是,要根据具体情况来定。换成英文,就是那句著名的“It depends”。看上去好像Follower2的消息数比Leader少了很多,它是最有可能与Leader不同步的。的确是这样的,但仅仅是可能。

事实上,这2个Follower副本都有可能与Leader不同步,但也都有可能与Leader同步。也就是说,Kafka判断Follower是否与Leader同步的标准,不是看相差的消息数,而是另有“玄机”,这个标准就是Broker端参数replica.lag.time.max.ms参数值。这个参数的含义是Follower副本能够落后Leader副本的最长时间间隔,当前默认值是10秒。这就是说,只要一个Follower副本落后Leader副本的时间不连续超过10秒,那么Kafka就认为该Follower副本与Leader是同步的,即使此时Follower副本中保存的消息明显少于Leader副本中的消息。

我们在前面说过,Follower副本唯一的工作就是不断地从Leader副本拉取消息,然后写入到自己的提交日志中。如果这个同步过程的速度持续慢于Leader副本的消息写入速度,那么在replica.lag.time.max.ms时间后,此Follower副本就会被认为是与Leader副本不同步的,因此不能再放入ISR中。此时,Kafka会自动收缩ISR集合,将该副本“踢出”ISR。

值得注意的是,倘若该副本后面慢慢地追上了Leader的进度,那么它是能够重新被加回ISR的。这也表明,ISR是一个动态调整的集合,而非静态不变的

Unclean领导者选举(Unclean Leader Election)

既然ISR是可以动态调整的,那么自然就可以出现这样的情形:ISR为空。因为Leader副本天然就在ISR中,如果ISR为空了,就说明Leader副本也“挂掉”了,Kafka需要重新选举一个新的Leader。可是ISR是空,此时该怎么选举新Leader呢?

Kafka把所有不在ISR中的存活副本都称为非同步副本。通常来说,非同步副本落后Leader太多,因此,如果选择这些副本作为新Leader,就可能出现数据的丢失。毕竟,这些副本中保存的消息远远落后于老Leader中的消息。在Kafka中,选举这种副本的过程称为Unclean领导者选举。Broker端参数unclean.leader.election.enable控制是否允许Unclean领导者选举

开启Unclean领导者选举可能会造成数据丢失,但好处是,它使得分区Leader副本一直存在,不至于停止对外提供服务,因此提升了高可用性。反之,禁止Unclean领导者选举的好处在于维护了数据的一致性,避免了消息丢失,但牺牲了高可用性。

如果你听说过CAP理论的话,你一定知道,一个分布式系统通常只能同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)中的两个。显然,在这个问题上,Kafka赋予你选择C或A的权利。

你可以根据你的实际业务场景决定是否开启Unclean领导者选举。不过,我强烈建议你不要开启它,毕竟我们还可以通过其他的方式来提升高可用性。如果为了这点儿高可用性的改善,牺牲了数据一致性,那就非常不值当了

Kafka消息大量堆积问题

第一步:定位问题根源

消息堆积的直接原因是消费速度跟不上生产速度。问题可能出在消费者(Consumer)、消息队列本身(Broker)或生产者(Producer)

首先,需要查看消费者的消费延迟(Consumer Lag)。这是最核心的监控指标,指的是消费者当前消费的消息位移(Offset)与分区最新消息位移的差值。

  • 使用命令行工具

    Bash

    1
    bin/kafka-consumer-groups.sh --bootstrap-server <broker_list> --describe --group <group_name>

    观察输出中的 LAG 列。如果 LAG 值持续增大,说明消费速度跟不上。

  • 使用监控系统:通过Prometheus、Grafana等监控系统,可以实现对Consumer Lag的图形化监控,更直观地发现问题。

根据消费延迟,可以初步判断问题:

  • 特定Topic或特定分区延迟高:很可能是该Topic的消费者或该分区所在的Broker有问题。
  • 所有消费者组都延迟:可能是整个Kafka集群或下游通用服务(如数据库、缓存)出现了问题。

第二步:分析具体原因

定位到瓶颈所在后,需要进一步分析导致堆积的具体原因。


1. 消费者(Consumer)问题

消费者处理能力不足是消息堆积最常见的原因。

  • 消费逻辑过重:消费者在处理每条消息时,执行了耗时很长的操作,如复杂的计算、调用外部接口、大量的数据库操作等。

    • 解决方案
      • 优化消费逻辑:代码层面优化,减少不必要的计算,批量处理数据库操作,异步调用外部接口。
      • 增加并发度
        • 增加消费者实例:如果当前消费者实例数小于分区数,可以增加消费者实例,Kafka会自动进行重平衡(Rebalance),让新的消费者分担分区。
        • 增加分区数:如果消费者实例数已经等于分区数,可以考虑增加Topic的分区数,并相应增加消费者实例。注意,分区数只能增加不能减少。
        • 多线程处理:在单个消费者内部使用线程池来并发处理消息,提高处理能力。
  • 消费者频繁发生重平衡(Rebalance):消费者组成员的频繁加入和退出会导致Rebalance,期间整个消费者组会停止消费。

    • 原因session.timeout.msheartbeat.interval.ms 配置不当、消费者实例频繁重启或Full GC。
    • 解决方案:适当调大 session.timeout.ms,确保 heartbeat.interval.ms 远小于 session.timeout.ms。同时排查消费者应用自身的不稳定因素。
  • 消费者消费能力不均:由于分区分配策略(如RangeAssignor)或消息key的哈希不均,导致某些消费者分配了更多的分区或处理了更多的热点数据。

    • 解决方案:尝试更换为更均衡的分区分配策略,如 RoundRobinAssignorStickyAssignor

2. Kafka Broker问题

Broker是消息中转站,其自身的性能瓶颈也会导致消息堆积。

  • Broker负载过高:CPU、内存、磁盘I/O、网络带宽等资源达到瓶颈。

    • 解决方案
      • 扩容:增加Broker节点,将分区迁移到新的节点上,分摊负载。
      • 优化配置:调整Broker的线程数(如num.network.threads, num.io.threads)、缓冲区大小等参数。
      • 磁盘性能:优先使用SSD,避免机械硬盘的随机写性能瓶颈。
  • ISR(In-Sync Replicas)频繁变动:副本同步效率低,导致Leader和Follower之间数据同步延迟,影响了消息的提交和拉取。

    • 解决方案:检查网络状况,优化副本同步相关的参数。

3. 生产者(Producer)问题

虽然生产者问题直接导致堆积的情况较少,但也会间接影响。

  • 瞬间流量过大
    生产者在短时间内发送了远超消费者处理能力的消息量。
    • 解决方案:与业务方沟通,在生产者端进行限流或错峰发送。如果业务上允许,可以适当降低生产速率。

第三步:处理已堆积的消息

在解决了消费能力瓶颈后,需要对已经堆积的大量消息进行处理。

  • 临时扩容,加速消费:临时增加消费者实例数量,甚至可以临时部署一个专门用于“清理”堆积消息的消费者应用,待堆积处理完毕后再缩容。这是最常用且有效的方法。

  • 消息转存:编写一个临时程序,将堆积的消息从Kafka中消费出来,转存到其他存储(如HDFS、S3),后续再离线分析处理

  • 丢弃不重要的消息:如果业务允许,可以临时将消费位移(Offset)重置到最新的位置,跳过堆积的消息。这种方法有数据丢失风险,必须谨慎评估

    1
    bin/kafka-consumer-groups.sh --bootstrap-server <broker_list> --group <group_name> --reset-offsets --to-latest --topic <topic_name> --execute

我们经常提到SQL执行计划去优化SQL语句,但是这些需要优化的SQL语句到底是怎么定位的呢?总不可能每一条都去测试吧,这篇文章主题就是关于如何定位慢SQL

The Slow Query Log(慢查询日志)

什么是慢查询日志?

MySQL 会记录执行时间过长的 SQL 语句,这项功能被称为慢查询日志 (Slow Query Log)。慢查询日志是一个非常有用的工具,可以帮助数据库管理员和开发人员识别出效率低下的查询,从而进行针对性的优化,提升数据库性能

特点

可配置性: 你可以自定义”慢”的阈值。通过设置 long_query_time 参数(单位为秒),可以定义执行时间超过多少的查询才被记录。例如,设置为 1,则执行时间超过 1 秒的查询都会被记录下来。

开关控制: 你可以随时开启或关闭慢查询日志功能。通过设置 slow_query_logONOFF 来控制。

日志内容: 日志中会详细记录慢查询的相关信息,帮助你分析问题:

  • 执行时间 (Query_time): 查询总共花费的时间。
  • 锁定时间 (Lock_time): 等待表锁定的时间。
  • 发送行数 (Rows_sent): 返回给客户端的行数。
  • 检查行数 (Rows_examined): 为了找到结果集而扫描的行数。
  • 执行用户和主机: 是哪个用户从哪个 IP 地址执行的查询。
  • 执行时间点: 查询发生的具体时间。
  • SQL 语句本身: 完整的 SQL 查询语句

如何启用和配置?

方式一:修改配置文件

在你的 MySQL 配置文件中的 [mysqld] 部分,添加或修改以下参数:

1
2
3
4
5
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
# 可选:记录没有使用索引的查询
# log_queries_not_using_indexes = 1

slow_query_log = 1: 1 代表开启,0 代表关闭。

slow_query_log_file: 指定日志文件的存放路径。请确保 MySQL 用户有权限写入该文件。

long_query_time = 1: 设置超过 1 秒的查询为慢查询

修改配置文件后,需要重启 MySQL 服务才能生效。

方式二:动态设置 (临时)

你也可以在不重启服务的情况下,通过 MySQL 客户端动态设置,但这只在当前服务运行期间有效,重启后会失效。

1
2
3
4
5
6
7
8
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';

-- 设置慢查询的时间阈值为 2 秒
SET GLOBAL long_query_time = 2;

-- 查看慢查询日志文件的位置
SHOW VARIABLES LIKE 'slow_query_log_file';

案例测试

场景: 一个简单的博客平台。平台上有 authors (作者) 表和 posts (文章) 表。我们需要在首页展示一个 “热门作者列表”,条件是:显示发布了超过 20 篇文章的作者及其总文章数。随着平台数据量的增长,用户抱怨首页加载越来越慢

第 1 步:创建表和模拟数据

首先,我们创建所需的表,并插入大量模拟数据来重现问题。一个数据量很小的数据库通常不会暴露慢查询问题

  1. 创建数据库和表结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE DATABASE blog_platform;
USE blog_platform;

-- 作者表
CREATE TABLE authors (
author_id INT AUTO_INCREMENT PRIMARY KEY,
author_name VARCHAR(255) NOT NULL,
registration_date DATE
);

-- 文章表
CREATE TABLE posts (
post_id INT AUTO_INCREMENT PRIMARY KEY,
author_id INT NOT NULL,
post_title VARCHAR(255),
post_content TEXT,
creation_date DATETIME DEFAULT CURRENT_TIMESTAMP
-- 注意:这里的 author_id 没有索引,这是我们故意制造问题的关键
);
  1. 插入模拟数据

为了让查询变慢,我们需要插入大量数据。这里我们用存储过程来快速生成数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
-- 创建插入作者的存储过程
DELIMITER $$
CREATE PROCEDURE insert_authors()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 1000 DO
INSERT INTO authors (author_name, registration_date) VALUES (CONCAT('作者_', i), CURDATE() - INTERVAL i DAY);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;

-- 创建插入文章的存储过程
DELIMITER $$
CREATE PROCEDURE insert_posts()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 200000 DO -- 插入 20 万篇文章
INSERT INTO posts (author_id, post_title) VALUES (
FLOOR(1 + RAND() * 1000), -- 随机关联一个作者
CONCAT('文章标题_', i)
);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;

-- 执行存储过程来填充数据
CALL insert_authors();
CALL insert_posts();

现在,我们有了一个包含 1000 个作者和 20 万篇文章的数据库。posts 表中的 author_id 列上没有索引

第 2 步:开启慢查询日志并执行慢 SQL

在执行查询之前,我们先开启慢查询日志,并将阈值设置得比较低(比如 0.5 秒),以便能捕获到我们的目标查询。

在 MySQL 客户端中执行:

1
2
3
4
5
6
7
8
9
-- 开启慢查询日志 我这里是零时设置
SET GLOBAL slow_query_log = 'ON';
-- 设置慢查询阈值为 0.5 秒
SET GLOBAL long_query_time = 0.5;
-- 确认设置是否生效
SHOW GLOBAL VARIABLES LIKE 'slow_query_log';
SHOW GLOBAL VARIABLES LIKE 'long_query_time';
-- 查看日志文件路径,以便后续查看
SHOW GLOBAL VARIABLES LIKE 'slow_query_log_file';

第 3步:执行有问题的 SQL 查询

这就是我们为 “热门作者列表” 编写的查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT
a.author_name,
COUNT(p.post_id) AS post_count
FROM
authors a
JOIN
posts p ON a.author_id = p.author_id
GROUP BY
a.author_id
HAVING
post_count > 20
ORDER BY
post_count DESC;

在有 20 万条文章记录且 posts.author_id 没有索引的情况下,这个查询会非常慢。它需要对 posts 表进行全表扫描 (Full Table Scan) 来为每个作者计算文章数量

第 4步:分析慢查询日志

执行完上面的慢 SQL 后,等待几秒钟,然后去查看你的慢查询日志文件。在 Linux 系统中,你通常可以使用 cattail 命令查看

第 5步:后续sql优化,不做赘述

0%