大家好,我是小黑,一个在互联网苟且偷生的农民工。

在之前的文章中,为了保证在并发情况下多线程共享数据的线程安全,我们会使用synchronized关键字来修饰方法或者代码块,以及在生产者消费者模式中同样使用synchronized来保证生产者和消费者对于缓冲区的原子操作。

synchronized的缺点

那么synchronized这么厉害,到底有没有什么缺点呢?主要有以下几个方面:

  1. 使用synchronized加锁的代码块或者方法,在线程获取锁时,会一直试图获取直到获取成功,不能中断。
  2. 加锁的条件只能在一个锁对象上,不支持其他条件
  3. 无法知道锁对象的状态,是否被锁
  4. synchronized锁只支持非公平锁,无法做到公平
  5. 对于读操作和写操作都是使用独占锁,无法支持共享锁(在读操作时共享,写操作时独占)
  6. synchronized锁在升级之后不支持降级,如在业务流量高峰阶段升级为重量级锁,流量降低时还是重量级,效率较低(有些JVM实现支持降级,但是降级条件极为苛刻,对于Java线程来说可基本认为是不支持降级)
  7. 线程间通信无法按条件进行线程的唤醒,如生产者消费者场景中生产者完成数据生产后无法做到只唤醒消费者,其他等待的生产者也会被同时唤醒

以上是我能想到的synchronized锁的一些缺点,如果你有不同的看法,欢迎私信交流。(没有留言板的痛/(ㄒoㄒ)/~~)

那么synchronized的这些问题该如何解决呢?或者有没有替代方案?答案是有的,就是使用我们今天要讲的Lock锁。

Lock的优点

Lock锁是Java.util.concurrent.locks(JUC)包中的一个接口,并且有很多不同的实现类。这些实现类基本可以完全解决上面我们说到的所有问题。

Lock锁具备以下优点:

  1. 支持超时获取,中断获取
  2. 可以按条件加锁,灵活性更高
  3. 支持公平和非公平锁
  4. 有独占锁和共享锁的实现,如读写锁
  5. 可以做到等待线程的精准唤醒

接下来具体看看对应的实现。

基础铺垫

在开始之前,先和大家对于一些概念做一下回顾和普及。

可重入锁

可重入锁是指锁具备可重入的特性,可重入的意思是一个线程在获取锁之后,如果再次获取锁时,可以成功获取,不会因为锁正在被占有而死锁。

synchronized锁就是可重入锁,在一个synchronized方法中递归调用本方法,可以成功获取到锁,不会死锁。

Lock锁的实现中基本也都支持可重入。

公平锁和非公平锁

公平锁指在有线程获取锁失败阻塞时,一定会让先开始阻塞的线程先执行,就好比是排队买票,排在前面的先买;

非公平锁则不保证这种公平性,就算有其他线程在阻塞等待,新来的线程也可以直接获取锁,就好比插队。

独占锁和共享锁

独占锁是指一把锁同一时间只能被一个线程持有,举个生活中的例子,我们使用打车软件打专车,那么一辆车同一时间只能让一个用户打到,这辆专车就好比是一把独占锁,被一个用户独自占有了嘛。

共享锁则不一样,一把锁可以被多个线程持有,这个就想我们打拼车,一辆拼车同一时间可以让多个用户打到,这辆拼车就是一把共享锁。

说完这些以后我们来看一下Lock接口的一些具体实现。

ReentrantLock

ReentrantLock从名称理解,就是一把可重入锁,并且它是一把独占锁,而且具有公平和非公平实现。

我们通过代码来看一下如何通过ReentrantLock来做加解锁操作。

public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock(false);
lock.lock();
try {
// do something...
}finally {
lock.unlock();
}
}

首先创建一个ReentrantLock对象,在创建时构造方法传入的boolean值控制是公平锁还是非公平锁,如果不传参数则默认是非公平锁。

调用lock()方法来进行加锁,可以看到使用try-finally代码块,在finally中进行unlock()解锁操作,这一点一定要注意,因为lock不会自己进行解锁,必须手动进行释放,为了保证锁一定可以被释放,防止发生死锁,所以要在finally中进行。这一点和synchronized有区别,使用synchronized不用关注锁的释放时机,这也是为了灵活性必须要付出的一点代价。

