深入剖析 Golang 程序启动原理 - 从 ELF 入口点到GMP初始化到执行 main!

大家好,我是飞哥!

在过去的开发工作中,大家都是通过创建进程或者线程来工作的。Linux进程是如何创建出来的? 、聊聊Linux中线程和进程的联系与区别! 和你的新进程是如何被内核调度执行到的? 这几篇文章就是帮大家深入理解进程线程原理的。

但是,时至今日光了解进程和线程已经不够了。因为现在协程编程模型大行其道。很多同学知道进程和线程,但就是不理解协程是如何工作的。虽然能写出来代码,但不理解底层运行原理。

今天就让我以 golang 版本的 hello world 程序为例,给大家拆解一下协程编程模型的工作过程。 

在本文中我会从 ELF 可执行文件的入口讲起,讲到 GMP 调度器的初始化,到主协程的创建,到主协程进入 runtime.main 最后执行到用户定义的 main 函数。

一、 hello world 程序的运行入口

golang 的 hello world 写起来非常的简单。

package main
import "fmt"
func main() {fmt.Println("Hello World!")
}

运行起来也是一样非常的简单。

# go build main.go
# ./main
Hello World!

程序是跑起来了,但是问题来了。传说中的协程究竟长什么样子,是何时被创建的,又是如何被加载运行并打印出 “Hello World!” 的。

不管是啥语言编译出来的可执行文件,都有一个执行入口点。shell 在将程序加载完后会跳转到程序入口点开始执行。

但值得提前说的一点是一般编程语言的入口点都不会是我们在代码中写的那个 main。c 语言中如此,golang 中更是这样。这是因为各个语言都需要在进程启动过程中做一些启动逻辑的。在 golang 中,其底层运行的 GMP、垃圾回收等机制都需要在进入用户的 main 函数之前启动起来。

接下来我们需要借助 readelf 和 nm 命令来找到上述编译出来的可执行文件 main 的执行入口。首先使用 readelf 找到 main 的入口点是在 0x45c220 位置处,如下图所示。

$ readelf --file-header main
ELF Header:......Entry point address:               0x45c220

那么 0x45c220 这个位置对应的是哪个函数呢?借助 nm 命令我们可以看到它是 _rt0_amd64_linux。

nm -n main | grep 45c220
000000000045c220 T _rt0_amd64_linux

这其实是一个汇编函数。

// file:asm_amd64.s
// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking.
TEXT _rt0_amd64(SB),NOSPLIT,$-8MOVQ	0(SP), DI	// argcLEAQ	8(SP), SI	// argvJMP	runtime·rt0_go(SB)

这个函数的开头也有明确的注释 “_rt0_amd64 is common startup code for most amd64 systems when using internal linking”。这说明我们找对了。

接下来的 golang 运行就是顺着这个汇编函数开始执行,最后一步步地运行到我们所熟悉的 main 函数的。

二、入口执行分析

这一小节我们来看看 golang 程序在启动的时候都做了哪些事情。相信理解这些底层的工作机制对从事 golang 开发的同学会非常的大有裨益。

在上一小节我们看到了的 golang 入口函数 _rt0_amd64。要注意的是,当代码运行到这里的时候,操作系统已经为当前可执行文件创建好了一个主线程了。_rt0_amd64 只是将参数简单地保存一下后就 JMP (汇编中的函数调用)到 runtime·rt0_go 中了。

这个函数很长,我们只挑有重要的讲!

// file:runtime/asm_amd64.s
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0......// 2.1 Golang 核心初始化过程CALL	runtime·osinit(SB)CALL	runtime·schedinit(SB)//2.2 调用 runtime·newproc 创建一个协程//    并将 runtime.main 函数作为入口MOVQ	$runtime·mainPC(SB), AX		// entryPUSHQ	AXCALL	runtime·newproc(SB)POPQ	AX//2.3 启动线程,启动调度系统CALL	runtime·mstart(SB)

洋洋洒洒好几百行汇编代码,其实缩略完后,关键的核心逻辑就是上面几个关键点。

第一、通过 runtime 中的 osinit、schedinit 等函数对 golang 运行时进行关键的初始化。在这里我们将看到 GMP 的初始化,与调度逻辑。

第二、创建一个主协程,并指明 runtime.main 函数是其入口函数。因为操作系统加载的时候只创建好了主线程,协程这种东西还是得用户态的 golang 自己来管理。golang 在这里创建出了自己的第一个协程。

