java入门(集合)

本文深入解析Java集合框架,包括List、Set、Queue和Map四种主要集合类型,详细介绍了各种集合的特性和应用场景,并重点分析了ArrayList、LinkedList、HashSet、TreeSet、HashMap、TreeMap等常用实现类的工作原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

什么是集合

集合类存放于java.util包中,主要有list(列表), set(集), map(映射),集合存放的都是对象的引用而非对象本身,所以我们称集合中的对象就是集合中对象的引用。简而言之,集合就是存放数据对象引用的容器。


图 1  Collection接口基本结构


图 2  Map接口基本结构


在 图 1 和图 2 中,黄色块为集合的接口,蓝色块为集合的实现类。表 1 介绍了这些接口的作用。
 

表 1 Java集合接口的作用
接口名称作    用
Iterator 接口集合的输出接口,主要用于遍历输出(即迭代访问)Collection 集合中的元素,Iterator 对象被称之为迭代器。迭代器接口是集合接口的父接口,实现类实现 Collection 时就必须实现 Iterator 接口。
Collection 接口是 List、Set 和 Queue 的父接口,是存放一组单值的最大接口。所谓的单值是指集合中的每个元素都是一个对象。一般很少直接使用此接口直接操作。
Queue 接口Queue 是 Java 提供的队列实现,有点类似于 List。
Dueue 接口是 Queue 的一个子接口,为双向队列。
List 接口是最常用的接口。是有序集合,允许有相同的元素。使用 List 能够精确地控制每个元素插入的位置,用户能够使用索引(元素在 List 中的位置,类似于数组下标)来访问 List 中的元素,与数组类似。
Set 接口不能包含重复的元素。
Map 接口是存放一对值的最大接口,即接口中的每个元素都是一对,以 key➡value 的形式保存。


对于 Set、List、Queue 和 Map 这 4 种集合,Java 最常用的实现类分别是 HashSet、TreeSet、ArrayList、ArrayDueue、LinkedList 和 HashMap、TreeMap 等。表 2 介绍了集合中这些常用的实现类。
 

表 2 Java集合实现类的作用
类名称作用
HashSet为优化査询速度而设计的 Set。它是基于 HashMap 实现的,HashSet 底层使用 HashMap 来保存所有元素,实现比较简单
TreeSet实现了 Set 接口,是一个有序的 Set,这样就能从 Set 里面提取一个有序序列
ArrayList一个用数组实现的 List,能进行快速的随机访问,效率高而且实现了可变大小的数组
ArrayDueue是一个基于数组实现的双端队列,按“先进先出”的方式操作集合元素
LinkedList对顺序访问进行了优化,但随机访问的速度相对较慢。此外它还有 addFirst()、addLast()、getFirst()、getLast()、removeFirst() 和 removeLast() 等方法,能把它当成栈(Stack)或队列(Queue)来用
HsahMap按哈希算法来存取键对象
TreeMap可以对键对象进行排序

List

List集合是有序的,可以对其中每个元素的插入位置进行精确地控制,通过索引来访问元素,遍历元素。常用地主要有ArrayList和LinkedList这两个类。
其中ArrayList底层是通过数组实现,随着元素的增加而动态扩容;而LinkedList底层是通过链表实现,随着元素的增加不断向链表的后端增加节点。

List的特点:

1.可以允许重复的对象。
2.可以插入多个null元素。
3.是一个有序容器,保持了每个元素的插入顺序,输出的顺序就是插入的顺序。
4.常用的实现类有 ArrayList、LinkedList 和 Vector。ArrayList 最为流行,它提供了使用索引的随意访问,而 LinkedList 则对于经常需要从 List 中添加或删除元素的场合更为合适。

ArrayList

ArrayList是一个数组队列,线程不安全集合,它允许对元素进行快速随机访问,数组的缺点是每个元素之间不能有间隔,当数组大小不足时需要增加存储能力,就需要将已经有数组的数据复制到新的存储空间中(copyOf),当从ArrayList的中间位置插入或者删除元素时,需要对数组进行赋值、移动,代价比较高,因此,它适合随机查找和遍历,不适合插入和删除。

