《Java并发编程的艺术》读书笔记:一、并发编程的目的与挑战
发现自己有很多读书笔记了,但是一直都是自己闷头背,没有输出,突然想起还有博客圆这么个好平台给我留着位置,可不能荒废了。
此文读的书是《Jvava并发编程的艺术》,方腾飞等著,非常经典的一本书,本笔记是一边复习笔记,一边对照书本上的勾画重点,互相补充得来。
一、并发编程的目的与挑战
并发编程的目的只有一个:更充分的利用好计算资源,让程序运行的更快更有效率
请把这个目的牢记在心,因为,并不是启动更多的线程就能让程序最大限度的并发执行的。为了实现并发编程,我们还要面对三大问题:
- 原子性问题
- 原子指代的是不可再分,原子性指的是一个或多个操作要么全部执行成功要么全部执行失败,期间不能被中断,也不存在上下文切换
- 在多线程环境下,我们的操作再没有额外加上并发限制的前提下几乎都是非原子的,比如一个最简单的
a++;
都需要先读取,再加一,最后写回三步完成,这期间如果其他线程也对该变量进行了操作,就很容易引发原子性问题
- 有序性问题
- 也就是我们的代码是 (至少从结果上来说是) 按顺序执行的
- 由于Java编译器会为了性能优化,针对性的重排序指令的顺序,因而在我们使用多线程运行时,可能会发生某些指令错位的现象。比如我们希望先执行
a++;
再执行b = true;
,然后让另一个线程根据b == true;
来判断操作是否完成,就可能因为指令重排序而产生有序性问题
- 可见性问题
- 任何线程对于共享变量的改动,都应当是对所有其他线程可见的
- 由于CPU运行线程时修改的是高速缓存中的值,而高速缓存是CPU独占的,不为其他线程共享,所以在该值没有写回时,就可能引发可见性问题
除此以外,我们还必须保证程序是真的有变得更快而不是因此起了反作用,所以,利用并发编程来提升程序效率还面临着以下几项挑战:
- 上下文切换
- 死锁
- 硬件与软件资源限制
1、上下文切换
什么是上下文切换?考虑一个CPU流水线式处理多个进程的情况,我们知道。CPU通过轮流分配给各个进程以一定的时间片来实现并发执行,那么当CPU在作出时间片的切换时,必须要保存上一个任务的状态,以便后续接着上次的位置进行处理,随后再将下一个任务之前运行到的状态(如果有的话)读取加载进入CPU,这就是上下文切换,很明显,这样的切换是会影响效率的。
为什么这样的切换影响效率?
因为CPU是整个计算机结构中处理速度最快的结构,在整个计算机组成结构中,距离CPU越近的存储器就越是寸土寸金,自然不能大量留给那些已经被换下CPU的进程。
通常来说,换下CPU的进程的上下文会作为匿名页保存在内存当中,而内存与距离CPU最近的高速缓存有100倍的访问速度差距,更不用提寄存器了,所以频繁的进行上下文切换必然是会带来性能损失的
考虑到CPU的时间片往往持续非常短,一秒钟就可能需要上千次上下文切换,如何避免它就成为了我们提高并发效率的核心。
减少上下文切换,比较常用的办法有:
- 无锁并发编程
- CAS算法
- 使用最少线程编程
- 使用协程
下面具体来看。
无锁并发编程
由于多线程竞争锁时,会引起上下文切换,所以多线程处理数据时可以尽量避免锁。比如可以根据数据ID取模来将数据分段,然后不同线程处理不同段的数据
CAS算法
Java的Atomic包就是使用CAS算法来更新数据的,这种办法不需要加锁。
CAS (Compare And Swap 比较交换算法),其实现方式是基于硬件平台的汇编指令,在intel的CPU中,使用的是cmpxchg
指令,也就是说CAS是靠硬件实现的,从而在硬件层面提升效率。这里还需要引入两个概念:
- 乐观锁,总是认为当前是线程安全的,不怕别的线程修改变量,所以并不真正上锁。只是在每次读写时判断一下临界区是否已经被其他线程修改,如果修改了,就(通常是通过循环)再重新读取被修改后的数据再处理。CAS算法就属于这类
- 悲观锁:总是认为当前是线程不安全的,不管什么情况都进行加锁,任何线程再读写时若获取锁失败,则阻塞。许多经典的锁机制如
synchronized
关键字都属于这类方法。
既然CAS算法是乐观锁的一种,那么也就是说,CAS并不会真的在并发时给临界区加锁,那么如何保证不会出现错误呢?下面来看CAS算法的原理,如图所示,当线程开启时,会从主存中给每个线程拷贝一个变量副本到线程各自的运行环境中,CAS算法中包含三个参数(V,E,N),V表示主存中该变量的值、E表示之前拷贝到的预期值、N表示此次更新后的新值。
那么现在有两个线程t1,t2。他们各自的运行环境中都有共享变量的副本V1、V2,预期值E1、E2,预期主存中的值还没有被改变,假设现在在并发环境,t1先拿到了执行权限,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试(只要还处于时间片中),然后t1比较预期值E1和主存中的V,发现E1=V,说明预期值是正确的,执行N1=V1+1,并将N1的值传入主存。这时候贮存中的V=21,然后t2又紧接着拿到了执行权,比较E2和主存V的值,由于V已经被t1改为21,所以E2!=V,t2线程将主存中已经改变的值更新到自己的副本中,再发起重试;直到预期值等于主存中的值,说明没有别的线程对旧值进行修改,则继续执行代码。
CAS算法很重要,它是Java并发编程里很多技术的基石。
使用最少线程
简单直白,我们避免创建不必要的线程,从而避免出现大量线程等待的情况。
协程
在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
2、死锁
显然在多线程编程中,锁是一种非常有用的工具,其运用场景极多,因为它使用简单且易于理解。不过,一旦滥用锁就会大大折损效率,更可怕的是有可能会造成死锁。
死锁要发生需要满足四个条件,缺一不可:
- 互斥条件
- 即目标资源是不可共享的,多个进程不能同时持有该资源
- 持有并等待条件
- 一旦某个进程持有该资源,则在该进程运行结束前,不会主动释放资源
- 不可夺占条件
- 其他进程不能抢夺已经被某个进程所持有的资源
- 循环等待条件
- 进程之间获取这些互斥资源的顺序形成了环形链
死锁本身很简单,就是因为某些原因导致两把锁或者更多锁的持有进程在相互等待,永无止境。解决死锁不难,只要破坏死锁四大条件中的任意一条即可。
一般而言我们有以下方法:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,即
lock.tryLock(timeout)
替代内部锁机制 - 对于数据库锁,加锁与解锁必须在一个数据库链接里,否则可能出现解锁失败的情况
- 保证需要请求互斥资源的进程都以完全相同的顺序去请求资源
3、资源限制
资源限制表示由于硬件或软件的硬性指标不能支持过高并发而引起的性能限制。最简单的例子就是,如果网络就2Mb/s,而某个资源的下载速度是1Mb/s,你即便启动10个线程来进行下载,也不可能达到10Mb/s。而对于并发程序来说,网络带宽,硬盘读写速度,CPU,GPU处理速度,软件资源限制以及socket连接数等等都可能成为这个障碍。
而要解决这个问题,我们能做的只有开源与节流
- 开源:尝试获取更多的资源,一台计算机无法处理,可以通过Hadoop等平台搭建一个计算机集群,通过大量计算机一同处理。其他资源同理
- 节流:根据我们所持有的资源量,设定一个合理的并发度,控制线程数量在一个合适的范围中