在Java中增加Map值的最有效方式是什么?

513

我希望这个问题不被认为是对这个论坛太基础,但我们会看到的。我想知道如何重构一些代码以获得更好的性能,因为它会运行很多次。

假设我正在创建一个单词频率列表,使用Map(可能是HashMap),其中每个键都是包含计数的单词的字符串,值是每次找到该单词的令牌时递增的整数。

在Perl中,增加这样的值将非常容易:

$map{$word}++;

但是在Java中,这个过程要复杂得多。以下是我目前正在使用的方法:

int count = map.containsKey(word) ? map.get(word) : 0;
map.put(word, count + 1);
当然,这当中依赖于最新的Java版本中的自动装箱特性。我想知道是否可以建议一种更高效的增加这种值的方法。是否有充分的性能原因来避免使用集合框架并使用其他替代方案?更新:我已经测试了几个答案,请见下文。

我认为对于java.util.Hashtable来说也是一样的。 - jrudolph
3
当然会一样,因为Hashtable实际上就是一个Map。 - whiskeysierra
2
Java 8:computeIfAbsent示例:https://dev59.com/5HVD5IYBdhLWcg3wHnyd#37439971 - akhil_mittal
3
请看 Map::computeIfAbsent 及其相关方法。 - Brian Goetz
28个回答

518

有了Java 8,现在有一种更短的方式,可以使用Map::merge

myMap.merge(key, 1, Integer::sum)
或者
myMap.merge(key, 1L, Long::sum)

分别针对long的情况。

它的作用:

  • 如果key不存在,则将值设为1
  • 否则,将与key关联的值加1

更多信息请参见此处


5
对我来说似乎不起作用,但是map.merge(key, 1, (a, b) -> a + b);有效。 - russter
2
@Tiina 原子性特征是实现特定的,参见文档:“默认实现不保证此方法的同步或原子性属性。任何提供原子性保证的实现都必须覆盖此方法并记录其并发属性。特别是,所有子接口ConcurrentMap的实现都必须记录函数是否仅在值不存在时被原子地应用一次。” - jensgram
3
对于Groovy而言,它无法接受Integer::sum作为BiFunction,并且不喜欢@russter所给出的答案的写法。以下代码对我有效: Map.merge(key, 1, { a, b -> a + b}) - jookyone
4
@russter,我知道你的评论已经超过一年了,但你是否还记得为什么它对你无效?是编译错误,还是值没有递增? - Paul
7
对于前126次递增(每个键),必须重用Integer实例,因此不会创建新对象。对于超过这个值的数值,是否创建新对象取决于运行时。相比之下,使用可变对象总是需要为每个键创建一个独立的新对象。因此,不要对性能作出一般性陈述。性能取决于键的数量和每个键的平均递增次数。 - Holger
显示剩余4条评论

427

一些测试结果

我得到了很多关于这个问题的好答案——感谢大家——所以我决定运行一些测试,找出哪种方法实际上是最快的。我测试过的五种方法是:

  • 我在这个问题中提出的"ContainsKey"方法
  • Aleksandar Dimitrov建议的"TestForNull"方法
  • Hank Gay建议的"AtomicLong"方法
  • jrudolph建议的"Trove"方法
  • phax.myopenid.com建议的"MutableInt"方法

方法

以下是我的做法...

  1. 创建了五个除不同之处外完全相同的类。每个类都必须执行一个场景中典型的操作:打开一个10MB的文件并将其读入,然后对文件中所有单词令牌进行频率计数。由于这只需要平均3秒钟,所以我让它执行频率计数(而不是I/O)10次。
  2. 计时10次迭代的循环但不计算I/O操作,并记录总共花费的时间(以时钟秒为单位),基本上使用了Java Cookbook中Ian Darwin的方法。
  3. 依次执行所有五个测试,然后再重复三次。
  4. 对每种方法的四个结果求平均值。

结果

我会先呈现结果,然后是下面有兴趣的人的代码。

