Java性能优化最易操作的10大技巧!

640?wx_fmt=jpeg

编辑 | ShirleyZ

译文 | 班纳睿 、一叶障目  、learner 

原文 | https://coyee.com/article/11753-top-10-easy-performance-optimisations-in-java


最近“全网域(Web Scale)”一词被炒得火热,人们也正在通过扩展他们的应用程序架构来使他们的系统变得更加“全网域”。

但是究竟什么是全网域?或者说如何确保全网域?


扩展的不同方面


全网域被炒作的最多的是扩展负载(Scaling load),比如支持单个用户访问的系统也可以支持10个、100个、甚至100万个用户访问。在理想情况下,我们的系统应该保持尽可能的“无状态化(stateless)”。即使必须存在状态,也可以在网络的不同处理终端上转化并进行传输。当负载成为瓶颈时候,可能就不会出现延迟。所以对于单个请求来说,耗费50到100毫秒也是可以接受的。这就是所谓的横向扩展(Scaling out)。


扩展在全网域优化中的表现则完全不同,比如确保成功处理一条数据的算法也可成功处理10条、100条甚至100万条数据。无论这种度量类型是是否可行,事件复杂度(大O符号)是最佳描述。延迟是性能扩展杀手。你会想尽办法将所有的运算处理在同一台机器上进行。这就是所谓的纵向扩展(Scaling up)。


如果天上能掉馅饼的话(当然这是不可能的),我们或许能把横向扩展和纵向扩展组合起来。但是,今天我们只打算介绍下面几条提升效率的简单方法。


大O符号


Java 7 的 ForkJoinPool 和 Java8 的并行数据流(parallel Stream) 都对并行处理有所帮助。但在多核处理器上部署 Java 程序时表现尤为明显,因所有的处理器都可以访问相同的内存。


所以,这种并行处理较之在跨网络的不同机器上进行扩展,根本的好处是几乎可以完全消除延迟。


但不要被并行处理的效果所迷惑!请谨记下面两点:


并行处理会吃光处理器资源。并行处理为批处理带来了极大的好处,但同时也是非同步服务器(如 HTTP)的噩梦。有很多原因可以解释,为什么在过去的几十年中我们一直在使用单线程的Servlet模型。并行处理仅在纵向扩展时才能带来实际的好处。


并行处理对算法复杂度没有影响。如果你的算法的时间复杂度为 O(nlogn),让算法在 c 个处理器上运行,事件复杂度仍然为 O(nlogn/c), 因为 c 只是算法中的一个无关紧要的常量。你节省的仅仅是时钟时间(wall-clock time),实际的算法复杂度并没有降低!


降低算法复杂度毫无疑问是改善性能最行之有效的办法。比如对于一个 HashMap 实例的 lookup() 方法来说,事件复杂度 O(1) 或者空间复杂度 O(1) 是最快的。但这种情况往往是不可能的,更别提轻易地实现。


如果你不能降低算法的复杂度,也可以通过找到算法中的关键点并加以改善的方法,来起到改善性能的作用。假设我们有下面这样的算法示意图:



640?wx_fmt=png


该算法的整体时间复杂度为 O(N3),如果按照单独访问顺序计算也可得出复杂度为 O(N x O x P)。但是不管怎样,在我们分析这段代码时会发现一些奇怪的场景:


在开发环境中,通过测试数据可以看到:左分支(N->M->Heavy operation)的时间复杂度 M 的值要大于右边的 O 和 P,所以在我们的分析器中仅仅看到了左分支。


在生产环境中,你的维护团队可能会通过 AppDynamics、DynaTrace 或其它小工具发现,真正导致问题的罪魁祸首是右分支(N -> O -> P -> Easy operation or also N.O.P.E.)。


在没有生产数据参照的情况下,我们可能会轻易的得出要优化“高开销操作”的结论。但我们做出的优化对交付的产品没有起到任何效果。


优化的法则不外乎以下内容:

  • 良好的设计将会使优化变得更加容易。

  • 过早的优化并不能解决多有的性能问题,但是不良的设计将会导致优化难度的增加。


理论就先谈到这里。假设我们已经发现了问题出现在了右分支上,很有可能是因产品中的简单处理因耗费了大量的时间而失去响应(假设N、O和 P 的值非常大), 请注意文章中提及的左分支的时间复杂度为 O(N3)。这里所做出的努力并不能扩展,但可以为用户节省时间,将困难的性能改善推迟到后面再进行


