7.3 锁与同步

▪ 了解如何使用锁来保护共享的可变数据
▪ 能够识别死锁并知道防止死锁的策略
▪ 了解监视器模式并能够将其应用于数据类型

1 同步
数据类型或函数的线程安全性:当从多个线程使用时,无论这些线程是如何执行的,都能正常工作,无需额外的协调。
原则:一个并发程序的正确性不应该依赖于时间的偶然性。

▪ 有四种策略可以确保代码的并发安全:
–限制:不要在线程之间共享数据。
–不变性:使共享数据不可变。
–使用现有的线程安全数据类型
–同步:防止线程同时访问共享数据。

▪ 前三种策略的核心思想:
– 避免共享 ➔ 即使共享,也只能读/不可写(immutable) ➔ 即使可写 (mutable),共享的可写数据应自己具备在多线程之间协调的能力,即“使 用线程安全的mutable ADT”
– 缺陷:不能用全局rep共享数据➔只能“读”共享数据,不能写➔可以共享 “读写”,但只有单一方法是安全的,多个方法调用就不安全了

▪由于共享可变数据的并发操作导致的竞争条件是灾难性的错误-很难发现、重现和调试
-我们需要一种方法让共享内存的并发模块彼此同步。
▪使代码安全并发的第四种策略:同步,防止线程同时访问共享数据。
▪锁是一种同步技术。
–锁是一个抽象,一次最多允许一个线程拥有它。
带锁的线程告诉另一个不带锁的线程,“我正在改变这个变量,现在不要碰它。”
▪使用锁告诉编译器和处理器线程正在并发地使用共享内存,这样寄存器和缓存将被刷新到共享存储,确保锁的所有者始终查看最新的数据。
▪阻塞通常意味着线程等待(不做进一步的工作)直到事件发生。

锁的两种操作
▪acquire 允许线程取得锁的所有权。
–如果一个线程试图获取另一个线程当前拥有的锁,它将阻塞,直到另一个线程释放该锁。
–在这一点上,它将与试图获取锁的任何其他线程竞争。
–一次最多只能有一个线程拥有锁。
▪release 放弃了锁的所有权,允许另一个线程获得它的所有权。
–如果另一个线程(比如线程2)持有锁l,则线程1上的获取(l)将被阻塞。它等待的事件是线程2执行释放(l)。
–在这一点上,如果线程1可以获取l,它将继续运行其代码,并拥有锁的所有权。
–可能另一个线程(比如线程3)在获取(l)时也被阻塞。线程1或线程3将获取锁l并继续。另一个将继续阻止,等待释放(l)再次。

▪为了用锁解决银行账户问题,我们可以添加一个锁来保护每个银行帐户。
▪现在,在他们能够访问或更新帐户余额之前,提款机必须首先获得该帐户的锁。
▪A和B都试图访问帐户1。
▪假设B先获得锁。然后A必须等待读写平衡,直到B完成并释放锁。
▪这可以确保A和B是同步的,但是另一台提款机C可以在不同的帐户上独立运行(因为该帐户受不同的锁保护)。

2锁定
▪锁的使用非常普遍,Java将其作为一种内置的语言特性提供给用户。
——每个对象都有一个与之隐式关联的锁—一个字符串、一个数组、一个ArrayList,每个类及其所有实例都有一个锁。
–即使是不起眼的对象也有一个锁,因此对象通常用于显式锁定:Object lock=new Object();
▪但是,不能在Java的内部锁上调用acquire和release。相反,可以使用synchronized语句获取语句块期间的锁:

在这里插入图片描述
▪ Java提供了两种基本的同步方法:
–synchronized statements/block同步语句/同步代码块
–和synchronized methods 同步方法
▪ 这样的同步区域,一次只能有一个线程处于由给定对象的锁保护的同步区域中。
▪ 换句话说,又回到了顺序编程世界,一次只运行一个线程,至少对于引用同一对象的其他同步区域是这样。
▪ 锁用于保护共享数据变量。
–如果对一个数据变量的所有访问都由同一个锁对象保护(由同步块包围),那么这些访问将被其他线程保证是原子不间断的。
▪ 使用获取与对象obj关联的锁
synchronized (obj) { … }
–它防止其他线程进入synchronized(obj)块,直到线程t完成其synchronized块。
▪ 锁只提供与获取相同锁的其他线程的互斥。对数据变量的所有访问都必须由同一个锁保护。
–可以在单个锁后面保护整个变量集合,但所有模块必须就它们将获取和释放的锁达成一致。

