欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

高性能编程论述

程序员文章站 2024-02-01 17:57:34
高性能编程 前言 首先说一下我为什么要写这篇博客。因为面试有提到这个,我当时直接说不懂(一方面当时心态很差,另一方面面试官的询问方式令我很反感。所以直接refuse了。小伙伴们千万别学我)。 所以,打算谈一谈我对Java高性能编程方面的认识与总结。 首先,高性能编程不涉及架构层次。所以打算通过这篇文 ......

高性能编程

前言

首先说一下我为什么要写这篇博客。因为面试有提到这个,我当时直接说不懂(一方面当时心态很差,另一方面面试官的询问方式令我很反感。所以直接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等,也有相关应用。

七,总结

最终,我想说的是,高性能,其实也和架构设计一样,伴随着均衡,即有得有失。只不过有些东西,在系统的目标中权重并不高,或者说并没有那么高的要求而已。我们要做的是找到性能的瓶颈,去处理它。

如果上述观点,有任何不当或不足的,可以直接私信或@我。愿与诸君共进步。