并发编程——7.Java的“锁”事

如题目所示,这篇文章我们着重讲解Java里面的各种锁以及与Java锁有关的内容。争取一篇文章让你吃透Java的锁

目录

1.前言

2.乐观锁和悲观锁

2.1悲观锁

2.2乐观锁

2.2.1乐观锁的介绍

2.2.2CAS算法

3.自旋锁与互斥锁

4.wait/sleep区别

5.生产者与消费者模式

6.synchronize的锁升级

6.1 管程的概念

6.2锁升级

6.2.1锁状态的存储

6.3无锁

6.3偏向锁

6.4轻量级锁

6.4.1自旋锁

6.4.2自适应自旋锁

6.5重量级锁

7.可重入锁

8.ReentrantLock同步互斥锁

8.1ReentrantLock的锁申请等待限时

8.2ReentrantLock获取锁的过程是可中断的

9.公平锁和非公平锁

9.1关于公平/非公平锁的几个问题

10.共享锁和排他锁

10.1排他锁

10.2共享锁

11.synchronized与Lock的区别

12.小结


1.前言

前面,我们介绍了一些保证线程安全的手段,比如将共享资源设置为只读,比如将共享变量变为局部变量的数据,比如用ThreadLocal线程变量,比如用volatile来解决可见性和有序性的问题,再比如我们使用原子类来解决数值的一些问题,再比如使用synchronized关键字,但是synchronized会将并行改为串行,吞吐量大的时候不适合使用,之前我们可以用上面的这些手段。

但是在实际开发中,由于锁会改变我们一个程序的吞吐量等问题,所以我们是能不用就尽量不用,如果实在要用,最好是用比较适合场景的锁,这就要求我们要对Java的锁有了解。这篇文章我们就来了解一下Java的锁。

下面给出了一张思维导图,大家可以看一下:

2.乐观锁和悲观锁

下面我们来讲一下乐观锁和悲观锁

首先要说明一点的是,乐观锁和悲观锁是锁的一种类型,不是锁的具体实现。就比如市面上有电子锁和机械锁,而电子锁的实现可以是指纹锁或者人脸识别说。记住,乐观锁和悲观锁是锁的一种类型。

乐观锁和悲观锁主要是从你当前任务的并发量,数据被篡改的几率去考虑的锁的一种分类。它不仅仅适用于Java,像数据库里面的锁也适用于这个分类。

2.1悲观锁

下面我们来看一下悲观锁的相关内容。

首先用一张图来了解悲观锁:

解释:

悲观锁认为自己在使用数据的时候一定会有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改

synchronized关键字和Lock的实现类都是悲观锁。

适合写操作多的场景,先加锁可以保证写操作时数据正确。显式的锁定之后再操作同步资源。

下面看一下悲观锁的代码样例:

2.2乐观锁

下面来看一下乐观锁的相关介绍

2.2.1乐观锁的介绍

乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁只是在更新数据的时候会去判断之前有没有别的线程更新了这个数据

如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法(Compare-and-Swap算法,即比较并替换算法),Java原子类中的递增操作就通过CAS自旋实现的。

乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升

在实现上,乐观锁会直接去操作同步资源,是一种无锁算法,得之我幸不得我命

下面看一下乐观锁的两种实现方式:

解释一下:

乐观锁觉得自己使用数据时不会有其他线程来修改数据,所以我们的线程是直接去拿共享资源的。拿到共享资源后,它会和自己工作内存中的数据进行比较看资源是否被更新,如果没有被更新,那么就进行后续的操作。如果资源被更新了,那么我们的线程就可以执行其他操作,比如重试(就是更新自己工作内存中的数据)或者报错。

2.2.2CAS算法

下面,我们来看一下CAS算法。

CAS是什么?CAS是一种算法,是一种机制。我们之前使用的原子类的递加和递减就是CAS算法的一种体现,只不过原子类将CAS的具体实现封装在底层,不看源码是不会了解的。

下面我们通过代码来看一下CAS算法。

如下图所示:

我们把原子类的减减操作翻译成18-20这三行代码,很显然,这不符合原子性,会造成线程不安全。而这里我们调用了原子类中的compareAndSet方法来实现原子性,它就是用来比较并且设置的它会先将我们的oldValue的值和我们的stock的值进行比较,如果相同,那么就将stock的值设置为newValue,如果不相等,那就返回一个false

然后我们看一下compareAndSet这个方法的底层

如图所示,它的最底层是一个native方法compareAndSwapInt方法,是一个本地方法,它位于Unsafe这个类里面。

下面介绍一下Unsafe这个类:

Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力。有一下特点:

  • 1、不受jvm管理,也就意味着无法被GC,需要我们手动GC,稍有不慎就会出现内存泄漏。
  • 2、Unsafe的不少方法中必须提供原始地址(内存地址)和被替换对象的地址,偏移量要自己计算,一旦出现问题就是JVM崩溃级别的异常,会导致整个JVM实例崩溃,表现为应用程序直接crash掉。
  • 3、直接操作内存,也意味着其速度更快,在高并发的条件之下能够很好地提高效率。

从理论上来讲,你可以直接调用Unsafe里面的compareAndSwapInt方法,但问题是你不会设置参数,但凡有一点失误,你的JVM就会崩溃,所以还是老老实实的用别人封装好的compareAndSet方法吧

小结:

CAS的全称为Compare-And-Swap,它是一条CPU并发原语

它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。

AtomicInteger类主要利用CAS (compare and swap) + volatile和native方法来保证原子操作,从而避免synchronized 的高开销,执行效率大为提升。

CAS的缺点:

1、循环时间长开销很大

我们可以看到getAndAddInt方法执行时,有个do while,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

2、ABA问题

假设这样一种场景,当第一个线程执行CAS(V,E,U)操作。在获取到当前变量V,准备修改为新值U前,另外两个线程已连续修改了两次变量V的值,使得该值又恢复为旧值,这样的话
我们就无法正确判断这个变量是否已被修改过,如下图所示:

3.自旋锁与互斥锁

下面我们来看一下自旋锁与互斥锁

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待(类似于上例中的do-while),然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。

对于互斥锁,会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。但是自旋锁不会引起调用者堵塞,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁。

自旋锁的实现基础是CAS算法机制CAS自旋锁属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。

4.wait/sleep区别

下面讲解一下wait与sleep的区别

整体的区别其实是有六个:

  1. 所属类不同: sleep是线程中的方法,但是wait是Object中的方法。
  2. 语法不同:sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字
  3. 参数不同:sleep必须设置参数时间,wait可以不设置时间,不设置将一直休眠。
  4. 释放锁资源不同:sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。
  5. 唤醒方式不同:sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。
  6. 线程进入状态不同:调用sleep方法线程会进入TIMED_WAITING有时限等待状态,而调用无参数的wait方法,线程会进入 WAITING无时限等待状态。

5.生产者与消费者模式

下面,我们来讲一下生产者与消费者模式

首先,我们来看一张图:

解释一下:

首先,我们的消费者是会持续工作的,所以我们给这个消费者一个死循环,让其一直工作。然后消费者在工作的过程中我们会给队列上一把锁,让其保证消费者在消费这个队列的过程中是线程安全的。注意:我们的消费是一种行为,是可能有很多线程去执行这个行为的,比如后端给手机发短信,我们的后端可以给很多手机发短信,就是多线程。再注意,我们这里说的上锁,是给我们的代码块、共享资源或者数据上锁,这个一定要清楚)。我们消费者上完锁后,当其使用队列时,会判断队列是否为空,如果队列为空,消费者就会执行wait()方法,让死循环进行休眠,这样就不至于一直占用CPU的资源,然后释放锁。