ContainsKey方法是最慢的,这是预料之中的,因此我将给出每种方法相对于该方法的速度。

  • ContainsKey:30.654秒(基准线)
  • AtomicLong:29.780秒(比基准线快1.03倍)
  • TestForNull:28.804秒(比基准线快1.06倍)
  • Trove:26.313秒(比基准线快1.16倍)
  • MutableInt:25.747秒(比基准线快1.19倍)

结论

看起来只有MutableInt方法和Trove方法显着更快,因为它们提供了超过10%的性能提升。但是,如果线程是一个问题,AtomicLong可能比其他方法更具吸引力(我不太确定)。我还使用final变量运行了TestForNull,但差异微不足道。

请注意,我没有在不同场景下分析内存使用情况。我很乐意听取任何对MutableInt和Trove方法如何影响内存使用的见解。

就我个人而言,我觉得MutableInt方法最具吸引力,因为它不需要加载任何第三方类。所以,除非我发现它存在问题,否则我最有可能采用这种方法。

代码

以下是每种方法的关键代码。

ContainsKey

import java.util.HashMap;
import java.util.Map;
...
Map<String, Integer> freq = new HashMap<String, Integer>();
...
int count = freq.containsKey(word) ? freq.get(word) : 0;
freq.put(word, count + 1);

测试 Null 值

import java.util.HashMap;
import java.util.Map;
...
Map<String, Integer> freq = new HashMap<String, Integer>();
...
Integer count = freq.get(word);
if (count == null) {
    freq.put(word, 1);
}
else {
    freq.put(word, count + 1);
}

原子长整型

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
...
final ConcurrentMap<String, AtomicLong> map = 
    new ConcurrentHashMap<String, AtomicLong>();
...
map.putIfAbsent(word, new AtomicLong(0));
map.get(word).incrementAndGet();

Trove

import gnu.trove.TObjectIntHashMap;
...
TObjectIntHashMap<String> freq = new TObjectIntHashMap<String>();
...
freq.adjustOrPutValue(word, 1, 1);

MutableInt

import java.util.HashMap;
import java.util.Map;
...
class MutableInt {
  int value = 1; // note that we start at 1 since we're counting
  public void increment () { ++value;      }
  public int  get ()       { return value; }
}
...
Map<String, MutableInt> freq = new HashMap<String, MutableInt>();
...
MutableInt count = freq.get(word);
if (count == null) {
    freq.put(word, new MutableInt());
}
else {
    count.increment();
}

3
干得好,做得很好。一个小建议-AtomicLong代码中的putIfAbsent()调用即使已经存在AtomicLong(0),也会实例化一个新的AtomicLong。如果你将其改为使用if (map.get(key) == null),可能会在测试结果中获得改进。 - Leigh Caldwell
3
最近我采用类似MutableInt的方法做了同样的事情。很高兴听到这是最佳解决方案(我只是假设它是,没有进行任何测试)。 - Kip
4
在Atomic Long案例中,一步完成操作(即只进行1次昂贵的获取操作而不是2次)是否更有效率?"map.putIfAbsent(word, new AtomicLong(0)).incrementAndGet();" - smartnut007
4
@gregory 你考虑过使用Java 8的 freq.compute(word, (key, count) -> count == null ? 1 : count + 1) 吗?内部比 containsKey 少进行了一次哈希查找,因为lambda表达式的原因,很有意思可以看看它与其他方法的比较结果。 - TWiStErRob
1
你也可以使用 freq.merge(word, 1, Integer::sum) - 4castle
显示剩余6条评论

49

2016年的一项小研究:https://github.com/leventov/java-word-count基准源代码

每种方法的最佳结果(越小越好):

                 time, ms
kolobokeCompile  18.8
koloboke         19.8
trove            20.8
fastutil         22.7
mutableInt       24.3
atomicInteger    25.3
eclipse          26.9
hashMap          28.0
hppc             33.6
hppcRt           36.5

时间结果:


2
谢谢,这真的很有帮助。如果能将Guava的Multiset(例如HashMultiset)添加到基准测试中会很酷。 - cabad

