并发与分布式编程(Concurrent and Distributed Programming)
1.并发(Concurrency)
注意并发并不等于平行(Parallel),下面的图片就形象的解释了这一概念:
在现代编程中,并发性是必不可少的:
- 多用户并发请求服务
- App在手机端与云端都有计算
- GUI的前端用户操作和后台的计算
并发编程主要有两种模式:
- 共享内存:在内存中读写共享数据。
eg:两个处理器共享内存、同一台机器上的两个程序共享文件系统、同一个java程序内的两个线程共享Java对象等。 - 消息传递:通过channel交换信息。
eg:网络上两台计算机通过网络连接通讯、浏览器与web服务器,A请求页面,B发送页面数据给A、即时通讯软件的客户端和服务器、同一台计算机上的两个程序,通过管道连接进行通讯。
2. 进程与线程(processes and Threads)
进程(process):
进程拥有整台计算机的资源。但是多进程之间不共享内存,进程之间需要通过消息传递进行协作!
一般来说,进程==程序==应用。不过并不绝对,有的应用可能包含多个进程。操作系统支持IPC机制(pipe/socket)支持进程间通信。(不仅是本机的多个进程之间通信,也可以是不同机器的多个进程之间)
JVM通常运行单一进程,但也可以创建新的进程。
线程(Thread):
如果把进程比作为虚拟机,那么线程就可以看做虚拟CPU。线程之间程序共享、资源共享,都隶属于进程。
不过虽然线程之间共享内存,不过很难获取线程的私有的内存空间(栈),所以可以通过创建消息队列在线程之间进行消息传递。
单一线程与多线程对比:
最上面一层为共享层:包括代码、数据和文件系统。然而对于寄存器和栈各线程之间并不共享!
每个应该至少有一个线程,不过线程可以创建其他线程。
创建线程的方法主要分为两种:
- 从Thread类派生子类(附带两种启动线程的方式):
- 从Runnable接口构造Thread对象:
其实都差不多,不过对于比较简单的线程我都喜欢这么写,开个匿名类:
public static void main(String[] args) { new Thread() { public void run() { //你的方法 } }.start(); }
另外……和之前说的那个cloneable接口一样,千万别把这个Runnable接口看的多么高大上,虽然不像前面那个那么萌,不过也就一个方法而已,下附JDK源码:
public interface Runnable { public abstract void run(); }
不过千万要记得启动线程不是要调用run方法!而是调用start方法!
3.交错与竞争(Interleaving and Race Condition)
时间分片:
虽然有多线程,但只有一个核,所以每个时刻只能执行一个线程。所以通过时间分片在多个进程/线程之间共享处理器。而且仅仅是多核CPU,进程/线程的数目也往往大于核的数目。
时间分片图解:
其实嘛……时间分片就是指执行线程的时间调度,就是叫的抽象了点,说白了就是我一次只能执行一个线程,现在有好多线程,所以需要一个方案,时间分片就是这个方案。这样讲会不会好理解一点呢?
值得说的是时间分片是由操作系统自动调度的。换句话说这不是你能决定的……sad
线程间的内存共享:
这里需要说的是因为线程间内存共享,但是这才是多线程编程的困难所在。
试想,内存中有一个变量x=5.对于线程A,他做的是把x乘2。对于线程B,他要做的是把x乘3。那么我们让两个线程都启动,会出现什么结果呢?当然x=30是我们最想看的。但是事实往往不是。
假设A先开始执行,他得到的值是5。然后此时B开始执行,这样B得到x的值也是5,然后A在执行将x*2在写入x。此时x=10。然后B接着执行,由于之前已经读取到x=5,所以他会在5的基础上*3写回,所以,此时x=15!
当然上面只是一种可能性,实际可能性还有很多,读取,执行,写入三个操作两个线程可以做任何不违反次序情况下的组合,那么结果……五花八门!(注意java中每条语句每一行都非原子的,是否原子由JVM确定。如果读者学过编译原理或者汇编语言等课程可能会了解到我们现在写的语句都会由编译器转为给CPU执行的汇编语句,汇编语句实现的运算规则非常简单,是CPU执行的原子语句【非流水线CPU,流水线CPU同时刻会执行多条汇编语句】,而汇编语句那个真的不是人写的!来读起来都非常困难,就像你知道每个英文字母但是根本不认识单词句子啊!所以不要期望写原子语句呀!诶?好像又扯多了……)
这就是多线程之间内存共享的隐患,也是最困难的地方。至于如何解决?我们会介绍几个基本知识后,告诉大家措施,请往下看。
而且多线程的程序很难测试和调试,由于时间分片由OS(操作系统)决定,改变源程序会导致OS判断不同从而时间分片也就改变了,况且每次运行哪怕相同代码OS决定的时间分片也是不一样的啊!有的时候甚至你在程序中加一个print输出语句bug就消失了。
扯淡的话:(网上很多说了神bug,什么去掉一行注释代码就崩了的,我猜是这个原因,不过太阳黑子爆发的导致硬件存储丢失的bug我是信的【不知道可以了解一下,很有趣OwO】,老板!你听我说!我这bug是由于太阳黑子爆发出来的宇宙射线辐射到内存单元上blablabla……不是我的锅!诶?老板!你别走!你相信我啊!【异常名称:ArrayIndexOutOfBoundsException】)
控制线程的一些操作:
先说一下,这仅仅是一些方法,而非上面问题的解决途径,完美的解决方法是线程锁,最重要的当然要放在最后了……
Thread.sleep()
线程休眠,参数是时间/ms,会将执行该操作的线程休眠,也就意味着其他线程得到更多的执行机会。不过进入休眠的线程并不会失去对线程锁或monitor的所有权。
Thread.interrupt()
向线程发出中断信号。
还有一个方法时,Thread.isInterrupted()检查线程是否被中断,使用方法如下:
//在其他线程向t线程发出中断信号 t.interrupt(); //检查线程是否中断 t.isInterrupted();
可能你会问为什么还要检查线程是否中断?原因当然是凭什么你这个线程这么大能耐,要别的线程中断他就中断呢?是的!其他线程收到中断信号不一定会中断!是否中断取决于其对Interrupt的Exception异常的处理啊……如果人家线程cache了这个异常return了,那么才会中断!
栗子:
虽说不友好,但是又有什么办法呢?
Thread.yield()
挂起,自动放弃对CPU的占用权。
使用该方法,线程告知调度器可以放弃对CPU的占用权,从而可能引起调度器唤醒其他线程。 一般的想法是:线程调度程序将选择一个不同的线程来运行,而不是当前的线程。不过这种应该尽量避免在代码中使用。
具体使用方法和sleep类似。
Thread.join()
让当前线程保持执行,直到执行结束。
一般情况下不需要这种显示指定线程执行次序。(因为这样和串行还有区别吗?!)
另外说一下,执行该操作的识货也会检测是否有其他线程发来的中断信号。使用方法和Thread.interrupt类似。
4. 线程安全(Thread Safety)
线程之间的“竞争条件”:作用于同一个mutable数据上的多个线程,彼此之间存在对该数据的访问竞争并导致interleaving (交错),导致前置条件可能会被违反,这是极其不安全的!
线程安全意味着要保证ADT或方法在多线程中要执行正确,主要需要做到以下几点:
- 不违反spec、保持RI(表示不变量)
- 与多少处理器、如何调度线程均无关
- 不需要在spec中强制要求客户端满足某种“线程安全”的义务
如何保证线程安全呢?主要分为四个方面:
- 限制数据共享
- 共享不可变数据
- 共享线程安全的可变数据
- 同步机制共享共享现成不安全的可变数据,对外即为线程安全的ADT。
(其实Iteractor再删除等操作也涉及到线程问题,具体可以看我很早之前写的一篇因为年少无知而在迭代器遍历过程中删除元素而导致异常的博客)
现在来介绍一下线程安全的四种策略:
策略1:限制约束(Confinement)
其核心思想是线程之间不共享mutable的数据类型,将可变数据限制在单一线程内部,避免竞争,且不允许任何线程直接读写该数据。
切记也要避免全局变量!
策略2:不变性(immutability)
其想法是使用不可变的数据类型和不可变的引用,避免多线程之间的竞争。因为不可变数据类型往往是线程安全的!
严格的不变性,主要靠以下几点:
- 没有变值器方法
- 所有的修饰符必须是private和final
- 没有表示外泄
- 在表示值没有任何可变对象的变值器-有益的也不可以
如果你做到这些那么你的程序就是线程安全的!然而很难!而且变值器不用真的能做的事太少!
策略3:用线程安全的数据类型
如果必须要mutable的数据类型在多线程之间共享数据,要使用线程安全的数据类型。(JDK文档中的类,明确的指明了哪些是线程安全的)
一般来说,JDK同时提供两个相同功能的类,一个是线程安全的,另一个不是。原因很简单:因为线程安全的类牺牲了部分性能。
记得集合类(List、Set、Map)都不是线程安全的。然而java也提供了线程安全的集合类。
注意:在使用synchronizedMap(hashMap)之后,不要再把参数hashMap共享给其他线程,不要保留别名,一定要彻底销毁!否则就不能保证咯。
不过即使是在线程安全的集合类上,Iteractor也依旧不安全。原因很简单,线程安全的集合类是其内部线程安全,而你要用Iteractor是在外部使用,完全不一样啊……
来几个例题(红色的是安全的哦):
策略4:线程锁与同步(Locks and Synchronization)
前三种策略核心思想:避免共享->即使共享,也只能读/不可写->即使可写,共享的可写数据应自己具备在多线程之间的协调能力,即“使用线程安全的mutable ADT”。
然而大多数请求下,上述三个条件是无法满足的。所以这个时候就可以用同步和锁。
程序员来负责多线程之间对mutable数据的共享操作,通过“同步”策略,避免多线程同时访问数据。
使用锁的机制,来获取对数据的独家修改权,其他线程被阻塞,不得访问。
使用锁告诉编译器和处理器,你正在同时使用共享内存,这样寄存器和缓存就会被刷新到共享存储器中,从而确保锁的所有者总是在查看最新的数据。
1. 同步代码块与方法
lock是java语言提供的内嵌机制,每个对象都有相关联的lock。锁可以保护共享数据,不过要注意,如果要互斥,必须使用同一个lock进行保护。
这里要提到一个Monitor模式,前面提到过一句,但并没解释:这种模式就是指ADT的所有方法都是互斥访问的。
当然也可以写在方法前,表示将整个方法上锁(注意不能加static,那是类锁,并非对象锁。而且构造方法是不可以的)
对同步的方法而言,多个线程执行他时不允许交错,也就是说是按原子的串行方式执行的。
下面再给大家看一种写法:
这种写法需要显式的给出lock,且不一定非要是this。而且提供了更细粒度的并发控制。
注意lock有以下两点原则:
- 任何共享的mutable变量/对象必须被lock所保护
- 涉及到多个mutable变量的时候,他们必须被同一个lock所保护
来到例题看看是否理解了?
正确答案是1,5哦……
2.原子数据访问与原子操作(Atomic data access and atomic operation)
可以采用volatile关键字来修饰变量,这样每次线程要用到该变量的时候都会重新加载,这样就保证了一致性。
不过由于有synchronized块的存在,我们可以让原子语句范围更大,将多个操作组合为一个更大的原则操作:
3.何处应该使用synchronized
java中的同步机制虽然带来了线程安全,但是也给性能带来了极大的影响,所以除非必要,否则不要用,java中很多mutable类都不是线程安全的原因就是这个。
而且我们应该尽可能的减少lock的范围。
synchronized不是灵丹妙药,程序应该严格遵从设计原则,先尝试其他方法,实在不行再考虑lock。
而且关于线程安全的设计决策都要在ADT中记录下来。
4.死锁、饥饿、活锁、LiveLock
死锁:多个线程竞争lock,相互等待对方释放lock
eg:
死锁的代价非常大,应该要严格避免!
解决方法:
- 对所有锁进行排序,确保所有线程都能按次序获取锁:
- 使用一个锁来保护许多对象,甚至是一个子系统
饥饿:因为其他线程lock时间太长,一个线程长时间无法获取其所需的资源访问权(lock)导致无法向下进行。
liveLock:课件上给的栗子不是很好,于是翻了翻网上的,发现都是一个例子呀,也不知道哪个是原创了,所以就直接复制下来了:
活锁可以认为是一种特殊的饥饿。 假设事务T2再不断的重复尝试获取锁R,那么这个就是活锁。 如果事务T1封锁了数据R,事务T2又请求封锁R,于是T2等待。T3也请求封锁R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统又批准了T4的请求......T2可能永远等待。 活锁应该是一系列进程在轮询地等待某个不可能为真的条件为真。活锁的时候进程是不会blocked,这会导致耗尽CPU资源。