以下是Java中10个最简单的性能优化建议:



1. 使用 StringBuilder


这个应该成为几乎所有的Java代码里你默认要使用的东西,应该要尝试避免使用+操作符。当然你可能会争辩说, StringBuilder只是一个语法糖而已比如:

640?wx_fmt=png


这段代码会被编译为:

640?wx_fmt=png


但是如果稍后你需要使用可选部分来修正的字符串,那会发生什么?

640?wx_fmt=png


现在你将会有第二个StringBuilder,这样会白白的浪费你的堆内存,给你的GC增加压力。相反的,你可以这样写:

640?wx_fmt=png



小结

在上面的例子中,如果你使用了显式的StringBuilder实例或者依赖Java编译器创建隐式的实例,那就可能是完全不相关的。但是请记住,我们是在N.O.P.E.分支。如果我们在每一个CPU周期里,做些如GC或者为StringBuilder分配默认空间这种愚蠢的事情的话,那我们其实是浪费了N x O x P 倍的时间。


一般来说,总是使用StringBuilder而不是使用+操作符。而且如果你的字符串过于复杂,你还可以让StringBuilder的引用跨多个方法。当你使用jOOQ生成一个复杂的SQL语句时,它就是这么做的,也就是说只有一个StringBuilder对象来“遍历”你的整个SQL的 AST(抽象语法树Abstract Syntax Tree)。


而且,如果你仍然还在使用StringBuffer的引用,请务必将它们替换为StringBuilder,(因为)你几乎用不着在字符串上进行同步。



2. 避免使用正则表达式


正则表达式相对来说 是廉价且方便易用的。但是如果你在N.O.P.E.分支上, 它们大概就是最糟糕的事情了。如果你在计算密集型的代码段里确实必须要使用正则表达式,那么至少要将Pattern(模式)的引用进行缓存而不是每次都去编译它:

640?wx_fmt=png


但是如果你的正则表达式看起来像这样愚蠢的话:

640?wx_fmt=png


那么你真的最好去使用普通的char[] 或者基于索引的操作。 例如, 以下这个完全读不懂的循环代码做了相同的事情:

640?wx_fmt=png


这段代码同时也表明了为什么你不要 过早的进行优化。相比于split()的版本,这段代码比较难于维护。


挑战:聪明的读者可能会发现有更快的算法。



小结

正则表达式是很有用,但是使用它们是要付出代价的。如果你在一个N.O.P.E. 分支深处时,你就应该不惜一切代价的避免使用正则表达式。(因此)要小心那些使用正则表达式的JDK字符串方法, 比如 String.replaceAll(),或者String.split()。


相反的,应该使用流行的类库像Apache Commons Lang,来进行字符串的操作。



3. 不要使用iterator()


目前这条建议并不是适合一些通用的场合,而是只是用于N.O.P.E.分支的场景 。 尽管如此,你也应该考虑一下它。编写Java-5风格的foreach循环是很方便的。你可以把内部循环完全忘掉,然后这样写:


640?wx_fmt=png


然而,每当代码运行到这个循环时,如果strings对象是一个Iterable,那么就会创建一个新的Iterator实例。如果你在使用ArrayList,那么这将会在你的堆内存里创建一个对象,并分配3个int类型的空间:


640?wx_fmt=png


相反的,你可以像下面这样书写代码,一样效果的循环,而只是“浪费”了一个单独的栈里的整型值,相当的廉价:


640?wx_fmt=png


或者,如果你的列表不会变化的话,你甚至还可以在一个数组版本上面进行操作:


640?wx_fmt=png



小结

Iterators、Iterable和foreach循环从易写程度和可读性方面来说都是相当有用的,而且在API设计方面来看也是如此。然而,它们在每一次单独迭代时都会在堆内存里产生一个新的小实例。如果你多次运行这个迭代,你会想要尽量避免创建这个无用的实例,而且使用基于索引的迭代来代替。


讨论

对于上面的某些部分有一些比较有趣的分歧(特别是将Iterator的使用替换为通过索引进行访问),具体讨论内容请查阅Reddit。



