对于任何一个 Java 程序员来说,集合框架(Java Containers / Collections)都是天天朝夕相处的“老伙计”了。无论是日常拉取数据库的 List 列表,还是在内存里做高频缓存的 Map,容器几乎承载了整个应用的所有血液。

许多初学者刚开始学Java集合的时候,看到一堆的ArrayList,Set.....难免感到头大;但是,不难发现,Java 的容器世界虽然看似庞大、有几十个实现类,但归根结底,它们其实只属于两大核心家族。那就是CollectionMap;

接下来的篇章,便带大家梳理一下其中比较重要的几个实现类,当然,也是面试中常问到的;话不多说,我们直接开学!

一.Map

1.1 Map简介

如果说Collection是让元素孤零零地排排坐,那么Map接口就是纯粹的“连线大师”。它的核心职责是保存 Key-Value的映射关系。在 Map 的世界里,有一个不可动摇的底层铁律:键(Key)是绝对唯一的,不能重复而每个键最多只能映射到一个值(Value)。这就像每个人的身份证号(Key)在系统里只能绑定一个特定的名字(Value),如果你强行用相同的 Key 再存一次新数据,旧的数据就会啪的一下被新内容无情覆盖。

面对这种成对出现的数据,我们该怎么去访问它呢?Map 接口非常贴心地提供了三种 Collection 视图,允许我们从不同的视角解剖这个映射表。

  1. keySet:如果你只关心所有的钥匙,可以通过“键集(keySet)”把所有的 Key 单独揪出来;
  2. values:如果你只关心背后的奖品,可以通过“值集(values)”拿到所有的 Value 集合;
  3. entrySet:而如果你想一网打尽,则可以通过“键-值映射关系集(entrySet)”

光说不练假把式。既然聊到了这三大视图,咱们来看看在真实的 Java 代码里它们是怎么被剥离出来的。先准备一盘基础数据:

Map<String, String> staffMap = new HashMap<>();
staffMap.put("9527", "周星驰");
staffMap.put("007", "凌凌漆");
staffMap.put("1024", "极客牛马");

当你只想知道系统里注册了哪些唯一的工号,根本不在乎背后是谁时,就用 keySet()

// 获取所有的 Key(键)
Set<String> employeeIds = staffMap.keySet();

System.out.println("--- 所有的工号(Keys) ---");
for (String id : employeeIds) {
    System.out.println("工号: " + id);
}

注意看,它的返回值类型是 Set<K>!为什么不是 List 或者是普通的Collection

回想一下前面说的铁律 ——Map 里的 Key 具有绝对唯一性,不能重复。这刚好和 Set 接口“去重、唯一”的特性天然吻合。所以 Java 源码规定,拿出来的键集用 Set 来装。

换一个场景,如果老板说:“把今天上班的所有员工名字打印出来,我不管他工号是几号。” 这时候你就需要用 values() 把所有的 Value 抽出来。

// 获取所有的 Value(值)
Collection<String> employeeNames = staffMap.values();

System.out.println("--- 所有的员工名字(Values) ---");
for (String name : employeeNames) {
    System.out.println("名字: " + name);
}

仔细看返回值,这次变成 Collection<V> 了。

为什么不用 Set 了?因为 Value是可以重复的!公司里叫“张三”的可能有两个,工号不同就行。既然允许重复,Java 底层就用了一个最宽泛的 Collection 接口来接住这些名字。

接下来就是第三种entrySet,这是日常开发中,出镜率最高的遍历方式。它会把 Map 里的每一对 Key-Value 整体打包成一个叫Map.Entry的小对象,然后放进一个Set里丢给你。

// 获取所有的键值对集合(Entries)
Set<Map.Entry<String, String>> entrySet = staffMap.entrySet();

System.out.println("--- 工号与名字齐活(Entries) ---");
for (Map.Entry<String, String> entry : entrySet) {
    // 每一对“红线组合”都可以通过 getKey() 和 getValue() 分别拿到头尾
    String id = entry.getKey();
    String name = entry.getValue();
    System.out.println("工号【" + id + "】绑定的员工是: " + name);
}