47
Map<String, Integer> map = new HashMap<>();
String key = "a random key";
int count = map.getOrDefault(key, 0); // ensure count will be one of 0,1,2,3,...
map.put(key, count + 1);

这就是如何使用简单代码增加一个值。

优点:

  • 无需添加新的类或使用可变整数的另一个概念
  • 不依赖于任何库
  • 易于理解发生了什么(抽象程度不太高)

缺点:

  • 哈希映射将被搜索两次以进行get()和put()。因此,这不是最有效的代码。

在理论上,一旦调用get(),您已经知道要放置的位置,因此不应再搜索。但通常在哈希映射中搜索只需要极少的时间,您可以忽略这个性能问题。

但是,如果您非常重视这个问题,您是一个完美主义者,另一种方法是使用merge方法,这可能比以前的代码片段更有效,因为您将(理论上)仅搜索一次map:(尽管这段代码乍一看不明显,但它很短且高效)

map.merge(key, 1, (a,b) -> a+b);

建议: 大多数时候,你应该更注重代码可读性,而不是追求微小的性能提升。如果第一个代码片段更容易理解,那就使用它。但是,如果您可以理解第二个代码片段,那么也可以选择使用它!


在JAVA 7中,getOfDefault方法不可用。我该如何实现它? - tanvi
2
你可能不得不依赖其他答案。这仅适用于Java 8。 - off99555
1
+1 for the merge solution, 这将是最高性能的函数,因为您只需为哈希码计算支付一次费用(在您使用的 Map 正确支持该方法的情况下),而不是潜在地为其支付 3 次。 - Ferrybig
3
使用方法推断:map.merge(key, 1, Integer::sum) - earandap

41

针对我的评论,推荐使用Trove。如果由于某种原因,您想坚持使用标准JDK,ConcurrentMapAtomicLong可以使代码变得更易读,但具体情况因人而异。

    final ConcurrentMap<String, AtomicLong> map = new ConcurrentHashMap<String, AtomicLong>();
    map.putIfAbsent("foo", new AtomicLong(0));
    map.get("foo").incrementAndGet();

这种方法只会将 foo 的映射值保留为 1。实际上,它的优点只是更友好地支持多线程。


11
putIfAbsent() 方法返回一个值。将返回值存储在本地变量中,并使用它来调用 incrementAndGet() 可能会大大改善性能,而不是再次调用 get()。 - smartnut007
如果Map中没有与指定键关联的值,则putIfAbsent可能返回空值,因此在使用返回值时要小心。详情请参阅:https://docs.oracle.com/javase/8/docs/api/java/util/Map.html#putIfAbsent-K-V- - bumbur
1
@bumbur,解决方案应该很明显。记住将新的AtomicLong传递给putIfAbsent以及方法的返回值,接着使用正确的值进行增量。当然,自从Java 8以来,使用map.computeIfAbsent("foo", key -> new AtomicLong(0)) .incrementAndGet();甚至更简单了... - Holger

36

Google Guava是你的好朋友...

...至少在某些情况下。他们拥有这个很棒的AtomicLongMap。特别棒的原因是你的映射中使用了long类型的值。

例如:

AtomicLongMap<String> map = AtomicLongMap.create();
[...]
map.getAndIncrement(word);

也可以将超过1的值添加到该值中:

map.getAndAdd(word, 112L); 

7
AtomicLongMap#getAndAdd 方法使用原始类型 long 而不是包装类,使用 new Long() 没有任何意义。另外,AtomicLongMap 是一个泛型类型;你应该将它声明为 AtomicLongMap<String> - Helder Pereira

30

查看Google集合库是一种明智的选择。在这种情况下,一个Multiset就可以解决问题:

Multiset bag = Multisets.newHashMultiset();
String word = "foo";
bag.add(word);
bag.add(word);
System.out.println(bag.count(word)); // Prints 2

有一些类似于Map的方法可以迭代键、条目等。内部实现目前使用了HashMap<E, AtomicInteger>,因此不会产生装箱成本。


