假设我们有许多命令。为了本文叙述起来简单些,我们将这些命令全都实现成一个类中的方法。通过字符串名可以调用到对应的命令。方法调用是大小写不敏感的。这个“命令类”看起来会是这样的:
class="java" name="code">
public class ObjectWithCommands {
public Object Command1( final Object arg ) { return arg; }
public Object Command2( final Object arg ) { return arg; }
...
public Object Command9( final Object arg ) { return arg; }
public Object Command10( final Object arg ) { return arg; }
...
public Object Command99( final Object arg ) { return arg; }
public Object Command100( final Object arg ) { return arg; }
}
本文将比较调用这些命令的不同方法之间的性能差别。
先来做个小测试吧。假设你要用下面的方法来调用这些命令:
public class EqualsIgnoreCaseCaller {
public static Object call( final ObjectWithCommands obj, final String commandName, final Object arg )
{
if ( commandName.equalsIgnoreCase( "Command1" ) )
return obj.Command1( arg );
if ( commandName.equalsIgnoreCase( "Command2" ) )
return obj.Command2( arg );
...
if ( commandName.equalsIgnoreCase( "Command99" ) )
return obj.Command99( arg );
if ( commandName.equalsIgnoreCase( "Command100" ) )
return obj.Command100( arg );
}
}
下面哪个方法调用会最快?
EqualsIgnoreCaseCaller.call( obj, "Command9", arg );
EqualsIgnoreCaseCaller.call( obj, "Command99", arg );
EqualsIgnoreCaseCaller.call( obj, "Command100", arg );
我们来写一个JMH
基准测试:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS )
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS )
@Threads(1)
@State(Scope.Thread)
public class CallTest {
private String m_commandName9 = "Command9";
private String m_commandName99 = "Command99";
private String m_commandName100 = "Command100";
private ObjectWithCommands m_obj = new ObjectWithCommands();
private Object m_arg = new Object();
@GenerateMicroBenchmark
public Object testEqualsIgnoreCase9()
{
return EqualsIgnoreCaseCaller.call(m_obj, m_commandName9, m_arg);
}
@GenerateMicroBenchmark
public Object testEqualsIgnoreCase99()
{
return EqualsIgnoreCaseCaller.call(m_obj, m_commandName99, m_arg);
}
@GenerateMicroBenchmark
public Object testEqualsIgnoreCase100()
{
return EqualsIgnoreCaseCaller.call(m_obj, m_commandName100, m_arg);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(".*" + CallTest.class.getSimpleName() + ".*")
.forks(1)
.build();
new Runner(opt).run();
}
}
下面是在我的笔记本上的运行结果:
BenchmarkModeSamplesMeanMean errorUnitst.CallTest.testEqualsIgnoreCase9thrpt10998445.6433518.385ops/st.CallTest.testEqualsIgnoreCase99thrpt1083494.9671237.927ops/s t.CallTest.testEqualsIgnoreCase100thrpt103701991.45721054.106ops/s
Command100的速度最快,其次是Command9,最后是Command99。那为什么在比较链最后的Command100会比前面的Command99要快44倍呢?难道它们的性能不是应该差不多的吗?
当然不是。我们来看下String.equalsIgnoreCase的源代码:
public boolean equalsIgnoreCase(String anotherString) {
return (this == anotherString) ? true
: (anotherString != null)
&& (anotherString.value.length == value.length)
&& regionMatches(true, 0, anotherString, 0, value.length);
}
这里最主要的优化就是比较了字符串的长度是否相等。这个检查使得Command100要比前面99个要快不少,从而成为速度最快的一个。而这个优化对Command99而言只能跳过前面9次比较而已。
Command99在找到匹配之前需要比较89*9个字符。下面还有第二个优化——如果拿字符串和自身进行比较的话,第一次比较就会直接通过了。在这里这个优化是有用的,因为所有的字符串都是字面量,因此它们会被驻留到
内存池里(interned)。这个优化对于Command100而言则更为重要——整个过程中它压根就不用进行字符串内容的比较(注:前面是长度不等,最后一次是引用相等)。
String.equals中也有类似的逻辑——先是唯一性检查,然后是长度检查,最后才是内容比较。
那么,如果我们要实现类似的命令代理的逻辑,使用这么一堆equalsIgnoreCase调用就是最好的了吗,还有没有更好的方法?
大小写不敏感的”字符串switch”的实现方式
String.equalsIgnoreCase的调用链
这是最直接的方法。不幸的是,只有当仅有一个固定长度的命令同时命令本身长度足够短的时候性能才算过得去。
if ( commandName.equalsIgnoreCase( "Command1" ) )
return obj.Command1( arg );
if ( commandName.equalsIgnoreCase( "Command2" ) )
return obj.Command2( arg );
...
Command.toLowerCase 然后再String.equals
我们可以先将命令名转化成全小写的,然后再和小写的命令名进行比较。这个方法在命令数增长的时候会表现得更好。
final String lcName = commandName.toLowerCase();
if ( lcName.equals( "command1" ) )
return obj.Command1( arg );
if ( lcName.equals( "command2" ) )
return obj.Command2( arg );
Java 7的字符串switch
从Java 7开始可以在switch语句中使用字符串了。逻辑上讲它和前面的做法是一样的,但在实现上则不同。字符串switch是用一个映射表来实现的,它将字符串映射到对应的处理代码块上。
final String lcName = commandName.toLowerCase();
switch( lcName ) {
case "command1":
return obj.Command1( arg );
case "command2":
return obj.Command2( arg );
...
}
我们将字符串switch和一个显式的使用小写命令名指向命令的map做一下比较。每个命令都通过一个匿名类来实现:
interface ICommandCaller {
public Object call( final ObjectWithCommands obj, final Object arg );
}
在Java 8之前实现会是这样的:
private static final Map<String, ICommandCaller> CALL_MAP = new HashMap<>( 100 );
static {
CALL_MAP.put( "command1", new ICommandCaller() {
public Object call( final ObjectWithCommands obj, final Object arg ) {
return obj.Command1( arg );
}
} );
CALL_MAP.put( "command2", new ICommandCaller() {
public Object call( final ObjectWithCommands obj, final Object arg ) {
return obj.Command2( arg );
}
} );
...
}
public static Object call( final ObjectWithCommands obj, final String commandName, final Object arg )
{
return CALL_MAP.get( commandName.toLowerCase() ).call( obj, arg );
}
Java 8的lambda
在Java 8中我们可以将匿名类替换成lambda
表达式:
private static final Map<String, ICommandCaller> CALL_MAP = new HashMap<>( 100 );
static {
CALL_MAP.put( "command1", ( obj, arg ) -> obj.Command1( arg ) );
CALL_MAP.put( "command2", ( obj, arg ) -> obj.Command2( arg ) );
...
}
测试
上述提到的类会由Generator.java来生成,源码在本文的结尾处。我们会修改命令的名字以及数量来完成这次测试。
极端情况——和其它命令的长度都不一样
我们来看下在上述的
例子中这些
算法执行的效率如何——100个命令:从Command1到Command100。我们会去检查Command100的访问时间:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS )
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS )
@Threads(1)
@State(Scope.Thread)
public class CallTest {
private String m_commandName = "Command100";
private ObjectWithCommands m_obj = new ObjectWithCommands();
private Object m_arg = new Object();
@GenerateMicroBenchmark
public Object testEqualsIgnoreCase()
{
return EqualsIgnoreCaseCaller.call(m_obj, m_commandName, m_arg);
}
@GenerateMicroBenchmark
public Object testEqualsLowerCase()
{
return EqualsCaller.call(m_obj, m_commandName, m_arg);
}
@GenerateMicroBenchmark
public Object testSwitchCall()
{
return SwitchCaller.call(m_obj, m_commandName, m_arg);
}
@GenerateMicroBenchmark
public Object testJava7Map()
{
return Map7Caller.call(m_obj, m_commandName, m_arg);
}
@GenerateMicroBenchmark
public Object testJava8Map()
{
return Map8Caller.call(m_obj, m_commandName, m_arg);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(".*" + CallTest.class.getSimpleName() + ".*")
.forks(1)
.build();
new Runner(opt).run();
}
}
下面是测试结果:
BenchmarkModeSamplesMeanMean errorUnitst.CallTest.testEqualsIgnoreCasethrpt103722062.95096314.315ops/st.CallTest.testEqualsLowerCasethrpt101399947.40211651.113ops/s t.CallTest.testJava7Mapthrpt102640137.15017767.132ops/s t.CallTest.testJava8Mapthrpt102673449.94013438.176ops/s t.CallTest.testSwitchCallthrpt102653356.31222085.341ops/s
equalsIgnoreCase是最快的,而equals最慢。代码几乎是一样的,除了一个地方——equals用例中它会主动去调用toLowerCase方法,而这正是不同的地方。而switch和map的测试结果则都差不多。
100个同样长度的命令:
我们来修改下测试集合——将100命令改成Command10001到Command10100。这会测试很多命令长度都一样的场景。我们测试的是Command10100的访问时间。
BenchmarkModeSamplesMeanMean errorUnitst.CallTest.testEqualsIgnoreCasethrpt1062066.170157.287ops/st.CallTest.testEqualsLowerCasethrpt10450102.1651121.556ops/s t.CallTest.testJava7Mapthrpt102411420.33711454.230ops/s t.CallTest.testJava8Mapthrpt102400935.81054165.643ops/s t.CallTest.testSwitchCallthrpt102406538.56310945.766ops/s
500个相同长度的命令
我们再试下500个命令的:Command10001 到Command10500。测试的是Command10500的访问时间。
BenchmarkModeSamplesMeanMean errorUnitst.CallTest.testEqualsIgnoreCasethrpt1012899.127886.002ops/st.CallTest.testEqualsLowerCasethrpt1049137.482976.380ops/s t.CallTest.testJava7Mapthrpt102435168.66028192.640ops/s t.CallTest.testJava8Mapthrpt102488337.117170484.548ops/s t.CallTest.testSwitchCallthrpt10948982.3502652.893ops/s
1000个相同长度的命令
最后我们再试下1000个命令的:Command10001 到Command11000。测试的是Command11000的访问时间。
BenchmarkModeSamplesMeanMean errorUnitst.CallTest.testEqualsIgnoreCasethrpt104338.513153.786ops/st.CallTest.testEqualsLowerCasethrpt107602.722746.926ops/s t.CallTest.testJava7Mapthrpt102147853.31745741.062ops/s t.CallTest.testJava8Mapthrpt102339853.84510194.687ops/s t.CallTest.testSwitchCallthrpt10896876.7725740.585ops/s
可以看到的是在这三个测试中,map的访问时间几乎在所有测试用例中都是差不多的,而equals/equalsIgnoreCase则随着命令数的增长变化很大。
字符串switch的实现
字符串switch的性能非常有意思——它呈对数速度降级,这意味着switch被实现成了一个固定大小的map。我们来试试找出这个固定的MAP的大小是多少。这里至少需要计算两个操作的性能——commandName.toLowerCase()以及command1.equals( command2 )。JMH可以帮忙做这些:
private String m_commandName = "Command10500";
private String m_commandName2 = "Command10090";
@GenerateMicroBenchmark
public String testLC()
{
return m_commandName.toLowerCase();
}
@GenerateMicroBenchmark
public boolean testEQ()
{
return m_commandName.equals( m_commandName2 );
}
BenchmarkModeSamplesMeanMean errorUnitst.CallTest.testLCthrpt103262501.308610471.458ops/st.CallTest.testEQthrpt1053070517.985232819.742ops/s
switch的测试会包括一个commandName.toLowerCase(),后面是大量的equals调用。因此我们可以通过下面的等式来计算equals方法的调用次数:
TEST_TIME = LC_TIME + N * EQ_TIME
N = ( TEST_TIME - LC_TIME ) / EQ_TIME
在我的测试中计时略有不同。最终结果大概是100个命令对应5个equals调用,而1000个命令有大概50个equals调用。这意味着switch的map表中大概有20个槽位(很可能是一个素数,比如说19,或者23)。
结论
Java 7中的字符串switch是使用一个固定大小的MAP来实现的,这意味着在大多数情况下你可以随意使用,不用太担心它的性能问题。正如你所看到的,当比较的case数小于100的时候,字符串switch的性能和手动实现MAP相比,性能是一样的。
如果你的case语句中字符串长度不等并且也不是很大的话,String.equals/equalsIgnoreCase要比switch的性能要好不少。这是因为在String.equals/equalsIgnoreCase中它是先比较字符串长度,然后再比较实际的内容。
源代码
原创文章转载请注明出处:http://it.deepinmind.com
英文原文链接