3 Java 集合的实现细节

本节要点

  • Set 和 Map 关联之处
  • HashMap 底层的 Hash 存储机制
  • Hash 存储机制的快速存取原理
  • TreeMap 底层的红黑树存储机制
  • 红黑树的快速访问机制
  • Set 实现的底层依然是 Map
  • Map 和 List 的相似性
  • List 集合代表线性表
  • ArrayList 集合底层的数组实现
  • LinkedList 集合底层的链表实现
  • ArrayList 和 LinkedList 在不同场景下的性能差异
  • 不同集合类对 Iterator 提供的实现类
  • 不同集合类在 Iterator 迭代时删除元素的行为差异

常用的数据结构如线性表、链表、哈希表,JDK 提供了一系列相应的类来实现基本的数据结构,参考 java.util.* 包。常用集合的继承关系如下:

Collection
  ├List
  │ ├LinkedList
  │ ├ArrayList
  │ └Vector
  │  └Stack
  └Set

Map
  ├Hashtable
  ├HashMap
  └WeakHashMap

Map 不属于 Collection 接口。

需指出:虽然集合号称存储的是 Java 对象,但实际并不会真正将 Java 对象放入 Set 集合中,只是在 Set 集合中保留这些对象的引用,即 java 集合实际上是多个引用变量所组成的集合,由引用变量指向实际的 Java 对象。

3.1 Set 和 Map

Set 、Map 集合的类继承体系如图:

这里写图片描述

可以发现,Set 集合的接口、实现类的类名和 Map 接口灰色区域里的类别完全相似,区别只是 Set 和 Map 结尾。

EnumSet – EnumMap
SortedSet – SortedMap
TreeSet – TreeMap
NavigableSet – NavigableMap
HashSet – HashMap
LinkedHashSet – LinkedHashMap

Map 的 key 不重复,其返回所有的 key 就是一个 Set 集合。

HashSet 和 HashMap

对于 HashSet 而言,系统采用 Hash 算法决定集合元素的存储位置,可以保证快速存、取集合元素;HashSet 是基于 HashMap 实现,其底层采用 HashMap 来保存所有元素。

HashSet 的构造器如下:

    private transient HashMap<E,Object> map;

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

    /**
     * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
     * default initial capacity (16) and load factor (0.75).
     */
    public HashSet() {
        // 初始化HashSet,底层初始化一个 Hashmap
        map = new HashMap<>();
    }

    /**
     * Constructs a new set containing the elements in the specified
     * collection.  The <tt>HashMap</tt> is created with default load factor
     * (0.75) and an initial capacity sufficient to contain the elements in the specified collection.
     */
    public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
    }

    /**
     * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
     * the specified initial capacity and the specified load factor.
     */
    public HashSet(int initialCapacity, float loadFactor) {
        map = new HashMap<>(initialCapacity, loadFactor);
    }

    /**
     * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
     * the specified initial capacity and default load factor (0.75).
     */
    public HashSet(int initialCapacity) {
        map = new HashMap<>(initialCapacity);
    }

    // 调用 map 的keySet 返回所有的 key
    public Iterator<E> iterator() {
        return map.keySet().iterator();
    }

从源程序可以看出,HashSet 的实现其实是封装了一个 HashMap 对象来存储所有的集合元素,由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个静态的 Object 对象。

对于 HashMap 而言,系统将 value 当成 key 的附属,系统根据 Hash 算法来决定 key 的存储位置,可以保证快速存、取集合key,而 value 总是紧随 key 存储。

HashMap 在底层将 key-value 当成一个整体进行处理,该整体就是一个 Entry 对象,采用一个 Entry[] 数组来保存所有的 key-value 对。当需要存储一个 Entry 对象时,会根据 Hash 算法来决定其存储位置。

当创建 HashMap 时,有一个默认的负载因子(load factor),默认值为 0.75,是时间和空间成本上的一种折中:增大负载因子可以减少 Hash 表(即 Entry 数组)所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的操作(get()、put() 方法都要用到查询);减小负载因子会提高数据查询的性能,但会增大 Hash 表所占用的内存空间。

