【JDK1.8】JDK1.8集合源码阅读——IdentityHashMap

一、前言

今天我们来看一下本次集合源码阅读里的最后一个Map——IdentityHashMap。这个Map之所以放在最后是因为它用到的情况最少,也相较于其他的map来说比较特殊。就笔者来说,到目前为止还没有用到过它 ┐(゚~゚)┌。它的罕见与它的用途有关,当时的Map设计者是这么说的:

This class is designed for use only in the rare cases wherein reference-equality semantics are required. 这个类仅用于需要引用相等的罕见情况。 A typical use of this class is topology-preserving object graph transformations, such as serialization or deep-copying. 该类一个典型的用途是拓扑保存对象图的转换,比如序列化或者深度拷贝 Another typical use of this class is to maintain proxy objects. For example, a debugging facility might wish to maintain a proxy object for each object in the program being debugged. 这个类的另一个典型用途是维护代理对象。 例如,调试工具可能希望为正在调试的程序中的每个对象维护一个代理对象

上面提到,这个Map采用的是引用相等的情况,即内存地址相同。我们来看一下它用法:

public static void main(String[] args) {
  IdentityHashMap<String, Integer> map = new IdentityHashMap<>();
  map.put("Hello " + "World", 1);
  map.put("Hello World", 2);
  map.put(new String("Hello World"), 3);
  map.put(null, 4);
  map.put(null, 5);
  System.out.println(map);
}

上面的输出是:

{null=5, Hello World=2, Hello World=3}

因为采用的是引用相等来比较,所以1和2是相等的(常量池),对于key相同,用新的value替换,而3是用new在堆中申请的,所以与前面的不同。至于后面的null,是为了说明IdentityHashMap可以存放null。

二、IdentityHashMap的数据结构

IdentityHashMap的结构与HashMap等不同,其实就是一个Object[]数组,数组的前一位存放Key,后面一个位置就存放它对应的Value。

三、IdentityHashMap源码阅读

3.1 类的继承关系

可以看到,继承关系和HashMap一样,没有什么其他特别的地方。

3.2 IdentityHashMap的成员变量

// 初始化容量,存放最大的数量为21,填充因子是2/3(后面代码可以看到)
private static final int DEFAULT_CAPACITY = 32;
//最小容量,实际最大存放容量为2,记住2/3
private static final int MINIMUM_CAPACITY = 4;
//最大容量2^29,然而实际上存放最大的量为MAXIMUM_CAPACITY-1,后面解释
private static final int MAXIMUM_CAPACITY = 1 << 29;
// 存放key-value的表
transient Object[] table;
//map里存放的key-value数量
int size;
//用于统计map修改次数的计数器,用于fail-fast抛出ConcurrentModificationException
transient int modCount;
//对于存放null的情况,
static final Object NULL_KEY = new Object();
//key、value的视图
private transient Set<Map.Entry<K,V>> entrySet;

值得注意的是:虽然成员变量里没有声明填充因子,但是从IdentityHashMap的代码里可以看到它的填充因子是2/3,并且所有的容量都要保证是2的幂次。

3.3 IdentityHashMap的构造函数

3.3.1 IdentityHashMap()

public IdentityHashMap() {
  init(DEFAULT_CAPACITY);
}

//申请initCapacity两倍的空间
private void init(int initCapacity) {
  table = new Object[2 * initCapacity];
}

说明:申请两倍的空间很好理解,因为key-value是放一个数组里的,所以是空间是存储键值对数量的两倍

3.3.2 IdentityHashMap(int expectedMaxSize)

public IdentityHashMap(int expectedMaxSize) {
  // 最大数量小于0,抛出异常
  if (expectedMaxSize < 0)
    throw new IllegalArgumentException("expectedMaxSize is negative: "
                                       + expectedMaxSize);
  init(capacity(expectedMaxSize));
}

// 根据给定的expectedMaxSize计算合适的容量,必为2的幂次
private static int capacity(int expectedMaxSize) {
  return
    (expectedMaxSize > MAXIMUM_CAPACITY / 3) ? MAXIMUM_CAPACITY :
  (expectedMaxSize <= 2 * MINIMUM_CAPACITY / 3) ? MINIMUM_CAPACITY :
  Integer.highestOneBit(expectedMaxSize + (expectedMaxSize << 1));
}

//获取i最高位的int值
public static int highestOneBit(int i) {
  i |= (i >>  1);
  i |= (i >>  2);
  i |= (i >>  4);
  i |= (i >>  8);
  i |= (i >> 16);
  return i - (i >>> 1);
}

这里的重点在于capacityhighestOneBit的计算,对于highestOneBit的逻辑其实与笔者之前在HashMap源码解析tableSizeFor方法类似,这里可以再说一遍:

|是或运算符,比如说0100 | 0011 = 0111>>则是右移运算符,左边的用原有标志位补充(负数为1,正数为0),比如说-3:1111 1101 >> 2 = 1111 1111(int 其实有32位,这里偷懒,大家懂就行),而>>>是无符号右移,左边用0补全

75的补码是0100 1011,0100 1011 >> 1 = 0010 0101, 然后0100 1011 | 0010 0101 = 0110 1111,然后再把 0110 1111无符号右移两位,0110 1111 >> 2 = 0001 1011,再进行或运算:0001 1011 | 0110 1111 = 0111 1111。至此,我们可以发现操作就是把最高位及其以下的位数都变为1 ,后面进行到16是因为int为32位,所以到16的时候,所有的位数都遍历完了,最后的i - (i >>> 1)返回i对应的二进制最高位数的整数,比如说12返回8,8返回8。

那么capacity(int expectedMaxSize)方法的逻辑是:

  1. expectedMaxSize > MAXIMUM_CAPACITY / 3,则直接用最大的容量
  2. expectedMaxSize < = 2 * MINIMUM_CAPACITY / 3,则直接用最小容量
  3. 2 * MINIMUM_CAPACITY / 3 ~ MAXIMUM_CAPACITY / 3之间,则大于且最接近3/2的expectedMaxSize的2的幂次的容量

3.3.3 IdentityHashMap(Map<? extends K, ? extends V> m)

public IdentityHashMap(Map<? extends K, ? extends V> m) {
  // 对原有的size进行一点扩容
  this((int) ((1 + m.size()) * 1.1));
  putAll(m);
}

// 循环map将值放进去
public void putAll(Map<? extends K, ? extends V> m) {
  int n = m.size();
  if (n == 0)
    return;
  if (n > size)
    resize(capacity(n));
  for (Entry<? extends K, ? extends V> e : m.entrySet())
    put(e.getKey(), e.getValue());
}

3.4 IdentityHashMap的重要方法

3.4.1 maskNull(Object key) & unmaskNull(Object key)

// 如果key为null,则返回NULL_KEY回去,否则返回key
private static Object maskNull(Object key) {
  return (key == null ? NULL_KEY : key);
}

// 对上面的逆转化,用于返回key的时候
static final Object unmaskNull(Object key) {
  return (key == NULL_KEY ? null : key);
}

说明:NULL_KEY的存在是为了解决key为null的情况,因为如果为null,key还是要存一个值,否则会破坏数组的存储规则。

3.4.2 get(Object key)

public V get(Object key) {
  Object k = maskNull(key);
  Object[] tab = table;
  int len = tab.length;
  //计算key对应的下标位置。
  int i = hash(k, len);
  while (true) {
    Object item = tab[i];
    // 引用相等,返回其value
    if (item == k)
      return (V) tab[i + 1];
    // 为null,说明未插入,则value肯定也为null(插入null,实际是NULL_KEY)
    if (item == null)
      return null;
    //不相等且不为null,寻找下一个index,循环查找
    i = nextKeyIndex(i, len);
  }
}

值得注意的是: 在没有key存在的情况下,退出while循环的关键是必须有一个null,所以前面提到MAXIMUM_CAPACITY实际存放的容量是MAXIMUM_CAPACITY - 1

get的逻辑还是很简单,里面有两个方法要更进一步的研究,首先需要思考的是,如何保证获得的总是key的位置,而不是value的下标:

private static int hash(Object x, int length) {
  //返回原来自带的哈希值,不论是否重写了hashCode()
  int h = System.identityHashCode(x);
  // Multiply by -127, and left-shift to use least bit as part of hash
  return ((h << 1) - (h << 8)) & (length - 1);
}

对于((h << 1) - (h << 8)) = h * -254,不知道为什么源码注释上写着乘以-127,有清楚的园友可以跟我说一下。总之, -254 * h得到的肯定为偶数。最后的& (length - 1)就是在数组 0 - (length-1) 之中取模,获取它的下标位置,由于length肯定为2的幂次,所以length -1的二进制补码肯定是011111111这种,在与偶数进行&运算后得到的结果肯定也是偶数,从而保证了得到的都是key的位置,但是为什么要乘以-254笔者还不是很清楚。

// 每次加2,寻找下一个位置,如果 >= len,则从头开始找
private static int nextKeyIndex(int i, int len) {
  return (i + 2 < len ? i + 2 : 0);
}

可以把数组想象成一个闭环,头尾相接的逻辑结构。

3.4.3 put(K key, V value)

public V put(K key, V value) {
  // null的key转成Object(NULL_KEY)
  final Object k = maskNull(key);

  retryAfterResize: for (;;) {
    final Object[] tab = table;
    final int len = tab.length;
    // 同样先计算数组下标位置
    int i = hash(k, len);
    // 先在数组里找是否有相同的key,找到则替换
    for (Object item; (item = tab[i]) != null;
         i = nextKeyIndex(i, len)) {
      if (item == k) {
        @SuppressWarnings("unchecked")
        V oldValue = (V) tab[i + 1];
        tab[i + 1] = value;
        return oldValue;
      }
    }
    // 没有找到则执行插入,先存放的键值对数量+1
    final int s = size + 1;
    
    //如果3*s > len,则进行扩容,扩容后的大小为 2 * len
    if (s + (s << 1) > len && resize(len))
      continue retryAfterResize;

    modCount++;
    tab[i] = k;
    tab[i + 1] = value;
    size = s;
    return null;
  }
}

还记得之前说的填充因子是2/3吗?因为len在调用init()的时候,是2倍的量,实际能存储的键值对我们可以用maxSize表示 2 * maxSize = len,所以可以得出填充因子是2/3。

我们再来看一下resize()

private boolean resize(int newCapacity) {
  // 新的length是原来的两倍
  int newLength = newCapacity * 2;
  Object[] oldTable = table;
  int oldLength = oldTable.length;
  // 如果已经到了最大的容量,则扩容失败
  if (oldLength == 2 * MAXIMUM_CAPACITY) {
    // 如果已经到了最大的容量,(留一个给null,不然不能退出循环)
    if (size == MAXIMUM_CAPACITY - 1)
      throw new IllegalStateException("Capacity exhausted.");
    return false;
  }
  if (oldLength >= newLength)
    return false;

  //下面就是循环旧的table,然后插入值到新的里面
  Object[] newTable = new Object[newLength];
  for (int j = 0; j < oldLength; j += 2) {
    Object key = oldTable[j];
    if (key != null) {
      Object value = oldTable[j+1];
      oldTable[j] = null;
      oldTable[j+1] = null;
      int i = hash(key, newLength);
      while (newTable[i] != null)
        i = nextKeyIndex(i, newLength);
      newTable[i] = key;
      newTable[i + 1] = value;
    }
  }
  table = newTable;
  return true;
}

可以看到和其他的Map一样,扩容的效率很差,所以可以的话,我们在每次初始化的时候最好都指定合适的容量,来提高性能。

3.4.4 remove(Object key)

最后再来看看remove方法:

public V remove(Object key) {
  Object k = maskNull(key);
  Object[] tab = table;
  int len = tab.length;
  // 计算下标
  int i = hash(k, len);

  while (true) {
    Object item = tab[i];
    // 如果下标所在的值与key相同,则进入删除
    if (item == k) {
      modCount++;
      // 所存的键值对数量-1
      size--;
      @SuppressWarnings("unchecked")
      V oldValue = (V) tab[i + 1];
      //引用设置为null,以便GC
      tab[i + 1] = null;
      tab[i] = null;
      // 删除后的后续处理,把后挪的元素移到前面来
      closeDeletion(i);
      return oldValue;
    }
    if (item == null)
      return null;
    i = nextKeyIndex(i, len);
  }
}

remove的逻辑很简单,找到之后,设置null,然后用closeDeletion(i)调整元素位置。

private void closeDeletion(int d) {
  Object[] tab = table;
  int len = tab.length;
  Object item;
  // 从删除的d开始向下找key,知道找到null停止,然后把后一个移到前面
  for (int i = nextKeyIndex(d, len); (item = tab[i]) != null;
       i = nextKeyIndex(i, len) ) {
    int r = hash(item, len);
    if ((i < r && (r <= d || d <= i)) || (r <= d && d <= i)) {
      tab[d] = item;
      tab[d + 1] = tab[i + 1];
      tab[i] = null;
      tab[i + 1] = null;
      d = i;
    }
  }
}

