Java教程

第六章 Java数据结构和算法 之 容器类(一)

本文主要是介绍第六章 Java数据结构和算法 之 容器类(一),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

文章目录

  • 一、常见集合类概述
    • (1)Collection 集合接口
      • 1、List子接口
        • (1)ArrayList 数组
        • (2)LinkedList 链表
          • (2.1)ArrayList与LinkedList
        • (3)Vector 向量
          • (3.1)Stack 栈
      • 2、Set子接口
        • (1)HashSet 散列集
          • (1.1)LinkedHashSet 链式散列集
        • (2)TreeSet 树形集
      • 3、Queue 队列
        • (1)Deque 双端队列
      • 补:Iterator迭代器
    • (2)Map 图接口
      • 1、HashTable 哈希表
      • 2、HashMap 哈希表
        • (1)LinkedHashMap 链式哈希表
        • (2)HashMap和Hashtable的区别
        • (2)HashMap和HashSet的区别
      • 3、WeakHashMap
      • 4、TreeMap
      • 补:总结
  • 二、Java多线程之同步集合与并发集合
    • (1)同步集合类
    • (2)并发集合类
      • 1、性能
      • 2、实现原理
      • 3、使用建议
      • 4、ConcurrentHashMap
      • 5、CopyOnWrite容器
        • (5.1)CopyOnWriteArrayList
        • (5.2)应用场景
        • (5.3)缺点
          • (5.3.1)内存占用问题
          • (5.3.2)数据一致性问题
  • 三、HashMap
    • 1、HashMap简介
    • 2、HashMap类图结构
    • 3、HashMap存储结构
    • 4、HashMap基本原理
      • (4.1)概念介绍
      • (4.2)初始化
      • (4.3)Hash计算和碰撞问题
      • (4.4)HashMap的put解析
      • (4.5)HashMap的get解析
      • (4.6)HashMap的size解析
      • (4.7)HashMap的reSzie解析
    • 5、HashMap、ArrayMap和SparseArray源码分析和性能对比
  • 四、ConcurrentHashMap
    • 1、解决问题
    • 2、JDK1.7:数组+Segment+分段锁机制
    • 3、JDK1.8:数组+链表/红黑树+CAS+Synchronized
    • 4、JDK1.7 & JDK1.8 对比

数据结构是以某种形式将数据组织在一起的集合,它不仅存储数据,还支持访问和处理数据的操作。JDK提供了几个能有效地组织和操作数据的数据结构(位于java.util包),这些数据结构通常称为Java集合框架。
在这里插入图片描述

一、常见集合类概述

集合继承关系图
在这里插入图片描述
在Java容器中一共定义了2种集合, 顶层接口分别是Collection和Map。但是这2个接口都不能直接被实现使用,分别代表两种不同类型的容器。
简单来看,Collection代表的是单个元素对象的序列,(可以有序/无序,可重复/不可重复 等,具体依据具体的子接口Set,List,Queue等);Map代表的是“键值对”对象的集合(同样可以有序/无序 等依据具体实现)

(1)Collection 集合接口

Collection是最基本的集合接口,存储对象元素集合。一个Collection代表一组Object(元素)。有些容器允许重复元素有的不允许,有些有序有些无需。由Collection接口派生的两个接口是List和Set。这个接口的设计目的是希望能最大程度抽象出元素的操作。
定义

public interface Collection<E> extends Iterable<E> {
    ...
}

 
 123

泛型即该Collection中元素对象的类型,继承的Iterable是定义的一个遍历操作接口,采用hasNext next的方式进行遍历。具体实现还是放在具体类中去实现。
主要方法

  • boolean add(Object o) 添加对象到集合
  • boolean remove(Object o) 删除指定的对象
  • int size() 返回当前集合中元素的数量
  • boolean contains(Object o) 查找集合中是否有指定的对象
  • boolean isEmpty() 判断集合是否为空
  • Iterator iterator() 返回一个迭代器
  • boolean containsAll(Collection c) 查找集合中是否有集合c中的元素
  • boolean addAll(Collection c) 将集合c中所有的元素添加给该集合
  • void clear() 删除集合中所有元素
  • void removeAll(Collection c) 从集合中删除c集合中也有的元素
  • void retainAll(Collection c) 从集合中删除集合c中不包含的元素

1、List子接口

List是一个允许重复元素的指定索引、有序集合。
从List接口的方法来看,List接口增加了面向位置的操作,允许在指定位置上操作元素。用户可以使用这个接口精准掌控元素插入,还能够使用索引index(元素在List中的位置,类似于数组下标)来访问List中的元素。List接口有两个重要的实现类:ArrayList和LinkedList。
Set里面和List最大的区别是Set里面的元素对象不可重复。

(1)ArrayList 数组

定义
1、ArrayList实现了List接口的可变大小的数组。(数组可动态创建,如果元素个数超过数组容量,那么就创建一个更大的新数组)
2、它允许所有元素,包括null
3、它的size, isEmpty, get, set, iterator,add这些方法的时间复杂度是O(1),如果add n个数据则时间复杂度是O(n)
4、ArrayList没有同步方法
常用方法

  • Boolean add(Object o)将指定元素添加到列表的末尾
  • Boolean add(int index,Object element)在列表中指定位置加入指定元素
  • Boolean addAll(Collection c)将指定集合添加到列表末尾
  • Boolean addAll(int index,Collection c)在列表中指定位置加入指定集合
  • Boolean clear()删除列表中所有元素
  • Boolean clone()返回该列表实例的一个拷贝
  • Boolean contains(Object o)判断列表中是否包含元素
  • Boolean ensureCapacity(int m)增加列表的容量,如果必须,该列表能够容纳m个元素
  • Object get(int index)返回列表中指定位置的元素
  • Int indexOf(Object elem)在列表中查找指定元素的下标
  • Int size()返回当前列表的元素个数
    常见源码分析
    add代码分析
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}
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 - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

 
 123456789101112131415161718192021222324252627282930313233

remove代码分析

public E remove(int index) {
    rangeCheck(index);
    modCount++;
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
    return oldValue;
}

 
 1234567891011121314

其实就是直接使用System.arraycopy把需要删除index后面的都往前移一位然后再把最后一个去掉。

与之前学的数据结构中数组实现方法联系

(2)LinkedList 链表

