关于“ web scale ”这个流行词有很多炒作,人们花了很多时间来重新组织他们的应用程序体系结构,以使其系统“规模化”。
但是什么是扩展,我们如何确保可以扩展?
缩放的不同方面
上面提到的炒作主要是关于扩展负载 ,即确保一个适用于1个用户的系统也适用于10个用户,100个用户或数百万个用户。 理想情况下,您的系统应尽可能“无状态”,以便可以在网络中的任何处理单元上转移和转换真正保留的少量状态。 当负载是您的问题时,延迟可能就没有了,因此,如果单个请求花费50-100毫秒就可以了。 这通常也称为横向扩展
扩展的一个完全不同的方面是扩展性能 ,即,确保适用于1条信息的算法也适用于10条或100条或数百万条。 Big O Notation最好地描述了这种缩放是否可行。 延迟是扩展性能的杀手。 您想尽一切可能将所有计算保持在一台计算机上。 这通常也称为放大
如果有免费午餐之类的东西( 没有 ),我们可以无限期地结合扩大规模和扩大规模。 无论如何,今天,我们将研究一些非常简单的方法来改善性能。
大O符号
Java 7的ForkJoinPool
以及Java 8的并行Stream
有助于并行化内容,这在将Java程序部署到多核处理器计算机上时非常有用。 与在网络上的不同计算机上进行扩展相比,这种并行性的优势在于您几乎可以完全消除延迟影响,因为所有内核都可以访问同一内存。
但是,不要被并行性的效果所迷惑! 请记住以下两件事:
- 并行主义吞噬了您的核心。 这对于批处理非常有用,但是对于异步服务器(例如HTTP)来说却是一场噩梦。 在过去的几十年中,我们使用单线程servlet模型是有充分的理由的。 因此,并行性仅在扩大规模时有用。
- 并行性对算法的Big O表示法没有影响。 如果您的算法是
O(n log n)
,并且让该算法在c
核上运行,那么您仍然会拥有O(n log n / c)
算法,因为c
在算法复杂度上是微不足道的常数。 您将节省挂钟时间,但不会降低复杂性!
当然,提高性能的最好方法是降低算法复杂度。 当然,杀手是实现O(1)
或准O(1)
,例如HashMap
查找。 但这并不总是可能的,更不用说轻松了。
如果您不能降低复杂性,只要找到合适的位置,只要对算法真正重要的地方进行调整,您仍然可以获得很多性能。 假定算法的以下直观表示形式:
如果我们要处理单个数量级,该算法的总体复杂度为O(N 3 )
或O(N x O x P)
。 但是,在分析此代码时,您可能会发现一个有趣的场景:
- 在开发框中,左分支(
N -> M -> Heavy operation
)是您可以在探查器中看到的唯一分支,因为O
和P
的值在开发样本数据中很小。 - 但是,在生产中,右分支(
N -> O -> P -> Easy operation
或NOPE )确实会造成麻烦。 您的运营团队可能已经使用AppDynamics或DynaTrace或某些类似的软件解决了这一问题。
没有生产数据,您可能会Swift得出结论并优化“繁重的操作”。 您将其运送到生产环境,并且修复无效。
除了以下事实外,没有最佳的黄金法则:
- 设计良好的应用程序更容易优化
- 过早的优化不会解决任何性能问题,但是会使您的应用程序设计欠佳,从而使优化变得更加困难
足够的理论。 假设您已找到问题所在的正确分支。 很容易在生产中吹起一个非常简单的操作,因为它被称为很多次(如果N
, O
和P
大)。 在不可避免的O(N 3 )
算法的叶子节点存在问题的情况下,请阅读本文。 这些优化不会帮助您扩展规模。 他们将帮助您暂时节省客户的时间,将整个算法的困难改进推迟到以后!
以下是Java中最容易进行的10个性能优化:
1.使用StringBuilder
这几乎是所有Java代码中的默认设置。 尽量避免使用+
运算符。 当然,您可能会争辩说它仍然只是StringBuilder
语法糖,例如:
String x = "a" + args.length + "b";
…编译成
0 new java.lang.StringBuilder [16]3 dup4 ldc <String "a"> [18]6 invokespecial java.lang.StringBuilder(java.lang.String) [20]9 aload_0 [args]
10 arraylength
11 invokevirtual java.lang.StringBuilder.append(int) : java.lang.StringBuilder [23]
14 ldc <String "b"> [27]
16 invokevirtual java.lang.StringBuilder.append(java.lang.String) : java.lang.StringBuilder [29]
19 invokevirtual java.lang.StringBuilder.toString() : java.lang.String [32]
22 astore_1 [x]
但是会发生什么,如果以后需要用可选部分修改String呢?
String x = "a" + args.length + "b";if (args.length == 1)x = x + args[0];
现在,您将拥有第二个StringBuilder
,它只是不必要地消耗了堆内存,从而给GC带来了压力。 改写这个:
StringBuilder x = new StringBuilder("a");
x.append(args.length);
x.append("b");if (args.length == 1);x.append(args[0]);
带走
在上面的示例中,如果您使用显式StringBuilder
实例,或者依赖Java编译器为您创建隐式实例,则可能完全不相关。 但是请记住,我们在NOPE分支中 。 每个CPU周期我们都在浪费像GC这样愚蠢的东西或分配StringBuilder
的默认容量,我们浪费的时间是N x O x P
次。
根据经验,请始终使用StringBuilder
而不是+
运算符。 如果可以的话,如果您的String
构建比较复杂,则可以在多个方法中保留StringBuilder
引用。 这是jOOQ在生成复杂的SQL语句时所做的。 只有一个StringBuilder
可以“遍历”您的整个SQL AST(抽象语法树)
为了大声喊叫,如果仍然有StringBuffer
引用,请用StringBuilder
替换它们。 您实际上几乎不需要同步正在创建的字符串。
2.避免使用正则表达式
正则表达式相对便宜且方便。 但是,如果您位于NOPE分支中 ,那么它们将是您最糟糕的事情。 如果您绝对必须在计算密集型代码节中使用正则表达式,则至少要缓存Pattern
引用,而不要一直重新编译它:
static final Pattern HEAVY_REGEX = Pattern.compile("(((X)*Y)*Z)*");
但是如果你的正则表达式真的很傻
String[] parts = ipAddress.split("\\.");
……那么您真的最好使用普通的char[]
或基于索引的操作。 例如,这个完全不可读的循环执行相同的操作:
int length = ipAddress.length();
int offset = 0;
int part = 0;
for (int i = 0; i < length; i++) {if (i == length - 1 || ipAddress.charAt(i + 1) == '.') {parts[part] = ipAddress.substring(offset, i + 1);part++;offset = i + 2;}
}
……这也说明了为什么您不应该进行任何过早的优化。 与split()
版本相比,这是无法维护的。
挑战:读者中的聪明人可能会找到更快的算法。
带走
正则表达式很有用,但要付出一定的代价。 如果您深入了解NOPE分支 ,则必须不惜一切代价避免使用正则表达式。 提防各种使用正则表达式的JDK String方法,例如String.replaceAll()
或String.split()
。
请改用诸如Apache Commons Lang之类的流行库来进行String操作。
3.不要使用iterator()
现在,此建议实际上并不适用于一般用例,而仅适用于NOPE分支的深处。 但是,您应该考虑一下。 编写Java-5样式的foreach循环很方便。 您可以完全忘记循环内部,然后编写:
for (String value : strings) {// Do something useful here
}
但是,每次遇到此循环时,如果strings
是Iterable
,则将创建一个新的Iterator
实例。 如果您使用ArrayList
,这将在堆上分配一个3 ints
的对象:
private class Itr implements Iterator<E> {int cursor;int lastRet = -1;int expectedModCount = modCount;// ...
相反,您可以编写以下等效循环,并仅在堆栈上“浪费”一个int
值,这非常便宜:
int size = strings.size();
for (int i = 0; i < size; i++) {String value : strings.get(i);// Do something useful here
}
…或者,如果您的列表没有真正改变,您甚至可以对其数组版本进行操作:
for (String value : stringArray) {// Do something useful here
}
带走
从可写性和可读性以及从API设计的角度来看,迭代器,Iterable和foreach循环都非常有用。 但是,它们为每次迭代在堆上创建一个小的新实例。 如果您多次运行此迭代,则要确保避免创建此无用的实例,而改为编写基于索引的迭代。
讨论区
关于上述部分的一些有趣的分歧(特别是用索引访问代替Iterator
用法) 已经在Reddit上进行了讨论 。
4.不要调用该方法
有些方法简单昂贵。 在我们的NOPE分支示例中,叶子上没有这样的方法,但是您很可能有一个。 假设您的JDBC驱动程序需要经历难以置信的麻烦才能计算ResultSet.wasNull()
的值。 您自己的SQL框架代码可能如下所示:
if (type == Integer.class) {result = (T) wasNull(rs, Integer.valueOf(rs.getInt(index)));
}// And then...
static final <T> T wasNull(ResultSet rs, T value)
throws SQLException {return rs.wasNull() ? null : value;
}
每次您从结果集中获取一个int
,此逻辑将立即调用ResultSet.wasNull()
。 但是getInt()
合同显示为:
返回:列值; 如果值为SQL NULL,则返回值为0
因此,对上述内容进行简单但可能很大的改进将是:
static final <T extends Number> T wasNull(ResultSet rs, T value
)
throws SQLException {return (value == null || (value.intValue() == 0 && rs.wasNull())) ? null : value;
}
因此,这很容易:
带走
不要在算法的“叶子节点”中调用昂贵的方法,而要缓存调用,或者在方法合同允许的情况下避免调用。
5.使用原语和堆栈
上面的示例来自jOOQ ,它使用了很多泛型,因此被迫对byte
, short
, int
和long
使用包装器类型-至少在泛型将在Java 10和项目Valhalla中实现特殊化之前。 但是您的代码中可能没有此约束,因此应采取所有措施替换:
// Goes to the heap
Integer i = 817598;
… 这样:
// Stays on the stack
int i = 817598;
使用数组时,情况变得更糟:
// Three heap objects!
Integer[] i = { 1337, 424242 };
… 这样:
// One heap object.
int[] i = { 1337, 424242 };
带走
当您深入了解NOPE分支时 ,应该非常警惕使用包装器类型。 可能会给GC带来很大压力,必须时刻加油清理GC。
一种特别有用的优化可能是使用某种原始类型并为其创建大型的一维数组,以及几个定界符变量以指示编码对象在数组上的确切位置。
trove4j是一个出色的原始集合库,它比您的平均int[]
要复杂一些 ,它随LGPL一起提供。
例外
此规则有一个例外: boolean
和byte
值很少,因此无法完全由JDK缓存。 你可以写:
Boolean a1 = true; // ... syntax sugar for:
Boolean a2 = Boolean.valueOf(true);Byte b1 = (byte) 123; // ... syntax sugar for:
Byte b2 = Byte.valueOf((byte) 123);
对于其他整数基元类型的低值(包括char
, short
, int
, long
。
但是仅当您将它们自动装箱或调用TheType.valueOf()
,才调用构造函数!
除非确实需要新实例,否则切勿在包装器类型上调用构造函数。
这个事实还可以帮助您为同事写一个复杂的,愚蠢的愚人节玩笑
堆外
当然,您可能还想尝试堆外库,尽管它们更多是一个战略决策,而不是本地优化。
彼得·劳瑞(Peter Lawrey)和本·科顿(Ben Cotton)撰写的有关该主题的有趣文章是: OpenJDK和HashMap…安全地教老狗新手(超堆!)技巧
6.避免递归
像Scala这样的现代函数式编程语言鼓励使用递归,因为它们提供了将尾递归算法优化回迭代算法的方法 。 如果您的语言支持这种优化,则可能会很好。 但是即使如此,算法的最细微改动也可能会产生一个分支,从而阻止您的递归成为尾递归。 希望编译器能够检测到这一点! 否则,您可能会浪费大量的堆栈框架,而这些内容可能只使用了几个局部变量就已经实现了。
带走
除了以下内容外,没有什么要说的:当您深入NOPE分支时,始终喜欢迭代而不是递归。
7.使用entrySet()
当您要遍历Map
,同时需要键和值时,必须有充分的理由编写以下内容:
for (K key : map.keySet()) {V value : map.get(key);
}
…而不是以下内容:
for (Entry<K, V> entry : map.entrySet()) {K key = entry.getKey();V value = entry.getValue();
}
当您在NOPE分支中时 ,无论如何,您都应该警惕地图,因为很多O(1)
地图访问操作仍然很多。 而且访问也不是免费的。 但是至少,如果您不能没有地图,请使用entrySet()
对其进行迭代! 无论如何,都有Map.Entry
实例,您只需要访问它即可。
带走
在地图迭代期间同时需要键和值时,请始终使用entrySet()
。
8.使用EnumSet或EnumMap
在某些情况下,映射中可能的键数是预先已知的,例如在使用配置映射时。 如果该数字相对较小,则应真正考虑使用EnumSet
或EnumMap
,而不是常规的HashSet
或HashMap
。 通过查看EnumMap.put()
可以很容易地解释这一点:
private transient Object[] vals;public V put(K key, V value) {// ...int index = key.ordinal();vals[index] = maskNull(value);// ...
}
此实现的本质是这样一个事实,即我们拥有一个索引值数组,而不是哈希表。 当插入一个新值时,我们要查找映射项的所有工作就是询问枚举的常量序数,该序数由Java编译器在每种枚举类型上生成。 如果这是一个全局配置映射(即仅一个实例),则提高的访问速度将帮助EnumMap
大大优于HashMap
,后者可能使用较少的堆内存,但必须在每个键上运行hashCode()
和equals()
。
带走
Enum
和EnumMap
是非常好的朋友。 每当您使用类似枚举的结构作为键时,请考虑实际上使这些结构成为枚举并将其用作EnumMap
键。
9.优化您的hashCode()和equals()方法
如果您不能使用EnumMap
,至少要优化您的hashCode()
和equals()
方法。 一个好的hashCode()
方法至关重要,因为它将阻止对更昂贵的equals()
进一步调用,因为它将为每个实例集生成更多不同的哈希存储桶。
在每个类层次结构中,您可能都有流行和简单的对象。 让我们看一下jOOQ的org.jooq.Table
实现。
hashCode()
的最简单,最快的实现方法是:
// AbstractTable, a common Table base implementation:@Override
public int hashCode() {// [#1938] This is a much more efficient hashCode()// implementation compared to that of standard// QueryPartsreturn name.hashCode();
}
…其中name
只是表名。 我们甚至不考虑表的模式或任何其他属性,因为表名通常在数据库中足够不同。 另外,该name
是一个字符串,因此它内部已经有一个缓存的hashCode()
值。
该注释很重要,因为AbstractTable
扩展了AbstractQueryPart
,它是任何AST(抽象语法树)元素的通用基本实现。 通用AST元素没有任何属性,因此它不能做任何假设来优化hashCode()
实现。 因此,重写的方法如下所示:
// AbstractQueryPart, a common AST element
// base implementation:@Override
public int hashCode() {// This is a working default implementation. // It should be overridden by concrete subclasses,// to improve performancereturn create().renderInlined(this).hashCode();
}
换句话说,必须触发整个SQL呈现工作流以计算公共AST元素的哈希码。
equals()
事情变得更加有趣
// AbstractTable, a common Table base implementation:@Override
public boolean equals(Object that) {if (this == that) {return true;}// [#2144] Non-equality can be decided early, // without executing the rather expensive// implementation of AbstractQueryPart.equals()if (that instanceof AbstractTable) {if (StringUtils.equals(name, (((AbstractTable<?>) that).name))) {return super.equals(that);}return false;}return false;
}
第一件事: 总是 (不仅在NOPE分支中 )提前中止每个equals()
方法,如果:
-
this == argument
-
this "incompatible type" argument
请注意,如果您使用instanceof
检查兼容类型,则后一个条件包括argument == null
。 之前,我们在“编码Java的10个微妙的最佳实践”中已经对此进行了博客撰写。
现在,在明显的情况下尽早中止比较之后,您可能还想在可以做出部分决策时就中止比较。 例如,jOOQ的Table.equals()
的Table.equals()
是要使两个表相等,无论具体的实现类型如何,它们都必须具有相同的名称。 例如,这两个项目不可能相等:
-
com.example.generated.Tables.MY_TABLE
-
DSL.tableByName("MY_OTHER_TABLE")
如果argument
不能等于this
,并且如果我们可以轻松地进行检查,那么我们可以进行检查,如果检查失败,则中止。 如果检查成功,我们仍然可以从super
进行更昂贵的实现。 鉴于Universe中的大多数对象不相等,我们将通过简化此方法来节省大量CPU时间。
有些对象比其他对象更平等
对于jOOQ,大多数实例实际上是由jOOQ源代码生成器生成的表,该表的equals()
实现甚至得到了进一步优化。 其他数十种表类型(派生表,表值函数,数组表,联接表,数据透视表,公用表表达式等)可以保持其“简单”实现。
10.集合思考,而不是个别思考
最后但并非最不重要的一点是,它与Java不相关,但适用于任何语言。 此外,我们将离开NOPE分支,因为此建议可能只是帮助您从O(N 3 )
迁移到O(n log n)
或类似的东西。
不幸的是,许多程序员认为是简单的本地算法。 他们一步一步地解决问题,逐分支,逐循环,逐方法。 那就是命令式和/或函数式编程风格。 从纯命令式到面向对象(仍然命令式)再到函数式编程时,为“更大的画面”建模变得越来越容易,但是所有这些样式都缺少只有SQL,R和类似语言才能具备的功能:
声明式编程。
在SQL中( 并且我们很喜欢,因为它是jOOQ博客 ),您可以声明要从数据库中获取的结果,而不会产生任何算法含义。 然后,数据库可以考虑所有可用的元数据( 例如约束,键,索引等 ),以找出可能的最佳算法。
从理论上讲,从一开始,这就是SQL和关系演算背后的主要思想。 在实践中,SQL供应商仅在最近十年才实施了高效的CBO(基于成本的优化工具) ,因此请与我们保持在一起,直到2010年SQL最终释放出其全部潜力(大约是时间!)。
但是,您不必执行SQL即可进行集合思考。 集合/收藏/袋子/清单可提供所有语言和库。 使用集合的主要优点是您的算法将变得更加简洁。 编写起来非常容易:
SomeSet INTERSECT SomeOtherSet
而不是:
// Pre-Java 8
Set result = new HashSet();
for (Object candidate : someSet)if (someOtherSet.contains(candidate))result.add(candidate);// Even Java 8 doesn't really help
someSet.stream().filter(someOtherSet::contains).collect(Collectors.toSet());
有人可能会认为函数式编程和Java 8将帮助您编写更简单,更简洁的算法。 不一定是真的。 您可以将命令性的Java-7循环转换为功能性的Java-8 Stream集合,但是您仍在编写完全相同的算法。 编写类似SQL的表达式是不同的。 这个…
SomeSet INTERSECT SomeOtherSet
…可以由实施引擎以1000种方式实施。 正如我们今天所了解的那样,在运行INTERSECT
操作之前,将两个集合自动转换为EnumSet
也许是明智的。 也许我们可以并行化此INTERSECT
而无需对Stream.parallel()
进行低级调用。
结论
在本文中,我们讨论了在NOPE分支上完成的优化,即深入到高复杂度算法中。 在我们的案例中,作为jOOQ开发人员,我们对优化我们的SQL生成感兴趣:
- 每个查询仅在单个
StringBuilder
上生成 - 我们的模板引擎实际上是解析字符,而不是使用正则表达式
- 我们会尽可能使用数组,尤其是在侦听器上进行迭代时
- 我们避免了不必调用的JDBC方法
- 等等…
jOOQ位于“食物链的底部”,因为它是(次)API,在调用离开JVM进入DBMS之前,我们的客户应用程序正在调用它。 位于食物链的底部意味着在jOOQ中执行的每一行代码都可能被称为N x O x P倍,因此我们必须热切地进行优化。
您的业务逻辑不在NOPE分支中 。 但是您自己的本地基础结构逻辑可能是(自定义SQL框架,自定义库等),应该根据我们今天所看到的规则进行审查。 例如,使用Java Mission Control或任何其他探查器。
翻译自: https://www.javacodegeeks.com/2015/02/top-10-easy-performance-optimisations-java.html