第三、调用 runtime·mstart 真正开启运行。

接下来我们分三个小节来详细了解下这三块的逻辑。

2.1 golang 核心初始化

golang 的核心初始化包括 runtime·osinit 和 runtime·schedinit 这两个函数。

在 runtime·osinit 中主要是获取CPU数量,页大小和 操作系统初始化工作。

// file:os_linux.go
func osinit() {ncpu = getproccount()physHugePageSize = getHugePageSize()osArchInit()
}

接下来是 runtime.schedinit 的初始化,这里主要是对调度系统的初始化。

在这个函数的注释中,也贴心地告诉了我们,golang 的 bootstrap(启动)流程步骤分别是 call osinit、call schedinit、make & queue new G 和 call runtime·mstart 四个步骤。这和我们前面说的一致。

Golang 中调度的核心就是 GMP 原理。这里我们不展开对 GMP 进行过多的说明,留着将来再细说。这里只提一下,在 runtime.schedinit 这个函数中,会将所有的 P 都给初始化好,并用一个 allp slice 维护管理起来。

// file:runtime/proc.go
// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {......// 默认情况下 procs 等于 cpu 个数// 如果设置了 GOMAXPROCS 则以这个为准procs := ncpuif n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {procs = n}// 分配 procs 个 Pif procresize(procs) != nil {throw("unknown runnable goroutine during bootstrap")}......
}

从上述源码中我们可以看到,P 的数量取决于当前 cpu 的数量,或者是 runtime.GOMAXPROCS 的配置。

不少 golang 的同学都有一种错误的认知,认为 runtime.GOMAXPROCS 限制的是 golang 中的线程数。这个认知是错误的。runtime.GOMAXPROCS 真正制约的是 GMP 中的 P,而不是 M。

再来简单看下 procresize,这个函数其实就是在维护 allp 变量,在这里保存着所有的 P。

// file:runtime/proc.go
// Change number of processors
// Returns list of Ps with local work, they need to be scheduled by the caller
func procresize(nprocs int32) *p {// 申请存储 P 的数组if nprocs > int32(len(allp)) {allp = ...}// 对新 P 进行内存分配和初始化,并保存到 allp 数组中for i := old; i < nprocs; i++ {pp := allp[i]if pp == nil {pp = new(p)}pp.init(i)atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))}...
}

2.2 golang 主协程的创建

汇编代码调用 runtime·newproc 创建一个协程,并将 runtime.main 函数作为入口。我们来看下第一个主协程是如何创建出来的。

//file:runtime/proc.go
func newproc(fn *funcval) {...systemstack(func() {newg := newproc1(fn, gp, pc)_p_ := getg().m.p.ptr()runqput(_p_, newg, true)if mainStarted {wakep()}})
}

systemstack 这个函数是 golang 内部经常使用的,runtime 代码经常通过调用 systemstack 临时性的切换到系统栈去执行一些特殊的任务。这里所谓的系统栈,就是操作系统视角创建出来的线程和线程栈。如果不理解,先不管这个也问题不大。

接着调用 newproc1 来创建一个协程出来,runqput 达标的是将协程添加到运行队列。最后的 wakep 是去唤醒一个线程去执行运行队列中的协程。

协程创建

我们一个一个分别来看。先看 newproc1 是如何创建协程的。

// file:runtime/proc.go
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {...//从缓存中获取或者创建 G 对象newg := gfget(_p_)if newg == nil {newg = malg(_StackMin)...}newg.sched.sp = spnewg.stktopsp = sp...newg.startpc = fn.fn...return newg
}

在 gfget 中是尝试从缓存中获取一个 G 对象出来。我们忽略这个逻辑,直接看 malg,因为它是创建一个 G。对我们理解更有帮助。在 malg 创建完后,对新的 gorutine 对象进行一些设置后就返回了。

在调用 malg 时传入了一个 _StackMin,这表示默认的栈大小,在 Golang 中的默认值是 2048。

这也就是很多人所说的 Golang 中协程很轻量,只需要消耗 2 KB 内存的缘由。但其实这个说法并不是很准确。首先这里分配的并不是 2KB,下面我们会看到还有有一些预留。另外当发生缺页中断的时候,Linux 是以 4 KB为单位分配的。