定义
1、LinkedList是一个实现了List接口的链表维护的序列容器
2、允许null元素。
3、LinkedList提供额外的get,remove,insert方法在LinkedList的首部或尾部。这些操作使LinkedList可被用作堆栈(stack),队列(queue)或双向队列(deque)。
4、LinkedList没有同步方法。如果多个线程同时访问一个List,则必须自己实现访问同步。
结点定义(双向链表)

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;
    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

 
 1234567891011

链表定义
每个LinkedList中会持有链表的头指针和尾指针

transient int size = 0;
transient Node<E> first;
transient Node<E> last;

 
 123

插入/删除操作

private void linkFirst(E e) {
    final Node<E> f = first;
    final Node<E> newNode = new Node<>(null, e, f);
    first = newNode;
    if (f == null)
        last = newNode;
    else
        f.prev = newNode;
    size++;
    modCount++;
}
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}
private E unlinkFirst(Node<E> f) {
    // assert f == first && f != null;
    final E element = f.item;
    final Node<E> next = f.next;
    f.item = null;
    f.next = null; // help GC
    first = next;
    if (next == null)
        last = null;
    else
        next.prev = null;
    size--;
    modCount++;
    return element;
}
private E unlinkLast(Node<E> l) {
    // assert l == last && l != null;
    final E element = l.item;
    final Node<E> prev = l.prev;
    l.item = null;
    l.prev = null; // help GC
    last = prev;
    if (prev == null)
        first = null;
    else
        prev.next = null;
    size--;
    modCount++;
    return element;
}
E unlink(Node<E> x) {
    // assert x != null;
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;
    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }
    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }
    x.item = null;
    size--;
    modCount++;
    return element;
}

 
 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
(2.1)ArrayList与LinkedList

链表LinkedList和数组ArrayList的最大区别在于它们对元素的存储方式的不同导致它们在对数据进行不同操作时的效率不同。实际使用时根据特定的需求选用合适的类。

  • 查找方面。数组的效率更高,可以直接索引出查找,而链表必须从头查找。
  • 插入删除方面。特别是在中间进行插入删除,这时候链表体现出了极大的便利性,只需要在插入或者删除的地方断掉链然后插入或者移除元素,然后再将前后链重新组装,但是数组必须重新复制一份将所有数据后移或者前移。
  • 在内存申请方面,当数组达到初始的申请长度后,需要重新申请一个更大的数组然后把数据迁移过去才行。而链表只需要动态创建即可。
    如果主要是查找元素则使用ArrayList。
    如果主要是插入/删除元素则使用LinkedList。
    在这里插入图片描述

(3)Vector 向量

Vector非常类似ArrayList。
Vector是同步的。当一个Iterator被创建而且正在被使用,另一个线程改变了Vector的状态(例如,添加或删除了一些元素),这时调用Iterator的方法时将抛出ConcurrentModificationException,因此必须捕获该异常。

(3.1)Stack 栈

Stack继承自Vector,实现一个后进先出的堆栈。Stack提供5个额外的方法使得Vector得以被当作堆栈使用。基本的push和pop方法,还有peek方法得到栈顶的元素,empty方法测试堆栈是否为空,search方法检测一个元素在堆栈中的位置。

2、Set子接口

Set是一种不包含重复的元素的Collection,即任意的两个元素e1和e2都有e1.equals(e2)=false,Set最多有一个null元素。

(1)HashSet 散列集

HashSet实现了Set接口,基于HashMap进行存储。遍历时不保证顺序,并且不保证下次遍历的顺序和之前一样。HashSet中允许null元素。

private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();

 
 12

意思就是HashSet的集合其实就是HashMap的key的集合,然后HashMap的val默认都是PRESENT。HashMap的定义即是key不重复的集合。使用HashMap实现,这样HashSet就不需要再实现一遍。
所以所有的add,remove等操作其实都是HashMap的add、remove操作。遍历操作其实就是HashMap的keySet的遍历

...
public Iterator<E> iterator() {
    return map.keySet().iterator();
}
public boolean contains(Object o) {
    return map.containsKey(o);
}
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}
public void clear() {
    map.clear();
}
...

 
 1234567891011121314151617
(1.1)LinkedHashSet 链式散列集

LinkedHashSet的核心概念相对于HashSet来说就是一个可以保持顺序的Set集合。HashSet是无序的,LinkedHashSet会根据add,remove这些操作的顺序在遍历时返回固定的集合顺序。这个顺序不是元素的大小顺序,而是可以保证2次遍历的顺序是一样的。
类似HashSet基于HashMap的源码实现,LinkedHashSet的数据结构是基于LinkedHashMap。

(2)TreeSet 树形集

TreeSet即是一组有次序的集合,如果没有指定排序规则Comparator,则会按照自然排序。(自然排序即e1.compareTo(e2) == 0作为比较)
TreeSet源码的算法即基于TreeMap,扩展自AbstractSet,并实现了NavigableSet,AbstractSet扩展自AbstractCollection,树形集是一个有序的Set,其底层是一颗树,这样就能从Set里面提取一个有序序列了。在实例化TreeSet时,我们可以给TreeSet指定一个比较器Comparator来指定树形集中的元素顺序。树形集中提供了很多便捷的方法。

注:TreeSet内的元素必须实现Comparable接口。

public class TestSet {
    public static void main(String[] args) {
        TreeSet<Integer> set = new TreeSet<>();
        set.add(1111);
        set.add(2222);
        set.add(3333);
        set.add(4444);
        set.add(5555);
        System.out.println(set.first()); // 输出第一个元素
        System.out.println(set.lower(3333)); //小于3333的最大元素
        System.out.println(set.higher(2222)); //大于2222的最大元素
        System.out.println(set.floor(3333)); //不大于3333的最大元素
        System.out.println(set.ceiling(3333)); //不小于3333的最大元素
        System.out.println(set.pollFirst()); //删除第一个元素
        System.out.println(set.pollLast()); //删除最后一个元素
        System.out.println(set);
    }
}

 
 123456789101112131415161718192021222324

在这里插入图片描述

3、Queue 队列

队列是一种先进先出的数据结构,元素在队列末尾添加,在队列头部删除。Queue接口扩展自Collection,并提供插入、提取、检验等操作。

  • offer:向队列添加一个元素
  • poll:移除队列头部元素(队列为空返回null)
  • remove:移除队列头部元素(队列为空抛出异常)
  • element:获取头部元素
  • peek:获取头部元素

(1)Deque 双端队列

接口Deque,是一个扩展自Queue的双端队列,它支持在两端插入和删除元素,因为LinkedList类实现了Deque接口,所以通常我们可以使用LinkedList来创建一个队列。PriorityQueue类实现了一个优先队列,优先队列中元素被赋予优先级,拥有高优先级的先被删除。

Queue<String> queue = new LinkedList<>();

 
 1

补:Iterator迭代器

如何遍历Collection中的每一个元素?不论Collection的实际类型如何,它都支持一个iterator()的方法,该方法返回一个迭代子,使用该迭代子即可逐一访问Collection中每一个元素。典型的用法如下:

Iterator it = collection.iterator(); // 获得一个迭代子  
while(it.hasNext()) {  
Object obj = it.next(); // 得到下一个元素  
} 

 
 1234

(2)Map 图接口

Map是图接口,存储键值对映射的容器类。Map提供key到value的映射。一个Map中不能包含相同的key,每个key只能映射一个value。
接口定义

public interface Map<K,V> {
    ...
    interface Entry<K,V> {
        K getKey();
        V getValue();
        ...
    } 
}

 
 123456789

泛型<K,V>分别代表key和value的类型。这时候注意到还定义了一个内部接口Entry,其实每一个键值对都是一个Entry的实例关系对象,所以Map实际其实就是Entry的一个Collection,然后Entry里面包含key,value。再设定key不重复的规则,自然就演化成了Map。
遍历方法
Map集合提供3种遍历访问方法,

  1. Set keySet() 获得所有key的集合然后通过key访问value
    会返回所有key的Set集合,因为key不可以重复,所以返回的是Set格式,而不是List格式。(之后会说明Set,List区别。这里先告诉一点Set集合内元素是不可以重复的,而List内是可以重复的) 获取到所有key的Set集合后,由于Set是Collection类型的,所以可以通过Iterator去遍历所有的key,然后再通过get方法获取value。如下
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");
Set<String> keySet = map.keySet();//先获取map集合的所有键的Set集合
Iterator<String> it = keySet.iterator();//有了Set集合,就可以获取其迭代器。
while(it.hasNext()) {
       String key = it.next();
       String value = map.get(key);//有了键可以通过map集合的get方法获取其对应的值。
       System.out.println("key: "+key+"-->value: "+value);//获得key和value值
}

 
 12345678910111213
  1. Collection values() 获得value的集合
    直接获取values的集合,无法再获取到key。所以如果只需要value的场景可以用这个方法。获取到后使用Iterator去遍历所有的value。如下
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");
Collection<String> collection = map.values();//返回值是个值的Collection集合
System.out.println(collection);

 
 1234567
  1. Set< Map.Entry< K, V>> entrySet() 获得key-value键值对的集合
    getValue获取key和value。如下
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");
//通过entrySet()方法将map集合中的映射关系取出(这个关系就是Map.Entry类型)
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//将关系集合entrySet进行迭代,存放到迭代器中                
Iterator<Map.Entry<String, String>> it = entrySet.iterator();
while(it.hasNext()) {
       Map.Entry<String, String> me = it.next();//获取Map.Entry关系对象me
       String key = me.getKey();//通过关系对象获取key
       String value = me.getValue();//通过关系对象获取value
}

 
 123456789101112131415

通过以上3种遍历方式我们可以知道,如果你只想获取key,建议使用keySet。如果只想获取value,建议使用values。如果key value希望遍历,建议使用entrySet。
Map的访问顺序取决于Map的遍历访问方法的遍历顺序。 有的Map,比如TreeMap可以保证访问顺序,但是有的比如HashMap,无法保证访问顺序。
主要方法

  • boolean equals(Object o)比较对象
  • boolean remove(Object o)删除一个对象
  • put(Object key,Object value)添加key和value

1、HashTable 哈希表

Hashtable继承Map接口,实现一个key-value映射的哈希表。任何非空(non-null)的对象都可作为key或者value。
添加数据使用put(key, value),取出数据使用get(key),这两个基本操作的时间开销为常数。HashTable是同步方法,线程安全但是效率低。

由于作为key的对象将通过计算其散列函数来确定与之对应的value的位置,因此任何作为key的对象都必须实现hashCode和equals方法。hashCode和equals方法继承自根类Object,如果你用自定义的类当作key的话,要相当小心,按照散列函数的定义,如果两个对象相同,即obj1.equals(obj2)=true,则它们的hashCode必须相同,但如果两个对象不同,则它们的hashCode不一定不同,如果两个不同对象的hashCode相同,这种现象称为冲突,冲突会导致操作哈希表的时间开销增大,所以尽量定义好的hashCode()方法,能加快哈希表的操作。
如果相同的对象有不同的hashCode,对哈希表的操作会出现意想不到的结果(期待的get方法返回null),要避免这种问题,只需要牢记一条:要同时复写equals方法和hashCode方法,而不要只写其中一个。

补充:浅析哈希表
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
哈希表是一种通过哈希函数将特定的键映射到特定值的一种数据结构,他维护者键和值之间一一对应关系。

  • 键(key):又称为关键字。唯一的标示要存储的数据,可以是数据本身或者数据的一部分。
  • 槽(slot/bucket):哈希表中用于保存数据的一个单元,也就是数据真正存放的容器。
  • 哈希函数(hash function):将键(key)映射(map)到数据应该存放的槽(slot)所在位置的函数。
  • 哈希冲突(hash collision):哈希函数将两个不同的键映射到同一个索引的情况。

解决哈希冲突的方法:

  • 拉链法
    哈希冲突后,用链表去延展来解决。将所有关键字为同义词的记录存储在同一线性链表中。如下图:
    在这里插入图片描述
  • 开地址法
    哈希冲突后,并不会在本身之外开拓新的空间,而是继续顺延下去某个位置来存放。
    开放地执法有一个公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)
    其中,m为哈希表的表长。di 是产生冲突的时候的增量序列。如果di值可能为1,2,3,…m-1,称线性探测再散列。
    如果di取1,则每次冲突之后,向后移动1个位置.如果di取值可能为1,-1,2,-2,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2),称二次探测再散列。
    如果di取值可能为伪随机数列。称伪随机探测再散列。

2、HashMap 哈希表

HashMap和Hashtable类似,不同之处在于HashMap是非同步的,并且允许null,即null value和null key。
定义
HashMap就是最基础最常用的一种Map,它无序,以散列表的方式进行存储。之前提到过,HashSet就是基于HashMap,只使用了HashMap的key作为单个元素存储。
HashMap实现了Map接口,即允许放入key为null的元素,也允许插入value为null的元素;除该类未实现同步外,其余跟Hashtable大致相同;跟TreeMap不同,该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。 根据对冲突的处理方式不同,哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java HashMap采用的是冲突链表方式。
HashMap的访问方式就是继承于Map的最基础的3种方式。
存储方式
散列表(哈希表)。哈希表是使用数组和链表的组合的方式进行存储。如下图就是HashMap采用的存储方法。
在这里插入图片描述
hash得到数值,放到数组中,如果遇到冲突则以链表方式挂在下方。
从上图容易看出,如果选择合适的哈希函数,put()和get()方法可以在常数时间内完成。但在对HashMap进行迭代时,需要遍历整个table以及后面跟的冲突链表。因此对于迭代比较频繁的场景,不宜将HashMap的初始大小设的过大。
有两个参数可以影响HashMap的性能:初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table的大小,负载系数用来指定自动扩容的临界值。当entry的数量超过capacity*load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。
将对象放入到HashMap或HashSet中时,有两个方法需要特别关心:hashCode()和equals()。hashCode()方法决定了对象会被放到哪个bucket里,当多个对象的哈希值冲突时,equals()方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到HashMap或HashSet中,需要@OverridehashCode()和equals()方法。
存储定义

transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

 
 12345678

数组table存放元素,如果遇到冲突下挂到冲突元素的next链表上。
get核心方法和put核心方法的源码
get()
get(Object key)方法根据指定的key值返回对应的value,该方法调用了getEntry(Object key)得到相应的entry,然后返回entry.getValue()。因此getEntry()是算法的核心。 算法思想是首先通过hash()函数得到对应bucket的下标,然后依次遍历冲突链表,通过key.equals(k)方法来判断是否是要找的那个entry。
在这里插入图片描述
上图中hash(k)&(table.length-1)等价于hash(k)%table.length,原因是HashMap要求table.length必须是2的指数,因此table.length-1就是二进制低位全是1,跟hash(k)相与会将哈希值的高位全抹掉,剩下的就是余数了。

//getEntry()方法
final Entry<K,V> getEntry(Object key) {
    ......
    int hash = (key == null) ? 0 : hash(key);
    for (Entry<K,V> e = table[hash&(table.length-1)];//得到冲突链表
         e != null; e = e.next) {//依次遍历冲突链表中的每个entry
        Object k;
        //依据equals()方法判断是否相等
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

put()
put(K key, V value)方法是将指定的key, value对添加到map里。该方法首先会对map做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry,插入方式为头插法。
在这里插入图片描述

//addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);//自动扩容,并重新哈希
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = hash & (table.length-1);//hash%table.length
    }
    //在冲突链表头部插入新的entry
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

 
 123456789101112

remove()
remove(Object key)的作用是删除key值对应的entry,该方法的具体逻辑是在removeEntryForKey(Object key)里实现的。removeEntryForKey()方法会首先找到key值对应的entry,然后删除该entry(修改链表的相应引用)。查找过程跟getEntry()过程类似。
在这里插入图片描述

//removeEntryForKey()
final Entry<K,V> removeEntryForKey(Object key) {
    ......
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);//hash&(table.length-1)
    Entry<K,V> prev = table[i];//得到冲突链表
    Entry<K,V> e = prev;
    while (e != null) {//遍历冲突链表
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {//找到要删除的entry
            modCount++; size--;
            if (prev == e) table[i] = next;//删除的是冲突链表的第一个entry
            else prev.next = next;
            return e;
        }
        prev = e; e = next;
    }
    return e;
}

 
 123456789101112131415161718192021

HashSet与HashMap如何判断相同元素
HashSet和HashMap一直都是JDK中最常用的两个类,HashSet要求不能存储相同的对象,HashMap要求不能存储相同的键。
(1)equals(Object obj)和hashcode()
在Java中任何一个对象都具备equals(Object obj)和hashcode()这两个方法,因为他们是在Object类中定义的。
equals(Object obj)方法用来判断两个对象是否“相同”,如果“相同”则返回true,否则返回false。
hashcode()方法返回一个int数,在Object类中的默认实现是“将该对象的内部地址转换成一个整数返回”。
对于这两个方法,有以下两个重要规范:

  • 规范1:若重写equals(Object obj)方法,有必要重写hashcode()方法,确保通过equals(Object obj)方法判断结果为true的两个对象具备相等的hashcode()返回值。说得简单点就是:“如果两个对象相同,那么他们的hashcode应该相等”。
  • 规范2:如果equals(Object obj)返回false,即两个对象“不相同”,并不要求对这两个对象调用hashcode()方法得到两个不相同的数。说的简单点就是:“如果两个对象不相同,他们的hashcode可能相同”。
    (2)Java 保证对象的一致性
    因此,在Java运行时环境判断HashSet和HastMap中的两个对象相同或不同应该先判断hashcode是否相等,再判断是否equals。 只有两者均相同,才能保证对象的一致性。
    结论:为了保证HashSet中的对象不会出现重复值,在被存放元素的类中必须要重写hashCode()和equals()这两个方法。

(1)LinkedHashMap 链式哈希表

LinkedHashSet是用一个链表实现来扩展HashSet类,它支持对规则集内的元素排序。HashSet中的元素是没有被排序的,而LinkedHashSet中的元素可以按照它们插入规则集的顺序提取。
其实LinkedHashMap的存储还是跟HashMap一样,采用哈希表方法存储,只不过LinkedHashMap多维护了一份head,tail链表。

transient LinkedHashMap.Entry<K,V> head;
transient LinkedHashMap.Entry<K,V> tail;

 
 12

即在创建新Node的时候将新Node放到最后,这样遍历的时候不再像HashMap一样,从数组开始判断第一个非空元素,而是直接从表头进行遍历。这样即满足有序遍历。

(2)HashMap和Hashtable的区别

  • 线程安全性:同步(synchronization)
    HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。
    HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
  • 速度
    由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。

Hashtable和HashMap有几个主要的不同:线程安全以及速度。仅在你需要完全的线程安全的时候使用Hashtable,而如果你使用Java 5或以上的话,请使用ConcurrentHashMap。

