并发编程艺术-锁

313 阅读16分钟

锁分为两种,互斥锁和自旋锁。 互斥锁的本质是通过操作系统进行线程上下文切换实现的。当获取锁失败时,操作系统内核就会把需要等待的线程置为休眠状态,线程会释放CPU给其他线程。当锁被释放后,操作系统会在合适的时间唤醒线程。所以获取锁失败,就浪费了两次上下文切换的时间。而且,在高并发服务中,线程主动进入休眠是不能容忍的事情,因为会影响其他的异步服务。 自旋锁的本质是CPU提供的CAS函数在用户态代码中完成加锁和解锁,不会释放CPU。线程在获取锁失败时,会进行忙等待,它会执行CPU的PASUE指令,减少耗电量 就性能而言,自旋锁肯定是更好的,但如果自旋等待的时间过长,会一直占用CPU,导致CPU利用率下降。所以在无法判断锁住的代码需要执行多久时,首选互斥锁。

读写锁:读锁共享,写锁互斥。即在发生多读的情况下不会互斥,但若有一写则会阻塞多读。那么在读锁被占用时,若同时发生了一读和一写,应该怎么办呢?若继续加读锁则可能发生写锁饥饿;若等待写锁则一读会被延迟。所以我们可以把请求的线程排队,按照先来后到的顺序加锁,即后来的读锁不能插队,这就是AQS的实现思路。

Synchronized

在JVM层面:

synchronized修饰代码块,是通过monitor-enter指令与monitor-exit指令实现的(JVM编译时会把这两个指令分别插入到同步块的开始与结束位置)。synchronized修饰同步方法,是通过方法修饰符上的ACC_SYNCHRONIZED来完成的。它们的本质上是对一个对象的monitor监视器进行互斥获取。持有了对象的monitor才能进入同步块或同步方法,且置对象为锁定状态,其他线程便无法获取之,会阻塞并进入同步队列等待。(补充:这个monitor相当于条件变量,再加上等待队列,就是管程)

在对象层面

synchronized使用的锁是存在Java对象头中的Mark Word中(补充:在64位虚拟机中,一个Java对象16B,具有12B的对象头、实例数据、对齐字节)。MarkWord中存储的数据会随着锁标记位的变化而变化。

①无锁:HashCode、是否偏向0、锁标记位01

②偏向锁:线程ID、epoch偏向撤销次数、是否偏向1、锁标记位01

③轻量级锁:指向栈中锁记录的指针、锁标记位00

④重量级锁:指向栈中锁记录的指针、锁标记位10 锁升级 线程到来时会先去判断MarkWord中的线程ID,若没有线程则把自己的线程ID写入,则占有锁;若有则去判断MarkWord中的是否偏向。

①如果是偏向锁。然后再去判断当前存储的线程ID是否为当前线程ID,如果是则为重入锁,执行代码块即可;如果不同,则发生锁竞争。此时会再次判断当前的MarkWord与无锁状态的MarkWord是否相同。若相同则直接持有,若不同则说明确实有其他线程持有了这个偏向锁,即出现了线程竞争,触发撤销偏向锁。

  • 撤销偏向锁:等待持有锁的线程到达一个没有执行字节码的时间点,暂停它。然后检查此线程是否还需要偏向锁(即是否还在同步代码块中)。若不需要则把当前锁置为无锁状态,唤醒线程;若还需要,则把偏向锁膨胀为轻量级锁,持有锁的线程依然持有。

②如果不是偏向锁。则当前线程会使用CAS操作将MarkWord中的指针替换为指向自己。若成功则表示竞争锁成功;若失败则自旋等待(如果当前锁是轻量级锁,失败后会膨胀为重量级锁。持有锁的线程执行完成后才会释放锁,置为无锁状态,线程重新竞争。)

锁重偏向

①对于单个锁且无竞争的情况下,没有重偏向行为。JVM考虑到每个线程到来时锁都会发生重偏向,性能较差。所以当第二个线程到来且无锁竞争时,锁会直接膨胀为轻量级锁。

