栈的基本结构和使用场景
栈是一种“后进先出”(LIFO)的数据结构,常用于函数调用、表达式求值、括号匹配等场景。比如你在写一个计算器程序,遇到括号嵌套时,用栈来保存临时结果就很方便。
在单线程环境下,栈的操作很简单:push 添加元素,pop 取出元素,不会出什么乱子。但一旦进入多线程世界,多个线程同时操作同一个栈,问题就来了。
为什么栈会有线程安全问题
想象一下超市的自助储物柜,如果系统没做好管理,两个人同时往同一个格子存包,或者一个人正在取包,另一个人却把格子清空了,肯定要出问题。栈也一样。
比如线程 A 正在执行 pop 操作,刚读取了栈顶位置,还没来得及删除数据,线程 B 也进来执行 pop,它读到的栈顶还是原来的位置,结果两个线程可能取到同一个元素,而另一个元素被跳过。更糟的是,可能引发数组越界或空指针异常。
一个简单的非线程安全栈示例
public class SimpleStack <T> {
private List<T> elements = new ArrayList<>();
public void push(T item) {
elements.add(item);
}
public T pop() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
}上面这个栈在多线程环境下运行,很可能出现数据错乱或异常。因为 add 和 remove 都不是原子操作,多个线程同时修改 List 会破坏内部状态。
如何实现线程安全的栈
解决办法有几个方向。最直接的是加锁,保证同一时间只有一个线程能操作栈。
public class ThreadSafeStack <T> {
private final List<T> elements = new ArrayList<>();
private final Object lock = new Object();
public void push(T item) {
synchronized (lock) {
elements.add(item);
}
}
public T pop() {
synchronized (lock) {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
}
}这样虽然安全了,但性能会打折扣,毕竟每次操作都要排队。如果并发量大,可以考虑使用 Java 并发包里的 ConcurrentLinkedDeque 来模拟栈,它基于链表结构,支持高效的并发访问。
使用并发容器替代手动同步
比如这样:
public class ConcurrentStack <T> {
private final Deque<T> deque = new ConcurrentLinkedDeque<>();
public void push(T item) {
deque.push(item);
}
public T pop() {
T result = deque.poll();
if (result == null) {
throw new EmptyStackException();
}
return result;
}
}这种方式不用显式加锁,底层由并发容器自己处理线程安全,适合高并发场景。
别忘了边界情况
即使加了锁,也要注意空栈时的 pop 操作。多个线程同时发现栈不为空,其中一个 pop 后栈变空,其他线程再 pop 就可能出问题。所以判断和操作必须放在同一个同步块里,不能拆开。
另外,如果业务允许,也可以让每个线程拥有自己的栈实例,彻底避免共享,这叫“线程本地存储”,用 ThreadLocal 就能实现。