ArrayList
ArrayList 是 java 集合框架中常用的数据结构,实现了List接口,同时还实现了 RandomAccess、Cloneable、Serializable 接口!
System.arraycopy()
方法
源码:
// 我们发现 arraycopy 是一个 native 方法,接下来我们解释一下各个参数的具体意义
/**
* 复制数组
* @param src 源数组
* @param srcPos 源数组中的起始位置
* @param dest 目标数组
* @param destPos 目标数组中的起始位置
* @param length 要复制的数组元素的数量
*/
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
Arrays.copyOf()
方法
源码:
public static int[] copyOf(int[] original, int newLength) {
// 申请一个新的数组
int[] copy = new int[newLength];
// 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
ArrayList是如何扩容的?
jdk 1.7之前默认容量是10
//默认初始化容量
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//集合的长度
private int size;
//集合存元素的数组
Object[] elementData;
//默认的容量
private static final int DEFAULT_CAPACITY = 10;
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//判断集合存储元素的数组是否为空
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
//如果当前最小容量小于默认容量10 就将默认容量10返回
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
//判断最小容量是否大于当前数组实际容量(就是判断是否要扩容)
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//扩容的核心方法
private void grow(int minCapacity) {
//原来容量
int oldCapacity = elementData.length;
//扩容1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
//判断扩容之后还是小了,就直接把最小容量当做新的容量
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
//拷贝数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
对于ArrayList ,new无参对象时,底层是一个空数组,当添加第一个元素时,会进行扩容,将底层数组长度扩为10,
其中扩容触发的条件是:存元素时,即先让size+1的值(也就是最小容量的值)判断是否大于底层elementData.length的长度,如果大于,则先扩容再添加,扩容的倍数为1.5倍。
当我们初始化一个arraylist数组时,jdk1.8 默认是为空的,当我调用add方法时,会进行第一次的扩容,将数组长度扩容为10;当我们数组的元素大于数组的长度10时,会触发自动扩容机制,首先创建一个新的数组,这个数组的长度是原数组的1.5倍,然后使用Arrays.copyOf方法把原数组的数据拷贝到新数组里面
ArrayList频繁扩容导致添加性能急剧下降,如何处理?
当需要加入的数据量特别多,如果是无参的话,数组的容量是逐渐增加的,那么就会触发很多次的扩容,因为扩容的时候会使用到数组拷贝,这个过程很耗费性能,会导致ArrayList效率下降
可以在创建集合时指定初始容量大小,减少扩容次数
Arraylist 与 LinkedList 区别?
- 是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全; - 底层数据结构:
Arraylist
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) - 插入和删除是否受元素位置的影响: ①
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 ②LinkedList
采用链表存储,所以对于add(E e)
方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i
插入和删除元素的话((add(int index, E element)
) 时间复杂度近似为o(n))
因为需要先移动到指定位置再插入。 - 是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。 - 内存空间占用:
ArrayList
的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而LinkedList
的空间花费则体现在它的每一个元素都需要消耗比ArrayList
更多的空间(因为要存放直接后继和直接前驱以及数据)。
Array和ArrayList的区别?
- array是数组,声明好后,长度是固定的;ArrayList是集合,底层是动态数组,长度可以改变
- array可以存储基本类型和对象类型;ArrayList只能存储对象类型
ArrayList插入或删除元素一定比LinkedList慢吗?
不一定,得看操作元素的位置
ArrayList在插入或删除元素时都会拷贝数组,增删越靠前的元素,拷贝的元素越多,效率越低;
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++;
}
LinkedList在插入或删除元素时,调用Node方法,折半查找要操作的元素,如果数据量大,并且操作的元素在中间,这时候效率也会很慢
//找元素的方法
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
//从头往后找
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
//从尾往后找
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
ArrayList线程不安全
因为ArrayList是动态数组,size(数组的长度)是成员变量,是共享的;当两个线程同时操作add方法时,线程a添加一个元素,数组下标增加,此时线程b在操作时发现没有这个下标,就会抛出数组越界异常;或者在赋值操作时候,多个线程操作同一个下标,会出现值被覆盖,值为null的现象;
解决:
-
使用Vector类,因为该类的方法加了同步锁(synchronized)
-
使用Collections.synchronizedCollection(Collection c):
Object mutex = new Object()。对此对象使用synchronized
-
使用CopyOnWriteArrayList,COW采用自旋锁(jdk1.6升级为synchronize)对写加锁,读不加锁,数组变量有使用volatile保证可见性,增删创建一新数组,读取的是原数据,适合读多写少场景。
Write的时候总是要Copy(将原来array复制到新的array,修改后,将引用指向新数组)。任何可变的操作(add、set、remove等)都通过ReentrantLock 控制并发。
复制ArrayList的5种方法
- 使用ArrayList的构造方法,底层实际上调用了Arrays.copyOf方法来对数组进行拷贝。这个拷贝调用了系统的native arraycopy方法,注意这里的拷贝是引用拷贝,而不是值的拷贝。
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
- 使用addAll方法
public boolean addAll(Collection<? extends E> c) {
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew); // Increments modCount
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
- 使用Collections.copy(dest,src):首先要指定要复制数组的大小(要大于或等于被复制数组的大小),因为该方法首先会判断两个数组的长度大小;
- 使用stream流的方式
- 直接使用clone方法
ArrayList<Integer> list = new ArrayList<Integer>();
list.add(1);
list.add(2);
list.add(3);
//构造方法
ArrayList<Integer> listCopy1 = new ArrayList<Integer>(list);
System.out.println(listCopy1);
//addAll方法
ArrayList<Integer> listCopy2 = new ArrayList<Integer>();
listCopy2.addAll(list);
System.out.println(listCopy2);
//Collections.copy方法
ArrayList<Integer> listCopy3 = new ArrayList<Integer>(
Arrays.asList(new Integer[list.size()]));
Collections.copy(listCopy3, list);
System.out.println(listCopy3);
//stream流方法
List<Integer> listCopy4 = list.stream().collect(Collectors.toList());
System.out.println(listCopy4);
//使用clone方法
ArrayList<Integer> listCopy5 = (ArrayList<Integer>) list.clone();
System.out.println(listCopy5);
HashMap
HashMap主要用来存放键值对,可以存放null的key和value,但是为null的key只能有一个,key是唯一的,存储的元素是无序的,线程不安全
HashMap底层实现
JDK1.8之前,是由数组加链表组成
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过(n-1) & hash公式(n为数组长度)得到key在数组中存放的下标,如果当前下标位置存在元素(哈希冲突),就要判断当前元素与要存入元素的hash值和key是否相同,如果相同直接覆盖;如果不同,就通过拉链法解决冲突。
所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
我们知道,链表查找数据必须从第一个元素开始查找,直到找到为止,时间复杂度为O(n),所以当链表越来越长是,hashmap的效率越来越低;
那么怎么解决这个问题?
- JDK1.8开始采用:数组+链表+红黑树 结构来实现HashMap,当链表的长度(元素)大于阈值8(在链表转为红黑树之前会判断,如果当前数组的长度小于64,会先进行数组扩容,而不是转为红黑树)时,就会将链表转为红黑树,以提高查找效率;
类的属性:
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 = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子
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 Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值(容量*填充因子) 当实际大小超过临界值时,会进行扩容
int threshold;
// 加载因子
final float loadFactor;
}
-
loadFactor 加载因子
loadFactor 加载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。
loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
为什么HashMap要用数组加链表来实现?
HashMap的key经过扰动函数之后得到一个hash值,然后通过公式(数组的长度-1)& hash值得到key在数组中的下标,如果当前下标位置存在元素,说明存在哈希冲突,就要判断当前元素与要存入元素的hash值和key值是否相同,如果相同就直接覆盖,如果不同,就要通过链表来解决哈希冲突;这是jdk1.7的做法,如果链表的长度过长,会导致查找和插入效率变低。所有jdk1.8新增加了红黑树
HashMapd的put方法的大致流程
- 首先根据key的值计算出hash值,然后通过(n-1)&hash操作找到该元素在数组中存储的下标
- 如果当前数组为空,则会调用resize方法进行初始化,得到一个默认容量16,阈值为12的数组
- 如果没有哈希冲突,就直接放入该数组的下标
- 如果有哈希冲突,并且key相同,就直接覆盖value的值
- 如果冲突后,发现该节点是红黑树(TreeNode),就把该节点挂在树上
- 如果冲突后是链表,判断链表的长度是否大于阈值8,如果大于8并且数组的容量小于64,就会进行扩容;
如果链表长度大于8并且数组容量大于64,就会把链表转为红黑树;否则,就会插入到链表中,如果有相同的key,就会直接覆盖。
jdk1.8对HashMap做了哪些优化?
- 引入了红黑树,当链表长度大于8并且数组长度大于64时,链表转为红黑树,解决了链表过长,导致查询效率降低的问题,链表查询时间复杂度O(n),红黑树增删查时间复杂度都为O(logn)
- 哈希冲突时,头插改尾插,可以避免扩容后相对位置的倒序,避免并发环境下,扩容产生循环链表,导致死循环
- 2倍扩容后,在计算数组下标时,直接判断hash高位值(前16位),如果为0,则原位置保持不变,如果为1,原位置加上原数组长度,这样省去了重新计算hash的时间,这样算出的数组下标具有随机性,减少哈希冲突
HashMap的扩容方式
jdk1.7中,扩容会重新计算hash值,并且会遍历hash表所有元素,是非常耗时的;
HashMap在容量大于阈值(加载因子*当前数组容量 =0.75*
16=12 )时,就会调用resize方法进行扩容,将hashmap的大小扩容为原来的2倍,并将原来的对象放入新数组当中;
当我们初始化一个Hashmap集合时,默认集合容量大小为0,当我们调用put方法时,hashmap会进行扩容,将大小扩容为默认的容量16,阈值为12;当向集合添加元素时,如果元素的个数大于阈值12时,就会进行扩容,将大小扩容为原来的2倍(左移一位),然后遍历数组,判断要转移的元素是单个元素、链表或者红黑树;
-
如果是单个元素,就直接放入到新数组
if (e.next == null) newTab[e.hash & (newCap - 1)] = e;
-
如果是链表,会有一个高低位的选择,计算的下标位置不变或者计算的(下标位置+原数组的长度)
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
- 如果是红黑树,遍历双向链表统计哪些元素在扩容完是原位置还是新位置,这样遍历完双向链表后,就会得到两个子链表,一个放在原下标位置,一个放在新下标位置,如果原下标位置或新下标位置没有元素,则红黑树不用拆分,否则判断这两个子链表的长度,如果超过八,则转成红黑树放到对应的
位置,否则把单向链表放到对应的位置。 - 元素转移完了之后,在把新数组对象赋值给HashMap的table属性,老数组会被回收。
HashMap 的长度为什么是2的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。
为什么HashMap是不安全的?
jdk1.7,头插法——>当并发执行扩容操作时会造成环形链和数据丢失的情况。
HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,**导致在get时会出现死循环,**所以HashMap是线程不安全的。
jdk1.8,尾插法——>在并发执行put操作时会发生数据覆盖的情况。
向HashMap集合中添加元素会存在覆盖的现象,导致了线程不安全。