靈魂拷問:你真的理解System.out.println()列印原理嗎?

朱季謙 發表於 2020-10-17

原創/朱季謙

 靈魂拷問,這位獨秀同學,你會這道題嗎?

 請說說,“System.out.println()”原理......

靈魂拷問:你真的理解System.out.println()列印原理嗎?
這應該是剛開始學習Java時用到最多一段程式碼,迄今為止,與它算是老朋友了。既然是老朋友,就應該多去深入瞭解下其“內心”深處的“真正想法”。

在深入瞭解之前,先給自己提幾個問題:

System是什麼?out是什麼?println又是什麼?三個程式碼組成為何能實現列印資訊的功能?

接下來,我們就帶著問題,去熟悉我們這位相處已久的老夥計。

 

先從System開始一步一步探究。

在百度百科上,有對System做了這樣的說明:System類代表系統,其中系統級的很多屬性和控制方法都放置在該類的內部。

簡而意之,該類與系統有關,可獲取系統內部的眾多屬性以及方法,其部分原始碼如下:

 1 public final class System {
 2     private static native void registerNatives();
 3     static {
 4         registerNatives();
 5     }
 6     private System() {
 7     }
 8     public final static InputStream in = null;
 9     public final static PrintStream out = null;
10     public final static PrintStream err = null;
11     private static volatile SecurityManager security = null;
12     public static void setIn(InputStream in) {
13         checkIO();
14         setIn0(in);
15     }
16     public static void setOut(PrintStream out) {
17         checkIO();
18         setOut0(out);
19     }
20     ......
21  }

 開啟原始碼,發現這是一個final定義的類,其次,該類的構造器是以private許可權進行定義的。根據這兩情況可以說明,該類即不能被繼承也無法例項化成物件,同時需注意一點,就是這個類裡定義的很多變數和方法都是static來定義的,即這些類成員都是屬於類而非物件。

因此,若需呼叫類中的這些帶static定義的屬性或者方法,無需建立物件就能直接通過“類名.成員名”來呼叫。

在System原始碼中,需要留意的是in,out,or三者,它們分別代表標準輸入流,標準輸出流,標準錯誤輸出流。

image

到這一步,便可以逐漸看到System.out.println中的影子,沒錯,這行程式碼裡的System.out,即為引用System類裡靜態成員out,它是PrintStream型別的引用變數,稱為"位元組輸出流"。作為static定義的out引用變數,它在類載入時就被初始化了,初始化後,會建立PrintStream物件對out賦值,之後便能呼叫PrintStream類中定義的方法。

具體怎麼建立PrintStream並賦值給靜態成員out,我放在本文後面講解。

接著,進入到PrintStream類當中——

 1 public class PrintStream extends FilterOutputStream
 2     implements Appendable, Closeable
 3 {
 4 ......
 5  public void println() {
 6         newLine();
 7     }
 8 
 9     public void println(boolean x) {
10         synchronized (this) {
11             print(x);
12             newLine();
13         }
14     }
15 
16     public void println(char x) {
17         synchronized (this) {
18             print(x);
19             newLine();
20         }
21     }
22 
23     public void println(int x) {
24         synchronized (this) {
25             print(x);
26             newLine();
27         }
28     }
29 
30     public void println(long x) {
31         synchronized (this) {
32             print(x);
33             newLine();
34         }
35     }
36 
37     public void println(float x) {
38         synchronized (this) {
39             print(x);
40             newLine();
41         }
42     }
43 
44     public void println(double x) {
45         synchronized (this) {
46             print(x);
47             newLine();
48         }
49     }
50 
51     public void println(char x[]) {
52         synchronized (this) {
53             print(x);
54             newLine();
55         }
56     }
57 
58     public void println(String x) {
59         synchronized (this) {
60             print(x);
61             newLine();
62         }
63     }
64 
65   ......
66 }

發現這PrintStream裡邊存在諸多以println名字命名的過載方法。

這個,就是我們本文中最後需要回答的問題,即println是什麼?

它其實是PrintStream列印輸出流類裡的方法。

每個有傳參的println方法裡,其最後呼叫的方法都是print()與newLine()。

