map简介

Map是一种键-值映射表,当我们调用put(K key, V value)方法时,就把keyvalue做了映射并放入Map。当我们调用V get(K key)时,就可以通过key获取到对应的value。如果key不存在,则返回null。和List类似,Map也是一个接口,最常用的实现类是HashMap
如果只是想查询某个key是否存在,可以调用boolean containsKey(K key)方法。
注意:如果放入重复的key-value并不会有问题,但是一个key只能关联一个value,在输入一个重复的key-value时,原来的旧value会被替换掉,并作为put()方法的返回对象,如果不存在重复的key-value(即put一个新的值键对)返回null。
虽然不能有多个重复key,但是value是可以重复的。

遍历Map

若要遍历key可以使用foreach去遍历Map实例的KeySet()方法返回的Set集合。
若要遍历key和value可以使用foreach去遍历Map实例的entrySet()方法返回的集合,他包含每一个key-value映射。
注意在遍历时,和List不同,不能保证顺序。

hashMap原理和前提:equalshashCode

Map能够根据key直接拿到value,原因是因为它内部通过空间换时间的方法,用一个大数组存储所有的value,并根据key直接计算出value应该存储在那个索引。
image.png
如果key的值为"a",计算得到的索引总是1,因此返回valuePerson("Xiao Ming"),如果key的值为"b",计算得到的索引总是5,因此返回valuePerson("Xiao Hong"),这样,就不必遍历整个数组,即可直接读取key对应的value
在判断传入的key是否是Map中的key时,一般以两个key的内容相等作为正确的判断,而不一定是同一个对象,这就要我们提前写好equals()方法来判断内容是否相同。
Map的内部,对key做比较是通过equals()实现的,这一点和List查找元素需要正确覆写equals()是一样的,即正确使用Map必须保证:作为key的对象必须正确覆写equals()方法。
我们经常使用String作为key,因为String已经正确覆写了equals()方法。但如果我们放入的key是一个自己写的类,就必须保证正确覆写了equals()方法。
HashMap为什么能通过key直接计算出value存储的索引。相同的key对象(使用equals()判断时返回true)必须要计算出相同的索引,否则,相同的key每次取出的value就不一定对。

hashcode

通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数。HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value
因此,正确使用Map必须保证:

  1. 作为key的对象必须正确覆写equals()方法,相等的两个key实例调用equals()必须返回true
  2. 作为key的对象还必须正确覆写hashCode()方法,且hashCode()方法要严格遵循以下规范:
  • 如果两个对象相等,则两个对象的hashCode()必须相等;
  • 如果两个对象不相等,则两个对象的hashCode()尽量不要相等。

即对应两个实例ab

  • 如果ab相等,那么a.equals(b)一定为true,则a.hashCode()必须等于b.hashCode()
  • 如果ab不相等,那么a.equals(b)一定为false,则a.hashCode()b.hashCode()尽量不要相等。

上述第一条规范是正确性,必须保证实现,否则HashMap不能正常工作。
而第二条如果尽量满足,则可以保证查询效率,因为不同的对象,如果返回相同的hashCode(),会造成Map内部存储冲突,使存取的效率下降。
在正确实现equals()的基础上,我们还需要正确实现hashCode(),即上述3个字段分别相同的实例,hashCode()返回的int必须相同:

  1. public class Person {
  2. String firstName;
  3. String lastName;
  4. int age;
  5. @Override
  6. int hashCode() {
  7. int h = 0;
  8. h = 31 * h + firstName.hashCode();
  9. h = 31 * h + lastName.hashCode();
  10. h = 31 * h + age;
  11. return h;
  12. }
  13. }

注意到String类已经正确实现了hashCode()方法,我们在计算PersonhashCode()时,反复使用31*h,这样做的目的是为了尽量把不同的Person实例的hashCode()均匀分布到整个int范围。
既然HashMap内部使用了数组,通过计算keyhashCode()直接定位value所在的索引,那么第一个问题来了:hashCode()返回的int范围高达±21亿,先不考虑负数,HashMap内部使用的数组得有多大?
实际上HashMap初始化时默认的数组大小只有16,任何key,无论它的hashCode()有多大,都可以简单地通过:

  1. int index = key.hashCode() & 0xf; // 0xf = 15

把索引确定在0~15,即永远不会超出数组范围,上述算法只是一种最简单的实现。
第二个问题:如果添加超过16个key-valueHashMap,数组不够用了怎么办?
添加超过一定数量的key-value时,HashMap会在内部自动扩容,每次扩容一倍,即长度为16的数组扩展为长度32,相应地,需要重新确定hashCode()计算的索引位置。例如,对长度为32的数组计算hashCode()对应的索引,计算方式要改为:

  1. int index = key.hashCode() & 0x1f; // 0x1f = 31

由于扩容会导致重新分布已有的key-value,所以,频繁扩容对HashMap的性能影响很大。如果我们确定要使用一个容量为10000key-valueHashMap,更好的方式是创建HashMap时就指定容量:

  1. Map<String, Integer> map = new HashMap<>(10000);

虽然指定容量是10000,但HashMap内部的数组长度总是2n,因此,实际数组长度被初始化为比10000大的16384(214)。
最后一个问题:如果不同的两个key,例如"a""b",它们的hashCode()恰好是相同的(这种情况是完全可能的,因为不相等的两个实例,只要求hashCode()尽量不相等),那么,当我们放入:

  1. map.put("a", new Person("Xiao Ming"));
  2. map.put("b", new Person("Xiao Hong"));

