面试整理

数组的长度:    java

int[] input; 
inp.length;

字符串长度: node

String str; 
str.length();

链表的长度:    面试

List<String> li; 
li.size();

字符串的第N个字符:  数组

Character tmp = str.charAt(i);

用 toCharArray()方法把字符串转成char数组,缓存

char chars[] = s.toCharArray();

完成后再用new String把char数组转成字符串。安全

s = new String(chars);

 

java数组/Collection的寻址细节?数据结构

 

集合类存放的都是对象的引用,而非对象自己,出于表达上的便利,咱们称集合中的对象就是指集合中对象的引用(reference)。多线程

 

ArrayList

  1. ArrayList 底层是一个动态扩容的数组结构并发

  2. 默认大小 DEFAULT_CAPACITY=10app

  3. 容许存放(不止一个) null 元素

  4. 容许存放重复数据,存储顺序按照元素的添加顺序

  5. ArrayList 并非一个线程安全的集合。若是集合的增删操做须要保证线程的安全性,能够考虑使用 CopyOnWriteArrayList 或者使用 collections.synchronizedList(List l)函数返回一个线程安全的ArrayList类.

 

  1. 底层是数组,初始大小为10
  2. 插入时会判断数组容量是否足够,不够的话会进行扩容
  3. 所谓扩容就是新建一个新的数组,而后将老的数据里面的元素复制到新的数组里面
  4. 移除元素的时候也涉及到数组中元素的移动,删除指定index位置的元素,而后将index+1至数组最后一个元素往前移动一个格
  5. 增删扩容底层都是用System.arraycopy(),native方法,由jvm实现,效率高。

*在定义一个native method时,并不提供实现体(有些像定义一个java interface),由于其实现体是由非java语言在外面实现的。

扩容:

/**
 * 集合的最大长度 Integer.MAX_VALUE - 8 是为了减小出错的概率 Integer 最大值已经很大了
 */
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

/**
 * 增长容量,以确保它至少能容纳最小容量参数指定的元素个数。
 * @param 知足条件的最小容量
 */
private void grow(int minCapacity) {
  //获取当前 elementData 的大小,也就是 List 中当前的容量
   int oldCapacity = elementData.length;
   //oldCapacity >> 1 等价于 oldCapacity / 2  因此新容量为当前容量的 1.5 倍
   int newCapacity = oldCapacity + (oldCapacity >> 1);
   //若是扩大1.5倍后仍旧比 minCapacity 小那么直接等于 minCapacity
   if (newCapacity - minCapacity < 0)
       newCapacity = minCapacity;
    //若是新数组大小比  MAX_ARRAY_SIZE 就须要进一步比较 minCapacity 和 MAX_ARRAY_SIZE 的大小
   if (newCapacity - MAX_ARRAY_SIZE > 0)
       newCapacity = hugeCapacity(minCapacity);
   // minCapacity一般接近 size 大小
   //使用 Arrays.copyOf 构建一个长度为 newCapacity 新数组 并将 elementData 指向新数组
   elementData = Arrays.copyOf(elementData, newCapacity);
}

/**
 * 比较 minCapacity 与 Integer.MAX_VALUE - 8 的大小若是大则放弃-8的设定,设置为 Integer.MAX_VALUE 
 */
private static int hugeCapacity(int minCapacity) {
   if (minCapacity < 0) // overflow
       throw new OutOfMemoryError();
   return (minCapacity > MAX_ARRAY_SIZE) ?
       Integer.MAX_VALUE :
       MAX_ARRAY_SIZE;
}

由此看来 ArrayList 的扩容机制的知识点一共又两个

  1. 每次扩容的大小为原来大小的 1.5倍 (固然这里没有包含 1.5倍后大于 MAX_ARRAY_SIZE 的状况)
  2. 扩容的过程实际上是一个将原来元素拷贝到一个扩容后数组大小的长度新数组中。因此 ArrayList 的扩容实际上是相对来讲比较消耗性能的。

 

  •  ArrayList 中调用 iterator() 将会返回一个内部类对象 Itr 其实现了 Iterator 接口。
  • ListItr对象继承自前边分析的 Itr,也就是说他拥有 Itr 的全部方法,并在此基础上进行扩展,其扩展了访问当前角标前一个元素的方法。以及在遍历过程当中添加元素和修改元素的方法previous 

