外部排序,殺雞焉用牛刀?


上篇:http://www.cnblogs.com/foreach-break/p/external_sort.html


  • 字符集和編碼
  • 字節序
  • I/O方式
  • 內存
  • 磁盤
  • 線程/同步/異步
  • 數據特點

字符集和編碼

為什么要考慮文件的編碼?
當你將文件從阿拉伯傳到中國,告訴你的中國朋友要進行一個外部排序,你的中國朋友也許會傻:
這里寫圖片描述

上面是什么?亂碼.
你也可以這樣體驗亂碼:

echo "數" > t.txt
iconv -f UTF-8 -t UNICODE t.txt
��pe

好了,你知道了如果不知道文件的編碼,你可能會解析到亂碼.

字符集是什么?###

charset - > char-set,字符的集合.比如UNICODE、ASCII

編碼是什么?###

encoding,字符的表示.比如UTF-8、ASCII

字符集和編碼的關系

你暈了,我也暈了,ASCII碼怎么既是字符集又是編碼?

歷史上,字符集和編碼是同義詞,實際卻又不盡相同,沒有一個規范地定義,那怎么理解呢?

字符集,往往強調其所“支持”的字符范圍,集外的字符它不支持.集合就有一個邊界,邊界內的我給個表示,邊界外的我不知道怎么表示。

編碼,往往強調針對某個字符集的字符,我這么去轉換表達為機器可理解的方式-二進制,如果對某個字符集的字符,我的轉換方式和其一致,那么我既是編碼也是字符集,否則我就只是一種字符集的轉換格式。

那么,UNICODE是字符集,它所支持的字符,在它內部,也有個表示,這種表示是不是一種編碼?毫無疑問,是一種編碼。

UTF-8是一種編碼,是對UNICODE的一個變長實現,這種編碼和UNICODE編碼是什么關系?轉換關系。

所以看編碼還是字符集,往往要看“語境”。

舉個簡單的例子:

漢字:“數”


編碼/字符集 進制
UNICODE 16 6570
UNICODE 2 110010101110000
UTF-8 16 E6 95 B0
UTF-8 2 111001101001010110110000

這個例子,你可以這樣體驗:

/// 看utf-8
echo "數" > t.txt
hexdump -xv -C t.txt

0000000    95e6    0ab0                                                
00000000  e6 95 b0 0a                                       |....|
00000004

/// 看unicode
iconv -f utf-8 -t unicode t.txt | hexdump -xv -C

0000000    feff    6570    000a                                        
00000000  ff fe 70 65 0a 00                                 |..pe..|
00000006


字節序

如果你注意到漢字“數”經過UTF-8編碼后的16進制是E6 95 B0,再給它后面加個換行是E6 95 B0 0A,你用下面的方法會看到:

hexdump -xv -C t.txt

0000000    95e6    0ab0                                                
00000000  e6 95 b0 0a                                       |....|
00000004

按雙字節(16位)的方式解析,你得到是95 E6 0A B0,你一定很費解,然並卵,這種費解的根源就是字節序造成的。

字節序就是字節的順序,如果按照雙字節(16位)的方式解析E6 95 B0 0A,那么讀16位,得到E6 95,字節序反一下,得到95 E6,再讀16位,得到B0 0A,字節序反一下,得到0A B0,合起來就是95 E6 0A B0

這種“反字節序”就叫Little-Endian,小端或者小尾。

大端(Big-Endian | BE)###

這里寫圖片描述
注:圖片來自wiki

從上圖可以看出,一個數的最高字節被放在內存的低地址處:
0A -> a + 0,這種方式就是大端(Big-Endian),數的最高字節叫作Most Significant Byte,這個你可以稱作最高有效字節或者最重要的字節,為什么最重要呢?

對於1個數來講,它的最高字節能反映這個數的符號(正負),也比低位字節更能代表這個數的大小。

大端存儲的特點也決定了它的優點,就是近似估計一個數的大小和符號,只需要取最高字節即可。

小端(Little-Endian | LE)###

