数组和链表
数组:
存储区间是连续,且占用内存严重,空间复杂也很大,时间复杂为O(1)
优点:是随机读取效率很高,原因数组是连续(随机访问性强,查找速度快)。
缺点:插入和删除数据效率低,因插入数据,这个位置后面的数据在内存中要往后移的,且大小固定不易动态扩展。
链表:
区间离散,占用内存宽松,空间复杂度小,时间复杂度O(N)
优点:插入删除速度快,内存利用率高,没有大小固定,扩展灵活。
缺点:不能随机查找,每次都是从第一个开始遍历(查询效率低)。
HashMap
HashMap是一个集合,键值对的集合,源码中每个节点用Node<K,V>表示
Node是一个内部类,这里的key为键,value为值,next指向下一个元素
static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;V value;Node<K,V> next;
HashMap的数据结构为 数组+(链表或红黑树),java7 之前是数组+链表 ,之后是 数组+链表/红黑树,这种结构完美的解决了数组和链表的问题,使得查询和插入,删除的效率都很高。
hashmap刚开始是左边链表形态
在达到某条件后原本是左边的链表形态会转为右边红黑树形态
同样,在达到某条件后,原本转成了右边红黑树形态会转回左边链表形态
这里都画出来是为了表示方便,左右两种形态是不同时空下的hashmap内部形态。
HashMap存储元素的过程:
HashMap<String,String> map = new HashMap<String,String>();
map.put("刘德华","张惠妹");
map.put("张学友","大S");
计算出键“刘德华”的hashcode,该值用来定位要将这个元素存放到数组中的什么位置.
在Object类中有一个方法:public native int hashCode();
该方法用native修饰,所以是一个本地方法,所谓本地方法就是非java代码,这个代码通常用c或c++写成,在java中可以去调用它。
调用这个方法会生成一个int型的整数,我们叫它哈希码,哈希码和调用它的对象地址和内容有关.
通过hashcode值和数组长度取模我们可以得到元素存储的下标。
1. 数组索引的地方是空的,这种情况很简单,直接将元素放进去就好了。
2. 已经有元素占据了索引的位置,这种情况下我们需要判断一下该位置的元素和当前元素是否相等,使用equals来比较。
如果使用默认的规则是比较两个对象的地址。也就是两者需要是同一个对象才相等,当然我们也可以重写equals方法来实现我们自己的比较规则最常见的是通过比较属性值来判断是否相等。
如果两者相等则直接覆盖,如果不等则在原元素下面使用链表的结构存储该元素
因为链表中元素太多的时候会影响查找效率,所以当链表的元素个数达到8的时候使用链表存储就转变成了使用红黑树存储,原因就是红黑树是平衡二叉树,在查找性能方面比链表要高.
HashMap中的两个重要的参数
HashMap中有两个重要的参数:初始容量大小和加载因子,初始容量大小是创建时给数组分配的容量大小,默认值为16,加载因子默认0.75f,用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用rehash方法将数组容量增加到原来的两倍,专业术语叫做扩容.
在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能.
HashMap的put(k,v)实现
首先将k,v封装到Node对象当中(节点)
调用K的hashCode()方法得出hash值
通过哈希表函数/哈希算法,将hash值转换成数组的下标,下标位置上如果没有任何元素,就把Node添加到这个位置上。
如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。
如其中有一个equals返回了true,那么这个节点的value将会被覆盖。
java8中put 源码:put 中调用 putVal()方法:
1.首先判断map中是否有数据,没有就执行resize方法
2.如果要插入的键值对要存放的这个位置刚好没有元素,那么把他封装成Node对象,放在这个位置上即可
3.如果这个元素的key与要插入的一样,那么就替换一下。
4.如果当前节点是TreeNode类型的数据,执行putTreeVal方法
5.遍历这条链子上的数据,完成了操作后多做了一件事情,判断,并且可能执行treeifyBin方法
static final int TREEIFY_THRESHOLD = 8;public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab;Node<K,V> p;int n, i;//如果当前map中无数据,执行resize方法。并且返回nif ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;//如果要插入的键值对要存放的这个位置刚好没有元素,那么把他封装成Node对象,放在这个位置上即可if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);//否则的话,说明这上面有元素else {Node<K,V> e; K k;//如果这个元素的key与要插入的一样,那么就替换一下。if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;//1.如果当前节点是TreeNode类型的数据,执行putTreeVal方法else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {//还是遍历这条链子上的数据,跟jdk7没什么区别for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);//2.完成了操作后多做了一件事情,判断,并且可能执行treeifyBin方法if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null) //true || --e.value = value;//3.afterNodeAccess(e);return oldValue;}}++modCount;//判断阈值,决定是否扩容if (++size > threshold)resize();//4.afterNodeInsertion(evict);return null;}
HashMap的map.get(k)实现
先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标
通过上一步哈希算法转换成数组的下标之后,在通过数组下标快速定位到某个位置上,如果这个位置上什么都没有,则返回null。
如果这个位置上有单向链表,那么它就会拿着参数K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。
如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。
java 1.7 和 java1.8 HashMap 的区别
jdk1.7中HashMap采用的是位桶+链表的方式,即我们常说的散列链表的方式,而jdk1.8中采用的是位桶+链表/红黑树的方式,也是非线程安全的。当某个位桶的链表的长度达到某个阀值(8)的时候,这个链表就将转换成红黑树。
在jdk1.8中,如果链表长度大于8且节点数组长度大于64的时候,就把链表下所有的节点转为红黑树。
树形化还有一个要求就是数组长度必须大于等于64,否则继续采用扩容策略
总的来说,HashMap默认采用数组+单链表方式存储元素,当元素出现哈希冲突时,会存储到该位置的单链表中。但是单链表不会一直增加元素,当元素个数超过8个时,会尝试将单链表转化为红黑树存储。但是在转化前,会再判断一次当前数组的长度,只有数组长度大于64才处理。否则,进行扩容操作。
HashMap链表转红黑树
当链表长度大于或等于阈值(默认为 8)的时候,如果同时还满足容量大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求,就会把链表转换为红黑树。
同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。
每次遍历一个链表,平均查找的时间复杂度是 O(n),n 是链表的长度。红黑树有和链表不一样的查找性能,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在 O(log(n))。最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。所以为了提升查找性能,需要把链表转化为红黑树的形式。
还要注意很重要的一点,单个 TreeNode 需要占用的空间大约是普通 Node 的两倍,所以只有当包含足够多的 Nodes 时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESHOLD 的值决定的。而当桶中节点数由于移除或者 resize 变少后,又会变回普通的链表的形式,以便节省空间。
默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想,最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大的问题。可是当链表越来越长,需要用红黑树的形式来保证查询的效率。
在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,是最理想的值。
事实上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。
通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值。
如果开发中发现 HashMap 内部出现了红黑树的结构,那可能是我们的哈希算法出了问题,所以需要选用合适的hashCode方法,以便减少冲突。
总结
HashMap基于哈希散列表实现 ,可以实现对数据的读写。
将键值对传递给put方法时,它调用键对象的hashCode()方法来计算hashCode,然后找到相应的bucket位置(即数组)来储存值对象。
当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。
HashMap使用链表来解决hash冲突问题,当发生冲突了,对象将会储存在链表的头节点中。HashMap在每个链表节点中储存键值对对象,当两个不同的键对象的hashCode相同时,它们会储存在同一个bucket位置的链表中,如果链表大小超过阈值(TREEIFY_THRESHOLD,8),链表就会被改造为树形结构。
1.7扩容时需要重新计算哈希值和索引位置,1.8并不重新计算哈希值,巧妙地采用和扩容后容量进行&操作来计算新的索引位置。