Android 8.0 OkHttp問題の解決:HttpURLConnection Leak


Androidでは、ネットワークにアクセスする際の最も簡単な方法は、次のようなものです.
HttpURLConnection connection = null;
try {
    //xxxxx        
    URL url = new URL("xxxxx");
    connection = (HttpURLConnection) url.openConnection();

    connection.connect();
    //      
    ...............
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (connection != null) {
        connection.disconnect();
    }
}

最近8.0の携帯電話で上記のようなコードを走っていると、次のようなロゴが確率的に印刷されていることがわかりました.
A connection to xxxxxx was leaked. Did you forget to close a response body?

コードをよくチェックしてみると、接続が切れた後、すでにdisconnectされているのに、どうしてこのような不快なコードを印刷するのですか?
この問題を解決するために、国内外のウェブサイトで長い間探していましたが、本当に実行可能な解決策は見つかりませんでした.
仕方なく、強引にソースコードを引っ張って、やっと問題の原因と解決策を見つけた.そこで、このブログで重要なことをメモしておきます.
Androidのソースコードでは、URLのopenConnection関数の下位実装がOkHttpライブラリに依存していることを知っています.この部分の流れについては、後でドキュメントを書いて記録します.
OkHttpライブラリで作成されたHttpリンクがRealConnectionオブジェクトであることを知る必要があります.多重化の効果を達成するために、OkHttpはすべてのRealConnectionを管理するためにConnectionPoolオブジェクトを作成しました.これは、スレッドプールがすべてのスレッドを管理するのと同じです.
新しいRealConnectionを作成すると、ConnectionPoolのput関数が呼び出されます.
void put(RealConnection connection) {
    assert (Thread.holdsLock(this));
    if (connections.isEmpty()) {
        //    cleanupRunnable
        executor.execute(cleanupRunnable);
    }
    //   connection     
    connections.add(connection);
}

では、cleanupRunnableが何をするかを見てみましょう.
private Runnable cleanupRunnable = new Runnable() {
    @Override public void run() {
        while (true) {
            //    ,          cleanup  
            long waitNanos = cleanup(System.nanoTime());
            if (waitNanos == -1) return;
            if (waitNanos > 0) {
                long waitMillis = waitNanos / 1000000L;
                waitNanos -= (waitMillis * 1000000L);
                synchronized (ConnectionPool.this) {
                    try {
                        ConnectionPool.this.wait(waitMillis, (int) waitNanos);
                    } catch (InterruptedException ignored) {
                    }
                }
            }
        }
    }
};

cleanup関数の正体は次のとおりです.
long cleanup(long now) {
    //      connection
    int inUseConnectionCount = 0;

    //     connection
    int idleConnectionCount = 0;

    //         connection
    RealConnection longestIdleConnection = null;

    //         
    long longestIdleDurationNs = Long.MIN_VALUE;

    synchronized (this) {
    for (Iterator i = connections.iterator(); i.hasNext(); ) {
        RealConnection connection = i.next();

            // If the connection is in use, keep searching.
            //      RealConnection
            if (pruneAndGetAllocationCount(connection, now) > 0) {
                inUseConnectionCount++;
                continue;
            }

            idleConnectionCount++;

            //         RealConnection
            long idleDurationNs = now - connection.idleAtNanos;
            if (idleDurationNs > longestIdleDurationNs) {
                longestIdleDurationNs = idleDurationNs;
                longestIdleConnection = connection;
            }
        }

        //           connection      ,          connection
        if (longestIdleDurationNs >= this.keepAliveDurationNs
                || idleConnectionCount > this.maxIdleConnections) {
            // We've found a connection to evict. Remove it from the list, then close it below (outside
            // of the synchronized block).
            connections.remove(longestIdleConnection);
        } else if (idleConnectionCount > 0) {
            // A connection will be ready to evict soon.
            //       cleanup      
            return keepAliveDurationNs - longestIdleDurationNs;
        } else if (inUseConnectionCount > 0) {
            // All connections are in use. It'll be at least the keep alive duration 'til we run again.
            //          
            return keepAliveDurationNs;
         } else {
            // No connections, idle or in use.
            return -1;
         }
    }

    //            ,      
    Util.closeQuietly(longestIdleConnection.getSocket());
    return 0;
}

cleanup関数により,connectionPoolに空きがあるRealConnectionを逐次クリーンアップすることが主な目的であることが分かる.
唯一の疑問点は、上記のprune AndGetAllocationCount関数です.
/**
 * Prunes any leaked allocations and then returns the number of remaining live allocations on
 * {@code connection}. Allocations are leaked if the connection is tracking them but the
 * application code has abandoned them. Leak detection is imprecise and relies on garbage
 * collection.
 */