其中if的判断重点分析一下,实际存放的位置i 和 理想情况下存放的位置 r,理论上存在三种情况:

  1. i < r,即 r 在i 的后面。
  2. i > r,即 r 在 i 的前面。
  3. i = r,r恰好放在了自己合适的位置。

那么

  • 针对情况一:我们可以想一想为什么r本该放置的位置却移到了前面去呢。根据put()的源码来看,应该是原本放r的位置已经有的值,所以nextKeyIndex又从table的0开始循环,导致实际存放的位置i < 理想情况下存放的位置 r。那么此时的 d应该处于什么位置?
  1. d恰好占据了r的位置,且d在数组末尾,即 r = d,于是被d占位置的老r,被挤到了数组前头放在了i的位置。
  2. r < d,即r在d的前面,且d在数组末尾,于是r、d位置都被占的老r被挤到了数组前头i的位置
  3. d在数组开头,但是r很不凑巧,在理想的位置r及其后面一直都被人占着,直到绕一圈跑到了d后面,即 d < i
  • 针对情况二:相对简单一些,原本应该放在前面的r却移到了后面i的位置,此时的数组的长度足够,不用考虑到重新回到前面的情况如下:
  1. r = d,d占据了r的位置,后移到了i
  2. r < d,此时r的位置被其他占了,紧接着d也被占了,所以到了i。

综上的话就是if上的情况,至于d = i = r 的情况,笔者认为至于数组长度为2的时候才会出现,但是规定最小的长度是8,所以应该不会出现这种情况。

四、总结

和其他的Map一样,扩容的代价比较高,所以最好创建一个合适大小的map。另一方面,对集合视图的迭代需要的时间与散列表中桶的数量成正比,所以如果你关注迭代器的性能和内存使用,容量值不能设置的太大。另外有解读不对的地方可以留言指正,最后谢谢各位园友观看,与大家共同进步!

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏me的随笔

抽象类 VS 接口

接口和抽象类是面向对象编程(OOP, Object Oriented programming)中两个绕不开的概念,二者相似而又有所不同。接下来,我们来了解二者的...

833
来自专栏编码小白

ofbiz实体引擎(七) 检查数据源

/** * Check the datasource to make sure the entity definitions are correct,...

2634
来自专栏光变

3.1 ASM-方法-结构

ASM-方法-结构 本章将会介绍如果使用ASM core API生成或者转换Java编译后的method。 本将开始会展示编译后的method,然后使用很多说...

852
来自专栏大数据钻研

Java 集合框架 ArrayList 源码剖析

总体介绍 ArrayList实现了List接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入null元素,底层通过数组实现。除该类未实现同步外,其余...

34212
来自专栏程序员互动联盟

读 Java Arrays 源码 笔记

Arrays.java是Java中用来操作数组的类。使用这个工具类可以减少平常很多的工作量。了解其实现,可以避免一些错误的用法。 它提供的操作包括: 排序 so...

35312
来自专栏微信公众号:Java团长

Java遍历集合的几种方法分析(实现原理、算法性能、适用场合)

Java语言中,提供了一套数据集合框架,其中定义了一些诸如List、Set等抽象数据类型,每个抽象数据类型的各个具体实现,底层又采用了不同的实现...

741
来自专栏程序员的酒和故事

跟Google学写代码--Chromium/base--stl_util源码学习及应用

Chromium是一个伟大的、庞大的开源工程,很多值得我们学习的地方。 今天与大家分享的就是Chromium下base中的stl_util,是对stl的补充,封...

3455
来自专栏于晓飞的专栏

读 Java Arrays 源码 笔记

Arrays.java是Java中用来操作数组的类。使用这个工具类可以减少平常很多的工作量。了解其实现,可以避免一些错误的用法。

632
来自专栏xingoo, 一个梦想做发明家的程序员

日志分析系统——Hangout源码学习

这两天看了下hangout的代码,虽然没有运行体验过,但是也算是学习了一点皮毛。 架构浅谈 Hangout可以说是java版的Logstash,我是没有测...

2388
来自专栏猿人谷

C++ primer里的template用法

template 的用法     在程序设计当中经常会出现使用同种数据结构的不同实例的情况。例如:在一个程序中     可以使用多个队列、树、图等结构来组织数据...

1885

扫码关注云+社区