集合类存放于 java.util 包中,主要有 Collection 和 Map
1. Collection
Collcetion 是集合 List、Set、Queue 的最基本的接口
1.1 List
学习重点:ArrayList、Vector、LinkList
ArrayList
ArrayList 是非线程安全的集合
ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动,代价比较高。因此,它适合随机查找和遍历,不适合插入和删除
Vector
Vector 与 ArrayList 一样,也是通过数组实现的 ,区别为它是线程安全的,支持线程的同步,由于同步需要很高的花费,所以访问它比 ArrayList 慢
LinkList
LinkList 是非线程安全的集合
LinkedList 是用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。另外,它还提供了 List 接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用
1.2 Set
学习重点:HashSet、TreeSet、LinkHashSet
Set 注重独一无二的性质,该体系集合用于存储无序元素,所以存入和取出的顺序不一定相同,并且值不能重复。对象的相等性本质是对象 hashCode 值(java 是依据对象的内存地址计算出的) 判断的, 如果想要让两个不同的对象视为相等的,就必须覆盖 Object 的 hashCode 方法和 equals 方法
HashSet
- HashSet 基于 HashMap 来实现的,是一个不允许有重复元素的集合。
- HashSet 允许有 null 值;HashSet 是无序的,即不会记录插入的顺序。
- HashSet 不是线程安全的。
TreeSet
- TreeSet 是一个有序的集合,它的作用是提供有序的Set集合。
- TreeSet是基于 TreeMap 实现的。TreeSet中的元素支持2种排序方式:自然排序 或者 根据创建TreeSet 时提供的 Comparator 进行排序。这取决于使用的构造方法。
- TreeSet 有基本操作(add、remove 和 contains)。
- 对于java类库中定义的类,TreeSet 可以直接对其进行存储,只有String,Integer,因为这些类已经实现了 Comparable 接口,其他类型的话,需要自己自定义;如果自定义对象的话,则必须自己实现 Comparable 接口,来让TreeSet知道要如何排序。
排序原理
//查看put的源码
private final Comparator<? super K> comparator; //成员变量
//TreeSet的底层是TreeMap。如果TreeSet使用的是无参构造,那么TreeMap肯定也是无参构造,此时comparator比较器对象就是null。
public TreeMap() {
comparator = null;
}
//如果TreeSet使用的是带参构造,那么TreeMap肯定也是带参构造,此时comparator比较器对象就不是null了。
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
public V put(K key, V value) { //我们只需要看这个K 即可(因为我们看add方法里面调用put的时候就知道了)
Entry<K,V> t = root;
if (t == null) { //创造一个树的根
compare(key, key);
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
//如果是TreeSet是无参构造,comparator就是null,如果是带构造器的带参构造comparator就有值不是null
Comparator<? super K> cpr = comparator;
if (cpr != null) { //如果有构造器(比较器方式)
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
else { //如果没有构造器(自然排序)
if (key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key; //把Integer类型的key转换成了k
do {
parent = t;
cmp = k.compareTo(t.key); //进行比较,根据结果进行排序
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
通过源码我们可以发现,存入元素的时候,它创建了一个树,第一个元素就是树的根节点,后面的元素依次从树的根节点开始向后比较(创建比较器,利用comparator()方法进行比较),小的就往左边放,大的就往右边放,而相同的就不放进去(实现了唯一性)。取出元素的时候,它采用前序遍历的方法(根节点 左子树 右子树)遍历整个树,达到有序。
LinkHashSet
LinkedHashSet 集合的特点:Java.util.LinkedHashSet集合extends HashSet集合,底层是一个哈希表(数组+链表、红黑树)+链表。多了一条链表(记录元素的存储顺序),保证元素有序。
其实 LinkedHashSet 和 HashSet 的用法差不多,只是多了一条链表来记录它的存储顺序。
1.3 Queue
未完待续…
2. Map
学习重点:HashMap、HashTable、TreeMap、ConcurrentHashMap、LinkHashMap
2.1 HashMap
HashMap 底层数据结构为数组+链表+红黑树
HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的
HashMap 最多只允许一条记录的 key 为 null,允许多条记录的 value 为 null,HashMap 为非线程安全的,如果需要线程安全,可以用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。
2.1.1 put
- 首先将 K,V 封装到Node对象当中
- 然后它的底层会调用 K 的hashCode()方法得出hash值
- 通过哈希表函数/哈希算法,将hash值转换成数组的下标,下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着 k 和链表上每个节点的 k 进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。
2.1.2 get
- 先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标
- 通过上一步哈希算法转换成数组的下标之后,在通过数组下标快速定位到某个位置上。如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。
注意:java7中,HashMap 中的查找根据 hash 值能快速定位到数组的具体下标,但是之后,需要顺着链表一个个比较下去才能找到需要的值,时间复杂度取决于链表的长度,为O(n),java8中,**当链表的元素超过8个并且桶的容量大于 64 时之后,**会将链表转换为红黑树,时间复杂度为 O(logN)
重点属性的含义
table:table就是 HashMap 中的数组(数组+链表+红黑树),数组其实就是 Node 数组
size:为 HashMap 中K-V的实时数量
loadFactor:加载因子,是用来衡量 HashMap 满的程度,计算HashMap的实时加载因子的方法为:size/capacity
capacity: 是桶的数量,也就是 table 的length,即数组的长度
threshold:计算公式:capacity * loadFactor。这个值是当前已占用数组长度的最大值。超过这个数就重新resize(扩容),扩容后的 HashMap 容量是之前容量的两倍
HashMap默认初始容量为16,默认加载因子为0.75
加载因子为什么是默认为 0.75?
这其实是出于容量和性能之间平衡的结果:
- 当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
- 而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
2.2 HashTable
HashTable 是遗留类,是线程安全的,现在已经不推荐使用,被遗弃
2.3 TreeMap
TreeMap是一个内部元素排序版的HashMap,一般需要排序的时候会使用
下面展示 TreeSet 的两种用法:
- 内部自动排序,默认从小到大的规则,也可使用降序方法排序
public static void main(String[] args) {
Set<String> strSet = new TreeSet<>();
strSet.add("abc");
strSet.add("afc");
strSet.add("ade");
strSet.add("oge");
strSet.add("bfg");
strSet = ((TreeSet<String>) strSet).descendingSet();//降序排序
Iterator it = strSet.iterator();
while (it.hasNext()){
System.out.println(it.next());
}
}
//输出结果
oge
bfg
afc
ade
abc
- 实现 Comparable 自定义排序规则
//学生类实现Comparable
public class Student implements Comparable{
private int age;
private String name;
public Student(){}
public Student(int age, String name) {
this.age = age;
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public int compareTo(Object o) {
Student student = (Student) o;
if (this.age > student.getAge()){
return 1;
}else if (this.age == student.getAge()){ //年龄相等时按照名字排序
return this.name.compareTo(student.getName());
}else {
return -1;
}
}
@Override
public String toString() {
return "Student{" +
"age=" + age +
", name='" + name + '\'' +
'}';
}
}
public class Chap7Main {
public static void main(String[] args) {
Set<Student> studentSet = new TreeSet<>();
Student student1 = new Student(10,"cheng");
Student student2 = new Student(10,"aheng");
Student student3 = new Student(11,"zhang");
Student student4 = new Student(13,"gheng");
studentSet.add(student1);
studentSet.add(student2);
studentSet.add(student3);
studentSet.add(student4);
Iterator iterator = studentSet.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
}
//输出结果,先按照年龄排序,如果年龄相等则按照名字排序,升序
Student{age=10, name='aheng'}
Student{age=10, name='cheng'}
Student{age=11, name='zhang'}
Student{age=13, name='gheng'}
studentSet = ((TreeSet<Student>) studentSet).descendingSet();//降序
2.4 ConcurrentHashMap
java7下的ConcurrentHashMap
从图中我们可以看出,在 ConcurrentHashMap 内部进行了 Segment 分段,Segment 继承了 ReentrantLock,可以理解为一把锁,各个 Segment 之间都是相互独立上锁的,互不影响。
每个 Segment 的底层数据结构与 HashMap 类似,仍然是数组和链表组成的拉链法结构。默认有 0~15 共 16 个 Segment,所以最多可以同时支持 16 个线程并发操作(操作分别分布在不同的 Segment 上)。16 这个默认值可以在初始化的时候设置为其他值,但是一旦确认初始化以后,是不可以扩容的。
put加锁
通过分段加锁segment,一个hashmap里有若干个segment,每个segment里有若干个桶,桶里存放K-V形式的链表,put数据时通过key哈希得到该元素要添加到的segment,然后对segment进行加锁,然后在哈希,计算得到给元素要添加到的桶,然后遍历桶中的链表,替换或新增节点到桶中
java8下的ConcurrentHashMap
可以看出,java8下的ConcurrentHashMap的结构为数组+链表+红黑树
put源码分析
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) {
throw new NullPointerException();
}
//计算 hash 值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
//如果数组是空的,就进行初始化
if (tab == null || (n = tab.length) == 0) {
tab = initTable();
}
// 找该 hash 值对应的数组下标
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该位置是空的,就用 CAS 的方式放入新值
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null))) {
break;
}
}
//hash值等于 MOVED 代表在扩容
else if ((fh = f.hash) == MOVED) {
tab = helpTransfer(tab, f);
}
//槽点上是有值的情况
else {
V oldVal = null;
//用 synchronized 锁住当前槽点,保证并发安全
synchronized (f) {
if (tabAt(tab, i) == f) {
//如果是链表的形式
if (fh >= 0) {
binCount = 1;
//遍历链表
for (Node<K, V> e = f; ; ++binCount) {
K ek;
//如果发现该 key 已存在,就判断是否需要进行覆盖,然后返回
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent) {
e.val = value;
}
break;
}
Node<K, V> pred = e;
//到了链表的尾部也没有发现该 key,说明之前不存在,就把新值添加到链表的最后
if ((e = e.next) == null) {
pred.next = new Node<K, V>(hash, key,
value, null);
break;
}
}
}
//如果是红黑树的形式
else if (f instanceof TreeBin) {
Node<K, V> p;
binCount = 2;
//调用 putTreeVal 方法往红黑树里增加数据
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent) {
p.val = value;
}
}
}
}
}
if (binCount != 0) {
//检查是否满足条件并把链表转换为红黑树的形式,默认的 TREEIFY_THRESHOLD 阈值 是 8
if (binCount >= TREEIFY_THRESHOLD) {
treeifyBin(tab, i);
}
//putVal 的返回是添加前的旧值,所以返回 oldVal
if (oldVal != null) {
return oldVal;
}
break;
}
}
}
addCount(1L, binCount);
return null;
}
get源码分析
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算 hash 值
int h = spread(key.hashCode());
//如果整个数组是空的,或者当前槽点的数据是空的,说明 key 对应的 value 不存在,直接返回 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//判断头结点是否就是我们需要的节点,如果是则直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果头结点 hash 值小于 0,说明是红黑树或者正在扩容,就用对应的 find 方法来查找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//遍历链表来查找
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
总结一下 get 的过程:
- 计算 Hash 值,并由此值找到对应的槽点;
- 如果数组是空的或者该位置为 null,那么直接返回 null 就可以了;
- 如果该位置处的节点刚好就是我们需要的,直接返回该节点的值;
- 如果该位置节点是红黑树或者正在扩容,就用 find 方法继续查找;
- 否则那就是链表,就进行遍历链表查找。
put安全
就算有多个线程同时进行put操作,在初始化数组时使用了乐观锁CAS操作来决定到底是哪个线程有资格进行初始化,其他线程均只能等待。
用到的并发技巧:
volatile变量(sizeCtl):它是一个标记位,用来告诉其他线程这个坑位有没有人在,其线程间的可见性由volatile保证。
CAS操作:CAS操作保证了设置sizeCtl标记位的原子性,保证了只有一个线程能设置成功
并发度
Java 7 中,每个 Segment 独立加锁,最大并发个数就是 Segment 的个数,默认是 16。
但是到了 Java 8 中,锁粒度更细,理想情况下 table 数组元素的个数(也就是数组长度)就是其支持并发的最大个数,并发度比之前有提高,所以java8中 concurrentHashMap的并发度默认是16,最大能支持到 table 数组元素个数的并发度。
ConcurrentHashMap默认的并发度为16,但用户也可以在构造函数中设置并发度。当用户设置并发度时,ConcurrentHashMap会使用大于等于该值的最小2幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)。
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。(根据你并发的线程数量决定,太多会导性能降低)
保证并发安全的原理
Java 7 采用 Segment 分段锁来保证安全,而 Segment 是继承自 ReentrantLock。
Java 8 中放弃了 Segment 的设计,采用 Node + CAS + synchronized 保证线程安全。
遇到 Hash 碰撞
Java 7 在 Hash 冲突时,会使用拉链法,也就是链表的形式。
Java 8 先使用拉链法,在链表长度超过一定阈值时,将链表转换为红黑树,来提高查找效率。
查询时间复杂度
Java 7 遍历链表的时间复杂度是 O(n),n 为链表长度。
Java 8 如果变成遍历红黑树,那么时间复杂度降低为 O(log(n)),n 为树的节点个数。
2.5 LinkHashMap
基本不怎么使用,先不做学习…
3. Set是如何保证集合内的元素不可重复?
Set中插入数据时,先比较HashCode
- 如果hashCode相同才会比较equals,equals相同,则两个对象相同,不能插入,equals不同,可以插入
- 如果hashCode不同,就直接插入了,两个对象hashCode不相等,他们equals一定是false
4. HashMap如何保证key值不可重复
HashMap是基于Hash算法实现的,我们通过put(key,value)存储,get(key)来获取。当传入key时,HashMap会根据key.hashCode()计算出hash值,根据hash值将value保存在bucket里。
当计算出的hash值相同时,我们称之为hash冲突,HashMap的做法是用链表和红黑树存储相同hash值的value。当hash冲突的个数比较少时,使用链表否则使用红黑树。
key值唯一的判断如下
- 如果hashCode相同才会比较equals,equals相同,则两个对象相同,不能插入,equals不同,可以插入。
- 如果hashCode不同,就直接插入了,两个对象hashCode不相等,他们equals一定是false。