草庐IT

动态数组底层是如何实现的

博学谷狂野架构师 2023-04-16 原文

动态数组底层是如何实现的

引言:
提到数组,大部分脑海里一下子想到了一堆东西
int long short byte float double boolean char String
没错,他们也可以定义成数组
但是,上面都是静态的
不过,咱们今天学习的可是动态的(ArrayList 数组)
好接下来,我们一起来下面的内容

2.1 动态数组的位置

目标:

简单认识下继承关系

ArrayList继承于AbstractList,实现了List, RandomAccess, Cloneable, java.io.Serializable这些接口

从继承关系看功能

AbstractList类

AbstractList,实现了List。List接口我们都知道,提供了相关的添加、删除、修改、遍历等功能

RandmoAccess接口

ArrayList 实现了RandmoAccess接口,即提供了随机访问功能; 即list.get(i)

Cloneable接口

ArrayList 实现了Cloneable接口,即覆盖了函数clone(),能被克隆

Serializable接口

ArrayList 实现java.io.Serializable接口,这意味着ArrayList支持序列化,能通过序列化去传输

2.2.动态数组与数据结构

目标:

图解+面试题快速认识ArrayList

1) 概念介绍

ArrayList 是一个数组队列,相当于动态(扩容)数组。

我们直接来看对象头,对其有个简单认识和猜想:(com.alist.InitialList)

package com.alist;

import org.openjdk.jol.info.ClassLayout;

import java.util.ArrayList;

public class ArrayListHeader {


    public static void main(String[] args) {
        int[] i = new int[8];
        ArrayList<Integer> list = new ArrayList(8);
        //将8个int类型依次放入数组和arrayList,注意,一个int占4个字节
        for (int j = 0; j < 8; j++) {
            i[j] = j;
            list.add(j);
        }

        System.out.println(ClassLayout.parseInstance(i).toPrintable());
        System.out.println("=============");
        System.out.println(ClassLayout.parseInstance(list).toPrintable());
//        System.out.println(ClassLayout.parseClass(ArrayList.class));
    }
}

2) 执行结果分析:

从对象头,我们大致可以看出ArrayList的数据结构:

  • ArrayList底层用一个数组存储数据:elementData
  • 额外附加了几组信息:modeCount(发生修改操作的次数)、size(当前数组的长度)

等会……

  • 为什么长度不是数组里的32,而是4个字节?ArrayList的长度到底应该是多少???
  • 这个数组后面多出来俩null又是怎么回事???

(下面构造函数部分会得到详细答案)

3) 结论 & 面试题

ArrayList外围暴露出来的只是一些操作的表象,底层数据的存储和操作都是基于数组的基础上

这就意味着,它的特性和数组一样:查询快!删除插入慢。

ArrayList访问为什么那么快?

1、ArrayList底层是数组实现的

2、数组是一块连续的内存空间

3、获取数据可以直接拿地址偏移量(get(i))

因此:查询(确切的说是访问,而不是查找)的时间复杂度是O(1)

为什么删除和增加那么慢?

增删会带来元素的移动,增加数据会向后移动,删除数据会向前移动,所以影响效率

因此:插入、删除的时间复杂度是O(N)

2.3 动态数组源码深入剖析

接下来,我们从底层源码层面看ArrayList的一系列操作

先准备测试代码,下面debug用(com.alist.AList

public class AList {

    public static void main(String[] args) {
        ArrayList<String> arrayList = new ArrayList<String>(2);
        //断点跟踪add
        arrayList.add("100");
        arrayList.add("200");
        arrayList.add("300");
        arrayList.add("400");


        //断点跟踪get
//        for (int i = 0; i <arrayList.size() ; i++) {
//            arrayList.get(i);//Random
//
//        }


//        //断点跟踪remove
//        arrayList.remove(1);
//        //arrayList.remove("100");//和上面逻辑一样remove
//        System.out.println(arrayList);


//         //断点跟踪set
//        arrayList.set(1,"2000000");
//        System.out.println(arrayList);


//        //断点跟踪clear
//        arrayList.clear();
//        System.out.println(arrayList);

    }

2.3.1 源码分析之全局变量

目标:

先认识下类变量和成员变量,方便讲解源码的时候能快速理解

 private static final int DEFAULT_CAPACITY = 10;//默认的初始化容量
 private static final Object[] EMPTY_ELEMENTDATA = {};//空,对象数组,注意static,所有空arraylist共享,那不会出问题吗???(秘密在add数据时的扩容操作里……)
 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};//空,无参构造使用(1.8才有)
 transient Object[] elementData; // 当前数据对象存放的地方,注意是transient,虽然数组实现了serializable接口,但是它的数据不会被默认的ObjectOutputStream序列化,想做网络传输,自己改写writeObject和readObject方法!
 private int size;//当前数据的个数
 private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;//数组最大长度?(扩容部分有彩蛋)

小问题:

  • ArrayList可以无限大吗?到底最大是多少?
  • elementData好理解,放数据,又为啥定义两个空数组?啰嗦不?

上面的定义看似明明白白,其实背地里藏着很多不为人知的事,我们接着往下看……

2.3.2 源码分析之构造器

目标:

源码查看ArrayList的3个构造器

1)无参构造函数

如果不传入参数,则使用默认无参构建方法创建ArrayList对象,如下:

    public ArrayList() {
        //默认构造函数,很简单,就是把default empty数组赋给了data
      	//jdk8里才有DEFAULTCAPACITY_EMPTY_ELEMENTDATA这货,并且仅仅被用在了这个构造函数里
      	//官方的解释是,为了区分判断第一次add的时候,数组初始化的容量
      	//这个秘密藏在calculateCapacity里(下文会讲)
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

2)带int类型的构造函数

如果传入参数,则代表指定ArrayList的初始数组长度,传入参数如果是大于等于0,则使用用户的参数初始化,如果用户传入的参数小于0,则抛出异常,构造方法如下:

    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            //以指定容量初始化Object数组
            this.elementData = new Object[initialCapacity];//初始化容量
        } else if (initialCapacity == 0) {
            //如果指定0的话,用empty数组
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            //否则,如果是负数的话,扔一个异常出来(哪有长度为负数的??)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

3)带Collection对象的构造函数

    public ArrayList(Collection<? extends E> c) {
        //集合转换成数组
        elementData = c.toArray();
        //将data长度赋值给size属性
        if ((size = elementData.length) != 0) {
            // 官方注释:c.toArray might (incorrectly) not return Object[] (see 6260652)
            // 翻译:toArray不一定会返回Object数组,参考jdk的bug号……汗!
            // 如果不是Object数组,转成Object[]
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);//数组赋值,类型转换
        } else {
            // 如果数据为空,将empty赋给data
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

Collection构造器意味着,你可以使用以下一揽子集合对象:

总结:

1、构造器一 啥也没干,只是声明了一个空数组

2、构造器二 通过自定义的初始化容量创建数组

3、**构造器三 **接受Collection的参数,如果有数据,就转换成一个新的elementData,否则还是一个空数组

事情有这么简单吗???接着往下看!

问题来了:无参构造和0长度构造有什么区别?

用代码说话,我们把对象结构给他打出来:

package com.alist;

import org.openjdk.jol.info.ClassLayout;

import java.util.ArrayList;

public class InitialList {
    public static void main(String[] args) {
        //两种方式构建list,有什么区别?
        ArrayList list1 = new ArrayList();
        ArrayList list2 = new ArrayList(0);

        //打印对象头
        System.out.println(ClassLayout.parseInstance(list1).toPrintable());
        System.out.println(ClassLayout.parseInstance(list2).toPrintable());

        System.out.println("========");

        //add一个元素之后再来打印试试
        list1.add(1);
        list2.add(1);

        System.out.println(ClassLayout.parseInstance(list1).toPrintable());
        System.out.println(ClassLayout.parseInstance(list2).toPrintable());
    }
}

结果分析:

原理:

		//calculateCapacity
		//每次元素变动,比如add,会调用该函数判断容量情况
    private static int calculateCapacity(Object[] elementData, int minCapacity) {
      	//定义default empty数组的意义就在这里!用于扩容时判断当初采用的是哪种构造函数
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
          	//如果是无参的构造函数,用的就是该default empty
          	//那么第一次add时候,容量取default和min中较大者
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
      	//如果是另外两个构造函数,比如指定容量为5,或者初始参数collection为5
      	//那就直接返回5,一定程度上,节约了内存空间
        return minCapacity;
    }

结论:

  • 刚构造时,都是空的!add时才初始化(这里容易误解,以为默认构造器data初始化就是10)
  • 虽然list可以自动扩容,但尽量初始就预估并定义list的容量,少用无参的构造器,尤其小于10的时候
  • default empty存在的意义:判断那种构造函数来的,初始阶段节约了扩容的空间占用

2.3.3 源码分析之增加与扩容

目标:

源码分析ArrayList的增加、扩容方法

add增加与扩容调用流程图

增加源码(简单)

public boolean add(E e) {
  //确认容量,不够则扩容
  ensureCapacityInternal(size + 1);
  //将元素追加到数组的末尾去,同时计数增size++
  elementData[size++] = e;
  return true;
}

扩容源码(重点)

    private void grow(int minCapacity) {
        //minCapacity:我需要的最小长度,也就是上面的size+1
        int oldCapacity = elementData.length;//先取出旧数组大小
        int newCapacity = oldCapacity + (oldCapacity >> 1);//扩容为旧数组的1.5倍;右移一位(除以2)
        if (newCapacity - minCapacity < 0)//如果扩容1.5后还不够
            newCapacity = minCapacity;//取需求量为新长度,数组的扩容还是比较保守和吝啬!
        if (newCapacity - MAX_ARRAY_SIZE > 0)//新长度大于数组最大长度,彩蛋来了!
            newCapacity = hugeCapacity(minCapacity);//看下面的huge方法 ↓
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);//返回一个新的数组对象
    }



    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // 负数说明超出了Integer的范围,溢出了,抛异常
            throw new OutOfMemoryError();
      	//否则:返回Integer的最大值,而不是最大值-8!惊不惊奇?意不意外?
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }



    //这是为什么呢?我们一开始integer-8还有啥意义?
    //让我们返回去,看这个变量的注释:
		//翻译:有些VM会在array头上预留信息,企图大于这个值也行,但是不保证安全性,可能会溢出报错!

    /**
     * The maximum size of array to allocate.
     * Some VMs reserve some header words in an array.
     * Attempts to allocate larger arrays may result in
     * OutOfMemoryError: Requested array size exceeds VM limit
     */
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

扩容总结:

1、按1.5倍扩容,如果1.5还不够,取你想要的容量(总之保证够你用的)

2、数组最大容量是integer的max_value,但是达到这个值的时候,arraylist不保证稳定可靠!

2.3.4 源码分析之get方法、

目标:

源码分析ArrayList的get方法

get流程图解:

因为基于数组,所以极其简单

public E get(int index) { //返回list中指定位置的元素
    rangeCheck(index);//越界检查

    return elementData(index);//返回list中指定位置的元素,数组访问,贼快~
}

总结:

和java的数组用法相近

2.3.5 源码分析之remove方法

目标:

源码分析ArrayList的remove方法

数组拷贝是重点,可以借助单步调试debug查看过程

移除回顾


remove方法执行流程

remove源码解释(重点)

    public E remove(int index) {
        rangeCheck(index);//数组越界检查

        modCount++;//结构性修改次数+1
        E oldValue = elementData(index);//将要移除的元素

        int numMoved = size - index - 1;//删除指定元素后,需要左移的元素个数(graph)
        if (numMoved > 0)//如果有需要左移的元素,就移动(移动后,要删除的元素就已经被覆盖了)
          	//参数:src、src   dest、dest、移动的长度
          	//从data的index+1到data的index,也就是元素挨个前移一格,一共移动num个
            System.arraycopy(elementData, index+1, elementData, index, numMoved);
      	//左移后,最后一个位置还有值,给他搞成null,下一步gc会把对象收走,size计数减少
      	//(借助断点查看data数组的最后一个元素的值)
        elementData[--size] = null; 

        return oldValue;//返回刚才要删除的值
    }

不好理解的地方