值得注意一點,這些帶有傳參的println方法當中,裡面都是通過同步synchronized來修飾,這說明System.out.println其實是執行緒安全的。同時還有一點需注意,在多執行緒情況下,當大量方法執行同一個println列印時,其synchronized同步效能效率都可能出現嚴重效能問題。因此,在實際生產上,普遍是用log.info()類似方式來列印日誌而不會用到System.out.println。

在以上程式碼裡,其中 newLine()是代表列印換行的意思。

眾所周知,以System.out.println()來列印資訊時,每條列印資訊都會換行的,之所以會出現換行,其原理就是println()內部通過newLine()方法實現的。

若換成System.out.print()來列印,則不會出現換行情況。

為什麼print()不會出現換行呢?

分析一下print()裡程式碼便可得知,是因為其方法裡並沒有呼叫newLine()方法來實現換行的——

 1 public void print(boolean b) {
 2     write(b ? "true" : "false");
 3 }
 4 
 5 public void print(char c) {
 6     write(String.valueOf(c));
 7 }
 8 
 9 public void print(int i) {
10     write(String.valueOf(i));
11 }
12 
13 public void print(long l) {
14     write(String.valueOf(l));
15 }
16 
17 public void print(float f) {
18     write(String.valueOf(f));
19 }
20 
21 public void print(double d) {
22     write(String.valueOf(d));
23 }
24 
25 public void print(char s[]) {
26     write(s);
27 }
28 
29 
30 public void print(String s) {
31     if (s == null) {
32         s = "null";
33     }
34     write(s);
35 }

這些過載方法裡面都呼叫相同的write()方法,值得注意的是,在呼叫write()時,部分方法的實現是都把引數轉換成了String字串型別,之後進入到write()方法詳情裡——

 1 private void write(String s) {
 2     try {
 3         synchronized (this) {
 4             ensureOpen();
 5             textOut.write(s);
 6             textOut.flushBuffer();
 7             charOut.flushBuffer();
 8             if (autoFlush && (s.indexOf('\n') >= 0))
 9                 out.flush();
10         }
11     }
12     catch (InterruptedIOException x) {
13         Thread.currentThread().interrupt();
14     }
15     catch (IOException x) {
16         trouble = true;
17     }
18 }

其中,ensureOpen()的方法是判斷out流是否已經開啟,其詳細方法如下:

1 private void ensureOpen() throws IOException {
2     if (out == null)
3         throw new IOException("Stream closed");
4 }

 由方法可得知,在進行寫入列印資訊時,需判斷PrintStream流是否已經開啟,若沒有開啟,則無法將列印資訊寫入計算機,故而丟擲說明流是關閉狀態的異常提示:“Stream closed”

若流是開啟的,即可執行 textOut.write(s);

根據個人理解,這裡的textOut是BufferedWriter引用變數,即為常說的IO流裡寫入流,最終會將資訊寫入到控制檯上,即我們平常說的控制檯列印。可以理解成,控制檯就是一個檔案,但是能被我們實時看到裡面是什麼的檔案,這樣當每次寫入東西時,就會實時呈現在檔案裡,也就是能被我們看到的控制檯列印資訊。

那麼,問題來了,哪行程式碼是表示寫入到控制檯檔案的呢?System、out、println又是如何組成到一起來起作用的?

讓我們回到System類最開始的地方——

 1 public final class System {
 2 
 3     /* register the natives via the static initializer.
 4      *
 5      * VM will invoke the initializeSystemClass method to complete
 6      * the initialization for this class separated from clinit.
 7      * Note that to use properties set by the VM, see the constraints
 8      * described in the initializeSystemClass method.
 9      */
10     private static native void registerNatives();
11     static {
12         registerNatives();
13     }
14 
15 }

 以上的靜態程式碼會在類的初始化階段被初始化,其會呼叫一個native方法registerNatives()。根據該方法的英文註釋“VM will invoke the initializeSystemClass method to complete”,可知,VM將呼叫initializeSystemClass方法來完成該類初始化。

