news 2026/10/22 5:37:41

Linux共享内存实战:从mmap到零拷贝,解锁高性能进程间通信

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux共享内存实战:从mmap到零拷贝,解锁高性能进程间通信

写 Linux 下多进程程序的人,迟早会被一个问题逼疯:两个进程之间,到底怎么交换数据才又快又稳?管道、消息队列、信号、本地 socket,这些东西文档上都写着“进程间通信”,可真到了要传几十上百 MB 的数据、或者要让多个进程同时维护一份不断变化的状态结构时,你会明显感觉它们要么慢、要么别扭。兜兜转转用了好几年共享内存,我现在可以很直接地说:在 Linux 系统编程里,共享内存是我默认优先考虑的一招。这篇文章就把我从接口到原理、从 Demo 到踩坑的全套心得倒出来,给正在学共享内存、或者准备在项目里真正用它的人做一份能直接参考的实战笔记。

1. 管道、消息队列、共享内存:传输代价与适用场景

1.1 数据拷贝次数决定 IPC 的下限

在选进程间通信方案之前,我建议你先算一笔账:数据从进程 A 到进程 B,中间到底经过了几次拷贝。这个数字几乎决定了方案的性能上限,因为内存拷贝是纯带宽操作,再优化也顶多无限逼近 memcpy 的速度。

管道和消息队列都属于“内核中转”型方案。进程 A 要发送数据,先把数据从用户态缓冲区拷贝到内核缓冲区,进程 B 接收时再把数据从内核缓冲区拷贝回用户态缓冲区,一次通信至少两次完整拷贝。Socket 走 loopback 时也是类似路径,本地 TCP 虽然在内核里做了不少优化,但本质上并没有绕开内核转发这层。信号能传递的信息量又太小,通常只适合做事件通知,不适合传业务数据。

共享内存不一样。它把同一块物理内存同时映射到多个进程的地址空间,之后进程 A 写数据、进程 B 读数据,都是在各自的用户态地址上直接操作,零次内核拷贝。这不是“接近零拷贝”,而是真正的零拷贝,因为数据本身就在你我共用的那块内存里,压根不需要搬动。等到进程 B 需要“收到”数据,它做的只是读自己地址空间里的某个地址而已。

所以我的经验判断很直接:只要数据量大、频率高、结构复杂,共享内存基本就是最优解。比如多个 worker 进程共享一份配置、一个实时行情结构、一段环形缓冲日志,这些场景用管道和 socket 会先死在拷贝上,用共享内存则是水到渠成。

1.2 共享内存不是万能药

但我得说句公道话,共享内存的“零拷贝”优势是有代价的。最大的代价是:内核不再帮你做同步。管道是有缓冲的,写端和读端天然有一层数据流控制;socket 有协议栈帮你维护连接状态。共享内存只是给了你一块公共区域,谁先写、谁后读、写了一半被读走了怎么办,这些问题内核一律不管,你必须自己设计同步机制,信号量、原子操作、自旋锁都得自己上。

共享内存的第二个代价是生命周期管理。管道随进程消亡自动清理,socket 关闭连接就结束。共享内存对象可不是,进程崩溃了、忘记调删除接口了,对象可能继续残留在系统里,要么占据内存,要么污染下一次启动的名字空间。这些坑我在文章后面会专门讲。

一句话总结这个章节:共享内存解决的是“传得快”的问题,它把“传得对”的问题抛回给了你。能用好它,你就掌握了 Linux 系统编程里最锋利的一把刀。

2. 一张页表把两个进程连起来

2.1 虚拟地址与物理页的映射关系

要理解共享内存为什么快,得先搞清楚 Linux 内存管理的底层模型。每个进程都有自己的虚拟地址空间,虚拟地址通过 CPU 的页表翻译成物理地址。页表是一级一级查的,但最终都是同一个结论:虚拟页指向物理页。

共享内存干的事情极其朴素——让多个进程的页表里,某些虚拟页指向同一批物理页。进程 A 在自己地址空间的 0x7f00... 处写入数据,本质是写到了某块物理内存;进程 B 虽然在自己的地址空间里看到的是另一个虚拟地址,但它的页表也指向这块物理内存。于是 A 写完,B 什么都不用做就能看到,这就是“共享”二字的全部秘密。

