Анализ Java NIO (10): улучшение использования памяти JVM вне кучи: сведения о DirectBuffer

Java задняя часть JVM

Ранее мы подробно обсуждалиАнализ Java NIO (8): подробное объяснение селектора ядра с высокой степенью параллелизмаа такжеАнализ Java NIO (9): от сокета BSD к SocketChannel, Они являются диспетчером событий NIO и неблокирующим процессором соответственно.

поддерживатьChannelдвунаправленное чтение и запись иScatter/Gatherоперация, нам также нужноBuffer, сохраните данные ввода-вывода для резервного копирования. Обычные буферы — это буферы в куче JVM, которые легче понять.

Далее поговорим о перипетиях использования JVM памяти вне кучи и о том, почему она была разработанаDirectBuffer.

Прежде всего, мы должны начать с эпохи до JDK1.4 без NIO, В то время все использовалиSocketInputStreamа такжеSocketOutputStream.

1. Как традиционный сокет Java отправляет и получает данные?

кSocketOutputStreamНапример, диаграмма классов наследования выглядит следующим образом:

На самом деле, все должны быть более знакомы с ним.java.ioСумка самая фундаментальная абстракцияInputStreamа такжеOutputStream, остальная часть абстракции и реализации класса представляет собой кучудекоратор.
Итак, что мы собираемся отслеживать, этоwrite方法

// java.io.SocketOutputStream
public void write(byte b[], int off, int len) throws IOException {
    // 委托给本类的socketWrite方法
    socketWrite(b, off, len);
}

    /**
     * Writes to the socket with appropriate locking of the
     * FileDescriptor.
     * @param b the data to be written
     * @param off the start offset in the data
     * @param len the number of bytes that are written
     * @exception IOException If an I/O error has occurred.
     */
private void socketWrite(byte b[], int off, int len) throws IOException {
    ...省略非关键代码
    FileDescriptor fd = impl.acquireFD();
    try {
        // 上面就是做了一些参数判断,然后委托给socketWrite0
        socketWrite0(fd, b, off, len);
    } catch (SocketException se) {
    ...
    } finally {
        impl.releaseFD();
    }
}

    /**
     * Writes to the socket.
     * @param fd the FileDescriptor
     * @param b the data to be written
     * @param off the start offset in the data
     * @param len the number of bytes that are written
     * @exception IOException If an I/O error has occurred.
     */
private native void socketWrite0(FileDescriptor fd, byte[] b, int off,
                                 int len) throws IOException;

Последний вызов является нативным методомsocketWrite0,Открытьjdk/src/solaris/native/java/net/SocketOutputStream.c

/*
 * Class:     java_net_SocketOutputStream
 * Method:    socketWrite0
 * Signature: (Ljava/io/FileDescriptor;[BII)V
 */
JNIEXPORT void JNICALL
Java_java_net_SocketOutputStream_socketWrite0(JNIEnv *env, jobject this,
                                              jobject fdObj,
                                              jbyteArray data,
                                              jint off, jint len) {
    char *bufP;
    char BUF[MAX_BUFFER_LEN];
    int buflen;
    int fd;

    if (IS_NULL(fdObj)) {
        JNU_ThrowByName(env, "java/net/SocketException", "Socket closed");
        return;
    } else {
        fd = (*env)->GetIntField(env, fdObj, IO_fd_fdID);
        /* Bug 4086704 - If the Socket associated with this file descriptor
         * was closed (sysCloseFD), the the file descriptor is set to -1.
         */
        if (fd == -1) {
            JNU_ThrowByName(env, "java/net/SocketException", "Socket closed");
            return;
        }

    }

    if (len <= MAX_BUFFER_LEN) {
        bufP = BUF;
        buflen = MAX_BUFFER_LEN;
    } else {
        buflen = min(MAX_HEAP_BUFFER_LEN, len);
        // 初始化一块直接内存
        bufP = (char *)malloc((size_t)buflen);

        /* if heap exhausted resort to stack buffer */
        if (bufP == NULL) {
            bufP = BUF;
            buflen = MAX_BUFFER_LEN;
        }
    }

    while(len > 0) {
        int loff = 0;
        int chunkLen = min(buflen, len);
        int llen = chunkLen;
        // 将堆内的data复制到刚才初始化的内存bufP里
        (*env)->GetByteArrayRegion(env, data, off, chunkLen, (jbyte *)bufP);

        while(llen > 0) {
            // 发送数据
            int n = NET_Send(fd, bufP + loff, llen, 0);
            if (n > 0) {
                llen -= n;
                loff += n;
                continue;
            }
            if (n == JVM_IO_INTR) {
                JNU_ThrowByName(env, "java/io/InterruptedIOException", 0);
            } else {
                if (errno == ECONNRESET) {
                    JNU_ThrowByName(env, "sun/net/ConnectionResetException",
                        "Connection reset");
                } else {
                    NET_ThrowByNameWithLastError(env, "java/net/SocketException",
                        "Write failed");
                }
            }
            if (bufP != BUF) {
                free(bufP);
            }
            return;
        }
        len -= chunkLen;
        off += chunkLen;
    }

    if (bufP != BUF) {
        free(bufP);
    }
}