ArrayList继承自AbstractList,实现了如下接口:

1、实现了List,得到了List集合框架的基本功能;
2、实现了RandomAccess(这是一个标记接口,没有任何方法),获得了快速随机访问存储元素的功能
3、ArrayList实现了Cloneable,得到clone()方法,可以实现克隆功能;
4、实现了Serializable,表示可以被序列化,通过序列化去传输。
它具有如下特点:

容量不固定,随着容量的增加而动态扩容;
有序集合;
插入的元素可以是null
随机查效率比LinkedList高
线程不安全,只能用在单线程环境下
ArrayList的动态扩容
一 初始化
有三种方式来初始化
1、默认的构造器,以默认的大小初始化内部的数组;public ArrayList();
2、用一个ICollection对象来构造,并将该集合的元素添加到ArrayList:public ArrayList(Collection<?extends E> c);
3、用指定大小来初始化内部的数组:public ArrayList(int initialCapacity);

默认构造器在JDK1.6之前是初始化长度为10,而在JDK1.6之后默认数组长度为0.

二 确保内部容量
使用无参构造初始化时,数组长度为0,通过ArrayList.add()添加数据时,

public boolean add(E e) {
 //确保内部容量(通过判断,如果够则不进行操作;容量不够就扩容来确保内部容量)
 ensureCapacityInternal(size + 1); // ①Increments modCount!!
 elementData[size++] = e;//②
 return true;
}


ensureCapacityInternal()方法通过将现有的元素个数数组的容量比较,如果需要扩容则扩容,再将要添加的元素放置到相应的数组中。elementData是用来存储实际内容的数组,根据传入的最小容量minCapacity和数组的容量长度对比,若minCapacity大于或等于数组容量,则需要进行扩容。

三 扩容

/*
    *增加容量,以确保它至少能容纳
    *由最小容量参数指定的元素数。
    * @param mincapacity所需的最小容量
    */
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        //>>位运算,右移动一位。 整体相当于newCapacity =oldCapacity + 0.5 * oldCapacity  
        // jdk1.7采用位运算比以前的计算方式更快
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
       //jdk1.7这里增加了对元素个数的最大个数判断,jdk1.7以前是没有最大值判断的,MAX_ARRAY_SIZE 为int最大值减去8(不清楚为什么用这个值做比较)
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // 最重要的复制元素方法
        elementData = Arrays.copyOf(elementData, newCapacity);
    }


如果实际存储数组是空数组,则最小需要容量就是默认容量,之后扩容按照1.5倍(位运算)增长。如有20个数据需要添加,那么会在第一次的时候,将ArrayList的容量变为10,在添加第11个数据的时候,ArrayList继续扩容为10*1.5=15,在添加第16个数据时,继续扩容变为15*1.5=22个。

小结
所以,如果通过无参构造的话,初始数组容量为0,当真正对数组进行添加时,才真正分配容量。第一次添加数据时,扩容到10,之后每次按照1.5倍(位运算)的比率通过copeOf的方式扩容。 在JKD1.6中实现是,如果通过无参构造的话,初始数组容量为10,每次通过copeOf的方式扩容后容量为原来的1.5倍。

LinkedList

LinkedList底层采用的是双向链表结构,因此无需像ArrayList进行扩容,很适合数据的动态插入和删除,但是LinkedList存储元素的节点需要额外的空间存储前驱和后继的引用,并且随机访问和遍历速度比较慢。另一方面,LinkedList在链表头部和尾部插入效率较高,但在指定位置进行插入时,效率一般(因为在指定位置插入需要定位到该位置处的节点O(n)),最后,LinkedList是非线程安全的集合类。

Vector

Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步,同时,Vector在内存不够需要扩容是默认扩容为原来的两倍。

Set

Set继承自Collection接口,是一个不允许出现重复元素并且无序的集合,主要有HashSet和TreeSet两大实现类。在判断重复元素的时候,Set集合会调用hashCode()和equal()方法来实现。HashSet是哈希表结构,主要利用HashMap的key来存储元素,计算插入元素的hashCode()来获取元素在集合中的位置;TreeSet是红黑树结构,每一个元素都是树中的一个节点,插入的元素都会进行排序。
Set:

1.不允许重复对象
2.无序容器,你无法保证每个元素的存储顺序,TreeSet通过 Comparator 或者 Comparable 维护了一个排序顺序。
3.只允许一个 null 元素
4.Set 接口最流行的几个实现类是 HashSet、LinkedHashSet 以及 TreeSet。最流行的是基于 HashMap 实现的 HashSet;TreeSet 还实现了 SortedSet 接口,因此 TreeSet 是一个根据其 compare() 和 compareTo() 的定义进行排序的有序容器。

HashSet

hashSet实现Set接口,底层由HashMap实现,为哈希表结构,新增元素相当于HashMap的key,value默认为一个固定的Object。
(1)HashSet继承AbstractSet类,获得了Set接口大部分的实现,减少了实现此接口所需的工作,实际上是又继承了AbstractCollection类;

(2)HashSet实现了Set接口,获取Set接口的方法,可以自定义具体实现,也可以继承AbstractSet类中的实现;

(3)HashSet实现Cloneable,得到了clone()方法,可以实现克隆功能;

(4)HashSet实现Serializable,表示可以被序列化,通过序列化去传输,典型的应用就是hessian协议。

具有如下特点:

不允许出现重复因素;

允许插入Null值;

元素无序(添加顺序和遍历顺序不一致);

线程不安全,若2个线程同时操作HashSet,必须通过代码实现同步。

不允许重复值的原因
HashSet的add()方法在底层调用的是HashMap的put(K key, V value)方法

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);  // 对传入的key计算hash值
    int i = indexFor(hash, table.length);  // 对hash值进行转换,转换成数组的index
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {  // 判断对应index下是否存在元素
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}


在向HashSet中添加元素时,先判断key的hashCode值是否相同,如果相同,则调用equals()和==进行判断,若相同则覆盖原有元素,若不同,则直接向Map中添加元素。

TreeSet

TreeSet是基于TreeMap实现的,其底层结构为红黑树(特殊的二叉查找树)。和HashSet不同的是,TreeSet具有排序功能,分为自然排序(123456)和自定义排序两类,默认是自然排序,在程序中我们可以按照任意顺序将元素插入到集合中,等到遍历时,TreeSet是按照一定顺序输出的(倒序或升序)。
它继承AbstractSet,实现NavigableSet, Cloneable, Serializable接口。

(1)与HashSet同理,TreeSet继承AbstractSet类,获得了Set集合基础实现操作;

(2)TreeSet实现NavigableSet接口,而NavigableSet又扩展了SortedSet接口。这两个接口主要定义了搜索元素的能力,例如给定某个元素,查找该集合中比给定元素大于、小于、等于的元素集合,或者比给定元素大于、小于、等于的元素个数;简单地说,实现NavigableSet接口使得TreeSet具备了元素搜索功能;

(3)TreeSet实现Cloneable接口,意味着它也可以被克隆;

(4)TreeSet实现了Serializable接口,可以被序列化,可以使用hessian协议来传输;

具有如下特点:

对插入的元素进行排序,是一个有序的集合(主要与HashSet的区别);

底层使用红黑树结构,而不是哈希表结构;

允许插入Null值;

不允许插入重复元素;

线程不安全

