java集合大家族之Map

Map

Map也被称为映射表(关联数组),使得你可以用来查找对象,键所关联的对象被称为,因此你可以使用键来找值,用对象来查找对象。Map保存的键不重复,如果有相同的键被加入,那么原来的值将被加入的值覆盖。键必须是唯一的,而值可以有重复。标准的java类库中包含了Map的几种基本实现,包括:HashMap、TreeMap、LinkedHashMap、WeakHashMap、ConcurrentHashMap、IdentityHashMap。它们都有同样的基本接口Map,但是行为特征各不相同,这表现在效率、键值对的保存及呈现次序、对象的保存周期、映射表如何在多线程程序中工作和判断“键”等价的策略等方面。

  • HashMap:和HashSet一样,以散列的方式来存储数据,提供了快速查找的能力(它取代了HashTable)。插入和查询“键值对”的开销是固定的。可以通过构造方法的容量和负载因子,以调整容器的性能。
  • LinkHashMap: 类似于HashMap,但是迭代遍历它时,取得“键值对”的顺序是其插入顺序,或者是最近最少使用(LRU)的次序。查询时只比HashMap慢一点;而在迭代访问时反而更快,因为它使用了链表维护内部次序。
  • TreeMap:基于红黑树的实现。查看“键”或“键值对”时,它们会被排序(次序由Comparable或Comparator决定)。TreeMap的特点在于所得到的结果是经过排序的。TreeMap是唯一的带有subMap()方法的Map,它可以返回一个子数。
  • WeakHashMap:弱键(weak key)映射,允许释放映射表所指向的对象;这是为解决某类特殊问题而设计的。如果映射表之外没有引用指向这个”键“,则该”键“可以被垃圾收集器(GC)回收。
  • ConcurrentHashMap:一种线程安全的Map,它不涉及同步加锁。
  • IdentityHashMap:使用==代替equals(),对“键”进行比较的散列映射。专为解决某类特殊问题而设计的。

带有Hash字段的映射类都是利用散列来存储元素的,如果“键”被用于散列Map,那么它必须还具有恰当的hashCode方法;而TreeMap则是利用树来存储”键“的,该键必须实现Comparable接口。

HashMap如何进行快速查找:

HashMap专门对速度进行了优化,那么HashMap是如何在映射表中快速查找键对象呢,如果使用线性查找(for循环)那执行速度会相当地慢。因此HashMap使用了特殊的值散列码,来取代对键的缓慢搜索。散列码是通过将对象的某些信息进行转换而生成的,散列码是”相对唯一”的,是int类型。通过根类Object中的hashCode()方法就可以获取散列码,所以每个对象都能产生散列码。HashMap就是使用对象的hashCode()进行快速查询,此方法能够显著提高性能。因此散列是映射中存储元素时最常用的方式。 散列的价值在于速度:散列使得查询得以快速进行。

那么键值是如何利用散列进行快速查询的呢?首先我们必须要知道存储一组数据最快的数据结构是数组,我们可以通过数组下标就可以立即得到该数据,所以我可以通过数组来存储键的信息。但是Map中保存键值的数量是不确定的,而数组的容量却是固定的,这不就冲突了吗?其实数组并不是保存键本身。而是通过键对象生成一个数字,将其作为数组的下标。这个数字就是散列码。也是就是我们上面所说的,由hashCode()方法生成()。

每个对象的散列码是”相对唯一”的,如果有1w个对象就差不多有1w个散列码,难道我们就必须创建1w容量的数组?因为hashCode()返回的是int类型的值,所以我只要把散列值除以一个特定数字,结果的余数就是数组的下标,而这个特定数字就是数组容量的大小。 如果把余数当作数组的下标,那么不同的键就就可能产生相同的下标。也就是说,可能产生冲突。因此,数组多大就不重要了,任何键总能在数组中找到它的位置。

于是查询一个值的过程首先就是计算散列码,然后使用散列码查询数组。而数组中的元素并不是存储键对象本身,而是存储是链表,链表中存储的是所有余数相同的键对象。然后根据链表中的键对象使用equal()方法进行线性查询。这部分的查询自然会比较慢,但是,如果散列函数好的话,数组每个位置就只有较少的值。

利用hashCode值快速跳到数组某个位置,然后对该位置的链表进行较少的线性比较,这就是HashMap之所以快的原因,就如下表所示:
这里写图片描述

理解了散列的原理,我们就能够实现一个简单的散列Map了:

/**
* 简单的Map容器,继承AbstractMap省去我们重写很多通用的方法
*
*/
public class SimpleHashMap<K, V> extends AbstractMap<K, V> {
    /**
     * 实现Map.Entry接口,用于存储键值对的节点
     */
    class Node<K, V> implements Map.Entry<K, V> {
        private K key;
        private V value;
        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
        @Override
        public K getKey() {
            return key;
        }
        @Override
        public V getValue() {
            return value;
        }
        @Override
        public V setValue(V v) {
            V result = v;
            value = v;
            return result;
        }
        @Override
        public int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
        /**
         * 只有键和值一样才相等
         */
        @Override
        public boolean equals(Object obj) {
            if (obj == this)
                return true;
            if (obj instanceof Map.Entry) {
                Map.Entry<K, V> e = (Map.Entry<K, V>) obj;
                //key和value都相等才返回true
                if (Objects.equals(key, e.getKey()) &&
                        Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
        @Override
        public String toString() {
            return key + "=" + value;
        }
    }
    /**
     * 使用2的次方作为哈希数组的大小
     */
    static final int SIZE = 512;
    /**
     * 声明了一个LinkedList类型的数组,LinkedList存放的是Node类型的键值对元素
     */
    LinkedList<Node<K, V>>[] buckets = new LinkedList[SIZE];
    /**
     * 继承Map接口,需要实现entrySet方法,用于返回所有的键值对集合。
     */
    @Override
    public Set<Entry<K, V>> entrySet() {
        Set<Map.Entry<K, V>> set = new HashSet<Map.Entry<K, V>>();
        for (LinkedList<Node<K, V>> bucket : buckets) {
            if (bucket == null) continue;
            for (Node<K, V> mpair : bucket) {
                set.add(mpair);
            }
        }
        return set;
    }
    /**
     * 把键值对存入Map中
     */
    @Override
    public V put(K key, V value) {
        V oldValue = null;
        //用key的hashCode余数作为数组的下标
        int index = Math.abs(key.hashCode()) % SIZE;
        if (buckets[index] == null) {
            buckets[index] = new LinkedList<Node<K, V>>();
        }
        //获取数组中LinkedList对象
        LinkedList<Node<K, V>> bucket = buckets[index];
        //创建键值对节点,存放键值对对象
        Node<K, V> pair = new Node<K, V>(key, value);
        boolean found = false;
        ListIterator<Node<K, V>> it = bucket.listIterator();
        //遍历LinkedList中是否有相等的key
        while (it.hasNext()) {
            Node<K, V> iPair = it.next();
            if (iPair.getKey().equals(key)) {
                //有相等的key
                //取出老value用于返回
                oldValue = iPair.getValue();
                //取代原来的键值对对象
                it.set(pair);
                found = true;
                break;
            }
        }
        //如果LinkedList中没有key于之相等,则把新的键值对对象加入。
        if (!found) {
            buckets[index].add(pair);
        }
        return oldValue;
    }
    /**
     * 根据key获取value
     */
    public V get(Object key) {
        //用key的hashCode余数作为数组的下标
        int index = Math.abs(key.hashCode()) % SIZE;
        if (buckets[index] == null) return null;
        //返回和key相等的value对象
        for (Node<K, V> iPair : buckets[index]) {
            if (iPair.getKey().equals(key)) {
                return iPair.getValue();
            }
        }
        return null;
    }
    public static void main(String[] args) {
        SimpleHashMap<String, String> m = new SimpleHashMap<>();
        m.put("A", "C");
        m.put("B", "C++");
        m.put("C", "Java");
        m.put("D", "C#");
        m.put("E", "Go");
        m.put("E", "Swift");
        System.out.println(m);
        System.out.println("-----------------");
        System.out.println(m.get("A"));
        System.out.println("-----------------");
        System.out.println(m.entrySet());
    }
    /*Output:
    {B=C++, A=C, C=Java, D=C#, E=Swift}
    -----------------
    C
    -----------------
    [B=C++, A=C, C=Java, D=C#, E=Swift]
     */
}

散列表中数组的每个元素我们称为”槽位”(slot) 也称为“桶位”(bucket),所以我们将表示实际散列表中的数组命令为bucket。桶的数量通常为2的整数次方。对现代处理器来说,除法与求余是最慢的操作。使用2的整数次方长度的散列表,可用掩码代替除法。因为get()是使用最多的操作,求余数的%操作是其开销最大的部分,而使用2的整数次方可以消除此开销。

get()和put()方法都用相同的方式计算在buckets数组中的索引,如果此位置有LinkedList存在,就对其查询。注意上面例子的实现并不意味着对性能进行了调优;它只是想要展示散列映射表执行的各种操作。你可以通过浏览HashMap的代码,看到一个调优过程的实现。比如我们可以修改Node使其成为一种自包含的单向链表(每个Node都有指向下一个的Node的引用),从而不用对每个桶位都使用LinkedList。

下面是用自定义的单向链表代替LinkedList:

public class SimpleHashMap<K, V> extends AbstractMap<K, V> {
    /**
     * 用于存储键值对的节点
     */
    class Node<K, V> implements Map.Entry<K, V> {
        private K key;
        private V value;
        Node<K, V> next;//用于指向下一个节点
        public Node(K key, V value, Node<K, V> next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
        @Override
        public K getKey() {
            return key;
        }
        @Override
        public V getValue() {
            return value;
        }
        @Override
        public V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        @Override
        public int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
        /**
         * 只有键和值一样才相等
         */
        @Override
        public boolean equals(Object obj) {
            if (obj == this)
                return true;
            if (obj instanceof Map.Entry) {
                Map.Entry<K, V> e = (Map.Entry<K, V>) obj;
                //key和value都相等才返回true
                if (Objects.equals(key, e.getKey()) &&
                        Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
        @Override
        public String toString() {
            return key + "=" + value;
        }
    }
    /**
     * 使用2的整数次方作为数组的大小
     */
    static final int SIZE = 512;
    /**
     * 声明了一个Node节点的数组
     */
    Node<K, V>[] buckets = new Node[SIZE];
    /**
     * 继承Map接口,需要实现entrySet方法,用于返回所有的键值对集合。
     */
    @Override
    public Set<Entry<K, V>> entrySet() {
        Set<Map.Entry<K, V>> set = new HashSet<Map.Entry<K, V>>();
        for (Node bucket : buckets) {
            for (Node<K, V> e = bucket; e != null; e = e.next) {
                set.add(e);
            }
        }
        return set;
    }
    /**
     * 把键值对存入Map中
     */
    @Override
    public V put(K key, V value) {
        //用key的hashCode余数作为数组的下标
        int index = Math.abs(key.hashCode()) % SIZE;
        for (Node<K, V> e = buckets[index]; e != null; e = e.next) {
            //桶内有Node节点,并且key值相等则进行替换
            if (key.equals(e.key)) {
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        //如果桶内没有Node节点 或 桶内有Node节点但是key值都不匹配
        //那么就创建一个新的Node节点,并插入链表的头部
        buckets[index] = new Node<K, V>(key, value, buckets[index]);
        return null;
    }
    /**
     * 根据key获取value
     */
    public V get(Object key) {
        //用key的hashCode余数作为数组的下标
        int index = Math.abs(key.hashCode()) % SIZE;
        for (Node<K, V> e = buckets[index]; e != null; e = e.next) {
            K eKey = e.key;
            if (key.equals(eKey)) {
                return e.value;
            }
        }
        return null;
    }
    public static void main(String[] args) {
        SimpleHashMap<String, String> m = new SimpleHashMap<>();
        m.put("A", "C");
        m.put("B", "C++");
        m.put("C", "Java");
        m.put("D", "C#");
        m.put("E", "Go");
        m.put("E", "Swift");
        System.out.println(m);
        System.out.println("-----------------");
        System.out.println(m.get("A"));
        System.out.println("-----------------");
        System.out.println(m.entrySet());
    }
    /*Output:
    {B=C++, A=C, C=Java, D=C#, E=Swift}
    -----------------
    C
    -----------------
    [B=C++, A=C, C=Java, D=C#, E=Swift]
     */
}

如何设计hashCode?

在明白了如何散列之后,如何编写自己的hashCode()就有十分重要的意义。设计hashCode()时最重要的因数是:如何何时,对同一个对象调用hashCode()都应该生成同样的值。如果在将一个对象用put()添加进去HashMap时产生一个hashCode()值,而用get()取出时却产生了另一个hashCode()值,那就就无法重现取得该对象了。所以,如何你的hashCode()方法依赖于对象中易变的数据,就要当心了,因为此数据发送变化时,hashCode()就会产生一个不同的散列码,相当于产生了一个不同的键。

此外,也不应该使用hashCode()依赖于具有唯一性的对象信息,尤其是使用this的值,因为这样做无法生存一个新的键,使之与put()中原始的键值对中的键相同。默认Object的hashCode()的值映射的就是对象的地址。所以我们应该使用对象内有意义的识别信息

以String为例,String有个特点只要字符串一样,那么不管多少个String对象,它们的hashCode的值都是一样的:

        String s1=new String("Hello");
        String s2=new String("Hello");
        String s3=new String("Hello");
        System.out.println(s1.hashCode()+" "+s2.hashCode()+" "+s3.hashCode());

输出的结果都为69609650,很明显String的hashCode的值是基于String内容的,下面是String类重写hashCode()的代码:

 public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;
            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

我们可以看出String的hashCode值就是基于每个字符产生的。

因此,想要使hashCode()实用,它必须速度快,并且必须有意义。也就是说,它必须基于对象的内容生成散列码。散列码不必是对一无二的(应该更关注生成速度,而不是唯一性),但是通过hashCode()和equals(),必须能够完全确定对象的身份。散列码的生成范围并不重要,只要int即可。好的hashCode()应该产生分布均匀的散列码。如果散列码都集中在一块,那么HashMap或者HashSet在某些区域的负载会很重,这样就不如分布均匀的散列函数快

在Effective Java Programming Language Guide这本书中,为怎样一份像样的hashCode()给出了基本的指导:

  1. 给int变量result赋予某个非零常量值,例如17
  2. 为对象内每个有意义的域f(即每个可以做equals()操作的域)计算出一个int散列吗hashCode:
    这里写图片描述

    如果是数组类型则对每个元素应用上述规则
    
  3. 合并计算得到散列码
    result=37*result+hashCode;
  4. 返回result。

下面便是遵循这些指导的一个例子:

public class Animal {
    private String name;
    private int age;
    private char gender;
    public Animal(String name, int age, char gender) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }
    @Override
    public String toString() {
        return "Animal{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", gender=" + gender +
                ", hashCode=" + hashCode() +
                '}'+"\n";
    }
    @Override
    public int hashCode() {
        int result = 17;
        result = 37 * result + name.hashCode();
        result = 37 * result + age;
        result = 37 * result + (int) gender;
        return result;
    }
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Animal)) return false;
        Animal animal = (Animal) o;
        if (age != animal.age) return false;
        if (gender != animal.gender) return false;
        return name.equals(animal.name);
    }
    public static void main(String[] args) {
        Map<Animal, Integer> map = new HashMap<Animal, Integer>();
        for (int i = 0; i < 5; i++) {
            Animal animal = new Animal("Jack", i, '男');
            map.put(animal, i);
        }
        System.out.println(map);
    }
    /*Output:
     {Animal{name='Jack', age=4, gender=男, hashCode=-1144106977}
    =4, Animal{name='Jack', age=3, gender=男, hashCode=-1144107014}
    =3, Animal{name='Jack', age=0, gender=男, hashCode=-1144107125}
    =0, Animal{name='Jack', age=2, gender=男, hashCode=-1144107051}
    =2, Animal{name='Jack', age=1, gender=男, hashCode=-1144107088}
    =1}

     */
}

上面的例子中hashCode和equals()都基于Animal中三个字段来生成结果的,只要发们其中一个发生改变,就会产生不同的结果。

HashMap的性能因子

我们可以通过手工调整HashMap来提高性能,从而满足我们特点应用的需求。为了调整HashMap时让你理解性能问题,需求了解以下的术语:

  • 容量:表中的桶的数量(数组的长度)
  • 初始容量:表在创建时所拥有的的桶位数。HashMap和HashSet创建时都可以指定初始容量。
  • 尺寸:表中当前存储的元素数量。
  • 负载因子:尺寸/容量。

空表的负载因子是0,而半满表的负载因子是0.5以此类推。负载轻(负载因子小)的表产生冲突的可能性小,因此对于插入和查找都是最理想的(但是会减慢使用迭代器进行遍历的过程)。HashMap和HashSet都具有指定负载因子的构造器:

 public HashMap(int capacity, float loadFactor) {
        this(capacity);
        if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
            throw new IllegalArgumentException("Load factor: " + loadFactor);
        }
        /*
         * Note that this implementation ignores loadFactor; it always uses
         * a load factor of 3/4. This simplifies the code and generally
         * improves performance.
         */
    }

表示当负载情况达到该负载因子的水平时,容器将自动增加其容量(桶位数),HashMap中的实现方式是使容量(数组大小)变成之前的2倍,并重新将现有的对象分布到新的桶位中(这被称为再散列)

HashMap使用默认的负载因子是0.75,只有当表达到四分之三满时,才进行散列:

   static final float DEFAULT_LOAD_FACTOR = .75F;

这个因子在时间和空间代价之间达到了平衡。更高的负载因子可以降低表(数组)所需的空间,但是会增加查找代价,而查找操作是我们用得最多的操作(包括put()和put())。而更低的负载因子就会提高表(数组)所需的空间,造成空间浪费,迭代速度变慢。所以负载因子应该设置成合理的值。

参考

《Thinking in Java》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值