ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux线程ID与地址空间布局:从pthread_t到内核TID完全解读

2026/10/5 3:41:41 拓冰建站 浏览量
Linux线程ID与地址空间布局:从pthread_t到内核TID完全解读 1. 线程ID的本质从数字困惑说起接触过Linux多线程开发的人十有八九被线程ID这个概念绕晕过。我自己带过不少新人几乎每个人第一次在代码里打印线程ID都会遇到一个让我哭笑不得的情况pthread_create返回一个tidpthread_self()又打印出一个id用ps -ef看又冒出另一个数字三个数互相都对不上然后就来问我哪个才是真正的线程ID。其实这不是他们的错是Linux的线程模型本身就够复杂。Linux线程不是传统意义上的线程它背后是以轻量级进程LWPLightweight Weight Process的方式实现的内核调度器根本不区分进程和线程它眼里只有task_struct也就是任务。这就导致一套系统里同时存在好几种ID用户态的pthread_t、内核态的TID、进程组ID甚至是TGID它们各司其职却又容易被混淆。这篇文章是Linux线程概念与控制系列的第三篇专门把线程ID的本质和地址空间布局这两件事掰开揉碎讲清楚。读完你至少能回答三个问题pthread_t是一个什么样的类型为什么说线程ID可能被复用以及多个线程共享同一个进程地址空间时哪些区域是公共的、哪些是私有的。无论你是在排查一个诡异的段错误还是想深入理解线程栈和堆的关系这篇都有参考价值。1.1 两个线程IDpthread_t和gettid()的纠缠先从最让人头大的点说起用户态库函数pthread_self()返回的是一个pthread_t类型这是一个不透明类型glibc实现里它实际上是一个unsigned long本质上是指向线程控制块struct pthread的指针。而内核里的线程ID需要通过gettid()系统调用获取返回的是pid_t也就是内核task_struct的pid字段。这两个数字为什么不一致因为pthread_create在用户态会先为一个新线程分配struct pthread结构体通常位于线程栈底部的固定区域然后调用clone系统调用创建内核线程。内核分配pid给它这个pid对用户态是不可见的。glibc封装层把struct pthread的地址返回给调用者于是你就得到了一个看起来像地址的pthread_t。你可以做一个简单验证创建一个线程在里面分别调用pthread_self()和gettid()或者用syscall(SYS_gettid)把两个值都打印出来。pthread_self()得出的数字往往很大且不规律因为它是mmap出来的栈地址附近的指针而gettid()返回一个从1开始递增的小整数。在我的64位CentOS上pthread_t打印出来类似0x7f0c12345670而gettid()可能是31856这样的形态。两者不是一回事这点必须刻在脑子里。这里有个重要的调试技巧gdb里info threads显示的是pthread_t的十六进制而perf、top、ps显示的是内核TID。如果想把两者对应起来可以在线程入口处打印gettid()或者从/proc/pid/task目录下手。多线程程序里看线程号一定盯紧内核TID。1.2 主线程的特殊性为什么主线程的PID等于TID一个进程被创建时内核为它分配了PID这个PID同时也是第一个线程主线程的TID。Linux把所有线程归到一个线程组里线程组的ID就是主线程的PID也就是TGID。getpid()返回的就是TGID不是当前线程的TID。所以你可以看到这个规律主线程的pthread_self()和gettid()没对应关系但它在内核视角有个身份就是进程PID。子线程没有单独PID它们只是同一个线程组里的成员。当我们执行top -H或ps -eLf时看到的每一条线程记录PID列显示的是TGID所有线程相同而LWP列显示的才是每个线程真正的ID。理解TGID机制对排查问题极有帮助。比如你的程序core dump了内核日志里打印的往往是一个PID实际上这是进程所在线程组的TGID。你要定位是哪个线程挂了得配合coredump_filter和/proc/接口去查看crashed线程的TID。提示不要把pthread_t和内核TID混用。设置线程名称的pthread_setname_np内部会调用prctl设置内核线程名但线程名的查看方式还是靠ps -eL或者/proc/pid/task/xxx/status。2. 地址空间布局线程到底共享了什么说清楚ID之后进入重头戏地址空间布局。这是理解线程安全、栈溢出、内存分配行为的地基。先建立一个直觉画面一个进程拥有一个完整的虚拟地址空间在64位Linux上用户空间是128TB按常规内核配置从0x0000000000000000到0x00007fffffffffff。经典布局从低地址到高地址依次是代码段、已初始化数据段、未初始化数据段BSS、堆、内存映射区共享库、mmap匿名映射、栈顶部还有内核空间和vsyscall等特殊区域。进程是资源分配的最小单位——每个进程独享一套地址空间。线程是调度的最小单位——同一个进程里所有线程共享这套地址空间但共享不等于毫无区别。每个线程有几块自己私有的区域栈、线程局部存储TLS、errno位置、浮点环境上下文等。想理解线程并发bug就是要把共享什么和私有什么分清楚。2.1 共享的部分堆、代码段和全局变量的归属共享的当然是代码段、数据段、BSS、堆、文件映射。一个线程malloc出来的内存另一个线程可以直接通过指针访问。全局变量、static变量、字符串常量全进程只有一个副本。这意味着一个线程写其他线程立刻能看到更新。这就是为什么说多线程编程要加锁才能保护共享数据——不加锁两个线程同时写同一个全局变量数据竞争就出来了。我在项目里见过一个典型事故两个线程同时调用strtok()分割字符串这个函数内部用了static char *类型的内部指针所以是线程不安全的。线程A刚分割完第一段线程B一调用就把它内部状态打乱了导致A后续解析到错误的数据。这个案例恰好说明全局static数据是进程级共享的任何线程的修改都影响所有线程。堆的分配本身也有讲究。ptmallocglibc默认的内存分配器在分配大块内存超过MMAP_THRESHOLD默认128KB时会用mmap直接映射匿名内存这些区域会分布在mmap区小内存则从主分配区的heap通过brk扩展。多线程下每个线程有各自的arena分配区但堆和高地址的mmap区域本质还是进程共享的。所以线程A销毁一个自己创建的堆对象线程B如果还有指针引用就会踩到悬空指针——地址空间共享的另一面就是生命周期也必须是共享的。2.2 私有的部分线程栈、TLS与内核栈每个线程有独立栈。我在之前写线程控制时提过pthread_create默认的线程栈大小是8MB可以通过ulimit -s查看但这8MB不是一次性分配的物理内存而是通过mmap做的一次虚拟内存映射按需分配物理页面。这就意味着创建几千个线程时虚拟地址空间消耗会很大——每个线程8MB1000个线程就是8GB虚拟地址空间。32位系统下这个数字很容易撑爆地址空间64位下还有余地。线程栈在mmap区域的分布很有特点。新线程栈通常从高地址往低地址长栈底指向高地址栈顶向低地址延伸。每个线程栈区域间隔着一个guard page通常是4KB作用是检测栈溢出。当线程访问到guard page会触发SIGSEGV而不是静默越界。这也是为什么线程栈溢出表现是段错误而不是直接踩到相邻线程的栈。除了栈还有一个叫线程局部存储TLS的东西。用__thread关键字声明的变量每个线程有独立副本这在多线程编程里很有用。TLS有两种实现方式编译期静态TLS通过FS/GS段寄存器访问和动态TLS__tls_model(dynamic)。访问TLS的开销很小因为是通过段寄存器偏移寻址的这也是errno能以线程安全方式工作的原因——errno实际上是一个宏展开后通过TLS访问所以每个线程都有自己的errno。内核栈也是个容易忽略的点。每个线程在用户态之外还有一个内核栈通常16KB这个栈不可以动态增长用于系统调用、中断处理时在内核态保存上下文。线程的内核栈和用户态栈是不同的别混淆这两个概念。线程创建时内核会分配好内核栈进程退出时一并回收。3. 实操环节亲手验证线程ID与地址布局理论讲再多不如动手跑一遍。这个环节我用C代码和一组shell命令带你直观感受前面的结论。环境是CentOS 7 x86_64内核3.10glibc 2.17。工具链不同结果会有点差异但原理都一样。3.1 一段代码同时打印pthread_t和内核TID写一个最小验证程序创建3个线程每个线程打印自己的pthread_self()和gettid()另外记录一下主线程的TGID。#include stdio.h #include pthread.h #include unistd.h #include sys/syscall.h #define THREADS 3 void *worker(void *arg) { int no *(int *)arg; pthread_t self pthread_self(); pid_t tid syscall(SYS_gettid); pid_t pid getpid(); printf(thread %d: pthread_self0x%lx, gettid%d, getpid%d\n, no, (unsigned long)self, tid, pid); return NULL; } int main() { pthread_t tids[THREADS]; int nos[THREADS] {1, 2, 3}; printf(main thread: pid/tgid%d, pthread_self0x%lx\n, getpid(), (unsigned long)pthread_self()); for (int i 0; i THREADS; i) { pthread_create(tids[i], NULL, worker, nos[i]); } for (int i 0; i THREADS; i) { pthread_join(tids[i], NULL); } return 0; }编译时加-pthread运行输出大概长这样main thread: pid/tgid11860, pthread_self0x1f613400 thread 1: pthread_self0x1f616700, gettid11861, getpid11860 thread 2: pthread_self0x1f616800, gettid11862, getpid11860 thread 3: pthread_self0x1f616900, gettid11863, getpid11860可以看到getpid()在三个子线程里都是11860这是线程组ID也就是主线程的PID。而gettid()依次递增从11861到11863这才是内核真正调度用的TID。pthread_self()那个0x1f61xxxx的数字是线程栈地址空间附近的指针值每次运行都不一样。有个值得注意的细节pthread_self()打印出的数值差异很小比如0x1f616700、0x1f616800、0x1f616900差值就是0x100恰好是1KB左右。别小看这个规律它和线程栈的排列方式有关新创建的线程栈都在相近的mmap区域相邻排列。3.2 通过/proc文件系统观察线程组的真实面貌光看代码输出不够直观我们把视角切换到内核视角用/proc文件系统来透视这个进程。还是运行上面的程序但在线程存活期间另开一个shell执行ps -eLf | grep a.out输出里能看到每一行都带相同PID、不同LWP。也可以直接看cat /proc/11860/status | grep -E PID|PPid|Tgid|Threads你会看到这么几行PID: 11860 PPid: 1 Tgid: 11860 Threads: 4Tgid就是线程组IDThreads显示总线程数含主线程共4个这个数值对应的是内核里这个线程组下挂在task_struct链表上的任务个数。如果想更细粒度看每个线程的具体状态跑ls /proc/11860/task/展开这个目录你看到的是每个线程的TID作为子目录名的列表。每个线程目录里又有独立的status、syscall、stack等文件这些就是内核为每个线程维护的完整proc节点。读线程的status文件可以拿到Threads字段此处为1因为每个线程目录代表一个线程等等。这个实操给我留下的最深感触是用户态的一套API和内核态的proc文件系统展示的是事物的两个侧面。如果你能熟练地在代码输出和/proc之间来回对照调试并发程序的效率会高很多。3.3 用maps文件解析地址空间布局的真实形态接着可以在线程运行期间查看它的地址空间这个文件信息量极大cat /proc/11860/maps | head -30你会看到一行行地址范围和权限位标志比如00400000-00401000 r-xp 00000000 fd:01 123456 /home/user/a.out 00600000-00601000 r--p 00000000 fd:01 123456 /home/user/a.out 00601000-00602000 rw-p 00001000 fd:01 123456 /home/user/a.out ... 7f7e8b41e000-7f7e8b5bd000 r-xp 00000000 fd:01 789101 /usr/lib64/libc-2.17.so ... 7f7e8b7c1000-7f7e8b7c2000 rw-p 00000000 00:00 0 7f7e8b7c2000-7f7e8b7c3000 rw-p 00000000 00:00 0第1行是只读可执行的代码段第3行开始了可写的数据段libc的映射出现在mmap区域紧接着的一堆rw-p匿名映射一部分就是新线程的栈和guard page。你可以把这些匿名映射和线程对应起来。具体做法查看线程的/proc/11860/task/11861/stat里面有个字段是startstack记录该线程的栈起始地址再用pmap命令或者从maps中比对地址范围就能锁定哪一段匿名映射是线程1的栈。我当年第一次做这个实验时看到8MB的栈映射里实际驻留物理内存才几页深切理解了虚拟内存按需分配的含义。4. 常见问题与避坑实录这块经验是多年踩坑积攒出来的遇到过的坑都列出来帮你省点血泪时间。4.1 pthread_t是指针导致的两个坑因为pthread_t本质是指针值它有几个非常反直觉的特性。第一个坑是pthread_t不具备跨进程意义。你在进程A里拿到一个线程的pthread_t发给进程B一点用都没有因为那个指针值只在进程A的地址空间有效。需要跨进程识别线程只能靠内核TID。所以写多进程多线程混合的程序要跨进程通信线程标识时请用gettid()或者自己维护一个唯一ID。第二个坑是pthread_t不能当作数值比较的对象。C语言里pthread_equal()这个函数的存在恰恰说明pthread_t不是保证可以用比较的简单整数类型尽管glibc下它是一个unsigned long通常可行但不是标准保证的。代码规范上建议永远用pthread_equal()判断线程身份。第三个隐藏坑是线程ID可复用。一个线程退出后它的内核TID可能被后续新创建的线程复用。所以保存已退出线程的TID隔了很久再拿出来用可能会指向一个新线程。我遇到过生产事故监控系统保存了崩溃线程的TID几小时后用这个TID去查线程状态结果看到的是一个全新的线程。排查了很久才意识到是TID复用问题。注意不要尝试拿pthread_t直接printf(%d)去看它可能是8字节的指针。正确做法是printf(%lu, (unsigned long)pthread_self())或者完整定义用%zx。4.2 线程栈溢出你看到的可能不是栈溢出线程栈溢出是并发编程里死因不明的段错误的头号来源。默认8MB的线程栈如果在线程里声明一个大的局部数组比如int a[2*1024*1024];这就直接占用了8MB的栈空间很可能触发guard page程序瞬间崩溃。这种崩溃现场最常见的是gdb进去看到一堆乱码很难定位。我教过团队一个排查口诀先算栈用量再怀疑线程栈。统计线程函数里所有局部变量、函数调用链的调用帧大小估算最深层递归时的栈消耗。Linux下可以用pthread_attr_getstacksize()查看栈大小pthread_attr_setstacksize()在创建线程前修改栈大小。这里有一个长期被误解的点mmap区域的线程栈之间是紧密排列的不是说线程A的栈顶和线程B的栈底之间隔了一大块空洞。一个线程栈溢出如果越过了guard page接下来踩到的很可能就是相邻线程的栈表现出来就是两个毫无关联的线程互相修改对方的数据。这种bug很难复现偶尔出现就消失极难排查。所以线程栈宁可设置大一点也不要抠门但也不要无脑大到每个线程几十MB——虚拟地址空间在32位下有限64位下也需要有节制。有几个实用建议线程栈默认8MB通常不用改动。递归深度大或局部大数组线程用pthread_attr_setstacksize设置到16MB或32MB。创建线程时设置guard大小pthread_attr_setguardsize可以调guard page的数量级默认是页大小4KB如果栈可能突破可以适当加大。定位栈溢出时优先用gdb的thread apply all bt看所有线程调用栈配合info proc mappings确认栈顶地址。日常代码里避免在栈上放大对象优先用堆。4.3 TLS的动态加载与dlopen的恩怨还有一个高频问题TLS变量和动态库的组合。如果你在动态库里用了__thread变量而这个库又是通过dlopen在运行时加载的库卸载再重新加载时TLS模型ie或gd可能导致变量地址指向过期数据。我踩过一次具体例子插件系统通过dlopen加载某个.so插件里声明了一个__thread的布尔变量做缓存标志。第一次加载工作正常第二次加载同一插件出现了缓存永远命中但数据是旧的现象。查了很久才定位到TLS的初始化与销毁逻辑问题——库卸载时TLS空间的析构没有完全清理干净重新加载时复用了旧的TLS槽位。出于稳定考虑我建议动态库内优先使用线程安全的库内函数而不是裸TLS如果必须使用__thread确保库的加载和卸载顺序是确定的并且尽量用静态链接或加载后不卸载。4.4 用perf和top观察线程的“假象”做性能分析时top命令默认显示进程的汇总CPU要看每个线程的CPU需要top -H -p PID。-H表示按线程展示。很多新人误以为top里看到的是多个进程其实是同一个进程下的多个线程。perf工具也类似perf top -t TID可以按线程过滤配合perf record -g采样你会看到每个线程独立的火焰图。注意这里入参都是内核TID不是pthread_t。所以想分析某个具体线程还得回到gettid()那一层把TID拿到手。还有一个小技巧线程命名。使用pthread_setname_np给线程起名字之后在top -H或perf里就能看到name列出现自定义名称比如worker-http-1。起好名字能大幅降低多线程程序调试时的心智负担——就不需要对着数字ID猜这个线程干嘛的了。5. 由线程ID与地址布局延展出的深层思考看完前面的内容你应该能理解线程ID本质背后的一套完整体系了。我再把视角拉远一点讲讲这层知识在实际系统中是怎么串联起来的也算给大家留个进阶路线。5.1 从clone标志位看线程与进程的血脉关系前面反复强调线程就是LWP支持这个说法的底层机制是clone系统调用。clone允许通过flags决定父子任务共享什么资源CLONE_VM共享地址空间CLONE_FS共享文件系统信息CLONE_FILES共享文件描述符表CLONE_SIGHAND共享信号处理函数表。创建线程时NPTL会把这些标志全加上再加上CLONE_THREAD把新任务放进同一线程组。反过来说fork创建子进程时这些标志一个都不加所以子进程获得的是独立的地址空间和文件描述符表副本。这就是为什么fork之后父子进程互不影响文件偏移而同一进程内两个线程共享同一个文件描述符表。理解了clone标志位就理解了进程和线程在Linux内核里其实是同一个东西的不同配置。这对实际开发有什么意义写RTOS移植或者嵌入式Linux裸核场景时你甚至可以绕过pthread库直接cloneCLONE_VM来创造线程不过不推荐NPTL已经封装得够好。做底层调试时看clone的flags能确认一个任务的资源归属关系。5.2 地址空间布局随机化ASLR带来的调试困扰现代的Linux默认开启ASLR地址空间布局是随机的。这意味着每次运行程序libc的加载地址、栈的起始地址、mmap区域的位置都会有轻微变化。这给调试带来的麻烦是上一次gdb里看到的栈溢出地址下一次就没法复现了。排查方法有两个方向。方向一是暂时关闭ASLRecho 0 /proc/sys/kernel/randomize_va_space这样地址就稳定了gdb和valgrind更容易抓到问题但要记得切回来默认是2。方向二是不依赖绝对地址而是依赖相对偏移——比如在gdb里用info proc mappings动态获取地址范围或者利用core dump里记录的信息。顺带说一句ASLR不影响线程ID本质也不影响TLS机制。但如果你在内核模块或ebpf程序里写死了某个地址随着ASLR变化就会失效这也是一个容易掉进去的坑。5.3 线程数量与地址空间的经济账这个问题值得单独提出来创建大量线程时地址空间到底怎么被消耗。每个线程的8MB栈是虚拟内存映射上面说过创建时不实际分配物理页。实际跑起来线程栈的驻留物理内存取决于栈上实际压了多少东西。默认栈方向向下增长线程启动后可能只用了几十KB。但如果遇到递归很深或局部大数组的代码物理页会慢慢补齐——而虚拟地址始终是那8MB区域。在一个32位系统上用户地址空间3GB1000个线程就消耗了约8GB虚拟地址空间早就撑爆了。64位系统带宽大但也要注意ulimit -v和其他限制。某个平台最多能创建多少线程不能只看栈大小还要看RLIMIT_NPROC、cgroup的pids控制器、内存总量等因素。我有一次优化YAPI一样的内部服务线程数从200提高到2000时服务一直出问题。后来发现不是栈空间问题而是pthread内部为了每个线程准备的管理结构和信号唤醒机制带来的内存开销。线程不是免费的每个线程有自己的内核栈、struct pthread、TLS空间。大规模并发场景下多线程方案慎重选型必要时考虑事件驱动模型。6. 实用工具与调试路径总结这一节算是个工具型速查把前面提到的关键操作汇总成可以直接抄作业的清单。6.1 查看线程ID与状态的核心命令目的命令说明查看进程下所有线程ps -eLf | grep nameLWP列显示线程TID动态观察线程CPUtop -H -p PID按线程维度展示CPU查看线程状态详情cat /proc/PID/task/TID/status含State、voluntary_ctxt_switches等查看进程全局线程数grep Threads /proc/PID/status线程组内线程总数查看所有线程的栈回溯gdb -p PID 然后 thread apply all bt多线程调试利器根据线程号查归属进程ps -eLo pid,tid,comm | grep TID确认线程归属调试多线程崩溃时我常用的打法gdb -p PID进入后thread apply all bt一次抓所有线程的栈再通过输出里的Frame信息定位到底哪个线程异常。遇到已经core掉的情况用gdb ./app core.xxx同样先bt然后切换线程看每个线程的函数调用。注意core dump后栈信息经常有损坏但大部分情况下够用。6.2 从代码层打印线程身份的推荐模板给大家一个通用模板放在线程入口处方便调试期快速定位身份#include sys/syscall.h static void log_thread_identity(const char *tag) { fprintf(stderr, [%s] pid%d tid%d pthread_self0x%lx\n, tag, getpid(), (pid_t)syscall(SYS_gettid), (unsigned long)pthread_self()); }不要用getpid()判断当前线程号它始终是整个线程组的ID。也不要试图用pthread_self()和内核TID互相推导它们之间没有稳定的数学关系。这里再补充一个实际中容易犯错的点一个脱离了主线程的线程如果你调fork()子进程里只有调用fork的那个线程其他线程全部消失。如果在多线程程序中fork子进程里的锁状态是继承自父进程某一时刻的极易死锁。所以多线程程序里fork要极度谨慎要么fork后马上exec要么用pthread_atfork注册清理函数。这和线程ID、地址空间的关系在于fork出的子进程TID沿袭父进程调用者的TID但进程PID是新的地址空间也是全新副本。6.3 一个直观的地址空间散布图前面提到maps的文本内容我用一个更结构化的方式描述64位Linux进程用户空间从上到下的分布高地址区域栈起始地址接近0x7fffffffffff向下增长栈区含环境变量和argv栈之下mmap区域共享库libc、libpthread、dlopen的库、线程栈、malloc大块映射、共享内存中间区域堆通过brk向上增长中低地址BSS段、数据段、只读代码段低地址保留的NULL页区域通常不可访问这个布局对两类bug排查非常重要。一类是为什么malloc返回的地址离栈那么远因为堆区分布在数据段上方而线程栈分布在mmap区二者中间隔着大片区域。另一类是为什么我define一个大数组没炸如果你把它放在了static区BSS它属于进程共享空间不算线程栈不会触发线程栈溢出但它会让整个进程的虚拟内存变大物理页按需分配程序启动时看起来没事一旦多个页被真正使用内存就吃紧了。7. 我在实际项目里积累的几个经验写到这里分享几个从项目里沉淀下来的实操体会希望能帮大家少走一点弯路。第一件事是线程ID和日志系统的配合。大规模分布式系统里日志里必须打印TID。我早期写日志框架时统一用的是getpid()结果发现多线程下所有日志的进程号都相同并发问题根本定位不了。后来改成打印gettid()配合线程名日志检索效率提升了一个数量级。现在我的团队规范是日志格式里强制包含线程名[TID]排查问题时先按TID过滤再结合时间线还原现场。第二件事是线程栈大小的设置策略。我给服务器写线程函数时会在入口加一行char buf[1024];这种主动使用栈空间的代码避免纯栈上没数据导致栈页没被触碰、后续在深路径里才爆栈的情况。当然这只是一个心理安抚措施。真正避免爆栈靠的是不在线程栈放大数据体、递归调用控制在确定深度、合理设定栈大小。第三件事是善用pthread_attr。不少同学用pthread_create永远传NULL属性其实很多调试信息能从属性里拿。比如pthread_getattr_np可以获取运行中线程的栈地址和大小这在我之前讲线程栈溢出排查时特别有用。它是非标准函数但glibc都支持值得了解。第四件事和TLS有关。我在网络编程框架里把每个连接的上下文放进了__thread变量避免层层传参性能很可观。但做热更新时发现__thread变量在.so卸载重载后会出问题。后来改用显式pthread_key_create的线程特定数据方案兼容性更好。做插件类项目时优先用pthread_key_t管理线程特定数据__thread适合放在主程序里。最后一件想提醒大家警惕过度使用多线程。线程的ID、栈、TLS都是有成本的结构线程切换的时间开销、线程间的同步与通信复杂度更是隐形成本。如果任务是IO密集且并发量很大协程或者事件循环可能是更优雅的方案。理解线程ID和地址空间布局不只是为了通过面试更是为了在设计并发架构时能做出对的判断——知道每个线程背后真实占用的资源才不会拍脑袋定线程数。