这里有个容易误解的点:mmap 或者 shmat 返回的地址,在不同进程里是完全不同的数值,千万不要以为两个进程会得到同一个虚拟地址。各进程看到的虚拟地址不一样,但背后的物理页是同一块。如果有人写出“把共享内存指针直接传给另一个进程”的代码,那个指针在对方进程里是无效的,跑起来就是段错误。

2.2 MAP_SHARED 与 MAP_PRIVATE 的区别

用 mmap 映射共享内存时,flags 参数只有两个选择:MAP_SHARED 或 MAP_PRIVATE。这两个词已经把行为说得很直白了。

MAP_SHARED 是说:我对这块映射的任何写入,都会直接落到背后的物理页上,别的进程立刻可见,并且可能写回到文件(如果是文件映射的话)。这是共享内存必须用的标志。

MAP_PRIVATE 是另一种完全不同的语义:映射刚建立时大家确实共享同一份物理页,但一旦任何一个进程对某个页面执行写入,内核就会触发“写时复制”(Copy-On-Write),把那个页复制一份给写入方,从此各走各路。这常用于父子进程共享只读代码段,或者实现 fork 的懒拷贝优化。如果你用 MAP_PRIVATE 做进程间通信,别人写的数据你大概率看不到,这是一个很隐蔽的逻辑 bug。

顺手提一个开发中很常用的小技巧:如果只在父子进程之间共享内存,根本不需要 shm_open 创建命名对象,直接用匿名映射就行:

void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

匿名映射没有文件对象,只能通过 fork 被子进程继承。父子进程 fork 之后看到的是同一块物理内存,配合上信号量就是一套极简 IPC。缺点是没有名字,无法让“无关”的两个进程找到它。

2.3 为什么“零拷贝”仍然要小心缓存一致性

说到“零拷贝”,有朋友可能会问:既然数据在两个进程里都可见,是不是写完立刻就能被读到?这里有一个现代 CPU 架构下的坑。

现在的多核 CPU 每个核都有自己的 L1/L2 缓存,不同核看到的内存视图可能短暂不一致。x86 因为内存模型比较强,大多数普通读写会在硬件层面自动保证最终一致;但 ARM 等弱内存模型的架构上,内存屏障是必须考虑的东西。更麻烦的是编译器优化,它可能把共享变量的值搬到寄存器里反复使用,完全意识不到这个变量会被另一个进程修改。

所以在共享内存里读写共享字段,绝对不要用普通变量的裸读写。要么用信号量这类内核同步原语把访问包起来,要么用 C11 的原子操作,要么在关键位置显式加内存屏障。把共享内存当成一块普通堆内存来用的代码,基本都会在 -O2 优化下给你表演一场“灵异事件”:别的进程明明写了,这边死活看不到。

3. System V 与 POSIX 两套接口怎么选

3.1 两套接口的编程差异

Linux 提供两套共享内存 API,这算历史遗留。教材里常见的是 System V 那套:shmget、shmat、shmdt、shmctl。它用整数 key 作为对象的标识,key 可以由程序员硬编码,也可以用 ftok 根据文件路径和项目编号生成。核心调用长这样:

#include <sys/ipc.h> #include <sys/shm.h> #include <string.h> #define SHM_KEY 0x5201 int main(void) { int shmid = shmget(SHM_KEY, 4096, IPC_CREAT | 0666); if (shmid < 0) { perror("shmget"); return 1; } void *p = shmat(shmid, NULL, 0); if (p == (void *)-1) { perror("shmat"); return 1; } strcpy((char *)p, "hello from process A"); shmdt(p); return 0; }

shmget 拿到的是共享内存段的 id,shmat 把这个段挂到自己地址空间,返回可用指针,shmdt 解除挂接。要删除段则调 shmctl(shmid, IPC_RMID, NULL)。这套接口年代久远,行为细节比较多,比如 shmat 返回 -1 而不是 NULL 表示失败,很多人第一次写都会在这里栽跟头。

POSIX 那套看起来更现代,因为它把共享内存表达成了一个“文件对象”。shm_open 打开或创建一个名字以斜杠开头的对象,返回一个文件描述符,ftruncate 设定大小,mmap 映射到地址空间。同样的“写一句话”逻辑,POSIX 版本长这样:

#include <fcntl.h> #include <sys/mman.h> #include <unistd.h> #include <string.h> #define SHM_PATH "/hello_shm" #define SHM_SIZE 4096 int main(void) { int fd = shm_open(SHM_PATH, O_CREAT | O_RDWR, 0666); if (fd < 0) { perror("shm_open"); return 1; } if (ftruncate(fd, SHM_SIZE) != 0) { perror("ftruncate"); return 1; } void *p = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap"); return 1; } close(fd); strcpy((char *)p, "hello from process A"); munmap(p, SHM_SIZE); shm_unlink(SHM_PATH); return 0; }

注意 mmap 返回 MAP_FAILED 而不是 NULL,这是一个特别容易写错的细节。另外 shm_open 之后 fd 可以立刻 close,因为映射已经持有底层对象,这是文件映射的统一语义。

3.2 对比表

两套接口的差异,我整理成了一张表,方便直接对照:

维度System V(shmget 系列)POSIX(shm_open 系列)
对象标识key_t 整数 key以 / 开头的字符串名字
句柄类型int shmidint fd
地址映射shmatmmap
大小设定shmget 创建时固定ftruncate 随时可调
删除接口shmctl IPC_RMIDshm_unlink
查看对象ipcs -mls -l /dev/shm
权限控制shmflg 里的 modeopen mode,受 umask 影响
新代码推荐度低,多为存量项目高,接口与文件操作统一

POSIX 版本最大的好处是对象名有实际意义,ls /dev/shm一下就能看到系统里有哪些共享内存对象,排查问题直观得多。System V 的整数 key 只能靠 ipcs 去猜,团队协作时还容易 key 撞车,两个项目用了同一个 key 互相串数据,那种问题排查起来非常酸爽。

3.3 我的选型建议

新写的代码,我的建议是无脑选 POSIX。理由有三条:第一,接口统一,学会了 mmap 就等于会了文件映射、匿名映射、共享内存三件事;第二,名字可读性强,运维和调试都友好;第三,ftruncate 可以动态调整大小,System V 的段大小一旦 shmget 就固定了,改大小只能删了重建。

什么时候还值得用 System V?一种情况是你接手老项目,现有代码全是 shmget,没必要引入两套机制;另一种情况是你需要用到 System V 特有的某些语义,比如 shmctl 的 IPC_SET 修改权限、共享内存段与进程的关联关系查询,这些在 POSIX 版本里没有直接对应物。除此之外,写新代码就别给自己添麻烦了。

4. 一个可以直接抄的共享计数器 Demo

4.1 完整代码

理论讲再多,不如一个能跑的 Demo 实在。我用一个经典场景演示完整链路:父进程创建共享内存,fork 两个子进程,每个子进程对同一个计数器自增一百万次,父进程等待两个子进程结束后打印最终值。程序用一个编译开关控制是否加锁,这样既能演示正确的同步写法,也能直观看到不加锁的后果。

#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <sys/mman.h> #include <sys/stat.h> #include <sys/wait.h> #include <unistd.h> #include <semaphore.h> #ifndef USE_LOCK #define USE_LOCK 0 #endif #define LOOP_N 1000000 #define SHM_PATH "/counter_demo" #define SEM_PATH "/counter_demo_sem" typedef struct { long counter; } shared_data; int main(void) { sem_t *sem = NULL; if (USE_LOCK) { sem = sem_open(SEM_PATH, O_CREAT, 0666, 1); if (sem == SEM_FAILED) { perror("sem_open"); exit(EXIT_FAILURE); } } int fd = shm_open(SHM_PATH, O_CREAT | O_RDWR, 0666); if (fd < 0) { perror("shm_open"); exit(EXIT_FAILURE); } if (ftruncate(fd, sizeof(shared_data)) != 0) { perror("ftruncate"); exit(EXIT_FAILURE); } shared_data *d = mmap(NULL, sizeof(shared_data), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (d == MAP_FAILED) { perror("mmap"); exit(EXIT_FAILURE); } close(fd); for (int i = 0; i < 2; i++) { pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(EXIT_FAILURE); } if (pid == 0) { for (int j = 0; j < LOOP_N; j++) { if (USE_LOCK) { sem_wait(sem); d->counter++; sem_post(sem); } else { d->counter++; } } _exit(0); } } while (wait(NULL) > 0) ; printf("final counter = %ld, expected = %d%s\n", d->counter, 2 * LOOP_N, USE_LOCK ? " (protected by sem)" : " (no lock)"); munmap(d, sizeof(shared_data)); shm_unlink(SHM_PATH); if (sem) { sem_close(sem); sem_unlink(SEM_PATH); } return 0; }