数组变化过程(左移个数,数组合并)

   int numMoved = size - index - 1;//删除指定元素后,需要左移的元素个数(graph)
        if (numMoved > 0)//如果有需要左移的元素,就移动(移动后,该删除的元素就已经被覆盖了)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);//参数:src、src   dest、dest、移动的长度
        elementData[--size] = null; //左移后,最后一个位置就为空了;size减一,为了让GC起作用,设置为null

总结:

1、移除后 ,后面的节点通过数组拷贝的方式需要左移

2、需要注意的是:如果末端太长,remove是非常耗费性能的

2.3.6 源码分析之set方法

    public E set(int index, E element) {
      rangeCheck(index);//越界检查
      E oldValue = elementData(index);//修改前的原素质
      elementData[index] = element;//新元素赋值
      return oldValue;//返回旧的元素
    }

2.3.7 源码分析之clear方法

目标:

源码分析ArrayList的clear方法

public void clear() { //从列表中删除所有元素。该调用返回后,数组将为空    
  modCount++;//修改测试自增    
  // clear to let GC do its work    
  for (int i = 0; i < size; i++)        
    elementData[i] = null;//清除表元素    
  size = 0;//大小为0
}

总结:

清除就是设置为null、大小设置为0;设置null,方便gc

需要注意的是:

clear过后,size=0,但是table的大小并没有回缩!

2.4 动态数组常见面试题

1、哪些集合实现了List接口和Collection接口,各自的优缺点是什么

通过上面类图可用看出,List接口下有4个实现类,分别为:LinkedList、ArrayList、Vector和Stack

List只是个接口,接口也就是定义了一组规范或者api

具体的实现甚至底层存储可以是完全不同的,比如数组,链表

2、ArrayList提供了几种查询方式、效率如何?

  • Iterator迭代器遍历方式
 Integer value = null;
 Iterator iter = list.iterator();
 while (iter.hasNext()) {
     value = (Integer)iter.next();
	 }
  • 随机访问 通过索引值遍历
 Integer value = null;
 for (int i=0; i<list.size(); i++) {
     value = (Integer)list.get(i);        
 }
  • for-each循环遍历
public   void show(List<Object> list){
   list.forEach( s -> System.out.println(s));
}

关于性能:

1、数据量不大的时候,以上三种方式差不多

2、数据量不断上升的情况下for each表现不错

3、ArrayList可以存放null吗?

可以。

4、ArrayList是如何扩容的?

  • 在用无参构造来创建对象的时候其实就是创建了一个空数组,长度为0。add时先分配一个默认大小10,后续扩容,每次扩容都是原容量的1.5倍。
  • 在有参构造中,传入的参数是正整数就按照传入的参数来确定创建数组的大小。再进行扩容,每次扩容都是原容量的1.5倍

5、ArrayList是线程安全的吗?

不是

6、ArrayList插入删除一定慢么?

取决于你删除的元素离数组末端有多远

本文由传智教育博学谷 - 狂野架构师教研团队发布
如果本文对您有帮助,欢迎关注和点赞;如果您有任何建议也可留言评论或私信,您的支持是我坚持创作的动力
转载请注明出处!

