最近看了一本书,名字叫作《Operating Systems: Three Easy Pieces》,它的中文版是《操做系统导论》,原书在豆瓣评分9.7分,质量还不错。该书围绕虚拟化、并发和持久性这三个主要概念展开,行文诙谐幽默却又鞭辟入里,不一样于寻常的操做系统书籍。这些天看了并发的几个章节,我主要关注了"锁"的部分,细读下来,有了更深入的认识。html
因此这篇文章就是对《操做系统导论》中讲解锁的章节的一个读书笔记,实在是忍不住想分享出来。java
锁实际上是一个变量,咱们须要声明某种类型的锁变量才能使用,好比下例:程序员
lock_t mutex; //声明
...
lock(&mutex); //加锁
balance = balance + 1;
unlock(&mutex); //解锁
复制代码
锁变量保存了锁在某一时刻的状态,它要么是可用的(avaliable,或unlocked,或free),表示没有线程持有锁,要么是被占用的(acquired,或locked,或held),表示有一个线程持有锁,正处于临界区。bash
锁为程序员提供了最小程度的调度控制,线程能够视为程序员建立的实体,可是被操做系统调度,具体方式由操做系统选择,而锁让程序员得到一些控制权,经过给临界区加锁,能够保证临界区内只有一个线程活跃。并发
此外,POSIX库将锁称为互斥量(mutex),由于它被用来提供线程之间的互斥,即当一个线程在临界区,它可以阻止其余线程进入直到本线程离开临界区。函数
咱们已经从程序员的角度,对锁如何工做有必定的理解。那如何实现一个锁呢?咱们须要什么硬件支持?须要什么操做系统的支持?下面会进行解答。性能
而在实现锁以前,咱们还须要明确目标,须要设立一些标准才能让“锁”工做的好,主要有3个标准:测试
这三个标准映射到Java层面来讲:fetch
- 互斥锁: JDK中的synchronized和JUC中的Lock就是互斥锁,保证一次最多只能由一个线程持有该锁
- 公平锁与非公平锁: Java中的公平锁是指多个线程在等待同一个锁时,必须按照申请锁的前后顺序来获取锁,而非公平锁是能够抢占的,公平锁可使用new ReentrantLock(true)实现
- 锁的性能: JDK中的synchronized的锁升级——偏向锁、轻量级锁和重量级锁,这几个锁的实现与转换,就是为了提高synchronized锁的性能
测试并设置指令 (test-and-set instruction) ,在x86系统上,具体是指xchg (atomic exchange,原子交换) 指令,咱们用以下的C代码片断来定义测试并设置指令作了什么:ui
int TestAndSet(int *old_ptr,int new) {
int old = *old_ptr; //fetch old value at old_ptr
*old_ptr = new; //store 'new' into old_ptr
return old; //return the old value
}
复制代码
它返回 old_ptr 指向的旧值,同时更新为 new 的新值。同时须要注意的是,上述的伪代码是为了说明使用,直接传统编译确定不能保证原子性,须要操做系统的硬件指令 (x86的xchg指令) 支持以保证原子性。
由于既能够测试旧值,又能够设置新值,因此把这条指令叫做 "测试并设置"。依靠这一条指令彻底能够实现一个简单的自旋锁 (spin lock),代码以下:
typedef struct lock_t {
int flag;
} lock_t;
void init(lock_t *lock) {
// 0 表示锁可用,1表示锁已经被抢占
lock->flag = 0;
}
void lock(lock_t *lock) {
while (TestAndSet(&lock->flag,1) == 1)
{
; // 自旋等待 (do something)
}
}
void unlock(lock_t *lock) {
lock->flag = 0;
}
复制代码
首先假设一个线程在运行,调用lock(),没有其余线程持有锁,因此flag = 0,当调用 TestAndSet(flag,1) 方法,返回0,线程会跳出 while 循环,获取锁。同时也还会原子的设置flag为1,标志锁已经被持有。当线程离开临界区,调用 unlock() 将 flag 清理为0。
依靠操做系统的硬件原语,将测试 (test 旧的锁值) 和设置 (set 新的值) 合并为一个原子操做以后,咱们保证了只有一个线程能获取锁,这就实现了一个有效的互斥原语!
上面的代码也就是自旋锁 (spin lock) 的实现,这是最简单的一种锁,一直自旋,利用CPU周期,直到锁可用。如今按照以前的标准来评价基本的自旋锁:
某些系统提供了另外一个硬件原语,即比较并交换指令 (SPARC系统是compare-and-swap,x86系统是compare-and-exchange),下面是这条指令的C语言伪代码。
int CompareAndSwap(int *ptr,int expected,int new) {
int actual = *ptr;
if (actual == expected)
{
*ptr = new;
}
return actual;
}
复制代码
比较并交换的基本思路是检测 ptr 指向的值是否和 expected 相等;若是是,更新 ptr 所指的值为新值。不然,什么也不作。不论哪一种状况,都返回该内存地址的值
有了比较并交换指令,就能够实现一个锁,相似于用测试并设置那样。例如,咱们只须要用下面的代码替换lock()函数:
void lock(lock_t *lock) {
while (CompareAndSwap(&lock->flag,0,1) == 1)
; //spin
}
复制代码
比较并交换指令实际上就是CAS指令,在Java开发工做中,咱们也会经常遇到,好比使用原子类AtomicXXX,内部实现就是使用了CAS操做。
还有一个硬件原语是: 获取并增长 (fetch-and-add) 指令,它能原子地返回特定地址的旧值,而且让该值自增1,在x86系统中,是xadd指令。获取并增长的C语言伪代码以下:
int FetchAndAdd(int *ptr) {
int old = *ptr;
*ptr = old + 1;
return old;
}
typedef struct lock_t {
int ticket;
int turn;
} lock_t;
void lock_init(lock_t *lock) {
lock->ticket = 0;
lock->turn = 0;
}
void lock(lock_t *lock) {
int myturn = FetchAndAdd(&lock->ticket);
while (lock->turn != myturn)
;//spin
}
void unlock(lock_t *lock) {
FetchAndAdd(&lock->turn);
}
复制代码
在这个例子中,咱们用获取并增长指令,实现了一个ticket锁,其实就是一个公平锁。
该实现不是利用一个值,而是使用了ticket 和 turn 两个变量来构建。基本思想是:若是线程但愿得到锁,首先对一个ticket值执行一个原子的获取并增长指令。这个值做为该线程的"turn"(即myturn,为该线程设定获取锁的顺序)。根据全局共享的lock->turn 变量,当某一个线程的 myturn == turn 时,则轮到这个线程进入临界区。unlock 则是增长 turn,从而下一个等待线程能够进入临界区。
本方法可以保证全部线程都能获到锁,并且是按线程来的前后顺序,也就是一种先进先出 (FIFO) 的公平性机制,该锁还有一种说法,叫作 排号自旋锁 (Ticket Lock)
前面几种指令均可以实现自旋锁,其实现也是很是简单,可是自旋过多,怎么办呢?好比以两个线程运行在单处理器为例,当一个线程(线程0)持有锁时,被中断。第二个线程(线程1)去获取锁,发现锁已经被持有。所以,它就开始自旋,接着自旋。若是线程0长时间持有锁,那么线程2会一直自旋,浪费CPU时间,因此如何让锁减小没必要要地自旋?
只有硬件支持是不够的,咱们还须要操做系统支持!
咱们能够利用 Solaris 提供的支持,它提供了两个调用:
能够用这两个调用来实现锁,让调用者在获取不到锁时睡眠,在锁可用时被唤醒。下面是C语言实现的伪代码。
typedef struct lock_t {
int flag;
int guard;
queue_t *q;
} lock_t;
void lock_init(lock_t *m) {
m->flag = 0;
m->guard = 0;
queueu_init(m->q);
}
void lock(lock_t *m) {
while (TestAndSet(&m->guard,1) == 1)
; //acquire guard lock by spinning
if (m->flag == 0) {
m->flag = 1; //lock is acquired
m->guard = 0;
} else {
queue_add(m->q,gettid());
m->guard = 0;
park();
}
}
void unlock(lock_t *m) {
while (TestAndSet(&m->guard,1) == 1)
; // acquire guard lock by spinning
if (queue_empty(m->q)) {
m->flag = 0; // let go of lock;on one wants it;
} else {
unpark(queue_remove(m->q)) ;//hold lock(for next thread!)
}
m->guard = 0;
}
复制代码
咱们将以前的测试并设置和等待队列结合,实现了一个更高性能的锁,guard基本上起到了自旋锁的做用。其次,咱们经过队列来控制谁会得到锁,避免饿死。
长时间的自旋等待会消耗处理器时间。对此,Java中的自旋锁也有必定的处理措施:自旋等待的时间必需要有必定的限度,若是自旋超过了限定次数没有成功得到锁,就应当挂起线程,限定次数默认为10次,可使用 -XX:PreBlockSpin来更改。
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启,其实现原理就是以前提到的CAS。JDK 6中变为默认开启,而且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)再也不固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。若是在同一个锁对象上,自旋等待刚刚成功得到过锁,而且持有锁的线程正在运行中,那么虚拟机就会认为此次自旋也是颇有可能再次成功,进而它将容许自旋等待持续相对更长的时间