反之,如果一個數的最低字節被放在內存的低地址處:
0D -> a + 0,這種方式就是小端(Little-Endian),數的最低字節叫作Least Significant Byte,可以稱作最低有效字節或者最不重要的字節。

32位/64位地址下:


10進制 16進制 位數(bits)/字節數(bytes) BE LE
168496141 0A 0B 0C 0D 32/4 0A 0B 0C 0D 0D 0C 0B 0A
3085 0C 0D 16/2 0C 0D 0D 0C

很顯然,大端存儲符合我們人類從左往右書寫或閱讀的習慣,為什么又要出一個如此麻煩的小端存儲呢?

小端存儲,取數的時候可以這樣:
a + 0 -> 第一個字節
a + 1 -> 第二個字節
a + 2 -> 第三個字節
a + 3 -> 第四個字節
一個基址a,+不同的偏移即可完成不同精度的取數操作,high么?

如何區分大端還是小端?###

如果你注意到:

/// 看unicode
iconv -f utf-8 -t unicode t.txt | hexdump -xv -C

0000000    feff    6570    000a                                        
00000000  ff fe 70 65 0a 00                                 |..pe..|
00000006

那么前2個字節feff就表示了小端存儲,很簡單,fe < ff,這2個字節叫作Byte Order Mark,稱字節序標記,不僅可以用作區分文件是大端/小端存儲,還可以表示編碼。

至於編程的方式區分大端小端,這個如果你謹記字節序的含義相信不難寫出代碼:

		/// java獲取字節序BE/LE

        Field us = Unsafe.class.getDeclaredField("theUnsafe");
        us.setAccessible(true);
        Unsafe unsafe = (Unsafe)us.get(null);
        long a = unsafe.allocateMemory(8);
        ByteOrder byteOrder = null;
        try {
            unsafe.putLong(a, 0x0102030405060708L);
            byte b = unsafe.getByte(a);
            switch (b) {
            case 0x01: byteOrder = ByteOrder.BIG_ENDIAN;     break;
            case 0x08: byteOrder = ByteOrder.LITTLE_ENDIAN;  break;
            default:
                assert false;
                byteOrder = null;
            }
        } finally {
            unsafe.freeMemory(a);
        }

        System.out.println(byteOrder.toString());

用C/C++就更簡單了,一個union就搞定了.

字節序影響了什么?###

字符集和編碼如果搞錯,會導致亂碼,字節序搞錯,會導致錯碼,很顯然0C0D0D0C不是同一個數,不是么?


I/O方式

既然是對文件進行排序,文件I/O是必不可少的,那么針對java的若干經典I/O方式,有沒有值得探討的地方呢?
這里寫圖片描述

利用字符緩沖在流中讀寫文件###

經典代表就是BufferedReader/Writer,粗略的講,這種方式僅適合字符文件(雖然文件的本質都是0101)。

從BufferedReader讀一行數據,如果其字符緩沖內木有或者不夠,那么就得

BufferedReader -> StreamDecoder -> FileInputStream -> JNI/native -> read/c -> system call/kernal ...

而且,在read/c這個層面還一次只讀一個字節,雖然操作系統會盡力在block-buffer/page-cache做足功夫,通過預讀更多的物理塊來減少磁盤I/O,但是“系統調用”和內核/用戶緩沖區的數據拷貝,以及jvm的堆內堆外內存拷貝、jvm的堆內的byte[]、ByteBuffer、CharBuffer、char[]間的數據拷貝,多的亂了眼。

決定了這種方式,不會那么的高效,盡管做了字符緩沖,也只是有限地減少了調用和拷貝次數。

利用字節緩沖在FileChannel中讀寫文件###

以讀取數據為例,這種方式,比起字符緩沖+流的方式,因為面向字節,所以減少了解碼的步驟,也不會一次一個字節的搞,在read/c的層面,它“批量”的讀取數據,所以這種方式更加靈活也更加高效。

