哈尔滨工业大学软件构造课程笔记第七章第三节

7.3锁与同步

1.同步

回忆
数据类型或函数的线程安全:从多个线程使用时行为正确,而不考虑这些线程是如何执行的,无需额外的协调。线程安全不应依赖于偶然
原理:并发程序的正确性不应该依赖于时间上的意外

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

锁与同步
很多时候,无法满足上述三个条件…➔要读写共享数据,且线程中的读写操作复杂
使代码对并发安全的第四个策略:
——同步:防止线程同时访问共享数据
程序员来负责多线程之间对mutable数据的共享操作,通过“同步”策略,避免多线程同时访问数据

锁是一种同步技术。
锁是一种抽象,一次最多允许一个线程拥有它。
持有锁是一个线程告诉其他线程的方式:“我正在改变这个东西,现在不要碰它。”
使用锁机制,获得对数据的独家mutation权,其他线程被阻塞,不得访问

使用锁告诉编译器和处理器您正在并发地使用共享内存,因此寄存器和缓存将被清除到共享存储中,确保锁的所有者总是在查看最新的数据。
阻塞通常意味着线程等待(不做进一步的工作)直到事件发生

2.锁


Lock是Java语言提供的内嵌机制
每个object都有相关联的lock
但是,您不能对Java的内部锁调用获取和释放。相反,你使用synchronized语句来获取语句块持续期间的锁:

synchronized (lock) {
      // thread blocks here until lock is free
    // now this thread has the lock
    balance = balance + 1;
    // exiting the block releases the lock
}

同步
java提供了两个基本的同步习惯用法:
同步语句/同步代码块,同步方法


这样的同步区域提供了互斥:一次只有一个线程可以在同步区域把守一个给定对象的锁。拥有锁的线程可独占式的执行该部分代码
换句话说,您回到了顺序编程的世界,一次只有一个线程在运行,至少相对于引用同一对象的其他同步区域而言是这样。

锁保护对数据的访问
Lock用来保护共享数据
如果对数据变量的所有访问都被同一个锁对象保护(被同步块包围),那么这些访问将被保证为原子性的——不受其他线程的干扰。
使用synchronized (obj) { … }获取与对象obj关联的锁
它阻止其他线程进入一个同步(obj)块,直到线程t完成它的同步块。
注意:要互斥,必须使用同一个lock进行保护
您可以在一个锁后面保护整个变量集合,但是所有模块必须就它们将获取和释放哪个锁达成一致
因为Java中的每个对象都有一个隐式关联的锁,所以您可能认为,拥有一个对象的锁会自动阻止其他线程访问该对象。事实并非如此。
锁只能确保与其他请求获取相同对象锁的线程互斥访问,如
果其他线程没有使用synchronized(obj)或者利用了不同的锁,则同
步会失效,需要仔细检查和设计同步块和同步方法

当你写一个类的方法时,最方便的锁是对象实例本身,也就是this。用ADT自己做lock

作为一种简单的方法,我们可以通过将对rep的所有访问包装在synchronized(this)中来保护类的整个rep。

监视器模式:监视器是其方法相互排斥的类,因此每次只能有一个线程在类的实例中。

监视器模式
每个接触到rep的方法都必须使用锁来保护
-即使是看起来非常小和琐碎的,比如length()和toString()。
读和写要受到同样的重视,避免返回处于部分修改状态的rep值

如果将关键字synchronized添加到方法签名,
Java的作用就像围绕方法主体编写synchronized(this)一样。一种简化的实现monitor
pattern的方法

同步:同步方法
当线程调用同步方法时,它会自动获取该方法所在对象的内部锁,并在方法返回时释放它。即使返回是由未捕获的异常引起的,也会释放锁。
同一对象上的同步方法的两次调用不会有交叉现象

当一个线程在执行一个对象的同步方法时,所有其他线程如果调用同一对象的同步方法块,则会挂起执行,直到第一个线程针对此对象的操作完成

当一个同步方法退出时,它会自动建立一个与之后调用同一个对象的同步方法的happens-before关系,这保证对象状态的更改对所有线程都是可见的。

happens-before关系
前一个事件的结果可以被后续的事件获取(即使出于优化的目的,实际运行中并不是按照指定顺序执行)
在Java中,采用happened-before机制,保证了语句A对内存的写入对语句B是可见的,也就是在B开始读数据之前,A已经完成了数据的写入
这是为了确保内存一致性。

