[集合]Java基础面试题(3)

1. ConcurrentHashMap

介绍一下 Java 7 的 ConcurrentHashMap 的数据结构?

image.png

整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表“部分”或者“一段”的意思,所以很多地方都会将其描述为分段锁。

简单来讲就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过 ReentrantLock 来进行加锁,所以每次加锁的是一个 Segment,这样只要保证了每个 Segment 是线程安全的就可以保证全局线程安全。

简单说一下 ConcurrentHashMap 的常用构造方法?

```java // 无参构造方法 public ConcurrentHashMap() { }

// 可传入初始容器大小的构造方法 public ConcurrentHashMap(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException(); int cap = ((initialCapacity >= (MAXIMUMCAPACITY >>> 1)) ? MAXIMUMCAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); this.sizeCtl = cap; }

// 传入Map public ConcurrentHashMap(Map extends K, ? extends V> m) { this.sizeCtl = DEFAULT_CAPACITY; putAll(m); }

// 设置初始容量和负载因子 public ConcurrentHashMap(int initialCapacity, float loadFactor) { this(initialCapacity, loadFactor, 1); }

// 设置初始容量和并发级别 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (initialCapacity < concurrencyLevel) // Use at least as many bins initialCapacity = concurrencyLevel; // as estimated threads long size = (long)(1.0 + (long)initialCapacity / loadFactor); int cap = (size >= (long)MAXIMUMCAPACITY) ? MAXIMUMCAPACITY : tableSizeFor((int)size); this.sizeCtl = cap; } ``` concurrencyLevel 就是并发级别、并发数、Segment 数量。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments。理论上可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值。一旦初始化后就不可以扩容。

每个 Segment 类似于 HashMap。

扫描二维码关注公众号,回复: 17031539 查看本文章

简单说一下,Java 7 中 ConcurrentHashMap 如何进行锁操作?

Java 7 版本使用 ReentrantLock + Segment + HashEntry 实现锁操作。写操作的时候可以只对元素所在的Segment 进行加锁即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作。

```java // HashEntry用来存储元素 static final class HashEntry { final int hash; final K key; volatile V value; volatile HashEntry next;

HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
    this.hash = hash;
    this.key = key;
    this.value = value;
    this.next = next;
}
// ...

}

// Segment实现线程安全的关键类 static final class Segment extends ReentrantLock implements Serializable { // ...

/** * The per-segment table. Elements are accessed via * entryAt/setEntryAt providing volatile semantics. */ transient volatile HashEntry [] table;

/** * The number of elements. Accessed only either within locks * or among other volatile reads that maintain visibility. */ transient int count; // ... } ``` Java 8 的 ConcurrentHashMap 如何保证并发安全?

Java 8 的 ConcurrentHashMap 参考了 HashMap 的实现,采用了数组 + 链表 + 红黑树的实现方式来设计,内部大量采用 CAS 操作,这里我简要介绍下CAS。 CAS 是 compare and swap 的缩写,即我们所说的比较交换。CAS 是一种基于锁的操作,而且是乐观锁。在 Java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。

Java 8 中彻底放弃了 Segment 转而采用的是 Node,其设计思想也不再是 Java 7 中的分段锁思想。 Node 是用于保存 key,value 及 key 的 hash 值的数据结构。其中 value 和 next 都用 volatile 修饰,保证并发的可见性。 Java 8 的 ConcurrentHashMap 结构基本上和 Java 8 的 HashMap 一样,不过保证线程安全性。

java static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; // ... } Java 8 和 Java 7 的 ConcurrentHashMap 的区别?

其实可以看出 Java 8 版本的 ConcurrentHashMap 的数据结构已经接近 HashMap,相对而言, ConcurrentHashMap 只是增加了同步的操作来控制并发,从 Java 7 版本的 ReentrantLock + Segment + HashEntry,到 Java 8 版本中 synchronized + CAS + HashEntry + 红黑树。

  • 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组 + 链表 + 红黑树的结构。
  • 保证线程安全机制:Java 7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock。Java 8 采用 CAS + synchronized 同步锁保证线程安全。
  • 锁的粒度:原来是对需要进行数据操作的 Segment 加锁,现调整为对每个数组元素加锁(Node)。
  • 链表转化为红黑树:定位结点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于或等于8 时,会将链表转化为红黑树进行存储。
  • 查询时间复杂度:从原来的遍历链表 $O(n)$,变成遍历红黑树 $O(log \, n)$。

