Java中的锁

21 篇文章 0 订阅
4 篇文章 0 订阅

前言

Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。本文主要介绍了Java中的锁机制以及常用的锁的实现,以帮助工作中更好的运用。

锁的分类

乐观锁和悲观锁

悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁(比如ReentrantLock)。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。(比如 AtomicLong)

CAS

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。

CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:

  1. ABA问题

    。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

    • JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
  2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

  3. 只能保证一个共享变量的原子操作

    。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

    • Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

自旋锁和适应性自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

公平锁和非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

具体可见下方公平锁与非公平锁

可重入锁和非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

非可重入锁就是不可重入,即使是同一个线程也不可以。

独占锁和共享锁

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

Java中的ReentrantReadWriteLock就是读写锁;读锁是共享锁,同时可以多个线程获取;写锁的独占锁,同时只能一个线程获取。当有读锁时,写锁就不能获得;而当有写锁时,除了获得写锁的这个线程可以获得读锁外,其他线程不能获得读锁。

无锁、偏向锁、轻量级锁和重量级锁

这四种锁是指锁的状态,专门针对synchronized的。也就是在jdk1.6的时候开始对synchronized关键字进行了优化,主要涉及到锁的升级,具体实现可见synchronized

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

重量级锁

升级为重量级锁时,对象头中锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

升级的大概流程:

一开始处于无锁的状态,为了不让这个线程每次获得锁都需要CAS操作的性能消耗,就引入了偏向锁。当一个线程访问对象并获取锁时,会在对象头里存储锁偏向的这个线程的ID,以后该线程再访问该对象时只需判断对象头的Mark Word里是否有这个线程的ID,如果有就不需要进行CAS操作,这就是偏向锁。当线程竞争更激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待一会儿上一个线程就会释放锁,但是当自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,重量级锁就是Synchronized,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。

常用的锁

synchronized

简介

synchronized锁是jvm内置的锁,不同于ReentrantLock锁。synchronized关键字可以修饰方法,也可以修饰代码块。synchronized关键字修饰方法时可以修饰静态方法,也可以修饰非静态方法;synchronized关键字可以修饰代码块。值得注意的是synchronized是一个对象锁,也就是它锁的是一个对象。因此,无论使用哪一种方法,synchronized都需要有一个锁对象。

当修饰实例方法时,synchronized加锁的对象就是这个方法所在实例的本身;当修饰静态方法时synchronized加锁的对象为当前静态方法所在类的Class对象;当修饰代码块的时候,此时synchronized加锁对象即为传入的这个对象实例。

需要注意在JDK1.6之后,JVM对synchronized进行了优化,有个锁升级的过程:无锁-》偏向锁 -》轻量级锁 -》重量级锁,这个升级只能是单向不可逆的。

Java对象头和Monitor对象

在JVM中,对象在内存中存储的布局可以分为三个区域,分别是对象头、实例数据以及填充数据。

  • 实例数据 存放类的属性数据信息,包括父类的属性信息。
  • 填充数据 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
  • 对象头 在HotSpot虚拟机中,对象头又被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针)。如果是数组,那么还会有数组长度。
对象头

在对象头的Mark Word中主要存储了对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID以及偏向时间戳等。同时,Mark Word也记录了对象和锁有关的信息。

Mark Word中有2bit的数据用来标记锁的状态。无锁状态和偏向锁标记位为01,轻量级锁的状态为00,重量级锁的状态为10。

  • 当对象为偏向锁时,Mark Word存储了偏向线程的ID;
  • 当状态为轻量级锁时,Mark Word存储了指向线程栈中Lock Record的指针;
  • 当状态为重量级锁时,Mark Word存储了指向堆中的Monitor对象的指针。
Monitor对象

可以看到在重量级锁的时候对象头的MarkWord中存储了指向Monitor对象的指针。

Monitor对象被称为管程或者监视器锁。在Java中,每一个对象实例都会关联一个Monitor对象。这个Monitor对象既可以与对象一起创建销毁,也可以在线程试图获取对象锁时自动生成。当这个Monitor对象被线程持有后,它便处于锁定状态。

在HotSpot虚拟机中,Monitor是由ObjectMonitor实现的,它是一个使用C++实现的一个类。

ObjectMonitor中有五个重要部分,分别为_ower,_WaitSet,_cxq,_EntryList和count。

  • _ower 用来指向持有monitor的线程,它的初始值为NULL,表示当前没有任何线程持有monitor。当一个线程成功持有该锁之后会保存线程的ID标识,等到线程释放锁后_ower又会被重置为NULL;
  • _WaitSet 调用了锁对象的wait方法后的线程会被加入到这个队列中;
  • _cxq 是一个阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList;
  • _EntryList 没有抢到锁的线程会被放到这个队列;
  • count 用于记录线程获取锁的次数,成功获取到锁后count会加1,释放锁时count减1。

如果线程获取到对象的monitor后,就会将monitor中的ower设置为该线程的ID,同时monitor中的count进行加1. 如果调用锁对象的wait()方法,线程会释放当前持有的monitor,并将owner变量重置为NULL,且count减1,同时该线程会进入到_WaitSet集合中等待被唤醒。