happens-before关系只是保证由一个特定语句写入的多个线程共享的对象对读取同一对象的另一个特定语句可见。
哈尔滨工业大学软件构造课程笔记第七章第三节_第1张图片
由于静态方法与类关联,而不是对象,此时线程获取与该类关联的Class对象的内部锁

对类的静态字段的访问由与该类的任何实例的锁截然不同的锁来控制

同步语句/块vs方法
– 前者需要显式的给出lock,且
不一定非要是this
– 前者可提供更细粒度的并发控制

到处用同步?
同步机制给性能带来极大影响
除非必要,否则不要用。Java中很多mutable的类型都不是threadsafe就是这个原因

尽可能减小lock的范围

避免在方法spec中加synchronized,而是在方法代码内部更加精细的区分哪些代码行可能有threadsafe风险,为其加锁

要先去思考清楚到底lock谁,然后再synchronized(…)

假设我们试图解决findReplace的同步问题,简单地把synchronized放到它的声明上:
它确实会获取一个锁——因为findReplace是一个静态方法,所以它会为findReplace所在的整个类获取一个静态锁,而不是一个实例对象锁。意味着在类层面上锁!
因此,一次只能有一个线程调用findReplace——即使其他线程想要操作不同的缓冲区(这应该是安全的),它们仍然会被阻塞,直到单个锁被释放。因此,我们将在性能上遭受重大损失。对性能带来极大损耗!

▪ Synchronized不是灵丹妙药,你的程序需要严格遵守设计原则,先尝
试其他办法,实在做不到再考虑lock。 ▪ 所有关于threadsafe的设计决策也都要在ADT中记录下来

同步的线程安全策略
请注意,类的封装,即不公开rep,对于提出这个论点非常重要。
如果文本是公共的,那么SimpleBuffer之外的客户端就可以读写它,而不需要知道他们应该首先获取锁,并且SimpleBuffer将不再是线程安全的。
哈尔滨工业大学软件构造课程笔记第七章第三节_第2张图片
锁规则
锁规则是一种确保同步代码是线程安全的策略。
必须满足两个条件:
任何共享的mutable变量/对象必须被lock所保护
涉及到多个mutable变量的时候,它们必须被同一个lock所保护

在monitor pattern中,ADT所有方法都被同一个synchronized(this)所保护
Java线程的状态模型:锁定哈尔滨工业大学软件构造课程笔记第七章第三节_第3张图片

3.原子操作

有时候让客户端使用你的数据类型锁是有用的,这样他们就可以使用你的数据类型来实现更高层次的原子操作

4.死锁

▪如果使用正确和小心,锁可以防止竞争条件。
▪但是,另一个问题又出现了。
▪因为锁的使用需要线程等待(当另一个线程持有锁时获取块),所以可能会出现两个线程互相等待的情况——因此两个线程都不能取得进展。
▪死锁描述了两个或多个线程被永远阻塞,互相等待的情况。
▪死锁:多个线程竞争锁,相互等待对方释放锁

死锁发生在并发模块被卡住,等待彼此做某事时。

僵局出现了
问题的本质是获取多个锁,并在等待另一个锁释放的同时持有其中的一些锁。

死锁解决方案1:锁排序
对需要同时获得的锁进行排序,并确保所有代码按照该顺序获得锁

尽管锁顺序很有用(特别是在操作系统内核之类的代码中),但在实践中它有许多缺点。
.需要知道子系统/系统中所有的锁,无法模块化,紧耦合
有时需要经过计算后才能知道需要用到哪些锁

死锁解决方案2:粗粒度的锁
粗粒度的锁,用单个锁来同步多个对象实例/子系统

但是,它会带来很大的性能损失。
如果用一个锁保护大量的可变数据,那么就放弃了同时访问这些数据的能力。
在最糟糕的情况下,程序可能基本上是顺序执行的,丧失了并发性。

5.wait(), notify(), 和notifyAll()

保护块
保护块:这样的块首先轮询一个条件,该条件必须为真才能继续执行。
某些条件未得到满足,所以一直在空循环检测,直到条件被满足。这是极大浪费。

以下是为任意Java对象o定义的:
o.wait():释放拥有对象o锁的线程的所有权,使线程进入等待队列中
o.notify():唤醒对象o锁的等待队列中的某个线程
o.notifyAll():唤醒对象o锁的等待队列中的所有线程

注意:wait()、notify()和notifyAll()属于java.lang.Object

Object.wait()
wait()导致当前线程等待,直到另一个线程调用此对象的notify()方法或notifyAll()方法。换句话说,这个方法的行为就像它只是简单地执行调用wait(0)一样。

synchronized (obj) {
     
while (<condition does not hold>)
  obj.wait();
  ... // Perform action appropriate to condition
}

Object.notify() /notifyAll()
notify()唤醒一个等待该对象监视器的线程。如果有任何线程在等待该对象,则选择其中一个被唤醒。
被唤醒的线程不会马上执行,需要等待当前获得锁的线程释放锁,同时还需要同其他等待锁的线程进行公平竞争。

此方法只能由拥有对象锁的线程调用
– 线程拥有锁的三种形式:同步方法、同步语句块、静态同步方法

在受保护块中使用wait()
▪wait()的调用不会返回,直到另一个线程发出了一些特殊事件可能已经发生的通知-虽然不一定是这个线程正在等待的事件。
▪object. wait()导致当前线程等待,直到另一个线程调用该对象的notify()方法或notifyAll()方法。
利用wait和notify方法实现的guarded blocks,采用了条件满足时的通告方式,避免了轮询方法浪费资源的缺点
当调用wait()时,线程释放锁并挂起执行。
▪在未来的某个时间,另一个线程将获得相同的锁并调用Object.notifyAll(),通知所有等待该锁的线程有重要的事情发生:
被唤醒的资源会重新参与到锁的竞争中,获取后,从wait的位置继续执行

只有获得了对象锁所有权的线程可以调用这些方法,否则会抛出异常

线程的状态模型:锁定和等待
哈尔滨工业大学软件构造课程笔记第七章第三节_第4张图片

6.总结

并行程序设计的目标
并行程序安全吗
?▪我们关注三种属性:
安全。并发程序满足它的不变量和它的规范吗?访问可变数据的竞赛威胁到安全。安全问题:你能证明一些坏事从来没有发生过吗? 安全故障:错误的计算
活性。程序会继续运行并最终做你想做的事情吗?或者它会在某个地方永远等待永远不会发生的事件吗?你能证明一些好事最终会发生吗?死锁威胁活性 活性失败:根本没有计算
公平。并发模块被赋予处理能力来进行它们的计算。公平性主要是操作系统的线程调度程序的问题,但是您可以通过设置线程优先级来影响公平性

并发性在实践中
在实际的项目中通常采用什么策略?
-库数据结构要么不使用同步(为单线程客户端提供高性能,而让多线程客户端在上面添加锁定),要么使用监控模式。-具有多个部分的可变数据结构通常使用粗粒度锁定或线程限制。大多数图形用户界面工具包都遵循这些方法中的一种,因为图形用户界面基本上是一个由可变对象组成的大的可变树。图形用户界面工具包Java Swing使用线程限制。只允许一个专用线程访问Swing的树。其他线程必须向该专用线程传递消息,以便访问树。

安全故障提供了一种错误的安全感。
活性失败迫使你直面bug。
喜欢活泼胜过安全的诱惑。

在实际的项目中通常采用什么策略?
-搜索通常使用不可变的数据类型。它很容易实现多线程,因为所涉及的所有数据类型都是不可变的。没有比赛或死锁的风险。
-操作系统经常使用细粒度的锁来获得高性能,并使用锁顺序来处理死锁问题。
数据库使用事务来避免竞争条件,事务类似于同步区域,因为它们的影响是原子的,但是它们不必获取锁,尽管事务可能会失败并在发生竞争时回滚。数据库还可以管理锁,并自动处理锁的顺序

总结
▪生产一个并行程序,是安全的bug,容易理解,并准备改变需要仔细思考。

  • Heisenbugs会在你试图控制它们的时候迅速消失,所以调试并不是实现正确线程安全代码的有效方法。
    -线程可以以多种不同的方式交错执行它们的操作,以至于你永远都无法测试所有可能执行的一小部分。
    ▪关于数据类型的线程安全参数,并在代码中记录它们。
    ▪获得锁允许一个线程独占访问该锁保护的数据,迫使其他线程阻塞-只要这些线程也试图获得相同的锁。
    ▪监控模式通过每个方法获得的单个锁来保护数据类型的代表。
    ▪获得多个锁造成阻塞造成死锁的可能性

你可能感兴趣的:(学习笔记,软件构造,哈工大,多线程,java)