// file:runtime/proc.go
func malg(stacksize int32) *g {newg := new(g)if stacksize >= 0 {//这里会在 stacksize 的基础上为每个栈预留系统调用所需的内存大小 \_StackSystem//在 Linux/Darwin 上( \_StackSystem == 0 )本行不改变 stacksize 的大小stacksize = round2(_StackSystem + stacksize)}// 切换到 G0 为 newg 初始化栈内存systemstack(func() {newg.stack = stackalloc(uint32(stacksize))})// 设置 stackguard0 ,用来判断是否要进行栈扩容 newg.stackguard0 = newg.stack.lo + _StackGuardnewg.stackguard1 = ^uintptr(0)
}

在调用 malg 的时候会将传入的内存大小加上一个 _StackSystem 值预留给系统调用使用,round2 函数会将传入的值舍入为 2 的指数。然后会切换到 G0 执行 stackalloc 函数进行栈内存分配。分配完毕之后会设置 stackguard0 为 stack.lo + _StackGuard,作为将来判断是否需要进行栈扩容使用。

//file:runtime/stack.go
func stackalloc(n uint32) stack {thisg := getg()...//对齐到整数页n = uint32(alignUp(uintptr(n), physPageSize))v := sysAlloc(uintptr(n), &memstats.stacks_sys)return stack{uintptr(v), uintptr(v) + uintptr(n)}
}

其中栈是这样一个结构体

//file:runtime/runtime2.go
type stack struct {lo uintptrhi uintptr
}

sysAlloc 使用 mmap 系统调用来真正为协程栈申请指定大小的地址空间。

// file:runtime/mem_darwin.go
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {v, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)if err != 0 {return nil}sysStat.add(int64(n))return v
}

将协程添加到运行队列

在协程创建出来后,会调用 runqput 将它添加到运行队列中。在讲这块的逻辑之前,我们得首先讲讲 Golang 中的运行队列。

Golang 为什么会抽象出一个 P 来呢。这是因为 Golang 在 1.0 版本的多线程调度器的实现中,调度器和锁都是全局资源,锁的竞争和开销非常的大,导致性能比较差。

其实这个问题在 Linux 中早已很好地解决了。Golang 就把它学来了。在 Linux 中每个 CPU 核都有一个 runqueue,来保存着将来要在该核上调度运行的进程或线程。这样调度的时候,只需要看当前的 CPU 上的资源就行,把锁的开销就砍掉了。

所以,Golang 中的 P 可以认为是对 Linux 中 CPU 的一个虚拟,目的是和 Linux 一样,找一个无竞争地保管运行队列资源的方法。在 Golang 中,每个 P 都有它的运行队列。

3de42bdd5e3810d645cda5788ba0a971.png

理解了这个背景,我们再来看 Golang 中的 runqput 是如何将协程添加到 P 的运行队列中的。

// file:runtime/proc.go
// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the _p_.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(_p_ *p, gp *g, next bool) {...//将新 goroutine 添加到 P 的 runnext 中if next {retryNext:oldnext := _p_.runnextif !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {goto retryNext}if oldnext == 0 {return}// 将原来的 runnext 添加到运行队列中gp = oldnext.ptr()}//将新协程或者被从 runnext 上踢下来的协程添加到运行队列中
retry:h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumerst := _p_.runqtail//如果 P 的运行队列没满,那就添加到尾部if t-h < uint32(len(_p_.runq)) {_p_.runq[t%uint32(len(_p_.runq))].set(gp)atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumptionreturn}//如果满了,就添加到全局运行队列中if runqputslow(_p_, gp, h, t) {return}
}

在 runqput 中首先尝试将新协程放到 runnext 中,这个有优先执行权。然后会将新协程,或者被新协程从 runnext 上踢下来的协程加入到当前 P(运行队列)的尾部去。但还有可能当前这个运行队列已经任务过多了,那就需要调用 runqputslow 分一部分运行队列中的协程到全局队列中去。以便于减轻当前运行队列的执行压力。

唤醒一个线程去

前面只是将新创建的 goroutine 添加到了 P 的运行队列中。现在 GMP 中的 G 有了,P 也有了,就还差 M 了。真正的运行还是需要操作系统的线程去执行的。

// file:runtime/proc.go
func wakep() {...startm(nil, true)
}

wakep 核心是调用 startm。这个函数将会调度线程去运行 P 中的运行队列。如果有必要的话,可能也需要创建新线程出来。

