HashMap

前置条件:Hash的公式---> index = HashCode(Key) & (Length - 1)

1.数据结构

  • java8之前:数组+链表
  • java8之后:数组+链表+红黑树

2.如何实现插入值的

hashmap数组每个地方都会保存K-V实例,java7叫Entry,java8叫node,因为本身所有位置都为null, 所以在插入的时候会根据key的hash去计算得到一个index值,比如put(“哈希”,22),插入了“哈希”的元素,这个时候假设会用哈希函数计算出index(图的下标值,具体hashmap在查找数据的时候也是根据前置条件中的公式去算出他的下标)是2,因为数组是有限的,在有限的数组使用哈希本身会存在概率性,比如当插入一个 “希哈” 的时候,通过Hash公式算法就可能算出同一个index值,这个时候就会形成链表,而java7之前链表是头插入,就会造成随后在数组扩容之后出现链表死循环,本文下面会讲到HashMap&ArrayList浅析_ArrayList

源码具体方法:

/**
从源码中可以清楚的看到,putVal首先调用了hash(传入的key),根据key算出插入的位置index
参数解析:
key:要存储的key
value:要存储的value
onlyIfAbsent  如果当前位置已存在一个值,是否替换,false是替换,true是不替换
evict  表是否在创建模式,如果为false,则表是在创建模式
**/public V put(K key, V value) {return putVal(hash(key), key, value, false, true);
    }复制代码

3.新的节点entry如何插入

java8之前用的是头插(新来的值会取代原来的值,原有的值推到链表中去,写这个代码的作者认为后面插入的值被查询的概率更大,是为了提高查询效率),java8之后就都是用的都是尾插,java8加入了在数据结构上加入了红黑树

4 为什么后来使用尾插

数组在扩容的时候因其长度与原先不同,则根据哈希公式,算出来的位置也就不同,如果是使用头插,(数据在扩容的时候都是会创建一个新的数组,然后把原数据进行迁移)那么在移动到新建的另一个数组的时候会产生链表指向错误的情况,比如说,在还没扩容之前链表是A→B,那么在扩容之后,链表可能会变成A→C,这个时候如果去取A后面的数据的值,就会出错了,甚至可能会出现环形链表(死循环),造成线程不安全,所以使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了

HashMap&ArrayList浅析_HashMap_02

5.扩容机制

两个因素:

  • Capacity:HashMap当前长度。
  • LoadFactor:负载因子,默认值0.75f
/**
 * The load factor used when none specified in constructor.
 **/
 static final float DEFAULT_LOAD_FACTOR = 0.75f;复制代码

当前数组长度*0.75,得出来的值就是需要扩容数组时候的插入的第N个值(A值),当插入的数值超出这个A值的时候,数组就需要进行扩容了,比如(数组长度是10,那么10乘以0.75=7.5),那么在插入第七个值的时候,就要扩容,扩容或新建一个entry空数据,长度是原来数组的两倍,再把原先数组的值全部重新Hash到新的数组里,重新hash的原因是长度不同公式算出来的具体index的位置也会随之不同

6.头插与尾插的区别

  • Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
  • Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。

7.不会出现链表循环是否意味这java8可以运用于多线程

即使不会出现死循环,因其源码get/put并没有加同步锁,很难保证上一秒put的值是下一面get的值,所以线程安全还是无法保证

8.默认初始化大小为啥是16

在java8JDK236行:

/**
 * The default initial capacity - MUST be a power of two.
 */static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16复制代码

16是2的次幂,或者可以定义为其他2的次幂,当时2的次幂的时候,length-1的值所有的二进制都是1,这种情况下,通过公式运算出index的结果等于后几位值,只要输入的hashcode本身分布均匀,Hash算法就均匀,比如:

  • 2-1的二进制1,
  • 4-1的二进制11,
  • 8-1的二进制111

2,4, 8 包括源码中定义的16都是2的幂,而在通过(length-1)的情况下计算出来的二级制都是1的情况下,hash出来数都等于hashcode本身的值

9.重写equals方法的时候需要重写hashCode方法

java的所有对象都是继承于object的,equals和hashcode是object类的方法,都是用来比较两个对象是否指向同一个地址的,hashmap的get方法就是用上面前置条件的公式去算出来的,会存在概率性,也就是说会有链表,而当算出来的index相同时,怎么去判断是不是这个想要的对象,用equals,所以要重写hashcode方法,以保证相同的对象返回相同的hash值不同的对象返回不同的hash值,这就可以取到链表中想要获得的那个值了

10.关于线程不安全的问题

首先HashMap是线程不安全的,其主要体现:

  • 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
  • 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

11.实现线程安全三种方式

1.使用Collections.synchronizedMap(Map)创建线程安全的map集合 其原理就是在方法里面都加上了synch锁,我们在调用这个方法的时候就需要传入一个Map,可以看到有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象。 如果没有,则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map。HashMap&ArrayList浅析_HashMap_03

