Java开发工程师进阶篇- 扫盲Java中的各种锁,你学废了吗?

作者:幻好

来源: 恒生LIGHT云社区

-Java中锁的概念

  • 多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。
  • 加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。

-锁的类型

  • 根据锁的状态、特性以及设计,可能会有多种区分:
    • 乐观锁/悲观锁、自旋锁、分布式锁、偏向锁、轻量级锁、重量级锁等。
-乐观锁/悲观锁

线程是否需要锁住同步资源

  • 乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度,在Java和数据库中都有此概念对应的实际应用。

  • 概念:

    • 悲观锁 认为对于同一个数据的并发操作,一定是会发生修改;悲观的认为,不加锁的并发操作一定会出问题。
      • 对于同一个数据的并发操作,悲观锁采取加锁的形式。
      • Java中, synchronized 关键字和 Lock 的实现类都是悲观锁。
    • 乐观锁 认为对于同一个数据的并发操作,是不会发生修改的;乐观的认为,不加锁的并发操作是没有事情的。
      • 在更新数据的时候,会采用尝试更新前判断数据是否被别的线程更新。
      • 如果这个数据没有被更新,当前线程将自己修改的数据成功写入。
      • 如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
      • Java中,通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
  • 场景:

    • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
    • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
  • 使用示例:

    *

    • 通过调用方式示例,可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。
    • 为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?
      • 乐观锁的主要实现方式 “CAS” 的技术原理。
        • CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。 java.util.concurrent 包中的原子类就是通过CAS来实现了乐观锁。
    • 看下原子类AtomicInteger的源码,看一下 AtomicInteger 的定义:
      • 根据定义我们可以看出各属性的作用:
        • unsafe : 获取并操作内存的数据。
        • valueOffset : 存储 value AtomicInteger 中的偏移量。
        • value : 存储 AtomicInteger int 值,该属性需要借助 volatile 关键字保证其在线程间是可见的。
      • 查看 AtomicInteger 的自增函数 incrementAndGet() 的源码时,发现自增函数底层调用的是 unsafe.getAndAddInt()
      • 通过OpenJDK 8 来查看Unsafe的源码:
      • 根据 OpenJDK 8 的源码我们可以看出, getAndAddInt() 循环获取给定对象 o 中的偏移量处的值 v,然后判断内存值是否等于 v 。如果相等则将内存值设置为 v + delta,否则返回 false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在 compareAndSwapInt() 中,在 JNI 里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。
      • JDK通过CPU的 cmpxchg 指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用 cmpxchg 指令进行重试,直到设置成功为止。
    • CAS虽然很高效,但也存在三大问题:
      • ABA问题
        • CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。
        • ABA问题的 解决思路 就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
        • JDK从1.5开始提供了 AtomicStampedReference 类来解决ABA问题,具体操作封装在 compareAndSet() 中。 compareAndSet() 首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
      • 循环时间长开销大
        • CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
      • 只能保证一个共享变量的原子操作
        • 对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
        • Java从1.5开始JDK提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
-阻塞锁/自旋锁/适应性自旋锁
  • 背景:
    • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
    • 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
  • 阻塞锁
    • 让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。
    • JAVA中,能够进入\退出、阻塞状态或包含阻塞锁的方法有, synchronized 关键字(其中的重量锁), ReentrantLock Object.wait()\notify(),LockSupport.park()/unpart() (j.u.c经常使用)
  • 自旋锁
    • 在Java中,尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU性能。 *
    • 自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。
    • 自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用 -XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。
    • 自旋锁的实现原理同样也是CAS, AtomicInteger 中调用 unsafe 进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
    • 自旋锁在JDK1.4.2中引入,使用 -XX:+UseSpinning 来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
  • 自适应自旋锁
    • 自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
    • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
    • 如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
  • 在自旋锁中,另有三种常见的锁形式: TicketLock CLHlock MCSlock
-无锁/偏向锁/轻量级锁/重量级锁
  • 背景:
    • 这四种锁是指锁的状态,专门针对 synchronized 的,锁的升级是单向的,不可逆转。
    • synchronized 是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的。
      • 以 Hotspot 虚拟机为例,Hotspot 的对象头主要包括两部分数据: Mark Word (标记字段)、 Klass Pointer (类型指针)。
      • Mark Word :默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
      • Klass Point :对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
      • Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。
    • 在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前 synchronized 效率低的原因。这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
    • 目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
  • 无锁
    • 无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
    • 无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
    • CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
  • 偏向锁
    • 指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
    • 在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
    • 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
    • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
    • 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁: -XX:-UseBiasedLocking=false ,关闭之后程序默认会进入轻量级锁状态。
  • 轻量级锁
    • 指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
    • 产生过程:
      • 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。
      • 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word
      • 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
      • 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
      • 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
  • 重量级锁
    • 指当锁为轻量级锁的时候,另一个线程虽然是自旋,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
    • 升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
  • 综上,偏向锁通过对比 Mark Word 解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