(2)HashMap和HashSet的区别

HashSet不能添加重复的元素,当调用add(Object)方法时候,首先会调用Object的hashCode方法判hashCode是否已经存在,如不存在则直接插入元素;如果已存在则调用Object对象的equals方法判断是否返回true,如果为true则说明元素已经存在,如为false则插入元素。
HashSet是借助HashMap来实现的,利用HashMap中Key的唯一性,来保证HashSet中不出现重复值。HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成合适的HashMap方法,因此HashSet的实现非常简单,只有不到300行代码。这里不再赘述。

//HashSet是对HashMap的简单包装
public class HashSet<E>
{
    ......
    private transient HashMap<E,Object> map;//HashSet里面有一个HashMap
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    public HashSet() {
        map = new HashMap<>();
    }
    ......
    public boolean add(E e) {//简单的方法转换
        return map.put(e, PRESENT)==null;
    }
    ......
}

 
 12345678910111213141516

3、WeakHashMap

WeakHashMap是一种改进的HashMap,它对key实行“弱引用”,如果一个key不再被外部所引用,那么该key可以被GC回收。
举例:声明了两个Map对象,一个是HashMap,一个是WeakHashMap,同时向两个map中放入a、b两个对象,当HashMap remove掉a 并且将a、b都指向null时,WeakHashMap中的a将自动被回收掉。出现这个状况的原因是,对于a对象而言,当HashMap remove掉并且将a指向null后,除了WeakHashMap中还保存a外已经没有指向a的指针了,所以WeakHashMap会自动舍弃掉a,而对于b对象虽然指向了null,但HashMap中还有指向b的指针,所以
WeakHashMap将会保留。

4、TreeMap

TreeMap基于红黑树数据结构的实现,是有序的key-value集合。键值可以使用Comparable或Comparator接口来排序。TreeMap继承自AbstractMap,同时实现了接口NavigableMap,而接口NavigableMap则继承自SortedMap。SortedMap是Map的子接口,使用它可以确保图中的条目是排好序的。
(4.1)特点

  • 内部红黑树实现
  • key-value不为空
  • TreeMap有序

(4.2)数据结构
如下图所示,是TreeMap(key:为[4,2,5,6,8,7,9])的一个内部结构示意图,其中每个节点都是Entry类型的。
Entry节点源码分析

//比较器
private final Comparator<? super K> comparator;
// Entry节点,这个表示红黑树的根节点
private transient Entry<K,V> root;
// TreeMap中元素的个数
private transient int size = 0;
// TreeMap修改次数
private transient int modCount = 0;
static final class Entry<K,V> implements Map.Entry<K,V> {
    K key;//对于key值
    V value;//对于value值
    Entry<K,V> left;//指向左子树的引用
    Entry<K,V> right;//指向右子树的引用
    Entry<K,V> parent;//指向父节点的引用
    boolean color = BLACK;//节点的颜色默认是黑色
    // 省略部分代码
}

 
 12345678910111213141516171819

(4.3)具体操作
put操作
1.校验根节点:校验根节点是否为空,若为空则根据传入的key-value的值创建一个新的节点,若根节点不为空则继续第二步
2.寻找插入位置:由于TreeMap内部是红黑树实现的则插入元素时,实际上是会去遍历左子树,或者右子树(具体遍历哪颗子树是根据当前插入key-value与根节点的比较判定的,这部在代码里面其实分为两步来体现是否指定比较器,若指定了则使用指定的比较器比较,否则使用默认key的比较器进行比较(这里有一点需要注意是TreeMap是不允许key-value为NULL)
3.新建并恢复:在第二步中实际上是需要确定当前插入节点的位置,而这一步是实际的插入操作,而插入之后为啥还需要调用fixAfterInsertion方法,这里是因为红黑树插入一个节点后可能会破坏红黑树的性质,则通过修改的代码使得红黑树从新达到平衡。
get操作
1.构造器校验:判断是否指定构造器,若指定则调用getEntryUsingComparator,若没有则进行第二步
2.空值校验:key若为空直接抛出NullPointerException,从这点可以看出TreeMap是不允许Key-value为空的
3.遍历返回:遍历整个红黑树若找到对应的值则返回,否则返回null值

补:总结

在实际使用中,如果更新图时不需要保持图中元素的顺序,就使用HashMap,如果需要保持图中元素的插入顺序或者访问顺序,就使用LinkedHashMap,如果需要使图按照键值排序,就使用TreeMap。
在这里插入图片描述
1、如果涉及到堆栈,队列等操作,应该考虑用List,对于需要快速插入,删除元素,应该使用LinkedList,如果需要快速随机访问元素,应该使用ArrayList。
2、如果程序在单线程环境中,或者访问仅仅在一个线程中进行,考虑非同步的类,其效率较高,如果多个线程可能同时操作一个类,应该使用同步的类。
3、要特别注意对哈希表的操作,作为key的对象要正确复写equals和hashCode方法。
4、尽量返回接口而非实际的类型,如返回List而非ArrayList,这样如果以后需要将ArrayList换成LinkedList时,客户端代码不用改变。这就是针对抽象编程。

二、Java多线程之同步集合与并发集合

(1)同步集合类

包括Hashtable、Vector、同步集合包装类,Collections.synchronizedMap()和Collections.synchronizedList()

(2)并发集合类

包括ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteHashSet

1、性能

同步集合比并发集合会慢得多,主要原因是锁,同步集合会对整个Map或List加锁

2、实现原理

ConcurrentHashMap:把整个Map 划分成几个片段,只对相关的几个片段上锁,同时允许多线程访问其他未上锁的片段。
CopyOnWriteArrayList:允许多个线程以非同步的方式读,当有线程写的时候它会将整个List复制一个副本给它。如果在读多写少这种对并发集合有利的条件下使用并发集合,这会比使用同步集合更具有可伸缩性。

3、使用建议

一般不需要多线程的情况,只用到HashMap、ArrayList,只要真正用到多线程的时候就一定要考虑同步。所以这时候才需要考虑同步集合或并发集合。

4、ConcurrentHashMap

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
在这里插入图片描述

5、CopyOnWrite容器

CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

(5.1)CopyOnWriteArrayList

可以发现在添加的时候是需要加锁的,否则多线程写的时候会Copy出N个副本出来。

public boolean add(T 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();  
    }  
}  
final void setArray(Object[] a) {  
    array = a;  
}  

 
 12345678910111213141516171819202122232425262728293031