TreeSet元素排序
在TreeSet调用add方法时,会调用到底层TreeMap的put方法,在put方法中会调用`compare(key,key)方法,进行key大小的比较。

方式一:元素自身具备比较性
元素自身具备比较性需要元素实现Comparable接口,重写compareTo()方法,也就是让元素自身具备比较性,这种方式叫做元素的自然排序或默认排序;

方式二:容器具备比较性
当元素自身不具备比较性或者自身具备的比较性不是所需要的,那么此时可以让容器自身具备比较性。需要定义一个类实现接口Comparator,重写compare方法
注意:当Comparable比较方式和Comparator比较方式同时存在时,以Comparator的比较方式为主;

注意:在重写compareTo或者compare方法时,必须要明确比较的主要条件相等时要比较次要条件。(假设姓名和年龄一直的人为相同的人,如果想要对人按照年龄的大小来排序,如果年龄相同的人,需要如何处理?不能直接return 0,因为可能姓名不同(年龄相同姓名不同的人是不同的人)。此时就需要进行次要条件判断(需要判断姓名),只有姓名和年龄同时相等的才可以返回0.)通过return 0来判断唯一性。

----| Comparable
               compareTo(Object o)     元素自身具备比较性
----| Comparator
               compare( Object o1, Object o2 )    给容器传入比较器

Map

Map是一种键-值对集合,Map集合中的每一个元素都包含一个键对象和一个值对象。Map接口主要有两个实现类:HashMap和TreeMap,其中,HashMap是按哈希算法来存取键对象,而TreeMap可以对键对象进行排序。

HashMap

8670d732a7a84eef939893b258f28422.png

 

哈希表底层是数组+链表地实现方式,通过计算哈希值,找到数组对应的位置,如果已存在元素,就加到这个位置的链表上,在Java8之后,链表长度超过8会转化为红黑树,将链表转换为红黑树之前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树。

HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。
HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。
HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。

HashMap的实例有两个参数影响其性能——初始容量加载因子。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量;加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行rehash操作(重建内部数据结构),从而哈希表将具有大约两倍的桶数。

通常,默认加载因子是0.75,加载因子过高虽然减少空间开销,但是增加了查询成本,同时在设置初始容量应考虑映射中所需的条目数及其加载因子,以便最大限度地减少rehash操作次数,如果初始容量大于最大条目数除以加载因子,就不会发送rehash操作。

哈希值计算

哈希表底层是数组+链表地实现方式,通过计算哈希值,找到数组对应的位置,如果已存在元素,就加到这个位置的链表上,在Java8之后,链表长度超过8会转化为红黑树。
数组大小为什么是2的n次方
这个数组默认只有16,大小一定是2的n次方,因为找到数组对应的位置需要通过取余计算,取余计算是一个很耗费性能的计算,而对2的n次方取余就是对2的n次方减1取与运算。所以保持数组大小为2的n次方可以保证计算位置高效。
计算哈希值
假设有如下两个key,哈希值分别为:
key1: 0000 0000 0010 1111 1001 0000 0110 1101
key2: 0000 0000 0010 0000 1001 0000 0110 1101
如果直接使用数组默认大小,取余之后key1与key2就会到数组同一下标,其实key1和key2的高位是不一样的。
由于数组是从小到大进行扩容的,为了优化高位被忽略这个问题,HashMap源码对计算哈希值做了优化,采用高位16位组成的数字与源哈希值取异或而生成的哈希值作为用来计算HashMap的数组位置的哈希值。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}


为什么要用异或
首先,对于一个数字,转换为二进制之后,其中为的是1的位置代表这个数字的特性。对于异或运算,如果a、b两个值不相同,则异或结果为1,如果a、b两个值相同,异或结果为0。所以这样相当于让高位的特性在低位得以体现,所以采用这种算法,减少碰撞。

HashMap的默认初始长度为16的原因
HashMap的默认初始化长度是16,并且每次自动扩展或者手动初始化时,长度必须是2的幂(见上文),之所以选择16是为了服务于从Key映射到index的Hash算法。从Key映射到HashMap数组的对应位置index,会用到一个Hash函数,即:index = Hash("Key"),我们通过利用Key的HashCode值与数组长度来做位运算:index = HashCode(Key) & (Length - 1)。因为2的幂,(Length - 1)的值是所有二进制位全为1,这种情况下,只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。

HashMap转化为红黑树的阈值为什么是8
链表的时间复杂度是O(n),红黑树的时间复杂度O(logn),很显然,红黑树的复杂度是优于链表的,既然这么棒,那为什么hashmap为什么不直接就用红黑树呢?

因为树节点所占空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点。也就是说,节点少的时候,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占空间比较大,综合考虑,认为只能在节点太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树。(来源于源码)

为什么在8的时候选择使用红黑树?

为了配合使用分布良好的hashCode,树节点很少使用。并且在理想状态下,受随机分布的hashCode影响,链表中的节点遵循泊松分布,而且根据统计,链表中节点数是8的概率已经接近千分之一,而且此时链表的性能已经很差了。所以在这种比较罕见和极端的情况下,才会把链表转变为红黑树。因为链表转换为红黑树也是需要消耗性能的,特殊情况特殊处理,为了挽回性能,权衡之下,才使用红黑树,提高性能。也就是大部分情况下,hashmap还是使用的链表,如果是理想的均匀分布,节点数不到8,hashmap就自动扩容了。(来源于源码)

HashMap转化为红黑树的条件
链表长度达到8;
数组长度达到64.
如果数组长度小于64,进行数组扩容,否则转为红黑树。
当桶中元素小于等于6,树结构还原为链表形式。中间有个差值7可以防止链表和树之间频繁的转换。

HashMap为什么要引入红黑树
在jdk1.8版本后,Java对HashMap做了改进,在链表长度大于8的时候,将后面的数据存在红黑树中,以加快检索速度。在链表长度小于8的情况下,链表的查询性能和红黑树相差不大,并且转化为树还需要时间和空间,所以此时没有转化为树的必要。红黑树的引入保证了在大量hash冲突的情况下,HashMap还具有良好的查询性能。其次,红黑树是近似平衡的,相比于AVL树,检索效率差不多,但是插入和删除操作红黑树效率提高且稳定。因为红黑树不像AVL追求绝对的平衡,它允许局部很少的不平衡,这样对效率影响不大,但省去了很多没必要的调平衡操作。

HashMap的扩容机制
扩容的时机:当map中包含的Entry的数量大于等于threshold = loadFactor * capacity的时候,且新建的Entry刚好落在一个非空的桶上,此刻触发扩容机制,将其容量扩大为2倍(为什么数组大小是2的倍数,见上)。所以当size大于等于threshold的时候,并不一定会触发扩容机制,但是很可能会触发扩容机制,只要有一个新建的Entry出现哈希冲突,则立刻resize。
HashMap的扩容过程

1、扩容:创建一个新的Entry空数组,长度是原数组的2倍;
2、Rehash:遍历原Entry数组,把所有的Entry重新Hash到新数组,因为长度扩大后,Hash的规则也随之改变(hash公式:index = HashCode(key) & (length-1))。

ConcurrentHashMap

ConcurrentHashMap采用锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也被其他线程访问。另外,ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,不用对整个ConcurrentHashMap加锁。
ConcurrentHashMap为了提高本身的并发能力,在内部采用了一个叫做Segment的结构,一个Segment其实就是一个类Hash Table的结构,Segment内部维护了一个链表数组。

TreeMap

TreeMap实现了SortedMap接口,它是有序的集合,而且是一个红黑树结构,每一个key-value都作为一个红黑树的节点。
(1)TreeMap 继承于AbstractMap,而AbstractMap实现了Map接口,并实现了Map接口中定义的方法,减少了其子类继承的复杂度;

(2)TreeMap 实现了Map接口,成为Map框架中的一员,可以包含着key–value形式的元素;

(3)TreeMap 实现了NavigableMap接口,意味着拥有了更强的元素搜索能力;

(4)TreeMap 实现了Cloneable接口,实现了clone()方法,可以被克隆;

(5)TreeMap 实现了Java.io.Serializable接口,支持序列化操作,可通过Hessian协议进行传输;

TreeMap具有如下特点:

不允许出现重复的key;

可以插入null键,null值;

可以对元素进行排序;

无序集合(插入和遍历顺序不一致);

红黑树

红黑树是一个高效的检索二叉树,有如下特点:

每个节点只能是红色或者黑色;
根节点永远是黑色;
所有的叶子的子节点都是空节点,并且都是黑色(???=)
每个红色节点的两个子节点都是黑色的(不会有两个连续的红色节点)
从任一节点到其子树的路径都包含相同数量的黑色节点(叶子节点到根节点的黑色节点数量每条路径都相同)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值