软件构造课程随笔——7【并发编程】

一、什么是并发编程

并发
并发意味着多个运算同时发生
为什么要并发
处理器时钟速度不再增加
新一代芯片都会有更多的内核
为了让计算更快运行,我们必须将计算分解为并发模块
两种常见的并发模型
共享内存:并发程序通过读写内存中的共享对象交互
软件构造课程随笔——7【并发编程】
例如:两个处理器共享物理内存;两个程序共享文件;两个线程共享对象
消息传递:在消息传递模型中,并发模块通过通信信道相互发送消息进行交互。 模块发送消息,并将每个模块的传入消息排队等待处理。
软件构造课程随笔——7【并发编程】
例如:网络中两台计算机通信;web浏览器和web server;即时消息的客户端和服务器;通过管道连接两个程序的输入和输出

二、进程、线程、时间切片

进程和线程
并发模块本身主要分为两种类型:进程和线程
进程是正在运行程序的一个实例,拥有自己私有专用的内存空间
线程是正在运行程序的一个执行路径(一个进程可对应多个线程),线程有自己的堆栈和局部变量,但是多个线程共享内存空间

(1)进程
进程可抽象为虚拟计算机,拥有独立的执行环境和完整的资源
进程间通常不共享内存
进程不能访问其他进程的内存或对象
需要特殊机制才能实现进程间共享内存
进程通信采用的是消息传递方式(因采用标准I/O 流)
应用程序实际上可能是一组协作进程
为了实现进程间通信,大多数操作系统都支持”进程间通信(IPC)资源”,例如pipe和socket
Java虚拟机本身的大多数实现都是作为单个进程运行的

(2)线程
线程和多线程编程
线程可抽象为一个虚拟处理器,线程有时也称为轻量级进程
线程与进程中的其他线程共享相同的资源(内存,打开的文件等),即“线程存在于进程内”
进程内的线程共享内存
线程需要特殊处理才能实现消息传递和访问私有内存
为什么使用线程
解决阻塞活动时的性能
为了利用多核处理器,必须编写多线程代码
Java提供了并发编程的库函数
需要理解基本概念
线程安全
软件构造课程随笔——7【并发编程】
每个应用程序至少有一个线程
站在程序员角度,main线程是开始线程,可以通过它创建其他的线程
创建线程的方式
所有的线程都需要实现Runnable接口,并实现run()方法
方法1:创建Thread类的子类
调用Thread.start()
软件构造课程随笔——7【并发编程】
方法2:实现Runnable接口,作为参数传递给 new Thread(…)构造函数
软件构造课程随笔——7【并发编程】

惯用法:用一个匿名的Runnable启动一个线程,它避免了创建命名的类
软件构造课程随笔——7【并发编程】

三、交织和Race条件

时间切片
在某时刻,一个运行核心上只有一个线程可以运行
进程/线程等采用OS提供的时间片机制共享处理时间
当线程数多于处理器数量时,并发通过时间片来模拟,处理器切换处理不同的线程
时间切片的例子
软件构造课程随笔——7【并发编程】

时间片的使用是不可预知和非确定性的,这意味着线程可能随时暂停或恢复。
共享内存的例子
共享内存可能会导致微妙的错误,看下面这个例子:
软件构造课程随笔——7【并发编程】
可以看到,存取款都是根据当余额进行的,所以多个ATM同时操作时很容易出问题
交叉/交错
软件构造课程随笔——7【并发编程】

由于低层的指令间存在交叉,很容易出现错误的结果
竞争条件
竞争条件:程序的正确性(后置条件和不变性的满足)取决于并发计算A和B中事件的相对时间
在有些情况下会导致违反后置条件和不变性
对于这种情况,调整代码起不到什么作用。不能仅仅从Java代码中看出处理器将如何执行它,本质原因是它们不是原子操作
银行账户的这个例子中的竞争情况,可以用顺序操作在不同处理器上的不同交叉执行解释,但是事实上,当使用多个变量和多个处理器时,甚至不能指望这些变量的变化以相同的顺序出现
消息传递的例子
不仅自动取款机是处理模块,账户也是处理模块,模块间通过消息传递进行交互
消息接收方将收到的消息形成队列逐一处理(先进先出) ,消息发送者并不停下来等待结果而是继续执行(异步方式)
消息传递的方式并不能消除竞争条件的可能性
A和B首先查询余额,然后取款,避免透支
交叉的问题仍然会出现,但是这次交叉信息发生在银行账户,而不是A和B执行的指令
happens-before关系
前一个事件的结果可以被后续的事件获取(即使出于优化的目的,实际运行中并不是按照指定顺序执行)
在Java中,采用happened-before机制,保证了语句A对内存的写入对语句B是可见的,也就是在B开始读数据之前,A已经完成了数据的写入