4. 不要调用那些(特殊的)方法


一些方法简单但开销不小。在N.O.P.E.分支示例中,我们没有在叶节点上使用这样的方法,但你可能使用到了。我们假设 JDBC 驱动程序需要耗费大量资源来计算 ResultSet.wasNull() 的值。你可能会用下列代码开发 SQL 框架:


640?wx_fmt=png


此处逻辑每次都会在你从结果集中获得一个 int 之后立即调用 ResultSet.wasNull()。但getInt() 的约定是:


返回类型:变量值;如果SQL查询结果为NULL,则返回0。


所以一个简单有效的改善方法如下:

640?wx_fmt=png

这是轻而易举的事情。



小结

将方法调用缓存起来替代在叶子节点的高开销方法,或者在方法约定允许的情况下避免调用高开销方法。



5、使用原始类型和栈


上面介绍了来自 jOOQ的例子中使用了大量的泛型,导致的结果是使用了 byte、 short、 int 和 long 的包装类。但至少泛型在Java 10或者Valhalla项目中被专门化之前,不应该成为代码的限制。因为可以通过下面的方法来进行替换:

640?wx_fmt=png


用如下代码替代:

640?wx_fmt=png


当你使用数组的时候事情就变得糟糕了:

640?wx_fmt=png


用如下代码替代:

640?wx_fmt=png



小结

如果你是在N.O.P.E.分支, 你就应该非常谨慎使用包装器类型。因为很用可能会JVM的垃圾收集器GC带来很大的压力,不得不让GC耗费不少时间来清理由此带来的垃圾。


比较有效的的一个优化办法是使用原始类型,然后创建一个比较大的一维数组存储这些原始类型的变量,和一些用于标识这些对象在数组中位置的分割变量。


在这里,我推荐使用一个优秀的用于操作原始类型集合的类库,名字叫 trove4j,它比普通的int[]整形数组功能要强大的多,而且它遵循LGPL(宽通用公共许可证,Lesser General Public License)协议。


例外

这条规则有一个例外,就是由于boolean和byte所能表示的数值数目太少,导致不会全部被JDK所缓存。你可以这样写:


640?wx_fmt=png


这也同样适用于其他整型的原始类型,包括char、short、int、long。

但是只在能对它们进行自动装箱或者调用.valueOf()方法的时候,而在调用构造器时请不要这样使用!


也不要在包装类上调用构造方法,除非你想得到一个不在堆上创建的实例。

这样做的好处是为你为同事献上一个巨坑的愚人节笑话。



非堆存储

当然了,如果你还想体验下堆外函数库的话,尽管这可能参杂着不少战略决策,而并非最乐观的本地方案。


一篇由Peter Lawrey和 Ben Cotton撰写的关于非堆存储的很有意思文章请点击:OpenJDK与HashMap——让老手安全地掌握(非堆存储!)新技巧。



6、避免递归


现在,类似Scala这样的函数式编程语言都鼓励使用递归。因为递归通常意味着能分解到单独个体优化的尾递归(tail-recursing)。如果你使用的编程语言能够支持那是再好不过。不过即使如此,也要注意对算法的细微调整将会使尾递归变为普通递归。希望编译器能自动探测到这一点,否则本来我们将为只需使用几个本地变量就能搞定的事情而白白浪费大量的堆栈框架(stack frames)。


小结

没有太多可说的,除了:当你在N.O.P.E.分支上时,总是要优先考虑迭代而不是递归。



7. 使用entrySet()


当你想要迭代一个Map并且同时需要键和值时,你必须考虑清楚为何要优先使用如下的语句:

640?wx_fmt=png


而不是这样:

640?wx_fmt=png


当你在N.O.P.E. 分支上时, 不管怎样你都应该慎用map,因为对时间复杂度为 O(1)的map的大量的访问操作仍然是很大量的操作,因此也并不是没有成本的。但是至少在你必须使用map的时候,选择使用entrySet()来迭代它们!反正Map.Entry实例是存在的,你只需要访问它即可。



小结

当你在遍历map的时候如果同时需要键和值,那么请总是优先使用entrySet()。



8. 使用EnumSet或EnumMap