2.用hashtable实现线程安全,适用多线程情况,但效率不可观,因为在其对数据操作的时候都加上了都加上了一个sync,所以会导致效率低下,CPU消耗大 注意:hashtable的key值不允许为null,而hashmap是可以的,hashtable在赋值空的时候会抛出异常,hashmap做了特殊的处理、 hashtable使用了安全失败机制(fail-safe)如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理

3.使用ConcurrentHashMap实现线程安全 采用了分段锁技术,每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。 就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。/ 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容,最终释放锁 ConcurrentHashMap的put操作:

  • 根据 key 计算出 hashcode 。
  • 判断是否需要进行初始化。
  • 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,    失败则自旋保证成功。
  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  • 如果都不满足,则利用 synchronized 锁写入数据。
  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

12.快速失败fail-fast与安全失败fail-safe

快速失败:遍历数组中的内容,遍历的时候定义一个modCount变量,集合遍历的时候发生变化的modCount也会随之变化,迭代器使用next()检查下一个元素时,会检查modCount与expectmoodConut值是否相等,相等返回,不相等返回concurrentModifilyException异常,即并发修改异常,终止遍历

安全失败:采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历,由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发ConcurrentModificationException。 快速失败和安全失败是对迭代器而言的。

快速失败:当在迭代一个集合的时候,如果有另外一个线程在修改这个集合,就会抛出ConcurrentModificationException异常,java.util下都是快速失败。

安全失败:在迭代时候会在集合二层做一个拷贝,所以在修改集合上层元素不会影响下层。在java.util.concurrent下都是安全失败

ArrayList

1.数据结构

数组实现的存储

特点:查询效率高,增删效率低,线程不安全。使用频率很高

2.源码构造方法

在定义Array数组的时候,没有调用add方法数组始终都是空的,只有调用add时才默认分配长度为10的初始容量,如果initialCapacity > 0,就创建一个新的长度是initialCapacity的数组,如果initialCapacity == 0,就使用EMPTY_ELEMENTDATA(即一个空对象数组),其他情况,initialCapacity不合法,抛出异常

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);
    }
}复制代码

源码默认的数组长度:

private static final int DEFAULT_CAPACITY = 10;复制代码

3.扩容机制

当个数已经超过数组长度时,会重新定义一个长度为10+10/2(自定义的话增加自定义长度的一半),然后把原数组,原封不动复制到新的数组里

HashMap&ArrayList浅析_HashMap_04

使用add方法是,需要先进行长度校验判断ensureCapacityInternal,如果长度不够是需要进行扩容的,扩容的源码如下:

//检验扩容private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}//add方法public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!elementData[size++] = e;return true;
}复制代码

指定位置增加时,检验完之后进行数组copy,复制了一个数组,是从index 5的位置开始的,然后把它放在了index 5+1的位置,给我们要新增的元素腾出了位置,然后在index的位置放入元素A就完成了新增的操作了,此方法效率比较低,不建议使用 public void add(int index, E element) { rangeCheckForAdd(index);

ensureCapacityInternal(size + 1);  // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
                 size - index);
elementData[index] = element;
size++;复制代码

}

4.ArrayList(int initialCapacity)会不会初始化数组大小?

不会初始化大小,而且将构造函数与initialCapacity结合使用,使用ensureCapacity()也不起作用,因为它基于elementData数组而不是大小。定义了长度之后,其实是调了ArrayList的空构造方法,空构造默认调用了DEFAULTCAPACITY_EMPTY_ELEMENTDATA,而这个静态常量是指向一个空数组的,所以进行此工作的唯一方法是在使用构造函数后,根据需要使用add()多次。HashMap&ArrayList浅析_ArrayList_05

public ArrayList() {this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};transient Object[] elementData;复制代码

综上所诉,自定义initcapital只是让让ArrayList有了容纳 initialCapacity个元素的潜力,并不能对其中的“位置”操作,看一下源码就知道,在用set方法的时候,会先检查下标是否大于数组长度,否则就抛出IndexOutOfBoundsException,也即数组越界异常

public E set(int index, E element) {
    rangeCheck(index);

    E oldValue = elementData(index);
    elementData[index] = element;return oldValue;
}//检查插入的地方是否大于长度private void rangeCheck(int index) {if (index >= size)throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}复制代码

5.一定不适合做插入操作吗?

看要插入的具体位置,如果是接近尾部,那么用ArrayList来进行数据的插入还是可以考虑的,完全取决于你删除的元素离数组末端有多远,ArrayList拿来作为堆栈来用还是挺合适的,push和pop操作完全不涉及数据移动操作,ArrayList的增加和删除都是找到对应index的位置,直接进行操作,然后再复制一个新的数组copy进去

6.关于线程安全问题

想要考虑线程安全可以使用Vector,Vector的实现很简单,就是把所有的方法统统加上synchronized就完事了。你也可以不使用Vector,用Collections.synchronizedList把一个普通ArrayList包装成一个线程安全版本的数组容器也可以,原理同Vector是一样的,就是给所有的方法套上层synchronized。