Java集合(总结,面试使用)

Posted 钢铁-程序猿

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java集合(总结,面试使用)相关的知识,希望对你有一定的参考价值。

文章目录

Java容器

List

一、ArrayList

  • 1、初始大小为10,扩容为现在的1.5
  • 2、扩容使用Arrays.copyOf()把原来的整个数组复制到新的数组中。
  • 3、变量的方式有
    for循环遍历
    增强for循环
    迭代器

fail-fast(快速失败)

使用modCount记录list发生变化(增加删除)的次数

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

序列化和反序列化

private void readObject(java.io.ObjectInputStream s)
private void writeObject(java.io.ObjectOutputStream s)

Vector和ArrayList的相同和不同

相同点:

  • 底层都是数组实现的
  • 默认长度都是10

不同点:

  • Vector是线程安全的,因为方法上加了Synchronized
  • 扩容容量,Vector是两倍,ArrayList是1.5倍

二、LinkedList

  • LinkedList是一个双向链表实现的List
  • LinkedList是一个双端队列,可以实现队列、双端队列、栈的特点
  • 包含头、尾引用

LinkedList对于查找的优化

若index < 双向链表长度的1/2,则从前向后查找; 否则,从后向前查找。

public E get(int index) 

    checkElementIndex(index);
    return node(index).item;


Node<E> node(int index) 

  // assert isElementIndex(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;
    

三、CopyOnWriteArrayList(有一个属性是ReentrantLock对象)

CopyOnWriteArrayList会使用ReentrantLock进行加锁。

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable

    final transient ReentrantLock lock = new ReentrantLock();
    private transient volatile Object[] array;
    
    public CopyOnWriteArrayList() // 构造一个空数组 setArray(new Object[0]);
    public CopyOnWriteArrayList(Collection<? extends E> c) // 将 传入的 Collection 转为Object[] 赋值给 array
	public CopyOnWriteArrayList(E[] toCopyIn) // 将传入的数组 toCopyIn 赋值给 array


add方法(会加锁)

先将数组拷贝到一个容量为之前数组容量+1的数组中,其他线程如果并发遍历的时候,可能就是遍历的是原数组,而不是新的数组。

public boolean add(E e)

public void add(int index, E element) 
    final ReentrantLock lock = this.lock;
    lock.lock();
    try 
        Object[] elements = getArray();
        int len = elements.length;
        //检查越界情况
        if (index > len || index < 0)
            throw new IndexOutOfBoundsException("Index: "+index+
                                                ", Size: "+len);
        Object[] newElements;
        //移动的元素的个数
        int numMoved = len - index;
        if (numMoved == 0) //插在末尾
            newElements = Arrays.copyOf(elements, len + 1);
        else 
            newElements = new Object[len + 1];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index, newElements, index + 1,
                             numMoved);
        
        newElements[index] = element;
        setArray(newElements);
     finally 
        lock.unlock();
    


public boolean addIfAbsent(E e) 
private boolean addIfAbsent(E e, Object[] snapshot) ---加锁的添加方法,详情看 jdk souorceCode注释

remove和add一样,先复制再删除再赋值。

get方法(未加锁)

public E get(int index) 
    return get(getArray(), index);

总结

  • 1、CopyOnWriteArrayList使用ReentrantLock进行枷锁,保证线程安全。
  • 2、CopyOnWriteArrayList的写操作都要先拷贝一份新数组,在新数组中做修改,修改完了再用新数组替换老数组,性能比较低下;
  • 3、CopyOnWriteArrayList的读操作支持随机访问,时间复杂度为O(1);
  • 4、CopyOnWriteArrayList采用读写分离的思想,读操作不加锁,写操作加锁,且写操作占用较大内存空间,所以适用于读多写少的场合;
  • CopyOnWriteArrayList只保证最终一致性,不保证实时一致性;缺陷,对于边读边写的情况,不一定能实时的读到最新的数据

Map

1.7 HashMap

put方法总结(一定要看,不在桶里面的话,先判断需不需要扩容(扩容条件:size的值是不是大于等于阈值且那个Hash桶元素不为null))

  • 1、没初始化的时候进行初始化操作
  • 2、如果key为null的话,往桶0中去put元素
  • 3、不为null的话,计算应该放在哪个桶,遍历这个桶,如果存在key相同且hash相同的元素,修改返回原来的值,如果不存在的话。
  • 4、如果值不存在的话,先判断size的值是不是大于等于阈值,如果大于且那个Hash桶元素不为null,则进行扩容,存放元素。否则直接存放元素。

