Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁

Java提供了种类丰富的锁, 每种锁因特性不同, 在适当的应用场景下能够展示出非常高的效率.

Java中往往是按照是否含有某一特性来定义锁, 我们通过特性将锁进行分组归类, 再使用对比的方式进行介绍, 帮助大家更快捷的理解相关知识. 下面给出本文内容的总体分类目录:
Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第1张图片

1. 乐观锁VS悲观锁

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

  • 悲观锁: 悲观锁认为自己在使用数据的时候一定有别的线程来修改数据, 因此在获取数据的时候会先加锁, 确保数据不会被别的线程修改. Java中, synchrionized关键字和Lock的实现类都是悲观锁.
  • 乐观锁: 乐观锁认为自己在使用数据时不会有别的线程修改数据, 所以不会添加锁, 只是在更新数据的时候去判断之前有没有别的线程更新了这个数据. 如果这个数据没有被更新, 当前线程将自己修改的数据成功写入. 如果数据已经被其他线程更新, 则根据不同的实现方式执行不同的操作(例如报错或自动重试).
  1. 乐观锁在Java中通过使用无所编程来实现, 最常采用的是CAS算法, Java原子类的递增操作就是通过CAS自旋实现的.
  2. CAS并不是一种实际的锁, 它仅仅是实现乐观锁的一种思想, java中的乐观锁(如自旋锁)基本都是通过CAS操作实现的, CAS是一种更新的原子操作, 比较当前值跟传入值是否一样, 一样则更新, 否则失败.
    Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第2张图片

根据从上面的概念描述我们发现:

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

2. 自旋锁VS适应性自旋锁

  • 什么是自选锁呢?
  1. 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种转态转换需要耗费处理器时间, 如果同步代码块中的内容过于简单, 转态转换消耗的时间有可能比用户代码执行的时间还要长.
  2. 在许多场景中, 同步资源的锁定时间很短, 线程挂起和恢复的时间有可能比它还长. 如果物理解析有多个处理器, 能够让两个或以上的线程同时并行执行, 我们就可以让后面那个请求锁的线程(当前线程)不放弃CPU的执行时间, 看看持有锁的线程是否很快就会释放锁.
  3. 为了让当前线程"稍微等一下", 我们需让当前线程进行自旋, 如果在自旋完成后, 前面锁定同步资源的线程已经释放了锁, 那么当前线程就可以不必阻塞而是直接获取同步资源, 从而避免切换线程的开销, 这就是自旋锁.
    Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第3张图片
  • 自旋锁不能替代阻塞, 自旋等待虽然避免了线程切换的开销,但它要占用处理器时间.
  • 如果锁被占用的时间很短, 自旋等待的效果就会非常好; 反之, 如果锁被占用的时间很长, 那么自旋的线程只会白浪费处理器资源.
  • 所以自旋等待的时间必须要有一定的限度, 如果自旋超过了限定次数(默认10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁, 就应当挂起线程.

那自旋次数还要手动更改,效率不高,所以JDK6以后就引入了自适应的自旋锁(适应性自旋锁).

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

3. 无锁VS偏向锁VS轻量级锁VS重量级锁

这四种锁是指锁的转态, 专门针对synchronized的. 在介绍这四种锁转态之前, 我们先介绍下一些额外的知识, 能帮忙帮助我们理解这四种锁.

首先为什么synchronized能实现线程同步呢?
在回答这个问题之前我们需要了解两个重要的概念: “Java对象头”, “Monitor”.

  • Java对象头
    synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

    我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

  1. Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  2. Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • Monitor
    Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁

    Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

现在我们继续说synchronized, synchronized通过Monitor来实现线程同步, Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步.

  1. 如果我们在自旋锁中提到的"阻塞或唤醒一个Java线程需要操作系统切换CPU转态来完成, 这种转态转换小耗费处理器时间. 如果同步代码块中的内容过于简单, 转态转换消耗的时间有可能比用户代码执行的时间还要长".
  2. 自旋锁就是synchronized最初实现同步的方法, 这就是JDK6之前synchronized效率低的原因. 这种依赖于操作系统Mutex Lock所实现的锁我们称之为"重量级锁".
  3. JDK 6中为了减少获得锁和释放锁带来的性能消耗, 引入 “偏向锁"和"轻量级锁”.

所以目前锁一种有4中转态, 级别从低到高依次是: 无锁, 偏向锁, 轻量级锁和重量级锁. 锁转态只能升级不能降级.

通过上面的介绍, 我们对synchronized的加锁机制以及相关知识有了一个了解, 那么下面我们给出4中锁转态对应的Mark Word内容, 然后再讲述四种锁转态的思路以及特点:
Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第4张图片

3.1 无锁

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

无锁的特点

  • 就是修改操作在循环内进行, 线程会不断的尝试修改共享资源. 如果没有冲突就修改成功并退出, 否则就会继续循环尝试.
  • 如果有多个线程修改同一个值, 必定会有一个线程能修改成功, 而其他修改失败的线程会不断重试直到修改成功.
  • 上面我们介绍的CAS原理及应用即是无锁的实现, 无锁无法全面代替有锁. 但无锁在某些场合下的性能是非常高的.

3.2 偏向锁

偏向锁是指一段同步代码一直被一个线程访问, 那么改线程会自动获取锁, 降低获取锁的代价.
在大多数情况下, 锁总是由同一线程多次获得, 不存在多线程竞争, 所以出现了偏向锁. 其目标就是在只有一个线程执行同步代码块时能够提高性能.

偏向锁特点:

  • 引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径.
  • 偏向锁只有遇到其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁, 线程不会主动释放偏向锁.
  • 偏向锁的撤销, 需要等待全局安全点(在这个时间点上没有字节码正在执行), 它会首先暂停拥有偏向锁的线程, 判断锁对象是否处于被锁定状态.
  • 撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态.
  • 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态.

3.3 轻量级锁

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

轻量级锁特点:

  • 在代码进入同步块的时候, 如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”), 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间, 用于存储锁对象目前的Mark Word的拷贝, 然后拷贝对象头中的Mark Word复制到锁记录中.
  • 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针, 并将Lock Record里的owner指针指向对象的Mark Word。
  • 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
  • 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁.
  • 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