这段代码里有几个细节值得说明。shm_open 用 O_CREAT | O_RDWR 创建对象,0666 是权限;ftruncate 把对象大小设定为结构体大小,这一步漏了的话,mmap 虽然能成功,但一访问对象末尾之外的内存就直接 SIGBUS。fork 之后每个子进程都继承了共享内存映射和 sem_t 指针,它们看到的是同一个物理页。子进程结束我用 _exit 而不是 exit,避免冲刷父进程的 stdio 缓冲。

4.2 编译与运行效果

编译时只需要加 -pthread,因为 sem_open 系列函数依赖 pthread 库符号。注意不同 glibc 版本对 -lrt 的要求不一样,glibc 2.34 之前 shm_open 需要 -lrt,新版本已经并入 libc。我建议直接两条编译命令都试一下,哪个能过用哪个:

gcc -O2 -o shm_nolock shm_fork_demo.c -pthread gcc -O2 -DUSE_LOCK=1 -o shm_lock shm_fork_demo.c -pthread

运行效果是这样的,我在普通 x86 机器上跑出来的结果:

$ ./shm_nolock final counter = 1094832, expected = 2000000 (no lock) $ ./shm_lock final counter = 2000000, expected = 2000000 (protected by sem)

加锁版本稳定得到两百万,不加锁版本每次跑出来的数字都不一样。这个差距就是共享内存同步必要性的最直观证明。

4.3 没有锁会发生什么

很多人第一次看到不加锁的结果会觉得奇怪:两个进程都是执行d->counter++,这条语句翻译成机器指令至少三步——读出 counter、加一、写回。两个进程同时执行时,完全可能发生 A 读出 100,B 也读出 100,A 写回 101,B 也写回 101,结果明明加了两次,计数器只长了 1。这就是经典的 read-modify-write 竞争,俗称丢更新。一百万次自增下来,丢掉的更新次数相当可观。

信号量在这里的作用是把“读出、加一、写回”这三步变成原子操作。sem_wait 会把值减到 0 并锁住临界区,另一个进程再 sem_wait 就得老老实实排队,等 sem_post 释放之后才能进来。这是个简单粗暴但绝对可靠的做法。代价是锁竞争会拖慢吞吐,如果你追求极致性能,可以把计数器换成atomic_long,用atomic_fetch_add做无锁自增,效果也正确。

5. 高频翻车现场:权限、泄漏、可见性与内核参数

5.1 生命周期与“删不掉”的共享内存

共享内存对象创建之后,它的生命周期和你进程的生命周期是解耦的。System V 的段在进程退出后依然存在于内核里,直到有人调用 shmctl IPC_RMID 或系统重启。POSIX 对象也有同样的问题,shm_unlink 之前,文件对象会一直躺在 /dev/shm 里,即使所有进程都退出了。

这就带来一个很常见的事故:程序上次崩溃前没有清理共享内存,这次启动时 shm_open 拿到的是一个残留的旧对象,里面的数据是半年前的脏数据,初始化逻辑一不小心就会读到垃圾值。更隐蔽的是 System V 的删除语义——调用 shmctl IPC_RMID 并不会立刻销毁段,只是标记“待删除”,已经 attach 的进程还能继续使用,等所有进程都 detach 之后段才真正消失。如果你在这个段里又新建了另一个进程去 shmat,它会失败。

我的清理习惯是这样的:程序启动时如果准备创建对象,就先用 shm_unlink(或 IPC_RMID)尝试清理旧的同名对象,再重新创建;正常退出路径上写好清理代码,同时做好“即使没清理也不会炸”的容忍逻辑,比如在共享内存头结构里放一个 magic number 和版本号,每次启动时校验,对不上就当作无效数据重置。这套做法能挡住百分之八十的重启脏数据问题。

5.2 权限、umask 与名字规范

