淺析java中的IO流

風吹草發表於2021-07-29

在java中IO類很龐大,初學的時候覺得傻傻分不清楚。其實java流歸根結底的原理是普通位元組流,位元組緩衝流,轉換流。最基礎的是普通位元組流,即從硬碟讀取位元組寫入到記憶體中,但在實際使用中又發現一些特殊的需求,所以java語言的設計者這引入了位元組緩衝流和轉換流。所有的java IO類對IO的處理都是基於這三種流中的一種或多種;在介紹完三種流的概念之後,會對IO流的部分java類做介紹。

1.普通位元組流

以FileInputStream為例子。FileInputStream的硬碟讀取方式分為兩種,一次讀取一個位元組和一次讀取一個位元組陣列。位元組陣列的大小不同,實際IO耗時也不同,圖1為示例程式碼,圖3展示了圖1程式碼中讀寫耗時隨位元組陣列大小的變化趨勢。隨著位元組陣列的增大,讀寫耗時減小,主要是硬碟尋道時間(seek time)和旋轉時間(rotational latency)的減少。在硬碟讀寫耗時很長時,記憶體讀寫的耗時相比硬碟讀寫可以忽略,硬碟讀寫的耗時分為尋道時間(seek time)、旋轉時間(rotational latency)和傳輸時間(transfer time),傳輸時間相對於尋道時間和旋轉時間(尋道時間和旋轉時間後合併稱為定址時間)可以忽略【1】。硬碟的定址時間在一個塊中的第一個位元組耗時長,一個塊中的其餘位元組可以忽略。當位元組陣列增大時(從32增加到1024*16 byte),定址一個塊中的第一個位元組的場景線性減少,定址時間也線性減少,因此IO耗時呈線性減少趨勢。當位元組陣列大小繼續增大(從1024 * 8增加到1024 * 1024 * 16),此時定址時間已降到很低,相比傳輸時間可以忽略時,IO耗時的變化趨於平穩。當位元組陣列大小繼續增大時,讀寫耗時又出現增大的趨勢,這個我還沒找到原因。當在陣列較大(大於1024 *1024 *4)時,read(byte[])方法中除去讀寫之外也會有其它耗時,測試程式碼如圖2,測試資料如圖3附表,這個機制我還不清楚(可能需要深入瞭解jvm的底層實現了),圖3中在計算讀寫耗時時應減去這部分時間。

public class Demo01_Copy {

	public static void main(String[] args) throws IOException {
		File src = new File ("e:\\foxit_Offline_FoxitInst.exe");
		File dest = new File("e:\\ithema\\foxit_Offline_FoxitInst.exe");

		byte[] bytes = new byte[1024*128];//調整位元組陣列的大小,看IO耗時的變化
		long time1 = System.currentTimeMillis();
		copyFile2(src,dest,bytes);
		long time2 = System.currentTimeMillis();
		System.out.println(time2 -time1);
	}
	
	public static void copyFile2(File src,File dest,byte[] bytes) throws IOException{
		InputStream in = new FileInputStream(src);
		OutputStream os = new FileOutputStream(dest);
		
		int len = 0;
		while((len = in.read(bytes))!=-1){
			os.write(bytes,0,len);
		}
		in.close();
		os.close();
	}
}

圖1 通過FileInputStream一次讀取一個位元組陣列

public class Demo02_Copy {
    public static void main(String[] args) throws IOException {
        File src = new File ("e:\\1.txt");
        File dest = new File("e:\\ithema\\1.txt");

        byte[] bytes = new byte[1024*128];//調整位元組陣列的大小,看IO耗時的變化
        long time1 = System.currentTimeMillis();
        copyFile2(src,dest,bytes);
        long time2 = System.currentTimeMillis();
        System.out.println(time2 -time1);
    }

    public static void copyFile2(File src,File dest,byte[] bytes) throws IOException{
        InputStream in = new FileInputStream(src);
        OutputStream os = new FileOutputStream(dest);

        int len = 0;
        while((len = in.read(bytes))!=-1){
            os.write(bytes,0,len);
        }
        in.close();
        os.close();
    }
}