有些情况下,map中所有可能的键的数量是确定已知的,比如当使用一个用于配置的map时。如果这个数量相对来说较少,那么你就真的可以考虑使用EnumSet或EnumMap,而不是常见的HashSet或HashMap。这一点可以通过EnumMap.put()的源码来解释清楚:


640?wx_fmt=png


这一实现的本质是用所引致的数组代替哈希表。当插入一个新值时,我们要做的就是通过枚举常量值来查看映射表的项,它由 Java 编译器产生,作用于每个枚举类型。如果这是一个全局的配置表(如,只有一个示例), EnumMap 的执行效率会比 HashMap 高。HashMap 会用稍少一些的堆类型,但它需要对每个键进行 hashCode() 和 equals() 计算。


小结

Enum 和 EnumMap 是关系密切的好朋友。一旦你将类似枚举的结构作为键,就应该实时考虑将结构转化为枚举,并将它们用作 EnumMap 中的键。



9. 优化hashCode()和equals()方法


如果不能使用EnumMap,至少也要优化下hashCode()和equals()方法。一个好的hashCode()方法是很有必要的,因为它可以避免以后对开销更高的equals()方法的调用,由于它将会对每一个实例集都产生截然不同的哈希桶。


在每一个类的层次结构里,你都会看到普通且简单的对象。让我们看下jOOQ的org.jooq.Table 里的实现。


这个可能是最简单且也最快速的hashCode() :


640?wx_fmt=png


这里name变量的值就是表的名字。我们甚至都不会考虑模式或者该表的任何其他属性,因为在一个数据库里表名通常就是用来区分彼此的。同时,name 是一个字符串变量,因此它本身就有一个缓存的hashCode() 值在里面了。


注释很重要,因为AbstractTable继承了AbstractQueryPart,这是一个任何AST (抽象语法树)元素的共同基础实现。这个公用的AST元素没有任何属性,所以它不能对hashCode()实现做什么优化。因此,覆盖的方法就如下所示:


640?wx_fmt=png


换言之,这将会触发整个的SQL渲染工作流来计算一个AST(普通抽象语法树)元素的哈希值。


而equals()方法则变得更有趣:


640?wx_fmt=png


首先要做的事: 总是 (不仅仅是在N.O.P.E.分支) 尽早放弃每一个equals()方法,如果有以下情况:


this == argument

this 为"不兼容类型的" 参数


如果你使用instanceof来检查兼容性的类型,请注意第二个条件包含argument == null的情形。我们之前已经在《使用Java编码的十个最佳实践》这篇博文中讨论过这个问题。


在一些场合下如果你能明显发现无需过多的对比就及早的退出,因此当你能作出部分判断之后你就会想早点结束对比。例如,jOOQ的Table.equals()方法里的条件是两个表如果要被认定为相同的,他们必须有相同的名字,而不管具体的实现类型是什么。例如,下面这两个东西是不可能相等的:


com.example.generated.Tables.MY_TABLE

DSL.tableByName("MY_OTHER_TABLE")


如果参数不可能跟this相等,而且我们能够轻易的判断,那就这么做,而检查失败后就退出。如果检查成功,我们仍然可以继续执行父类中比较重的实现。由于宇宙中的大部分对象都是不相等的,我们就可以使用这种简洁的方法(即及早结束比较)来节省大量的CPU时间。


有些对象比其他对象更接近些

在jOOQ的某些场景中,大部分实例实际上是由jOOQ源代码生成器所生成的表对象,它们的equals()方法的实现都是经过深入优化的。其他几十种类型的表(如衍生表,表值函数, 数组表,连接表,数据透视表,公用表表达式等)则维持它们“简洁的”实现。



10. 多考虑使用集合,而不把它们当作独立的元素


最后但并非最不重要的是,还有一件事虽然不是Java相关的,但是却是适合于任何语言的。另外我们将不考虑 N.O.P.E.分支,因为这条建议可能会帮助你将算法的时间复杂度从O(N3)变为O(n log n)。


不幸的是,很多程序员只考虑简单的本地算法。他们解决问题时喜欢按部就班。那是命令式的和/或函数的编程方式。当从纯粹命令式转向面向对象(依然是命令式的)再到函数式编程的过程中,越来越容易将“更大的场景”模型化,所有这些风格就缺乏只有SQL和R语言及其类似语言才有的东西:


声明式编程。