shm_open 的 mode 参数和 open 一样受 umask 影响。你写了 0666,但进程的 umask 是 0022,实际生效权限就是 0644,另一个用户启动的进程想写这个共享内存时直接 Permission denied。不少团队踩过这个坑,排查半天发现不是代码逻辑问题,就是 umask 没处理。需要放松权限时,可以在启动脚本里显式设置 umask 0,或者统一约定共享内存对象的权限位,再配合文件系统 ACL 管理。

另外 POSIX 共享内存对象的名字有硬性规范:必须以单个斜杠开头,且不能包含其他斜杠。/counter_demo合法,demo不合法,/a/b也不合法。名字长度也不能超过 NAME_MAX,否则打开失败。团队项目里建议给对象名加统一前缀,比如/team_project_xxx,防止多个服务之间无意撞名。

System V 那边也有类似的坑。key 如果硬编码,两个项目用了同一个 key,shmget 会返回同一个段的 id,双方无意识地共享了数据。更合理的做法是用 ftok 从文件路径生成 key,但要记住 ftok 依赖文件的 inode 和设备号,文件被删除重建后 key 就变了,依赖 ftok 的旧进程和新进程会各说各话。

5.3 编译器优化与内存可见性

这一节可能是共享内存最容易出“灵异事件”的地方。一个很典型的例子:两个进程共享一个 int 标志位,进程 A 写标志位通知进程 B 退出,进程 B 的主循环里用 while (!flag) 空转等待。这段代码换成 -O0 编译能正常工作,换成 -O2 就死循环,读一百遍都看不到 flag 变化。

原因是编译器看到这个循环里 flag 没有在代码层面被修改,就把它的值从内存里读一次放进寄存器,之后一直用寄存器里的旧值判断,彻底不碰内存了。要解决这个问题,共享变量必须声明成_Atomic类型,或者用atomic_load/atomic_store访问,再或者依赖信号量这种内核原语隐式提供的内存屏障。普通volatile只能阻止编译器优化,不能提供 CPU 级别的原子性和可见性保证,所以别指望用它扛大旗。

我自己写共享内存数据结构时的原则是:能不加锁就不用锁,但能保证正确性的原子操作必须用足;任何共享字段都不要用裸 int 读写,一律通过 stdatomic 接口。这套习惯换来的是,线上服务跑再久也不会因为共享内存可见性问题出现诡异数据。

5.4 伪共享与性能排查

当性能达不到预期时,检查一下“伪共享”常常有惊喜。伪共享是指两个变量逻辑上无关,但恰好落在同一条 64 字节的缓存行里,两个进程分别频繁写这两个变量,导致缓存行在多个核之间反复失效,性能断崖式下跌。

比如你定义了一个共享结构,包含 hit_count 和 miss_count 两个计数,两个进程一个只写 hit,一个只写 miss,它们其实不需要同步,但 CPU 缓存按行管理,只要任何一方写了这个缓存行里的任何字节,整行都要从别的核上失效,双方互相拖累,速度可能慢几十倍。解决办法很暴力:把需要各自独立写的字段按 64 字节对齐填充,让它们落在不同的缓存行。我的 Demo 里的结构体只放了一个 long counter,其实最干净的做法是继续填充到 64 字节,只是演示代码为了可读性没这么做,实际项目里别忘了这一手。

排查性能问题时,先看是不是锁竞争激烈,再看有没有伪共享,最后才考虑改算法。用perf看 cache-misses 事件,通常能快速定位问题所在。

5.5 内核参数调优与调试命令

共享内存也受内核参数约束。System V 三个最常碰到的参数,都在 /proc/sys/kernel/ 下面,路径和含义如下:

参数含义默认值参考
shmmax单个共享内存段最大字节数通常较大,32 位系统上可能受限
shmall系统内共享内存页总数上限一般够用
shmmni系统内共享内存段数量上限默认 4096

如果 shmget 创建超大段时报 EINVAL,先看是不是超过了 shmmax;报 ENOSPC 再看是不是段数量或者页总数到了上限。临时生效可以直接写 /proc/sys/kernel/shmmax,永久生效则要写进 /etc/sysctl.conf。POSIX 共享内存实质落在 /dev/shm 这个 tmpfs 上,它的总容量受 tmpfs 挂载参数 size 限制,用df -h /dev/shm就能看到,需要扩容就修改 /etc/fstab 里 tmpfs 的 size 选项再重新挂载。