读的时候不需要加锁,如果读的时候有多个线程正在向ArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的ArrayList。

public E get(int index) {  
    return get(getArray(), index);  
}  
  • 1
  • 2
  • 3
  • 4

(5.2)应用场景

CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。实现代码如下:

public class BlackListServiceImpl {  
    private static CopyOnWriteMap<String, Boolean> blackListMap = new CopyOnWriteMap<String, Boolean>(  
            1000);  
    public static boolean isBlackList(String id) {  
        return blackListMap.get(id) == null ? false : true;  
    }  
    public static void addBlackList(String id) {  
        blackListMap.put(id, Boolean.TRUE);  
    }  
    /** 
     * 批量添加黑名单 
     * 
     * @param ids 
     */  
    public static void addBlackList(Map<String,Boolean> ids) {  
        blackListMap.putAll(ids);  
    }  
}  

 
 1234567891011121314151617181920212223

注意两点:
1、减少扩容开销。根据实际需要,初始化CopyOnWriteMap的大小,避免写时CopyOnWriteMap扩容的开销。
2、使用批量添加。因为每次添加,容器每次都会进行复制,所以减少添加次数,可以减少容器的复制次数。如使用上面代码里的addBlackList方法。

(5.3)缺点

(5.3.1)内存占用问题

因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。

(5.3.2)数据一致性问题

CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

三、HashMap

1、HashMap简介

HashMap就是最基础最常用的一种Map,它无序,以散列表(数组+链表/红黑树)的方式进行存储,存储内容是键值对映射。是一种非同步的容器类,故它的线程不安全。

2、HashMap类图结构

此处的类图根据JDK1.6画出来的,如下:
在这里插入图片描述
HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable { }

 
 123
  • HashMap继承于AbstractMap类,实现了Map接口。Map是"key-value键值对"接口,AbstractMap实现了"键值对"的通用函数接口。
  • HashMap是通过"拉链法"实现的哈希表。它包括几个重要的成员变量:table, size, threshold, loadFactor, modCount。
  • table是一个Entry[]数组类型,而Entry实际上就是一个单向链表。哈希表的"key-value键值对"都是存储在Entry数组中的。
  • size是HashMap的大小,它是HashMap保存的键值对的数量。
  • threshold是HashMap的阈值,用于判断是否需要调整HashMap的容量。threshold的值=“容量*加载因子”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。
  • loadFactor就是加载因子。
  • modCount是用来实现fail-fast机制的。

3、HashMap存储结构

HashMap存储结构由数组和单向链表共同完成的,如图:
在这里插入图片描述
从上图可以看出HashMap是Y轴方向是数组,X轴方向就是链表的存储方式。大家都知道数组的存储方式在内存的地址是连续的,大小固定,一旦分配不能被其他引用占用。它的特点是查询快,时间复杂度是O(1),插入和删除的操作比较慢,时间复杂度是O(n),链表的存储方式是非连续的,大小不固定,特点与数组相反,插入和删除快,查询速度慢。
单向链表的表示