以上答案需要反映出Tovares的回应。自发布以来(3年前:)API已经发生了变化。 - Steve
multisetcount() 方法在最坏情况下的时间复杂度是 O(1) 还是 O(n)?文档对此并没有明确说明。 - Adam Parkin
我在这种情况下的算法是:如果 (有 ApacheLib(东西)) 就返回 apacheLib; 否则,如果 (有 OnGuava(东西)) 就返回 guava。通常我不会超过这两步。 :) - digao_mb

23

请注意,您最初的尝试

int count = map.containsKey(word) ? map.get(word) : 0;

包含对映射执行两个潜在昂贵操作,即containsKeyget。前者执行的操作与后者可能非常相似,因此您做了相同的工作两次

如果您查看Map的API,get操作通常在地图不包含请求的元素时返回null

请注意,这将使像下面这样的解决方案变得危险

map.put(key, map.get(key)+1);

因为它可能会产生NullPointerException。您应该首先检查是否为null

还要注意,这非常重要,HashMap根据定义可以包含nulls。因此,不是每个返回的null都表示“没有这样的元素”。在这方面,containsKey的行为与get不同,实际上告诉您是否有这样的元素。有关详细信息,请参阅API。

但是,对于您的情况,您可能不想区分存储的null和“noSuchElement”。如果您不想允许null,则可能更喜欢Hashtable。对于手动处理,使用已经提出的包装库可能是更好的解决方案,具体取决于您的应用程序的复杂性。

为了补充答案(我一开始忘记了,在此感谢编辑功能!),原生实现的最佳方法是将其存储在一个final变量中,检查是否为空并使用1重新放回。由于这个变量不可变,所以应该是final类型的。编译器可能不需要这个提示,但那样更清晰。
final HashMap map = 生成随机HashMap();
final Object key = 获取某些Key();
final Integer i = map.get(key);
if (i != null) {
    map.put(i + 1);
} else {
    // 做点什么
}
如果您不想依赖自动装箱,可以像这样说:map.put(new Integer(1 + i.getValue()));

为了避免Groovy中初始未映射/空值的问题,我最终做了以下操作:counts.put(key, (counts.get(key) ?: 0) + 1) // 这是++的过度复杂版本 - Joe Atzberger
2
或者,最简单的方式是:counts = [:].withDefault{0} // ++即可 - Joe Atzberger

20

另一种方法是创建一个可变整数:

class MutableInt {
  int value = 0;
  public void inc () { ++value; }
  public int get () { return value; }
}
...
Map<String,MutableInt> map = new HashMap<String,MutableInt> ();
MutableInt value = map.get (key);
if (value == null) {
  value = new MutableInt ();
  map.put (key, value);
} else {
  value.inc ();
}

当然,这意味着创建一个额外的对象,但与创建一个整数相比(即使使用Integer.valueOf),开销不应该太大。


6
第一次将MutableInt放入map中时,您不想将其从1开始吗? - Tom Hawtin - tackline
5
Apache 的 commons-lang 库中已经为您编写了一个 MutableInt。 - SingleShot
inc设为final在理论上可以提供可衡量的好处。 - Davis Herring

13

你可以在Java 8中提供的Map接口中使用computeIfAbsent方法。

final Map<String,AtomicLong> map = new ConcurrentHashMap<>();
map.computeIfAbsent("A", k->new AtomicLong(0)).incrementAndGet();
map.computeIfAbsent("B", k->new AtomicLong(0)).incrementAndGet();
map.computeIfAbsent("A", k->new AtomicLong(0)).incrementAndGet(); //[A=2, B=1]

computeIfAbsent方法检查指定的键是否已经与值相关联。如果没有关联的值,则尝试使用给定的映射函数计算其值。无论如何,它都返回与指定键相关联的当前值(现有或计算的),如果计算出的值为空,则返回null。

另外,如果您遇到多个线程更新公共总和的情况,可以查看LongAdder类。在高争用率下,该类的预期吞吐量比AtomicLong高得多,但代价是更高的空间消耗。


2
为什么要使用ConcurrentHashMap和AtomicLong? - ealeon

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接