Go Slice的底层实现原理深度解析

在Go语言的世界里,切片(Slice)是一种极其重要的数据结构,它以其灵活性和高效性在众多编程场景中扮演着核心角色。本文将深入探讨Go切片的底层实现原理,通过实例和源码分析,带你领略Go语言设计之美。

切片的诞生:数组的延伸

在Go中,数组是一种固定长度的数据结构,这在某些情况下限制了它的使用。为了解决这个问题,切片应运而生。切片基于数组实现,但它提供了一种动态调整大小的能力,使得数据的存储和管理更加灵活。

切片的结构

切片的内部结构在src/runtime/slice.go​中定义,它包含三个主要部分:

  • array:指向底层数组的指针。
  • len:切片的长度,即当前切片包含的元素数量。
  • cap:切片的容量,即底层数组能够容纳的元素数量。

初始化切片

切片有多种初始化方式,包括直接声明、使用字面量、使用make​函数以及从已有的切片或数组中截取。这些初始化方式在底层都会调用相应的函数,如runtime.makeslice​,它负责计算所需内存大小并分配。

切片的内存管理

切片的内存管理是其高效性的关键。当切片的len​小于cap​时,我们可以通过追加元素来扩展切片,而不需要重新分配整个底层数组。这种设计使得切片在添加元素时具有很高的效率。

扩容机制

len​达到cap​时,切片需要扩容。扩容过程中,Go会分配一个新的更大的底层数组,并将原数组中的元素复制到新数组中。这个过程在runtime.growslice​函数中实现。

实例分析:切片的动态特性

让我们通过一个简单的例子来观察切片的动态特性。

go 复制代码
package main

import "fmt"

func main() {
    slice1 := make([]int, 0, 5) // 初始化一个长度为0,容量为5的切片
    slice1 = append(slice1, 1, 2, 3) // 追加元素,此时len=3,cap=5

    // 当len达到cap时,扩容会发生
    slice1 = append(slice1, 4, 5) // 此时len=5,cap=5,扩容后len=5,cap>5

    fmt.Println(slice1) // 输出:[1 2 3 4 5]
}

在这个例子中,我们可以看到切片在追加元素时如何动态调整其大小。

切片与性能

切片的设计使得它在性能上具有优势。由于它基于数组,所以它提供了对数组的快速访问。同时,它的动态特性使得它在处理不确定数量的元素时更加高效。

性能对比

与其他语言中的动态数组相比,Go切片在内存管理和性能上都有显著的优势。这得益于Go语言的编译器优化和运行时的高效内存管理。

切片的并发安全

在并发编程中,数据结构的安全性至关重要。Go语言的切片在设计时就考虑了并发安全。虽然切片本身不是线程安全的,但是它的操作(如追加、删除等)在单线程环境下是安全的。在多线程环境下,需要开发者手动同步对切片的访问。

并发场景下的切片操作

在多线程环境中,如果多个goroutine同时对同一个切片进行操作,可能会导致竞态条件。为了避免这种情况,可以使用互斥锁(mutex)来保护对切片的访问。

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var slice []int
	var lock sync.Mutex

	// 启动两个goroutine,分别向切片中追加元素
	for i := 0; i < 2; i++ {
		go func(i int) {
			lock.Lock()
			defer lock.Unlock()
			slice = append(slice, i)
			fmt.Println("Appended", i, "to slice", slice)
		}(i)
	}

	// 等待goroutine完成
	for i := 0; i < 2; i++ {
		<-make(chan struct{})
	}

	fmt.Println("Final slice:", slice)
}

在这个例子中,我们使用了sync.Mutex​来确保在追加元素时不会有并发问题。

切片与接口

Go语言的切片还与接口(interface)有着紧密的联系。切片可以存储任何类型的元素,这使得它在处理异构数据时非常有用。然而,切片的元素类型必须是相同的,这是Go语言类型安全的一个体现。

切片与空接口

空接口(empty interface)interface{}​可以存储任何类型的值,包括切片。但是,当我们将切片存储在空接口中时,会丢失切片的类型信息。

go 复制代码
package main

import "fmt"