知道了怎么访问Map之后,我们来看一下,Map的整体结构长什么样,便于我们后序对重点的实现类进行讲解:

接口:

  • Map 是Map容器家族的祖先,Map是一个用于Map保存键值对(key-value)的接口。Map中不能包含重复的键;每个键最多只能映射到一个值。
  • SortedMap 继承了Map的接口,其内容是排序的键值对;
  • NavigableMap 接口继承了SortedMap,相比于SortedMap,NavigableMap有一系列的“导航”方法;如"获取大于/等于某对象的键值对"、"获取小于/等于某对象的键值对”等等。

实现类:

  • HashMap实现了Map,主要作用HashMap是储存无序的键值对
  • Hashtable也实现接口Map。因此,Hashtable的主要作用是储存无序的键值对。Hashtable和HashMap相比,Hashtable在它的主要方法中使用synchronized关键字修饰,来保证线程安全。但是,由于它的锁粒度太大,非常影响读写速度,所以,现代Java程序几乎不会使Hashtable,如果需要保证线程安全,一般会用ConcurrentHashMap来替代
  • TreeMap实现了NavigableMap接口。TreeMap是基于红黑树实现的一种提供顺序访问的Map,其主要作用是储存有序的键值对,所以键Key需要定义比较大小的逻辑,具体顺序可以由指定的Comparator 来决定,或者根据键的自然顺序来判断。
  • LinkedHashMap解决了HashMap本身并不保证键值对的顺序的问题,如果我们需要按照插入顺序或访问顺序来遍历键值对,就需要使用LinkedHashMap了,它在内部维护了一个双向链表
  • WeakHashMap继承了Map。WeakHashMap的键是弱引用,它的主要作用是当GC内存不足时,会自动将中的key回收,这避免了WeakHashMap的内存空间无限膨胀。很明显,WeakHashMap适用于作为缓存

1.2 HashMap

相信大家对HashMap的常用方法都比较熟悉了,所以接下来就通过一个例子带大家简单过一下,详细的就不过多阐述:

public class HashMapDemo {
    public static void main(String[] args) {

        // 1. 创建 HashMap
        HashMap<String, Integer> map = new HashMap<>();

        // 2. put() 添加元素
        map.put("Tom", 90);
        map.put("Jack", 85);
        map.put("Alice", 95);

        System.out.println("初始Map:" + map);

        // 3. get() 获取元素
        System.out.println("Tom的成绩:" + map.get("Tom"));

        // 4. getOrDefault()
        System.out.println("Bob成绩:" + map.getOrDefault("Bob", 0));

        // 5. containsKey()
        System.out.println("是否包含Jack:" + map.containsKey("Jack"));

        // 6. containsValue()
        System.out.println("是否有人得95:" + map.containsValue(95));

        // 7. put() 更新元素
        map.put("Tom", 100);
        System.out.println("Tom更新后:" + map);

        // 8. remove()
        map.remove("Jack");
        System.out.println("删除Jack:" + map);

        // 9. size()
        System.out.println("元素个数:" + map.size());

        // 10. isEmpty()
        System.out.println("是否为空:" + map.isEmpty());

        // 11. keySet() 遍历所有key
        System.out.println("\n遍历key:");
        for (String key : map.keySet()) {
            System.out.println(key);
        }

        // 12. values() 遍历所有value
        System.out.println("\n遍历value:");
        for (Integer value : map.values()) {
            System.out.println(value);
        }

        // 13. entrySet() 遍历键值对(最常用)
        System.out.println("\n遍历Entry:");
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
    }
}