modCount 变量用于标记当前集合被修改(增删)的次数,若是并发访问了集合那么将会致使这个 modCount 的变化,在遍历过程当中不正确的操做集合将会抛出 ConcurrentModificationException ,这是 Java 「fast-fail 的机制」。
 

modCount 这个变量主要用来记录 ArrayList 被修改的次数,那么为何要记录这个次数呢?是为了防止多线程对同一集合进行修改产生错误,记录了这个变量,在对 ArrayList 进行迭代的过程当中咱们能很快的发现这个变量是否被修改过,若是被修改了 ConcurrentModificationException 将会产生。下面咱们来看下例子,这个例子并非在多线程下的,而是由于咱们在同一线程中对 list 进行了错误操做致使的:

Iterator<SubClass> iterator = lists.iterator();

while (iterator.hasNext()) {
  SubClass next = iterator.next();
  int index = next.test;
  if (index == 3) {
      list2.remove(index);//操做1: 注意是 list2.remove 操做
      //iterator.remove();/操做2 注意是 iterator.remove 操做
  }
}
//操做1: Exception in thread "main" java.util.ConcurrentModificationException
//操做2:  [SubClass{test=1}, SubClass{test=2}]
System.out.println(list2);
复制代码

咱们对操做1,2分别运行程序,能够看到,操做1很快就抛出了 java.util.ConcurrentModificationException 异常,操做2 则顺利运行出正常结果,若是对 modCount 注意了的话,咱们很容易理解,list.remove(index) 操做会修改List 的 modCount,而 iterator.next() 内部每次会检验 expectedModCount != modCount,因此当咱们使用 list.remove 下一次再调用 iterator.next() 就会报错了,而iterator.remove为何是安全的呢?由于其操做内部会在调用 list.remove 后从新将新的 modCount 赋值给 expectedModCount。因此咱们直接调用 list.remove 操做是错误的。

 

Fail-fast

在线程不安全的集合中,若是使用迭代器的过程当中,发现集合被修改,会抛出ConcurrentModificationExceptions错误,这就是fail-fast机制。对集合进行结构性修改时,modCount都会增长,在初始化迭代器时,modCount的值会赋给expectedModCount,在迭代的过程当中,只要modCount改变了,int expectedModCount = modCount等式就不成立了,迭代器检测到这一点,就会抛出错误:currentModificationExceptions。

 

看看为何说ArrayList查询快而增删慢?

        支持random access,下角标直接定位元素;可是修改时候须要对后续元素都进行移动

CopyOnWriteArrayList为何并发安全且性能比Vector好

我知道Vector是增删改查方法都加了synchronized,保证同步,可是每一个方法执行的时候都要去得到锁,性能就会大大降低,而CopyOnWriteArrayList 只是在增删改上加锁,可是读不加锁,在读方面的性能就好于Vector,CopyOnWriteArrayList支持读多写少的并发状况。

 

CopyOnWriteArrayList 几个要点

  • 实现了List接口

  • 内部持有一个ReentrantLock lock = new ReentrantLock();

  • 底层是用volatile transient声明的数组 array

  • 读写分离,写时复制出一个新的数组,完成插入、修改或者移除操做后将新数组赋值给array

 

Vector 介绍

Vector 是一个至关古老的 Java 容器类,始于 JDK 1.0,并在 JDK 1.2 时代对其进行修改,使其实现了 List 和 Collection 。从做用上来看,Vector 和 ArrayList 很类似,都是内部维护了一个能够动态变换长度的数组。可是他们的扩容机制却不相同。对于 Vector 的源码大部分都和 ArrayList 差很少,这里简单看下 Vector 的构造函数,以及 Vector 的扩容机制。

Vector 的构造函数能够指定内部数组的初始容量和扩容系数,若是不指定初始容量默认初始容量为 10,可是不一样于 ArrayList 的是它在建立的时候就分配了容量为10的内存空间,而 ArrayList 则是在第一次调用 add 的时候才生成一个容量为 10 数组。

Vector 的须要扩容的时候,首先会判断 capacityIncrement 即在构造的 Vector 的时候时候指定了扩容系数,若是指定了则按照指定的系数来扩大容量,扩大后新的容量为 oldCapacity + capacityIncrement,若是没有指定capacityIncrement的大小,则默认扩大原来容量的一倍,这点不一样于 ArrayList 的 0.5 倍长度。

对于 Vector 与 ArrayList 的区别最重要的一点是 Vector全部的访问内部数组的方法都带有synchronized ,这意味着 Vector 是线程安全的,而ArrayList 并无这样的特性。

 

Vector 与 ArrayList 的比较

  1. Vector 与 ArrayList 底层都是数组数据结构,都维护着一个动态长度的数组。

  2. Vector 对扩容机制在没有经过构造指定扩大系数的时候,默认增加现有数组长度的一倍。而 ArrayList 则是扩大现有数组长度的一半长度。

  3. Vector 是线程安全的, 而 ArrayList 不是线程安全的,在不涉及多线程操做的时候 ArrayList 要比 Vector效率高

  4. 对于 Vector 而言,除了 for 循环,高级 for 循环,迭代器的迭代方法外,还能够调用 elements() 返回一个 Enumeration 来遍历内部元素。

 

LinkedList 和 ArrayList 的区别:

  1. ArrayList 是底层采用数组结构,存储空间是连续的。查询快,增删须要进行数组元素拷贝过程,当删除元素位置比较靠前的时候性能较低。

  2. LinkedList 底层是采用双向链表数据结构,每一个节点都包含本身的前一个节点和后一个节点的信息,存储空间能够不是连续的。增删块,查询慢。

  3. ArrayList 和 LinkedList 都是线程不安全的。而 Vector 是线程安全的

  4. 尽可能不要使用 for 循环去遍历一个LinkedList集合,而是用迭代器或者高级 for。

 

Stack

栈(stack)又名堆栈,它是一种运算受限的线性表。其限制是仅容许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把另外一端称为栈底。Stack 继承自 Vector,也就是 Stack 拥有 Vector 全部的增删改查方法。

通常来讲对于栈有一下几种操做:

  1. push 入栈
  2. pop 出栈
  3. peek 查询栈顶
  4. empty 栈是否为空

我以为我是面试官,若是回答者只写出了出栈入栈的操做方法应该算是不及格的,面试官关注的应该是在写 push 操做的时候有没有考虑过 StackOverFlow 也就是栈满的状况。

public class SimpleStack<E> {
    //默认容量
    private static final int DEFAULT_CAPACITY = 10;
    //栈中存放元素的数组
    private Object[] elements;
    //栈中元素的个数
    private int size = 0;
    //栈顶指针
    private int top;


    public SimpleStack() {
        this(DEFAULT_CAPACITY);
    }

    public SimpleStack(int initialCapacity) {
        elements = new Object[initialCapacity];
        top = -1;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    public int size() {
        return size;
    }

    @SuppressWarnings("unchecked")
    public E pop() throws Exception {
        if (isEmpty()) {
            throw new EmptyStackException();
        }

        E element = (E) elements[top];
        elements[top--] = null;
        size--;
        return element;
    }

    @SuppressWarnings("unchecked")
    public E peek() throws Exception {
        if (isEmpty()) {
            throw new Exception("当前栈为空");
        }
        return (E) elements[top];
    }

    public void push(E element) throws Exception {
        //添加以前确保容量是否知足条件
        ensureCapacity(size + 1);
        elements[size++] = element;
        top++;
    }

    private void ensureCapacity(int minSize) {
        if (minSize - elements.length > 0) {
            grow();
        }
    }

    private void grow() {
        int oldLength = elements.length;
        // 更新容量操做 扩充为原来的1.5倍 这里也能够选择其余方案
        int newLength = oldLength + (oldLength >> 1);
        elements = Arrays.copyOf(elements, newLength);
    }
}

同步 vs 非同步

对于 Vector 和 Stack 从源码上他们在对应的增删改查方法上都使用 synchronized关键字修饰了方法,这也就表明这个方法是同步方法,线程安全的。而 ArrayList 和 LinkedList 并非线程安全的。不过咱们在介绍 ArrayList和 LinkedList 的时候说起到了咱们可使用Collections 的静态方法,将一个 List 转化为线程同步的 List

List<Integer> synchronizedArrayList = Collections.synchronizedList(arrayList);
List<Integer> synchronizedLinkedList = Collections.synchronizedList(linkedList);
复制代码

那么这里又有一道面试题是这样问的:

请简述一下 Vector 和 SynchronizedList 区别,

SynchronizedList与 Vector的三点差别:

  1. SynchronizedList 做为一个包装类,有很好的扩展和兼容功能。能够将全部的 List 的子类转成线程安全的类。
  2. 使用 SynchronizedList 的获取迭代器,进行遍历时要手动进行同步处理,而 Vector 不须要。
  3. SynchronizedList 能够经过参数指定锁定的对象,而 Vector 只能是对象自己。

 

 

equals 与 == 操做符的区别总结以下:

  1. 若 == 两侧都是基本数据类型,则判断的是左右两边操做数据的值是否相等

  2. 若 == 两侧都是引用数据类型,则判断的是左右两边操做数的内存地址是否相同。若此时返回 true , 则该操做符做用的必定是同一个对象。

  3. Object 基类的 equals 默认比较两个对象的内存地址,在构建的对象没有重写 equals 方法的时候,与 == 操做符比较的结果相同。

  4. equals 用于比较引用数据类型是否相等。在知足equals 判断规则的前体系,两个对象只要规定的属性相同咱们就认为两个对象是相同的。

关于equals和hashCode

  1. hashCode 返回值不必定对象的存储地址,好比发生哈希碰撞的时候。
  2. 调用 equals 返回 true 的两个对象必须具备相等的哈希码。
  3. 若是两个对象的 hashCode 返回值相同,调用它们 equals 方法不一返回 true 。

 

Hashtable

Hashtable是java一开始发布时就提供的键值映射的数据结构,而HashMap产生于JDK1.2。虽然Hashtable比HashMap出现的早一些,可是如今Hashtable基本上已经被弃用了。而HashMap已经成为应用最为普遍的一种数据类型了。形成这样的缘由一方面是由于Hashtable是线程安全的,效率比较低。另外一方面多是由于Hashtable没有遵循驼峰命名法吧。。。

继承的父类不一样 
HashMap和Hashtable不只做者不一样,并且连父类也是不同的。HashMap是继承自AbstractMap类,而HashTable是继承自Dictionary类。不过它们都实现了同时实现了map、Cloneable(可复制)、Serializable(可序列化)这三个接口

Dictionary类是一个已经被废弃的类(见其源码中的注释)。父类都被废弃,天然而然也没人用它的子类Hashtable了。

对外提供的接口不一样 
Hashtable比HashMap多提供了elments() 和contains() 两个方法。

elments() 方法继承自Hashtable的父类Dictionnary。elements() 方法用于返回此Hashtable中的value的枚举。

contains()方法判断该Hashtable是否包含传入的value。它的做用与containsValue()一致。事实上,contansValue() 就只是调用了一下contains() 方法。
对Null key 和Null value的支持不一样 
Hashtable既不支持Null key也不支持Null value。Hashtable的put()方法的注释中有说明。 

线程安全性不一样 
Hashtable是线程安全的,它的每一个方法中都加入了Synchronize方法。在多线程并发的环境下,能够直接使用Hashtable,不须要本身为它的方法实现同步

HashMap不是线程安全的,在多线程并发的环境下,可能会产生死锁等问题。具体的缘由在下一篇文章中会详细进行分析。使用HashMap时就必需要本身增长同步处理,

虽然HashMap不是线程安全的,可是它的效率会比Hashtable要好不少。这样设计是合理的。在咱们的平常使用当中,大部分时间是单线程操做的。HashMap把这部分操做解放出来了。当须要多线程操做的时候可使用线程安全的ConcurrentHashMap。ConcurrentHashMap虽然也是线程安全的,可是它的效率比Hashtable要高好多倍。由于ConcurrentHashMap使用了分段锁,并不对整个数据进行锁定。

遍历方式的内部实现上不一样 
Hashtable、HashMap都使用了 Iterator。而因为历史缘由,Hashtable还使用了Enumeration的方式 。

HashMap的Iterator是fail-fast迭代器。当有其它线程改变了HashMap的结构(增长,删除,修改元素),将会抛出ConcurrentModificationException。不过,经过Iterator的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并非一个必定发生的行为,要看JVM。

JDK8以前的版本中,Hashtable是没有fast-fail机制的。在JDK8及之后的版本中 ,HashTable也是使用fast-fail的, 源码以下: 

计算hash值的方法不一样 
为了获得元素的位置,首先须要根据元素的 KEY计算出一个hash值,而后再用这个hash值来计算获得最终的位置。

Hashtable直接使用对象的hashCode。hashCode是JDK根据对象的地址或者字符串或者数字算出来的int类型的数值。而后再使用除留余数发来得到最终的位置。 
Hashtable在计算元素的位置时须要进行一次除法运算,而除法运算是比较耗时的。 
HashMap为了提升计算效率,将哈希表的大小固定为了2的幂,这样在取模预算时,不须要作除法,只须要作位运算。位运算比除法的效率要高不少。

HashMap的效率虽然提升了,可是hash冲突却也增长了。由于它得出的hash值的低位相同的几率比较高,而计算位运算

为了解决这个问题,HashMap从新根据hashcode计算hash值后,又对hash值作了一些运算来打散数据。使得取得的位置更加分散,从而减小了hash冲突。固然了,为了高效,HashMap只作了一些简单的位处理。从而不至于把使用2 的幂次方带来的效率提高给抵消掉。
 

 

Map

