天天看點

Java集合詳解8:Java集合類細節精講,細節決定成敗

《Java集合詳解系列》是我在完成夯實Java基礎篇的系列部落格後準備開始寫的新系列。

這些文章将整理到我在GitHub上的《Java面試指南》倉庫,更多精彩内容請到我的倉庫裡檢視

https://github.com/h2pl/Java-Tutorial

喜歡的話麻煩點下Star、fork哈

文章首發于我的個人部落格:

www.how2playlife.com

今天我們來探索一下Java集合類中的一些技術細節。主要是對一些比較容易被遺漏和誤解的知識點做一些講解和補充。可能不全面,還請諒解。

初始容量

集合是我們在Java程式設計中使用非常廣泛的,它就像大海,海納百川,像萬能容器,盛裝萬物,而且這個大海,萬能容器還可以無限變大(如果條件允許)。當這個海、容器的量變得非常大的時候,它的初始容量就會顯得很重要了,因為挖海、擴容是需要消耗大量的人力物力财力的。

同樣的道理,Collection的初始容量也顯得異常重要。是以:對于已知的情景,請為集合指定初始容量。

public static void main(String[] args) {
    StudentVO student = null;
    long begin1 = System.currentTimeMillis();
    List<StudentVO> list1 = new ArrayList<>();
    for(int i = 0 ; i < 1000000; i++){
        student = new StudentVO(i,"chenssy_"+i,i);
        list1.add(student);
    }
    long end1 = System.currentTimeMillis();
    System.out.println("list1 time:" + (end1 - begin1));
    
    long begin2 = System.currentTimeMillis();
    List<StudentVO> list2 = new ArrayList<>(1000000);
    for(int i = 0 ; i < 1000000; i++){
        student = new StudentVO(i,"chenssy_"+i,i);
        list2.add(student);
    }
    long end2 = System.currentTimeMillis();
    System.out.println("list2 time:" + (end2 - begin2));
}           

上面代碼兩個list都是插入1000000條資料,隻不過list1沒有沒有申請初始化容量,而list2初始化容量1000000。那運作結果如下:

list1 time:1638
list2 time:921
           

從上面的運作結果我們可以看出list2的速度是list1的兩倍左右。在前面LZ就提過,ArrayList的擴容機制是比較消耗資源的。我們先看ArrayList的add方法:

public boolean add(E e) {  
        ensureCapacity(size + 1);   
        elementData[size++] = e;  
        return true;  
    }  

public void ensureCapacity(int minCapacity) {  
    modCount++;         //修改計數器
    int oldCapacity = elementData.length;    
    //目前需要的長度超過了數組長度,進行擴容處理
    if (minCapacity > oldCapacity) {  
        Object oldData[] = elementData;  
        //新的容量 = 舊容量 * 1.5 + 1
        int newCapacity = (oldCapacity * 3)/2 + 1;  
            if (newCapacity < minCapacity)  
                newCapacity = minCapacity;  
      //數組拷貝,生成新的數組 
      elementData = Arrays.copyOf(elementData, newCapacity);  
    }  
}           

ArrayList每次新增一個元素,就會檢測ArrayList的目前容量是否已經到達臨界點,如果到達臨界點則會擴容1.5倍。然而ArrayList的擴容以及數組的拷貝生成新的數組是相當耗資源的。是以若我們事先已知集合的使用場景,知道集合的大概範圍,我們最好是指定初始化容量,這樣對資源的利用會更加好,尤其是大資料量的前提下,效率的提升和資源的利用會顯得更加具有優勢。

asList的缺陷

在實際開發過程中我們經常使用asList講數組轉換為List,這個方法使用起來非常友善,但是asList方法存在幾個缺陷:

避免使用基本資料類型數組轉換為清單

使用8個基本類型數組轉換為清單時會存在一個比較有味的缺陷。先看如下程式:

public static void main(String[] args) {
        int[] ints = {1,2,3,4,5};
        List list = Arrays.asList(ints);
        System.out.println("list'size:" + list.size());
    }
------------------------------------
outPut:
list'size:1           

程式的運作結果并沒有像我們預期的那樣是5而是逆天的1,這是什麼情況?先看源碼:

public static <T> List<T> asList(T... a) {
        return new ArrayList<>(a);
    }
           

asList接受的參數是一個泛型的變長參數,我們知道基本資料類型是無法發型化的,也就是說8個基本類型是無法作為asList的參數的, 要想作為泛型參數就必須使用其所對應的包裝類型。但是這個這個執行個體中為什麼沒有出錯呢?

因為該執行個體是将int類型的數組當做其參數,而在Java中數組是一個對象,它是可以泛型化的。是以該例子是不會産生錯誤的。既然例子是将整個int類型的數組當做泛型參數,那麼經過asList轉換就隻有一個int 的清單了。如下:

public static void main(String[] args) {
    int[] ints = {1,2,3,4,5};
    List list = Arrays.asList(ints);
    System.out.println("list 的類型:" + list.get(0).getClass());
    System.out.println("list.get(0) == ints:" + list.get(0).equals(ints));
}           

outPut:

list 的類型:class [I

list.get(0) == ints:true

從這個運作結果我們可以充分證明list裡面的元素就是int數組。弄清楚這點了,那麼修改方法也就一目了然了:将int 改變為Integer。

public static void main(String[] args) {
        Integer[] ints = {1,2,3,4,5};
        List list = Arrays.asList(ints);
        System.out.println("list'size:" + list.size());
        System.out.println("list.get(0) 的類型:" + list.get(0).getClass());
        System.out.println("list.get(0) == ints[0]:" + list.get(0).equals(ints[0]));
    }
----------------------------------------
outPut:
list'size:5
list.get(0) 的類型:class java.lang.Integer
list.get(0) == ints[0]:true
           

asList産生的清單不可操作

對于上面的執行個體我們再做一個小小的修改:

public static void main(String[] args) {
        Integer[] ints = {1,2,3,4,5};
        List list = Arrays.asList(ints);
        list.add(6);
    }
           

該執行個體就是講ints通過asList轉換為list 類别,然後再通過add方法加一個元素,這個執行個體簡單的不能再簡單了,但是運作結果呢?打出我們所料:

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.AbstractList.add(Unknown Source)
    at java.util.AbstractList.add(Unknown Source)
    at com.chenssy.test.arrayList.AsListTest.main(AsListTest.java:10)
           

運作結果盡然抛出UnsupportedOperationException異常,該異常表示list不支援add方法。這就讓我們郁悶了,list怎麼可能不支援add方法呢?難道jdk腦袋堵塞了?我們再看asList的源碼:

public static <T> List<T> asList(T... a) {
        return new ArrayList<>(a);
    }           

asList接受參數後,直接new 一個ArrayList,到這裡看應該是沒有錯誤的啊?别急,再往下看:

private static class ArrayList<E> extends AbstractList<E>
    implements RandomAccess, java.io.Serializable{
        private static final long serialVersionUID = -2764017481108945198L;
        private final E[] a;

        ArrayList(E[] array) {
            if (array==null)
                throw new NullPointerException();
            a = array;
        }
        //.................
    }           

這是ArrayList的源碼,從這裡我們可以看出,此ArrayList不是java.util.ArrayList,他是Arrays的内部類。

該内部類提供了size、toArray、get、set、indexOf、contains方法,而像add、remove等改變list結果的方法從AbstractList父類繼承過來,同時這些方法也比較奇葩,它直接抛出UnsupportedOperationException異常:

public boolean add(E e) {
        add(size(), e);
        return true;
    }
    
    public E set(int index, E element) {
        throw new UnsupportedOperationException();
    }
    
    public void add(int index, E element) {
        throw new UnsupportedOperationException();
    }
    
    public E remove(int index) {
        throw new UnsupportedOperationException();
    }
           

通過這些代碼可以看出asList傳回的清單隻不過是一個披着list的外衣,它并沒有list的基本特性(變長)。該list是一個長度不可變的清單,傳入參數的數組有多長,其傳回的清單就隻能是多長。是以::不要試圖改變asList傳回的清單,否則你會自食苦果。

subList的缺陷

我們經常使用subString方法來對String對象進行分割處理,同時我們也可以使用subList、subMap、subSet來對List、Map、Set進行分割處理,但是這個分割存在某些瑕疵。

subList傳回僅僅隻是一個視圖

首先我們先看如下執行個體:

public static void main(String[] args) {

List<Integer> list1 = new ArrayList<Integer>();
    list1.add(1);
    list1.add(2);
    
    //通過構造函數建立一個包含list1的清單 list2
    List<Integer> list2 = new ArrayList<Integer>(list1);
    
    //通過subList生成一個與list1一樣的清單 list3
    List<Integer> list3 = list1.subList(0, list1.size());
    
    //修改list3
    list3.add(3);
    
    System.out.println("list1 == list2:" + list1.equals(list2));
    System.out.println("list1 == list3:" + list1.equals(list3));
}
           

這個例子非常簡單,無非就是通過構造函數、subList重新生成一個與list1一樣的list,然後修改list3,最後比較list1 == list2?、list1 == list3?。

按照我們正常的思路應該是這樣的:因為list3通過add新增了一個元素,那麼它肯定與list1不等,而list2是通過list1構造出來的,是以應該相等,是以結果應該是:

list1 == list2:true
list1 == list3: false           

首先我們先不論結果的正确與否,我們先看subList的源碼:

public List<E> subList(int fromIndex, int toIndex) {
        subListRangeCheck(fromIndex, toIndex, size);
        return new SubList(this, 0, fromIndex, toIndex);
}
           

subListRangeCheck方式是判斷fromIndex、toIndex是否合法,如果合法就直接傳回一個subList對象,注意在産生該new該對象的時候傳遞了一個參數 this ,該參數非常重要,因為他代表着原始list。

/**

* 繼承AbstractList類,實作RandomAccess接口
 */
private class SubList extends AbstractList<E> implements RandomAccess {
    private final AbstractList<E> parent;    //清單
    private final int parentOffset;   
    private final int offset;
    int size;

    //構造函數
    SubList(AbstractList<E> parent,
            int offset, int fromIndex, int toIndex) {
        this.parent = parent;
        this.parentOffset = fromIndex;
        this.offset = offset + fromIndex;
        this.size = toIndex - fromIndex;
        this.modCount = ArrayList.this.modCount;
    }

    //set方法
    public E set(int index, E e) {
        rangeCheck(index);
        checkForComodification();
        E oldValue = ArrayList.this.elementData(offset + index);
        ArrayList.this.elementData[offset + index] = e;
        return oldValue;
    }

    //get方法
    public E get(int index) {
        rangeCheck(index);
        checkForComodification();
        return ArrayList.this.elementData(offset + index);
    }

    //add方法
    public void add(int index, E e) {
        rangeCheckForAdd(index);
        checkForComodification();
        parent.add(parentOffset + index, e);
        this.modCount = parent.modCount;
        this.size++;
    }

    //remove方法
    public E remove(int index) {
        rangeCheck(index);
        checkForComodification();
        E result = parent.remove(parentOffset + index);
        this.modCount = parent.modCount;
        this.size--;
        return result;
    }
}
           

該SubLsit是ArrayList的内部類,它與ArrayList一樣,都是繼承AbstractList和實作RandomAccess接口。同時也提供了get、set、add、remove等list常用的方法。但是它的構造函數有點特殊,在該構造函數中有兩個地方需要注意:

1、this.parent = parent;而parent就是在前面傳遞過來的list,也就是說this.parent就是原始list的引用。

2、this.offset = offset + fromIndex;this.parentOffset = fromIndex;。同時在構造函數中它甚至将modCount(fail-fast機制)傳遞過來了。

我們再看get方法,在get方法中return ArrayList.this.elementData(offset + index);

這段代碼可以清晰表明get所傳回就是原清單offset + index位置的元素。同樣的道理還有add方法裡面的:

parent.add(parentOffset + index, e);

this.modCount = parent.modCount;

remove方法裡面的

E result = parent.remove(parentOffset + index);

誠然,到了這裡我們可以判斷subList傳回的SubList同樣也是AbstractList的子類,同時它的方法如get、set、add、remove等都是在原清單上面做操作,它并沒有像subString一樣生成一個新的對象。

是以subList傳回的隻是原清單的一個視圖,它所有的操作最終都會作用在原清單上。

那麼從這裡的分析我們可以得出上面的結果應該恰恰與我們上面的答案相反:

list1 == list2:false

list1 == list3:true

subList生成子清單後,不要試圖去操作原清單

從上面我們知道subList生成的子清單隻是原清單的一個視圖而已,如果我們操作子清單它産生的作用都會在原清單上面表現,但是如果我們操作原清單會産生什麼情況呢?

List<Integer> list1 = new ArrayList<Integer>();
    list1.add(1);
    list1.add(2);
    
    //通過subList生成一個與list1一樣的清單 list3
    List<Integer> list3 = list1.subList(0, list1.size());
    //修改list1
    list1.add(3);
    
    System.out.println("list1'size:" + list1.size());
    System.out.println("list3'size:" + list3.size());
}           