有关动态数组底层是如何实现的的更多相关文章

  1. ruby - 如何使用 Nokogiri 的 xpath 和 at_xpath 方法 - 2

    我正在学习如何使用Nokogiri,根据这段代码我遇到了一些问题:require'rubygems'require'mechanize'post_agent=WWW::Mechanize.newpost_page=post_agent.get('http://www.vbulletin.org/forum/showthread.php?t=230708')puts"\nabsolutepathwithtbodygivesnil"putspost_page.parser.xpath('/html/body/div/div/div/div/div/table/tbody/tr/td/div

  2. ruby - 如何从 ruby​​ 中的字符串运行任意对象方法? - 2

    总的来说,我对ruby​​还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用

  3. python - 如何使用 Ruby 或 Python 创建一系列高音调和低音调的蜂鸣声? - 2

    关闭。这个问题是opinion-based.它目前不接受答案。想要改进这个问题?更新问题,以便editingthispost可以用事实和引用来回答它.关闭4年前。Improvethisquestion我想在固定时间创建一系列低音和高音调的哔哔声。例如:在150毫秒时发出高音调的蜂鸣声在151毫秒时发出低音调的蜂鸣声200毫秒时发出低音调的蜂鸣声250毫秒的高音调蜂鸣声有没有办法在Ruby或Python中做到这一点?我真的不在乎输出编码是什么(.wav、.mp3、.ogg等等),但我确实想创建一个输出文件。

  4. ruby-on-rails - 如何验证 update_all 是否实际在 Rails 中更新 - 2

    给定这段代码defcreate@upgrades=User.update_all(["role=?","upgraded"],:id=>params[:upgrade])redirect_toadmin_upgrades_path,:notice=>"Successfullyupgradeduser."end我如何在该操作中实际验证它们是否已保存或未重定向到适当的页面和消息? 最佳答案 在Rails3中,update_all不返回任何有意义的信息,除了已更新的记录数(这可能取决于您的DBMS是否返回该信息)。http://ar.ru

  5. ruby-on-rails - 'compass watch' 是如何工作的/它是如何与 rails 一起使用的 - 2

    我在我的项目目录中完成了compasscreate.和compassinitrails。几个问题:我已将我的.sass文件放在public/stylesheets中。这是放置它们的正确位置吗?当我运行compasswatch时,它不会自动编译这些.sass文件。我必须手动指定文件:compasswatchpublic/stylesheets/myfile.sass等。如何让它自动运行?文件ie.css、print.css和screen.css已放在stylesheets/compiled。如何在编译后不让它们重新出现的情况下删除它们?我自己编译的.sass文件编译成compiled/t

  6. ruby-on-rails - 在 Ruby 中循环遍历多个数组 - 2

    我有多个ActiveRecord子类Item的实例数组,我需要根据最早的事件循环打印。在这种情况下,我需要打印付款和维护日期,如下所示:ItemAmaintenancerequiredin5daysItemBpaymentrequiredin6daysItemApaymentrequiredin7daysItemBmaintenancerequiredin8days我目前有两个查询,用于查找maintenance和payment项目(非排他性查询),并输出如下内容:paymentrequiredin...maintenancerequiredin...有什么方法可以改善上述(丑陋的)代

  7. ruby - 如何将脚本文件的末尾读取为数据文件(Perl 或任何其他语言) - 2

    我正在寻找执行以下操作的正确语法(在Perl、Shell或Ruby中):#variabletoaccessthedatalinesappendedasafileEND_OF_SCRIPT_MARKERrawdatastartshereanditcontinues. 最佳答案 Perl用__DATA__做这个:#!/usr/bin/perlusestrict;usewarnings;while(){print;}__DATA__Texttoprintgoeshere 关于ruby-如何将脚

  8. ruby - 多次弹出/移动 ruby​​ 数组 - 2

    我的代码目前看起来像这样numbers=[1,2,3,4,5]defpop_threepop=[]3.times{pop有没有办法在一行中完成pop_three方法中的内容?我基本上想做类似numbers.slice(0,3)的事情,但要删除切片中的数组项。嗯...嗯,我想我刚刚意识到我可以试试slice! 最佳答案 是numbers.pop(3)或者numbers.shift(3)如果你想要另一边。 关于ruby-多次弹出/移动ruby​​数组,我们在StackOverflow上找到一

  9. ruby - 如何指定 Rack 处理程序 - 2

    Rackup通过Rack的默认处理程序成功运行任何Rack应用程序。例如:classRackAppdefcall(environment)['200',{'Content-Type'=>'text/html'},["Helloworld"]]endendrunRackApp.new但是当最后一行更改为使用Rack的内置CGI处理程序时,rackup给出“NoMethodErrorat/undefinedmethod`call'fornil:NilClass”:Rack::Handler::CGI.runRackApp.newRack的其他内置处理程序也提出了同样的反对意见。例如Rack

  10. ruby - 将数组的内容转换为 int - 2

    我需要读入一个包含数字列表的文件。此代码读取文件并将其放入二维数组中。现在我需要获取数组中所有数字的平均值,但我需要将数组的内容更改为int。有什么想法可以将to_i方法放在哪里吗?ClassTerraindefinitializefile_name@input=IO.readlines(file_name)#readinfile@size=@input[0].to_i@land=[@size]x=1whilex 最佳答案 只需将数组映射为整数:@land边注如果你想得到一条线的平均值,你可以这样做:values=@input[x]

随机推荐