圖 2 測試除硬碟記憶體讀寫外的其它耗時(1.txt檔案為空)

圖3 當位元組陣列大小變化,讀寫總耗時的變化趨勢(折線圖資料來源於表格中藍色背景填充的資料)

當陣列大小從32逐漸增大到1024*16byte時,IO耗時呈線性減少,這基於FileInputStream的read(byte[])實現。read(byte[])的原始碼如圖4所示,read(byte b[])是一個本地方法,它保證了硬碟的定址時間在讀取一個陣列大小的位元組塊的第一個位元組耗時較長,位元組塊的其餘位元組可以忽略。而相對於read()方法,一個位元組一個位元組讀取,每讀取一個位元組都要重新進行硬碟定址。

public class FileInputStream extends InputStream
{
    public int read(byte b[]) throws IOException {
        return readBytes(b, 0, b.length);
    }
    
     /**
     * Reads a subarray as a sequence of bytes.
     * @param b the data to be written
     * @param off the start offset in the data
     * @param len the number of bytes that are written
     * @exception IOException If an I/O error has occurred.
     */
    private native int readBytes(byte b[], int off, int len) throws IOException;
}

圖4 FileInputStream 的 read(byte[]) 方法原始碼

2.位元組緩衝流

假設現在你要寫一個程式以計算一個text檔案的行數。一種方法是使用read()方法從硬碟中一次讀取1個位元組到記憶體中,並檢查該位元組是不是換行符“\n”【2】。這種方法已被證明是低效的。

更好的方法是使用位元組緩衝流,先將位元組從硬碟一次讀取一個緩衝區大小的位元組到記憶體中的讀緩衝區,然後在從讀緩衝區中一次讀取一個位元組。在逐位元組讀取讀取緩衝區時,檢查位元組是不是換行符'\n'。位元組緩衝流BufferedInputStream的原始碼如圖5所示,先從硬碟讀取一個緩衝大小的位元組塊到緩衝區,然後逐個讀取緩衝區的位元組;當緩衝區的位元組讀取完畢後,在呼叫fill()方法填充緩衝區。位元組緩衝流BufferedInputStream的緩衝區大小為8192。圖6中對比了位元組緩衝流和普通位元組流的讀寫效率;位元組緩衝流的讀耗時僅為8ms,而沒有緩衝區的普通位元組流的耗時為567ms。圖7中展示了圖6中位元組緩衝流讀寫檔案的示意圖。

public class BufferedInputStream extends FilterInputStream {
    private static int DEFAULT_BUFFER_SIZE = 8192;
    
	public synchronized int read() throws IOException {
        //當緩衝區的位元組已被讀取完畢後,呼叫fill()方法從硬碟讀取位元組塊填充緩衝區;
        if (pos >= count) {
            fill();
            if (pos >= count)
                return -1;
        }
        //返回緩衝區的一個位元組
        return getBufIfOpen()[pos++] & 0xff;
    }

	private void fill() throws IOException {
        byte[] buffer = getBufIfOpen();
        //初始定義int markpos = -1;
        if (markpos < 0)
            pos = 0;            /* no mark: throw away the buffer */
        else if (pos >= buffer.length)  /* no room left in buffer */
            if (markpos > 0) {  /* can throw away early part of the buffer */
                int sz = pos - markpos;
                System.arraycopy(buffer, markpos, buffer, 0, sz);
                pos = sz;
                markpos = 0;
            } else if (buffer.length >= marklimit) {
                markpos = -1;   /* buffer got too big, invalidate mark */
                pos = 0;        /* drop buffer contents */
            } else if (buffer.length >= MAX_BUFFER_SIZE) {
                throw new OutOfMemoryError("Required array size too large");
            } else {            /* grow buffer */
                int nsz = (pos <= MAX_BUFFER_SIZE - pos) ?
                        pos * 2 : MAX_BUFFER_SIZE;
                if (nsz > marklimit)
                    nsz = marklimit;
                byte nbuf[] = new byte[nsz];
                System.arraycopy(buffer, 0, nbuf, 0, pos);
                if (!bufUpdater.compareAndSet(this, buffer, nbuf)) {
                    // Can't replace buf if there was an async close.
                    // Note: This would need to be changed if fill()
                    // is ever made accessible to multiple threads.
                    // But for now, the only way CAS can fail is via close.
                    // assert buf == null;
                    throw new IOException("Stream closed");
                }
                buffer = nbuf;
            }
        count = pos;
        //從硬碟讀取一個緩衝區大小的塊到緩衝區
        int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
        if (n > 0)
            count = n + pos;
    }
}