func main() {
	var i interface{} = []int{1, 2, 3}
	fmt.Println(i) // 输出:[1 2 3]

	// 无法直接访问切片的元素类型信息
	// 需要通过类型断言来获取具体的切片类型
}

切片的遍历与操作

切片提供了多种方法来遍历和操作其元素。这些方法包括len()​、cap()​、append()​、copy()​等。这些方法使得切片的操作变得简单而直观。

遍历切片

遍历切片通常使用for​循环或者range​关键字。range​关键字可以同时获取切片的索引和值。

go 复制代码
package main

import "fmt"

func main() {
	slice := []string{"apple", "banana", "cherry"}

	// 使用for循环遍历切片
	for i := 0; i < len(slice); i++ {
		fmt.Println("Index", i, "Value", slice[i])
	}

	// 使用range遍历切片
	for index, value := range slice {
		fmt.Println("Index", index, "Value", value)
	}
}

切片的切片操作

切片的切片操作允许我们创建原切片的一个子集。这在处理大型数据集时非常有用。

go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4, 5}

	// 创建一个子切片
	subslice := slice[1:4]
	fmt.Println(subslice) // 输出:[2 3 4]
}

切片的垃圾回收

在Go语言中,垃圾回收(GC)是自动进行的。切片作为引用类型,其生命周期由垃圾回收器管理。当切片不再被任何变量引用时,它所占用的内存会被垃圾回收器回收。

切片的生命周期

go 复制代码
package main

import "fmt"

func main() {
	slice := make([]int, 0, 10)
	defer fmt.Println("Slice is garbage collected")

	// 在这里,slice被创建并使用
	// ...

	// 当main函数结束时,slice的生命周期结束
	// 垃圾回收器会在适当的时候回收slice
}

在这个例子中,defer​语句确保了在main​函数结束时,会打印出切片被垃圾回收的信息。

切片与性能优化

在Go语言中,切片的性能优化是一个值得深入探讨的话题。由于切片在内存管理上的特殊性,它在某些情况下可能成为性能瓶颈。了解这些情况并采取相应的优化措施,可以使程序运行得更加高效。

预分配与扩容

在创建切片时,预分配足够的容量可以避免多次扩容操作。虽然Go的扩容机制已经非常高效,但在某些情况下,预先知道切片的大致大小并进行预分配,可以减少内存分配的次数,从而提高性能。

go 复制代码
// 预分配容量的切片
slice := make([]int, 0, 100)

避免不必要的切片操作

在处理切片时,不必要的切片操作会增加额外的开销。例如,频繁地创建切片的子集,或者在循环中不断地追加元素,都可能导致性能下降。在这些情况下,考虑使用其他数据结构或者优化切片的使用方式,可能会带来更好的性能。

使用切片池

在某些应用场景中,频繁创建和销毁切片可能会导致大量的内存分配和回收。为了解决这个问题,可以考虑使用切片池(slice pool)来重用切片。通过维护一个切片池,可以在需要时从池中获取切片,使用完毕后放回池中,从而减少内存分配的频率。

go 复制代码
type slicePool struct {
	sync.Pool
}

func (p *slicePool) Get(size int) []int {
	if v := p.Get(); v != nil {
		s := v.([]int)
		if len(s) >= size {
			return s[:size]
		}
	}
	return make([]int, size)
}

func (p *slicePool) Put(s []int) {
	if len(s) < 1024 {
		p.Put(s)
	}
}

在这个例子中,我们创建了一个切片池,它可以帮助我们重用切片,减少内存分配。

切片与并发

在并发编程中,切片的共享使用需要谨慎处理。由于切片不是线程安全的,因此在多线程环境中共享切片时,需要确保对切片的访问是同步的。这可以通过互斥锁、channel或者原子操作来实现。

切片的并发访问

在Go中,使用channel来传递切片是一种安全的做法,因为channel保证了在发送和接收操作的原子性。这样可以避免在多个goroutine之间共享切片时出现竞态条件。

go 复制代码
func worker(c chan []int) {
	slice := <-c
	// 在这里处理slice
}

