first commit
This commit is contained in:
89
专栏/Java并发编程实战/00学习攻略如何才能学好并发编程?.md
Normal file
89
专栏/Java并发编程实战/00学习攻略如何才能学好并发编程?.md
Normal 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对并发问题的深刻认识。可惜这个设计的思想目前并没有相关的论文,所以只能自己琢磨了。
|
||||
|
||||
分工、同步和互斥的全景图,是我对并发问题的个人总结,不一定正确,但是可以帮助我快速建立解决并发问题的思路,梳理并发编程的知识,加深认识。我将其分享给你,希望对你也有用。
|
||||
|
||||
对于某个具体的技术,我建议你探索它背后的理论本质,理论的应用面更宽,一项优秀的理论往往在多个语言中都有体现,在多个不同领域都有应用。所以探求理论本质,既能加深对技术本身的理解,也能拓展知识深度和广度,这是个一举多得的方法。这方面,希望我们一起探讨,共同进步。
|
||||
|
||||
欢迎在留言区跟我分享你的经历与想法。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
63
专栏/Java并发编程实战/00开篇词你为什么需要学习并发编程?.md
Normal file
63
专栏/Java并发编程实战/00开篇词你为什么需要学习并发编程?.md
Normal 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并发,让你的知识更成体系,融会贯通。最终让你能够得心应手地解决各种并发难题,同时将这些知识用于其他编程语言,让你的一分辛劳三分收获。
|
||||
|
||||
下面就是这个专栏的目录,你可以快速了解下整个专栏的知识结构体系。
|
||||
|
||||
|
||||
|
||||
当然,我们要坚持下去,不能三天打鱼两天晒网,因为滴水穿石非一日之功。
|
||||
|
||||
很多人都说学习是反人性的,开始容易,但是长久的坚持却很难。这个我也认同,我面试的时候,就经常问候选人一个问题:“工作中,有没有一件事你自己坚持了很久,并且从中获益?”如果候选人能够回答出来,那会是整个面试的加分项,因为我觉得,坚持真是一个可贵的品质,一件事情,有的人三分热度,而有的人,一做就能做一年,或者更久。你放长到时间的维度里看,这两种人,最后的成就绝对是指数级的差距。
|
||||
|
||||
我希望你能和我坚持下来,我们一起学习,一起交流,遇到问题不是简单地抱怨和逃避,而是努力探寻答案与解决方法。这一次,就让我们一起来坚持探索并发编程的奥秘,体会探索知识的乐趣。今天的文章是开篇词,我们的主菜很快就来,如果可以的话,还请在留言区中做个自我介绍,和我聊聊你目前的工作、学习情况,以及你在并发编程方面的学习痛点,方便我在后面针对性地给你讲解,这样,我们可以彼此了解。
|
||||
|
||||
最后,感谢你对我的信任,我定会努力实现完美交付。
|
||||
|
||||
|
||||
|
||||
|
179
专栏/Java并发编程实战/01可见性、原子性和有序性问题:并发编程Bug的源头.md
Normal file
179
专栏/Java并发编程实战/01可见性、原子性和有序性问题:并发编程Bug的源头.md
Normal 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=6;b=7;”编译器优化后可能变成“b=7;a=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型变量进行加减操作存在并发隐患,到底是不是这样呢?现在相信你一定能分析出来。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
208
专栏/Java并发编程实战/02Java内存模型:看Java如何解决可见性和有序性问题.md
Normal file
208
专栏/Java并发编程实战/02Java内存模型:看Java如何解决可见性和有序性问题.md
Normal 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中指的就是synchronized,synchronized是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-Before)B事件发生的,这个就是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
|
||||
|
||||
|
||||
|
||||
|
||||
|
195
专栏/Java并发编程实战/03互斥锁(上):解决原子性问题.md
Normal file
195
专栏/Java并发编程实战/03互斥锁(上):解决原子性问题.md
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
178
专栏/Java并发编程实战/04互斥锁(下):如何用一把锁保护多个资源?.md
Normal file
178
专栏/Java并发编程实战/04互斥锁(下):如何用一把锁保护多个资源?.md
Normal 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作为互斥锁,你觉得是否可以呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
235
专栏/Java并发编程实战/05一不小心就死锁了,怎么办?.md
Normal file
235
专栏/Java并发编程实战/05一不小心就死锁了,怎么办?.md
Normal 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的锁(对于T1,this是账户A),而T2获得了账户B的锁(对于T2,this是账户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)有没有性能优势呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
139
专栏/Java并发编程实战/06用“等待-通知”机制优化循环等待.md
Normal file
139
专栏/Java并发编程实战/06用“等待-通知”机制优化循环等待.md
Normal 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()方法都能让当前线程挂起一段时间,那它们的区别是什么?现在你也试着回答一下吧。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
153
专栏/Java并发编程实战/07安全性、活跃性以及性能问题.md
Normal file
153
专栏/Java并发编程实战/07安全性、活跃性以及性能问题.md
Normal file
@ -0,0 +1,153 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 安全性、活跃性以及性能问题
|
||||
通过前面六篇文章,我们开启了一个简单的并发旅程,相信现在你对并发编程需要注意的问题已经有了更深入的理解,这是一个很大的进步,正所谓只有发现问题,才能解决问题。但是前面六篇文章的知识点可能还是有点分散,所以是时候将其总结一下了。
|
||||
|
||||
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:安全性问题、活跃性问题和性能问题。下面我就来一一介绍这些问题。
|
||||
|
||||
安全性问题
|
||||
|
||||
相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等等。
|
||||
|
||||
那什么是线程安全呢?其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。在第一篇《可见性、原子性和有序性问题:并发编程Bug的源头》中,我们已经见识过很多诡异的Bug,都是出乎我们预料的,它们都没有按照我们期望的执行。
|
||||
|
||||
那如何才能写出线程安全的程序呢?第一篇文章中已经介绍了并发Bug的三个主要源头:原子性问题、可见性问题和有序性问题。也就是说,理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
|
||||
|
||||
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢?当然不是,其实只有一种情况需要:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的,例如线程本地存储(Thread Local Storage,TLS)、不变模式等等,后面我会详细介绍相关的技术方案是如何在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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
182
专栏/Java并发编程实战/08管程:并发编程的万能钥匙.md
Normal file
182
专栏/Java并发编程实战/08管程:并发编程的万能钥匙.md
Normal 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模型里面,增加了超时参数,你觉得这个参数有必要吗?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
176
专栏/Java并发编程实战/09Java线程(上):Java线程的生命周期.md
Normal file
176
专栏/Java并发编程实战/09Java线程(上):Java线程的生命周期.md
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
96
专栏/Java并发编程实战/10Java线程(中):创建多少线程才是合适的?.md
Normal file
96
专栏/Java并发编程实战/10Java线程(中):创建多少线程才是合适的?.md
Normal 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计算[1,25亿),线程B计算[25亿,50亿),线程C计算[50,75亿),线程D计算[75亿,100亿],之后汇总,那么理论上应该比一个线程计算[1,100亿]快将近4倍,响应时间能够降到25%。一个线程,对于4核的CPU,CPU的利用率只有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,你觉得这个经验值合理吗?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
98
专栏/Java并发编程实战/11Java线程(下):为什么局部变量是线程安全的?.md
Normal file
98
专栏/Java并发编程实战/11Java线程(下):为什么局部变量是线程安全的?.md
Normal 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支持一种栈结构,栈你一定很熟悉了,就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为调用栈。
|
||||
|
||||
例如,有三个方法A、B、C,他们的调用关系是A->B->C(A调用B,B调用C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。
|
||||
|
||||
|
||||
|
||||
调用栈结构
|
||||
|
||||
利用栈结构来支持方法调用这个方案非常普遍,以至于CPU里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪,但是方法的内部执行原理却是出奇的一致:都是靠栈结构解决的。Java语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。
|
||||
|
||||
局部变量存哪里?
|
||||
|
||||
我们已经知道了方法间的调用在CPU眼里是怎么执行的,但还有一个关键问题:方法内的局部变量存哪里?
|
||||
|
||||
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。
|
||||
|
||||
|
||||
|
||||
保护局部变量的调用栈结构
|
||||
|
||||
这个结论相信很多人都知道,因为学Java语言的时候,基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
|
||||
|
||||
调用栈与线程
|
||||
|
||||
两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:每个线程都有自己独立的调用栈。因为如果不是这样,那两个线程就互相干扰了。如下面这幅图所示,线程A、B、C每个线程都有自己独立的调用栈。
|
||||
|
||||
|
||||
|
||||
线程与调用栈的关系图
|
||||
|
||||
现在,让我们回过头来再看篇首的问题:Java方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。
|
||||
|
||||
线程封闭
|
||||
|
||||
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
|
||||
|
||||
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接Connection,在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术,保证一个Connection一旦被一个线程获取之后,在这个线程关闭Connection之前的这段时间里,不会再分配给其他线程,从而保证了Connection不会有并发问题。
|
||||
|
||||
总结
|
||||
|
||||
调用栈是一个通用的计算机概念,所有的编程语言都会涉及到,Java调用栈相关的知识,我并没有花费很大的力气去深究,但是靠着那点C语言的知识,稍微思考一下,基本上也就推断出来了。工作了十几年,我发现最近几年和前些年最大的区别是:很多技术的实现原理我都是靠推断,然后看源码验证,而不是像以前一样纯粹靠看源码来总结了。
|
||||
|
||||
建议你也多研究原理性的东西、通用的东西,有这些东西之后再学具体的技术就快多了。
|
||||
|
||||
课后思考
|
||||
|
||||
常听人说,递归调用太深,可能导致栈溢出。你思考一下原因是什么?有哪些解决方案呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
132
专栏/Java并发编程实战/12如何用面向对象思想写好并发程序?.md
Normal file
132
专栏/Java并发编程实战/12如何用面向对象思想写好并发程序?.md
Normal 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并发编程实战》,这本书的第三章《对象的共享》、第四章《对象的组合》全面地介绍了如何构建线程安全的对象,你可以拿来深入地学习。
|
||||
|
||||
|
||||
|
||||
|
215
专栏/Java并发编程实战/13理论基础模块热点问题答疑.md
Normal file
215
专栏/Java并发编程实战/13理论基础模块热点问题答疑.md
Normal 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安全编码标准》这本书,如果你英文足够好,也可以参考这份文档。
|
||||
|
||||
最后总结一句,学好理论有思路,关注细节定成败。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
198
专栏/Java并发编程实战/14Lock和Condition(上):隐藏在并发包中的管程.md
Normal file
198
专栏/Java并发编程实战/14Lock和Condition(上):隐藏在并发包中的管程.md
Normal 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规则:
|
||||
|
||||
|
||||
顺序性规则:对于线程T1,value+=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
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
191
专栏/Java并发编程实战/15Lock和Condition(下):Dubbo如何用管程实现异步转同步?.md
Normal file
191
专栏/Java并发编程实战/15Lock和Condition(下):Dubbo如何用管程实现异步转同步?.md
Normal file
@ -0,0 +1,191 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 Lock和Condition(下):Dubbo如何用管程实现异步转同步?
|
||||
在上一篇文章中,我们讲到Java SDK并发包里的Lock有别于synchronized隐式锁的三个特性:能够响应中断、支持超时和非阻塞地获取锁。那今天我们接着再来详细聊聊Java SDK并发包里的Condition,Condition实现了管程模型里面的条件变量。
|
||||
|
||||
在《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(),你来分析一下,这样做是否合理呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
150
专栏/Java并发编程实战/16Semaphore:如何快速实现一个限流器?.md
Normal file
150
专栏/Java并发编程实战/16Semaphore:如何快速实现一个限流器?.md
Normal 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,是否可以呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
202
专栏/Java并发编程实战/17ReadWriteLock:如何快速实现一个完备的缓存?.md
Normal file
202
专栏/Java并发编程实战/17ReadWriteLock:如何快速实现一个完备的缓存?.md
Normal 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利用率很低,你怀疑有同学一不小心写出了读锁升级写锁的方案,那你该如何验证自己的怀疑呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
208
专栏/Java并发编程实战/18StampedLock:有没有比读写锁更快的锁?.md
Normal file
208
专栏/Java并发编程实战/18StampedLock:有没有比读写锁更快的锁?.md
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
225
专栏/Java并发编程实战/19CountDownLatch和CyclicBarrier:如何让多线程步调一致?.md
Normal file
225
专栏/Java并发编程实战/19CountDownLatch和CyclicBarrier:如何让多线程步调一致?.md
Normal 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的回调函数我们使用了一个固定大小的线程池,你觉得是否有必要呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
161
专栏/Java并发编程实战/20并发容器:都有哪些“坑”需要我们填?.md
Normal file
161
专栏/Java并发编程实战/20并发容器:都有哪些“坑”需要我们填?.md
Normal 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%,你觉得该如何验证你的猜测呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
265
专栏/Java并发编程实战/21原子类:无锁工具类的典范.md
Normal file
265
专栏/Java并发编程实战/21原子类:无锁工具类的典范.md
Normal 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;否则返回fasle。compareAndSwapLong的语义和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. 原子化的基本数据类型
|
||||
|
||||
相关实现有AtomicBoolean、AtomicInteger和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. 原子化的对象引用类型
|
||||
|
||||
相关实现有AtomicReference、AtomicStampedReference和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. 原子化数组
|
||||
|
||||
相关实现有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,利用这些原子类,我们可以原子化地更新数组里面的每一个元素。这些类提供的方法和原子化的基本数据类型的区别仅仅是:每个方法多了一个数组的索引参数,所以这里也不再赘述了。
|
||||
|
||||
4. 原子化对象属性更新器
|
||||
|
||||
相关实现有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
166
专栏/Java并发编程实战/22Executor与线程池:如何创建正确的线程池?.md
Normal file
166
专栏/Java并发编程实战/22Executor与线程池:如何创建正确的线程池?.md
Normal 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这样,没有业务含义。而很多情况下为了便于诊断问题,都需要给线程赋予一个有意义的名字,那你知道有哪些办法可以给线程池里的线程指定名字吗?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
220
专栏/Java并发编程实战/23Future:如何用多线程实现最优的“烧水泡茶”程序?.md
Normal file
220
专栏/Java并发编程实战/23Future:如何用多线程实现最优的“烧水泡茶”程序?.md
Normal 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对象是f,f.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和ft2,ft1完成洗水壶、烧开水、泡茶的任务,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);
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
279
专栏/Java并发编程实战/24CompletableFuture:异步编程没那么难.md
Normal file
279
专栏/Java并发编程实战/24CompletableFuture:异步编程没那么难.md
Normal 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();
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
212
专栏/Java并发编程实战/25CompletionService:如何批量执行异步任务?.md
Normal file
212
专栏/Java并发编程实战/25CompletionService:如何批量执行异步任务?.md
Normal 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;
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
196
专栏/Java并发编程实战/26Fork_Join:单机版的MapReduce.md
Normal file
196
专栏/Java并发编程实战/26Fork_Join:单机版的MapReduce.md
Normal 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并行计算框架是否能够提高性能呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
220
专栏/Java并发编程实战/27并发工具类模块热点问题答疑.md
Normal file
220
专栏/Java并发编程实战/27并发工具类模块热点问题答疑.md
Normal 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&Condition(下):Dubbo如何用管程实现异步转同步?》的思考题是关于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并发工具类内容繁杂,熟练使用是需要一个过程的,而且需要多加实践。希望你学完这个模块之后,遇到并发问题时最起码能知道用哪些工具可以解决。至于工具使用的细节和最佳实践,我总结的也只是我认为重要的。由于每个人的思维方式和编码习惯不同,也许我认为不重要的,恰恰是你的短板,所以这部分内容更多地还是需要你去实践,在实践中养成良好的编码习惯,不断纠正错误的思维方式。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
220
专栏/Java并发编程实战/28Immutability模式:如何利用不变性解决并发问题?.md
Normal file
220
专栏/Java并发编程实战/28Immutability模式:如何利用不变性解决并发问题?.md
Normal 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语言里面Long、Integer、Short、Byte等这些基本数据类型的包装类都用到了享元模式。
|
||||
|
||||
下面我们就以Long这个类作为例子,看看它是如何利用享元模式来优化对象的创建的。
|
||||
|
||||
享元模式本质上其实就是一个对象池,利用享元模式创建对象的逻辑也很简单:创建之前,首先去对象池里看看是不是存在;如果已经存在,就利用对象池里的对象;如果不存在,就会新创建一个对象,并且把这个新创建出来的对象放进对象池里。
|
||||
|
||||
Long这个类并没有照搬享元模式,Long内部维护了一个静态的对象池,仅缓存了[-128,127]之间的数字,这个对象池在JVM启动的时候就创建好了,而且这个对象池一直都不会变化,也就是说它是静态的。之所以采用这样的设计,是因为Long这个对象的状态共有 264 种,实在太多,不宜全部缓存,而[-128,127]之间的数字利用率最高。下面的示例代码出自Java 1.8,valueOf()方法就用到了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用锁al,B用锁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和Long、Integer、Double等基础类型的包装类都具备不可变性,这些对象的线程安全性都是靠不可变性来保证的。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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
116
专栏/Java并发编程实战/29Copy-on-Write模式:不是延时策略的COW.md
Normal file
116
专栏/Java并发编程实战/29Copy-on-Write模式:不是延时策略的COW.md
Normal 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)、aufs(advanced 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呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
168
专栏/Java并发编程实战/30线程本地存储模式:没有共享,就没有伤害.md
Normal file
168
专栏/Java并发编程实战/30线程本地存储模式:没有共享,就没有伤害.md
Normal 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是每个线程拥有的变量V,ThreadLocal内部持有这样的一个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,其类型就是ThreadLocalMap,ThreadLocalMap的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的事务管理器呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
241
专栏/Java并发编程实战/31GuardedSuspension模式:等待唤醒机制的规范实现.md
Normal file
241
专栏/Java并发编程实战/31GuardedSuspension模式:等待唤醒机制的规范实现.md
Normal 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和Condition(下):Dubbo如何用管程实现异步转同步?》中曾介绍过的异步转同步问题吗?仔细分析,的确是这样,不过在那一篇文章中我们只是介绍了最终方案,让你知其然,但是并没有介绍这个方案是如何设计出来的,今天咱们再仔细聊聊这个问题,让你知其所以然,遇到类似问题也能自己设计出方案来。
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
243
专栏/Java并发编程实战/32Balking模式:再谈线程安全的单例模式.md
Normal file
243
专栏/Java并发编程实战/32Balking模式:再谈线程安全的单例模式.md
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
166
专栏/Java并发编程实战/33Thread-Per-Message模式:最简单实用的分工方法.md
Normal file
166
专栏/Java并发编程实战/33Thread-Per-Message模式:最简单实用的分工方法.md
Normal 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,那有什么办法可以快速解决呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
163
专栏/Java并发编程实战/34WorkerThread模式:如何避免重复创建线程?.md
Normal file
163
专栏/Java并发编程实战/34WorkerThread模式:如何避免重复创建线程?.md
Normal 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) {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
197
专栏/Java并发编程实战/35两阶段终止模式:如何优雅地终止线程?.md
Normal file
197
专栏/Java并发编程实战/35两阶段终止模式:如何优雅地终止线程?.md
Normal 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;
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
201
专栏/Java并发编程实战/36生产者-消费者模式:用流水线思想提高效率.md
Normal file
201
专栏/Java并发编程实战/36生产者-消费者模式:用流水线思想提高效率.md
Normal 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) {}
|
||||
}
|
||||
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
165
专栏/Java并发编程实战/37设计模式模块热点问题答疑.md
Normal file
165
专栏/Java并发编程实战/37设计模式模块热点问题答疑.md
Normal file
@ -0,0 +1,165 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 设计模式模块热点问题答疑
|
||||
多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。
|
||||
|
||||
在这个模块,我们总共介绍了9种常见的多线程设计模式。下面我们就对这9种设计模式做个分类和总结,同时也对前面各章的课后思考题做个答疑。
|
||||
|
||||
避免共享的设计模式
|
||||
|
||||
Immutability模式、Copy-on-Write模式和线程本地存储模式本质上都是为了避免共享,只是实现手段不同而已。这3种设计模式的实现都很简单,但是实现过程中有些细节还是需要格外注意的。例如,使用Immutability模式需要注意对象属性的不可变性,使用Copy-on-Write模式需要注意性能问题,使用线程本地存储模式需要注意异步执行问题。所以,每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。
|
||||
|
||||
《28 | Immutability模式:如何利用不变性解决并发问题?》的课后思考题是讨论Account这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现,而实质上这个实现是有问题的,原因在于StringBuffer不同于String,StringBuffer不具备不可变性,通过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多线程设计模式》这本书来深入学习这个模块,这是一本不错的并发编程入门书籍,虽然重点是讲解设计模式,但是也详细讲解了设计模式中涉及到的方方面面的基础知识,而且深入浅出,非常推荐入门的同学认真学习一下。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
215
专栏/Java并发编程实战/38案例分析(一):高性能限流器GuavaRateLimiter.md
Normal file
215
专栏/Java并发编程实战/38案例分析(一):高性能限流器GuavaRateLimiter.md
Normal 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构建了一个积分函数来解决这个问题,如果你感兴趣,可以继续深入研究。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
145
专栏/Java并发编程实战/39案例分析(二):高性能网络应用框架Netty.md
Normal file
145
专栏/Java并发编程实战/39案例分析(二):高性能网络应用框架Netty.md
Normal file
@ -0,0 +1,145 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 案例分析(二):高性能网络应用框架Netty
|
||||
Netty是一个高性能网络应用框架,应用非常普遍,目前在Java领域里,Netty基本上成为网络程序的标配了。Netty框架功能丰富,也非常复杂,今天我们主要分析Netty框架中的线程模型,而线程模型直接影响着网络程序的性能。
|
||||
|
||||
在介绍Netty的线程模型之前,我们首先需要把问题搞清楚,了解网络编程性能的瓶颈在哪里,然后再看Netty的线程模型是如何解决这个问题的。
|
||||
|
||||
网络编程性能的瓶颈
|
||||
|
||||
在《33 | Thread-Per-Message模式:最简单实用的分工方法》中,我们写过一个简单的网络程序echo,采用的是阻塞式I/O(BIO)。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里还提供了非阻塞式(NIO)API,利用非阻塞式API就能够实现一个线程处理多个连接了。那具体如何实现呢?现在普遍都是采用Reactor模式,包括Netty的实现。所以,要想理解Netty的实现,接下来我们就需要先了解一下Reactor模式。
|
||||
|
||||
Reactor模式
|
||||
|
||||
下面是Reactor模式的类结构图,其中Handle指的是I/O句柄,在Java网络编程里,它本质上就是一个网络连接。Event Handler很容易理解,就是一个事件处理器,其中handle_event()方法处理I/O事件,也就是每个Event Handler处理一个I/O Handle;get_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网络编程》。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
83
专栏/Java并发编程实战/3个用户来信打开一个新的并发世界.md
Normal file
83
专栏/Java并发编程实战/3个用户来信打开一个新的并发世界.md
Normal 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篇文章里,我跟着学会了如何用多线程并行操作来优化程序执行时间,以及如何用线程通信来让程序执行得更高效。
|
||||
|
||||
就这样,从头到尾跟下来后,我收获颇丰!
|
||||
|
||||
现在,我也尝试着把我学到的知识点用于项目中,不断优化自己的代码。如今我开始每做一个需求,都会对其资源消耗、时间损耗和并发安全多进行一步思考和优化。这些都为我的项目成功上线起到了重要的保障作用,我的同事也开始夸奖我并发方面的表现突出。这是我最开心和欣慰的地方。
|
||||
|
||||
由此,我想感谢宝令老师,是发自内心且由衷地感谢。感谢老师这几个月的一直陪伴,感谢老师分享的知识让我向着理想更进了一步,感谢老师怀揣着对技术的执着之心激励我初心依旧。
|
||||
|
||||
宝令回信:
|
||||
|
||||
你这么快就能在工作中熟练使用了,这是我最开心和欣慰的地方。学会怎么思考并且在工作中实践,进步一定很快。祝在工作中更上一层楼!
|
||||
|
||||
|
||||
|
||||
|
207
专栏/Java并发编程实战/40案例分析(三):高性能队列Disruptor.md
Normal file
207
专栏/Java并发编程实战/40案例分析(三):高性能队列Disruptor.md
Normal 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独占一个缓存行。下面的示例代码出自Disruptor,Sequence 对象中的value属性就能避免伪共享,因为这个属性前后都填充了56个字节。Disruptor中很多对象,例如RingBuffer、RingBuffer内部的数组都用到了这种填充技术来避免伪共享。
|
||||
|
||||
//前:填充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)。不过避免伪共享是以牺牲内存为代价的,所以具体使用的时候还是需要仔细斟酌。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
229
专栏/Java并发编程实战/41案例分析(四):高性能数据库连接池HiKariCP.md
Normal file
229
专栏/Java并发编程实战/41案例分析(四):高性能数据库连接池HiKariCP.md
Normal 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提供的SynchronousQueue,SynchronousQueue主要用于线程之间传递数据。
|
||||
|
||||
//用于存储所有的数据库连接
|
||||
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做一次预分配,避免直接竞争共享资源,非常适合池化资源的分配。
|
||||
|
||||
在实际工作中,我们遇到的并发问题千差万别,这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解,只有了解到无谓的性能消耗在哪里,才能对症下药。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
133
专栏/Java并发编程实战/42Actor模型:面向对象原生的并发模型.md
Normal file
133
专栏/Java并发编程实战/42Actor模型:面向对象原生的并发模型.md
Normal 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模型先有个感官的印象。
|
||||
|
||||
在下面的示例代码中,我们首先创建了一个ActorSystem(Actor不能脱离ActorSystem存在);之后创建了一个HelloActor,Akka中创建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模型也是有成本的。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
247
专栏/Java并发编程实战/43软件事务内存:借鉴数据库的并发经验.md
Normal file
247
专栏/Java并发编程实战/43软件事务内存:借鉴数据库的并发经验.md
Normal file
@ -0,0 +1,247 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
43 软件事务内存:借鉴数据库的并发经验
|
||||
很多同学反馈说,工作了挺长时间但是没有机会接触并发编程,实际上我们天天都在写并发程序,只不过并发相关的问题都被类似Tomcat这样的Web服务器以及MySQL这样的数据库解决了。尤其是数据库,在解决并发问题方面,可谓成绩斐然,它的事务机制非常简单易用,能甩Java里面的锁、原子类十条街。技术无边界,很显然要借鉴一下。
|
||||
|
||||
其实很多编程语言都有从数据库的事务管理中获得灵感,并且总结出了一个新的并发解决方案:软件事务内存(Software Transactional Memory,简称STM)。传统的数据库事务,支持4个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是大家常说的ACID,STM由于不涉及到持久化,所以只支持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这个类负责完成事务内的读写操作,读写操作委托给了接口Txn,Txn代表的是读写操作所在的当前事务, 内部持有的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内部有两个Map:inTxnMap,用于保存当前事务中所有读写的数据的快照;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 这篇博文,里面讲到了如何优化,你可以尝试学习下。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
152
专栏/Java并发编程实战/44协程:更轻量级的线程.md
Normal file
152
专栏/Java并发编程实战/44协程:更轻量级的线程.md
Normal 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程序员也可以使用协程来解决并发问题了。
|
||||
|
||||
计算机里很多面向开发人员的技术,大多数都是在解决一个问题:易用性。协程作为一项并发编程技术,本质上也不过是解决并发工具的易用性问题而已。对于易用性,我觉得最重要的就是要适应我们的思维模式,在工作的前几年,我并没有怎么关注它,但是最近几年思维模式已成为我重点关注的对象。因为思维模式对工作的很多方面都会产生影响,例如质量。
|
||||
|
||||
一个软件产品是否能够活下去,从质量的角度看,最核心的就是代码写得好。那什么样的代码是好代码呢?我觉得,最根本的是可读性好。可读性好的代码,意味着大家都可以上手,而且上手后不会大动干戈。那如何让代码的可读性好呢?很简单,换位思考,用大众、普通的思维模式去写代码,而不是炫耀自己的各种设计能力。我觉得好的代码,就像人民的艺术一样,应该是为人民群众服务的,只有根植于广大群众之中,才有生命力。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
133
专栏/Java并发编程实战/45CSP模型:Golang的主力队员.md
Normal file
133
专栏/Java并发编程实战/45CSP模型:Golang的主力队员.md
Normal file
@ -0,0 +1,133 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
45 CSP模型:Golang的主力队员
|
||||
Golang是一门号称从语言层面支持并发的编程语言,支持并发是Golang一个非常重要的特性。在上一篇文章《44 | 协程:更轻量级的线程》中我们介绍过,Golang支持协程,协程可以类比Java中的线程,解决并发问题的难点就在于线程(协程)之间的协作。
|
||||
|
||||
那Golang是如何解决协作问题的呢?
|
||||
|
||||
总的来说,Golang提供了两种不同的方案:一种方案支持协程之间以共享内存的方式通信,Golang提供了管程和原子类来对协程进行同步控制,这个方案与Java语言类似;另一种方案支持协程之间以消息传递(Message-Passing)的方式通信,本质上是要避免共享,Golang的这个方案是基于CSP(Communicating Sequential Processes)模型实现的。Golang比较推荐的方案是后者。
|
||||
|
||||
什么是CSP模型
|
||||
|
||||
我们在《42 | Actor模型:面向对象原生的并发模型》中介绍了Actor模型,Actor模型中Actor之间就是不能共享内存的,彼此之间通信只能依靠消息传递的方式。Golang实现的CSP模型和Actor模型看上去非常相似,Golang程序员中有句格言:“不要以共享内存方式通信,要以通信方式共享内存(Don’t 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中协程之间通信推荐的是使用channel,channel你可以形象地理解为现实世界里的管道。另外,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并没有经过广泛的生产环境检验,所以并不建议你在生产环境中使用。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
125
专栏/Java并发编程实战/用户来信真好,面试考到这些并发编程,我都答对了!.md
Normal file
125
专栏/Java并发编程实战/用户来信真好,面试考到这些并发编程,我都答对了!.md
Normal 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 及大家,能坚持看完我的专栏,并能运用在实际工作中,真的值了。也欢迎大家把这篇文章分享给朋友,相互学习,互相激励。
|
||||
|
||||
|
||||
|
||||
|
25
专栏/Java并发编程实战/结束语十年之后,初心依旧.md
Normal file
25
专栏/Java并发编程实战/结束语十年之后,初心依旧.md
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 十年之后,初心依旧
|
||||
曾经有个特别好的朋友跟我说过:“你挺适合当老师的!”其实适不适合并不一定,但是好为人师是一定的。到这里,我已经分享了45篇的技术文章,估计你也看累了、听累了,需要些时间好好消化消化。所以,最后咱们轻松一下吧,聊聊人生、聊聊理想,正好我也和你聊聊我那些“不堪回首的往事”。
|
||||
|
||||
我曾经搞过5年的ERP,其间我是很想在这条路上一直走下去,但在这个行业摸爬滚打了几年之后,我发现这个行业里懂业务比懂技术更重要。于是为了提高业务水平,我就去搞注册会计师了;但在我还没有搞定它的时候,我突然发现自己竟然失业了。这个时候我才意识到,选择拼搏于细分行业里的夕阳产业,是多么愚蠢。选择,永远比努力更重要。
|
||||
|
||||
可笑的是我们选择的,往往不是我们期望的那样。后来我阴错阳差去了一家央企,传统观点认为这里和养老院是对门儿,可实际上,在“养老院对门儿”的这三年多,是我成长最快的三年,包括技术。这三年属于被“骂”的最多的三年,做的东西被同行“骂”,汇报被领导“骂”,被“骂”的多了,渐渐就意识到自己的问题了。找到自己的问题,才是最重要的。
|
||||
|
||||
一哥们儿曾有过一段经典的总结:所有的失败都可以归结为“错估了形势,低估了敌人,高估了自己”。人,总是高估了自己,显然,我也是。很多时候,我也会一不小心就高估了自己,而且还一点都意识不到。感谢佛家经典《金刚经》,虽说到现在我也没有把它抄完,但是抄到不到一半的时候,我已经深深认识到自己是多么的浅薄与狂妄了。驱除虚妄,才能进步。
|
||||
|
||||
搞技术的瓶颈在哪里呢?每个人资质、机遇不同,其实没有必要强求。我也曾经兴趣广泛,大学时还买过全英文的《Intel微处理器》,搬了几次家,都没舍得扔,前两年终于扔掉了,纯粹是浪费时间和空间。有时我们得承认,不是随便一个领域我们都能干得很深入的,实际场景和资质都很重要。拿不动的东西越早放弃越好,做了减法,才能做加法,生也有涯,该放就放。
|
||||
|
||||
工作十年,很多人已经在不同的轨道上了,有些人选择了做管理,有些人选择了创业,只有很少的人在搞技术。十年,很多面具下的脸都已千疮百孔,有些人摘下面具很丑,有些人摘下面具很怪,只有很少的人摘下面具你还认得。事实证明,你不认得的,基本都已落马;你还认得的,基本都混得不错。正所谓,路遥知马力,日久见人心。简单做人,挺好。
|
||||
|
||||
工作了十多年,最值得骄傲的是,更加相信善良。最后也祝你十年之后,初心依旧。
|
||||
|
||||
](https://jinshuju.net/f/9W7ghF)
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user