first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@ -0,0 +1,89 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 学习攻略 如何才能学好并发编程?
并发编程并不是一门相对独立的学科,而是一个综合学科。并发编程相关的概念和技术看上非常零散,相关度也很低,总给你一种这样的感觉:我已经学习很多相关技术了,可还是搞不定并发编程。那如何才能学习好并发编程呢?
其实很简单,只要你能从两个方面突破一下就可以了。一个是“跳出来,看全景”,另一个是“钻进去,看本质”。
跳出来,看全景
我们先说“跳出来”。你应该也知道,学习最忌讳的就是“盲人摸象”,只看到局部,而没有看到全局。所以,你需要从一个个单一的知识和技术中“跳出来”,高屋建瓴地看并发编程。当然,这首要之事就是你建立起一张全景图。
不过,并发编程相关的知识和技术还真是错综复杂,时至今日也还没有一张普遍认可的全景图,也许这正是很多人在并发编程方面难以突破的原因吧。好在经过多年摸爬滚打,我自己已经“勾勒”出了一张全景图,不一定科学,但是在某种程度上我想它还是可以指导你学好并发编程的。
在我看来,并发编程领域可以抽象成三个核心问题:分工、同步和互斥。
1. 分工
所谓分工,类似于现实中一个组织完成一个项目,项目经理要拆分任务,安排合适的成员去完成。
在并发编程领域,你就是项目经理,线程就是项目组成员。任务分解和分工对于项目成败非常关键,不过在并发领域里,分工更重要,它直接决定了并发程序的性能。在现实世界里,分工是很复杂的,著名数学家华罗庚曾用“烧水泡茶”的例子通俗地讲解了统筹方法(一种安排工作进程的数学方法),“烧水泡茶”这么简单的事情都这么多说道,更何况是并发编程里的工程问题呢。
既然分工很重要又很复杂那一定有前辈努力尝试解决过并且也一定有成果。的确在并发编程领域这方面的成果还是很丰硕的。Java SDK并发包里的Executor、Fork/Join、Future本质上都是一种分工方法。除此之外并发编程领域还总结了一些设计模式基本上都是和分工方法相关的例如生产者-消费者、Thread-Per-Message、Worker Thread模式等都是用来指导你如何分工的。
学习这部分内容,最佳的方式就是和现实世界做对比。例如生产者-消费者模式,可以类比一下餐馆里的大厨和服务员,大厨就是生产者,负责做菜,做完放到出菜口,而服务员就是消费者,把做好的菜给你端过来。不过,我们经常会发现,出菜口有时候一下子出了好几个菜,服务员是可以把这一批菜同时端给你的。其实这就是生产者-消费者模式的一个优点,生产者一个一个地生产数据,而消费者可以批处理,这样就提高了性能。
2. 同步
分好工之后,就是具体执行了。在项目执行过程中,任务之间是有依赖的,一个任务结束后,依赖它的后续任务就可以开工了,后续工作怎么知道可以开工了呢?这个就是靠沟通协作了,这是一项很重要的工作。
在并发编程领域里的同步,主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。
协作一般是和分工相关的。Java SDK并发包里的Executor、Fork/Join、Future本质上都是分工方法但同时也能解决线程协作的问题。例如用Future可以发起一个异步调用当主线程通过get()方法取结果时主线程就会等待当异步执行的结果返回时get()方法就自动返回了。主线程和异步线程之间的协作Future工具类已经帮我们解决了。除此之外Java SDK里提供的CountDownLatch、CyclicBarrier、Phaser、Exchanger也都是用来解决线程协作问题的。
不过还有很多场景,是需要你自己来处理线程之间的协作的。
工作中遇到的线程协作问题,基本上都可以描述为这样的一个问题:当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。例如,在生产者-消费者模型里,也有类似的描述,“当队列满时,生产者线程等待,当队列不满时,生产者线程需要被唤醒执行;当队列空时,消费者线程等待,当队列不空时,消费者线程需要被唤醒执行。”
在Java并发编程领域解决协作问题的核心技术是管程上面提到的所有线程协作技术底层都是利用管程解决的。管程是一种解决并发问题的通用模型除了能解决线程协作问题还能解决下面我们将要介绍的互斥问题。可以这么说管程是解决并发问题的万能钥匙。
所以说这部分内容的学习关键是理解管程模型学好它就可以解决所有问题。其次是了解Java SDK并发包提供的几个线程协作的工具类的应用场景用好它们可以妥妥地提高你的工作效率。
3. 互斥
分工、同步主要强调的是性能但并发程序里还有一部分是关于正确性的用专业术语叫“线程安全”。并发程序里当多个线程同时访问同一个共享变量的时候结果是不确定的。不确定则意味着可能正确也可能错误事先是不知道的。而导致不确定的主要源头是可见性问题、有序性问题和原子性问题为了解决这三个问题Java语言引入了内存模型内存模型提供了一系列的规则利用这些规则我们可以避免可见性问题、有序性问题但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。
所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。
实现互斥的核心技术就是锁Java语言里synchronized、SDK里的各种Lock都能解决互斥问题。虽说锁解决了安全性问题但同时也带来了性能问题那如何保证安全性的同时又尽量提高性能呢可以分场景优化Java SDK里提供的ReadWriteLock、StampedLock就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构例如Java SDK里提供的原子类都是基于无锁技术实现的。
除此之外还有一些其他的方案原理是不共享变量或者变量只允许读。这方面Java提供了Thread Local和final关键字还有一种Copy-on-write的模式。
使用锁除了要注意性能问题外,还需要注意死锁问题。
这部分内容比较复杂往往还是跨领域的例如要理解可见性就需要了解一些CPU和缓存的知识要理解原子性就需要理解一些操作系统的知识很多无锁算法的实现往往也需要理解CPU缓存。这部分内容的学习需要博览群书在大脑里建立起CPU、内存、I/O执行的模拟器。这样遇到问题就能得心应手了。
跳出来,看全景,可以让你的知识成体系,所学知识也融汇贯通起来,由点成线,由线及面,画出自己的知识全景图。
并发编程全景图之思维导图
钻进去,看本质
但是光跳出来还不够,还需要下一步,就是在某个问题上钻进去,深入理解,找到本质。
就拿我个人来说,我已经烦透了去讲述或被讲述一堆概念和结论,而不分析这些概念和结论是怎么来的,以及它们是用来解决什么问题的。在大学里,这样的教材很流行,直接导致了芸芸学子成绩很高,但解决问题的能力很差。其实,知其然知其所以然,才算真的学明白了。
我属于理论派我认为工程上的解决方案一定要有理论做基础。所以在学习并发编程的过程中我都会探索它背后的理论是什么。比如当看到Java SDK里面的条件变量Condition的时候我会下意识地问“它是从哪儿来的是Java的特有概念还是一个通用的编程概念”当我知道它来自管程的时候我又会问“管程被提出的背景和解决的问题是什么”这样一路探索下来我发现Java语言里的并发技术基本都是有理论基础的并且这些理论在其他编程语言里也有类似的实现。所以我认为技术的本质是背后的理论模型。
总结
当初我学习Java并发编程的时候试图上来就看Java SDK的并发包但是很快就放弃了。原因是我觉得东西太多眼花缭乱的虽然借助网络上的技术文章感觉都看懂了但是很快就又忘了。实际应用的时候大脑也一片空白根本不知道从哪里下手有时候好不容易解决了个问题也不知道这个方案是不是合适的。
我知道根本原因是,我的并发知识还没有成体系。
我想要让自己的知识成体系一定要挖掘Java SDK并发包背后的设计理念。Java SDK并发包是并发大师Doug Lea设计的他一定不是随意设计的一定是深思熟虑的其背后是Doug Lea对并发问题的深刻认识。可惜这个设计的思想目前并没有相关的论文所以只能自己琢磨了。
分工、同步和互斥的全景图,是我对并发问题的个人总结,不一定正确,但是可以帮助我快速建立解决并发问题的思路,梳理并发编程的知识,加深认识。我将其分享给你,希望对你也有用。
对于某个具体的技术,我建议你探索它背后的理论本质,理论的应用面更宽,一项优秀的理论往往在多个语言中都有体现,在多个不同领域都有应用。所以探求理论本质,既能加深对技术本身的理解,也能拓展知识深度和广度,这是个一举多得的方法。这方面,希望我们一起探讨,共同进步。
欢迎在留言区跟我分享你的经历与想法。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 你为什么需要学习并发编程?
你好我是王宝令资深架构师目前从事电商架构的设计工作。从毕业到现在我前前后后写了15年的程序刚毕业的时候从事证券业务的开发开发语言是C/C++之后从事ERP产品的研发开发语言主要是C#和Java最近几年主要是从事Java开发平台和基础中间件的设计开发工作。
还记得毕业后我接触的第一个项目是证券相关的国外的同事用C语言写了一个内存数据库代码写得极为简练优美我当时怀着无比崇敬的心情把代码看了又看看完感觉受益匪浅。不过兴奋之余我也有些焦虑因为其中一块并发相关的代码我看得是云里雾里总感觉自己没有悟透。
我下意识地告诉自己说这块的知识积累还不够所以要勤学苦练。你可知道15年前相关的学习资料并不多我的师傅向我推荐了《操作系统原理》这本教材他说“并发编程最早的应用领域就是操作系统的实现你把这本书看懂了并发的问题自然就解决了。”但是理论和实践之间总是有鸿沟的之后好多年最让我感到无助的还是处理并发相关的问题。
并发编程的掌握过程并不容易。我相信为了解决这个问题你也听别人总结过并发编程的第一原则那就是不要写并发程序。这个原则在我刚毕业的那几年曾经是行得通的那个时候多核服务器还是一种奢侈品系统的并发量也很低借助数据库和类似Tomcat这种中间件我们基本上不用写并发程序。或者说并发问题基本上都被中间件和数据库解决了。
但是最近几年,并发编程已经慢慢成为一项必备技能。
这主要是硬件的驱动以及国内互联网行业的飞速发展决定的现在64核的服务器已经飞入寻常百姓家大型互联网厂商的系统并发量轻松过百万传统的中间件和数据库已经不能为我们遮风挡雨反而成了瓶颈所在。
于是,并发编程最近几年成为非常热门的领域,人才稀缺。但与此同时,关于并发编程的书籍也渐渐丰富起来了。所以当极客时间团队和我聊这个专栏的时候,我的第一个疑问就是目前市面上已经有很多这方面的图书了,而且很多都非常优秀,是否还有必要搞一个这样的专栏。
但是深入想过之后,我坚定了写作的信心。这些年接触的大部分同学,都是工作几年后很多技术突飞猛进,却只有并发编程成为瓶颈,虽然并发相关的类库他们也熟悉,却总是写不出正确、高效的并发程序,原因在哪里?我发现很多人是因为某个地方有了盲点,忽略了一些细节,但恰恰是这些细节决定了程序的正确性和效率。
而这个盲点有时候涉及对操作系统的理解,有时候又涉及一点硬件知识,非常复杂,如果要推荐相关图书,可能要推荐好几本,这就有点“大炮打蚊子”的感觉了,效率很差。同时图书更追求严谨性,却也因此失掉了形象性,所以阅读的过程也确实有点艰辛。
我想,如果能够把这些问题解决,那么做这个事情应该是有意义的。
例如Java里synchronized、wait()/notify()相关的知识很琐碎看懂难会用更难。但实际上synchronized、wait()、notify()不过是操作系统领域里管程模型的一种实现而已Java SDK并发包里的条件变量Condition也是管程里的概念synchronized、wait()/notify()、条件变量这些知识如果单独理解,自然是管中窥豹。但是如果站在管程这个理论模型的高度,你就会发现这些知识原来这么简单,同时用起来也就得心应手了。
管程作为一种解决并发问题的模型是继信号量模型之后的一项重大创新它与信号量在逻辑上是等价的可以用管程实现信号量也可以用信号量实现管程但是相比之下管程更易用。而且很多编程语言都支持管程搞懂管程对学习其他很多语言的并发编程有很大帮助。然而很多人急于学习Java并发编程技术却忽略了技术背后的理论和模型而理论和模型却往往比具体的技术更为重要。
此外Java经过这些年的发展Java SDK并发包提供了非常丰富的功能对于初学者来说可谓是眼花缭乱好多人觉得无从下手。但是Java SDK并发包乃是并发大师Doug Lea出品堪称经典它内部一定是有章可循的。那它的章法在哪里呢
其实并发编程可以总结为三个核心问题:分工、同步、互斥。
所谓分工指的是如何高效地拆解任务并分配给线程而同步指的是线程之间如何协作互斥则是保证同一时刻只允许一个线程访问共享资源。Java SDK并发包很大部分内容都是按照这三个维度组织的例如Fork/Join框架就是一种分工模式CountDownLatch就是一种典型的同步方式而可重入锁则是一种互斥手段。
当把并发编程核心的问题搞清楚再回过头来看Java SDK并发包你会感觉豁然开朗它不过是针对并发问题开发出来的工具而已此时的SDK并发包可以任你“盘”了。
而且这三个核心问题是跨语言的你如果要学习其他语言的并发编程类库完全可以顺着这三个问题按图索骥。Java SDK并发包其余的一部分则是并发容器和原子类这些比较容易理解属于辅助工具其他语言里基本都能找到对应的。
所以,你说并发编程难学吗?
首先难是肯定的。因为这其中涉及操作系统、CPU、内存等等多方面的知识如果你缺少某一块那理解起来自然困难。其次难不难学也可能因人而异就我的经验来看很多人在学习并发编程的时候总是喜欢从点出发希望能从点里找到规律或者本质最后却把自己绕晕了。
我前面说过并发编程并不是Java特有的语言特性它是一个通用且早已成熟的领域。Java只是根据自身情况做了实现罢了当你理解或学习并发编程的时候如果能够站在较高层面系统且有体系地思考问题那就会容易很多。
所以我希望这个专栏更多地谈及问题背后的本质、问题的起源同时站在理论、模型的角度讲解Java并发让你的知识更成体系融会贯通。最终让你能够得心应手地解决各种并发难题同时将这些知识用于其他编程语言让你的一分辛劳三分收获。
下面就是这个专栏的目录,你可以快速了解下整个专栏的知识结构体系。
当然,我们要坚持下去,不能三天打鱼两天晒网,因为滴水穿石非一日之功。
很多人都说学习是反人性的,开始容易,但是长久的坚持却很难。这个我也认同,我面试的时候,就经常问候选人一个问题:“工作中,有没有一件事你自己坚持了很久,并且从中获益?”如果候选人能够回答出来,那会是整个面试的加分项,因为我觉得,坚持真是一个可贵的品质,一件事情,有的人三分热度,而有的人,一做就能做一年,或者更久。你放长到时间的维度里看,这两种人,最后的成就绝对是指数级的差距。
我希望你能和我坚持下来,我们一起学习,一起交流,遇到问题不是简单地抱怨和逃避,而是努力探寻答案与解决方法。这一次,就让我们一起来坚持探索并发编程的奥秘,体会探索知识的乐趣。今天的文章是开篇词,我们的主菜很快就来,如果可以的话,还请在留言区中做个自我介绍,和我聊聊你目前的工作、学习情况,以及你在并发编程方面的学习痛点,方便我在后面针对性地给你讲解,这样,我们可以彼此了解。
最后,感谢你对我的信任,我定会努力实现完美交付。

View File

@ -0,0 +1,179 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 可见性、原子性和有序性问题并发编程Bug的源头
如果你细心观察的话,你会发现,不管是哪一门编程语言,并发类的知识都是在高级篇里。换句话说,这块知识点其实对于程序员来说,是比较进阶的知识。我自己这么多年学习过来,也确实觉得并发是比较难的,因为它会涉及到很多的底层知识,比如若你对操作系统相关的知识一无所知的话,那去理解一些原理就会费些力气。这是我们整个专栏的第一篇文章,我说这些话的意思是如果你在中间遇到自己没想通的问题,可以去查阅资料,也可以在评论区找我,以保证你能够跟上学习进度。
你我都知道编写正确的并发程序是一件极困难的事情并发程序的Bug往往会诡异地出现然后又诡异地消失很难重现也很难追踪很多时候都让人很抓狂。但要快速而又精准地解决“并发”类的疑难杂症你就要理解这件事情的本质追本溯源深入分析这些Bug的源头在哪里。
那为什么并发编程容易出问题呢它是怎么出问题的今天我们就重点聊聊这些Bug的源头。
并发程序幕后的故事
这些年我们的CPU、内存、I/O设备都在不断迭代不断朝着更快的方向努力。但是在这个快速发展的过程中有一个核心矛盾一直存在就是这三者的速度差异。CPU和内存的速度差异可以形象地描述为CPU是天上一天内存是地上一年假设CPU执行一条普通指令需要一天那么CPU读写内存得等待一年的时间。内存和I/O设备的速度差异就更大了内存是天上一天I/O设备是地上十年。
程序里大部分语句都要访问内存有些还要访问I/O根据木桶理论一只水桶能装多少水取决于它最短的那块木板程序整体的性能取决于最慢的操作——读写I/O设备也就是说单方面提高CPU性能是无效的。
为了合理利用CPU的高性能平衡这三者的速度差异计算机体系结构、操作系统、编译程序都做出了贡献主要体现为
CPU增加了缓存以均衡与内存的速度差异
操作系统增加了进程、线程以分时复用CPU进而均衡CPU与I/O设备的速度差异
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。
源头之一:缓存导致的可见性问题
在单核时代所有的线程都是在一颗CPU上执行CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存一个线程对缓存的写对另外一个线程来说一定是可见的。例如在下面的图中线程A和线程B都是操作同一个CPU里面的缓存所以线程A更新了变量V的值那么线程B之后再访问变量V得到的一定是V的最新值线程A写过的值
CPU缓存与内存的关系图
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
多核时代每颗CPU都有自己的缓存这时CPU缓存与内存的数据一致性就没那么容易解决了当多个线程在不同的CPU上执行时这些线程操作的是不同的CPU缓存。比如下图中线程A操作的是CPU-1上的缓存而线程B操作的是CPU-2上的缓存很明显这个时候线程A对变量V的操作对于线程B而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
多核CPU的缓存与内存关系图
下面我们再用一段代码来验证一下多核场景下的可见性问题。下面的代码每执行一次add10K()方法都会循环10000次count+=1操作。在calc()方法中我们创建了两个线程每个线程调用一次add10K()方法我们来想一想执行calc()方法得到的结果应该是多少呢?
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 创建两个线程执行add()操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
return count;
}
}
直觉告诉我们应该是20000因为在单线程里调用两次add10K()方法count的值就是20000但实际上calc()的执行结果是个10000到20000之间的随机数。为什么呢
我们假设线程A和线程B同时开始执行那么第一次都会将 count=0 读到各自的CPU缓存里执行完 count+=1 之后各自CPU缓存里的值都是1同时写入内存后我们会发现内存中是1而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值两个线程都是基于CPU缓存里的 count 值来计算所以导致最终count的值都是小于20000的。这就是缓存的可见性问题。
循环10000次count+=1操作如果改为循环1亿次你会发现效果更明显最终count的值接近1亿而不是2亿。如果循环10000次count的值接近20000原因是两个线程不是同时启动的有一个时差。
变量count在CPU缓存和内存的分布图
源头之二:线程切换带来的原子性问题
由于IO太慢早期的操作系统就发明了多进程即便在单核的CPU上我们也可以一边听着歌一边写Bug这个就是多进程的功劳。
操作系统允许某个进程执行一小段时间例如50毫秒过了50毫秒操作系统就会重新选择一个进程来执行我们称为“任务切换”这个50毫秒称为“时间片”。
线程切换示意图
在一个时间片内如果一个进程进行一个IO操作例如读个文件这个时候该进程可以把自己标记为“休眠状态”并出让CPU的使用权待文件读进内存操作系统会把这个休眠的进程唤醒唤醒后的进程就有机会重新获得CPU的使用权了。
这里的进程在等待IO时之所以会释放CPU使用权是为了让CPU在这段等待时间里可以做别的事情这样一来CPU的使用率就上来了此外如果这时有另外一个进程也读文件读文件的操作就会排队磁盘驱动在完成一个进程的读操作后发现有排队的任务就会立即启动下一个读操作这样IO的使用率也上来了。
是不是很简单的逻辑但是虽然看似简单支持多进程分时复用在操作系统的发展史上却具有里程碑意义Unix就是因为解决了这个问题而名噪天下的。
早期的操作系统基于进程来调度CPU不同进程间是不共享内存空间的所以进程要做任务切换就要切换内存映射地址而一个进程创建的所有线程都是共享一个内存空间的所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度现在我们提到的“任务切换”都是指“线程切换”。
Java并发程序都是基于多线程的自然也会涉及到任务切换也许你想不到任务切换竟然也是并发编程里诡异Bug的源头之一。任务切换的时机大多数是在时间片结束的时候我们现在基本都使用高级语言编程高级语言里一条语句往往需要多条CPU指令完成例如上面代码中的count += 1至少需要三条CPU指令。
指令1首先需要把变量count从内存加载到CPU的寄存器
指令2之后在寄存器中执行+1操作
指令3最后将结果写入内存缓存机制导致可能写入的是CPU缓存而不是内存
操作系统做任务切换可以发生在任何一条CPU指令执行完是的是CPU指令而不是高级语言里的一条语句。对于上面的三条指令来说我们假设count=0如果线程A在指令1执行完后做线程切换线程A和线程B按照下图的序列执行那么我们会发现两个线程都执行了count+=1的操作但是得到的结果不是我们期望的2而是1。
非原子操作的执行路径示意图
我们潜意识里面觉得count+=1这个操作是一个不可分割的整体就像一个原子一样线程的切换可以发生在count+=1之前也可以发生在count+=1之后但就是不会发生在中间。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU能保证的原子操作是CPU指令级别的而不是高级语言的操作符这是违背我们直觉的地方。因此很多时候我们需要在高级语言层面保证操作的原子性。
源头之三:编译优化带来的有序性问题
那并发编程里还有没有其他有违直觉容易导致诡异Bug的技术呢有的就是有序性。顾名思义有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能有时候会改变程序中语句的先后顺序例如程序中“a=6b=7”编译器优化后可能变成“b=7a=6在这个例子中编译器调整了语句的顺序但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
在Java领域一个经典的案例就是利用双重检查创建单例对象例如下面的代码在获取实例getInstance()的方法中我们首先判断instance是否为空如果为空则锁定Singleton.class并再次检查instance是否为空如果还为空则创建Singleton的一个实例。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假设有两个线程A、B同时调用getInstance()方法,他们会同时发现 instance == null 于是同时对Singleton.class加锁此时JVM保证只有一个线程能够加锁成功假设是线程A另外一个线程则会处于等待状态假设是线程B线程A会创建一个Singleton实例之后释放锁锁释放后线程B被唤醒线程B再次尝试加锁此时是可以加锁成功的加锁成功后线程B检查 instance == null 时会发现已经创建过Singleton实例了所以线程B不会再创建一个Singleton实例。
这看上去一切都很完美无懈可击但实际上这个getInstance()方法并不完美。问题出在哪里呢出在new操作上我们以为的new操作应该是
分配一块内存M
在内存M上初始化Singleton对象
然后M的地址赋值给instance变量。
但是实际上优化后的执行路径却是这样的:
分配一块内存M
将M的地址赋值给instance变量
最后在内存M上初始化Singleton对象。
优化后会导致什么问题呢我们假设线程A先执行getInstance()方法当执行完指令2时恰好发生了线程切换切换到了线程B上如果此时线程B也执行getInstance()方法那么线程B在执行第一个判断时会发现 instance != null 所以直接返回instance而此时的instance是没有初始化过的如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
双重检查创建单例的异常执行路径
总结
要写好并发程序首先要知道并发程序的问题在哪里只有确定了“靶子”才有可能把问题解决毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头但是深究的话无外乎就是直觉欺骗了我们只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理很多并发Bug都是可以理解、可以诊断的。
在介绍可见性、原子性、有序性的时候,特意提到缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。
我们这个专栏在讲解每项技术的时候,都会尽量将每项技术解决的问题以及产生的问题讲清楚,也希望你能够在这方面多思考、多总结。
课后思考
常听人说在32位的机器上对long型变量进行加减操作存在并发隐患到底是不是这样呢现在相信你一定能分析出来。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,208 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 Java内存模型看Java如何解决可见性和有序性问题
上一期我们讲到在并发场景中因可见性、原子性、有序性导致的问题常常会违背我们的直觉从而成为并发编程的Bug之源。这三者在编程领域属于共性问题所有的编程语言都会遇到Java在诞生之初就支持多线程自然也有针对这三者的技术方案而且在编程语言领域处于领先地位。理解Java解决并发问题的解决方案对于理解其他语言的解决方案有触类旁通的效果。
那我们就先来聊聊如何解决其中的可见性和有序性导致的问题这也就引出来了今天的主角——Java内存模型。
Java内存模型这个概念在职场的很多面试中都会考核到是一个热门的考点也是一个人并发水平的具体体现。原因是当并发程序出问题时需要一行一行地检查代码这个时候只有掌握Java内存模型才能慧眼如炬地发现问题。
什么是Java内存模型
你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
Java内存模型是个很复杂的规范可以从不同的视角来解读站在我们这些程序员的视角本质上可以理解为Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。
使用volatile的困惑
volatile关键字并不是Java语言的特产古老的C语言里也有它最原始的意义就是禁用CPU缓存。
例如我们声明一个volatile变量 volatile int x = 0它表达的是告诉编译器对这个变量的读写不能使用CPU缓存必须从内存中读取或者写入。这个语义看上去相当明确但是在实际使用的时候却会带来困惑。
例如下面的示例代码假设线程A执行writer()方法,按照 volatile 语义,会把变量 “v=true” 写入内存假设线程B执行reader()方法,同样按照 volatile 语义线程B会从内存中读取变量v如果线程B看到 “v == true” 时那么线程B看到的变量x是多少呢
直觉上看应该是42那实际应该是多少呢这个要看Java的版本如果在低于1.5版本上运行x可能是42也有可能是0如果在1.5以上的版本上运行x就是等于42。
// 以下代码来源于【参考1】
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里x会是多少呢
}
}
}
分析一下为什么1.5以前的版本会出现x = 0的情况呢我相信你一定想到了变量x可能被CPU缓存而导致可见性问题。这个问题在1.5版本已经被圆满解决了。Java内存模型在1.5版本对volatile语义进行了增强。怎么增强的呢答案是一项 Happens-Before 规则。
Happens-Before 规则
如何理解 Happens-Before 呢如果望文生义很多网文也都爱按字面意思翻译成“先行发生”那就南辕北辙了Happens-Before 并不是说前面一个操作发生在后续操作的前面它真正要表达的是前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人虽然远隔千里一个人心之所想另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。所以比较正式的说法是Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
Happens-Before 规则应该是Java内存模型里面最晦涩的内容了和程序员相关的规则一共有如下六项都是关于可见性的。
恰好前面示例代码涉及到这六项规则中的前三项为便于你理解我也会分析上面的示例代码来看看规则1、2和3到底该如何理解。至于其他三项我也会结合其他例子作以说明。
1. 程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的比如刚才那段示例代码按照程序的顺序第6行代码 “x = 42;” Happens-Before 于第7行代码 “v = true;”这就是规则1的内容也比较符合单线程里面的思维程序前面对某个变量的修改一定是对后续操作可见的。
(为方便你查看,我将那段示例代码在这儿再呈现一遍)
// 以下代码来源于【参考1】
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里x会是多少呢
}
}
}
2. volatile变量规则
这条规则是指对一个volatile变量的写操作 Happens-Before 于后续对这个volatile变量的读操作。
这个就有点费解了对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见这怎么看都是禁用缓存的意思啊貌似和1.5版本以前的语义没有变化啊如果单看这个规则的确是这样但是如果我们关联一下规则3就有点不一样的感觉了。
3. 传递性
这条规则是指如果A Happens-Before B且B Happens-Before C那么A Happens-Before C。
我们将规则3的传递性应用到我们的例子中会发生什么呢可以看下面这幅图
示例代码中的传递性规则
从图中,我们可以看到:
“x=42” Happens-Before 写变量 “v=true” 这是规则1的内容
写变量“v=true” Happens-Before 读变量 “v=true”这是规则2的内容 。
再根据这个传递性规则我们得到结果“x=42” Happens-Before 读变量“v=true”。这意味着什么呢
如果线程B读到了“v=true”那么线程A设置的“x=42”对线程B是可见的。也就是说线程B能看到 “x == 42” 有没有一种恍然大悟的感觉这就是1.5版本对volatile语义的增强这个增强意义重大1.5版本的并发工具包java.util.concurrent就是靠volatile语义来搞定可见性的这个在后面的内容中会详细介绍。
4. 管程中锁的规则
这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
要理解这个规则就首先要了解“管程指的是什么”。管程是一种通用的同步原语在Java中指的就是synchronizedsynchronized是Java里对管程的实现。
管程中的锁在Java里是隐式实现的例如下面的代码在进入同步块之前会自动加锁而在代码块执行完会自动释放锁加锁以及释放锁都是编译器帮我们实现的。
synchronized (this) { //此处自动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
所以结合规则4——管程中锁的规则可以这样理解假设x的初始值是10线程A执行完代码块后x的值会变成12执行完自动释放锁线程B进入代码块时能够看到线程A对x的写操作也就是线程B能够看到x==12。这个也是符合我们直觉的应该不难理解。
5. 线程 start() 规则
这条是关于线程启动的它是指主线程A启动子线程B后子线程B能够看到主线程在启动子线程B前的操作
换句话说就是如果线程A调用线程B的 start() 方法即在线程A中启动线程B那么该start()操作 Happens-Before 于线程B中的任意操作具体可参考下面示例代码
Thread B = new Thread(()->{
// 主线程调用B.start()之前
// 所有对共享变量的修改,此处皆可见
// 此例中var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();
6. 线程 join() 规则
这条是关于线程等待的。它是指主线程A等待子线程B完成主线程A通过调用子线程B的join()方法实现当子线程B完成后主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
换句话说就是如果在线程A中调用线程B的 join() 并成功返回那么线程B中的任意操作Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中var==66
被我们忽视的final
前面我们讲volatile为的是禁用缓存以及编译优化我们再从另外一个方面来看有没有办法告诉编译器优化得更好一点呢这个可以有就是final关键字。
final修饰变量时初衷是告诉编译器这个变量生而不变可以可劲儿优化。Java编译器在1.5以前的版本的确优化得很努力,以至于都优化错了。
问题类似于上一期提到的利用双重检查方法创建单例构造函数的错误重排导致线程可能看到final变量的值会变化。详细的案例可以参考这个文档。
当然了在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”就不会出问题了。
“逸出”有点抽象我们还是举个例子吧在下面例子中在构造函数里面将this赋值给了全局变量global.obj这就是“逸出”线程通过global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。
// 以下代码来源于【参考1】
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出
global.obj = this;
}
总结
Java的内存模型是并发编程领域的一次重要创新之后C++、C#、Golang等高级语言都开始支持内存模型。Java内存模型里面最晦涩的部分就是Happens-Before规则了Happens-Before规则最初是在一篇叫做Time, Clocks, and the Ordering of Events in a Distributed System的论文中提出来的在这篇论文中Happens-Before的语义是一种因果关系。在现实世界里如果A事件是导致B事件的起因那么A事件一定是先于Happens-BeforeB事件发生的这个就是Happens-Before语义的现实理解。
在Java语言里面Happens-Before的语义本质上是一种可见性A Happens-Before B 意味着A事件对B事件来说是可见的无论A事件和B事件是否发生在同一个线程里。例如A事件发生在线程1上B事件发生在线程2上Happens-Before规则保证线程2上也能看到A事件的发生。
Java内存模型主要分为两部分一部分面向你我这种编写并发程序的应用开发人员另一部分是面向JVM的实现人员的我们可以重点关注前者也就是和编写并发程序相关的部分这部分内容的核心就是Happens-Before规则。相信经过本章的介绍你应该对这部分内容已经有了深入的认识。
课后思考
有一个共享变量 abc在一个线程里设置了abc的值 abc=3你思考一下有哪些办法可以让其他线程能够看到abc==3
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
参考
JSR 133 (Java Memory Model) FAQ
Java内存模型FAQ
JSR-133: JavaTM Memory Model and Thread Specification

View File

@ -0,0 +1,195 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 互斥锁(上):解决原子性问题
在第一篇文章中我们提到一个或者多个操作在CPU执行的过程中不被中断的特性称为“原子性”。理解这个特性有助于你分析并发编程Bug出现的原因例如利用它可以分析出long型变量在32位机器上读写可能出现的诡异Bug明明已经把变量成功写入内存重新读出来却不是自己写入的。
那原子性问题到底该如何解决呢?
你已经知道原子性问题的源头是线程切换如果能够禁用线程切换那不就能解决这个问题了吗而操作系统做线程切换是依赖CPU中断的所以禁止CPU发生中断就能够禁止线程切换。
在早期单核CPU时代这个方案的确是可行的而且也有很多应用案例但是并不适合多核场景。这里我们以32位CPU上执行long型变量的写操作为例来说明这个问题long型变量是64位在32位CPU上执行写操作会被拆分成两次写操作写高32位和写低32位如下图所示
在单核CPU场景下同一时刻只有一个线程执行禁止CPU中断意味着操作系统不会重新调度线程也就是禁止了线程切换获得CPU使用权的线程就可以不间断地执行所以两次写操作一定是要么都被执行要么都没有被执行具有原子性。
但是在多核场景下同一时刻有可能有两个线程同时在执行一个线程执行在CPU-1上一个线程执行在CPU-2上此时禁止CPU中断只能保证CPU上的线程连续执行并不能保证同一时刻只有一个线程执行如果这两个线程同时写long型变量高32位的话那就有可能出现我们开头提及的诡异Bug了。
“同一时刻只有一个线程执行”这个条件非常重要我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的那么无论是单核CPU还是多核CPU就都能保证原子性了。
简易锁模型
当谈到互斥,相信聪明的你一定想到了那个杀手级解决方案:锁。同时大脑中还会出现以下模型:
简易锁模型
我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前首先尝试加锁lock()如果成功则进入临界区此时我们称这个线程持有锁否则呢就等待直到持有锁的线程解锁持有锁的线程执行完临界区的代码后执行解锁unlock()。
这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。很长时间里,我也是这么理解的。这样理解本身没有问题,但却很容易让我们忽视两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?
改进后的锁模型
我们知道在现实世界里,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。
改进后的锁模型
首先我们要把临界区要保护的资源标注出来如图中临界区里增加了一个元素受保护的资源R其次我们要保护资源R就得为它创建一把锁LR最后针对这把锁LR我们还需在进出临界区时添上加锁操作和解锁操作。另外在锁LR和受保护资源之间我特地用一条线做了关联这个关联关系非常重要。很多并发Bug的出现都是因为把它忽略了然后就出现了类似锁自家门来保护他家资产的事情这样的Bug非常不好诊断因为潜意识里我们认为已经正确加锁了。
Java语言提供的锁技术synchronized
锁是一种通用的技术方案Java语言提供的synchronized关键字就是锁的一种实现。synchronized关键字可以用来修饰方法也可以用来修饰代码块它的使用示例基本上都是下面这个样子
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object()
void baz() {
synchronized(obj) {
// 临界区
}
}
}
看完之后你可能会觉得有点奇怪这个和我们上面提到的模型有点对不上号啊加锁lock()和解锁unlock()在哪里呢其实这两个操作都是有的只是这两个操作是被Java默默加上的Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock()这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的毕竟忘记解锁unlock()可是个致命的Bug意味着其他线程只能死等下去了
那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢上面的代码我们看到只有修饰代码块的时候锁定了一个obj对象那修饰方法的时候锁定的是什么呢这个也是Java的一条隐式规则
当修饰静态方法的时候锁定的是当前类的Class对象在上面的例子中就是Class X-
当修饰非静态方法的时候锁定的是当前实例对象this。
对于上面的例子synchronized修饰静态方法相当于:
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
修饰非静态方法,相当于:
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}
用synchronized解决count+=1问题
相信你一定记得我们前面文章中提到过的count+=1存在的并发问题现在我们可以尝试用synchronized来小试牛刀一把代码如下所示。SafeCalc这个类有两个方法一个是get()方法用来获得value的值另一个是addOne()方法用来给value加1并且addOne()方法我们用synchronized修饰。那么我们使用的这两个方法有没有并发问题呢
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
我们先来看看addOne()方法首先可以肯定被synchronized修饰后无论是单核CPU还是多核CPU只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题呢?要回答这问题,就要重温一下上一篇文章中提到的管程中锁的规则。
管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
管程就是我们这里的synchronized至于为什么叫管程我们后面介绍我们知道synchronized修饰的临界区是互斥的也就是说同一时刻只有一个线程执行临界区的代码而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”指的是前一个线程的解锁操作对后一个线程的加锁操作可见综合Happens-Before的传递性原则我们就能得出前一个线程在临界区修改的共享变量该操作在解锁之前对后续进入临界区该操作在加锁之后的线程是可见的。
按照这个规则如果多个线程同时执行addOne()方法可见性是可以保证的也就说如果有1000个线程执行addOne()方法最终结果一定是value的值增加了1000。看到这个结果我们长出一口气问题终于解决了。
但也许你一不小心就忽视了get()方法。执行addOne()方法后value的值对get()方法是可见的吗这个可见性是没法保证的。管程中锁的规则是只保证后续对这个锁的加锁的可见性而get()方法并没有加锁操作所以可见性没法保证。那如何解决呢很简单就是get()方法也synchronized一下完整的代码如下所示。
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
上面的代码转换为我们提到的锁模型就是下面图示这个样子。get()方法和addOne()方法都需要访问value这个受保护的资源这个资源用this这把锁来保护。线程要进入临界区get()和addOne()必须先获得this这把锁这样get()和addOne()也是互斥的。
保护临界区get()和addOne()的示意图
这个模型更像现实世界里面球赛门票的管理一个座位只允许一个人使用这个座位就是“受保护资源”球场的入口就是Java类里的方法而门票就是用来保护资源的“锁”Java里的检票工作是由synchronized解决的。
锁和受保护资源的关系
我们前面提到受保护资源和锁之间的关联关系非常重要他们的关系是怎样的呢一个合理的关系是受保护资源和锁之间的关联关系是N:1的关系。还拿前面球赛门票的管理来类比就是一个座位我们只能用一张票来保护如果多发了重复的票那就要打架了。现实世界里我们可以用多把锁来保护同一个资源但在并发领域是不行的并发领域的锁和现实世界的锁不是完全匹配的。不过倒是可以用同一把锁来保护多个资源这个对应到现实世界就是我们所谓的“包场”了。
上面那个例子我稍作改动把value改成静态变量把addOne()方法改成静态方法此时get()方法和addOne()方法是否存在并发问题呢?
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
如果你仔细观察就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value两个锁分别是this和SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区get()和addOne()是用两个锁保护的因此这两个临界区没有互斥关系临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。
两把锁保护一个资源的示意图
总结
互斥锁,在并发领域的知名度极高,只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似于球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
synchronized是Java在语言层面提供的互斥原语其实Java里面还有很多其他类型的锁但作为互斥锁原理都是相通的一定有一个要锁定的对象至于这个锁定的对象要保护的资源以及在哪里加锁/解锁,就属于设计层面的事情了。
课后思考
下面的代码用synchronized修饰代码块来尝试解决并发问题你觉得这个使用方式正确吗有哪些问题呢能解决可见性和原子性问题吗
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,178 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 互斥锁(下):如何用一把锁保护多个资源?
在上一篇文章中我们提到受保护资源和锁之间合理的关联关系应该是N:1的关系也就是说可以用一把锁来保护多个资源但是不能用多把锁来保护一个资源并且结合文中示例我们也重点强调了“不能用多把锁来保护一个资源”这个问题。而至于如何保护多个资源我们今天就来聊聊。
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
保护没有关联关系的多个资源
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。
同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
相关的示例代码如下账户类Account有两个成员变量分别是账户余额balance和账户密码password。取款withdraw()和查看余额getBalance()操作会访问账户余额balance我们创建一个final对象balLock作为锁类比球赛门票而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password我们创建一个final对象pwLock作为锁类比电影票。不同的资源用不同的锁保护各自管各自的很简单。
class Account {
// 锁:保护账户余额
private final Object balLock
= new Object();
// 账户余额
private Integer balance;
// 锁:保护账户密码
private final Object pwLock
= new Object();
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balLock) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 查看余额
Integer getBalance() {
synchronized(balLock) {
return balance;
}
}
// 更改密码
void updatePassword(String pw){
synchronized(pwLock) {
this.password = pw;
}
}
// 查看密码
String getPassword() {
synchronized(pwLock) {
return password;
}
}
}
当然我们也可以用一把互斥锁来保护多个资源例如我们可以用this这一把锁来管理账户类里所有的资源账户余额和用户密码。具体实现很简单示例程序中所有的方法都增加同步关键字synchronized就可以了这里我就不一一展示了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
保护有关联关系的多个资源
如果多个资源是有关联关系的那这个问题就有点复杂了。例如银行业务里面的转账操作账户A减少100元账户B增加100元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作我们应该怎么去解决呢先把这个问题代码化。我们声明了个账户类Account该类有一个成员变量余额balance还有一个用于转账的方法transfer()然后怎么保证转账操作transfer()没有并发问题呢?
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
相信你的直觉会告诉你这样的解决方案用户synchronized关键字修饰一下transfer()方法就可以了,于是你很快就完成了相关的代码,如下所示。
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
在这段代码中临界区内有两个资源分别是转出账户的余额this.balance和转入账户的余额target.balance并且用的是一把锁this符合我们前面提到的多个资源可以用一把锁来保护这看上去完全正确呀。真的是这样吗可惜这个方案仅仅是看似正确为什么呢
问题就出在this这把锁上this这把锁可以保护自己的余额this.balance却保护不了别人的余额target.balance就像你不能用自家的锁来保护别人家的资产也不能用自己的票来保护别人的座位一样。
用锁this保护this.balance和target.balance的示意图
下面我们具体分析一下假设有A、B、C三个账户余额都是200元我们用两个线程分别执行两个转账操作账户A转给账户B 100 元账户B转给账户C 100 元最后我们期望的结果应该是账户A的余额是100元账户B的余额是200元 账户C的余额是300元。
我们假设线程1执行账户A转账户B的操作线程2执行账户B转账户C的操作。这两个线程分别在两颗CPU上同时执行那它们是互斥的吗我们期望是但实际上并不是。因为线程1锁定的是账户A的实例A.this而线程2锁定的是账户B的实例B.this所以这两个线程可以同时进入临界区transfer()。同时进入临界区的结果是什么呢线程1和线程2都会读到账户B的余额为200导致最终账户B的余额可能是300线程1后于线程2写B.balance线程2写的B.balance值被线程1覆盖可能是100线程1先于线程2写B.balance线程1写的B.balance值被线程2覆盖就是不可能是200。
并发转账示意图
使用锁的正确姿势
在上一篇文章中我们提到用同一把锁来保护多个资源也就是现实世界的“包场”那在编程领域应该怎么“包场”呢很简单只要我们的锁能覆盖所有受保护资源就可以了。在上面的例子中this是对象级别的锁所以A对象和B对象都有自己的锁如何让A对象和B对象共享一把锁呢
稍微开动脑筋你会发现其实方案还挺多的比如可以让所有对象都持有一个唯一性的对象这个对象在创建Account时传入。方案有了完成代码就简单了。示例代码如下我们把Account默认构造函数变为private同时增加一个带Object lock参数的构造函数创建Account对象时传入相同的lock这样所有的Account对象都会共享这个lock了。
class Account {
private Object lock
private int balance;
private Account();
// 创建Account时传入同一个lock对象
public Account(Object lock) {
this.lock = lock;
}
// 转账
void transfer(Account target, int amt){
// 此处检查所有对象共享的锁
synchronized(lock) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
这个办法确实能解决问题但是有点小瑕疵它要求在创建Account对象的时候必须传入同一个对象如果创建Account对象时传入的lock不是同一个对象那可就惨了会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中创建Account对象的代码很可能分散在多个工程中传入共享的lock真的很难。
所以上面的方案缺乏实践的可行性我们需要更好的方案。还真有就是用Account.class作为共享的锁。Account.class是所有Account对象共享的而且这个对象是Java虚拟机在加载Account类的时候创建的所以我们不用担心它的唯一性。使用Account.class作为共享的锁我们就无需在创建Account对象时传入了代码更简单。
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
下面这幅图很直观地展示了我们是如何使用共享的锁Account.class来保护不同对象的临界区的。
总结
相信你看完这篇文章后,对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
我们再引申一下上面提到的关联关系关联关系如果用更具体、更专业的语言来描述的话其实是一种“原子性”特征在前面的文章中我们提到的原子性主要是面向CPU指令的转账操作的原子性则是属于是面向高级语言的不过它们本质上是一样的。
“原子性”的本质是什么其实不是不可分割不可分割只是外在表现其本质是多个资源间有一致性的要求操作的中间状态对外不可见。例如在32位的机器上写long型变量有中间状态只写了64位中的32位在银行转账的操作中也有中间状态账户A减少了100账户B还没来得及发生变化。所以解决原子性问题是要保证中间状态对外不可见。
课后思考
在第一个示例程序里我们用了两把不同的锁来分别保护账户余额、账户密码创建锁的时候我们用的是private final Object xxxLock = new Object();,如果账户余额用 this.balance 作为互斥锁账户密码用this.password作为互斥锁你觉得是否可以呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,235 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 一不小心就死锁了,怎么办?
在上一篇文章中我们用Account.class作为互斥锁来解决银行业务里面的转账问题虽然这个方案不存在并发问题但是所有账户的转账操作都是串行的例如账户A 转账户B、账户C 转账户D这两个转账操作现实世界里是可以并行的但是在这个方案里却被串行化了这样的话性能太差。
试想互联网支付盛行的当下8亿网民每人每天一笔交易每天就是8亿笔交易每笔交易都对应着一次转账操作8亿笔交易就是8亿次转账操作也就是说平均到每秒就是近1万次转账操作若所有的转账操作都串行性能完全不能接受。
那下面我们就尝试着把性能提升一下。
向现实世界要答案
现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。
我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:
文件架上恰好有转出账本和转入账本,那就同时拿走;
如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
上面这个过程在编程的世界里怎么实现呢其实用两把锁就实现了转出账本一把转入账本另一把。在transfer()方法内部我们首先尝试锁定转出账户this先把转出账本拿到手然后尝试锁定转入账户target再把转入账本拿到手只有当两者都成功时才执行转账操作。这个逻辑可以图形化为下图这个样子。
两个转账操作并行示意图
而至于详细的代码实现如下所示。经过这样的优化后账户A 转账户B和账户C 转账户D这两个转账操作就可以并行了。
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) {
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
没有免费的午餐
上面的实现看上去很完美并且也算是将锁用得出神入化了。相对于用Account.class作为互斥锁锁定的范围太大而我们锁定两个账户范围就小多了这样的锁上一章我们介绍过叫细粒度锁。使用细粒度锁可以提高并行度是性能优化的一个重要手段。
这个时候可能你已经开始警觉了,使用细粒度锁这么简单,有这样的好事,是不是也要付出点什么代价啊?编写并发程序就需要这样时时刻刻保持谨慎。
的确,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
在详细介绍死锁之前我们先看看现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务账户A 转账户B 100元此时另一个客户找柜员李四也做个转账业务账户B 转账户A 100 元于是张三和李四同时都去文件架上拿账本这时候有可能凑巧张三拿到了账本A李四拿到了账本B。张三拿到账本A后就等着账本B账本B已经被李四拿走而李四拿到账本B后就等着账本A账本A已经被张三拿走他们要等多久呢他们会永远等待下去…因为张三不会把账本A送回去李四也不会把账本B送回去。我们姑且称为死等吧。
转账业务中的“死等”
现实世界里的死等,就是编程领域的死锁了。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
上面转账的代码是怎么发生死锁的呢我们假设线程T1执行账户A转账户B的操作账户A.transfer(账户B)同时线程T2执行账户B转账户A的操作账户B.transfer(账户A)。当T1和T2同时执行完①处的代码时T1获得了账户A的锁对于T1this是账户A而T2获得了账户B的锁对于T2this是账户B。之后T1和T2在执行②处的代码时T1试图获取账户B的锁时发现账户B已经被锁定被T2锁定所以T1开始等待T2则试图获取账户A的锁时发现账户A已经被锁定被T1锁定所以T2也开始等待。于是T1和T2会无期限地等待下去也就是我们所说的死锁了。
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this){ ①
// 锁定转入账户
synchronized(target){ ②
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。转账发生死锁时的资源分配图就如下图所示,一个“各据山头死等”的尴尬局面。
转账发生死锁时的资源分配图
如何预防死锁
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
那如何避免死锁呢要避免死锁就需要分析死锁发生的条件有个叫Coffman的牛人早就总结过了只有以下这四个条件都发生时才会出现死锁
互斥共享资源X和Y只能被一个线程占用
占有且等待线程T1已经取得共享资源X在等待共享资源Y的时候不释放共享资源X
不可抢占其他线程不能强行抢占线程T1占有的资源
循环等待线程T1等待线程T2占有的资源线程T2等待线程T1占有的资源就是循环等待。
反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
我们已经从理论上解决了如何预防死锁,那具体如何体现在代码上呢?下面我们就来尝试用代码实践一下这些理论。
1. 破坏占用且等待条件
从理论上讲,要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?
可以增加一个账本管理员然后只允许账本管理员从文件架上拿账本也就是说柜员不能直接在文件架上拿账本必须通过账本管理员才能拿到想要的账本。例如张三同时申请账本A和B账本管理员如果发现文件架上只有账本A这个时候账本管理员是不会把账本A拿下来给张三的只有账本A和B都在的时候才会给张三。这样就保证了“一次性申请所有资源”。
通过账本管理员拿账本
对应到编程领域“同时申请”这个操作是一个临界区我们也需要一个角色Java里面的类来管理这个临界区我们就把这个角色定为Allocator。它有两个重要功能分别是同时申请资源apply()和同时释放资源free()。账户Account 类里面持有一个Allocator的单例必须是单例只能由一个人来分配资源。当账户Account在执行转账操作的时候首先向Allocator同时申请转出账户和转入账户这两个资源成功后再锁定这两个资源当转账操作执行完释放锁之后我们需通知Allocator同时释放转出账户和转入账户这两个资源。具体的代码实现如下。
class Allocator {
private List<Object> als =
new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply(
Object from, Object to){
if(als.contains(from) ||
als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 归还资源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
}
}
class Account {
// actr应该为单例
private Allocator actr;
private int balance;
// 转账
void transfer(Account target, int amt){
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
try{
// 锁定转出账户
synchronized(this){
// 锁定转入账户
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
2. 破坏不可抢占条件
破坏不可抢占条件看上去很简单核心是要能够主动释放它占有的资源这一点synchronized是做不到的。原因是synchronized申请资源的时候如果申请不到线程直接进入阻塞状态了而线程进入阻塞状态啥都干不了也释放不了线程已经占有的资源。
你可能会质疑“Java作为排行榜第一的语言这都解决不了”你的怀疑很有道理Java在语言层次确实没有解决这个问题不过在SDK层面还是解决了的java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。关于这个话题咱们后面会详细讲。
3. 破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 id这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户this和转入账户target排序然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
class Account {
private int id;
private int balance;
// 转账
void transfer(Account target, int amt){
Account left = this ①
Account right = target; ②
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 锁定序号小的账户
synchronized(left){
// 锁定序号大的账户
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
总结
当我们在编程世界里遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,利用现实世界的模型来构思解决方案,这样往往能够让我们的方案更容易理解,也更能够看清楚问题的本质。
但是现实世界的模型有些细节往往会被我们忽视。因为在现实世界里,人太智能了,以致有些细节实在是显得太不重要了。在转账的模型中,我们为什么会忽视死锁问题呢?原因主要是在现实世界,我们会交流,并且会很智能地交流。而编程世界里,两个线程是不会智能地交流的。所以在利用现实模型建模的时候,我们还要仔细对比现实世界和编程世界里的各角色之间的差异。
我们今天这一篇文章主要讲了用细粒度锁来锁定多个资源时,要注意死锁的问题。这个就需要你能把它强化为一个思维定势,遇到这种场景,马上想到可能存在死锁问题。当你知道风险之后,才有机会谈如何预防和避免,因此,识别出风险很重要。
预防死锁主要是破坏三个条件中的一个,有了这个思路后,实现就简单了。但仍需注意的是,有时候预防死锁成本也是很高的。例如上面转账那个例子,我们破坏占用且等待条件的成本就比破坏循环等待条件的成本高,破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));方法不过好在apply()这个方法基本不耗时。 在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。
所以我们在选择具体方案的时候,还需要评估一下操作成本,从中选择一个成本最低的方案。
课后思考
我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));这个方法那它比synchronized(Account.class)有没有性能优势呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,139 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 用“等待-通知”机制优化循环等待
由上一篇文章你应该已经知道,在破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待,核心代码如下:
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
如果apply()操作耗时非常短而且并发冲突量也不大时这个方案还挺不错的因为这种场景下循环上几次或者几十次就能一次性获取转出账户和转入账户了。但是如果apply()操作耗时长或者并发冲突量大的时候循环等待这种方案就不适用了因为在这种场景下可能要循环上万次才能获取到锁太消耗CPU了。
其实在这种场景下最好的方案应该是如果线程要求的条件转出账本和转入账本同在文件架上不满足则线程阻塞自己进入等待状态当线程要求的条件转出账本和转入账本同在文件架上满足后通知等待的线程重新执行。其中使用线程阻塞的方式就能避免循环等待消耗CPU的问题。
那Java语言是否支持这种等待-通知机制呢答案是一定支持毕竟占据排行榜第一那么久。下面我们就来看看Java语言是如何支持等待-通知机制的。
完美的就医流程
在介绍Java语言如何支持等待-通知机制之前,我们先看一个现实世界里面的就医流程,因为它有着完善的等待-通知机制,所以对比就医流程,我们就能更好地理解和应用并发编程中的等待-通知机制。
就医流程基本上是这样:
患者先去挂号,然后到就诊门口分诊,等待叫号;
当叫到自己的号时,患者就可以找大夫就诊了;
就诊过程中,大夫可能会让患者去做检查,同时叫下一位患者;
当患者做完检查后,拿检测报告重新分诊,等待叫号;
当大夫再次叫到自己的号时,患者再去找大夫就诊。
或许你已经发现了,这个有着完美等待-通知机制的就医流程,不仅能够保证同一时刻大夫只为一个患者服务,而且还能够保证大夫和患者的效率。与此同时你可能也会有疑问,“这个就医流程很复杂呀,我们前面描述的等待-通知机制相较而言是不是太简单了?”那这个复杂度是否是必须的呢?这个是必须的,我们不能忽视等待-通知机制中的一些细节。
下面我们来对比看一下前面都忽视了哪些细节。
患者到就诊门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了。
大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的条件没有满足。
患者去做检查,类似于线程进入等待状态;然后大夫叫下一个患者,这个步骤我们在前面的等待-通知机制中忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁。
患者做完检查,类似于线程要求的条件已经满足;患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待-通知机制中也忽视了。
所以加上这些至关重要的细节,综合一下,就可以得出一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
用synchronized实现等待-通知机制
在Java语言里等待-通知机制可以有多种实现方式比如Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法就能轻松实现。
如何用synchronized实现互斥锁你应该已经很熟悉了。在下面这个图里左边有一个等待队列同一时刻只允许一个线程进入synchronized保护的临界区这个临界区可以看作大夫的诊室当有一个线程进入临界区后其他线程就只能进入图中左边的等待队列里等待相当于患者分诊等待。这个等待队列和互斥锁是一对一的关系每个互斥锁都有自己独立的等待队列。
wait()操作工作原理图
在并发程序中当一个线程进入临界区后由于某些条件不满足需要进入等待状态Java对象的wait()方法就能够满足这种需求。如上图所示当调用wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
那线程要求的条件满足时该怎么通知这个等待的线程呢很简单就是Java对象的notify()和notifyAll()方法。我在下面这个图里为你大致描述了这个过程当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
notify()操作工作原理图
为什么说是曾经满足过呢因为notify()只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点你需要格外注意。
除此之外还有一个需要注意的点被通知的线程要想重新执行仍然需要获取到互斥锁因为曾经获取的锁在调用wait()时已经释放了)。
上面我们一直强调wait()、notify()、notifyAll()方法操作的等待队列是互斥锁的等待队列所以如果synchronized锁定的是this那么对应的一定是this.wait()、this.notify()、this.notifyAll()如果synchronized锁定的是target那么对应的一定是target.wait()、target.notify()、target.notifyAll() 。而且wait()、notify()、notifyAll()这三个方法能够被调用的前提是已经获取了相应的互斥锁所以我们会发现wait()、notify()、notifyAll()都是在synchronized{}内部被调用的。如果在synchronized{}外部调用或者锁定的this而用target.wait()调用的话JVM会抛出一个运行时异常java.lang.IllegalMonitorStateException。
小试牛刀:一个更好地资源分配器
等待-通知机制的基本原理搞清楚后,我们就来看看它如何解决一次性申请转出账户和转入账户的问题吧。在这个等待-通知机制中,我们需要考虑以下四个要素。
互斥锁上一篇文章我们提到Allocator需要是单例的所以我们可以用this作为互斥锁。
线程要求的条件:转出账户和转入账户都没有被分配过。
何时等待:线程要求的条件不满足就等待。
何时通知:当有线程释放账户时就通知。
将上面几个问题考虑清楚,可以快速完成下面的代码。需要注意的是我们使用了:
while(条件不满足) {
wait();
}
利用这种范式可以解决上面提到的条件曾经满足过这个问题。因为当wait()返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。后面在介绍“管程”的时候,我会详细介绍这个经典做法的前世今生。
class Allocator {
private List<Object> als;
// 一次性申请所有资源
synchronized void apply(
Object from, Object to){
// 经典写法
while(als.contains(from) ||
als.contains(to)){
try{
wait();
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
// 归还资源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
notifyAll();
}
}
尽量使用notifyAll()
在上面的代码中我用的是notifyAll()来实现通知机制为什么不使用notify()呢这二者是有区别的notify()是会随机地通知等待队列中的一个线程而notifyAll()会通知等待队列中的所有线程。从感觉上来讲应该是notify()更好一些因为即便通知所有线程也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险实际上使用notify()也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
假设我们有资源A、B、C、D线程1申请到了AB线程2申请到了CD此时线程3申请AB会进入等待队列AB分配给线程1线程3要求的条件不满足线程4申请CD也会进入等待队列。我们再假设之后线程1归还了资源AB如果使用notify()来通知等待队列中的线程有可能被通知的是线程4但线程4申请的是CD所以此时线程4还是会继续等待而真正该唤醒的线程3就再也没有机会被唤醒了。
所以除非经过深思熟虑否则尽量使用notifyAll()。
总结
等待-通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有同学使用轮询的方式来等待某个状态,其实很多情况下都可以用今天我们介绍的等待-通知机制来优化。Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法可以快速实现这种机制但是它们的使用看上去还是有点复杂所以你需要认真理解等待队列和wait()、notify()、notifyAll()的关系。最好用现实世界做个类比,这样有助于你的理解。
Java语言的这种实现背后的理论模型其实是管程这个很重要不过你不用担心后面会有专门的一章来介绍管程。现在你只需要能够熟练使用就可以了。
课后思考
很多面试都会问到wait()方法和sleep()方法都能让当前线程挂起一段时间,那它们的区别是什么?现在你也试着回答一下吧。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,153 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 安全性、活跃性以及性能问题
通过前面六篇文章,我们开启了一个简单的并发旅程,相信现在你对并发编程需要注意的问题已经有了更深入的理解,这是一个很大的进步,正所谓只有发现问题,才能解决问题。但是前面六篇文章的知识点可能还是有点分散,所以是时候将其总结一下了。
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:安全性问题、活跃性问题和性能问题。下面我就来一一介绍这些问题。
安全性问题
相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等等。
那什么是线程安全呢其实本质上就是正确性而正确性的含义就是程序按照我们期望的执行不要让我们感到意外。在第一篇《可见性、原子性和有序性问题并发编程Bug的源头》中我们已经见识过很多诡异的Bug都是出乎我们预料的它们都没有按照我们期望的执行。
那如何才能写出线程安全的程序呢第一篇文章中已经介绍了并发Bug的三个主要源头原子性问题、可见性问题和有序性问题。也就是说理论上线程安全的程序就要避免出现原子性问题、可见性问题和有序性问题。
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢当然不是其实只有一种情况需要存在共享数据并且该数据会发生变化通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的例如线程本地存储Thread Local StorageTLS、不变模式等等后面我会详细介绍相关的技术方案是如何在Java语言中实现的。
但是,现实生活中,必须共享会发生变化的数据,这样的应用场景还是很多的。
当多个线程同时访问同一数据并且至少有一个线程会写这个数据的时候如果我们不采取防护措施那么就会导致并发Bug对此还有一个专业的术语叫做数据竞争Data Race。比如前面第一篇文章里有个add10K()的方法,当多个线程调用时候就会发生数据竞争,如下所示。
public class Test {
private long count = 0;
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
}
那是不是在访问数据的地方我们加个锁保护一下就能解决所有的并发问题了呢显然没有这么简单例如对于上面示例我们稍作修改增加两个被 synchronized 修饰的get()和set()方法 add10K()方法里面通过get()和set()方法来访问value变量修改后的代码如下所示对于修改后的代码所有访问共享变量value的地方我们都增加了互斥锁此时是不存在数据竞争的但很显然修改后的add10K()方法并不是线程安全的
public class Test {
private long count = 0;
synchronized long get(){
return count
}
synchronized void set(long v){
count = v;
}
void add10K() {
int idx = 0;
while(idx++ < 10000) {
set(get()+1)
}
}
}
假设count=0当两个线程同时执行get()方法时get()方法会返回相同的值0两个线程执行get()+1操作结果都是1之后两个线程再将结果1写入了内存。你本来期望的是2而结果却是1。
这种问题有个官方的称呼叫竞态条件Race Condition)。所谓竞态条件指的是程序的执行结果依赖线程执行的顺序例如上面的例子如果两个线程完全同时执行那么结果是1如果两个线程是前后执行那么结果就是2在并发环境里线程的执行顺序是不确定的如果程序存在竞态条件问题那就意味着程序执行的结果是不确定的而执行结果不确定这可是个大Bug
下面再结合一个例子来说明下竞态条件就是前面文章中提到的转账操作转账操作里面有个判断条件——转出金额不能大于账户余额但在并发环境里面如果不加控制当多个线程同时对一个账号执行转出操作时就有可能出现超额转出问题假设账户A有余额200线程1和线程2都要从账户A转出150在下面的代码里有可能线程1和线程2同时执行到第6行这样线程1和线程2都会发现转出金额150小于账户余额200于是就会发生超额转出的情况
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
所以你也可以按照下面这样来理解竞态条件。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:
if (状态变量 满足 执行条件) {
执行操作
}
当某个线程发现状态变量满足执行条件后开始执行操作可是就在这个线程执行操作的时候其他线程同时修改了状态变量导致状态变量不满足执行条件了。当然很多场景下这个条件不是显式的例如前面addOne的例子中set(get()+1)这个复合操作其实就隐式依赖get()的结果。
那面对数据竞争和竞态条件问题又该如何保证线程的安全性呢其实这两类问题都可以用互斥这个技术方案而实现互斥的方案有很多CPU提供了相关的互斥指令操作系统、编程语言也会提供相关的API。从逻辑上来看我们可以统一归为锁。前面几章我们也粗略地介绍了如何使用锁相信你已经胸中有丘壑了这里就不再赘述了你可以结合前面的文章温故知新。
活跃性问题
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
通过前面的学习你已经知道,发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程永久地“阻塞”了。
但有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
解决“活锁”的方案很简单谦让时尝试等待一个随机的时间就可以了。例如上面的那个例子路人甲走左手边发现前面有人并不是立刻换到右手边而是等待一个随机的时间后再换到右手边同样路人乙也不是立刻切换路线也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单却非常有效Raft这样知名的分布式一致性算法中也用到了它。
那“饥饿”该怎么去理解呢所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。“不患寡而患不均”如果线程优先级“不均”在CPU繁忙的情况下优先级低的线程得到执行的机会很小就可能发生线程“饥饿”持有锁的线程如果执行的时间过长也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
所以我们要尽量减少串行那串行对性能的影响是怎么样的呢假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?
有个阿姆达尔Amdahl定律代表了处理器并行运算之后效率提升的能力它正好可以解决这个问题具体公式如下
\(S=\\frac{1}{(1-p)+\\frac{p}{n}}\)
公式里的n可以理解为CPU的核数p可以理解为并行百分比1-p就是串行百分比了也就是我们假设的5%。我们再假设CPU的核数也就是n无穷大那加速比S的极限就是20。也就是说如果我们的串行率是5%那么我们无论采用什么技术最高也就只能提高20倍的性能。
所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢这个问题很复杂Java SDK并发包里之所以有那么多东西有很大一部分原因就是要提升在某个特定领域的性能。
不过从方案层面,我们可以这样来解决这个问题。
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储(Thread Local Storage, TLS)、写入时复制(Copy-on-write)、乐观锁等Java并发包里面的原子类也是一种无锁的数据结构Disruptor则是一个无锁的内存队列性能都非常好……
第二减少锁持有的时间。互斥锁本质上是将并行的程序串行化所以要增加并行度一定要减少持有锁的时间。这个方案具体的实现技术也有很多例如使用细粒度的锁一个典型的例子就是Java并发包里的ConcurrentHashMap它使用了所谓分段锁的技术这个技术后面我们会详细介绍还可以使用读写锁也就是读是无锁的只有写的时候才会互斥。
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。
吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
并发量指的是能同时处理的请求数量一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标一般都会是基于并发量来说的。例如并发量是1000的时候延迟是50毫秒。
总结
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。
要解决问题首先要把问题分析清楚。同样要写好并发程序首先要了解并发程序相关的问题经过这7章的内容相信你一定对并发程序相关的问题有了深入的理解同时对并发程序也一定心存敬畏因为一不小心就出问题了。不过这恰恰也是一个很好的开始因为你已经学会了分析并发问题然后解决并发问题也就不远了。
课后思考
Java语言提供的Vector是一个线程安全的容器有同学写了下面的代码你看看是否存在并发问题呢
void addIfNotExist(Vector v,
Object o){
if(!v.contains(o)) {
v.add(o);
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,182 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 管程:并发编程的万能钥匙
并发编程这个技术领域已经发展了半个世纪了相关的理论和技术纷繁复杂。那有没有一种核心技术可以很方便地解决我们的并发问题呢这个问题如果让我选择我一定会选择管程技术。Java语言在1.5之前提供的唯一的并发原语就是管程而且1.5之后提供的SDK并发包也是以管程技术为基础的。除此之外C/C++、C#等高级语言也都支持管程
可以这么说,管程就是一把解决并发问题的万能钥匙。
什么是管程
不知道你是否曾思考过这个问题为什么Java在1.5之前仅仅提供了synchronized关键字及wait()、notify()、notifyAll()这三个看似从天而降的方法在刚接触Java的时候我以为它会提供信号量这种编程原语因为操作系统原理课程告诉我用信号量能解决所有并发问题结果我发现不是。后来我找到了原因Java采用的是管程技术synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。而管程和信号量是等价的所谓等价指的是用管程能够实现信号量也能用信号量实现管程。但是管程更容易使用所以Java选择了管程。
管程对应的英文是Monitor很多Java领域的同学都喜欢将其翻译成“监视器”这是直译。操作系统领域一般都翻译成“管程”这个是意译而我自己也更倾向于使用“管程”。
所谓管程指的是管理共享变量以及对共享变量的操作过程让他们支持并发。翻译为Java领域的语言就是管理类的成员变量和成员方法让这个类是线程安全的。那管程是怎么管的呢
MESA模型
在管程的发展史上先后出现过三种不同的管程模型分别是Hasen模型、Hoare模型和MESA模型。其中现在广泛应用的是MESA模型并且Java管程的实现参考的也是MESA模型。所以今天我们重点介绍一下MESA模型。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
我们先来看看管程是如何解决互斥问题的。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
利用管程可以快速实现这个直观的想法。在下图中管程X将共享变量queue这个线程不安全的队列和相关的操作入队操作enq()、出队操作deq()都封装起来了线程A和线程B如果想访问共享变量queue只能通过调用管程提供的enq()、deq()方法来实现enq()、deq()保证互斥性,只允许一个线程进入管程。
不知你有没有发现管程模型和面向对象高度契合的。估计这也是Java选择管程的原因吧。而我在前面章节介绍的互斥锁用法其背后的模型其实就是它。
那管程如何解决线程间的同步问题呢?
这个就比较复杂了不过你可以借鉴一下我们曾经提到过的就医流程它可以帮助你快速地理解这个问题。为进一步便于你理解在下面我展示了一幅MESA管程模型示意图它详细描述了MESA模型的主要组成部分。
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
管程里还引入了条件变量的概念而且每个条件变量都对应有一个等待队列如下图条件变量A和条件变量B分别都有自己的等待队列。
那条件变量和条件变量等待队列的作用是什么呢?其实就是解决线程同步问题。你可以结合上面提到的阻塞队列的例子加深一下理解(阻塞队列的例子,是用管程来实现线程安全的阻塞队列,这个阻塞队列和管程内部的等待队列没有关系,本文中一定要注意阻塞队列和等待队列是不同的)。
假设有个线程T1执行阻塞队列的出队操作执行出队操作需要注意有个前提条件就是阻塞队列不能是空的空队列只能出Null值是不允许的阻塞队列不空这个前提条件对应的就是管程里的条件变量。 如果线程T1进入管程后恰好发现阻塞队列是空的那怎么办呢等待啊去哪里等呢就去条件变量对应的等待队列里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。这个过程类似于大夫发现你要去验个血于是给你开了个验血的单子你呢就去验血的队伍里排队。线程T1进入条件变量的等待队列后是允许其他线程进入管程的。这和你去验血的时候医生可以给其他患者诊治道理都是一样的。
再假设之后另外一个线程T2执行阻塞队列的入队操作入队操作执行成功之后“阻塞队列不空”这个条件对于线程T1来说已经满足了此时线程T2要通知T1告诉它需要的条件已经满足了。当线程T1得到通知后会从等待队列里面出来但是出来之后不是马上执行而是重新进入到入口等待队列里面。这个过程类似你验血完回来找大夫需要重新分诊。
条件变量及其等待队列我们讲清楚了下面再说说wait()、notify()、notifyAll()这三个操作。前面提到线程T1发现“阻塞队列不空”这个条件不满足需要进到对应的等待队列里等待。这个过程就是通过调用wait()来实现的。如果我们用对象A代表“阻塞队列不空”这个条件那么线程T1需要调用A.wait()。同理当“阻塞队列不空”这个条件满足时线程T2需要调用A.notify()来通知A等待队列中的一个线程此时这个等待队列里面只有线程T1。至于notifyAll()这个方法,它可以通知等待队列中的所有线程。
这里我还是来一段代码再次说明一下吧。下面的代码用管程实现了一个线程安全的阻塞队列(再次强调:这个阻塞队列和管程内部的等待队列没关系,示例代码只是用管程来实现阻塞队列,而不是解释管程内部等待队列的实现原理)。阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
对于阻塞队列的入队操作如果阻塞队列已满就需要等待直到阻塞队列不满所以这里用了notFull.await();。
对于阻塞出队操作如果阻塞队列为空就需要等待直到阻塞队列不空所以就用了notEmpty.await();。
如果入队成功那么阻塞队列就不空了就需要通知条件变量阻塞队列不空notEmpty对应的等待队列。
如果出队成功那就阻塞队列就不满了就需要通知条件变量阻塞队列不满notFull对应的等待队列。
public class BlockedQueue{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
//入队后,通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
在这段示例代码中我们用了Java并发包里面的Lock和Condition如果你看着吃力也没关系后面我们还会详细介绍这个例子只是先让你明白条件变量及其等待队列是怎么回事。需要注意的是await()和前面我们提到的wait()语义是一样的signal()和前面我们提到的notify()语义是一样的。
wait()的正确姿势
但是有一点需要再次提醒对于MESA管程来说有一个编程范式就是需要在一个while循环里面调用wait()。这个是MESA管程特有的。
while(条件不满足) {
wait();
}
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后如何通知相关线程。管程要求同一时刻只允许一个线程执行那当线程T2的操作使线程T1等待的条件满足时T1和T2究竟谁可以执行呢
Hasen模型里面要求notify()放在代码的最后这样T2通知完T1后T2就结束了然后T1再执行这样就能保证同一时刻只有一个线程执行。
Hoare模型里面T2通知完T1后T2阻塞T1马上执行等T1执行完再唤醒T2也能保证同一时刻只有一个线程执行。但是相比Hasen模型T2多了一次阻塞唤醒操作。
MESA管程里面T2通知完T1后T2还是会接着执行T1并不立即执行仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是notify()不用放到代码的最后T2也没有多余的阻塞唤醒操作。但是也有个副作用就是当T1再次执行的时候可能曾经满足的条件现在已经不满足了所以需要以循环方式检验条件变量。
notify()何时可以使用
还有一个需要注意的地方就是notify()和notifyAll()的使用前面章节我曾经介绍过除非经过深思熟虑否则尽量使用notifyAll()。那什么时候可以使用notify()呢?需要满足以下三个条件:
所有等待线程拥有相同的等待条件;
所有等待线程被唤醒后,执行相同的操作;
只需要唤醒一个线程。
比如上面阻塞队列的例子中对于“阻塞队列不满”这个条件变量其等待线程都是在等待“阻塞队列不满”这个条件反映在代码里就是下面这3行代码。对所有等待线程来说都是执行这3行代码重点是 while 里面的等待条件是完全相同的。
while (阻塞队列已满){
// 等待队列不满
notFull.await();
}
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
// 省略入队操作...
// 入队后,通知可出队
notEmpty.signal();
同时也满足第3条只需要唤醒一个线程。所以上面阻塞队列的代码使用signal()是可以的。
总结
管程是一个解决并发问题的模型,你可以参考医院就医的流程来加深理解。理解这个模型的重点在于理解条件变量及其等待队列的工作原理。
Java参考了MESA模型语言内置的管程synchronized对MESA模型进行了精简。MESA模型中条件变量可以有多个Java语言内置的管程里只有一个条件变量。具体如下图所示。
Java内置的管程方案synchronized使用简单synchronized关键字修饰的代码块在编译期会自动生成相关加锁和解锁的代码但是仅支持一个条件变量而Java SDK并发包实现的管程支持多个条件变量不过并发包里的锁需要开发人员自己进行加锁和解锁操作。
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。
课后思考
wait()方法在Hasen模型和Hoare模型里面都是没有参数的而在MESA模型里面增加了超时参数你觉得这个参数有必要吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,176 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 Java线程Java线程的生命周期
在Java领域实现并发程序的主要手段就是多线程。线程是操作系统里的一个概念虽然各种不同的开发语言如Java、C#等都对其进行了封装但是万变不离操作系统。Java语言里的线程本质上就是操作系统的线程它们是一一对应的。
在操作系统层面,线程也有“生老病死”,专业的说法叫有生命周期。对于有生命周期的事物,要学好它,思路非常简单,只要能搞懂生命周期中各个节点的状态转换机制就可以了。
虽然不同的开发语言对于操作系统线程进行了不同的封装但是对于线程的生命周期这部分基本上是雷同的。所以我们可以先来了解一下通用的线程生命周期模型这部分内容也适用于很多其他编程语言然后再详细有针对性地学习一下Java中线程的生命周期。
通用的线程生命周期
通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。
通用线程状态转换图——五态模型
这“五态模型”的详细情况如下所示。
初始状态指的是线程已经被创建但是还不允许分配CPU执行。这个状态属于编程语言特有的不过这里所谓的被创建仅仅是在编程语言层面被创建而在操作系统层面真正的线程还没有创建。
可运行状态指的是线程可以分配CPU执行。在这种状态下真正的操作系统线程已经被成功创建了所以可以分配CPU执行。
当有空闲的CPU时操作系统会将其分配给一个处于可运行状态的线程被分配到CPU的线程的状态就转换成了运行状态。
运行状态的线程如果调用一个阻塞的API例如以阻塞方式读文件或者等待某个事件例如条件变量那么线程的状态就会转换到休眠状态同时释放CPU使用权休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了线程就会从休眠状态转换到可运行状态。
线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
这五种状态在不同编程语言里会有简化合并。例如C语言的POSIX Threads规范就把初始状态和可运行状态合并了Java语言里则把可运行状态和运行状态合并了这两个状态在操作系统调度层面有用而JVM层面不关心这两个状态因为JVM把线程调度交给操作系统处理了。
除了简化合并这五种状态也有可能被细化比如Java语言里就细化了休眠状态这个下面我们会详细讲解
Java中线程的生命周期
介绍完通用的线程生命周期模型想必你已经对线程的“生老病死”有了一个大致的了解。那接下来我们就来详细看看Java语言里的线程生命周期是什么样的。
Java语言中线程共有六种状态分别是
NEW初始化状态
RUNNABLE可运行/运行状态)
BLOCKED阻塞状态
WAITING无时限等待
TIMED_WAITING有时限等待
TERMINATED终止状态
这看上去挺复杂的状态类型也比较多。但其实在操作系统层面Java线程中的BLOCKED、WAITING、TIMED_WAITING是一种状态即前面我们提到的休眠状态。也就是说只要Java线程处于这三种状态之一那么这个线程就永远没有CPU的使用权。
所以Java线程的生命周期可以简化为下图
Java中的线程状态转换图
其中BLOCKED、WAITING、TIMED_WAITING可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从RUNNABLE状态转换到这三种状态呢而这三种状态又是何时转换回RUNNABLE的呢以及NEW、TERMINATED和RUNNABLE状态是如何转换的
1. RUNNABLE与BLOCKED的状态转换
只有一种场景会触发这种转换就是线程等待synchronized的隐式锁。synchronized修饰的方法、代码块同一时刻只允许一个线程执行其他线程只能等待这种情况下等待的线程就会从RUNNABLE转换到BLOCKED状态。而当等待的线程获得synchronized隐式锁时就又会从BLOCKED转换到RUNNABLE状态。
如果你熟悉操作系统线程的生命周期的话可能会有个疑问线程调用阻塞式API时是否会转换到BLOCKED状态呢在操作系统层面线程是会转换到休眠状态的但是在JVM层面Java线程的状态不会发生变化也就是说Java线程的状态会依然保持RUNNABLE状态。JVM层面并不关心操作系统调度相关的状态因为在JVM看来等待CPU使用权操作系统层面此时处于可执行状态与等待I/O操作系统层面此时处于休眠状态没有区别都是在等待某个资源所以都归入了RUNNABLE状态。
而我们平时所谓的Java在调用阻塞式API时线程会阻塞指的是操作系统线程的状态并不是Java线程的状态。
2. RUNNABLE与WAITING的状态转换
总体来说,有三种场景会触发这种转换。
第一种场景获得synchronized隐式锁的线程调用无参数的Object.wait()方法。其中wait()方法我们在上一篇讲解管程的时候已经深入介绍过了,这里就不再赘述。
第二种场景调用无参数的Thread.join()方法。其中的join()是一种线程同步方法例如有一个线程对象thread A当调用A.join()的时候执行这条语句的线程会等待thread A执行完而等待中的这个线程其状态会从RUNNABLE转换到WAITING。当线程thread A执行完原来等待它的线程又会从WAITING状态转换到RUNNABLE。
第三种场景调用LockSupport.park()方法。其中的LockSupport对象也许你有点陌生其实Java并发包中的锁都是基于它实现的。调用LockSupport.park()方法当前线程会阻塞线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpark(Thread thread)可唤醒目标线程目标线程的状态又会从WAITING状态转换到RUNNABLE。
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)方法。
这里你会发现TIMED_WAITING和WAITING状态的区别仅仅是触发条件多了超时参数。
4. 从NEW到RUNNABLE状态
Java刚创建出来的Thread对象就是NEW状态而创建Thread对象主要有两种方法。一种是继承Thread对象重写run()方法。示例代码如下:
// 自定义线程对象
class MyThread extends Thread {
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
MyThread myThread = new MyThread();
另一种是实现Runnable接口重写run()方法并将该实现类作为创建Thread对象的参数。示例代码如下
// 实现Runnable接口
class Runner implements Runnable {
@Override
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
Thread thread = new Thread(new Runner());
NEW状态的线程不会被操作系统调度因此不会执行。Java线程要执行就必须转换到RUNNABLE状态。从NEW状态转换到RUNNABLE状态很简单只要调用线程对象的start()方法就可以了,示例代码如下:
MyThread myThread = new MyThread();
// 从NEW状态转换到RUNNABLE状态
myThread.start()
5. 从RUNNABLE到TERMINATED状态
线程执行完 run() 方法后会自动转换到TERMINATED状态当然如果执行run()方法的时候异常抛出也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如 run()方法访问一个很慢的网络我们等不下去了想终止怎么办呢Java的Thread类里面倒是有个stop()方法,不过已经标记为@Deprecated所以不建议使用了。正确的姿势其实是调用interrupt()方法。
那stop()和interrupt()方法的主要区别是什么呢?
stop()方法会真的杀死线程不给线程喘息的机会如果线程持有ReentrantLock锁被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁那其他线程就再也没机会获得ReentrantLock锁这实在是太危险了。所以该方法就不建议使用了类似的方法还有suspend() 和 resume()方法,这两个方法同样也都不建议使用了,所以这里也就不多介绍了。
而interrupt()方法就温柔多了interrupt()方法仅仅是通知线程线程有机会执行一些后续操作同时也可以无视这个通知。被interrupt的线程是怎么收到通知的呢一种是异常另一种是主动检测。
当线程A处于WAITING、TIMED_WAITING状态时如果其他线程调用线程A的interrupt()方法会使线程A返回到RUNNABLE状态同时线程A的代码会触发InterruptedException异常。上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件都是调用了类似wait()、join()、sleep()这样的方法我们看这些方法的签名发现都会throws InterruptedException这个异常。这个异常的触发条件就是其他线程调用了该线程的interrupt()方法。
当线程A处于RUNNABLE状态时并且阻塞在java.nio.channels.InterruptibleChannel上时如果其他线程调用线程A的interrupt()方法线程A会触发java.nio.channels.ClosedByInterruptException这个异常而阻塞在java.nio.channels.Selector上时如果其他线程调用线程A的interrupt()方法线程A的java.nio.channels.Selector会立即返回。
上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测如果线程处于RUNNABLE状态并且没有阻塞在某个I/O操作上例如中断计算圆周率的线程A这时就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法那么线程A可以通过isInterrupted()方法,检测是不是自己被中断了。
总结
理解Java线程的各种状态以及生命周期对于诊断多线程Bug非常有帮助多线程程序很难调试出了Bug基本上都是靠日志靠线程dump来跟踪问题分析线程dump的一个基本功就是分析线程状态大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。同时本文介绍的线程生命周期具备很强的通用性对于学习其他语言的多线程编程也有很大的帮助。
你可以通过 jstack 命令或者Java VisualVM这个可视化工具将JVM所有的线程栈信息导出来完整的线程栈信息不仅包括线程的当前状态、调用栈还包括了锁的信息。例如我曾经写过一个死锁的程序导出的线程栈明确告诉我发生了死锁并且将死锁线程的调用栈信息清晰地显示出来了如下图。导出线程栈分析线程状态是诊断并发问题的一个重要工具。
发生死锁的线程栈
课后思考
下面代码的本意是当前线程被中断之后退出while(true),你觉得这段代码是否正确呢?
Thread th = Thread.currentThread();
while(true) {
if(th.isInterrupted()) {
break;
}
// 省略业务代码无数
try {
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,96 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Java线程创建多少线程才是合适的
在Java领域实现并发程序的主要手段就是多线程使用多线程还是比较简单的但是使用多少个线程却是个困难的问题。工作中经常有人问“各种线程池的线程数量调整成多少是合适的”或者“Tomcat的线程数、Jdbc连接池的连接数是多少”等等。那我们应该如何设置合适的线程数呢
要解决这个问题,首先要分析以下两个问题:
为什么要使用多线程?
多线程的应用场景有哪些?
为什么要使用多线程?
使用多线程,本质上就是提升程序性能。不过此刻谈到的性能,可能在你脑海里还是比较笼统的,基本上就是快、快、快,这种无法度量的感性认识很不科学,所以在提升性能之前,首要问题是:如何度量性能。
度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。 吞吐量指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。
我们所谓提升性能,从度量的角度,主要是降低延迟,提高吞吐量。这也是我们使用多线程的主要目的。那我们该怎么降低延迟,提高吞吐量呢?这个就要从多线程的应用场景说起了。
多线程的应用场景
要想“降低延迟提高吞吐量”对应的方法呢基本上有两个方向一个方向是优化算法另一个方向是将硬件的性能发挥到极致。前者属于算法范畴后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢主要是两类一个是I/O一个是CPU。简言之在并发编程领域提升性能本质上就是提升硬件的利用率再具体点来说就是提升I/O的利用率和CPU的利用率。
估计这个时候你会有个疑问操作系统不是已经解决了硬件的利用率问题了吗的确是这样例如操作系统已经解决了磁盘和网卡的利用率问题利用中断机制还能避免CPU轮询I/O状态也提升了CPU的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备而我们的并发程序往往需要CPU和I/O设备相互配合工作也就是说我们需要解决CPU和I/O设备综合利用率的问题。关于这个综合利用率的问题操作系统虽然没有办法完美解决但是却给我们提供了方案那就是多线程。
下面我们用一个简单的示例来说明如何利用多线程来提升CPU和I/O设备的利用率假设程序按照CPU计算和I/O操作交叉执行的方式运行而且CPU计算和I/O操作的耗时是1:1。
如下图所示如果只有一个线程执行CPU计算的时候I/O设备空闲执行I/O操作的时候CPU空闲所以CPU的利用率和I/O设备的利用率都是50%。
单线程执行示意图
如果有两个线程如下图所示当线程A执行CPU计算的时候线程B执行I/O操作当线程A执行I/O操作的时候线程B执行CPU计算这样CPU的利用率和I/O设备的利用率就都达到了100%。
二线程执行示意图
我们将CPU的利用率和I/O设备的利用率都提升到了100%会对性能产生了哪些影响呢通过上面的图示很容易看出单位时间处理的请求数量翻了一番也就是说吞吐量提高了1倍。此时可以逆向思维一下如果CPU和I/O设备的利用率都很低那么可以尝试通过增加线程来提高吞吐量。
在单核时代多线程主要就是用来平衡CPU和I/O设备的。如果程序只有CPU计算而没有I/O操作的话多线程不但不会提升性能还会使性能变得更差原因是增加了线程切换的成本。但是在多核时代这种纯计算型的程序也可以利用多线程来提升性能。为什么呢因为利用多核可以降低响应时间。
为便于你理解这里我举个简单的例子说明一下计算1+2+… … +100亿的值如果在4核的CPU上利用4个线程执行线程A计算[125亿)线程B计算[25亿50亿)线程C计算[5075亿)线程D计算[75亿100亿],之后汇总,那么理论上应该比一个线程计算[1100亿]快将近4倍响应时间能够降到25%。一个线程对于4核的CPUCPU的利用率只有25%而4个线程则能够将CPU的利用率提高到100%。
多核执行多线程示意图
创建多少线程合适?
创建多少线程合适要看多线程具体的应用场景。我们的程序一般都是CPU计算和I/O操作交叉执行的由于I/O设备的速度相对于CPU来说都很慢所以大部分情况下I/O操作执行的时间相对于CPU计算来说都非常长这种场景我们一般都称为I/O密集型计算和I/O密集型计算相对的就是CPU密集型计算了CPU密集型计算大部分场景下都是纯CPU计算。I/O密集型程序和CPU密集型程序计算最佳线程数的方法是不同的。
下面我们对这两个场景分别说明。
对于CPU密集型计算多线程本质上是提升多核CPU的利用率所以对于一个4核的CPU每个核一个线程理论上创建4个线程就可以了再多创建线程也只是增加线程切换的成本。所以对于CPU密集型的计算场景理论上“线程的数量=CPU核数”就是最合适的。不过在工程上线程的数量一般会设置为“CPU核数+1”这样的话当线程因为偶尔的内存页失效或其他原因导致阻塞时这个额外的线程可以顶上从而保证CPU的利用率。
对于I/O密集型的计算场景比如前面我们的例子中如果CPU计算和I/O操作的耗时是1:1那么2个线程是最合适的。如果CPU计算和I/O操作的耗时是1:2那多少个线程合适呢是3个线程如下图所示CPU在A、B、C三个线程之间切换对于线程A当CPU从B、C切换回来时线程A正好执行完I/O操作。这样CPU和I/O设备的利用率都达到了100%。
三线程执行示意图
通过上面这个例子我们会发现对于I/O密集型计算场景最佳的线程数是与程序中CPU计算和I/O操作的耗时比相关的我们可以总结出这样一个公式
最佳线程数=1 +I/O耗时 / CPU耗时
我们令R=I/O耗时 / CPU耗时综合上图可以这样理解当线程A执行IO操作时另外R个线程正好执行完各自的CPU计算。这样CPU的利用率就达到了100%。
不过上面这个公式是针对单核CPU的至于多核CPU也很简单只需要等比扩大就可以了计算公式如下
最佳线程数=CPU核数 * [ 1 +I/O耗时 / CPU耗时]
总结
很多人都知道线程数不是越多越好但是设置多少是合适的却又拿不定主意。其实只要把握住一条原则就可以了这条原则就是将硬件的性能发挥到极致。上面我们针对CPU密集型和I/O密集型计算场景都给出了理论上的最佳公式这些公式背后的目标其实就是将硬件的性能发挥到极致。
对于I/O密集型计算场景I/O耗时和CPU耗时的比值是一个关键参数不幸的是这个参数是未知的而且是动态变化的所以工程上我们要估算这个参数然后做各种不同场景下的压测来验证我们的估计。不过工程上原则还是将硬件的性能发挥到极致所以压测时我们需要重点关注CPU、I/O设备的利用率和性能指标响应时间、吞吐量之间的关系。
课后思考
有些同学对于最佳线程数的设置积累了一些经验值认为对于I/O密集型应用最佳线程数应该为2 * CPU的核数 + 1你觉得这个经验值合理吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,98 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 Java线程为什么局部变量是线程安全的
我们一遍一遍重复再重复地讲到多个线程同时访问共享变量的时候会导致并发问题。那在Java语言里是不是所有变量都是共享变量呢工作中我发现不少同学会给方法里面的局部变量设置同步显然这些同学并没有把共享变量搞清楚。那Java方法里面的局部变量是否存在并发问题呢下面我们就先结合一个例子剖析下这个问题。
比如,下面代码里的 fibonacci() 这个方法,会根据传入的参数 n ,返回 1 到 n 的斐波那契数列,斐波那契数列类似这样: 1、1、2、3、5、8、13、21、34……第1项和第2项是1从第3项开始每一项都等于前两项之和。在这个方法里面有个局部变量数组 r 用来保存数列的结果,每次计算完一项,都会更新数组 r 对应位置中的值。你可以思考这样一个问题,当多个线程调用 fibonacci() 这个方法的时候,数组 r 是否存在数据竞争Data Race
// 返回斐波那契数列
int[] fibonacci(int n) {
// 创建结果数组
int[] r = new int[n];
// 初始化第一、第二个数
r[0] = r[1] = 1; // ①
// 计算2..n
for(int i = 2; i < n; i++) {
r[i] = r[i-2] + r[i-1];
}
return r;
}
你自己可以在大脑里模拟一下多个线程调用 fibonacci() 方法的情景假设多个线程执行到 多个线程都要对数组r的第1项和第2项赋值这里看上去感觉是存在数据竞争的不过感觉再次欺骗了你
其实很多人也是知道局部变量不存在数据竞争的但是至于原因嘛就说不清楚了
那它背后的原因到底是怎样的呢要弄清楚这个你需要一点编译原理的知识你知道在CPU层面是没有方法概念的CPU的眼里只有一条条的指令编译程序负责把高级语言里的方法转换成一条条的指令所以你可以站在编译器实现者的角度来思考怎么完成方法到指令的转换
方法是如何被执行的
高级语言里的普通语句例如上面的r[i] = r[i-2] + r[i-1];翻译成CPU的指令相对简单可方法的调用就比较复杂了例如下面这三行代码第1行声明一个int变量a第2行调用方法 fibonacci(a)第3行将b赋值给c
int a = 7
int[] b = fibonacci(a);
int[] c = b;
当你调用fibonacci(a)的时候CPU要先找到方法 fibonacci() 的地址然后跳转到这个地址去执行代码最后CPU执行完方法 fibonacci() 之后要能够返回首先找到调用方法的下一条语句的地址也就是int[] c=b;的地址,再跳转到这个地址去执行。 你可以参考下面这个图再加深一下理解
方法的调用过程
到这里方法调用的过程想必你已经清楚了但是还有一个很重要的问题CPU去哪里找到调用方法的参数和返回地址如果你熟悉CPU的工作原理你应该会立刻想到通过CPU的堆栈寄存器CPU支持一种栈结构栈你一定很熟悉了就像手枪的弹夹先入后出因为这个栈是和方法调用相关的因此经常被称为调用栈
例如有三个方法ABC他们的调用关系是A->B->CA调用BB调用C在运行时会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间称为栈帧每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时会创建新的栈帧并压入调用栈当方法返回时对应的栈帧就会被自动弹出。也就是说栈帧和方法是同生共死的。
调用栈结构
利用栈结构来支持方法调用这个方案非常普遍以至于CPU里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪但是方法的内部执行原理却是出奇的一致都是靠栈结构解决的。Java语言虽然是靠虚拟机解释执行的但是方法的调用也是利用栈结构解决的。
局部变量存哪里?
我们已经知道了方法间的调用在CPU眼里是怎么执行的但还有一个关键问题方法内的局部变量存哪里
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。
保护局部变量的调用栈结构
这个结论相信很多人都知道因为学Java语言的时候基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
调用栈与线程
两个线程可以同时用不同的参数调用相同的方法那调用栈和线程之间是什么关系呢答案是每个线程都有自己独立的调用栈。因为如果不是这样那两个线程就互相干扰了。如下面这幅图所示线程A、B、C每个线程都有自己独立的调用栈。
线程与调用栈的关系图
现在让我们回过头来再看篇首的问题Java方法里面的局部变量是否存在并发问题现在你应该很清楚了一点问题都没有。因为每个线程都有自己的调用栈局部变量保存在线程各自的调用栈里面不会共享所以自然也就没有并发问题。再次重申一遍没有共享就没有伤害。
线程封闭
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
采用线程封闭技术的案例非常多例如从数据库连接池里获取的连接Connection在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术保证一个Connection一旦被一个线程获取之后在这个线程关闭Connection之前的这段时间里不会再分配给其他线程从而保证了Connection不会有并发问题。
总结
调用栈是一个通用的计算机概念所有的编程语言都会涉及到Java调用栈相关的知识我并没有花费很大的力气去深究但是靠着那点C语言的知识稍微思考一下基本上也就推断出来了。工作了十几年我发现最近几年和前些年最大的区别是很多技术的实现原理我都是靠推断然后看源码验证而不是像以前一样纯粹靠看源码来总结了。
建议你也多研究原理性的东西、通用的东西,有这些东西之后再学具体的技术就快多了。
课后思考
常听人说,递归调用太深,可能导致栈溢出。你思考一下原因是什么?有哪些解决方案呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,132 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 如何用面向对象思想写好并发程序?
在工作中我发现很多同学在设计之初都是直接按照单线程的思路来写程序的而忽略了本应该重视的并发问题等上线后的某天突然发现诡异的Bug再历经千辛万苦终于定位到问题所在却发现对于如何解决已经没有了思路。
关于这个问题,我觉得咱们今天很有必要好好聊聊“如何用面向对象思想写好并发程序”这个话题。
面向对象思想与并发编程有关系吗本来是没关系的它们分属两个不同的领域但是在Java语言里这两个领域被无情地融合在一起了好在融合的效果还是不错的在Java语言里面向对象思想能够让并发编程变得更简单。
那如何才能用面向对象思想写好并发程序呢?结合我自己的工作经验来看,我觉得你可以从封装共享变量、识别共享变量间的约束条件和制定并发访问策略这三个方面下手。
一、封装共享变量
并发程序我们关注的一个核心问题不过是解决多线程同时访问共享变量的问题。在《03 | 互斥锁(上):解决原子性问题》中,我们类比过球场门票的管理,现实世界里门票管理的一个核心问题是:所有观众只能通过规定的入口进入,否则检票就形同虚设。在编程世界这个问题也很重要,编程领域里面对于共享变量的访问路径就类似于球场的入口,必须严格控制。好在有了面向对象思想,对共享变量的访问路径可以轻松把控。
面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性,这和门票管理模型匹配度相当的高,球场里的座位就是对象属性,球场入口就是对象的公共方法。我们把共享变量作为对象的属性,那对于共享变量的访问路径就是对象的公共方法,所有入口都要安排检票程序就相当于我们前面提到的并发访问策略。
利用面向对象思想写并发程序的思路其实就这么简单将共享变量作为对象属性封装在内部对所有公共方法制定并发访问策略。就拿很多统计程序都要用到计数器来说下面的计数器程序共享变量只有一个就是value我们把它作为Counter类的属性并且将两个公共方法get()和addOne()声明为同步方法这样Counter类就成为一个线程安全的类了。
public class Counter {
private long value;
synchronized long get(){
return value;
}
synchronized long addOne(){
return ++value;
}
}
当然实际工作中很多的场景都不会像计数器这么简单经常要面临的情况往往是有很多的共享变量例如信用卡账户有卡号、姓名、身份证、信用额度、已出账单、未出账单等很多共享变量。这么多的共享变量如果每一个都考虑它的并发安全问题那我们就累死了。但其实仔细观察你会发现很多共享变量的值是不会变的例如信用卡账户的卡号、姓名、身份证。对于这些不会发生变化的共享变量建议你用final关键字来修饰。这样既能避免并发问题也能很明了地表明你的设计意图让后面接手你程序的兄弟知道你已经考虑过这些共享变量的并发安全问题了。
二、识别共享变量间的约束条件
识别共享变量间的约束条件非常重要。因为这些约束条件决定了并发访问策略。例如库存管理里面有个合理库存的概念库存量不能太高也不能太低它有一个上限和一个下限。关于这些约束条件我们可以用下面的程序来模拟一下。在类SafeWM中声明了两个成员变量upper和lower分别代表库存上限和库存下限这两个变量用了AtomicLong这个原子类原子类是线程安全的所以这两个成员变量的set方法就不需要同步了。
public class SafeWM {
// 库存上限
private final AtomicLong upper =
new AtomicLong(0);
// 库存下限
private final AtomicLong lower =
new AtomicLong(0);
// 设置库存上限
void setUpper(long v){
upper.set(v);
}
// 设置库存下限
void setLower(long v){
lower.set(v);
}
// 省略其他业务代码
}
虽说上面的代码是没有问题的但是忽视了一个约束条件就是库存下限要小于库存上限这个约束条件能够直接加到上面的set方法上吗我们先直接加一下看看效果如下面代码所示。我们在setUpper()和setLower()中增加了参数校验这乍看上去好像是对的但其实存在并发问题问题在于存在竞态条件。这里我顺便插一句其实当你看到代码里出现if语句的时候就应该立刻意识到可能存在竞态条件。
我们假设库存的下限和上限分别是(2,10)线程A调用setUpper(5)将上限设置为5线程B调用setLower(7)将下限设置为7如果线程A和线程B完全同时执行你会发现线程A能够通过参数校验因为这个时候下限还没有被线程B设置还是2而5>2线程B也能够通过参数校验因为这个时候上限还没有被线程A设置还是10而7库存下限要小于库存上限这个约束条件的。
public class SafeWM {
// 库存上限
private final AtomicLong upper =
new AtomicLong(0);
// 库存下限
private final AtomicLong lower =
new AtomicLong(0);
// 设置库存上限
void setUpper(long v){
// 检查参数合法性
if (v < lower.get()) {
throw new IllegalArgumentException();
}
upper.set(v);
}
// 设置库存下限
void setLower(long v){
// 检查参数合法性
if (v > upper.get()) {
throw new IllegalArgumentException();
}
lower.set(v);
}
// 省略其他业务代码
}
在没有识别出库存下限要小于库存上限这个约束条件之前,我们制定的并发访问策略是利用原子类,但是这个策略,完全不能保证库存下限要小于库存上限这个约束条件。所以说,在设计阶段,我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。
共享变量之间的约束条件反映在代码里基本上都会有if语句所以一定要特别注意竞态条件。
三、制定并发访问策略
制定并发访问策略,是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上来看,无外乎就是以下“三件事”。
避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
不变模式这个在Java领域应用的很少但在其他领域却有着广泛的应用例如Actor模式、CSP模式以及函数式编程的基础都是不变模式。
管程及其他同步工具Java领域万能的解决方案是管程但是对于很多特定场景使用Java并发包提供的读写锁、并发容器等同步工具会更好。
接下来在咱们专栏的第二模块我会仔细讲解Java并发工具类以及他们的应用场景在第三模块我还会讲解并发编程的设计模式这些都是和制定并发访问策略有关的。
除了这些方案之外,还有一些宏观的原则需要你了解。这些宏观原则,有助于你写出“健壮”的并发程序。这些原则主要有以下三条。
优先使用成熟的工具类Java SDK并发包里提供了丰富的工具类基本上能满足你日常的需要建议你熟悉它们用好它们而不是自己再“发明轮子”毕竟并发工具类不是随随便便就能发明成功的。
迫不得已时才使用低级的同步原语低级的同步原语主要指的是synchronized、Lock、Semaphore等这些虽然感觉简单但实际上并没那么简单一定要小心使用。
避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。
总结
利用面向对象思想编写并发程序一个关键点就是利用面向对象里的封装特性由于篇幅原因这里我只做了简单介绍详细的你可以借助相关资料定向学习。而对共享变量进行封装要避免“逸出”所谓“逸出”简单讲就是共享变量逃逸到对象的外面比如在《02 | Java内存模型看Java如何解决可见性和有序性问题》那一篇我们已经讲过构造函数里的this“逸出”。这些都是必须要避免的。
这是我们专栏并发理论基础的最后一部分内容,这一部分内容主要是让你对并发编程有一个全面的认识,让你了解并发编程里的各种概念,以及它们之间的关系,当然终极目标是让你知道遇到并发问题该怎么思考。这部分的内容还是有点烧脑的,但专栏后面几个模块的内容都是具体的实践部分,相对来说就容易多了。我们一起坚持吧!
课后思考
本期示例代码中类SafeWM不满足库存下限要小于库存上限这个约束条件那你来试试修改一下让它能够在并发条件下满足库存下限要小于库存上限这个约束条件。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
延伸阅读
关于这部分的内容如果你觉得还不“过瘾”这里我再给你推荐一本书吧——《Java并发编程实战》这本书的第三章《对象的共享》、第四章《对象的组合》全面地介绍了如何构建线程安全的对象你可以拿来深入地学习。

View File

@ -0,0 +1,215 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 理论基础模块热点问题答疑
到这里专栏的第一模块——并发编程的理论基础我们已经讲解完了总共12篇不算少但“跳出来看全景”你会发现这12篇的内容基本上是一个“串行的故事”。所以在学习过程中建议你从一个个单一的知识和技术中“跳出来”看全局搭建自己的并发编程知识体系。
为了便于你更好地学习和理解,下面我会先将这些知识点再简单地为你“串”一下,咱们一起复习下;然后就每篇文章的课后思考题、留言区的热门评论,我也集中总结和回复一下。
那这个“串行的故事”是怎样的呢?
起源是一个硬件的核心矛盾CPU与内存、I/O的速度差异系统软件操作系统、编译器在解决这个核心矛盾的同时引入了可见性、原子性和有序性问题这三个问题就是很多并发程序的Bug之源。这就是01的内容。
那如何解决这三个问题呢Java语言自然有招儿它提供了Java内存模型和互斥锁方案。所以在02我们介绍了Java内存模型以应对可见性和有序性问题那另一个原子性问题该如何解决多方考量用好互斥锁才是关键这就是03和04的内容。
虽说互斥锁是解决并发问题的核心工具但它也可能会带来死锁问题所以05就介绍了死锁的产生原因以及解决方案同时还引出一个线程间协作的问题这也就引出了06这篇文章的内容介绍线程间的协作机制等待-通知。
你应该也看出来了前六篇文章我们更多地是站在微观的角度看待并发问题。而07则是换一个角度站在宏观的角度重新审视并发编程相关的概念和理论同时也是对前六篇文章的查漏补缺。
08介绍的管程是Java并发编程技术的基础是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步都是可以由管程来解决的。所以学好管程就相当于掌握了一把并发编程的万能钥匙。
至此,并发编程相关的问题,理论上你都应该能找到问题所在,并能给出理论上的解决方案了。
而后在09、10和11我们又介绍了线程相关的知识毕竟Java并发编程是要靠多线程来实现的所以有针对性地学习这部分知识也是很有必要的包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。
最后在12我们还介绍了如何用面向对象思想写好并发程序因为在Java语言里面向对象思想能够让并发编程变得更简单。
并发编程理论基础模块思维导图
经过这样一个简要的总结,相信你此时对于并发编程相关的概念、理论、产生的背景以及它们背后的关系已经都有了一个相对全面的认识。至于更深刻的认识和应用体验,还是需要你“钻进去,看本质”,加深对技术本身的认识,拓展知识深度和广度。
另外在每篇文章的最后我都附上了一个思考题这些思考题虽然大部分都很简单但是隐藏的问题却很容易让人忽略从而不经意间就引发了Bug再加上留言区的一些热门评论所以我想着将这些隐藏的问题或者易混淆的问题做一个总结也是很有必要的。
用锁的最佳实践
———–
例如在《03 | 互斥锁解决原子性问题》和《04 | 互斥锁(下):如何用一把锁保护多个资源?》这两篇文章中,我们的思考题都是关于如何创建正确的锁,而思考题里的做法都是错误的。
03的思考题的示例代码如下synchronized (new Object()) 这行代码很多同学已经分析出来了每次调用方法get()、addOne()都创建了不同的锁相当于无锁。这里需要你再次加深一下记忆“一个合理的受保护资源与锁之间的关联关系应该是N:1”。只有共享一把锁才能起到互斥的作用。
另外很多同学也提到JVM开启逃逸分析之后synchronized (new Object()) 这行代码在实际执行的时候会被优化掉也就是说在真实执行的时候这行代码压根就不存在。不过无论你是否懂“逃逸分析”都不影响你学好并发编程如果你对“逃逸分析”感兴趣可以参考一些JVM相关的资料。
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}
04的思考题转换成代码是下面这个样子。它的核心问题有两点一个是锁有可能会变化另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。 Integer 和 String 类型的对象在JVM里面是可能被重用的除此之外JVM里可能被重用的对象还有Boolean那重用意味着什么呢意味着你的锁可能被其他代码使用如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。
class Account {
// 账户余额
private Integer balance;
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balance) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 更改密码
void updatePassword(String pw){
synchronized(password) {
this.password = pw;
}
}
}
通过这两个反例我们可以总结出这样一个基本的原则应是私有的、不可变的、不可重用的。我们经常看到别人家的锁都长成下面示例代码这样这种写法貌不惊人却能避免各种意想不到的坑这个其实就是最佳实践。最佳实践这方面的资料推荐你看《Java安全编码标准》这本书研读里面的每一条规则都会让你受益匪浅。
// 普通对象锁
private final Object
lock = new Object();
// 静态对象锁
private static final Object
lock = new Object();
锁的性能要看场景
————
《05 | 一不小心就死锁了怎么办》的思考题是比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好。
这个要看具体的应用场景不同应用场景它们的性能表现是不同的。在这个思考题里面如果转账操作非常费时那么前者的性能优势就显示出来了因为前者允许A->B、C->D这种转账业务的并行。不同的并发场景用不同的方案这是并发编程里面的一项基本原则没有通吃的技术和方案因为每种技术和方案都是优缺点和适用场景的。
竞态条件需要格外关注
————–
《07 | 安全性、活跃性以及性能问题》里的思考题是一种典型的竞态条件问题如下所示。竞态条件问题非常容易被忽略contains()和add()方法虽然都是线程安全的,但是组合在一起却不是线程安全的。所以你的程序里如果存在类似的组合操作,一定要小心。
void addIfNotExist(Vector v,
Object o){
if(!v.contains(o)) {
v.add(o);
}
}
这道思考题的解决方法可以参考《12 | 如何用面向对象思想写好并发程序你需要将共享变量v封装在对象的内部而后控制并发访问的路径这样就能有效防止对Vector v变量的滥用从而导致并发问题。你可以参考下面的示例代码来加深理解。
class SafeVector{
private Vector v;
// 所有公共方法增加同步控制
synchronized
void addIfNotExist(Object o){
if(!v.contains(o)) {
v.add(o);
}
}
}
方法调用是先计算参数
————–
不过还有同学对07文中所举的例子有疑议认为set(get()+1);这条语句是进入set()方法之后才执行get()方法其实并不是这样的。方法的调用是先计算参数然后将参数压入调用栈之后才会执行方法体方法调用的过程在11这篇文章中我们已经做了详细的介绍你可以再次重温一下。
while(idx++ < 10000) {
set(get()+1);
}
先计算参数这个事情也是容易被忽视的细节例如下面写日志的代码如果日志级别设置为INFO虽然这行代码不会写日志但是会计算"The var1" + var1 + ", var2:" + var2的值因为方法调用前会先计算参数
logger.debug("The var1" +
var1 + ", var2:" + var2);
更好地写法应该是下面这样这种写法仅仅是讲参数压栈而没有参数的计算使用{}占位符是写日志的一个良好习惯
logger.debug("The var1{}, var2:{}",
var1, var2);
InterruptedException异常处理需小心
-
09 | Java线程Java线程的生命周期的思考题主要是希望你能够注意InterruptedException的处理方式当你调用Java对象的wait()方法或者线程的sleep()方法时需要捕获并处理InterruptedException异常在思考题里面如下所示本意是通过isInterrupted()检查线程是否被中断了如果中断了就退出while循环当其他线程通过调用th.interrupt().来中断th线程时会设置th线程的中断标志位从而使th.isInterrupted()返回true这样就能退出while循环了
Thread th = Thread.currentThread();
while(true) {
if(th.isInterrupted()) {
break;
}
// 省略业务代码无数
try {
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
这看上去一点问题没有实际上却是几乎起不了作用原因是这段代码在执行的时候大部分时间都是阻塞在sleep(100)当其他线程通过调用th.interrupt().来中断th线程时大概率地会触发InterruptedException 异常在触发InterruptedException 异常的同时JVM会同时把线程的中断标志位清除所以这个时候th.isInterrupted()返回的是false
正确的处理方式应该是捕获异常之后重新设置中断标志位也就是下面这样
try {
Thread.sleep(100);
}catch(InterruptedException e){
// 重新设置中断标志位
th.interrupt();
}
理论值 or 经验值
10 | Java线程创建多少线程才是合适的的思考题是经验值为最佳线程=2 * CPU的核数 + 1是否合理
从理论上来讲这个经验值一定是靠不住的但是经验值对于很多I/O耗时 / CPU耗时不太容易确定的系统来说却是一个很好到初始值
我们曾讲到最佳线程数最终还是靠压测来确定的实际工作中大家面临的系统I/O耗时 / CPU耗时往往都大于1所以基本上都是在这个初始值的基础上增加增加的过程中应关注线程数是如何影响吞吐量和延迟的一般来讲随着线程数的增加吞吐量会增加延迟也会缓慢增加但是当线程数增加到一定程度吞吐量就会开始下降延迟会迅速增加这个时候基本上就是线程能够设置的最大值了
实际工作中不同的I/O模型对最佳线程数的影响非常大例如大名鼎鼎的Nginx用的是非阻塞I/O采用的是多进程单线程结构Nginx本来是一个I/O密集型系统但是最佳进程数设置的却是CPU的核数完全参考的是CPU密集型的算法所以理论我们还是要活学活用
总结
这个模块内容主要聚焦在并发编程相关的理论上但是思考题则是聚焦在细节上我们经常说细节决定成败在并发编程领域尤其如此理论主要用来给我们提供解决问题的思路和方法但在具体实践的时候还必须重点关注每一个细节哪怕有一个细节没有处理好都会导致并发问题这方面推荐你认真阅读Java安全编码标准这本书如果你英文足够好也可以参考这份文档
最后总结一句学好理论有思路关注细节定成败
欢迎在留言区与我分享你的想法也欢迎你在留言区记录你的思考过程感谢阅读如果你觉得这篇文章对你有帮助的话也欢迎把它分享给更多的朋友

View File

@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 Lock和Condition隐藏在并发包中的管程
Java SDK并发包内容很丰富包罗万象但是我觉得最核心的还是其对管程的实现。因为理论上利用管程你几乎可以实现并发包里所有的工具类。在前面《08 | 管程并发编程的万能钥匙》中我们提到过在并发编程领域有两大核心问题一个是互斥即同一时刻只允许一个线程访问共享资源另一个是同步即线程之间如何通信、协作。这两大问题管程都是能够解决的。Java SDK并发包通过Lock和Condition两个接口来实现管程其中Lock用于解决互斥问题Condition用于解决同步问题。
今天我们重点介绍Lock的使用在介绍Lock的使用之前有个问题需要你首先思考一下Java语言本身提供的synchronized也是管程的一种实现既然Java从语言层面已经实现了管程了那为什么还要在SDK里提供另外一种实现呢难道Java标准委员会还能同意“重复造轮子”的方案很显然它们之间是有巨大区别的。那区别在哪里呢如果能深入理解这个问题对你用好Lock帮助很大。下面我们就一起来剖析一下这个问题。
再造管程的理由
你也许曾经听到过很多这方面的传说例如在Java的1.5版本中synchronized性能不如SDK里面的Lock但1.6版本之后synchronized做了很多优化将性能追了上来所以1.6之后的版本又有人推荐使用synchronized了。那性能是否可以成为“重复造轮子”的理由呢显然不能。因为性能问题优化一下就可以了完全没必要“重复造轮子”。
到这里关于这个问题你是否能够想出一条理由来呢如果你细心的话也许能想到一点。那就是我们前面在介绍死锁问题的时候提出了一个破坏不可抢占条件方案但是这个方案synchronized没有办法解决。原因是synchronized申请资源的时候如果申请不到线程直接进入阻塞状态了而线程进入阻塞状态啥都干不了也释放不了线程已经占有的资源。但我们希望的是
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
如果我们重新设计一把互斥锁去解决这个问题,那该怎么设计呢?我觉得有三种方案。
能够响应中断。synchronized的问题是持有锁A后如果尝试获取锁B失败那么线程就进入阻塞状态一旦发生死锁就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号也就是说当我们给阻塞的线程发送中断信号的时候能够唤醒它那它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。
支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
这三种方案可以全面弥补synchronized的问题。到这里相信你应该也能理解了这三个方案就是“重复造轮子”的主要原因体现在API上就是Lock接口的三个方法。详情如下
// 支持中断的API
void lockInterruptibly()
throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit)
throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
如何保证可见性
Java SDK里面Lock的使用有一个经典的范例就是try{}finally{}需要重点关注的是在finally里面释放锁。这个范例无需多解释你看一下下面的代码就明白了。但是有一点需要解释一下那就是可见性是怎么保证的。你已经知道Java里多线程的可见性是通过Happens-Before规则保证的而synchronized之所以能够保证可见性也是因为有一条synchronized相关的规则synchronized的解锁 Happens-Before 于后续对这个锁的加锁。那Java SDK里面Lock靠什么保证可见性呢例如在下面的代码中线程T1对value进行了+=1操作那后续的线程T2能够看到value的正确结果吗
class X {
private final Lock rtl =
new ReentrantLock();
int value;
public void addOne() {
// 获取锁
rtl.lock();
try {
value+=1;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
答案必须是肯定的。Java SDK里面锁的实现非常复杂这里我就不展开细说了但是原理还是需要简单介绍一下它是利用了volatile相关的Happens-Before规则。Java SDK里面的ReentrantLock内部持有一个volatile 的成员变量state获取锁的时候会读写state的值解锁的时候也会读写state的值简化后的代码如下面所示。也就是说在执行value+=1之前程序先读写了一次volatile变量state在执行value+=1之后又读写了一次volatile变量state。根据相关的Happens-Before规则
顺序性规则对于线程T1value+=1 Happens-Before 释放锁的操作unlock()
volatile变量规则由于state = 1会先读取state所以线程T1的unlock()操作Happens-Before线程T2的lock()操作;
传递性规则:线程 T1的value+=1 Happens-Before 线程 T2 的 lock() 操作。
class SampleLock {
volatile int state;
// 加锁
lock() {
// 省略代码无数
state = 1;
}
// 解锁
unlock() {
// 省略代码无数
state = 0;
}
}
所以说后续线程T2能够看到value的正确结果。如果你觉得理解起来还有点困难建议你重温一下前面我们讲过的《02 | Java内存模型看Java如何解决可见性和有序性问题》里面的相关内容。
什么是可重入锁
如果你细心观察会发现我们创建的锁的具体类名是ReentrantLock这个翻译过来叫可重入锁这个概念前面我们一直没有介绍过。所谓可重入锁顾名思义指的是线程可以重复获取同一把锁。例如下面代码中当线程T1执行到 ① 处时,已经获取到了锁 rtl ,当在 ① 处调用 get()方法时,会在 ② 再次对锁 rtl 执行加锁操作。此时,如果锁 rtl 是可重入的那么线程T1可以再次加锁成功如果锁 rtl 是不可重入的那么线程T1此时会被阻塞。
除了可重入锁,可能你还听说过可重入函数,可重入函数怎么理解呢?指的是线程可以重复调用?显然不是,所谓可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换,这意味着什么呢?线程安全啊。所以,可重入函数是线程安全的。
class X {
private final Lock rtl =
new ReentrantLock();
int value;
public int get() {
// 获取锁
rtl.lock(); ②
try {
return value;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
public void addOne() {
// 获取锁
rtl.lock();
try {
value = 1 + get(); ①
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
公平锁与非公平锁
在使用ReentrantLock的时候你会发现ReentrantLock这个类有两个构造函数一个是无参构造函数一个是传入fair参数的构造函数。fair参数代表的是锁的公平策略如果传入true就表示需要构造一个公平锁反之则表示要构造一个非公平锁。
//无参构造函数:默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
sync = fair ? new FairSync()
: new NonfairSync();
}
在前面《08 | 管程:并发编程的万能钥匙》中,我们介绍过入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
用锁的最佳实践
你已经知道用锁虽然能解决很多并发问题但是风险也是挺高的。可能会导致死锁也可能影响性能。这方面有是否有相关的最佳实践呢还很多。但是我觉得最值得推荐的是并发大师Doug Lea《Java并发编程设计原则与模式》一书中推荐的三个用锁的最佳实践它们分别是
永远只在更新对象的成员变量时加锁
永远只在访问可变的成员变量时加锁
永远不在调用其他对象的方法时加锁
这三条规则前两条估计你一定会认同最后一条你可能会觉得过于严苛。但是我还是倾向于你去遵守因为调用其他对象的方法实在是太不安全了也许“其他”方法里面有线程sleep()的调用也可能会有奇慢无比的I/O操作这些都会严重影响性能。更可怕的是“其他”类的方法可能也会加锁然后双重加锁就可能导致死锁。
并发问题,本来就难以诊断,所以你一定要让你的代码尽量安全,尽量简单,哪怕有一点可能会出问题,都要努力避免。
总结
Java SDK 并发包里的Lock接口里面的每个方法你可以感受到都是经过深思熟虑的。除了支持类似synchronized隐式加锁的lock()方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利。希望你以后在使用锁的时候,一定要仔细斟酌。
除了并发大师Doug Lea推荐的三个最佳实践外你也可以参考一些诸如减少锁的持有时间、减小锁的粒度等业界广为人知的规则其实本质上它们都是相通的不过是在该加锁的地方加锁而已。你可以自己体会自己总结最终总结出自己的一套最佳实践来。
课后思考
你已经知道 tryLock() 支持非阻塞方式获取锁,下面这段关于转账的程序就使用到了 tryLock(),你来看看,它是否存在死锁问题呢?
class Account {
private int balance;
private final Lock lock
= new ReentrantLock();
// 转账
void transfer(Account tar, int amt){
while (true) {
if(this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
} finally {
tar.lock.unlock();
}
}//if
} finally {
this.lock.unlock();
}
}//if
}//while
}//transfer
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 Lock和ConditionDubbo如何用管程实现异步转同步
在上一篇文章中我们讲到Java SDK并发包里的Lock有别于synchronized隐式锁的三个特性能够响应中断、支持超时和非阻塞地获取锁。那今天我们接着再来详细聊聊Java SDK并发包里的ConditionCondition实现了管程模型里面的条件变量。
在《08 | 管程并发编程的万能钥匙》里我们提到过Java 语言内置的管程里只有一个条件变量而Lock&Condition实现的管程是支持多个条件变量的这是二者的一个重要区别。
在很多并发场景下,支持多个条件变量能够让我们的并发程序可读性更好,实现起来也更容易。例如,实现一个阻塞队列,就需要两个条件变量。
那如何利用两个条件变量快速实现阻塞队列呢?
一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队),这个例子我们前面在介绍管程的时候详细说过,这里就不再赘述。相关的代码,我这里重新列了出来,你可以温故知新一下。
public class BlockedQueue<T>{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
//入队后,通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
不过这里你需要注意Lock和Condition实现的管程线程等待和通知需要调用await()、signal()、signalAll()它们的语义和wait()、notify()、notifyAll()是相同的。但是不一样的是Lock&Condition实现的管程里只能使用前面的await()、signal()、signalAll()而后面的wait()、notify()、notifyAll()只有在synchronized实现的管程里才能使用。如果一不小心在Lock&Condition实现的管程里调用了wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。
Java SDK并发包里的Lock和Condition不过就是管程的一种实现而已管程你已经很熟悉了那Lock和Condition的使用自然是小菜一碟。下面我们就来看看在知名项目Dubbo中Lock和Condition是怎么用的。不过在开始介绍源码之前我还先要介绍两个概念同步和异步。
同步与异步
我们平时写的代码,基本都是同步的。但最近几年,异步编程大火。那同步和异步的区别到底是什么呢?通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。
比如在下面的代码里有一个计算圆周率小数点后100万位的方法pai1M()这个方法可能需要执行俩礼拜如果调用pai1M()之后,线程一直等着计算结果,等俩礼拜之后结果返回,就可以执行 printf("hello world")了这个属于同步如果调用pai1M()之后,线程不用等待计算结果,立刻就可以执行 printf("hello world"),这个就属于异步。
// 计算圆周率小说点后100万位
String pai1M() {
//省略代码无数
}
pai1M()
printf("hello world")
同步是Java代码默认的处理方式。如果你想让你的程序支持异步可以通过下面两种方式来实现
调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用;
方法实现的时候创建一个新的线程执行主要逻辑主线程直接return这种方法我们一般称为异步方法。
Dubbo源码分析
其实在编程领域异步的场景还是挺多的比如TCP协议本身就是异步的我们工作中经常用到的RPC调用在TCP协议层面发送完RPC请求后线程是不会等待RPC的响应结果的。可能你会觉得奇怪平时工作中的RPC调用大多数都是同步的啊这是怎么回事呢
其实很简单一定是有人帮你做了异步转同步的事情。例如目前知名的RPC框架Dubbo就给我们做了异步转同步的事情那它是怎么做的呢下面我们就来分析一下Dubbo的相关源码。
对于下面一个简单的RPC调用默认情况下sayHello()方法是个同步方法也就是说执行service.sayHello(“dubbo”)的时候,线程会停下来等结果。
DemoService service = 初始化部分省略
String message =
service.sayHello("dubbo");
System.out.println(message);
如果此时你将调用线程dump出来的话会是下图这个样子你会发现调用线程阻塞了线程状态是TIMED_WAITING。本来发送请求是异步的但是调用线程却阻塞了说明Dubbo帮我们做了异步转同步的事情。通过调用栈你能看到线程是阻塞在DefaultFuture.get()方法上所以可以推断Dubbo异步转同步的功能应该是通过DefaultFuture这个类实现的。
调用栈信息
不过为了理清前后关系还是有必要分析一下调用DefaultFuture.get()之前发生了什么。DubboInvoker的108行调用了DefaultFuture.get()这一行很关键我稍微修改了一下列在了下面。这一行先调用了request(inv, timeout)方法这个方法其实就是发送RPC请求之后通过调用get()方法等待RPC返回结果。
public class DubboInvoker{
Result doInvoke(Invocation inv){
// 下面这行就是源码中108行
// 为了便于展示,做了修改
return currentClient
.request(inv, timeout)
.get();
}
}
DefaultFuture这个类是很关键我把相关的代码精简之后列到了下面。不过在看代码之前你还是有必要重复一下我们的需求当RPC返回结果之前阻塞调用线程让调用线程等待当RPC返回结果后唤醒调用线程让调用线程重新执行。不知道你有没有似曾相识的感觉这不就是经典的等待-通知机制吗这个时候想必你的脑海里应该能够浮现出管程的解决方案了。有了自己的方案之后我们再来看看Dubbo是怎么实现的。
// 创建锁与条件变量
private final Lock lock
= new ReentrantLock();
private final Condition done
= lock.newCondition();
// 调用方通过该方法等待结果
Object get(int timeout){
long start = System.nanoTime();
lock.lock();
try {
while (!isDone()) {
done.await(timeout);
long cur=System.nanoTime();
if (isDone() ||
cur-start > timeout){
break;
}
}
} finally {
lock.unlock();
}
if (!isDone()) {
throw new TimeoutException();
}
return returnFromResponse();
}
// RPC结果是否已经返回
boolean isDone() {
return response != null;
}
// RPC结果返回时调用该方法
private void doReceived(Response res) {
lock.lock();
try {
response = res;
if (done != null) {
done.signal();
}
} finally {
lock.unlock();
}
}
调用线程通过调用get()方法等待RPC返回结果这个方法里面你看到的都是熟悉的“面孔”调用lock()获取锁在finally里面调用unlock()释放锁获取锁后通过经典的在循环中调用await()方法来实现等待。
当RPC结果返回时会调用doReceived()方法这个方法里面调用lock()获取锁在finally里面调用unlock()释放锁获取锁后通过调用signal()来通知调用线程,结果已经返回,不用继续等待了。
至此Dubbo里面的异步转同步的源码就分析完了有没有觉得还挺简单的最近这几年工作中需要异步处理的越来越多了其中有一个主要原因就是有些API本身就是异步API。例如websocket也是一个异步的通信协议如果基于这个协议实现一个简单的RPC你也会遇到异步转同步的问题。现在很多公有云的API本身也是异步的例如创建云主机就是一个异步的API调用虽然成功了但是云主机并没有创建成功你需要调用另外一个API去轮询云主机的状态。如果你需要在项目内部封装创建云主机的API你也会面临异步转同步的问题因为同步的API更易用。
总结
Lock&Condition是管程的一种实现所以能否用好Lock和Condition要看你对管程模型理解得怎么样。管程的技术前面我们已经专门用了一篇文章做了介绍你可以结合着来学理论联系实践有助于加深理解。
Lock&Condition实现的管程相对于synchronized实现的管程来说更加灵活、功能也更丰富。
结合我自己的经验我认为了解原理比了解实现更能让你快速学好并发编程所以没有介绍太多Java SDK并发包里锁和条件变量是如何实现的。但如果你对实现感兴趣可以参考《Java并发编程的艺术》一书的第5章《Java中的锁》里面详细介绍了实现原理我觉得写得非常好。
另外专栏里对DefaultFuture的代码缩减了很多如果你感兴趣也可以去看看完整版。-
Dubbo的源代码在Github上DefaultFuture的路径是incubator-dubbo/dubbo-remoting/dubbo-remoting-api/src/main/java/org/apache/dubbo/remoting/exchange/support/DefaultFuture.java。
课后思考
DefaultFuture里面唤醒等待的线程用的是signal()而不是signalAll(),你来分析一下,这样做是否合理呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,150 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 Semaphore如何快速实现一个限流器
Semaphore现在普遍翻译为“信号量”以前也曾被翻译成“信号灯”因为类似现实生活里的红绿灯车辆能不能通行要看是不是绿灯。同样在编程世界里线程能不能执行也要看信号量是不是允许。
信号量是由大名鼎鼎的计算机科学家迪杰斯特拉Dijkstra于1965年提出在这之后的15年信号量一直都是并发编程领域的终结者直到1980年管程被提出来我们才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制所以学好信号量还是很有必要的。
下面我们首先介绍信号量模型,之后介绍如何使用信号量,最后我们再用信号量来实现一个限流器。
信号量模型
信号量模型还是很简单的可以简单概括为一个计数器一个等待队列三个方法。在信号量模型里计数器和等待队列对外是透明的所以只能通过信号量模型提供的三个方法来访问它们这三个方法分别是init()、down()和up()。你可以结合下图来形象化地理解。
信号量模型图
这三个方法详细的语义具体如下所示。
init():设置计数器的初始值。
down()计数器的值减1如果此时计数器的值小于0则当前线程将被阻塞否则当前线程可以继续执行。
up()计数器的值加1如果此时计数器的值小于或者等于0则唤醒等待队列中的一个线程并将其从等待队列中移除。
这里提到的init()、down()和up()三个方法都是原子性的并且这个原子性是由信号量模型的实现方保证的。在Java SDK里面信号量模型是由java.util.concurrent.Semaphore实现的Semaphore这个类能够保证这三个方法都是原子操作。
如果你觉得上面的描述有点绕的话,可以参考下面这个代码化的信号量模型。
class Semaphore{
// 计数器
int count;
// 等待队列
Queue queue;
// 初始化操作
Semaphore(int c){
this.count=c;
}
//
void down(){
this.count--;
if(this.count<0){
//将当前线程插入等待队列
//阻塞当前线程
}
}
void up(){
this.count++;
if(this.count<=0) {
//移除等待队列中的某个线程T
//唤醒线程T
}
}
}
这里再插一句信号量模型里面down()up()这两个操作历史上最早称为P操作和V操作所以信号量模型也被称为PV原语另外还有些人喜欢用semWait()和semSignal()来称呼它们虽然叫法不同但是语义都是相同的在Java SDK并发包里down()和up()对应的则是acquire()和release()
如何使用信号量
通过上文你应该会发现信号量的模型还是很简单的那具体该如何使用呢其实你想想红绿灯就可以了十字路口的红绿灯可以控制交通得益于它的一个关键规则车辆在通过路口前必须先检查是否是绿灯只有绿灯才能通行这个规则和我们前面提到的锁规则是不是很类似
其实信号量的使用也是类似的这里我们还是用累加器的例子来说明信号量的使用吧在累加器的例子里面count+=1操作是个临界区只允许一个线程执行也就是说要保证互斥那这种情况用信号量怎么控制呢
其实很简单就像我们用互斥锁一样只需要在进入临界区之前执行一下down()操作退出临界区之前执行一下up()操作就可以了下面是Java代码的示例acquire()就是信号量里的down()操作release()就是信号量里的up()操作
static int count;
//初始化信号量
static final Semaphore s
= new Semaphore(1);
//用信号量保证互斥
static void addOne() {
s.acquire();
try {
count+=1;
} finally {
s.release();
}
}
下面我们再来分析一下信号量是如何保证互斥的假设两个线程T1和T2同时访问addOne()方法当它们同时调用acquire()的时候由于acquire()是一个原子操作所以只能有一个线程假设T1把信号量里的计数器减为0另外一个线程T2则是将计数器减为-1对于线程T1信号量里面的计数器的值是0大于等于0所以线程T1会继续执行对于线程T2信号量里面的计数器的值是-1小于0按照信号量模型里对down()操作的描述线程T2将被阻塞所以此时只有线程T1会进入临界区执行count+=1
当线程T1执行release()操作也就是up()操作的时候信号量里计数器的值是-1加1之后的值是0小于等于0按照信号量模型里对up()操作的描述此时等待队列中的T2将会被唤醒于是T2在T1执行完临界区代码之后才获得了进入临界区执行的机会从而保证了互斥性
快速实现一个限流器
上面的例子我们用信号量实现了一个最简单的互斥锁功能估计你会觉得奇怪既然有Java SDK里面提供了Lock为啥还要提供一个Semaphore 其实实现一个互斥锁仅仅是 Semaphore的部分功能Semaphore还有一个功能是Lock不容易实现的那就是Semaphore可以允许多个线程访问一个临界区
现实中还有这种需求有的比较常见的需求就是我们工作中遇到的各种池化资源例如连接池对象池线程池等等其中你可能最熟悉数据库连接池在同一时刻一定是允许多个线程同时使用连接池的当然每个连接在被释放前是不允许其他线程使用的
其实前不久我在工作中也遇到了一个对象池的需求所谓对象池呢指的是一次性创建出N个对象之后所有的线程重复利用这N个对象当然对象在被释放前也是不允许其他线程使用的对象池可以用List保存实例对象这个很简单但关键是限流器的设计这里的限流指的是不允许多于N个线程同时进入临界区那如何快速实现一个这样的限流器呢这种场景我立刻就想到了信号量的解决方案
信号量的计数器在上面的例子中我们设置成了1这个1表示只允许一个线程进入临界区但如果我们把计数器的值设置成对象池里对象的个数N就能完美解决对象池的限流问题了下面就是对象池的示例代码
class ObjPool<T, R> {
final List<T> pool;
// 用信号量实现限流器
final Semaphore sem;
// 构造函数
ObjPool(int size, T t){
pool = new Vector<T>(){};
for(int i=0; i<size; i++){
pool.add(t);
}
sem = new Semaphore(size);
}
// 利用对象池的对象调用func
R exec(Function<T,R> func) {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
}
// 创建对象池
ObjPool<Long, String> pool =
new ObjPool<Long, String>(10, 2);
// 通过对象池获取t之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
我们用一个List来保存对象实例用Semaphore实现限流器。关键的代码是ObjPool里面的exec()方法这个方法里面实现了限流的功能。在这个方法里面我们首先调用acquire()方法与之匹配的是在finally里面调用release()方法假设对象池的大小是10信号量的计数器初始化为10那么前10个线程调用acquire()方法都能继续执行相当于通过了信号灯而其他线程则会阻塞在acquire()方法上。对于通过信号灯的线程,我们为每个线程分配了一个对象 t这个分配工作是通过pool.remove(0)实现的分配完之后会执行一个回调函数func而函数的参数正是前面分配的对象 t 执行完回调函数之后它们就会释放对象这个释放工作是通过pool.add(t)实现的同时调用release()方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于0那么说明有线程在等待此时会自动唤醒等待的线程。
简言之,使用信号量,我们可以轻松地实现一个限流器,使用起来还是非常简单的。
总结
信号量在Java语言里面名气并不算大但是在其他语言里却是很有知名度的。Java在并发编程领域走的很快重点支持的还是管程模型。 管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面,例如用信号量解决我们曾经提到过的阻塞队列问题,就比管程模型麻烦很多,你如果感兴趣,可以课下了解和尝试一下。
课后思考
在上面对象池的例子中对象保存在了Vector中Vector是Java提供的线程安全的容器如果我们把Vector换成ArrayList是否可以呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,202 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 ReadWriteLock如何快速实现一个完备的缓存
前面我们介绍了管程和信号量这两个同步原语在Java语言中的实现理论上用这两个同步原语中任何一个都可以解决所有的并发问题。那Java SDK并发包里为什么还有很多其他的工具类呢原因很简单分场景优化性能提升易用性。
今天我们就介绍一种非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。
针对读多写少这种并发场景Java SDK并发包提供了读写锁——ReadWriteLock非常容易使用并且性能很好。
那什么是读写锁呢?
读写锁并不是Java语言特有的而是一个广为使用的通用技术所有的读写锁都遵守以下三条基本原则
允许多个线程同时读共享变量;
只允许一个线程写共享变量;
如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。
快速实现一个缓存
下面我们就实践起来用ReadWriteLock快速实现一个通用的缓存工具类。
在下面的代码中我们声明了一个Cache类其中类型参数K代表缓存里key的类型V代表缓存里value的类型。缓存的数据保存在Cache类内部的HashMap里面HashMap不是线程安全的这里我们使用读写锁ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口它的实现类是ReentrantReadWriteLock通过名字你应该就能判断出来它是支持可重入的。下面我们通过rwl创建了一把读锁和一把写锁。
Cache这个工具类我们提供了两个方法一个是读缓存方法get()另一个是写缓存方法put()。读缓存需要用到读锁读锁的使用和前面我们介绍的Lock的使用是相同的都是try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。这样看来,读写锁的使用还是非常简单的。
class Cache<K,V> {
final Map<K, V> m =
new HashMap<>();
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
V put(K key, V value) {
w.lock();
try { return m.put(key, v); }
finally { w.unlock(); }
}
}
如果你曾经使用过缓存的话,你应该知道使用缓存首先要解决缓存数据的初始化问题。缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载的方式。
如果源头数据的数据量不大就可以采用一次性加载的方式这种方式最简单可参考下图只需在应用启动的时候把源头数据查询出来依次调用类似上面示例代码中的put()方法就可以了。
缓存一次性加载示意图
如果源头数据量非常大那么就需要按需加载了按需加载也叫懒加载指的是只有当应用查询缓存并且数据不在缓存里的时候才触发加载源头相关数据进缓存的操作。下面你可以结合文中示意图看看如何利用ReadWriteLock 来实现缓存的按需加载。
缓存按需加载示意图
实现缓存的按需加载
文中下面的这段代码实现了按需加载的功能,这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,我们调用了 w.lock() 来获取写锁。
另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?
class Cache<K,V> {
final Map<K, V> m =
new HashMap<>();
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
final Lock r = rwl.readLock();
final Lock w = rwl.writeLock();
V get(K key) {
V v = null;
//读缓存
r.lock(); ①
try {
v = m.get(key); ②
} finally{
r.unlock(); ③
}
//缓存中存在,返回
if(v != null) { ④
return v;
}
//缓存中不存在,查询数据库
w.lock(); ⑤
try {
//再次验证
//其他线程可能已经查询过数据库
v = m.get(key); ⑥
if(v == null){ ⑦
//查询数据库
v=省略代码无数
m.put(key, v);
}
} finally{
w.unlock();
}
return v;
}
}
原因是在高并发的场景下有可能会有多线程竞争写锁。假设缓存是空的没有缓存任何东西如果此时有三个线程T1、T2和T3同时调用get()方法并且参数key也是相同的。那么它们会同时执行到代码⑤处但此时只有一个线程能够获得写锁假设是线程T1线程T1获取写锁之后查询数据库并更新缓存最终释放写锁。此时线程T2和T3会再有一个线程能够获取写锁假设是T2如果不采用再次验证的方式此时T2会再次查询数据库。T2释放写锁之后T3也会再次查询一次数据库。而实际上线程T1已经把缓存的值设置好了T2、T3完全没有必要再次查询数据库。所以再次验证的方式能够避免高并发场景下重复查询数据的问题。
读写锁的升级与降级
上面按需加载的示例代码中,在①处获取读锁,在③处释放读锁,那是否可以在②处的下面增加验证缓存并更新缓存的逻辑呢?详细的代码如下。
//读缓存
r.lock(); ①
try {
v = m.get(key); ②
if (v == null) {
w.lock();
try {
//再次验证并更新缓存
//省略详细代码
} finally{
w.unlock();
}
}
} finally{
r.unlock(); ③
}
这样看上去好像是没有问题的先是获取读锁然后再升级为写锁对此还有个专业的名字叫锁的升级。可惜ReadWriteLock并不支持这种升级。在上面的代码示例中读锁还没有释放此时获取写锁会导致写锁永久等待最终导致相关线程都被阻塞永远也没有机会被唤醒。锁的升级是不允许的这个你一定要注意。
不过虽然锁的升级是不允许的但是锁的降级却是允许的。以下代码来源自ReentrantReadWriteLock的官方示例略做了改动。你会发现在代码①处获取读锁的时候线程还是持有写锁的这种锁的降级是支持的。
class CachedData {
Object data;
volatile boolean cacheValid;
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
//写锁
final Lock w = rwl.writeLock();
void processCachedData() {
// 获取读锁
r.lock();
if (!cacheValid) {
// 释放读锁,因为不允许读锁的升级
r.unlock();
// 获取写锁
w.lock();
try {
// 再次检查状态
if (!cacheValid) {
data = ...
cacheValid = true;
}
// 释放写锁前,降级为读锁
// 降级是可以的
r.lock(); ①
} finally {
// 释放写锁
w.unlock();
}
}
// 此处仍然持有读锁
try {use(data);}
finally {r.unlock();}
}
}
总结
读写锁类似于ReentrantLock也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock接口所以除了支持lock()方法外tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意那就是只有写锁支持条件变量读锁是不支持条件变量的读锁调用newCondition()会抛出UnsupportedOperationException异常。
今天我们用ReadWriteLock实现了一个简单的缓存这个缓存虽然解决了缓存的初始化问题但是没有解决缓存数据与源头数据的同步问题这里的数据同步指的是保证缓存数据和源头数据的一致性。解决数据同步问题的一个最简单的方案就是超时机制。所谓超时机制指的是加载进缓存的数据不是长久有效的而是有时效的当缓存的数据超过时效也就是超时之后这条数据在缓存中就失效了。而访问缓存中失效的数据会触发缓存重新从源头把数据加载进缓存。
当然也可以在源头数据发生变化时快速反馈给缓存但这个就要依赖具体的场景了。例如MySQL作为数据源头可以通过近实时地解析binlog来识别数据是否发生了变化如果发生了变化就将最新的数据推送给缓存。另外还有一些方案采取的是数据库和缓存的双写方案。
总之,具体采用哪种方案,还是要看应用的场景。
课后思考
有同学反映线上系统停止响应了CPU利用率很低你怀疑有同学一不小心写出了读锁升级写锁的方案那你该如何验证自己的怀疑呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,208 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 StampedLock有没有比读写锁更快的锁
在上一篇文章中我们介绍了读写锁学习完之后你应该已经知道“读写锁允许多个线程同时读共享变量适用于读多写少的场景”。那在读多写少的场景中还有没有更快的技术方案呢还真有Java在1.8这个版本里提供了一种叫StampedLock的锁它的性能就比读写锁还要好。
下面我们就来介绍一下StampedLock的使用方法、内部工作原理以及在使用过程中需要注意的事项。
StampedLock支持的三种锁模式
我们先来看看在使用上StampedLock和上一篇文章讲的ReadWriteLock有哪些区别。
ReadWriteLock支持两种模式一种是读锁一种是写锁。而StampedLock支持三种模式分别是写锁、悲观读锁和乐观读。其中写锁、悲观读锁的语义和ReadWriteLock的写锁、读锁的语义非常类似允许多个线程同时获取悲观读锁但是只允许一个线程获取写锁写锁和悲观读锁是互斥的。不同的是StampedLock里的写锁和悲观读锁加锁成功之后都会返回一个stamp然后解锁的时候需要传入这个stamp。相关的示例代码如下。
final StampedLock sl =
new StampedLock();
// 获取/释放悲观读锁示意代码
long stamp = sl.readLock();
try {
//省略业务相关代码
} finally {
sl.unlockRead(stamp);
}
// 获取/释放写锁示意代码
long stamp = sl.writeLock();
try {
//省略业务相关代码
} finally {
sl.unlockWrite(stamp);
}
StampedLock的性能之所以比ReadWriteLock还要好其关键是StampedLock支持乐观读的方式。ReadWriteLock支持多个线程同时读但是当多个线程同时读的时候所有的写操作会被阻塞而StampedLock提供的乐观读是允许一个线程获取写锁的也就是说不是所有的写操作都被阻塞。
注意这里我们用的是“乐观读”这个词而不是“乐观读锁”是要提醒你乐观读这个操作是无锁的所以相比较ReadWriteLock的读锁乐观读的性能更好一些。
文中下面这段代码是出自Java SDK官方示例并略做了修改。在distanceFromOrigin()这个方法中首先通过调用tryOptimisticRead()获取了一个stamp这里的tryOptimisticRead()就是我们前面提到的乐观读。之后将共享变量x和y读入方法的局部变量中不过需要注意的是由于tryOptimisticRead()是无锁的所以共享变量x和y读入方法局部变量时x和y有可能被其他线程修改了。因此最后读完之后还需要再次验证一下是否存在写操作这个验证操作是通过调用validate(stamp)来实现的。
class Point {
private int x, y;
final StampedLock sl =
new StampedLock();
//计算到原点的距离
int distanceFromOrigin() {
// 乐观读
long stamp =
sl.tryOptimisticRead();
// 读入局部变量,
// 读的过程数据可能被修改
int curX = x, curY = y;
//判断执行读操作期间,
//是否存在写操作,如果存在,
//则sl.validate返回false
if (!sl.validate(stamp)){
// 升级为悲观读锁
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(
curX * curX + curY * curY);
}
}
在上面这个代码示例中如果执行乐观读操作的期间存在写操作会把乐观读升级为悲观读锁。这个做法挺合理的否则你就需要在一个循环里反复执行乐观读直到执行乐观读操作的期间没有写操作只有这样才能保证x和y的正确性和一致性而循环读会浪费大量的CPU。升级为悲观读锁代码简练且不易出错建议你在具体实践时也采用这样的方法。
进一步理解乐观读
如果你曾经用过数据库的乐观锁可能会发现StampedLock的乐观读和数据库的乐观锁有异曲同工之妙。的确是这样的就拿我个人来说我是先接触的数据库里的乐观锁然后才接触的StampedLock我就觉得我前期数据库里乐观锁的学习对于后面理解StampedLock的乐观读有很大帮助所以这里有必要再介绍一下数据库里的乐观锁。
还记得我第一次使用数据库乐观锁的场景是这样的在ERP的生产模块里会有多个人通过ERP系统提供的UI同时修改同一条生产订单那如何保证生产订单数据是并发安全的呢我采用的方案就是乐观锁。
乐观锁的实现很简单,在生产订单的表 product_doc 里增加了一个数值型版本号字段 version每次更新product_doc这个表的时候都将 version 字段加1。生产订单的UI在展示的时候需要查询数据库此时将这个 version 字段和其他业务字段一起返回给生产订单UI。假设用户查询的生产订单的id=777那么SQL语句类似下面这样
select id... version
from product_doc
where id=777
用户在生产订单UI执行保存操作的时候后台利用下面的SQL语句更新生产订单此处我们假设该条生产订单的 version=9。
update product_doc
set version=version+1...
where id=777 and version=9
如果这条SQL语句执行成功并且返回的条数等于1那么说明从生产订单UI执行查询操作到执行保存操作期间没有其他人修改过这条数据。因为如果这期间其他人修改过这条数据那么版本号字段一定会大于9。
你会发现数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于StampedLock里面的stamp。这样对比着看相信你会更容易理解StampedLock里乐观读的用法。
StampedLock使用注意事项
对于读多写少的场景StampedLock性能很好简单的应用场景基本上可以替代ReadWriteLock但是StampedLock的功能仅仅是ReadWriteLock的子集在使用的时候还是有几个地方需要注意一下。
StampedLock在命名上并没有增加Reentrant想必你已经猜测到StampedLock应该是不可重入的。事实上的确是这样的StampedLock不支持重入。这个是在使用中必须要特别注意的。
另外StampedLock的悲观读锁、写锁都不支持条件变量这个也需要你注意。
还有一点需要特别注意那就是如果线程阻塞在StampedLock的readLock()或者writeLock()上时此时调用该阻塞线程的interrupt()方法会导致CPU飙升。例如下面的代码中线程T1获取写锁之后将自己阻塞线程T2尝试获取悲观读锁也会阻塞如果此时调用线程T2的interrupt()方法来中断线程T2的话你会发现线程T2所在CPU会飙升到100%。
final StampedLock lock
= new StampedLock();
Thread T1 = new Thread(()->{
// 获取写锁
lock.writeLock();
// 永远阻塞在此处,不释放写锁
LockSupport.park();
});
T1.start();
// 保证T1获取写锁
Thread.sleep(100);
Thread T2 = new Thread(()->
//阻塞在悲观读锁
lock.readLock()
);
T2.start();
// 保证T2阻塞在读锁
Thread.sleep(100);
//中断线程T2
//会导致线程T2所在CPU飙升
T2.interrupt();
T2.join();
所以使用StampedLock一定不要调用中断操作如果需要支持中断功能一定使用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()。这个规则一定要记清楚。
总结
StampedLock的使用看上去有点复杂但是如果你能理解乐观锁背后的原理使用起来还是比较流畅的。建议你认真揣摩Java的官方示例这个示例基本上就是一个最佳实践。我们把Java官方示例精简后形成下面的代码模板建议你在实际工作中尽量按照这个模板来使用StampedLock。
StampedLock读模板
final StampedLock sl =
new StampedLock();
// 乐观读
long stamp =
sl.tryOptimisticRead();
// 读入方法局部变量
......
// 校验stamp
if (!sl.validate(stamp)){
// 升级为悲观读锁
stamp = sl.readLock();
try {
// 读入方法局部变量
.....
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
//使用方法局部变量执行业务操作
......
StampedLock写模板
long stamp = sl.writeLock();
try {
// 写共享变量
......
} finally {
sl.unlockWrite(stamp);
}
课后思考
StampedLock支持锁的降级通过tryConvertToReadLock()方法实现和升级通过tryConvertToWriteLock()方法实现但是建议你要慎重使用。下面的代码也源自Java的官方示例我仅仅做了一点修改隐藏了一个Bug你来看看Bug出在哪里吧。
private double x, y;
final StampedLock sl = new StampedLock();
// 存在问题的方法
void moveIfAtOrigin(double newX, double newY){
long stamp = sl.readLock();
try {
while(x == 0.0 && y == 0.0){
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
x = newX;
y = newY;
break;
} else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,225 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 CountDownLatch和CyclicBarrier如何让多线程步调一致
前几天老板突然匆匆忙忙过来,说对账系统最近越来越慢了,能不能快速优化一下。我了解了对账系统的业务后,发现还是挺简单的,用户通过在线商城下单,会生成电子订单,保存在订单库;之后物流会生成派送单给用户发货,派送单保存在派送单库。为了防止漏派送或者重复派送,对账系统每天还会校验是否存在异常订单。
对账系统的处理逻辑很简单,你可以参考下面的对账系统流程图。目前对账系统的处理逻辑是首先查询订单,然后查询派送单,之后对比订单和派送单,将差异写入差异库。
对账系统流程图
对账系统的代码抽象之后,也很简单,核心代码如下,就是在一个单线程里面循环查询订单、派送单,然后执行对账,最后将写入差异库。
while(存在未对账订单){
// 查询未对账订单
pos = getPOrders();
// 查询派送单
dos = getDOrders();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
利用并行优化对账系统
老板要我优化性能,那我就首先要找到这个对账系统的瓶颈所在。
目前的对账系统由于订单量和派送单量巨大所以查询未对账订单getPOrders()和查询派送单getDOrders()相对较慢,那有没有办法快速优化一下呢?目前对账系统是单线程执行的,图形化后是下图这个样子。对于串行化的系统,优化性能首先想到的是能否利用多线程并行处理。
对账系统单线程执行示意图
所以这里你应该能够看出来这个对账系统里的瓶颈查询未对账订单getPOrders()和查询派送单getDOrders()是否可以并行处理呢显然是可以的因为这两个操作并没有先后顺序的依赖。这两个最耗时的操作并行之后执行过程如下图所示。对比一下单线程的执行示意图你会发现同等时间里并行执行的吞吐量近乎单线程的2倍优化效果还是相对明显的。
对账系统并行执行示意图
思路有了下面我们再来看看如何用代码实现。在下面的代码中我们创建了两个线程T1和T2并行执行查询未对账订单getPOrders()和查询派送单getDOrders()这两个操作。在主线程中执行对账操作check()和差异写入save()两个操作。不过需要注意的是主线程需要等待线程T1和T2执行完才能执行check()和save()这两个操作为此我们通过调用T1.join()和T2.join()来实现等待当T1和T2线程退出时调用T1.join()和T2.join()的主线程就会从阻塞态被唤醒从而执行之后的check()和save()。
while(存在未对账订单){
// 查询未对账订单
Thread T1 = new Thread(()->{
pos = getPOrders();
});
T1.start();
// 查询派送单
Thread T2 = new Thread(()->{
dos = getDOrders();
});
T2.start();
// 等待T1、T2结束
T1.join();
T2.join();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
用CountDownLatch实现线程等待
经过上面的优化之后基本上可以跟老板汇报收工了但还是有点美中不足相信你也发现了while循环里面每次都会创建新的线程而创建线程可是个耗时的操作。所以最好是创建出来的线程能够循环利用估计这时你已经想到线程池了是的线程池就能解决这个问题。
而下面的代码就是用线程池优化后的我们首先创建了一个固定大小为2的线程池之后在while循环里重复利用。一切看上去都很顺利但是有个问题好像无解了那就是主线程如何知道getPOrders()和getDOrders()这两个操作什么时候执行完。前面主线程通过调用线程T1和T2的join()方法来等待线程T1和T2退出但是在线程池的方案里线程根本就不会退出所以join()方法已经失效了。
// 创建2个线程的线程池
Executor executor =
Executors.newFixedThreadPool(2);
while(存在未对账订单){
// 查询未对账订单
executor.execute(()-> {
pos = getPOrders();
});
// 查询派送单
executor.execute(()-> {
dos = getDOrders();
});
/* ??如何实现等待??*/
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
那如何解决这个问题呢你可以开动脑筋想出很多办法最直接的办法是弄一个计数器初始值设置成2当执行完pos = getPOrders();这个操作之后将计数器减1执行完dos = getDOrders();之后也将计数器减1在主线程里等待计数器等于0当计数器等于0时说明这两个查询操作执行完了。等待计数器等于0其实就是一个条件变量用管程实现起来也很简单。
不过我并不建议你在实际项目中去实现上面的方案因为Java并发包里已经提供了实现类似功能的工具类CountDownLatch我们直接使用就可以了。下面的代码示例中在while循环里面我们首先创建了一个CountDownLatch计数器的初始值等于2之后在pos = getPOrders();和dos = getDOrders();两条语句的后面对计数器执行减1操作这个对计数器减1的操作是通过调用 latch.countDown(); 来实现的。在主线程中,我们通过调用 latch.await() 来实现对计数器等于0的等待。
// 创建2个线程的线程池
Executor executor =
Executors.newFixedThreadPool(2);
while(存在未对账订单){
// 计数器初始化为2
CountDownLatch latch =
new CountDownLatch(2);
// 查询未对账订单
executor.execute(()-> {
pos = getPOrders();
latch.countDown();
});
// 查询派送单
executor.execute(()-> {
dos = getDOrders();
latch.countDown();
});
// 等待两个查询操作结束
latch.await();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
进一步优化性能
经过上面的重重优化之后,长出一口气,终于可以交付了。不过在交付之前还需要再次审视一番,看看还有没有优化的余地,仔细看还是有的。
前面我们将getPOrders()和getDOrders()这两个查询操作并行了但这两个查询操作和对账操作check()、save()之间还是串行的。很显然,这两个查询操作和对账操作也是可以并行的,也就是说,在执行对账操作的时候,可以同时去执行下一轮的查询操作,这个过程可以形象化地表述为下面这幅示意图。
完全并行执行示意图
那接下来我们再来思考一下如何实现这步优化,两次查询操作能够和对账操作并行,对账操作还依赖查询操作的结果,这明显有点生产者-消费者的意思,两次查询操作是生产者,对账操作是消费者。既然是生产者-消费者模型,那就需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据。
不过针对对账这个项目,我设计了两个队列,并且两个队列的元素之间还有对应关系。具体如下图所示,订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送单插入派送单队列,这两个队列的元素之间是有一一对应的关系的。两个队列的好处是,对账操作可以每次从订单队列出一个元素,从派送单队列出一个元素,然后对这两个元素执行对账操作,这样数据一定不会乱掉。
双队列示意图
下面再来看如何用双队列来实现完全的并行。一个最直接的想法是一个线程T1执行订单的查询工作一个线程T2执行派送单的查询工作当线程T1和T2都各自生产完1条数据的时候通知线程T3执行对账操作。这个想法虽看上去简单但其实还隐藏着一个条件那就是线程T1和线程T2的工作要步调一致不能一个跑得太快一个跑得太慢只有这样才能做到各自生产完1条数据的时候通知线程T3。
下面这幅图形象地描述了上面的意图线程T1和线程T2只有都生产完1条数据的时候才能一起向下执行也就是说线程T1和线程T2要互相等待步调要一致同时当线程T1和T2都生产完一条数据的时候还要能够通知线程T3执行对账操作。
同步执行示意图
用CyclicBarrier实现线程同步
下面我们就来实现上面提到的方案。这个方案的难点有两个一个是线程T1和T2要做到步调一致另一个是要能够通知到线程T3。
你依然可以利用一个计数器来解决这两个难点计数器初始化为2线程T1和T2生产完一条数据都将计数器减1如果计数器大于0则线程T1或者T2等待。如果计数器等于0则通知线程T3并唤醒等待的线程T1或者T2与此同时将计数器重置为2这样线程T1和线程T2生产下一条数据的时候就可以继续使用这个计数器了。
同样还是建议你不要在实际项目中这么做因为Java并发包里也已经提供了相关的工具类CyclicBarrier。在下面的代码中我们首先创建了一个计数器初始值为2的CyclicBarrier你需要注意的是创建CyclicBarrier的时候我们还传入了一个回调函数当计数器减到0的时候会调用这个回调函数。
线程T1负责查询订单当查出一条时调用 barrier.await() 来将计数器减1同时等待计数器变成0线程T2负责查询派送单当查出一条时也调用 barrier.await() 来将计数器减1同时等待计数器变成0当T1和T2都调用 barrier.await() 的时候计数器会减到0此时T1和T2就可以执行下一条语句了同时会调用barrier的回调函数来执行对账操作。
非常值得一提的是CyclicBarrier的计数器有自动重置的功能当减到0的时候会自动重置你设置的初始值。这个功能用起来实在是太方便了。
// 订单队列
Vector<P> pos;
// 派送单队列
Vector<D> dos;
// 执行回调的线程池
Executor executor =
Executors.newFixedThreadPool(1);
final CyclicBarrier barrier =
new CyclicBarrier(2, ()->{
executor.execute(()->check());
});
void check(){
P p = pos.remove(0);
D d = dos.remove(0);
// 执行对账操作
diff = check(p, d);
// 差异写入差异库
save(diff);
}
void checkAll(){
// 循环查询订单库
Thread T1 = new Thread(()->{
while(存在未对账订单){
// 查询订单库
pos.add(getPOrders());
// 等待
barrier.await();
}
});
T1.start();
// 循环查询运单库
Thread T2 = new Thread(()->{
while(存在未对账订单){
// 查询运单库
dos.add(getDOrders());
// 等待
barrier.await();
}
});
T2.start();
}
总结
CountDownLatch和CyclicBarrier是Java并发包提供的两个非常易用的线程同步工具类这两个工具类用法的区别在这里还是有必要再强调一下CountDownLatch主要用来解决一个线程等待多个线程的场景可以类比旅游团团长要等待所有的游客到齐才能去下一个景点而CyclicBarrier是一组线程之间互相等待更像是几个驴友之间不离不弃。除此之外CountDownLatch的计数器是不能循环利用的也就是说一旦计数器减到0再有线程调用await()该线程会直接通过。但CyclicBarrier的计数器是可以循环利用的而且具备自动重置的功能一旦计数器减到0会自动重置到你设置的初始值。除此之外CyclicBarrier还可以设置回调函数可以说是功能丰富。
本章的示例代码中有两处用到了线程池你现在只需要大概了解即可因为线程池相关的知识咱们专栏后面还会有详细介绍。另外线程池提供了Future特性我们也可以利用Future特性来实现线程之间的等待这个后面我们也会详细介绍。
课后思考
本章最后的示例代码中CyclicBarrier的回调函数我们使用了一个固定大小的线程池你觉得是否有必要呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,161 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 并发容器:都有哪些“坑”需要我们填?
Java并发包有很大一部分内容都是关于并发容器的因此学习和搞懂这部分的内容很有必要。
Java 1.5之前提供的同步容器虽然也能保证线程安全但是性能很差而Java 1.5版本之后提供的并发容器在性能方面则做了很多优化,并且容器的类型也更加丰富了。下面我们就对比二者来学习这部分的内容。
同步容器及其注意事项
Java中的容器主要可以分为四个大类分别是List、Map、Set和Queue但并不是所有的Java容器都是线程安全的。例如我们常用的ArrayList、HashMap就不是线程安全的。在介绍线程安全的容器之前我们先思考这样一个问题如何将非线程安全的容器变成线程安全的容器
在前面《12 | 如何用面向对象思想写好并发程序?》我们讲过实现思路其实很简单,只要把非线程安全的容器封装在对象内部,然后控制好访问路径就可以了。
下面我们就以ArrayList为例看看如何将它变成线程安全的。在下面的代码中SafeArrayList内部持有一个ArrayList的实例c所有访问c的方法我们都增加了synchronized关键字需要注意的是我们还增加了一个addIfNotExist()方法这个方法也是用synchronized来保证原子性的。
SafeArrayList<T>{
//封装ArrayList
List<T> c = new ArrayList<>();
//控制访问路径
synchronized
T get(int idx){
return c.get(idx);
}
synchronized
void add(int idx, T t) {
c.add(idx, t);
}
synchronized
boolean addIfNotExist(T t){
if(!c.contains(t)) {
c.add(t);
return true;
}
return false;
}
}
看到这里你可能会举一反三然后想到所有非线程安全的类是不是都可以用这种包装的方式来实现线程安全呢其实这一点不止你想到了Java SDK的开发人员也想到了所以他们在Collections这个类中还提供了一套完备的包装类比如下面的示例代码中分别把ArrayList、HashSet和HashMap包装成了线程安全的List、Set和Map。
List list = Collections.
synchronizedList(new ArrayList());
Set set = Collections.
synchronizedSet(new HashSet());
Map map = Collections.
synchronizedMap(new HashMap());
我们曾经多次强调组合操作需要注意竞态条件问题例如上面提到的addIfNotExist()方法就包含组合操作。组合操作往往隐藏着竞态条件问题,即便每个操作都能保证原子性,也并不能保证组合操作的原子性,这个一定要注意。
在容器领域一个容易被忽视的“坑”是用迭代器遍历容器例如在下面的代码中通过迭代器遍历容器list对每个元素调用foo()方法,这就存在并发问题,这些组合的操作不具备原子性。
List list = Collections.
synchronizedList(new ArrayList());
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
而正确做法是下面这样锁住list之后再执行遍历操作。如果你查看Collections内部的包装类源码你会发现包装类的公共方法锁的是对象的this其实就是我们这里的list所以锁住list绝对是线程安全的。
List list = Collections.
synchronizedList(new ArrayList());
synchronized (list) {
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
}
上面我们提到的这些经过包装后线程安全容器都是基于synchronized这个同步关键字实现的所以也被称为同步容器。Java提供的同步容器还有Vector、Stack和Hashtable这三个容器不是基于包装类实现的但同样是基于synchronized实现的对这三个容器的遍历同样要加锁保证互斥。
并发容器及其注意事项
Java在1.5版本之前所谓的线程安全的容器主要指的就是同步容器。不过同步容器有个最大的问题那就是性能差所有方法都用synchronized来保证互斥串行度太高了。因此Java在1.5及之后版本提供了性能更高的容器,我们一般称为并发容器。
并发容器虽然数量非常多但依然是前面我们提到的四大类List、Map、Set和Queue下面的并发容器关系图基本上把我们经常用的容器都覆盖到了。
并发容器关系图
鉴于并发容器的数量太多,再加上篇幅限制,所以我并不会一一详细介绍它们的用法,只是把关键点介绍一下。
List
List里面只有一个实现类就是CopyOnWriteArrayList。CopyOnWrite顾名思义就是写的时候会将共享变量新复制一份出来这样做的好处是读操作完全无锁。
那CopyOnWriteArrayList的实现原理是怎样的呢下面我们就来简单介绍一下
CopyOnWriteArrayList内部维护了一个数组成员变量array就指向这个内部数组所有的读操作都是基于array进行的如下图所示迭代器Iterator遍历的就是array数组。
执行迭代的内部结构图
如果在遍历array的同时还有一个写操作例如增加元素CopyOnWriteArrayList是如何处理的呢CopyOnWriteArrayList会将array复制一份然后在新复制处理的数组上执行增加元素的操作执行完之后再将array指向这个新的数组。通过下图你可以看到读写是可以并行的遍历操作一直都是基于原array执行而写操作则是基于新array进行。
执行增加元素的内部结构图
使用CopyOnWriteArrayList需要注意的“坑”主要有两个方面。一个是应用场景CopyOnWriteArrayList仅适用于写操作非常少的场景而且能够容忍读写的短暂不一致。例如上面的例子中写入的新元素并不能立刻被遍历到。另一个需要注意的是CopyOnWriteArrayList迭代器是只读的不支持增删改。因为迭代器遍历的仅仅是一个快照而对快照进行增删改是没有意义的。
Map
Map接口的两个实现是ConcurrentHashMap和ConcurrentSkipListMap它们从应用的角度来看主要区别在于ConcurrentHashMap的key是无序的而ConcurrentSkipListMap的key是有序的。所以如果你需要保证key的顺序就只能使用ConcurrentSkipListMap。
使用ConcurrentHashMap和ConcurrentSkipListMap需要注意的地方是它们的key和value都不能为空否则会抛出NullPointerException这个运行时异常。下面这个表格总结了Map相关的实现类对于key和value的要求你可以对比学习。
ConcurrentSkipListMap里面的SkipList本身就是一种数据结构中文一般都翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(log n)理论上和并发线程数没有关系所以在并发度非常高的情况下若你对ConcurrentHashMap的性能还不满意可以尝试一下ConcurrentSkipListMap。
Set
Set接口的两个实现是CopyOnWriteArraySet和ConcurrentSkipListSet使用场景可以参考前面讲述的CopyOnWriteArrayList和ConcurrentSkipListMap它们的原理都是一样的这里就不再赘述了。
Queue
Java并发包里面Queue这类并发容器是最复杂的你可以从以下两个维度来分类。一个维度是阻塞与非阻塞所谓阻塞指的是当队列已满时入队操作阻塞当队列已空时出队操作阻塞。另一个维度是单端与双端单端指的是只能队尾入队队首出队而双端指的是队首队尾皆可入队出队。Java并发包里阻塞队列都用Blocking关键字标识单端队列使用Queue标识双端队列使用Deque标识。
这两个维度组合后可以将Queue细分为四大类分别是
1.单端阻塞队列其实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue和DelayQueue。内部一般会持有一个队列这个队列可以是数组其实现是ArrayBlockingQueue也可以是链表其实现是LinkedBlockingQueue甚至还可以不持有队列其实现是SynchronousQueue此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合LinkedBlockingQueue和SynchronousQueue的功能性能比LinkedBlockingQueue更好PriorityBlockingQueue支持按照优先级出队DelayQueue支持延时出队。
单端阻塞队列示意图
2.双端阻塞队列其实现是LinkedBlockingDeque。
双端阻塞队列示意图
3.单端非阻塞队列其实现是ConcurrentLinkedQueue。-
4.双端非阻塞队列其实现是ConcurrentLinkedDeque。
另外使用队列时需要格外注意队列是否支持有界所谓有界指的是内部的队列是否有容量限制。实际工作中一般都不建议使用无界的队列因为数据量大了之后很容易导致OOM。上面我们提到的这些Queue中只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的所以在使用其他无界队列时一定要充分考虑是否存在导致OOM的隐患。
总结
Java并发容器的内容很多但鉴于篇幅有限我们只是对一些关键点进行了梳理和介绍。
而在实际工作中你不单要清楚每种容器的特性还要能选对容器这才是关键至于每种容器的用法用的时候看一下API说明就可以了这些容器的使用都不难。在文中我们甚至都没有介绍Java容器的快速失败机制Fail-Fast原因就在于当你选对容器的时候根本不会触发它。
课后思考
线上系统CPU突然飙升你怀疑有同学在并发场景里使用了HashMap因为在1.8之前的版本里并发执行HashMap.put()可能会导致CPU飙升到100%,你觉得该如何验证你的猜测呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,265 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 原子类:无锁工具类的典范
前面我们多次提到一个累加器的例子示例代码如下。在这个例子中add10K()这个方法不是线程安全的问题就出在变量count的可见性和count+=1的原子性上。可见性问题可以用volatile来解决而原子性问题我们前面一直都是采用的互斥锁方案。
public class Test {
long count = 0;
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
}
其实对于简单的原子性问题还有一种无锁方案Java SDK并发包将这种无锁方案封装提炼之后实现了一系列的原子类不过在深入介绍原子类的实现之前我们先看看如何利用原子类解决累加器问题这样你会对原子类有个初步的认识
在下面的代码中我们将原来的long型变量count替换为了原子类AtomicLong原来的 count +=1 替换成了 count.getAndIncrement()仅需要这两处简单的改动就能使add10K()方法变成线程安全的原子类的使用还是挺简单的
public class Test {
AtomicLong count =
new AtomicLong(0);
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count.getAndIncrement();
}
}
}
无锁方案相对互斥锁方案最大的好处就是性能互斥锁方案为了保证互斥性需要执行加锁解锁操作而加锁解锁操作本身就消耗性能同时拿不到锁的线程还会进入阻塞状态进而触发线程切换线程切换对性能的消耗也很大 相比之下无锁方案则完全没有加锁解锁的性能消耗同时还能保证互斥性既解决了问题又没有带来新的问题可谓绝佳方案那它是如何做到的呢
无锁方案的实现原理
其实原子类性能高的秘密很简单硬件支持而已CPU为了解决并发问题提供了CAS指令CAS全称是Compare And Swap比较并交换”)。CAS指令包含3个参数共享变量的内存地址A用于比较的值B和共享变量的新值C并且只有当内存中地址A处的值等于B时才能将内存中地址A处的值更新为新值C作为一条CPU指令CAS指令本身是能够保证原子性的
你可以通过下面CAS指令的模拟代码来理解CAS的工作原理在下面的模拟程序中有两个参数一个是期望值expect另一个是需要写入的新值newValue只有当目前count的值和期望值expect相等时才会将count更新为newValue
class SimulatedCAS{
int count
synchronized int cas(
int expect, int newValue){
// 读目前count的值
int curValue = count;
// 比较目前count值是否==期望值
if(curValue == expect){
// 如果是则更新count的值
count = newValue;
}
// 返回写入前的值
return curValue;
}
}
你仔细地再次思考一下这句话,“只有当目前count的值和期望值expect相等时才会将count更新为newValue。”要怎么理解这句话呢
对于前面提到的累加器的例子count += 1 的一个核心问题是基于内存中count的当前值A计算出来的count+=1为A+1在将A+1写入内存的时候很可能此时内存中count已经被其他线程更新过了这样就会导致错误地覆盖其他线程写入的值如果你觉得理解起来还有困难建议你再重新看看01 | 可见性原子性和有序性问题并发编程Bug的源头》)。也就是说只有当内存中count的值等于期望值A时才能将内存中count的值更新为计算结果A+1这不就是CAS的语义吗
使用CAS来解决并发问题一般都会伴随着自旋而所谓自旋其实就是循环尝试例如实现一个线程安全的count += 1操作,“CAS+自旋的实现方案如下所示首先计算newValue = count+1如果cas(count,newValue)返回的值不等于count则意味着线程在执行完代码①处之后执行代码②处之前count的值被其他线程更新过。那此时该怎么处理呢可以采用自旋方案就像下面代码中展示的可以重新读count最新的值来计算newValue并尝试再次更新直到成功。
class SimulatedCAS{
volatile int count;
// 实现count+=1
addOne(){
do {
newValue = count+1; //①
}while(count !=
cas(count,newValue) //②
}
// 模拟实现CAS仅用来帮助理解
synchronized int cas(
int expect, int newValue){
// 读目前count的值
int curValue = count;
// 比较目前count值是否==期望值
if(curValue == expect){
// 如果是则更新count的值
count= newValue;
}
// 返回写入前的值
return curValue;
}
}
通过上面的示例代码想必你已经发现了CAS这种无锁方案完全没有加锁解锁操作即便两个线程完全同时执行addOne()方法也不会有线程被阻塞所以相对于互斥锁方案来说性能好了很多
但是在CAS方案中有一个问题可能会常被你忽略那就是ABA的问题什么是ABA问题呢
前面我们提到如果cas(count,newValue)返回的值不等于count意味着线程在执行完代码处之后执行代码处之前count的值被其他线程更新过”,那如果cas(count,newValue)返回的值等于count是否就能够认为count的值没有被其他线程更新过呢显然不是的假设count原本是A线程T1在执行完代码处之后执行代码处之前有可能count被线程T2更新成了B之后又被T3更新回了A这样线程T1虽然看到的一直是A但是其实已经被其他线程更新过了这就是ABA问题
可能大多数情况下我们并不关心ABA问题例如数值的原子递增但也不能所有情况下都不关心例如原子化的更新对象很可能就需要关心ABA问题因为两个A虽然相等但是第二个A的属性可能已经发生变化了所以在使用CAS方案的时候一定要先check一下
看Java如何实现原子化的count += 1
在本文开始部分我们使用原子类AtomicLong的getAndIncrement()方法替代了count += 1从而实现了线程安全原子类AtomicLong的getAndIncrement()方法内部就是基于CAS实现的下面我们来看看Java是如何使用CAS来实现原子化的count += 1的
在Java 1.8版本中getAndIncrement()方法会转调unsafe.getAndAddLong()方法这里this和valueOffset两个参数可以唯一确定共享变量的内存地址
final long getAndIncrement() {
return unsafe.getAndAddLong(
this, valueOffset, 1L);
}
unsafe.getAndAddLong()方法的源码如下该方法首先会在内存中读取共享变量的值之后循环调用compareAndSwapLong()方法来尝试设置共享变量的值直到成功为止compareAndSwapLong()是一个native方法只有当内存中共享变量的值等于expected时才会将共享变量的值更新为x并且返回true否则返回faslecompareAndSwapLong的语义和CAS指令的语义的差别仅仅是返回值不同而已
public final long getAndAddLong(
Object o, long offset, long delta){
long v;
do {
// 读取内存中的值
v = getLongVolatile(o, offset);
} while (!compareAndSwapLong(
o, offset, v, v + delta));
return v;
}
//原子性地将变量更新为x
//条件是内存中的值等于expected
//更新成功则返回true
native boolean compareAndSwapLong(
Object o, long offset,
long expected,
long x);
另外需要你注意的是getAndAddLong()方法的实现基本上就是CAS使用的经典范例所以请你再次体会下面这段抽象后的代码片段它在很多无锁程序中经常出现Java提供的原子类里面CAS一般被实现为compareAndSet()compareAndSet()的语义和CAS指令的语义的差别仅仅是返回值不同而已compareAndSet()里面如果更新成功则会返回true否则返回false
do {
// 获取当前值
oldV = xxxx
// 根据当前值计算新值
newV = ...oldV...
}while(!compareAndSet(oldV,newV);
原子类概览
Java SDK并发包里提供的原子类内容很丰富我们可以将它们分为五个类别原子化的基本数据类型原子化的对象引用类型原子化数组原子化对象属性更新器和原子化的累加器这五个类别提供的方法基本上是相似的并且每个类别都有若干原子类你可以通过下面的原子类组成概览图来获得一个全局的印象下面我们详细解读这五个类别
原子类组成概览图
1. 原子化的基本数据类型
相关实现有AtomicBooleanAtomicInteger和AtomicLong提供的方法主要有以下这些详情你可以参考SDK的源代码都很简单这里就不详细介绍了
getAndIncrement() //原子化i++
getAndDecrement() //原子化的i--
incrementAndGet() //原子化的++i
decrementAndGet() //原子化的--i
//当前值+=delta返回+=前的值
getAndAdd(delta)
//当前值+=delta返回+=后的值
addAndGet(delta)
//CAS操作返回是否成功
compareAndSet(expect, update)
//以下四个方法
//新值可以通过传入func函数来计算
getAndUpdate(func)
updateAndGet(func)
getAndAccumulate(x,func)
accumulateAndGet(x,func)
2. 原子化的对象引用类型
相关实现有AtomicReferenceAtomicStampedReference和AtomicMarkableReference利用它们可以实现对象引用的原子化更新AtomicReference提供的方法和原子化的基本数据类型差不多这里不再赘述不过需要注意的是对象引用的更新需要重点关注ABA问题AtomicStampedReference和AtomicMarkableReference这两个原子类可以解决ABA问题
解决ABA问题的思路其实很简单增加一个版本号维度就可以了这个和我们在18 | StampedLock有没有比读写锁更快的锁?》介绍的乐观锁机制很类似每次执行CAS操作附加再更新一个版本号只要保证版本号是递增的那么即便A变成B之后再变回A版本号也不会变回来版本号递增的)。AtomicStampedReference实现的CAS方法就增加了版本号参数方法签名如下
boolean compareAndSet(
V expectedReference,
V newReference,
int expectedStamp,
int newStamp)
AtomicMarkableReference的实现机制则更简单将版本号简化成了一个Boolean值方法签名如下
boolean compareAndSet(
V expectedReference,
V newReference,
boolean expectedMark,
boolean newMark)
3. 原子化数组
相关实现有AtomicIntegerArrayAtomicLongArray和AtomicReferenceArray利用这些原子类我们可以原子化地更新数组里面的每一个元素这些类提供的方法和原子化的基本数据类型的区别仅仅是每个方法多了一个数组的索引参数所以这里也不再赘述了
4. 原子化对象属性更新器
相关实现有AtomicIntegerFieldUpdaterAtomicLongFieldUpdater和AtomicReferenceFieldUpdater利用它们可以原子化地更新对象的属性这三个方法都是利用反射机制实现的创建更新器的方法如下
public static <U>
AtomicXXXFieldUpdater<U>
newUpdater(Class<U> tclass,
String fieldName)
需要注意的是对象属性必须是volatile类型的只有这样才能保证可见性如果对象属性不是volatile类型的newUpdater()方法会抛出IllegalArgumentException这个运行时异常。
你会发现newUpdater()的方法参数只有类的信息没有对象的引用而更新对象的属性一定需要对象的引用那这个参数是在哪里传入的呢是在原子操作的方法参数中传入的。例如compareAndSet()这个原子操作相比原子化的基本数据类型多了一个对象引用obj。原子化对象属性更新器相关的方法相比原子化的基本数据类型仅仅是多了对象引用参数所以这里也不再赘述了。
boolean compareAndSet(
T obj,
int expect,
int update)
5. 原子化的累加器
DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder这四个类仅仅用来执行累加操作相比原子化的基本数据类型速度更快但是不支持compareAndSet()方法。如果你仅仅需要累加操作,使用原子化的累加器性能会更好。
总结
无锁方案相对于互斥锁方案优点非常多首先性能好其次是基本不会出现死锁问题但可能出现饥饿和活锁问题因为自旋会反复重试。Java提供的原子类大部分都实现了compareAndSet()方法基于compareAndSet()方法,你可以构建自己的无锁数据结构,但是建议你不要这样做,这个工作最好还是让大师们去完成,原因是无锁算法没你想象的那么简单。
Java提供的原子类能够解决一些简单的原子性问题但你可能会发现上面我们所有原子类的方法都是针对一个共享变量的如果你需要解决多个变量的原子性问题建议还是使用互斥锁方案。原子类虽好但使用要慎之又慎。
课后思考
下面的示例代码是合理库存的原子化实现仅实现了设置库存上限setUpper()方法你觉得setUpper()方法的实现是否正确呢?
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
//省略构造函数实现
}
}
final AtomicReference<WMRange>
rf = new AtomicReference<>(
new WMRange(0,0)
);
// 设置库存上限
void setUpper(int v){
WMRange nr;
WMRange or = rf.get();
do{
// 检查参数合法性
if(v < or.lower){
throw new IllegalArgumentException();
}
nr = new
WMRange(v, or.lower);
}while(!rf.compareAndSet(or, nr));
}
}
欢迎在留言区与我分享你的想法也欢迎你在留言区记录你的思考过程感谢阅读如果你觉得这篇文章对你有帮助的话也欢迎把它分享给更多的朋友

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 Executor与线程池如何创建正确的线程池
虽然在Java语言中创建线程看上去就像创建一个对象一样简单只需要new Thread()就可以了但实际上创建线程远不是创建一个对象那么简单。创建对象仅仅是在JVM的堆里分配一块内存而已而创建一个线程却需要调用操作系统内核的API然后操作系统要为线程分配一系列的资源这个成本就很高了所以线程是一个重量级的对象应该避免频繁创建和销毁。
那如何避免呢?应对方案估计你已经知道了,那就是线程池。
线程池的需求是如此普遍所以Java SDK并发包自然也少不了它。但是很多人在初次接触并发包里线程池相关的工具类时多少会都有点蒙不知道该从哪里入手我觉得根本原因在于线程池和一般意义上的池化资源是不同的。一般意义上的池化资源都是下面这样当你需要资源的时候就调用acquire()方法来申请资源用完之后就调用release()释放资源。若你带着这个固有模型来看并发包里线程池相关的工具类时会很遗憾地发现它们完全匹配不上Java提供的线程池里面压根就没有申请线程和释放线程的方法。
class XXXPool{
// 获取池化资源
XXX acquire() {
}
// 释放池化资源
void release(XXX x){
}
}
线程池是一种生产者-消费者模式
为什么线程池没有采用一般意义上池化资源的设计方法呢如果线程池采用一般意义上池化资源的设计方法应该是下面示例代码这样。你可以来思考一下假设我们获取到一个空闲线程T1然后该如何使用T1呢你期望的可能是这样通过调用T1的execute()方法传入一个Runnable对象来执行具体业务逻辑就像通过构造函数Thread(Runnable target)创建线程一样。可惜的是你翻遍Thread对象的所有方法都不存在类似execute(Runnable target)这样的公共方法。
//采用一般意义上池化资源的设计方法
class ThreadPool{
// 获取空闲线程
Thread acquire() {
}
// 释放线程
void release(Thread t){
}
}
//期望的使用
ThreadPool pool
Thread T1=pool.acquire();
//传入Runnable对象
T1.execute(()->{
//具体业务逻辑
......
});
所以,线程池的设计,没有办法直接采用一般意义上池化资源的设计方法。那线程池该如何设计呢?目前业界线程池的设计,普遍采用的都是生产者-消费者模式。线程池的使用方是生产者线程池本身是消费者。在下面的示例代码中我们创建了一个非常简单的线程池MyThreadPool你可以通过它来理解线程池的工作原理。
//简化的线程池,仅用来说明工作原理
class MyThreadPool{
//利用阻塞队列实现生产者-消费者模式
BlockingQueue<Runnable> workQueue;
//保存内部工作线程
List<WorkerThread> threads
= new ArrayList<>();
// 构造方法
MyThreadPool(int poolSize,
BlockingQueue<Runnable> workQueue){
this.workQueue = workQueue;
// 创建工作线程
for(int idx=0; idx<poolSize; idx++){
WorkerThread work = new WorkerThread();
work.start();
threads.add(work);
}
}
// 提交任务
void execute(Runnable command){
workQueue.put(command);
}
// 工作线程负责消费任务并执行任务
class WorkerThread extends Thread{
public void run() {
//循环取任务并执行
while(true){
Runnable task = workQueue.take();
task.run();
}
}
}
}
/** 下面是使用示例 **/
// 创建有界阻塞队列
BlockingQueue<Runnable> workQueue =
new LinkedBlockingQueue<>(2);
// 创建线程池
MyThreadPool pool = new MyThreadPool(
10, workQueue);
// 提交任务
pool.execute(()->{
System.out.println("hello");
});
在MyThreadPool的内部我们维护了一个阻塞队列workQueue和一组工作线程工作线程的个数由构造函数中的poolSize来指定。用户通过调用execute()方法来提交Runnable任务execute()方法的内部实现仅仅是将任务加入到workQueue中。MyThreadPool内部维护的工作线程会消费workQueue中的任务并执行任务相关的代码就是代码①处的while循环。线程池主要的工作原理就这些是不是还挺简单的
如何使用Java中的线程池
Java并发包里提供的线程池远比我们上面的示例代码强大得多当然也复杂得多。Java提供的线程池相关的工具类中最核心的是ThreadPoolExecutor通过名字你也能看出来它强调的是Executor而不是一般意义上的池化资源。
ThreadPoolExecutor的构造函数非常复杂如下面代码所示这个最完备的构造函数有7个参数。
ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
下面我们一一介绍这些参数的意义,你可以把线程池类比为一个项目组,而线程就是项目组的成员。
corePoolSize表示线程池保有的最小线程数。有些项目很闲但是也不能把人都撤了至少要留corePoolSize个人坚守阵地。
maximumPoolSize表示线程池创建的最大线程数。当项目很忙时就需要加人但是也不能无限制地加最多就加到maximumPoolSize个人。当项目闲下来时就要撤人了最多能撤到corePoolSize个人。
keepAliveTime & unit上面提到项目根据忙闲来增减人员那在编程世界里如何定义忙和闲呢很简单一个线程如果在一段时间内都没有执行任务说明很闲keepAliveTime 和 unit 就是用来定义这个“一段时间”的参数。也就是说如果一个线程空闲了keepAliveTime & unit这么久而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了。
workQueue工作队列和上面示例代码的工作队列同义。
threadFactory通过这个参数你可以自定义如何创建线程例如你可以给线程指定一个有意义的名字。
handler通过这个参数你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌并且工作队列也满了前提是工作队列是有界队列那么此时提交任务线程池就会拒绝接收。至于拒绝的策略你可以通过handler这个参数来指定。ThreadPoolExecutor已经提供了以下4种策略。
CallerRunsPolicy提交任务的线程自己去执行该任务。
AbortPolicy默认的拒绝策略会throws RejectedExecutionException。
DiscardPolicy直接丢弃任务没有任何异常抛出。
DiscardOldestPolicy丢弃最老的任务其实就是把最早进入工作队列的任务丢弃然后把新任务加入到工作队列。
Java在1.6版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时,这意味着如果项目很闲,就会将项目组的成员都撤走。
使用线程池要注意些什么
考虑到ThreadPoolExecutor的构造函数实在是有些复杂所以Java并发包里提供了一个线程池的静态工厂类Executors利用Executors你可以快速创建线程池。不过目前大厂的编码规范中基本上都不建议使用Executors了所以这里我就不再花篇幅介绍了。
不建议使用Executors的最重要的原因是Executors提供的很多方法默认使用的都是无界的LinkedBlockingQueue高负载情境下无界队列很容易导致OOM而OOM会导致所有请求都无法处理这是致命问题。所以强烈建议使用有界队列。
使用有界队列当任务过多时线程池会触发执行拒绝策略线程池默认的拒绝策略会throw RejectedExecutionException 这是个运行时异常对于运行时异常编译器并不强制catch它所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要建议自定义自己的拒绝策略并且在实际工作中自定义的拒绝策略往往和降级策略配合使用。
使用线程池还要注意异常处理的问题例如通过ThreadPoolExecutor对象的execute()方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;不过,最致命的是任务虽然异常了,但是你却获取不到任何通知,这会让你误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理,你可以参考下面的示例代码。
try {
//业务逻辑
} catch (RuntimeException x) {
//按需处理
} catch (Throwable x) {
//按需处理
}
总结
线程池在Java并发编程领域非常重要很多大厂的编码规范都要求必须通过线程池来管理线程。线程池和普通的池化资源有很大不同线程池实际上是生产者-消费者模式的一种实现,理解生产者-消费者模式是理解线程池的关键所在。
创建线程池设置合适的线程数非常重要这部分内容你可以参考《10 | Java线程创建多少线程才是合适的》的内容。另外《Java并发编程实战》的第7章《取消与关闭》的7.3节“处理非正常的线程终止” 详细介绍了异常处理的方案第8章《线程池的使用》对线程池的使用也有更深入的介绍如果你感兴趣或有需要的话建议你仔细阅读。
课后思考
使用线程池默认情况下创建的线程名字都类似pool-1-thread-2这样没有业务含义。而很多情况下为了便于诊断问题都需要给线程赋予一个有意义的名字那你知道有哪些办法可以给线程池里的线程指定名字吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,220 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 Future如何用多线程实现最优的“烧水泡茶”程序
在上一篇文章《22 | Executor与线程池如何创建正确的线程池》中我们详细介绍了如何创建正确的线程池那创建完线程池我们该如何使用呢在上一篇文章中我们仅仅介绍了ThreadPoolExecutor的 void execute(Runnable command) 方法利用这个方法虽然可以提交任务但是却没有办法获取任务的执行结果execute()方法没有返回值。而很多场景下我们又都是需要获取任务的执行结果的。那ThreadPoolExecutor是否提供了相关功能呢必须的这么重要的功能当然需要提供了。
下面我们就来介绍一下使用ThreadPoolExecutor的时候如何获取任务执行结果。
如何获取任务执行结果
Java通过ThreadPoolExecutor提供的3个submit()方法和1个FutureTask工具类来支持获得任务执行结果的需求。下面我们先来介绍这3个submit()方法这3个方法的方法签名如下。
// 提交Runnable任务
Future<?>
submit(Runnable task);
// 提交Callable任务
<T> Future<T>
submit(Callable<T> task);
// 提交Runnable任务及结果引用
<T> Future<T>
submit(Runnable task, T result);
你会发现它们的返回值都是Future接口Future接口有5个方法我都列在下面了它们分别是取消任务的方法cancel()、判断任务是否已取消的方法isCancelled()、判断任务是否已结束的方法isDone()以及2个获得任务执行结果的get()和get(timeout, unit)其中最后一个get(timeout, unit)支持超时机制。通过Future接口的这5个方法你会发现我们提交的任务不但能够获取任务执行结果还可以取消任务。不过需要注意的是这两个get()方法都是阻塞式的如果被调用的时候任务还没有执行完那么调用get()方法的线程会阻塞,直到任务执行完才会被唤醒。
// 取消任务
boolean cancel(
boolean mayInterruptIfRunning);
// 判断任务是否已取消
boolean isCancelled();
// 判断任务是否已结束
boolean isDone();
// 获得任务执行结果
get();
// 获得任务执行结果,支持超时
get(long timeout, TimeUnit unit);
这3个submit()方法之间的区别在于方法参数不同,下面我们简要介绍一下。
提交Runnable任务 submit(Runnable task) 这个方法的参数是一个Runnable接口Runnable接口的run()方法是没有返回值的,所以 submit(Runnable task) 这个方法返回的Future仅可以用来断言任务已经结束了类似于Thread.join()。
提交Callable任务 submit(Callable<T> task)这个方法的参数是一个Callable接口它只有一个call()方法并且这个方法是有返回值的所以这个方法返回的Future对象可以通过调用其get()方法来获取任务的执行结果。
提交Runnable任务及结果引用 submit(Runnable task, T result)这个方法很有意思假设这个方法返回的Future对象是ff.get()的返回值就是传给submit()方法的参数result。这个方法该怎么用呢下面这段示例代码展示了它的经典用法。需要你注意的是Runnable接口的实现类Task声明了一个有参构造函数 Task(Result r) 创建Task对象的时候传入了result对象这样就能在类Task的run()方法中对result进行各种操作了。result相当于主线程和子线程之间的桥梁通过它主子线程可以共享数据。
ExecutorService executor
= Executors.newFixedThreadPool(1);
// 创建Result对象r
Result r = new Result();
r.setAAA(a);
// 提交任务
Future future =
executor.submit(new Task®, r);
Result fr = future.get();
// 下面等式成立
fr === r;
fr.getAAA() === a;
fr.getXXX() === x
class Task implements Runnable{
Result r;
//通过构造函数传入result
Task(Result r){
this.r = r;
}
void run() {
//可以操作result
a = r.getAAA();
r.setXXX(x);
}
}
下面我们再来介绍FutureTask工具类。前面我们提到的Future是一个接口而FutureTask是一个实实在在的工具类这个工具类有两个构造函数它们的参数和前面介绍的submit()方法类似,所以这里我就不再赘述了。
FutureTask(Callable<V> callable);
FutureTask(Runnable runnable, V result);
那如何使用FutureTask呢其实很简单FutureTask实现了Runnable和Future接口由于实现了Runnable接口所以可以将FutureTask对象作为任务提交给ThreadPoolExecutor去执行也可以直接被Thread执行又因为实现了Future接口所以也能用来获得任务的执行结果。下面的示例代码是将FutureTask对象提交给ThreadPoolExecutor去执行。
// 创建FutureTask
FutureTask<Integer> futureTask
= new FutureTask<>(()-> 1+2);
// 创建线程池
ExecutorService es =
Executors.newCachedThreadPool();
// 提交FutureTask
es.submit(futureTask);
// 获取计算结果
Integer result = futureTask.get();
FutureTask对象直接被Thread执行的示例代码如下所示。相信你已经发现了利用FutureTask对象可以很容易获取子线程的执行结果。
// 创建FutureTask
FutureTask<Integer> futureTask
= new FutureTask<>(()-> 1+2);
// 创建并启动线程
Thread T1 = new Thread(futureTask);
T1.start();
// 获取计算结果
Integer result = futureTask.get();
实现最优的“烧水泡茶”程序
记得以前初中语文课文里有一篇著名数学家华罗庚先生的文章《统筹方法》,这篇文章里介绍了一个烧水泡茶的例子,文中提到最优的工序应该是下面这样:
烧水泡茶最优工序
下面我们用程序来模拟一下这个最优工序。我们专栏前面曾经提到并发编程可以总结为三个核心问题分工、同步和互斥。编写并发程序首先要做的就是分工所谓分工指的是如何高效地拆解任务并分配给线程。对于烧水泡茶这个程序一种最优的分工方案可以是下图所示的这样用两个线程T1和T2来完成烧水泡茶程序T1负责洗水壶、烧开水、泡茶这三道工序T2负责洗茶壶、洗茶杯、拿茶叶三道工序其中T1在执行泡茶这道工序时需要等待T2完成拿茶叶的工序。对于T1的这个等待动作你应该可以想出很多种办法例如Thread.join()、CountDownLatch甚至阻塞队列都可以解决不过今天我们用Future特性来实现。
烧水泡茶最优分工方案
下面的示例代码就是用这一章提到的Future特性来实现的。首先我们创建了两个FutureTask——ft1和ft2ft1完成洗水壶、烧开水、泡茶的任务ft2完成洗茶壶、洗茶杯、拿茶叶的任务这里需要注意的是ft1这个任务在执行泡茶任务前需要等待ft2把茶叶拿来所以ft1内部需要引用ft2并在执行泡茶之前调用ft2的get()方法实现等待。
// 创建任务T2的FutureTask
FutureTask<String> ft2
= new FutureTask<>(new T2Task());
// 创建任务T1的FutureTask
FutureTask<String> ft1
= new FutureTask<>(new T1Task(ft2));
// 线程T1执行任务ft1
Thread T1 = new Thread(ft1);
T1.start();
// 线程T2执行任务ft2
Thread T2 = new Thread(ft2);
T2.start();
// 等待线程T1执行结果
System.out.println(ft1.get());
// T1Task需要执行的任务
// 洗水壶、烧开水、泡茶
class T1Task implements Callable<String>{
FutureTask<String> ft2;
// T1任务需要T2任务的FutureTask
T1Task(FutureTask<String> ft2){
this.ft2 = ft2;
}
@Override
String call() throws Exception {
System.out.println("T1:洗水壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T1:烧开水...");
TimeUnit.SECONDS.sleep(15);
// 获取T2线程的茶叶
String tf = ft2.get();
System.out.println("T1:拿到茶叶:"+tf);
System.out.println("T1:泡茶...");
return "上茶:" + tf;
}
}
// T2Task需要执行的任务:
// 洗茶壶、洗茶杯、拿茶叶
class T2Task implements Callable<String> {
@Override
String call() throws Exception {
System.out.println("T2:洗茶壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T2:洗茶杯...");
TimeUnit.SECONDS.sleep(2);
System.out.println("T2:拿茶叶...");
TimeUnit.SECONDS.sleep(1);
return "龙井";
}
}
// 一次执行结果:
T1:洗水壶...
T2:洗茶壶...
T1:烧开水...
T2:洗茶杯...
T2:拿茶叶...
T1:拿到茶叶:龙井
T1:泡茶...
上茶:龙井
总结
利用Java并发包提供的Future可以很容易获得异步任务的执行结果无论异步任务是通过线程池ThreadPoolExecutor执行的还是通过手工创建子线程来执行的。Future可以类比为现实世界里的提货单比如去蛋糕店订生日蛋糕蛋糕店都是先给你一张提货单你拿到提货单之后没有必要一直在店里等着可以先去干点其他事比如看场电影等看完电影后基本上蛋糕也做好了然后你就可以凭提货单领蛋糕了。
利用多线程可以快速将一些串行的任务并行化从而提高性能如果任务之间有依赖关系比如当前任务依赖前一个任务的执行结果这种问题基本上都可以用Future来解决。在分析这种问题的过程中建议你用有向图描述一下任务之间的依赖关系同时将线程的分工也做好类似于烧水泡茶最优分工方案那幅图。对照图来写代码好处是更形象且不易出错。
课后思考
不久前听说小明要做一个询价应用,这个应用需要从三个电商询价,然后保存在自己的数据库里。核心示例代码如下所示,由于是串行的,所以性能很慢,你来试着优化一下吧。
// 向电商S1询价并保存
r1 = getPriceByS1();
save(r1);
// 向电商S2询价并保存
r2 = getPriceByS2();
save(r2);
// 向电商S3询价并保存
r3 = getPriceByS3();
save(r3);
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,279 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 CompletableFuture异步编程没那么难
前面我们不止一次提到,用多线程优化性能,其实不过就是将串行操作变成并行操作。如果仔细观察,你还会发现在串行转换成并行的过程中,一定会涉及到异步化,例如下面的示例代码,现在是串行的,为了提升性能,我们得把它们并行化,那具体实施起来该怎么做呢?
//以下两个方法都是耗时操作
doBizA();
doBizB();
还是挺简单的就像下面代码中这样创建两个子线程去执行就可以了。你会发现下面的并行方案主线程无需等待doBizA()和doBizB()的执行结果也就是说doBizA()和doBizB()两个操作已经被异步化了。
new Thread(()->doBizA())
.start();
new Thread(()->doBizB())
.start();
异步化是并行方案得以实施的基础更深入地讲其实就是利用多线程优化性能这个核心方案得以实施的基础。看到这里相信你应该就能理解异步编程最近几年为什么会大火了因为优化性能是互联网大厂的一个核心需求啊。Java在1.8版本提供了CompletableFuture来支持异步编程CompletableFuture有可能是你见过的最复杂的工具类了不过功能也着实让人感到震撼。
CompletableFuture的核心优势
为了领略CompletableFuture异步编程的优势这里我们用CompletableFuture重新实现前面曾提及的烧水泡茶程序。首先还是需要先完成分工方案在下面的程序中我们分了3个任务任务1负责洗水壶、烧开水任务2负责洗茶壶、洗茶杯和拿茶叶任务3负责泡茶。其中任务3要等待任务1和任务2都完成后才能开始。这个分工如下图所示。
烧水泡茶分工方案
下面是代码实现你先略过runAsync()、supplyAsync()、thenCombine()这些不太熟悉的方法,从大局上看,你会发现:
无需手工维护线程,没有繁琐的手工维护线程的工作,给任务分配线程的工作也不需要我们关注;
语义更清晰,例如 f3 = f1.thenCombine(f2, ()->{}) 能够清晰地表述“任务3要等待任务1和任务2都完成后才能开始”
代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的。
//任务1洗水壶->烧开水
CompletableFuture f1 =
CompletableFuture.runAsync(()->{
System.out.println(“T1:洗水壶…”);
sleep(1, TimeUnit.SECONDS);
System.out.println(“T1:烧开水…”);
sleep(15, TimeUnit.SECONDS);
});
//任务2洗茶壶->洗茶杯->拿茶叶
CompletableFuture f2 =
CompletableFuture.supplyAsync(()->{
System.out.println(“T2:洗茶壶…”);
sleep(1, TimeUnit.SECONDS);
System.out.println(“T2:洗茶杯…”);
sleep(2, TimeUnit.SECONDS);
System.out.println(“T2:拿茶叶…”);
sleep(1, TimeUnit.SECONDS);
return “龙井”;
});
//任务3任务1和任务2完成后执行泡茶
CompletableFuture f3 =
f1.thenCombine(f2, (__, tf)->{
System.out.println("T1:拿到茶叶:" + tf);
System.out.println("T1:泡茶...");
return "上茶:" + tf;
});
//等待任务3执行结果
System.out.println(f3.join());
void sleep(int t, TimeUnit u) {
try {
u.sleep(t);
}catch(InterruptedException e){}
}
// 一次执行结果:
T1:洗水壶…
T2:洗茶壶…
T1:烧开水…
T2:洗茶杯…
T2:拿茶叶…
T1:拿到茶叶:龙井
T1:泡茶…
上茶:龙井
领略CompletableFuture异步编程的优势之后下面我们详细介绍CompletableFuture的使用首先是如何创建CompletableFuture对象。
创建CompletableFuture对象
创建CompletableFuture对象主要靠下面代码中展示的这4个静态方法我们先看前两个。在烧水泡茶的例子中我们已经使用了runAsync(Runnable runnable)和supplyAsync(Supplier<U> supplier)它们之间的区别是Runnable 接口的run()方法没有返回值而Supplier接口的get()方法是有返回值的。
前两个方法和后两个方法的区别在于:后两个方法可以指定线程池参数。
默认情况下CompletableFuture会使用公共的ForkJoinPool线程池这个线程池默认创建的线程数是CPU的核数也可以通过JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism来设置ForkJoinPool线程池的线程数。如果所有CompletableFuture共享一个线程池那么一旦有任务执行一些很慢的I/O操作就会导致线程池中所有线程都阻塞在I/O操作上从而造成线程饥饿进而影响整个系统的性能。所以强烈建议你要根据不同的业务类型创建不同的线程池以避免互相干扰。
//使用默认线程池
static CompletableFuture<Void>
runAsync(Runnable runnable)
static <U> CompletableFuture<U>
supplyAsync(Supplier<U> supplier)
//可以指定线程池
static CompletableFuture<Void>
runAsync(Runnable runnable, Executor executor)
static <U> CompletableFuture<U>
supplyAsync(Supplier<U> supplier, Executor executor)
创建完CompletableFuture对象之后会自动地异步执行runnable.run()方法或者supplier.get()方法对于一个异步操作你需要关注两个问题一个是异步操作什么时候结束另一个是如何获取异步操作的执行结果。因为CompletableFuture类实现了Future接口所以这两个问题你都可以通过Future接口来解决。另外CompletableFuture类还实现了CompletionStage接口这个接口内容实在是太丰富了在1.8版本里有40个方法这些方法我们该如何理解呢
如何理解CompletionStage接口
我觉得,你可以站在分工的角度类比一下工作流。任务是有时序关系的,比如有串行关系、并行关系、汇聚关系等。这样说可能有点抽象,这里还举前面烧水泡茶的例子,其中洗水壶和烧开水就是串行关系,洗水壶、烧开水和洗茶壶、洗茶杯这两组任务之间就是并行关系,而烧开水、拿茶叶和泡茶就是汇聚关系。
串行关系
并行关系
汇聚关系
CompletionStage接口可以清晰地描述任务之间的这种时序关系例如前面提到的 f3 = f1.thenCombine(f2, ()->{}) 描述的就是一种汇聚关系。烧水泡茶程序中的汇聚关系是一种 AND 聚合关系这里的AND指的是所有依赖的任务烧开水和拿茶叶都完成后才开始执行当前任务泡茶。既然有AND聚合关系那就一定还有OR聚合关系所谓OR指的是依赖的任务只要有一个完成就可以执行当前任务。
在编程领域还有一个绕不过去的山头那就是异常处理CompletionStage接口也可以方便地描述异常处理。
下面我们就来一一介绍CompletionStage接口如何描述串行关系、AND聚合关系、OR聚合关系以及异常处理。
1. 描述串行关系
CompletionStage接口里面描述串行关系主要是thenApply、thenAccept、thenRun和thenCompose这四个系列的接口。
thenApply系列函数里参数fn的类型是接口Function这个接口里与CompletionStage相关的方法是 R apply(T t)这个方法既能接收参数也支持返回值所以thenApply系列方法返回的是CompletionStage<R>
而thenAccept系列方法里参数consumer的类型是接口Consumer<T>这个接口里与CompletionStage相关的方法是 void accept(T t)这个方法虽然支持参数但却不支持回值所以thenAccept系列方法返回的是CompletionStage<Void>
thenRun系列方法里action的参数是Runnable所以action既不能接收参数也不支持返回值所以thenRun系列方法返回的也是CompletionStage<Void>
这些方法里面Async代表的是异步执行fn、consumer或者action。其中需要你注意的是thenCompose系列方法这个系列的方法会新创建出一个子流程最终结果和thenApply系列是相同的。
CompletionStage<R> thenApply(fn);
CompletionStage<R> thenApplyAsync(fn);
CompletionStage<Void> thenAccept(consumer);
CompletionStage<Void> thenAcceptAsync(consumer);
CompletionStage<Void> thenRun(action);
CompletionStage<Void> thenRunAsync(action);
CompletionStage<R> thenCompose(fn);
CompletionStage<R> thenComposeAsync(fn);
通过下面的示例代码你可以看一下thenApply()方法是如何使用的。首先通过supplyAsync()启动一个异步流程,之后是两个串行操作,整体看起来还是挺简单的。不过,虽然这是一个异步流程,但任务①②③却是串行执行的,②依赖①的执行结果,③依赖②的执行结果。
CompletableFuture<String> f0 =
CompletableFuture.supplyAsync(
() -> "Hello World") //①
.thenApply(s -> s + " QQ") //②
.thenApply(String::toUpperCase);//③
System.out.println(f0.join());
//输出结果
HELLO WORLD QQ
2. 描述AND汇聚关系
CompletionStage接口里面描述AND汇聚关系主要是thenCombine、thenAcceptBoth和runAfterBoth系列的接口这些接口的区别也是源自fn、consumer、action这三个核心参数不同。它们的使用你可以参考上面烧水泡茶的实现程序这里就不赘述了。
CompletionStage<R> thenCombine(other, fn);
CompletionStage<R> thenCombineAsync(other, fn);
CompletionStage<Void> thenAcceptBoth(other, consumer);
CompletionStage<Void> thenAcceptBothAsync(other, consumer);
CompletionStage<Void> runAfterBoth(other, action);
CompletionStage<Void> runAfterBothAsync(other, action);
3. 描述OR汇聚关系
CompletionStage接口里面描述OR汇聚关系主要是applyToEither、acceptEither和runAfterEither系列的接口这些接口的区别也是源自fn、consumer、action这三个核心参数不同。
CompletionStage applyToEither(other, fn);
CompletionStage applyToEitherAsync(other, fn);
CompletionStage acceptEither(other, consumer);
CompletionStage acceptEitherAsync(other, consumer);
CompletionStage runAfterEither(other, action);
CompletionStage runAfterEitherAsync(other, action);
下面的示例代码展示了如何使用applyToEither()方法来描述一个OR汇聚关系。
CompletableFuture<String> f1 =
CompletableFuture.supplyAsync(()->{
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});
CompletableFuture<String> f2 =
CompletableFuture.supplyAsync(()->{
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});
CompletableFuture<String> f3 =
f1.applyToEither(f2,s -> s);
System.out.println(f3.join());
4. 异常处理
虽然上面我们提到的fn、consumer、action它们的核心方法都不允许抛出可检查异常但是却无法限制它们抛出运行时异常例如下面的代码执行 7/0 就会出现除零错误这个运行时异常。非异步编程里面我们可以使用try{}catch{}来捕获并处理异常,那在异步编程里面,异常该如何处理呢?
CompletableFuture<Integer>
f0 = CompletableFuture.
.supplyAsync(()->(7/0))
.thenApply(r->r*10);
System.out.println(f0.join());
CompletionStage接口给我们提供的方案非常简单比try{}catch{}还要简单,下面是相关的方法,使用这些方法进行异常处理和串行操作是一样的,都支持链式编程方式。
CompletionStage exceptionally(fn);
CompletionStage<R> whenComplete(consumer);
CompletionStage<R> whenCompleteAsync(consumer);
CompletionStage<R> handle(fn);
CompletionStage<R> handleAsync(fn);
下面的示例代码展示了如何使用exceptionally()方法来处理异常exceptionally()的使用非常类似于try{}catch{}中的catch{}但是由于支持链式编程方式所以相对更简单。既然有try{}catch{}那就一定还有try{}finally{}whenComplete()和handle()系列方法就类似于try{}finally{}中的finally{}无论是否发生异常都会执行whenComplete()中的回调函数consumer和handle()中的回调函数fn。whenComplete()和handle()的区别在于whenComplete()不支持返回结果而handle()是支持返回结果的。
CompletableFuture<Integer>
f0 = CompletableFuture
.supplyAsync(()->(7/0))
.thenApply(r->r*10)
.exceptionally(e->0);
System.out.println(f0.join());
总结
曾经一提到异步编程大家脑海里都会随之浮现回调函数例如在JavaScript里面异步问题基本上都是靠回调函数来解决的回调函数在处理异常以及复杂的异步任务关系时往往力不从心对此业界还发明了个名词回调地狱Callback Hell。应该说在前些年异步编程还是声名狼藉的。
不过最近几年伴随着ReactiveX的发展Java语言的实现版本是RxJava回调地狱已经被完美解决了异步编程已经慢慢开始成熟Java语言也开始官方支持异步编程在1.8版本提供了CompletableFuture在Java 9版本则提供了更加完备的Flow API异步编程目前已经完全工业化。因此学好异步编程还是很有必要的。
CompletableFuture已经能够满足简单的异步编程需求如果你对异步编程感兴趣可以重点关注RxJava这个项目利用RxJava即便在Java 1.6版本也能享受异步编程的乐趣。
课后思考
创建采购订单的时候需要校验一些规则例如最大金额是和采购员级别相关的。有同学利用CompletableFuture实现了这个校验的功能逻辑很简单首先是从数据库中把相关规则查出来然后执行规则校验。你觉得他的实现是否有问题呢
//采购订单
PurchersOrder po;
CompletableFuture<Boolean> cf =
CompletableFuture.supplyAsync(()->{
//在数据库中查询规则
return findRuleByJdbc();
}).thenApply(r -> {
//规则校验
return check(po, r);
});
Boolean isOk = cf.join();
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,212 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 CompletionService如何批量执行异步任务
在《23 | Future如何用多线程实现最优的“烧水泡茶”程序》的最后我给你留了道思考题如何优化一个询价应用的核心代码如果采用“ThreadPoolExecutor+Future”的方案你的优化结果很可能是下面示例代码这样用三个线程异步执行询价通过三次调用Future的get()方法获取询价结果,之后将询价结果保存在数据库中。
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 异步向电商S1询价
Future<Integer> f1 =
executor.submit(
()->getPriceByS1());
// 异步向电商S2询价
Future<Integer> f2 =
executor.submit(
()->getPriceByS2());
// 异步向电商S3询价
Future<Integer> f3 =
executor.submit(
()->getPriceByS3());
// 获取电商S1报价并保存
r=f1.get();
executor.execute(()->save(r));
// 获取电商S2报价并保存
r=f2.get();
executor.execute(()->save(r));
// 获取电商S3报价并保存
r=f3.get();
executor.execute(()->save(r));
上面的这个方案本身没有太大问题但是有个地方的处理需要你注意那就是如果获取电商S1报价的耗时很长那么即便获取电商S2报价的耗时很短也无法让保存S2报价的操作先执行因为这个主线程都阻塞在了 f1.get() 操作上。这点小瑕疵你该如何解决呢?
估计你已经想到了增加一个阻塞队列获取到S1、S2、S3的报价都进入阻塞队列然后在主线程中消费阻塞队列这样就能保证先获取到的报价先保存到数据库了。下面的示例代码展示了如何利用阻塞队列实现先获取到的报价先保存到数据库。
// 创建阻塞队列
BlockingQueue<Integer> bq =
new LinkedBlockingQueue<>();
//电商S1报价异步进入阻塞队列
executor.execute(()->
bq.put(f1.get()));
//电商S2报价异步进入阻塞队列
executor.execute(()->
bq.put(f2.get()));
//电商S3报价异步进入阻塞队列
executor.execute(()->
bq.put(f3.get()));
//异步保存所有报价
for (int i=0; i<3; i++) {
Integer r = bq.take();
executor.execute(()->save(r));
}
利用CompletionService实现询价系统
不过在实际项目中并不建议你这样做因为Java SDK并发包里已经提供了设计精良的CompletionService。利用CompletionService不但能帮你解决先获取到的报价先保存到数据库的问题而且还能让代码更简练。
CompletionService的实现原理也是内部维护了一个阻塞队列当任务执行结束就把任务的执行结果加入到阻塞队列中不同的是CompletionService是把任务执行结果的Future对象加入到阻塞队列中而上面的示例代码是把任务最终的执行结果放入了阻塞队列中。
那到底该如何创建CompletionService呢
CompletionService接口的实现类是ExecutorCompletionService这个实现类的构造方法有两个分别是
ExecutorCompletionService(Executor executor)
ExecutorCompletionService(Executor executor, BlockingQueue<Future<V>> completionQueue)。
这两个构造方法都需要传入一个线程池如果不指定completionQueue那么默认会使用无界的LinkedBlockingQueue。任务执行结果的Future对象就是加入到completionQueue中。
下面的示例代码完整地展示了如何利用CompletionService来实现高性能的询价系统。其中我们没有指定completionQueue因此默认使用无界的LinkedBlockingQueue。之后通过CompletionService接口提供的submit()方法提交了三个询价操作这三个询价操作将会被CompletionService异步执行。最后我们通过CompletionService接口提供的take()方法获取一个Future对象前面我们提到过加入到阻塞队列中的是任务执行结果的Future对象调用Future对象的get()方法就能返回询价操作的执行结果了。
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService<Integer> cs = new
ExecutorCompletionService<>(executor);
// 异步向电商S1询价
cs.submit(()->getPriceByS1());
// 异步向电商S2询价
cs.submit(()->getPriceByS2());
// 异步向电商S3询价
cs.submit(()->getPriceByS3());
// 将询价结果异步保存到数据库
for (int i=0; i<3; i++) {
Integer r = cs.take().get();
executor.execute(()->save(r));
}
CompletionService接口说明
下面我们详细地介绍一下CompletionService接口提供的方法CompletionService接口提供的方法有5个这5个方法的方法签名如下所示。
其中submit()相关的方法有两个。一个方法参数是Callable<V> task前面利用CompletionService实现询价系统的示例代码中我们提交任务就是用的它。另外一个方法有两个参数分别是Runnable task和V result这个方法类似于ThreadPoolExecutor的 <T> Future<T> submit(Runnable task, T result) 这个方法在《23 | Future如何用多线程实现最优的“烧水泡茶”程序》中我们已详细介绍过这里不再赘述。
CompletionService接口其余的3个方法都是和阻塞队列相关的take()、poll()都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值。 poll(long timeout, TimeUnit unit) 方法支持以超时的方式获取并移除阻塞队列头部的一个元素,如果等待了 timeout unit时间阻塞队列还是空的那么该方法会返回 null 值。
Future<V> submit(Callable<V> task);
Future<V> submit(Runnable task, V result);
Future<V> take()
throws InterruptedException;
Future<V> poll();
Future<V> poll(long timeout, TimeUnit unit)
throws InterruptedException;
利用CompletionService实现Dubbo中的Forking Cluster
Dubbo中有一种叫做Forking的集群模式这种集群模式下支持并行地调用多个查询服务只要有一个成功返回结果整个服务就可以返回了。例如你需要提供一个地址转坐标的服务为了保证该服务的高可用和性能你可以并行地调用3个地图服务商的API然后只要有1个正确返回了结果r那么地址转坐标这个服务就可以直接返回r了。这种集群模式可以容忍2个地图服务商服务异常但缺点是消耗的资源偏多。
geocoder(addr) {
//并行执行以下3个查询服务
r1=geocoderByS1(addr);
r2=geocoderByS2(addr);
r3=geocoderByS3(addr);
//只要r1,r2,r3有一个返回
//则返回
return r1|r2|r3;
}
利用CompletionService可以快速实现 Forking 这种集群模式比如下面的示例代码就展示了具体是如何实现的。首先我们创建了一个线程池executor 、一个CompletionService对象cs和一个Future<Integer>类型的列表 futures每次通过调用CompletionService的submit()方法提交一个异步任务会返回一个Future对象我们把这些Future对象保存在列表futures中。通过调用 cs.take().get(),我们能够拿到最快返回的任务执行结果,只要我们拿到一个正确返回的结果,就可以取消所有任务并且返回最终结果了。
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService<Integer> cs =
new ExecutorCompletionService<>(executor);
// 用于保存Future对象
List<Future<Integer>> futures =
new ArrayList<>(3);
//提交异步任务并保存future到futures
futures.add(
cs.submit(()->geocoderByS1()));
futures.add(
cs.submit(()->geocoderByS2()));
futures.add(
cs.submit(()->geocoderByS3()));
// 获取最快返回的任务执行结果
Integer r = 0;
try {
// 只要有一个成功返回则break
for (int i = 0; i < 3; ++i) {
r = cs.take().get();
//简单地通过判空来检查是否成功返回
if (r != null) {
break;
}
}
} finally {
//取消所有任务
for(Future<Integer> f : futures)
f.cancel(true);
}
// 返回结果
return r;
总结
当需要批量提交异步任务的时候建议你使用CompletionService。CompletionService将线程池Executor和阻塞队列BlockingQueue的功能融合在了一起能够让批量异步任务的管理更简单。除此之外CompletionService能够让异步任务的执行结果有序化先执行完的先进入阻塞队列利用这个特性你可以轻松实现后续处理的有序性避免无谓的等待同时还可以快速实现诸如Forking Cluster这样的需求。
CompletionService的实现类ExecutorCompletionService需要你自己创建线程池虽看上去有些啰嗦但好处是你可以让多个ExecutorCompletionService的线程池隔离这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险。
课后思考
本章使用CompletionService实现了一个询价应用的核心功能后来又有了新的需求需要计算出最低报价并返回下面的示例代码尝试实现这个需求你看看是否存在问题呢
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService<Integer> cs = new
ExecutorCompletionService<>(executor);
// 异步向电商S1询价
cs.submit(()->getPriceByS1());
// 异步向电商S2询价
cs.submit(()->getPriceByS2());
// 异步向电商S3询价
cs.submit(()->getPriceByS3());
// 将询价结果异步保存到数据库
// 并计算最低报价
AtomicReference<Integer> m =
new AtomicReference<>(Integer.MAX_VALUE);
for (int i=0; i<3; i++) {
executor.execute(()->{
Integer r = null;
try {
r = cs.take().get();
} catch (Exception e) {}
save(r);
m.set(Integer.min(m.get(), r));
});
}
return m;
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,196 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 Fork_Join单机版的MapReduce
前面几篇文章我们介绍了线程池、Future、CompletableFuture和CompletionService仔细观察你会发现这些工具类都是在帮助我们站在任务的视角来解决并发问题而不是让我们纠缠在线程之间如何协作的细节上比如线程之间如何实现等待、通知等。对于简单的并行任务你可以通过“线程池+Future”的方案来解决如果任务之间有聚合关系无论是AND聚合还是OR聚合都可以通过CompletableFuture来解决而批量的并行任务则可以通过CompletionService来解决。
我们一直讲并发编程可以分为三个层面的问题分别是分工、协作和互斥当你关注于任务的时候你会发现你的视角已经从并发编程的细节中跳出来了你应用的更多的是现实世界的思维模式类比的往往是现实世界里的分工所以我把线程池、Future、CompletableFuture和CompletionService都列到了分工里面。
下面我用现实世界里的工作流程图描述了并发编程领域的简单并行任务、聚合任务和批量并行任务,辅以这些流程图,相信你一定能将你的思维模式转换到现实世界里来。
从上到下,依次为简单并行任务、聚合任务和批量并行任务示意图
上面提到的简单并行、聚合、批量并行这三种任务模型,基本上能够覆盖日常工作中的并发场景了,但还是不够全面,因为还有一种“分治”的任务模型没有覆盖到。分治,顾名思义,即分而治之,是一种解决复杂问题的思维方法和模式;具体来讲,指的是把一个复杂的问题分解成多个相似的子问题,然后再把子问题分解成更小的子问题,直到子问题简单到可以直接求解。理论上来讲,解决每一个问题都对应着一个任务,所以对于问题的分治,实际上就是对于任务的分治。
分治思想在很多领域都有广泛的应用例如算法领域有分治算法归并排序、快速排序都属于分治算法二分法查找也是一种分治算法大数据领域知名的计算框架MapReduce背后的思想也是分治。既然分治这种任务模型如此普遍那Java显然也需要支持Java并发包里提供了一种叫做Fork/Join的并行计算框架就是用来支持分治这种任务模型的。
分治任务模型
这里你需要先深入了解一下分治任务模型,分治任务模型可分为两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。下图是一个简化的分治任务模型图,你可以对照着理解。
简版分治任务模型图
在这个分治任务模型里,任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法。
Fork/Join的使用
Fork/Join是一个并行计算的框架主要就是用来支持分治任务模型的这个计算框架里的Fork对应的是分治任务模型里的任务分解Join对应的是结果合并。Fork/Join计算框架主要包含两部分一部分是分治任务的线程池ForkJoinPool另一部分是分治任务ForkJoinTask。这两部分的关系类似于ThreadPoolExecutor和Runnable的关系都可以理解为提交任务到线程池只不过分治任务有自己独特类型ForkJoinTask。
ForkJoinTask是一个抽象类它的方法有很多最核心的是fork()方法和join()方法其中fork()方法会异步地执行一个子任务而join()方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask有两个子类——RecursiveAction和RecursiveTask通过名字你就应该能知道它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法compute()不过区别是RecursiveAction定义的compute()没有返回值而RecursiveTask定义的compute()方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。
接下来我们就来实现一下看看如何用Fork/Join这个并行计算框架计算斐波那契数列下面的代码源自Java官方示例。首先我们需要创建一个分治任务线程池以及计算斐波那契数列的分治任务之后通过调用分治任务线程池的 invoke() 方法来启动分治任务。由于计算斐波那契数列需要有返回值所以Fibonacci 继承自RecursiveTask。分治任务Fibonacci 需要实现compute()方法,这个方法里面的逻辑和普通计算斐波那契数列非常类似,区别之处在于计算 Fibonacci(n - 1) 使用了异步子任务,这是通过 f1.fork() 这条语句实现的。
static void main(String[] args){
//创建分治任务线程池
ForkJoinPool fjp =
new ForkJoinPool(4);
//创建分治任务
Fibonacci fib =
new Fibonacci(30);
//启动分治任务
Integer result =
fjp.invoke(fib);
//输出结果
System.out.println(result);
}
//递归任务
static class Fibonacci extends
RecursiveTask<Integer>{
final int n;
Fibonacci(int n){this.n = n;}
protected Integer compute(){
if (n <= 1)
return n;
Fibonacci f1 =
new Fibonacci(n - 1);
//创建子任务
f1.fork();
Fibonacci f2 =
new Fibonacci(n - 2);
//等待子任务结果,并合并结果
return f2.compute() + f1.join();
}
}
ForkJoinPool工作原理
Fork/Join并行计算的核心组件是ForkJoinPool所以下面我们就来简单介绍一下ForkJoinPool的工作原理。
通过专栏前面文章的学习你应该已经知道ThreadPoolExecutor本质上是一个生产者-消费者模式的实现内部有一个任务队列这个任务队列是生产者和消费者通信的媒介ThreadPoolExecutor可以有多个工作线程但是这些工作线程都共享一个任务队列。
ForkJoinPool本质上也是一个生产者-消费者的实现但是更加智能你可以参考下面的ForkJoinPool工作原理图来理解其原理。ThreadPoolExecutor内部只有一个任务队列而ForkJoinPool内部有多个任务队列当我们通过ForkJoinPool的invoke()或者submit()方法提交任务时ForkJoinPool根据一定的路由规则把任务提交到一个任务队列中如果任务在执行过程中会创建出子任务那么子任务会提交到工作线程对应的任务队列中。
如果工作线程对应的任务队列空了是不是就没活儿干了呢不是的ForkJoinPool支持一种叫做“任务窃取”的机制如果工作线程空闲了那它可以“窃取”其他工作任务队列里的任务例如下图中线程T2对应的任务队列已经空了它可以“窃取”线程T1对应的任务队列的任务。如此一来所有的工作线程都不会闲下来了。
ForkJoinPool中的任务队列采用的是双端队列工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费这样能避免很多不必要的数据竞争。我们这里介绍的仅仅是简化后的原理ForkJoinPool的实现远比我们这里介绍的复杂如果你感兴趣建议去看它的源码。
ForkJoinPool工作原理图
模拟MapReduce统计单词数量
学习MapReduce有一个入门程序统计一个文件里面每个单词的数量下面我们来看看如何用Fork/Join并行计算框架来实现。
我们可以先用二分法递归地将一个文件拆分成更小的文件,直到文件里只有一行数据,然后统计这一行数据里单词的数量,最后再逐级汇总结果,你可以对照前面的简版分治任务模型图来理解这个过程。
思路有了,我们马上来实现。下面的示例程序用一个字符串数组 String[] fc 来模拟文件内容fc里面的元素与文件里面的行数据一一对应。关键的代码在 compute() 这个方法里面这是一个递归方法前半部分数据fork一个递归任务去处理关键代码mr1.fork()后半部分数据则在当前任务中递归处理mr2.compute())。
static void main(String[] args){
String[] fc = {"hello world",
"hello me",
"hello fork",
"hello join",
"fork join in world"};
//创建ForkJoin线程池
ForkJoinPool fjp =
new ForkJoinPool(3);
//创建任务
MR mr = new MR(
fc, 0, fc.length);
//启动任务
Map<String, Long> result =
fjp.invoke(mr);
//输出结果
result.forEach((k, v)->
System.out.println(k+":"+v));
}
//MR模拟类
static class MR extends
RecursiveTask<Map<String, Long>> {
private String[] fc;
private int start, end;
//构造函数
MR(String[] fc, int fr, int to){
this.fc = fc;
this.start = fr;
this.end = to;
}
@Override protected
Map<String, Long> compute(){
if (end - start == 1) {
return calc(fc[start]);
} else {
int mid = (start+end)/2;
MR mr1 = new MR(
fc, start, mid);
mr1.fork();
MR mr2 = new MR(
fc, mid, end);
//计算子任务,并返回合并的结果
return merge(mr2.compute(),
mr1.join());
}
}
//合并结果
private Map<String, Long> merge(
Map<String, Long> r1,
Map<String, Long> r2) {
Map<String, Long> result =
new HashMap<>();
result.putAll(r1);
//合并结果
r2.forEach((k, v) -> {
Long c = result.get(k);
if (c != null)
result.put(k, c+v);
else
result.put(k, v);
});
return result;
}
//统计单词数量
private Map<String, Long>
calc(String line) {
Map<String, Long> result =
new HashMap<>();
//分割单词
String [] words =
line.split("\\s+");
//统计单词数量
for (String w : words) {
Long v = result.get(w);
if (v != null)
result.put(w, v+1);
else
result.put(w, 1L);
}
return result;
}
}
总结
Fork/Join并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”将一个大的任务拆分成小的子任务去解决然后再把子任务的结果聚合起来从而得到最终结果。这个过程非常类似于大数据处理中的MapReduce所以你可以把Fork/Join看作单机版的MapReduce。
Fork/Join并行计算框架的核心组件是ForkJoinPool。ForkJoinPool支持任务窃取机制能够让所有线程的工作量基本均衡不会出现有的线程很忙而有的线程很闲的状况所以性能很好。Java 1.8提供的Stream API里面并行流也是以ForkJoinPool为基础的。不过需要你注意的是默认情况下所有的并行流计算都共享一个ForkJoinPool这个共享的ForkJoinPool默认的线程数是CPU的核数如果所有的并行流计算都是CPU密集型计算的话完全没有问题但是如果存在I/O密集型的并行流计算那么很可能会因为一个很慢的I/O计算而拖慢整个系统的性能。所以建议用不同的ForkJoinPool执行不同类型的计算任务。
如果你对ForkJoinPool详细的实现细节感兴趣也可以参考Doug Lea的论文。
课后思考
对于一个CPU密集型计算程序在单核CPU上使用Fork/Join并行计算框架是否能够提高性能呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,220 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 并发工具类模块热点问题答疑
前面我们用13篇文章的内容介绍了Java SDK提供的并发工具类这些工具类都是久经考验的所以学好用好它们对于解决并发问题非常重要。我们在介绍这些工具类的时候重点介绍了这些工具类的产生背景、应用场景以及实现原理目的就是让你在面对并发问题的时候有思路有办法。只有思路、办法有了才谈得上开始动手解决问题。
当然了,只有思路和办法还不足以把问题解决,最终还是要动手实践的,我觉得在实践中有两方面的问题需要重点关注:细节问题与最佳实践。千里之堤毁于蚁穴,细节虽然不能保证成功,但是可以导致失败,所以我们一直都强调要关注细节。而最佳实践是前人的经验总结,可以帮助我们不要阴沟里翻船,所以没有十足的理由,一定要遵守。
为了让你学完即学即用我在每篇文章的最后都给你留了道思考题。这13篇文章的13个思考题基本上都是相关工具类在使用中需要特别注意的一些细节问题工作中容易碰到且费神费力所以咱们今天就来一一分析。
while(true) 总不让人省心
———————-
《14 | Lock&Condition隐藏在并发包中的管程》的思考题本意是通过破坏不可抢占条件来避免死锁问题但是它的实现中有一个致命的问题那就是 while(true) 没有break条件从而导致了死循环。除此之外这个实现虽然不存在死锁问题但还是存在活锁问题的解决活锁问题很简单只需要随机等待一小段时间就可以了。
修复后的代码如下所示我仅仅修改了两个地方一处是转账成功之后break另一处是在while循环体结束前增加了Thread.sleep(随机时间)。
class Account {
private int balance;
private final Lock lock
= new ReentrantLock();
// 转账
void transfer(Account tar, int amt){
while (true) {
if(this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
//新增:退出循环
break;
} finally {
tar.lock.unlock();
}
}//if
} finally {
this.lock.unlock();
}
}//if
//新增sleep一个随机时间避免活锁
Thread.sleep(随机时间);
}//while
}//transfer
}
这个思考题里面的while(true)问题还是比较容易看出来的但不是所有的while(true)问题都这么显而易见的,很多都隐藏得比较深。
例如《21 | 原子类无锁工具类的典范》的思考题本质上也是一个while(true),不过它隐藏得就比较深了。看上去 while(!rf.compareAndSet(or, nr)) 是有终止条件的而且跑单线程测试一直都没有问题。实际上却存在严重的并发问题问题就出在对or的赋值在while循环之外这样每次循环or的值都不会发生变化所以一旦有一次循环rf.compareAndSet(or, nr)的值等于false那之后无论循环多少次都会等于false。也就是说在特定场景下变成了while(true)问题。既然找到了原因修改就很简单了只要把对or的赋值移到while循环之内就可以了修改后的代码如下所示
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
//省略构造函数实现
}
}
final AtomicReference<WMRange>
rf = new AtomicReference<>(
new WMRange(0,0)
);
// 设置库存上限
void setUpper(int v){
WMRange nr;
WMRange or;
//原代码在这里
//WMRange or=rf.get();
do{
//移动到此处
//每个回合都需要重新获取旧值
or = rf.get();
// 检查参数合法性
if(v < or.lower){
throw new IllegalArgumentException();
}
nr = new
WMRange(v, or.lower);
}while(!rf.compareAndSet(or, nr));
}
}
signalAll() 总让人省心
15 | Lock&ConditionDubbo如何用管程实现异步转同步的思考题是关于signal()和signalAll()Dubbo最近已经把signal()改成signalAll()我觉得用signal()也不能说错但的确是用signalAll()会更安全我个人也倾向于使用signalAll()因为我们写程序不是做数学题而是在搞工程工程中会有很多不稳定的因素更有很多你预料不到的情况发生所以不要让你的代码铤而走险尽量使用更稳妥的方案和设计Dubbo修改后的相关代码如下所示
// RPC结果返回时调用该方法
private void doReceived(Response res) {
lock.lock();
try {
response = res;
done.signalAll();
} finally {
lock.unlock();
}
}
Semaphore需要锁中锁
16 | Semaphore如何快速实现一个限流器的思考题是对象池的例子中Vector能否换成ArrayList答案是不可以的Semaphore可以允许多个线程访问一个临界区那就意味着可能存在多个线程同时访问ArrayList而ArrayList不是线程安全的所以对象池的例子中是不能够将Vector换成ArrayList的Semaphore允许多个线程访问一个临界区这也是一把双刃剑当多个线程进入临界区时如果需要访问共享变量就会存在并发问题所以必须加锁也就是说Semaphore需要锁中锁
锁的申请和释放要成对出现
-
18 | StampedLock有没有比读写锁更快的锁思考题的Bug出在没有正确地释放锁锁的申请和释放要成对出现对此我们有一个最佳实践就是使用try{}finally{}但是try{}finally{}并不能解决所有锁的释放问题比如示例代码中锁的升级会生成新的stamp 而finally中释放锁用的是锁升级前的stamp本质上这也属于锁的申请和释放没有成对出现只是它隐藏得有点深解决这个问题倒也很简单只需要对stamp 重新赋值就可以了修复后的代码如下所示
private double x, y;
final StampedLock sl = new StampedLock();
// 存在问题的方法
void moveIfAtOrigin(double newX, double newY){
long stamp = sl.readLock();
try {
while(x == 0.0 && y == 0.0){
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
//问题出在没有对stamp重新赋值
//新增下面一行
stamp = ws;
x = newX;
y = newY;
break;
} else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
//此处unlock的是stamp
sl.unlock(stamp);
}
回调总要关心执行线程是谁
-
19 | CountDownLatch和CyclicBarrier如何让多线程步调一致的思考题是CyclicBarrier的回调函数使用了一个固定大小为1的线程池是否合理我觉得是合理的可以从以下两个方面来分析
第一个是线程池大小是1只有1个线程主要原因是check()方法的耗时比getPOrders()和getDOrders()都要短所以没必要用多个线程同时单线程能保证访问的数据不存在并发问题
第二个是使用了线程池如果不使用直接在回调函数里调用check()方法是否可以呢绝对不可以为什么呢这个要分析一下回调函数和唤醒等待线程之间的关系下面是CyclicBarrier相关的源码通过源码你会发现CyclicBarrier是同步调用回调函数之后才唤醒等待的线程如果我们在回调函数里直接调用check()方法那就意味着在执行check()的时候是不能同时执行getPOrders()和getDOrders()这样就起不到提升性能的作用
try {
//barrierCommand是回调函数
final Runnable command = barrierCommand;
//调用回调函数
if (command != null)
command.run();
ranAction = true;
//唤醒等待的线程
nextGeneration();
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
所以当遇到回调函数的时候你应该本能地问自己执行回调函数的线程是哪一个这个在多线程场景下非常重要因为不同线程ThreadLocal里的数据是不同的有些框架比如Spring就用ThreadLocal来管理事务如果不清楚回调函数用的是哪个线程很可能会导致错误的事务管理并最终导致数据不一致
CyclicBarrier的回调函数究竟是哪个线程执行的呢如果你分析源码你会发现执行回调函数的线程是将CyclicBarrier内部计数器减到 0 的那个线程所以我们前面讲执行check()的时候是不能同时执行getPOrders()和getDOrders()因为执行这两个方法的线程一个在等待一个正在忙着执行check()
再次强调一下当看到回调函数的时候一定问一问执行回调函数的线程是谁
共享线程池有福同享就要有难同当
24 | CompletableFuture异步编程没那么难的思考题是下列代码是否有问题很多同学都发现这段代码的问题了例如没有异常处理逻辑不严谨等等不过我更想让你关注的是findRuleByJdbc()这个方法隐藏着一个阻塞式I/O这意味着会阻塞调用线程默认情况下所有的CompletableFuture共享一个ForkJoinPool当有阻塞式I/O时可能导致所有的ForkJoinPool线程都阻塞进而影响整个系统的性能
//采购订单
PurchersOrder po;
CompletableFuture<Boolean> cf =
CompletableFuture.supplyAsync(()->{
//在数据库中查询规则
return findRuleByJdbc();
}).thenApply(r -> {
//规则校验
return check(po, r);
});
Boolean isOk = cf.join();
利用共享,往往能让我们快速实现功能,所谓是有福同享,但是代价就是有难要同当。在强调高可用的今天,大多数人更倾向于使用隔离的方案。
线上问题定位的利器线程栈dump
———————
《17 | ReadWriteLock如何快速实现一个完备的缓存》和《20 | 并发容器都有哪些“坑”需要我们填》的思考题本质上都是定位线上并发问题方案很简单就是通过查看线程栈来定位问题。重点是查看线程状态分析线程进入该状态的原因是否合理你可以参考《09 | Java线程Java线程的生命周期》来加深理解。
为了便于分析定位线程问题你需要给线程赋予一个有意义的名字对于线程池可以通过自定义ThreadFactory来给线程池中的线程赋予有意义的名字也可以在执行run()方法时通过Thread.currentThread().setName();来给线程赋予一个更贴近业务的名字。
总结
Java并发工具类到今天为止就告一段落了由于篇幅原因不能每个工具类都详细介绍。Java并发工具类内容繁杂熟练使用是需要一个过程的而且需要多加实践。希望你学完这个模块之后遇到并发问题时最起码能知道用哪些工具可以解决。至于工具使用的细节和最佳实践我总结的也只是我认为重要的。由于每个人的思维方式和编码习惯不同也许我认为不重要的恰恰是你的短板所以这部分内容更多地还是需要你去实践在实践中养成良好的编码习惯不断纠正错误的思维方式。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,220 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 Immutability模式如何利用不变性解决并发问题
我们曾经说过,“多个线程同时读写同一共享变量存在并发问题”,这里的必要条件之一是读写,如果只有读,而没有写,是没有并发问题的。
解决并发问题其实最简单的办法就是让共享变量只有读操作而没有写操作。这个办法如此重要以至于被上升到了一种解决并发问题的设计模式不变性Immutability模式。所谓不变性简单来讲就是对象一旦被创建之后状态就不再发生变化。换句话说就是变量一旦被赋值就不允许修改了没有写操作没有修改操作也就是保持了不变性。
快速实现具备不可变性的类
实现一个具备不可变性的类还是挺简单的。将一个类所有的属性都设置成final的并且只允许存在只读方法那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是final的也就是不允许继承。因为子类可以覆盖父类的方法有可能改变不可变性所以推荐你在实际工作中使用这种更严格的做法。
Java SDK里很多类都具备不可变性只是由于它们的使用太简单最后反而被忽略了。例如经常用到的String和Long、Integer、Double等基础类型的包装类都具备不可变性这些对象的线程安全性都是靠不可变性来保证的。如果你仔细翻看这些类的声明、属性和方法你会发现它们都严格遵守不可变类的三点要求类和属性都是final的所有方法均是只读的。
看到这里你可能会疑惑Java的String方法也有类似字符替换操作怎么能说所有方法都是只读的呢我们结合String的源代码来解释一下这个问题下面的示例代码源自Java 1.8 SDK我略做了修改仅保留了关键属性value[]和replace()方法你会发现String这个类以及它的属性value[]都是final的而replace()方法的实现就的确没有修改value[],而是将替换后的字符串作为返回值返回了。
public final class String {
private final char value[];
// 字符替换
String replace(char oldChar,
char newChar) {
//无需替换直接返回this
if (oldChar == newChar){
return this;
}
int len = value.length;
int i = -1;
/* avoid getfield opcode */
char[] val = value;
//定位到需要替换的字符位置
while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
//未找到oldChar无需替换
if (i >= len) {
return this;
}
//创建一个buf[],这是关键
//用来保存替换后的字符串
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ?
newChar : c;
i++;
}
//创建一个新的字符串返回
//原字符串不会发生任何变化
return new String(buf, true);
}
}
通过分析String的实现你可能已经发现了如果具备不可变性的类需要提供类似修改的功能具体该怎么操作呢做法很简单那就是创建一个新的不可变对象这是与可变对象的一个重要区别可变对象往往是修改自己的属性
所有的修改操作都创建一个新的不可变对象你可能会有这种担心是不是创建的对象太多了有点太浪费内存呢是的这样做的确有些浪费那如何解决呢
利用享元模式避免创建重复对象
如果你熟悉面向对象相关的设计模式相信你一定能想到享元模式Flyweight Pattern利用享元模式可以减少创建对象的数量从而减少内存占用Java语言里面LongIntegerShortByte等这些基本数据类型的包装类都用到了享元模式
下面我们就以Long这个类作为例子看看它是如何利用享元模式来优化对象的创建的
享元模式本质上其实就是一个对象池利用享元模式创建对象的逻辑也很简单创建之前首先去对象池里看看是不是存在如果已经存在就利用对象池里的对象如果不存在就会新创建一个对象并且把这个新创建出来的对象放进对象池里
Long这个类并没有照搬享元模式Long内部维护了一个静态的对象池仅缓存了[-128,127]之间的数字这个对象池在JVM启动的时候就创建好了而且这个对象池一直都不会变化也就是说它是静态的之所以采用这样的设计是因为Long这个对象的状态共有 264 实在太多不宜全部缓存[-128,127]之间的数字利用率最高下面的示例代码出自Java 1.8valueOf()方法就用到了LongCache这个缓存你可以结合着来加深理解
Long valueOf(long l) {
final int offset = 128;
// [-128,127]直接的数字做了缓存
if (l >= -128 && l <= 127) {
return LongCache
.cache[(int)l + offset];
}
return new Long(l);
}
//缓存,等价于对象池
//仅缓存[-128,127]直接的数字
static class LongCache {
static final Long cache[]
= new Long[-(-128) + 127 + 1];
static {
for(int i=0; i<cache.length; i++)
cache[i] = new Long(i-128);
}
}
前面我们在13 | 理论基础模块热点问题答疑中提到Integer String 类型的对象不适合做锁其实基本上所有的基础类型的包装类都不适合做锁因为它们内部用到了享元模式这会导致看上去私有的锁其实是共有的例如在下面代码中本意是A用锁alB用锁bl各自管理各自的互不影响但实际上al和bl是一个对象结果A和B共用的是一把锁
class A {
Long al=Long.valueOf(1);
public void setAX(){
synchronized (al) {
//省略代码无数
}
}
}
class B {
Long bl=Long.valueOf(1);
public void setBY(){
synchronized (bl) {
//省略代码无数
}
}
}
使用Immutability模式的注意事项
在使用Immutability模式的时候需要注意以下两点
对象的所有属性都是final的并不能保证不可变性
不可变对象也需要正确发布
在Java语言中final修饰的属性一旦被赋值就不可以再修改但是如果属性的类型是普通对象那么这个普通对象的属性是可以被修改的例如下面的代码中Bar的属性foo虽然是final的依然可以通过setAge()方法来设置foo的属性age所以在使用Immutability模式的时候一定要确认保持不变性的边界在哪里是否要求属性对象也具备不可变性
class Foo{
int age=0;
int name="abc";
}
final class Bar {
final Foo foo;
void setAge(int a){
foo.age=a;
}
}
下面我们再看看如何正确地发布不可变对象不可变对象虽然是线程安全的但是并不意味着引用这些不可变对象的对象就是线程安全的例如在下面的代码中Foo具备不可变性线程安全但是类Bar并不是线程安全的类Bar中持有对Foo的引用foo对foo这个引用的修改在多线程中并不能保证可见性和原子性
//Foo线程安全
final class Foo{
final int age=0;
final int name="abc";
}
//Bar线程不安全
class Bar {
Foo foo;
void setFoo(Foo f){
this.foo=f;
}
}
如果你的程序仅仅需要foo保持可见性无需保证原子性那么可以将foo声明为volatile变量这样就能保证可见性如果你的程序需要保证原子性那么可以通过原子类来实现下面的示例代码是合理库存的原子化实现你应该很熟悉了其中就是用原子类解决了不可变对象引用的原子性问题
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
//省略构造函数实现
}
}
final AtomicReference<WMRange>
rf = new AtomicReference<>(
new WMRange(0,0)
);
// 设置库存上限
void setUpper(int v){
while(true){
WMRange or = rf.get();
// 检查参数合法性
if(v < or.lower){
throw new IllegalArgumentException();
}
WMRange nr = new
WMRange(v, or.lower);
if(rf.compareAndSet(or, nr)){
return;
}
}
}
}
总结
利用Immutability模式解决并发问题也许你觉得有点陌生其实你天天都在享受它的战果Java语言里面的String和LongIntegerDouble等基础类型的包装类都具备不可变性这些对象的线程安全性都是靠不可变性来保证的Immutability模式是最简单的解决并发问题的方法建议当你试图解决一个并发问题时可以首先尝试一下Immutability模式看是否能够快速解决
具备不变性的对象只有一种状态这个状态由对象内部所有的不变属性共同决定其实还有一种更简单的不变性对象那就是无状态无状态对象内部没有属性只有方法除了无状态的对象你可能还听说过无状态的服务无状态的协议等等无状态有很多好处最核心的一点就是性能在多线程领域无状态对象没有线程安全问题无需同步处理自然性能很好在分布式领域无状态意味着可以无限地水平扩展所以分布式领域里面性能的瓶颈一定不是出在无状态的服务节点上
课后思考
下面的示例代码中Account的属性是final的并且只有get方法那这个类是不是具备不可变性呢
public final class Account{
private final
StringBuffer user;
public Account(String user){
this.user =
new StringBuffer(user);
}
public StringBuffer getUser(){
return this.user;
}
public String toString(){
return "user"+user;
}
}
欢迎在留言区与我分享你的想法也欢迎你在留言区记录你的思考过程感谢阅读如果你觉得这篇文章对你有帮助的话也欢迎把它分享给更多的朋友

View File

@ -0,0 +1,116 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 Copy-on-Write模式不是延时策略的COW
在上一篇文章中我们讲到Java里String这个类在实现replace()方法的时候并没有更改原字符串里面value[]数组的内容而是创建了一个新字符串这种方法在解决不可变对象的修改问题时经常用到。如果你深入地思考这个方法你会发现它本质上是一种Copy-on-Write方法。所谓Copy-on-Write经常被缩写为COW或者CoW顾名思义就是写时复制。
不可变对象的写操作往往都是使用Copy-on-Write方法解决的当然Copy-on-Write的应用领域并不局限于Immutability模式。下面我们先简单介绍一下Copy-on-Write的应用领域让你对它有个更全面的认识。
Copy-on-Write模式的应用领域
我们前面在《20 | 并发容器都有哪些“坑”需要我们填》中介绍过CopyOnWriteArrayList和CopyOnWriteArraySet这两个Copy-on-Write容器它们背后的设计思想就是Copy-on-Write通过Copy-on-Write这两个容器实现的读操作是无锁的由于无锁所以将读操作的性能发挥到了极致。
除了Java这个领域Copy-on-Write在操作系统领域也有广泛的应用。
我第一次接触Copy-on-Write其实就是在操作系统领域。类Unix的操作系统中创建进程的API是fork()传统的fork()函数会创建父进程的一个完整副本例如父进程的地址空间现在用到了1G的内存那么fork()子进程的时候要复制父进程整个进程的地址空间占有1G内存给子进程这个过程是很耗时的。而Linux中的fork()函数就聪明得多了fork()子进程的时候,并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间;只用在父进程或者子进程需要写入的时候才会复制地址空间,从而使父子进程拥有各自的地址空间。
本质上来讲父子进程的地址空间以及数据都是要隔离的使用Copy-on-Write更多地体现的是一种延时策略只有在真正需要复制的时候才复制而不是提前复制好同时Copy-on-Write还支持按需复制所以Copy-on-Write在操作系统领域是能够提升性能的。相比较而言Java提供的Copy-on-Write容器由于在修改的同时会复制整个容器所以在提升读操作性能的同时是以内存复制为代价的。这里你会发现同样是应用Copy-on-Write不同的场景对性能的影响是不同的。
在操作系统领域除了创建进程用到了Copy-on-Write很多文件系统也同样用到了例如Btrfs (B-Tree File System)、aufsadvanced multi-layered unification filesystem等。
除了上面我们说的Java领域、操作系统领域很多其他领域也都能看到Copy-on-Write的身影Docker容器镜像的设计是Copy-on-Write甚至分布式源码管理系统Git背后的设计思想都有Copy-on-Write……
不过Copy-on-Write最大的应用领域还是在函数式编程领域。函数式编程的基础是不可变性Immutability所以函数式编程里面所有的修改操作都需要Copy-on-Write来解决。你或许会有疑问“所有数据的修改都需要复制一份性能是不是会成为瓶颈呢”你的担忧是有道理的之所以函数式编程早年间没有兴起性能绝对拖了后腿。但是随着硬件性能的提升性能问题已经慢慢变得可以接受了。而且Copy-on-Write也远不像Java里的CopyOnWriteArrayList那样笨整个数组都复制一遍。Copy-on-Write也是可以按需复制的如果你感兴趣可以参考Purely Functional Data Structures这本书里面描述了各种具备不变性的数据结构的实现。
CopyOnWriteArrayList和CopyOnWriteArraySet这两个Copy-on-Write容器在修改的时候会复制整个数组所以如果容器经常被修改或者这个数组本身就非常大的时候是不建议使用的。反之如果是修改非常少、数组数量也不大并且对读性能要求苛刻的场景使用Copy-on-Write容器效果就非常好了。下面我们结合一个真实的案例来讲解一下。
一个真实案例
我曾经写过一个RPC框架有点类似Dubbo服务提供方是多实例分布式部署的所以服务的客户端在调用RPC的时候会选定一个服务实例来调用这个选定的过程本质上就是在做负载均衡而做负载均衡的前提是客户端要有全部的路由信息。例如在下图中A服务的提供方有3个实例分别是192.168.1.1、192.168.1.2和192.168.1.3客户端在调用目标服务A前首先需要做的是负载均衡也就是从这3个实例中选出1个来然后再通过RPC把请求发送选中的目标实例。
RPC路由关系图
RPC框架的一个核心任务就是维护服务的路由关系我们可以把服务的路由关系简化成下图所示的路由表。当服务提供方上线或者下线的时候就需要更新客户端的这张路由表。
我们首先来分析一下如何用程序来实现。每次RPC调用都需要通过负载均衡器来计算目标服务的IP和端口号而负载均衡器需要通过路由表获取接口的所有路由信息也就是说每次RPC调用都需要访问路由表所以访问路由表这个操作的性能要求是很高的。不过路由表对数据的一致性要求并不高一个服务提供方从上线到反馈到客户端的路由表里即便有5秒钟很多时候也都是能接受的5秒钟对于以纳秒作为时钟周期的CPU来说那何止是一万年所以路由表对一致性的要求并不高。而且路由表是典型的读多写少类问题写操作的量相比于读操作可谓是沧海一粟少得可怜。
通过以上分析你会发现一些关键词对读的性能要求很高读多写少弱一致性。它们综合在一起你会想到什么呢CopyOnWriteArrayList和CopyOnWriteArraySet天生就适用这种场景啊。所以下面的示例代码中RouteTable这个类内部我们通过ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>这个数据结构来描述路由表ConcurrentHashMap的Key是接口名Value是路由集合这个路由集合我们用是CopyOnWriteArraySet。
下面我们再来思考Router该如何设计服务提供方的每一次上线、下线都会更新路由信息这时候你有两种选择。一种是通过更新Router的一个状态位来标识如果这样做那么所有访问该状态位的地方都需要同步访问这样很影响性能。另外一种就是采用Immutability模式每次上线、下线都创建新的Router对象或者删除对应的Router对象。由于上线、下线的频率很低所以后者是最好的选择。
Router的实现代码如下所示是一种典型Immutability模式的实现需要你注意的是我们重写了equals方法这样CopyOnWriteArraySet的add()和remove()方法才能正常工作。
//路由信息
public final class Router{
private final String ip;
private final Integer port;
private final String iface;
//构造函数
public Router(String ip,
Integer port, String iface){
this.ip = ip;
this.port = port;
this.iface = iface;
}
//重写equals方法
public boolean equals(Object obj){
if (obj instanceof Router) {
Router r = (Router)obj;
return iface.equals(r.iface) &&
ip.equals(r.ip) &&
port.equals(r.port);
}
return false;
}
public int hashCode() {
//省略hashCode相关代码
}
}
//路由表信息
public class RouterTable {
//Key:接口名
//Value:路由集合
ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>
rt = new ConcurrentHashMap<>();
//根据接口名获取路由表
public Set<Router> get(String iface){
return rt.get(iface);
}
//删除路由
public void remove(Router router) {
Set<Router> set=rt.get(router.iface);
if (set != null) {
set.remove(router);
}
}
//增加路由
public void add(Router router) {
Set<Router> set = rt.computeIfAbsent(
route.iface, r ->
new CopyOnWriteArraySet<>());
set.add(router);
}
}
总结
目前Copy-on-Write在Java并发编程领域知名度不是很高很多人都在无意中把它忽视了但其实Copy-on-Write才是最简单的并发解决方案。它是如此简单以至于Java中的基本数据类型String、Integer、Long等都是基于Copy-on-Write方案实现的。
Copy-on-Write是一项非常通用的技术方案在很多领域都有着广泛的应用。不过它也有缺点的那就是消耗内存每次修改都需要复制一个新的对象出来好在随着自动垃圾回收GC算法的成熟以及硬件的发展这种内存消耗已经渐渐可以接受了。所以在实际工作中如果写操作非常少那你就可以尝试用一下Copy-on-Write效果还是不错的。
课后思考
Java提供了CopyOnWriteArrayList为什么没有提供CopyOnWriteLinkedList呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,168 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 线程本地存储模式:没有共享,就没有伤害
民国年间某山东省主席参加某大学校庆演讲,在篮球场看到十来个人穿着裤衩抢一个球,观之实在不雅,于是怒斥学校的总务处长贪污,并且发话:“多买几个球,一人发一个,省得你争我抢!”小时候听到这个段子只是觉得好玩,今天再来看,却别有一番滋味。为什么呢?因为其间蕴藏着解决并发问题的一个重要方法:避免共享。
我们曾经一遍一遍又一遍地重复,多个线程同时读写同一共享变量存在并发问题。前面两篇文章我们突破的是写,没有写操作自然没有并发问题了。其实还可以突破共享变量,没有共享变量也不会有并发问题,正所谓是没有共享,就没有伤害。
那如何避免共享呢?思路其实很简单,多个人争一个球总容易出矛盾,那就每个人发一个球。对应到并发编程领域,就是每个线程都拥有自己的变量,彼此之间不共享,也就没有并发问题了。
我们在《11 | Java线程为什么局部变量是线程安全的》中提到过线程封闭其本质上就是避免共享。你已经知道通过局部变量可以做到避免共享那还有没有其他方法可以做到呢有的Java语言提供的线程本地存储ThreadLocal就能够做到。下面我们先看看ThreadLocal到底该如何使用。
ThreadLocal的使用方法
下面这个静态类ThreadId会为每个线程分配一个唯一的线程Id如果一个线程前后两次调用ThreadId的get()方法两次get()方法的返回值是相同的。但如果是两个线程分别调用ThreadId的get()方法那么两个线程看到的get()方法的返回值是不同的。若你是初次接触ThreadLocal可能会觉得奇怪为什么相同线程调用get()方法结果就相同而不同线程调用get()方法结果就不同呢?
static class ThreadId {
static final AtomicLong
nextId=new AtomicLong(0);
//定义ThreadLocal变量
static final ThreadLocal<Long>
tl=ThreadLocal.withInitial(
()->nextId.getAndIncrement());
//此方法会为每个线程分配一个唯一的Id
static long get(){
return tl.get();
}
}
能有这个奇怪的结果都是ThreadLocal的杰作不过在详细解释ThreadLocal的工作原理之前我们再看一个实际工作中可能遇到的例子来加深一下对ThreadLocal的理解。你可能知道SimpleDateFormat不是线程安全的那如果需要在并发场景下使用它你该怎么办呢
其实有一个办法就是用ThreadLocal来解决下面的示例代码就是ThreadLocal解决方案的具体实现这段代码与前面ThreadId的代码高度相似同样地不同线程调用SafeDateFormat的get()方法将返回不同的SimpleDateFormat对象实例由于不同线程并不共享SimpleDateFormat所以就像局部变量一样是线程安全的。
static class SafeDateFormat {
//定义ThreadLocal变量
static final ThreadLocal<DateFormat>
tl=ThreadLocal.withInitial(
()-> new SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss"));
static DateFormat get(){
return tl.get();
}
}
//不同线程执行下面代码
//返回的df是不同的
DateFormat df =
SafeDateFormat.get()
通过上面两个例子相信你对ThreadLocal的用法以及应用场景都了解了下面我们就来详细解释ThreadLocal的工作原理。
ThreadLocal的工作原理
在解释ThreadLocal的工作原理之前 你先自己想想如果让你来实现ThreadLocal的功能你会怎么设计呢ThreadLocal的目标是让不同的线程有不同的变量V那最直接的方法就是创建一个Map它的Key是线程Value是每个线程拥有的变量VThreadLocal内部持有这样的一个Map就可以了。你可以参考下面的示意图和示例代码来理解。
ThreadLocal持有Map的示意图
class MyThreadLocal<T> {
Map<Thread, T> locals =
new ConcurrentHashMap<>();
//获取线程变量
T get() {
return locals.get(
Thread.currentThread());
}
//设置线程变量
void set(T t) {
locals.put(
Thread.currentThread(), t);
}
}
那Java的ThreadLocal是这么实现的吗这一次我们的设计思路和Java的实现差异很大。Java的实现里面也有一个Map叫做ThreadLocalMap不过持有ThreadLocalMap的不是ThreadLocal而是Thread。Thread这个类内部有一个私有属性threadLocals其类型就是ThreadLocalMapThreadLocalMap的Key是ThreadLocal。你可以结合下面的示意图和精简之后的Java实现代码来理解。
Thread持有ThreadLocalMap的示意图
class Thread {
//内部持有ThreadLocalMap
ThreadLocal.ThreadLocalMap
threadLocals;
}
class ThreadLocal<T>{
public T get() {
//首先获取线程持有的
//ThreadLocalMap
ThreadLocalMap map =
Thread.currentThread()
.threadLocals;
//在ThreadLocalMap中
//查找变量
Entry e =
map.getEntry(this);
return e.value;
}
static class ThreadLocalMap{
//内部是数组而不是Map
Entry[] table;
//根据ThreadLocal查找Entry
Entry getEntry(ThreadLocal key){
//省略查找逻辑
}
//Entry定义
static class Entry extends
WeakReference<ThreadLocal>{
Object value;
}
}
}
初看上去我们的设计方案和Java的实现仅仅是Map的持有方不同而已我们的设计里面Map属于ThreadLocal而Java的实现里面ThreadLocalMap则是属于Thread。这两种方式哪种更合理呢很显然Java的实现更合理一些。在Java的实现方案里面ThreadLocal仅仅是一个代理工具类内部并不持有任何与线程相关的数据所有和线程相关的数据都存储在Thread里面这样的设计容易理解。而从数据的亲缘性上来讲ThreadLocalMap属于Thread也更加合理。
当然还有一个更加深层次的原因那就是不容易产生内存泄露。在我们的设计方案中ThreadLocal持有的Map会持有Thread对象的引用这就意味着只要ThreadLocal对象存在那么Map中的Thread对象就永远不会被回收。ThreadLocal的生命周期往往都比线程要长所以这种设计方案很容易导致内存泄露。而Java的实现中Thread持有ThreadLocalMap而且ThreadLocalMap里对ThreadLocal的引用还是弱引用WeakReference所以只要Thread对象可以被回收那么ThreadLocalMap就能被回收。Java的这种实现方案虽然看上去复杂一些但是更加安全。
Java的ThreadLocal实现应该称得上深思熟虑了不过即便如此深思熟虑还是不能百分百地让程序员避免内存泄露例如在线程池中使用ThreadLocal如果不谨慎就可能导致内存泄露。
ThreadLocal与内存泄露
在线程池中使用ThreadLocal为什么可能导致内存泄露呢原因就出在线程池中线程的存活时间太长往往都是和程序同生共死的这就意味着Thread持有的ThreadLocalMap一直都不会被回收再加上ThreadLocalMap中的Entry对ThreadLocal是弱引用WeakReference所以只要ThreadLocal结束了自己的生命周期是可以被回收掉的。但是Entry中的Value却是被Entry强引用的所以即便Value的生命周期结束了Value也是无法被回收的从而导致内存泄露。
那在线程池中我们该如何正确使用ThreadLocal呢其实很简单既然JVM不能做到自动释放对Value的强引用那我们手动释放就可以了。如何能做到手动释放呢估计你马上想到try{}finally{}方案了,这个简直就是手动释放资源的利器。示例的代码如下,你可以参考学习。
ExecutorService es;
ThreadLocal tl;
es.execute(()->{
//ThreadLocal增加变量
tl.set(obj);
try {
// 省略业务逻辑代码
}finally {
//手动清理ThreadLocal
tl.remove();
}
});
InheritableThreadLocal与继承性
通过ThreadLocal创建的线程变量其子线程是无法继承的。也就是说你在线程中通过ThreadLocal创建了线程变量V而后该线程创建了子线程你在子线程中是无法通过ThreadLocal来访问父线程的线程变量V的。
如果你需要子线程继承父线程的线程变量那该怎么办呢其实很简单Java提供了InheritableThreadLocal来支持这种特性InheritableThreadLocal是ThreadLocal子类所以用法和ThreadLocal相同这里就不多介绍了。
不过我完全不建议你在线程池中使用InheritableThreadLocal不仅仅是因为它具有ThreadLocal相同的缺点——可能导致内存泄露更重要的原因是线程池中线程的创建是动态的很容易导致继承关系错乱如果你的业务逻辑依赖InheritableThreadLocal那么很可能导致业务逻辑计算错误而这个错误往往比内存泄露更要命。
总结
线程本地存储模式本质上是一种避免共享的方案,由于没有共享,所以自然也就没有并发问题。如果你需要在并发场景中使用一个线程不安全的工具类,最简单的方案就是避免共享。避免共享有两种方案,一种方案是将这个工具类作为局部变量使用,另外一种方案就是线程本地存储模式。这两种方案,局部变量方案的缺点是在高并发场景下会频繁创建对象,而线程本地存储方案,每个线程只需要创建一个工具类的实例,所以不存在频繁创建对象的问题。
线程本地存储模式是解决并发问题的常用方案所以Java SDK也提供了相应的实现ThreadLocal。通过上面我们的分析你应该能体会到Java SDK的实现已经是深思熟虑了不过即便如此仍不能尽善尽美例如在线程池中使用ThreadLocal仍可能导致内存泄漏所以使用ThreadLocal还是需要你打起精神足够谨慎。
课后思考
实际工作中有很多平台型的技术方案都是采用ThreadLocal来传递一些上下文信息例如Spring使用ThreadLocal来传递事务信息。我们曾经说过异步编程已经很成熟了那你觉得在异步场景中是否可以使用Spring的事务管理器呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,241 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 Guarded Suspension模式等待唤醒机制的规范实现
前不久同事小灰工作中遇到一个问题他开发了一个Web项目Web版的文件浏览器通过它用户可以在浏览器里查看服务器上的目录和文件。这个项目依赖运维部门提供的文件浏览服务而这个文件浏览服务只支持消息队列MQ方式接入。消息队列在互联网大厂中用的非常多主要用作流量削峰和系统解耦。在这种接入方式中发送消息和消费结果这两个操作之间是异步的你可以参考下面的示意图来理解。
消息队列MQ示意图
在小灰的这个Web项目中用户通过浏览器发过来一个请求会被转换成一个异步消息发送给MQ等MQ返回结果后再将这个结果返回至浏览器。小灰同学的问题是给MQ发送消息的线程是处理Web请求的线程T1但消费MQ结果的线程并不是线程T1那线程T1如何等待MQ的返回结果呢为了便于你理解这个场景我将其代码化了示例代码如下。
class Message{
String id;
String content;
}
//该方法可以发送消息
void send(Message msg){
//省略相关代码
}
//MQ消息返回后会调用该方法
//该方法的执行线程不同于
//发送消息的线程
void onMessage(Message msg){
//省略相关代码
}
//处理浏览器发来的请求
Respond handleWebReq(){
//创建一消息
Message msg1 = new
Message("1","{...}");
//发送消息
send(msg1);
//如何等待MQ返回的消息呢
String result = ...;
}
看到这里相信你一定有点似曾相识的感觉这不就是前面我们在《15 | Lock和ConditionDubbo如何用管程实现异步转同步》中曾介绍过的异步转同步问题吗仔细分析的确是这样不过在那一篇文章中我们只是介绍了最终方案让你知其然但是并没有介绍这个方案是如何设计出来的今天咱们再仔细聊聊这个问题让你知其所以然遇到类似问题也能自己设计出方案来。
Guarded Suspension模式
上面小灰遇到的问题,在现实世界里比比皆是,只是我们一不小心就忽略了。比如,项目组团建要外出聚餐,我们提前预订了一个包间,然后兴冲冲地奔过去,到那儿后大堂经理看了一眼包间,发现服务员正在收拾,就会告诉我们:“您预订的包间服务员正在收拾,请您稍等片刻。”过了一会,大堂经理发现包间已经收拾完了,于是马上带我们去包间就餐。
我们等待包间收拾完的这个过程和小灰遇到的等待MQ返回消息本质上是一样的都是等待一个条件满足就餐需要等待包间收拾完小灰的程序里要等待MQ返回消息。
那我们来看看现实世界里是如何解决这类问题的呢现实世界里大堂经理这个角色很重要我们是否等待完全是由他来协调的。通过类比相信你也一定有思路了我们的程序里也需要这样一个大堂经理。的确是这样那程序世界里的大堂经理该如何设计呢其实设计方案前人早就搞定了而且还将其总结成了一个设计模式Guarded Suspension。所谓Guarded Suspension直译过来就是“保护性地暂停”。那下面我们就来看看Guarded Suspension模式是如何模拟大堂经理进行保护性地暂停的。
下图就是Guarded Suspension模式的结构图非常简单一个对象GuardedObject内部有一个成员变量——受保护的对象以及两个成员方法——get(Predicate<T> p)和onChanged(T obj)方法。其中对象GuardedObject就是我们前面提到的大堂经理受保护对象就是餐厅里面的包间受保护对象的get()方法对应的是我们的就餐就餐的前提条件是包间已经收拾好了参数p就是用来描述这个前提条件的受保护对象的onChanged()方法对应的是服务员把包间收拾好了通过onChanged()方法可以fire一个事件而这个事件往往能改变前提条件p的计算结果。下图中左侧的绿色线程就是需要就餐的顾客而右侧的蓝色线程就是收拾包间的服务员。
Guarded Suspension模式结构图
GuardedObject的内部实现非常简单是管程的一个经典用法你可以参考下面的示例代码核心是get()方法通过条件变量的await()方法实现等待onChanged()方法通过条件变量的signalAll()方法实现唤醒功能。逻辑还是很简单的,所以这里就不再详细介绍了。
class GuardedObject<T>{
//受保护的对象
T obj;
final Lock lock =
new ReentrantLock();
final Condition done =
lock.newCondition();
final int timeout=1;
//获取受保护对象
T get(Predicate<T> p) {
lock.lock();
try {
//MESA管程推荐写法
while(!p.test(obj)){
done.await(timeout,
TimeUnit.SECONDS);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}finally{
lock.unlock();
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
lock.lock();
try {
this.obj = obj;
done.signalAll();
} finally {
lock.unlock();
}
}
}
扩展Guarded Suspension模式
上面我们介绍了Guarded Suspension模式及其实现这个模式能够模拟现实世界里大堂经理的角色那现在我们再来看看这个“大堂经理”能否解决小灰同学遇到的问题。
Guarded Suspension模式里GuardedObject有两个核心方法一个是get()方法一个是onChanged()方法。很显然在处理Web请求的方法handleWebReq()中可以调用GuardedObject的get()方法来实现等待在MQ消息的消费方法onMessage()中可以调用GuardedObject的onChanged()方法来实现唤醒。
//处理浏览器发来的请求
Respond handleWebReq(){
//创建一消息
Message msg1 = new
Message("1","{...}");
//发送消息
send(msg1);
//利用GuardedObject实现等待
GuardedObject<Message> go
=new GuardObjec<>();
Message r = go.get(
t->t != null);
}
void onMessage(Message msg){
//如何找到匹配的go
GuardedObject<Message> go=???
go.onChanged(msg);
}
但是在实现的时候会遇到一个问题handleWebReq()里面创建了GuardedObject对象的实例go并调用其get()方等待结果那在onMessage()方法中如何才能够找到匹配的GuardedObject对象呢这个过程类似服务员告诉大堂经理某某包间已经收拾好了大堂经理如何根据包间找到就餐的人。现实世界里大堂经理的头脑中有包间和就餐人之间的关系图所以服务员说完之后大堂经理立刻就能把就餐人找出来。
我们可以参考大堂经理识别就餐人的办法来扩展一下Guarded Suspension模式从而使它能够很方便地解决小灰同学的问题。在小灰的程序中每个发送到MQ的消息都有一个唯一性的属性id所以我们可以维护一个MQ消息id和GuardedObject对象实例的关系这个关系可以类比大堂经理大脑里维护的包间和就餐人的关系。
有了这个关系我们来看看具体如何实现。下面的示例代码是扩展Guarded Suspension模式的实现扩展后的GuardedObject内部维护了一个Map其Key是MQ消息id而Value是GuardedObject对象实例同时增加了静态方法create()和fireEvent()create()方法用来创建一个GuardedObject对象实例并根据key值将其加入到Map中而fireEvent()方法则是模拟的大堂经理根据包间找就餐人的逻辑。
class GuardedObject<T>{
//受保护的对象
T obj;
final Lock lock =
new ReentrantLock();
final Condition done =
lock.newCondition();
final int timeout=2;
//保存所有GuardedObject
final static Map<Object, GuardedObject>
gos=new ConcurrentHashMap<>();
//静态方法创建GuardedObject
static <K> GuardedObject
create(K key){
GuardedObject go=new GuardedObject();
gos.put(key, go);
return go;
}
static <K, T> void
fireEvent(K key, T obj){
GuardedObject go=gos.remove(key);
if (go != null){
go.onChanged(obj);
}
}
//获取受保护对象
T get(Predicate<T> p) {
lock.lock();
try {
//MESA管程推荐写法
while(!p.test(obj)){
done.await(timeout,
TimeUnit.SECONDS);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}finally{
lock.unlock();
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
lock.lock();
try {
this.obj = obj;
done.signalAll();
} finally {
lock.unlock();
}
}
}
这样利用扩展后的GuardedObject来解决小灰同学的问题就很简单了具体代码如下所示。
//处理浏览器发来的请求
Respond handleWebReq(){
int id=序号生成器.get();
//创建一消息
Message msg1 = new
Message(id,"{...}");
//创建GuardedObject实例
GuardedObject<Message> go=
GuardedObject.create(id);
//发送消息
send(msg1);
//等待MQ消息
Message r = go.get(
t->t != null);
}
void onMessage(Message msg){
//唤醒等待的线程
GuardedObject.fireEvent(
msg.id, msg);
}
总结
Guarded Suspension模式本质上是一种等待唤醒机制的实现只不过Guarded Suspension模式将其规范化了。规范化的好处是你无需重头思考如何实现也无需担心实现程序的可理解性问题同时也能避免一不小心写出个Bug来。但Guarded Suspension模式在解决实际问题的时候往往还是需要扩展的扩展的方式有很多本篇文章就直接对GuardedObject的功能进行了增强Dubbo中DefaultFuture这个类也是采用的这种方式你可以对比着来看相信对DefaultFuture的实现原理会理解得更透彻。当然你也可以创建新的类来实现对Guarded Suspension模式的扩展。
Guarded Suspension模式也常被称作Guarded Wait模式、Spin Lock模式因为使用了while循环去等待这些名字都很形象不过它还有一个更形象的非官方名字多线程版本的if。单线程场景中if语句是不需要等待的因为在只有一个线程的条件下如果这个线程被阻塞那就没有其他活动线程了这意味着if判断条件的结果也不会发生变化了。但是多线程场景中等待就变得有意义了这种场景下if判断条件的结果是可能发生变化的。所以用“多线程版本的if”来理解这个模式会更简单。
课后思考
有同学觉得用done.await()还要加锁太啰嗦还不如直接使用sleep()方法,下面是他的实现,你觉得他的写法正确吗?
//获取受保护对象
T get(Predicate<T> p) {
try {
while(!p.test(obj)){
TimeUnit.SECONDS
.sleep(timeout);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
this.obj = obj;
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,243 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 Balking模式再谈线程安全的单例模式
上一篇文章中我们提到可以用“多线程版本的if”来理解Guarded Suspension模式不同于单线程中的if这个“多线程版本的if”是需要等待的而且还很执着必须要等到条件为真。但很显然这个世界不是所有场景都需要这么执着有时候我们还需要快速放弃。
需要快速放弃的一个最常见的例子是各种编辑器提供的自动保存功能。自动保存功能的实现逻辑一般都是隔一定时间自动执行存盘操作存盘操作的前提是文件做过修改如果文件没有执行过修改操作就需要快速放弃存盘操作。下面的示例代码将自动保存功能代码化了很显然AutoSaveEditor这个类不是线程安全的因为对共享变量changed的读写没有使用同步那如何保证AutoSaveEditor的线程安全性呢
class AutoSaveEditor{
//文件是否被修改过
boolean changed=false;
//定时任务线程池
ScheduledExecutorService ses =
Executors.newSingleThreadScheduledExecutor();
//定时执行自动保存
void startAutoSave(){
ses.scheduleWithFixedDelay(()->{
autoSave();
}, 5, 5, TimeUnit.SECONDS);
}
//自动存盘操作
void autoSave(){
if (!changed) {
return;
}
changed = false;
//执行存盘操作
//省略且实现
this.execSave();
}
//编辑操作
void edit(){
//省略编辑逻辑
......
changed = true;
}
}
解决这个问题相信你一定手到擒来了读写共享变量changed的方法autoSave()和edit()都加互斥锁就可以了。这样做虽然简单但是性能很差原因是锁的范围太大了。那我们可以将锁的范围缩小只在读写共享变量changed的地方加锁实现代码如下所示。
//自动存盘操作
void autoSave(){
synchronized(this){
if (!changed) {
return;
}
changed = false;
}
//执行存盘操作
//省略且实现
this.execSave();
}
//编辑操作
void edit(){
//省略编辑逻辑
......
synchronized(this){
changed = true;
}
}
如果你深入地分析一下这个示例程序你会发现示例中的共享变量是一个状态变量业务逻辑依赖于这个状态变量的状态当状态满足某个条件时执行某个业务逻辑其本质其实不过就是一个if而已放到多线程场景里就是一种“多线程版本的if”。这种“多线程版本的if”的应用场景还是很多的所以也有人把它总结成了一种设计模式叫做Balking模式。
Balking模式的经典实现
Balking模式本质上是一种规范化地解决“多线程版本的if”的方案对于上面自动保存的例子使用Balking模式规范化之后的写法如下所示你会发现仅仅是将edit()方法中对共享变量changed的赋值操作抽取到了change()中,这样的好处是将并发处理逻辑和业务逻辑分开。
boolean changed=false;
//自动存盘操作
void autoSave(){
synchronized(this){
if (!changed) {
return;
}
changed = false;
}
//执行存盘操作
//省略且实现
this.execSave();
}
//编辑操作
void edit(){
//省略编辑逻辑
......
change();
}
//改变状态
void change(){
synchronized(this){
changed = true;
}
}
用volatile实现Balking模式
前面我们用synchronized实现了Balking模式这种实现方式最为稳妥建议你实际工作中也使用这个方案。不过在某些特定场景下也可以使用volatile来实现但使用volatile的前提是对原子性没有要求。
在《29 | Copy-on-Write模式不是延时策略的COW》中有一个RPC框架路由表的案例在RPC框架中本地路由表是要和注册中心进行信息同步的应用启动的时候会将应用依赖服务的路由表从注册中心同步到本地路由表中如果应用重启的时候注册中心宕机那么会导致该应用依赖的服务均不可用因为找不到依赖服务的路由表。为了防止这种极端情况出现RPC框架可以将本地路由表自动保存到本地文件中如果重启的时候注册中心宕机那么就从本地文件中恢复重启前的路由表。这其实也是一种降级的方案。
自动保存路由表和前面介绍的编辑器自动保存原理是一样的也可以用Balking模式实现不过我们这里采用volatile来实现实现的代码如下所示。之所以可以采用volatile来实现是因为对共享变量changed和rt的写操作不存在原子性的要求而且采用scheduleWithFixedDelay()这种调度方式能保证同一时刻只有一个线程执行autoSave()方法。
//路由表信息
public class RouterTable {
//Key:接口名
//Value:路由集合
ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>
rt = new ConcurrentHashMap<>();
//路由表是否发生变化
volatile boolean changed;
//将路由表写入本地文件的线程池
ScheduledExecutorService ses=
Executors.newSingleThreadScheduledExecutor();
//启动定时任务
//将变更后的路由表写入本地文件
public void startLocalSaver(){
ses.scheduleWithFixedDelay(()->{
autoSave();
}, 1, 1, MINUTES);
}
//保存路由表到本地文件
void autoSave() {
if (!changed) {
return;
}
changed = false;
//将路由表写入本地文件
//省略其方法实现
this.save2Local();
}
//删除路由
public void remove(Router router) {
Set<Router> set=rt.get(router.iface);
if (set != null) {
set.remove(router);
//路由表已发生变化
changed = true;
}
}
//增加路由
public void add(Router router) {
Set<Router> set = rt.computeIfAbsent(
route.iface, r ->
new CopyOnWriteArraySet<>());
set.add(router);
//路由表已发生变化
changed = true;
}
}
Balking模式有一个非常典型的应用场景就是单次初始化下面的示例代码是它的实现。这个实现方案中我们将init()声明为一个同步方法这样同一个时刻就只有一个线程能够执行init()方法init()方法在第一次执行完时会将inited设置为true这样后续执行init()方法的线程就不会再执行doInit()了。
class InitTest{
boolean inited = false;
synchronized void init(){
if(inited){
return;
}
//省略doInit的实现
doInit();
inited=true;
}
}
线程安全的单例模式本质上其实也是单次初始化所以可以用Balking模式来实现线程安全的单例模式下面的示例代码是其实现。这个实现虽然功能上没有问题但是性能却很差因为互斥锁synchronized将getInstance()方法串行化了,那有没有办法可以优化一下它的性能呢?
class Singleton{
private static
Singleton singleton;
//构造方法私有化
private Singleton(){}
//获取实例(单例)
public synchronized static
Singleton getInstance(){
if(singleton == null){
singleton=new Singleton();
}
return singleton;
}
}
办法当然是有的那就是经典的双重检查Double Check方案下面的示例代码是其详细实现。在双重检查方案中一旦Singleton对象被成功创建之后就不会执行synchronized(Singleton.class){}相关的代码也就是说此时getInstance()方法的执行路径是无锁的从而解决了性能问题。不过需要你注意的是这个方案中使用了volatile来禁止编译优化其原因你可以参考《01 | 可见性、原子性和有序性问题并发编程Bug的源头》中相关的内容。至于获取锁后的二次检查则是出于对安全性负责。
class Singleton{
private static volatile
Singleton singleton;
//构造方法私有化
private Singleton() {}
//获取实例(单例)
public static Singleton
getInstance() {
//第一次检查
if(singleton==null){
synchronize(Singleton.class){
//获取锁后二次检查
if(singleton==null){
singleton=new Singleton();
}
}
}
return singleton;
}
}
总结
Balking模式和Guarded Suspension模式从实现上看似乎没有多大的关系Balking模式只需要用互斥锁就能解决而Guarded Suspension模式则要用到管程这种高级的并发原语但是从应用的角度来看它们解决的都是“线程安全的if”语义不同之处在于Guarded Suspension模式会等待if条件为真而Balking模式不会等待。
Balking模式的经典实现是使用互斥锁你可以使用Java语言内置synchronized也可以使用SDK提供Lock如果你对互斥锁的性能不满意可以尝试采用volatile方案不过使用volatile方案需要你更加谨慎。
当然你也可以尝试使用双重检查方案来优化性能双重检查中的第一次检查完全是出于对性能的考量避免执行加锁操作因为加锁操作很耗时。而加锁之后的二次检查则是出于对安全性负责。双重检查方案在优化加锁性能方面经常用到例如《17 | ReadWriteLock如何快速实现一个完备的缓存》中实现缓存按需加载功能时也用到了双重检查方案。
课后思考
下面的示例代码中init()方法的本意是仅需计算一次count的值采用了Balking模式的volatile实现方式你觉得这个实现是否有问题呢
class Test{
volatile boolean inited = false;
int count = 0;
void init(){
if(inited){
return;
}
inited = true;
//计算count的值
count = calc();
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 Thread-Per-Message模式最简单实用的分工方法
我们曾经把并发编程领域的问题总结为三个核心问题:分工、同步和互斥。其中,同步和互斥相关问题更多地源自微观,而分工问题则是源自宏观。我们解决问题,往往都是从宏观入手,在编程领域,软件的设计过程也是先从概要设计开始,而后才进行详细设计。同样,解决并发编程问题,首要问题也是解决宏观的分工问题。
并发编程领域里解决分工问题也有一系列的设计模式比较常用的主要有Thread-Per-Message模式、Worker Thread模式、生产者-消费者模式等等。今天我们重点介绍Thread-Per-Message模式。
如何理解Thread-Per-Message模式
现实世界里很多事情我们都需要委托他人办理一方面受限于我们的能力总有很多搞不定的事比如教育小朋友搞不定怎么办呢只能委托学校老师了另一方面受限于我们的时间比如忙着写Bug哪有时间买别墅呢只能委托房产中介了。委托他人代办有一个非常大的好处那就是可以专心做自己的事了。
在编程领域也有很多类似的需求比如写一个HTTP Server很显然只能在主线程中接收请求而不能处理HTTP请求因为如果在主线程中处理HTTP请求的话那同一时间只能处理一个请求太慢了怎么办呢可以利用代办的思路创建一个子线程委托子线程去处理HTTP请求。
这种委托他人办理的方式在并发编程领域被总结为一种设计模式叫做Thread-Per-Message模式简言之就是为每个任务分配一个独立的线程。这是一种最简单的分工方法实现起来也非常简单。
用Thread实现Thread-Per-Message模式
Thread-Per-Message模式的一个最经典的应用场景是网络编程里服务端的实现服务端为每个客户端请求创建一个独立的线程当线程处理完请求后自动销毁这是一种最简单的并发处理网络请求的方法。
网络编程里最简单的程序当数echo程序了echo程序的服务端会原封不动地将客户端的请求发送回客户端。例如客户端发送TCP请求”Hello World”那么服务端也会返回”Hello World”。
下面我们就以echo程序的服务端为例介绍如何实现Thread-Per-Message模式。
在Java语言中实现echo程序的服务端还是很简单的。只需要30行代码就能够实现示例代码如下我们为每个请求都创建了一个Java线程核心代码是new Thread(()->{…}).start()。
final ServerSocketChannel =
ServerSocketChannel.open().bind(
new InetSocketAddress(8080));
//处理请求
try {
while (true) {
// 接收请求
SocketChannel sc = ssc.accept();
// 每个请求都创建一个线程
new Thread(()->{
try {
// 读Socket
ByteBuffer rb = ByteBuffer
.allocateDirect(1024);
sc.read(rb);
//模拟处理请求
Thread.sleep(2000);
// 写Socket
ByteBuffer wb =
(ByteBuffer)rb.flip();
sc.write(wb);
// 关闭Socket
sc.close();
}catch(Exception e){
throw new UncheckedIOException(e);
}
}).start();
}
} finally {
ssc.close();
}
如果你熟悉网络编程相信你一定会提出一个很尖锐的问题上面这个echo服务的实现方案是不具备可行性的。原因在于Java中的线程是一个重量级的对象创建成本很高一方面创建线程比较耗时另一方面线程占用的内存也比较大。所以为每个请求创建一个新的线程并不适合高并发场景。
于是你开始质疑Thread-Per-Message模式而且开始重新思索解决方案这时候很可能你会想到Java提供的线程池。你的这个思路没有问题但是引入线程池难免会增加复杂度。其实你完全可以换一个角度来思考这个问题语言、工具、框架本身应该是帮助我们更敏捷地实现方案的而不是用来否定方案的Thread-Per-Message模式作为一种最简单的分工方案Java语言支持不了显然是Java语言本身的问题。
Java语言里Java线程是和操作系统线程一一对应的这种做法本质上是将Java线程的调度权完全委托给操作系统而操作系统在这方面非常成熟所以这种做法的好处是稳定、可靠但是也继承了操作系统线程的缺点创建成本高。为了解决这个缺点Java并发包里提供了线程池等工具类。这个思路在很长一段时间里都是很稳妥的方案但是这个方案并不是唯一的方案。
业界还有另外一种方案叫做轻量级线程。这个方案在Java领域知名度并不高但是在其他编程语言里却叫得很响例如Go语言、Lua语言里的协程本质上就是一种轻量级的线程。轻量级的线程创建的成本很低基本上和创建一个普通对象的成本相似并且创建的速度和内存占用相比操作系统线程至少有一个数量级的提升所以基于轻量级线程实现Thread-Per-Message模式就完全没有问题了。
Java语言目前也已经意识到轻量级线程的重要性了OpenJDK有个Loom项目就是要解决Java语言的轻量级线程问题在这个项目中轻量级线程被叫做Fiber。下面我们就来看看基于Fiber如何实现Thread-Per-Message模式。
用Fiber实现Thread-Per-Message模式
Loom项目在设计轻量级线程时充分考量了当前Java线程的使用方式采取的是尽量兼容的态度所以使用上还是挺简单的。用Fiber实现echo服务的示例代码如下所示对比Thread的实现你会发现改动量非常小只需要把new Thread(()->{…}).start()换成 Fiber.schedule(()->{})就可以了。
final ServerSocketChannel ssc =
ServerSocketChannel.open().bind(
new InetSocketAddress(8080));
//处理请求
try{
while (true) {
// 接收请求
final SocketChannel sc =
ssc.accept();
Fiber.schedule(()->{
try {
// 读Socket
ByteBuffer rb = ByteBuffer
.allocateDirect(1024);
sc.read(rb);
//模拟处理请求
LockSupport.parkNanos(2000*1000000);
// 写Socket
ByteBuffer wb =
(ByteBuffer)rb.flip()
sc.write(wb);
// 关闭Socket
sc.close();
} catch(Exception e){
throw new UncheckedIOException(e);
}
});
}//while
}finally{
ssc.close();
}
那使用Fiber实现的echo服务是否能够达到预期的效果呢我们可以在Linux环境下做一个简单的实验步骤如下
首先通过 ulimit -u 512 将用户能创建的最大进程数包括线程设置为512
启动Fiber实现的echo程序
利用压测工具ab进行压测ab -r -c 20000 -n 200000 http://测试机IP地址:8080/
压测执行结果如下:
Concurrency Level: 20000
Time taken for tests: 67.718 seconds
Complete requests: 200000
Failed requests: 0
Write errors: 0
Non-2xx responses: 200000
Total transferred: 16400000 bytes
HTML transferred: 0 bytes
Requests per second: 2953.41 [#/sec] (mean)
Time per request: 6771.844 [ms] (mean)
Time per request: 0.339 [ms] (mean, across all concurrent requests)
Transfer rate: 236.50 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 557 3541.6 1 63127
Processing: 2000 2010 31.8 2003 2615
Waiting: 1986 2008 30.9 2002 2615
Total: 2000 2567 3543.9 2004 65293
你会发现即便在20000并发下该程序依然能够良好运行。同等条件下Thread实现的echo程序512并发都抗不过去直接就OOM了。
如果你通过Linux命令 top -Hp pid 查看Fiber实现的echo程序的进程信息你可以看到该进程仅仅创建了16不同CPU核数结果会不同个操作系统线程。
如果你对Loom项目感兴趣也想上手试一把可以下载源代码自己构建构建方法可以参考Project Loom的相关资料不过需要注意的是构建之前一定要把代码分支切换到Fibers。
总结
并发编程领域的分工问题指的是如何高效地拆解任务并分配给线程。前面我们在并发工具类模块中已经介绍了不少解决分工问题的工具类例如Future、CompletableFuture 、CompletionService、Fork/Join计算框架等这些工具类都能很好地解决特定应用场景的问题所以这些工具类曾经是Java语言引以为傲的。不过这些工具类都继承了Java语言的老毛病太复杂。
如果你一直从事Java开发估计你已经习以为常了习惯性地认为这个复杂度是正常的。不过这个世界时刻都在变化曾经正常的复杂度现在看来也许就已经没有必要了例如Thread-Per-Message模式如果使用线程池方案就会增加复杂度。
Thread-Per-Message模式在Java领域并不是那么知名根本原因在于Java语言里的线程是一个重量级的对象为每一个任务创建一个线程成本太高尤其是在高并发领域基本就不具备可行性。不过这个背景条件目前正在发生巨变Java语言未来一定会提供轻量级线程这样基于轻量级线程实现Thread-Per-Message模式就是一个非常靠谱的选择。
当然对于一些并发度没那么高的异步场景例如定时任务采用Thread-Per-Message模式是完全没有问题的。实际工作中我就见过完全基于Thread-Per-Message模式实现的分布式调度框架这个框架为每个定时任务都分配了一个独立的线程。
课后思考
使用Thread-Per-Message模式会为每一个任务都创建一个线程在高并发场景中很容易导致应用OOM那有什么办法可以快速解决呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,163 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 Worker Thread模式如何避免重复创建线程
在上一篇文章中我们介绍了一种最简单的分工模式——Thread-Per-Message模式对应到现实世界其实就是委托代办。这种分工模式如果用Java Thread实现频繁地创建、销毁线程非常影响性能同时无限制地创建线程还可能导致OOM所以在Java领域使用场景就受限了。
要想有效避免线程的频繁创建、销毁以及OOM问题就不得不提今天我们要细聊的也是Java领域使用最多的Worker Thread模式。
Worker Thread模式及其实现
Worker Thread模式可以类比现实世界里车间的工作模式车间里的工人有活儿了大家一起干没活儿了就聊聊天等着。你可以参考下面的示意图来理解Worker Thread模式中Worker Thread对应到现实世界里其实指的就是车间里的工人。不过这里需要注意的是车间里的工人数量往往是确定的。
车间工作示意图
那在编程领域该如何模拟车间的这种工作模式呢或者说如何去实现Worker Thread模式呢通过上面的图你很容易就能想到用阻塞队列做任务池然后创建固定数量的线程消费阻塞队列中的任务。其实你仔细想会发现这个方案就是Java语言提供的线程池。
线程池有很多优点例如能够避免重复创建、销毁线程同时能够限制创建线程的上限等等。学习完上一篇文章后你已经知道用Java的Thread实现Thread-Per-Message模式难以应对高并发场景原因就在于频繁创建、销毁Java线程的成本有点高而且无限制地创建线程还可能导致应用OOM。线程池则恰好能解决这些问题。
那我们还是以echo程序为例看看如何用线程池来实现。
下面的示例代码是用线程池实现的echo服务端相比于Thread-Per-Message模式的实现改动非常少仅仅是创建了一个最多线程数为500的线程池es然后通过es.execute()方法将请求处理的任务提交给线程池处理。
ExecutorService es = Executors
.newFixedThreadPool(500);
final ServerSocketChannel ssc =
ServerSocketChannel.open().bind(
new InetSocketAddress(8080));
//处理请求
try {
while (true) {
// 接收请求
SocketChannel sc = ssc.accept();
// 将请求处理任务提交给线程池
es.execute(()->{
try {
// 读Socket
ByteBuffer rb = ByteBuffer
.allocateDirect(1024);
sc.read(rb);
//模拟处理请求
Thread.sleep(2000);
// 写Socket
ByteBuffer wb =
(ByteBuffer)rb.flip();
sc.write(wb);
// 关闭Socket
sc.close();
}catch(Exception e){
throw new UncheckedIOException(e);
}
});
}
} finally {
ssc.close();
es.shutdown();
}
正确地创建线程池
Java的线程池既能够避免无限制地创建线程导致OOM也能避免无限制地接收任务导致OOM。只不过后者经常容易被我们忽略例如在上面的实现中就被我们忽略了。所以强烈建议你用创建有界的队列来接收任务。
当请求量大于有界队列的容量时,就需要合理地拒绝请求。如何合理地拒绝呢?这需要你结合具体的业务场景来制定,即便线程池默认的拒绝策略能够满足你的需求,也同样建议你在创建线程池时,清晰地指明拒绝策略。
同时,为了便于调试和诊断问题,我也强烈建议你在实际工作中给线程赋予一个业务相关的名字。
综合以上这三点建议echo程序中创建线程可以使用下面的示例代码。
ExecutorService es = new ThreadPoolExecutor(
50, 500,
60L, TimeUnit.SECONDS,
//注意要创建有界队列
new LinkedBlockingQueue<Runnable>(2000),
//建议根据业务需求实现ThreadFactory
r->{
return new Thread(r, "echo-"+ r.hashCode());
},
//建议根据业务需求实现RejectedExecutionHandler
new ThreadPoolExecutor.CallerRunsPolicy());
避免线程死锁
使用线程池过程中,还要注意一种线程死锁的场景。如果提交到相同线程池的任务不是相互独立的,而是有依赖关系的,那么就有可能导致线程死锁。实际工作中,我就亲历过这种线程死锁的场景。具体现象是应用每运行一段时间偶尔就会处于无响应的状态,监控数据看上去一切都正常,但是实际上已经不能正常工作了。
这个出问题的应用,相关的逻辑精简之后,如下图所示,该应用将一个大型的计算任务分成两个阶段,第一个阶段的任务会等待第二阶段的子任务完成。在这个应用里,每一个阶段都使用了线程池,而且两个阶段使用的还是同一个线程池。
应用业务逻辑示意图
我们可以用下面的示例代码来模拟该应用,如果你执行下面的这段代码,会发现它永远执行不到最后一行。执行过程中没有任何异常,但是应用已经停止响应了。
//L1、L2阶段共用的线程池
ExecutorService es = Executors.
newFixedThreadPool(2);
//L1阶段的闭锁
CountDownLatch l1=new CountDownLatch(2);
for (int i=0; i<2; i++){
System.out.println("L1");
//执行L1阶段任务
es.execute(()->{
//L2阶段的闭锁
CountDownLatch l2=new CountDownLatch(2);
//执行L2阶段子任务
for (int j=0; j<2; j++){
es.execute(()->{
System.out.println("L2");
l2.countDown();
});
}
//等待L2阶段任务执行完
l2.await();
l1.countDown();
});
}
//等着L1阶段任务执行完
l1.await();
System.out.println("end");
当应用出现类似问题时,首选的诊断方法是查看线程栈。下图是上面示例代码停止响应后的线程栈,你会发现线程池中的两个线程全部都阻塞在 l2.await(); 这行代码上了也就是说线程池里所有的线程都在等待L2阶段的任务执行完那L2阶段的子任务什么时候能够执行完呢永远都没那一天了为什么呢因为线程池里的线程都阻塞了没有空闲的线程执行L2阶段的任务了。
原因找到了那如何解决就简单了最简单粗暴的办法就是将线程池的最大线程数调大如果能够确定任务的数量不是非常多的话这个办法也是可行的否则这个办法就行不通了。其实这种问题通用的解决方案是为不同的任务创建不同的线程池。对于上面的这个应用L1阶段的任务和L2阶段的任务如果各自都有自己的线程池就不会出现这种问题了。
最后再次强调一下:提交到相同线程池中的任务一定是相互独立的,否则就一定要慎重。
总结
我们曾经说过解决并发编程里的分工问题最好的办法是和现实世界做对比。对比现实世界构建编程领域的模型能够让模型更容易理解。上一篇我们介绍的Thread-Per-Message模式类似于现实世界里的委托他人办理而今天介绍的Worker Thread模式则类似于车间里工人的工作模式。如果你在设计阶段发现对业务模型建模之后模型非常类似于车间的工作模式那基本上就能确定可以在实现阶段采用Worker Thread模式来实现。
Worker Thread模式和Thread-Per-Message模式的区别有哪些呢从现实世界的角度看你委托代办人做事往往是和代办人直接沟通的对应到编程领域其实现也是主线程直接创建了一个子线程主子线程之间是可以直接通信的。而车间工人的工作方式则是完全围绕任务展开的一个具体的任务被哪个工人执行预先是无法知道的对应到编程领域则是主线程提交任务到线程池但主线程并不关心任务被哪个线程执行。
Worker Thread模式能避免线程频繁创建、销毁的问题而且能够限制线程的最大数量。Java语言里可以直接使用线程池来实现Worker Thread模式线程池是一个非常基础和优秀的工具类甚至有些大厂的编码规范都不允许用new Thread()来创建线程的,必须使用线程池。
不过使用线程池还是需要格外谨慎的除了今天重点讲到的如何正确创建线程池、如何避免线程死锁问题还需要注意前面我们曾经提到的ThreadLocal内存泄露问题。同时对于提交到线程池的任务还要做好异常处理避免异常的任务从眼前溜走从业务的角度看有时没有发现异常的任务后果往往都很严重。
课后思考
小灰同学写了如下的代码本义是异步地打印字符串“QQ”请问他的实现是否有问题呢
ExecutorService pool = Executors
.newSingleThreadExecutor();
pool.submit(() -> {
try {
String qq=pool.submit(()->"QQ").get();
System.out.println(qq);
} catch (Exception e) {
}
});
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,197 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 两阶段终止模式:如何优雅地终止线程?
前面两篇文章我们讲述的内容,从纯技术的角度看,都是启动多线程去执行一个异步任务。既启动,那又该如何终止呢?今天咱们就从技术的角度聊聊如何优雅地终止线程,正所谓有始有终。
在《09 | Java线程Java线程的生命周期》中我曾讲过线程执行完或者出现异常就会进入终止状态。这样看终止一个线程看上去很简单啊一个线程执行完自己的任务自己进入终止状态这的确很简单。不过我们今天谈到的“优雅地终止线程”不是自己终止自己而是在一个线程T1中终止线程T2这里所谓的“优雅”指的是给T2一个机会料理后事而不是被一剑封喉。
Java语言的Thread类中曾经提供了一个stop()方法,用来终止线程,可是早已不建议使用了,原因是这个方法用的就是一剑封喉的做法,被终止的线程没有机会料理后事。
既然不建议使用stop()方法那在Java领域我们又该如何优雅地终止线程呢
如何理解两阶段终止模式
前辈们经过认真对比分析已经总结出了一套成熟的方案叫做两阶段终止模式。顾名思义就是将终止过程分成两个阶段其中第一个阶段主要是线程T1向线程T2发送终止指令而第二阶段则是线程T2响应终止指令。
两阶段终止模式示意图
那在Java语言里终止指令是什么呢这个要从Java线程的状态转换过程说起。我们在《09 | Java线程Java线程的生命周期》中曾经提到过Java线程的状态转换图如下图所示。
Java中的线程状态转换图
从这个图里你会发现Java线程进入终止状态的前提是线程进入RUNNABLE状态而实际上线程也可能处在休眠状态也就是说我们要想终止一个线程首先要把线程的状态从休眠状态转换到RUNNABLE状态。如何做到呢这个要靠Java Thread类提供的interrupt()方法它可以将休眠状态的线程转换到RUNNABLE状态。
线程转换到RUNNABLE状态之后我们如何再将其终止呢RUNNABLE状态转换到终止状态优雅的方式是让Java线程自己执行完 run() 方法所以一般我们采用的方法是设置一个标志位然后线程会在合适的时机检查这个标志位如果发现符合终止条件则自动退出run()方法。这个过程其实就是我们前面提到的第二阶段:响应终止指令。
综合上面这两点我们能总结出终止指令其实包括两方面内容interrupt()方法和线程终止的标志位。
理解了两阶段终止模式之后,下面我们看一个实际工作中的案例。
用两阶段终止模式终止监控操作
实际工作中,有些监控系统需要动态地采集一些数据,一般都是监控系统发送采集指令给被监控系统的监控代理,监控代理接收到指令之后,从监控目标收集数据,然后回传给监控系统,详细过程如下图所示。出于对性能的考虑(有些监控项对系统性能影响很大,所以不能一直持续监控),动态采集功能一般都会有终止操作。
动态采集功能示意图
下面的示例代码是监控代理简化之后的实现start()方法会启动一个新的线程rptThread来执行监控数据采集和回传的功能stop()方法需要优雅地终止线程rptThread那stop()相关功能该如何实现呢?
class Proxy {
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
rptThread = new Thread(()->{
while (true) {
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
//如何实现?
}
}
按照两阶段终止模式我们首先需要做的就是将线程rptThread状态转换到RUNNABLE做法很简单只需要在调用 rptThread.interrupt() 就可以了。线程rptThread的状态转换到RUNNABLE之后如何优雅地终止呢下面的示例代码中我们选择的标志位是线程的中断状态Thread.currentThread().isInterrupted() 需要注意的是我们在捕获Thread.sleep()的中断异常之后,通过 Thread.currentThread().interrupt() 重新设置了线程的中断状态因为JVM的异常处理会清除线程的中断状态。
class Proxy {
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
rptThread = new Thread(()->{
while (!Thread.currentThread().isInterrupted()){
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e){
//重新设置线程中断状态
Thread.currentThread().interrupt();
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
rptThread.interrupt();
}
}
上面的示例代码的确能够解决当前的问题但是建议你在实际工作中谨慎使用。原因在于我们很可能在线程的run()方法中调用第三方类库提供的方法而我们没有办法保证第三方类库正确处理了线程的中断异常例如第三方类库在捕获到Thread.sleep()方法抛出的中断异常后没有重新设置线程的中断状态那么就会导致线程不能够正常终止。所以强烈建议你设置自己的线程终止标志位例如在下面的代码中使用isTerminated作为线程终止标志位此时无论是否正确处理了线程的中断异常都不会影响线程优雅地终止。
class Proxy {
//线程终止标志位
volatile boolean terminated = false;
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
terminated = false;
rptThread = new Thread(()->{
while (!terminated){
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e){
//重新设置线程中断状态
Thread.currentThread().interrupt();
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
//设置中断标志位
terminated = true;
//中断线程rptThread
rptThread.interrupt();
}
}
如何优雅地终止线程池
Java领域用的最多的还是线程池而不是手动地创建线程。那我们该如何优雅地终止线程池呢
线程池提供了两个方法shutdown()和shutdownNow()。这两个方法有什么区别呢?要了解它们的区别,就先需要了解线程池的实现原理。
我们曾经讲过Java线程池是生产者-消费者模式的一种实现,提交给线程池的任务,首先是进入一个阻塞队列中,之后线程池中的线程从阻塞队列中取出任务执行。
shutdown()方法是一种很保守的关闭线程池的方法。线程池执行shutdown()后,就会拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。
而shutdownNow()方法相对就激进一些了线程池执行shutdownNow()后会拒绝接收新的任务同时还会中断线程池中正在执行的任务已经进入阻塞队列的任务也被剥夺了执行的机会不过这些被剥夺执行机会的任务会作为shutdownNow()方法的返回值返回。因为shutdownNow()方法会中断正在执行的线程,所以提交到线程池的任务,如果需要优雅地结束,就需要正确地处理线程中断。
如果提交到线程池的任务不允许取消那就不能使用shutdownNow()方法终止线程池。不过如果提交到线程池的任务允许后续以补偿的方式重新执行也是可以使用shutdownNow()方法终止线程池的。《Java并发编程实战》这本书第7章《取消与关闭》的“shutdownNow的局限性”一节中提到一种将已提交但尚未开始执行的任务以及已经取消的正在执行的任务保存起来以便后续重新执行的方案你可以参考一下方案很简单这里就不详细介绍了。
其实分析完shutdown()和shutdownNow()方法你会发现,它们实质上使用的也是两阶段终止模式,只是终止指令的范围不同而已,前者只影响阻塞队列接收任务,后者范围扩大到线程池中所有的任务。
总结
两阶段终止模式是一种应用很广泛的并发设计模式在Java语言中使用两阶段终止模式来优雅地终止线程需要注意两个关键点一个是仅检查终止标志位是不够的因为线程的状态可能处于休眠态另一个是仅检查线程的中断状态也是不够的因为我们依赖的第三方类库很可能没有正确处理中断异常。
当你使用Java的线程池来管理线程的时候需要依赖线程池提供的shutdown()和shutdownNow()方法来终止线程池。不过在使用时需要注意它们的应用场景尤其是在使用shutdownNow()的时候,一定要谨慎。
课后思考
本文的示例代码中线程终止标志位isTerminated被声明为volatile你觉得是否有必要呢
class Proxy {
//线程终止标志位
volatile boolean terminated = false;
......
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,201 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 生产者-消费者模式:用流水线思想提高效率
前面我们在《34 | Worker Thread模式如何避免重复创建线程》中讲到Worker Thread模式类比的是工厂里车间工人的工作模式。但其实在现实世界工厂里还有一种流水线的工作模式类比到编程领域就是生产者-消费者模式。
生产者-消费者模式在编程领域的应用也非常广泛前面我们曾经提到Java线程池本质上就是用生产者-消费者模式实现的,所以每当使用线程池的时候,其实就是在应用生产者-消费者模式。
当然,除了在线程池中的应用,为了提升性能,并发编程领域很多地方也都用到了生产者-消费者模式例如Log4j2中异步Appender内部也用到了生产者-消费者模式。所以今天我们就来深入地聊聊生产者-消费者模式,看看它具体有哪些优点,以及如何提升系统的性能。
生产者-消费者模式的优点
生产者-消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。下面是生产者-消费者模式的一个示意图,你可以结合它来理解。
生产者-消费者模式示意图
从架构设计的角度来看,生产者-消费者模式有一个很重要的优点,就是解耦。解耦对于大型系统的设计非常重要,而解耦的一个关键就是组件之间的依赖关系和通信方式必须受限。在生产者-消费者模式中,生产者和消费者没有任何依赖关系,它们彼此之间的通信只能通过任务队列,所以生产者-消费者模式是一个不错的解耦方案。
除了架构设计上的优点之外,生产者-消费者模式还有一个重要的优点就是支持异步,并且能够平衡生产者和消费者的速度差异。在生产者-消费者模式中,生产者线程只需要将任务添加到任务队列而无需等待任务被消费者线程执行完,也就是说任务的生产和消费是异步的,这是与传统的方法之间调用的本质区别,传统的方法之间调用是同步的。
你或许会有这样的疑问异步化处理最简单的方式就是创建一个新的线程去处理那中间增加一个“任务队列”究竟有什么用呢我觉得主要还是用于平衡生产者和消费者的速度差异。我们假设生产者的速率很慢而消费者的速率很高比如是1:3如果生产者有3个线程采用创建新的线程的方式那么会创建3个子线程而采用生产者-消费者模式消费线程只需要1个就可以了。Java语言里Java线程和操作系统线程是一一对应的线程创建得太多会增加上下文切换的成本所以Java线程不是越多越好适量即可。而生产者-消费者模式恰好能支持你用适量的线程。
支持批量执行以提升性能
前面我们在《33 | Thread-Per-Message模式最简单实用的分工方法》中讲过轻量级的线程如果使用轻量级线程就没有必要平衡生产者和消费者的速度差异了因为轻量级线程本身就是廉价的那是否意味着生产者-消费者模式在性能优化方面就无用武之地了呢?当然不是,有一类并发场景应用生产者-消费者模式就有奇效,那就是批量执行任务。
例如我们要在数据库里INSERT 1000条数据有两种方案第一种方案是用1000个线程并发执行每个线程INSERT一条数据第二种方案是用1个线程执行一个批量的SQL一次性把1000条数据INSERT进去。这两种方案显然是第二种方案效率更高其实这样的应用场景就是我们上面提到的批量执行场景。
在《35 | 两阶段终止模式如何优雅地终止线程》文章中我们提到一个监控系统动态采集的案例其实最终回传的监控数据还是要存入数据库的如下图。但被监控系统往往有很多如果每一条回传数据都直接INSERT到数据库那么这个方案就是上面提到的第一种方案每个线程INSERT一条数据。很显然更好的方案是批量执行SQL那如何实现呢这就要用到生产者-消费者模式了。
动态采集功能示意图
利用生产者-消费者模式实现批量执行SQL非常简单将原来直接INSERT数据到数据库的线程作为生产者线程生产者线程只需将数据添加到任务队列然后消费者线程负责将任务从任务队列中批量取出并批量执行。
在下面的示例代码中我们创建了5个消费者线程负责批量执行SQL这5个消费者线程以 while(true){} 循环方式批量地获取任务并批量地执行。需要注意的是从任务队列中获取批量任务的方法pollTasks()中,首先是以阻塞方式获取任务队列中的一条任务,而后则是以非阻塞的方式获取任务;之所以首先采用阻塞方式,是因为如果任务队列中没有任务,这样的方式能够避免无谓的循环。
//任务队列
BlockingQueue<Task> bq=new
LinkedBlockingQueue<>(2000);
//启动5个消费者线程
//执行批量任务
void start() {
ExecutorService es=executors
.newFixedThreadPool(5);
for (int i=0; i<5; i++) {
es.execute(()->{
try {
while (true) {
//获取批量任务
List<Task> ts=pollTasks();
//执行批量任务
execTasks(ts);
}
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
//从任务队列中获取批量任务
List<Task> pollTasks()
throws InterruptedException{
List<Task> ts=new LinkedList<>();
//阻塞式获取一条任务
Task t = bq.take();
while (t != null) {
ts.add(t);
//非阻塞式获取一条任务
t = bq.poll();
}
return ts;
}
//批量执行任务
execTasks(List<Task> ts) {
//省略具体代码无数
}
支持分阶段提交以提升性能
利用生产者-消费者模式还可以轻松地支持一种分阶段提交的应用场景。我们知道写文件如果同步刷盘性能会很慢,所以对于不是很重要的数据,我们往往采用异步刷盘的方式。我曾经参与过一个项目,其中的日志组件是自己实现的,采用的就是异步刷盘方式,刷盘的时机是:
ERROR级别的日志需要立即刷盘
数据积累到500条需要立即刷盘
存在未刷盘数据且5秒钟内未曾刷盘需要立即刷盘。
这个日志组件的异步刷盘操作本质上其实就是一种分阶段提交。下面我们具体看看用生产者-消费者模式如何实现。在下面的示例代码中,可以通过调用 info()和error() 方法写入日志这两个方法都是创建了一个日志任务LogMsg并添加到阻塞队列中调用 info()和error() 方法的线程是生产者而真正将日志写入文件的是消费者线程在Logger这个类中我们只创建了1个消费者线程在这个消费者线程中会根据刷盘规则执行刷盘操作逻辑很简单这里就不赘述了。
class Logger {
//任务队列
final BlockingQueue<LogMsg> bq
= new BlockingQueue<>();
//flush批量
static final int batchSize=500;
//只需要一个线程写日志
ExecutorService es =
Executors.newFixedThreadPool(1);
//启动写日志线程
void start(){
File file=File.createTempFile(
"foo", ".log");
final FileWriter writer=
new FileWriter(file);
this.es.execute(()->{
try {
//未刷盘日志数量
int curIdx = 0;
long preFT=System.currentTimeMillis();
while (true) {
LogMsg log = bq.poll(
5, TimeUnit.SECONDS);
//写日志
if (log != null) {
writer.write(log.toString());
++curIdx;
}
//如果不存在未刷盘数据,则无需刷盘
if (curIdx <= 0) {
continue;
}
//根据规则刷盘
if (log!=null && log.level==LEVEL.ERROR ||
curIdx == batchSize ||
System.currentTimeMillis()-preFT>5000){
writer.flush();
curIdx = 0;
preFT=System.currentTimeMillis();
}
}
}catch(Exception e){
e.printStackTrace();
} finally {
try {
writer.flush();
writer.close();
}catch(IOException e){
e.printStackTrace();
}
}
});
}
//写INFO级别日志
void info(String msg) {
bq.put(new LogMsg(
LEVEL.INFO, msg));
}
//写ERROR级别日志
void error(String msg) {
bq.put(new LogMsg(
LEVEL.ERROR, msg));
}
}
//日志级别
enum LEVEL {
INFO, ERROR
}
class LogMsg {
LEVEL level;
String msg;
//省略构造函数实现
LogMsg(LEVEL lvl, String msg){}
//省略toString()实现
String toString(){}
}
总结
Java语言提供的线程池本身就是一种生产者-消费者模式的实现,但是线程池中的线程每次只能从任务队列中消费一个任务来执行,对于大部分并发场景这种策略都没有问题。但是有些场景还是需要自己来实现,例如需要批量执行以及分阶段提交的场景。
生产者-消费者模式在分布式计算中的应用也非常广泛。在分布式场景下你可以借助分布式消息队列MQ来实现生产者-消费者模式。MQ一般都会支持两种消息模型一种是点对点模型一种是发布订阅模型。这两种模型的区别在于点对点模型里一个消息只会被一个消费者消费和Java的线程池非常类似Java线程池的任务也只会被一个线程执行而发布订阅模型里一个消息会被多个消费者消费本质上是一种消息的广播在多线程编程领域你可以结合观察者模式实现广播功能。
课后思考
在日志组件异步刷盘的示例代码中,写日志的线程以 while(true){} 的方式执行,你有哪些办法可以优雅地终止这个线程呢?
this.writer.execute(()->{
try {
//未刷盘日志数量
int curIdx = 0;
long preFT=System.currentTimeMillis();
while (true) {
......
}
} catch(Exception e) {}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,165 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 设计模式模块热点问题答疑
多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。
在这个模块我们总共介绍了9种常见的多线程设计模式。下面我们就对这9种设计模式做个分类和总结同时也对前面各章的课后思考题做个答疑。
避免共享的设计模式
Immutability模式、Copy-on-Write模式和线程本地存储模式本质上都是为了避免共享只是实现手段不同而已。这3种设计模式的实现都很简单但是实现过程中有些细节还是需要格外注意的。例如使用Immutability模式需要注意对象属性的不可变性使用Copy-on-Write模式需要注意性能问题使用线程本地存储模式需要注意异步执行问题。所以每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。
《28 | Immutability模式如何利用不变性解决并发问题》的课后思考题是讨论Account这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现而实质上这个实现是有问题的原因在于StringBuffer不同于StringStringBuffer不具备不可变性通过getUser()方法获取user之后是可以修改user的。一个简单的解决方案是让getUser()方法返回String对象。
public final class Account{
private final
StringBuffer user;
public Account(String user){
this.user =
new StringBuffer(user);
}
//返回的StringBuffer并不具备不可变性
public StringBuffer getUser(){
return this.user;
}
public String toString(){
return "user"+user;
}
}
《29 | Copy-on-Write模式不是延时策略的COW》的课后思考题是讨论Java SDK中为什么没有提供 CopyOnWriteLinkedList。这是一个开放性的问题没有标准答案但是性能问题一定是其中一个很重要的原因毕竟完整地复制LinkedList性能开销太大了。
《30 | 线程本地存储模式:没有共享,就没有伤害》的课后思考题是在异步场景中,是否可以使用 Spring 的事务管理器。答案显然是不能的Spring 使用 ThreadLocal 来传递事务信息,因此这个事务信息是不能跨线程共享的。实际工作中有很多类库都是用 ThreadLocal 传递上下文信息的,这种场景下如果有异步操作,一定要注意上下文信息是不能跨线程共享的。
多线程版本IF的设计模式
Guarded Suspension模式和Balking模式都可以简单地理解为“多线程版本的if”但它们的区别在于前者会等待if条件变为真而后者则不需要等待。
Guarded Suspension模式的经典实现是使用管程很多初学者会简单地用线程sleep的方式实现比如《31 | Guarded Suspension模式等待唤醒机制的规范实现》的思考题就是用线程sleep方式实现的。但不推荐你使用这种方式最重要的原因是性能如果sleep的时间太长会影响响应时间sleep的时间太短会导致线程频繁地被唤醒消耗系统资源。
同时示例代码的实现也有问题由于obj不是volatile变量所以即便obj被设置了正确的值执行 while(!p.test(obj)) 的线程也有可能看不到从而导致更长时间的sleep。
//获取受保护对象
T get(Predicate<T> p) {
try {
//obj的可见性无法保证
while(!p.test(obj)){
TimeUnit.SECONDS
.sleep(timeout);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
this.obj = obj;
}
实现Balking模式最容易忽视的就是竞态条件问题。比如《32 | Balking模式再谈线程安全的单例模式》的思考题就存在竞态条件问题。因此在多线程场景中使用if语句时一定要多问自己一遍是否存在竞态条件。
class Test{
volatile boolean inited = false;
int count = 0;
void init(){
//存在竞态条件
if(inited){
return;
}
//有可能多个线程执行到这里
inited = true;
//计算count的值
count = calc();
}
}
三种最简单的分工模式
Thread-Per-Message模式、Worker Thread模式和生产者-消费者模式是三种最简单实用的多线程分工方法。虽说简单,但也还是有许多细节需要你多加小心和注意。
Thread-Per-Message模式在实现的时候需要注意是否存在线程的频繁创建、销毁以及是否可能导致OOM。在《33 | Thread-Per-Message模式最简单实用的分工方法》文章中最后的思考题就是关于如何快速解决OOM问题的。在高并发场景中最简单的办法其实是限流。当然限流方案也并不局限于解决Thread-Per-Message模式中的OOM问题。
Worker Thread模式的实现需要注意潜在的线程死锁问题。《34 | Worker Thread模式如何避免重复创建线程》思考题中的示例代码就存在线程死锁。有名叫vector的同学关于这道思考题的留言我觉得描述得很贴切和形象“工厂里只有一个工人他的工作就是同步地等待工厂里其他人给他提供东西然而并没有其他人他将等到天荒地老海枯石烂”因此共享线程池虽然能够提供线程池的使用效率但一定要保证一个前提那就是任务之间没有依赖关系。
ExecutorService pool = Executors
.newSingleThreadExecutor();
//提交主任务
pool.submit(() -> {
try {
//提交子任务并等待其完成,
//会导致线程死锁
String qq=pool.submit(()->"QQ").get();
System.out.println(qq);
} catch (Exception e) {
}
});
Java线程池本身就是一种生产者-消费者模式的实现所以大部分场景你都不需要自己实现直接使用Java的线程池就可以了。但若能自己灵活地实现生产者-消费者模式会更好比如可以实现批量执行和分阶段提交不过这过程中还需要注意如何优雅地终止线程《36 | 生产者-消费者模式:用流水线思想提高效率》的思考题就是关于此的。
如何优雅地终止线程我们在《35 | 两阶段终止模式:如何优雅地终止线程?》有过详细介绍,两阶段终止模式是一种通用的解决方案。但其实终止生产者-消费者服务还有一种更简单的方案叫做“毒丸”对象。《Java并发编程实战》第7章的7.2.3节对“毒丸”对象有过详细的介绍。简单来讲,“毒丸”对象是生产者生产的一条特殊任务,然后当消费者线程读到“毒丸”对象时,会立即终止自身的执行。
下面是用“毒丸”对象终止写日志线程的具体实现整体的实现过程还是很简单的类Logger中声明了一个“毒丸”对象poisonPill 当消费者线程从阻塞队列bq中取出一条LogMsg后先判断是否是“毒丸”对象如果是则break while循环从而终止自己的执行。
class Logger {
//用于终止日志执行的“毒丸”
final LogMsg poisonPill =
new LogMsg(LEVEL.ERROR, "");
//任务队列
final BlockingQueue<LogMsg> bq
= new BlockingQueue<>();
//只需要一个线程写日志
ExecutorService es =
Executors.newFixedThreadPool(1);
//启动写日志线程
void start(){
File file=File.createTempFile(
"foo", ".log");
final FileWriter writer=
new FileWriter(file);
this.es.execute(()->{
try {
while (true) {
LogMsg log = bq.poll(
5, TimeUnit.SECONDS);
//如果是“毒丸”,终止执行
if(poisonPill.equals(logMsg)){
break;
}
//省略执行逻辑
}
} catch(Exception e){
} finally {
try {
writer.flush();
writer.close();
}catch(IOException e){}
}
});
}
//终止写日志线程
public void stop() {
//将“毒丸”对象加入阻塞队列
bq.add(poisonPill);
es.shutdown();
}
}
总结
到今天为止“并发设计模式”模块就告一段落了多线程的设计模式当然不止我们提到的这9种不过这里提到的这9种设计模式一定是最简单实用的。如果感兴趣你也可以结合《图解Java多线程设计模式》这本书来深入学习这个模块这是一本不错的并发编程入门书籍虽然重点是讲解设计模式但是也详细讲解了设计模式中涉及到的方方面面的基础知识而且深入浅出非常推荐入门的同学认真学习一下。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,215 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 案例分析高性能限流器Guava RateLimiter
从今天开始,我们就进入案例分析模块了。 这个模块我们将分析四个经典的开源框架,看看它们是如何处理并发问题的,通过这四个案例的学习,相信你会对如何解决并发问题有个更深入的认识。
首先我们来看看Guava RateLimiter是如何解决高并发场景下的限流问题的。Guava是Google开源的Java类库提供了一个工具类RateLimiter。我们先来看看RateLimiter的使用让你对限流有个感官的印象。假设我们有一个线程池它每秒只能处理两个任务如果提交的任务过快可能导致系统不稳定这个时候就需要用到限流。
在下面的示例代码中我们创建了一个流速为2个请求/秒的限流器这里的流速该怎么理解呢直观地看2个请求/秒指的是每秒最多允许2个请求通过限流器其实在Guava中流速还有更深一层的意思是一种匀速的概念2个请求/秒等价于1个请求/500毫秒。
在向线程池提交任务之前,调用 acquire() 方法就能起到限流的作用。通过示例代码的执行结果任务提交到线程池的时间间隔基本上稳定在500毫秒。
//限流器流速2个请求/秒
RateLimiter limiter =
RateLimiter.create(2.0);
//执行任务的线程池
ExecutorService es = Executors
.newFixedThreadPool(1);
//记录上一次执行时间
prev = System.nanoTime();
//测试执行20次
for (int i=0; i<20; i++){
//限流器限流
limiter.acquire();
//提交任务异步执行
es.execute(()->{
long cur=System.nanoTime();
//打印时间间隔:毫秒
System.out.println(
(cur-prev)/1000_000);
prev = cur;
});
}
输出结果:
...
500
499
499
500
499
经典限流算法:令牌桶算法
Guava的限流器使用上还是很简单的那它是如何实现的呢Guava采用的是令牌桶算法其核心是要想通过限流器必须拿到令牌。也就是说只要我们能够限制发放令牌的速率那么就能控制流速了。令牌桶算法的详细描述如下
令牌以固定的速率添加到令牌桶中,假设限流的速率是 r/秒,则令牌每 1/r 秒会添加一个;
假设令牌桶的容量是 b ,如果令牌桶已满,则新的令牌会被丢弃;
请求能够通过限流器的前提是令牌桶中有令牌。
这个算法中,限流的速率 r 还是比较容易理解的,但令牌桶的容量 b 该怎么理解呢b 其实是burst的简写意义是限流器允许的最大突发流量。比如b=10而且令牌桶中的令牌已满此时限流器允许10个请求同时通过限流器当然只是突发流量而已这10个请求会带走10个令牌所以后续的流量只能按照速率 r 通过限流器。
令牌桶这个算法如何用Java实现呢很可能你的直觉会告诉你生产者-消费者模式:一个生产者线程定时向阻塞队列中添加令牌,而试图通过限流器的线程则作为消费者线程,只有从阻塞队列中获取到令牌,才允许通过限流器。
这个算法看上去非常完美,而且实现起来非常简单,如果并发量不大,这个实现并没有什么问题。可实际情况却是使用限流的场景大部分都是高并发场景,而且系统压力已经临近极限了,此时这个实现就有问题了。问题就出在定时器上,在高并发场景下,当系统压力已经临近极限的时候,定时器的精度误差会非常大,同时定时器本身会创建调度线程,也会对系统的性能产生影响。
那还有什么好的实现方式呢当然有Guava的实现就没有使用定时器下面我们就来看看它是如何实现的。
Guava如何实现令牌桶算法
Guava实现令牌桶算法用了一个很简单的办法其关键是记录并动态计算下一令牌发放的时间。下面我们以一个最简单的场景来介绍该算法的执行过程。假设令牌桶的容量为 b=1限流速率 r = 1个请求/秒如下图所示如果当前令牌桶中没有令牌下一个令牌的发放时间是在第3秒而在第2秒的时候有一个线程T1请求令牌此时该如何处理呢
线程T1请求令牌示意图
对于这个请求令牌的线程而言很显然需要等待1秒因为1秒以后第3秒它就能拿到令牌了。此时需要注意的是下一个令牌发放的时间也要增加1秒为什么呢因为第3秒发放的令牌已经被线程T1预占了。处理之后如下图所示。
线程T1请求结束示意图
假设T1在预占了第3秒的令牌之后马上又有一个线程T2请求令牌如下图所示。
线程T2请求令牌示意图
很显然由于下一个令牌产生的时间是第4秒所以线程T2要等待两秒的时间才能获取到令牌同时由于T2预占了第4秒的令牌所以下一令牌产生时间还要增加1秒完全处理之后如下图所示。
线程T2请求结束示意图
上面线程T1、T2都是在下一令牌产生时间之前请求令牌如果线程在下一令牌产生时间之后请求令牌会如何呢假设在线程T1请求令牌之后的5秒也就是第7秒线程T3请求令牌如下图所示。
线程T3请求令牌示意图
由于在第5秒已经产生了一个令牌所以此时线程T3可以直接拿到令牌而无需等待。在第7秒实际上限流器能够产生3个令牌第5、6、7秒各产生一个令牌。由于我们假设令牌桶的容量是1所以第6、7秒产生的令牌就丢弃了其实等价地你也可以认为是保留的第7秒的令牌丢弃的第5、6秒的令牌也就是说第7秒的令牌被线程T3占有了于是下一令牌的的产生时间应该是第8秒如下图所示。
线程T3请求结束示意图
通过上面简要地分析你会发现我们只需要记录一个下一令牌产生的时间并动态更新它就能够轻松完成限流功能。我们可以将上面的这个算法代码化示例代码如下所示依然假设令牌桶的容量是1。关键是reserve()方法这个方法会为请求令牌的线程预分配令牌同时返回该线程能够获取令牌的时间。其实现逻辑就是上面提到的如果线程请求令牌的时间在下一令牌产生时间之后那么该线程立刻就能够获取令牌反之如果请求时间在下一令牌产生时间之前那么该线程是在下一令牌产生的时间获取令牌。由于此时下一令牌已经被该线程预占所以下一令牌产生的时间需要加上1秒。
class SimpleLimiter {
//下一令牌产生时间
long next = System.nanoTime();
//发放令牌间隔:纳秒
long interval = 1000_000_000;
//预占令牌,返回能够获取令牌的时间
synchronized long reserve(long now){
//请求时间在下一令牌产生时间之后
//重新计算下一令牌产生时间
if (now > next){
//将下一令牌产生时间重置为当前时间
next = now;
}
//能够获取令牌的时间
long at=next;
//设置下一令牌产生时间
next += interval;
//返回线程需要等待的时间
return Math.max(at, 0L);
}
//申请令牌
void acquire() {
//申请令牌时的时间
long now = System.nanoTime();
//预占令牌
long at=reserve(now);
long waitTime=max(at-now, 0);
//按照条件等待
if(waitTime > 0) {
try {
TimeUnit.NANOSECONDS
.sleep(waitTime);
}catch(InterruptedException e){
e.printStackTrace();
}
}
}
}
如果令牌桶的容量大于1又该如何处理呢按照令牌桶算法令牌要首先从令牌桶中出所以我们需要按需计算令牌桶中的数量当有线程请求令牌时先从令牌桶中出。具体的代码实现如下所示。我们增加了一个resync()方法,在这个方法中,如果线程请求令牌的时间在下一令牌产生时间之后,会重新计算令牌桶中的令牌数,新产生的令牌的计算公式是:(now-next)/interval你可对照上面的示意图来理解。reserve()方法中则增加了先从令牌桶中出令牌的逻辑不过需要注意的是如果令牌是从令牌桶中出的那么next就无需增加一个 interval 了。
class SimpleLimiter {
//当前令牌桶中的令牌数量
long storedPermits = 0;
//令牌桶的容量
long maxPermits = 3;
//下一令牌产生时间
long next = System.nanoTime();
//发放令牌间隔:纳秒
long interval = 1000_000_000;
//请求时间在下一令牌产生时间之后,则
// 1.重新计算令牌桶中的令牌数
// 2.将下一个令牌发放时间重置为当前时间
void resync(long now) {
if (now > next) {
//新产生的令牌数
long newPermits=(now-next)/interval;
//新令牌增加到令牌桶
storedPermits=min(maxPermits,
storedPermits + newPermits);
//将下一个令牌发放时间重置为当前时间
next = now;
}
}
//预占令牌,返回能够获取令牌的时间
synchronized long reserve(long now){
resync(now);
//能够获取令牌的时间
long at = next;
//令牌桶中能提供的令牌
long fb=min(1, storedPermits);
//令牌净需求:首先减掉令牌桶中的令牌
long nr = 1 - fb;
//重新计算下一令牌产生时间
next = next + nr*interval;
//重新计算令牌桶中的令牌
this.storedPermits -= fb;
return at;
}
//申请令牌
void acquire() {
//申请令牌时的时间
long now = System.nanoTime();
//预占令牌
long at=reserve(now);
long waitTime=max(at-now, 0);
//按照条件等待
if(waitTime > 0) {
try {
TimeUnit.NANOSECONDS
.sleep(waitTime);
}catch(InterruptedException e){
e.printStackTrace();
}
}
}
}
总结
经典的限流算法有两个一个是令牌桶算法Token Bucket另一个是漏桶算法Leaky Bucket。令牌桶算法是定时向令牌桶发送令牌请求能够从令牌桶中拿到令牌然后才能通过限流器而漏桶算法里请求就像水一样注入漏桶漏桶会按照一定的速率自动将水漏掉只有漏桶里还能注入水的时候请求才能通过限流器。令牌桶算法和漏桶算法很像一个硬币的正反面所以你可以参考令牌桶算法的实现来实现漏桶算法。
上面我们介绍了Guava是如何实现令牌桶算法的我们的示例代码是对Guava RateLimiter的简化Guava RateLimiter扩展了标准的令牌桶算法比如还能支持预热功能。对于按需加载的缓存来说预热后缓存能支持5万TPS的并发但是在预热前5万TPS的并发直接就把缓存击垮了所以如果需要给该缓存限流限流器也需要支持预热功能在初始阶段限制的流速 r 很小但是动态增长的。预热功能的实现非常复杂Guava构建了一个积分函数来解决这个问题如果你感兴趣可以继续深入研究。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,145 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 案例分析高性能网络应用框架Netty
Netty是一个高性能网络应用框架应用非常普遍目前在Java领域里Netty基本上成为网络程序的标配了。Netty框架功能丰富也非常复杂今天我们主要分析Netty框架中的线程模型而线程模型直接影响着网络程序的性能。
在介绍Netty的线程模型之前我们首先需要把问题搞清楚了解网络编程性能的瓶颈在哪里然后再看Netty的线程模型是如何解决这个问题的。
网络编程性能的瓶颈
在《33 | Thread-Per-Message模式最简单实用的分工方法》中我们写过一个简单的网络程序echo采用的是阻塞式I/OBIO。BIO模型里所有read()操作和write()操作都会阻塞当前线程的如果客户端已经和服务端建立了一个连接而迟迟不发送数据那么服务端的read()操作会一直阻塞所以使用BIO模型一般都会为每个socket分配一个独立的线程这样就不会因为线程阻塞在一个socket上而影响对其他socket的读写。BIO的线程模型如下图所示每一个socket都对应一个独立的线程为了避免频繁创建、消耗线程可以采用线程池但是socket和线程之间的对应关系并不会变化。
BIO的线程模型
BIO这种线程模型适用于socket连接不是很多的场景但是现在的互联网场景往往需要服务器能够支撑十万甚至百万连接而创建十万甚至上百万个线程显然并不现实所以BIO线程模型无法解决百万连接的问题。如果仔细观察你会发现互联网场景中虽然连接多但是每个连接上的请求并不频繁所以线程大部分时间都在等待I/O就绪。也就是说线程大部分时间都阻塞在那里这完全是浪费如果我们能够解决这个问题那就不需要这么多线程了。
顺着这个思路我们可以将线程模型优化为下图这个样子可以用一个线程来处理多个连接这样线程的利用率就上来了同时所需的线程数量也跟着降下来了。这个思路很好可是使用BIO相关的API是无法实现的这是为什么呢因为BIO相关的socket读写操作都是阻塞式的而一旦调用了阻塞式API在I/O就绪前调用线程会一直阻塞也就无法处理其他的socket连接了。
理想的线程模型图
好在Java里还提供了非阻塞式NIOAPI利用非阻塞式API就能够实现一个线程处理多个连接了。那具体如何实现呢现在普遍都是采用Reactor模式包括Netty的实现。所以要想理解Netty的实现接下来我们就需要先了解一下Reactor模式。
Reactor模式
下面是Reactor模式的类结构图其中Handle指的是I/O句柄在Java网络编程里它本质上就是一个网络连接。Event Handler很容易理解就是一个事件处理器其中handle_event()方法处理I/O事件也就是每个Event Handler处理一个I/O Handleget_handle()方法可以返回这个I/O的Handle。Synchronous Event Demultiplexer可以理解为操作系统提供的I/O多路复用API例如POSIX标准里的select()以及Linux里面的epoll()。
Reactor模式类结构图
Reactor模式的核心自然是Reactor这个类其中register_handler()和remove_handler()这两个方法可以注册和删除一个事件处理器handle_events()方式是核心也是Reactor模式的发动机这个方法的核心逻辑如下首先通过同步事件多路选择器提供的select()方法监听网络事件当有网络事件就绪后就遍历事件处理器来处理该网络事件。由于网络事件是源源不断的所以在主程序中启动Reactor模式需要以 while(true){} 的方式调用handle_events()方法。
void Reactor::handle_events(){
//通过同步事件多路选择器提供的
//select()方法监听网络事件
select(handlers);
//处理网络事件
for(h in handlers){
h.handle_event();
}
}
// 在主程序中启动事件循环
while (true) {
handle_events();
Netty中的线程模型
Netty的实现虽然参考了Reactor模式但是并没有完全照搬Netty中最核心的概念是事件循环EventLoop其实也就是Reactor模式中的Reactor负责监听网络事件并调用事件处理器进行处理。在4.x版本的Netty中网络连接和EventLoop是稳定的多对1关系而EventLoop和Java线程是1对1关系这里的稳定指的是关系一旦确定就不再发生变化。也就是说一个网络连接只会对应唯一的一个EventLoop而一个EventLoop也只会对应到一个Java线程所以一个网络连接只会对应到一个Java线程。
一个网络连接对应到一个Java线程上有什么好处呢最大的好处就是对于一个网络连接的事件处理是单线程的这样就避免了各种并发问题。
Netty中的线程模型可以参考下图这个图和前面我们提到的理想的线程模型图非常相似核心目标都是用一个线程处理多个网络连接。
Netty中的线程模型
Netty中还有一个核心概念是EventLoopGroup顾名思义一个EventLoopGroup由一组EventLoop组成。实际使用中一般都会创建两个EventLoopGroup一个称为bossGroup一个称为workerGroup。为什么会有两个EventLoopGroup呢
这个和socket处理网络请求的机制有关socket处理TCP网络连接请求是在一个独立的socket中每当有一个TCP连接成功建立都会创建一个新的socket之后对TCP连接的读写都是由新创建处理的socket完成的。也就是说处理TCP连接请求和读写请求是通过两个不同的socket完成的。上面我们在讨论网络请求的时候为了简化模型只是讨论了读写请求而没有讨论连接请求。
在Netty中bossGroup就用来处理连接请求的而workerGroup是用来处理读写请求的。bossGroup处理完连接请求后会将这个连接提交给workerGroup来处理 workerGroup里面有多个EventLoop那新的连接会交给哪个EventLoop来处理呢这就需要一个负载均衡算法Netty中目前使用的是轮询算法。
下面我们用Netty重新实现以下echo程序的服务端近距离感受一下Netty。
用Netty实现Echo程序服务端
下面的示例代码基于Netty实现了echo程序服务端首先创建了一个事件处理器等同于Reactor模式中的事件处理器然后创建了bossGroup和workerGroup再之后创建并初始化了ServerBootstrap代码还是很简单的不过有两个地方需要注意一下。
第一个如果NettybossGroup只监听一个端口那bossGroup只需要1个EventLoop就可以了多了纯属浪费。
第二个默认情况下Netty会创建“2*CPU核数”个EventLoop由于网络连接与EventLoop有稳定的关系所以事件处理器在处理网络事件的时候是不能有阻塞操作的否则很容易导致请求大面积超时。如果实在无法避免使用阻塞操作那可以通过线程池来异步处理。
//事件处理器
final EchoServerHandler serverHandler
= new EchoServerHandler();
//boss线程组
EventLoopGroup bossGroup
= new NioEventLoopGroup(1);
//worker线程组
EventLoopGroup workerGroup
= new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch){
ch.pipeline().addLast(serverHandler);
}
});
//bind服务端端口
ChannelFuture f = b.bind(9090).sync();
f.channel().closeFuture().sync();
} finally {
//终止工作线程组
workerGroup.shutdownGracefully();
//终止boss线程组
bossGroup.shutdownGracefully();
}
//socket连接处理器
class EchoServerHandler extends
ChannelInboundHandlerAdapter {
//处理读事件
@Override
public void channelRead(
ChannelHandlerContext ctx, Object msg){
ctx.write(msg);
}
//处理读完成事件
@Override
public void channelReadComplete(
ChannelHandlerContext ctx){
ctx.flush();
}
//处理异常事件
@Override
public void exceptionCaught(
ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
总结
Netty是一个款优秀的网络编程框架性能非常好为了实现高性能的目标Netty做了很多优化例如优化了ByteBuffer、支持零拷贝等等和并发编程相关的就是它的线程模型了。Netty的线程模型设计得很精巧每个网络连接都关联到了一个线程上这样做的好处是对于一个网络连接读写操作都是单线程执行的从而避免了并发程序的各种问题。
你要想深入理解Netty的线程模型还需要对网络相关知识有一定的理解关于Java IO的演进过程你可以参考Scalable IO in Java至于TCP/IP网络编程的知识你可以参考韩国尹圣雨写的经典教程——《TCP/IP网络编程》。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,83 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
3 个用户来信 打开一个新的并发世界
你好,我是王宝令。
很高兴能再次收到用户的来信,一下子还是 3 封,真是受宠若惊。
通过大家的来信、留言,我深刻感受到大家学习的欲望和热情,也很感谢你们能跟着我一起,把并发这么难啃的知识点都“嚼碎了”——“吃下去”——“消化掉”,变成自己的东西。
脚踏实地,才能仰望天空。
来信一:他说,这是一盏明灯,可以带你少走很多弯路,正确前行,野蛮生长。
你好我是笑笑17届杭师大计算机毕业的学生现在一个电商互联网公司做 Java开发。
在没有学习这个专栏之前,我自己也曾读过一些并发编程相关的书,但那时刚毕业,看完后也仅仅是知道了并发的概念、并发产生的原因,以及一些并发工具类的使用,整体处于“了解”阶段,距离“掌握”还很远。所以,看到“极客时间”出并发编程的专栏后,我立马就订阅了。
第一个感受:宝令老师的讲解思路特别清晰,由简入深。为什么会出现这些技术、这些技术带来的影响点以及如何能更合理地使用这些技术等内容,都阐述得清清楚楚。整个专栏下来,宝令老师带我“游览”并看清了并发编程的全貌。
第二个感受:清晰简洁,理论和实践并行。每次读完老师的文章后,先前很多模糊的知识点都变得更加地清晰,比如:
可见性是由于在多核时代每颗CPU都有自己的缓存导致的具体看《01 | 可见性、原子性和有序性问题并发编程Bug的源头》
锁要和资源关联起来一个锁可以锁多个资源但是一个资源不可以用多个锁可类比球赛门票的管理点击温故《03 | 互斥锁(上):解决原子性问题》;
Java 线程的生命周期与操作系统线程生命周期的相通点以及区别可参考《09 | Java线程Java线程的生命周期》
结合例子来带你理解 Happens-Before 规则具体看《02 | Java内存模型看Java如何解决可见性和有序性问题》。-
……
总之 ,十分感谢宝令老师这几个月的付出。想必很多同学都跟我一样,不能说看了专栏我们并发的能力一下子变得多么多么厉害(这也不现实)。但,它绝对是一盏明灯,给我们指明了方向,让我们在并发的道路上少走很多弯路,正确前行,野蛮生长。
宝令回信:
很高兴能够为你答疑解惑,学习最怕的是没问题,只要有问题就一定能找到答案,探索的过程就是提升的过程。也感谢你这几个月的支持和信任!
来信二他说于是我有了自己的“Java并发编程全景图”。
你好,我是华应,互联网行业的一名非著名程序员。
关于并发编程的学习,我也曾多次尝试学习,从不同的切入点或者方法学习过,但都不得要领。 看了宝令老师专栏的试读文章后,我发现每句话都戳中自己学习过程中的痛处,就决定跟着了。
让我印象比较深刻的是在第一部分“并发理论基础”的最后老师专门拿出了一篇文章来为大家答疑每个问题都非常经典涉及到CPU、缓存、内存、IO、并发编程相关的操作系统层面的线程、锁、指令等知识点为我打开了并发新世界。同时我把难啃的知识放在了自己技能全景图中时时温故知新。
总体来说,学习完这个专栏后,我获益良多,不仅是对并发编程有了系统化的理解,也第一次针对并发编程绘制出了自己的全景图(如下图)。生有涯而学无涯,相信这些知识图谱定能给我指明前进方向,点亮我的技术人生。
真心谢谢宝令老师,希望老师能再出更多的专栏,我们江湖再见!
宝令回信:
系统化地学习很重要,这样遇到问题不会迷茫。感谢你分享的全景图,教学相长,我们互相学习!
来信三:他说,如今每做一个需求,都会对其资源消耗、时间损耗和并发安全有进一步的思考和优化。
你好,我是小肖,在深圳的一家金融公司负责后端业务开发。
想起学并发编程是因为在找工作时经常会有面试官问我有没有并发经验这时我才意识到自己在并发方面的不足。但由于整天沉浸在各种业务代码的CRUD中而且公司用户数量不大导致自己接触的并发场景少完全缺少理论+实战经验。
所以当“极客时间”出了《Java并发编程实战》这门课后我立马就订阅了同时也对宝令老师的分享充满了期待。
事实证明老师分享的知识深度广度让我叹为观止干货非常之多许多知识也很贴近实战。比如在第二部分“并发工具类”的14篇文章里我跟着学会了如何用多线程并行操作来优化程序执行时间以及如何用线程通信来让程序执行得更高效。
就这样,从头到尾跟下来后,我收获颇丰!
现在,我也尝试着把我学到的知识点用于项目中,不断优化自己的代码。如今我开始每做一个需求,都会对其资源消耗、时间损耗和并发安全多进行一步思考和优化。这些都为我的项目成功上线起到了重要的保障作用,我的同事也开始夸奖我并发方面的表现突出。这是我最开心和欣慰的地方。
由此,我想感谢宝令老师,是发自内心且由衷地感谢。感谢老师这几个月的一直陪伴,感谢老师分享的知识让我向着理想更进了一步,感谢老师怀揣着对技术的执着之心激励我初心依旧。
宝令回信:
你这么快就能在工作中熟练使用了,这是我最开心和欣慰的地方。学会怎么思考并且在工作中实践,进步一定很快。祝在工作中更上一层楼!

View File

@ -0,0 +1,207 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 案例分析高性能队列Disruptor
我们在《20 | 并发容器都有哪些“坑”需要我们填》介绍过Java SDK提供了2个有界队列ArrayBlockingQueue 和 LinkedBlockingQueue它们都是基于ReentrantLock实现的在高并发场景下锁的效率并不高那有没有更好的替代品呢今天我们就介绍一种性能更高的有界队列Disruptor。
Disruptor是一款高性能的有界内存队列目前应用非常广泛Log4j2、Spring Messaging、HBase、Storm都用到了Disruptor那Disruptor的性能为什么这么高呢Disruptor项目团队曾经写过一篇论文详细解释了其原因可以总结为如下
内存分配更加合理使用RingBuffer数据结构数组元素在初始化时一次性全部创建提升缓存命中率对象循环利用避免频繁GC。
能够避免伪共享,提升缓存利用率。
采用无锁算法,避免频繁加锁、解锁的性能消耗。
支持批量消费,消费者可以无锁方式消费多个消息。
其中前三点涉及到的知识比较多所以今天咱们重点讲解前三点不过在详细介绍这些知识之前我们先来聊聊Disruptor如何使用好让你先对Disruptor有个感官的认识。
下面的代码出自官方示例我略做了一些修改相较而言Disruptor的使用比Java SDK提供BlockingQueue要复杂一些但是总体思路还是一致的其大致情况如下
在Disruptor中生产者生产的对象也就是消费者消费的对象称为Event使用Disruptor必须自定义Event例如示例代码的自定义Event是LongEvent
构建Disruptor对象除了要指定队列大小外还需要传入一个EventFactory示例代码中传入的是LongEvent::new
消费Disruptor中的Event需要通过handleEventsWith()方法注册一个事件处理器发布Event则需要通过publishEvent()方法。
//自定义Event
class LongEvent {
private long value;
public void set(long value) {
this.value = value;
}
}
//指定RingBuffer大小,
//必须是2的N次方
int bufferSize = 1024;
//构建Disruptor
Disruptor disruptor
= new Disruptor<>(
LongEvent::new,
bufferSize,
DaemonThreadFactory.INSTANCE);
//注册事件处理器
disruptor.handleEventsWith(
(event, sequence, endOfBatch) ->
System.out.println("E: "+event));
//启动Disruptor
disruptor.start();
//获取RingBuffer
RingBuffer ringBuffer
= disruptor.getRingBuffer();
//生产Event
ByteBuffer bb = ByteBuffer.allocate(8);
for (long l = 0; true; l++){
bb.putLong(0, l);
//生产者生产消息
ringBuffer.publishEvent(
(event, sequence, buffer) ->
event.set(buffer.getLong(0)), bb);
Thread.sleep(1000);
}
RingBuffer如何提升性能
Java SDK中ArrayBlockingQueue使用数组作为底层的数据存储而Disruptor是使用RingBuffer作为数据存储。RingBuffer本质上也是数组所以仅仅将数据存储从数组换成RingBuffer并不能提升性能但是Disruptor在RingBuffer的基础上还做了很多优化其中一项优化就是和内存分配有关的。
在介绍这项优化之前,你需要先了解一下程序的局部性原理。简单来讲,程序的局部性原理指的是在一段时间内程序的执行会限定在一个局部范围内。这里的“局部性”可以从两个方面来理解,一个是时间局部性,另一个是空间局部性。时间局部性指的是程序中的某条指令一旦被执行,不久之后这条指令很可能再次被执行;如果某条数据被访问,不久之后这条数据很可能再次被访问。而空间局部性是指某块内存一旦被访问,不久之后这块内存附近的内存也很可能被访问。
CPU的缓存就利用了程序的局部性原理CPU从内存中加载数据X时会将数据X缓存在高速缓存Cache中实际上CPU缓存X的同时还缓存了X周围的数据因为根据程序具备局部性原理X周围的数据也很有可能被访问。从另外一个角度来看如果程序能够很好地体现出局部性原理也就能更好地利用CPU的缓存从而提升程序的性能。Disruptor在设计RingBuffer的时候就充分考虑了这个问题下面我们就对比着ArrayBlockingQueue来分析一下。
首先是ArrayBlockingQueue。生产者线程向ArrayBlockingQueue增加一个元素每次增加元素E之前都需要创建一个对象E如下图所示ArrayBlockingQueue内部有6个元素这6个元素都是由生产者线程创建的由于创建这些元素的时间基本上是离散的所以这些元素的内存地址大概率也不是连续的。
ArrayBlockingQueue内部结构图
下面我们再看看Disruptor是如何处理的。Disruptor内部的RingBuffer也是用数组实现的但是这个数组中的所有元素在初始化时是一次性全部创建的所以这些元素的内存地址大概率是连续的相关的代码如下所示。
for (int i=0; i<bufferSize; i++){
//entries[]就是RingBuffer内部的数组
//eventFactory就是前面示例代码中传入的LongEvent::new
entries[BUFFER_PAD + i]
= eventFactory.newInstance();
}
Disruptor内部RingBuffer的结构可以简化成下图那问题来了数组中所有元素内存地址连续能提升性能吗为什么呢因为消费者线程在消费的时候是遵循空间局部性原理的消费完第1个元素很快就会消费第2个元素当消费第1个元素E1的时候CPU会把内存中E1后面的数据也加载进Cache如果E1和E2在内存中的地址是连续的那么E2也就会被加载进Cache中然后当消费第2个元素的时候由于E2已经在Cache中了所以就不需要从内存中加载了这样就能大大提升性能
Disruptor内部RingBuffer结构图
除此之外在Disruptor中生产者线程通过publishEvent()发布Event的时候并不是创建一个新的Event而是通过event.set()方法修改Event 也就是说RingBuffer创建的Event是可以循环利用的这样还能避免频繁创建删除Event导致的频繁GC问题
如何避免伪共享
高效利用Cache能够大大提升性能所以要努力构建能够高效利用Cache的内存结构而从另外一个角度看努力避免不能高效利用Cache的内存结构也同样重要
有一种叫做伪共享False sharing)”的内存布局就会使Cache失效那什么是伪共享
伪共享和CPU内部的Cache有关Cache内部是按照缓存行Cache Line管理的缓存行的大小通常是64个字节CPU从内存中加载数据X会同时加载X后面64-size(X)个字节的数据下面的示例代码出自Java SDK的ArrayBlockingQueue其内部维护了4个成员变量分别是队列数组items出队索引takeIndex入队索引putIndex以及队列中的元素总数count
/** 队列数组 */
final Object[] items;
/** 出队索引 */
int takeIndex;
/** 入队索引 */
int putIndex;
/** 队列中元素总数 */
int count;
当CPU从内存中加载takeIndex的时候会同时将putIndex以及count都加载进Cache下图是某个时刻CPU中Cache的状况为了简化缓存行中我们仅列出了takeIndex和putIndex
CPU缓存示意图
假设线程A运行在CPU-1上执行入队操作入队操作会修改putIndex而修改putIndex会导致其所在的所有核上的缓存行均失效此时假设运行在CPU-2上的线程执行出队操作出队操作需要读取takeIndex由于takeIndex所在的缓存行已经失效所以CPU-2必须从内存中重新读取入队操作本不会修改takeIndex但是由于takeIndex和putIndex共享的是一个缓存行就导致出队操作不能很好地利用Cache这其实就是伪共享简单来讲伪共享指的是由于共享缓存行导致缓存无效的场景
ArrayBlockingQueue的入队和出队操作是用锁来保证互斥的所以入队和出队不会同时发生如果允许入队和出队同时发生那就会导致线程A和线程B争用同一个缓存行这样也会导致性能问题所以为了更好地利用缓存我们必须避免伪共享那如何避免呢
CPU缓存失效示意图
方案很简单每个变量独占一个缓存行不共享缓存行就可以了具体技术是缓存行填充比如想让takeIndex独占一个缓存行可以在takeIndex的前后各填充56个字节这样就一定能保证takeIndex独占一个缓存行下面的示例代码出自DisruptorSequence 对象中的value属性就能避免伪共享因为这个属性前后都填充了56个字节Disruptor中很多对象例如RingBufferRingBuffer内部的数组都用到了这种填充技术来避免伪共享
//填充56字节
class LhsPadding{
long p1, p2, p3, p4, p5, p6, p7;
}
class Value extends LhsPadding{
volatile long value;
}
//填充56字节
class RhsPadding extends Value{
long p9, p10, p11, p12, p13, p14, p15;
}
class Sequence extends RhsPadding{
//省略实现
}
Disruptor中的无锁算法
ArrayBlockingQueue是利用管程实现的中规中矩生产消费操作都需要加锁实现起来简单但是性能并不十分理想Disruptor采用的是无锁算法很复杂但是核心无非是生产和消费两个操作Disruptor中最复杂的是入队操作所以我们重点来看看入队操作是如何实现的
对于入队操作最关键的要求是不能覆盖没有消费的元素对于出队操作最关键的要求是不能读取没有写入的元素所以Disruptor中也一定会维护类似出队索引和入队索引这样两个关键变量Disruptor中的RingBuffer维护了入队索引但是并没有维护出队索引这是因为在Disruptor中多个消费者可以同时消费每个消费者都会有一个出队索引所以RingBuffer的出队索引是所有消费者里面最小的那一个
下面是Disruptor生产者入队操作的核心代码看上去很复杂其实逻辑很简单如果没有足够的空余位置就出让CPU使用权然后重新计算反之则用CAS设置入队索引
//生产者获取n个写入位置
do {
//cursor类似于入队索引指的是上次生产到这里
current = cursor.get();
//目标是在生产n个
next = current + n;
//减掉一个循环
long wrapPoint = next - bufferSize;
//获取上一次的最小消费位置
long cachedGatingSequence = gatingSequenceCache.get();
//没有足够的空余位置
if (wrapPoint>cachedGatingSequence || cachedGatingSequence>current){
//重新计算所有消费者里面的最小值位置
long gatingSequence = Util.getMinimumSequence(
gatingSequences, current);
//仍然没有足够的空余位置出让CPU使用权重新执行下一循环
if (wrapPoint > gatingSequence){
LockSupport.parkNanos(1);
continue;
}
//从新设置上一次的最小消费位置
gatingSequenceCache.set(gatingSequence);
} else if (cursor.compareAndSet(current, next)){
//获取写入位置成功,跳出循环
break;
}
} while (true);
总结
Disruptor在优化并发性能方面可谓是做到了极致优化的思路大体是两个方面一个是利用无锁算法避免锁的争用另外一个则是将硬件CPU的性能发挥到极致。尤其是后者在Java领域基本上属于经典之作了。
发挥硬件的能力一般是C这种面向硬件的语言常干的事儿C语言领域经常通过调整内存布局优化内存占用而Java领域则用的很少原因在于Java可以智能地优化内存布局内存布局对Java程序员的透明的。这种智能的优化大部分场景是很友好的但是如果你想通过填充方式避免伪共享就必须绕过这种优化关于这方面Disruptor提供了经典的实现你可以参考。
由于伪共享问题如此重要所以Java也开始重视它了比如Java 8中提供了避免伪共享的注解@sun.misc.Contended通过这个注解就能轻松避免伪共享需要设置JVM参数-XX:-RestrictContended。不过避免伪共享是以牺牲内存为代价的所以具体使用的时候还是需要仔细斟酌。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,229 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
41 案例分析高性能数据库连接池HiKariCP
实际工作中我们总会难免和数据库打交道只要和数据库打交道就免不了使用数据库连接池。业界知名的数据库连接池有不少例如c3p0、DBCP、Tomcat JDBC Connection Pool、Druid等不过最近最火的是HiKariCP。
HiKariCP号称是业界跑得最快的数据库连接池这两年发展得顺风顺水尤其是Springboot 2.0将其作为默认数据库连接池后,江湖一哥的地位已是毋庸置疑了。那它为什么那么快呢?今天咱们就重点聊聊这个话题。
什么是数据库连接池
在详细分析HiKariCP高性能之前我们有必要先简单介绍一下什么是数据库连接池。本质上数据库连接池和线程池一样都属于池化资源作用都是避免重量级资源的频繁创建和销毁对于数据库连接池来说也就是避免数据库连接频繁创建和销毁。如下图所示服务端会在运行期持有一定数量的数据库连接当需要执行SQL时并不是直接创建一个数据库连接而是从连接池中获取一个当SQL执行完也并不是将数据库连接真的关掉而是将其归还到连接池中。
数据库连接池示意图
在实际工作中我们都是使用各种持久化框架来完成数据库的增删改查基本上不会直接和数据库连接池打交道为了能让你更好地理解数据库连接池的工作原理下面的示例代码并没有使用任何框架而是原生地使用HiKariCP。执行数据库操作基本上是一系列规范化的步骤
通过数据源获取一个数据库连接;
创建Statement
执行SQL
通过ResultSet获取SQL执行结果
释放ResultSet
释放Statement
释放数据库连接。
下面的示例代码,通过 ds.getConnection() 获取一个数据库连接时,其实是向数据库连接池申请一个数据库连接,而不是创建一个新的数据库连接。同样,通过 conn.close() 释放一个数据库连接时,也不是直接将连接关闭,而是将连接归还给数据库连接池。
//数据库连接池配置
HikariConfig config = new HikariConfig();
config.setMinimumIdle(1);
config.setMaximumPoolSize(2);
config.setConnectionTestQuery("SELECT 1");
config.setDataSourceClassName("org.h2.jdbcx.JdbcDataSource");
config.addDataSourceProperty("url", "jdbc:h2:mem:test");
// 创建数据源
DataSource ds = new HikariDataSource(config);
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
// 获取数据库连接
conn = ds.getConnection();
// 创建Statement
stmt = conn.createStatement();
// 执行SQL
rs = stmt.executeQuery("select * from abc");
// 获取结果
while (rs.next()) {
int id = rs.getInt(1);
......
}
} catch(Exception e) {
e.printStackTrace();
} finally {
//关闭ResultSet
close(rs);
//关闭Statement
close(stmt);
//关闭Connection
close(conn);
}
//关闭资源
void close(AutoCloseable rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
HiKariCP官方网站解释了其性能之所以如此之高的秘密。微观上HiKariCP程序编译出的字节码执行效率更高站在字节码的角度去优化Java代码HiKariCP的作者对性能的执着可见一斑不过遗憾的是他并没有详细解释都做了哪些优化。而宏观上主要是和两个数据结构有关一个是FastList另一个是ConcurrentBag。下面我们来看看它们是如何提升HiKariCP的性能的。
FastList解决了哪些性能问题
按照规范步骤执行完数据库操作之后需要依次关闭ResultSet、Statement、Connection但是总有粗心的同学只是关闭了Connection而忘了关闭ResultSet和Statement。为了解决这种问题最好的办法是当关闭Connection时能够自动关闭Statement。为了达到这个目标Connection就需要跟踪创建的Statement最简单的办法就是将创建的Statement保存在数组ArrayList里这样当关闭Connection的时候就可以依次将数组中的所有Statement关闭。
HiKariCP觉得用ArrayList还是太慢当通过 conn.createStatement() 创建一个Statement时需要调用ArrayList的add()方法加入到ArrayList中这个是没有问题的但是当通过 stmt.close() 关闭Statement的时候需要调用 ArrayList的remove()方法来将其从ArrayList中删除这里是有优化余地的。
假设一个Connection依次创建6个Statement分别是S1、S2、S3、S4、S5、S6按照正常的编码习惯关闭Statement的顺序一般是逆序的关闭的顺序是S6、S5、S4、S3、S2、S1而ArrayList的remove(Object o)方法是顺序遍历查找,逆序删除而顺序查找,这样的查找效率就太慢了。如何优化呢?很简单,优化成逆序查找就可以了。
逆序删除示意图
HiKariCP中的FastList相对于ArrayList的一个优化点就是将 remove(Object element) 方法的查找顺序变成了逆序查找。除此之外FastList还有另一个优化点是 get(int index) 方法没有对index参数进行越界检查HiKariCP能保证不会越界所以不用每次都进行越界检查。
整体来看FastList的优化点还是很简单的。下面我们再来聊聊HiKariCP中的另外一个数据结构ConcurrentBag看看它又是如何提升性能的。
ConcurrentBag解决了哪些性能问题
如果让我们自己来实现一个数据库连接池最简单的办法就是用两个阻塞队列来实现一个用于保存空闲数据库连接的队列idle另一个用于保存忙碌数据库连接的队列busy获取连接时将空闲的数据库连接从idle队列移动到busy队列而关闭连接时将数据库连接从busy移动到idle。这种方案将并发问题委托给了阻塞队列实现简单但是性能并不是很理想。因为Java SDK中的阻塞队列是用锁实现的而高并发场景下锁的争用对性能影响很大。
//忙碌队列
BlockingQueue<Connection> busy;
//空闲队列
BlockingQueue<Connection> idle;
HiKariCP并没有使用Java SDK中的阻塞队列而是自己实现了一个叫做ConcurrentBag的并发容器。ConcurrentBag的设计最初源自C#它的一个核心设计是使用ThreadLocal避免部分并发问题不过HiKariCP中的ConcurrentBag并没有完全参考C#的实现,下面我们来看看它是如何实现的。
ConcurrentBag中最关键的属性有4个分别是用于存储所有的数据库连接的共享队列sharedList、线程本地存储threadList、等待数据库连接的线程数waiters以及分配数据库连接的工具handoffQueue。其中handoffQueue用的是Java SDK提供的SynchronousQueueSynchronousQueue主要用于线程之间传递数据。
//用于存储所有的数据库连接
CopyOnWriteArrayList<T> sharedList;
//线程本地存储中的数据库连接
ThreadLocal<List<Object>> threadList;
//等待数据库连接的线程数
AtomicInteger waiters;
//分配数据库连接的工具
SynchronousQueue<T> handoffQueue;
当线程池创建了一个数据库连接时通过调用ConcurrentBag的add()方法加入到ConcurrentBag中下面是add()方法的具体实现逻辑很简单就是将这个连接加入到共享队列sharedList中如果此时有线程在等待数据库连接那么就通过handoffQueue将这个连接分配给等待的线程。
//将空闲连接添加到队列
void add(final T bagEntry){
//加入共享队列
sharedList.add(bagEntry);
//如果有等待连接的线程,
//则通过handoffQueue直接分配给等待的线程
while (waiters.get() > 0
&& bagEntry.getState() == STATE_NOT_IN_USE
&& !handoffQueue.offer(bagEntry)) {
yield();
}
}
通过ConcurrentBag提供的borrow()方法可以获取一个空闲的数据库连接borrow()的主要逻辑是:
首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;
如果线程本地存储中无空闲连接,则从共享队列中获取。
如果共享队列中也没有空闲的连接,则请求线程需要等待。
需要注意的是线程本地存储中的连接是可以被其他线程窃取的所以需要用CAS方法防止重复分配。在共享队列中获取空闲连接也采用了CAS方法防止重复分配。
T borrow(long timeout, final TimeUnit timeUnit){
// 先查看线程本地存储是否有空闲连接
final List<Object> list = threadList.get();
for (int i = list.size() - 1; i >= 0; i--) {
final Object entry = list.remove(i);
final T bagEntry = weakThreadLocals
? ((WeakReference<T>) entry).get()
: (T) entry;
//线程本地存储中的连接也可以被窃取,
//所以需要用CAS方法防止重复分配
if (bagEntry != null
&& bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
// 线程本地存储中无空闲连接,则从共享队列中获取
final int waiting = waiters.incrementAndGet();
try {
for (T bagEntry : sharedList) {
//如果共享队列中有空闲连接,则返回
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
//共享队列中没有连接,则需要等待
timeout = timeUnit.toNanos(timeout);
do {
final long start = currentTime();
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
if (bagEntry == null
|| bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
//重新计算等待时间
timeout -= elapsedNanos(start);
} while (timeout > 10_000);
//超时没有获取到连接返回null
return null;
} finally {
waiters.decrementAndGet();
}
}
释放连接需要调用ConcurrentBag提供的requite()方法该方法的逻辑很简单首先将数据库连接状态更改为STATE_NOT_IN_USE之后查看是否存在等待线程如果有则分配给等待线程如果没有则将该数据库连接保存到线程本地存储里。
//释放连接
void requite(final T bagEntry){
//更新连接状态
bagEntry.setState(STATE_NOT_IN_USE);
//如果有等待的线程,则直接分配给线程,无需进入任何队列
for (int i = 0; waiters.get() > 0; i++) {
if (bagEntry.getState() != STATE_NOT_IN_USE
|| handoffQueue.offer(bagEntry)) {
return;
} else if ((i & 0xff) == 0xff) {
parkNanos(MICROSECONDS.toNanos(10));
} else {
yield();
}
}
//如果没有等待的线程,则进入线程本地存储
final List<Object> threadLocalList = threadList.get();
if (threadLocalList.size() < 50) {
threadLocalList.add(weakThreadLocals
? new WeakReference<>(bagEntry)
: bagEntry);
}
}
总结
HiKariCP中的FastList和ConcurrentBag这两个数据结构使用得非常巧妙虽然实现起来并不复杂但是对于性能的提升非常明显根本原因在于这两个数据结构适用于数据库连接池这个特定的场景。FastList适用于逆序删除场景而ConcurrentBag通过ThreadLocal做一次预分配避免直接竞争共享资源非常适合池化资源的分配。
在实际工作中,我们遇到的并发问题千差万别,这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解,只有了解到无谓的性能消耗在哪里,才能对症下药。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
42 Actor模型面向对象原生的并发模型
上学的时候有门计算机专业课叫做面向对象编程学这门课的时候有个问题困扰了我很久按照面向对象编程的理论对象之间通信需要依靠消息而实际上像C++、Java这些面向对象的语言对象之间通信依靠的是对象方法。对象方法和过程语言里的函数本质上没有区别有入参、有出参思维方式很相似使用起来都很简单。那面向对象理论里的消息是否就等价于面向对象语言里的对象方法呢很长一段时间里我都以为对象方法是面向对象理论中消息的一种实现直到接触到Actor模型才明白消息压根不是这个实现法。
Hello Actor模型
Actor模型本质上是一种计算模型基本的计算单元称为Actor换言之在Actor模型中所有的计算都是在Actor中执行的。在面向对象编程里面一切都是对象在Actor模型里一切都是Actor并且Actor之间是完全隔离的不会共享任何变量。
当看到“不共享任何变量”的时候相信你一定会眼前一亮并发问题的根源就在于共享变量而Actor模型中Actor之间不共享变量那用Actor模型解决并发问题一定是相当顺手。的确是这样所以很多人就把Actor模型定义为一种并发计算模型。其实Actor模型早在1973年就被提出来了只是直到最近几年才被广泛关注一个主要原因就在于它是解决并发问题的利器而最近几年随着多核处理器的发展并发问题被推到了风口浪尖上。
但是Java语言本身并不支持Actor模型所以如果你想在Java语言里使用Actor模型就需要借助第三方类库目前能完备地支持Actor模型而且比较成熟的类库就是Akka了。在详细介绍Actor模型之前我们就先基于Akka写一个Hello World程序让你对Actor模型先有个感官的印象。
在下面的示例代码中我们首先创建了一个ActorSystemActor不能脱离ActorSystem存在之后创建了一个HelloActorAkka中创建Actor并不是new一个对象出来而是通过调用system.actorOf()方法创建的该方法返回的是ActorRef而不是HelloActor最后通过调用ActorRef的tell()方法给HelloActor发送了一条消息 “Actor” 。
//该Actor当收到消息message后
//会打印Hello message
static class HelloActor
extends UntypedActor {
@Override
public void onReceive(Object message) {
System.out.println("Hello " + message);
}
}
public static void main(String[] args) {
//创建Actor系统
ActorSystem system = ActorSystem.create("HelloSystem");
//创建HelloActor
ActorRef helloActor =
system.actorOf(Props.create(HelloActor.class));
//发送消息给HelloActor
helloActor.tell("Actor", ActorRef.noSender());
}
通过这个例子你会发现Actor模型和面向对象编程契合度非常高完全可以用Actor类比面向对象编程里面的对象而且Actor之间的通信方式完美地遵守了消息机制而不是通过对象方法来实现对象之间的通信。那Actor中的消息机制和面向对象语言里的对象方法有什么区别呢
消息和对象方法的区别
在没有计算机的时代,异地的朋友往往是通过写信来交流感情的,但信件发出去之后,也许会在寄送过程中弄丢了,也有可能寄到后,对方一直没有时间写回信……这个时候都可以让邮局“背个锅”,不过无论如何,也不过是重写一封,生活继续。
Actor中的消息机制就可以类比这现实世界里的写信。Actor内部有一个邮箱Mailbox接收到的消息都是先放到邮箱里如果邮箱里有积压的消息那么新收到的消息就不会马上得到处理也正是因为Actor使用单线程处理消息所以不会出现并发问题。你可以把Actor内部的工作模式想象成只有一个消费者线程的生产者-消费者模式。
所以在Actor模型里发送消息仅仅是把消息发出去而已接收消息的Actor在接收到消息后也不一定会立即处理也就是说Actor中的消息机制完全是异步的。而调用对象方法实际上是同步的对象方法return之前调用方会一直等待。
除此之外调用对象方法需要持有对象的引用所有的对象必须在同一个进程中。而在Actor中发送消息类似于现实中的写信只需要知道对方的地址就可以发送消息和接收消息的Actor可以不在一个进程中也可以不在同一台机器上。因此Actor模型不但适用于并发计算还适用于分布式计算。
Actor的规范化定义
通过上面的介绍相信你应该已经对Actor有一个感官印象了下面我们再来看看Actor规范化的定义是什么样的。Actor是一种基础的计算单元具体来讲包括三部分能力分别是
处理能力,处理接收到的消息。
存储能力Actor可以存储自己的内部状态并且内部状态在不同Actor之间是绝对隔离的。
通信能力Actor可以和其他Actor之间通信。
当一个Actor接收的一条消息之后这个Actor可以做以下三件事
创建更多的Actor
发消息给其他Actor
确定如何处理下一条消息。
其中前两条还是很好理解的就是最后一条该如何去理解呢前面我们说过Actor具备存储能力它有自己的内部状态所以你也可以把Actor看作一个状态机把Actor处理消息看作是触发状态机的状态变化而状态机的变化往往要基于上一个状态触发状态机发生变化的时刻上一个状态必须是确定的所以确定如何处理下一条消息本质上不过是改变内部状态。
在多线程里面由于可能存在竞态条件所以根据当前状态确定如何处理下一条消息还是有难度的需要使用各种同步工具但在Actor模型里由于是单线程处理所以就不存在竞态条件问题了。
用Actor实现累加器
支持并发的累加器可能是最简单并且有代表性的并发问题了可以基于互斥锁方案实现也可以基于原子类实现但今天我们要尝试用Actor来实现。
在下面的示例代码中CounterActor内部持有累计值counter当CounterActor接收到一个数值型的消息message时就将累计值counter += message但如果是其他类型的消息则打印当前累计值counter。在main()方法中我们启动了4个线程来执行累加操作。整个程序没有锁也没有CAS但是程序是线程安全的。
//累加器
static class CounterActor extends UntypedActor {
private int counter = 0;
@Override
public void onReceive(Object message){
//如果接收到的消息是数字类型,执行累加操作,
//否则打印counter的值
if (message instanceof Number) {
counter += ((Number) message).intValue();
} else {
System.out.println(counter);
}
}
}
public static void main(String[] args) throws InterruptedException {
//创建Actor系统
ActorSystem system = ActorSystem.create("HelloSystem");
//4个线程生产消息
ExecutorService es = Executors.newFixedThreadPool(4);
//创建CounterActor
ActorRef counterActor =
system.actorOf(Props.create(CounterActor.class));
//生产4*100000个消息
for (int i=0; i<4; i++) {
es.execute(()->{
for (int j=0; j<100000; j++) {
counterActor.tell(1, ActorRef.noSender());
}
});
}
//关闭线程池
es.shutdown();
//等待CounterActor处理完所有消息
Thread.sleep(1000);
//打印结果
counterActor.tell("", ActorRef.noSender());
//关闭Actor系统
system.shutdown();
}
总结
Actor模型是一种非常简单的计算模型其中Actor是最基本的计算单元Actor之间是通过消息进行通信Actor与面向对象编程OOP中的对象匹配度非常高在面向对象编程里系统由类似于生物细胞那样的对象构成对象之间也是通过消息进行通信所以在面向对象语言里使用Actor模型基本上不会有违和感
在Java领域除了可以使用Akka来支持Actor模型外还可以使用Vert.x不过相对来说Vert.x更像是Actor模型的隐式实现对应关系不像Akka那样明显不过本质上也是一种Actor模型
Actor可以创建新的Actor这些Actor最终会呈现出一个树状结构非常像现实世界里的组织结构所以利用Actor模型来对程序进行建模和现实世界的匹配度非常高Actor模型和现实世界一样都是异步模型理论上不保证消息百分百送达也不保证消息送达的顺序和发送的顺序是一致的甚至无法保证消息会被百分百处理虽然实现Actor模型的厂商都在试图解决这些问题但遗憾的是解决得并不完美所以使用Actor模型也是有成本的
欢迎在留言区与我分享你的想法也欢迎你在留言区记录你的思考过程感谢阅读如果你觉得这篇文章对你有帮助的话也欢迎把它分享给更多的朋友

View File

@ -0,0 +1,247 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
43 软件事务内存:借鉴数据库的并发经验
很多同学反馈说工作了挺长时间但是没有机会接触并发编程实际上我们天天都在写并发程序只不过并发相关的问题都被类似Tomcat这样的Web服务器以及MySQL这样的数据库解决了。尤其是数据库在解决并发问题方面可谓成绩斐然它的事务机制非常简单易用能甩Java里面的锁、原子类十条街。技术无边界很显然要借鉴一下。
其实很多编程语言都有从数据库的事务管理中获得灵感并且总结出了一个新的并发解决方案软件事务内存Software Transactional Memory简称STM。传统的数据库事务支持4个特性原子性Atomicity、一致性Consistency、隔离性Isolation和持久性Durability也就是大家常说的ACIDSTM由于不涉及到持久化所以只支持ACI。
STM的使用很简单下面我们以经典的转账操作为例看看用STM该如何实现。
用STM实现转账
我们曾经在《05 | 一不小心就死锁了,怎么办?》这篇文章中,讲到了并发转账的例子,示例代码如下。简单地使用 synchronized 将 transfer() 方法变成同步方法并不能解决并发问题,因为还存在死锁问题。
class UnsafeAccount {
//余额
private long balance;
//构造函数
public UnsafeAccount(long balance) {
this.balance = balance;
}
//转账
void transfer(UnsafeAccount target, long amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
该转账操作若使用数据库事务就会非常简单如下面的示例代码所示。如果所有SQL都正常执行则通过 commit() 方法提交事务如果SQL在执行过程中有异常则通过 rollback() 方法回滚事务。数据库保证在并发情况下不会有死锁而且还能保证前面我们说的原子性、一致性、隔离性和持久性也就是ACID。
Connection conn = null;
try{
//获取数据库连接
conn = DriverManager.getConnection();
//设置手动提交事务
conn.setAutoCommit(false);
//执行转账SQL
......
//提交事务
conn.commit();
} catch (Exception e) {
//出现异常回滚事务
conn.rollback();
}
那如果用STM又该如何实现呢Java语言并不支持STM不过可以借助第三方的类库来支持Multiverse就是个不错的选择。下面的示例代码就是借助Multiverse实现了线程安全的转账操作相比较上面线程不安全的UnsafeAccount其改动并不大仅仅是将余额的类型从 long 变成了 TxnLong ,将转账的操作放到了 atomic(()->{}) 中。
class Account{
//余额
private TxnLong balance;
//构造函数
public Account(long balance){
this.balance = StmUtils.newTxnLong(balance);
}
//转账
public void transfer(Account to, int amt){
//原子化操作
atomic(()->{
if (this.balance.get() > amt) {
this.balance.decrement(amt);
to.balance.increment(amt);
}
});
}
}
一个关键的atomic()方法就把并发问题解决了这个方案看上去比传统的方案的确简单了很多那它是如何实现的呢数据库事务发展了几十年了目前被广泛使用的是MVCC全称是Multi-Version Concurrency Control也就是多版本并发控制。
MVCC可以简单地理解为数据库事务在开启的时候会给数据库打一个快照以后所有的读写都是基于这个快照的。当提交事务的时候如果所有读写过的数据在该事务执行期间没有发生过变化那么就可以提交如果发生了变化说明该事务和有其他事务读写的数据冲突了这个时候是不可以提交的。
为了记录数据是否发生了变化可以给每条数据增加一个版本号这样每次成功修改数据都会增加版本号的值。MVCC的工作原理和我们曾经在《18 | StampedLock有没有比读写锁更快的锁》中提到的乐观锁非常相似。有不少STM的实现方案都是基于MVCC的例如知名的Clojure STM。
下面我们就用最简单的代码基于MVCC实现一个简版的STM这样你会对STM以及MVCC的工作原理有更深入的认识。
自己实现STM
我们首先要做的就是让Java中的对象有版本号在下面的示例代码中VersionedRef这个类的作用就是将对象value包装成带版本号的对象。按照MVCC理论数据的每一次修改都对应着一个唯一的版本号所以不存在仅仅改变value或者version的情况用不变性模式就可以很好地解决这个问题所以VersionedRef这个类被我们设计成了不可变的。
所有对数据的读写操作一定是在一个事务里面TxnRef这个类负责完成事务内的读写操作读写操作委托给了接口TxnTxn代表的是读写操作所在的当前事务 内部持有的curRef代表的是系统中的最新值。
//带版本号的对象引用
public final class VersionedRef<T> {
final T value;
final long version;
//构造方法
public VersionedRef(T value, long version) {
this.value = value;
this.version = version;
}
}
//支持事务的引用
public class TxnRef<T> {
//当前数据,带版本号
volatile VersionedRef curRef;
//构造方法
public TxnRef(T value) {
this.curRef = new VersionedRef(value, 0L);
}
//获取当前事务中的数据
public T getValue(Txn txn) {
return txn.get(this);
}
//在当前事务中设置数据
public void setValue(T value, Txn txn) {
txn.set(this, value);
}
}
STMTxn是Txn最关键的一个实现类事务内对于数据的读写都是通过它来完成的。STMTxn内部有两个MapinTxnMap用于保存当前事务中所有读写的数据的快照writeMap用于保存当前事务需要写入的数据。每个事务都有一个唯一的事务ID txnId这个txnId是全局递增的。
STMTxn有三个核心方法分别是读数据的get()方法、写数据的set()方法和提交事务的commit()方法。其中get()方法将要读取数据作为快照放入inTxnMap同时保证每次读取的数据都是一个版本。set()方法会将要写入的数据放入writeMap但如果写入的数据没被读取过也会将其放入 inTxnMap。
至于commit()方法我们为了简化实现使用了互斥锁所以事务的提交是串行的。commit()方法的实现很简单首先检查inTxnMap中的数据是否发生过变化如果没有发生变化那么就将writeMap中的数据写入这里的写入其实就是TxnRef内部持有的curRef如果发生过变化那么就不能将writeMap中的数据写入了。
//事务接口
public interface Txn {
<T> T get(TxnRef<T> ref);
<T> void set(TxnRef<T> ref, T value);
}
//STM事务实现类
public final class STMTxn implements Txn {
//事务ID生成器
private static AtomicLong txnSeq = new AtomicLong(0);
//当前事务所有的相关数据
private Map<TxnRef, VersionedRef> inTxnMap = new HashMap<>();
//当前事务所有需要修改的数据
private Map<TxnRef, Object> writeMap = new HashMap<>();
//当前事务ID
private long txnId;
//构造函数自动生成当前事务ID
STMTxn() {
txnId = txnSeq.incrementAndGet();
}
//获取当前事务中的数据
@Override
public <T> T get(TxnRef<T> ref) {
//将需要读取的数据加入inTxnMap
if (!inTxnMap.containsKey(ref)) {
inTxnMap.put(ref, ref.curRef);
}
return (T) inTxnMap.get(ref).value;
}
//在当前事务中修改数据
@Override
public <T> void set(TxnRef<T> ref, T value) {
//将需要修改的数据加入inTxnMap
if (!inTxnMap.containsKey(ref)) {
inTxnMap.put(ref, ref.curRef);
}
writeMap.put(ref, value);
}
//提交事务
boolean commit() {
synchronized (STM.commitLock) {
//是否校验通过
boolean isValid = true;
//校验所有读过的数据是否发生过变化
for(Map.Entry<TxnRef, VersionedRef> entry : inTxnMap.entrySet()){
VersionedRef curRef = entry.getKey().curRef;
VersionedRef readRef = entry.getValue();
//通过版本号来验证数据是否发生过变化
if (curRef.version != readRef.version) {
isValid = false;
break;
}
}
//如果校验通过,则所有更改生效
if (isValid) {
writeMap.forEach((k, v) -> {
k.curRef = new VersionedRef(v, txnId);
});
}
return isValid;
}
}
下面我们来模拟实现Multiverse中的原子化操作atomic()。atomic()方法中使用了类似于CAS的操作如果事务提交失败那么就重新创建一个新的事务重新执行。
@FunctionalInterface
public interface TxnRunnable {
void run(Txn txn);
}
//STM
public final class STM {
//私有化构造方法
private STM() {
//提交数据需要用到的全局锁
static final Object commitLock = new Object();
//原子化提交方法
public static void atomic(TxnRunnable action) {
boolean committed = false;
//如果没有提交成功,则一直重试
while (!committed) {
//创建新的事务
STMTxn txn = new STMTxn();
//执行业务逻辑
action.run(txn);
//提交事务
committed = txn.commit();
}
}
}}
就这样我们自己实现了STM并完成了线程安全的转账操作使用方法和Multiverse差不多这里就不赘述了具体代码如下面所示。
class Account {
//余额
private TxnRef<Integer> balance;
//构造方法
public Account(int balance) {
this.balance = new TxnRef<Integer>(balance);
}
//转账操作
public void transfer(Account target, int amt){
STM.atomic((txn)->{
Integer from = balance.getValue(txn);
balance.setValue(from-amt, txn);
Integer to = target.balance.getValue(txn);
target.balance.setValue(to+amt, txn);
});
}
}
总结
STM借鉴的是数据库的经验数据库虽然复杂但仅仅存储数据而编程语言除了有共享变量之外还会执行各种I/O操作很显然I/O操作是很难支持回滚的。所以STM也不是万能的。目前支持STM的编程语言主要是函数式语言函数式语言里的数据天生具备不可变性利用这种不可变性实现STM相对来说更简单。
另外需要说明的是文中的“自己实现STM”部分我参考了Software Transactional Memory in Scala这篇博文以及一个GitHub项目目前还很粗糙并不是一个完备的MVCC。如果你对这方面感兴趣可以参考Improving the STM: Multi-Version Concurrency Control 这篇博文,里面讲到了如何优化,你可以尝试学习下。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,152 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
44 协程:更轻量级的线程
Java语言里解决并发问题靠的是多线程但线程是个重量级的对象不能频繁创建、销毁而且线程切换的成本也很高为了解决这些问题Java SDK提供了线程池。然而用好线程池并不容易Java围绕线程池提供了很多工具类这些工具类学起来也不容易。那有没有更好的解决方案呢Java语言里目前还没有但是其他语言里有这个方案就是协程Coroutine
我们可以把协程简单地理解为一种轻量级的线程。从操作系统的角度来看线程是在内核态中调度的而协程是在用户态调度的所以相对于线程来说协程切换的成本更低。协程虽然也有自己的栈但是相比线程栈要小得多典型的线程栈大小差不多有1M而协程栈的大小往往只有几K或者几十K。所以无论是从时间维度还是空间维度来看协程都比线程轻量得多。
支持协程的语言还是挺多的例如Golang、Python、Lua、Kotlin等都支持协程。下面我们就以Golang为代表看看协程是如何在Golang中使用的。
Golang中的协程
在Golang中创建协程非常简单在下面的示例代码中要让hello()方法在一个新的协程中执行只需要go hello("World") 这一行代码就搞定了。你可以对比着想想在Java里是如何“辛勤”地创建线程和线程池的吧我的感觉一直都是每次写完Golang的代码就再也不想写Java代码了。
import (
"fmt"
"time"
)
func hello(msg string) {
fmt.Println("Hello " + msg)
}
func main() {
//在新的协程中执行hello方法
go hello("World")
fmt.Println("Run in main")
//等待100毫秒让协程执行结束
time.Sleep(100 * time.Millisecond)
}
我们在《33 | Thread-Per-Message模式最简单实用的分工方法》中介绍过利用协程能够很好地实现Thread-Per-Message模式。Thread-Per-Message模式非常简单其实越是简单的模式功能上就越稳定可理解性也越好。
下面的示例代码是用Golang实现的echo程序的服务端用的是Thread-Per-Message模式为每个成功建立连接的socket分配一个协程相比Java线程池的实现方案Golang中协程的方案更简单。
import (
"log"
"net"
)
func main() {
//监听本地9090端口
socket, err := net.Listen("tcp", "127.0.0.1:9090")
if err != nil {
log.Panicln(err)
}
defer socket.Close()
for {
//处理连接请求
conn, err := socket.Accept()
if err != nil {
log.Panicln(err)
}
//处理已经成功建立连接的请求
go handleRequest(conn)
}
}
//处理已经成功建立连接的请求
func handleRequest(conn net.Conn) {
defer conn.Close()
for {
buf := make([]byte, 1024)
//读取请求数据
size, err := conn.Read(buf)
if err != nil {
return
}
//回写相应数据
conn.Write(buf[:size])
}
}
利用协程实现同步
其实协程并不仅限于实现Thread-Per-Message模式它还可以将异步模式转换为同步模式。异步编程虽然近几年取得了长足发展但是异步的思维模式对于普通人来讲毕竟是有难度的只有线性的思维模式才是适合所有人的。而线性的思维模式反映到编程世界就是同步。
在Java里使用多线程并发地处理I/O基本上用的都是异步非阻塞模型这种模型的异步主要是靠注册回调函数实现的那能否都使用同步处理呢显然是不能的。因为同步意味着等待而线程等待本质上就是一种严重的浪费。不过对于协程来说等待的成本就没有那么高了所以基于协程实现同步非阻塞是一个可行的方案。
OpenResty里实现的cosocket就是一种同步非阻塞方案借助cosocket我们可以用线性的思维模式来编写非阻塞的程序。下面的示例代码是用cosocket实现的socket程序的客户端建立连接、发送请求、读取响应所有的操作都是同步的由于cosocket本身是非阻塞的所以这些操作虽然是同步的但是并不会阻塞。
-- 创建socket
local sock = ngx.socket.tcp()
-- 设置socket超时时间
sock:settimeouts(connect_timeout, send_timeout, read_timeout)
-- 连接到目标地址
local ok, err = sock:connect(host, port)
if not ok then
- -- 省略异常处理
end
-- 发送请求
local bytes, err = sock:send(request_data)
if not bytes then
-- 省略异常处理
end
-- 读取响应
local line, err = sock:receive()
if err then
-- 省略异常处理
end
-- 关闭socket
sock:close()
-- 处理读取到的数据line
handle(line)
结构化并发编程
Golang中的 go 语句让协程用起来太简单了,但是这种简单也蕴藏着风险。要深入了解这个风险是什么,就需要先了解一下 goto 语句的前世今生。
在我上学的时候,各种各样的编程语言书籍中都会谈到不建议使用 goto 语句,原因是 goto 语句会让程序变得混乱,当时对于这个问题我也没有多想,不建议用那就不用了。那为什么 goto 语句会让程序变得混乱呢混乱具体指的又是什么呢多年之后我才了解到所谓的混乱指的是代码的书写顺序和执行顺序不一致。代码的书写顺序代表的是我们的思维过程如果思维的过程与代码执行的顺序不一致那就会干扰我们对代码的理解。我们的思维是线性的傻傻地一条道儿跑到黑而goto语句太灵活随时可以穿越时空实在是太“混乱”了。
首先发现 goto 语句是“毒药”的人是著名的计算机科学家艾兹格·迪科斯彻Edsger Dijkstra同时他还提出了结构化程序设计。在结构化程序设计中可以使用三种基本控制结构来代替goto这三种基本的控制结构就是今天我们广泛使用的顺序结构、选择结构和循环结构。
顺序结构
选择结构
循环结构while
循环结构do while
这三种基本的控制结构奠定了今天高级语言的基础,如果仔细观察这三种结构,你会发现它们的入口和出口只有一个,这意味它们是可组合的,而且组合起来一定是线性的,整体来看,代码的书写顺序和执行顺序也是一致的。
我们以前写的并发程序是否违背了结构化程序设计呢这个问题以前并没有被关注但是最近两年随着并发编程的快速发展已经开始有人关注了而且剑指Golang中的 go 语句,指其为“毒药”,类比的是 goto 语句。详情可以参考相关的文章。
Golang中的 go 语句不过是快速创建协程的方法而已这篇文章本质上并不仅仅在批判Golang中的 go 语句而是在批判开启新的线程或者协程异步执行这种粗糙的做法违背了结构化程序设计Java语言其实也在其列。
当开启一个新的线程时,程序会并行地出现两个分支,主线程一个分支,子线程一个分支,这两个分支很多情况下都是天各一方、永不相见。而结构化的程序,可以有分支,但是最终一定要汇聚,不能有多个出口,因为只有这样它们组合起来才是线性的。
总结
最近几年支持协程的开发语言越来越多了Java OpenSDK中Loom项目的目标就是支持协程相信不久的将来Java程序员也可以使用协程来解决并发问题了。
计算机里很多面向开发人员的技术,大多数都是在解决一个问题:易用性。协程作为一项并发编程技术,本质上也不过是解决并发工具的易用性问题而已。对于易用性,我觉得最重要的就是要适应我们的思维模式,在工作的前几年,我并没有怎么关注它,但是最近几年思维模式已成为我重点关注的对象。因为思维模式对工作的很多方面都会产生影响,例如质量。
一个软件产品是否能够活下去,从质量的角度看,最核心的就是代码写得好。那什么样的代码是好代码呢?我觉得,最根本的是可读性好。可读性好的代码,意味着大家都可以上手,而且上手后不会大动干戈。那如何让代码的可读性好呢?很简单,换位思考,用大众、普通的思维模式去写代码,而不是炫耀自己的各种设计能力。我觉得好的代码,就像人民的艺术一样,应该是为人民群众服务的,只有根植于广大群众之中,才有生命力。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
45 CSP模型Golang的主力队员
Golang是一门号称从语言层面支持并发的编程语言支持并发是Golang一个非常重要的特性。在上一篇文章《44 | 协程更轻量级的线程》中我们介绍过Golang支持协程协程可以类比Java中的线程解决并发问题的难点就在于线程协程之间的协作。
那Golang是如何解决协作问题的呢
总的来说Golang提供了两种不同的方案一种方案支持协程之间以共享内存的方式通信Golang提供了管程和原子类来对协程进行同步控制这个方案与Java语言类似另一种方案支持协程之间以消息传递Message-Passing的方式通信本质上是要避免共享Golang的这个方案是基于CSPCommunicating Sequential Processes模型实现的。Golang比较推荐的方案是后者。
什么是CSP模型
我们在《42 | Actor模型面向对象原生的并发模型》中介绍了Actor模型Actor模型中Actor之间就是不能共享内存的彼此之间通信只能依靠消息传递的方式。Golang实现的CSP模型和Actor模型看上去非常相似Golang程序员中有句格言“不要以共享内存方式通信要以通信方式共享内存Dont communicate by sharing memory, share memory by communicating。”虽然Golang中协程之间也能够以共享内存的方式通信但是并不推荐而推荐的以通信的方式共享内存实际上指的就是协程之间以消息传递方式来通信。
下面我们先结合一个简单的示例看看Golang中协程之间是如何以消息传递的方式实现通信的。我们示例的目标是打印从1累加到100亿的结果如果使用单个协程来计算大概需要4秒多的时间。单个协程只能用到CPU中的一个核为了提高计算性能我们可以用多个协程来并行计算这样就能发挥多核的优势了。
在下面的示例代码中我们用了4个子协程来并行执行这4个子协程分别计算[1, 25亿]、(25亿, 50亿]、(50亿, 75亿]、(75亿, 100亿]最后再在主协程中汇总4个子协程的计算结果。主协程要汇总4个子协程的计算结果势必要和4个子协程之间通信Golang中协程之间通信推荐的是使用channelchannel你可以形象地理解为现实世界里的管道。另外calc()方法的返回值是一个只能接收数据的channel ch它创建的子协程会把计算结果发送到这个ch中而主协程也会将这个计算结果通过ch读取出来。
import (
"fmt"
"time"
)
func main() {
// 变量声明
var result, i uint64
// 单个协程执行累加操作
start := time.Now()
for i = 1; i <= 10000000000; i++ {
result += i
}
// 统计计算耗时
elapsed := time.Since(start)
fmt.Printf("执行消耗的时间为:", elapsed)
fmt.Println(", result:", result)
// 4个协程共同执行累加操作
start = time.Now()
ch1 := calc(1, 2500000000)
ch2 := calc(2500000001, 5000000000)
ch3 := calc(5000000001, 7500000000)
ch4 := calc(7500000001, 10000000000)
// 汇总4个协程的累加结果
result = <-ch1 + <-ch2 + <-ch3 + <-ch4
// 统计计算耗时
elapsed = time.Since(start)
fmt.Printf("执行消耗的时间为:", elapsed)
fmt.Println(", result:", result)
}
// 在协程中异步执行累加操作累加结果通过channel传递
func calc(from uint64, to uint64) <-chan uint64 {
// channel用于协程间的通信
ch := make(chan uint64)
// 在协程中执行累加操作
go func() {
result := from
for i := from + 1; i <= to; i++ {
result += i
}
// 将结果写入channel
ch <- result
}()
// 返回结果是用于通信的channel
return ch
}
CSP模型与生产者-消费者模式
你可以简单地把Golang实现的CSP模型类比为生产者-消费者模式而channel可以类比为生产者-消费者模式中的阻塞队列不过需要注意的是Golang中channel的容量可以是0容量为0的channel在Golang中被称为无缓冲的channel容量大于0的则被称为有缓冲的channel
无缓冲的channel类似于Java中提供的SynchronousQueue主要用途是在两个协程之间做数据交换比如上面累加器的示例代码中calc()方法内部创建的channel就是无缓冲的channel
而创建一个有缓冲的channel也很简单在下面的示例代码中我们创建了一个容量为4的channel同时创建了4个协程作为生产者4个协程作为消费者
// 创建一个容量为4的channel
ch := make(chan int, 4)
// 创建4个协程作为生产者
for i := 0; i < 4; i++ {
go func() {
ch <- 7
}()
}
// 创建4个协程作为消费者
for i := 0; i < 4; i++ {
go func() {
o := <-ch
fmt.Println("received:", o)
}()
}
Golang中的channel是语言层面支持的所以可以使用一个左向箭头<-来完成向channel发送数据和读取数据的任务使用上还是比较简单的Golang中的channel是支持双向传输的所谓双向传输指的是一个协程既可以通过它发送数据也可以通过它接收数据
不仅如此Golang中还可以将一个双向的channel变成一个单向的channel在累加器的例子中calc()方法中创建了一个双向channel但是返回的就是一个只能接收数据的单向channel所以主协程中只能通过它接收数据而不能通过它发送数据如果试图通过它发送数据编译器会提示错误对比之下双向变单向的功能如果以SDK方式实现还是很困难的
CSP模型与Actor模型的区别
同样是以消息传递的方式来避免共享那Golang实现的CSP模型和Actor模型有什么区别呢
第一个最明显的区别就是Actor模型中没有channel虽然Actor模型中的 mailbox channel 非常像看上去都像个FIFO队列但是区别还是很大的Actor模型中的mailbox对于程序员来说是透明mailbox明确归属于一个特定的Actor是Actor模型中的内部机制而且Actor之间是可以直接通信的不需要通信中介但CSP模型中的 channel 就不一样了它对于程序员来说是可见是通信的中介传递的消息都是直接发送到 channel 中的
第二个区别是Actor模型中发送消息是非阻塞的而CSP模型中是阻塞的Golang实现的CSP模型channel是一个阻塞队列当阻塞队列已满的时候向channel中发送数据会导致发送消息的协程阻塞
第三个区别则是关于消息送达的42 | Actor模型面向对象原生的并发模型这篇文章中我们介绍过Actor模型理论上不保证消息百分百送达而在Golang实现的CSP模型中是能保证消息百分百送达的不过这种百分百送达也是有代价的那就是有可能会导致死锁
比如下面这段代码就存在死锁问题在主协程中我们创建了一个无缓冲的channel ch然后从ch中接收数据此时主协程阻塞main()方法中的主协程阻塞整个应用就阻塞了这就是Golang中最简单的一种死锁
func main() {
// 创建一个无缓冲的channel
ch := make(chan int)
// 主协程会阻塞在此处发生死锁
<- ch
}
总结
Golang中虽然也支持传统的共享内存的协程间通信方式但是推荐的还是使用CSP模型以通信的方式共享内存
Golang中实现的CSP模型功能上还是很丰富的例如支持select语句select语句类似于网络编程里的多路复用函数select()只要有一个channel能够发送成功或者接收到数据就可以跳出阻塞状态鉴于篇幅原因我就点到这里不详细介绍那么多了
CSP模型是托尼·霍尔Tony Hoare在1978年提出的不过这个模型这些年一直都在发展其理论远比Golang的实现复杂得多如果你感兴趣可以参考霍尔写的Communicating Sequential Processes这本电子书另外霍尔在并发领域还有一项重要成就那就是提出了霍尔管程模型这个你应该很熟悉了Java领域解决并发问题的理论基础就是它
Java领域可以借助第三方的类库JCSP来支持CSP模型相比Golang的实现JCSP更接近理论模型如果你感兴趣可以下载学习不过需要注意的是JCSP并没有经过广泛的生产环境检验所以并不建议你在生产环境中使用
欢迎在留言区与我分享你的想法也欢迎你在留言区记录你的思考过程感谢阅读如果你觉得这篇文章对你有帮助的话也欢迎把它分享给更多的朋友

View File

@ -0,0 +1,125 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户来信 真好,面试考到这些并发编程,我都答对了!
你好我是Zed是《Java并发编程实战》1W+订阅者中的一员。
我从事Java开发已有五年时间了曾在一家国内知名物流企业工作现在杭州一家金融支付类公司继续担任Java工程师一职。
大概在今年四月份在高铁上翻到一篇文章讲的是“为什么Object.wait()方法一定要在synchronized内部使用”因为之前我根本不知道这个问题所以打算考考我朋友。
结果他给了我一些迥然不同的答案并邀请我读了宝令老师的《Java并发编程实战》专栏中的一篇文章《08 | 管程:并发编程的万能钥匙》,看完后我感觉醍醐灌顶,津津有味,果断开始学习。
我是如何通过专栏拿到 Offer 的?
机缘巧合,专栏学习到一半时 ,我辞掉了原有的工作出去面试。因为面试的岗位都是高级工程师,所以基本上离不开并发编程的问题,像锁、线程安全、线程池、并发工具类都是家常便饭。
印象比较深刻的是面试官问我:线程池的大小如何确定?
那时我刚看完《10 | Java线程创建多少线程才是合适的然后就胸有成竹且不紧不慢地回答了面试官听了直点头。
另外一个问题是:怎么理解活锁?
于是我又如法炮制搬出了《07 | 安全性、活跃性以及性能问题》中老师提到“路人甲乙相撞”的例子,同时给出具体的解决方案。
不得不说,这个例子太经典了,这里我必须再展示给大家看!在文章里老师是这样描述活锁的:
所谓的“活锁”,可以类比现实世界里的例子。路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
并且给出了简单有效的解决方案:
解决“活锁”的方案很简单:谦让时,尝试等待一个随机的时间就可以了。
例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。
“等待一个随机时间”的方案虽然很简单却非常有效Raft这样知名的分布式一致性算法中也用到了它。
真的很庆幸提前遇到了并发专栏,我的面试顺利通过了。
我可以很负责任地说,如果没有专栏的学习,我不会那么顺利地找到工作。换句话说,专栏其实涵盖了几乎所有大家面试可能会被问到的内容。
我是如何进行高效学习的呢?
第一,直接上手跟着敲一遍代码。
我觉得最能表示你在用心学习的方式就是付诸实际行动了,就拿专栏第一模块“并发理论基础”来讲,会涉及到很多的例子,比如:
可见性、原子性等问题的举例说明直接看《01 | 可见性、原子性和有序性问题并发编程Bug的源头》
用“银行转账模拟”的例子引出死锁问题并如何处理《05 | 一不小心就死锁了,怎么办?》;
如何保证线程安全的同时保证性能《06 | 用“等待-通知”机制优化循环等待》。
……
针对这些例子,我能做的就是自己花时间,手动敲一遍代码。要知道,纸上得来终觉浅,绝知此事要躬行!
第二,换位理解。
这也是我觉得最为有效的学习方式之一,站在老师角度,去思考他是如何看这个问题的,他是如何一步步讲解清楚的。而且,宝令老师在每篇文章后面,都会有一段总结,非常有效地来帮助我去获得这篇文章的知识点。提出问题—解决问题—总结得出结论,这关键的三步,在任何地方都适用。
所以我还会花时间思考专栏的“这里”或“那里”跟我之前的理解是否有出入例如《11 | Java线程为什么局部变量是线程安全的》中对局部变量线程安全的解释跟自己了解的虚拟机执行过程变量共享关系是否吻合
反复问自己问题的答案是什么,然后和老师的理解做对比,收获感和进步才会是巨大的。
第三,坚持,坚持,再坚持。
学习最难的也是最有价值的一点就是“坚持”,所以每天我都会主动去看专栏,包括相关书籍以及网上的各类资料。学习到新的知识是一件多么幸福的事情,每天一点点,这日积月累下来就是一笔不小的财富。我们办公室就有一个有趣的墙画,内容就是:
1.01365\=37.8
而1.02365\=1 377.4。
每天跟着专栏去渗透,每一句话都读得很细致,每次看到后一句忘记前面说的都会返回去再读一遍直到弄懂为止。知识一定要彻底掌握才能被更好地使用,这是我个人的要求。需要补充一句,学习的时候切记要结合源码去看,事半功倍!
再说专栏的 2 个宝藏之地
除此之外,还有两个我很喜欢的、也很激励我学习的点。
第一个,每一篇文章最后都会有思考题,而思考题的背后就是众多同学的头脑风暴。
每篇文章的留言我都会细细去看,看同学们的回答以及提问我是否了解,如果了解,就暗暗地“得意”一番;如果没有,那就说明我还没完全弄明白,路漫漫仍需继续努力。
所以留言区也是一个宝藏之地有些同学的回答甚至可能比老师举的例子更加让人印象深刻例如《01 | 可见性、原子性和有序性问题并发编程Bug的源头》下的留言两个字精彩当初读完第一章的时候我就暗下决心绝不能输给这些同学。
这也侧面反映了一点:自己拿一本书去看去学,和你潜意识里知道有很多人在跟你一起学,效果是完全不一样的。-
第二个,热点问题答疑,这也是宝令老师专栏的特色了。
每一模块的最后都会专门有一篇文章去详细回答各类问题,看这类问题的同时我自己脑子里存储的各类知识也都会融会贯通起来,不知不觉中勾勒出自己的知识全景图,很有成就感。
总结
平心而论,如果不是因为这个专栏,我想我不会学得这么快、学得这么好、中间找工作也找得那么顺利。当初也是被宝令老师的知识总结以及传授方式所吸引,才选择去订阅,看完专栏第一个模块就的确感觉学了很多很多,收获颇丰。
所以,我可以拍胸脯说:这是我订阅的所有专栏里最值的一个!
在结束语中,宝令老师说他自己好为人师,作为读者的我确实感受到了。专栏下面的留言,老师都会耐心去解答,看回复也能收获很多。后续也希望老师能继续输出一些自己的所得和见解,而我们,则站在巨人的肩膀上,遇见我们最美好的风景!
在最后,我也附上我自己学习过程中的一些代码积累。
宝令老师的回信:
首先,恭喜 Zed 顺利收获一份新工作。我觉得能通过面试只有一个原因就是技术水平过关,并发相关的知识和技术到位,面试自然就轻松了。
感谢你分享的学习方法,尤其是第一点:直接上手跟着敲一遍代码。我们搞工程的必须上手才能理解的更深刻,所以专栏一直都没有给出一份完整的代码,更希望大家自己去动手。
大致浏览了一下你在 Github 分享的代码以及知识点的总结,代码写的很规范,能看出来很用心,我觉得你的新东家眼光很不错!
非常感谢 Zed 及大家,能坚持看完我的专栏,并能运用在实际工作中,真的值了。也欢迎大家把这篇文章分享给朋友,相互学习,互相激励。

View File

@ -0,0 +1,25 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 十年之后,初心依旧
曾经有个特别好的朋友跟我说过“你挺适合当老师的”其实适不适合并不一定但是好为人师是一定的。到这里我已经分享了45篇的技术文章估计你也看累了、听累了需要些时间好好消化消化。所以最后咱们轻松一下吧聊聊人生、聊聊理想正好我也和你聊聊我那些“不堪回首的往事”。
我曾经搞过5年的ERP其间我是很想在这条路上一直走下去但在这个行业摸爬滚打了几年之后我发现这个行业里懂业务比懂技术更重要。于是为了提高业务水平我就去搞注册会计师了但在我还没有搞定它的时候我突然发现自己竟然失业了。这个时候我才意识到选择拼搏于细分行业里的夕阳产业是多么愚蠢。选择永远比努力更重要。
可笑的是我们选择的,往往不是我们期望的那样。后来我阴错阳差去了一家央企,传统观点认为这里和养老院是对门儿,可实际上,在“养老院对门儿”的这三年多,是我成长最快的三年,包括技术。这三年属于被“骂”的最多的三年,做的东西被同行“骂”,汇报被领导“骂”,被“骂”的多了,渐渐就意识到自己的问题了。找到自己的问题,才是最重要的。
一哥们儿曾有过一段经典的总结:所有的失败都可以归结为“错估了形势,低估了敌人,高估了自己”。人,总是高估了自己,显然,我也是。很多时候,我也会一不小心就高估了自己,而且还一点都意识不到。感谢佛家经典《金刚经》,虽说到现在我也没有把它抄完,但是抄到不到一半的时候,我已经深深认识到自己是多么的浅薄与狂妄了。驱除虚妄,才能进步。
搞技术的瓶颈在哪里呢每个人资质、机遇不同其实没有必要强求。我也曾经兴趣广泛大学时还买过全英文的《Intel微处理器》搬了几次家都没舍得扔前两年终于扔掉了纯粹是浪费时间和空间。有时我们得承认不是随便一个领域我们都能干得很深入的实际场景和资质都很重要。拿不动的东西越早放弃越好做了减法才能做加法生也有涯该放就放。
工作十年,很多人已经在不同的轨道上了,有些人选择了做管理,有些人选择了创业,只有很少的人在搞技术。十年,很多面具下的脸都已千疮百孔,有些人摘下面具很丑,有些人摘下面具很怪,只有很少的人摘下面具你还认得。事实证明,你不认得的,基本都已落马;你还认得的,基本都混得不错。正所谓,路遥知马力,日久见人心。简单做人,挺好。
工作了十多年,最值得骄傲的是,更加相信善良。最后也祝你十年之后,初心依旧。
](https://jinshuju.net/f/9W7ghF)