TreeSet 和 TreeMap

同样,TreeSet 底层采用一个 NavigableMap 来保存集合的元素,但 NavigableMap 只是一个接口,其底层依然是使用 TreeMap 来包含 Set 集合中的所有元素。TreeSet 里绝大部分方法也是直接调用 TreeMap 的方法来实现的。

public TreeSet() {
   this(new TreeMap<E,Object>());
}

public TreeSet(Comparator<? super E> comparator) {
   this(new TreeMap<>(comparator));
}

对于 TreeMap 而言,采用“红黑树”的排序二叉树来保存 Map 中每个 Entry —— 每个 Entry 都被当成“红黑树”的一个节点对待,示例如下:

TreeMap<String,Integer> map = new TreeMap<String,Integer>();

map.put("ccc", 89);
map.put("aaa", 80);
map.put("zzz", 70);
map.put("bbb", 90);

System.out.println(map);  // {aaa=80, bbb=90, ccc=89, zzz=70}

注:红黑树是一种自平衡二叉查找树,树中每个节点的值,都大于或等于它的左子树中所有节点的值,并且小于或等于其右子树中的所有节点的值,这确保红黑树运行时可以快速的在树中查找、定位所需节点。

当程序执行 map.put(“ccc”,89); 时,系统直接把“ccc”- 89 这个 Entry 放入 Map 中,这个 Entry 就是该“红黑树”的根节点。之后每向 map 中放入键值对(Entry),系统都需要将该 Entry 当成一个新节点添加到已有树中,保证 TreeMap 中所有 key 总是由小到大排列,如示例输出结果。

由于 TreeMap 采用“红黑树”来保存集合中的 Entry,意味着在添加元素、取出元素的性能都比 HashMap 低。

当 TreeMap 添加、取出元素时,需要通过循环找到新增 Entry 的插入位置,因此比较耗性能;

TreeMap 、TreeSet 相比 HashMap、HashSet 的优势在于:TreeMap 的所有 Entry 总是按 key 根据指定排序规则保持有序状态,TreeSet 中的所有元素也总是根据指定排序规则保持有序状态。

3.2 Map 和 List

HashMap 和 TreeMap 都有一个 values() 方法,用于获取所有 value 值,该方法返回的是一个不存储元素的 Collection 集合,当程序遍历 Collection 集合时,实际上就是遍历 Map 对象的 value。

public Collection<V> values(){
    Collection<V> vs = values;
    return (vs != null ? vs : (values = new Values()));
}

这样可以降低系统的内存开销(避免了过多的对象)。

Map 和 List 关系

两者底层实现上没有太大相似之处,在一些方法调用上存在相似而已;

因 List 含有int类型的索引,可以说 List 相当于所有 key 都是 int 类型的 Map;同理,也可以说 Map 相当于索引是任意类型的 List。

3.3 ArrayList 和 LinkedList

List 集合主要有 3 个实现类:ArrayList、LinkedList、Vector。

其中 Vector 还有一个 Stack 子类,在 Vector 类的基础上增加了 5 个方法,就将 Vector 扩展成了 Stack。本质上,Stack 依然是一个 Vector。

Stack 的源码如下:

public class Stack<E> extends Vector<E> {

    public Stack() {
    }

    /**
     * 向栈顶添加元素
     */
    public E push(E item) {
        addElement(item);
        return item;
    }

    /**
     * 栈顶元素被弹出栈
     */
    public synchronized E pop() {
        E       obj;
        int     len = size();

        obj = peek();
        removeElementAt(len - 1);

        return obj;
    }

    /**
     * 查找栈顶元素,但不弹出栈
     */
    public synchronized E peek() {
        int     len = size();
        if (len == 0)
            throw new EmptyStackException();
        return elementAt(len - 1);
    }

    /**
     * Tests if this stack is empty.
     */
    public boolean empty() {
        return size() == 0;
    }