ReentrantLock除了通过lock()方法加锁之外,还有以下方式加锁:

  • tryLock():只有在调用时它不被另一个线程占用才能获取锁
  • tryLock(long timeout, TimeUnit unit) 如果在给定的等待时间内没有被另一个线程占用,并且当前线程尚未被中断,则获取该锁
  • lockInterruptibly() 获取锁定,除非当前线程是interrupted

除了获取锁的方法之外,还有一些其他的方法可以获得一些锁相关的状态信息:

  • isLocked() 查询此锁是否由任何线程持有
  • isHeldByCurrentThread() 查询此锁是否由当前线程持有
  • getOwner() 返回当前拥有此锁的线程,如果不拥有,则返回null

ReentrantLock本身是独占锁,不支持共享,那么如何做到线程的精准唤醒,我们接着说。

Condition

Condition也是JUC包下的locks包中的一个接口,提供了类似于Object的wait(),notify(),notifyAll()这样的对象监听器方法,可以与Lock的实现类配合做到线程的等待/唤醒机制,并且能够做到精准唤醒。接下来我们看下面的例子:

public class ProdConsDemo {

    public static void main(String[] args) {
KFC kfc = new KFC();
new Thread(() -> {
for (int i = 0; i < 100; i++) {
kfc.product();
}
}, "店员1").start();
new Thread(() -> {
for (int i = 0; i < 100; i++) {
kfc.product();
}
}, "店员2").start();
new Thread(() -> {
for (int i = 0; i < 100; i++) {
kfc.consume();
}
}, "顾客1").start();
new Thread(() -> {
for (int i = 0; i < 100; i++) {
kfc.consume();
}
}, "顾客2").start();
}
} class KFC {
int hamburgerNum = 0; public synchronized void product() {
while (hamburgerNum == 10) {
try {
// 数量到达最大,生产者等待
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("生产一个汉堡" + (++hamburgerNum));
// 唤醒其他线程
this.notifyAll();
} public synchronized void consume() {
while (hamburgerNum == 0) {
try {
//数量到达最小,消费者等待
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("卖出一个汉堡" + (hamburgerNum--));
// 唤醒其他线程
this.notifyAll();
}
}

看过小黑之前文章的朋友应该还记得这个例子,KFC里的店员生产汉堡,顾客来消费,典型的生产者消费者模式,我们可以看到在上面的代码中,是使用的锁对象this的wait()和notifyAll()方法来做线程等待和唤醒。那么这里会有一个问题,就是在notifyAll()时,无法做到只唤醒消费者或者只唤醒生产者。而在线程被唤醒之后就会面临更多的线程切换,而线程切换是很消耗CPU资源的。

那么我们使用Condition和ReentrantLock来修改一下我们的代码。

class KFC {
int hamburgerNum = 0;
ReentrantLock lock = new ReentrantLock();
Condition isEmpty = lock.newCondition();
Condition isFull = lock.newCondition();
public void product() {
lock.lock();
try {
while (hamburgerNum == 10) {
// 数量到达最大,生产者等待
isFull.await();
}
System.out.println("生产一个汉堡" + (++hamburgerNum));
// 唤醒消费者线程
isEmpty.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
} public void consume() {
lock.lock();
try {
while (hamburgerNum == 0) {
//数量到达最小,消费者等待
isEmpty.await();
}
System.out.println("卖出一个汉堡" + (hamburgerNum--));
// 唤醒生产者线程
isFull.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

可以看到,我们使用ReentrantLock来进行线程安全控制,进行加解锁,然后创建两个Condition对象,分别代表生产者和消费者的标记,当生产者生产完一个之后,就会准确的唤醒消费者线程,反之同理。

ReadWriteLock

ReadWriteLock是读写锁接口,通过ReadWriteLock可以实现多个线程对于读操作共享,对于写操作独占。

在ReadWriteLock中有两个Lock变量,通过两个Lock分别控制读和写。

class Data {
private int num = 0;
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
public void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "读取=>" + num);
TimeUnit.SECONDS.sleep(5);
System.out.println(Thread.currentThread().getName() + "读取结束");
} catch (Exception e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
} public void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "写入=>" + num++);
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "写入结束");
} catch (Exception e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
}
}

对于读写锁,线程对于锁的竞争情况如下:

  1. 读-读操作共享;
  2. 读-写操作独占;
  3. 写-读操作独占;
  4. 写-写操作独占;

也就是说,当有一个线程持有读锁时,其他线程也可以获取读到读锁,但是不能获取写锁,必须等读锁释放;当有一个线程持有写锁时,其他线程都不能获取到锁。

StampedLock

StampedLock是JDK1.8新引入的,主要是为了优化ReadWriteLock的读写锁性能,相比于普通的ReadWriteLock主要多了乐观获取读锁的功能。

那么ReadWriteLock有什么性能问题呢?主要出现在读-写操作上,当有一个线程在读取时,写线程只能等读取完之后才能获取,读的过程中不允许写,是一个悲观读锁。

StampedLock允许在读的过程中写,但是这样会导致我们读线程获取的数据不一致,所以需要增加一点代码来判断在读的过程中是否有些操作,这是一种乐观读的锁;我们来看一下代码。

class Data {
private int num = 0; private final StampedLock lock = new StampedLock(); public void read() {
// long stamp = lock.readLock();
// 获取乐观读,拿到一个版本戳
long stamp = lock.tryOptimisticRead();
try {
System.out.println(Thread.currentThread().getName() + "读取=>" + num);
TimeUnit.SECONDS.sleep(5);
System.out.println(Thread.currentThread().getName() + "读取结束");
// 读取完之后对刚开始拿到的版本戳进行验证
if (!lock.validate(stamp)) {
// 验证不通过,说明发生了写操作,这是需要重新获取悲观读锁进行处理
System.out.println("validatefalse");
stamp = lock.readLock();
// do something...
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlockRead(stamp);
}
} public void write() {
long stamp = lock.writeLock();
try {
System.out.println(Thread.currentThread().getName() + "写入=>" + num++);
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "写入结束");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlockWrite(stamp);
}
}
}

所以StampedLock就是先乐观的认为在读的过程中不会有写操作,所以是乐观锁,而悲观锁就是悲观的认为在读的过程中会有些操作,所以拒绝写入。

显然在并发高的情况下乐观锁的并发效率要更高,但是会有一小部分的写入导致数据不准确,所以需要通过validate(stamp)检测出来,重新读取。

总结

简单总结一下,首先我们讲了synchronized的7个缺点:不能超时中断;只能在一个对象上加锁;获取不到锁的状态;不支持公平锁;不支持共享锁;锁升级后不能降级;无法做到精准唤醒阻塞线程等。

然后我们通过Lock的具体实现看到,Lock都解决了这些问题,ReentrantLock支持超时中断获取锁,并且可以按条件判断进行加锁,有方法可以看到锁的状态信息,支持公平和非公平实现等,通过Condition的await()和signal()/signalAll()可以做到精准唤醒等待线程;ReadWriteLock可以支持共享锁,读锁共享,写锁独占;然后StampedLock在性能上对读写锁进行优化,主要是通过乐观读锁和vaidate(stamp)验证读取过程中有没有写入。

使用Lock锁很重要的一点就是需要自己手动释放锁,所以一定要写在finally中;

使用Conditon进行唤醒线程时要记清楚是signal()/signalAll()方法,不是notify()/notifyAll()方法,不要用错了。

Lock锁的底层实现逻辑都是依赖于AbstractQueuedSynchronizer(AQS)和CAS无锁机制来实现的,这部分内容比较复杂,我们下期单独来说一说。


好的,今天的内容就到这里,我们下期见。

关注我的公众号【小黑说Java】,更多干货内容。

并发编程之:Lock的更多相关文章

  1. Java并发编程之Lock

    重入锁ReentrantLock 可以代替synchronized, 但synchronized更灵活. 但是, 必须必须必须要手动释放锁. try { lock.lock(); } finally ...

  2. Java并发编程之Lock(同步锁、死锁)

    这篇文章是接着我上一篇文章来的. 上一篇文章 同步锁 为什么需要同步锁? 首先,我们来看看这张图. 这是一个程序,多个对象进行抢票. package MovieDemo; public class T ...

  3. Java并发编程之CAS

    CAS(Compare and swap)比较和替换是设计并发算法时用到的一种技术.简单来说,比较和替换是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替 ...

  4. 并发编程之 Condition 源码分析

    前言 Condition 是 Lock 的伴侣,至于如何使用,我们之前也写了一些文章来说,例如 使用 ReentrantLock 和 Condition 实现一个阻塞队列,并发编程之 Java 三把锁 ...

  5. python并发编程之multiprocessing进程(二)

    python的multiprocessing模块是用来创建多进程的,下面对multiprocessing总结一下使用记录. 系列文章 python并发编程之threading线程(一) python并 ...

  6. python并发编程之threading线程(一)

    进程是系统进行资源分配最小单元,线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.进程在执行过程中拥有独立的内存单元,而多个线程共享内存等资源. 系列文章 py ...

  7. 并发编程之J.U.C的第一篇

    并发编程之J.U.C AQS 原理 ReentrantLock 原理 1. 非公平锁实现原理 2)可重入原理 3. 可打断原理 5) 条件变量实现原理 3. 读写锁 3.1 ReentrantRead ...

  8. 并发编程之:Atomic

    大家好,我是小黑,一个在互联网苟且偷生的农民工. 在开始讲今天的内容之前,先问一个问题,使用int类型做加减操作是不是线程安全的呢?比如 i++ ,++i,i=i+1这样的操作在并发情况下是否会有问题 ...

  9. [转载]并发编程之Operation Queue和GCD

    并发编程之Operation Queue http://www.cocoachina.com/applenews/devnews/2013/1210/7506.html 随着移动设备的更新换代,移动设 ...

  10. 并发编程之wait()、notify()

    前面的并发编程之volatile中我们用程序模拟了一个场景:在main方法中开启两个线程,其中一个线程t1往list里循环添加元素,另一个线程t2监听list中的size,当size等于5时,t2线程 ...

随机推荐

  1. 7.15考试总结(NOIP模拟16)[Star Way To Heaven·God Knows·Lost My Music]

    败者死于绝望,胜者死于渴望. 前言 一看这个题就来者不善,对于第一题第一眼以为是一个大模拟,没想到是最小生成树. 对于第二题,先是看到了状压可以搞到的 20pts 然后对着暴力一顿猛调后来发现是题面理 ...

  2. 第三篇 -- SpringBoot打包成jar包

    本篇介绍怎么将SprintBoot项目打包成jar包. 第一步:点击IDEA右侧的maven. 第二步:双击package,然后就会开始打包,当出现build success时,就打包成功了,一般在t ...

  3. 前后端数据交互利器--Protobuf

    Protobuf 介绍 Protocol Buffers(又名 protobuf)是 Google 的语言中立.平台中立.可扩展的结构化数据序列化机制. https://github.com/prot ...

  4. Hashtable 的实现原理

    概述 和 HashMap 一样,Hashtable 也是一个散列表,它存储的内容是键值对. Hashtable 在 Java 中的定义为: public class Hashtable<K,V& ...

  5. C#曲线分析平台的制作(一,ajax+json前后台数据传递)

    在最近的项目学习中,需要建立一个实时数据的曲线分析平台,这其中的关键在于前后台数据传递过程的学习,经过一天的前辈资料整理,大概有了一定的思路,现总结如下: 1.利用jquery下ajax函数实现: & ...

  6. 百度nlp api接口测试

    date:2021/7/8 使用postman测试 网址:https://ai.baidu.com/ 在百度AI首页-开放能力-自然语言处理-语言处理基础技术 点击技术文档 在左侧文档目录选择API参 ...

  7. Apereo CAS 4.1 反序列化命令执行漏洞

    命令执行 java -jar apereo-cas-attack-1.0-SNAPSHOT-all.jar CommonsCollections4 "touch /tmp/success&q ...

  8. kubernetes/k8s CRI分析-kubelet创建pod分析

    先来简单回顾上一篇博客<kubernetes/k8s CRI 分析-容器运行时接口分析>的内容. 上篇博文先对 CRI 做了介绍,然后对 kubelet CRI 相关源码包括 kubele ...

  9. 为ScrollView增加圆角的三种方式,及自定义属性【在Linearlayout中新增ScrollView支持滚动 后续】

    获取圆角的几种方案如下:方案一:通过shape来实现,给scrollView增加背景来实现方案二:通过自定义ScrollView,还要自定义属性,在dispatchDraw中不停的裁剪方案三:用And ...

  10. 得到、微信、美团、爱奇艺APP组件化架构实践

    一.背景 随着项目逐渐扩展,业务功能越来越多,代码量越来越多,开发人员数量也越来越多.此过程中,你是否有过以下烦恼? 项目模块多且复杂,编译一次要5分钟甚至10分钟?太慢不能忍? 改了一行代码 或只调 ...