// file:runtime/proc.go
// Schedules some M to run the p (creates an M if necessary).
func startm(_p_ *p, spinning bool) {mp := acquirem()//如果没有传入 p,就获取一个 idel pif _p_ == nil {_p_ = pidleget()}//再获取一个空闲的 mnmp := mget()if nmp == nil {//如果获取不到,就创建一个出来newm(fn, _p_, id)...return}...
}

2.3 启动调度系统

现在 GMP 中的三元素全具备了,而且主协程中的运行函数 fn 也指定为了 runtime.main。接下来就是调用 mstart 来启动线程,启动调度系统。

汇编中的 mstart 函数调用的是 golang 源码中的 mstart0

// file:runtime/proc.go
func mstart0() {...mstart1()
}
// file:runtime/proc.go
func mstart1() {...// 进入调度循环schedule()
}

其中,schedule 是整个 golang 程序的运行核心。所有的协程都是通过它来开始运行的。

schedule 的主要工作逻辑有这么几点

  1. 每隔 61 次调度轮回从全局队列找,避免全局队列中的g被饿死。

  2. 从 p.runnext 获取 g,从 p 的本地队列中获取。

  3. 调用 findrunnable 找 g,找不到的话就将 m 休眠,等待唤醒。

  4. 当找到一个 g 后,就会调用 execute 去执行 g

然后再来看源码就很容易理解了。

// file:runtime/proc.go
func schedule() {_g_ := getg()...
top:pp := _g_.m.p.ptr()//每 61 次从全局运行队列中获取可运行的协程if gp == nil {if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {lock(&sched.lock)gp = globrunqget(_g_.m.p.ptr(), 1)unlock(&sched.lock)}}if gp == nil {//从当前 P 的运行队列中获取可运行gp, inheritTime = runqget(_g_.m.p.ptr())}if gp == nil {//当前P或者全局队列中获取可运行协程//尝试从其它P中steal任务来处理//如果获取不到,就阻塞gp, inheritTime = findrunnable() // blocks until work is available}//执行协程execute(gp, inheritTime) 
}

其中 findrunnable 如果从当前 P 的运行队列和全局运行队列获取 G 都没有任务后,还会尝试从其它的 P 中获取一些任务过来运行。代码就不过多展示了。

三、main 函数的真正运行

至此,整个 golang 的调度系统就算是跑起来了。因为前面我们创建了主协程,而且还给它设置了 runtime.main 函数作为入口。所以对于主协程的调度,就会进入这个入口进行执行。终于,能看到 runtime 快运行到我们自己写的 main 函数中了。

runtime.main 在执行 main 包中的 main 之前,还是做了一些不少其他工作,包括:

  1. 新建一个线程来执行 sysmon。sysmon的工作是系统后台监控(定期垃圾回收和调度抢占)。

  2. 执行 runtime init 函数。runtime 包中也有不少的 init 函数,会在这个时机运行

  3. 启动 gc 清扫的 goroutine。

  4. 执行 main init 函数。包括用户定义的所有的 init 函数。

  5. 执行用户 main 函数。

// file:runtime/proc.go
// The main goroutine.
func main() {g := getg()// 在系统栈上运行 sysmonsystemstack(func() {newm(sysmon, nil, -1)})// runtime 内部 init 函数的执行,编译器动态生成的。doInit(&runtime_inittask) // Must be before defer.// gc 启动一个goroutine进行gc清扫gcenable()// 执行main initdoInit(&main_inittask)// 执行用户mainfn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtimefn()// 退出程序exit(0)
}

好了,终于,我们定义的 main 函数能被执行到。可以输出 “Hello World!”了。

四、总结

Golang 程序的运行入口是 runtime 定义的一个汇编函数。这个函数核心有三个逻辑:

第一、通过 runtime 中的 osinit、schedinit 等函数对 golang 运行时进行关键的初始化。在这里我们将看到 GMP 的初始化,与调度逻辑。

第二、创建一个主协程,并指明 runtime.main 函数是其入口函数。因为操作系统加载的时候只创建好了主线程,协程这种东西还是得用户态的 golang 自己来管理。golang 在这里创建出了自己的第一个协程。

第三、调用 runtime·mstart 真正开启调度器进行运行。

当调度器开始执行后,其中主协程会进入 runtime.main 函数中运行。在这个函数中进行几件初始化后,最后后真正进入用户的 main 中运行。

第一、新建一个线程来执行 sysmon。sysmon的工作是系统后台监控(定期垃圾回收和调度抢占)。
第二、启动 gc 清扫的 goroutine。
第三、执行 runtime init,用户 init。
第四、执行用户 main 函数。