    /**
     * 对象在栈中的位置
     */
    public synchronized int search(Object o) {
        int i = lastIndexOf(o);
        if (i >= 0) {
            return size() - i;
        }
        return -1;
    }
}

新增的方法中,有3个使用了 synchronized 修饰,这些是需要操作集合元素的方法。

从 JDK 1.6 开始,Java 提供了一个 Deque 接口,并为该接口提供了一个 ArrayDeque 实现类,在无需保证线程安全的情况下,程序完全可以使用 ArrayDeque 来代替 Stack 类。

Deque 接口代表双端队列这种数据结构,双端队列已不再是简单的队列了,它既具有队列的性质(FIFO),也具有栈的性质(FILO)。

ArrayList 和 Vector 区别

ArrayList 和 Vector 都实现了 List 接口,底层都是基于 Java 数组来存储集合元素。另外,Vector 其实就是 ArrayList 的线程安全版本。

在 ArrayList 类的源代码中有如下声明:

private transient Object[] elementData; // 保存集合元素

在 Vector 类的源代码中同样有如下声明:

private Object[] elementData; // 保存集合元素

ArrayList 使用了 transient 修饰了 elementData 数组,这保证系统序列化 ArrayList 对象时不会直接序列化该数组,而是通过类提供的 writeObject、readObject 方法来实现定制序列化
对于 Vector 没有使用 transient 修饰 elementData 数组,而且只提供了一个 writeObject 方法,并未完全实现定制序列化。

从序列化机制的角度来看,ArrayList 的实现比 Vector 的实现更安全。

ArrayList 总是将底层数组容量扩充为原来的 1.5 倍:

int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);

Vector 则比较灵活,当 capacityIncrement 值大于 0 时,扩充后的容量为原来的容量加上 capacityIncrement 的值,该值是创建对象时通过构造器 Vector(int initialCapacity, int capacityIncrement)传入的。

Vector 基本上已经被 ArrayList 代替了,因为 ArrayList 的序列化实现更安全,而且即使需要在多线程环境下使用 List 集合,依然可以避免使用 Vector,可以考虑将 ArrayList 包装成线程安全的集合类。Java 提供了一个 Collections 工具类,通过其中的 synchronizedList 方法即可将一个 ArrayList 包装成线程安全的 ArrayList。

ArrayList 和 LinkedList 的实现差异

List 代表一种线性表的数据结构,ArrayList 则是一种顺序存储的线性表,底层采用数组来保存每个集合元素;

LinkedList 是一种链式存储的线性表,其本质上是一个双向链表,不仅实现了 List 接口,还实现了 Deque 接口。所以 LinkedList 既可以当成双向链表使用,也可以当成队列使用,还可以当成栈来使用(Deque 是双端队列,同时具有队列和栈的特征)。

ArrayList 底层采用一个 elementData[] 数组保存所有的集合元素,因此 ArrayList 在插入元素时需要完成下面两件事情:

1) 保证 ArrayList 底层封装的数组长度大于集合元素的个数

2) 将插入位置之后的所有数组元素“整体搬家”,向后移动“一格”

当删除 ArrayList 集合中指定位置的元素时,其后元素也要进行“整体搬家(System.arraycopy())”,而且还需将被删除索引处的数组元素赋为 null。

public E remove(int index) {
   rangeCheck(index);

   modCount++;
   E oldValue = elementData(index);

   int numMoved = size - index - 1;
   if (numMoved > 0) {
       System.arraycopy(elementData, index+1, elementData, index, numMoved);
   }
   // clear to let GC do its work
   elementData[--size] = null; 

   return oldValue;
}

对于 ArrayList 集合而言,当添加、删除元素时,底层都需要对数组进行“整体搬家”,因此性能非常差。
但调用 get(int index)方法获取集合元素时,性能和数组几乎相同——非常快。