另外_WaitSet,_cxq与_EntryList都是链表结构的队列,存放的是封装了线程的ObjectWaiter对象。如果不深入虚拟机查看相关源码很难理解这几个队列的作用,关于源码会在后边系列文章中分析。这里我简单说下它们之间的关系,如下:

在多条线程竞争monitor锁的时候,所有没有竞争到锁的线程会被封装成ObjectWaiter并加入_EntryList队列。 当一个已经获取到锁的线程,调用锁对象的wait方法后,线程也会被封装成一个ObjectWaiter并加入到_WaitSet队列中。 当调用锁对象的notify方法后,会根据不同的情况来决定是将_WaitSet集合中的元素转移到_cxq队列还是_EntryList队列。 等到获得锁的线程释放锁后,又会根据条件来执行_EntryList中的线程或者将_cxq转移到_EntryList中再执行_EntryList中的线程。

所以,可以看得出来,_WaitSet存放的是处于WAITING状态等待被唤醒的线程。而_EntryList队列中存放的是等待锁的BLOCKED状态。_cxq队列仅仅是临时存放,最终还是会被转移到_EntryList中等待获取锁。

底层实现原理
同步代码块

在同步代码块的入口和出口加上monitorenter和moniterexit指令。当执行到monitorenter指令时,线程就会去尝试获取该对象对应的Monitor的所有权,即尝试获得该对象的锁。

当该对象的 monitor 的计数器count为0时,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有该对象monitor的持有权,那它可以重入这个 monitor ,计数器的值也会加 1。而当执行monitorexit指令时,锁的计数器会减1。

倘若其他线程已经拥有monitor 的所有权,那么当前线程获取锁失败将被阻塞并进入到_EntryList中,直到等待的锁被释放为止。也就是说,当所有相应的monitorexit指令都被执行,计数器的值减为0,执行线程将释放 monitor(锁),其他线程才有机会持有 monitor 。

同步方法

在同步方法中并没有monitorenter和moniterexit两条指令,而是在方法的flag上加入了ACC_SYNCHRONIZED的标记位。这其实也容易理解,因为整个方法都是同步代码,因此就不需要标记同步代码的入口和出口了。当线程执行到这个方法时会判断是否有这个ACC_SYNCHRONIZED标志,如果有的话则会尝试获取monitor对象锁。执行步骤与同步代码块一致。

注意点
  • synchronized锁不可中断

    通过interupt()方法是无法中断持有锁的线程,也就是不会释放锁的。

  • synchronized锁是可重入的

    同一个线程可以同时进入锁的方法。

  • synchronized锁不带超时功能

    synchronized锁不带超时功能,而ReentrantLocktryLock是具备带超时功能的,在指定时间没获取到锁,该线程会苏醒,有助于预防死锁的产生。

  • 唤醒/等待需要synchronized

    使用Objectnotifywait等方法时,必须要使用synchronized锁,否则会抛出IllegalMonitorStateException

  • synchronized锁时尽量缩小范围以保证性能

    使用synchronized锁时,为了尽可能提高性能,我们应该尽量缩小锁的范围。能不锁方法就不锁方法,推荐尽量使用synchronized代码块来降低锁的范围。

  • synchronized锁升级到重量级锁的时候是依赖底层的操作系统的 Mutex Lock来实现。操作系统实现线程的切换需要从用户态切换到核心态,成本非常高。

Lock

简介

在Java5之后,Java并发包提供了Lock(以及其实现类)用来实现锁的功能。而之前的锁相关功能只能通过synchronized关键字来实现。而新提供的可以显示的获取锁与释放锁。有着比synchronized更灵活的用法。

Lock接口的使用方式非常简单。它本身没有做非常复杂的逻辑操作,而是通过聚合一个同步器(AQS)的子类来实现线程访问控制的。通过观察Lock的实现类可以发现,锁的功能都委托给了内部类(AQS的子类),具体的实现功能都是在内部类中。也就是Lock对外提供了简单易用的API,然后把复杂的功能委托给内部类从而实现功能。

image-20210718184245495

AQS原理

AQS全名AbstractQueuedSynchronizer,是一个队列同步器,JUC的锁和同步组件都是基于AQS构建的。它的主要设计模式是模板方法模式,AQS的结构主要分两部分:

  • state:用了violate关键字修饰的int类型成员变量,表示同步状态,每次更新都能被所有线程可见;
  • head、tail:内置的Node类型(Node包含当前线程、等待状态等信息),组成了一个FIFO的同步队列

