Java——EnumMap和EnumSet

EnumMap和EnumSet

1、EnumMap

如果需要一个Map的实现类,并且键的类型为枚举类型,可以使用HashMap,但应该使用一个专门的实现类EnumMap。主要是因为枚举类型有两个特征:一是它可能的值是有限的且预先定义的;二是枚举值都有一个顺序,这两个特征使得可以更为高效地实现Map接口。

1.1、基本用法

举个简单的例子。比如,有一批关于衣服的记录,我们希望按尺寸统计衣服的数量。定义一个简单的枚举类Size,表示衣服的尺寸:

java 复制代码
public enum Size {
    SMALL,
    MEDIUM,
    LARGE
}

定义一个简单类Clothes,表示衣服:

java 复制代码
class Clothes {
    String id;
    Size size;
    //省略getter/setter和构造方法
}

有一个表示衣服记录的列表List,我们希望按尺寸统计数量,统计方法可以为:

java 复制代码
    public static Map<Size, Integer> countBySize(List<Clothes> clothes) {
        Map<Size, Integer> map = new EnumMap<Size, Integer>(Size.class);
        for (Clothes c : clothes) {
            Size size = c.getSize();
            Integer count = map.get(size);
            if (count != null) {
                map.put(size, count + 1);
            } else {
                map.put(size, 1);
            }
        }
        return map;
    }

与HashMap不同,它需要传递一个类型信息,Size.class表示枚举类Size的运行时类型信息,Size.class也是一个对象,它的类型是Class。为什么需要这个参数呢?没有这个, EnumMap就不知道具体的枚举类是什么,也无法初始化内部的数据结构。

使用以上的统计方法也是很简单的,比如:

java 复制代码
    public static void main(String[] args) {
        List<Clothes> cLothes = Arrays.asList(new Clothes[]{
                new Clothes("C001", Size.SMALL),
                new Clothes("C002", Size.MEDIUM),
                new Clothes("C003", Size.LARGE)
        });
        System.out.println(countBySize(cLothes));
    }
    //{SMALL=1, MEDIUM=1, LARGE=1}

需要说明的是,与HashMap不同,EnumMap是保证顺序的,输出是按照键在枚举中的顺序的。

你可能认为,对于枚举,使用Map是没有必要的,比如对于上面的统计例子,可以使用一个简单的数组:

java 复制代码
public static int[] countBySize(List<Clothes> clothes){
    int[] stat = new int[Size.values().length];
    for(Clothes c : clothes){
        Size size = c.getSize();
        stat[size.ordinal()]++;
    }
    return stat;
}

这个方法可以这么使用:

java 复制代码
List<Clothes> clothes = Arrays.asList(new Clothes[]{
        new Clothes("C001", Size.SMALL), new Clothes("C002", Size.LARGE),
        new Clothes("C003", Size.LARGE), new Clothes("C004", Size.MEDIUM),
        new Clothes("C005", Size.SMALL), new Clothes("C006", Size.SMALL),
});
int[] stat = countBySize(clothes);
for(int i=0; i<stat.length; i++){
    System.out.println(Size.values()[i]+": "+ stat[i]);
}

输出为:

java 复制代码
        SMALL 3
        MEDIUM 1
        LARGE 2

可以达到同样的目的。但,直接使用数组需要自己维护数组索引和枚举值之间的关系,正如枚举的优点是简洁、安全、方便一样,EnumMap同样是更为简洁、安全、方便,它内部也是基于数组实现的,但隐藏了细节,提供了更为方便安全的接口。

1.2、实现原理

EnumMap有如下实例变量:

java 复制代码
private final Class<K> keyType;
private transient K[] keyUniverse;
private transient Object[] vals;
private transient int size = 0;

keyType表示类型信息,keyUniverse表示键,是所有可能的枚举值,vals表示键对应的值,size表示键值对个数。EnumMap的基本构造方法代码为:

java 复制代码
public EnumMap(Class<K> keyType) {
    this.keyType = keyType;
    keyUniverse = getKeyUniverse(keyType);
    vals = new Object[keyUniverse.length];
}

