文章目录
Collection 集合接口
1、List接口
List是一个允许重复元素的指定索引、有序集合。
ArrayList
定义与特点
1、ArrayList实现了List接口的可变大小的数组。(数组可动态创建,如果元素个数超过数组容量,那么就创建一个更大的新数组)
2、它允许所有元素,包括null
3、它的size, isEmpty, get, set, iterator,add这些方法的时间复杂度是O(1),如果add n个数据则时间复杂度是O(n)
4、ArrayList没有同步方法
remove(index):
使用System.arraycopy把需要删除index后面的都往前移一位然后再把最后一个去掉
LinkedList
定义与特点
1、LinkedList是一个实现了List接口的链表维护的序列容器
2、允许null元素。
3、LinkedList提供额外的get,remove,insert方法在LinkedList的首部或尾部。这些操作使LinkedList可被用作堆栈(stack),队列(queue)或双向队列(deque)。
4、LinkedList没有同步方法。如果多个线程同时访问一个List,则必须自己实现访问同步。
单向链表
双向链表
结构 : 含有当前节点的值与前后节点的指针,添加和删除直接断开指针,改变指针指向。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
以下情况使用 ArrayList :
频繁访问列表中的某一个元素。
只需要在列表末尾进行添加和删除元素操作。
以下情况使用 LinkedList :
你需要通过循环迭代来访问列表中的某些元素。
需要频繁的在列表开头、中间、末尾等位置进行添加和删除元素操作。
数组与链表区别
相同点:
两种数据结构都属于线性表。所谓线性表,就是所有数据都排列在只有一个维度的“线”上,对其中任意一个节点来说,除了头尾,只有一个前趋,也只有一个后继。
数组:
- 数组占用的是一块连续的内存区
- 查找访问:数组因为内存地址连续,支持随机访问,时间复杂度O(1) ,性能较好
- 增加删除:因为数组的内存连续性,增加删除都是往后/往前移动后面的元素,性能差
- 内存预读方面:数组会提前预读数组申请的内存大小到缓存中,大小固定。申请过大浪费资源,申请较小,容易出界
链表:
- 而链表在内存中,是分散的,因为是分散的,由指针串联
- 查找访问:链表没有下标的概念,只有前后节点指针,所以访问时只能迭代依次查找,时间复杂度O(n),性能差
- 增加删除: 只需要断开指针,改变指针指向元素,性能快
- 内存空间动态申请,根据增加删除操作动态操控内存空间
Set接口
不包含重复的元素的Collection,即任意的两个元素e1和e2都有e1.equals(e2)=false,Set最多有一个null元素。
HashSet
定义与特点
HashSet 基于 HashMap 来实现的,是一个不允许有重复元素的集合。
HashSet 允许有 null 值。
HashSet 是无序的,即不会记录插入的顺序。
HashSet 不是线程安全的, 如果多个线程尝试同时修改 HashSet,则最终结果是不确定的。 您必须在多线程访问时显式同步对 HashSet 的并发访问。
LinkedHashSet : 保持顺序的 Set集合
TreeSet
TreeSet即是一组有次序的集合,如果没有指定排序规则Comparator,则会按照自然排序。(自然排序即e1.compareTo(e2) == 0作为比较)
Queue队列
队列是一种先进先出的数据结构,元素在队列末尾添加,在队列头部删除。Queue接口扩展自Collection,并提供插入、提取、检验等操作。
offer:向队列添加一个元素
poll:移除队列头部元素(队列为空返回null)
remove:移除队列头部元素(队列为空抛出异常)
element:获取头部元素
peek:获取头部元素
Map接口
定义与特点
Map是图接口,存储键值对映射的容器类。Map提供key到value的映射。一个Map中不能包含相同的key,每个key只能映射一个value
接口定义
public interface Map<K,V> {
...
interface Entry<K,V> {
K getKey();
V getValue();
...
}
}
泛型<K,V>分别代表key和value的类型。这时候注意到还定义了一个内部接口Entry,其实每一个键值对都是一个Entry的实例关系对象,所以Map实际其实就是Entry的一个Collection,然后Entry里面包含key,value。再设定key不重复的规则,自然就演化成了Map。
迭代方式
方式一:Set keySet() 获得所有key的集合然后通过key访问value
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");
Set<String> keySet = map.keySet();//先获取map集合的所有键的Set集合
Iterator<String> it = keySet.iterator();//有了Set集合,就可以获取其迭代器。
while(it.hasNext()) {
String key = it.next();
String value = map.get(key);//有了键可以通过map集合的get方法获取其对应的值。
System.out.println("key: "+key+"-->value: "+value);//获得key和value值
}
方式二:Collection values() 获得value的集合
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");
Collection<String> collection = map.values();//返回值是个值的Collection集合
System.out.println(collection);
方式三:Set< Map.Entry< K, V>> entrySet() 获得key-value键值对的集合
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");
//通过entrySet()方法将map集合中的映射关系取出(这个关系就是Map.Entry类型)
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//将关系集合entrySet进行迭代,存放到迭代器中
Iterator<Map.Entry<String, String>> it = entrySet.iterator();
while(it.hasNext()) {
Map.Entry<String, String> me = it.next();//获取Map.Entry关系对象me
String key = me.getKey();//通过关系对象获取key
String value = me.getValue();//通过关系对象获取value
}
HashTable
定义与特点
Hashtable继承Map接口,实现一个key-value映射的哈希表。任何非空(non-null)的对象都可作为key或者value。
添加数据使用put(key, value),取出数据使用get(key),这两个基本操作的时间开销为常数。HashTable是同步方法,线程安全但是效率低。
为什么重写hashCode 也必须重写equals
相同的对象有不同的hashCode,对哈希表的操作会出现意想不到的结果(期待的get方法返回null),要避免这种问题,只需要牢记一条:要同时复写equals方法和hashCode方法,而不要只写其中一个.
防止重复,也要满足规则:
- 如果两个对象相同,即obj1.equals(obj2)=true,则它们的hashCode必须相同
- 如果两个对象不相同,即obj1.equals(obj2)=false,则它们的hashCode不一定不相等
- 如果两个对象的hsahCode相等,则obj1.equals(obj2)=true 不一定成立
- 如果两个对象的hsahCode不相等,则obj1.equals(obj2)=true 一定不成立
HashMap(下面详解)
TreeMap
- 内部红黑树实现
- key-value不为空
- TreeMap有序
WeakHashMap
WeakHashMap是一种改进的HashMap,它对key实行“弱引用”,如果一个key不再被外部所引用,那么该key可以被GC回收
ConcurrentHashMap
JDK1.7 :
底层采用数组+链表的存储结构。其包含两个核心静态内部类 Segment和HashEntry
Segment:分段锁。继承ReentrantLock用来充当锁的角色,类似HashMap的结构,内部拥有一个Entry数组,数组中每个元素又是一个链表。每个 Segment 对象守护每个散列映射表的若干个桶。
HashEntry用来封装映射表的键 / 值对;每个桶是由若干个 HashEntry 对象链接起来的链表
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问
JDK1.8:
利用CAS+Synchronized来保证并发更新的安全,底层采用数组+链表+红黑树的存储结构。
ConcurrentHashMap 成员变量使用 volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用 CAS 操作和 synchronized 结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。
CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
HashMap详解
定义与特点
HashMap就是最基础最常用的一种Map,它无序,以散列表(数组+链表/红黑树)的方式进行存储,存储内容是键值对映射。是一种非同步的容器类,故它的线程不安全。
原理结构
通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
变量
常量
HashMap 怎么设定初始容量大小
一般如果new HashMap() 不传值,默认大小是 16,负载因子是 0.75, 如果自己传入初始大小 k,初始化大小为 大于 k 的 2 的整数次方,例如如果传 10,大小为 16
Hash计算和碰撞问题
Hash函数计算
计算出的哈希值需要满足:
(1)结果为Int类型
(2)数组长度范围内(0~n-1)
(3)尽可能充分利用数组中每一个位置
哈希函数设计:
hash 函数是先拿到通过 key 的 hashcode,是 32 位的 int 值,然后让 hashcode 的高 16 位和低 16 位进行异或操作
为什么如此设计:
这里就是解决Hash的的冲突的函数,通过让高位参与运算使得结果尽可能不一样,均匀分布,充分利用数组中每一个位置。
为什么不能用hashCode当key?
因为 key.hashCode()函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。int 值范围为-2147483648~2147483647,前后加起来大概 40 亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。你想,如果 HashMap 数组的初始大小才 16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。
为什么 HashMap 的数组长度要取 2 的整数幂?
(n - 1) & hash的计算方法,其中n是集合的容量,hash是添加的元素经过hash函数计算出来的hash值。
符号&是按位与的计算,这是位运算,特别高效,按位与&的计算方法是,只有当对应位置的数据都为1时,运算结果也为1,当HashMap的容量是2的n次幂时,(n-1)的2进制也就是1111111***111这样形式的,这样与添加元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞
扰动函数
把哈希值右移 16 位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性,让数据元素更加均衡的散列,减少碰撞。
实例:
// 计算二次Hash
int hash = hash(key.hashCode());
// 通过Hash找数组索引
int i = indexFor(hash, table.length);
1、第一次Hash:String.hashCode()
JDK的String的Hash算法。
2、第二次Hash:hash(key.hashCode())
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);
}
Hash冲突及解决
Hash冲突
如果两个不同对象的hashCode相同,这种现象称为冲突
Hash冲突解决
- 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
- 再哈希法(上面代码)
- 链地址法(HashMap目前采用的方法)
HashMap put()解析
总流程:
(1) 判断数组是否为空,为空进行初始化;
(2) 不为空,计算 k 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;
(3) 查看 table[index] 是否存在数据,没有数据就构造一个 Node 节点存放在 table[index] 中;
(4) 存在数据,说明发生了 hash 冲突(存在二个节点 key 的 hash 值一样), 继续判断 key 是否相等,相等,用新的 value 替换原数据(onlyIfAbsent 为 false);
(5) 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;
(6) 如果不是树型节点,创建普通 Node 加入链表中;判断链表长度是否大于 8, 大于的话链表转换为红黑树;
(7) 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。
初始化时怎么设定HashMap的大小
一般如果new HashMap() 不传值,默认大小是 16,负载因子是 0.75, 如果自己传入初始大小 k,初始化大小为 大于 k 的 2 的整数次方,例如如果传 10,大小为 16
HashMap get()解析
计算key的hashcode,再用计算的结果二次hash,通过indexFor(hash, table.length);找到Entry数组的索引i。然后遍历以table[i]为头节点的链表,如果发现有节点的hash,key都相同的节点时,取出该结点的值。
JDK1.7 & JDK1.8 对比
Hashmap性能优化:
(1)最重要的一点是底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构;
原因:防止发生 hash 冲突,链表长度过长,将时间复杂度由O(n)降为O(logn)
(2)插入键值对的put方法的区别,1.8中会将节点插入到链表尾部,而1.7中是采用头插
原因(头插法弊端):
因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;
A 线程在插入节点 B,B 线程也在插入,遇到容量不够开始扩容,重新 hash,放置元素,采用头插法,后遍历到的 B 节点放入了头部,这样形成了环
(3)jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值,而1.8中则是采用key的hashCode异或上key的hashCode进行无符号右移16位的结果,避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀
原因:
(4) 扩容策略:1.7中是只要不小于阈值就直接扩容2倍;而1.8的扩容策略会更优化,当数组容量未达到64时,以2倍进行扩容,超过64之后若桶中元素个数大于等于8就将链表转换为红黑树,但如果红黑树中的元素个数小于6就会还原为链表,当红黑树中元素不小于32的时候才会再次扩容。
(5)扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,位置不变或索引+旧容量大小
原因:
扩容的时候为什么 1.8 不用重新 hash 就可以直接定位原节点在新数据的位置呢?
这是由于扩容是扩大为原数组大小的 2 倍,用于计算数组位置的掩码仅仅只是高位多了一个 1,怎么理解呢?
扩容前长度为 16,用于计算(n-1) & hash 的二进制 n-1 为 0000 1111,扩容为 32 后的二进制就高位多了 1,为 0001 1111。
因为是& 运算,1 和任何数 & 都是它本身,那就分二种情况,如下图:原数据 hashcode 高位第 4 位为 0 和高位为 1 的情况;
第四位高位为 0,重新 hash 数值不变,第四位为 1,重新 hash 数值比原来大 16(旧数组的容量)
(6)concurrentHashMap的数据结构由数组+链表 变成了数组+链表+红黑树。
(7)concurrentHashMap 的分段锁 变成了CAS+Synchronized 对每个数组元素Node加锁。
线程安全
ConcurrentHashMap 成员变量使用 volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用 CAS 操作和 synchronized 结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。
总结
此章节面试必问内容,理解越深越细越出众。