我們找到該initializeSystemClass方法,下面只列出本文需要用到的核心程式碼,稍微做了一下注釋:

 1 private static void initializeSystemClass() {
 2      //被vm執行系統屬性初始化
 3     props = new Properties();
 4     initProperties(props); 
 5     sun.misc.VM.saveAndRemoveProperties(props);
 6 
 7     //從系統屬性中獲取系統相關的換行符,賦值給變數lineSeparator
 8     lineSeparator = props.getProperty("line.separator");
 9     sun.misc.Version.init();
10     //分別建立in、out、err的例項物件,並通過set()方法初始化
11     FileInputStream fdIn = new FileInputStream(FileDescriptor.in);
12     FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
13     FileOutputStream fdErr = new FileOutputStream(FileDescriptor.err);
14     setIn0(new BufferedInputStream(fdIn));
15     setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));
16     setErr0(newPrintStream(fdErr, props.getProperty("sun.stderr.encoding")));
17 
18     ......
19 }

主要關注這兩行程式碼:

1  FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
2  setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));

一.這裡逐行進行分析,首先FileDescriptor是一個“檔案描述符”,可以通俗地把它當成一個檔案,它有以下三個屬性:

  1. in:標準輸入(鍵盤)的描述符

  2. out:標準輸出(螢幕)的描述符

  3. err:標準錯誤輸出(螢幕)的描述符

FileDescriptor.out代表為“標準輸出(螢幕)”,可以通俗地理解成標準輸出到控制檯的檔案,即表示控制檯。

new FileOutputStream(FileDescriptor.out)該行程式碼即說明通過檔案輸出流將資訊輸出到螢幕即控制檯上。

若還是不理解,可舉一個比較常見的例子——

1 public static void main(String[] args) throws IOException {
2         FileOutputStream out=new FileOutputStream("C:\\file.txt");
3         out.write(66);
4 }

這是比較簡單的通過FileOutputStream輸出流寫入檔案的寫法,這裡的路徑“C:\file.txt”就與FileDescriptor.out做法類似,都是描述一個可寫入資料的檔案,只不過FileDescriptor.out比較特殊,它描述的是螢幕,即常說的控制檯。

二.接下來是newPrintStream(fdOut, props.getProperty("sun.stdout.encoding"))——

1 private static PrintStream newPrintStream(FileOutputStream fos, String enc) {
2    if (enc != null) {
3         try {
4             return new PrintStream(new BufferedOutputStream(fos, 128), true, enc);
5         } catch (UnsupportedEncodingException uee) {}
6     }
7     return new PrintStream(new BufferedOutputStream(fos, 128), true);
8 }

 該方法是為輸出流建立一個BufferedOutputStream緩衝輸出流,起到流緩衝的作用,最後通過new PrintStream()建立一個列印輸出流。

通過該流的列印介面,如print(), println(),可實現列印輸出的作用。

三.最後就是執行 setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));

1 private static native void setOut0(PrintStream out);

可知,該方法是一個native方法,感興趣的童鞋可繼續深入研究,這裡大概就是將生成的PrintStream物件賦值給System裡的靜態物件引用變數:out。

1 public final static PrintStream out = null;

到這裡,就回到了我們最開始的地方:System.out.println,沒錯,這裡面的out,就是通過setOut0來進行PrintStream物件賦值的,我們既然能拿到了PrintStream的物件引用out,自然就可以訪問PrintStream類裡的任何public方法裡,包括println(),包括print(),等等。

可提取以上初始化out的原始碼重做一個手動列印的測試,如:

image

執行,發現可以控制檯上列印出"測試列印"四字。

最後,總結一下,System.out.println的原理是在類載入System時,會初始化System的initializeSystemClass()方法,該方法中將建立一個列印輸出流PrintStream物件,隨後通過setOut0(PrintStream out)方法,會將初始化建立的PrintStream 物件賦值給System靜態引用變數out。out被賦值物件地址後,就可以呼叫PrintStream中的各種public修飾的方法裡,其中就包括println()、print()這類列印資訊的方法,通過out.println(“xxxx”)即可將“xxxx”列印到控制檯上,也就是等價於System.out.println("xxxx")。

1 System.out.println("列印資料");
2 等價於--->
3 PrintStream out=System.out;
4 out.println("列印資料");

以上,就是System.out.println的執行原理。

 

 

 

若有不足,還請指出改正。