2019-5-18
作者:水不要鱼
(注:能力有限,如有说错,请指正!)
- ArrayList 底层使用数组,每一次空间满了就进行扩容,
先创建一个容量为原来容量 1.5 倍的新数组,然后再将数据复制到新数组完成扩容 - 由于数组在内存上是连续的,这就意味着一旦要往中间插入数据或者删除数据,
这个位置后面的所有数据都要重新进行排列,这就会带来性能上的消耗 - LinkedList 底层使用的是链表,所以在内存上不一定连续,而且即使是连续的也不要紧,
因为它是靠两个指针来找到下一个和上一个数据的,所以它不需要进行扩容,
或者说每一次新增数据就是一次扩容 - 由于链表底层不连续,所以要找到第 n 个元素就需要从头遍历,在查找上性能会比较低,
另外链表结构中使用两个节点来保存上一个和下一个元素的节点地址,所以每一个元素就会多浪费两个地址大小,
如果是 64 位系统,就是 16 字节,当元素特别多时,相比 ArrayList 更浪费内存
扩展:多线程下的操作
我们先来看这个例子:
class Test {
public static void main(String[] args){
List<Integer> list = new ArrayList<>();
// 装一些数据
for (int i = 0; i < 10; i ++) {
list.add(i);
}
// 边遍历便删除
for (Integer num : list) {
// 如果 num 是奇数,就删除
if ((num & 0x1) == 1) {
list.remove(num);
}
}
}
}
这段代码的结果是什么?对,它会爆出一个异常,java.util.ConcurrentModificationException。
这是由于我们一边遍历一边修改数据产生的并发修改异常,内部是有一个变量用来记录当前修改次数,感兴趣的可以找来看看,
重点在于,我们怎么解决?
你可以把所有需要删除的元素记录下来,用一个 List 去存着,然后遍历这个 List 把所有数据从另一个 List 中删除。
这样显然会浪费很多空间,因为要保存所有需要删除的下标。那还有别的解决办法吗?
对,可以使用迭代器!
class Test {
public static void main(String[] args){
List<Integer> list = new ArrayList<>();
// 装一些数据
for (int i = 0; i < 10; i ++) {
list.add(i);
}
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
int num = iterator.next();
if ((num & 0x1) == 1) {
iterator.remove();
}
}
}
}
这种方法很符合我们的逻辑,但是代码看起来有些多,还能简化吗?
可以,如果你使用 JDK8,你可以使用 Predicate 模式来剔除元素,代码如下:
class Test {
public static void main(String[] args){
List<Integer> list = new ArrayList<>();
// 装一些数据
for (int i = 0; i < 10; i ++) {
list.add(i);
}
// 使用 lambda 表达式
list.removeIf(num -> (num & 0x1) == 1);
}
}
乍一看很神奇,那内部是怎么样的呢?其实它内部还是使用迭代器来删除元素的!下面是源码:
// Collection 集合类的顶层接口
interface Collection {
// 接口默认实现
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
// 使用迭代器遍历元素
final Iterator<E> each = iterator();
while (each.hasNext()) {
// 如果 Predicate 接口的 test 方法返回 true,就会执行删除元素
// 按上面的条件:num -> (num & 0x1) == 1
// 也就是每一个数都会进行 (num & 0x1) == 1 的判断
// (num & 0x1) == 1 返回 true 就会删除元素
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
}
- 两者实现的都是 Map 接口,但是 HashMap 继承自 AbstractMap,而 Hashtable 继承自 Dictionary
- Hashtable 是线程安全的,从源码中我们可以看到每个方法都被 synchronized 修饰了,也就是加了锁
- HashMap 不是线程安全的,所以如果你的数据需要被多线程访问,就没办法直接使用 HashMap
- 由于 Hashtable 的方法都加锁了,所以如果是单线程程序,使用 HashMap 的效率反而更高
扩展:多线程下使用 HashMap
从上面我们知道,HashMap 不是线程安全的,而 Hashtable 的性能又不高,这时候我们就可以使用 ConcurrentHashMap。
这是 JDK5 新增的并发集合,可以保证高性能的同时,线程安全。那它是怎么做到的呢?
JDK7 中是使用了 Segment 来保证线程安全,Segment 其实是 ReentrantLock 的子类,
ReentrantLock 是一个可重入锁,所以很明显,JDK7 是通过加锁的方式来保证并发安全的,
而 Segment 内部又是一个数组,当一个元素到来时,首先判断它在哪一个 Segment 上,
然后再在 Segment 中定位到具体的元素,这会经历两次定位。说白了,JDK7 就是通过细化锁的粒度来达到并发安全和性能的权衡的。
JDK8 对这个类进行了重写,取消了 Segment 用 Node 来代替,结构上差不多,但是不使用悲观锁,
而是使用 CAS 算法的形式来保证并发安全,这在一定程度上比加锁要好,但不完全更好。
由于 CAS 就是使用 CPU 循环来比较和更改数据,一旦数据更改失败,就会不断循环继续比较更改,
所以这就有可能导致一个线程一直循环占用 CPU 时间却更改不成功,虽然这概率极低,但是一旦发生,
还不如使用锁来的快。
扩展:Hash 定位
我们知道 HashMap 是散列结构,需要对数据进行 Hash 散列计算在数组中的位置,
这就存在一个数组下标定位的问题,最简单就是使用求余,但是求余操作是非常浪费比较次数的,
也就是存在多次重复的相同比较和操作,那 HashMap 中是如何来定位的呢?
我们先来看 HashMap 中对初始化容量的描述:必须是 2 的次方。
class HashMap {
/**
* The default initial capacity - MUST be a power of two.
* 默认的初始容量,必须是 2 的次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
}
为什么是 2 的次方?
原因就在于 HashMap 没有使用求余操作,而是使用 & 操作符来定位下标。
// n 是数组下标,hash 是数据的哈希值
int index = (n - 1) & hash;
当我们的容量是 2 的次方,比如 16,换成二进制就是 0001 0000,而 n - 1 的话,
就是 16 - 1 = 15,换成二进制也就是 0000 1111,刚好对应数组的最大下标,而且每一位上都是 1,
也就是我们通过 & 操作符之后,可以保留原本 hash 的后几位,间接的进行了下标定位的功能!!
- 很多集合类都有默认初始容量,并且可以自定义大小
- 比如 ArrayList 默认是 0 个元素,第一次扩容变为 10 个
- 如果你的数据大小大概是 1000 个,使用默认大小 10 将带来非常多次内存的拷贝操作,
这时候你可以调整默认初始化大小为一个更大的数,比如 1024,这样就可以节省很多次内存的分配和回收操作 - 这在 HashMap 上同样使用,不过 HashMap 的扩容不是空间满了才分配,而是当个数达到容量和负载因子的乘积时就扩容了,
所以这里还有一个负载因子要计算,默认的负载因子是 0.75,也就是容量为 100 时,75 就开始扩容了,
如果你的数据大概有 70 个,你可以初始化为 100,这样就可以减小元素的冲突,提高查询效率,还可以减少内存的多次分配