private int pruneAndGetAllocationCount(RealConnection connection, long now) {
    //     RealConnection      
    List> references = connection.allocations;
    for (int i = 0; i < references.size(); ) {
        Reference reference = references.get(i);

        //    null,    java     
        if (reference.get() != null) {
            i++;
            continue;
        }

        //        ,      RealConnection      
        // We've discovered a leaked allocation. This is an application bug.
        Internal.logger.warning("A connection to " + connection.getRoute().getAddress().url()
                + " was leaked. Did you forget to close a response body?");

        //    
        references.remove(i);
        connection.noNewStreams = true;

        // If this was the last allocation, the connection is eligible for immediate eviction.
        //       ,    idle,   cleanup
        if (references.isEmpty()) {
            connection.idleAtNanos = now - keepAliveDurationNs;
            return 0;
        }
    }

    return references.size();
}

上記のコードから分かるように、prune AndGetAllocationCountが参照されていないRealConnectionを発見すると、上記のleaked logが印刷されます.
個人的には,冒頭のコードの実行が完了した場合,GCはまずHttpURLConnection(直接所有ではない)などRealConnectionを持つオブジェクトを回収し,その後RealConnectionを回収すると推測する.また、HttpURLConnectionを回収した後、RealConnectionを回収する前にpruneAndGetAllocationCountを実行すると、このようなlogが印刷される可能性があります.これも注記で述べたように、pruneAndGetAllocationCountはGCに依存している.
しかし、コードから見ると、これは問題なく、Androidシステムはこれらのリソースを回収します.
冒頭のコードで、最後に呼び出されたHttpURLConnectionのdisconnect関数.StreamAllocationのcancel関数のみが呼び出され、RealConnectionのcancel関数に最終的に呼び出されます.
public void cancel() {
    // Close the raw socket so we don't end up doing synchronous I/O.
    Util.closeQuietly(rawSocket);
}

この方法はsocketのみを閉じ,参照を除去せず,我々が直面する問題を解決しないことがわかる.
ソースコードの試行と読み取りを続けた結果、次の方法でこの問題を解決できることがわかりました.
HttpURLConnection connection = null;
try {
    //xxxxx        
    URL url = new URL("xxxxx");
    connection = (HttpURLConnection) url.openConnection();

    connection.connect();
    //      
    ...............
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (connection != null) {
    try {
        //    inputStream
        //           
        connection.getInputStream().close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        connection.disconnect();
    }
}

HttpURLConnectionのinputStreamをアクティブに閉じると、StreamAllocationのnoNewStreamsとstreamFinished関数が相次いで呼び出されます.
public void noNewStreams() {
    deallocate(true, false, false);
}

public void streamFinished(HttpStream stream) {
    synchronized (connectionPool) {
        if (stream == null || stream != this.stream) {
            throw new IllegalStateException("expected " + this.stream + " but was " + stream);
        }
    }
    //  deallocate
    deallocate(false, false, true);
}

//      , 1、3      true
private void deallocate(boolean noNewStreams, boolean released, boolean streamFinished) {
    RealConnection connectionToClose = null;
    synchronized (connectionPool) {
        if (streamFinished) {
            //   ,stream  null
            this.stream = null;
        }

        if (released) {
            this.released = true;
        }

        if (connection != null) {
            if (noNewStreams) {
                //   ,noNewStreams  true
                connection.noNewStreams = true;
            }

            //stream   null,           
            if (this.stream == null && (this.released || connection.noNewStreams)) {
                //     release  
                release(connection);
                if (connection.streamCount > 0) {
                    routeSelector = null;
                }

                //idle RealConnection        
                if (connection.allocations.isEmpty()) {
                    connection.idleAtNanos = System.nanoTime();
                    if (Internal.instance.connectionBecameIdle(connectionPool, connection)) {
                        connectionToClose = connection;
                    }
                }
                connection = null;
            }
        }
    }

    if (connectionToClose != null) {
        Util.closeQuietly(connectionToClose.getSocket());
    }
}

//    release  
private void release(RealConnection connection) {
    for (int i = 0, size = connection.allocations.size(); i < size; i++) {
        Reference reference = connection.allocations.get(i);
        //   StreamAllocation     
        //         
        if (reference.get() == this) {
            connection.allocations.remove(i);
            return;
        }
    }
    throw new IllegalStateException();
}

そこで,この問題の原因と対応策をやっと知ることができた.
上記のコードはHttpURLConnectionと下位OkHttpの多くの流れを省略して、重要な部分だけを提供して、後で私は専門的にブログを書いてこのコードを補充して分析します.
この問題は正直、個人的な感覚は重要ではありませんが、本当に原理を理解するには、ソースコードを細かく読む必要があります.本当に分かったら、確かにGAI爺の歌の中の感じがします:前に虎の山に行って、雲霧をかき分けて光を見ます.