圖5 BufferedInputStream的read()方法原始碼

public class Demo03_Copy {

	public static void main(String[] args) throws IOException {
		File src = new File ("e:\\settings.xml");
		File dest = new File("e:\\ithema\\settings.xml");

		byte[] bytes = new byte[1024*128];
		long time1 = System.currentTimeMillis();
		//耗時:567 ms
        //copyFile1(src,dest);
		//耗時:8 ms
		copyFile3(src,dest);
		long time2 = System.currentTimeMillis();
		System.out.println(time2 -time1);
	}

    //使用普通位元組流
	public static void copyFile1(File src,File dest) throws IOException{
		InputStream in = new FileInputStream(src);
		OutputStream os = new FileOutputStream(dest);
		int len = 0;
		int lineSum = 1;
		while((len = in.read())!= -1){
			if(len == '\n'){
				lineSum++;
			}
			os.write(len);
		}
		System.out.println("lineSum:"+lineSum);
		in.close();
		os.close();
	}
	
    //使用位元組緩衝流
	public static void copyFile3(File src,File dest) throws IOException{
		
		InputStream in = new BufferedInputStream(new FileInputStream(src));
		OutputStream os = new BufferedOutputStream(new FileOutputStream(dest));
		
		int len = 0;
		int lineSum = 1;
		while((len = in.read())!=-1){
			if(len == '\n'){
				lineSum ++;
			}
			os.write(len);
		}
		System.out.println("lineSum:"+lineSum);
		in.close();
		os.close();
	}
}

圖6 位元組緩衝流和普通位元組流的讀寫效率對比

圖7 使用位元組緩衝流在圖6中讀寫檔案的示意圖

3.轉換流

轉換流實現了在指定的編碼方式下進行位元組編碼和字元編碼的轉換。轉換流如果直接從硬碟一次一個位元組讀取的轉換流效率也很低,所以轉換流一般都是基於位元組緩衝流的。轉換流InputStreamReader的使用如圖8所示,圖中程式碼底層的執行流程圖如圖9所示。InputStreamReader 的原始碼解析圖如圖10所示,轉碼的關鍵程式碼如圖11所示。如圖11,一個字元的字元編碼所佔位元組個數固定為2個位元組,但一個字元的字元編碼經過轉換流按UTF-8格式轉換為位元組編碼後,位元組編碼所佔位元組個數為1~4個。

public class Demo01_InputStreamReader {
	public static void main(String[] args) throws IOException {
		readUTF();
	}
    
    //一次讀取一個字元
	public static void readUTF() throws IOException{
		InputStreamReader isr = new InputStreamReader(new FileInputStream("e:\\2.txt"),"UTF-8");
		int ch = 0;
		while((ch = isr.read())!=-1){
			System.out.println((char)ch);
		}
		isr.close();
	}
}

圖8 使用轉換流InputStreamReader一次讀取一個字元

圖9 InputStreamReader在read()時的底層流程圖(檔案中的位元組編碼可通過FileInputStream讀取檢視)

圖10 InputStreamReader的read()原始碼解析圖

