【Java】集合
集合框架的概述
集合、数组都是对多个数据进行存储操作的结构,简称Java容器。
Java 数组
数组在存储多个数据方面的特点:
- 一旦初始化以后,其长度就确定了。
- 数组一旦定义好,其元素的类型也就确定了。我们也就只能操作指定类型的数据了。比如:
String[] arr;int[] arr1;Object[] arr2;
数组在存储多个数据方面的缺点:
- 一旦初始化以后,其长度就不可修改。
- 数组中提供的方法非常有限,对于添加、删除、插入数据等操作,非常不便,同时效率不高。
- 获取数组中实际元素的个数的需求,数组没有现成的属性或方法可用
数组存储数据的特点:有序、可重复。对于无序、不可重复的需求,不能满足。
Java 集合
Java集合类可以用于存储数量不等的多个对象 ,还可用于保存具有映射关系的关联数组。其可分为 Collection
和Map
两种体系。
Collection
接口:单列集合,用来存储一个一个的对象List
接口:存储有序的、可重复的数据。包含ArrayList
、LinkedList
、Vector
Set
接口:存储无序的、不可重复的数据。包含HashSet
、LinkedHashSet
、TreeSet
Map
接口:双列集合,用来存储一对(key-value)一对的数据。包含HashMap
、LinkedHashMap
、TreeMap
、Hashtable
、Properties
Collection接口继承树
Map接口继承树
Collection 接口
向Collection
接口的实现类的对象中添加数据obj时,要求obj所在类要重写equals()
方法。
Collection接口常用方法:
1 | public class CollectionTest { |
向Collection
接口的实现类的对象中添加数据obj时,要求obj所在类要重写equals()
方法。
1 | public class CollectionTest { |
集合元素的遍历操作,使用迭代器Iterator
接口。获取方法:collection.iterator();
- 内部的方法:
hasNext()
和next()
- 集合对象每次调用
iterator()
方法都得到一个全新的迭代器对象,默认游标都在集合的第一个元素之前。 - 内部定义了
remove()
,可以在遍历的时候,删除集合中的元素。此方法不同于集合直接调用remove()
。
1 | public class IteratorTest { |
调用next()
方法时,先将iterator
的指针下移,再将下移后位置上的元素返回。
增强for循环:
1 | //for(集合元素的类型 局部变量 : 集合对象) |
List 接口
List接口:存储有序的、可重复的数据。是一种“动态”数组。能够替换原有的数组。其实现类有三种:
- ArrayList:作为
List
接口的主要实现类;线程不安全的(可使用Collections工具类返回线程安全的ArrayList
),效率高;底层使用Object[] elementData
数组存储,随机查找效率高,删除和插入效率低;数组长度动态扩容时每次扩容1.5倍。 - LinkedList:对于频繁的插入、删除操作,使用此类效率比
ArrayList
高(但随机查找的效率低);底层使用双向链表存储。不仅实现了List
接口,还实现了Deque
接口,可用作双端队列和栈 - Vector:作为
List
接口的古老实现类;线程安全的,效率低;底层使用Object[] elementData
数组存储;数组长度动态扩容时每次扩容2倍。不常使用
List接口中的常用方法
1 | void add(int index, Object ele) //在index位置插入ele元素 |
总结:
- 增:add(Object obj)
- 删:remove(int index) / remove(Object obj)
- 改:set(int index, Object ele)
- 查:get(int index)
- 插:add(int index, Object ele)
- 长度:size()
- 遍历:
- Iterator迭代器方式
- 增强for循环
- 普通的循环
注意:remove方法有两个重载,一个是删除指定索引位置,一个是删除指定对象
ArrayList
ArrayList
的源码分析
jdk 7情况下:
1 | ArrayList list = new ArrayList();//底层创建了长度是10的Object[]数组elementData |
默认情况下,扩容为原来的容量的1.5倍,同时需要将原有数组中的数据复制到新的数组中。结论:建议开发中使用带参的构造器:ArrayList list = new ArrayList(int capacity);
jdk 8中ArrayList
的变化:
1 | ArrayList list = new ArrayList();//底层Object[] elementData初始化为{}.并没有创建长度为10的数组 |
后续的添加和扩容操作与jdk 7无异。
小结:jdk 7中的ArrayList
的对象的创建类似于单例的饿汉式,而jdk 8中的ArrayList
的对象的创建类似于单例的懒汉式,延迟了数组的创建,节省内存。
LinkedList
LinkedList
不仅实现了List
接口,还实现了Deque
接口,可以用作双端队列或栈。LinkedList
内部维护了一个Node类型的first
和last
属性,用于快速进行头插和尾插。仅插入到队列尾部和头部的时间复杂度为 O(1)。
LinkedList
的源码分析:
1 | LinkedList list = new LinkedList(); // 内部声明了Node类型的first和last属性,默认值为null |
其中,Node定义为:体现了LinkedList
的双向链表的说法
1 | private static class Node<E> { |
Vector
Vector
的源码分析:jdk7和jdk8中通过Vector()
构造器创建对象时,底层都创建了长度为10的数组。在扩容方面,默认扩容为原来的数组长度的2倍。
问:ArrayList、LinkedList、Vector三者的异同?
- 同:三个类都是实现了
List
接口,存储数据的特点相同:存储有序的、可重复的数据 - 异:
ArrayList
和Vector
区别:ArrayList
效率高,线程不安全,Vector
效率低,线程安全;数组长度动态扩容倍数不同;ArrayList
和LinkedList
的区别:ArrayList
使用数组结构存储,LinkedList
使用双向链表结构存储;LinkedList
适用于频繁插入删除数据,Array
适用于频繁查询某个位置数据;
Set 接口
Set接口:存储无序的、不可重复的数据。Set
接口中没有额外定义新的方法,使用的都是Collection
中声明过的方法。其实现类有三个:
- HashSet:作为
Set
接口的主要实现类;线程不安全的(可使用Collections工具类返回线程安全的HashSet
;可以存储null
值;遍历时无法按照添加时的顺序遍历(HashSet
底层是通过HashMap
实现的,HashSet中的值就是HashMap
中的key)- LinkedHashSet:作为
HashSet
的子类;遍历其内部数据时,可以按照添加的顺序遍历。对于频繁的遍历操作,LinkedHashSet
效率高于HashSet
。
- LinkedHashSet:作为
- TreeSet:可以按照添加对象的指定属性,进行排序。
使用要求:向Set
(主要指:HashSet
、LinkedHashSet
)中添加的数据,其所在的类一定要重写hashCode()和equals()方法。并且重写的hashCode()
和equals()
尽可能保持一致性:相等的对象必须具有相等的hashCode 值。重写两个方法的小技巧:对象中用作 equals()
方法比较的 Field
,都应该用来计算 hashCode 值。
- OpenJDK默认的
hashCode(
)方法实现和对象内存地址无关,在版本6和7中,它是随机生成的数字,在版本8中,它是基于线程状态的数字。(AZUL-ZING的hashcode是基于地址的) - 在Hotspot中,hash值会存在对象头中。
hashCode()
方法和System.identityHashCode()
会让对象不能使用偏向锁,所以如果想使用偏向锁,那就最好重写hashCode方法。
HashSet
HashSet
底层:数组+链表的结构(HashSet
本质上是 HashMap
)。
- 无序性:不等于随机性。存储的数据在底层数组中并非按照数组索引的顺序添加,而是根据数据的哈希值决定的。
- 不可重复性:保证添加的元素按照
equals()
判断时,不能返回true。即:相同的元素只能添加一个。
添加元素的过程:
- 向
HashSet
中添加元素a,首先调用元素a所在类的hashCode
()方法,计算元素a的哈希值,此哈希值接着通过某种算法计算出在HashSet
底层数组中的存放位置(即为:索引位置); - 判断数组此位置上是否已经有元素:如果此位置上没有其他元素,则元素a添加成功。(情况1)
- 如果此位置上有其他元素b(或以链表形式存在的多个元素),则比较元素a与元素b的hash值:
- 如果hash值不相同,则元素a添加成功。(情况2)
- 如果hash值相同,进而需要调用元素a所在类的
equals()
方法:equals()
返回true,元素a添加失败;equals()
返回false,则元素a添加成功。(情况3)
对于添加成功的情况2和情况3而言:元素a与已经存在指定索引位置上数据以链表的方式存储。jdk 7:元素a放到数组中,指向原来的元素。jdk 8:原来的元素在数组中,指向元素a
1 |
|
HashSet
本质上是 HashMap
:
HashSet
的 add()
方法调用的就是 HashMap
的 put()
方法,add()
方法传入的值就是 HashMap
里的 key
,value
为一个共用的 Object
常量 PRESENT
(不放 null
是因为 remove()
时要判断是否为空):
LinkedHashSet
LinkedHashSet
作为HashSet
的子类,在添加数据的同时,每个数据还维护了两个引用,记录此数据前一个数据和后一个数据(类似于双向链表结构)。优点:对于频繁的遍历操作,LinkedHashSet
效率高于HashSet
。
1 |
|
TreeSet
TreeSet
是 SortedSet
接口的实现类,TreeSet
可以确保集合元素处于排序状态。TreeSet
底层使用红黑树结构存储数据。特点:有序,查询速度比List快。
向TreeSet
中添加的数据,要求是相同类的对象。两种排序方式:自然排序(实现Comparable
接口)和定制排序(Comparator
)。自然排序中,比较两个对象是否相同的标准为:compareTo()
返回0,不再使用equals()
。定制排序中,比较两个对象是否相同的标准为:compare()
返回0,不再使用equals()
。
1 |
|
List和Set插入数据时判断是否重复的方法:
- List:遍历每个元素,调用其
equals()
方法判断是否相等。效率较低。 - Set:计算插入数据的哈希值,判断是否已经存在该哈希值,不存在说明没有重复;若存在则调用
equals()
方法判断是否相等。效率较高。
Set 的使用
List实现类对象和Set实现类对象可以相互转换,用于过滤List中重复的元素
1 | //练习:在List内去除重复数字值,要求尽量简单 |
考察Set接口的底层原理:
1 |
|
Map 接口
Map
:双列数据,存储key-value对的数据
HashMap
:作为Map的主要实现类;线程不安全的(可使用Collections工具类返回线程安全的HashMap
),效率高;可以存储null的key和value;不可以按照添加的顺序实现遍历(HashSet
底层是通过HashMap
实现的,HashSet
中的值就是HashMap
中的key)LinkedHashMap
:继承自HashMap
,保证在遍历map元素时,可以按照添加的顺序实现遍历。原因:在原有的HashMap底层结构基础上,添加了一对指针,指向前一个和后一个元素。对于频繁的遍历操作,此类执行效率高于HashMap
TreeMap
:保证按照添加的key-value对进行排序,实现排序遍历。此时考虑key的自然排序或定制排序。底层使用红黑树Hashtable
:作为古老的实现类;线程安全的,效率低;不能存储null的key和value;其他实现细节和HashMap
一致Properties
:常用来处理配置文件。key和value都是String
类型
HashMap
的底层:数组+链表 (jdk7及之前);数组+链表+红黑树 (jdk 8)
问:
HashMap
的底层实现原理?HashMap
和Hashtable
的异同?- HashMap是线程不安全的,效率高,可以存储null的key和value
- Hashtable是线程安全的,效率低,不可以存储null的key和value
CurrentHashMap
与Hashtable
的异同?
Map 结构的理解:
Map
中的key:无序的、不可重复的,使用Set
存储所有的key。key所在的类要重写equals()
和hashCode()
方法(以HashMap为例)Map
中的value:无序的、可重复的,使用Collection
存储所有的value。value所在的类要重写equals()
方法。
一个键值对:key-value构成了一个Node
对象。Map
中的Node
:无序的、不可重复的,使用Set
存储所有的Node
HashMap 的底层实现原理
HashMap
底层是: Node
类型的数组 + Node
类型的单向链表 + 红黑树(jdk1.8)
以jdk7为例说明:
1 | HashMap map = new HashMap(); |
在实例化以后,底层创建了长度是16的一维数组Entry[] table
。经过执行过多次put()方法…
1 | map.put(key1,value1); |
首先,调用key1所在类的hashCode()
计算key1哈希值,此哈希值经过某种算法计算以后(一些位运算操作),得到在Entry
数组中的存放位置。如果此位置上的数据为空,此时的key1-value1添加成功。 ---- 情况1
如果此位置上的数据不为空(意味着此位置上存在一个或多个数据(以链表形式存在)),比较key1和已经存在的一个或多个数据的哈希值:
- 如果key1的哈希值与已经存在的数据的哈希值都不相同,此时key1-value1添加成功。---- 情况2
- 如果key1的哈希值和已经存在的某一个数据(key2-value2)的哈希值相同,继续比较:调用key1所在类的
equals(key2)
方法,比较:- 如果
equals()
返回false:此时key1-value1添加成功。----情况3 - 如果
equals()
返回true:使用value1替换value2。
- 如果
补充:关于情况2和情况3:此时key1-value1和原来的数据以链表的方式存储。
在不断的添加过程中,会涉及到扩容问题,当超出临界值(且要存放的位置非空)时,扩容。默认的扩容方式:扩容为原来容量的2倍,并将原有的数据复制过来。
jdk 8 相较于 jdk 7 在底层实现方面的不同:
new HashMap();
底层没有立刻创建一个长度为16的数组- jdk 8底层的数组是:
Node[]
,而非Entry[]
- 首次调用
put()
方法时,底层创建长度为16的数组 - jdk7底层结构只有:数组+链表。jdk8中底层结构:数组+链表+红黑树。
- 形成链表时,七上八下(jdk7:新的元素指向旧的元素。jdk8:旧的元素指向新的元素)
- 当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且当前数组的长度 > 64时,此时此索引位置上的所数据改为使用红黑树存储(若数组长度 < 64,则数组扩容,不改用红黑树)。
HashMap
内重要常量(背诵):
DEFAULT_INITIAL_CAPACITY
:HashMap
的默认容量,16DEFAULT_LOAD_FACTOR
:HashMap
的默认加载因子:0.75threshold
:扩容的临界值,等于容量*填充因子:16 * 0.75 => 12
,大于该值时扩容TREEIFY_THRESHOLD
:Bucket
中链表长度大于该默认值,转化为红黑树:8MIN_TREEIFY_CAPACITY
:桶中的Node
被树化时最小的hash表容量:64
问:负载因子值的大小,对HashMap有什么影响?
- 负载因子的大小决定了HashMap的数据密度。
- 负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
- 负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间。
- 按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。
LinkedHashMap 的底层实现原理
继承自HashMap
。修改了Entry
的内容,增加了两个Entry类型对象代表链表结构中当前对象指向的前后对象。
1 | static class Entry<K,V> extends HashMap.Node<K,V> { |
HashMap 常用方法
1 | public class MapTest { |
TreeMap
向TreeMap中添加key-value,要求key必须是由同一个类创建的对象。因为要按照key进行排序:自然排序 、定制排序。
1 | public class TreeMapTest { |
Collections 工具类
Collections
是一个操作 Set
、List
和 Map
等集合的工具类(操作数组的工具类:Arrays
)
Collections
中提供了一系列静态的方法对集合元素进行排序、查询和修改等操作, 还提供了对集合对象设置不可变、对集合对象实现同步控制等方法。
其提供的线程安全功能是通过 synchronized
方式加锁,其效率不高,也不常用。通常使用 JUC 包下的 CopyOnWriteArrayList 类实现集合类的线程安全功能。
排序操作:(均为static
方法)
reverse(List)
:反转List
中元素的顺序shuffle(List)
:对List
集合元素进行随机排序sort(List)
:根据元素的自然顺序对指定List
集合元素按升序排序sort(List,Comparator)
:根据指定的Comparator
产生的顺序对List
集合元素进行排序swap(List,int, int)
:将指定List
集合中的 i 处元素和 j 处元素进行交换
查找、替换
Object max(Collection)
:根据元素的自然顺序,返回给定集合中的最大元素Object max(Collection,Comparator)
:根据Comparator
指定的顺序,返回给定集合中的最大元素Object min(Collection)
:根据元素的自然顺序,返回给定集合中的最小元素Object min(Collection,Comparator)
:根据Comparator
指定的顺序,返回给定集合中的最小元素int frequency(Collection,Object)
:返回指定集合中指定元素的出现次数void copy(List dest,List src)
:将src
中的内容复制到dest
中boolean replaceAll(List list,Object oldVal,Object newVal)
:使用新值替换List
对象的所有旧值
同步控制:Collections
类中提供了多个 synchronizedXxx()
方法,该方法可使将指定集合包装成线程同步的集合,从而可以解决多线程并发访问集合时的线程安全问题。使用 synchronizedXxx()
方法,将返回一个新的集合类对象,其各个方法都添加了同步代码块:
1 | public int hashCode() { |
1 | public class CollectionsTest { |
问:Collection
和 Collections
的区别?
Collection
:集合中的一种接口,其实现类有ArrayList
,LinkedArrayList
等。Collections
:集合工具类,用于对集合进行排序、查找等操作。
集合类的线程安全分析
ArrayList 线程不安全
ArrayList
是线程不安全的,并发条件下对 ArrayList
的 add()
和 remove()
操作会抛出异常: java.util.ConcurrentModificationException
。
ArrayList
在迭代的时候如果同时对其进行修改就会抛出异常 ConcurrentModificationException
。原因在于调用 list.remove()
方法导致 modCount
和 expectedModCount
的值不一致。注意,像使用for-each进行迭代实际上也会出现这种问题。
解决方案一:使用 Vector
集合(较为古老,不常用)
Vector
是 List
接口的古老实现类,其特点是线程安全的,效率低,不太常用(底层是 synchronized
加锁方式的效率都比较低,因为不论是读还是写都会阻塞,而读操作没必要加锁)
解决方案二:使用 Collections
工具类(不常用)
Collections 是一个操作 Set
、List
和 Map
等集合的工具类(操作数组的工具类:Arrays
)。Collections
中提供了一系列静态的方法对集合元素进行排序、查询和修改等操作,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法。
其提供的线程安全功能是通过 synchronized
方式加锁,其效率不高,也不常用。
1 | List<String> list = Collections.synchronizedList(new ArrayList<>()); |
解决方案三:使用 JUC 包下的 CopyOnWriteArrayList 类(常用)
它相当于线程安全的 ArrayList
。和 ArrayList
一样,它是个可变数组;但是和 ArrayList
不同的是,它具有以下特性:
- 它最适合于具有以下特征的应用程序:List 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。
- 它是线程安全的。
- 因为通常需要复制整个基础数组,所以可变操作(
add()
、set()
和remove()
等等)的开销很大。 - 迭代器支持
hasNext()
,next()
等不可变操作,但不支持可变remove()
等操作。 - 使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。
CopyOnWriteArrayList
不使用独占锁,而是使用写时复制技术,将读写分离。读操作不加锁,写操作才加锁。
写时复制思路:当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样读和写的是不同的容器,因此实现了读写分离,写时不耽误并发读。
add()
方法源码:
拷贝时使用的是 Arrays
工具类的 copyOf()
方法。
总结:Vector
读写时都上锁,因此性能较差,不能同时读和写。但 CopyOnWriteArrayList 可以在读的时候进行写,效率较高。
HashSet 线程不安全
解决方案:使用 CopyOnWriteArraySet
HashMap 线程不安全
解决方案:使用 ConcurrentHashMap