博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
Java 8 并发教程:同步和锁
阅读量:6455 次
发布时间:2019-06-23

本文共 7983 字,大约阅读时间需要 26 分钟。

原文:

译者:
协议:

欢迎阅读我的Java8并发教程的第二部分。这份指南将会以简单易懂的代码示例来教给你如何在Java8中进行并发编程。这是一系列教程中的第二部分。在接下来的15分钟,你将会学会如何通过同步关键字,锁和信号量来同步访问共享可变变量。

  • 第一部分:线程和执行器

  • 第二部分:同步和锁

  • 第三部分:原子操作和 ConcurrentMap

这篇文章中展示的中心概念也适用于Java的旧版本,然而代码示例适用于Java 8,并严重依赖于lambda表达式和新的并发特性。如果你还不熟悉lambda,我推荐你先阅读我的Java 8 教程。

出于简单的因素,这个教程的代码示例使用了定义在的两个辅助函数sleep(seconds)stop(executor)

同步

在上一章中,我们学到了如何通过执行器服务同时执行代码。当我们编写这种多线程代码时,我们需要特别注意共享可变变量的并发访问。假设我们打算增加某个可被多个线程同时访问的整数。

我们定义了count字段,带有increment()方法来使count加一:

int count = 0;void increment() {    count = count + 1;}

当多个线程并发调用这个方法时,我们就会遇到大麻烦:

ExecutorService executor = Executors.newFixedThreadPool(2);IntStream.range(0, 10000)    .forEach(i -> executor.submit(this::increment));stop(executor);System.out.println(count);  // 9965

我们没有看到count为10000的结果,上面代码的实际结果在每次执行时都不同。原因是我们在不同的线程上共享可变变量,并且变量访问没有同步机制,这会产生。

增加一个数值需要三个步骤:(1)读取当前值,(2)使这个值加一,(3)将新的值写到变量。如果两个线程同时执行,就有可能出现两个线程同时执行步骤1,于是会读到相同的当前值。这会导致无效的写入,所以实际的结果会偏小。上面的例子中,对count的非同步并发访问丢失了35次增加操作,但是你在自己执行代码时会看到不同的结果。

幸运的是,Java自从很久之前就通过synchronized关键字支持线程同步。我们可以使用synchronized来修复上面在增加count时的竞争条件。

synchronized void incrementSync() {    count = count + 1;}

在我们并发调用incrementSync()时,我们得到了count为10000的预期结果。没有再出现任何竞争条件,并且结果在每次代码执行中都很稳定:

ExecutorService executor = Executors.newFixedThreadPool(2);IntStream.range(0, 10000)    .forEach(i -> executor.submit(this::incrementSync));stop(executor);System.out.println(count);  // 10000

synchronized关键字也可用于语句块:

void incrementSync() {    synchronized (this) {        count = count + 1;    }}

Java在内部使用所谓的“监视器”(monitor),也称为监视器锁(monitor lock)或内在锁( intrinsic lock)来管理同步。监视器绑定在对象上,例如,当使用同步方法时,每个方法都共享相应对象的相同监视器。

所有隐式的监视器都实现了重入(reentrant)特性。重入的意思是锁绑定在当前线程上。线程可以安全地多次获取相同的锁,而不会产生死锁(例如,同步方法调用相同对象的另一个同步方法)。

并发API支持多种显式的锁,它们由Lock接口规定,用于代替synchronized的隐式锁。锁对细粒度的控制支持多种方法,因此它们比隐式的监视器具有更大的开销。

锁的多个实现在标准JDK中提供,它们会在下面的章节中展示。

ReentrantLock

ReentrantLock类是互斥锁,与通过synchronized访问的隐式监视器具有相同行为,但是具有扩展功能。就像它的名称一样,这个锁实现了重入特性,就像隐式监视器一样。

让我们看看使用ReentrantLock之后的上面的例子。

ReentrantLock lock = new ReentrantLock();int count = 0;void increment() {    lock.lock();    try {        count++;    } finally {        lock.unlock();    }}

锁可以通过lock()来获取,通过unlock()来释放。把你的代码包装在try-finally代码块中来确保异常情况下的解锁非常重要。这个方法是线程安全的,就像同步副本那样。如果另一个线程已经拿到锁了,再次调用lock()会阻塞当前线程,直到锁被释放。在任意给定的时间内,只有一个线程可以拿到锁。

锁对细粒度的控制支持多种方法,就像下面的例子那样:

executor.submit(() -> {    lock.lock();    try {        sleep(1);    } finally {        lock.unlock();    }});executor.submit(() -> {    System.out.println("Locked: " + lock.isLocked());    System.out.println("Held by me: " + lock.isHeldByCurrentThread());    boolean locked = lock.tryLock();    System.out.println("Lock acquired: " + locked);});stop(executor);

在第一个任务拿到锁的一秒之后,第二个任务获得了锁的当前状态的不同信息。

Locked: trueHeld by me: falseLock acquired: false

tryLock()方法是lock()方法的替代,它尝试拿锁而不阻塞当前线程。在访问任何共享可变变量之前,必须使用布尔值结果来检查锁是否已经被获取。

ReadWriteLock

ReadWriteLock接口规定了锁的另一种类型,包含用于读写访问的一对锁。读写锁的理念是,只要没有任何线程写入变量,并发读取可变变量通常是安全的。所以读锁可以同时被多个线程持有,只要没有线程持有写锁。这样可以提升性能和吞吐量,因为读取比写入更加频繁。

ExecutorService executor = Executors.newFixedThreadPool(2);Map
map = new HashMap<>();ReadWriteLock lock = new ReentrantReadWriteLock();executor.submit(() -> { lock.writeLock().lock(); try { sleep(1); map.put("foo", "bar"); } finally { lock.writeLock().unlock(); }});

上面的例子在暂停一秒之后,首先获取写锁来向映射添加新的值。在这个任务完成之前,两个其它的任务被启动,尝试读取映射中的元素,并暂停一秒:

Runnable readTask = () -> {    lock.readLock().lock();    try {        System.out.println(map.get("foo"));        sleep(1);    } finally {        lock.readLock().unlock();    }};executor.submit(readTask);executor.submit(readTask);stop(executor);

当你执行这一代码示例时,你会注意到两个读任务需要等待写任务完成。在释放了写锁之后,两个读任务会同时执行,并同时打印结果。它们不需要相互等待完成,因为读锁可以安全同步获取,只要没有其它线程获取了写锁。

StampedLock

Java 8 自带了一种新的锁,叫做StampedLock,它同样支持读写锁,就像上面的例子那样。与ReadWriteLock不同的是,StampedLock的锁方法会返回表示为long的标记。你可以使用这些标记来释放锁,或者检查锁是否有效。此外,StampedLock支持另一种叫做乐观锁(optimistic locking)的模式。

让我们使用StampedLock代替ReadWriteLock重写上面的例子:

ExecutorService executor = Executors.newFixedThreadPool(2);Map
map = new HashMap<>();StampedLock lock = new StampedLock();executor.submit(() -> { long stamp = lock.writeLock(); try { sleep(1); map.put("foo", "bar"); } finally { lock.unlockWrite(stamp); }});Runnable readTask = () -> { long stamp = lock.readLock(); try { System.out.println(map.get("foo")); sleep(1); } finally { lock.unlockRead(stamp); }};executor.submit(readTask);executor.submit(readTask);stop(executor);

通过readLock()writeLock()来获取读锁或写锁会返回一个标记,它可以在稍后用于在finally块中解锁。要记住StampedLock并没有实现重入特性。每次调用加锁都会返回一个新的标记,并且在没有可用的锁时阻塞,即使相同线程已经拿锁了。所以你需要额外注意不要出现死锁。

就像前面的ReadWriteLock例子那样,两个读任务都需要等待写锁释放。之后两个读任务同时向控制台打印信息,因为多个读操作不会相互阻塞,只要没有线程拿到写锁。

下面的例子展示了乐观锁:

ExecutorService executor = Executors.newFixedThreadPool(2);StampedLock lock = new StampedLock();executor.submit(() -> {    long stamp = lock.tryOptimisticRead();    try {        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));        sleep(1);        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));        sleep(2);        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));    } finally {        lock.unlock(stamp);    }});executor.submit(() -> {    long stamp = lock.writeLock();    try {        System.out.println("Write Lock acquired");        sleep(2);    } finally {        lock.unlock(stamp);        System.out.println("Write done");    }});stop(executor);

乐观的读锁通过调用tryOptimisticRead()获取,它总是返回一个标记而不阻塞当前线程,无论锁是否真正可用。如果已经有写锁被拿到,返回的标记等于0。你需要总是通过lock.validate(stamp)检查标记是否有效。

执行上面的代码会产生以下输出:

Optimistic Lock Valid: trueWrite Lock acquiredOptimistic Lock Valid: falseWrite doneOptimistic Lock Valid: false

乐观锁在刚刚拿到锁之后是有效的。和普通的读锁不同的是,乐观锁不阻止其他线程同时获取写锁。在第一个线程暂停一秒之后,第二个线程拿到写锁而无需等待乐观的读锁被释放。此时,乐观的读锁就不再有效了。甚至当写锁释放时,乐观的读锁还处于无效状态。

所以在使用乐观锁时,你需要每次在访问任何共享可变变量之后都要检查锁,来确保读锁仍然有效。

有时,将读锁转换为写锁而不用再次解锁和加锁十分实用。StampedLock为这种目的提供了tryConvertToWriteLock()方法,就像下面那样:

ExecutorService executor = Executors.newFixedThreadPool(2);StampedLock lock = new StampedLock();executor.submit(() -> {    long stamp = lock.readLock();    try {        if (count == 0) {            stamp = lock.tryConvertToWriteLock(stamp);            if (stamp == 0L) {                System.out.println("Could not convert to write lock");                stamp = lock.writeLock();            }            count = 23;        }        System.out.println(count);    } finally {        lock.unlock(stamp);    }});stop(executor);

第一个任务获取读锁,并向控制台打印count字段的当前值。但是如果当前值是零,我们希望将其赋值为23。我们首先需要将读锁转换为写锁,来避免打破其它线程潜在的并发访问。tryConvertToWriteLock()的调用不会阻塞,但是可能会返回为零的标记,表示当前没有可用的写锁。这种情况下,我们调用writeLock()来阻塞当前线程,直到有可用的写锁。

信号量

除了锁之外,并发API也支持计数的信号量。不过锁通常用于变量或资源的互斥访问,信号量可以维护整体的准入许可。这在一些不同场景下,例如你需要限制你程序某个部分的并发访问总数时非常实用。

下面是一个例子,演示了如何限制对通过sleep(5)模拟的长时间运行任务的访问:

ExecutorService executor = Executors.newFixedThreadPool(10);Semaphore semaphore = new Semaphore(5);Runnable longRunningTask = () -> {    boolean permit = false;    try {        permit = semaphore.tryAcquire(1, TimeUnit.SECONDS);        if (permit) {            System.out.println("Semaphore acquired");            sleep(5);        } else {            System.out.println("Could not acquire semaphore");        }    } catch (InterruptedException e) {        throw new IllegalStateException(e);    } finally {        if (permit) {            semaphore.release();        }    }}IntStream.range(0, 10)    .forEach(i -> executor.submit(longRunningTask));stop(executor);

执行器可能同时运行10个任务,但是我们使用了大小为5的信号量,所以将并发访问限制为5。使用try-finally代码块在异常情况中合理释放信号量十分重要。

执行上述代码产生如下结果:

Semaphore acquiredSemaphore acquiredSemaphore acquiredSemaphore acquiredSemaphore acquiredCould not acquire semaphoreCould not acquire semaphoreCould not acquire semaphoreCould not acquire semaphoreCould not acquire semaphore

信号量限制对通过sleep(5)模拟的长时间运行任务的访问,最大5个线程。每个随后的tryAcquire()调用在经过最大为一秒的等待超时之后,会向控制台打印不能获取信号量的结果。

这就是我的系列并发教程的第二部分。以后会放出更多的部分,所以敬请等待吧。像以前一样,你可以在上找到这篇文档的所有示例代码,所以请随意fork这个仓库,并自己尝试它。

我希望你能喜欢这篇文章。如果你还有任何问题,在下面的评论中向我反馈。你也可以来获取更多开发相关的信息。

  • 第一部分:线程和执行器

  • 第二部分:同步和锁

  • 第三部分:原子操作和 ConcurrentMap

转载地址:http://ohfzo.baihongyu.com/

你可能感兴趣的文章
转载:《TypeScript 中文入门教程》 16、Symbols
查看>>
JavaScript、jQuery、HTML5、Node.js实例大全-读书笔记4
查看>>
C#技术------垃圾回收机制(GC)
查看>>
漫谈并发编程(三):共享受限资源
查看>>
【转】github如何删除一个仓库
查看>>
Linux系统编程——进程调度浅析
查看>>
大数据Lambda架构
查看>>
openCV_java 图像二值化
查看>>
状态模式
查看>>
删除CentOS / RHEL的库和配置文件(Repositories and configuraiton files)
查看>>
DJANGO变动库的一次真实手动经历
查看>>
8个基本的引导工具的网页设计师
查看>>
【下载分】C语言for循环语句PK自我活动
查看>>
VC++获得微秒级时间的方法与技巧探讨(转)
查看>>
HDOJ-1010 Tempter of the Bone
查看>>
MySQL my.cnf参数配置优化详解
查看>>
HDU/HDOJ 2102 A计划 广度优先搜索BFS
查看>>
JavaNIO基础02-缓存区基础
查看>>
阿里 Blink 正式开源,重要优化点解读
查看>>
日本开设无人机专业,打造无人机“人才市场”
查看>>