因为Java中的每个对象都有一个隐式关联的锁,所以你可能认为拥有对象的锁会自动阻止其他线程访问该对象。事实并非如此。
▪ 当线程t使用synchronized(obj)获取对象的锁时,
它只做一件事:它阻止其他线程进入它们自己的synchronized(expression)块,其中expression引用与obj相同的对象,直到线程t完成它的synchronized块。
▪ 即使它在synchronized块中,另一个线程也可能危险地对对象进行变异,只需忽略使用synchronizeditself即可。为了使用对象锁进行同步,必须使用适当的synchronizedblock或method关键字显式、仔细地保护每个这样的访问。
.锁只能确保与其他请求获取相同对象锁的线程互斥访问,如 果其他线程没有使用synchronized(obj)或者利用了不同的锁,则同 步会失效,需要仔细检查和设计同步块和同步方法

▪ 在编写类的方法时,最方便的锁是对象实例本身,即this。
▪ 作为一种简单的方法,我们可以通过在synchronized(this)中包装对rep的所有访问来保护类的整个rep。
▪ Monitor模式:Monitor是一个方法互斥的类,因此一次只能有一个线程在类的实例中。

在这里插入图片描述
▪ 每一个接触到rep的方法都必须用锁来保护——即使是像length()和toString()这样的小而琐碎的方法。
▪ 这是因为读和写都必须被保护——如果读没有被保护,那么它们可能会看到rep处于部分修改状态。
▪ 如果将关键字synchronized添加到方法签名中,Java将充当在方法体周围编写synchronized(this)的角色。
在这里插入图片描述
▪ 当线程调用同步方法时,它会自动获取该方法对象的内部锁,并在方法返回时释放它。即使返回是由未捕获的异常引起的,也会发生锁释放。
▪ 同一对象上的两个同步方法调用不可能交错。
当一个线程正在为一个对象执行同步方法时,为同一个对象块调用同步方法(挂起执行)的所有其他线程,直到第一个线程处理完该对象为止。
▪ 当同步方法退出时,它会自动建立与同一对象(获取同一锁)的任何后续同步方法调用的“发生在之前”关系。
▪ 这保证了对对象状态的更改对所有线程都可见。

▪ 在计算机科学中,happened-before relation是两个事件的结果之间的关系,因此如果一个事件发生在另一个事件之前,则结果必须反映这一点,即使这些事件实际上是无序执行的(通常是为了优化程序流)。
▪ 在Java中,happerborerelationship保证语句a写入的内存对语句B可见,即语句a在语句B开始读取之前完成写入。
▪ 这是为了确保内存的一致性。
在这里插入图片描述
▪ 调用静态同步方法时,锁是什么?
▪ 因为静态方法与类而不是对象相关联。在这种情况下,线程获取与类关联的类对象的内部锁。
▪ 因此,对类的静态字段的访问由一个与类的任何实例的锁不同的锁控制。
▪ synchronized方法和synchronized(this)块有什么区别?
–与synchronized方法不同,synchronized语句必须指定提供内部锁的对象。
–Synchronized语句对于改进细粒度同步的并发性非常有用。
▪ 那么线程安全仅仅是将synchronized关键字放在程序中的每个方法上吗?
▪ 不幸的是并不是这样。
▪ 首先,你实际上不想随意地同步方法。
–同步会给程序带来很大的成本。
–由于需要获取锁(刷新缓存并与其他处理器通信),同步方法调用可能需要更长的时间。
–由于这些性能原因,Java在默认情况下让许多可变数据类型不同步。当你不需要同步时,不要使用它。

以一种更谨慎的方式使用synchronized的另一个原因是,它最小化了对锁的访问范围。
–将synchronized添加到每个方法意味着锁就是对象本身,每个引用了对象的客户机都自动拥有对锁的引用,它可以随意获取和释放。
–因此,线程安全机制是公开的,可能会受到客户端的干扰。 ▪ 与此相反,使用的锁是rep内部的对象,并使用synchronized()块,适当而尽量减少lock的使用范围。

使用lock前应该思考对谁使用lock,随意而不加思考的lock会导致极大的性能损耗。
▪ synchronized关键字不是万能的。
▪ 线程安全需要一个规程-使用限制、不变性或锁来保护共享数据。
▪ 而且这个规程需要写下来,否则维护人员将不知道它是什么。

