高性能编程论述
高性能编程
前言
首先说一下我为什么要写这篇博客。因为面试有提到这个,我当时直接说不懂(一方面当时心态很差,另一方面面试官的询问方式令我很反感。所以直接refuse了。小伙伴们千万别学我)。
所以,打算谈一谈我对java高性能编程方面的认识与总结。
首先,高性能编程不涉及架构层次。所以打算通过这篇文章,来了解架构提升系统性能的小伙伴要失望了。我将java高性能编程主要分为编码与网络两个部分(说白了,只关注编码,不提其它)。
其次,我们需要了解何为高性能。性能往往与系统的吞吐量,响应时间,并发量等息息相关。只有了解到这点,我们才可以对症下药。
网络部分:bio,nio,netty等,这部分在之前的《从bio到netty的演变》有所提及,这里不再赘述。
而编码部分,也是最多人关注的部分。我将它按层次分为:
- 数据结构(如string,stringbuffer,stringbuilder)
- 语言特性(如for循环的jit优化,并行流等)
- 算法(如分治算法,贪心算法等)
- 设计模式(如原型模式)
- 多线程(包括线程池,锁等)
- 扩展-特定机制(其实就是一些成熟的方案)
并发容器被归类到多线程中,而fork/join框架被归类到特定机制(当然,也可以归类到算法,多线程等。取决于看待它的角度)。
由于这其中每个分支,拆分出来都是很大的一块内容。所以这篇文章的目标只是给个方向而已,不会写得非常深入。
一,数据结构
这里的数据结构指的是,类中属性的数据类型,可以是java自带的数据类型,也可以是自定义的数据类型。
那么数据结构是如何影响性能的呢?这可以从我之前提到的高性能编码的六个层次去分析。这里不在展开,否则就是无限地递归分析了。
说得再多,不如举一些例子(好吧,其实这边的总结,我感觉不够好。划分的维度,我自己不满意而已)。
1.integer与int:
比如integer与int,在大量数据处理时,内部结构最好采用int类型,从而避免基本数据类型装箱拆箱带来的性能损耗。而这一点在《阿里开发手册》中也有提及,所以在不知道怎么做时,跟着规范走,也不失为一种选择。
ps:记得当时,我回答面试官高性能编程问题时,就是从《阿里开发手册》规范谈起。然后面试官打断说,我不是问你规范,我就问你怎么高性能编程。当时心态本来就不好,我想引经据典,都不让我说。所以我直接一句,我不懂。不过不推荐大家学我,该回答还是要回答滴。
2.string,stringbuffer,stringbuilder:
比如string,stringbuffer,stringbuilder(这三个应该鼎鼎有名了)。
其中最常用的时string,其底层实现是一种常量字符串(也就是不可变字符串)。其底层就是final修饰的,所谓的string更新,其实是返回了一个新的字符串。由于是常量,所以也就没有什么线程安全问题了,故线程安全。
其次就是我目前用得最多的,就是stringbuiilder,其内部是维持一个可变长度的char[]。字符串更新,也是采用底层的native方法,所以效率非常高,是三个字符串类型中效率最高的。但没有做线程安全处理,所以线程不安全。
最后就是在一些高并发框架,中间件底层中常看到的stringbuffer,其内部基本与stringbuilder类似。不过不同的是其中多数方法采用了synchronized修饰,作为线程安全的处理,所以线程安全。
所以,就有了以下的使用场景:
- string:针对于字符串几乎没有变动的操作,在字符串变动方面操作是三个中最慢的(除非两个产生扩容)。但固定字符串方面,确实是调用最快,占用空间最少的。
- stringbuilder:在单线程下,存在字符串变动的情况下,速度最快。
- stringbuffer:在多线程下,存在字符串变动的情况下,速度最快。
如果表示上面的记不住。那就这样记:微量使用,用string;单线程最快,是stringbuilder;多线程安全,是stringbuffer。
其实数据结构,还有nio的bytebuffer,netty的bytebuf等,可以聊。不过这里不再赘述。
二,语言特性:
这里的语言特点,指的是java语言的一些高级特性(我也不知道算不算高级,不过确实有不少人不清楚,所以就这样写吧)。
那么语言特性是如何影响性能的呢?其实,其中涉及到语言的底层实现(这个实现,其实也可以按照六个层次分析)。
继续举例子。
1.for循环jit优化:
这个部分,涉及jvm的jit编译。简单说一下,jit是一种即时编译手段。java代码一般采用解释,但在遇到重复执行代码块等,会进行编译,从而提升速度。其中又涉及到无数人深恶痛绝的三大重排序(编译器,指令,内存)之一-jit重排序。
扯远了,收回来。那么for循环中的循环体,当然属于重复执行的代码块喽。所以for循环会被jit优化,提升运行速度。所以在有些场景下,别觉得for循环很low,觉得迭代器(了解迭代器模式,应该知道迭代器模式本身,不存在循环体这样的代码块),stream看着高级。在所有场景下,for循环效率高于迭代器,在多数场景下,for循环效率高于stream。
当然,由于性能并不是系统的唯一质量属性,所以站在扩展性等方面,我们也需要对iterator等正确看待。没有最好的技术,只有更合适的技术。
2.stream:
stream作为java8新引入的特性,不少人对它了解甚少。看到别人处理集合的stream代码,简直看得口水都下来了,想着自己什么时候才能写出这样优雅的代码。
遥想当初,我也是看着大佬处理订单集合的代码,流口水,果断去学习这个,然后。。。扯远了,拉回来。
前面已经提到了,在多数场景下for循环的效率高于stream。只不过for采用的是外部迭代,而stream采用的是内部迭代。但是stream和迭代器一样,没有类似循环体的代码块,所以没有这方面的jit优化。所以在正常应用场景下,两者存在一定的差距(数据量大了后,两者的差距也在10%之内)。
但是,stream有一个方法-parallel()。这个方法就厉害了,它是一个并行流。说白了,就是多线程处理当前流,通过多线程方法,将当前流拆分为多个流,来并行处理。
是不是感觉追根揭底,还是能归类到之前提到的六个层次中。
既然并行流效率这么高,为什么还用for循环呢?
因为并行流有着诸多限制。首先它内部是采用了多线程的处理手段,所以多线程的约束它都有(如cpu核心数带来的限制等)。其次它对流的处理必须是可以并行的,如过滤,转变,提交等。但是如排序,findfirst这样需要整个流的操作,就无法实现(或者说无法获得正确结果),或者实现效率极低。
所以,在一些可以并行操作的情况下,并且数据量较大,那么推荐使用并行流处理。优雅而高效。
3.迭代器:
这里提一下迭代器,否则大家就抛弃这个了。
迭代器的好处:自定义迭代逻辑,无返回函数的集合处理等。
其实迭代器,我用得并不多,一方面是一开始看到有前辈这样写,所以学习了一下这种写法。不过后来大多用for,以及stream了。另一方面,就是面试问到如何通过无返回方法,修改集合元素。我当时只想到了java方法参数不是引用传递这个点,解决还整没想到。面试官后来告诉我,可以通过迭代器实现。囧
三,算法:
算法这部分内容就海了去了。而且以我现在的算法积累,感觉并不能很好地解释这个问题。所以简单举些例子吧,以后有机会,再深入阐述算法(等我深入学习后,计划在明后年深入)。
比如排序算法,这也算是算法实现比较多的。感兴趣的,可以看我在博客园c语言算法部分总结的八大排序算法。
1.红黑树:
面试集合必问的hashmap与concurrenthashmap按不同版本(java8及java8以前)可以分为四个。其中不同版本之间最大的区别就是java8之后,两者底层增加了红黑树(再单个链表达到阈值后,就会树化)。
那为什么红黑树可以提高性能呢?
在原本的hashmap(以hashmap为代表)中,最初数据操作的时间复杂度平均为o(logn)。但是存在数据集中于数组特定位置的情况(hashmap底层数据结构是数组+链表,链表作为数组的元素),最糟糕情况就是数据集中在数组一个下标下,即所有元素在同一个链表。那么链表的数据操作的时间复杂度就变成了o(n)。
为了避免这一情况,新版本的hashmap增加了红黑树。在单个链表的数据量达到阈值后,则会将链表转为红黑树,从而确保在数据多的时候(即n较大时),时间复杂度依旧为o(logn)。
时间复杂度降低了,说明cpu占用时间降低了,即性能提升了。
2.分治算法:
分治算法作为一种及其重要的算法思想,可以说程序中很多地方都体现了这点。甚至有人总结分布式系统的高并发就是限流(非侠义的限流,保护下游服务)与分流(提高吞吐量),而分流也是分治思想的一种体现。
那为什么分治算法可以提高性能呢?其实分治算法也是一种空间换时间的体现。更深入的阐述,其实就是均衡。
这里简单阐述一下我对均衡的认识。早在架构相关的博客中,我就提到,架构设计就是一个权衡的过程。提高性能,可能就需要降低安全性。提高系统扩展性,可能就会带来系统复杂度的提升。这点可用于各个层次,包括设计模式也是这样的。以后有机会深入阐述这种思想。
举一个分治算法的体现例子。如fork/join框架,通过对现有任务的拆分,分开进行计算,最终汇总。其实本质所需要的算力并没有改变,但是可以充分利用多线程,或者cpu多核的特性(其实还涉及cpu资源的竞争,不再深入)。
再举一个分治算法的体现例子,正好也是我在阿里面试时遇到的一个问题,如何实现淘宝双十一交易额的实时统计(延迟不得高于1s)。我的实现思路,就是一方面利用redis内存数据库的高速特性(单台redis实例正常每秒都可以有几十万的读写)。另一方面就是利用分治算法,将统计任务进行拆分。
这里说一下我的思路吧。首先排除大数据,因为我不熟悉大数据,而且我面试的也不是大数据,最重要的是大数据底层也需要实现(不能面试时,就扔个名词)。其次通过redis来提供一个分布式系统的存储(分布式系统的存储无非缓存,数据库,文件系统,消息队列算半个吧)。这个时候,需要思考的是,虽然redsi可以提供足够的读写,但是在数据处理时,程序为了确保数据不被别的程序修改,一定需要加分布式锁。那么时间消耗就体现在了这里。参照concurrenthash分段锁,及其体现的分治算法。可以将交易额按照业务,地域,或者单纯的数字拆分为多个字段(如1000g个),然后分别计算,最后通过一个服务,按照交易额刷新时间进行统计(这里统计还可以使用fork/join框架)。当然,这并不是一个完整的程序,其中还有许多的细节,如动态管理等。但是这样的思路是可行的。
对算法感兴趣的,可以看看相关算法书籍,刷刷leetcode等。
四,设计模式
设计模式部分,就是利用设计模式的特点,来达成提高程序性能的目的。
其实设计模式本身是面向对象设计原则(单一职责原则,里氏替换原则,开闭原则,依赖倒置原则,接口隔离原则,组合复用原则,迪米特原则)的落实,而这七大原则并没有性能方面的要求。所以大部分设计模式并没有性能提升的能力。但是部分设计模式由于其特定机制,以及语言特性,有了性能方面的提升。
1.单例模式
单例模式,由于其构造方法私有,所以无法通过构造器新建。这点透出其本质,单例模式保证了一个类只有一个实例,该实例只需要创建一次,并不会销毁。
这里简单说一下,由于是单例,所以线程安全。另外spring的bean默认是单例的。某些情况下,可能需要通过注解,实现多例,此时就需要注意线程安全问题了。
由于单例的延迟加载,以及实例只创建一次,不会销毁的特性。在某些对象重复使用的情况下,会带来性能的提升。甚至在某些情况下,可能需要建立容器单例(一个容器,管理多个单例,如spring)。
2.原型模式
原型模式,就是指定目标对象类型,通过拷贝来生成对象,而不需要调用构造器。而通过对象拷贝生成对象的效率远高于构造器生成对象。
所以需要大量相同对象时,可以通过原型模式,来实现相同对象的高效生成。
但是,这里提醒一下,原型模式需要注意复杂对象的拷贝问题。复杂对象的拷贝容易产生问题,这其中设计浅拷贝与深拷贝问题。感兴趣的朋友,可以查阅相关资料。
3.享元模式
享元模式,通过减少对象数量,从而改善应用所需的对象结构。既然减少了对象的创建,那么也就减少了内存中对象数量,从而降低了系统内存占用与对象创建的资源消耗。
所以需要大量类似对象时,如缓存池等,可以采用享元模式,来提高系统性能。
享元模式,貌似在系统底层应用得比较多。
以上,通过三个设计模式,简单阐述了设计模式带来的编程性能。更多的应用,需要各位朋友自行探讨。
五,多线程
这个部分,应该是多数人在高性能编程时优先考虑到的点。也是诸多面试的重点,所以这里我就简单说一下。
多线程是如何提高编程性能的呢?这其中有多种原因。
- 有的是由于单个线程的操作存在资源阻塞的情况(如数据库请求,等待数据库响应)
- 有的是为了充分利用多核cpu的多核特性(如nginx的worker默认是auto,也就是系统cpu核心数)
- 其实还有一种比较难以理解,是为了平滑cpu计算(如大当量的计算任务进行拆分等,具体后面会阐述)
这里还有一点,需要进行说明。那就是线程数量的增加,带来了局部程序的性能提升,但其实降低了系统整体性能。因为每个线程都有竞争cpu时间片的资格。在达到一定的总线数量后,某个程序的线程数增加,只是增加了其获得cpu时间片的时间。而整体cpu时间是固定的,也就是系统整体性能并没有带来提升,并且系统整体性能反而因为线程的上下文切换等原因,降低了性能。当然这个总线程数量的“一定”是不好把握的。并且在某些情况下,多个线程会带来一定的平滑效果。
为了更好的说明,下面举一些例子。
1.资源阻塞:
这个应该是一个比较好理解的例子。
正如之前提到的数据库的例子。单个服务所需总时间为1s,其中数据库资源访问花了0.9s,程序运行花费0.1s。如果是单线程,这个服务的吞吐量就只有1/s。那如果采用多个线程,并且线程数量设置为10,那么该服务的吞吐量就有10/s。甚至在数据库等条件允许的情况下,这个服务的吞吐量可以无限大,只要线程数量足够(当然实际是不可能的,总会有新的性能瓶颈出来的)。
这个应用,请大家牢牢记住,因为这是一个非常常用的应用。
2.cpu核心数
大家都知道,一个线程会竞争一个cpu。而如今很多服务器都是多核的。那么为了充分利用服务器的多核特性,面对计算密集型任务,我们往往将其线程数量设置为cpu核心数。
举个例子,八核服务器上有这么一个服务,需要进行挖矿计算(其采用pow算法,是非常消耗算力的)。如果单线程的话,那就是传说中的“一核有难,七核围观”(一个cpu核心疯狂工作,其余七个cpu核心基本空闲)。那么算力的产出就只有1。那么如果是多线程,并且设置线程数为8,那么服务器八个核心都会疯狂工作。算力的产出有8。如果采用多线程,并且线程数为9(大于8)。那么就是服务器八个核心疯狂工作,8个线程在执行,还有1个线程在疯狂插队。从而导致服务器八个cpu核心,是不是还得停下来,切换上下文,从而换一个线程执行。最终算力产出低于8。
这种情况下,一定要考虑上下文切换的资源消耗。另一方面,一定要清楚总体算力,算力有多少是真正应用到目标程序中,最终算力产出是多少。
3.平滑cpu计算
这个比较难以理解,也基本看不到人提及,实际应用价值也不大(一般用不到)。而且没有在实际应用中,真的应用,只能说从理论方面探寻。
之前提到的上下文切换,说白了就是cpu将数据从内存中读取到cpu中,再将之前线程的数据保存到内存中。
那么如果服务执行的数据量很大呢?
单线程情况下,一方面上下文切换时数据量较大(可以将上下文理解为程序快照),另一方面由于高速缓存无法承载如此大的数据量,就会频繁与内存发生数据交互(这涉及内存管理,这里不再赘述,详见软考-架构师系列博客)。
多线程情况下,一方面上下文切换时数据量较小(但是总体不变,甚至更大,但是合理的程序设计,有可能上下文切换占用内存更低),另一方面,避免了高速缓存无法承载过多数据的问题。
多线程提高性能的情况,多数可以归于上述三类(或者说前两类吧)。有不同看法,或者有无法归类上述类型的,欢迎提出,共同进步。
六,特定机制
特定机制,就类似于一些取巧的方法,大多是采用系统资源交换来实现(如空间换时间等)。
常见的有:
- fork/join框架
- copyonwritearray
- netty的零拷贝
等
1.fork/join框架
这个框架已经在前面的算法部分提及,这里只是站在机制角度多说两句。
一方面,多数的机制,也都可以按照高性能六个层次来分,只不过成为了较为成熟的方案而已。另一方面,多数机制都是采用分治,资源交换等来实现的。
2.copyonwritearry
这是jdk中的一个数组,用于针对读多写少的场景。
最早,解决数组数据的读写问题,我们都是单线程操作,除了效率低,没什么问题。
然后,为了提高性能,我们采用了多线程,而线程不安全的数组会给我们带来线程安全问题。
紧接着,为了解决线程安全问题,最早采用简单的独占锁来解决这个问题。
后来,人们发现读的操作并不会带来线程安全问题(因为没有数据修改,等同于操作常量)。所以人们采用读写锁来解决线程安全问题(读写锁,即同时只能一个写操作,但可以有多个读操作。这里可以复习一下锁降级概念)。
最后,人们给出了更好的解决方案-copyonwritearry。同时有两个数组,一个进行读操作,一个进行写操作。但是就像jvm运行时数据区中的survive区一样,在一个写操作完成后,写数组与数组就进行切换(这个切换,只需要修改数组的指向reference即可)。这样就保证了最高效的读操作。这和数据库的读写分离有着异曲同工之妙(只不过,一般不直接进行切换而已)。
通过,双倍的内存空间消耗,换来了读操作的大幅提升,即程序性能提升。
3.netty的零拷贝:
netty的零拷贝机制,来源于netty的bytebuf。而bytebuf有着诸多优点,这里不再赘述,感兴趣的可以等待我的netty源码分析的博客。
这里只简单说一下其中的零拷贝机制。
netty的零拷贝机制,体现在两点。第一个是多个实际数据的虚拟逻辑组合。另一个是避免数据在内存中的用户空间与内核空间之间的拷贝。
前者就是通过compositebytebuf,将多个bytebuf合并为一个** 逻辑** 上的bytebuf,避免了各个bytebuf之间的拷贝。举个栗子,现有数组a与数组b,你需要两个数组的合并数组c。正常就是生成一个新的数组,将两者copy过来。而零拷贝,则是数组c中包含数组a和数组b的指向。
后者则涉及内存的用户空间与内核空间的知识,这里不再解释两者,感兴趣的可以查看我早期windows内核编程的相关部分。而bytebuf的零拷贝用到了java的filechannel.transferto方法,直接将文件缓冲区的数据,直接发送到目标channel(网卡接口buffer)。
bytebuf对直接内存的使用,算是一种支撑。但是其独立出来,我认为并不能体现零拷贝特性。
当然,零拷贝的应用还有很多,包括kafka等,也有相关应用。
七,总结
最终,我想说的是,高性能,其实也和架构设计一样,伴随着均衡,即有得有失。只不过有些东西,在系统的目标中权重并不高,或者说并没有那么高的要求而已。我们要做的是找到性能的瓶颈,去处理它。
如果上述观点,有任何不当或不足的,可以直接私信或@我。愿与诸君共进步。
上一篇: 前端之js