②对于一批锁在线程A中加锁后变为偏向锁,此时B线程又尝试加锁,这批锁膨胀为轻量级锁。若偏向撤销的次数到达20时(即这批锁的数量超过20个时),JVM触发锁的重偏向。会让偏向A线程的所有锁全部偏向B线程。

原理:每个锁的Class对象有一个变量i对应着偏向撤销的次数。当i==20时Class对象中的epoch_class变量会改变状态,然后修改每个锁对象MarkWord中的epoch偏向撤销次数不等于epoch_class当epoch != epoch_class时,就会发生批量重偏向。

其他

  • 在JDK1.6之前,synchronized是直接调用OS的互斥机制,使用OS线程的函数进行加锁。
  • synchronized作用范围:实例方法、静态方法、代码块。
  • 大部分对象没有偏向锁,所以JVM加载时会把不用偏向锁的对象提前。即线程启动的前4秒,代码块中的对象都是无锁状态。
  • 偏向锁不主动释放。
  • 偏向锁与hashCode()不共存,即偏向锁对象计算hashCode后会膨胀为无锁对象。所以hashCode()生成的hashcode会覆盖MarkWord中前七个字节,而偏向锁的前七个字节存放线程ID,所以偏向锁失效。而轻量级锁会建立LokRecord空间存放线程ID,然后MarkWord中存储指针即可,不会被hashCode覆盖。
  • 调用wait()方法时,锁会立即变为重量级锁。

对于synchronized(String),我们的toString()方法每次都是创建一个新的String对象,导致锁失效。可以加一个toString().intern(),它会先去String常量池中找,找到就返回同一个对象。

AbstractQueuedSynchronizer队列同步器

什么是AQS:AQS是用来构建锁和其他同步组件的框架。使用者继承AQS并重写指定的模板方法,使用AQS提供的getState()、setState()、compareAndSetState()三个方法来修改同步状态(volatile state-)。AQS也定义了若干模板方法供子类直接使用。在锁的实现中可以聚合AQS自定义同步组件,重写AQS提供的模板方法实现锁的语义。同步器面向的是锁是实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队等底层操作;而锁就面向的是使用者,提供接口实现线程并行访问共享资源。

AQS中的同步队列:同步器依赖内部的FIFO双向队列来完成线程排队的管理。若当前线程获取同步状态失败,则AQS会将当前线程与其信息构造成一个节点并CAS加入同步队列队尾,同时阻塞当前线程。同步队列中的首节点是获取同步状态成功的节点,它释放同步状态时会唤醒后继节点,后继节点获取同步状态成功后,原首节点会将此后继节点设置为首节点并断开next引用。

acquire()--独占锁的获取

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
    }
}
    
private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode); //将当前线程包装成Node
    Node pred = tail;
    // 如果队列不为空, 则用CAS方式将当前节点设为尾节点
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node); //将节点插入队列
    return node;
}


final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            // 在当前节点的前驱就是HEAD节点时, 再次尝试获取锁
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            //在获取锁失败后, 判断是否需要把当前线程挂起
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

acquire()首先调用子类自定义同步器实现的tryAcquire(1)。该方法保证线程安全地获取同步状态。

若获取成功则直接返回。若获取失败,则调用AQS的addWrite()方法进行节点入队操作。[addWrite()方法把当前线程封装成一个Node,然后判断FIFO队列是否已经初始化。若没有初始化则会CAS创建一个虚拟节点head并放在队首,然后CAS把当前节点放在head后面并置为tail,此过程通过死循环来保证节点的正确添加;若已初始化则CAS把当前Node放入FIFO队尾]。然后调用AQS的acquiredQueue()方法。[acquiredQueue()方法:进入死循环块,判断当前节点的前驱节点是否为head。若前驱节点是head,则执行tryAcquire(1)循环尝试获取锁直至加锁成功,然后把当前节点置为head并返回false,加锁成功;若前驱节点不是head,拿到前驱节点的waitStatus置为-1,调用LockSupport.park()阻塞当前Node上的线程。那CPU会执行其他线程,经过相同的过程进入到这里,拿到前驱节点的waitStatus状态值置为-1并阻塞。于是后面所有到来的线程都会接连阻塞,保持不竞争。]。