这时,我们的生产者就可以进行一个生产的任务首先它会尝试获取我们队列的锁它是能获取到的,因为我们的消费者此时处于wait状态。然后消费者会将队列的长度和最大长度进行比较,如果队列的长度小于最大的长度,那么就会执行notify()方法,唤醒我们的消费者,我们的消费者开始进行消费行为。如果队列的长度大于最大的长度,说明队列已满,生产者就会执行wait()方法,释放锁,队列满了,我们的消费者就可以获得锁,然后循环的进行消费行为,释放队列,执行notify()方法,这样生成者就会被唤醒,就可以继续进行生成行为。

下面,我们用代码来实现一下上面的内容

然后看一下结果:

不多解释,代码中都有注释

6.synchronize的锁升级

下面,我们来讲一下synchronized锁升级

什么是synchronized的锁升级?

解释:先举个例子,我们很久很久以前,门锁用的插销,然后换为了钥匙锁,然后换为了防盗锁,然后换为了密码锁,然后换为了指纹锁,这是我们现实中的锁升级。synchronized锁升级与此类似。随着我们一个程序的并发量的增加,synchronized由最开始的轻量级锁慢慢的升级为了重量级锁,随着锁的升级,我们程序的安全性也随之提升

其实在JDK 1.6之前,Java内置锁还是一个重量级锁,是一个效率比较低下的锁,会由jvm用户态切换到操作系统的管程来实现互斥。

6.1 管程的概念

下面讲一下管程的概念

管程,Monitor,直译为“监视器”,而操作系统领域—般翻译为“管程”,在java领域就是“对象锁”

管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。翻译成Java领域的语言,就是管理类的状态变量,让这个类是线程安全的

synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分

Monitor有两大作用:同步和互斥

wait / notify 基于monitor做的,monitor中有owner、entryList、waitSet三部分,synchronized关联了monitor,是在JVM层面实现的,源码是C++

java对象与Monitor之间的关系:每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象加锁(重量级锁)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针。

下面看一下管程在代码中运行的一个流程:

解释如下:

6.2锁升级

在JDK 1.6之后,JVM为了提高锁的获取与释放效率,对synchronized的实现进行了优化,引入了偏向锁和轻量级锁,从此以后Java内置锁的状态就有了4种(无锁、偏向锁、轻量级锁和重量
级锁),
并且4种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别)

级别由低到高依次为:无锁一> 偏向锁一> 轻量级锁一> 重量级锁

6.2.1锁状态的存储

synchronized也称为对象锁,是与我们的对象绑定的,那么synchronized到底存储在哪呢?

我们来看下面的这部分内容:

markWord

这个对象的内存布局是和JVM的实现有关。当一个对象被创建出来后它在内存中的布局如下,由四部分组成:

  • 8个字节的markword,(markword里面包含了其它的东西,比如GC标记,锁类型)
  • 4个字节的ClassPoint(此指针指向的Class),默认是开启指针压缩所以是四个字节,关闭指针压缩后是八个字节
  • 实例对象中的成员属性大小
  • 字节填充(有的JVM需要8字节对齐,如果上面的字节相加后不能被8整除,则需要在此补齐)

如下图所示:

6.3无锁

前面我们知道了synchronized能锁对象,能锁方法,能锁代码块,其实说到底都是对对象进行加锁。对于锁方法、锁代码块,实际上虚拟机会根据synchronized修饰的是实例方法还是类方法,去取对应的实例对象或者Class对象来进行加锁。

那线程是如何知道对象上锁了呢?前面讲了,线程是通过对象的markword知道的。而对象的结构我们前面也讲了,简单来说如下图所示:

从表格可以看到,对象中关于锁对信息锁存在Markword里的。

当我们创建一个对象LockObject时,该对象的部分Markword关键数据如下:

偏向锁的标志位是“01”,状态是“0”,表示该对象还没有被加上偏向锁。(“1”是表示被加上偏向锁)。该对象被创建出来的那一刻,就有了偏向锁的标志位,这也说明了所有对象都是可偏向的,但所有对象的状态都为“0”,也同时说明所有被创建的对象的偏向锁并没有生效。

6.3偏向锁

当线程执行到临界区(critical section)时,此时会利用CAS(Compare and Swap)操作,将线程ID插入到Markword中,同时修改偏向锁的标志位

临界区:就是只允许一个线程进去执行操作的区域,即同步代码块,只要对多线程并发有影响的都叫临界区。CAS是一个原子性操作。

此时Mark word的结构信息如下:

偏向锁的状态为“1”,说明对象的偏向锁生效了,同时也可以看到那个线程获取了该对象的锁

偏向锁:jdk1.6引入的一项锁优化,其中的“偏”是偏心的偏。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作

也就是说:
在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不再需要去进行加锁或者解锁操作,而是会做以下的步骤:

  1. Load-and-test,也就是简单判断一下当前线程id是否与Markword当中的线程id是否一致。
  2. 如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码。
  3. 如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁”标志位的值。
  4. 如果还未偏向,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作。
  5. 如果此对象已经偏向了,并且不是偏向自己,则说明存在了竞争。此时可能就要根据另外线程的情况,可能是重新偏向,也有可能是做偏向撤销,但大部分情况下就是升级成轻量级锁了。

偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。

为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。在Jdk1.6中,偏向锁的开关是默认开启的,适用于只有一个线程访问同步块的场景。

简单来说:偏向锁就是,在没有线程竞争的条件下,第一个获取锁的线程通过CAS将自己的threadld写入到该对象的mark word中,若后续该线程再次获取锁,需要比较当前线程threadld和对象mark word中的threadld是否一致,如果一致那么可以直接获取,并且锁对象始终保持对该线程的偏向,也就是说偏向锁不会主动释放。

举例如下:

6.4轻量级锁

在讲解轻量级锁之前,我们来了解几个概念

锁膨胀

当出现有两个线程来竞争锁的话,那么偏向锁就失效了,此时锁就会膨胀,升级为轻量级锁。这也是我们经常所说的锁膨胀

锁撤销

由于偏向锁失效了,那么接下来就得把该锁撤销,锁撤销的开销花费还是挺大的。

其大概的过程如下:

  1. 在一个安全点停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复记录和 Markword,使其变成无锁状态。
  3. 唤醒当前线程,将当前锁升级成轻量级锁。
  4. 所以如果某些同步代码块大多数情况下都是有两个及以上的线程竞争的话,那么偏向锁就会是一种累赘,对于这种情况,我们一开始就把偏向锁默认关闭。

轻量级锁

锁撤销升级为轻量级锁之后,那么对象的Markword也会进行相应的变化。下面先简单描述下锁撤销之后,升级为轻量级锁的过程

  1. 线程在自己的栈桢中创建锁记录LockRecord。
  2. 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中‘
  3. 将锁记录中的Owner指针指向锁对象。
  4. 将锁对象的对象头的Markword替换为指向偏向锁记录的指针。

对应图描述如下:

此刻Markword:

锁标志位“00”标示轻量级锁

轻量级锁主要有两种:自旋锁,自适应自旋锁

简单来说就是:两个或以上线程交替获取锁,但并没有在对象上并发的获取锁时,偏向锁升级为轻量级锁。在此阶段,线程采取CAS的自旋方式尝试获取锁,避免阻塞线程造成的cpu在用户态和内核态间转换的消耗
 

6.4.1自旋锁

所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。

注意:锁在原地循环的时候,是会消耗CPU的,就相当于在执行一个啥也没有的for循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。

经验表明,大部分同步代码块执行的时间都是很短很短的,也正是基于这个原因,才有了轻量级锁这么个东西。

自旋锁的一些问题

如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时候其他线程在原地等待空消耗CPU。

本来一个线程把锁释放后,当前线程是能够获得锁的;但是假如这个时候有好几个线程来竞争这个锁的话,那么有可能当前线程会获取不到这把锁,继续等待消耗CPU,甚至有可能一直获取不到锁。

基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁

默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin来进行更改。

6.4.2自适应自旋锁

所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数

假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。

另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。

轻量级锁也被称为非阻塞同步、乐观锁因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待,串行执行

6.5重量级锁

轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁

当轻量级所经过锁撤销等步骤升级为重量级锁之后,它的Markword部分数据大体如下:

为什么重量级锁开销大呢

主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗CPU。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。这就是说为什么重量级线程开销很大的

互斥锁(重量级锁)也称为阻塞同步、悲观锁

简单来说就是:两个或两个以上线程并发的在同一个对象上进行同步时,为了避免无用自旋消耗CPU,轻量级锁会升级成重量级锁。这时mark word中的指针指向的是monitor对象(也被称为管程或监视器锁)的起始地址。

7.可重入锁

可重入锁是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞

如果是1个有synchronized修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。所以Java中ReentrantLock和synchronized都是可重入锁

可重入锁的一个优点是可一定程度避免死锁

代码演示如下图所示:

8.ReentrantLock同步互斥锁

下面来讲一下ReentrantLock同步互斥锁

我们知道,前面常用的synchronized是隐式锁,而这里的Lock是显式锁

ReentrantLock是Lock的默认实现,在聊ReentranLock之前,我们需要先弄清楚一些概念:

  1. 可重入锁:可重入锁是指同一个线程可以多次获得同一把锁;ReentrantLock和关键字Synchronized都是可重入锁
  2. 可中断锁:可中断锁是子线程在获取锁的过程中,是否可以进行相应的线程中断操作synchronized是不可中断的,ReentrantLock是可中断的
  3. 公平锁和非公平锁:公平锁是指多个线程尝试获取同一把锁的时候,获取锁的顺序按照线程到达的先后顺序获取,而不是随机插队的方式获取。synchronized是非公平锁,而ReentrantLock是两种都可以实现,不过默认是非公平锁

下面看一下代码演示:

不多解释。

8.1ReentrantLock的锁申请等待限时

申请锁等待限时是什么意思?一般情况下,获取锁的时间我们是不知道的,synchronized关键字获取锁的过程中,只能等待其他线程把锁释放之后才能够有机会获取到锁。所以获取锁的时间有长有短。如果获取锁的时间能够设置超时时间,那就非常好了

ReentrantLock刚好提供了这样功能,给我们提供了获取锁限时等待的方法trytock(),可以选择传入时间参数,表示等待指定的时间,无参则表示立即返回锁申请的结果:true表示获取锁成功,false表示获取锁失败。

下面看一下代码演示:

结果如下:

8.2ReentrantLock获取锁的过程是可中断的

对于synchronized关键字,如果一个线程在等待获取锁,最终只有2种结果:

  1. 要么获取到锁然后继续后面的操作
  2. 要么一直等待,直到其他线程释放锁为止

而ReentrantLock提供了另外一种可能,就是在等待获取锁的过程中(发起获取锁请求到还未获
取到锁这段时间内)是可以被中断的,也就是说在等待锁的过程中,程序可以根据需要取消获以锁的请求
。有些使用这个操作是非常有必要的。比如:你和好朋友越好一起去打球,如未例守半小时朋友还没到,突然你接到一个电话,朋友由于突发状况,不能来了,那么你一定达到回府。中断操作正是提供了一套类似的机制,如果一个线程正在等待获取锁,那么它依然可以收到一个通知,被告知无需等待,可以停止工作了。

关于获取锁的过程中被中断,注意几点:

  1. ReentrankLock中必须使用实例方法locInterruptibly()获取锁时,在线程调用interrupt()方法之后,才会引发InterruptedException异常
  2. 线程调用interrupt()之后,线程的中断标志会被置为true
  3. 触发InterruptedException异常之后,线程的中断标志会被清空,即置为false
  4. 所以当线程调用interrupt()引发InterruptedException异常,中断标志的变化是:false -> true -> false

下面看一下代码实例:

很简单,不解释。

9.公平锁和非公平锁

下面介绍一下公平锁与非公平锁

