ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

栈:从后进先出到调用栈,一文吃透核心原理与应用

栈:从后进先出到调用栈,一文吃透核心原理与应用 栈Stack大概是计算机科学里最被低估的一个概念。做开发这些年我面试过不少候选人问栈是什么多数人能背出“后进先出”四个字但再往下追问一句方法调用的时候系统是怎么靠栈记住上下文的栈内存溢出的根因是什么很多人就卡住了。再问得深一点单调栈为什么能在O(n)时间内解决一类经典问题能讲清楚的就更少了。这其实很不应该因为栈这个数据结构贯穿了从底层内存管理到高层算法设计的几乎每一个环节。这篇我把栈的核心概念从头到尾拆一遍从数据结构定义到内存模型从单调栈算法到调试器里的调用栈视图最后整理几个我实际踩过的栈相关的坑希望对你有用。抛开那些枯燥定义我尽量用写代码的人能直接上手的视角来讲。如果你正准备面试、刚入门数据结构或者写了好几年业务却发现栈的知识不成体系这篇文章都可以当作一份完整参考。熟悉栈的老手也不用急着走后面关于堆和栈的对比、调用栈排查、StackOverflowError的现场分析这些内容平常工作里都是硬碰硬的问题。1. 栈是什么从一摞盘子开始理解后进先出1.1 栈的核心规则LIFO的逻辑为什么应用这么广栈的本质是一种受限的线性表所有的插入和删除操作都限制在表的同一端进行。这一端叫栈顶Top另一端叫栈底Bottom。这种限制带来一个鲜明的行为特征后进先出Last In First OutLIFO。生活里最典型的类比就是一摞盘子。你洗碗的时候洗好的盘子一个一个往上叠最后洗好的那个在最上面。等要用盘子的时候你先拿走的也是最上面那个。想从中间抽一个盘子出来可以但得先把上面的都挪开。这个“最后放上去的最先被拿走”的规则就是栈的灵魂。这个看起来简单的规则应用范围却广得吓人。函数调用的时候系统需要记住每个方法执行到哪一行返回值该给谁这靠调用栈。浏览器的历史记录是栈你点“后退”回到的是最近访问的页面。文本编辑器里的撤销操作也是栈每次撤销都回退到最近的一次编辑。表达式求值把中缀表达式转后缀表达式并用栈计算结果这是编译原理的经典内容。算法题里的括号匹配、函数调用深度计算、深搜遍历更是站栈的专场。我自己最初学栈的时候犯过一个理解偏差总想把“栈”和“数组”“链表”对立起来看。后来才想明白数组和链表是物理存储结构栈是一种逻辑结构。栈可以用数组实现也可以用链表实现。数据结构这门课真正训练的不是记住某种结构而是理解一种抽象规则再选择恰当的底层实现。这个认知对后续理解JDK源码里的Deque、理解JVM的内存模型帮助都很大。1.2 栈的三大核心操作push、pop、peek各司其职栈对外暴露的操作非常精简这是它的特点也是它的优势。一个标准的栈接口通常只包含三个核心操作。push入栈把新元素放到栈顶。如果底层是数组实现需要注意容量问题满了之后要扩容或者报错。pop出栈把栈顶元素移除并返回这个操作会改变栈的内容。peek查看栈顶元素只返回栈顶元素的值但不改变栈本身所以也叫top操作。此外还有一个常规的isEmpty判断以及部分实现里提供的size方法。正是因为这个接口极简所以栈的实现非常稳定几乎不存在复杂的边界情况。但反过来如果使用方不遵守“只在栈顶操作”这个约束栈也就不成其为栈了。日常开发里我见过不少人把Stack当普通List用往中间插元素这属于用错了数据结构会丧失栈本身的所有语义保证。1.3 顺序栈与链栈两种底层实现的取舍底层实现上栈主要分为顺序栈和链栈两类。顺序栈用数组存储元素栈顶指针通常指向上一个入栈元素所在的位置。数组实现的优势是内存连续CPU缓存友好随机访问效率高。入栈出栈只是在数组尾部做读写时间复杂度O(1)。缺点是数组长度固定需要扩容时得搬移数据。JDK里java.util.Stack就是继承自Vector的数组实现。链栈用链表存储元素入栈出栈操作在链表头部进行。每次push相当于头插法插入一个新节点pop则是移除头节点。链栈的优点是没有长度限制不会因为扩容导致整体搬移缺点是每个节点需要额外的指针内存而且节点散布在内存各处Cache友好性不如数组。Java里ArrayDeque虽然名字带Array但它同时支持两端操作用作栈时效率比Stack更好。选择哪种实现主要看应用场景。如果栈的最大深度可预估用数组如果深度不可控或者频繁入栈出栈导致需要反复扩容用链表更稳妥。JVM的调用栈就是固定大小的数组结构深度超过阈值直接StackOverflowError这恰恰说明任何栈都有容量边界在内存世界里尤其如此。2. 栈在内存世界中的角色调用栈与本地方法栈2.1 JVM里的调用栈是怎么记录方法执行的从数据结构跳到JVM运行时数据区栈这个概念立刻被赋予了更具体的含义。JVM的运行时内存模型里有线程私有的虚拟机栈Java Virtual Machine Stack也就是我们常说的调用栈每个线程一个生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型每个方法从调用到执行完毕对应一个栈帧Stack Frame入栈和出栈。一个栈帧里主要装着四样东西局部变量表、操作数栈、动态链接和方法返回地址。局部变量表存放方法参数和方法内部定义的局部变量编译期就能确定大小。操作数栈是执行引擎的工作区字节码指令从局部变量表加载数据压入操作数栈计算完再把结果弹出去写回很像一个临时计算台。动态链接指向运行时常量池中该类的符号引用实现多态调用时的动态绑定。方法返回地址则记录了方法调用指令的下一条字节码位置方法正常返回或者异常抛出时CPU执行权才能回到正确的调用位置。每进入一个方法JVM就往当前线程的虚拟机栈里压入一个新栈帧方法return或者抛出未捕获异常栈帧就出栈。这个过程非常机械也正因为机械所以极其可靠。我有时候跟人解释递归为什么容易栈溢出就说一句话你每递归一次栈帧就多一层栈的总深度有上限递归到一万层栈就爆了原理就这么简单。2.2 本地方法栈的作用与Java的“跨界”调用与虚拟机栈并列的还有一个区域叫本地方法栈Native Method Stack对应热词里搜到的“本地方法栈的作用”。它服务的对象是native方法也就是用C、C等非Java语言实现的方法。为什么需要单独一块本地方法栈因为普通Java方法由解释执行引擎逐条执行字节码而native方法走的是一条完全不同的执行路径它直接调用底层的C函数或系统API。这些C函数同样需要一块栈空间来管理自己的局部变量和函数调用关系这块栈就是本地方法栈。Java标准库里很多底层的、与操作系统交互的方法都是native的。Object类的hashCode、Thread的start、System.currentTimeMillis、文件IO的很多细节都走的是native调用。本地方法栈和虚拟机栈一样也会抛出StackOverflowError如果JVM不支持动态扩展还可能抛出OutOfMemoryError。平时排查问题看到线程dump里的“native”字样基本就是线程卡在某个本地方法里了。2.3 一次StackOverflowError的现场排查记录就地取材分享一个我实际处理过的栈溢出案例。一个定时任务在跑报表计算时突然崩溃日志最底下只有一行java.lang.StackOverflowError。除此之外没有任何业务异常信息。我先用jstack抓了线程快照发现崩溃线程的调用栈最深处是某个实体类的toString方法。再往下看这个实体类里有两个字段互相引用A对象里有个B对象B对象里又有个A对象。项目里用了Lombok的Data注解自动生成的toString方法形成无限递归A打印BB打印AA又打印B栈帧一层层往上涨直到溢出。定位到根因后修复方案很简单在互相引用的字段上加上ToString.Exclude注解不让它们参与toString输出。但这个问题暴露了一个深层隐患任何定义toString、equals、hashCode的地方都要特别注意双向引用。这也算是我踩坑总结里最经典的一条。3. 堆和栈的区别一份对照表讲透两个高频考点3.1 JVM视角下的堆与栈从存储内容到生命周期“堆和栈的区别”是面试题里的常青树也是很多初学者最容易混淆的。从JVM内存模型的角度堆Heap和栈Stack有本质区别。栈存的是方法执行的上下文局部变量、操作数、方法返回地址每个线程一份生命周期跟线程走。方法执行完毕栈帧立刻出栈数据自动失效不需要垃圾回收器介入。堆存的是所有的对象实例和数组所有线程共享一份。对象的生命周期由垃圾回收器管理什么时候被回收取决于可达性分析而不是方法是否返回。所以栈是“自动清理”的堆是“延迟清理”的。栈上分配的数据快进快出堆上的对象则可能存活很久。正是因为这种语义差异栈上的数据访问速度远快于堆。也正因为栈上数据线程私有所以局部变量天然是线程安全的而堆上共享的对象必须自行处理并发问题。3.2 一个简单例子看懂变量到底存哪光讲概念还是抽象我用一段极短的代码来说明。假设有这样一个方法public void demo() { User user new User(); int age 18; }当这个方法被调用时栈帧里会有一个局部变量表里面存着两个条目一个是引用类型的user一个是基本类型的age。user这个引用变量本身存在栈上但它指向的User对象实例是在堆上分配的。age的值18作为一个基本类型直接就存在栈帧的局部变量表里。所以严格回答“对象在堆里还是在栈里”这个问题标准答案是对象实例在堆里但对象的引用可能放在栈里。Java里的问题十有八九要区分“引用”和“对象本体”。这也是为什么两个引用指向同一个对象时其中一个引用的修改会影响另一个“看到”的内容因为它们指向的是同一个堆对象。栈和堆的配合关系其实很像一张便签贴在一个文件柜上便签引用放在栈上方便快速翻阅文件本体对象躺在堆的柜子里由管理员长期保管。3.3 堆和栈的五维度对照速查表为了便于记忆和复习我把堆和栈的核心差异整理成一张表对比维度栈Stack堆Heap存储内容局部变量表、操作数栈、动态链接、返回地址对象实例、数组线程共享每个线程私有所有线程共享生命周期方法调用开始入栈方法返回自动出栈对象创建后由GC管理回收时间不确定空间大小JVM启动时固定通常较小可以是物理内存的很大一部分可动态扩展访问速度极快压栈弹栈就是指针移动相对较慢涉及引用查找和GC典型异常StackOverflowErrorOutOfMemoryError这张表无论是应对面试还是帮助自己梳理JVM内存模型都值得存一份。实际定位问题时见到StackOverflowError先检查递归和深度调用见到堆上的OutOfMemoryError再去分析对象存活和内存泄漏方向就不会跑偏。4. 栈在算法题里的高光时刻单调栈与经典题型4.1 单调栈的核心思想与应用场景如果把栈当作工具那单调栈Monotonic Stack就是栈结构里最精致的玩法。所谓单调栈就是栈内元素始终保持单调递增或单调递减。往栈里压入元素时如果新元素破坏了单调性就先把栈顶元素弹出去直到满足单调条件再入栈。单调栈的核心价值在于它可以快速找到某个元素左侧或右侧第一个比它大或小的元素。通过一次线性扫描每个元素最多入栈一次、出栈一次整体时间复杂度稳定在O(n)空间复杂度O(n)。这比暴力解法的O(n^2)快了一个数量级是典型的用空间换时间。单调栈的应用场景非常集中柱状图中的最大矩形、接雨水、每日温度、下一个更大元素、股票价格跨度等都是它的主场。理解了“维护单调性”为什么要弹栈其实就理解了这类问题的通用解法。4.2 接雨水问题的单调栈解法接雨水Trapping Rain Water是单调栈最经典的题目之一也是很多公司面试的热门题。给定n个非负整数表示每个宽度为1的柱子的高度计算按此排列的柱子下雨之后能接多少雨水。单调栈解法的核心逻辑是从左到右遍历柱子高度维护一个单调递减栈。当遍历到一根柱子比栈顶柱子高时说明栈顶柱子处可能出现一个“洼地”可以接雨水。此时弹出栈顶作为底找到当前柱子左边最近且比底高的柱子作为左边界当前柱子作为右边界计算这一层的雨水量。我给出一版Java实现public int trap(int[] height) { DequeInteger stack new ArrayDeque(); int water 0; for (int i 0; i height.length; i) { while (!stack.isEmpty() height[i] height[stack.peek()]) { int bottomIndex stack.pop(); if (stack.isEmpty()) { break; } int leftIndex stack.peek(); int width i - leftIndex - 1; int currentHeight Math.min(height[leftIndex], height[i]) - height[bottomIndex]; water width * currentHeight; } stack.push(i); } return water; }这段代码里的water累计的其实是一层一层的横向水量。每次弹出一个底用左右边界计算这一层能装多少然后累加。这里面有一个细节宽度用的是左右索引差再减1因为中间包含的柱子宽度本身占据空间。高度用的是左右边界中较矮的那个减去底的高度才是真正空出来的可积水高度。当初我理解这个解法时卡了很久后来自己在纸上画了一组柱状图手动模拟一遍入栈出栈才真正通透。数组从0到11高度为0,1,0,2,1,0,1,3,2,1,2,1这组数据可以从第一步推到最后一步每一步stack里的状态都写出来一页纸下来基本就掌握了。建议你也这么试一次比看十遍别人的代码都管用。4.3 括号匹配与表达式求值栈的两个经典应用方向除了单调栈栈在算法里还有两个经典分支括号匹配和表达式求值。这两个都是编译原理和日常开发里实打实要用到的能力。括号匹配的思路非常简单遍历字符串遇到左括号就入栈遇到右括号就检查栈顶是否是对应的左括号是则弹出不是则说明不匹配。遍历结束后如果栈为空说明所有括号都正确闭合。这个算法可以扩展到多种括号并存的情况比如同时包含小括号、中括号、大括号。实现时用Map存匹配关系或者直接用switch判断都可以。这个问题的工程意义不止于算法题。IDE里的语法高亮、代码格式化工具、Lint检查都在用类似机制检测代码块是否闭合。我自己写代码时如果某一段嵌套层级特别深会在心里过一遍括号匹配的逻辑防止写出括号数量不对的代码。表达式求值稍微复杂一些核心是借助两个栈一个操作数栈一个运算符栈。从左到右扫描中缀表达式遇到数字压入操作数栈遇到运算符则和运算符栈顶比较优先级弹出优先级不低于当前运算符的运算符并进行计算结果再压回操作数栈。扫描结束后把运算符栈里的剩余运算符逐个弹出计算最后操作数栈顶就是表达式结果。这套机制也是实现计算器程序、SQL解析器、模板引擎的核心底层原理之一。理解了算符优先级的栈处理方式你再看任何涉及“规则运算”的代码都会有似曾相识的感觉。5. 从数据结构到技术栈栈这个概念的跨界延伸5.1 编程中“技术栈”到底指什么最近几年“全栈”“技术栈”这些词到处都是但很多人没意识到这个“栈”和数据结构的栈是有语义关联的。技术栈Technology Stack指的是构建一个应用所用的技术集合包括编程语言、框架、数据库、中间件、部署工具等。为什么叫Stack因为这些技术是一层摞一层的最底层是操作系统和网络往上依次是数据库、后端框架、前端框架、CDN和网关最上层才是用户界面。这正好对应栈的“叠放”特征。每一层依赖下面一层上面换了下面可能不用动但下面换了上面通常要跟着调整。就像往栈里推入一个元素会压在栈顶之上。理解了这个逻辑就明白为什么招聘里写“熟悉全栈”会要求你从数据库一直懂到前端交互因为你要对整个技术栈的每一层都有掌控力。5.2 全栈开发与Java/Python技术栈的经验之谈聊到全栈开发顺便分享一点我的经验。技术栈的选择应该跟项目形态强绑定个人博客、内容站点用轻量方案即可企业级中后台系统稳定性和生态完整度要优先考虑实时交互类应用要重点考察WebSocket和流处理能力。以Java技术栈为例典型的一整套后端可以包括Spring Boot作为基础框架MyBatis或JPA负责数据库持久层Redis做缓存MySQL存业务数据RabbitMQ或Kafka处理异步消息Nginx做反向代理Docker容器化部署。Python技术栈这边常用FastAPI或Django做Web接口Celery处理异步任务PostgreSQL存数据Redis做缓存。前端如果做多端Vue或React全家桶加UniApp也很成熟。技术栈选型的核心原则是“够用就好别炫技”。项目的核心价值在业务逻辑上不在技术的新旧和多少上。引入一个新技术前先问自己三个问题它能解决什么问题引入它带来什么运维成本团队里几个人会用三个问题都过了才值得引入。6. 调试器里的调用栈为什么IDEA的调用栈视图让人怀念Eclipse6.1 看懂调用栈视图是程序员的基本功“idea 调用栈查看不如eclipse”这个热搜词很有意思。它背后其实是一个很真实的场景切换IDE时调用栈视图的操作方式和布局变了调试体验也随之变化。在Eclipse里调试时Debug透视图右侧的Variables视图下方就是Stack视图线程的调用栈信件展开帧和帧之间清晰排列当前执行到哪一行一目了然。且双击任一帧编辑区就同步跳到对应代码行关联非常自然。IDEA的调试器里对应的是Debugger工具窗口的Frames面板位置在左侧或下方展示的是当前线程的方法调用列表同样支持点击跳转。老Eclipse用户到IDEA觉得别扭主要原因是习惯差异不是功能缺失。IDEA的Frames面板默认显示的是“当前线程”的状态而Eclipse往往会展示所有线程的调用层级。排查多线程问题的时候Eclipse的全局视角确实更直观。但IDEA也不是不能做到打开Debugger窗口的Threads选项卡可以看到所有线程各自的调用栈。6.2 实际调试中如何用好调用栈信息调用栈视图的本质是JVM通过Stack Walker机制把当前线程虚拟机栈里的所有栈帧一条条列出来。读取顺序是从当前执行帧往上报最底部是入口方法最顶部是当前正在执行的方法。日常调试和排查问题时我有几个固定的操作习惯第一看最顶上三帧判断线程当前正在执行什么代码。这是定位问题最优先要确认的是卡在业务代码里还是卡在JVM内部还是native代码里不回来了。第二结合Variables视图检查每一帧的局部变量。有时候问题不在当前帧而在某个上层方法传下来的参数不对。这时逐个帧往下点观察参数在哪一层开始变化往往能找到根因。第三在线程阻塞或死锁排查时打开所有线程的栈视图找两个线程互相等待对方持有的锁。这种死锁特征在调用栈里特别明显一个线程持有锁A等待锁B另一个线程持有锁B等待锁A。JDK自带的jstack也会直接打印“Found one Java-level deadlock”的提示但前提是你得看得懂线程dump里那些栈帧的含义。7. 栈相关常见问题与避坑指南7.1 栈溢出的三种典型场景与排查路径栈溢出是栈相关最常见的问题没有之一。我把实际工作中碰到的场景归纳为三种典型情况。第一种是无限递归。方法没有退出条件或者递归深度超过栈容量。例如深度优先遍历二叉树时树退化成链表形态且没有限制高度几百层递归就可能触发溢出。排查方法首选线程dump看最深的栈帧反复出现哪个方法基本就是递归点。第二种是对象图自引用引发的toString循环。前面分享的Lombok例子就是典型。这类问题的特征是栈溢出的同时调用栈里不断重复出现同一个toString方法。解决方案是给互相引用的字段排除toString/equals/hashCode或者手动重写toString不打印引用字段。第三种是深度调用链太长。业务上不太容易出现但框架代码里常见比如Spring AOP嵌套代理、Filter链过长、责任链模式处理链路过深。这类问题可以尝试通过调整JVM栈大小参数-Xss来缓解比如-Xss2m或更大但根本方案还是优化调用链的深度。7.2 使用Java集合类实现栈时的选型建议Java里实现栈有好几种选择许多人对它们之间的区别并不清楚。最传统的java.util.Stack继承自Vector所有公开方法都加了synchronized线程安全但性能有损耗而且Vector的扩容机制是增幅加倍空间利用率一般。业界早已不推荐使用这个类。更推荐的是ArrayDeque实现Deque接口既能当栈用也能当队列用。它不是线程安全的但单线程环境下性能优于Stack。用ArrayDeque实现栈时建议只使用push、pop、peek三个方法不要调用addFirst、removeFirst等语义相同但没有栈语义的方法。我见过有人在用ArrayDeque的push方法时偶发混用addFirst代码还能跑但语义混乱后来维护的人很难判断设计意图。LinkedList也可以实现栈但它同时实现了List和Deque允许从中间访问元素破坏栈的约束。链式实现适合深不可控的场景但日常代码里用ArrayDeque就足够了。如果需要线程安全的栈可以用ConcurrentLinkedDeque配合双端操作或者用BlockingDeque的LinkedBlockingDeque实现生产者消费者模型。具体选择取决于对阻塞语义的需求。7.3 手写一个栈需要注意的边界条件面试里偶尔会让你现场手写一个栈很多候选人会把push和pop写出来就认为结束了其实边界条件才是重点。以数组实现的栈为例必须考虑三个问题栈空时调用peek和pop怎么办栈满时push怎么办数组扩容的时机与策略。我给出一个常规且完备的顺序栈参考实现public class MyStackE { private Object[] elements; private int size; private static final int DEFAULT_CAPACITY 10; public MyStack() { elements new Object[DEFAULT_CAPACITY]; } public synchronized void push(E item) { ensureCapacity(); elements[size] item; } SuppressWarnings(unchecked) public synchronized E pop() { if (isEmpty()) { throw new EmptyStackException(); } E result (E) elements[--size]; elements[size] null; return result; } SuppressWarnings(unchecked) public synchronized E peek() { if (isEmpty()) { throw new EmptyStackException(); } return (E) elements[size - 1]; } public boolean isEmpty() { return size 0; } private void ensureCapacity() { if (size elements.length) { elements Arrays.copyOf(elements, elements.length * 2); } } }这段实现里有两个容易被忽略的细节。第一pop时把数组对应位置置为null这是帮助垃圾回收的关键一步。如果不置null被弹出的对象会一直被数组引用即使业务上已经不再需要它GC也无法回收它对应的内存。第二peek时返回的引用指向内部数组元素如果外部直接修改该引用指向的对象状态会破坏封装性。更严格的实现里peek返回的是对象的拷贝但大多数场景下直接返回引用是可以接受的。这些边界条件的处理方式不仅适用于手写题也是评估一个候选人对“栈”理解是否深入的好标尺。7.4 栈相关的经典面试题速查清单最后整理一份栈相关的经典面试题清单正好可以当作自测题型核心考点推荐解法实现最小栈支持常数时间获取最小值辅助栈或栈内存储差值双栈法或单栈变体用两个栈实现队列入队栈与出队栈的配合入队只push出队时若空则迁移括号匹配栈顶匹配与入栈时机单栈遍历每日温度下一个更高温度出现前等待天数单调栈与索引差单调递减栈柱状图中最大的矩形单调栈确定左右边界单调递增栈哨兵节点辅助逆波兰表达式求值操作数栈与运算规则单栈遍历栈的压入弹出序列判断模拟入栈出栈过程辅助栈模拟递归转非递归显式栈模拟函数调用手写栈保存状态与返回点每一道题都不是背答案而是考察你对栈特性、操作时机和边界条件是否真正理解。把这些题吃透栈这个主题在面试里就基本不会失分了。写在最后的几个实操体会栈这个数据结构学了这么多年我最大的体会反而很简单它越简单越值得反复琢磨。数据结构与算法这门课里栈是少数几个在业务代码里天天用、在JVM底层里天天跑、在算法题里又高频出现的概念。正因为简单它才渗透得更深理解得更扎实对整个技术体系的认知就越连贯。如果你刚接触栈建议先把“后进先出”这一件事彻底吃透然后自己用数组和链表各写一遍栈再把括号匹配、表达式求值、单调栈三道经典题手撸一遍。这个量级的学习投入大约是两到三个晚上但收获的是一劳永逸的基石。如果你已经工作了几年那可以从调用栈入手多看看线上线程dump里的栈帧多分析几次StackOverflowError和死锁把栈从理论层面拉回工程实践里。等你能够不用调试器单看线程dump就能脑补出方法之间的调用关系时对程序的运行机制理解就算真正上了一个台阶。
返回列表