Java中的集合类(一)

您所在的位置:网站首页 java去除list中的重复数据 Java中的集合类(一)

Java中的集合类(一)

2023-06-13 07:43| 来源: 网络整理| 查看: 265

1. 集合框架图

Java中的集合是用于存储对象的工具类容器,它实现了常用的数据结构,提供了一系列公开的方法用于增加、删除、修改、查找和遍历数据,降低开发成本。集合种类非常多,形成了一个比较经典的继承关系数,称为Java集合框架图,如下图所示。框架图主要分为两类:第一类按照单个元素存储的Collection,在继承树中Set和List都实现了Collection接口;第二类是按照key-value村村的Map。以上两类集合体系,无论在数据存储还是遍历,都存在非常大的差别。

Java集合框架图.png

  在集合框架图中,红色代表接口,蓝色代表抽象类,绿色代表并发包中的类,灰色代表早期线程安全的类(基本已弃用)。可以看到,与Collection相关的4条线分别是List、Set、Queue、Map,它们的子类会映射到数据结构中的表、数、哈希等。

List集合   List集合是线性数据结构的主要实现,集合元素通常存在明确的上一个和下一个元素,也存在明确的第一个元素和最后一个元素。List 集合的遍历结果是稳定的。该体系最常用的是ArrayList 和 LinkedList 两个集合类。   ArrayList 是容量可以改变的非线程安全集合。内部实现使用数组进行存储,集合扩客时会创建更大的数组空间,把原有数据复制到新数组中。AmayList 支持对元素的快速随机访问,但是插入与删除时速度通常很慢,因为这个过程很有可能需要移动其它元素。   LinkedList 的本质是双向链表。与 ArrayList 相比,LinkedList 的插入和删除速更快,但是随机访问速度则很慢。测试表明,对于 10万条的数据,与 ArrayList相比随机提取元素时存在数百倍的差距。除继承 AbstractList 抽象类外,LinkedList 还实现了另一个接口 Deque,即 double-ended queue。这个接口同时具有队列和栈的性质。LinkedList 包含3个重要的成员: sizefirst、last。size 是双向链表中节点的个数,first和last分别指向第一个和最后一个节点的引用。LinkedList 的优点在于可以将零散的内存单元通过附加引用的方式关联起来,形成按链路顺序查找的线性结构,内存利用率较高。

Queue集合   Queue(队列)是一种先进先出的数据结构,队列是一种特殊的线性表,它只许在表的一端进行获取操作,在表的另一端进行插入操作。当队列中没有元素时,称为空队列。自从BlockingQueue(阻塞队列)问世以来,队列的地位得到极大的提升在各种高并发编程场景中,由于其本身 FIFO的特性和阻塞操作的特点,经常被作为Buffer(数据缓冲区)使用。

Map集合   Map集合是以Key-Value键值对作为存储元素实现的哈希结构,Key 按某种哈函数计算后是唯一的,Value 则是可以重复的。Map 类提供三种 Collection 视图,集合框架图中,Map 指向 Collection 的箭头仅表示两个类之间的依赖关系。可以使用keySet()查看所有的Key,使用 values()查看所有的Value,使用entrySet()查看所的键值对。最早用于存储键值对的 Hashtable 因为性能瓶颈已经被淘头,而如今广使用的 HashMap,线程是不安全的。ConcurrentHashMap 是线程安全的,在JDK8中进行了锁的大幅度优化,体现出不错的性能。在多线程并发场景中,优先推荐使用ConcurrentHashMap,而不是 HashMap。TreeMap 是 Key 有序的 Map 类集合。

Set集合   Set是不允许出现重复元素的集合类型。Set 体系最常用的是 HashSet、TreeSe和LinkedHashSet 三个集合类。HashSet 从源码分析是使用HashMap 来实现的,只是Value固定为一个静态对象,使用 Key 保证集合元素的唯一性,但它不保证集合元素的顺序。TreeSet也是如此,从源码分析是使用 TreeMap 来实现的,底层为树结构,在添加新元素到集合中时,按照某种比较规则将其插入合适的位置,保证插入后的人仍然是有序的。LinkedHashSet 继承自 HashSet,具有 HashSet 的优点,内部使用链表维护了元素插入顺序。

2. 集合初始化

  集合初始化通常进行分配客量、设置特定参数等相关工作。我们以使用频率较高为ArayList 和 HashMap 为例,简要说明初始化的相关工作,并解释为什么在任何情况下,都需要显式地设定集合容量的初始大小。ArayList是存储单个元素的顺序表结构,HashMap 是存储 KV 键值对的哈希式结构。分析两者的初始化相关源码,洞悉它们的容量分配、参数设定等相关逻辑,有助于更好地了解集合特性,提升代码质量。下面先从 ArrayList 源码说起:

public class ArrayList extends AbstractList implements List, RandomAccess, Cloneable, java.io.Serializable { private static final int DEFAULT_CAPACITY = 10; // 空表的表示方法 private static final Object[] EMPTY_ELEMENTDATA = {}; transient Object[] elementData; // non-private to simplify nested class access /** * The size of the ArrayList (the number of elements it contains). * * @serial */ private int size; public ArrayList(int initialCapacity) { if (initialCapacity > 0) { // 值大于 0时,根据构造方法的参数值,忠实地创建一个多大的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } } // 公开的 ada 方法调用此内部私有方法 private void add(E e, Object[] elementData, int s) { // 当前数组能否容纳 size+1 的元素,如果不够,则调用grow来扩容 if (s == elementData.length) elementData = grow(); elementData[s] = e; size = s + 1; } //扩容的最小要求,必须容纳刚才的元素个数 +1,注意,newCapacity() // 方法才是扩容的重点! private Object[] grow(int minCapacity) { return elementData = Arrays.copyOf(elementData, newCapacity(minCapacity)); } private Object[] grow() { return grow(size + 1); } private int newCapacity(int minCapacity) { // overflow-conscious code 防止扩容1.5 倍之后,超过 int 的表示范围(第1处) int oldCapacity = elementData.length; // JDK6之前扩容 50%或50-1,但是取ceil,而之后的版本取 Floor (第2处 int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity l)的结果可能超过int可以表示的最大值,反而有可能比参数的 minCapacity 更小,则返回值为(size+1)的minCapacity。   第2处说明:如果原始容量是 13,当新添加一个元素时,依据程序中的计算方法得出13的二进制数为 1101,随后右移1位操作后得到二进制数 110,即十进制数6最终扩容的大小计算结果为 oldCapacitiy +(oldCapacity>>1)= 13+6=19。使用位算主要是基于计算效率的考虑。在JDK7之前的公式,扩容计算方式和结果为 oldCaacitiy x3÷2+1=13x3÷2+1=20。   当ArrayList 使用无参构造时,默认大小为 10,也就是说在第一次 add 的时候分配为10的容量,后续的每次扩容都会调用 Array.copyof方法,创建新数组再复制,可以想象,假如需要将 1000个元素放在 ArayList中,采用默认构造方法,需要被动扩容13次才可以究成存。反之,如果在初始化时便指定了容量new ArrayList(1000),那么在初始化 ArrayList对象的时候就直接分配 1000个储空间而避免被动扩容和数组复制的额外开销。最后,进一步设想,如果这个值达到更大量级,却没有注意初始的容量分配问题,那么无形中造成的性能损耗是非常大的,甚至导致 0OM 的风险。   再来看一下HashMap,如果它需要放置1000个元素,同样没有设置初始容量大小随着元素的不断增加,则需要被动扩客7次才可以完成存储。扩容时需要重建hash表非常影响性能。在 HashMap 中有两个比较重要的参数 Capacity 和 Load Factor,其中Capacity 决定了存储容量的大小,默认为 16;而 Lod Factor 决定了填充比例-般使用默认的0.75。基于这两个参数的乘积,HashMap 内部用 threshold 变量表示HashMap中能放入的元素个数。HashMap 容量并不会在 new 的时候分配,而是在第一次put 的时候完成创建的,源码如下(jdk1.7).

public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } ......... } /** * Inflates the table. 第一次 put 时,调用如下方法,初始化 table */ private void inflateTable(int toSize) { // Find a power of 2 >= toSize // 找到大于参数值且最接近 2 的幂值,假如输入参数是 27,则返回32 int capacity = roundUpToPowerOf2(toSize); //threshold 在不超过限制最大值的前提下等于 capacity * loadFactor threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }

  为了提高运算速度,设定 HashMap 容量大小为2ⁿ,这样的方式使计算落槽位置更快。如果初始化 HashMap 的时侯通过构造器指定了 initialCapacity,则会先计算出比 initialCapacity 大的2 的幂存入 threshold,在第一次 put 时会按照这个2的幂初始化数组大小,此后每次扩容都是增加2倍。如果没有指定初始值,log₂1000 =9.96,结合源码分析可知,如果想要容纳 1000 个元素,必须经过7次扩客。HashMap的扩容还是有不小的成本的,如果提前能够预估出 HashMap 内要放置的元素数量,就可在初始化时合理设置容量大小,避免不断扩容带来的性能损耗。   综上所述,集合初始化时,指定集合初始值大小。如果暂时无法确定集合大小那么指定相应的默认值,这也要求我们记得各种集合的默认值大小,ArayList大小10,而 HashMap 默认值为 16。

3. 数组与集合

  数组是一种顺序表,在各种高级语言中,它是组织和处理数据的一种常见方式,我们可以使用索引下标进行快速定位并获取指定位置的元素。数组的下标从0开始,但这并不符合生活常识,这源于BCPL 语言,它将指针设置在0的位置,用数组下标作为直接偏移量进行计算。为什么下标不从1 开始呢?如果是这样,计算偏移量就要使用当前下标减1的操作。加减法运算对 CPU 来说是一种双数运算,在数组下标使用频率极高的场景下,这种运算是十分耗时的。在Java 体系中,数组用以存储同-类型的对象,一旦分配内存后则无法扩容。提倡类型与中括号紧挨相连来定义数组,因为在Java的世界里,万物皆为对象。String[] 用来指代String数组对象,示例代码如下.

String[] strings = {"a", "b"};//数组引用赋值给 Object Object obj = strings;//使用类名string[]进行强制转化,并成功赋值,strings[0]的值由a变为 object ((String[]) obj)[0] = "object";

  声明数组和赋值的方式示例代码如下:

// 初始化完成,容量的大小即等于大括号内元素的个数,使用频率并不高 String[] args3 = {"a", "b"}; String[] args4 = new String[2]; args4[0] = "a"; args4[1] = "h";

  上述源码中的 args3 是静态初始化,而 args4 是动态初始化。无论静态初始化还是动态初始化,数组是固定容量大小的。注意在数组动态初始化时,出现了 new,这意味着需要在 new String[]的方括号内填写一个整数。如果写的是负数,并不会编译出错,但运行时会抛出异带:NegsivcAmysizcExoepion。对于动态大小的数组,集合提供了Vector和 AmayLsit 两个类,前者是线程安全,性能校差,基本弃用,而后者是线程不安全,它是使用频率最高的集合之一。   数组的遍历优先推荐 DK5引进的 foreach 方式,即 for(元素:数组名)的方式,可以在不使用下标的情况下遍历数组。如果需要使用数组下标,则使用for(int i=0;i System.out.println(x)); Arrays.asList(args3).stream().forEach(System.out::println);

  Arrays 是针对数组对象进行操作的工具类,包括数组的排序、查找、对比、拷贝等操作。尤其是排序,在多个JDK 版本中在不断地进化,比如原来的归并排序改成Timsort,明显地改善了集合的排序性能。另外,通过这个工具类也可以把数组转成集合。   数组与集合都是用来存储对象的容器,前者性质单一,方便易用;后者类型安全,功能强大,且两者之间必然有互相转换的方式。毕竟它们的性格迥异,在转换过程中,如果不注意转换背后的实现方式,很容易产生意料之外的问题。转换分成两种情况:数组转集合和集合转数组。在数组转集合的过程中,注意是否使用了视图方式直接返回数组中的数据。我们以Arrays.asList0为例,它把数组转换成集合时,不能使用其修改集合相关的方法,它的add/remove/clear 方法会抛出UnsupportedOperationException 异常。示例源码如下:

public class ArraysAsList { public static void main(String[] args) { String[] stringArray = new String[3]; stringArray[0] = "one"; stringArray[1] = "two"; stringArray[2] = "three"; List stringList = Arrays.asList(stringArray);// 修改转换后的集合,成功地把第一个元素“one”改成“oneList stringList.set(0, "oneList"); // 运行结果是 oneList,数组的值随之改变 System.out.println(stringArray[0]); // 这是重点:以下三行编译正确,但都会抛出运行时异常 stringList.add("four"); stringList.remove(2); stringList.clear(); } }

  事实证明,可以通过set0 方法修改元素的值,原有数组相应位置的值同时也会被修改,但是不能进行修改元素个数的任何操作,否则均会抛出UnsupportedOperationException 异常。Arays.asList 体现的是适配器模式,后台的数据仍是原有数组,set0方法即间接对数组进行值的修改操作。asList 的返回对象是一个Arrays 的内部类,它并没有实现集合个数的相关修改方法,这也正是抛出异常的原因。Arrays.asList 的源码如下:

public static List asList(T... a) { return new ArrayList(a); }

  返回的明明是ArrayList 对象,怎么就不可以随心所欲地对此集合进行修改呢?注意此ArrayList 非彼ArrayList,虽然Arrays 与ArrayList 同属于一个包,但是在Arrays类中还定义了一个ArrayList的内部类(或许命名为InnerArrayList更容易识别),根据作用域就近原则,此处的ArrayList是李鬼,即这是个内部类。此李鬼十分简单只提供了个别方法的实现,如下所示:

private static class ArrayList extends AbstractList implements RandomAccess, java.io.Serializable { private static final long serialVersionUID = -2764017481108945198L; // final修饰不准修改其引用 (第1处) private final E[] a; // 直接把数组引用赋值给 a,而 objects 是 JDK7引入的工具包 // requireNonNul1 仅仅判断是否为 null ArrayList(E[] array) { a = Objects.requireNonNull(array); } // 实现了修改特定位置元素的方法 @Override public E set(int index, E element) { E oldValue = a[index]; a[index] = element; // 注意 set 成功返回的是此位置上的旧值 return oldValue; } }

  第1处的 final 引用,用于存储集合的数组引用始终被强制指向原有数组。这个内部类并没有实现任何修改集合元麦个数的相关方法,那这个UnspportedOperationException 异常 是 从哪里 出 来的呢? 是李鬼的父类AbstractList:

public abstract class AbstractList extends AbstractCollection implements List { public void add(int index, E element) { throw new UnsupportedOperationException(); } public E remove(int index) { throw new UnsupportedOperationException(); } // clear()方法调用 remove 方法,依然抛出异常 public void clear() { removeRange(0, size()); } }

  如果李鬼Arrays.ArrayList 内部类覆写这些方法不抛出异常,避免使用者踩进这个坑会不会更好?数组具有不为五斗米折腰的气节,传递的信息是“要么直接用我,要么小心异常!”数组转集合引发的故障还是十分常见的。比如,某业务调用某接口时,对方以这样的方式返回一个 List 类型的集合对象,本方获取集合数据时,99.9%是只读操作,但在小概率情况下需要增加一个元素,从而引发故障。在使用数组转集合时,需要使用李逵iava.util.ArrayList 直接创建一个新集合,参数就是ArraysasList返回的不可变集合,源码如下:

List objectList = new java.util.ArrayList(Arrays.asList(stringArray));

  相对于数组转集合来说,集合转数组更加可控,毕竟是从相对自由的集合容器转为更加苛刻的数组。什么情况下集合需要转成数组呢?适配别人的数组接口,或者进行局部方法计算等。先看一个源码,猜猜执行结果

public class ListToArray { public static void main(String[] args) { List list = new ArrayList(3); list.add("one"); list.add("two"); list.add("three"); //泛型丢失,无法使用 string[] 接收无参方法返回的结果 (第1处) Object[] arrayl = list.toArray(); // array2 数组长度小于元素个数 (第2处) String[] array2 = new String[2]; list.toArray(array2); System.out.println(Arrays.asList(array2)); // array3 数组长度等于元素个数 (第3处) String[] array3 = new String[3]; list.toArray(array3); System.out.println(Arrays.asList(array3)); } }

执行结果如下: [null,null] [one,two, three]   第1处比较容易理解,不要用toArra()无参方法把集合转换成数组,这样会致泛型丢失;在第2处执行成功后,输出却为 null;第3处正常执行,成功地把集合数据复制到array3数组中。第2处与第3处的区别在于即将复制进去的数组容量是否足够。如果容量不够,则弃用此数组,另起炉灶,关于此方法的源码如下.

// 注意入参数组的 length 大小是重中之重,如果大于或等于集合的大小 // 则集合中的数据复制进入数组即可,如果空间不够,入参数组 a 就会被无视 // 重新分配一个空间,复制完成后返回一个新的数组引用 public T[] toArray(T[] a) { if (a.length < size) // Make a new array of a's runtime type, but my contents: // 如果数组长度小于集合 size,那么执行此语句,直接 return。(第1处) return (T[]) Arrays.copyOf(elementData, size, a.getClass()); // 如果容量足够,则直接复制 (第2处) System.arraycopy(elementData, 0, a, 0, size); if (a.length > size) a[size] = null; // 只有在数组容量足够的情况下,才返回传入参数 return a; }

  第1处和第 2 处均 复制 java.util.ArrayLit 的 elementData到数组中,这个elementData是 ArrayList 集合对象中真正用于存储数据的数组,它的定义为:transient Object[] elementData   这个存储ArrayList 真正数据的数组由 transient 修饰,表示此字段在类的序列化时将被忽略。因为集合序列化时系统会调用 writeOtject 写入流中,在网络客户端反序列化的readObject 时,会重新赋值到新对象的 elementData 中。为什么多此一举?因为 elementData 容量经常会大于实际存储元素的数量,所以只需发送真正有实际值的数组元素即可。回到刚才的场景,当入参数组客量小于集合大小时,使用Amsys.copy0f()方法,它的源码如下

public static T[] copyOf(U[] original, int newLength, Class称为通配待集合可以接受任何类型的集合引用赋值,不能添加任何元素,但可以remove和clear,并非 immutable 集合。List一般作为参数来接收外部的集合,或者返回一个不知具体元素类型的集合。   List最大的问题是只能放置一种类型,如果随意转换类型的话,就是“破窗像论”,泛型就失去了类型安全的意义。如果需要放置多种受泛型约束的类型呢?JDK 的开发者顺应了民意,实现了


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3