-公平锁/非公平锁
  • 公平锁

    • 指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
    • 优点是等待锁的线程不会饿死。
    • 缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
  • 非公平锁

    • 指多个线程获取锁的顺序并不是按照申请锁的顺序,获取不到才会到等待队列的队尾等待,有可能后申请的线程比先申请的线程优先获取锁。(有可能,会造成优先级反转或者饥饿现象。)
    • 优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
    • 缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

  • 实例:

    • 对于Java 的 ReentrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
      • 根据代码可知, ReentrantLock 里面有一个内部类 Sync Sync 继承AQS( AbstractQueuedSynchronizer ),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。 ReentrantLock 默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。 *
      • 可以明显的看出公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件: hasQueuedPredecessors() 。 *
        • 进入 hasQueuedPredecessors() ,可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
    • 对于 Synchronized 而言,也是一种非公平锁。由于其并不像 ReentrantLock 是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
  • 综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

-可重入锁/非可重入锁
  • 可重入锁
    • 又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
  • Java中 ReentrantLock synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
    • 上面的代码中,类中的两个方法都是被内置锁 synchronized 修饰的, doSomething() 方法中调用 doOthers() 方法。因为内置锁是可重入的,所以同一个线程在调用 doOthers() 时可以直接获得当前对象的锁,进入 doOthers() 进行操作。
    • 如果是一个不可重入锁,那么当前线程在调用 doOthers() 之前需要将执行 doSomething() 时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放,所以此时会出现死锁。
  • 非可重入锁导致死锁原因分析
    • 通过重入锁 ReentrantLock 以及非可重入锁 NonReentrantLock 的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
    • 首先 ReentrantLock NonReentrantLock 都继承父类AQS,其父类AQS中维护了一个同步状态 status 来计数重入次数,status初始值为0。
    • 当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把 status 置为1,当前线程开始执行。如果 status != 0 ,则判断当前线程是否是获取到这个锁的线程,如果是的话执行 status+1 ,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前 status 的值,如果 status != 0 的话会导致其获取锁失败,当前线程阻塞。
    • 释放锁时,可重入锁同样先获取当前 status 的值,在当前线程是持有锁的线程的前提下。如果 status-1 == 0 ,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将 status 置为0,将锁释放。
-独享锁/共享锁
  • 独享锁和共享锁同样是一种概念。

  • 独享锁

    • 也叫排他锁,是指该锁一次只能被一个线程所持有。
    • 如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。
    • 获得排它锁的线程即能读数据又能修改数据。JDK中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。
  • 共享锁

    • 指该锁可被多个线程所持有。
    • 如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。
    • 获得共享锁的线程只能读数据,不能修改数据。
  • 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。通过 ReentrantLock ReentrantReadWriteLock 的源码来介绍独享锁和共享锁。

  • ReentrantReadWriteLock 有两把锁: ReadLock WriteLock ,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现 ReadLock WriteLock 是靠内部类Sync实现的锁。 Sync 是AQS的一个子类,这种结构在 CountDownLatch ReentrantLock Semaphore 里面也都存在。

  • ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync ,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以 ReentrantReadWriteLock 的并发性相比一般的互斥锁有了很大提升。

  • 读锁和写锁的具体加锁方式的区别:

    • 最开始提及AQS的时候提到了 state 字段(int类型,32位),该字段用来描述有多少线程获持有锁。
    • 在独享锁中这个值通常是0或者1(如果是重入锁的话 state 值就是重入的次数),在共享锁中 state 就是持有锁的数量。
    • 但是在 ReentrantReadWriteLock 中有读、写两把锁,所以需要在一个整型变量 state 上分别描述读锁和写锁的数量(或者也可以叫状态)。
    • 于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。
  • 写锁的加锁源码:

    • 这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w = exclusiveCount(c); ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
    • 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
    • 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
    • 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
    • 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
    • tryAcquire() 除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
    • 因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与 ReentrantLock 的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。
  • 读锁的代码:

    • tryAcquireShared(int unused) 方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。
    • 如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。
    • 读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。
    • 所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
  • 实例:

    • 对于Java ReentrantLock 而言,其是独享锁。但是对于 Lock 的另一个实现类 ReadWriteLock ,其读锁是共享锁,其写锁是独享锁。
    • 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
    • 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
    • 对于 Synchronized 而言,当然是独享锁。
