JVM的ServerSocket是怎麼實現的(上)

超人汪小建發表於2019-02-27

概況

JDK 為我們提供了 ServerSocket 類作為服務端套接字的實現,通過它可以讓主機監聽某個埠而接收其他端的請求,處理完後還可以對請求端做出響應。它的內部真正實現是通過 SocketImpl 類來實現的,它提供了工廠模式,所以如果自己想要其他的實現也可以通過工廠模式來改變的。

繼承結構

--java.lang.Object
  --java.net.ServerSocket
複製程式碼

相關類圖

前面說到 ServerSocket 類真正的實現是通過 SocketImpl 類,於是可以看到它使用了 SocketImpl 類,但由於 windows 和 unix-like 系統有差異,而 windows 不同的版本也需要做不同的處理,所以兩類系統的類不盡相同。

下圖是 windows 的類圖關係, SocketImpl 類實現了 SocketOptions 介面,接著還派生出了一系列的子類,其中 AbstractPlainSocketImpl 是原始套接字的實現的一些抽象,而 PlainSocketImpl 類是一個代理類,由它代理 TwoStacksPlainSocketImpl 和 DualStackPlainSocketImpl 兩種不同實現。存在兩種實現的原因是一個用於處理 Windows Vista 以下的版本,另一個用於處理 Windows Vista 及以上的版本。

image

比起 windows 的實現,unix-like 的實現則不會這麼繁瑣,它不存在版本的問題,所以它直接由 PlainSocketImpl 類實現,此外,可以看到兩類作業系統都還存在一個 SocksSocketImpl 類,它其實主要是實現了防火牆安全會話轉換協議,包括 SOCKS V4 和 V5 。

image

根據上面可以看到其實對於不同系統就是需要做差異處理,基本都是大同小異,下面涉及到套接字實現均以 Windows Vista 及以上的版本為例進行分析。

類定義

public class ServerSocket implements java.io.Closeable
複製程式碼

ServerSocket 類的宣告很簡單,實現了 Closeable 介面,該介面只有一個close方法。

主要屬性

private boolean created = false;
private boolean bound = false;
private boolean closed = false;
private Object closeLock = new Object();
private SocketImpl impl;
private boolean oldImpl = false;
複製程式碼
  • created 表示是否已經建立了 SocketImpl 物件,ServerSocket 需要依賴該物件實現套接字操作。
  • bound 是否已繫結地址和埠。
  • closed 是否已經關閉套接字。
  • closeLock 關閉套接字時用的鎖。
  • impl 真正的套接字實現物件。
  • oldImpl 是不是使用舊的實現。

主要方法

建構函式

有五類建構函式,可以什麼引數都不傳,也可以傳入 SocketImpl、埠、backlog和地址等。主要看一下最後一個建構函式,setImpl 方法用於設定實現物件,然後檢查埠大小是否正確,檢查 backlog 小於0就讓它等於50,最後進行埠和地址繫結操作。

ServerSocket(SocketImpl impl) {
        this.impl = impl;
        impl.setServerSocket(this);
    }
    
public ServerSocket() throws IOException {
        setImpl();
    }
    
public ServerSocket(int port) throws IOException {
        this(port, 50, null);
    }
    
public ServerSocket(int port, int backlog) throws IOException {
        this(port, backlog, null);
    }
    
public ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException {
        setImpl();
        if (port < 0 || port > 0xFFFF)
            throw new IllegalArgumentException(
                       "Port value out of range: " + port);
        if (backlog < 1)
          backlog = 50;
        try {
            bind(new InetSocketAddress(bindAddr, port), backlog);
        } catch(SecurityException e) {
            close();
            throw e;
        } catch(IOException e) {
            close();
            throw e;
        }
    }    
複製程式碼

setImpl方法

設定套接字實現物件,這裡提供了工廠模式可以方便的對接其他的實現,而預設是沒有工廠物件的,所以模式的實現為 SocksSocketImpl 物件。

private void setImpl() {
        if (factory != null) {
            impl = factory.createSocketImpl();
            checkOldImpl();
        } else {
            impl = new SocksSocketImpl();
        }
        if (impl != null)
            impl.setServerSocket(this);
    }
複製程式碼

createImpl方法

該方法用於建立套接字實現物件,如果實現物件為空則先呼叫setImpl方法設定一下,接著呼叫套接字實現物件的create方法建立套接字。

void createImpl() throws SocketException {
        if (impl == null)
            setImpl();
        try {
            impl.create(true);
            created = true;
        } catch (IOException e) {
            throw new SocketException(e.getMessage());
        }
    }
複製程式碼

create方法幹了些啥?它的實現邏輯在 AbstractPlainSocketImpl 類中,這裡會傳入一個 boolean 型別的 stream 變數,這裡其實用來標識是 udp 還是 tcp 協議,stream 即是流,tcp是基於連線的,自然存在流的抽象。而 udp 是非連線的非流的。

兩類連線是通過 boolean 型別來標識的,true 為 tcp,false 為 udp,再通過 socketCreate 方法傳入到本地實現中,在此之前兩者都會建立 FileDescriptor 物件作為套接字的引用,FileDescriptor 為檔案描述符,可以用來描述檔案、套接字和資源等。另外,udp 協議時還會通過 ResourceManager.beforeUdpCreate()來統計虛擬機器 udp 套接字數量,超過指定最大值則會丟擲異常,預設值為25。最後將套接字的 created 標識設為 true,對應 Java 中抽象的客戶端套接字 Socket 物件和服務端套接字 ServerSocket 物件。

protected synchronized void create(boolean stream) throws IOException {
        this.stream = stream;
        if (!stream) {
            ResourceManager.beforeUdpCreate();
            fd = new FileDescriptor();
            try {
                socketCreate(false);
            } catch (IOException ioe) {
                ResourceManager.afterUdpClose();
                fd = null;
                throw ioe;
            }
        } else {
            fd = new FileDescriptor();
            socketCreate(true);
        }
        if (socket != null)
            socket.setCreated();
        if (serverSocket != null)
            serverSocket.setCreated();
    }
    
複製程式碼

往下看上面呼叫的socketCreate方法的邏輯,判斷檔案描述符不能為空,再呼叫本地socket0方法,最後將得到的控制程式碼關聯到檔案描述符物件上。

void socketCreate(boolean stream) throws IOException {
        if (fd == null)
            throw new SocketException("Socket closed");

        int newfd = socket0(stream, false /*v6 Only*/);

        fdAccess.set(fd, newfd);
    }
    
static native int socket0(boolean stream, boolean v6Only) throws IOException;
複製程式碼

接著看本地方法socket0的實現,邏輯為:

  1. 通過呼叫NET_Socket函式建立套接字控制程式碼,其中通過 Winsock 庫的 socket函式建立控制程式碼,並且通過SetHandleInformation函式設定控制程式碼的繼承標誌。這裡可以看到根據 stream 標識對應的類別為SOCK_STREAMSOCK_DGRAM。如果控制程式碼是無效的則丟擲 create 異常。
  2. 然後通過setsockopt函式設定套接字的選項值,如果發生錯誤則丟擲 create 異常。
  3. 最後再次通過SetHandleInformation設定控制程式碼的繼承標誌,返回控制程式碼。
JNIEXPORT jint JNICALL Java_java_net_DualStackPlainSocketImpl_socket0
  (JNIEnv *env, jclass clazz, jboolean stream, jboolean v6Only /*unused*/) {
    int fd, rv, opt=0;

    fd = NET_Socket(AF_INET6, (stream ? SOCK_STREAM : SOCK_DGRAM), 0);
    if (fd == INVALID_SOCKET) {
        NET_ThrowNew(env, WSAGetLastError(), "create");
        return -1;
    }

    rv = setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, (char *) &opt, sizeof(opt));
    if (rv == SOCKET_ERROR) {
        NET_ThrowNew(env, WSAGetLastError(), "create");
    }

    SetHandleInformation((HANDLE)(UINT_PTR)fd, HANDLE_FLAG_INHERIT, FALSE);

    return fd;
}

int NET_Socket (int domain, int type, int protocol) {
    SOCKET sock;
    sock = socket (domain, type, protocol);
    if (sock != INVALID_SOCKET) {
        SetHandleInformation((HANDLE)(uintptr_t)sock, HANDLE_FLAG_INHERIT, FALSE);
    }
    return (int)sock;
}
複製程式碼

bind方法

該方法用於將套接字繫結到指定的地址和埠上,如果 SocketAddress 為空,即代表地址和埠都不指定,此時系統會將套接字繫結到所有有效的本地地址,且動態生成一個埠。邏輯如下:

  1. 判斷是否已關閉,關閉則拋SocketException("Socket is closed")
  2. 判斷是否已繫結,繫結則拋SocketException("Already bound")
  3. 判斷地址是否為空,為空則建立一個 InetSocketAddress,預設是所有有效的本地地址,對應的為0.0.0.0,而埠預設為0,由作業系統動態生成。
  4. 判斷物件是否為 InetSocketAddress 型別,不是則拋IllegalArgumentException("Unsupported address type")
  5. 判斷地址是否已經有值了,沒有則拋SocketException("Unresolved address")
  6. backlog 如果小於1則設為50。
  7. 通過安全管理器檢查埠。
  8. 通過套接字實現物件呼叫bindlisten方法。
  9. bound 標識設為 true。
public void bind(SocketAddress endpoint) throws IOException {
        bind(endpoint, 50);
    }
    
public void bind(SocketAddress endpoint, int backlog) throws IOException {
        if (isClosed())
            throw new SocketException("Socket is closed");
        if (!oldImpl && isBound())
            throw new SocketException("Already bound");
        if (endpoint == null)
            endpoint = new InetSocketAddress(0);
        if (!(endpoint instanceof InetSocketAddress))
            throw new IllegalArgumentException("Unsupported address type");
        InetSocketAddress epoint = (InetSocketAddress) endpoint;
        if (epoint.isUnresolved())
            throw new SocketException("Unresolved address");
        if (backlog < 1)
          backlog = 50;
        try {
            SecurityManager security = System.getSecurityManager();
            if (security != null)
                security.checkListen(epoint.getPort());
            getImpl().bind(epoint.getAddress(), epoint.getPort());
            getImpl().listen(backlog);
            bound = true;
        } catch(SecurityException e) {
            bound = false;
            throw e;
        } catch(IOException e) {
            bound = false;
            throw e;
        }
    }
複製程式碼

套接字實現物件的bind方法會間接呼叫socketBind方法,邏輯如下:

  1. 獲取本地檔案描述符 nativefd。
  2. 判斷地址是否為空。
  3. 呼叫bind0本地方法。
  4. 如果埠為0還會呼叫localPort0本地方法獲取本地埠賦值給套接字實現物件的 localport 屬性上,目的是獲取作業系統動態生成的埠。
void socketBind(InetAddress address, int port) throws IOException {
        int nativefd = checkAndReturnNativeFD();

        if (address == null)
            throw new NullPointerException("inet address argument is null.");

        bind0(nativefd, address, port, exclusiveBind);
        if (port == 0) {
            localport = localPort0(nativefd);
        } else {
            localport = port;
        }

        this.address = address;
    }
static native void bind0(int fd, InetAddress localAddress, int localport, boolean exclBind)
                             
static native int localPort0(int fd) throws IOException;
複製程式碼

bind0本地方法邏輯如下,

  1. 通過NET_InetAddressToSockaddr函式將 Java 層的 InetAddress 物件的屬性值填充到 SOCKETADDRESS 聯合體中,對應的都是 Winsock 庫的結構體,目的即是為了填充好它們。
typedef union {
    struct sockaddr     sa;
    struct sockaddr_in  sa4;
    struct sockaddr_in6 sa6;
} SOCKETADDRESS;
複製程式碼
  1. NET_WinBind函式的邏輯是先根據 exclBind 標識看是否需要獨佔埠,如果需要則通過 Winsock 庫的setsockopt函式設定SO_EXCLUSIVEADDRUSE選型,在 Java 層中決定獨不獨佔埠可以通過sun.net.useExclusiveBind引數來配置,預設情況下是獨佔的。接著,通過作業系統的bind函式完成繫結操作。
  2. 如果繫結失敗則拋異常。
JNIEXPORT void JNICALL Java_java_net_DualStackPlainSocketImpl_bind0
  (JNIEnv *env, jclass clazz, jint fd, jobject iaObj, jint port,
   jboolean exclBind)
{
    SOCKETADDRESS sa;
    int rv, sa_len = 0;

    if (NET_InetAddressToSockaddr(env, iaObj, port, &sa,
                                  &sa_len, JNI_TRUE) != 0) {
      return;
    }

    rv = NET_WinBind(fd, &sa, sa_len, exclBind);

    if (rv == SOCKET_ERROR)
        NET_ThrowNew(env, WSAGetLastError(), "NET_Bind");
}
複製程式碼

localPort0本地方法的實現主要是先通過 Winsock 庫的getsockname函式獲取套接字地址,然後通過ntohs函式將網路位元組轉成主機位元組並轉為 int 型。

JNIEXPORT jint JNICALL Java_java_net_DualStackPlainSocketImpl_localPort0
  (JNIEnv *env, jclass clazz, jint fd) {
    SOCKETADDRESS sa;
    int len = sizeof(sa);

    if (getsockname(fd, &sa.sa, &len) == SOCKET_ERROR) {
        if (WSAGetLastError() == WSAENOTSOCK) {
            JNU_ThrowByName(env, JNU_JAVANETPKG "SocketException",
                    "Socket closed");
        } else {
            NET_ThrowNew(env, WSAGetLastError(), "getsockname failed");
        }
        return -1;
    }
    return (int) ntohs((u_short)GET_PORT(&sa));
}
複製程式碼

套接字實現物件的listen方法會間接呼叫socketListen方法,邏輯比較簡單,獲取本地的檔案描述符然後呼叫listen0本地方法。可以看到本地方法很簡單,僅僅是呼叫了 Winsock 庫的listen函式來完成監聽操作。

void socketListen(int backlog) throws IOException {
        int nativefd = checkAndReturnNativeFD();

        listen0(nativefd, backlog);
    }
    
static native void listen0(int fd, int backlog) throws IOException;

JNIEXPORT void JNICALL Java_java_net_DualStackPlainSocketImpl_listen0
  (JNIEnv *env, jclass clazz, jint fd, jint backlog) {
    if (listen(fd, backlog) == SOCKET_ERROR) {
        NET_ThrowNew(env, WSAGetLastError(), "listen failed");
    }
}
複製程式碼

accept方法

該方法用於接收套接字連線,套接字開啟監聽後會阻塞等待套接字連線,一旦有連線可接收了則通過該方法進行接收操作。邏輯為,

  1. 判斷套接字是否已經關閉。
  2. 判斷套接字是否已經繫結。
  3. 建立 Socket 物件,並呼叫implAccept方法,
  4. 返回 Socket 物件。
public Socket accept() throws IOException {
        if (isClosed())
            throw new SocketException("Socket is closed");
        if (!isBound())
            throw new SocketException("Socket is not bound yet");
        Socket s = new Socket((SocketImpl) null);
        implAccept(s);
        return s;
    }

複製程式碼

implAccept方法邏輯為,

  1. 傳入的 Socket 物件裡面的套接字實現如果為空,則通過setImpl方法設定套接字實現,如果非空就執行reset操作。
  2. 呼叫套接字實現物件的accept方法完成接收操作,做這一步是因為我們的 Socket 物件裡面的 SocketImpl 物件還差作業系統底層的套接字對應的檔案描述符。
  3. 呼叫安全管理器檢查許可權。
  4. 得到完整的 SocketImpl 物件,賦值給 Socket 物件,並且呼叫postAccept方法將 Socket 物件設定為已建立、已連線、已繫結。
protected final void implAccept(Socket s) throws IOException {
        SocketImpl si = null;
        try {
            if (s.impl == null)
              s.setImpl();
            else {
                s.impl.reset();
            }
            si = s.impl;
            s.impl = null;
            si.address = new InetAddress();
            si.fd = new FileDescriptor();
            getImpl().accept(si);

            SecurityManager security = System.getSecurityManager();
            if (security != null) {
                security.checkAccept(si.getInetAddress().getHostAddress(),
                                     si.getPort());
            }
        } catch (IOException e) {
            if (si != null)
                si.reset();
            s.impl = si;
            throw e;
        } catch (SecurityException e) {
            if (si != null)
                si.reset();
            s.impl = si;
            throw e;
        }
        s.impl = si;
        s.postAccept();
    }
複製程式碼

套接字實現物件的accept方法主要呼叫如下的socketAccept方法,邏輯為,

  1. 獲取作業系統的檔案描述符。
  2. SocketImpl 物件為空則丟擲NullPointerException("socket is null")
  3. 如果 timeout 小於等於0則直接呼叫本地accept0方法,一直阻塞。
  4. 反之,如果 timeout 大於0,即設定了超時,那麼會先呼叫configureBlocking本地方法,該方法用於將指定套接字設定為非阻塞模式。接著呼叫waitForNewConnection本地方法,如果在超時時間內能獲取到新的套接字,則呼叫accept0方法獲取新套接字的控制程式碼,獲取成功後再次呼叫configureBlocking本地方法將新套接字設定為阻塞模式。最後,如果非阻塞模式失敗了,則將原來的套接字設定會紫塞模式,這裡使用了 finally,所以能保證就算髮生異常也能被執行。
  5. 最後將獲取到的新檔案描述符賦給 SocketImpl 物件,同時也將遠端埠、遠端地址、本地埠等都賦給它相關變數。
void socketAccept(SocketImpl s) throws IOException {
        int nativefd = checkAndReturnNativeFD();
        if (s == null)
            throw new NullPointerException("socket is null");
        int newfd = -1;
        InetSocketAddress[] isaa = new InetSocketAddress[1];
        if (timeout <= 0) {
            newfd = accept0(nativefd, isaa);
        } else {
            configureBlocking(nativefd, false);
            try {
                waitForNewConnection(nativefd, timeout);
                newfd = accept0(nativefd, isaa);
                if (newfd != -1) {
                    configureBlocking(newfd, true);
                }
            } finally {
                configureBlocking(nativefd, true);
            }
        }
        fdAccess.set(s.fd, newfd);
        InetSocketAddress isa = isaa[0];
        s.port = isa.getPort();
        s.address = isa.getAddress();
        s.localport = localport;
    }
複製程式碼

configureBlocking本地方法邏輯很簡單,如下,核心就是通過呼叫 Winsock 庫的ioctlsocket函式來設定套接字為阻塞還是非阻塞,根據 blocking 標識。

JNIEXPORT void JNICALL Java_java_net_DualStackPlainSocketImpl_configureBlocking
  (JNIEnv *env, jclass clazz, jint fd, jboolean blocking) {
    u_long arg;
    int result;

    if (blocking == JNI_TRUE) {
        arg = SET_BLOCKING;    // 0
    } else {
        arg = SET_NONBLOCKING;   // 1
    }

    result = ioctlsocket(fd, FIONBIO, &arg);
    if (result == SOCKET_ERROR) {
        NET_ThrowNew(env, WSAGetLastError(), "configureBlocking");
    }
}
複製程式碼

waitForNewConnection本地方法邏輯如下,核心是通過 Winsock 庫的select函式來實現超時的功能,它會等待 timeout 時間看指定的檔案描述符是否有活動,超時了的話則會返回0,此時向 Java 層丟擲 SocketTimeoutException 異常。而如果返回了-1則表示套接字已經關閉了,丟擲 SocketException 異常。如果返回-2則丟擲 InterruptedIOException。

JNIEXPORT void JNICALL Java_java_net_DualStackPlainSocketImpl_waitForNewConnection
  (JNIEnv *env, jclass clazz, jint fd, jint timeout) {
    int rv;

    rv = NET_Timeout(fd, timeout);
    if (rv == 0) {
        JNU_ThrowByName(env, JNU_JAVANETPKG "SocketTimeoutException",
                        "Accept timed out");
    } else if (rv == -1) {
        JNU_ThrowByName(env, JNU_JAVANETPKG "SocketException", "socket closed");
    } else if (rv == -2) {
        JNU_ThrowByName(env, JNU_JAVAIOPKG "InterruptedIOException",
                        "operation interrupted");
    }
}

JNIEXPORT int JNICALL
NET_Timeout(int fd, long timeout) {
    int ret;
    fd_set tbl;
    struct timeval t;
    t.tv_sec = timeout / 1000;
    t.tv_usec = (timeout % 1000) * 1000;
    FD_ZERO(&tbl);
    FD_SET(fd, &tbl);
    ret = select (fd + 1, &tbl, 0, 0, &t);
    return ret;
}
複製程式碼

accept0本地方法實現邏輯為,

  1. 通過C語言的memset函式將 SOCKETADDRESS 聯合體對應的結構體內的值設定為0。
  2. 通過 Winsock 庫的accept函式獲取套接字地址。
  3. 判斷接收的套接字描述符是否無效,分別可能拋 InterruptedIOException 或 SocketException 異常。
  4. 通過SetHandleInformation函式設定控制程式碼的繼承標誌。
  5. NET_SockaddrToInetAddress函式用於將得到的套接字轉換成 Java 層的 InetAddress 物件。
  6. 將生成的 InetAddress 物件用於生成 Java 層的 InetSocketAddress 物件。
  7. 賦值給 Java 層的 InetSocketAddress 陣列物件。
  8. 返回新接收的套接字的檔案描述符。
JNIEXPORT jint JNICALL Java_java_net_DualStackPlainSocketImpl_accept0
  (JNIEnv *env, jclass clazz, jint fd, jobjectArray isaa) {
    int newfd, port=0;
    jobject isa;
    jobject ia;
    SOCKETADDRESS sa;
    int len = sizeof(sa);

    memset((char *)&sa, 0, len);
    newfd = accept(fd, &sa.sa, &len);

    if (newfd == INVALID_SOCKET) {
        if (WSAGetLastError() == -2) {
            JNU_ThrowByName(env, JNU_JAVAIOPKG "InterruptedIOException",
                            "operation interrupted");
        } else {
            JNU_ThrowByName(env, JNU_JAVANETPKG "SocketException",
                            "socket closed");
        }
        return -1;
    }

    SetHandleInformation((HANDLE)(UINT_PTR)newfd, HANDLE_FLAG_INHERIT, 0);

    ia = NET_SockaddrToInetAddress(env, &sa, &port);
    isa = (*env)->NewObject(env, isa_class, isa_ctorID, ia, port);
    (*env)->SetObjectArrayElement(env, isaa, 0, isa);

    return newfd;
}
複製程式碼

-------------推薦閱讀------------

我的2017文章彙總——機器學習篇

我的2017文章彙總——Java及中介軟體

我的2017文章彙總——深度學習篇

我的2017文章彙總——JDK原始碼篇

我的2017文章彙總——自然語言處理篇

我的2017文章彙總——Java併發篇

------------------廣告時間----------------

知識星球:遠洋號

公眾號的選單已分為“分散式”、“機器學習”、“深度學習”、“NLP”、“Java深度”、“Java併發核心”、“JDK原始碼”、“Tomcat核心”等,可能有一款適合你的胃口。

為什麼寫《Tomcat核心設計剖析》

歡迎關注:

這裡寫圖片描述

相關文章