該執行個體如果不産生意外,那麼他們兩個list的大小都應該都是3,但是偏偏事與願違,事實上我們得到的結果是這樣的:

list1'size:3
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$SubList.checkForComodification(Unknown Source)
    at java.util.ArrayList$SubList.size(Unknown Source)
    at com.chenssy.test.arrayList.SubListTest.main(SubListTest.java:17)           

list1正常輸出,但是list3就抛出ConcurrentModificationException異常,看過我另一篇部落格的同仁肯定對這個異常非常,fail-fast?不錯就是fail-fast機制,在fail-fast機制中,LZ花了很多力氣來講述這個異常,是以這裡LZ就不對這個異常多講了。我們再看size方法:

public int size() {
            checkForComodification();
            return this.size;
        }           

size方法首先會通過checkForComodification驗證,然後再傳回this.size。

private void checkForComodification() {
            if (ArrayList.this.modCount != this.modCount)
                throw new ConcurrentModificationException();
        }           

該方法表明當原清單的modCount與this.modCount不相等時就會抛出ConcurrentModificationException。

同時我們知道modCount 在new的過程中 "繼承"了原清單modCount,隻有在修改該清單(子清單)時才會修改該值(先表現在原清單後作用于子清單)。

而在該執行個體中我們是操作原清單,原清單的modCount當然不會反應在子清單的modCount上啦,是以才會抛出該異常。

