Java并发编程实战 笔记

第一部分 并发理论基础

01 可见性、原子性和有序性

举几个例子先。

  1. 缓存可能导致可见性问题,因为多核CPU上的多个核可能都持有同一数据的不同缓存。两个线程并行地对一个字段进行累加,结果介于一倍与两倍之间。关键字volatile就是为了这样的需求,它提示底层去掉缓存这样的优化,从而使得任何写入,对于之后的读取都是可见的。
  2. 线程切换带来原子性问题,因为切换去切换来的中间可能会修改本线程也关注的数据,好比git维护着的多个版本之间merge的时候会因为改动了同一个数据而报冲突,以前或许是改同一个文件就冲突,现在更聪明了,修改同一行才会冲突;类似SQL实现中的表级锁与行锁。
  3. 再举一个原子性(也是有序性)的问题 —— 一行命令可能对应于多条底层命令,比如,原本C语言的内存申请和初始化是分开的,但面向对象模型引入后,new一个对象不但申请了对象需要的内存空间大小,还执行了对象的初始化,不了解这个过程可能误觉这是原子的,但其实,可能在这中间有其他线程访问了这个对象,发现它是非空的,并访问了这个未初始化的对象。而这个对象可以被其他线程访问,是由于可能存在指令重排,导致对象在初始化之前被返回;而另一个线程在临界区之外判空并返回该未初始化对象,这就导致本来想提高效率的双重判空检查反而使得临界区失效了,自己这边在临界区内还在准备要初始化其成员,另一个线程已经拿到了这个对象。

02 内存模型

  1. 单线程内的命令是顺序性的和传递性的,如果再引入volatile,那么会使得volatile变量赋值紧挨着的之前的非volatile变量赋值也具有volatile的属性。与锁相比,volatile不具有原子性,但也不会像锁那样会造成线程等待。
  2. 类似地,子线程的异步与转同步也具有这种传递性,即,线程的start能看到其调用点之前到结果,join的结果则为调用点之后可见。

03 加锁与临界区

  1. 加锁是一个很好的解决并发冲突的办法。但是,加锁的范围大了,影响效率;小了,临界区就失效了,就像双重判空检查那样的。
  2. 通常,用一把锁来锁相关联的多个资源(在面向对象编程语言中,资源就是对象,尤其是作为字段的被管理的对象);但如果多个资源并非一定要关联在一起,那么,为了提高效率,可以给每一个资源加一个各自的锁。就像数据库的表级锁和行级锁。但是,在采取后者的情况下,又得对偶尔的关联情况做预防,好比,一个系统为每一个用户建立了各自的账本,如果没有转账行为,或者没有并发的转账行为,那么是相安无事的;否则,情况就是可能死锁。

04 死锁

死锁是无法终结的等待。

  1. 占用且等待:细粒度地为每个资源都提供一个锁就会导致该问题,破坏并发的一个方式就是重新回到一把锁来管理多个资源的情况,唯一的问题是将两个已经分开的锁合并为一个,就需要轮询直到两个资源都获取到手,合并的粒度可以是原来那个全局唯一的锁;但是,也可以让多个局部资源由一个局部性的单例来管理,局部性越强,效率也就越好,当然,跨域资源的管理依然得需要更广级别的锁。
  2. 不可抢占:Java语言层面的锁synchronized就是不可抢占的;而标准库中有可中断的锁实现版本。
  3. 循环等待:可以规定,按照资源的序号,只能用一种顺序来加锁,这样,等待就变成了抢锁。因为有序的多资源就是一个资源,在我们其实不关心获取资源的先后顺序的情况下,它实现了最恰当好处的粒度;当然,我们也需要在排序消耗与轮询等待消耗之间做选择。

插曲

有一个关于请求的说法是 Tell, don't ask,主张这一原则者不无嘲讽地说,一个对象应该命令其他对象该做什么,而不是去查询其它对象的状态来决定做什么,查询其它对象的状态来决定做什么也被称作 “功能嫉妒”;一个对象应该只跟它的直接朋友通话,不要跟陌生人说话。
然而,根据现代经济学的基本假设,资源是稀缺的,那么,资源索取者必然无不陷入功能嫉妒之中;而嫉妒与贪婪、浪费、暴力相生相伴。
于此绝望命运之中,放弃一些一时无法获取到的欲求也是可选的;于公于私。颠倒主体和客体,资源也可不必浪费自己在等待被合理地取用之上。

05 等待通知

  1. 并发因为大量的等待(阻塞)而陷入困境,因此,才有了等待通知。等待通知就是减少了资源等待被用的情况,因为干等的情况下已经被占用的资源继续被占着实属浪费且会死锁,而在等待通知的情况下,先前占有的锁就先被 释放 了,而资源访问者虽然依然要等待,但它将知道自己不会造成资源被锁死,而一旦有资源可用自己就能得到通知继而再次尝试了。这种对资源的释放,在线程选择暂时先yield自己的运行权限中也是类似的。
  2. 通用的编程模版是:循环判断条件不满足就弃锁等待,满足就算进去了真正的临界区。等待条件的是线程,被通知的也是线程。资源维护方亦需要维护被迫等待资源的使用方的列表,因为终归是要给这些线程使用的,不管是谁。只不过,当稀缺的资源可用的时候,使用者往往依然需要摇号。

06 线程