要点:

  • 基于哈希表,访问速度快。进行put或者get操作,可以达到常数时间的性能O(1)。(极端情况:当hash冲突比较严重时,查找元素只能去链表遍历,复杂度为0(n);如果是Java8转化成了红黑树,那平均复杂度就是O(logn))
  • 元素之间没有顺序性:HashMap使用一个哈希函数将键映射到特定的桶中。这个映射过程是基于键的哈希值,并不是线性顺序
  • HashMap最多只允许一条记录的键Key为null;但允许多条记录的Value为null;key为null的键值对会放在第0个桶,且只允许一个(当key==null时hash值为0);
  • Java7:数据结构采用(数组+链表)实现
  • Java8:数据结构采用(数组+链表+红黑树)实现。
  • HashMap是非线程安全的,需要保证线程安推荐使用并发包中的ConcurrentHashMap

Hashtable与HashMap的简单对比:

  1. HashTable基于Dictionary类,而HashMap是基于AbstractMap。Dictionary是任何可以键映射到相应值的类的抽象类,而AbstractMap是基于Map接口的实现,它以减少实现此接口所需的工作。
  2. HashMap 的key和 value都允许为null,而Hashtable 的key和value都不允许为null。HashMap遇到key为null的时候,会进行处理,而对value没有处理;
  3. HashTable是同步的,而HashMap则不是。HashTable中的几乎所有公共的方法都是synchronized的。

1.2.1 Java7与8的核心进化

咱们把 HashMap 的皮扒开,大方向上,它里面就是一个数组,然后数组中的每个元素是一个单向链表

Java 7:纯正的“数组 + 链表”

在 Java 7 的世界里,数据结构纯粹采用(数组 + 链表)实现。

这时候,如果你去翻源码,会发现数组里存的每一个节点,都是嵌套类 Entry 的实例。这个 Entry 就是承载我们数据的核心“大腰子”,它里面雷打不动地包含四个属性:

  • key:你存进去的键。

  • value:你存进去的值。

  • hash:算出来的哈希值(提前存好,免得重复计算)。

  • next:用于单向链表的指针,指向下一个 Entry

看完其数据结构了,那么我们可以看到下述的构造方法

public HashMap(); //默认负载因子0.75
public HashMap(int initialCapacity); 

其中涉及到了几个专业词汇:

  • initialCapacity:初始化容量,即刚创建这个 Map 时它底层的数组大小。

  • capacity:当前的数组容量。有一个铁律,它始终保持$2^n$(2 的次幂)。它可以自动扩容,扩容后数组大小直接变为当前的2倍

  • loadFactor:负载因子。它是自动扩容之前,被允许的最大饱和量。官方默认给的值是 0.75capacity × loadFactor,当 size 超过这个值时,就会触发扩容。

扩容就是用一个新的大数组替换原来的小数组,并将原来数组总的值迁移到新的数组中;

默认负载因子为什么是0.75这个值呢?设太大和太小有什么问题?

1.加载因子过大: 扩容频率相对较低、哈希冲突更频繁、链表的长度更长、链表转化成红黑树的发生越频繁导致性能有一定下降;

2.加载因子过小: 扩容频率相对更频繁、空间利用率低、容易造成性能抖动:

总结完,我们来看一下经常使用的put方法:

public V put(K key, V value) {
    // 当插入第一个元素的时候,需要先初始化数组大小
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 如果 key 为 null,感兴趣的可以往里看,最终会将这个 entry 放到 table[0] 中
    if (key == null)
        return putForNullKey(value);
    // 1. 求 key 的 hash 值
    int hash = hash(key);
    // 2. 找到对应的数组下标
     //hash & (length-1)
    int i = indexFor(hash, table.length);
    // 3. 遍历一下对应下标处的链表,看是否有重复的 key 已经存在,
    //    如果有,直接覆盖,put 方法返回旧值就结束了
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        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++;
    // 4. 不存在重复的 key,将此 entry 添加到链表中,细节后面说
    addEntry(hash, key, value, i);
    return null;
}

在上述的put方法中,我们可以看到第一步是先判断table容器是否为空,如果为空的话,会调用inflateTable来进行数组初始化,我们来看一下这个方法的源码:

private void inflateTable(int toSize) {
    // 保证数组大小一定是 2 的 n 次方。
    // 比如这样初始化: new HashMap(20), 那么处理成初始数组大小是 32
    int capacity = roundUpToPowerOf2(toSize);
    // 计算扩容阈值: capacity * loadFactor
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    // 算是初始化数组吧
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity); //ignore
}

可以看到,它主要的功能是确定初始的数组大小,并计算数组扩容的阈值;

值得注意的一点在于,这里有一个将数组大小保持为2的n次方的做法,Java7 和 Java8 的HashMap 和 ConcurrentHashMap 都有相应的要求。

为啥必须为2的n次方?

因为我们在计算数组下标的时候,一般采取的是取模的方法,例如hash%length;但是"取模运算%"相对于"位与运算 a&n"是更慢的,所以能用"位与运算"替代取模运算的话,就会带来一定的性能优化。

但是,要用“位与运算&”替代“取模运算%”,需要满足一定的条件,那就是:

  • 只有当length为2的n次方的时候,hash% length=hash & (length-1)才成立

举例:
hash = 35 = 100011

length = 32= 100000
hash % length = 35%32 =3
hash & (length-1) = 35 & 31=100011&011111=000011=3

看完put方法,我们常用的get方法会更简单一些,它的实现过程可以总结如下:

  1. 根据来计算hash值
  2. 找到对应的数组下标hash&(length-1)
  3. 遍历该数组位置处的链表(此链表是hash冲突时产生的链表),直到找到相等(==或者equals)的key

在进入Java8的原理之前,我们先来总结一下Java7的:

Java 8: 数组 + 链表 + 红黑树

由上图数据结构示意图可知,到了 Java 8,底层数据结构变成了(数组 + 链表 + 红黑树)。

虽然底层的嵌套类改名叫 Node(本质还是那四个属性),但最大的改变在于:当某一个数组位置上的链表长度大于 8,并且数组总容量大于等于 64 时,这个单向链表就会当场黄袍加身,转化成一棵红黑树(节点变成 TreeNode)。这就把上面提到的极端 O(n)情况,完美优化成了 O(log n)。

Java8中put方法大致的思路和Java7类似,不同之处在于,如果哈希碰撞导致链表过(大于等于TREEIFY THRESHOLD,数值为8)并且满足容量大于等于64,就把链表转换成红黑树;如果容量不满足大于等于64的条件,那就触发扩容,扩容会将原来为8的链表节点分散开,缩短链表长度。而当红黑树节点数量减少变为6时,树会退化为链表

这个地方会出现面试题,Java8中链表转红黑树和红黑树转链表为什么是8和6?

答: 红黑树中的TreeNode是链表中的Node所占空间的2倍,虽然红黑树的查找效率为o(logN),要优于链表的o(N),但是当链表长度比较小的时候,即使全部遍历,时间复杂度也不会太高。故,要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。

  • 之所以是8: 是因为Java的源码贡献者在进行大量实验发现,hash碰撞发生8次的概率几乎为不可能事件,如果真的碰撞发生了8次,那么说明此时的链表性能已经很差了,操作的hash碰撞的可能性非常大了,后续可能还会继续发生hash碰撞。所以,在这种极端的情况下才会把链表转换为红黑树,链表转换为红黑树也是需要消耗性能的,为了挽回性能,权衡之下,才使用红黑树,提高性能的,大部分情况下hashMap还是使用链表
  • 红黑树转链表的阈值为6:主要是因为,如果也将该阈值设置8,那么当hash碰撞个数在8左右时,会反生链表和红黑树的不停相互激荡转换,白白浪费资源。中间有个差值7可以防止链表和树之间的频繁转换

面试题:为什么是红黑树而不是平衡树AVL?

答:AVL树更加严格平衡,因此可以提供更快的查找效果。但是带来的代价就是添加/删除速度更慢,因为会进行更多次数的旋转平衡操作。红黑树在添加/删除/查找方面取了折中,表现都相对较好。

总结一下put的关键要点就是:

  • Java8采用的采用尾插法,Java7采用头插法。(头插改尾插能解决并发状态下,hashmap出现死循环的问题。关于hashmap并发问题的讲解,这里不展开,后续会展开。)
  • 和Java7稍微有点不一样的地方就是,Java7是先扩容后插入新值的,Java8先插值再扩容。 

回顾一下,之前Java7在计算hash方法实现的时候,采取的公式是:

(n-1) & hash

但是在java8中,这里是把key的hashcode取出来,然后把它右移16位,然后取异或,为什么这么做呢?

之前的方法,设计者认为很容易发生碰撞。为什么这么说呢?不妨思考一下,在n-1为15(0x1111)时,其实散列真正生效的只是低4bit的有效位,当然容易碰撞了。

因此,设计者想了一个顾全大局的方法(综合考虑了速度、作用、质量),就是把高16bit和低16bit异或了下,让hash值的高位数据也能参与到最终的下标计算中,这样结果更随机,从而减少不同hash值计算出的下标发生碰撞的可能

1.2.2 HashMap不是线程安全的

在1.2.1我们讲了HashMap不是线程安全的,HashTable才是线程安全;那么在下面这一节,我们将会着重介绍为什么HashMap不是线程安全的?在并发安全的情况下,会产生什么问题呢?而且面试有时候也会问你HashMap多线程情况下会产生什么影响?

那么在详细介绍前,先开门见山其会出现两个问题:

1. 数据覆盖问题

2. 扩容时导致的死循环问题

数据覆盖问题

假设其中一个场景,A、B两个线程同时执行put()操作,且两个key都指向同一个bucket,那么此时两个结点,都会做头插法。

其中涉及到createEntry()方法,他主要工作是获取到了bucket上的头结点,然后再将新结点作为bucket的头部,并指向旧的头结点,完成一次头插法的操作。

好,那么我们现在继续刚刚的场景,当线程A和线程B都获取到了bucket的头结点后,若此时线程A的时间片用完,线程B将其新数据完成了头插法操作,此时轮到线程A操作,但这时线程A所据有的旧头结点已经过时了(并未包含线程B刚插入的新结点),线程A再做头插法操作,就会抹掉B刚刚新增的结点,导致数据丢失。

而JDK8是将头插法改为了尾插法,所以这个覆盖的问题还是存在,并没有解决。当然,不光是put()操作,删除操作、修改操作,同样都会有覆盖问题。

讲完第一个问题,我们接下来将第二个问题,也是最常遇到的情况:

扩容时导致死循环

但是这个问题不同于数据覆盖问题,它只有JDK7及以前的版本会存在死循环现象,在JDK8中,resize()方式已经做了调整,都是使用的尾插法,即使多线程下,也不会造成死循环。而JDK7能造成死循环,就是因为resize()时使用了头插法,将原本的顺序做了反转,才留下了死循环的机会。

实在不想整理了,这部分原因可以看下述的这篇文章,链接如下: 为什么 HashMap 会死循环?

扩展:Comparable和Comparator 

既然前面咱们聊到 TreeMap 必须得按顺序排列 Key,那这里面就必然牵扯到 Java 蓝星上最容易让人搞混的一对“双子星”接口:ComparableComparator

这两个词长得像亲兄弟一样,好多人面试时一两句话根本说不清。咱们今天就把这两个接口揉碎了聊,看看它们在底层到底是怎么指挥对象去排队的。

先来说 Comparable。这个接口的名字叫“可比较的”,它的特点是从内部改变一个类

你想啊,如果一个类(比如 User)实现了 Comparable 接口,那就意味着这个类在出生的时候,它的基因里就自带了“怎么跟同类比大小”的属性。它里面只有一个方法需要你实现:

compareTo(T o)

你可以把它理解为“自力更生”。对象自己跟别人比。

// 实现了 Comparable,意味着 User 具备了天生的排序能力
public class User implements Comparable<User> {
    private String name;
    private int age;

    @Override
    public int compareTo(User other) {
        // 拿自己的年龄跟别人的年龄比
        // 返回正数说明我大,负数说明我小,0说明咱俩一样大
        return this.age - other.age; 
    }
}

什么时候用它呢? 当你觉得这个类有一个绝对占主导地位的、默认的排序规则时。比如商品默认按 ID 排序,学生默认按学号排序。这就是它的“自然排序”。

那既然有了 Comparable,为什么 Java 大佬还要再搞一个 Comparator 呢?

这时候你得换个场景想。

  • 第一种情况,假设你现在在用别人写好的 String 类或者大厂封装好的老代码,你根本没有权限去修改人家的源码、往人家的基因里塞 Comparable 接口。
  • 第二种情况,你的 User 类虽然默认按年龄排序了,但今天运营产品经理突然抽风,说要在某一个活动页面按用户的姓名首字母排序,明天又说要按注册时间排序

你的类只能实现一次 Comparable,总不能天天改源码里的基因吧?

这时候 Comparator(比较器) 就闪亮登场了。它不需要类自己去实现,而是像一个外聘的第三方裁判。它里面最核心的方法是 compare(T o1, T o2),裁判站在上帝视角,把两个对象拿过来帮它们排座位。

// 这是一个独立的裁判类,专门管按名字排序
public class NameComparator implements Comparator<User> {
    @Override
    public int compare(User u1, User u2) {
        // 裁判手握两个对象,直接比对它们的名字
        return u1.getName().compareTo(u2.getName());
    }
}

回过头来,咱们把它们代入到 TreeMap 或者 Collections.sort() 的案发现场。

如果你用的是 Comparable

// 因为 User 类基因里实现了 Comparable,TreeMap 会自动调用 compareTo 帮它们排好序
TreeMap<User, String> map = new TreeMap<>();
map.put(new User("张三", 18), "VIP");

如果你用的是 Comparator

// User 类里有没有排序基因无所谓,因为我们在创建 TreeMap 的时候,硬塞给它一个外聘裁判
TreeMap<User, String> map = new TreeMap<>(new NameComparator());
// 或者直接塞 Lambda
TreeMap<User, String> map = new TreeMap<>((u1, u2) -> u1.getAge() - u2.getAge());
维度ComparableComparator
角色定位对象的天生基因(自然排序)外聘的特约裁判(定制排序)
源码位置java.lang 包下java.util 包下
核心方法compareTo(T o) (自己跟别人比)compare(T o1, T o2) (裁判比两个人)
对源码的影响必须修改目标类的源码去实现它不需要修改目标类,完全解耦
灵活性一个类只能实现一次,规则写死了可以写出无数个不同的裁判,随用随换

二.List

2.1 List简介

介绍完Map之后,我们再来看一下日常开发以及面试中也是经常使用到的list;而List的成员有以下几种实现:ArrayList,LinkedList,Stack,Vector;其中ArrayList,LinkedList是线程不安全的,而Stack与Vector是线程安全的,但是在实际开发中,这两者基本上都放弃使用了;

所以这次的重点是ArrayList以及LinkedList,在正式介绍他俩之前,我们先来对其进行整体概述:

  • 底层数据结构: ArrayList基于动态数组实现,而LinkedList基于双向链表实现。
  • 扩容: ArrayList基于动态数组实现,存在容量限制,当元素数超过最大容量时,会自动扩容; LinkedList基于双向链表实现,不存在容量限制。
  • CPU 缓存局部性:ArrayList在内存中存储连续,适合下标访问(随机访问)可以很好地利用CPU缓存局部性原理; LinkedList的元素存储不连续,访问时可能跳转到不同地址,无法利用CPU缓存局部性原理。
  • 非线程安全:ArrayList和LinkedList 都不是线程用户安全的。
  • 浅拷贝: LinkedList以及ArrayList都实现了Cloneable接口,默认为浅拷贝。
     

知识补充:

CPU cache的局部性原理: 利用时间局部性和空间局部性提高内存访问效率。时间局部性指近期访问过的数据会被再次访问,空间局部性指被访问的数据附近的数据也会被访问。通过这种原理,CPUcache能够预先缓存可能频繁访问的数据块,减少对慢速主存的访问,从而提高程序执行速度,优化整体性能。

