二月Java温故而知新,把集合这一块知识再整体回顾一下,
前言:
集合差不多分为两块,一块以Collection为底层接口往上延伸,一块是以Map为底层接口往上延伸,这两块大致延伸关系如下图所示:
基本概念:
Collection:是集合的根接口,定义了一系列基础方法
Set:是不可重复集,即集合中不能包含重复元素
List:有序集合,集合中允许包含重复元素
Map:是一种key-value的存储集合,不能包含重复的key,每个key只能映射到同一个value
HashMap:根据键的hashcode值进行存储,最多允许一个key的值为null,允许多个value的值为null,默认容量是16,加载因子是0.75f,每次扩容一倍,是一种异步式线程不安全的映射,如果想要实现线程安全,如下即可
Map<String, String> map = new HashMap<>();
// 使map变为线程安全
Map<String, String> map1 = Collections.synchronizedMap(map);
HashTable:是一种遗留类,常用功能与HashMap类似,不过该类继承自Dictionary类,不允许键值为null,默认初始容量是11,加载因子也是0.75f,是一种同步式线程安全的映射,其并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁,而HashTable对get/put等所有操作都加了synchronized,相当于锁了整张表,将所有的操作串行化。
ConcurrentHashMap:线程安全的map,允许多个修改操作并发进行,其关键在于使用了分段锁。它使用多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hashtable,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。
LinkedHashMap:是HashMap的一个子类,保存了元素的插入顺序,在遍历时,先得到的元素肯定是先插入的
TreeMap:实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。
知识详解:
1、关于Set集合的不可重复特性使用
1.1、当集合中元素类型为基本数据类型时
Set<String> set = new HashSet<>(); // String类型的equals方法只比较内容
set.add("a");
set.add("b");
set.add("c");
System.out.println(set); // [a, b, c]
set.add("a");
set.add("d");
System.out.println(set); // [a, b, c, d]
1.2、当集合中元素类型为对象时,通过重写对象的equals和hashcode方法来实现不可重复特性
// 未重写equals和hashcode方法时Set集合会认为这是不同的两个元素
Set<User> users = new HashSet<>();
users.add(new User("1","张三"));
users.add(new User("1","张三"));
System.out.println(users); // [entity.User@58d25a40, entity.User@1c655221]
// 下面是用idea自动重写的equasl和hashcode,当id和name均相同时,Set会认为这是相同的一个元素,当然如果你的idea装了lombok插件,直接使用@Data注解即会默认重写这些方法。
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(getId(), user.getId()) &&
Objects.equals(getName(), user.getName());
}
@Override
public int hashCode() {
return Objects.hash(getId(), getName());
}
// 重写之后,Set的不可重复特性即能正常生效
Set<User> users = new HashSet<>();
users.add(new User("1","张三"));
users.add(new User("1","张三"));
System.out.println(users); // [entity.User@bdc99]
users.add(new User("2","张三"));
System.out.println(users); // [entity.User@bdc99, entity.User@bdcb8]
2、ArrayList与Vector的相同与不同
a、两者都是基于索引,基于数组来实现的
b、两者都维护插入顺序,即可以根据插入顺序来获取对应的元素
c、两者都允许null值
d、Vector是同步的,即线程安全,ArrayList不是,如果你是因为想用Vector的同步特性的话,建议使用CopyOnWriteArrayList
3、CopyOnWriteArrayList介绍
是jdk1.5所引进的一个集合,想必是用来替代不那么高效的Vector所用,其内部实现机制正如其名字所示,写时复制技术,即在进行写操作的时候先加锁,然后以当前数据复制出一个新的副本,对新的数据副本进行修改,最后提交修改并释放锁,与Vector不同的是,它的读操作并没有加锁,所以当读操作远大于写操作的时候,CopyOnWriteArrayList比Vector会快很多,源码如下
public boolean add(E e) {
// 加锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 生成新的数据副本
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 在新的副本上进行修改
newElements[len] = e;
// 提交修改,让此次修改对其他线程可见
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}
当多个线程并发写的时候,此时通过锁来控制,当并发读的时候,只要写操作还未提交修改,即array的引用未指向新数组,那么读的永远是老的数据。
4、ArrayList与LinkedList的区别
a、前者是基于动态数组的数据结构实现,由于有索引,所以ArrayList对于随机访问会非常快,复杂度为O(1),后者是基于双向链表实现,每个节点都与上一个节点和下一个节点相连,因此查询时首先判断序号与总数的一半来决定是从前往后还是从后往前,直到获取到指定的元素返回,因此耗时相比前者会慢,复杂度为O(n),LinkedList查询源码如下
Node<E> node(int index) {
// 从前往后遍历
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
b、对于插入、删除元素操作,ArrayList会更慢一点,每进行一次操作,ArrayList需要更新插入或删除元素之后的每个元素的索引,并改变数组的大小,而LinkedList只需要更新插入或删除元素前后的两个节点,比起前者会快很多。
c、LinkedList需要消耗更多的内存,因为它需要同时存储每个节点前后节点的引用
d、LinkedList提供了额外的get/remove/insert方法在它的首部和尾部,因此可以被用来作堆栈(stack)/ 队列(queue)或双向队列(queue)
5、HashMap put原理解释
自jdk1.8之后,HashMap采用数组+链表+红黑二叉树结构存储数据,通过对key进行hashcode操作找到数组中对应的下标,那具体这个下标是如何映射的呢?一般情况下是通过hash(key) % len获得,也就是key的哈希值对数组长度取模得到,但是在源码中的话是通过位运算的形式实现的,即 hash(key) & (len-1),该方式与取模运算得到的结果是一样的,但是平常使用的话是有条件的,例如:B%C,要满足C=2^n,比如14%4等价于14&(2^2-1),结果都是2,回到正题= = 先看下put方法大致流程图
有了大致概念之后我们来看下HashMap的put方法源码实现:
// 参数onlyIfAbsent表示是否替换原值
// 参数evict我们可以忽略它,它主要用来区别通过put添加还是创建时初始化数据的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 空表,需要初始化
if ((tab = table) == null || (n = tab.length) == 0)
// resize()不仅用来调整大小,还用来进行初始化配置
n = (tab = resize()).length;
// 这里就是看下在hash位置有没有元素,实际位置是hash % (length-1)
if ((p = tab[i = (n - 1) & hash]) == null)
// 将元素直接插进去
tab[i] = newNode(hash, key, value, null);
else {
// 这时就需要链表或红黑树了
// e是用来查看是不是待插入的元素已经有了,有就替换
Node<K,V> e; K k;
// p是存储在当前位置的元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //要插入的元素就是p,这说明目的是修改值
// p是一个树节点
else if (p instanceof TreeNode)
// 把节点添加到树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 这时候就是链表结构了,要把待插入元素挂在链尾
for (int binCount = 0; ; ++binCount) {
// 向后循环
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表比较长,需要树化,
// 由于初始即为p.next,所以当插入第9个元素才会树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 找到了对应元素,就可以停止了
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 继续向后
p = e;
}
}
// e就是被替换出来的元素,这时候就是修改元素值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 默认为空实现,允许我们修改完成后做一些操作
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// size太大,达到了capacity的0.75,需要扩容
if (++size > threshold)
resize();
// 默认也是空实现,允许我们插入完成后做一些操作
afterNodeInsertion(evict);
return null;
}
6、HashMap get原理解释
相比put方法,get则简单多了,主要根据传入key的hashcode找到数组中对应的下标,然后通过equals方法比较key值,满足则返回,下面看下源码实现
public V get(Object key) {
Node<K,V> e;
// 对key进行hashcode
// 若未匹配到key对应的值则返回null
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 通过equals方法判断key值,满足则返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
7、List集合去重方式介绍(只介绍实用的)
a、当元素为基本数据类型时
1) 利用Set集合的不可重复特性
List<String> list = Lists.newArrayList("a","a","b","c");
List<String> newList = new ArrayList<>();
newList.addAll(new HashSet<>(list));
System.out.println(newList); // [a, b, c]
2) 利用jdk8的stream流
List<String> list = Lists.newArrayList("a","a","b","c");
list = list.stream().distinct().collect(Collectors.toList());
System.out.println(list); // [a, b, c]
b、当元素为对象时,利用stream流去重
User user1 = new User("a", "a1");
User user2 = new User("a", "a1");
User user3 = new User("b", "b1");
User user4 = new User("c", "c1");
List<User> users = Lists.newArrayList(user1, user2, user3, user4);
System.out.println("before :" + users); // before :[entity.User@1b70, entity.User@1b70, entity.User@1bae, entity.User@1bec]
users = users.stream().collect(
Collectors.collectingAndThen(
Collectors.toCollection(
() -> new TreeSet<>(Comparator.comparing(User::getId))
), ArrayList::new
)
);
System.out.println("after :" + users); // after :[entity.User@1b70, entity.User@1bae, entity.User@1bec]
8、什么时候需要重写对象的hashcode方法
hashcode方法一般为哈希表所用,当我们需要对自定义对象进行自定义比较的时候一般会需要重写equals方法,如果此时该对象正好被用于hashmap或者其他hash结构,我们就需要重写hashcode方法,所以重写equals方法的时候最好也重写下hashcode方法,以保证相同的对象返回相同的哈希码。由上文可知,hashmap在增加一个元素时,先对key进行哈希得到一个哈希值并根据其值找到数组中对应的下标,如果未重写hashcode,则会出现我们认为的同一对象却在hashmap中占了多个数组下标,然而其值经过equasl比较却是一样的。因为Object的hashcode是基于对象的id计算哈希值的,而我们需要基于对象的内容来计算其值