四、线程安全

数据类型或静态方法在多线程中执行时,无论如何执行,不需调用者做额外的协作,仍然能够行为正确,则称为线程安全的
行为正确意味着满足规格说明和保持不变性
不能在前置条件中对调用者增加时间性要求
注意,迭代器并非线程安全的
线程安全的四种方式

(1)限制可变变量的共享

通过将数据限制在单个线程中,可以避免线程在可变数据上进行竞争
局部变量保存在 线程栈中,每个调用都有自己的变量副本
软件构造课程随笔——7【并发编程】

避免使用全局变量
全局静态变量不会自动受到线程访问限制
如果使用了全局静态变量,则应说明只允许一个线程使用它们
在多线程环境中,取消全局变量
一般来说,静态变量对于并行是非常危险的,它们可能隐藏在似乎没有副作用或改变的无害功能之后
例如:
当有多个线程同时执行cache.put()操作时,map可能会遭到破坏

(2)用不可变的共享变量

使用不可变的引用和数据类型
不可变解决了因为共享可变数据造成的竞争,并简单地通过使共享数据不可变来解决它
final变量不允许再赋值,所以声明为final的变量可以安全地从多个线程访问
因为这种安全性只适用于变量本身,仍然必须确保变量指向的对象是不可变的
不可变性的强定义
没有改变数据的操作
所有的字段均为private 和 final
没有表示泄露
表示中的任何可变对象都不能变化

(3)将共享数据封装在线程安全的数据类型中

将共享的可变数据存储在线程安全的数据类型中
Java类库中线程安全的数据类型,会显式地声明
因为线程安全的数据类型性能较差,所以Java对一些可变数据类型提供两种形式供选择:线程安全的和线程不安全的
举例:StringBuffer和StringBuilder
StringBuffer 和 StringBuilder都是可变数据类型,功能基本相同
StringBuffer是线程安全的,StringBuilder不是
StringBuilder性能更好,推荐在单线程程序中使用
线程安全的集合
Java提供了线程安全 的Collections类型版本,会确保方法是原子的
原子方法指的是动作的内部操作不会同其他操作交叉, 不会产生部分完成的情况
例如:private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());
线程安全包装类
包装的实现是将所有的实际工作委托给指定的容器,但在容器的基础上添加额外的功能
是装饰者模式的一个例子
软件构造课程随笔——7【并发编程】
注意:
不要绕开包装类,统一采用包装类的形式
确保抛弃对底层非线程安全容器类的引用,并只通过同步的包装类来访问它
新的HashMap只传递给synchronizedMap,并且永远不会存储在其他地方
因为底层的容器仍然是可变的,引用它的代码可以规避不变性,失去了包装的意义
迭代器不是线程安全的,解决方案将是在需要迭代collection 时获取它的锁
原子操作不足以完全防止竞争
例如检查列表是否至少有一个元 素,然后获取该元素,找到操作时某个线程可能已经将其移除了
Synchronied map 能够保证各操作是原子的,但是操作间的交叉仍然会破坏不变性

(4)使用同步机制来防止线程同时使用变量