對于子清單視圖,它是動态生成的,生成之後就不要操作原清單了,否則必然都導緻視圖的不穩定而抛出異常。最好的辦法就是将原清單設定為隻讀狀态,要操作就操作子清單:

//通過subList生成一個與list1一樣的清單 list3

List<Integer> list3 = list1.subList(0, list1.size());
           

//對list1設定為隻讀狀态

list1 = Collections.unmodifiableList(list1);
           

推薦使用subList處理局部清單

在開發過程中我們一定會遇到這樣一個問題:擷取一堆資料後,需要删除某段資料。例如,有一個清單存在1000條記錄,我們需要删除100-200位置處的資料,可能我們會這樣處理:

for(int i = 0 ; i < list1.size() ; i++){
   if(i >= 100 && i <= 200){
       list1.remove(i);
       /*
        * 當然這段代碼存在問題,list remove之後後面的元素會填充上來,
         * 是以需要對i進行簡單的處理,當然這個不是這裡讨論的問題。
         */
   }
}           

這個應該是我們大部分人的處理方式吧,其實還有更好的方法,利用subList。在前面LZ已經講過,子清單的操作都會反映在原清單上。是以下面一行代碼全部搞定:

list1.subList(100, 200).clear();           

簡單而不失華麗!!!!!

保持compareTo和equals同步

在Java中我們常使用Comparable接口來實作排序,其中compareTo是實作該接口方法。我們知道compareTo傳回0表示兩個對象相等,傳回正數表示大于,傳回負數表示小于。同時我們也知道equals也可以判斷兩個對象是否相等,那麼他們兩者之間是否存在關聯關系呢?

public class Student implements Comparable<Student>{
    private String id;
    private String name;
    private int age;
    
    public Student(String id,String name,int age){
        this.id = id;
        this.name = name;
        this.age = age;
    }

