2.2 Java集合框架

本文主要源自 JavaGuide 地址:https://github.com/Snailclimb/JavaGuide 作者:SnailClimb
仅供个人复习使用

2.2.1 Arraylist 与 LinkedList 异同

  1. 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
  2. 底层数据结构: Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向链表数据结构(JDK1.6之前为双向循环链表,JDK1.7取消了循环。注意双向链表和双向循环链表的区别:); 详细可阅读JDK1.7-LinkedList循环链表优化
  3. 时间复杂度分析
  • ArrayList 复杂度add(E e)如果需要不扩容则默认添加到表尾,所以为o(1);add(int index, E element)为o(n);remove(int index)为o(n);get(int index)为o(1);
  • LinkedList 复杂度add(E e)默认添加到表尾,所以为o(1);add(int index, E element)为o(n);remove(int index)为o(n);get(int index)为o(n);
  1. 是否支持快速随机访问: LinkedList 不支持随机元素访问,而 ArrayList 支持。
  2. 内存空间占用: ArrayList的空间浪费主要体现在在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)。当列表很大时, ArrayList更节省空间

补充:RandomAccess接口
ArrayList中实现了RandomAccess接口,而LinkedList却没有实现RandomAccess接口。
查看源码我们发现实际上 RandomAccess 接口中什么都没有定义。所以,在我看来 RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能

public interface RandomAccess {
}

Collections是集合的一个工具类,我们看一下Collections源码中的二分搜索方法。在binarySearch()方法中,它要判断传入的list 是否是RamdomAccess的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法。

因为实现RandomAccess接口的List,优先选择for循环更高效;未实现RandomAccess接口的List,优先选择iterator更高效。(foreach遍历底层也是通过iterator实现的,大size的数据,千万不要使用普通for循环)

public static <T>
int binarySearch(List<? extends Comparable<? super T>> list, T key) {
	if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
		return Collections.indexedBinarySearch(list, key);
	else
		return Collections.iteratorBinarySearch(list, key);
}

tips:
interface 关键字编译后仍然会产生 .class 文件,因此可以将接口看做是一种只包含了功能声明的特殊类。其他类用implement实现接口后,就相当于变成了该接口的子类。

  • 子类 instanceof 父类 == true
  • 父类 instanceof 子类 == false

2.2.2 ArrayList 与 Vector 区别

1、Vector类的所有方法都会加同步锁,所以是线程安全的,而ArrayList不是线程安全的。
2、ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍。

单线程时,Vector效率要差很多。而多线程时,虽然ArrayList本身不是线程安全的,但通过Collections.synchronizedList可以将其包装成一个线程安全的List。因此,Vector已经几乎不用了。

