反序列化后HashMap为什么变慢了? - 为什么?

5
我有一个相当大的HashMap(约250MB)。创建它需要大约50-55秒,所以我决定将它序列化并保存到文件中。现在从文件中读取需要大约16-17秒。
唯一的问题是这种方式似乎会使查找更慢。我一直认为HashMap是从文件中读入内存的,因此与我自己创建HashMap的情况相比,性能应该是相同的,对吧?这是我用来读取HashMap到文件中的代码:
File file = new File("omaha.ser");
FileInputStream f = new FileInputStream(file);
ObjectInputStream s = new ObjectInputStream(new BufferedInputStream(f));
omahaMap = (HashMap<Long, Integer>) s.readObject();
s.close();

当我自己创建哈希表时,3亿个查找大约需要3.1秒,当我从文件中读取相同的哈希表时,需要约8.5秒。有人有想法吗?我有没有忽略什么明显的东西?

编辑:

我只是使用System.nanotime()记录时间,所以没有使用适当的基准测试方法。以下是代码:

public class HandEvaluationTest
{
    public static void Test()
    {

        HandEvaluation.populate5Card();
        HandEvaluation.populate9CardOmaha();


        Card[] player1cards = {new Card("4s"), new Card("2s"), new Card("8h"), new Card("4d")};
        Card[] player2cards = {new Card("As"), new Card("9s"), new Card("6c"), new Card("2h")};
        Card[] player3cards = {new Card("9h"), new Card("7h"), new Card("Kc"), new Card("Kh")};
        Card[] table = {new Card("2d"), new Card("2c"), new Card("3c"), new Card("5c"), new Card("4h")};


        int j=0, k=0, l=0;
        long startTime = System.nanoTime();
        for(int p=0; p<100000000; p++)    {
           j = HandEvaluation.handEval9Hash(player1cards, table);
            k = HandEvaluation.handEval9Hash(player2cards, table);
            l = HandEvaluation.handEval9Hash(player3cards, table);

        }
        long estimatedTime = System.nanoTime() - startTime;
        System.out.println("Time needed: " + estimatedTime*Math.pow(10,-6) + "ms");
        System.out.println("Handstrength Player 1: " + j);
        System.out.println("Handstrength Player 2: " + k);
        System.out.println("Handstrength Player 3: " + l);
    }
}