get方法总结

  • 1、如果key为null,则到桶0中去寻找元素
  • 2、如果不为null,先计算在哪个桶,再去那个桶中去找,看是否有key相同且hash值相同的元素

1.8HashMap

参考链接
自己整理的博客

  • 最大容量为2的30次方
  • 当一个桶中元素个数大于等于8的时候树化
  • 当一个桶中元素小于等于6的时候转化成链表
  • 当桶中元素个数达到64的时候才树化
  • 有modCount属性,用于迭代时候执行快速失败操作

put函数总结(1.7 单独判断key是否为null,1.8单独判断桶是否为null)

  • 1、如果没初始化的话,先初始化
  • 2、如果计算出的hash桶的位置为null,那么就会直接放进去
  • 3、如果不为null,如果桶的第一个元素的key值和hash值相同,则直接替换
  • 4、如果第一个不为null,会判断是树还是链表,如果是树,放入树,是链表放入链表(在遍历链表的时候要注意可能要转成红黑树,要求树的长度大于等于8,如果在转树的时候桶容量小于64,则会扩容到64)。
  • 5、插入之后,如果size大于阈值,则进行resize操作。

ConcurrentHashMap 1.7

put(总结,面试看懂这个即可)

定位Segment:(((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE

定位HashEntry:(((tab.length - 1) & h)) << TSHIFT) + TBASE

(先计算在哪个Entry(hash值的高sshift位和size-1进行按位与),再计算在Entry的哪个位置

put操作(先计算在哪个Entry(hash值的高sshift位和size-1进行按位与),再计算在Entry的哪个位置

  • 1、ConcurrentHashMap中包含Segment数组,每个Segment中又包含HashEntry数组,Segment继承自ReentrantLock
    在JDK1.7中,Java使用分段锁机制实现ConcurrentHashMap,在ConcurrentHashMap对象中有一个Segment数组(Segment类继承ReentrantLock类),即将整个Hash表划分成了多个Segment分段,即每个分段则类似于一个Hashtable,这样在执行put操作的时候首先根据hash算法定位到属于哪个Segment,然后对Segment加锁即可。因此ConcurrentHashMap在多线程并发执行的过程中实现了多线程put操作。

  • 2、segmentShift用于定位参与散列运算的位数,其等于32减去sshift,使用32是因为ConcurrentHashMap的hash()方法返回的最大数是32位的,hash >>> segmentShift所以就会只剩下低sshift位为1,其余都为0,因此,求key散列到长度为ssize的Segment数组的下标j,就是求key的hash值的高sshift位,总的来说,计算在哪个Segment就是计算hash的高sshift位和hash值进行按位与。

总的来说,计算在Segment的哪个位置就是计算Segment的高sshift位与segmentMask进行按位与得到元素位于位于Segment中的下标。

@SuppressWarnings("unchecked")
public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) 
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;
    // Find power-of-two sizes best matching arguments
    
    int sshift = 0;
    //Segment的大小,分段数组的大小,如果小于concurrencyLevel
    //会被调整成大于或等于concurrencyLevel的最小的2的N次方值来作为segments数组的长度
    //2^sshift=ssize  
    int ssize = 1; 
    while (ssize < concurrencyLevel) 
        ++sshift;
        ssize <<= 1;
    
    //一个键值对在Segment数组中下标j的计算公式为:
    //比如ssize=16,则segmentMask为1111b,
    //j = (hash >>> segmentShift) & segmentMask
  	//2^sshift=ssize  
    this.segmentShift = 32 - sshift;
    this.segmentMask = ssize - 1;
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    while (cap < c)
        cap <<= 1;
    // create segments and segments[0]
    Segment<K,V> s0 =
        new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                         (HashEntry<K,V>[])new HashEntry[cap]);
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;


get操作(不加锁)

JDK1.7的ConcurrentHashMap的get操作是不加锁的,因为在每个Segment中定义的HashEntry数组和在每个HashEntry中定义的value和next HashEntry节点都是volatile类型的,volatile类型的变量可以保证其在多线程之间的可见性,因此可以被多个线程同时读,从而不用加锁。而其get操作步骤也比较简单,定位Segment –> 定位HashEntry –> 通过getObjectVolatile()方法获取指定偏移量上的HashEntry –> 通过循环遍历链表获取对应值。

size函数

ConcurrentHashMap的size操作的实现方法也非常巧妙,一开始并不对Segment加锁(遍历segment),而是直接尝试将所有的Segment元素中的count相加,这样执行两次,然后将两次的结果对比,如果两次结果相等则直接返回;而如果两次结果不同,则再将所有Segment加锁,然后再执行统计得到对应的size值。

ConcurrentHashMap 1.8

sizeCtl

  • 1、-1,表示有线程正在进行初始化操作
  • 2、-(1 + nThreads),表示有n个线程正在一起扩容
  • 3、0,默认值,后续在真正初始化的时候使用默认容量
  • 4、> 0,初始化或扩容完成后下一次的扩容门槛

put总结

  • 1、如果桶数组未初始化,则初始化;
  • 2、如果待插入的元素所在的桶为空,则尝试把此元素直接插入到桶的第一个位置(通过CAS,初始化的时候也使用CAS锁控制只有一个线程初始化桶数组,sizeCtl在初始化后存储的是扩容门槛);
  • 3、如果正在扩容,则当前线程一起加入到扩容的过程中;
  • 4、如果待插入的元素所在的桶不为空且不在迁移元素,则锁住这个桶(分段锁);
  • 5、如果当前桶中元素以链表方式存储,则在链表中寻找该元素或者插入元素(需要判断是否要转化成红黑树);
  • 6、如果当前桶中元素以红黑树方式存储,则在红黑树中寻找该元素或者插入元素;
  • 7、如果元素存在,则返回旧值;
  • 8、如果元素不存在,整个Map的元素个数加1,并检查是否需要扩容(每次添加元素后,元素数量加1,并判断是否达到扩容门槛,达到了则进行扩容或协助扩容,判断是否需要扩容使用的LongAdder思想。);

使用的锁有自旋锁+CAS+synchronized+分段锁(相当于一个Node一个锁)

为什么使用synchronized而不是ReentrantLock?

  • 因为synchronized已经得到了极大地优化,在特定情况下并不比ReentrantLock差。

在判断是否需要扩容的时候,使用的是LongAdder的思想。

一些难点

(1)新桶数组大小是旧桶数组的两倍;

(2)迁移元素先从靠后的桶开始;

(3)迁移完成的桶在里面放置一ForwardingNode类型的元素,标记该桶迁移完成;

(4)迁移时根据hash&n是否等于0把桶中元素分化成两个链表或树;

(5)低位链表(树)存储在原来的位置;

(6)高们链表(树)存储在原来的位置加n的位置;

(7)迁移元素时会锁住当前桶,也是分段锁的思想;

LinkedHashMap

数组+红黑树+单链表+双向链表
要重写下面的这个函数才能实现LRU,比如重写成:

public boolean removeEldestEntry(Map.Entry<K,V>eldest)

	// 当元素个数大于了缓存的容量, 就移除元素
    return size()>this.capacity;


参考博客

public class LinkedHashMap<K,V>
    extends HashMap<K,V>
    implements Map<K,V>

重要的三个函数

  • 1、afterNodeAccess
    在节点被访问后调用,主要在put已经存在的元素的或者get()时候被调用,如果accessOrder为true,调用这个方法把访问到的节点移动到双向链表的末尾
  • 2、afterNodeInsertion
    在HashMap的putVal方法中被调用,可以看到HashMap中这个方法的实现为空,如果evict为true,则移除最老的头节点。
  • 3、afterNodeRemoval
    在节点被删除的时候调用,从双链表中将节点删除。
//HashMap 中,这三个方法都是没实现的,在 LinkedHashMap 中实现来维护结点顺序
//
void afterNodeAccess(Node<K,V> p)  
void afterNodeInsertion(boolean evict)  
void afterNodeRemoval(Node<K,V> p)  

//LinkedHashMap
/*
	在节点访问之后被调用,主要在put()已经存在的元素或get()时被调用,
	如果accessOrder为true,调用这个方法把访问到的节点移动到双向链表的末尾。
*/
void afterNodeAccess(Node<K,V> e)  // move node to last
    LinkedHashMap.Entry<K,V> last;
    // accessOrder = true则执行,否则结束
    // accessOrder = true, e 不是 tail 尾结点
    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;
    


/*
	在节点插入之后做些什么,在HashMap中的putVal()方法中被调用,可以看到HashMap中这个方法的实现为空。
	evict:驱逐的意思
	如果 evict 为 true,则移除最老的元素(head)
	默认removeEldestEntry()方法返回false,也就是不删除元素。
*/
void afterNodeInsertion(boolean evict)  // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    //如果evict为true,且头节点不为空,且 确定移除最老的元素,即移除 head    
    //head 为 双向链表的头结点
    if (evict && (first = head) != null && removeEldestEntry(first)) 
        K key = first.key;
        //HashMap.removeNode()从HashMap中把这个节点移除之后,会调用 afterNodeRemoval() 方法;
        removeNode(hash(key), key, null, false, true);
    

//传进来的参数 是 双向链表的头结点 (即最老的结点)
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) 
    return false;


/*
	在节点被删除之后调用的方法 afterNodeInsertion -> HashMap.removeNode() -> afterNodeRemoval
	从双向链表中 删除结点 e
*/
void afterNodeRemoval(Node<K,V> e)  // unlink
    LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    // 把节点p从双向链表中删除。
    p.before = p.after = null;
    if (b == null)
        head = a;
    else
        b.after = a;
    if (a == null)
        tail = b;
    else
        a.before = b;


/*
	因此使用 LinkedHashMap 实现 LRU, 
	1) 设置 accessOrder 为 true --> 把最近访问的结点移动到尾部
	2) 重写 removeEldestEntry 方法  --> 返回 true 会删除该结点, false 不删除
*/

总结

(1)LinkedHashMap继承自HashMap,具有HashMap的所有特性;

(2)LinkedHashMap内部维护了一个双向链表存储所有的元素;

(3)如果accessOrder为false,则可以按插入元素的顺序遍历元素;

(4)如果accessOrder为true,则可以按访问元素的顺序遍历元素;

(5)LinkedHashMap的实现非常精妙,很多方法都是在HashMap中留的钩子(Hook),直接实现这些Hook就可以实现对应的功能了,并不需要再重写put()等方法;

(6)默认的LinkedHashMap并不会移除旧元素,如果需要移除旧元素,则需要重写removeEldestEntry()方法设定移除策略;

(7)LinkedHashMap可以用来实现LRU缓存淘汰策略;

使用LinkedHashMap实现LRU

LinkedHashMap如何实现LRU缓存淘汰策略呢?

首先,我们先来看看LRU是个什么鬼。LRU,Least Recently Used,最近最少使用,也就是优先淘汰最近最少使用的元素。

如果使用LinkedHashMap,我们把accessOrder设置为true是不是就差不多能实现这个策略了呢?答案是肯定的。请看下面的代码:

public class LRUTest

	public static void main(String[] args)
	
		LRU<Integer,Integer> lru = new LRU(5,0.75f);
		lru.put(1,1);
		lru.put(2,2);
		lru.put(3,3);
		lru.put(4,4);
		lru.put(5,5);
		lru.put(6,6);
		lru.put(7,7);
		System.out.println(lru.get(4));
		lru.put(6,666);
		System.out.println(lru);
	

class LRU extends LinkedHashMap<K,V>

	private int capacity;
	public LRU(int capacity,int loadFactor)
	
		super(capacity,loadFactor,true);
		this.capacity = capacity;
	
	/**
	* 重写removeEldestEntry()方法设置何时移除旧元素
    * @param eldest
    * @return 
	*/
	public boolean removeEldestEntry(Map.Entry<K,V>eldest)
	
		// 当元素个数大于了缓存的容量, 就移除元素
        return size()>this.capacity;
	

以上是关于Java集合(总结,面试使用)的主要内容,如果未能解决你的问题,请参考以下文章

JAVA面试总结--集合

阿里JAVA开发面试常问问题总结3

Java集合类常见面试知识点总结

含泪经验分享!25 个月 79 场 Java 岗面试,程序员的入职门槛到底是什么?

毕业两年的java面试总结及个人知识总结

25 个月 79 场 Java 岗面试,程序员的入职门槛到底是什么?(含泪经验分享)