如果你去面 Java 岗位,问数据结构,栈(Stack)大概率会被第一批翻牌子。它看起来简单到不像话——一个“后进先出”,背过的人满地都是。但你翻翻搜索记录就会发现,“java面试八股文”、“栈和堆”、“数据结构期末复习”这些词常年挂在热门上,说明越是看似简单的东西,越有人在细节上栽跟头。今天这篇文章我不想写成 API 手册,而是想从源码实现、运行时机制、面试真题到工程选型,把 Java 里的栈完整拆一遍,尤其会把那些“文档里不写、实战中踩坑”的地方说清楚。
这篇文章适合三类人:正在准备 Java 面试的人(尤其是背了八股文但没细想过原理的)、期末复习数据结构的学生、以及已经写了几年业务代码但从来没认真看过 Stack 源码的开发者。看完你至少能搞清楚一个问题:为什么越来越多 Java 工程师说“别再用 Stack 类了”,以及栈到底凭什么能同时出现在算法题、JVM 虚拟机和编译原理里。
1. 先理解栈的本质:一种被“限制”住的线性表,反而有大用
有人说栈很神奇,其实它一点不神秘。栈的本质就是一个线性表,只不过这个线性表只允许在一端进行插入和删除操作。这一端叫栈顶,另一端叫栈底。你可以把栈理解成食堂里那种弹簧托盘架,你只能把托盘放在最上面,也只能从最上面拿走托盘。谁最后一个放上去,谁第一个被拿走——这就是“后进先出”,英文 Last In First Out,缩写 LIFO。
1.1 后进先出:从一个生活场景到操作系统内核
“后进先出”这个约束看似简单,实际上威力巨大。你想想浏览器里的“后退”按钮:你访问页面 A -> B -> C,再点后退,回到的是 B,而不是 A;你写文档时 Ctrl+Z 撤销,撤销的总是最近一次操作。这些都是栈的典型应用,它们有一个共同特征:需要记住操作发生的时序,并且必须能回退到上一时刻的状态。
在操作系统和语言运行时内部,栈更是无处不在。函数调用栈、表达式求值、异常处理、线程切换,全都依赖栈。可以说,没有栈,程序根本无法运行。你写的 main 方法调用了一个 service 方法,service 又调用了 dao 方法,这个调用链在 JVM 里就是一个不断压栈和弹栈的过程。这种场景和食堂托盘架完全一样,只不过托盘换成了“栈帧”而已。
1.2 五个基础操作与时间复杂度
栈这个“抽象数据类型”(ADT)核心操作就五个,不多不少:
| 操作 | 说明 | 时间复杂度 |
|---|---|---|
| push(E e) | 元素入栈,放到栈顶 | O(1) |
| pop() | 弹出栈顶元素并移除 | O(1) |
| peek() | 查看栈顶元素但不移除 | O(1) |
| isEmpty() | 判断栈是否为空 | O(1) |
| size() | 返回栈中元素个数 | O(1) |
为什么入栈出栈是 O(1)?因为栈操作只发生在栈顶。你永远不需要遍历找到插入位置或删除位置,头部就是你要操作的位置。当然这里说的是普通栈结构,如果底层基于数组且容量不够,push 可能触发扩容,扩容要拷贝数组,那一次性的时间复杂度是 O(n)。不过均摊下来,动态栈的 push 仍然是 O(1)。
这就引出一个很多初学者没想明白的点:栈不是一种新的存储结构,而是对已有存储结构(数组或链表)施加了“只能在一端操作”的约束。约束越多,能力越受限,但也越容易被高效实现和维护。这个道理放到软件设计里也是一样的。
1.3 顺序栈和链栈:底层用数组还是链表
栈的实现方式一般分两种:顺序栈(数组实现)和链栈(链表实现)。
顺序栈底层用一块连续内存。你只需要维护一个 top 指针(或者一个 size 变量),push 时往 top 位置放元素再 top+1,pop 时 top-1 再把对应位置置空。它的优点是内存连续,CPU 缓存友好,随机访问快;缺点是容量固定(动态扩容除外),满的时候要扩容。
链栈底层用链表节点。每个节点包含数据和指向下一个节点的指针,push 就是在链表头部插入一个新节点,pop 就是删除头部节点。它的优点是理论上没有容量上限(受堆内存限制),不需要考虑扩容;缺点是每个节点需要额外存储指针,内存碎片化,而且节点创建有开销。
实际开发中,数组实现的顺序栈是主流。Java 里的 ArrayDeque 就是典型的动态数组实现,默认初始容量 16,容量不够时自动翻倍。链表实现的链栈在真实业务里反而很少见,更多是在面试手写题或某些无锁并发场景里出现。
2. 官方 Stack 类的大坑:继承 Vector 的历史包袱,以及 Deque 的正确姿势
如果你刚学 Java 不久,第一反应可能是:Java 不是有现成的 java.util.Stack 类吗?直接 new Stack 然后用 push、pop 不就行了吗?行是行,但你要知道,Stack 类在官方文档里早就被建议不要再用了。这不是某个技术大 V 的个人观点,而是 JDK 文档里白纸黑字的建议:“A more complete and consistent set of LIFO stack operations is provided by the Deque interface and its implementations, which should be used in preference to this class.”——翻译过来就是:想用栈操作,请去用 Deque 接口及其实现类,别用我。
2.1 Stack 类源码里的三大硬伤
为什么官方这么不给面子?打开 java.util.Stack 的源码,一眼就能看出问题。第一,Stack 直接继承自 Vector。这意味着 Stack 不是一个独立的类,而是 Vector 的子类,它天然继承了 Vector 的随机访问能力。换句话说,你可以对一个栈执行 get(0) 这种操作,可以直接遍历栈内部所有元素。这严重破坏了栈的“只能在一端操作”的约束——你本意是设计一个严格的栈,结果别人想怎么访问就怎么访问。
第二,Vector 的每个方法上都加了 synchronized 锁。Stack 所有操作都是方法级同步,这在单线程环境里纯粹是性能浪费。每次 push、pop 都要经过同步块,虽然现代 JVM 会对无竞争锁做优化,但总体开销依然比无锁实现大。
第三,Stack 缺少接口抽象。你在代码里如果用 Stack 类型声明变量,那就被这个具体类绑死了。而 Deque 是接口,你可以灵活替换实现类,比如 ArrayDeque、LinkedList,甚至自己实现的并发双端队列。面向抽象编程,这是 Java 集合框架的基本素养,Stack 却反着来。
所以结论很明确:除非你在维护古董代码,否则不要再 new Stack()。面试的时候能说出这一层,你就已经超过了绝大多数只会调 API 的候选人了。
2.2 用 ArrayDeque 实现栈的正确姿势
既然不让你用 Stack,那用什么?最推荐的是 ArrayDeque。它实现了 Deque 接口,提供了一整套 LIFO 操作:
Deque<String> stack = new ArrayDeque<>(); // 入栈 stack.push("Java"); stack.push("数据结构"); stack.push("栈"); // 查看栈顶,不会移除 System.out.println(stack.peek()); // 输出:栈 // 出栈 String top = stack.pop(); System.out.println(top); // 输出:栈 System.out.println(stack.size()); // 输出:2注意,Deque 接口下操作栈的方法有好几对:push/pop、addFirst/removeFirst、offerFirst/pollFirst。它们语义上都能用,但细节有差别。push 在容量受限的 deque 中可能抛 IllegalStateException,而无限制的 ArrayDeque 则不会;pop 在栈为空时抛 NoSuchElementException。如果你需要更宽松的语义,可以用 offerFirst 和 pollFirst,它们不会抛异常,而是返回特殊值来提示失败。
我个人的习惯是:写算法题和业务代码统一用 push/pop/peek,因为它们语义最贴合“栈”。如果栈可能为空,调用 pop 前一定要先判 isEmpty,这是很多线上 bug 的源头。
2.3 手写一个通用栈:数组扩容版与链表头插版
理解栈最好的方式,就是亲手写一个。即使你平时直接用 ArrayDeque,手写的过程也能帮你把扩容、空栈、泛型这些底层机制一次想清楚。下面先看数组扩容版:
public class MyStack<E> { private static final int DEFAULT_CAPACITY = 10; private Object[] elements; private int size; public MyStack() { elements = new Object[DEFAULT_CAPACITY]; } public void push(E e) { ensureCapacity(); elements[size++] = e; } @SuppressWarnings("unchecked") public E pop() { if (size == 0) { throw new RuntimeException("stack is empty"); } E result = (E) elements[--size]; elements[size] = null; // 防止内存泄漏 return result; } @SuppressWarnings("unchecked") public E peek() { if (size == 0) { throw new RuntimeException("stack is empty"); } return (E) elements[size - 1]; } public boolean isEmpty() { return size == 0; } public int size() { return size; } private void ensureCapacity() { if (size == elements.length) { elements = Arrays.copyOf(elements, elements.length << 1); } } }你有没有注意到一个细节:pop 的时候我特意把 elements[size] 置为 null。这个操作叫“清空引用”,目的是防止对象滞留。如果数组里还保留着被弹出元素的引用,就算栈里已经看不到了,这个对象也不会被回收,时间久了就会内存泄漏。这在写通用容器时是个很重要的职业习惯。
下面是链表头插版:
public class LinkedStack<E> { private Node<E> head; private int size; private static class Node<E> { E value; Node<E> next; Node(E value) { this.value = value; } } public void push(E e) { Node<E> newNode = new Node<>(e); newNode.next = head; head = newNode; size++; } public E pop() { if (head == null) { throw new RuntimeException("stack is empty"); } E value = head.value; head = head.next; size--; return value; } public E peek() { if (head == null) { throw new RuntimeException("stack is empty"); } return head.value; } public boolean isEmpty() { return size == 0; } public int size() { return size; } }每次 push 新节点前,先把新节点的 next 指向当前 head,再把 head 移到新节点上。这其实就是一个单链表的头插法,时间复杂度 O(1),非常干净。面试时如果让你手写栈,链表版和数组版写一个出来基本就能过关。
3. 栈不止活在考试题里:方法调用、表达式求值与括号匹配
很多人觉得栈就是数据结构课程里的一个章节,考试背完就扔。但栈实际上是整个计算机系统运行的底座之一。这节我把栈拉到运行时的视角,讲讲它在 JVM、编译器和日常开发里的真实角色。
3.1 方法调用就是压栈和弹栈:JVM 栈帧机制与 StackOverflowError
JVM 为每个线程分配了一个私有的虚拟机栈,这个栈的生命周期和线程一致。每次调用一个方法,JVM 就会创建并压入一个栈帧;方法执行完毕,这个栈帧就被弹出。栈帧里装的是局部变量表、操作数栈、动态链接和方法返回地址等一大堆运行信息。
看一段简单的代码:
public class StackDemo { public static void main(String[] args) { int result = add(1, 2); System.out.println(result); } public static int add(int a, int b) { return a + b; } }main 方法先入栈,然后调用 add,add 的栈帧压到 main 上面。add 执行完,栈帧弹出,返回值交给 main。整个过程就像叠盘子,一层压一层。
如果你写一个没有结束条件的递归:
public class RecursionDemo { public static void main(String[] args) { recurse(); } public static void recurse() { recurse(); } }每次递归都会压入一个新栈帧,而栈的内存是有上限的(默认大约是 512KB 到 1MB,取决于平台和 JVM 参数),栈帧越压越多,迟早会把栈空间耗尽,最终抛出著名的 StackOverflowError。这就是热门搜索里 “protect(): protection stack overflow” 这类错误的本家兄弟。解决这类问题要么是改递归为循环或显式栈,要么是限制递归深度,要么调整 JVM 栈大小参数-Xss。
这里顺便提一个 JVM 调优的常识:-Xss设置的是每个线程的栈大小。调大它能延缓递归溢出,但也会增加内存占用,因为 JVM 要给每个线程预留这么多空间。线程数一多,内存就吃紧了。所以看到一个 StackOverflowError,第一反应不应该是调大栈,而应该是检查你的递归逻辑是否真的会终止。
3.2 表达式求值:双栈法如何计算一个中缀算式
第二个经典应用是表达式求值。你输入3 + 4 * 2,计算器怎么知道先算乘法再算加法?因为人眼能看懂运算符优先级,但程序不能。程序的解决方案之一就是用两个栈:一个数字栈,一个运算符栈。
思路是这样的:从左到右扫描表达式。遇到数字就压入数字栈;遇到运算符的时候,如果运算符栈为空或者当前运算符优先级高于栈顶运算符,就压入运算符栈;否则弹出栈顶运算符,从数字栈弹出两个数做计算,把结果压回数字栈,然后重复比较。遇到左括号直接压栈,遇到右括号则一直弹出运算符计算,直到遇到左括号。
以3 + 4 * 2为例,扫描到*的时候,因为*的优先级高于+,所以先把*压栈。继续扫描2,压入数字栈。表达式结束,开始弹运算符栈:先弹*,计算4 * 2 = 8,再弹+,计算3 + 8 = 11。最终结果是 11,正确。
这种“双栈求值”是编译原理里表达式处理的雏形。Java 编译器、数据库的 SQL 解析器、模板引擎的表达式计算,底层都有类似机制。你也许不会在手写一个计算器,但理解了这套逻辑,再看到“表达式中缀转后缀”、“调度场算法”这些名词就不会发怵了。
3.3 括号匹配、DFS 与页面栈:从算法到日常开发
你上学时肯定做过括号匹配的题:判断一个字符串里的括号是否正确嵌套。解决方式就是一个栈:
public boolean isValid(String s) { Deque<Character> stack = new ArrayDeque<>(); for (char c : s.toCharArray()) { if (c == '(' || c == '[' || c == '{') { stack.push(c); } else { if (stack.isEmpty()) { return false; } char top = stack.pop(); if (c == ')' && top != '(') return false; if (c == ']' && top != '[') return false; if (c == '}' && top != '{') return false; } } return stack.isEmpty(); }这题的价值不只是面试,你写代码时用的 IDE、格式化工具、解析器,全都在靠这种机制检查语法结构的完整性。遇到括号不匹配,编译器能精确报错说“期望 } 但实际上来了 )”,背后就是栈在查岗。
深度优先搜索(DFS)是另一个典型场景。递归实现 DFS 时,系统调用栈本身就帮你做了“回溯”的管理;如果不用递归,就需要手动维护一个栈,模拟递归压栈弹栈的过程。你在 LeetCode 上做二叉树前序遍历、迷宫寻路、岛屿数量,全都能看到显式栈的身影。
还有一个非常贴近我们日常开发的东西:页面栈。小程序里访问页面就是压栈,返回上一页就是弹栈,热门搜索里那个“小程序页面栈大于10怎么处理”就是页面栈超过上限的问题。浏览器也是同样的逻辑,你每开一页就压入一页,点后退就弹出一页。浏览器和 JVM 在这一点上是相通的,栈这个结构从内核到应用层无处不在。
4. 面试真题与期末考点:最小栈、单调栈、双栈队列与堆栈混淆
栈是面试题的重灾区。因为栈本身简单,所以面试官往往会把它当成底层工具,设计出各种各样的变种题来考察你的算法能力和代码功底。这节我挑几道有代表性的题讲透,你再遇到这类题至少不会发懵。
4.1 栈和堆到底怎么分,别被两个“栈”搞混
先把这个最容易混淆的概念理清。搜索热词里“栈和堆”常年上榜,说明太多人分不清数据结构的栈和 JVM 内存的栈。这是两个不同维度的概念。
数据结构的栈和队列,讨论的是数据组织方式:栈是 LIFO,队列是 FIFO。而 JVM 内存里的堆和栈,讨论的是运行时存储划分:方法中定义的局部变量、引用等放在虚拟机栈的栈帧中;new 出来的对象实例存放在堆中。
举个例子:
public void foo() { int a = 10; String s = new String("hello"); }变量a是基本类型,直接存在 main 的栈帧局部变量表里。变量s本身是一个引用,也保存在栈帧里,但它指向的 String 对象实体在堆上。所以“对象在堆上,引用在栈上”是对Java对象内存分配最简单粗略的描述。
那为什么大家说起递归溢出会提到栈,说起大对象频繁创建会提到堆?因为递归深度由栈大小决定,而对象分配频率由堆管理。两个“栈”字很容易把人绕晕,你面试时如果能主动区分这两个概念,面试官通常会觉得你学得比较扎实。
4.2 最小栈:辅助栈的空间换时间
最小栈是一道非常经典的题:要求设计一个栈,在 O(1) 时间内能获取栈中的最小值。你可能会想,用一个变量记录最小值不就行了?但问题在于,pop 操作可能会把这个最小值弹出去,那之前第二小的值怎么办?所以一个变量明显不够。
标准解法是额外维护一个辅助栈,栈顶永远保存当前栈中的最小值。push 的时候,如果当前值比辅助栈栈顶小,就压入辅助栈;否则辅助栈不压。pop 的时候,如果主栈弹出的值等于辅助栈栈顶,辅助栈也要弹。这样 getMin 只需要直接 peek 辅助栈即可:
class MinStack { Deque<Integer> stack = new ArrayDeque<>(); Deque<Integer> minStack = new ArrayDeque<>(); public void push(int val) { stack.push(val); if (minStack.isEmpty() || val <= minStack.peek()) { minStack.push(val); } } public void pop() { int val = stack.pop(); if (val == minStack.peek()) { minStack.pop(); } } public int top() { return stack.peek(); } public int getMin() { return minStack.peek(); } }这题的底子就是“空间换时间”,也是面试官最喜欢引导你往深里想的点。你可以想想如果面试官要求你用 O(1) 空间实现,怎么办?答案是用差值法,栈里每个元素存的是和当前最小值的差值,这样就不需要辅助栈了。不过差值法容易踩整型溢出的坑,刷题时要注意边界。
4.3 单调栈:栈里存的是索引,不只是值
单调栈是在普通栈的基础上,加了一条“单调性”约束:栈中元素从栈底到栈顶保持单调递增或单调递减。它在解决“数组中某个元素离它最近的大/小数”这类问题时,能把 O(n²) 暴力优化到 O(n)。
最经典的题目是每日温度:给你每天的温度,要返回一个数组,answer[i] 表示对于第 i 天,下一个更高温度出现在几天后。暴力解法是双层循环,而单调栈只需要一遍遍历:
public int[] dailyTemperatures(int[] temperatures) { int n = temperatures.length; int[] answer = new int[n]; Deque<Integer> stack = new ArrayDeque<>(); // 存索引 for (int i = 0; i < n; i++) { while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) { int prevIndex = stack.pop(); answer[prevIndex] = i - prevIndex; } stack.push(i); } return answer; }核心思路是:栈里保存的是下标,而不是温度值本身。每次遇到一个新温度时,不断和栈顶下标对应的温度比较,如果新温度更高,说明栈顶元素“找到了下一个更高温度”,它就可以出栈了。因为每个下标最多入栈一次、出栈一次,所以总时间复杂度 O(n)。单调栈在接雨水、柱状图最大矩形、滑动窗口最大值等题目中都能用到,值得单独多练几道。
4.4 双栈实现队列:两次倒腾的摊还复杂度
队列的规则是先进先出,栈的规则是后进先出,两者怎么用栈模拟队列?办法是用两个栈:一个 in 栈负责入队,一个 out 栈负责出队。入队只管往 in 栈 push;出队的时候,如果 out 栈为空,就把 in 栈所有元素依次 pop 再 push 进 out 栈,这样元素顺序就被反转了,再从 out 栈 pop 就变成了先进先出:
Deque<Integer> in = new ArrayDeque<>(); Deque<Integer> out = new ArrayDeque<>(); public void push(int x) { in.push(x); } public int pop() { if (out.isEmpty()) { while (!in.isEmpty()) { out.push(in.pop()); } } return out.pop(); }你可能会问,这样会不会慢?其实整体是摊还 O(1)。虽然每次 out 为空时需要倒腾一批元素,但每个元素只被倒腾一次,均摊到每个操作上,复杂度还是常数。这道题能帮你理解“均摊分析”这个进阶概念,面试时如果能把摊还复杂度的道理讲清楚,是很加分的。
4.5 空栈与异常:最容易失分的小细节
八股文背得再溜,一个空栈操作就能让你当场翻车。Stack 的 pop 方法用peek()时,空栈会抛 EmptyStackException;ArrayDeque 的 push 在容量无限时不会抛异常,但 pop 在空栈时会抛 NoSuchElementException。很多人在写算法题时忘了判空,然后抛异常,非常尴尬。
还有一个细节是 Stack 的 search 方法:它返回的是对象在栈中的 1-based 位置,栈顶为 1。如果元素不存在,返回 -1。这个方法的语义和 List 的 indexOf 完全不同,我以前面试时就因为把这个搞混被面试官点过。现在我已养成了习惯:凡是 stack.peek()、stack.pop() 之前,先问自己一句——这个栈现在到底为空吗?
5. 工程视角的补充:栈的线程安全、扩容策略与拷贝问题
前面讲的都是栈的原理和算法,接下来说几个工程实践中一定会碰到、但文档里很少写清楚的细节。
5.1 Stack 的同步锁与并发场景选型
Stack 类继承了 Vector,所以它的方法都带 synchronized。这意味着 Stack 本身是线程安全的,但线程安全不是免费午餐。方法级锁在并发高的时候会形成竞争热点,性能比无锁容器差一个量级。而且它锁的粒度太粗,整个方法从检查到修改都锁住,并发度很低。
如果你在多线程场景下需要栈结构,我的建议是不要直接用 Stack,也不要用普通的 ArrayDeque(它非线程安全),而是用 ConcurrentLinkedDeque,或者给 ArrayDeque 加外部锁。如果队列本身能代替栈的话,BlockingDeque 的实现类也可以考虑。关键点在于:先想清楚你的并发模型,再决定容器。无脑使用 Vector 系的老容器,通常是历史包袱而不是最佳选择。
5.2 ArrayDeque 的循环数组与扩容逻辑
ArrayDeque 是很多场景下替代 Stack 的首选,但它为什么快?因为它内部是一个循环数组。所谓循环数组,就是逻辑上的首尾相连:当插入位置到达数组末尾时,下一次插入会回到数组头部继续插,这样头和尾都可以高效地增长。
ArrayDeque 默认初始容量为 16,扩容时容量翻倍。你如果大概能预估栈的最大规模,可以用构造函数提前指定容量:
Deque<Integer> stack = new ArrayDeque<>(1024);这能减少扩容次数,提升性能。不过有一点要注意:ArrayDeque 不允许存放 null 元素。官方明确写了这一点,如果业务上确实需要存 null,那你只能换 LinkedList 或者自己实现。这是我在一个解析配置文件的功能中踩过的坑——解析结果偶尔为空,结果往栈里 push null 直接抛 NPE,排查了半天才发现是容器的限制。
5.3 栈内元素的拷贝陷阱
最后说一个特别容易忽略的问题:栈里的元素其实是引用。你在栈里 push 一个对象,再修改这个对象的状态,栈里的内容也会跟着变。很多人以为“存入栈就安全了”,其实只是复制了引用,不是复制了对象本体。
如果你需要栈中的数据在后续操作中不受原对象影响,就得在 push 时做深拷贝,或者让对象实现不可变设计。尤其是做撤销重做、历史记录这类场景,一份可变对象被反复引用,最后可能出现“撤销之后发现历史记录里的数据也变了”的反直觉问题。这个坑在写业务代码时非常典型,处理方式没有标准答案,但你必须意识到:栈只管你的元素进出顺序,管不了元素内部的纯度。
还有一点,当你把一个元素从栈里 pop 出来以后,如果这个元素是某个大对象的引用,而这个大对象短期内还会被其他数据结构继续引用,栈这边及时清空引用反而能帮助 GC 尽快回收。我在第一节手写栈的 pop 方法里强调过 elements[size] = null,就是这个道理。凡是自己实现容器类,都要养成释放引用的习惯。
收尾:说点我自己的体会
写了这么多年 Java,我真正直接用到 java.util.Stack 类的次数其实屈指可数,但栈的思想几乎每天都出现在我的代码里:处理嵌套 JSON 时用栈辅助层级配对、解析 DSL 表达式时用双栈求值、做代码扫描时靠栈来判断块结构是否闭合,甚至调 JVM 参数排查递归问题时,脑子里想的也全是栈帧的压弹过程。
所以我对初学者的建议是:不要停留在“栈就是后进先出”这句话上,而是亲手写一遍数组栈和链表栈,再把括号匹配和表达式求值手推一遍。做完这些,你才算真正吃透了 Java 里的栈。数据结构这东西,看着是具体类和 API,但真正留在脑子里的,永远是那套组织数据和控制时序的思路。栈可能是其中最简单的一个,但它恰恰是理解递归、理解方法调用、理解一切“先发生的事要后处理”场景的起点。