验证go循环删除slice,map的操作和map delete操作不会释放底层内存的问题
目录
- 切片 for 循环删除切片元素
- 其他循环中删除slice元素的方法
- 方法1
- 方法2(推荐)
- 方法3
- 官方提供的方法
- 结论
- 切片 for 循环删除map元素
- goalng map delete操作不会释放底层内存
- go map原理
- 源码
- CRUD
- 查询
- 新增
- 操作注意事项
- map元素是无法取址的
- map是线程不安全的
切片 for 循环删除切片元素
在 Go 语言中,使用 for
循环删除切片元素可能会引发意外的结果,因为切片的长度在循环过程中可能会发生变化,导致索引越界或不正确的元素被删除。这是因为在删除切片元素时,删除操作会影响切片的长度和索引,从而影响后续的迭代。
以下是一个示例,演示了在循环中删除切片元素可能引发的问题:
package mainimport ("fmt"
)func main() {// 8*5 =40slice := []int{1, 2, 2, 2, 2, 4, 5}fmt.Printf("切片长度:%d,容量:%d \n", len(slice), cap(slice))for index, value := range slice {if value == 2 {slice = append(slice[:index], slice[index+1:]...)fmt.Println("删除了一次2")}fmt.Println(index, value)}fmt.Println(slice)fmt.Printf("切片长度:%d,容量:%d \n", len(slice), cap(slice))slice = slice[:cap(slice)]fmt.Println(slice)
}
在这个示例中,删除切片 slice
中值为 2 的元素。然而,由于删除操作改变了切片的长度和索引,循环会出现问题。
接下来通过画图来解释这个现象:
-
这是开始的slice:
slice := []int{1, 2, 2, 2, 2, 4, 5} fmt.Printf("切片长度:%d,容量:%d \n", len(slice), cap(slice))
-
进入循环删除元素:
for index, value := range slice {if value == 2 {slice = append(slice[:index], slice[index+1:]...)}fmt.Println(index, value) }
当index = 1时,删除第一次2后:
当index = 2时,删除第二次2后:
在 Go 的 for index, val := range slice
循环中,index
和 val
在每次循环迭代中都会被重新赋值,以便遍历切片中的每个元素。这意味着在每次循环迭代中,index
和 val
都会随着切片中的元素不断变化。
例如,考虑以下代码片段:
slice := []int{1, 2, 3, 4, 5}
for index, val := range slice {fmt.Printf("Index: %d, Value: %d\n", index, val)
}
在这个循环中,index
会取遍历到的元素的索引值,val
会取遍历到的元素的值。每次循环迭代,index
和 val
都会随着切片中的元素变化,从 0 到切片长度减 1。
虽然 index
和 val
会在循环中变化,但在循环内部对它们的重新赋值不会影响切片本身。即使在循环内部修改了 index
或 val
的值,也不会影响切片中的元素。这是因为 index
和 val
是在每次迭代中以新的值被复制,不会直接影响原切片中的数据。
用文字描述就是:
// index = 0,val = 1 不删除 slice = [1,2,2,2,2,4,5],打印(index,val)=(0,1)
// index = 1,val = 2 删除 slice = [1,2(1),2(2),2,4,5],打印(index,val)=(1,2)
// index = 2,val = 2 删除 slice = [1,2(1),2,4,5],打印(index,val)=(2,2)
// index = 3,val = 4 不删除
// index = 4,val = 5 不删除
// index = 5,val = 5 不删除
// index = 6,val = 5 不删除
index和val在循环开始时就已经确定了,所以打印时不受影响;但由于slice变化了,所以下一次循环开始时,index和val顺次增加从内存中取出的值却不是以前的值了,所以打印受到了影响。
正确的做法是,可以首先记录需要删除的元素的索引,然后再循环外面执行删除操作,避免在循环中修改切片。例如:
package mainimport "fmt"func main() {slice := []int{1, 2, 3, 4, 5}indexesToDelete := []int{}for index, value := range slice {if value == 3 {indexesToDelete = append(indexesToDelete, index)}}// 从后往前删除前面的不会受到影响for i := len(indexesToDelete) - 1; i >= 0; i-- {index := indexesToDelete[i]slice = append(slice[:index], slice[index+1:]...)}fmt.Println(slice)
}
在这个示例中,我们首先记录了需要删除的元素的索引,然后在第二个循环中进行了删除操作。这样可以避免在循环中修改切片,从而避免了索引越界和其他问题。
其他循环中删除slice元素的方法
a := []int{1, 2, 3, 4, 5}
,slice 删除大于 3 的数字
方法1
package mainimport "fmt"func main() {a := []int{1, 2, 3, 4, 5}for i := 0; i < len(a); i++ {if a[i] > 3 {// 当前元素被删除后,整体元素前移1位// 如果此时index++,相当于指针向后移动了两位,会导致跳过1位数组的读取// 因此,把i的自增行为抵消掉,指针不动,数组前移,i指向的地方自动会有下一个值填充进来a = append(a[:i], a[i+1:]...)i--}}fmt.Println(a)
}
方法2(推荐)
package mainimport "fmt"func main() {a := []int{1, 2, 3, 4, 5}j := 0for _, v := range a {if v <= 3 {a[j] = v// 符合条件的顺次赋值给前面的数组j++}}// 通过一次切片操作,将len置为j// 相当于只有len<=j的数组才可以看到a = a[:j]fmt.Println(a)
}
方法3
package mainimport "fmt"func main() {a := []int{1, 2, 3, 4, 5}j := 0// 相当于将a拷贝到qq := make([]int, len(a))for _, v := range a {if v <= 3 {q[j] = vj++}}q = q[:j] // q is copy with numbers >= 0fmt.Println(q)
}
官方提供的方法
go1.21版本后提供了slice库,封装了常用的slice方法:
func DeleteFunc[S ~[]E, E any](s S, del func(E) bool) S {// Don't start copying elements until we find one to delete.for i, v := range s {if del(v) {j := ifor i++; i < len(s); i++ {v = s[i]if !del(v) {s[j] = vj++}}return s[:j]}}return s
}
将del(v)
改为v <= 3
func DeleteFunc[S ~[]int](s S) S {// Don't start copying elements until we find one to delete.for i, v := range s {if v <= 3 {j := ifor i++; i < len(s); i++ {v = s[i]if !(v <= 3) {s[j] = vj++}}return s[:j]}}return s
}
官方的操作和方法2
非常相似,
func main() {a := []int{1, 2, 3, 4, 5}a = DeleteFunc(a)fmt.Println(a)a = a[:cap(a)]fmt.Println(a)
}
由于切片的扩缩容机制,基本上必须要把切片返回,防止切片底层指向的地址变动导致外部感受不到。
结论
- 当使用 for range 循环(for range) 遍历切片时,key 返回的是切片的索引,value 返回的是索引对应的值的拷贝。
- 在 Go 语言中,使用 for 循环删除切片元素可能会引发意外的结果,因为切片的长度在循环过程中可能会发生变化,导致索引越界或不正确的元素被删除。这是因为在删除切片元素时,删除操作会影响切片的长度和索引,从而影响后续的迭代。
切片 for 循环删除map元素
前提知识:map为什么会有这种无序性呢?map在某些条件下会自动扩容和重新hash所有的key以便存储更多的数据。 因为散列值映射到数组索引上本身就是随机的,在重新hash前后,key的顺序自然就会改变了。所以Go的设计者们就对map增加了一种随机性,以确保开发者在使用map时不依赖于有序的这个特性。
一句话:for循环中删除map元素是安全的。
官方go1.21 maps包中的删除方法:
// DeleteFunc deletes any key/value pairs from m for which del returns true.
func DeleteFunc[M ~map[K]V, K comparable, V any](m M, del func(K, V) bool) {for k, v := range m {if del(k, v) {delete(m, k)}}
}
奇怪的是,删除元素是安全的,新增元素却是不可预知的:
func main() {m := map[int]bool{0: true,1: false,2: true,}for k, v := range m {if v {m[10+k] = true}}fmt.Println(m)
}
上面这段代码的输出结果是不确定的。为什么呢?Go的官方文档中有这样的一段话:
If a map entry is created during iteration, it may be produced during the iteration or skipped. The choice may vary for each entry created and from one iteration to the next. – Go spec
大致的意思就是:
在遍历map期间,如果有一个新的key被创建,那么,在循环遍历过程中可能会被输出,也可能会被跳过。对于每一个创建的key在迭代过程中是选择输出还是跳过都是不同的。
也就是说,在迭代期间创建的key,有的可能会被输出,也的就可能会被跳过。这就是由于map中key的无序性造成的。
怎么解决上述问题,让输出结果变的是稳定的呢?最简单的方案就是使用复制:
m := map[int]bool{0: true,1: false,2: true,
}
m2 := make(map[int]bool)
for k, v := range m {m2[k] = vif v {m2[10+k] = true}
}
fmt.Println(m2)
由此可知,通过一个新的map,将读和写分离。即从m中读,在m2中更新,这样就能保持稳定的输出结果:
map[0:true 1:false 2:true 10:true 12:true]
goalng map delete操作不会释放底层内存
package mainimport ("fmt""runtime"
)//var a = make(map[int]struct{})func main() {v := struct{}{}a := make(map[int]struct{})for i := 0; i < 10000; i++ {a[i] = v}runtime.GC()printMemStats("添加1万个键值对后")fmt.Println("删除前Map长度:", len(a))for i := 0; i < 10000-1; i++ {delete(a, i)}fmt.Println("删除后Map长度:", len(a))// 再次进行手动GC回收runtime.GC()printMemStats("删除1万个键值对后")// 设置为nil进行回收a = nilruntime.GC()printMemStats("设置为nil后")
}func printMemStats(mag string) {var m runtime.MemStatsruntime.ReadMemStats(&m)fmt.Printf("%v:分配的内存 = %vKB, GC的次数 = %v\n", mag, m.Alloc/1024, m.NumGC)
}
可以看到,新版本的 Golang 难道真的会回收 map 的多余空间,难道哈希表会随着 map 里面的元素变少,然后缩小了?
将 map 放在外层:
package mainimport ("fmt""runtime"
)var a = make(map[int]struct{})func main() {v := struct{}{}//a := make(map[int]struct{})for i := 0; i < 10000; i++ {a[i] = v}runtime.GC()printMemStats("添加1万个键值对后")fmt.Println("删除前Map长度:", len(a))for i := 0; i < 10000-1; i++ {delete(a, i)}fmt.Println("删除后Map长度:", len(a))// 再次进行手动GC回收runtime.GC()printMemStats("删除1万个键值对后")// 设置为nil进行回收a = nilruntime.GC()printMemStats("设置为nil后")
}func printMemStats(mag string) {var m runtime.MemStatsruntime.ReadMemStats(&m)fmt.Printf("%v:分配的内存 = %vKB, GC的次数 = %v\n", mag, m.Alloc/1024, m.NumGC)
}
这时 map 好像内存没变化,直到设置为 nil。
为什么全局变量就会不变呢?
将局部变量添加一万个数,然后再删除9999个数,再添加9999个,看其变化:
package mainimport ("fmt""runtime"
)//var a = make(map[int]struct{})func main() {v := struct{}{}a := make(map[int]struct{})for i := 0; i < 10000; i++ {a[i] = v}runtime.GC()printMemStats("添加1万个键值对后")fmt.Println("删除前Map长度:", len(a))for i := 0; i < 10000-1; i++ {delete(a, i)}fmt.Println("删除后Map长度:", len(a))// 再次进行手动GC回收runtime.GC()printMemStats("删除1万个键值对后")for i := 0; i < 10000-1; i++ {a[i] = v}// 再次进行手动GC回收runtime.GC()printMemStats("再一次添加1万个键值对后")// 设置为nil进行回收a = nilruntime.GC()printMemStats("设置为nil后")
}func printMemStats(mag string) {var m runtime.MemStatsruntime.ReadMemStats(&m)fmt.Printf("%v:分配的内存 = %vKB, GC的次数 = %v\n", mag, m.Alloc/1024, m.NumGC)
}
这次局部变量删除后,和全局变量map一样了,内存也没变化。
但是添加10000个数后内存反而变小了。
map删除元素后map内存是不会释放的,无论是局部还是全局,但引出了上面一个奇怪的问题。
https://github.com/golang/go/issues/20135
为什么添加10000个数后内存反而变小了?因为 Golang 编译器有提前优化功能,它知道后面 map a 已经不会被使用了,所以会垃圾回收掉,a = nil 不起作用。
go map原理
源码
// A header for a Go map.
type hmap struct {count int // map元素的个数,len()的返回值flags uint8 // 状态标识,比如正在被写、buckets和oldbuckets在被遍历、等量扩容(Map扩容相关字段)B uint8 // B的值==log_2(buckets的长度)noverflow uint16 // 溢出桶里bmap大致的数量hash0 uint32 // hash因子buckets unsafe.Pointer // 2^B个桶对应的指针数组的指针oldbuckets unsafe.Pointer // 旧指针,用于扩缩容nevacuate uintptr // 记录渐进式扩容阶段下一个要迁移的旧桶编号 extra *mapextra // 可选字段
}// bucket结构体定义type bmap struct {tophash [8]uint8 //存储哈希值的高8位keys // key数组elems // 值数组overflow *bmap //溢出bucket的地址}type mapextra struct {overflow *[]*bmapoldoverflow *[]*bmap// nextOverflow 持有一个指向空闲溢出桶的指针。nextOverflow *bmap
}
- tophash用来快速查找key值是否在该bucket中,而不同每次都通过真值进行比较;
- 根据注释(us to eliminate padding which would be needed for, e.g., map[int64]int8.),map[int64]int8,key是int64(8个字节),value是int8(一个字节),kv的长度不同,如果按照kv格式存放,则考虑内存对齐v也会占用int64,而按照后者存储时,8个v刚好占用一个int64。
CRUD
将B初始化为4,则buckets为16
查询
-
计算key的hash值。
-
通过最后的“B”位来确定在哪号桶,此时B为4,所以取k4对应哈希值的后4位,也就是0101
-
根据key对应的hash值前8位快速确定是在这个桶的哪个位置
-
对比key完整的hash是否匹配,如果匹配则获取对应value
-
如果都没有找到,就去连接的下一个溢出桶中找
新增
- 通过key获取hash值
- hash值的低八位和bucket数组长度取余,定位到在数组中的哪个个下标
- hash值的高八位存储在bucket中的tophash中,用来快速判断key是否存在,key和value的具体值则通过指针运算存储,当一个bucket满时,通过overfolw指针链接到下一个bucket。
操作注意事项
map元素是无法取址的
- 可以得到m[key],但是无法对它的值作出任何修改,除非使用带指针的value。
- 因为map 会随着元素数量的增长而重新分配更大的内存空间,会导致之前的地址无效。
map是线程不安全的
某map桶数量为4,即B=2,此时 goroutine1来插入key1, goroutine2来读取 key2. 可能会发生如下过程:
-
goroutine2 计算key2的hash值,B=2,并确定桶号为1。
-
goroutine1添加key1,触发扩容条件。
-
B=B+1=3, buckets数据迁移到oldbuckets。
-
goroutine2从桶1中遍历,获取数据失败。
相关文章:

验证go循环删除slice,map的操作和map delete操作不会释放底层内存的问题
目录 切片 for 循环删除切片元素其他循环中删除slice元素的方法方法1方法2(推荐)方法3 官方提供的方法结论 切片 for 循环删除map元素goalng map delete操作不会释放底层内存go map原理源码CRUD查询新增 操作注意事项map元素是无法取址的map是线程不安全…...
C++二级题2
数字字符求和 #include<iostream> #include<string.h> #include<stdio.h> #include<iomanip> #include<cmath> #include<bits/stdc.h> int a[2000][2000]; int b[2000]; char c[2000]; long long n; using namespace std; int main() {ci…...

DataWhale 机器学习夏令营第三期——任务二:可视化分析
DataWhale 机器学习夏令营第三期 学习记录二 (2023.08.23)——可视化分析1.赛题理解2. 数据可视化分析2.1 用户维度特征分布分析2.2 时间特征分布分析 DataWhale 机器学习夏令营第三期 ——用户新增预测挑战赛 学习记录二 (2023.08.23)——可视化分析 2023.08.17 已跑通baseli…...
ubuntu 上安装flutter dart android studio
因为国内网站不能使用 使用一下: vi ~/.bashrc 最后添加 export FLUTTER_STORAGE_BASE_URLhttps://mirrors.cloud.tencent.com/flutter export PUB_HOSTED_URLhttps://mirrors.tuna.tsinghua.edu.cn/dart-pub export PATH$PATH:/usr/local/go/bin export GOPROXY…...
【Golang】go交叉编译
交叉编译是用来在一个平台上生成另一个平台的可执行程序 。Go 命令集是原生支持交叉编译的。 Mac下编译:Linux 或 Windows 的可执行程序 # linux 可执行程序 CGO_ENABLED0 GOOSlinux GOARCHamd64 go build main.go # Windows可执行程序 CGO_ENABLED0 GOOSwindow…...

【人工智能】—_贝叶斯网络、概率图模型、全局语义、因果链、朴素贝叶斯模型、枚举推理、变量消元
文章目录 频率学派 vs. 贝叶斯学派贝叶斯学派Probability(概率):独立性/条件独立性:Probability Theory(概率论):Graphical models (概率图模型)什么是图模型(Graphical Models&…...
学习笔记:ROS使用经验( 查看rostopic的信息)
查看topic的信息 要查看ROS中的话题信息,你可以使用以下命令: 1.查看所有活动话题: $ rostopic list这将列出当前运行的所有活动话题。 2.查看特定话题的消息类型: $ rostopic info <topic_name>将<topic_name>替…...
数据库——redis内存淘汰,持久化机制
文章目录 Redis 内存淘汰机制了解么?⭐了解操作系统中lru并尝试用java实现lru 2.Redis 持久化机制(怎么保证 Redis 挂掉之后再重启数据可以进行恢复)快照(snapshotting)持久化(RDB)AOF(append-only file&am…...
亚马逊云科技 云技能孵化营 我也说ai
自从chatgpt大火以后,我也关注了人工智能方面的东西,偶尔同学推荐参加了亚马逊云科技云技能孵化营活动,免费学习了亚马逊云科技和机器学习方面的知识,还获得了小礼品,现在将活动及心得分享给大家。 活动内容ÿ…...
『PyQt5-基础篇』| 04 Qt Designer的初步快速了解
04 Qt Designer的初步快速了解 1 Qt Designer入口2 Qt Designer-Widget Box2.1 窗口部件盒(Widget Box)2.2 Layouts布局2.3 Spacers间隔部件2.4 Button按钮2.5 Item Views(Model-Based)2.6 Item Widgets(Item-Based)2.7 Containers容器2.8 Input Widget输入部件2.9 Display W…...
SpringCloud学习笔记(十一)_Hystrix仪表盘
我们来看一下如何使用它吧 1.引入依赖 1 2 3 4 5 6 7 8 9 10 11 12 | <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-hystrix</artifactId> </depende…...
# ruby安装设置笔记
ruby安装设置笔记 文章目录 ruby安装设置笔记1 克隆并设置环境变量2 安装ruby3 设置ruby4 设置源5 安装bundler6 检查安装后的软件版本7 ubuntu 20.04 默认ruby环境 系统自带的ruby版本低了,需要手动安装更高版本(使用rbenv方式) 环境&#x…...
关于对文件路径权限判断的记录
首先需要添加引用 using System.Security.AccessControl;以下为具体代码,其中fileServerPath为需要判断的文件路径 #region Authority judgmentDirectorySecurity fileAcl Directory.GetAccessControl(fileServerPath);var rules fileAcl.GetAccessRules(true, t…...

git 基础
1.下载安装Git(略) 2.打开git bash窗口 3.查看版本号、设置用户名和邮箱 用户名和邮箱可以随意起,与GitHub的账号邮箱没有关系 4.初始化git 在D盘中新建gitspace文件夹,并在该目录下打开git bash窗口 git init 初始化完成后会…...

C语言网络编程实现广播
1.概念 如果同时发给局域网中的所有主机,称为广播 我们可以使用命令查看我们Linux下当前的广播地址:ifconfig 2.广播地址 以192.168.1.0 (255.255.255.0) 网段为例,最大的主机地址192.168.1.255代表该网段的广播地址(具体以ifcon…...

js对url进行编码解码(三种方式)
第一种:escape 和 unescape escape()不能直接用于URL编码,它的真正作用是返回一个字符的Unicode编码值 它的具体规则是,除了ASCII字母、数字、标点符号" * _ - . /"以外,对其他所有字符进行编码。在u0000到u00ff之间…...

React面向组件编程
往期回顾:# React基础入门之虚拟Dom【一】 面向组件编程 react是面向组件编程的一种模式,它包含两种组件类型:函数式组件及类式组件 函数式组件 注:react17开始,函数式组件成为主流 一个基本的函数组件长这个样子 …...

Linux 多线程同步机制(上)
文章目录 前言一、线程同步二、互斥量 mutex三、死锁总结 前言 一、线程同步 在多线程环境下,多个线程可以并发地执行,访问共享资源(如内存变量、文件、网络连接 等)。 这可能导致 数据不一致性, 死锁, 竞争条件等 问题。 为了解…...

C++学习vector
1,把list的相关函数都实现出来(未完) 2, 运行结果:...

17.3 【Linux】systemctl 针对 service 类型的配置文件
17.3.1 systemctl 配置文件相关目录简介 服务的管理是通过 systemd,而 systemd 的配置文件大部分放置于/usr/lib/systemd/system/ 目录内。但是 Red Hat 官方文件指出, 该目录的文件主要是原本软件所提供的设置,建议不要修改!而要…...

【Axure高保真原型】引导弹窗
今天和大家中分享引导弹窗的原型模板,载入页面后,会显示引导弹窗,适用于引导用户使用页面,点击完成后,会显示下一个引导弹窗,直至最后一个引导弹窗完成后进入首页。具体效果可以点击下方视频观看或打开下方…...
设计模式和设计原则回顾
设计模式和设计原则回顾 23种设计模式是设计原则的完美体现,设计原则设计原则是设计模式的理论基石, 设计模式 在经典的设计模式分类中(如《设计模式:可复用面向对象软件的基础》一书中),总共有23种设计模式,分为三大类: 一、创建型模式(5种) 1. 单例模式(Sing…...

Linux 文件类型,目录与路径,文件与目录管理
文件类型 后面的字符表示文件类型标志 普通文件:-(纯文本文件,二进制文件,数据格式文件) 如文本文件、图片、程序文件等。 目录文件:d(directory) 用来存放其他文件或子目录。 设备…...
Java 8 Stream API 入门到实践详解
一、告别 for 循环! 传统痛点: Java 8 之前,集合操作离不开冗长的 for 循环和匿名类。例如,过滤列表中的偶数: List<Integer> list Arrays.asList(1, 2, 3, 4, 5); List<Integer> evens new ArrayList…...

MongoDB学习和应用(高效的非关系型数据库)
一丶 MongoDB简介 对于社交类软件的功能,我们需要对它的功能特点进行分析: 数据量会随着用户数增大而增大读多写少价值较低非好友看不到其动态信息地理位置的查询… 针对以上特点进行分析各大存储工具: mysql:关系型数据库&am…...
QMC5883L的驱动
简介 本篇文章的代码已经上传到了github上面,开源代码 作为一个电子罗盘模块,我们可以通过I2C从中获取偏航角yaw,相对于六轴陀螺仪的yaw,qmc5883l几乎不会零飘并且成本较低。 参考资料 QMC5883L磁场传感器驱动 QMC5883L磁力计…...
VTK如何让部分单位不可见
最近遇到一个需求,需要让一个vtkDataSet中的部分单元不可见,查阅了一些资料大概有以下几种方式 1.通过颜色映射表来进行,是最正规的做法 vtkNew<vtkLookupTable> lut; //值为0不显示,主要是最后一个参数,透明度…...

学习STC51单片机32(芯片为STC89C52RCRC)OLED显示屏2
每日一言 今天的每一份坚持,都是在为未来积攒底气。 案例:OLED显示一个A 这边观察到一个点,怎么雪花了就是都是乱七八糟的占满了屏幕。。 解释 : 如果代码里信号切换太快(比如 SDA 刚变,SCL 立刻变&#…...

安宝特案例丨Vuzix AR智能眼镜集成专业软件,助力卢森堡医院药房转型,赢得辉瑞创新奖
在Vuzix M400 AR智能眼镜的助力下,卢森堡罗伯特舒曼医院(the Robert Schuman Hospitals, HRS)凭借在无菌制剂生产流程中引入增强现实技术(AR)创新项目,荣获了2024年6月7日由卢森堡医院药剂师协会࿰…...
纯 Java 项目(非 SpringBoot)集成 Mybatis-Plus 和 Mybatis-Plus-Join
纯 Java 项目(非 SpringBoot)集成 Mybatis-Plus 和 Mybatis-Plus-Join 1、依赖1.1、依赖版本1.2、pom.xml 2、代码2.1、SqlSession 构造器2.2、MybatisPlus代码生成器2.3、获取 config.yml 配置2.3.1、config.yml2.3.2、项目配置类 2.4、ftl 模板2.4.1、…...