看似简简单单的一个 Golang 的 Hello World 程序,只要你愿意深挖,里面真的有极其丰富的营养的!

如果觉得有用,期待和给你的朋友一起分享~

e9b2f9bec5ac14497d74dde450095d2e.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/116027.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

K 次取反后最大化的数组和【贪心算法】

1005 . K 次取反后最大化的数组和 给你一个整数数组 nums 和一个整数 k &#xff0c;按以下方法修改该数组&#xff1a; 选择某个下标 i 并将 nums[i] 替换为 -nums[i] 。 重复这个过程恰好 k 次。可以多次选择同一个下标 i 。 以这种方式修改数组后&#xff0c;返回数组 可能…

打击儿童性虐待,遭多家机构反对,苹果宣布停止开发CSAM检测计划

据报道&#xff0c;苹果公司曾计划在其iCloud云服务中引入一项儿童性虐待资料&#xff08;CSAM&#xff09;检测计划&#xff0c;但由于反对声浪日益高涨&#xff0c;该计划最终宣布停止开发。CSAM检测计划的原本目的是为了帮助阻止儿童性虐待资料的传播&#xff0c;保护儿童的…

Go操作各大消息队列教程(RabbitMQ、Kafka)

Go操作各大消息队列教程 1 RabbitMQ 1.1 概念 ①基本名词 当前市面上mq的产品很多&#xff0c;比如RabbitMQ、Kafka、ActiveMQ、ZeroMQ和阿里巴巴捐献给Apache的RocketMQ。甚至连redis这种NoSQL都支持MQ的功能。 Broker&#xff1a;表示消息队列服务实体Virtual Host&#x…

关于购买AirPods,现在是否为最佳时机?

我们不需要解释你为什么想要AirPods。苹果对真正的无线耳机的采用彻底改变了市场&#xff0c;并从那时起大量销售。你总是在记者、同事和名人的耳朵里看到它们——尤其是在我们这个远程工作和Zoom会议的时代。 真正的问题是&#xff0c;你应该现在就买一个&#xff0c;还是在几…

MySQL索引,事务和存储引擎

一、索引 1、索引的概念 ●索引是一个排序的列表&#xff0c;在这个列表中存储着索引的值和包含这个值的数据所在行的物理地址&#xff08;类似于C语言的链表通过指针指向数据记录的内存地址&#xff09;。 ●使用索引后可以不用扫描全表来定位某行的数据&#xff0c;而是先…

博流RISC-V芯片Eclipse环境搭建

文章目录 1、下载 Eclipse2、导入 bouffalo_sdk3、编译4、烧录5、使用ninja编译 之前编译是通过 VSCode 编译&#xff0c;通过手工输入 make 命令编译&#xff0c;我们也可以通过 Eclipse 可视化 IDE 来编译、烧录。 1、下载 Eclipse 至 Eclipse 官网 https://www.eclipse.org…

IntelliJ IDEA 2023.2.1 Android开发变化

IntelliJ IDEA 2023.2.1之前的版本&#xff0c;Empty Activity是指Empty View Activity&#xff0c;而现在Empty Activity是指Empty Compose Activity&#xff0c;另外多了一个Empty View Activity的选项 这表明官方推荐使用Compose这种声明式的编程方式来描述UI&#xff0c;命…

springboot上线打包+vuecli2部署在linux服务器上(打包上线)

这里也是记录一下springboot的上线打包流程,我这里前端使用的是vuecli2 springboot的依赖是2.7.9的版本 前端是使用的vue2 打包前,你的linux上必须要先安装,tomcat\java\nginx springboot打包 springboot打包点击一下,等maven编译打包成功在target文件下找到,jar包, 然后,把j…

C语言:指针的运算

一、指针 或 - 整数 指针 或 - 整数表示指针跳过几个字节&#xff08;具体跳过几个字节由指针类型决定&#xff09; 本文不做具体讲解&#xff0c;详解跳转链接&#xff1a; 《C语言&#xff1a;指针类型的意义》 二、指针 - 指针 前提条件&#xff1a;指针类型相同并且指向同…

[学习笔记]斜率优化dp 总结