多线程环境下,Hashtable 和 ConcurrentHashMap 表现有区别吗?

Hashtable 和 ConcurrentHashMap 都是线程安全的,但是在多线程下 ConcurrentHashMap 效率更高。

Hashtable 使用一把锁处理并发问题,当有多个线程访问时,需要多个线程竞争一把锁,导致阻塞。Java 7 的 ConcurrentHashMap 则使用分段锁,相当于把一个 HashMap 分成多个,然后每个部分分配一把锁,这样就可以支持多线程访问。(默认情况下,理论上讲,能同时支持 16 个线程并发)Java 8 的锁粒度细到了元素本身。理论上讲,是最高级别的并发。

2. LinkedHashMap

LinkedHashMap 与 HashMap 区别?

  • LinkedHashMap 继承 HashMap,是基于 HashMap 和双向链表来实现的。
  • HashMap 无序,而 LinkedHashMap 有序,可分为插入顺序和访问顺序两种。如果是 访问顺序,那 put 和 get 操作已存在的 Entry 时,都会把 Entry 移动到双向链表的表尾 (其实是先删除再插入)。
  • LinkedHashMap 存取数据,还是跟 HashMap 一样使用的 Entry 数组的方式,双向链表只是为了保证顺序。
  • LinkedHashMap 是线程不安全的。

简单说一下 LinkedHashMap 如何维护双向链表?

在 LinkedHashMap 中,是通过双向链表的结构来维护节点的顺序的。每个节点都进行了双向的连接,维护插入的顺序(默认)。head 指向第一个插入的节点,tail 指向最后一个节点。

``` public class LinkedHashMap extends HashMap implements Map { static class Entry extends HashMap.Node { Entry before, after; Entry(int hash, K key, V value, Node next) { super(hash, key, value, next); } }

/**
 * The head (eldest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> head;

/**
 * The tail (youngest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> tail;

final boolean accessOrder;

// ...

} ```

介绍一下 LinkedHashMap 的两种顺序,插入顺序和访问顺序?

LinkedHashMap 提供了两种遍历顺序:插入顺序和访问顺序。当创建 LinkedHashMap 对象时,可以通过指定 accessOrder 参数来选择遍历顺序,默认情况下为 false,表示按照插入顺序遍历。如果将 accessOrder 设置为 true,LinkedHashMap 会按照访问顺序遍历,即最近访问的元素排在最后。

以下是使用LinkedHashMap的示例代码:

``` import java.util.LinkedHashMap;

public class LinkedHashMapExample { public static void main(String[] args) { LinkedHashMap map = new LinkedHashMap (16, 0.75f, true);

map.put("apple", 1);
    map.put("banana", 2);
    map.put("orange", 3);

    // 遍历顺序为插入顺序
    System.out.println("遍历顺序为插入顺序:");
    for (String key : map.keySet()) {
        System.out.println(key + " : " + map.get(key));
    }

    // 访问一次"banana"元素,遍历顺序变为访问顺序
    System.out.println("访问一次\"banana\"元素,遍历顺序变为访问顺序:");
    map.get("banana");
    for (String key : map.keySet()) {
        System.out.println(key + " : " + map.get(key));
    }
}

} ```

输出结果为:

遍历顺序为插入顺序: apple : 1 banana : 2 orange : 3 访问一次"banana"元素,遍历顺序变为访问顺序: apple : 1 orange : 3 banana : 2

可以看到,当 accessOrder 为 false 时,遍历顺序为插入顺序;而当 accessOrder 为true 时,遍历顺序变为访问顺序。

LinkedHashMap 如何实现控制访问顺序,原理是什么?

首先在构造方法里面可以设置 accessOrder 为 true,表示开启访问顺序遍历。