在大多数情况下,锁的申请都是非公平的,也就是说,线程1首先请求锁A,接着线程2也请求了锁A。那么当锁A可用时,是线程1可获得锁还是线程2可获得锁呢?这是不一定的,系统只是会从这个锁的等待队列中随机挑选一个,因此不能保证其公平性。这就好比买票不排队,大家都围在售票窗口前,售票员忙的焦头烂额,也顾及不上谁先谁后,随便找个人出票就完事了,最终导致的结果是,有些人可能一直买不到票。而公平锁,则不是这样,它会按照到达的先后顺序获得资源。

公平锁的一大特点是:它不会产生饥饿现象,只要你排队,最终还是可以等到资的;synchronized关键字默认是有jvm内部实现控制的,是非公平锁。而ReentrantLock运行开发者自己设置锁的公平性。

代码演示这里就省略了。

9.1关于公平/非公平锁的几个问题

为什么会有公平/非公平锁的设计?为什么默认非公平锁?

原因有以下几点:

1.线程恢复挂起到真正获取到锁是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU的时间片,尽量减少CPU空闲状态时间

2.使用多线程的一个很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。

使用公平锁会有什么问题?

公平锁保证了排队的公平性,非公平锁霸气的忽视这个规则,所以就有可能导致排队的长时间在排队,也没有机会获取到锁,这就是传说中的“锁饥饿”

什么时候用公平锁?什么时候用非公平锁?

如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量目然就上去了;否则那就用公平锁,大家公平使用。

10.共享锁和排他锁

下面讲一下共享锁和排他锁

10.1排他锁

排它锁又称独占锁获得了以后既能读又能写,其他没有获得锁的线程不能读也不能写,典型的synchronized就是排它锁

10.2共享锁

共享锁又称读锁获得了共享锁以后可以查看但无法修改和删除数据,其他线程也能获得共享锁,也可以查看但不能修改和删除数据

在没有读写锁之前,我们虽然保证了线程安全,但是也浪费了一定的资源,因为多个读操作同时进行并没有线程安全问题

ReentrantReadWriteLock中读锁就是共享锁,写锁是排它锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果不这样,读是无限阻塞的,这样提高了程序的执行效率

读写锁的规则:

多个线程只申请读锁,都能申请到;如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放该锁;如果有一个线程已经占用写锁,则其他线程申请写锁或读锁都要等待它释放也就是说,要么多读要么一写

11.synchronized与Lock的区别

下面讲一下synchronized与Lock的区别。

区别如下:

  1. 首先synchronized是java内置关键字,在jvm层面,Lock是个java类
  2. synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁
  3. synchronized会自动释放锁(a线程执行完同步代码会释放锁;b线程执行过程中发生异常会释放锁),Lock需在 finally 中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁
  4. 用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了
  5. synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可中断、可公平(两者皆可)
  6. Lock锁适合大量同步的代码的同步问题,synchronized锁适合代码少量的同步问题。

12.小结

下面小结一下这篇文章的内容。

首先,我们讲了乐观锁和悲观锁,要清楚这是锁的分类,不是锁的具体实现,并且要明白这两种锁的含义。然后我们讲了乐观锁的实现方式,即CAS算法,我们要清楚CAS算法是什么,它的底层是什么样的,注意一下ABA问题。然后我们讲了自旋锁和互斥锁,要清楚这两种锁的概念。然后我们讲了wait和sleep的区别,并且在此基础上,我们详细的讲解了生产者和消费者模式的流程,并且代码实现了生产者和消费者模式,这个要重点掌握。之后,我们重点讲了synchronized的锁升级,是按无锁->偏向性锁->轻量级锁->重量级锁的方式升级的,我们要清楚在什么情况下会升级为什么,以及它的底层是什么样的,然后讲了管程的内容。然后讲了可重入锁。之后讲了ReentrantLock同步互斥锁,讲了它的几个特性,之后讲了公平锁和非公平锁共享锁和排他锁,最后讲了synchronized与Lock的区别

总体来说,内容非常的散。但是没办法,这些都挺重要的。我在前言部分给了一张图,按照那张图,然后再结合文章里面的内容,应该可以较好的掌握并发锁的这部分内容。

  • 35
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

L纸鸢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值