前言&#xff1a; 我们学过不少优化类的算法了&#xff0c;大部分都是基于凸函数的性质给出的优化&#xff0c;比如Slope Trick&#xff0c;Wqs二分&#xff0c;又比如今天的斜率优化&#xff08;不知道什么时候会有空把Slope Trick写掉&#xff09; 正文&#xff1a; 我们考…

应用案例 | 基于三维机器视觉的机器人麻袋拆垛应用解决方案

​Part.1 项目背景 在现代物流和制造行业中&#xff0c;麻袋的拆垛操作是一个重要且频繁的任务。传统的麻袋拆垛工作通常由人工完成&#xff0c;分拣效率较低&#xff0c;人力成本较高&#xff0c;现场麻袋堆叠、变形严重&#xff0c;垛型不规则、不固定&#xff0c;严重影响分…

拓世科技集团 | “书剑人生”李步云学术思想研讨会暨李步云先生九十华诞志庆

2023年&#xff0c;中国改革开放迎来了45周年&#xff0c;改革春风浩荡&#xff0c;席卷神州大地&#xff0c;45年间&#xff0c;中国特色社会主义伟大事业大步迈入崭新境界&#xff0c;一路上结出了饶为丰硕的果实。中华民族在这45年间的砥砺前行&#xff0c;不仅使中国的经济…

解决uniapp下拉框 内容被覆盖的问题

1. 下拉框 内容被覆盖的问题 场景: 现在是下拉框被表格覆盖了 解决办法: 在表格上添加css 样式来解决这个问题 .add-table{display: static;overflow: visible; } display: static: 将元素会按照默认的布局方式进行显示&#xff0c;不会分为块状或行内元素。 overflow: vi…

【用unity实现100个游戏之7】从零开始制作一个仿杀戮尖塔卡牌回合制游戏

文章目录 前言素材资源开始一、UI框架二、挂载脚本三、事件监听&#xff0c;用于绑定按钮事件四、声音管理器五、excel转txt文本六、游戏配置七、用户信息表八、战斗管理器九、 敌人管理器十、玩家血量、能量、防御值、卡牌数十一、敌人血量 行动显示逻辑十二、UI提示效果实现十…

PHP旅游管理系统Dreamweaver开发mysql数据库web结构php编程计算机网页

一、源码特点 PHP 旅游管理系统是一套完善的web设计系统&#xff0c;对理解php编程开发语言有帮助&#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采用B/S模式开发。 PHP 旅游管理系统 源码下载地址&#xff1a; https://download.csdn.net/download/qq_41…

Docker切换文件系统为VFS

一、介绍 Docker支持AUFS、Btrfs、Device Mapper、OverlayFS、VFS、ZFS六种不同的存储驱动。 1. AUFS AUFS是一种常见的存储驱动程序&#xff0c;它也使用了Linux内核的AUFS文件系统。它的优点是支持所有的Linux发行版&#xff0c;可以在不同的容器之间共享文件系统&#xf…

JVM 对象的内存布局

对象头 Mark word 标记字段 用于存储对象自身的运行时数据&#xff0c;如哈希码&#xff08;HashCode&#xff09;、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等 ClassPoint 类型指针 对象指向它的类型元数据的指针&#xff0c;Java虚拟机通过这个指针 来…

【STM32】学习笔记(TIM定时器)

TIM&#xff08;Timer&#xff09;定时器 定时器可以对输入的时钟进行计数&#xff0c;并在计数值达到设定值时触发中断 16位计数器、预分频器、自动重装寄存器的时基单元&#xff0c;在72MHz计数时钟下可以实现最大59.65s的定时 不仅具备基本的定时中断功能&#xff0c;而且…

【MySQL】基础知识(二)

MySQL基础知识(二) 文章目录 MySQL基础知识(二)01 表操作1.1 创建表1.2 查看所有表1.3 查看指定表的结构1.4 删除表练习 02 CURD2.1 新增2.1.1 指定列插入2.1.2 datetime类型插入 2.2 查询2.2.1 全列查询2.2.2 指定列查询2.2.3 查询字段为表达式2.2.4 别名查询2.2.5 去重2.2.6 …

面试被打脸,数据结构底层都不知道么--回去等通知吧

数据结构之常见的8种数据结构&#xff1a; -数组Array -链表 Linked List -堆 heap -栈 stack -队列 Queue -树 Tree -散列表 Hash -图 Graph 数据结构-链表篇 Linklist定义&#xff1a; -是一种线性表&#xff0c;并不会按线性的顺序存储数据&#xff0c;即逻辑上相邻…