其次在 get 方法里面,会判断 accessOrder,如果是 true,那么就会执行 afterNodeAccess 钩子方法,afterNodeAccess 在 HashMap 中定义,LinkedHashMap 做了实现。然后被访问过的元素就会被放到双向链表的末尾。

java public V get(Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) return null; if (accessOrder) // 把元素放到末尾 afterNodeAccess(e); return e.value; } 简述 LinkedHashMap 的 put 方法原理?

LinkedHashMap 类中并没有重写 put 方法,而是通过重写了 newNode 和 afterNodeAccess 方法扩展父类的 put 方法。

  • newNode 方法:

java Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) { LinkedHashMap.Entry<K,V> p = new LinkedHashMap.Entry<K,V>(hash, key, value, e); linkNodeLast(p); return p; }

  • afterNodeAccess 方法:

java void afterNodeAccess(Node<K,V> e) { // move node to last LinkedHashMap.Entry<K,V> last; if (accessOrder && (last = tail) != e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.after = null; if (b == null) head = a; else b.after = a; if (a != null) a.before = b; else last = b; if (last == null) head = p; else { p.before = last; last.after = p; } tail = p; ++modCount; } }

简述 LinkedHashMap 的 get 方法原理?

通过 HashMap 定义个 getNode 方法获取对应的节点,如果是访问顺序则把节点放到双向链表最末尾。

使用 LinkedHashMap 实现 LRU 算法?

LRU(最近最少使用)是一种常见的缓存淘汰算法,它会在缓存达到容量上限时,淘汰最近最少使用的缓存项,以腾出空间来存储新的缓存项。

在 Java 中,可以使用 LinkedHashMap 类来实现 LRU 算法。LinkedHashMap 是HashMap 的一个子类,它通过维护一个双向链表来保证元素的顺序。具体来说,每次访问一个元素时,LinkedHashMap 会将这个元素移到链表的末尾,以表示它是最近使用的元素。当需要淘汰元素时,LinkedHashMap 会淘汰链表头部的元素,即最久未使用的元素。

以下是一个使用 LinkedHashMap 实现 LRU 算法的示例代码:

```java import java.util.LinkedHashMap; import java.util.Map;

public class LRUCache extends LinkedHashMap {

private int capacity;

public LRUCache(int capacity) {
    super(capacity, 0.75f, true);
    this.capacity = capacity;
}

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return size() > capacity;
}

public static void main(String[] args) {
    LRUCache<Integer, String> cache = new LRUCache<>(2);
    cache.put(1, "one");
    cache.put(2, "two");
    System.out.println(cache); // {1=one, 2=two}
    cache.put(3, "three");
    System.out.println(cache); // {2=two, 3=three}
    cache.get(2);
    System.out.println(cache); // {1=one, 3=three}
    cache.put(4, "four");
    System.out.println(cache); // {3=three, 4=four}
}

} ```

在这个示例中,LRUCache 继承自 LinkedHashMap,并在构造函数中调用了父类的构造函数,以指定容量、负载因子和访问顺序。removeEldestEntry 方法会在每次添加元素时被调用,用于判断是否需要移除最久未使用的元素。最后,示例代码演示了一个 LRU 缓存的基本用法,包括添加、访问和淘汰元素。

3. HashSet

简单介绍一下 HashSet 的底层数据结构?

HashSet 的底层数据结构是基于 HashMap 实现的。HashSet 内部维护了一个 HashMap 对象,使用 HashMap 的键来存储 HashSet 中的元素,HashMap 中的值则是一个固定的Object 类型的对象 PRESENT。HashSet 中添加元素时,实际上是将元素作为 HashMap 的键存储在 HashMap 中,并将对应的值设为 PRESENT。

```java public class HashSet extends AbstractSet implements Set , Cloneable, java.io.Serializable {

static final long serialVersionUID = -5024744406713321676L;

private transient HashMap<E, Object> map;

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

/**
 * Constructs a new, empty set; the backing <tt>HashMap</tt> instance      * has default initial capacity (16) and load factor (0.75).
 */
public HashSet() {
    map = new HashMap<>();
}
// ...

} ```

HashSet 如何判断元素是否重复?

HashSet 通过 contains 方法判断元素是否重复,内部通过调用 HashMap 的 containsKey 方法判断键是否存在。

4. LinkedHashSet

之前介绍了,HashSet 其实就是利用了 HashMap 进行了元素的存取,但是并非继承关系,因为一方是 Set,一方是 Map,完全两码事儿。那么对于 LinkedHashSet 是否也是通过 LinkedHashMap 做的双向链表控制呢?先来看 LinkedHashSet 源代码。

```java public class LinkedHashSet extends HashSet implements Set , Cloneable, java.io.Serializable {

private static final long serialVersionUID = -2851667679971038690L;

public LinkedHashSet(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor, true);
}

public LinkedHashSet(int initialCapacity) {
    super(initialCapacity, .75f, true);
}

public LinkedHashSet() {
    super(16, .75f, true);
}

public LinkedHashSet(Collection<? extends E> c) {
    super(Math.max(2*c.size(), 11), .75f, true);
    addAll(c);
}

@Override
public Spliterator<E> spliterator() {
    return Spliterators.spliterator(this, Spliterator.DISTINCT | Spliterator.ORDERED);
}

} ```

内部貌似没有任何实现控制访问顺序的方法。但是在父类 HashSet 中仔细观察,你会发现有一个 default 构造方法:

java /** * Constructs a new, empty linked hash set. (This package private * constructor is only used by LinkedHashSet.) The backing * HashMap instance is a LinkedHashMap with the specified initial * capacity and the specified load factor. * * @param initialCapacity the initial capacity of the hash map * @param loadFactor the load factor of the hash map * @param dummy ignored (distinguishes this * constructor from other int, float constructor.) * @throws IllegalArgumentException if the initial capacity is less * than zero, or if the load factor is nonpositive */ HashSet(int initialCapacity, float loadFactor, boolean dummy) { map = new LinkedHashMap<>(initialCapacity, loadFactor); }

这个构造方法在 LinkedHashSet 的构造方法中被调用,创建一个 LinkedHashMap 对象,从而实现控制顺序访问。

5. TreeSet

TreeSet 和 HashMap 的关系?

TreeSet 和 HashMap 没有直接关系,而是内部依赖了 TreeMap 实现功能。

6. Map性能比较

  1. HashMap:HashMap 是最常用的 Map 集合之一,它基于哈希表实现。在大多数情况下,HashMap 的性能比其他 Map 集合更好,因为它具有 $O(1)$ 的常数时间复杂度,可以快速地查找和插入元素。但是,在使用 HashMap 时需要注意,由于哈希冲突,可能会出现性能下降的情况。

  2. TreeMap:TreeMap 是一种基于红黑树实现的有序 Map 集合。相比于 HashMap,TreeMap 的插入、查找、删除操作的时间复杂度更高,但是它提供了按照键排序的能力,这对于需要按照键值排序的场景非常有用。

  3. LinkedHashMap:LinkedHashMap 继承自 HashMap,它可以保持元素的插入顺序或访问顺序,具有 $O(1)$ 的常数时间复杂度。LinkedHashMap 适用于需要保持元素顺序的场景。

  4. ConcurrentHashMap:ConcurrentHashMap 是线程安全的 HashMap,它通过分段锁来实现高并发。相比于 HashMap,在并发场景下,ConcurrentHashMap 的性能更好。但是,在单线程环境下,ConcurrentHashMap 的性能比 HashMap 略差。

  5. WeakHashMap:WeakHashMap 是一种特殊的 Map 集合,它的键是弱引用类型。如果某个键没有被其他对象引用,那么它就可能被垃圾回收器回收,从而从 WeakHashMap 中删除。WeakHashMap 适用于需要自动释放资源的场景。

7. ArrayList

说说 Java 8 的 ArrayList 的特点?

  • ArrayList 是一个动态数组,实现了 List、RandomAccess、Cloneable、Serializable 接口,并允许包括 null 在内的所有元素。实现了 RandomAccess 接口标识着其支持随机快速访问,实际上,我们查看 RandomAccess 源码可以看到,其实里面什么都没有定义。因为 ArrayList 底层是数组,那么随机快速访问是理所当然的, 访问速度 $O(1)$。实现了 Cloneable 接口,标志着可以它可以被复制。注意,ArrayList 里面的clone 方法其实是浅拷贝。实现了 Serializable,支持序列化传输。
  • 底层使用数组实现,默认初始容量为 10。当超出后,会自动扩容为原来的 1.5 倍,即自动扩容机制。数组的扩容是新建一个大容量(原始数组大小 + 扩充容量)的数组,然后将原始数组数据拷贝到新数组,然后将新数组作为扩容之后的数组。数组扩容的操作代价很高,我们应该尽量减少这种操作。
  • 该集合是可变长度数组,数组扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组 1.5 倍扩容,如果扩容一半不够,就将目标 size 作为扩容后的容量。这种操作的代价很高。采用的是 Arrays.copyOf 浅复制。
  • 采用了 Fail-Fast 机制,面对并发的修改时,迭代器很快就会完全失败,报异常 concurrentModificationException(并发修改一次),而不是冒着在将来某个不确定时间发生任意不确定行为的风险。
  • remove 方法会让下标到数组末尾的元素向前移动一个单位,并把最后一位的值置空,方便 GC。
  • 数组扩容代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造 ArrayList 实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用 ensureCapacity 方法来手动增加 ArrayList 实例的容量。
  • ArrayList 不是线程安全的,只能用在单线程环境下,多线程环境下可以考虑用 Collections.synchronizedList 方法返回一个线程安全的 ArrayList 类,也可以使用并发包下的 CopyOnWriteArrayList 类。
  • 如果是删除数组指定位置的元素,那么可能会挪动大量的数组元素。如果是删除末尾元素的话,那么代价是最小的。

简单说一下 ArrayList 的构造方法?

```java // 构造一个指定容量的数组列表 public ArrayList(int initialCapacity) { if (initialCapacity > 0) { this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } }

// 构造一个默认容量的数组列表 public ArrayList() { this.elementData = DEFAULTCAPACITYEMPTYELEMENTDATA; }

// 构造一个包含指定元素集合的数组列表 public ArrayList(Collection extends E> c) { Object[] a = c.toArray(); if ((size = a.length) != 0) { if (c.getClass() == ArrayList.class) { elementData = a; } else { elementData = Arrays.copyOf(a, size, Object[].class); } } else { // replace with empty array. elementData = EMPTY_ELEMENTDATA; } } ```

为什么 ArrayList 内部有两个静态 final 的 Object 数组?

```java /** * Shared empty array instance used for empty instances. */ private static final Object[] EMPTY_ELEMENTDATA = {};

/** * Shared empty array instance used for default sized empty instances. We * distinguish this from EMPTYELEMENTDATA to know how much to inflate when * first element is added. */ private static final Object[] DEFAULTCAPACITYEMPTY_ELEMENTDATA = {}; ```

这两个值,有着一样的修饰符和初始化值。只有默认构造方法使用了 DEFAULTCAPACITYEMPTYELEMENTDATA。其他两个构造方法,在不满足 if 条件的情况下,使用的是 EMPTY_ELEMENTDATA。

ArrayList 类的用于存储元素的数组使用 transient 修饰,序列化后数据会丢失吗?

ArrayList 在调用 writeObject 方法序列化的时候对 elementData 中的元素进行循环,单独序列化:

```java private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{ // Write out element count, and any hidden stuff int expectedModCount = modCount; s.defaultWriteObject();

// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
    s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) {
    throw new ConcurrentModificationException();
}

} ```

反序列化时,在 readObject 方法中,一个一个地读取元素存入 elementData 中:

```java private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { elementData = EMPTY_ELEMENTDATA;

// Read in size, and any hidden stuff
s.defaultReadObject();

// Read in capacity
s.readInt(); // ignored

if (size > 0) {
    // be like clone(), allocate array based upon size not capacity
    int capacity = calculateCapacity(elementData, size);
    SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity);
    ensureCapacityInternal(size);

    Object[] a = elementData;
    // Read in all elements in the proper order.
    for (int i=0; i<size; i++) {
        a[i] = s.readObject();
    }
}

} ```

那为什么不直接序列化 elementData 呢?

不直接序列化这个对象,是因为这个对象绝大多数情况下没有存储任何元素。这样将会是一个很大的空间浪费。

每次数组扩容是 1.5 倍扩容。试想一下, 10 万条数据,扩容之后变成 15 万个容量空间,但是只有 10 万零 1 条数据,浪费了太多。

谈谈对 transient 关键字的理解?

  • 一旦变量被 transient 修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。
  • transient 关键字只能修饰变量,而不能修饰方法和类。注意,本地变量是不能被transient 关键字修饰的。变量如果是用户自定义类变量,则该类需要实现 Serializable 接口。
  • 被 transient 关键字修饰的变量不再能被序列化,一个静态变量不管是否被 transient 修饰,均不能被序列化。
  • 使用场景举例,在实际开发过程中,我们常常会遇到这样的问题,这个类的有些属性需要序列化,而其他属性不需要被序列化,打个比方,如果一个用户有一些敏感信息(如密码,银行卡号等),为了安全起见,不希望在网络操作(主要涉及到序列化操作,本地序列化缓存也适用)中被传输,这些信息对应的变量就可以加上 transient 关键字。换句话说,这个字段的生命周期仅存于调用者的内存中而不会写到磁盘里持久化。

说说 ArrayList 的 Fail-Fast 机制?

在多线程环境下,如果一个线程在迭代 ArrayList 时,另外一个线程修改了 ArrayList的结构(例如添加或删除元素),就可能会导致 ConcurrentModificationException 异常的抛出。

为了解决这个问题,Java 引入了 Fail-Fast 机制,这个机制的原理是在迭代 ArrayList 时,通过记录 ArrayList 的修改次数(modCount),当另外一个线程进行修改时,会导致 modCount 的变化,从而导致迭代器抛出ConcurrentModificationException 异常,提示用户当前的迭代已经失效。

具体来说,ArrayList 内部维护了一个 int 类型的成员变量 modCount,表示对ArrayList 结构修改的次数。当创建一个迭代器(Iterator)时,该迭代器会记录当前的modCount 值。在迭代过程中,每次通过 next 方法获取下一个元素时,会检查 modCount是否与之前记录的值相等。如果不相等,说明 ArrayList 的结构已经发生了改变,就会抛出 ConcurrentModificationException 异常。

因此,在使用 ArrayList 进行多线程操作时,需要注意避免在迭代 ArrayList 的同时修改其结构,以避免 ConcurrentModificationException 异常的发生。如果需要在多线程环境下操作 ArrayList,可以考虑使用线程安全的 List 实现,例如CopyOnWriteArrayList 或 Collections.synchronizedList 等。

说一下 add 方法的原理?

add主要的执行逻辑如下:

  • 确保数组已使用长度(size)加 1 之后足够存下下一个数据。
  • 修改次数 modCount 标识自增 1,如果当前数组已使用长度(size)加 1 后的大于当前的数组长度,则调用 grow 方法扩容,grow 方法会将当前数组的长度变为原来容量的 1.5 倍。
  • 确保新增的数据有地方存储之后,则将新元素添加到位于 size 的位置上。
  • 返回添加成功布尔值。

java public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; } 说一下 ArrayList 的扩容原理?