class UTF_8 extends Unicode{
    private CoderResult decodeArrayLoop(ByteBuffer paramByteBuffer, CharBuffer paramCharBuffer)
    {
      byte[] arrayOfByte = paramByteBuffer.array();
      int i = paramByteBuffer.arrayOffset() + paramByteBuffer.position();
      int j = paramByteBuffer.arrayOffset() + paramByteBuffer.limit();
      char[] arrayOfChar = paramCharBuffer.array();
      int k = paramCharBuffer.arrayOffset() + paramCharBuffer.position();
      int m = paramCharBuffer.arrayOffset() + paramCharBuffer.limit();
      int n = k + Math.min(j - i, m - k);
      while ((k < n) && (arrayOfByte[i] >= 0))
        arrayOfChar[(k++)] = (char)arrayOfByte[(i++)];
      while (i < j)
      {
        int i1 = arrayOfByte[i];
        if (i1 >= 0)
        {
          if (k >= m)
            return xflow(paramByteBuffer, i, j, paramCharBuffer, k, 1);
          arrayOfChar[(k++)] = (char)i1;
          i++;
        }
        else
        {
          int i2;
          if ((i1 >> 5 == -2) && ((i1 & 0x1E) != 0))
          {
            if ((j - i < 2) || (k >= m))
              return xflow(paramByteBuffer, i, j, paramCharBuffer, k, 2);
            i2 = arrayOfByte[(i + 1)];
            if (isNotContinuation(i2))
              return malformedForLength(paramByteBuffer, i, paramCharBuffer, k, 1);
            arrayOfChar[(k++)] = (char)(i1 << 6 ^ i2 ^ 0xF80);
            i += 2;
          }
          else
          {
            int i3;
            int i4;
            if (i1 >> 4 == -2)
            {
              i2 = j - i;
              if ((i2 < 3) || (k >= m))
              {
                if ((i2 > 1) && (isMalformed3_2(i1, arrayOfByte[(i + 1)])))
                  return malformedForLength(paramByteBuffer, i, paramCharBuffer, k, 1);
                return xflow(paramByteBuffer, i, j, paramCharBuffer, k, 3);
              }
              i3 = arrayOfByte[(i + 1)];
              i4 = arrayOfByte[(i + 2)];
              if (isMalformed3(i1, i3, i4))
                return malformed(paramByteBuffer, i, paramCharBuffer, k, 3);
              char c = (char)(i1 << 12 ^ i3 << 6 ^ (i4 ^ 0xFFFE1F80));
              if (Character.isSurrogate(c))
                return malformedForLength(paramByteBuffer, i, paramCharBuffer, k, 3);
              arrayOfChar[(k++)] = c;
              i += 3;
            }
            else if (i1 >> 3 == -2)
            {
              i2 = j - i;
              if ((i2 < 4) || (m - k < 2))
              {
                i1 &= 255;
                if ((i1 > 244) || ((i2 > 1) && (isMalformed4_2(i1, arrayOfByte[(i + 1)] & 0xFF))))
                  return malformedForLength(paramByteBuffer, i, paramCharBuffer, k, 1);
                if ((i2 > 2) && (isMalformed4_3(arrayOfByte[(i + 2)])))
                  return malformedForLength(paramByteBuffer, i, paramCharBuffer, k, 2);
                return xflow(paramByteBuffer, i, j, paramCharBuffer, k, 4);
              }
              i3 = arrayOfByte[(i + 1)];
              i4 = arrayOfByte[(i + 2)];
              int i5 = arrayOfByte[(i + 3)];
              int i6 = i1 << 18 ^ i3 << 12 ^ i4 << 6 ^ (i5 ^ 0x381F80);
              if ((isMalformed4(i3, i4, i5)) || (!Character.isSupplementaryCodePoint(i6)))
                return malformed(paramByteBuffer, i, paramCharBuffer, k, 4);
              arrayOfChar[(k++)] = Character.highSurrogate(i6);
              arrayOfChar[(k++)] = Character.lowSurrogate(i6);
              i += 4;
            }
            else
            {
              return malformed(paramByteBuffer, i, paramCharBuffer, k, 1);
            }
          }
        }
      }
      return xflow(paramByteBuffer, i, j, paramCharBuffer, k, 0);
    }
}

圖11 UTF_8中將位元組編碼解碼為字元編碼的方法decodeArrayLoop()

4.常用的IO類FileReader和BufferedReader