调试和运维命令我平时用得很勤,整理成一张速查表:

需求命令
查看 System V 共享内存段ipcs -m
查看段与进程的关联ipcs -p
删除指定共享内存段ipcrm -m
查看 POSIX 共享内存对象ls -l /dev/shm
删除 POSIX 共享内存对象rm /dev/shm/
跟踪 System V 共享内存调用strace -e trace=shmget,shmat,shmdt,shmctl ./a.out
跟踪 POSIX 共享内存调用strace -e trace=mmap,ftruncate,openat ./a.out

最后再分享一个实战体会

我个人的经验是,共享内存在 Linux 系统编程里属于那种“上限极高、下限也极低”的技术。用得好,它能让你在毫无锁竞争的情况下做出极高吞吐的进程间数据交换;用得糙,权限、生命周期、缓存一致性、名字冲突,每个点都能让你调一整天。建议所有准备在项目中使用共享内存的朋友,开工前先把生命周期和同步方案画清楚,谁创建、谁清理、谁负责加锁、崩溃了怎么恢复,这几个问题想明白了再写代码。把这套功夫练好之后,你会和我一样,在遇到进程间通信需求时第一时间想到它。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 5:37:06

JWT认证实战指南:原理、签名算法与安全排坑

1. 项目概述与核心价值1.1 我为什么会专门聊JWT先说说我自己的经历。做后端开发这些年&#xff0c;几乎每个Web项目都会遇到登录鉴权的问题。早期用Session方案&#xff0c;后来维护微服务拆分的项目多了&#xff0c;Session同步就成了麻烦事。直到某次做项目重构&#xff0c;技…

作者头像 李华
网站建设 2026/10/22 5:37:05

从测试脚本到质量工程:Harness与Loop双轮驱动实践

1. 先搞清楚&#xff1a;为什么质量团队开始讨论"工程化"这件事前阵子我帮某团队做质量评审&#xff0c;负责人拍着胸脯说"我们有全套自动化测试"。结果打开仓库一看&#xff0c;几百个脚本散落在各个模块目录里&#xff0c;测试数据靠手工拷贝&#xff0c…

作者头像 李华
网站建设 2026/10/22 5:35:02

CMake+vcpkg+cline三件套:构建Windows C++高效开发流水线

在Windows上做C个人开发&#xff0c;早年间是件让人又爱又恨的事。爱的是性能和控制力&#xff0c;恨的是环境配置、依赖安装、构建脚本这三座大山。我见过太多人&#xff0c;写代码一小时&#xff0c;配环境一整天&#xff0c;最后连库都没链上&#xff0c;直接在控制台里跟LN…

作者头像 李华
网站建设 2026/10/22 5:34:56

模板代码安全审计:从硬编码密钥到自动化扫描的实践指南

接到这个任务时&#xff0c;我的第一反应是把它当作一次普通的代码审查看待&#xff0c;可真坐下来之后才发现&#xff0c;模板代码的安全审计和常规代码审计完全是两回事。所谓的模板代码安全审计&#xff0c;指的是对项目脚手架、代码生成器输出&#xff0c;以及团队内部公共…

作者头像 李华
网站建设 2026/10/22 5:34:38

LeetCode 739 每日温度:单调栈详解与机考实战

机考刷题这个系列写到第8篇&#xff0c;终于轮到一道让人“一拍大腿”的经典题了&#xff1a;LeetCode 739 每日温度。说它是经典&#xff0c;不是因为它有多难&#xff0c;而是因为它把“单调栈”这个数据结构讲得特别生动——你甚至不需要背模板&#xff0c;跟着题意走一遍&a…

作者头像 李华
网站建设 2026/10/22 5:34:36

Java开发者AI转型指南:框架停更背后的真相与出路

最近各个技术群里又开始流传同一张截图&#xff0c;大意是某个与Spring AI相关的国内开源集成项目&#xff0c;提交记录已经停了好几个月&#xff0c;issues里催更新的声音越来越多&#xff0c;于是"Spring AI Alibaba已停更"这个说法迅速传开。作为一个从SSH时代一路…

作者头像 李华