3.4 重量级锁

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

整体的锁状态升级流程如下:
在这里插入图片描述

综上所述, 各锁特点:

  • 偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。
  • 而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
  • 重量级锁是将除了拥有锁的线程以外的线程都阻塞。

4. 公平锁VS非公平锁

  • 公平锁是指多个线程按照申请锁的顺序来获取锁, 线程直接进入队列中排队, 队列中的第一个线程才能获得锁.
  1. 公平锁的优点, 等待锁的线程不会饿死.
  2. 公平锁的缺点, 整体吞吐效率相对非公平锁要低, 等待队列中除第一个线程以外的所有线程都会阻塞, CPU唤醒阻塞线程的开销比非公平锁大.
    Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第5张图片
  • 非公平锁是多个线程加锁时直接尝试获取锁, 获取不到才会到等待队列的队尾等待. 但如果此时锁刚好可用, 那么这个线程可以无需阻塞直接获取到锁, 所以非公平锁有可能出现后申请锁的线程先获取锁的场景.
  1. 非公平锁的优点, 是可以减少唤起线程的开销, 整体的吞吐效率高, 因为线程有几率不阻塞直接获得锁, CPU不必唤醒所有线程.
  2. 非公平锁的缺点, 处于等队列中的线程可能会饿死, 或者等很久才会获得锁.

Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第6张图片

5. 可重入锁VS非可重入锁

一个线程中有多个子流程, 而资源只有一个, 那么这些子流程如何和资源锁定呢? 所以这时候又引入了可重入锁和非可重入锁.

  • 可重入锁有名递归锁, 是指在同一个线程在外层方法获取锁的时候, 再进入线程的内层方法会自动获取锁(不过前提是锁对象也就是资源是一个对象或class). 不会因为之前获取过还没释放而阻塞.
    可重入锁的优点就是: 可一定程度避免死锁.
    Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第7张图片
  • 非可重入锁, 就是指在同一个线程在外层方法获取锁的时候, 再进入线程的内层方法必须等外层方法释放锁才能获取, 如果外层方法没有释放锁, 就会出现死锁现象.
    Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第8张图片

6. 独享锁VS共享锁

上面提到了可重入锁解决了一个线程种多个子流程竞争资源要如何和资源锁定的问题, 那么多个线程能不能共享同一个锁呢? 这就有了独享锁和共享锁的概念.

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

7. 死锁VS活锁

  • 死锁: 当多个线程循环等待彼此占有的而无限期的僵持等待下去的局面, 原因是,
  1. 系统提供的资源太少了,远不能满足并发进程对资源的需求.
  2. 进程推进顺序不合适,互相占有彼此需要的资源,同时请求对方占有的资源,往往是程序设计不合理
    Java基础之java中的各种锁详细介绍,悲观锁, 乐观锁, 可重入锁, 死锁_第9张图片
  • 死锁产生的必要条件, 也就是需要同时具备以下4个条件, 才会有死锁现象, 如果有一项不满足也是不会出现死锁的.
  1. 互斥性:线程对资源的占有是排他性的,一个资源只能被一个线程占有,直到释放。
  2. 请求和保持条件:一个线程对请求被占有资源发生阻塞时,对已经获得的资源不释放。
  3. 不剥夺:一个线程在释放资源之前,其他的线程无法剥夺占用。
  4. 循环等待:发生死锁时,线程进入死循环,永久阻塞。
  • 如何避免死锁
  1. 破坏“请求和保持”条件
    想办法,让线程不要那么贪心,自己已经有了资源就不要去竞争那些不可抢占的资源。比如,让进程在申请资源时,一次性申请所有需要用到的资源,不要一次一次来申请,当申请的资源有一些没空,那就让线程等待。不过这个方法比较浪费资源,进程可能经常处于饥饿状态。还有一种方法是,要求进程在申请资源前,要释放自己拥有的资源。
  2. 破坏“不可抢占”条件
    允许进程进行抢占,方法一:如果去抢资源,被拒绝,就释放自己的资源。方法二:操作系统允许抢,只要你优先级大,可以抢到。
  3. 破坏“循环等待”条件
    将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序(升序)提出
  • 活锁和死锁在表现上是一样的两个线程都没有任何进展,但是区别在于:死锁,两个线程都处于阻塞状态,说白了就是它不会再做任何动作,我们通过查看线程状态是可以分辨出来的。
  1. 而活锁呢,并不会阻塞,而是一直尝试去获取需要的锁,不断的try,这种情况下线程并没有阻塞所以是活的状态,我们查看线程的状态也会发现线程是正常的,但重要的是整个程序却不能继续执行了,一直在做无用功。
  2. 举个生动的例子的话,两个人都没有停下来等对方让路,而是都有很有礼貌的给对方让路,但是两个人都在不断朝路的同一个方向移动,这样只是在做无用功,还是不能让对方通过。

你可能感兴趣的:(Java)