func main() {
	c := make(chan []int, 10)
	c <- make([]int, 0, 100) // 发送切片到channel

	go worker(c)
	// ...
}

在这个例子中,我们通过channel安全地在goroutine之间传递切片。

切片与错误处理

在使用切片时,错误处理是一个不可忽视的方面。Go语言提供了丰富的错误处理机制,这些机制同样适用于切片操作。了解如何在切片操作中处理错误,可以帮助我们编写更健壮的代码。

切片操作的错误检查

在进行切片操作时,如索引访问、切片操作等,我们需要确保索引不会越界。Go语言的运行时会检查这些操作,一旦发现越界,程序会立即崩溃并打印堆栈跟踪。因此,合理地使用切片可以避免这类错误。

go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3}

	// 正确的索引访问
	fmt.Println(slice[1]) // 输出:2

	// 错误的索引访问会导致程序崩溃
	// fmt.Println(slice[5])
}

切片的边界检查

在Go中,没有直接的函数或方法来检查切片的边界。但是,我们可以通过比较索引与切片的长度来手动检查。在处理切片时,始终要确保索引不会超出切片的长度。

go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3}

	for i := range slice {
		if i >= len(slice) {
			fmt.Println("Index out of bounds")
			break
		}
		fmt.Println(slice[i])
	}
}

切片与panic/recover

在Go中,当切片操作导致越界时,程序会触发panic​。我们可以使用defer​和recover​来捕获并处理这种异常情况。

go 复制代码
package main

import "fmt"

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Recovered in main", r)
		}
	}()

	slice := []int{1, 2, 3}
	fmt.Println(slice[5]) // 这将触发panic
}

在这个例子中,我们通过defer​语句捕获了由于越界访问切片而引发的panic​。

切片的高级应用

切片不仅在日常编程中扮演着基础角色,它还可以用于实现更复杂的数据结构和算法。以下是一些切片的高级应用示例。

切片作为队列

切片可以很容易地实现队列(FIFO)的功能。通过在切片的末尾追加元素,并从前端移除元素,我们可以创建一个高效的队列。

go 复制代码
package main

import "fmt"

type Queue struct {
	slice []int
}

func (q *Queue) Enqueue(value int) {
	q.slice = append(q.slice, value)
}

func (q *Queue) Dequeue() (int, bool) {
	if len(q.slice) == 0 {
		return 0, false
	}
	value := q.slice[0]
	q.slice = q.slice[1:]
	return value, true
}

func main() {
	q := Queue{}
	q.Enqueue(1)
	q.Enqueue(2)
	q.Enqueue(3)

	for {
		value, ok := q.Dequeue()
		if !ok {
			break
		}
		fmt.Println(value)
	}
}

切片与排序

切片提供了sort.Slice​函数,它可以对切片进行排序。这个函数非常灵活,可以用于各种类型的切片排序。

go 复制代码
package main

import (
	"fmt"
	"sort"
)

type Person struct {
	Name string
	Age  int
}

func main() {
	people := []Person{
		{"Bob", 31},
		{"John", 42},
		{"Michael", 17},
	}

	// 按年龄排序
	sort.Slice(people, func(i, j int) bool {
		return people[i].Age < people[j].Age
	})

	fmt.Println(people)
}

在这个例子中,我们定义了一个Person​结构体,并使用sort.Slice​对people​切片按年龄进行了排序。

切片与迭代器

在处理大型数据集时,使用迭代器可以提高代码的可读性和效率。Go语言的切片没有内置的迭代器,但我们可以通过编写自定义函数来模拟迭代器的行为。

自定义迭代器

以下是一个简单的切片迭代器的示例,它允许我们遍历切片中的每个元素,而不需要直接操作索引。

go 复制代码
package main

import "fmt"

// SliceIterator 是一个自定义的切片迭代器
type SliceIterator struct {
	slice []int
	index int
}

// NewSliceIterator 创建一个新的切片迭代器
func NewSliceIterator(slice []int) *SliceIterator {
	return &SliceIterator{slice: slice, index: 0}
}

// HasNext 检查迭代器是否还有更多的元素
func (i *SliceIterator) HasNext() bool {
	return i.index < len(i.slice)
}

