Java 集合中的队列与列表使用详解
1. 同步队列(SynchronousQueue)
同步队列(SynchronousQueue)常用于工作共享系统,在该系统中,设计确保有足够的消费者线程,从而使生产者线程无需等待即可移交任务。在这种情况下,它允许线程之间安全地传输任务数据,而无需承担阻塞队列(BlockingQueue)对每个传输任务进行入队和出队操作的开销。
从集合方法的角度来看,同步队列表现得像一个空集合。队列和阻塞队列方法的行为就如同一个容量为零的队列,因此它始终为空。其迭代器方法返回一个空迭代器,其中
hasNext
方法始终返回
false
。
2. 双端队列(Deque)
双端队列(Deque,发音为 “deck”)是一种双端队列。与普通队列不同,普通队列只能在尾部插入元素,在头部检查或移除元素,而双端队列可以在两端接受元素插入,并在两端进行元素的检查或移除。并且,与普通队列不同的是,双端队列的契约规定了其元素的呈现顺序:它是一种线性结构,尾部添加的元素会以相同的顺序在头部被取出。因此,当用作队列时,双端队列始终是一个先进先出(FIFO)结构,其契约不允许出现优先级双端队列的情况。如果元素从添加的同一端(头部或尾部)被移除,双端队列则充当栈或后进先出(LIFO)结构。
双端队列接口(Deque)扩展了队列接口(Queue),提供了关于头部和尾部对称的方法。为了命名清晰,队列接口中隐式引用队列一端的方法在双端队列中有了同义词,以明确其行为。例如,从队列接口继承的
peek
和
offer
方法分别等同于
peekFirst
和
offerLast
。
2.1 类似集合的方法
-
void addFirst(E e):如果有足够空间,在头部插入元素e。 -
void addLast(E e):如果有足够空间,在尾部插入元素e。 -
void push(E e):如果有足够空间,在头部插入元素e,此方法是addFirst的同义词,用于将双端队列用作栈。 -
boolean removeFirstOccurrence(Object o):移除元素o的第一次出现。 -
boolean removeLastOccurrence(Object o):移除元素o的最后一次出现。 -
Iterator<E> descendingIterator():获取一个迭代器,以逆序返回双端队列的元素。
addFirst
和
addLast
方法的契约与集合的
add
方法类似,但额外指定了要添加元素的位置,并且如果无法添加元素,将抛出
IllegalStateException
异常。与有界队列一样,有界双端队列的用户应避免使用这些方法,而应优先使用
offerFirst
和
offerLast
方法,它们可以通过返回布尔值来报告 “正常” 失败情况。
2.2 类似队列的方法
-
boolean offerFirst(E e):如果双端队列有空间,在头部插入元素e。 -
boolean offerLast(E e):如果双端队列有空间,在尾部插入元素e,此方法是队列接口中offer方法的重命名。 -
返回
null表示队列为空的方法: -
E peekFirst():检索但不移除第一个元素。 -
E peekLast():检索但不移除最后一个元素。 -
E pollFirst():检索并移除第一个元素。 -
E pollLast():检索并移除最后一个元素。 - 队列为空时抛出异常的方法:
-
E getFirst():检索但不移除第一个元素。 -
E getLast():检索但不移除最后一个元素。 -
E removeFirst():检索并移除第一个元素。 -
E removeLast():检索并移除最后一个元素。 -
E pop():检索并移除第一个元素,此方法是removeFirst的同义词,用于栈操作。
3. 双端队列的实现
3.1 数组双端队列(ArrayDeque)
Java 6 引入了基于循环数组的高效实现
ArrayDeque
,类似于
ArrayBlockingQueue
所使用的循环数组。它填补了队列类中的一个空白,在单线程环境中,如果需要一个先进先出(FIFO)队列,以前可能需要使用
LinkedList
类(但应避免将其作为通用队列实现),或者使用并发类
ArrayBlockingQueue
或
LinkedBlockingQueue
而承担不必要的线程安全开销。现在,
ArrayDeque
是双端队列和 FIFO 队列的通用首选实现。它具有循环数组的性能特点:在头部或尾部添加或移除元素的时间复杂度为常数。其迭代器是快速失败的。
3.2 链表(LinkedList)
在双端队列的实现中,
LinkedList
是一个特例。例如,它是唯一允许
null
元素的实现,而队列接口不鼓励使用
null
元素,因为通常将
null
用作特殊值。
LinkedList
从一开始就存在于集合框架中,最初是列表(List)的标准实现之一,在 Java 5 中被适配了队列的方法,在 Java 6 中被适配了双端队列的方法。它基于类似于跳表的链表结构,但每个节点有一个额外的字段,指向先前的条目,这使得列表可以反向遍历,例如用于反向迭代或从列表末尾移除元素。
作为双端队列的实现,
LinkedList
不太可能受欢迎。其主要优点是插入和移除操作的时间复杂度为常数,但在 Java 6 中,对于队列和双端队列,
ArrayDeque
在其他方面更具优势。以前,在不需要线程安全且不需要阻塞行为的情况下会使用它。现在,使用
LinkedList
作为队列或双端队列实现的唯一可能原因是还需要对元素进行随机访问,但即使如此,这也需要付出高昂的代价,因为随机访问必须通过线性搜索实现,其时间复杂度为 $O(n)$。
4. 阻塞双端队列(BlockingDeque)
阻塞双端队列(BlockingDeque)为阻塞队列(BlockingQueue)添加了一些方法。阻塞队列的两个阻塞插入方法和两个移除方法都有同义词,以明确修改的是双端队列的哪一端,并提供了在另一端执行相同操作的匹配方法。因此,与阻塞队列定义的四种基本行为(失败时返回特殊值、超时后失败返回特殊值、失败时抛出异常以及阻塞直到成功)相同,这些行为也可应用于双端队列两端的元素插入或移除操作。
随着多核和多处理器架构成为标准,良好的负载均衡算法将变得越来越重要。并发双端队列是最佳负载均衡方法之一 —— 工作窃取(work stealing)的基础。为了理解工作窃取,想象一个负载均衡算法以某种方式(例如轮询)将任务分配到一系列队列中,每个队列都有一个专用的消费者线程,该线程反复从其队列的头部取出一个任务,处理它,然后返回获取另一个任务。虽然这种方案通过并行性提高了速度,但它有一个主要缺点:可以想象两个相邻的队列,一个队列积压了大量长任务,消费者线程难以跟上,而旁边的队列是空的,消费者线程闲置等待工作。如果允许闲置线程从另一个队列的头部取出一个任务,显然会提高吞吐量。工作窃取进一步改进了这个想法,考虑到闲置线程从另一个队列的头部窃取工作可能会导致对头部元素的竞争,它将队列改为双端队列,并指示闲置线程从另一个线程的双端队列的尾部取出一个任务。事实证明,这是一种非常高效的机制,并且正在被广泛使用。
阻塞双端队列接口(BlockingDeque)只有一个实现:
LinkedBlockingDeque
。它基于类似于
LinkedList
的双向链表结构。它可以选择有界,因此除了两个标准构造函数外,还提供了一个可以指定其容量的构造函数:
LinkedBlockingDeque(int capacity)
它的性能特点与
LinkedBlockingQueue
类似:队列插入和移除操作的时间复杂度为常数,而需要遍历队列的操作(如
contains
)的时间复杂度为线性。其迭代器是弱一致性的。
5. 队列实现的比较
以下表格展示了我们讨论过的双端队列和队列实现的一些示例操作的顺序性能,不考虑锁定和 CAS 开销。这些结果有助于理解所选实现的行为,但正如前面提到的,它们不太可能是决定因素。选择更可能由应用程序的功能和并发要求决定。
| 实现 | offer | peek | poll | size |
|---|---|---|---|---|
| PriorityQueue | $O(log n)$ | $O(1)$ | $O(log n)$ | $O(1)$ |
| ConcurrentLinkedQueue | $O(1)$ | $O(1)$ | $O(1)$ | $O(n)$ |
| ArrayBlockingQueue | $O(1)$ | $O(1)$ | $O(1)$ | $O(1)$ |
| LinkedBlockingQueue | $O(1)$ | $O(1)$ | $O(1)$ | $O(1)$ |
| PriorityBlockingQueue | $O(log n)$ | $O(1)$ | $O(log n)$ | $O(1)$ |
| DelayQueue | $O(log n)$ | $O(1)$ | $O(log n)$ | $O(1)$ |
| LinkedList | $O(1)$ | $O(1)$ | $O(1)$ | $O(1)$ |
| ArrayDeque | $O(1)$ | $O(1)$ | $O(1)$ | $O(1)$ |
| LinkedBlockingDeque | $O(1)$ | $O(1)$ | $O(1)$ | $O(1)$ |
在选择队列时,首先要问的问题是所选实现是否需要支持并发访问。如果不需要,选择很简单:对于 FIFO 排序,选择
ArrayDeque
;对于优先级排序,选择
PriorityQueue
。
如果应用程序需要线程安全,则接下来需要考虑排序。如果需要优先级或延迟排序,显然应分别选择
PriorityBlockingQueue
或
DelayQueue
。另一方面,如果 FIFO 排序可以接受,那么第三个问题是是否需要阻塞方法,这通常是生产者 - 消费者问题所需要的(要么是因为消费者必须通过等待处理空队列,要么是因为希望通过限制队列大小来约束对消费者的需求,从而生产者有时必须等待)。如果不需要阻塞方法或队列大小的限制,选择高效且无等待的
ConcurrentLinkedQueue
。
如果确实需要一个阻塞队列,因为应用程序需要支持生产者 - 消费者合作,那么需要思考是否真的需要缓冲数据,或者是否只需要在线程之间安全地移交数据。如果可以不进行缓冲(通常是因为有足够的消费者来防止数据堆积),那么同步队列(SynchronousQueue)是剩余的 FIFO 阻塞实现(
LinkedBlockingQueue
和
ArrayBlockingQueue
)的有效替代方案。
否则,最终需要在
LinkedBlockingQueue
和
ArrayBlockingQueue
之间进行选择。如果无法确定队列大小的实际上限,则必须选择
LinkedBlockingQueue
,因为
ArrayBlockingQueue
总是有界的。对于有界使用,将根据性能在两者之间进行选择。它们在顺序访问方面的性能特点相同,但在并发使用中的表现是不同的问题。如前所述,如果服务的线程超过三四个,
LinkedBlockingQueue
总体上比
ArrayBlockingQueue
表现更好,这与
LinkedBlockingQueue
的头部和尾部独立锁定,允许两端同时更新的事实相符。另一方面,
ArrayBlockingQueue
在每次插入时不需要分配新对象。如果队列性能对应用程序的成功至关重要,应该使用对自己最重要的基准(即应用程序本身)来衡量这两种实现。
5. 列表(List)
列表(List)可能是 Java 实践中使用最广泛的集合。与集合(Set)不同,列表可以包含重复元素;与队列不同,列表让用户可以完全了解和控制其元素的顺序。对应的集合框架接口是
List
。
除了从集合继承的操作外,列表接口还包括以下操作:
5.1 位置访问方法
这些方法根据元素在列表中的数字位置访问元素:
-
void add(int index, E e)
:在给定索引处添加元素
e
。
-
boolean addAll(int index, Collection<? extends E> c)
:在给定索引处添加集合
c
的内容。
-
E get(int index)
:返回给定索引的元素。
-
E remove(int index)
:移除给定索引的元素。
-
E set(int index, E e)
:用元素
e
替换给定索引的元素。
5.2 搜索方法
这些方法在列表中搜索指定对象并返回其数字位置。如果对象不存在,这些方法返回 -1:
-
int indexOf(Object o)
:返回对象
o
第一次出现的索引。
-
int lastIndexOf(Object o)
:返回对象
o
最后一次出现的索引。
5.3 范围视图方法
-
List<E> subList(int fromIndex, int toIndex):返回列表一部分的视图。该方法的工作方式类似于有序集合(SortedSet)上的subSet操作,但使用元素在列表中的位置而不是其值:返回的列表包含从fromIndex开始到toIndex(不包括)的列表元素。返回的列表没有独立的存在,它只是获取它的列表的一部分视图,因此对它的更改会反映在原始列表中。但与subSet有一个重要区别:对子列表所做的更改会写入到后备列表,但反之不一定成立。如果通过直接调用后备列表的 “结构更改” 方法插入或移除了元素,后续尝试使用子列表将导致ConcurrentModificationException。
5.4 列表迭代方法
这些方法返回一个
ListIterator
,它是一个具有扩展语义的迭代器,利用了列表的顺序性质:
public interface ListIterator<E> extends Iterator<E> {
void add(E e); // 在列表中插入指定元素
boolean hasPrevious(); // 如果此列表迭代器在反向方向上有更多元素,则返回 true
int nextIndex(); // 返回后续调用 next 方法将返回的元素的索引
E previous(); // 返回列表中的前一个元素
int previousIndex(); // 返回后续调用 previous 方法将返回的元素的索引
void set(E e); // 用指定元素替换 next 或 previous 方法返回的最后一个元素
}
ListIterator
在
Iterator
的
hasNext
、
next
和
remove
方法基础上添加了上述方法。其当前位置始终位于两个元素之间,因此在长度为
n
的列表中,有
n + 1
个有效的列表迭代器位置,从 0(第一个元素之前)到
n
(最后一个元素之后)。
listIterator
的第二个重载方法使用提供的值将列表迭代器的初始位置设置为这些位置之一(无参数调用
listIterator
等同于提供参数 0)。
以一个包含三个元素的列表为例,考虑一个位于位置 2 的迭代器(无论是从其他位置移动到这里还是通过调用
listIterator(2)
创建):
-
add
方法在当前迭代器位置(索引 1 和 2 的元素之间)插入一个元素。
-
hasPrevious
和
hasNext
方法返回
true
。
-
previous
和
next
方法分别返回索引 1 和 2 的元素。
-
previousIndex
和
nextIndex
方法分别返回这些索引本身。
在列表的极端位置(图中的 0 和 3),
previousIndex
和
nextIndex
方法分别返回 -1 和 3(列表的大小),
previous
或
next
方法分别会抛出
NoSuchElementException
。
set
和
remove
方法的工作方式不同。它们的效果不取决于迭代器的当前位置,而是取决于其 “当前元素”,即最后一次使用
next
或
previous
方法遍历过的元素:
set
方法替换当前元素,
remove
方法移除它。如果没有当前元素(要么是因为迭代器刚刚创建,要么是因为当前元素已被移除),这些方法将抛出
IllegalStateException
。
6. 列表方法的使用示例
下面是一个基于列表的任务调度器示例:
public class TaskScheduler {
private List<StoppableTaskQueue> schedule;
private final int FORWARD_PLANNING_DAYS = 365;
public TaskScheduler() {
List<StoppableTaskQueue> temp = new ArrayList<StoppableTaskQueue>();
for (int i = 0 ; i < FORWARD_PLANNING_DAYS ; i++) {
temp.add(new StoppableTaskQueue());
}
schedule = new CopyOnWriteArrayList<StoppableTaskQueue>(temp); //1
}
public PriorityTask getTask() {
for (StoppableTaskQueue daysTaskQueue : schedule) {
PriorityTask topTask = daysTaskQueue.getTask();
if (topTask != null) return topTask;
}
return null; // no outstanding tasks - at all!?
}
// at midnight, remove and shut down the queue for day 0, assign its tasks
// to the new day 0, and create a new day's queue at the planning horizon
public void rollOver() throws InterruptedException{
StoppableTaskQueue oldDay = schedule.remove(0);
Collection<PriorityTask> remainingTasks = oldDay.shutDown();
StoppableTaskQueue firstDay = schedule.get(0);
for (PriorityTask t : remainingTasks) {
firstDay.addTask(t);
}
StoppableTaskQueue lastDay = new StoppableTaskQueue();
schedule.add(lastDay);
}
public void addTask(PriorityTask task, int day) {
if (day < 0 || day >= FORWARD_PLANNING_DAYS)
throw new IllegalArgumentException("day out of range");
StoppableTaskQueue daysTaskQueue = schedule.get(day);
if (daysTaskQueue.addTask(task)) return; //2
// StoppableTaskQueue.addTask returns false only when called on
// a queue that has been shut down. In that case, it will also
// have been removed by now, so it's safe to try again.
if (! schedule.get(0).addTask(task)) {
throw new IllegalStateException("failed to add task " + task);
}
}
}
这个示例主要展示了列表接口方法的使用。选择实现时,主要考虑因素是应用程序的并发需求。客户端在消费或生产任务时只读取表示日程安排的列表,因此(一旦构造完成)只有在一天结束时才会对其进行写入操作。此时,当前日期的队列从日程安排中移除,并在末尾添加一个新队列。不需要在这之前排除客户端使用当前日期的队列,因为
StoppableTaskQueue
的设计确保了队列停止后客户端能够有序完成操作。因此,唯一需要排除的情况是确保客户端在翻转过程更改日程安排的值时不尝试读取它。
CopyOnWriteArrayList
很好地满足了这些需求。它优化了读访问,符合我们的一个需求。在进行写操作时,它会同步足够长的时间来创建其内部后备数组的新副本,从而满足了防止读写操作相互干扰的另一个需求。
在选择实现后,可以理解示例中的构造函数:向列表写入操作开销较大,因此使用转换构造函数一次性设置一年的任务队列是明智的(代码行
//1
)。
getTask
方法很简单,只需遍历任务队列,从今天的队列开始,查找已安排的任务。如果没有找到未完成的任务,则返回
null
。
每天午夜,系统会调用
rollOver
方法,该方法实现了关闭旧日期的任务队列并将其中剩余任务转移到新日期的操作。这里的事件顺序很重要:
rollOver
方法首先从列表中移除队列,此时生产者和消费者可能仍要插入或移除元素。然后调用
StoppableTaskQueue.shutDown
方法,该方法返回队列中剩余的任务并保证不会再添加新任务。根据操作的进度,
addTask
方法的调用要么完成,要么返回
false
,表示由于队列已关闭而失败。
这就解释了
addTask
方法的逻辑:
StoppableTaskQueue
的
addTask
方法返回
false
的唯一情况是调用的队列已经停止。由于唯一停止的队列是第 0 天的队列,
addTask
返回
false
一定是因为生产者线程在午夜翻转之前获取了该队列的引用。在这种情况下,列表中元素 0 的当前值现在是新的第 0 天队列,可以安全地再次尝试。如果第二次尝试失败,线程已经被挂起了 24 小时!
需要注意的是,
rollOver
方法的开销相当大,它会对日程安排进行两次写入操作,由于日程安排由
CopyOnWriteArrayList
表示,每次写入都会导致整个后备数组被复制。选择这种实现的理由是,与迭代遍历日程安排的
getTask
方法的调用次数相比,
rollOver
方法的调用非常少。
综上所述,在 Java 中使用队列和列表时,需要根据具体的应用场景和需求来选择合适的实现,同时要考虑到并发、性能等多方面的因素。通过合理运用这些集合类,可以提高程序的效率和可维护性。
Java 集合中的队列与列表使用详解
7. 队列与列表选择决策流程
为了更清晰地展示在不同场景下如何选择合适的队列或列表实现,我们可以通过以下 mermaid 流程图来进行说明:
graph TD;
A[是否需要支持并发访问?] -->|否| B[FIFO 排序选 ArrayDeque,优先级排序选 PriorityQueue];
A -->|是| C[是否需要优先级或延迟排序?];
C -->|是| D[选 PriorityBlockingQueue 或 DelayQueue];
C -->|否| E[是否需要阻塞方法?];
E -->|否| F[选 ConcurrentLinkedQueue];
E -->|是| G[是否需要缓冲数据?];
G -->|否| H[选 SynchronousQueue];
G -->|是| I[能否确定队列大小上限?];
I -->|否| J[选 LinkedBlockingQueue];
I -->|是| K[根据性能选 LinkedBlockingQueue 或 ArrayBlockingQueue];
这个流程图详细地展示了在选择队列时需要考虑的各个因素以及对应的选择结果,帮助开发者在实际应用中做出更合适的决策。
8. 列表操作示例分析
下面我们对前面提到的
TaskScheduler
类中的方法进行更深入的分析,以更好地理解列表操作在实际应用中的使用:
-
构造函数
:
public TaskScheduler() {
List<StoppableTaskQueue> temp = new ArrayList<StoppableTaskQueue>();
for (int i = 0 ; i < FORWARD_PLANNING_DAYS ; i++) {
temp.add(new StoppableTaskQueue());
}
schedule = new CopyOnWriteArrayList<StoppableTaskQueue>(temp); //1
}
这里先使用
ArrayList
临时存储任务队列,然后通过
CopyOnWriteArrayList
的转换构造函数将其转换为最终的
schedule
列表。这样做的好处是,
ArrayList
在添加元素时性能较好,而
CopyOnWriteArrayList
则提供了线程安全的读操作和写时复制的特性,适合该应用场景。
-
getTask
方法
:
public PriorityTask getTask() {
for (StoppableTaskQueue daysTaskQueue : schedule) {
PriorityTask topTask = daysTaskQueue.getTask();
if (topTask != null) return topTask;
}
return null; // no outstanding tasks - at all!?
}
该方法遍历
schedule
列表中的所有任务队列,依次调用每个队列的
getTask
方法,直到找到一个非空的任务并返回。如果所有队列都没有任务,则返回
null
。
-
rollOver
方法
:
public void rollOver() throws InterruptedException{
StoppableTaskQueue oldDay = schedule.remove(0);
Collection<PriorityTask> remainingTasks = oldDay.shutDown();
StoppableTaskQueue firstDay = schedule.get(0);
for (PriorityTask t : remainingTasks) {
firstDay.addTask(t);
}
StoppableTaskQueue lastDay = new StoppableTaskQueue();
schedule.add(lastDay);
}
此方法在每天午夜调用,用于处理任务队列的翻转。首先,从
schedule
列表中移除第 0 天的队列,并调用其
shutDown
方法获取剩余任务。然后,将这些剩余任务添加到新的第 0 天队列中。最后,创建一个新的任务队列并添加到
schedule
列表的末尾。
-
addTask
方法
:
public void addTask(PriorityTask task, int day) {
if (day < 0 || day >= FORWARD_PLANNING_DAYS)
throw new IllegalArgumentException("day out of range");
StoppableTaskQueue daysTaskQueue = schedule.get(day);
if (daysTaskQueue.addTask(task)) return; //2
// StoppableTaskQueue.addTask returns false only when called on
// a queue that has been shut down. In that case, it will also
// have been removed by now, so it's safe to try again.
if (! schedule.get(0).addTask(task)) {
throw new IllegalStateException("failed to add task " + task);
}
}
该方法用于向指定日期的任务队列中添加任务。首先检查日期是否合法,如果不合法则抛出异常。然后尝试将任务添加到指定日期的队列中,如果添加成功则直接返回。如果添加失败,说明该队列可能已关闭,此时尝试将任务添加到第 0 天的队列中,如果仍然失败则抛出异常。
9. 不同队列和列表实现的性能总结
下面通过表格再次总结不同队列和列表实现的一些常见操作的性能:
| 实现 | 添加元素 | 移除元素 | 随机访问 | 迭代 |
| — | — | — | — | — |
| ArrayDeque | 头部/尾部:$O(1)$ | 头部/尾部:$O(1)$ | 不支持 | 快速失败,$O(n)$ |
| LinkedList | 头部/尾部:$O(1)$,中间:$O(n)$ | 头部/尾部:$O(1)$,中间:$O(n)$ | $O(n)$ | 可双向迭代,$O(n)$ |
| CopyOnWriteArrayList | $O(n)$ | $O(n)$ | $O(1)$ | 弱一致性,$O(n)$ |
| PriorityQueue | $O(log n)$ | $O(log n)$ | 不支持 | 无顺序保证,$O(n)$ |
| ConcurrentLinkedQueue | $O(1)$ | $O(1)$ | 不支持 | 弱一致性,$O(n)$ |
| LinkedBlockingQueue | $O(1)$ | $O(1)$ | 不支持 | 弱一致性,$O(n)$ |
| ArrayBlockingQueue | $O(1)$ | $O(1)$ | 不支持 | 快速失败,$O(n)$ |
从这个表格中可以清晰地看到不同实现的性能特点,开发者可以根据具体的应用需求和操作特点来选择合适的实现。
10. 总结
在 Java 开发中,队列和列表是非常重要的集合类型,它们各自有不同的特点和适用场景。队列主要用于处理元素的顺序处理,如生产者 - 消费者问题;而列表则更侧重于对元素的位置访问和控制。在选择队列或列表的实现时,需要综合考虑并发需求、排序要求、是否需要阻塞方法、是否需要缓冲数据以及队列大小上限等因素。通过合理选择和使用这些集合实现,可以提高程序的性能和可维护性。同时,通过实际的代码示例,我们也看到了这些集合操作在实际应用中的具体使用方式,帮助我们更好地理解和掌握它们。希望本文的内容能够对开发者在 Java 集合的使用上提供一些有价值的参考。
超级会员免费看
1117

被折叠的 条评论
为什么被折叠?