2.2 ArrayList

ArrayList是一个数组队列,相当于动态数组。ArrayList 默认创建时容量为0,第一次添加元素时,才会使用默认初始容量10,继续添加元素时,如果发现容量已满,会自动扩容为原始大小的1.5倍。因此,应该尽量在初始化ArrayList 时,为其指定合适的初始化容量大小,减少扩容操作产生的性能开销。

ArrayList中有一个Array,那么他俩之间的区别是什么呢?我们可以从以下几点阐述:

  • ArrayList会根据实际存储的元素动态地扩容,而Array被创建之后就不能改变它的长度了。
  • ArrayList配合泛型,能将许多类型错误提前到编译阶段暴露出来,使程序更安全、更易调试;而传统的数组在这方面存在不足,某些类型错误只有到运行时才会触发异常。这就是"使用泛型确保类型安全”这句话的典型体现。
  • ArrayList中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如Integer、Double等)。Array可以直接存储基本类型数据,也可以存储对象。
  • ArrayList支持插入、删除、遍历等常见操作,并且提供了丰富的API操作方法,比如add()、remove()等。Array只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。
  • ArrayList创建时不需要指定大小,而Array创建时必须指定大小。

三. Set

和前面类似,但是我们需要注意Set集合的一个个比较重要的特点: Set集合中的元素是唯一的,所以不会出现重复的元素。总结一下:

  • HashSet中存储的元素是无序的。
  • HashSet允许null值的元素。
  • HashSet不是线程安全的。
     

说白了,HashSet 浑身上下没有一块骨头是自己的,它全都在“空手套白狼”地依赖 HashMap

你想啊,Set 的核心诉求是什么?不就是去重、保证元素唯一吗?

这时候,设计 Java 容器的那帮大佬一琢磨:既然 Map 里的 Key 本来就是唯一的、不能重复的,那我还费个什么劲去重新写一套去重的算法和数据结构?直接把 Map 拿过来套个马甲不就完了!

所以,当你点开 HashSet 的源码,你会发现它的构造函数里第一行就写着:

private transient HashMap<E,Object> map;

那你肯定会好奇,Map 存的是“键值对(Key-Value)”,Set 存的可是“单个元素”啊,这怎么对应上去?

这就不得不佩服大佬们的骚操作了。他们直接在 HashSet 内部定义了一个毫无实质卵用的虚无对象,叫 PRESENT

private static final Object PRESENT = new Object(); // 一个占位的 dummy 对象

当你调用 hashSet.add("张三") 的时候,它在底层其实是这么调用的:

public boolean add(E e) {
    return map.put(e, PRESENT) == null; // 你的值被拿去当 Key,Value 全都塞这个 PRESENT
}

看懂了吧?你放进 HashSet 里的所有元素,其实在底层全被当成了 HashMap 的 Key。 至于 Value,因为不能空着,大家就共享同一个没有任何意义的 PRESENT 占位对象。

既然底子是 HashMap,那 HashMap 的臭脾气,HashSet 自然是一成不变地全盘继承了。

最直接的表现就是:HashSet 里的元素是完全无序的、散列的

所以下次如果有人问你 HashSet 怎么实现去重的,你直接告诉他:别问我,问 HashMap 去,它只是个披着羊皮的 HashMap 罢了。

其它的LinkedHashSet和TreeSet也类似,就不在此过多介绍。

四.Queue

Java中的Queue(队列)是一种具有先进先出(FIFO)特性的集合框架,可以用于存储一组元素并支持在队列的一端插入元素,在另一端移除元素。

其中:

  • ArrayDeque是Deque的动态数组实现。
  • LinkedList是 Deque的双向链表实现。
  • PriorityQueue是Deque的堆实现。它基于二叉堆实现,并且其中的元素根据自然排序或Comparator提供的顺序排序;不接受null值元素

更多推荐