-互斥锁/读写锁
  • 上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
  • 实例:
    • 互斥锁在Java中的具体实现就是 ReentrantLock
    • 读写锁在Java中的具体实现就是 ReadWriteLock
-分段锁
  • 分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap 而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
  • 分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
  • 实例:
    • ConcurrentHashMap 来说一下分段锁的含义以及设计思想, ConcurrentHashMap 中的分段锁称为 Segment,它即类似于 HashMap (JDK7与JDK8中 HashMap 的实现)的结构,即内部拥有一个 Entry 数组,数组中的每个元素又是一个链表;同时又是一个 ReentrantLock (Segment继承了 ReentrantLock )。

-乐观锁与悲观锁的基本原理

  • 悲观锁做事比较悲观,它认为 多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁
  • 乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是: 先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

-CAS的原理

  • 在计算机科学中,比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。
  • 它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值,这是作为单个原子操作完成的。
  • 原子性保证新值基于最新信息计算;如果该值在同一时间被另一个线程更新,则写入将失败。

-CAS的优缺点

  • ABA 问题
    • 如果变量 V ,初次读取时是 A 值,并且在准备赋值的时候,检查到它仍然是 A 值,这样能否说明它的值,没有被其他线程修改过?答案是否定的,因为在这段时间内,它的值可能被更改为其他的值,然后又改回成了 A 值,那 CAS 操作就会误认为它从来没有被修改过。这个问题,被称为 CAS 操作的 ABA 问题。
    • JDK1.5 以后的 AtomicStampedReference 类提供了这样的功能,其中的 compareAndSet() 方法,就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,才会以原子的方式,将该引用和该标志的值,设置为给定的更新值。
  • 循环时间长,开销大
    • 自旋 CAS (也就是不成功就一直循环执行直到成功),如果长时间不成功,会给CPU带来非常大的执行开销。 如果 JVM 能支持处理器提供的 pause 指令,那么效率会有一定的提升, pause 指令有两个作用,第一,它可以延迟流水线执行指令( de-pipeline ),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候,因内存顺序冲突(memory order violation),而引起 CPU 流水线被清空( CPU pipeline flush ),从而提高 CPU 的执行效率。
  • 只保证单个共享变量的原子操作
    • CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时, CAS 操作无效。但是从 JDK1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里,来进行 CAS 操作。所以可以使用锁,或者利用 AtomicReference 类,把多个共享变量合并成一个共享变量来操作。

-锁的消除,粗化,升级,降级

  • 锁的消除
    • JIT编译器(Just In Time编译器)可以动态编译同步代码时,适用于一种叫做逃逸分析的技术,来通过该项技术判别程序中所使用的锁对象是否只被一个线程所使用,而没有散布到其他线程中;如果情况就是这样的话,那么JIT编译器在编译这个同步代码时就不会生成 synchronized 关键字所标识的锁的申请与释放机器码,从而消除了锁的使用流程。
  • 锁的粗化
    • JIt编译器在执行动态编译时,若发现前后相邻的 synchronized 块使用的是同一个锁对象,那么它就会把这几个 synchronized 块合并为一个较大的同步块,这样做的好处在于线程在执行这些代码时,就不用频繁的申请和释放锁了,从而达到申请与释放锁一次,就可以执行完全部的同步代码块,从而提升了性能。
  • 锁的升级和降级
    • 锁的升级和降级主要都是对象头中通过 Mark Word 中的锁标志位与是否是偏向锁标志位来实现的;

-互斥锁与自旋锁的概念与区别?

  • 当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
    • 互斥锁 加锁失败后,线程会 释放 CPU ,给其他线程;
    • 自旋锁 加锁失败后,线程会 忙等待 ,直到它拿到锁;
  • 互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程, 既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞
    • 对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的 。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
    • 所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
    • 那这个开销成本是什么呢?会有 两次线程上下文切换的成本
      • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
      • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
    • 所以, 如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
  • 自旋锁是通过 CPU 提供的 CAS 函数( Compare And Swap ),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
    • 一般加锁的过程,包含两个步骤:
      • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
      • 第二步,将锁设置为当前线程持有;
    • 自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。 需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
  • 自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同: 当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对

-读写锁的优先级区分?

  • 读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
  • 读写锁的工作原理是:
    • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
    • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
  • 写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
  • 读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取读锁。