release()--独占锁的释放

public final boolean release(int arg) {
        //解锁
        if (tryRelease(arg)) {
            //释放成功,获取到AQS队列的头节点
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
}

release(1)首先调用子类自定义同步器实现的tryRelease(1)方法。该方法保证线程安全地释放同步状态。若释放失败则直接返回false。若释放成功,则判断FIFO队列是否为空 和 判断头结点的waitStatus状态是否为0。

若FIFO队列为空或waitStatus状态为0,则直接返回true,表示此时锁不需要释放,返回true(waitStatus为0表示FIFO头结点后没有后继节点 因为后继节点在入队时会把头节点置为-1)。

否则,把head的waitStatus还原为0,然后调用LockSupport.unPark()唤醒head的后继节点线程。后继节点线程会在自己之前执行LockSupprot.park()方法的地方继续执行,最终执行到selfInterrupt()方法打断当前线程。最终断开head节点的next指针,head头结点就成功释放锁了(selfInterrupt()是通过变更interrupted变量而执行到的)。


aquireShared()--共享锁的获取:

它也是通过子类自定义同步器实现的tryAcquireShared(1)来尝试获取同步状态。独占锁的tryAcquire()是返回true获取,返回false入队;而共享锁的tryAcquireShared(1)是返回int类型,若返回值大于等于0则成功获取,否则入队。

AQS-ReentrantLock重入锁]

ReentrantLock是通过组合自定义同步器来实现锁的获取与释放。也就是说他使用了AQS的acquire()方法,重写了其中的tryAcquire()方法,复用addWrite()、acquiredQueue()等模板方法。ReentrantLock的tryAcquire()有两种实现形式,构造参数true为公平锁,false为非公平锁。

① 公平锁tryAcquire(1):

取出state重入次数变量,判断是否为0。

若state为0,表示锁空闲。于是调用hasQueuedPredecessors()方法判断FIFO队列种是否有Node,即判断当前线程是否需要排队。若没有则CAS加锁:若加锁成功则将state置为1,ReentrantLock的线程变量置为currentThread,然后返回成功;若加锁失败则判断说明FIFO中队列又有节点了,判断第二位是否为当前线程,若是则加锁成功。否则获取锁失败,往下执行addWaiter()。

若state不为0,表示锁被占用。然后判断ReentrantLock中的线程是否为当前线程。若是则为重入锁,state++并返回加锁成功;若不是则加锁失败,往下执行addWaiter()。

② 非公平锁的tryAcquire(1)没有hasQueuedPredecessors()方法,也就是说线程不需要判断FIFO队列中是否有等待线程,即不需要判断是否需要排队。所以非公平锁的新线程到来时有可能会直接和head节点的后继节点竞争锁,所以是不公平的。

AQS-ReentrantReadWriteLock读写锁

读写锁同样依赖于自定义同步器的模板方法来实现同步功能,其中读锁重写了tryAcquireShared()方法,写锁重写了tryAcquire()方法。读写锁将state重入次数变量切分成了两个部分,高16位代表读,低16位代表写,然后通过位运算来计算读写重入次数

①写锁tryAcquire():

得到state重入次数变量后,取出state的后为16位字节代表写锁的重入次数w。

若w为0,表示写锁未被占用,返回获取锁成功并w++。

若w不为0且r为0,即读写锁中的线程ID为当前线程,且写锁重入但读锁为0,则w++并返回加锁成功。

其他情况下尝试加锁失败,就继续执行addWaiter()方法了。

②读锁tryAcquireShared():

得到state重入次数变量,首先拿到state的低16位写锁,判断写锁是否被占用了。如果写锁被其他线程占用,则返回加锁失败;如果写锁被自己占用,则可以成功获取锁,继续往下执行,最后会发生锁的降级

