目录

聊聊你知道的锁

开篇思考

  1. 你知道哪些锁?
  2. 锁解决了哪些应用场景的问题?
  3. 锁的底层实现?
  4. java 中的并发包了解吗?
  5. CAS 会有哪些问题?如何解决?
  6. AQS 是并发包的基础,实现原理是什么?
  7. 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()方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* Lock接口 */
public interface Lock {

    void lock(); // 拿不到锁就一直等,拿到马上返回。

    void lockInterruptibly() throws InterruptedException; // 拿不到锁就一直等,如果等待时收到中断请求,则需要处理InterruptedException。

    boolean tryLock(); // 无论拿不拿得到锁,都马上返回。拿到返回true,拿不到返回false。

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 同上,可以自定义等待的时间。

    void unlock();

    Condition newCondition();
}
  • 内部锁(synchronized) 优先响应锁获取再响应中断
  • Lock.lock() 优先响应锁获取再响应中断
  • Lock.tryLock() 判断锁的状态不可用后马上返回不等待
  • tryLock(long time, TimeUnit unit) 优先响应中断再响应锁获取
  • Lock.lockInterruptibly() 优先响应中断再响应锁获取

读写锁、互斥锁、共享锁

读写锁其实是一对锁,一个读锁(又称共享锁)和一个写锁(又称互斥锁、排他锁)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
 /** @see ReentrantReadWriteLock
 * @see Lock
 * @see ReentrantLock
 *
 * @since 1.5
 * @author Doug Lea
 */
public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing
     */
    Lock writeLock();
}

记得之前的乐观锁策略吗?所有线程随时都可以读,仅在写之前判断值有没有被更改。

读写锁其实做的事情是一样的,但是策略稍有不同。很多情况下,线程知道自己读取数据后,是否是为了更新它。 那么何不在加锁的时候直接明确这一点呢?如果我读取值是为了更新它(SQL的for update就是这个意思), 那么加锁的时候就直接加写锁,我持有写锁的时候别的线程无论读还是写都需要等待;如果我读取数据仅为了前端展示, 那么加锁时就明确地加一个读锁,其他线程如果也要加读锁,不需要等待,可以直接获取(读锁计数器+1)。

虽然读写锁感觉与乐观锁有点像,但是读写锁是悲观锁策略。因为读写锁并没有在更新前判断值有没有被修改过, 而是在加锁前决定应该用读锁还是写锁。乐观锁特指无锁编程。 JDK提供的唯一一个ReadWriteLock接口实现类是ReentrantReadWriteLock。看名字就知道,它不仅提供了读写锁, 而是都是可重入锁。 除了两个接口方法以外,ReentrantReadWriteLock还提供了一些便于外界监控其内部工作状态的方法, 这里就不一一展开。

Java中的悲观锁、乐观锁

我们在Java里使用的各种锁,几乎全都是悲观锁。synchronized从偏向锁、轻量级锁到重量级锁,全是悲观锁。 JDK提供的Lock实现类全是悲观锁。其实只要有“锁对象”出现,那么就一定是悲观锁。因为乐观锁不是锁, 而是一个在循环里尝试CAS的算法。 那JDK并发包里到底有没有乐观锁呢?很多。 java.util.concurrent.atomic包里面的原子类都是利用乐观锁实现的。 有。java.util.concurrent.atomic包里面的原子类都是利用乐观锁实现的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
     * Atomically sets the value to the given updated value
     * if the current value {@code ==} the expected value.
     *
     * @param expect the expected value
     * @param update the new value
     * @return {@code true} if successful. False return indicates that
     * the actual value was not equal to the expected value.
     */
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

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。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Node head;
head = B;
A.next = head;
head = A;


Thread thread1 = new Thread(
    ->{
          oldValue = head;
          sleep(3秒);
          compareAndSet(oldValue, B);

    }
);

Thread thread2 = new Thread(
    ->{
        // 弹出A
          newHead = head.next;
          head.next = null; //即A.next = null;
          head = newHead;
         // 弹出B
          newHead = head.next;
          
          head = newHead; // 此时head为null
          
          // 压入C
          head = C;
          // 压入D
          D.next = head;
          head = D;
          // 压入A
          A.next = D;
          head = A;
          

    }
);

