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

在之前的文章中,为了保证在并发情况下多线程共享数据的线程安全,我们会使用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. P2014选课

    洛谷P2014选课 一道树形DP题. f[i][j]表示i个点选j门课程的最大学分. 递推方程: for(int a=n;a>0;a--)//总共选择多少 for(int b=0;b<a; ...

  2. P4778 Counting Swaps 题解

    第一道 A 掉的严格意义上的组合计数题,特来纪念一发. 第一次真正接触到这种类型的题,给人感觉好像思维得很发散才行-- 对于一个排列 \(p_1,p_2,\dots,p_n\),对于每个 \(i\) ...

  3. odoo里面批量上传图片

    import os import base64 def base_data_product_image(self): """ odoo里批量创建产品,并上传图片 图片为b ...

  4. SQL Server 判断表名称、索引、表字段是否存在

    1.判断索引是否存在 ps:@tableName 表名称, @indexName 索引名 IF EXISTS (SELECT 1 FROM sys.indexes WHERE object_id=OB ...

  5. HttpRunner3源码阅读:7.响应后处理 response.py

    response 上一篇说的client.py来发送请求,这里就来看另一个response.py,该文件主要是完成测试断言方法 可用资料 jmespath[json数据取值处理]: https://g ...

  6. WarError syncing load balancer: failed to ensure load balancer: network.SubnetsClient#Get: Failure responding to request: StatusCode=403

    Warning SyncLoadBalancerFailed 4m55s (x8 over 15m) service-controller Error syncing load balancer: f ...

  7. 接口管理效率神器Apifox

    前言 你是一个测试,你们团队目前开发模式是前后端分离. 某一天,版本V1.0接口评审完,发布在了swagger上,前后端各自进行开发.此时你根据接口文档将新接口迁移到JMeter上,然后开始编写接口测 ...

  8. 第5篇-调用Java方法后弹出栈帧及处理返回结果

    在前一篇 第4篇-JVM终于开始调用Java主类的main()方法啦 介绍了通过callq调用entry point,不过我们并没有看完generate_call_stub()函数的实现.接下来在ge ...

  9. 一文让你彻底掌握ArcGisJS地图管理的秘密

    使用ArcGis开发地图 引用ArcGisJS 使用ArcGisJS开发地图,首先需要引入ArcGis的Js文件和CSS文件,引入方式有两种,一种是官网JS引用,一种是本地JS引用.如下: 官网JS引 ...

  10. Linux 鸟叔的私房菜--完全结束

    2018年10月22日 我不想再拖下去了,一本书看不完就无法进行下一本书的阅读,可能算是我的一个强迫症(借口吧) 之前看05年第一版<鸟叔的Linux私房菜>停在脚本语言那里,迟迟没有前进 ...