-死锁的概念

  • 死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
  • Java 中死锁产生的四个 必要条件
    • 互斥条件 ,即当资源被一个线程使用(占有)时,别的线程不能使用;
    • 不可抢占 ,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放;
    • 请求和保持 ,即当资源请求者在请求其他的资源的同时保持对原有资源的占有;
    • 循环等待 ,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
  • 当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。

-死锁产生的场景

  • 发生死锁的场景有:
    • 顺序加锁时导致死锁;
    • 相互协作对象调用导致死锁;
    • 动态加锁时导致死锁;

-死锁的定位

  • 在我们实际项目中,为了解决死锁问题,前提是需要先定位到死锁发生的具体位置。
  • 死锁实例代码:
public class Deadlock {
    public static String obj01 = "objA";
    public static String obj02 = "objB";

    public static void main(String[] args) {
        LockA lockA = new LockA();
        LockB lockB = new LockB();
        new Thread(lockA).start();
        new Thread(lockB).start();
    }

    public static class LockA implements Runnable {
        @Override
        public void run() {
            try {
                System.out.println(new Date().toString() + "LockA 开始执行");
                synchronized (Deadlock.obj01) {
                    System.out.println(new Date().toString() + "LockA 锁住对象 objA");
                    Thread.sleep(1000);
                    synchronized (Deadlock.obj02){
                        System.out.println(new Date().toString() + "LockA 锁住对象 objB");
                        Thread.sleep(60 * 1000); // 为测试,占用了就不放
                    }
                    System.out.println(new Date().toString() + "LockA 释放对象 objA");
                }
                System.out.println(new Date().toString() + "LockA 释放对象 objB");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static class LockB implements Runnable {
        @Override
        public void run() {
            try {
                System.out.println(new Date().toString() + "LockB 开始执行");
                synchronized (Deadlock.obj02) {
                    System.out.println(new Date().toString() + "LockB 锁住对象 objB");
                    Thread.sleep(1000);
                    synchronized (Deadlock.obj01){
                        System.out.println(new Date().toString() + "LockB 锁住对象 objA");
                        Thread.sleep(60 * 1000); // 为测试,占用了就不放
                    }
                    System.out.println(new Date().toString() + "LockA 释放对象 objB");
                }
                System.out.println(new Date().toString() + "LockA 释放对象 objA");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
  • 运行以上实例代码,然后通过一下方法对死锁进行定位:
      1. 使用命令 jstack -l [pid] 进行定位
      • 首先在项目控制台输入命令 jps ,找到死锁的线程pid
>jps
13136 Deadlock
15952 Launcher
18784 Launcher
9172 RemoteMavenServer36
20376 Jps
11996
- 然后发现 pid 为 13136 的线程,发生了死锁
  - 然后输入命令 `jstack -l 13136` ,打印出线程具体信息
     - ![image.png](https://cdn.nlark.com/yuque/0/2021/png/3010199/1627740718472-84b9a835-a6b9-4799-998b-d0ce890e0058.png#align=left&display=inline&height=416&margin=%5Bobject%20Object%5D&name=image.png&originHeight=636&originWidth=987&size=59400&status=done&style=shadow&width=646)
  - 可以看到具体死锁发生的方法和代码行数,方便我们解决死锁问题。
    1. 使用 JConsole 图形化工具,定位死锁
    • 在命令控制台输入 jconsole ,运行工具
      • 选择项目运行的进程连接
      • 进入图形化监控界面后,进入线程界面,有个检测死锁的按钮,会自动检测死锁线程
      • 工具找到具体死锁的代码位置。

-如何避免死锁

  • 系统中发生死锁会导致多个任务无法执行,造成无尽的等待,严重时会导致系统的崩溃等重大事故。
  • 为了避免死锁,得先从死锁的四个条件入手:
    • 1.互斥;2.不可剥夺;3.循环等待;4.请求保持;
  • 具体方法:
      1. 避免使用多个锁 , 且只有需要时才持有锁,嵌套的 synchronized 或者 lock 非常容易出现问题。
      1. 固定加锁的顺序 ,所有线程尽量以固定的顺序来获得锁。
      1. 超时自动释放锁 ,使用带超时的方法,为程序带来更多可控性。
      • Object.wait() 或者 CountDownLatch.await() ,都支持所谓的 timed wait,指定超时时间,并为无法得到锁时准备退出逻辑。
      • 使用显式Lock锁,在获取锁时使用 tryLock() 方法。当等待 超过时限 的时候, tryLock() 不会一直等待,而是返回错误信息。
if(lock.tryLock() || lock.tryLock(timeout, unit)){
    // ...
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值