一、HashMap底层实现
参考资料
深入理解 Map,HashMap,LinkedHashMap,TreeMap 等
1. HashMap概述
在JDK1.8之前,HashMap采用数组+链表实现,即使用链表处理冲突,同一hash值的节点都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。
而JDK1.8中,HashMap采用数组+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。
下图中代表jdk1.8之前的hashmap结构,左边部分即代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中:
如果在一个链表中查找其中一个节点时,将会花费O(n)的查找时间,会有很大的性能损失。
到了jdk1.8,当同一个hash值的节点数不小于8时,不再采用单链表形式存储,而是采用红黑树,如下图所示:
说明:上图很形象的展示了HashMap的数据结构(数组+链表+红黑树),桶中的结构可能是链表,也可能是红黑树,红黑树的引入是为了提高效率。
2. 数据结构:链表、红黑树、位桶
(1)链表
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。来看具体代码:
// 源码版本:JDK11
// Node是单向链表,它实现了Map.Entry接口
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
HashMap.Node<K, V> next;
//构造函数Hash值 键 值 下一个节点
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return this.key; }
public final V getValue() { return this.value; }
public final String toString() { return this.key + "=" + this.value; }
public final int hashCode() {
return Objects.hashCode(this.key) ^ Objects.hashCode(this.value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//判断两个node是否相等,若key和value都相等,返回true。可以与自身比较为true
public final boolean equals(Object o) {
if (o == this) {
return true;
} else {
if (o instanceof Entry) {
Entry<?, ?> e = (Entry)o;
if (Objects.equals(this.key, e.getKey()) && Objects.equals(this.value, e.getValue())) {
return true;
}
}
return false;
}
}
}
可以看到,Node中包含一个next变量,这个就是链表的关键点,hash结果相同的元素就是通过这个next进行关联的。
(2)红黑树
//红黑树
static final class TreeNode<K, V> extends java.util.LinkedHashMap.Entry<K, V> {
HashMap.TreeNode<K, V> parent; // 父节点
HashMap.TreeNode<K, V> left; // 左子树
HashMap.TreeNode<K, V> right;// 右子树
HashMap.TreeNode<K, V> prev; // needed to unlink next upon deletion
boolean red; // 颜色属性
TreeNode(int hash, K key, V val, HashMap.Node<K, V> next) {
super(hash, key, val, next);
}
//返回当前节点的根节点
final HashMap.TreeNode<K, V> root() {
HashMap.TreeNode r;
HashMap.TreeNode p;
for(r = this; (p = r.parent) != null; r = p) {
}
return r;
}
// ……
}
红黑树比链表多了四个变量,parent父节点、left左节点、right右节点、prev上一个同级节点,红黑树内容较多,不在赘述。
(3)位桶
transient HashMap.Node<K, V>[] table; // 存储(位桶)的数组
HashMap类中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,明显它是一个Node的数组。
有了以上3个数据结构,首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。
3. HashMap源码分析
3.1 继承关系
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable
可以看到HashMap继承自父类(AbstractMap),实现了Map、Cloneable、Serializable接口。其中,Map接口定义了一组通用的操作;Cloneable接口则表示可以进行拷贝,在HashMap中,实现的是浅层次拷贝,即对拷贝对象的改变会影响被拷贝的对象;Serializable接口表示HashMap实现了序列化,即可以将HashMap对象保存至本地,之后可以恢复状态。
3.2 属性
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 最大容量 1 << 30 = 1073741824, 2的30次方
static final int MAXIMUM_CAPACITY = 1073741824;
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75F;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树(我称之为树化阈值)
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表(我称之为链化或解树化阈值)
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient HashMap.Node<K, V>[] table;
// 存放具体元素的集合
transient Set<Entry<K, V>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值,当实际大小(容量*填充因子)超过临界值时,会进行扩容,扩容阈值
int threshold;
// 填充因子
final float loadFactor;
}
3.3 构造函数
(1)HashMap(int, float)型构造函数
// 这里开始默认用JDK8源码作为示例,之前是JDK11的源码 (JDK11的源码中在很多if后面加了else,这些else
// 在JDK8中是没有的,JDK8是直接继续if,比如本段源码中的第8行开始,在JDK11中用else{}包起来了)
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量不能小于0,否则报错
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 初始容量不能大于最大值,否则为最大值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 填充因子不能小于或等于0,不能为非数字
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 初始化填充因子
this.loadFactor = loadFactor;
// 初始化threshold大小
this.threshold = tableSizeFor(initialCapacity);
}
说明:tableSizeFor(initialCapacity)返回大于initialCapacity的最小的二次幂数值;
// JDK8
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
// JDK11
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return n < 0 ? 1 : (n >= 1073741824 ? 1073741824 : n + 1);
}
说明:>>> 操作符表示无符号右移,高位取0。
(2)HashMap(int)型构造函数
public HashMap(int initialCapacity) {
// 调用HashMap(int, float)型构造函数
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
(3)HashMap()型构造函数
public HashMap() {
// 初始化填充因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
(4)HashMap(Map)型构造函数
public HashMap(Map<? extends K, ? extends V> m) {
// 初始化填充因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 将m中的所有元素添加至HashMap中
putMapEntries(m, false);
}
说明:putMapEntries(Map<? extends K, ? extends V> m, boolean evict)函数将m的所有元素存入本HashMap实例中。
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// 判断table是否已经初始化
if (table == null) { // pre-size
// 未初始化,s为m的实际元素个数
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 计算得到的t大于阈值,则初始化阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
// 已初始化,并且m元素个数大于阈值,进行扩容处理
else if (s > threshold)
resize();
// 将m中的所有元素添加至HashMap中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
3.4 hash算法
在JDK 1.8中,hash方法如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
(1)首先获取对象的hashCode()值,然后将hashCode值右移16位,然后将右移后的值与原来的hashCode做异或运算,返回结果。(其中h>>>16,在JDK1.8中,优化了高位运算的算法,使用了零扩展,无论正数还是负数,都在高位插入0)。
(2)在putVal源码中,我们通过(n-1)&hash获取该对象的键在hashmap中的位置。(其中hash的值就是(1)中获得的值)其中n表示的是hash桶数组的长度,并且该长度为2的n次方,这样**(n-1)&hash就等价于hash%n**。因为&运算的效率高于%运算。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
...
if ((p = tab[i = (n - 1) & hash]) == null)//获取位置
tab[i] = newNode(hash, key, value, null);
...
}
tab即是table,n是map集合的容量大小,hash是上面方法的返回值。因为通常声明map集合时不会指定大小,或者初始化的时候就创建一个容量很大的map对象,所以这个通过容量大小与key值进行hash的算法在开始的时候只会对低位进行计算,虽然容量的2进制高位一开始都是0,但是key的2进制高位通常是有值的,因此先在hash方法中将key的hashCode右移16位在与自身异或,使得高位也可以参与hash,更大程度上减少了碰撞率。
下面举例说明下,n为table的长度:
3.5 重要方法
(1)putVal方法
首先说明,HashMap并没有直接提供putVal接口给用户调用,而是提供的put方法,而put方法就是通过putVal来插入元素的:
public V put(K key, V value) {
// 对key的hashCode()做hash
return putVal(hash(key), key, value, false, true);
}
putVal方法执行过程可以通过下图来理解:
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤①:tab为空则创建
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
Node<K,V> e; K k;
// 步骤③:节点key存在,直接覆盖value
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// 步骤④:判断该链为红黑树
// hash值不相等,即key不相等;为红黑树结点
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 步骤⑤:该链为链表
// 为链表结点
else {
// 在链表最末插入结点
for (int binCount = 0; ; ++binCount) {
// 到达链表的尾部
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值,转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 步骤⑥:超过最大容量 就扩容
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
HashMap的数据存储实现原理
流程:
根据key计算得到key.hash = (h = k.hashCode()) ^ (h >>> 16);
根据key.hash计算得到桶数组的索引index = key.hash & (table.length - 1),这样就找到该key的存放位置了:
① 如果该位置没有数据,用该数据新生成一个节点保存新数据,返回null;
② 如果该位置有数据是一个红黑树,那么执行相应的插入 / 更新操作;
③ 如果该位置有数据是一个链表,分两种情况:一是该链表没有这个节点,另一个是该链表上有这个节点,注意这里判断的依据是key.hash是否一样:
如果该链表没有这个节点,那么采用尾插法新增节点保存新数据,返回null;如果该链表已经有这个节点了,那么找到该节点并更新新数据,返回老数据。
注意:
HashMap的put会返回key的上一次保存的数据,比如:
HashMap<String, String> map = new HashMap<String, String>();
System.out.println(map.put("a", "A")); // 打印null
System.out.println(map.put("a", "AA")); // 打印A
System.out.println(map.put("a", "AB")); // 打印AA
(2)getNode方法
说明:HashMap同样并没有直接提供getNode接口给用户调用,而是提供的get方法,而get方法就是通过getNode来取得元素的:
public V get(Object key) {
Node<k,v> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab;
Node<K,V> first, e;
int n;
K k;
// table已经初始化,长度大于0,根据hash寻找table中的项也不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 桶中第一项(数组元素)相等
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 桶中不止一个结点
if ((e = first.next) != null) {
// 为红黑树结点
if (first instanceof TreeNode)
// 在红黑树中查找
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 否则,在链表中查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
// JDK11:
// 对比JDK8好像就是几个变量的定义位置不同,那为什么要改呢?是为了节省空间吗?比如将k和e定义在需要
// 使用的时候
final HashMap.Node<K, V> getNode(int hash, Object key) {
HashMap.Node[] tab;
HashMap.Node first;
int n;
if ((tab = this.table) != null && (n = tab.length) > 0 &&
(first = tab[n - 1 & hash]) != null) {
Object k;
if (first.hash == hash && ((k = first.key) == key || key != null
&& key.equals(k))) {
return first;
}
HashMap.Node e;
if ((e = first.next) != null) {
if (first instanceof HashMap.TreeNode) {
return ((HashMap.TreeNode)first).getTreeNode(hash, key);
}
do {
if (e.hash == hash && ((k = e.key) == key || key != null
&& key.equals(k))) {
return e;
}
} while((e = e.next) != null);
}
}
return null;
}
(3)resize方法
①.在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;
②.每次扩展的时候,都是扩展2倍;
③.扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置(原偏移量+原数组长度),扩容机制JDK8对JDK7有优化(36~66行,尤其是第66行,直接通过原偏移量+原数组长度得到新的偏移量)。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值
threshold = Integer.MAX_VALUE;
return oldTab;//返回
}//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
table = newTab;//将新数组的值复制给旧的hash桶数组
if (oldTab != null) {//进行扩容操作,复制Node对象值到新的hash桶数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {//如果旧的hash桶数组在j结点处不为空,复制给e
oldTab[j] = null;//将旧的hash桶数组在j结点处设置为空,方便gc
if (e.next == null)//如果e后面没有Node结点
newTab[e.hash & (newCap - 1)] = e;//直接对e的hash值对新的数组长度求模获得存储位置
else if (e instanceof TreeNode)//如果e是红黑树的类型,那么添加到红黑树中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;//将Node结点的next赋值给next
if ((e.hash & oldCap) == 0) {//如果结点e的hash值与原hash桶数组的长度作与运算为0
if (loTail == null)//如果loTail为null
loHead = e;//将e结点赋值给loHead
else
loTail.next = e;//否则将e赋值给loTail.next
loTail = e;//然后将e复制给loTail
}
else {//如果结点e的hash值与原hash桶数组的长度作与运算不为0
if (hiTail == null)//如果hiTail为null
hiHead = e;//将e赋值给hiHead
else
hiTail.next = e;//如果hiTail不为空,将e复制给hiTail.next
hiTail = e;//将e复制个hiTail
}
} while ((e = next) != null);//直到e为空
if (loTail != null) {//如果loTail不为空
loTail.next = null;//将loTail.next设置为空
newTab[j] = loHead;//将loHead赋值给新的hash桶数组[j]处
}
if (hiTail != null) {//如果hiTail不为空
hiTail.next = null;//将hiTail.next赋值为空
newTab[j + oldCap] = hiHead;//将hiHead赋值给新的hash桶数组[j+旧hash桶数组长度]
}
}
}
}
}
return newTab;
}
4. 思考
4.1 为什么HashMap中的&位数必须为奇数(Length-1)
长度是16或者其他2的幂,Length-1的值是所有二进制未全为1,这种情况下,index结构就等同于HashCode后几位的值,只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的,然后使用(length-1)&HashCode,相当于HashCode%length,确定在桶中的位置tab[i = (n - 1) & hash],效率比取余%高;
4.2 如何解决hash冲突?
Hash冲突:hashcode一样,但key不一样,就导致了冲突(如果key也相同,就是覆盖操作)。
HashMap的冲突处理使用的是链地址法,将所有哈希值相同的记录链接在一个链表中(同一个桶的链表中)。
解决hash冲突常见方法:
(1)开放地址(外加增量d)
这种方法也称再散列法,其基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。这种方法有一个通用的再散列函数形式:
Hi = (H(key) + di) % m i=1,2,…,n
其中H(key)为哈希函数,m 为表长,di称为增量序列。增量序列的取值方式不同,相应的再散列方式也不同。
又包括三种再散列方式:
线性探测再散列
dii=1,2,3,…,m-1
这种方法的特点是:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。
二次探测再散列
di=12,-12,22,-22,…,k2,-k2 ( k<=m/2 )
这种方法的特点是:冲突发生时,在表的左右进行跳跃式探测,比较灵活。
伪随机探测再散列
di=伪随机数序列。
(2)再哈希(哈希函数不同)
这种方法是同时构造多个不同的哈希函数:
Hi = RH1(key) i=1,2,…,k
当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。
(3)链地址法(拉链法)
拉链法解决冲突的做法是:将所有关键字为同义词的结点链接在同一个单链表中。若选定的散列表长度为m,则可将散列表定义为一个由m个头指针组成的指针数 组T[0…m-1]。凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均应为空指针。
(4)建立公共溢出区
这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
4.3 HashMap线程并发安全性问题
HashMap在并发时可能出现的问题主要是两方面:
(1)如果多个线程同时使用put方法添加元素,而且假设正好存在两个put的key发生了碰撞,那么这两个key会添加到数据的同一个位置,这样最终就会发生其中一个线程put的数据被覆盖;
(2)如果多个线程同时检测到元素个数超过数组大小*loadFactor,也就是多个线程同时对数组进行扩容,都在重新计算元素位置以复制数据,但是最终只有一个线程扩容的数组会赋给table,其他线程的操作都会丢失,并且各自线程put的数据也会丢失。
如何实现一个并发安全的HashMap?
ConcurrentHashMap
4.4 HashMap底层用到了红黑树,红黑树五大特征
(1)节点要么为红色,要么为黑色;
(2)根节点为黑色;
(3)叶子节点为黑色;
(4)每个红色节点的左右孩子都是黑色(保证了从根节点到叶子节点不会出现连续两个红色节点);
(5)从任意节点到每个叶子节点的路径都包含相同数目的黑色节点。(4、5是使得红黑树为平衡树的关键)
从性质(5)可以推出性质5.1:如果一个节点存在黑子节点,那么该结点肯定有两个子节点;
红黑树并不是一个完美平衡二叉查找树,红黑树这种平衡为黑色完美平衡。
红黑树能自平衡,它靠的是什么?
三种操作:左旋、右旋和变色。
- 左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。如图3。
- 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。如图4。
- 变色:结点的颜色由红变黑或由黑变红。
旋转操作不会影响旋转结点的父结点,父结点以上的结构还是保持不变的。
左旋只影响旋转结点和其右子树的结构,把右子树的结点往左子树挪了。
右旋只影响旋转结点和其左子树的结构,把左子树的结点往右子树挪了。
所以旋转操作是局部的。
另外可以看出旋转能保持红黑树平衡的一些端详了:当一边子树的结点少了,那么向另外一边子树“借”一些结点;当一边子树的结点多了,那么向另外一边子树“租”一些结点。
红黑树总是通过旋转和变色达到自平衡。
4.5 红黑树和AVL(平衡二叉搜索树)比较
首先明确一些概念:
(1)平衡二叉搜索树(BBST)
Wiki:在计算机科学中,AVL树是最早被发明的自平衡二叉查找树。在AVL树中,任一节点对应的两棵子树的最大高度差为1,因此它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下的时间复杂度都是O(logn)。增加和删除元素的操作则可能需要借由一次或多次树旋转,以实现树的重新平衡。AVL 树得名于它的发明者 G. M. Adelson-Velsky 和 Evgenii Landis,他们在1962年的论文《An algorithm for the organization of information》中公开了这一数据结构。
性质:
(1)它的左子树和右子树的深度之差(平衡因子)的绝对值不超过1;
(2)它的左子树和右子树都是一颗平衡二叉树。
(2)平衡树
百度百科:平衡树(Balance Tree,BT) 指的是,任意节点的子树的高度差都小于等于1。常见的符合平衡树的有,B树(多路平衡搜索树)、AVL树(二叉平衡搜索树)等。平衡树可以完成集合的一系列操作, 时间复杂度和空间复杂度相对于“2-3树”要低,在完成集合的一系列操作中始终保持平衡,为大型数据库的组织、索引提供了一条新的途径。
设“2-3 树”的每个结点存放一组与应用问题有关的数据, 且有一个关键字 (>0的整数) 作为标识。关键字的存放规则如下:对于结点X, 设左、中、右子树均不空, 则左子树任一结点的关键字小于中子树中任一结点的关键字;中子树中任一结点的关键字小于结点X的关键字;而X的关键字又小于右子树中任一结点的关键字, 称这样的“2-3树”为平衡树。
(3)红黑树
百度百科:红黑树是一种平衡二叉查找树的变体,它的左右子树高差有可能大于 1,所以红黑树不是严格意义上的平衡二叉树(AVL),但 对之进行平衡的代价较低, 其平均统计性能要强于 AVL 。
红黑树与AVL树各自优缺点:
红黑树(RB-Tree)和AVL都是BBST(平衡二叉搜索树),其实现的算法复杂度相同,AVL作为最早提出的BBST,后引入了RB-Tree。
(1)红黑树不追求“完全平衡”,不像AVL那样要求节点的|balFact|<=1,只要求部分达到平衡,但是提出了为节点增加颜色,红黑是用非严格的平衡来换取增删节点的时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,而 AVL是严格平衡树,因此在增加或者删除节点的时候,旋转的次数比红黑树多;
(2)就插入节点导致树失衡的情况,AVL和RB-Tree都是最多两次旋转来实现复衡rebalance,旋转的量级是O(1);
(3)删除节点导致失衡,AVL需要维护从被删除节点到根节点root这条路径上所有节点的平衡,旋转量级为O(logN),而RB-Tree最多只需要旋转3次,所以RB-Tree删除节点的rebalance效率更高,开销更小;
(4)AVL的结构相较于RB-Tree更为平衡,插入和删除引起失衡,如2所述,RB-Tree复衡效率更高;当然,由于AVL高度平衡,因此AVL的Search效率更高;
(5)针对插入和删除节点导致失衡后的rebalance操作,红黑树能够提供一个比较"便宜"的解决方案,降低开销,是对search,insert ,以及delete效率的折衷,总体来说,RB-Tree的统计性能高于AVL;
(6)因此引入RB-Tree是对功能、性能、空间开销的折中:
- AVL更平衡,结构上更加直观,时间效能针对读取而言更高;维护稍慢,空间开销较大;
- 红黑树,读取略逊于AVL,维护强于AVL,空间开销与AVL类似,内容极多时略优于AVL,维护优于AVL;
基本上主要的几种平衡树看来,红黑树有着良好的稳定性和完整的功能,性能表现也很不错,综合实力强,在诸如STL的场景中需要稳定表现。
红黑树的查询性能略微逊色于AVL树,因为其比AVL树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的AVL树最多多一次比较,但是,红黑树在插入和删除上优于AVL树,AVL树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于AVL树为了维持平衡的开销要小得多。
综上,实际应用中,若搜索的次数远远大于插入和删除,那么选择AVL,如果搜索,插入删除次数几乎差不多,应该选择RB。
4.6 HashMap、LinkedHashMap、TreeMap底层区别
深入理解 Map,HashMap,LinkedHashMap,TreeMap 等
(1)Map
Map 是一个接口,代表的是 key-value
键值对,Map 中不能包含重复的 key,一个 key 最多对应一个值。有一些 Map 的实现允许 null 值,一些则不允许 null 值。
(2)HashMap
基于哈希表的 Map 接口实现。除了未实现同步并允许 null 值,HashMap 和 HashTable 大致一样,不过 HashTable 基本上已经废弃了,如果需要同步,可以使用 CurrentHashMap 作为更好的代替。
HashMap 中核心代码:
/**
* Returns a power of two size for the given target capacity.
*/
/**
* 这个方法是基于给定的 size 计算一个不小于 size 的 2^n,实际上相当于把 cap - 1 的
* 最高位及其后面所有的低位都置为 1,得到 2^n - 1,最后结果 +1 得到 tableSize.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
HashMap 底层哈希表的 size 的大小总是 2 的倍数,这是 HashMap 在效率上的一个优化:当底层数组的 length 为 2^n 时, h & (length - 1)
就相当于对 length 取模,其效率要比直接取模高得多。
// 查找哈希值为 hash,key 为 key 的节点
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
/*
第一步,判断表是否为空,如果为空,直接返回null值
若不为空,通过 hash 值对 table.length 取模( (n - 1) & hash )得到该节点在 table 中的下标
*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 若该下标处有值,比较该位置的第一个元素的 hash 和 key 是否相等
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 这之后的代码就是解决哈希冲突了,不过一般来说不大会走下面的代码
if ((e = first.next) != null) {
/*
如果有哈希冲突,就接着往后面找
如果哈希冲突较多,使用的是红黑树处理哈希冲突,进行红黑树查找
*/
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 否则就是简单的链表查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
// 遍历完也没找到,说明确实不存在,返回null值
}
}
return null;
}
// 往 HashMap 中存放哈希值为 hash,key 为 key 的节点
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 如果哈希表是空的,就初始化(这里的 resize 其实就是初始化的作用)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果该位置没有值,直接放在改位置上
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 如果该位置有值并且 key 一样,把 p 的引用赋给 e
e = p;
else if (p instanceof TreeNode)
// 如果该节点是 TreeNode(说明哈希冲突较多),则执行树的插入算法
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
// 否则就是从链表里继续找,直到找到相同的 key 或者链表的最后
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 当链表的元素超过一个阀值是时,将链表转换为红黑树
treeifyBin(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 key
// 如果不是 putIfAbsent ,则替换原来的值,并且返回原来的值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在HashMap中链表部分,七上八下:
在jdk1.7中:新的节点总是在链表的头部,旧的节点在新节点的next域当中;
在jdk1.8中:新的节点总是在链表的尾部,他们的指向都是由旧节点指向新的节点;
另一个值得注意的是 HashMap 的 resize 方法,这个方法会在初始化和扩展容量的时候使用。当扩展容量时,HashMap 的容量会扩充为原来的 2 倍,同时,原来的所有元素需要重新计算哈希值,位置也会发生相应的变化,这是比较耗性能的,如果事先知道 Map 的 size,可以在一开始就创建大小适用的 Map 以减去 resize 的开销。
从上面的代码可以看出来:HashMap 是基于散列表,并且用拉链法来解决哈希冲突的。所以 HashMap 的底层数据结构是 “数组 + 链表”,即元素是链表的数组。不过当链表的元素个数超过一个阀值(static final int TREEIFY_THRESHOLD = 8;)的时候,会将链表转换为红黑树,所以如果哈希冲突多的话,数组的元素将会是红黑树。
拉链法解决哈希冲突示意图:
(3)LinkedHashMap
LinkedHashMap 拥有 HashMap 的所有特性,它比 HashMap 多维护了一个双向链表。因此可以按照插入的顺序从头部或者从尾部迭代,是有序的,不过因为比 HashMap 多维护了一个双向链表,它的内存相比而言要比 HashMap 大,并且性能会差一些,但是如果需要考虑到元素插入的顺序的话,LinkedHashMap 不失为一种好的选择。
(4)SortedMap
SortedMap 是一个有序的 Map 接口,按照自然排序,也可以按照传入的 comparator 进行排序,与 Map 相比,SortedMap 提供了 subMap(K fromKey, K toKey),headMap(K toKey),tailMap(K fromKey)
等额外的方法,用于获取与元素顺序相关的值。
(5)TreeMap
SortedMap 的一种实现,与 HashMap 不同,TreeMap 的底层就是一颗红黑树,它的 containsKey , get , put and remove
方法的时间复杂度是 log(n) ,并且它是按照 key 的自然顺序(或者指定排序)排列,与 LinkedHashMap 不同,LinkedHashMap 保证了元素是按照插入的顺序排列。
4.7 HashMap扩容的优化,是否会重算hash
(1)JDK7:扩容会重新计算这个key值在新桶的什么位置(key%新长度),会有大量的重复计算;
在jdk1.7以前,resize()方法传入的参数是新数组的容量,HashMap也不是无限能扩容的:
1:方法中会首先判断扩容前的旧数组容量是否已经达到最大即2^30了,如果达到了就修改阈值为int的最大取值,这样以后就不会扩容了;
2:初始化一个新Entry数组;
3:计算rehash,判断扩容的时候是否需要重新计算hash值,将此值作为参数传入到transfer方法中——这个是和jdk1.8不同的一点;
4:通过transfer方法将旧数组中的元素复制到新数组,在这个方法中进行了包括释放就的Entry中的对象引用,该过程中如果需要重新计算hash值就重新计算,然后根据indexfor()方法计算索引值。而索引值的计算方法为{return h & (length-1) ;}即hashcode计算出的hash值和数组长度进行与运算。**jdk1.7中重新插入到新数组的元素,如果原来一条链上的元素又被分配到同一条链上那么他们的顺序会发生倒置——这个和1.8也不一样。
(2)JDK8进行了优化:
- 优化1:桶中的链表不需要像JDK7的实现那样重新计算hash,只需要看原来的hash值新增的那个bit是1还是0,是0的话索引不变,是1的话索引编程“原索引+oldCap”;
- 优化2:引入红黑树,仍然采用原索引+oldCap的方式重新构建链表,如果重构长度大于一定值,就会转成红黑树。
JDK8在复制原数组时对其中元素分三种情况讨论,普通节点、红黑树、链表,对不同节点也有不同的复制方法:
1:resize方法源码融入了红黑树,本质和1.7区别不大,但是在插入元素的时候循环旧数组内的元素时会进行判断:
- 如果是普通节点直接和1.7一样放置;
- 如果是红黑树结构,就调用split()方法进行拆分放置;
- 如果是链表,则采用下面2中要分析的方式!!!!
2:在经过一次容量判断是否大于最大值之后在进行扩容,使用的扩容方法是2次幂的扩展,**所以元素要么在原来的位置,要么在原位置在移动2次幂的位置,**如下图:图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,其实我们在扩容的时候不需要像jdk1.7那样重新计算hash,只要看看原hash值新增的那个bit位是1还是0就好了,是0的话索引没有变,是1的话索引变成“原索引+oldCap(旧数组大小)”,下图为resize()方法示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,也就是说1.8不用重新计算hash值而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,因为他采用的是头插法,先拿出旧链表头元素。但是从上图可以看出**,JDK1.8不会倒置,采用的尾插法(七上八下)。**
引申:为何HashMap的数组长度一定是2的次幂?
数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:
我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。
如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。
4.8 HashSet
HashSet底层就是HashMap实现的!
HashSet实现Set接口,由哈希表(实际上是一个HashMap实例)支持。它不保证set 的迭代顺序;特别是它不保证该顺序恒久不变。此类允许使用null元素。
对于HashSet而言,它是基于HashMap实现的,HashSet底层使用HashMap来保存所有元素,因此HashSet 的实现比较简单,相关HashSet的操作,基本上都是直接调用底层HashMap的相关方法来完成, HashSet的源代码如下:
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
static final long serialVersionUID = -5024744406713321676L;
// 底层使用HashMap来保存HashSet中所有元素。
private transient HashMap<E,Object> map;
// 定义一个虚拟的Object对象作为HashMap的value,将此对象定义为static final。
private static final Object PRESENT = new Object();
/**
* 默认的无参构造器,构造一个空的HashSet。
*
* 实际底层会初始化一个空的HashMap,并使用默认初始容量为16和加载因子0.75。
*/
public HashSet() {
map = new HashMap<E,Object>();
}
/**
* 构造一个包含指定collection中的元素的新set。
*
* 实际底层使用默认的加载因子0.75和足以包含指定
* collection中所有元素的初始容量来创建一个HashMap。
* @param c 其中的元素将存放在此set中的collection。
*/
public HashSet(Collection<? extends E> c) {
map = new HashMap<E,Object>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
/**
* 以指定的initialCapacity和loadFactor构造一个空的HashSet。
*
* 实际底层以相应的参数构造一个空的HashMap。
* @param initialCapacity 初始容量。
* @param loadFactor 加载因子。
*/
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<E,Object>(initialCapacity, loadFactor);
}
/**
* 以指定的initialCapacity构造一个空的HashSet。
*
* 实际底层以相应的参数及加载因子loadFactor为0.75构造一个空的HashMap。
* @param initialCapacity 初始容量。
*/
public HashSet(int initialCapacity) {
map = new HashMap<E,Object>(initialCapacity);
}
/**
* 以指定的initialCapacity和loadFactor构造一个新的空链接哈希集合。
* 此构造函数为包访问权限,不对外公开,实际只是是对LinkedHashSet的支持。
*
* 实际底层会以指定的参数构造一个空LinkedHashMap实例来实现。
* @param initialCapacity 初始容量。
* @param loadFactor 加载因子。
* @param dummy 标记。
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<E,Object>(initialCapacity, loadFactor);
}
/**
* 返回对此set中元素进行迭代的迭代器。返回元素的顺序并不是特定的。
*
* 底层实际调用底层HashMap的keySet来返回所有的key。
* 可见HashSet中的元素,只是存放在了底层HashMap的key上,
* value使用一个static final的Object对象标识。
* @return 对此set中元素进行迭代的Iterator。
*/
public Iterator<E> iterator() {
return map.keySet().iterator();
}
/**
* 返回此set中的元素的数量(set的容量)。
*
* 底层实际调用HashMap的size()方法返回Entry的数量,就得到该Set中元素的个数。
* @return 此set中的元素的数量(set的容量)。
*/
public int size() {
return map.size();
}
/**
* 如果此set不包含任何元素,则返回true。
*
* 底层实际调用HashMap的isEmpty()判断该HashSet是否为空。
* @return 如果此set不包含任何元素,则返回true。
*/
public boolean isEmpty() {
return map.isEmpty();
}
/**
* 如果此set包含指定元素,则返回true。
* 更确切地讲,当且仅当此set包含一个满足(o==null ? e==null : o.equals(e))
* 的e元素时,返回true。
*
* 底层实际调用HashMap的containsKey判断是否包含指定key。
* @param o 在此set中的存在已得到测试的元素。
* @return 如果此set包含指定元素,则返回true。
*/
public boolean contains(Object o) {
return map.containsKey(o);
}
/**
* 如果此set中尚未包含指定元素,则添加指定元素。
* 更确切地讲,如果此 set 没有包含满足(e==null ? e2==null : e.equals(e2))
* 的元素e2,则向此set 添加指定的元素e。
* 如果此set已包含该元素,则该调用不更改set并返回false。
*
* 底层实际将将该元素作为key放入HashMap。
* 由于HashMap的put()方法添加key-value对时,当新放入HashMap的Entry中key
* 与集合中原有Entry的key相同(hashCode()返回值相等,通过equals比较也返回true),
* 新添加的Entry的value会将覆盖原来Entry的value,但key不会有任何改变,
* 因此如果向HashSet中添加一个已经存在的元素时,新添加的集合元素将不会被放入HashMap中,
* 原来的元素也不会有任何改变,这也就满足了Set中元素不重复的特性。
* @param e 将添加到此set中的元素。
* @return 如果此set尚未包含指定元素,则返回true。
*/
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
/**
* 如果指定元素存在于此set中,则将其移除。
* 更确切地讲,如果此set包含一个满足(o==null ? e==null : o.equals(e))的元素e,
* 则将其移除。如果此set已包含该元素,则返回true
* (或者:如果此set因调用而发生更改,则返回true)。(一旦调用返回,则此set不再包含该元素)。
*
* 底层实际调用HashMap的remove方法删除指定Entry。
* @param o 如果存在于此set中则需要将其移除的对象。
* @return 如果set包含指定元素,则返回true。
*/
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
/**
* 从此set中移除所有元素。此调用返回后,该set将为空。
*
* 底层实际调用HashMap的clear方法清空Entry中所有元素。
*/
public void clear() {
map.clear();
}
/**
* 返回此HashSet实例的浅表副本:并没有复制这些元素本身。
*
* 底层实际调用HashMap的clone()方法,获取HashMap的浅表副本,并设置到HashSet中。
*/
public Object clone() {
try {
HashSet<E> newSet = (HashSet<E>) super.clone();
newSet.map = (HashMap<E, Object>) map.clone();
return newSet;
} catch (CloneNotSupportedException e) {
throw new InternalError();
}
}
}
对于HashSet中保存的对象,请注意正确重写其equals和hashCode方法,以保证放入的对象的唯一性。
二、String、StringBuffer和StringBuilder
参考资料
String,StringBuffer与StringBuilder的区别
Java中String,StringBuilder和StringBuffer的区别
1. String:字符串常量
String在java编程中广泛应用,首先从源码进行分析
从这我们可以得知,String底层是一个final类型的字符数组,所以String的值是不可变的,每次对String的操作都会生成新的String对象,造成内存浪费;
我们可以看到,初始String值为“hello”,然后在这个字符串后面加上新的字符串“world”,这个过程是需要重新在栈堆内存中开辟内存空间的,最终得到了“hello world”字符串也相应的需要开辟内存空间,这样短短的两个字符串,却需要开辟三次内存空间,不得不说这是对内存空间的极大浪费。为了应对经常性的字符串相关的操作,引入了两个新的类——StringBuffer类和StringBuild类来对此种变化字符串进行处理。
2. StringBuffer和StringBuilder——字符串变量
而StringBuffer和StringBuilder就不一样了,他们两都继承了AbstractStringBuilder抽象类,从AbstractStringBuilder抽象类中我们可以看到
他们的底层都是可变的字符数组,所以在进行频繁的字符串操作时,建议使用StringBuffer和StringBuilder来进行操作。
StringBuffer 和 StringBuilder 类的对象能够被多次的修改,并且不产生新的未使用对象。
StringBuilder 类在 Java 5 中被提出,它和 StringBuffer 之间的最大不同在于 StringBuilder 的方法不是线程安全的(不能同步访问)。
由于 StringBuilder 相较于 StringBuffer 有速度优势,所以多数情况下建议使用 StringBuilder 类。然而在应用程序要求线程安全的情况下,则必须使用 StringBuffer 类。
接着看一下他们的继承结构以及部分源码实现
从这三张图我们不难得知:StringBuilder 类在 Java 5 中被提出,它和 StringBuffer 之间的最大不同在于 StringBuilder 的方法不是线程安全的(不能同步访问)由于 StringBuilder 相较于 StringBuffer 有速度优势,所以多数情况下建议使用 StringBuilder 类。然而在应用程序要求线程安全的情况下,则必须使用 StringBuffer 类。
再详细说一下String
String并不是基本数据类型,而是一个对象。字符串为对象,那么在初始化之前,它的值为null,到这里就有必要提下””、null、new String()三者的区别。null 表示string还没有new ,也就是说对象的引用还没有创建,也没有分配内存空间给他,而””、new String()则说明了已经new了,只不过内部为空,但是它创建了对象的引用,是需要分配内存空间的。
java的虚拟机在内存中开辟出一块单独的区域,用来存储字符串对象,这块内存区域被称为字符串缓冲池。那个java的字符串缓冲池是如何工作的呢?
String a = "abc";
String b = "abc";
String c = new String("xyz");
例如上边的代码:
String a = "abc";
创建字符串的时候 先查找字符串缓冲池中有没有相同的对象,如果有相同的对象就直接返回该对象的引用,如果没有相同的对象就在字符串缓冲池中创建该对象,然后将该对象的应用返回。对于这一步而言,缓冲池中没有abc这个字符串对象,所以首先创建一个字符串对象,然后将对象引用返回给a。
String b = "abc";
这一句也是想要创建一个对象引用变量b使其指向abc这一对象。这时,首先查找字符串缓冲池,发现abc这个对象已经有了,这是就直接将这个对象的引用返回给b,此时a和b就共用了一个对象abc,不过不用担心,a改变了字符串不会影响b,因为字符串都是常量,一旦创建就没办法修改了,除非创建一个新的对象。
String c = new String("xyz");
查找字符串缓冲池发现没有xyz这个字符串对象,于是就在字符串缓冲池中创建了一个xyz对象然后再将引用返回。
从上边的分析可以看出,当new一个字符串时并不一定是创建了一个新的对象,有可能是与别的引用变量共同使用了同一个对象。下面看几个常见的有关字符串缓冲池的问题。
创建了几个对象
String a = "abc";
String b = "abc";
String c = new String("xyz");
String d = new String("xyz");
String e = "ab" + "cd";
这个程序与上边的程序比较相似,我们分比来看一下:
1、String a = “abc”;这一句由于缓冲池中没有abc这个字符串对象,所以会创建一个对象;
2、String b = “abc”;由于缓冲池中已经有了abc这个对象,所以不会再创建新的对象;
3、String c = new String(“xyz”);由于没有xyz这个字符串对象,所以会首先创建一个xyz的对象,然后这个字符串对象由作为String的构造方法,在内存中(不是缓冲池中)又创建了一个新的字符串对象,所以一共创建了两个对象;
4、String d = new String(“xyz”);缓冲池中已有该字符串对象,则缓冲池中不再创建该对象,然后会在内存中创建一个新的字符串对象,所以只创建了一个对象;
5、String e = ”ab” + ”cd”;由于常量的值在编译的时候就被确定了。所以这一句等价于String e = ”abcd”;所以创建了一个对象;
所以创建的对象的个数分别是:1,0,2,1,1。
到底相等不相等
我们在学习java时就知道两个字符串对象相等的判断要用equal而不能使用==
,但是学习了字符串缓冲池以后,应该知道为什么不能用==
, 什么情况下==
,和equal是等价的,首先,必须知道的是,equal比较的是两个字符串的值是否相等,而==比较的是两个对象的内存地址是否相等,下面我们就通过几个程序来看一下。
实例一:
public static void main(String[] args) {
String s1 = "Monday";
String s2 = "Monday";
if (s1 == s2) {
System.out.println("s1 == s2");
}
else {
System.out.println("s1 != s2");
}
}
输出结果:
s1 == s2
分析:通过上边的介绍字符串缓冲池,我们知道s1和s2都是指向字符串缓冲池中的同一个对象,所以内存地址是一样的,所以用==可以判断两个字符串是否相等。
实例二:
public static void main(String[] args) {
String s1 = "Monday";
String s2 = new String("Monday");
if (s1 == s2) {
System.out.println("s1 == s2");
}
else {
System.out.println("s1 != s2");
}
if (s1.equals(s2)) {
System.out.println("s1 equals s2");
}
else {
System.out.println("s1 not equals s2");
}
}
输出结果:
s1 != s2
s1 equals s2
分析:由上边的分析我们知道,String s2 = new String(“Monday”);这一句话没有在字符串缓冲池中创建新的对象,但是会在内存的其他位置创建一个新的对象,所以s1是指向字符串缓冲池的,s2是指向内存的其他位置,两者的内存地址不同的。(这里涉及到String的字面量赋值和new对象赋值)
实例三:
public static void main(String[] args) {
String s1 = "Monday";
String s2 = new String("Monday");
s2 = s2.intern();
if (s1 == s2) {
System.out.println("s1 == s2");
}
else {
System.out.println("s1 != s2");
}
if (s1.equals(s2)) {
System.out.println("s1 equals s2");
}
else {
System.out.println("s1 not equals s2");
}
}
输出结果:
s1 == s2
s1 equals s2
分析:先来说说intern()这个方法的作用吧,这个方法的作用是返回在字符串缓冲池中的对象的引用,所以s2指向的也是字符串缓冲池中的地址,和s1是相等的。
实例四:
public static void main(String[] args) {
String Monday = "Monday";
String Mon = "Mon";
String day = "day";
System.out.println(Monday == "Mon" + "day");
System.out.println(Monday == "Mon" + day);
}
输出结果:
true
false
分析:第一个为什么等于true我们已经说过了,因为两者都是常量所以在编译阶段就已经能确定了,在第二个中,day是一个变量,所以不能提前确定他的值,所以两者不相等,从这个例子我们可以看出,只有+连接的两边都是字符串常量时,引用才会指向字符串缓冲池,都则都是指向内存中的其他地址。
实例五:
public static void main(String[] args) {
String Monday = "Monday";
String Mon = "Mon";
final String day = "day";
System.out.println(Monday == "Mon" + "day");
System.out.println(Monday == "Mon" + day);
}
输出结果:
true
true
分析:加上final后day也变成了常量,所以第二句的引用也是指向的字符串缓冲池。
3. 区别
这三个类的主要区别在两个方面:运算速度(运算性能或执行效率)和线程安全性。
-
运算速度比较(通常情况下):StringBuilder > StringBuffer > String
-
线程安全性:
StringBuilder(非线程安全),StringBuilder的方法没有synchronized关键字修饰,所以不能保证线程安全性。
StringBuffer(线程安全的),StringBuffer中大部分方法由synchronized关键字修饰,在必要时可对方法进行同步,因此任意特定实例上的所有操作就好像是以串行顺序发生的,该顺序与所涉及的每个线程进行的方法调用顺序一致,所以是线程安全的。
(1)String:不可变字符序列;底层使用char[]存储;初始化可以null;
(2)StringBuffer:可变的字符序列;线程安全,效率低;底层使用char[]存储;
(3)StringBuilder:可变的字符序列;JDK5新增,线程不安全,效率高;底层使用char[]存储;
使用场景:
(1)当字符串相加操作或者改动较少的情况下使用 String str = "hello”这种形式;
(2)多线程操作字符串缓冲区下操作大量数据 StringBuffer(内部方法利用synchronized修饰);
(3)单线程操作,需要操作大量数据,使用 StringBuilder。
引申:为什么说String类是final修饰的:
(1)为了实现字符串池,因为只有当字符串是不可变的,字符串池才可能实现;
(2)线程安全;
(3)实现String可以创建HashCode不可变性,因为字符串是不可变的,所以在创建的时候HashCode就被缓存了,不需要重新计算。