在SQL (而且我们喜欢它,因为这里有一篇JOOQ的文章) 里,你可以声明你想要从你的数据库里获取的结果,而不会对算法产生任何的影响。数据库则会考虑所有的元数据(比如约束, 键,索引等)来推断出可能是最佳的算法。


理论上,这就是SQL和关系演算最初就考虑到的主要思想。 在实际中呢,SQL实现者已经在过去的十年内实现了高效的CBOs (基于成本的优化器 Cost-Based Optimisers) ,因此在新的十年内(2010‘s)SQL终于将要释放其全部潜能了(也是时候了!)。


但是你没必要在SQL里使用集合。Sets / collections / bags / lists 在所有的语言和类库里都是支持的。使用集合的主要优势是可以使你的算法变得相当的简洁。更容易写出如下代码:


SomeSet INTERSECT SomeOtherSet


而不是:

640?wx_fmt=png

有些人会认为函数式编程和Java 8会帮助你写更容易、更简洁的算法。然而这也并不一定。你可以将你的命令式的Java7风格的循环语句翻译成函数式的Java8的流式集合,但是你还是要写同样的逻辑代码。编写一个 SQL式的表达式就是不一样的了。这个语句:


SomeSet INTERSECT SomeOtherSet


可以由执行引擎以1000种方式实现。正如我们今天学习到的,在运行INTERSECT操作之前将两个set自动转换成EnumSet,也许是很明智的。我们活血可以将这个INTERSECT进行并行化处理,而不用对低层的Stream.parallel()进行调用。


总结

本文中我们讨论了对 N.O.P.E 分支上的优化,重点在对高复杂度算法的优化上。在我们的示例中,要成为 jOOQ 开发者,还要关注优化 SQL 生成:


  • 每个查询都只用一个 StringBuilder 来生成

  • 模板引擎实际上是按字符解析,而不是用正则表达式

  • 尽可能的使用数组,尤其是在遍历监听器的时候

  • 不要使用 JDBC 中非必要的方法

  • 其它…


jOOQ 在 “食物链的底部”,因为它是用户应用在离开 JVM 进入 DBMS [ 译者注:数据库管理系统,一般就是指数据库 ] 之前调用的最后(或倒数第二)层 API。在食物链的底部就意味着 JOOQ 的每一行代码都可能被调用 N x O x P 次,所以我们必须加强优化。


你的商业逻辑并没有深入 N.O.P.E 分支中,但你自己产生的基础逻辑( 自定义的 SQL 框架、库等 )或许能到达那个位置。它们应该按照我们今日所见的规则进行优化,如,使用 Java Mission Control 或其他类似工具。


长按订阅更多精彩▼

640?wx_fmt=jpeg

一. 简易后台管理系统功能: 1.1) 登录验证:登录时检测帐号密码是否正确及用户是否有效。否则给予提示并需要重新输入 1.2) 权限管理功能:登录后,根据当前角色获取该角色的权限,权限在左侧列表显示,点击左侧列表前往相应界面 1.3) 角色管理功能:可根据不同角色分配不同权限。 1.4) 日志记录功能:通过切面和注解的形式自动记录需要记录的操作 1.5) 前台:界面右侧的导航栏添加右键关闭当前、关闭其它、关闭所有tab的功能,提供收起、展开左侧导航列表功能,列表界面配合mybatis分页插件进行分页,提供便捷的增删改查和批量操作模版,提供简易的页面元素验证方式,提供form表单元素自动加载功能,提供图片上传及预览示例,提供前后台数据快速交互示例等 1.6) 后台:提供基本的Controller,service, mapper,使能够极快速得获取数据; 提供异常/错误页面处理;提供是否有效用户访问校验,未登录者需先登录;提供代码生成工具类,可一键生成所需的基本代码,生成的代码模版中增删改查功能直接可用。 二. 使用步骤 2.1).下载后解压。导入项目。 2.2).执行init中的sql。主要是要把系统用户建立起来。 2.3).运行项目,前台输入http://localhost:8080 可前往登录页。 2.4).帐号 admin 密码 123456 登录。 登录进去之后就自己玩啦。 2.5).上传资源的位置默认为当前项目同一级的路径,可以在uploadSetting.properties中设置 #原博客见: https://blog.csdn.net/HelloWorld_In_Java/article/details/83794696
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值