写在前面无关的内容
白驹过隙,看下日历已经毕业4年多,加上在大学里的4年,算算在计算机界也躺了八年,按照格拉德韦尔的1万小时定律差不多我也该成为行业的专家了,然后并没有。当看着“什么是Java?”、“什么是程序?”、“多线程是什么?”、“怎么构建一个合理的大型网站?”、“怎么保证系统的稳定运行”这些耳熟能详的问题时,就知道前方的路还有很远很远,这些问题也许我一直无法给出确切的回答,但至少希望给别人解释多线程时,不会说“多线程就是很多线程一起跑”这种文字级别的说明。
初读此书的感受
每天晚上看一点,零零碎碎的花二十天左右差不多算是翻完了。总的来说讲的是通俗易懂,用例和代码选择得当;但不管怎么说,这是一本纯理论的解释书籍,虽说中间穿插了很多样例,也都基本上是为了演示理论构造出来的,自然就少了些生动;这种纯理论的介绍对我这种记忆不佳的人简直就是成吨的伤害,以至于前天才看完,今天上面的很多细节就被忘的差不多了,甚至怀疑要不了多久我都是否记得看过这本书。
言归正传,看书本来就不是为了记住书名和作者名,真正要记住的是作者想通过书来表达的东西,或者是看书者想通过看书得到的收获,反正我是这么认为的。当时买这本书看就是想系统性的了解Java高并发这个概念,什么是Java高并发,Java高并发的原理、怎么实现Java高并发,哪里用到Java高并发,我们怎么使用Java高并发等,关于这些核心问题书中也都或多或少的给出了一定的回答;可能是此书专注于理论解释的缘故,对“Java高并发的原理”、“怎么实现Java高并发”讲的比较详细,而其它的则讲的粗略些。对于书上的理论、方法是不是最好的,我没做考究,当然目前我的水平也不够;纯从感觉上讲,书中的理论严谨,方法简洁,至少我是还没发现有什么不妥,或者是需要改进的地方,目前权且当它是最好的吧,以后会是啥样,那就是以后的事了。
一些个人的理解
什么是Java高并发?
Java高并发就是,用Java语言在短时间内处理大量的业务。
Java高并发的原理?
并行的设计模式和对多线程的良好支持,后面详细讲到,也是全文的重点。
高并发的背景:
计算机经过这么多年的发展,如果当前的计算理论或者芯片的生产工艺没有本质的突破,那么单个CUP的计算能力可能已经走到尽头了,而且目前我们还没有看到突破的可能。CUP的性能是遇到了不可逾越的瓶颈,但我们的硬件工程师并没有放弃对性能的提升,于是他们想出了一个十分“机智”的办法,一个CPU的性能是固定的,那我多弄几个CPU放一起组成一个多核CPU,性能不就提高了几倍么。
并行模式:
一个16核CPU,总的来看性能确实比单核CPU性能提高了16倍,这很显而易见;但CPU性能提高16倍的前提是这CPU的16个核都同时勤奋的工作,然后目前计算机显然还没这么智能,并不会把我们的给它的任务拆分到16个不同的CPU内核上执行,而是选一个CPU内核来执行完成任务;如果这样来看,那CPU内核再多也没用啊,干活的就那几个,只是多了些吃瓜的群众。为了更好的将这些吃瓜的群众也派上用场,加快我们的任务执行速度,既然计算不会自动帮我们分拆任务,那我们先将任务分拆好,然后再给计算机运行,这样总能接受了吧。
通俗的说,就是在业务逻辑层将业务拆分成很多子业务,然后再将这些大量的子业务交给计算机处理,这样计算机就可以用每个CPU内核分别去处理这些子业务,只要拆分的合理,那么就能将合成的多核CPU的性能发挥出来了。由于是CPU的每个内核都在同时运行,于是就把这种模式称之为--并行模式。
Java的多线程:
我们都知道线程是程序运行的最小单位,也就是说一个CPU内核在一个时刻只能运行一个线程。我们把任务拆分成子任务,然后交给CPU运行,实质上就是把一个大的程序(可以看做是一个需要运行很久的线程),拆分成多个小的线程,交给操作系统,然后由操作系统调度分配CPU内核来分别运行这些线程,当这些线程都执行完成后,我们的程序也就运行完成了。而Java提供一整套对线程进行管理的方法,包括创建、启动、运行、挂起、结束等,我们通过Java提供的方法,能够较方便的实现我们对线程的拆分,以及协同运行的控制,对于这一整体解决方案,称之为Java多线程。Java多线程是实现Java程序高并发的核心,比较复杂,下节详细讲解。
总结:
这一节我们谈到了三个重要概念,高并发、并行模式、多线程。下面总结下这三者的关系。
高并发是我们要实现的目标,要在较短的时间内处理掉大量的业务;
并行模式是实现我们高并发的一种方案,这个主要是基于现实情况考虑得来的。假如我们的CPU运行能力能提高几个数量级,一个CPU在短时间内就能处理掉大量业务,那么就没有并行的必要了;主要是当前单个CPU的运行能力有限,只能通过多个CPU同时运行,才能在短时间能完成大量业务,所以就有了并行;
多线程是实现并行模式的软件基础(多核CPU是实现并行的物理基础),因为线程是CPU核运行的最小单位,如果只有一个线程,再多CPU也无法并行;
由于我们是软件开发,所以高并发问题也就具体化到了解决程序的多线程问题,而在这些前面加上限定词Java,就是说我们是用Java这个工具来解决这些问题的。
怎么实现Java高并发?
高并发的原理我们已经明白,简单的说就是把一个大任务拆分成很多小任务,然后完成这些小的任务就可以了。大任务:简单的理解就是要做很多、或者是很复杂的事,这里可以等同于我们的高并发;小任务:简单的理解就是做一件简单的事,这里可以等同于我们的线程;这种等价是十分不严谨的,但这也算是对复杂概念的一种白话解释,方便记忆和理解。
听这么一说,感觉要实现高并发也很简单啊,就把事情拆分下交给计算机就ok了,然而难就难在这拆分上。至于为啥很难拆分,首先让我们了解下程序的执行流程:
1读取数据--》2缓存到内存--》3缓存到cache--》4CPU运算--》5写回cahce--》6写回内存--》7持久化数据,重复上述流程,直到完成程序完成。
从执行流程我们不难看出,程序下一步的执行是依赖上一步的执行结果,比如2没有执行完成,3是无法运行的。如果不对我任务进行拆分我们是很容易做到这一点的,因为整个软件设计就是严格要求程序是有序的,为此还要求程序只能有顺序、选择、循环这3种结构;但一旦对任务进行了拆分,那么一切都变了。比如我把一个任务拆分成两个子任务A和B,假如B的运算数据是A的运算结果,那么B就需要A执行完成6后,才能开始3,否则就会出现错误。而我们不管对A、或者B的程序单独做什么样的设计,都无法保证B能正确执行,要保证B的正确执行,我们就需要实现A、B两个程序的交叉控制;我们都知道,一个程序的耦合的越低越好,那么我们不难得出:在拆分的时候要尽可能的减小子任务间的耦合度。
减少耦合是我们拆分时的第一目标,如果能消除耦合当然是最好的,但这是很难、几乎不可能做到(或者是消除耦合的代价很大不能接受)。这个时候我们就不仅仅是对大任务的拆分,还要考虑拆分后的运行;反映到Java高并发上,就是把高并发的任务合理拆分成多线程,并处理好线程间的资源分配、线程的协同运行等问题。要保证高效率,又要n多线程运行不冲突,这是一件很难办到的事;不过好消息就是,Java提供了大量的方法和工具来帮我们解决这个问题,下面将重点介绍这些方法和工具。
Java多线程的基础
首先我们了解下线程的生命周期,创建---》运行--》挂起--》....运行--》结束;
创建线程Java提供了2种方法:extends Thread; implements Runnable(推荐使用);
启动线程:Object.start();启动后线程进入运行状态;
挂起线程:分为主动挂起,Object.wait()、Thread.sleep();被动挂起,synchronized、lock等;
唤醒线程:不同的挂起方式,分别有对于激活方式wait(notify),sleep(时间参数),synchronized(锁对象),lock(unlock);
结束线程:执行完成后自然退出(常用);设置状态量,让线程结束执行而退出;
有了这些基础的方法后,我们理论上是能开发多线程程序了,但如果用这么原始的方法去开发几万、几十万行代码的程序,这是一件让人无法想象的事。于是Java再进一步,给你我们提供了大量实用的API和框架。
线程间的资源共享
进程是操作系统分配和调度的基本单位,在当代面向线程设计的计算机结构中,进程是线程的容器;简单的说就是操作系统只负责把资源分配给进程,然后线程使用进程里的资源。既然线程使用的是进程里的资源,而不是仅仅使用线程独有的资源,如果有多个线程,那么自然就会存在一个资源共享的问题,否则临界资源同时被多个线程使用,那肯定是要出错了。为了解决资源的共享问题,JDK提供了很多方法,不过我只写我认为重要、有用的。
1、Lock:
解决临界资源问题最直接的方法就是加锁,Lock作为synchronized功能的扩展,比synchronized更加灵活好用,并且也扩展了一些相关的子类针对特定场景的使用,在JDK底层中有大量应用;不过这还是属于基础方法,使用复杂,建议谨慎使用。
2、线程池:
为了避免频繁的创建和消耗线程,我们可以对创建的线程进行复用。这里和数据库连接池类似,当系统使用数据库时并不是创建一个新的连接,而是从连接池中获得一个可用的连接;反之,关闭连接时也不是真的关闭这个连接,而是将连接还给连接池,通过这种方式,既可以节约创建和销毁对象的时间,又可以控制系统连接的数量,保证系统稳定运行。使用线程池我们也可以获得类似的好处,将线程统一管理,既有利于系统运行效率的提高,也可以减少不同用户独立创建、销毁线程可能带来的风险,从而提高系统运行的稳定性。
为了实现线程池的功能,JDK提供了一套Executor框架,帮助我们实现线程池的功能。在实际应用中我们也应该使用线程池来对我们的线程进行管理,避免对线程的手动管理。关于Executor具体内容和使用会在附录中有一篇单独介绍。
3、并发集合:
在Java基础里我们知道,JDK给我们提供了一套常用的集合类,比如其中List、Map就给我们的编程带来了极大的方便。很不幸的是,这些常用的集合类都是非线程安全的,比如两个线程都在使用map.put(k, v)时,可能只有一个线程成功,这对于我们程序肯定是无法接受的。为了在多线程中我们也能方便的使用这些常用的集合类的功能,JDK提供了几套不同的解决方案。
方法一:collections.synchronizedMap(new HashMap());、collections.synchronizedList(new LinkedList<String>()),使用collections集合工具包里的方法将非线程安全的集合类包装成线程安全的集合类。该方法的内部实现是mutex状态锁,可以满足线程安全要求,但性能不优;
方法二:JDK单独为实现线程安全重新提供了线程安全的集合类,ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、BlockingQueue等,这些集合类对多线程的支持良好,性能优异,唯一不好的就是实现复杂。
关于并发集合的具体内容和使用也会在附录中有一篇单独介绍。
4、锁的优化:
锁是最常用的同步方法之一,在高并发的环境下,激烈的锁竞争会导致程序性能下降。这里解释下高并发下,锁与性能的关系。通过上面论述我们知道,高并发其实就是通过多线程,让多个CPU核同时运行,来加快运行速度;但是由于锁的存在,线程需要获取锁资源后才能运行,如果有多个线程同时需要锁资源,那么同一时刻只能有获取锁的那个线程运行,其它没能获取锁资源的线程则需要等待;这样就不能完全发挥出多线程的优势,系统性能也就降低了。所以为了提高高并发的性能,需要我们对锁进行合理优化。
下面介绍几条锁优化的建议。
减小持锁时间:比如,fun(){ A(); B(); C(); },如果只有B()方法是需要同步的,那就只对B()加锁就可以了,而不需要对整个fun()加锁,这样线程对锁的持有时间明显变短。
减小锁粒度:比如,ConcurrentHashMap,我们HashMap内部是分段,当我们get()、put()时,一次只会操作其中的一段,这个时候显然我们只需要对需要操作的这一小段加锁就可以保证线程安全了;相对于对HashMap加锁,对段的加锁颗粒更小,锁的竞争也就降低了。
读写分离锁:比如,i是临界资源,但相对于读取,i并不是临界资源(如果不修改i,n个线程同时读取i,读到的都是i,不会有线程安全问题);但是写i就是临界资源了,需要锁的控制(因为存在一个写的覆盖问题,以及因为写,造成的脏读的问题);所以将读和写分离,能减少锁的竞争。
锁分离:比如,LinkedBlockingQueue,take()和put()方法,虽说都修改了队列,但它们修改的位置不同,理论上两者不存在冲突,所以用两个锁分别控制take()和put(),这样削弱了锁的竞争。
锁粗化:因为申请锁、释放锁都是要花时间的,比如,一次操作里确实是一直需要锁A,那么就没必要一会申请锁A,一会儿又释放锁A,就一直拿着用完就得了。
关于锁的优化,就两个方向,一个是怎么样让锁更小(锁的时间、锁的对象、锁的功能),让锁更小的目的就是降低系统对锁的竞争;另一个方向就是让锁更大,让锁更大的目的是减少不必要的加锁、释放锁的时间。性能优化需要我们根据运行的实际情况来改进,那个地方阻碍了系统性能的发挥,我们就应该做相应的优化。
一种很好的新思路ThreadLocal。
之前我们一直讲锁的优化,但想想如果我们直接都不用锁了,那不就不存在竞争,性能最好了。这要从锁的起源说起,加锁我们是为了解决临界资源的问题,如果我增加临界资源的数量,一直增加到临界资源的数量和线程数量一样多,这样不就不存在临界资源竞争的问题了,于是就可以不需要锁,自然就不需要解决锁的问题了。关于ThreadLocal的方式,说出来很容易懂,只是有时在实际中往往会忽视掉。还有一点要注意的是,ThreadLocal只是提供一个简单的容器,为每个线程都分配资源需要业务层代码来完成。这里也体现了计算机里的一个重要思想,即时间和空间是可以互换的,只是使用者需要控制好转换的方向和量。
5、无锁策略:
无锁策略实质上是一种乐观锁思想,即认为线程是不会冲突的,既然不会发生冲突,那自然就不需要等待、加锁来保证线程安全了,所有的线程都直接执行就完了。如果真的发生冲突了,那发生冲突的线程重新执行,直到没发生冲突执行完成。
那问题是我们怎么实现没冲突就执行完成线程,有冲突就重新执行线程呢?这里就使用到一个重要技术:比较交换技术(CAS Compare And Swap)。
CAS算法:参数CAS(V,E,N),V要更新的变量,E变量的预测值,N新值;当V的值和E的值相同时,则将V的值更新为N的值,不同则放弃更新。通俗的说就是只有当变量当前的值和我预测是一致时,我才修改。而且现在大多数处理器已经支持原子化CAS指令,性能优异,而且线程安全。
有了CAS技术后,就可以来实现我们的无锁策略了:当我在线程中需要修改一个共享变量时,可以先读出变量,缓存到线程独有的内存中,再对变量运算后;把变量、缓存值、运算后的结果作为参数传给CAS;如果CAS执行成功,那我们线程就完成了,如果CAS执行失败,那么我们就放弃本次操作,重新执行线程。
从无锁策略原理我们知道,无锁策略既没有锁的开销,也不会切换线程,并且还有处理器的底层支持,显然在高并发性能上是优于有锁的程序;而且CAS策略也保证了每次一定会有一个线程执行成功,这样也不存在死锁、阻塞的问题。
写了一大堆无锁策略的优点,确实在并发性能上很有优势;缺点呢就是我没写,并且也不打算写的,实现复杂。JDK提供了无锁策略实现的并发包,在java.util.concurrent.atomic工具包里有相关方法,可以直接使用;关于无锁策略的具体实现,参照书上Vector吧。
线程的协同运行(第5章)
在上节我们详细讲解了怎么解决线程间的竞争问题,保证了多线程的安全,使多个线程能够同时稳定运行。那么怎么让这些线程更好的协同运行来完成我们大并发的任务呢?这节我们将讲解一些常见的并行模式的设计方法,通过对这些经典的设计模式的学习,来提高我们并行程序的设计能力,让我们设计出来的多线程运行的更好,最大的发挥出多线程的性能;最后通过介绍Java重要的NIO和AIO让我们对多线程的使用有个更深入的认识。
1、常见的并行模式
单例模式:作为最常见的、最简单的模式之一。核心思想就是将构造方法私有化,保证无法创建该类对象;然后对外提供一个获取静态实例的静态方法,将静态实例的创建隐藏到类里面完成;这样就确保了系统只有一个该静态实例。
不变模式:不变模式的核心是对象一旦创建,就不允许修改。实现的关键字是final,用来修饰class时表示该类不能被继承,防止通过继承子类的修改;用来修饰变量时,表示该变量只许在对象构造时被赋值一次,不允许再修改。不变模式天生对多线程友好,比如说Java的基础数据类型和String使用的都是不变模式,使用不变模式后,所有的实例方法均是不需要进行同步操作(例如:String.subString(),在截取字符串时就不用担心别的线程此时修改了该字符串;Long l=0L,也不用担心受别的线程影响,但long l=0L就没那么幸运了),保证了多线程下的性能。
生产者-消费者模式:生产者-消费者的核心思想就是通过共享内存缓冲区,避免了两者的直接通信,实现生产者、消费者解耦。这也是并行模式的核心设计思想之一,实现线程的解耦。通常用来充当共享缓冲区数据结构有BlockQueue(BlockQueue是阻塞队列,ConCurrentLinkedQueue为非线程阻塞队列),即生产者负责将数据写到BlockQueue里,消费者直接到BlockQueue里读取数据,BlockQueue本身是JDK提供的一款多线程安全的阻塞队列,能较好的实现线程安全和同步。但BlockQueue底层还是基于锁的实现机制,完全使用锁和阻塞等待来实现线程的同步,高并发性能并不是十分优异。我们知道ConCurrentLinkedQueue底层是用CAS策略实现的,性能优异,但这里并不适合用来做共享缓冲区,主要是因为ConCurrentLinkedQueue是非线程阻塞,如果用来做缓冲区,需要业务层代码控制循环监控队列,使用不方便,也影响了性能。那么有没有一种使用简便,底层也是基于CAS策略实现的共享数据通道呢?Disrupt无缓存框架提供了我们需要的共享数据通道,具体实现太复杂,就不研究了,使用十分方便,参加下官方API和样例即可。
Future模式:Future模式核心思想是异步调用,说到异步调用大家最熟悉的肯定是$.ajax();Future模式原理与之相同,唯一不同的是Future会立即返回一个伪造的数据,等实际业务处理完成后再返回真实的数据。这种异步调用,完全无阻塞,在大并发下能更好的提高系统性能。关于异步与同步的好处,本身就是一个很大的话题,这里我们知道它带来的好处,以及提高系统性能的原因就好了。
2、网络NIO和AIO
IO:IO是计算机经典的问题之一,我们知道计算机运行需要连接不同的设备,但这些设备的速度完全不在一个等级,其中IO是最突出的例子。例如CPU一个时钟周期0.3ns,cache(1-10ns),内存100ns,SSD固态硬盘50*1000ns,普通磁盘500*1000ns,网络IO速度和普通磁盘一个等级。上面是整理的当前各种电脑器件的大致速度。CPU的速度与磁盘速度相差都达到了6个数量级,在Java标准IO中,一个线程负责网络数据的读取、数据的运算、结果的返回,这样如果程序中有大量的IO操作,那么CPU基本上就长时间处于无效的等待状态了(等待IO设备读取数据),极大的降低了系统的性能。
NIO:NIO是替代Java IO的一套新机制,核心思想就是通过实现IO准备(即数据写到缓冲区,但不一定全部读写完成)和线程处理的分离,来解决IO速度与CPU速度不匹配的问题。具体实现策略就是,用Channel作为数据缓存工具,单独创建一个或者少量的线程专门负责Channel的管理,当Channel里的数据准备好了后会通知对应的线程进行数据处理。这样就避免了每个线程都去等待IO的问题,把IO等待操作交给少量的线程去处理,实际的线程只是等到IO数据准备好了后再开始执行,这里很大的提高了系统的性能。
AIO:NIO在网络操作中提供了非阻塞方法,但NIO的IO行为还是同步的,只是这个非阻塞是由少数的“管理线程”来实现的。AIO则在NIO的基础上更进一步,它不是IO准备好时通知线程,而是在IO操作已经完成后(完成了整个IO操作),在给线程发通知。此时线程是完全不是阻塞,我们的业务逻辑将变成一个回调函数,等待IO完成后,由系统自动触发。很明显AIO设计模式更符合IO业务场景的需求,实现起来也更简单,是我们应该重点理解的。插说一句,实现AIO回调很明显的用到了Future模式,这也是对理论的很好的一个应用。
哪里用到Java高并发?
这里借用下Linus Torvalds关于并行模式的观点,Linus认为并行模式只有在服务器端开发和图片处理这两个领域有广泛的应用。而Java作为最广泛的服务器端开发语言之一,Java高并发在服务器端的开发自然是大有用处。当前互联网系统主流采用的都是《服务器--客户端》模式,这时候往往是一个服务器对应成千上万的客户端,此时对服务器的性能要求极高,要求服务器在短时间内处理大量的客户端的请求,于是也就有了我们高并发的要求,这也是Java高并发当前的主要应用。
怎么使用Java高并发?
即使我们学习并掌握了上面关于Java高并发这么多的知识,但写出一个正确的、高性能并且可扩展的高并发程序依然是很困难的,在实际开发中更多的还是使用优质的框架来做开发,这样可以大大的降低我们开发难度和提高程序的稳定性、安全性、性能等等,在附录中会讲解Akka这个高并发框架。可能初学者会好奇,既然不用,那还大篇幅的去将高并发的实现原理、实现方法干嘛,直接上Akka框架不就得了。其实这里是对编程的一个误解,以为编程就是写代码,就是调API,其实不然,核心还是设计、分析、综合处理等,代码只是具体的一种实现方法。比如如果对多线程没有一个实质的了解,可能在业务的拆分上就很不合理,这样是用什么先进的框架也弥补不了的,这也是花大篇幅去解释基本原理的原因。
书中重要知识附录
这部分是单独讲解某个内容的,以讲清核心原理为主,中间的部分方法、以及知识点有很多的忽略,详情请参数书上的对应章节。
线程池
Executor框架
Executor采用工厂模式提供了各种类型的线程池,是实际使用中我们就直接从Executor中获取我们想要的线程池,拿来直接使用即可。下面简单的介绍下Executor提供的五大类线程池。
newFixedThreadPool()方法:该方法返回一个固定数量的线程池;
newSingleThreadExecutor()方法:该方法返回只有一个线程的线程池;
newCachedThreadPool()方法:该方法返回一个可根据实际情况调整线程数量的线程池;
newScheduledThreadPool()方法:该方法返回一个ScheduleExecutorService对象,可以指定线程数量;
newSingleThreadScheduledExecutor()方法:该方法返回一个ScheduleExecutorService对象,线程数量为1;
上面线程池分两大类,一类是无计划的任务,提交就会执行,这类业务应用很广泛;另一类是有计划的任务,提交后会按照设定的规则去执行,这种应用的场景相对少一些,不过对有些业务是必须的,比如:我们系统晚上需要清空用户的状态、优惠券到期了自动提醒等等,用到的就是这类计划任务,常见的有spring task。下面分别举例演示两种线程池的使用。
1、newFixedThreadPool()固定大小的线程池
package concurrent.threadpool;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadDemo {
public static void main(String args[]) {
// 创建任务对象
MyTask task = new MyTask();
// 创建固定数量线程池
ExecutorService es = Executors.newFixedThreadPool(5);
for(int i=0; i<10; i++) {
// 向线程池里提交任务
es.submit(task);
}
}
public static class MyTask implements Runnable {
@Override
public void run() {
System.out.println(System.currentTimeMillis() + ": Thread ID: " + Thread.currentThread().getId());
try {
Thread.sleep(1000);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
2、newScheduledThreadPool()计划任务
package concurrent.threadpool;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduleThreadDemo {
public static void main(String args[]) {
// 创建任务对象
MyTask task = new MyTask();
// 创建任务调度计划对象
ScheduledExecutorService ses = Executors.newScheduledThreadPool(100);
// 设置任务与执行计划
ses.scheduleAtFixedRate(task, 0, 1, TimeUnit.SECONDS);
}
public static class MyTask implements Runnable {
@Override
public void run() {
System.out.println(System.currentTimeMillis()/1000 + ": Thread ID: " + Thread.currentThread().getId());
try {
Thread.sleep(1000);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
大家可以看的出来这两段代码都非常简单,都是创建任务对象、获取Executors提供的线程池对象、将任务对象绑定到线程池对象上。通过Executors提供的不同策略的对象,就能快速实现我们对线程的控制。
线程池的内部实现
Executor为我们提供了功能各异的线程池,其实其内部均是由ThreadPoolExecutor实现的,我们详细了解下ThreadPoolExecutor实现原理不但对我们使用理解Executor提供的线程池大有帮助,也让我们能根据实际情况自定义特定的线程池。
首先介绍下ThreadPoolExecutor类最核心的构造方法,
public ThreadPoolExecutor(
int corePoolSize, // 指定线程池中线程的数量(总线程量可大于等于这个值)
int maximumPoolSize, // 指定线程池中最大线程数量(总线程量不可能超越这个数值)
long keepAliveTime, // 超过corePoolSize数量的空闲线程,存活的时间
TimeUtil unit, // keepAliveTime的单位
BlockingQueue<Runnable> workQueue, // 任务队列,被提交但为执行的任务
ThreadFactory threadFactory, // 线程工厂,用于创建线程
RejectedExecutionHandler handler // 当workQueue队列满的时候的拒绝策略
)
看到corePoolSize和maximumPoolSize的含义,应该很容易通过设置参数的不同,得到Executors提供的线程池对象。该方法一共七个参数,前四个很简单,我们都会使用,第六个一般使用的是JDK默认提供的,剩下的就只有workQueue和handler了。
例如:es.submit(task);在样例中提交了10次task,但线程只有5个,于是就有5个提交但没开始执行的任务存到了workQueue里啦。既然是一个存放任务的队列,我们知道实现队列的方式有多种,比如:ArrayBlockQueue、LinkedBlockQueue、PriorityBlockQueue等等,选择不同的队列就会带来不同的问题。ArrayBlockQueue,存在一个任务过多超出队列长度;LinkedBlockQueue,接受过多的任务可能会占用太多内存,造成内存崩溃等等。这里介绍下,newFixedThreadPool和newSingleFixedThreadPool使用的都是LinkedBlockQueue,newCacheThreadExecutor使用的SynchronousQueue队列。关于队列的选择是要根据实际情况来确定,这也是自定义线程池的核心。
handler:拒绝策略,实际上是一种补救措施,就是当超出了workQueue临界值了,我们怎么让我们的系统不至于崩溃。JDK内置的处理方法有AbortPolicy,抛出异常阻止程序(除非是安全性要求极高,否则在大并发情况下使用这种做法不是很明智);DiscardPolicy,丢弃无法处理的任务(如果允许丢弃,这是不错方案);DiscardOledesPolicy:也是丢弃任务,只不过丢弃的是队列最前的一个任务。由于上面策略都是实现RejectExecutionHandler接口,我们也可以实现该接口自定义拒绝策略。
自定义线程创建
package concurrent.threadpool;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolDemo {
public static void main(String args[]) {
// 创建任务对象
MyTask task = new MyTask();
// 获取自定义线程池
ExecutorService es = getMyThreadPool();
for(int i=0; i<20; i++) {
// 向线程池提交任务
es.submit(task);
}
}
// 自定义线程池,我们创建一个线程数固定的线程池
public static ExecutorService getMyThreadPool() {
ExecutorService es = new ThreadPoolExecutor(
// 设置线程池大小
5, 5, 0L, TimeUnit.MILLISECONDS,
// 设置缓存队列
new LinkedBlockingQueue<Runnable>(5),
// 设置线程工厂
Executors.defaultThreadFactory(),
// 设置拒绝策略
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString() + " is discard! "); // 输出日志后直接丢弃任务
}
});
return es;
}
public static class MyTask implements Runnable {
@Override
public void run() {
System.out.println(System.currentTimeMillis() + ": Thread ID: " + Thread.currentThread().getId());
try {
Thread.sleep(1000);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
从上面程序的运行结果我们可以看到,10任务被执行(因为线程池有5个线程,缓存队列也能缓存5个),10任务被丢弃,符合我们预期。这个样例十分简单,只是通过这个样例展示怎么去自定义一个线程池,具体的线程池定义,我们要根据实际情况,设置传入的参数即可。
Fork/Join框架
上面我们详细介绍了线程池的原理,还是那句话,学底层原理是拿来做设计,并不是让直接去使用。Fork/Join在线程池的基础上,做了更近一步的封装,对线程的开启、分发做了优化,使系统更稳定。另外补充下,Fork/Join还涉及到关于多线程的一个重要思想:“分而治之”,通俗的将就是将复杂的问题拆分成多个简单问题,分开处理。下面通过一段样例了解下Fork/Join。
package concurrent.threadpool;
import java.util.ArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
public class ForkJoinDemo extends RecursiveTask<Long> {
private static final int THRESHOLD = 10000; // 门阀值,当大于这个值才进行拆分处理
private long start; // 数列的起始值
private long end; // 数列的结束值
public ForkJoinDemo(long start, long end) {
this.start = start;
this.end = end;
}
// 对数列进行求和
@Override
protected Long compute() {
// 定义求和对象
long sum = 0;
if((end - start) < THRESHOLD) {
// 当求和数列的数量小于门阀值,则直接计算不需要分拆
for(long i=start; i<=end; i++) {
sum += i;
}
}
else {
// 当求和数列的数量大于门阀值,则拆分成100个小任务
ArrayList<ForkJoinDemo> subTasks = new ArrayList<ForkJoinDemo>();
long step = (start + end) / 100; // 计算每个小任务的数列的数量
long firstOne = start; // 动态记录小任务数列的起始值
// 将任务拆分,并提交给框架
for(int i=0; i<100; i++) {
long lastOne = firstOne + step; // 动态记录小任务数列的结束值
if(lastOne > end) {
// 当队列结束值大于end,需要将lastOne设置为end
lastOne = end;
}
ForkJoinDemo subTask = new ForkJoinDemo(firstOne, lastOne);
firstOne = firstOne + step + 1;
// 将子任务添加到数组中
subTasks.add(subTask);
// 执行子任务,这里是将子任务交个Fork/Join框架,什么时候开始执行由框架自己决定
subTask.fork();
}
// 将子任务的计算结果汇总
for(ForkJoinDemo st : subTasks) {
sum += st.join();
}
}
return sum;
}
public static void main(String args[]) {
// 创建任务对象
ForkJoinDemo task = new ForkJoinDemo(0, 500000L);
// 创建Fork/Join线程池
ForkJoinPool forkJoinPool = new ForkJoinPool();
// 将任务提交给线程池
ForkJoinTask<Long> result = forkJoinPool.submit(task);
try {
// 取出运算结果
long sum = result.get();
System.out.println("sum: " + sum);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
这段多线程求和代码本身没啥用,主要是通过这段代码理解分拆的思想,和熟悉框架的使用。
并发集合:
有一种说法,程序就是“算法+数据结构”,而这类并发集合就是JDK为我们提供的数据结构,这些线程安全的并发集合主要有链表、HashMap、队列等。
1、concurrentHashMap
concurrentHashMap主要是使用了“锁的优化方案--减小锁颗粒度方案”对mutex互斥变量进行了优化,提高了并发性。
我们知道为了让HashMap线程安全,JDK采用的策略是,对HashMap添加mutex变量,所有的对HashMap的操作均要先获得锁,这样就实现的HashMap多线程的同步;concurrentHashMap也是使用相同的策略,也是添加互斥变量mutex来实现线程的同步,不同的是concurrentHashMap的数据结构和HashMap有略微的差别。
我们可以理解为concurrentHashMap是由16(默认为16个)个小HashMap来组成的,然后对每个小HashMap添加互斥变量来实现小HashMap的多线程安全,这样concurrentHashMap也就是线程安全的了。因为Map的重要方法get()和put()方法一次只会操作一个元素,也就是只会修改16个中的某一个HashMap,这样我们只需对当前被修改的HashMap加锁就能保证线程安全了。
但是Map也有操作全局的方法,比如size()方法,要获取当前Map里元素的总数,这时候就要对全局加锁,相当于是16个HashMap的锁都获取到了才能计算(当然在实际中JDK并没有这么去做,而是使用了优化方案,但concurrentHashMap的size()方法也比HashMap的size()方法慢)。这里也揭示了一个问题,就是使用减小锁颗粒的优化方案时,过多的使用获取全局信息的方法会降低性能。
2、concurrentLinkedQueue
concurrentLinkedQueue是在高并发环境中性能最好的队列。之所以有很好的性能,是因为采用了CAS无锁策略来保证线程安全的。关于CAS实现原理、性能好的原因在锁的优化里面有详细论述,这里就不再重复了。
3、CopyOnWriteArrayList
CopyOnWriteArrayList是适合用于读操作远远大于写操作场景的链表。首先我们来了解下CopyOnWriteArrayList的实现原理,其实在名字上已经展现了大半;链表采用无锁机制,不过这里使用的不是CAS策略,而是不变模式,当有读操作,直接读链表就可以了;当有写操作,不去直接修改链表,而是将链表复制一份,然后修改复制的链表,修改完成后直接替换掉之前的链表,这类似于Java中String的处理。
这样确实保证多线程安全,不过我们也看的出来,这样做代价很大;每修改一次,就要对链表全部复制,这是非常占内存和浪费CPU的;只有当写操作很少时,这种方案才有价值,当写操作较多时,这种方案就会因为频繁复制对象而大大降低系统性能。
4、BlockingQueue
BlockingQueue是一个专门用来做数据共享通道的接口。上面提到concurrentLinkedQueue是性能最好的队列,但它并不适用用来做数据共享通道;因为数据共享通道并不仅仅提供数据存储的功能,还要做为线程的桥梁,实现线程间的协同运行等功能,concurrentLinkedQueue显然没有这样的功能。
BlockingQueue之所以适合用来做数据共享通道,其核心还是在Blocking(阻塞)上。BlockingQueue提供了阻塞的put()和take()方法,当向通道写、读数据时,如果数据通道为空或者已满,它们会等待满足条件了后再执行操作,这样就较方便的实现了读和写线程的协同运行。生产者-消费者就是BlockingQueue使用的经典案例。
5、SkipList
SkipList是一种可以用来快速查找的数据结构,结构比较奇特,总的来讲采用的是分层的链表,可以看做是为适用多线程改良的平衡二叉树吧。在功能上concurrentShipListMap和concurrentHashMap类似,但用ShipList实现的Map是有序的,在有顺序要求的业务中,当然首推适用concurrentShipListMap。
在性能上,当并发量不高、数据量不大时,concurrentHashMap总体性能略好些;当并发量很高、数据量很大时,concurrentShipListMap性能更好;也就是说concurrentShipListMap更适合大并发。concurrentShipListMap一个缺点就是占内存较多,因为其实现需要冗余记录一些节点,这里也是使用计算机里总要的思想:用空间换时间。
总的来说这些常见的集合都有自己的优势和缺陷,在使用时我们要根据实际业务场景选择合适的集合,我们需要的是熟知其中的原理,这样才能让我们做出合理的选择。
Akka介绍:
还是回到高并发这个起点上来,通过上面对Java多线程的了解,我们知道怎么去合理使用服务器上的多核CPU;但是实际高并发环境中,往往一台服务器是不够的,需要多台服务器的联合使用才能完成任务,这个时候或许你会想到分布式、集群等。而Akka就提供了分布式这样的功能,通过Akka不仅可以在单机上构建高并发程序,也可以在网络中构建分布式程序,并提供位置透明的Actor定位服务。总的来说Akka采用的是Actor和消息系统来实现分布式的,下面就简单的介绍下Actor模型、消息投递、消息接受、消息路由等知识,最后用一个简单样例演示下Akka的使用方式。
插说一段:多线程、分布式、集群,作为解决高并发问题的方案(注意:并行模式是设计模式,并不是解决方案),经常一起出现,其实给初学者带来了很大的困惑,很多初学者并不了解它们间的关系,很长一段时间我也是把它们当同一个东西来看的,其实不然。既然上面提到了这些概念,那我还是对它们分别做一个简单说明吧。
多线程:一台服务器上运行多个线程,解决的重点是怎样提高单个服务器内存、CPU等资源的使用率问题;
分布式:一个业务拆分成多个子业务,部署在不同的服务器上,解决的重点是业务在多台服务器高效协同运行的问题;
集群:同一个业务,部署在多个服务器上,指的是系统对多硬件的组合使用方式;
多线程我们可以看做是对程序的优化,提高程序本身的性能;分布式可以看做是一个架构,因为只有是分布式架构的系统才支持利用集群的方式来对系统扩展;集群就是对分布式系统横向扩展;所以一般集群与分布式是一起出现的,而两者与多线程的关系则不大。因为这三者都提高了系统的性能,而且解决的问题的方向也不同,所以实际生产中都是将它们一起使用,来解决大高发问题。
Actor模型:A Model of Concurrent Computing in Distributed Systems(分布式系统中的并行计算模型)。Actor模型我们可以看做是一个函数,只不过比函数要复杂,因为还包含内置状态,但它具有函数最重要的特征,就是无论怎么执行,也无论在哪里执行,只有参数、环境一定,执行结果就是确定的,这也是实现分布式的基础。
消息系统:整个Akka应用是由消息来驱动的,我们可以简单的理解,Actor是具体的执行函数,消息则是去调用这些函数;但这与一般的函数调用又不同,不是通过方法名来调用,而是给需要调用的Actor发一个消息就可以了,Actor收到消息后,就会自己去执行业务。这里就涉及到消息投递、消息接受、消息路由等。
消息投递:函数我们知道,参数确定了执行结果也就确定了,Actor模型类似于函数,我们想要一个确定的结果,那么我们投递的消息就应该是确定的,所以这里强烈建议消息使用不变模式,防止消息在传递的过程中被修改。另外一个重要问题就是消息的投递策略,Akka采用的是至多一次投递策略,这种策略中,每条消息最多会被投递一次。在这种情况下,可能偶尔会出现消息的投递失败,而导致消息丢失,这就需要我们在业务层维护消息的可靠性,采用这种策略主要是因为性能最高、成本最低。
消息收件箱:Akka框架为我们提供了一个叫做“收件箱”的组件,使用收件箱,可以很方便地对Actor进行消息的发送和接受。
消息路由:其实就是用来群发消息的,通过收件箱我们可以方便的给某个Actor发送消息,但是在高并发环境中,往往存在大量的功能相同的Actor在并行处理业务,这个时候我们需要把消息合理的发送给这些Actor,首选就是群发了,而消息路由就是用来帮我们实现群发消息功能的。消息路由提供了不同的发送规则,在创建消息路由时可以指定合适的规则。
1、粒子群算法
粒子群算法与Actor模型有着天生的切合度,下面就正好用这经典的算法来演示Akka的使用。当然Akka应用场景非常多,粒子群算法只是某一个应用而已,介于我对Akka的了解也不深入,就不去标新立异,还是老老实实的先模仿吧。
粒子群最典型的应用就是用来寻找最优化方案。本质就是就是穷举法,比如解决一个问题的方案有1亿种,如果我们把这1亿种方案的结果都算出来,然后对结果进行比较,自然是能找出最优方案的;但由于计算机的运算能力有限,只能运算出一万种方案的结果;这个时候我们就只能随机计算一万种方案,然后找出其中最好的结果,做为我们的次优解,虽说这并不是最好的方案,但这是我们能获取的最接近最优解的方案,在很多应用中,这种方案也是能被接受的(很典型的就是天气预报,虽说不能100%准确,但也有很大的价值)。
随机方案的选取其实是这种最优化计算的核心,Akka框架提供的运算支持只是基础,但我们这里主要是为了演示Akka的使用方法,就不去讨论优化算法了。
比如有这么一个问题:
假设有400万资金,要求4年内使用完。若在任意一年使用x万元,则可以获取√x万元的收益(本金和收益均不能再使用);当年不用的资金可以存入银行,年利率为10%,这部分的本金和收益均能拿去使用。数学好的使用拉格朗日公式对方程组求解很快就能算出第一年使用86.19万元,第二年使用104.29万元,第三年使用126.29万元,第四年使用152.69万元,能获得43.09万元的最优收益。数学不好的也没事,现在不有了计算机么,下面让我们的计算机试试,看看能得到什么样的结果。
package concurrent.akka;
import java.util.Collections;
import java.util.List;
/**
* 可行的解决方案类,记录每年投资的钱,和该种方案获得的收益;
* 因为每种方案都是固定的,不会改变,所以这里都设置成不变模式;
*/
public final class PsoValue {
// 该方案的收益
final double value;
// 该方案每年投资的钱
final List<Double> x;
public PsoValue(double v, List<Double> x2) {
this.value = v;
this.x = Collections.unmodifiableList(x2);
}
public double getValue() {
return value;
}
public List<Double> getX() {
return x;
}
public String toString() {
StringBuffer sb = new StringBuffer();
sb.append("value: ").append("-->").append(x.toString());
return sb.toString();
}
}
package concurrent.akka;
/**
* 全局最优方案类,记录全局最优的方案;
*/
public final class GBestMsg {
final PsoValue value;
public GBestMsg(PsoValue v) {
value = v;
}
public PsoValue getValue() {
return value;
}
}
package concurrent.akka;
/**
* 个体最优方案类,记录个体最优的方案;
*/
public final class PBestMsg {
final PsoValue value;
public PBestMsg(PsoValue v) {
value = v;
}
public PsoValue getValue() {
return value;
}
}
package concurrent.akka;
import java.util.List;
/**
* 投资方案适应度类,计算出投资方案的收益,收益越大,我们认为适应度越高
*/
public class Fitness {
public static double fitness(List<Double> x) {
double sum = 0;
for(int i=1; i<x.size(); i++) {
// 获取对于年投资的收益
sum += Math.sqrt(x.get(i));
}
return sum;
}
}
package concurrent.akka;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import akka.actor.ActorSelection;
import akka.actor.UntypedActor;
/**
* 粒子类,也是粒子群算法中最核心的类;
* 在粒子群算法中,为了提高效率,选择执行方案时并不是完全随机的;
* 而是让粒子先随机分布在整个区域内单独查找,看谁查找的方案适配度最优,
* 并将这最优方案发送给大家,大家再以这个最优方案为方向,
* 按一定算法选择执行方案,继续查找,以此类推,直到结束。
*
* 如果不明白的,就直接把Bird看做是一个执行了多种方案的类,
* 重点理解怎么使用Akka让这些Bird传递消息就可以了。
*/
public class Bird extends UntypedActor {
// Bird个体最优方案
private PsoValue pBest = null;
// 全局最优方案
private PsoValue gBest = null;
// 粒子在各个维度上的移动速度;每一年的投资认为是一个维度,一共4个维度(这里数组长度为5,是为从1-4的角标,方便代码阅读)
private List<Double> velocity = new ArrayList<Double>(5);
// 粒子的初始化位置
private List<Double> x = new ArrayList<Double>(5);
// 创建生成随机数对象
private Random r = new Random();
// 创建粒子时,初始化粒子的位置,每一个位置可以看做是一种方案
@Override
public void preStart() {
// 初始化velocity和x
for(int i=0; i<5; i++) {
velocity.add(Double.NEGATIVE_INFINITY);
x.add(Double.NEGATIVE_INFINITY);
}
// 第一年投资资金 x1<=400
x.set(1, (double)r.nextInt(401));
// 第二年投资资金 x2<=440-1.1*x1(x1:第一年投资资金)
double max = 440 - 1.1 * x.get(1);
if(max < 0){
max = 0;
}
x.set(2, r.nextDouble() * max);
// 第三年投资资金 x3<=484-1.21*x1-1.1*x2
max = 484 - 1.21 * x.get(1) - 1.1 * x.get(2);
if(max < 0) {
max = 0;
}
x.set(3, r.nextDouble() * max);
// 第四年投资资金 x4<=532.4-1.331*x1-1.21*x2-1.1*x3
max = 532.4 - 1.331 * x.get(1) - 1.21 * x.get(2) - 1.1 * x.get(3);
if(max < 0) {
max = 0;
}
x.set(4, r.nextDouble() * max);
// 计算出该方案的适应度(收益)
double newFit = Fitness.fitness(x);
// 得到局部最优方案(因为是第一个方案,肯定是当前最优方案)
pBest = new PsoValue(newFit, x);
// 创建局部最优方案消息
PBestMsg pBestMsg = new PBestMsg(pBest);
// 通过工厂获取消息发送对象
ActorSelection selection = getContext().actorSelection("/user/masterbird");
// 将局部最优方案消息发送给Master
selection.tell(pBestMsg, getSelf());
}
@Override
public void onReceive(Object msg) throws Exception {
// 如果接受到的是全局最优方案消息,则记录最优方案,并根据全局最优方案更新自己的运行速度
if(msg instanceof GBestMsg) {
gBest = ((GBestMsg) msg).getValue();
// 更新速度
for(int i=1; i<velocity.size(); i++) {
updateVelocity(i);
}
// 更新位置
for(int i=1; i<x.size(); i++) {
updateX(i);
}
// 有效性检测,防止粒子超出了边界
validateX();
// 重新计算适应度,如果产生了新的个体最优,就发送给Master
double newFit = Fitness.fitness(x);
if(newFit > pBest.value) {
pBest = new PsoValue(newFit, x);
PBestMsg pBestMsg = new PBestMsg(pBest);
getSender().tell(pBestMsg, getSelf());
}
}
else {
unhandled(msg);
}
}
// 更新速度
public double updateVelocity(int i) {
double v = Math.random() * velocity.get(i)
+ 2 * Math.random() * (pBest.getX().get(i) - x.get(i))
+ 2 * Math.random() * (gBest.getX().get(i) - x.get(i));
v = v > 0 ? Math.min(v, 5) : Math.max(v, -5);
velocity.set(i, v);
return v;
}
// 更新位置
public double updateX(int i) {
double newX = x.get(i) + velocity.get(i);
x.set(i, newX);
return newX;
}
// 有效性检测,防止粒子超出了边界
public void validateX() {
// x1
if(x.get(1) > 400) {
x.set(1, (double) r.nextInt(401));
}
// x2
double max = 440 - 1.1 * x.get(1);
if((x.get(2) > max) || (x.get(2) < 0)) {
x.set(2, r.nextDouble() * max);
}
// x3
max = 484 - 1.21 * x.get(1) - 1.1 * x.get(2);;
if((x.get(3) > max) || (x.get(3) < 0)) {
x.set(3, r.nextDouble() * max);
}
// x4
max = 532.4 - 1.331 * x.get(1) - 1.21 * x.get(2) - 1.1 * x.get(3);
if((x.get(4) > max) || (x.get(4) < 0)) {
x.set(4, r.nextDouble() * max);
}
}
}
package concurrent.akka;
import akka.actor.ActorSelection;
import akka.actor.UntypedActor;
/**
* 主粒子类,用户管理和通知全局最优方案
*/
public class MasterBird extends UntypedActor {
// 全局最优方案
private PsoValue gBest = null;
@Override
public void onReceive(Object msg) throws Exception {
if(msg instanceof PBestMsg) {
PsoValue pBest = ((PBestMsg) msg).getValue();
if((gBest == null) || (gBest.getValue() < pBest.getValue())) {
// 更新全局最优方案,并通知所有粒子
gBest = pBest;
ActorSelection selection = getContext().actorSelection("/user/bird_*");
selection.tell(new GBestMsg(gBest), getSelf());
// 打印最优方案
System.out.println(gBest.getValue());
}
}
}
}
package concurrent.akka;
import akka.actor.ActorSystem;
import akka.actor.Props;
public class PSOMain {
public static final int BIRD_COUNT = 1000000;
public static void main(String args[]) {
// 创建Actor的管理和维护系统
ActorSystem system = ActorSystem.create("psoSystem");
// 创建Master粒子
system.actorOf(Props.create(MasterBird.class), "masterbird");
// 创建Bird粒子群
for(int i=0; i<BIRD_COUNT; i++) {
system.actorOf(Props.create(Bird.class), "bird_" + i);
}
}
}
当粒子群的数量达到100w时,基本每次的最大收益都能达到43.05万之上了,与我们计算的理论值43.09非常接近,基本能满足我们的要求。还有一个值得注意的问题,这里创建了100w个粒子,如果是创建这么多线程,我这ThinkPad的笔记本估计早卡死了,但在测试的时候,电脑完全不卡,并且一分钟不到也就运算完了,可见在系统中是可以启用大量的Actor,提高了我们对并发的支持。