```java private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAXARRAYSIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); }

private static int hugeCapacity(int minCapacity) { if (minCapacity < 0) // overflow throw new OutOfMemoryError(); return (minCapacity > MAXARRAYSIZE) ? Integer.MAXVALUE : MAXARRAY_SIZE; } ```

ArrayList是基于动态数组实现的集合类,当元素数量增加到超过数组容量时,就需要进行扩容。ArrayList的扩容原理可以简单概括为以下几个步骤:

  1. 创建一个新的数组,长度为原数组长度的 1.5 倍。若扩容 1.5 倍还不满足,那么把 newCapacity 设为 minCapacity。若 newCapacity 比 MAXARRAYSIZE 还大,用 hugeCapacity 方法返回值赋值给 newCapacity。
  2. 将原数组中的元素复制到新数组中。
  3. 将新元素插入到新数组中。
  4. 将 ArrayList 内部的数组引用指向新数组。

当然,这只是一个简单的概括,实际上,在 ArrayList 的扩容过程中还会涉及到一些细节和优化,例如:

  1. 扩容后的数组长度可能不是严格按照 1.5 倍或 2 倍增加的,而是根据实际情况进行动态计算。
  2. 在进行数组复制操作时,可以使用 System.arraycopy 方法进行快速复制,而不必遍历原数组,提高效率。
  3. 在插入新元素时,可以使用数组下标进行快速访问,而不必像链表那样从头开始遍历,提高效率。