调用了getKeyUniverse以初始化键数组,这段代码又调用了其他一些比较底层的代码,就不列举了,原理是最终调用了枚举类型的values方法,values方法返回所有可能的枚举值。

保存键值对的方法是put,代码为:

java 复制代码
public V put(K key, V value) {
    typeCheck(key);
    int index = key.ordinal();
    Object oldValue = vals[index];
    vals[index] = maskNull(value);
    if(oldValue == null)
        size++;
    return unmaskNull(oldValue);
}

首先调用typeCheck检查键的类型,其代码为:

java 复制代码
private void typeCheck(K key) {
    Class keyClass = key.getClass();
    if(keyClass ! = keyType && keyClass.getSuperclass() ! = keyType)
        throw new ClassCastException(keyClass + " ! = " + keyType);
}

如果类型不对,会抛出异常。如果类型正确,调用ordinal获取索引index,并将值value放入值数组vals[index]中。EnumMap允许值为null,为了区别null值与没有值,EnumMap将null值包装成了一个特殊的对象,有两个辅助方法用于null的打包和解包,打包方法为maskNull,解包方法为unmaskNull。这个特殊对象及两个方法的代码为:

java 复制代码
private static final Object NULL = new Object() {
    public int hashCode() {
        return 0;
    }
    public String toString() {
        return "java.util.EnumMap.NULL";
    }
};
private Object maskNull(Object value) {
    return (value == null ? NULL : value);
}
private V unmaskNull(Object value) {
    return(V) (value == NULL ? null : value);
}

根据键获取值的方法是get,代码为:

java 复制代码
public V get(Object key) {
    return (isValidKey(key) ?
            unmaskNull(vals[((Enum<?>)key).ordinal()]) : null);
}

如果键有效,通过ordinal方法取索引,然后直接在值数组vals里找。isValidKey的代码与typeCheck类似,但是返回boolean值而不是抛出异常,代码为:

java 复制代码
private boolean isValidKey(Object key) {
    if(key == null)
        return false;
    //Cheaper than instanceof Enum followed by getDeclaringClass
    Class keyClass = key.getClass();
    return keyClass == keyType || keyClass.getSuperclass() == keyType;
}

查看是否包含某个值的方法是containsValue,代码为:

java 复制代码
public boolean containsValue(Object value) {
    value = maskNull(value);
    for(Object val : vals)
        if(value.equals(val))
            return true;
    return false;
}

就是遍历值数组进行比较。

根据键删除的方法是remove,其代码为:

java 复制代码
public V remove(Object key) {
  if(! isValidKey(key))
      return null;
  int index = ((Enum)key).ordinal();
    Object oldValue = vals[index];
    vals[index] = null;
    if(oldValue ! = null)
        size--;
    return unmaskNull(oldValue);
}

2、EnumSet

与EnumMap类似,之所以会有一个专门的针对枚举类型的实现类,主要是因为它可以非常高效地实现Set接口。

除了实现机制,EnumSet的用法也有一些不同。EnumSet可以说是处理枚举类型数据的一把利器,在一些应用领域,它非常方便和高效。

2.1、基本用法

与TreeSet/HashSet不同,EnumSet是一个抽象类,不能直接通过new新建,也就是说,类似下面代码是错误的:

java 复制代码
EnumSet<Size> set = new EnumSet<Size>();

不过,EnumSet提供了若干静态工厂方法,可以创建EnumSet类型的对象,比如:

java 复制代码
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType)

noneOf方法会创建一个指定枚举类型的EnumSet,不含任何元素。创建的EnumSet对象的实际类型是EnumSet的子类,待会我们再分析其具体实现。

为方便举例,我们定义一个表示星期几的枚举类Day,值从周一到周日,如下所示:

java 复制代码
enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

可以这么用noneOf方法:

java 复制代码
Set<Day> weekend = EnumSet.noneOf(Day.class);
weekend.add(Day.SATURDAY);
weekend.add(Day.SUNDAY);
System.out.println(weekend);

weekend表示休息日,noneOf返回的Set为空,添加了周六和周日,所以输出为:

复制代码
        [SATURDAY, SUNDAY]