thread1.start();
thread2.start();

解决

并发包下有AtomicStampedReference提供根据版本号判断的实现。 基本思路就是通过版本号来控制,这个也是乐观锁的常用解决方案。 数据库同样可以通过这种版本号的控制方式来实现乐观锁。

参考:Java解决CAS机制中ABA问题的方案

AQS

AbstractQueuedSynchronizer (AQS ), 抽象的队列式同步器,并发包的基础

简单解释一下J.U.C,是JDK中提供的并发工具包,java.util.concurrent。 里面提供了很多并发编程中很常用的实用工具类,比如atomic原子操作、比如lock同步锁、fork/join等。

AQS 的核心功能,就是用来将当前工作的线程设置为占有资源状态,并将资源状态设置为锁定,如果其他线程继续访问共享资源, 则需要使用队列将其他线程进行管理,这个队列并不是实例化的某种队列,只是一个 Node 节点的双向关联。

下面只列出一些 AQS 核心的东西,后面有机会详细解读 AQS

  1. FIFO 先入先出队列
  2. 状态控制(volatile修饰共享变量state)
  3. lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中
  4. lock释放锁的过程:修改状态值,调整等待链表。

下面这段代码就是 AQS 静态内部类,Node:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
    static final class Node {
        /** Marker to indicate a node is waiting in shared mode */
        static final Node SHARED = new Node();
        /** Marker to indicate a node is waiting in exclusive mode */
        static final Node EXCLUSIVE = null;

        /** waitStatus value to indicate thread has cancelled */
        static final int CANCELLED =  1;
        /** waitStatus value to indicate successor's thread needs unparking */
        static final int SIGNAL    = -1;
        /** waitStatus value to indicate thread is waiting on condition */
        static final int CONDITION = -2;
        /**
         * waitStatus value to indicate the next acquireShared should
         * unconditionally propagate
         */
        static final int PROPAGATE = -3;

        /**
         * Status field, taking on only the values:
         *   SIGNAL:     The successor of this node is (or will soon be)
         *               blocked (via park), so the current node must
         *               unpark its successor when it releases or
         *               cancels. To avoid races, acquire methods must
         *               first indicate they need a signal,
         *               then retry the atomic acquire, and then,
         *               on failure, block.
         *   CANCELLED:  This node is cancelled due to timeout or interrupt.
         *               Nodes never leave this state. In particular,
         *               a thread with cancelled node never again blocks.
         *   CONDITION:  This node is currently on a condition queue.
         *               It will not be used as a sync queue node
         *               until transferred, at which time the status
         *               will be set to 0. (Use of this value here has
         *               nothing to do with the other uses of the
         *               field, but simplifies mechanics.)
         *   PROPAGATE:  A releaseShared should be propagated to other
         *               nodes. This is set (for head node only) in
         *               doReleaseShared to ensure propagation
         *               continues, even if other operations have
         *               since intervened.
         *   0:          None of the above
         *
         * The values are arranged numerically to simplify use.
         * Non-negative values mean that a node doesn't need to
         * signal. So, most code doesn't need to check for particular
         * values, just for sign.
         *
         * The field is initialized to 0 for normal sync nodes, and
         * CONDITION for condition nodes.  It is modified using CAS
         * (or when possible, unconditional volatile writes).
         */
        volatile int waitStatus;

        
        volatile Node prev;

        
        volatile Node next;

        /**
         * The thread that enqueued this node.  Initialized on
         * construction and nulled out after use.
         */
        volatile Thread thread;

        
        Node nextWaiter;

        /**
         * Returns true if node is waiting in shared mode.
         */
        final boolean isShared() {
            return nextWaiter == SHARED;
        }

        /**
         * Returns previous node, or throws NullPointerException if null.
         * Use when predecessor cannot be null.  The null check could
         * be elided, but is present to help the VM.
         *
         * @return the predecessor of this node
         */
        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }

        Node() {    // Used to establish initial head or SHARED marker
        }

        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }

        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

重点看下这个 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,则释放该锁。

参考

聊聊你知道的锁