static class Entry<K,V> implements Map.Entry<K,V> {
     final K key;
     V value;
     // 指向下一个节点
     Entry<K,V> next;
     final int hash;
     // 构造函数。
     // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)"
    Entry(int h, K k, V v, Entry<K,V> n) {
         value = v;
         next = n;
         key = k;
         hash = h;
     }
     ...

 
 12345678910111213141516

数组的表示

transient Entry[] table;

 
 1

(数组的初始化:Node[] table = new Node[16];resize();//数组的初始化)
HashMap中的key-value都是存储在Entry数组中的。Entry实际上就是一个单向链表。Entry实现了Map.Entry接口,即实现getKey(),getValue(),setValue()等读取/修改key和value值的函数。

4、HashMap基本原理

(4.1)概念介绍

变量

变量术语说明
size大小HashMap的存储大小
threshold临界值HashMap大小达到临界值,需要重新分配大小
loadFactor负载因子HashMap大小负载因子,默认为75%
modCount统一修改HashMap被修改或者删除的次数总数
Entry实体HashMap存储对象的实际实体,由Key,value,hash,next组成

常量

常量大小说明
DEFAULT_INITIAL_CAPACITY1<<4默认初始数组大小 2的4次方=16
DEFAULT_INITIAL_CAPACITY1<<30数组大小最大值 2的30次方
DEFAULT_LOAD_FACTOR0.75负载因子,数组需要扩大的容量标准,当容量>数组大小负载因子时(160.75),需要重新分配大小
TREEIFY_THRESHOLD8阈值:链表长度超过阈值,将链表转换为红黑树(平衡二叉树——压缩深度+方便查找);当链表长度小于阈值,将红黑树转换为链表

(4.2)初始化

通过调用new HashMap()来初始化的,这里分析new HashMap(int initialCapacity, float loadFactor)的构造函数,代码如下:

public HashMap(int initialCapacity, float loadFactor) {
     // initialCapacity代表初始化HashMap的容量,它的最大容量是MAXIMUM_CAPACITY = 1 << 30,默认为DEFAULT_INITIAL_CAPACITY = 1 << 4
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
     // loadFactor代表它的负载因子,默认是是DEFAULT_LOAD_FACTOR=0.75,用来计算threshold临界值的。
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;
        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        //初始化哈希表
        table = new Entry[capacity];
        init();
    }

 
 123456789101112131415161718192021222324

初始化的时候需要知道初始化的容量大小,因为在后面要通过按位与的Hash算法计算Entry数组的索引,那么要求Entry的数组长度是2的N次方

(4.3)Hash计算和碰撞问题

计算出的哈希值需要满足:
(1)结果为Int类型
(2)数组长度范围内(0~n-1)
(3)尽可能充分利用数组中每一个位置
HashMap的hash计算时先计算hashCode(),然后进行二次hash。代码如下:

// 计算二次Hash    
int hash = hash(key.hashCode());

// 通过Hash找数组索引
int i = indexFor(hash, table.length);

  • 1
  • 2
  • 3
  • 4
  • 5

1、第一次Hash:String.hashCode()
JDK的String的Hash算法。
2、第二次Hash:hash(key.hashCode())

static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

 
 1234567

这里就是解决Hash的的冲突的函数,通过让高位参与运算使得结果尽可能不一样,均匀分布,充分利用数组中每一个位置。
解决Hash的冲突有以下几种方法:

  1. 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
  2. 再哈希法
  3. 链地址法
  4. 建立一 公共溢出区

而HashMap采用的是链地址法。

3、通过Hash查找数组的索引indexFor(hash,tableLength)

/**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

 
 123456

其中h是hash值,length是数组的长度,这个按位与的算法其实就是h%length求余:n-1 & hash === hash%(n-1)。保证计算结果是一个0~n-1范围内的值。
既然知道了分组的原理了,那我们看看几个例子,代码如下:

        int h=15,length=16;
        System.out.println(h & (length-1));
        h=15+16;
        System.out.println(h & (length-1));
        h=15+16+16;
        System.out.println(h & (length-1));
        h=15+16+16+16;
        System.out.println(h & (length-1));

 
 12345678

运行结果都是15,为什么呢?我们换算成二进制来看看。

System.out.println(Integer.parseInt("0001111", 2) & Integer.parseInt("0001111", 2));
System.out.println(Integer.parseInt("0011111", 2) & Integer.parseInt("0001111", 2));
System.out.println(Integer.parseInt("0111111", 2) & Integer.parseInt("0001111", 2));
System.out.println(Integer.parseInt("1111111", 2) & Integer.parseInt("0001111", 2));

 
 1234

这里你就发现了,在做按位与操作的时候,后面的始终是低位在做计算,高位不参与计算,因为高位都是0。这样导致的结果就是只要是低位是一样的,高位无论是什么,最后结果是一样的。

(4.4)HashMap的put解析

总流程:

  • 如果HashMap为空,则进行初始化;
  • 对Key求Hash值,然后再计算下标。
  • 如果没有碰撞,则直接放入桶中。
  • 如果碰撞了,以链表的方式连接到后面。
  • 如果链表长度超过阈值(TREEIFY_THRESHOLD == 8),就把链表转换成红黑树。
  • 如果结点已经存在就替换旧值。
  • 如果桶满了(容量+加载因子),就需要resize(双倍扩容,保证2的n次幂),并且为了使结点均匀分散,应该重新分配结点位置
    if(++size>threshold)resize();

代码如下:

 /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

从代码可以看出,步骤如下:
(1) 首先判断key是否为null,如果是null,就单独调用putForNullKey(value)处理。
代码如下:

 /**
     * Offloaded version of put for null keys
     */
    private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

 
 12345678910111213141516

从代码可以看出,如果key为null的值,默认就存储到table[0]开头的链表了。然后遍历table[0]的链表的每个节点Entry,如果发现其中存在节点Entry的key为null,就替换新的value,然后返回旧的value,如果没发现key等于null的节点Entry,就增加新的节点。
(2) 计算key的hashcode,再用计算的结果二次hash,通过indexFor(hash, table.length);找到Entry数组的索引i。
(3) 然后遍历以table[i]为头节点的链表,如果发现有节点的hash,key都相同的节点时,就替换为新的value,然后返回旧的value。
(4) modCount是干嘛的啊? 让我来为你解答。
众所周知,HashMap不是线程安全的,但在某些容错能力较好的应用中,如果你不想仅仅因为1%的可能性而去承受hashTable的同步开销,HashMap使用了Fail-Fast机制来处理这个问题,你会发现modCount在源码中是这样声明的。
volatile关键字声明了modCount,代表了多线程环境下访问modCount,根据JVM规范,只要modCount改变了,其他线程将读到最新的值。其实在Hashmap中modCount只是在迭代的时候起到关键作用。

private abstract class HashIterator<E> implements Iterator<E> {
        Entry<K,V> next;    // next entry to return
        int expectedModCount;    // For fast-fail
        int index;        // current slot
        Entry<K,V> current;    // current entry
        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }
        public final boolean hasNext() {
            return next != null;
        }
        final Entry<K,V> nextEntry() {
        // 这里就是关键
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();
            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        current = e;
            return e;
        }
        public void remove() {
            if (current == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Object k = current.key;
            current = null;
            HashMap.this.removeEntryForKey(k);
            expectedModCount = modCount;
        }
    }

 
 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748

使用Iterator开始迭代时,会将modCount的赋值给expectedModCount,在迭代过程中,通过每次比较两者是否相等来判断HashMap是否在内部或被其它线程修改,如果modCount和expectedModCount值不一样,证明有其他线程在修改HashMap的结构,会抛出异常。
所以HashMap的put、remove等操作都有modCount++的计算。
(5) 如果没有找到key的hash相同的节点,就增加新的节点addEntry()
代码如下:

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
        if (size++ >= threshold)
            resize(2 * table.length);
    }

 
 123456

这里增加节点的时候取巧了,每个新添加的节点都增加到头节点,然后新的头节点的next指向旧的老节点。
(6) 如果HashMap大小超过临界值,就要重新设置大小,扩容。
容量 是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
见size解析。

(4.5)HashMap的get解析

计算key的hashcode,再用计算的结果二次hash,通过indexFor(hash, table.length);找到Entry数组的索引i。然后遍历以table[i]为头节点的链表,如果发现有节点的hash,key都相同的节点时,取出该结点的值。

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
    }

 
 12345678910111213

(4.6)HashMap的size解析

HashMap的大小很简单,不是实时计算的,而是每次新增加Entry的时候,size就递增。删除的时候就递减。空间换时间的做法。因为它不是线程安全的。完全可以这么做。效力高。

(4.7)HashMap的reSzie解析

扩容+重新分配结点过程:

  • 将新结点加到链表后
  • 容量扩充为原来的两倍,然后对每个结点重新计算哈希值
  • 这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为<原下标+原容量>的位置

当HashMap的大小超过临界值的时候,就需要扩充HashMap的容量了。代码如下:

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

 
 1234567891011121314

从代码可以看出,如果大小超过最大容量就返回。否则就new 一个新的Entry数组,长度为旧的Entry数组长度的两倍(保证数组长度为2的n次幂)。然后将旧的Entry[]复制到新的Entry[].(保证均匀分布)代码如下:

void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

在复制的时候数组的索引int i = indexFor(e.hash, newCapacity);重新参与计算。
针对HashMap中某个Entry链太长,查找的时间复杂度达到O(n),JDK做了优化,将链表转换成红黑树。

5、HashMap、ArrayMap和SparseArray源码分析和性能对比