// Next 返回下一个元素,并更新迭代器的索引
func (i *SliceIterator) Next() int {
	if i.HasNext() {
		value := i.slice[i.index]
		i.index++
		return value
	}
	panic("迭代器没有更多元素")
}

func main() {
	slice := []int{1, 2, 3, 4, 5}
	iterator := NewSliceIterator(slice)

	for iterator.HasNext() {
		fmt.Println(iterator.Next())
	}
}

在这个例子中,我们创建了一个SliceIterator​结构体,它包含了切片和当前索引。通过HasNext​和Next​方法,我们可以遍历切片中的所有元素。

切片与反射

Go语言的反射(reflection)机制允许我们在运行时检查和操作数据。虽然切片的类型信息在编译时就已经确定,但我们仍然可以使用反射来操作切片。

使用反射操作切片

以下是一个使用反射来操作切片的示例。这个例子展示了如何动态地访问切片的元素类型和值。

go 复制代码
package main

import (
	"fmt"
	"reflect"
)

func main() {
	slice := []int{1, 2, 3, 4, 5}

	// 使用反射获取切片的类型信息
	sliceType := reflect.TypeOf(slice)
	fmt.Println("Slice Type:", sliceType)

	// 使用反射遍历切片
	for i := 0; i < sliceType.Len(); i++ {
		value := sliceType.Elem().Index(i).Int()
		fmt.Println("Element at index", i, ": ", value)
	}
}

在这个例子中,我们使用reflect.TypeOf​获取切片的类型信息,并使用reflect.Value​来遍历切片的元素。

切片与接口

切片可以与接口(interface)结合使用,这为我们提供了更多的灵活性。当我们将切片作为接口的值时,我们可以在不知道具体类型的情况下操作切片。

切片与接口的结合

以下是一个将切片与接口结合使用的示例。这个例子展示了如何将切片存储在接口中,并在需要时进行类型断言。

go 复制代码
package main

import "fmt"

func processSlice(slice interface{}) {
	sliceValue := slice.([]int)
	fmt.Println("Processing slice:", sliceValue)
}

func main() {
	intSlice := []int{1, 2, 3, 4, 5}
	processSlice(intSlice)
}

在这个例子中,我们将一个整数切片存储在接口变量中,并传递给processSlice​函数。在函数内部,我们通过类型断言来获取切片的值。

切片与并发映射

在并发编程中,映射(map)是一种常用的数据结构,用于存储键值对。切片也可以与映射结合使用,以实现更复杂的数据结构,如并发安全的映射。

并发安全的切片映射

以下是一个使用sync.Map​来存储切片的示例。sync.Map​是Go语言提供的一种并发安全的映射,它可以在多个goroutine之间安全地共享和修改数据。

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var m sync.Map

	// 向映射中添加切片
	m.Store("slice1", []int{1, 2, 3})

	// 从映射中获取切片
	if slice, ok := m.Load("slice1"); ok {
		fmt.Println("Retrieved slice:", slice.([]int))
	}
}

在这个例子中,我们使用sync.Map​来存储和检索切片。这种方式确保了在并发环境下对映射的访问是安全的。

切片与错误处理

在处理切片时,错误处理是一个重要的方面。Go语言提供了panic​和recover​机制来处理运行时错误。在切片操作中,我们可以通过这些机制来处理潜在的错误情况。

使用defer​和recover​处理切片错误

以下是一个使用defer​和recover​来处理切片越界错误的示例。

go 复制代码
package main

import (
	"fmt"
)

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Recovered from panic:", r)
		}
	}()

	slice := []int{1, 2, 3}
	// 故意越界访问切片,触发panic
	fmt.Println(slice[5])
}

在这个例子中,我们故意访问了一个不存在的切片索引,这会导致panic​。通过defer​语句,我们捕获了这个panic​并进行了处理。

切片与算法

切片是实现各种算法的理想选择,因为它们提供了灵活的内存管理和高效的元素访问。以下是一些使用切片实现的常见算法示例。

切片排序

切片排序是处理切片时的一个基本操作。Go标准库提供了sort.Sort​函数,它可以对切片进行排序。