在这里插入图片描述
请注意,类的封装(没有rep公开)对于进行此参数非常重要。如果文本是公共的,那么SimpleBuffer之外的客户机将能够在不知道应该首先获取锁的情况下读写它,SimpleBuffer将不再是线程安全的。
在这里插入图片描述
▪ 锁定规程是确保同步代码是线程安全的策略。
▪ 我们必须满足两个条件:
–每个共享的可变变量都必须有锁保护。数据不能被读取或写入,除非在获取该锁的同步块内。–如果一个不变量包含多个共享的可变变量(甚至可能在不同的对象中),那么所有涉及的变量都必须由同一个锁保护。一旦线程获得锁,在释放锁之前必须重新建立不变量。▪ 这里使用的监视器模式满足这两个规则。rep中的所有共享可变数据(rep不变量依赖于这些数据)都由同一个锁保护。
在这里插入图片描述
3原子操作
使用同步开发线程安全的ADT
▪ 假设我们正在构建一个多用户编辑器,它允许多个用户同时连接并编辑它。
▪ 我们需要一个可变数据类型来表示文档中的文本。
▪ 这里是接口➔基本上它表示一个包含插入和删除操作的字符串。
在这里插入图片描述
在这里插入图片描述
间隙缓冲区–一个字符数组,其中有额外的空间,但不是所有的空间在最后,额外的空间是一个间隙,可以出现在缓冲区的任何地方。–间隙缓冲区中的文本表示为两个字符串,只占用很少的额外空间,可以很快搜索和显示。–每当需要插入或删除操作时,数据类型首先将间隙移动到操作的位置,然后执行插入或删除操作。–如果间隙已经存在,则无需复制任何内容-插入只会占用间隙的一部分,删除只会扩大间隙。–间隙缓冲区特别适合表示用户使用光标编辑的字符串,因为插入和删除往往集中在光标周围,所以间隙很少移动。
在这里插入图片描述
在多用户场景中,我们需要多个间隙,每个用户的光标对应一个间隙
考虑对EditBuffer数据类型执行查找和替换操作:
▪ 此方法对buf进行三次不同的调用。–即使这些单独的调用都是原子的,但findReplace方法作为一个整体并不是线程安全的,因为其他线程可能在findReplace工作时改变缓冲区,导致它删除错误的区域或将替换的放回错误的位置。
▪ 为了防止这种情况,findReplace需要与buf的所有其他客户端同步。
在这里插入图片描述
▪ 有时将数据类型的锁提供给客户机是很有用的,这样客户机就可以使用它来使用数据类型实现更高级别的原子操作。
▪ 因此,解决findReplace问题的一种方法是记录客户机可以使用EditBuffer的锁来相互同步:在这里插入图片描述
以确保所有三个方法都在不受其他线程干扰的情况下执行。
▪ 这样做的效果是将监视器模式已经放在各个toString、delete和insert方法周围的同步区域放大到一个原子区域中,从而确保所有三个方法都在不受其他线程干扰的情况下执行。

4 死锁
▪ 如果使用得当和小心,锁可以防止竞争。
▪ 但另一个问题却暴露了出来。
▪ 因为使用锁需要线程等待(当另一个线程持有锁时获取块),所以有可能进入两个线程相互等待的情况,因此两个线程都无法取得进展。
▪ deadlock描述两个或多个线程永远被阻塞、相互等待的情况。
▪ 当并发模块被困在等待对方做某事时,就会发生死锁。
▪ 死锁可能涉及两个以上的模块:死锁的信号特征是一个依赖的循环,例如A在等待B,B在等待C,C在等待A。没有一个模块能够取得进展。
在这里插入图片描述
▪ 死锁:线程A获取harry的锁(因为friend方法是同步的)。
–然后线程B获取snape上的锁(出于相同的原因)。
–他们都独立地更新各自的rep,然后尝试在另一个对象上调用friend()-这需要他们获取另一个对象上的锁。
▪ 所以A持有哈利在等斯内普,B持有斯内普在等哈利。–两个线程都卡在friend()中,因此两个线程都无法退出同步区域并将锁释放到另一个线程。
问题的本质是获取多个锁,并在等待另一个锁释放的同时保留一些锁。
死锁解决方案1:锁排序
▪ 对需要同时获取的锁进行排序,并确保所有代码都按该顺序获取锁。
–在本例中,我们可能总是按向导名称的字母顺序获取向导对象上的锁。
在这里插入图片描述
▪ 尽管锁排序很有用(特别是在操作系统内核之类的代码中),但它在实践中有许多缺点。
▪ 首先,它不是模块化的——代码必须知道系统中的所有锁,或者至少在它的子系统中。
▪ 其次,在获得第一个锁之前,代码可能很难或不可能确切地知道它需要哪些锁。它可能需要做一些计算来解决这个问题。
——比如,考虑对社交网络图进行深度优先搜索—在开始查找节点之前,如何知道哪些节点需要锁定?

