Java并发编程
2021-08-03 10:06:35 1 举报
AI智能生成
Java并发编程
作者其他创作
大纲/内容
并发设计模式
Immutability模式
Copy-on-Write模式
线程本地存储模式
GuardedSuspension模式
Balking模式
Thread-Per-Message模式
WorkerThread模式
两阶段终止模式
生产者-消费者模式
并发案例分析
高性能限流器GuavaRateLimiter
高性能网络应用框架Netty
高性能队列Disruptor
高性能数据库连接池HiKariCP
其他并发模型
CSP模型:Golang的主力队员
协程:更轻量级的线程
软件事务内存:借鉴数据库的并发经验
Actor模型:面向对象原生的并发模型
并发理论知识
为什么出现并发?
计算机硬件的CPU、内存、I/O等功能是有限制的,我们需要使用复杂的代码去突破限制这就是并发了。
并发编程的掌握过程并不容易:<font color="#f44336">并发编程的第一原则,那就是不要写并发程序</font>
管程作为一种解决并发问题的模型:是继信号量模型之后的一项重大创新,它与信号量在逻辑上是等价的(可以用管程实现信号量,也可以用信号量实现管程),但是相比之下管程更易用
其实并发编程可以总结为三个核心问题:<b>分工、同步、互斥</b>
如何才能学好并发?
分工
所谓分工,类似于现实中一个组织完成一个项目,项目经理要拆分任务,安排合适的成员去完成。<br>
在并发编程领域,你就是项目经理,线程就是项目组成员。可以用华罗庚曾用"烧水泡茶"的例子来说明一下。
最简单的显示世界对比方式就是:生产者-消费者模式
同步
主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。
在Java并发编程领域,解决协作问题的核心技术是管程,上面提到的所有线程协作技术底层都是利用管程解决的
在现实中生产者-消费者模型里,也有类似的描述,"当队列满时,生产者线程等待,当队列不满时,生产者线程需要被唤醒执行;当队列空时,消费者线程等待,当队列不空时,消费者线程需要被唤醒执行。"
互斥
指的是同一时刻,只允许一个线程访问共享变量。
实现互斥的核心技术就是锁,Java语言里synchronized、SDK里的各种Lock都能解决互斥问题。
跳出来,看全景
把所学知识串联起来
并发编程
分工
Executor与线程池
Fork/Join
Futrue
Guarded Suspension模式
Balking模式
Thread-Per-Message模式
生产者-消费者模式
Worker Thread模式
两阶段终止模式
互斥
无锁
不变模式
线程本地存储
CAS
Copy-on-Write
原子类
互斥锁
synchronized
Lock
读写锁
协作
信号量(Semaphore)
管程(Monitor)
Lock$Condition
synchronized
countDownlatch
CyclicBarrier
Phaser
Exchanger
钻进去,看本质
知其然知其所以然,学习技术的理论
并发工具类
并发Bug的源头
CPU、内存、I/O设备问题<br>
性能差异
CPU是天上一天,内存是地上一年;
内存是天上一天,I/O设备是地上十年;
解决性能差异问题
CPU增加了缓存,以均衡与内存的速度差异;
操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
缓存导致的可见性问题
单核时代
单核时代所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。<br>因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。<br>
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
多核时代
多核时代,每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决了。<br>当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。<br>
线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存;<br>很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了<br>
线程切换带来的原子性问题
文件读取
在一个时间片内一个进程进行一个IO操作,进程会释放CPU的使用权;<br>待文件读进内存,操作系统会唤起进程获得CPU使用权。<br>
进程在等待IO时会释放CPU使用权是为了让CPU在等待时间里可以做别的事情,CPU的使用率就提高了
这时如果有另外一个进程也读文件,读文件的操作就会排队;<br>磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。<br>
线程切换
Java并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异Bug的源头之一;
任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成;
比如count+=1至少需要三条CPU指令<br>指令1:首先,需要把变量count从内存加载到CPU的寄存器;<br>指令2:之后,在寄存器中执行+1操作;<br>指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。<br>
我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。<br>CPU保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。<br>因此,很多时候我们需要在高级语言层面保证操作的原子性。<br>
编译优化带来的有序性问题
那并发编程里还有没有其他有违直觉容易导致诡异Bug的技术呢?有的,就是有序性。<br>顾名思义,有序性指的是程序按照代码的先后顺序执行。<br>
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”<br>在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。<br>
在获取实例getInstance()的方法中,我们首先判断instance是否为空,<br>如果为空,则锁定Singleton.class并再次检查instance是否为空,<br>如果还为空则创建Singleton的一个实例。<br>
假设有两个线程A、B同时调用getInstance()方法,他们会同时发现instance==null,于是同时对Singleton.class加锁,<br>此时JVM保证只有一个线程能够加锁成功(假设是线程A),另外一个线程则会处于等待状态(假设是线程B);<br>
线程A会创建一个Singleton实例,之后释放锁,锁释放后,线程B被唤醒,线程B再次尝试加锁,此时是可以加锁成功的,<br>加锁成功后,线程B检查instance==null时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。<br>
这看上去一切都很完美,无懈可击,但实际上这个getInstance()方法并不完美。问题出在哪里呢?<br>出在new操作上,我们以为的new操作应该是:<br><ul><li><span style="font-size: inherit;">分配一块内存M;</span></li><li>在内存M上初始化Singleton对象;</li><li>然后M的地址赋值给instance变量。</li></ul>但是实际上优化后的执行路径却是这样的:<br><ul><li>分配一块内存M;</li><li>将M的地址赋值给instance变量;</li><li>最后在内存M上初始化Singleton对象。</li></ul><br>
优化后会导致什么问题呢?<br>我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;<br>如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,<br>而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。<br>
Java内存模型
Java内存模型
你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化;<br>那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。<br>
合理的方案应该是按需禁用缓存以及编译优化。
那么,如何做到“按需禁用”呢?
对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。
所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法
三个关键字
volatile
synchronized
final
六项Happens-Before规则
使用volatile的困惑
volatile关键字并不是Java语言的特产,古老的C语言里也有,它最原始的意义就是禁用CPU缓存。
例如,我们声明一个volatile变量volatile int x=0,它表达的是:<br>告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。<br>这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。<br>
直觉上看,应该是42,那实际应该是多少呢?<br>这个要看Java的版本,如果在低于1.5版本上运行,x可能是42,也有可能是0;<br>如果在1.5以上的版本上运行,x就是等于42。<br>
分析一下,为什么1.5以前的版本会出现x=0的情况呢?<br>我相信你一定想到了,变量x可能被CPU缓存而导致可见性问题。<br>这个问题在1.5版本已经被圆满解决了。Java内存模型在1.5版本对volatile语义进行了增强。<br>怎么增强的呢?答案是一项Happens-Before规则。<br>
Happens-Before规则
如何理解Happens-Before呢?
如果望文生义(很多网文也都爱按字面意思翻译成“先行发生”),那就南辕北辙了;
Happens-Before并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:<br>前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到<br>
Happens-Before规则就是要保证线程之间的这种“心灵感应”。
比较正式的说法是:Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。
程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作。
程序前面对某个变量的修改一定是对后续操作可见的。
volatile变量规则
指对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。
这个就有点费解了,对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见,这怎么看都是禁用缓存。<br>貌似和1.5版本以前的语义没有变化啊?<br>如果单看这个规则,的确是这样,但是如果我们关联一下规则3.3,就有点不一样的感觉了。<br>
传递性
这条规则是指如果AHappens-BeforeB,且BHappens-BeforeC,那么AHappens-BeforeC。
“x=42”Happens-Before写变量“v=true”,这是规则3.1的内容;<br>写变量“v=true”Happens-Before读变量“v=true”,这是规则3.2的内容。
再根据这个传递性规则,我们得到结果:“x=42”Happens-Before读变量“v=true”。这意味着什么呢?<br>如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。<br>也就是说,线程B能看到“x==42”,有没有一种恍然大悟的感觉?<br>
这就是1.5版本对volatile语义的增强,这个增强意义重大。<br>1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的,这个在后面的内容中会详细介绍。<br>
管程中锁的规则
这条规则是指对一个锁的解锁Happens-Before于后续对这个锁的加锁。
要理解这个规则,就首先要了解“管程指的是什么”。
管程是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。
假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),<br>线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。完全符合我们直觉很容易理解<br>
线程start()
规则这条是关于线程启动的。它是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
换句话说就是,如果线程A调用线程B的start()方法(即在线程A中启动线程B),那么该start()操作Happens-Before于线程B中的任意操作。
线程join()规则
它是指主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),<br>当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。<br>当然所谓的“看到”,指的是对共享变量的操作。<br>
ThreadB=newThread(()->{<br> //此处对共享变量var修改<br> var=66;<br>});<br>//例如此处对共享变量修改,<br>//则这个修改结果对线程B可见<br>//主线程启动子线程<br>B.start();<br>B.join()<br>//子线程所有对共享变量的修改<br>//在主线程调用B.join()之后皆可见<br>//此例中,var==66
换句话说就是,如果在线程A中,调用线程B的join()并成功返回,那么线程B中的任意操作Happens-Before于该join()操作的返回
被我们忽视的final
前面都是volatile为的是禁用缓存以及编译优化,有没有办法告诉编译器优化得更好一点呢?这个可以有,就是final关键字。
final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。<br>Java编译器在1.5以前的版本的确优化得很努力,以至于都优化错了。<br>
利用双重检查方法创建单例,构造函数的错误重排导致线程可能看到final变量的值会变化。
当然了,在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将this赋值给了全局变量global.obj,<br>这就是“逸出”,线程通过global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。<br>
//以下代码来源于【参考1】<br>finalintx;<br>//错误的构造函数<br>publicFinalFieldExample(){<br> x=3;<br> y=4;<br> //此处就是讲this逸出,<br> global.obj=this;<br>}
互斥锁
解决原子性问题
如何解决原子问题
原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?<br>而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。<br>
单核CPU场景
同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,<br>也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,<br><u>所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。</u><br>
多核CPU场景
同一时刻有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,<br>此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行。<br>如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。<br>
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。<br>如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。<br>
简易锁模型
互斥执行的代码叫做临界区。<br>
线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;
否则呢就等待,直到持有锁的线程解锁;
持有锁的线程执行完临界区的代码后,执行解锁unlock()。
错误理解例子
我很长一段时间认为,这个过程非常像办公室里高峰期抢占坑位,<br>每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。<br>
这样理解本身没有问题,但却很容易让我们忽视两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?
改进后的锁模型
现实世界中
锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。
并发编程中
锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;<br>其次,我们要保护资源R就得为它创建一把锁LR;<br>最后,针对这把锁LR,我们还需在进出临界区时添上加锁操作和解锁操作。<br>
另外,在锁LR和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。<br>很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,<br>这样的Bug非常不好诊断,因为潜意识里我们认为已经正确加锁了。<br>
Java相关锁技术
锁是一种通用的技术方案,Java语言提供的synchronized关键字,就是锁的一种实现。
synchronized关键字可以用来修饰方法,也可以用来修饰代码块
这个和我们上面提到的模型有点对不上号啊,加锁lock()和解锁unlock()在哪里呢?
其实这两个操作都是有的,只是这两个操作是被Java默默加上的,<br>Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),<br>
这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的,<br>毕竟忘记解锁unlock()可是个致命的Bug(意味着其他线程只能死等下去了)。<br>
那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢?
上面的代码我们看到只有修饰代码块的时候,<br>锁定了一个obj对象,那修饰方法的时候锁定的是什么呢?<br>
这个也是Java的一条隐式规则:
当修饰静态方法的时候,锁定的是当前类的Class对象,在上面的例子中就是ClassX;
当修饰非静态方法的时候,锁定的是当前实例对象this。
举例说明:用synchronized解决count+=1
SafeCalc这个类有两个方法:一个是get()方法,用来获得value的值;<br>另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰。<br>那么我们使用的这两个方法有没有并发问题呢?<br>
我们先来看看addOne()方法,首先可以肯定,被synchronized修饰后,无论是单核CPU还是多核CPU,<br>只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题呢?<br>
管程中锁的规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁。
管程就是我们这里的synchronized,我们知道synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;<br>而所谓“对一个锁解锁Happens-Before后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,<br>综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),<br>对后续进入临界区(该操作在加锁之后)的线程是可见的。<br>
按照这个规则,如果多个线程同时执行addOne()方法,可见性是可以保证的,<br>也就说如果有1000个线程执行addOne()方法,最终结果一定是value的值增加了1000。<br>看到这个结果,我们长出一口气,问题终于解决了。<br>
但也许,你一不小心就忽视了get()方法。执行addOne()方法后,value的值对get()方法是可见的吗?<br>这个可见性是没法保证的。<br>管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。那如何解决呢?<br>很简单,就是get()方法也synchronized一下,详见代码<br>
上面的代码转换为我们提到的锁模型,就是下面图示。<br>get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。<br>线程要进入临界区get()和addOne(),必须先获得this这把锁,这样get()和addOne()也是互斥的。<br>
这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是Java类里的方法,<br>而门票就是用来保护资源的“锁”,Java的检票工作是由synchronized解决的。<br>
锁和受保护资源之间的关系
受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?
一个合理的关系是:<br>受保护资源和锁之间的关联关系是N:1的关系。<br>还拿前面球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,<br>如果多发了重复的票,那就要打架了。<br>
现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。
不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的“包场”了。
如果你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。
由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。
如何用一把锁保护多个资源?
保护没有关联关系的多个资源
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,<br>这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各的<br>
同样这对应到编程领域,也很容易解决。<br>例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,<br>我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。<br>
账户类Account有两个成员变量,分别是账户余额balance和账户密码password。<br>取款withdraw()和查看余额getBalance()操作会访问账户余额balance,我们创建一个final对象balLock作为锁(类比球赛门票);<br>而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password,我们创建一个final对象pwLock作为锁(类比电影票)。<br>不同的资源用不同的锁保护,各自管各自的,很简单。<br>
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用this这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字synchronized就可以了,这里我就不一一展示了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。<br>而我们用两把锁,取款和修改密码是可以并行的。<br>用不同的锁对受保护资源进行精细化管理,能够提升性能。<br>这种锁还有个名字,叫细粒度锁。<br>
保护有关联关系的多个资
如果多个资源是有关联关系的,那这个问题就有点复杂了。
。例如银行业务里面的转账操作,账户A减少100元,账户B增加100元。这两个账户就是有关联关系的。
那对于像转账这种有关联关系的操作,我们应该怎么去解决呢?先把这个问题代码化。我们声明了个账户类:<br>Account,该类有一个成员变量余额:balance,<br>还有一个用于转账的方法:transfer(),然后怎么保证转账操作transfer()没有并发问题呢?<br>
相信你的直觉会告诉你这样的解决方案:<br>用户synchronized关键字修饰一下transfer()方法就可以了,<br>于是你很快就完成了相关的代码<br>
在这段代码中,临界区内有两个资源,分别是转出账户的余额this.balance和转入账户的余额target.balance,<br>并且用的是一把锁this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。<br>真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?<br>
问题就出在this这把锁上,this这把锁可以保护自己的余额this.balance,却保护不了别人的余额target.balance,<br>就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。<br>
下面我们具体分析一下,假设有A、B、C三个账户,余额都是200元,我们用两个线程分别执行两个转账操作:<br>账户A转给账户B100元,账户B转给账户C100元,<br>最后我们期望的结果应该是账户A的余额是100元,账户B的余额是200元,账户C的余额是300元。<br>
我们假设线程1执行账户A转账户B的操作,线程2执行账户B转账户C的操作。这两个线程分别在两颗CPU上同时执行,那它们是互斥的吗?<br>我们期望是,但实际上并不是。<br>
因为线程1锁定的是账户A的实例(A.this),而线程2锁定的是账户B的实例(B.this),所以这两个线程可以同时进入临界区transfer()。<br>同时进入临界区的结果是什么呢?<br>线程1和线程2都会读到账户B的余额为200,导致最终账户B的余额可能是300(线程1后于线程2写B.balance,线程2写的B.balance值被线程1覆盖),<br>可能是100(线程1先于线程2写B.balance,线程1写的B.balance值被线程2覆盖),就是不可能是200。<br>
使用锁的正确姿势
用同一把锁来保护多个资源,也就是现实世界的“包场”,<br>那在编程领域应该怎么“包场”呢?<br>
很简单,只要我们的锁能覆盖所有受保护资源就可以了
例子中this是对象级别的锁,所以A对象和B对象都有自己的锁,如何让A对象和B对象共享一把锁呢?
稍微开动脑筋,你会发现其实方案还挺多的,比如可以让所有对象都持有一个唯一性的对象,这个对象在创建Account时传入。<br>方案有了,完成代码就简单了。<br>
我们把Account默认构造函数变为private,同时增加一个带Objectlock参数的构造函数,<br>创建Account对象时,传入相同的lock,这样所有的Account对象都会共享这个lock了。<br>
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建Account对象的时候必须传入同一个对象,<br>如果创建Account对象时,传入的lock不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。<br>在真实的项目场景中,创建Account对象的代码很可能分散在多个工程中,传入共享的lock真的很难。<br>
上面的方案缺乏实践的可行性,我们需要更好的方案。<br>还真有,就是用Account.class作为共享的锁。<br>Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创建的,<br>所以我们不用担心它的唯一性。<br>使用Account.class作为共享的锁,我们就无需在创建Account对象时传入了,代码更简单。<br>
子主题
死锁
向现实世界要答案
现实世界里,账户转账操作是支持并发的,<br>而且绝对是真正的并行,银行所有的窗口都可以做转账操作。<br>
如果没有信息化,账户的存在形式真的就是一个账本,<br>而且每个账户都有一个账本,这些账本都统一存放在文件架上。<br>
银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。
文件架上恰好有转出账本和转入账本,那就同时拿走;
如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,<br>同时等着其他柜员把另外一个账本送回来;<br>
转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
编程世界里,其实用两把锁就实现了,转出账本一把,转入账本另一把。
在transfer()方法内部,我们首先尝试锁定转出账户this(先把转出账本拿到手),<br>然后尝试锁定转入账户target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。<br>
经过这样的优化后,账户A转账户B和账户C转账户D这两个转账操作就可以并行了。
没有免费的午餐
A中看似很完美,并且也算是将锁用得出神入化了。<br>相对于用Account.class作为互斥锁,锁定的范围太大,<br>而我们锁定两个账户范围就小多了,这样的锁叫细粒度锁。<br>使用细粒度锁可以提高并行度,是性能优化的一个重要手段。<br>
使用细粒度锁这么简单,有这样的好事,是不是也要付出点什么代价啊?
编写并发程序就需要这样时时刻刻保持谨慎。
的确,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
死锁的一个比较专业的定义是:<br>一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。<br>
如果有客户找柜员张三做个转账业务:账户A转账户B100元,此时另一个客户找柜员李四也做个转账业务:<br>账户B转账户A100元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本A,李四拿到了账本B。<br>张三拿到账本A后就等着账本B(账本B已经被李四拿走),<br>而李四拿到账本B后就等着账本A(账本A已经被张三拿走),他们要等多久呢?<br>他们会永远等待下去…因为张三不会把账本A送回去,李四也不会把账本B送回去。我们姑且称为死等吧。<br>
上面转账的代码是怎么发生死锁的呢?<br>我们假设线程T1执行账户A转账户B的操作,账户A.transfer(账户B);同时线程T2执行账户B转账户A的操作,账户B.transfer(账户A)。<br>当T1和T2同时执行完①处的代码时,T1获得了账户A的锁(对于T1,this是账户A),而T2获得了账户B的锁(对于T2,this是账户B)。<br>之后T1和T2在执行②处的代码时,T1试图获取账户B的锁时,发现账户B已经被锁定(被T2锁定),所以T1开始等待;<br>T2则试图获取账户A的锁时,发现账户A已经被锁定(被T1锁定),所以T2也开始等待。<br>于是T1和T2会无期限地等待下去,也就是我们所说的死锁了。<br>
资源用方形节点表示,线程用圆形节点表示;<br>资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。<br>
如何预防死锁
破坏占用且等待条件
从理论上讲,要破坏这个条件,可以一次性申请所有资源。
在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,<br>另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?<br>
可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,<br>也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。<br>
例如,张三同时申请账本A和B,账本管理员如果发现文件架上只有账本A,<br>这个时候账本管理员是不会把账本A拿下来给张三的,只有账本A和B都在的时候才会给张三。<br>这样就保证了“一次性申请所有资源”。<br>
对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java里面的类)来管理这个临界区,<br>我们就把这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。<br>
账户Account类里面持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。<br>当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;<br>当转账操作执行完,释放锁之后,我们需通知Allocator同时释放转出账户和转入账户这两个资源。<br>
破坏不可抢占条件
破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。
原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,<br>而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。<br>
你可能会质疑,“Java作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java在语言层次确实没有解决这个问题,<br>不过在SDK层面还是解决了的,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。<br>
破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。
这个实现非常简单,我们假设每个账户都有不同的属性id,这个id可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。
比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
“等待-通知”优化循环
完美的就医流程
现实世界中,有着完美等待-通知机制的就医流程
患者先去挂号,然后到就诊门口分诊,等待叫号;
当叫到自己的号时,患者就可以找大夫就诊了;
就诊过程中,大夫可能会让患者去做检查,同时叫下一位患者;
当患者做完检查后,拿检测报告重新分诊,等待叫号;
当大夫再次叫到自己的号时,患者再去找大夫就诊。
不能忽视等待-通知机制的就医流程的一些细节
患者到就诊门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了;
大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的条件没有满足;
患者去做检查,类似于线程进入等待状态;然后大夫叫下一个患者,这个步骤我们在前面的等待-通知机制中忽视了,<br>这个步骤对应到程序里,本质是线程释放持有的互斥锁;<br>
患者做完检查,类似于线程要求的条件已经满足;<br>患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待-通知机制中也忽视了。<br>
结论:<br>
线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
用synchronized实现等待-通知机制
在Java语言里,等待-通知机制可以有多种实现方式,比如Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法就能轻松实现。
如何用synchronized实现互斥锁
图中左边有一个等待队列,同一时刻,只允许一个线程进入synchronized保护的临界区(这个临界区可以看作大夫的诊室),<br>当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。<br>这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。<br>
如何使用wait()方法实现互斥锁
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java对象的wait()方法就能够满足这种需求。
如上图所示,当调用wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
如何notify()和notifyAll()方法实现互斥锁
线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是Java对象的notify()和notifyAll()方法。
图里为你大致描述了这个过程,当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
为什么说是曾经满足过呢?
因为notify()只能保证在通知时间点,条件是满足的。<br>而被通知线程的执行时间点和通知的时间点基本上不会重合,<br>所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点需要格外注意。<br>
还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用wait()时已经释放了)
资源分配器
等待-通知机制需要考虑以下四个要素
互斥锁:上一篇文章我们提到Allocator需要是单例的,所以我们可以用this作为互斥锁。
线程要求的条件:转出账户和转入账户都没有被分配过。
何时等待:线程要求的条件不满足就等待。
何时通知:当有线程释放账户时就通知。
考虑完四要素我们可以使用
利用这种范式可以解决上面提到的条件曾经满足过这个问题。<br>因为当wait()返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。<br>范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。<br>
尽量使用notifyAll()
我们经常使用notifyAll()来实现通知机制,为什么不使用notify()呢
这二者是有区别的,notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程。
从感觉上来讲,应该是notify()更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。
但那所谓的感觉往往都蕴藏着风险,实际上使用notify()也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
假设我们有资源A、B、C、D,线程1申请到了AB,线程2申请到了CD,<br>此时线程3申请AB,会进入等待队列(AB分配给线程1,线程3要求的条件不满足),线程4申请CD也会进入等待队列。<br>
我们再假设之后线程1归还了资源AB,如果使用notify()来通知等待队列中的线程,有可能被通知的是线程4,<br>但线程4申请的是CD,所以此时线程4还是会继续等待,而真正该唤醒的线程3就再也没有机会被唤醒了。<br>
因此,除非经过深思熟虑,否则尽量使用notifyAll()。
并发编程主要问题
安全性问题
那什么是线程安全呢?
其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。
那如何才能写出线程安全的程序呢?
理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢?
当然不是,其实只有一种情况需要:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。
那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程的安全性了嘛!
有不少技术方案都是基于这个理论的,例如线程本地存储(ThreadLocalStorage,TLS)、不变模式等等,
但是,现实生活中,必须共享会发生变化的数据,这样的应用场景还是很多的。
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,<br>如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做数据竞争(DataRace)。<br>
那是不是在访问数据的地方,我们加个锁保护一下就能解决所有的并发问题了呢?显然没有这么简单。
对于上面示例,我们稍作修改,增加两个被synchronized修饰的get()和set()方法,<br>add10K()方法里面通过get()和set()方法来访问value变量,修改后的代码如下所示。<br>对于修改后的代码,所有访问共享变量value的地方,我们都增加了互斥锁,此时是不存在数据竞争的。<br>但很显然修改后的add10K()方法并不是线程安全的。<br>
假设count=0,当两个线程同时执行get()方法时,get()方法会返回相同的值0,两个线程执行get()+1操作,结果都是1,<br>之后两个线程再将结果1写入了内存。你本来期望的是2,而结果却是1。<br>
上面的问题有个官方的称呼,叫竞态条件(RaceCondition)。所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序。
例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2。
在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大Bug。
转账操作里面有个判断条件——转出金额不能大于账户余额,但在并发环境里面,<br>如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。<br>假设账户A有余额200,线程1和线程2都要从账户A转出150,在下面的代码里,有可能线程1和线程2同时执行到第6行,<br>这样线程1和线程2都会发现转出金额150小于账户余额200,于是就会发生超额转出的情况。<br>
所以你也可以按照下面这样来理解竞态条件。<br>在并发场景中,程序的执行依赖于某个状态变量:<br>
某个线程发现状态变量满足执行条件后,开始执行操作;<br>可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。<br>当然很多场景下,这个条件不是显式的,例如前面addOne的例子中,set(get()+1)这个复合操作,其实就隐式依赖get()的结果。<br>
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?
其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。
从逻辑上来看,我们可以统一归为:锁。
活跃性问题
指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程永久地“阻塞”了。
但有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。
可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,<br>路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。<br>这种情况,基本上谦让几次就解决了,因为人会交流啊。<br>可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。<br>
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。
例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;<br>同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。<br>由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。<br>“等待一个随机时间”的方案虽然很简单,却非常有效,Raft这样知名的分布式一致性算法中也用到了它。<br>
那“饥饿”该怎么去理解呢?
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。
“不患寡,而患不均”,如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;<br>持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。<br>
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。<br>倒是方案二的适用场景相对来说更多一些。<br>
那如何公平地分配资源呢?
在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。<br>“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。<br>
所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?
有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:<img src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20width%3D%2221.872ex%22%20height%3D%222.843ex%22%20style%3D%22vertical-align%3A%20-0.838ex%3B%22%20viewBox%3D%220%20-863.1%209416.9%201223.9%22%20role%3D%22img%22%20focusable%3D%22false%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20aria-labelledby%3D%22MathJax-SVG-1-Title%22%3E%0A%3Ctitle%20id%3D%22MathJax-SVG-1-Title%22%3Es%3D1%2F((1-p)%2Bp%2Fn)%3C%2Ftitle%3E%0A%3Cdefs%20aria-hidden%3D%22true%22%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMATHI-73%22%20d%3D%22M131%20289Q131%20321%20147%20354T203%20415T300%20442Q362%20442%20390%20415T419%20355Q419%20323%20402%20308T364%20292Q351%20292%20340%20300T328%20326Q328%20342%20337%20354T354%20372T367%20378Q368%20378%20368%20379Q368%20382%20361%20388T336%20399T297%20405Q249%20405%20227%20379T204%20326Q204%20301%20223%20291T278%20274T330%20259Q396%20230%20396%20163Q396%20135%20385%20107T352%2051T289%207T195%20-10Q118%20-10%2086%2019T53%2087Q53%20126%2074%20143T118%20160Q133%20160%20146%20151T160%20120Q160%2094%20142%2076T111%2058Q109%2057%20108%2057T107%2055Q108%2052%20115%2047T146%2034T201%2027Q237%2027%20263%2038T301%2066T318%2097T323%20122Q323%20150%20302%20164T254%20181T195%20196T148%20231Q131%20256%20131%20289Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMAIN-3D%22%20d%3D%22M56%20347Q56%20360%2070%20367H707Q722%20359%20722%20347Q722%20336%20708%20328L390%20327H72Q56%20332%2056%20347ZM56%20153Q56%20168%2072%20173H708Q722%20163%20722%20153Q722%20140%20707%20133H70Q56%20140%2056%20153Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMAIN-31%22%20d%3D%22M213%20578L200%20573Q186%20568%20160%20563T102%20556H83V602H102Q149%20604%20189%20617T245%20641T273%20663Q275%20666%20285%20666Q294%20666%20302%20660V361L303%2061Q310%2054%20315%2052T339%2048T401%2046H427V0H416Q395%203%20257%203Q121%203%20100%200H88V46H114Q136%2046%20152%2046T177%2047T193%2050T201%2052T207%2057T213%2061V578Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMAIN-2F%22%20d%3D%22M423%20750Q432%20750%20438%20744T444%20730Q444%20725%20271%20248T92%20-240Q85%20-250%2075%20-250Q68%20-250%2062%20-245T56%20-231Q56%20-221%20230%20257T407%20740Q411%20750%20423%20750Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMAIN-28%22%20d%3D%22M94%20250Q94%20319%20104%20381T127%20488T164%20576T202%20643T244%20695T277%20729T302%20750H315H319Q333%20750%20333%20741Q333%20738%20316%20720T275%20667T226%20581T184%20443T167%20250T184%2058T225%20-81T274%20-167T316%20-220T333%20-241Q333%20-250%20318%20-250H315H302L274%20-226Q180%20-141%20137%20-14T94%20250Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMAIN-2212%22%20d%3D%22M84%20237T84%20250T98%20270H679Q694%20262%20694%20250T679%20230H98Q84%20237%2084%20250Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMATHI-70%22%20d%3D%22M23%20287Q24%20290%2025%20295T30%20317T40%20348T55%20381T75%20411T101%20433T134%20442Q209%20442%20230%20378L240%20387Q302%20442%20358%20442Q423%20442%20460%20395T497%20281Q497%20173%20421%2082T249%20-10Q227%20-10%20210%20-4Q199%201%20187%2011T168%2028L161%2036Q160%2035%20139%20-51T118%20-138Q118%20-144%20126%20-145T163%20-148H188Q194%20-155%20194%20-157T191%20-175Q188%20-187%20185%20-190T172%20-194Q170%20-194%20161%20-194T127%20-193T65%20-192Q-5%20-192%20-24%20-194H-32Q-39%20-187%20-39%20-183Q-37%20-156%20-26%20-148H-6Q28%20-147%2033%20-136Q36%20-130%2094%20103T155%20350Q156%20355%20156%20364Q156%20405%20131%20405Q109%20405%2094%20377T71%20316T59%20280Q57%20278%2043%20278H29Q23%20284%2023%20287ZM178%20102Q200%2026%20252%2026Q282%2026%20310%2049T356%20107Q374%20141%20392%20215T411%20325V331Q411%20405%20350%20405Q339%20405%20328%20402T306%20393T286%20380T269%20365T254%20350T243%20336T235%20326L232%20322Q232%20321%20229%20308T218%20264T204%20212Q178%20106%20178%20102Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMAIN-29%22%20d%3D%22M60%20749L64%20750Q69%20750%2074%20750H86L114%20726Q208%20641%20251%20514T294%20250Q294%20182%20284%20119T261%2012T224%20-76T186%20-143T145%20-194T113%20-227T90%20-246Q87%20-249%2086%20-250H74Q66%20-250%2063%20-250T58%20-247T55%20-238Q56%20-237%2066%20-225Q221%20-64%20221%20250T66%20725Q56%20737%2055%20738Q55%20746%2060%20749Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMAIN-2B%22%20d%3D%22M56%20237T56%20250T70%20270H369V420L370%20570Q380%20583%20389%20583Q402%20583%20409%20568V270H707Q722%20262%20722%20250T707%20230H409V-68Q401%20-82%20391%20-82H389H387Q375%20-82%20369%20-68V230H70Q56%20237%2056%20250Z%22%3E%3C%2Fpath%3E%0A%3Cpath%20stroke-width%3D%221%22%20id%3D%22E1-MJMATHI-6E%22%20d%3D%22M21%20287Q22%20293%2024%20303T36%20341T56%20388T89%20425T135%20442Q171%20442%20195%20424T225%20390T231%20369Q231%20367%20232%20367L243%20378Q304%20442%20382%20442Q436%20442%20469%20415T503%20336T465%20179T427%2052Q427%2026%20444%2026Q450%2026%20453%2027Q482%2032%20505%2065T540%20145Q542%20153%20560%20153Q580%20153%20580%20145Q580%20144%20576%20130Q568%20101%20554%2073T508%2017T439%20-10Q392%20-10%20371%2017T350%2073Q350%2092%20386%20193T423%20345Q423%20404%20379%20404H374Q288%20404%20229%20303L222%20291L189%20157Q156%2026%20151%2016Q138%20-11%20108%20-11Q95%20-11%2087%20-5T76%207T74%2017Q74%2030%20112%20180T152%20343Q153%20348%20153%20366Q153%20405%20129%20405Q91%20405%2066%20305Q60%20285%2060%20284Q58%20278%2041%20278H27Q21%20284%2021%20287Z%22%3E%3C%2Fpath%3E%0A%3C%2Fdefs%3E%0A%3Cg%20stroke%3D%22currentColor%22%20fill%3D%22currentColor%22%20stroke-width%3D%220%22%20transform%3D%22matrix(1%200%200%20-1%200%200)%22%20aria-hidden%3D%22true%22%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-73%22%20x%3D%220%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-3D%22%20x%3D%22747%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-31%22%20x%3D%221803%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-2F%22%20x%3D%222304%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-28%22%20x%3D%222804%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-28%22%20x%3D%223194%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-31%22%20x%3D%223583%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-2212%22%20x%3D%224306%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-70%22%20x%3D%225307%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-29%22%20x%3D%225810%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-2B%22%20x%3D%226422%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-70%22%20x%3D%227422%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-2F%22%20x%3D%227926%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-6E%22%20x%3D%228426%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-29%22%20x%3D%229027%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%3C%2Fg%3E%0A%3C%2Fsvg%3E" style="box-sizing: border-box; vertical-align: middle; border-style: none; max-width: 100%; color: rgb(64, 64, 64); font-size: 15px;">
假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?
公式里的n可以理解为CPU的核数,p可以理解为并行百分比,那(1-p)就是串行百分比了,也就是我们假设的5%。<br>我们再假设CPU的核数(也就是n)无穷大,那加速比S的极限就是20。<br>也就是说,如果我们的串行率是5%,那么我们无论采用什么技术,最高也就只能提高20倍的性能。<br>
那怎么才能避免锁带来的性能问题呢?
这个问题很复杂,JavaSDK并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。
方案层面解决这个问题
既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。
例如<br>线程本地存储(ThreadLocalStorage,TLS)、写入时复制(Copy-on-write)、乐观锁等;<br>Java并发包里面的原子类也是一种无锁的数据结构;<br>Disruptor则是一个无锁的内存队列,性能都非常好<br>
减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。
例如<br>使用细粒度的锁,一个典型的例子就是Java并发包里的ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);<br>还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。<br>
性能方面的度量指标有很多,我觉得有三个指标非常重要
吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是1000的时候,延迟是50毫秒。
管程
什么是管程
为什么Java在1.5之前仅仅提供了synchronized关键字及wait()、notify()、notifyAll()这三个看似从天而降的方法?
我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。
后来我找到了原因:Java采用的是管程技术,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。
而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以Java选择了管程。
管程对应的英文是Monitor,很多Java领域的同学都喜欢将其翻译成“监视器”。操作系统领域一般都翻译成“管程”,这个是意译,而我自己也更倾向于使用“管程”。
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
MESA模型
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型。
现在广泛应用的是MESA模型,并且Java管程的实现参考的也是MESA模型
在并发编程领域,有两大核心问题:
一个是互斥,即同一时刻只允许一个线程访问共享资源;
另一个是同步,即线程之间如何通信、协作。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。
假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:<br>将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。<br>
管程X将共享变量queue这个线程不安全的队列和相关的操作入队操作enq()、出队操作deq()都封装起来了;<br>线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq()、deq()方法来实现;enq()、deq()保证互斥性,只允许一个线程进入管程。<br>
那管程如何解决线程间的同步问题呢?
这个比较复杂不过可以借鉴一下我们曾经提到过的就医流程,它可以帮助你快速地理解这个问题。
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。<br>框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。<br>当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。<br>这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。<br>
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,条件变量A和条件变量B分别都有自己的等待队列。
条件变量和条件变量等待队列的作用是什么呢?
其实就是解决线程同步问题。
如果线程T1进入管程后恰好发现阻塞队列是空的,那怎么办呢?等待啊,去哪里等呢?
就去条件变量对应的等待队列里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。
wait()、notify()、notifyAll()这三个操作
再假设之后另外一个线程T2执行阻塞队列的入队操作,入队操作执行成功之后,<br>“阻塞队列不空”这个条件对于线程T1来说已经满足了,此时线程T2要通知T1,告诉它需要的条件已经满足了。<br>当线程T1得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。<br>这个过程类似你验血完,回来找大夫,需要重新分诊。<br>
前面提到线程T1发现“阻塞队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用wait()来实现的。
如果我们用对象A代表“阻塞队列不空”这个条件,那么线程T1需要调用A.wait()。<br>同理当“阻塞队列不空”这个条件满足时,线程T2需要调用A.notify()来通知A等待队列中的一个线程,此时这个等待队列里面只有线程T1。<br>
至于notifyAll()这个方法,它可以通知等待队列中的所有线程。
下面的代码用管程实现了一个线程安全的阻塞队列。<br>阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。<br>
对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await();
对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了notEmpty.await();
如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列
如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满notFull对应的等待队列
在这段示例代码中,我们用了Java并发包里面的Lock和Condition,如果你看着吃力,也没关系,<br>后面我们还会详细介绍,这个例子只是先让你明白条件变量及其等待队列是怎么回事。<br>
需要注意的是:await()和前面我们提到的wait()语义是一样的;signal()和前面我们提到的notify()语义是一样的。
wait()的正确姿势
对于MESA管程来说,有一个编程范式,就是需要在一个while循环里面调用wait()。这个是MESA管程特有的
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。
管程要求同一时刻只允许一个线程执行,<br>那当线程T2的操作使线程T1等待的条件满足时,T1和T2究竟谁可以执行呢?<br>
Hasen模型里面:要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
Hoare模型里面:T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2,也能保证同一时刻只有一个线程执行。<br>但是相比Hasen模型,T2多了一次阻塞唤醒操作。<br>
MESA管程里面:T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。<br>这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。<br>但是也有个副作用,就是当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。<br>
notify()何时可以使用
那什么时候可以使用notify()呢?需要满足以下三个条件:
所有等待线程拥有相同的等待条件;
所有等待线程被唤醒后,执行相同的操作;
只需要唤醒一个线程。
比如上面阻塞队列的例子中,对于“阻塞队列不满”这个条件变量,其等待线程都是在等待“阻塞队列不满”这个条件,反映在代码里就是下面这3行代码。<br>对所有等待线程来说,都是执行这3行代码,重点是while里面的等待条件是完全相同的。<br>
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
同时也满足第3条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用signal()是可以的。
Java线程
Java线程的生命周期
通用的线程生命周期
用的线程生命周期基本上可以用下图这个“五态模型”来描述
初始状态
指的是线程已经被创建,但是还不允许分配 CPU 执行。<br>这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,<br>而在操作系统层面,真正的线程还没有创建。<br>
可运行状态
指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,<br>所以可以分配 CPU 执行。当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,<br>被分配到 CPU 的线程的状态就转换成了运行状态。<br>
运行状态
运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),<br>那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。<br>
休眠状态
当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
终止状态
线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,<br>进入终止状态也就意味着线程的生命周期结束了。<br>
这五种状态在不同编程语言里会有简化合并
C 语言的 POSIX Threads 规范,就把初始状态和可运行状态合并了
Java 语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,<br>而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。<br>
Java 中线程的生命周期
Java 语言中线程共有六种状态
NEW(初始化状态)
RUNNABLE(可运行 / 运行状态)
BLOCKED(阻塞状态)
WAITING(无时限等待)
TIMED_WAITING(有时限等待)
TERMINATED(终止状态)
但其实在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。
Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
1)RUNNABLE 与 BLOCKED 的状态转换<br>
只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。
线程调用阻塞式 API 时,是否会转换到 BLOCKED 状态呢?
在操作系统层面,线程是会转换到休眠状态的,但是在 JVM 层面,<br>Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。<br>
JVM层面并不关心操作系统调度相关的状态,比如等待CPU使用权与等待 I/O没有区别,都是在等待某个资源,所以都归入了RUNNABLE状态。<br>
2)RUNNABLE 与 WAITING 的状态转换
第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
wait() 方法上面有描述,这里就不再赘述。
第二种场景,调用无参数的 Thread.join() 方法。
其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,<br>执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。<br>当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。<br>
第三种场景,调用 LockSupport.park() 方法。
其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。<br>调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。<br>调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。<br>
3)RUNNABLE 与 TIMED_WAITING 的状态转换
调用带超时参数的 Thread.sleep(long millis) 方法;
获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
调用带超时参数的 Thread.join(long millis) 方法;
调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
4)从 NEW 到 RUNNABLE 状态
一种是继承 Thread 对象,重写 run() 方法
另一种是实现 Runnable 接口,重写 run() 方法,并将该实现类作为创建 Thread 对象的参数
NEW 状态的线程,不会被操作系统调度,因此不会执行。Java 线程要执行,就必须转换到 RUNNABLE 状态。<br>从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法就可以了<br>
5)从 RUNNABLE 到 TERMINATED 状态
线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。
有时候我们需要强制中断 run() 方法的执行,例如 run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?
Java 的 Thread 类里面倒是有个 stop() 方法,不过已经标记为 @Deprecated,所以不建议使用了。<br>正确的姿势其实是调用 interrupt() 方法。<br>
那stop()和 interrupt()方法的主要区别是什么呢?
stop()方法会真的杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,<br>被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,<br>那其他线程就再也没机会获得ReentrantLock锁,这实在是太危险了。<br>
所以该方法就不建议使用了,类似的方法还有suspend()和resume()方法,<br>这两个方法同样也都不建议使用了,所以这里也就不多介绍了。<br>
而interrupt()方法就温柔多了,interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。<br>被interrupt的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。<br>
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,<br>会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。<br>
上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,<br>我们看这些方法的签名,发现都会throwsInterruptedException这个异常。<br>
这个异常的触发条件就是:其他线程调用了该线程的interrupt()方法。
当线程A处于RUNNABLE状态时,并且阻塞在java.nio.channels.InterruptibleChannel上时,<br>如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;<br>而阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。<br>
上面五个完美回答了下面的问题,BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因。<br>那具体是哪些情形会导致线程从 RUNNABLE 状态转换到这三种状态呢?<br>而这三种状态又是何时转换回 RUNNABLE 的呢?<br>以及 NEW、TERMINATED 和 RUNNABLE 状态是如何转换的?
创建多少线程才是合适的?
多线程的应用场景
创建多少线程合适?
为什么局部变量是线程安全的?
方法是如何被执行的
局部变量存哪里?
调用栈与线程
线程封闭
并发编程
并发基础
Lock和Condition
Semaphore
ReadWriteLock
StampedLock
CountDownLatch和CyclicBarrier
并发容器
原子类
Executor与线程池
Future
CompletableFuture
CompletionService
Fork/Join
0 条评论
下一页