  • 不能包括两个相同的键,一个键最多能绑定一个值。
  • null能够做为键,这样的键只有一个
  • 能够有一个或多个键所对应的值为null。当get()方法返回null值时,便可以表示Map中没有该键,也能够表示该键所对应的值为null。所以,在Map中不能由get()方法来判断Map中是否存在某个键,而应该用containsKey()方法来判断。

 

概述

为了方便下边的叙述这里须要先对几个常见的关于 HashMap 的知识点进行下概述:

  1. HashMap 存储数据是根据键值对存储数据的,而且存储多个数据时,数据的键不能相同,若是相同该键以前对应的值将被覆盖。注意若是想要保证 HashMap 可以正确的存储数据,请确保做为键的类,已经正确覆写了 equals() 方法。

  2. HashMap 存储数据的位置与添加数据的键的 hashCode() 返回值有关。因此在将元素使用 HashMap 存储的时候请确保你已经按照要求重写了 hashCode()方法。这里说有关系表明最终的存储位置不必定就是 hashCode 的返回值。

  3. HashMap 最多只容许一条存储数据的键为 null,可容许多条数据的值为 null。

  4. HashMap 存储数据的顺序是不肯定的,而且可能会由于扩容致使元素存储位置改变。所以遍历顺序是不肯定的。

  5. HashMap 是线程不安全的,若是须要再多线程的状况下使用能够用 Collections.synchronizedMap(Map map) 方法使 HashMap 具备线程安全的能力,或者使用 ConcurrentHashMap

JDK1.8中的 HashMap 存储结构。

对于 JDK1.8 以后的HashMap底层在解决哈希冲突的时候,就不仅仅是使用数组加上单链表的组合了,由于当处理若是 hash 值冲突较多的状况下,链表的长度就会愈来愈长,此时经过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),所以在 JDK1.8 以后,在链表新增节点致使链表长度超过 TREEIFY_THRESHOLD = 8 的时候,就会在添加元素的同时将原来的单链表转化为红黑树。

对数据结构很在行的读者应该,知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn) 级别,因此利用红黑树的特色就能够更高效的对 HashMap 中的元素进行操做。

 

 

关于 HashMap 源码中分析的文章通常都会说起几个重要的概念:

重要参数

  1. 哈希桶(buckets):在 HashMap 的注释里使用哈希桶来形象的表示数组中每一个地址位置。注意这里并非数组自己,数组是装哈希桶的,他能够被称为哈希表

  2. 初始容量(initial capacity) : 这个很容易理解,就是哈希表中哈希桶初始的数量。若是咱们没有经过构造方法修改这个容量值默认为DEFAULT_INITIAL_CAPACITY = 1<<4 即16。值得注意的是为了保证 HashMap 添加和查找的高效性,HashMap 的容量老是 2^n 的形式。

  3. 加载因子(load factor):加载因子是哈希表(散列表)在其容量自动增长以前被容许得到的最大数量的度量。当哈希表中的条目数量超过负载因子和当前容量的乘积时,散列表就会被从新映射(即重建内部数据结构),从新建立的散列表容量大约是以前散列表哈系统桶数量的两倍。默认加载因子(0.75)在时间和空间成本之间提供了良好的折衷。加载因子过大会致使很容易链表过长,加载因子很小又容易致使频繁的扩容。因此不要轻易试着去改变这个默认值

  4. 扩容阈值(threshold):其实在说加载因子的时候已经提到了扩容阈值了,扩容阈值 = 哈希表容量 * 加载因子。哈希表的键值对总数 = 全部哈希桶中全部链表节点数的加和,扩容阈值比较的是是键值对的个数而不是哈希表的数组中有多少个位置被占了。

  5. 树化阀值(TREEIFY_THRESHOLD) :这个参数概念是在 JDK1.8后加入的,它的含义表明一个哈希桶中的节点个数大于该值(默认为8)的时候将会被转为红黑树行存储结构。

  6. 非树化阀值(UNTREEIFY_THRESHOLD): 与树化阈值相对应,表示当一个已经转化为数形存储结构的哈希桶中节点数量小于该值(默认为 6)的时候将再次改成单链表的格式存储。致使这种操做的缘由可能有删除节点或者扩容。

  7. 最小树化容量(MIN_TREEIFY_CAPACITY): 通过上边的介绍咱们只知道,当链表的节点数超过8的时候就会转化为树化存储,其实对于转化还有一个要求就是哈希表的数量超过最小树化容量的要求(默认要求是 64),且为了不进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD);在达到该有求以前优先选择扩容。扩容由于由于容量的变化可能会使单链表的长度改变。