为什么 ArrayList 的 MAXARRAYSIZE 是 Integer.MAX_VALUE - 8?

数组在 Java 里是一种特殊类型。有别于普通的“类的实例”对象,Java 里数组不是类,所以也就没有对应的 class 文件,数组类型是由 JVM 从元素类型合成出来的。在 JVM 中获取数组的长度是用 arraylength 这个专门的字节码指令的。在数组的对象头里有一个_length 字段,记录数组长度,只需要去读 _length 字段就可以了。所以 ArrayList 中定义的最大长度为 Integer 最大值减 8,这个 8 就是就是存了数组 _length 字段。

ArrayList 在循环中删除一个元素如何避开 Fail-Fast 机制?

使用迭代器或者使用 fori 循环,在循环中直接调用 remove 方法。

ArrayList 和 Vector 区别?

  • 同步性: Vector 是线程安全的,也就是说是它的方法之间是线程同步的,而 ArrayList 是线程是不安全的,它的方法之间是线程不同步的。如果只有一个线程会访问到集合,那最好是使用 ArrayList,因为它不考虑线程安全,效率会高些;如果有多个线程会访问到集合,那最好是使用 Vector,因为不需要我们自己 再去考虑和编写线程安全的代码。 备注:对于 Vector & ArrayList、Hashtable & HashMap,要记住线程安全的问题,记住 Vector 与 Hashtable 是旧的,是 Java 一诞生就提供了的,它们是线程安全的,ArrayList 与 HashMap 是 Java2 时才提供的,它们是线程不安全的。
  • 数据增长: ArrayList 与 Vector 都有一个初始的容量大小,当存储进它们里面的元素的个数超过了容量时,就需要增加 ArrayList 与 Vector 的存储空间,每次要增加存储空间时,不是只增加一个存储单元,而是增加多个存储单元,每次增加的存储单元的个数在内存空间利用与程序效率之间要取得一定的平衡。Vector 默认增长为原来两倍,而 ArrayList 的增长为原来的 1.5 倍。ArrayList 与 Vector 都可以设置初始的空间大小,Vector 还可以设置增长的空间大小,而 ArrayList 没有提供设置增长空间的方法。