go 复制代码
package main

import (
	"fmt"
	"sort"
)

func main() {
	intSlice := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}
	fmt.Println("Original slice:", intSlice)

	// 使用sort.Sort对切片进行排序
	sort.Sort(sort.IntSlice(intSlice))
	fmt.Println("Sorted slice:", intSlice)
}

在这个例子中,我们使用sort.Sort​和sort.IntSlice​对整数切片进行了排序。

切片搜索

切片搜索是另一个常见的操作。Go标准库提供了sort.Search​函数,它可以在有序切片中查找特定元素的索引。

go 复制代码
package main

import (
	"fmt"
	"sort"
)

func main() {
	intSlice := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
	fmt.Println("Original slice:", intSlice)

	// 使用sort.Search在切片中搜索元素
	index := sort.SearchInts(intSlice, 5)
	fmt.Println("Index of 5:", index)
}

在这个例子中,我们使用sort.SearchInts​在整数切片中搜索元素5的索引。

切片与数据流

在处理数据流时,切片可以作为一种缓冲机制,帮助我们管理数据的读取和写入。这在文件操作、网络通信等场景中尤为常见。

使用切片处理文件数据

在读取或写入文件时,我们通常会使用切片来临时存储数据块。以下是一个使用切片读取文件内容的示例。

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	file, err := os.Open("example.txt")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	reader := bufio.NewReader(file)

	// 使用切片作为缓冲区读取文件
	buffer := make([]byte, 1024)
	for {
		n, err := reader.Read(buffer)
		if err != nil {
			if err != nil {
				panic(err)
			}
			break
		}

		// 处理读取的数据
		fmt.Print(string(buffer[:n]))
	}
}

在这个例子中,我们使用bufio.Reader​来逐块读取文件,每次读取1024字节到切片buffer​中,并处理这些数据。

使用切片处理网络数据

在网络编程中,切片同样可以用来处理接收到的数据。以下是一个简单的TCP服务器示例,它使用切片来接收客户端发送的数据。

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"net"
	"strings"
)

func main() {
	listener, err := net.Listen("tcp", "localhost:8080")
	if err != nil {
		panic(err)
	}
	defer listener.Close()

	for {
		conn, err := listener.Accept()
		if err != nil {
			panic(err)
		}

		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()

	reader := bufio.NewReader(conn)
	for {
		buffer := make([]byte, 1024)
		n, err := reader.Read(buffer)
		if err != nil {
			break
		}

		// 处理接收到的数据
		message := string(buffer[:n])
		fmt.Println("Received message:", message)
	}
}

在这个例子中,我们创建了一个TCP服务器,它使用bufio.Reader​来接收客户端发送的数据,并将数据存储在切片buffer​中。

切片与数据结构

切片可以与其他数据结构结合使用,以实现更复杂的数据结构。例如,切片可以作为其他数据结构的一部分,或者用于实现自定义的数据结构。

切片作为数据结构的一部分

以下是一个使用切片实现的简单栈(Stack)数据结构示例。

go 复制代码
package main

import "fmt"

type Stack struct {
	items []interface{}
}

func (s *Stack) Push(item interface{}) {
	s.items = append(s.items, item)
}

func (s *Stack) Pop() interface{} {
	if len(s.items) == 0 {
		return nil
	}
	item := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return item
}

func main() {
	stack := Stack{}
	stack.Push(1)
	stack.Push("hello")
	stack.Push(true)

	for {
		item := stack.Pop()
		if item == nil {
			break
		}
		fmt.Println(item)
	}
}

在这个例子中,我们定义了一个Stack​结构体,它包含一个切片items​,用于存储栈中的元素。我们实现了Push​和Pop​方法来操作栈。

使用切片实现自定义数据结构

切片也可以用于实现更复杂的自定义数据结构。例如,我们可以使用切片来实现一个二叉搜索树(BST)。

go 复制代码
// 这里只是一个简单的BST节点定义,实际实现会更复杂
type BSTNode struct {
	Value int
	Left *BSTNode
	Right *BSTNode
}

// BSTInsert 用于向BST中插入新值
func BSTInsert(root *BSTNode, value int) {
	if root == nil {
		return &BSTNode{Value: value}
	}
	if value < root.Value {
		root.Left = BSTInsert(root.Left, value)
	} else if value > root.Value {
		root.Right = BSTInsert(root.Right, value)
	}
	return root
}

// BSTSearch 用于在BST中搜索特定值
func BSTSearch(root *BSTNode, value int) bool {
	if root == nil {
		return false
	}
	if root.Value == value {
		return true
	}
	if value < root.Value {
		return BSTSearch(root.Left, value)
	}
	return BSTSearch(root.Right, value)
}

func main() {
	root := &BSTNode{Value: 5}
	BSTInsert(root, 3)
	BSTInsert(root, 7)
	fmt.Println(BSTSearch(root, 3)) // 输出:true
	fmt.Println(BSTSearch(root, 6)) // 输出:false
}

在这个例子中,我们定义了一个BSTNode​结构体来表示二叉搜索树的节点,并实现了插入和搜索功能。

切片与标准库

Go语言的标准库提供了许多与切片相关的功能,这些功能可以帮助我们更高效地处理数据。了解这些功能对于编写高效的Go代码至关重要。

使用标准库处理切片

标准库中的sort​和strings​包提供了丰富的切片处理功能。例如,sort​包可以用来对切片进行排序,而strings​包则提供了字符串切片的处理方法。

go 复制代码
package main

import (
	"fmt"
	"sort"
	"strings"
)

func main() {
	// 使用sort包对整数切片进行排序
	intSlice := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3}
	fmt.Println("Original int slice:", intSlice)
	sort.Ints(intSlice)
	fmt.Println("Sorted int slice:", intSlice)

	// 使用strings包处理字符串切片
	strSlice := []string{"banana", "apple", "cherry"}
	fmt.Println("Original string slice:", strSlice)
	sort.Strings(strSlice)
	fmt.Println("Sorted string slice:", strSlice)
}