不足的是,因為面向字節,所以如果你解析的是個字符文件,那么你需要自己實現解碼的步驟。
同時,因為利用文件通道FileChannel去操作文件,所以會開辟或者從本地直接內存池(DirectBytebuffer)中開辟直接內存(DirectByteBuffer),而這種內存是一種堆外內存,所以將數據從堆外內存拷貝到堆內,這個過程不可避免。

利用字節緩沖 + mmap讀寫文件###

mmap是什么?
memory-map -> 內存映射。
這里寫圖片描述
簡單的說,mmap打通了用戶進程的虛擬內存地址和文件的訪問通道,你可以認為文件的一部分被映射進了你的進程能訪問到的一段內存中,這樣,你就可以像操作內存那樣操作文件,high么。

這種方式對比前一種文件I/O的方式,避免了顯式地read/c,也就避免了用戶/內核緩沖區的數據拷貝,所以,一般而言,這種I/O方式是高效的典范。

既然有一般而言,就有特殊,對於定長文件支持地很好,對於不定長的有增長的文件,它很無力,看下面的方法簽名:

java.nio.channels.FileChannel
public abstract java.nio.MappedByteBuffer map(FileChannel.MapMode mode,
                                              long position,
                                              long size)
                                      throws java.io.IOException

注意到positionsize,再注意到:

if (size > Integer.MAX_VALUE)
            throw new IllegalArgumentException("Size exceeds Integer.MAX_VALUE");

你就知道這種方式,一次映射的不能超過2G大小,如果定長文件超過2G,你需要多次進行map調用。
如果文件不定長滴增長,那就沒法獲知准確的size,那這種方式再高效,然並卵。
同時mmap方式很吃內存,如果你映射了一個文件的一部分,又進行了大量的隨機寫,那么許多臟頁就會造成,操作系統會定期/按需地處理這些臟頁,采用write-back方式將修改后的頁寫入磁盤。

可以想象,因為頁是隨機的,那么這種持久化的方式會引發大量的磁盤隨機I/O,這種情況,你懂的。

所以,當文件越大,被映射的size越大,隨機I/O的概率可能就越大,那么write-back的阻塞和鎖時間就會很長,你卡住了,臟頁越來越多,卡的越來越嚴重,磁盤的磁頭會很糾結,mmap就沒那么高效了,甚至會慢的讓你費解。


內存

你應該知道使用你的內存的,不僅僅是你代碼中的new,還包括你使用快排、歸並排序的調用棧,遞歸算法的調用層次越深,棧就越深,開棧消棧的開銷就會越大,所以你應該在數據集合很小的時候,進行cutoff to insertion-sort,不要認為插入排序木有用,它處理基本有序的數據和小量數據,非常高效,同時可以顯著地減少你遞歸調用的深度和開銷。

如果你使用了歸並排序,注意提前分配輔助數組的空間,而不是在遞歸調用中分配、銷毀,也應該注意到利用輔助數組和原始數組互為拷貝來減少兩者之間的數據拷貝,這樣,你的歸並排序算法會高效的多。

同時,你應該意識到這些堆棧信息很可能會被交換到swap空間,意味着你沒有預料的磁盤I/O會狠狠地拖垮你的性能。

關於內存碎片,和降低小對象、小數組的頻繁生滅,使用池的技術,能改善性能,降低碎片。

還有,intInteger占用的內存空間大小是迥異的,當然如果你使用了泛型,這一點你可以忽略。


磁盤

關於磁盤沒什么太多要討論的,這里主要是機械磁盤,應該知道順序I/O是機械磁盤的最愛,同時要注意到使用緩沖、緩存(預讀)和批處理的思想來操作數據,這樣可以降低磁盤I/O所占的時間比例。


線程/同步/異步

在上篇我們無腦而愉快地使用了單線程+同步的方式來處理外部排序問題,現在我們可以簡單討論下有沒有更好的方式。

這里寫圖片描述

關於同步/異步、阻塞/非阻塞,這里再簡單提一下:

同步/異步,關注多個對象或者線程的執行順序,是一種邏輯次序的約定關系。