public class SynchronizedListTest {
	public static void main(String[] args) {
		// 创建一个List数组
		List<String> lists = new ArrayList<String>();
		// 添加元素
		lists.add("1");
		lists.add("2");
 
		// 创建一个synchronizedList
		List<String> synlist = Collections.synchronizedList(lists);

		// 迭代集合元素
		synchronized (lists) {
			//获取迭代器
			Iterator<String> iterator = synlist.iterator();
			//遍历
			while (iterator.hasNext()) {
				System.out.println(iterator.next());
			}
		}
	}

2.2.3 HashMap的底层实现

JDK1.8 之前

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列

HashMap的链表散列中存的一个个元素是 Entry<K, V> 键值对。

HashMap进行put元素时,会首先用hashCode()获取 key 的hash值, 然后利用hash()得到扰动后的 hash 值(即int hash = hash(key.hashCode());),然后通过 hash & (n - 1) (等价于hash%n)判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

tips:使用 hash 方法也就是扰动函数,是为了防止一些实现比较差的hashCode() 方法。换句话说使用扰动函数之后可以减少碰撞。

JDK 1.8 HashMap 的 hash 方法源码:

static final int hash(Object key) {
	int h;
	// key.hashCode():返回散列值也就是hashcode
	// ^ :按位异或
	// >>>:无符号右移,忽略符号位,空位都以0补齐
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

对比一下 JDK1.7的 HashMap 的 hash 方法源码:

static int hash(int h) {
	// This function ensures that hashCodes that differ only by
	// constant multiples at each bit position have a bounded
	// number of collisions (approximately 8 at default load factor).
	h ^= (h >>> 20) ^ (h >>> 12);
	return h ^ (h >>> 7) ^ (h >>> 4);
}

JDK 1.8 的 hash方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。而JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。

tips:所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
在这里插入图片描述

从JDK1.8开始

相比于之前的版本, JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
在这里插入图片描述
补充:红黑树

红黑树的性质:

  • 性质1:每个节点要么是黑色,要么是红色。
  • 性质2:根节点是黑色。
  • 性质3:每个叶子节点(NIL)是黑色。
  • 性质4:每个红色结点的两个子结点一定都是黑色。
  • 性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点。

红黑树相比于平衡二叉树(AVL树)树,牺牲了部分平衡性,以换取删除/插入操作时更少的旋转次数,整体来说,性能优于AVL树。因此,红黑树在很多地方都有应用。比如在 Java 集合框架中,很多部分(HashMap, TreeMap, TreeSet 等)都有红黑树的应用,这些集合均提供了很好的性能。

2.2.4 HashMap 和 Hashtable 的异同

  1. 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的;(如果要保证线程安全的话可以使用 ConcurrentHashMap ,Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。);
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 已经基本被淘汰了。
  3. 对Null key 和Null value的支持: HashMap 中,null 可以作为key,这样的key只能有一个,但可以有一个或多个key所对应的value为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
  4. 初始容量大小和每次扩充容量大小的不同 : HashTable中的hash数组初始大小是11,增加的方式是 old * 2+1。HashMap中hash数组的默认大小是16,而且大小一定是2的指数倍,增加的方式是old * 2。
  5. 底层数据结构: HashTable和HashMap底层实现几乎一样,只不过HashTable的方法添加了synchronized关键字确保线程同步检查,效率较低。而且JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

2.2.5 HashMap 的长度为什么是2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。Hash 值的范围值-2147483648到2147483647,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ hash & (n - 1) ”。(n代表数组长度)。这也就解释了 HashMap 的长度为什么是2的幂次方。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

2.2.6 HashMap 多线程操作导致死循环问题

在多线程下,进行 put 操作会导致 HashMap 死循环,原因在于 HashMap 的扩容 resize()方法。由于扩容是新建一
个数组,复制原数据到数组。由于数组下标挂有链表,所以需要复制链表,但是多线程操作有可能导致环形链表。


补充:HashMap的扩容条件
HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。

在HashMap中,threshold = loadFactor * capacity。

loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置成0.75有一个好处,那就是0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数(capacity为2也同样)。默认情况下,capacity初始为16,因此当其size大于12(16*0.75)时就会触发扩容。


扩容时会新建一个更大的数组,并通过transfer方法,移动元素。
transfer方法:

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            //核心,头插法
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

说明: newTable[i] 存的是指针,始终指向链表的头结点,所以采用头插法后,链表的元素变成了原来的逆序。

下面举个例子来说明为什么会发生死循环。假设HashMap初始化大小为4,插入3个节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容,为了验证效果,假设负载因子是1。
在这里插入图片描述
插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。
在这里插入图片描述
假设 线程2 在执行到 Entry<K,V> next = e.next; 时,cpu时间片用完了,这时线程2中的变量e指向节点a,变量next指向节点b。
然后线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,移动节点(变成逆序)。
在这里插入图片描述
这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行。
在这里插入图片描述

执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系:
在这里插入图片描述
变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:
1、执行完 Entry<K,V> next = e.next; ,目前节点a没有next,所以变量next指向null;
2、e.next = newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;
3、newTable[i] = e 把节点a放到了数组i位置;
4、e = next; 把变量e赋值为null,因为第一步中变量next就是指向null;

所以最终的引用关系是这样的:
在这里插入图片描述
节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。

另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,还有数据遗失的问题。

注意,HashMap在jdk1.7中采用头插法,在扩容时会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题。而在jdk1.8中采用的是尾插法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,但会因为其他问题产生死循环。

所以要避免在并发环境下使用HashMap,要并发就用ConcurrentHashmap。

2.2.7 HashSet 和 HashMap 区别

如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone() 方法、writeObject()方法、readObject()方法是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。)

HashMapHashSet
实现了Map接口实现了Set接口
存储<K,V>键值对仅使用key存储对象
使用put()方法将元素放入map中使用add()方法将元素放入set中,当元素值重复时则会立即返回 false,如果成功添加的话会返回 true。
较快较慢

为什么HashMap比Hashset快?

他们俩都必须使用键(key)来计算hashcode,但要考虑HashMap的键的性质,它通常是一个简单的String甚至是一个数字。

而 String和Integer的计算哈希码的速度 比 整个对象的默认哈希码 计算要快得多

换句话说,如果HashMap的键与存储在HashSet中的键是相同的对象,则性能将没有真正的区别。区别在于HashMap的键是哪种对象。

2.2.8 ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构: ConcurrentHashMap在JDK1.7底层采用 分段数组+链表 实现,JDK1.8底层采用 数组+链表/红黑树 。而Hashtable仅采用 数组+链表 的形式。
  • 实现线程安全的方式(重要)
    • ConcurrentHashMap(分段锁):在JDK1.7的时候, ConcurrentHashMap 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 数组+链表/红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
    • Hashtable(全表锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

补充: synchronized 和 CAS

  • synchronized 是悲观锁,这种线程一旦得到锁,其他需要锁的线程就挂起的情况就是悲观锁。
  • CAS 操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

参考博客:Java:CAS(乐观锁)


HashTable:
在这里插入图片描述

JDK1.7的ConcurrentHashMap:
在这里插入图片描述
JDK1.8的ConcurrentHashMap(TreeBin: 红黑树节点 Node: 链表节点):
在这里插入图片描述

2.2.9 ConcurrentHashMap线程安全的具体实现方式/底层具体实现

JDK1.7(上面有示意图):

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。

Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色(记住ReentrantLock和sychronized都是可重入锁)。HashEntry 用于存储键值对数据。

可重入锁:一个线程获得了锁之后仍然可以反复的加锁,不会出现自己阻塞自己的情况。

static class Segment<K,V> extends ReentrantLock implements Serializable {
}

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。


JDK1.8 (上面有示意图):

ConcurrentHashMap取消了Segment分段锁,采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。

synchronized只锁定当前链表或红黑二叉树的头节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

2.2.10 集合框架底层数据结构总结

List

  • Arraylist: Object数组
  • Vector: Object数组
  • LinkedList: 双向链表(JDK1.6之前为循环链表,JDK1.7取消了循环) 详细可阅读JDK1.7-LinkedList循环链表优化

Set

  • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
  • LinkedHashSet: LinkedHashSet 继承与 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样,不过还是有一点点区别的。
  • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树。)

tips:如果无排序要求可以选用HashSet;如果想取出元素的顺序和放入元素的顺序相同,那么可以选用LinkedHashSet。如果想插入、删除立即排序或者按照一定规则排序可以选用TreeSet。

Map

  • HashMap(无序,不唯一)JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突).JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间
  • LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组+链表/红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。通过对链表进行相应的操作,可以实现访问顺序相关逻辑。详细可以查看:LinkedHashMap 源码详细分析(JDK1.8)
  • HashTable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
  • TreeMap(按key有序,不唯一): 红黑树(自平衡的排序二叉树)

tips:Set类的底层都是通过Map类实现的,如TreeSet 底层是通过 TreeMap 来实现的,HashSet底层是是通过HashMap来实现的

2.2.11 ArrayList 的扩容机制

在JDK1.8中,如果通过无参构造的话,初始数组容量为0,当真正对数组进行添加时(即添加第一个元素时),才真正分配容量,默认分配容量为10;当容量不足时(容量为size,添加第size+1个元素时),先判断按照1.5倍(位运算)的比例扩容能否满足最低容量要求,若能,则以1.5倍扩容,否则以最低容量要求进行扩容。

执行add(E e)方法时,先判断ArrayList是否为空,若为空,则先初始化ArrayList初始容量为10;

若不为空,则直接判断当前容量是否满足最低容量要求;若满足最低容量要求,则直接添加;若不满足,则先扩容,再添加。

ArrayList的最大容量为Integer.MAX_VALUE。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值