Java优化 - 集合

大多数编程语言库提供了最少两种通用容器:

  • Sequential containers:数据保存在一定位置上,使用数字索引
  • Associative containers:由对象自己决定,保存在集合内的什么地方

为了让容器方法正确工作,要保存的对象必须具有可比性和相等性概念。Java的集合API中,这些对象必须实现hashCode()和equals()。
我们知道,引用类型的成员保存在堆内。因此,我们说顺序存储,不是说对象自己存储在容器内,而是它的引用是顺序存储的。这意味着,不能达到C/C++的数组或者vector的相同性能。
Java放弃了对内存系统的低级控制,来实现垃圾的自动回收。我们看到,使用Java不但放弃了对分配和释放的手动控制,也放弃了对低级内存布局的控制。

列表

有两种列表可供选择:ArrayList和LinkedList。

ArrayList

ArrayList是基于数组的,它有固定的大小。条目能被加到数组,如果数组满了,就分配一个新的大的数组,把旧数据拷贝到新数组。
所以,注重性能的程序员必须平衡resize的成本和不知道列表会变多大的灵活性。ArrayList基于空数组,默认的初始容量是10。在构造器中指定初始容量可以防止resize,也可以使用ensureCapacity()增加容量来避免resize。
我们使用JMH看一下影响:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
public class ArrayListBenchmark {
    private String item = "ArrayListBenchmark";

    @Benchmark
    public List<String> properlySizedArrayList() {
        List<String> list = new ArrayList<>(1_000_000);
        for (int i = 0; i < 1_000_000; i++) {
            list.add(item);
        }
        return list;
    }

    @Benchmark
    public List<String> resizingArrayList() {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) {
            list.add(item);
        }
        return list;
    }
}

执行结果:

Benchmark                                   Mode  Cnt    Score   Error  Units
ArrayListBenchmark.properlySizedArrayList  thrpt   10  316.915 ± 2.014  ops/s
ArrayListBenchmark.resizingArrayList       thrpt   10  209.429 ± 2.417  ops/s

可以看到,properlySizedArrayList测试每秒能够多执行100次操作。

LinkedList

LinkedList更适合动态生长场景,它实现了一个双端链表,扩展成本是O(1)。

ArrayList 和 LinkedList 对比

应该使用ArrayList还是LinkedList,依赖于访问和修改数据的模式。在ArrayList和LinkedList的结尾插入数据,都是常量时间操作-对于ArrayList,摊销掉resize的影响。
但是,在ArrayList的某个位置增加元素,需要把后面的元素全都向后移动一个位置。对于LinkedList,需要遍历节点,找到应该插入的位置,不过增加节点的操作倒是很简单,只要增加一个节点,设置两个引用就可以了。下面的代码显示了在开始处插入数据的性能差异:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
public class InsertBeginBenchmark {
    private static final String item = "*";
    private static final int N = 1_000_000;

    private static List<String> arrayList = new ArrayList<>();
    private static List<String> linkedList = new LinkedList<>();

    @Setup(Level.Invocation)
    public static final void setup() {
        arrayList = new ArrayList<>();
        linkedList = new LinkedList<>();
        for (int i = 0; i < N; i++) {
            arrayList.add(item);
            linkedList.add(item);
        }
    }

    @Benchmark
    public String beginArrayList() {
        arrayList.add(0, item);
        return arrayList.get(0);
    }

    @Benchmark
    public String beginLinkedList() {
        linkedList.add(0, item);
        return linkedList.get(0);
    }
}

执行结果:

Benchmark                              Mode  Cnt       Score       Error  Units
InsertBeginBenchmark.beginArrayList   thrpt   10    3317.829 ±   488.438  ops/s
InsertBeginBenchmark.beginLinkedList  thrpt   10  252265.897 ± 12625.616  ops/s

删除的行为也类似。LinkedList的删除是廉价的,最多只修改两个引用。而ArrayList,需要删除的节点的右侧的全部元素都要向左移动一个位置。
如果列表主要是随机访问,那么ArrayList是最佳选择,因为任何元素只需要O(1)的时间,而LinkedList需要从开始遍历节点。访问成本的代码见下:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
public class AccessingListBenchmark {
    private static final String item = "*";
    private static final int N = 1_000_000;

    private static final List<String> arrayList = new ArrayList<>();
    private static final List<String> linkedList = new LinkedList<>();

    @Setup
    public static final void setup() {
        for (int i = 0; i < N; i++) {
            arrayList.add(item);
            linkedList.add(item);
        }
    }

    @Benchmark
    public String accessArrayList() {
        return arrayList.get(500_000);
    }

    @Benchmark
    public String accessLinkedList() {
        return linkedList.get(500_000);
    }

}

执行结果:

Benchmark                                 Mode  Cnt          Score          Error  Units
AccessingListBenchmark.accessArrayList   thrpt   10  302101236.806 ± 11760857.021  ops/s
AccessingListBenchmark.accessLinkedList  thrpt   10       1145.040 ±       38.987  ops/s

除非你需要LinkedList的特定行为,一般都推荐使用ArrayList,特别是你使用的算法需要随机访问的时候。
如果有可能,就设置ArrayList的大小,以避免resize。

Maps

java.util.Map<K, V>描述key和value之间的相关性。key和value都是引用类型。

HashMap

HashMap可以被看作经典的hash表,不过添加了些现代装饰。
HashMap的简化版本(删除泛型和关键属性)应该是这样的:

    public Object get(Object key) {
        // 简化:不支持null key
        if (key == null) return null;
        int hash = key.hashCode();
        int i = indexFor(hash, table.length);
        for (Entry e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
        }
        return null;
    }
    
    private int indexFor(int h, int length) {
        return h & (length-1);
    }
    
    // 链表节点
    static class Node implements Map.Entry {
        final int hash;
        final Object key;
        Object value;
        Node next;
        Node(int h, Object k, Object v, Entry n) {
            hash = h;
            key = k;
            value = v;
            next = n;
        }
    }

HashMap.Node类只能被java.util包访问,这是静态类的经典用例。
A simple HashMap

开始,bucket的条目保存在一个列表内。当它查找一个值的时候,计算key的hash然后使用equals()方法找到列表中的key。所以,一个HashMap内不能有重复的key。插入相同的key会取代当前的值。
在现代Java版本里,一个提升是indexFor()已经被key的hashCode()的code取代,并使用掩码向下传播hash中的高位。这样,当计算一个key应该被hash到哪个桶时,要使用高位。这样做的一个理由是,输入的小变化应该引起hash函数输出的大变化。
HashMap中,和性能相关的两个重要变量是initialCapacity和loadFactor,他们都可以通过构造器设置。HashMap的容量代表当前已经增加的桶的数量,默认值是16。loadFactor代表hash表有多满的时候自动增长。增加 容量和重新计算hash的过程叫rehashing,它会把桶的数量加倍。
准确的initialCapacity会避免表增长,也就避免了自动rehash的需要。也可以调整loadFactor,
不过默认的0.75提供了空间和访问时间的一个平衡。loadFactor大于0.75会简单rehash的需要,但是因为桶比较满,访问会变慢。
HashMap提供了常数级的get()和put()支持,但是遍历比较费时。
另一个影响性能的是treeifying-该创新是HashMap的内部实现细节,对性能工程师比较有用。考虑一下桶被高度填充的情况,如果桶元素是使用LinkedList实现的,列表变大会导致遍历寻找一个元素变得平均更贵。为了抵消这种线性影响,HashMap使用了新机制,一旦桶到达了TREEIFY_THRESHOLD,它就转换成一棵树(行为类似于TreeMap)。
为什么开始的时候不这么做呢?TreeNodes大概是列表节点的两倍大小,因此浪费了空间。一个分布比较好的hash函数很少导致桶转换成TreeNodes。如果i你的程序出现了这种情况,应该回头考虑hash函数、initialCapacity和loadFactor的设置。

LinkedHashMap

LinkedHashMap是HashMap的子类,通过双端链表维护元素的插入顺序。LinkedHashMap默认维护插入顺序,但是也可以切换到访问顺序模式。LinkedHashMap常用于消费者关心顺序的场景,它没TreeMap昂贵。

TreeMap

TreeMap实现了红黑树-它基于二叉树结构,附加了元数据(节点颜色),防止树过度失衡。当需要key的范围的时候,可以使用TreeMap快速访问子map。TreeMap也用于分割数据-从开始到某一点,或者从某一点到结束。
对于get()、put()、containsKey()和remove(),TreeMap提供了log(n)性能。
大多数时候,HashMap能满足Map需求。考虑下面的例子:使用streams或者lambdas处理一个Map的一部分数据。此时,使用TreeMap可能更合适。

缺少MultiMap

Java没提供MultiMap-允许一个key对应多个值。官方认为这个不常用,而且也可以使用Map<K, List>实现。如果需要,可以使用开源实现。

Sets

Java有三种类型的Set,都和Map有类似的性能考虑。事实上,我们研究HashSet,会发现,是使用HashMap实现的。
Set不允许有重复值。add()方法,HashSet简单地把元素E作为key插入HashMap,使用虚拟对象PRESENT作为value。这样做,负担很小,PRESENT对象只需要被增加一次。HashSet有protected的构造器,允许一个LinkedHashMap。用来保持插入顺序。HashSet有O(1)的插入、删除和包含操作的时间。
TreeSet使用类似的方式实现。使用TreeSet会保持由Comparator定义的key的顺序。这样,范围操作和遍历更适合TreeSet。它的插入、删除和包含操作需要log(n)时间。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值