在这个例子中,我们展示了如何使用sort.Ints​和sort.Strings​对整数和字符串切片进行排序。

切片与并发

在并发编程中,切片的使用需要特别注意,因为它们可能被多个goroutine共享。为了确保数据的一致性和安全性,我们通常需要使用互斥锁或其他同步机制。

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	slice := []int{1, 2, 3}
	var lock sync.Mutex

	// 启动一个goroutine来修改切片
	go func() {
		lock.Lock()
		defer lock.Unlock()
		slice[0] = 42
	}()

	// 在主goroutine中打印切片
	lock.Lock()
	defer lock.Unlock()
	fmt.Println("Slice after goroutine:", slice)
}

在这个例子中,我们使用sync.Mutex​来确保对切片的修改是线程安全的。

切片与错误处理

在处理切片时,我们可能会遇到各种错误,例如索引越界。Go语言提供了panic​和recover​机制来处理这类错误。

go 复制代码
package main

import (
	"fmt"
)

func main() {
	slice := []int{1, 2, 3}

	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Recovered from panic:", r)
		}
	}()

	// 故意触发索引越界错误
	_ = slice[5]
}

在这个例子中,我们通过defer​和recover​捕获并处理了由于索引越界引起的panic​。


参考资料:

相关推荐
柏油5 小时前
MySQL InnoDB 行锁
数据库·后端·mysql
咖啡调调。5 小时前
使用Django框架表单
后端·python·django
白泽talk5 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务
摆烂工程师5 小时前
全网最详细的5分钟快速申请一个国际 “edu教育邮箱” 的保姆级教程!
前端·后端·程序员
一只叫煤球的猫5 小时前
你真的会用 return 吗?—— 11个值得借鉴的 return 写法
java·后端·代码规范
Asthenia04126 小时前
HTTP调用超时与重试问题分析
后端
颇有几分姿色6 小时前
Spring Boot 读取配置文件的几种方式
java·spring boot·后端
AntBlack6 小时前
别说了别说了 ,Trae 已经在不停优化迭代了
前端·人工智能·后端
@淡 定6 小时前
Spring Boot 的配置加载顺序
java·spring boot·后端