大的哈希表工作是在HandEvaluation.populate9CardOmaha()中完成的。5张牌的小型哈希表。大型哈希表的代码:

 public static void populate9CardOmaha()
        {

            //Check if the hashmap is already there- then just read it and exit
            File hashmap = new File("omaha.ser");
            if(hashmap.exists())
            {
                try
                {
                    File file = new File("omaha.ser");
                    FileInputStream f = new FileInputStream(file);
                    ObjectInputStream s = new ObjectInputStream(new BufferedInputStream(f));
                    omahaMap = (HashMap<Long, Integer>) s.readObject();
                    s.close();
                }
                catch(IOException ioex) {ioex.printStackTrace();}
                catch(ClassNotFoundException cnfex)
                {
                    System.out.println("Class not found");
                    cnfex.printStackTrace();
                    return;
                }
                return;
            }

    // if it's not there, populate it yourself
    ... Code for populating hashmap ...
    // and then save it to file
          (

            try
            {
                File file = new File("omaha.ser");
                FileOutputStream f = new FileOutputStream(file);
                ObjectOutputStream s = new ObjectOutputStream(new BufferedOutputStream(f));
                s.writeObject(omahaMap);
                s.close();
            }
            catch(IOException ioex) {ioex.printStackTrace();}
        }

当我自己填写内容时(即文件不存在),HandEvaluationTest.Test() 中的查找需要8秒而不是3秒。也许这只是我非常幼稚的测量时间流逝的方式?


据我所知,它不应该有影响。可能是你测试的方式不对。 - SMA
1
你是如何进行基准测试的?你使用了jmh吗?还是Caliper?如果你使用了_任何其他工具_,那么你可能可以完全忽略你的数据。 - Boris the Spider
有趣的现象。你是否碰巧有一些sscce的基准代码,以便我们可以尝试重现这个问题? - miku
刚刚编辑了一些额外的信息。问题可能真的是我非常幼稚的基准测试方式(我基本上是Java初学者)导致的。 - TschavaTschigger
使用jmh重新运行您的测试。 - Boris the Spider
我会用jmh重新运行,但这要等到周二之后才能进行:( 到时候我会让你知道最新情况。在此期间谢谢你。 - TschavaTschigger
2个回答

3
这个问题很有意思,所以我编写了自己的测试用例来验证它。我发现实时查找与从序列化文件加载的查找速度没有区别。程序在帖子末尾可供任何感兴趣的人运行。
方法使用JProfiler进行监视。
序列化文件与你的文件相当。 ~ 230 MB。
内存中的查找成本为1210毫秒,没有任何序列化。
将地图序列化并重新读取后,查找的成本保持不变(几乎相同 - 1224毫秒)。
分析器被调整以在两种情况下添加最小开销。
这是在Java(TM) SE Runtime Environment (build 1.6.0_25-b06) / 4个运行在1.7 Ghz的CPU / 4GB Ram 800 Mhz上测量的。
测量是棘手的。我自己注意到了你描述的8秒查找时间,但猜猜当那发生时我还注意到了什么。
GC活动
您的测量可能也会捕捉到它。如果您单独隔离Map.get()的测量,您将看到结果是可比较的。
public class GenericTest
{
    public static void main(String... args)
    {
        // Call the methods as you please for a live Vs ser <-> de_ser run
    }

    private static Map<Long, Integer> generateHashMap()
    {
        Map<Long, Integer> map = new HashMap<Long, Integer>();
        final Random random = new Random();
        for(int counter = 0 ; counter < 10000000 ; counter++)
        {
            final int value = random.nextInt();
            final long key = random.nextLong();
            map.put(key, value);
        }
        return map;
    }

    private static void lookupItems(int n, Map<Long, Integer> map)
    {
        final Random random = new Random();
        for(int counter = 0 ; counter < n ; counter++)
        {
            final long key = random.nextLong();
            final Integer value = map.get(key);
        }
    }

    private static void serialize(Map<Long, Integer> map)
    {
        try
        {
            File file = new File("temp/omaha.ser");
            FileOutputStream f = new FileOutputStream(file);
            ObjectOutputStream s = new ObjectOutputStream(new BufferedOutputStream(f));
            s.writeObject(map);
            s.close();
        }
        catch (Exception e)
        {
            e.printStackTrace();
        }
    }

    private static Map<Long, Integer> deserialize()
    {
        try
        {
            File file = new File("temp/omaha.ser");
            FileInputStream f = new FileInputStream(file);
            ObjectInputStream s = new ObjectInputStream(new BufferedInputStream(f));
            HashMap<Long, Integer> map = (HashMap<Long, Integer>) s.readObject();
            s.close();
            return map;
        }
        catch (Exception e)
        {
            throw new RuntimeException(e);
        }
    }
}

2
当我自己创建哈希映射表时,进行3.1秒的3亿查找,但是从文件中读取相同的哈希映射表时,则需要8.5秒。有人知道为什么吗?我是否忽略了一些明显的东西?
可能的原因之一是重构后的HashMap可能没有与原始HashMap相同的容量(桶的数量),这可能会增加哈希冲突的频率或(如果大小增加)降低主存储器访问的局部性(导致更多的缓存未命中)。要验证,请使用调试器检查重建之前和之后map.table的长度。如果确实如此,请尝试将数据复制到具有适当loadFactor的新HashMap中。
至于为什么序列化不保持容量:HashMap通过提供writeObject和readObject方法来自定义其序列化格式(对于每个空表元素序列化null毫无意义),并忽略它在输入流中发现的容量。
/**
 * Reconstitute the {@code HashMap} instance from a stream (i.e.,
 * deserialize it).
 */
private void readObject(java.io.ObjectInputStream s)
    throws IOException, ClassNotFoundException {
    // Read in the threshold (ignored), loadfactor, and any hidden stuff
    s.defaultReadObject();
    reinitialize();
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new InvalidObjectException("Illegal load factor: " +
                                         loadFactor);
    s.readInt();                // Read and ignore number of buckets
    int mappings = s.readInt(); // Read number of mappings (size)
    if (mappings < 0)
        throw new InvalidObjectException("Illegal mappings count: " +
                                         mappings);
    else if (mappings > 0) { // (if zero, use defaults)
        // Size the table using given load factor only if within
        // range of 0.25...4.0
        float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
        float fc = (float)mappings / lf + 1.0f;
        int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                   DEFAULT_INITIAL_CAPACITY :
                   (fc >= MAXIMUM_CAPACITY) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor((int)fc));
        float ft = (float)cap * lf;
        threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                     (int)ft : Integer.MAX_VALUE);
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
        table = tab;

        // Read the keys and values, and put the mappings in the HashMap
        for (int i = 0; i < mappings; i++) {
            @SuppressWarnings("unchecked")
                K key = (K) s.readObject();
            @SuppressWarnings("unchecked")
                V value = (V) s.readObject();
            putVal(hash(key), key, value, false, false);
        }
    }
}

我怀疑它忽略了桶的数量,以防止拒绝服务攻击。攻击者可以构造一个序列化流,并给出一个不真实地高(或低)的桶数,这将导致OutOfMemoryError(或由于哈希冲突而导致的过多CPU负载),这是一种廉价的方式来对任何接受来自不可信来源的序列化数据的应用程序进行拒绝服务攻击(CVE-2012-2739描述了这样的问题)。

我的 java.util.HashMap 考虑到了这一点 - // 读取桶的数量并分配桶数组; int numBuckets = s.readInt(); table = new Entry[numBuckets];。当条目数超过容量和负载因子的乘积时,我期望 Map 会被重新哈希。只要哈希算法相同,对象创建后的碰撞次数应该与 HashMap 中的桶随时间增长而增加的数量保持不变。 - Deepak Bala
引用的源代码来自jdk1.8.0_05/src.zip。你的代码从哪里来?另外,我上面发布的代码使用putVal将映射添加到表中,它既不检查阈值也不调整表的大小。因此,只有在反序列化后添加了其他映射之后,表才会重新调整大小。 - meriton
我引用的是 JDK 1.6。你说得对,这些值在后来的版本中被忽略了。我在 JDK 1.7.25JDK 1.8.31 中再次运行了测试,结果仍然可比较。在 1.7.25 上为 1152 毫秒,在 1.8.31 上为 1127 毫秒。我不知道 putVal 的工作原理,因为它在 JDK 1.7.25 中不存在,对我来说是新的,但这个改变似乎没有影响性能。 - Deepak Bala

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