ArrayMap及SparseArray是android的系统API,是专门为移动设备而定制的。用于在一定情况下取代HashMap而达到节省内存的目的。
HashMap存储原理
在这里插入图片描述
从hashMap的结构中可以看出,首先对key值求hash,根据hash结果确定在table数组中的位置,当出现哈希冲突时采用开放链地址法进行处理。Map.Entity的数据结构如下:

static class HashMapEntry<K, V> implements Entry<K, V> {    
final K key;    
V value; 
final int hash;   
 HashMapEntry<K, V> next;
}   

 
 123456

从空间的角度分析,HashMap中会有一个利用率不超过负载因子(默认为0.75)的table数组,其次,对于HashMap的每一条数据都会用一个HashMapEntry进行记录,除了记录key,value外,还会记录下hash值,及下一个entity的指针。
时间效率方面,利用hash算法,插入和查找等操作都很快,且一般情况下,每一个数组值后面不会存在很长的链表(因为出现hash冲突毕竟占比较小的比例),所以不考虑空间利用率的话,HashMap的效率非常高。
ArrayMap存储原理
在这里插入图片描述
ArrayMap利用两个数组,mHashes用来保存每一个key的hash值,mArrray大小为mHashes的2倍,依次保存key和value。源码的细节方面会在下一篇文章中说明。现在我们先抛开细节部分,只看关键语句:

mHashes[index] = hash;
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;

 
 123

相信看到这大家都明白了原理了。但是它怎么查询呢?答案是二分查找。当插入时,根据key的hashcode()方法得到hash值,计算出在mArrays的index位置,然后利用二分查找找到对应的位置进行插入,当出现哈希冲突时,会在index的相邻位置插入。
总结一下,空间角度考虑,ArrayMap每存储一条信息,需要保存一个hash值,一个key值,一个value值。对比下HashMap 粗略的看,只是减少了一个指向下一个entity的指针。还有就是节省了一部分可见空间上的内存节省也不是特别明显。是不是这样呢?后面会验证。
时间效率上看,插入和查找的时候因为都用的二分法,查找的时候应该是没有hash查找快,插入的时候呢,如果顺序插入的话效率肯定高,但如果是随机插入,肯定会涉及到大量的数组搬移,数据量大,肯定不行,再想一下,如果是不凑巧,每次插入的hash值都比上一次的小,那就得次次搬移,效率一下就扛不住了的感脚。
SparseArray存储原理
在这里插入图片描述
sparseArray相对来说就简单的多了,但是不要以为它可以取代前两种,sparseArray只能在key为int的时候才能使用,注意是int而不是Integer,这也是sparseArray效率提升的一个点,去掉了装箱的操作!
因为key为int也就不需要什么hash值了,只要int值相等,那就是同一个对象,简单粗暴。插入和查找也是基于二分法,所以原理和Arraymap基本一致,这里就不多说了。
总结一下:空间上对比,与HashMap,去掉了Hash值的存储空间,没有next的指针占用,还有其他一些小的内存占用,看着节省了不少。
时间上对比:插入和查找的情形和Arraymap基本一致,可能存在大量的数组搬移。但是它避免了装箱的环节,不要小看装箱过程,还是很费时的。所以从源码上来看,效率谁快,就看数据量大小了。
总结
1.在数据量小的时候一般认为1000以下,当你的key为int的时候,使用SparseArray确实是一个很不错的选择,内存大概能节省30%,相比用HashMap,因为它key值不需要装箱,所以时间性能平均来看也优于HashMap,建议使用!
2.ArrayMap相对于SparseArray,特点就是key值类型不受限,任何情况下都可以取代HashMap,但是通过研究和测试发现,ArrayMap的内存节省并不明显,也就在10%左右,但是时间性能确是最差的,当然了,1000以内的数据量也无所谓了,加上它只有在API>=19才可以使用,个人建议没必要使用!还不如用HashMap放心。估计这也是为什么我们再new一个HashMap的时候google也没有提示让我们使用的原因吧。

四、ConcurrentHashMap

1、解决问题

HashMap是我们平时开发过程中用的比较多的集合,但它是非线程安全的,在涉及到多线程并发的情况,进行put操作有可能会引起死循环,导致CPU利用率接近100%。

final HashMap<String, String> map = new HashMap<String, String>(2);
for (int i = 0; i < 10000; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            map.put(UUID.randomUUID().toString(), "");
        }
    }).start();
}

 
 123456789

解决方案有Hashtable,但Hashtable基本上是对读写进行加锁操作,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁。多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
所以,Doug Lea给我们带来了并发安全的ConcurrentHashMap,它的实现是依赖于 Java 内存模型。解决HashMap在并发环境下不安全而诞生的。

2、JDK1.7:数组+Segment+分段锁机制

ConcurrentHashMap采用 分段锁的机制,实现并发的更新操作,底层采用数组+链表的存储结构。其包含两个核心静态内部类 Segment和HashEntry。

  • Segment:分段锁。继承ReentrantLock用来充当锁的角色,类似HashMap的结构,内部拥有一个Entry数组,数组中每个元素又是一个链表。每个 Segment 对象守护每个散列映射表的若干个桶。
  • HashEntry用来封装映射表的键 / 值对;每个桶是由若干个 HashEntry 对象链接起来的链表。

一个 ConcurrentHashMap 实例中包含由若干个 Segment 对象组成的数组Segments,下面我们通过一个图来演示一下 ConcurrentHashMap 的结构:
在这里插入图片描述
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。

理解:写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。

从上ConcurrentHashMap 的结构图可了解,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。

3、JDK1.8:数组+链表/红黑树+CAS+Synchronized

利用CAS+Synchronized来保证并发更新的安全,底层采用数组+链表+红黑树的存储结构。
在这里插入图片描述
重要结构
(1)Node:保存key,value及key的hash值的数据结构。

class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    ... 省略部分代码
}

 
 1234567

其中value和next都用volatile修饰,保证并发的可见性。
(2)sizeCtl :默认为0,用来控制table的初始化和扩容操作。
-1 代表table正在初始化
-N 表示有N-1个线程正在进行扩容操作
其余情况:
1、如果table未初始化,表示table需要初始化的大小。
2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍,居然用这个公式算0.75(n - (n >>> 2))。
实现原理
JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作,这里我简要介绍下CAS。
CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

4、JDK1.7 & JDK1.8 对比

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

这篇关于第六章 Java数据结构和算法 之 容器类(一)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!