AQS全称是(Abstract Queued Synchronizer),单从名字可以翻译为抽象队列同步器,它是构建J.U.C(java.util.concurrent)包下并发工具类的基础框架,AQS除了提供了可中断锁(等待中断),超时锁、独占锁、共享锁等等功能之外,又在这些基础的功能上进行扩展,衍生除了其他的一些工具类,这些工具类基本上可以满足我们实际应用中对于锁的各种需求。
在我没有看过AQS源码之前,感觉它的实现和synchronized原理是一样的,感觉都是通过对象锁来实现并发访问控制,但事实上它仅仅是一个普通的工具类,就好比我们平时开发过程中写的utils类一样,AQS的实现没有像synchronized关键字一样,利用高级的机器指令和内存模型的规则,它没有利用高级机器指令,也没有利用JDK编译时的特殊处理,仅仅是一个普通的类,就实现了并发的控制。这是令我非常有兴趣想去深入的探索和学习它的设计思想和实现原理。
我们为什么要研究AQS的实现呢?因为synchronized和J.U.C包下的工具类是我们并发编程中经常使用到的,J.U.C包下的大部分工具类都是基于AQS进行的扩展实现,想要掌握和了解J.U.C包下工具类的实现原理,了解AQS的实现是必不可少的。
既然JVM已经提供了像synchronized、volatile这样的关键字,已经可以解决并发中的三个问题,也可以解决线程执行顺序的问题,那为什么还要去创造一个AQS框架,重复造造轮子的意义又在哪里?
一个框架或者技术的出现肯定是为了解决某些问题,那功能和性能是否能成为重复造轮子的理由呢?那AQS同步框架的出现是为了解决synchronized没有办法满足的使用场景,我们来看一下AQS提供的功能特点。
上述所说的几个特点,都是synchronized这个关键字不不具备的特点,AQS除了满足synchronized的所有功能之外呢,又基于实现了扩展读写锁(ReadWriteLock)、信号量(Semaphore)、栅栏(CyclicBarrier)等额外的功能锁,极大的提高的使用场景和灵活性。那我们接下就一起看看AQS的详细实现。
我们进入到AQS的源码中可以看到AQS是一个抽象类,但是我们发现AQS中并没有一个抽象方法,这是因为AQS是被设计来支持多种用途的,它是作为很多工具类的基础框架来使用的,如果有抽象方法则子类在继承时必须要重写所有的抽象方法,这显然是不符合AQS的设计初衷;所以,AQS框架采用了模板方法的设计模式,AQS将一些需要子类覆写的方法都设计成protect方法,将其默认实现为抛出UnsupportedOperationException异常,如果子类需要使用到此方法,则重写此方法。
AQS底层设计并不是特别复杂,它底层采用的是状态标志位(state变量)+FIFO队列的方式来记录获取锁、释放锁、竞争锁等一系列锁操作;对于AQS而言,其中的state变量可以看做是锁,队列采用的是先进先出的双向链表,state共享状态变量表示锁状态,内部使用CAS对state进行原子操作修改来完成锁状态变更(锁的持有和释放)。
当某个线程请求持有锁时,通过判断state当前状态,判断锁是否被其他线程持有,如果没有被占用,那就让请求线程持有锁;如果锁被占用,那请求线程将进入阻塞状态,将其封装成Node节点,然后通过节点之间进行关联,组成了一个双向链表。当持有锁的线程完成操作以后,会释放锁资源,然后唤醒在队列中的节点(当然这是公平锁的做法,我们下面会说到),就这样通过队列来实现了线程的阻塞和唤醒。那下面我们就通过具体的代码来看一下AQS的实现。
private volatile int state;
protected final int getState() {
return state;
}
protected final void setState(int newState) {
state = newState;
}
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
state状态这里还是比较简单的,使用volatile修饰,保证state变量的可见性, setState(int newState)方法只是用作给state进行初始化,而compareAndSetState(int expect, int update)用作了在运行期间对state变量的修改。
为什么要单独多出来一个compareAndSetState方法对state变量进行修改呢?因为对共享变量的赋值,不是原子操作需要额外的锁同步,我们可能想到使用synchronized来保证原子性,但是synchronizedh会使线程阻塞,导致线程上下文的切换,影响其性能。这里采用的是CAS无锁操作,但是CAS也是有不足的,它会进行自旋操作,这样也会对CPU的资源造成浪费。
AQS会把没有争抢到锁的线程包装成Node节点,加入到队列中,我们看一下Node的结构
static final class Node {
//标记节点是共享模式
static final Node SHARED = new Node();
//标记节点是独占的
static final Node EXCLUSIVE = null;
//代表此节点的线程取消了争抢资源
static final int CANCELLED = 1;
//表示当前node的后继节点对应的线程需要被唤醒
static final int SIGNAL = -1;
//这两个状态和condition有关系,这里先不说condition
static final int CONDITION = -2;
static final int PROPAGATE = -3;
// 取值为上面的1、-1、-2、-3 或者 0
volatile int waitStatus;
volatile Node prev;
volatile Node next;
//等待线程
volatile Thread thread;
Node nextWaiter;
final boolean isShared() {
return nextWaiter == SHARED;
}
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() {
}
//线程入队。
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
//使用condition用到
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
同步队列是AQS的核心,用来实现线程的阻塞和唤醒操作,waitStatus它表示了当前Node所代表的线程的等待锁的状态,在独占锁模式下,我们只需要关注CANCELLED、SIGNAL两种状态即可。nextWaiter属性,它在独占锁模式下永远为null,仅仅起到一个标记作用。下图是基于独占锁画的。
AQS定义两种资源共享方式:
AQS的设计是基于模板方法模式,队列维护和Node节点的入队出队或者获取资源失败等操作,AQS都已经实现了。资源的实际获取逻辑交由子类去实现。而且它提供了两种资源访问的方式:Exclusive(独占模式)和Share(共享模式);需要实现什么样的资源访问模式,子类只需要重写AQS预留的方法,利用其提供的原子操作方法,来修改state变量实现相应的同步逻辑就可以了。一般情况下,子类只需要根据需求实现其中一种模式,当然也有同时实现两种模式的同步类,如ReadWriteLock。
自定义同步器实现时主要实现以下几种方法:
在独占模式下和synchronized实现的效果是一样的,一次只能有一个线程访问。state 等于0 代表没有线程持有锁,大于0代表有线程持有当前锁。这个值可以大于1,是因为锁可以重入,每次重入都加上 1,也需要对应的多次释放。
在共享模式下,state的值代表着有多少个许可,但是它在每个具体的工具类里的应用还是有一些差别的。通过下面的动画来感受一下什么是共享锁的用法。
前面我们说AQS获取锁的逻辑都是交由子类去实现,那我们就通过具体代码来看一下子类到底是如何实现的,以ReentranLock为例,来看一下实现的细节。
ReentrantLock有公平锁 和 非公平锁 两种实现, 默认实现为非公平锁, 这体现在它的构造函数中,我们接下来就以独占锁开始分析一下ReentranLock,我们先来看一下ReentranLock的结构。
public class ReentrantLock implements Lock, java.io.Serializable {
private final Sync sync;
//ReentranLock的内部类,
abstract static class Sync extends AbstractQueuedSynchronizer {
...
}
// 非公平锁
static final class NonfairSync extends Sync{...}
//公平锁
static final class FairSync extends Sync {...}
//构造函数
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
// 获取锁
public void lock() {
sync.lock();
}
// 释放锁
public void unlock() {
sync.release(1);
}
...
}
可以看到FairSync和NonfairSync都是继承自Sync,而Sync又继承自AQS。ReentrantLock获取锁的逻辑是直接调用了FairSync或者NonfairSync的逻辑.我们就以FairSync为例,来看一下具体实现。
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
//抢锁
final void lock() {
//这里直接调用的AQS的acquire()方法
acquire(1);
}
//====此方法来自AQS,为了方便阅读,贴过来了====
/**
通过代码我们能看到,如果tryAcquire(arg)这个方法返回true,直接就退出了,后续也就不会进行了。
所以我们可以推断出来,大部分情况下,应该返回的是false。我们一个方法一个方法来看。
*/
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
selfInterrupt();
}
}
//======================================
protected final boolean tryAcquire(int acquires) {
...
}
}
tryAcquire 判断当前锁有没有被占用:
获取锁成功返回true
, 失败则返回false
/**
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
selfInterrupt();
}
尝试获取锁,返回boolean,是否获取锁成功。
true:1.代表没有线程在等待锁。2.本身就持有锁,但是是重入锁。
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//如果等于0,那么代表没有线程持有锁。
if (c == 0) {
/**
到这里说明没有线程抢锁,再去判断队列中是否有线程在等待获取锁。
因为是公平锁,总是先来后到的
如果队列中没有线程等待获取锁,那就尝试去获取锁。
如果获取成功了,那就把当前占用锁的线程,更新为当前线程。
*/
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
/**
hasQueuedPredecessors方法,主要来判断队列是否为空,
判断头结点的后节点是不是当前节点。
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
*/
// 将当前线程设置为占用锁的线程
setExclusiveOwnerThread(current);
return true;
}
}else if (current == getExclusiveOwnerThread()) {
/**
如果到这里,说明当前占用锁的线程就是本身。就是我们所说的重入锁。
*/
int nextc = c + acquires;
if (nextc < 0){
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
//如果到这里都没有返回true,说明没有获取到锁。
return false;
}
如果tryAcquire()方法返回false说明抢锁失败了,那就继续执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法,
这一步主要是将没有抢到锁的线程加入到队列中,我们先来看一下addWaiter()方法。
/**
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
selfInterrupt();
}
*/
private Node addWaiter(Node mode) {
/** Node构造方法
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
*/
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
// tail !=null 说明队列不为空。当队列为空时tail = head 是为null的,
if (pred != null) {
//将新节点的前驱节点指向旧的尾结点。
node.prev = pred;
//将新的节点变成尾结点。
if (compareAndSetTail(pred, node)) {
//如果设置成功,那就将旧的尾结点的后继节点,指向新的节点。直接返回Node
pred.next = node;
return node;
}
}
//如果执行到这里说明有两种情况 :1.队列为空。2.CAS失败(有线程在竞争入队)
//这时会执行enq()方法
enq(node);
return node;
}
此方法主要是将等待的线程包装成 Node节点。可见,每一个处于独占锁模式下的节点,nextWaiter 一定是null。此方法会先判断队列是否为空,如果不为空,尝试将Node节点添加到队列的队尾。如果入队失败了或者队列为空,就执行enq方法。
如果执行了enq()方法会有两种可能:
在该方法中使用了死循环, 即以自旋方式将节点插入队列,如果失败则不停的尝试, 直到成功为止, 另外, 该方法也负责在队列为空时, 初始化队列,这也说明,队列是延时初始化的(lazily initialized):
/*我们再看一下enq()这个方法的代码。
这个方法采用了自旋式入队列的方式。
如果没有抢到锁,那就一直循环,直到入队。
*/
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
/**如果tail==null 说明队列为空,我们在刚开始的时候会发现,head和tail都为null,
是没有进行初始化的。这里还是使用的cas设置头结点,跟设置尾结点一样。
*/
if (compareAndSetHead(new Node())){
/**
这里设置了头节点,但是尾结点还是为null,
将尾结点也设置一下,注意,此时还没有return,继续循环。
*/
tail = head;
}
}else {
//这个其实和addWaiter()方法是类似的,都是将线程添加到队尾。
//只不过是如果不成功一直循环,直到成功为止。
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
我们这里可以看到,当队列为空的时候,初始化队列没有使传入的那个Node节点,而是新建了一个Node节点。初始化以后里面没有返回,而是直接进入下一次循环,此时队列已经不为空了,就将传入的Node节点添加到队尾。这也说明了为什么在我们刚开始说FIFO队列的时候头结点是空节点了。
这里我们可以看到enq()方法是有返回值的,返回的是node结点的前驱节点,只不过在这里没有用到它的返回值,但是在其他的地方用到了它的返回值。
代码能走到这里已经说明,经过addWaiter(Node.EXCLUSIVE),此时节点添加到了队列中。
注意:如果acquireQueued(addWaiter(Node.EXCLUSIVE),arg))返回true的话,意味着上面这段代码将进入selfInterrupt(),所以正常情况下,下面应该返回false。
/**
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
selfInterrupt();
}
*/
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//当前节点的前驱节点。addWaiter方法返回的是经过封装的Node节点。
final Node p = node.predecessor();
/**
p == head 说明当前节点已经进到了阻塞队列中,但是Node节点是阻塞队列的第一个,因为它的前驱是 head。正常情况下,我们是将Node节点添加到队尾的,如果说Node的前驱节点是head节点,说明Node节点是 阻塞队列中的第一个,可以再去尝试获取锁。
*/
if (p == head && tryAcquire(arg)) {
setHead(node);
/**
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
*/
p.next = null; // help GC
failed = false;
return interrupted;
}
//当前Node不是在CLH队列的第一位或者是当前线程获取锁失败,判断是否需要把当前线程挂起。
if(shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()){
interrupted = true;
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}
我们在分析FIFO队列的结构时,看到节点组成中有 waitStatus这个状态,它的取值有四个
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
在独占锁的情况下只会用到 CANCELLED 和 SIGNAL这两个状态,怎么理解这个状态代表的含义呢。
CANCELLED这个比较好理解,它表示当前的节点取消了排队,即取消了抢锁。SIGNAL这个状态它不表示当前节点的状态,它代表当前节点前驱节点的状态,当一个节点的waitStatus被置为SIGNAL
,就说明它的下一个节点已经被挂起了(或者马上就要被挂起了),因此在当前节点释放了锁或者放弃获取锁时,如果它的waitStatus属性为SIGNAL
,它还要完成一个额外的操作——唤醒它的后继节点。
/**if(shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()){}
*/
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
//我认为这里只是对前驱节点状态进行判断,判断前驱节点时候是正常状态,因为我们知道如果当前节点被挂
//唤醒时需要前驱节点来进行唤醒的,如果当前节点的前驱节点是正常状态,就能保证当前节点可以被正常唤醒,
//因为在等待队列中的节点有可能退出了所等待,所以需要判断前驱节点状态是否正常。
if (ws == Node.SIGNAL)
//如果前驱节点的状态已经是SIGNAL,就直接返回true,接下来就会直接去执行parkAndCheckInterrupt()将线程挂起
//因为前驱节点状态正常,当前节点可以被挂起。
return true;
/*
* 当前驱节点的status大于0说明前驱节点取消了抢锁,退出了队列。
如果前驱节点取消了抢锁,就继续往前找,找到一个节点是正常状态的节点,然后直接跳过那些不排队的节点,添加到 第一个正常等待节点的后面
*/
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
前驱节点的状态既不是SIGNAL,也不是CANCELLED
用CAS设置前驱节点的ws为 Node.SIGNAL。
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
这里值得我们注意的是,只有当前节点的前驱节点状态等于SIGNAL的时候才会返回ture,其他情况只会返回false。
当返回false之后呢,又会回到acquireQueued方法中循环,因为当前节点的前驱节点发生了变化,说不定前驱节点是头结点了呢,直到返回true,也就是前驱节点状态时SIGNAL,就可以安心的将当前线程挂起了,此时将调用parkAndCheckInterrupt将线程挂起。
这个方法很简单,因为前面返回true,所以需要挂起线程,这个方法就是负责挂起线程的,到这里锁获取就已经分析完了。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 线程被挂起,停在这里不再往下执行了
return Thread.interrupted();
}
非公平锁的实现,其实和公平锁的实现差别不大,具体通过代码来看一下吧。
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
//非公平锁的lock和公平锁的lock区别在于,非公平锁直接上来就去直接获取锁,不管阻塞队列是有线程等待
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
/**
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
}
*/
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
//这个方法来自于Sync
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//这里非公平锁直接去获取锁。
//而公平锁的话,要判断队列中是否有线程在等待。
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
公平锁和非公平锁的实现有细微的差别,但是差别不是很大。非公平锁和公平锁的不同在于,非公平锁在lock()的时候,多了一段代码
//非公平锁的lock
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
//公平锁的lock
final void lock() {
acquire(1);
}
非公平锁在lock的时候,就会直接去尝试拿锁,如果尝试成功了,就直接占有锁。这是第一个不同。
在tryAcquire()方法中,公平锁会多出!hasQueuedPredecessors()行这个代码,这段代码主要就是判断阻塞队列中是否已经有等待线程。
//公平锁
if (c == 0) {
if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
/**
public final boolean hasQueuedPredecessors() {
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
*/
//非公平锁。
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
这里公平锁会去判断队列中是否有线程在等待获取锁,只有当阻塞队列为空时,才会尝试去获取锁。
但是非公平锁不会检查阻塞队列中是否已经有线程等待,而是会直接去获取锁。
公平和非公平锁的实现差异就这些不同,其他的实现逻辑都是差不多的。
前面我们说到如果没有抢到锁,就会被LockSupport.park(this)挂起线程,那如何解锁,唤醒线程的呢,接下来我们看一下。
public void unlock() {
sync.release(1);
}
//====此方法来自AQS================
public final boolean release(int arg) {
//尝试去释放锁
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
这里会调用tryRelease()方法尝试去释放锁,如果释放锁成功了,判断头结点的状态,去唤醒线程。
这里需要说明的一点,head != null 我们能理解,但是为什么waitStatus != 0 呢。
我们前面看了线程抢锁,只有一处给waitStatus赋值了。在shouldParkAfterFailedAcquire这个方法中,将前驱节点的 waitStatus设为Node.SIGNAL。可以往前翻一下。
除此以外,还有就是在初始化的时候enq()方法中,对waitStatus初始化的时候默认为0,其他地方没有对 waitStatus赋值。如果waitStatus != 0,那说明head后面没有被挂起等待唤醒的线程,也就不需要去唤醒。
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
//判断占有锁的线程是不是当前线程。
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
//判断是否完全释放锁,有可能重入
if (c == 0) {
free = true;
//将表示占有锁的线程标志置空。
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
太简单了,没有什么好说的。
private void unparkSuccessor(Node node) {
//我们知道阻塞队列是一个先进先出的队列,唤醒的话,也是按照顺序唤醒的,我们可以看到参数的Node是头结点
//如果头结点的waitStatus < 0 ,说
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
//下面的代码就是唤醒后继节点,但是有可能后继节点取消了等待(waitStatus==1)
// 从队尾往前找,找到waitStatus<=0的所有节点中排在最前面的
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
//唤醒线程
if (s != null)
LockSupport.unpark(s.thread);
}
//唤醒线程以后,被唤醒的线程将从以下代码中继续往前走:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 刚刚线程被挂起在这里了
return Thread.interrupted();
}
// 又回到这个方法了:acquireQueued(final Node node, int arg),这个时候,node的前驱是head了
到这里基本上吧ReenTranLock的获取锁、释放锁都分析完了,具体的一些细节可能没有说到,大家就自己去跟一下代码就可以了。
本片文章基于ReentranLock独占锁,分析了AQS了解到了一下几点,
原本的计划是一周输出一篇,但是临时赶上有一个紧急需求要做,上上周六加班,周天又和chessy大佬面基约了一个饭,上周也是每天很晚回去,周六加班也在加班赶需求,本周要提测,月底要上线,中间也是抽时间磕磕绊绊的写一点是一点,终于在昨天写完了。最近两周感觉自己的精力被耗尽了,状态不是很好,这几天把状态调整一下。上上周跟chessy大佬聊了很多,让我有很多感想,计划写一篇关于持续学习和个人成长方向的分享,大家到时候也可以互相交流一下心得。
码了这么多字也是不容易,那就点个赞支持一下呗。
参考
https://javadoop.com/2017/06/16/AbstractQueuedSynchronizer/