聊聊你知道的锁
开篇思考
- 你知道哪些锁?
- 锁解决了哪些应用场景的问题?
- 锁的底层实现?
- java 中的并发包了解吗?
- CAS 会有哪些问题?如何解决?
- AQS 是并发包的基础,实现原理是什么?
- synchronize 是可重入锁吗?
锁
悲观锁
并不是某一个锁,是一个锁类型,无论是否并发竞争资源,都会锁住资源,并等待资源释放下一个线程才能获取到锁。 这明显很悲观,所以就叫悲观锁。这明显可以归纳为一种策略,只要符合这种策略的锁的具体实现,都是悲观锁的范畴。
乐观锁
与悲观锁相对的,也是一个锁类型。当线程开始竞争资源时,不是立马给资源上锁,而是进行一些前后值比对,以此来操作资源。
无锁
显然,就是不给资源上锁,线程可以直接获取资源。
自旋锁
自旋通俗的讲就是轮询,for(;;) 很好理解,就是一直循环等待资源释放后获取锁。
不释放cpu
自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
偏向锁
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是 “偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时, 线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁, 这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
上偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程 偏向锁不可重偏向 批量偏向 批量撤销
偏向锁由于有锁撤销的过程revoke,会消耗系统资源,所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。
轻量级锁
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争: 如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候, 发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋, 即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。 先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了, 然后线程将当前锁的持有者信息修改为自己。长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU, 执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争, 那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
如果有线程竞争
撤销偏向锁,升级轻量级锁 线程在自己的线程栈生成LockRecord ,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁
重量级锁
如果竞争加剧
竞争加剧:有线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制 升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间
系统调用,使用内核空间的锁就是重量级锁,锁资源是操作系统内核的资源。
可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作, 递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
公平锁
多个线程竞争同一把锁,如果依照先来先得的原则,那么就是一把公平锁。
非公平锁
多个线程竞争锁资源,如果是抢占式的,谁都可以先上,那么显然不公平,我先来的凭什么要插队,无耻。
可中断锁
线程等待获取内部锁的时候,是一种不可立即中断状态,即线程不会立即响应中断而是会继续等待,这种无义无反顾的等待可能会造成资源浪费或者死锁等问题,后果非常严重。新Lock锁提供了等待锁资源时可立即响应中断的lockInterruptibly()方法和tryLock(long time, TimeUnit unit)方法及其实现,当使用这两个方法去请求锁时,如果主通过Thread.interrupt()对它们发起中断,那么它们会立即响应中断,不再继续等待获取锁,这让主线程(管理调度线程)在特殊情况下可以使用生杀大权,以避免系统陷于死锁状态或者避免资源严重浪费。
tryLock(long time, TimeUnit unit)是加强版的tryLock(),又具有lockInterruptibly()的可被中断特性,既可任其超时主动退出又可中断让其被动退出,很多场合可以使用,但如果想让线程只有当被动中断时才退出,那么就使用lockInterruptibly()方法。
|
|
- 内部锁(synchronized) 优先响应锁获取再响应中断
- Lock.lock() 优先响应锁获取再响应中断
- Lock.tryLock() 判断锁的状态不可用后马上返回不等待
- tryLock(long time, TimeUnit unit) 优先响应中断再响应锁获取
- Lock.lockInterruptibly() 优先响应中断再响应锁获取
读写锁、互斥锁、共享锁
读写锁其实是一对锁,一个读锁(又称共享锁)和一个写锁(又称互斥锁、排他锁)
|
|
记得之前的乐观锁策略吗?所有线程随时都可以读,仅在写之前判断值有没有被更改。
读写锁其实做的事情是一样的,但是策略稍有不同。很多情况下,线程知道自己读取数据后,是否是为了更新它。 那么何不在加锁的时候直接明确这一点呢?如果我读取值是为了更新它(SQL的for update就是这个意思), 那么加锁的时候就直接加写锁,我持有写锁的时候别的线程无论读还是写都需要等待;如果我读取数据仅为了前端展示, 那么加锁时就明确地加一个读锁,其他线程如果也要加读锁,不需要等待,可以直接获取(读锁计数器+1)。
虽然读写锁感觉与乐观锁有点像,但是读写锁是悲观锁策略。因为读写锁并没有在更新前判断值有没有被修改过, 而是在加锁前决定应该用读锁还是写锁。乐观锁特指无锁编程。 JDK提供的唯一一个ReadWriteLock接口实现类是ReentrantReadWriteLock。看名字就知道,它不仅提供了读写锁, 而是都是可重入锁。 除了两个接口方法以外,ReentrantReadWriteLock还提供了一些便于外界监控其内部工作状态的方法, 这里就不一一展开。
Java中的悲观锁、乐观锁
我们在Java里使用的各种锁,几乎全都是悲观锁。synchronized从偏向锁、轻量级锁到重量级锁,全是悲观锁。 JDK提供的Lock实现类全是悲观锁。其实只要有“锁对象”出现,那么就一定是悲观锁。因为乐观锁不是锁, 而是一个在循环里尝试CAS的算法。 那JDK并发包里到底有没有乐观锁呢?很多。 java.util.concurrent.atomic包里面的原子类都是利用乐观锁实现的。 有。java.util.concurrent.atomic包里面的原子类都是利用乐观锁实现的。
|
|
cas中的ABA问题及解决方案
cas
CAS(CompareAndSet)是保证并发安全性的一条CPU底层原子指令,它的功能是判断某个值是否为预期值,如果是的话,就改为新值,在CAS过程中不会被中断。
compareAndSet 在JNI(Java Naive Interface)中实现,位于unsafe.cpp文件,关键的语句是 cmpxchg(x, addr, e),其中x指的是旧值,addr是要和oldValue一致的内存位置,而e是要变为的新值。执行该原子语句时,将oldValue和从addr取出的值进行比较,相等的话才设置addr位置的值为新值e。
aba
但CAS存在一个ABA问题,举例来说,假设线程1和线程2拥有同一个引用p,p指向对象A。某个时刻,线程1想要利用CAS把p指向的对象换成C,此时被线程2中断,线程2将p指向的对象换成B后再换成A,然后线程1继续运行,发现p确实仍然指向对象A,因此执行CAS将A换成C。但线程1并不知道在它中断的这段时间内,p指向的引用经历了从A到B在到A的过程,这个bug就称为ABA问题。对于普通场景来说,ABA问题似乎不会造成什么危害,但我们来考虑下面这种场景。
危害
下面是一段伪代码,将就着看一下。场景是用链表来实现一个栈,初始化向栈中压入B、A两个元素,栈顶head指向A元素。
在某个时刻,线程1试图将栈顶换成B,但它获取栈顶的oldValue(为A)后,被线程2中断了。线程2依次将A、B弹出,然后压入C、D、A。然后换线程1继续运行,线程1执行compareAndSet发现head指向的元素确实与oldValue一致,都是A,所以就将head指向B了。但是,线程2在弹出B的时候,将B的next置为null了,因此在线程1将head指向B后,栈中只剩了一个孤零零的元素B。但按预期来说,栈中应该放的是B → A → D → C。
|
|
解决
并发包下有AtomicStampedReference提供根据版本号判断的实现。 基本思路就是通过版本号来控制,这个也是乐观锁的常用解决方案。 数据库同样可以通过这种版本号的控制方式来实现乐观锁。
AQS
AbstractQueuedSynchronizer (AQS ), 抽象的队列式同步器,并发包的基础
简单解释一下J.U.C,是JDK中提供的并发工具包,java.util.concurrent。 里面提供了很多并发编程中很常用的实用工具类,比如atomic原子操作、比如lock同步锁、fork/join等。
AQS 的核心功能,就是用来将当前工作的线程设置为占有资源状态,并将资源状态设置为锁定,如果其他线程继续访问共享资源, 则需要使用队列将其他线程进行管理,这个队列并不是实例化的某种队列,只是一个 Node 节点的双向关联。
下面只列出一些 AQS 核心的东西,后面有机会详细解读 AQS
- FIFO 先入先出队列
- 状态控制(volatile修饰共享变量state)
- lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中
- lock释放锁的过程:修改状态值,调整等待链表。
下面这段代码就是 AQS 静态内部类,Node:
|
|
重点看下这个 volatile int waitStatus;
,这是一个volatile 修饰的int 状态类型,volatile 就是确保该变量是对其他线程可见的, 是java 内存模型中的重要概念,不理解的可以去看下 JMM 。 SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。 CANCELLED(1):表示当前结点已取消调度。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。 CONDITION(-2):表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。 PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。 0:初始状态
synchronized是不是可重入锁
synchronized 是一个可重入锁
可重入性是锁的一个基本要求,是为了解决自己锁死自己的情况。比如一个类中的同步方法调用另一个同步方法,假如Synchronized不支持重入,进入method2方法时当前线程获得锁,method2方法里面执行method1时当前线程又要去尝试获取锁,这时如果不支持重入,它就要等释放,把自己阻塞,导致自己锁死自己。
对Synchronized来说,可重入性是显而易见的,在执行monitorenter指令时,如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁(而不是已拥有了锁则不能继续获取),就把锁的计数器+1,其实本质上就通过这种方式实现了可重入性。
重入锁实现可重入性原理或机制是:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。