JDK1.8 中 hash 函数的实现

JDK1.8中再次优化了这个哈希函数,把 key 的 hashCode 方法返回值右移16位,即丢弃低16位,高16位全为0 ,而后在于 hashCode 返回值作异或运算,即高 16 位与低 16 位进行异或运算,这么作能够在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到 hash 的计算中,同时不会有太大的开销,扰动处理次数也从 4次位运算 + 5次异或运算 下降到 1次位运算 + 1次异或运算

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

进过上述的扰动函数只是获得了合适的 hash 值,可是尚未肯定在 Node[] 数组中的角标,在 JDK1.7中存在一个函数,JDK1.8中虽然没有可是只是把这步运算放到了 put 函数中。咱们就看下这个函数实现:

static int indexFor(int h, int length) {
     return h & (length-1);  // 取模运算
}

为了让 hash 值可以对应到现有数组中的位置,咱们上篇文章讲到一个方法为 取模运算,即 hash % length,获得结果做为角标位置。可是 HashMap 就厉害了,连这一步取模运算的都优化了。咱们须要知道一个计算机对于2进制的运算是要快于10进制的,取模算是10进制的运算了,而位与运算就要更高效一些了。

咱们知道 HashMap 底层数组的长度老是 2^n ,转为二进制老是 1000 即1后边多个0的状况。此时一个数与 2^n 取模,等价于 一个数与 2^n - 1作位与运算。而 JDK 中就使用h & (length-1) 运算替代了对 length取模。

 

为何HashMap中链表长度超过8会转换成红黑树

HashMap在jdk1.8以后引入了红黑树的概念,表示若桶中链表元素超过8时,会自动转化成红黑树;若桶中元素小于等于6时,树结构还原成链表形式。

缘由:

  红黑树的平均查找长度是log(n),长度为8,查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度若是是小于等于6,6/2=3,虽然速度也很快的,可是转化为树结构和生成树的时间并不会过短。

还有选择6和8的缘由是:

  中间有个差值7能够防止链表和树之间频繁的转换。假设一下,若是设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,若是一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

 

添加元素过程:

  1. 若是 Node[] table 表为 null ,则表示是第一次添加元素,讲构造函数也提到了,及时构造函数指定了指望初始容量,在第一次添加元素的时候也为空。这时候须要进行首次扩容过程。
  2. 计算对应的键值对在 table 表中的索引位置,经过i = (n - 1) & hash 得到。
  3. 判断索引位置是否有元素若是没有元素则直接插入到数组中。若是有元素且key 相同,则覆盖 value 值,这里判断是用的 equals 这就表示要正确的存储元素,就必须按照业务要求覆写 key 的 equals 方法,上篇文章咱们也说起到了该方法重要性。
  4. 若是索引位置的 key 不相同,则须要遍历单链表,若是遍历过若是有与 key 相同的节点,则保存索引,替换 Value;若是没有相同节点,则在但单链表尾部插入新节点。这里操做与1.7不一样,1.7新来的节点老是在数组索引位置,而以前的元素做为下个节点拼接到新节点尾部。
  5. 若是插入节点后链表的长度大于树化阈值,则须要将单链表转为红黑树。
  6. 成功插入节点后,判断键值对个数是否大于扩容阈值,若是大于了则须要再次扩容。至此整个插入元素过程结束。

相信你们看到扩容的整个函数后对扩容机制应该有所了解了,总体分为两部分:1. 寻找扩容后数组的大小以及新的扩容阈值,2. 将原有哈希表拷贝到新的哈希表中

第一部分没的说,可是第二部分我看的有点懵逼了,可是踩在巨人的肩膀上老是比较容易的,美团的大佬们早就写过一些有关 HashMap 的源码分析文章,给了我很大的帮助。在文章的最后我会放出参考连接。下面说下个人理解:

JDK 1.8 不像 JDK1.7中会从新计算每一个节点在新哈希表中的位置,而是经过 (e.hash & oldCap) == 0是否等于0 就能够得出原来链表中的节点在新哈希表的位置。为何能够这样高效的得出新位置呢?

由于扩容是容量翻倍,因此原链表上的每一个节点,可能存放新哈希表中在原来的下标位置, 或者扩容后的原位置偏移量为 oldCap 的位置上,下边举个例子

图(a)表示扩容前的key1和key2两种key肯定索引位置的示例,图(b)表示扩容后key1和key2两种key肯定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

 

 

元素在从新计算hash以后,由于n变为2倍,那么n-1的mask范围在高位多1bit(红色),所以新的index就会发生这样的变化:

 

 

因此在 JDK1.8 中扩容后,只须要看看原来的hash值新增的那个bit是1仍是0就行了,是0的话索引没变,是1的话索引变成“原索引+oldCap

另外还须要注意的一点是 HashMap 在 1.7的时候扩容后,链表的节点顺序会倒置,1.8则不会出现这种状况。

Map的迭代器都是经过,遍历 table 表来获取下个节点,来遍历的,遍历过程能够理解为一种深度优先遍历,即优先遍历链表节点(或者红黑树),而后在遍历其余数组位置。

 

HashTable 的区别

面试的时候面试官老是问完 HashMap 后会问 HashTable 其实 HashTable 也算是比较古老的类了。翻看 HashTable 的源码能够发现有以下区别:

  1. HashMap 是线程不安全的,HashTable是线程安全的。

  2. HashMap 容许 key 和 Vale 是 null,可是只容许一个 key 为 null,且这个元素存放在哈希表 0 角标位置。 HashTable 不容许key、value 是 null

  3. HashMap 内部使用hash(Object key)扰动函数对 key 的 hashCode 进行扰动后做为 hash 值。HashTable 是直接使用 key 的 hashCode() 返回值做为 hash 值。

  4. HashMap默认容量为 2^4 且容量必定是 2^n ; HashTable 默认容量是11,不必定是 2^n

  5. HashTable 取哈希桶下标是直接用模运算,扩容时新容量是原来的2倍+1。HashMap 在扩容的时候是原来的两倍,且哈希桶的下标使用 &运算代替了取模。

 

HashMap的常见面试题

1.何时会使用HashMap?他有什么特色?

  • 基于Map接口实现的Key-Value容器,容许null值,同时非有序,非同步。

2.你知道HashMap的工做原理吗?

  • 参见概括
  • 在Java 8中,若是一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提升速度

3.你知道get和put的原理吗?equals()和hashCode()的都有什么做用?

  • 经过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而得到buckets的位置。若是产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点

4.你知道hash的实现吗?为何要这样实现?

  • 在Java 1.8的实现中,是经过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么作能够在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。
  • 使用hash还有一个好处就是 尽量确保每一个链表中的长度一致

5. 若是HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

  • 若是超过了负载因子(默认0.75),则会从新resize一个原来长度两倍的HashMap,而且从新调用hash方法;同时此时极可能出现一系列问题:参见问题6

6. 你了解从新调整HashMap大小存在什么问题吗?

  • 当数据过多时,极可能出现性能瓶颈(包括rehash时间) 
    使用HashMap时必定保证数量有限
  • 多线程状况下可能产生条件竞竞争从而形成死循环(具体表如今CPU接近100%)。多线程同时试着调整大小,可能致使存储在链表中的元素的次序颠倒,由于移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了不尾部遍历。具体死循环代码参见transfer(newTable) (线程1在遍历,nodeA.next -> nodeB ,此时线程2移动了位置,且有倒置,致使nodeB.next ->nodeA ?)
  • 多线程环境下推荐使用ConcurrentHashMap

transfer方法

/**
  * Transfers all entries from current table to newTable.
  */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    //注意:若是在新表的数组索引位置相同,则链表元素会倒置,也就是先插入最近的
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                //从新计算hash null的位置仍是tab[0]不变
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            //从新计算下标索引(主要是由于容量变化成2倍)
            int i = indexFor(e.hash, newCapacity);
            //注意:多线程环境可能因为执行次序非有序形成next引用变动赋值出错致使环形连接出现,从而形成死循环
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

7. 为何String, Interger这样的wrapper类适合做为键?

  • class具备final属性,同时重写equals()和hashCode()
  • hashCode变更会致使读取失效
  • final同时保证线程安全 
    对象推荐重写equals和hashCode方法,主要用于Map存取时的对比,同时有利于减小碰撞

8.咱们可使用自定义的对象做为键吗?

  • 这是前一个问题的延伸。固然你可能使用任何对象做为键,只要它遵照了equals()和hashCode()方法的定义规则,而且当对象插入到Map中以后将不会再改变了。若是这个自定义对象时不可变的,那么它已经知足了做为键的条件,由于当它建立以后就已经不能改变了
  • 典型实例就是ThreadLocal,读者可参见笔者的 并发番@ThreadLocal一文通(1.7版)

9.如何对HashMap进行排序?

  • 转换:Map -> Set -> LinkedList(存key)
  • 排序:LinkedList自行sort
  • 存储:存入有序LinkedHashMap

10.HashMap的remove陷阱?

  • 经过Iterator方式可正确遍历完成remove操做
  • 直接调用list的remove方法就会抛异常

11.为何只容许经过iterator进行remove操做?

  • HashMap和keySet的remove方法均可以经过传递key参数删除任意的元素
  • 而iterator只能删除当前元素(current),一旦删除的元素是iterator对象中next所正在引用的,若是没有经过modCount、 expectedModCount的比较实现快速失败抛出异常,下次循环该元素将成为current指向,此时iterator就遍历了一个已移除的过时数据
  • 之因此推荐迭代器remove的根本缘由在于只有迭代器的remove方法中实现了变动时于modCount的同步工做 
    expectedModCount = modCount;

12.若是是遍历过程当中增长或修改数据呢?

  • 增长或修改数据只能经过Map的put方法实现,在遍历过程当中修改数据能够,但若是增长新key就会在下次循环时抛异常,由于在添加新key时modCount也会自增(迭代器只实现了remove方法也是缘由之一)

 

LinkedHashMap

LinkedHashMap 继承自 HashMap,在 HashMap 基础上,经过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。除此以外,LinkedHashMap 对访问顺序也提供了相关支持。在一些场景下,该特性颇有用,好比缓存。在实现上,LinkedHashMap 不少方法直接继承自 HashMap,仅为维护双向链表覆写了部分方法。

 

LinkedHashMap 在上面结构的基础上,增长了一条双向链表,使得上面的结构能够保持键值对的插入顺序。同时经过对链表进行相应的操做,实现了访问顺序相关逻辑。其结构可能以下图:

上图中,淡蓝色的箭头表示前驱引用,红色箭头表示后继引用。每当有新键值对节点插入,新节点最终会接在 tail 引用指向的节点后面。而 tail 引用则会移动到新的节点上,这样一个双向链表就创建起来了。

 

LinkedHashMap 插入操做的调用过程。以下:

我把 newNode 方法红色背景标注了出来,这一步比较关键。LinkedHashMap 覆写了该方法。在这个方法中,LinkedHashMap 建立了 Entry,并经过 linkNodeLast 方法将 Entry 接在双向链表的尾部,实现了双向链表的创建。

 

删除的过程并不复杂,上面这么多代码其实就作了三件事:

  1. 根据 hash 定位到桶位置
  2. 遍历链表或调用红黑树相关的删除方法
  3. 从 LinkedHashMap 维护的双链表中移除要删除的节点

举个例子说明一下,假如咱们要删除下图键值为 3 的节点。

根据 hash 定位到该节点属于3号桶,而后在对3号桶保存的单链表进行遍历。找到要删除的节点后,先从单链表中移除该节点。以下:

而后再双向链表中移除该节点:

相关文章
相关标签/搜索