AQS主要提供了如下三个方法来访问或修改同步状态state:

  • getState():获取当前同步状态
  • setState(int newState):设置当前同步状态
  • compareAndSetState(int expect, int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。

image-20210719001316600

同步队列中的第一个节点(head)表示获取同步状态的节点。setHead方法可以看到这个节点的thread是被置空的。锁释放后唤醒时是从head的下一个节点开始唤醒的。

大体步骤如下:

初始时,head还是空的,节点A加进来时发现head为空,则会先添加一个空的node作为head,然后再把节点A加到尾节点,然后A的线程获取不到锁会一直阻塞(会根据状态判断是否需要park,即让线程休眠,等待唤醒,具体地方可参考这里的实现:java.util.concurrent.locks.ReentrantLock.NonfairSync#tryAcquire)。

**当别人释放锁后,会尝试从同步队列中获取第一个可以unpark(唤醒)的线程(具体实现见release方法中的unparkSuccessor方法),**把他唤醒然后(此时是节点A)他会继续进入循环尝试获取锁。如果获取成功后则会让把自己设置为head(setHead方法)。当自己释放(release)时还是上面的步骤。从head节点往后找可以唤醒的线程。(如下图)

image-20210719003215063

ReentrantLock

重入锁ReentrantLock,就是支持冲进入的锁,它表示该锁能够支持一个线程对资源重新加锁。虽然它没有像synchronized关键字那样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。重进入时会把count+1,释放锁时会count-1,当count为0 时则释放锁。

公平锁与非公平锁

公平锁和非公平锁的代码对比如下:

  //非公平锁
 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) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

  //公平锁
  protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
              //hasQueuedPredecessors这个方法就是最大区别所在
                if (!hasQueuedPredecessors() &&
                    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;
        }

唯一的不同就是公平锁多了hasQueuedPredecessors()判断,即会判断加入同步队列是否前驱节点。也就是获取时先看有没有其他人先排队,从而实现公平锁。而非公平锁则会直接尝试获取锁,如果此时锁释放刚好被新来的抢到,那么同步队列中的节点就还要继续等(能快速响应,但是可能导致某个线程长时间获取不到锁)。

ReentrantReadWriteLock

ReentrantReadWriteLock的实现中,把state的高16位表示读状态,低16位表示写状态。

它是支持锁降级的(写锁降级为读锁)。锁降级的demo和使用可参考这篇文章:Java读写锁降级

Java中的ReetrantReadWriteLock就是读写锁;读锁是共享锁,同时可以多个线程获取;写锁的独占锁,同时只能一个线程获取。
当有读锁时,写锁就不能获得;而当有写锁时,除了获得写锁的这个线程可以获得读锁外,其他线程不能获得读锁。

Conditon接口实现 等待/通知

Java中的对象可以通过synchronized关键字以及 **wait()/notify()**等接口(这些事Object的方法,所有的Java对象都有)实现等待/通知模式。Condition接口也可以和Lock配合实现等待/通知模式。Condition更加灵活,而且锁的力度更小,更推荐使用。

Object 的监视器方法与 Condition 接口对比如下:

对比项Object 监视器方法Condition
前置条件获取对象的监视器锁调用 Lock.lock() 获取锁调用 Lock.newCondition() 获取 Condition 对象
调用方法直接调用如:object.wait()直接调用如:condition.await()
等待队列个数一个多个
当前线程释放锁并进入等待队列支持支持
当前线程释放锁并进入等待队列,在等待状态中不响应中断不支持支持
当前线程释放锁并进入超时等待状态支持支持
当前线程释放锁并进入等待状态到将来的某个时间不支持支持
唤醒等待队列中的一个线程支持支持
唤醒等待队列中的全部线程支持支持

整体架构图如下:

image-20210719230306556

Condition对象必须绑定到Lock,并使用newCondition()方法获取对象。

以下是Condition类中可用的重要方法的列表。

序号方法名称描述
1public void await()使当前线程等待,直到发出信号或中断信号。
2public boolean await(long time, TimeUnit unit)使当前线程等待直到发出信号或中断,或指定的等待时间过去。
3public long awaitNanos(long nanosTimeout)使当前线程等待直到发出信号或中断,或指定的等待时间过去。
4public long awaitUninterruptibly()使当前线程等待直到发出信号。
5public long awaitUntil()使当前线程等待直到发出信号或中断,或者指定的最后期限过去。
6public void signal()唤醒一个等待线程。
7public void signalAll()唤醒所有等待线程。

通过await()方法释放锁、构造节点加入等待队列(每个Condition都有一个等待队列)并进入等待状态。当其它线程调用signal()方法时会唤醒等待队列的节点,并把它构造节点加入同步队列(signal后不是一定获取锁的,而是加入同步队列重新抢锁),获取锁后从await()地方返回,并接着执行接下来的代码。

注意点
  • Lock的主要实现类是ReentrantLock,是可重入的,但是需要手动加锁和解锁,一般解锁操作都会放在finally里面

  • ReentrantLock里面虽然有CAS操作,但是它是一个悲观锁的实现

    它使用了setExclusiveOwnerThread方法,这个方法是将某一个线程设置为独占线程。就是我们常说的互斥锁。该线程占用该方法以后就无法被其他线程占有,也就是线程的互斥。采用的是独占的形式所以是一个悲观锁的实现。

参考

Java中的synchronized关键字

AQS实现原理

Java锁(meituan)

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值