由于 LinkedList 采用双向链表保存集合元素,因此在添加、删除集合元素是,只需修改前后元素的 previous、next 引用的元素即可。
这里写图片描述
获取指定索引元素时,由于 LinkedList 是一个双向链表,底层程序会根据 index 判断它里链表头端近还是离链表尾端近,然后从近的一端挨个元素的查找,直至找到 index 索引处的元素。
因此查找开销比较大

/**
 * Returns the (non-null) Node at the specified element index.
 */
Node<E> node(int index) {
   // assert isElementIndex(index);
   if (index < (size >> 1)) {
       Node<E> x = first;
       for (int i = 0; i < index; i++)
           x = x.next;
       return x;
   } else {
       Node<E> x = last;
       for (int i = size - 1; i > index; i--)
           x = x.prev;
       return x;
   }
}

如果只是单纯的添加某个节点,不涉及位置查找(addfirst(E e)、addList(E e)等)的方法来操作 LinkedList 集合元素时,LinkedList 性能非常好——因为这些操作可以避免了搜索开销;
如果需要向指定索引处添加节点,LinkedList 必须先找到指定索引处的节点——该搜索过程的系统开销并不小,因此 LinkedList 的 add(int index, E e)方法的性能并不是特别好。

性能分析和适用场景

1) 当需要获取集合指定索引处元素时,ArrayList 性能大大优于 LinkedList;

2) 当向集合中添加、删除元素时,ArrayList 必须对底层数组元素整体移动,如果添加的元素导致集合长度超过底层数组长度,ArrayList 必须创建一个原数组 1.5 倍的数组,再由垃圾回收机制回收原有数组,因此系统开销比较大;
     对于 LinkedList ,其主要开销集中在 entry(int index)上,该方法挨个搜索元素,直到找到 index 处的元素,然后在该元素之前插入新元素,但性能依然高于 ArrayList。

大部分情况下,ArrayList 的性能总是优于 LinkedList,因此绝大部分应该考虑使用 ArrayList,但如果需要频繁增删元素,尤其是调用 add(E e)方法(尾部添加元素)时,则考虑使用 LinkedList。

3.4 Iterator 迭代器

Iterator 接口专门用于迭代各种 Collection 集合(Set、List),该接口只有一个 Scanner 实现类。

Java 要求各种集合都提供一个 iterator() 方法,该方法返回一个 Iterator 用于遍历该集合中的元素,至于返回的 Iterator 到底是哪种实现类,程序不用关心,这就是典型的“迭代器模式”。

**注:**Java 中的 Iterator 和 Enumeration 两个接口都是迭代器模式的代表之作,它们是迭代器模式里的“迭代器接口”。
所谓迭代器模式,是指系统为遍历多种数据列表、集合、容器而提供的一个标准的“迭代器接口”,这些数据列表、集合、容器就可面向相同的接口编程,通过相同的接口访问不同数据列表、集合、容器中的数据。不同的数据列表、集合、容器如何实现这个接口,则交给各数据列表、集合、容器自己完成。

迭代时删除指定元素

由于 Iterator 迭代器只负责迭代,它自己并没有保留集合元素,因此迭代时,通常不应该删除集合元素,否则将引发 ConcurrentModificationException 异常。

1) 对于 ArrayList、LinkedList、Vector 等 List 集合,如果正在遍历倒数第2个集合元素时,使用 List 的 remove() 方法删除集合的任意一个元素并不会引发并发操作异常,但正在遍历其他元素时,删除其他元素就会引发该异常。为什么呢?

关键在于 List 集合对应的 Iterator (Itr)的 hasNext() 方法:

public boolean hasNext(){
    return cursor != size();
}

如果下一步即将访问的元素的索引不等于集合的大小,则返回 false。

2) 对于 TreeSet、HashSet 等 Set 集合,如果正在遍历最后一个集合元素时,使用 Set 的 remove() 方法删除集合的任意一个元素并不会引发并发操作异常,但正在遍历其他元素时,删除其他元素就会引发该异常。



参考资料:

疯狂Java:突破程序员基本功的16课-常见 Java 集合的实现细节

java中的各个数据结构区别

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值