《Java并发编程的艺术》-Java并发包中的读写锁及其实现分析 | 并发编程网 – ifeve.com
锁可以控制多个线程访问共享资源的方式,可以防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发地访问共享资源,如读写锁)。
在不同JDK版本,实现锁的方式不同
(1)javaSE5 之前: 通过synchronized 关键字实现锁的功能
(2)javaSE5 之后: 增加并发包(java.util.concurrent)中Lock接口(及其相关实现类)来实现锁功能,当然synchronized依然可以实现锁的功能。
(1)synchronized可以隐式的获取和释放锁(简化了同步管理、扩展性比较差), 而Lock需要显示的获取和释放锁
(2)synchronized不具有中断获取锁、超时获取锁的功能等同步特性,Lock具有锁释放/获取的可操作性, 具有可中断获取锁,具有超时获取锁等同步特性。
下面是Lock提供的synchronized关键字不具备的特性:
特性 | 描述 |
---|---|
尝试非阻塞的获取锁 | 当前线程尝试获取锁,如果这一时刻锁没有被其它线程获取到,则成功获取并持有锁 |
能被中断的获取锁 | 与synchronized不同,获取到锁的线程可以响应中断,当获取到锁的线程响应中断时,会抛出中断异常,同时释放锁 |
超时获取锁 | 在指定的截至时间之前获取锁,如果截止时间之前任然没有获取到锁,则返回 |
demo演示:
public class MyLockTest implements Runnable {
public synchronized void get() {
System.out.println("2 enter thread name-->" + Thread.currentThread().getName());
//reentrantLock.lock();
System.out.println("3 get thread name-->" + Thread.currentThread().getName());
set();
//reentrantLock.unlock();
System.out.println("5 leave run thread name-->" + Thread.currentThread().getName());
}
public synchronized void set() {
//reentrantLock.lock();
System.out.println("4 set thread name-->" + Thread.currentThread().getName());
//reentrantLock.unlock();
}
@Override
public void run() {
System.out.println("1 run thread name-->" + Thread.currentThread().getName());
get();
}
public static void main(String[] args) {
MyLockTest test = new MyLockTest();
for (int i = 0; i < 10; i++) {
new Thread(test, "thread-" + i).start();
}
}
}
某次运行结果:
1 run thread name-->thread-0
2 enter thread name-->thread-0
3 get thread name-->thread-0
1 run thread name-->thread-1
1 run thread name-->thread-2
4 set thread name-->thread-0
5 leave run thread name-->thread-0
1 run thread name-->thread-3
2 enter thread name-->thread-2
3 get thread name-->thread-2
4 set thread name-->thread-2
5 leave run thread name-->thread-2
2 enter thread name-->thread-1
3 get thread name-->thread-1
4 set thread name-->thread-1
5 leave run thread name-->thread-1
2 enter thread name-->thread-3
3 get thread name-->thread-3
4 set thread name-->thread-3
5 leave run thread name-->thread-3
1 run thread name-->thread-5
2 enter thread name-->thread-5
3 get thread name-->thread-5
4 set thread name-->thread-5
5 leave run thread name-->thread-5
1 run thread name-->thread-7
1 run thread name-->thread-6
2 enter thread name-->thread-7
3 get thread name-->thread-7
4 set thread name-->thread-7
1 run thread name-->thread-4
5 leave run thread name-->thread-7
1 run thread name-->thread-8
2 enter thread name-->thread-8
3 get thread name-->thread-8
4 set thread name-->thread-8
5 leave run thread name-->thread-8
1 run thread name-->thread-9
2 enter thread name-->thread-4
3 get thread name-->thread-4
4 set thread name-->thread-4
5 leave run thread name-->thread-4
2 enter thread name-->thread-6
3 get thread name-->thread-6
4 set thread name-->thread-6
5 leave run thread name-->thread-6
2 enter thread name-->thread-9
3 get thread name-->thread-9
4 set thread name-->thread-9
5 leave run thread name-->thread-9
(a)get()方法中顺利进入了set()方法,说明synchronized的确是可重入锁;
(b)分析打印Log,thread-0先进入get方法体,这个时候thread-1、thread-2、thread-3等待进入,但当thread-0离开时,thread-2却先进入了方法体,没有按照thread-1、thread-2、thread-3的顺序进入get方法体,说明sychronized是非公平锁;
(c)而且在一个线程进入get方法体后,其他线程只能等待,无法同时进入,验证了synchronized是独占锁。
Lock lock = new ReentrantLock();
lock.lock();
try {
}
finally {
lock.unlock();
}
Lock接口的实现基本都是通过聚合了一个同步器的子类来完成线程访问控制的。
注意不要将获取锁的过程写在try中,因为如果在获取锁(自定义锁的实现)发生异常会导致锁无故释放。
线程相互等待对方释放锁会引起死锁问题。
避免死锁的几个常见方法。
(1)避免一个线程同时获取多个锁。
(2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
(3)尝试使用定时锁,使用tryLock(timeout)来替代使用内部锁机制。
(4)对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败。
java.util.concurrent.locks.ReentrantLock
java.util.concurrent.locks.ReentrantReadWriteLock
重入锁ReentrantLock,支持重进入的锁,表示该锁能够支持一个线程对资源的重复加锁。独享锁。还支持获取锁时的公平和非公平性选择(默认是公平的)。查看源码构造公平锁只要在构造器传入boolean值true即可:
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
线程在获取到锁后能够再次获取该锁而不被堵塞,该特性的实现需要以下两个问题:
(1)线程再次获取锁
锁需要识别获取锁的线程是否为当前占据锁的线程,如果是,则能够再次获取锁
(2)锁的最终释放
要求锁对于当前线程获取锁的次数进行计数,锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。
ReentrantLock通过组合自定同步器来实现锁的获取与释放。
如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
公平锁tryAcquire与非公平锁nonfairAcquire方法进行比较,区别在于判断位置多了一个hasQueuedPredecessors方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示线程比当前线程更早地请求获取锁,所以需要等待前驱线程获取并释放锁之后才能继续获取锁。
非公平性锁地开销更小,切换次数较少。
(1)非公平锁
import java.util.concurrent.locks.ReentrantLock;
public class MyLockTest implements Runnable {
private ReentrantLock reentrantLock = new ReentrantLock();
public void get() {
System.out.println("2 enter thread name-->" + Thread.currentThread().getName());
reentrantLock.lock();
System.out.println("3 get thread name-->" + Thread.currentThread().getName());
set();
reentrantLock.unlock();
System.out.println("5 leave run thread name-->" + Thread.currentThread().getName());
}
public void set() {
reentrantLock.lock();
System.out.println("4 set thread name-->" + Thread.currentThread().getName());
reentrantLock.unlock();
}
@Override
public void run() {
System.out.println("1 run thread name-->" + Thread.currentThread().getName());
get();
}
public static void main(String[] args) {
MyLockTest test = new MyLockTest();
for (int i = 0; i < 10; i++) {
new Thread(test, "thread-" + i).start();
}
}
}
某次运行结果:
1 run thread name-->thread-0
2 enter thread name-->thread-0
1 run thread name-->thread-1
2 enter thread name-->thread-1
3 get thread name-->thread-0
4 set thread name-->thread-0
1 run thread name-->thread-3
2 enter thread name-->thread-3
3 get thread name-->thread-3
4 set thread name-->thread-3
5 leave run thread name-->thread-3
1 run thread name-->thread-4
2 enter thread name-->thread-4
3 get thread name-->thread-4
4 set thread name-->thread-4
5 leave run thread name-->thread-4
1 run thread name-->thread-5
2 enter thread name-->thread-5
3 get thread name-->thread-5
4 set thread name-->thread-5
5 leave run thread name-->thread-5
1 run thread name-->thread-7
2 enter thread name-->thread-7
3 get thread name-->thread-7
4 set thread name-->thread-7
5 leave run thread name-->thread-7
5 leave run thread name-->thread-0
3 get thread name-->thread-1
4 set thread name-->thread-1
5 leave run thread name-->thread-1
1 run thread name-->thread-2
2 enter thread name-->thread-2
3 get thread name-->thread-2
4 set thread name-->thread-2
5 leave run thread name-->thread-2
1 run thread name-->thread-9
2 enter thread name-->thread-9
3 get thread name-->thread-9
4 set thread name-->thread-9
5 leave run thread name-->thread-9
1 run thread name-->thread-6
1 run thread name-->thread-8
2 enter thread name-->thread-8
3 get thread name-->thread-8
4 set thread name-->thread-8
5 leave run thread name-->thread-8
2 enter thread name-->thread-6
3 get thread name-->thread-6
4 set thread name-->thread-6
5 leave run thread name-->thread-6
结论:
(a)可重入锁;
(b)独享锁:注意这个例子和synchronized的例子不一样,synchronized修饰的整个方法体都是锁的内容,所以可以从打印的enter、leave的顺序分析是否为独享锁,而ReentrantLock只有lock()和unlock之间的内容为锁,所以只可以分析这段代码的顺序,比如分析打印的get、set的顺序;
(c)非公平锁,thread-0持有锁期间,thread-1等待拥有锁,当thread-0释放锁时thread-3先获取到锁,并非按照先后顺序获取锁的。
(2)公平锁:
上例的构造方法改为:ReentrantLock reentrantLock = new ReentrantLock(true);
一次运行结果为:
1 run thread name-->thread-0
2 enter thread name-->thread-0
3 get thread name-->thread-0
1 run thread name-->thread-2
2 enter thread name-->thread-2
4 set thread name-->thread-0
1 run thread name-->thread-3
2 enter thread name-->thread-3
1 run thread name-->thread-1
2 enter thread name-->thread-1
1 run thread name-->thread-5
2 enter thread name-->thread-5
3 get thread name-->thread-2
4 set thread name-->thread-2
5 leave run thread name-->thread-2
5 leave run thread name-->thread-0
3 get thread name-->thread-3
4 set thread name-->thread-3
5 leave run thread name-->thread-3
1 run thread name-->thread-9
2 enter thread name-->thread-9
3 get thread name-->thread-1
4 set thread name-->thread-1
5 leave run thread name-->thread-1
3 get thread name-->thread-5
4 set thread name-->thread-5
5 leave run thread name-->thread-5
3 get thread name-->thread-9
4 set thread name-->thread-9
5 leave run thread name-->thread-9
1 run thread name-->thread-6
2 enter thread name-->thread-6
3 get thread name-->thread-6
4 set thread name-->thread-6
1 run thread name-->thread-7
5 leave run thread name-->thread-6
2 enter thread name-->thread-7
3 get thread name-->thread-7
4 set thread name-->thread-7
5 leave run thread name-->thread-7
1 run thread name-->thread-4
2 enter thread name-->thread-4
3 get thread name-->thread-4
1 run thread name-->thread-8
2 enter thread name-->thread-8
4 set thread name-->thread-4
5 leave run thread name-->thread-4
3 get thread name-->thread-8
4 set thread name-->thread-8
5 leave run thread name-->thread-8
是ReadWriteLock的实现类。
之前的锁(mutex和ReentranLock)都是排他锁,同一时刻只允许一个线程访问,而读写锁在同一时刻可以允许多个读线程访问,但在写线程访问时,所有的读线程和其他写线程均被堵塞。读写锁维护了一对锁,一个读锁和一个写锁。
有三个特性:公平性选择,重进入,锁降级。
公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平 |
重进入 | 该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁 |
锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁 |
ReadWriteLock仅定义了获取读锁和写锁的两个方法,ReadLock()和WriteLock()方法,其实现ReentrantReadWriteLock除了接口方法,还提供了一些便于外界控制其内部工作状态的方法:
Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的、提升读操作的并发性、保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。
(1)在读操作get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。
(2)写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。
public class Cache {
static Map map = new HashMap();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
// 获取一个key对应的value
public static final Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 设置key对应的value,并返回旧的value
public static final Object put(String key, Object value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// 清空所有的内容
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
}
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class MyLockTest {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
Cache.put("key", new String(Thread.currentThread().getName() + " joke"));
}
}, "threadW-" + i).start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Cache.get("key"));
}
}, "threadR-" + i).start();
new Thread(new Runnable() {
@Override
public void run() {
Cache.clear();
}
}, "threadC-" + i).start();
}
}
}
class Cache {
static Map map = new HashMap();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
// 获取一个key对应的value
public static final Object get(String key) {
r.lock();
try {
System.out.println("get " + Thread.currentThread().getName());
return map.get(key);
} finally {
r.unlock();
}
}
// 设置key对应的value,并返回旧有的value
public static final Object put(String key, Object value) {
w.lock();
try {
System.out.println("put " + Thread.currentThread().getName());
return map.put(key, value);
} finally {
w.unlock();
}
}
// 清空所有的内容
public static final void clear() {
w.lock();
try {
System.out.println("clear " + Thread.currentThread().getName());
map.clear();
} finally {
w.unlock();
}
}
}
某次运行结果, 可看到普通HashMap在多线程中数据可见性正常。
put threadW-0
clear threadC-1
put threadW-1
get threadR-1
threadW-1 joke
put threadW-2
get threadR-0
threadW-2 joke
clear threadC-0
get threadR-2
null
clear threadC-4
clear threadC-2
clear threadC-3
put threadW-4
put threadW-3
get threadR-3
threadW-3 joke
put threadW-5
get threadR-4
threadW-5 joke
clear threadC-5
put threadW-6
put threadW-7
get threadR-7
threadW-7 joke
get threadR-5
threadW-7 joke
get threadR-6
threadW-7 joke
clear threadC-6
clear threadC-7
put threadW-8
clear threadC-8
put threadW-9
get threadR-9
threadW-9 joke
clear threadC-9
get threadR-8
null
主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放以及锁降级
读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。
回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。
如果在一个整型变量上维护多种状态,就一定需要 “按位切割使用” 这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如图。
当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。读写锁是如何迅速确定读和写各自的状态呢?
答案是通过位运算。
假设当前同步状态值为 S ,写状态等于 S&0x0000FFFF (将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是S+0x00010000。
根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。
写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。
如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态,获取写锁的代码如下
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
// 锁状态
int c = getState();
// 通过状态计算写锁数量
int w = exclusiveCount(c);
// 锁不为0,说明有读或者写
if (c != 0) {
// 写锁不存在(说明现在是读锁)或者当前获取线程不是已经获取写锁的线程
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 如果重入次数大于最大冲入数目
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(c + acquires);
return true;
}
// 尝试获取写锁
// writerShouldBlock:公平锁会调用 hasQueuedPredecessors判断轮得到自己吗,非公平直接返回false去竞争锁
// compareAndSetState 失败就会返回false
if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) {
return false;
}
// 设置当前线程拥有锁
setExclusiveOwnerThread(current);
return true;
}
该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的
判断。
如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0
时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对
后续读写线程可见。
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在 没有其他写线程访问(或者写状态为0) 时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。
如果当前线程已经获取了读锁,则增加读状态。
如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。
获取读锁的实现从Java 5到Java 6变得复杂许多,主要原因是新增了一些功能,例如getReadHoldCount() 方法,作用是返回当前线程获取读锁的次数。
由于读状态是 所有线程获取读锁次数的总和 ,所以每个线程 各自获取读锁的次数只能选择保存在ThreadLocal中 ,由线程自身维护,于是使获取读锁的实现变得复杂。
protected final int tryAcquireShared(int unused) {
for (; ; ) {
// 获取锁状态
int c = getState();
// 读数目+1
int nextc = c + (1 << 16);
// 溢出
if (nextc < c)
throw new Error("Maximum lock count exceeded");
// 现在是写状态 且 拥有者不是自己
if (exclusiveCount(c) != 0 && owner != Thread.currentThread())
return -1;
// 修改状态
if (compareAndSetState(c, nextc))
return 1;
}
}
在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读
锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。
读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的
值是(1<<16)。
锁降级指的是写锁降级成为读锁。
如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。
锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
接下来看一个锁降级的示例。
因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后
如果当前线程感知到数据变化,则进行数据的准备工作
同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,
public void processData() {
//锁住读锁
readLock.lock();
// 数据在更新吗
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
//开始降级
readLock.lock();
} finally {
// 锁降级完成,写锁降级为读锁
writeLock.unlock();
}
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。
java.util.concurrent.locks.LockSupport
当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作。
(1)LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具。
(2)LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)
方法来唤醒一个被阻塞的线程。
任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。
Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的。
ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键。
下面将分析Condition的实现,主要包括:等待队列、等待和通知,下面提到的Condition如
果不加说明均指的是ConditionObject。
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是
在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。
事实上,节点的定义复用了同步器中节点的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node。
一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列,
Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。
上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。
在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列
如图所示,Condition的实现是同步器的内部类,因此每个Condition实例都能够访问同步器提供的方法,相当于每个Condition都拥有所属同步器的引用。
调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。
如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。