Этот раздел относительно короткий, поэтому код не пропущен.
Грубо разделить на 3 шага:

  1. Подать заявку на кусок прямой памяти bufP, длина len
  2. Скопируйте данные из кучи в только что инициализированную память bufP.
  3. Вызов функции Net_send для отправки данных

Net_sendмакрос, определенный вnet_util_md.hвнутри

// net_util_md.h
#define NET_Send        JVM_Send

// jvm.cpp
JVM_LEAF(jint, JVM_Send(jint fd, char *buf, jint nBytes, jint flags))
  JVMWrapper2("JVM_Send (0x%x)", fd);
  //%note jvm_r6
  return os::send(fd, buf, (size_t)nBytes, (uint)flags);
JVM_END

// os_linux.inline.hpp
inline int os::send(int fd, char* buf, size_t nBytes, uint flags) {
  RESTARTABLE_RETURN_INT(::send(fd, buf, nBytes, flags));
}

Этот макрос заменяетJVM_Send, это метод инкапсуляции cpp, который, наконец, вызоветos::send, это вos_linux.inline.hpp, вы можете видеть, что последний вызов является глобальной функциейsend.

sendЭто наш старый друг, он соответствует стандарту POSIX.Socket API, если вы работаете в Unix-подобной системе, либо черезman sendДавайте посмотрим на его документацию. Его сигнатура функции выглядит следующим образом:

#include <sys/socket.h>

 ssize_t
 send(int socket, const void *buffer, size_t length, int flags);

Эта функция используется для отправки данных, первый адрес которых — буфер, а длина — длина доsocket fd.

Следовательно, каждый раз, когда традиционное программирование Java Socket отправляет данные, оно будет обращаться к части прямой памяти (вне кучи), затем копировать ее из кучи за пределы кучи и, наконец, вызывать send для ее отправки.

Зачем копировать данные из кучи в кучу? потому чтоАдрес объекта в куче изменится с помощью gc, и при отправке произойдет сбой..

В сценариях с высоким уровнем параллелизма это очень интенсивно использует память. Если данные, отправляемые по каждой ссылке, составляют 1 КБ, то 1 КБ данных находится в куче, и 1 КБ данных необходимо применять за пределами кучи. Требуется 2 ТБ памяти ( экстремальные сценарии), что, несомненно, является одним из узких мест.

2. Решение NIO: DirectBuffer

Поскольку Socket Api должен использовать память за пределами кучи, одним из решений являетсяповторно использовать эту память, так чтоНет необходимости каждый раз запрашивать новую память, что снижает потери системных вызовов..

Компьютерный мир — это решение одной проблемы и создание новых., если эта память повторно используется, то как ее утилизировать, когда она не используется, является проблемой, потому что эта памятьПамять находится вне кучи, и сборщик мусора JVM не может ею управлять, оставьте его в покое и рано или поздно запустите LinuxOOM Killer.

NIO после jdk1.4 предлагает решение:DirectBuffer

2.1 DirectBuffer

Объекты в куче JVM связаны с gc, затем связывают объекты в куче JVM с кучей и запускают операцию по освобождению памяти вне кучи, когда объекты в куче освобождаются, и эта проблема может быть решена.

Распространенной реализацией этой дизайнерской идеи являетсяDirectByteBuffer, откройте его код