线程是Java的基石。综上,它有如下几种状态:

  1. 新建&运行态:运行态包括实际正在运行中的和随时可被调度的;P.S. C++线程对应的对象类没有新建状态,新建即运行态
  2. 阻塞:干等,blocked = be locked。语言内置的synchronized机制中被锁是无法被打断的,而标准库的锁支持可被中断
  3. 等待:等通知。同样地,标准库的条件变量才有可中断的能力
  4. 定时等待:可打断;interrupt与sleep互斥
  5. 终止

P.S 为什么判断死锁的工具叫jstack呢?因为等待的是线程,函数栈又一一对应地属于其线程。P.P.S. 循环等待的死锁也是容易被检测出来的,只要查看多线程是否互相持有他者所等待的锁即可。

07 常量、ThreadLocal与auto

  1. 常量是另一个可以解决并发问题的方式,因为它不存在写只存在读,读是一种远观,因为它不影响其他人,写是一种消耗,它可能会影响其他人使用,比如,可能会破坏其他人对两次读的结果应该一致的预想。而一个这样的资源,被所有各方共享,也同时不属于任何一方,它成为一种象征、标志或意识形态。除了开发者自定义final对象外,标准库中的Long类型缓存了-128到127之间的数字对象,以及String对象均因常量而保有了并发安全性。
  2. 另外一种解决资源问题的方式是,完全按照使用者的要求,但是随着使用者的存续来分配,即ThreadLocal;而更加奢侈的方式是,在使用者用到的时候临时创造资源,阅后即焚,即为auto变量,即函数栈上的局部数据,自然也不会存在并发问题。就像IP地址中的私网网段,在不同的局域网之间是永远也不存在冲突的。

第二部分 标准库

01 ReentrantLock

  1. 一些锁实现是可重入的,synchronized也是可重入的,但标准库并非再造synchronized已有的基本功能,而是在其上扩充,引入了可中断、可尝试获取(让资源访问者不必干等或等通知而有机会先去做其他任务,实现彻底的异步)和可定时等待获取,以及可配备多个Condition(等待队列)的能力。对于后者来说,逻辑上要比synchronized机制仅关联一个条件变量的限制要更自然,因为锁对应资源,而多个条件队列意味着或的关系,即任意满足一个条件者均有机会获取到资源;至于与的关系,用一个条件变量就好了。
  2. 与synchronized关键字就内生于内存模型定义之中不同的是,Lock的实现依赖的是其中的一个volatile state字段,以实现与synchronized一致的加解锁前后的可见性。
  3. Lock的一般用法是在try catch前加锁,在finally中解锁。题外话:try-with-resources优于手写try catch块的好处除了便利、可以同时列出多个资源外,它还会将被最后一次抛出的异常覆盖的/suppressed异常记录下来。

02 Semaphore

它仅有acquire和release(即down和up)接口。它是一个仅仅锁住了一个数字自增减的锁。所以,用来实现无差别地限定个数的资源池的话,它仅能锁住个数限定,在超标之前,存在多线程竞争访问资源池的情况,所以,还需要另外一把锁来锁住资源池的访问。可以将它视为一个不合理地将锁的粒度设得过小的例子。

插曲 AQS源码

AQS是JUC中的ReentrantLock和ReentrantReadWriteLock、Semaphore、CountDownLatch等共享锁与ThreadPoolExecutor中工作线程Worker(线程池是生产者消费者模式,用户是生产者,生产任务,而线程池是消费者,更具体来说,每一个线程下面可能排队等待很多任务待完成)共同的基石。它的核心是state字段以控制资源数量与可见性,而state的修改则是通过原子性的CAS指令实现,而它的等待队列仅做一端插入,因而也可以通过CAS指令实现原子性。有关ReentrantLock源码,可以看这篇文章 site,以了解AQS的基本逻辑。而有关ReentrantReadWriteLock中共享锁的源码原理,可以看这篇文章 site;可以看到,写锁同ReentrantLock是一致的,但读锁可以在别人读和自己写的时候获取。

03 读写锁与乐观读锁

读写锁的原理在上一段中的ReentrantReadWriteLock源码中可见。与读写锁不同的是,乐观读锁实际并不是锁,或者说StampedLock提供了ReentrantReadWriteLock的类似能力之外的另一种无锁读方法tryOptimisticRead,并在读后经validate方法判断读期间是否有被写入,如果有写入再升级为普通(悲观)读锁。实现机制的区别是,StampedLock是不可重入的。
此外,标准库的CopyOnWrite容器也是利用了读多写少的思想。

04 CountDownLatch与CyclicBarrier

二者封装了一个计数触发器,前者count down到0的时候signal等待的那个线程,而后者在则调用回调函数并重置计数器。需要注意的是,后者的回调是在主线程中直接run的,所以,异步机制依赖应用开发者自己提供。

05 FutureTask与异步任务

FutureTask是RunnableFuture的是实现体,后者是Runnable与Future的合体,Future是一个类似Optional的原子对象,而Runnable是任务实体。ThreadPoolExecutor的execute方法接受Runnable并丢给任务队列后便任由线程池取用了,执行情况是它是不管的,但submit接口接受任务后会返回一个Future对象,用以判断任务完成情况。而CompletableFuture则内涵一个Executor,并提供一种异步任务运行机制。与CompletableFuture仅支持线性的流式的任务处理流程,而CompletionService则提供了一种批量任务处理机制,类似CyClicBarrier。


第四部分 案例分析

令牌桶算法

令牌数有限,按时间间隔发放令牌,即定时循环将令牌桶数加一,每次获取可获取令牌的时间的时候,会刷新下次可获取令牌的时间,并将令牌桶数减一。

posted @ 2023-02-01 00:36  joel-q  阅读(163)  评论(1编辑  收藏  举报