Java基础系列:了解ArrayList
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java基础系列:了解ArrayList相关的知识,希望对你有一定的参考价值。
来,进来的小伙伴们,我们认识一下。
我是俗世游子,在外流浪多年的Java程序猿
认识数组
在Java中,存在两种存储数据的容器:
- 数组
- 集合
我们首先来了解下数组
数组
认识数组
首先,我们要明白:数组是相同类型数据的有序集合。
我猜一定有人会说,Object的数组可以存字符串,数字等等,你说的不对
Object: 在我面前,你们都是弟弟
其中,我们将存储在数组中的数据称之为:元素,
元素在数组中存储的位置称之为下标。
我们可以通过下标来得到所对应的元素,反过来也一样
内存空间
我们都知道,声明对象就是在内存中开辟一块空间,而声明一个数组就是在内存中开辟一串连续的空间:
如何使用数组
声明数组
在Java中,任何的数据类型都可以定义对象,比如:
String[] arrs = new String[8];
arrs[0] = "元素1";
System.out.println(arrs[0]); // 元素1
int[] intArrs = {1,2,3,4,5,6,7};
System.out.println(intArrs[0]); // 元素1
内存结构如上图所示
简单一点说,使用数组可以分为3步:
- 声明一个数组,然后进行分配内存空间
- 根据下标赋值
- 通过下标处理元素
我们来看下下面这段代码:
String[] arrs = new String[8];
arrs[0] = "元素1";
arrs[8] = "元素8";
System.out.println(arrs[0]);
/**
* Java.lang.ArrayIndexOutOfBoundsException: 8
*/
需要注意的一点是:在任何的编程语言中,如果需要通过下标来得到指定元素,那么这个下标的值一定是从0开始的,且可取值的最大范围为:指定长度 - 1。
上面代码中:我们最大可以操作的范围为 7
而且,数组在声明的时候有分配内存空间的操作,那么如果我们指定下标超过了数组指定的长度,那么我们的程序就会抛出异常:
- ArrayIndexOutOfBoundsException:数组越界问题
数组拷贝
上面介绍了数组的简单使用,下面我们再来重点看一个操作:数组拷贝。比如:
String[] arrs = {"元素1","元素2","元素3","元素4","元素5","元素6","元素7"};
String[] newArrs = new String[7];
// 如何把arrs数据赋值给newArrs
arrs 是另一种定义方式:
- 就是说我们知道数组中存放的数据,可以直接通过{}的形式存储数据,也就是初始值
数组拷贝操作:
- 常规方式就是通过for循环,根据下标然后对新数组进行复制,这种方式比较简单,我就不做过多的说明
在Java.lang.System
中为我们提供了一个静态方法,可以专门用来做数组拷贝:
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
首先来看如何使用:
String[] arrs = {"元素1","元素2","元素3","元素4","元素5","元素6","元素7"};
String[] newArrs = new String[7];
System.arraycopy(arrs, 0, newArrs, 0, 7);
Arrays.stream(newArrs).forEach(System.out::println);
/**
元素1
元素2
元素3
元素4
元素5
元素6
元素7
*/
newArrs = new String[3];
System.arraycopy(arrs, 2, newArrs, 0, 3);
Arrays.stream(newArrs).forEach(System.out::println);
/**
元素3
元素4
元素5
*/
Arrays.stream(newArrs).forEach(System.out::println);
这是jdk8之后推出的lambda的写法,看不懂的可以忽略,就当for循环输出就好
从上面的方法和输出我们多少都能看出来这里的参数的意义,用一句话总结下来就是:
将源数组的第srcPos个位置到指定长度的元素copy到目标数组从第destPos个位置开始到指定长度位置
老外很有趣,以后再看到
src
,source
这些都是代表的是源;dest
,target
都是代表目标而且一般参数的顺序都是:先源后目标
切记:在copy的时候,不能超过目标数组限定长度
而且:该方法执行效率略低,涉及到数组整体数据的copy
数组还有一个二维数组,这里就不介绍了。在实际工作中很少会用到。
关于数组就介绍到这里,我们还没有结束,下面我们重点要来聊一聊Java中的集合
认识集合
可以说,集合在Java中也是属于一种容器,上面我们介绍的数组也是一种容器。上面我们也介绍过了数组,我们先考虑一个问题:
- 为什么会在存在了数组的情况下,Java又推出了集合?
我们在这里来总结一下数组的特点,上面说到:
-
数组是一个相同类型数据的集合,在数组中只能存储一种类型的数据
- 数组在定义的时候必须指定容器的长度,而且超出长度之后就无法在存储数据。
可以说,这两个特点让我们在实际的开发中存在过多的限制。
所以出现了我们接下来要聊的集合
集合框架
集合在Java中存在于java.util
包下,为我们提供了一套性能优良,使用方便的接口和类。
在集合框架中,我们可以感受到另外一种编程思想:面向接口编程
根据存储数据格式进行划分,可以分为两大类
- Collection
- Map
两者都是接口,通过接口来规范操作方式
其中,Collection
存储的数据是单一元素,而Map
是已 <Key, Value>的格式存储起来的。下面我们通过一张图来看看这一系列的主角们:
我们直接来聊聊ArrayList
ArrayList
ArrayList属于List的子类,其特点:
- 有序
- 不唯一(可重复)
很多时候不知道无从下手的时候,就先从该类的构造方法看起:
这是我们在使用ArrayList的方式
// 无参
List<String> list1 = new ArrayList<>();
// 指定初始化长度
List<String> list2 = new ArrayList<>(8);
// 初始化内容
List<String> list3 = new ArrayList<String>(Arrays.asList(arrs));
下面我们通过这三个构造方法来具体看下ArrayList是如何实现的:
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
transient Object[] elementData;
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
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;
}
}
通过查看构造方法,我们可以看出:
- ArrayList底层是采用数组来存储数据的,上面我们对数组也有了一定的了解
- 因为是数组的形式,添加元素是有序的,且允许添加null值
- 同时,当我们采用无参构造方法的时候,默认是空的Object数组
private static final int DEFAULT_CAPACITY = 10;
我们记住这个属性,很快我们就会说到
具体操作方法
背景: 我们后续的方式都以无参构造方法创建的ArrayList对象进行说明
默认构造方法在初始化的时候,底层默认是空的Object数据,上面数组说到,我们在存储数据的时候数组必须要定义数组的长度,那么无参构造方法在添加元素的时候又是怎么做到添加成功而且不会出错呢?
下面我们来详细了解一下
add()
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
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++;
}
两者都是添加元素的方法,唯一的区别在于:
-
add(index, element)方法可以指定元素的添加位置
关于该方法的执行效率,是分情况的
- 如果指定的index越靠近头部位置,那么效率越低;反之亦然
- add(element)默认是按照顺序添加
而且添加方式可以分为两步:
- 判断当前容量是否需要扩容?如果需要扩容就先对数组进行扩容操作然后再进行赋值
- 否则就直接在对应位置上进行赋值操作
下面我们来研究一下扩容的方法:
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
针对底层初始化的时候出现空Object数组的情况,这里对该情况进行了验证:
- 如果是空的Object[], 那么就会拿默认初始化值10和容量对比,取最大值(addAll也是一样的),可以说,默认情况下第一次添加元素时,会将当前容量扩容到10个长度
然后得到上面通过验证得到的长度进行扩容判断,如果容量不足,才会进行扩容的操作:
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
我们具体查看grow()方法,可以得到以下结果:
- 如果
elementData
是空数组的情况下,会扩容到10个长度。
因为如果新扩容后的长度小于calculateCapacity()得到的值的话,calculateCapacity()得到的值就是最终要扩容的值
calculateCapacity()得到的值:使我们当前List的size() + 我们需要添加List中元素的数量
- 而且下次还需要扩容的话,会扩大到当前容量的1.5倍长度
- List扩容操作其实是通过上面我们讲到的 System.arraycopy()方法来进行操作的,我们也说到过,这种方式对性能有影响。所以我们在使用的时候,最好预先设定初始容量,这样可以减少扩容的次数
迭代器:Iterator
ArrayList底层是采用数据结构来实现的,所以我们随机访问数据的方式和直接通过数组来访问是一样的,不过在ArrayList中专门提供了一个方法:
List<String> list = new ArrayList<>(5);
list.add("11");
list.add("12");
list.add("13");
list.add("14");
list.add("15");
System.out.println(list.get(0)); // 11
下来可以自己看下实现源码,肯定是数组根据下标获取元素的方式
这块主要的内容是 Iterator,是Java对设计模式中迭代器模式的实现,下面我们来看操作代码
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String next = iterator.next();
System.out.println(next);
}
List提供了iterator()
,通过该方法我们就可以拿到Iterator进行迭代操作,我们先来看一看它的执行流程是什么样,其中最重要的两个属性:
- cursor: 游标,得到的是下一个元素
- lastRet:返回当前元素
流程
- 默认情况下,cursor=0,lastRet=-1
- 在使用的时候,while一直在判断是否还存在元素:
public boolean hasNext() {
return cursor != size;
}
- 如果存在的话,调用
next()
得到当前元素,同时改变cursor和lastRet的值,可以进行下一次的操作
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
- 接下来就是不断循环直到
hasNext()
为false
看图更详细
和ListIterator的对比
ListIterator<String> listIterator = list.listIterator();
准确来讲,ListIterator是Iterator的子类,拥有和Iterator同样的功能,但是又有不同:
- ListIterator中存在
add()
和set()
,支持在迭代过程中向集合中添加和修改元素 - ListIterator中存在
hasPrevious()
和previous()
,支持逆向迭代 - 还有其他如得到当前定位索引等特性,下来自己看看源代码
线程安全性
其实,ArrayList如果只是局部变量的话,是不牵扯线程安全不安全的,只有作为共享资源存在的时候,才会出现线程不安全的问题:
- ArrayList的方法没有加锁
- 变量没有用
volidate
修饰
这部分设计到多线程的知识,后面会对多线程的基础知识进行介绍
同时还会专门开一个专栏,深入了解多线程的内容。大家在这里记住就可以了
那么, 这种情况下,我们如何操作来保证线程安全呢?
- 自己对操作的方法进行加锁
- 采用Java提供的方法:Collections.synchronizedList(list);
自己查看源码,其实就是对方法进行加锁操作
- 使用CopyOnWriteArrayList,该类属于
java.util.concurrent
下,也就是我们所说的JUC
文档
更多关于ArrayList使用方法推荐查看其文档:
以上是关于Java基础系列:了解ArrayList的主要内容,如果未能解决你的问题,请参考以下文章