// Primary constructor
//
DirectByteBuffer(int cap) {                   // package-private
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    // 主要是记录jdk已经使用的直接内存的数量,当分配直接内存时,需要进行增加,当释放时,需要减少
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 分配直接内存
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    // 内存清零
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    // 创建Cleaner对象
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

private ByteBuffer putShort(long a, short x) {
    if (unaligned) {
        short y = (x);
        unsafe.putShort(a, (nativeByteOrder ? y : Bits.swap(y)));
    } else {
        Bits.putShort(a, x, bigEndian);
    }
    return this;
}

В конструкторе будет применена прямая память, а размерByteBuffer.allocateDirectуказано время.

вызовDirectByteBufferизPutXXXКогда приходит время, оно записывается в эту прямую память, не нужно идти вmalloc.

такSocketChannelиспользоватьDirectBufferПри чтении и записи производительность намного лучше, чемSocketOutputStreamвысокий, не требует столько памяти, даSocketChannelЕсли вы сомневаетесь, пожалуйста, обратитесь к предыдущему анализуАнализ Java NIO (9): от сокета BSD к SocketChannel

2.2 Когда восстанавливается прямая память?

Последняя строка приведенного выше конструктора должна использоватьCleanerобъект, этот класс является虚引用класс, унаследованный отPhantomReference.

// 创建Cleaner对象
cleaner = Cleaner.create(this,
        new Deallocator(base, size, cap));

создать ссылку на первый параметр наблюдается, второй параметр ссылка операция срабатывает восстанавливается.

В JVM фантомные ссылки используются для реализацииСредства более тонкого управления памятью, вы должны передать виртуальную ссылку при создании виртуальной ссылкиЭталонная очередь (ReferenceQueue), после вызова функции финализации объекта будет добавлена ​​виртуальная ссылка на объектэталонная очередь, Вы можете узнать, будет ли объект переработан, проверив очередь.

sun.misc.Cleanerявляется автономнымReferenceQueueкласс вcreateбудетDirectBufferСсылка добавляется к наблюдению.После повторного использования ссылки JVM уведомит Cleaner о выполнении операции повторного использования.

public class Cleaner
    extends PhantomReference<Object>
{

    // 引用队列
    private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>();

    ...
    private Cleaner(Object referent, Runnable thunk) {
        super(referent, dummyQueue);
        this.thunk = thunk;
    }
    ...
}

2.3 Как восстановить прямую память?

существуетDirectBufferСуществует внутренний класс вDeallocator

private static class Deallocator
    implements Runnable
{

    private static Unsafe unsafe = Unsafe.getUnsafe();

    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
        assert (address != 0);
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    public void run() {
        if (address == 0) {
            // Paranoia
            return;
        }
        // 调用unsafe释放内存
        unsafe.freeMemory(address);
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }
}

Вызовите его метод запуска при перезапуске, вы можете видеть, что он вызываетсяsun.misc.unsafeосвободить. Как упоминалось здесь, все небезопасные классы — это статические собственные методы, которые на самом деле представляют собой кучу различных часто используемых методов.free,mallocИнкапсуляция таких функций манипулирования памятью, потому что java не может манипулировать прямой памятью.

One more thing

структураDirectBuffer, есть линияBits.reserveMemory, Функция в основном состоит в том, чтобы записывать объем прямой памяти, которую использовал jdk, но она будет судить о том, достаточно ли использования прямой памяти, если памяти недостаточно, она запускает gc..

// These methods should be called whenever direct memory is allocated or
// freed.  They allow the user to control the amount of direct memory
// which a process may access.  All sizes are specified in bytes.
static void reserveMemory(long size, int cap) {
    synchronized (Bits.class) {
        // 判断内存够不够, 够的话直接增加内存引用的计数
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }
        // -XX:MaxDirectMemorySize limits the total capacity rather than the
        // actual memory usage, which will differ when buffers are page
        // aligned.
        if (cap <= maxMemory - totalCapacity) {
            reservedMemory += size;
            totalCapacity += cap;
            count++;
            return;
        }
    }

    // 内存不够就去建议JVM gc了。。
    System.gc();
    try {
        // 等待垃圾回收
        Thread.sleep(100);
    } catch (InterruptedException x) {
        // Restore interrupt status
        Thread.currentThread().interrupt();
    }
    synchronized (Bits.class) {
        // 增加直接内存的计数
        if (totalCapacity + cap > maxMemory)
            throw new OutOfMemoryError("Direct buffer memory");
        reservedMemory += size;
        totalCapacity += cap;
        count++;
    }

}

3. Резюме

Эта статья изSocketStreamВ начале я проанализировал дефекты памяти и узкие места производительности Socket в предковом BIO:

  1. Требуется для каждой отправкиmallocНовый кусочек прямой памяти,
  2. Чтобы скопировать данные из кучи в кучу, а затем отправить.

Затем вводятся улучшения NIO для памяти вне кучи, а именноПовторное использование памяти вне кучи, а также проблему утилизации памяти вне кучи, вызванную повторным использованием памяти вне кучи, и интерпретирует способы ее создания и повторного использования на уровне исходного кода. Улучшение NIO есть только для 1, а для 2 улучшения нет. Может быть, это одна из причин, почему java ест память.

DirectBufferнаш обычный冰山对象, Будьте осторожны при его использовании.Если объект находится в молодом поколении, его легко скомпилировать, и прямая память может быть освобождена при ее восстановлении. Однако, пережив несколько сборщиков мусора, он перемещается в старое поколение, и сборщик мусора не так прост. Связанная с ним прямая память также не освобождается.

Это тоже частая причина утечек памяти.Смотрите сколько памяти не используется в куче JVM, а на машине осталось мало памяти, частый gc и частые срабатыванияOOM Killer, я надеюсь, что прочитав эту статью, вы сможете понять, в чем проблема :)