8. LinkedList

说一下 ArrayList 和 LinkedList 区别?

  • ArrayList 是实现了基于动态数组的数据结构,LinkedList 基于双向链表的数据结构。
  • 对于随机访问 get 和 set 方法,ArrayList 绝对优于 LinkedList,因为LinkedList 要移动指针。
  • 对于新增和删除操作 add 和 remove,LinkedList 比较占优势,因为 ArrayList 要移动数据。
  • Arraylist 的额外空间占用是 1.5 倍扩容导致的空间资源预留,LinkedList 是需要 对前后指针进行保存,单个元素比 ArrayList 占用更大的空间。

描述一下 LinkedList 的数据结构?

image.png

LinkedList 底层使用的双向链表结构,有一个头指针和一个尾指针,双向链表意味着我们可以从头开始正向遍历,或者是从尾开始逆向遍历,并且可以针对头部和尾部进行相应的操作。

```java public class LinkedList extends AbstractSequentialList implements List , Deque , Cloneable, java.io.Serializable { transient int size = 0;

/**
 * Pointer to first node.
 * Invariant: (first == null && last == null) ||
 *            (first.prev == null && first.item != null)
 */
transient Node<E> first;

/**
 * Pointer to last node.
 * Invariant: (first == null && last == null) ||
 *            (last.next == null && last.item != null)
 */
transient Node<E> last;

/**
 * Constructs an empty list.
 */
public LinkedList() {
}
// ...

} ```

猜你喜欢

转载自blog.csdn.net/weixin_45254062/article/details/130340300
今日推荐