從java的NIO版hello world看java原始碼,我們能看到什麼?

爬蜥發表於2019-02-28

Java NIO服務端程式碼的hello world如下

public class NBTimeServer {

    public static void main(String[] args) {

        try {
            Selector acceptSelector = SelectorProvider.provider().openSelector();
            //建立一個新的server socket,設定為非阻塞模式
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.configureBlocking(false);

            // 繫結server sokcet到本機和對應的埠

            InetAddress lh = InetAddress.getLocalHost();
            InetSocketAddress isa = new InetSocketAddress(lh, 8900);
            ssc.socket().bind(isa);

            //通過selector註冊server socket,這裡即告訴selector,當accept發生的時候,socket會被放在reday佇列
            SelectionKey acceptKey = ssc.register(acceptSelector,
                    SelectionKey.OP_ACCEPT);

            int keysAdded = 0;

            // 當任何一個註冊事件發生的時候,select就會返回
            while ((keysAdded = acceptSelector.select()) > 0) {
                // 獲取已經準備好的selectorkey
                Set readyKeys = acceptSelector.selectedKeys();
                Iterator i = readyKeys.iterator();


                while (i.hasNext()) {
                    SelectionKey sk = (SelectionKey)i.next();
                    i.remove();
                    ServerSocketChannel nextReady =
                            (ServerSocketChannel)sk.channel();
                    Socket s = nextReady.accept().socket();
                    PrintWriter out = new PrintWriter(s.getOutputStream(), true);
                    Date now = new Date();
                    out.println(now);
                    out.close();
                }
            }
        } catch(Exception e) {
            e.printStackTrace();
        }
    }
}
複製程式碼

1: 獲取selector。

SelectorProvider提供的所有provider都是同一個物件。如果沒有,它會通過AccessController.doPrivileged來給獲取provider的程式碼最高的許可權,執行邏輯是:

  • java.nio.channels.spi.SelectorProvider 是否有配置,有就通過反射建立(本例沒有)
  • 是不是在jar中已經例項化了 java.nio.channels.spi.SelectorProvider,並且他能夠通過getSystemClassLoader載入,就是用第一個獲取到的SelectorProvider(本例沒有)
  • 最終通過sun.nio.ch.DefaultSelectorProvider類來建立,它在不同的作業系統下有著不同的實現

從java的NIO版hello world看java原始碼,我們能看到什麼?
以solaris的實現為例,建立的provider會根據作業系統的版本和作業系統的名字分別建立不同的例項

if ("SunOS".equals(osname)) {
        return new sun.nio.ch.DevPollSelectorProvider();
}
if("Linux".equals(osname)){
     if (major > 2 || (major == 2 && minor >= 6)) {
        return new sun.nio.ch.EPollSelectorProvider();
    }
}
 return new sun.nio.ch.PollSelectorProvider(); //預設返回
複製程式碼

程式碼存在縮減,只取核心

類之間的關係如下

從java的NIO版hello world看java原始碼,我們能看到什麼?

下面只關注Epoll和Poll

拿到provider之後,開始執行openSelector,獲取真正的selector。
對於poll,返回的例項是PollSelectorImpl,對於Epoll返回的例項則是EpollSelectorImpl。

從java的NIO版hello world看java原始碼,我們能看到什麼?

file descriptor :unix設計哲學就是一切都是檔案,它可能是一個網路連線、一個終端等等。它本身就是一個數值,在系統中會維護檔案描述符和它對應檔案的一個指標,從而找到對應的檔案操作

  • fd0的獲取主要是呼叫Native方法實現
long pipeFds = IOUtil.makePipe(false);
fd0 = (int) (pipeFds >>> 32); // >>> 表示無符號右移,最高位補0,這裡即獲取讀檔案描述符
fd1 = (int) pipeFds; //截掉了高位,儲存的是寫檔案描述符
複製程式碼

IOUtil針對不同的作業系統有不同的實現,以solaris為例,它的實現在IOUtil.c中,主要實現即通過Linux pipe方法和Linux fcntl方法 (程式碼有刪減)

 int fd[2];
  if (pipe(fd) < 0) // 獲取讀和寫的檔案符
  if ((configureBlocking(fd[0], JNI_FALSE) < 0) //標註為非阻塞
       || (configureBlocking(fd[1], JNI_FALSE) < 0))
  return ((jlong) fd[0] << 32) | (jlong) fd[1]; //讀的檔案描述符放在高位,寫的檔案描述符放在低位
複製程式碼

configureBlocking本身的實現在IOUtil.c中

static int configureBlocking(int fd, jboolean blocking) //設定為非阻塞狀態
{
   int flags = fcntl(fd, F_GETFL); 
   int newflags = blocking ? (flags & ~O_NONBLOCK) : (flags | O_NONBLOCK);
   return (flags == newflags) ? 0 : fcntl(fd, F_SETFL, newflags);
}
複製程式碼

pipe實際是建立了一個程式間通訊的單向資料管道,引數中的fd[0]表示管道讀取端的結尾,fd[1]表示管道寫端的結尾;fcntl則主要是根據第二個引數,如原始碼中的F_GETFL和F_SETFL,對第一個引數執行對應的操作;

  • 新建EPollArrayWrapper,部分欄位如下
    從java的NIO版hello world看java原始碼,我們能看到什麼?
pollWrapper = new EPollArrayWrapper();
pollWrapper.initInterrupt(fd0, fd1);
複製程式碼
  1. epfd:通過Native方法去構建,對應的實現在EPollArrayWrapper.c中,方法為:Java_sun_nio_ch_EPollArrayWrapper_epollCreate,主要的實現邏輯是int epfd = (*epoll_create_func)(256);而epoll_create_func在Java_sun_nio_ch_EPollArrayWrapper_init執行的時候已經是執行了初始化,對應的是Linux epoll_create ,返回既是一個epoll例項,它實質也是一個檔案描述符
   epoll_create_func = (epoll_create_t) dlsym(RTLD_DEFAULT, "epoll_create");
   epoll_ctl_func    = (epoll_ctl_t)    dlsym(RTLD_DEFAULT, "epoll_ctl");
   epoll_wait_func   = (epoll_wait_t)   dlsym(RTLD_DEFAULT, "epoll_wait");
複製程式碼
  1. pollArray:一個用來儲存從epoll_wait中得到結果的陣列,它的大小為NUM_EPOLLEVENTS * SIZE_EPOLLEVENT,其中的NUM_EPOLLEVENTS則是取的檔案描述符限制和8192相比的最小值Math.min(fdLimit(), 8192);詳見Linux getrlimit實質是AllocatedNativeObject
  1. initInterrupt:出了儲存對應的檔案描述符之外,還執行了epollCtl(epfd, EPOLL_CTL_ADD, fd0, EPOLLIN);,即把fd0註冊到epfd上,將epfd上的EPOLLIN事件關聯到fd0上,詳見Linux epoll_ctl
  • 新建PollArrayWrapper,部分欄位如下

從java的NIO版hello world看java原始碼,我們能看到什麼?

pollWrapper = new PollArrayWrapper(INIT_CAP); //初始為10
pollWrapper.initInterrupt(fd0, fd1);
複製程式碼

pollArray:它的大小為(10+1)*SIZE_POLLFD(SIZE_POLLFD取值為8),實質是AllocatedNativeObject

  • AllocatedNativeObject
    從java的NIO版hello world看java原始碼,我們能看到什麼?

NativeObject是用來操作本地記憶體的一個代理,所有的操作通過Unsafe來實現,它本身是一個單例

2: 開啟服務端socket的channel

它還是會去獲取系統級別的provider,由於已經在拿selector的時候初始化,不再新建。同樣會通過PollSelectorProvider或者是EPollSelectorProvider來開啟服務端的socket的channel,而二者的實現均是通過父類SelectorProviderImpl,建立一個ServerSocketChannelImpl例項

從java的NIO版hello world看java原始碼,我們能看到什麼?

channel:代表與硬體、檔案、網路socket或者是程式元件等能夠進行一些I/O操作(讀和寫)的實體的連線

Closeable:是關閉與流相關的系統資源

AutoCloseable:從1.7開始的支援的語法糖try-with-resources結構,實現自動關閉資源

SelectableChannel:支援通過selector複用的Channel,提供對channel的註冊,返回對應的SelectionKey,可以工作在阻塞(預設)和非阻塞模式下

NetworkChannel:對應網路socket的channel,提供將socket繫結到本機地址的bind方法

fd是使用IOUtil.newFD建立,建立過程如下:

  1. 呼叫 Native方法 Net.socket0

Net.scoket0 方法對應的實現為Net.c中的Java_sun_nio_ch_Net_socket0,從標頭檔案的引入 #include <sys/socket.h> 可以看到,socket0的內部很多實現都依賴於作業系統本身,作業系統不一樣,就會有不同的呼叫結果。關鍵實現如下

fd = socket(domain, type, 0);
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (char*)&arg,sizeof(arg))
複製程式碼
  • socket(family, type, protocol):其中family指的是要在解釋名稱時使用的地址格式(AF_INET6/AF_INET等),type指定的是通訊的語義(SOCK_STREAM/SOCK_DGRAM等),protocol執行通訊用的協議,0意味著使用預設的。它返回的就是socket file descriptor。詳見Linux socketAPI [solaris 下存在兩套實現,BSD風格socket庫-3SOCKET和 GNU/Linux軟體使用這個庫 XNET ]
  • setsockop:給檔案描述符fd設定socket的選項,返回值小於0表示出了異常,詳見Linux setsocketopt
  1. 新建java物件FileDescriptor ,將1中返回值和新建物件一起交給IOUtil的Native方法setfdVal執行

在IOUtil.c中存在方法 Java_sun_nio_ch_IOUtil_setfdVal,它就是呼叫JNI的方法將獲取的值存入到java物件FileDescriptor中取

FileDescriptor的例項是用來表示1個開啟的檔案,或者是一個開啟的socket或者類似的位元組源

fdVal的賦值則是使用建立好的fd呼叫JNI中的(*env)->GetIntField(env, fdo, fd_fdID);實現

3:獲取socket

本質是通過ServerSocketAdaptor建立一個例項返回

從java的NIO版hello world看java原始碼,我們能看到什麼?
ServerSocket本質是一個對SocketImpl的包裝類,相關的請求處理都是由impl來處理

從java的NIO版hello world看java原始碼,我們能看到什麼?
SocksSocketImpl是按照SOCKS協議的TCP socket實現,而PlainSocketImpl則是一個‘平凡’的socket實現,它不對防火牆或者代理做任何的突破。
SocketImpl是所有實現socket的父抽象類,用來建立客戶端和服務端的socket

Socket類是兩臺機器之間通訊的端點,端點(endpoint)指的是 服務IP和它的埠,它的實際操作還是由SocketImpl來實現。

SOCKS4(SOCKets縮寫)是一個網路協議,它主要負責在防火牆上中繼TCP會話,以便應用使用者能夠透過防火牆進行訪問。它主要定義了兩個操作:CONNECT和BIND。

  • 需要CONNECT時,客戶端傳送一個CONNECT請求給SOCKS伺服器,請求包含要連線的目的埠和目的主機等資訊,SOCKS伺服器會做一些服務許可權的校驗,驗證成功SOCKS伺服器建立與目標主機指定埠的連線(即應用伺服器),然後傳送反饋包給客戶端,反饋包通過CD的值來標識CONNECT請求的結果,CONNECT成功,SOCKS就可以在兩個方向上轉發流量了
  • BIND必須發生在CONNECT之後,它實際包括一系列的步驟:1 獲取socket;2 拿到scoket對應的埠和ip地址;3 開始監聽,準備接收來自應用伺服器的呼叫 4:使用主連線通知應用伺服器它需要連線的IP地址和埠 5:接收一個來自應用伺服器的連線

SOCKS5相對於SOCKS4做了功能擴充套件,支援UDP、IPV6、鑑定的支援

4:繫結伺服器和它的埠

ServerSocketChannelImpl的bind方法。

  1. 看看當前channel是不是已經繫結或者關閉,如果完成,丟擲相關異常
  2. 看看是否有分配伺服器,沒有就隨便建一個
public InetSocketAddress(int port) {    
    this(InetAddress.anyLocalAddress(), port);
}
複製程式碼
  1. 獲取系統的SecurityManager,獲取成功,就去檢查執行緒是否有許可權來操作埠等待連線到來,不行則丟擲SecurityException
  2. NetHooks.beforeTcpBind ,如果使用了com.sun.sdp.conf配置,那麼將會把Tcp Socket包裝成Sdp Socket(Hello world沒有啟用)
  3. 執行繫結,實際執行Native方法Net.bind0,對應Net.c中的Java_sun_nio_ch_Net_bind0方法,關鍵程式碼如下
//將傳入的java物件的InetAddress和埠轉換為結構體:sockaddr_in或者sockaddr_in6
NET_InetAddressToSockaddr(env, iao, port, (struct sockaddr *)&sa, &sa_len, preferIPv6);
rv = NET_Bind(fdval(env, fdo), (struct sockaddr *)&sa, sa_len);
複製程式碼

bind對於windows系統和linux系統有不同的實現,以Linux為例,它實際執行的就是Linux bind,所做的操作就是把指定的地址(SocketAddress)分配給socket檔案描述符,對於Hello world的實現來說就是它的欄位fd

  1. 監聽,實際為Linux listen,表明這個socket將會用來接收即將到來的連線請求

5:通過selector註冊channel

從java的NIO版hello world看java原始碼,我們能看到什麼?
註冊事件在實質上就是維護新建channel的檔案描述符和SelectionKey的關係,就實現上而言, Poll用的是陣列,Epoll用的是HashMap

合法的操作為SelectionKey.OP_READ、SelectionKey.OP_WRITE、SelectionKey.OP_CONNECT

6:從selector獲取任何已經註冊好併發生的事件

從java的NIO版hello world看java原始碼,我們能看到什麼?
根據是Poll還是Epoll有不同的實現。select的實質就是去獲取poll和epoll的結果,然後更新自身維護的selector結構對應的狀態

7:接收已經準備好的channel傳過來的資料

在非阻塞模式下,accept會立馬返回

從java的NIO版hello world看java原始碼,我們能看到什麼?
Linux accept 實際上就是從監聽狀態的socketfd的連線等待佇列中獲取第一個連線請求,然後新建一個socket返回。

這裡新建的SocketChannelImpl,而之前使用的是ServerSocketChannelImpl。區別在於 SocketChannelImpl支援讀寫資料,而ServerSocketChannelImpl則更多的用於等待連線的到來,充當服務端

接下來,獲取的socket方式同第3步中新建socket

8:從socket中獲取outputStream

outpusStream通過Channels.newOutputStream新建,它會持有accept處新建的SocketChannelImpl,它實際上就是新建OutputStream並重寫它的write方法

9:回寫資料

printWriter的print經過BufferWriter到OutputStreamWriter,再到它的StreamEncoder到它的方法writeBytes執行out.write(bb.array(), bb.arrayOffset() + pos, rem);即socket中重寫的write方法,它的主要實現是呼叫Channels.writeFully,然後呼叫Channel自己的SocketChannelImpl.write方法,它核心在於n = IOUtil.write(fd, buf, -1, nd, writeLock);

 static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd, Object lock)
        throws IOException
    {
        //判斷是否是直接記憶體
        if (src instanceof DirectBuffer)
            return writeFromNativeBuffer(fd, src, position, nd, lock);

        // Substitute a native buffer
        int pos = src.position();
        int lim = src.limit();
        assert (pos <= lim);
        int rem = (pos <= lim ? lim - pos : 0);
        //申請一個DirectBuffer,即通過ByteBuffer.allocateDirect來申請直接記憶體;
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {
            bb.put(src);
            bb.flip();
            // Do not update src until we see how many bytes were written
            src.position(pos);
            //寫資料,實際上執行的是FileDispatcherImpl的Native方法writ0
            int n = writeFromNativeBuffer(fd, bb, position, nd, lock);
            if (n > 0) {
                // now update src
                src.position(pos + n);
            }
            return n;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
    }

複製程式碼

可以看到這裡有一段從JVM的Buffer拷貝到NativeBuffer中,也就是說NIO的資料寫肯定是從直接記憶體傳送出去的,如果本身不是直接記憶體則會經過一次記憶體拷貝。

JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_write0(JNIEnv *env, jclass clazz,
                              jobject fdo, jlong address, jint len)
{
    jint fd = fdval(env, fdo);
    void *buf = (void *)jlong_to_ptr(address);

    return convertReturnVal(env, write(fd, buf, len), JNI_FALSE);
}
複製程式碼

最終的寫可以看到用的就是Linux write

Java NIO的本質是什麼?

為什麼一個Selector管理了多個Channel?

SelectionKey會持有各自作業系統下的SelectorImpl物件,對於PollSelectorImpl的channel註冊內部實際是通過陣列儲存了檔案描述符和Selector的關係,EpollSelectorImpl的channel註冊則是內部用的HashMap儲存檔案描述符和Selector的關係。當讀取到事件的時候,就通過輪詢的方式拿到所有準備好的事件返回,一個個的處理

從java的NIO版hello world看java原始碼,我們能看到什麼?

NIO是如何實現的?

它依賴於作業系統本身,對於windows/mac/linux均有不同的版本實現。這裡以Liunx為例,它實際上就是個使用Linux的一系列方法,比如 read/write/accept等,操作檔案描述符

socket是什麼?

socket本身只是獲取通訊的服務和埠的一個實現類,對於服務的連線,是通過自身的屬性來處理。而這個屬性impl實際也就是對SOCKS協議的實現。來提供連線和繫結服務。

Java 阻塞IO服務端程式碼的hello world怎麼寫?

public class TimeServer {

    private static Charset charset = Charset.forName("US-ASCII");
    private static CharsetEncoder encoder = charset.newEncoder();

    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        InetSocketAddress isa = new InetSocketAddress(InetAddress.getLocalHost(), 8013);
        ssc.socket().bind(isa);
        for (;;)
        {
            SocketChannel sc = ssc.accept();
            try {
                String now = new Date().toString();
                sc.write(encoder.encode(CharBuffer.wrap(now + "\r\n")));
                System.out.println(sc.socket().getInetAddress() + " : " + now);
                sc.close();
            } finally {
                // Make sure we close the channel (and hence the socket)
                sc.close();
            }
        }
    }

}
複製程式碼

它與NIO的區別主要區別在於在於,NIO通過configureBlocking設定為false,會把它自身的fd設定為非阻塞,而阻塞IO則沒有,預設阻塞。

Java客戶端的hello world怎麼寫?

public class TimeQuery {

    // Charset and decoder for US-ASCII
    private static Charset charset = Charset.forName("US-ASCII");
    private static CharsetDecoder decoder = charset.newDecoder();

    // Direct byte buffer for reading
    private static ByteBuffer dbuf = ByteBuffer.allocateDirect(1024);

    public static void main(String[] args) {
            try {
                InetSocketAddress isa = new InetSocketAddress(InetAddress.getLocalHost(), 8900);
                SocketChannel sc = null;
                try {

                    // Connect
                    sc = SocketChannel.open();
                    sc.connect(isa);

                    // Read the time from the remote host.  For simplicity we assume
                    // that the time comes back to us in a single packet, so that we
                    // only need to read once.
                    dbuf.clear();
                    sc.read(dbuf);

                    // Print the remote address and the received time
                    dbuf.flip();
                    CharBuffer cb = decoder.decode(dbuf);
                    System.out.print(isa + " : " + cb);

                } finally {
                    // Make sure we close the channel (and hence the socket)
                    if (sc != null)
                        sc.close();
                }
            } catch (IOException x) {
                System.err.println( x);
            }
    }

}
複製程式碼

真實的執行實際上也就是Linux connectLinux read

附錄

jdk 7 原始碼地址
NIO服務端 原始碼地址
IO服務端 原始碼地址
客戶端 原始碼地址
如何讀open jdk native 原始碼
java JNI簡介

相關文章