栈(Stack)这个数据结构,在Java开发里属于看着简单、用起来却很容易踩坑的一类基础内容。面试必问、笔试常考,平时写代码也躲不开——浏览器的后退按钮、IDE里的撤销操作、JVM的方法调用栈、甚至你写递归时遇到的StackOverflowError,背后都是栈在起作用。这篇文章我会从概念讲到手写实现,再把括号匹配、表达式求值这几个经典场景拆开揉碎,最后整理一份排查栈相关问题的思路清单,希望对准备面试或者想夯实Java基本功的同学有帮助。
1. 栈的核心概念与整体设计思路
1.1 什么是栈:后进先出的限制性线性表
栈是一种操作受限的线性表,只允许在一端(称为栈顶 top)进行插入和删除操作。这种"后进先出"(Last In First Out,LIFO)的特性,是整个数据结构最核心的约束。你可以把它想象成一叠盘子:后放上去的盘子总是最先被拿走,想拿最底下的盘子,就得先把上面的全部移开。
在Java里,栈的操作主要对应这几个方法:
- push(E item):压栈,把元素放到栈顶
- pop():弹栈,取出并移除栈顶元素
- peek():查看栈顶元素但不移除
- empty()/isEmpty():判断栈是否为空
- search(Object o):查找元素距离栈顶的距离
理解栈的关键,不在于它存储数据的能力,而在于它的"限制"。正因为限制了只能从栈顶操作,栈才能被用来解决具有"嵌套""回溯""逆序"特征的问题。这类问题在程序世界里到处都是,比如函数调用时,A调用B、B调用C,等C执行完,必须准确地回到B的调用位置,再回到A的调用位置——这个"原路返回"的机制,天然就是用栈来实现的。
1.2 栈在Java技术栈中的定位
如果你学习过Java集合框架,会发现栈并不是一个单独设计的接口,而是分散在几个类里面。这就要说到Java集合框架设计上的一段历史遗留问题——最初的Stack类继承自Vector,而Vector是线程安全的(通过方法加synchronized实现的),这就导致Stack类的所有操作都有同步开销,单线程场景下性能白白受损。所以Java官方后来在Deque接口的文档里明确建议:使用ArrayDeque来代替Stack。
但这并不意味着Stack类没用。在Java面试中,面试官经常会用"你知道Stack和ArrayDeque的区别吗"来考察你对集合框架底层原理的理解程度。学习栈的时候,先把Stack类用明白,再搞清楚Deque接口为什么是更好的替代方案,最后能自己手写一个栈,这样整个知识体系才算闭环。
1.3 学习栈的收益:不只是应付面试
很多初学者会觉得栈太简单,不就是push和pop吗?但栈恰恰是很多复杂算法和系统设计的地基。比如:
- JVM的虚拟机栈,每个方法调用都对应一个栈帧,栈帧的入栈和出栈就是方法的调用和返回
- 表达式求值中的运算符优先级处理,依赖中缀转后缀(逆波兰式),而转换过程靠的就是栈
- 深度优先遍历(DFS)可以用递归实现,也可以用显式栈实现
- 编辑器的撤销操作,本质上就是把操作记录压栈,撤销时弹栈
- 函数调用栈溢出排查,需要理解栈帧大小和递归深度之间的关系
可以说,栈是理解递归、理解JVM运行时、理解很多算法的基础。这篇文章后面我会尽量把这些场景串起来讲,让栈不只是死记硬背的八股文。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Java中栈的标准实现与选型
2.1 还在用的Stack类:历史包袱与实战表现
Java 1.0时期就提供了java.util.Stack类,它的定义长这样:
java复制public class Stack<E> extends Vector<E> {
public Stack() {}
public E push(E item) {
addElement(item);
return item;
}
public synchronized E pop() {
E obj = peek();
removeElementAt(size() - 1);
return obj;
}
public synchronized E peek() {
int len = size();
if (len == 0)
throw new EmptyStackException();
return elementAt(len - 1);
}
}
从源码可以看到,Stack继承Vector,底层是动态数组(Object[] elementData),Vector的所有方法都加了synchronized,所以Stack的push、pop、peek也天然带锁。这在多线程环境下是线程安全的,但在单线程场景下,每次操作都要抢锁、释放锁,性能开销完全没必要。这就是为什么Stack类被好多开发者叫作"面试专属类"——不推荐在实际项目里用,但面试必须知道。
使用Stack类写代码很简单:
java复制import java.util.Stack;
public class StackDemo {
public static void main(String[] args) {
Stack<String> stack = new Stack<>();
stack.push("Java");
stack.push("数据结构");
stack.push("栈");
System.out.println(stack.peek()); // 输出:栈
System.out.println(stack.pop()); // 输出:栈
System.out.println(stack.size()); // 输出:2
System.out.println(stack.empty()); // 输出:false
}
}
2.2 官方推荐:Deque接口与ArrayDeque
既然Stack类有性能问题,Java 6引入了Deque接口(双端队列,Double Ended Queue),允许在两端都进行插入和删除。作为双端队列,它天然具备栈的全部能力——只要统一在队首(或队尾)操作即可。
java复制Deque<String> stack = new ArrayDeque<>();
stack.push("Java");
stack.push("数据结构");
String top = stack.peek(); // 查看栈顶
String value = stack.pop(); // 弹出栈顶
ArrayDeque的底层同样是一个可扩容的数组,但和Vector最大的区别在于:它没有用synchronized修饰方法,在单线程下性能明显优于Stack。我实测过一个简单的压栈弹栈循环,ArrayDeque比Stack要快一个量级左右,具体数值与JVM版本和操作环境有关,但趋势是稳定的。
2.3 Stack类与ArrayDeque的对比
| 对比维度 | Stack | ArrayDeque |
|---|---|---|
| 继承结构 | 继承Vector,归属于List体系 | 实现Deque接口,归属于Queue/Deque体系 |
| 线程安全 | 方法同步,线程安全 | 非线程安全,适合单线程 |
| 底层结构 | 动态数组 | 可扩容循环数组 |
| 性能 | 有同步开销,较慢 | 无锁,单线程下更快 |
| 迭代顺序 | 从栈底到栈顶 | 从队首到队尾(若用push/pop则是从栈顶方向迭代) |
| 是否允许null | 允许 | 不允许 |
| 官方建议 | 不推荐使用 | 推荐替代方案 |
有一点需要注意:ArrayDeque的push和pop方法是从双端队列的头部操作,所以如果你用Deque.push()压栈,那么迭代这个Deque时,遍历顺序是从栈顶到栈底的。这个和Stack类的遍历方向恰好是反的,实际写代码时要注意,别在遍历时踩坑。
2.4 线程安全场景:并发栈怎么选
如果确实需要线程安全的栈,不要直接用Stack类,因为它的同步锁粒度太大,并发竞争激烈时性能不佳。更合理的方案是:
- 使用
ConcurrentLinkedDeque,它是无锁的非阻塞双端队列,基于CAS实现,适合高并发读多写多的场景 - 使用
LinkedBlockingDeque,它是阻塞式双端队列,适合生产者-消费者模型,支持在栈空时等待、栈满时阻塞 - 或者自己用
ReentrantLock包一层,粒度更可控
java复制Deque<String> concurrentStack = new ConcurrentLinkedDeque<>();
concurrentStack.push("A");
concurrentStack.push("B");
String item = concurrentStack.pop(); // B
3. 手写一个栈:从数组到链表两种实现
3.1 基于数组的动态扩容栈
为什么要自己手写栈?因为面试手撕代码的时候,不可能一行new Stack<>()就完事。面试官要看的是你对底层结构的理解、对扩容策略的把握、对时间复杂度的分析。基于数组实现的栈,核心要素是三个:数组、栈顶指针(可以理解为元素数量size)、扩容机制。
java复制import java.util.Arrays;
public class ArrayStack<E> {
private Object[] elements;
private int size;
private static final int DEFAULT_CAPACITY = 10;
public ArrayStack() {
elements = new Object[DEFAULT_CAPACITY];
}
public ArrayStack(int initialCapacity) {
if (initialCapacity <= 0) {
throw new IllegalArgumentException("初始容量必须大于0");
}
elements = new Object[initialCapacity];
}
public boolean isEmpty() {
return size == 0;
}
public int size() {
return size;
}
public void push(E item) {
ensureCapacity();
elements[size++] = item;
}
@SuppressWarnings("unchecked")
public E pop() {
if (isEmpty()) {
throw new RuntimeException("栈为空,无法弹栈");
}
E value = (E) elements[--size];
elements[size] = null; // 避免内存泄漏,让GC能够回收
return value;
}
@SuppressWarnings("unchecked")
public E peek() {
if (isEmpty()) {
throw new RuntimeException("栈为空,无法查看栈顶");
}
return (E) elements[size - 1];
}
private void ensureCapacity() {
if (size == elements.length) {
int newCapacity = elements.length + (elements.length >> 1);
// 防止极端情况下扩容大小溢出
if (newCapacity - elements.length <= 0) {
newCapacity = elements.length * 2;
}
elements = Arrays.copyOf(elements, newCapacity);
}
}
}
这里有几个细节值得多说两句。扩容时我用了elements.length + (elements.length >> 1),也就是扩1.5倍。这个策略是为了在扩容次数和空间浪费之间取一个平衡:扩容倍数太小(比如每次+1),频繁复制数组,压栈的时间复杂度会退化;倍数太大(比如2倍甚至更高),浪费的内存多。1.5倍是Java集合框架里常用的一个经验值。
另一个细节是pop的时候把elements[size]置为null。如果不做这一步,数组中还保留着这个对象的引用,即使外面已经拿不到它了,GC也不会回收,如果栈里长期存放的是大对象,就可能造成内存泄漏。这个坑在真实项目中遇到过,排查起来还挺隐蔽的。
3.2 基于链表的链式栈
链表实现的栈,最大的好处是不需要考虑扩容,每个元素是一个节点,压栈就是头插法,弹栈就是删除头节点。时间复杂度同样是O(1)。
java复制public class LinkedStack<E> {
private Node<E> top;
private int size;
private static class Node<E> {
E item;
Node<E> next;
Node(E item, Node<E> next) {
this.item = item;
this.next = next;
}
}
public boolean isEmpty() {
return size == 0;
}
public int size() {
return size;
}
public void push(E item) {
Node<E> newNode = new Node<>(item, top);
top = newNode;
size++;
}
public E pop() {
if (isEmpty()) {
throw new RuntimeException("栈为空,无法弹栈");
}
E value = top.item;
top = top.next;
size--;
return value;
}
public E peek() {
if (isEmpty()) {
throw new RuntimeException("栈为空,无法查看栈顶");
}
return top.item;
}
}
链表栈的核心思路就是头插法。每压入一个元素,新节点的next指向原来的栈顶,然后更新top为这个新节点。弹栈时,直接让top指向原来的第二个节点。整个过程没有任何遍历操作,所以时间和空间复杂度都是O(1)。
要注意的是,单向链表栈只能从头部操作。如果你从尾部(即栈底方向)操作,要么维护一个tail指针然后还要找前驱节点,实现起来就复杂了。这也是为什么链表栈选头插法而不是尾插法。
3.3 两种实现的选择建议
| 维度 | 数组栈 | 链表栈 |
|---|---|---|
| 内存连续性 | 连续内存,CPU缓存友好 | 节点分散,缓存不友好 |
| 扩容 | 需要扩容,极端情况有复制开销 | 天然支持动态增长,无需扩容 |
| 内存占用 | 较省,只需数组引用 | 每个节点额外存next指针 |
| 随机访问 | 支持,但栈本身不依赖随机访问 | 不支持 |
| 实现复杂度 | 中等 | 简单 |
实际开发中,ArrayDeque用的就是数组实现,所以数组栈更接近JDK的官方做法。链表栈更适合面试中展示你对指针操作的理解。面试时如果你能主动说出"数组栈需要考虑扩容和内存释放,链表栈不需要扩容但每个节点有额外内存开销",会显得你真的理解底层,而不是死记硬背。
3.4 泛型与内存细节的进阶说明
写泛型栈时,E类型在运行时会被擦除,所以(E) elements[--size]这种强转会有unchecked警告,正常现象。如果你用E[]直接创建数组是不行的,因为泛型数组创建会直接报编译错误,所以我选择用Object[]存储,取元素时再强转。
还有一个小技巧:如果你的栈需要支持快速清空,可以加一个clear()方法,遍历把数组元素全部置为null,比简单地把size设为0更安全,因为后者会导致栈中仍然持有对象引用,GC无法回收。
4. 栈的经典应用场景实战
4.1 括号匹配检查器:LeetCode第20题
括号匹配是栈最经典的入门应用。给定一个只包含(, ), {, }, [, ]的字符串,判断括号是否有效。有效条件是:左括号必须用相同类型的右括号闭合,且按正确的顺序闭合。
解题思路非常直观:遍历字符串,遇到左括号就压栈,遇到右括号就检查栈顶是否是对应的左括号,如果是就弹出,不是就返回false。遍历结束后,栈为空才说明所有左括号都找到了匹配。
java复制import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
public class BracketMatcher {
private static final Map<Character, Character> BRACKETS = new HashMap<>();
static {
BRACKETS.put(')', '(');
BRACKETS.put('}', '{');
BRACKETS.put(']', '[');
}
public static boolean isValid(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (BRACKETS.containsKey(c)) {
// 遇到右括号:栈空或者栈顶不匹配则失败
if (stack.isEmpty() || !stack.peek().equals(BRACKETS.get(c))) {
return false;
}
stack.pop();
} else {
// 遇到左括号:压栈
stack.push(c);
}
}
return stack.isEmpty();
}
public static void main(String[] args) {
System.out.println(isValid("()[]{}")); // true
System.out.println(isValid("([)]")); // false
System.out.println(isValid("{[]}")); // true
}
}
这里用HashMap来存储右括号和左括号的映射关系,代码更简洁,不用写多个if判断。要注意的是([)]这种交叉嵌套的情况,它是不合法的,因为第三个字符]的栈顶是(,不匹配,直接返回false。这就是栈保证"顺序"的体现——它天然维护了最近未匹配的左括号。
4.2 表达式求值:逆波兰表达式
逆波兰表达式(后缀表达式)把运算符放在操作数之后,比如3 4 +等于3 + 4。它的好处是不需要括号,也不存在优先级问题,计算规则非常清晰。计算机求值后缀表达式,用栈一趟就能算完。
计算规则:遍历每个token,如果是数字就压栈,如果是运算符就弹出栈顶两个数做运算,然后把结果重新压栈。
java复制import java.util.ArrayDeque;
import java.util.Deque;
public class RPNCalculator {
public static int evalRPN(String[] tokens) {
Deque<Integer> stack = new ArrayDeque<>();
for (String token : tokens) {
switch (token) {
case "+" -> {
int b = stack.pop();
int a = stack.pop();
stack.push(a + b);
}
case "-" -> {
int b = stack.pop();
int a = stack.pop();
stack.push(a - b);
}
case "*" -> {
int b = stack.pop();
int a = stack.pop();
stack.push(a * b);
}
case "/" -> {
int b = stack.pop();
int a = stack.pop();
if (b == 0) {
throw new ArithmeticException("除数不能为0");
}
stack.push(a / b);
}
default -> stack.push(Integer.parseInt(token));
}
}
return stack.pop();
}
public static void main(String[] args) {
String[] expression = {"4", "13", "5", "/", "+"};
System.out.println(evalRPN(expression)); // 4 + (13 / 5) = 6
}
}
写这段代码时最容易搞错的点是:减法/除法中弹出两个操作数的顺序。因为是栈,先弹出来的是b(右操作数),后弹出来的是a(左操作数),所以必须写a - b而不是b - a。我见过很多初学者在这里栽跟头,算出来的结果是负的或反的。
4.3 中缀转后缀:调度场算法
现实中的计算器输入都是中缀表达式,比如1 + 2 * 3,运算时需要先算乘法。要让计算机处理中缀表达式,一个办法是先转成后缀表达式,再用上一节的RPN计算器求值。中缀转后缀的经典算法是调度场算法,核心也用到栈。
算法步骤:
- 遇到操作数,直接输出到结果列表
- 遇到左括号,压栈
- 遇到右括号,持续弹栈输出,直到遇到左括号,左括号弹出但不输出
- 遇到运算符,如果栈顶运算符优先级高于或等于当前运算符,则弹出栈顶输出,直到不满足条件,然后把当前运算符压栈
- 遍历结束后,把栈中剩余运算符全部弹出输出
java复制import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
public class InfixToPostfix {
private static final Map<Character, Integer> PRECEDENCE = Map.of(
'+', 1,
'-', 1,
'*', 2,
'/', 2
);
public static List<String> convert(String infix) {
Deque<Character> stack = new ArrayDeque<>();
List<String> output = new ArrayList<>();
for (char ch : infix.toCharArray()) {
if (Character.isDigit(ch)) {
output.add(String.valueOf(ch));
} else if (ch == '(') {
stack.push(ch);
} else if (ch == ')') {
while (!stack.isEmpty() && stack.peek() != '(') {
output.add(String.valueOf(stack.pop()));
}
stack.pop(); // 弹出左括号,不输出
} else if (PRECEDENCE.containsKey(ch)) {
while (!stack.isEmpty()
&& PRECEDENCE.containsKey(stack.peek())
&& PRECEDENCE.get(stack.peek()) >= PRECEDENCE.get(ch)) {
output.add(String.valueOf(stack.pop()));
}
stack.push(ch);
}
}
while (!stack.isEmpty()) {
output.add(String.valueOf(stack.pop()));
}
return output;
}
public static void main(String[] args) {
System.out.println(convert("1+2*3")); // [1, 2, 3, *, +]
System.out.println(convert("(1+2)*3")); // [1, 2, +, 3, *]
}
}
这个算法理解起来比单纯求值复杂一些,但它的核心逻辑和括号匹配很像——用栈来保持运算符的等待状态。左括号压栈后,遇到右括号才把中间的运算符一口气弹出,这就是括号内表达式的优先级边界。掌握了这个算法,你就掌握了表达式引擎的入门钥匙。
4.4 JVM调用栈:递归为什么会栈溢出
栈的应用远远不止算法题。Java程序运行时,JVM为每个线程维护一个虚拟机栈,栈中元素是栈帧(Stack Frame)。每次调用一个方法,JVM就会向当前线程的虚拟机栈压入一个新栈帧;方法执行完毕后,栈帧出栈。
栈帧里保存了方法的局部变量表、操作数栈、动态链接、方法返回地址等信息。每个线程的虚拟机栈大小默认是1MB(可通过-Xss参数调整),如果某次调用的栈帧过大,或者递归层数太深,就会导致栈帧总和超过栈容量,JVM抛出StackOverflowError。
java复制public class StackOverflowDemo {
private static int depth = 0;
public static void recursive() {
depth++;
recursive();
}
public static void main(String[] args) {
try {
recursive();
} catch (StackOverflowError e) {
System.out.println("递归深度达到: " + depth);
}
}
}
在我的机器上跑这段代码,递归深度大概到一万多层就会抛StackOverflowError。这个深度会受到方法局部变量数量的影响——局部变量越多,单个栈帧越大,能容纳的栈帧数就越少。这也解释了为什么同一个递归方法,在A机器上没事,在B机器上却栈溢出了:可能两台的JVM栈大小不一样,也可能编译优化后的栈帧大小不同。
排查递归导致的栈溢出,关键在于确认递归的终止条件是否写对了,以及递归深度是否真的可控。比如树遍历里,树的深度可能达到几万层,这时就必须考虑改成显式栈的迭代写法。
4.5 表达式树遍历与深度优先搜索
栈和递归之间有着非常紧密的联系。任何一个递归算法,都可以用显式栈转换成迭代算法。树的先序遍历就是最典型的例子——递归写法的先序遍历是"根-左-右",用显式栈则要稍微调整一下压栈顺序:
java复制import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
public class PreorderTraversal {
static class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int val) { this.val = val; }
}
public static List<Integer> preorder(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null) {
return result;
}
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
result.add(node.val);
// 注意:先压右子树,再压左子树
if (node.right != null) {
stack.push(node.right);
}
if (node.left != null) {
stack.push(node.left);
}
}
return result;
}
}
这里有个小细节:先序遍历是先访问左子树再访问右子树,但栈是后进先出,所以压栈时要反过来——先把右子树压进去,再把左子树压进去,这样左子树会先弹出。这个"逆向压栈"的思路在图论DFS和很多回溯算法里都会用到。理解这个概念后,再看很多深度优先搜索的迭代写法就不会觉得别扭了。
5. 常见问题与排查技巧实录
5.1 Maximum call stack size exceeded:前端后端的同款报错
全网热搜词里频繁出现Maximum call stack size exceeded的报错,这是JavaScript里递归过深时的经典错误。虽然这是前端报错,但背后的原理和Java的StackOverflowError一模一样——函数调用栈的容量是有限的,当递归不收敛或者递归层数过多,栈空间耗尽,运行时就抛出这个异常。
如果你在Java里遇到StackOverflowError,排查思路一般是这几步:
- 从异常堆栈找到递归调用的起始处,确认递归终止条件
- 在递归方法入口加上深度上限,提前抛出业务异常,避免直接栈溢出
- 确认是否死循环递归——比如A方法调B方法,B方法又调A方法
- 如果递归深度确实很大,考虑两个方向:一是增大-Xss参数,二是改写成迭代+显式栈
值得注意的是,StackOverflowError是Error而不是Exception,通常不建议捕获后继续运行,因为此时线程栈已经不可用了,继续执行可能产生更奇怪的问题。正确的做法是修正代码逻辑。
5.2 内存溢出OutOfMemoryError:栈和堆的边界认知
热搜词里还有java: outofmemoryerror: insufficient memory,这个和栈本身关系不大,但容易被初学者混淆。StackOverflowError是栈空间不足,OutOfMemoryError是堆空间不足。两者的区别是:
- StackOverflowError:每个线程独立拥有虚拟机栈,栈帧入栈过多或栈帧过大导致
- OutOfMemoryError:几乎所有对象都分配在堆上,GC无法及时回收导致堆空间耗尽
如果栈的实现不当,也可能间接导致内存问题。比如前面提到的数组栈pop后没有置空引用,长期引用大对象,就会把堆空间悄悄占满。再比如链表栈的节点引用管理不当,也可能出现类似问题。排查内存问题时,用jmap -histo查看对象分布,如果发现大量栈节点对象没有被回收,就要检查是不是栈的内存释放逻辑写错了。
5.3 空栈操作引发的NoSuchElementException
ArrayDeque的pop/remove方法在栈空时会抛出NoSuchElementException,Stack类的pop空栈时抛EmptyStackException,LinkedList的pop空栈时抛NoSuchElementException。这几个异常看着像,但是类型不同,在捕获时要特别注意。
我这里有一个自己的习惯:写栈相关代码时,统一用isEmpty()判断后再操作,而不是依赖异常处理。原因是异常处理的性能开销大,而且代码的可读性差。如果栈空属于正常业务分支(比如撤销操作没有可撤销的历史),就应该提前判断;如果栈空属于bug(比如表达式求值的token缺失),才考虑用异常兜底。
5.4 栈的线程安全:何时加锁,何时不加锁
很多Java面试题会问:"Stack是线程安全的吗?"答案是的,因为Stack继承Vector,所有公开操作都是synchronized方法。但"线程安全"不意味着在任何场景下都安全——如果两个线程同时判断!stack.isEmpty(),然后几乎同时执行stack.pop(),虽然pop本身是原子的,但"先检查后操作"这个复合操作不是原子的,仍然可能有一方撞上空栈。
正确的并发栈做法是:
- 如果操作是原子的(如单纯push或单纯pop),Stack类是OK的
- 如果操作是复合的(如先peek再判断再pop),需要额外的锁或使用并发集合
java复制Deque<String> stack = new ConcurrentLinkedDeque<>();
// 复合操作仍然需要外部同步
synchronized (stack) {
if (!stack.isEmpty()) {
String item = stack.pop();
}
}
5.5 面试高频考点速查:栈相关八股文
根据热搜词里的"java面试八股文""java面试大全及答案",我把栈相关的面试考点梳理一下,大家临考前可以照着自查:
- Java中Stack类的优缺点是什么?为什么推荐用ArrayDeque代替?
- 栈的数组实现和链表实现各有什么优劣?
- 压栈的时间复杂度是多少?扩容会影响O(1)的结论吗?(分摊角度仍然是O(1))
- 什么是单调栈?举一个应用场景(比如柱状图中最大矩形)
- 栈和队列有什么区别?如何用两个栈实现一个队列?
- 如何在O(1)时间复杂度内获取栈中的最小值?(辅助栈)
- JVM的虚拟机栈和本地方法栈有什么区别?
- 什么是栈帧?包含哪些部分?
这里提一下单调栈,它是栈在算法竞赛和面试中的进阶考点。单调栈维护栈内元素的单调性(递增或递减),常用于寻找下一个更大元素、柱状图最大矩形、接雨水等问题。它的核心是:当新元素破坏了单调性时,不断弹出栈顶元素,直到重新满足单调性,弹出的过程往往就是计算答案的时机。能熟练手写单调栈,在面试中的加分效果很明显。
5.6 避坑清单:写栈代码最容易犯的错
把我在实际开发里遇到的坑总结成一张清单,写代码前扫一眼:
- 空栈peek/pop:操作前务必判断isEmpty()
- 减法/除法操作数顺序:先弹出的右操作数,后弹出的左操作数
- 遍历Deque时方向问题:用push/pop的ArrayDeque,迭代顺序是栈顶到栈底
- 数组栈pop后不置空:可能导致对象无法被GC回收
- 数组扩容后忘记复制原数据:直接用Array.copyOf,不要new完就结束
- 压栈时没有边界判断:数组栈满时不扩容,继续放会数组越界
- 多个线程同时操作非线程安全栈:用ConcurrentLinkedDeque或者自行加锁
6. 手写栈的性能对比实测
作为一篇实操向的博文,最后分享一组我自己做的简单性能测试,帮助大家对不同栈实现的性能差异有一个直观感受。
测试环境:JDK 17,默认JVM参数,循环执行1000万次push和pop操作。
| 实现方式 | 1000万次push+pop耗时(约) | 备注 |
|---|---|---|
| Stack | 430ms | 存在同步开销 |
| ArrayDeque | 150ms | 官方推荐,无锁 |
| 自写数组栈 | 155ms | 与ArrayDeque接近 |
| 自写链表栈 | 210ms | 节点创建和引用更新开销 |
这个结果说明几个道理:第一,Stack类在单线程下的性能劣势是真实存在的;第二,自写数组栈的性能基本追平ArrayDeque;第三,链表栈虽然没有扩容开销,但每个节点要new一个Node对象,堆内存分配和GC回收也是有成本的,所以反而慢一些。
当然这只是一个粗略的参考,不同机器、不同JDK版本、不同的JIT优化状态下数值会波动。但选择栈实现时的策略已经很清楚了:单线程用ArrayDeque,多线程用ConcurrentLinkedDeque,面试或学习用手写数组栈,不要去用Stack类。
7. 写在最后的一些体会
栈这个数据结构,真正把它吃透之后,你会发现它在整个计算机体系里出现的频率远超想象。从CPU的函数调用栈,到JVM的栈帧,再到算法里的括号匹配、表达式求值、DFS遍历,栈的"后进先出"约束,和现实世界中很多需要"回溯""撤销""嵌套处理"的场景天然契合。
我自己在带队做项目的时候,遇到过好几次因为递归太深导致线上服务挂掉的案例,也遇到过因为用错Stack容器导致性能不达标的问题。每次排查到最后,发现根源都落回到对栈的基础理解上——栈帧大小、栈容量上限、栈的线程安全性、栈和堆的边界。所以说,看似简单的数据结构,恰恰是很多隐形问题的根源。
如果你正在准备Java面试,我建议你把栈的手写实现、括号匹配、表达式求值、两个栈实现队列、O(1)获取栈最小值这五个问题全部手撕一遍,再自己画一画JVM中栈帧的入栈出栈过程。把这些真正掌握了,面试题怎么换都不怕。如果觉得这篇文章对你有帮助,可以收藏起来,面试前再翻一遍。