EnumSet还有很多其他静态工厂方法,如下所示(省略了修饰public static)​:

java 复制代码
//初始集合包括指定枚举类型的所有枚举值
<E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType)
//初始集合包括枚举值中指定范围的元素
<E extends Enum<E>> EnumSet<E> range(E from, E to)
//初始集合包括指定集合的补集
<E extends Enum<E>> EnumSet<E> complementOf(EnumSet<E> s)
//初始集合包括参数中的所有元素
<E extends Enum<E>> EnumSet<E> of(E e)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4, E e5)
<E extends Enum<E>> EnumSet<E> of(E first, E... rest)
//初始集合包括参数容器中的所有元素
<E extends Enum<E>> EnumSet<E> copyOf(EnumSet<E> s)
<E extends Enum<E>> EnumSet<E> copyOf(Collection<E> c)

可以看到,EnumSet有很多重载形式的of方法,最后一个接受的是可变参数,其他重载方法看上去是多余的,之所以有其他重载方法是因为可变参数的运行效率低一些。

2.2、应用场景

在一些工作中(如医生、客服)​,不是每个工作人员每天都在的,每个人可工作的时间是不一样的,比如张三可能是周一和周三,李四可能是周四和周六,给定每个人可工作的时间,我们可能有一些问题需要回答。比如:

  • 有没有哪天一个人都不会来?
  • 有哪些天至少会有一个人来?
  • 有哪些天至少会有两个人来?
  • 有哪些天所有人都会来,以便开会?
  • 哪些人周一和周二都会来?

使用EnumSet,可以方便高效地回答这些问题,怎么做呢?我们先来定义一个表示工作人员的类Worker,如下所示:

java 复制代码
class Worker {
    String name;
    Set<Day> availableDays;
    public Worker(String name, Set<Day> availableDays) {
        this.name = name;
        this.availableDays = availableDays;
    }
    //省略getter方法
}

为演示方便,将所有工作人员的信息放到一个数组workers中,如下所示:

java 复制代码
Worker[] workers = new Worker[]{
        new Worker("张三", EnumSet.of(
                Day.MONDAY, Day.TUESDAY, Day.WEDNESDAY, Day.FRIDAY)),
        new Worker("李四", EnumSet.of(
                Day.TUESDAY, Day.THURSDAY, Day.SATURDAY)),
        new Worker("王五", EnumSet.of(Day.TUESDAY, Day.THURSDAY)),
};

每个工作人员的可工作时间用一个EnumSet表示。有了这个信息,我们就可以回答以上的问题了。哪些天一个人都不会来?代码可以为:

java 复制代码
Set<Day> days = EnumSet.allOf(Day.class);
for(Worker w : workers){
    days.removeAll(w.getAvailableDays());
}
System.out.println(days);

days初始化为所有值,然后遍历workers,从days中删除可工作的所有时间,最终剩下的就是一个人都不会来的时间,这实际是在求worker时间并集的补集,输出为:

java 复制代码
        [SUNDAY]

有哪些天至少会有一个人来?就是求worker时间的并集,代码可以为:

java 复制代码
Set<Day> days = EnumSet.noneOf(Day.class);
for(Worker w : workers){
    days.addAll(w.getAvailableDays());
}
System.out.println(days);

输出为:

java 复制代码
        [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]

有哪些天所有人都会来?就是求worker时间的交集,代码可以为:

java 复制代码
Set<Day> days = EnumSet.allOf(Day.class);
for(Worker w : workers){
	days.retainAll(w.getAvailableDays());
}
System.out.println(days);

输出为:

复制代码
        [TUESDAY]

哪些人周一和周二都会来?使用containsAll方法,代码可以为:

java 复制代码
Set<Worker> availableWorkers = new HashSet<Worker>();
for(Worker w : workers){
    if(w.getAvailableDays().containsAll(
            EnumSet.of(Day.MONDAY, Day.TUESDAY))){
        availableWorkers.add(w);
    }
}
for(Worker w : availableWorkers){
    System.out.println(w.getName());
}

输出为:

java 复制代码
        张三

哪些天至少会有两个人来?我们先使用EnumMap统计每天的人数,然后找出至少有两个人的天,代码可以为:

java 复制代码
Map<Day, Integer> countMap = new EnumMap<>(Day.class);
for(Worker w : workers){
    for(Day d : w.getAvailableDays()){
        Integer count = countMap.get(d);
        countMap.put(d, count==null?1:count+1);
    }
}
Set<Day> days = EnumSet.noneOf(Day.class);
for(Map.Entry<Day, Integer> entry : countMap.entrySet()){
    if(entry.getValue()>=2){
        days.add(entry.getKey());
    }
}
System.out.println(days);

输出为:

java 复制代码
        [TUESDAY, THURSDAY]

2.3、实现原理

EnumSet是使用位向量实现的,什么是位向量呢?就是用一个位表示一个元素的状态,用一组位表示一个集合的状态,每个位对应一个元素,而状态只可能有两种。

对于之前的枚举类Day,它有7个枚举值,一个Day的集合就可以用一个字节byte表示,最高位不用,设为0,最右边的位对应顺序最小的枚举值,从右到左,每位对应一个枚举值,1表示包含该元素,0表示不含该元素。

比如,表示包含Day.MONDAY、Day.TUESDAY、Day.WEDNESDAY、Day.FRIDAY的集合,位向量结构如图所示。

对应的整数是23。

位向量能表示的元素个数与向量长度有关,一个byte类型能表示8个元素,一个long类型能表示64个元素,那EnumSet用的长度是多少呢?

EnumSet是一个抽象类,它没有定义使用的向量长度,它有两个子类:RegularEnumSet和JumboEnumSet。RegularEnumSet使用一个long类型的变量作为位向量,long类型的位长度是64,而JumboEnumSet使用一个long类型的数组。如果枚举值个数小于等于64,则静态工厂方法中创建的就是RegularEnumSet,如果大于64就是JumboEnumSet。

理解了位向量的基本概念,下面我们来看EnumSet的实现,包括其内部组成和一些主要方法的实现。同EnumMap一样,EnumSet也有表示类型信息和所有枚举值的实例变量,如下所示:

java 复制代码
final Class<E> elementType;
final Enum[] universe;

elementType表示类型信息,universe表示枚举类的所有枚举值。

EnumSet自身没有记录元素个数的变量,也没有位向量,它们是子类维护的。对于RegularEnumSet,它用一个long类型表示位向量,代码为:

java 复制代码
        private long elements = 0L;

它没有定义表示元素个数的变量,是实时计算出来的,计算的代码是:

java 复制代码
public int size() {
    return Long.bitCount(elements);
}

对于JumboEnumSet,它用一个long数组表示,有单独的size变量,代码为:

java 复制代码
private long elements[];
private int size = 0;

我们来看EnumSet的静态工厂方法noneOf,代码为:

java 复制代码
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
Enum[] universe = getUniverse(elementType);
if(universe == null)
    throw new ClassCastException(elementType + " not an enum");
if(universe.length <= 64)
    return new RegularEnumSet<>(elementType, universe);
else
    return new JumboEnumSet<>(elementType, universe);
}

getUniverse的代码与EnumMap是一样的,就不赘述了。如果元素个数不超过64,就创建RegularEnumSet,否则创建JumboEnumSet。

RegularEnumSet和JumboEnumSet的构造方法为:

java 复制代码
RegularEnumSet(Class<E>elementType, Enum[] universe) {
    super(elementType, universe);
}
JumboEnumSet(Class<E>elementType, Enum[] universe) {
    super(elementType, universe);
    elements = new long[(universe.length + 63) >>> 6];
}

它们都调用了父类EnumSet的构造方法,其代码为:

java 复制代码
EnumSet(Class<E>elementType, Enum[] universe) {
    this.elementType = elementType;
    this.universe     = universe;
}

就是给实例变量赋值,JumboEnumSet根据元素个数分配足够长度的long数组。

其他工厂方法基本都是先调用noneOf方法构造一个空的集合,然后再调用添加方法。我们来看添加方法,RegularEnumSet的add方法的代码为:

java 复制代码
public boolean add(E e) {
    typeCheck(e);
    long oldElements = elements;
    elements |= (1L << ((Enum)e).ordinal());
    return elements ! = oldElements;
}

主要代码是按位或操作:

java 复制代码
elements |= (1L << ((Enum)e).ordinal());

(1L << ((Enum)e).ordinal())将元素e对应的位设为1,与现有的位向量elements相或,就表示添加e了。JumboEnumSet的add方法的代码为:

java 复制代码
public boolean add(E e) {
 typeCheck(e);
 int eOrdinal = e.ordinal();
 int eWordNum = eOrdinal >>> 6;
 long oldElements = elements[eWordNum];
   elements[eWordNum] |= (1L << eOrdinal);
   boolean result = (elements[eWordNum] ! = oldElements);
   if(result)
       size++;
   return result;
}

与RegularEnumSet的add方法的区别是,它先找对应的数组位置,eOrdinal >>> 6就是eOrdinal除以64,eWordNum就表示数组索引,有了索引之后,其他操作与Regular-EnumSet就类似了。

对于其他操作,JumboEnumSet的思路是类似的,主要算法与RegularEnumSet一样,主要是增加了寻找对应long位向量的操作,或者有一些循环处理,逻辑也都比较简单,后文就只介绍RegularEnumSet的实现了。

RegularEnumSet的remove方法的代码为:

java 复制代码
public boolean remove(Object e) {
    if(e == null)
        return false;
    Class eClass = e.getClass();
    if(eClass ! = elementType && eClass.getSuperclass() ! = elementType)
        return false;
    long oldElements = elements;
    elements &= ~(1L << ((Enum)e).ordinal());
    return elements ! = oldElements;
}

主要代码是:

java 复制代码
elements &= ~(1L << ((Enum)e).ordinal());

~是取反,该代码将元素e对应的位设为了0,这样就完成了删除。

查看是否包含某元素的方法是contains,其代码为:

java 复制代码
public boolean contains(Object e) {
    if(e == null)
        return false;
    Class eClass = e.getClass();
    if(eClass ! = elementType && eClass.getSuperclass() ! = elementType)
        return false;
    return (elements & (1L << ((Enum)e).ordinal())) ! = 0;
}

代码也很简单,按位与操作,不为0,则表示包含。

EnumSet的静态工厂方法complementOf是求补集,它调用的代码是:

java 复制代码
void complement() {
if(universe.length ! = 0) {
    elements = ~elements;
	    elements &= -1L >>> -universe.length;   // Mask unused bits
	}
}

这段代码有点晦涩,elements=~elements比较容易理解,就是按位取反,相当于就是取补集,但我们知道elements是64位的,当前枚举类可能没有用那么多位,取反后高位部分都变为了1,需要将超出universe.length的部分设为0。下面的代码就是在做这件事:

java 复制代码
elements &= -1L >>> -universe.length;

-1L是64位全1的二进制,我们在剖析Integer一节介绍过移动位数是负数的情况,上面代码相当于:

java 复制代码
elements &= -1L >>> (64-universe.length);

如果universe.length为7,则-1L>>>(64-7)就是二进制的1111111,与elements相与,就会将超出universe.length部分的右边的57位都变为0。

相关推荐
gjwjuejin1 小时前
从 Vue 2 到 Vue 3:一位前端工程师的实战学习笔记
java
3D探路人2 小时前
模灵 大模型聚合API 转发流程技术实现
java·大数据·开发语言·前端·人工智能·计算机视觉
程似锦吖2 小时前
无中生有 之 从0开始写一个动态定时任务管理
java·开发语言
techdashen2 小时前
dial9:给 Tokio 装上“飞行记录仪“
java·数据库·redis
ShiJiuD6668889993 小时前
springboot基础篇
java·spring boot·spring
砚底藏山河3 小时前
python、JavaScript 、JAVA,定制化数据服务,助力业务高效落地
java·javascript·python
qq_452396233 小时前
第六篇:《JMeter逻辑控制器:循环、条件和交替执行》
android·java·jmeter
humcomm3 小时前
Java 新特性2026年5月速览
java·开发语言
luck_bor3 小时前
集合进阶(Collections Set List)
java