Java 面试复习_2

本文介绍了ArrayList和LinkedList的区别,ArrayList使用数组实现,适合随机访问,而LinkedList使用链表,适合频繁插入删除。讨论了多线程环境下ArrayList的并发修改异常及其解决方案。接着对比了HashMap和Hashtable,HashMap非线程安全,适合单线程高效率场景,而Hashtable全局加锁,性能较低。推荐在多线程下使用ConcurrentHashMap。文章还涉及了HashMap的Hash定位技巧以及集合初始容量的选择策略。
摘要由CSDN通过智能技术生成

2019-5-18
作者:水不要鱼

(注:能力有限,如有说错,请指正!)

  • ArrayList 和 LinkedList

  1. ArrayList 底层使用数组,每一次空间满了就进行扩容,
    先创建一个容量为原来容量 1.5 倍的新数组,然后再将数据复制到新数组完成扩容
  2. 由于数组在内存上是连续的,这就意味着一旦要往中间插入数据或者删除数据,
    这个位置后面的所有数据都要重新进行排列,这就会带来性能上的消耗
  3. LinkedList 底层使用的是链表,所以在内存上不一定连续,而且即使是连续的也不要紧,
    因为它是靠两个指针来找到下一个和上一个数据的,所以它不需要进行扩容,
    或者说每一次新增数据就是一次扩容
  4. 由于链表底层不连续,所以要找到第 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;
    }
}
  • HashMap 和 Hashtable

  1. 两者实现的都是 Map 接口,但是 HashMap 继承自 AbstractMap,而 Hashtable 继承自 Dictionary
  2. Hashtable 是线程安全的,从源码中我们可以看到每个方法都被 synchronized 修饰了,也就是加了锁
  3. HashMap 不是线程安全的,所以如果你的数据需要被多线程访问,就没办法直接使用 HashMap
  4. 由于 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 的后几位,间接的进行了下标定位的功能!!

  • 集合初始容量的使用

  1. 很多集合类都有默认初始容量,并且可以自定义大小
  2. 比如 ArrayList 默认是 0 个元素,第一次扩容变为 10 个
  3. 如果你的数据大小大概是 1000 个,使用默认大小 10 将带来非常多次内存的拷贝操作,
    这时候你可以调整默认初始化大小为一个更大的数,比如 1024,这样就可以节省很多次内存的分配和回收操作
  4. 这在 HashMap 上同样使用,不过 HashMap 的扩容不是空间满了才分配,而是当个数达到容量和负载因子的乘积时就扩容了,
    所以这里还有一个负载因子要计算,默认的负载因子是 0.75,也就是容量为 100 时,75 就开始扩容了,
    如果你的数据大概有 70 个,你可以初始化为 100,这样就可以减小元素的冲突,提高查询效率,还可以减少内存的多次分配
今天就到这里!晚安!
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值