    public boolean equals(Object obj){
        if(obj == null){
            return false;
        }
        
        if(this == obj){
            return true;
        }
        
        if(obj.getClass() != this.getClass()){
            return false;
        }
        
        Student student = (Student)obj;
        if(!student.getName().equals(getName())){
            return false;
        }
        
        return true;
    }
    
    public int compareTo(Student student) {
        return this.age - student.age;
    }

    /** 省略getter、setter方法 */
}           

Student類實作Comparable接口和實作equals方法,其中compareTo是根據age來比對的,equals是根據name來比對的。

public static void main(String[] args){
        List<Student> list = new ArrayList<>();
        list.add(new Student("1", "chenssy1", 24));
        list.add(new Student("2", "chenssy1", 26));
        
        Collections.sort(list);   //排序
        
        Student student = new Student("2", "chenssy1", 26);
        
        //檢索student在list中的位置
        int index1 = list.indexOf(student);
        int index2 = Collections.binarySearch(list, student);
        
        System.out.println("index1 = " + index1);
        System.out.println("index2 = " + index2);
    }
           

按照正常思路來說應該兩者index是一緻的,因為他們檢索的是同一個對象,但是非常遺憾,其運作結果:

index1 = 0

index2 = 1

為什麼會産生這樣不同的結果呢?這是因為indexOf和binarySearch的實作機制不同。

indexOf是基于equals來實作的隻要equals傳回TRUE就認為已經找到了相同的元素。

而binarySearch是基于compareTo方法的,當compareTo傳回0 時就認為已經找到了該元素。

在我們實作的Student類中我們覆寫了compareTo和equals方法,但是我們的compareTo、equals的比較依據不同,一個是基于age、一個是基于name。

比較依據不同那麼得到的結果很有可能會不同。是以知道了原因,我們就好修改了:将兩者之間的比較依據保持一緻即可。

對于compareTo和equals兩個方法我們可以總結為:compareTo是判斷元素在排序中的位置是否相等,equals是判斷元素是否相等,既然一個決定排序位置,一個決定相等,是以我們非常有必要確定當排序位置相同時,其equals也應該相等。

使其相等的方式就是兩者應該依附于相同的條件。當compareto相等時equals也應該相等,而compareto不相等時equals不應該相等,并且compareto依據某些屬性來決定排序。

參考文章

https://www.cnblogs.com/galibujianbusana/p/6600226.html http://blog.itpub.net/69906029/viewspace-2641300/ https://www.cnblogs.com/itxiaok/p/10356553.html

微信公衆号

Java技術江湖

如果大家想要實時關注我更新的文章以及分享的幹貨的話,可以關注我的公衆号【Java技術江湖】一位阿裡 Java 工程師的技術小站,作者黃小斜,專注 Java 相關技術:SSM、SpringBoot、MySQL、分布式、中間件、叢集、Linux、網絡、多線程,偶爾講點Docker、ELK,同時也分享技術幹貨和學習經驗,緻力于Java全棧開發!

Java工程師必備學習資源: 一些Java工程師常用學習資源,關注公衆号後,背景回複關鍵字 “Java” 即可免費無套路擷取。

Java集合詳解8:Java集合類細節精講,細節決定成敗

個人公衆号:黃小斜

黃小斜是跨考軟體工程的 985 碩士,自學 Java 兩年,拿到了 BAT 等近十家大廠 offer,從技術小白成長為阿裡工程師。

作者專注于 JAVA 後端技術棧,熱衷于分享程式員幹貨、學習經驗、求職心得和程式人生,目前黃小斜的CSDN部落格有百萬+通路量,知乎粉絲2W+,全網已有10W+讀者。

黃小斜是一個斜杠青年,堅持學習和寫作,相信終身學習的力量,希望和更多的程式員交朋友,一起進步和成長!關注公衆号【黃小斜】後回複【原創電子書】即可領取我原創的電子書《菜鳥程式員修煉手冊:從技術小白到阿裡巴巴Java工程師》

程式員3T技術學習資源: 一些程式員學習技術的資源大禮包,關注公衆号後,背景回複關鍵字 “資料” 即可免費無套路擷取。

Java集合詳解8:Java集合類細節精講,細節決定成敗