在java编程中,最常用到的数据结构莫过于list的了。List接口中最常用的几个实现类莫过于ArrayList,LinkedList.如果涉及到并发,可能还会用到Vector,CopyOnWriteList等等。第三方的链表就更多了,在此不做介绍。从数据存储的角度来考虑,list无非分为两种,一种是基于数组的,以ArrayList为代表,一种是基于松散的引用关系的,代表为LinkedList。毕业两年了,忘记链表究竟是对list的翻译,还是专门对LinkedList这种类型的list的表述了。
稍微有经验的人都知道两者的差异,ArrayList有着良好的搜索性能,比较适合用在各种遍历场景较多的代码块中,如果对Arraylist进行大量的插入或者删除,会因为扩容等问题导致数组的拷贝等;LinkedList有着良好的插入或删除的性能,而如果用在搜索场景中,肯定不及数组的效率高。在此不对本段文字进行代码举例。
我所谓的血案为何事呢,请看代码。
public static void main(String[] args) {
int LOOPTIMES=10000000;
List list = new LinkedList();
long t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
list.add(i);
list.remove(0);
}
System.err.println(System.currentTimeMillis() - t1);
t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
list.add(i);
// list.remove(0);
}
System.err.println(System.currentTimeMillis() - t1);
list = new ArrayList(10);
t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
list.add(i);
list.remove(0);
}
System.err.println(System.currentTimeMillis() - t1);
t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
list.add(i);
// list.remove(0);
}
System.err.println(System.currentTimeMillis() - t1);
t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
list.remove(0);
}
System.err.println(System.currentTimeMillis() - t1);
}
一次任意的执行的输出结果为:
244
11057
99
2995
.....
引发我所谓血案的问题在于第一行与第二行的差异,第一个循环比第二个循环多了一倍的remove操作,结果反而快了几个数量级。后来为了丰富场景,我才加入了下面几个循环作为对比,第三与第四的对比和第一与第二的差异是同样的。至于第五行,对不起,在我写本文档的时候运行结果还没出来。
事实上,如果LOOPTIMES小一些,比如降至10000,得到的结果会是相反的,执行remove的循环要比不执行remove的循环慢很多。为什么循环到一定量级了,结果却反过来了,而且差异这么明显?还有,不是说ArrayList适合做搜索,LinkedList是和做插入删除吗,为什么第一个循环与第三个循环相比,第二个循环与第四个相比,却慢很多呢?
看到这里,楼主sb吧,第一个问题明显是垃圾回收导致的。事实的确如此,单独运行第二个循环,查看垃圾回收情况得到下图:
第二个循环总共耗时12559ms,略高于总的GC时间。我电脑为mac air,jdk1.7.没有配置任何jvm参数。程序运行期间,因为list中一直在引用这大量的对象,导致jvm出现了堆震荡(上图中无法明确显示,总之初始状态下老年代可用内存没这么大),两次full gc,多次新生代gc。也就是说,对于第二个循环,大量的时间花在了垃圾回收以及寻找新的空间来为i装箱上面。
至于第二个问题,也挺简单的。第三个循环一直在array[0]上进行操作,自然很快;至于第四个循环,基于前三个循环的执行,list = new ArrayList(10);这一步首先释放了大量的内存空间,其次,执行时也无需在经历堆震荡,因此,第四个循环的时间比第二个的时间段也是容易理解的。大二学了c++之后再也没关心过指针什么的,所以至于数组中存放的是引用还是对象,是否ArrayList在扩容是已经为对象预分配了空间,在此不做讨论了。
第五个循环到我写到现在还没执行完毕,它证明了一个问题,在ArrayList上对其进行随机更新,也就是说不从尾部,而从其他任意位置进行删除(也肯定包括插入),会引起数组的频繁拷贝,大量的拷贝导致了remove的低效。
第一次写这种文档,感觉上面的论证有点乱糟糟的。不知道有没有解释清楚。
以上从内部存储方面来对list的实现做了一些比较。从用途方便,list也有两种不同的场景,第一,作为数据存储,第二,作为数据中转。在作为数据存储的时候,往往搜索操作比较多,而对于数据中转,则更新操作比较多。比如,假设你为一种类型的请求设置了多个拦截器,那么把这些拦截器放到list中,每次请求的时候都遍历这个list来执行拦截,是比较合适的选择。你可能只会在初始化的时候往list里插入数据,之后整个运行期只是在遍历。这个时候,ArrayList的优点会得到充分发挥,这个就是list的存储作用。而对于消息队列,往往生产者往队列的尾部插入数据,消费者从队列的头部取出数据,这种频繁的更新,自然LinkedList更好一点。
对于高并发的消息队列,list的性能的好坏很重要。如果不考虑并发,LinkedList是否是最适合做消息队列的一种实现呢?我一向遵从DRY原则,所以在开始的时候,我的确选择了LinkedList作为消息队列。后来,遇到了上述的血案,当时我朋友第一个想到的不是垃圾回收因素,而是在想是不是LinkedList的内部实现有问题。于是,我开始回忆大二刚学习链表时候的那些代码,做了一个简单的双端队列。
public class SillyQueue<T> {
private Node first, last;
private int size = 0;
class Node<T> {
Node<T> prev;
Node<T> next;
T value;
public Node(Node<T> prev, Node<T> next, T value) {
this.prev = prev;
this.next = next;
this.value = value;
}
}
public int size() {
return size;
}
public void addFirst(T element) {
Node newNode = new Node(null, first, element);
if (first == null) {
last = first = newNode;
} else {
first.prev = newNode;
first = newNode;
}
size++;
}
public void addLast(T element) {
Node newNode = new Node(last, null, element);
if (last == null) {
last = first = newNode;
} else {
last.next = newNode;
last = newNode;
}
size++;
}
public T removeFirst() {
Node<T> node = first;
if (node != null) {
size--;
first = node.next;
if (first != null) {
first.prev = null;
} else {
last = null;
}
T value = node.value;
return value;
}
return null;
}
public T removeLast() {
Node<T> node = last;
if (node != null) {
size--;
last = node.prev;
if (last != null) {
last.next = null;
} else {
first = null;
}
T value = node.value;
return value;
}
return null;
}
}
这个傻瓜队列就是我的实现,当然,最终在血案那个场景里,他也是灰头土脸的。不过,用作数据中转,他的性能的确比LinkedList好了那么一丢丢——因此存取的时候不用考虑并发迭代、快速失败之类的问题。
因此,在那段时间,我为考虑过用傻瓜队列来替换LinkedList。
难道,LinkedList真的已经算是链表领域里最好的实现?
别说,真让我发现了一个更好一点的实现,而且这种实现的设计思想,我们其实都很熟悉。这个实现的代码是2002年seda模型发表时顺带的sandstorm框架里的代码,由于年代久远,代码风格不太好,因此,我在它的设计思想下,对傻瓜队列进行了改进。下面对其进行的改进,以及测试代码。
public class IOFastQuque<T> {
private Node first, last;
private int size = 0;
private int cached = 0;
private int NODE_CACHED_SIZE = 5;
private Node<T>[] NODE_CACHED = new Node[NODE_CACHED_SIZE];
class Node<T> {
Node<T> prev;
Node<T> next;
T value;
public Node(Node<T> prev, Node<T> next, T value) {
this.prev = prev;
this.next = next;
this.value = value;
}
public Node() {
prev = next = null;
value = null;
}
}
public int size() {
return size;
}
private Node<T> getNode() {
if (cached == 0) {
for (; cached < NODE_CACHED_SIZE;) {
NODE_CACHED[cached++] = new Node<>();
}
}
return NODE_CACHED[--cached];
}
private void freeNode(Node<T> node) {
if (cached < NODE_CACHED_SIZE) {
node.prev = node.next = null;
node.value = null;
NODE_CACHED[cached++] = node;
}
}
public void addFirst(T element) {
Node newNode = getNode();
newNode.value = element;
if (first == null) {
last = first = newNode;
} else {
first.prev = newNode;
newNode.next = first;
first = newNode;
}
size++;
}
public void addLast(T element) {
Node newNode = getNode();
newNode.value = element;
if (last == null) {
last = first = newNode;
} else {
last.next = newNode;
newNode.prev = last;
last = newNode;
}
size++;
}
public T removeFirst() {
Node<T> node = first;
if (node != null) {
size--;
first = node.next;
if (first != null) {
first.prev = null;
} else {
last = null;
}
T value = node.value;
freeNode(node);
return value;
}
return null;
}
public T removeLast() {
Node<T> node = last;
if (node != null) {
size--;
last = node.prev;
if (last != null) {
last.next = null;
} else {
first = null;
}
T value = node.value;
freeNode(node);
return value;
}
return null;
}
public static void main(String[] args) {
int LOOPTIMES = 10000000;
List list = new LinkedList();
long t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
list.add(i);
list.remove(0);
}
System.err.println(System.currentTimeMillis() - t1);
SillyQueue queue = new SillyQueue();
t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
queue.addFirst(i);
queue.removeLast();
}
System.err.println(System.currentTimeMillis() - t1);
IOFastQuque ioq = new IOFastQuque();
t1 = System.currentTimeMillis();
for (int i = 0; i < LOOPTIMES; i++) {
ioq.addFirst(i);
ioq.removeLast();
}
System.err.println(System.currentTimeMillis() - t1);
}
}
一次随机的运行得到的结果为:
402
396
182
这种链表的设计思想是这样的,对于list的中的元素的存取,会伴随着内部node的大量的新建与销毁。对象的创建与回收操作虽然不是什么耗时的任务(与业务代码相比),但是大量的存取行为中,使用一个node池来重复使用一些node,能减少一定的时间消耗。
当然,上面测试的结果,看上去使用了node池之后,比单纯的链表快了一倍,这主要得意与本场景中存取是相间操作的。对于存取次数比例随机,先后随机的操作,是达不到这么好的性能的。
对于消息队列这种场景,使用上面的队列的话,相对LinkedList而言是个不错的选择。
以上代码并未经过大量测试,在我写文档的过程中,还出现了一两个bug。