然后再获取state的高16位字节读锁的重入次数r。执行hasQueuedPredecessors()判断是否需要排队。若不需要则返回加锁成功。否则,判断r的值,若为0则成功占用锁;若不为0,但FIFO队列获取锁的节点线程ID为当前线程,则成功占有锁;否则,则表示现在有其他线程占有读锁,则成功获取读锁,然后去看缓存中存放的最近线程ID是否为自己,若是则count++;若不是则就去ThreadLocal中拿到自己的kv,执行count++。

③锁降级机制:当写锁被线程自己占用时,线程自己仍然能获取该对象的读锁,写锁释放时ReentrantReadWriteLock会把写锁降级为读锁(也就是一个线程先获取写锁,再获取读锁,释放写锁后写锁会被降级为读锁),使得其他线程需要使用读锁时不用先解锁再重新排队,提高性能。但读写锁不能升级,因为读读并行,所以若读写升级为写锁则会阻塞其他读线程,一直阻塞执行tryAcquireShared()造成死锁。

Synchronized和ReentrantLock的区别

在实现方面,Synchronized是通过Java对象同中的标记位来实现线程之间的互斥;ReentrantLock是基于AQS自定义同步器来实现线程之间的互斥。

在使用方面,Synchronized可以修饰方法与代码块,不需要手动开启和释放锁;ReentrantLock只能用于代码块,使用起来比较灵活,但必须手动释放锁。

在性能方面:synchronized在JDK1.6之后进行了非常大的优化,与ReentrantLock性能相差不大。

ReentrantLock还具有很多Synchronized不具备的特性(本质上可以破坏死锁的不可抢占条件)

①可以尝试非阻塞地获取锁,当前线程尝试获取锁,如果锁没有被其他线程占有则获取成功。

②占有锁的线程可以响应中断,然后主动释放锁(lockInterruptibly())

③可以设置超时时间,避免了线程无限制地阻塞等待(tryLock(time,unit))

④支持公平地获取锁。

AtomicInteger原子类解决并发问题。

它底层是Unsafe类,里面是一句CPU指令,即由操作系统的CPU指令来保证原子性,防止了并发问题。

volatile + synchronized也可以保证原子性,解决并发问题。

悲观锁与乐观锁

悲观锁:它认为同时修改资源的概率,所以每次在访问共享数据之前都要上锁,效率更高。SQL中的行锁、表锁,JAVA中的sync、ReentrantLock都是悲观锁

乐观锁:它认为线程冲突的概率很低,所以它采取的措施是先修改共享资源,然后再验证这段时间内有没有发生冲突,若有则放弃修改并重试。虽然重试消耗很高,但是冲突概率低即可。所以乐观锁一般用在冲突概率非常低,且加锁成本很高的场景。

实现方式:版本号(CAS思想)在数据表上加version字段,每次需要更新数据时先读取版本号,在提交更新时判断读取到的版本号是否与当前版本号一致,若一致则更新。

银行家算法

为一个进程分配资源之前先进行安全性检测,判断本次分配后是否会导致系统进入不安全状态。若是则进程阻塞等待,否则正式分配。安全性检查即查询当前的剩余可用资源是否满足进程的最大需求,若满足则把该进程加入安全序列并回收该进程持有的资源,不断循环此过程。若所有进程都进入安全序列则表示安全。

Condition

Lock解决了并发编程中的互斥问题,Condition解决了并发编程中的同步问题。Condition是一个接口类,提供了等待/通知方法,它使得一个锁可以有多个等待队列,把wait、notify等操作转化直观可控的对象行为,从而精细地控制等待唤醒的对象。

实现原理:

它的底层是AQS的内部类ConditionObject,它内部维护了一个等待队列,等待队列中存放阻塞线程的引用。那么为一个Lock创建多个Condition,就相当于一个锁用于了多个等待队列。

调用await()将当前线程加入等待队列,然后释放当前线程占有的锁,唤醒同步队列中的线程抢占锁。

调用signal()时,唤醒该Condition等待队列中的第一个线程,移入同步队列自旋等待获取锁。