时,由于计算出的数组索引相同,后面放入的"Xiao Hong"会不会把"Xiao Ming"覆盖了?
当然不会!使用Map的时候,只要key不相同,它们映射的value就互不干扰。但是,在HashMap内部,确实可能存在不同的key,映射到相同的hashCode(),即相同的数组索引上,肿么办?
我们就假设"a""b"这两个key最终计算出的索引都是5,那么,在HashMap的数组中,实际存储的不是一个Person实例,而是一个List,它包含两个Entry,一个是"a"的映射,一个是"b"的映射:

  1. ┌───┐
  2. 0
  3. ├───┤
  4. 1
  5. ├───┤
  6. 2
  7. ├───┤
  8. 3
  9. ├───┤
  10. 4
  11. ├───┤
  12. 5 ●─┼───> List<Entry<String, Person>>
  13. ├───┤
  14. 6
  15. ├───┤
  16. 7
  17. └───┘

在查找的时候,例如:

Person p = map.get("a");

HashMap内部通过"a"找到的实际上是List>,它还需要遍历这个List,并找到一个Entry,它的key字段是"a",才能返回对应的Person实例。
我们把不同的key具有相同的hashCode()的情况称之为哈希冲突。在冲突的时候,一种最简单的解决办法是用List存储hashCode()相同的key-value。显然,如果冲突的概率越大,这个List就越长,Mapget()方法效率就越低,这就是为什么要尽量满足条件二:
如果两个对象不相等,则两个对象的hashCode()尽量不要相等。
hashCode()方法编写得越好,HashMap工作的效率就越高。

使用EnumMap

HashMap是一种通过key计算hashcode,通过空间换时间的方式,定位到value所在的内部数组的索引。
如果key是作为enum类型,则可以使用Java集合库中提供的一种EnumMap,它在内部以一个非常紧凑的数组存储value,并根据enum类型的key直接定位到内部数组的索引,不需要计算hashCode(),并且没有额外的空间浪费。
我们以DayOfWeek这个枚举类型为例,为它做一个“翻译”功能:

public class Main {
    public static void main(String[] args) {
        Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class);
        map.put(DayOfWeek.MONDAY, "星期一");
        map.put(DayOfWeek.TUESDAY, "星期二");
        map.put(DayOfWeek.WEDNESDAY, "星期三");
        map.put(DayOfWeek.THURSDAY, "星期四");
        map.put(DayOfWeek.FRIDAY, "星期五");
        map.put(DayOfWeek.SATURDAY, "星期六");
        map.put(DayOfWeek.SUNDAY, "星期日");
        System.out.println(map);
        System.out.println(map.get(DayOfWeek.MONDAY));
    }
}

TreeMap

还有一种map在内部会对Key进行排序,那就是SortedMap。而SortedMap是接口,它的实现类为TreeMap
image.png
SortedMap保证遍历时以Key的顺序来进行排序。例如,放入的Key是"apple""pear""orange",遍历的顺序一定是"apple""orange""pear",因为String默认按字母排序。
TreeMap在实现排序的前提是放入的Key必须实现Comparable接口,String、Integer这些Java类已经实现了Comparable接口,因此可以直接作为Key使用。
若Key的class没有实现Comparable接口,则必须在创建TreeMap时同时制定一个Comparator :

Map<Person, Integer> map = new TreeMap<>(new Comparator<Person>() {
            public int compare(Person p1, Person p2) {
                return p1.name.compareTo(p2.name);
            }
        });

Properties

在编写应用程序时,经常要读写配置文件。配置文件的特点是,它的Key-Value一般都是String类型的,对此我们可以使用Map<String,String>来表示。因为配置文件十分常用,Java提供了一个Properties来表示一组配置。Properties内部本质上是一个Hashtable,但我们只需要用到Properties自身关于读写配置的接口。

读取配置文件

Properties读取配置文件非常简单。Java默认配置文件以.properties为扩展名,每行以key=value表示,以#课开头的是注释。以下是一个典型的配置文件:

# setting.properties
last_open_file=/data/hello.txt
auto_save_interval=60

可以从文件系统读取这个.properties文件:

String f = "setting.properties";
Properties props = new Properties();
props.load(new java.io.FileInputStream(f));
String filepath = props.getProperty("last_open_file");
String interval = props.getProperty("auto_save_interval", "120");

如果有多个.properties文件,可以反复调用load()读取,后读取的key-value会覆盖已读取的key-value:

Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));
props.load(new FileInputStream("C:\\conf\\setting.properties"));

写入配置文件

如果通过setProperty()修改了Properties实例,可以把配置写入文件,以便下次启动时获得最新配置。写入配置文件使用store()方法:

Properties props = new Properties();
props.setProperty("url", "http://www.liaoxuefeng.com");
props.setProperty("language", "Java");
props.store(new FileOutputStream("C:\\conf\\setting.properties"), "这是写入的properties注释");

编码问题

早期版本的Java规定.properties文件编码是ASCII编码(ISO8859-1),如果涉及到中文就必须用name=\u4e2d\u6587来表示,非常别扭。从JDK9开始,Java的.properties文件可以使用UTF-8编码了。
不过,需要注意的是,由于load(InputStream)默认总是以ASCII编码读取字节流,所以会导致读到乱码。我们需要用另一个重载方法load(Reader)读取:

Properties props = new Properties();
props.load(new FileReader("settings.properties", StandardCharsets.UTF_8));

就可以正常读取中文。InputStreamReader的区别是一个是字节流,一个是字符流。字符流在内存中已经以char类型表示了,不涉及编码问题。