FileReader(String fileName)和InputStreamReader(new FileInputStream(String fileName))是等價的,如圖12所示,具體實現參見第3節。BufferedReader的實現與FileReader不同,它們的效能對比如圖13所示。圖14展示了BufferedReader的使用,這為了和圖7中InputStreamReader(new FileInputStream(String fileName))的使用做對比。圖14中程式碼底層的執行流程圖如圖15所示。BufferedReader的方法read()的原始碼解析圖如圖16所示。BufferedReader和FileReader在字元編碼和位元組編碼的轉換時都呼叫了CharsetDecoder.decode()方法;不同的是BufferedReader一次轉換了8192個字元(圖15),而FileReader一次只轉換了2個字元(圖9)。但由於BufferedReader和FileReader的位元組緩衝區大小於均為8192個位元組,因此BufferedReader與FileReader效率相差不大。

public class FileReader extends InputStreamReader {
    public FileReader(String fileName) throws FileNotFoundException {
        super(new FileInputStream(fileName));
    }
}

圖12 FileReader(String filePath)的構造方法

public class Demo01_Copy {

	public static void main(String[] args) throws IOException {
		File src = new File ("e:\\foxit_Offline_FoxitInst.exe");
		File dest = new File("e:\\ithema\\foxit_Offline_FoxitInst.exe");

		long time1 = System.currentTimeMillis();
        //耗時:3801 ms
		//copyFile5(src,dest,bytes);
		//耗時:2938 ms
		copyFile6(src,dest);
		long time2 = System.currentTimeMillis();
		System.out.println(time2 -time1);
	}


	public static void copyFile5(File src ,File dest) throws IOException {
		FileReader fr = new FileReader(src);
		FileWriter fw = new FileWriter(dest);
		int len = 0;
		while((len=fr.read())!=-1){
			fw.write(len);
		}
		fr.close();
		fw.close();
	}

	public static void copyFile6(File src,File dest) throws IOException{
		BufferedReader br = new BufferedReader(new FileReader(src));
		BufferedWriter bw = new BufferedWriter(new FileWriter(dest));
		int len = 0;
		while((len=br.read())!=-1){
			bw.write(len);
		}
		br.close();
		bw.close();
	}
}

圖13 FileReader和BufferedReader的效能對比

public class Demo01_BufferedReader {
	public static void main(String[] args) throws IOException {
		readUTF();
	}
    
    //一次讀取一個字元
	public static void readUTF() throws IOException{
		BufferedReader br = new BufferedReader(
            new InputStreamReader(new FileInputStream("e:\\2.txt"),"UTF-8"));
		int ch = 0;
		while((ch = br.read())!=-1){
			System.out.println((char)ch);
		}
		br.close();
	}
}

圖14 使用BufferedReader一次讀取一個字元(與圖7做對比)

圖15 BufferedReader在read()時的底層流程圖(與圖8做對比)

圖16 BufferedReader的read()原始碼解析圖(與圖9做對比)

5.總結

普通位元組流是基礎,是最簡單高效的流。如果沒有特殊的需求,只是高效的進行檔案讀寫,選擇合適的位元組陣列大小,一次從硬碟讀取一個位元組陣列大小的位元組塊,其效率是最高的。

位元組緩衝流是為行數統計,按行讀取等特殊需求而設計的。相比於直接從硬碟一次讀取一個位元組;先從硬碟一次讀取一個緩衝區大小的位元組塊到緩衝區(位於記憶體),再從緩衝區一個位元組一個位元組的讀取並判斷是不是行末尾('\n')的效率更高。

轉換流實現了在指定的編碼方式下進行位元組編碼和字元編碼的轉換。轉換流如果直接從硬碟一次一個位元組讀取的轉換流效率也很低,所以轉換流一般都是基於位元組緩衝流的。

參考資料:

【1】Computer Systems A Programmers Perspective.3rd->Section 6.1 Storage Technologies->Disk Operation;

【2】Computer Systems A Programmers Perspective.3rd->Section 10.5.2 Rio Buffered Input Functions;

附件:

文中測試用的檔案1.txt,2.txt,foxit_Offline_FoxitInst.exe,settings.xml

相關文章