同步和锁
并发模块彼此间采用同步的方式共享内存,以解决竞争带来的问题
锁是一种抽象,某时刻最多只允许一个线程拥有锁
获取锁的拥有权,如果锁被其他线程拥有,将进入阻塞状态,等待锁释放后,再同其他线程竞争获取锁的拥有权
锁机制可以确保锁的拥有者始终查看最新的数据,避免了 reordering问题
阻塞一般意味着线程等待(不再继续工作),直到一个事件发生(其他线程释放锁)
银行账户例子
通过加锁的方式解决此问题,在访问银行账号之前,ATM需要获取到银行账号锁的拥有权
软件构造课程随笔——7【并发编程】
同步块和方法
Java将锁定机制作为内置语言特性提供
每个类及其所有对象实例都有一个锁
Object类也有锁,经常用于显示地定义锁
软件构造课程随笔——7【并发编程】
同步语句/同步代码块
同步区域提供了互斥功能: 一次只能有一个线程处于由给定对象的锁保护的同步区域中,锁定时,遵循顺序执行模式
软件构造课程随笔——7【并发编程】
synchronized (obj) { … }
在线程t中,会阻止其他线程进入保护块中,直到语句块中代码执行完
锁只能确保与其他请求获取相同对象锁的线程互斥访问
错误: 拥有对象的锁会自动阻止其他线程访问同步区域
锁只能确保与其他请求获取相同对象锁的线程互斥访问,如果其他线程没有使用synchronized(obj)或者利用了不同object的锁,则同步会失效,需要仔细检查和设计同步块和同步方法
软件构造课程随笔——7【并发编程】
对比可以发现,第一个例子共用同一个lock,所以按顺序输出,先后进行,而第二个例子每个线程有自己的lock,会出现交替的现象
监控模式
使用this变量作为锁,保护所有的Rep,某时刻只能有一个线程在类的一个实例中
同步方法
当把synchronized关键词加在方法签名上时,相当于用synchronized(this)环绕了整个方法体
当线程调用同步方法时,它会自动获取该方法所在对象的内部锁,并在方法返回时释放它。即使返回是由未捕获的异常引起的,也会释放锁
同一对象上的同步方法的两次调用不会有交叉现象
当一个线程在执行一个对象的同步方法时,所有其他线程如果调用同一对象的同步方法块,则会挂起执行,直到第一线程针对此对象的操作完成
当一个同步方法退出时,它会自动建立一个与之后调用同一个对象的同步方法的happens-before关系,这保证对象状态的更改对所有线程都是可见的
happens-before关系
前一个事件的结果可以被后续的事件获取(即使出于优化的目的,实际运行中并不是按照指定顺序执行)
在Java中,采用happened-before机制,保证了语句A对内存的写入对语句B是可见的,也就是在B开始读数据之前,A已经完成了数据的写入
确保内存一致性

使用synchronized的坏处
同步开销大
需要时间申请锁,刷新缓存,同其他线程通讯
当不需要同步时,不要使用
将同步添加到每个方法意味着锁是对象本身,并且每个引用对象的客户端都会自动拥有锁的引用,导致他们可以随意获取和释放锁
线程安全机制因此是公开的,可能会受到客户的干扰
相比而言,使用某内部对象的锁,并使用synchronized语句块进行适当的获取和使用,更加合适
同步方法意味着正在获取一个锁,而不考虑它是哪个锁,或者它是否是保护你将要执行的共享数据访问的正确锁
上面的例子, findReplace是一个静态方法,声明为synchronized方法后,将使用其所在类的锁,将对所有对象生效。这样导致一次只有一个线程可以调用findReplace,即使操作不同的buffer也不行。性能损失严重!
所以应当适当缩小其限制范围,保证线程安全的同时减少性能的损耗
活跃度: 死锁, 饥饿和活锁
并发应用程序的及时执行能力被称为活跃度
死锁
由于使用锁需要线程等待(当另一个线程持有锁时),因此可能会陷入两个线程都在等待对方的情况 – 此时都无法继续
死锁描述了两个或更多线程 永远被阻塞的情况,都在等待对方
软件构造课程随笔——7【并发编程】
死锁可能涉及两个以上的模块:线程间的依赖关系环是出现死锁的信号
T1: synchronized(a){ synchronized(b){ … } }
T2: synchronized(b){ synchronized(a){ … } }
软件构造课程随笔——7【并发编程】
软件构造课程随笔——7【并发编程】

避免了死锁的发生
解决方法1:锁顺序
需要知道子系统/系统中所有的锁,无法模块化
需要知道需要用到哪些锁
解决方法2:粗粒度锁
粗粒度的锁,用单个锁来监控多个对象实例
对整个社交网络设置一个锁,并且对其任何组成部分的所有操作都在该锁上进行同步。 例如:所有的Wizards都属于一个Castle, 可使用castle实例的锁

饥饿
饥饿描述了线程无法获得对共享资源的访问,而无法取得进展的情况
当共享资源由“贪婪”线程导致长时间不 可用时,会发生这种情况
活锁
如果一个线程的行为也是对另一个线程的行为的响应,则可能导致活锁
与死锁一样,活锁线程无法取得进一步进展
线程并未被阻止 - 他们只是忙于响应对方恢复工作
相向而行,同时让路
线程调度的方法
sleep()
让当前线程暂停指定时间的执行,期间不参与CPU的调度,不释放所拥有的监视器资源(锁)
软件构造课程随笔——7【并发编程】