阻塞/非阻塞,關注某個對象或者線程的執行狀態,一個線程進入阻塞狀態,它所被分配的cpu時間就被沒收了。

回到我們的外部排序問題上來,針對單個大文件bigdata(4663M,5億行),因為是字符文件,很難利用多線程同時讀取文件的不同部分,因為你無法確知某行數據的換行符的偏移量在第幾個字節,所以讀取采用單線程,而且能充分利用順序I/O(如果文件本身的物理塊大量連續的話)。

因為按行讀取並搜集一定數目的數據進入內存緩沖區memBuffer,並且進行了排序,所以在讀取時,會遇到阻塞的情況,iowait不可避免,這個時候,沒有其它線程在處理排序,這是對cpu的極大浪費,而且按行讀取往往不能充分讓磁盤轉起來,也浪費了I/O能力。

那么在排序的時候,因為是單線程,所以無法進行I/O,這就是糾結所在。

如果文件不是很大,這種方式簡單快捷省腦子,如果文件極大,這種方式恐怕就過於簡單粗暴了。

考慮要充分利用cpu(多核的話),並充分讓磁盤轉起來,達到I/O的最大化,我們可以在這一步省去排序操作,盡快的將大文件分割為多個無序小文件。

到這里,我們有兩種選擇:

1. 單線程、異步、非阻塞I/O###

因為是非阻塞I/O,所以線程除了被操作系統調度放棄cpu外,不會因為I/O而放棄cpu時間,
這個時間可以用來處理排序,當I/O就緒,異步回調會將數據奉送到線程的堆棧當中(memBuffer),
你就可以將這批新鮮熱乎的數據掛入待排序的隊列,然后接着處理排序。

這種方式,將線程的上下文切換時間降至最低,是cpu利用+的方式,但是無法利用多核cpu的優勢。
同時,實現顯然比較復雜。

2. 多線程、同步、阻塞I/O###

很簡單,將無序小文件平均分配給多個線程,多個線程是多少呢?
根據你的要求,如果在意吞吐量,那么就無法避免多個線程的上下文切換,開更多的線程處理吧,性能不會那么高。

如果在意性能,一般取2、4、8、16,最多不要超過cpu物理核心的2倍。

當然,並發度的高低,都得看測試的結果,上面只是經驗數值。

這種方式,建立在如下這個基礎之上:
let t1 = 大文件I/O而閑置的cpu時間(不能排序) + memBuffer排序浪費的I/O時間(不能I/O)
let t2 = 多線程上下文切換時間 + 因多個文件同時I/O造成的隨機I/O時間
如果 t2 < t1 ,那么這種方式可選。

事實上,根據實測,文件越大,這種方式的優勢越明顯,你可以試試。

文件數據特點

說了這么多,最關鍵的還是文件本身的數據特點。
如果是字符文件,采用UTF-8編碼,一個整型99999999當作字符串寫進文件,需要8個字節來表示;
而如果面向字節,大端,4個字節就夠了:

    static private int makeInt(byte b3, byte b2, byte b1, byte b0) {
        return (((b3       ) << 24) |
                ((b2 & 0xff) << 16) |
                ((b1 & 0xff) <<  8) |
                ((b0 & 0xff)      ));
    }

放在上一篇的問題中說,5億條整形數據,采用UTF-8編碼的字符方式,每行還以\n結束,總數據量在約4663M;
而采用定長4字節的方式來編碼,總數據量約1908M;
直接打了4.1折,親。

數據越小,I/O越少,時間蹭蹭地降:


文件 起始/最終 數據格式 數據量 起始/最終 文件大小(M) 排序方案 時間(s)
bigdata 字符/字符 5億 4663/4663 分為若干有序小文件,然后多路歸並排序 772
bigdata 字符/字節 5億 4663/1908 1.分為若干無序小文件;
2.四線程同步、阻塞I/O進行並發排序得到若干有序小文件;
3.多路歸並排序.
430
bigdata 字符/字符 5億 4663/4663 位圖法 191


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM