CLOVER🍀

That was when it all began.

HttpURLConnection#disconnectとKeep-Aliveと

あんまりHttpURLConnectionってふだん使っていないのですが(Apache HttpComponentsとかを使っていることの方が多い)、
HttpURLConnection#disconnectを呼んでないソースコードを見て、「あれ、これいいの?」と疑問に思い、ちょっと
調べてみることにしました。

ふつう、disconnect呼ぶんじゃない?と思っていたので。

で、少し調べるとこんなエントリを見たりして

HttpURLConnectionのdisconnect()を使うと他の通信が中断されてしまうことがあるので注意 - ytch's diary

これはなにかあるんだろうなぁと。

で、あらためてHttpURLConnectionのJavadocを読んでみます。

単一の要求を行う際には個々のHttpURLConnectionインスタンスが使用されますが、その背後のHTTPサーバーへのネットワーク接続は、ほかのインスタンスと透過的に共有される可能性があります。要求後、HttpURLConnectionのInputStreamまたはOutputStream上でclose()メソッドを呼び出すと、そのインスタンスに関連付けられていたネットワーク・リソースが解放される可能性がありますが、共有されている持続接続への影響はまったくありません。disconnect()メソッドを呼び出した場合、持続接続がその時点でアイドル状態になっていれば、使用していたソケットがクローズされる可能性があります。

https://docs.oracle.com/javase/jp/8/docs/api/java/net/HttpURLConnection.html

「透過的に共有」…?Keep-Aliveの話?

HttpURLConnectionは、デフォルトでKeep-Aliveが有効です(http.keepalive、http.maxConnections)。
※http.keepAlive?

その他のHTTPプロパティ

せっかくなので、ちょっと確認&ソースコードを見てみましょう。

簡単なHTTPサーバー

なにはともあれ、HTTPサーバーが必要なので簡単にGroovyで書いてみます。
simple-httpd.groovy

import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.util.concurrent.Executors

import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer

def responseHandler = { exchange ->
    println("[${LocalDateTime.now()}] Accept: Client[$exchange.remoteAddress], Method[${exchange.requestMethod}] Url[$exchange.requestURI]")

    try {
        exchange.responseHeaders.with {
            add("Content-Type", "text/plain; charset=UTF-8")

            if (!exchange.requestHeaders['Connection'].contains('keep-alive')) {
                add("Connection", "close")
            }
        }

        def bodyText = "Hello Simple Httpd!!\n".stripMargin().getBytes(StandardCharsets.UTF_8)
        exchange.sendResponseHeaders(200, bodyText.size())
        exchange.responseBody.withStream { it.write(bodyText) }
    } catch (e) {
        e.printStackTrace()
    }
}

def server =
    HttpServer.create(new InetSocketAddress(args.length > 0 ? args[0].toInteger() : 8080), 0)
server.executor = Executors.newCachedThreadPool()
server.createContext("/", responseHandler as HttpHandler)
server.start()

println("[${LocalDateTime.now()}] SimpleJdkHttpd Startup[${server.address}]")

どのアクセスに対しても「Hello Simple Httpd!!」と返すだけの、ExecutorServiceを使った単純なHTTPサーバーです。
クライアントから「Connection: keep-alive」を受け取らなかった場合、サーバー側は「Connection: close」を送るように
しています。

これに対してアクセスする、HTTPクライアントを書いて確認してみます。

HTTPクライアント側

まずは、Maven依存関係。テストコードで確認することにしましょう。

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.0.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.8.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.19</version>
                <dependencies>
                    <dependency>
                        <groupId>org.junit.platform</groupId>
                        <artifactId>junit-platform-surefire-provider</artifactId>
                        <version>1.0.2</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.jupiter</groupId>
                        <artifactId>junit-jupiter-engine</artifactId>
                        <version>5.0.2</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>

テストコードの雛形としては、このように。
src/test/java/org/littlewings/urlconnection/HttpUrlConnectionTest.java

package org.littlewings.urlconnection;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class HttpUrlConnectionTest {
    // ここに、テストを書く!
}

とりあえず、ふつうに書くとこんな感じですね。

    @Test
    public void simpleUsage() throws IOException {
        HttpURLConnection connection = (HttpURLConnection) URI.create("http://localhost:8080").toURL().openConnection();
        connection.setDoInput(true);

        try (InputStream is = connection.getInputStream();
             BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {

            assertThat(reader.readLine()).isEqualTo("Hello Simple Httpd!!");
        } finally {
            connection.disconnect();
        }
    }

最後に、HttpURLConnection#disconnectを呼んで終了。

では、ここでリクエストを100回送ってみます。

    @Test
    public void request100() throws IOException {
        for (int i = 0; i < 100; i++) {
            HttpURLConnection connection = (HttpURLConnection) URI.create("http://localhost:8080").toURL().openConnection();
            connection.setDoInput(true);

            try (InputStream is = connection.getInputStream();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {

                assertThat(reader.readLine()).isEqualTo("Hello Simple Httpd!!");
            } finally {
                // connection.disconnect();
            }
        }
    }

HttpURLConnection#disconnectは、意図的に外してみます。

接続状態を確認してみましょう。多くのリクエストを送っている割には、ssコマンドで現れる接続数は1です。

$ ss -an | grep 8080
tcp    LISTEN     0      50                    :::8080                 :::*     
tcp    ESTAB      0      138     ::ffff:127.0.0.1:8080   ::ffff:127.0.0.1:44692 
tcp    ESTAB      0      0       ::ffff:127.0.0.1:44692  ::ffff:127.0.0.1:8080  

終了後は、TIME-WAITに移行し、しばらくするといなくなります。

$ ss -an | grep 8080
tcp    LISTEN     0      50                    :::8080                 :::*     
tcp    TIME-WAIT  0      0       ::ffff:127.0.0.1:44692  ::ffff:127.0.0.1:8080

なるほど、Keep-Aliveが効いているようです。この結果は、HttpURLConnection#disconnectを呼び出しても変わりません。

では、Keep-Aliveを使わないようにしてみます。クライアントから「Connection: close」を送ってみるパターンと、システムプロパティ「http.keepalive」で
無効にするパターンで書いてみましょう。

    @Test
    public void request100DisableKeepAlive1() throws IOException {
        for (int i = 0; i < 100; i++) {
            HttpURLConnection connection = (HttpURLConnection) URI.create("http://localhost:8080").toURL().openConnection();

            connection.addRequestProperty("Connection", "close");

            connection.setDoInput(true);

            try (InputStream is = connection.getInputStream();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {

                assertThat(reader.readLine()).isEqualTo("Hello Simple Httpd!!");
            } finally {
                // connection.disconnect();
            }
        }
    }

    @Test
    public void request100DisableKeepAlive2() throws IOException {
        System.setProperty("http.keepAlive", "false");

        for (int i = 0; i < 100; i++) {
            HttpURLConnection connection = (HttpURLConnection) URI.create("http://localhost:8080").toURL().openConnection();

            connection.setDoInput(true);

            try (InputStream is = connection.getInputStream();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {

                assertThat(reader.readLine()).isEqualTo("Hello Simple Httpd!!");
            } finally {
                // connection.disconnect();
            }
        }
    }

ん?「http.keepAlive」??
※あとで

ともに、ssコマンドで見ると大量のTIME-WAIT…というか接続ができていることがわかります。Keep-Aliveが無効になりましたと。

$ ss -an | grep 8080 | wc -l
104

もう少し中身を

では、もう少し中身を追ってみましょう。そもそも、HttpURLConnection#disconnectってなんなのでしょう。

InputStreamを使って入ればそれを閉じ、そうでなければSocketをクローズするようです。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/protocol/http/HttpURLConnection.java#l2840
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/HttpClient.java#l1050

この時、Keep-Aliveが有効であればHttpClient#closeIdleConnectionが呼び出されます。

                // un-synchronized
                boolean ka = hc.isKeepingAlive();

                try {
                    inputStream.close();
                } catch (IOException ioe) { }

                // if the connection is persistent it may have been closed
                // or returned to the keep-alive cache. If it's been returned
                // to the keep-alive cache then we would like to close it
                // but it may have been allocated

                if (ka) {
                    hc.closeIdleConnection();
                }

HttpClient#closeIdleConnectionでは、URLをキーにしてKeepAliveCacheからHttpClientを取得し、nullでなければSocketをクローズしようとします。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/HttpClient.java#l450

ここが、

disconnect()メソッドを呼び出した場合、持続接続がその時点でアイドル状態になっていれば、使用していたソケットがクローズされる可能性があります。

https://docs.oracle.com/javase/jp/8/docs/api/java/net/HttpURLConnection.html

に該当する部分ですね。

KeepAliveCacheとは、HttpURLConnectionの実装が内部的にstaticなフィールドとして保持しています。
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/bedc29a6d074/src/share/classes/sun/net/www/http/HttpClient.java#l93

KeepAliveCacheはHashMapを継承したクラスで、
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/KeepAliveCache.java#l41

パッと見、URLをキーにしてKeepAliveCacheを利用しているように見えるので、一見キーはURLなのかなぁとも思うのですが
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/bedc29a6d074/src/share/classes/sun/net/www/http/HttpClient.java#l407

    protected synchronized void putInKeepAliveCache() {
        if (inCache) {
            assert false : "Duplicate put to keep alive cache";
            return;
        }
        inCache = true;
        kac.put(url, null, this);
    }

(接続時にHttpClientを使いまわしているところ)
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/HttpClient.java#l303

        if (useCache) {
            ret = kac.get(url, null);
            if (ret != null && httpuc != null &&
                httpuc.streaming() &&
                httpuc.getRequestMethod() == "POST") {
                if (!ret.available()) {
                    ret.inCache = false;
                    ret.closeServer();
                    ret = null;
                }
            }

実際にKeepAliveCacheのキーとして機能しているのはKeepAliveKeyというクラスで、プロトコル、ホスト、ポートを比較しているので、結局接続先(とプロトコル)単位で
管理しているということになります。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/KeepAliveCache.java#l321

    @Override
    public boolean equals(Object obj) {
        if ((obj instanceof KeepAliveKey) == false)
            return false;
        KeepAliveKey kae = (KeepAliveKey)obj;
        return host.equals(kae.host)
            && (port == kae.port)
            && protocol.equals(kae.protocol)
            && this.obj == kae.obj;
    }

よって、URLパスを変えてアクセスしたりしても、Keep-Aliveが有効な場合は接続が増えたりすることはありません。

    @Test
    public void request100SeparateUrl() throws IOException {
        for (int i = 0; i < 100; i++) {
            HttpURLConnection connection = (HttpURLConnection) URI.create("http://localhost:8080/" + i).toURL().openConnection();
            connection.setDoInput(true);

            try (InputStream is = connection.getInputStream();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {

                assertThat(reader.readLine()).isEqualTo("Hello Simple Httpd!!");
            } finally {
                // connection.disconnect();
            }
        }
    }

確認。

$ ss -an | grep 8080
tcp    LISTEN     0      50                    :::8080                 :::*     
tcp    ESTAB      0      0       ::ffff:127.0.0.1:45490  ::ffff:127.0.0.1:8080  
tcp    ESTAB      0      0       ::ffff:127.0.0.1:8080   ::ffff:127.0.0.1:45490 

$ ss -an | grep 8080
tcp    LISTEN     0      50                    :::8080                 :::*     
tcp    TIME-WAIT  0      0       ::ffff:127.0.0.1:45490  ::ffff:127.0.0.1:8080

Keep-Aliveのタイムアウト値は、デフォルトの5000ミリ秒か、サーバーから返ってきたKeep-Aliveヘッダのtimeout値が使われます。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/HttpClient.java#l811
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/KeepAliveCache.java#l123

KeepAliveCacheから取得する時に、すでにタイムアウト値を越えていた場合、その接続は破棄されます。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/KeepAliveCache.java#l262

KeepAliveCacheから取得できたということは、利用可能なHttpClient(接続)であるということになります。

また、定期的にクリーンアップも行われており
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/KeepAliveCache.java#l199

この処理は、KeepAliveCacheに登録される時に、接続ごとにスレッドが割り当てられて監視されるようです。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/KeepAliveCache.java#l112

で、ここでちょっと気になることがあって、HttpURLConnectionはdisconnect時にKeep-Aliveが有効であれば、アイドルなコネクションを
切断しようとします。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/protocol/http/HttpURLConnection.java#l2890

                if (ka) {
                    hc.closeIdleConnection();
                }

http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/HttpClient.java#l452

    public void closeIdleConnection() {
        HttpClient http = kac.get(url, null);
        if (http != null) {
            http.closeServer();
        }
    }

でも、KeepAliveCacheの実装を見ていると、これむしろアクティブな接続を切っているのでは…?と不思議に思ったのですが、InputStreamをクローズしたり
すると、ここへは到達しなくなるようです。
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/protocol/http/HttpURLConnection.java#l3474

なお、HttpURLConnection#disconnectのこのあたりの説明が書いているようです。

If we have an input stream this means we received a response from the server.

That stream may have been read to EOF and dependening on the stream type may already be closed or the http client may be returned to the keep-alive cache.

If the http client has been returned to the keep-alive cache it may be closed (idle timeout) or may be allocated to another request.

In other to avoid timing issues we close the input stream which will either close the underlying connection or return the client to the cache.

If there's a possibility that the client has been returned to the cache (ie: stream is a keep alive stream or a chunked input stream) then we remove an idle connection to the server.

Note that this approach can be considered an approximation in that we may close a different idle connection to that used by the request.

Additionally it's possible that we close two connections - the first becuase it wasn't an EOF (and couldn't be hurried) - the second, another idle connection to the same server.

The is okay because "disconnect" is an indication that the application doesn't intend to access this http server for a while.

http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/protocol/http/HttpURLConnection.java#l2848

で、結局disconnectは呼んだ方がいいのか?

最初に戻ると、HttpURLConnection#disconnectは呼んだ方が良いのかどうかということになりますが、HttpURLConnectionについて
言えば、Keep-Aliveで使っているアイドルなコネクションの破棄(放っておいてもバックグラウンドで破棄される)と
内部的な情報をnullにするくらいの差しか出ないようなので、情報の読み込み、書き出しに使うInputStream/OutputStreamを
きちんとcloseしておけば問題ないような気がします。

URLConnectionの作法に則る(?)のであれば、disconnectした方がよいとは思いますが。

ただまあ、単発のHTTP通信に使いたい程度であれば、APIドキュメントのとおりStreamをcloseしておけば最低限OKということに
なるのでしょうね。

要求後、HttpURLConnectionのInputStreamまたはOutputStream上でclose()メソッドを呼び出すと、そのインスタンスに関連付けられていたネットワーク・リソースが解放される可能性がありますが、共有されている持続接続への影響はまったくありません。

https://docs.oracle.com/javase/jp/8/docs/api/java/net/HttpURLConnection.html

オマケ

Keep-Aliveの有効/無効はシステムプロパティで制御しますが、以下のソースコードを見ると「http.keepAlive」に見えるのですが、これいかに?
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/file/31bc1a681b51/src/share/classes/sun/net/www/http/HttpClient.java#l160

ドキュメントは、「http.keepalive」のような…。