死锁解决方案2:粗粒度锁定
▪ 比锁排序更常见的方法是使用更粗糙的锁
-使用单个锁来保护多个对象实例,甚至程序的整个子系统。
–例如,对于整个社交网络,我们可能有一个锁,并让其任何组成部分上的所有操作在该锁上同步。在下面的代码中,所有的巫师都属于一个城堡,我们只是使用这个城堡对象的锁来同步。
在这里插入图片描述
▪ 但是,它有很大的性能损失。
–如果你用一个锁保护一大堆可变数据,那么你就放弃了同时访问这些数据的能力…在最坏的情况下,用一个锁保护一切,你的程序可能本质上是顺序的。

5 wait(), notify(), and notifyAll()
▪ 保护块:这种块首先轮询一个必须为真的条件,然后才能继续。
▪ 例如,假设guardedJoy是一个方法,在另一个线程设置共享变量joy之前,该方法不能继续。
–这样的方法可以简单地循环,直到满足条件,但是循环是浪费的,因为它在等待的时候会连续执行
在这里插入图片描述
▪ 以下是为任意Java对象o定义的:
–o.wait():释放o上的锁,输入o的等待队列并等待
–o.notify():唤醒o的等待队列中的一个线程
–o.notifyAll():唤醒o的等待队列中的所有线程
▪ 注意:wait()、notify()和notifyAll()属于java.lang.Object对象

▪ Object.wait()导致当前线程等待,直到另一个线程为此对象调用notify()方法或notifyAll()方法。
▪ 换句话说,这个方法的行为就像它只是执行调用wait(0)一样。
在这里插入图片描述
▪ 对象.notify()唤醒等待此对象监视器的单个线程。如果有任何线程正在等待这个对象,则选择其中一个线程被唤醒。
–线程通过调用其中一个等待方法来等待对象的监视器。
–在当前线程放弃此对象上的锁之前,唤醒的线程将无法继续。–唤醒线程将以通常的方式与任何其他线程竞争,这些线程可能正在积极竞争以在此对象上进行同步;例如,唤醒线程在成为下一个锁定此对象的线程时没有可靠的特权或劣势。

– 被唤醒的线程不会马上执行,需要等待当前获得锁的线程释放锁,同时还 需要同其他等待锁的线程进行公平竞争。
▪ 此方法只能由作为此对象监视器所有者的线程调用。
▪ 线程通过以下三种方式之一成为对象监视器的所有者:通过执行该对象的同步实例方法。
–通过在对象上执行同步语句的主体。
–对于类类型的对象,执行该类的同步静态方法。

在保护块中使用wait()
▪ 在另一个线程发出某个特殊事件可能已发生的通知(尽管不一定是该线程正在等待的事件)之前,不会返回wait()的调用。
▪ 这个 Object.wait()导致当前线程等待,直到另一个线程为此对象调用notify()方法或notifyAll()方法。
在这里插入图片描述
▪ 调用wait()时,线程释放锁并暂停执行。
▪ 在将来的某个时候,另一个线程将获得相同的锁并调用对象.notifyAll,通知等待该锁的所有线程发生了重要事件:
在这里插入图片描述
▪ 在第二个线程释放锁后的一段时间内,第一个线程重新获取锁并通过调用wait返回来恢复。
当wait()被调用时,线程释放锁并暂停执行
在这里插入图片描述
▪ 在将来的某个时候,另一个线程将获得相同的锁并调用Object.notifyAll,通知等待该锁的所有线程发生了重要事件:

在这里插入图片描述
只有获得了对象锁所有权的线程可以调用这 些方法,否则会抛出异常

在这里插入图片描述
▪ 在实际的程序中通常遵循什么策略?
–搜索通常使用不可变的数据类型。使多线程变得容易,因为涉及的所有数据类型都是不可变的。
–操作系统通常使用细粒度锁以获得高性能,并使用锁排序来处理死锁问题。
–数据库使用事务避免竞争条件,这与同步区域类似,因为它们的影响是原子的,但它们不必获取锁,尽管事务可能会失败,如果发现发生了竞争,则会回滚。数据库还可以管理锁,并自动处理锁顺序。将在数据库系统课程中介绍。

▪ 要生成一个安全、易于理解、随时可以更改的并发程序,需要仔细考虑。
–heisenbug会在您试图锁定它们时迅速消失,因此调试并不是获得正确线程安全代码的有效方法。
–线程可以以许多不同的方式交错它们的操作,以至于您永远无法测试所有可能执行中的一小部分。
▪ 对数据类型进行线程安全参数设置,并在代码中记录它们。 ▪ 获取一个锁允许一个线程以独占方式访问由该锁保护的数据,迫使其他线程阻塞——只要这些线程也试图获取相同的锁。
▪ 监视器模式用每个方法获取的单个锁保护数据类型的代表。
▪ 由于获取多个锁而导致的阻塞可能会造成死锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值